From 2dce876a860cbe163119297290eaa9a560441644 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 28 Aug 2024 18:51:50 +0200 Subject: [PATCH 0001/1309] Bump version to 2024.10.0dev0 (#124808) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f6ffa439d9b..b62fff06c0c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 10 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 8 - HA_SHORT_VERSION: "2024.9" + HA_SHORT_VERSION: "2024.10" DEFAULT_PYTHON: "3.12" ALL_PYTHON_VERSIONS: "['3.12']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 8384a6d44bd..1ee73408f98 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 -MINOR_VERSION: Final = 9 +MINOR_VERSION: Final = 10 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index b4d3bf46916..596c0297131 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.9.0.dev0" +version = "2024.10.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -801,7 +801,7 @@ ignore = [ "SIM103", # Return the condition {condition} directly "SIM108", # Use ternary operator {contents} instead of if-else-block "SIM115", # Use context handler for opening files - + # Moving imports into type-checking blocks can mess with pytest.patch() "TCH001", # Move application import {} into a type-checking block "TCH002", # Move third-party import {} into a type-checking block From c04912914726eec2c7c73508d73bdfbbe6f223aa Mon Sep 17 00:00:00 2001 From: Blake Bryant Date: Wed, 28 Aug 2024 10:16:05 -0700 Subject: [PATCH 0002/1309] Add Deako integration (#121132) * Deako integration using pydeako * fix: address feedback - make unit tests more e2e - use runtime_data to store connection * fix: address feedback part 2 - added better type safety for Deako config entries - refactored the config flow tests to use a conftest mock instead of directly patching - removed pytest.mark.asyncio test decorators * fix: address feedback pt 3 - simplify config entry type - add test for single_instance_allowed - remove light.py get_state(), only used once, no need to be separate function * fix: ruff format * Update homeassistant/components/deako/__init__.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/deako/__init__.py | 59 ++++++ homeassistant/components/deako/config_flow.py | 26 +++ homeassistant/components/deako/const.py | 5 + homeassistant/components/deako/light.py | 96 +++++++++ homeassistant/components/deako/manifest.json | 13 ++ homeassistant/components/deako/strings.json | 13 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 7 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/deako/__init__.py | 1 + tests/components/deako/conftest.py | 45 ++++ .../deako/snapshots/test_light.ambr | 168 +++++++++++++++ tests/components/deako/test_config_flow.py | 80 ++++++++ tests/components/deako/test_init.py | 87 ++++++++ tests/components/deako/test_light.py | 192 ++++++++++++++++++ 20 files changed, 817 insertions(+) create mode 100644 homeassistant/components/deako/__init__.py create mode 100644 homeassistant/components/deako/config_flow.py create mode 100644 homeassistant/components/deako/const.py create mode 100644 homeassistant/components/deako/light.py create mode 100644 homeassistant/components/deako/manifest.json create mode 100644 homeassistant/components/deako/strings.json create mode 100644 tests/components/deako/__init__.py create mode 100644 tests/components/deako/conftest.py create mode 100644 tests/components/deako/snapshots/test_light.ambr create mode 100644 tests/components/deako/test_config_flow.py create mode 100644 tests/components/deako/test_init.py create mode 100644 tests/components/deako/test_light.py diff --git a/.strict-typing b/.strict-typing index 2566a5349c2..c8aa9878413 100644 --- a/.strict-typing +++ b/.strict-typing @@ -139,6 +139,7 @@ homeassistant.components.cpuspeed.* homeassistant.components.crownstone.* homeassistant.components.date.* homeassistant.components.datetime.* +homeassistant.components.deako.* homeassistant.components.deconz.* homeassistant.components.default_config.* homeassistant.components.demo.* diff --git a/CODEOWNERS b/CODEOWNERS index 6f118ca1ba8..990ed679d2b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -294,6 +294,8 @@ build.json @home-assistant/supervisor /tests/components/date/ @home-assistant/core /homeassistant/components/datetime/ @home-assistant/core /tests/components/datetime/ @home-assistant/core +/homeassistant/components/deako/ @sebirdman @balake @deakolights +/tests/components/deako/ @sebirdman @balake @deakolights /homeassistant/components/debugpy/ @frenck /tests/components/debugpy/ @frenck /homeassistant/components/deconz/ @Kane610 diff --git a/homeassistant/components/deako/__init__.py b/homeassistant/components/deako/__init__.py new file mode 100644 index 00000000000..fdcf09fad60 --- /dev/null +++ b/homeassistant/components/deako/__init__.py @@ -0,0 +1,59 @@ +"""The deako integration.""" + +from __future__ import annotations + +import logging + +from pydeako.deako import Deako, DeviceListTimeout, FindDevicesTimeout +from pydeako.discover import DeakoDiscoverer + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.LIGHT] + +type DeakoConfigEntry = ConfigEntry[Deako] + + +async def async_setup_entry(hass: HomeAssistant, entry: DeakoConfigEntry) -> bool: + """Set up deako.""" + _zc = await zeroconf.async_get_instance(hass) + discoverer = DeakoDiscoverer(_zc) + + connection = Deako(discoverer.get_address) + + await connection.connect() + try: + await connection.find_devices() + except DeviceListTimeout as exc: # device list never received + _LOGGER.warning("Device not responding to device list") + await connection.disconnect() + raise ConfigEntryNotReady(exc) from exc + except FindDevicesTimeout as exc: # total devices expected not received + _LOGGER.warning("Device not responding to device requests") + await connection.disconnect() + raise ConfigEntryNotReady(exc) from exc + + # If deako devices are advertising on mdns, we should be able to get at least one device + devices = connection.get_devices() + if len(devices) == 0: + await connection.disconnect() + raise ConfigEntryNotReady(devices) + + entry.runtime_data = connection + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DeakoConfigEntry) -> bool: + """Unload a config entry.""" + await entry.runtime_data.disconnect() + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/deako/config_flow.py b/homeassistant/components/deako/config_flow.py new file mode 100644 index 00000000000..d0676fa81d9 --- /dev/null +++ b/homeassistant/components/deako/config_flow.py @@ -0,0 +1,26 @@ +"""Config flow for deako.""" + +from pydeako.discover import DeakoDiscoverer, DevicesNotFoundException + +from homeassistant.components import zeroconf +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_flow + +from .const import DOMAIN, NAME + + +async def _async_has_devices(hass: HomeAssistant) -> bool: + """Return if there are devices that can be discovered.""" + _zc = await zeroconf.async_get_instance(hass) + discoverer = DeakoDiscoverer(_zc) + + try: + await discoverer.get_address() + except DevicesNotFoundException: + return False + else: + # address exists, there's at least one device + return True + + +config_entry_flow.register_discovery_flow(DOMAIN, NAME, _async_has_devices) diff --git a/homeassistant/components/deako/const.py b/homeassistant/components/deako/const.py new file mode 100644 index 00000000000..f6b688b9b07 --- /dev/null +++ b/homeassistant/components/deako/const.py @@ -0,0 +1,5 @@ +"""Constants for Deako.""" + +# Base component constants +NAME = "Deako" +DOMAIN = "deako" diff --git a/homeassistant/components/deako/light.py b/homeassistant/components/deako/light.py new file mode 100644 index 00000000000..c7ff8765402 --- /dev/null +++ b/homeassistant/components/deako/light.py @@ -0,0 +1,96 @@ +"""Binary sensor platform for integration_blueprint.""" + +from typing import Any + +from pydeako.deako import Deako + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DeakoConfigEntry +from .const import DOMAIN + +# Model names +MODEL_SMART = "smart" +MODEL_DIMMER = "dimmer" + + +async def async_setup_entry( + hass: HomeAssistant, + config: DeakoConfigEntry, + add_entities: AddEntitiesCallback, +) -> None: + """Configure the platform.""" + client = config.runtime_data + + add_entities([DeakoLightEntity(client, uuid) for uuid in client.get_devices()]) + + +class DeakoLightEntity(LightEntity): + """Deako LightEntity class.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_is_on = False + _attr_available = True + + client: Deako + + def __init__(self, client: Deako, uuid: str) -> None: + """Save connection reference.""" + self.client = client + self._attr_unique_id = uuid + + dimmable = client.is_dimmable(uuid) + + model = MODEL_SMART + self._attr_color_mode = ColorMode.ONOFF + if dimmable: + model = MODEL_DIMMER + self._attr_color_mode = ColorMode.BRIGHTNESS + + self._attr_supported_color_modes = {self._attr_color_mode} + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, uuid)}, + name=client.get_name(uuid), + manufacturer="Deako", + model=model, + ) + + client.set_state_callback(uuid, self.on_update) + self.update() # set initial state + + def on_update(self) -> None: + """State update callback.""" + self.update() + self.schedule_update_ha_state() + + async def control_device(self, power: bool, dim: int | None = None) -> None: + """Control entity state via client.""" + assert self._attr_unique_id is not None + await self.client.control_device(self._attr_unique_id, power, dim) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + dim = None + if ATTR_BRIGHTNESS in kwargs: + dim = round(kwargs[ATTR_BRIGHTNESS] / 2.55, 0) + await self.control_device(True, dim) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the device.""" + await self.control_device(False) + + def update(self) -> None: + """Call to update state.""" + assert self._attr_unique_id is not None + state = self.client.get_state(self._attr_unique_id) or {} + self._attr_is_on = bool(state.get("power", False)) + if ( + self._attr_supported_color_modes is not None + and ColorMode.BRIGHTNESS in self._attr_supported_color_modes + ): + self._attr_brightness = int(round(state.get("dim", 0) * 2.55)) diff --git a/homeassistant/components/deako/manifest.json b/homeassistant/components/deako/manifest.json new file mode 100644 index 00000000000..e8f6f235107 --- /dev/null +++ b/homeassistant/components/deako/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "deako", + "name": "Deako", + "codeowners": ["@sebirdman", "@balake", "@deakolights"], + "config_flow": true, + "dependencies": ["zeroconf"], + "documentation": "https://www.home-assistant.io/integrations/deako", + "iot_class": "local_polling", + "loggers": ["pydeako"], + "requirements": ["pydeako==0.4.0"], + "single_config_entry": true, + "zeroconf": ["_deako._tcp.local."] +} diff --git a/homeassistant/components/deako/strings.json b/homeassistant/components/deako/strings.json new file mode 100644 index 00000000000..6bb292d74a9 --- /dev/null +++ b/homeassistant/components/deako/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "Please confirm setting up the Deako integration" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index afa906fd371..ee6658a2515 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -114,6 +114,7 @@ FLOWS = { "cpuspeed", "crownstone", "daikin", + "deako", "deconz", "deluge", "denonavr", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e204170a06f..aad03b25390 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1091,6 +1091,13 @@ "config_flow": false, "iot_class": "local_polling" }, + "deako": { + "name": "Deako", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "single_config_entry": true + }, "debugpy": { "name": "Remote Python Debugger", "integration_type": "service", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 389a4435910..3d5b0b4cfa1 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -423,6 +423,11 @@ ZEROCONF = { "domain": "forked_daapd", }, ], + "_deako._tcp.local.": [ + { + "domain": "deako", + }, + ], "_devialet-http._tcp.local.": [ { "domain": "devialet", diff --git a/mypy.ini b/mypy.ini index a312a77122f..c7a31d7354c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1145,6 +1145,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.deako.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.deconz.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1660c94ac11..c8c7412abe2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1803,6 +1803,9 @@ pydaikin==2.13.4 # homeassistant.components.danfoss_air pydanfossair==0.1.0 +# homeassistant.components.deako +pydeako==0.4.0 + # homeassistant.components.deconz pydeconz==116 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4cda97ea71..871957558da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1447,6 +1447,9 @@ pycsspeechtts==1.0.8 # homeassistant.components.daikin pydaikin==2.13.4 +# homeassistant.components.deako +pydeako==0.4.0 + # homeassistant.components.deconz pydeconz==116 diff --git a/tests/components/deako/__init__.py b/tests/components/deako/__init__.py new file mode 100644 index 00000000000..248a389f2e6 --- /dev/null +++ b/tests/components/deako/__init__.py @@ -0,0 +1 @@ +"""Tests for the Deako integration.""" diff --git a/tests/components/deako/conftest.py b/tests/components/deako/conftest.py new file mode 100644 index 00000000000..659634b8784 --- /dev/null +++ b/tests/components/deako/conftest.py @@ -0,0 +1,45 @@ +"""deako session fixtures.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.deako.const import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + ) + + +@pytest.fixture(autouse=True) +def pydeako_deako_mock() -> Generator[MagicMock]: + """Mock pydeako deako client.""" + with patch("homeassistant.components.deako.Deako", autospec=True) as mock: + yield mock + + +@pytest.fixture(autouse=True) +def pydeako_discoverer_mock(mock_async_zeroconf: MagicMock) -> Generator[MagicMock]: + """Mock pydeako discovery client.""" + with ( + patch("homeassistant.components.deako.DeakoDiscoverer", autospec=True) as mock, + patch("homeassistant.components.deako.config_flow.DeakoDiscoverer", new=mock), + ): + yield mock + + +@pytest.fixture +def mock_deako_setup() -> Generator[MagicMock]: + """Mock async_setup_entry for config flow tests.""" + with patch( + "homeassistant.components.deako.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup diff --git a/tests/components/deako/snapshots/test_light.ambr b/tests/components/deako/snapshots/test_light.ambr new file mode 100644 index 00000000000..7bc170654e1 --- /dev/null +++ b/tests/components/deako/snapshots/test_light.ambr @@ -0,0 +1,168 @@ +# serializer version: 1 +# name: test_dimmable_light_props[light.kitchen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.kitchen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'deako', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'uuid', + 'unit_of_measurement': None, + }) +# --- +# name: test_dimmable_light_props[light.kitchen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 127, + 'color_mode': , + 'friendly_name': 'kitchen', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.kitchen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_initial_props[light.kitchen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.kitchen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'deako', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'uuid', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_initial_props[light.kitchen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'kitchen', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.kitchen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_light_setup_with_device[light.some_device-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.some_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'deako', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'some_device', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_setup_with_device[light.some_device-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 1, + 'color_mode': , + 'friendly_name': 'some device', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.some_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/deako/test_config_flow.py b/tests/components/deako/test_config_flow.py new file mode 100644 index 00000000000..21b10eaaa36 --- /dev/null +++ b/tests/components/deako/test_config_flow.py @@ -0,0 +1,80 @@ +"""Tests for the deako component config flow.""" + +from unittest.mock import MagicMock + +from pydeako.discover import DevicesNotFoundException + +from homeassistant.components.deako.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_found( + hass: HomeAssistant, + pydeako_discoverer_mock: MagicMock, + mock_deako_setup: MagicMock, +) -> None: + """Test finding a Deako device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Confirmation form + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + pydeako_discoverer_mock.return_value.get_address.assert_called_once() + + mock_deako_setup.assert_called_once() + + +async def test_not_found( + hass: HomeAssistant, + pydeako_discoverer_mock: MagicMock, + mock_deako_setup: MagicMock, +) -> None: + """Test not finding any Deako devices.""" + pydeako_discoverer_mock.return_value.get_address.side_effect = ( + DevicesNotFoundException() + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # Confirmation form + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + pydeako_discoverer_mock.return_value.get_address.assert_called_once() + + mock_deako_setup.assert_not_called() + + +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_deako_setup: MagicMock, +) -> None: + """Test flow aborts when already configured.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + mock_deako_setup.assert_not_called() diff --git a/tests/components/deako/test_init.py b/tests/components/deako/test_init.py new file mode 100644 index 00000000000..b4c0e8bb1f7 --- /dev/null +++ b/tests/components/deako/test_init.py @@ -0,0 +1,87 @@ +"""Tests for the deako component init.""" + +from unittest.mock import MagicMock + +from pydeako.deako import DeviceListTimeout, FindDevicesTimeout + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_deako_async_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + pydeako_deako_mock: MagicMock, + pydeako_discoverer_mock: MagicMock, +) -> None: + """Test successful setup entry.""" + pydeako_deako_mock.return_value.get_devices.return_value = { + "id1": {}, + "id2": {}, + } + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + pydeako_deako_mock.assert_called_once_with( + pydeako_discoverer_mock.return_value.get_address + ) + pydeako_deako_mock.return_value.connect.assert_called_once() + pydeako_deako_mock.return_value.find_devices.assert_called_once() + pydeako_deako_mock.return_value.get_devices.assert_called() + + assert mock_config_entry.runtime_data == pydeako_deako_mock.return_value + + +async def test_deako_async_setup_entry_device_list_timeout( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + pydeako_deako_mock: MagicMock, + pydeako_discoverer_mock: MagicMock, +) -> None: + """Test async_setup_entry raises ConfigEntryNotReady when pydeako raises DeviceListTimeout.""" + + mock_config_entry.add_to_hass(hass) + + pydeako_deako_mock.return_value.find_devices.side_effect = DeviceListTimeout() + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + pydeako_deako_mock.assert_called_once_with( + pydeako_discoverer_mock.return_value.get_address + ) + pydeako_deako_mock.return_value.connect.assert_called_once() + pydeako_deako_mock.return_value.find_devices.assert_called_once() + pydeako_deako_mock.return_value.disconnect.assert_called_once() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_deako_async_setup_entry_find_devices_timeout( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + pydeako_deako_mock: MagicMock, + pydeako_discoverer_mock: MagicMock, +) -> None: + """Test async_setup_entry raises ConfigEntryNotReady when pydeako raises FindDevicesTimeout.""" + + mock_config_entry.add_to_hass(hass) + + pydeako_deako_mock.return_value.find_devices.side_effect = FindDevicesTimeout() + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + pydeako_deako_mock.assert_called_once_with( + pydeako_discoverer_mock.return_value.get_address + ) + pydeako_deako_mock.return_value.connect.assert_called_once() + pydeako_deako_mock.return_value.find_devices.assert_called_once() + pydeako_deako_mock.return_value.disconnect.assert_called_once() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/deako/test_light.py b/tests/components/deako/test_light.py new file mode 100644 index 00000000000..b969c7f71cb --- /dev/null +++ b/tests/components/deako/test_light.py @@ -0,0 +1,192 @@ +"""Tests for the light module.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_light_setup_with_device( + hass: HomeAssistant, + pydeako_deako_mock: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test light platform setup with device returned.""" + mock_config_entry.add_to_hass(hass) + + pydeako_deako_mock.return_value.get_devices.return_value = { + "some_device": {}, + } + pydeako_deako_mock.return_value.get_name.return_value = "some device" + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_light_initial_props( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + pydeako_deako_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test on/off light is setup with accurate initial properties.""" + mock_config_entry.add_to_hass(hass) + + pydeako_deako_mock.return_value.get_devices.return_value = { + "uuid": { + "name": "kitchen", + } + } + pydeako_deako_mock.return_value.get_name.return_value = "kitchen" + pydeako_deako_mock.return_value.get_state.return_value = { + "power": False, + } + pydeako_deako_mock.return_value.is_dimmable.return_value = False + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_dimmable_light_props( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + pydeako_deako_mock: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test dimmable on/off light is setup with accurate initial properties.""" + mock_config_entry.add_to_hass(hass) + + pydeako_deako_mock.return_value.get_devices.return_value = { + "uuid": { + "name": "kitchen", + } + } + pydeako_deako_mock.return_value.get_name.return_value = "kitchen" + pydeako_deako_mock.return_value.get_state.return_value = { + "power": True, + "dim": 50, + } + pydeako_deako_mock.return_value.is_dimmable.return_value = True + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_light_power_change_on( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + pydeako_deako_mock: MagicMock, +) -> None: + """Test turing on a deako device.""" + mock_config_entry.add_to_hass(hass) + + pydeako_deako_mock.return_value.get_devices.return_value = { + "uuid": { + "name": "kitchen", + } + } + pydeako_deako_mock.return_value.get_name.return_value = "kitchen" + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.kitchen"}, + blocking=True, + ) + + pydeako_deako_mock.return_value.control_device.assert_called_once_with( + "uuid", True, None + ) + + +async def test_light_power_change_off( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + pydeako_deako_mock: MagicMock, +) -> None: + """Test turing off a deako device.""" + mock_config_entry.add_to_hass(hass) + + pydeako_deako_mock.return_value.get_devices.return_value = { + "uuid": { + "name": "kitchen", + } + } + pydeako_deako_mock.return_value.get_name.return_value = "kitchen" + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.kitchen"}, + blocking=True, + ) + + pydeako_deako_mock.return_value.control_device.assert_called_once_with( + "uuid", False, None + ) + + +@pytest.mark.parametrize( + ("dim_input", "expected_dim_value"), + [ + (3, 1), + (255, 100), + (127, 50), + ], +) +async def test_light_brightness_change( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + pydeako_deako_mock: MagicMock, + dim_input: int, + expected_dim_value: int, +) -> None: + """Test turing on a deako device.""" + mock_config_entry.add_to_hass(hass) + + pydeako_deako_mock.return_value.get_devices.return_value = { + "uuid": { + "name": "kitchen", + } + } + pydeako_deako_mock.return_value.get_name.return_value = "kitchen" + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.kitchen", + ATTR_BRIGHTNESS: dim_input, + }, + blocking=True, + ) + + pydeako_deako_mock.return_value.control_device.assert_called_once_with( + "uuid", True, expected_dim_value + ) From af8131e68f95ebb33fdb9f0dbc826143fe323cab Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 28 Aug 2024 19:19:04 +0200 Subject: [PATCH 0003/1309] Bump pydaikin to 2.13.5 (#124802) bump pydaikin version --- homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 0d93c0e25ad..c395ee35cad 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.13.4"], + "requirements": ["pydaikin==2.13.5"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index c8c7412abe2..0a9fb74c4dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1798,7 +1798,7 @@ pycsspeechtts==1.0.8 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.13.4 +pydaikin==2.13.5 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 871957558da..920895f5a7b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1445,7 +1445,7 @@ pycoolmasternet-async==0.2.2 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.13.4 +pydaikin==2.13.5 # homeassistant.components.deako pydeako==0.4.0 From 7d61dd13d9f94a98914fdacbd3c593dc04830476 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 28 Aug 2024 20:42:50 +0200 Subject: [PATCH 0004/1309] Use reauth_confirm in discovergy (#124782) --- homeassistant/components/discovergy/config_flow.py | 12 ++++++++++-- homeassistant/components/discovergy/strings.json | 2 +- tests/components/discovergy/test_config_flow.py | 12 +++--------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index 5e17f0764b7..47a78ff4308 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -70,8 +70,16 @@ class DiscovergyConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle the initial step.""" - self._existing_entry = await self.async_set_unique_id(self.context["unique_id"]) - return await self._validate_and_save(entry_data, step_id="reauth") + self._existing_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reauth step.""" + return await self._validate_and_save(user_input, step_id="reauth_confirm") async def _validate_and_save( self, user_input: Mapping[str, Any] | None = None, step_id: str = "user" diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index 34c21bc1cfe..9a91fa92dc4 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -7,7 +7,7 @@ "password": "[%key:common::config_flow::data::password%]" } }, - "reauth": { + "reauth_confirm": { "data": { "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index 2464ba3846f..470ef65fccd 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -6,7 +6,7 @@ from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin import pytest from homeassistant.components.discovergy.const import DOMAIN -from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -49,15 +49,9 @@ async def test_reauth( ) -> None: """Test reauth flow.""" config_entry.add_to_hass(hass) - - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH, "unique_id": config_entry.unique_id}, - data=None, - ) - + init_result = await config_entry.start_reauth_flow(hass) assert init_result["type"] is FlowResultType.FORM - assert init_result["step_id"] == "reauth" + assert init_result["step_id"] == "reauth_confirm" with patch( "homeassistant.components.discovergy.async_setup_entry", From 2900fa733d97ac649ce2da8551cefbe88d3ac190 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 28 Aug 2024 20:43:11 +0200 Subject: [PATCH 0005/1309] Use reauth_confirm in co2signal (#124781) --- homeassistant/components/co2signal/config_flow.py | 11 +++++++++-- homeassistant/components/co2signal/strings.json | 2 +- tests/components/co2signal/test_config_flow.py | 11 ++--------- tests/components/co2signal/test_sensor.py | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index bf5d645638f..3313d01be85 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -131,16 +131,23 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN): self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) + return await self.async_step_reauth_confirm() + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reauth step.""" data_schema = vol.Schema( { vol.Required(CONF_API_KEY): cv.string, } ) - return await self._validate_and_create("reauth", data_schema, entry_data) + return await self._validate_and_create( + "reauth_confirm", data_schema, user_input + ) async def _validate_and_create( - self, step_id: str, data_schema: vol.Schema, data: Mapping[str, Any] + self, step_id: str, data_schema: vol.Schema, data: Mapping[str, Any] | None ) -> ConfigFlowResult: """Validate data and show form if it is invalid.""" errors: dict[str, str] = {} diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 7444cde73d7..a4ec916bd42 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -19,7 +19,7 @@ "country_code": "Country code" } }, - "reauth": { + "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::access_token%]" } diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index 7397b6e2355..ad61ae4f897 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -198,17 +198,10 @@ async def test_reauth( """Test reauth flow.""" config_entry.add_to_hass(hass) - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": config_entry.entry_id, - }, - data=None, - ) + init_result = await config_entry.start_reauth_flow(hass) assert init_result["type"] is FlowResultType.FORM - assert init_result["step_id"] == "reauth" + assert init_result["step_id"] == "reauth_confirm" with patch( "homeassistant.components.co2signal.async_setup_entry", diff --git a/tests/components/co2signal/test_sensor.py b/tests/components/co2signal/test_sensor.py index e9f46e483d1..fddda17f3ed 100644 --- a/tests/components/co2signal/test_sensor.py +++ b/tests/components/co2signal/test_sensor.py @@ -109,4 +109,4 @@ async def test_sensor_reauth_triggered( assert (flows := hass.config_entries.flow.async_progress()) assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth" + assert flows[0]["step_id"] == "reauth_confirm" From 70488ffd15a7fa10b2348c4843adc6e914e2233c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Aug 2024 09:00:52 -1000 Subject: [PATCH 0006/1309] Address yale review comments (#124810) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/yale/__init__.py | 2 +- .../components/yale/binary_sensor.py | 11 +- homeassistant/components/yale/button.py | 4 +- homeassistant/components/yale/camera.py | 4 +- homeassistant/components/yale/config_flow.py | 5 +- homeassistant/components/yale/const.py | 4 - homeassistant/components/yale/diagnostics.py | 5 +- homeassistant/components/yale/entity.py | 4 +- homeassistant/components/yale/event.py | 28 +- homeassistant/components/yale/lock.py | 4 +- homeassistant/components/yale/manifest.json | 2 +- homeassistant/components/yale/sensor.py | 21 +- homeassistant/components/yale/util.py | 7 +- tests/components/yale/__init__.py | 11 - .../yale/snapshots/test_binary_sensor.ambr | 33 +++ .../yale/snapshots/test_diagnostics.ambr | 2 +- tests/components/yale/test_binary_sensor.py | 252 ++++++------------ tests/components/yale/test_config_flow.py | 76 +++++- tests/components/yale/test_event.py | 44 ++- tests/components/yale/test_init.py | 31 ++- 20 files changed, 267 insertions(+), 283 deletions(-) create mode 100644 tests/components/yale/snapshots/test_binary_sensor.ambr diff --git a/homeassistant/components/yale/__init__.py b/homeassistant/components/yale/__init__.py index f7a4a6e0f4d..1cbd9c87b57 100644 --- a/homeassistant/components/yale/__init__.py +++ b/homeassistant/components/yale/__init__.py @@ -26,7 +26,7 @@ from .util import async_create_yale_clientsession type YaleConfigEntry = ConfigEntry[YaleData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: """Set up yale from a config entry.""" session = async_create_yale_clientsession(hass) implementation = ( diff --git a/homeassistant/components/yale/binary_sensor.py b/homeassistant/components/yale/binary_sensor.py index cbc0b48b177..dbb00ad7d42 100644 --- a/homeassistant/components/yale/binary_sensor.py +++ b/homeassistant/components/yale/binary_sensor.py @@ -109,12 +109,11 @@ async def async_setup_entry( for description in SENSOR_TYPES_DOORBELL ) - for doorbell in data.doorbells: - entities.extend( - YaleDoorbellBinarySensor(data, doorbell, description) - for description in SENSOR_TYPES_DOORBELL + SENSOR_TYPES_VIDEO_DOORBELL - ) - + entities.extend( + YaleDoorbellBinarySensor(data, doorbell, description) + for description in SENSOR_TYPES_DOORBELL + SENSOR_TYPES_VIDEO_DOORBELL + for doorbell in data.doorbells + ) async_add_entities(entities) diff --git a/homeassistant/components/yale/button.py b/homeassistant/components/yale/button.py index de0cff4f0c8..b04ad638f0c 100644 --- a/homeassistant/components/yale/button.py +++ b/homeassistant/components/yale/button.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import YaleConfigEntry -from .entity import YaleEntityMixin +from .entity import YaleEntity async def async_setup_entry( @@ -18,7 +18,7 @@ async def async_setup_entry( async_add_entities(YaleWakeLockButton(data, lock, "wake") for lock in data.locks) -class YaleWakeLockButton(YaleEntityMixin, ButtonEntity): +class YaleWakeLockButton(YaleEntity, ButtonEntity): """Representation of an Yale lock wake button.""" _attr_translation_key = "wake" diff --git a/homeassistant/components/yale/camera.py b/homeassistant/components/yale/camera.py index 500239d7f3a..217e8f5f6fd 100644 --- a/homeassistant/components/yale/camera.py +++ b/homeassistant/components/yale/camera.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import YaleConfigEntry, YaleData from .const import DEFAULT_NAME, DEFAULT_TIMEOUT -from .entity import YaleEntityMixin +from .entity import YaleEntity _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ async def async_setup_entry( ) -class YaleCamera(YaleEntityMixin, Camera): +class YaleCamera(YaleEntity, Camera): """An implementation of an Yale security camera.""" _attr_translation_key = "camera" diff --git a/homeassistant/components/yale/config_flow.py b/homeassistant/components/yale/config_flow.py index cdd44754103..6cbc9543ea4 100644 --- a/homeassistant/components/yale/config_flow.py +++ b/homeassistant/components/yale/config_flow.py @@ -26,7 +26,9 @@ class YaleConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain= """Return logger.""" return _LOGGER - async def async_step_reauth(self, data: Mapping[str, Any]) -> ConfigFlowResult: + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: """Handle configuration by re-auth.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] @@ -54,4 +56,5 @@ class YaleConfigFlow(config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain= return self.async_abort(reason="reauth_invalid_user") return self.async_update_reload_and_abort(entry, data=data) await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/yale/const.py b/homeassistant/components/yale/const.py index 630d15f7230..3da4fb1dfb4 100644 --- a/homeassistant/components/yale/const.py +++ b/homeassistant/components/yale/const.py @@ -1,7 +1,5 @@ """Constants for Yale devices.""" -from yalexs.const import Brand - from homeassistant.const import Platform DEFAULT_TIMEOUT = 25 @@ -13,8 +11,6 @@ CONF_INSTALL_ID = "install_id" VERIFICATION_CODE_KEY = "verification_code" -DEFAULT_BRAND = Brand.YALE_HOME - MANUFACTURER = "Yale Home Inc." DEFAULT_NAME = "Yale" diff --git a/homeassistant/components/yale/diagnostics.py b/homeassistant/components/yale/diagnostics.py index ef8d837b82e..7e7f6179e7a 100644 --- a/homeassistant/components/yale/diagnostics.py +++ b/homeassistant/components/yale/diagnostics.py @@ -4,11 +4,12 @@ from __future__ import annotations from typing import Any +from yalexs.const import Brand + from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant from . import YaleConfigEntry -from .const import CONF_BRAND, DEFAULT_BRAND TO_REDACT = { "HouseID", @@ -45,5 +46,5 @@ async def async_get_config_entry_diagnostics( ) for doorbell in data.doorbells }, - "brand": entry.data.get(CONF_BRAND, DEFAULT_BRAND), + "brand": Brand.YALE_GLOBAL.value, } diff --git a/homeassistant/components/yale/entity.py b/homeassistant/components/yale/entity.py index 7105fda861c..152070c0be3 100644 --- a/homeassistant/components/yale/entity.py +++ b/homeassistant/components/yale/entity.py @@ -20,7 +20,7 @@ from .const import MANUFACTURER DEVICE_TYPES = ["keypad", "lock", "camera", "doorbell", "door", "bell"] -class YaleEntityMixin(Entity): +class YaleEntity(Entity): """Base implementation for Yale device.""" _attr_should_poll = False @@ -87,7 +87,7 @@ class YaleEntityMixin(Entity): self._update_from_data() -class YaleDescriptionEntity(YaleEntityMixin): +class YaleDescriptionEntity(YaleEntity): """An Yale entity with a description.""" def __init__( diff --git a/homeassistant/components/yale/event.py b/homeassistant/components/yale/event.py index 7014c5dafbf..935ba7376f8 100644 --- a/homeassistant/components/yale/event.py +++ b/homeassistant/components/yale/event.py @@ -63,22 +63,17 @@ async def async_setup_entry( ) -> None: """Set up the yale event platform.""" data = config_entry.runtime_data - entities: list[YaleEventEntity] = [] - - for lock in data.locks: - detail = data.get_device_detail(lock.device_id) - if detail.doorbell: - entities.extend( - YaleEventEntity(data, lock, description) - for description in TYPES_DOORBELL - ) - - for doorbell in data.doorbells: - entities.extend( - YaleEventEntity(data, doorbell, description) - for description in TYPES_DOORBELL + TYPES_VIDEO_DOORBELL - ) - + entities: list[YaleEventEntity] = [ + YaleEventEntity(data, lock, description) + for description in TYPES_DOORBELL + for lock in data.locks + if (detail := data.get_device_detail(lock.device_id)) and detail.doorbell + ] + entities.extend( + YaleEventEntity(data, doorbell, description) + for description in TYPES_DOORBELL + TYPES_VIDEO_DOORBELL + for doorbell in data.doorbells + ) async_add_entities(entities) @@ -86,7 +81,6 @@ class YaleEventEntity(YaleDescriptionEntity, EventEntity): """An yale event entity.""" entity_description: YaleEventEntityDescription - _attr_has_entity_name = True _last_activity: Activity | None = None @callback diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py index 36d865bf527..b911c92ba0f 100644 --- a/homeassistant/components/yale/lock.py +++ b/homeassistant/components/yale/lock.py @@ -19,7 +19,7 @@ from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.dt as dt_util from . import YaleConfigEntry, YaleData -from .entity import YaleEntityMixin +from .entity import YaleEntity _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,7 @@ async def async_setup_entry( async_add_entities(YaleLock(data, lock) for lock in data.locks) -class YaleLock(YaleEntityMixin, RestoreEntity, LockEntity): +class YaleLock(YaleEntity, RestoreEntity, LockEntity): """Representation of an Yale lock.""" _attr_name = None diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 2dc84758610..d6da9ba3993 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -11,6 +11,6 @@ ], "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", - "loggers": ["pubnub", "yalexs"], + "loggers": ["socketio", "engineio", "yalexs"], "requirements": ["yalexs==8.5.4", "yalexs-ble==2.4.3"] } diff --git a/homeassistant/components/yale/sensor.py b/homeassistant/components/yale/sensor.py index f1931c112cb..bb3d4317277 100644 --- a/homeassistant/components/yale/sensor.py +++ b/homeassistant/components/yale/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Generic, TypeVar, cast +from typing import Any, cast from yalexs.activity import ActivityType, LockOperationActivity from yalexs.doorbell import Doorbell @@ -42,7 +42,7 @@ from .const import ( OPERATION_METHOD_REMOTE, OPERATION_METHOD_TAG, ) -from .entity import YaleDescriptionEntity, YaleEntityMixin +from .entity import YaleDescriptionEntity, YaleEntity def _retrieve_device_battery_state(detail: LockDetail) -> int: @@ -55,14 +55,13 @@ def _retrieve_linked_keypad_battery_state(detail: KeypadDetail) -> int | None: return detail.battery_percentage -_T = TypeVar("_T", LockDetail, KeypadDetail) - - @dataclass(frozen=True, kw_only=True) -class YaleSensorEntityDescription(SensorEntityDescription, Generic[_T]): +class YaleSensorEntityDescription[T: LockDetail | KeypadDetail]( + SensorEntityDescription +): """Mixin for required keys.""" - value_fn: Callable[[_T], int | None] + value_fn: Callable[[T], int | None] SENSOR_TYPE_DEVICE_BATTERY = YaleSensorEntityDescription[LockDetail]( @@ -112,7 +111,7 @@ async def async_setup_entry( async_add_entities(entities) -class YaleOperatorSensor(YaleEntityMixin, RestoreSensor): +class YaleOperatorSensor(YaleEntity, RestoreSensor): """Representation of an Yale lock operation sensor.""" _attr_translation_key = "operator" @@ -196,10 +195,12 @@ class YaleOperatorSensor(YaleEntityMixin, RestoreSensor): self._operated_autorelock = last_attrs[ATTR_OPERATION_AUTORELOCK] -class YaleBatterySensor(YaleDescriptionEntity, SensorEntity, Generic[_T]): +class YaleBatterySensor[T: LockDetail | KeypadDetail]( + YaleDescriptionEntity, SensorEntity +): """Representation of an Yale sensor.""" - entity_description: YaleSensorEntityDescription[_T] + entity_description: YaleSensorEntityDescription[T] _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE diff --git a/homeassistant/components/yale/util.py b/homeassistant/components/yale/util.py index d8bdaab4a66..3462c576fd9 100644 --- a/homeassistant/components/yale/util.py +++ b/homeassistant/components/yale/util.py @@ -63,16 +63,11 @@ def _activity_time_based(latest: Activity) -> Activity | None: """Get the latest state of the sensor.""" start = latest.activity_start_time end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION - if start <= _native_datetime() <= end: + if start <= datetime.now() <= end: return latest return None -def _native_datetime() -> datetime: - """Return time in the format yale uses without timezone.""" - return datetime.now() - - def retrieve_online_state(data: YaleData, detail: DoorbellDetail | LockDetail) -> bool: """Get the latest state of the sensor.""" # The doorbell will go into standby mode when there is no motion diff --git a/tests/components/yale/__init__.py b/tests/components/yale/__init__.py index f0604940686..7f72d348042 100644 --- a/tests/components/yale/__init__.py +++ b/tests/components/yale/__init__.py @@ -1,12 +1 @@ """Tests for the yale component.""" - -MOCK_CONFIG_ENTRY_DATA = { - "auth_implementation": "cloud", - "token": { - "access_token": "access_token", - "expires_in": 1, - "refresh_token": "refresh_token", - "expires_at": 2, - "service": "yale", - }, -} diff --git a/tests/components/yale/snapshots/test_binary_sensor.ambr b/tests/components/yale/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..e294cb7c76c --- /dev/null +++ b/tests/components/yale/snapshots/test_binary_sensor.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_doorbell_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': 'tmt100_name', + 'config_entries': , + 'configuration_url': 'https://account.aaecosystem.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'yale', + 'tmt100', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Yale Home Inc.', + 'model': 'hydra1', + 'model_id': None, + 'name': 'tmt100 Name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'tmt100 Name', + 'sw_version': '3.1.0-HYDRC75+201909251139', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/yale/snapshots/test_diagnostics.ambr b/tests/components/yale/snapshots/test_diagnostics.ambr index fd31bc0ec91..c3d8d8e2aaa 100644 --- a/tests/components/yale/snapshots/test_diagnostics.ambr +++ b/tests/components/yale/snapshots/test_diagnostics.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'brand': 'yale_home', + 'brand': 'yale_global', 'doorbells': dict({ 'K98GiDT45GUL': dict({ 'HouseID': '**REDACTED**', diff --git a/tests/components/yale/test_binary_sensor.py b/tests/components/yale/test_binary_sensor.py index ad4d4155e5b..811c845e359 100644 --- a/tests/components/yale/test_binary_sensor.py +++ b/tests/components/yale/test_binary_sensor.py @@ -1,7 +1,9 @@ """The binary_sensor tests for the yale platform.""" import datetime -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.const import ( @@ -33,28 +35,19 @@ async def test_doorsense(hass: HomeAssistant) -> None: hass, "get_lock.online_with_doorsense.json" ) await _create_yale_with_devices(hass, [lock_one]) - - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + states = hass.states + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) - await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" + assert ( + states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_OFF ) - assert binary_sensor_online_with_doorsense_name.state == STATE_OFF async def test_lock_bridge_offline(hass: HomeAssistant) -> None: @@ -66,112 +59,78 @@ async def test_lock_bridge_offline(hass: HomeAssistant) -> None: hass, "get_activity.bridge_offline.json" ) await _create_yale_with_devices(hass, [lock_one], activities=activities) - - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" + states = hass.states + assert ( + states.get("binary_sensor.online_with_doorsense_name_door").state + == STATE_UNAVAILABLE ) - assert binary_sensor_online_with_doorsense_name.state == STATE_UNAVAILABLE async def test_create_doorbell(hass: HomeAssistant) -> None: """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") await _create_yale_with_devices(hass, [doorbell_one]) - - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" + states = hass.states + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_OFF + assert ( + states.get("binary_sensor.k98gidt45gul_name_image_capture").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF - binary_sensor_k98gidt45gul_name_image_capture = hass.states.get( - "binary_sensor.k98gidt45gul_name_image_capture" + assert states.get("binary_sensor.k98gidt45gul_name_connectivity").state == STATE_ON + assert ( + states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_image_capture.state == STATE_OFF - binary_sensor_k98gidt45gul_name_online = hass.states.get( - "binary_sensor.k98gidt45gul_name_connectivity" + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_OFF + assert ( + states.get("binary_sensor.k98gidt45gul_name_image_capture").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" - ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" - ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF - binary_sensor_k98gidt45gul_name_image_capture = hass.states.get( - "binary_sensor.k98gidt45gul_name_image_capture" - ) - assert binary_sensor_k98gidt45gul_name_image_capture.state == STATE_OFF async def test_create_doorbell_offline(hass: HomeAssistant) -> None: """Test creation of a doorbell that is offline.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") await _create_yale_with_devices(hass, [doorbell_one]) - - binary_sensor_tmt100_name_motion = hass.states.get( - "binary_sensor.tmt100_name_motion" + states = hass.states + assert states.get("binary_sensor.tmt100_name_motion").state == STATE_UNAVAILABLE + assert states.get("binary_sensor.tmt100_name_connectivity").state == STATE_OFF + assert ( + states.get("binary_sensor.tmt100_name_doorbell_ding").state == STATE_UNAVAILABLE ) - assert binary_sensor_tmt100_name_motion.state == STATE_UNAVAILABLE - binary_sensor_tmt100_name_online = hass.states.get( - "binary_sensor.tmt100_name_connectivity" - ) - assert binary_sensor_tmt100_name_online.state == STATE_OFF - binary_sensor_tmt100_name_ding = hass.states.get( - "binary_sensor.tmt100_name_doorbell_ding" - ) - assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE -async def test_create_doorbell_with_motion(hass: HomeAssistant) -> None: +async def test_create_doorbell_with_motion( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") activities = await _mock_activities_from_fixture( hass, "get_activity.doorbell_motion.json" ) await _create_yale_with_devices(hass, [doorbell_one], activities=activities) - - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" + states = hass.states + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_ON + assert states.get("binary_sensor.k98gidt45gul_name_connectivity").state == STATE_ON + assert ( + states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON - binary_sensor_k98gidt45gul_name_online = hass.states.get( - "binary_sensor.k98gidt45gul_name_connectivity" - ) - assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" - ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.yale.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" - ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_OFF -async def test_doorbell_update_via_socketio(hass: HomeAssistant) -> None: +async def test_doorbell_update_via_socketio( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test creation of a doorbell that can be updated via socketio.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") _, socketio = await _create_yale_with_devices(hass, [doorbell_one]) assert doorbell_one.pubsub_channel == "7c7a6672-59c8-3333-ffff-dcd98705cccc" - - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" + states = hass.states + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_OFF + assert ( + states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" - ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF listener = list(socketio._listeners)[0] listener( @@ -192,10 +151,7 @@ async def test_doorbell_update_via_socketio(hass: HomeAssistant) -> None: await hass.async_block_till_done() - binary_sensor_k98gidt45gul_name_image_capture = hass.states.get( - "binary_sensor.k98gidt45gul_name_image_capture" - ) - assert binary_sensor_k98gidt45gul_name_image_capture.state == STATE_ON + assert states.get("binary_sensor.k98gidt45gul_name_image_capture").state == STATE_ON listener( doorbell_one.device_id, @@ -226,29 +182,18 @@ async def test_doorbell_update_via_socketio(hass: HomeAssistant) -> None: await hass.async_block_till_done() - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_ON + assert ( + states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + states.get("binary_sensor.k98gidt45gul_name_image_capture").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF - - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.yale.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() - - binary_sensor_k98gidt45gul_name_image_capture = hass.states.get( - "binary_sensor.k98gidt45gul_name_image_capture" - ) - assert binary_sensor_k98gidt45gul_name_image_capture.state == STATE_OFF listener( doorbell_one.device_id, @@ -260,37 +205,28 @@ async def test_doorbell_update_via_socketio(hass: HomeAssistant) -> None: await hass.async_block_till_done() - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" - ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_ON - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.yale.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + assert states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_ON - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF async def test_doorbell_device_registry( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test creation of a lock with doorsense and bridge ands up in the registry.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") await _create_yale_with_devices(hass, [doorbell_one]) reg_device = device_registry.async_get_device(identifiers={("yale", "tmt100")}) - assert reg_device.model == "hydra1" - assert reg_device.name == "tmt100 Name" - assert reg_device.manufacturer == "Yale Home Inc." - assert reg_device.sw_version == "3.1.0-HYDRC75+201909251139" + assert reg_device == snapshot async def test_door_sense_update_via_socketio(hass: HomeAssistant) -> None: @@ -302,11 +238,8 @@ async def test_door_sense_update_via_socketio(hass: HomeAssistant) -> None: config_entry, socketio = await _create_yale_with_devices( hass, [lock_one], activities=activities ) - - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + states = hass.states + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON listener = list(socketio._listeners)[0] listener( @@ -316,10 +249,10 @@ async def test_door_sense_update_via_socketio(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" + + assert ( + states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_OFF ) - assert binary_sensor_online_with_doorsense_name.state == STATE_OFF listener( lock_one.device_id, @@ -328,33 +261,22 @@ async def test_door_sense_update_via_socketio(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON socketio.connected = True async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON # Ensure socketio status is always preserved async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2)) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON listener( lock_one.device_id, @@ -363,17 +285,11 @@ async def test_door_sense_update_via_socketio(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4)) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() @@ -383,8 +299,10 @@ async def test_create_lock_with_doorbell(hass: HomeAssistant) -> None: """Test creation of a lock with a doorbell.""" lock_one = await _mock_lock_from_fixture(hass, "lock_with_doorbell.online.json") await _create_yale_with_devices(hass, [lock_one]) - - ding_sensor = hass.states.get( - "binary_sensor.a6697750d607098bae8d6baa11ef8063_name_doorbell_ding" + states = hass.states + assert ( + states.get( + "binary_sensor.a6697750d607098bae8d6baa11ef8063_name_doorbell_ding" + ).state + == STATE_OFF ) - assert ding_sensor.state == STATE_OFF diff --git a/tests/components/yale/test_config_flow.py b/tests/components/yale/test_config_flow.py index a62aa2d38f9..163f8240553 100644 --- a/tests/components/yale/test_config_flow.py +++ b/tests/components/yale/test_config_flow.py @@ -1,16 +1,16 @@ """Test the yale config flow.""" from collections.abc import Generator -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import pytest -from homeassistant import config_entries from homeassistant.components.yale.application_credentials import ( OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) from homeassistant.components.yale.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -44,7 +44,7 @@ async def test_full_flow( ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) state = config_entry_oauth2_flow._encode_jwt( hass, @@ -78,13 +78,81 @@ async def test_full_flow( }, ) - await hass.config_entries.flow.async_configure(result["flow_id"]) + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup_entry.mock_calls) == 1 entry = hass.config_entries.async_entries(DOMAIN)[0] assert entry.unique_id == USER_ID + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["result"].unique_id == USER_ID + assert entry.data == { + "auth_implementation": "yale", + "token": { + "access_token": jwt, + "expires_at": ANY, + "expires_in": ANY, + "refresh_token": "mock-refresh-token", + "scope": "any", + "user_id": "mock-user-id", + }, + } + + +@pytest.mark.usefixtures("client_credentials") +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow_already_exists( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + jwt: str, + mock_setup_entry: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Check full flow for a user that already exists.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": jwt, + "scope": "any", + "expires_in": 86399, + "refresh_token": "mock-refresh-token", + "user_id": "mock-user-id", + "expires_at": 1697753347, + }, + ) + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + @pytest.mark.usefixtures("client_credentials") @pytest.mark.usefixtures("current_request_with_host") diff --git a/tests/components/yale/test_event.py b/tests/components/yale/test_event.py index f2f205289ff..7aeb9d8f12b 100644 --- a/tests/components/yale/test_event.py +++ b/tests/components/yale/test_event.py @@ -1,7 +1,6 @@ """The event tests for the yale.""" -import datetime -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -42,7 +41,9 @@ async def test_create_doorbell_offline(hass: HomeAssistant) -> None: assert doorbell_state.state == STATE_UNAVAILABLE -async def test_create_doorbell_with_motion(hass: HomeAssistant) -> None: +async def test_create_doorbell_with_motion( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") activities = await _mock_activities_from_fixture( @@ -58,19 +59,16 @@ async def test_create_doorbell_with_motion(hass: HomeAssistant) -> None: assert doorbell_state is not None assert doorbell_state.state == STATE_UNKNOWN - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.yale.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() motion_state = hass.states.get("event.k98gidt45gul_name_motion") assert motion_state.state == isotime -async def test_doorbell_update_via_socketio(hass: HomeAssistant) -> None: +async def test_doorbell_update_via_socketio( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test creation of a doorbell that can be updated via socketio.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") @@ -119,14 +117,9 @@ async def test_doorbell_update_via_socketio(hass: HomeAssistant) -> None: assert motion_state.state != STATE_UNKNOWN isotime = motion_state.state - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.yale.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() motion_state = hass.states.get("event.k98gidt45gul_name_motion") assert motion_state is not None @@ -147,14 +140,9 @@ async def test_doorbell_update_via_socketio(hass: HomeAssistant) -> None: assert doorbell_state.state != STATE_UNKNOWN isotime = motion_state.state - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.yale.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() doorbell_state = hass.states.get("event.k98gidt45gul_name_doorbell") assert doorbell_state is not None diff --git a/tests/components/yale/test_init.py b/tests/components/yale/test_init.py index c9cb4be5882..4f0a853710c 100644 --- a/tests/components/yale/test_init.py +++ b/tests/components/yale/test_init.py @@ -89,16 +89,15 @@ async def test_unlock_throws_yale_api_http_error(hass: HomeAssistant) -> None: "unlock_return_activities": _unlock_return_activities_side_effect }, ) - last_err = None data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} - try: + with pytest.raises( + HomeAssistantError, + match=( + "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user" + " consumable" + ), + ): await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - except HomeAssistantError as err: - last_err = err - assert str(last_err) == ( - "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user" - " consumable" - ) async def test_lock_throws_yale_api_http_error(hass: HomeAssistant) -> None: @@ -119,16 +118,15 @@ async def test_lock_throws_yale_api_http_error(hass: HomeAssistant) -> None: "lock_return_activities": _lock_return_activities_side_effect }, ) - last_err = None data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} - try: + with pytest.raises( + HomeAssistantError, + match=( + "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user" + " consumable" + ), + ): await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) - except HomeAssistantError as err: - last_err = err - assert str(last_err) == ( - "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user" - " consumable" - ) async def test_open_throws_hass_service_not_supported_error( @@ -185,6 +183,7 @@ async def test_load_unload(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED async def test_load_triggers_ble_discovery( From 5825e8fee83ed362d3283106fae4a1d749bbb99c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Aug 2024 09:01:17 -1000 Subject: [PATCH 0007/1309] Redirect virtual integration yale_home to point to yale (#124817) --- homeassistant/components/yale_home/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yale_home/manifest.json b/homeassistant/components/yale_home/manifest.json index 0e45b0da7d0..c497fa3fe34 100644 --- a/homeassistant/components/yale_home/manifest.json +++ b/homeassistant/components/yale_home/manifest.json @@ -2,5 +2,5 @@ "domain": "yale_home", "name": "Yale Home", "integration_type": "virtual", - "supported_by": "august" + "supported_by": "yale" } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index aad03b25390..bb81b6a5b04 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7012,7 +7012,7 @@ "yale_home": { "integration_type": "virtual", "config_flow": false, - "supported_by": "august", + "supported_by": "yale", "name": "Yale Home" }, "yale": { From 2b20b2a80b8a007474052a00d9c0e9d9dd5b97ef Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 28 Aug 2024 21:10:49 +0200 Subject: [PATCH 0008/1309] Bump tellduslive to 0.10.12 (#124816) * Bump tellduslive version * update licenses.py too --- homeassistant/components/tellduslive/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/tellduslive/manifest.json b/homeassistant/components/tellduslive/manifest.json index 929d502971f..dc1389c15c5 100644 --- a/homeassistant/components/tellduslive/manifest.json +++ b/homeassistant/components/tellduslive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tellduslive", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["tellduslive==0.10.11"] + "requirements": ["tellduslive==0.10.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a9fb74c4dc..5081fe2fd36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2742,7 +2742,7 @@ tellcore-net==0.4 tellcore-py==1.1.2 # homeassistant.components.tellduslive -tellduslive==0.10.11 +tellduslive==0.10.12 # homeassistant.components.lg_soundbar temescal==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 920895f5a7b..5ec8e6cba40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2167,7 +2167,7 @@ systembridgemodels==4.2.4 tailscale==0.6.1 # homeassistant.components.tellduslive -tellduslive==0.10.11 +tellduslive==0.10.12 # homeassistant.components.lg_soundbar temescal==0.5 diff --git a/script/licenses.py b/script/licenses.py index ac9a836396c..84797372309 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -165,7 +165,6 @@ EXCEPTIONS = { "sensirion-ble", # https://github.com/akx/sensirion-ble/pull/9 "sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14 "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 - "tellduslive", # https://github.com/molobrakos/tellduslive/pull/24 "tellsticknet", # https://github.com/molobrakos/tellsticknet/pull/33 "vincenty", # Public domain "zeversolar", # https://github.com/kvanzuijlen/zeversolar/pull/46 From ada6b7875c2a7f4a54ba9dd93c70a93047d71874 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Wed, 28 Aug 2024 21:40:57 +0100 Subject: [PATCH 0009/1309] Add evohome test for setup (#123129) * allow for different systems * installation is a load_json_*fixture param * allow installation to be parameterized * test setup of various systems * add more fixtures * test setup of integration * tweak test * tweak const * add expected state/services * extend setup test * tidy up * tidy up tweaks * code tweaks * refactor expected results dicts * woops * refatcor serialize * refactor test * tweak * tweak code * rename symbol * ensure actual I/O remains blocked * tweak * typo * use constants * Update conftest.py Co-authored-by: Martin Hjelmare * change filename * add config fixture * config is a fixture * config is a fixture now 2 * lint * lint * refactor * lint * lint * restore email addr * use const * use snapshots instead of helper class * doctweak * correct snapshot --------- Co-authored-by: Martin Hjelmare --- tests/components/evohome/conftest.py | 126 ++- tests/components/evohome/const.py | 9 + .../fixtures/{ => default}/schedule_dhw.json | 0 .../fixtures/{ => default}/schedule_zone.json | 0 .../{ => default}/status_2738909.json | 0 .../fixtures/{ => default}/user_account.json | 0 .../{ => default}/user_locations.json | 4 +- .../fixtures/h032585/status_111111.json | 31 + .../fixtures/h032585/temperatures.json | 3 + .../fixtures/h032585/user_locations.json | 79 ++ .../fixtures/h099625/status_111111.json | 44 + .../fixtures/h099625/user_locations.json | 113 +++ .../fixtures/minimal/status_2738909.json | 28 + .../fixtures/minimal/user_locations.json | 120 +++ .../fixtures/system_004/status_3164610.json | 33 + .../fixtures/system_004/user_locations.json | 99 ++ .../evohome/snapshots/test_init.ambr | 863 ++++++++++++++++++ tests/components/evohome/test_init.py | 25 + tests/components/evohome/test_storage.py | 30 +- 19 files changed, 1549 insertions(+), 58 deletions(-) rename tests/components/evohome/fixtures/{ => default}/schedule_dhw.json (100%) rename tests/components/evohome/fixtures/{ => default}/schedule_zone.json (100%) rename tests/components/evohome/fixtures/{ => default}/status_2738909.json (100%) rename tests/components/evohome/fixtures/{ => default}/user_account.json (100%) rename tests/components/evohome/fixtures/{ => default}/user_locations.json (99%) create mode 100644 tests/components/evohome/fixtures/h032585/status_111111.json create mode 100644 tests/components/evohome/fixtures/h032585/temperatures.json create mode 100644 tests/components/evohome/fixtures/h032585/user_locations.json create mode 100644 tests/components/evohome/fixtures/h099625/status_111111.json create mode 100644 tests/components/evohome/fixtures/h099625/user_locations.json create mode 100644 tests/components/evohome/fixtures/minimal/status_2738909.json create mode 100644 tests/components/evohome/fixtures/minimal/user_locations.json create mode 100644 tests/components/evohome/fixtures/system_004/status_3164610.json create mode 100644 tests/components/evohome/fixtures/system_004/user_locations.json create mode 100644 tests/components/evohome/snapshots/test_init.ambr create mode 100644 tests/components/evohome/test_init.py diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 260330896b7..82c5cd76024 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -2,8 +2,10 @@ from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta -from typing import Any, Final +from http import HTTPMethod +from typing import Any from unittest.mock import MagicMock, patch from aiohttp import ClientSession @@ -16,75 +18,112 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.json import JsonArrayType, JsonObjectType -from .const import ACCESS_TOKEN, REFRESH_TOKEN +from .const import ACCESS_TOKEN, REFRESH_TOKEN, USERNAME from tests.common import load_json_array_fixture, load_json_object_fixture -TEST_CONFIG: Final = { - CONF_USERNAME: "username", - CONF_PASSWORD: "password", -} - -def user_account_config_fixture() -> JsonObjectType: +def user_account_config_fixture(install: str) -> JsonObjectType: """Load JSON for the config of a user's account.""" - return load_json_object_fixture("user_account.json", DOMAIN) + try: + return load_json_object_fixture(f"{install}/user_account.json", DOMAIN) + except FileNotFoundError: + return load_json_object_fixture("default/user_account.json", DOMAIN) -def user_locations_config_fixture() -> JsonArrayType: +def user_locations_config_fixture(install: str) -> JsonArrayType: """Load JSON for the config of a user's installation (a list of locations).""" - return load_json_array_fixture("user_locations.json", DOMAIN) + return load_json_array_fixture(f"{install}/user_locations.json", DOMAIN) -def location_status_fixture(loc_id: str) -> JsonObjectType: +def location_status_fixture(install: str, loc_id: str | None = None) -> JsonObjectType: """Load JSON for the status of a specific location.""" - return load_json_object_fixture(f"status_{loc_id}.json", DOMAIN) + if loc_id is None: + _install = load_json_array_fixture(f"{install}/user_locations.json", DOMAIN) + loc_id = _install[0]["locationInfo"]["locationId"] # type: ignore[assignment, call-overload, index] + return load_json_object_fixture(f"{install}/status_{loc_id}.json", DOMAIN) -def dhw_schedule_fixture() -> JsonObjectType: +def dhw_schedule_fixture(install: str) -> JsonObjectType: """Load JSON for the schedule of a domesticHotWater zone.""" - return load_json_object_fixture("schedule_dhw.json", DOMAIN) + try: + return load_json_object_fixture(f"{install}/schedule_dhw.json", DOMAIN) + except FileNotFoundError: + return load_json_object_fixture("default/schedule_dhw.json", DOMAIN) -def zone_schedule_fixture() -> JsonObjectType: +def zone_schedule_fixture(install: str) -> JsonObjectType: """Load JSON for the schedule of a temperatureZone zone.""" - return load_json_object_fixture("schedule_zone.json", DOMAIN) + try: + return load_json_object_fixture(f"{install}/schedule_zone.json", DOMAIN) + except FileNotFoundError: + return load_json_object_fixture("default/schedule_zone.json", DOMAIN) -async def mock_get( - self: Broker, url: str, **kwargs: Any -) -> JsonArrayType | JsonObjectType: - """Return the JSON for a HTTP get of a given URL.""" +def mock_get_factory(install: str) -> Callable: + """Return a get method for a specified installation.""" - # a proxy for the behaviour of the real web API - if self.refresh_token is None: - self.refresh_token = f"new_{REFRESH_TOKEN}" + async def mock_get( + self: Broker, url: str, **kwargs: Any + ) -> JsonArrayType | JsonObjectType: + """Return the JSON for a HTTP get of a given URL.""" - if self.access_token_expires is None or self.access_token_expires < datetime.now(): - self.access_token = f"new_{ACCESS_TOKEN}" - self.access_token_expires = datetime.now() + timedelta(minutes=30) + # a proxy for the behaviour of the real web API + if self.refresh_token is None: + self.refresh_token = f"new_{REFRESH_TOKEN}" - # assume a valid GET, and return the JSON for that web API - if url == "userAccount": # userAccount - return user_account_config_fixture() + if ( + self.access_token_expires is None + or self.access_token_expires < datetime.now() + ): + self.access_token = f"new_{ACCESS_TOKEN}" + self.access_token_expires = datetime.now() + timedelta(minutes=30) - if url.startswith("location"): - if "installationInfo" in url: # location/installationInfo?userId={id} - return user_locations_config_fixture() - if "location" in url: # location/{id}/status - return location_status_fixture("2738909") + # assume a valid GET, and return the JSON for that web API + if url == "userAccount": # userAccount + return user_account_config_fixture(install) - elif "schedule" in url: - if url.startswith("domesticHotWater"): # domesticHotWater/{id}/schedule - return dhw_schedule_fixture() - if url.startswith("temperatureZone"): # temperatureZone/{id}/schedule - return zone_schedule_fixture() + if url.startswith("location"): + if "installationInfo" in url: # location/installationInfo?userId={id} + return user_locations_config_fixture(install) + if "location" in url: # location/{id}/status + return location_status_fixture(install) - pytest.xfail(f"Unexpected URL: {url}") + elif "schedule" in url: + if url.startswith("domesticHotWater"): # domesticHotWater/{id}/schedule + return dhw_schedule_fixture(install) + if url.startswith("temperatureZone"): # temperatureZone/{id}/schedule + return zone_schedule_fixture(install) + + pytest.fail(f"Unexpected request: {HTTPMethod.GET} {url}") + + return mock_get -@patch("evohomeasync2.broker.Broker.get", mock_get) -async def setup_evohome(hass: HomeAssistant, test_config: dict[str, str]) -> MagicMock: +async def block_request( + self: Broker, method: HTTPMethod, url: str, **kwargs: Any +) -> None: + """Fail if the code attempts any actual I/O via aiohttp.""" + + pytest.fail(f"Unexpected request: {method} {url}") + + +@pytest.fixture +def evo_config() -> dict[str, str]: + "Return a default/minimal configuration." + return { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: "password", + } + + +@patch("evohomeasync.broker.Broker._make_request", block_request) +@patch("evohomeasync2.broker.Broker._client", block_request) +async def setup_evohome( + hass: HomeAssistant, + test_config: dict[str, str], + install: str = "default", +) -> MagicMock: """Set up the evohome integration and return its client. The class is mocked here to check the client was instantiated with the correct args. @@ -93,6 +132,7 @@ async def setup_evohome(hass: HomeAssistant, test_config: dict[str, str]) -> Mag with ( patch("homeassistant.components.evohome.evo.EvohomeClient") as mock_client, patch("homeassistant.components.evohome.ev1.EvohomeClient", return_value=None), + patch("evohomeasync2.broker.Broker.get", mock_get_factory(install)), ): mock_client.side_effect = EvohomeClient diff --git a/tests/components/evohome/const.py b/tests/components/evohome/const.py index 0b298db533a..c25a259e602 100644 --- a/tests/components/evohome/const.py +++ b/tests/components/evohome/const.py @@ -8,3 +8,12 @@ ACCESS_TOKEN: Final = "at_1dc7z657UKzbhKA..." REFRESH_TOKEN: Final = "rf_jg68ZCKYdxEI3fF..." SESSION_ID: Final = "F7181186..." USERNAME: Final = "test_user@gmail.com" + +# The h-numbers refer to issues in HA's core repo +TEST_INSTALLS: Final = ( + "minimal", # evohome (single zone, no DHW) + "default", # evohome (multi-zone, with DHW & ghost zones) + "h032585", # VisionProWifi (no preset_mode for TCS) + "h099625", # RoundThermostat + "system_004", # RoundModulation +) diff --git a/tests/components/evohome/fixtures/schedule_dhw.json b/tests/components/evohome/fixtures/default/schedule_dhw.json similarity index 100% rename from tests/components/evohome/fixtures/schedule_dhw.json rename to tests/components/evohome/fixtures/default/schedule_dhw.json diff --git a/tests/components/evohome/fixtures/schedule_zone.json b/tests/components/evohome/fixtures/default/schedule_zone.json similarity index 100% rename from tests/components/evohome/fixtures/schedule_zone.json rename to tests/components/evohome/fixtures/default/schedule_zone.json diff --git a/tests/components/evohome/fixtures/status_2738909.json b/tests/components/evohome/fixtures/default/status_2738909.json similarity index 100% rename from tests/components/evohome/fixtures/status_2738909.json rename to tests/components/evohome/fixtures/default/status_2738909.json diff --git a/tests/components/evohome/fixtures/user_account.json b/tests/components/evohome/fixtures/default/user_account.json similarity index 100% rename from tests/components/evohome/fixtures/user_account.json rename to tests/components/evohome/fixtures/default/user_account.json diff --git a/tests/components/evohome/fixtures/user_locations.json b/tests/components/evohome/fixtures/default/user_locations.json similarity index 99% rename from tests/components/evohome/fixtures/user_locations.json rename to tests/components/evohome/fixtures/default/user_locations.json index cf59aa9ae8a..f2f4091a2dc 100644 --- a/tests/components/evohome/fixtures/user_locations.json +++ b/tests/components/evohome/fixtures/default/user_locations.json @@ -246,7 +246,7 @@ }, { "zoneId": "3450733", - "modelType": "xx", + "modelType": "xxx", "setpointCapabilities": { "maxHeatSetpoint": 35.0, "minHeatSetpoint": 5.0, @@ -268,7 +268,7 @@ "setpointValueResolution": 0.5 }, "name": "Spare Room", - "zoneType": "xx" + "zoneType": "xxx" } ], "dhw": { diff --git a/tests/components/evohome/fixtures/h032585/status_111111.json b/tests/components/evohome/fixtures/h032585/status_111111.json new file mode 100644 index 00000000000..0ea535c2461 --- /dev/null +++ b/tests/components/evohome/fixtures/h032585/status_111111.json @@ -0,0 +1,31 @@ +{ + "locationId": "111111", + "gateways": [ + { + "gatewayId": "222222", + "temperatureControlSystems": [ + { + "systemId": "416856", + "zones": [ + { + "zoneId": "416856", + "temperatureStatus": { + "temperature": 21.5, + "isAvailable": true + }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 21.5, + "setpointMode": "FollowSchedule" + }, + "name": "THERMOSTAT" + } + ], + "activeFaults": [], + "systemModeStatus": { "mode": "Heat", "isPermanent": true } + } + ], + "activeFaults": [] + } + ] +} diff --git a/tests/components/evohome/fixtures/h032585/temperatures.json b/tests/components/evohome/fixtures/h032585/temperatures.json new file mode 100644 index 00000000000..a2015c94f46 --- /dev/null +++ b/tests/components/evohome/fixtures/h032585/temperatures.json @@ -0,0 +1,3 @@ +{ + "416856": 21.5 +} diff --git a/tests/components/evohome/fixtures/h032585/user_locations.json b/tests/components/evohome/fixtures/h032585/user_locations.json new file mode 100644 index 00000000000..b4ea2e5c420 --- /dev/null +++ b/tests/components/evohome/fixtures/h032585/user_locations.json @@ -0,0 +1,79 @@ +[ + { + "locationInfo": { + "locationId": "111111", + "name": "My Home", + "timeZone": { + "timeZoneId": "GMTStandardTime", + "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", + "offsetMinutes": 0, + "currentOffsetMinutes": 60, + "supportsDaylightSaving": true + } + }, + "gateways": [ + { + "gatewayInfo": { + "gatewayId": "222222", + "mac": "00D02DEE0000", + "crc": "1234", + "isWiFi": false + }, + "temperatureControlSystems": [ + { + "systemId": "416856", + "modelType": "VisionProWifiRetail", + "zones": [ + { + "zoneId": "416856", + "modelType": "VisionProWifiRetail", + "setpointCapabilities": { + "vacationHoldCapabilities": { + "isChangeable": true, + "isCancelable": true, + "minDuration": "1.00:00:00", + "maxDuration": "365.23:45:00", + "timingResolution": "00:15:00" + }, + "maxHeatSetpoint": 32.0, + "minHeatSetpoint": 4.5, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride", + "VacationHold" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:15:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 4, + "minSwitchpointsPerDay": 0, + "timingResolution": "00:15:00", + "setpointValueResolution": 0.5 + }, + "name": "THERMOSTAT", + "zoneType": "Thermostat" + } + ], + "allowedSystemModes": [ + { + "systemMode": "Off", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "Heat", + "canBePermanent": true, + "canBeTemporary": false + } + ] + } + ] + } + ] + } +] diff --git a/tests/components/evohome/fixtures/h099625/status_111111.json b/tests/components/evohome/fixtures/h099625/status_111111.json new file mode 100644 index 00000000000..149d8aba783 --- /dev/null +++ b/tests/components/evohome/fixtures/h099625/status_111111.json @@ -0,0 +1,44 @@ +{ + "locationId": "111111", + "gateways": [ + { + "gatewayId": "222222", + "temperatureControlSystems": [ + { + "systemId": "8557535", + "zones": [ + { + "zoneId": "8557539", + "temperatureStatus": { + "temperature": 21.5, + "isAvailable": true + }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 21.5, + "setpointMode": "FollowSchedule" + }, + "name": "THERMOSTAT" + }, + { + "zoneId": "8557541", + "temperatureStatus": { + "temperature": 21.5, + "isAvailable": true + }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 21.5, + "setpointMode": "FollowSchedule" + }, + "name": "THERMOSTAT" + } + ], + "activeFaults": [], + "systemModeStatus": { "mode": "Auto", "isPermanent": true } + } + ], + "activeFaults": [] + } + ] +} diff --git a/tests/components/evohome/fixtures/h099625/user_locations.json b/tests/components/evohome/fixtures/h099625/user_locations.json new file mode 100644 index 00000000000..cc32caccc73 --- /dev/null +++ b/tests/components/evohome/fixtures/h099625/user_locations.json @@ -0,0 +1,113 @@ +[ + { + "locationInfo": { + "locationId": "111111", + "name": "My Home", + "timeZone": { + "timeZoneId": "FLEStandardTime", + "displayName": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius", + "offsetMinutes": 120, + "currentOffsetMinutes": 180, + "supportsDaylightSaving": true + } + }, + "gateways": [ + { + "gatewayInfo": { + "gatewayId": "222222", + "mac": "00D02DEE0000", + "crc": "1234", + "isWiFi": false + }, + "temperatureControlSystems": [ + { + "systemId": "8557535", + "modelType": "EvoTouch", + "zones": [ + { + "zoneId": "8557539", + "modelType": "RoundWireless", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 0, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat", + "zoneType": "Thermostat" + }, + { + "zoneId": "8557541", + "modelType": "RoundWireless", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 0, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat 2", + "zoneType": "Thermostat" + } + ], + "allowedSystemModes": [ + { + "systemMode": "Auto", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "AutoWithEco", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "1.00:00:00", + "timingResolution": "01:00:00", + "timingMode": "Duration" + }, + { + "systemMode": "Away", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + }, + { + "systemMode": "HeatingOff", + "canBePermanent": true, + "canBeTemporary": false + } + ] + } + ] + } + ] + } +] diff --git a/tests/components/evohome/fixtures/minimal/status_2738909.json b/tests/components/evohome/fixtures/minimal/status_2738909.json new file mode 100644 index 00000000000..4b344314a67 --- /dev/null +++ b/tests/components/evohome/fixtures/minimal/status_2738909.json @@ -0,0 +1,28 @@ +{ + "locationId": "2738909", + "gateways": [ + { + "gatewayId": "2499896", + "temperatureControlSystems": [ + { + "systemId": "3432522", + "zones": [ + { + "zoneId": "3432576", + "name": "Main Room", + "temperatureStatus": { "temperature": 19.0, "isAvailable": true }, + "setpointStatus": { + "targetHeatTemperature": 17.0, + "setpointMode": "FollowSchedule" + }, + "activeFaults": [] + } + ], + "activeFaults": [], + "systemModeStatus": { "mode": "AutoWithEco", "isPermanent": true } + } + ], + "activeFaults": [] + } + ] +} diff --git a/tests/components/evohome/fixtures/minimal/user_locations.json b/tests/components/evohome/fixtures/minimal/user_locations.json new file mode 100644 index 00000000000..932686d8728 --- /dev/null +++ b/tests/components/evohome/fixtures/minimal/user_locations.json @@ -0,0 +1,120 @@ +[ + { + "locationInfo": { + "locationId": "2738909", + "name": "My Home", + "streetAddress": "1 Main Street", + "city": "London", + "country": "UnitedKingdom", + "postcode": "E1 1AA", + "locationType": "Residential", + "useDaylightSaveSwitching": true, + "timeZone": { + "timeZoneId": "GMTStandardTime", + "displayName": "(UTC+00:00) Dublin, Edinburgh, Lisbon, London", + "offsetMinutes": 0, + "currentOffsetMinutes": 60, + "supportsDaylightSaving": true + }, + "locationOwner": { + "userId": "2263181", + "username": "user_2263181@gmail.com", + "firstname": "John", + "lastname": "Smith" + } + }, + "gateways": [ + { + "gatewayInfo": { + "gatewayId": "2499896", + "mac": "00D02DEE0000", + "crc": "1234", + "isWiFi": false + }, + "temperatureControlSystems": [ + { + "systemId": "3432522", + "modelType": "EvoTouch", + "zones": [ + { + "zoneId": "3432576", + "modelType": "HeatingZone", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 1, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Main Room", + "zoneType": "RadiatorZone" + } + ], + "allowedSystemModes": [ + { + "systemMode": "HeatingOff", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "Auto", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "AutoWithReset", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "AutoWithEco", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "1.00:00:00", + "timingResolution": "01:00:00", + "timingMode": "Duration" + }, + { + "systemMode": "Away", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + }, + { + "systemMode": "DayOff", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + }, + { + "systemMode": "Custom", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + } + ] + } + ] + } + ] + } +] diff --git a/tests/components/evohome/fixtures/system_004/status_3164610.json b/tests/components/evohome/fixtures/system_004/status_3164610.json new file mode 100644 index 00000000000..a9ef3f6ee28 --- /dev/null +++ b/tests/components/evohome/fixtures/system_004/status_3164610.json @@ -0,0 +1,33 @@ +{ + "locationId": "3164610", + "gateways": [ + { + "gatewayId": "2938388", + "temperatureControlSystems": [ + { + "systemId": "4187769", + "zones": [ + { + "zoneId": "4187768", + "temperatureStatus": { "temperature": 19.5, "isAvailable": true }, + "activeFaults": [], + "setpointStatus": { + "targetHeatTemperature": 15.0, + "setpointMode": "PermanentOverride" + }, + "name": "Thermostat" + } + ], + "activeFaults": [], + "systemModeStatus": { "mode": "Auto", "isPermanent": true } + } + ], + "activeFaults": [ + { + "faultType": "GatewayCommunicationLost", + "since": "2023-05-04T18:47:36.7727046" + } + ] + } + ] +} diff --git a/tests/components/evohome/fixtures/system_004/user_locations.json b/tests/components/evohome/fixtures/system_004/user_locations.json new file mode 100644 index 00000000000..9defab8b6ee --- /dev/null +++ b/tests/components/evohome/fixtures/system_004/user_locations.json @@ -0,0 +1,99 @@ +[ + { + "locationInfo": { + "locationId": "3164610", + "name": "Living room", + "streetAddress": "1 Main Road", + "city": "Boomtown", + "country": "Netherlands", + "postcode": "1234XX", + "locationType": "Residential", + "useDaylightSaveSwitching": true, + "timeZone": { + "timeZoneId": "WEuropeStandardTime", + "displayName": "(UTC+01:00) Amsterdam, Berlijn, Bern, Rome, Stockholm, Wenen", + "offsetMinutes": 60, + "currentOffsetMinutes": 120, + "supportsDaylightSaving": true + }, + "locationOwner": { + "userId": "2624305", + "username": "user_2624305@gmail.com", + "firstname": "Chris", + "lastname": "Jones" + } + }, + "gateways": [ + { + "gatewayInfo": { + "gatewayId": "2938388", + "mac": "00D02D5A7000", + "crc": "1234", + "isWiFi": false + }, + "temperatureControlSystems": [ + { + "systemId": "4187769", + "modelType": "EvoTouch", + "zones": [ + { + "zoneId": "4187768", + "modelType": "RoundModulation", + "setpointCapabilities": { + "maxHeatSetpoint": 35.0, + "minHeatSetpoint": 5.0, + "valueResolution": 0.5, + "canControlHeat": true, + "canControlCool": false, + "allowedSetpointModes": [ + "PermanentOverride", + "FollowSchedule", + "TemporaryOverride" + ], + "maxDuration": "1.00:00:00", + "timingResolution": "00:10:00" + }, + "scheduleCapabilities": { + "maxSwitchpointsPerDay": 6, + "minSwitchpointsPerDay": 0, + "timingResolution": "00:10:00", + "setpointValueResolution": 0.5 + }, + "name": "Thermostat", + "zoneType": "Thermostat" + } + ], + "allowedSystemModes": [ + { + "systemMode": "Auto", + "canBePermanent": true, + "canBeTemporary": false + }, + { + "systemMode": "AutoWithEco", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "1.00:00:00", + "timingResolution": "01:00:00", + "timingMode": "Duration" + }, + { + "systemMode": "Away", + "canBePermanent": true, + "canBeTemporary": true, + "maxDuration": "99.00:00:00", + "timingResolution": "1.00:00:00", + "timingMode": "Period" + }, + { + "systemMode": "HeatingOff", + "canBePermanent": true, + "canBeTemporary": false + } + ] + } + ] + } + ] + } +] diff --git a/tests/components/evohome/snapshots/test_init.ambr b/tests/components/evohome/snapshots/test_init.ambr new file mode 100644 index 00000000000..210c45354a5 --- /dev/null +++ b/tests/components/evohome/snapshots/test_init.ambr @@ -0,0 +1,863 @@ +# serializer version: 1 +# name: test_entities[default] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.7, + 'friendly_name': 'My Home', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:thermostat', + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': 'eco', + 'preset_modes': list([ + 'Reset', + 'eco', + 'away', + 'home', + 'Custom', + ]), + 'status': dict({ + 'active_system_faults': list([ + ]), + 'system_id': '3432522', + 'system_mode_status': dict({ + 'is_permanent': True, + 'mode': 'AutoWithEco', + }), + }), + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.my_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Dead Zone', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 17.0, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T23:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T15:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': False, + }), + 'zone_id': '3432521', + }), + 'supported_features': , + 'temperature': 17.0, + }), + 'context': , + 'entity_id': 'climate.dead_zone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Main Room', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'permanent', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + dict({ + 'faultType': 'TempZoneActuatorCommunicationLost', + 'since': '2022-03-02T15:56:01', + }), + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'PermanentOverride', + 'target_heat_temperature': 17.0, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T23:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T15:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 19.0, + }), + 'zone_id': '3432576', + }), + 'supported_features': , + 'temperature': 17.0, + }), + 'context': , + 'entity_id': 'climate.main_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Front Room', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'temporary', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + dict({ + 'faultType': 'TempZoneActuatorLowBattery', + 'since': '2022-03-02T04:50:20', + }), + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'TemporaryOverride', + 'target_heat_temperature': 21.0, + 'until': '2022-03-07T11:00:00-08:00', + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T23:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T15:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 19.0, + }), + 'zone_id': '3432577', + }), + 'supported_features': , + 'temperature': 21.0, + }), + 'context': , + 'entity_id': 'climate.front_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.0, + 'friendly_name': 'Kitchen', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 17.0, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T23:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T15:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 20.0, + }), + 'zone_id': '3432578', + }), + 'supported_features': , + 'temperature': 17.0, + }), + 'context': , + 'entity_id': 'climate.kitchen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20.0, + 'friendly_name': 'Bathroom Dn', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 16.0, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T23:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T15:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 20.0, + }), + 'zone_id': '3432579', + }), + 'supported_features': , + 'temperature': 16.0, + }), + 'context': , + 'entity_id': 'climate.bathroom_dn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.0, + 'friendly_name': 'Main Bedroom', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 16.0, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T23:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T15:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 21.0, + }), + 'zone_id': '3432580', + }), + 'supported_features': , + 'temperature': 16.0, + }), + 'context': , + 'entity_id': 'climate.main_bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.5, + 'friendly_name': 'Kids Room', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 17.0, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T23:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T15:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 19.5, + }), + 'zone_id': '3449703', + }), + 'supported_features': , + 'temperature': 17.0, + }), + 'context': , + 'entity_id': 'climate.kids_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'on', + 'current_temperature': 23, + 'friendly_name': 'Domestic Hot Water', + 'icon': 'mdi:thermometer-lines', + 'max_temp': 60, + 'min_temp': 43, + 'operation_list': list([ + 'auto', + 'on', + 'off', + ]), + 'operation_mode': 'off', + 'status': dict({ + 'active_faults': list([ + ]), + 'dhw_id': '3933910', + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T22:30:00-07:00', + 'next_sp_state': 'On', + 'this_sp_from': '2024-08-14T14:30:00-07:00', + 'this_sp_state': 'Off', + }), + 'state_status': dict({ + 'mode': 'PermanentOverride', + 'state': 'Off', + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 23.0, + }), + }), + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': None, + }), + 'context': , + 'entity_id': 'water_heater.domestic_hot_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_entities[h032585] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'My Home', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:thermostat', + 'max_temp': 35, + 'min_temp': 7, + 'status': dict({ + 'active_system_faults': list([ + ]), + 'system_id': '416856', + 'system_mode_status': dict({ + 'is_permanent': True, + 'mode': 'Heat', + }), + }), + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.my_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'THERMOSTAT', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32.0, + 'min_temp': 4.5, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 21.5, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T23:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T15:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 21.5, + }), + 'zone_id': '416856', + }), + 'supported_features': , + 'temperature': 21.5, + }), + 'context': , + 'entity_id': 'climate.thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + ]) +# --- +# name: test_entities[h099625] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'My Home', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:thermostat', + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'away', + ]), + 'status': dict({ + 'active_system_faults': list([ + ]), + 'system_id': '8557535', + 'system_mode_status': dict({ + 'is_permanent': True, + 'mode': 'Auto', + }), + }), + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.my_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'THERMOSTAT', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 21.5, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T21:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T13:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 21.5, + }), + 'zone_id': '8557539', + }), + 'supported_features': , + 'temperature': 21.5, + }), + 'context': , + 'entity_id': 'climate.thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'THERMOSTAT', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 21.5, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T21:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T13:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 21.5, + }), + 'zone_id': '8557541', + }), + 'supported_features': , + 'temperature': 21.5, + }), + 'context': , + 'entity_id': 'climate.thermostat_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + ]) +# --- +# name: test_entities[h118169] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'My Home', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:thermostat', + 'max_temp': 35, + 'min_temp': 7, + 'status': dict({ + 'active_system_faults': list([ + ]), + 'system_id': '333333', + 'system_mode_status': dict({ + 'is_permanent': True, + 'mode': 'Heat', + }), + }), + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.my_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'THERMOSTAT', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 32.0, + 'min_temp': 4.5, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 21.5, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T23:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T15:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 21.5, + }), + 'zone_id': '444444', + }), + 'supported_features': , + 'temperature': 21.5, + }), + 'context': , + 'entity_id': 'climate.thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + ]) +# --- +# name: test_entities[minimal] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'My Home', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:thermostat', + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': 'eco', + 'preset_modes': list([ + 'Reset', + 'eco', + 'away', + 'home', + 'Custom', + ]), + 'status': dict({ + 'active_system_faults': list([ + ]), + 'system_id': '3432522', + 'system_mode_status': dict({ + 'is_permanent': True, + 'mode': 'AutoWithEco', + }), + }), + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.my_home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.0, + 'friendly_name': 'Main Room', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'FollowSchedule', + 'target_heat_temperature': 17.0, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T23:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T15:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 19.0, + }), + 'zone_id': '3432576', + }), + 'supported_features': , + 'temperature': 17.0, + }), + 'context': , + 'entity_id': 'climate.main_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + ]) +# --- +# name: test_entities[system_004] + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.5, + 'friendly_name': 'Living room', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:thermostat', + 'max_temp': 35, + 'min_temp': 7, + 'preset_mode': None, + 'preset_modes': list([ + 'eco', + 'away', + ]), + 'status': dict({ + 'active_system_faults': list([ + ]), + 'system_id': '4187769', + 'system_mode_status': dict({ + 'is_permanent': True, + 'mode': 'Auto', + }), + }), + 'supported_features': , + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 19.5, + 'friendly_name': 'Thermostat', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'preset_mode': 'permanent', + 'preset_modes': list([ + 'none', + 'temporary', + 'permanent', + ]), + 'status': dict({ + 'active_faults': list([ + ]), + 'setpoint_status': dict({ + 'setpoint_mode': 'PermanentOverride', + 'target_heat_temperature': 15.0, + }), + 'setpoints': dict({ + 'next_sp_from': '2024-08-14T22:00:00-07:00', + 'next_sp_temp': 18.1, + 'this_sp_from': '2024-08-14T14:00:00-07:00', + 'this_sp_temp': 15.9, + }), + 'temperature_status': dict({ + 'is_available': True, + 'temperature': 19.5, + }), + 'zone_id': '4187768', + }), + 'supported_features': , + 'temperature': 15.0, + }), + 'context': , + 'entity_id': 'climate.thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }), + ]) +# --- diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py new file mode 100644 index 00000000000..b717b19e6cd --- /dev/null +++ b/tests/components/evohome/test_init.py @@ -0,0 +1,25 @@ +"""The tests for evohome.""" + +from __future__ import annotations + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from .conftest import setup_evohome +from .const import TEST_INSTALLS + + +@pytest.mark.parametrize("install", TEST_INSTALLS) +async def test_entities( + hass: HomeAssistant, + evo_config: dict[str, str], + install: str, + snapshot: SnapshotAssertion, +) -> None: + """Test entities and state after setup of a Honeywell TCC-compatible system.""" + + await setup_evohome(hass, evo_config, install=install) + + assert hass.states.async_all() == snapshot diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index e87b847a9ff..32cd49a1539 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -8,7 +8,6 @@ from typing import Any, Final, NotRequired, TypedDict import pytest from homeassistant.components.evohome import ( - CONF_PASSWORD, CONF_USERNAME, DOMAIN, STORAGE_KEY, @@ -56,11 +55,6 @@ ACCESS_TOKEN_EXP_DTM, ACCESS_TOKEN_EXP_STR = dt_pair(dt_util.now() + timedelta(h USERNAME_DIFF: Final = f"not_{USERNAME}" USERNAME_SAME: Final = USERNAME -TEST_CONFIG: Final = { - CONF_USERNAME: USERNAME_SAME, - CONF_PASSWORD: "password", -} - TEST_DATA: Final[dict[str, _TokenStoreT]] = { "sans_session_id": { SZ_USERNAME: USERNAME_SAME, @@ -93,13 +87,14 @@ DOMAIN_STORAGE_BASE: Final = { async def test_auth_tokens_null( hass: HomeAssistant, hass_storage: dict[str, Any], + evo_config: dict[str, str], idx: str, ) -> None: """Test loading/saving authentication tokens when no cached tokens in the store.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA_NULL[idx]} - mock_client = await setup_evohome(hass, TEST_CONFIG) + mock_client = await setup_evohome(hass, evo_config, install="minimal") # Confirm client was instantiated without tokens, as cache was empty... assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs @@ -120,13 +115,16 @@ async def test_auth_tokens_null( @pytest.mark.parametrize("idx", TEST_DATA) async def test_auth_tokens_same( - hass: HomeAssistant, hass_storage: dict[str, Any], idx: str + hass: HomeAssistant, + hass_storage: dict[str, Any], + evo_config: dict[str, str], + idx: str, ) -> None: """Test loading/saving authentication tokens when matching username.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA[idx]} - mock_client = await setup_evohome(hass, TEST_CONFIG) + mock_client = await setup_evohome(hass, evo_config, install="minimal") # Confirm client was instantiated with the cached tokens... assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN @@ -146,7 +144,10 @@ async def test_auth_tokens_same( @pytest.mark.parametrize("idx", TEST_DATA) async def test_auth_tokens_past( - hass: HomeAssistant, hass_storage: dict[str, Any], idx: str + hass: HomeAssistant, + hass_storage: dict[str, Any], + evo_config: dict[str, str], + idx: str, ) -> None: """Test loading/saving authentication tokens with matching username, but expired.""" @@ -158,7 +159,7 @@ async def test_auth_tokens_past( hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data} - mock_client = await setup_evohome(hass, TEST_CONFIG) + mock_client = await setup_evohome(hass, evo_config, install="minimal") # Confirm client was instantiated with the cached tokens... assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN @@ -181,14 +182,17 @@ async def test_auth_tokens_past( @pytest.mark.parametrize("idx", TEST_DATA) async def test_auth_tokens_diff( - hass: HomeAssistant, hass_storage: dict[str, Any], idx: str + hass: HomeAssistant, + hass_storage: dict[str, Any], + evo_config: dict[str, str], + idx: str, ) -> None: """Test loading/saving authentication tokens when unmatched username.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA[idx]} mock_client = await setup_evohome( - hass, TEST_CONFIG | {CONF_USERNAME: USERNAME_DIFF} + hass, evo_config | {CONF_USERNAME: USERNAME_DIFF}, install="minimal" ) # Confirm client was instantiated without tokens, as username was different... From c7cfd56b720be8212af2686ecfa5b8cad6ee299b Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 29 Aug 2024 00:01:53 +0200 Subject: [PATCH 0010/1309] Support Z-Wave JS dimming lights using color intensity (#122639) * Z-Wave JS: support non-dimmable color lights * remove black_is_off light, support on/off/color * fix: tests for on/off light * fix: typo * remove commented out old test code * add test for off and on * support colored lights without separate brightness control * add test for color-only light * refactor: extract color only light * fix: preserve color when changing brightness * extend tests * refactor again * refactor scale check * refactor: remove impossible check * review feedback * review feedback --------- Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 55 +- homeassistant/components/zwave_js/light.py | 281 +++++-- tests/components/zwave_js/test_light.py | 752 ++++++++++++------ 3 files changed, 736 insertions(+), 352 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 6e750ee8b2d..6de5a56dc33 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -238,6 +238,12 @@ 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}, @@ -762,33 +768,6 @@ 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 @@ -1014,10 +993,11 @@ DISCOVERY_SCHEMAS = [ ), entity_category=EntityCategory.CONFIG, ), - # binary switches + # binary switches without color support ZWaveDiscoverySchema( platform=Platform.SWITCH, primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, + absent_values=[COLOR_SWITCH_CURRENT_VALUE_SCHEMA], ), # switch for Indicator CC ZWaveDiscoverySchema( @@ -1111,6 +1091,25 @@ 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 020f1b66b3d..4a044ca3f52 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 == "black_is_off": - async_add_entities([ZwaveBlackIsOffLight(config_entry, driver, info)]) + if info.platform_hint == "color_onoff": + async_add_entities([ZwaveColorOnOffLight(config_entry, driver, info)]) else: async_add_entities([ZwaveLight(config_entry, driver, info)]) @@ -111,9 +111,10 @@ 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 @@ -129,15 +130,28 @@ 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 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: + 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 self._attr_name = self.generate_name( include_value_name=True, alternate_value_name="Basic" ) @@ -146,6 +160,13 @@ 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, @@ -216,7 +237,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): @property def rgbw_color(self) -> tuple[int, int, int, int] | None: - """Return the hs color.""" + """Return the RGBW color.""" return self._rgbw_color @property @@ -243,11 +264,39 @@ 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, @@ -257,10 +306,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # turn of white leds when setting rgb colors[ColorComponent.WARM_WHITE] = 0 colors[ColorComponent.COLD_WHITE] = 0 - await self._async_set_colors(colors, transition) + return colors # 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( @@ -275,20 +323,18 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): ), ) warm = 255 - cold - 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, - ) + 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 # RGBW - rgbw = kwargs.get(ATTR_RGBW_COLOR) if rgbw is not None and self._supports_rgbw: rgbw_channels = { ColorComponent.RED: rgbw[0], @@ -300,17 +346,15 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self._cold_white: rgbw_channels[ColorComponent.COLD_WHITE] = rgbw[3] - await self._async_set_colors(rgbw_channels, transition) - # set brightness - await self._async_set_brightness(kwargs.get(ATTR_BRIGHTNESS), transition) + return rgbw_channels - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the light off.""" - await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) + return None 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 @@ -361,9 +405,14 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): zwave_transition = {TRANSITION_DURATION_OPTION: "default"} # setting a value requires setting targetValue - await self._async_set_value( - self._target_brightness, zwave_brightness, zwave_transition - ) + 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 + ) # 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. @@ -427,15 +476,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): """Calculate light colors.""" (red_val, green_val, blue_val, ww_val, cw_val) = self._get_color_values() - # 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 + if self._current_color and isinstance(self._current_color.value, dict): + multi_color = self._current_color.value else: multi_color = {} @@ -486,11 +528,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._color_mode = ColorMode.RGBW -class ZwaveBlackIsOffLight(ZwaveLight): - """Representation of a Z-Wave light where setting the color to black turns it off. +class ZwaveColorOnOffLight(ZwaveLight): + """Representation of a colored Z-Wave light with an optional binary switch to turn on/off. - Currently only supports lights with RGB, no color temperature, and no white - channels. + Dimming for RGB lights is realized by scaling the color channels. """ def __init__( @@ -499,61 +540,137 @@ class ZwaveBlackIsOffLight(ZwaveLight): """Initialize the light.""" super().__init__(config_entry, driver, info) - self._last_color: dict[str, int] | None = None - self._supported_color_modes.discard(ColorMode.BRIGHTNESS) + self._last_on_color: dict[ColorComponent, int] | None = None + self._last_brightness: int | None = None @property - def brightness(self) -> int: - """Return the brightness of this light between 0..255.""" - return 255 + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255. - @property - def is_on(self) -> bool | None: - """Return true if device is on (brightness above 0).""" + Z-Wave multilevel switches use a range of [0, 99] to control brightness. + """ if self.info.primary_value.value is None: return None - return any(value != 0 for value in self.info.primary_value.value.values()) + 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 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) - # 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( - { + 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 = { ColorComponent.RED: 255, ColorComponent.GREEN: 255, ColorComponent.BLUE: 255, - }, - transition, + } + 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 ) + 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.""" - self._last_color = self.info.primary_value.value - await self._async_set_colors( - { + + # 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 = { ColorComponent.RED: 0, ColorComponent.GREEN: 0, ColorComponent.BLUE: 0, - }, - kwargs.get(ATTR_TRANSITION), - ) - await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) + } + + await self._async_set_colors( + colors, + kwargs.get(ATTR_TRANSITION), + ) diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 376bd700a2a..4c725c6dc29 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -8,6 +8,7 @@ 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, @@ -37,8 +38,8 @@ from .common import ( ZEN_31_ENTITY, ) -HSM200_V1_ENTITY = "light.hsm200" ZDB5100_ENTITY = "light.matrix_office" +HSM200_V1_ENTITY = "light.hsm200" async def test_light( @@ -510,14 +511,388 @@ async def test_light_none_color_value( assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["hs"] -async def test_black_is_off( +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( hass: HomeAssistant, client, express_controls_ezmultipli, integration ) -> None: - """Test the black is off light entity.""" + """Test the light entity for RGB lights with Color Switch CC only.""" 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, @@ -539,64 +914,14 @@ async def test_black_is_off( 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": 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() + await update_color(0, 0, 0) + state = hass.states.get(HSM200_V1_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": 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() + # 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 @@ -619,6 +944,9 @@ async def test_black_is_off( 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, @@ -635,11 +963,131 @@ async def test_black_is_off( "endpoint": 0, "property": "targetColor", } - assert args["value"] == {"red": 0, "green": 255, "blue": 0} + assert args["value"] == {"red": 0, "green": 128, "blue": 0} client.async_send_command.reset_mock() - # Force the light to turn on + # 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 event = Event( type="value updated", data={ @@ -652,17 +1100,14 @@ async def test_black_is_off( "endpoint": 0, "property": "currentColor", "newValue": None, - "prevValue": { - "red": 0, - "green": 255, - "blue": 0, - }, + "prevValue": None, "propertyName": "currentColor", }, }, ) node.receive_event(event) await hass.async_block_till_done() + state = hass.states.get(HSM200_V1_ENTITY) assert state.state == STATE_UNKNOWN @@ -687,183 +1132,6 @@ async def test_black_is_off( 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 5f810d908f61b06d72e306b4416e5dff7ec63860 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Aug 2024 12:28:41 -1000 Subject: [PATCH 0011/1309] Add missing dependencies to yale (#124821) * Add missing dependencies to yale * try another way * Revert "try another way" This reverts commit fbb731a33491bf51290fd98acde7b532ea39fb88. * patch out cloud setup --- homeassistant/components/yale/manifest.json | 1 + tests/components/yale/conftest.py | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index d6da9ba3993..115036b96d5 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -3,6 +3,7 @@ "name": "Yale", "codeowners": ["@bdraco"], "config_flow": true, + "dependencies": ["application_credentials", "cloud"], "dhcp": [ { "hostname": "yale-connect-plus", diff --git a/tests/components/yale/conftest.py b/tests/components/yale/conftest.py index c890087ad12..3e633430846 100644 --- a/tests/components/yale/conftest.py +++ b/tests/components/yale/conftest.py @@ -57,3 +57,16 @@ def load_reauth_jwt_wrong_account_fixture() -> str: async def mock_client_credentials_fixture(hass: HomeAssistant) -> None: """Mock client credentials.""" await mock_client_credentials(hass) + + +@pytest.fixture(name="skip_cloud", autouse=True) +def skip_cloud_fixture(): + """Skip setting up cloud. + + Cloud already has its own tests for account link. + + We do not need to test it here as we only need to test our + usage of the oauth2 helpers. + """ + with patch("homeassistant.components.cloud.async_setup", return_value=True): + yield From 3d39f6ce88487998ffb5fb22bcfdd53ddc79c66a Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Wed, 28 Aug 2024 23:34:20 +0100 Subject: [PATCH 0012/1309] Fix evohome test by setting datetime to match snapshot (#124824) * initial commit * freeze time instead * use fixture instead of API --- .../evohome/snapshots/test_init.ambr | 104 +++++++++--------- tests/components/evohome/test_init.py | 5 + 2 files changed, 57 insertions(+), 52 deletions(-) diff --git a/tests/components/evohome/snapshots/test_init.ambr b/tests/components/evohome/snapshots/test_init.ambr index 210c45354a5..8e5338ecb9b 100644 --- a/tests/components/evohome/snapshots/test_init.ambr +++ b/tests/components/evohome/snapshots/test_init.ambr @@ -62,10 +62,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T23:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T15:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T14:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-10T00:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': False, @@ -110,10 +110,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T23:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T15:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T14:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-10T00:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, @@ -160,10 +160,10 @@ 'until': '2022-03-07T11:00:00-08:00', }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T23:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T15:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T14:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-10T00:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, @@ -205,10 +205,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T23:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T15:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T14:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-10T00:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, @@ -250,10 +250,10 @@ 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T23:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T15:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T14:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-10T00:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, @@ -295,10 +295,10 @@ 'target_heat_temperature': 16.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T23:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T15:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T14:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-10T00:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, @@ -340,10 +340,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T23:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T15:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T14:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-10T00:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, @@ -380,10 +380,10 @@ ]), 'dhw_id': '3933910', 'setpoints': dict({ - 'next_sp_from': '2024-08-14T22:30:00-07:00', - 'next_sp_state': 'On', - 'this_sp_from': '2024-08-14T14:30:00-07:00', - 'this_sp_state': 'Off', + 'next_sp_from': '2024-07-10T05:00:00-07:00', + 'next_sp_state': 'Off', + 'this_sp_from': '2024-07-10T04:00:00-07:00', + 'this_sp_state': 'On', }), 'state_status': dict({ 'mode': 'PermanentOverride', @@ -463,10 +463,10 @@ 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T23:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T15:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T14:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-10T00:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, @@ -546,10 +546,10 @@ 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T21:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T13:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T12:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-09T22:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, @@ -591,10 +591,10 @@ 'target_heat_temperature': 21.5, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T21:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T13:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T12:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-09T22:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, @@ -755,10 +755,10 @@ 'target_heat_temperature': 17.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T23:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T15:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T14:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-10T00:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, @@ -838,10 +838,10 @@ 'target_heat_temperature': 15.0, }), 'setpoints': dict({ - 'next_sp_from': '2024-08-14T22:00:00-07:00', - 'next_sp_temp': 18.1, - 'this_sp_from': '2024-08-14T14:00:00-07:00', - 'this_sp_temp': 15.9, + 'next_sp_from': '2024-07-10T13:10:00-07:00', + 'next_sp_temp': 18.6, + 'this_sp_from': '2024-07-09T23:00:00-07:00', + 'this_sp_temp': 16.0, }), 'temperature_status': dict({ 'is_available': True, diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index b717b19e6cd..ad688d04882 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -2,6 +2,7 @@ from __future__ import annotations +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -17,9 +18,13 @@ async def test_entities( evo_config: dict[str, str], install: str, snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test entities and state after setup of a Honeywell TCC-compatible system.""" + # some extended state attrs are relative the current time + freezer.move_to("2024-07-10 12:00:00+00:00") + await setup_evohome(hass, evo_config, install=install) assert hass.states.async_all() == snapshot From 4b59ef4733b8c4e219914b89e4b7df76fc7098ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 Aug 2024 15:47:11 -1000 Subject: [PATCH 0013/1309] Set GoogleEntity entity_id in constructor (#124830) --- homeassistant/components/google_assistant/helpers.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 7f8f7a68ffa..76869487ee3 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -521,7 +521,7 @@ def supported_traits_for_state(state: State) -> list[type[trait._Trait]]: class GoogleEntity: """Adaptation of Entity expressed in Google's terms.""" - __slots__ = ("hass", "config", "state", "_traits") + __slots__ = ("hass", "config", "state", "entity_id", "_traits") def __init__( self, hass: HomeAssistant, config: AbstractConfig, state: State @@ -530,17 +530,13 @@ class GoogleEntity: self.hass = hass self.config = config self.state = state + self.entity_id = state.entity_id self._traits: list[trait._Trait] | None = None def __repr__(self) -> str: """Return the representation.""" return f"" - @property - def entity_id(self): - """Return entity ID.""" - return self.state.entity_id - @callback def traits(self) -> list[trait._Trait]: """Return traits for entity.""" From 7f4fca63ed78f1cdcbf7e70f08a73e0cb9d7c353 Mon Sep 17 00:00:00 2001 From: Jeremy Cook <8317651+jm-cook@users.noreply.github.com> Date: Thu, 29 Aug 2024 07:49:05 +0200 Subject: [PATCH 0014/1309] SmartThings edge driver for heatit thermostats does not require cooling setpoint (#123188) * remove cooling setpoint requirement for thermostats. Air conditioning remains unchanged * remove cooling setpoint requirement for thermostats. Air conditioning remains unchanged * versions should not be set on core integrations. * Added tests for minimal smartthings thermostat with no cooling. * Added tests for minimal smartthings thermostat with no cooling. * Formatted tests with ruff format --- .../components/smartthings/climate.py | 1 - tests/components/smartthings/test_climate.py | 42 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index c3929ababc1..0598e549f24 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -143,7 +143,6 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: # Or must have all of these thermostat capabilities thermostat_capabilities = [ Capability.temperature_measurement, - Capability.thermostat_cooling_setpoint, Capability.thermostat_heating_setpoint, Capability.thermostat_mode, ] diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index e4b8cb6d373..d39ee2d6bed 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -88,6 +88,26 @@ def basic_thermostat_fixture(device_factory): return device +@pytest.fixture(name="minimal_thermostat") +def minimal_thermostat_fixture(device_factory): + """Fixture returns a minimal thermostat without cooling.""" + device = device_factory( + "Minimal Thermostat", + capabilities=[ + Capability.temperature_measurement, + Capability.thermostat_heating_setpoint, + Capability.thermostat_mode, + ], + status={ + Attribute.heating_setpoint: 68, + Attribute.thermostat_mode: "off", + Attribute.supported_thermostat_modes: ["off", "heat"], + }, + ) + device.status.attributes[Attribute.temperature] = Status(70, "F", None) + return device + + @pytest.fixture(name="thermostat") def thermostat_fixture(device_factory): """Fixture returns a fully-featured thermostat.""" @@ -310,6 +330,28 @@ async def test_basic_thermostat_entity_state( assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius +async def test_minimal_thermostat_entity_state( + hass: HomeAssistant, minimal_thermostat +) -> None: + """Tests the state attributes properly match the thermostat type.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[minimal_thermostat]) + state = hass.states.get("climate.minimal_thermostat") + assert state.state == HVACMode.OFF + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON + ) + assert ATTR_HVAC_ACTION not in state.attributes + assert sorted(state.attributes[ATTR_HVAC_MODES]) == [ + HVACMode.HEAT, + HVACMode.OFF, + ] + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius + + async def test_thermostat_entity_state(hass: HomeAssistant, thermostat) -> None: """Tests the state attributes properly match the thermostat type.""" await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) From 3b6128d590dac63ce0d13154805fd72653ae87ec Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Thu, 29 Aug 2024 07:59:07 +0200 Subject: [PATCH 0015/1309] Bump pyatmo to 8.1.0 (#124340) --- homeassistant/components/netatmo/manifest.json | 2 +- homeassistant/components/netatmo/select.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../netatmo/snapshots/test_sensor.ambr | 18 +++++++++--------- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 98734bcb742..0a32777b527 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.0.3"] + "requirements": ["pyatmo==8.1.0"] } diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index 3fe098a75a9..92568b73e80 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -72,7 +72,7 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): self._attr_current_option = getattr(self.home.get_selected_schedule(), "name") self._attr_options = [ - schedule.name for schedule in self.home.schedules.values() + schedule.name for schedule in self.home.schedules.values() if schedule.name ] async def async_added_to_hass(self) -> None: @@ -128,5 +128,5 @@ class NetatmoScheduleSelect(NetatmoBaseEntity, SelectEntity): self.home.schedules ) self._attr_options = [ - schedule.name for schedule in self.home.schedules.values() + schedule.name for schedule in self.home.schedules.values() if schedule.name ] diff --git a/requirements_all.txt b/requirements_all.txt index 5081fe2fd36..2b3d8bb93d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1741,7 +1741,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.3 +pyatmo==8.1.0 # homeassistant.components.apple_tv pyatv==0.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ec8e6cba40..de47e944688 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1409,7 +1409,7 @@ pyasuswrt==0.1.21 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.3 +pyatmo==8.1.0 # homeassistant.components.apple_tv pyatv==0.15.0 diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index bc2a18d918d..0d13a88cd67 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -1159,7 +1159,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'True', }) # --- # name: test_entity[sensor.cold_water_power-entry] @@ -1508,7 +1508,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'True', }) # --- # name: test_entity[sensor.gas_power-entry] @@ -3257,7 +3257,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'True', }) # --- # name: test_entity[sensor.hot_water_power-entry] @@ -3896,7 +3896,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'True', }) # --- # name: test_entity[sensor.line_1_power-entry] @@ -3995,7 +3995,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'True', }) # --- # name: test_entity[sensor.line_2_power-entry] @@ -4094,7 +4094,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'True', }) # --- # name: test_entity[sensor.line_3_power-entry] @@ -4193,7 +4193,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'True', }) # --- # name: test_entity[sensor.line_4_power-entry] @@ -4292,7 +4292,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'True', }) # --- # name: test_entity[sensor.line_5_power-entry] @@ -5622,7 +5622,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'True', }) # --- # name: test_entity[sensor.total_power-entry] From 1101e7ef64e14ee7d5abc188e0348485e770582d Mon Sep 17 00:00:00 2001 From: AutonomousOwl <116417295+AutonomousOwl@users.noreply.github.com> Date: Thu, 29 Aug 2024 02:34:13 -0400 Subject: [PATCH 0016/1309] Update utility_account_id in Opower to be lowercase in statistic id (#124837) Update utility_account_id to be lowercase in statistic id --- homeassistant/components/opower/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index d0795ae4e15..9cef4e4a252 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -98,7 +98,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): account.meter_type.name.lower(), # Some utilities like AEP have "-" in their account id. # Replace it with "_" to avoid "Invalid statistic_id" - account.utility_account_id.replace("-", "_"), + account.utility_account_id.replace("-", "_").lower(), ) ) cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" From 1cb9690001c71dd9b6016304fa5c12de499f9dca Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 29 Aug 2024 10:52:57 +0200 Subject: [PATCH 0017/1309] Cleanup unused `hass_storage` mocks in mqtt tests (#124846) --- tests/components/mqtt/test_client.py | 5 ----- tests/components/mqtt/test_init.py | 5 ----- 2 files changed, 10 deletions(-) diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index dcded7d187a..31c062b1abd 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -37,11 +37,6 @@ from tests.common import ( from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator, MqttMockPahoClient -@pytest.fixture(autouse=True) -def mock_storage(hass_storage: dict[str, Any]) -> None: - """Autouse hass_storage for the TestCase tests.""" - - def help_assert_message( msg: ReceiveMessage, topic: str | None = None, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 5dab5689518..8f7f7ed6289 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -77,11 +77,6 @@ class _DebugInfo(TypedDict): config: _DebugDeviceInfo -@pytest.fixture(autouse=True) -def mock_storage(hass_storage: dict[str, Any]) -> None: - """Autouse hass_storage for the TestCase tests.""" - - async def test_command_template_value(hass: HomeAssistant) -> None: """Test the rendering of MQTT command template.""" From eac7794741add5396af01abdd09984746e26e034 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Thu, 29 Aug 2024 05:29:54 -0400 Subject: [PATCH 0018/1309] Fix sonos get_queue service call to restrict to sonos media_player entities (#124815) add sonos to filter --- homeassistant/components/sonos/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index 6d6e7ef83f9..89706428899 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -66,6 +66,7 @@ remove_from_queue: get_queue: target: entity: + integration: sonos domain: media_player update_alarm: From a4e9e4b23badb47c80f29f6b529d8b792fff0018 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 29 Aug 2024 11:31:19 +0200 Subject: [PATCH 0019/1309] Tweak exception message in yaml loader (#124841) --- homeassistant/util/yaml/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index a56cf126f79..31efced60f6 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -385,7 +385,7 @@ def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: return _add_reference(loaded_yaml, loader, node) except FileNotFoundError as exc: raise HomeAssistantError( - f"{node.start_mark}: Unable to read file {fname}." + f"{node.start_mark}: Unable to read file {fname}" ) from exc From c4fd1cfc8f527928a017b10628b54dc1b46924b7 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Thu, 29 Aug 2024 11:23:04 +0100 Subject: [PATCH 0020/1309] Fix Mastodon migrate config entry log warning (#124848) Fix migrate config entry --- homeassistant/components/mastodon/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 0d680170f3d..e8d23434248 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -97,10 +97,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.error("Migration failed with error %s", ex) return False - entry.minor_version = 2 - hass.config_entries.async_update_entry( entry, + minor_version=2, unique_id=slugify(construct_mastodon_username(instance, account)), ) From 354f4491c86741d4eecb2bc00e46f628ed0126d9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 29 Aug 2024 13:03:47 +0200 Subject: [PATCH 0021/1309] Avoid unnecessary copying of variables when setting up automations (#124844) --- homeassistant/components/automation/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8ab9c478bc4..2081ea938ae 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -991,15 +991,15 @@ async def _create_automation_entities( # Add trigger variables to variables variables = None - if CONF_TRIGGER_VARIABLES in config_block: + if CONF_TRIGGER_VARIABLES in config_block and CONF_VARIABLES in config_block: variables = ScriptVariables( dict(config_block[CONF_TRIGGER_VARIABLES].as_dict()) ) - if CONF_VARIABLES in config_block: - if variables: - variables.variables.update(config_block[CONF_VARIABLES].as_dict()) - else: - variables = config_block[CONF_VARIABLES] + variables.variables.update(config_block[CONF_VARIABLES].as_dict()) + elif CONF_TRIGGER_VARIABLES in config_block: + variables = config_block[CONF_TRIGGER_VARIABLES] + elif CONF_VARIABLES in config_block: + variables = config_block[CONF_VARIABLES] entity = AutomationEntity( automation_id, From 34680becaac608a7ea6297f50f3c2a7cf7189160 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 29 Aug 2024 13:20:57 +0200 Subject: [PATCH 0022/1309] Bump pydaikin to 2.13.6 (#124852) --- homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index c395ee35cad..88c29a20435 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.13.5"], + "requirements": ["pydaikin==2.13.6"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 2b3d8bb93d8..1892eccc8f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1798,7 +1798,7 @@ pycsspeechtts==1.0.8 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.13.5 +pydaikin==2.13.6 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de47e944688..0918fb4d0f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1445,7 +1445,7 @@ pycoolmasternet-async==0.2.2 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.13.5 +pydaikin==2.13.6 # homeassistant.components.deako pydeako==0.4.0 From 681fe3485db8089570fb0eb1c2d54fe994404608 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 29 Aug 2024 17:24:04 +0200 Subject: [PATCH 0023/1309] Improve config flow type hints (a-f) (#124859) --- .../components/broadlink/config_flow.py | 28 +++++++++++-------- .../components/control4/config_flow.py | 14 +++++++--- .../components/dexcom/config_flow.py | 4 ++- .../components/ecobee/config_flow.py | 8 +++--- .../components/emonitor/config_flow.py | 7 +++-- .../components/emulated_roku/config_flow.py | 4 +-- .../components/enocean/config_flow.py | 10 +++++-- .../components/forked_daapd/config_flow.py | 4 ++- .../components/freedompro/config_flow.py | 6 ++-- 9 files changed, 53 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py index 5d7acfd8b84..c9b2fb46608 100644 --- a/homeassistant/components/broadlink/config_flow.py +++ b/homeassistant/components/broadlink/config_flow.py @@ -5,7 +5,7 @@ import errno from functools import partial import logging import socket -from typing import TYPE_CHECKING, Any +from typing import Any import broadlink as blk from broadlink.exceptions import ( @@ -37,9 +37,7 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize the Broadlink flow.""" - self.device: blk.Device | None = None + device: blk.Device async def async_set_device( self, device: blk.Device, raise_on_progress: bool = True @@ -131,8 +129,6 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN): ) return await self.async_step_auth() - if TYPE_CHECKING: - assert self.device if device.mac == self.device.mac: await self.async_set_device(device, raise_on_progress=False) return await self.async_step_auth() @@ -158,10 +154,10 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_auth(self): + async def async_step_auth(self) -> ConfigFlowResult: """Authenticate to the device.""" device = self.device - errors = {} + errors: dict[str, str] = {} try: await self.hass.async_add_executor_job(device.auth) @@ -211,7 +207,11 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="auth", errors=errors) - async def async_step_reset(self, user_input=None, errors=None): + async def async_step_reset( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: """Guide the user to unlock the device manually. We are unable to authenticate because the device is locked. @@ -234,7 +234,9 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN): {CONF_HOST: device.host[0], CONF_TIMEOUT: device.timeout} ) - async def async_step_unlock(self, user_input=None): + async def async_step_unlock( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Unlock the device. The authentication succeeded, but the device is locked. @@ -288,10 +290,12 @@ class BroadlinkFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_finish(self, user_input=None): + async def async_step_finish( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Choose a name for the device and create config entry.""" device = self.device - errors = {} + errors: dict[str, str] = {} # Abort reauthentication flow. self._abort_if_unique_id_configured( diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index aa7839b4383..77ae2c98c7d 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp.client_exceptions import ClientError from pyControl4.account import C4Account @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_USERNAME, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.device_registry import format_mac @@ -49,7 +49,9 @@ DATA_SCHEMA = vol.Schema( class Control4Validator: """Validates that config details can be used to authenticate and communicate with Control4.""" - def __init__(self, host, username, password, hass): + def __init__( + self, host: str, username: str, password: str, hass: HomeAssistant + ) -> None: """Initialize.""" self.host = host self.username = username @@ -126,6 +128,8 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: controller_unique_id = hub.controller_unique_id + if TYPE_CHECKING: + assert hub.controller_unique_id mac = (controller_unique_id.split("_", 3))[2] formatted_mac = format_mac(mac) await self.async_set_unique_id(formatted_mac) @@ -160,7 +164,9 @@ class OptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index 17bd1b3f7a8..c3ed43c8e9a 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -79,7 +79,9 @@ class DexcomOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/ecobee/config_flow.py b/homeassistant/components/ecobee/config_flow.py index c0d4d9b03fc..f7709c68d91 100644 --- a/homeassistant/components/ecobee/config_flow.py +++ b/homeassistant/components/ecobee/config_flow.py @@ -23,9 +23,7 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize the ecobee flow.""" - self._ecobee: Ecobee | None = None + _ecobee: Ecobee async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -59,7 +57,9 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_authorize(self, user_input=None): + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Present the user with the PIN so that the app can be authorized on ecobee.com.""" errors = {} diff --git a/homeassistant/components/emonitor/config_flow.py b/homeassistant/components/emonitor/config_flow.py index b90b1477f87..b924c7df522 100644 --- a/homeassistant/components/emonitor/config_flow.py +++ b/homeassistant/components/emonitor/config_flow.py @@ -34,10 +34,11 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + discovered_info: dict[str, str] + def __init__(self) -> None: """Initialize Emonitor ConfigFlow.""" self.discovered_ip: str | None = None - self.discovered_info: dict[str, str] | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -87,7 +88,9 @@ class EmonitorConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user() return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Attempt to confirm.""" if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/emulated_roku/config_flow.py b/homeassistant/components/emulated_roku/config_flow.py index eed0298fc57..725987418da 100644 --- a/homeassistant/components/emulated_roku/config_flow.py +++ b/homeassistant/components/emulated_roku/config_flow.py @@ -6,13 +6,13 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from .const import CONF_LISTEN_PORT, DEFAULT_NAME, DEFAULT_PORT, DOMAIN @callback -def configured_servers(hass): +def configured_servers(hass: HomeAssistant) -> set[str]: """Return a set of the configured servers.""" return { entry.data[CONF_NAME] for entry in hass.config_entries.async_entries(DOMAIN) diff --git a/homeassistant/components/enocean/config_flow.py b/homeassistant/components/enocean/config_flow.py index 3105b3ab595..fef633d94c3 100644 --- a/homeassistant/components/enocean/config_flow.py +++ b/homeassistant/components/enocean/config_flow.py @@ -43,12 +43,14 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_detect() - async def async_step_detect(self, user_input=None): + async def async_step_detect( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Propose a list of detected dongles.""" errors = {} if user_input is not None: if user_input[CONF_DEVICE] == self.MANUAL_PATH_VALUE: - return await self.async_step_manual(None) + return await self.async_step_manual() if await self.validate_enocean_conf(user_input): return self.create_enocean_entry(user_input) errors = {CONF_DEVICE: ERROR_INVALID_DONGLE_PATH} @@ -64,7 +66,9 @@ class EnOceanFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_manual(self, user_input=None): + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Request manual USB dongle path.""" default_value = None errors = {} diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index 1f76fe21bad..5f061aa4be1 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -56,7 +56,9 @@ class ForkedDaapdOptionsFlowHandler(OptionsFlow): """Initialize.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="options", data=user_input) diff --git a/homeassistant/components/freedompro/config_flow.py b/homeassistant/components/freedompro/config_flow.py index f986cd05904..48d075f8a87 100644 --- a/homeassistant/components/freedompro/config_flow.py +++ b/homeassistant/components/freedompro/config_flow.py @@ -19,19 +19,19 @@ STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) class Hub: """Freedompro Hub class.""" - def __init__(self, hass, api_key): + def __init__(self, hass: HomeAssistant, api_key: str) -> None: """Freedompro Hub class init.""" self._hass = hass self._api_key = api_key - async def authenticate(self): + async def authenticate(self) -> dict[str, Any]: """Freedompro Hub class authenticate.""" return await get_list( aiohttp_client.async_get_clientsession(self._hass), self._api_key ) -async def validate_input(hass: HomeAssistant, api_key): +async def validate_input(hass: HomeAssistant, api_key: str) -> None: """Validate api key.""" hub = Hub(hass, api_key) result = await hub.authenticate() From c36fc70ab4c6374c07c05ebf4b63ca133134d1d1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 29 Aug 2024 17:24:25 +0200 Subject: [PATCH 0024/1309] Update frontend to 20240829.0 (#124864) --- 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 0e1d443553d..7e934c887fa 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==20240828.0"] + "requirements": ["home-assistant-frontend==20240829.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3aa1b7a298a..e6a5d6746f5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ habluetooth==3.3.2 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20240828.0 +home-assistant-frontend==20240829.0 home-assistant-intents==2024.8.7 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1892eccc8f1..96274a06631 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1102,7 +1102,7 @@ hole==0.8.0 holidays==0.55 # homeassistant.components.frontend -home-assistant-frontend==20240828.0 +home-assistant-frontend==20240829.0 # homeassistant.components.conversation home-assistant-intents==2024.8.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0918fb4d0f2..7c968bb479c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -925,7 +925,7 @@ hole==0.8.0 holidays==0.55 # homeassistant.components.frontend -home-assistant-frontend==20240828.0 +home-assistant-frontend==20240829.0 # homeassistant.components.conversation home-assistant-intents==2024.8.7 From 149aebb0bcbef7814255064dd3a263c05226dc1d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 29 Aug 2024 17:25:04 +0200 Subject: [PATCH 0025/1309] Add missing translation key in Knocki (#124862) --- homeassistant/components/knocki/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/knocki/strings.json b/homeassistant/components/knocki/strings.json index b7a7daad1fc..8f5d0161166 100644 --- a/homeassistant/components/knocki/strings.json +++ b/homeassistant/components/knocki/strings.json @@ -11,6 +11,9 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, "entity": { From a04970bd54a9cadfc4b61c97d9b4328c7393e51d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Aug 2024 07:32:13 -1000 Subject: [PATCH 0026/1309] Address august review comments (#124819) * Address august review comments Followup to https://github.com/home-assistant/core/pull/124677 * cleanup loop * drop mixin name * event entity add cleanup * remove duplicate prop * pep0695 type * remove some not needed block till done * cleanup august tests * switch to freezegun * snapshots for dev reg * SOURCE_USER nit * snapshots * pytest.raises * not loaded check --- homeassistant/components/august/__init__.py | 2 +- .../components/august/binary_sensor.py | 11 +- homeassistant/components/august/button.py | 4 +- homeassistant/components/august/camera.py | 4 +- homeassistant/components/august/entity.py | 4 +- homeassistant/components/august/event.py | 28 +- homeassistant/components/august/lock.py | 4 +- homeassistant/components/august/sensor.py | 21 +- homeassistant/components/august/util.py | 7 +- .../august/snapshots/test_binary_sensor.ambr | 33 +++ .../august/snapshots/test_lock.ambr | 37 +++ tests/components/august/test_binary_sensor.py | 239 ++++++------------ tests/components/august/test_button.py | 1 - tests/components/august/test_camera.py | 10 +- tests/components/august/test_config_flow.py | 14 +- tests/components/august/test_event.py | 46 ++-- tests/components/august/test_init.py | 32 +-- tests/components/august/test_lock.py | 166 ++++-------- tests/components/august/test_sensor.py | 71 ++---- 19 files changed, 312 insertions(+), 422 deletions(-) create mode 100644 tests/components/august/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/august/snapshots/test_lock.ambr diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 53aa3cdffd8..47a7f75611a 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -24,7 +24,7 @@ from .util import async_create_august_clientsession type AugustConfigEntry = ConfigEntry[AugustData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bool: """Set up August from a config entry.""" session = async_create_august_clientsession(hass) august_gateway = AugustGateway(Path(hass.config.config_dir), session) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 6a56692bcd6..fb877252010 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -109,12 +109,11 @@ async def async_setup_entry( for description in SENSOR_TYPES_DOORBELL ) - for doorbell in data.doorbells: - entities.extend( - AugustDoorbellBinarySensor(data, doorbell, description) - for description in SENSOR_TYPES_DOORBELL + SENSOR_TYPES_VIDEO_DOORBELL - ) - + entities.extend( + AugustDoorbellBinarySensor(data, doorbell, description) + for description in SENSOR_TYPES_DOORBELL + SENSOR_TYPES_VIDEO_DOORBELL + for doorbell in data.doorbells + ) async_add_entities(entities) diff --git a/homeassistant/components/august/button.py b/homeassistant/components/august/button.py index 406475db601..79f2b67888a 100644 --- a/homeassistant/components/august/button.py +++ b/homeassistant/components/august/button.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AugustConfigEntry -from .entity import AugustEntityMixin +from .entity import AugustEntity async def async_setup_entry( @@ -18,7 +18,7 @@ async def async_setup_entry( async_add_entities(AugustWakeLockButton(data, lock, "wake") for lock in data.locks) -class AugustWakeLockButton(AugustEntityMixin, ButtonEntity): +class AugustWakeLockButton(AugustEntity, ButtonEntity): """Representation of an August lock wake button.""" _attr_translation_key = "wake" diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 4e569e2a91e..f4398455256 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AugustConfigEntry, AugustData from .const import DEFAULT_NAME, DEFAULT_TIMEOUT -from .entity import AugustEntityMixin +from .entity import AugustEntity _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ async def async_setup_entry( ) -class AugustCamera(AugustEntityMixin, Camera): +class AugustCamera(AugustEntity, Camera): """An implementation of an August security camera.""" _attr_translation_key = "camera" diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index babf5c587fb..28c722354ba 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -20,7 +20,7 @@ from .const import MANUFACTURER DEVICE_TYPES = ["keypad", "lock", "camera", "doorbell", "door", "bell"] -class AugustEntityMixin(Entity): +class AugustEntity(Entity): """Base implementation for August device.""" _attr_should_poll = False @@ -87,7 +87,7 @@ class AugustEntityMixin(Entity): self._update_from_data() -class AugustDescriptionEntity(AugustEntityMixin): +class AugustDescriptionEntity(AugustEntity): """An August entity with a description.""" def __init__( diff --git a/homeassistant/components/august/event.py b/homeassistant/components/august/event.py index b65f72272a3..49b14630337 100644 --- a/homeassistant/components/august/event.py +++ b/homeassistant/components/august/event.py @@ -63,22 +63,17 @@ async def async_setup_entry( ) -> None: """Set up the august event platform.""" data = config_entry.runtime_data - entities: list[AugustEventEntity] = [] - - for lock in data.locks: - detail = data.get_device_detail(lock.device_id) - if detail.doorbell: - entities.extend( - AugustEventEntity(data, lock, description) - for description in TYPES_DOORBELL - ) - - for doorbell in data.doorbells: - entities.extend( - AugustEventEntity(data, doorbell, description) - for description in TYPES_DOORBELL + TYPES_VIDEO_DOORBELL - ) - + entities: list[AugustEventEntity] = [ + AugustEventEntity(data, lock, description) + for description in TYPES_DOORBELL + for lock in data.locks + if (detail := data.get_device_detail(lock.device_id)) and detail.doorbell + ] + entities.extend( + AugustEventEntity(data, doorbell, description) + for description in TYPES_DOORBELL + TYPES_VIDEO_DOORBELL + for doorbell in data.doorbells + ) async_add_entities(entities) @@ -86,7 +81,6 @@ class AugustEventEntity(AugustDescriptionEntity, EventEntity): """An august event entity.""" entity_description: AugustEventEntityDescription - _attr_has_entity_name = True _last_activity: Activity | None = None @callback diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 5382c710229..fe5d90371ad 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -19,7 +19,7 @@ from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.dt as dt_util from . import AugustConfigEntry, AugustData -from .entity import AugustEntityMixin +from .entity import AugustEntity _LOGGER = logging.getLogger(__name__) @@ -36,7 +36,7 @@ async def async_setup_entry( async_add_entities(AugustLock(data, lock) for lock in data.locks) -class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): +class AugustLock(AugustEntity, RestoreEntity, LockEntity): """Representation of an August lock.""" _attr_name = None diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 7a4c1a92358..b7c0d618492 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Generic, TypeVar, cast +from typing import Any, cast from yalexs.activity import ActivityType, LockOperationActivity from yalexs.doorbell import Doorbell @@ -42,7 +42,7 @@ from .const import ( OPERATION_METHOD_REMOTE, OPERATION_METHOD_TAG, ) -from .entity import AugustDescriptionEntity, AugustEntityMixin +from .entity import AugustDescriptionEntity, AugustEntity def _retrieve_device_battery_state(detail: LockDetail) -> int: @@ -55,14 +55,13 @@ def _retrieve_linked_keypad_battery_state(detail: KeypadDetail) -> int | None: return detail.battery_percentage -_T = TypeVar("_T", LockDetail, KeypadDetail) - - @dataclass(frozen=True, kw_only=True) -class AugustSensorEntityDescription(SensorEntityDescription, Generic[_T]): +class AugustSensorEntityDescription[T: LockDetail | KeypadDetail]( + SensorEntityDescription +): """Mixin for required keys.""" - value_fn: Callable[[_T], int | None] + value_fn: Callable[[T], int | None] SENSOR_TYPE_DEVICE_BATTERY = AugustSensorEntityDescription[LockDetail]( @@ -114,7 +113,7 @@ async def async_setup_entry( async_add_entities(entities) -class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): +class AugustOperatorSensor(AugustEntity, RestoreSensor): """Representation of an August lock operation sensor.""" _attr_translation_key = "operator" @@ -198,10 +197,12 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): self._operated_autorelock = last_attrs[ATTR_OPERATION_AUTORELOCK] -class AugustBatterySensor(AugustDescriptionEntity, SensorEntity, Generic[_T]): +class AugustBatterySensor[T: LockDetail | KeypadDetail]( + AugustDescriptionEntity, SensorEntity +): """Representation of an August sensor.""" - entity_description: AugustSensorEntityDescription[_T] + entity_description: AugustSensorEntityDescription[T] _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE diff --git a/homeassistant/components/august/util.py b/homeassistant/components/august/util.py index 6972913ba22..5449d048613 100644 --- a/homeassistant/components/august/util.py +++ b/homeassistant/components/august/util.py @@ -63,16 +63,11 @@ def _activity_time_based(latest: Activity) -> Activity | None: """Get the latest state of the sensor.""" start = latest.activity_start_time end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION - if start <= _native_datetime() <= end: + if start <= datetime.now() <= end: return latest return None -def _native_datetime() -> datetime: - """Return time in the format august uses without timezone.""" - return datetime.now() - - def retrieve_online_state( data: AugustData, detail: DoorbellDetail | LockDetail ) -> bool: diff --git a/tests/components/august/snapshots/test_binary_sensor.ambr b/tests/components/august/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..6e95b0ce552 --- /dev/null +++ b/tests/components/august/snapshots/test_binary_sensor.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_doorbell_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': 'tmt100_name', + 'config_entries': , + 'configuration_url': 'https://account.august.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'august', + 'tmt100', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'August Home Inc.', + 'model': 'hydra1', + 'model_id': None, + 'name': 'tmt100 Name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'tmt100 Name', + 'sw_version': '3.1.0-HYDRC75+201909251139', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/august/snapshots/test_lock.ambr b/tests/components/august/snapshots/test_lock.ambr new file mode 100644 index 00000000000..6aad3a140ca --- /dev/null +++ b/tests/components/august/snapshots/test_lock.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_lock_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': 'online_with_doorsense_name', + 'config_entries': , + 'configuration_url': 'https://account.august.com', + 'connections': set({ + tuple( + 'bluetooth', + '12:22', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'august', + 'online_with_doorsense', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'August Home Inc.', + 'model': 'AUG-MD01', + 'model_id': None, + 'name': 'online_with_doorsense Name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'online_with_doorsense Name', + 'sw_version': 'undefined-4.3.0-1.8.14', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 33d582de8d8..4ae300ae56b 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -1,8 +1,10 @@ """The binary_sensor tests for the august platform.""" import datetime -from unittest.mock import Mock, patch +from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion from yalexs.pubnub_async import AugustPubNub from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN @@ -36,28 +38,20 @@ async def test_doorsense(hass: HomeAssistant) -> None: hass, "get_lock.online_with_doorsense.json" ) await _create_august_with_devices(hass, [lock_one]) + states = hass.states - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) - await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" + assert ( + states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_OFF ) - assert binary_sensor_online_with_doorsense_name.state == STATE_OFF async def test_lock_bridge_offline(hass: HomeAssistant) -> None: @@ -69,113 +63,82 @@ async def test_lock_bridge_offline(hass: HomeAssistant) -> None: hass, "get_activity.bridge_offline.json" ) await _create_august_with_devices(hass, [lock_one], activities=activities) - - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" + states = hass.states + assert ( + states.get("binary_sensor.online_with_doorsense_name_door").state + == STATE_UNAVAILABLE ) - assert binary_sensor_online_with_doorsense_name.state == STATE_UNAVAILABLE async def test_create_doorbell(hass: HomeAssistant) -> None: """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") await _create_august_with_devices(hass, [doorbell_one]) + states = hass.states - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_OFF + assert ( + states.get("binary_sensor.k98gidt45gul_name_image_capture").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF - binary_sensor_k98gidt45gul_name_image_capture = hass.states.get( - "binary_sensor.k98gidt45gul_name_image_capture" + assert states.get("binary_sensor.k98gidt45gul_name_connectivity").state == STATE_ON + assert ( + states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_image_capture.state == STATE_OFF - binary_sensor_k98gidt45gul_name_online = hass.states.get( - "binary_sensor.k98gidt45gul_name_connectivity" + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_OFF + assert ( + states.get("binary_sensor.k98gidt45gul_name_image_capture").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" - ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" - ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF - binary_sensor_k98gidt45gul_name_image_capture = hass.states.get( - "binary_sensor.k98gidt45gul_name_image_capture" - ) - assert binary_sensor_k98gidt45gul_name_image_capture.state == STATE_OFF async def test_create_doorbell_offline(hass: HomeAssistant) -> None: """Test creation of a doorbell that is offline.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") await _create_august_with_devices(hass, [doorbell_one]) + states = hass.states - binary_sensor_tmt100_name_motion = hass.states.get( - "binary_sensor.tmt100_name_motion" + assert states.get("binary_sensor.tmt100_name_motion").state == STATE_UNAVAILABLE + assert states.get("binary_sensor.tmt100_name_connectivity").state == STATE_OFF + assert ( + states.get("binary_sensor.tmt100_name_doorbell_ding").state == STATE_UNAVAILABLE ) - assert binary_sensor_tmt100_name_motion.state == STATE_UNAVAILABLE - binary_sensor_tmt100_name_online = hass.states.get( - "binary_sensor.tmt100_name_connectivity" - ) - assert binary_sensor_tmt100_name_online.state == STATE_OFF - binary_sensor_tmt100_name_ding = hass.states.get( - "binary_sensor.tmt100_name_doorbell_ding" - ) - assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE -async def test_create_doorbell_with_motion(hass: HomeAssistant) -> None: +async def test_create_doorbell_with_motion( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") activities = await _mock_activities_from_fixture( hass, "get_activity.doorbell_motion.json" ) await _create_august_with_devices(hass, [doorbell_one], activities=activities) + states = hass.states - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_ON + assert states.get("binary_sensor.k98gidt45gul_name_connectivity").state == STATE_ON + assert ( + states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON - binary_sensor_k98gidt45gul_name_online = hass.states.get( - "binary_sensor.k98gidt45gul_name_connectivity" - ) - assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" - ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.august.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" - ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_OFF -async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: +async def test_doorbell_update_via_pubnub( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test creation of a doorbell that can be updated via pubnub.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") pubnub = AugustPubNub() await _create_august_with_devices(hass, [doorbell_one], pubnub=pubnub) assert doorbell_one.pubsub_channel == "7c7a6672-59c8-3333-ffff-dcd98705cccc" - - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" + states = hass.states + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_OFF + assert ( + states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" - ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF pubnub.message( pubnub, @@ -198,10 +161,7 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() - binary_sensor_k98gidt45gul_name_image_capture = hass.states.get( - "binary_sensor.k98gidt45gul_name_image_capture" - ) - assert binary_sensor_k98gidt45gul_name_image_capture.state == STATE_ON + assert states.get("binary_sensor.k98gidt45gul_name_image_capture").state == STATE_ON pubnub.message( pubnub, @@ -235,29 +195,19 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() - binary_sensor_k98gidt45gul_name_motion = hass.states.get( - "binary_sensor.k98gidt45gul_name_motion" - ) - assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON + assert states.get("binary_sensor.k98gidt45gul_name_motion").state == STATE_ON - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" + assert ( + states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.august.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() - binary_sensor_k98gidt45gul_name_image_capture = hass.states.get( - "binary_sensor.k98gidt45gul_name_image_capture" + assert ( + states.get("binary_sensor.k98gidt45gul_name_image_capture").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_image_capture.state == STATE_OFF pubnub.message( pubnub, @@ -271,37 +221,25 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" - ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_ON - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.august.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + assert states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_ON + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() - binary_sensor_k98gidt45gul_name_ding = hass.states.get( - "binary_sensor.k98gidt45gul_name_doorbell_ding" + assert ( + states.get("binary_sensor.k98gidt45gul_name_doorbell_ding").state == STATE_OFF ) - assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF async def test_doorbell_device_registry( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion ) -> None: """Test creation of a lock with doorsense and bridge ands up in the registry.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") await _create_august_with_devices(hass, [doorbell_one]) reg_device = device_registry.async_get_device(identifiers={("august", "tmt100")}) - assert reg_device.model == "hydra1" - assert reg_device.name == "tmt100 Name" - assert reg_device.manufacturer == "August Home Inc." - assert reg_device.sw_version == "3.1.0-HYDRC75+201909251139" + assert reg_device == snapshot async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: @@ -314,11 +252,9 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: config_entry = await _create_august_with_devices( hass, [lock_one], activities=activities, pubnub=pubnub ) + states = hass.states - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON pubnub.message( pubnub, @@ -330,10 +266,9 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" + assert ( + states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_OFF ) - assert binary_sensor_online_with_doorsense_name.state == STATE_OFF pubnub.message( pubnub, @@ -344,33 +279,22 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: ), ) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON pubnub.connected = True async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON # Ensure pubnub status is always preserved async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2)) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON pubnub.message( pubnub, @@ -381,17 +305,11 @@ async def test_door_sense_update_via_pubnub(hass: HomeAssistant) -> None: ), ) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4)) await hass.async_block_till_done() - binary_sensor_online_with_doorsense_name = hass.states.get( - "binary_sensor.online_with_doorsense_name_door" - ) - assert binary_sensor_online_with_doorsense_name.state == STATE_ON + assert states.get("binary_sensor.online_with_doorsense_name_door").state == STATE_ON await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() @@ -402,7 +320,10 @@ async def test_create_lock_with_doorbell(hass: HomeAssistant) -> None: lock_one = await _mock_lock_from_fixture(hass, "lock_with_doorbell.online.json") await _create_august_with_devices(hass, [lock_one]) - ding_sensor = hass.states.get( - "binary_sensor.a6697750d607098bae8d6baa11ef8063_name_doorbell_ding" + states = hass.states + assert ( + states.get( + "binary_sensor.a6697750d607098bae8d6baa11ef8063_name_doorbell_ding" + ).state + == STATE_OFF ) - assert ding_sensor.state == STATE_OFF diff --git a/tests/components/august/test_button.py b/tests/components/august/test_button.py index 8ae2bc8a70d..948b59b2286 100644 --- a/tests/components/august/test_button.py +++ b/tests/components/august/test_button.py @@ -20,5 +20,4 @@ async def test_wake_lock(hass: HomeAssistant) -> None: await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - await hass.async_block_till_done() api_instance.async_status_async.assert_called_once() diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py index 539a26cc30f..5ab7d49c3b8 100644 --- a/tests/components/august/test_camera.py +++ b/tests/components/august/test_camera.py @@ -25,14 +25,10 @@ async def test_create_doorbell( ): await _create_august_with_devices(hass, [doorbell_one], brand=Brand.AUGUST) - camera_k98gidt45gul_name_camera = hass.states.get( - "camera.k98gidt45gul_name_camera" - ) - assert camera_k98gidt45gul_name_camera.state == STATE_IDLE + camera_state = hass.states.get("camera.k98gidt45gul_name_camera") + assert camera_state.state == STATE_IDLE - url = hass.states.get("camera.k98gidt45gul_name_camera").attributes[ - "entity_picture" - ] + url = camera_state.attributes["entity_picture"] client = await hass_client_no_auth() resp = await client.get(url) diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py index e0ccee55f10..9902901d29f 100644 --- a/tests/components/august/test_config_flow.py +++ b/tests/components/august/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import patch from yalexs.authenticator_common import ValidationResult from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation -from homeassistant import config_entries from homeassistant.components.august.const import ( CONF_ACCESS_TOKEN_CACHE_FILE, CONF_BRAND, @@ -14,6 +13,7 @@ from homeassistant.components.august.const import ( DOMAIN, VERIFICATION_CODE_KEY, ) +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -25,7 +25,7 @@ async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -66,7 +66,7 @@ async def test_form(hass: HomeAssistant) -> None: async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) with patch( @@ -90,7 +90,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: async def test_user_unexpected_exception(hass: HomeAssistant) -> None: """Test we handle an unexpected exception.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) with patch( @@ -115,7 +115,7 @@ async def test_user_unexpected_exception(hass: HomeAssistant) -> None: async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) with patch( @@ -138,7 +138,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: async def test_form_needs_validate(hass: HomeAssistant) -> None: """Test we present validation when we need to validate.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) with ( @@ -367,7 +367,7 @@ async def test_switching_brands(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} diff --git a/tests/components/august/test_event.py b/tests/components/august/test_event.py index 61b7560f462..0bb482c5b89 100644 --- a/tests/components/august/test_event.py +++ b/tests/components/august/test_event.py @@ -1,13 +1,12 @@ """The event tests for the august.""" -import datetime -from unittest.mock import Mock, patch +from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory from yalexs.pubnub_async import AugustPubNub from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -import homeassistant.util.dt as dt_util from .mocks import ( _create_august_with_devices, @@ -45,7 +44,9 @@ async def test_create_doorbell_offline(hass: HomeAssistant) -> None: assert doorbell_state.state == STATE_UNAVAILABLE -async def test_create_doorbell_with_motion(hass: HomeAssistant) -> None: +async def test_create_doorbell_with_motion( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") activities = await _mock_activities_from_fixture( @@ -61,19 +62,16 @@ async def test_create_doorbell_with_motion(hass: HomeAssistant) -> None: assert doorbell_state is not None assert doorbell_state.state == STATE_UNKNOWN - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.august.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() motion_state = hass.states.get("event.k98gidt45gul_name_motion") assert motion_state.state == isotime -async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: +async def test_doorbell_update_via_pubnub( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test creation of a doorbell that can be updated via pubnub.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") pubnub = AugustPubNub() @@ -125,14 +123,9 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: assert motion_state.state != STATE_UNKNOWN isotime = motion_state.state - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.august.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() motion_state = hass.states.get("event.k98gidt45gul_name_motion") assert motion_state is not None @@ -155,14 +148,9 @@ async def test_doorbell_update_via_pubnub(hass: HomeAssistant) -> None: assert doorbell_state.state != STATE_UNKNOWN isotime = motion_state.state - new_time = dt_util.utcnow() + datetime.timedelta(seconds=40) - native_time = datetime.datetime.now() + datetime.timedelta(seconds=40) - with patch( - "homeassistant.components.august.util._native_datetime", - return_value=native_time, - ): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.tick(40) + async_fire_time_changed(hass) + await hass.async_block_till_done() doorbell_state = hass.states.get("event.k98gidt45gul_name_doorbell") assert doorbell_state is not None diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 8261e32d668..954436f209a 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -122,16 +122,16 @@ async def test_unlock_throws_august_api_http_error(hass: HomeAssistant) -> None: "unlock_return_activities": _unlock_return_activities_side_effect }, ) - last_err = None data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} - try: + + with pytest.raises( + HomeAssistantError, + match=( + "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user" + " consumable" + ), + ): await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - except HomeAssistantError as err: - last_err = err - assert str(last_err) == ( - "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user" - " consumable" - ) async def test_lock_throws_august_api_http_error(hass: HomeAssistant) -> None: @@ -152,16 +152,15 @@ async def test_lock_throws_august_api_http_error(hass: HomeAssistant) -> None: "lock_return_activities": _lock_return_activities_side_effect }, ) - last_err = None data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} - try: + with pytest.raises( + HomeAssistantError, + match=( + "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user" + " consumable" + ), + ): await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) - except HomeAssistantError as err: - last_err = err - assert str(last_err) == ( - "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user" - " consumable" - ) async def test_open_throws_hass_service_not_supported_error( @@ -371,6 +370,7 @@ async def test_load_unload(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED async def test_load_triggers_ble_discovery( diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 8bb71826d24..e786cebf3e1 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -6,6 +6,7 @@ from unittest.mock import Mock from aiohttp import ClientResponseError from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from yalexs.manager.activity import INITIAL_LOCK_RESYNC_TIME from yalexs.pubnub_async import AugustPubNub @@ -43,7 +44,7 @@ from tests.common import async_fire_time_changed async def test_lock_device_registry( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion ) -> None: """Test creation of a lock with doorsense and bridge ands up in the registry.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -52,10 +53,7 @@ async def test_lock_device_registry( reg_device = device_registry.async_get_device( identifiers={("august", "online_with_doorsense")} ) - assert reg_device.model == "AUG-MD01" - assert reg_device.sw_version == "undefined-4.3.0-1.8.14" - assert reg_device.name == "online_with_doorsense Name" - assert reg_device.manufacturer == "August Home Inc." + assert reg_device == snapshot async def test_lock_changed_by(hass: HomeAssistant) -> None: @@ -65,14 +63,10 @@ async def test_lock_changed_by(hass: HomeAssistant) -> None: activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json") await _create_august_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED - - assert ( - lock_online_with_doorsense_name.attributes.get("changed_by") - == "Your favorite elven princess" - ) + assert lock_state.state == STATE_LOCKED + assert lock_state.attributes["changed_by"] == "Your favorite elven princess" async def test_state_locking(hass: HomeAssistant) -> None: @@ -82,9 +76,7 @@ async def test_state_locking(hass: HomeAssistant) -> None: activities = await _mock_activities_from_fixture(hass, "get_activity.locking.json") await _create_august_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_LOCKING + assert hass.states.get("lock.online_with_doorsense_name").state == STATE_LOCKING async def test_state_unlocking(hass: HomeAssistant) -> None: @@ -96,9 +88,7 @@ async def test_state_unlocking(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_UNLOCKING + assert hass.states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING async def test_state_jammed(hass: HomeAssistant) -> None: @@ -108,9 +98,7 @@ async def test_state_jammed(hass: HomeAssistant) -> None: activities = await _mock_activities_from_fixture(hass, "get_activity.jammed.json") await _create_august_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_JAMMED + assert hass.states.get("lock.online_with_doorsense_name").state == STATE_JAMMED async def test_one_lock_operation( @@ -119,35 +107,27 @@ async def test_one_lock_operation( """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) await _create_august_with_devices(hass, [lock_one]) + states = hass.states - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + lock_state = states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_state.state == STATE_LOCKED - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + lock_state = states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_UNLOCKED - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) - await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKED # No activity means it will be unavailable until the activity feed has data lock_operator_sensor = entity_registry.async_get( @@ -155,8 +135,7 @@ async def test_one_lock_operation( ) assert lock_operator_sensor assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == STATE_UNKNOWN + states.get("sensor.online_with_doorsense_name_operator").state == STATE_UNKNOWN ) @@ -170,7 +149,6 @@ async def test_open_lock_operation(hass: HomeAssistant) -> None: data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) - await hass.async_block_till_done() lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") assert lock_online_with_unlatch_name.state == STATE_UNLOCKED @@ -189,12 +167,10 @@ async def test_open_lock_operation_pubnub_connected( await _create_august_with_devices(hass, [lock_with_unlatch], pubnub=pubnub) pubnub.connected = True - lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") - assert lock_online_with_unlatch_name.state == STATE_LOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == STATE_LOCKED data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) - await hass.async_block_till_done() pubnub.message( pubnub, @@ -209,8 +185,7 @@ async def test_open_lock_operation_pubnub_connected( await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") - assert lock_online_with_unlatch_name.state == STATE_UNLOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == STATE_UNLOCKED await hass.async_block_till_done() @@ -227,19 +202,15 @@ async def test_one_lock_operation_pubnub_connected( await _create_august_with_devices(hass, [lock_one], pubnub=pubnub) pubnub.connected = True - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_state.state == STATE_LOCKED - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - await hass.async_block_till_done() pubnub.message( pubnub, @@ -254,17 +225,13 @@ async def test_one_lock_operation_pubnub_connected( await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + lock_state = hass.states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_UNLOCKED - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) - await hass.async_block_till_done() pubnub.message( pubnub, @@ -279,8 +246,8 @@ async def test_one_lock_operation_pubnub_connected( await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + lock_state = hass.states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_LOCKED # No activity means it will be unavailable until the activity feed has data lock_operator_sensor = entity_registry.async_get( @@ -306,8 +273,8 @@ async def test_one_lock_operation_pubnub_connected( ) await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + lock_state = hass.states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_UNLOCKED async def test_lock_jammed(hass: HomeAssistant) -> None: @@ -325,22 +292,18 @@ async def test_lock_jammed(hass: HomeAssistant) -> None: }, ) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_state.state == STATE_LOCKED - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_JAMMED + lock_state = hass.states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_JAMMED async def test_lock_throws_exception_on_unknown_status_code( @@ -360,15 +323,12 @@ async def test_lock_throws_exception_on_unknown_status_code( }, ) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_state.state == STATE_LOCKED - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} with pytest.raises(ClientResponseError): @@ -383,9 +343,7 @@ async def test_one_lock_unknown_state(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one]) - lock_brokenid_name = hass.states.get("lock.brokenid_name") - - assert lock_brokenid_name.state == STATE_UNKNOWN + assert hass.states.get("lock.brokenid_name").state == STATE_UNKNOWN async def test_lock_bridge_offline(hass: HomeAssistant) -> None: @@ -397,9 +355,7 @@ async def test_lock_bridge_offline(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_UNAVAILABLE + assert hass.states.get("lock.online_with_doorsense_name").state == STATE_UNAVAILABLE async def test_lock_bridge_online(hass: HomeAssistant) -> None: @@ -411,14 +367,13 @@ async def test_lock_bridge_online(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert hass.states.get("lock.online_with_doorsense_name").state == STATE_LOCKED async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + states = hass.states assert lock_one.pubsub_channel == "pubsub" pubnub = AugustPubNub() @@ -428,9 +383,7 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: ) pubnub.connected = True - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKED pubnub.message( pubnub, @@ -446,8 +399,7 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING pubnub.message( pubnub, @@ -463,25 +415,21 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKING + assert hass.states.get("lock.online_with_doorsense_name").state == STATE_LOCKING pubnub.connected = True async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING # Ensure pubnub status is always preserved async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2)) await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING pubnub.message( pubnub, @@ -496,13 +444,11 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4)) await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index 67223e9dff0..2d72d287ce3 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -28,13 +28,9 @@ async def test_create_doorbell(hass: HomeAssistant) -> None: doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") await _create_august_with_devices(hass, [doorbell_one]) - sensor_k98gidt45gul_name_battery = hass.states.get( - "sensor.k98gidt45gul_name_battery" - ) - assert sensor_k98gidt45gul_name_battery.state == "96" - assert ( - sensor_k98gidt45gul_name_battery.attributes["unit_of_measurement"] == PERCENTAGE - ) + battery_state = hass.states.get("sensor.k98gidt45gul_name_battery") + assert battery_state.state == "96" + assert battery_state.attributes["unit_of_measurement"] == PERCENTAGE async def test_create_doorbell_offline( @@ -44,9 +40,9 @@ async def test_create_doorbell_offline( doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") await _create_august_with_devices(hass, [doorbell_one]) - sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery") - assert sensor_tmt100_name_battery.state == "81" - assert sensor_tmt100_name_battery.attributes["unit_of_measurement"] == PERCENTAGE + battery_state = hass.states.get("sensor.tmt100_name_battery") + assert battery_state.state == "81" + assert battery_state.attributes["unit_of_measurement"] == PERCENTAGE entry = entity_registry.async_get("sensor.tmt100_name_battery") assert entry @@ -60,8 +56,7 @@ async def test_create_doorbell_hardwired(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [doorbell_one]) - sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery") - assert sensor_tmt100_name_battery is None + assert hass.states.get("sensor.tmt100_name_battery") is None async def test_create_lock_with_linked_keypad( @@ -71,25 +66,21 @@ async def test_create_lock_with_linked_keypad( lock_one = await _mock_lock_from_fixture(hass, "get_lock.doorsense_init.json") await _create_august_with_devices(hass, [lock_one]) - sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( + battery_state = hass.states.get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" ) - assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88" - assert ( - sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[ - "unit_of_measurement" - ] - == PERCENTAGE - ) + assert battery_state.state == "88" + assert battery_state.attributes["unit_of_measurement"] == PERCENTAGE + entry = entity_registry.async_get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" ) assert entry assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_device_battery" - state = hass.states.get("sensor.front_door_lock_keypad_battery") - assert state.state == "62" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + keypad_battery_state = hass.states.get("sensor.front_door_lock_keypad_battery") + assert keypad_battery_state.state == "62" + assert keypad_battery_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE entry = entity_registry.async_get("sensor.front_door_lock_keypad_battery") assert entry assert entry.unique_id == "5bc65c24e6ef2a263e1450a8_linked_keypad_battery" @@ -101,42 +92,32 @@ async def test_create_lock_with_low_battery_linked_keypad( """Test creation of a lock with a linked keypad that both have a battery.""" lock_one = await _mock_lock_from_fixture(hass, "get_lock.low_keypad_battery.json") await _create_august_with_devices(hass, [lock_one]) + states = hass.states - sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( - "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" - ) - assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88" - assert ( - sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[ - "unit_of_measurement" - ] - == PERCENTAGE - ) + battery_state = states.get("sensor.a6697750d607098bae8d6baa11ef8063_name_battery") + assert battery_state.state == "88" + assert battery_state.attributes["unit_of_measurement"] == PERCENTAGE entry = entity_registry.async_get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" ) assert entry assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_device_battery" - state = hass.states.get("sensor.front_door_lock_keypad_battery") - assert state.state == "10" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + keypad_battery_state = states.get("sensor.front_door_lock_keypad_battery") + assert keypad_battery_state.state == "10" + assert keypad_battery_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE entry = entity_registry.async_get("sensor.front_door_lock_keypad_battery") assert entry assert entry.unique_id == "5bc65c24e6ef2a263e1450a8_linked_keypad_battery" # No activity means it will be unavailable until someone unlocks/locks it - lock_operator_sensor = entity_registry.async_get( + operator_entry = entity_registry.async_get( "sensor.a6697750d607098bae8d6baa11ef8063_name_operator" ) - assert ( - lock_operator_sensor.unique_id - == "A6697750D607098BAE8D6BAA11EF8063_lock_operator" - ) - assert ( - hass.states.get("sensor.a6697750d607098bae8d6baa11ef8063_name_operator").state - == STATE_UNKNOWN - ) + assert operator_entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_lock_operator" + + operator_state = states.get("sensor.a6697750d607098bae8d6baa11ef8063_name_operator") + assert operator_state.state == STATE_UNKNOWN async def test_lock_operator_bluetooth( From ef452427e38b400cfaeeacae09d2f359a12efb34 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 29 Aug 2024 19:34:19 +0200 Subject: [PATCH 0027/1309] Bump PyTurboJPEG to 1.7.5 (#124865) --- homeassistant/components/camera/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index b1df158a260..9c56d97f910 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/camera", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.1"] + "requirements": ["PyTurboJPEG==1.7.5"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 37158aa5fe3..dffd6d65a6e 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.1", "ha-av==10.1.1", "numpy==1.26.0"] + "requirements": ["PyTurboJPEG==1.7.5", "ha-av==10.1.1", "numpy==1.26.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e6a5d6746f5..329b2535855 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -51,7 +51,7 @@ pyOpenSSL==24.2.1 pyserial==3.5 pyspeex-noise==1.0.2 python-slugify==8.0.4 -PyTurboJPEG==1.7.1 +PyTurboJPEG==1.7.5 pyudev==0.24.1 PyYAML==6.0.2 requests==2.32.3 diff --git a/requirements_all.txt b/requirements_all.txt index 96274a06631..1497033bd81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -97,7 +97,7 @@ PyTransportNSW==0.1.1 # homeassistant.components.camera # homeassistant.components.stream -PyTurboJPEG==1.7.1 +PyTurboJPEG==1.7.5 # homeassistant.components.vicare PyViCare-neo==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c968bb479c..bbeaf4cfcdb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -91,7 +91,7 @@ PyTransportNSW==0.1.1 # homeassistant.components.camera # homeassistant.components.stream -PyTurboJPEG==1.7.1 +PyTurboJPEG==1.7.5 # homeassistant.components.vicare PyViCare-neo==0.2.1 From ff9937f942828c18c82e8e6ff66ca9f0004fcea8 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 29 Aug 2024 13:29:11 -0500 Subject: [PATCH 0028/1309] Bump intents to 2024.8.29 (#124874) --- homeassistant/components/conversation/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/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index d7a308b8b2b..5a689485b29 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.8.7"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.8.29"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 329b2535855..c01b23ab4e4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 home-assistant-frontend==20240829.0 -home-assistant-intents==2024.8.7 +home-assistant-intents==2024.8.29 httpx==0.27.0 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 1497033bd81..301fd44af3a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1105,7 +1105,7 @@ holidays==0.55 home-assistant-frontend==20240829.0 # homeassistant.components.conversation -home-assistant-intents==2024.8.7 +home-assistant-intents==2024.8.29 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbeaf4cfcdb..8c674932b29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -928,7 +928,7 @@ holidays==0.55 home-assistant-frontend==20240829.0 # homeassistant.components.conversation -home-assistant-intents==2024.8.7 +home-assistant-intents==2024.8.29 # homeassistant.components.home_connect homeconnect==0.8.0 From 175ffe29f6363e3adb3827d1777c312fa6599952 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Aug 2024 13:07:21 -1000 Subject: [PATCH 0029/1309] Bump yalexs to 8.5.5 (#124891) changelog: https://github.com/bdraco/yalexs/compare/v8.5.4...v8.5.5 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index fe630638cf2..5f317a20834 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.5.4", "yalexs-ble==2.4.3"] + "requirements": ["yalexs==8.5.5", "yalexs-ble==2.4.3"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 115036b96d5..9bee7df2e00 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.5.4", "yalexs-ble==2.4.3"] + "requirements": ["yalexs==8.5.5", "yalexs-ble==2.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 301fd44af3a..e86b82791ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2976,7 +2976,7 @@ yalexs-ble==2.4.3 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.5.4 +yalexs==8.5.5 # homeassistant.components.yeelight yeelight==0.7.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c674932b29..493fde89f89 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2359,7 +2359,7 @@ yalexs-ble==2.4.3 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.5.4 +yalexs==8.5.5 # homeassistant.components.yeelight yeelight==0.7.14 From 7eeebf198b2c2c2c53ddc47d43b3834c416f4311 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 30 Aug 2024 02:13:47 +0200 Subject: [PATCH 0030/1309] Fix ZHA group removal entity registry cleanup (#124889) * Fix ZHA cleanup entity registry parameter * Fix missing `gateway` when accessing coordinator device * Get `ZHADeviceProxy` for coordinator device --- homeassistant/components/zha/helpers.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index a5446af7e76..f70c8a9cb3e 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -802,21 +802,24 @@ class ZHAGatewayProxy(EventBase): ) def _cleanup_group_entity_registry_entries( - self, zigpy_group: zigpy.group.Group + self, zha_group_proxy: ZHAGroupProxy ) -> None: """Remove entity registry entries for group entities when the groups are removed from HA.""" # first we collect the potential unique ids for entities that could be created from this group possible_entity_unique_ids = [ - f"{domain}_zha_group_0x{zigpy_group.group_id:04x}" + f"{domain}_zha_group_0x{zha_group_proxy.group.group_id:04x}" for domain in GROUP_ENTITY_DOMAINS ] # then we get all group entity entries tied to the coordinator entity_registry = er.async_get(self.hass) - assert self.coordinator_zha_device + assert self.gateway.coordinator_zha_device + coordinator_proxy = self.device_proxies[ + self.gateway.coordinator_zha_device.ieee + ] all_group_entity_entries = er.async_entries_for_device( entity_registry, - self.coordinator_zha_device.device_id, + coordinator_proxy.device_id, include_disabled_entities=True, ) From 4dfc11a1401ee79dc61196a3c82c388a23cb6a0d Mon Sep 17 00:00:00 2001 From: Tony <29752086+ms264556@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:03:51 +1200 Subject: [PATCH 0031/1309] Bump aioruckus to v0.41 removing blocking call to load_default_certs from ruckus_unleashed integration (#123974) * fix ruckusd_unleashed blocking call to load_default_certs * remove extra loggers, bump aioruckus ver for debian packagers --- homeassistant/components/ruckus_unleashed/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json index edaf0aa95d2..039840efc14 100644 --- a/homeassistant/components/ruckus_unleashed/manifest.json +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/ruckus_unleashed", "integration_type": "hub", "iot_class": "local_polling", - "loggers": ["aioruckus", "xmltodict"], - "requirements": ["aioruckus==0.34"] + "loggers": ["aioruckus"], + "requirements": ["aioruckus==0.41"] } diff --git a/requirements_all.txt b/requirements_all.txt index e86b82791ec..405eb9c2cd7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -347,7 +347,7 @@ aiorecollect==2023.09.0 aioridwell==2024.01.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.34 +aioruckus==0.41 # homeassistant.components.russound_rio aiorussound==2.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 493fde89f89..6c101b2db4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -329,7 +329,7 @@ aiorecollect==2023.09.0 aioridwell==2024.01.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.34 +aioruckus==0.41 # homeassistant.components.russound_rio aiorussound==2.3.2 From 7bb93d4f3e2daaa8919d1c84b0ce68f5c0fade10 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 30 Aug 2024 04:05:27 +0200 Subject: [PATCH 0032/1309] Deduplicate warning messages in recorder DB migration (#124845) --- .../components/recorder/migration.py | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 7127a576580..3da0bc9abb1 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -128,6 +128,11 @@ MIGRATION_NOTE_OFFLINE = ( "Home Assistant will not start until the upgrade is completed. Please be patient " "and do not turn off or restart Home Assistant while the upgrade is in progress!" ) +MIGRATION_NOTE_MINUTES = ( + "Note: this may take several minutes on large databases and slow machines. " + "Please be patient!" +) +MIGRATION_NOTE_WHILE = "This will take a while; please be patient!" _EMPTY_ENTITY_ID = "missing.entity_id" _EMPTY_EVENT_TYPE = "missing_event_type" @@ -373,11 +378,10 @@ def _create_index( index = index_list[0] _LOGGER.debug("Creating %s index", index_name) _LOGGER.warning( - "Adding index `%s` to table `%s`. Note: this can take several " - "minutes on large databases and slow machines. Please " - "be patient!", + "Adding index `%s` to table `%s`. %s", index_name, table_name, + MIGRATION_NOTE_MINUTES, ) with session_scope(session=session_maker()) as session: try: @@ -422,11 +426,10 @@ def _drop_index( DO NOT USE THIS FUNCTION IN ANY OPERATION THAT TAKES USER INPUT. """ _LOGGER.warning( - "Dropping index `%s` from table `%s`. Note: this can take several " - "minutes on large databases and slow machines. Please " - "be patient!", + "Dropping index `%s` from table `%s`. %s", index_name, table_name, + MIGRATION_NOTE_MINUTES, ) index_to_drop: str | None = None with session_scope(session=session_maker()) as session: @@ -472,13 +475,10 @@ def _add_columns( ) -> None: """Add columns to a table.""" _LOGGER.warning( - ( - "Adding columns %s to table %s. Note: this can take several " - "minutes on large databases and slow machines. Please " - "be patient!" - ), + "Adding columns %s to table %s. %s", ", ".join(column.split(" ")[0] for column in columns_def), table_name, + MIGRATION_NOTE_MINUTES, ) columns_def = [f"ADD {col_def}" for col_def in columns_def] @@ -534,13 +534,10 @@ def _modify_columns( return _LOGGER.warning( - ( - "Modifying columns %s in table %s. Note: this can take several " - "minutes on large databases and slow machines. Please " - "be patient!" - ), + "Modifying columns %s in table %s. %s", ", ".join(column.split(" ")[0] for column in columns_def), table_name, + MIGRATION_NOTE_MINUTES, ) if engine.dialect.name == SupportedDialect.POSTGRESQL: @@ -1781,10 +1778,9 @@ def _migrate_statistics_columns_to_timestamp_removing_duplicates( except IntegrityError as ex: _LOGGER.error( "Statistics table contains duplicate entries: %s; " - "Cleaning up duplicates and trying again; " - "This will take a while; " - "Please be patient!", + "Cleaning up duplicates and trying again; %s", ex, + MIGRATION_NOTE_WHILE, ) # There may be duplicated statistics entries, delete duplicates # and try again @@ -1812,10 +1808,9 @@ def _correct_table_character_set_and_collation( """Correct issues detected by validate_db_schema.""" # Attempt to convert the table to utf8mb4 _LOGGER.warning( - "Updating character set and collation of table %s to utf8mb4. " - "Note: this can take several minutes on large databases and slow " - "machines. Please be patient!", + "Updating character set and collation of table %s to utf8mb4. %s", table, + MIGRATION_NOTE_MINUTES, ) with ( contextlib.suppress(SQLAlchemyError), @@ -2736,10 +2731,7 @@ def rebuild_sqlite_table( orig_name = table_table.name temp_name = f"{table_table.name}_temp_{int(time())}" - _LOGGER.warning( - "Rebuilding SQLite table %s; This will take a while; Please be patient!", - orig_name, - ) + _LOGGER.warning("Rebuilding SQLite table %s; %s", orig_name, MIGRATION_NOTE_WHILE) try: # 12 step SQLite table rebuild From 3e0bd44d2acbdf31e7bea1e521709b479a45b272 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Aug 2024 16:19:12 -1000 Subject: [PATCH 0033/1309] Bump aioesphomeapi to 25.3.1 (#124890) changelog: https://github.com/esphome/aioesphomeapi/compare/v25.2.1...v25.3.1 --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 454b547cdf4..9d42b7206e3 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==25.2.1", + "aioesphomeapi==25.3.1", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.0.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 405eb9c2cd7..d04dcee2c88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==25.2.1 +aioesphomeapi==25.3.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c101b2db4c..5ebeb167d47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -225,7 +225,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==25.2.1 +aioesphomeapi==25.3.1 # homeassistant.components.flo aioflo==2021.11.0 From cf90e77e57d3f6bb0cac1df4b169d269d6fac68e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Aug 2024 21:35:19 -1000 Subject: [PATCH 0034/1309] Add a repair issue for Yale Home users using the August integration (#124895) The Yale Home brand will stop working with the August integration very soon. Users must migrate to the Yale integration to avoid an interruption in service. --- homeassistant/components/august/__init__.py | 32 +++++++++++++++++-- .../components/august/config_flow.py | 10 ++++-- homeassistant/components/august/strings.json | 6 ++++ tests/components/august/test_config_flow.py | 4 +-- tests/components/august/test_init.py | 28 +++++++++++++++- 5 files changed, 73 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 47a7f75611a..434db46384b 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -6,15 +6,16 @@ from pathlib import Path from typing import cast from aiohttp import ClientResponseError +from yalexs.const import Brand from yalexs.exceptions import AugustApiAIOHTTPError from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation from yalexs.manager.gateway import Config as YaleXSConfig from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from .const import DOMAIN, PLATFORMS from .data import AugustData @@ -24,6 +25,26 @@ from .util import async_create_august_clientsession type AugustConfigEntry = ConfigEntry[AugustData] +@callback +def _async_create_yale_brand_migration_issue( + hass: HomeAssistant, entry: AugustConfigEntry +) -> None: + """Create an issue for a brand migration.""" + ir.async_create_issue( + hass, + DOMAIN, + "yale_brand_migration", + breaks_in_ha_version="2024.9", + learn_more_url="https://www.home-assistant.io/integrations/yale", + translation_key="yale_brand_migration", + is_fixable=False, + severity=ir.IssueSeverity.CRITICAL, + translation_placeholders={ + "migrate_url": "https://my.home-assistant.io/redirect/config_flow_start?domain=yale" + }, + ) + + async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bool: """Set up August from a config entry.""" session = async_create_august_clientsession(hass) @@ -40,6 +61,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo return True +async def async_remove_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> None: + """Remove an August config entry.""" + ir.async_delete_issue(hass, DOMAIN, "yale_brand_migration") + + async def async_unload_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -51,6 +77,8 @@ async def async_setup_august( """Set up the August component.""" config = cast(YaleXSConfig, entry.data) await august_gateway.async_setup(config) + if august_gateway.api.brand == Brand.YALE_HOME: + _async_create_yale_brand_migration_issue(hass, entry) await august_gateway.async_authenticate() await august_gateway.async_refresh_access_token_if_needed() data = entry.runtime_data = AugustData(hass, august_gateway) diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 2a1a20a9dc4..58c3549fe4d 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -9,7 +9,7 @@ from typing import Any import aiohttp import voluptuous as vol from yalexs.authenticator_common import ValidationResult -from yalexs.const import BRANDS_WITHOUT_OAUTH, DEFAULT_BRAND +from yalexs.const import BRANDS_WITHOUT_OAUTH, DEFAULT_BRAND, Brand from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -28,6 +28,12 @@ from .const import ( from .gateway import AugustGateway from .util import async_create_august_clientsession +# The Yale Home Brand is not supported by the August integration +# anymore and should migrate to the Yale integration +AVAILABLE_BRANDS = BRANDS_WITHOUT_OAUTH.copy() +del AVAILABLE_BRANDS[Brand.YALE_HOME] + + _LOGGER = logging.getLogger(__name__) @@ -118,7 +124,7 @@ class AugustConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required( CONF_BRAND, default=self._user_auth_details.get(CONF_BRAND, DEFAULT_BRAND), - ): vol.In(BRANDS_WITHOUT_OAUTH), + ): vol.In(AVAILABLE_BRANDS), vol.Required( CONF_LOGIN_METHOD, default=self._user_auth_details.get( diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json index 772a8dca479..589a494590b 100644 --- a/homeassistant/components/august/strings.json +++ b/homeassistant/components/august/strings.json @@ -1,4 +1,10 @@ { + "issues": { + "yale_brand_migration": { + "title": "Yale Home has a new integration", + "description": "Add the [Yale integration]({migrate_url}), and remove the August integration as soon as possible to avoid an interruption in service. The Yale Home brand will stop working with the August integration soon and will be removed in a future release." + } + }, "config": { "error": { "unhandled": "Unhandled error: {error}", diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py index 9902901d29f..b3138342b8c 100644 --- a/tests/components/august/test_config_flow.py +++ b/tests/components/august/test_config_flow.py @@ -385,7 +385,7 @@ async def test_switching_brands(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_BRAND: "yale_home", + CONF_BRAND: "yale_access", CONF_LOGIN_METHOD: "email", CONF_USERNAME: "my@email.tld", CONF_PASSWORD: "test-password", @@ -396,4 +396,4 @@ async def test_switching_brands(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" assert len(mock_setup_entry.mock_calls) == 1 - assert entry.data[CONF_BRAND] == "yale_home" + assert entry.data[CONF_BRAND] == "yale_access" diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 954436f209a..1bbe8033ec8 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch from aiohttp import ClientResponseError import pytest from yalexs.authenticator_common import AuthenticationState +from yalexs.const import Brand from yalexs.exceptions import AugustApiAIOHTTPError from homeassistant.components.august.const import DOMAIN @@ -20,7 +21,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -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 .mocks import ( @@ -420,3 +425,24 @@ async def test_device_remove_devices( ) response = await client.remove_device(dead_device_entry.id, config_entry.entry_id) assert response["success"] + + +async def test_brand_migration_issue(hass: HomeAssistant) -> None: + """Test creating and removing the brand migration issue.""" + august_operative_lock = await _mock_operative_august_lock_detail(hass) + config_entry = await _create_august_with_devices( + hass, [august_operative_lock], brand=Brand.YALE_HOME + ) + + assert config_entry.state is ConfigEntryState.LOADED + + issue_reg = ir.async_get(hass) + issue_entry = issue_reg.async_get_issue(DOMAIN, "yale_brand_migration") + assert issue_entry + assert issue_entry.severity == ir.IssueSeverity.CRITICAL + assert issue_entry.translation_placeholders == { + "migrate_url": "https://my.home-assistant.io/redirect/config_flow_start?domain=yale" + } + + await hass.config_entries.async_remove(config_entry.entry_id) + assert not issue_reg.async_get_issue(DOMAIN, "yale_brand_migration") From df60e59a9541276a8f2a3110fe771f95c841a8f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Aug 2024 21:37:19 -1000 Subject: [PATCH 0035/1309] Address yale review comments part 2 (#124887) * Remove some unneeded block till done * Additional state check cleanups and snapshots * Use more snapshots in yale tests --- .../components/yale/snapshots/test_lock.ambr | 37 ++++ .../yale/snapshots/test_sensor.ambr | 95 +++++++++ tests/components/yale/test_button.py | 1 - tests/components/yale/test_lock.py | 193 ++++++------------ tests/components/yale/test_sensor.py | 106 +++------- 5 files changed, 226 insertions(+), 206 deletions(-) create mode 100644 tests/components/yale/snapshots/test_lock.ambr create mode 100644 tests/components/yale/snapshots/test_sensor.ambr diff --git a/tests/components/yale/snapshots/test_lock.ambr b/tests/components/yale/snapshots/test_lock.ambr new file mode 100644 index 00000000000..b1a9f6a4d86 --- /dev/null +++ b/tests/components/yale/snapshots/test_lock.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_lock_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': 'online_with_doorsense_name', + 'config_entries': , + 'configuration_url': 'https://account.aaecosystem.com', + 'connections': set({ + tuple( + 'bluetooth', + '12:22', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'yale', + 'online_with_doorsense', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Yale Home Inc.', + 'model': 'AUG-MD01', + 'model_id': None, + 'name': 'online_with_doorsense Name', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': 'online_with_doorsense Name', + 'sw_version': 'undefined-4.3.0-1.8.14', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/yale/snapshots/test_sensor.ambr b/tests/components/yale/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..a425cfa90de --- /dev/null +++ b/tests/components/yale/snapshots/test_sensor.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_lock_operator_autorelock + ReadOnlyDict({ + 'autorelock': True, + 'friendly_name': 'online_with_doorsense Name Operator', + 'keypad': False, + 'manual': False, + 'method': 'autorelock', + 'remote': False, + 'tag': False, + }) +# --- +# name: test_lock_operator_keypad + ReadOnlyDict({ + 'autorelock': False, + 'friendly_name': 'online_with_doorsense Name Operator', + 'keypad': True, + 'manual': False, + 'method': 'keypad', + 'remote': False, + 'tag': False, + }) +# --- +# name: test_lock_operator_manual + ReadOnlyDict({ + 'autorelock': False, + 'friendly_name': 'online_with_doorsense Name Operator', + 'keypad': False, + 'manual': True, + 'method': 'manual', + 'remote': False, + 'tag': False, + }) +# --- +# name: test_lock_operator_remote + ReadOnlyDict({ + 'autorelock': False, + 'friendly_name': 'online_with_doorsense Name Operator', + 'keypad': False, + 'manual': False, + 'method': 'remote', + 'remote': True, + 'tag': False, + }) +# --- +# name: test_restored_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'autorelock': False, + 'entity_picture': 'image.png', + 'friendly_name': 'online_with_doorsense Name Operator', + 'keypad': False, + 'manual': False, + 'method': 'tag', + 'remote': False, + 'tag': True, + }), + 'context': , + 'entity_id': 'sensor.online_with_doorsense_name_operator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Tag Unlock', + }) +# --- +# name: test_unlock_operator_manual + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'autorelock': False, + 'friendly_name': 'online_with_doorsense Name Operator', + 'keypad': False, + 'manual': True, + 'method': 'manual', + 'remote': False, + 'tag': False, + }), + 'context': , + 'entity_id': 'sensor.online_with_doorsense_name_operator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Your favorite elven princess', + }) +# --- +# name: test_unlock_operator_tag + ReadOnlyDict({ + 'autorelock': False, + 'friendly_name': 'online_with_doorsense Name Operator', + 'keypad': False, + 'manual': False, + 'method': 'tag', + 'remote': False, + 'tag': True, + }) +# --- diff --git a/tests/components/yale/test_button.py b/tests/components/yale/test_button.py index ebd22f1da59..92d3ecef859 100644 --- a/tests/components/yale/test_button.py +++ b/tests/components/yale/test_button.py @@ -20,5 +20,4 @@ async def test_wake_lock(hass: HomeAssistant) -> None: await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True ) - await hass.async_block_till_done() api_instance.async_status_async.assert_called_once() diff --git a/tests/components/yale/test_lock.py b/tests/components/yale/test_lock.py index b449be9153d..2bbb7408953 100644 --- a/tests/components/yale/test_lock.py +++ b/tests/components/yale/test_lock.py @@ -5,6 +5,7 @@ import datetime from aiohttp import ClientResponseError from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy import SnapshotAssertion from yalexs.manager.activity import INITIAL_LOCK_RESYNC_TIME from homeassistant.components.lock import ( @@ -41,7 +42,7 @@ from tests.common import async_fire_time_changed async def test_lock_device_registry( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion ) -> None: """Test creation of a lock with doorsense and bridge ands up in the registry.""" lock_one = await _mock_doorsense_enabled_yale_lock_detail(hass) @@ -50,10 +51,7 @@ async def test_lock_device_registry( reg_device = device_registry.async_get_device( identifiers={("yale", "online_with_doorsense")} ) - assert reg_device.model == "AUG-MD01" - assert reg_device.sw_version == "undefined-4.3.0-1.8.14" - assert reg_device.name == "online_with_doorsense Name" - assert reg_device.manufacturer == "Yale Home Inc." + assert reg_device == snapshot async def test_lock_changed_by(hass: HomeAssistant) -> None: @@ -63,14 +61,9 @@ async def test_lock_changed_by(hass: HomeAssistant) -> None: activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json") await _create_yale_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_LOCKED - - assert ( - lock_online_with_doorsense_name.attributes.get("changed_by") - == "Your favorite elven princess" - ) + lock_state = hass.states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_LOCKED + assert lock_state.attributes["changed_by"] == "Your favorite elven princess" async def test_state_locking(hass: HomeAssistant) -> None: @@ -80,9 +73,7 @@ async def test_state_locking(hass: HomeAssistant) -> None: activities = await _mock_activities_from_fixture(hass, "get_activity.locking.json") await _create_yale_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_LOCKING + assert hass.states.get("lock.online_with_doorsense_name").state == STATE_LOCKING async def test_state_unlocking(hass: HomeAssistant) -> None: @@ -106,9 +97,7 @@ async def test_state_jammed(hass: HomeAssistant) -> None: activities = await _mock_activities_from_fixture(hass, "get_activity.jammed.json") await _create_yale_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_JAMMED + assert hass.states.get("lock.online_with_doorsense_name").state == STATE_JAMMED async def test_one_lock_operation( @@ -118,44 +107,31 @@ async def test_one_lock_operation( lock_one = await _mock_doorsense_enabled_yale_lock_detail(hass) await _create_yale_with_devices(hass, [lock_one]) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_state.state == STATE_LOCKED - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + lock_state = hass.states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_UNLOCKED - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) - await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + lock_state = hass.states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_LOCKED # No activity means it will be unavailable until the activity feed has data - lock_operator_sensor = entity_registry.async_get( - "sensor.online_with_doorsense_name_operator" - ) - assert lock_operator_sensor - assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == STATE_UNKNOWN - ) + assert entity_registry.async_get("sensor.online_with_doorsense_name_operator") + operator_state = hass.states.get("sensor.online_with_doorsense_name_operator") + assert operator_state.state == STATE_UNKNOWN async def test_open_lock_operation(hass: HomeAssistant) -> None: @@ -163,15 +139,12 @@ async def test_open_lock_operation(hass: HomeAssistant) -> None: lock_with_unlatch = await _mock_lock_with_unlatch(hass) await _create_yale_with_devices(hass, [lock_with_unlatch]) - lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") - assert lock_online_with_unlatch_name.state == STATE_LOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == STATE_LOCKED data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) - await hass.async_block_till_done() - lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") - assert lock_online_with_unlatch_name.state == STATE_UNLOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == STATE_UNLOCKED async def test_open_lock_operation_socketio_connected( @@ -186,12 +159,10 @@ async def test_open_lock_operation_socketio_connected( _, socketio = await _create_yale_with_devices(hass, [lock_with_unlatch]) socketio.connected = True - lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") - assert lock_online_with_unlatch_name.state == STATE_LOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == STATE_LOCKED data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) - await hass.async_block_till_done() listener = list(socketio._listeners)[0] listener( @@ -205,8 +176,7 @@ async def test_open_lock_operation_socketio_connected( await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") - assert lock_online_with_unlatch_name.state == STATE_UNLOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == STATE_UNLOCKED await hass.async_block_till_done() @@ -218,23 +188,18 @@ async def test_one_lock_operation_socketio_connected( """Test lock and unlock operations are async when socketio is connected.""" lock_one = await _mock_doorsense_enabled_yale_lock_detail(hass) assert lock_one.pubsub_channel == "pubsub" + states = hass.states _, socketio = await _create_yale_with_devices(hass, [lock_one]) socketio.connected = True - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_LOCKED - - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + lock_state = hass.states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_LOCKED + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - await hass.async_block_till_done() listener = list(socketio._listeners)[0] listener( @@ -248,17 +213,12 @@ async def test_one_lock_operation_socketio_connected( await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED - - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + lock_state = states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_UNLOCKED + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) - await hass.async_block_till_done() listener( lock_one.device_id, @@ -271,17 +231,12 @@ async def test_one_lock_operation_socketio_connected( await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKED # No activity means it will be unavailable until the activity feed has data - lock_operator_sensor = entity_registry.async_get( - "sensor.online_with_doorsense_name_operator" - ) - assert lock_operator_sensor + assert entity_registry.async_get("sensor.online_with_doorsense_name_operator") assert ( - hass.states.get("sensor.online_with_doorsense_name_operator").state - == STATE_UNKNOWN + states.get("sensor.online_with_doorsense_name_operator").state == STATE_UNKNOWN ) freezer.tick(INITIAL_LOCK_RESYNC_TIME) @@ -296,8 +251,7 @@ async def test_one_lock_operation_socketio_connected( await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKED async def test_lock_jammed(hass: HomeAssistant) -> None: @@ -315,22 +269,16 @@ async def test_lock_jammed(hass: HomeAssistant) -> None: }, ) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_LOCKED - - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + states = hass.states + lock_state = states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_LOCKED + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_JAMMED + assert states.get("lock.online_with_doorsense_name").state == STATE_JAMMED async def test_lock_throws_exception_on_unknown_status_code( @@ -350,15 +298,10 @@ async def test_lock_throws_exception_on_unknown_status_code( }, ) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_LOCKED - - assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 - assert ( - lock_online_with_doorsense_name.attributes.get("friendly_name") - == "online_with_doorsense Name" - ) + lock_state = hass.states.get("lock.online_with_doorsense_name") + assert lock_state.state == STATE_LOCKED + assert lock_state.attributes["battery_level"] == 92 + assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} with pytest.raises(ClientResponseError): @@ -373,9 +316,7 @@ async def test_one_lock_unknown_state(hass: HomeAssistant) -> None: ) await _create_yale_with_devices(hass, [lock_one]) - lock_brokenid_name = hass.states.get("lock.brokenid_name") - - assert lock_brokenid_name.state == STATE_UNKNOWN + assert hass.states.get("lock.brokenid_name").state == STATE_UNKNOWN async def test_lock_bridge_offline(hass: HomeAssistant) -> None: @@ -387,9 +328,8 @@ async def test_lock_bridge_offline(hass: HomeAssistant) -> None: ) await _create_yale_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_UNAVAILABLE + states = hass.states + assert states.get("lock.online_with_doorsense_name").state == STATE_UNAVAILABLE async def test_lock_bridge_online(hass: HomeAssistant) -> None: @@ -401,9 +341,8 @@ async def test_lock_bridge_online(hass: HomeAssistant) -> None: ) await _create_yale_with_devices(hass, [lock_one], activities=activities) - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_LOCKED + states = hass.states + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKED async def test_lock_update_via_socketio(hass: HomeAssistant) -> None: @@ -416,10 +355,9 @@ async def test_lock_update_via_socketio(hass: HomeAssistant) -> None: hass, [lock_one], activities=activities ) socketio.connected = True + states = hass.states - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKED listener = list(socketio._listeners)[0] listener( @@ -433,8 +371,7 @@ async def test_lock_update_via_socketio(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING listener( lock_one.device_id, @@ -447,25 +384,21 @@ async def test_lock_update_via_socketio(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING socketio.connected = True async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING # Ensure socketio status is always preserved async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2)) await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING listener( lock_one.device_id, @@ -478,13 +411,11 @@ async def test_lock_update_via_socketio(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4)) await hass.async_block_till_done() - lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/yale/test_sensor.py b/tests/components/yale/test_sensor.py index caf8781b4ad..5d724b4bb9d 100644 --- a/tests/components/yale/test_sensor.py +++ b/tests/components/yale/test_sensor.py @@ -2,6 +2,8 @@ from typing import Any +from syrupy import SnapshotAssertion + from homeassistant import core as ha from homeassistant.const import ( ATTR_ENTITY_PICTURE, @@ -28,13 +30,9 @@ async def test_create_doorbell(hass: HomeAssistant) -> None: doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") await _create_yale_with_devices(hass, [doorbell_one]) - sensor_k98gidt45gul_name_battery = hass.states.get( - "sensor.k98gidt45gul_name_battery" - ) - assert sensor_k98gidt45gul_name_battery.state == "96" - assert ( - sensor_k98gidt45gul_name_battery.attributes["unit_of_measurement"] == PERCENTAGE - ) + battery_state = hass.states.get("sensor.k98gidt45gul_name_battery") + assert battery_state.state == "96" + assert battery_state.attributes["unit_of_measurement"] == PERCENTAGE async def test_create_doorbell_offline( @@ -44,9 +42,9 @@ async def test_create_doorbell_offline( doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") await _create_yale_with_devices(hass, [doorbell_one]) - sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery") - assert sensor_tmt100_name_battery.state == "81" - assert sensor_tmt100_name_battery.attributes["unit_of_measurement"] == PERCENTAGE + battery_state = hass.states.get("sensor.tmt100_name_battery") + assert battery_state.state == "81" + assert battery_state.attributes["unit_of_measurement"] == PERCENTAGE entry = entity_registry.async_get("sensor.tmt100_name_battery") assert entry @@ -71,25 +69,21 @@ async def test_create_lock_with_linked_keypad( lock_one = await _mock_lock_from_fixture(hass, "get_lock.doorsense_init.json") await _create_yale_with_devices(hass, [lock_one]) - sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( + battery_state = hass.states.get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" ) - assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88" - assert ( - sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[ - "unit_of_measurement" - ] - == PERCENTAGE - ) + assert battery_state.state == "88" + assert battery_state.attributes["unit_of_measurement"] == PERCENTAGE + entry = entity_registry.async_get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" ) assert entry assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_device_battery" - state = hass.states.get("sensor.front_door_lock_keypad_battery") - assert state.state == "62" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + keypad_battery_state = hass.states.get("sensor.front_door_lock_keypad_battery") + assert keypad_battery_state.state == "62" + assert keypad_battery_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE entry = entity_registry.async_get("sensor.front_door_lock_keypad_battery") assert entry assert entry.unique_id == "5bc65c24e6ef2a263e1450a8_linked_keypad_battery" @@ -102,16 +96,11 @@ async def test_create_lock_with_low_battery_linked_keypad( lock_one = await _mock_lock_from_fixture(hass, "get_lock.low_keypad_battery.json") await _create_yale_with_devices(hass, [lock_one]) - sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( + battery_state = hass.states.get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" ) - assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88" - assert ( - sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[ - "unit_of_measurement" - ] - == PERCENTAGE - ) + assert battery_state.state == "88" + assert battery_state.attributes["unit_of_measurement"] == PERCENTAGE entry = entity_registry.async_get( "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" ) @@ -166,7 +155,7 @@ async def test_lock_operator_bluetooth( async def test_lock_operator_keypad( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_yale_lock_detail(hass) @@ -183,16 +172,11 @@ async def test_lock_operator_keypad( state = hass.states.get("sensor.online_with_doorsense_name_operator") assert state.state == "Your favorite elven princess" - assert state.attributes["manual"] is False - assert state.attributes["tag"] is False - assert state.attributes["remote"] is False - assert state.attributes["keypad"] is True - assert state.attributes["autorelock"] is False - assert state.attributes["method"] == "keypad" + assert state.attributes == snapshot async def test_lock_operator_remote( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_yale_lock_detail(hass) @@ -207,16 +191,11 @@ async def test_lock_operator_remote( state = hass.states.get("sensor.online_with_doorsense_name_operator") assert state.state == "Your favorite elven princess" - assert state.attributes["manual"] is False - assert state.attributes["tag"] is False - assert state.attributes["remote"] is True - assert state.attributes["keypad"] is False - assert state.attributes["autorelock"] is False - assert state.attributes["method"] == "remote" + assert state.attributes == snapshot async def test_lock_operator_manual( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_yale_lock_detail(hass) @@ -232,16 +211,11 @@ async def test_lock_operator_manual( assert lock_operator_sensor state = hass.states.get("sensor.online_with_doorsense_name_operator") assert state.state == "Your favorite elven princess" - assert state.attributes["manual"] is True - assert state.attributes["tag"] is False - assert state.attributes["remote"] is False - assert state.attributes["keypad"] is False - assert state.attributes["autorelock"] is False - assert state.attributes["method"] == "manual" + assert state.attributes == snapshot async def test_lock_operator_autorelock( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test operation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_yale_lock_detail(hass) @@ -258,16 +232,11 @@ async def test_lock_operator_autorelock( state = hass.states.get("sensor.online_with_doorsense_name_operator") assert state.state == "Auto Relock" - assert state.attributes["manual"] is False - assert state.attributes["tag"] is False - assert state.attributes["remote"] is False - assert state.attributes["keypad"] is False - assert state.attributes["autorelock"] is True - assert state.attributes["method"] == "autorelock" + assert state.attributes == snapshot async def test_unlock_operator_manual( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test operation of a lock manually.""" lock_one = await _mock_doorsense_enabled_yale_lock_detail(hass) @@ -284,16 +253,11 @@ async def test_unlock_operator_manual( state = hass.states.get("sensor.online_with_doorsense_name_operator") assert state.state == "Your favorite elven princess" - assert state.attributes["manual"] is True - assert state.attributes["tag"] is False - assert state.attributes["remote"] is False - assert state.attributes["keypad"] is False - assert state.attributes["autorelock"] is False - assert state.attributes["method"] == "manual" + assert state == snapshot async def test_unlock_operator_tag( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test operation of a lock with a tag.""" lock_one = await _mock_doorsense_enabled_yale_lock_detail(hass) @@ -310,16 +274,11 @@ async def test_unlock_operator_tag( state = hass.states.get("sensor.online_with_doorsense_name_operator") assert state.state == "Your favorite elven princess" - assert state.attributes["manual"] is False - assert state.attributes["tag"] is True - assert state.attributes["remote"] is False - assert state.attributes["keypad"] is False - assert state.attributes["autorelock"] is False - assert state.attributes["method"] == "tag" + assert state.attributes == snapshot async def test_restored_state( - hass: HomeAssistant, hass_storage: dict[str, Any] + hass: HomeAssistant, hass_storage: dict[str, Any], snapshot: SnapshotAssertion ) -> None: """Test restored state.""" @@ -358,5 +317,4 @@ async def test_restored_state( state = hass.states.get(entity_id) assert state.state == "Tag Unlock" - assert state.attributes["method"] == "tag" - assert state.attributes[ATTR_ENTITY_PICTURE] == "image.png" + assert state == snapshot From 600c6a0dcba3285c9b163c121f6bbaf63a57862a Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 30 Aug 2024 10:05:28 +0200 Subject: [PATCH 0036/1309] Bump lmcloud to 1.2.1 (#124908) --- homeassistant/components/lamarzocco/__init__.py | 1 + homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index dfcaa54047d..02e47ecd78e 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -53,6 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], + client=get_async_client(hass), ) # initialize local API diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 73d14250525..37a4e1d0c99 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["lmcloud"], - "requirements": ["lmcloud==1.1.13"] + "requirements": ["lmcloud==1.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index d04dcee2c88..171a4ae9cdd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1282,7 +1282,7 @@ linear-garage-door==0.2.9 linode-api==4.1.9b1 # homeassistant.components.lamarzocco -lmcloud==1.1.13 +lmcloud==1.2.1 # homeassistant.components.google_maps locationsharinglib==5.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ebeb167d47..8c9d9225c65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1060,7 +1060,7 @@ libsoundtouch==0.8 linear-garage-door==0.2.9 # homeassistant.components.lamarzocco -lmcloud==1.1.13 +lmcloud==1.2.1 # homeassistant.components.london_underground london-tube-status==0.5 From f5e0382123885262c221334f41833a2e80d1cf2b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Aug 2024 10:29:25 +0200 Subject: [PATCH 0037/1309] Bump github/codeql-action from 3.26.5 to 3.26.6 (#124898) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.26.5 to 3.26.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3.26.5...v3.26.6) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a4653a833c4..33c7d6a2711 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.1.7 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.26.5 + uses: github/codeql-action/init@v3.26.6 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.26.5 + uses: github/codeql-action/analyze@v3.26.6 with: category: "/language:python" From 252f05e0f76b93c730bdfca11b7a3573e322e745 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Fri, 30 Aug 2024 10:41:07 +0200 Subject: [PATCH 0038/1309] Update diagnostics for BSBLan (#124508) * update diagnostics to include static and make room for multiple coordinator data objects * fix mac address is not stored in config_entry but on device --- .../components/bsblan/diagnostics.py | 5 +- homeassistant/components/bsblan/entity.py | 6 +- .../bsblan/snapshots/test_diagnostics.ambr | 88 +++++++++++-------- 3 files changed, 60 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py index 3b42d47e1d3..b4ff67f4fbf 100644 --- a/homeassistant/components/bsblan/diagnostics.py +++ b/homeassistant/components/bsblan/diagnostics.py @@ -20,5 +20,8 @@ async def async_get_config_entry_diagnostics( return { "info": data.info.to_dict(), "device": data.device.to_dict(), - "state": data.coordinator.data.state.to_dict(), + "coordinator_data": { + "state": data.coordinator.data.state.to_dict(), + }, + "static": data.static.to_dict(), } diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py index 0c507938794..252c397f4f2 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -22,10 +22,10 @@ class BSBLanEntity(CoordinatorEntity[BSBLanUpdateCoordinator]): def __init__(self, coordinator: BSBLanUpdateCoordinator, data: BSBLanData) -> None: """Initialize BSBLan entity.""" super().__init__(coordinator, data) - host = self.coordinator.config_entry.data["host"] - mac = self.coordinator.config_entry.data["mac"] + host = coordinator.config_entry.data["host"] + mac = data.device.MAC self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, data.device.MAC)}, + identifiers={(DOMAIN, mac)}, connections={(CONNECTION_NETWORK_MAC, format_mac(mac))}, name=data.device.name, manufacturer="BSBLAN Inc.", diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr index b172d26c249..c9a82edf4e2 100644 --- a/tests/components/bsblan/snapshots/test_diagnostics.ambr +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -1,6 +1,52 @@ # serializer version: 1 # name: test_diagnostics dict({ + 'coordinator_data': dict({ + 'state': dict({ + 'current_temperature': dict({ + 'data_type': 0, + 'desc': '', + 'name': 'Room temp 1 actual value', + 'unit': '°C', + 'value': '18.6', + }), + 'hvac_action': dict({ + 'data_type': 1, + 'desc': 'Raumtemp’begrenzung', + 'name': 'Status heating circuit 1', + 'unit': '', + 'value': '122', + }), + 'hvac_mode': dict({ + 'data_type': 1, + 'desc': 'Komfort', + 'name': 'Operating mode', + 'unit': '', + 'value': 'heat', + }), + 'hvac_mode2': dict({ + 'data_type': 1, + 'desc': 'Reduziert', + 'name': 'Operating mode', + 'unit': '', + 'value': '2', + }), + 'room1_thermostat_mode': dict({ + 'data_type': 1, + 'desc': 'Kein Bedarf', + 'name': 'Raumthermostat 1', + 'unit': '', + 'value': '0', + }), + 'target_temperature': dict({ + 'data_type': 0, + 'desc': '', + 'name': 'Room temperature Comfort setpoint', + 'unit': '°C', + 'value': '18.5', + }), + }), + }), 'device': dict({ 'MAC': '00:80:41:19:69:90', 'name': 'BSB-LAN', @@ -30,48 +76,20 @@ 'value': 'RVS21.831F/127', }), }), - 'state': dict({ - 'current_temperature': dict({ + 'static': dict({ + 'max_temp': dict({ 'data_type': 0, 'desc': '', - 'name': 'Room temp 1 actual value', + 'name': 'Summer/winter changeover temp heat circuit 1', 'unit': '°C', - 'value': '18.6', + 'value': '20.0', }), - 'hvac_action': dict({ - 'data_type': 1, - 'desc': 'Raumtemp’begrenzung', - 'name': 'Status heating circuit 1', - 'unit': '', - 'value': '122', - }), - 'hvac_mode': dict({ - 'data_type': 1, - 'desc': 'Komfort', - 'name': 'Operating mode', - 'unit': '', - 'value': 'heat', - }), - 'hvac_mode2': dict({ - 'data_type': 1, - 'desc': 'Reduziert', - 'name': 'Operating mode', - 'unit': '', - 'value': '2', - }), - 'room1_thermostat_mode': dict({ - 'data_type': 1, - 'desc': 'Kein Bedarf', - 'name': 'Raumthermostat 1', - 'unit': '', - 'value': '0', - }), - 'target_temperature': dict({ + 'min_temp': dict({ 'data_type': 0, 'desc': '', - 'name': 'Room temperature Comfort setpoint', + 'name': 'Room temp frost protection setpoint', 'unit': '°C', - 'value': '18.5', + 'value': '8.0', }), }), }) From cc4340b80ceacaecb4ffb8d5d5e3bcb2909ca504 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 30 Aug 2024 10:50:18 +0200 Subject: [PATCH 0039/1309] Remove update call from init in ViCare integration (#124905) fix --- homeassistant/components/vicare/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 5d51abfbbf6..4ac3c504d9a 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -932,7 +932,9 @@ async def async_setup_entry( await hass.async_add_executor_job( _build_entities, device_list, - ) + ), + # run update to have device_class set depending on unit_of_measurement + True, ) @@ -950,8 +952,6 @@ class ViCareSensor(ViCareEntity, SensorEntity): """Initialize the sensor.""" super().__init__(device_config, api, description.key) self.entity_description = description - # run update to have device_class set depending on unit_of_measurement - self.update() @property def available(self) -> bool: From a9975071c3e868edaa2358b6161e12697e60afc3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Aug 2024 10:53:06 +0200 Subject: [PATCH 0040/1309] Bump actions/setup-python from 5.1.1 to 5.2.0 (#124899) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.1.1 to 5.2.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5.1.1...v5.2.0) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 32 +++++++++++++++--------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 2 +- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index d206f8fe8c8..ab64f9e5519 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -32,7 +32,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -116,7 +116,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -453,7 +453,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b62fff06c0c..24b204f3f55 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -234,7 +234,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -279,7 +279,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -319,7 +319,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -359,7 +359,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -454,7 +454,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -538,7 +538,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -571,7 +571,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -605,7 +605,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -648,7 +648,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -695,7 +695,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -740,7 +740,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -815,7 +815,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -879,7 +879,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -999,7 +999,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1125,7 +1125,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1271,7 +1271,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 0ab95510480..4b3907e6cb9 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.1.7 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 694208d30ac..735163e3b12 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -36,7 +36,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v5.1.1 + uses: actions/setup-python@v5.2.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true From 4940968cd59ee1d13586a697d5757a9bbe3928ab Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:02:29 +0200 Subject: [PATCH 0041/1309] Bump lmcloud 1.2.2 (#124911) bump lmcloud 1.2.2 --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 37a4e1d0c99..181a2b9ab9b 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["lmcloud"], - "requirements": ["lmcloud==1.2.1"] + "requirements": ["lmcloud==1.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 171a4ae9cdd..4fd2c8932f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1282,7 +1282,7 @@ linear-garage-door==0.2.9 linode-api==4.1.9b1 # homeassistant.components.lamarzocco -lmcloud==1.2.1 +lmcloud==1.2.2 # homeassistant.components.google_maps locationsharinglib==5.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c9d9225c65..63721bc9360 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1060,7 +1060,7 @@ libsoundtouch==0.8 linear-garage-door==0.2.9 # homeassistant.components.lamarzocco -lmcloud==1.2.1 +lmcloud==1.2.2 # homeassistant.components.london_underground london-tube-status==0.5 From 6833af6286da53b60988154a8bde74f285b6024a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:04:58 +0200 Subject: [PATCH 0042/1309] Improve config flow type hints (n-p) (#124909) --- .../components/netgear/config_flow.py | 10 +++++++-- homeassistant/components/nuki/config_flow.py | 11 +++++++--- .../components/octoprint/config_flow.py | 18 +++++++++------ .../components/omnilogic/config_flow.py | 4 +++- homeassistant/components/onvif/config_flow.py | 10 ++++++--- .../components/opentherm_gw/config_flow.py | 10 ++++++--- .../components/plaato/config_flow.py | 18 ++++++++++----- .../components/progettihwsw/config_flow.py | 10 ++++++--- .../components/prosegur/config_flow.py | 6 +++-- homeassistant/components/ps4/config_flow.py | 22 ++++++++++++------- 10 files changed, 81 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index 55112c6662c..fba934af38d 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -67,7 +67,9 @@ class OptionsFlowHandler(OptionsFlow): """Init object.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, int] | None = None + ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -109,7 +111,11 @@ class NetgearFlowHandler(ConfigFlow, domain=DOMAIN): """Get the options flow.""" return OptionsFlowHandler(config_entry) - async def _show_setup_form(self, user_input=None, errors=None): + async def _show_setup_form( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: """Show the setup form to the user.""" if not user_input: user_input = {} diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 3b8015827f1..4a9789c7e51 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN +from homeassistant.core import HomeAssistant from .const import CONF_ENCRYPT_TOKEN, DEFAULT_PORT, DEFAULT_TIMEOUT, DOMAIN from .helpers import CannotConnect, InvalidAuth, parse_id @@ -34,7 +35,7 @@ REAUTH_SCHEMA = vol.Schema( ) -async def validate_input(hass, data): +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from USER_SCHEMA with values provided by the user. @@ -99,7 +100,9 @@ class NukiConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Dialog that inform the user that reauth is required.""" errors = {} if user_input is None: @@ -140,7 +143,9 @@ class NukiConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=REAUTH_SCHEMA, errors=errors ) - async def async_step_validate(self, user_input=None): + async def async_step_validate( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle init step of a flow.""" data_schema = self.discovery_schema or USER_SCHEMA diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py index 706670738a6..cd8706f2350 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping import logging -from typing import Any +from typing import TYPE_CHECKING, Any import aiohttp from pyoctoprintapi import ApiError, OctoprintClient, OctoprintException @@ -104,7 +104,9 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): self._user_input = user_input return await self.async_step_get_api_key() - async def async_step_get_api_key(self, user_input=None): + async def async_step_get_api_key( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Get an Application Api Key.""" if not self.api_key_task: self.api_key_task = self.hass.async_create_task( @@ -130,7 +132,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_progress_done(next_step_id="user") - async def _finish_config(self, user_input: dict): + async def _finish_config(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Finish the configuration setup.""" existing_entry = await self.async_set_unique_id(self.unique_id) if existing_entry is not None: @@ -156,7 +158,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) - async def async_step_auth_failed(self, user_input): + async def async_step_auth_failed(self, user_input: None) -> ConfigFlowResult: """Handle api fetch failure.""" return self.async_abort(reason="auth_failed") @@ -252,15 +254,17 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): self._user_input = self._reauth_data return await self.async_step_get_api_key() - async def _async_get_auth_key(self): + async def _async_get_auth_key(self) -> None: """Get application api key.""" + if TYPE_CHECKING: + assert self._user_input is not None octoprint = self._get_octoprint_client(self._user_input) self._user_input[CONF_API_KEY] = await octoprint.request_app_key( "Home Assistant", self._user_input[CONF_USERNAME], 300 ) - def _get_octoprint_client(self, user_input: dict) -> OctoprintClient: + def _get_octoprint_client(self, user_input: dict[str, Any]) -> OctoprintClient: """Build an octoprint client from the user_input.""" verify_ssl = user_input.get(CONF_VERIFY_SSL, True) @@ -281,7 +285,7 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN): path=user_input[CONF_PATH], ) - def async_remove(self): + def async_remove(self) -> None: """Detach the session.""" for session in self._sessions: session.detach() diff --git a/homeassistant/components/omnilogic/config_flow.py b/homeassistant/components/omnilogic/config_flow.py index 166e4414767..77bca0039a9 100644 --- a/homeassistant/components/omnilogic/config_flow.py +++ b/homeassistant/components/omnilogic/config_flow.py @@ -88,7 +88,9 @@ class OptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage options.""" if user_input is not None: diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 30184d1abc3..f4e3f11d0b7 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -198,7 +198,9 @@ class OnvifFlowHandler(ConfigFlow, domain=DOMAIN): hass.async_create_task(self.hass.config_entries.async_reload(entry_id)) return self.async_abort(reason="already_configured") - async def async_step_device(self, user_input=None): + async def async_step_device( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle WS-Discovery. Let user choose between discovered devices and manual configuration. @@ -395,11 +397,13 @@ class OnvifOptionsFlowHandler(OptionsFlow): self.config_entry = config_entry self.options = dict(config_entry.options) - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the ONVIF options.""" return await self.async_step_onvif_devices() - async def async_step_onvif_devices(self, user_input=None): + async def async_step_onvif_devices( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the ONVIF devices options.""" if user_input is not None: self.options[CONF_EXTRA_ARGUMENTS] = user_input[CONF_EXTRA_ARGUMENTS] diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index c1d1caa2fb0..a5ac116ac11 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -50,7 +50,9 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OpenThermGwOptionsFlow(config_entry) - async def async_step_init(self, info=None): + async def async_step_init( + self, info: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle config flow initiation.""" if info: name = info[CONF_NAME] @@ -104,7 +106,7 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN): } return await self.async_step_init(info=formatted_config) - def _show_form(self, errors=None): + def _show_form(self, errors: dict[str, str] | None = None) -> ConfigFlowResult: """Show the config flow form with possible errors.""" return self.async_show_form( step_id="init", @@ -132,7 +134,9 @@ class OpenThermGwOptionsFlow(OptionsFlow): """Initialize the options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the opentherm_gw options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index 3ada4fdc312..74967c417a4 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -71,7 +71,9 @@ class PlaatoConfigFlow(ConfigFlow, domain=DOMAIN): ), ) - async def async_step_api_method(self, user_input=None): + async def async_step_api_method( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle device type step.""" device_type = self._init_info[CONF_DEVICE_TYPE] @@ -90,7 +92,9 @@ class PlaatoConfigFlow(ConfigFlow, domain=DOMAIN): return await self._show_api_method_form(device_type) - async def async_step_webhook(self, user_input=None): + async def async_step_webhook( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Validate config step.""" use_webhook = self._init_info[CONF_USE_WEBHOOK] @@ -136,8 +140,8 @@ class PlaatoConfigFlow(ConfigFlow, domain=DOMAIN): ) async def _show_api_method_form( - self, device_type: PlaatoDeviceType, errors: dict | None = None - ): + self, device_type: PlaatoDeviceType, errors: dict[str, str] | None = None + ) -> ConfigFlowResult: data_schema = vol.Schema({vol.Optional(CONF_TOKEN, default=""): str}) if device_type == PlaatoDeviceType.Airlock: @@ -186,7 +190,7 @@ class PlaatoOptionsFlowHandler(OptionsFlow): self._config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the options.""" use_webhook = self._config_entry.data.get(CONF_USE_WEBHOOK, False) if use_webhook: @@ -215,7 +219,9 @@ class PlaatoOptionsFlowHandler(OptionsFlow): ), ) - async def async_step_webhook(self, user_input=None): + async def async_step_webhook( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the options for webhook device.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py index 95596b940a4..2202678da9b 100644 --- a/homeassistant/components/progettihwsw/config_flow.py +++ b/homeassistant/components/progettihwsw/config_flow.py @@ -1,6 +1,6 @@ """Config flow for ProgettiHWSW Automation integration.""" -from typing import Any +from typing import TYPE_CHECKING, Any from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI import voluptuous as vol @@ -42,9 +42,13 @@ class ProgettiHWSWConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize class variables.""" self.s1_in: dict[str, Any] | None = None - async def async_step_relay_modes(self, user_input=None): + async def async_step_relay_modes( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Manage relay modes step.""" - errors = {} + errors: dict[str, str] = {} + if TYPE_CHECKING: + assert self.s1_in is not None if user_input is not None: whole_data = user_input whole_data.update(self.s1_in) diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index 7a8f67cef7d..7bd87e405ef 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -116,9 +116,11 @@ class ProsegurConfigFlow(ConfigFlow, domain=DOMAIN): ) return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle re-authentication with Prosegur.""" - errors = {} + errors: dict[str, str] = {} if user_input: try: diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index cdbf02dcc90..877fb595fc0 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -48,13 +48,13 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" self.helper = Helper() - self.creds = None + self.creds: str | None = None self.name = None self.host = None self.region = None - self.pin = None + self.pin: str | None = None self.m_device = None - self.location = None + self.location: location.LocationInfo | None = None self.device_list: list[str] = [] async def async_step_user( @@ -69,7 +69,9 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason=reason) return await self.async_step_creds() - async def async_step_creds(self, user_input=None): + async def async_step_creds( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Return PS4 credentials from 2nd Screen App.""" errors = {} if user_input is not None: @@ -85,7 +87,9 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="creds", errors=errors) - async def async_step_mode(self, user_input=None): + async def async_step_mode( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Prompt for mode.""" errors = {} mode = [CONF_AUTO, CONF_MANUAL] @@ -100,7 +104,7 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): if not errors: return await self.async_step_link() - mode_schema = OrderedDict() + mode_schema = OrderedDict[vol.Marker, Any]() mode_schema[vol.Required(CONF_MODE, default=CONF_AUTO)] = vol.In(list(mode)) mode_schema[vol.Optional(CONF_IP_ADDRESS)] = str @@ -108,7 +112,9 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): step_id="mode", data_schema=vol.Schema(mode_schema), errors=errors ) - async def async_step_link(self, user_input=None): + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Prompt user input. Create or edit entry.""" regions = sorted(COUNTRIES.keys()) default_region = None @@ -193,7 +199,7 @@ class PlayStation4FlowHandler(ConfigFlow, domain=DOMAIN): default_region = country # Show User Input form. - link_schema = OrderedDict() + link_schema = OrderedDict[vol.Marker, Any]() link_schema[vol.Required(CONF_IP_ADDRESS)] = vol.In(list(self.device_list)) link_schema[vol.Required(CONF_REGION, default=default_region)] = vol.In( list(regions) From 74fa30e59d883faff09018e69352120146590c9c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:05:18 +0200 Subject: [PATCH 0043/1309] Improve config flow type hints (g-m) (#124907) --- .../geonetnz_volcano/config_flow.py | 4 ++-- .../components/hlk_sw16/config_flow.py | 5 ++-- .../components/insteon/config_flow.py | 24 ++++++++++++++----- .../components/iotawatt/config_flow.py | 4 +++- .../components/kitchen_sink/config_flow.py | 4 +++- .../components/kmtronic/config_flow.py | 4 +++- homeassistant/components/kodi/config_flow.py | 20 +++++++++++----- .../components/lutron_caseta/config_flow.py | 8 +++++-- homeassistant/components/mill/config_flow.py | 8 +++++-- .../components/monoprice/config_flow.py | 4 +++- 10 files changed, 61 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/geonetnz_volcano/config_flow.py b/homeassistant/components/geonetnz_volcano/config_flow.py index 45a074d215c..cf3d5bc1139 100644 --- a/homeassistant/components/geonetnz_volcano/config_flow.py +++ b/homeassistant/components/geonetnz_volcano/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_UNIT_SYSTEM, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -26,7 +26,7 @@ from .const import ( @callback -def configured_instances(hass): +def configured_instances(hass: HomeAssistant) -> set[str]: """Return a set of configured GeoNet NZ Volcano instances.""" return { f"{entry.data[CONF_LATITUDE]}, {entry.data[CONF_LONGITUDE]}" diff --git a/homeassistant/components/hlk_sw16/config_flow.py b/homeassistant/components/hlk_sw16/config_flow.py index 8dd75561af3..34ee1ebd0e7 100644 --- a/homeassistant/components/hlk_sw16/config_flow.py +++ b/homeassistant/components/hlk_sw16/config_flow.py @@ -4,6 +4,7 @@ import asyncio from typing import Any from hlk_sw16 import create_hlk_sw16_connection +from hlk_sw16.protocol import SW16Client import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -27,7 +28,7 @@ DATA_SCHEMA = vol.Schema( ) -async def connect_client(hass, user_input): +async def connect_client(hass: HomeAssistant, user_input: dict[str, Any]) -> SW16Client: """Connect the HLK-SW16 client.""" client_aw = create_hlk_sw16_connection( host=user_input[CONF_HOST], @@ -41,7 +42,7 @@ async def connect_client(hass, user_input): return await client_aw -async def validate_input(hass: HomeAssistant, user_input): +async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> None: """Validate the user input allows us to connect.""" try: client = await connect_client(hass, user_input) diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 7a701db1b82..6b048004ba1 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -64,7 +64,9 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): modem_types = [STEP_PLM, STEP_HUB_V1, STEP_HUB_V2] return self.async_show_menu(step_id="user", menu_options=modem_types) - async def async_step_plm(self, user_input=None): + async def async_step_plm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Set up the PLM modem type.""" errors = {} if user_input is not None: @@ -83,7 +85,9 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): step_id=STEP_PLM, data_schema=data_schema, errors=errors ) - async def async_step_plm_manually(self, user_input=None): + async def async_step_plm_manually( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Set up the PLM modem type manually.""" errors = {} schema_defaults = {} @@ -97,15 +101,21 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): step_id=STEP_PLM_MANUALLY, data_schema=data_schema, errors=errors ) - async def async_step_hubv1(self, user_input=None): + async def async_step_hubv1( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Set up the Hub v1 modem type.""" return await self._async_setup_hub(hub_version=1, user_input=user_input) - async def async_step_hubv2(self, user_input=None): + async def async_step_hubv2( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Set up the Hub v2 modem type.""" return await self._async_setup_hub(hub_version=2, user_input=user_input) - async def _async_setup_hub(self, hub_version, user_input): + async def _async_setup_hub( + self, hub_version: int, user_input: dict[str, Any] | None + ) -> ConfigFlowResult: """Set up the Hub versions 1 and 2.""" errors = {} if user_input is not None: @@ -144,7 +154,9 @@ class InsteonFlowHandler(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DEFAULT_DISCOVERY_UNIQUE_ID) return await self.async_step_confirm_usb() - async def async_step_confirm_usb(self, user_input=None) -> ConfigFlowResult: + async def async_step_confirm_usb( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Confirm a USB discovery.""" if user_input is not None: return await self.async_step_plm({CONF_DEVICE: self._device_path}) diff --git a/homeassistant/components/iotawatt/config_flow.py b/homeassistant/components/iotawatt/config_flow.py index 187423c7d8b..668844a1c5c 100644 --- a/homeassistant/components/iotawatt/config_flow.py +++ b/homeassistant/components/iotawatt/config_flow.py @@ -75,7 +75,9 @@ class IOTaWattConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - async def async_step_auth(self, user_input=None): + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Authenticate user if authentication is enabled on the IoTaWatt device.""" if user_input is None: user_input = {} diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index 9a0b78c80e6..8cff9321729 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -48,7 +48,9 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN): """Reauth step.""" return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Reauth confirm step.""" if user_input is None: return self.async_show_form(step_id="reauth_confirm") diff --git a/homeassistant/components/kmtronic/config_flow.py b/homeassistant/components/kmtronic/config_flow.py index f83d102ac05..6bf0b878f72 100644 --- a/homeassistant/components/kmtronic/config_flow.py +++ b/homeassistant/components/kmtronic/config_flow.py @@ -106,7 +106,9 @@ class KMTronicOptionsFlow(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/kodi/config_flow.py b/homeassistant/components/kodi/config_flow.py index 26b5214c733..ef0798220dd 100644 --- a/homeassistant/components/kodi/config_flow.py +++ b/homeassistant/components/kodi/config_flow.py @@ -140,7 +140,9 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() - async def async_step_discovery_confirm(self, user_input=None): + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle user-confirmation of discovered node.""" if user_input is None: return self.async_show_form( @@ -178,7 +180,9 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): return self._show_user_form(errors) - async def async_step_credentials(self, user_input=None): + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle username and password input.""" errors = {} @@ -203,7 +207,9 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): return self._show_credentials_form(errors) - async def async_step_ws_port(self, user_input=None): + async def async_step_ws_port( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle websocket port of discovered node.""" errors = {} @@ -249,7 +255,9 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason=reason) @callback - def _show_credentials_form(self, errors=None): + def _show_credentials_form( + self, errors: dict[str, str] | None = None + ) -> ConfigFlowResult: schema = vol.Schema( { vol.Optional( @@ -262,7 +270,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="credentials", data_schema=schema, errors=errors or {} + step_id="credentials", data_schema=schema, errors=errors ) @callback @@ -304,7 +312,7 @@ class KodiConfigFlow(ConfigFlow, domain=DOMAIN): ) @callback - def _get_data(self): + def _get_data(self) -> dict[str, Any]: return { CONF_NAME: self._name, CONF_HOST: self._host, diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 703fbb813c6..cd566b767fb 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -95,7 +95,9 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by homekit discovery.""" return await self.async_step_zeroconf(discovery_info) - async def async_step_link(self, user_input=None): + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle pairing with the hub.""" errors = {} # Abort if existing entry with matching host exists. @@ -198,7 +200,9 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry(title=ENTRY_DEFAULT_TITLE, data=self.data) - async def async_step_import_failed(self, user_input=None): + async def async_step_import_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Make failed import surfaced to user.""" self.context["title_placeholders"] = {CONF_NAME: self.data[CONF_HOST]} diff --git a/homeassistant/components/mill/config_flow.py b/homeassistant/components/mill/config_flow.py index db1b2711575..7b2e5c3c4d5 100644 --- a/homeassistant/components/mill/config_flow.py +++ b/homeassistant/components/mill/config_flow.py @@ -43,7 +43,9 @@ class MillConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_local() return await self.async_step_cloud() - async def async_step_local(self, user_input=None): + async def async_step_local( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle the local step.""" data_schema = vol.Schema({vol.Required(CONF_IP_ADDRESS): str}) if user_input is None: @@ -75,7 +77,9 @@ class MillConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_cloud(self, user_input=None): + async def async_step_cloud( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle the cloud step.""" data_schema = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} diff --git a/homeassistant/components/monoprice/config_flow.py b/homeassistant/components/monoprice/config_flow.py index 5f0b1bf27b5..cac673e38c1 100644 --- a/homeassistant/components/monoprice/config_flow.py +++ b/homeassistant/components/monoprice/config_flow.py @@ -139,7 +139,9 @@ class MonopriceOptionsFlowHandler(OptionsFlow): return previous - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry( From df2ea1e87506fb6b660c415f45b0991846691373 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:21:05 +0200 Subject: [PATCH 0044/1309] Improve type hints in nina config flow (#124910) * Improve type hints in nina config flow * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/nina/config_flow.py | 52 ++++++++++---------- tests/components/nina/test_config_flow.py | 2 +- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 1fee6430ffc..e048ce81be3 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -182,9 +182,11 @@ class OptionsFlowHandler(OptionsFlow): if name not in self.data: self.data[name] = [] - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle options flow.""" - errors: dict[str, Any] = {} + errors: dict[str, str] = {} if not self._all_region_codes_sorted: nina: Nina = Nina(async_get_clientsession(self.hass)) @@ -244,33 +246,33 @@ class OptionsFlowHandler(OptionsFlow): self.config_entry, data=user_input ) - return self.async_create_entry(title="", data=None) + return self.async_create_entry(title="", data={}) errors["base"] = "no_selection" + schema: VolDictType = { + **{ + vol.Optional(region, default=self.data[region]): cv.multi_select( + self.regions[region] + ) + for region in CONST_REGIONS + }, + vol.Required( + CONF_MESSAGE_SLOTS, + default=self.data[CONF_MESSAGE_SLOTS], + ): vol.All(int, vol.Range(min=1, max=20)), + vol.Optional( + CONF_HEADLINE_FILTER, + default=self.data[CONF_HEADLINE_FILTER], + ): cv.string, + vol.Optional( + CONF_AREA_FILTER, + default=self.data[CONF_AREA_FILTER], + ): cv.string, + } + return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - **{ - vol.Optional( - region, default=self.data[region] - ): cv.multi_select(self.regions[region]) - for region in CONST_REGIONS - }, - vol.Required( - CONF_MESSAGE_SLOTS, - default=self.data[CONF_MESSAGE_SLOTS], - ): vol.All(int, vol.Range(min=1, max=20)), - vol.Optional( - CONF_HEADLINE_FILTER, - default=self.data[CONF_HEADLINE_FILTER], - ): cv.string, - vol.Optional( - CONF_AREA_FILTER, - default=self.data[CONF_AREA_FILTER], - ): cv.string, - } - ), + data_schema=vol.Schema(schema), errors=errors, ) diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 23ee8cbf797..6bc17cdf674 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -188,7 +188,7 @@ async def test_options_flow_init(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] is None + assert result["data"] == {} assert dict(config_entry.data) == { CONF_HEADLINE_FILTER: deepcopy(DUMMY_DATA[CONF_HEADLINE_FILTER]), From 19cbc1b258db345808587c96957cc284d7e4d1db Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:22:07 +0200 Subject: [PATCH 0045/1309] Improve type hints in plex config flow (#124914) --- homeassistant/components/plex/config_flow.py | 59 ++++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 7162e517e23..fcd5751effb 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -35,7 +35,7 @@ from homeassistant.const import ( CONF_URL, CONF_VERIFY_SSL, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -71,7 +71,7 @@ _LOGGER = logging.getLogger(__package__) @callback -def configured_servers(hass): +def configured_servers(hass: HomeAssistant) -> set[str]: """Return a set of the configured Plex servers.""" return { entry.data[CONF_SERVER_IDENTIFIER] @@ -79,7 +79,7 @@ def configured_servers(hass): } -async def async_discover(hass): +async def async_discover(hass: HomeAssistant) -> None: """Scan for available Plex servers.""" gdm = GDM() await hass.async_add_executor_job(gdm.scan) @@ -97,6 +97,9 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + available_servers: list[tuple[str, str, str]] + plexauth: PlexAuth + @staticmethod @callback def async_get_options_flow( @@ -108,28 +111,34 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Plex flow.""" self.current_login: dict[str, Any] = {} - self.available_servers = None - self.plexauth = None self.token = None self.client_id = None self._manual = False self._reauth_config: dict[str, Any] | None = None - async def async_step_user(self, user_input=None, errors=None): + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" if user_input is not None: - return await self.async_step_plex_website_auth() + return await self._async_step_plex_website_auth() if self.show_advanced_options: return await self.async_step_user_advanced(errors=errors) return self.async_show_form(step_id="user", errors=errors) - async def async_step_user_advanced(self, user_input=None, errors=None): + async def async_step_user_advanced( + self, + user_input: dict[str, str] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: """Handle an advanced mode flow initialized by the user.""" if user_input is not None: if user_input.get("setup_method") == MANUAL_SETUP_STRING: self._manual = True return await self.async_step_manual_setup() - return await self.async_step_plex_website_auth() + return await self._async_step_plex_website_auth() data_schema = vol.Schema( { @@ -142,7 +151,11 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): step_id="user_advanced", data_schema=data_schema, errors=errors ) - async def async_step_manual_setup(self, user_input=None, errors=None): + async def async_step_manual_setup( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: """Begin manual configuration.""" if user_input is not None and errors is None: user_input.pop(CONF_URL, None) @@ -264,7 +277,9 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=url, data=data) - async def async_step_select_server(self, user_input=None): + async def async_step_select_server( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Use selected Plex server.""" config = dict(self.current_login) if user_input is not None: @@ -292,7 +307,9 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): errors={}, ) - async def async_step_integration_discovery(self, discovery_info): + async def async_step_integration_discovery( + self, discovery_info: dict[str, Any] + ) -> ConfigFlowResult: """Handle GDM discovery.""" machine_identifier = discovery_info["data"]["Resource-Identifier"] await self.async_set_unique_id(machine_identifier) @@ -305,7 +322,7 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): } return await self.async_step_user() - async def async_step_plex_website_auth(self): + async def _async_step_plex_website_auth(self) -> ConfigFlowResult: """Begin external auth flow on Plex website.""" self.hass.http.register_view(PlexAuthorizationCallbackView) if (req := http.current_request.get()) is None: @@ -329,7 +346,9 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): auth_url = self.plexauth.auth_url(forward_url) return self.async_external_step(step_id="obtain_token", url=auth_url) - async def async_step_obtain_token(self, user_input=None): + async def async_step_obtain_token( + self, user_input: None = None + ) -> ConfigFlowResult: """Obtain token after external auth completed.""" token = await self.plexauth.token(10) @@ -340,11 +359,13 @@ class PlexFlowHandler(ConfigFlow, domain=DOMAIN): self.client_id = self.plexauth.client_identifier return self.async_external_step_done(next_step_id="use_external_token") - async def async_step_timed_out(self, user_input=None): + async def async_step_timed_out(self, user_input: None = None) -> ConfigFlowResult: """Abort flow when time expires.""" return self.async_abort(reason="token_request_timeout") - async def async_step_use_external_token(self, user_input=None): + async def async_step_use_external_token( + self, user_input: None = None + ) -> ConfigFlowResult: """Continue server validation with external token.""" server_config = {CONF_TOKEN: self.token} return await self.async_step_server_validate(server_config) @@ -367,11 +388,13 @@ class PlexOptionsFlowHandler(OptionsFlow): self.options = copy.deepcopy(dict(config_entry.options)) self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the Plex options.""" return await self.async_step_plex_mp_settings() - async def async_step_plex_mp_settings(self, user_input=None): + async def async_step_plex_mp_settings( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the Plex media_player options.""" plex_server = get_plex_server(self.hass, self.server_id) From 9e2360791d1d52c53d4131de4a86809cc940b6bd Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:22:48 +0200 Subject: [PATCH 0046/1309] Add hot water target temp number entity in ViCare integration (#123633) * add DHW target temp number entity * Update number.py * Update strings.json * Update strings.json * update test snapshot * fix snapshot --- homeassistant/components/vicare/number.py | 12 ++++ homeassistant/components/vicare/strings.json | 3 + .../vicare/snapshots/test_number.ambr | 57 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index d53b7183327..a6bb849ce62 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -50,6 +50,18 @@ class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysM DEVICE_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( + ViCareNumberEntityDescription( + key="dhw_temperature", + translation_key="dhw_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDomesticHotWaterConfiguredTemperature(), + value_setter=lambda api, value: api.setDomesticHotWaterTemperature(value), + min_value_getter=lambda api: api.getDomesticHotWaterMinTemperature(), + max_value_getter=lambda api: api.getDomesticHotWaterMaxTemperature(), + native_step=1, + ), ViCareNumberEntityDescription( key="dhw_secondary_temperature", translation_key="dhw_secondary_temperature", diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 0452a560cb8..1466baab8f3 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -105,6 +105,9 @@ "comfort_heating_temperature": { "name": "[%key:component::vicare::entity::number::comfort_temperature::name%]" }, + "dhw_temperature": { + "name": "DHW temperature" + }, "dhw_secondary_temperature": { "name": "DHW secondary temperature" } diff --git a/tests/components/vicare/snapshots/test_number.ambr b/tests/components/vicare/snapshots/test_number.ambr index a55c29ab8c1..e6e87ce5dc7 100644 --- a/tests/components/vicare/snapshots/test_number.ambr +++ b/tests/components/vicare/snapshots/test_number.ambr @@ -565,3 +565,60 @@ 'state': 'unavailable', }) # --- +# name: test_all_entities[number.model0_dhw_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.model0_dhw_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW temperature', + 'platform': 'vicare', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_temperature', + 'unique_id': 'gateway0-dhw_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.model0_dhw_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'model0 DHW temperature', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.model0_dhw_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- From ffabd5d7db225cbf1de066f0b6953009d1aa606e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:24:06 +0200 Subject: [PATCH 0047/1309] Improve type hints in konnected config flow (#124904) --- .../components/konnected/config_flow.py | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 48016cd066a..18e113e146b 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -7,7 +7,7 @@ import copy import logging import random import string -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse import voluptuous as vol @@ -227,8 +227,12 @@ class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return await self.async_step_import_confirm() - async def async_step_import_confirm(self, user_input=None): + async def async_step_import_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Confirm the user wants to import the config entry.""" + if TYPE_CHECKING: + assert self.unique_id is not None if user_input is None: return self.async_show_form( step_id="import_confirm", @@ -349,7 +353,9 @@ class KonnectedFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Attempt to link with the Konnected panel. Given a configured host, will ask the user to confirm and finalize @@ -401,8 +407,8 @@ class OptionsFlowHandler(OptionsFlow): self.current_opt = self.entry.options or self.entry.data[CONF_DEFAULT_OPTIONS] # as config proceeds we'll build up new options and then replace what's in the config entry - self.new_opt: dict[str, dict[str, Any]] = {CONF_IO: {}} - self.active_cfg = None + self.new_opt: dict[str, Any] = {CONF_IO: {}} + self.active_cfg: str | None = None self.io_cfg: dict[str, Any] = {} self.current_states: list[dict[str, Any]] = [] self.current_state = 1 @@ -419,13 +425,17 @@ class OptionsFlowHandler(OptionsFlow): {}, ) - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle options flow.""" return await self.async_step_options_io() - async def async_step_options_io(self, user_input=None): + async def async_step_options_io( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Configure legacy panel IO or first half of pro IO.""" - errors = {} + errors: dict[str, str] = {} current_io = self.current_opt.get(CONF_IO, {}) if user_input is not None: @@ -508,9 +518,11 @@ class OptionsFlowHandler(OptionsFlow): return self.async_abort(reason="not_konn_panel") - async def async_step_options_io_ext(self, user_input=None): + async def async_step_options_io_ext( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Allow the user to configure the extended IO for pro.""" - errors = {} + errors: dict[str, str] = {} current_io = self.current_opt.get(CONF_IO, {}) if user_input is not None: @@ -566,10 +578,12 @@ class OptionsFlowHandler(OptionsFlow): return self.async_abort(reason="not_konn_panel") - async def async_step_options_binary(self, user_input=None): + async def async_step_options_binary( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Allow the user to configure the IO options for binary sensors.""" - errors = {} - if user_input is not None: + errors: dict[str, str] = {} + if user_input is not None and self.active_cfg is not None: zone = {"zone": self.active_cfg} zone.update(user_input) self.new_opt[CONF_BINARY_SENSORS] = [ @@ -602,7 +616,7 @@ class OptionsFlowHandler(OptionsFlow): description_placeholders={ "zone": f"Zone {self.active_cfg}" if len(self.active_cfg) < 3 - else self.active_cfg.upper + else self.active_cfg.upper() }, errors=errors, ) @@ -635,17 +649,19 @@ class OptionsFlowHandler(OptionsFlow): description_placeholders={ "zone": f"Zone {self.active_cfg}" if len(self.active_cfg) < 3 - else self.active_cfg.upper + else self.active_cfg.upper() }, errors=errors, ) return await self.async_step_options_digital() - async def async_step_options_digital(self, user_input=None): + async def async_step_options_digital( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Allow the user to configure the IO options for digital sensors.""" - errors = {} - if user_input is not None: + errors: dict[str, str] = {} + if user_input is not None and self.active_cfg is not None: zone = {"zone": self.active_cfg} zone.update(user_input) self.new_opt[CONF_SENSORS] = [*self.new_opt.get(CONF_SENSORS, []), zone] @@ -710,10 +726,12 @@ class OptionsFlowHandler(OptionsFlow): return await self.async_step_options_switch() - async def async_step_options_switch(self, user_input=None): + async def async_step_options_switch( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Allow the user to configure the IO options for switches.""" - errors = {} - if user_input is not None: + errors: dict[str, str] = {} + if user_input is not None and self.active_cfg is not None: zone = {"zone": self.active_cfg} zone.update(user_input) del zone[CONF_MORE_STATES] @@ -825,7 +843,9 @@ class OptionsFlowHandler(OptionsFlow): return await self.async_step_options_misc() - async def async_step_options_misc(self, user_input=None): + async def async_step_options_misc( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Allow the user to configure the LED behavior.""" errors = {} if user_input is not None: From 1906155c18e8398097b5e08d07e57faf3712e071 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:24:34 +0200 Subject: [PATCH 0048/1309] Improve type hints in mobile_app config flow (#124906) --- homeassistant/components/mobile_app/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mobile_app/config_flow.py b/homeassistant/components/mobile_app/config_flow.py index bd72b2d7f42..33c0442b529 100644 --- a/homeassistant/components/mobile_app/config_flow.py +++ b/homeassistant/components/mobile_app/config_flow.py @@ -28,7 +28,9 @@ class MobileAppFlowHandler(ConfigFlow, domain=DOMAIN): reason="install_app", description_placeholders=placeholders ) - async def async_step_registration(self, user_input=None): + async def async_step_registration( + self, user_input: dict[str, Any] + ) -> ConfigFlowResult: """Handle a flow initialized during registration.""" if ATTR_DEVICE_ID in user_input: # Unique ID is combi of app + device ID. From febb3820309a9cb0cb4e8e6ee109d5500fde4bee Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:25:08 +0200 Subject: [PATCH 0049/1309] Improve type hints in hvv_departures config flow (#124902) --- .../components/hvv_departures/config_flow.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index a02796dbffb..3e1b98d9a38 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -49,10 +49,11 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + hub: GTIHub + data: dict[str, Any] + def __init__(self) -> None: """Initialize component.""" - self.hub: GTIHub | None = None - self.data: dict[str, Any] | None = None self.stations: dict[str, Any] = {} async def async_step_user( @@ -86,7 +87,9 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=SCHEMA_STEP_USER, errors=errors ) - async def async_step_station(self, user_input=None): + async def async_step_station( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle the step where the user inputs his/her station.""" if user_input is not None: errors = {} @@ -116,7 +119,9 @@ class HVVDeparturesConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="station", data_schema=SCHEMA_STEP_STATION) - async def async_step_station_select(self, user_input=None): + async def async_step_station_select( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle the step where the user inputs his/her station.""" schema = vol.Schema({vol.Required(CONF_STATION): vol.In(list(self.stations))}) @@ -148,7 +153,9 @@ class OptionsFlowHandler(OptionsFlow): self.options = dict(config_entry.options) self.departure_filters: dict[str, Any] = {} - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the options.""" errors = {} if not self.departure_filters: @@ -177,7 +184,7 @@ class OptionsFlowHandler(OptionsFlow): if not errors: self.departure_filters = { str(i): departure_filter - for i, departure_filter in enumerate(departure_list.get("filter")) + for i, departure_filter in enumerate(departure_list["filter"]) } if user_input is not None and not errors: @@ -195,7 +202,7 @@ class OptionsFlowHandler(OptionsFlow): old_filter = [ i for (i, f) in self.departure_filters.items() - if f in self.config_entry.options.get(CONF_FILTER) + if f in self.config_entry.options[CONF_FILTER] ] else: old_filter = [] From afa02dcce9335d07370469550f157c966021947a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:25:29 +0200 Subject: [PATCH 0050/1309] Improve type hints in growatt_server config flow (#124901) --- homeassistant/components/growatt_server/config_flow.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index 8123d7ff067..e676d8fae32 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -23,9 +23,10 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + api: growattServer.GrowattApi + def __init__(self) -> None: """Initialise growatt server flow.""" - self.api: growattServer.GrowattApi | None = None self.user_id = None self.data: dict[str, Any] = {} @@ -70,7 +71,9 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): self.data = user_input return await self.async_step_plant() - async def async_step_plant(self, user_input=None): + async def async_step_plant( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle adding a "plant" to Home Assistant.""" plant_info = await self.hass.async_add_executor_job( self.api.plant_list, self.user_id @@ -86,7 +89,8 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="plant", data_schema=data_schema) - if user_input is None and len(plant_info["data"]) == 1: + if user_input is None: + # single plant => mark it as selected user_input = {CONF_PLANT_ID: plant_info["data"][0]["plantId"]} user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] From 69a9aa4594505bd28923ade30a9274b28e5017a1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:25:58 +0200 Subject: [PATCH 0051/1309] Improve type hints in icloud config flow (#124900) --- .../components/icloud/config_flow.py | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py index 544f751dc0b..efcef15b4d0 100644 --- a/homeassistant/components/icloud/config_flow.py +++ b/homeassistant/components/icloud/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Mapping import logging import os -from typing import Any +from typing import TYPE_CHECKING, Any from pyicloud import PyiCloudService from pyicloud.exceptions import ( @@ -200,11 +200,17 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN): return await self._validate_and_create_entry(user_input, "reauth_confirm") - async def async_step_trusted_device(self, user_input=None, errors=None): + async def async_step_trusted_device( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: """We need a trusted device.""" if errors is None: errors = {} + if TYPE_CHECKING: + assert self.api is not None trusted_devices = await self.hass.async_add_executor_job( getattr, self.api, "trusted_devices" ) @@ -216,7 +222,7 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is None: return await self._show_trusted_device_form( - trusted_devices_for_form, user_input, errors + trusted_devices_for_form, errors ) self._trusted_device = trusted_devices[int(user_input[CONF_TRUSTED_DEVICE])] @@ -229,18 +235,18 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN): errors[CONF_TRUSTED_DEVICE] = "send_verification_code" return await self._show_trusted_device_form( - trusted_devices_for_form, user_input, errors + trusted_devices_for_form, errors ) return await self.async_step_verification_code() async def _show_trusted_device_form( - self, trusted_devices, user_input=None, errors=None - ): + self, trusted_devices, errors: dict[str, str] | None = None + ) -> ConfigFlowResult: """Show the trusted_device form to the user.""" return self.async_show_form( - step_id=CONF_TRUSTED_DEVICE, + step_id="trusted_device", data_schema=vol.Schema( { vol.Required(CONF_TRUSTED_DEVICE): vol.All( @@ -251,13 +257,20 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def async_step_verification_code(self, user_input=None, errors=None): + async def async_step_verification_code( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: """Ask the verification code to the user.""" if errors is None: errors = {} if user_input is None: - return await self._show_verification_code_form(user_input, errors) + return await self._show_verification_code_form(errors) + + if TYPE_CHECKING: + assert self.api is not None self._verification_code = user_input[CONF_VERIFICATION_CODE] @@ -310,11 +323,13 @@ class IcloudFlowHandler(ConfigFlow, domain=DOMAIN): } ) - async def _show_verification_code_form(self, user_input=None, errors=None): + async def _show_verification_code_form( + self, errors: dict[str, str] | None = None + ) -> ConfigFlowResult: """Show the verification_code form to the user.""" return self.async_show_form( - step_id=CONF_VERIFICATION_CODE, + step_id="verification_code", data_schema=vol.Schema({vol.Required(CONF_VERIFICATION_CODE): str}), - errors=errors or {}, + errors=errors, ) From 6781a76de2c5c9d080df1f2c0e98dc683caa569c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 29 Aug 2024 23:36:31 -1000 Subject: [PATCH 0052/1309] Speed up ssdp domain matching (#124842) * Speed up ssdp domain matching Switch all() expression to dict.items() <= dict.items() * rewrite as setcomp --- homeassistant/components/ssdp/__init__.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 7ca2f3e9318..f5e2a012730 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -284,16 +284,13 @@ class IntegrationMatchers: def async_matching_domains(self, info_with_desc: CaseInsensitiveDict) -> set[str]: """Find domains matching the passed CaseInsensitiveDict.""" assert self._match_by_key is not None - domains = set() - for key, matchers_by_key in self._match_by_key.items(): - if not (match_value := info_with_desc.get(key)): - continue - for domain, matcher in matchers_by_key.get(match_value, []): - if domain in domains: - continue - if all(info_with_desc.get(k) == v for (k, v) in matcher.items()): - domains.add(domain) - return domains + return { + domain + for key, matchers_by_key in self._match_by_key.items() + if (match_value := info_with_desc.get(key)) + for domain, matcher in matchers_by_key.get(match_value, ()) + if info_with_desc.items() >= matcher.items() + } class Scanner: From f394dfb8d061b96f74c92dc13e79bd9ea43c5ea5 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Fri, 30 Aug 2024 11:38:07 +0200 Subject: [PATCH 0053/1309] Handle CancelledError in bluesound integration (#124873) Catch CancledError in async_will_remove_from_hass --- homeassistant/components/bluesound/media_player.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 92f47977ee5..1ed53d7bfc5 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -309,7 +309,7 @@ class BluesoundPlayer(MediaPlayerEntity): return True - async def _start_poll_command(self): + async def _poll_loop(self): """Loop which polls the status of the player.""" while True: try: @@ -335,7 +335,7 @@ class BluesoundPlayer(MediaPlayerEntity): await super().async_added_to_hass() self._polling_task = self.hass.async_create_background_task( - self._start_poll_command(), + self._poll_loop(), name=f"bluesound.polling_{self.host}:{self.port}", ) @@ -345,7 +345,9 @@ class BluesoundPlayer(MediaPlayerEntity): assert self._polling_task is not None if self._polling_task.cancel(): - await self._polling_task + # the sleeps in _poll_loop will raise CancelledError + with suppress(CancelledError): + await self._polling_task self.hass.data[DATA_BLUESOUND].remove(self) From aeb95c45091760be4f1dbdb873b8404d503b1a3b Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 30 Aug 2024 06:43:29 -0400 Subject: [PATCH 0054/1309] Bump pysqueezebox to v0.8.1 (#124856) --- 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 40bc8f36d22..c43225f94cd 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.7.1"] + "requirements": ["pysqueezebox==0.8.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4fd2c8932f4..ead909dbcc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2235,7 +2235,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.7.1 +pysqueezebox==0.8.1 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63721bc9360..a0bc05e14b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1789,7 +1789,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.7.1 +pysqueezebox==0.8.1 # homeassistant.components.suez_water pysuez==0.2.0 From f3da9de744ce5942abeeb1fb57043e8649a931db Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 30 Aug 2024 04:45:08 -0600 Subject: [PATCH 0055/1309] Bump weatherflow4py to 0.2.23 (#124072) patch weatherflow for new data --- homeassistant/components/weatherflow_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 354b9642c06..aaa5bce2e16 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==0.2.21"] + "requirements": ["weatherflow4py==0.2.23"] } diff --git a/requirements_all.txt b/requirements_all.txt index ead909dbcc2..9fd599fba93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2918,7 +2918,7 @@ watchdog==2.3.1 waterfurnace==1.1.0 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.2.21 +weatherflow4py==0.2.23 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a0bc05e14b3..0acc2a9f916 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2307,7 +2307,7 @@ wallbox==0.7.0 watchdog==2.3.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.2.21 +weatherflow4py==0.2.23 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 From 54188b4128c795054a59f9e1bd3edf98af66f28b Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 30 Aug 2024 22:59:13 +1200 Subject: [PATCH 0056/1309] Add returning activity to Husqvarna lawn mower (#124511) * add returning activity to husqvarna lawn mower * Update test, fix bug with comparison operator --- homeassistant/components/husqvarna_automower/lawn_mower.py | 3 ++- tests/components/husqvarna_automower/test_lawn_mower.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index ac0f1fd6af2..eeabaa09f79 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -26,7 +26,6 @@ DOCKED_ACTIVITIES = (MowerActivities.PARKED_IN_CS, MowerActivities.CHARGING) MOWING_ACTIVITIES = ( MowerActivities.MOWING, MowerActivities.LEAVING, - MowerActivities.GOING_HOME, ) PAUSED_STATES = [ MowerStates.PAUSED, @@ -107,6 +106,8 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): return LawnMowerActivity.PAUSED if mower_attributes.mower.activity in MOWING_ACTIVITIES: return LawnMowerActivity.MOWING + if mower_attributes.mower.activity == MowerActivities.GOING_HOME: + return LawnMowerActivity.RETURNING if (mower_attributes.mower.state == "RESTRICTED") or ( mower_attributes.mower.activity in DOCKED_ACTIVITIES ): diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index 2ae427e0e1e..552a3a6a9cf 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -44,6 +44,7 @@ async def test_lawn_mower_states( ("UNKNOWN", "PAUSED", LawnMowerActivity.PAUSED), ("MOWING", "NOT_APPLICABLE", LawnMowerActivity.MOWING), ("NOT_APPLICABLE", "ERROR", LawnMowerActivity.ERROR), + ("GOING_HOME", "IN_OPERATION", LawnMowerActivity.RETURNING), ): values[TEST_MOWER_ID].mower.activity = activity values[TEST_MOWER_ID].mower.state = state From 397198c6d090287f9d3ab5c17f5424ac131586e2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 30 Aug 2024 13:09:10 +0200 Subject: [PATCH 0057/1309] Optimize hassfest image (#124855) * Optimize hassfest docker image * Adjust CI * Use dynamic uv version * Remove workaround --- .github/workflows/builder.yml | 10 +- script/hassfest/docker.py | 134 +++++++++++++++--- script/hassfest/docker/Dockerfile | 35 +++-- .../hassfest/docker/Dockerfile.dockerignore | 8 ++ script/hassfest/docker/entrypoint.sh | 22 +-- 5 files changed, 163 insertions(+), 46 deletions(-) create mode 100644 script/hassfest/docker/Dockerfile.dockerignore diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ab64f9e5519..910e179cd8e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -491,7 +491,7 @@ jobs: packages: write attestations: write id-token: write - needs: ["init", "build_base"] + needs: ["init"] if: github.repository_owner == 'home-assistant' env: HASSFEST_IMAGE_NAME: ghcr.io/home-assistant/hassfest @@ -510,8 +510,8 @@ jobs: - name: Build Docker image uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0 with: - context: ./script/hassfest/docker - build-args: BASE_IMAGE=ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }} + context: . # So action will not pull the repository again + file: ./script/hassfest/docker/Dockerfile load: true tags: ${{ env.HASSFEST_IMAGE_TAG }} @@ -523,8 +523,8 @@ jobs: id: push uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0 with: - context: ./script/hassfest/docker - build-args: BASE_IMAGE=ghcr.io/home-assistant/amd64-homeassistant:${{ needs.init.outputs.version }} + context: . # So action will not pull the repository again + file: ./script/hassfest/docker/Dockerfile push: true tags: ${{ env.HASSFEST_IMAGE_TAG }},${{ env.HASSFEST_IMAGE_NAME }}:latest diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index e38a238be7d..6e39a5c350b 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -1,7 +1,12 @@ """Generate and validate the dockerfile.""" +from dataclasses import dataclass +from pathlib import Path + from homeassistant import core +from homeassistant.const import Platform from homeassistant.util import executor, thread +from script.gen_requirements_all import gather_recursive_requirements from .model import Config, Integration from .requirements import PACKAGE_REGEX, PIP_VERSION_RANGE_SEPARATOR @@ -20,7 +25,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv=={uv_version} +RUN pip3 install uv=={uv} WORKDIR /usr/src @@ -61,30 +66,105 @@ COPY rootfs / WORKDIR /config """ +_HASSFEST_TEMPLATE = r"""# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker +FROM python:alpine3.20 -def _get_uv_version() -> str: - with open("requirements_test.txt") as fp: +ENV \ + UV_SYSTEM_PYTHON=true \ + UV_EXTRA_INDEX_URL="https://wheels.home-assistant.io/musllinux-index/" + +SHELL ["/bin/sh", "-o", "pipefail", "-c"] +ENTRYPOINT ["/usr/src/homeassistant/script/hassfest/docker/entrypoint.sh"] +WORKDIR "/github/workspace" + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:{uv} /uv /bin/uv + +COPY . /usr/src/homeassistant + +RUN \ + # Required for PyTurboJPEG + apk add --no-cache libturbojpeg \ + && cd /usr/src/homeassistant \ + && uv pip install \ + --no-build \ + --no-cache \ + -c homeassistant/package_constraints.txt \ + -r requirements.txt \ + stdlib-list==0.10.0 pipdeptree=={pipdeptree} tqdm=={tqdm} ruff=={ruff} \ + {required_components_packages} + +LABEL "name"="hassfest" +LABEL "maintainer"="Home Assistant " + +LABEL "com.github.actions.name"="hassfest" +LABEL "com.github.actions.description"="Run hassfest to validate standalone integration repositories" +LABEL "com.github.actions.icon"="terminal" +LABEL "com.github.actions.color"="gray-dark" +""" + + +def _get_package_versions(file: str, packages: set[str]) -> dict[str, str]: + package_versions: dict[str, str] = {} + with open(file, encoding="UTF-8") as fp: for _, line in enumerate(fp): + if package_versions.keys() == packages: + return package_versions + if match := PACKAGE_REGEX.match(line): pkg, sep, version = match.groups() - if pkg != "uv": + if pkg not in packages: continue if sep != "==" or not version: raise RuntimeError( - 'Requirement uv need to be pinned "uv==".' + f'Requirement {pkg} need to be pinned "{pkg}==".' ) for part in version.split(";", 1)[0].split(","): version_part = PIP_VERSION_RANGE_SEPARATOR.match(part) if version_part: - return version_part.group(2) + package_versions[pkg] = version_part.group(2) + break - raise RuntimeError("Invalid uv requirement in requirements_test.txt") + if package_versions.keys() == packages: + return package_versions + + raise RuntimeError("At least one package was not found in the requirements file.") -def _generate_dockerfile() -> str: +@dataclass +class File: + """File.""" + + content: str + path: Path + + +def _generate_hassfest_dockerimage( + config: Config, timeout: int, package_versions: dict[str, str] +) -> File: + packages = set() + already_checked_domains = set() + for platform in Platform: + packages.update( + gather_recursive_requirements(platform.value, already_checked_domains) + ) + + return File( + _HASSFEST_TEMPLATE.format( + timeout=timeout, + required_components_packages=" ".join(sorted(packages)), + **package_versions, + ), + config.root / "script/hassfest/docker/Dockerfile", + ) + + +def _generate_files(config: Config) -> list[File]: timeout = ( core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + core.STOP_STAGE_SHUTDOWN_TIMEOUT @@ -93,27 +173,39 @@ def _generate_dockerfile() -> str: + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + 10 + ) * 1000 + + package_versions = _get_package_versions( + "requirements_test.txt", {"pipdeptree", "tqdm", "uv"} ) - return DOCKERFILE_TEMPLATE.format( - timeout=timeout * 1000, uv_version=_get_uv_version() + package_versions |= _get_package_versions( + "requirements_test_pre_commit.txt", {"ruff"} ) + return [ + File( + DOCKERFILE_TEMPLATE.format(timeout=timeout, **package_versions), + config.root / "Dockerfile", + ), + _generate_hassfest_dockerimage(config, timeout, package_versions), + ] + def validate(integrations: dict[str, Integration], config: Config) -> None: """Validate dockerfile.""" - dockerfile_content = _generate_dockerfile() - config.cache["dockerfile"] = dockerfile_content + docker_files = _generate_files(config) + config.cache["docker"] = docker_files - dockerfile_path = config.root / "Dockerfile" - if dockerfile_path.read_text() != dockerfile_content: - config.add_error( - "docker", - "File Dockerfile is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) + for file in docker_files: + if file.content != file.path.read_text(): + config.add_error( + "docker", + f"File {file.path} is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate dockerfile.""" - dockerfile_path = config.root / "Dockerfile" - dockerfile_path.write_text(config.cache["dockerfile"]) + for file in _generate_files(config): + file.path.write_text(file.content) diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 8921d92307e..4fc60c0c621 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -1,17 +1,32 @@ -ARG BASE_IMAGE=ghcr.io/home-assistant/home-assistant:beta -FROM $BASE_IMAGE +# Automatically generated by hassfest. +# +# To update, run python3 -m script.hassfest -p docker +FROM python:alpine3.20 -SHELL ["/bin/bash", "-o", "pipefail", "-c"] +ENV \ + UV_SYSTEM_PYTHON=true \ + UV_EXTRA_INDEX_URL="https://wheels.home-assistant.io/musllinux-index/" -COPY entrypoint.sh /entrypoint.sh +SHELL ["/bin/sh", "-o", "pipefail", "-c"] +ENTRYPOINT ["/usr/src/homeassistant/script/hassfest/docker/entrypoint.sh"] +WORKDIR "/github/workspace" + +# Install uv +COPY --from=ghcr.io/astral-sh/uv:0.2.27 /uv /bin/uv + +COPY . /usr/src/homeassistant RUN \ - uv pip install stdlib-list==0.10.0 \ - $(grep -e "^pipdeptree" -e "^tqdm" /usr/src/homeassistant/requirements_test.txt) \ - $(grep -e "^ruff" /usr/src/homeassistant/requirements_test_pre_commit.txt) - -WORKDIR "/github/workspace" -ENTRYPOINT ["/entrypoint.sh"] + # Required for PyTurboJPEG + apk add --no-cache libturbojpeg \ + && cd /usr/src/homeassistant \ + && uv pip install \ + --no-build \ + --no-cache \ + -c homeassistant/package_constraints.txt \ + -r requirements.txt \ + stdlib-list==0.10.0 pipdeptree==2.23.1 tqdm==4.66.4 ruff==0.6.2 \ + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.8.29 mutagen==1.47.0 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/script/hassfest/docker/Dockerfile.dockerignore b/script/hassfest/docker/Dockerfile.dockerignore new file mode 100644 index 00000000000..75ed4f0e5d3 --- /dev/null +++ b/script/hassfest/docker/Dockerfile.dockerignore @@ -0,0 +1,8 @@ +# Ignore everything except the specified files +* + +!homeassistant/ +!requirements.txt +!script/ +script/hassfest/docker/ +!script/hassfest/docker/entrypoint.sh diff --git a/script/hassfest/docker/entrypoint.sh b/script/hassfest/docker/entrypoint.sh index 33330f63161..7b75eb186d2 100755 --- a/script/hassfest/docker/entrypoint.sh +++ b/script/hassfest/docker/entrypoint.sh @@ -1,16 +1,18 @@ -#!/usr/bin/env bashio -declare -a integrations -declare integration_path +#!/bin/sh -shopt -s globstar nullglob -for manifest in **/manifest.json; do +integrations="" +integration_path="" + +# Enable recursive globbing using find +for manifest in $(find . -name "manifest.json"); do manifest_path=$(realpath "${manifest}") - integrations+=(--integration-path "${manifest_path%/*}") + integrations="$integrations --integration-path ${manifest_path%/*}" done -if [[ ${#integrations[@]} -eq 0 ]]; then - bashio::exit.nok "No integrations found!" +if [ -z "$integrations" ]; then + echo "Error: No integrations found!" + exit 1 fi -cd /usr/src/homeassistant -exec python3 -m script.hassfest --action validate "${integrations[@]}" "$@" \ No newline at end of file +cd /usr/src/homeassistant || exit 1 +exec python3 -m script.hassfest --action validate $integrations "$@" From 5bd736029f7b4eea3ed70786abe9b9ee4ce8116c Mon Sep 17 00:00:00 2001 From: "Lektri.co" <137074859+Lektrico@users.noreply.github.com> Date: Fri, 30 Aug 2024 14:20:15 +0300 Subject: [PATCH 0058/1309] Add lektrico integration (#102371) * Add Lektrico Integration * Make the changes proposed by Lash-L: new coordinator.py, new entity.py; use: translation_key, last_update_sucess, PlatformNotReady; remove: global variables * Replace FlowResult with ConfigFlowResult and add tests. * Remove unused lines. * Remove Options from condif_flow * Fix ruff and mypy. * Fix CODEOWNERS. * Run python3 -m script.hassfest. * Correct rebase mistake. * Make modifications suggested by emontnemery. * Add pytest fixtures. * Remove meaningless patches. * Update .coveragerc * Replace CONF_FRIENDLY_NAME with CONF_NAME. * Remove underscores. * Update tests. * Update test file with is and no config_entries. . * Set serial_number in DeviceInfo and add return type of the async_update_data to DataUpdateCoordinator. * Use suggested_unit_of_measurement for KILO_WATT and replace Any in value_fn (sensor file). * Add device class duration to charging_time sensor. * Change raising PlatformNotReady to raising IntegrationError. * Test the unique id of the entry. * Rename PF Lx with Power factor Lx and remove PF from strings.json. * Remove comment. * Make state and limit reason sensors to be enum sensors. * Use result variable to check unique_id in test. * Remove CONF_NAME from entry and __init__ from LektricoFlowHandler. * Remove session parameter from LektricoDeviceDataUpdateCoordinator. * Use config_entry: ConfigEntry in coordinator. * Replace Connected,NeedAuth with Waiting for Authentication. * Use lektricowifi 0.0.29. * Use lektricowifi 0.0.39 * Use lektricowifi 0.0.40 * Use lektricowifi 0.0.41 * Replace hass.data with entry.runtime_data * Delete .coveragerc * Restructure the user step * Fix tests * Add returned value of _async_update_data to class DataUpdateCoordinator * Use hw_version at DeviceInfo * Remove a variable * Use StateType * Replace friendly_name with device_name * Use sentence case in translation strings * Uncomment and fix test_discovered_zeroconf * Add type LektricoConfigEntry * Remove commented code * Remove the type of coordinator in sensor async_setup_entry * Make zeroconf test end in ABORT, not FORM * Remove all async_block_till_done from tests * End test_user_setup_device_offline with CREATE_ENTRY * Patch the full Device * Add snapshot tests * Overwrite the type LektricoSensorEntityDescription outside of the constructor * Test separate already_configured for zeroconf --------- Co-authored-by: mihaela.tarjoianu Co-authored-by: Erik Montnemery --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/lektrico/__init__.py | 51 ++ .../components/lektrico/config_flow.py | 138 +++++ homeassistant/components/lektrico/const.py | 9 + .../components/lektrico/coordinator.py | 52 ++ homeassistant/components/lektrico/entity.py | 33 ++ .../components/lektrico/manifest.json | 16 + homeassistant/components/lektrico/sensor.py | 324 +++++++++++ .../components/lektrico/strings.json | 101 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 4 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/lektrico/__init__.py | 13 + tests/components/lektrico/conftest.py | 92 +++ .../lektrico/fixtures/current_measures.json | 16 + .../lektrico/fixtures/get_config.json | 5 + .../lektrico/fixtures/get_info.json | 13 + .../lektrico/snapshots/test_init.ambr | 33 ++ .../lektrico/snapshots/test_sensor.ambr | 534 ++++++++++++++++++ tests/components/lektrico/test_config_flow.py | 173 ++++++ tests/components/lektrico/test_init.py | 29 + tests/components/lektrico/test_sensor.py | 31 + 26 files changed, 1693 insertions(+) create mode 100644 homeassistant/components/lektrico/__init__.py create mode 100644 homeassistant/components/lektrico/config_flow.py create mode 100644 homeassistant/components/lektrico/const.py create mode 100644 homeassistant/components/lektrico/coordinator.py create mode 100644 homeassistant/components/lektrico/entity.py create mode 100644 homeassistant/components/lektrico/manifest.json create mode 100644 homeassistant/components/lektrico/sensor.py create mode 100644 homeassistant/components/lektrico/strings.json create mode 100644 tests/components/lektrico/__init__.py create mode 100644 tests/components/lektrico/conftest.py create mode 100644 tests/components/lektrico/fixtures/current_measures.json create mode 100644 tests/components/lektrico/fixtures/get_config.json create mode 100644 tests/components/lektrico/fixtures/get_info.json create mode 100644 tests/components/lektrico/snapshots/test_init.ambr create mode 100644 tests/components/lektrico/snapshots/test_sensor.ambr create mode 100644 tests/components/lektrico/test_config_flow.py create mode 100644 tests/components/lektrico/test_init.py create mode 100644 tests/components/lektrico/test_sensor.py diff --git a/.strict-typing b/.strict-typing index c8aa9878413..a65ccf3ec88 100644 --- a/.strict-typing +++ b/.strict-typing @@ -279,6 +279,7 @@ homeassistant.components.lawn_mower.* homeassistant.components.lcn.* homeassistant.components.ld2410_ble.* homeassistant.components.led_ble.* +homeassistant.components.lektrico.* homeassistant.components.lidarr.* homeassistant.components.lifx.* homeassistant.components.light.* diff --git a/CODEOWNERS b/CODEOWNERS index 990ed679d2b..97a1a1e49a1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -799,6 +799,8 @@ build.json @home-assistant/supervisor /tests/components/leaone/ @bdraco /homeassistant/components/led_ble/ @bdraco /tests/components/led_ble/ @bdraco +/homeassistant/components/lektrico/ @lektrico +/tests/components/lektrico/ @lektrico /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 /homeassistant/components/lidarr/ @tkdrob diff --git a/homeassistant/components/lektrico/__init__.py b/homeassistant/components/lektrico/__init__.py new file mode 100644 index 00000000000..70dbecca77a --- /dev/null +++ b/homeassistant/components/lektrico/__init__.py @@ -0,0 +1,51 @@ +"""The Lektrico Charging Station integration.""" + +from __future__ import annotations + +from lektricowifi import Device + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import LektricoDeviceDataUpdateCoordinator + +# List the platforms that charger supports. +CHARGERS_PLATFORMS = [Platform.SENSOR] + +# List the platforms that load balancer device supports. +LB_DEVICES_PLATFORMS = [Platform.SENSOR] + +type LektricoConfigEntry = ConfigEntry[LektricoDeviceDataUpdateCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: LektricoConfigEntry) -> bool: + """Set up Lektrico Charging Station from a config entry.""" + coordinator = LektricoDeviceDataUpdateCoordinator( + hass, + f"{entry.data[CONF_TYPE]}_{entry.data[ATTR_SERIAL_NUMBER]}", + ) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _get_platforms(entry)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms( + entry, _get_platforms(entry) + ) + + +def _get_platforms(entry: ConfigEntry) -> list[Platform]: + """Return the platforms for this type of device.""" + _device_type: str = entry.data[CONF_TYPE] + if _device_type in (Device.TYPE_1P7K, Device.TYPE_3P22K): + return CHARGERS_PLATFORMS + return LB_DEVICES_PLATFORMS diff --git a/homeassistant/components/lektrico/config_flow.py b/homeassistant/components/lektrico/config_flow.py new file mode 100644 index 00000000000..7091856f4fd --- /dev/null +++ b/homeassistant/components/lektrico/config_flow.py @@ -0,0 +1,138 @@ +"""Config flow for Lektrico Charging Station.""" + +from __future__ import annotations + +from typing import Any + +from lektricowifi import Device, DeviceConnectionError +import voluptuous as vol + +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + ATTR_HW_VERSION, + ATTR_SERIAL_NUMBER, + CONF_HOST, + CONF_TYPE, +) +from homeassistant.core import callback +from homeassistant.helpers.httpx_client import get_async_client + +from .const import DOMAIN + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class LektricoFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Lektrico config flow.""" + + VERSION = 1 + + _host: str + _name: str + _serial_number: str + _board_revision: str + _device_type: str + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors = None + + if user_input is not None: + self._host = user_input[CONF_HOST] + + # obtain serial number + try: + await self._get_lektrico_device_settings_and_treat_unique_id() + return self._async_create_entry() + except DeviceConnectionError: + errors = {CONF_HOST: "cannot_connect"} + + return self._async_show_setup_form(user_input=user_input, errors=errors) + + @callback + def _async_show_setup_form( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: + """Show the setup form to the user.""" + if user_input is None: + user_input = {} + + schema = self.add_suggested_values_to_schema(STEP_USER_DATA_SCHEMA, user_input) + + return self.async_show_form( + step_id="user", + data_schema=schema, + errors=errors or {}, + ) + + @callback + def _async_create_entry(self) -> ConfigFlowResult: + return self.async_create_entry( + title=self._name, + data={ + CONF_HOST: self._host, + ATTR_SERIAL_NUMBER: self._serial_number, + CONF_TYPE: self._device_type, + ATTR_HW_VERSION: self._board_revision, + }, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self._host = discovery_info.host # 192.168.100.11 + + # read settings from the device + try: + await self._get_lektrico_device_settings_and_treat_unique_id() + except DeviceConnectionError: + return self.async_abort(reason="cannot_connect") + + self.context["title_placeholders"] = { + "serial_number": self._serial_number, + "name": self._name, + } + + return await self.async_step_confirm() + + async def _get_lektrico_device_settings_and_treat_unique_id(self) -> None: + """Get device's serial number from a Lektrico device.""" + device = Device( + _host=self._host, + asyncClient=get_async_client(self.hass), + ) + + settings = await device.device_config() + self._serial_number = str(settings["serial_number"]) + self._device_type = settings["type"] + self._board_revision = settings["board_revision"] + self._name = f"{settings["type"]}_{self._serial_number}" + + # Check if already configured + # Set unique id + await self.async_set_unique_id(self._serial_number, raise_on_progress=True) + # Abort if already configured, but update the last-known host + self._abort_if_unique_id_configured( + updates={CONF_HOST: self._host}, reload_on_update=True + ) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: + """Allow the user to confirm adding the device.""" + + if user_input is not None: + return self._async_create_entry() + + self._set_confirm_only() + return self.async_show_form(step_id="confirm") diff --git a/homeassistant/components/lektrico/const.py b/homeassistant/components/lektrico/const.py new file mode 100644 index 00000000000..d3fc52f61be --- /dev/null +++ b/homeassistant/components/lektrico/const.py @@ -0,0 +1,9 @@ +"""Constants for the Lektrico Charging Station integration.""" + +from logging import Logger, getLogger + +# Integration domain +DOMAIN = "lektrico" + +# Logger +LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/lektrico/coordinator.py b/homeassistant/components/lektrico/coordinator.py new file mode 100644 index 00000000000..7c72a00e2d3 --- /dev/null +++ b/homeassistant/components/lektrico/coordinator.py @@ -0,0 +1,52 @@ +"""Coordinator for the Lektrico Charging Station integration.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from lektricowifi import Device, DeviceConnectionError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_HW_VERSION, + ATTR_SERIAL_NUMBER, + CONF_HOST, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +SCAN_INTERVAL = timedelta(seconds=10) + + +class LektricoDeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Data update coordinator for Lektrico device.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, device_name: str) -> None: + """Initialize a Lektrico Device.""" + super().__init__( + hass, + LOGGER, + name=device_name, + update_interval=SCAN_INTERVAL, + ) + self.device = Device( + self.config_entry.data[CONF_HOST], + asyncClient=get_async_client(hass), + ) + self.serial_number: str = self.config_entry.data[ATTR_SERIAL_NUMBER] + self.board_revision: str = self.config_entry.data[ATTR_HW_VERSION] + self.device_type: str = self.config_entry.data[CONF_TYPE] + + async def _async_update_data(self) -> dict[str, Any]: + """Async Update device state.""" + try: + return await self.device.device_info(self.device_type) + except DeviceConnectionError as lek_ex: + raise UpdateFailed(lek_ex) from lek_ex diff --git a/homeassistant/components/lektrico/entity.py b/homeassistant/components/lektrico/entity.py new file mode 100644 index 00000000000..1a5e08febe3 --- /dev/null +++ b/homeassistant/components/lektrico/entity.py @@ -0,0 +1,33 @@ +"""Entity classes for the Lektrico integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import LektricoDeviceDataUpdateCoordinator +from .const import DOMAIN + + +class LektricoEntity(CoordinatorEntity[LektricoDeviceDataUpdateCoordinator]): + """Define an Lektrico entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LektricoDeviceDataUpdateCoordinator, + device_name: str, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.serial_number)}, + model=coordinator.device_type.upper(), + name=device_name, + manufacturer="Lektrico", + sw_version=coordinator.data["fw_version"], + hw_version=coordinator.board_revision, + serial_number=coordinator.serial_number, + ) diff --git a/homeassistant/components/lektrico/manifest.json b/homeassistant/components/lektrico/manifest.json new file mode 100644 index 00000000000..5aef09f3845 --- /dev/null +++ b/homeassistant/components/lektrico/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "lektrico", + "name": "Lektrico Charging Station", + "codeowners": ["@lektrico"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/lektrico", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["lektricowifi==0.0.41"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "lektrico*" + } + ] +} diff --git a/homeassistant/components/lektrico/sensor.py b/homeassistant/components/lektrico/sensor.py new file mode 100644 index 00000000000..a8a929d974f --- /dev/null +++ b/homeassistant/components/lektrico/sensor.py @@ -0,0 +1,324 @@ +"""Support for Lektrico charging station sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from lektricowifi import Device + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + ATTR_SERIAL_NUMBER, + CONF_TYPE, + PERCENTAGE, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import IntegrationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator +from .entity import LektricoEntity + + +@dataclass(frozen=True, kw_only=True) +class LektricoSensorEntityDescription(SensorEntityDescription): + """A class that describes the Lektrico sensor entities.""" + + value_fn: Callable[[dict[str, Any]], StateType] + + +SENSORS_FOR_CHARGERS: tuple[LektricoSensorEntityDescription, ...] = ( + LektricoSensorEntityDescription( + key="state", + device_class=SensorDeviceClass.ENUM, + options=[ + "available", + "connected", + "need_auth", + "paused", + "charging", + "error", + "updating_firmware", + ], + translation_key="state", + value_fn=lambda data: str(data["charger_state"]), + ), + LektricoSensorEntityDescription( + key="charging_time", + translation_key="charging_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + value_fn=lambda data: int(data["charging_time"]), + ), + LektricoSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + value_fn=lambda data: float(data["instant_power"]), + ), + LektricoSensorEntityDescription( + key="energy", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda data: float(data["session_energy"]) / 1000, + ), + LektricoSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: float(data["temperature"]), + ), + LektricoSensorEntityDescription( + key="lifetime_energy", + translation_key="lifetime_energy", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_fn=lambda data: int(data["total_charged_energy"]), + ), + LektricoSensorEntityDescription( + key="installation_current", + translation_key="installation_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda data: int(data["install_current"]), + ), + LektricoSensorEntityDescription( + key="limit_reason", + translation_key="limit_reason", + device_class=SensorDeviceClass.ENUM, + options=[ + "no_limit", + "installation_current", + "user_limit", + "dynamic_limit", + "schedule", + "em_offline", + "em", + "ocpp", + ], + value_fn=lambda data: str(data["current_limit_reason"]), + ), +) + +SENSORS_FOR_LB_DEVICES: tuple[LektricoSensorEntityDescription, ...] = ( + LektricoSensorEntityDescription( + key="breaker_current", + translation_key="breaker_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda data: int(data["breaker_curent"]), + ), +) + +SENSORS_FOR_1_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( + LektricoSensorEntityDescription( + key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_fn=lambda data: float(data["voltage_l1"]), + ), + LektricoSensorEntityDescription( + key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda data: float(data["current_l1"]), + ), +) + +SENSORS_FOR_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( + LektricoSensorEntityDescription( + key="voltage_l1", + translation_key="voltage_l1", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_fn=lambda data: float(data["voltage_l1"]), + ), + LektricoSensorEntityDescription( + key="voltage_l2", + translation_key="voltage_l2", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_fn=lambda data: float(data["voltage_l2"]), + ), + LektricoSensorEntityDescription( + key="voltage_l3", + translation_key="voltage_l3", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_fn=lambda data: float(data["voltage_l3"]), + ), + LektricoSensorEntityDescription( + key="current_l1", + translation_key="current_l1", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda data: float(data["current_l1"]), + ), + LektricoSensorEntityDescription( + key="current_l2", + translation_key="current_l2", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda data: float(data["current_l2"]), + ), + LektricoSensorEntityDescription( + key="current_l3", + translation_key="current_l3", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda data: float(data["current_l3"]), + ), +) + + +SENSORS_FOR_LB_1_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( + LektricoSensorEntityDescription( + key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + value_fn=lambda data: float(data["power_l1"]), + ), + LektricoSensorEntityDescription( + key="pf", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: float(data["power_factor_l1"]) * 100, + ), +) + + +SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = ( + LektricoSensorEntityDescription( + key="power_l1", + translation_key="power_l1", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + value_fn=lambda data: float(data["power_l1"]), + ), + LektricoSensorEntityDescription( + key="power_l2", + translation_key="power_l2", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + value_fn=lambda data: float(data["power_l2"]), + ), + LektricoSensorEntityDescription( + key="power_l3", + translation_key="power_l3", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + value_fn=lambda data: float(data["power_l3"]), + ), + LektricoSensorEntityDescription( + key="pf_l1", + translation_key="pf_l1", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: float(data["power_factor_l1"]) * 100, + ), + LektricoSensorEntityDescription( + key="pf_l2", + translation_key="pf_l2", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: float(data["power_factor_l2"]) * 100, + ), + LektricoSensorEntityDescription( + key="pf_l3", + translation_key="pf_l3", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: float(data["power_factor_l3"]) * 100, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LektricoConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Lektrico charger based on a config entry.""" + coordinator = entry.runtime_data + + sensors_to_be_used: tuple[LektricoSensorEntityDescription, ...] + if coordinator.device_type == Device.TYPE_1P7K: + sensors_to_be_used = SENSORS_FOR_CHARGERS + SENSORS_FOR_1_PHASE + elif coordinator.device_type == Device.TYPE_3P22K: + sensors_to_be_used = SENSORS_FOR_CHARGERS + SENSORS_FOR_3_PHASE + elif coordinator.device_type == Device.TYPE_EM: + sensors_to_be_used = ( + SENSORS_FOR_LB_DEVICES + SENSORS_FOR_1_PHASE + SENSORS_FOR_LB_1_PHASE + ) + elif coordinator.device_type == Device.TYPE_3EM: + sensors_to_be_used = ( + SENSORS_FOR_LB_DEVICES + SENSORS_FOR_3_PHASE + SENSORS_FOR_LB_3_PHASE + ) + else: + raise IntegrationError + + async_add_entities( + LektricoSensor( + description, + coordinator, + f"{entry.data[CONF_TYPE]}_{entry.data[ATTR_SERIAL_NUMBER]}", + ) + for description in sensors_to_be_used + ) + + +class LektricoSensor(LektricoEntity, SensorEntity): + """The entity class for Lektrico charging stations sensors.""" + + entity_description: LektricoSensorEntityDescription + + def __init__( + self, + description: LektricoSensorEntityDescription, + coordinator: LektricoDeviceDataUpdateCoordinator, + device_name: str, + ) -> None: + """Initialize Lektrico charger.""" + super().__init__(coordinator, device_name) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json new file mode 100644 index 00000000000..767987e7e64 --- /dev/null +++ b/homeassistant/components/lektrico/strings.json @@ -0,0 +1,101 @@ +{ + "config": { + "step": { + "user": { + "description": "Set required parameters to connect to your device", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "device_name": "[%key:common::config_flow::data::name%]" + } + }, + "zeroconf_confirm": { + "description": "Do you want to add the Lektrico Charger with serial number `{serial_number}` to Home Assistant?", + "title": "Discovered Lektrico Charger device" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "sensor": { + "state": { + "name": "State", + "state": { + "available": "Available", + "connected": "Connected", + "need_auth": "Waiting for authentication", + "paused": "Paused", + "charging": "Charging", + "error": "Error", + "updating_firmware": "Updating firmware" + } + }, + "charging_time": { + "name": "Charging time" + }, + "lifetime_energy": { + "name": "Lifetime energy" + }, + "installation_current": { + "name": "Installation current" + }, + "limit_reason": { + "name": "Limit reason", + "state": { + "no_limit": "No limit", + "installation_current": "Installation current", + "user_limit": "User limit", + "dynamic_limit": "Dynamic limit", + "schedule": "Schedule", + "em_offline": "EM offline", + "em": "EM", + "ocpp": "OCPP" + } + }, + "breaker_current": { + "name": "Breaker current" + }, + "voltage_l1": { + "name": "Voltage L1" + }, + "voltage_l2": { + "name": "Voltage L2" + }, + "voltage_l3": { + "name": "Voltage L3" + }, + "current_l1": { + "name": "Current L1" + }, + "current_l2": { + "name": "Current L2" + }, + "current_l3": { + "name": "Current L3" + }, + "power_l1": { + "name": "Power L1" + }, + "power_l2": { + "name": "Power L2" + }, + "power_l3": { + "name": "Power L3" + }, + "pf_l1": { + "name": "Power factor L1" + }, + "pf_l2": { + "name": "Power factor L2" + }, + "pf_l3": { + "name": "Power factor L3" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ee6658a2515..0ca3335725f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -315,6 +315,7 @@ FLOWS = { "ld2410_ble", "leaone", "led_ble", + "lektrico", "lg_netcast", "lg_soundbar", "lidarr", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bb81b6a5b04..2e9199a3b0a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3211,6 +3211,12 @@ "integration_type": "virtual", "supported_by": "netatmo" }, + "lektrico": { + "name": "Lektrico Charging Station", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "leviton": { "name": "Leviton", "iot_standards": [ diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3d5b0b4cfa1..36b0da4a9f4 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -527,6 +527,10 @@ ZEROCONF = { "domain": "bosch_shc", "name": "bosch shc*", }, + { + "domain": "lektrico", + "name": "lektrico*", + }, { "domain": "loqed", "name": "loqed*", diff --git a/mypy.ini b/mypy.ini index c7a31d7354c..102ae5c8aa9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2546,6 +2546,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lektrico.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lidarr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 9fd599fba93..42962c759aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1254,6 +1254,9 @@ leaone-ble==0.1.0 # homeassistant.components.led_ble led-ble==1.0.2 +# homeassistant.components.lektrico +lektricowifi==0.0.41 + # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0acc2a9f916..6ba5ecbea6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1047,6 +1047,9 @@ leaone-ble==0.1.0 # homeassistant.components.led_ble led-ble==1.0.2 +# homeassistant.components.lektrico +lektricowifi==0.0.41 + # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/tests/components/lektrico/__init__.py b/tests/components/lektrico/__init__.py new file mode 100644 index 00000000000..449da2b35c4 --- /dev/null +++ b/tests/components/lektrico/__init__.py @@ -0,0 +1,13 @@ +"""Tests for Lektrico integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/lektrico/conftest.py b/tests/components/lektrico/conftest.py new file mode 100644 index 00000000000..fd840b0c290 --- /dev/null +++ b/tests/components/lektrico/conftest.py @@ -0,0 +1,92 @@ +"""Fixtures for Lektrico Charging Station integration tests.""" + +from collections.abc import Generator +from ipaddress import ip_address +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.lektrico.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.const import ( + ATTR_HW_VERSION, + ATTR_SERIAL_NUMBER, + CONF_HOST, + CONF_TYPE, +) + +from tests.common import MockConfigEntry, load_fixture + +MOCKED_DEVICE_IP_ADDRESS = "192.168.100.10" +MOCKED_DEVICE_SERIAL_NUMBER = "500006" +MOCKED_DEVICE_TYPE = "1p7k" +MOCKED_DEVICE_BOARD_REV = "B" + +MOCKED_DEVICE_ZC_NAME = "Lektrico-1p7k-500006._http._tcp" +MOCKED_DEVICE_ZC_TYPE = "_http._tcp.local." +MOCKED_DEVICE_ZEROCONF_DATA = ZeroconfServiceInfo( + ip_address=ip_address(MOCKED_DEVICE_IP_ADDRESS), + ip_addresses=[ip_address(MOCKED_DEVICE_IP_ADDRESS)], + hostname=f"{MOCKED_DEVICE_ZC_NAME.lower()}.local.", + port=80, + type=MOCKED_DEVICE_ZC_TYPE, + name=MOCKED_DEVICE_ZC_NAME, + properties={ + "id": "1p7k_500006", + "fw_id": "20230109-124642/v1.22-36-g56a3edd-develop-dirty", + }, +) + + +@pytest.fixture +def mock_device() -> Generator[AsyncMock]: + """Mock a Lektrico device.""" + with ( + patch( + "homeassistant.components.lektrico.Device", + autospec=True, + ) as mock_device, + patch( + "homeassistant.components.lektrico.config_flow.Device", + new=mock_device, + ), + patch( + "homeassistant.components.lektrico.coordinator.Device", + new=mock_device, + ), + ): + device = mock_device.return_value + + device.device_config.return_value = json.loads( + load_fixture("get_config.json", DOMAIN) + ) + device.device_info.return_value = json.loads( + load_fixture("get_info.json", DOMAIN) + ) + + yield device + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setup entry.""" + with patch( + "homeassistant.components.lektrico.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: MOCKED_DEVICE_IP_ADDRESS, + CONF_TYPE: MOCKED_DEVICE_TYPE, + ATTR_SERIAL_NUMBER: MOCKED_DEVICE_SERIAL_NUMBER, + ATTR_HW_VERSION: "B", + }, + unique_id=MOCKED_DEVICE_SERIAL_NUMBER, + ) diff --git a/tests/components/lektrico/fixtures/current_measures.json b/tests/components/lektrico/fixtures/current_measures.json new file mode 100644 index 00000000000..1175b49f63c --- /dev/null +++ b/tests/components/lektrico/fixtures/current_measures.json @@ -0,0 +1,16 @@ +{ + "charger_state": "Available", + "charging_time": 0, + "instant_power": 0, + "session_energy": 0.0, + "temperature": 34.5, + "total_charged_energy": 0, + "install_current": 6, + "current_limit_reason": "Installation current", + "voltage_l1": 220.0, + "current_l1": 0.0, + "type": "1p7k", + "serial_number": "500006", + "board_revision": "B", + "fw_version": "1.44" +} diff --git a/tests/components/lektrico/fixtures/get_config.json b/tests/components/lektrico/fixtures/get_config.json new file mode 100644 index 00000000000..175475004ec --- /dev/null +++ b/tests/components/lektrico/fixtures/get_config.json @@ -0,0 +1,5 @@ +{ + "type": "1p7k", + "serial_number": "500006", + "board_revision": "B" +} diff --git a/tests/components/lektrico/fixtures/get_info.json b/tests/components/lektrico/fixtures/get_info.json new file mode 100644 index 00000000000..a8f2a56b8d8 --- /dev/null +++ b/tests/components/lektrico/fixtures/get_info.json @@ -0,0 +1,13 @@ +{ + "charger_state": "available", + "charging_time": 0, + "instant_power": 0, + "session_energy": 0.0, + "temperature": 34.5, + "total_charged_energy": 0, + "install_current": 6, + "current_limit_reason": "installation_current", + "voltage_l1": 220.0, + "current_l1": 0.0, + "fw_version": "1.44" +} diff --git a/tests/components/lektrico/snapshots/test_init.ambr b/tests/components/lektrico/snapshots/test_init.ambr new file mode 100644 index 00000000000..63739e1c9d8 --- /dev/null +++ b/tests/components/lektrico/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': 'B', + 'id': , + 'identifiers': set({ + tuple( + 'lektrico', + '500006', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Lektrico', + 'model': '1P7K', + 'model_id': None, + 'name': '1p7k_500006', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '500006', + 'suggested_area': None, + 'sw_version': '1.44', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/lektrico/snapshots/test_sensor.ambr b/tests/components/lektrico/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..7df5df70218 --- /dev/null +++ b/tests/components/lektrico/snapshots/test_sensor.ambr @@ -0,0 +1,534 @@ +# serializer version: 1 +# name: test_all_entities[sensor.1p7k_500006_charging_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1p7k_500006_charging_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging time', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charging_time', + 'unique_id': '500006_charging_time', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_charging_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': '1p7k_500006 Charging time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.1p7k_500006_charging_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1p7k_500006_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '500006_current', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '1p7k_500006 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.1p7k_500006_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1p7k_500006_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '500006_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': '1p7k_500006 Energy', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.1p7k_500006_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_installation_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1p7k_500006_installation_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation current', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'installation_current', + 'unique_id': '500006_installation_current', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_installation_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '1p7k_500006 Installation current', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.1p7k_500006_installation_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_lifetime_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1p7k_500006_lifetime_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '500006_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_lifetime_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': '1p7k_500006 Lifetime energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.1p7k_500006_lifetime_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_limit_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_limit', + 'installation_current', + 'user_limit', + 'dynamic_limit', + 'schedule', + 'em_offline', + 'em', + 'ocpp', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1p7k_500006_limit_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Limit reason', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'limit_reason', + 'unique_id': '500006_limit_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_limit_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': '1p7k_500006 Limit reason', + 'options': list([ + 'no_limit', + 'installation_current', + 'user_limit', + 'dynamic_limit', + 'schedule', + 'em_offline', + 'em', + 'ocpp', + ]), + }), + 'context': , + 'entity_id': 'sensor.1p7k_500006_limit_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'installation_current', + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1p7k_500006_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '500006_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '1p7k_500006 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.1p7k_500006_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0000', + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'available', + 'connected', + 'need_auth', + 'paused', + 'charging', + 'error', + 'updating_firmware', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1p7k_500006_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state', + 'unique_id': '500006_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': '1p7k_500006 State', + 'options': list([ + 'available', + 'connected', + 'need_auth', + 'paused', + 'charging', + 'error', + 'updating_firmware', + ]), + }), + 'context': , + 'entity_id': 'sensor.1p7k_500006_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'available', + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1p7k_500006_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '500006_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '1p7k_500006 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.1p7k_500006_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34.5', + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.1p7k_500006_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '500006_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.1p7k_500006_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '1p7k_500006 Voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.1p7k_500006_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220.0', + }) +# --- diff --git a/tests/components/lektrico/test_config_flow.py b/tests/components/lektrico/test_config_flow.py new file mode 100644 index 00000000000..15ab5f7cdda --- /dev/null +++ b/tests/components/lektrico/test_config_flow.py @@ -0,0 +1,173 @@ +"""Tests for the Lektrico Charging Station config flow.""" + +import dataclasses +from ipaddress import ip_address + +from lektricowifi import DeviceConnectionError + +from homeassistant.components.lektrico.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import ( + ATTR_HW_VERSION, + ATTR_SERIAL_NUMBER, + CONF_HOST, + CONF_TYPE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import ( + MOCKED_DEVICE_BOARD_REV, + MOCKED_DEVICE_IP_ADDRESS, + MOCKED_DEVICE_SERIAL_NUMBER, + MOCKED_DEVICE_TYPE, + MOCKED_DEVICE_ZEROCONF_DATA, +) + +from tests.common import MockConfigEntry + + +async def test_user_setup(hass: HomeAssistant, mock_device, mock_setup_entry) -> None: + """Test manually setting up.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_USER + assert "flow_id" in result + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCKED_DEVICE_IP_ADDRESS, + }, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == f"{MOCKED_DEVICE_TYPE}_{MOCKED_DEVICE_SERIAL_NUMBER}" + assert result.get("data") == { + CONF_HOST: MOCKED_DEVICE_IP_ADDRESS, + ATTR_SERIAL_NUMBER: MOCKED_DEVICE_SERIAL_NUMBER, + CONF_TYPE: MOCKED_DEVICE_TYPE, + ATTR_HW_VERSION: MOCKED_DEVICE_BOARD_REV, + } + assert "result" in result + assert len(mock_setup_entry.mock_calls) == 1 + assert result.get("result").unique_id == MOCKED_DEVICE_SERIAL_NUMBER + + +async def test_user_setup_already_exists( + hass: HomeAssistant, mock_device, mock_config_entry: MockConfigEntry +) -> None: + """Test manually setting up when the device already exists.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCKED_DEVICE_IP_ADDRESS, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_setup_device_offline(hass: HomeAssistant, mock_device) -> None: + """Test manually setting up when device is offline.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + mock_device.device_config.side_effect = DeviceConnectionError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCKED_DEVICE_IP_ADDRESS, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_HOST: "cannot_connect"} + assert result["step_id"] == "user" + + mock_device.device_config.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: MOCKED_DEVICE_IP_ADDRESS, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_discovered_zeroconf( + hass: HomeAssistant, mock_device, mock_setup_entry +) -> None: + """Test we can setup when discovered from zeroconf.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=MOCKED_DEVICE_ZEROCONF_DATA, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + assert result.get("step_id") == "confirm" + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == { + CONF_HOST: MOCKED_DEVICE_IP_ADDRESS, + ATTR_SERIAL_NUMBER: MOCKED_DEVICE_SERIAL_NUMBER, + CONF_TYPE: MOCKED_DEVICE_TYPE, + ATTR_HW_VERSION: MOCKED_DEVICE_BOARD_REV, + } + assert result2["title"] == f"{MOCKED_DEVICE_TYPE}_{MOCKED_DEVICE_SERIAL_NUMBER}" + + +async def test_zeroconf_setup_already_exists( + hass: HomeAssistant, mock_device, mock_config_entry: MockConfigEntry +) -> None: + """Test we abort zeroconf flow if device already configured.""" + mock_config_entry.add_to_hass(hass) + zc_data_new_ip = dataclasses.replace(MOCKED_DEVICE_ZEROCONF_DATA) + zc_data_new_ip.ip_address = ip_address(MOCKED_DEVICE_IP_ADDRESS) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zc_data_new_ip, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_discovered_zeroconf_device_connection_error( + hass: HomeAssistant, mock_device +) -> None: + """Test we can setup when discovered from zeroconf but device went offline.""" + + mock_device.device_config.side_effect = DeviceConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=MOCKED_DEVICE_ZEROCONF_DATA, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/lektrico/test_init.py b/tests/components/lektrico/test_init.py new file mode 100644 index 00000000000..93068ffe531 --- /dev/null +++ b/tests/components/lektrico/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Lektrico integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.lektrico.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> 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 == snapshot diff --git a/tests/components/lektrico/test_sensor.py b/tests/components/lektrico/test_sensor.py new file mode 100644 index 00000000000..756f149d3ad --- /dev/null +++ b/tests/components/lektrico/test_sensor.py @@ -0,0 +1,31 @@ +"""Tests for the Lektrico sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.lektrico.CHARGERS_PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 7f405686d13b8ac995a8f8676e6ecfe0ae7ae7af Mon Sep 17 00:00:00 2001 From: shapournemati-iotty <130070037+shapournemati-iotty@users.noreply.github.com> Date: Fri, 30 Aug 2024 13:30:56 +0200 Subject: [PATCH 0059/1309] Add shapournemati to iotty codeowners (#123649) * add shapournemati to codeowners for improved support * update codeowners with hassfest script * update codeowners with hassfest script --- CODEOWNERS | 4 ++-- homeassistant/components/iotty/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 97a1a1e49a1..c31056089de 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -709,8 +709,8 @@ build.json @home-assistant/supervisor /tests/components/ios/ @robbiet480 /homeassistant/components/iotawatt/ @gtdiehl @jyavenard /tests/components/iotawatt/ @gtdiehl @jyavenard -/homeassistant/components/iotty/ @pburgio -/tests/components/iotty/ @pburgio +/homeassistant/components/iotty/ @pburgio @shapournemati-iotty +/tests/components/iotty/ @pburgio @shapournemati-iotty /homeassistant/components/iperf3/ @rohankapoorcom /homeassistant/components/ipma/ @dgomes /tests/components/ipma/ @dgomes diff --git a/homeassistant/components/iotty/manifest.json b/homeassistant/components/iotty/manifest.json index 87aa49799b2..66baddc6b47 100644 --- a/homeassistant/components/iotty/manifest.json +++ b/homeassistant/components/iotty/manifest.json @@ -1,7 +1,7 @@ { "domain": "iotty", "name": "iotty", - "codeowners": ["@pburgio"], + "codeowners": ["@pburgio", "@shapournemati-iotty"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/iotty", From 32babd39589bbfba8baa2df872c418e1e7f2427c Mon Sep 17 00:00:00 2001 From: Jeef Date: Fri, 30 Aug 2024 05:32:07 -0600 Subject: [PATCH 0060/1309] Clean up Weatherflow Cloud (#124643) cleanup --- homeassistant/components/weatherflow_cloud/sensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py index 1c7fa5fb377..aeab955878f 100644 --- a/homeassistant/components/weatherflow_cloud/sensor.py +++ b/homeassistant/components/weatherflow_cloud/sensor.py @@ -180,11 +180,9 @@ async def async_setup_entry( entry.entry_id ] - stations = coordinator.data.keys() - async_add_entities( WeatherFlowCloudSensor(coordinator, sensor_description, station_id) - for station_id in stations + for station_id in coordinator.data for sensor_description in WF_SENSORS ) From c9335598db008f551ad913697fd9306f5883fc01 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Fri, 30 Aug 2024 05:32:32 -0700 Subject: [PATCH 0061/1309] Alphabetize keys list for nut sensor icons (#124188) Alphabetize keys list for sensor icons --- homeassistant/components/nut/icons.json | 126 ++++++++++++------------ 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json index a4125d8633f..e0f78d6400b 100644 --- a/homeassistant/components/nut/icons.json +++ b/homeassistant/components/nut/icons.json @@ -1,59 +1,11 @@ { "entity": { "sensor": { - "ups_status_display": { + "battery_alarm_threshold": { "default": "mdi:information-outline" }, - "ups_status": { - "default": "mdi:information-outline" - }, - "ups_alarm": { - "default": "mdi:alarm" - }, - "ups_load": { - "default": "mdi:gauge" - }, - "ups_load_high": { - "default": "mdi:gauge" - }, - "ups_id": { - "default": "mdi:information-outline" - }, - "ups_test_result": { - "default": "mdi:information-outline" - }, - "ups_test_date": { - "default": "mdi:calendar" - }, - "ups_display_language": { - "default": "mdi:information-outline" - }, - "ups_contacts": { - "default": "mdi:information-outline" - }, - "ups_efficiency": { - "default": "mdi:gauge" - }, - "ups_beeper_status": { - "default": "mdi:information-outline" - }, - "ups_type": { - "default": "mdi:information-outline" - }, - "ups_watchdog_status": { - "default": "mdi:information-outline" - }, - "ups_start_auto": { - "default": "mdi:information-outline" - }, - "ups_start_battery": { - "default": "mdi:information-outline" - }, - "ups_start_reboot": { - "default": "mdi:information-outline" - }, - "ups_shutdown": { - "default": "mdi:information-outline" + "battery_capacity": { + "default": "mdi:flash" }, "battery_charge_low": { "default": "mdi:gauge" @@ -67,12 +19,6 @@ "battery_charger_status": { "default": "mdi:information-outline" }, - "battery_capacity": { - "default": "mdi:flash" - }, - "battery_alarm_threshold": { - "default": "mdi:information-outline" - }, "battery_date": { "default": "mdi:calendar" }, @@ -88,19 +34,19 @@ "battery_type": { "default": "mdi:information-outline" }, - "input_sensitivity": { - "default": "mdi:information-outline" - }, - "input_transfer_reason": { + "input_bypass_phases": { "default": "mdi:information-outline" }, "input_frequency_status": { "default": "mdi:information-outline" }, - "input_bypass_phases": { + "input_phases": { "default": "mdi:information-outline" }, - "input_phases": { + "input_sensitivity": { + "default": "mdi:information-outline" + }, + "input_transfer_reason": { "default": "mdi:information-outline" }, "output_l1_power_percent": { @@ -114,6 +60,60 @@ }, "output_phases": { "default": "mdi:information-outline" + }, + "ups_alarm": { + "default": "mdi:alarm" + }, + "ups_beeper_status": { + "default": "mdi:information-outline" + }, + "ups_contacts": { + "default": "mdi:information-outline" + }, + "ups_display_language": { + "default": "mdi:information-outline" + }, + "ups_efficiency": { + "default": "mdi:gauge" + }, + "ups_id": { + "default": "mdi:information-outline" + }, + "ups_load": { + "default": "mdi:gauge" + }, + "ups_load_high": { + "default": "mdi:gauge" + }, + "ups_shutdown": { + "default": "mdi:information-outline" + }, + "ups_start_auto": { + "default": "mdi:information-outline" + }, + "ups_start_battery": { + "default": "mdi:information-outline" + }, + "ups_start_reboot": { + "default": "mdi:information-outline" + }, + "ups_status": { + "default": "mdi:information-outline" + }, + "ups_status_display": { + "default": "mdi:information-outline" + }, + "ups_test_date": { + "default": "mdi:calendar" + }, + "ups_test_result": { + "default": "mdi:information-outline" + }, + "ups_type": { + "default": "mdi:information-outline" + }, + "ups_watchdog_status": { + "default": "mdi:information-outline" } } } From 928ff7c78c657ad690a4b9a76cd9147acf45802d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 30 Aug 2024 14:32:57 +0200 Subject: [PATCH 0062/1309] Add 100% coverage of Reolink sensor platform (#124472) * Add 100% sensor test coverage * use DOMAIN instead of const.DOMAIN * snake_case * better split tests * styling * Use entity_registry_enabled_by_default fixture --- tests/components/reolink/test_sensor.py | 62 +++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/components/reolink/test_sensor.py diff --git a/tests/components/reolink/test_sensor.py b/tests/components/reolink/test_sensor.py new file mode 100644 index 00000000000..df164634355 --- /dev/null +++ b/tests/components/reolink/test_sensor.py @@ -0,0 +1,62 @@ +"""Test the Reolink sensor platform.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant + +from .conftest import TEST_NVR_NAME + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test sensor entities.""" + reolink_connect.ptz_pan_position.return_value = 1200 + reolink_connect.wifi_connection = True + reolink_connect.wifi_signal = 3 + reolink_connect.hdd_list = [0] + reolink_connect.hdd_storage.return_value = 95 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SENSOR]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_ptz_pan_position" + assert hass.states.get(entity_id).state == "1200" + + entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_wi_fi_signal" + assert hass.states.get(entity_id).state == "3" + + entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_sd_0_storage" + assert hass.states.get(entity_id).state == "95" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_hdd_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test hdd sensor entity.""" + reolink_connect.hdd_list = [0] + reolink_connect.hdd_type.return_value = "HDD" + reolink_connect.hdd_storage.return_value = 85 + reolink_connect.hdd_available.return_value = False + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SENSOR]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SENSOR}.{TEST_NVR_NAME}_hdd_0_storage" + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE From b6dc410464048b65f5ee1f52d3e12bac71f58163 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 30 Aug 2024 14:34:17 +0200 Subject: [PATCH 0063/1309] Add 100% coverage of Reolink light platform (#124382) * Add 100% light test coverage * review comments * fix * use STATE_ON * split tests --- homeassistant/components/reolink/light.py | 3 +- tests/components/reolink/test_light.py | 146 ++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 tests/components/reolink/test_light.py diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index 877bf80080b..fe34cccc0c4 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -108,8 +108,7 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): @property def brightness(self) -> int | None: """Return the brightness of this light between 0.255.""" - if self.entity_description.get_brightness_fn is None: - return None + assert self.entity_description.get_brightness_fn is not None bright_pct = self.entity_description.get_brightness_fn( self._host.api, self._channel diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py new file mode 100644 index 00000000000..c495a0ff25e --- /dev/null +++ b/tests/components/reolink/test_light.py @@ -0,0 +1,146 @@ +"""Test the Reolink light platform.""" + +from unittest.mock import MagicMock, call, patch + +import pytest +from reolink_aio.exceptions import InvalidParameterError, ReolinkError + +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import TEST_NVR_NAME + +from tests.common import MockConfigEntry + + +async def test_light_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test light entity state with floodlight.""" + reolink_connect.whiteled_state.return_value = True + reolink_connect.whiteled_brightness.return_value = 100 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes["brightness"] == 255 + + +async def test_light_brightness_none( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test light entity with floodlight and brightness returning None.""" + reolink_connect.whiteled_state.return_value = True + reolink_connect.whiteled_brightness.return_value = None + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes["brightness"] is None + + +async def test_light_turn_off( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test light turn off service.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_whiteled.assert_called_with(0, state=False) + + reolink_connect.set_whiteled.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_light_turn_on( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test light turn on service.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, + blocking=True, + ) + reolink_connect.set_whiteled.assert_has_calls( + [call(0, brightness=20), call(0, state=True)] + ) + + reolink_connect.set_whiteled.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + reolink_connect.set_whiteled.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, + blocking=True, + ) + + reolink_connect.set_whiteled.side_effect = InvalidParameterError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, + blocking=True, + ) From 6589216ed35dc4f0b3f38196981bf2030faba2f4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 30 Aug 2024 14:34:49 +0200 Subject: [PATCH 0064/1309] Add 100% coverage of Reolink camera platform (#124381) * Add 100% camera test coverage * review comments * use DOMAIN instead of const.DOMAIN * use entity_registry_enabled_by_default fixture * fixes --- tests/components/reolink/conftest.py | 1 + .../components/reolink/test_binary_sensor.py | 4 +- tests/components/reolink/test_camera.py | 63 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/components/reolink/test_camera.py diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index ddea36cb292..ed6ff4c4ec7 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -38,6 +38,7 @@ TEST_USE_HTTPS = True TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" TEST_CAM_MODEL = "RLC-123" +TEST_DUO_MODEL = "Reolink Duo PoE" @pytest.fixture diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index e02742afe1d..0872c3ab3b2 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -10,7 +10,7 @@ from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import TEST_NVR_NAME, TEST_UID +from .conftest import TEST_DUO_MODEL, TEST_NVR_NAME, TEST_UID from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -25,7 +25,7 @@ async def test_motion_sensor( entity_registry: er.EntityRegistry, ) -> None: """Test binary sensor entity with motion sensor.""" - reolink_connect.model = "Reolink Duo PoE" + reolink_connect.model = TEST_DUO_MODEL reolink_connect.motion_detected.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/reolink/test_camera.py b/tests/components/reolink/test_camera.py new file mode 100644 index 00000000000..96bb5a099c9 --- /dev/null +++ b/tests/components/reolink/test_camera.py @@ -0,0 +1,63 @@ +"""Test the Reolink camera platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.camera import async_get_image, async_get_stream_source +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_IDLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import TEST_DUO_MODEL, TEST_NVR_NAME + +from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator + + +async def test_camera( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test camera entity with fluent.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.CAMERA}.{TEST_NVR_NAME}_fluent" + assert hass.states.get(entity_id).state == STATE_IDLE + + # check getting a image from the camera + reolink_connect.get_snapshot.return_value = b"image" + assert (await async_get_image(hass, entity_id)).content == b"image" + + reolink_connect.get_snapshot.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await async_get_image(hass, entity_id) + + # check getting the stream source + assert await async_get_stream_source(hass, entity_id) is not None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_camera_no_stream_source( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test camera entity with no stream source.""" + reolink_connect.model = TEST_DUO_MODEL + reolink_connect.get_stream_source.return_value = None + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.CAMERA}.{TEST_NVR_NAME}_snapshots_fluent_lens_0" + assert hass.states.get(entity_id).state == STATE_IDLE From a5bacf5652ce9b9ae0cdcf54e32795270cbaf8f0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 30 Aug 2024 14:39:12 +0200 Subject: [PATCH 0065/1309] Add 100% coverage of Reolink switch platform (#124482) * Add 100% switch test coverage * use DOMAIN instead of const.DOMAIN * Split tests and use parametrize * Revert "Split tests and use parametrize" This reverts commit 50d2184ce67b1ac95bd1517cb4963707f9c7954a. * fixes --- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_switch.py | 228 ++++++++++++++++++++++-- 2 files changed, 215 insertions(+), 14 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index ed6ff4c4ec7..be87aac9291 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -33,6 +33,7 @@ TEST_UID = "ABC1234567D89EFG" TEST_UID_CAM = "DEF7654321D89GHT" TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" +TEST_CAM_NAME = "test_reolink_cam" TEST_NVR_NAME2 = "test2_reolink_name" TEST_USE_HTTPS = True TEST_HOST_MODEL = "RLN8-410" diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index ebf805b593d..7f8d606555d 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -1,15 +1,31 @@ """Test the Reolink switch platform.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch -from homeassistant.components.reolink import const -from homeassistant.const import Platform +from freezegun.api import FrozenDateTimeFactory +import pytest +from reolink_aio.api import Chime +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL +from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir -from .conftest import TEST_UID +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME, TEST_UID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_cleanup_hdr_switch_( @@ -27,23 +43,21 @@ async def test_cleanup_hdr_switch_( entity_registry.async_get_or_create( domain=domain, - platform=const.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, const.DOMAIN, original_id) + 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, const.DOMAIN, original_id) is None - ) + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None async def test_hdr_switch_deprecated_repair_issue( @@ -62,20 +76,206 @@ async def test_hdr_switch_deprecated_repair_issue( entity_registry.async_get_or_create( domain=domain, - platform=const.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, const.DOMAIN, original_id) + 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, const.DOMAIN, original_id) + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) - assert (const.DOMAIN, "hdr_switch_deprecated") in issue_registry.issues + assert (DOMAIN, "hdr_switch_deprecated") in issue_registry.issues + + +async def test_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, +) -> None: + """Test switch entity.""" + reolink_connect.camera_name.return_value = TEST_CAM_NAME + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.recording_enabled.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_OFF + + # test switch turn on + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_recording.assert_called_with(0, True) + + reolink_connect.set_recording.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # test switch turn off + reolink_connect.set_recording.side_effect = None + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_recording.assert_called_with(0, False) + + reolink_connect.set_recording.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_host_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, +) -> None: + """Test host switch entity.""" + reolink_connect.camera_name.return_value = TEST_CAM_NAME + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.recording_enabled.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_OFF + + # test switch turn on + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_recording.assert_called_with(None, True) + + reolink_connect.set_recording.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # test switch turn off + reolink_connect.set_recording.side_effect = None + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_recording.assert_called_with(None, False) + + reolink_connect.set_recording.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_chime_switch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, + test_chime: Chime, +) -> None: + """Test host switch entity.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.test_chime_led" + assert hass.states.get(entity_id).state == STATE_ON + + test_chime.led_state = 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_OFF + + # test switch turn on + test_chime.set_option = AsyncMock() + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + test_chime.set_option.assert_called_with(led=True) + + test_chime.set_option.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # test switch turn off + test_chime.set_option.side_effect = None + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + test_chime.set_option.assert_called_with(led=False) + + test_chime.set_option.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) From c47b37af4fe963f78d2f6125ae0f0b598227b5fa Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 30 Aug 2024 14:40:28 +0200 Subject: [PATCH 0066/1309] Use snapshot in Axis camera tests (#122677) --- .../axis/snapshots/test_camera.ambr | 101 ++++++++++++++++++ tests/components/axis/test_camera.py | 90 ++++++++-------- 2 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 tests/components/axis/snapshots/test_camera.ambr diff --git a/tests/components/axis/snapshots/test_camera.ambr b/tests/components/axis/snapshots/test_camera.ambr new file mode 100644 index 00000000000..564ff96b3d8 --- /dev/null +++ b/tests/components/axis/snapshots/test_camera.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_camera[config_entry_options0-][camera.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'axis', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:40:8c:12:34:56-camera', + 'unit_of_measurement': None, + }) +# --- +# name: test_camera[config_entry_options0-][camera.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/camera_proxy/camera.home?token=1', + 'friendly_name': 'home', + 'frontend_stream_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_camera[config_entry_options1-streamprofile=profile_1][camera.home-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.home', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'axis', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:40:8c:12:34:56-camera', + 'unit_of_measurement': None, + }) +# --- +# name: test_camera[config_entry_options1-streamprofile=profile_1][camera.home-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1', + 'entity_picture': '/api/camera_proxy/camera.home?token=1', + 'friendly_name': 'home', + 'frontend_stream_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'camera.home', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py index 00fe4391b0c..91e24a8c0c0 100644 --- a/tests/components/axis/test_camera.py +++ b/tests/components/axis/test_camera.py @@ -1,58 +1,31 @@ """Axis camera platform tests.""" +from unittest.mock import patch + import pytest +from syrupy import SnapshotAssertion from homeassistant.components import camera from homeassistant.components.axis.const import CONF_STREAM_PROFILE from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.const import STATE_IDLE +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from .conftest import ConfigEntryFactoryType from .const import MAC, NAME - -@pytest.mark.usefixtures("config_entry_setup") -async def test_camera(hass: HomeAssistant) -> None: - """Test that Axis camera platform is loaded properly.""" - assert len(hass.states.async_entity_ids(CAMERA_DOMAIN)) == 1 - - entity_id = f"{CAMERA_DOMAIN}.{NAME}" - - cam = hass.states.get(entity_id) - assert cam.state == STATE_IDLE - assert cam.name == NAME - - camera_entity = camera._get_camera_from_entity_id(hass, entity_id) - assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi" - assert camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi" - assert ( - await camera_entity.stream_source() - == "rtsp://root:pass@1.2.3.4/axis-media/media.amp?videocodec=h264" - ) +from tests.common import snapshot_platform -@pytest.mark.parametrize("config_entry_options", [{CONF_STREAM_PROFILE: "profile_1"}]) -@pytest.mark.usefixtures("config_entry_setup") -async def test_camera_with_stream_profile(hass: HomeAssistant) -> None: - """Test that Axis camera entity is using the correct path with stream profike.""" - assert len(hass.states.async_entity_ids(CAMERA_DOMAIN)) == 1 - - entity_id = f"{CAMERA_DOMAIN}.{NAME}" - - cam = hass.states.get(entity_id) - assert cam.state == STATE_IDLE - assert cam.name == NAME - - camera_entity = camera._get_camera_from_entity_id(hass, entity_id) - assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi" - assert ( - camera_entity.mjpeg_source - == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi?streamprofile=profile_1" - ) - assert ( - await camera_entity.stream_source() - == "rtsp://root:pass@1.2.3.4/axis-media/media.amp?videocodec=h264&streamprofile=profile_1" - ) +@pytest.fixture(autouse=True) +def mock_getrandbits(): + """Mock camera access token which normally is randomized.""" + with patch( + "homeassistant.components.camera.SystemRandom.getrandbits", + return_value=1, + ): + yield PROPERTY_DATA = f"""root.Properties.API.HTTP.Version=3 @@ -66,6 +39,39 @@ root.Properties.System.SerialNumber={MAC} """ # No image format data to signal camera support +@pytest.mark.parametrize( + ("config_entry_options", "stream_profile"), + [ + ({}, ""), + ({CONF_STREAM_PROFILE: "profile_1"}, "streamprofile=profile_1"), + ], +) +async def test_camera( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry_factory: ConfigEntryFactoryType, + snapshot: SnapshotAssertion, + stream_profile: str, +) -> None: + """Test that Axis camera platform is loaded properly.""" + with patch("homeassistant.components.deconz.PLATFORMS", [Platform.CAMERA]): + config_entry = await config_entry_factory() + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + entity_id = f"{CAMERA_DOMAIN}.{NAME}" + camera_entity = camera._get_camera_from_entity_id(hass, entity_id) + assert camera_entity.image_source == "http://1.2.3.4:80/axis-cgi/jpg/image.cgi" + assert ( + camera_entity.mjpeg_source == "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi" + f"{"" if not stream_profile else f"?{stream_profile}"}" + ) + assert ( + await camera_entity.stream_source() + == "rtsp://root:pass@1.2.3.4/axis-media/media.amp?videocodec=h264" + f"{"" if not stream_profile else f"&{stream_profile}"}" + ) + + @pytest.mark.parametrize("param_properties_payload", [PROPERTY_DATA]) @pytest.mark.usefixtures("config_entry_setup") async def test_camera_disabled(hass: HomeAssistant) -> None: From 6467c8d6111c76fd1fcf8ec4443d3a5c19a76a56 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 30 Aug 2024 08:48:09 -0400 Subject: [PATCH 0067/1309] Bump ZHA to 0.0.32 (#124804) * Always prefer XY color mode in ZHA Remove a few more HS remnants * Use new ZHA OTA format * Bump ZHA to 0.0.32 * Fix existing OTA unit tests * Fix schema conversion test to account for new command parameters * Update snapshot with new `zcl_type` kwarg * Migrate existing entities to icon translations * Remove "no longer compatible" test * Test that the library release summary is correctly exposed to ZHA * Revert "Always prefer XY color mode in ZHA" This reverts commit 8fb7789ea8ddb6ed2a287aed5010374c0452f6c9. * Test `release_notes`, not `release_summary` --- homeassistant/components/zha/icons.json | 39 ++++++ homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/update.py | 11 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../zha/snapshots/test_diagnostics.ambr | 18 +-- tests/components/zha/test_helpers.py | 10 +- tests/components/zha/test_update.py | 121 ++++++++---------- 8 files changed, 117 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/zha/icons.json b/homeassistant/components/zha/icons.json index 65ad029a66d..9d5254fe237 100644 --- a/homeassistant/components/zha/icons.json +++ b/homeassistant/components/zha/icons.json @@ -86,6 +86,18 @@ }, "presence_detection_timeout": { "default": "mdi:timer-edit" + }, + "exercise_trigger_time": { + "default": "mdi:clock" + }, + "external_temperature_sensor": { + "default": "mdi:thermometer" + }, + "load_room_mean": { + "default": "mdi:scale-balance" + }, + "regulation_setpoint_offset": { + "default": "mdi:thermostat" } }, "select": { @@ -94,6 +106,9 @@ }, "keypad_lockout": { "default": "mdi:lock" + }, + "exercise_day_of_week": { + "default": "mdi:wrench-clock" } }, "sensor": { @@ -132,6 +147,15 @@ }, "hooks_state": { "default": "mdi:hook" + }, + "open_window_detected": { + "default": "mdi:window-open" + }, + "load_estimate": { + "default": "mdi:scale-balance" + }, + "preheat_time": { + "default": "mdi:radiator" } }, "switch": { @@ -158,6 +182,21 @@ }, "hooks_locked": { "default": "mdi:lock" + }, + "external_window_sensor": { + "default": "mdi:window-open" + }, + "use_internal_window_detection": { + "default": "mdi:window-open" + }, + "prioritize_external_temperature_sensor": { + "default": "mdi:thermometer" + }, + "heat_available": { + "default": "mdi:water-boiler" + }, + "use_load_balancing": { + "default": "mdi:scale-balance" } } }, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a5e57fcb1ec..df60829a1e2 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.31"], + "requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.32"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index e12d048b190..3a857f9d89b 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -95,6 +95,7 @@ class ZHAFirmwareUpdateEntity( UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS | UpdateEntityFeature.SPECIFIC_VERSION + | UpdateEntityFeature.RELEASE_NOTES ) def __init__(self, entity_data: EntityData, **kwargs: Any) -> None: @@ -143,6 +144,14 @@ class ZHAFirmwareUpdateEntity( """ return self.entity_data.entity.release_summary + async def async_release_notes(self) -> str | None: + """Return full release notes. + + This is suitable for a long changelog that does not fit in the release_summary + property. The returned string can contain markdown. + """ + return self.entity_data.entity.release_notes + @property def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" @@ -155,7 +164,7 @@ class ZHAFirmwareUpdateEntity( ) -> None: """Install an update.""" try: - await self.entity_data.entity.async_install(version=version, backup=backup) + await self.entity_data.entity.async_install(version=version) except ZHAException as exc: raise HomeAssistantError(exc) from exc finally: diff --git a/requirements_all.txt b/requirements_all.txt index 42962c759aa..1bfe1756173 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3012,7 +3012,7 @@ zeroconf==0.133.0 zeversolar==0.3.1 # homeassistant.components.zha -zha==0.0.31 +zha==0.0.32 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ba5ecbea6c..4a63a54d0b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2389,7 +2389,7 @@ zeroconf==0.133.0 zeversolar==0.3.1 # homeassistant.components.zha -zha==0.0.31 +zha==0.0.32 # homeassistant.components.zwave_js zwave-js-server-python==0.57.0 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 67655aebc8c..e0da54e2492 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -162,19 +162,19 @@ '0x0500': dict({ 'attributes': dict({ '0x0000': dict({ - 'attribute': "ZCLAttributeDef(id=0x0000, name='zone_state', type=, access=, mandatory=True, is_manufacturer_specific=False)", + 'attribute': "ZCLAttributeDef(id=0x0000, name='zone_state', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", 'value': None, }), '0x0001': dict({ - 'attribute': "ZCLAttributeDef(id=0x0001, name='zone_type', type=, access=, mandatory=True, is_manufacturer_specific=False)", + 'attribute': "ZCLAttributeDef(id=0x0001, name='zone_type', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", 'value': None, }), '0x0002': dict({ - 'attribute': "ZCLAttributeDef(id=0x0002, name='zone_status', type=, access=, mandatory=True, is_manufacturer_specific=False)", + 'attribute': "ZCLAttributeDef(id=0x0002, name='zone_status', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", 'value': None, }), '0x0010': dict({ - 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, access=, mandatory=True, is_manufacturer_specific=False)", + 'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", 'value': list([ 50, 79, @@ -187,15 +187,15 @@ ]), }), '0x0011': dict({ - 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, access=, mandatory=True, is_manufacturer_specific=False)", + 'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", 'value': None, }), '0x0012': dict({ - 'attribute': "ZCLAttributeDef(id=0x0012, name='num_zone_sensitivity_levels_supported', type=, access=, mandatory=False, is_manufacturer_specific=False)", + 'attribute': "ZCLAttributeDef(id=0x0012, name='num_zone_sensitivity_levels_supported', type=, zcl_type=, access=, mandatory=False, is_manufacturer_specific=False)", 'value': None, }), '0x0013': dict({ - 'attribute': "ZCLAttributeDef(id=0x0013, name='current_zone_sensitivity_level', type=, access=, mandatory=False, is_manufacturer_specific=False)", + 'attribute': "ZCLAttributeDef(id=0x0013, name='current_zone_sensitivity_level', type=, zcl_type=, access=, mandatory=False, is_manufacturer_specific=False)", 'value': None, }), }), @@ -208,11 +208,11 @@ '0x0501': dict({ 'attributes': dict({ '0xfffd': dict({ - 'attribute': "ZCLAttributeDef(id=0xFFFD, name='cluster_revision', type=, access=, mandatory=True, is_manufacturer_specific=False)", + 'attribute': "ZCLAttributeDef(id=0xFFFD, name='cluster_revision', type=, zcl_type=, access=, mandatory=True, is_manufacturer_specific=False)", 'value': None, }), '0xfffe': dict({ - 'attribute': "ZCLAttributeDef(id=0xFFFE, name='reporting_status', type=, access=, mandatory=False, is_manufacturer_specific=False)", + 'attribute': "ZCLAttributeDef(id=0xFFFE, name='reporting_status', type=, zcl_type=, access=, mandatory=False, is_manufacturer_specific=False)", 'value': None, }), }), diff --git a/tests/components/zha/test_helpers.py b/tests/components/zha/test_helpers.py index 13c03c17cf7..d3392685437 100644 --- a/tests/components/zha/test_helpers.py +++ b/tests/components/zha/test_helpers.py @@ -60,16 +60,14 @@ async def test_zcl_schema_conversions(hass: HomeAssistant) -> None: "required": True, }, { - "type": "integer", - "valueMin": 0, - "valueMax": 255, + "type": "multi_select", + "options": ["Execute if off present"], "name": "options_mask", "optional": True, }, { - "type": "integer", - "valueMin": 0, - "valueMax": 255, + "type": "multi_select", + "options": ["Execute if off"], "name": "options_override", "optional": True, }, diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py index 6a1a19b407f..e2a614915f9 100644 --- a/tests/components/zha/test_update.py +++ b/tests/components/zha/test_update.py @@ -3,8 +3,11 @@ from unittest.mock import AsyncMock, call, patch import pytest +from zha.application.platforms.update import ( + FirmwareUpdateEntity as ZhaFirmwareUpdateEntity, +) from zigpy.exceptions import DeliveryError -from zigpy.ota import OtaImageWithMetadata +from zigpy.ota import OtaImagesResult, OtaImageWithMetadata import zigpy.ota.image as firmware from zigpy.ota.providers import BaseOtaImageMetadata from zigpy.profiles import zha @@ -43,6 +46,8 @@ from homeassistant.setup import async_setup_component from .common import find_entity_id, update_attribute_cache from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.typing import WebSocketGenerator + @pytest.fixture(autouse=True) def update_platform_only(): @@ -119,8 +124,11 @@ async def setup_test_data( ), ) - cluster.endpoint.device.application.ota.get_ota_image = AsyncMock( - return_value=None if file_not_found else fw_image + cluster.endpoint.device.application.ota.get_ota_images = AsyncMock( + return_value=OtaImagesResult( + upgrades=() if file_not_found else (fw_image,), + downgrades=(), + ) ) zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee) zha_device_proxy.device.async_update_sw_build_id(installed_fw_version) @@ -544,81 +552,56 @@ async def test_firmware_update_raises( ) -async def test_firmware_update_no_longer_compatible( +async def test_update_release_notes( hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, setup_zha, zigpy_device_mock, ) -> None: - """Test ZHA update platform - firmware update is no longer valid.""" + """Test ZHA update platform release notes.""" await setup_zha() - zha_device, cluster, fw_image, installed_fw_version = await setup_test_data( - hass, zigpy_device_mock + + gateway = get_zha_gateway(hass) + gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass) + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id], + SIG_EP_OUTPUT: [general.Ota.cluster_id], + SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zha.PROFILE_ID, + } + }, + node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00", ) + gateway.get_or_create_device(zigpy_device) + await gateway.async_device_initialized(zigpy_device) + await hass.async_block_till_done(wait_background_tasks=True) + + zha_device: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee) + zha_lib_entity = next( + e + for e in zha_device.device.platform_entities.values() + if isinstance(e, ZhaFirmwareUpdateEntity) + ) + zha_lib_entity._attr_release_notes = "Some lengthy release notes" + zha_lib_entity.maybe_emit_state_changed_event() + await hass.async_block_till_done() + entity_id = find_entity_id(Platform.UPDATE, zha_device, hass) assert entity_id is not None - assert hass.states.get(entity_id).state == STATE_UNKNOWN - - # simulate an image available notification - await cluster._handle_query_next_image( - foundation.ZCLHeader.cluster( - tsn=0x12, command_id=general.Ota.ServerCommandDefs.query_next_image.id - ), - general.QueryNextImageCommand( - fw_image.firmware.header.field_control, - zha_device.device.manufacturer_code, - fw_image.firmware.header.image_type, - installed_fw_version, - fw_image.firmware.header.header_version, - ), + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": entity_id, + } ) - await hass.async_block_till_done() - state = hass.states.get(entity_id) - assert state.state == STATE_ON - attrs = state.attributes - assert attrs[ATTR_INSTALLED_VERSION] == f"0x{installed_fw_version:08x}" - assert not attrs[ATTR_IN_PROGRESS] - assert ( - attrs[ATTR_LATEST_VERSION] == f"0x{fw_image.firmware.header.file_version:08x}" - ) - - new_version = 0x99999999 - - async def endpoint_reply(cluster_id, tsn, data, command_id): - if cluster_id == general.Ota.cluster_id: - hdr, cmd = cluster.deserialize(data) - if isinstance(cmd, general.Ota.ImageNotifyCommand): - zha_device.device.device.packet_received( - make_packet( - zha_device.device.device, - cluster, - general.Ota.ServerCommandDefs.query_next_image.name, - field_control=general.Ota.QueryNextImageCommand.FieldControl.HardwareVersion, - manufacturer_code=fw_image.firmware.header.manufacturer_id, - image_type=fw_image.firmware.header.image_type, - # The device reports that it is no longer compatible! - current_file_version=new_version, - hardware_version=1, - ) - ) - - cluster.endpoint.reply = AsyncMock(side_effect=endpoint_reply) - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - UPDATE_DOMAIN, - SERVICE_INSTALL, - { - ATTR_ENTITY_ID: entity_id, - }, - blocking=True, - ) - - # We updated the currently installed firmware version, as it is no longer valid - state = hass.states.get(entity_id) - assert state.state == STATE_OFF - attrs = state.attributes - assert attrs[ATTR_INSTALLED_VERSION] == f"0x{new_version:08x}" - assert not attrs[ATTR_IN_PROGRESS] - assert attrs[ATTR_LATEST_VERSION] == f"0x{new_version:08x}" + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == "Some lengthy release notes" From d7fb245213a846ec9aafab663cd7d2409cc9b211 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Fri, 30 Aug 2024 22:12:49 +0900 Subject: [PATCH 0068/1309] Add LG ThinQ Integration (#123860) * Add manifest.json * add switch entity * Add tests * fix function's name * adjust the changes after running scipt * Update homeassistant/components/lgthinq/__init__.py Accept the suggested change about format. Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/__init__.py Accept suggested change for log removal Co-authored-by: Franck Nijhof * Delete homeassistant/components/lgthinq/services.yaml * Update homeassistant/components/lgthinq/switch.py Accpet suggested change for log removal Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/strings.json Accept suggested change for service removal Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/manifest.json Accept suggested change for spaces removal Co-authored-by: Franck Nijhof * Delete homeassistant/components/lgthinq/icons.json * Update __init__.py Remove unnecessary check code * Modification to pass ruff-format * Modification for mypy issues * Remove service registry and related code * Update strings.json Modification to pass the prettier issues * Update manifest.json Modification to pass the prettier issues * Update homeassistant/components/lgthinq/__init__.py Remove the unnecessary log. Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/__init__.py Remove unnecessary log. Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/__init__.py Remove unnecessary code. Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/__init__.py Co-authored-by: Franck Nijhof * Modifications for the review and related autocheck * Update homeassistant/components/lgthinq/config_flow.py Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/config_flow.py Co-authored-by: Franck Nijhof * Modifications for reviews and autocheck * Modifications for the reviews and autocheck * Update homeassistant/components/lgthinq/const.py Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/const.py Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/const.py Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/device.py Co-authored-by: Franck Nijhof * Update homeassistant/components/lgthinq/device.py Co-authored-by: Franck Nijhof * Remove type definition after Final * Update const.py Do not use Final for DOMAIN * Refactoring for reviews - remove thinq.py - remove type definition - remove entry name in config flow - put config flow steps into a single step * Update tests - remove region * Refactoring for reviews - move property.py into PyPI library - replace error_code handling with try/catch - remove http response handling - remove generic - remove unnecessary class or map instance - refactor adding entities logic * Refactoring - remove unused code - change import path * Update tests * Refactoring for reviews 1. Use coordinator extended class instead of LGDevice 2. Rename entity_helper.py to entity.py 3. Move entity description to each entity file 4. Remove dynamic device creation code * Refactoring for reviews * Update requirements * Fix for reviews * Modify tests for reviews * Update for reviews * Remove property info and description class * Update tests/components/lgthinq/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/lgthinq/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lgthinq/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lgthinq/switch.py Co-authored-by: Joost Lekkerkerker * Update tests/components/lgthinq/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update for reviews * Update homeassistant/components/lgthinq/switch.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/lgthinq/switch.py Co-authored-by: Joost Lekkerkerker * Update for reviews * Fix ruff issues * Fix ruff check * Fix for reviews * Fix ruff check * Fix for reviews * Fix prettier failure and hassfest failure --------- Co-authored-by: Jangwon Lee Co-authored-by: yunseon.park Co-authored-by: nahyun.lee Co-authored-by: Franck Nijhof Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/lgthinq/__init__.py | 105 +++++++++++++ .../components/lgthinq/config_flow.py | 103 +++++++++++++ homeassistant/components/lgthinq/const.py | 82 ++++++++++ .../components/lgthinq/coordinator.py | 142 ++++++++++++++++++ homeassistant/components/lgthinq/entity.py | 80 ++++++++++ homeassistant/components/lgthinq/icons.json | 9 ++ .../components/lgthinq/manifest.json | 11 ++ homeassistant/components/lgthinq/strings.json | 28 ++++ homeassistant/components/lgthinq/switch.py | 118 +++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/lgthinq/__init__.py | 1 + tests/components/lgthinq/conftest.py | 86 +++++++++++ tests/components/lgthinq/const.py | 8 + tests/components/lgthinq/test_config_flow.py | 66 ++++++++ 18 files changed, 854 insertions(+) create mode 100644 homeassistant/components/lgthinq/__init__.py create mode 100644 homeassistant/components/lgthinq/config_flow.py create mode 100644 homeassistant/components/lgthinq/const.py create mode 100644 homeassistant/components/lgthinq/coordinator.py create mode 100644 homeassistant/components/lgthinq/entity.py create mode 100644 homeassistant/components/lgthinq/icons.json create mode 100644 homeassistant/components/lgthinq/manifest.json create mode 100644 homeassistant/components/lgthinq/strings.json create mode 100644 homeassistant/components/lgthinq/switch.py create mode 100644 tests/components/lgthinq/__init__.py create mode 100644 tests/components/lgthinq/conftest.py create mode 100644 tests/components/lgthinq/const.py create mode 100644 tests/components/lgthinq/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index c31056089de..0ebc49eda50 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -803,6 +803,8 @@ build.json @home-assistant/supervisor /tests/components/lektrico/ @lektrico /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 +/homeassistant/components/lgthinq/ @LG-ThinQ-Integration +/tests/components/lgthinq/ @LG-ThinQ-Integration /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob /homeassistant/components/lifx/ @Djelibeybi diff --git a/homeassistant/components/lgthinq/__init__.py b/homeassistant/components/lgthinq/__init__.py new file mode 100644 index 00000000000..259d494902e --- /dev/null +++ b/homeassistant/components/lgthinq/__init__.py @@ -0,0 +1,105 @@ +"""Support for LG ThinQ Connect device.""" + +from __future__ import annotations + +import asyncio +import logging + +from thinqconnect import ThinQApi, ThinQAPIException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_CONNECT_CLIENT_ID +from .coordinator import DeviceDataUpdateCoordinator, async_setup_device_coordinator + +type ThinqConfigEntry = ConfigEntry[dict[str, DeviceDataUpdateCoordinator]] + +PLATFORMS = [Platform.SWITCH] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ThinqConfigEntry) -> bool: + """Set up an entry.""" + access_token = entry.data[CONF_ACCESS_TOKEN] + client_id = entry.data[CONF_CONNECT_CLIENT_ID] + country_code = entry.data[CONF_COUNTRY] + + thinq_api = ThinQApi( + session=async_get_clientsession(hass), + access_token=access_token, + country_code=country_code, + client_id=client_id, + ) + + # Setup coordinators and register devices. + await async_setup_coordinators(hass, entry, thinq_api) + + # Set up all platforms for this device/entry. + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Clean up devices they are no longer in use. + async_cleanup_device_registry(hass, entry) + + return True + + +async def async_setup_coordinators( + hass: HomeAssistant, + entry: ThinqConfigEntry, + thinq_api: ThinQApi, +) -> None: + """Set up coordinators and register devices.""" + entry.runtime_data = {} + + # Get a device list from the server. + try: + device_list = await thinq_api.async_get_device_list() + except ThinQAPIException as exc: + raise ConfigEntryNotReady(exc.message) from exc + + if not device_list: + return + + # Setup coordinator per device. + coordinator_list: list[DeviceDataUpdateCoordinator] = [] + task_list = [ + hass.async_create_task(async_setup_device_coordinator(hass, thinq_api, device)) + for device in device_list + ] + task_result = await asyncio.gather(*task_list) + for coordinators in task_result: + if coordinators: + coordinator_list += coordinators + + for coordinator in coordinator_list: + entry.runtime_data[coordinator.unique_id] = coordinator + + +@callback +def async_cleanup_device_registry(hass: HomeAssistant, entry: ThinqConfigEntry) -> None: + """Clean up device registry.""" + new_device_unique_ids = [ + coordinator.unique_id for coordinator in entry.runtime_data.values() + ] + device_registry = dr.async_get(hass) + existing_entries = dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + + # Remove devices that are no longer exist. + for old_entry in existing_entries: + old_unique_id = next(iter(old_entry.identifiers))[1] + if old_unique_id not in new_device_unique_ids: + device_registry.async_remove_device(old_entry.id) + _LOGGER.debug("Remove device_registry: device_id=%s", old_entry.id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ThinqConfigEntry) -> bool: + """Unload the entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lgthinq/config_flow.py b/homeassistant/components/lgthinq/config_flow.py new file mode 100644 index 00000000000..cdb41916688 --- /dev/null +++ b/homeassistant/components/lgthinq/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for LG ThinQ.""" + +from __future__ import annotations + +import logging +from typing import Any +import uuid + +from thinqconnect import ThinQApi, ThinQAPIException +from thinqconnect.country import Country +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import CountrySelector, CountrySelectorConfig + +from .const import ( + CLIENT_PREFIX, + CONF_CONNECT_CLIENT_ID, + DEFAULT_COUNTRY, + DOMAIN, + THINQ_DEFAULT_NAME, + THINQ_PAT_URL, +) + +SUPPORTED_COUNTRIES = [country.value for country in Country] + +_LOGGER = logging.getLogger(__name__) + + +class ThinQFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + def _get_default_country_code(self) -> str: + """Get the default country code based on config.""" + country = self.hass.config.country + if country is not None and country in SUPPORTED_COUNTRIES: + return country + + return DEFAULT_COUNTRY + + async def _validate_and_create_entry( + self, access_token: str, country_code: str + ) -> ConfigFlowResult: + """Create an entry for the flow.""" + connect_client_id = f"{CLIENT_PREFIX}-{uuid.uuid4()!s}" + + # To verify PAT, create an api to retrieve the device list. + await ThinQApi( + session=async_get_clientsession(self.hass), + access_token=access_token, + country_code=country_code, + client_id=connect_client_id, + ).async_get_device_list() + + # If verification is success, create entry. + return self.async_create_entry( + title=THINQ_DEFAULT_NAME, + data={ + CONF_ACCESS_TOKEN: access_token, + CONF_CONNECT_CLIENT_ID: connect_client_id, + CONF_COUNTRY: country_code, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + access_token = user_input[CONF_ACCESS_TOKEN] + country_code = user_input[CONF_COUNTRY] + + # Check if PAT is already configured. + await self.async_set_unique_id(access_token) + self._abort_if_unique_id_configured() + + try: + return await self._validate_and_create_entry(access_token, country_code) + except ThinQAPIException: + errors["base"] = "token_unauthorized" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required( + CONF_COUNTRY, default=self._get_default_country_code() + ): CountrySelector( + CountrySelectorConfig(countries=SUPPORTED_COUNTRIES) + ), + } + ), + description_placeholders={"pat_url": THINQ_PAT_URL}, + errors=errors, + ) diff --git a/homeassistant/components/lgthinq/const.py b/homeassistant/components/lgthinq/const.py new file mode 100644 index 00000000000..9b9b162bb06 --- /dev/null +++ b/homeassistant/components/lgthinq/const.py @@ -0,0 +1,82 @@ +"""Constants for LG ThinQ.""" + +# Base component constants. +from typing import Final + +from thinqconnect import ( + AirConditionerDevice, + AirPurifierDevice, + AirPurifierFanDevice, + CeilingFanDevice, + CooktopDevice, + DehumidifierDevice, + DeviceType, + DishWasherDevice, + DryerDevice, + HomeBrewDevice, + HoodDevice, + HumidifierDevice, + KimchiRefrigeratorDevice, + MicrowaveOvenDevice, + OvenDevice, + PlantCultivatorDevice, + RefrigeratorDevice, + RobotCleanerDevice, + StickCleanerDevice, + StylerDevice, + SystemBoilerDevice, + WashcomboMainDevice, + WashcomboMiniDevice, + WasherDevice, + WashtowerDevice, + WashtowerDryerDevice, + WashtowerWasherDevice, + WaterHeaterDevice, + WaterPurifierDevice, + WineCellarDevice, +) + +# Common +DOMAIN = "lgthinq" +COMPANY = "LGE" +THINQ_DEFAULT_NAME: Final = "LG ThinQ" +THINQ_PAT_URL: Final = "https://connect-pat.lgthinq.com" + +# Config Flow +CLIENT_PREFIX: Final = "home-assistant" +CONF_CONNECT_CLIENT_ID: Final = "connect_client_id" +DEFAULT_COUNTRY: Final = "US" + +THINQ_DEVICE_ADDED: Final = "thinq_device_added" + +DEVICE_TYPE_API_MAP: Final = { + DeviceType.AIR_CONDITIONER: AirConditionerDevice, + DeviceType.AIR_PURIFIER_FAN: AirPurifierFanDevice, + DeviceType.AIR_PURIFIER: AirPurifierDevice, + DeviceType.CEILING_FAN: CeilingFanDevice, + DeviceType.COOKTOP: CooktopDevice, + DeviceType.DEHUMIDIFIER: DehumidifierDevice, + DeviceType.DISH_WASHER: DishWasherDevice, + DeviceType.DRYER: DryerDevice, + DeviceType.HOME_BREW: HomeBrewDevice, + DeviceType.HOOD: HoodDevice, + DeviceType.HUMIDIFIER: HumidifierDevice, + DeviceType.KIMCHI_REFRIGERATOR: KimchiRefrigeratorDevice, + DeviceType.MICROWAVE_OVEN: MicrowaveOvenDevice, + DeviceType.OVEN: OvenDevice, + DeviceType.PLANT_CULTIVATOR: PlantCultivatorDevice, + DeviceType.REFRIGERATOR: RefrigeratorDevice, + DeviceType.ROBOT_CLEANER: RobotCleanerDevice, + DeviceType.STICK_CLEANER: StickCleanerDevice, + DeviceType.STYLER: StylerDevice, + DeviceType.SYSTEM_BOILER: SystemBoilerDevice, + DeviceType.WASHER: WasherDevice, + DeviceType.WASHCOMBO_MAIN: WashcomboMainDevice, + DeviceType.WASHCOMBO_MINI: WashcomboMiniDevice, + DeviceType.WASHTOWER_DRYER: WashtowerDryerDevice, + DeviceType.WASHTOWER: WashtowerDevice, + DeviceType.WASHTOWER_WASHER: WashtowerWasherDevice, + DeviceType.WATER_HEATER: WaterHeaterDevice, + DeviceType.WATER_PURIFIER: WaterPurifierDevice, + DeviceType.WINE_CELLAR: WineCellarDevice, +} diff --git a/homeassistant/components/lgthinq/coordinator.py b/homeassistant/components/lgthinq/coordinator.py new file mode 100644 index 00000000000..1a23b70d8a7 --- /dev/null +++ b/homeassistant/components/lgthinq/coordinator.py @@ -0,0 +1,142 @@ +"""DataUpdateCoordinator for the LG ThinQ device.""" + +from __future__ import annotations + +import logging +from typing import Any + +from thinqconnect import ConnectBaseDevice, DeviceType, ThinQApi, ThinQAPIException + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEVICE_TYPE_API_MAP, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """LG Device's Data Update Coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + device_api: ConnectBaseDevice, + *, + sub_id: str | None = None, + ) -> None: + """Initialize data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{device_api.device_id}", + ) + + # For washTower's washer or dryer + self.sub_id = sub_id + + # The device name is usually set to 'alias'. + # But, if the sub_id exists, it will be set to 'alias {sub_id}'. + # e.g. alias='MyWashTower', sub_id='dryer' then 'MyWashTower dryer'. + self.device_name = ( + f"{device_api.alias} {self.sub_id}" if self.sub_id else device_api.alias + ) + + # The unique id is usually set to 'device_id'. + # But, if the sub_id exists, it will be set to 'device_id_{sub_id}'. + # e.g. device_id='TQSXXXX', sub_id='dryer' then 'TQSXXXX_dryer'. + self.unique_id = ( + f"{device_api.device_id}_{self.sub_id}" + if self.sub_id + else device_api.device_id + ) + + # Get the api instance. + self.device_api = device_api.get_sub_device(self.sub_id) or device_api + + async def _async_update_data(self) -> dict[str, Any]: + """Request to the server to update the status from full response data.""" + try: + data = await self.device_api.thinq_api.async_get_device_status( + self.device_api.device_id + ) + except ThinQAPIException as exc: + raise UpdateFailed(exc) from exc + + # Full response data into the device api. + self.device_api.set_status(data) + return data + + +async def async_setup_device_coordinator( + hass: HomeAssistant, thinq_api: ThinQApi, device: dict[str, Any] +) -> list[DeviceDataUpdateCoordinator] | None: + """Create DeviceDataUpdateCoordinator and device_api per device.""" + device_id = device["deviceId"] + device_info = device["deviceInfo"] + + # Get an appropriate class constructor for the device type. + device_type = device_info.get("deviceType") + constructor = DEVICE_TYPE_API_MAP.get(device_type) + if constructor is None: + _LOGGER.error( + "Failed to setup device(%s): not supported device. type=%s", + device_id, + device_type, + ) + return None + + # Get a device profile from the server. + try: + profile = await thinq_api.async_get_device_profile(device_id) + except ThinQAPIException: + _LOGGER.warning("Failed to setup device(%s): no profile", device_id) + return None + + device_group_id = device_info.get("groupId") + + # Create new device api instance. + device_api: ConnectBaseDevice = ( + constructor( + thinq_api=thinq_api, + device_id=device_id, + device_type=device_type, + model_name=device_info.get("modelName"), + alias=device_info.get("alias"), + group_id=device_group_id, + reportable=device_info.get("reportable"), + profile=profile, + ) + if device_group_id + else constructor( + thinq_api=thinq_api, + device_id=device_id, + device_type=device_type, + model_name=device_info.get("modelName"), + alias=device_info.get("alias"), + reportable=device_info.get("reportable"), + profile=profile, + ) + ) + + # Create a list of sub-devices from the profile. + # Note that some devices may have more than two device profiles. + # In this case we should create multiple lg device instance. + # e.g. 'WashTower-Single-Unit' = 'WashTower{dryer}' + 'WashTower{washer}'. + device_sub_ids = ( + list(profile.keys()) + if device_type == DeviceType.WASHTOWER and "property" not in profile + else [None] + ) + + # Create new device coordinator instances. + coordinator_list: list[DeviceDataUpdateCoordinator] = [] + for sub_id in device_sub_ids: + coordinator = DeviceDataUpdateCoordinator(hass, device_api, sub_id=sub_id) + await coordinator.async_config_entry_first_refresh() + + # Finally add a device coordinator into the result list. + coordinator_list.append(coordinator) + _LOGGER.debug("Setup device's coordinator: %s", coordinator) + + return coordinator_list diff --git a/homeassistant/components/lgthinq/entity.py b/homeassistant/components/lgthinq/entity.py new file mode 100644 index 00000000000..151687aabb8 --- /dev/null +++ b/homeassistant/components/lgthinq/entity.py @@ -0,0 +1,80 @@ +"""Base class for ThinQ entities.""" + +from __future__ import annotations + +import logging +from typing import Any + +from thinqconnect import ThinQAPIException +from thinqconnect.integration.homeassistant.property import Property as ThinQProperty + +from homeassistant.core import callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import COMPANY, DOMAIN +from .coordinator import DeviceDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): + """The base implementation of all lg thinq entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DeviceDataUpdateCoordinator, + entity_description: EntityDescription, + property: ThinQProperty, + ) -> None: + """Initialize an entity.""" + super().__init__(coordinator) + + self.entity_description = entity_description + self.property = property + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, coordinator.unique_id)}, + manufacturer=COMPANY, + model=coordinator.device_api.model_name, + name=coordinator.device_name, + ) + + # Set the unique key. If there exist a location, add the prefix location name. + unique_key = ( + f"{entity_description.key}" + if property.location is None + else f"{property.location}_{entity_description.key}" + ) + self._attr_unique_id = f"{coordinator.unique_id}_{unique_key}" + + # Update initial status. + self._update_status() + + async def async_post_value(self, value: Any) -> None: + """Post the value of entity to server.""" + try: + await self.property.async_post_value(value) + except ThinQAPIException as exc: + raise ServiceValidationError( + exc.message, + translation_domain=DOMAIN, + translation_key=exc.code, + ) from exc + finally: + await self.coordinator.async_request_refresh() + + def _update_status(self) -> None: + """Update status itself. + + All inherited classes can update their own status in here. + """ + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_status() + self.async_write_ha_state() diff --git a/homeassistant/components/lgthinq/icons.json b/homeassistant/components/lgthinq/icons.json new file mode 100644 index 00000000000..6a4ff48494a --- /dev/null +++ b/homeassistant/components/lgthinq/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "switch": { + "operation_power": { + "default": "mdi:power" + } + } + } +} diff --git a/homeassistant/components/lgthinq/manifest.json b/homeassistant/components/lgthinq/manifest.json new file mode 100644 index 00000000000..641c78844f9 --- /dev/null +++ b/homeassistant/components/lgthinq/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "lgthinq", + "name": "LG ThinQ", + "codeowners": ["@LG-ThinQ-Integration"], + "config_flow": true, + "dependencies": [], + "documentation": "https://www.home-assistant.io/integrations/lgthinq/", + "iot_class": "cloud_push", + "loggers": ["thinqconnect"], + "requirements": ["thinqconnect==0.9.5"] +} diff --git a/homeassistant/components/lgthinq/strings.json b/homeassistant/components/lgthinq/strings.json new file mode 100644 index 00000000000..6334fd9a893 --- /dev/null +++ b/homeassistant/components/lgthinq/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "token_unauthorized": "The token is invalid or unauthorized." + }, + "step": { + "user": { + "title": "Connect to ThinQ", + "description": "Please enter a ThinQ [PAT(Personal Access Token)]({pat_url}) created with your LG ThinQ account.", + "data": { + "access_token": "Personal Access Token", + "country": "Country" + } + } + } + }, + "entity": { + "switch": { + "operation_power": { + "name": "Power" + } + } + } +} diff --git a/homeassistant/components/lgthinq/switch.py b/homeassistant/components/lgthinq/switch.py new file mode 100644 index 00000000000..ee7dfdb02d7 --- /dev/null +++ b/homeassistant/components/lgthinq/switch.py @@ -0,0 +1,118 @@ +"""Support for switch entities.""" + +from __future__ import annotations + +import logging +from typing import Any + +from thinqconnect import PROPERTY_WRITABLE, DeviceType +from thinqconnect.devices.const import Property as ThinQProperty +from thinqconnect.integration.homeassistant.property import create_properties + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ThinqConfigEntry +from .entity import ThinQEntity + +OPERATION_SWITCH_DESC: dict[ThinQProperty, SwitchEntityDescription] = { + ThinQProperty.AIR_FAN_OPERATION_MODE: SwitchEntityDescription( + key=ThinQProperty.AIR_FAN_OPERATION_MODE, + translation_key="operation_power", + ), + ThinQProperty.AIR_PURIFIER_OPERATION_MODE: SwitchEntityDescription( + key=ThinQProperty.AIR_PURIFIER_OPERATION_MODE, + translation_key="operation_power", + ), + ThinQProperty.BOILER_OPERATION_MODE: SwitchEntityDescription( + key=ThinQProperty.BOILER_OPERATION_MODE, + translation_key="operation_power", + ), + ThinQProperty.DEHUMIDIFIER_OPERATION_MODE: SwitchEntityDescription( + key=ThinQProperty.DEHUMIDIFIER_OPERATION_MODE, + translation_key="operation_power", + ), + ThinQProperty.HUMIDIFIER_OPERATION_MODE: SwitchEntityDescription( + key=ThinQProperty.HUMIDIFIER_OPERATION_MODE, + translation_key="operation_power", + ), +} + +DEVIE_TYPE_SWITCH_MAP: dict[DeviceType, tuple[SwitchEntityDescription, ...]] = { + DeviceType.AIR_PURIFIER_FAN: ( + OPERATION_SWITCH_DESC[ThinQProperty.AIR_FAN_OPERATION_MODE], + ), + DeviceType.AIR_PURIFIER: ( + OPERATION_SWITCH_DESC[ThinQProperty.AIR_PURIFIER_OPERATION_MODE], + ), + DeviceType.DEHUMIDIFIER: ( + OPERATION_SWITCH_DESC[ThinQProperty.DEHUMIDIFIER_OPERATION_MODE], + ), + DeviceType.HUMIDIFIER: ( + OPERATION_SWITCH_DESC[ThinQProperty.HUMIDIFIER_OPERATION_MODE], + ), + DeviceType.SYSTEM_BOILER: ( + OPERATION_SWITCH_DESC[ThinQProperty.BOILER_OPERATION_MODE], + ), +} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThinqConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up an entry for switch platform.""" + entities: list[ThinQSwitchEntity] = [] + for coordinator in entry.runtime_data.values(): + if ( + descriptions := DEVIE_TYPE_SWITCH_MAP.get( + coordinator.device_api.device_type + ) + ) is not None: + for description in descriptions: + properties = create_properties( + device_api=coordinator.device_api, + key=description.key, + children_keys=None, + rw_type=PROPERTY_WRITABLE, + ) + if not properties: + continue + + entities.extend( + ThinQSwitchEntity(coordinator, description, prop) + for prop in properties + ) + + if entities: + async_add_entities(entities) + + +class ThinQSwitchEntity(ThinQEntity, SwitchEntity): + """Represent a thinq switch platform.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + def _update_status(self) -> None: + """Update status itself.""" + super()._update_status() + + self._attr_is_on = self.property.get_value_as_bool() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + _LOGGER.debug("[%s] async_turn_on", self.name) + await self.async_post_value("POWER_ON") + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + _LOGGER.debug("[%s] async_turn_off", self.name) + await self.async_post_value("POWER_OFF") diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0ca3335725f..1756a896d25 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -318,6 +318,7 @@ FLOWS = { "lektrico", "lg_netcast", "lg_soundbar", + "lgthinq", "lidarr", "lifx", "linear_garage_door", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2e9199a3b0a..d7cfe503dd9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3246,6 +3246,12 @@ } } }, + "lgthinq": { + "name": "LG ThinQ", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "lidarr": { "name": "Lidarr", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 1bfe1756173..8f89d72d9a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2785,6 +2785,9 @@ thermoworks-smoke==0.1.8 # homeassistant.components.thingspeak thingspeak==1.0.0 +# homeassistant.components.lgthinq +thinqconnect==0.9.5 + # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a63a54d0b8..a1862a1340d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2198,6 +2198,9 @@ thermobeacon-ble==0.7.0 # homeassistant.components.thermopro thermopro-ble==0.10.0 +# homeassistant.components.lgthinq +thinqconnect==0.9.5 + # homeassistant.components.tilt_ble tilt-ble==0.2.3 diff --git a/tests/components/lgthinq/__init__.py b/tests/components/lgthinq/__init__.py new file mode 100644 index 00000000000..68ffb960f71 --- /dev/null +++ b/tests/components/lgthinq/__init__.py @@ -0,0 +1 @@ +"""Tests for the lgthinq integration.""" diff --git a/tests/components/lgthinq/conftest.py b/tests/components/lgthinq/conftest.py new file mode 100644 index 00000000000..321c770ee8d --- /dev/null +++ b/tests/components/lgthinq/conftest.py @@ -0,0 +1,86 @@ +"""Configure tests for the LGThinQ integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from thinqconnect import ThinQAPIException + +from homeassistant.components.lgthinq.const import CONF_CONNECT_CLIENT_ID, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY + +from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT, MOCK_UUID + +from tests.common import MockConfigEntry + + +def mock_thinq_api_response( + *, + status: int = 200, + body: dict | None = None, + error_code: str | None = None, + error_message: str | None = None, +) -> MagicMock: + """Create a mock thinq api response.""" + response = MagicMock() + response.status = status + response.body = body + response.error_code = error_code + response.error_message = error_message + return response + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=f"Test {DOMAIN}", + unique_id=MOCK_PAT, + data={ + CONF_ACCESS_TOKEN: MOCK_PAT, + CONF_CONNECT_CLIENT_ID: MOCK_CONNECT_CLIENT_ID, + CONF_COUNTRY: MOCK_COUNTRY, + }, + ) + + +@pytest.fixture +def mock_uuid() -> Generator[AsyncMock]: + """Mock a uuid.""" + with ( + patch("uuid.uuid4", autospec=True, return_value=MOCK_UUID) as mock_uuid, + patch( + "homeassistant.components.lgthinq.config_flow.uuid.uuid4", + new=mock_uuid, + ), + ): + yield mock_uuid.return_value + + +@pytest.fixture +def mock_thinq_api() -> Generator[AsyncMock]: + """Mock a thinq api.""" + with ( + patch("thinqconnect.ThinQApi", autospec=True) as mock_api, + patch( + "homeassistant.components.lgthinq.config_flow.ThinQApi", + new=mock_api, + ), + ): + thinq_api = mock_api.return_value + thinq_api.async_get_device_list = AsyncMock( + return_value=mock_thinq_api_response(status=200, body={}) + ) + yield thinq_api + + +@pytest.fixture +def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock: + """Mock an invalid thinq api.""" + mock_thinq_api.async_get_device_list = AsyncMock( + side_effect=ThinQAPIException( + code="1309", message="Not allowed api call", headers=None + ) + ) + return mock_thinq_api diff --git a/tests/components/lgthinq/const.py b/tests/components/lgthinq/const.py new file mode 100644 index 00000000000..f46baa61c38 --- /dev/null +++ b/tests/components/lgthinq/const.py @@ -0,0 +1,8 @@ +"""Constants for lgthinq test.""" + +from typing import Final + +MOCK_PAT: Final[str] = "123abc4567de8f90g123h4ij56klmn789012p345rst6uvw789xy" +MOCK_UUID: Final[str] = "1b3deabc-123d-456d-987d-2a1c7b3bdb67" +MOCK_CONNECT_CLIENT_ID: Final[str] = f"home-assistant-{MOCK_UUID}" +MOCK_COUNTRY: Final[str] = "KR" diff --git a/tests/components/lgthinq/test_config_flow.py b/tests/components/lgthinq/test_config_flow.py new file mode 100644 index 00000000000..457549ccb7e --- /dev/null +++ b/tests/components/lgthinq/test_config_flow.py @@ -0,0 +1,66 @@ +"""Test the lgthinq config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.lgthinq.const import CONF_CONNECT_CLIENT_ID, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT + +from tests.common import MockConfigEntry + + +async def test_config_flow( + hass: HomeAssistant, mock_thinq_api: AsyncMock, mock_uuid: AsyncMock +) -> None: + """Test that an thinq entry is normally created.""" + 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"], + user_input={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_ACCESS_TOKEN: MOCK_PAT, + CONF_COUNTRY: MOCK_COUNTRY, + CONF_CONNECT_CLIENT_ID: MOCK_CONNECT_CLIENT_ID, + } + + mock_thinq_api.async_get_device_list.assert_called_once() + + +async def test_config_flow_invalid_pat( + hass: HomeAssistant, mock_invalid_thinq_api: AsyncMock +) -> None: + """Test that an thinq flow should be aborted with an invalid PAT.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "token_unauthorized"} + mock_invalid_thinq_api.async_get_device_list.assert_called_once() + + +async def test_config_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_thinq_api: AsyncMock +) -> None: + """Test that thinq flow should be aborted when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From a8b55a16fd1cc3aa30249581d338836c3c42511f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 30 Aug 2024 16:24:27 +0200 Subject: [PATCH 0069/1309] Add 100% coverage of Reolink host.py (#124577) * Add 100% host test coverage * Add missing test --- homeassistant/components/reolink/host.py | 14 +- tests/components/reolink/test_config_flow.py | 2 + tests/components/reolink/test_host.py | 313 ++++++++++++++++++- 3 files changed, 320 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 310188b720e..0df4918be76 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -437,7 +437,15 @@ class ReolinkHost: self._long_poll_task.cancel() self._long_poll_task = None - await self._api.unsubscribe(sub_type=SubType.long_poll) + try: + await self._api.unsubscribe(sub_type=SubType.long_poll) + except ReolinkError as err: + _LOGGER.error( + "Reolink error while unsubscribing from host %s:%s: %s", + self._api.host, + self._api.port, + err, + ) async def stop(self, event=None) -> None: """Disconnect the API.""" @@ -511,9 +519,7 @@ class ReolinkHost: ) if sub_type == SubType.push: await self.subscribe() - else: - await self._api.subscribe(self._webhook_url, sub_type) - return + return timer = self._api.renewtimer(sub_type) _LOGGER.debug( diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 926baf324bc..2d55f62ec74 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -94,6 +94,8 @@ async def test_config_flow_errors( reolink_connect.is_admin = False reolink_connect.user_level = "guest" + reolink_connect.unsubscribe.side_effect = ReolinkError("Test error") + reolink_connect.logout.side_effect = ReolinkError("Test error") result = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index c4096a4582f..64c3fe5c1b7 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -1,28 +1,43 @@ """Test the Reolink host.""" from asyncio import CancelledError -from unittest.mock import AsyncMock, MagicMock +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError +from freezegun.api import FrozenDateTimeFactory import pytest +from reolink_aio.enums import SubType +from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError -from homeassistant.components.reolink import const +from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL +from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.reolink.host import ( + FIRST_ONVIF_LONG_POLL_TIMEOUT, + FIRST_ONVIF_TIMEOUT, + LONG_POLL_COOLDOWN, + LONG_POLL_ERROR_COOLDOWN, + POLL_INTERVAL_NO_PUSH, +) from homeassistant.components.webhook import async_handle_webhook from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.network import NoURLAvailableError from homeassistant.util.aiohttp import MockRequest from .conftest import TEST_UID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator async def test_webhook_callback( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, reolink_connect: MagicMock, entity_registry: er.EntityRegistry, @@ -32,7 +47,7 @@ async def test_webhook_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - webhook_id = f"{const.DOMAIN}_{TEST_UID.replace(':', '')}_ONVIF" + webhook_id = f"{DOMAIN}_{TEST_UID.replace(':', '')}_ONVIF" signal_all = MagicMock() signal_ch = MagicMock() @@ -46,6 +61,10 @@ async def test_webhook_callback( await client.post(f"/api/webhook/{webhook_id}") signal_all.assert_called_once() + freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + # test webhook callback all channels with failure to read motion_state signal_all.reset_mock() reolink_connect.get_motion_state_all_ch.return_value = False @@ -59,7 +78,9 @@ async def test_webhook_callback( # test webhook callback single channel with error in event callback signal_ch.reset_mock() - reolink_connect.ONVIF_event_callback.side_effect = Exception("Test error") + reolink_connect.ONVIF_event_callback = AsyncMock( + side_effect=Exception("Test error") + ) await client.post(f"/api/webhook/{webhook_id}", data="test_data") signal_ch.assert_not_called() @@ -81,3 +102,285 @@ async def test_webhook_callback( with pytest.raises(CancelledError): await async_handle_webhook(hass, webhook_id, request) signal_all.assert_not_called() + + +async def test_no_mac( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test setup of host with no mac.""" + reolink_connect.mac_address = None + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_subscribe_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test error when subscribing to ONVIF does not block startup.""" + reolink_connect.subscribe.side_effect = ReolinkError("Test Error") + reolink_connect.subscribed.return_value = False + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_subscribe_unsuccesfull( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test that a unsuccessful ONVIF subscription does not block startup.""" + reolink_connect.subscribed.return_value = False + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_initial_ONVIF_not_supported( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test setup when initial ONVIF is not supported.""" + + def test_supported(ch, key): + """Test supported function.""" + if key == "initial_ONVIF_state": + return False + return True + + reolink_connect.supported = test_supported + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_ONVIF_not_supported( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test setup is not blocked when ONVIF API returns NotSupportedError.""" + + def test_supported(ch, key): + """Test supported function.""" + if key == "initial_ONVIF_state": + return False + return True + + reolink_connect.supported = test_supported + reolink_connect.subscribed.return_value = False + reolink_connect.subscribe.side_effect = NotSupportedError("Test error") + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + +async def test_renew( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test renew of the ONVIF subscription.""" + reolink_connect.renewtimer.return_value = 1 + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + reolink_connect.renew.assert_called() + + reolink_connect.renew.side_effect = SubscriptionError("Test error") + + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + reolink_connect.subscribe.assert_called() + + reolink_connect.subscribe.reset_mock() + reolink_connect.subscribe.side_effect = SubscriptionError("Test error") + + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + reolink_connect.subscribe.assert_called() + + +async def test_long_poll_renew_fail( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test ONVIF long polling errors while renewing.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + reolink_connect.subscribe.side_effect = NotSupportedError("Test error") + + freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # ensure long polling continues + reolink_connect.pull_point_request.assert_called() + + +async def test_register_webhook_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test errors while registering the webhook.""" + with patch( + "homeassistant.components.reolink.host.get_url", + side_effect=NoURLAvailableError("Test error"), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) is False + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_long_poll_stop_when_push( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test ONVIF long polling stops when ONVIF push comes in.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + # start ONVIF long polling because ONVIF push did not came in + freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # simulate ONVIF push callback + client = await hass_client_no_auth() + reolink_connect.ONVIF_event_callback.return_value = None + webhook_id = f"{DOMAIN}_{TEST_UID.replace(':', '')}_ONVIF" + await client.post(f"/api/webhook/{webhook_id}") + + freezer.tick(DEVICE_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + reolink_connect.unsubscribe.assert_called_with(sub_type=SubType.long_poll) + + +async def test_long_poll_errors( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test errors during ONVIF long polling.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + reolink_connect.pull_point_request.side_effect = ReolinkError("Test error") + + # start ONVIF long polling because ONVIF push did not came in + freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + reolink_connect.pull_point_request.assert_called_once() + reolink_connect.pull_point_request.side_effect = Exception("Test error") + + freezer.tick(timedelta(seconds=LONG_POLL_ERROR_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=LONG_POLL_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + reolink_connect.unsubscribe.assert_called_with(sub_type=SubType.long_poll) + + +async def test_fast_polling_errors( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test errors during ONVIF fast polling.""" + reolink_connect.get_motion_state_all_ch.side_effect = ReolinkError("Test error") + reolink_connect.pull_point_request.side_effect = ReolinkError("Test error") + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + # start ONVIF long polling because ONVIF push did not came in + freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # start ONVIF fast polling because ONVIF long polling did not came in + freezer.tick(timedelta(seconds=FIRST_ONVIF_LONG_POLL_TIMEOUT)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert reolink_connect.get_motion_state_all_ch.call_count == 1 + + freezer.tick(timedelta(seconds=POLL_INTERVAL_NO_PUSH)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # fast polling continues despite errors + assert reolink_connect.get_motion_state_all_ch.call_count == 2 + + +async def test_diagnostics_event_connection( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test Reolink diagnostics event connection return values.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag["event connection"] == "Fast polling" + + # start ONVIF long polling because ONVIF push did not came in + freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag["event connection"] == "ONVIF long polling" + + # simulate ONVIF push callback + client = await hass_client_no_auth() + reolink_connect.ONVIF_event_callback.return_value = None + webhook_id = f"{DOMAIN}_{TEST_UID.replace(':', '')}_ONVIF" + await client.post(f"/api/webhook/{webhook_id}") + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag["event connection"] == "ONVIF push" From 5e93394ae763516278b7f0be57bdc75409d2b8ff Mon Sep 17 00:00:00 2001 From: TimL Date: Sat, 31 Aug 2024 00:25:30 +1000 Subject: [PATCH 0070/1309] Ensure smilight fixtures select correct platform for tests (#124305) * Fix return type hint for setup_integration * Ensure platform fixture selects tested platform --- tests/components/smlight/conftest.py | 23 ++++++++++++++++++++--- tests/components/smlight/test_sensor.py | 4 ++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 0338bf4b672..93493daf51d 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -1,13 +1,14 @@ """Common fixtures for the SMLIGHT Zigbee tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch from pysmlight.web import Info, Sensors import pytest +from homeassistant.components.smlight import PLATFORMS from homeassistant.components.smlight.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_json_object_fixture @@ -31,6 +32,19 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms, which should be loaded during the test.""" + return PLATFORMS + + +@pytest.fixture(autouse=True) +async def mock_patch_platforms(platforms: list[str]) -> AsyncGenerator[None, None]: + """Fixture to set up platforms for tests.""" + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + yield + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Override async_setup_entry.""" @@ -64,7 +78,10 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: yield api -async def setup_integration(hass: HomeAssistant, mock_config_entry: MockConfigEntry): +async def setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: """Set up the integration.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/smlight/test_sensor.py b/tests/components/smlight/test_sensor.py index 4d16a73a0a7..e1239c99e32 100644 --- a/tests/components/smlight/test_sensor.py +++ b/tests/components/smlight/test_sensor.py @@ -19,9 +19,9 @@ pytestmark = [ @pytest.fixture -def platforms() -> Platform | list[Platform]: +def platforms() -> list[Platform]: """Platforms, which should be loaded during the test.""" - return Platform.SENSOR + return [Platform.SENSOR] @pytest.mark.usefixtures("entity_registry_enabled_by_default") From c01bb44757f312123d5fabac75bb8a7ed826cb2a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 30 Aug 2024 07:27:19 -0700 Subject: [PATCH 0071/1309] Add Google Photos integration (#124835) * Add Google Photos integration * Mark credentials typing * Add code review suggestions to simpilfy google_photos * Update tests/components/google_photos/conftest.py Co-authored-by: Joost Lekkerkerker * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Fix comment typo * Update test fixtures from review feedback * Remove unnecessary test for services * Remove keyword argument --------- Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/google.json | 1 + .../components/google_photos/__init__.py | 45 +++ homeassistant/components/google_photos/api.py | 143 +++++++++ .../google_photos/application_credentials.py | 23 ++ .../components/google_photos/config_flow.py | 54 ++++ .../components/google_photos/const.py | 10 + .../components/google_photos/exceptions.py | 7 + .../components/google_photos/manifest.json | 10 + .../components/google_photos/media_source.py | 283 ++++++++++++++++++ .../components/google_photos/strings.json | 29 ++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/google_photos/__init__.py | 1 + tests/components/google_photos/conftest.py | 121 ++++++++ .../fixtures/api_not_enabled_response.json | 17 ++ .../fixtures/list_mediaitems.json | 35 +++ .../fixtures/list_mediaitems_empty.json | 5 + .../google_photos/fixtures/not_dict.json | 1 + .../google_photos/test_config_flow.py | 205 +++++++++++++ tests/components/google_photos/test_init.py | 109 +++++++ .../google_photos/test_media_source.py | 199 ++++++++++++ 27 files changed, 1321 insertions(+) create mode 100644 homeassistant/components/google_photos/__init__.py create mode 100644 homeassistant/components/google_photos/api.py create mode 100644 homeassistant/components/google_photos/application_credentials.py create mode 100644 homeassistant/components/google_photos/config_flow.py create mode 100644 homeassistant/components/google_photos/const.py create mode 100644 homeassistant/components/google_photos/exceptions.py create mode 100644 homeassistant/components/google_photos/manifest.json create mode 100644 homeassistant/components/google_photos/media_source.py create mode 100644 homeassistant/components/google_photos/strings.json create mode 100644 tests/components/google_photos/__init__.py create mode 100644 tests/components/google_photos/conftest.py create mode 100644 tests/components/google_photos/fixtures/api_not_enabled_response.json create mode 100644 tests/components/google_photos/fixtures/list_mediaitems.json create mode 100644 tests/components/google_photos/fixtures/list_mediaitems_empty.json create mode 100644 tests/components/google_photos/fixtures/not_dict.json create mode 100644 tests/components/google_photos/test_config_flow.py create mode 100644 tests/components/google_photos/test_init.py create mode 100644 tests/components/google_photos/test_media_source.py diff --git a/.strict-typing b/.strict-typing index a65ccf3ec88..d77c12293c4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -209,6 +209,7 @@ homeassistant.components.glances.* homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_assistant_sdk.* +homeassistant.components.google_photos.* homeassistant.components.google_sheets.* homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* diff --git a/CODEOWNERS b/CODEOWNERS index 0ebc49eda50..8ae6aa367b5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -554,6 +554,8 @@ build.json @home-assistant/supervisor /tests/components/google_generative_ai_conversation/ @tronikos /homeassistant/components/google_mail/ @tkdrob /tests/components/google_mail/ @tkdrob +/homeassistant/components/google_photos/ @allenporter +/tests/components/google_photos/ @allenporter /homeassistant/components/google_sheets/ @tkdrob /tests/components/google_sheets/ @tkdrob /homeassistant/components/google_tasks/ @allenporter diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 7c6ebc044e9..460c92076d8 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -9,6 +9,7 @@ "google_generative_ai_conversation", "google_mail", "google_maps", + "google_photos", "google_pubsub", "google_sheets", "google_tasks", diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py new file mode 100644 index 00000000000..ab1ee4a63a4 --- /dev/null +++ b/homeassistant/components/google_photos/__init__.py @@ -0,0 +1,45 @@ +"""The Google Photos integration.""" + +from __future__ import annotations + +from aiohttp import ClientError, ClientResponseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN + +type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] + +__all__ = [ + "DOMAIN", +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: GooglePhotosConfigEntry +) -> bool: + """Set up Google Photos from a config entry.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = api.AsyncConfigEntryAuth(hass, session) + try: + await auth.async_get_access_token() + except (ClientResponseError, ClientError) as err: + raise ConfigEntryNotReady from err + entry.runtime_data = auth + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: GooglePhotosConfigEntry +) -> bool: + """Unload a config entry.""" + return True diff --git a/homeassistant/components/google_photos/api.py b/homeassistant/components/google_photos/api.py new file mode 100644 index 00000000000..2fa6ee2d8f6 --- /dev/null +++ b/homeassistant/components/google_photos/api.py @@ -0,0 +1,143 @@ +"""API for Google Photos bound to Home Assistant OAuth.""" + +from abc import ABC, abstractmethod +from functools import partial +import logging +from typing import Any, cast + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import Resource, build +from googleapiclient.errors import HttpError +from googleapiclient.http import BatchHttpRequest, HttpRequest + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +from .exceptions import GooglePhotosApiError + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PAGE_SIZE = 20 + +# Only included necessary fields to limit response sizes +GET_MEDIA_ITEM_FIELDS = ( + "id,baseUrl,mimeType,filename,mediaMetadata(width,height,photo,video)" +) +LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})" + + +class AuthBase(ABC): + """Base class for Google Photos authentication library. + + Provides an asyncio interface around the blocking client library. + """ + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize Google Photos auth.""" + self._hass = hass + + @abstractmethod + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + + async def get_user_info(self) -> dict[str, Any]: + """Get the user profile info.""" + service = await self._get_profile_service() + cmd: HttpRequest = service.userinfo().get() + return await self._execute(cmd) + + async def get_media_item(self, media_item_id: str) -> dict[str, Any]: + """Get all MediaItem resources.""" + service = await self._get_photos_service() + cmd: HttpRequest = service.mediaItems().get( + media_item_id, fields=GET_MEDIA_ITEM_FIELDS + ) + return await self._execute(cmd) + + async def list_media_items( + self, page_size: int | None = None, page_token: str | None = None + ) -> dict[str, Any]: + """Get all MediaItem resources.""" + service = await self._get_photos_service() + cmd: HttpRequest = service.mediaItems().list( + pageSize=(page_size or DEFAULT_PAGE_SIZE), + pageToken=page_token, + fields=LIST_MEDIA_ITEM_FIELDS, + ) + return await self._execute(cmd) + + async def _get_photos_service(self) -> Resource: + """Get current photos library API resource.""" + token = await self.async_get_access_token() + return await self._hass.async_add_executor_job( + partial( + build, + "photoslibrary", + "v1", + credentials=Credentials(token=token), # type: ignore[no-untyped-call] + static_discovery=False, + ) + ) + + async def _get_profile_service(self) -> Resource: + """Get current profile service API resource.""" + token = await self.async_get_access_token() + return await self._hass.async_add_executor_job( + partial(build, "oauth2", "v2", credentials=Credentials(token=token)) # type: ignore[no-untyped-call] + ) + + async def _execute(self, request: HttpRequest | BatchHttpRequest) -> dict[str, Any]: + try: + result = await self._hass.async_add_executor_job(request.execute) + except HttpError as err: + raise GooglePhotosApiError( + f"Google Photos API responded with error ({err.status_code}): {err.reason}" + ) from err + if not isinstance(result, dict): + raise GooglePhotosApiError( + f"Google Photos API replied with unexpected response: {result}" + ) + if error := result.get("error"): + message = error.get("message", "Unknown Error") + raise GooglePhotosApiError(f"Google Photos API response: {message}") + return cast(dict[str, Any], result) + + +class AsyncConfigEntryAuth(AuthBase): + """Provide Google Photos authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize AsyncConfigEntryAuth.""" + super().__init__(hass) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) + + +class AsyncConfigFlowAuth(AuthBase): + """An API client used during the config flow with a fixed token.""" + + def __init__( + self, + hass: HomeAssistant, + token: str, + ) -> None: + """Initialize ConfigFlowAuth.""" + super().__init__(hass) + self._token = token + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + return self._token diff --git a/homeassistant/components/google_photos/application_credentials.py b/homeassistant/components/google_photos/application_credentials.py new file mode 100644 index 00000000000..fc6cdbd272d --- /dev/null +++ b/homeassistant/components/google_photos/application_credentials.py @@ -0,0 +1,23 @@ +"""application_credentials platform the Google Photos integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_photos/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_photos/config_flow.py b/homeassistant/components/google_photos/config_flow.py new file mode 100644 index 00000000000..9bc4b35b6b4 --- /dev/null +++ b/homeassistant/components/google_photos/config_flow.py @@ -0,0 +1,54 @@ +"""Config flow for Google Photos.""" + +import logging +from typing import Any + +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN, OAUTH2_SCOPES +from .exceptions import GooglePhotosApiError + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Photos OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow.""" + client = api.AsyncConfigFlowAuth(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + try: + user_resource_info = await client.get_user_info() + await client.list_media_items() + except GooglePhotosApiError as ex: + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": str(ex)}, + ) + except Exception: + self.logger.exception("Unknown error occurred") + return self.async_abort(reason="unknown") + user_id = user_resource_info["id"] + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_resource_info["name"], data=data) diff --git a/homeassistant/components/google_photos/const.py b/homeassistant/components/google_photos/const.py new file mode 100644 index 00000000000..7752f817608 --- /dev/null +++ b/homeassistant/components/google_photos/const.py @@ -0,0 +1,10 @@ +"""Constants for the Google Photos integration.""" + +DOMAIN = "google_photos" + +OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" +OAUTH2_SCOPES = [ + "https://www.googleapis.com/auth/photoslibrary.readonly", + "https://www.googleapis.com/auth/userinfo.profile", +] diff --git a/homeassistant/components/google_photos/exceptions.py b/homeassistant/components/google_photos/exceptions.py new file mode 100644 index 00000000000..b1a40688677 --- /dev/null +++ b/homeassistant/components/google_photos/exceptions.py @@ -0,0 +1,7 @@ +"""Exceptions for Google Photos api calls.""" + +from homeassistant.exceptions import HomeAssistantError + + +class GooglePhotosApiError(HomeAssistantError): + """Error talking to the Google Photos API.""" diff --git a/homeassistant/components/google_photos/manifest.json b/homeassistant/components/google_photos/manifest.json new file mode 100644 index 00000000000..3299b437d29 --- /dev/null +++ b/homeassistant/components/google_photos/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "google_photos", + "name": "Google Photos", + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_photos", + "iot_class": "cloud_polling", + "requirements": ["google-api-python-client==2.71.0"] +} diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py new file mode 100644 index 00000000000..e6011cb0e61 --- /dev/null +++ b/homeassistant/components/google_photos/media_source.py @@ -0,0 +1,283 @@ +"""Media source for Google Photos.""" + +from dataclasses import dataclass +import logging +from typing import Any, cast + +from homeassistant.components.media_player import MediaClass, MediaType +from homeassistant.components.media_source import ( + BrowseError, + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.core import HomeAssistant + +from . import GooglePhotosConfigEntry +from .const import DOMAIN +from .exceptions import GooglePhotosApiError + +_LOGGER = logging.getLogger(__name__) + +# Media Sources do not support paging, so we only show a subset of recent +# photos when displaying the users library. We fetch a minimum of 50 photos +# unless we run out, but in pages of 100 at a time given sometimes responses +# may only contain a handful of items Fetches at least 50 photos. +MAX_PHOTOS = 50 +PAGE_SIZE = 100 + +THUMBNAIL_SIZE = 256 +LARGE_IMAGE_SIZE = 2048 + + +# Markers for parts of PhotosIdentifier url pattern. +# The PhotosIdentifier can be in the following forms: +# config-entry-id +# config-entry-id/a/album-media-id +# config-entry-id/p/photo-media-id +# +# The album-media-id can contain special reserved folder names for use by +# this integration for virtual folders like the `recent` album. +PHOTO_SOURCE_IDENTIFIER_PHOTO = "p" +PHOTO_SOURCE_IDENTIFIER_ALBUM = "a" + +# Currently supports a single album of recent photos +RECENT_PHOTOS_ALBUM = "recent" +RECENT_PHOTOS_TITLE = "Recent Photos" + + +@dataclass +class PhotosIdentifier: + """Google Photos item identifier in a media source URL.""" + + config_entry_id: str + """Identifies the account for the media item.""" + + album_media_id: str | None = None + """Identifies the album contents to show. + + Not present at the same time as `photo_media_id`. + """ + + photo_media_id: str | None = None + """Identifies an indiviidual photo or video. + + Not present at the same time as `album_media_id`. + """ + + def as_string(self) -> str: + """Serialize the identiifer as a string. + + This is the opposite if parse_identifier(). + """ + if self.photo_media_id is None: + if self.album_media_id is None: + return self.config_entry_id + return f"{self.config_entry_id}/{PHOTO_SOURCE_IDENTIFIER_ALBUM}/{self.album_media_id}" + return f"{self.config_entry_id}/{PHOTO_SOURCE_IDENTIFIER_PHOTO}/{self.photo_media_id}" + + +def parse_identifier(identifier: str) -> PhotosIdentifier: + """Parse a PhotosIdentifier form a string. + + This is the opposite of as_string(). + """ + parts = identifier.split("/") + if len(parts) == 1: + return PhotosIdentifier(parts[0]) + if len(parts) != 3: + raise BrowseError(f"Invalid identifier: {identifier}") + if parts[1] == PHOTO_SOURCE_IDENTIFIER_PHOTO: + return PhotosIdentifier(parts[0], photo_media_id=parts[2]) + return PhotosIdentifier(parts[0], album_media_id=parts[2]) + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Synology media source.""" + return GooglePhotosMediaSource(hass) + + +class GooglePhotosMediaSource(MediaSource): + """Provide Google Photos as media sources.""" + + name = "Google Photos" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize Google Photos source.""" + super().__init__(DOMAIN) + self.hass = hass + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media identifier to a url. + + This will resolve a specific media item to a url for the full photo or video contents. + """ + identifier = parse_identifier(item.identifier) + if identifier.photo_media_id is None: + raise BrowseError( + f"Could not resolve identifier without a photo_media_id: {identifier}" + ) + entry = self._async_config_entry(identifier.config_entry_id) + client = entry.runtime_data + media_item = await client.get_media_item( + media_item_id=identifier.photo_media_id + ) + is_video = media_item["mediaMetadata"].get("video") is not None + return PlayMedia( + url=( + _video_url(media_item) + if is_video + else _media_url(media_item, LARGE_IMAGE_SIZE) + ), + mime_type=media_item["mimeType"], + ) + + async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: + """Return details about the media source. + + This renders the multi-level album structure for an account, its albums, + or the contents of an album. This will return a BrowseMediaSource with a + single level of children at the next level of the hierarchy. + """ + if not item.identifier: + # Top level view that lists all accounts. + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="Google Photos", + can_play=False, + can_expand=True, + children_media_class=MediaClass.DIRECTORY, + children=[ + _build_account(entry, PhotosIdentifier(cast(str, entry.unique_id))) + for entry in self.hass.config_entries.async_loaded_entries(DOMAIN) + ], + ) + + # Determine the configuration entry for this item + identifier = parse_identifier(item.identifier) + entry = self._async_config_entry(identifier.config_entry_id) + client = entry.runtime_data + + if identifier.album_media_id is None: + source = _build_account(entry, identifier) + source.children = [ + _build_album( + RECENT_PHOTOS_TITLE, + PhotosIdentifier( + identifier.config_entry_id, album_media_id=RECENT_PHOTOS_ALBUM + ), + ) + ] + return source + + # Currently only supports listing a single album of recent photos. + if identifier.album_media_id != RECENT_PHOTOS_ALBUM: + raise BrowseError(f"Unsupported album: {identifier}") + + # Fetch recent items + media_items: list[dict[str, Any]] = [] + page_token: str | None = None + while len(media_items) < MAX_PHOTOS: + try: + result = await client.list_media_items( + page_size=PAGE_SIZE, page_token=page_token + ) + except GooglePhotosApiError as err: + raise BrowseError(f"Error listing media items: {err}") from err + media_items.extend(result["mediaItems"]) + page_token = result.get("nextPageToken") + if page_token is None: + break + + # Render the grid of media item results + source = _build_account(entry, PhotosIdentifier(cast(str, entry.unique_id))) + source.children = [ + _build_media_item( + PhotosIdentifier( + identifier.config_entry_id, photo_media_id=media_item["id"] + ), + media_item, + ) + for media_item in media_items + ] + return source + + def _async_config_entry(self, config_entry_id: str) -> GooglePhotosConfigEntry: + """Return a config entry with the specified id.""" + entry = self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, config_entry_id + ) + if not entry: + raise BrowseError( + f"Could not find config entry for identifier: {config_entry_id}" + ) + return entry + + +def _build_account( + config_entry: GooglePhotosConfigEntry, + identifier: PhotosIdentifier, +) -> BrowseMediaSource: + """Build the root node for a Google Photos account for a config entry.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier=identifier.as_string(), + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title=config_entry.title, + can_play=False, + can_expand=True, + ) + + +def _build_album(title: str, identifier: PhotosIdentifier) -> BrowseMediaSource: + """Build an album node.""" + return BrowseMediaSource( + domain=DOMAIN, + identifier=identifier.as_string(), + media_class=MediaClass.ALBUM, + media_content_type=MediaClass.ALBUM, + title=title, + can_play=False, + can_expand=True, + ) + + +def _build_media_item( + identifier: PhotosIdentifier, media_item: dict[str, Any] +) -> BrowseMediaSource: + """Build the node for an individual photos or video.""" + is_video = media_item["mediaMetadata"].get("video") is not None + return BrowseMediaSource( + domain=DOMAIN, + identifier=identifier.as_string(), + media_class=MediaClass.IMAGE if not is_video else MediaClass.VIDEO, + media_content_type=MediaType.IMAGE if not is_video else MediaType.VIDEO, + title=media_item["filename"], + can_play=is_video, + can_expand=False, + thumbnail=_media_url(media_item, THUMBNAIL_SIZE), + ) + + +def _media_url(media_item: dict[str, Any], max_size: int) -> str: + """Return a media item url with the specified max thumbnail size on the longest edge. + + See https://developers.google.com/photos/library/guides/access-media-items#base-urls + """ + width = media_item["mediaMetadata"]["width"] + height = media_item["mediaMetadata"]["height"] + key = "h" if height > width else "w" + return f"{media_item["baseUrl"]}={key}{max_size}" + + +def _video_url(media_item: dict[str, Any]) -> str: + """Return a video url for the item. + + See https://developers.google.com/photos/library/guides/access-media-items#base-urls + """ + return f"{media_item["baseUrl"]}=dv" diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json new file mode 100644 index 00000000000..57bce01d9f8 --- /dev/null +++ b/homeassistant/components/google_photos/strings.json @@ -0,0 +1,29 @@ +{ + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Photos. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + }, + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 75fd489bad3..efb6f426d36 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -10,6 +10,7 @@ APPLICATION_CREDENTIALS = [ "google", "google_assistant_sdk", "google_mail", + "google_photos", "google_sheets", "google_tasks", "home_connect", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1756a896d25..d4342d80d41 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -224,6 +224,7 @@ FLOWS = { "google_assistant_sdk", "google_generative_ai_conversation", "google_mail", + "google_photos", "google_sheets", "google_tasks", "google_translate", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d7cfe503dd9..8091d48ca4d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2280,6 +2280,12 @@ "iot_class": "cloud_polling", "name": "Google Maps" }, + "google_photos": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Photos" + }, "google_pubsub": { "integration_type": "hub", "config_flow": false, diff --git a/mypy.ini b/mypy.ini index 102ae5c8aa9..817060ac869 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1846,6 +1846,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_photos.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.google_sheets.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 8f89d72d9a0..18bdde48625 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -979,6 +979,7 @@ goalzero==0.2.2 goodwe==0.3.6 # homeassistant.components.google_mail +# homeassistant.components.google_photos # homeassistant.components.google_tasks google-api-python-client==2.71.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1862a1340d..2a1e3e718eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -829,6 +829,7 @@ goalzero==0.2.2 goodwe==0.3.6 # homeassistant.components.google_mail +# homeassistant.components.google_photos # homeassistant.components.google_tasks google-api-python-client==2.71.0 diff --git a/tests/components/google_photos/__init__.py b/tests/components/google_photos/__init__.py new file mode 100644 index 00000000000..fa345811216 --- /dev/null +++ b/tests/components/google_photos/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Photos integration.""" diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py new file mode 100644 index 00000000000..874e55f0d33 --- /dev/null +++ b/tests/components/google_photos/conftest.py @@ -0,0 +1,121 @@ +"""Test fixtures for Google Photos.""" + +from collections.abc import Awaitable, Callable, Generator +import time +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_photos.const import DOMAIN, OAUTH2_SCOPES +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_json_array_fixture + +USER_IDENTIFIER = "user-identifier-1" +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +FAKE_ACCESS_TOKEN = "some-access-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="token_entry") +def mock_token_entry(expires_at: int) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(OAUTH2_SCOPES), + "token_type": "Bearer", + "expires_at": expires_at, + } + + +@pytest.fixture(name="config_entry") +def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="config-entry-id-123", + data={ + "auth_implementation": DOMAIN, + "token": token_entry, + }, + title="Account Name", + ) + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="fixture_name") +def mock_fixture_name() -> str | None: + """Provide a json fixture file to load for list media item api responses.""" + return None + + +@pytest.fixture(name="setup_api") +def mock_setup_api(fixture_name: str) -> Generator[Mock, None, None]: + """Set up fake Google Photos API responses from fixtures.""" + with patch("homeassistant.components.google_photos.api.build") as mock: + mock.return_value.userinfo.return_value.get.return_value.execute.return_value = { + "id": USER_IDENTIFIER, + "name": "Test Name", + } + + responses = ( + load_json_array_fixture(fixture_name, DOMAIN) if fixture_name else [] + ) + + queue = list(responses) + + def list_media_items(**kwargs: Any) -> Mock: + mock = Mock() + mock.execute.return_value = queue.pop(0) + return mock + + mock.return_value.mediaItems.return_value.list = list_media_items + + # Mock a point lookup by reading contents of the fixture above + def get_media_item(media_item_id: str, **kwargs: Any) -> Mock: + for response in responses: + for media_item in response["mediaItems"]: + if media_item["id"] == media_item_id: + mock = Mock() + mock.execute.return_value = media_item + return mock + return None + + mock.return_value.mediaItems.return_value.get = get_media_item + yield mock + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/google_photos/fixtures/api_not_enabled_response.json b/tests/components/google_photos/fixtures/api_not_enabled_response.json new file mode 100644 index 00000000000..8933fcdc7bd --- /dev/null +++ b/tests/components/google_photos/fixtures/api_not_enabled_response.json @@ -0,0 +1,17 @@ +[ + { + "error": { + "code": 403, + "message": "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "errors": [ + { + "message": "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "domain": "usageLimits", + "reason": "accessNotConfigured", + "extendedHelp": "https://console.developers.google.com" + } + ], + "status": "PERMISSION_DENIED" + } + } +] diff --git a/tests/components/google_photos/fixtures/list_mediaitems.json b/tests/components/google_photos/fixtures/list_mediaitems.json new file mode 100644 index 00000000000..8e470a2fc04 --- /dev/null +++ b/tests/components/google_photos/fixtures/list_mediaitems.json @@ -0,0 +1,35 @@ +[ + { + "mediaItems": [ + { + "id": "id1", + "description": "some-descripton", + "productUrl": "http://example.com/id1", + "baseUrl": "http://img.example.com/id1", + "mimeType": "image/jpeg", + "mediaMetadata": { + "creationTime": "2014-10-02T15:01:23Z", + "width": 1600, + "height": 768 + }, + "filename": "example1.jpg" + }, + { + "id": "id2", + "description": "some-descripton", + "productUrl": "http://example.com/id2", + "baseUrl": "http://img.example.com/id2", + "mimeType": "video/mp4", + "mediaMetadata": { + "creationTime": "2014-10-02T16:01:23Z", + "width": 1600, + "height": 768, + "video": { + "cameraMake": "Pixel" + } + }, + "filename": "example2.mp4" + } + ] + } +] diff --git a/tests/components/google_photos/fixtures/list_mediaitems_empty.json b/tests/components/google_photos/fixtures/list_mediaitems_empty.json new file mode 100644 index 00000000000..bf6a4da855f --- /dev/null +++ b/tests/components/google_photos/fixtures/list_mediaitems_empty.json @@ -0,0 +1,5 @@ +[ + { + "mediaItems": [] + } +] diff --git a/tests/components/google_photos/fixtures/not_dict.json b/tests/components/google_photos/fixtures/not_dict.json new file mode 100644 index 00000000000..05e325337d2 --- /dev/null +++ b/tests/components/google_photos/fixtures/not_dict.json @@ -0,0 +1 @@ +["not a dictionary"] diff --git a/tests/components/google_photos/test_config_flow.py b/tests/components/google_photos/test_config_flow.py new file mode 100644 index 00000000000..e9f2a68f2f5 --- /dev/null +++ b/tests/components/google_photos/test_config_flow.py @@ -0,0 +1,205 @@ +"""Test the Google Photos config flow.""" + +from unittest.mock import Mock, patch + +from googleapiclient.errors import HttpError +from httplib2 import Response +import pytest + +from homeassistant import config_entries +from homeassistant.components.google_photos.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import USER_IDENTIFIER + +from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.mark.usefixtures("current_request_with_host", "setup_api") +@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/userinfo.profile" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_photos.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + config_entry = result["result"] + assert config_entry.unique_id == USER_IDENTIFIER + assert config_entry.title == "Test Name" + config_entry_data = dict(config_entry.data) + assert "token" in config_entry_data + assert "expires_at" in config_entry_data["token"] + del config_entry_data["token"]["expires_at"] + assert config_entry_data == { + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "expires_in": 60, + "refresh_token": "mock-refresh-token", + "type": "Bearer", + }, + } + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.usefixtures( + "current_request_with_host", + "setup_credentials", +) +async def test_api_not_enabled( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_api: Mock, +) -> None: + """Check flow aborts if api is not enabled.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/userinfo.profile" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + setup_api.return_value.mediaItems.return_value.list = Mock() + setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = HttpError( + Response({"status": "403"}), + bytes(load_fixture("google_photos/api_not_enabled_response.json"), "utf-8"), + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "access_not_configured" + assert result["description_placeholders"]["message"].endswith( + "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + ) + + +@pytest.mark.usefixtures("current_request_with_host", "setup_credentials") +async def test_general_exception( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check flow aborts if exception happens.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/userinfo.profile" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_photos.api.build", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" diff --git a/tests/components/google_photos/test_init.py b/tests/components/google_photos/test_init.py new file mode 100644 index 00000000000..a2f835c8611 --- /dev/null +++ b/tests/components/google_photos/test_init.py @@ -0,0 +1,109 @@ +"""Tests for Google Photos.""" + +import http +import time + +from aiohttp import ClientError +import pytest + +from homeassistant.components.google_photos.const import OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.usefixtures("setup_integration") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test successful setup and unload.""" + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.fixture(name="refresh_token_status") +def mock_refresh_token_status() -> http.HTTPStatus: + """Fixture to set a token refresh status.""" + return http.HTTPStatus.OK + + +@pytest.fixture(name="refresh_token_exception") +def mock_refresh_token_exception() -> Exception | None: + """Fixture to set a token refresh status.""" + return None + + +@pytest.fixture(name="refresh_token") +def mock_refresh_token( + aioclient_mock: AiohttpClientMocker, + refresh_token_status: http.HTTPStatus, + refresh_token_exception: Exception | None, +) -> MockConfigEntry: + """Fixture to simulate a token refresh response.""" + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + exc=refresh_token_exception, + status=refresh_token_status, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + +@pytest.mark.usefixtures("refresh_token", "setup_integration") +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test expired token is refreshed.""" + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data["token"]["access_token"] == "updated-access-token" + assert config_entry.data["token"]["expires_in"] == 3600 + + +@pytest.mark.usefixtures("refresh_token", "setup_integration") +@pytest.mark.parametrize( + ("expires_at", "refresh_token_status", "refresh_token_exception", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.NOT_FOUND, + None, + ConfigEntryState.SETUP_RETRY, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + None, + ConfigEntryState.SETUP_RETRY, + ), + ( + time.time() - 3600, + None, + ClientError("Client exception raised"), + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["unauthorized", "internal_server_error", "client_error"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + assert config_entry.state is expected_state diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py new file mode 100644 index 00000000000..31c84f4811c --- /dev/null +++ b/tests/components/google_photos/test_media_source.py @@ -0,0 +1,199 @@ +"""Test the Google Photos media source.""" + +from typing import Any +from unittest.mock import Mock + +from googleapiclient.errors import HttpError +from httplib2 import Response +import pytest + +from homeassistant.components.google_photos.const import DOMAIN +from homeassistant.components.media_source import ( + URI_SCHEME, + BrowseError, + async_browse_media, + async_resolve_media, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +async def setup_components(hass: HomeAssistant) -> None: + """Fixture to initialize the integration.""" + await async_setup_component(hass, "media_source", {}) + + +@pytest.mark.usefixtures("setup_integration") +async def test_no_config_entries( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test a media source with no active config entry.""" + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert browse.can_expand + assert not browse.children + + +@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize( + ("fixture_name", "expected_results", "expected_medias"), + [ + ("list_mediaitems_empty.json", [], []), + ( + "list_mediaitems.json", + [ + ("config-entry-id-123/p/id1", "example1.jpg"), + ("config-entry-id-123/p/id2", "example2.mp4"), + ], + [ + ("http://img.example.com/id1=w2048", "image/jpeg"), + ("http://img.example.com/id2=dv", "video/mp4"), + ], + ), + ], +) +async def test_recent_items( + hass: HomeAssistant, + expected_results: list[tuple[str, str]], + expected_medias: list[tuple[str, str]], +) -> None: + """Test a media source with no eligible camera devices.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert [(child.identifier, child.title) for child in browse.children] == [ + ("config-entry-id-123", "Account Name") + ] + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123") + assert browse.domain == DOMAIN + assert browse.identifier == "config-entry-id-123" + assert browse.title == "Account Name" + assert [(child.identifier, child.title) for child in browse.children] == [ + ("config-entry-id-123/a/recent", "Recent Photos") + ] + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" + ) + assert browse.domain == DOMAIN + assert browse.identifier == "config-entry-id-123" + assert browse.title == "Account Name" + assert [ + (child.identifier, child.title) for child in browse.children + ] == expected_results + + media = [ + await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/{child.identifier}", None + ) + for child in browse.children + ] + assert [ + (play_media.url, play_media.mime_type) for play_media in media + ] == expected_medias + + +@pytest.mark.usefixtures("setup_integration", "setup_api") +async def test_invalid_config_entry(hass: HomeAssistant) -> None: + """Test browsing to a config entry that does not exist.""" + with pytest.raises(BrowseError, match="Could not find config entry"): + await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/invalid-config-entry") + + +@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) +async def test_invalid_album_id(hass: HomeAssistant) -> None: + """Test browsing to an album id that does not exist.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert [(child.identifier, child.title) for child in browse.children] == [ + ("config-entry-id-123", "Account Name") + ] + + with pytest.raises(BrowseError, match="Unsupported album"): + await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/invalid-album-id" + ) + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize( + ("identifier", "expected_error"), + [ + ("invalid-config-entry", "without a photo_media_id"), + ("too/many/slashes/in/path", "Invalid identifier"), + ], +) +async def test_missing_photo_id( + hass: HomeAssistant, identifier: str, expected_error: str +) -> None: + """Test parsing an invalid media identifier.""" + with pytest.raises(BrowseError, match=expected_error): + await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/{identifier}", None) + + +@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize( + "side_effect", + [ + HttpError(Response({"status": "403"}), b""), + ], +) +async def test_list_media_items_failure( + hass: HomeAssistant, + setup_api: Any, + side_effect: HttpError | Response, +) -> None: + """Test browsing to an album id that does not exist.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert [(child.identifier, child.title) for child in browse.children] == [ + ("config-entry-id-123", "Account Name") + ] + + setup_api.return_value.mediaItems.return_value.list = Mock() + setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = side_effect + + with pytest.raises(BrowseError, match="Error listing media items"): + await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" + ) + + +@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize( + "fixture_name", + [ + "api_not_enabled_response.json", + "not_dict.json", + ], +) +async def test_media_items_error_parsing_response(hass: HomeAssistant) -> None: + """Test browsing to an album id that does not exist.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert [(child.identifier, child.title) for child in browse.children] == [ + ("config-entry-id-123", "Account Name") + ] + with pytest.raises(BrowseError, match="Error listing media items"): + await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" + ) From 240bd6c3bf9324920cda0ab8742f680c9a38f4ab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 30 Aug 2024 16:41:48 +0200 Subject: [PATCH 0072/1309] Bump aiomealie to 0.9.0 (#124924) * Bump aiomealie to 0.9.0 * Bump aiomealie to 0.9.0 --- homeassistant/components/mealie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../mealie/snapshots/test_diagnostics.ambr | 29 +++++++++++++++ .../mealie/snapshots/test_services.ambr | 37 +++++++++++++++++++ 5 files changed, 69 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index 75093577b0f..4a277cbd09b 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.8.1"] + "requirements": ["aiomealie==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 18bdde48625..48dbabd47d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -288,7 +288,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.8.1 +aiomealie==0.9.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a1e3e718eb..4c70d7bc4c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -270,7 +270,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.8.1 +aiomealie==0.9.0 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/tests/components/mealie/snapshots/test_diagnostics.ambr b/tests/components/mealie/snapshots/test_diagnostics.ambr index e6c72c950cc..a694c72fcf6 100644 --- a/tests/components/mealie/snapshots/test_diagnostics.ambr +++ b/tests/components/mealie/snapshots/test_diagnostics.ambr @@ -10,6 +10,7 @@ 'description': None, 'entry_type': 'breakfast', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-23', @@ -18,6 +19,7 @@ 'recipe': dict({ 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'JeQ2', 'name': 'Roast Chicken', 'original_url': 'https://tastesbetterfromscratch.com/roast-chicken/', @@ -35,6 +37,7 @@ 'description': None, 'entry_type': 'dinner', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-22', @@ -43,6 +46,7 @@ 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', @@ -58,6 +62,7 @@ 'description': None, 'entry_type': 'dinner', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-23', @@ -66,6 +71,7 @@ 'recipe': dict({ 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'En9o', 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', @@ -81,6 +87,7 @@ 'description': None, 'entry_type': 'dinner', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-23', @@ -89,6 +96,7 @@ 'recipe': dict({ 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'Kn62', 'name': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', 'original_url': 'https://www.ambitiouskitchen.com/greek-turkey-meatballs/', @@ -104,6 +112,7 @@ 'description': None, 'entry_type': 'dinner', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-23', @@ -112,6 +121,7 @@ 'recipe': dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'ibL6', 'name': 'Pampered Chef Double Chocolate Mocha Trifle', 'original_url': 'https://www.food.com/recipe/pampered-chef-double-chocolate-mocha-trifle-74963', @@ -127,6 +137,7 @@ 'description': None, 'entry_type': 'dinner', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-22', @@ -135,6 +146,7 @@ 'recipe': dict({ 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'beGq', 'name': 'Cheeseburger Sliders (Easy, 30-min Recipe)', 'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/', @@ -150,6 +162,7 @@ 'description': None, 'entry_type': 'dinner', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-23', @@ -158,6 +171,7 @@ 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': '356X', 'name': 'All-American Beef Stew Recipe', 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', @@ -173,6 +187,7 @@ 'description': None, 'entry_type': 'dinner', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-22', @@ -181,6 +196,7 @@ 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'nOPT', 'name': 'Einfacher Nudelauflauf mit Brokkoli', 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', @@ -196,6 +212,7 @@ 'description': None, 'entry_type': 'dinner', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-23', @@ -204,6 +221,7 @@ 'recipe': dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': '5G1v', 'name': 'Miso Udon Noodles with Spinach and Tofu', 'original_url': 'https://www.allrecipes.com/recipe/284039/miso-udon-noodles-with-spinach-and-tofu/', @@ -219,6 +237,7 @@ 'description': None, 'entry_type': 'dinner', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-22', @@ -227,6 +246,7 @@ 'recipe': dict({ 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'rrNL', 'name': 'Mousse de saumon', 'original_url': 'https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon', @@ -242,6 +262,7 @@ 'description': 'Dineren met de boys', 'entry_type': 'dinner', 'group_id': '3931df86-0679-4579-8c63-4bedc9ca9a85', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-21', @@ -257,6 +278,7 @@ 'description': None, 'entry_type': 'lunch', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-23', @@ -265,6 +287,7 @@ 'recipe': dict({ 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'INQz', 'name': 'Receta de pollo al curry en 10 minutos (con vídeo incluido)', 'original_url': 'https://www.directoalpaladar.com/recetas-de-carnes-y-aves/receta-de-pollo-al-curry-en-10-minutos', @@ -280,6 +303,7 @@ 'description': None, 'entry_type': 'lunch', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-23', @@ -288,6 +312,7 @@ 'recipe': dict({ 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'nj5M', 'name': 'Boeuf bourguignon : la vraie recette (2)', 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', @@ -303,6 +328,7 @@ 'description': None, 'entry_type': 'lunch', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-22', @@ -311,6 +337,7 @@ 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': '356X', 'name': 'All-American Beef Stew Recipe', 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', @@ -328,6 +355,7 @@ 'description': None, 'entry_type': 'side', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': dict({ '__type': "", 'isoformat': '2024-01-23', @@ -336,6 +364,7 @@ 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'nOPT', 'name': 'Einfacher Nudelauflauf mit Brokkoli', 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index 3ae158f1d2d..4f9ee6a5c09 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -5,6 +5,7 @@ 'date_added': datetime.date(2024, 6, 29), 'description': 'The world’s most famous cake, the Original Sacher-Torte, is the consequence of several lucky twists of fate. The first was in 1832, when the Austrian State Chancellor, Prince Klemens Wenzel von Metternich, tasked his kitchen staff with concocting an extraordinary dessert to impress his special guests. As fortune had it, the chef had fallen ill that evening, leaving the apprentice chef, the then-16-year-old Franz Sacher, to perform this culinary magic trick. Metternich’s parting words to the talented teenager: “I hope you won’t disgrace me tonight.”', 'group_id': '24477569-f6af-4b53-9e3f-6d04b0ca6916', + 'household_id': None, 'image': 'SuPW', 'ingredients': list([ dict({ @@ -196,11 +197,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', @@ -216,11 +219,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), 'mealplan_id': 229, 'recipe': dict({ 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'JeQ2', 'name': 'Roast Chicken', 'original_url': 'https://tastesbetterfromscratch.com/roast-chicken/', @@ -236,11 +241,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), 'mealplan_id': 226, 'recipe': dict({ 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'INQz', 'name': 'Receta de pollo al curry en 10 minutos (con vídeo incluido)', 'original_url': 'https://www.directoalpaladar.com/recetas-de-carnes-y-aves/receta-de-pollo-al-curry-en-10-minutos', @@ -256,11 +263,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), 'mealplan_id': 224, 'recipe': dict({ 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'nj5M', 'name': 'Boeuf bourguignon : la vraie recette (2)', 'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx', @@ -276,11 +285,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), 'mealplan_id': 222, 'recipe': dict({ 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'En9o', 'name': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο (1)', 'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno', @@ -296,11 +307,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), 'mealplan_id': 221, 'recipe': dict({ 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'Kn62', 'name': 'Greek Turkey Meatballs with Lemon Orzo & Creamy Feta Yogurt Sauce', 'original_url': 'https://www.ambitiouskitchen.com/greek-turkey-meatballs/', @@ -316,11 +329,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), 'mealplan_id': 220, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'nOPT', 'name': 'Einfacher Nudelauflauf mit Brokkoli', 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', @@ -336,11 +351,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), 'mealplan_id': 219, 'recipe': dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'ibL6', 'name': 'Pampered Chef Double Chocolate Mocha Trifle', 'original_url': 'https://www.food.com/recipe/pampered-chef-double-chocolate-mocha-trifle-74963', @@ -356,11 +373,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), 'mealplan_id': 217, 'recipe': dict({ 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'beGq', 'name': 'Cheeseburger Sliders (Easy, 30-min Recipe)', 'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/', @@ -376,11 +395,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), 'mealplan_id': 216, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': '356X', 'name': 'All-American Beef Stew Recipe', 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', @@ -396,11 +417,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), 'mealplan_id': 212, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': '356X', 'name': 'All-American Beef Stew Recipe', 'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe', @@ -416,11 +439,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), 'mealplan_id': 211, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'nOPT', 'name': 'Einfacher Nudelauflauf mit Brokkoli', 'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/', @@ -436,11 +461,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 23), 'mealplan_id': 196, 'recipe': dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': '5G1v', 'name': 'Miso Udon Noodles with Spinach and Tofu', 'original_url': 'https://www.allrecipes.com/recipe/284039/miso-udon-noodles-with-spinach-and-tofu/', @@ -456,11 +483,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 22), 'mealplan_id': 195, 'recipe': dict({ 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'rrNL', 'name': 'Mousse de saumon', 'original_url': 'https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon', @@ -476,6 +505,7 @@ 'description': 'Dineren met de boys', 'entry_type': , 'group_id': '3931df86-0679-4579-8c63-4bedc9ca9a85', + 'household_id': None, 'mealplan_date': FakeDate(2024, 1, 21), 'mealplan_id': 1, 'recipe': None, @@ -491,6 +521,7 @@ 'date_added': datetime.date(2024, 6, 29), 'description': 'The world’s most famous cake, the Original Sacher-Torte, is the consequence of several lucky twists of fate. The first was in 1832, when the Austrian State Chancellor, Prince Klemens Wenzel von Metternich, tasked his kitchen staff with concocting an extraordinary dessert to impress his special guests. As fortune had it, the chef had fallen ill that evening, leaving the apprentice chef, the then-16-year-old Franz Sacher, to perform this culinary magic trick. Metternich’s parting words to the talented teenager: “I hope you won’t disgrace me tonight.”', 'group_id': '24477569-f6af-4b53-9e3f-6d04b0ca6916', + 'household_id': None, 'image': 'SuPW', 'ingredients': list([ dict({ @@ -681,11 +712,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', @@ -705,11 +738,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', @@ -729,11 +764,13 @@ 'description': None, 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'mealplan_date': datetime.date(2024, 1, 22), 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', + 'household_id': None, 'image': 'AiIo', 'name': 'Zoete aardappel curry traybake', 'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/', From 1d05a917f953554937b5f860f4472fe06bcd4c09 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Fri, 30 Aug 2024 15:45:46 +0100 Subject: [PATCH 0073/1309] Add work items per type and state counter sensors to Azure DevOps (#119737) * Add work item data * Add work item sensors * Add icon * Add test fixtures * Add none return tests * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Apply suggestion * Use icon translations * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Update test --------- Co-authored-by: Joost Lekkerkerker --- .../components/azure_devops/coordinator.py | 54 ++++++++++++ homeassistant/components/azure_devops/data.py | 2 + .../components/azure_devops/icons.json | 3 + .../components/azure_devops/sensor.py | 85 ++++++++++++++++++- .../components/azure_devops/strings.json | 3 + tests/components/azure_devops/__init__.py | 52 ++++++++++++ tests/components/azure_devops/conftest.py | 16 +++- tests/components/azure_devops/test_init.py | 45 ++++++++++ 8 files changed, 253 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/azure_devops/coordinator.py b/homeassistant/components/azure_devops/coordinator.py index 2460a9bbfce..21fb76560c3 100644 --- a/homeassistant/components/azure_devops/coordinator.py +++ b/homeassistant/components/azure_devops/coordinator.py @@ -6,8 +6,14 @@ import logging from typing import Final from aioazuredevops.client import DevOpsClient +from aioazuredevops.helper import ( + WorkItemTypeAndState, + work_item_types_states_filter, + work_items_by_type_and_state, +) from aioazuredevops.models.build import Build from aioazuredevops.models.core import Project +from aioazuredevops.models.work_item_type import Category import aiohttp from homeassistant.config_entries import ConfigEntry @@ -20,6 +26,7 @@ from .const import CONF_ORG, DOMAIN from .data import AzureDevOpsData BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" +IGNORED_CATEGORIES: Final[list[Category]] = [Category.COMPLETED, Category.REMOVED] def ado_exception_none_handler(func: Callable) -> Callable: @@ -105,13 +112,60 @@ class AzureDevOpsDataUpdateCoordinator(DataUpdateCoordinator[AzureDevOpsData]): BUILDS_QUERY, ) + @ado_exception_none_handler + async def _get_work_items( + self, project_name: str + ) -> list[WorkItemTypeAndState] | None: + """Get the work items.""" + + if ( + work_item_types := await self.client.get_work_item_types( + self.organization, + project_name, + ) + ) is None: + # If no work item types are returned, return an empty list + return [] + + if ( + work_item_ids := await self.client.get_work_item_ids( + self.organization, + project_name, + # Filter out completed and removed work items so we only get active work items + states=work_item_types_states_filter( + work_item_types, + ignored_categories=IGNORED_CATEGORIES, + ), + ) + ) is None: + # If no work item ids are returned, return an empty list + return [] + + if ( + work_items := await self.client.get_work_items( + self.organization, + project_name, + work_item_ids, + ) + ) is None: + # If no work items are returned, return an empty list + return [] + + return work_items_by_type_and_state( + work_item_types, + work_items, + ignored_categories=IGNORED_CATEGORIES, + ) + async def _async_update_data(self) -> AzureDevOpsData: """Fetch data from Azure DevOps.""" # Get the builds from the project builds = await self._get_builds(self.project.name) + work_items = await self._get_work_items(self.project.name) return AzureDevOpsData( organization=self.organization, project=self.project, builds=builds, + work_items=work_items, ) diff --git a/homeassistant/components/azure_devops/data.py b/homeassistant/components/azure_devops/data.py index c2da38ccc09..ff34bc90c24 100644 --- a/homeassistant/components/azure_devops/data.py +++ b/homeassistant/components/azure_devops/data.py @@ -2,6 +2,7 @@ from dataclasses import dataclass +from aioazuredevops.helper import WorkItemTypeAndState from aioazuredevops.models.build import Build from aioazuredevops.models.core import Project @@ -13,3 +14,4 @@ class AzureDevOpsData: organization: str project: Project builds: list[Build] + work_items: list[WorkItemTypeAndState] diff --git a/homeassistant/components/azure_devops/icons.json b/homeassistant/components/azure_devops/icons.json index de720b46106..ea6b4c632ea 100644 --- a/homeassistant/components/azure_devops/icons.json +++ b/homeassistant/components/azure_devops/icons.json @@ -3,6 +3,9 @@ "sensor": { "latest_build": { "default": "mdi:pipe" + }, + "work_item_count": { + "default": "mdi:ticket" } } } diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index 7b7af1dd666..fd47115214a 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -8,6 +8,7 @@ from datetime import datetime import logging from typing import Any +from aioazuredevops.helper import WorkItemState, WorkItemTypeAndState from aioazuredevops.models.build import Build from homeassistant.components.sensor import ( @@ -29,12 +30,19 @@ _LOGGER = logging.getLogger(__name__) @dataclass(frozen=True, kw_only=True) class AzureDevOpsBuildSensorEntityDescription(SensorEntityDescription): - """Class describing Azure DevOps base build sensor entities.""" + """Class describing Azure DevOps build sensor entities.""" attr_fn: Callable[[Build], dict[str, Any] | None] = lambda _: None value_fn: Callable[[Build], datetime | StateType] +@dataclass(frozen=True, kw_only=True) +class AzureDevOpsWorkItemSensorEntityDescription(SensorEntityDescription): + """Class describing Azure DevOps work item sensor entities.""" + + value_fn: Callable[[WorkItemState], datetime | StateType] + + BASE_BUILD_SENSOR_DESCRIPTIONS: tuple[AzureDevOpsBuildSensorEntityDescription, ...] = ( # Attributes are deprecated in 2024.7 and can be removed in 2025.1 AzureDevOpsBuildSensorEntityDescription( @@ -116,6 +124,16 @@ BASE_BUILD_SENSOR_DESCRIPTIONS: tuple[AzureDevOpsBuildSensorEntityDescription, . ), ) +BASE_WORK_ITEM_SENSOR_DESCRIPTIONS: tuple[ + AzureDevOpsWorkItemSensorEntityDescription, ... +] = ( + AzureDevOpsWorkItemSensorEntityDescription( + key="work_item_count", + translation_key="work_item_count", + value_fn=lambda work_item_state: len(work_item_state.work_items), + ), +) + def parse_datetime(value: str | None) -> datetime | None: """Parse datetime string.""" @@ -134,7 +152,7 @@ async def async_setup_entry( coordinator = entry.runtime_data initial_builds: list[Build] = coordinator.data.builds - async_add_entities( + entities: list[SensorEntity] = [ AzureDevOpsBuildSensor( coordinator, description, @@ -143,8 +161,22 @@ async def async_setup_entry( for description in BASE_BUILD_SENSOR_DESCRIPTIONS for key, build in enumerate(initial_builds) if build.project and build.definition + ] + + entities.extend( + AzureDevOpsWorkItemSensor( + coordinator, + description, + key, + state_key, + ) + for description in BASE_WORK_ITEM_SENSOR_DESCRIPTIONS + for key, work_item_type_state in enumerate(coordinator.data.work_items) + for state_key, _ in enumerate(work_item_type_state.state_items) ) + async_add_entities(entities) + class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity): """Define a Azure DevOps build sensor.""" @@ -162,8 +194,8 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity): self.entity_description = description self.item_key = item_key self._attr_unique_id = ( - f"{self.coordinator.data.organization}_" - f"{self.build.project.id}_" + f"{coordinator.data.organization}_" + f"{coordinator.data.project.id}_" f"{self.build.definition.build_id}_" f"{description.key}" ) @@ -185,3 +217,48 @@ class AzureDevOpsBuildSensor(AzureDevOpsEntity, SensorEntity): def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the state attributes of the entity.""" return self.entity_description.attr_fn(self.build) + + +class AzureDevOpsWorkItemSensor(AzureDevOpsEntity, SensorEntity): + """Define a Azure DevOps work item sensor.""" + + entity_description: AzureDevOpsWorkItemSensorEntityDescription + + def __init__( + self, + coordinator: AzureDevOpsDataUpdateCoordinator, + description: AzureDevOpsWorkItemSensorEntityDescription, + wits_key: int, + state_key: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self.entity_description = description + self.wits_key = wits_key + self.state_key = state_key + self._attr_unique_id = ( + f"{coordinator.data.organization}_" + f"{coordinator.data.project.id}_" + f"{self.work_item_type.name}_" + f"{self.work_item_state.name}_" + f"{description.key}" + ) + self._attr_translation_placeholders = { + "item_type": self.work_item_type.name, + "item_state": self.work_item_state.name, + } + + @property + def work_item_type(self) -> WorkItemTypeAndState: + """Return the work item.""" + return self.coordinator.data.work_items[self.wits_key] + + @property + def work_item_state(self) -> WorkItemState: + """Return the work item state.""" + return self.work_item_type.state_items[self.state_key] + + @property + def native_value(self) -> datetime | StateType: + """Return the state.""" + return self.entity_description.value_fn(self.work_item_state) diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json index 8a17169fb6b..c5304270396 100644 --- a/homeassistant/components/azure_devops/strings.json +++ b/homeassistant/components/azure_devops/strings.json @@ -60,6 +60,9 @@ }, "url": { "name": "{definition_name} latest build url" + }, + "work_item_count": { + "name": "{item_type} {item_state} work items" } } }, diff --git a/tests/components/azure_devops/__init__.py b/tests/components/azure_devops/__init__.py index cc4732b1495..6414fe0257c 100644 --- a/tests/components/azure_devops/__init__.py +++ b/tests/components/azure_devops/__init__.py @@ -1,9 +1,12 @@ """Tests for the Azure DevOps integration.""" +from datetime import datetime from typing import Final from aioazuredevops.models.build import Build, BuildDefinition from aioazuredevops.models.core import Project +from aioazuredevops.models.work_item import WorkItem, WorkItemFields +from aioazuredevops.models.work_item_type import Category, Icon, State, WorkItemType from homeassistant.components.azure_devops.const import CONF_ORG, CONF_PAT, CONF_PROJECT from homeassistant.core import HomeAssistant @@ -77,6 +80,55 @@ DEVOPS_BUILD_MISSING_PROJECT_DEFINITION = Build( build_id=9876, ) +DEVOPS_WORK_ITEM_TYPES = [ + WorkItemType( + name="Bug", + reference_name="System.Bug", + description="Bug", + color="ff0000", + icon=Icon(id="1234", url="https://example.com/icon.png"), + is_disabled=False, + xml_form="", + fields=[], + field_instances=[], + transitions={}, + states=[ + State(name="New", color="ff0000", category=Category.PROPOSED), + State(name="Active", color="ff0000", category=Category.IN_PROGRESS), + State(name="Resolved", color="ff0000", category=Category.RESOLVED), + State(name="Closed", color="ff0000", category=Category.COMPLETED), + ], + url="", + ) +] + +DEVOPS_WORK_ITEM_IDS = [1] + +DEVOPS_WORK_ITEMS = [ + WorkItem( + id=1, + rev=1, + fields=WorkItemFields( + area_path="", + team_project="", + iteration_path="", + work_item_type="Bug", + state="New", + reason="New", + assigned_to=None, + created_date=datetime(2021, 1, 1), + created_by=None, + changed_date=datetime(2021, 1, 1), + changed_by=None, + comment_count=0, + title="Test", + microsoft_vsts_common_state_change_date=datetime(2021, 1, 1), + microsoft_vsts_common_priority=1, + ), + url="https://example.com", + ) +] + async def setup_integration( hass: HomeAssistant, diff --git a/tests/components/azure_devops/conftest.py b/tests/components/azure_devops/conftest.py index c65adaa4da5..54c730f9523 100644 --- a/tests/components/azure_devops/conftest.py +++ b/tests/components/azure_devops/conftest.py @@ -7,7 +7,16 @@ import pytest from homeassistant.components.azure_devops.const import DOMAIN -from . import DEVOPS_BUILD, DEVOPS_PROJECT, FIXTURE_USER_INPUT, PAT, UNIQUE_ID +from . import ( + DEVOPS_BUILD, + DEVOPS_PROJECT, + DEVOPS_WORK_ITEM_IDS, + DEVOPS_WORK_ITEM_TYPES, + DEVOPS_WORK_ITEMS, + FIXTURE_USER_INPUT, + PAT, + UNIQUE_ID, +) from tests.common import MockConfigEntry @@ -33,8 +42,9 @@ async def mock_devops_client() -> AsyncGenerator[MagicMock]: devops_client.get_project.return_value = DEVOPS_PROJECT devops_client.get_builds.return_value = [DEVOPS_BUILD] devops_client.get_build.return_value = DEVOPS_BUILD - devops_client.get_work_item_ids.return_value = None - devops_client.get_work_items.return_value = None + devops_client.get_work_item_types.return_value = DEVOPS_WORK_ITEM_TYPES + devops_client.get_work_item_ids.return_value = DEVOPS_WORK_ITEM_IDS + devops_client.get_work_items.return_value = DEVOPS_WORK_ITEMS yield devops_client diff --git a/tests/components/azure_devops/test_init.py b/tests/components/azure_devops/test_init.py index a7655042f25..dd512cb12e0 100644 --- a/tests/components/azure_devops/test_init.py +++ b/tests/components/azure_devops/test_init.py @@ -91,3 +91,48 @@ async def test_no_builds( assert mock_devops_client.get_builds.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_no_work_item_types( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_devops_client: MagicMock, +) -> None: + """Test a failed update entry.""" + mock_devops_client.get_work_item_types.return_value = None + + await setup_integration(hass, mock_config_entry) + + assert mock_devops_client.get_work_item_types.call_count == 1 + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_no_work_item_ids( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_devops_client: MagicMock, +) -> None: + """Test a failed update entry.""" + mock_devops_client.get_work_item_ids.return_value = None + + await setup_integration(hass, mock_config_entry) + + assert mock_devops_client.get_work_item_ids.call_count == 1 + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_no_work_items( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_devops_client: MagicMock, +) -> None: + """Test a failed update entry.""" + mock_devops_client.get_work_items.return_value = None + + await setup_integration(hass, mock_config_entry) + + assert mock_devops_client.get_work_items.call_count == 1 + + assert mock_config_entry.state is ConfigEntryState.LOADED From 20f9b9e412a555e95010217f43c812dfbe4a3b18 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Fri, 30 Aug 2024 17:03:24 +0200 Subject: [PATCH 0074/1309] Add inverter-devices to solarlog (#123205) * Add inverter-devices * Minor code adjustments * Update manifest.json Seperate dependency upgrade to seperate PR * Update requirements_all.txt Seperate dependency upgrade to seperate PR * Update requirements_test_all.txt Seperate dependency upgrade to seperate PR * Update homeassistant/components/solarlog/sensor.py Co-authored-by: Joost Lekkerkerker * Split up base class, document SolarLogSensorEntityDescription * Split up sensor types * Update snapshot * Add all devices in config_flow * Remove options flow * Move devices in config_entry from options to data * Correct mock_config_entry * Minor adjustments * Remove enabled_devices from config * Remove obsolete test * Update snapshot * Delete obsolete code snips * Update homeassistant/components/solarlog/sensor.py Co-authored-by: Joost Lekkerkerker * Remove obsolete test in setting up sensors * Update homeassistant/components/solarlog/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/solarlog/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/solarlog/config_flow.py Co-authored-by: Joost Lekkerkerker * Fix typing error --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/solarlog/__init__.py | 6 +- .../components/solarlog/coordinator.py | 9 +- homeassistant/components/solarlog/entity.py | 71 ++++++++++ homeassistant/components/solarlog/sensor.py | 125 +++++++++++------- tests/components/solarlog/conftest.py | 25 +++- .../solarlog/fixtures/solarlog_data.json | 3 +- .../solarlog/snapshots/test_sensor.ambr | 99 ++++++++++---- tests/components/solarlog/test_config_flow.py | 2 +- 8 files changed, 260 insertions(+), 80 deletions(-) create mode 100644 homeassistant/components/solarlog/entity.py diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index 962efa4e190..f23305ca8f2 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -7,17 +7,17 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .coordinator import SolarlogData +from .coordinator import SolarLogCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -type SolarlogConfigEntry = ConfigEntry[SolarlogData] +type SolarlogConfigEntry = ConfigEntry[SolarLogCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: SolarlogConfigEntry) -> bool: """Set up a config entry for solarlog.""" - coordinator = SolarlogData(hass, entry) + coordinator = SolarLogCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index d2963e1950e..96ee00af1ec 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: from . import SolarlogConfigEntry -class SolarlogData(update_coordinator.DataUpdateCoordinator): +class SolarLogCoordinator(update_coordinator.DataUpdateCoordinator): """Get and update the latest data.""" def __init__(self, hass: HomeAssistant, entry: SolarlogConfigEntry) -> None: @@ -49,12 +49,19 @@ class SolarlogData(update_coordinator.DataUpdateCoordinator): self.host, extended_data, hass.config.time_zone ) + async def _async_setup(self) -> None: + """Do initialization logic.""" + if self.solarlog.extended_data: + device_list = await self.solarlog.client.get_device_list() + self.solarlog.set_enabled_devices({key: True for key in device_list}) + async def _async_update_data(self): """Update the data from the SolarLog device.""" _LOGGER.debug("Start data update") try: data = await self.solarlog.update_data() + await self.solarlog.update_device_list() except SolarLogConnectionError as err: raise ConfigEntryNotReady(err) from err except SolarLogUpdateError as err: diff --git a/homeassistant/components/solarlog/entity.py b/homeassistant/components/solarlog/entity.py new file mode 100644 index 00000000000..1d91fc8726b --- /dev/null +++ b/homeassistant/components/solarlog/entity.py @@ -0,0 +1,71 @@ +"""Entities for SolarLog integration.""" + +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import SolarLogCoordinator + + +class SolarLogBaseEntity(CoordinatorEntity[SolarLogCoordinator]): + """SolarLog base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SolarLogCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the SolarLogCoordinator sensor.""" + super().__init__(coordinator) + + self.entity_description = description + + +class SolarLogCoordinatorEntity(SolarLogBaseEntity): + """Base SolarLog Coordinator entity.""" + + def __init__( + self, + coordinator: SolarLogCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the SolarLogCoordinator sensor.""" + super().__init__(coordinator, description) + + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = DeviceInfo( + manufacturer="Solar-Log", + model="Controller", + identifiers={(DOMAIN, coordinator.unique_id)}, + name=coordinator.name, + configuration_url=coordinator.host, + ) + + +class SolarLogInverterEntity(SolarLogBaseEntity): + """Base SolarLog inverter entity.""" + + def __init__( + self, + coordinator: SolarLogCoordinator, + description: SensorEntityDescription, + device_id: int, + ) -> None: + """Initialize the SolarLogInverter sensor.""" + super().__init__(coordinator, description) + name = f"{coordinator.unique_id}-{slugify(coordinator.solarlog.device_name(device_id))}" + self._attr_unique_id = f"{name}-{description.key}" + self._attr_device_info = DeviceInfo( + manufacturer="Solar-Log", + model="Inverter", + identifiers={(DOMAIN, name)}, + name=coordinator.solarlog.device_name(device_id), + via_device=(DOMAIN, coordinator.unique_id), + ) + self.device_id = device_id diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index 45961133e8a..cd4a711cdc9 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -1,8 +1,11 @@ """Platform for solarlog sensors.""" +from __future__ import annotations + from collections.abc import Callable from dataclasses import dataclass from datetime import datetime +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -17,22 +20,22 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import SolarlogConfigEntry, SolarlogData -from .const import DOMAIN +from . import SolarlogConfigEntry +from .entity import SolarLogCoordinatorEntity, SolarLogInverterEntity @dataclass(frozen=True) class SolarLogSensorEntityDescription(SensorEntityDescription): """Describes Solarlog sensor entity.""" - value: Callable[[float | int], float] | Callable[[datetime], datetime] | None = None + value_fn: Callable[[float | int], float] | Callable[[datetime], datetime] = ( + lambda value: value + ) -SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( +SOLARLOG_SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( SolarLogSensorEntityDescription( key="last_updated", translation_key="last_update", @@ -71,28 +74,28 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( translation_key="yield_day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), + value_fn=lambda value: round(value / 1000, 3), ), SolarLogSensorEntityDescription( key="yield_yesterday", translation_key="yield_yesterday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), + value_fn=lambda value: round(value / 1000, 3), ), SolarLogSensorEntityDescription( key="yield_month", translation_key="yield_month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), + value_fn=lambda value: round(value / 1000, 3), ), SolarLogSensorEntityDescription( key="yield_year", translation_key="yield_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), + value_fn=lambda value: round(value / 1000, 3), ), SolarLogSensorEntityDescription( key="yield_total", @@ -100,7 +103,7 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, - value=lambda value: round(value / 1000, 3), + value_fn=lambda value: round(value / 1000, 3), ), SolarLogSensorEntityDescription( key="consumption_ac", @@ -114,28 +117,28 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( translation_key="consumption_day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), + value_fn=lambda value: round(value / 1000, 3), ), SolarLogSensorEntityDescription( key="consumption_yesterday", translation_key="consumption_yesterday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), + value_fn=lambda value: round(value / 1000, 3), ), SolarLogSensorEntityDescription( key="consumption_month", translation_key="consumption_month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), + value_fn=lambda value: round(value / 1000, 3), ), SolarLogSensorEntityDescription( key="consumption_year", translation_key="consumption_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), + value_fn=lambda value: round(value / 1000, 3), ), SolarLogSensorEntityDescription( key="consumption_total", @@ -143,7 +146,7 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, - value=lambda value: round(value / 1000, 3), + value_fn=lambda value: round(value / 1000, 3), ), SolarLogSensorEntityDescription( key="self_consumption_year", @@ -171,7 +174,7 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, - value=lambda value: round(value * 100, 1), + value_fn=lambda value: round(value * 100, 1), ), SolarLogSensorEntityDescription( key="efficiency", @@ -179,7 +182,7 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, - value=lambda value: round(value * 100, 1), + value_fn=lambda value: round(value * 100, 1), ), SolarLogSensorEntityDescription( key="power_available", @@ -194,7 +197,24 @@ SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, - value=lambda value: round(value * 100, 1), + value_fn=lambda value: round(value * 100, 1), + ), +) + +INVERTER_SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( + SolarLogSensorEntityDescription( + key="current_power", + translation_key="current_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="consumption_year", + translation_key="consumption_year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value_fn=lambda value: round(value / 1000, 3), ), ) @@ -206,39 +226,50 @@ async def async_setup_entry( ) -> None: """Add solarlog entry.""" coordinator = entry.runtime_data - async_add_entities( - SolarlogSensor(coordinator, description) for description in SENSOR_TYPES - ) + + # https://github.com/python/mypy/issues/14294 + + entities: list[SensorEntity] = [ + SolarLogCoordinatorSensor(coordinator, sensor) + for sensor in SOLARLOG_SENSOR_TYPES + ] + + device_data: dict[str, Any] = coordinator.data["devices"] + + if not device_data: + entities.extend( + SolarLogInverterSensor(coordinator, sensor, int(device_id)) + for device_id in device_data + for sensor in INVERTER_SENSOR_TYPES + if sensor.key in device_data[device_id] + ) + + async_add_entities(entities) -class SolarlogSensor(CoordinatorEntity[SolarlogData], SensorEntity): - """Representation of a Sensor.""" - - _attr_has_entity_name = True +class SolarLogCoordinatorSensor(SolarLogCoordinatorEntity, SensorEntity): + """Represents a SolarLog sensor.""" entity_description: SolarLogSensorEntityDescription - def __init__( - self, - coordinator: SolarlogData, - description: SolarLogSensorEntityDescription, - ) -> None: - """Initialize the sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.unique_id)}, - manufacturer="Solar-Log", - name=coordinator.name, - configuration_url=coordinator.host, - ) + @property + def native_value(self) -> float | datetime: + """Return the state for this sensor.""" + + val = self.coordinator.data[self.entity_description.key] + return self.entity_description.value_fn(val) + + +class SolarLogInverterSensor(SolarLogInverterEntity, SensorEntity): + """Represents a SolarLog inverter sensor.""" + + entity_description: SolarLogSensorEntityDescription @property - def native_value(self): - """Return the native sensor value.""" - raw_attr = self.coordinator.data.get(self.entity_description.key) + def native_value(self) -> float | datetime: + """Return the state for this sensor.""" - if self.entity_description.value: - return self.entity_description.value(raw_attr) - return raw_attr + val = self.coordinator.data["devices"][self.device_id][ + self.entity_description.key + ] + return self.entity_description.value_fn(val) diff --git a/tests/components/solarlog/conftest.py b/tests/components/solarlog/conftest.py index c34d0c011a3..44c0e27f9b0 100644 --- a/tests/components/solarlog/conftest.py +++ b/tests/components/solarlog/conftest.py @@ -1,6 +1,7 @@ """Test helpers.""" from collections.abc import Generator +from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest @@ -35,9 +36,27 @@ def mock_solarlog_connector(): mock_solarlog_api = AsyncMock() mock_solarlog_api.test_connection = AsyncMock(return_value=True) - mock_solarlog_api.update_data.return_value = load_json_object_fixture( - "solarlog_data.json", SOLARLOG_DOMAIN - ) + + data = { + "devices": { + 0: {"consumption_total": 354687, "current_power": 5}, + } + } + data |= load_json_object_fixture("solarlog_data.json", SOLARLOG_DOMAIN) + data["last_updated"] = datetime.fromisoformat(data["last_updated"]).astimezone(UTC) + + mock_solarlog_api.update_data.return_value = data + mock_solarlog_api.device_list.return_value = { + 0: {"name": "Inverter 1"}, + 1: {"name": "Inverter 2"}, + } + mock_solarlog_api.device_name = {0: "Inverter 1", 1: "Inverter 2"}.get + mock_solarlog_api.client.get_device_list.return_value = { + 0: {"name": "Inverter 1"}, + 1: {"name": "Inverter 2"}, + } + mock_solarlog_api.client.close = AsyncMock(return_value=None) + with ( patch( "homeassistant.components.solarlog.coordinator.SolarLogConnector", diff --git a/tests/components/solarlog/fixtures/solarlog_data.json b/tests/components/solarlog/fixtures/solarlog_data.json index 4976f4fa8b7..f7077d88d0d 100644 --- a/tests/components/solarlog/fixtures/solarlog_data.json +++ b/tests/components/solarlog/fixtures/solarlog_data.json @@ -20,5 +20,6 @@ "efficiency": 0.9804, "usage": 0.5487, "power_available": 45.13, - "capacity": 0.85 + "capacity": 0.85, + "last_updated": "2024-08-01T15:20:45" } diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr index df154a5eb9b..74a397be900 100644 --- a/tests/components/solarlog/snapshots/test_sensor.ambr +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_all_entities[sensor.inverter_1_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_power', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-inverter_1-current_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.inverter_1_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- # name: test_all_entities[sensor.solarlog_alternator_loss-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -30,7 +81,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'alternator_loss', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_alternator_loss', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-alternator_loss', 'unit_of_measurement': , }) # --- @@ -81,7 +132,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'capacity', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_capacity', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-capacity', 'unit_of_measurement': '%', }) # --- @@ -132,7 +183,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'consumption_ac', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_ac', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-consumption_ac', 'unit_of_measurement': , }) # --- @@ -181,7 +232,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'consumption_day', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_day', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-consumption_day', 'unit_of_measurement': , }) # --- @@ -229,7 +280,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'consumption_month', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_month', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-consumption_month', 'unit_of_measurement': , }) # --- @@ -279,7 +330,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'consumption_total', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_total', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-consumption_total', 'unit_of_measurement': , }) # --- @@ -328,7 +379,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'consumption_year', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_year', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-consumption_year', 'unit_of_measurement': , }) # --- @@ -376,7 +427,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'consumption_yesterday', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_consumption_yesterday', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-consumption_yesterday', 'unit_of_measurement': , }) # --- @@ -426,7 +477,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'efficiency', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_efficiency', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-efficiency', 'unit_of_measurement': '%', }) # --- @@ -475,7 +526,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'total_power', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_total_power', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-total_power', 'unit_of_measurement': , }) # --- @@ -523,7 +574,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'last_update', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_last_updated', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-last_updated', 'unit_of_measurement': None, }) # --- @@ -538,7 +589,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': '2024-08-01T15:20:45+00:00', }) # --- # name: test_all_entities[sensor.solarlog_power_ac-entry] @@ -572,7 +623,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_ac', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_ac', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-power_ac', 'unit_of_measurement': , }) # --- @@ -623,7 +674,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_available', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_available', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-power_available', 'unit_of_measurement': , }) # --- @@ -674,7 +725,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_dc', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_power_dc', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-power_dc', 'unit_of_measurement': , }) # --- @@ -725,7 +776,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'self_consumption_year', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_self_consumption_year', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-self_consumption_year', 'unit_of_measurement': , }) # --- @@ -776,7 +827,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'usage', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_usage', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-usage', 'unit_of_measurement': '%', }) # --- @@ -827,7 +878,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_ac', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_voltage_ac', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-voltage_ac', 'unit_of_measurement': , }) # --- @@ -878,7 +929,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_dc', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_voltage_dc', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-voltage_dc', 'unit_of_measurement': , }) # --- @@ -927,7 +978,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'yield_day', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_day', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-yield_day', 'unit_of_measurement': , }) # --- @@ -975,7 +1026,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'yield_month', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_month', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-yield_month', 'unit_of_measurement': , }) # --- @@ -1025,7 +1076,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'yield_total', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_total', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-yield_total', 'unit_of_measurement': , }) # --- @@ -1074,7 +1125,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'yield_year', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_year', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-yield_year', 'unit_of_measurement': , }) # --- @@ -1122,7 +1173,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'yield_yesterday', - 'unique_id': 'ce5f5431554d101905d31797e1232da8_yield_yesterday', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-yield_yesterday', 'unit_of_measurement': , }) # --- diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py index b06f2ac0587..b2b2ff9566e 100644 --- a/tests/components/solarlog/test_config_flow.py +++ b/tests/components/solarlog/test_config_flow.py @@ -67,7 +67,7 @@ async def test_user( # tests with all provided result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: HOST, CONF_NAME: NAME, "extended_data": False} + result["flow_id"], {CONF_HOST: HOST, CONF_NAME: NAME, "extended_data": True} ) await hass.async_block_till_done() From 50577883dcce85ff2ac136ab4a542baefe8f2b27 Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Fri, 30 Aug 2024 17:08:06 +0200 Subject: [PATCH 0075/1309] Add option to login with username/email and password in Habitica integration (#117622) * add login/password authentication * add advanced config flow * remove unused exception classes, fix errors * update username in init * update tests * update strings * combine steps with menu * remove username from entry * update tests * Revert "update tests" This reverts commit 6ac8ad6a26547b623e217db817ec4d0cf8c91f1d. * Revert "remove username from entry" This reverts commit d9323fb72df3f9d41be0a53bb0cbe16be718d005. * small changes * remove pylint broad-excep * run habitipy init in executor * Add text selectors * changes --- homeassistant/components/habitica/__init__.py | 16 +- .../components/habitica/config_flow.py | 177 ++++++++++++---- .../components/habitica/strings.json | 24 ++- tests/components/habitica/test_config_flow.py | 197 ++++++++++++++---- tests/components/habitica/test_init.py | 1 + 5 files changed, 318 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 468db8fbc42..bcf8713f9b1 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_NAME, CONF_SENSORS, CONF_URL, + CONF_VERIFY_SSL, Platform, ) from homeassistant.core import HomeAssistant, ServiceCall @@ -125,6 +126,7 @@ async def async_setup_entry( name = call.data[ATTR_NAME] path = call.data[ATTR_PATH] entries = hass.config_entries.async_entries(DOMAIN) + api = None for entry in entries: if entry.data[CONF_NAME] == name: @@ -147,18 +149,16 @@ async def async_setup_entry( EVENT_API_CALL_SUCCESS, {ATTR_NAME: name, ATTR_PATH: path, ATTR_DATA: data} ) - websession = async_get_clientsession(hass) - - url = config_entry.data[CONF_URL] - username = config_entry.data[CONF_API_USER] - password = config_entry.data[CONF_API_KEY] + websession = async_get_clientsession( + hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True) + ) api = await hass.async_add_executor_job( HAHabitipyAsync, { - "url": url, - "login": username, - "password": password, + "url": config_entry.data[CONF_URL], + "login": config_entry.data[CONF_API_USER], + "password": config_entry.data[CONF_API_KEY], }, ) try: diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index a40261c0902..2947032c41e 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from http import HTTPStatus import logging from typing import Any @@ -10,48 +11,53 @@ from habitipy.aio import HabitipyAsync import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.const import ( + CONF_API_KEY, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) from .const import CONF_API_USER, DEFAULT_URL, DOMAIN -DATA_SCHEMA = vol.Schema( +STEP_ADVANCED_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_USER): str, vol.Required(CONF_API_KEY): str, - vol.Optional(CONF_NAME): str, vol.Optional(CONF_URL, default=DEFAULT_URL): str, + vol.Required(CONF_VERIFY_SSL, default=True): bool, + } +) + +STEP_LOGIN_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + autocomplete="email", + ) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), } ) _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, str]: - """Validate the user input allows us to connect.""" - - websession = async_get_clientsession(hass) - api = await hass.async_add_executor_job( - HabitipyAsync, - { - "login": data[CONF_API_USER], - "password": data[CONF_API_KEY], - "url": data[CONF_URL] or DEFAULT_URL, - }, - ) - try: - await api.user.get(session=websession) - return { - "title": f"{data.get('name', 'Default username')}", - CONF_API_USER: data[CONF_API_USER], - } - except ClientResponseError as ex: - raise InvalidAuth from ex - - class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for habitica.""" @@ -62,24 +68,115 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" - errors = {} + return self.async_show_menu( + step_id="user", + menu_options=["login", "advanced"], + ) + + async def async_step_login( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Config flow with username/password. + + Simplified configuration setup that retrieves API credentials + from Habitica.com by authenticating with login and password. + """ + errors: dict[str, str] = {} if user_input is not None: try: - info = await validate_input(self.hass, user_input) - except InvalidAuth: - errors = {"base": "invalid_credentials"} + session = async_get_clientsession(self.hass) + api = await self.hass.async_add_executor_job( + HabitipyAsync, + { + "login": "", + "password": "", + "url": DEFAULT_URL, + }, + ) + login_response = await api.user.auth.local.login.post( + session=session, + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + + except ClientResponseError as ex: + if ex.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") - errors = {"base": "unknown"} + errors["base"] = "unknown" else: - await self.async_set_unique_id(info[CONF_API_USER]) + await self.async_set_unique_id(login_response["id"]) self._abort_if_unique_id_configured() - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=login_response["username"], + data={ + CONF_API_USER: login_response["id"], + CONF_API_KEY: login_response["apiToken"], + CONF_USERNAME: login_response["username"], + CONF_URL: DEFAULT_URL, + CONF_VERIFY_SSL: True, + }, + ) + return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, + step_id="login", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_LOGIN_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) + + async def async_step_advanced( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Advanced configuration with User Id and API Token. + + Advanced configuration allows connecting to Habitica instances + hosted on different domains or to self-hosted instances. + """ + errors: dict[str, str] = {} + if user_input is not None: + try: + session = async_get_clientsession( + self.hass, verify_ssl=user_input.get(CONF_VERIFY_SSL, True) + ) + api = await self.hass.async_add_executor_job( + HabitipyAsync, + { + "login": user_input[CONF_API_USER], + "password": user_input[CONF_API_KEY], + "url": user_input.get(CONF_URL, DEFAULT_URL), + }, + ) + api_response = await api.user.get( + session=session, + userFields="auth", + ) + except ClientResponseError as ex: + if ex.status == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_API_USER]) + self._abort_if_unique_id_configured() + user_input[CONF_USERNAME] = api_response["auth"]["local"]["username"] + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="advanced", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_ADVANCED_DATA_SCHEMA, suggested_values=user_input + ), errors=errors, - description_placeholders={}, ) async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: @@ -98,8 +195,4 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): "integration_title": "Habitica", }, ) - return await self.async_step_user(import_data) - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" + return await self.async_step_advanced(import_data) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 21d2622245c..c5a54d254cc 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -4,18 +4,32 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, "error": { - "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { "user": { + "menu_options": { + "login": "Login to Habitica", + "advanced": "Login to other instances" + }, + "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks." + }, + "login": { + "data": { + "username": "Email or username (case-sensitive)", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "advanced": { "data": { "url": "[%key:common::config_flow::data::url%]", - "name": "Override for Habitica’s username. Will be used for actions", - "api_user": "Habitica’s API user ID", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_user": "User ID", + "api_key": "API Token", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, - "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" + "description": "You can retrieve your `User ID` and `API Token` from **Settings -> Site Data** on Habitica or the instance you want to connect to" } } }, diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py index 4dfc696daf2..09cda3fbb0a 100644 --- a/tests/components/habitica/test_config_flow.py +++ b/tests/components/habitica/test_config_flow.py @@ -3,26 +3,152 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError +import pytest from homeassistant import config_entries -from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN +from homeassistant.components.habitica.const import CONF_API_USER, DEFAULT_URL, DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_PASSWORD, + CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry +MOCK_DATA_LOGIN_STEP = { + CONF_USERNAME: "test-email@example.com", + CONF_PASSWORD: "test-password", +} +MOCK_DATA_ADVANCED_STEP = { + CONF_API_USER: "test-api-user", + CONF_API_KEY: "test-api-key", + CONF_URL: DEFAULT_URL, + CONF_VERIFY_SSL: True, +} -async def test_form(hass: HomeAssistant) -> None: + +async def test_form_login(hass: HomeAssistant) -> None: + """Test we get the login form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert "login" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "login"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "login" + + mock_obj = MagicMock() + mock_obj.user.auth.local.login.post = AsyncMock() + mock_obj.user.auth.local.login.post.return_value = { + "id": "test-api-user", + "apiToken": "test-api-key", + "username": "test-username", + } + with ( + patch( + "homeassistant.components.habitica.config_flow.HabitipyAsync", + return_value=mock_obj, + ), + patch( + "homeassistant.components.habitica.async_setup", return_value=True + ) as mock_setup, + patch( + "homeassistant.components.habitica.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LOGIN_STEP, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + **MOCK_DATA_ADVANCED_STEP, + CONF_USERNAME: "test-username", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (ClientResponseError(MagicMock(), (), status=400), "cannot_connect"), + (ClientResponseError(MagicMock(), (), status=401), "invalid_auth"), + (IndexError(), "unknown"), + ], +) +async def test_form_login_errors(hass: HomeAssistant, raise_error, text_error) -> None: + """Test we handle invalid credentials error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "login"} + ) + + mock_obj = MagicMock() + mock_obj.user.auth.local.login.post = AsyncMock(side_effect=raise_error) + with patch( + "homeassistant.components.habitica.config_flow.HabitipyAsync", + return_value=mock_obj, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_LOGIN_STEP, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": text_error} + + +async def test_form_advanced(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + + assert result["type"] is FlowResultType.MENU + assert "advanced" in result["menu_options"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "advanced"} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "advanced" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "advanced"} + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} mock_obj = MagicMock() mock_obj.user.get = AsyncMock() + mock_obj.user.get.return_value = {"auth": {"local": {"username": "test-username"}}} with ( patch( @@ -39,29 +165,46 @@ async def test_form(hass: HomeAssistant) -> None: ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"api_user": "test-api-user", "api_key": "test-api-key"}, + user_input=MOCK_DATA_ADVANCED_STEP, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Default username" + assert result2["title"] == "test-username" assert result2["data"] == { - "url": DEFAULT_URL, - "api_user": "test-api-user", - "api_key": "test-api-key", + **MOCK_DATA_ADVANCED_STEP, + CONF_USERNAME: "test-username", } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_credentials(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (ClientResponseError(MagicMock(), (), status=400), "cannot_connect"), + (ClientResponseError(MagicMock(), (), status=401), "invalid_auth"), + (IndexError(), "unknown"), + ], +) +async def test_form_advanced_errors( + hass: HomeAssistant, raise_error, text_error +) -> None: """Test we handle invalid credentials error.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "advanced"} + ) + mock_obj = MagicMock() - mock_obj.user.get = AsyncMock(side_effect=ClientResponseError(MagicMock(), ())) + mock_obj.user.get = AsyncMock(side_effect=raise_error) with patch( "homeassistant.components.habitica.config_flow.HabitipyAsync", @@ -69,41 +212,11 @@ async def test_form_invalid_credentials(hass: HomeAssistant) -> None: ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "url": DEFAULT_URL, - "api_user": "test-api-user", - "api_key": "test-api-key", - }, + user_input=MOCK_DATA_ADVANCED_STEP, ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_credentials"} - - -async def test_form_unexpected_exception(hass: HomeAssistant) -> None: - """Test we handle unexpected exception error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - mock_obj = MagicMock() - mock_obj.user.get = AsyncMock(side_effect=Exception) - - with patch( - "homeassistant.components.habitica.config_flow.HabitipyAsync", - return_value=mock_obj, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "url": DEFAULT_URL, - "api_user": "test-api-user", - "api_key": "test-api-key", - }, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"] == {"base": text_error} async def test_manual_flow_config_exist(hass: HomeAssistant) -> None: @@ -119,7 +232,7 @@ async def test_manual_flow_config_exist(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" + assert result["step_id"] == "advanced" mock_obj = MagicMock() mock_obj.user.get = AsyncMock(return_value={"api_user": "test-api-user"}) diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 4c2b1e2aae6..56f17bc9889 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -52,6 +52,7 @@ def common_requests(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: "https://habitica.com/api/v3/user", json={ "data": { + "auth": {"local": {"username": TEST_USER_NAME}}, "api_user": "test-api-user", "profile": {"name": TEST_USER_NAME}, "stats": { From 28c24e5fefc5fcc4252204c881c293c7ac968b37 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:08:58 -0400 Subject: [PATCH 0076/1309] Bump `nice-go` to 0.3.8 (#124872) * Bump nice-go to 0.3.6 * Bump to 0.3.7 * Bump to 0.3.8 --- homeassistant/components/nice_go/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index 45dd3c8b5b4..884f2eb7b18 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nice_go", "iot_class": "cloud_push", "loggers": ["nice-go"], - "requirements": ["nice-go==0.3.5"] + "requirements": ["nice-go==0.3.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 48dbabd47d3..9c873e247a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1433,7 +1433,7 @@ nextdns==3.2.0 nibe==2.11.0 # homeassistant.components.nice_go -nice-go==0.3.5 +nice-go==0.3.8 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c70d7bc4c4..cf3d84208a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1190,7 +1190,7 @@ nextdns==3.2.0 nibe==2.11.0 # homeassistant.components.nice_go -nice-go==0.3.5 +nice-go==0.3.8 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From cb742a677c41c257189d1ea75b370560dabd882e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 30 Aug 2024 08:31:24 -0700 Subject: [PATCH 0077/1309] Add Google Photos reauth support (#124933) * Add Google Photos reauth support * Update tests/components/google_photos/test_config_flow.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/google_photos/__init__.py | 10 +- .../components/google_photos/config_flow.py | 30 ++- tests/components/google_photos/conftest.py | 31 ++- .../google_photos/test_config_flow.py | 192 ++++++++++++++---- tests/components/google_photos/test_init.py | 4 +- .../google_photos/test_media_source.py | 30 +-- 6 files changed, 230 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index ab1ee4a63a4..643ad0b41ad 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -6,7 +6,7 @@ from aiohttp import ClientError, ClientResponseError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow from . import api @@ -32,7 +32,13 @@ async def async_setup_entry( auth = api.AsyncConfigEntryAuth(hass, session) try: await auth.async_get_access_token() - except (ClientResponseError, ClientError) as err: + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from err + raise ConfigEntryNotReady from err + except ClientError as err: raise ConfigEntryNotReady from err entry.runtime_data = auth return True diff --git a/homeassistant/components/google_photos/config_flow.py b/homeassistant/components/google_photos/config_flow.py index 9bc4b35b6b4..93f0347e32f 100644 --- a/homeassistant/components/google_photos/config_flow.py +++ b/homeassistant/components/google_photos/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Google Photos.""" +from collections.abc import Mapping import logging from typing import Any @@ -7,7 +8,7 @@ from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers import config_entry_oauth2_flow -from . import api +from . import GooglePhotosConfigEntry, api from .const import DOMAIN, OAUTH2_SCOPES from .exceptions import GooglePhotosApiError @@ -19,6 +20,8 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + reauth_entry: GooglePhotosConfigEntry | None = None + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -49,6 +52,31 @@ class OAuth2FlowHandler( self.logger.exception("Unknown error occurred") return self.async_abort(reason="unknown") user_id = user_resource_info["id"] + + if self.reauth_entry: + if self.reauth_entry.unique_id == user_id: + return self.async_update_reload_and_abort( + self.reauth_entry, unique_id=user_id, data=data + ) + return self.async_abort(reason="wrong_account") + await self.async_set_unique_id(user_id) self._abort_if_unique_id_configured() return self.async_create_entry(title=user_resource_info["name"], data=data) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index 874e55f0d33..84ed717895d 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -18,16 +18,18 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_array_fixture USER_IDENTIFIER = "user-identifier-1" +CONFIG_ENTRY_ID = "user-identifier-1" CLIENT_ID = "1234" CLIENT_SECRET = "5678" FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" +EXPIRES_IN = 3600 @pytest.fixture(name="expires_at") def mock_expires_at() -> int: """Fixture to set the oauth token expiration time.""" - return time.time() + 3600 + return time.time() + EXPIRES_IN @pytest.fixture(name="token_entry") @@ -37,17 +39,26 @@ def mock_token_entry(expires_at: int) -> dict[str, Any]: "access_token": FAKE_ACCESS_TOKEN, "refresh_token": FAKE_REFRESH_TOKEN, "scope": " ".join(OAUTH2_SCOPES), - "token_type": "Bearer", + "type": "Bearer", "expires_at": expires_at, + "expires_in": EXPIRES_IN, } +@pytest.fixture(name="config_entry_id") +def mock_config_entry_id() -> str | None: + """Provide a json fixture file to load for list media item api responses.""" + return CONFIG_ENTRY_ID + + @pytest.fixture(name="config_entry") -def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: +def mock_config_entry( + config_entry_id: str, token_entry: dict[str, Any] +) -> MockConfigEntry: """Fixture for a config entry.""" return MockConfigEntry( domain=DOMAIN, - unique_id="config-entry-id-123", + unique_id=config_entry_id, data={ "auth_implementation": DOMAIN, "token": token_entry, @@ -73,12 +84,20 @@ def mock_fixture_name() -> str | None: return None +@pytest.fixture(name="user_identifier") +def mock_user_identifier() -> str | None: + """Provide a json fixture file to load for list media item api responses.""" + return USER_IDENTIFIER + + @pytest.fixture(name="setup_api") -def mock_setup_api(fixture_name: str) -> Generator[Mock, None, None]: +def mock_setup_api( + fixture_name: str, user_identifier: str +) -> Generator[Mock, None, None]: """Set up fake Google Photos API responses from fixtures.""" with patch("homeassistant.components.google_photos.api.build") as mock: mock.return_value.userinfo.return_value.get.return_value.execute.return_value = { - "id": USER_IDENTIFIER, + "id": user_identifier, "name": "Test Name", } diff --git a/tests/components/google_photos/test_config_flow.py b/tests/components/google_photos/test_config_flow.py index e9f2a68f2f5..4bd933a7eb8 100644 --- a/tests/components/google_photos/test_config_flow.py +++ b/tests/components/google_photos/test_config_flow.py @@ -1,5 +1,7 @@ """Test the Google Photos config flow.""" +from collections.abc import Generator +from typing import Any from unittest.mock import Mock, patch from googleapiclient.errors import HttpError @@ -16,9 +18,9 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from .conftest import USER_IDENTIFIER +from .conftest import EXPIRES_IN, FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, USER_IDENTIFIER -from tests.common import load_fixture +from tests.common import MockConfigEntry, load_fixture from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -26,12 +28,44 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" +@pytest.fixture(name="mock_setup") +def mock_setup_entry() -> Generator[Mock, None, None]: + """Fixture to mock out integration setup.""" + with patch( + "homeassistant.components.google_photos.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture(name="updated_token_entry", autouse=True) +def mock_updated_token_entry() -> dict[str, Any]: + """Fixture to provide any test specific overrides to token data from the oauth token endpoint.""" + return {} + + +@pytest.fixture(name="mock_oauth_token_request", autouse=True) +def mock_token_request( + aioclient_mock: AiohttpClientMocker, + token_entry: dict[str, any], + updated_token_entry: dict[str, Any], +) -> None: + """Fixture to provide a fake response from the oauth token endpoint.""" + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + **token_entry, + **updated_token_entry, + }, + ) + + @pytest.mark.usefixtures("current_request_with_host", "setup_api") @pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, + mock_setup: Mock, ) -> None: """Check full flow.""" result = await hass.config_entries.flow.async_init( @@ -59,20 +93,7 @@ async def test_full_flow( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) - - with patch( - "homeassistant.components.google_photos.async_setup_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.unique_id == USER_IDENTIFIER @@ -84,10 +105,14 @@ async def test_full_flow( assert config_entry_data == { "auth_implementation": DOMAIN, "token": { - "access_token": "mock-access-token", - "expires_in": 60, - "refresh_token": "mock-refresh-token", + "access_token": FAKE_ACCESS_TOKEN, + "expires_in": EXPIRES_IN, + "refresh_token": FAKE_REFRESH_TOKEN, "type": "Bearer", + "scope": ( + "https://www.googleapis.com/auth/photoslibrary.readonly" + " https://www.googleapis.com/auth/userinfo.profile" + ), }, } assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -101,7 +126,6 @@ async def test_full_flow( async def test_api_not_enabled( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, setup_api: Mock, ) -> None: """Check flow aborts if api is not enabled.""" @@ -130,16 +154,6 @@ async def test_api_not_enabled( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) - setup_api.return_value.mediaItems.return_value.list = Mock() setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = HttpError( Response({"status": "403"}), @@ -158,7 +172,6 @@ async def test_api_not_enabled( async def test_general_exception( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, ) -> None: """Check flow aborts if exception happens.""" result = await hass.config_entries.flow.async_init( @@ -185,16 +198,6 @@ async def test_general_exception( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - aioclient_mock.post( - OAUTH2_TOKEN, - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) - with patch( "homeassistant.components.google_photos.api.build", side_effect=Exception, @@ -203,3 +206,108 @@ async def test_general_exception( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown" + + +@pytest.mark.usefixtures("current_request_with_host", "setup_api", "setup_integration") +@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) +@pytest.mark.parametrize( + "updated_token_entry", + [ + { + "access_token": "updated-access-token", + } + ], +) +@pytest.mark.parametrize( + ( + "user_identifier", + "abort_reason", + "resulting_access_token", + "expected_setup_calls", + ), + [ + ( + USER_IDENTIFIER, + "reauth_successful", + "updated-access-token", + 1, + ), + ( + "345", + "wrong_account", + FAKE_ACCESS_TOKEN, + 0, + ), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + config_entry: MockConfigEntry, + user_identifier: str, + abort_reason: str, + resulting_access_token: str, + mock_setup: Mock, + expected_setup_calls: int, +) -> None: + """Test the re-authentication case updates the correct config entry.""" + + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/userinfo.profile" + "&access_type=offline&prompt=consent" + ) + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason + + assert config_entry.unique_id == USER_IDENTIFIER + assert config_entry.title == "Account Name" + config_entry_data = dict(config_entry.data) + assert "token" in config_entry_data + assert "expires_at" in config_entry_data["token"] + del config_entry_data["token"]["expires_at"] + assert config_entry_data == { + "auth_implementation": DOMAIN, + "token": { + # Verify token is refreshed or not + "access_token": resulting_access_token, + "expires_in": EXPIRES_IN, + "refresh_token": FAKE_REFRESH_TOKEN, + "type": "Bearer", + "scope": ( + "https://www.googleapis.com/auth/photoslibrary.readonly" + " https://www.googleapis.com/auth/userinfo.profile" + ), + }, + } + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == expected_setup_calls diff --git a/tests/components/google_photos/test_init.py b/tests/components/google_photos/test_init.py index a2f835c8611..ea236cfc712 100644 --- a/tests/components/google_photos/test_init.py +++ b/tests/components/google_photos/test_init.py @@ -80,9 +80,9 @@ async def test_expired_token_refresh_success( [ ( time.time() - 3600, - http.HTTPStatus.NOT_FOUND, + http.HTTPStatus.UNAUTHORIZED, None, - ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_ERROR, # Reauth ), ( time.time() - 3600, diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index 31c84f4811c..b24b37c10e6 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -17,6 +17,8 @@ from homeassistant.components.media_source import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from .conftest import CONFIG_ENTRY_ID + from tests.common import MockConfigEntry @@ -52,8 +54,8 @@ async def test_no_config_entries( ( "list_mediaitems.json", [ - ("config-entry-id-123/p/id1", "example1.jpg"), - ("config-entry-id-123/p/id2", "example2.mp4"), + (f"{CONFIG_ENTRY_ID}/p/id1", "example1.jpg"), + (f"{CONFIG_ENTRY_ID}/p/id2", "example2.mp4"), ], [ ("http://img.example.com/id1=w2048", "image/jpeg"), @@ -73,22 +75,22 @@ async def test_recent_items( assert browse.identifier is None assert browse.title == "Google Photos" assert [(child.identifier, child.title) for child in browse.children] == [ - ("config-entry-id-123", "Account Name") + (CONFIG_ENTRY_ID, "Account Name") ] - browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123") + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}") assert browse.domain == DOMAIN - assert browse.identifier == "config-entry-id-123" + assert browse.identifier == CONFIG_ENTRY_ID assert browse.title == "Account Name" assert [(child.identifier, child.title) for child in browse.children] == [ - ("config-entry-id-123/a/recent", "Recent Photos") + (f"{CONFIG_ENTRY_ID}/a/recent", "Recent Photos") ] browse = await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" + hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" ) assert browse.domain == DOMAIN - assert browse.identifier == "config-entry-id-123" + assert browse.identifier == CONFIG_ENTRY_ID assert browse.title == "Account Name" assert [ (child.identifier, child.title) for child in browse.children @@ -121,12 +123,12 @@ async def test_invalid_album_id(hass: HomeAssistant) -> None: assert browse.identifier is None assert browse.title == "Google Photos" assert [(child.identifier, child.title) for child in browse.children] == [ - ("config-entry-id-123", "Account Name") + (CONFIG_ENTRY_ID, "Account Name") ] with pytest.raises(BrowseError, match="Unsupported album"): await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/invalid-album-id" + hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/invalid-album-id" ) @@ -164,7 +166,7 @@ async def test_list_media_items_failure( assert browse.identifier is None assert browse.title == "Google Photos" assert [(child.identifier, child.title) for child in browse.children] == [ - ("config-entry-id-123", "Account Name") + (CONFIG_ENTRY_ID, "Account Name") ] setup_api.return_value.mediaItems.return_value.list = Mock() @@ -172,7 +174,7 @@ async def test_list_media_items_failure( with pytest.raises(BrowseError, match="Error listing media items"): await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" + hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" ) @@ -191,9 +193,9 @@ async def test_media_items_error_parsing_response(hass: HomeAssistant) -> None: assert browse.identifier is None assert browse.title == "Google Photos" assert [(child.identifier, child.title) for child in browse.children] == [ - ("config-entry-id-123", "Account Name") + (CONFIG_ENTRY_ID, "Account Name") ] with pytest.raises(BrowseError, match="Error listing media items"): await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/config-entry-id-123/a/recent" + hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" ) From 910fb0930ebba96f9b606cf6db2c35bd5293a61d Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 30 Aug 2024 08:34:27 -0700 Subject: [PATCH 0078/1309] Attempt to fix IndexError in Opower (#124478) * Change the order of async_add_external_statistics in Opower * Use consumption_statistic_id instead of cost_statistic_id --- homeassistant/components/opower/coordinator.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 9cef4e4a252..3249cf1a375 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -110,7 +110,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ) last_stat = await get_instance(self.hass).async_add_executor_job( - get_last_statistics, self.hass, 1, cost_statistic_id, True, set() + get_last_statistics, self.hass, 1, consumption_statistic_id, True, set() ) if not last_stat: _LOGGER.debug("Updating statistic for the first time") @@ -124,7 +124,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): cost_reads = await self._async_get_cost_reads( account, self.api.utility.timezone(), - last_stat[cost_statistic_id][0]["start"], + last_stat[consumption_statistic_id][0]["start"], ) if not cost_reads: _LOGGER.debug("No recent usage/cost data. Skipping update") @@ -141,7 +141,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ) cost_sum = cast(float, stats[cost_statistic_id][0]["sum"]) consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"]) - last_stats_time = stats[cost_statistic_id][0]["start"] + last_stats_time = stats[consumption_statistic_id][0]["start"] cost_statistics = [] consumption_statistics = [] @@ -187,7 +187,17 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): else UnitOfVolume.CENTUM_CUBIC_FEET, ) + _LOGGER.debug( + "Adding %s statistics for %s", + len(cost_statistics), + cost_statistic_id, + ) async_add_external_statistics(self.hass, cost_metadata, cost_statistics) + _LOGGER.debug( + "Adding %s statistics for %s", + len(consumption_statistics), + consumption_statistic_id, + ) async_add_external_statistics( self.hass, consumption_metadata, consumption_statistics ) From 7868ffac35d37960708a95b2b55ecee4a2945bc4 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Fri, 30 Aug 2024 20:21:27 +0200 Subject: [PATCH 0079/1309] Enable strict typing checking for bluesound integration (#123821) * Enable strict typing * Fix types * Update to pyblu 0.5.2 for typing support * Update pyblu to 1.0.0 * Update pyblu to 1.0.1 * Update error handling * Fix tests * Remove return None from methods only returning None --- .strict-typing | 1 + .../components/bluesound/__init__.py | 14 ++--- .../components/bluesound/config_flow.py | 12 ++-- .../components/bluesound/manifest.json | 2 +- .../components/bluesound/media_player.py | 59 ++++++++----------- mypy.ini | 10 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/bluesound/test_config_flow.py | 8 +-- 9 files changed, 56 insertions(+), 54 deletions(-) diff --git a/.strict-typing b/.strict-typing index d77c12293c4..9e91272c37d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -110,6 +110,7 @@ homeassistant.components.bitcoin.* homeassistant.components.blockchain.* homeassistant.components.blue_current.* homeassistant.components.blueprint.* +homeassistant.components.bluesound.* homeassistant.components.bluetooth.* homeassistant.components.bluetooth_adapters.* homeassistant.components.bluetooth_tracker.* diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index cbe95fc3abf..da74ed042be 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -2,8 +2,8 @@ from dataclasses import dataclass -import aiohttp from pyblu import Player, SyncStatus +from pyblu.errors import PlayerUnreachableError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform @@ -22,14 +22,14 @@ PLATFORMS = [Platform.MEDIA_PLAYER] @dataclass -class BluesoundData: +class BluesoundRuntimeData: """Bluesound data class.""" player: Player sync_status: SyncStatus -type BluesoundConfigEntry = ConfigEntry[BluesoundData] +type BluesoundConfigEntry = ConfigEntry[BluesoundRuntimeData] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -51,14 +51,10 @@ async def async_setup_entry( async with Player(host, port, session=session, default_timeout=10) as player: try: sync_status = await player.sync_status(timeout=1) - except TimeoutError as ex: - raise ConfigEntryNotReady( - f"Timeout while connecting to {host}:{port}" - ) from ex - except aiohttp.ClientError as ex: + except PlayerUnreachableError as ex: raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex - config_entry.runtime_data = BluesoundData(player, sync_status) + config_entry.runtime_data = BluesoundRuntimeData(player, sync_status) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py index aae527187d2..050b3ee4eac 100644 --- a/homeassistant/components/bluesound/config_flow.py +++ b/homeassistant/components/bluesound/config_flow.py @@ -3,8 +3,8 @@ import logging from typing import Any -import aiohttp from pyblu import Player, SyncStatus +from pyblu.errors import PlayerUnreachableError import voluptuous as vol from homeassistant.components import zeroconf @@ -43,7 +43,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): ) as player: try: sync_status = await player.sync_status(timeout=1) - except (TimeoutError, aiohttp.ClientError): + except PlayerUnreachableError: errors["base"] = "cannot_connect" else: await self.async_set_unique_id( @@ -79,7 +79,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): ) as player: try: sync_status = await player.sync_status(timeout=1) - except (TimeoutError, aiohttp.ClientError): + except PlayerUnreachableError: return self.async_abort(reason="cannot_connect") await self.async_set_unique_id( @@ -105,7 +105,7 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): discovery_info.host, self._port, session=session ) as player: sync_status = await player.sync_status(timeout=1) - except (TimeoutError, aiohttp.ClientError): + except PlayerUnreachableError: return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(format_unique_id(sync_status.mac, self._port)) @@ -127,7 +127,9 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): ) return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None) -> ConfigFlowResult: + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Confirm the zeroconf setup.""" assert self._sync_status is not None assert self._host is not None diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 64b8e8abffc..13514f52893 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bluesound", "iot_class": "local_polling", - "requirements": ["pyblu==0.4.0"], + "requirements": ["pyblu==1.0.1"], "zeroconf": [ { "type": "_musc._tcp.local." diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 1ed53d7bfc5..cd1d9510eaa 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -9,8 +9,8 @@ from datetime import datetime, timedelta import logging from typing import TYPE_CHECKING, Any, NamedTuple -from aiohttp.client_exceptions import ClientError from pyblu import Input, Player, Preset, Status, SyncStatus +from pyblu.errors import PlayerUnreachableError import voluptuous as vol from homeassistant.components import media_source @@ -239,7 +239,7 @@ class BluesoundPlayer(MediaPlayerEntity): self.port = port self._polling_task: Task[None] | None = None # The actual polling task. self._id = sync_status.id - self._last_status_update = None + self._last_status_update: datetime | None = None self._sync_status = sync_status self._status: Status | None = None self._inputs: list[Input] = [] @@ -247,7 +247,7 @@ class BluesoundPlayer(MediaPlayerEntity): self._muted = False self._master: BluesoundPlayer | None = None self._is_master = False - self._group_name = None + self._group_name: str | None = None self._group_list: list[str] = [] self._bluesound_device_name = sync_status.name self._player = player @@ -273,14 +273,6 @@ class BluesoundPlayer(MediaPlayerEntity): via_device=(DOMAIN, format_mac(sync_status.mac)), ) - @staticmethod - def _try_get_index(string, search_string): - """Get the index.""" - try: - return string.index(search_string) - except ValueError: - return -1 - async def force_update_sync_status(self) -> bool: """Update the internal status.""" sync_status = await self._player.sync_status() @@ -309,12 +301,12 @@ class BluesoundPlayer(MediaPlayerEntity): return True - async def _poll_loop(self): + async def _poll_loop(self) -> None: """Loop which polls the status of the player.""" while True: try: await self.async_update_status() - except (TimeoutError, ClientError): + except PlayerUnreachableError: _LOGGER.error( "Node %s:%s is offline, retrying later", self.host, self.port ) @@ -324,9 +316,9 @@ class BluesoundPlayer(MediaPlayerEntity): "Stopping the polling of node %s:%s", self.host, self.port ) return - except Exception: + except: # noqa: E722 - this loop should never stop _LOGGER.exception( - "Unexpected error in %s:%s, retrying later", self.host, self.port + "Unexpected error for %s:%s, retrying later", self.host, self.port ) await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) @@ -356,12 +348,12 @@ class BluesoundPlayer(MediaPlayerEntity): if not self.available: return - with suppress(TimeoutError): + with suppress(PlayerUnreachableError): await self.async_update_sync_status() await self.async_update_presets() await self.async_update_captures() - async def async_update_status(self): + async def async_update_status(self) -> None: """Use the poll session to always get the status of the player.""" etag = None if self._status is not None: @@ -394,11 +386,11 @@ class BluesoundPlayer(MediaPlayerEntity): # the device is playing. This would solve a lot of # problems. This change will be done when the # communication is moved to a separate library - with suppress(TimeoutError): + with suppress(PlayerUnreachableError): await self.force_update_sync_status() self.async_write_ha_state() - except (TimeoutError, ClientError): + except PlayerUnreachableError: self._attr_available = False self._last_status_update = None self._status = None @@ -409,7 +401,7 @@ class BluesoundPlayer(MediaPlayerEntity): ) raise - async def async_trigger_sync_on_all(self): + async def async_trigger_sync_on_all(self) -> None: """Trigger sync status update on all devices.""" _LOGGER.debug("Trigger sync status on all devices") @@ -417,7 +409,7 @@ class BluesoundPlayer(MediaPlayerEntity): await player.force_update_sync_status() @Throttle(SYNC_STATUS_INTERVAL) - async def async_update_sync_status(self): + async def async_update_sync_status(self) -> None: """Update sync status.""" await self.force_update_sync_status() @@ -506,8 +498,6 @@ class BluesoundPlayer(MediaPlayerEntity): return None position = self._status.seconds - if position is None: - return None if mediastate == MediaPlayerState.PLAYING: position += (dt_util.utcnow() - self._last_status_update).total_seconds() @@ -524,7 +514,7 @@ class BluesoundPlayer(MediaPlayerEntity): if duration is None: return None - return duration + return int(duration) @property def media_position_updated_at(self) -> datetime | None: @@ -660,7 +650,7 @@ class BluesoundPlayer(MediaPlayerEntity): return shuffle - async def async_join(self, master): + async def async_join(self, master: str) -> None: """Join the player to a group.""" master_device = [ device @@ -711,7 +701,7 @@ class BluesoundPlayer(MediaPlayerEntity): if entity.bluesound_device_name in device_group ] - async def async_unjoin(self): + async def async_unjoin(self) -> None: """Unjoin the player from a group.""" if self._master is None: return @@ -719,11 +709,11 @@ class BluesoundPlayer(MediaPlayerEntity): _LOGGER.debug("Trying to unjoin player: %s", self.id) await self._master.async_remove_slave(self) - async def async_add_slave(self, slave_device: BluesoundPlayer): + async def async_add_slave(self, slave_device: BluesoundPlayer) -> None: """Add slave to master.""" await self._player.add_slave(slave_device.host, slave_device.port) - async def async_remove_slave(self, slave_device: BluesoundPlayer): + async def async_remove_slave(self, slave_device: BluesoundPlayer) -> None: """Remove slave to master.""" await self._player.remove_slave(slave_device.host, slave_device.port) @@ -731,7 +721,7 @@ class BluesoundPlayer(MediaPlayerEntity): """Increase sleep time on player.""" return await self._player.sleep_timer() - async def async_clear_timer(self): + async def async_clear_timer(self) -> None: """Clear sleep timer on player.""" sleep = 1 while sleep > 0: @@ -755,6 +745,9 @@ class BluesoundPlayer(MediaPlayerEntity): if preset.name == source: url = preset.url + if url is None: + raise ServiceValidationError(f"Source {source} not found") + await self._player.play_url(url) async def async_clear_playlist(self) -> None: @@ -826,20 +819,20 @@ class BluesoundPlayer(MediaPlayerEntity): async def async_volume_up(self) -> None: """Volume up the media player.""" if self.volume_level is None: - return None + return new_volume = self.volume_level + 0.01 new_volume = min(1, new_volume) - return await self.async_set_volume_level(new_volume) + await self.async_set_volume_level(new_volume) async def async_volume_down(self) -> None: """Volume down the media player.""" if self.volume_level is None: - return None + return new_volume = self.volume_level - 0.01 new_volume = max(0, new_volume) - return await self.async_set_volume_level(new_volume) + await self.async_set_volume_level(new_volume) async def async_set_volume_level(self, volume: float) -> None: """Send volume_up command to media player.""" diff --git a/mypy.ini b/mypy.ini index 817060ac869..873cf1f66bd 100644 --- a/mypy.ini +++ b/mypy.ini @@ -855,6 +855,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.bluesound.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.bluetooth.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 9c873e247a5..570c16db626 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1763,7 +1763,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==0.4.0 +pyblu==1.0.1 # homeassistant.components.neato pybotvac==0.0.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf3d84208a9..b1be638d4e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1428,7 +1428,7 @@ pybalboa==1.0.2 pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==0.4.0 +pyblu==1.0.1 # homeassistant.components.neato pybotvac==0.0.25 diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py index 8fecba7017d..53cf40a8d46 100644 --- a/tests/components/bluesound/test_config_flow.py +++ b/tests/components/bluesound/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiohttp import ClientConnectionError +from pyblu.errors import PlayerUnreachableError from homeassistant.components.bluesound.const import DOMAIN from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -49,7 +49,7 @@ async def test_user_flow_cannot_connect( context={"source": SOURCE_USER}, ) - mock_player.sync_status.side_effect = ClientConnectionError + mock_player.sync_status.side_effect = PlayerUnreachableError("Player not reachable") result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -129,7 +129,7 @@ async def test_import_flow_cannot_connect( hass: HomeAssistant, mock_player: AsyncMock ) -> None: """Test we handle cannot connect error.""" - mock_player.sync_status.side_effect = ClientConnectionError + mock_player.sync_status.side_effect = PlayerUnreachableError("Player not reachable") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, @@ -200,7 +200,7 @@ async def test_zeroconf_flow_cannot_connect( hass: HomeAssistant, mock_player: AsyncMock ) -> None: """Test we handle cannot connect error.""" - mock_player.sync_status.side_effect = ClientConnectionError + mock_player.sync_status.side_effect = PlayerUnreachableError("Player not reachable") result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, From ed161d3d49ff3277bb6b6366c69f08e0d615ed50 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 30 Aug 2024 19:43:28 +0100 Subject: [PATCH 0080/1309] Bump python-kasa to 0.7.2 (#124930) --- 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 10b0ef61153..0d9761ec8ce 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.1"] + "requirements": ["python-kasa[speedups]==0.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 570c16db626..c63778c604e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2320,7 +2320,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.1 +python-kasa[speedups]==0.7.2 # homeassistant.components.linkplay python-linkplay==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1be638d4e9..cd6db30def9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1838,7 +1838,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.1 +python-kasa[speedups]==0.7.2 # homeassistant.components.linkplay python-linkplay==0.0.8 From 29a17edaa532f0d4112d006c89dfb895a8abadaf Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 30 Aug 2024 19:56:30 +0100 Subject: [PATCH 0081/1309] Exclude tplink firmware entities (#124935) Co-authored-by: J. Nick Koston --- homeassistant/components/tplink/entity.py | 2 ++ tests/components/tplink/fixtures/features.json | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 4ec0480cf82..beb71d4e5ce 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -68,6 +68,8 @@ EXCLUDED_FEATURES = { # update "current_firmware_version", "available_firmware_version", + "update_available", + "check_latest_firmware", } diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index 7cfe979ea25..6d4afd98d15 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -150,6 +150,11 @@ "type": "Sensor", "category": "Debug" }, + "check_latest_firmware": { + "value": "", + "type": "Action", + "category": "Info" + }, "thermostat_mode": { "value": "off", "type": "Sensor", From 460363c4ba2bbe613fafaadc0d8c5333fef9fa74 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Aug 2024 09:05:16 -1000 Subject: [PATCH 0082/1309] Bump aioshelly to 11.4.1 to accomodate shelly GetStatus calls that take a few seconds to respond (#124893) Co-authored-by: Shay Levy --- homeassistant/components/shelly/coordinator.py | 3 +-- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/shelly/test_coordinator.py | 4 ++-- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 012f6b43dc7..c8e6cc03a06 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -793,8 +793,7 @@ class ShellyRpcPollingCoordinator(ShellyCoordinatorBase[RpcDevice]): LOGGER.debug("Polling Shelly RPC Device - %s", self.name) try: - await self.device.update_status() - await self.device.get_dynamic_components() + await self.device.poll() except (DeviceConnectionError, RpcCallError) as err: raise UpdateFailed(f"Device disconnected: {err!r}") from err except InvalidAuthError: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index a384255705c..f9fa2d571d1 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==11.3.0"], + "requirements": ["aioshelly==11.4.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index c63778c604e..2b37b515a3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -359,7 +359,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==11.3.0 +aioshelly==11.4.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd6db30def9..3319870591e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -341,7 +341,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==11.3.0 +aioshelly==11.4.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index bb9694cf9b4..47c338e3fad 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -678,7 +678,7 @@ async def test_rpc_polling_auth_error( monkeypatch.setattr( mock_rpc_device, - "update_status", + "poll", AsyncMock( side_effect=InvalidAuthError, ), @@ -768,7 +768,7 @@ async def test_rpc_polling_connection_error( monkeypatch.setattr( mock_rpc_device, - "update_status", + "poll", AsyncMock( side_effect=DeviceConnectionError, ), From 8c2e63807cb867182d31d1755a4169b63488c2b0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 30 Aug 2024 22:02:10 +0200 Subject: [PATCH 0083/1309] Make set_value required in number template (#124917) * Make set_value required in number template * Make set_value required in number template * Fix tests --- homeassistant/components/template/number.py | 9 ++++----- tests/components/template/test_config_flow.py | 20 +++++++++++++++++++ tests/components/template/test_init.py | 10 ++++++++++ tests/components/template/test_number.py | 10 ++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 955600a9b9e..499ddc192cc 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -70,7 +70,7 @@ NUMBER_CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_NAME): cv.template, vol.Required(CONF_STATE): cv.template, vol.Required(CONF_STEP): cv.template, - vol.Optional(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, vol.Optional(CONF_MIN): cv.template, vol.Optional(CONF_MAX): cv.template, vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), @@ -154,11 +154,10 @@ class TemplateNumber(TemplateEntity, NumberEntity): super().__init__(hass, config=config, unique_id=unique_id) assert self._attr_name is not None self._value_template = config[CONF_STATE] - self._command_set_value = ( - Script(hass, config[CONF_SET_VALUE], self._attr_name, DOMAIN) - if config.get(CONF_SET_VALUE, None) is not None - else None + self._command_set_value = Script( + hass, config[CONF_SET_VALUE], self._attr_name, DOMAIN ) + self._step_template = config[CONF_STEP] self._min_value_template = config[CONF_MIN] self._max_value_template = config[CONF_MAX] diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index a62370f4261..f8ab190e664 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -101,11 +101,21 @@ from tests.typing import WebSocketGenerator "min": "{{ 0 }}", "max": "{{ 100 }}", "step": "{{ 0.1 }}", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, { "min": "{{ 0 }}", "max": "{{ 100 }}", "step": "{{ 0.1 }}", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, {}, ), @@ -444,11 +454,21 @@ def get_suggested(schema, key): "min": "{{ 0 }}", "max": "{{ 100 }}", "step": "{{ 0.1 }}", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, { "min": "{{ 0 }}", "max": "{{ 100 }}", "step": "{{ 0.1 }}", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, "state", ), diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 06d59d4d176..3b4db4bf668 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -322,12 +322,22 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None: "min": "{{ 0 }}", "max": "{{ 100 }}", "step": "{{ 0.1 }}", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, { "state": "{{ 11 }}", "min": "{{ 0 }}", "max": "{{ 100 }}", "step": "{{ 0.1 }}", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, ), ( diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index c8befc2b8f8..fdca94d9fa4 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -61,6 +61,11 @@ async def test_setup_config_entry( "min": "{{ 0 }}", "max": "{{ 100 }}", "step": "{{ 0.1 }}", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, }, title="My template", ) @@ -522,6 +527,11 @@ async def test_device_id( "min": "{{ 0 }}", "max": "{{ 100 }}", "step": "{{ 0.1 }}", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, "device_id": device_entry.id, }, title="My template", From 933ae143b3147f9bb1c91f7adacc7ddb15e4fb29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Aug 2024 10:32:09 -1000 Subject: [PATCH 0084/1309] Bump google-cloud-texttospeech to 2.17.2 (#124938) changelog: https://github.com/googleapis/google-cloud-python/compare/google-cloud-texttospeech-v2.16.3...google-cloud-texttospeech-v2.17.2 --- homeassistant/components/google_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index b4fc3f39b86..052fa79eef4 100644 --- a/homeassistant/components/google_cloud/manifest.json +++ b/homeassistant/components/google_cloud/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@lufton"], "documentation": "https://www.home-assistant.io/integrations/google_cloud", "iot_class": "cloud_push", - "requirements": ["google-cloud-texttospeech==2.16.3"] + "requirements": ["google-cloud-texttospeech==2.17.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2b37b515a3c..9fd769185ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -987,7 +987,7 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.13.11 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.16.3 +google-cloud-texttospeech==2.17.2 # homeassistant.components.google_generative_ai_conversation google-generativeai==0.6.0 From 66ddf44399005870a80bc4b7ede904706109a0b7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Aug 2024 10:32:23 -1000 Subject: [PATCH 0085/1309] Bump google-cloud-pubsub to 2.23.0 (#124937) changelog: https://github.com/googleapis/python-pubsub/compare/v2.13.11...v2.23.0 --- homeassistant/components/google_pubsub/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_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json index f22317404ab..aa13f1808c4 100644 --- a/homeassistant/components/google_pubsub/manifest.json +++ b/homeassistant/components/google_pubsub/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/google_pubsub", "iot_class": "cloud_push", - "requirements": ["google-cloud-pubsub==2.13.11"] + "requirements": ["google-cloud-pubsub==2.23.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9fd769185ac..7b9666742fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -984,7 +984,7 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.13.11 +google-cloud-pubsub==2.23.0 # homeassistant.components.google_cloud google-cloud-texttospeech==2.17.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3319870591e..2dd8eb468a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -834,7 +834,7 @@ goodwe==0.3.6 google-api-python-client==2.71.0 # homeassistant.components.google_pubsub -google-cloud-pubsub==2.13.11 +google-cloud-pubsub==2.23.0 # homeassistant.components.google_generative_ai_conversation google-generativeai==0.6.0 From 8cafa1bcdf9d98d8d840b114f33b0264e4e1426d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Aug 2024 10:33:26 -1000 Subject: [PATCH 0086/1309] Bump google-generativeai to 0.7.2 (#124940) changelog: https://github.com/google-gemini/generative-ai-python/compare/v0.6.0...v0.7.2 --- .../components/google_generative_ai_conversation/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_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 9e0dc1ddeab..a15da4906f8 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["google-generativeai==0.6.0"] + "requirements": ["google-generativeai==0.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7b9666742fe..980ba8d94e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -990,7 +990,7 @@ google-cloud-pubsub==2.23.0 google-cloud-texttospeech==2.17.2 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.6.0 +google-generativeai==0.7.2 # homeassistant.components.nest google-nest-sdm==5.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2dd8eb468a0..f766c8c13d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -837,7 +837,7 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.23.0 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.6.0 +google-generativeai==0.7.2 # homeassistant.components.nest google-nest-sdm==5.0.0 From 0a9e20615ec9a468d398a0d3a52e0462d5c3ba98 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Aug 2024 10:33:57 -1000 Subject: [PATCH 0087/1309] Limit maximum template render output to 256KiB (#124946) * Limit maximum template render output to 256KiB fixes #124931 256KiB is likely to still block the event loop for an unreasonable amont of time but its likely someone is using the template engine for large blocks of data so we want a limit which still allows that but has a reasonable safety to prevent the system from crashing down * Update homeassistant/helpers/template.py --- homeassistant/helpers/template.py | 6 ++++++ tests/helpers/test_template.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index e090e0de2d1..0a980db30b4 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -149,6 +149,7 @@ CACHED_TEMPLATE_STATES = 512 EVAL_CACHE_SIZE = 512 MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 +MAX_TEMPLATE_OUTPUT = 256 * 1024 # 256KiB CACHED_TEMPLATE_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) CACHED_TEMPLATE_NO_COLLECT_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) @@ -604,6 +605,11 @@ class Template: except Exception as err: raise TemplateError(err) from err + if len(render_result) > MAX_TEMPLATE_OUTPUT: + raise TemplateError( + f"Template output exceeded maximum size of {MAX_TEMPLATE_OUTPUT} characters" + ) + render_result = render_result.strip() if not parse_result or self.hass and self.hass.config.legacy_templates: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 0676ae21ab7..f585b5c3260 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6281,3 +6281,10 @@ def test_unzip(hass: HomeAssistant, col, expected) -> None: ).async_render({"col": col}) == expected ) + + +def test_template_output_exceeds_maximum_size(hass: HomeAssistant) -> None: + """Test template output exceeds maximum size.""" + tpl = template.Template("{{ 'a' * 1024 * 257 }}", hass) + with pytest.raises(TemplateError): + tpl.async_render() From ac39bf991faf28ee597a223836774481eda7cae7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 30 Aug 2024 22:34:34 +0200 Subject: [PATCH 0088/1309] Rename lg_thinq domain name (#124926) --- CODEOWNERS | 4 ++-- homeassistant/brands/lg.json | 2 +- .../components/{lgthinq => lg_thinq}/__init__.py | 0 .../components/{lgthinq => lg_thinq}/config_flow.py | 0 .../components/{lgthinq => lg_thinq}/const.py | 2 +- .../components/{lgthinq => lg_thinq}/coordinator.py | 0 .../components/{lgthinq => lg_thinq}/entity.py | 0 .../components/{lgthinq => lg_thinq}/icons.json | 0 .../components/{lgthinq => lg_thinq}/manifest.json | 2 +- .../components/{lgthinq => lg_thinq}/strings.json | 0 .../components/{lgthinq => lg_thinq}/switch.py | 0 homeassistant/generated/config_flows.py | 2 +- homeassistant/generated/integrations.json | 12 ++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/{lgthinq => lg_thinq}/__init__.py | 0 tests/components/{lgthinq => lg_thinq}/conftest.py | 6 +++--- tests/components/{lgthinq => lg_thinq}/const.py | 0 .../{lgthinq => lg_thinq}/test_config_flow.py | 2 +- 19 files changed, 18 insertions(+), 18 deletions(-) rename homeassistant/components/{lgthinq => lg_thinq}/__init__.py (100%) rename homeassistant/components/{lgthinq => lg_thinq}/config_flow.py (100%) rename homeassistant/components/{lgthinq => lg_thinq}/const.py (99%) rename homeassistant/components/{lgthinq => lg_thinq}/coordinator.py (100%) rename homeassistant/components/{lgthinq => lg_thinq}/entity.py (100%) rename homeassistant/components/{lgthinq => lg_thinq}/icons.json (100%) rename homeassistant/components/{lgthinq => lg_thinq}/manifest.json (92%) rename homeassistant/components/{lgthinq => lg_thinq}/strings.json (100%) rename homeassistant/components/{lgthinq => lg_thinq}/switch.py (100%) rename tests/components/{lgthinq => lg_thinq}/__init__.py (100%) rename tests/components/{lgthinq => lg_thinq}/conftest.py (90%) rename tests/components/{lgthinq => lg_thinq}/const.py (100%) rename tests/components/{lgthinq => lg_thinq}/test_config_flow.py (96%) diff --git a/CODEOWNERS b/CODEOWNERS index 8ae6aa367b5..3b250ceb9ab 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -805,8 +805,8 @@ build.json @home-assistant/supervisor /tests/components/lektrico/ @lektrico /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 -/homeassistant/components/lgthinq/ @LG-ThinQ-Integration -/tests/components/lgthinq/ @LG-ThinQ-Integration +/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration +/tests/components/lg_thinq/ @LG-ThinQ-Integration /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob /homeassistant/components/lifx/ @Djelibeybi diff --git a/homeassistant/brands/lg.json b/homeassistant/brands/lg.json index 350db80b5f3..6b706685f1f 100644 --- a/homeassistant/brands/lg.json +++ b/homeassistant/brands/lg.json @@ -1,5 +1,5 @@ { "domain": "lg", "name": "LG", - "integrations": ["lg_netcast", "lg_soundbar", "webostv"] + "integrations": ["lg_netcast", "lg_thinq", "lg_soundbar", "webostv"] } diff --git a/homeassistant/components/lgthinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py similarity index 100% rename from homeassistant/components/lgthinq/__init__.py rename to homeassistant/components/lg_thinq/__init__.py diff --git a/homeassistant/components/lgthinq/config_flow.py b/homeassistant/components/lg_thinq/config_flow.py similarity index 100% rename from homeassistant/components/lgthinq/config_flow.py rename to homeassistant/components/lg_thinq/config_flow.py diff --git a/homeassistant/components/lgthinq/const.py b/homeassistant/components/lg_thinq/const.py similarity index 99% rename from homeassistant/components/lgthinq/const.py rename to homeassistant/components/lg_thinq/const.py index 9b9b162bb06..811b7c50340 100644 --- a/homeassistant/components/lgthinq/const.py +++ b/homeassistant/components/lg_thinq/const.py @@ -37,7 +37,7 @@ from thinqconnect import ( ) # Common -DOMAIN = "lgthinq" +DOMAIN = "lg_thinq" COMPANY = "LGE" THINQ_DEFAULT_NAME: Final = "LG ThinQ" THINQ_PAT_URL: Final = "https://connect-pat.lgthinq.com" diff --git a/homeassistant/components/lgthinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py similarity index 100% rename from homeassistant/components/lgthinq/coordinator.py rename to homeassistant/components/lg_thinq/coordinator.py diff --git a/homeassistant/components/lgthinq/entity.py b/homeassistant/components/lg_thinq/entity.py similarity index 100% rename from homeassistant/components/lgthinq/entity.py rename to homeassistant/components/lg_thinq/entity.py diff --git a/homeassistant/components/lgthinq/icons.json b/homeassistant/components/lg_thinq/icons.json similarity index 100% rename from homeassistant/components/lgthinq/icons.json rename to homeassistant/components/lg_thinq/icons.json diff --git a/homeassistant/components/lgthinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json similarity index 92% rename from homeassistant/components/lgthinq/manifest.json rename to homeassistant/components/lg_thinq/manifest.json index 641c78844f9..0fa447a511b 100644 --- a/homeassistant/components/lgthinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -1,5 +1,5 @@ { - "domain": "lgthinq", + "domain": "lg_thinq", "name": "LG ThinQ", "codeowners": ["@LG-ThinQ-Integration"], "config_flow": true, diff --git a/homeassistant/components/lgthinq/strings.json b/homeassistant/components/lg_thinq/strings.json similarity index 100% rename from homeassistant/components/lgthinq/strings.json rename to homeassistant/components/lg_thinq/strings.json diff --git a/homeassistant/components/lgthinq/switch.py b/homeassistant/components/lg_thinq/switch.py similarity index 100% rename from homeassistant/components/lgthinq/switch.py rename to homeassistant/components/lg_thinq/switch.py diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d4342d80d41..fcabc463f0a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -319,7 +319,7 @@ FLOWS = { "lektrico", "lg_netcast", "lg_soundbar", - "lgthinq", + "lg_thinq", "lidarr", "lifx", "linear_garage_door", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8091d48ca4d..ebfe7e056f2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3238,6 +3238,12 @@ "iot_class": "local_polling", "name": "LG Netcast" }, + "lg_thinq": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push", + "name": "LG ThinQ" + }, "lg_soundbar": { "integration_type": "hub", "config_flow": true, @@ -3252,12 +3258,6 @@ } } }, - "lgthinq": { - "name": "LG ThinQ", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_push" - }, "lidarr": { "name": "Lidarr", "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 980ba8d94e2..27cd2d5abc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2786,7 +2786,7 @@ thermoworks-smoke==0.1.8 # homeassistant.components.thingspeak thingspeak==1.0.0 -# homeassistant.components.lgthinq +# homeassistant.components.lg_thinq thinqconnect==0.9.5 # homeassistant.components.tikteck diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f766c8c13d3..08903ae1c6f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2199,7 +2199,7 @@ thermobeacon-ble==0.7.0 # homeassistant.components.thermopro thermopro-ble==0.10.0 -# homeassistant.components.lgthinq +# homeassistant.components.lg_thinq thinqconnect==0.9.5 # homeassistant.components.tilt_ble diff --git a/tests/components/lgthinq/__init__.py b/tests/components/lg_thinq/__init__.py similarity index 100% rename from tests/components/lgthinq/__init__.py rename to tests/components/lg_thinq/__init__.py diff --git a/tests/components/lgthinq/conftest.py b/tests/components/lg_thinq/conftest.py similarity index 90% rename from tests/components/lgthinq/conftest.py rename to tests/components/lg_thinq/conftest.py index 321c770ee8d..cae2de61fa4 100644 --- a/tests/components/lgthinq/conftest.py +++ b/tests/components/lg_thinq/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from thinqconnect import ThinQAPIException -from homeassistant.components.lgthinq.const import CONF_CONNECT_CLIENT_ID, DOMAIN +from homeassistant.components.lg_thinq.const import CONF_CONNECT_CLIENT_ID, DOMAIN from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT, MOCK_UUID @@ -51,7 +51,7 @@ def mock_uuid() -> Generator[AsyncMock]: with ( patch("uuid.uuid4", autospec=True, return_value=MOCK_UUID) as mock_uuid, patch( - "homeassistant.components.lgthinq.config_flow.uuid.uuid4", + "homeassistant.components.lg_thinq.config_flow.uuid.uuid4", new=mock_uuid, ), ): @@ -64,7 +64,7 @@ def mock_thinq_api() -> Generator[AsyncMock]: with ( patch("thinqconnect.ThinQApi", autospec=True) as mock_api, patch( - "homeassistant.components.lgthinq.config_flow.ThinQApi", + "homeassistant.components.lg_thinq.config_flow.ThinQApi", new=mock_api, ), ): diff --git a/tests/components/lgthinq/const.py b/tests/components/lg_thinq/const.py similarity index 100% rename from tests/components/lgthinq/const.py rename to tests/components/lg_thinq/const.py diff --git a/tests/components/lgthinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py similarity index 96% rename from tests/components/lgthinq/test_config_flow.py rename to tests/components/lg_thinq/test_config_flow.py index 457549ccb7e..db0e2d29450 100644 --- a/tests/components/lgthinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from homeassistant.components.lgthinq.const import CONF_CONNECT_CLIENT_ID, DOMAIN +from homeassistant.components.lg_thinq.const import CONF_CONNECT_CLIENT_ID, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY from homeassistant.core import HomeAssistant From 26281662b5557a1fb2b435ca3316f27aa015251e Mon Sep 17 00:00:00 2001 From: Alex Yao <33379584+alexyao2015@users.noreply.github.com> Date: Fri, 30 Aug 2024 16:22:14 -0500 Subject: [PATCH 0089/1309] Enable config flow for html5 (#112806) * html5: Enable config flow * Add tests * attempt check create_issue * replace len with call_count * fix config flow tests * test user config * more tests * remove whitespace * Update homeassistant/components/html5/issues.py Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> * Update homeassistant/components/html5/issues.py Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> * fix config * Adjust issues log * lint * lint * rename create issue * fix typing * update codeowners * fix test * fix tests * Update issues.py * Update tests/components/html5/test_config_flow.py Co-authored-by: J. Nick Koston * Update tests/components/html5/test_config_flow.py Co-authored-by: J. Nick Koston * Update tests/components/html5/test_config_flow.py Co-authored-by: J. Nick Koston * update from review * remove ternary * fix * fix missing service * fix tests * updates * adress review comments * fix indent * fix * fix format * cleanup from review * Restore config schema and use HA issue * Restore config schema and use HA issue --------- Co-authored-by: alexyao2015 Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Joostlek --- CODEOWNERS | 2 + homeassistant/components/html5/__init__.py | 15 ++ homeassistant/components/html5/config_flow.py | 103 +++++++++ homeassistant/components/html5/const.py | 5 + homeassistant/components/html5/issues.py | 50 +++++ homeassistant/components/html5/manifest.json | 6 +- homeassistant/components/html5/notify.py | 51 +++-- homeassistant/components/html5/strings.json | 27 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 5 +- tests/components/html5/test_config_flow.py | 203 ++++++++++++++++++ tests/components/html5/test_init.py | 44 ++++ tests/components/html5/test_notify.py | 22 +- 13 files changed, 497 insertions(+), 37 deletions(-) create mode 100644 homeassistant/components/html5/config_flow.py create mode 100644 homeassistant/components/html5/issues.py create mode 100644 tests/components/html5/test_config_flow.py create mode 100644 tests/components/html5/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 3b250ceb9ab..7b8b4ec1106 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -633,6 +633,8 @@ build.json @home-assistant/supervisor /tests/components/homewizard/ @DCSBL /homeassistant/components/honeywell/ @rdfurman @mkmer /tests/components/honeywell/ @rdfurman @mkmer +/homeassistant/components/html5/ @alexyao2015 +/tests/components/html5/ @alexyao2015 /homeassistant/components/http/ @home-assistant/core /tests/components/http/ @home-assistant/core /homeassistant/components/huawei_lte/ @scop @fphammerle diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py index 88e437ef566..4b85bf8ab8c 100644 --- a/homeassistant/components/html5/__init__.py +++ b/homeassistant/components/html5/__init__.py @@ -1 +1,16 @@ """The html5 component.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery + +from .const import DOMAIN + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up HTML5 from a config entry.""" + await discovery.async_load_platform( + hass, Platform.NOTIFY, DOMAIN, dict(entry.data), {} + ) + return True diff --git a/homeassistant/components/html5/config_flow.py b/homeassistant/components/html5/config_flow.py new file mode 100644 index 00000000000..1dae0102d05 --- /dev/null +++ b/homeassistant/components/html5/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for the html5 component.""" + +import binascii +from typing import Any, cast + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from py_vapid import Vapid +from py_vapid.utils import b64urlencode +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_NAME +from homeassistant.core import callback + +from .const import ATTR_VAPID_EMAIL, ATTR_VAPID_PRV_KEY, ATTR_VAPID_PUB_KEY, DOMAIN +from .issues import async_create_html5_issue + + +def vapid_generate_private_key() -> str: + """Generate a VAPID private key.""" + private_key = ec.generate_private_key(ec.SECP256R1(), default_backend()) + return b64urlencode( + binascii.unhexlify(f"{private_key.private_numbers().private_value:x}".zfill(64)) + ) + + +def vapid_get_public_key(private_key: str) -> str: + """Get the VAPID public key from a private key.""" + vapid = Vapid.from_string(private_key) + public_key = cast(ec.EllipticCurvePublicKey, vapid.public_key) + return b64urlencode( + public_key.public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint + ) + ) + + +class HTML5ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for HTML5.""" + + @callback + def _async_create_html5_entry( + self: "HTML5ConfigFlow", data: dict[str, str] + ) -> tuple[dict[str, str], ConfigFlowResult | None]: + """Create an HTML5 entry.""" + errors = {} + flow_result = None + + if not data.get(ATTR_VAPID_PRV_KEY): + data[ATTR_VAPID_PRV_KEY] = vapid_generate_private_key() + + # we will always generate the corresponding public key + try: + data[ATTR_VAPID_PUB_KEY] = vapid_get_public_key(data[ATTR_VAPID_PRV_KEY]) + except (ValueError, binascii.Error): + errors[ATTR_VAPID_PRV_KEY] = "invalid_prv_key" + + if not errors: + config = { + ATTR_VAPID_EMAIL: data[ATTR_VAPID_EMAIL], + ATTR_VAPID_PRV_KEY: data[ATTR_VAPID_PRV_KEY], + ATTR_VAPID_PUB_KEY: data[ATTR_VAPID_PUB_KEY], + CONF_NAME: DOMAIN, + } + flow_result = self.async_create_entry(title="HTML5", data=config) + return errors, flow_result + + async def async_step_user( + self: "HTML5ConfigFlow", user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input: + errors, flow_result = self._async_create_html5_entry(user_input) + if flow_result: + return flow_result + else: + user_input = {} + + return self.async_show_form( + data_schema=vol.Schema( + { + vol.Required( + ATTR_VAPID_EMAIL, default=user_input.get(ATTR_VAPID_EMAIL, "") + ): str, + vol.Optional(ATTR_VAPID_PRV_KEY): str, + } + ), + errors=errors, + ) + + async def async_step_import( + self: "HTML5ConfigFlow", import_config: dict + ) -> ConfigFlowResult: + """Handle config import from yaml.""" + _, flow_result = self._async_create_html5_entry(import_config) + if not flow_result: + async_create_html5_issue(self.hass, False) + return self.async_abort(reason="invalid_config") + async_create_html5_issue(self.hass, True) + return flow_result diff --git a/homeassistant/components/html5/const.py b/homeassistant/components/html5/const.py index bf7eaca7e24..75826ab90c9 100644 --- a/homeassistant/components/html5/const.py +++ b/homeassistant/components/html5/const.py @@ -1,4 +1,9 @@ """Constants for the HTML5 component.""" DOMAIN = "html5" +DATA_HASS_CONFIG = "html5_hass_config" SERVICE_DISMISS = "dismiss" + +ATTR_VAPID_PUB_KEY = "vapid_pub_key" +ATTR_VAPID_PRV_KEY = "vapid_prv_key" +ATTR_VAPID_EMAIL = "vapid_email" diff --git a/homeassistant/components/html5/issues.py b/homeassistant/components/html5/issues.py new file mode 100644 index 00000000000..8892562d347 --- /dev/null +++ b/homeassistant/components/html5/issues.py @@ -0,0 +1,50 @@ +"""Issues utility for HTML5.""" + +import logging + +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SUCCESSFUL_IMPORT_TRANSLATION_KEY = "deprecated_yaml" +FAILED_IMPORT_TRANSLATION_KEY = "deprecated_yaml_import_issue" + +INTEGRATION_TITLE = "HTML5 Push Notifications" + + +@callback +def async_create_html5_issue(hass: HomeAssistant, import_success: bool) -> None: + """Create issues for HTML5.""" + if import_success: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) diff --git a/homeassistant/components/html5/manifest.json b/homeassistant/components/html5/manifest.json index f480086d153..c6cbd826544 100644 --- a/homeassistant/components/html5/manifest.json +++ b/homeassistant/components/html5/manifest.json @@ -1,10 +1,12 @@ { "domain": "html5", "name": "HTML5 Push Notifications", - "codeowners": [], + "codeowners": ["@alexyao2015"], + "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/html5", "iot_class": "cloud_push", "loggers": ["http_ece", "py_vapid", "pywebpush"], - "requirements": ["pywebpush==1.14.1"] + "requirements": ["pywebpush==1.14.1"], + "single_config_entry": true } diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index 8082ca37aa3..48cc0598479 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -29,6 +29,7 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, BaseNotificationService, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ATTR_NAME, URL_ROOT from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError @@ -38,32 +39,23 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import ensure_unique_string from homeassistant.util.json import JsonObjectType, load_json_object -from .const import DOMAIN, SERVICE_DISMISS +from .const import ( + ATTR_VAPID_EMAIL, + ATTR_VAPID_PRV_KEY, + ATTR_VAPID_PUB_KEY, + DOMAIN, + SERVICE_DISMISS, +) +from .issues import async_create_html5_issue _LOGGER = logging.getLogger(__name__) REGISTRATIONS_FILE = "html5_push_registrations.conf" -ATTR_VAPID_PUB_KEY = "vapid_pub_key" -ATTR_VAPID_PRV_KEY = "vapid_prv_key" -ATTR_VAPID_EMAIL = "vapid_email" - - -def gcm_api_deprecated(value): - """Warn user that GCM API config is deprecated.""" - if value: - _LOGGER.warning( - "Configuring html5_push_notifications via the GCM api" - " has been deprecated and stopped working since May 29," - " 2019. Use the VAPID configuration instead. For instructions," - " see https://www.home-assistant.io/integrations/html5/" - ) - return value - PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( { - vol.Optional("gcm_sender_id"): vol.All(cv.string, gcm_api_deprecated), + vol.Optional("gcm_sender_id"): cv.string, vol.Optional("gcm_api_key"): cv.string, vol.Required(ATTR_VAPID_PUB_KEY): cv.string, vol.Required(ATTR_VAPID_PRV_KEY): cv.string, @@ -171,15 +163,30 @@ async def async_get_service( discovery_info: DiscoveryInfoType | None = None, ) -> HTML5NotificationService | None: """Get the HTML5 push notification service.""" + if config: + existing_config_entry = hass.config_entries.async_entries(DOMAIN) + if existing_config_entry: + async_create_html5_issue(hass, True) + return None + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + return None + + if discovery_info is None: + return None + json_path = hass.config.path(REGISTRATIONS_FILE) registrations = await hass.async_add_executor_job(_load_config, json_path) - vapid_pub_key = config[ATTR_VAPID_PUB_KEY] - vapid_prv_key = config[ATTR_VAPID_PRV_KEY] - vapid_email = config[ATTR_VAPID_EMAIL] + vapid_pub_key = discovery_info[ATTR_VAPID_PUB_KEY] + vapid_prv_key = discovery_info[ATTR_VAPID_PRV_KEY] + vapid_email = discovery_info[ATTR_VAPID_EMAIL] - def websocket_appkey(hass, connection, msg): + def websocket_appkey(_hass, connection, msg): connection.send_message(websocket_api.result_message(msg["id"], vapid_pub_key)) websocket_api.async_register_command( diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index fa69025c43c..40bdbb36261 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -1,4 +1,31 @@ { + "config": { + "step": { + "user": { + "data": { + "vapid_email": "[%key:common::config_flow::data::email%]", + "vapid_prv_key": "VAPID private key" + }, + "data_description": { + "vapid_email": "Email to use for html5 push notifications.", + "vapid_prv_key": "If not specified, one will be automatically generated." + } + } + }, + "error": { + "unknown": "Unknown error", + "invalid_prv_key": "Invalid private key" + }, + "abort": { + "invalid_config": "Invalid configuration" + } + }, + "issues": { + "deprecated_yaml_import_issue": { + "title": "HTML5 YAML configuration import failed", + "description": "Configuring HTML5 push notification using YAML has been deprecated. An automatic import of your existing configuration was attempted, but it failed.\n\nPlease remove the HTML5 push notification YAML configuration from your configuration.yaml file and reconfigure HTML5 push notification again manually." + } + }, "services": { "dismiss": { "name": "Dismiss", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fcabc463f0a..912df1aee0f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -253,6 +253,7 @@ FLOWS = { "homewizard", "homeworks", "honeywell", + "html5", "huawei_lte", "hue", "huisbaasje", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ebfe7e056f2..38958845782 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2633,8 +2633,9 @@ "html5": { "name": "HTML5 Push Notifications", "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push" + "config_flow": true, + "iot_class": "cloud_push", + "single_config_entry": true }, "huawei_lte": { "name": "Huawei LTE", diff --git a/tests/components/html5/test_config_flow.py b/tests/components/html5/test_config_flow.py new file mode 100644 index 00000000000..ca0b3da0389 --- /dev/null +++ b/tests/components/html5/test_config_flow.py @@ -0,0 +1,203 @@ +"""Test the HTML5 config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.html5.const import ( + ATTR_VAPID_EMAIL, + ATTR_VAPID_PRV_KEY, + ATTR_VAPID_PUB_KEY, + DOMAIN, +) +from homeassistant.components.html5.issues import ( + FAILED_IMPORT_TRANSLATION_KEY, + SUCCESSFUL_IMPORT_TRANSLATION_KEY, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.issue_registry as ir + +MOCK_CONF = { + ATTR_VAPID_EMAIL: "test@example.com", + ATTR_VAPID_PRV_KEY: "h6acSRds8_KR8hT9djD8WucTL06Gfe29XXyZ1KcUjN8", +} +MOCK_CONF_PUB_KEY = "BIUtPN7Rq_8U7RBEqClZrfZ5dR9zPCfvxYPtLpWtRVZTJEc7lzv2dhzDU6Aw1m29Ao0-UA1Uq6XO9Df8KALBKqA" + + +async def test_step_user_success(hass: HomeAssistant) -> None: + """Test a successful user config flow.""" + + with patch( + "homeassistant.components.html5.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=MOCK_CONF.copy(), + ) + + await hass.async_block_till_done() + + assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + ATTR_VAPID_PRV_KEY: MOCK_CONF[ATTR_VAPID_PRV_KEY], + ATTR_VAPID_PUB_KEY: MOCK_CONF_PUB_KEY, + ATTR_VAPID_EMAIL: MOCK_CONF[ATTR_VAPID_EMAIL], + CONF_NAME: DOMAIN, + } + + assert mock_setup_entry.call_count == 1 + + +async def test_step_user_success_generate(hass: HomeAssistant) -> None: + """Test a successful user config flow, generating a key pair.""" + + with patch( + "homeassistant.components.html5.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + conf = {ATTR_VAPID_EMAIL: MOCK_CONF[ATTR_VAPID_EMAIL]} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf + ) + + await hass.async_block_till_done() + + assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][ATTR_VAPID_EMAIL] == MOCK_CONF[ATTR_VAPID_EMAIL] + + assert mock_setup_entry.call_count == 1 + + +async def test_step_user_new_form(hass: HomeAssistant) -> None: + """Test new user input.""" + + with patch( + "homeassistant.components.html5.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + ) + + await hass.async_block_till_done() + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert mock_setup_entry.call_count == 0 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONF + ) + assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY + assert mock_setup_entry.call_count == 1 + + +@pytest.mark.parametrize( + ("key", "value"), + [ + (ATTR_VAPID_PRV_KEY, "invalid"), + ], +) +async def test_step_user_form_invalid_key( + hass: HomeAssistant, key: str, value: str +) -> None: + """Test invalid user input.""" + + with patch( + "homeassistant.components.html5.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + bad_conf = MOCK_CONF.copy() + bad_conf[key] = value + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=bad_conf + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert mock_setup_entry.call_count == 0 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_CONF + ) + assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY + assert mock_setup_entry.call_count == 1 + + +async def test_step_import_good( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test valid import input.""" + + with ( + patch( + "homeassistant.components.html5.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + conf = MOCK_CONF.copy() + conf[ATTR_VAPID_PUB_KEY] = MOCK_CONF_PUB_KEY + conf["random_key"] = "random_value" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + ATTR_VAPID_PRV_KEY: conf[ATTR_VAPID_PRV_KEY], + ATTR_VAPID_PUB_KEY: MOCK_CONF_PUB_KEY, + ATTR_VAPID_EMAIL: conf[ATTR_VAPID_EMAIL], + CONF_NAME: DOMAIN, + } + + assert mock_setup_entry.call_count == 1 + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue + assert issue.translation_key == SUCCESSFUL_IMPORT_TRANSLATION_KEY + + +@pytest.mark.parametrize( + ("key", "value"), + [ + (ATTR_VAPID_PRV_KEY, "invalid"), + ], +) +async def test_step_import_bad( + hass: HomeAssistant, issue_registry: ir.IssueRegistry, key: str, value: str +) -> None: + """Test invalid import input.""" + + with ( + patch( + "homeassistant.components.html5.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): + bad_conf = MOCK_CONF.copy() + bad_conf[key] = value + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=bad_conf + ) + + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert mock_setup_entry.call_count == 0 + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, f"deprecated_yaml_{DOMAIN}") + assert issue + assert issue.translation_key == FAILED_IMPORT_TRANSLATION_KEY diff --git a/tests/components/html5/test_init.py b/tests/components/html5/test_init.py new file mode 100644 index 00000000000..290cb381296 --- /dev/null +++ b/tests/components/html5/test_init.py @@ -0,0 +1,44 @@ +"""Test the HTML5 setup.""" + +from homeassistant.core import HomeAssistant +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +NOTIFY_CONF = { + "notify": [ + { + "platform": "html5", + "name": "html5", + "vapid_pub_key": "BIUtPN7Rq_8U7RBEqClZrfZ5dR9zPCfvxYPtLpWtRVZTJEc7lzv2dhzDU6Aw1m29Ao0-UA1Uq6XO9Df8KALBKqA", + "vapid_prv_key": "h6acSRds8_KR8hT9djD8WucTL06Gfe29XXyZ1KcUjN8", + "vapid_email": "test@example.com", + } + ] +} + + +async def test_setup_entry( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test setup of a good config entry.""" + config_entry = MockConfigEntry(domain="html5", data={}) + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "html5", {}) + + assert len(issue_registry.issues) == 0 + + +async def test_setup_entry_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test setup of an imported config entry with deprecated YAML.""" + config_entry = MockConfigEntry(domain="html5", data={}) + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, "notify", NOTIFY_CONF) + assert await async_setup_component(hass, "html5", NOTIFY_CONF) + + assert len(issue_registry.issues) == 1 diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index 42ca6067418..85a790c0610 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -94,7 +94,7 @@ async def test_get_service_with_no_json(hass: HomeAssistant) -> None: await async_setup_component(hass, "http", {}) m = mock_open() with patch("homeassistant.util.json.open", m, create=True): - service = await html5.async_get_service(hass, VAPID_CONF) + service = await html5.async_get_service(hass, {}, VAPID_CONF) assert service is not None @@ -109,7 +109,7 @@ async def test_dismissing_message(mock_wp, hass: HomeAssistant) -> None: m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): - service = await html5.async_get_service(hass, VAPID_CONF) + service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None @@ -138,7 +138,7 @@ async def test_sending_message(mock_wp, hass: HomeAssistant) -> None: m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): - service = await html5.async_get_service(hass, VAPID_CONF) + service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None @@ -169,7 +169,7 @@ async def test_fcm_key_include(mock_wp, hass: HomeAssistant) -> None: m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): - service = await html5.async_get_service(hass, VAPID_CONF) + service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None @@ -194,7 +194,7 @@ async def test_fcm_send_with_unknown_priority(mock_wp, hass: HomeAssistant) -> N m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): - service = await html5.async_get_service(hass, VAPID_CONF) + service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None @@ -219,7 +219,7 @@ async def test_fcm_no_targets(mock_wp, hass: HomeAssistant) -> None: m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): - service = await html5.async_get_service(hass, VAPID_CONF) + service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None @@ -244,7 +244,7 @@ async def test_fcm_additional_data(mock_wp, hass: HomeAssistant) -> None: m = mock_open(read_data=json.dumps(data)) with patch("homeassistant.util.json.open", m, create=True): - service = await html5.async_get_service(hass, VAPID_CONF) + service = await html5.async_get_service(hass, {}, VAPID_CONF) service.hass = hass assert service is not None @@ -479,7 +479,7 @@ async def test_callback_view_with_jwt( mock_wp().send().status_code = 201 await hass.services.async_call( "notify", - "notify", + "html5", {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, blocking=True, ) @@ -516,7 +516,7 @@ async def test_send_fcm_without_targets( mock_wp().send().status_code = 201 await hass.services.async_call( "notify", - "notify", + "html5", {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, blocking=True, ) @@ -541,7 +541,7 @@ async def test_send_fcm_expired( mock_wp().send().status_code = 410 await hass.services.async_call( "notify", - "notify", + "html5", {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, blocking=True, ) @@ -566,7 +566,7 @@ async def test_send_fcm_expired_save_fails( mock_wp().send().status_code = 410 await hass.services.async_call( "notify", - "notify", + "html5", {"message": "Hello", "target": ["device"], "data": {"icon": "beer.png"}}, blocking=True, ) From 582b7eab66508531c5362b76d6f3a819f4ba01e6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 31 Aug 2024 01:01:27 -0700 Subject: [PATCH 0090/1309] Add missing translation for Google Photos reauth (#124959) --- homeassistant/components/google_photos/strings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index 57bce01d9f8..b44e04287b1 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -18,6 +18,7 @@ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "access_not_configured": "Unable to access the Google API:\n\n{message}", "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "Wrong account: Please authenticate with the right account.", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]" From c1eb5f8b74a842a8021a633b74648e68dbf3fd90 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 31 Aug 2024 01:01:51 -0700 Subject: [PATCH 0091/1309] Fix Google Photos get media calls (#124958) --- homeassistant/components/google_photos/api.py | 2 +- tests/components/google_photos/conftest.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_photos/api.py b/homeassistant/components/google_photos/api.py index 2fa6ee2d8f6..b387326148f 100644 --- a/homeassistant/components/google_photos/api.py +++ b/homeassistant/components/google_photos/api.py @@ -54,7 +54,7 @@ class AuthBase(ABC): """Get all MediaItem resources.""" service = await self._get_photos_service() cmd: HttpRequest = service.mediaItems().get( - media_item_id, fields=GET_MEDIA_ITEM_FIELDS + mediaItemId=media_item_id, fields=GET_MEDIA_ITEM_FIELDS ) return await self._execute(cmd) diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index 84ed717895d..73e506658e6 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -115,10 +115,10 @@ def mock_setup_api( mock.return_value.mediaItems.return_value.list = list_media_items # Mock a point lookup by reading contents of the fixture above - def get_media_item(media_item_id: str, **kwargs: Any) -> Mock: + def get_media_item(mediaItemId: str, **kwargs: Any) -> Mock: for response in responses: for media_item in response["mediaItems"]: - if media_item["id"] == media_item_id: + if media_item["id"] == mediaItemId: mock = Mock() mock.execute.return_value = media_item return mock From 3bfcb1ebdd0c00e573789cce9b4ea548d26a83a9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Aug 2024 22:07:36 -1000 Subject: [PATCH 0092/1309] Restore sisyphus integration (#124749) * Revert "Disable sisyphus integration (#124742)" This reverts commit 1b304e60d926ceffbe79e25c5065af233fc4c059. * Restore sisyphus integration reverts #124742 and updates the lib instead changelog: https://github.com/jkeljo/sisyphus-control/compare/v3.1.3...v3.1.4 release is pending: https://github.com/jkeljo/sisyphus-control/pull/8#issuecomment-2313893689 --- homeassistant/components/sisyphus/__init__.py | 3 +-- homeassistant/components/sisyphus/manifest.json | 3 +-- homeassistant/components/sisyphus/media_player.py | 3 +-- homeassistant/components/sisyphus/ruff.toml | 5 ----- requirements_all.txt | 3 +++ 5 files changed, 6 insertions(+), 11 deletions(-) delete mode 100644 homeassistant/components/sisyphus/ruff.toml diff --git a/homeassistant/components/sisyphus/__init__.py b/homeassistant/components/sisyphus/__init__.py index 1fc440f260d..da8d670d412 100644 --- a/homeassistant/components/sisyphus/__init__.py +++ b/homeassistant/components/sisyphus/__init__.py @@ -1,10 +1,9 @@ """Support for controlling Sisyphus Kinetic Art Tables.""" -# mypy: ignore-errors import asyncio import logging -# from sisyphus_control import Table +from sisyphus_control import Table import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform diff --git a/homeassistant/components/sisyphus/manifest.json b/homeassistant/components/sisyphus/manifest.json index f1d90cebbd3..4e344c0b25e 100644 --- a/homeassistant/components/sisyphus/manifest.json +++ b/homeassistant/components/sisyphus/manifest.json @@ -2,9 +2,8 @@ "domain": "sisyphus", "name": "Sisyphus", "codeowners": ["@jkeljo"], - "disabled": "This integration is disabled because it uses an old version of socketio.", "documentation": "https://www.home-assistant.io/integrations/sisyphus", "iot_class": "local_push", "loggers": ["sisyphus_control"], - "requirements": ["sisyphus-control==3.1.3"] + "requirements": ["sisyphus-control==3.1.4"] } diff --git a/homeassistant/components/sisyphus/media_player.py b/homeassistant/components/sisyphus/media_player.py index 0248bbeac32..3884a83928a 100644 --- a/homeassistant/components/sisyphus/media_player.py +++ b/homeassistant/components/sisyphus/media_player.py @@ -1,11 +1,10 @@ """Support for track controls on the Sisyphus Kinetic Art Table.""" -# mypy: ignore-errors from __future__ import annotations import aiohttp +from sisyphus_control import Track -# from sisyphus_control import Track from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, diff --git a/homeassistant/components/sisyphus/ruff.toml b/homeassistant/components/sisyphus/ruff.toml deleted file mode 100644 index 38f6f586aef..00000000000 --- a/homeassistant/components/sisyphus/ruff.toml +++ /dev/null @@ -1,5 +0,0 @@ -extend = "../../../pyproject.toml" - -lint.extend-ignore = [ - "F821" -] diff --git a/requirements_all.txt b/requirements_all.txt index 27cd2d5abc1..afb7ae7bc77 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2625,6 +2625,9 @@ simplepush==2.2.3 # homeassistant.components.simplisafe simplisafe-python==2024.01.0 +# homeassistant.components.sisyphus +sisyphus-control==3.1.4 + # homeassistant.components.slack slackclient==2.5.0 From 2cab9f7fe9cc374beb5f7b29a40598b2389a26bc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 31 Aug 2024 01:10:45 -0700 Subject: [PATCH 0093/1309] Address additional Google Photos integration feedback (#124957) * Address review feedback * Fix typing for arguments --- .../components/google_photos/config_flow.py | 2 +- .../components/google_photos/media_source.py | 117 ++++++++++-------- .../google_photos/test_media_source.py | 8 +- 3 files changed, 72 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/google_photos/config_flow.py b/homeassistant/components/google_photos/config_flow.py index 93f0347e32f..e5378f67ffd 100644 --- a/homeassistant/components/google_photos/config_flow.py +++ b/homeassistant/components/google_photos/config_flow.py @@ -42,7 +42,7 @@ class OAuth2FlowHandler( client = api.AsyncConfigFlowAuth(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) try: user_resource_info = await client.get_user_info() - await client.list_media_items() + await client.list_media_items(page_size=1) except GooglePhotosApiError as ex: return self.async_abort( reason="access_not_configured", diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index e6011cb0e61..a2f9383ec5f 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -1,6 +1,7 @@ """Media source for Google Photos.""" from dataclasses import dataclass +from enum import StrEnum import logging from typing import Any, cast @@ -28,7 +29,7 @@ MAX_PHOTOS = 50 PAGE_SIZE = 100 THUMBNAIL_SIZE = 256 -LARGE_IMAGE_SIZE = 2048 +LARGE_IMAGE_SIZE = 2160 # Markers for parts of PhotosIdentifier url pattern. @@ -47,6 +48,21 @@ RECENT_PHOTOS_ALBUM = "recent" RECENT_PHOTOS_TITLE = "Recent Photos" +class PhotosIdentifierType(StrEnum): + """Type for a PhotosIdentifier.""" + + PHOTO = "p" + ALBUM = "a" + + @classmethod + def of(cls, name: str) -> "PhotosIdentifierType": + """Parse a PhotosIdentifierType by string value.""" + for enum in PhotosIdentifierType: + if enum.value == name: + return enum + raise ValueError(f"Invalid PhotosIdentifierType: {name}") + + @dataclass class PhotosIdentifier: """Google Photos item identifier in a media source URL.""" @@ -54,47 +70,48 @@ class PhotosIdentifier: config_entry_id: str """Identifies the account for the media item.""" - album_media_id: str | None = None - """Identifies the album contents to show. + id_type: PhotosIdentifierType | None = None + """Type of identifier""" - Not present at the same time as `photo_media_id`. - """ - - photo_media_id: str | None = None - """Identifies an indiviidual photo or video. - - Not present at the same time as `album_media_id`. - """ + media_id: str | None = None + """Identifies the album or photo contents to show.""" def as_string(self) -> str: """Serialize the identiifer as a string. - This is the opposite if parse_identifier(). + This is the opposite if of(). """ - if self.photo_media_id is None: - if self.album_media_id is None: - return self.config_entry_id - return f"{self.config_entry_id}/{PHOTO_SOURCE_IDENTIFIER_ALBUM}/{self.album_media_id}" - return f"{self.config_entry_id}/{PHOTO_SOURCE_IDENTIFIER_PHOTO}/{self.photo_media_id}" + if self.id_type is None: + return self.config_entry_id + return f"{self.config_entry_id}/{self.id_type}/{self.media_id}" + @staticmethod + def of(identifier: str) -> "PhotosIdentifier": + """Parse a PhotosIdentifier form a string. -def parse_identifier(identifier: str) -> PhotosIdentifier: - """Parse a PhotosIdentifier form a string. + This is the opposite of as_string(). + """ + parts = identifier.split("/") + _LOGGER.debug("parts=%s", parts) + if len(parts) == 1: + return PhotosIdentifier(parts[0]) + if len(parts) != 3: + raise BrowseError(f"Invalid identifier: {identifier}") + return PhotosIdentifier(parts[0], PhotosIdentifierType.of(parts[1]), parts[2]) - This is the opposite of as_string(). - """ - parts = identifier.split("/") - if len(parts) == 1: - return PhotosIdentifier(parts[0]) - if len(parts) != 3: - raise BrowseError(f"Invalid identifier: {identifier}") - if parts[1] == PHOTO_SOURCE_IDENTIFIER_PHOTO: - return PhotosIdentifier(parts[0], photo_media_id=parts[2]) - return PhotosIdentifier(parts[0], album_media_id=parts[2]) + @staticmethod + def album(config_entry_id: str, media_id: str) -> "PhotosIdentifier": + """Create an album PhotosIdentifier.""" + return PhotosIdentifier(config_entry_id, PhotosIdentifierType.ALBUM, media_id) + + @staticmethod + def photo(config_entry_id: str, media_id: str) -> "PhotosIdentifier": + """Create an album PhotosIdentifier.""" + return PhotosIdentifier(config_entry_id, PhotosIdentifierType.PHOTO, media_id) async def async_get_media_source(hass: HomeAssistant) -> MediaSource: - """Set up Synology media source.""" + """Set up Google Photos media source.""" return GooglePhotosMediaSource(hass) @@ -113,16 +130,20 @@ class GooglePhotosMediaSource(MediaSource): This will resolve a specific media item to a url for the full photo or video contents. """ - identifier = parse_identifier(item.identifier) - if identifier.photo_media_id is None: + try: + identifier = PhotosIdentifier.of(item.identifier) + except ValueError as err: + raise BrowseError(f"Could not parse identifier: {item.identifier}") from err + if ( + identifier.media_id is None + or identifier.id_type != PhotosIdentifierType.PHOTO + ): raise BrowseError( - f"Could not resolve identifier without a photo_media_id: {identifier}" + f"Could not resolve identiifer that is not a Photo: {identifier}" ) entry = self._async_config_entry(identifier.config_entry_id) client = entry.runtime_data - media_item = await client.get_media_item( - media_item_id=identifier.photo_media_id - ) + media_item = await client.get_media_item(media_item_id=identifier.media_id) is_video = media_item["mediaMetadata"].get("video") is not None return PlayMedia( url=( @@ -158,24 +179,24 @@ class GooglePhotosMediaSource(MediaSource): ) # Determine the configuration entry for this item - identifier = parse_identifier(item.identifier) + identifier = PhotosIdentifier.of(item.identifier) entry = self._async_config_entry(identifier.config_entry_id) client = entry.runtime_data - if identifier.album_media_id is None: - source = _build_account(entry, identifier) + source = _build_account(entry, identifier) + if identifier.id_type is None: source.children = [ _build_album( RECENT_PHOTOS_TITLE, - PhotosIdentifier( - identifier.config_entry_id, album_media_id=RECENT_PHOTOS_ALBUM + PhotosIdentifier.album( + identifier.config_entry_id, RECENT_PHOTOS_ALBUM ), ) ] return source # Currently only supports listing a single album of recent photos. - if identifier.album_media_id != RECENT_PHOTOS_ALBUM: + if identifier.media_id != RECENT_PHOTOS_ALBUM: raise BrowseError(f"Unsupported album: {identifier}") # Fetch recent items @@ -194,12 +215,9 @@ class GooglePhotosMediaSource(MediaSource): break # Render the grid of media item results - source = _build_account(entry, PhotosIdentifier(cast(str, entry.unique_id))) source.children = [ _build_media_item( - PhotosIdentifier( - identifier.config_entry_id, photo_media_id=media_item["id"] - ), + PhotosIdentifier.photo(identifier.config_entry_id, media_item["id"]), media_item, ) for media_item in media_items @@ -250,7 +268,7 @@ def _build_album(title: str, identifier: PhotosIdentifier) -> BrowseMediaSource: def _build_media_item( identifier: PhotosIdentifier, media_item: dict[str, Any] ) -> BrowseMediaSource: - """Build the node for an individual photos or video.""" + """Build the node for an individual photo or video.""" is_video = media_item["mediaMetadata"].get("video") is not None return BrowseMediaSource( domain=DOMAIN, @@ -269,10 +287,7 @@ def _media_url(media_item: dict[str, Any], max_size: int) -> str: See https://developers.google.com/photos/library/guides/access-media-items#base-urls """ - width = media_item["mediaMetadata"]["width"] - height = media_item["mediaMetadata"]["height"] - key = "h" if height > width else "w" - return f"{media_item["baseUrl"]}={key}{max_size}" + return f"{media_item["baseUrl"]}=h{max_size}" def _video_url(media_item: dict[str, Any]) -> str: diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index b24b37c10e6..db57ab755c1 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -58,7 +58,7 @@ async def test_no_config_entries( (f"{CONFIG_ENTRY_ID}/p/id2", "example2.mp4"), ], [ - ("http://img.example.com/id1=w2048", "image/jpeg"), + ("http://img.example.com/id1=h2160", "image/jpeg"), ("http://img.example.com/id2=dv", "video/mp4"), ], ), @@ -90,7 +90,7 @@ async def test_recent_items( hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" ) assert browse.domain == DOMAIN - assert browse.identifier == CONFIG_ENTRY_ID + assert browse.identifier == f"{CONFIG_ENTRY_ID}/a/recent" assert browse.title == "Account Name" assert [ (child.identifier, child.title) for child in browse.children @@ -136,7 +136,9 @@ async def test_invalid_album_id(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("identifier", "expected_error"), [ - ("invalid-config-entry", "without a photo_media_id"), + (CONFIG_ENTRY_ID, "not a Photo"), + ("invalid-config-entry/a/example", "not a Photo"), + ("invalid-config-entry/q/example", "Could not parse"), ("too/many/slashes/in/path", "Invalid identifier"), ], ) From 5fa23b1785bb3ea162488f667aef13b60129b35b Mon Sep 17 00:00:00 2001 From: vhkristof Date: Sat, 31 Aug 2024 10:56:23 +0200 Subject: [PATCH 0094/1309] Bump renault-api to v0.2.7 (#124858) * Bump renault-api to v0.2.7 * Updated requirements_all and requirements_test_all --- homeassistant/components/renault/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 6691921e850..716f2086bf1 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "platinum", - "requirements": ["renault-api==0.2.5"] + "requirements": ["renault-api==0.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index afb7ae7bc77..52c7879cb91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2505,7 +2505,7 @@ refoss-ha==1.2.4 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.5 +renault-api==0.2.7 # homeassistant.components.renson renson-endura-delta==1.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08903ae1c6f..20a86de78fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1987,7 +1987,7 @@ refoss-ha==1.2.4 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.2.5 +renault-api==0.2.7 # homeassistant.components.renson renson-endura-delta==1.7.1 From 7210cc1da6224f43abe1e33c2fd605dc6e9daf9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 30 Aug 2024 23:03:08 -1000 Subject: [PATCH 0095/1309] Bump yarl to 1.9.6 (#124955) * Bump yarl to 1.9.5 changelog: https://github.com/aio-libs/yarl/compare/v1.9.4...v1.9.5 * remove default port since mocker does exact matching and yarl now normalizes this * 1.9.6 --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/dremel_3d_printer/conftest.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c01b23ab4e4..30edee058bb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ urllib3>=1.26.5,<2 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.9.4 +yarl==1.9.6 zeroconf==0.133.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 596c0297131..4376ed63d0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.9.4", + "yarl==1.9.6", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index ad6a39ddb54..a9e01545b83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,4 @@ urllib3>=1.26.5,<2 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.9.4 +yarl==1.9.6 diff --git a/tests/components/dremel_3d_printer/conftest.py b/tests/components/dremel_3d_printer/conftest.py index 6490b844dc0..cc70537db3d 100644 --- a/tests/components/dremel_3d_printer/conftest.py +++ b/tests/components/dremel_3d_printer/conftest.py @@ -34,7 +34,7 @@ def connection() -> None: """Mock Dremel 3D Printer connection.""" with requests_mock.Mocker() as mock: mock.post( - f"http://{HOST}:80/command", + f"http://{HOST}/command", response_list=[ {"text": load_fixture("dremel_3d_printer/command_1.json")}, {"text": load_fixture("dremel_3d_printer/command_2.json")}, From 221f9615742f41307b17b7fda7a2ff6a7ccecd7b Mon Sep 17 00:00:00 2001 From: Alan Murray Date: Sat, 31 Aug 2024 19:33:58 +1000 Subject: [PATCH 0096/1309] Bump aiopulse to 0.4.6 (#124964) Non-breaking changes to fix isses: * eliminating hub exceptions raised due use of unicode strings. * eliminating hub exceptions raised due to Timers being configured on hub. --- homeassistant/components/acmeda/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/acmeda/manifest.json b/homeassistant/components/acmeda/manifest.json index a8b3c7c829f..0c35904cac6 100644 --- a/homeassistant/components/acmeda/manifest.json +++ b/homeassistant/components/acmeda/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/acmeda", "iot_class": "local_push", "loggers": ["aiopulse"], - "requirements": ["aiopulse==0.4.4"] + "requirements": ["aiopulse==0.4.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 52c7879cb91..d8e33b96236 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -318,7 +318,7 @@ aiooui==0.1.6 aiopegelonline==0.0.10 # homeassistant.components.acmeda -aiopulse==0.4.4 +aiopulse==0.4.6 # homeassistant.components.purpleair aiopurpleair==2022.12.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20a86de78fd..d253225d17b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -300,7 +300,7 @@ aiooui==0.1.6 aiopegelonline==0.0.10 # homeassistant.components.acmeda -aiopulse==0.4.4 +aiopulse==0.4.6 # homeassistant.components.purpleair aiopurpleair==2022.12.1 From 36b7e8569ec0f9ca93b079523b60501bb38d254b Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sat, 31 Aug 2024 11:42:22 +0200 Subject: [PATCH 0097/1309] Send entity name or original name to LCN frontend (#124518) * Send name or original name to frontend * Use walrus operator * Fix docstring * Fix mutated config_entry.data --- homeassistant/components/lcn/websocket.py | 28 ++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index b418e362b27..65896cc78d1 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -158,7 +158,13 @@ async def websocket_get_entity_configs( else: entity_configs = config_entry.data[CONF_ENTITIES] - connection.send_result(msg["id"], entity_configs) + result_entity_configs = [ + {**entity_config, CONF_NAME: entity.name or entity.original_name} + for entity_config in entity_configs[:] + if (entity := get_entity_entry(hass, entity_config, config_entry)) is not None + ] + + connection.send_result(msg["id"], result_entity_configs) @websocket_api.require_admin @@ -438,3 +444,23 @@ async def async_create_or_update_device_in_config_entry( await async_update_device_config(device_connection, device_config) hass.config_entries.async_update_entry(config_entry, data=data) + + +def get_entity_entry( + hass: HomeAssistant, entity_config: dict, config_entry: ConfigEntry +) -> er.RegistryEntry | None: + """Get entity RegistryEntry from entity_config.""" + entity_registry = er.async_get(hass) + domain_name = entity_config[CONF_DOMAIN] + domain_data = entity_config[CONF_DOMAIN_DATA] + resource = get_resource(domain_name, domain_data).lower() + unique_id = generate_unique_id( + config_entry.entry_id, + entity_config[CONF_ADDRESS], + resource, + ) + if ( + entity_id := entity_registry.async_get_entity_id(domain_name, DOMAIN, unique_id) + ) is None: + return None + return entity_registry.async_get(entity_id) From 2a8feda69116d0884965156ee78d444ab3803fdf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 31 Aug 2024 12:00:12 +0200 Subject: [PATCH 0098/1309] Define household support in Mealie (#124950) --- homeassistant/components/mealie/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index 5c9c91729c0..bf0fbcac406 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -48,6 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo ), ) try: + await client.define_household_support() about = await client.get_about() version = create_version(about.version) except MealieAuthenticationError as error: From 65f007ace7a8346661e97d1f98f81d02b4a61f5b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 1 Sep 2024 00:28:35 +1000 Subject: [PATCH 0099/1309] Remove HVAC Modes when no scopes in Teslemetry (#124612) * Remove modes when not scoped * Fix inits * Re-add raise * Remove unused raise_for_scope * Set hvac_modes when not scoped * tests --- .../components/teslemetry/climate.py | 36 +++++---- .../teslemetry/snapshots/test_climate.ambr | 79 +++++++++++++++++++ tests/components/teslemetry/test_climate.py | 15 +++- 3 files changed, 112 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index bd4fb0eba53..9fc68688271 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -84,8 +84,10 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): ) -> None: """Initialize the climate.""" self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: self._attr_supported_features = ClimateEntityFeature(0) + self._attr_hvac_modes = [] super().__init__( data, @@ -102,6 +104,10 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): else: self._attr_hvac_mode = HVACMode.OFF + # If not scoped, prevent the user from changing the HVAC mode by making it the only option + if self._attr_hvac_mode and not self.scoped: + self._attr_hvac_modes = [self._attr_hvac_mode] + self._attr_current_temperature = self.get("climate_state_inside_temp") self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting") self._attr_preset_mode = self.get("climate_state_climate_keeper_mode") @@ -114,7 +120,6 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_turn_on(self) -> None: """Set the climate state to on.""" - self.raise_for_scope() await self.wake_up_if_asleep() await handle_vehicle_command(self.api.auto_conditioning_start()) @@ -124,7 +129,6 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_turn_off(self) -> None: """Set the climate state to off.""" - self.raise_for_scope() await self.wake_up_if_asleep() await handle_vehicle_command(self.api.auto_conditioning_stop()) @@ -135,7 +139,6 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set the climate temperature.""" - if temp := kwargs.get(ATTR_TEMPERATURE): await self.wake_up_if_asleep() await handle_vehicle_command( @@ -206,20 +209,21 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn ) -> None: """Initialize the climate.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + if self.scoped: + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + else: + self._attr_supported_features = ClimateEntityFeature(0) + self._attr_hvac_modes = [] + super().__init__(data, "climate_state_cabin_overheat_protection") - # Supported Features - self._attr_supported_features = ( - ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF - ) - if self.get("vehicle_config_cop_user_set_temp_supported"): + # Supported Features from data + if self.scoped and self.get("vehicle_config_cop_user_set_temp_supported"): self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - # Scopes - self.scoped = Scope.VEHICLE_CMDS in scopes - if not self.scoped: - self._attr_supported_features = ClimateEntityFeature(0) - def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" @@ -228,6 +232,10 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn else: self._attr_hvac_mode = COP_MODES.get(state) + # If not scoped, prevent the user from changing the HVAC mode by making it the only option + if self._attr_hvac_mode and not self.scoped: + self._attr_hvac_modes = [self._attr_hvac_mode] + if (level := self.get("climate_state_cop_activation_temperature")) is None: self._attr_target_temperature = None else: @@ -245,8 +253,6 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn async def async_set_temperature(self, **kwargs: Any) -> None: """Set the climate temperature.""" - self.raise_for_scope() - if not (temp := kwargs.get(ATTR_TEMPERATURE)): return diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index b65796fe10e..f5a95c7e3f2 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -280,6 +280,85 @@ 'state': 'off', }) # --- +# name: test_climate_noscope[climate.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'target_temp_step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_noscope[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unit_of_measurement': None, + }) +# --- # name: test_climate_offline[climate.test_cabin_overheat_protection-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 31a39f1f21a..3cb4b67dc54 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -1,6 +1,6 @@ """Test the Teslemetry climate platform.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -371,12 +371,21 @@ async def test_asleep_or_offline( async def test_climate_noscope( hass: HomeAssistant, - mock_metadata, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_metadata: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" mock_metadata.return_value = METADATA_NOSCOPE - await setup_platform(hass, [Platform.CLIMATE]) + entry = await setup_platform(hass, [Platform.CLIMATE]) + + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + assert entity_entries + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + entity_id = "climate.test_climate" with pytest.raises(ServiceValidationError): From 9da5dd0090f947eeab79230e2c5b3e23127cc515 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 31 Aug 2024 16:38:06 +0200 Subject: [PATCH 0100/1309] Improve config flow type hints in cast (#124861) --- homeassistant/components/cast/config_flow.py | 34 +++++++++++++------- tests/components/cast/test_config_flow.py | 4 +-- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 22351f5d2f7..4f7dd59e83e 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -63,7 +63,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() - async def async_step_config(self, user_input=None): + async def async_step_config( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Confirm the setup.""" errors = {} data = {CONF_KNOWN_HOSTS: self._known_hosts} @@ -90,7 +92,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): step_id="config", data_schema=vol.Schema(fields), errors=errors ) - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Confirm the setup.""" data = self._get_data() @@ -116,13 +120,15 @@ class CastOptionsFlowHandler(OptionsFlow): self.config_entry = config_entry self.updated_config: dict[str, Any] = {} - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: None = None) -> ConfigFlowResult: """Manage the Google Cast options.""" return await self.async_step_basic_options() - async def async_step_basic_options(self, user_input=None): + async def async_step_basic_options( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the Google Cast options.""" - errors = {} + errors: dict[str, str] = {} current_config = self.config_entry.data if user_input is not None: bad_hosts, known_hosts = _string_to_list( @@ -139,9 +145,9 @@ class CastOptionsFlowHandler(OptionsFlow): self.hass.config_entries.async_update_entry( self.config_entry, data=self.updated_config ) - return self.async_create_entry(title="", data=None) + return self.async_create_entry(title="", data={}) - fields = {} + fields: dict[vol.Marker, type[str]] = {} suggested_value = _list_to_string(current_config.get(CONF_KNOWN_HOSTS)) _add_with_suggestion(fields, CONF_KNOWN_HOSTS, suggested_value) @@ -152,9 +158,11 @@ class CastOptionsFlowHandler(OptionsFlow): last_step=not self.show_advanced_options, ) - async def async_step_advanced_options(self, user_input=None): + async def async_step_advanced_options( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Manage the Google Cast options.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: bad_cec, ignore_cec = _string_to_list( user_input.get(CONF_IGNORE_CEC, ""), IGNORE_CEC_SCHEMA @@ -169,9 +177,9 @@ class CastOptionsFlowHandler(OptionsFlow): self.hass.config_entries.async_update_entry( self.config_entry, data=self.updated_config ) - return self.async_create_entry(title="", data=None) + return self.async_create_entry(title="", data={}) - fields = {} + fields: dict[vol.Marker, type[str]] = {} current_config = self.config_entry.data suggested_value = _list_to_string(current_config.get(CONF_UUID)) _add_with_suggestion(fields, CONF_UUID, suggested_value) @@ -204,5 +212,7 @@ def _string_to_list(string, schema): return invalid, items -def _add_with_suggestion(fields, key, suggested_value): +def _add_with_suggestion( + fields: dict[vol.Marker, type[str]], key: str, suggested_value: str +) -> None: fields[vol.Optional(key, description={"suggested_value": suggested_value})] = str diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 7dce3f768e2..2dcf007c6d4 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -250,7 +250,7 @@ async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: user_input=user_input_dict, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] is None + assert result["data"] == {} for other_param in advanced_parameters: if other_param == parameter: continue @@ -264,7 +264,7 @@ async def test_option_flow(hass: HomeAssistant, parameter_data) -> None: user_input={"known_hosts": ""}, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] is None + assert result["data"] == {} expected_data = {**orig_data, "known_hosts": []} if parameter in advanced_parameters: expected_data[parameter] = updated From 30aa3a26adb3f9fc1e7b7787c987592710cbcfdb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 31 Aug 2024 16:40:12 +0200 Subject: [PATCH 0101/1309] Merge coordinators in Airgradient (#124714) --- .../components/airgradient/__init__.py | 40 +++------------- .../components/airgradient/button.py | 17 +++---- .../components/airgradient/coordinator.py | 35 ++++++-------- .../components/airgradient/entity.py | 8 ++++ .../components/airgradient/number.py | 16 +++---- .../components/airgradient/select.py | 18 ++++--- .../components/airgradient/sensor.py | 47 ++++++++++--------- .../components/airgradient/switch.py | 14 +++--- .../components/airgradient/update.py | 11 ++--- .../airgradient/snapshots/test_init.ambr | 2 +- tests/components/airgradient/test_button.py | 2 +- .../airgradient/test_config_flow.py | 2 +- tests/components/airgradient/test_init.py | 2 +- tests/components/airgradient/test_number.py | 2 +- tests/components/airgradient/test_select.py | 2 +- tests/components/airgradient/test_sensor.py | 2 +- tests/components/airgradient/test_switch.py | 2 +- 17 files changed, 98 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/airgradient/__init__.py b/homeassistant/components/airgradient/__init__.py index 7ee8ac6a3c7..3b27d6cda5e 100644 --- a/homeassistant/components/airgradient/__init__.py +++ b/homeassistant/components/airgradient/__init__.py @@ -2,18 +2,14 @@ from __future__ import annotations -from dataclasses import dataclass - -from airgradient import AirGradientClient, get_model_name +from airgradient import AirGradientClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator +from .coordinator import AirGradientCoordinator PLATFORMS: list[Platform] = [ Platform.BUTTON, @@ -25,15 +21,7 @@ PLATFORMS: list[Platform] = [ ] -@dataclass -class AirGradientData: - """AirGradient data class.""" - - measurement: AirGradientMeasurementCoordinator - config: AirGradientConfigCoordinator - - -type AirGradientConfigEntry = ConfigEntry[AirGradientData] +type AirGradientConfigEntry = ConfigEntry[AirGradientCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) -> bool: @@ -43,27 +31,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirGradientConfigEntry) entry.data[CONF_HOST], session=async_get_clientsession(hass) ) - measurement_coordinator = AirGradientMeasurementCoordinator(hass, client) - config_coordinator = AirGradientConfigCoordinator(hass, client) + coordinator = AirGradientCoordinator(hass, client) - await measurement_coordinator.async_config_entry_first_refresh() - await config_coordinator.async_config_entry_first_refresh() + await coordinator.async_config_entry_first_refresh() - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, measurement_coordinator.serial_number)}, - manufacturer="AirGradient", - model=get_model_name(measurement_coordinator.data.model), - model_id=measurement_coordinator.data.model, - serial_number=measurement_coordinator.data.serial_number, - sw_version=measurement_coordinator.data.firmware_version, - ) - - entry.runtime_data = AirGradientData( - measurement=measurement_coordinator, - config=config_coordinator, - ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/airgradient/button.py b/homeassistant/components/airgradient/button.py index b59188ebdd4..32a9b5adedf 100644 --- a/homeassistant/components/airgradient/button.py +++ b/homeassistant/components/airgradient/button.py @@ -15,8 +15,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, AirGradientConfigEntry -from .coordinator import AirGradientConfigCoordinator +from . import AirGradientConfigEntry +from .const import DOMAIN +from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity @@ -47,8 +48,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up AirGradient button entities based on a config entry.""" - model = entry.runtime_data.measurement.data.model - coordinator = entry.runtime_data.config + coordinator = entry.runtime_data + model = coordinator.data.measures.model added_entities = False @@ -57,7 +58,7 @@ async def async_setup_entry( nonlocal added_entities if ( - coordinator.data.configuration_control is ConfigurationControl.LOCAL + coordinator.data.config.configuration_control is ConfigurationControl.LOCAL and not added_entities ): entities = [AirGradientButton(coordinator, CO2_CALIBRATION)] @@ -67,7 +68,8 @@ async def async_setup_entry( async_add_entities(entities) added_entities = True elif ( - coordinator.data.configuration_control is not ConfigurationControl.LOCAL + coordinator.data.config.configuration_control + is not ConfigurationControl.LOCAL and added_entities ): entity_registry = er.async_get(hass) @@ -87,11 +89,10 @@ class AirGradientButton(AirGradientEntity, ButtonEntity): """Defines an AirGradient button.""" entity_description: AirGradientButtonEntityDescription - coordinator: AirGradientConfigCoordinator def __init__( self, - coordinator: AirGradientConfigCoordinator, + coordinator: AirGradientCoordinator, description: AirGradientButtonEntityDescription, ) -> None: """Initialize airgradient button.""" diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py index c3def0b1f33..4e1c335019c 100644 --- a/homeassistant/components/airgradient/coordinator.py +++ b/homeassistant/components/airgradient/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING @@ -16,7 +17,15 @@ if TYPE_CHECKING: from . import AirGradientConfigEntry -class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]): +@dataclass +class AirGradientData: + """Class for AirGradient data.""" + + measures: Measures + config: Config + + +class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]): """Class to manage fetching AirGradient data.""" config_entry: AirGradientConfigEntry @@ -33,25 +42,11 @@ class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]): assert self.config_entry.unique_id self.serial_number = self.config_entry.unique_id - async def _async_update_data(self) -> _DataT: + async def _async_update_data(self) -> AirGradientData: try: - return await self._update_data() + measures = await self.client.get_current_measures() + config = await self.client.get_config() except AirGradientError as error: raise UpdateFailed(error) from error - - async def _update_data(self) -> _DataT: - raise NotImplementedError - - -class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]): - """Class to manage fetching AirGradient data.""" - - async def _update_data(self) -> Measures: - return await self.client.get_current_measures() - - -class AirGradientConfigCoordinator(AirGradientCoordinator[Config]): - """Class to manage fetching AirGradient data.""" - - async def _update_data(self) -> Config: - return await self.client.get_config() + else: + return AirGradientData(measures, config) diff --git a/homeassistant/components/airgradient/entity.py b/homeassistant/components/airgradient/entity.py index 4de07904bba..588a799610b 100644 --- a/homeassistant/components/airgradient/entity.py +++ b/homeassistant/components/airgradient/entity.py @@ -1,5 +1,7 @@ """Base class for AirGradient entities.""" +from airgradient import get_model_name + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -15,6 +17,12 @@ class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]): def __init__(self, coordinator: AirGradientCoordinator) -> None: """Initialize airgradient entity.""" super().__init__(coordinator) + measures = coordinator.data.measures self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.serial_number)}, + manufacturer="AirGradient", + model=get_model_name(measures.model), + model_id=measures.model, + serial_number=coordinator.serial_number, + sw_version=measures.firmware_version, ) diff --git a/homeassistant/components/airgradient/number.py b/homeassistant/components/airgradient/number.py index 139357f3753..7fd282ddd8b 100644 --- a/homeassistant/components/airgradient/number.py +++ b/homeassistant/components/airgradient/number.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN -from .coordinator import AirGradientConfigCoordinator +from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity @@ -62,8 +62,8 @@ async def async_setup_entry( ) -> None: """Set up AirGradient number entities based on a config entry.""" - model = entry.runtime_data.measurement.data.model - coordinator = entry.runtime_data.config + coordinator = entry.runtime_data + model = coordinator.data.measures.model added_entities = False @@ -72,7 +72,7 @@ async def async_setup_entry( nonlocal added_entities if ( - coordinator.data.configuration_control is ConfigurationControl.LOCAL + coordinator.data.config.configuration_control is ConfigurationControl.LOCAL and not added_entities ): entities = [] @@ -84,7 +84,8 @@ async def async_setup_entry( async_add_entities(entities) added_entities = True elif ( - coordinator.data.configuration_control is not ConfigurationControl.LOCAL + coordinator.data.config.configuration_control + is not ConfigurationControl.LOCAL and added_entities ): entity_registry = er.async_get(hass) @@ -104,11 +105,10 @@ class AirGradientNumber(AirGradientEntity, NumberEntity): """Defines an AirGradient number entity.""" entity_description: AirGradientNumberEntityDescription - coordinator: AirGradientConfigCoordinator def __init__( self, - coordinator: AirGradientConfigCoordinator, + coordinator: AirGradientCoordinator, description: AirGradientNumberEntityDescription, ) -> None: """Initialize AirGradient number.""" @@ -119,7 +119,7 @@ class AirGradientNumber(AirGradientEntity, NumberEntity): @property def native_value(self) -> int | None: """Return the state of the number.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self.coordinator.data.config) async def async_set_native_value(self, value: float) -> None: """Set the selected value.""" diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py index 532f7167dff..af56802d842 100644 --- a/homeassistant/components/airgradient/select.py +++ b/homeassistant/components/airgradient/select.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN, PM_STANDARD, PM_STANDARD_REVERSE -from .coordinator import AirGradientConfigCoordinator +from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity @@ -144,13 +144,11 @@ async def async_setup_entry( ) -> None: """Set up AirGradient select entities based on a config entry.""" - coordinator = entry.runtime_data.config - measurement_coordinator = entry.runtime_data.measurement + coordinator = entry.runtime_data + model = coordinator.data.measures.model async_add_entities([AirGradientSelect(coordinator, CONFIG_CONTROL_ENTITY)]) - model = measurement_coordinator.data.model - added_entities = False @callback @@ -158,7 +156,7 @@ async def async_setup_entry( nonlocal added_entities if ( - coordinator.data.configuration_control is ConfigurationControl.LOCAL + coordinator.data.config.configuration_control is ConfigurationControl.LOCAL and not added_entities ): entities: list[AirGradientSelect] = [ @@ -179,7 +177,8 @@ async def async_setup_entry( async_add_entities(entities) added_entities = True elif ( - coordinator.data.configuration_control is not ConfigurationControl.LOCAL + coordinator.data.config.configuration_control + is not ConfigurationControl.LOCAL and added_entities ): entity_registry = er.async_get(hass) @@ -201,11 +200,10 @@ class AirGradientSelect(AirGradientEntity, SelectEntity): """Defines an AirGradient select entity.""" entity_description: AirGradientSelectEntityDescription - coordinator: AirGradientConfigCoordinator def __init__( self, - coordinator: AirGradientConfigCoordinator, + coordinator: AirGradientCoordinator, description: AirGradientSelectEntityDescription, ) -> None: """Initialize AirGradient select.""" @@ -216,7 +214,7 @@ class AirGradientSelect(AirGradientEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the state of the select.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self.coordinator.data.config) async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py index f431c49ed2a..497d4cc0488 100644 --- a/homeassistant/components/airgradient/sensor.py +++ b/homeassistant/components/airgradient/sensor.py @@ -32,7 +32,7 @@ from homeassistant.helpers.typing import StateType from . import AirGradientConfigEntry from .const import PM_STANDARD, PM_STANDARD_REVERSE -from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator +from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity @@ -218,7 +218,7 @@ async def async_setup_entry( ) -> None: """Set up AirGradient sensor entities based on a config entry.""" - coordinator = entry.runtime_data.measurement + coordinator = entry.runtime_data listener: Callable[[], None] | None = None not_setup: set[AirGradientMeasurementSensorEntityDescription] = set( MEASUREMENT_SENSOR_TYPES @@ -232,7 +232,7 @@ async def async_setup_entry( not_setup = set() sensors = [] for description in sensor_descriptions: - if description.value_fn(coordinator.data) is None: + if description.value_fn(coordinator.data.measures) is None: not_setup.add(description) else: sensors.append(AirGradientMeasurementSensor(coordinator, description)) @@ -248,64 +248,65 @@ async def async_setup_entry( add_entities() entities = [ - AirGradientConfigSensor(entry.runtime_data.config, description) + AirGradientConfigSensor(coordinator, description) for description in CONFIG_SENSOR_TYPES ] - if "L" in coordinator.data.model: + if "L" in coordinator.data.measures.model: entities.extend( - AirGradientConfigSensor(entry.runtime_data.config, description) + AirGradientConfigSensor(coordinator, description) for description in CONFIG_LED_BAR_SENSOR_TYPES ) - if "I" in coordinator.data.model: + if "I" in coordinator.data.measures.model: entities.extend( - AirGradientConfigSensor(entry.runtime_data.config, description) + AirGradientConfigSensor(coordinator, description) for description in CONFIG_DISPLAY_SENSOR_TYPES ) async_add_entities(entities) -class AirGradientMeasurementSensor(AirGradientEntity, SensorEntity): +class AirGradientSensor(AirGradientEntity, SensorEntity): """Defines an AirGradient sensor.""" - entity_description: AirGradientMeasurementSensorEntityDescription - coordinator: AirGradientMeasurementCoordinator - def __init__( self, - coordinator: AirGradientMeasurementCoordinator, - description: AirGradientMeasurementSensorEntityDescription, + coordinator: AirGradientCoordinator, + description: SensorEntityDescription, ) -> None: """Initialize airgradient sensor.""" super().__init__(coordinator) self.entity_description = description self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + +class AirGradientMeasurementSensor(AirGradientSensor): + """Defines an AirGradient sensor.""" + + entity_description: AirGradientMeasurementSensorEntityDescription + @property def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self.coordinator.data.measures) -class AirGradientConfigSensor(AirGradientEntity, SensorEntity): +class AirGradientConfigSensor(AirGradientSensor): """Defines an AirGradient sensor.""" entity_description: AirGradientConfigSensorEntityDescription - coordinator: AirGradientConfigCoordinator def __init__( self, - coordinator: AirGradientConfigCoordinator, + coordinator: AirGradientCoordinator, description: AirGradientConfigSensorEntityDescription, ) -> None: """Initialize airgradient sensor.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + super().__init__(coordinator, description) self._attr_entity_registry_enabled_default = ( - coordinator.data.configuration_control is not ConfigurationControl.LOCAL + coordinator.data.config.configuration_control + is not ConfigurationControl.LOCAL ) @property def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self.coordinator.data.config) diff --git a/homeassistant/components/airgradient/switch.py b/homeassistant/components/airgradient/switch.py index 60c3f83ae5e..329f704e755 100644 --- a/homeassistant/components/airgradient/switch.py +++ b/homeassistant/components/airgradient/switch.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AirGradientConfigEntry from .const import DOMAIN -from .coordinator import AirGradientConfigCoordinator +from .coordinator import AirGradientCoordinator from .entity import AirGradientEntity @@ -46,7 +46,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up AirGradient switch entities based on a config entry.""" - coordinator = entry.runtime_data.config + coordinator = entry.runtime_data added_entities = False @@ -55,7 +55,7 @@ async def async_setup_entry( nonlocal added_entities if ( - coordinator.data.configuration_control is ConfigurationControl.LOCAL + coordinator.data.config.configuration_control is ConfigurationControl.LOCAL and not added_entities ): async_add_entities( @@ -63,7 +63,8 @@ async def async_setup_entry( ) added_entities = True elif ( - coordinator.data.configuration_control is not ConfigurationControl.LOCAL + coordinator.data.config.configuration_control + is not ConfigurationControl.LOCAL and added_entities ): entity_registry = er.async_get(hass) @@ -82,11 +83,10 @@ class AirGradientSwitch(AirGradientEntity, SwitchEntity): """Defines an AirGradient switch entity.""" entity_description: AirGradientSwitchEntityDescription - coordinator: AirGradientConfigCoordinator def __init__( self, - coordinator: AirGradientConfigCoordinator, + coordinator: AirGradientCoordinator, description: AirGradientSwitchEntityDescription, ) -> None: """Initialize AirGradient switch.""" @@ -97,7 +97,7 @@ class AirGradientSwitch(AirGradientEntity, SwitchEntity): @property def is_on(self) -> bool: """Return the state of the switch.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self.coordinator.data.config) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" diff --git a/homeassistant/components/airgradient/update.py b/homeassistant/components/airgradient/update.py index 95e64930ea6..eb6708afb67 100644 --- a/homeassistant/components/airgradient/update.py +++ b/homeassistant/components/airgradient/update.py @@ -7,7 +7,7 @@ from homeassistant.components.update import UpdateDeviceClass, UpdateEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirGradientConfigEntry, AirGradientMeasurementCoordinator +from . import AirGradientConfigEntry, AirGradientCoordinator from .entity import AirGradientEntity SCAN_INTERVAL = timedelta(hours=1) @@ -20,18 +20,17 @@ async def async_setup_entry( ) -> None: """Set up Airgradient update platform.""" - data = config_entry.runtime_data + coordinator = config_entry.runtime_data - async_add_entities([AirGradientUpdate(data.measurement)], True) + async_add_entities([AirGradientUpdate(coordinator)], True) class AirGradientUpdate(AirGradientEntity, UpdateEntity): """Representation of Airgradient Update.""" _attr_device_class = UpdateDeviceClass.FIRMWARE - coordinator: AirGradientMeasurementCoordinator - def __init__(self, coordinator: AirGradientMeasurementCoordinator) -> None: + def __init__(self, coordinator: AirGradientCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) self._attr_unique_id = f"{coordinator.serial_number}-update" @@ -44,7 +43,7 @@ class AirGradientUpdate(AirGradientEntity, UpdateEntity): @property def installed_version(self) -> str: """Return the installed version of the entity.""" - return self.coordinator.data.firmware_version + return self.coordinator.data.measures.firmware_version async def async_update(self) -> None: """Update the entity.""" diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr index e47c5b38bbc..72cb12535f1 100644 --- a/tests/components/airgradient/snapshots/test_init.ambr +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -57,7 +57,7 @@ 'name': 'Airgradient', 'name_by_user': None, 'primary_config_entry': , - 'serial_number': '84fce60bec38', + 'serial_number': '84fce612f5b8', 'suggested_area': None, 'sw_version': '3.1.1', 'via_device_id': None, diff --git a/tests/components/airgradient/test_button.py b/tests/components/airgradient/test_button.py index 7901c3a067b..83de2c2f048 100644 --- a/tests/components/airgradient/test_button.py +++ b/tests/components/airgradient/test_button.py @@ -7,7 +7,7 @@ from airgradient import Config from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion -from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py index 8730b18676f..73dbd17a213 100644 --- a/tests/components/airgradient/test_config_flow.py +++ b/tests/components/airgradient/test_config_flow.py @@ -9,7 +9,7 @@ from airgradient import ( ConfigurationControl, ) -from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST diff --git a/tests/components/airgradient/test_init.py b/tests/components/airgradient/test_init.py index 408e6f5f3ba..a566254d106 100644 --- a/tests/components/airgradient/test_init.py +++ b/tests/components/airgradient/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from syrupy import SnapshotAssertion -from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.airgradient.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py index 0803c0d437f..7aabda8f81c 100644 --- a/tests/components/airgradient/test_number.py +++ b/tests/components/airgradient/test_number.py @@ -7,7 +7,7 @@ from airgradient import Config from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion -from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, diff --git a/tests/components/airgradient/test_select.py b/tests/components/airgradient/test_select.py index 61679a15c07..de4a7beaaa7 100644 --- a/tests/components/airgradient/test_select.py +++ b/tests/components/airgradient/test_select.py @@ -8,7 +8,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/airgradient/test_sensor.py b/tests/components/airgradient/test_sensor.py index c2e53ef4de2..e3fed70839a 100644 --- a/tests/components/airgradient/test_sensor.py +++ b/tests/components/airgradient/test_sensor.py @@ -8,7 +8,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.airgradient.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er diff --git a/tests/components/airgradient/test_switch.py b/tests/components/airgradient/test_switch.py index 20a1cb7470b..a0cbdd17d75 100644 --- a/tests/components/airgradient/test_switch.py +++ b/tests/components/airgradient/test_switch.py @@ -7,7 +7,7 @@ from airgradient import Config from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion -from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, From 3e60d7aa11cc7d89149d06d49437bf24044fdb6d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 1 Sep 2024 00:41:00 +1000 Subject: [PATCH 0102/1309] Small code quality fix in Teslemetry (#124603) * Fix cop_mode logic bug * Update climate.py * Fix attributes --- .../components/teslemetry/climate.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 9fc68688271..9218be4dcb1 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -183,20 +183,28 @@ COP_MODES = { "FanOnly": HVACMode.FAN_ONLY, } +# String to celsius COP_LEVELS = { "Low": 30, "Medium": 35, "High": 40, } +# Celsius to IntEnum +TEMP_LEVELS = { + 30: CabinOverheatProtectionTemp.LOW, + 35: CabinOverheatProtectionTemp.MEDIUM, + 40: CabinOverheatProtectionTemp.HIGH, +} + class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEntity): """Telemetry vehicle cabin overheat protection entity.""" _attr_precision = PRECISION_WHOLE _attr_target_temperature_step = 5 - _attr_min_temp = 30 - _attr_max_temp = 40 + _attr_min_temp = COP_LEVELS["Low"] + _attr_max_temp = COP_LEVELS["High"] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(COP_MODES.values()) _enable_turn_on_off_backwards_compatibility = False @@ -256,13 +264,7 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn if not (temp := kwargs.get(ATTR_TEMPERATURE)): return - if temp == 30: - cop_mode = CabinOverheatProtectionTemp.LOW - elif temp == 35: - cop_mode = CabinOverheatProtectionTemp.MEDIUM - elif temp == 40: - cop_mode = CabinOverheatProtectionTemp.HIGH - else: + if (cop_mode := TEMP_LEVELS.get(temp)) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_cop_temp", From 81f5068354372ec67f03b4bca8e62318611a41bb Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 31 Aug 2024 08:22:50 -0700 Subject: [PATCH 0103/1309] Clean up Google Photos media source (#124977) * Clean up Google Photos media source * Fix typo --------- Co-authored-by: Martin Hjelmare --- .../components/google_photos/media_source.py | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index a2f9383ec5f..cdb6b22a3ed 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from enum import StrEnum import logging -from typing import Any, cast +from typing import Any, Self, cast from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source import ( @@ -77,37 +77,31 @@ class PhotosIdentifier: """Identifies the album or photo contents to show.""" def as_string(self) -> str: - """Serialize the identiifer as a string. - - This is the opposite if of(). - """ + """Serialize the identifier as a string.""" if self.id_type is None: return self.config_entry_id return f"{self.config_entry_id}/{self.id_type}/{self.media_id}" - @staticmethod - def of(identifier: str) -> "PhotosIdentifier": - """Parse a PhotosIdentifier form a string. - - This is the opposite of as_string(). - """ + @classmethod + def of(cls, identifier: str) -> Self: + """Parse a PhotosIdentifier form a string.""" parts = identifier.split("/") _LOGGER.debug("parts=%s", parts) if len(parts) == 1: - return PhotosIdentifier(parts[0]) + return cls(parts[0]) if len(parts) != 3: raise BrowseError(f"Invalid identifier: {identifier}") - return PhotosIdentifier(parts[0], PhotosIdentifierType.of(parts[1]), parts[2]) + return cls(parts[0], PhotosIdentifierType.of(parts[1]), parts[2]) - @staticmethod - def album(config_entry_id: str, media_id: str) -> "PhotosIdentifier": + @classmethod + def album(cls, config_entry_id: str, media_id: str) -> Self: """Create an album PhotosIdentifier.""" - return PhotosIdentifier(config_entry_id, PhotosIdentifierType.ALBUM, media_id) + return cls(config_entry_id, PhotosIdentifierType.ALBUM, media_id) - @staticmethod - def photo(config_entry_id: str, media_id: str) -> "PhotosIdentifier": + @classmethod + def photo(cls, config_entry_id: str, media_id: str) -> Self: """Create an album PhotosIdentifier.""" - return PhotosIdentifier(config_entry_id, PhotosIdentifierType.PHOTO, media_id) + return cls(config_entry_id, PhotosIdentifierType.PHOTO, media_id) async def async_get_media_source(hass: HomeAssistant) -> MediaSource: From 994c2ebca124b6e2610324e8086a6d6495efef93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 31 Aug 2024 17:30:58 +0200 Subject: [PATCH 0104/1309] Update aioairzone-cloud to v0.6.3 (#124978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index b691770e934..05f854e6caf 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.2"] + "requirements": ["aioairzone-cloud==0.6.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index d8e33b96236..1be3393dfc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -176,7 +176,7 @@ aio-georss-gdacs==0.9 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.2 +aioairzone-cloud==0.6.3 # homeassistant.components.airzone aioairzone==0.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d253225d17b..4bd9dfa1e29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aio-georss-gdacs==0.9 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.2 +aioairzone-cloud==0.6.3 # homeassistant.components.airzone aioairzone==0.8.2 From 5cd8e4ab7e879f955a43f36618f757bcf9c6cf7e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 31 Aug 2024 19:34:41 +0200 Subject: [PATCH 0105/1309] Update mypy-dev to 1.12.0a3 (#124939) * Update mypy-dev to 1.12.0a3 * Fix --- homeassistant/runner.py | 2 +- homeassistant/util/frozen_dataclass_compat.py | 7 ++++++- requirements_test.txt | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 4bac12ec399..102dbafe147 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -175,7 +175,7 @@ def _enable_posix_spawn() -> None: # less efficient. This is a workaround to force posix_spawn() # when using musl since cpython is not aware its supported. tag = next(packaging.tags.sys_tags()) - subprocess._USE_POSIX_SPAWN = "musllinux" in tag.platform # noqa: SLF001 + subprocess._USE_POSIX_SPAWN = "musllinux" in tag.platform # type: ignore[misc] # noqa: SLF001 def run(runtime_config: RuntimeConfig) -> int: diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py index 6184e4564eb..81ce9961a0b 100644 --- a/homeassistant/util/frozen_dataclass_compat.py +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -8,7 +8,10 @@ from __future__ import annotations import dataclasses import sys -from typing import Any, dataclass_transform +from typing import TYPE_CHECKING, Any, cast, dataclass_transform + +if TYPE_CHECKING: + from _typeshed import DataclassInstance def _class_fields(cls: type, kw_only: bool) -> list[tuple[str, Any, Any]]: @@ -111,6 +114,8 @@ class FrozenOrThawed(type): """ cls, *_args = args if dataclasses.is_dataclass(cls): + if TYPE_CHECKING: + cls = cast(type[DataclassInstance], cls) return object.__new__(cls) return cls._dataclass(*_args, **kwargs) diff --git a/requirements_test.txt b/requirements_test.txt index 19a60b6aa28..87203daae96 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==3.2.4 coverage==7.6.0 freezegun==1.5.1 mock-open==1.4.0 -mypy-dev==1.12.0a2 +mypy-dev==1.12.0a3 pre-commit==3.7.1 pydantic==1.10.17 pylint==3.2.6 From 93afc9458ab2841a3383b8e37808040792240e71 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 31 Aug 2024 11:38:45 -0700 Subject: [PATCH 0106/1309] Update nest to only include the image attachment payload for cameras that support fetching media (#124590) Only include the image attachment payload for cameras that support fetching media --- homeassistant/components/nest/__init__.py | 31 +++++++++------- tests/components/nest/test_events.py | 43 ++++++++++++++++++++--- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index da72fdfd53b..8a1719a9bd5 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -166,38 +166,43 @@ class SignalUpdateCallback: ) if not device_entry: return + supported_traits = self._supported_traits(device_id) for api_event_type, image_event in events.items(): if not (event_type := EVENT_NAME_MAP.get(api_event_type)): continue nest_event_id = image_event.event_token - attachment = { - "image": EVENT_THUMBNAIL_URL_FORMAT.format( - device_id=device_entry.id, event_token=image_event.event_token - ), - } - if self._supports_clip(device_id): - attachment["video"] = EVENT_MEDIA_API_URL_FORMAT.format( - device_id=device_entry.id, event_token=image_event.event_token - ) message = { "device_id": device_entry.id, "type": event_type, "timestamp": event_message.timestamp, "nest_event_id": nest_event_id, - "attachment": attachment, } + if ( + TraitType.CAMERA_EVENT_IMAGE in supported_traits + or TraitType.CAMERA_CLIP_PREVIEW in supported_traits + ): + attachment = { + "image": EVENT_THUMBNAIL_URL_FORMAT.format( + device_id=device_entry.id, event_token=image_event.event_token + ) + } + if TraitType.CAMERA_CLIP_PREVIEW in supported_traits: + attachment["video"] = EVENT_MEDIA_API_URL_FORMAT.format( + device_id=device_entry.id, event_token=image_event.event_token + ) + message["attachment"] = attachment if image_event.zones: message["zones"] = image_event.zones self._hass.bus.async_fire(NEST_EVENT, message) - def _supports_clip(self, device_id: str) -> bool: + def _supported_traits(self, device_id: str) -> list[TraitType]: if not ( device_manager := self._hass.data[DOMAIN] .get(self._config_entry_id, {}) .get(DATA_DEVICE_MANAGER) ) or not (device := device_manager.devices.get(device_id)): - return False - return TraitType.CAMERA_CLIP_PREVIEW in device.traits + return [] + return list(device.traits) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index 643a2614bbc..e746e5f263f 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -122,28 +122,28 @@ def create_events(events, device_id=DEVICE_ID, timestamp=None): [ ( "sdm.devices.types.DOORBELL", - ["sdm.devices.traits.DoorbellChime"], + ["sdm.devices.traits.DoorbellChime", "sdm.devices.traits.CameraEventImage"], "sdm.devices.events.DoorbellChime.Chime", "Doorbell", "doorbell_chime", ), ( "sdm.devices.types.CAMERA", - ["sdm.devices.traits.CameraMotion"], + ["sdm.devices.traits.CameraMotion", "sdm.devices.traits.CameraEventImage"], "sdm.devices.events.CameraMotion.Motion", "Camera", "camera_motion", ), ( "sdm.devices.types.CAMERA", - ["sdm.devices.traits.CameraPerson"], + ["sdm.devices.traits.CameraPerson", "sdm.devices.traits.CameraEventImage"], "sdm.devices.events.CameraPerson.Person", "Camera", "camera_person", ), ( "sdm.devices.types.CAMERA", - ["sdm.devices.traits.CameraSound"], + ["sdm.devices.traits.CameraSound", "sdm.devices.traits.CameraEventImage"], "sdm.devices.events.CameraSound.Sound", "Camera", "camera_sound", @@ -234,6 +234,41 @@ async def test_camera_multiple_event( } +@pytest.mark.parametrize( + "device_traits", + [(["sdm.devices.traits.CameraMotion"])], +) +async def test_media_not_supported( + hass: HomeAssistant, entity_registry: er.EntityRegistry, subscriber, setup_platform +) -> None: + """Test a pubsub message for a camera person event.""" + events = async_capture_events(hass, NEST_EVENT) + await setup_platform() + entry = entity_registry.async_get("camera.front") + assert entry is not None + + event_map = { + "sdm.devices.events.CameraMotion.Motion": { + "eventSessionId": EVENT_SESSION_ID, + "eventId": EVENT_ID, + }, + } + + timestamp = utcnow() + await subscriber.async_receive_event(create_events(event_map, timestamp=timestamp)) + await hass.async_block_till_done() + + event_time = timestamp.replace(microsecond=0) + assert len(events) == 1 + assert event_view(events[0].data) == { + "device_id": entry.device_id, + "type": "camera_motion", + "timestamp": event_time, + } + # Media fetching not supported by this device + assert "attachment" not in events[0].data + + async def test_unknown_event(hass: HomeAssistant, subscriber, setup_platform) -> None: """Test a pubsub message for an unknown event type.""" events = async_capture_events(hass, NEST_EVENT) From d3879a36d163aa6307308738fbe5f2985441e053 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 31 Aug 2024 12:11:22 -0700 Subject: [PATCH 0107/1309] Add loggers for Google Photos integration (#124986) --- homeassistant/components/google_photos/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/google_photos/manifest.json b/homeassistant/components/google_photos/manifest.json index 3299b437d29..3fefb6cf610 100644 --- a/homeassistant/components/google_photos/manifest.json +++ b/homeassistant/components/google_photos/manifest.json @@ -6,5 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/google_photos", "iot_class": "cloud_polling", + "loggers": ["googleapiclient"], "requirements": ["google-api-python-client==2.71.0"] } From ef84a8869e6e5d1772688f9a02f2c91319fe10ee Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 31 Aug 2024 12:16:14 -0700 Subject: [PATCH 0108/1309] Add Google Photos service for uploading content (#124956) * Add Google Photos upload support * Fix format * Merge in scope/reauth changes * Address PR feedback * Fix blocking i/o in async --- .../components/google_photos/__init__.py | 4 + homeassistant/components/google_photos/api.py | 48 +++- .../components/google_photos/const.py | 9 +- .../components/google_photos/icons.json | 7 + .../components/google_photos/media_source.py | 13 +- .../components/google_photos/services.py | 116 ++++++++ .../components/google_photos/services.yaml | 11 + .../components/google_photos/strings.json | 37 +++ tests/components/google_photos/conftest.py | 10 +- .../google_photos/test_config_flow.py | 12 + .../google_photos/test_media_source.py | 20 +- .../components/google_photos/test_services.py | 256 ++++++++++++++++++ 12 files changed, 536 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/google_photos/icons.json create mode 100644 homeassistant/components/google_photos/services.py create mode 100644 homeassistant/components/google_photos/services.yaml create mode 100644 tests/components/google_photos/test_services.py diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index 643ad0b41ad..ee02c695f16 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -11,6 +11,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import DOMAIN +from .services import async_register_services type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] @@ -41,6 +42,9 @@ async def async_setup_entry( except ClientError as err: raise ConfigEntryNotReady from err entry.runtime_data = auth + + async_register_services(hass) + return True diff --git a/homeassistant/components/google_photos/api.py b/homeassistant/components/google_photos/api.py index b387326148f..c5de03d7d21 100644 --- a/homeassistant/components/google_photos/api.py +++ b/homeassistant/components/google_photos/api.py @@ -5,6 +5,7 @@ from functools import partial import logging from typing import Any, cast +from aiohttp.client_exceptions import ClientError from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build from googleapiclient.errors import HttpError @@ -12,7 +13,7 @@ from googleapiclient.http import BatchHttpRequest, HttpRequest from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from .exceptions import GooglePhotosApiError @@ -25,6 +26,7 @@ GET_MEDIA_ITEM_FIELDS = ( "id,baseUrl,mimeType,filename,mediaMetadata(width,height,photo,video)" ) LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})" +UPLOAD_API = "https://photoslibrary.googleapis.com/v1/uploads" class AuthBase(ABC): @@ -70,6 +72,40 @@ class AuthBase(ABC): ) return await self._execute(cmd) + async def upload_content(self, content: bytes, mime_type: str) -> str: + """Upload media content to the API and return an upload token.""" + token = await self.async_get_access_token() + session = aiohttp_client.async_get_clientsession(self._hass) + try: + result = await session.post( + UPLOAD_API, headers=_upload_headers(token, mime_type), data=content + ) + result.raise_for_status() + return await result.text() + except ClientError as err: + raise GooglePhotosApiError(f"Failed to upload content: {err}") from err + + async def create_media_items(self, upload_tokens: list[str]) -> list[str]: + """Create a batch of media items and return the ids.""" + service = await self._get_photos_service() + cmd: HttpRequest = service.mediaItems().batchCreate( + body={ + "newMediaItems": [ + { + "simpleMediaItem": { + "uploadToken": upload_token, + } + for upload_token in upload_tokens + } + ] + } + ) + result = await self._execute(cmd) + return [ + media_item["mediaItem"]["id"] + for media_item in result["newMediaItemResults"] + ] + async def _get_photos_service(self) -> Resource: """Get current photos library API resource.""" token = await self.async_get_access_token() @@ -141,3 +177,13 @@ class AsyncConfigFlowAuth(AuthBase): async def async_get_access_token(self) -> str: """Return a valid access token.""" return self._token + + +def _upload_headers(token: str, mime_type: str) -> dict[str, Any]: + """Create the upload headers.""" + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/octet-stream", + "X-Goog-Upload-Content-Type": mime_type, + "X-Goog-Upload-Protocol": "raw", + } diff --git a/homeassistant/components/google_photos/const.py b/homeassistant/components/google_photos/const.py index 7752f817608..c629e6feb27 100644 --- a/homeassistant/components/google_photos/const.py +++ b/homeassistant/components/google_photos/const.py @@ -4,7 +4,14 @@ DOMAIN = "google_photos" OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" -OAUTH2_SCOPES = [ + +UPLOAD_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly" +READ_SCOPES = [ "https://www.googleapis.com/auth/photoslibrary.readonly", + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", +] +OAUTH2_SCOPES = [ + *READ_SCOPES, + UPLOAD_SCOPE, "https://www.googleapis.com/auth/userinfo.profile", ] diff --git a/homeassistant/components/google_photos/icons.json b/homeassistant/components/google_photos/icons.json new file mode 100644 index 00000000000..5d51ed4370a --- /dev/null +++ b/homeassistant/components/google_photos/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "upload": { + "service": "mdi:cloud-upload" + } + } +} diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index cdb6b22a3ed..9b922ee3201 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -16,7 +16,7 @@ from homeassistant.components.media_source import ( from homeassistant.core import HomeAssistant from . import GooglePhotosConfigEntry -from .const import DOMAIN +from .const import DOMAIN, READ_SCOPES from .exceptions import GooglePhotosApiError _LOGGER = logging.getLogger(__name__) @@ -168,7 +168,7 @@ class GooglePhotosMediaSource(MediaSource): children_media_class=MediaClass.DIRECTORY, children=[ _build_account(entry, PhotosIdentifier(cast(str, entry.unique_id))) - for entry in self.hass.config_entries.async_loaded_entries(DOMAIN) + for entry in self._async_config_entries() ], ) @@ -218,6 +218,15 @@ class GooglePhotosMediaSource(MediaSource): ] return source + def _async_config_entries(self) -> list[GooglePhotosConfigEntry]: + """Return all config entries that support photo library reads.""" + entries = [] + for entry in self.hass.config_entries.async_loaded_entries(DOMAIN): + scopes = entry.data["token"]["scope"].split(" ") + if any(scope in scopes for scope in READ_SCOPES): + entries.append(entry) + return entries + def _async_config_entry(self, config_entry_id: str) -> GooglePhotosConfigEntry: """Return a config entry with the specified id.""" entry = self.hass.config_entries.async_entry_for_domain_unique_id( diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py new file mode 100644 index 00000000000..a895f333962 --- /dev/null +++ b/homeassistant/components/google_photos/services.py @@ -0,0 +1,116 @@ +"""Google Photos services.""" + +from __future__ import annotations + +import asyncio +import mimetypes +from pathlib import Path + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILENAME +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv + +from . import api +from .const import DOMAIN, UPLOAD_SCOPE + +type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] + +__all__ = [ + "DOMAIN", +] + +CONF_CONFIG_ENTRY_ID = "config_entry_id" + +UPLOAD_SERVICE = "upload" +UPLOAD_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]), + } +) + + +def _read_file_contents( + hass: HomeAssistant, filenames: list[str] +) -> list[tuple[str, bytes]]: + """Read the mime type and contents from each filen.""" + results = [] + for filename in filenames: + if not hass.config.is_allowed_path(filename): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="no_access_to_path", + translation_placeholders={"filename": filename}, + ) + filename_path = Path(filename) + if not filename_path.exists(): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="filename_does_not_exist", + translation_placeholders={"filename": filename}, + ) + mime_type, _ = mimetypes.guess_type(filename) + if mime_type is None or not (mime_type.startswith(("image", "video"))): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="filename_is_not_image", + translation_placeholders={"filename": filename}, + ) + results.append((mime_type, filename_path.read_bytes())) + return results + + +def async_register_services(hass: HomeAssistant) -> None: + """Register Google Photos services.""" + + async def async_handle_upload(call: ServiceCall) -> ServiceResponse: + """Generate content from text and optionally images.""" + config_entry: GooglePhotosConfigEntry | None = ( + hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID]) + ) + if not config_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + scopes = config_entry.data["token"]["scope"].split(" ") + if UPLOAD_SCOPE not in scopes: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="missing_upload_permission", + translation_placeholders={"target": DOMAIN}, + ) + + client_api = config_entry.runtime_data + upload_tasks = [] + file_results = await hass.async_add_executor_job( + _read_file_contents, hass, call.data[CONF_FILENAME] + ) + for mime_type, content in file_results: + upload_tasks.append(client_api.upload_content(content, mime_type)) + upload_tokens = await asyncio.gather(*upload_tasks) + media_ids = await client_api.create_media_items(upload_tokens) + if call.return_response: + return { + "media_items": [{"media_item_id": media_id for media_id in media_ids}] + } + return None + + if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE): + hass.services.async_register( + DOMAIN, + UPLOAD_SERVICE, + async_handle_upload, + schema=UPLOAD_SERVICE_SCHEMA, + supports_response=SupportsResponse.OPTIONAL, + ) diff --git a/homeassistant/components/google_photos/services.yaml b/homeassistant/components/google_photos/services.yaml new file mode 100644 index 00000000000..047305c0bca --- /dev/null +++ b/homeassistant/components/google_photos/services.yaml @@ -0,0 +1,11 @@ +upload: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: google_photos + filename: + required: false + selector: + object: diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index b44e04287b1..9e88429124e 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -26,5 +26,42 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "exceptions": { + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "no_access_to_path": { + "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" + }, + "filename_does_not_exist": { + "message": "`{filename}` does not exist" + }, + "filename_is_not_image": { + "message": "`{filename}` is not an image" + }, + "missing_upload_permission": { + "message": "Home Assistnt was not granted permission to upload to Google Photos" + } + }, + "services": { + "upload": { + "name": "Upload media", + "description": "Upload images or videos to Google Photos.", + "fields": { + "config_entry_id": { + "name": "Integration Id", + "description": "The Google Photos integration id." + }, + "filename": { + "name": "Filename", + "description": "Path to the image or video to upload.", + "example": "/config/www/image.jpg" + } + } + } } } diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index 73e506658e6..2cdad5d4d10 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -32,13 +32,19 @@ def mock_expires_at() -> int: return time.time() + EXPIRES_IN +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set scopes used during the config entry.""" + return OAUTH2_SCOPES + + @pytest.fixture(name="token_entry") -def mock_token_entry(expires_at: int) -> dict[str, Any]: +def mock_token_entry(expires_at: int, scopes: list[str]) -> dict[str, Any]: """Fixture for OAuth 'token' data for a ConfigEntry.""" return { "access_token": FAKE_ACCESS_TOKEN, "refresh_token": FAKE_REFRESH_TOKEN, - "scope": " ".join(OAUTH2_SCOPES), + "scope": " ".join(scopes), "type": "Bearer", "expires_at": expires_at, "expires_in": EXPIRES_IN, diff --git a/tests/components/google_photos/test_config_flow.py b/tests/components/google_photos/test_config_flow.py index 4bd933a7eb8..2564a8ed134 100644 --- a/tests/components/google_photos/test_config_flow.py +++ b/tests/components/google_photos/test_config_flow.py @@ -84,6 +84,8 @@ async def test_full_flow( "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" ) @@ -111,6 +113,8 @@ async def test_full_flow( "type": "Bearer", "scope": ( "https://www.googleapis.com/auth/photoslibrary.readonly" + " https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + " https://www.googleapis.com/auth/photoslibrary.appendonly" " https://www.googleapis.com/auth/userinfo.profile" ), }, @@ -145,6 +149,8 @@ async def test_api_not_enabled( "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" ) @@ -189,6 +195,8 @@ async def test_general_exception( "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" ) @@ -274,6 +282,8 @@ async def test_reauth( "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" + "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" ) @@ -305,6 +315,8 @@ async def test_reauth( "type": "Bearer", "scope": ( "https://www.googleapis.com/auth/photoslibrary.readonly" + " https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + " https://www.googleapis.com/auth/photoslibrary.appendonly" " https://www.googleapis.com/auth/userinfo.profile" ), }, diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index db57ab755c1..ff4993eb3df 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -7,7 +7,7 @@ from googleapiclient.errors import HttpError from httplib2 import Response import pytest -from homeassistant.components.google_photos.const import DOMAIN +from homeassistant.components.google_photos.const import DOMAIN, UPLOAD_SCOPE from homeassistant.components.media_source import ( URI_SCHEME, BrowseError, @@ -46,6 +46,24 @@ async def test_no_config_entries( assert not browse.children +@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize( + ("scopes"), + [ + [UPLOAD_SCOPE], + ], +) +async def test_no_read_scopes( + hass: HomeAssistant, +) -> None: + """Test a media source with only write scopes configured so no media source exists.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert not browse.children + + @pytest.mark.usefixtures("setup_integration", "setup_api") @pytest.mark.parametrize( ("fixture_name", "expected_results", "expected_medias"), diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py new file mode 100644 index 00000000000..198de3295a9 --- /dev/null +++ b/tests/components/google_photos/test_services.py @@ -0,0 +1,256 @@ +"""Tests for Google Photos.""" + +import http +from unittest.mock import Mock, patch + +from googleapiclient.errors import HttpError +from httplib2 import Response +import pytest + +from homeassistant.components.google_photos.api import UPLOAD_API +from homeassistant.components.google_photos.const import DOMAIN, READ_SCOPES +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.usefixtures("setup_integration") +async def test_upload_service( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_api: Mock, +) -> None: + """Test service call to upload content.""" + assert hass.services.has_service(DOMAIN, "upload") + + aioclient_mock.post(UPLOAD_API, text="some-upload-token") + setup_api.return_value.mediaItems.return_value.batchCreate.return_value.execute.return_value = { + "newMediaItemResults": [ + { + "status": { + "code": 200, + }, + "mediaItem": { + "id": "new-media-item-id-1", + }, + } + ] + } + + with ( + patch( + "homeassistant.components.google_photos.services.Path.read_bytes", + return_value=b"image bytes", + ), + patch( + "homeassistant.components.google_photos.services.Path.exists", + return_value=True, + ), + patch.object(hass.config, "is_allowed_path", return_value=True), + ): + response = await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + assert response == {"media_items": [{"media_item_id": "new-media-item-id-1"}]} + + +@pytest.mark.usefixtures("setup_integration") +async def test_upload_service_config_entry_not_found( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a config entry that does not exist.""" + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": "invalid-config-entry-id", + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_config_entry_not_loaded( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a config entry that is not loaded.""" + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises(HomeAssistantError, match="not found in registry"): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.unique_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_path_is_not_allowed( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that is not allowed.""" + with ( + patch.object(hass.config, "is_allowed_path", return_value=False), + pytest.raises(HomeAssistantError, match="no access to path"), + ): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_filename_does_not_exist( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that does not exist.""" + with ( + patch.object(hass.config, "is_allowed_path", return_value=True), + patch("pathlib.Path.exists", return_value=False), + pytest.raises(HomeAssistantError, match="does not exist"), + ): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_upload_service_upload_content_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_api: Mock, +) -> None: + """Test service call to upload content.""" + + aioclient_mock.post(UPLOAD_API, status=http.HTTPStatus.SERVICE_UNAVAILABLE) + + with ( + patch( + "homeassistant.components.google_photos.services.Path.read_bytes", + return_value=b"image bytes", + ), + patch( + "homeassistant.components.google_photos.services.Path.exists", + return_value=True, + ), + patch.object(hass.config, "is_allowed_path", return_value=True), + pytest.raises(HomeAssistantError, match="Failed to upload content"), + ): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_upload_service_fails_create( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_api: Mock, +) -> None: + """Test service call to upload content.""" + + aioclient_mock.post(UPLOAD_API, text="some-upload-token") + setup_api.return_value.mediaItems.return_value.batchCreate.return_value.execute.side_effect = HttpError( + Response({"status": "403"}), b"" + ) + + with ( + patch( + "homeassistant.components.google_photos.services.Path.read_bytes", + return_value=b"image bytes", + ), + patch( + "homeassistant.components.google_photos.services.Path.exists", + return_value=True, + ), + patch.object(hass.config, "is_allowed_path", return_value=True), + pytest.raises( + HomeAssistantError, match="Google Photos API responded with error" + ), + ): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize( + ("scopes"), + [ + READ_SCOPES, + ], +) +async def test_upload_service_no_scope( + hass: HomeAssistant, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_api: Mock, +) -> None: + """Test service call to upload content but the config entry is read-only.""" + + with pytest.raises(HomeAssistantError, match="not granted permission"): + await hass.services.async_call( + DOMAIN, + "upload", + { + "config_entry_id": config_entry.entry_id, + "filename": "doorbell_snapshot.jpg", + }, + blocking=True, + return_response=True, + ) From 30772da0e1e65af7991f2b2786b8e320f55e15e2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 31 Aug 2024 14:39:18 -0700 Subject: [PATCH 0109/1309] Add Google Photos media source support for albums and favorites (#124985) --- homeassistant/components/google_photos/api.py | 37 ++++++-- .../components/google_photos/media_source.py | 89 +++++++++++++++---- tests/components/google_photos/conftest.py | 11 ++- .../google_photos/fixtures/list_albums.json | 12 +++ .../google_photos/test_media_source.py | 49 ++++++++-- 5 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 tests/components/google_photos/fixtures/list_albums.json diff --git a/homeassistant/components/google_photos/api.py b/homeassistant/components/google_photos/api.py index c5de03d7d21..0bbb2fe162b 100644 --- a/homeassistant/components/google_photos/api.py +++ b/homeassistant/components/google_photos/api.py @@ -9,7 +9,7 @@ from aiohttp.client_exceptions import ClientError from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build from googleapiclient.errors import HttpError -from googleapiclient.http import BatchHttpRequest, HttpRequest +from googleapiclient.http import HttpRequest from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant @@ -27,6 +27,9 @@ GET_MEDIA_ITEM_FIELDS = ( ) LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})" UPLOAD_API = "https://photoslibrary.googleapis.com/v1/uploads" +LIST_ALBUMS_FIELDS = ( + "nextPageToken,albums(id,title,coverPhotoBaseUrl,coverPhotoMediaItemId)" +) class AuthBase(ABC): @@ -61,14 +64,38 @@ class AuthBase(ABC): return await self._execute(cmd) async def list_media_items( - self, page_size: int | None = None, page_token: str | None = None + self, + page_size: int | None = None, + page_token: str | None = None, + album_id: str | None = None, + favorites: bool = False, ) -> dict[str, Any]: """Get all MediaItem resources.""" service = await self._get_photos_service() - cmd: HttpRequest = service.mediaItems().list( + args: dict[str, Any] = { + "pageSize": (page_size or DEFAULT_PAGE_SIZE), + "pageToken": page_token, + } + cmd: HttpRequest + if album_id is not None or favorites: + if album_id is not None: + args["albumId"] = album_id + if favorites: + args["filters"] = {"featureFilter": {"includedFeatures": "FAVORITES"}} + cmd = service.mediaItems().search(body=args, fields=LIST_MEDIA_ITEM_FIELDS) + else: + cmd = service.mediaItems().list(**args, fields=LIST_MEDIA_ITEM_FIELDS) + return await self._execute(cmd) + + async def list_albums( + self, page_size: int | None = None, page_token: str | None = None + ) -> dict[str, Any]: + """Get all Album resources.""" + service = await self._get_photos_service() + cmd: HttpRequest = service.albums().list( pageSize=(page_size or DEFAULT_PAGE_SIZE), pageToken=page_token, - fields=LIST_MEDIA_ITEM_FIELDS, + fields=LIST_ALBUMS_FIELDS, ) return await self._execute(cmd) @@ -126,7 +153,7 @@ class AuthBase(ABC): partial(build, "oauth2", "v2", credentials=Credentials(token=token)) # type: ignore[no-untyped-call] ) - async def _execute(self, request: HttpRequest | BatchHttpRequest) -> dict[str, Any]: + async def _execute(self, request: HttpRequest) -> dict[str, Any]: try: result = await self._hass.async_add_executor_job(request.execute) except HttpError as err: diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index 9b922ee3201..a709dd66a0a 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -1,7 +1,7 @@ """Media source for Google Photos.""" from dataclasses import dataclass -from enum import StrEnum +from enum import Enum, StrEnum import logging from typing import Any, Self, cast @@ -25,14 +25,41 @@ _LOGGER = logging.getLogger(__name__) # photos when displaying the users library. We fetch a minimum of 50 photos # unless we run out, but in pages of 100 at a time given sometimes responses # may only contain a handful of items Fetches at least 50 photos. -MAX_PHOTOS = 50 +MAX_RECENT_PHOTOS = 50 +MAX_ALBUMS = 50 PAGE_SIZE = 100 THUMBNAIL_SIZE = 256 LARGE_IMAGE_SIZE = 2160 -# Markers for parts of PhotosIdentifier url pattern. +@dataclass +class SpecialAlbumDetails: + """Details for a Special album.""" + + path: str + title: str + list_args: dict[str, Any] + max_photos: int | None + + +class SpecialAlbum(Enum): + """Special Album types.""" + + RECENT = SpecialAlbumDetails("recent", "Recent Photos", {}, MAX_RECENT_PHOTOS) + FAVORITE = SpecialAlbumDetails( + "favorites", "Favorite Photos", {"favorites": True}, None + ) + + @classmethod + def of(cls, path: str) -> Self | None: + """Parse a PhotosIdentifierType by string value.""" + for enum in cls: + if enum.value.path == path: + return enum + return None + + # The PhotosIdentifier can be in the following forms: # config-entry-id # config-entry-id/a/album-media-id @@ -40,12 +67,6 @@ LARGE_IMAGE_SIZE = 2160 # # The album-media-id can contain special reserved folder names for use by # this integration for virtual folders like the `recent` album. -PHOTO_SOURCE_IDENTIFIER_PHOTO = "p" -PHOTO_SOURCE_IDENTIFIER_ALBUM = "a" - -# Currently supports a single album of recent photos -RECENT_PHOTOS_ALBUM = "recent" -RECENT_PHOTOS_TITLE = "Recent Photos" class PhotosIdentifierType(StrEnum): @@ -86,7 +107,6 @@ class PhotosIdentifier: def of(cls, identifier: str) -> Self: """Parse a PhotosIdentifier form a string.""" parts = identifier.split("/") - _LOGGER.debug("parts=%s", parts) if len(parts) == 1: return cls(parts[0]) if len(parts) != 3: @@ -179,27 +199,50 @@ class GooglePhotosMediaSource(MediaSource): source = _build_account(entry, identifier) if identifier.id_type is None: + result = await client.list_albums(page_size=MAX_ALBUMS) source.children = [ _build_album( - RECENT_PHOTOS_TITLE, + special_album.value.title, PhotosIdentifier.album( - identifier.config_entry_id, RECENT_PHOTOS_ALBUM + identifier.config_entry_id, special_album.value.path ), ) + for special_album in SpecialAlbum + ] + [ + _build_album( + album["title"], + PhotosIdentifier.album( + identifier.config_entry_id, + album["id"], + ), + _cover_photo_url(album, THUMBNAIL_SIZE), + ) + for album in result["albums"] ] return source - # Currently only supports listing a single album of recent photos. - if identifier.media_id != RECENT_PHOTOS_ALBUM: - raise BrowseError(f"Unsupported album: {identifier}") + if ( + identifier.id_type != PhotosIdentifierType.ALBUM + or identifier.media_id is None + ): + raise BrowseError(f"Unsupported identifier: {identifier}") + + list_args: dict[str, Any] + if special_album := SpecialAlbum.of(identifier.media_id): + list_args = special_album.value.list_args + else: + list_args = {"album_id": identifier.media_id} - # Fetch recent items media_items: list[dict[str, Any]] = [] page_token: str | None = None - while len(media_items) < MAX_PHOTOS: + while ( + not special_album + or (max_photos := special_album.value.max_photos) is None + or len(media_items) < max_photos + ): try: result = await client.list_media_items( - page_size=PAGE_SIZE, page_token=page_token + **list_args, page_size=PAGE_SIZE, page_token=page_token ) except GooglePhotosApiError as err: raise BrowseError(f"Error listing media items: {err}") from err @@ -255,7 +298,9 @@ def _build_account( ) -def _build_album(title: str, identifier: PhotosIdentifier) -> BrowseMediaSource: +def _build_album( + title: str, identifier: PhotosIdentifier, thumbnail_url: str | None = None +) -> BrowseMediaSource: """Build an album node.""" return BrowseMediaSource( domain=DOMAIN, @@ -265,6 +310,7 @@ def _build_album(title: str, identifier: PhotosIdentifier) -> BrowseMediaSource: title=title, can_play=False, can_expand=True, + thumbnail=thumbnail_url, ) @@ -299,3 +345,8 @@ def _video_url(media_item: dict[str, Any]) -> str: See https://developers.google.com/photos/library/guides/access-media-items#base-urls """ return f"{media_item["baseUrl"]}=dv" + + +def _cover_photo_url(album: dict[str, Any], max_size: int) -> str: + """Return a media item url for the cover photo of the album.""" + return f"{album["coverPhotoBaseUrl"]}=h{max_size}" diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index 2cdad5d4d10..f7289993258 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -15,7 +15,11 @@ from homeassistant.components.google_photos.const import DOMAIN, OAUTH2_SCOPES from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_array_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) USER_IDENTIFIER = "user-identifier-1" CONFIG_ENTRY_ID = "user-identifier-1" @@ -119,6 +123,7 @@ def mock_setup_api( return mock mock.return_value.mediaItems.return_value.list = list_media_items + mock.return_value.mediaItems.return_value.search = list_media_items # Mock a point lookup by reading contents of the fixture above def get_media_item(mediaItemId: str, **kwargs: Any) -> Mock: @@ -131,6 +136,10 @@ def mock_setup_api( return None mock.return_value.mediaItems.return_value.get = get_media_item + mock.return_value.albums.return_value.list.return_value.execute.return_value = ( + load_json_object_fixture("list_albums.json", DOMAIN) + ) + yield mock diff --git a/tests/components/google_photos/fixtures/list_albums.json b/tests/components/google_photos/fixtures/list_albums.json new file mode 100644 index 00000000000..57f2873715b --- /dev/null +++ b/tests/components/google_photos/fixtures/list_albums.json @@ -0,0 +1,12 @@ +{ + "albums": [ + { + "id": "album-media-id-1", + "title": "Album title", + "isWriteable": true, + "mediaItemsCount": 7, + "coverPhotoBaseUrl": "http://img.example.com/id3", + "coverPhotoMediaItemId": "cover-photo-media-id-3" + } + ] +} diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index ff4993eb3df..1028a34aec1 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -65,6 +65,14 @@ async def test_no_read_scopes( @pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.parametrize( + ("album_path", "expected_album_title"), + [ + (f"{CONFIG_ENTRY_ID}/a/recent", "Recent Photos"), + (f"{CONFIG_ENTRY_ID}/a/favorites", "Favorite Photos"), + (f"{CONFIG_ENTRY_ID}/a/album-media-id-1", "Album title"), + ], +) @pytest.mark.parametrize( ("fixture_name", "expected_results", "expected_medias"), [ @@ -82,8 +90,10 @@ async def test_no_read_scopes( ), ], ) -async def test_recent_items( +async def test_browse_albums( hass: HomeAssistant, + album_path: str, + expected_album_title: str, expected_results: list[tuple[str, str]], expected_medias: list[tuple[str, str]], ) -> None: @@ -101,14 +111,14 @@ async def test_recent_items( assert browse.identifier == CONFIG_ENTRY_ID assert browse.title == "Account Name" assert [(child.identifier, child.title) for child in browse.children] == [ - (f"{CONFIG_ENTRY_ID}/a/recent", "Recent Photos") + (f"{CONFIG_ENTRY_ID}/a/recent", "Recent Photos"), + (f"{CONFIG_ENTRY_ID}/a/favorites", "Favorite Photos"), + (f"{CONFIG_ENTRY_ID}/a/album-media-id-1", "Album title"), ] - browse = await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" - ) + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{album_path}") assert browse.domain == DOMAIN - assert browse.identifier == f"{CONFIG_ENTRY_ID}/a/recent" + assert browse.identifier == album_path assert browse.title == "Account Name" assert [ (child.identifier, child.title) for child in browse.children @@ -134,7 +144,25 @@ async def test_invalid_config_entry(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_integration", "setup_api") @pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) -async def test_invalid_album_id(hass: HomeAssistant) -> None: +async def test_browse_invalid_path(hass: HomeAssistant) -> None: + """Test browsing to a photo is not possible.""" + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Google Photos" + assert [(child.identifier, child.title) for child in browse.children] == [ + (CONFIG_ENTRY_ID, "Account Name") + ] + + with pytest.raises(BrowseError, match="Unsupported identifier"): + await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/p/some-photo-id" + ) + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) +async def test_invalid_album_id(hass: HomeAssistant, setup_api: Mock) -> None: """Test browsing to an album id that does not exist.""" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN @@ -144,7 +172,12 @@ async def test_invalid_album_id(hass: HomeAssistant) -> None: (CONFIG_ENTRY_ID, "Account Name") ] - with pytest.raises(BrowseError, match="Unsupported album"): + setup_api.return_value.mediaItems.return_value.search = Mock() + setup_api.return_value.mediaItems.return_value.search.return_value.execute.side_effect = HttpError( + Response({"status": "404"}), b"" + ) + + with pytest.raises(BrowseError, match="Error listing media items"): await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/invalid-album-id" ) From 95a25c72dc363ad9240ae16bc8add225d1bc481c Mon Sep 17 00:00:00 2001 From: Bill Flood Date: Sat, 31 Aug 2024 22:12:24 -0700 Subject: [PATCH 0110/1309] Use constant for default medium type in Mopeka (#125002) - Updated the Mopeka BLE device setup to use const DEFAULT_MEDIUM_TYPE - Fix Spelling error in a coment --- homeassistant/components/mopeka/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mopeka/__init__.py b/homeassistant/components/mopeka/__init__.py index 17a87efd6e6..d73ece581d7 100644 --- a/homeassistant/components/mopeka/__init__.py +++ b/homeassistant/components/mopeka/__init__.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_MEDIUM_TYPE +from .const import CONF_MEDIUM_TYPE, DEFAULT_MEDIUM_TYPE PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -29,8 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MopekaConfigEntry) -> bo address = entry.unique_id assert address is not None - # Default sensors configured prior to the intorudction of MediumType - medium_type_str = entry.data.get(CONF_MEDIUM_TYPE, MediumType.PROPANE.value) + # Default sensors configured prior to the introduction of MediumType + medium_type_str = entry.data.get(CONF_MEDIUM_TYPE, DEFAULT_MEDIUM_TYPE) data = MopekaIOTBluetoothDeviceData(MediumType(medium_type_str)) coordinator = entry.runtime_data = PassiveBluetoothProcessorCoordinator( hass, From 68162e1a27896fb7c04d04af1052b9bde90382ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 1 Sep 2024 12:45:59 +0200 Subject: [PATCH 0111/1309] Update aioairzone-cloud to v0.6.4 (#125007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 9 ++++++++ tests/components/airzone_cloud/util.py | 21 +++++++++++++++++++ 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 05f854e6caf..47a06c308ad 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.3"] + "requirements": ["aioairzone-cloud==0.6.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1be3393dfc2..08bd494955a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -176,7 +176,7 @@ aio-georss-gdacs==0.9 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.3 +aioairzone-cloud==0.6.4 # homeassistant.components.airzone aioairzone==0.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bd9dfa1e29..d3c94d221d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aio-georss-gdacs==0.9 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.3 +aioairzone-cloud==0.6.4 # homeassistant.components.airzone aioairzone==0.8.2 diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 26a606bde42..2e6463d35a1 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -154,6 +154,9 @@ 'available': True, 'double-set-point': True, 'id': 'aidoo_pro', + 'indoor-exchanger-temperature': 26.0, + 'indoor-return-temperature': 26.0, + 'indoor-work-temperature': 25.0, 'installation': 'installation1', 'is-connected': True, 'mode': 2, @@ -166,6 +169,12 @@ 5, ]), 'name': 'Bron Pro', + 'outdoor-condenser-pressure': 150.0, + 'outdoor-discharge-temperature': 121.0, + 'outdoor-electric-current': 3.0, + 'outdoor-evaporator-pressure': 20.0, + 'outdoor-exchanger-temperature': -25.0, + 'outdoor-temperature': 29.0, 'power': True, 'problems': False, 'speed': 3, diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index fb538ea7c8e..52b0ae0bec3 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -24,12 +24,17 @@ from aioairzone_cloud.const import ( API_CELSIUS, API_CONFIG, API_CONNECTION_DATE, + API_CONSUMPTION_UE, API_CPU_WS, API_DEVICE_ID, API_DEVICES, + API_DISCH_COMP_TEMP_UE, API_DISCONNECTION_DATE, API_DOUBLE_SET_POINT, API_ERRORS, + API_EXCH_HEAT_TEMP_IU, + API_EXCH_HEAT_TEMP_UE, + API_EXT_TEMP, API_FAH, API_FREE, API_FREE_MEM, @@ -46,6 +51,8 @@ from aioairzone_cloud.const import ( API_MODE_AVAIL, API_NAME, API_OLD_ID, + API_PC_UE, + API_PE_UE, API_POWER, API_POWERFUL_MODE, API_RAD_ACTIVE, @@ -69,6 +76,7 @@ from aioairzone_cloud.const import ( API_RANGE_SP_MIN_HOT_AIR, API_RANGE_SP_MIN_STOP_AIR, API_RANGE_SP_MIN_VENT_AIR, + API_RETURN_TEMP, API_SETPOINT, API_SP_AIR_AUTO, API_SP_AIR_COOL, @@ -94,6 +102,7 @@ from aioairzone_cloud.const import ( API_THERMOSTAT_TYPE, API_TYPE, API_WARNINGS, + API_WORK_TEMP, API_WS_CONNECTED, API_WS_FW, API_WS_ID, @@ -266,6 +275,18 @@ GET_WEBSERVER_MOCK_AIDOO_PRO = { def mock_get_device_config(device: Device) -> dict[str, Any]: """Mock API device config.""" + if device.get_id() == "aidoo_pro": + return { + API_CONSUMPTION_UE: 3, + API_DISCH_COMP_TEMP_UE: {API_CELSIUS: 121, API_FAH: -250}, + API_EXCH_HEAT_TEMP_IU: {API_CELSIUS: 26, API_FAH: 79}, + API_EXCH_HEAT_TEMP_UE: {API_CELSIUS: -25, API_FAH: -13}, + API_EXT_TEMP: {API_CELSIUS: 29, API_FAH: 84}, + API_PC_UE: 0.15, + API_PE_UE: 0.02, + API_RETURN_TEMP: {API_CELSIUS: 26, API_FAH: 79}, + API_WORK_TEMP: {API_CELSIUS: 25, API_FAH: 77}, + } if device.get_id() == "system1": return { API_SYSTEM_FW: "3.35", From 1661304f10b0370797bf4a4b45774ca721b326dc Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Sun, 1 Sep 2024 12:47:52 +0200 Subject: [PATCH 0112/1309] Bump solarlog_cli to 0.2.2 (#124948) * Add inverter-devices * Minor code adjustments * Update manifest.json Seperate dependency upgrade to seperate PR * Update requirements_all.txt Seperate dependency upgrade to seperate PR * Update requirements_test_all.txt Seperate dependency upgrade to seperate PR * Update homeassistant/components/solarlog/sensor.py Co-authored-by: Joost Lekkerkerker * Split up base class, document SolarLogSensorEntityDescription * Split up sensor types * Update snapshot * Bump solarlog_cli to 0.2.1 * Add strict typing * Bump fyta_cli to 0.6.3 (#124574) * Ensure write access to hassrelease data folder (#124573) Co-authored-by: Robert Resch * Update a roborock blocking call to be fully async (#124266) Remove a blocking call in roborock * Add inverter-devices * Split up sensor types * Update snapshot * Bump solarlog_cli to 0.2.1 * Backport/rebase * Tidy up * Simplyfication coordinator.py * Minor adjustments * Ruff * Bump solarlog_cli to 0.2.2 * Update homeassistant/components/solarlog/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/solarlog/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/solarlog/sensor.py Co-authored-by: Joost Lekkerkerker * Update persentage-values in fixture --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Paulus Schoutsen Co-authored-by: Robert Resch Co-authored-by: Allen Porter --- .strict-typing | 1 + .../components/solarlog/config_flow.py | 23 +- .../components/solarlog/coordinator.py | 19 +- .../components/solarlog/manifest.json | 2 +- homeassistant/components/solarlog/sensor.py | 143 +++++++----- mypy.ini | 10 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/solarlog/__init__.py | 2 + tests/components/solarlog/conftest.py | 43 ++-- .../solarlog/fixtures/solarlog_data.json | 8 +- .../solarlog/snapshots/test_sensor.ambr | 218 +++++++++++++++++- tests/components/solarlog/test_config_flow.py | 2 +- 13 files changed, 350 insertions(+), 125 deletions(-) diff --git a/.strict-typing b/.strict-typing index 9e91272c37d..fb35bc5d227 100644 --- a/.strict-typing +++ b/.strict-typing @@ -411,6 +411,7 @@ homeassistant.components.slack.* homeassistant.components.sleepiq.* homeassistant.components.smhi.* homeassistant.components.snooz.* +homeassistant.components.solarlog.* homeassistant.components.sonarr.* homeassistant.components.speedtestdotnet.* homeassistant.components.sql.* diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py index 4587cb7d886..5d68a16eabe 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) @callback -def solarlog_entries(hass: HomeAssistant): +def solarlog_entries(hass: HomeAssistant) -> set[str]: """Return the hosts already configured.""" return { entry.data[CONF_HOST] for entry in hass.config_entries.async_entries(DOMAIN) @@ -36,7 +36,7 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self._errors: dict = {} - def _host_in_configuration_exists(self, host) -> bool: + def _host_in_configuration_exists(self, host: str) -> bool: """Return True if host exists in configuration.""" if host in solarlog_entries(self.hass): return True @@ -50,7 +50,7 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): url = ParseResult("http", netloc, path, *url[3:]) return url.geturl() - async def _test_connection(self, host): + async def _test_connection(self, host: str) -> bool: """Check if we can connect to the Solar-Log device.""" solarlog = SolarLogConnector(host) try: @@ -66,11 +66,12 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): return True - async def async_step_user(self, user_input=None) -> ConfigFlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Step when user initializes a integration.""" self._errors = {} if user_input is not None: - # set some defaults in case we need to return to the form user_input[CONF_NAME] = slugify(user_input[CONF_NAME]) user_input[CONF_HOST] = self._parse_url(user_input[CONF_HOST]) @@ -81,20 +82,14 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): title=user_input[CONF_NAME], data=user_input ) else: - user_input = {} - user_input[CONF_NAME] = DEFAULT_NAME - user_input[CONF_HOST] = DEFAULT_HOST + user_input = {CONF_NAME: DEFAULT_NAME, CONF_HOST: DEFAULT_HOST} return self.async_show_form( step_id="user", data_schema=vol.Schema( { - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): str, - vol.Required( - CONF_HOST, default=user_input.get(CONF_HOST, DEFAULT_HOST) - ): str, + vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, + vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, vol.Required("extended_data", default=False): bool, } ), diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 96ee00af1ec..5c9aa540261 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -12,11 +12,12 @@ from solarlog_cli.solarlog_exceptions import ( SolarLogConnectionError, SolarLogUpdateError, ) +from solarlog_cli.solarlog_models import SolarlogData from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import update_coordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) @@ -24,7 +25,7 @@ if TYPE_CHECKING: from . import SolarlogConfigEntry -class SolarLogCoordinator(update_coordinator.DataUpdateCoordinator): +class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): """Get and update the latest data.""" def __init__(self, hass: HomeAssistant, entry: SolarlogConfigEntry) -> None: @@ -43,29 +44,29 @@ class SolarLogCoordinator(update_coordinator.DataUpdateCoordinator): self.name = entry.title self.host = url.geturl() - extended_data = entry.data["extended_data"] - self.solarlog = SolarLogConnector( - self.host, extended_data, hass.config.time_zone + self.host, entry.data["extended_data"], hass.config.time_zone ) async def _async_setup(self) -> None: """Do initialization logic.""" if self.solarlog.extended_data: - device_list = await self.solarlog.client.get_device_list() + device_list = await self.solarlog.update_device_list() self.solarlog.set_enabled_devices({key: True for key in device_list}) - async def _async_update_data(self): + async def _async_update_data(self) -> SolarlogData: """Update the data from the SolarLog device.""" _LOGGER.debug("Start data update") try: data = await self.solarlog.update_data() - await self.solarlog.update_device_list() + if self.solarlog.extended_data: + await self.solarlog.update_device_list() + data.inverter_data = await self.solarlog.update_inverter_data() except SolarLogConnectionError as err: raise ConfigEntryNotReady(err) from err except SolarLogUpdateError as err: - raise update_coordinator.UpdateFailed(err) from err + raise UpdateFailed(err) from err _LOGGER.debug("Data successfully updated") diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index 0c097b7146d..eb2268e08da 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/solarlog", "iot_class": "local_polling", "loggers": ["solarlog_cli"], - "requirements": ["solarlog_cli==0.1.6"] + "requirements": ["solarlog_cli==0.2.2"] } diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index cd4a711cdc9..498429f70cf 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -5,7 +5,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import Any + +from solarlog_cli.solarlog_models import InverterData, SolarlogData from homeassistant.components.sensor import ( SensorDeviceClass, @@ -21,200 +22,219 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from . import SolarlogConfigEntry from .entity import SolarLogCoordinatorEntity, SolarLogInverterEntity -@dataclass(frozen=True) -class SolarLogSensorEntityDescription(SensorEntityDescription): - """Describes Solarlog sensor entity.""" +@dataclass(frozen=True, kw_only=True) +class SolarLogCoordinatorSensorEntityDescription(SensorEntityDescription): + """Describes Solarlog coordinator sensor entity.""" - value_fn: Callable[[float | int], float] | Callable[[datetime], datetime] = ( - lambda value: value - ) + value_fn: Callable[[SolarlogData], StateType | datetime | None] -SOLARLOG_SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( - SolarLogSensorEntityDescription( +@dataclass(frozen=True, kw_only=True) +class SolarLogInverterSensorEntityDescription(SensorEntityDescription): + """Describes Solarlog inverter sensor entity.""" + + value_fn: Callable[[InverterData], float | None] + + +SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = ( + SolarLogCoordinatorSensorEntityDescription( key="last_updated", translation_key="last_update", device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.last_updated, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="power_ac", translation_key="power_ac", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.power_ac, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="power_dc", translation_key="power_dc", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.power_dc, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="voltage_ac", translation_key="voltage_ac", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.voltage_ac, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="voltage_dc", translation_key="voltage_dc", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.voltage_dc, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="yield_day", translation_key="yield_day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda data: round(data.yield_day / 1000, 3), ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="yield_yesterday", translation_key="yield_yesterday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda data: round(data.yield_yesterday / 1000, 3), ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="yield_month", translation_key="yield_month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda data: round(data.yield_month / 1000, 3), ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="yield_year", translation_key="yield_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda data: round(data.yield_year / 1000, 3), ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="yield_total", translation_key="yield_total", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda data: round(data.yield_total / 1000, 3), ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="consumption_ac", translation_key="consumption_ac", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.consumption_ac, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="consumption_day", translation_key="consumption_day", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda data: round(data.consumption_day / 1000, 3), ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="consumption_yesterday", translation_key="consumption_yesterday", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda data: round(data.consumption_yesterday / 1000, 3), ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="consumption_month", translation_key="consumption_month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda data: round(data.consumption_month / 1000, 3), ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="consumption_year", translation_key="consumption_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda data: round(data.consumption_year / 1000, 3), ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="consumption_total", translation_key="consumption_total", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda data: round(data.consumption_total / 1000, 3), ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="self_consumption_year", translation_key="self_consumption_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.self_consumption_year, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="total_power", translation_key="total_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + value_fn=lambda data: data.total_power, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="alternator_loss", translation_key="alternator_loss", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.alternator_loss, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="capacity", translation_key="capacity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: round(value * 100, 1), + value_fn=lambda data: data.capacity, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="efficiency", translation_key="efficiency", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: round(value * 100, 1), + value_fn=lambda data: data.efficiency, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="power_available", translation_key="power_available", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.power_available, ), - SolarLogSensorEntityDescription( + SolarLogCoordinatorSensorEntityDescription( key="usage", translation_key="usage", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: round(value * 100, 1), + value_fn=lambda data: data.usage, ), ) -INVERTER_SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( - SolarLogSensorEntityDescription( +INVERTER_SENSOR_TYPES: tuple[SolarLogInverterSensorEntityDescription, ...] = ( + SolarLogInverterSensorEntityDescription( key="current_power", translation_key="current_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda inverter: inverter.current_power, ), - SolarLogSensorEntityDescription( + SolarLogInverterSensorEntityDescription( key="consumption_year", translation_key="consumption_year", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda value: round(value / 1000, 3), + value_fn=lambda inverter: None + if inverter.consumption_year is None + else round(inverter.consumption_year / 1000, 3), ), ) @@ -227,21 +247,18 @@ async def async_setup_entry( """Add solarlog entry.""" coordinator = entry.runtime_data - # https://github.com/python/mypy/issues/14294 - entities: list[SensorEntity] = [ SolarLogCoordinatorSensor(coordinator, sensor) for sensor in SOLARLOG_SENSOR_TYPES ] - device_data: dict[str, Any] = coordinator.data["devices"] + device_data = coordinator.data.inverter_data - if not device_data: + if device_data: entities.extend( - SolarLogInverterSensor(coordinator, sensor, int(device_id)) + SolarLogInverterSensor(coordinator, sensor, device_id) for device_id in device_data for sensor in INVERTER_SENSOR_TYPES - if sensor.key in device_data[device_id] ) async_add_entities(entities) @@ -250,26 +267,24 @@ async def async_setup_entry( class SolarLogCoordinatorSensor(SolarLogCoordinatorEntity, SensorEntity): """Represents a SolarLog sensor.""" - entity_description: SolarLogSensorEntityDescription + entity_description: SolarLogCoordinatorSensorEntityDescription @property - def native_value(self) -> float | datetime: + def native_value(self) -> StateType | datetime: """Return the state for this sensor.""" - val = self.coordinator.data[self.entity_description.key] - return self.entity_description.value_fn(val) + return self.entity_description.value_fn(self.coordinator.data) class SolarLogInverterSensor(SolarLogInverterEntity, SensorEntity): """Represents a SolarLog inverter sensor.""" - entity_description: SolarLogSensorEntityDescription + entity_description: SolarLogInverterSensorEntityDescription @property - def native_value(self) -> float | datetime: + def native_value(self) -> StateType: """Return the state for this sensor.""" - val = self.coordinator.data["devices"][self.device_id][ - self.entity_description.key - ] - return self.entity_description.value_fn(val) + return self.entity_description.value_fn( + self.coordinator.data.inverter_data[self.device_id] + ) diff --git a/mypy.ini b/mypy.ini index 873cf1f66bd..7fb8c49c8d9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3866,6 +3866,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.solarlog.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.sonarr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 08bd494955a..f7d8147a058 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2650,7 +2650,7 @@ soco==0.30.4 solaredge-local==0.2.3 # homeassistant.components.solarlog -solarlog_cli==0.1.6 +solarlog_cli==0.2.2 # homeassistant.components.solax solax==3.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d3c94d221d3..f234b427248 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2093,7 +2093,7 @@ snapcast==2.3.6 soco==0.30.4 # homeassistant.components.solarlog -solarlog_cli==0.1.6 +solarlog_cli==0.2.2 # homeassistant.components.solax solax==3.1.1 diff --git a/tests/components/solarlog/__init__.py b/tests/components/solarlog/__init__.py index 74b19bd297e..c2c0296d9e2 100644 --- a/tests/components/solarlog/__init__.py +++ b/tests/components/solarlog/__init__.py @@ -17,3 +17,5 @@ async def setup_platform( with patch("homeassistant.components.solarlog.PLATFORMS", platforms): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/solarlog/conftest.py b/tests/components/solarlog/conftest.py index 44c0e27f9b0..b363f655c57 100644 --- a/tests/components/solarlog/conftest.py +++ b/tests/components/solarlog/conftest.py @@ -1,10 +1,10 @@ """Test helpers.""" from collections.abc import Generator -from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest +from solarlog_cli.solarlog_models import InverterData, SolarlogData from homeassistant.components.solarlog.const import DOMAIN as SOLARLOG_DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME @@ -13,6 +13,19 @@ from .const import HOST, NAME from tests.common import MockConfigEntry, load_json_object_fixture +DEVICE_LIST = { + 0: InverterData(name="Inverter 1", enabled=True), + 1: InverterData(name="Inverter 2", enabled=True), +} +INVERTER_DATA = { + 0: InverterData( + name="Inverter 1", enabled=True, consumption_year=354687, current_power=5 + ), + 1: InverterData( + name="Inverter 2", enabled=True, consumption_year=354, current_power=6 + ), +} + @pytest.fixture def mock_config_entry() -> MockConfigEntry: @@ -34,28 +47,18 @@ def mock_config_entry() -> MockConfigEntry: def mock_solarlog_connector(): """Build a fixture for the SolarLog API that connects successfully and returns one device.""" + data = SolarlogData.from_dict( + load_json_object_fixture("solarlog_data.json", SOLARLOG_DOMAIN) + ) + data.inverter_data = INVERTER_DATA + mock_solarlog_api = AsyncMock() - mock_solarlog_api.test_connection = AsyncMock(return_value=True) - - data = { - "devices": { - 0: {"consumption_total": 354687, "current_power": 5}, - } - } - data |= load_json_object_fixture("solarlog_data.json", SOLARLOG_DOMAIN) - data["last_updated"] = datetime.fromisoformat(data["last_updated"]).astimezone(UTC) - + mock_solarlog_api.test_connection.return_value = True mock_solarlog_api.update_data.return_value = data - mock_solarlog_api.device_list.return_value = { - 0: {"name": "Inverter 1"}, - 1: {"name": "Inverter 2"}, - } + mock_solarlog_api.update_device_list.return_value = INVERTER_DATA + mock_solarlog_api.update_inverter_data.return_value = INVERTER_DATA mock_solarlog_api.device_name = {0: "Inverter 1", 1: "Inverter 2"}.get - mock_solarlog_api.client.get_device_list.return_value = { - 0: {"name": "Inverter 1"}, - 1: {"name": "Inverter 2"}, - } - mock_solarlog_api.client.close = AsyncMock(return_value=None) + mock_solarlog_api.device_enabled = {0: True, 1: False}.get with ( patch( diff --git a/tests/components/solarlog/fixtures/solarlog_data.json b/tests/components/solarlog/fixtures/solarlog_data.json index f7077d88d0d..339ab4a4dfc 100644 --- a/tests/components/solarlog/fixtures/solarlog_data.json +++ b/tests/components/solarlog/fixtures/solarlog_data.json @@ -17,9 +17,9 @@ "total_power": 120, "self_consumption_year": 545, "alternator_loss": 2, - "efficiency": 0.9804, - "usage": 0.5487, + "efficiency": 98.1, + "usage": 54.8, "power_available": 45.13, - "capacity": 0.85, - "last_updated": "2024-08-01T15:20:45" + "capacity": 85.5, + "last_updated": "2024-08-01T15:20:45Z" } diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr index 74a397be900..6fccbd89dba 100644 --- a/tests/components/solarlog/snapshots/test_sensor.ambr +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -1,4 +1,103 @@ # serializer version: 1 +# name: test_all_entities[sensor.inverter_1_consumption_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_consumption_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption total', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_total', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-inverter_1-consumption_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.inverter_1_consumption_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Consumption total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_consumption_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '354.687', + }) +# --- +# name: test_all_entities[sensor.inverter_1_consumption_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_consumption_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption year', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_year', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-inverter_1-consumption_year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.inverter_1_consumption_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Consumption year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_consumption_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '354.687', + }) +# --- # name: test_all_entities[sensor.inverter_1_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -50,6 +149,105 @@ 'state': '5', }) # --- +# name: test_all_entities[sensor.inverter_2_consumption_year-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2_consumption_year', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumption year', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumption_year', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-inverter_2-consumption_year', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.inverter_2_consumption_year-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 2 Consumption year', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_2_consumption_year', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.354', + }) +# --- +# name: test_all_entities[sensor.inverter_2_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_2_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'solarlog', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_power', + 'unique_id': 'ce5f5431554d101905d31797e1232da8-inverter_2-current_power', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.inverter_2_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 2 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_2_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- # name: test_all_entities[sensor.solarlog_alternator_loss-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -98,7 +296,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2', + 'state': '2.0', }) # --- # name: test_all_entities[sensor.solarlog_capacity-entry] @@ -149,7 +347,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '85.0', + 'state': '85.5', }) # --- # name: test_all_entities[sensor.solarlog_consumption_ac-entry] @@ -494,7 +692,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '98.0', + 'state': '98.1', }) # --- # name: test_all_entities[sensor.solarlog_installed_peak_power-entry] @@ -542,7 +740,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '120', + 'state': '120.0', }) # --- # name: test_all_entities[sensor.solarlog_last_update-entry] @@ -640,7 +838,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_all_entities[sensor.solarlog_power_available-entry] @@ -742,7 +940,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '102', + 'state': '102.0', }) # --- # name: test_all_entities[sensor.solarlog_self_consumption_year-entry] @@ -793,7 +991,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '545', + 'state': '545.0', }) # --- # name: test_all_entities[sensor.solarlog_usage-entry] @@ -844,7 +1042,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '54.9', + 'state': '54.8', }) # --- # name: test_all_entities[sensor.solarlog_voltage_ac-entry] @@ -895,7 +1093,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_all_entities[sensor.solarlog_voltage_dc-entry] @@ -946,7 +1144,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_all_entities[sensor.solarlog_yield_day-entry] diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py index b2b2ff9566e..223ceec3ebb 100644 --- a/tests/components/solarlog/test_config_flow.py +++ b/tests/components/solarlog/test_config_flow.py @@ -123,7 +123,7 @@ async def test_form_exceptions( assert result["data"]["extended_data"] is False -async def test_abort_if_already_setup(hass: HomeAssistant, test_connect) -> None: +async def test_abort_if_already_setup(hass: HomeAssistant, test_connect: None) -> None: """Test we abort if the device is already setup.""" flow = init_config_flow(hass) MockConfigEntry( From 12336f5c15e854d9138fd6d819ac00893125df01 Mon Sep 17 00:00:00 2001 From: Jeef Date: Sun, 1 Sep 2024 04:48:38 -0600 Subject: [PATCH 0113/1309] Bump Intellifire to 4.1.9 (#121091) * rebase * Minor patch to fix duplicate DeviceInfo beign created - if data hasnt updated yet * rebase * Minor patch to fix duplicate DeviceInfo beign created - if data hasnt updated yet * fixing formatting * Update homeassistant/components/intellifire/__init__.py Co-authored-by: Erik Montnemery * Update homeassistant/components/intellifire/__init__.py Co-authored-by: Erik Montnemery * Removing cloud connectivity sensor - leaving local one in * Renaming class to something more useful * addressing pr * Update homeassistant/components/intellifire/__init__.py Co-authored-by: Erik Montnemery * add ruff exception * Fix test annotations * remove access to private variable * Bumping to 4.1.9 instead of 4.1.5 * A renaming * rename * Updated testing * Update __init__.py Co-authored-by: Joost Lekkerkerker * updateing styrings * Update tests/components/intellifire/conftest.py Co-authored-by: Joost Lekkerkerker * Testing refactor - WIP * everything is passing - cleanup still needed * cleaning up comments * update pr * unrename * Update homeassistant/components/intellifire/coordinator.py Co-authored-by: Joost Lekkerkerker * fixing sentence * fixed fixture and removed error codes * reverted a bad change * fixing strings.json * revert renaming * fix * typing inother pr * adding extra tests - one has a really dumb name * using a real value * added a migration in * Update homeassistant/components/intellifire/config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/intellifire/test_init.py Co-authored-by: Joost Lekkerkerker * cleanup continues * addressing pr * switch back to debug * Update tests/components/intellifire/conftest.py Co-authored-by: Joost Lekkerkerker * some changes * restore property mock cuase didnt work otherwise * cleanup has begun * removed extra text * addressing pr stuff * fixed reauth --------- Co-authored-by: Erik Montnemery Co-authored-by: Joost Lekkerkerker --- .../components/intellifire/__init__.py | 168 ++-- .../components/intellifire/binary_sensor.py | 4 +- .../components/intellifire/climate.py | 2 +- .../components/intellifire/config_flow.py | 399 +++++----- homeassistant/components/intellifire/const.py | 19 +- .../components/intellifire/coordinator.py | 50 +- .../components/intellifire/entity.py | 6 +- homeassistant/components/intellifire/fan.py | 10 +- homeassistant/components/intellifire/light.py | 9 +- .../components/intellifire/manifest.json | 2 +- .../components/intellifire/sensor.py | 53 +- .../components/intellifire/strings.json | 35 +- .../components/intellifire/switch.py | 29 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/intellifire/__init__.py | 12 + tests/components/intellifire/conftest.py | 242 +++++- .../intellifire/fixtures/local_poll.json | 29 + .../intellifire/fixtures/user_data_1.json | 17 + .../intellifire/fixtures/user_data_3.json | 33 + .../snapshots/test_binary_sensor.ambr | 717 ++++++++++++++++++ .../intellifire/snapshots/test_climate.ambr | 66 ++ .../intellifire/snapshots/test_sensor.ambr | 587 ++++++++++++++ .../intellifire/test_binary_sensor.py | 35 + tests/components/intellifire/test_climate.py | 34 + .../intellifire/test_config_flow.py | 415 ++++------ tests/components/intellifire/test_init.py | 111 +++ tests/components/intellifire/test_sensor.py | 35 + 28 files changed, 2445 insertions(+), 678 deletions(-) create mode 100644 tests/components/intellifire/fixtures/local_poll.json create mode 100644 tests/components/intellifire/fixtures/user_data_1.json create mode 100644 tests/components/intellifire/fixtures/user_data_3.json create mode 100644 tests/components/intellifire/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/intellifire/snapshots/test_climate.ambr create mode 100644 tests/components/intellifire/snapshots/test_sensor.ambr create mode 100644 tests/components/intellifire/test_binary_sensor.py create mode 100644 tests/components/intellifire/test_climate.py create mode 100644 tests/components/intellifire/test_init.py create mode 100644 tests/components/intellifire/test_sensor.py diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 7af472c8745..7609398673b 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -2,15 +2,17 @@ from __future__ import annotations -from aiohttp import ClientConnectionError -from intellifire4py import IntellifireControlAsync -from intellifire4py.exceptions import LoginException -from intellifire4py.intellifire import IntellifireAPICloud, IntellifireAPILocal +import asyncio + +from intellifire4py import UnifiedFireplace +from intellifire4py.cloud_interface import IntelliFireCloudInterface +from intellifire4py.model import IntelliFireCommonFireplaceData from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_HOST, + CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME, Platform, @@ -18,7 +20,18 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import CONF_USER_ID, DOMAIN, LOGGER +from .const import ( + CONF_AUTH_COOKIE, + CONF_CONTROL_MODE, + CONF_READ_MODE, + CONF_SERIAL, + CONF_USER_ID, + CONF_WEB_CLIENT_ID, + DOMAIN, + INIT_WAIT_TIME_SECONDS, + LOGGER, + STARTUP_TIMEOUT, +) from .coordinator import IntellifireDataUpdateCoordinator PLATFORMS = [ @@ -32,79 +45,114 @@ PLATFORMS = [ ] +def _construct_common_data(entry: ConfigEntry) -> IntelliFireCommonFireplaceData: + """Convert config entry data into IntelliFireCommonFireplaceData.""" + + return IntelliFireCommonFireplaceData( + auth_cookie=entry.data[CONF_AUTH_COOKIE], + user_id=entry.data[CONF_USER_ID], + web_client_id=entry.data[CONF_WEB_CLIENT_ID], + serial=entry.data[CONF_SERIAL], + api_key=entry.data[CONF_API_KEY], + ip_address=entry.data[CONF_IP_ADDRESS], + read_mode=entry.options[CONF_READ_MODE], + control_mode=entry.options[CONF_CONTROL_MODE], + ) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate entries.""" + LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version == 1: + new = {**config_entry.data} + + if config_entry.minor_version < 2: + username = config_entry.data[CONF_USERNAME] + password = config_entry.data[CONF_PASSWORD] + + # Create a Cloud Interface + async with IntelliFireCloudInterface() as cloud_interface: + await cloud_interface.login_with_credentials( + username=username, password=password + ) + + new_data = cloud_interface.user_data.get_data_for_ip(new[CONF_HOST]) + + if not new_data: + raise ConfigEntryAuthFailed + new[CONF_API_KEY] = new_data.api_key + new[CONF_WEB_CLIENT_ID] = new_data.web_client_id + new[CONF_AUTH_COOKIE] = new_data.auth_cookie + + new[CONF_IP_ADDRESS] = new_data.ip_address + new[CONF_SERIAL] = new_data.serial + + hass.config_entries.async_update_entry( + config_entry, + data=new, + options={CONF_READ_MODE: "local", CONF_CONTROL_MODE: "local"}, + unique_id=new[CONF_SERIAL], + version=1, + minor_version=2, + ) + LOGGER.debug("Pseudo Migration %s successful", config_entry.version) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IntelliFire from a config entry.""" - LOGGER.debug("Setting up config entry: %s", entry.unique_id) if CONF_USERNAME not in entry.data: - LOGGER.debug("Old config entry format detected: %s", entry.unique_id) + LOGGER.debug("Config entry without username detected: %s", entry.unique_id) raise ConfigEntryAuthFailed - ift_control = IntellifireControlAsync( - fireplace_ip=entry.data[CONF_HOST], - ) try: - await ift_control.login( - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], + fireplace: UnifiedFireplace = ( + await UnifiedFireplace.build_fireplace_from_common( + _construct_common_data(entry) + ) ) - except (ConnectionError, ClientConnectionError) as err: - raise ConfigEntryNotReady from err - except LoginException as err: - raise ConfigEntryAuthFailed(err) from err - - finally: - await ift_control.close() - - # Extract API Key and User_ID from ift_control - # Eventually this will migrate to using IntellifireAPICloud - - if CONF_USER_ID not in entry.data or CONF_API_KEY not in entry.data: - LOGGER.info( - "Updating intellifire config entry for %s with api information", - entry.unique_id, - ) - cloud_api = IntellifireAPICloud() - await cloud_api.login( - username=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - ) - api_key = cloud_api.get_fireplace_api_key() - user_id = cloud_api.get_user_id() - # Update data entry - hass.config_entries.async_update_entry( - entry, - data={ - **entry.data, - CONF_API_KEY: api_key, - CONF_USER_ID: user_id, - }, + LOGGER.debug("Waiting for Fireplace to Initialize") + await asyncio.wait_for( + _async_wait_for_initialization(fireplace), timeout=STARTUP_TIMEOUT ) + except TimeoutError as err: + raise ConfigEntryNotReady( + "Initialization of fireplace timed out after 10 minutes" + ) from err - else: - api_key = entry.data[CONF_API_KEY] - user_id = entry.data[CONF_USER_ID] - - # Instantiate local control - api = IntellifireAPILocal( - fireplace_ip=entry.data[CONF_HOST], - api_key=api_key, - user_id=user_id, + # Construct coordinator + data_update_coordinator = IntellifireDataUpdateCoordinator( + hass=hass, fireplace=fireplace ) - # Define the update coordinator - coordinator = IntellifireDataUpdateCoordinator( - hass=hass, - api=api, - ) + LOGGER.debug("Fireplace to Initialized - Awaiting first refresh") + await data_update_coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_update_coordinator - await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def _async_wait_for_initialization( + fireplace: UnifiedFireplace, timeout=STARTUP_TIMEOUT +): + """Wait for a fireplace to be initialized.""" + while ( + fireplace.data.ipv4_address == "127.0.0.1" and fireplace.data.serial == "unset" + ): + LOGGER.debug(f"Waiting for fireplace to initialize [{fireplace.read_mode}]") + await asyncio.sleep(INIT_WAIT_TIME_SECONDS) + + 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): diff --git a/homeassistant/components/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index a1b8865c876..f0a5d84fa62 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from intellifire4py import IntellifirePollData +from intellifire4py.model import IntelliFirePollData from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -26,7 +26,7 @@ from .entity import IntellifireEntity class IntellifireBinarySensorRequiredKeysMixin: """Mixin for required keys.""" - value_fn: Callable[[IntellifirePollData], bool] + value_fn: Callable[[IntelliFirePollData], bool] @dataclass(frozen=True) diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py index ed4facffc67..4eddde5ff10 100644 --- a/homeassistant/components/intellifire/climate.py +++ b/homeassistant/components/intellifire/climate.py @@ -69,7 +69,7 @@ class IntellifireClimate(IntellifireEntity, ClimateEntity): super().__init__(coordinator, description) if coordinator.data.thermostat_on: - self.last_temp = coordinator.data.thermostat_setpoint_c + self.last_temp = int(coordinator.data.thermostat_setpoint_c) @property def hvac_mode(self) -> HVACMode: diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index 268fc6623d3..56f0d5ca6a5 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -7,16 +7,33 @@ from dataclasses import dataclass from typing import Any from aiohttp import ClientConnectionError -from intellifire4py import AsyncUDPFireplaceFinder -from intellifire4py.exceptions import LoginException -from intellifire4py.intellifire import IntellifireAPICloud, IntellifireAPILocal +from intellifire4py.cloud_interface import IntelliFireCloudInterface +from intellifire4py.exceptions import LoginError +from intellifire4py.local_api import IntelliFireAPILocal +from intellifire4py.model import IntelliFireCommonFireplaceData import voluptuous as vol from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_USERNAME, +) -from .const import CONF_USER_ID, DOMAIN, LOGGER +from .const import ( + API_MODE_LOCAL, + CONF_AUTH_COOKIE, + CONF_CONTROL_MODE, + CONF_READ_MODE, + CONF_SERIAL, + CONF_USER_ID, + CONF_WEB_CLIENT_ID, + DOMAIN, + LOGGER, +) STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) @@ -31,17 +48,20 @@ class DiscoveredHostInfo: serial: str | None -async def validate_host_input(host: str, dhcp_mode: bool = False) -> str: +async def _async_poll_local_fireplace_for_serial( + host: str, dhcp_mode: bool = False +) -> str: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ LOGGER.debug("Instantiating IntellifireAPI with host: [%s]", host) - api = IntellifireAPILocal(fireplace_ip=host) + api = IntelliFireAPILocal(fireplace_ip=host) await api.poll(suppress_warnings=dhcp_mode) serial = api.data.serial LOGGER.debug("Found a fireplace: %s", serial) + # Return the serial number which will be used to calculate a unique ID for the device/sensors return serial @@ -50,239 +70,206 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for IntelliFire.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the Config Flow Handler.""" - self._host: str = "" - self._serial: str = "" - self._not_configured_hosts: list[DiscoveredHostInfo] = [] + + # DHCP Variables + self._dhcp_discovered_serial: str = "" # used only in discovery mode self._discovered_host: DiscoveredHostInfo + self._dhcp_mode = False + self._is_reauth = False + + self._not_configured_hosts: list[DiscoveredHostInfo] = [] self._reauth_needed: DiscoveredHostInfo - async def _find_fireplaces(self): - """Perform UDP discovery.""" - fireplace_finder = AsyncUDPFireplaceFinder() - discovered_hosts = await fireplace_finder.search_fireplace(timeout=12) - configured_hosts = { - entry.data[CONF_HOST] - for entry in self._async_current_entries(include_ignore=False) - if CONF_HOST in entry.data # CONF_HOST will be missing for ignored entries - } + self._configured_serials: list[str] = [] - self._not_configured_hosts = [ - DiscoveredHostInfo(ip, None) - for ip in discovered_hosts - if ip not in configured_hosts - ] - LOGGER.debug("Discovered Hosts: %s", discovered_hosts) - LOGGER.debug("Configured Hosts: %s", configured_hosts) - LOGGER.debug("Not Configured Hosts: %s", self._not_configured_hosts) - - async def validate_api_access_and_create_or_update( - self, *, host: str, username: str, password: str, serial: str - ): - """Validate username/password against api.""" - LOGGER.debug("Attempting login to iftapi with: %s", username) - - ift_cloud = IntellifireAPICloud() - await ift_cloud.login(username=username, password=password) - api_key = ift_cloud.get_fireplace_api_key() - user_id = ift_cloud.get_user_id() - - data = { - CONF_HOST: host, - CONF_PASSWORD: password, - CONF_USERNAME: username, - CONF_API_KEY: api_key, - CONF_USER_ID: user_id, - } - - # Update or Create - existing_entry = await self.async_set_unique_id(serial) - if existing_entry: - self.hass.config_entries.async_update_entry(existing_entry, data=data) - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") - return self.async_create_entry(title=f"Fireplace {serial}", data=data) - - async def async_step_api_config( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure API access.""" - - errors = {} - control_schema = vol.Schema( - { - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } - ) - - if user_input is not None: - control_schema = vol.Schema( - { - vol.Required( - CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") - ): str, - vol.Required( - CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") - ): str, - } - ) - - try: - return await self.validate_api_access_and_create_or_update( - host=self._host, - username=user_input[CONF_USERNAME], - password=user_input[CONF_PASSWORD], - serial=self._serial, - ) - - except (ConnectionError, ClientConnectionError): - errors["base"] = "iftapi_connect" - LOGGER.error( - "Could not connect to iftapi.net over https - verify connectivity" - ) - except LoginException: - errors["base"] = "api_error" - LOGGER.error("Invalid credentials for iftapi.net") - - return self.async_show_form( - step_id="api_config", errors=errors, data_schema=control_schema - ) - - async def _async_validate_ip_and_continue(self, host: str) -> ConfigFlowResult: - """Validate local config and continue.""" - self._async_abort_entries_match({CONF_HOST: host}) - self._serial = await validate_host_input(host) - await self.async_set_unique_id(self._serial, raise_on_progress=False) - self._abort_if_unique_id_configured(updates={CONF_HOST: host}) - # Store current data and jump to next stage - self._host = host - - return await self.async_step_api_config() - - async def async_step_manual_device_entry(self, user_input=None): - """Handle manual input of local IP configuration.""" - LOGGER.debug("STEP: manual_device_entry") - errors = {} - self._host = user_input.get(CONF_HOST) if user_input else None - if user_input is not None: - try: - return await self._async_validate_ip_and_continue(self._host) - except (ConnectionError, ClientConnectionError): - errors["base"] = "cannot_connect" - - return self.async_show_form( - step_id="manual_device_entry", - errors=errors, - data_schema=vol.Schema({vol.Required(CONF_HOST, default=self._host): str}), - ) - - async def async_step_pick_device( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Pick which device to configure.""" - errors = {} - LOGGER.debug("STEP: pick_device") - - if user_input is not None: - if user_input[CONF_HOST] == MANUAL_ENTRY_STRING: - return await self.async_step_manual_device_entry() - - try: - return await self._async_validate_ip_and_continue(user_input[CONF_HOST]) - except (ConnectionError, ClientConnectionError): - errors["base"] = "cannot_connect" - - return self.async_show_form( - step_id="pick_device", - errors=errors, - data_schema=vol.Schema( - { - vol.Required(CONF_HOST): vol.In( - [host.ip for host in self._not_configured_hosts] - + [MANUAL_ENTRY_STRING] - ) - } - ), - ) + # Define a cloud api interface we can use + self.cloud_api_interface = IntelliFireCloudInterface() async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Start the user flow.""" - # Launch fireplaces discovery - await self._find_fireplaces() - LOGGER.debug("STEP: user") - if self._not_configured_hosts: - LOGGER.debug("Running Step: pick_device") - return await self.async_step_pick_device() - LOGGER.debug("Running Step: manual_device_entry") - return await self.async_step_manual_device_entry() + current_entries = self._async_current_entries(include_ignore=False) + self._configured_serials = [ + entry.data[CONF_SERIAL] for entry in current_entries + ] + + return await self.async_step_cloud_api() + + async def async_step_cloud_api( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Authenticate against IFTAPI Cloud in order to see configured devices. + + Local control of IntelliFire devices requires that the user download the correct API KEY which is only available on the cloud. Cloud control of the devices requires the user has at least once authenticated against the cloud and a set of cookie variables have been stored locally. + + """ + errors: dict[str, str] = {} + LOGGER.debug("STEP: cloud_api") + + if user_input is not None: + try: + async with self.cloud_api_interface as cloud_interface: + await cloud_interface.login_with_credentials( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + ) + + # If login was successful pass username/password to next step + return await self.async_step_pick_cloud_device() + except LoginError: + errors["base"] = "api_error" + + return self.async_show_form( + step_id="cloud_api", + errors=errors, + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + ) + + async def async_step_pick_cloud_device( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step to select a device from the cloud. + + We can only get here if we have logged in. If there is only one device available it will be auto-configured, + else the user will be given a choice to pick a device. + """ + errors: dict[str, str] = {} + LOGGER.debug( + f"STEP: pick_cloud_device: {user_input} - DHCP_MODE[{self._dhcp_mode}" + ) + + if self._dhcp_mode or user_input is not None: + if self._dhcp_mode: + serial = self._dhcp_discovered_serial + LOGGER.debug(f"DHCP Mode detected for serial [{serial}]") + if user_input is not None: + serial = user_input[CONF_SERIAL] + + # Run a unique ID Check prior to anything else + await self.async_set_unique_id(serial) + self._abort_if_unique_id_configured(updates={CONF_SERIAL: serial}) + + # If Serial is Good obtain fireplace and configure + fireplace = self.cloud_api_interface.user_data.get_data_for_serial(serial) + if fireplace: + return await self._async_create_config_entry_from_common_data( + fireplace=fireplace + ) + + # Parse User Data to see if we auto-configure or prompt for selection: + user_data = self.cloud_api_interface.user_data + + available_fireplaces: list[IntelliFireCommonFireplaceData] = [ + fp + for fp in user_data.fireplaces + if fp.serial not in self._configured_serials + ] + + # Abort if all devices have been configured + if not available_fireplaces: + return self.async_abort(reason="no_available_devices") + + # If there is a single fireplace configure it + if len(available_fireplaces) == 1: + if self._is_reauth: + reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self._async_create_config_entry_from_common_data( + fireplace=available_fireplaces[0], existing_entry=reauth_entry + ) + + return await self._async_create_config_entry_from_common_data( + fireplace=available_fireplaces[0] + ) + + return self.async_show_form( + step_id="pick_cloud_device", + errors=errors, + data_schema=vol.Schema( + { + vol.Required(CONF_SERIAL): vol.In( + [fp.serial for fp in available_fireplaces] + ) + } + ), + ) + + async def _async_create_config_entry_from_common_data( + self, + fireplace: IntelliFireCommonFireplaceData, + existing_entry: ConfigEntry | None = None, + ) -> ConfigFlowResult: + """Construct a config entry based on an object of IntelliFireCommonFireplaceData.""" + + data = { + CONF_IP_ADDRESS: fireplace.ip_address, + CONF_API_KEY: fireplace.api_key, + CONF_SERIAL: fireplace.serial, + CONF_AUTH_COOKIE: fireplace.auth_cookie, + CONF_WEB_CLIENT_ID: fireplace.web_client_id, + CONF_USER_ID: fireplace.user_id, + CONF_USERNAME: self.cloud_api_interface.user_data.username, + CONF_PASSWORD: self.cloud_api_interface.user_data.password, + } + + options = {CONF_READ_MODE: API_MODE_LOCAL, CONF_CONTROL_MODE: API_MODE_LOCAL} + + if existing_entry: + return self.async_update_reload_and_abort( + existing_entry, data=data, options=options + ) + return self.async_create_entry( + title=f"Fireplace {fireplace.serial}", data=data, options=options + ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Perform reauth upon an API authentication error.""" LOGGER.debug("STEP: reauth") + self._is_reauth = True entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) - assert entry - assert entry.unique_id # populate the expected vars - self._serial = entry.unique_id - self._host = entry.data[CONF_HOST] + self._dhcp_discovered_serial = entry.data[CONF_SERIAL] # type: ignore[union-attr] - placeholders = {CONF_HOST: self._host, "serial": self._serial} + placeholders = {"serial": self._dhcp_discovered_serial} self.context["title_placeholders"] = placeholders - return await self.async_step_api_config() + + return await self.async_step_cloud_api() async def async_step_dhcp( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle DHCP Discovery.""" + self._dhcp_mode = True # Run validation logic on ip - host = discovery_info.ip - LOGGER.debug("STEP: dhcp for host %s", host) + ip_address = discovery_info.ip + LOGGER.debug("STEP: dhcp for ip_address %s", ip_address) - self._async_abort_entries_match({CONF_HOST: host}) + self._async_abort_entries_match({CONF_IP_ADDRESS: ip_address}) try: - self._serial = await validate_host_input(host, dhcp_mode=True) + self._dhcp_discovered_serial = await _async_poll_local_fireplace_for_serial( + ip_address, dhcp_mode=True + ) except (ConnectionError, ClientConnectionError): LOGGER.debug( - "DHCP Discovery has determined %s is not an IntelliFire device", host + "DHCP Discovery has determined %s is not an IntelliFire device", + ip_address, ) return self.async_abort(reason="not_intellifire_device") - await self.async_set_unique_id(self._serial) - self._abort_if_unique_id_configured(updates={CONF_HOST: host}) - self._discovered_host = DiscoveredHostInfo(ip=host, serial=self._serial) - - placeholders = {CONF_HOST: host, "serial": self._serial} - self.context["title_placeholders"] = placeholders - self._set_confirm_only() - - return await self.async_step_dhcp_confirm() - - async def async_step_dhcp_confirm(self, user_input=None): - """Attempt to confirm.""" - - LOGGER.debug("STEP: dhcp_confirm") - # Add the hosts one by one - host = self._discovered_host.ip - serial = self._discovered_host.serial - - if user_input is None: - # Show the confirmation dialog - return self.async_show_form( - step_id="dhcp_confirm", - description_placeholders={CONF_HOST: host, "serial": serial}, - ) - - return self.async_create_entry( - title=f"Fireplace {serial}", - data={CONF_HOST: host}, - ) + return await self.async_step_cloud_api() diff --git a/homeassistant/components/intellifire/const.py b/homeassistant/components/intellifire/const.py index 5c8af1eefe9..f194eeaf4e2 100644 --- a/homeassistant/components/intellifire/const.py +++ b/homeassistant/components/intellifire/const.py @@ -5,11 +5,22 @@ from __future__ import annotations import logging DOMAIN = "intellifire" - -CONF_USER_ID = "user_id" - LOGGER = logging.getLogger(__package__) +DEFAULT_THERMOSTAT_TEMP = 21 + +CONF_USER_ID = "user_id" # part of the cloud cookie +CONF_WEB_CLIENT_ID = "web_client_id" # part of the cloud cookie +CONF_AUTH_COOKIE = "auth_cookie" # part of the cloud cookie CONF_SERIAL = "serial" +CONF_READ_MODE = "cloud_read" +CONF_CONTROL_MODE = "cloud_control" -DEFAULT_THERMOSTAT_TEMP = 21 + +API_MODE_LOCAL = "local" +API_MODE_CLOUD = "cloud" + + +STARTUP_TIMEOUT = 600 + +INIT_WAIT_TIME_SECONDS = 10 diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index 0a46ff61435..b4f03f4b5c8 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -2,27 +2,27 @@ from __future__ import annotations -import asyncio from datetime import timedelta -from aiohttp import ClientConnectionError -from intellifire4py import IntellifirePollData -from intellifire4py.intellifire import IntellifireAPILocal +from intellifire4py import UnifiedFireplace +from intellifire4py.control import IntelliFireController +from intellifire4py.model import IntelliFirePollData +from intellifire4py.read import IntelliFireDataProvider from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER -class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData]): +class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntelliFirePollData]): """Class to manage the polling of the fireplace API.""" def __init__( self, hass: HomeAssistant, - api: IntellifireAPILocal, + fireplace: UnifiedFireplace, ) -> None: """Initialize the Coordinator.""" super().__init__( @@ -31,36 +31,21 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData name=DOMAIN, update_interval=timedelta(seconds=15), ) - self._api = api - async def _async_update_data(self) -> IntellifirePollData: - if not self._api.is_polling_in_background: - LOGGER.info("Starting Intellifire Background Polling Loop") - await self._api.start_background_polling() - - # Don't return uninitialized poll data - async with asyncio.timeout(15): - try: - await self._api.poll() - except (ConnectionError, ClientConnectionError) as exception: - raise UpdateFailed from exception - - LOGGER.debug("Failure Count %d", self._api.failed_poll_attempts) - if self._api.failed_poll_attempts > 10: - LOGGER.debug("Too many polling errors - raising exception") - raise UpdateFailed - - return self._api.data + self.fireplace = fireplace @property - def read_api(self) -> IntellifireAPILocal: + def read_api(self) -> IntelliFireDataProvider: """Return the Status API pointer.""" - return self._api + return self.fireplace.read_api @property - def control_api(self) -> IntellifireAPILocal: + def control_api(self) -> IntelliFireController: """Return the control API.""" - return self._api + return self.fireplace.control_api + + async def _async_update_data(self) -> IntelliFirePollData: + return self.fireplace.data @property def device_info(self) -> DeviceInfo: @@ -69,7 +54,6 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData manufacturer="Hearth and Home", model="IFT-WFM", name="IntelliFire", - identifiers={("IntelliFire", f"{self.read_api.data.serial}]")}, - sw_version=self.read_api.data.fw_ver_str, - configuration_url=f"http://{self._api.fireplace_ip}/poll", + identifiers={("IntelliFire", str(self.fireplace.serial))}, + configuration_url=f"http://{self.fireplace.ip_address}/poll", ) diff --git a/homeassistant/components/intellifire/entity.py b/homeassistant/components/intellifire/entity.py index 3b35c9dabd8..571c4717ac2 100644 --- a/homeassistant/components/intellifire/entity.py +++ b/homeassistant/components/intellifire/entity.py @@ -9,7 +9,7 @@ from . import IntellifireDataUpdateCoordinator class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): - """Define a generic class for Intellifire entities.""" + """Define a generic class for IntelliFire entities.""" _attr_attribution = "Data provided by unpublished Intellifire API" _attr_has_entity_name = True @@ -22,6 +22,8 @@ class IntellifireEntity(CoordinatorEntity[IntellifireDataUpdateCoordinator]): """Class initializer.""" super().__init__(coordinator=coordinator) self.entity_description = description - self._attr_unique_id = f"{description.key}_{coordinator.read_api.data.serial}" + self._attr_unique_id = f"{description.key}_{coordinator.fireplace.serial}" + self.identifiers = ({("IntelliFire", f"{coordinator.fireplace.serial}]")},) + # Configure the Device Info self._attr_device_info = self.coordinator.device_info diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index f68827b0a56..dc2fc279a5d 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -7,7 +7,8 @@ from dataclasses import dataclass import math from typing import Any -from intellifire4py import IntellifireControlAsync, IntellifirePollData +from intellifire4py.control import IntelliFireController +from intellifire4py.model import IntelliFirePollData from homeassistant.components.fan import ( FanEntity, @@ -31,8 +32,8 @@ from .entity import IntellifireEntity class IntellifireFanRequiredKeysMixin: """Required keys for fan entity.""" - set_fn: Callable[[IntellifireControlAsync, int], Awaitable] - value_fn: Callable[[IntellifirePollData], bool] + set_fn: Callable[[IntelliFireController, int], Awaitable] + value_fn: Callable[[IntelliFirePollData], int] speed_range: tuple[int, int] @@ -91,7 +92,8 @@ class IntellifireFan(IntellifireEntity, FanEntity): def percentage(self) -> int | None: """Return fan percentage.""" return ranged_value_to_percentage( - self.entity_description.speed_range, self.coordinator.read_api.data.fanspeed + self.entity_description.speed_range, + self.coordinator.read_api.data.fanspeed, ) @property diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index a7f2befaf33..5f25b5de823 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -6,7 +6,8 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from intellifire4py import IntellifireControlAsync, IntellifirePollData +from intellifire4py.control import IntelliFireController +from intellifire4py.model import IntelliFirePollData from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -27,8 +28,8 @@ from .entity import IntellifireEntity class IntellifireLightRequiredKeysMixin: """Required keys for fan entity.""" - set_fn: Callable[[IntellifireControlAsync, int], Awaitable] - value_fn: Callable[[IntellifirePollData], bool] + set_fn: Callable[[IntelliFireController, int], Awaitable] + value_fn: Callable[[IntelliFirePollData], int] @dataclass(frozen=True) @@ -56,7 +57,7 @@ class IntellifireLight(IntellifireEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @property - def brightness(self): + def brightness(self) -> int: """Return the current brightness 0-255.""" return 85 * self.entity_description.value_fn(self.coordinator.read_api.data) diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index 90d41fcffe7..e3ee663e8fe 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/intellifire", "iot_class": "local_polling", "loggers": ["intellifire4py"], - "requirements": ["intellifire4py==2.2.2"] + "requirements": ["intellifire4py==4.1.9"] } diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index dd3eef9c9b4..eaff89d08e7 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -6,8 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from intellifire4py import IntellifirePollData - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -29,7 +27,9 @@ from .entity import IntellifireEntity class IntellifireSensorRequiredKeysMixin: """Mixin for required keys.""" - value_fn: Callable[[IntellifirePollData], int | str | datetime | None] + value_fn: Callable[ + [IntellifireDataUpdateCoordinator], int | str | datetime | float | None + ] @dataclass(frozen=True) @@ -40,16 +40,29 @@ class IntellifireSensorEntityDescription( """Describes a sensor entity.""" -def _time_remaining_to_timestamp(data: IntellifirePollData) -> datetime | None: +def _time_remaining_to_timestamp( + coordinator: IntellifireDataUpdateCoordinator, +) -> datetime | None: """Define a sensor that takes into account timezone.""" - if not (seconds_offset := data.timeremaining_s): + if not (seconds_offset := coordinator.data.timeremaining_s): return None return utcnow() + timedelta(seconds=seconds_offset) -def _downtime_to_timestamp(data: IntellifirePollData) -> datetime | None: +def _downtime_to_timestamp( + coordinator: IntellifireDataUpdateCoordinator, +) -> datetime | None: """Define a sensor that takes into account a timezone.""" - if not (seconds_offset := data.downtime): + if not (seconds_offset := coordinator.data.downtime): + return None + return utcnow() - timedelta(seconds=seconds_offset) + + +def _uptime_to_timestamp( + coordinator: IntellifireDataUpdateCoordinator, +) -> datetime | None: + """Return a timestamp of how long the sensor has been up.""" + if not (seconds_offset := coordinator.data.uptime): return None return utcnow() - timedelta(seconds=seconds_offset) @@ -60,14 +73,14 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( translation_key="flame_height", state_class=SensorStateClass.MEASUREMENT, # UI uses 1-5 for flame height, backing lib uses 0-4 - value_fn=lambda data: (data.flameheight + 1), + value_fn=lambda coordinator: (coordinator.data.flameheight + 1), ), IntellifireSensorEntityDescription( key="temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: data.temperature_c, + value_fn=lambda coordinator: coordinator.data.temperature_c, ), IntellifireSensorEntityDescription( key="target_temp", @@ -75,13 +88,13 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda data: data.thermostat_setpoint_c, + value_fn=lambda coordinator: coordinator.data.thermostat_setpoint_c, ), IntellifireSensorEntityDescription( key="fan_speed", translation_key="fan_speed", state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: data.fanspeed, + value_fn=lambda coordinator: coordinator.data.fanspeed, ), IntellifireSensorEntityDescription( key="timer_end_timestamp", @@ -102,27 +115,27 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( translation_key="uptime", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda data: utcnow() - timedelta(seconds=data.uptime), + value_fn=_uptime_to_timestamp, ), IntellifireSensorEntityDescription( key="connection_quality", translation_key="connection_quality", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.connection_quality, + value_fn=lambda coordinator: coordinator.data.connection_quality, entity_registry_enabled_default=False, ), IntellifireSensorEntityDescription( key="ecm_latency", translation_key="ecm_latency", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.ecm_latency, + value_fn=lambda coordinator: coordinator.data.ecm_latency, entity_registry_enabled_default=False, ), IntellifireSensorEntityDescription( key="ipv4_address", translation_key="ipv4_address", entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda data: data.ipv4_address, + value_fn=lambda coordinator: coordinator.data.ipv4_address, ), ) @@ -134,17 +147,17 @@ async def async_setup_entry( coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - IntellifireSensor(coordinator=coordinator, description=description) + IntelliFireSensor(coordinator=coordinator, description=description) for description in INTELLIFIRE_SENSORS ) -class IntellifireSensor(IntellifireEntity, SensorEntity): - """Extends IntellifireEntity with Sensor specific logic.""" +class IntelliFireSensor(IntellifireEntity, SensorEntity): + """Extends IntelliFireEntity with Sensor specific logic.""" entity_description: IntellifireSensorEntityDescription @property - def native_value(self) -> int | str | datetime | None: + def native_value(self) -> int | str | datetime | float | None: """Return the state.""" - return self.entity_description.value_fn(self.coordinator.read_api.data) + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/intellifire/strings.json b/homeassistant/components/intellifire/strings.json index 6393a4e070d..2eeb2b50b93 100644 --- a/homeassistant/components/intellifire/strings.json +++ b/homeassistant/components/intellifire/strings.json @@ -1,39 +1,30 @@ { "config": { - "flow_title": "{serial} ({host})", + "flow_title": "{serial}", "step": { - "manual_device_entry": { - "description": "Local Configuration", - "data": { - "host": "Host (IP Address)" - } + "pick_cloud_device": { + "title": "Configure fireplace", + "description": "Select fireplace by serial number:" }, - "api_config": { + "cloud_api": { + "description": "Authenticate against IntelliFire Cloud", + "data_description": { + "username": "Your IntelliFire app username", + "password": "Your IntelliFire app password" + }, "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } - }, - "dhcp_confirm": { - "description": "Do you want to set up {host}\nSerial: {serial}?" - }, - "pick_device": { - "title": "Device Selection", - "description": "The following IntelliFire devices were discovered. Please select which you wish to configure.", - "data": { - "host": "[%key:common::config_flow::data::host%]" - } } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "api_error": "Login failed", - "iftapi_connect": "Error conecting to iftapi.net" + "api_error": "Login failed" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "not_intellifire_device": "Not an IntelliFire Device." + "not_intellifire_device": "Not an IntelliFire device.", + "no_available_devices": "All available devices have already been configured." } }, "entity": { diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index 00de6d74a9c..ac6096497b6 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -6,16 +6,13 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from intellifire4py import IntellifirePollData -from intellifire4py.intellifire import IntellifireAPILocal - from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import IntellifireDataUpdateCoordinator from .const import DOMAIN -from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity @@ -23,9 +20,9 @@ from .entity import IntellifireEntity class IntellifireSwitchRequiredKeysMixin: """Mixin for required keys.""" - on_fn: Callable[[IntellifireAPILocal], Awaitable] - off_fn: Callable[[IntellifireAPILocal], Awaitable] - value_fn: Callable[[IntellifirePollData], bool] + on_fn: Callable[[IntellifireDataUpdateCoordinator], Awaitable] + off_fn: Callable[[IntellifireDataUpdateCoordinator], Awaitable] + value_fn: Callable[[IntellifireDataUpdateCoordinator], bool] @dataclass(frozen=True) @@ -39,16 +36,16 @@ INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = ( IntellifireSwitchEntityDescription( key="on_off", translation_key="flame", - on_fn=lambda control_api: control_api.flame_on(), - off_fn=lambda control_api: control_api.flame_off(), - value_fn=lambda data: data.is_on, + on_fn=lambda coordinator: coordinator.control_api.flame_on(), + off_fn=lambda coordinator: coordinator.control_api.flame_off(), + value_fn=lambda coordinator: coordinator.read_api.data.is_on, ), IntellifireSwitchEntityDescription( key="pilot", translation_key="pilot_light", - on_fn=lambda control_api: control_api.pilot_on(), - off_fn=lambda control_api: control_api.pilot_off(), - value_fn=lambda data: data.pilot_on, + on_fn=lambda coordinator: coordinator.control_api.pilot_on(), + off_fn=lambda coordinator: coordinator.control_api.pilot_off(), + value_fn=lambda coordinator: coordinator.read_api.data.pilot_on, ), ) @@ -74,15 +71,15 @@ class IntellifireSwitch(IntellifireEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" - await self.entity_description.on_fn(self.coordinator.control_api) + await self.entity_description.on_fn(self.coordinator) await self.async_update_ha_state(force_refresh=True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" - await self.entity_description.off_fn(self.coordinator.control_api) + await self.entity_description.off_fn(self.coordinator) await self.async_update_ha_state(force_refresh=True) @property def is_on(self) -> bool | None: """Return the on state.""" - return self.entity_description.value_fn(self.coordinator.read_api.data) + return self.entity_description.value_fn(self.coordinator) diff --git a/requirements_all.txt b/requirements_all.txt index f7d8147a058..9c12227e6ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1180,7 +1180,7 @@ inkbird-ble==0.5.8 insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire -intellifire4py==2.2.2 +intellifire4py==4.1.9 # homeassistant.components.iotty iottycloud==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f234b427248..c4d7dd1ad44 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ inkbird-ble==0.5.8 insteon-frontend-home-assistant==0.5.0 # homeassistant.components.intellifire -intellifire4py==2.2.2 +intellifire4py==4.1.9 # homeassistant.components.iotty iottycloud==0.1.3 diff --git a/tests/components/intellifire/__init__.py b/tests/components/intellifire/__init__.py index f655ccc2fa4..50497939f7f 100644 --- a/tests/components/intellifire/__init__.py +++ b/tests/components/intellifire/__init__.py @@ -1 +1,13 @@ """Tests for the IntelliFire integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/intellifire/conftest.py b/tests/components/intellifire/conftest.py index cf1e085c10f..251d5bdde48 100644 --- a/tests/components/intellifire/conftest.py +++ b/tests/components/intellifire/conftest.py @@ -1,14 +1,40 @@ """Fixtures for IntelliFire integration tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch -from aiohttp.client_reqrep import ConnectionKey +from intellifire4py.const import IntelliFireApiMode +from intellifire4py.model import ( + IntelliFireCommonFireplaceData, + IntelliFirePollData, + IntelliFireUserData, +) import pytest +from homeassistant.components.intellifire.const import ( + API_MODE_CLOUD, + API_MODE_LOCAL, + CONF_AUTH_COOKIE, + CONF_CONTROL_MODE, + CONF_READ_MODE, + CONF_SERIAL, + CONF_USER_ID, + CONF_WEB_CLIENT_ID, + DOMAIN, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_USERNAME, +) + +from tests.common import MockConfigEntry, load_json_object_fixture + @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: +def mock_setup_entry() -> Generator[AsyncMock, None, None]: """Mock setting up a config entry.""" with patch( "homeassistant.components.intellifire.async_setup_entry", return_value=True @@ -17,44 +43,206 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_fireplace_finder_none() -> Generator[MagicMock]: +def mock_fireplace_finder_none() -> Generator[None, MagicMock, None]: """Mock fireplace finder.""" mock_found_fireplaces = Mock() mock_found_fireplaces.ips = [] with patch( - "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace" + "homeassistant.components.intellifire.config_flow.UDPFireplaceFinder.search_fireplace" ): yield mock_found_fireplaces @pytest.fixture -def mock_fireplace_finder_single() -> Generator[MagicMock]: - """Mock fireplace finder.""" - mock_found_fireplaces = Mock() - mock_found_fireplaces.ips = ["192.168.1.69"] - with patch( - "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace" - ): - yield mock_found_fireplaces +def mock_config_entry_current() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=2, + data={ + CONF_IP_ADDRESS: "192.168.2.108", + CONF_USERNAME: "grumpypanda@china.cn", + CONF_PASSWORD: "you-stole-my-pandas", + CONF_SERIAL: "3FB284769E4736F30C8973A7ED358123", + CONF_WEB_CLIENT_ID: "FA2B1C3045601234D0AE17D72F8E975", + CONF_API_KEY: "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2", + CONF_AUTH_COOKIE: "B984F21A6378560019F8A1CDE41B6782", + CONF_USER_ID: "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + }, + options={CONF_READ_MODE: API_MODE_LOCAL, CONF_CONTROL_MODE: API_MODE_CLOUD}, + unique_id="3FB284769E4736F30C8973A7ED358123", + ) @pytest.fixture -def mock_intellifire_config_flow() -> Generator[MagicMock]: - """Return a mocked IntelliFire client.""" - data_mock = Mock() - data_mock.serial = "12345" +def mock_config_entry_old() -> MockConfigEntry: + """For migration testing.""" + return MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=1, + title="Fireplace 3FB284769E4736F30C8973A7ED358123", + data={ + CONF_HOST: "192.168.2.108", + CONF_USERNAME: "grumpypanda@china.cn", + CONF_PASSWORD: "you-stole-my-pandas", + CONF_USER_ID: "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + }, + ) + +@pytest.fixture +def mock_common_data_local() -> IntelliFireCommonFireplaceData: + """Fixture for mock common data.""" + return IntelliFireCommonFireplaceData( + auth_cookie="B984F21A6378560019F8A1CDE41B6782", + user_id="52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + web_client_id="FA2B1C3045601234D0AE17D72F8E975", + serial="3FB284769E4736F30C8973A7ED358123", + api_key="B5C4DA27AAEF31D1FB21AFF9BFA6BCD2", + ip_address="192.168.2.108", + read_mode=IntelliFireApiMode.LOCAL, + control_mode=IntelliFireApiMode.LOCAL, + ) + + +@pytest.fixture +def mock_apis_multifp( + mock_cloud_interface, mock_local_interface, mock_fp +) -> Generator[tuple[AsyncMock, AsyncMock, MagicMock], None, None]: + """Multi fireplace version of mocks.""" + return mock_local_interface, mock_cloud_interface, mock_fp + + +@pytest.fixture +def mock_apis_single_fp( + mock_cloud_interface, mock_local_interface, mock_fp +) -> Generator[tuple[AsyncMock, AsyncMock, MagicMock], None, None]: + """Single fire place version of the mocks.""" + data_v1 = IntelliFireUserData( + **load_json_object_fixture("user_data_1.json", DOMAIN) + ) + with patch.object( + type(mock_cloud_interface), "user_data", new_callable=PropertyMock + ) as mock_user_data: + mock_user_data.return_value = data_v1 + yield mock_local_interface, mock_cloud_interface, mock_fp + + +@pytest.fixture +def mock_cloud_interface() -> Generator[AsyncMock, None, None]: + """Mock cloud interface to use for testing.""" + user_data = IntelliFireUserData( + **load_json_object_fixture("user_data_3.json", DOMAIN) + ) + + with ( + patch( + "homeassistant.components.intellifire.IntelliFireCloudInterface", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.intellifire.config_flow.IntelliFireCloudInterface", + new=mock_client, + ), + patch( + "intellifire4py.cloud_interface.IntelliFireCloudInterface", + new=mock_client, + ), + ): + # Mock async context manager + mock_client = mock_client.return_value + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + # Mock other async methods if needed + mock_client.login_with_credentials = AsyncMock() + mock_client.poll = AsyncMock() + type(mock_client).user_data = PropertyMock(return_value=user_data) + + yield mock_client # Yielding to the test + + +@pytest.fixture +def mock_local_interface() -> Generator[AsyncMock, None, None]: + """Mock version of IntelliFireAPILocal.""" + poll_data = IntelliFirePollData( + **load_json_object_fixture("intellifire/local_poll.json") + ) with patch( - "homeassistant.components.intellifire.config_flow.IntellifireAPILocal", + "homeassistant.components.intellifire.config_flow.IntelliFireAPILocal", autospec=True, - ) as intellifire_mock: - intellifire = intellifire_mock.return_value - intellifire.data = data_mock - yield intellifire + ) as mock_client: + mock_client = mock_client.return_value + # Mock all instances of the class + type(mock_client).data = PropertyMock(return_value=poll_data) + yield mock_client -def mock_api_connection_error() -> ConnectionError: - """Return a fake a ConnectionError for iftapi.net.""" - ret = ConnectionError() - ret.args = [ConnectionKey("iftapi.net", 443, False, None, None, None, None)] - return ret +@pytest.fixture +def mock_fp(mock_common_data_local) -> Generator[AsyncMock, None, None]: + """Mock fireplace.""" + + local_poll_data = IntelliFirePollData( + **load_json_object_fixture("local_poll.json", DOMAIN) + ) + + assert local_poll_data.connection_quality == 988451 + + with patch( + "homeassistant.components.intellifire.UnifiedFireplace" + ) as mock_unified_fireplace: + # Create an instance of the mock + mock_instance = mock_unified_fireplace.return_value + + # Mock methods and properties of the instance + mock_instance.perform_cloud_poll = AsyncMock() + mock_instance.perform_local_poll = AsyncMock() + + mock_instance.async_validate_connectivity = AsyncMock(return_value=(True, True)) + + type(mock_instance).is_cloud_polling = PropertyMock(return_value=False) + type(mock_instance).is_local_polling = PropertyMock(return_value=True) + + mock_instance.get_user_data_as_json.return_value = '{"mock": "data"}' + + mock_instance.ip_address = "192.168.1.100" + mock_instance.api_key = "mock_api_key" + mock_instance.serial = "mock_serial" + mock_instance.user_id = "mock_user_id" + mock_instance.auth_cookie = "mock_auth_cookie" + mock_instance.web_client_id = "mock_web_client_id" + + # Configure the READ Api + mock_instance.read_api = MagicMock() + mock_instance.read_api.poll = MagicMock(return_value=local_poll_data) + mock_instance.read_api.data = local_poll_data + + mock_instance.control_api = MagicMock() + + mock_instance.local_connectivity = True + mock_instance.cloud_connectivity = False + + mock_instance._read_mode = IntelliFireApiMode.LOCAL + mock_instance.read_mode = IntelliFireApiMode.LOCAL + + mock_instance.control_mode = IntelliFireApiMode.LOCAL + mock_instance._control_mode = IntelliFireApiMode.LOCAL + + mock_instance.data = local_poll_data + + mock_instance.set_read_mode = AsyncMock() + mock_instance.set_control_mode = AsyncMock() + + mock_instance.async_validate_connectivity = AsyncMock( + return_value=(True, False) + ) + + # Patch class methods + with patch( + "homeassistant.components.intellifire.UnifiedFireplace.build_fireplace_from_common", + new_callable=AsyncMock, + return_value=mock_instance, + ): + yield mock_instance diff --git a/tests/components/intellifire/fixtures/local_poll.json b/tests/components/intellifire/fixtures/local_poll.json new file mode 100644 index 00000000000..9dac47c698d --- /dev/null +++ b/tests/components/intellifire/fixtures/local_poll.json @@ -0,0 +1,29 @@ +{ + "name": "", + "serial": "4GC295860E5837G40D9974B7FD459234", + "temperature": 17, + "battery": 0, + "pilot": 1, + "light": 0, + "height": 1, + "fanspeed": 1, + "hot": 0, + "power": 1, + "thermostat": 0, + "setpoint": 0, + "timer": 0, + "timeremaining": 0, + "prepurge": 0, + "feature_light": 0, + "feature_thermostat": 1, + "power_vent": 0, + "feature_fan": 1, + "errors": [], + "fw_version": "0x00030200", + "fw_ver_str": "0.3.2+hw2", + "downtime": 0, + "uptime": 117, + "connection_quality": 988451, + "ecm_latency": 0, + "ipv4_address": "192.168.2.108" +} diff --git a/tests/components/intellifire/fixtures/user_data_1.json b/tests/components/intellifire/fixtures/user_data_1.json new file mode 100644 index 00000000000..501d240662b --- /dev/null +++ b/tests/components/intellifire/fixtures/user_data_1.json @@ -0,0 +1,17 @@ +{ + "auth_cookie": "B984F21A6378560019F8A1CDE41B6782", + "user_id": "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + "web_client_id": "FA2B1C3045601234D0AE17D72F8E975", + "fireplaces": [ + { + "auth_cookie": "B984F21A6378560019F8A1CDE41B6782", + "user_id": "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + "web_client_id": "FA2B1C3045601234D0AE17D72F8E975", + "ip_address": "192.168.2.108", + "api_key": "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2", + "serial": "3FB284769E4736F30C8973A7ED358123" + } + ], + "username": "grumpypanda@china.cn", + "password": "you-stole-my-pandas" +} diff --git a/tests/components/intellifire/fixtures/user_data_3.json b/tests/components/intellifire/fixtures/user_data_3.json new file mode 100644 index 00000000000..39e9c95abbd --- /dev/null +++ b/tests/components/intellifire/fixtures/user_data_3.json @@ -0,0 +1,33 @@ +{ + "auth_cookie": "B984F21A6378560019F8A1CDE41B6782", + "user_id": "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + "web_client_id": "FA2B1C3045601234D0AE17D72F8E975", + "fireplaces": [ + { + "auth_cookie": "B984F21A6378560019F8A1CDE41B6782", + "user_id": "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + "web_client_id": "FA2B1C3045601234D0AE17D72F8E975", + "ip_address": "192.168.2.108", + "api_key": "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2", + "serial": "3FB284769E4736F30C8973A7ED358123" + }, + { + "auth_cookie": "B984F21A6378560019F8A1CDE41B6782", + "user_id": "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + "web_client_id": "FA2B1C3045601234D0AE17D72F8E975", + "ip_address": "192.168.2.109", + "api_key": "D4C5EB28BBFF41E1FB21AFF9BFA6CD34", + "serial": "4GC295860E5837G40D9974B7FD459234" + }, + { + "auth_cookie": "B984F21A6378560019F8A1CDE41B6782", + "user_id": "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + "web_client_id": "FA2B1C3045601234D0AE17D72F8E975", + "ip_address": "192.168.2.110", + "api_key": "E5D6FC39CCED52F1FB21AFF9BFA6DE56", + "serial": "5HD306971F5938H51EAA85C8GE561345" + } + ], + "username": "grumpypanda@china.cn", + "password": "you-stole-my-pandas" +} diff --git a/tests/components/intellifire/snapshots/test_binary_sensor.ambr b/tests/components/intellifire/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..34d5836a025 --- /dev/null +++ b/tests/components/intellifire/snapshots/test_binary_sensor.ambr @@ -0,0 +1,717 @@ +# serializer version: 1 +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_accessory_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_accessory_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Accessory error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'accessory_error', + 'unique_id': 'error_accessory_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_accessory_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire Accessory error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_accessory_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_disabled_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_disabled_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disabled error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disabled_error', + 'unique_id': 'error_disabled_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_disabled_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire Disabled error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_disabled_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_ecm_offline_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_ecm_offline_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ECM offline error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ecm_offline_error', + 'unique_id': 'error_ecm_offline_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_ecm_offline_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire ECM offline error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_ecm_offline_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_fan_delay_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_fan_delay_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fan delay error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_delay_error', + 'unique_id': 'error_fan_delay_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_fan_delay_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire Fan delay error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_fan_delay_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_fan_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_fan_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Fan error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_error', + 'unique_id': 'error_fan_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_fan_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire Fan error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_fan_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_flame-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.intellifire_flame', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flame', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flame', + 'unique_id': 'on_off_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_flame-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire Flame', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_flame', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_flame_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_flame_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flame Error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flame_error', + 'unique_id': 'error_flame_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_flame_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire Flame Error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_flame_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_lights_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_lights_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lights error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lights_error', + 'unique_id': 'error_lights_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_lights_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire Lights error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_lights_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_maintenance_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_maintenance_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maintenance error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'maintenance_error', + 'unique_id': 'error_maintenance_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_maintenance_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire Maintenance error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_maintenance_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_offline_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_offline_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Offline error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'offline_error', + 'unique_id': 'error_offline_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_offline_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire Offline error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_offline_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_pilot_flame_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_pilot_flame_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pilot flame error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pilot_flame_error', + 'unique_id': 'error_pilot_flame_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_pilot_flame_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire Pilot flame error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_pilot_flame_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_pilot_light_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.intellifire_pilot_light_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Pilot light on', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pilot_light_on', + 'unique_id': 'pilot_light_on_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_pilot_light_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire Pilot light on', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_pilot_light_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_soft_lock_out_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.intellifire_soft_lock_out_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Soft lock out error', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'soft_lock_out_error', + 'unique_id': 'error_soft_lock_out_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_soft_lock_out_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'problem', + 'friendly_name': 'IntelliFire Soft lock out error', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_soft_lock_out_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_thermostat_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.intellifire_thermostat_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat on', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'thermostat_on', + 'unique_id': 'thermostat_on_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_thermostat_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire Thermostat on', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_thermostat_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_timer_on-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.intellifire_timer_on', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Timer on', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'timer_on', + 'unique_id': 'timer_on_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensor_entities[binary_sensor.intellifire_timer_on-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire Timer on', + }), + 'context': , + 'entity_id': 'binary_sensor.intellifire_timer_on', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/intellifire/snapshots/test_climate.ambr b/tests/components/intellifire/snapshots/test_climate.ambr new file mode 100644 index 00000000000..36f719d2264 --- /dev/null +++ b/tests/components/intellifire/snapshots/test_climate.ambr @@ -0,0 +1,66 @@ +# serializer version: 1 +# name: test_all_sensor_entities[climate.intellifire_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 37, + 'min_temp': 0, + 'target_temp_step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.intellifire_thermostat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Thermostat', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'climate_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[climate.intellifire_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'current_temperature': 17.0, + 'friendly_name': 'IntelliFire Thermostat', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 37, + 'min_temp': 0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 0.0, + }), + 'context': , + 'entity_id': 'climate.intellifire_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/intellifire/snapshots/test_sensor.ambr b/tests/components/intellifire/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..d5e59e3f00f --- /dev/null +++ b/tests/components/intellifire/snapshots/test_sensor.ambr @@ -0,0 +1,587 @@ +# serializer version: 1 +# name: test_all_sensor_entities[sensor.intellifire_connection_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.intellifire_connection_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Connection quality', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'connection_quality', + 'unique_id': 'connection_quality_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_connection_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire Connection quality', + }), + 'context': , + 'entity_id': 'sensor.intellifire_connection_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '988451', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_downtime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.intellifire_downtime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Downtime', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'downtime', + 'unique_id': 'downtime_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_downtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'timestamp', + 'friendly_name': 'IntelliFire Downtime', + }), + 'context': , + 'entity_id': 'sensor.intellifire_downtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_ecm_latency-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.intellifire_ecm_latency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'ECM latency', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ecm_latency', + 'unique_id': 'ecm_latency_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_ecm_latency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire ECM latency', + }), + 'context': , + 'entity_id': 'sensor.intellifire_ecm_latency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_fan_speed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.intellifire_fan_speed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Fan Speed', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fan_speed', + 'unique_id': 'fan_speed_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_fan_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire Fan Speed', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.intellifire_fan_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_flame_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.intellifire_flame_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flame height', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flame_height', + 'unique_id': 'flame_height_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_flame_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire Flame height', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.intellifire_flame_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_ip_address-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.intellifire_ip_address', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'IP address', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ipv4_address', + 'unique_id': 'ipv4_address_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_ip_address-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire IP address', + }), + 'context': , + 'entity_id': 'sensor.intellifire_ip_address', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '192.168.2.108', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_local_connectivity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.intellifire_local_connectivity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Local connectivity', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'local_connectivity', + 'unique_id': 'local_connectivity_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_local_connectivity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire Local connectivity', + }), + 'context': , + 'entity_id': 'sensor.intellifire_local_connectivity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'True', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.intellifire_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'local_connectivity', + 'unique_id': 'local_connectivity_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'friendly_name': 'IntelliFire None', + }), + 'context': , + 'entity_id': 'sensor.intellifire_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'True', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.intellifire_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Target temperature', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'target_temp', + 'unique_id': 'target_temp_mock_serial', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'temperature', + 'friendly_name': 'IntelliFire Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.intellifire_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.intellifire_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'temperature_mock_serial', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'temperature', + 'friendly_name': 'IntelliFire Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.intellifire_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_timer_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.intellifire_timer_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Timer end', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'timer_end_timestamp', + 'unique_id': 'timer_end_timestamp_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_timer_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'timestamp', + 'friendly_name': 'IntelliFire Timer end', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.intellifire_timer_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.intellifire_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'intellifire', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'uptime', + 'unique_id': 'uptime_mock_serial', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensor_entities[sensor.intellifire_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by unpublished Intellifire API', + 'device_class': 'timestamp', + 'friendly_name': 'IntelliFire Uptime', + }), + 'context': , + 'entity_id': 'sensor.intellifire_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2021-01-01T11:58:03+00:00', + }) +# --- diff --git a/tests/components/intellifire/test_binary_sensor.py b/tests/components/intellifire/test_binary_sensor.py new file mode 100644 index 00000000000..a40f92b84d5 --- /dev/null +++ b/tests/components/intellifire/test_binary_sensor.py @@ -0,0 +1,35 @@ +"""Test IntelliFire Binary Sensors.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_binary_sensor_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry_current: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_apis_single_fp: tuple[AsyncMock, AsyncMock, AsyncMock], +) -> None: + """Test all entities.""" + + with ( + patch( + "homeassistant.components.intellifire.PLATFORMS", [Platform.BINARY_SENSOR] + ), + ): + await setup_integration(hass, mock_config_entry_current) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry_current.entry_id + ) diff --git a/tests/components/intellifire/test_climate.py b/tests/components/intellifire/test_climate.py new file mode 100644 index 00000000000..da1b2864791 --- /dev/null +++ b/tests/components/intellifire/test_climate.py @@ -0,0 +1,34 @@ +"""Test climate.""" + +from unittest.mock import patch + +from freezegun import freeze_time +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@freeze_time("2021-01-01T12:00:00Z") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_sensor_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry_current: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_fp, +) -> None: + """Test all entities.""" + with ( + patch("homeassistant.components.intellifire.PLATFORMS", [Platform.CLIMATE]), + ): + await setup_integration(hass, mock_config_entry_current) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry_current.entry_id + ) diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index ba4e2f039a3..f1465c4dcd4 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -1,323 +1,168 @@ """Test the IntelliFire config flow.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock -from intellifire4py.exceptions import LoginException +from intellifire4py.exceptions import LoginError from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.intellifire.config_flow import MANUAL_ENTRY_STRING -from homeassistant.components.intellifire.const import CONF_USER_ID, DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.intellifire.const import CONF_SERIAL, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import mock_api_connection_error - from tests.common import MockConfigEntry -@patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", - login=AsyncMock(), - get_user_id=MagicMock(return_value="intellifire"), - get_fireplace_api_key=MagicMock(return_value="key"), -) -async def test_no_discovery( +async def test_standard_config_with_single_fireplace( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_intellifire_config_flow: MagicMock, + mock_apis_single_fp, ) -> None: - """Test we should get the manual discovery form - because no discovered fireplaces.""" - with patch( - "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace", - return_value=[], - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM + """Test standard flow with a user who has only a single fireplace.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} - assert result["step_id"] == "manual_device_entry" + assert result["step_id"] == "cloud_api" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - }, + {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "api_config" - - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: "test", CONF_PASSWORD: "AROONIE"}, - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Fireplace 12345" - assert result3["data"] == { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test", - CONF_PASSWORD: "AROONIE", - CONF_API_KEY: "key", - CONF_USER_ID: "intellifire", + # For a single fireplace we just create it + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "ip_address": "192.168.2.108", + "api_key": "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2", + "serial": "3FB284769E4736F30C8973A7ED358123", + "auth_cookie": "B984F21A6378560019F8A1CDE41B6782", + "web_client_id": "FA2B1C3045601234D0AE17D72F8E975", + "user_id": "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + "username": "grumpypanda@china.cn", + "password": "you-stole-my-pandas", } - assert len(mock_setup_entry.mock_calls) == 1 -@patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", - login=AsyncMock(side_effect=mock_api_connection_error()), - get_user_id=MagicMock(return_value="intellifire"), - get_fireplace_api_key=MagicMock(return_value="key"), -) -async def test_single_discovery( +async def test_standard_config_with_pre_configured_fireplace( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_intellifire_config_flow: MagicMock, + mock_config_entry_current, + mock_apis_single_fp, ) -> None: - """Test single fireplace UDP discovery.""" - with patch( - "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace", - return_value=["192.168.1.69"], - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) + """What if we try to configure an already configured fireplace.""" + # Configure an existing entry + mock_config_entry_current.add_to_hass(hass) - await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "192.168.1.69"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} ) - await hass.async_block_till_done() - result3 = await hass.config_entries.flow.async_configure( + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "cloud_api" + + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test", CONF_PASSWORD: "AROONIE"}, + {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, ) - await hass.async_block_till_done() - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": "iftapi_connect"} + + # For a single fireplace we just create it + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_available_devices" -@patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", - login=AsyncMock(side_effect=LoginException), - get_user_id=MagicMock(return_value="intellifire"), - get_fireplace_api_key=MagicMock(return_value="key"), -) -async def test_single_discovery_loign_error( +async def test_standard_config_with_single_fireplace_and_bad_credentials( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_intellifire_config_flow: MagicMock, + mock_apis_single_fp, ) -> None: - """Test single fireplace UDP discovery.""" - with patch( - "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace", - return_value=["192.168.1.69"], - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: "192.168.1.69"} - ) - await hass.async_block_till_done() - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_USERNAME: "test", CONF_PASSWORD: "AROONIE"}, - ) - await hass.async_block_till_done() - assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == {"base": "api_error"} - - -async def test_manual_entry( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_intellifire_config_flow: MagicMock, -) -> None: - """Test for multiple Fireplace discovery - involving a pick_device step.""" - with patch( - "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace", - return_value=["192.168.1.69", "192.168.1.33", "192.168.169"], - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["step_id"] == "pick_device" - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: MANUAL_ENTRY_STRING} - ) - - await hass.async_block_till_done() - assert result2["step_id"] == "manual_device_entry" - - -async def test_multi_discovery( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_intellifire_config_flow: MagicMock, -) -> None: - """Test for multiple fireplace discovery - involving a pick_device step.""" - with patch( - "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace", - return_value=["192.168.1.69", "192.168.1.33", "192.168.169"], - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["step_id"] == "pick_device" - await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: "192.168.1.33"} - ) - await hass.async_block_till_done() - assert result["step_id"] == "pick_device" - - -async def test_multi_discovery_cannot_connect( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_intellifire_config_flow: MagicMock, -) -> None: - """Test for multiple fireplace discovery - involving a pick_device step.""" - with patch( - "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace", - return_value=["192.168.1.69", "192.168.1.33", "192.168.169"], - ): - mock_intellifire_config_flow.poll.side_effect = ConnectionError - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pick_device" - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: "192.168.1.33"} - ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_cannot_connect_manual_entry( - hass: HomeAssistant, - mock_intellifire_config_flow: MagicMock, - mock_fireplace_finder_single: AsyncMock, -) -> None: - """Test we handle cannot connect error.""" - mock_intellifire_config_flow.poll.side_effect = ConnectionError + """Test bad credentials on a login.""" + mock_local_interface, mock_cloud_interface, mock_fp = mock_apis_single_fp + # Set login error + mock_cloud_interface.login_with_credentials.side_effect = LoginError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "manual_device_entry" + assert result["errors"] == {} + assert result["step_id"] == "cloud_api" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_HOST: "1.1.1.1", - }, + {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + # Erase the error + mock_cloud_interface.login_with_credentials.side_effect = None + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "api_error"} + assert result["step_id"] == "cloud_api" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, + ) + # For a single fireplace we just create it + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "ip_address": "192.168.2.108", + "api_key": "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2", + "serial": "3FB284769E4736F30C8973A7ED358123", + "auth_cookie": "B984F21A6378560019F8A1CDE41B6782", + "web_client_id": "FA2B1C3045601234D0AE17D72F8E975", + "user_id": "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + "username": "grumpypanda@china.cn", + "password": "you-stole-my-pandas", + } -async def test_picker_already_discovered( +async def test_standard_config_with_multiple_fireplace( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_intellifire_config_flow: MagicMock, + mock_apis_multifp, ) -> None: - """Test single fireplace UDP discovery.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "192.168.1.3", - }, - title="Fireplace", - unique_id=44444, - ) - entry.add_to_hass(hass) - with patch( - "homeassistant.components.intellifire.config_flow.AsyncUDPFireplaceFinder.search_fireplace", - return_value=["192.168.1.3"], - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOST: "192.168.1.4", - }, - ) - assert result2["type"] is FlowResultType.FORM - assert len(mock_setup_entry.mock_calls) == 0 - - -@patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", - login=AsyncMock(), - get_user_id=MagicMock(return_value="intellifire"), - get_fireplace_api_key=MagicMock(return_value="key"), -) -async def test_reauth_flow( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - mock_intellifire_config_flow: MagicMock, -) -> None: - """Test the reauth flow.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "host": "192.168.1.3", - }, - title="Fireplace 1234", - version=1, - unique_id="4444", - ) - entry.add_to_hass(hass) - + """Test multi-fireplace user who must be very rich.""" result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": "reauth", - "unique_id": entry.unique_id, - "entry_id": entry.entry_id, - }, + DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "cloud_api" - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "api_config" - - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_USERNAME: "test", CONF_PASSWORD: "AROONIE"}, + {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, ) - await hass.async_block_till_done() - assert result3["type"] is FlowResultType.ABORT - assert entry.data[CONF_PASSWORD] == "AROONIE" - assert entry.data[CONF_USERNAME] == "test" + # When we have multiple fireplaces we get to pick a serial + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pick_cloud_device" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_SERIAL: "4GC295860E5837G40D9974B7FD459234"}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "ip_address": "192.168.2.109", + "api_key": "D4C5EB28BBFF41E1FB21AFF9BFA6CD34", + "serial": "4GC295860E5837G40D9974B7FD459234", + "auth_cookie": "B984F21A6378560019F8A1CDE41B6782", + "web_client_id": "FA2B1C3045601234D0AE17D72F8E975", + "user_id": "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + "username": "grumpypanda@china.cn", + "password": "you-stole-my-pandas", + } async def test_dhcp_discovery_intellifire_device( hass: HomeAssistant, mock_setup_entry: AsyncMock, - mock_intellifire_config_flow: MagicMock, + mock_apis_multifp, ) -> None: """Test successful DHCP Discovery.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -327,26 +172,26 @@ async def test_dhcp_discovery_intellifire_device( hostname="zentrios-Test", ), ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "dhcp_confirm" - result2 = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "dhcp_confirm" - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], user_input={} + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "cloud_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, ) - assert result3["title"] == "Fireplace 12345" - assert result3["data"] == {"host": "1.1.1.1"} + assert result["type"] == FlowResultType.CREATE_ENTRY async def test_dhcp_discovery_non_intellifire_device( hass: HomeAssistant, - mock_intellifire_config_flow: MagicMock, mock_setup_entry: AsyncMock, + mock_apis_multifp, ) -> None: - """Test failed DHCP Discovery.""" + """Test successful DHCP Discovery of a non intellifire device..""" - mock_intellifire_config_flow.poll.side_effect = ConnectionError + # Patch poll with an exception + mock_local_interface, mock_cloud_interface, mock_fp = mock_apis_multifp + mock_local_interface.poll.side_effect = ConnectionError result = await hass.config_entries.flow.async_init( DOMAIN, @@ -357,6 +202,28 @@ async def test_dhcp_discovery_non_intellifire_device( hostname="zentrios-Evil", ), ) - - assert result["type"] is FlowResultType.ABORT + assert result["type"] == FlowResultType.ABORT assert result["reason"] == "not_intellifire_device" + # Test is finished - the DHCP scanner detected a hostname that "might" be an IntelliFire device, but it was not. + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry_current: MockConfigEntry, + mock_apis_single_fp, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth.""" + + mock_config_entry_current.add_to_hass(hass) + result = await mock_config_entry_current.start_reauth_flow(hass) + assert result["type"] == FlowResultType.FORM + result["step_id"] = "cloud_api" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "donJulio", CONF_PASSWORD: "Tequila0FD00m"}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/intellifire/test_init.py b/tests/components/intellifire/test_init.py new file mode 100644 index 00000000000..6d08fda26c3 --- /dev/null +++ b/tests/components/intellifire/test_init.py @@ -0,0 +1,111 @@ +"""Test the IntelliFire config flow.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.components.intellifire import CONF_USER_ID +from homeassistant.components.intellifire.const import ( + API_MODE_CLOUD, + API_MODE_LOCAL, + CONF_AUTH_COOKIE, + CONF_CONTROL_MODE, + CONF_READ_MODE, + CONF_SERIAL, + CONF_WEB_CLIENT_ID, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_minor_migration( + hass: HomeAssistant, mock_config_entry_old, mock_apis_single_fp +) -> None: + """With the new library we are going to end up rewriting the config entries.""" + mock_config_entry_old.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_old.entry_id) + + assert mock_config_entry_old.data == { + "ip_address": "192.168.2.108", + "host": "192.168.2.108", + "api_key": "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2", + "serial": "3FB284769E4736F30C8973A7ED358123", + "auth_cookie": "B984F21A6378560019F8A1CDE41B6782", + "web_client_id": "FA2B1C3045601234D0AE17D72F8E975", + "user_id": "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + "username": "grumpypanda@china.cn", + "password": "you-stole-my-pandas", + } + + +async def test_minor_migration_error(hass: HomeAssistant, mock_apis_single_fp) -> None: + """Test the case where we completely fail to initialize.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=1, + title="Fireplace of testing", + data={ + CONF_HOST: "11.168.2.218", + CONF_USERNAME: "grumpypanda@china.cn", + CONF_PASSWORD: "you-stole-my-pandas", + CONF_USER_ID: "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + }, + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.MIGRATION_ERROR + + +async def test_init_with_no_username(hass: HomeAssistant, mock_apis_single_fp) -> None: + """Test the case where we completely fail to initialize.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + minor_version=2, + data={ + CONF_IP_ADDRESS: "192.168.2.108", + CONF_PASSWORD: "you-stole-my-pandas", + CONF_SERIAL: "3FB284769E4736F30C8973A7ED358123", + CONF_WEB_CLIENT_ID: "FA2B1C3045601234D0AE17D72F8E975", + CONF_API_KEY: "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2", + CONF_AUTH_COOKIE: "B984F21A6378560019F8A1CDE41B6782", + CONF_USER_ID: "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456", + }, + options={CONF_READ_MODE: API_MODE_LOCAL, CONF_CONTROL_MODE: API_MODE_CLOUD}, + unique_id="3FB284769E4736F30C8973A7ED358123", + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_connectivity_bad( + hass: HomeAssistant, + mock_config_entry_current, + mock_apis_single_fp, +) -> None: + """Test a timeout error on the setup flow.""" + + with patch( + "homeassistant.components.intellifire.UnifiedFireplace.build_fireplace_from_common", + new_callable=AsyncMock, + side_effect=TimeoutError, + ): + mock_config_entry_current.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_current.entry_id) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/intellifire/test_sensor.py b/tests/components/intellifire/test_sensor.py new file mode 100644 index 00000000000..96e344d77fc --- /dev/null +++ b/tests/components/intellifire/test_sensor.py @@ -0,0 +1,35 @@ +"""Test IntelliFire Binary Sensors.""" + +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@freeze_time("2021-01-01T12:00:00Z") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_sensor_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry_current: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_apis_single_fp: tuple[AsyncMock, AsyncMock, AsyncMock], +) -> None: + """Test all entities.""" + + with ( + patch("homeassistant.components.intellifire.PLATFORMS", [Platform.SENSOR]), + ): + await setup_integration(hass, mock_config_entry_current) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry_current.entry_id + ) From 2f7a39677806302e3427535aac740b26df783e27 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 1 Sep 2024 13:28:08 +0200 Subject: [PATCH 0114/1309] Split opentherm_gw entities between different devices (#124869) * * Add migration from single device to multiple devices, removing all old entities * Create new devices for Boiler and Thermostat * Add classes for new entities based on the new devices * Split binary_sensor entities into devices * Split sensor entities into different devices * Move climate entity to thermostat device * Fix climate entity away mode * Fix translation placeholders * Allow sensor values with capital letters * * Add EntityCategory * Update and add device_classes * Fix translation keys * Fix climate entity category * Update tests * Handle `available` property in `entity.py` * Improve GPIO state binary_sensor translations * Fix: Updates are already subscribed to in the base entity * Remove entity_id generation from sensor and binary_sensor entities * * Use _attr_name on climate class instead of through entity_description * Add type hints * Rewrite to derive entities for all OpenTherm devices from a single base class * Improve type annotations * Use OpenThermDataSource to access status dict * Move entity_category from entity_description to _attr_entity_category * Move entity descriptions with the same translation_key closer together * Update tests * Add device migration test * * Add missing sensors and binary_sensors back * Improve migration, do not delete old entities from registry * Add comments for migration period * Use single lists for entity descriptions * Avoid changing sensor values, remove translations * * Import only required class from pyotgw * Update tests --- .../components/opentherm_gw/__init__.py | 83 +- .../components/opentherm_gw/binary_sensor.py | 583 +++++--- .../components/opentherm_gw/climate.py | 213 +-- .../components/opentherm_gw/config_flow.py | 3 +- .../components/opentherm_gw/const.py | 43 + .../components/opentherm_gw/entity.py | 38 +- .../components/opentherm_gw/sensor.py | 1317 ++++++++++------- .../components/opentherm_gw/strings.json | 295 ++++ tests/components/opentherm_gw/test_init.py | 82 +- 9 files changed, 1702 insertions(+), 955 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 30410f73c2d..f0f5c709d0c 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -4,7 +4,7 @@ import asyncio from datetime import date, datetime import logging -import pyotgw +from pyotgw import OpenThermGateway import pyotgw.vars as gw_vars from serial import SerialException import voluptuous as vol @@ -59,6 +59,8 @@ from .const import ( SERVICE_SET_MAX_MOD, SERVICE_SET_OAT, SERVICE_SET_SB_TEMP, + OpenThermDataSource, + OpenThermDeviceIdentifier, ) _LOGGER = logging.getLogger(__name__) @@ -113,6 +115,23 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b del migrate_options[CONF_PRECISION] hass.config_entries.async_update_entry(config_entry, options=migrate_options) + # Migration can be removed in 2025.4.0 + dev_reg = dr.async_get(hass) + if ( + migrate_device := dev_reg.async_get_device( + {(DOMAIN, config_entry.data[CONF_ID])} + ) + ) is not None: + dev_reg.async_update_device( + migrate_device.id, + new_identifiers={ + ( + DOMAIN, + f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}", + ) + }, + ) + config_entry.add_update_listener(options_updated) try: @@ -427,10 +446,9 @@ class OpenThermGatewayHub: self.name = config_entry.data[CONF_NAME] self.climate_config = config_entry.options self.config_entry_id = config_entry.entry_id - self.status = gw_vars.DEFAULT_STATUS self.update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_update" self.options_update_signal = f"{DATA_OPENTHERM_GW}_{self.hub_id}_options_update" - self.gateway = pyotgw.OpenThermGateway() + self.gateway = OpenThermGateway() self.gw_version = None async def cleanup(self, event=None) -> None: @@ -441,11 +459,11 @@ class OpenThermGatewayHub: async def connect_and_subscribe(self) -> None: """Connect to serial device and subscribe report handler.""" - self.status = await self.gateway.connect(self.device_path) - if not self.status: + status = await self.gateway.connect(self.device_path) + if not status: await self.cleanup() raise ConnectionError - version_string = self.status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT) + version_string = status[OpenThermDataSource.GATEWAY].get(gw_vars.OTGW_ABOUT) self.gw_version = version_string[18:] if version_string else None _LOGGER.debug( "Connected to OpenTherm Gateway %s at %s", self.gw_version, self.device_path @@ -453,22 +471,69 @@ class OpenThermGatewayHub: dev_reg = dr.async_get(self.hass) gw_dev = dev_reg.async_get_or_create( config_entry_id=self.config_entry_id, - identifiers={(DOMAIN, self.hub_id)}, - name=self.name, + identifiers={ + (DOMAIN, f"{self.hub_id}-{OpenThermDeviceIdentifier.GATEWAY}") + }, manufacturer="Schelte Bron", model="OpenTherm Gateway", + translation_key="gateway_device", sw_version=self.gw_version, ) if gw_dev.sw_version != self.gw_version: dev_reg.async_update_device(gw_dev.id, sw_version=self.gw_version) + + boiler_device = dev_reg.async_get_or_create( + config_entry_id=self.config_entry_id, + identifiers={(DOMAIN, f"{self.hub_id}-{OpenThermDeviceIdentifier.BOILER}")}, + translation_key="boiler_device", + ) + thermostat_device = dev_reg.async_get_or_create( + config_entry_id=self.config_entry_id, + identifiers={ + (DOMAIN, f"{self.hub_id}-{OpenThermDeviceIdentifier.THERMOSTAT}") + }, + translation_key="thermostat_device", + ) + self.hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.cleanup) async def handle_report(status): """Handle reports from the OpenTherm Gateway.""" _LOGGER.debug("Received report: %s", status) - self.status = status async_dispatcher_send(self.hass, self.update_signal, status) + dev_reg.async_update_device( + boiler_device.id, + manufacturer=status[OpenThermDataSource.BOILER].get( + gw_vars.DATA_SLAVE_MEMBERID + ), + model_id=status[OpenThermDataSource.BOILER].get( + gw_vars.DATA_SLAVE_PRODUCT_TYPE + ), + hw_version=status[OpenThermDataSource.BOILER].get( + gw_vars.DATA_SLAVE_PRODUCT_VERSION + ), + sw_version=status[OpenThermDataSource.BOILER].get( + gw_vars.DATA_SLAVE_OT_VERSION + ), + ) + + dev_reg.async_update_device( + thermostat_device.id, + manufacturer=status[OpenThermDataSource.THERMOSTAT].get( + gw_vars.DATA_MASTER_MEMBERID + ), + model_id=status[OpenThermDataSource.THERMOSTAT].get( + gw_vars.DATA_MASTER_PRODUCT_TYPE + ), + hw_version=status[OpenThermDataSource.THERMOSTAT].get( + gw_vars.DATA_MASTER_PRODUCT_VERSION + ), + sw_version=status[OpenThermDataSource.THERMOSTAT].get( + gw_vars.DATA_MASTER_OT_VERSION + ), + ) + self.gateway.subscribe(handle_report) @property diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index f978a2695d7..00885a18088 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -5,281 +5,387 @@ from dataclasses import dataclass from pyotgw import vars as gw_vars from homeassistant.components.binary_sensor import ( - ENTITY_ID_FORMAT, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ID +from homeassistant.const import CONF_ID, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OpenThermGatewayHub -from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW +from .const import ( + BOILER_DEVICE_DESCRIPTION, + DATA_GATEWAYS, + DATA_OPENTHERM_GW, + GATEWAY_DEVICE_DESCRIPTION, + THERMOSTAT_DEVICE_DESCRIPTION, + OpenThermDataSource, +) from .entity import OpenThermEntity, OpenThermEntityDescription @dataclass(frozen=True, kw_only=True) class OpenThermBinarySensorEntityDescription( - BinarySensorEntityDescription, OpenThermEntityDescription + OpenThermEntityDescription, BinarySensorEntityDescription ): """Describes opentherm_gw binary sensor entity.""" -BINARY_SENSOR_INFO: tuple[ - tuple[list[str], OpenThermBinarySensorEntityDescription], ... -] = ( - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_MASTER_CH_ENABLED, - friendly_name_format="Thermostat Central Heating {}", - ), +BINARY_SENSOR_DESCRIPTIONS: tuple[OpenThermBinarySensorEntityDescription, ...] = ( + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_FAULT_IND, + translation_key="fault_indication", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_MASTER_DHW_ENABLED, - friendly_name_format="Thermostat Hot Water {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_CH_ACTIVE, + translation_key="central_heating_n", + translation_placeholders={"circuit_number": "1"}, + device_class=BinarySensorDeviceClass.RUNNING, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_MASTER_COOLING_ENABLED, - friendly_name_format="Thermostat Cooling {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_CH2_ACTIVE, + translation_key="central_heating_n", + translation_placeholders={"circuit_number": "2"}, + device_class=BinarySensorDeviceClass.RUNNING, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_MASTER_OTC_ENABLED, - friendly_name_format="Thermostat Outside Temperature Correction {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_DHW_ACTIVE, + translation_key="hot_water", + device_class=BinarySensorDeviceClass.RUNNING, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_MASTER_CH2_ENABLED, - friendly_name_format="Thermostat Central Heating 2 {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_FLAME_ON, + translation_key="flame", + device_class=BinarySensorDeviceClass.HEAT, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_FAULT_IND, - friendly_name_format="Boiler Fault {}", - device_class=BinarySensorDeviceClass.PROBLEM, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_COOLING_ACTIVE, + translation_key="cooling", + device_class=BinarySensorDeviceClass.RUNNING, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_CH_ACTIVE, - friendly_name_format="Boiler Central Heating {}", - device_class=BinarySensorDeviceClass.HEAT, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_DIAG_IND, + translation_key="diagnostic_indication", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_DHW_ACTIVE, - friendly_name_format="Boiler Hot Water {}", - device_class=BinarySensorDeviceClass.HEAT, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_DHW_PRESENT, + translation_key="supports_hot_water", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_FLAME_ON, - friendly_name_format="Boiler Flame {}", - device_class=BinarySensorDeviceClass.HEAT, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_CONTROL_TYPE, + translation_key="control_type", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_COOLING_ACTIVE, - friendly_name_format="Boiler Cooling {}", - device_class=BinarySensorDeviceClass.COLD, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_COOLING_SUPPORTED, + translation_key="supports_cooling", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_CH2_ACTIVE, - friendly_name_format="Boiler Central Heating 2 {}", - device_class=BinarySensorDeviceClass.HEAT, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_DHW_CONFIG, + translation_key="hot_water_config", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_DIAG_IND, - friendly_name_format="Boiler Diagnostics {}", - device_class=BinarySensorDeviceClass.PROBLEM, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP, + translation_key="supports_pump_control", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_DHW_PRESENT, - friendly_name_format="Boiler Hot Water Present {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_CH2_PRESENT, + translation_key="supports_ch_2", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_CONTROL_TYPE, - friendly_name_format="Boiler Control Type {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_SERVICE_REQ, + translation_key="service_required", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_COOLING_SUPPORTED, - friendly_name_format="Boiler Cooling Support {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_REMOTE_RESET, + translation_key="supports_remote_reset", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_DHW_CONFIG, - friendly_name_format="Boiler Hot Water Configuration {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_LOW_WATER_PRESS, + translation_key="low_water_pressure", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP, - friendly_name_format="Boiler Pump Commands Support {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_GAS_FAULT, + translation_key="gas_fault", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_CH2_PRESENT, - friendly_name_format="Boiler Central Heating 2 Present {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_AIR_PRESS_FAULT, + translation_key="air_pressure_fault", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_SERVICE_REQ, - friendly_name_format="Boiler Service Required {}", - device_class=BinarySensorDeviceClass.PROBLEM, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_WATER_OVERTEMP, + translation_key="water_overtemperature", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_REMOTE_RESET, - friendly_name_format="Boiler Remote Reset Support {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_REMOTE_TRANSFER_MAX_CH, + translation_key="supports_central_heating_setpoint_transfer", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_LOW_WATER_PRESS, - friendly_name_format="Boiler Low Water Pressure {}", - device_class=BinarySensorDeviceClass.PROBLEM, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_REMOTE_RW_MAX_CH, + translation_key="supports_central_heating_setpoint_writing", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_GAS_FAULT, - friendly_name_format="Boiler Gas Fault {}", - device_class=BinarySensorDeviceClass.PROBLEM, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_REMOTE_TRANSFER_DHW, + translation_key="supports_hot_water_setpoint_transfer", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_AIR_PRESS_FAULT, - friendly_name_format="Boiler Air Pressure Fault {}", - device_class=BinarySensorDeviceClass.PROBLEM, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_REMOTE_RW_DHW, + translation_key="supports_hot_water_setpoint_writing", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_SLAVE_WATER_OVERTEMP, - friendly_name_format="Boiler Water Overtemperature {}", - device_class=BinarySensorDeviceClass.PROBLEM, - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.OTGW_GPIO_A_STATE, + translation_key="gpio_state_n", + translation_placeholders={"gpio_id": "A"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_REMOTE_TRANSFER_DHW, - friendly_name_format="Remote Hot Water Setpoint Transfer Support {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.OTGW_GPIO_B_STATE, + translation_key="gpio_state_n", + translation_placeholders={"gpio_id": "B"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_REMOTE_TRANSFER_MAX_CH, - friendly_name_format="Remote Maximum Central Heating Setpoint Write Support {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.OTGW_IGNORE_TRANSITIONS, + translation_key="ignore_transitions", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_REMOTE_RW_DHW, - friendly_name_format="Remote Hot Water Setpoint Write Support {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.OTGW_OVRD_HB, + translation_key="override_high_byte", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_REMOTE_RW_MAX_CH, - friendly_name_format="Remote Central Heating Setpoint Write Support {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_MASTER_CH_ENABLED, + translation_key="central_heating_n", + translation_placeholders={"circuit_number": "1"}, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_ROVRD_MAN_PRIO, - friendly_name_format="Remote Override Manual Change Priority {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_MASTER_CH2_ENABLED, + translation_key="central_heating_n", + translation_placeholders={"circuit_number": "2"}, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermBinarySensorEntityDescription( - key=gw_vars.DATA_ROVRD_AUTO_PRIO, - friendly_name_format="Remote Override Program Change Priority {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_MASTER_DHW_ENABLED, + translation_key="hot_water", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermBinarySensorEntityDescription( - key=gw_vars.OTGW_GPIO_A_STATE, - friendly_name_format="Gateway GPIO A {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_MASTER_COOLING_ENABLED, + translation_key="cooling", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermBinarySensorEntityDescription( - key=gw_vars.OTGW_GPIO_B_STATE, - friendly_name_format="Gateway GPIO B {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_MASTER_OTC_ENABLED, + translation_key="outside_temp_correction", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermBinarySensorEntityDescription( - key=gw_vars.OTGW_IGNORE_TRANSITIONS, - friendly_name_format="Gateway Ignore Transitions {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_ROVRD_MAN_PRIO, + translation_key="override_manual_change_prio", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermBinarySensorEntityDescription( - key=gw_vars.OTGW_OVRD_HB, - friendly_name_format="Gateway Override High Byte {}", - ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_ROVRD_AUTO_PRIO, + translation_key="override_program_change_prio", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_FAULT_IND, + translation_key="fault_indication", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_CH_ACTIVE, + translation_key="central_heating_n", + translation_placeholders={"circuit_number": "1"}, + device_class=BinarySensorDeviceClass.RUNNING, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_CH2_ACTIVE, + translation_key="central_heating_n", + translation_placeholders={"circuit_number": "2"}, + device_class=BinarySensorDeviceClass.RUNNING, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_DHW_ACTIVE, + translation_key="hot_water", + device_class=BinarySensorDeviceClass.RUNNING, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_FLAME_ON, + translation_key="flame", + device_class=BinarySensorDeviceClass.HEAT, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_COOLING_ACTIVE, + translation_key="cooling", + device_class=BinarySensorDeviceClass.RUNNING, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_DIAG_IND, + translation_key="diagnostic_indication", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_DHW_PRESENT, + translation_key="supports_hot_water", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_CONTROL_TYPE, + translation_key="control_type", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_COOLING_SUPPORTED, + translation_key="supports_cooling", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_DHW_CONFIG, + translation_key="hot_water_config", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP, + translation_key="supports_pump_control", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_CH2_PRESENT, + translation_key="supports_ch_2", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_SERVICE_REQ, + translation_key="service_required", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_REMOTE_RESET, + translation_key="supports_remote_reset", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_LOW_WATER_PRESS, + translation_key="low_water_pressure", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_GAS_FAULT, + translation_key="gas_fault", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_AIR_PRESS_FAULT, + translation_key="air_pressure_fault", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_SLAVE_WATER_OVERTEMP, + translation_key="water_overtemperature", + device_class=BinarySensorDeviceClass.PROBLEM, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_REMOTE_TRANSFER_MAX_CH, + translation_key="supports_central_heating_setpoint_transfer", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_REMOTE_RW_MAX_CH, + translation_key="supports_central_heating_setpoint_writing", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_REMOTE_TRANSFER_DHW, + translation_key="supports_hot_water_setpoint_transfer", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_REMOTE_RW_DHW, + translation_key="supports_hot_water_setpoint_writing", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_MASTER_CH_ENABLED, + translation_key="central_heating_n", + translation_placeholders={"circuit_number": "1"}, + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_MASTER_CH2_ENABLED, + translation_key="central_heating_n", + translation_placeholders={"circuit_number": "2"}, + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_MASTER_DHW_ENABLED, + translation_key="hot_water", + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_MASTER_COOLING_ENABLED, + translation_key="cooling", + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_MASTER_OTC_ENABLED, + translation_key="outside_temp_correction", + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_ROVRD_MAN_PRIO, + translation_key="override_manual_change_prio", + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermBinarySensorEntityDescription( + key=gw_vars.DATA_ROVRD_AUTO_PRIO, + translation_key="override_program_change_prio", + device_description=BOILER_DEVICE_DESCRIPTION, ), ) @@ -293,35 +399,22 @@ async def async_setup_entry( gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] async_add_entities( - OpenThermBinarySensor(gw_hub, source, description) - for sources, description in BINARY_SENSOR_INFO - for source in sources + OpenThermBinarySensor(gw_hub, description) + for description in BINARY_SENSOR_DESCRIPTIONS ) class OpenThermBinarySensor(OpenThermEntity, BinarySensorEntity): """Represent an OpenTherm Gateway binary sensor.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC entity_description: OpenThermBinarySensorEntityDescription - def __init__( - self, - gw_hub: OpenThermGatewayHub, - source: str, - description: OpenThermBinarySensorEntityDescription, - ) -> None: - """Initialize the binary sensor.""" - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, - f"{description.key}_{source}_{gw_hub.hub_id}", - hass=gw_hub.hass, - ) - super().__init__(gw_hub, source, description) - @callback - def receive_report(self, status: dict[str, dict]) -> None: + def receive_report(self, status: dict[OpenThermDataSource, dict]) -> None: """Handle status updates from the component.""" - self._attr_available = self._gateway.connected - state = status[self._source].get(self.entity_description.key) + state = status[self.entity_description.device_description.data_source].get( + self.entity_description.key + ) self._attr_is_on = None if state is None else bool(state) self.async_write_ha_state() diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index bf295fb1fb7..795a508be12 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -2,50 +2,52 @@ from __future__ import annotations +from dataclasses import dataclass import logging +from types import MappingProxyType from typing import Any from pyotgw import vars as gw_vars from homeassistant.components.climate import ( - ENTITY_ID_FORMAT, PRESET_AWAY, PRESET_NONE, ClimateEntity, + ClimateEntityDescription, ClimateEntityFeature, HVACAction, HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_ID, - PRECISION_HALVES, - PRECISION_TENTHS, - PRECISION_WHOLE, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, CONF_ID, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN +from . import OpenThermGatewayHub from .const import ( - CONF_FLOOR_TEMP, CONF_READ_PRECISION, CONF_SET_PRECISION, CONF_TEMPORARY_OVRD_MODE, DATA_GATEWAYS, DATA_OPENTHERM_GW, + THERMOSTAT_DEVICE_DESCRIPTION, + OpenThermDataSource, ) +from .entity import OpenThermEntity, OpenThermEntityDescription _LOGGER = logging.getLogger(__name__) DEFAULT_FLOOR_TEMP = False +@dataclass(frozen=True, kw_only=True) +class OpenThermClimateEntityDescription( + ClimateEntityDescription, OpenThermEntityDescription +): + """Describes an opentherm_gw climate entity.""" + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -56,6 +58,10 @@ async def async_setup_entry( ents.append( OpenThermClimate( hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]], + OpenThermClimateEntityDescription( + key="thermostat_entity", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), config_entry.options, ) ) @@ -63,98 +69,82 @@ async def async_setup_entry( async_add_entities(ents) -class OpenThermClimate(ClimateEntity): +class OpenThermClimate(OpenThermEntity, ClimateEntity): """Representation of a climate device.""" - _attr_should_poll = False _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_available = False _attr_hvac_modes = [] + _attr_name = None _attr_preset_modes = [] _attr_min_temp = 1 _attr_max_temp = 30 - _hvac_mode = HVACMode.HEAT - _current_temperature: float | None = None - _new_target_temperature: float | None = None - _target_temperature: float | None = None + _attr_hvac_mode = HVACMode.HEAT _away_mode_a: int | None = None _away_mode_b: int | None = None _away_state_a = False _away_state_b = False - _current_operation: HVACAction | None = None _enable_turn_on_off_backwards_compatibility = False + _target_temperature: float | None = None + _new_target_temperature: float | None = None + entity_description: OpenThermClimateEntityDescription - def __init__(self, gw_hub, options): - """Initialize the device.""" - self._gateway = gw_hub - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, gw_hub.hub_id, hass=gw_hub.hass - ) - self.friendly_name = gw_hub.name - self._attr_name = self.friendly_name - self.floor_temp = options.get(CONF_FLOOR_TEMP, DEFAULT_FLOOR_TEMP) - self.temp_read_precision = options.get(CONF_READ_PRECISION) - self.temp_set_precision = options.get(CONF_SET_PRECISION) + def __init__( + self, + gw_hub: OpenThermGatewayHub, + description: OpenThermClimateEntityDescription, + options: MappingProxyType[str, Any], + ) -> None: + """Initialize the entity.""" + super().__init__(gw_hub, description) + if CONF_READ_PRECISION in options: + self._attr_precision = options[CONF_READ_PRECISION] + self._attr_target_temperature_step = options.get(CONF_SET_PRECISION) self.temporary_ovrd_mode = options.get(CONF_TEMPORARY_OVRD_MODE, True) - self._unsub_options = None - self._unsub_updates = None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, gw_hub.hub_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=gw_hub.name, - sw_version=gw_hub.gw_version, - ) self._attr_unique_id = gw_hub.hub_id @callback def update_options(self, entry): """Update climate entity options.""" - self.floor_temp = entry.options[CONF_FLOOR_TEMP] - self.temp_read_precision = entry.options[CONF_READ_PRECISION] - self.temp_set_precision = entry.options[CONF_SET_PRECISION] + self._attr_precision = entry.options[CONF_READ_PRECISION] + self._attr_target_temperature_step = entry.options[CONF_SET_PRECISION] self.temporary_ovrd_mode = entry.options[CONF_TEMPORARY_OVRD_MODE] self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Connect to the OpenTherm Gateway device.""" - _LOGGER.debug("Added OpenTherm Gateway climate device %s", self.friendly_name) - self._unsub_updates = async_dispatcher_connect( - self.hass, self._gateway.update_signal, self.receive_report + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect( + self.hass, self._gateway.options_update_signal, self.update_options + ) ) - self._unsub_options = async_dispatcher_connect( - self.hass, self._gateway.options_update_signal, self.update_options - ) - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe from updates from the component.""" - _LOGGER.debug("Removing OpenTherm Gateway climate %s", self.friendly_name) - self._unsub_options() - self._unsub_updates() @callback - def receive_report(self, status): + def receive_report(self, status: dict[OpenThermDataSource, dict]): """Receive and handle a new report from the Gateway.""" - self._attr_available = self._gateway.connected - ch_active = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_CH_ACTIVE) - flame_on = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_FLAME_ON) - cooling_active = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_COOLING_ACTIVE) + ch_active = status[OpenThermDataSource.BOILER].get(gw_vars.DATA_SLAVE_CH_ACTIVE) + flame_on = status[OpenThermDataSource.BOILER].get(gw_vars.DATA_SLAVE_FLAME_ON) + cooling_active = status[OpenThermDataSource.BOILER].get( + gw_vars.DATA_SLAVE_COOLING_ACTIVE + ) if ch_active and flame_on: - self._current_operation = HVACAction.HEATING - self._hvac_mode = HVACMode.HEAT + self._attr_hvac_action = HVACAction.HEATING + self._attr_hvac_mode = HVACMode.HEAT elif cooling_active: - self._current_operation = HVACAction.COOLING - self._hvac_mode = HVACMode.COOL + self._attr_hvac_action = HVACAction.COOLING + self._attr_hvac_mode = HVACMode.COOL else: - self._current_operation = HVACAction.IDLE + self._attr_hvac_action = HVACAction.IDLE - self._current_temperature = status[gw_vars.THERMOSTAT].get( + self._attr_current_temperature = status[OpenThermDataSource.THERMOSTAT].get( gw_vars.DATA_ROOM_TEMP ) - temp_upd = status[gw_vars.THERMOSTAT].get(gw_vars.DATA_ROOM_SETPOINT) + temp_upd = status[OpenThermDataSource.THERMOSTAT].get( + gw_vars.DATA_ROOM_SETPOINT + ) if self._target_temperature != temp_upd: self._new_target_temperature = None @@ -162,82 +152,35 @@ class OpenThermClimate(ClimateEntity): # GPIO mode 5: 0 == Away # GPIO mode 6: 1 == Away - gpio_a_state = status[gw_vars.OTGW].get(gw_vars.OTGW_GPIO_A) - if gpio_a_state == 5: - self._away_mode_a = 0 - elif gpio_a_state == 6: - self._away_mode_a = 1 - else: - self._away_mode_a = None - gpio_b_state = status[gw_vars.OTGW].get(gw_vars.OTGW_GPIO_B) - if gpio_b_state == 5: - self._away_mode_b = 0 - elif gpio_b_state == 6: - self._away_mode_b = 1 - else: - self._away_mode_b = None - if self._away_mode_a is not None: - self._away_state_a = ( - status[gw_vars.OTGW].get(gw_vars.OTGW_GPIO_A_STATE) == self._away_mode_a + gpio_a_state = status[OpenThermDataSource.GATEWAY].get(gw_vars.OTGW_GPIO_A) + gpio_b_state = status[OpenThermDataSource.GATEWAY].get(gw_vars.OTGW_GPIO_B) + self._away_mode_a = gpio_a_state - 5 if gpio_a_state in (5, 6) else None + self._away_mode_b = gpio_b_state - 5 if gpio_b_state in (5, 6) else None + self._away_state_a = ( + ( + status[OpenThermDataSource.GATEWAY].get(gw_vars.OTGW_GPIO_A_STATE) + == self._away_mode_a ) - if self._away_mode_b is not None: - self._away_state_b = ( - status[gw_vars.OTGW].get(gw_vars.OTGW_GPIO_B_STATE) == self._away_mode_b + if self._away_mode_a is not None + else False + ) + self._away_state_b = ( + ( + status[OpenThermDataSource.GATEWAY].get(gw_vars.OTGW_GPIO_B_STATE) + == self._away_mode_b ) + if self._away_mode_b is not None + else False + ) self.async_write_ha_state() @property - def precision(self): - """Return the precision of the system.""" - if self.temp_read_precision: - return self.temp_read_precision - if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS: - return PRECISION_HALVES - return PRECISION_WHOLE - - @property - def hvac_action(self) -> HVACAction | None: - """Return current HVAC operation.""" - return self._current_operation - - @property - def hvac_mode(self) -> HVACMode: - """Return current HVAC mode.""" - return self._hvac_mode - - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set the HVAC mode.""" - _LOGGER.warning("Changing HVAC mode is not supported") - - @property - def current_temperature(self): - """Return the current temperature.""" - if self._current_temperature is None: - return None - if self.floor_temp is True: - if self.precision == PRECISION_HALVES: - return int(2 * self._current_temperature) / 2 - if self.precision == PRECISION_TENTHS: - return int(10 * self._current_temperature) / 10 - return int(self._current_temperature) - return self._current_temperature - - @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._new_target_temperature or self._target_temperature @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - if self.temp_set_precision: - return self.temp_set_precision - if self.hass.config.units.temperature_unit == UnitOfTemperature.CELSIUS: - return PRECISION_HALVES - return PRECISION_WHOLE - - @property - def preset_mode(self): + def preset_mode(self) -> str: """Return current preset mode.""" if self._away_state_a or self._away_state_b: return PRESET_AWAY diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index a5ac116ac11..3cf8a1c4594 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -34,6 +34,7 @@ from .const import ( CONF_SET_PRECISION, CONF_TEMPORARY_OVRD_MODE, CONNECTION_TIMEOUT, + OpenThermDataSource, ) @@ -74,7 +75,7 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN): await otgw.disconnect() if not status: raise ConnectionError - return status[gw_vars.OTGW].get(gw_vars.OTGW_ABOUT) + return status[OpenThermDataSource.GATEWAY].get(gw_vars.OTGW_ABOUT) try: async with asyncio.timeout(CONNECTION_TIMEOUT): diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index c1932c7b2bd..c842ff568ae 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -1,5 +1,10 @@ """Constants for the opentherm_gw integration.""" +from dataclasses import dataclass +from enum import StrEnum + +from pyotgw import vars as gw_vars + ATTR_GW_ID = "gateway_id" ATTR_LEVEL = "level" ATTR_DHW_OVRD = "dhw_override" @@ -33,3 +38,41 @@ SERVICE_SET_MAX_MOD = "set_max_modulation" SERVICE_SET_OAT = "set_outside_temperature" SERVICE_SET_SB_TEMP = "set_setback_temperature" SERVICE_SEND_TRANSP_CMD = "send_transparent_command" + + +class OpenThermDataSource(StrEnum): + """List valid OpenTherm data sources.""" + + BOILER = gw_vars.BOILER + GATEWAY = gw_vars.OTGW + THERMOSTAT = gw_vars.THERMOSTAT + + +class OpenThermDeviceIdentifier(StrEnum): + """List valid OpenTherm device identifiers.""" + + BOILER = "boiler" + GATEWAY = "gateway" + THERMOSTAT = "thermostat" + + +@dataclass(frozen=True, kw_only=True) +class OpenThermDeviceDescription: + """Describe OpenTherm device properties.""" + + data_source: OpenThermDataSource + device_identifier: OpenThermDeviceIdentifier + + +BOILER_DEVICE_DESCRIPTION = OpenThermDeviceDescription( + data_source=OpenThermDataSource.BOILER, + device_identifier=OpenThermDeviceIdentifier.BOILER, +) +GATEWAY_DEVICE_DESCRIPTION = OpenThermDeviceDescription( + data_source=OpenThermDataSource.GATEWAY, + device_identifier=OpenThermDeviceIdentifier.GATEWAY, +) +THERMOSTAT_DEVICE_DESCRIPTION = OpenThermDeviceDescription( + data_source=OpenThermDataSource.THERMOSTAT, + device_identifier=OpenThermDeviceIdentifier.THERMOSTAT, +) diff --git a/homeassistant/components/opentherm_gw/entity.py b/homeassistant/components/opentherm_gw/entity.py index a1035b946c2..b7110fa9e1b 100644 --- a/homeassistant/components/opentherm_gw/entity.py +++ b/homeassistant/components/opentherm_gw/entity.py @@ -10,7 +10,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, EntityDescription from . import OpenThermGatewayHub -from .const import DOMAIN +from .const import DOMAIN, OpenThermDataSource, OpenThermDeviceDescription _LOGGER = logging.getLogger(__name__) @@ -24,53 +24,49 @@ TRANSLATE_SOURCE = { class OpenThermEntityDescription(EntityDescription): """Describe common opentherm_gw entity properties.""" - friendly_name_format: str + device_description: OpenThermDeviceDescription class OpenThermEntity(Entity): - """Represent an OpenTherm Gateway entity.""" + """Represent an OpenTherm entity.""" + _attr_has_entity_name = True _attr_should_poll = False - _attr_entity_registry_enabled_default = False - _attr_available = False entity_description: OpenThermEntityDescription def __init__( self, gw_hub: OpenThermGatewayHub, - source: str, description: OpenThermEntityDescription, ) -> None: """Initialize the entity.""" self.entity_description = description self._gateway = gw_hub - self._source = source - friendly_name_format = ( - f"{description.friendly_name_format} ({TRANSLATE_SOURCE[source]})" - if TRANSLATE_SOURCE[source] is not None - else description.friendly_name_format - ) - self._attr_name = friendly_name_format.format(gw_hub.name) - self._attr_unique_id = f"{gw_hub.hub_id}-{source}-{description.key}" + self._attr_unique_id = f"{gw_hub.hub_id}-{description.device_description.device_identifier}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, gw_hub.hub_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=gw_hub.name, - sw_version=gw_hub.gw_version, + identifiers={ + ( + DOMAIN, + f"{gw_hub.hub_id}-{description.device_description.device_identifier}", + ) + }, ) async def async_added_to_hass(self) -> None: """Subscribe to updates from the component.""" - _LOGGER.debug("Added OpenTherm Gateway entity %s", self._attr_name) self.async_on_remove( async_dispatcher_connect( self.hass, self._gateway.update_signal, self.receive_report ) ) + @property + def available(self) -> bool: + """Return connection status of the hub to indicate availability.""" + return self._gateway.connected + @callback - def receive_report(self, status: dict[str, dict]) -> None: + def receive_report(self, status: dict[OpenThermDataSource, dict]) -> None: """Handle status updates from the component.""" # Must be implemented at the platform level. raise NotImplementedError diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index fb30b2ce35c..eeadd5c4ee1 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -5,7 +5,6 @@ from dataclasses import dataclass import pyotgw.vars as gw_vars from homeassistant.components.sensor import ( - ENTITY_ID_FORMAT, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -15,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ID, PERCENTAGE, + EntityCategory, UnitOfPower, UnitOfPressure, UnitOfTemperature, @@ -22,11 +22,16 @@ from homeassistant.const import ( UnitOfVolumeFlowRate, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import OpenThermGatewayHub -from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW +from .const import ( + BOILER_DEVICE_DESCRIPTION, + DATA_GATEWAYS, + DATA_OPENTHERM_GW, + GATEWAY_DEVICE_DESCRIPTION, + THERMOSTAT_DEVICE_DESCRIPTION, + OpenThermDataSource, +) from .entity import OpenThermEntity, OpenThermEntityDescription SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION = 1 @@ -36,584 +41,833 @@ SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION = 1 class OpenThermSensorEntityDescription( SensorEntityDescription, OpenThermEntityDescription ): - """Describes opentherm_gw sensor entity.""" + """Describes an opentherm_gw sensor entity.""" -SENSOR_INFO: tuple[tuple[list[str], OpenThermSensorEntityDescription], ...] = ( - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_CONTROL_SETPOINT, - friendly_name_format="Control Setpoint {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), +SENSOR_DESCRIPTIONS: tuple[OpenThermSensorEntityDescription, ...] = ( + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CONTROL_SETPOINT, + translation_key="control_setpoint_n", + translation_placeholders={"circuit_number": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_MASTER_MEMBERID, - friendly_name_format="Thermostat Member ID {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CONTROL_SETPOINT_2, + translation_key="control_setpoint_n", + translation_placeholders={"circuit_number": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_MEMBERID, - friendly_name_format="Boiler Member ID {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_MEMBERID, + translation_key="manufacturer_id", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_OEM_FAULT, - friendly_name_format="Boiler OEM Fault Code {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_OEM_FAULT, + translation_key="oem_fault_code", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_COOLING_CONTROL, - friendly_name_format="Cooling Control Signal {}", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_COOLING_CONTROL, + translation_key="cooling_control", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_CONTROL_SETPOINT_2, - friendly_name_format="Control Setpoint 2 {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD, + translation_key="max_relative_mod_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_ROOM_SETPOINT_OVRD, - friendly_name_format="Room Setpoint Override {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_MAX_CAPACITY, + translation_key="max_capacity", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD, - friendly_name_format="Boiler Maximum Relative Modulation {}", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_MIN_MOD_LEVEL, + translation_key="min_mod_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_MAX_CAPACITY, - friendly_name_format="Boiler Maximum Capacity {}", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_REL_MOD_LEVEL, + translation_key="relative_mod_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_MIN_MOD_LEVEL, - friendly_name_format="Boiler Minimum Modulation Level {}", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CH_WATER_PRESS, + translation_key="central_heating_pressure", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_ROOM_SETPOINT, - friendly_name_format="Room Setpoint {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_FLOW_RATE, + translation_key="hot_water_flow_rate", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_REL_MOD_LEVEL, - friendly_name_format="Relative Modulation Level {}", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CH_WATER_TEMP, + translation_key="central_heating_temperature_n", + translation_placeholders={"circuit_number": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_CH_WATER_PRESS, - friendly_name_format="Central Heating Water Pressure {}", - device_class=SensorDeviceClass.PRESSURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPressure.BAR, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CH_WATER_TEMP_2, + translation_key="central_heating_temperature_n", + translation_placeholders={"circuit_number": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_DHW_FLOW_RATE, - friendly_name_format="Hot Water Flow Rate {}", - device_class=SensorDeviceClass.VOLUME_FLOW_RATE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_TEMP, + translation_key="hot_water_temperature_n", + translation_placeholders={"circuit_number": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_ROOM_SETPOINT_2, - friendly_name_format="Room Setpoint 2 {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_TEMP_2, + translation_key="hot_water_temperature_n", + translation_placeholders={"circuit_number": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_ROOM_TEMP, - friendly_name_format="Room Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_RETURN_WATER_TEMP, + translation_key="return_water_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_CH_WATER_TEMP, - friendly_name_format="Central Heating Water Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SOLAR_STORAGE_TEMP, + translation_key="solar_storage_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_DHW_TEMP, - friendly_name_format="Hot Water Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SOLAR_COLL_TEMP, + translation_key="solar_collector_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_OUTSIDE_TEMP, - friendly_name_format="Outside Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_EXHAUST_TEMP, + translation_key="exhaust_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_RETURN_WATER_TEMP, - friendly_name_format="Return Water Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_DHW_MAX_SETP, + translation_key="max_hot_water_setpoint_upper", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SOLAR_STORAGE_TEMP, - friendly_name_format="Solar Storage Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_DHW_MIN_SETP, + translation_key="max_hot_water_setpoint_lower", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SOLAR_COLL_TEMP, - friendly_name_format="Solar Collector Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_CH_MAX_SETP, + translation_key="max_central_heating_setpoint_upper", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_CH_WATER_TEMP_2, - friendly_name_format="Central Heating 2 Water Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_CH_MIN_SETP, + translation_key="max_central_heating_setpoint_lower", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_DHW_TEMP_2, - friendly_name_format="Hot Water 2 Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_SETPOINT, + translation_key="hot_water_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_EXHAUST_TEMP, - friendly_name_format="Exhaust Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_MAX_CH_SETPOINT, + translation_key="max_central_heating_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_DHW_MAX_SETP, - friendly_name_format="Hot Water Maximum Setpoint {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_OEM_DIAG, + translation_key="oem_diagnostic_code", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_DHW_MIN_SETP, - friendly_name_format="Hot Water Minimum Setpoint {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_TOTAL_BURNER_STARTS, + translation_key="total_burner_starts", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="starts", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_CH_MAX_SETP, - friendly_name_format="Boiler Maximum Central Heating Setpoint {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CH_PUMP_STARTS, + translation_key="central_heating_pump_starts", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="starts", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_CH_MIN_SETP, - friendly_name_format="Boiler Minimum Central Heating Setpoint {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_PUMP_STARTS, + translation_key="hot_water_pump_starts", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="starts", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_DHW_SETPOINT, - friendly_name_format="Hot Water Setpoint {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_BURNER_STARTS, + translation_key="hot_water_burner_starts", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="starts", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_MAX_CH_SETPOINT, - friendly_name_format="Maximum Central Heating Setpoint {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_TOTAL_BURNER_HOURS, + translation_key="total_burner_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfTime.HOURS, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_OEM_DIAG, - friendly_name_format="OEM Diagnostic Code {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CH_PUMP_HOURS, + translation_key="central_heating_pump_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfTime.HOURS, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_TOTAL_BURNER_STARTS, - friendly_name_format="Total Burner Starts {}", - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="starts", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_PUMP_HOURS, + translation_key="hot_water_pump_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfTime.HOURS, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_CH_PUMP_STARTS, - friendly_name_format="Central Heating Pump Starts {}", - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="starts", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_BURNER_HOURS, + translation_key="hot_water_burner_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfTime.HOURS, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_DHW_PUMP_STARTS, - friendly_name_format="Hot Water Pump Starts {}", - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="starts", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_OT_VERSION, + translation_key="opentherm_version", + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_DHW_BURNER_STARTS, - friendly_name_format="Hot Water Burner Starts {}", - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement="starts", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_PRODUCT_TYPE, + translation_key="product_type", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_TOTAL_BURNER_HOURS, - friendly_name_format="Total Burner Hours {}", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement=UnitOfTime.HOURS, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_PRODUCT_VERSION, + translation_key="product_version", + device_description=BOILER_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_CH_PUMP_HOURS, - friendly_name_format="Central Heating Pump Hours {}", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement=UnitOfTime.HOURS, - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_MODE, + translation_key="operating_mode", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_DHW_PUMP_HOURS, - friendly_name_format="Hot Water Pump Hours {}", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement=UnitOfTime.HOURS, - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_DHW_OVRD, + translation_key="hot_water_override_mode", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_DHW_BURNER_HOURS, - friendly_name_format="Hot Water Burner Hours {}", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.TOTAL, - native_unit_of_measurement=UnitOfTime.HOURS, - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_ABOUT, + translation_key="firmware_version", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_MASTER_OT_VERSION, - friendly_name_format="Thermostat OpenTherm Version {}", - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_BUILD, + translation_key="firmware_build", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_OT_VERSION, - friendly_name_format="Boiler OpenTherm Version {}", - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_CLOCKMHZ, + translation_key="clock_speed", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_MASTER_PRODUCT_TYPE, - friendly_name_format="Thermostat Product Type {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_LED_A, + translation_key="led_mode_n", + translation_placeholders={"led_id": "A"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_MASTER_PRODUCT_VERSION, - friendly_name_format="Thermostat Product Version {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_LED_B, + translation_key="led_mode_n", + translation_placeholders={"led_id": "B"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_PRODUCT_TYPE, - friendly_name_format="Boiler Product Type {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_LED_C, + translation_key="led_mode_n", + translation_placeholders={"led_id": "C"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.BOILER, gw_vars.THERMOSTAT], - OpenThermSensorEntityDescription( - key=gw_vars.DATA_SLAVE_PRODUCT_VERSION, - friendly_name_format="Boiler Product Version {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_LED_D, + translation_key="led_mode_n", + translation_placeholders={"led_id": "D"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_MODE, - friendly_name_format="Gateway/Monitor Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_LED_E, + translation_key="led_mode_n", + translation_placeholders={"led_id": "E"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_DHW_OVRD, - friendly_name_format="Gateway Hot Water Override Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_LED_F, + translation_key="led_mode_n", + translation_placeholders={"led_id": "F"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_ABOUT, - friendly_name_format="Gateway Firmware Version {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_GPIO_A, + translation_key="gpio_mode_n", + translation_placeholders={"gpio_id": "A"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_BUILD, - friendly_name_format="Gateway Firmware Build {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_GPIO_B, + translation_key="gpio_mode_n", + translation_placeholders={"gpio_id": "B"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_CLOCKMHZ, - friendly_name_format="Gateway Clock Speed {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_SB_TEMP, + translation_key="setback_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_LED_A, - friendly_name_format="Gateway LED A Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_SETP_OVRD_MODE, + translation_key="room_setpoint_override_mode", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_LED_B, - friendly_name_format="Gateway LED B Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_SMART_PWR, + translation_key="smart_power_mode", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_LED_C, - friendly_name_format="Gateway LED C Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_THRM_DETECT, + translation_key="thermostat_detection_mode", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_LED_D, - friendly_name_format="Gateway LED D Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.OTGW_VREF, + translation_key="reference_voltage", + device_description=GATEWAY_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_LED_E, - friendly_name_format="Gateway LED E Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_MASTER_MEMBERID, + translation_key="manufacturer_id", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_LED_F, - friendly_name_format="Gateway LED F Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_ROOM_SETPOINT_OVRD, + translation_key="room_setpoint_override", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_GPIO_A, - friendly_name_format="Gateway GPIO A Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_ROOM_SETPOINT, + translation_key="room_setpoint_n", + translation_placeholders={"setpoint_id": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_GPIO_B, - friendly_name_format="Gateway GPIO B Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_ROOM_SETPOINT_2, + translation_key="room_setpoint_n", + translation_placeholders={"setpoint_id": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_SB_TEMP, - friendly_name_format="Gateway Setback Temperature {}", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_ROOM_TEMP, + translation_key="room_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_SETP_OVRD_MODE, - friendly_name_format="Gateway Room Setpoint Override Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_OUTSIDE_TEMP, + translation_key="outside_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_SMART_PWR, - friendly_name_format="Gateway Smart Power Mode {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_MASTER_OT_VERSION, + translation_key="opentherm_version", + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_THRM_DETECT, - friendly_name_format="Gateway Thermostat Detection {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_MASTER_PRODUCT_TYPE, + translation_key="product_type", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, ), - ( - [gw_vars.OTGW], - OpenThermSensorEntityDescription( - key=gw_vars.OTGW_VREF, - friendly_name_format="Gateway Reference Voltage Setting {}", - ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_MASTER_PRODUCT_VERSION, + translation_key="product_version", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CONTROL_SETPOINT, + translation_key="control_setpoint_n", + translation_placeholders={"circuit_number": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CONTROL_SETPOINT_2, + translation_key="control_setpoint_n", + translation_placeholders={"circuit_number": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_MEMBERID, + translation_key="manufacturer_id", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_OEM_FAULT, + translation_key="oem_fault_code", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_COOLING_CONTROL, + translation_key="cooling_control", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD, + translation_key="max_relative_mod_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_MAX_CAPACITY, + translation_key="max_capacity", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_MIN_MOD_LEVEL, + translation_key="min_mod_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_REL_MOD_LEVEL, + translation_key="relative_mod_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CH_WATER_PRESS, + translation_key="central_heating_pressure", + device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_FLOW_RATE, + translation_key="hot_water_flow_rate", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CH_WATER_TEMP, + translation_key="central_heating_temperature_n", + translation_placeholders={"circuit_number": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CH_WATER_TEMP_2, + translation_key="central_heating_temperature_n", + translation_placeholders={"circuit_number": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_TEMP, + translation_key="hot_water_temperature_n", + translation_placeholders={"circuit_number": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_TEMP_2, + translation_key="hot_water_temperature_n", + translation_placeholders={"circuit_number": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_RETURN_WATER_TEMP, + translation_key="return_water_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SOLAR_STORAGE_TEMP, + translation_key="solar_storage_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SOLAR_COLL_TEMP, + translation_key="solar_collector_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_EXHAUST_TEMP, + translation_key="exhaust_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_DHW_MAX_SETP, + translation_key="max_hot_water_setpoint_upper", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_DHW_MIN_SETP, + translation_key="max_hot_water_setpoint_lower", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_CH_MAX_SETP, + translation_key="max_central_heating_setpoint_upper", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_CH_MIN_SETP, + translation_key="max_central_heating_setpoint_lower", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_SETPOINT, + translation_key="hot_water_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_MAX_CH_SETPOINT, + translation_key="max_central_heating_setpoint", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_OEM_DIAG, + translation_key="oem_diagnostic_code", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_TOTAL_BURNER_STARTS, + translation_key="total_burner_starts", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="starts", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CH_PUMP_STARTS, + translation_key="central_heating_pump_starts", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="starts", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_PUMP_STARTS, + translation_key="hot_water_pump_starts", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="starts", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_BURNER_STARTS, + translation_key="hot_water_burner_starts", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="starts", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_TOTAL_BURNER_HOURS, + translation_key="total_burner_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfTime.HOURS, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_CH_PUMP_HOURS, + translation_key="central_heating_pump_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfTime.HOURS, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_PUMP_HOURS, + translation_key="hot_water_pump_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfTime.HOURS, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_DHW_BURNER_HOURS, + translation_key="hot_water_burner_hours", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfTime.HOURS, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_OT_VERSION, + translation_key="opentherm_version", + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_PRODUCT_TYPE, + translation_key="product_type", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_SLAVE_PRODUCT_VERSION, + translation_key="product_version", + device_description=THERMOSTAT_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_MASTER_MEMBERID, + translation_key="manufacturer_id", + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_ROOM_SETPOINT_OVRD, + translation_key="room_setpoint_override", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_ROOM_SETPOINT, + translation_key="room_setpoint_n", + translation_placeholders={"setpoint_id": "1"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_ROOM_SETPOINT_2, + translation_key="room_setpoint_n", + translation_placeholders={"setpoint_id": "2"}, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_ROOM_TEMP, + translation_key="room_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_OUTSIDE_TEMP, + translation_key="outside_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_MASTER_OT_VERSION, + translation_key="opentherm_version", + suggested_display_precision=SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION, + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_MASTER_PRODUCT_TYPE, + translation_key="product_type", + device_description=BOILER_DEVICE_DESCRIPTION, + ), + OpenThermSensorEntityDescription( + key=gw_vars.DATA_MASTER_PRODUCT_VERSION, + translation_key="product_version", + device_description=BOILER_DEVICE_DESCRIPTION, ), ) @@ -629,37 +883,22 @@ async def async_setup_entry( async_add_entities( OpenThermSensor( gw_hub, - source, description, ) - for sources, description in SENSOR_INFO - for source in sources + for description in SENSOR_DESCRIPTIONS ) class OpenThermSensor(OpenThermEntity, SensorEntity): - """Representation of an OpenTherm Gateway sensor.""" + """Representation of an OpenTherm sensor.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC entity_description: OpenThermSensorEntityDescription - def __init__( - self, - gw_hub: OpenThermGatewayHub, - source: str, - description: OpenThermSensorEntityDescription, - ) -> None: - """Initialize the OpenTherm Gateway sensor.""" - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, - f"{description.key}_{source}_{gw_hub.hub_id}", - hass=gw_hub.hass, - ) - super().__init__(gw_hub, source, description) - @callback - def receive_report(self, status: dict[str, dict]) -> None: + def receive_report(self, status: dict[OpenThermDataSource, dict]) -> None: """Handle status updates from the component.""" - self._attr_available = self._gateway.connected - value = status[self._source].get(self.entity_description.key) - self._attr_native_value = value + self._attr_native_value = status[ + self.entity_description.device_description.data_source + ].get(self.entity_description.key) self.async_write_ha_state() diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 9eb97539df9..006ccd1909b 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -1,4 +1,8 @@ { + "common": { + "state_not_supported": "Not supported", + "state_supported": "Supported" + }, "config": { "step": { "init": { @@ -16,6 +20,297 @@ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" } }, + "device": { + "boiler_device": { + "name": "OpenTherm Boiler" + }, + "gateway_device": { + "name": "OpenTherm Gateway" + }, + "thermostat_device": { + "name": "OpenTherm Thermostat" + } + }, + "entity": { + "binary_sensor": { + "fault_indication": { + "name": "Fault indication" + }, + "central_heating_n": { + "name": "Central heating {circuit_number}" + }, + "cooling": { + "name": "Cooling" + }, + "flame": { + "name": "Flame" + }, + "hot_water": { + "name": "Hot water" + }, + "diagnostic_indication": { + "name": "Diagnostic indication" + }, + "supports_hot_water": { + "name": "Hot water support", + "state": { + "off": "[%key:component::opentherm_gw::common::state_not_supported%]", + "on": "[%key:component::opentherm_gw::common::state_supported%]" + } + }, + "control_type": { + "name": "Control type" + }, + "supports_cooling": { + "name": "Cooling support", + "state": { + "off": "[%key:component::opentherm_gw::common::state_not_supported%]", + "on": "[%key:component::opentherm_gw::common::state_supported%]" + } + }, + "hot_water_config": { + "name": "Hot water system type", + "state": { + "off": "Instantaneous or unspecified", + "on": "Storage tank" + } + }, + "supports_pump_control": { + "name": "Pump control support", + "state": { + "off": "[%key:component::opentherm_gw::common::state_not_supported%]", + "on": "[%key:component::opentherm_gw::common::state_supported%]" + } + }, + "supports_ch_2": { + "name": "Central heating 2 support", + "state": { + "off": "[%key:component::opentherm_gw::common::state_not_supported%]", + "on": "[%key:component::opentherm_gw::common::state_supported%]" + } + }, + "service_required": { + "name": "Service required" + }, + "supports_remote_reset": { + "name": "Remote reset support", + "state": { + "off": "[%key:component::opentherm_gw::common::state_not_supported%]", + "on": "[%key:component::opentherm_gw::common::state_supported%]" + } + }, + "low_water_pressure": { + "name": "Low water pressure" + }, + "gas_fault": { + "name": "Gas fault" + }, + "air_pressure_fault": { + "name": "Air pressure fault" + }, + "water_overtemperature": { + "name": "Water overtemperature" + }, + "supports_central_heating_setpoint_transfer": { + "name": "Central heating setpoint transfer support", + "state": { + "off": "[%key:component::opentherm_gw::common::state_not_supported%]", + "on": "[%key:component::opentherm_gw::common::state_supported%]" + } + }, + "supports_central_heating_setpoint_writing": { + "name": "Central heating setpoint write support", + "state": { + "off": "[%key:component::opentherm_gw::common::state_not_supported%]", + "on": "[%key:component::opentherm_gw::common::state_supported%]" + } + }, + "supports_hot_water_setpoint_transfer": { + "name": "Hot water setpoint transfer support", + "state": { + "off": "[%key:component::opentherm_gw::common::state_not_supported%]", + "on": "[%key:component::opentherm_gw::common::state_supported%]" + } + }, + "supports_hot_water_setpoint_writing": { + "name": "Hot water setpoint write support", + "state": { + "off": "[%key:component::opentherm_gw::common::state_not_supported%]", + "on": "[%key:component::opentherm_gw::common::state_supported%]" + } + }, + "gpio_state_n": { + "name": "GPIO {gpio_id} state" + }, + "ignore_transitions": { + "name": "Ignore transitions" + }, + "override_high_byte": { + "name": "Override high byte" + }, + "outside_temp_correction": { + "name": "Outside temperature correction" + }, + "override_manual_change_prio": { + "name": "Manual change has priority over override" + }, + "override_program_change_prio": { + "name": "Programmed change has priority over override" + } + }, + "sensor": { + "control_setpoint_n": { + "name": "Control setpoint {circuit_number}" + }, + "manufacturer_id": { + "name": "Manufacturer ID" + }, + "oem_fault_code": { + "name": "Manufacturer-specific fault code" + }, + "cooling_control": { + "name": "Cooling control signal" + }, + "max_relative_mod_level": { + "name": "Maximum relative modulation level" + }, + "max_capacity": { + "name": "Maximum capacity" + }, + "min_mod_level": { + "name": "Minimum modulation level" + }, + "relative_mod_level": { + "name": "Relative modulation level" + }, + "central_heating_pressure": { + "name": "Central heating water pressure" + }, + "hot_water_flow_rate": { + "name": "Hot water flow rate" + }, + "central_heating_temperature_n": { + "name": "Central heating {circuit_number} water temperature" + }, + "hot_water_temperature_n": { + "name": "Hot water {circuit_number} temperature" + }, + "return_water_temperature": { + "name": "Return water temperature" + }, + "solar_storage_temperature": { + "name": "Solar storage temperature" + }, + "solar_collector_temperature": { + "name": "Solar collector temperature" + }, + "exhaust_temperature": { + "name": "Exhaust temperature" + }, + "max_hot_water_setpoint_upper": { + "name": "Maximum hot water setpoint upper bound" + }, + "max_hot_water_setpoint_lower": { + "name": "Maximum hot water setpoint lower bound" + }, + "max_central_heating_setpoint_upper": { + "name": "Maximum central heating setpoint upper bound" + }, + "max_central_heating_setpoint_lower": { + "name": "Maximum central heating setpoint lower bound" + }, + "hot_water_setpoint": { + "name": "Hot water setpoint" + }, + "max_central_heating_setpoint": { + "name": "Maximum central heating setpoint" + }, + "oem_diagnostic_code": { + "name": "Manufacturer-specific diagnostic code" + }, + "total_burner_starts": { + "name": "Burner start count" + }, + "central_heating_pump_starts": { + "name": "Central heating pump start count" + }, + "hot_water_pump_starts": { + "name": "Hot water pump start count" + }, + "hot_water_burner_starts": { + "name": "Hot water burner start count" + }, + "total_burner_hours": { + "name": "Burner running time" + }, + "central_heating_pump_hours": { + "name": "Central heating pump running time" + }, + "hot_water_pump_hours": { + "name": "Hot water pump running time" + }, + "hot_water_burner_hours": { + "name": "Hot water burner running time" + }, + "opentherm_version": { + "name": "OpenTherm protocol version" + }, + "product_type": { + "name": "Product type" + }, + "product_version": { + "name": "Product version" + }, + "operating_mode": { + "name": "Operating mode" + }, + "hot_water_override_mode": { + "name": "Hot water override mode" + }, + "firmware_version": { + "name": "Firmware version" + }, + "firmware_build": { + "name": "Firmware build" + }, + "clock_speed": { + "name": "Clock speed" + }, + "led_mode_n": { + "name": "LED {led_id} mode" + }, + "gpio_mode_n": { + "name": "GPIO {gpio_id} mode" + }, + "setback_temperature": { + "name": "Setback temperature" + }, + "room_setpoint_override_mode": { + "name": "Room setpoint override mode" + }, + "smart_power_mode": { + "name": "Smart power mode" + }, + "thermostat_detection_mode": { + "name": "Thermostat detection mode" + }, + "reference_voltage": { + "name": "Reference voltage setting" + }, + "room_setpoint_override": { + "name": "Room setpoint override" + }, + "room_setpoint_n": { + "name": "Room setpoint {setpoint_id}" + }, + "room_temperature": { + "name": "Room temperature" + }, + "outside_temperature": { + "name": "Outside temperature" + } + } + }, "options": { "step": { "init": { diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index a466f788f1a..7b9801c8280 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -1,12 +1,15 @@ """Test Opentherm Gateway init.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch from pyotgw.vars import OTGW, OTGW_ABOUT import pytest from homeassistant import setup -from homeassistant.components.opentherm_gw.const import DOMAIN +from homeassistant.components.opentherm_gw.const import ( + DOMAIN, + OpenThermDeviceIdentifier, +) from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -49,7 +52,9 @@ async def test_device_registry_insert( await hass.async_block_till_done() - gw_dev = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_GATEWAY_ID)}) + gw_dev = device_registry.async_get_device( + identifiers={(DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}")} + ) assert gw_dev.sw_version == VERSION_OLD @@ -63,7 +68,9 @@ async def test_device_registry_update( device_registry.async_get_or_create( config_entry_id=MOCK_CONFIG_ENTRY.entry_id, - identifiers={(DOMAIN, MOCK_GATEWAY_ID)}, + identifiers={ + (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}") + }, name="Mock Gateway", manufacturer="Schelte Bron", model="OpenTherm Gateway", @@ -80,5 +87,70 @@ async def test_device_registry_update( await setup.async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - gw_dev = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_GATEWAY_ID)}) + gw_dev = device_registry.async_get_device( + identifiers={(DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}")} + ) + assert gw_dev is not None assert gw_dev.sw_version == VERSION_NEW + + +# Device migration test can be removed in 2025.4.0 +async def test_device_migration( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test that the device registry is updated correctly.""" + MOCK_CONFIG_ENTRY.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=MOCK_CONFIG_ENTRY.entry_id, + identifiers={ + (DOMAIN, MOCK_GATEWAY_ID), + }, + name="Mock Gateway", + manufacturer="Schelte Bron", + model="OpenTherm Gateway", + sw_version=VERSION_OLD, + ) + + with ( + patch( + "homeassistant.components.opentherm_gw.OpenThermGateway", + return_value=MagicMock( + connect=AsyncMock(return_value=MINIMAL_STATUS_UPD), + set_control_setpoint=AsyncMock(), + set_max_relative_mod=AsyncMock(), + disconnect=AsyncMock(), + ), + ), + ): + await setup.async_setup_component(hass, DOMAIN, {}) + + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device(identifiers={(DOMAIN, MOCK_GATEWAY_ID)}) + is None + ) + + gw_dev = device_registry.async_get_device( + identifiers={(DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}")} + ) + assert gw_dev is not None + + assert ( + device_registry.async_get_device( + identifiers={ + (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.BOILER}") + } + ) + is not None + ) + + assert ( + device_registry.async_get_device( + identifiers={ + (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.THERMOSTAT}") + } + ) + is not None + ) From f735d12a66f7b3b8251d5045c043539526518b21 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Sun, 1 Sep 2024 16:26:14 +0200 Subject: [PATCH 0115/1309] Fix BMW client blocking on load_default_certs (#125015) * Fix BMW client blocking load_default_certs * Use get_default_context --- homeassistant/components/bmw_connected_drive/coordinator.py | 2 ++ homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 6e0ed2ab670..992e7dea6b2 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -15,6 +15,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import get_default_context from .const import CONF_GCID, CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN, SCAN_INTERVALS @@ -33,6 +34,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): entry.data[CONF_PASSWORD], get_region_from_name(entry.data[CONF_REGION]), observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), + verify=get_default_context(), ) self.read_only = entry.options[CONF_READ_ONLY] self._entry = entry diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 7ee91388d29..6bc9027ac19 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], "quality_scale": "platinum", - "requirements": ["bimmer-connected[china]==0.16.2"] + "requirements": ["bimmer-connected[china]==0.16.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c12227e6ab..3b95f22f161 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -559,7 +559,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.2 +bimmer-connected[china]==0.16.3 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4d7dd1ad44..0f03f95e485 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -493,7 +493,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.16.2 +bimmer-connected[china]==0.16.3 # homeassistant.components.eq3btsmart # homeassistant.components.esphome From fa21613951b2718606ead069b967a5e216ca65d1 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Sun, 1 Sep 2024 17:13:04 +0200 Subject: [PATCH 0116/1309] Fix telegram_bot blocking on load_default_certs (#125014) * Fix telegram_bot blocking on load_default_certs * Use sync variant of create_issue --- homeassistant/components/telegram_bot/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 9d1a5398055..2d53c744c22 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -41,6 +41,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_loaded_integration +from homeassistant.util.ssl import get_default_context, get_default_no_verify_context _LOGGER = logging.getLogger(__name__) @@ -378,7 +379,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: for p_config in domain_config: # Each platform config gets its own bot - bot = initialize_bot(hass, p_config) + bot = await hass.async_add_executor_job(initialize_bot, hass, p_config) p_type: str = p_config[CONF_PLATFORM] platform = platforms[p_type] @@ -486,7 +487,7 @@ def initialize_bot(hass: HomeAssistant, p_config: dict) -> Bot: # Auth can actually be stuffed into the URL, but the docs have previously # indicated to put them here. auth = proxy_params.pop("username"), proxy_params.pop("password") - ir.async_create_issue( + ir.create_issue( hass, DOMAIN, "proxy_params_auth_deprecation", @@ -503,7 +504,7 @@ def initialize_bot(hass: HomeAssistant, p_config: dict) -> Bot: learn_more_url="https://github.com/home-assistant/core/pull/112778", ) else: - ir.async_create_issue( + ir.create_issue( hass, DOMAIN, "proxy_params_deprecation", @@ -852,7 +853,11 @@ class TelegramNotificationService: username=kwargs.get(ATTR_USERNAME), password=kwargs.get(ATTR_PASSWORD), authentication=kwargs.get(ATTR_AUTHENTICATION), - verify_ssl=kwargs.get(ATTR_VERIFY_SSL), + verify_ssl=( + get_default_context() + if kwargs.get(ATTR_VERIFY_SSL, False) + else get_default_no_verify_context() + ), ) if file_content: From 56667ec2bc137e44616a82ed420f5afd191c2879 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 1 Sep 2024 17:22:03 +0200 Subject: [PATCH 0117/1309] Migrate opentherm_gw climate entity unique_id (#125024) * Migrate climate entity unique_id to match the format used by other opentherm_gw entities * Add test to verify migration --- .../components/opentherm_gw/__init__.py | 19 +++++++++- .../components/opentherm_gw/climate.py | 1 - tests/components/opentherm_gw/test_init.py | 35 ++++++++++++++++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index f0f5c709d0c..d8c352f3768 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -9,6 +9,7 @@ import pyotgw.vars as gw_vars from serial import SerialException import voluptuous as vol +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_DATE, @@ -27,7 +28,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType @@ -132,6 +137,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b }, ) + # Migration can be removed in 2025.4.0 + ent_reg = er.async_get(hass) + if ( + entity_id := ent_reg.async_get_entity_id( + CLIMATE_DOMAIN, DOMAIN, config_entry.data[CONF_ID] + ) + ) is not None: + ent_reg.async_update_entity( + entity_id, + new_unique_id=f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity", + ) + config_entry.add_update_listener(options_updated) try: diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index 795a508be12..45f1ca478f5 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -103,7 +103,6 @@ class OpenThermClimate(OpenThermEntity, ClimateEntity): self._attr_precision = options[CONF_READ_PRECISION] self._attr_target_temperature_step = options.get(CONF_SET_PRECISION) self.temporary_ovrd_mode = options.get(CONF_TEMPORARY_OVRD_MODE, True) - self._attr_unique_id = gw_hub.hub_id @callback def update_options(self, entry): diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 7b9801c8280..ed829cb1986 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -12,7 +12,7 @@ from homeassistant.components.opentherm_gw.const import ( ) from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -154,3 +154,36 @@ async def test_device_migration( ) is not None ) + + +# Entity migration test can be removed in 2025.4.0 +async def test_climate_entity_migration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test that the climate entity unique_id gets migrated correctly.""" + MOCK_CONFIG_ENTRY.add_to_hass(hass) + entry = entity_registry.async_get_or_create( + domain="climate", + platform="opentherm_gw", + unique_id=MOCK_CONFIG_ENTRY.data[CONF_ID], + ) + + with ( + patch( + "homeassistant.components.opentherm_gw.OpenThermGateway", + return_value=MagicMock( + connect=AsyncMock(return_value=MINIMAL_STATUS_UPD), + set_control_setpoint=AsyncMock(), + set_max_relative_mod=AsyncMock(), + disconnect=AsyncMock(), + ), + ), + ): + await setup.async_setup_component(hass, DOMAIN, {}) + + await hass.async_block_till_done() + + assert ( + entity_registry.async_get(entry.entity_id).unique_id + == f"{MOCK_CONFIG_ENTRY.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" + ) From ef8fc3913e8c40989e073c814b2c98adf8a64169 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Sun, 1 Sep 2024 17:35:55 +0200 Subject: [PATCH 0118/1309] Fix ollama blocking on load_default_certs (#125012) * Fix ollama blocking on load_default_certs * Use get_default_context instead of client_context --- homeassistant/components/ollama/__init__.py | 3 ++- homeassistant/components/ollama/config_flow.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 2ad389c55c3..3bcba567803 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.util.ssl import get_default_context from .const import ( CONF_KEEP_ALIVE, @@ -43,7 +44,7 @@ PLATFORMS = (Platform.CONVERSATION,) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ollama from a config entry.""" settings = {**entry.data, **entry.options} - client = ollama.AsyncClient(host=settings[CONF_URL]) + client = ollama.AsyncClient(host=settings[CONF_URL], verify=get_default_context()) try: async with asyncio.timeout(DEFAULT_TIMEOUT): await client.list() diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 6b516d67138..65b8efaf525 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -33,6 +33,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.util.ssl import get_default_context from .const import ( CONF_KEEP_ALIVE, @@ -91,7 +92,9 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} try: - self.client = ollama.AsyncClient(host=self.url) + self.client = ollama.AsyncClient( + host=self.url, verify=get_default_context() + ) async with asyncio.timeout(DEFAULT_TIMEOUT): response = await self.client.list() From c6865d0862d01d7b6137680efd9035a11e77ead3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 1 Sep 2024 17:37:06 +0200 Subject: [PATCH 0119/1309] Bump aiomealie to 0.9.1 (#125017) --- 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 4a277cbd09b..d8fe26d97b3 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.0"] + "requirements": ["aiomealie==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3b95f22f161..6d80139df37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -288,7 +288,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.0 +aiomealie==0.9.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f03f95e485..908adbaf678 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -270,7 +270,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.0 +aiomealie==0.9.1 # homeassistant.components.modern_forms aiomodernforms==0.1.8 From 5f2964d3e85338365ad9e6e4c230c18ed1eee665 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Mon, 2 Sep 2024 01:38:48 +1000 Subject: [PATCH 0120/1309] Bump aio-georss-gdacs to 0.10 (#125021) bump aio-georss-gdacs to 0.10 --- homeassistant/components/gdacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gdacs/manifest.json b/homeassistant/components/gdacs/manifest.json index d743dd00424..fab47e00904 100644 --- a/homeassistant/components/gdacs/manifest.json +++ b/homeassistant/components/gdacs/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aio_georss_gdacs", "aio_georss_client"], "quality_scale": "platinum", - "requirements": ["aio-georss-gdacs==0.9"] + "requirements": ["aio-georss-gdacs==0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6d80139df37..13d3c94d902 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -170,7 +170,7 @@ aio-geojson-nsw-rfs-incidents==0.7 aio-geojson-usgs-earthquakes==0.3 # homeassistant.components.gdacs -aio-georss-gdacs==0.9 +aio-georss-gdacs==0.10 # homeassistant.components.airq aioairq==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 908adbaf678..24b0928dad9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -158,7 +158,7 @@ aio-geojson-nsw-rfs-incidents==0.7 aio-geojson-usgs-earthquakes==0.3 # homeassistant.components.gdacs -aio-georss-gdacs==0.9 +aio-georss-gdacs==0.10 # homeassistant.components.airq aioairq==0.3.2 From bd6b5568ebd62f0b5d9cd2e774347001bca5a636 Mon Sep 17 00:00:00 2001 From: Dmitry Krasnoukhov Date: Sun, 1 Sep 2024 18:50:53 +0300 Subject: [PATCH 0121/1309] Extend hjjcy device category in Tuya integration (#124854) * Extend hjjcy device category in Tuya integration * Better AQI level names --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/icons.json | 3 +++ homeassistant/components/tuya/sensor.py | 13 ++++++++++++- homeassistant/components/tuya/strings.json | 11 +++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 55af95f0d34..eb56761d26a 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -96,6 +96,7 @@ class DPCode(StrEnum): """ AIR_QUALITY = "air_quality" + AIR_QUALITY_INDEX = "air_quality_index" ALARM_SWITCH = "alarm_switch" # Alarm switch ALARM_TIME = "alarm_time" # Alarm time ALARM_VOLUME = "alarm_volume" # Alarm volume diff --git a/homeassistant/components/tuya/icons.json b/homeassistant/components/tuya/icons.json index 48ae61f36fd..e28371f2b3d 100644 --- a/homeassistant/components/tuya/icons.json +++ b/homeassistant/components/tuya/icons.json @@ -236,6 +236,9 @@ }, "air_quality": { "default": "mdi:air-filter" + }, + "air_quality_index": { + "default": "mdi:air-filter" } }, "switch": { diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 1ab3ea700d7..4f3c6099377 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -264,8 +264,12 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), ), # Air Quality Monitor - # No specification on Tuya portal + # https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv "hjjcy": ( + TuyaSensorEntityDescription( + key=DPCode.AIR_QUALITY_INDEX, + translation_key="air_quality_index", + ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, translation_key="temperature", @@ -301,6 +305,13 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.PM10, + translation_key="pm10", + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, ), # Formaldehyde Detector # Note: Not documented diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 6b699c0ffc0..865fbaffbbe 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -620,6 +620,17 @@ "good": "Good", "severe": "Severe" } + }, + "air_quality_index": { + "name": "Air quality index", + "state": { + "level_1": "Level 1", + "level_2": "Level 2", + "level_3": "Level 3", + "level_4": "Level 4", + "level_5": "Level 5", + "level_6": "Level 6" + } } }, "switch": { From ae1f53775f78817aa230f7e51cbafbed52581a51 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Sun, 1 Sep 2024 17:51:31 +0200 Subject: [PATCH 0122/1309] Bump python-telegram-bot to 21.5 (#125025) --- homeassistant/components/telegram_bot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json index c176e6c2cdf..b432c88762f 100644 --- a/homeassistant/components/telegram_bot/manifest.json +++ b/homeassistant/components/telegram_bot/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/telegram_bot", "iot_class": "cloud_push", "loggers": ["telegram"], - "requirements": ["python-telegram-bot[socks]==21.0.1"] + "requirements": ["python-telegram-bot[socks]==21.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 13d3c94d902..c4909d0de44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2375,7 +2375,7 @@ python-tado==0.17.6 python-technove==1.3.1 # homeassistant.components.telegram_bot -python-telegram-bot[socks]==21.0.1 +python-telegram-bot[socks]==21.5 # homeassistant.components.vlc python-vlc==3.0.18122 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24b0928dad9..cc13bb03fd6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1887,7 +1887,7 @@ python-tado==0.17.6 python-technove==1.3.1 # homeassistant.components.telegram_bot -python-telegram-bot[socks]==21.0.1 +python-telegram-bot[socks]==21.5 # homeassistant.components.tile pytile==2023.12.0 From 92c1fb77e9ff04c5e779b110428f4dc23a993ec3 Mon Sep 17 00:00:00 2001 From: Etienne Soufflet Date: Sun, 1 Sep 2024 18:33:45 +0200 Subject: [PATCH 0123/1309] Fix Tado fan speed for AC (#122415) * change capabilities * fix tests 2 * improve usability with capabilities * fix swings management * Update homeassistant/components/tado/climate.py Co-authored-by: Erwin Douna * fix after Erwin's review * fix after joostlek's review * use constant * use in instead of get --------- Co-authored-by: Erwin Douna --- homeassistant/components/tado/climate.py | 165 +++++++++++++++-------- homeassistant/components/tado/const.py | 7 + 2 files changed, 115 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 314a2315d0a..60096c25301 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -16,6 +16,7 @@ from homeassistant.components.climate import ( SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, + SWING_ON, SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, @@ -47,7 +48,6 @@ from .const import ( HA_TO_TADO_FAN_MODE_MAP, HA_TO_TADO_FAN_MODE_MAP_LEGACY, HA_TO_TADO_HVAC_MODE_MAP, - HA_TO_TADO_SWING_MODE_MAP, ORDERED_KNOWN_TADO_MODES, PRESET_AUTO, SIGNAL_TADO_UPDATE_RECEIVED, @@ -55,17 +55,20 @@ from .const import ( SUPPORT_PRESET_MANUAL, TADO_DEFAULT_MAX_TEMP, TADO_DEFAULT_MIN_TEMP, - TADO_FAN_LEVELS, - TADO_FAN_SPEEDS, + TADO_FANLEVEL_SETTING, + TADO_FANSPEED_SETTING, + TADO_HORIZONTAL_SWING_SETTING, TADO_HVAC_ACTION_TO_HA_HVAC_ACTION, TADO_MODES_WITH_NO_TEMP_SETTING, TADO_SWING_OFF, TADO_SWING_ON, + TADO_SWING_SETTING, TADO_TO_HA_FAN_MODE_MAP, TADO_TO_HA_FAN_MODE_MAP_LEGACY, TADO_TO_HA_HVAC_MODE_MAP, TADO_TO_HA_OFFSET_MAP, TADO_TO_HA_SWING_MODE_MAP, + TADO_VERTICAL_SWING_SETTING, TEMP_OFFSET, TYPE_AIR_CONDITIONING, TYPE_HEATING, @@ -166,29 +169,30 @@ def create_climate_entity( supported_hvac_modes.append(TADO_TO_HA_HVAC_MODE_MAP[mode]) if ( - capabilities[mode].get("swings") - or capabilities[mode].get("verticalSwing") - or capabilities[mode].get("horizontalSwing") + TADO_SWING_SETTING in capabilities[mode] + or TADO_VERTICAL_SWING_SETTING in capabilities[mode] + or TADO_VERTICAL_SWING_SETTING in capabilities[mode] ): support_flags |= ClimateEntityFeature.SWING_MODE supported_swing_modes = [] - if capabilities[mode].get("swings"): + if TADO_SWING_SETTING in capabilities[mode]: supported_swing_modes.append( TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON] ) - if capabilities[mode].get("verticalSwing"): + if TADO_VERTICAL_SWING_SETTING in capabilities[mode]: supported_swing_modes.append(SWING_VERTICAL) - if capabilities[mode].get("horizontalSwing"): + if TADO_HORIZONTAL_SWING_SETTING in capabilities[mode]: supported_swing_modes.append(SWING_HORIZONTAL) if ( SWING_HORIZONTAL in supported_swing_modes - and SWING_HORIZONTAL in supported_swing_modes + and SWING_VERTICAL in supported_swing_modes ): supported_swing_modes.append(SWING_BOTH) supported_swing_modes.append(TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF]) - if not capabilities[mode].get("fanSpeeds") and not capabilities[mode].get( - "fanLevel" + if ( + TADO_FANSPEED_SETTING not in capabilities[mode] + and TADO_FANLEVEL_SETTING not in capabilities[mode] ): continue @@ -197,14 +201,15 @@ def create_climate_entity( if supported_fan_modes: continue - if capabilities[mode].get("fanSpeeds"): + if TADO_FANSPEED_SETTING in capabilities[mode]: supported_fan_modes = generate_supported_fanmodes( - TADO_TO_HA_FAN_MODE_MAP_LEGACY, capabilities[mode]["fanSpeeds"] + TADO_TO_HA_FAN_MODE_MAP_LEGACY, + capabilities[mode][TADO_FANSPEED_SETTING], ) else: supported_fan_modes = generate_supported_fanmodes( - TADO_TO_HA_FAN_MODE_MAP, capabilities[mode]["fanLevel"] + TADO_TO_HA_FAN_MODE_MAP, capabilities[mode][TADO_FANLEVEL_SETTING] ) cool_temperatures = capabilities[CONST_MODE_COOL]["temperatures"] @@ -316,12 +321,16 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._target_temp: float | None = None self._current_tado_fan_speed = CONST_FAN_OFF + self._current_tado_fan_level = CONST_FAN_OFF self._current_tado_hvac_mode = CONST_MODE_OFF self._current_tado_hvac_action = HVACAction.OFF self._current_tado_swing_mode = TADO_SWING_OFF self._current_tado_vertical_swing = TADO_SWING_OFF self._current_tado_horizontal_swing = TADO_SWING_OFF + capabilities = tado.get_capabilities(zone_id) + self._current_tado_capabilities = capabilities + self._tado_zone_data: PyTado.TadoZone = {} self._tado_geofence_data: dict[str, str] | None = None @@ -382,20 +391,23 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): def fan_mode(self) -> str | None: """Return the fan setting.""" if self._ac_device: - return TADO_TO_HA_FAN_MODE_MAP.get( - self._current_tado_fan_speed, - TADO_TO_HA_FAN_MODE_MAP_LEGACY.get( + if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): + return TADO_TO_HA_FAN_MODE_MAP_LEGACY.get( self._current_tado_fan_speed, FAN_AUTO - ), - ) + ) + if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): + return TADO_TO_HA_FAN_MODE_MAP.get( + self._current_tado_fan_level, FAN_AUTO + ) + return FAN_AUTO return None def set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" - if self._current_tado_fan_speed in TADO_FAN_LEVELS: - self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) - else: + if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP_LEGACY[fan_mode]) + elif self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): + self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) @property def preset_mode(self) -> str: @@ -555,24 +567,30 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): swing = None if self._attr_swing_modes is None: return - if ( - SWING_VERTICAL in self._attr_swing_modes - or SWING_HORIZONTAL in self._attr_swing_modes - ): - if swing_mode == SWING_VERTICAL: + if swing_mode == SWING_OFF: + if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING): + swing = TADO_SWING_OFF + if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): + horizontal_swing = TADO_SWING_OFF + if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): + vertical_swing = TADO_SWING_OFF + if swing_mode == SWING_ON: + swing = TADO_SWING_ON + if swing_mode == SWING_VERTICAL: + if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): vertical_swing = TADO_SWING_ON - elif swing_mode == SWING_HORIZONTAL: + if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): + horizontal_swing = TADO_SWING_OFF + if swing_mode == SWING_HORIZONTAL: + if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): + vertical_swing = TADO_SWING_OFF + if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): horizontal_swing = TADO_SWING_ON - elif swing_mode == SWING_BOTH: + if swing_mode == SWING_BOTH: + if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): vertical_swing = TADO_SWING_ON + if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): horizontal_swing = TADO_SWING_ON - elif swing_mode == SWING_OFF: - if SWING_VERTICAL in self._attr_swing_modes: - vertical_swing = TADO_SWING_OFF - if SWING_HORIZONTAL in self._attr_swing_modes: - horizontal_swing = TADO_SWING_OFF - else: - swing = HA_TO_TADO_SWING_MODE_MAP[swing_mode] self._control_hvac( swing_mode=swing, @@ -596,21 +614,23 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._device_id ][TEMP_OFFSET][offset_key] - self._current_tado_fan_speed = ( - self._tado_zone_data.current_fan_level - if self._tado_zone_data.current_fan_level is not None - else self._tado_zone_data.current_fan_speed - ) - self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action - self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode - self._current_tado_vertical_swing = ( - self._tado_zone_data.current_vertical_swing_mode - ) - self._current_tado_horizontal_swing = ( - self._tado_zone_data.current_horizontal_swing_mode - ) + + if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): + self._current_tado_fan_level = self._tado_zone_data.current_fan_level + if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): + self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed + if self._is_valid_setting_for_hvac_mode(TADO_SWING_SETTING): + self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode + if self._is_valid_setting_for_hvac_mode(TADO_VERTICAL_SWING_SETTING): + self._current_tado_vertical_swing = ( + self._tado_zone_data.current_vertical_swing_mode + ) + if self._is_valid_setting_for_hvac_mode(TADO_HORIZONTAL_SWING_SETTING): + self._current_tado_horizontal_swing = ( + self._tado_zone_data.current_horizontal_swing_mode + ) @callback def _async_update_zone_callback(self) -> None: @@ -665,7 +685,10 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._target_temp = target_temp if fan_mode: - self._current_tado_fan_speed = fan_mode + if self._is_valid_setting_for_hvac_mode(TADO_FANSPEED_SETTING): + self._current_tado_fan_speed = fan_mode + if self._is_valid_setting_for_hvac_mode(TADO_FANLEVEL_SETTING): + self._current_tado_fan_level = fan_mode if swing_mode: self._current_tado_swing_mode = swing_mode @@ -735,21 +758,32 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): fan_speed = None fan_level = None if self.supported_features & ClimateEntityFeature.FAN_MODE: - if self._current_tado_fan_speed in TADO_FAN_LEVELS: - fan_level = self._current_tado_fan_speed - elif self._current_tado_fan_speed in TADO_FAN_SPEEDS: + if self._is_current_setting_supported_by_current_hvac_mode( + TADO_FANSPEED_SETTING, self._current_tado_fan_speed + ): fan_speed = self._current_tado_fan_speed + if self._is_current_setting_supported_by_current_hvac_mode( + TADO_FANLEVEL_SETTING, self._current_tado_fan_level + ): + fan_level = self._current_tado_fan_level + swing = None vertical_swing = None horizontal_swing = None if ( self.supported_features & ClimateEntityFeature.SWING_MODE ) and self._attr_swing_modes is not None: - if SWING_VERTICAL in self._attr_swing_modes: + if self._is_current_setting_supported_by_current_hvac_mode( + TADO_VERTICAL_SWING_SETTING, self._current_tado_vertical_swing + ): vertical_swing = self._current_tado_vertical_swing - if SWING_HORIZONTAL in self._attr_swing_modes: + if self._is_current_setting_supported_by_current_hvac_mode( + TADO_HORIZONTAL_SWING_SETTING, self._current_tado_horizontal_swing + ): horizontal_swing = self._current_tado_horizontal_swing - if vertical_swing is None and horizontal_swing is None: + if self._is_current_setting_supported_by_current_hvac_mode( + TADO_SWING_SETTING, self._current_tado_swing_mode + ): swing = self._current_tado_swing_mode self._tado.set_zone_overlay( @@ -765,3 +799,20 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): vertical_swing=vertical_swing, # api defaults to not sending verticalSwing if swing not None horizontal_swing=horizontal_swing, # api defaults to not sending horizontalSwing if swing not None ) + + def _is_valid_setting_for_hvac_mode(self, setting: str) -> bool: + return ( + self._current_tado_capabilities.get(self._current_tado_hvac_mode, {}).get( + setting + ) + is not None + ) + + def _is_current_setting_supported_by_current_hvac_mode( + self, setting: str, current_state: str | None + ) -> bool: + if self._is_valid_setting_for_hvac_mode(setting): + return current_state in self._current_tado_capabilities[ + self._current_tado_hvac_mode + ].get(setting, []) + return False diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 5c6a80c5beb..8033a653325 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -234,3 +234,10 @@ CONF_READING = "reading" ATTR_MESSAGE = "message" WATER_HEATER_FALLBACK_REPAIR = "water_heater_fallback" + +TADO_SWING_SETTING = "swings" +TADO_FANSPEED_SETTING = "fanSpeeds" + +TADO_FANLEVEL_SETTING = "fanLevel" +TADO_VERTICAL_SWING_SETTING = "verticalSwing" +TADO_HORIZONTAL_SWING_SETTING = "horizontalSwing" From 24414369d72163e58db03c66cb3c99a9571f84e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 1 Sep 2024 20:28:13 +0200 Subject: [PATCH 0124/1309] Update aioairzone-cloud to v0.6.5 (#125030) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 47a06c308ad..e0b0695655d 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_push", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.6.4"] + "requirements": ["aioairzone-cloud==0.6.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index c4909d0de44..599b557a730 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -176,7 +176,7 @@ aio-georss-gdacs==0.10 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.4 +aioairzone-cloud==0.6.5 # homeassistant.components.airzone aioairzone==0.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc13bb03fd6..58556b87c90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -164,7 +164,7 @@ aio-georss-gdacs==0.10 aioairq==0.3.2 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.6.4 +aioairzone-cloud==0.6.5 # homeassistant.components.airzone aioairzone==0.8.2 From 659d135fca5fa1568b10e5c163888935e48eb2d9 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Sun, 1 Sep 2024 21:02:32 +0200 Subject: [PATCH 0125/1309] Add ConductivityConverter in websocket_api.py (#125029) --- homeassistant/components/recorder/websocket_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index 5e0eef37721..f08f7bdcb97 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -15,6 +15,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + ConductivityConverter, DataRateConverter, DistanceConverter, DurationConverter, @@ -48,7 +49,7 @@ from .util import PERIOD_SCHEMA, get_instance, resolve_period UNIT_SCHEMA = vol.Schema( { - vol.Optional("conductivity"): vol.In(DataRateConverter.VALID_UNITS), + vol.Optional("conductivity"): vol.In(ConductivityConverter.VALID_UNITS), vol.Optional("data_rate"): vol.In(DataRateConverter.VALID_UNITS), vol.Optional("distance"): vol.In(DistanceConverter.VALID_UNITS), vol.Optional("duration"): vol.In(DurationConverter.VALID_UNITS), From 07e251d488b66b644eea48af90606a0851c74e41 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 1 Sep 2024 22:04:29 +0200 Subject: [PATCH 0126/1309] Add diagnostics platform to modern forms (#125032) --- .../components/modern_forms/diagnostics.py | 36 +++++++++++++ .../snapshots/test_diagnostics.ambr | 50 +++++++++++++++++++ .../modern_forms/test_diagnostics.py | 26 ++++++++++ 3 files changed, 112 insertions(+) create mode 100644 homeassistant/components/modern_forms/diagnostics.py create mode 100644 tests/components/modern_forms/snapshots/test_diagnostics.ambr create mode 100644 tests/components/modern_forms/test_diagnostics.py diff --git a/homeassistant/components/modern_forms/diagnostics.py b/homeassistant/components/modern_forms/diagnostics.py new file mode 100644 index 00000000000..0011a7c3bab --- /dev/null +++ b/homeassistant/components/modern_forms/diagnostics.py @@ -0,0 +1,36 @@ +"""Diagnostics support for Modern Forms.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import TYPE_CHECKING, Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import ModernFormsDataUpdateCoordinator + +REDACT_CONFIG = {CONF_MAC} +REDACT_DEVICE_INFO = {"mac_address", "owner"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + if TYPE_CHECKING: + assert coordinator is not None + + return { + "config_entry": async_redact_data(entry.as_dict(), REDACT_CONFIG), + "device": { + "info": async_redact_data( + asdict(coordinator.modern_forms.info), REDACT_DEVICE_INFO + ), + "status": asdict(coordinator.modern_forms.status), + }, + } diff --git a/tests/components/modern_forms/snapshots/test_diagnostics.ambr b/tests/components/modern_forms/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..56e299aa12a --- /dev/null +++ b/tests/components/modern_forms/snapshots/test_diagnostics.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '192.168.1.123', + 'mac': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'modern_forms', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + 'device': dict({ + 'info': dict({ + 'client_id': 'MF_000000000000', + 'device_name': 'ModernFormsFan', + 'fan_motor_type': 'DC125X25', + 'fan_type': '1818-56', + 'federated_identity': 'us-east-1:f3da237b-c19c-4f61-b387-0e6dde2e470b', + 'firmware_url': '', + 'firmware_version': '01.03.0025', + 'light_type': 'F6IN-120V-R1-30', + 'mac_address': '**REDACTED**', + 'main_mcu_firmware_version': '01.03.3008', + 'owner': '**REDACTED**', + 'product_sku': '', + 'production_lot_number': '', + }), + 'status': dict({ + 'adaptive_learning_enabled': False, + 'away_mode_enabled': False, + 'fan_direction': 'forward', + 'fan_on': True, + 'fan_sleep_timer': 0, + 'fan_speed': 3, + 'light_brightness': 50, + 'light_on': True, + 'light_sleep_timer': 0, + }), + }), + }) +# --- diff --git a/tests/components/modern_forms/test_diagnostics.py b/tests/components/modern_forms/test_diagnostics.py new file mode 100644 index 00000000000..9eb2e4efa94 --- /dev/null +++ b/tests/components/modern_forms/test_diagnostics.py @@ -0,0 +1,26 @@ +"""Tests for the Modern Forms diagnostics platform.""" + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation and values of the Modern Forms fans.""" + entry = await init_integration(hass, aioclient_mock) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == snapshot(exclude=props("created_at", "modified_at", "entry_id")) From 77b464f2bd5002fbf86f23669f9d3934fc20eba1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Sep 2024 10:47:24 -1000 Subject: [PATCH 0127/1309] Bump yarl to 1.9.7 (#125035) --- 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 30edee058bb..414bff657a0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ urllib3>=1.26.5,<2 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.9.6 +yarl==1.9.7 zeroconf==0.133.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 4376ed63d0d..69d952f4bc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.9.6", + "yarl==1.9.7", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index a9e01545b83..fd6e8815e90 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,4 @@ urllib3>=1.26.5,<2 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.9.6 +yarl==1.9.7 From 99f43400bf25ecaafe7ea3d14d148e6a68463b69 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 2 Sep 2024 00:08:19 +0300 Subject: [PATCH 0128/1309] Bump aioshelly to 11.4.2 (#125036) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index f9fa2d571d1..5e2522ea456 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==11.4.1"], + "requirements": ["aioshelly==11.4.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 599b557a730..7c15916913f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -359,7 +359,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==11.4.1 +aioshelly==11.4.2 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58556b87c90..54d9953779c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -341,7 +341,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==11.4.1 +aioshelly==11.4.2 # homeassistant.components.skybell aioskybell==22.7.0 From 9fff3a13a57adaf512d48a27d60fc5dee9a07b98 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 1 Sep 2024 21:49:38 -0700 Subject: [PATCH 0129/1309] Clarify comment in google photos upload service (#125042) --- homeassistant/components/google_photos/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index a895f333962..77015d5c700 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -42,7 +42,7 @@ UPLOAD_SERVICE_SCHEMA = vol.Schema( def _read_file_contents( hass: HomeAssistant, filenames: list[str] ) -> list[tuple[str, bytes]]: - """Read the mime type and contents from each filen.""" + """Return the mime types and file contents for each file.""" results = [] for filename in filenames: if not hass.config.is_allowed_path(filename): From 78cf7dc873ab2b95305a71034c2b5a49bd86f868 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 2 Sep 2024 09:13:10 +0300 Subject: [PATCH 0130/1309] New template merge_response (#114204) * New template merge_response * Extending * Extend comment * Update * Fixes * Fix comments * Mods * snapshots * Fixes from discussion --- homeassistant/helpers/template.py | 58 ++++ tests/helpers/snapshots/test_template.ambr | 337 +++++++++++++++++++++ tests/helpers/test_template.py | 259 ++++++++++++++++ 3 files changed, 654 insertions(+) create mode 100644 tests/helpers/snapshots/test_template.ambr diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 0a980db30b4..6856983aa59 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -51,6 +51,7 @@ from homeassistant.const import ( from homeassistant.core import ( Context, HomeAssistant, + ServiceResponse, State, callback, split_entity_id, @@ -2118,6 +2119,62 @@ def as_timedelta(value: str) -> timedelta | None: return dt_util.parse_duration(value) +def merge_response(value: ServiceResponse) -> list[Any]: + """Merge action responses into single list. + + Checks that the input is a correct service response: + { + "entity_id": {str: dict[str, Any]}, + } + If response is a single list, it will extend the list with the items + and add the entity_id and value_key to each dictionary for reference. + If response is a dictionary or multiple lists, + it will append the dictionary/lists to the list + and add the entity_id to each dictionary for reference. + """ + if not isinstance(value, dict): + raise TypeError("Response is not a dictionary") + if not value: + # Bail out early if response is an empty dictionary + return [] + + is_single_list = False + response_items: list = [] + for entity_id, entity_response in value.items(): # pylint: disable=too-many-nested-blocks + if not isinstance(entity_response, dict): + raise TypeError("Response is not a dictionary") + for value_key, type_response in entity_response.items(): + if len(entity_response) == 1 and isinstance(type_response, list): + # Provides special handling for responses such as calendar events + # and weather forecasts where the response contains a single list with multiple + # dictionaries inside. + is_single_list = True + for dict_in_list in type_response: + if isinstance(dict_in_list, dict): + if ATTR_ENTITY_ID in dict_in_list: + raise ValueError( + f"Response dictionary already contains key '{ATTR_ENTITY_ID}'" + ) + dict_in_list[ATTR_ENTITY_ID] = entity_id + dict_in_list["value_key"] = value_key + response_items.extend(type_response) + else: + # Break the loop if not a single list as the logic is then managed in the outer loop + # which handles both dictionaries and in the case of multiple lists. + break + + if not is_single_list: + _response = entity_response.copy() + if ATTR_ENTITY_ID in _response: + raise ValueError( + f"Response dictionary already contains key '{ATTR_ENTITY_ID}'" + ) + _response[ATTR_ENTITY_ID] = entity_id + response_items.append(_response) + + return response_items + + def strptime(string, fmt, default=_SENTINEL): """Parse a time string to datetime.""" try: @@ -2833,6 +2890,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["as_timedelta"] = as_timedelta self.globals["as_timestamp"] = forgiving_as_timestamp self.globals["timedelta"] = timedelta + self.globals["merge_response"] = merge_response self.globals["strptime"] = strptime self.globals["urlencode"] = urlencode self.globals["average"] = average diff --git a/tests/helpers/snapshots/test_template.ambr b/tests/helpers/snapshots/test_template.ambr new file mode 100644 index 00000000000..af38433f1a4 --- /dev/null +++ b/tests/helpers/snapshots/test_template.ambr @@ -0,0 +1,337 @@ +# serializer version: 1 +# name: test_merge_response[calendar][a_response] + dict({ + 'calendar.local_furry_events': dict({ + 'events': list([ + ]), + }), + 'calendar.sports': dict({ + 'events': list([ + dict({ + 'description': '', + 'end': '2024-02-27T18:00:00-06:00', + 'start': '2024-02-27T17:00:00-06:00', + 'summary': 'Basketball vs. Rockets', + }), + ]), + }), + 'calendar.yap_house_schedules': dict({ + 'events': list([ + dict({ + 'description': '', + 'end': '2024-02-26T09:00:00-06:00', + 'start': '2024-02-26T08:00:00-06:00', + 'summary': 'Dr. Appt', + }), + dict({ + 'description': 'something good', + 'end': '2024-02-28T21:00:00-06:00', + 'start': '2024-02-28T20:00:00-06:00', + 'summary': 'Bake a cake', + }), + ]), + }), + }) +# --- +# name: test_merge_response[calendar][b_rendered] + Wrapper([ + dict({ + 'description': '', + 'end': '2024-02-27T18:00:00-06:00', + 'entity_id': 'calendar.sports', + 'start': '2024-02-27T17:00:00-06:00', + 'summary': 'Basketball vs. Rockets', + 'value_key': 'events', + }), + dict({ + 'description': '', + 'end': '2024-02-26T09:00:00-06:00', + 'entity_id': 'calendar.yap_house_schedules', + 'start': '2024-02-26T08:00:00-06:00', + 'summary': 'Dr. Appt', + 'value_key': 'events', + }), + dict({ + 'description': 'something good', + 'end': '2024-02-28T21:00:00-06:00', + 'entity_id': 'calendar.yap_house_schedules', + 'start': '2024-02-28T20:00:00-06:00', + 'summary': 'Bake a cake', + 'value_key': 'events', + }), + ]) +# --- +# name: test_merge_response[vacuum][a_response] + dict({ + 'vacuum.deebot_n8_plus_1': dict({ + 'header': dict({ + 'ver': '0.0.1', + }), + 'payloadType': 'j', + 'resp': dict({ + 'body': dict({ + 'msg': 'ok', + }), + }), + }), + 'vacuum.deebot_n8_plus_2': dict({ + 'header': dict({ + 'ver': '0.0.1', + }), + 'payloadType': 'j', + 'resp': dict({ + 'body': dict({ + 'msg': 'ok', + }), + }), + }), + }) +# --- +# name: test_merge_response[vacuum][b_rendered] + Wrapper([ + dict({ + 'entity_id': 'vacuum.deebot_n8_plus_1', + 'header': dict({ + 'ver': '0.0.1', + }), + 'payloadType': 'j', + 'resp': dict({ + 'body': dict({ + 'msg': 'ok', + }), + }), + }), + dict({ + 'entity_id': 'vacuum.deebot_n8_plus_2', + 'header': dict({ + 'ver': '0.0.1', + }), + 'payloadType': 'j', + 'resp': dict({ + 'body': dict({ + 'msg': 'ok', + }), + }), + }), + ]) +# --- +# name: test_merge_response[weather][a_response] + dict({ + 'weather.forecast_home': dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2024-03-31T10:00:00+00:00', + 'humidity': 71, + 'precipitation': 0, + 'precipitation_probability': 6.6, + 'temperature': 10.9, + 'templow': 6.5, + 'wind_bearing': 71.8, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2024-04-01T10:00:00+00:00', + 'humidity': 79, + 'precipitation': 0, + 'precipitation_probability': 8, + 'temperature': 10.2, + 'templow': 3.4, + 'wind_bearing': 350.6, + 'wind_gust_speed': 38.2, + 'wind_speed': 21.6, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2024-04-02T10:00:00+00:00', + 'humidity': 77, + 'precipitation': 2.3, + 'precipitation_probability': 67.4, + 'temperature': 3, + 'templow': 0, + 'wind_bearing': 24.5, + 'wind_gust_speed': 64.8, + 'wind_speed': 37.4, + }), + ]), + }), + 'weather.smhi_home': dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2024-03-31T16:00:00', + 'humidity': 87, + 'precipitation': 0.2, + 'pressure': 998, + 'temperature': 10, + 'templow': 4, + 'wind_bearing': 79, + 'wind_gust_speed': 21.6, + 'wind_speed': 11.88, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2024-04-01T12:00:00', + 'humidity': 88, + 'precipitation': 2.2, + 'pressure': 999, + 'temperature': 6, + 'templow': 1, + 'wind_bearing': 17, + 'wind_gust_speed': 20.52, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2024-04-02T12:00:00', + 'humidity': 71, + 'precipitation': 1.3, + 'pressure': 1003, + 'temperature': 0, + 'templow': -3, + 'wind_bearing': 17, + 'wind_gust_speed': 57.24, + 'wind_speed': 30.6, + }), + ]), + }), + }) +# --- +# name: test_merge_response[weather][b_rendered] + Wrapper([ + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2024-03-31T16:00:00', + 'entity_id': 'weather.smhi_home', + 'humidity': 87, + 'precipitation': 0.2, + 'pressure': 998, + 'temperature': 10, + 'templow': 4, + 'value_key': 'forecast', + 'wind_bearing': 79, + 'wind_gust_speed': 21.6, + 'wind_speed': 11.88, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'rainy', + 'datetime': '2024-04-01T12:00:00', + 'entity_id': 'weather.smhi_home', + 'humidity': 88, + 'precipitation': 2.2, + 'pressure': 999, + 'temperature': 6, + 'templow': 1, + 'value_key': 'forecast', + 'wind_bearing': 17, + 'wind_gust_speed': 20.52, + 'wind_speed': 8.64, + }), + dict({ + 'cloud_coverage': 100, + 'condition': 'cloudy', + 'datetime': '2024-04-02T12:00:00', + 'entity_id': 'weather.smhi_home', + 'humidity': 71, + 'precipitation': 1.3, + 'pressure': 1003, + 'temperature': 0, + 'templow': -3, + 'value_key': 'forecast', + 'wind_bearing': 17, + 'wind_gust_speed': 57.24, + 'wind_speed': 30.6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2024-03-31T10:00:00+00:00', + 'entity_id': 'weather.forecast_home', + 'humidity': 71, + 'precipitation': 0, + 'precipitation_probability': 6.6, + 'temperature': 10.9, + 'templow': 6.5, + 'value_key': 'forecast', + 'wind_bearing': 71.8, + 'wind_gust_speed': 24.1, + 'wind_speed': 13.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2024-04-01T10:00:00+00:00', + 'entity_id': 'weather.forecast_home', + 'humidity': 79, + 'precipitation': 0, + 'precipitation_probability': 8, + 'temperature': 10.2, + 'templow': 3.4, + 'value_key': 'forecast', + 'wind_bearing': 350.6, + 'wind_gust_speed': 38.2, + 'wind_speed': 21.6, + }), + dict({ + 'condition': 'snowy', + 'datetime': '2024-04-02T10:00:00+00:00', + 'entity_id': 'weather.forecast_home', + 'humidity': 77, + 'precipitation': 2.3, + 'precipitation_probability': 67.4, + 'temperature': 3, + 'templow': 0, + 'value_key': 'forecast', + 'wind_bearing': 24.5, + 'wind_gust_speed': 64.8, + 'wind_speed': 37.4, + }), + ]) +# --- +# name: test_merge_response[workday][a_response] + dict({ + 'binary_sensor.workday': dict({ + 'workday': True, + }), + 'binary_sensor.workday2': dict({ + 'workday': False, + }), + }) +# --- +# name: test_merge_response[workday][b_rendered] + Wrapper([ + dict({ + 'entity_id': 'binary_sensor.workday', + 'workday': True, + }), + dict({ + 'entity_id': 'binary_sensor.workday2', + 'workday': False, + }), + ]) +# --- +# name: test_merge_response_with_empty_response[a_response] + dict({ + 'calendar.local_furry_events': dict({ + 'events': list([ + ]), + }), + 'calendar.sports': dict({ + 'events': list([ + ]), + }), + 'calendar.yap_house_schedules': dict({ + 'events': list([ + ]), + }), + }) +# --- +# name: test_merge_response_with_empty_response[b_rendered] + Wrapper([ + ]) +# --- diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index f585b5c3260..e4f833b2d1d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -15,6 +15,7 @@ from unittest.mock import patch from freezegun import freeze_time import orjson import pytest +from syrupy import SnapshotAssertion import voluptuous as vol from homeassistant import config_entries @@ -6288,3 +6289,261 @@ def test_template_output_exceeds_maximum_size(hass: HomeAssistant) -> None: tpl = template.Template("{{ 'a' * 1024 * 257 }}", hass) with pytest.raises(TemplateError): tpl.async_render() + + +@pytest.mark.parametrize( + ("service_response"), + [ + { + "calendar.sports": { + "events": [ + { + "start": "2024-02-27T17:00:00-06:00", + "end": "2024-02-27T18:00:00-06:00", + "summary": "Basketball vs. Rockets", + "description": "", + } + ] + }, + "calendar.local_furry_events": {"events": []}, + "calendar.yap_house_schedules": { + "events": [ + { + "start": "2024-02-26T08:00:00-06:00", + "end": "2024-02-26T09:00:00-06:00", + "summary": "Dr. Appt", + "description": "", + }, + { + "start": "2024-02-28T20:00:00-06:00", + "end": "2024-02-28T21:00:00-06:00", + "summary": "Bake a cake", + "description": "something good", + }, + ] + }, + }, + { + "binary_sensor.workday": {"workday": True}, + "binary_sensor.workday2": {"workday": False}, + }, + { + "weather.smhi_home": { + "forecast": [ + { + "datetime": "2024-03-31T16:00:00", + "condition": "cloudy", + "wind_bearing": 79, + "cloud_coverage": 100, + "temperature": 10, + "templow": 4, + "pressure": 998, + "wind_gust_speed": 21.6, + "wind_speed": 11.88, + "precipitation": 0.2, + "humidity": 87, + }, + { + "datetime": "2024-04-01T12:00:00", + "condition": "rainy", + "wind_bearing": 17, + "cloud_coverage": 100, + "temperature": 6, + "templow": 1, + "pressure": 999, + "wind_gust_speed": 20.52, + "wind_speed": 8.64, + "precipitation": 2.2, + "humidity": 88, + }, + { + "datetime": "2024-04-02T12:00:00", + "condition": "cloudy", + "wind_bearing": 17, + "cloud_coverage": 100, + "temperature": 0, + "templow": -3, + "pressure": 1003, + "wind_gust_speed": 57.24, + "wind_speed": 30.6, + "precipitation": 1.3, + "humidity": 71, + }, + ] + }, + "weather.forecast_home": { + "forecast": [ + { + "condition": "cloudy", + "precipitation_probability": 6.6, + "datetime": "2024-03-31T10:00:00+00:00", + "wind_bearing": 71.8, + "temperature": 10.9, + "templow": 6.5, + "wind_gust_speed": 24.1, + "wind_speed": 13.7, + "precipitation": 0, + "humidity": 71, + }, + { + "condition": "cloudy", + "precipitation_probability": 8, + "datetime": "2024-04-01T10:00:00+00:00", + "wind_bearing": 350.6, + "temperature": 10.2, + "templow": 3.4, + "wind_gust_speed": 38.2, + "wind_speed": 21.6, + "precipitation": 0, + "humidity": 79, + }, + { + "condition": "snowy", + "precipitation_probability": 67.4, + "datetime": "2024-04-02T10:00:00+00:00", + "wind_bearing": 24.5, + "temperature": 3, + "templow": 0, + "wind_gust_speed": 64.8, + "wind_speed": 37.4, + "precipitation": 2.3, + "humidity": 77, + }, + ] + }, + }, + { + "vacuum.deebot_n8_plus_1": { + "payloadType": "j", + "resp": { + "body": { + "msg": "ok", + } + }, + "header": { + "ver": "0.0.1", + }, + }, + "vacuum.deebot_n8_plus_2": { + "payloadType": "j", + "resp": { + "body": { + "msg": "ok", + } + }, + "header": { + "ver": "0.0.1", + }, + }, + }, + ], + ids=["calendar", "workday", "weather", "vacuum"], +) +async def test_merge_response( + hass: HomeAssistant, + service_response: dict, + snapshot: SnapshotAssertion, +) -> None: + """Test the merge_response function/filter.""" + + _template = "{{ merge_response(" + str(service_response) + ") }}" + + tpl = template.Template(_template, hass) + assert service_response == snapshot(name="a_response") + assert tpl.async_render() == snapshot(name="b_rendered") + + +async def test_merge_response_with_entity_id_in_response( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the merge_response function/filter with empty lists.""" + + service_response = { + "test.response": {"some_key": True, "entity_id": "test.response"}, + "test.response2": {"some_key": False, "entity_id": "test.response2"}, + } + _template = "{{ merge_response(" + str(service_response) + ") }}" + with pytest.raises( + TemplateError, + match="ValueError: Response dictionary already contains key 'entity_id'", + ): + template.Template(_template, hass).async_render() + + service_response = { + "test.response": { + "happening": [ + { + "start": "2024-02-27T17:00:00-06:00", + "end": "2024-02-27T18:00:00-06:00", + "summary": "Magic day", + "entity_id": "test.response", + } + ] + } + } + _template = "{{ merge_response(" + str(service_response) + ") }}" + with pytest.raises( + TemplateError, + match="ValueError: Response dictionary already contains key 'entity_id'", + ): + template.Template(_template, hass).async_render() + + +async def test_merge_response_with_empty_response( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the merge_response function/filter with empty lists.""" + + service_response = { + "calendar.sports": {"events": []}, + "calendar.local_furry_events": {"events": []}, + "calendar.yap_house_schedules": {"events": []}, + } + _template = "{{ merge_response(" + str(service_response) + ") }}" + tpl = template.Template(_template, hass) + assert service_response == snapshot(name="a_response") + assert tpl.async_render() == snapshot(name="b_rendered") + + +async def test_response_empty_dict( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the merge_response function/filter with empty dict.""" + + service_response = {} + _template = "{{ merge_response(" + str(service_response) + ") }}" + tpl = template.Template(_template, hass) + assert tpl.async_render() == [] + + +async def test_response_incorrect_value( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the merge_response function/filter with incorrect response.""" + + service_response = "incorrect" + _template = "{{ merge_response(" + str(service_response) + ") }}" + with pytest.raises(TemplateError, match="TypeError: Response is not a dictionary"): + template.Template(_template, hass).async_render() + + +async def test_merge_response_with_incorrect_response(hass: HomeAssistant) -> None: + """Test the merge_response function/filter with empty response should raise.""" + + service_response = {"calendar.sports": []} + _template = "{{ merge_response(" + str(service_response) + ") }}" + tpl = template.Template(_template, hass) + with pytest.raises(TemplateError, match="TypeError: Response is not a dictionary"): + tpl.async_render() + + service_response = { + "binary_sensor.workday": [], + } + _template = "{{ merge_response(" + str(service_response) + ") }}" + tpl = template.Template(_template, hass) + with pytest.raises(TemplateError, match="TypeError: Response is not a dictionary"): + tpl.async_render() From 8f679fcbf3fd34eeec8e62c909d2b1898865f91f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Sep 2024 09:51:05 +0200 Subject: [PATCH 0131/1309] Fix motionblinds_ble tests (#125060) --- tests/components/motionblinds_ble/test_entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/motionblinds_ble/test_entity.py b/tests/components/motionblinds_ble/test_entity.py index 1bfd3b185e5..00369ba1e22 100644 --- a/tests/components/motionblinds_ble/test_entity.py +++ b/tests/components/motionblinds_ble/test_entity.py @@ -23,6 +23,7 @@ from . import setup_integration from tests.common import MockConfigEntry +@pytest.mark.usefixtures("motionblinds_ble_connect") @pytest.mark.parametrize( ("platform", "entity"), [ From fa14321aa19425c30a867375e5ed5dade93de92a Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 2 Sep 2024 01:41:29 -0700 Subject: [PATCH 0132/1309] Bump androidtvremote2 to 0.1.2 to fix blocking event loop when loading ssl certificate chain (#125061) Bump androidtvremote2 to 0.1.2 --- homeassistant/components/androidtv_remote/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index e24fcc5d653..a06152fa570 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.1.1"], + "requirements": ["androidtvremote2==0.1.2"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c15916913f..bac5b32ce89 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -440,7 +440,7 @@ amcrest==1.9.8 androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.1 +androidtvremote2==0.1.2 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 54d9953779c..4724f5d38c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -416,7 +416,7 @@ amberelectric==1.1.1 androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote -androidtvremote2==0.1.1 +androidtvremote2==0.1.2 # homeassistant.components.anova anova-wifi==0.17.0 From 72d5146a3edfcfd57f8f60db1afe82829fec0ea8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 2 Sep 2024 10:46:35 +0200 Subject: [PATCH 0133/1309] Improve renault tests (#125064) --- .../renault/snapshots/test_services.ambr | 460 ++++++++++++++++++ tests/components/renault/test_services.py | 9 +- 2 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 tests/components/renault/snapshots/test_services.ambr diff --git a/tests/components/renault/snapshots/test_services.ambr b/tests/components/renault/snapshots/test_services.ambr new file mode 100644 index 00000000000..df4269c7430 --- /dev/null +++ b/tests/components/renault/snapshots/test_services.ambr @@ -0,0 +1,460 @@ +# serializer version: 1 +# name: test_service_set_charge_schedule[zoe_40] + list([ + dict({ + 'activated': True, + 'friday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'id': 1, + 'monday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'raw_data': dict({ + 'activated': True, + 'friday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'id': 1, + 'monday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'saturday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'sunday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'thursday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'tuesday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'wednesday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + }), + 'saturday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'sunday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'thursday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'tuesday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'wednesday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + }), + dict({ + 'activated': True, + 'friday': dict({ + 'duration': 15, + 'raw_data': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'startTime': 'T23:30Z', + }), + 'id': 2, + 'monday': dict({ + 'duration': 15, + 'raw_data': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'startTime': 'T23:30Z', + }), + 'raw_data': dict({ + 'activated': True, + 'friday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'id': 2, + 'monday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'saturday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'sunday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'thursday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'wednesday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + }), + 'saturday': dict({ + 'duration': 15, + 'raw_data': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'startTime': 'T23:30Z', + }), + 'sunday': dict({ + 'duration': 15, + 'raw_data': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'startTime': 'T23:30Z', + }), + 'thursday': dict({ + 'duration': 15, + 'raw_data': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'startTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'duration': 15, + 'raw_data': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'startTime': 'T23:30Z', + }), + 'wednesday': dict({ + 'duration': 15, + 'raw_data': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'startTime': 'T23:30Z', + }), + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 3, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 3, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 4, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 4, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 5, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 5, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + ]) +# --- +# name: test_service_set_charge_schedule_multi[zoe_40] + list([ + dict({ + 'activated': True, + 'friday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'id': 1, + 'monday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'raw_data': dict({ + 'activated': True, + 'friday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'id': 1, + 'monday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'saturday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'sunday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'thursday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'tuesday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'wednesday': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + }), + 'saturday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'sunday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'thursday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'tuesday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + 'wednesday': dict({ + 'duration': 450, + 'raw_data': dict({ + 'duration': 450, + 'startTime': 'T00:00Z', + }), + 'startTime': 'T00:00Z', + }), + }), + dict({ + 'activated': True, + 'friday': dict({ + 'duration': 30, + 'raw_data': dict({ + 'duration': 30, + 'startTime': 'T12:00Z', + }), + 'startTime': 'T12:00Z', + }), + 'id': 2, + 'monday': dict({ + 'duration': 30, + 'raw_data': dict({ + 'duration': 30, + 'startTime': 'T12:00Z', + }), + 'startTime': 'T12:00Z', + }), + 'raw_data': dict({ + 'activated': True, + 'friday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'id': 2, + 'monday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'saturday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'sunday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'thursday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'wednesday': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + }), + 'saturday': dict({ + 'duration': 30, + 'raw_data': dict({ + 'duration': 30, + 'startTime': 'T12:00Z', + }), + 'startTime': 'T12:00Z', + }), + 'sunday': dict({ + 'duration': 30, + 'raw_data': dict({ + 'duration': 30, + 'startTime': 'T12:00Z', + }), + 'startTime': 'T12:00Z', + }), + 'thursday': dict({ + 'duration': 15, + 'raw_data': dict({ + 'duration': 15, + 'startTime': 'T23:30Z', + }), + 'startTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'duration': 30, + 'raw_data': dict({ + 'duration': 30, + 'startTime': 'T12:00Z', + }), + 'startTime': 'T12:00Z', + }), + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 3, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 3, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 4, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 4, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 5, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 5, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + ]) +# --- diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 4e3460b9afa..831204c59b4 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -8,6 +8,7 @@ import pytest from renault_api.exceptions import RenaultException from renault_api.kamereon import schemas from renault_api.kamereon.models import ChargeSchedule +from syrupy import SnapshotAssertion from homeassistant.components.renault.const import DOMAIN from homeassistant.components.renault.services import ( @@ -143,7 +144,7 @@ async def test_service_set_ac_start_with_date( async def test_service_set_charge_schedule( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: ConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test that service invokes renault_api with correct data.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -176,11 +177,11 @@ async def test_service_set_charge_schedule( ) assert len(mock_action.mock_calls) == 1 mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] - assert mock_action.mock_calls[0][1] == (mock_call_data,) + assert mock_call_data == snapshot async def test_service_set_charge_schedule_multi( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: ConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test that service invokes renault_api with correct data.""" await hass.config_entries.async_setup(config_entry.entry_id) @@ -225,7 +226,7 @@ async def test_service_set_charge_schedule_multi( ) assert len(mock_action.mock_calls) == 1 mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] - assert mock_action.mock_calls[0][1] == (mock_call_data,) + assert mock_call_data == snapshot # Monday updated with new values assert mock_call_data[1].monday.startTime == "T12:00Z" From 077edb08f6a940d7fe6786f2b7cf45ac8908b11c Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Mon, 2 Sep 2024 11:27:31 +0200 Subject: [PATCH 0134/1309] Bump fyta_cli to 0.6.6 (#125065) --- homeassistant/components/fyta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index c07a19a3db0..dbd44ed34dc 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["fyta_cli==0.6.3"] + "requirements": ["fyta_cli==0.6.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index bac5b32ce89..283f096fd0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -924,7 +924,7 @@ freesms==0.2.0 fritzconnection[qr]==1.13.2 # homeassistant.components.fyta -fyta_cli==0.6.3 +fyta_cli==0.6.6 # homeassistant.components.google_translate gTTS==2.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4724f5d38c2..97a49ef04f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -777,7 +777,7 @@ freebox-api==1.1.0 fritzconnection[qr]==1.13.2 # homeassistant.components.fyta -fyta_cli==0.6.3 +fyta_cli==0.6.6 # homeassistant.components.google_translate gTTS==2.2.4 From 9334099bedebd16dc4552b82f7c5f8fb323c53b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Sep 2024 23:28:42 -1000 Subject: [PATCH 0135/1309] Bump habluetooth to 3.4.0 (#125058) changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.3.2...v3.4.0 --- homeassistant/components/bluetooth/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/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 027e2450bb4..0d17be70e0b 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,6 +20,6 @@ "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.20.0", "dbus-fast==2.24.0", - "habluetooth==3.3.2" + "habluetooth==3.4.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 414bff657a0..0b91d1e792c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ dbus-fast==2.24.0 fnv-hash-fast==1.0.2 ha-av==10.1.1 ha-ffmpeg==3.2.0 -habluetooth==3.3.2 +habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 diff --git a/requirements_all.txt b/requirements_all.txt index 283f096fd0e..5d26a6dafa0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1060,7 +1060,7 @@ ha-philipsjs==3.2.2 habitipy==0.3.1 # homeassistant.components.bluetooth -habluetooth==3.3.2 +habluetooth==3.4.0 # homeassistant.components.cloud hass-nabucasa==0.81.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97a49ef04f8..d1aa76a4950 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -895,7 +895,7 @@ ha-philipsjs==3.2.2 habitipy==0.3.1 # homeassistant.components.bluetooth -habluetooth==3.3.2 +habluetooth==3.4.0 # homeassistant.components.cloud hass-nabucasa==0.81.1 From 2ce6bd2378c28e0ffd1d0fc36d519fcae1310f76 Mon Sep 17 00:00:00 2001 From: Nidre Date: Mon, 2 Sep 2024 13:28:49 +0300 Subject: [PATCH 0136/1309] Update Matter light transition blocklist to include YNDX LightStrip (#124657) --- homeassistant/components/matter/light.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 58ef8081fa9..bcac945562a 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -66,6 +66,7 @@ TRANSITION_BLOCKLIST = ( (4999, 25057, "1.0", "27.0"), (5009, 514, "1.0", "1.0.0"), (5010, 769, "3.0", "1.0.0"), + (5130, 544, "v0.4", "6.7.196e9d4e08-14"), ) From f4a16c8dc9278a284ab4a65bc488622d5cf10ce2 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 2 Sep 2024 04:07:12 -0700 Subject: [PATCH 0137/1309] Add strict typing in Google Cloud (#125068) --- .strict-typing | 1 + .../components/google_cloud/helpers.py | 8 ++-- homeassistant/components/google_cloud/tts.py | 44 +++++++++++++------ mypy.ini | 10 +++++ 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/.strict-typing b/.strict-typing index fb35bc5d227..797a1b51293 100644 --- a/.strict-typing +++ b/.strict-typing @@ -210,6 +210,7 @@ homeassistant.components.glances.* homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_assistant_sdk.* +homeassistant.components.google_cloud.* homeassistant.components.google_photos.* homeassistant.components.google_sheets.* homeassistant.components.gpsd.* diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index 8ae6a456a4f..940bae709d8 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -4,7 +4,6 @@ from __future__ import annotations import functools import operator -from types import MappingProxyType from typing import Any from google.cloud import texttospeech @@ -51,8 +50,9 @@ async def async_tts_voices( def tts_options_schema( - config_options: MappingProxyType[str, Any], voices: dict[str, list[str]] -): + config_options: dict[str, Any], + voices: dict[str, list[str]], +) -> vol.Schema: """Return schema for TTS options with default values from config or constants.""" return vol.Schema( { @@ -152,7 +152,7 @@ def tts_options_schema( ) -def tts_platform_schema(): +def tts_platform_schema() -> vol.Schema: """Return schema for TTS platform.""" return vol.Schema( { diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index ee9999fc496..29f7e10a580 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -2,6 +2,7 @@ import logging import os +from typing import Any, cast from google.api_core.exceptions import GoogleAPIError from google.cloud import texttospeech @@ -11,9 +12,11 @@ from homeassistant.components.tts import ( CONF_LANG, PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, + TtsAudioType, Voice, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( CONF_ENCODING, @@ -34,7 +37,11 @@ _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = TTS_PLATFORM_SCHEMA.extend(tts_platform_schema().schema) -async def async_get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> Provider | None: """Set up Google Cloud TTS component.""" if key_file := config.get(CONF_KEY_FILE): key_file = hass.config.path(key_file) @@ -42,7 +49,7 @@ async def async_get_engine(hass, config, discovery_info=None): _LOGGER.error("File %s doesn't exist", key_file) return None if key_file: - client = texttospeech.TextToSpeechAsyncClient.from_service_account_json( + client = texttospeech.TextToSpeechAsyncClient.from_service_account_file( key_file ) else: @@ -69,8 +76,8 @@ class GoogleCloudTTSProvider(Provider): hass: HomeAssistant, client: texttospeech.TextToSpeechAsyncClient, voices: dict[str, list[str]], - language, - options_schema, + language: str, + options_schema: vol.Schema, ) -> None: """Init Google Cloud TTS service.""" self.hass = hass @@ -81,24 +88,24 @@ class GoogleCloudTTSProvider(Provider): self._options_schema = options_schema @property - def supported_languages(self): - """Return list of supported languages.""" + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" return list(self._voices) @property - def default_language(self): + def default_language(self) -> str: """Return the default language.""" return self._language @property - def supported_options(self): + def supported_options(self) -> list[str]: """Return a list of supported options.""" return [option.schema for option in self._options_schema.schema] @property - def default_options(self): + def default_options(self) -> dict[str, Any]: """Return a dict including default options.""" - return self._options_schema({}) + return cast(dict[str, Any], self._options_schema({})) @callback def async_get_supported_voices(self, language: str) -> list[Voice] | None: @@ -107,16 +114,25 @@ class GoogleCloudTTSProvider(Provider): return None return [Voice(voice, voice) for voice in voices] - async def async_get_tts_audio(self, message, language, options): - """Load TTS from google.""" + async def async_get_tts_audio( + self, + message: str, + language: str, + options: dict[str, Any], + ) -> TtsAudioType: + """Load TTS from Google Cloud.""" try: options = self._options_schema(options) except vol.Invalid as err: _LOGGER.error("Error: %s when validating options: %s", err, options) return None, None - encoding = texttospeech.AudioEncoding[options[CONF_ENCODING]] - gender = texttospeech.SsmlVoiceGender[options[CONF_GENDER]] + encoding: texttospeech.AudioEncoding = texttospeech.AudioEncoding[ + options[CONF_ENCODING] + ] # type: ignore[misc] + gender: texttospeech.SsmlVoiceGender | None = texttospeech.SsmlVoiceGender[ + options[CONF_GENDER] + ] # type: ignore[misc] voice = options[CONF_VOICE] if voice: gender = None diff --git a/mypy.ini b/mypy.ini index 7fb8c49c8d9..c29db45cd53 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1856,6 +1856,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.google_cloud.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.google_photos.*] check_untyped_defs = true disallow_incomplete_defs = true From d40e3145fe3f7eca87e2e5fed784de1a8ea4a65f Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 2 Sep 2024 04:30:18 -0700 Subject: [PATCH 0138/1309] Setup Google Cloud from the UI (#121502) * Google Cloud can now be setup from the UI * mypy * Add BaseGoogleCloudProvider * Allow clearing options in the UI * Address feedback * Don't translate Google Cloud title * mypy * Revert strict typing changes * Address comments --- CODEOWNERS | 3 +- .../components/google_cloud/__init__.py | 25 +++ .../components/google_cloud/config_flow.py | 169 ++++++++++++++++ .../components/google_cloud/const.py | 4 + .../components/google_cloud/helpers.py | 44 +++-- .../components/google_cloud/manifest.json | 7 +- .../components/google_cloud/strings.json | 32 +++ homeassistant/components/google_cloud/tts.py | 134 +++++++++++-- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 +- requirements_test_all.txt | 3 + tests/components/google_cloud/__init__.py | 1 + tests/components/google_cloud/conftest.py | 122 ++++++++++++ .../google_cloud/test_config_flow.py | 183 ++++++++++++++++++ 14 files changed, 696 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/google_cloud/config_flow.py create mode 100644 homeassistant/components/google_cloud/strings.json create mode 100644 tests/components/google_cloud/__init__.py create mode 100644 tests/components/google_cloud/conftest.py create mode 100644 tests/components/google_cloud/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 7b8b4ec1106..f4c7d972f7c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -549,7 +549,8 @@ build.json @home-assistant/supervisor /tests/components/google_assistant/ @home-assistant/cloud /homeassistant/components/google_assistant_sdk/ @tronikos /tests/components/google_assistant_sdk/ @tronikos -/homeassistant/components/google_cloud/ @lufton +/homeassistant/components/google_cloud/ @lufton @tronikos +/tests/components/google_cloud/ @lufton @tronikos /homeassistant/components/google_generative_ai_conversation/ @tronikos /tests/components/google_generative_ai_conversation/ @tronikos /homeassistant/components/google_mail/ @tkdrob diff --git a/homeassistant/components/google_cloud/__init__.py b/homeassistant/components/google_cloud/__init__.py index 97b669245d2..84848543790 100644 --- a/homeassistant/components/google_cloud/__init__.py +++ b/homeassistant/components/google_cloud/__init__.py @@ -1 +1,26 @@ """The google_cloud component.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.TTS] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True + + +async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/google_cloud/config_flow.py b/homeassistant/components/google_cloud/config_flow.py new file mode 100644 index 00000000000..bf97de67eb1 --- /dev/null +++ b/homeassistant/components/google_cloud/config_flow.py @@ -0,0 +1,169 @@ +"""Config flow for the Google Cloud integration.""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any, cast + +from google.cloud import texttospeech +import voluptuous as vol + +from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.components.tts import CONF_LANG +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithConfigEntry, +) +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + FileSelector, + FileSelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_KEY_FILE, CONF_SERVICE_ACCOUNT_INFO, DEFAULT_LANG, DOMAIN, TITLE +from .helpers import ( + async_tts_voices, + tts_options_schema, + tts_platform_schema, + validate_service_account_info, +) + +_LOGGER = logging.getLogger(__name__) + +UPLOADED_KEY_FILE = "uploaded_key_file" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(UPLOADED_KEY_FILE): FileSelector( + FileSelectorConfig(accept=".json,application/json") + ) + } +) + + +class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Google Cloud integration.""" + + VERSION = 1 + + _name: str | None = None + entry: ConfigEntry | None = None + abort_reason: str | None = None + + def _parse_uploaded_file(self, uploaded_file_id: str) -> dict[str, Any]: + """Read and parse an uploaded JSON file.""" + with process_uploaded_file(self.hass, uploaded_file_id) as file_path: + contents = file_path.read_text() + return cast(dict[str, Any], json.loads(contents)) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, Any] = {} + if user_input is not None: + try: + service_account_info = await self.hass.async_add_executor_job( + self._parse_uploaded_file, user_input[UPLOADED_KEY_FILE] + ) + validate_service_account_info(service_account_info) + except ValueError: + _LOGGER.exception("Reading uploaded JSON file failed") + errors["base"] = "invalid_file" + else: + data = {CONF_SERVICE_ACCOUNT_INFO: service_account_info} + if self.entry: + if TYPE_CHECKING: + assert self.abort_reason + return self.async_update_reload_and_abort( + self.entry, data=data, reason=self.abort_reason + ) + return self.async_create_entry(title=TITLE, data=data) + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "url": "https://console.cloud.google.com/apis/credentials/serviceaccountkey" + }, + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Import Google Cloud configuration from YAML.""" + + def _read_key_file() -> dict[str, Any]: + with open( + self.hass.config.path(import_data[CONF_KEY_FILE]), encoding="utf8" + ) as f: + return cast(dict[str, Any], json.load(f)) + + service_account_info = await self.hass.async_add_executor_job(_read_key_file) + try: + validate_service_account_info(service_account_info) + except ValueError: + _LOGGER.exception("Reading credentials JSON file failed") + return self.async_abort(reason="invalid_file") + options = { + k: v for k, v in import_data.items() if k in tts_platform_schema().schema + } + options.pop(CONF_KEY_FILE) + _LOGGER.debug("Creating imported config entry with options: %s", options) + return self.async_create_entry( + title=TITLE, + data={CONF_SERVICE_ACCOUNT_INFO: service_account_info}, + options=options, + ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> GoogleCloudOptionsFlowHandler: + """Create the options flow.""" + return GoogleCloudOptionsFlowHandler(config_entry) + + +class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Google Cloud options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + + service_account_info = self.config_entry.data[CONF_SERVICE_ACCOUNT_INFO] + client: texttospeech.TextToSpeechAsyncClient = ( + texttospeech.TextToSpeechAsyncClient.from_service_account_info( + service_account_info + ) + ) + voices = await async_tts_voices(client) + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional( + CONF_LANG, + default=DEFAULT_LANG, + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, options=list(voices) + ) + ), + **tts_options_schema( + self.options, voices, from_config_flow=True + ).schema, + } + ), + self.options, + ), + ) diff --git a/homeassistant/components/google_cloud/const.py b/homeassistant/components/google_cloud/const.py index 0fbd5e78274..6a718bf35d3 100644 --- a/homeassistant/components/google_cloud/const.py +++ b/homeassistant/components/google_cloud/const.py @@ -2,6 +2,10 @@ from __future__ import annotations +DOMAIN = "google_cloud" +TITLE = "Google Cloud" + +CONF_SERVICE_ACCOUNT_INFO = "service_account_info" CONF_KEY_FILE = "key_file" DEFAULT_LANG = "en-US" diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py index 940bae709d8..3c614156132 100644 --- a/homeassistant/components/google_cloud/helpers.py +++ b/homeassistant/components/google_cloud/helpers.py @@ -2,11 +2,13 @@ from __future__ import annotations +from collections.abc import Mapping import functools import operator from typing import Any from google.cloud import texttospeech +from google.oauth2.service_account import Credentials import voluptuous as vol from homeassistant.components.tts import CONF_LANG @@ -52,14 +54,18 @@ async def async_tts_voices( def tts_options_schema( config_options: dict[str, Any], voices: dict[str, list[str]], + from_config_flow: bool = False, ) -> vol.Schema: """Return schema for TTS options with default values from config or constants.""" + # If we are called from the config flow we want the defaults to be from constants + # to allow clearing the current value (passed as suggested_value) in the UI. + # If we aren't called from the config flow we want the defaults to be from the config. + defaults = {} if from_config_flow else config_options return vol.Schema( { vol.Optional( CONF_GENDER, - description={"suggested_value": config_options.get(CONF_GENDER)}, - default=config_options.get( + default=defaults.get( CONF_GENDER, texttospeech.SsmlVoiceGender.NEUTRAL.name, # type: ignore[attr-defined] ), @@ -74,8 +80,7 @@ def tts_options_schema( ), vol.Optional( CONF_VOICE, - description={"suggested_value": config_options.get(CONF_VOICE)}, - default=config_options.get(CONF_VOICE, DEFAULT_VOICE), + default=defaults.get(CONF_VOICE, DEFAULT_VOICE), ): SelectSelector( SelectSelectorConfig( mode=SelectSelectorMode.DROPDOWN, @@ -84,8 +89,7 @@ def tts_options_schema( ), vol.Optional( CONF_ENCODING, - description={"suggested_value": config_options.get(CONF_ENCODING)}, - default=config_options.get( + default=defaults.get( CONF_ENCODING, texttospeech.AudioEncoding.MP3.name, # type: ignore[attr-defined] ), @@ -100,23 +104,19 @@ def tts_options_schema( ), vol.Optional( CONF_SPEED, - description={"suggested_value": config_options.get(CONF_SPEED)}, - default=config_options.get(CONF_SPEED, 1.0), + default=defaults.get(CONF_SPEED, 1.0), ): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)), vol.Optional( CONF_PITCH, - description={"suggested_value": config_options.get(CONF_PITCH)}, - default=config_options.get(CONF_PITCH, 0), + default=defaults.get(CONF_PITCH, 0), ): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)), vol.Optional( CONF_GAIN, - description={"suggested_value": config_options.get(CONF_GAIN)}, - default=config_options.get(CONF_GAIN, 0), + default=defaults.get(CONF_GAIN, 0), ): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)), vol.Optional( CONF_PROFILES, - description={"suggested_value": config_options.get(CONF_PROFILES)}, - default=config_options.get(CONF_PROFILES, []), + default=defaults.get(CONF_PROFILES, []), ): SelectSelector( SelectSelectorConfig( mode=SelectSelectorMode.DROPDOWN, @@ -137,8 +137,7 @@ def tts_options_schema( ), vol.Optional( CONF_TEXT_TYPE, - description={"suggested_value": config_options.get(CONF_TEXT_TYPE)}, - default=config_options.get(CONF_TEXT_TYPE, "text"), + default=defaults.get(CONF_TEXT_TYPE, "text"), ): vol.All( vol.Lower, SelectSelector( @@ -166,3 +165,16 @@ def tts_platform_schema() -> vol.Schema: ), } ) + + +def validate_service_account_info(info: Mapping[str, str]) -> None: + """Validate service account info. + + Args: + info: The service account info in Google format. + + Raises: + ValueError: If the info is not in the expected format. + + """ + Credentials.from_service_account_info(info) # type:ignore[no-untyped-call] diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index 052fa79eef4..d0dda80a870 100644 --- a/homeassistant/components/google_cloud/manifest.json +++ b/homeassistant/components/google_cloud/manifest.json @@ -1,8 +1,11 @@ { "domain": "google_cloud", - "name": "Google Cloud Platform", - "codeowners": ["@lufton"], + "name": "Google Cloud", + "codeowners": ["@lufton", "@tronikos"], + "config_flow": true, + "dependencies": ["file_upload"], "documentation": "https://www.home-assistant.io/integrations/google_cloud", + "integration_type": "service", "iot_class": "cloud_push", "requirements": ["google-cloud-texttospeech==2.17.2"] } diff --git a/homeassistant/components/google_cloud/strings.json b/homeassistant/components/google_cloud/strings.json new file mode 100644 index 00000000000..0a0804005de --- /dev/null +++ b/homeassistant/components/google_cloud/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "description": "Upload your Google Cloud service account JSON file that you can create at {url}.", + "data": { + "uploaded_key_file": "Upload service account JSON file" + } + } + }, + "error": { + "invalid_file": "Invalid service account JSON file" + } + }, + "options": { + "step": { + "init": { + "data": { + "language": "Default language of the voice", + "gender": "Default gender of the voice", + "voice": "Default voice name (overrides language and gender)", + "encoding": "Default audio encoder", + "speed": "Default rate/speed of the voice", + "pitch": "Default pitch of the voice", + "gain": "Default volume gain (in dB) of the voice", + "profiles": "Default audio profiles", + "text_type": "Default text type" + } + } + } + } +} diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 29f7e10a580..d65a743c015 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -1,10 +1,12 @@ """Support for the Google Cloud TTS service.""" +from __future__ import annotations + import logging -import os +from pathlib import Path from typing import Any, cast -from google.api_core.exceptions import GoogleAPIError +from google.api_core.exceptions import GoogleAPIError, Unauthenticated from google.cloud import texttospeech import voluptuous as vol @@ -12,10 +14,14 @@ from homeassistant.components.tts import ( CONF_LANG, PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, + TextToSpeechEntity, TtsAudioType, Voice, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -25,10 +31,12 @@ from .const import ( CONF_KEY_FILE, CONF_PITCH, CONF_PROFILES, + CONF_SERVICE_ACCOUNT_INFO, CONF_SPEED, CONF_TEXT_TYPE, CONF_VOICE, DEFAULT_LANG, + DOMAIN, ) from .helpers import async_tts_voices, tts_options_schema, tts_platform_schema @@ -45,13 +53,20 @@ async def async_get_engine( """Set up Google Cloud TTS component.""" if key_file := config.get(CONF_KEY_FILE): key_file = hass.config.path(key_file) - if not os.path.isfile(key_file): + if not Path(key_file).is_file(): _LOGGER.error("File %s doesn't exist", key_file) return None if key_file: client = texttospeech.TextToSpeechAsyncClient.from_service_account_file( key_file ) + if not hass.config_entries.async_entries(DOMAIN): + _LOGGER.debug("Creating config entry by importing: %s", config) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) else: client = texttospeech.TextToSpeechAsyncClient() try: @@ -60,7 +75,6 @@ async def async_get_engine( _LOGGER.error("Error from calling list_voices: %s", err) return None return GoogleCloudTTSProvider( - hass, client, voices, config.get(CONF_LANG, DEFAULT_LANG), @@ -68,20 +82,51 @@ async def async_get_engine( ) -class GoogleCloudTTSProvider(Provider): - """The Google Cloud TTS API provider.""" +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Google Cloud text-to-speech.""" + service_account_info = config_entry.data[CONF_SERVICE_ACCOUNT_INFO] + client: texttospeech.TextToSpeechAsyncClient = ( + texttospeech.TextToSpeechAsyncClient.from_service_account_info( + service_account_info + ) + ) + try: + voices = await async_tts_voices(client) + except GoogleAPIError as err: + _LOGGER.error("Error from calling list_voices: %s", err) + if isinstance(err, Unauthenticated): + config_entry.async_start_reauth(hass) + return + options_schema = tts_options_schema(dict(config_entry.options), voices) + language = config_entry.options.get(CONF_LANG, DEFAULT_LANG) + async_add_entities( + [ + GoogleCloudTTSEntity( + config_entry, + client, + voices, + language, + options_schema, + ) + ] + ) + + +class BaseGoogleCloudProvider: + """The Google Cloud TTS base provider.""" def __init__( self, - hass: HomeAssistant, client: texttospeech.TextToSpeechAsyncClient, voices: dict[str, list[str]], language: str, options_schema: vol.Schema, ) -> None: - """Init Google Cloud TTS service.""" - self.hass = hass - self.name = "Google Cloud TTS" + """Init Google Cloud TTS base provider.""" self._client = client self._voices = voices self._language = language @@ -114,7 +159,7 @@ class GoogleCloudTTSProvider(Provider): return None return [Voice(voice, voice) for voice in voices] - async def async_get_tts_audio( + async def _async_get_tts_audio( self, message: str, language: str, @@ -155,11 +200,7 @@ class GoogleCloudTTSProvider(Provider): ), ) - try: - response = await self._client.synthesize_speech(request, timeout=10) - except GoogleAPIError as err: - _LOGGER.error("Error occurred during Google Cloud TTS call: %s", err) - return None, None + response = await self._client.synthesize_speech(request, timeout=10) if encoding == texttospeech.AudioEncoding.MP3: extension = "mp3" @@ -169,3 +210,64 @@ class GoogleCloudTTSProvider(Provider): extension = "wav" return extension, response.audio_content + + +class GoogleCloudTTSEntity(BaseGoogleCloudProvider, TextToSpeechEntity): + """The Google Cloud TTS entity.""" + + def __init__( + self, + entry: ConfigEntry, + client: texttospeech.TextToSpeechAsyncClient, + voices: dict[str, list[str]], + language: str, + options_schema: vol.Schema, + ) -> None: + """Init Google Cloud TTS entity.""" + super().__init__(client, voices, language, options_schema) + self._attr_unique_id = f"{entry.entry_id}-tts" + self._attr_name = entry.title + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Google", + model="Cloud", + entry_type=dr.DeviceEntryType.SERVICE, + ) + self._entry = entry + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load TTS from Google Cloud.""" + try: + return await self._async_get_tts_audio(message, language, options) + except GoogleAPIError as err: + _LOGGER.error("Error occurred during Google Cloud TTS call: %s", err) + if isinstance(err, Unauthenticated): + self._entry.async_start_reauth(self.hass) + return None, None + + +class GoogleCloudTTSProvider(BaseGoogleCloudProvider, Provider): + """The Google Cloud TTS API provider.""" + + def __init__( + self, + client: texttospeech.TextToSpeechAsyncClient, + voices: dict[str, list[str]], + language: str, + options_schema: vol.Schema, + ) -> None: + """Init Google Cloud TTS service.""" + super().__init__(client, voices, language, options_schema) + self.name = "Google Cloud TTS" + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load TTS from Google Cloud.""" + try: + return await self._async_get_tts_audio(message, language, options) + except GoogleAPIError as err: + _LOGGER.error("Error occurred during Google Cloud TTS call: %s", err) + return None, None diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 912df1aee0f..5f46cb1013e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -222,6 +222,7 @@ FLOWS = { "goodwe", "google", "google_assistant_sdk", + "google_cloud", "google_generative_ai_conversation", "google_mail", "google_photos", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 38958845782..e379851b37f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2251,10 +2251,10 @@ "name": "Google Assistant SDK" }, "google_cloud": { - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "cloud_push", - "name": "Google Cloud Platform" + "name": "Google Cloud" }, "google_domains": { "integration_type": "hub", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1aa76a4950..8dc22562398 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -836,6 +836,9 @@ google-api-python-client==2.71.0 # homeassistant.components.google_pubsub google-cloud-pubsub==2.23.0 +# homeassistant.components.google_cloud +google-cloud-texttospeech==2.17.2 + # homeassistant.components.google_generative_ai_conversation google-generativeai==0.7.2 diff --git a/tests/components/google_cloud/__init__.py b/tests/components/google_cloud/__init__.py new file mode 100644 index 00000000000..67e83b58c71 --- /dev/null +++ b/tests/components/google_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Cloud integration.""" diff --git a/tests/components/google_cloud/conftest.py b/tests/components/google_cloud/conftest.py new file mode 100644 index 00000000000..acde62144a9 --- /dev/null +++ b/tests/components/google_cloud/conftest.py @@ -0,0 +1,122 @@ +"""Tests helpers.""" + +from collections.abc import Generator +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from google.cloud.texttospeech_v1.types import cloud_tts +import pytest + +from homeassistant.components.google_cloud.const import ( + CONF_SERVICE_ACCOUNT_INFO, + DOMAIN, +) + +from tests.common import MockConfigEntry + +VALID_SERVICE_ACCOUNT_INFO = { + "type": "service_account", + "project_id": "my project id", + "private_key_id": "my private key if", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKYscIlwm7soDsHAz6L6YvUkCvkrX19rS6yeYOmovvhoK5WeYGWUsd8V72zmsyHB7XO94YgJVjvxfzn5K8bLePjFzwoSJjZvhBJ/ZQ05d8VmbvgyWUoPdG9oEa4fZ/lCYrXoaFdTot2xcJvrb/ZuiRl4s4eZpNeFYvVK/Am7UeFPAgMBAAECgYAUetOfzLYUudofvPCaKHu7tKZ5kQPfEa0w6BAPnBF1Mfl1JiDBRDMryFtKs6AOIAVwx00dY/Ex0BCbB3+Cr58H7t4NaPTJxCpmR09pK7o17B7xAdQv8+SynFNud9/5vQ5AEXMOLNwKiU7wpXT6Z7ZIibUBOR7ewsWgsHCDpN1iqQJBAOMODPTPSiQMwRAUHIc6GPleFSJnIz2PAoG3JOG9KFAL6RtIc19lob2ZXdbQdzKtjSkWo+O5W20WDNAl1k32h6MCQQC7W4ZCIY67mPbL6CxXfHjpSGF4Dr9VWJ7ZrKHr6XUoOIcEvsn/pHvWonjMdy93rQMSfOE8BKd/I1+GHRmNVgplAkAnSo4paxmsZVyfeKt7Jy2dMY+8tVZe17maUuQaAE7Sk00SgJYegwrbMYgQnWCTL39HBfj0dmYA2Zj8CCAuu6O7AkEAryFiYjaUAO9+4iNoL27+ZrFtypeeadyov7gKs0ZKaQpNyzW8A+Zwi7TbTeSqzic/E+z/bOa82q7p/6b7141xsQJBANCAcIwMcVb6KVCHlQbOtKspo5Eh4ZQi8bGl+IcwbQ6JSxeTx915IfAldgbuU047wOB04dYCFB2yLDiUGVXTifU=\n-----END PRIVATE KEY-----\n", + "client_email": "my client email", + "client_id": "my client id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/service-account", + "universe_domain": "googleapis.com", +} + + +@pytest.fixture +def create_google_credentials_json(tmp_path: Path) -> str: + """Create googlecredentials.json.""" + file_path = tmp_path / "googlecredentials.json" + with open(file_path, "w", encoding="utf8") as f: + json.dump(VALID_SERVICE_ACCOUNT_INFO, f) + return str(file_path) + + +@pytest.fixture +def create_invalid_google_credentials_json(create_google_credentials_json: str) -> str: + """Create invalid googlecredentials.json.""" + invalid_service_account_info = VALID_SERVICE_ACCOUNT_INFO.copy() + invalid_service_account_info.pop("client_email") + with open(create_google_credentials_json, "w", encoding="utf8") as f: + json.dump(invalid_service_account_info, f) + return create_google_credentials_json + + +@pytest.fixture +def mock_process_uploaded_file( + create_google_credentials_json: str, +) -> Generator[MagicMock]: + """Mock upload certificate files.""" + with patch( + "homeassistant.components.google_cloud.config_flow.process_uploaded_file", + return_value=Path(create_google_credentials_json), + ) as mock_upload: + yield mock_upload + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="my Google Cloud title", + domain=DOMAIN, + data={CONF_SERVICE_ACCOUNT_INFO: VALID_SERVICE_ACCOUNT_INFO}, + ) + + +@pytest.fixture +def mock_api_tts() -> AsyncMock: + """Return a mocked TTS client.""" + mock_client = AsyncMock() + mock_client.list_voices.return_value = cloud_tts.ListVoicesResponse( + voices=[ + cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-A"), + cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-B"), + cloud_tts.Voice(language_codes=["el-GR"], name="el-GR-Standard-A"), + ] + ) + return mock_client + + +@pytest.fixture +def mock_api_tts_from_service_account_info( + mock_api_tts: AsyncMock, +) -> Generator[AsyncMock]: + """Return a mocked TTS client created with from_service_account_info.""" + with ( + patch( + "google.cloud.texttospeech.TextToSpeechAsyncClient.from_service_account_info", + return_value=mock_api_tts, + ), + ): + yield mock_api_tts + + +@pytest.fixture +def mock_api_tts_from_service_account_file( + mock_api_tts: AsyncMock, +) -> Generator[AsyncMock]: + """Return a mocked TTS client created with from_service_account_file.""" + with ( + patch( + "google.cloud.texttospeech.TextToSpeechAsyncClient.from_service_account_file", + return_value=mock_api_tts, + ), + ): + yield mock_api_tts + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.google_cloud.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/google_cloud/test_config_flow.py b/tests/components/google_cloud/test_config_flow.py new file mode 100644 index 00000000000..a5a51052e66 --- /dev/null +++ b/tests/components/google_cloud/test_config_flow.py @@ -0,0 +1,183 @@ +"""Test the Google Cloud config flow.""" + +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +from homeassistant import config_entries +from homeassistant.components import tts +from homeassistant.components.google_cloud.config_flow import UPLOADED_KEY_FILE +from homeassistant.components.google_cloud.const import ( + CONF_KEY_FILE, + CONF_SERVICE_ACCOUNT_INFO, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component + +from .conftest import VALID_SERVICE_ACCOUNT_INFO + +from tests.common import MockConfigEntry + + +async def test_user_flow_success( + hass: HomeAssistant, + mock_process_uploaded_file: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user flow creates entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + uploaded_file = str(uuid4()) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {UPLOADED_KEY_FILE: uploaded_file}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Google Cloud" + assert result["data"] == {CONF_SERVICE_ACCOUNT_INFO: VALID_SERVICE_ACCOUNT_INFO} + mock_process_uploaded_file.assert_called_with(hass, uploaded_file) + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_flow_missing_file( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test user flow when uploaded file is missing.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {UPLOADED_KEY_FILE: str(uuid4())}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_file"} + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_user_flow_invalid_file( + hass: HomeAssistant, + create_invalid_google_credentials_json: str, + mock_process_uploaded_file: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user flow when uploaded file is invalid.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + uploaded_file = str(uuid4()) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {UPLOADED_KEY_FILE: uploaded_file}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_file"} + mock_process_uploaded_file.assert_called_with(hass, uploaded_file) + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_import_flow( + hass: HomeAssistant, + create_google_credentials_json: str, + mock_api_tts_from_service_account_file: AsyncMock, + mock_api_tts_from_service_account_info: AsyncMock, +) -> None: + """Test the import flow.""" + assert not hass.config_entries.async_entries(DOMAIN) + assert await async_setup_component( + hass, + tts.DOMAIN, + { + tts.DOMAIN: {CONF_PLATFORM: DOMAIN} + | {CONF_KEY_FILE: create_google_credentials_json} + }, + ) + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.state is config_entries.ConfigEntryState.LOADED + + +async def test_import_flow_invalid_file( + hass: HomeAssistant, + create_invalid_google_credentials_json: str, + mock_api_tts_from_service_account_file: AsyncMock, +) -> None: + """Test the import flow when the key file is invalid.""" + assert not hass.config_entries.async_entries(DOMAIN) + assert await async_setup_component( + hass, + tts.DOMAIN, + { + tts.DOMAIN: {CONF_PLATFORM: DOMAIN} + | {CONF_KEY_FILE: create_invalid_google_credentials_json} + }, + ) + await hass.async_block_till_done() + assert not hass.config_entries.async_entries(DOMAIN) + assert mock_api_tts_from_service_account_file.list_voices.call_count == 1 + + +async def test_options_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_tts_from_service_account_info: AsyncMock, +) -> None: + """Test options flow.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_api_tts_from_service_account_info.list_voices.call_count == 1 + + assert mock_config_entry.options == {} + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + data_schema = result["data_schema"].schema + assert set(data_schema) == { + "language", + "gender", + "voice", + "encoding", + "speed", + "pitch", + "gain", + "profiles", + "text_type", + } + assert mock_api_tts_from_service_account_info.list_voices.call_count == 2 + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"language": "el-GR"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_config_entry.options == { + "language": "el-GR", + "gender": "NEUTRAL", + "voice": "", + "encoding": "MP3", + "speed": 1.0, + "pitch": 0.0, + "gain": 0.0, + "profiles": [], + "text_type": "text", + } + assert mock_api_tts_from_service_account_info.list_voices.call_count == 3 From fbfd8c48aaeae10a6386e0e5142cab6ee05e8f03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Sep 2024 13:33:51 +0200 Subject: [PATCH 0139/1309] Remove unused event from recorder (#125067) --- homeassistant/components/recorder/core.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index c57274317e3..96a4f954c71 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -225,7 +225,6 @@ class Recorder(threading.Thread): self.event_session: Session | None = None self._get_session: Callable[[], Session] | None = None self._completed_first_database_setup: bool | None = None - self.async_migration_event = asyncio.Event() self.migration_in_progress = False self.migration_is_live = False self.use_legacy_events_index = False @@ -934,11 +933,6 @@ class Recorder(threading.Thread): return False - @callback - def _async_migration_started(self) -> None: - """Set the migration started event.""" - self.async_migration_event.set() - def _migrate_schema_offline( self, schema_status: migration.SchemaValidationStatus ) -> tuple[bool, migration.SchemaValidationStatus]: @@ -963,7 +957,6 @@ class Recorder(threading.Thread): "Database upgrade in progress", "recorder_database_migration", ) - self.hass.add_job(self._async_migration_started) return self._migrate_schema(schema_status, True) def _migrate_schema( From 114e254aa650e5eb8f2ad0db419db8bd1840513a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Sep 2024 14:20:50 +0200 Subject: [PATCH 0140/1309] Don't raise when registering entity service with invalid schema (#125057) * Don't raise when registering entity service with invalid schema * Update homeassistant/helpers/service.py Co-authored-by: Robert Resch --------- Co-authored-by: Robert Resch --- homeassistant/helpers/service.py | 11 ++++++++++- tests/helpers/test_entity_component.py | 22 ++++++++++++---------- tests/helpers/test_entity_platform.py | 22 ++++++++++++---------- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 573073f3809..bb9490b9edd 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1268,7 +1268,16 @@ def async_register_entity_service( # the check could be extended to require All/Any to have sub schema(s) # with all entity service fields elif not cv.is_entity_service_schema(schema): - raise HomeAssistantError("The schema is not an entity service schema") + # pylint: disable-next=import-outside-toplevel + from .frame import report + + report( + ( + "registers an entity service with a non entity service schema " + "which will stop working in HA Core 2025.9" + ), + error_if_core=False, + ) service_func: str | HassJob[..., Any] service_func = func if isinstance(func, str) else HassJob(func) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 8f4ece09a17..9723b91eb9a 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -557,21 +557,22 @@ async def test_register_entity_service( async def test_register_entity_service_non_entity_service_schema( - hass: HomeAssistant, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test attempting to register a service with a non entity service schema.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + expected_message = "registers an entity service with a non entity service schema" - for schema in ( - vol.Schema({"some": str}), - vol.All(vol.Schema({"some": str})), - vol.Any(vol.Schema({"some": str})), + for idx, schema in enumerate( + ( + vol.Schema({"some": str}), + vol.All(vol.Schema({"some": str})), + vol.Any(vol.Schema({"some": str})), + ) ): - with pytest.raises( - HomeAssistantError, - match=("The schema is not an entity service schema"), - ): - component.async_register_entity_service("hello", schema, Mock()) + component.async_register_entity_service(f"hello_{idx}", schema, Mock()) + assert expected_message in caplog.text + caplog.clear() for idx, schema in enumerate( ( @@ -581,6 +582,7 @@ async def test_register_entity_service_non_entity_service_schema( ) ): component.async_register_entity_service(f"test_service_{idx}", schema, Mock()) + assert expected_message not in caplog.text async def test_register_entity_service_response_data(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 2b0598cfe9d..db83819085b 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1811,23 +1811,24 @@ async def test_register_entity_service_none_schema( async def test_register_entity_service_non_entity_service_schema( - hass: HomeAssistant, + hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test attempting to register a service with a non entity service schema.""" entity_platform = MockEntityPlatform( hass, domain="mock_integration", platform_name="mock_platform", platform=None ) + expected_message = "registers an entity service with a non entity service schema" - for schema in ( - vol.Schema({"some": str}), - vol.All(vol.Schema({"some": str})), - vol.Any(vol.Schema({"some": str})), + for idx, schema in enumerate( + ( + vol.Schema({"some": str}), + vol.All(vol.Schema({"some": str})), + vol.Any(vol.Schema({"some": str})), + ) ): - with pytest.raises( - HomeAssistantError, - match="The schema is not an entity service schema", - ): - entity_platform.async_register_entity_service("hello", schema, Mock()) + entity_platform.async_register_entity_service(f"hello_{idx}", schema, Mock()) + assert expected_message in caplog.text + caplog.clear() for idx, schema in enumerate( ( @@ -1839,6 +1840,7 @@ async def test_register_entity_service_non_entity_service_schema( entity_platform.async_register_entity_service( f"test_service_{idx}", schema, Mock() ) + assert expected_message not in caplog.text @pytest.mark.parametrize("update_before_add", [True, False]) From b99dceab74c42540a09b6b7fbed362c88e655233 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Mon, 2 Sep 2024 21:58:06 +0900 Subject: [PATCH 0141/1309] Do not LG thinq retry entry setup, when a single coordinator failed (#125052) Do not retry entry setup, when a single coordinator failed. Co-authored-by: jangwon.lee --- homeassistant/components/lg_thinq/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index 1a23b70d8a7..1e16ac7ec56 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -133,7 +133,7 @@ async def async_setup_device_coordinator( coordinator_list: list[DeviceDataUpdateCoordinator] = [] for sub_id in device_sub_ids: coordinator = DeviceDataUpdateCoordinator(hass, device_api, sub_id=sub_id) - await coordinator.async_config_entry_first_refresh() + await coordinator.async_refresh() # Finally add a device coordinator into the result list. coordinator_list.append(coordinator) From baa876d4d9af99b41deb254bc2e99fba6433e85e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Sep 2024 15:18:02 +0200 Subject: [PATCH 0142/1309] Remove lying comment from service.async_register_entity_service (#125079) --- homeassistant/helpers/service.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index bb9490b9edd..ac21f1da3fc 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1264,9 +1264,6 @@ def async_register_entity_service( """ if schema is None or isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) - # Do a sanity check to check this is a valid entity service schema, - # the check could be extended to require All/Any to have sub schema(s) - # with all entity service fields elif not cv.is_entity_service_schema(schema): # pylint: disable-next=import-outside-toplevel from .frame import report From df4bd721b5de5dcf9cc7ff6ce95214eb1848ef41 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Sep 2024 15:33:10 +0200 Subject: [PATCH 0143/1309] Deprecate template.attach (#124843) --- homeassistant/helpers/template.py | 16 +++++++++++++--- tests/components/script/test_blueprint.py | 1 - tests/helpers/test_service.py | 4 ---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 6856983aa59..1786194b437 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -81,6 +81,7 @@ from . import ( label_registry, location as loc_helper, ) +from .deprecation import deprecated_function from .singleton import singleton from .translation import async_translate_state from .typing import TemplateVarsType @@ -207,15 +208,24 @@ def async_setup(hass: HomeAssistant) -> bool: @bind_hass +@deprecated_function( + "automatic setting of Template.hass introduced by HA Core PR #89242", + breaks_in_ha_version="2025.10", +) def attach(hass: HomeAssistant, obj: Any) -> None: + """Recursively attach hass to all template instances in list and dict.""" + return _attach(hass, obj) + + +def _attach(hass: HomeAssistant, obj: Any) -> None: """Recursively attach hass to all template instances in list and dict.""" if isinstance(obj, list): for child in obj: - attach(hass, child) + _attach(hass, child) elif isinstance(obj, collections.abc.Mapping): for child_key, child_value in obj.items(): - attach(hass, child_key) - attach(hass, child_value) + _attach(hass, child_key) + _attach(hass, child_value) elif isinstance(obj, Template): obj.hass = hass diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index aef22b93bcf..160b330c109 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -109,7 +109,6 @@ async def test_confirmable_notification( assert len(mock_call_action.mock_calls) == 1 _hass, config, variables, _context = mock_call_action.mock_calls[0][1] - template.attach(hass, config) rendered_config = template.render_complex(config, variables) assert rendered_config == { diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 81cc189e1af..efe24fe4b8e 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -39,7 +39,6 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, service, - template, ) import homeassistant.helpers.config_validation as cv from homeassistant.loader import async_get_integration @@ -565,9 +564,6 @@ async def test_not_mutate_input(hass: HomeAssistant) -> None: config = cv.SERVICE_SCHEMA(config) orig = cv.SERVICE_SCHEMA(orig) - # Only change after call is each template getting hass attached - template.attach(hass, orig) - await service.async_call_from_config(hass, config, validate_config=False) assert orig == config From 1b1c1c2a55173e6e4dba6acff9a30f58bd89728d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:03:58 +0100 Subject: [PATCH 0144/1309] Call async_write_ha_state after ring update (#125096) Use async_write_ha_state after ring update --- homeassistant/components/ring/camera.py | 4 +++- homeassistant/components/ring/light.py | 2 +- homeassistant/components/ring/switch.py | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index b45803f3618..df71de29089 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -81,6 +81,8 @@ class RingCam(RingEntity[RingDoorBell], Camera): history_data = self._device.last_history if history_data: self._last_event = history_data[0] + # will call async_update to update the attributes and get the + # video url from the api self.async_schedule_update_ha_state(True) else: self._last_event = None @@ -183,7 +185,7 @@ class RingCam(RingEntity[RingDoorBell], Camera): await self._device.async_set_motion_detection(new_state) self._attr_motion_detection_enabled = new_state - self.async_schedule_update_ha_state(False) + self.async_write_ha_state() async def async_enable_motion_detection(self) -> None: """Enable motion detection in the camera.""" diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index f7f7f9b44ae..99c4105f4e9 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -86,7 +86,7 @@ class RingLight(RingEntity[RingStickUpCam], LightEntity): self._attr_is_on = new_state == OnOffState.ON self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on for 30 seconds.""" diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 810011d68c8..effb43cedbe 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -87,7 +87,7 @@ class SirenSwitch(BaseRingSwitch): self._attr_is_on = new_state > 0 self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the siren on for 30 seconds.""" From 9ae59e5ea06d1cb96956e3493f0bb2b855a28587 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Mon, 2 Sep 2024 17:18:45 +0100 Subject: [PATCH 0145/1309] Bump ring-doorbell to 0.9.3 (#125087) --- 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 23e7b882efe..3aced8fd1ea 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.0"] + "requirements": ["ring-doorbell[listen]==0.9.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5d26a6dafa0..e7455f29b75 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2520,7 +2520,7 @@ rfk101py==0.0.1 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell[listen]==0.9.0 +ring-doorbell[listen]==0.9.3 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8dc22562398..4d0b9323e59 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2002,7 +2002,7 @@ reolink-aio==0.9.8 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell[listen]==0.9.0 +ring-doorbell[listen]==0.9.3 # homeassistant.components.roku rokuecp==0.19.3 From 9f558d13e610075c7d99b8b7cbb98c207bc0dff7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Sep 2024 19:32:01 +0200 Subject: [PATCH 0146/1309] Correct start version in recorder schema migration tests (#125090) * Correct start version in recorder schema migration tests * Remove default from states.last_updated_ts --- tests/components/recorder/db_schema_30.py | 3 +- .../components/recorder/test_v32_migration.py | 72 +++++++++++-------- 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 2668f610dfd..97c33334111 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -9,7 +9,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging -import time from typing import Any, Self, TypedDict, cast, overload import ciso8601 @@ -381,7 +380,7 @@ class States(Base): # type: ignore[misc,valid-type] ) # *** Not originally in v30, only added for recorder to startup ok last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) last_updated_ts = Column( - TIMESTAMP_TYPE, default=time.time, index=True + TIMESTAMP_TYPE, index=True ) # *** Not originally in v30, only added for recorder to startup ok old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) attributes_id = Column( diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 1006a03f4ec..8db2b9fa78c 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -1,5 +1,6 @@ """The tests for recorder platform migrating data from v30.""" +from collections.abc import Callable from datetime import timedelta import importlib import sys @@ -25,29 +26,38 @@ from tests.common import async_test_home_assistant from tests.typing import RecorderInstanceGenerator CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" -SCHEMA_MODULE = "tests.components.recorder.db_schema_32" +SCHEMA_MODULE_30 = "tests.components.recorder.db_schema_30" +SCHEMA_MODULE_32 = "tests.components.recorder.db_schema_32" -def _create_engine_test(*args, **kwargs): +def _create_engine_test(schema_module: str) -> Callable: """Test version of create_engine that initializes with old schema. This simulates an existing db with the old schema. """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] - engine = create_engine(*args, **kwargs) - old_db_schema.Base.metadata.create_all(engine) - with Session(engine) as session: - session.add( - recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) - ) - session.add( - recorder.db_schema.SchemaChanges( - schema_version=old_db_schema.SCHEMA_VERSION + + def _create_engine_test(*args, **kwargs): + """Test version of create_engine that initializes with old schema. + + This simulates an existing db with the old schema. + """ + importlib.import_module(schema_module) + old_db_schema = sys.modules[schema_module] + engine = create_engine(*args, **kwargs) + old_db_schema.Base.metadata.create_all(engine) + with Session(engine) as session: + session.add( + recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) ) - ) - session.commit() - return engine + session.add( + recorder.db_schema.SchemaChanges( + schema_version=old_db_schema.SCHEMA_VERSION + ) + ) + session.commit() + return engine + + return _create_engine_test @pytest.mark.parametrize("enable_migrate_context_ids", [True]) @@ -60,8 +70,8 @@ async def test_migrate_times( caplog: pytest.LogCaptureFixture, ) -> None: """Test we can migrate times.""" - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_30) + old_db_schema = sys.modules[SCHEMA_MODULE_30] now = dt_util.utcnow() one_second_past = now - timedelta(seconds=1) now_timestamp = now.timestamp() @@ -108,7 +118,7 @@ async def test_migrate_times( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_30)), patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), patch( "homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids" @@ -216,8 +226,8 @@ async def test_migrate_can_resume_entity_id_post_migration( recorder_db_url: str, ) -> None: """Test we resume the entity id post migration after a restart.""" - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] now = dt_util.utcnow() one_second_past = now - timedelta(seconds=1) mock_state = State( @@ -259,7 +269,7 @@ async def test_migrate_can_resume_entity_id_post_migration( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), patch( "homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids" @@ -327,8 +337,8 @@ async def test_migrate_can_resume_ix_states_event_id_removed( This case tests the migration still happens if ix_states_event_id is removed from the states table. """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] now = dt_util.utcnow() one_second_past = now - timedelta(seconds=1) mock_state = State( @@ -381,7 +391,7 @@ async def test_migrate_can_resume_ix_states_event_id_removed( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), patch( "homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids" @@ -463,8 +473,8 @@ async def test_out_of_disk_space_while_rebuild_states_table( This case tests the migration still happens if ix_states_event_id is removed from the states table. """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] now = dt_util.utcnow() one_second_past = now - timedelta(seconds=1) mock_state = State( @@ -517,7 +527,7 @@ async def test_out_of_disk_space_while_rebuild_states_table( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), patch( "homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids" @@ -643,8 +653,8 @@ async def test_out_of_disk_space_while_removing_foreign_key( removed when migrating to schema version 46, inspecting the schema in cleanup_legacy_states_event_ids is not likely to fail. """ - importlib.import_module(SCHEMA_MODULE) - old_db_schema = sys.modules[SCHEMA_MODULE] + importlib.import_module(SCHEMA_MODULE_32) + old_db_schema = sys.modules[SCHEMA_MODULE_32] now = dt_util.utcnow() one_second_past = now - timedelta(seconds=1) mock_state = State( @@ -697,7 +707,7 @@ async def test_out_of_disk_space_while_removing_foreign_key( patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), - patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), patch( "homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids" From 5300eddf3336330f955694070441fcaed0fc1650 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Mon, 2 Sep 2024 19:50:09 +0200 Subject: [PATCH 0147/1309] Remove roundig in Solarlog and add suggested_display_precision (#125094) * Remove roundig and add suggested_display_precision * Add suggested_unit_of_measurement * Put lamda in parentheses --- homeassistant/components/solarlog/sensor.py | 74 +++++++++++----- .../solarlog/snapshots/test_sensor.ambr | 88 +++++++++++++++++-- 2 files changed, 133 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index 498429f70cf..91e18da1cb2 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -84,38 +84,47 @@ SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = SolarLogCoordinatorSensorEntityDescription( key="yield_day", translation_key="yield_day", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda data: round(data.yield_day / 1000, 3), + suggested_display_precision=3, + value_fn=lambda data: data.yield_day, ), SolarLogCoordinatorSensorEntityDescription( key="yield_yesterday", translation_key="yield_yesterday", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda data: round(data.yield_yesterday / 1000, 3), + suggested_display_precision=3, + value_fn=lambda data: data.yield_yesterday, ), SolarLogCoordinatorSensorEntityDescription( key="yield_month", translation_key="yield_month", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda data: round(data.yield_month / 1000, 3), + suggested_display_precision=3, + value_fn=lambda data: data.yield_month, ), SolarLogCoordinatorSensorEntityDescription( key="yield_year", translation_key="yield_year", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda data: round(data.yield_year / 1000, 3), + value_fn=lambda data: data.yield_year, ), SolarLogCoordinatorSensorEntityDescription( key="yield_total", translation_key="yield_total", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, - value_fn=lambda data: round(data.yield_total / 1000, 3), + suggested_display_precision=3, + value_fn=lambda data: data.yield_total, ), SolarLogCoordinatorSensorEntityDescription( key="consumption_ac", @@ -128,38 +137,48 @@ SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = SolarLogCoordinatorSensorEntityDescription( key="consumption_day", translation_key="consumption_day", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda data: round(data.consumption_day / 1000, 3), + suggested_display_precision=3, + value_fn=lambda data: data.consumption_day, ), SolarLogCoordinatorSensorEntityDescription( key="consumption_yesterday", translation_key="consumption_yesterday", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda data: round(data.consumption_yesterday / 1000, 3), + suggested_display_precision=3, + value_fn=lambda data: data.consumption_yesterday, ), SolarLogCoordinatorSensorEntityDescription( key="consumption_month", translation_key="consumption_month", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda data: round(data.consumption_month / 1000, 3), + suggested_display_precision=3, + value_fn=lambda data: data.consumption_month, ), SolarLogCoordinatorSensorEntityDescription( key="consumption_year", translation_key="consumption_year", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda data: round(data.consumption_year / 1000, 3), + suggested_display_precision=3, + value_fn=lambda data: data.consumption_year, ), SolarLogCoordinatorSensorEntityDescription( key="consumption_total", translation_key="consumption_total", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, - value_fn=lambda data: round(data.consumption_total / 1000, 3), + suggested_display_precision=3, + value_fn=lambda data: data.consumption_total, ), SolarLogCoordinatorSensorEntityDescription( key="self_consumption_year", @@ -190,6 +209,7 @@ SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, value_fn=lambda data: data.capacity, ), SolarLogCoordinatorSensorEntityDescription( @@ -198,6 +218,7 @@ SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, value_fn=lambda data: data.efficiency, ), SolarLogCoordinatorSensorEntityDescription( @@ -214,6 +235,7 @@ SOLARLOG_SENSOR_TYPES: tuple[SolarLogCoordinatorSensorEntityDescription, ...] = native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, value_fn=lambda data: data.usage, ), ) @@ -230,11 +252,15 @@ INVERTER_SENSOR_TYPES: tuple[SolarLogInverterSensorEntityDescription, ...] = ( SolarLogInverterSensorEntityDescription( key="consumption_year", translation_key="consumption_year", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, - value_fn=lambda inverter: None - if inverter.consumption_year is None - else round(inverter.consumption_year / 1000, 3), + suggested_display_precision=3, + value_fn=( + lambda inverter: None + if inverter.consumption_year is None + else inverter.consumption_year + ), ), ) diff --git a/tests/components/solarlog/snapshots/test_sensor.ambr b/tests/components/solarlog/snapshots/test_sensor.ambr index 6fccbd89dba..9f95e04a38f 100644 --- a/tests/components/solarlog/snapshots/test_sensor.ambr +++ b/tests/components/solarlog/snapshots/test_sensor.ambr @@ -71,6 +71,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -170,6 +176,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -322,6 +334,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -422,6 +437,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -446,7 +467,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.005', + 'state': '0.00531', }) # --- # name: test_all_entities[sensor.solarlog_consumption_month-entry] @@ -470,6 +491,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -520,6 +547,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -569,6 +602,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -617,6 +656,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -641,7 +686,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.007', + 'state': '0.00734', }) # --- # name: test_all_entities[sensor.solarlog_efficiency-entry] @@ -667,6 +712,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -1017,6 +1065,9 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), 'original_device_class': , 'original_icon': None, @@ -1168,6 +1219,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1192,7 +1249,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.004', + 'state': '0.00421', }) # --- # name: test_all_entities[sensor.solarlog_yield_month-entry] @@ -1216,6 +1273,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1266,6 +1329,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1315,6 +1384,9 @@ }), 'name': None, 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1339,7 +1411,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.023', + 'state': '1.0230', }) # --- # name: test_all_entities[sensor.solarlog_yield_yesterday-entry] @@ -1363,6 +1435,12 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -1387,6 +1465,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.005', + 'state': '0.00521', }) # --- From 633c90485292a2673124637832d16013a2ebdcea Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 2 Sep 2024 20:04:33 +0200 Subject: [PATCH 0148/1309] Update frontend to 20240902.0 (#125093) --- 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 7e934c887fa..50bcb3b3d97 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==20240829.0"] + "requirements": ["home-assistant-frontend==20240902.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0b91d1e792c..1729e6e8131 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20240829.0 +home-assistant-frontend==20240902.0 home-assistant-intents==2024.8.29 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e7455f29b75..16ff5a9a032 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1103,7 +1103,7 @@ hole==0.8.0 holidays==0.55 # homeassistant.components.frontend -home-assistant-frontend==20240829.0 +home-assistant-frontend==20240902.0 # homeassistant.components.conversation home-assistant-intents==2024.8.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4d0b9323e59..a453a5948fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -929,7 +929,7 @@ hole==0.8.0 holidays==0.55 # homeassistant.components.frontend -home-assistant-frontend==20240829.0 +home-assistant-frontend==20240902.0 # homeassistant.components.conversation home-assistant-intents==2024.8.29 From 7c4fd9473cb453d27586c39770196d84d67a0915 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Mon, 2 Sep 2024 20:08:44 +0200 Subject: [PATCH 0149/1309] Add diagnostics to solarlog (#125072) * Add diagnostics to solarlog * Fix wrong comment --- .../components/solarlog/diagnostics.py | 27 ++++++++ .../solarlog/snapshots/test_diagnostics.ambr | 64 +++++++++++++++++++ tests/components/solarlog/test_diagnostics.py | 32 ++++++++++ 3 files changed, 123 insertions(+) create mode 100644 homeassistant/components/solarlog/diagnostics.py create mode 100644 tests/components/solarlog/snapshots/test_diagnostics.ambr create mode 100644 tests/components/solarlog/test_diagnostics.py diff --git a/homeassistant/components/solarlog/diagnostics.py b/homeassistant/components/solarlog/diagnostics.py new file mode 100644 index 00000000000..02f6c96edc2 --- /dev/null +++ b/homeassistant/components/solarlog/diagnostics.py @@ -0,0 +1,27 @@ +"""Provides diagnostics for Solarlog.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from . import SolarlogConfigEntry + +TO_REDACT = [ + CONF_HOST, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: SolarlogConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = config_entry.runtime_data.data + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "solarlog_data": data.to_dict(), + } diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..09ff3a333ee --- /dev/null +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'extended_data': True, + 'host': '**REDACTED**', + 'name': 'Solarlog test 1 2 3', + }), + 'disabled_by': None, + 'domain': 'solarlog', + 'entry_id': 'ce5f5431554d101905d31797e1232da8', + 'minor_version': 2, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'solarlog', + 'unique_id': None, + 'version': 1, + }), + 'solarlog_data': dict({ + 'alternator_loss': 2.0, + 'capacity': 85.5, + 'consumption_ac': 54.87, + 'consumption_day': 5.31, + 'consumption_month': 758.0, + 'consumption_total': 354687.0, + 'consumption_year': 4587.0, + 'consumption_yesterday': 7.34, + 'efficiency': 98.1, + 'inverter_data': dict({ + '0': dict({ + 'consumption_year': 354687, + 'current_power': 5, + 'enabled': True, + 'name': 'Inverter 1', + }), + '1': dict({ + 'consumption_year': 354, + 'current_power': 6, + 'enabled': True, + 'name': 'Inverter 2', + }), + }), + 'last_updated': '2024-08-01T15:20:45+00:00', + 'power_ac': 100.0, + 'power_available': 45.13, + 'power_dc': 102.0, + 'production_year': None, + 'self_consumption_year': 545.0, + 'total_power': 120.0, + 'usage': 54.8, + 'voltage_ac': 100.0, + 'voltage_dc': 100.0, + 'yield_day': 4.21, + 'yield_month': 515.0, + 'yield_total': 56513.0, + 'yield_year': 1023.0, + 'yield_yesterday': 5.21, + }), + }) +# --- diff --git a/tests/components/solarlog/test_diagnostics.py b/tests/components/solarlog/test_diagnostics.py new file mode 100644 index 00000000000..bc0b020462d --- /dev/null +++ b/tests/components/solarlog/test_diagnostics.py @@ -0,0 +1,32 @@ +"""Test Solarlog diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_platform + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_solarlog_connector: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("created_at", "modified_at")) From 4c27bfbf7fac053f4811b73cb4eec813f88bf24a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 2 Sep 2024 20:35:36 +0200 Subject: [PATCH 0150/1309] Cleanup removed options for mqtt climate (#125083) --- .../components/mqtt/abbreviations.py | 5 ---- homeassistant/components/mqtt/climate.py | 28 ------------------- 2 files changed, 33 deletions(-) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index f4a32bbdf9d..3c1d0abdb66 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -6,9 +6,6 @@ ABBREVIATIONS = { "act_stat_t": "activity_state_topic", "act_val_tpl": "activity_value_template", "atype": "automation_type", - "aux_cmd_t": "aux_command_topic", - "aux_stat_tpl": "aux_state_template", - "aux_stat_t": "aux_state_topic", "av_tones": "available_tones", "avty": "availability", "avty_mode": "availability_mode", @@ -157,8 +154,6 @@ ABBREVIATIONS = { "pos_open": "position_open", "pow_cmd_t": "power_command_topic", "pow_cmd_tpl": "power_command_template", - "pow_stat_t": "power_state_topic", - "pow_stat_tpl": "power_state_template", "pr_mode_cmd_t": "preset_mode_command_topic", "pr_mode_cmd_tpl": "preset_mode_command_template", "pr_mode_stat_t": "preset_mode_state_topic", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 426bac8e9ca..ac276c37d71 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -93,13 +93,6 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "MQTT HVAC" -# Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC -# and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 -# Support was removed in HA Core 2024.3 -CONF_AUX_COMMAND_TOPIC = "aux_command_topic" -CONF_AUX_STATE_TEMPLATE = "aux_state_template" -CONF_AUX_STATE_TOPIC = "aux_state_topic" - CONF_FAN_MODE_COMMAND_TEMPLATE = "fan_mode_command_template" CONF_FAN_MODE_COMMAND_TOPIC = "fan_mode_command_topic" CONF_FAN_MODE_LIST = "fan_modes" @@ -113,10 +106,6 @@ CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" CONF_HUMIDITY_MAX = "max_humidity" CONF_HUMIDITY_MIN = "min_humidity" -# Support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE -# was removed in HA Core 2023.8 -CONF_POWER_STATE_TEMPLATE = "power_state_template" -CONF_POWER_STATE_TOPIC = "power_state_topic" CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" @@ -201,7 +190,6 @@ TOPIC_KEYS = ( CONF_MODE_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, CONF_POWER_COMMAND_TOPIC, - CONF_POWER_STATE_TOPIC, CONF_PRESET_MODE_COMMAND_TOPIC, CONF_PRESET_MODE_STATE_TOPIC, CONF_SWING_MODE_COMMAND_TOPIC, @@ -295,8 +283,6 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_POWER_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_POWER_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_POWER_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRECISION): vol.In( [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] ), @@ -343,16 +329,6 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( - # Support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE - # was removed in HA Core 2023.8 - cv.removed(CONF_POWER_STATE_TEMPLATE), - cv.removed(CONF_POWER_STATE_TOPIC), - # Options CONF_AUX_COMMAND_TOPIC, CONF_AUX_STATE_TOPIC - # and CONF_AUX_STATE_TEMPLATE were deprecated in HA Core 2023.9 - # Support was removed in HA Core 2024.3 - cv.removed(CONF_AUX_COMMAND_TOPIC), - cv.removed(CONF_AUX_STATE_TEMPLATE), - cv.removed(CONF_AUX_STATE_TOPIC), _PLATFORM_SCHEMA_BASE, valid_preset_mode_configuration, valid_humidity_range_configuration, @@ -363,10 +339,6 @@ _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA DISCOVERY_SCHEMA = vol.All( _DISCOVERY_SCHEMA_BASE, - # Support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE - # was removed in HA Core 2023.8 - cv.removed(CONF_POWER_STATE_TEMPLATE), - cv.removed(CONF_POWER_STATE_TOPIC), valid_preset_mode_configuration, valid_humidity_range_configuration, valid_humidity_state_configuration, From 3206979488fcbbb281d35bcbfd5dcb8ab2f6cdb2 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 2 Sep 2024 20:46:32 +0200 Subject: [PATCH 0151/1309] Add separate entities for temperature, humidity and pressure in AccuWeather integration (#125041) * Add temperature, humidity and pressure sensors * Make uv index sensor disabled by default * Fix type --- .../components/accuweather/sensor.py | 31 ++++ .../accuweather/snapshots/test_sensor.ambr | 159 ++++++++++++++++++ tests/components/accuweather/test_sensor.py | 1 + 3 files changed, 191 insertions(+) diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index fac3a2a4ba3..2f6b10b296f 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( UV_INDEX, UnitOfIrradiance, UnitOfLength, + UnitOfPressure, UnitOfSpeed, UnitOfTemperature, UnitOfTime, @@ -279,6 +280,15 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), translation_key="realfeel_temperature_shade", ), + AccuWeatherSensorDescription( + key="RelativeHumidity", + device_class=SensorDeviceClass.HUMIDITY, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(int, data), + translation_key="humidity", + ), AccuWeatherSensorDescription( key="Precipitation", device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, @@ -288,6 +298,16 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( attr_fn=lambda data: {"type": data["PrecipitationType"]}, translation_key="precipitation", ), + AccuWeatherSensorDescription( + key="Pressure", + device_class=SensorDeviceClass.PRESSURE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + native_unit_of_measurement=UnitOfPressure.HPA, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), + translation_key="pressure", + ), AccuWeatherSensorDescription( key="PressureTendency", device_class=SensorDeviceClass.ENUM, @@ -295,9 +315,19 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( value_fn=lambda data: cast(str, data["LocalizedText"]).lower(), translation_key="pressure_tendency", ), + AccuWeatherSensorDescription( + key="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data: cast(float, data[API_METRIC][ATTR_VALUE]), + translation_key="temperature", + ), AccuWeatherSensorDescription( key="UVIndex", state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, native_unit_of_measurement=UV_INDEX, value_fn=lambda data: cast(int, data), attr_fn=lambda data: {ATTR_LEVEL: data["UVIndexText"]}, @@ -324,6 +354,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( AccuWeatherSensorDescription( key="Wind", device_class=SensorDeviceClass.WIND_SPEED, + entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, value_fn=lambda data: cast(float, data[ATTR_SPEED][API_METRIC][ATTR_VALUE]), diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr index 5e28be5a72b..3468d638bc0 100644 --- a/tests/components/accuweather/snapshots/test_sensor.ambr +++ b/tests/components/accuweather/snapshots/test_sensor.ambr @@ -1969,6 +1969,58 @@ 'state': '9.2', }) # --- +# name: test_sensor[sensor.home_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '0123456-relativehumidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.home_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'humidity', + 'friendly_name': 'Home Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67', + }) +# --- # name: test_sensor[sensor.home_mold_pollen_day_0-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2267,6 +2319,61 @@ 'state': '0.0', }) # --- +# name: test_sensor[sensor.home_pressure-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pressure', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pressure', + 'unique_id': '0123456-pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'pressure', + 'friendly_name': 'Home Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1012.0', + }) +# --- # name: test_sensor[sensor.home_pressure_tendency-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4145,6 +4252,58 @@ 'state': '276.1', }) # --- +# name: test_sensor[sensor.home_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'accuweather', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '0123456-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.home_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by AccuWeather', + 'device_class': 'temperature', + 'friendly_name': 'Home Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.6', + }) +# --- # name: test_sensor[sensor.home_thunderstorm_probability_day_0-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 41c1c0d930a..37ebe260f39 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -148,6 +148,7 @@ async def test_manual_update_entity( assert mock_accuweather_client.async_get_current_conditions.call_count == 2 +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_imperial_units( hass: HomeAssistant, mock_accuweather_client: AsyncMock ) -> None: From 0b14f0a379d0d7053a10a89d2c5cc7242363dbcd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Sep 2024 21:13:26 +0200 Subject: [PATCH 0152/1309] Add test of statistics timestamp migration (#125100) --- .../recorder/test_migration_from_schema_32.py | 168 +++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index b2a83ae8313..bc16eae3410 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -48,6 +48,7 @@ from .common import ( async_wait_recording_done, ) +from tests.common import async_test_home_assistant from tests.typing import RecorderInstanceGenerator CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" @@ -94,7 +95,7 @@ def _create_engine_test(*args, **kwargs): return engine -@pytest.fixture(autouse=True) +@pytest.fixture def db_schema_32(): """Fixture to initialize the db with the old schema.""" importlib.import_module(SCHEMA_MODULE) @@ -118,6 +119,7 @@ def db_schema_32(): @pytest.mark.parametrize("enable_migrate_context_ids", [True]) +@pytest.mark.usefixtures("db_schema_32") async def test_migrate_events_context_ids( hass: HomeAssistant, recorder_mock: Recorder ) -> None: @@ -333,6 +335,7 @@ async def test_migrate_events_context_ids( @pytest.mark.parametrize("enable_migrate_context_ids", [True]) +@pytest.mark.usefixtures("db_schema_32") async def test_migrate_states_context_ids( hass: HomeAssistant, recorder_mock: Recorder ) -> None: @@ -530,6 +533,7 @@ async def test_migrate_states_context_ids( @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) +@pytest.mark.usefixtures("db_schema_32") async def test_migrate_event_type_ids( hass: HomeAssistant, recorder_mock: Recorder ) -> None: @@ -621,6 +625,7 @@ async def test_migrate_event_type_ids( @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +@pytest.mark.usefixtures("db_schema_32") async def test_migrate_entity_ids(hass: HomeAssistant, recorder_mock: Recorder) -> None: """Test we can migrate entity_ids to the StatesMeta table.""" await async_wait_recording_done(hass) @@ -697,6 +702,7 @@ async def test_migrate_entity_ids(hass: HomeAssistant, recorder_mock: Recorder) @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +@pytest.mark.usefixtures("db_schema_32") async def test_post_migrate_entity_ids( hass: HomeAssistant, recorder_mock: Recorder ) -> None: @@ -750,6 +756,7 @@ async def test_post_migrate_entity_ids( @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) +@pytest.mark.usefixtures("db_schema_32") async def test_migrate_null_entity_ids( hass: HomeAssistant, recorder_mock: Recorder ) -> None: @@ -833,6 +840,7 @@ async def test_migrate_null_entity_ids( @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) +@pytest.mark.usefixtures("db_schema_32") async def test_migrate_null_event_type_ids( hass: HomeAssistant, recorder_mock: Recorder ) -> None: @@ -918,6 +926,7 @@ async def test_migrate_null_event_type_ids( ) +@pytest.mark.usefixtures("db_schema_32") async def test_stats_timestamp_conversion_is_reentrant( hass: HomeAssistant, recorder_mock: Recorder ) -> None: @@ -1070,6 +1079,7 @@ async def test_stats_timestamp_conversion_is_reentrant( ] +@pytest.mark.usefixtures("db_schema_32") async def test_stats_timestamp_with_one_by_one( hass: HomeAssistant, recorder_mock: Recorder ) -> None: @@ -1289,6 +1299,7 @@ async def test_stats_timestamp_with_one_by_one( ] +@pytest.mark.usefixtures("db_schema_32") async def test_stats_timestamp_with_one_by_one_removes_duplicates( hass: HomeAssistant, recorder_mock: Recorder ) -> None: @@ -1483,3 +1494,158 @@ async def test_stats_timestamp_with_one_by_one_removes_duplicates( "sum": None, }, ] + + +@pytest.mark.parametrize("persistent_database", [True]) +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage +async def test_migrate_times( + async_test_recorder: RecorderInstanceGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we can migrate times in the statistics tables.""" + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] + now = dt_util.utcnow() + now_timestamp = now.timestamp() + + statistics_kwargs = { + "created": now, + "mean": 0, + "metadata_id": 1, + "min": 0, + "max": 0, + "last_reset": now, + "start": now, + "state": 0, + "sum": 0, + } + mock_metadata = old_db_schema.StatisticMetaData( + has_mean=False, + has_sum=False, + name="Test", + source="sensor", + statistic_id="sensor.test", + unit_of_measurement="cats", + ) + number_of_migrations = 5 + + def _get_index_names(table): + with session_scope(hass=hass) as session: + return inspect(session.connection()).get_indexes(table) + + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + def _add_data(): + with session_scope(hass=hass) as session: + session.add(old_db_schema.StatisticsMeta.from_meta(mock_metadata)) + with session_scope(hass=hass) as session: + session.add(old_db_schema.Statistics(**statistics_kwargs)) + session.add(old_db_schema.StatisticsShortTerm(**statistics_kwargs)) + + await instance.async_add_executor_job(_add_data) + await hass.async_block_till_done() + await instance.async_block_till_done() + + statistics_indexes = await instance.async_add_executor_job( + _get_index_names, "statistics" + ) + statistics_short_term_indexes = await instance.async_add_executor_job( + _get_index_names, "statistics_short_term" + ) + statistics_index_names = {index["name"] for index in statistics_indexes} + statistics_short_term_index_names = { + index["name"] for index in statistics_short_term_indexes + } + + await hass.async_stop() + await hass.async_block_till_done() + + assert "ix_statistics_statistic_id_start" in statistics_index_names + assert ( + "ix_statistics_short_term_statistic_id_start" + in statistics_short_term_index_names + ) + + # Test that the times are migrated during migration from schema 32 + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + await hass.async_block_till_done() + + # We need to wait for all the migration tasks to complete + # before we can check the database. + for _ in range(number_of_migrations): + await instance.async_block_till_done() + await async_wait_recording_done(hass) + + def _get_test_data_from_db(): + with session_scope(hass=hass) as session: + statistics_result = list( + session.query(recorder.db_schema.Statistics) + .join( + recorder.db_schema.StatisticsMeta, + recorder.db_schema.Statistics.metadata_id + == recorder.db_schema.StatisticsMeta.id, + ) + .where( + recorder.db_schema.StatisticsMeta.statistic_id == "sensor.test" + ) + ) + statistics_short_term_result = list( + session.query(recorder.db_schema.StatisticsShortTerm) + .join( + recorder.db_schema.StatisticsMeta, + recorder.db_schema.StatisticsShortTerm.metadata_id + == recorder.db_schema.StatisticsMeta.id, + ) + .where( + recorder.db_schema.StatisticsMeta.statistic_id == "sensor.test" + ) + ) + session.expunge_all() + return statistics_result, statistics_short_term_result + + ( + statistics_result, + statistics_short_term_result, + ) = await instance.async_add_executor_job(_get_test_data_from_db) + + for results in (statistics_result, statistics_short_term_result): + assert len(results) == 1 + assert results[0].created is None + assert results[0].created_ts == now_timestamp + assert results[0].last_reset is None + assert results[0].last_reset_ts == now_timestamp + assert results[0].start is None + assert results[0].start_ts == now_timestamp + + statistics_indexes = await instance.async_add_executor_job( + _get_index_names, "statistics" + ) + statistics_short_term_indexes = await instance.async_add_executor_job( + _get_index_names, "statistics_short_term" + ) + statistics_index_names = {index["name"] for index in statistics_indexes} + statistics_short_term_index_names = { + index["name"] for index in statistics_short_term_indexes + } + + assert "ix_statistics_statistic_id_start" not in statistics_index_names + assert ( + "ix_statistics_short_term_statistic_id_start" + not in statistics_short_term_index_names + ) + + await hass.async_stop() From 3e350bdc906bbc6faf16c77c2ac0e303354c8780 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Tue, 3 Sep 2024 05:22:39 +1000 Subject: [PATCH 0153/1309] Bump aiolifx to 1.0.9 and remove unused HomeKit model prefixes (#125055) Co-authored-by: J. Nick Koston --- homeassistant/components/lifx/manifest.json | 4 +--- homeassistant/generated/zeroconf.py | 8 -------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 08540702736..3ef70f16467 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -17,7 +17,6 @@ "models": [ "LIFX A19", "LIFX A21", - "LIFX B10", "LIFX Beam", "LIFX BR30", "LIFX Candle", @@ -41,7 +40,6 @@ "LIFX Round", "LIFX Square", "LIFX String", - "LIFX T10", "LIFX Tile", "LIFX White", "LIFX Z" @@ -50,7 +48,7 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.0.8", + "aiolifx==1.0.9", "aiolifx-effects==0.3.2", "aiolifx-themes==0.5.0" ] diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 36b0da4a9f4..2e3ffa23ff5 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -68,10 +68,6 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, - "LIFX B10": { - "always_discover": True, - "domain": "lifx", - }, "LIFX BR30": { "always_discover": True, "domain": "lifx", @@ -164,10 +160,6 @@ HOMEKIT = { "always_discover": True, "domain": "lifx", }, - "LIFX T10": { - "always_discover": True, - "domain": "lifx", - }, "LIFX Tile": { "always_discover": True, "domain": "lifx", diff --git a/requirements_all.txt b/requirements_all.txt index 16ff5a9a032..ba6313f6466 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.5.0 # homeassistant.components.lifx -aiolifx==1.0.8 +aiolifx==1.0.9 # homeassistant.components.livisi aiolivisi==0.0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a453a5948fd..98f18156cc0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -258,7 +258,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.5.0 # homeassistant.components.lifx -aiolifx==1.0.8 +aiolifx==1.0.9 # homeassistant.components.livisi aiolivisi==0.0.19 From fb27297df9d0fff953afe824cbbb28eefa2fcf3f Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 2 Sep 2024 21:23:07 +0200 Subject: [PATCH 0154/1309] Fix area registry indexing when there is a name collision (#125050) --- homeassistant/helpers/area_registry.py | 5 +++-- tests/helpers/test_area_registry.py | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 3e101f185ed..5009ec654cf 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -153,22 +153,23 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]): def _index_entry(self, key: str, entry: AreaEntry) -> None: """Index an entry.""" + super()._index_entry(key, entry) if entry.floor_id is not None: self._floors_index[entry.floor_id][key] = True for label in entry.labels: self._labels_index[label][key] = True - super()._index_entry(key, entry) def _unindex_entry( self, key: str, replacement_entry: AreaEntry | None = None ) -> None: + # always call base class before other indices + super()._unindex_entry(key, replacement_entry) entry = self.data[key] if labels := entry.labels: for label in labels: self._unindex_entry_value(key, label, self._labels_index) if floor_id := entry.floor_id: self._unindex_entry_value(key, floor_id, self._floors_index) - return super()._unindex_entry(key, replacement_entry) def get_areas_for_label(self, label: str) -> list[AreaEntry]: """Get areas for label.""" diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index ad571ac50cc..da1947adbc8 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -242,9 +242,12 @@ async def test_update_area_with_same_name_change_case( async def test_update_area_with_name_already_in_use( area_registry: ar.AreaRegistry, + floor_registry: fr.FloorRegistry, ) -> None: """Make sure that we can't update an area with a name already in use.""" - area1 = area_registry.async_create("mock1") + floor = floor_registry.async_create("mock") + floor_id = floor.floor_id + area1 = area_registry.async_create("mock1", floor_id=floor_id) area2 = area_registry.async_create("mock2") with pytest.raises(ValueError) as e_info: @@ -255,6 +258,8 @@ async def test_update_area_with_name_already_in_use( assert area2.name == "mock2" assert len(area_registry.areas) == 2 + assert area_registry.areas.get_areas_for_floor(floor_id) == [area1] + async def test_update_area_with_normalized_name_already_in_use( area_registry: ar.AreaRegistry, From 687cd321426d3d567bee0feb19a6c0c35ef2d1d1 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 2 Sep 2024 21:23:24 +0200 Subject: [PATCH 0155/1309] Handle telegram polling errors (#124327) --- .../components/telegram_bot/polling.py | 16 ++- .../telegram_bot/test_telegram_bot.py | 103 +++++++++++++++++- 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index 45d2ee65b45..bee7f752f6c 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -25,14 +25,22 @@ async def async_setup_platform(hass, bot, config): async def process_error(update: Update, context: CallbackContext) -> None: """Telegram bot error handler.""" + if context.error: + error_callback(context.error, update) + + +def error_callback(error: Exception, update: Update | None = None) -> None: + """Log the error.""" try: - if context.error: - raise context.error + raise error except (TimedOut, NetworkError, RetryAfter): # Long polling timeout or connection problem. Nothing serious. pass except TelegramError: - _LOGGER.error('Update "%s" caused error: "%s"', update, context.error) + if update is not None: + _LOGGER.error('Update "%s" caused error: "%s"', update, error) + else: + _LOGGER.error("%s: %s", error.__class__.__name__, error) class PollBot(BaseTelegramBotEntity): @@ -53,7 +61,7 @@ class PollBot(BaseTelegramBotEntity): """Start the polling task.""" _LOGGER.debug("Starting polling") await self.application.initialize() - await self.application.updater.start_polling() + await self.application.updater.start_polling(error_callback=error_callback) await self.application.start() async def stop_polling(self, event=None): diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index aad758827ca..bdf6ba72fcc 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -1,8 +1,11 @@ """Tests for the telegram_bot component.""" -from unittest.mock import AsyncMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +import pytest from telegram import Update +from telegram.error import NetworkError, RetryAfter, TelegramError, TimedOut from homeassistant.components.telegram_bot import ( ATTR_MESSAGE, @@ -11,6 +14,7 @@ from homeassistant.components.telegram_bot import ( SERVICE_SEND_MESSAGE, ) from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL +from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component @@ -188,6 +192,103 @@ async def test_polling_platform_message_text_update( assert isinstance(events[0].context, Context) +@pytest.mark.parametrize( + ("error", "log_message"), + [ + ( + TelegramError("Telegram error"), + 'caused error: "Telegram error"', + ), + (NetworkError("Network error"), ""), + (RetryAfter(42), ""), + (TimedOut("TimedOut error"), ""), + ], +) +async def test_polling_platform_add_error_handler( + hass: HomeAssistant, + config_polling: dict[str, Any], + update_message_text: dict[str, Any], + caplog: pytest.LogCaptureFixture, + error: Exception, + log_message: str, +) -> None: + """Test polling add error handler.""" + with patch( + "homeassistant.components.telegram_bot.polling.ApplicationBuilder" + ) as application_builder_class: + await async_setup_component( + hass, + DOMAIN, + config_polling, + ) + await hass.async_block_till_done() + + application = ( + application_builder_class.return_value.bot.return_value.build.return_value + ) + application.updater.stop = AsyncMock() + application.stop = AsyncMock() + application.shutdown = AsyncMock() + process_error = application.add_error_handler.call_args[0][0] + application.bot.defaults.tzinfo = None + update = Update.de_json(update_message_text, application.bot) + + await process_error(update, MagicMock(error=error)) + + assert log_message in caplog.text + + +@pytest.mark.parametrize( + ("error", "log_message"), + [ + ( + TelegramError("Telegram error"), + "TelegramError: Telegram error", + ), + (NetworkError("Network error"), ""), + (RetryAfter(42), ""), + (TimedOut("TimedOut error"), ""), + ], +) +async def test_polling_platform_start_polling_error_callback( + hass: HomeAssistant, + config_polling: dict[str, Any], + caplog: pytest.LogCaptureFixture, + error: Exception, + log_message: str, +) -> None: + """Test polling add error handler.""" + with patch( + "homeassistant.components.telegram_bot.polling.ApplicationBuilder" + ) as application_builder_class: + await async_setup_component( + hass, + DOMAIN, + config_polling, + ) + await hass.async_block_till_done() + + application = ( + application_builder_class.return_value.bot.return_value.build.return_value + ) + application.initialize = AsyncMock() + application.updater.start_polling = AsyncMock() + application.start = AsyncMock() + application.updater.stop = AsyncMock() + application.stop = AsyncMock() + application.shutdown = AsyncMock() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + error_callback = application.updater.start_polling.call_args.kwargs[ + "error_callback" + ] + + error_callback(error) + + assert log_message in caplog.text + + async def test_webhook_endpoint_unauthorized_update_doesnt_generate_telegram_text_event( hass: HomeAssistant, webhook_platform, From f760c13e8f8a6fcf7751ca14c74b845d05383587 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Mon, 2 Sep 2024 21:23:38 +0200 Subject: [PATCH 0156/1309] Fix blocking calls for OpenAI conversation (#125010) --- .../components/openai_conversation/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 75b5db23094..0fbda9b7f4a 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -19,6 +19,7 @@ from homeassistant.exceptions import ( ServiceValidationError, ) from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER @@ -88,7 +89,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool: """Set up OpenAI Conversation from a config entry.""" - client = openai.AsyncOpenAI(api_key=entry.data[CONF_API_KEY]) + client = openai.AsyncOpenAI( + api_key=entry.data[CONF_API_KEY], + http_client=get_async_client(hass), + ) + + # Cache current platform data which gets added to each request (caching done by library) + _ = await hass.async_add_executor_job(client.platform_headers) + try: await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) except openai.AuthenticationError as err: From cd89db9bb6636c02adc52db7485d1122f2feccd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Sep 2024 09:26:02 -1000 Subject: [PATCH 0157/1309] Add coverage for late unifiprotect person detection events (#125103) --- .../unifiprotect/test_binary_sensor.py | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index af8ce015955..31669aa62bb 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -575,3 +575,149 @@ async def test_binary_sensor_package_detected( ufp.ws_msg(mock_msg) await hass.async_block_till_done() assert len(state_changes) == 2 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_binary_sensor_person_detected( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, +) -> None: + """Test binary_sensor person detected detection entity.""" + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 15, 15) + + doorbell.smart_detect_settings.object_types.append(SmartDetectObjectType.PERSON) + + _, entity_id = ids_from_device_description( + Platform.BINARY_SENSOR, doorbell, EVENT_SENSORS[3] + ) + + events = async_capture_events(hass, EVENT_STATE_CHANGED) + + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=None, + score=50, + smart_detect_types=[], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + new_camera.is_smart_detected = True + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + event = Event( + model=ModelType.EVENT, + id="test_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=65, + smart_detect_types=[SmartDetectObjectType.PERSON], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] = event.id + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + entity_events = [event for event in events if event.data["entity_id"] == entity_id] + assert len(entity_events) == 3 + assert entity_events[0].data["new_state"].state == STATE_OFF + assert entity_events[1].data["new_state"].state == STATE_ON + assert entity_events[2].data["new_state"].state == STATE_OFF + + # Event is already seen and has end, should now be off + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + # Now send an event that has an end right away + event = Event( + model=ModelType.EVENT, + id="new_event_id", + type=EventType.SMART_DETECT, + start=fixed_now - timedelta(seconds=1), + end=fixed_now + timedelta(seconds=1), + score=80, + smart_detect_types=[SmartDetectObjectType.PERSON], + smart_detect_event_ids=[], + camera_id=doorbell.id, + api=ufp.api, + ) + + new_camera = doorbell.copy() + new_camera.is_smart_detected = True + new_camera.last_smart_detect_event_ids[SmartDetectObjectType.PERSON] = event.id + + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = event + + state_changes: list[HAEvent[EventStateChangedData]] = async_capture_events( + hass, EVENT_STATE_CHANGED + ) + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + assert len(state_changes) == 2 + + on_event = state_changes[0] + state = on_event.data["new_state"] + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + assert state.attributes[ATTR_EVENT_SCORE] == 80 + + off_event = state_changes[1] + state = off_event.data["new_state"] + assert state + assert state.state == STATE_OFF + assert ATTR_EVENT_SCORE not in state.attributes + + # replay and ensure ignored + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert len(state_changes) == 2 From 606524f9e7c0775ad6e566aa84485046a85da448 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Sep 2024 21:33:35 +0200 Subject: [PATCH 0158/1309] Test string timestamps are wiped after migration to schema version 32 (#125091) Co-authored-by: J. Nick Koston --- tests/components/recorder/test_v32_migration.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 8db2b9fa78c..1e00353d02c 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -192,9 +192,12 @@ async def test_migrate_times( assert len(events_result) == 1 assert events_result[0].time_fired_ts == now_timestamp + assert events_result[0].time_fired is None assert len(states_result) == 1 assert states_result[0].last_changed_ts == one_second_past_timestamp assert states_result[0].last_updated_ts == now_timestamp + assert states_result[0].last_changed is None + assert states_result[0].last_updated is None def _get_events_index_names(): with session_scope(hass=hass) as session: From f93259a2f1e62328c7a2b237308767cbcd7d1399 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Sep 2024 09:43:34 -1000 Subject: [PATCH 0159/1309] Bump yalexs to 8.6.0 (#125102) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 5f317a20834..a40c6920136 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.5.5", "yalexs-ble==2.4.3"] + "requirements": ["yalexs==8.6.0", "yalexs-ble==2.4.3"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 9bee7df2e00..030df50a482 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.5.5", "yalexs-ble==2.4.3"] + "requirements": ["yalexs==8.6.0", "yalexs-ble==2.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ba6313f6466..66d3ca23a1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2986,7 +2986,7 @@ yalexs-ble==2.4.3 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.5.5 +yalexs==8.6.0 # homeassistant.components.yeelight yeelight==0.7.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98f18156cc0..88e21eaddf9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2369,7 +2369,7 @@ yalexs-ble==2.4.3 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.5.5 +yalexs==8.6.0 # homeassistant.components.yeelight yeelight==0.7.14 From faefe624f62dba3f8fda95dd570bffb7444f3f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 2 Sep 2024 22:17:24 +0200 Subject: [PATCH 0160/1309] Add Airzone Cloud Aidoo HVAC indoor/outdoor sensors (#125013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/sensor.py | 83 +++++++++++++++++++ .../components/airzone_cloud/strings.json | 27 ++++++ tests/components/airzone_cloud/test_sensor.py | 27 ++++++ 3 files changed, 137 insertions(+) diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index 9f0ee01aca2..70d2fd079d4 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -12,7 +12,16 @@ from aioairzone_cloud.const import ( AZD_AQ_PM_10, AZD_CPU_USAGE, AZD_HUMIDITY, + AZD_INDOOR_EXCHANGER_TEMP, + AZD_INDOOR_RETURN_TEMP, + AZD_INDOOR_WORK_TEMP, AZD_MEMORY_FREE, + AZD_OUTDOOR_CONDENSER_PRESS, + AZD_OUTDOOR_DISCHARGE_TEMP, + AZD_OUTDOOR_ELECTRIC_CURRENT, + AZD_OUTDOOR_EVAPORATOR_PRESS, + AZD_OUTDOOR_EXCHANGER_TEMP, + AZD_OUTDOOR_TEMP, AZD_TEMP, AZD_THERMOSTAT_BATTERY, AZD_THERMOSTAT_COVERAGE, @@ -32,7 +41,9 @@ from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfElectricCurrent, UnitOfInformation, + UnitOfPressure, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback @@ -48,6 +59,78 @@ from .entity import ( ) AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_INDOOR_EXCHANGER_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + translation_key="indoor_exchanger_temp", + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_INDOOR_RETURN_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + translation_key="indoor_return_temp", + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_INDOOR_WORK_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + translation_key="indoor_work_temp", + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_OUTDOOR_CONDENSER_PRESS, + native_unit_of_measurement=UnitOfPressure.KPA, + state_class=SensorStateClass.MEASUREMENT, + translation_key="outdoor_condenser_press", + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_OUTDOOR_DISCHARGE_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + translation_key="outdoor_discharge_temp", + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_OUTDOOR_ELECTRIC_CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + translation_key="outdoor_electric_current", + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_OUTDOOR_EVAPORATOR_PRESS, + native_unit_of_measurement=UnitOfPressure.KPA, + state_class=SensorStateClass.MEASUREMENT, + translation_key="outdoor_evaporator_press", + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_OUTDOOR_EXCHANGER_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + translation_key="outdoor_exchanger_temp", + ), + SensorEntityDescription( + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + key=AZD_OUTDOOR_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + translation_key="outdoor_temp", + ), SensorEntityDescription( device_class=SensorDeviceClass.TEMPERATURE, key=AZD_TEMP, diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json index eb9529c7ca5..523c43f4955 100644 --- a/homeassistant/components/airzone_cloud/strings.json +++ b/homeassistant/components/airzone_cloud/strings.json @@ -45,6 +45,33 @@ "free_memory": { "name": "Free memory" }, + "indoor_exchanger_temp": { + "name": "Indoor exchanger temperature" + }, + "indoor_return_temp": { + "name": "Indoor return temperature" + }, + "indoor_work_temp": { + "name": "Indoor working temperature" + }, + "outdoor_condenser_press": { + "name": "Outdoor condenser pressure" + }, + "outdoor_discharge_temp": { + "name": "Outdoor discharge temperature" + }, + "outdoor_electric_current": { + "name": "Outdoor electric current" + }, + "outdoor_evaporator_press": { + "name": "Outdoor evaporator pressure" + }, + "outdoor_exchanger_temp": { + "name": "Outdoor exchanger temperature" + }, + "outdoor_temp": { + "name": "Outdoor temperature" + }, "thermostat_coverage": { "name": "Signal percentage" } diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index cf291ec23a6..672e10adedb 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -20,6 +20,33 @@ async def test_airzone_create_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.bron_pro_temperature") assert state.state == "20.0" + state = hass.states.get("sensor.bron_pro_indoor_exchanger_temperature") + assert state.state == "26.0" + + state = hass.states.get("sensor.bron_pro_indoor_return_temperature") + assert state.state == "26.0" + + state = hass.states.get("sensor.bron_pro_indoor_working_temperature") + assert state.state == "25.0" + + state = hass.states.get("sensor.bron_pro_outdoor_condenser_pressure") + assert state.state == "150.0" + + state = hass.states.get("sensor.bron_pro_outdoor_discharge_temperature") + assert state.state == "121.0" + + state = hass.states.get("sensor.bron_pro_outdoor_electric_current") + assert state.state == "3.0" + + state = hass.states.get("sensor.bron_pro_outdoor_evaporator_pressure") + assert state.state == "20.0" + + state = hass.states.get("sensor.bron_pro_outdoor_exchanger_temperature") + assert state.state == "-25.0" + + state = hass.states.get("sensor.bron_pro_outdoor_temperature") + assert state.state == "29.0" + # WebServers state = hass.states.get("sensor.webserver_11_22_33_44_55_66_cpu_usage") assert state.state == "32" From 671aaa7e957ffd9603d0c5beb39b10d00d223da6 Mon Sep 17 00:00:00 2001 From: cnico Date: Mon, 2 Sep 2024 23:51:10 +0200 Subject: [PATCH 0161/1309] Bump flipr api to 1.6.1 (#125106) --- homeassistant/components/flipr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flipr/manifest.json b/homeassistant/components/flipr/manifest.json index 1f9b04e3d57..cdd03770bab 100644 --- a/homeassistant/components/flipr/manifest.json +++ b/homeassistant/components/flipr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/flipr", "iot_class": "cloud_polling", "loggers": ["flipr_api"], - "requirements": ["flipr-api==1.6.0"] + "requirements": ["flipr-api==1.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 66d3ca23a1e..9bb204db562 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -895,7 +895,7 @@ fjaraskupan==2.3.0 flexit_bacnet==2.2.1 # homeassistant.components.flipr -flipr-api==1.6.0 +flipr-api==1.6.1 # homeassistant.components.flux_led flux-led==1.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88e21eaddf9..c675c5a0c6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -754,7 +754,7 @@ fjaraskupan==2.3.0 flexit_bacnet==2.2.1 # homeassistant.components.flipr -flipr-api==1.6.0 +flipr-api==1.6.1 # homeassistant.components.flux_led flux-led==1.0.4 From d68ee8dceae7acd2fbdc2be0724847ac0827dcde Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Tue, 3 Sep 2024 00:38:09 +0200 Subject: [PATCH 0162/1309] Replace _host_in_configuration_exists with async_abort_entries_match in solarlog (#125099) * Add diagnostics to solarlog * Fix wrong comment * Move to async_abort_entries_match * Remove obsolete method solarlog_entries * Update tests/components/solarlog/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/solarlog/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/solarlog/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/solarlog/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Amend import of config_entries.SOURCE_USER * Update tests/components/solarlog/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Ruff --------- Co-authored-by: Joost Lekkerkerker --- .../components/solarlog/config_flow.py | 24 ++------- tests/components/solarlog/test_config_flow.py | 51 +++++++------------ 2 files changed, 23 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py index 5d68a16eabe..5f047a9c844 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -10,7 +10,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import HomeAssistant, callback from homeassistant.util import slugify from .const import DEFAULT_HOST, DEFAULT_NAME, DOMAIN @@ -18,14 +17,6 @@ from .const import DEFAULT_HOST, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) -@callback -def solarlog_entries(hass: HomeAssistant) -> set[str]: - """Return the hosts already configured.""" - return { - entry.data[CONF_HOST] for entry in hass.config_entries.async_entries(DOMAIN) - } - - class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for solarlog.""" @@ -36,12 +27,6 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self._errors: dict = {} - def _host_in_configuration_exists(self, host: str) -> bool: - """Return True if host exists in configuration.""" - if host in solarlog_entries(self.hass): - return True - return False - def _parse_url(self, host: str) -> str: """Return parsed host url.""" url = urlparse(host, "http") @@ -72,12 +57,13 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): """Step when user initializes a integration.""" self._errors = {} if user_input is not None: - user_input[CONF_NAME] = slugify(user_input[CONF_NAME]) user_input[CONF_HOST] = self._parse_url(user_input[CONF_HOST]) - if self._host_in_configuration_exists(user_input[CONF_HOST]): - self._errors[CONF_HOST] = "already_configured" - elif await self._test_connection(user_input[CONF_HOST]): + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + user_input[CONF_NAME] = slugify(user_input[CONF_NAME]) + + if await self._test_connection(user_input[CONF_HOST]): return self.async_create_entry( title=user_input[CONF_NAME], data=user_input ) diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py index 223ceec3ebb..b7ae6119893 100644 --- a/tests/components/solarlog/test_config_flow.py +++ b/tests/components/solarlog/test_config_flow.py @@ -5,9 +5,9 @@ from unittest.mock import AsyncMock, patch import pytest from solarlog_cli.solarlog_exceptions import SolarLogConnectionError, SolarLogError -from homeassistant import config_entries from homeassistant.components.solarlog import config_flow from homeassistant.components.solarlog.const import DOMAIN +from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,7 +21,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -60,7 +60,7 @@ async def test_user( ) -> None: """Test user config.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} @@ -125,40 +125,25 @@ async def test_form_exceptions( async def test_abort_if_already_setup(hass: HomeAssistant, test_connect: None) -> None: """Test we abort if the device is already setup.""" - flow = init_config_flow(hass) - MockConfigEntry( - domain="solarlog", data={CONF_NAME: NAME, CONF_HOST: HOST} - ).add_to_hass(hass) - # Should fail, same HOST different NAME (default) - result = await flow.async_step_user( - {CONF_HOST: HOST, CONF_NAME: "solarlog_test_7_8_9", "extended_data": False} + MockConfigEntry(domain=DOMAIN, data={CONF_NAME: NAME, CONF_HOST: HOST}).add_to_hass( + hass ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM - assert result["errors"] == {CONF_HOST: "already_configured"} + assert result["step_id"] == "user" + assert result["errors"] == {} - # Should fail, same HOST and NAME - result = await flow.async_step_user({CONF_HOST: HOST, CONF_NAME: NAME}) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {CONF_HOST: "already_configured"} - - # SHOULD pass, diff HOST (without http://), different NAME - result = await flow.async_step_user( - {CONF_HOST: "2.2.2.2", CONF_NAME: "solarlog_test_7_8_9", "extended_data": False} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: HOST, CONF_NAME: "solarlog_test_7_8_9", "extended_data": False}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "solarlog_test_7_8_9" - assert result["data"][CONF_HOST] == "http://2.2.2.2" - - # SHOULD pass, diff HOST, same NAME - result = await flow.async_step_user( - {CONF_HOST: "http://2.2.2.2", CONF_NAME: NAME, "extended_data": False} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "solarlog_test_1_2_3" - assert result["data"][CONF_HOST] == "http://2.2.2.2" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" async def test_reconfigure_flow( @@ -178,7 +163,7 @@ async def test_reconfigure_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={ - "source": config_entries.SOURCE_RECONFIGURE, + "source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id, }, ) From 0c18b2e7ffe069024cbce77fc011b0b588164673 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 3 Sep 2024 06:57:25 +0200 Subject: [PATCH 0163/1309] Remove `is_on` function from `homeassistant.components` (#125104) * Remove `is_on` method from `homeassistant.components` * Cleanup test --- homeassistant/components/__init__.py | 49 --------------------- tests/components/homeassistant/test_init.py | 10 ----- 2 files changed, 59 deletions(-) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 030e23628d6..d01f51c3951 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -6,52 +6,3 @@ Component design guidelines: format ".". - Each component should publish services only under its own domain. """ - -from __future__ import annotations - -import logging - -from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers.frame import report -from homeassistant.helpers.group import expand_entity_ids - -_LOGGER = logging.getLogger(__name__) - - -def is_on(hass: HomeAssistant, entity_id: str | None = None) -> bool: - """Load up the module to call the is_on method. - - If there is no entity id given we will check all. - """ - report( - ( - "uses homeassistant.components.is_on." - " This is deprecated and will stop working in Home Assistant 2024.9, it" - " should be updated to use the function of the platform directly." - ), - error_if_core=True, - ) - - if entity_id: - entity_ids = expand_entity_ids(hass, [entity_id]) - else: - entity_ids = hass.states.entity_ids() - - for ent_id in entity_ids: - domain = split_entity_id(ent_id)[0] - - try: - component = getattr(hass.components, domain) - - except ImportError: - _LOGGER.error("Failed to call %s.is_on: component not found", domain) - continue - - if not hasattr(component, "is_on"): - _LOGGER.warning("Integration %s has no is_on method", domain) - continue - - if component.is_on(ent_id): - return True - - return False diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index a0902fe62df..a66d13e5ffe 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -7,7 +7,6 @@ import voluptuous as vol import yaml from homeassistant import config -import homeassistant.components as comps from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, ATTR_SAFE_MODE, @@ -46,15 +45,6 @@ from tests.common import ( ) -async def test_is_on(hass: HomeAssistant) -> None: - """Test is_on method.""" - with pytest.raises( - RuntimeError, - match="Detected code that uses homeassistant.components.is_on. This is deprecated and will stop working", - ): - assert comps.is_on(hass, "light.Bowl") - - async def test_turn_on_without_entities(hass: HomeAssistant) -> None: """Test turn_on method without entities.""" await async_setup_component(hass, ha.DOMAIN, {}) From 7c223db1d5df60fb8e2a7fa8818d077b2b751c4f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Sep 2024 07:51:27 +0200 Subject: [PATCH 0164/1309] Remove recorder PostSchemaMigrationTask (#125076) Co-authored-by: J. Nick Koston --- homeassistant/components/recorder/core.py | 4 -- .../components/recorder/migration.py | 63 +++++-------------- homeassistant/components/recorder/tasks.py | 25 -------- 3 files changed, 14 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 96a4f954c71..c0ac1fc1277 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1283,10 +1283,6 @@ class Recorder(threading.Thread): self.event_session = self.get_session() self.event_session.expire_on_commit = False - def _post_schema_migration(self, old_version: int, new_version: int) -> None: - """Run post schema migration tasks.""" - migration.post_schema_migration(self, old_version, new_version) - def _post_migrate_entity_ids(self) -> bool: """Post migrate entity_ids if needed.""" return migration.post_migrate_entity_ids(self) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 3da0bc9abb1..213462e3731 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -99,14 +99,8 @@ from .queries import ( migrate_single_short_term_statistics_row_to_timestamp, migrate_single_statistics_row_to_timestamp, ) -from .statistics import get_start_time -from .tasks import ( - CommitTask, - EntityIDPostMigrationTask, - PostSchemaMigrationTask, - RecorderTask, - StatisticsTimestampMigrationCleanupTask, -) +from .statistics import cleanup_statistics_timestamp_migration, get_start_time +from .tasks import EntityIDPostMigrationTask, RecorderTask from .util import ( database_job_retry_wrapper, execute_stmt_lambda_element, @@ -350,13 +344,6 @@ def migrate_schema_live( states_correct_db_schema(instance, schema_errors) events_correct_db_schema(instance, schema_errors) - start_version = schema_status.start_version - if start_version != SCHEMA_VERSION: - instance.queue_task(PostSchemaMigrationTask(start_version, SCHEMA_VERSION)) - # Make sure the post schema migration task is committed in case - # the next task does not have commit_before = True - instance.queue_task(CommitTask()) - return schema_status @@ -1414,6 +1401,12 @@ class _SchemaVersion32Migrator(_SchemaVersionMigrator, target_version=32): _drop_index(self.session_maker, "events", "ix_events_event_type_time_fired") _drop_index(self.session_maker, "states", "ix_states_last_updated") _drop_index(self.session_maker, "events", "ix_events_time_fired") + with session_scope(session=self.session_maker()) as session: + # In version 31 we migrated all the time_fired, last_updated, and last_changed + # columns to be timestamps. In version 32 we need to wipe the old columns + # since they are no longer used and take up a significant amount of space. + assert self.instance.engine is not None, "engine should never be None" + _wipe_old_string_time_columns(self.instance, self.instance.engine, session) class _SchemaVersion33Migrator(_SchemaVersionMigrator, target_version=33): @@ -1492,6 +1485,12 @@ class _SchemaVersion35Migrator(_SchemaVersionMigrator, target_version=35): # ix_statistics_start and ix_statistics_statistic_id_start are still used # for the post migration cleanup and can be removed in a future version. + # In version 34 we migrated all the created, start, and last_reset + # columns to be timestamps. In version 35 we need to wipe the old columns + # since they are no longer used and take up a significant amount of space. + while not cleanup_statistics_timestamp_migration(self.instance): + pass + class _SchemaVersion36Migrator(_SchemaVersionMigrator, target_version=36): def _apply_update(self) -> None: @@ -1828,40 +1827,6 @@ def _correct_table_character_set_and_collation( ) -def post_schema_migration( - instance: Recorder, - old_version: int, - new_version: int, -) -> None: - """Post schema migration. - - Run any housekeeping tasks after the schema migration has completed. - - Post schema migration is run after the schema migration has completed - and the queue has been processed to ensure that we reduce the memory - pressure since events are held in memory until the queue is processed - which is blocked from being processed until the schema migration is - complete. - """ - if old_version < 32 <= new_version: - # In version 31 we migrated all the time_fired, last_updated, and last_changed - # columns to be timestamps. In version 32 we need to wipe the old columns - # since they are no longer used and take up a significant amount of space. - assert instance.event_session is not None - assert instance.engine is not None - _wipe_old_string_time_columns(instance, instance.engine, instance.event_session) - if old_version < 35 <= new_version: - # In version 34 we migrated all the created, start, and last_reset - # columns to be timestamps. In version 35 we need to wipe the old columns - # since they are no longer used and take up a significant amount of space. - _wipe_old_string_statistics_columns(instance) - - -def _wipe_old_string_statistics_columns(instance: Recorder) -> None: - """Wipe old string statistics columns to save space.""" - instance.queue_task(StatisticsTimestampMigrationCleanupTask()) - - @database_job_retry_wrapper("Wipe old string time columns", 3) def _wipe_old_string_time_columns( instance: Recorder, engine: Engine, session: Session diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 46e529d4909..c51ba2b16ca 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -322,31 +322,6 @@ class SynchronizeTask(RecorderTask): instance.hass.loop.call_soon_threadsafe(self.event.set) -@dataclass(slots=True) -class PostSchemaMigrationTask(RecorderTask): - """Post migration task to update schema.""" - - old_version: int - new_version: int - - def run(self, instance: Recorder) -> None: - """Handle the task.""" - instance._post_schema_migration( # noqa: SLF001 - self.old_version, self.new_version - ) - - -@dataclass(slots=True) -class StatisticsTimestampMigrationCleanupTask(RecorderTask): - """An object to insert into the recorder queue to run a statistics migration cleanup task.""" - - def run(self, instance: Recorder) -> None: - """Run statistics timestamp cleanup task.""" - if not statistics.cleanup_statistics_timestamp_migration(instance): - # Schedule a new statistics migration task if this one didn't finish - instance.queue_task(StatisticsTimestampMigrationCleanupTask()) - - @dataclass(slots=True) class AdjustLRUSizeTask(RecorderTask): """An object to insert into the recorder queue to adjust the LRU size.""" From aa8fe9911362a97c53fcc155af9cb9ad9d7b3b7c Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 3 Sep 2024 16:30:46 +0900 Subject: [PATCH 0165/1309] Add binary_sensor platform to LG Thinq (#125054) * Add binary_sensor entity * Update the document link due to the domain name change * Update casing --------- Co-authored-by: jangwon.lee --- homeassistant/components/lg_thinq/__init__.py | 2 +- .../components/lg_thinq/binary_sensor.py | 115 ++++++++++++++++++ homeassistant/components/lg_thinq/icons.json | 17 +++ .../components/lg_thinq/manifest.json | 2 +- .../components/lg_thinq/strings.json | 17 +++ 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/lg_thinq/binary_sensor.py diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index 259d494902e..a86afc68171 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -19,7 +19,7 @@ from .coordinator import DeviceDataUpdateCoordinator, async_setup_device_coordin type ThinqConfigEntry = ConfigEntry[dict[str, DeviceDataUpdateCoordinator]] -PLATFORMS = [Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lg_thinq/binary_sensor.py b/homeassistant/components/lg_thinq/binary_sensor.py new file mode 100644 index 00000000000..fc6564c7652 --- /dev/null +++ b/homeassistant/components/lg_thinq/binary_sensor.py @@ -0,0 +1,115 @@ +"""Support for binary sensor entities.""" + +from __future__ import annotations + +from thinqconnect import PROPERTY_READABLE, DeviceType +from thinqconnect.devices.const import Property as ThinQProperty +from thinqconnect.integration.homeassistant.property import create_properties + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import ThinqConfigEntry +from .entity import ThinQEntity + +BINARY_SENSOR_DESC: dict[ThinQProperty, BinarySensorEntityDescription] = { + ThinQProperty.RINSE_REFILL: BinarySensorEntityDescription( + key=ThinQProperty.RINSE_REFILL, + translation_key=ThinQProperty.RINSE_REFILL, + ), + ThinQProperty.ECO_FRIENDLY_MODE: BinarySensorEntityDescription( + key=ThinQProperty.ECO_FRIENDLY_MODE, + translation_key=ThinQProperty.ECO_FRIENDLY_MODE, + ), + ThinQProperty.POWER_SAVE_ENABLED: BinarySensorEntityDescription( + key=ThinQProperty.POWER_SAVE_ENABLED, + translation_key=ThinQProperty.POWER_SAVE_ENABLED, + ), + ThinQProperty.REMOTE_CONTROL_ENABLED: BinarySensorEntityDescription( + key=ThinQProperty.REMOTE_CONTROL_ENABLED, + translation_key=ThinQProperty.REMOTE_CONTROL_ENABLED, + ), + ThinQProperty.SABBATH_MODE: BinarySensorEntityDescription( + key=ThinQProperty.SABBATH_MODE, + translation_key=ThinQProperty.SABBATH_MODE, + ), +} + +DEVICE_TYPE_BINARY_SENSOR_MAP: dict[ + DeviceType, tuple[BinarySensorEntityDescription, ...] +] = { + DeviceType.COOKTOP: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), + DeviceType.DISH_WASHER: ( + BINARY_SENSOR_DESC[ThinQProperty.RINSE_REFILL], + BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], + ), + DeviceType.DRYER: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), + DeviceType.OVEN: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), + DeviceType.REFRIGERATOR: ( + BINARY_SENSOR_DESC[ThinQProperty.ECO_FRIENDLY_MODE], + BINARY_SENSOR_DESC[ThinQProperty.POWER_SAVE_ENABLED], + BINARY_SENSOR_DESC[ThinQProperty.SABBATH_MODE], + ), + DeviceType.STYLER: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), + DeviceType.WASHCOMBO_MAIN: ( + BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], + ), + DeviceType.WASHCOMBO_MINI: ( + BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], + ), + DeviceType.WASHER: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), + DeviceType.WASHTOWER_DRYER: ( + BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], + ), + DeviceType.WASHTOWER: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), + DeviceType.WASHTOWER_WASHER: ( + BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], + ), + DeviceType.WINE_CELLAR: (BINARY_SENSOR_DESC[ThinQProperty.SABBATH_MODE],), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ThinqConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up an entry for binary sensor platform.""" + entities: list[ThinQBinarySensorEntity] = [] + for coordinator in entry.runtime_data.values(): + if ( + descriptions := DEVICE_TYPE_BINARY_SENSOR_MAP.get( + coordinator.device_api.device_type + ) + ) is not None: + for description in descriptions: + properties = create_properties( + device_api=coordinator.device_api, + key=description.key, + children_keys=None, + rw_type=PROPERTY_READABLE, + ) + if not properties: + continue + + entities.extend( + ThinQBinarySensorEntity(coordinator, description, prop) + for prop in properties + ) + + if entities: + async_add_entities(entities) + + +class ThinQBinarySensorEntity(ThinQEntity, BinarySensorEntity): + """Represent a thinq binary sensor platform.""" + + def _update_status(self) -> None: + """Update status itself.""" + super()._update_status() + + self._attr_is_on = self.property.get_value_as_bool() diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 6a4ff48494a..550d023d278 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -4,6 +4,23 @@ "operation_power": { "default": "mdi:power" } + }, + "binary_sensor": { + "eco_friendly_mode": { + "default": "mdi:sprout" + }, + "power_save_enabled": { + "default": "mdi:meter-electric" + }, + "remote_control_enabled": { + "default": "mdi:remote" + }, + "rinse_refill": { + "default": "mdi:tune-vertical-variant" + }, + "sabbath_mode": { + "default": "mdi:food-off-outline" + } } } } diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 0fa447a511b..a49b91892f5 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@LG-ThinQ-Integration"], "config_flow": true, "dependencies": [], - "documentation": "https://www.home-assistant.io/integrations/lgthinq/", + "documentation": "https://www.home-assistant.io/integrations/lg_thinq/", "iot_class": "cloud_push", "loggers": ["thinqconnect"], "requirements": ["thinqconnect==0.9.5"] diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 6334fd9a893..472e8b848b7 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -23,6 +23,23 @@ "operation_power": { "name": "Power" } + }, + "binary_sensor": { + "eco_friendly_mode": { + "name": "Eco friendly" + }, + "power_save_enabled": { + "name": "Power saving mode" + }, + "remote_control_enabled": { + "name": "Remote start" + }, + "rinse_refill": { + "name": "Rinse refill needed" + }, + "sabbath_mode": { + "name": "Sabbath" + } } } } From 22b62393041a37a1c9288fb9dbc912be4046d61d Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 3 Sep 2024 11:04:35 +0100 Subject: [PATCH 0166/1309] Convert ring integration to use entry.runtime_data (#125127) --- homeassistant/components/ring/__init__.py | 30 ++++++++----------- .../components/ring/binary_sensor.py | 8 ++--- homeassistant/components/ring/button.py | 8 ++--- homeassistant/components/ring/camera.py | 8 ++--- homeassistant/components/ring/diagnostics.py | 8 ++--- homeassistant/components/ring/light.py | 8 ++--- homeassistant/components/ring/sensor.py | 8 ++--- homeassistant/components/ring/siren.py | 8 ++--- homeassistant/components/ring/switch.py | 8 ++--- tests/components/ring/test_init.py | 4 +-- 10 files changed, 39 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 14ab435fda6..3714802b63a 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -31,7 +31,10 @@ class RingData: notifications_coordinator: RingNotificationsCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type RingConfigEntry = ConfigEntry[RingData] + + +async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool: """Set up a config entry.""" def token_updater(token: dict[str, Any]) -> None: @@ -56,7 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await devices_coordinator.async_config_entry_first_refresh() await notifications_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RingData( + entry.runtime_data = RingData( api=ring, devices=ring.devices(), devices_coordinator=devices_coordinator, @@ -86,11 +89,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: severity=IssueSeverity.WARNING, translation_key="deprecated_service_ring_update", ) - - for info in hass.data[DOMAIN].values(): - ring_data = cast(RingData, info) - await ring_data.devices_coordinator.async_refresh() - await ring_data.notifications_coordinator.async_refresh() + for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN): + await loaded_entry.runtime_data.devices_coordinator.async_refresh() + await loaded_entry.runtime_data.notifications_coordinator.async_refresh() # register service hass.services.async_register(DOMAIN, "update", async_refresh_all) @@ -100,18 +101,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Ring entry.""" - if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - return False + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN].pop(entry.entry_id) + if len(hass.config_entries.async_loaded_entries(DOMAIN)) == 1: + # This is the last loaded entry, clean up service + hass.services.async_remove(DOMAIN, "update") - if len(hass.data[DOMAIN]) != 0: - return True - - # Last entry unloaded, clean up service - hass.services.async_remove(DOMAIN, "update") - - return True + return unload_ok async def async_remove_config_entry_device( diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 2db04cfd461..2fb557ddde0 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -14,12 +14,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingNotificationsCoordinator from .entity import RingBaseEntity @@ -50,11 +48,11 @@ BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ring binary sensors from a config entry.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = entry.runtime_data entities = [ RingBinarySensor( diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py index c8d7d902d18..b9d5cceb373 100644 --- a/homeassistant/components/ring/button.py +++ b/homeassistant/components/ring/button.py @@ -5,12 +5,10 @@ from __future__ import annotations from ring_doorbell import RingOther from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -21,11 +19,11 @@ BUTTON_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the buttons for the Ring devices.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = entry.runtime_data devices_coordinator = ring_data.devices_coordinator async_add_entities( diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index df71de29089..9c66df9d89e 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -12,14 +12,12 @@ from ring_doorbell import RingDoorBell from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -31,11 +29,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = entry.runtime_data devices_coordinator = ring_data.devices_coordinator ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) diff --git a/homeassistant/components/ring/diagnostics.py b/homeassistant/components/ring/diagnostics.py index 2e7604d9f50..cecf26a46a7 100644 --- a/homeassistant/components/ring/diagnostics.py +++ b/homeassistant/components/ring/diagnostics.py @@ -5,11 +5,9 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry TO_REDACT = { "id", @@ -29,10 +27,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RingConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - ring_data: RingData = hass.data[DOMAIN][entry.entry_id] + ring_data = entry.runtime_data devices_data = ring_data.api.devices_data devices_raw = [ devices_data[device_type][device_id] diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 99c4105f4e9..9e29373a3aa 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -8,13 +8,11 @@ from typing import Any from ring_doorbell import RingStickUpCam from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -38,11 +36,11 @@ class OnOffState(StrEnum): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the lights for the Ring devices.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = entry.runtime_data devices_coordinator = ring_data.devices_coordinator async_add_entities( diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index b6849e37d96..83d07dbd9b4 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -21,7 +21,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -31,19 +30,18 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingDeviceT, RingEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a sensor for a Ring device.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = entry.runtime_data devices_coordinator = ring_data.devices_coordinator entities = [ diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 665de07a5bb..f5730d942b8 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -6,12 +6,10 @@ from typing import Any from ring_doorbell import RingChime, RingEventKind from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -20,11 +18,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the sirens for the Ring devices.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = entry.runtime_data devices_coordinator = ring_data.devices_coordinator async_add_entities( diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index effb43cedbe..01d321572ac 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -7,13 +7,11 @@ from typing import Any from ring_doorbell import RingStickUpCam from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import RingData -from .const import DOMAIN +from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import RingEntity, exception_wrap @@ -30,11 +28,11 @@ SKIP_UPDATES_DELAY = timedelta(seconds=5) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + entry: RingConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Create the switches for the Ring devices.""" - ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id] + ring_data = entry.runtime_data devices_coordinator = ring_data.devices_coordinator async_add_entities( diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 4ab3e1bd366..97392e0c93b 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -186,7 +186,7 @@ async def test_error_on_global_update( assert log_msg in caplog.text - assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) @pytest.mark.parametrize( @@ -226,7 +226,7 @@ async def test_error_on_device_update( await hass.async_block_till_done(wait_background_tasks=True) assert log_msg in caplog.text - assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) async def test_issue_deprecated_service_ring_update( From fc24843274ae6086b85662c379774f473068310c Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 3 Sep 2024 12:43:31 +0200 Subject: [PATCH 0167/1309] Fix Onkyo action select_hdmi_output (#125115) * Fix Onkyo service select_hdmi_output * Move Hasskey directly under Onkyo domain --- .../components/onkyo/media_player.py | 72 +++++++++++-------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index acc0459e258..8d8f4d3bfd5 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -11,7 +11,7 @@ import pyeiscp import voluptuous as vol from homeassistant.components.media_player import ( - DOMAIN, + DOMAIN as MEDIA_PLAYER_DOMAIN, PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -28,9 +28,14 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) +DOMAIN = "onkyo" + +DATA_MP_ENTITIES: HassKey[list[dict[str, OnkyoMediaPlayer]]] = HassKey(DOMAIN) + CONF_SOURCES = "sources" CONF_MAX_VOLUME = "max_volume" CONF_RECEIVER_MAX_VOLUME = "receiver_max_volume" @@ -148,6 +153,33 @@ class ReceiverInfo: identifier: str +async def async_register_services(hass: HomeAssistant) -> None: + """Register Onkyo services.""" + + async def async_service_handle(service: ServiceCall) -> None: + """Handle for services.""" + entity_ids = service.data[ATTR_ENTITY_ID] + + targets: list[OnkyoMediaPlayer] = [] + for receiver_entities in hass.data[DATA_MP_ENTITIES]: + targets.extend( + entity + for entity in receiver_entities.values() + if entity.entity_id in entity_ids + ) + + for target in targets: + if service.service == SERVICE_SELECT_HDMI_OUTPUT: + await target.async_select_output(service.data[ATTR_HDMI_OUTPUT]) + + hass.services.async_register( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_HDMI_OUTPUT, + async_service_handle, + schema=ONKYO_SELECT_OUTPUT_SCHEMA, + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -155,29 +187,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Onkyo platform.""" + await async_register_services(hass) + receivers: dict[str, pyeiscp.Connection] = {} # indexed by host - entities: dict[str, dict[str, OnkyoMediaPlayer]] = {} # indexed by host and zone - - async def async_service_handle(service: ServiceCall) -> None: - """Handle for services.""" - entity_ids = service.data[ATTR_ENTITY_ID] - targets = [ - entity - for h in entities.values() - for entity in h.values() - if entity.entity_id in entity_ids - ] - - for target in targets: - if service.service == SERVICE_SELECT_HDMI_OUTPUT: - await target.async_select_output(service.data[ATTR_HDMI_OUTPUT]) - - hass.services.async_register( - DOMAIN, - SERVICE_SELECT_HDMI_OUTPUT, - async_service_handle, - schema=ONKYO_SELECT_OUTPUT_SCHEMA, - ) + all_entities = hass.data.setdefault(DATA_MP_ENTITIES, []) host = config.get(CONF_HOST) name = config.get(CONF_NAME) @@ -188,6 +201,9 @@ async def async_setup_platform( async def async_setup_receiver( info: ReceiverInfo, discovered: bool, name: str | None ) -> None: + entities: dict[str, OnkyoMediaPlayer] = {} + all_entities.append(entities) + @callback def async_onkyo_update_callback( message: tuple[str, str, Any], origin: str @@ -199,7 +215,7 @@ async def async_setup_platform( ) zone, _, value = message - entity = entities[origin].get(zone) + entity = entities.get(zone) if entity is not None: if entity.enabled: entity.process_update(message) @@ -210,7 +226,7 @@ async def async_setup_platform( zone_entity = OnkyoMediaPlayer( receiver, sources, zone, max_volume, receiver_max_volume ) - entities[origin][zone] = zone_entity + entities[zone] = zone_entity async_add_entities([zone_entity]) @callback @@ -221,7 +237,7 @@ async def async_setup_platform( "Receiver (re)connected: %s (%s)", receiver.name, receiver.host ) - for entity in entities[origin].values(): + for entity in entities.values(): entity.backfill_state() _LOGGER.debug("Creating receiver: %s (%s)", info.model_name, info.host) @@ -237,9 +253,7 @@ async def async_setup_platform( receiver.name = name or info.model_name receiver.discovered = discovered - # Store the receiver object and create a dictionary to store its entities. receivers[receiver.host] = receiver - entities[receiver.host] = {} # Discover what zones are available for the receiver by querying the power. # If we get a response for the specific zone, it means it is available. @@ -251,7 +265,7 @@ async def async_setup_platform( main_entity = OnkyoMediaPlayer( receiver, sources, "main", max_volume, receiver_max_volume ) - entities[receiver.host]["main"] = main_entity + entities["main"] = main_entity async_add_entities([main_entity]) if host is not None: From f34b449f61a089d45a920c81040834db69ede97f Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 3 Sep 2024 12:50:05 +0200 Subject: [PATCH 0168/1309] Correct device serial for ViCare integration (#125125) * expose correct serial * adapt inits * adjust _build_entities * adapt inits * add serial data point * update snapshot * apply suggestions * apply suggestions --- .../components/vicare/binary_sensor.py | 78 +++++++----------- homeassistant/components/vicare/button.py | 6 +- homeassistant/components/vicare/climate.py | 2 +- homeassistant/components/vicare/entity.py | 16 ++-- homeassistant/components/vicare/fan.py | 2 +- homeassistant/components/vicare/number.py | 43 +++++----- homeassistant/components/vicare/sensor.py | 79 +++++++------------ .../components/vicare/water_heater.py | 2 +- .../vicare/fixtures/Vitodens300W.json | 17 ++++ .../vicare/snapshots/test_diagnostics.ambr | 18 +++++ 10 files changed, 128 insertions(+), 135 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 2c114d15b85..7fe248fa266 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -112,61 +112,36 @@ def _build_entities( entities: list[ViCareBinarySensor] = [] for device in device_list: - entities.extend(_build_entities_for_device(device.api, device.config)) + # add device entities entities.extend( - _build_entities_for_component( - get_circuits(device.api), device.config, CIRCUIT_SENSORS + ViCareBinarySensor( + description, + device.config, + device.api, ) + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device.api) ) - entities.extend( - _build_entities_for_component( - get_burners(device.api), device.config, BURNER_SENSORS + # add component entities + for component_list, entity_description_list in ( + (get_circuits(device.api), CIRCUIT_SENSORS), + (get_burners(device.api), BURNER_SENSORS), + (get_compressors(device.api), COMPRESSOR_SENSORS), + ): + entities.extend( + ViCareBinarySensor( + description, + device.config, + device.api, + component, + ) + for component in component_list + for description in entity_description_list + if is_supported(description.key, description, component) ) - ) - entities.extend( - _build_entities_for_component( - get_compressors(device.api), device.config, COMPRESSOR_SENSORS - ) - ) return entities -def _build_entities_for_device( - device: PyViCareDevice, - device_config: PyViCareDeviceConfig, -) -> list[ViCareBinarySensor]: - """Create device specific ViCare binary sensor entities.""" - - return [ - ViCareBinarySensor( - device_config, - device, - description, - ) - for description in GLOBAL_SENSORS - if is_supported(description.key, description, device) - ] - - -def _build_entities_for_component( - components: list[PyViCareHeatingDeviceComponent], - device_config: PyViCareDeviceConfig, - entity_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], -) -> list[ViCareBinarySensor]: - """Create component specific ViCare binary sensor entities.""" - - return [ - ViCareBinarySensor( - device_config, - component, - description, - ) - for component in components - for description in entity_descriptions - if is_supported(description.key, description, component) - ] - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -190,12 +165,13 @@ class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): def __init__( self, - device_config: PyViCareDeviceConfig, - api: PyViCareDevice | PyViCareHeatingDeviceComponent, description: ViCareBinarySensorEntityDescription, + device_config: PyViCareDeviceConfig, + device: PyViCareDevice, + component: PyViCareHeatingDeviceComponent | None = None, ) -> None: """Initialize the sensor.""" - super().__init__(device_config, api, description.key) + super().__init__(description.key, device_config, device, component) self.entity_description = description @property diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index f880c39ddea..51a763c1fcc 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -54,9 +54,9 @@ def _build_entities( return [ ViCareButton( + description, device.config, device.api, - description, ) for device in device_list for description in BUTTON_DESCRIPTIONS @@ -87,12 +87,12 @@ class ViCareButton(ViCareEntity, ButtonEntity): def __init__( self, + description: ViCareButtonEntityDescription, device_config: PyViCareDeviceConfig, device: PyViCareDevice, - description: ViCareButtonEntityDescription, ) -> None: """Initialize the button.""" - super().__init__(device_config, device, description.key) + super().__init__(description.key, device_config, device) self.entity_description = description def press(self) -> None: diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index df1cde2abca..4968e565d0b 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -148,7 +148,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): circuit: PyViCareHeatingCircuit, ) -> None: """Initialize the climate device.""" - super().__init__(device_config, device, circuit.id) + super().__init__(circuit.id, device_config, device) self._circuit = circuit self._attributes: dict[str, Any] = {} self._attributes["vicare_programs"] = self._circuit.getPrograms() diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 1bb2993cd3a..eef114b4039 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -2,6 +2,9 @@ from PyViCare.PyViCareDevice import Device as PyViCareDevice from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig +from PyViCare.PyViCareHeatingDevice import ( + HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -16,21 +19,24 @@ class ViCareEntity(Entity): def __init__( self, + unique_id_suffix: str, device_config: PyViCareDeviceConfig, device: PyViCareDevice, - unique_id_suffix: str, + component: PyViCareHeatingDeviceComponent | None = None, ) -> None: """Initialize the entity.""" - self._api = device + self._api: PyViCareDevice | PyViCareHeatingDeviceComponent = ( + component if component else device + ) self._attr_unique_id = f"{device_config.getConfig().serial}-{unique_id_suffix}" # valid for compressors, circuits, burners (HeatingDeviceWithComponent) - if hasattr(device, "id"): - self._attr_unique_id += f"-{device.id}" + if component: + self._attr_unique_id += f"-{component.id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_config.getConfig().serial)}, - serial_number=device_config.getConfig().serial, + serial_number=device.getSerial(), name=device_config.getModel(), manufacturer="Viessmann", model=device_config.getModel(), diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index 5b9dd2787e8..d7dbd037b56 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -129,7 +129,7 @@ class ViCareFan(ViCareEntity, FanEntity): device: PyViCareDevice, ) -> None: """Initialize the fan entity.""" - super().__init__(device_config, device, self._attr_translation_key) + super().__init__(self._attr_translation_key, device_config, device) def update(self) -> None: """Update state of fan.""" diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index a6bb849ce62..ea64fb174e8 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -245,30 +245,30 @@ def _build_entities( ) -> list[ViCareNumber]: """Create ViCare number entities for a device.""" - entities: list[ViCareNumber] = [ - ViCareNumber( - device.config, - device.api, - description, - ) - for device in device_list - for description in DEVICE_ENTITY_DESCRIPTIONS - if is_supported(description.key, description, device.api) - ] - - entities.extend( - [ + entities: list[ViCareNumber] = [] + for device in device_list: + # add device entities + entities.extend( ViCareNumber( - device.config, - circuit, description, + device.config, + device.api, + ) + for description in DEVICE_ENTITY_DESCRIPTIONS + if is_supported(description.key, description, device.api) + ) + # add component entities + entities.extend( + ViCareNumber( + description, + device.config, + device.api, + circuit, ) - for device in device_list for circuit in get_circuits(device.api) for description in CIRCUIT_ENTITY_DESCRIPTIONS if is_supported(description.key, description, circuit) - ] - ) + ) return entities @@ -295,12 +295,13 @@ class ViCareNumber(ViCareEntity, NumberEntity): def __init__( self, - device_config: PyViCareDeviceConfig, - api: PyViCareDevice | PyViCareHeatingDeviceComponent, description: ViCareNumberEntityDescription, + device_config: PyViCareDeviceConfig, + device: PyViCareDevice, + component: PyViCareHeatingDeviceComponent | None = None, ) -> None: """Initialize the number.""" - super().__init__(device_config, api, description.key) + super().__init__(description.key, device_config, device, component) self.entity_description = description @property diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 4ac3c504d9a..bdcb6dfa3aa 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -747,7 +747,6 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ), ) - CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ViCareSensorEntityDescription( key="supply_temperature", @@ -865,61 +864,36 @@ def _build_entities( entities: list[ViCareSensor] = [] for device in device_list: - entities.extend(_build_entities_for_device(device.api, device.config)) + # add device entities entities.extend( - _build_entities_for_component( - get_circuits(device.api), device.config, CIRCUIT_SENSORS + ViCareSensor( + description, + device.config, + device.api, ) + for description in GLOBAL_SENSORS + if is_supported(description.key, description, device.api) ) - entities.extend( - _build_entities_for_component( - get_burners(device.api), device.config, BURNER_SENSORS + # add component entities + for component_list, entity_description_list in ( + (get_circuits(device.api), CIRCUIT_SENSORS), + (get_burners(device.api), BURNER_SENSORS), + (get_compressors(device.api), COMPRESSOR_SENSORS), + ): + entities.extend( + ViCareSensor( + description, + device.config, + device.api, + component, + ) + for component in component_list + for description in entity_description_list + if is_supported(description.key, description, component) ) - ) - entities.extend( - _build_entities_for_component( - get_compressors(device.api), device.config, COMPRESSOR_SENSORS - ) - ) return entities -def _build_entities_for_device( - device: PyViCareDevice, - device_config: PyViCareDeviceConfig, -) -> list[ViCareSensor]: - """Create device specific ViCare sensor entities.""" - - return [ - ViCareSensor( - device_config, - device, - description, - ) - for description in GLOBAL_SENSORS - if is_supported(description.key, description, device) - ] - - -def _build_entities_for_component( - components: list[PyViCareHeatingDeviceComponent], - device_config: PyViCareDeviceConfig, - entity_descriptions: tuple[ViCareSensorEntityDescription, ...], -) -> list[ViCareSensor]: - """Create component specific ViCare sensor entities.""" - - return [ - ViCareSensor( - device_config, - component, - description, - ) - for component in components - for description in entity_descriptions - if is_supported(description.key, description, component) - ] - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -945,12 +919,13 @@ class ViCareSensor(ViCareEntity, SensorEntity): def __init__( self, - device_config: PyViCareDeviceConfig, - api: PyViCareDevice | PyViCareHeatingDeviceComponent, description: ViCareSensorEntityDescription, + device_config: PyViCareDeviceConfig, + device: PyViCareDevice, + component: PyViCareHeatingDeviceComponent | None = None, ) -> None: """Initialize the sensor.""" - super().__init__(device_config, api, description.key) + super().__init__(description.key, device_config, device, component) self.entity_description = description @property diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index c76c6ea81aa..621d2f2a09b 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -113,7 +113,7 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity): circuit: PyViCareHeatingCircuit, ) -> None: """Initialize the DHW water_heater device.""" - super().__init__(device_config, device, circuit.id) + super().__init__(circuit.id, device_config, device) self._circuit = circuit self._attributes: dict[str, Any] = {} diff --git a/tests/components/vicare/fixtures/Vitodens300W.json b/tests/components/vicare/fixtures/Vitodens300W.json index 4cf67ebe0f7..bb86bda981b 100644 --- a/tests/components/vicare/fixtures/Vitodens300W.json +++ b/tests/components/vicare/fixtures/Vitodens300W.json @@ -1,5 +1,22 @@ { "data": [ + { + "apiVersion": 1, + "commands": {}, + "deviceId": "0", + "feature": "device.serial", + "gatewayId": "################", + "isEnabled": true, + "isReady": true, + "properties": { + "value": { + "type": "string", + "value": "################" + } + }, + "timestamp": "2024-03-20T01:29:35.549Z", + "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial" + }, { "properties": {}, "commands": {}, diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index dfc29d46cc2..430b2de35ad 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4,6 +4,24 @@ 'data': list([ dict({ 'data': list([ + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'deviceId': '0', + 'feature': 'device.serial', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'string', + 'value': '################', + }), + }), + 'timestamp': '2024-03-20T01:29:35.549Z', + 'uri': 'https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial', + }), dict({ 'apiVersion': 1, 'commands': dict({ From b9db9eeab22d7ca3b1e456e7b3ba188251d48c82 Mon Sep 17 00:00:00 2001 From: Philip Vanloo <26272906+dukeofphilberg@users.noreply.github.com> Date: Tue, 3 Sep 2024 13:34:47 +0200 Subject: [PATCH 0169/1309] Add Linkplay mTLS/HTTPS and improve logging (#124307) * Work * Implement 0.0.8 changes, fixup tests * Cleanup * Implement new playmodes, close clientsession upon ha close * Implement new playmodes, close clientsession upon ha close * Add test for zeroconf bridge failure * Bump 0.0.9 Address old comments in 113940 * Exact _async_register_default_clientsession_shutdown --- homeassistant/components/linkplay/__init__.py | 24 ++++++---- .../components/linkplay/config_flow.py | 40 ++++++++++++---- homeassistant/components/linkplay/const.py | 1 + .../components/linkplay/manifest.json | 2 +- .../components/linkplay/media_player.py | 11 +++++ homeassistant/components/linkplay/utils.py | 27 +++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/linkplay/conftest.py | 9 +++- tests/components/linkplay/test_config_flow.py | 48 +++++++++++++------ 10 files changed, 128 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/linkplay/__init__.py b/homeassistant/components/linkplay/__init__.py index c0fe711a61b..808f2f93ce2 100644 --- a/homeassistant/components/linkplay/__init__.py +++ b/homeassistant/components/linkplay/__init__.py @@ -1,17 +1,22 @@ """Support for LinkPlay devices.""" +from dataclasses import dataclass + +from aiohttp import ClientSession from linkplay.bridge import LinkPlayBridge -from linkplay.discovery import linkplay_factory_bridge +from linkplay.discovery import linkplay_factory_httpapi_bridge +from linkplay.exceptions import LinkPlayRequestException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import PLATFORMS +from .utils import async_get_client_session +@dataclass class LinkPlayData: """Data for LinkPlay.""" @@ -24,16 +29,17 @@ type LinkPlayConfigEntry = ConfigEntry[LinkPlayData] async def async_setup_entry(hass: HomeAssistant, entry: LinkPlayConfigEntry) -> bool: """Async setup hass config entry. Called when an entry has been setup.""" - session = async_get_clientsession(hass) - if ( - bridge := await linkplay_factory_bridge(entry.data[CONF_HOST], session) - ) is None: + session: ClientSession = await async_get_client_session(hass) + bridge: LinkPlayBridge | None = None + + try: + bridge = await linkplay_factory_httpapi_bridge(entry.data[CONF_HOST], session) + except LinkPlayRequestException as exception: raise ConfigEntryNotReady( f"Failed to connect to LinkPlay device at {entry.data[CONF_HOST]}" - ) + ) from exception - entry.runtime_data = LinkPlayData() - entry.runtime_data.bridge = bridge + entry.runtime_data = LinkPlayData(bridge=bridge) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/linkplay/config_flow.py b/homeassistant/components/linkplay/config_flow.py index 0f9c40d0fd4..7dfdce238ff 100644 --- a/homeassistant/components/linkplay/config_flow.py +++ b/homeassistant/components/linkplay/config_flow.py @@ -1,16 +1,22 @@ """Config flow to configure LinkPlay component.""" +import logging from typing import Any -from linkplay.discovery import linkplay_factory_bridge +from aiohttp import ClientSession +from linkplay.bridge import LinkPlayBridge +from linkplay.discovery import linkplay_factory_httpapi_bridge +from linkplay.exceptions import LinkPlayRequestException import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +from .utils import async_get_client_session + +_LOGGER = logging.getLogger(__name__) class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN): @@ -25,10 +31,15 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle Zeroconf discovery.""" - session = async_get_clientsession(self.hass) - bridge = await linkplay_factory_bridge(discovery_info.host, session) + session: ClientSession = await async_get_client_session(self.hass) + bridge: LinkPlayBridge | None = None - if bridge is None: + try: + bridge = await linkplay_factory_httpapi_bridge(discovery_info.host, session) + except LinkPlayRequestException: + _LOGGER.exception( + "Failed to connect to LinkPlay device at %s", discovery_info.host + ) return self.async_abort(reason="cannot_connect") self.data[CONF_HOST] = discovery_info.host @@ -66,14 +77,26 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors: dict[str, str] = {} if user_input: - session = async_get_clientsession(self.hass) - bridge = await linkplay_factory_bridge(user_input[CONF_HOST], session) + session: ClientSession = await async_get_client_session(self.hass) + bridge: LinkPlayBridge | None = None + + try: + bridge = await linkplay_factory_httpapi_bridge( + user_input[CONF_HOST], session + ) + except LinkPlayRequestException: + _LOGGER.exception( + "Failed to connect to LinkPlay device at %s", user_input[CONF_HOST] + ) + errors["base"] = "cannot_connect" if bridge is not None: self.data[CONF_HOST] = user_input[CONF_HOST] self.data[CONF_MODEL] = bridge.device.name - await self.async_set_unique_id(bridge.device.uuid) + await self.async_set_unique_id( + bridge.device.uuid, raise_on_progress=False + ) self._abort_if_unique_id_configured( updates={CONF_HOST: self.data[CONF_HOST]} ) @@ -83,7 +106,6 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN): data={CONF_HOST: self.data[CONF_HOST]}, ) - errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", data_schema=vol.Schema({vol.Required(CONF_HOST): str}), diff --git a/homeassistant/components/linkplay/const.py b/homeassistant/components/linkplay/const.py index 48ae225dd98..91a427d5eb8 100644 --- a/homeassistant/components/linkplay/const.py +++ b/homeassistant/components/linkplay/const.py @@ -4,3 +4,4 @@ from homeassistant.const import Platform DOMAIN = "linkplay" PLATFORMS = [Platform.MEDIA_PLAYER] +CONF_SESSION = "session" diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index 5212f3f99b8..66a719c640e 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.8"], + "requirements": ["python-linkplay==0.0.9"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 398add235bd..0b62b4dbcee 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -48,6 +48,17 @@ SOURCE_MAP: dict[PlayingMode, str] = { PlayingMode.XLR: "XLR", PlayingMode.HDMI: "HDMI", PlayingMode.OPTICAL_2: "Optical 2", + PlayingMode.EXTERN_BLUETOOTH: "External Bluetooth", + PlayingMode.PHONO: "Phono", + PlayingMode.ARC: "ARC", + PlayingMode.COAXIAL_2: "Coaxial 2", + PlayingMode.TF_CARD_1: "SD Card 1", + PlayingMode.TF_CARD_2: "SD Card 2", + PlayingMode.CD: "CD", + PlayingMode.DAB: "DAB Radio", + PlayingMode.FM: "FM Radio", + PlayingMode.RCA: "RCA", + PlayingMode.UDISK: "USB", } SOURCE_MAP_INV: dict[str, PlayingMode] = {v: k for k, v in SOURCE_MAP.items()} diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 7532c9b354a..7f15e297145 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -2,6 +2,14 @@ from typing import Final +from aiohttp import ClientSession +from linkplay.utils import async_create_unverified_client_session + +from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE +from homeassistant.core import Event, HomeAssistant, callback + +from .const import CONF_SESSION, DOMAIN + MANUFACTURER_ARTSOUND: Final[str] = "ArtSound" MANUFACTURER_ARYLIC: Final[str] = "Arylic" MANUFACTURER_IEAST: Final[str] = "iEAST" @@ -44,3 +52,22 @@ def get_info_from_project(project: str) -> tuple[str, str]: return MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5 case _: return MANUFACTURER_GENERIC, MODELS_GENERIC + + +async def async_get_client_session(hass: HomeAssistant) -> ClientSession: + """Get a ClientSession that can be used with LinkPlay devices.""" + hass.data.setdefault(DOMAIN, {}) + if CONF_SESSION not in hass.data[DOMAIN]: + clientsession: ClientSession = await async_create_unverified_client_session() + + @callback + def _async_close_websession(event: Event) -> None: + """Close websession.""" + clientsession.detach() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_websession) + hass.data[DOMAIN][CONF_SESSION] = clientsession + return clientsession + + session: ClientSession = hass.data[DOMAIN][CONF_SESSION] + return session diff --git a/requirements_all.txt b/requirements_all.txt index 9bb204db562..da902149cfd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2323,7 +2323,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.2 # homeassistant.components.linkplay -python-linkplay==0.0.8 +python-linkplay==0.0.9 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c675c5a0c6a..67986dd3a53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1844,7 +1844,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.7.2 # homeassistant.components.linkplay -python-linkplay==0.0.8 +python-linkplay==0.0.9 # homeassistant.components.matter python-matter-server==6.3.0 diff --git a/tests/components/linkplay/conftest.py b/tests/components/linkplay/conftest.py index b3d65422e08..be83dd2412d 100644 --- a/tests/components/linkplay/conftest.py +++ b/tests/components/linkplay/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from aiohttp import ClientSession from linkplay.bridge import LinkPlayBridge, LinkPlayDevice import pytest @@ -14,11 +15,15 @@ NAME = "Smart Zone 1_54B9" @pytest.fixture def mock_linkplay_factory_bridge() -> Generator[AsyncMock]: - """Mock for linkplay_factory_bridge.""" + """Mock for linkplay_factory_httpapi_bridge.""" with ( patch( - "homeassistant.components.linkplay.config_flow.linkplay_factory_bridge" + "homeassistant.components.linkplay.config_flow.async_get_client_session", + return_value=AsyncMock(spec=ClientSession), + ), + patch( + "homeassistant.components.linkplay.config_flow.linkplay_factory_httpapi_bridge", ) as factory, ): bridge = AsyncMock(spec=LinkPlayBridge) diff --git a/tests/components/linkplay/test_config_flow.py b/tests/components/linkplay/test_config_flow.py index 641f09893c2..3fd1fbea95e 100644 --- a/tests/components/linkplay/test_config_flow.py +++ b/tests/components/linkplay/test_config_flow.py @@ -3,6 +3,9 @@ from ipaddress import ip_address from unittest.mock import AsyncMock +from linkplay.exceptions import LinkPlayRequestException +import pytest + from homeassistant.components.linkplay.const import DOMAIN from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF @@ -47,10 +50,9 @@ ZEROCONF_DISCOVERY_RE_ENTRY = ZeroconfServiceInfo( ) +@pytest.mark.usefixtures("mock_linkplay_factory_bridge", "mock_setup_entry") async def test_user_flow( hass: HomeAssistant, - mock_linkplay_factory_bridge: AsyncMock, - mock_setup_entry: AsyncMock, ) -> None: """Test user setup config flow.""" result = await hass.config_entries.flow.async_init( @@ -74,10 +76,9 @@ async def test_user_flow( assert result["result"].unique_id == UUID +@pytest.mark.usefixtures("mock_linkplay_factory_bridge") async def test_user_flow_re_entry( hass: HomeAssistant, - mock_linkplay_factory_bridge: AsyncMock, - mock_setup_entry: AsyncMock, ) -> None: """Test user setup config flow when an entry with the same unique id already exists.""" @@ -105,10 +106,9 @@ async def test_user_flow_re_entry( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_linkplay_factory_bridge", "mock_setup_entry") async def test_zeroconf_flow( hass: HomeAssistant, - mock_linkplay_factory_bridge: AsyncMock, - mock_setup_entry: AsyncMock, ) -> None: """Test Zeroconf flow.""" result = await hass.config_entries.flow.async_init( @@ -133,10 +133,9 @@ async def test_zeroconf_flow( assert result["result"].unique_id == UUID +@pytest.mark.usefixtures("mock_linkplay_factory_bridge") async def test_zeroconf_flow_re_entry( hass: HomeAssistant, - mock_linkplay_factory_bridge: AsyncMock, - mock_setup_entry: AsyncMock, ) -> None: """Test Zeroconf flow when an entry with the same unique id already exists.""" @@ -160,16 +159,35 @@ async def test_zeroconf_flow_re_entry( assert result["reason"] == "already_configured" -async def test_flow_errors( +@pytest.mark.usefixtures("mock_setup_entry") +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + mock_linkplay_factory_bridge: AsyncMock, +) -> None: + """Test flow when the device discovered through Zeroconf cannot be reached.""" + + # Temporarily make the mock_linkplay_factory_bridge throw an exception + mock_linkplay_factory_bridge.side_effect = (LinkPlayRequestException("Error"),) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_errors( hass: HomeAssistant, mock_linkplay_factory_bridge: AsyncMock, - mock_setup_entry: AsyncMock, ) -> None: """Test flow when the device cannot be reached.""" - # Temporarily store bridge in a separate variable and set factory to return None - bridge = mock_linkplay_factory_bridge.return_value - mock_linkplay_factory_bridge.return_value = None + # Temporarily make the mock_linkplay_factory_bridge throw an exception + mock_linkplay_factory_bridge.side_effect = (LinkPlayRequestException("Error"),) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -188,8 +206,8 @@ async def test_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {"base": "cannot_connect"} - # Make linkplay_factory_bridge return a mock bridge again - mock_linkplay_factory_bridge.return_value = bridge + # Make mock_linkplay_factory_bridge_exception no longer throw an exception + mock_linkplay_factory_bridge.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], From c07a9e9d593aa57976426ed29e85bc8d00640bfc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 3 Sep 2024 04:54:43 -0700 Subject: [PATCH 0170/1309] Add dependency on google-photos-library-api: Change the Google Photos client library to a new external package (#125040) * Change the Google Photos client library to a new external package * Remove mime type guessing * Update tests to mock out the client library and iterators * Update homeassistant/components/google_photos/media_source.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/google_photos/__init__.py | 13 +- homeassistant/components/google_photos/api.py | 198 ++---------------- .../components/google_photos/config_flow.py | 15 +- .../components/google_photos/exceptions.py | 7 - .../components/google_photos/manifest.json | 4 +- .../components/google_photos/media_source.py | 105 +++++----- .../components/google_photos/services.py | 44 +++- .../components/google_photos/strings.json | 6 + .../components/google_photos/types.py | 7 + requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- tests/components/google_photos/conftest.py | 113 ++++++---- .../fixtures/api_not_enabled_response.json | 17 -- .../google_photos/fixtures/list_albums.json | 1 + .../google_photos/fixtures/not_dict.json | 1 - .../google_photos/test_config_flow.py | 45 ++-- .../google_photos/test_media_source.py | 58 ++--- .../components/google_photos/test_services.py | 51 ++--- 18 files changed, 281 insertions(+), 412 deletions(-) delete mode 100644 homeassistant/components/google_photos/exceptions.py create mode 100644 homeassistant/components/google_photos/types.py delete mode 100644 tests/components/google_photos/fixtures/api_not_enabled_response.json delete mode 100644 tests/components/google_photos/fixtures/not_dict.json diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index ee02c695f16..950995e72c0 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -3,17 +3,17 @@ from __future__ import annotations from aiohttp import ClientError, ClientResponseError +from google_photos_library_api.api import GooglePhotosLibraryApi -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import api from .const import DOMAIN from .services import async_register_services - -type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] +from .types import GooglePhotosConfigEntry __all__ = [ "DOMAIN", @@ -29,8 +29,9 @@ async def async_setup_entry( hass, entry ) ) - session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - auth = api.AsyncConfigEntryAuth(hass, session) + web_session = async_get_clientsession(hass) + oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = api.AsyncConfigEntryAuth(web_session, oauth_session) try: await auth.async_get_access_token() except ClientResponseError as err: @@ -41,7 +42,7 @@ async def async_setup_entry( raise ConfigEntryNotReady from err except ClientError as err: raise ConfigEntryNotReady from err - entry.runtime_data = auth + entry.runtime_data = GooglePhotosLibraryApi(auth) async_register_services(hass) diff --git a/homeassistant/components/google_photos/api.py b/homeassistant/components/google_photos/api.py index 0bbb2fe162b..35878efd792 100644 --- a/homeassistant/components/google_photos/api.py +++ b/homeassistant/components/google_photos/api.py @@ -1,216 +1,44 @@ """API for Google Photos bound to Home Assistant OAuth.""" -from abc import ABC, abstractmethod -from functools import partial -import logging -from typing import Any, cast +from typing import cast -from aiohttp.client_exceptions import ClientError -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build -from googleapiclient.errors import HttpError -from googleapiclient.http import HttpRequest +import aiohttp +from google_photos_library_api import api from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow - -from .exceptions import GooglePhotosApiError - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_PAGE_SIZE = 20 - -# Only included necessary fields to limit response sizes -GET_MEDIA_ITEM_FIELDS = ( - "id,baseUrl,mimeType,filename,mediaMetadata(width,height,photo,video)" -) -LIST_MEDIA_ITEM_FIELDS = f"nextPageToken,mediaItems({GET_MEDIA_ITEM_FIELDS})" -UPLOAD_API = "https://photoslibrary.googleapis.com/v1/uploads" -LIST_ALBUMS_FIELDS = ( - "nextPageToken,albums(id,title,coverPhotoBaseUrl,coverPhotoMediaItemId)" -) +from homeassistant.helpers import config_entry_oauth2_flow -class AuthBase(ABC): - """Base class for Google Photos authentication library. - - Provides an asyncio interface around the blocking client library. - """ - - def __init__( - self, - hass: HomeAssistant, - ) -> None: - """Initialize Google Photos auth.""" - self._hass = hass - - @abstractmethod - async def async_get_access_token(self) -> str: - """Return a valid access token.""" - - async def get_user_info(self) -> dict[str, Any]: - """Get the user profile info.""" - service = await self._get_profile_service() - cmd: HttpRequest = service.userinfo().get() - return await self._execute(cmd) - - async def get_media_item(self, media_item_id: str) -> dict[str, Any]: - """Get all MediaItem resources.""" - service = await self._get_photos_service() - cmd: HttpRequest = service.mediaItems().get( - mediaItemId=media_item_id, fields=GET_MEDIA_ITEM_FIELDS - ) - return await self._execute(cmd) - - async def list_media_items( - self, - page_size: int | None = None, - page_token: str | None = None, - album_id: str | None = None, - favorites: bool = False, - ) -> dict[str, Any]: - """Get all MediaItem resources.""" - service = await self._get_photos_service() - args: dict[str, Any] = { - "pageSize": (page_size or DEFAULT_PAGE_SIZE), - "pageToken": page_token, - } - cmd: HttpRequest - if album_id is not None or favorites: - if album_id is not None: - args["albumId"] = album_id - if favorites: - args["filters"] = {"featureFilter": {"includedFeatures": "FAVORITES"}} - cmd = service.mediaItems().search(body=args, fields=LIST_MEDIA_ITEM_FIELDS) - else: - cmd = service.mediaItems().list(**args, fields=LIST_MEDIA_ITEM_FIELDS) - return await self._execute(cmd) - - async def list_albums( - self, page_size: int | None = None, page_token: str | None = None - ) -> dict[str, Any]: - """Get all Album resources.""" - service = await self._get_photos_service() - cmd: HttpRequest = service.albums().list( - pageSize=(page_size or DEFAULT_PAGE_SIZE), - pageToken=page_token, - fields=LIST_ALBUMS_FIELDS, - ) - return await self._execute(cmd) - - async def upload_content(self, content: bytes, mime_type: str) -> str: - """Upload media content to the API and return an upload token.""" - token = await self.async_get_access_token() - session = aiohttp_client.async_get_clientsession(self._hass) - try: - result = await session.post( - UPLOAD_API, headers=_upload_headers(token, mime_type), data=content - ) - result.raise_for_status() - return await result.text() - except ClientError as err: - raise GooglePhotosApiError(f"Failed to upload content: {err}") from err - - async def create_media_items(self, upload_tokens: list[str]) -> list[str]: - """Create a batch of media items and return the ids.""" - service = await self._get_photos_service() - cmd: HttpRequest = service.mediaItems().batchCreate( - body={ - "newMediaItems": [ - { - "simpleMediaItem": { - "uploadToken": upload_token, - } - for upload_token in upload_tokens - } - ] - } - ) - result = await self._execute(cmd) - return [ - media_item["mediaItem"]["id"] - for media_item in result["newMediaItemResults"] - ] - - async def _get_photos_service(self) -> Resource: - """Get current photos library API resource.""" - token = await self.async_get_access_token() - return await self._hass.async_add_executor_job( - partial( - build, - "photoslibrary", - "v1", - credentials=Credentials(token=token), # type: ignore[no-untyped-call] - static_discovery=False, - ) - ) - - async def _get_profile_service(self) -> Resource: - """Get current profile service API resource.""" - token = await self.async_get_access_token() - return await self._hass.async_add_executor_job( - partial(build, "oauth2", "v2", credentials=Credentials(token=token)) # type: ignore[no-untyped-call] - ) - - async def _execute(self, request: HttpRequest) -> dict[str, Any]: - try: - result = await self._hass.async_add_executor_job(request.execute) - except HttpError as err: - raise GooglePhotosApiError( - f"Google Photos API responded with error ({err.status_code}): {err.reason}" - ) from err - if not isinstance(result, dict): - raise GooglePhotosApiError( - f"Google Photos API replied with unexpected response: {result}" - ) - if error := result.get("error"): - message = error.get("message", "Unknown Error") - raise GooglePhotosApiError(f"Google Photos API response: {message}") - return cast(dict[str, Any], result) - - -class AsyncConfigEntryAuth(AuthBase): +class AsyncConfigEntryAuth(api.AbstractAuth): """Provide Google Photos authentication tied to an OAuth2 based config entry.""" def __init__( self, - hass: HomeAssistant, + websession: aiohttp.ClientSession, oauth_session: config_entry_oauth2_flow.OAuth2Session, ) -> None: """Initialize AsyncConfigEntryAuth.""" - super().__init__(hass) - self._oauth_session = oauth_session + super().__init__(websession) + self._session = oauth_session async def async_get_access_token(self) -> str: """Return a valid access token.""" - if not self._oauth_session.valid_token: - await self._oauth_session.async_ensure_token_valid() - return cast(str, self._oauth_session.token[CONF_ACCESS_TOKEN]) + await self._session.async_ensure_token_valid() + return cast(str, self._session.token[CONF_ACCESS_TOKEN]) -class AsyncConfigFlowAuth(AuthBase): +class AsyncConfigFlowAuth(api.AbstractAuth): """An API client used during the config flow with a fixed token.""" def __init__( self, - hass: HomeAssistant, + websession: aiohttp.ClientSession, token: str, ) -> None: """Initialize ConfigFlowAuth.""" - super().__init__(hass) + super().__init__(websession) self._token = token async def async_get_access_token(self) -> str: """Return a valid access token.""" return self._token - - -def _upload_headers(token: str, mime_type: str) -> dict[str, Any]: - """Create the upload headers.""" - return { - "Authorization": f"Bearer {token}", - "Content-Type": "application/octet-stream", - "X-Goog-Upload-Content-Type": mime_type, - "X-Goog-Upload-Protocol": "raw", - } diff --git a/homeassistant/components/google_photos/config_flow.py b/homeassistant/components/google_photos/config_flow.py index e5378f67ffd..6b025cac6be 100644 --- a/homeassistant/components/google_photos/config_flow.py +++ b/homeassistant/components/google_photos/config_flow.py @@ -4,13 +4,15 @@ from collections.abc import Mapping import logging from typing import Any +from google_photos_library_api.api import GooglePhotosLibraryApi +from google_photos_library_api.exceptions import GooglePhotosApiError + from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN -from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import GooglePhotosConfigEntry, api from .const import DOMAIN, OAUTH2_SCOPES -from .exceptions import GooglePhotosApiError class OAuth2FlowHandler( @@ -39,7 +41,10 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: """Create an entry for the flow.""" - client = api.AsyncConfigFlowAuth(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + session = aiohttp_client.async_get_clientsession(self.hass) + auth = api.AsyncConfigFlowAuth(session, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + client = GooglePhotosLibraryApi(auth) + try: user_resource_info = await client.get_user_info() await client.list_media_items(page_size=1) @@ -51,7 +56,7 @@ class OAuth2FlowHandler( except Exception: self.logger.exception("Unknown error occurred") return self.async_abort(reason="unknown") - user_id = user_resource_info["id"] + user_id = user_resource_info.id if self.reauth_entry: if self.reauth_entry.unique_id == user_id: @@ -62,7 +67,7 @@ class OAuth2FlowHandler( await self.async_set_unique_id(user_id) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user_resource_info["name"], data=data) + return self.async_create_entry(title=user_resource_info.name, data=data) async def async_step_reauth( self, entry_data: Mapping[str, Any] diff --git a/homeassistant/components/google_photos/exceptions.py b/homeassistant/components/google_photos/exceptions.py deleted file mode 100644 index b1a40688677..00000000000 --- a/homeassistant/components/google_photos/exceptions.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Exceptions for Google Photos api calls.""" - -from homeassistant.exceptions import HomeAssistantError - - -class GooglePhotosApiError(HomeAssistantError): - """Error talking to the Google Photos API.""" diff --git a/homeassistant/components/google_photos/manifest.json b/homeassistant/components/google_photos/manifest.json index 3fefb6cf610..5ff37135f9a 100644 --- a/homeassistant/components/google_photos/manifest.json +++ b/homeassistant/components/google_photos/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/google_photos", "iot_class": "cloud_polling", - "loggers": ["googleapiclient"], - "requirements": ["google-api-python-client==2.71.0"] + "loggers": ["google_photos_library_api"], + "requirements": ["google-photos-library-api==0.8.0"] } diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index a709dd66a0a..63d66d5a82b 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -5,6 +5,9 @@ from enum import Enum, StrEnum import logging from typing import Any, Self, cast +from google_photos_library_api.exceptions import GooglePhotosApiError +from google_photos_library_api.model import Album, MediaItem + from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source import ( BrowseError, @@ -17,17 +20,12 @@ from homeassistant.core import HomeAssistant from . import GooglePhotosConfigEntry from .const import DOMAIN, READ_SCOPES -from .exceptions import GooglePhotosApiError _LOGGER = logging.getLogger(__name__) -# Media Sources do not support paging, so we only show a subset of recent -# photos when displaying the users library. We fetch a minimum of 50 photos -# unless we run out, but in pages of 100 at a time given sometimes responses -# may only contain a handful of items Fetches at least 50 photos. -MAX_RECENT_PHOTOS = 50 -MAX_ALBUMS = 50 -PAGE_SIZE = 100 +MAX_RECENT_PHOTOS = 100 +MEDIA_ITEMS_PAGE_SIZE = 100 +ALBUM_PAGE_SIZE = 50 THUMBNAIL_SIZE = 256 LARGE_IMAGE_SIZE = 2160 @@ -158,14 +156,15 @@ class GooglePhotosMediaSource(MediaSource): entry = self._async_config_entry(identifier.config_entry_id) client = entry.runtime_data media_item = await client.get_media_item(media_item_id=identifier.media_id) - is_video = media_item["mediaMetadata"].get("video") is not None + if not media_item.mime_type: + raise BrowseError("Could not determine mime type of media item") + if media_item.media_metadata and (media_item.media_metadata.video is not None): + url = _video_url(media_item) + else: + url = _media_url(media_item, LARGE_IMAGE_SIZE) return PlayMedia( - url=( - _video_url(media_item) - if is_video - else _media_url(media_item, LARGE_IMAGE_SIZE) - ), - mime_type=media_item["mimeType"], + url=url, + mime_type=media_item.mime_type, ) async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: @@ -199,7 +198,6 @@ class GooglePhotosMediaSource(MediaSource): source = _build_account(entry, identifier) if identifier.id_type is None: - result = await client.list_albums(page_size=MAX_ALBUMS) source.children = [ _build_album( special_album.value.title, @@ -208,17 +206,27 @@ class GooglePhotosMediaSource(MediaSource): ), ) for special_album in SpecialAlbum - ] + [ + ] + albums: list[Album] = [] + try: + async for album_result in await client.list_albums( + page_size=ALBUM_PAGE_SIZE + ): + albums.extend(album_result.albums) + except GooglePhotosApiError as err: + raise BrowseError(f"Error listing albums: {err}") from err + + source.children.extend( _build_album( - album["title"], + album.title, PhotosIdentifier.album( identifier.config_entry_id, - album["id"], + album.id, ), _cover_photo_url(album, THUMBNAIL_SIZE), ) - for album in result["albums"] - ] + for album in albums + ) return source if ( @@ -233,28 +241,24 @@ class GooglePhotosMediaSource(MediaSource): else: list_args = {"album_id": identifier.media_id} - media_items: list[dict[str, Any]] = [] - page_token: str | None = None - while ( - not special_album - or (max_photos := special_album.value.max_photos) is None - or len(media_items) < max_photos - ): - try: - result = await client.list_media_items( - **list_args, page_size=PAGE_SIZE, page_token=page_token - ) - except GooglePhotosApiError as err: - raise BrowseError(f"Error listing media items: {err}") from err - media_items.extend(result["mediaItems"]) - page_token = result.get("nextPageToken") - if page_token is None: - break + media_items: list[MediaItem] = [] + try: + async for media_item_result in await client.list_media_items( + **list_args, page_size=MEDIA_ITEMS_PAGE_SIZE + ): + media_items.extend(media_item_result.media_items) + if ( + special_album + and (max_photos := special_album.value.max_photos) + and len(media_items) > max_photos + ): + break + except GooglePhotosApiError as err: + raise BrowseError(f"Error listing media items: {err}") from err - # Render the grid of media item results source.children = [ _build_media_item( - PhotosIdentifier.photo(identifier.config_entry_id, media_item["id"]), + PhotosIdentifier.photo(identifier.config_entry_id, media_item.id), media_item, ) for media_item in media_items @@ -315,38 +319,41 @@ def _build_album( def _build_media_item( - identifier: PhotosIdentifier, media_item: dict[str, Any] + identifier: PhotosIdentifier, + media_item: MediaItem, ) -> BrowseMediaSource: """Build the node for an individual photo or video.""" - is_video = media_item["mediaMetadata"].get("video") is not None + is_video = media_item.media_metadata and ( + media_item.media_metadata.video is not None + ) return BrowseMediaSource( domain=DOMAIN, identifier=identifier.as_string(), media_class=MediaClass.IMAGE if not is_video else MediaClass.VIDEO, media_content_type=MediaType.IMAGE if not is_video else MediaType.VIDEO, - title=media_item["filename"], + title=media_item.filename, can_play=is_video, can_expand=False, thumbnail=_media_url(media_item, THUMBNAIL_SIZE), ) -def _media_url(media_item: dict[str, Any], max_size: int) -> str: +def _media_url(media_item: MediaItem, max_size: int) -> str: """Return a media item url with the specified max thumbnail size on the longest edge. See https://developers.google.com/photos/library/guides/access-media-items#base-urls """ - return f"{media_item["baseUrl"]}=h{max_size}" + return f"{media_item.base_url}=h{max_size}" -def _video_url(media_item: dict[str, Any]) -> str: +def _video_url(media_item: MediaItem) -> str: """Return a video url for the item. See https://developers.google.com/photos/library/guides/access-media-items#base-urls """ - return f"{media_item["baseUrl"]}=dv" + return f"{media_item.base_url}=dv" -def _cover_photo_url(album: dict[str, Any], max_size: int) -> str: +def _cover_photo_url(album: Album, max_size: int) -> str: """Return a media item url for the cover photo of the album.""" - return f"{album["coverPhotoBaseUrl"]}=h{max_size}" + return f"{album.cover_photo_base_url}=h{max_size}" diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index 77015d5c700..66aa61e23a4 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -6,9 +6,10 @@ import asyncio import mimetypes from pathlib import Path +from google_photos_library_api.exceptions import GooglePhotosApiError +from google_photos_library_api.model import NewMediaItem, SimpleMediaItem import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_FILENAME from homeassistant.core import ( HomeAssistant, @@ -19,14 +20,8 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv -from . import api from .const import DOMAIN, UPLOAD_SCOPE - -type GooglePhotosConfigEntry = ConfigEntry[api.AsyncConfigEntryAuth] - -__all__ = [ - "DOMAIN", -] +from .types import GooglePhotosConfigEntry CONF_CONFIG_ENTRY_ID = "config_entry_id" @@ -98,11 +93,38 @@ def async_register_services(hass: HomeAssistant) -> None: ) for mime_type, content in file_results: upload_tasks.append(client_api.upload_content(content, mime_type)) - upload_tokens = await asyncio.gather(*upload_tasks) - media_ids = await client_api.create_media_items(upload_tokens) + try: + upload_results = await asyncio.gather(*upload_tasks) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_error", + translation_placeholders={"message": str(err)}, + ) from err + try: + upload_result = await client_api.create_media_items( + [ + NewMediaItem( + SimpleMediaItem(upload_token=upload_result.upload_token) + ) + for upload_result in upload_results + ] + ) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": str(err)}, + ) from err if call.return_response: return { - "media_items": [{"media_item_id": media_id for media_id in media_ids}] + "media_items": [ + { + "media_item_id": item_result.media_item.id + for item_result in upload_result.new_media_item_results + if item_result.media_item and item_result.media_item.id + } + ] } return None diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index 9e88429124e..bf2809f896f 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -45,6 +45,12 @@ }, "missing_upload_permission": { "message": "Home Assistnt was not granted permission to upload to Google Photos" + }, + "upload_error": { + "message": "Failed to upload content: {message}" + }, + "api_error": { + "message": "Google Photos API responded with error: {message}" } }, "services": { diff --git a/homeassistant/components/google_photos/types.py b/homeassistant/components/google_photos/types.py new file mode 100644 index 00000000000..2fe57fe1d15 --- /dev/null +++ b/homeassistant/components/google_photos/types.py @@ -0,0 +1,7 @@ +"""Google Photos types.""" + +from google_photos_library_api.api import GooglePhotosLibraryApi + +from homeassistant.config_entries import ConfigEntry + +type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosLibraryApi] diff --git a/requirements_all.txt b/requirements_all.txt index da902149cfd..19d99787672 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -979,7 +979,6 @@ goalzero==0.2.2 goodwe==0.3.6 # homeassistant.components.google_mail -# homeassistant.components.google_photos # homeassistant.components.google_tasks google-api-python-client==2.71.0 @@ -995,6 +994,9 @@ google-generativeai==0.7.2 # homeassistant.components.nest google-nest-sdm==5.0.0 +# homeassistant.components.google_photos +google-photos-library-api==0.8.0 + # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67986dd3a53..6203d295d78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -829,7 +829,6 @@ goalzero==0.2.2 goodwe==0.3.6 # homeassistant.components.google_mail -# homeassistant.components.google_photos # homeassistant.components.google_tasks google-api-python-client==2.71.0 @@ -845,6 +844,9 @@ google-generativeai==0.7.2 # homeassistant.components.nest google-nest-sdm==5.0.0 +# homeassistant.components.google_photos +google-photos-library-api==0.8.0 + # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index f7289993258..9dbe85bd25b 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -1,10 +1,18 @@ """Test fixtures for Google Photos.""" -from collections.abc import Awaitable, Callable, Generator +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator import time from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch +from google_photos_library_api.api import GooglePhotosLibraryApi +from google_photos_library_api.model import ( + Album, + ListAlbumResult, + ListMediaItemResult, + MediaItem, + UserInfoResult, +) import pytest from homeassistant.components.application_credentials import ( @@ -28,6 +36,12 @@ CLIENT_SECRET = "5678" FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" EXPIRES_IN = 3600 +USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo" +PHOTOS_BASE_URL = "https://photoslibrary.googleapis.com" +MEDIA_ITEMS_URL = f"{PHOTOS_BASE_URL}/v1/mediaItems" +ALBUMS_URL = f"{PHOTOS_BASE_URL}/v1/albums" +UPLOADS_URL = f"{PHOTOS_BASE_URL}/v1/uploads" +CREATE_MEDIA_ITEMS_URL = f"{PHOTOS_BASE_URL}/v1/mediaItems:batchCreate" @pytest.fixture(name="expires_at") @@ -100,56 +114,83 @@ def mock_user_identifier() -> str | None: return USER_IDENTIFIER -@pytest.fixture(name="setup_api") -def mock_setup_api( - fixture_name: str, user_identifier: str +@pytest.fixture(name="api_error") +def mock_api_error() -> Exception | None: + """Provide a json fixture file to load for list media item api responses.""" + return None + + +@pytest.fixture(name="mock_api") +def mock_client_api( + fixture_name: str, + user_identifier: str, + api_error: Exception, ) -> Generator[Mock, None, None]: """Set up fake Google Photos API responses from fixtures.""" - with patch("homeassistant.components.google_photos.api.build") as mock: - mock.return_value.userinfo.return_value.get.return_value.execute.return_value = { - "id": user_identifier, - "name": "Test Name", - } + mock_api = AsyncMock(GooglePhotosLibraryApi, autospec=True) + mock_api.get_user_info.return_value = UserInfoResult( + id=user_identifier, + name="Test Name", + email="test.name@gmail.com", + ) - responses = ( - load_json_array_fixture(fixture_name, DOMAIN) if fixture_name else [] - ) + responses = load_json_array_fixture(fixture_name, DOMAIN) if fixture_name else [] - queue = list(responses) + async def list_media_items( + *args: Any, + ) -> AsyncGenerator[ListMediaItemResult, None, None]: + for response in responses: + mock_list_media_items = Mock(ListMediaItemResult) + mock_list_media_items.media_items = [ + MediaItem.from_dict(media_item) for media_item in response["mediaItems"] + ] + yield mock_list_media_items - def list_media_items(**kwargs: Any) -> Mock: - mock = Mock() - mock.execute.return_value = queue.pop(0) - return mock + mock_api.list_media_items.return_value.__aiter__ = list_media_items + mock_api.list_media_items.return_value.__anext__ = list_media_items + mock_api.list_media_items.side_effect = api_error - mock.return_value.mediaItems.return_value.list = list_media_items - mock.return_value.mediaItems.return_value.search = list_media_items + # Mock a point lookup by reading contents of the fixture above + async def get_media_item(media_item_id: str, **kwargs: Any) -> Mock: + for response in responses: + for media_item in response["mediaItems"]: + if media_item["id"] == media_item_id: + return MediaItem.from_dict(media_item) + return None - # Mock a point lookup by reading contents of the fixture above - def get_media_item(mediaItemId: str, **kwargs: Any) -> Mock: - for response in responses: - for media_item in response["mediaItems"]: - if media_item["id"] == mediaItemId: - mock = Mock() - mock.execute.return_value = media_item - return mock - return None + mock_api.get_media_item = get_media_item - mock.return_value.mediaItems.return_value.get = get_media_item - mock.return_value.albums.return_value.list.return_value.execute.return_value = ( - load_json_object_fixture("list_albums.json", DOMAIN) - ) + # Emulate an async iterator for returning pages of response objects. We just + # return a single page. - yield mock + async def list_albums( + *args: Any, **kwargs: Any + ) -> AsyncGenerator[ListAlbumResult, None, None]: + mock_list_album_result = Mock(ListAlbumResult) + mock_list_album_result.albums = [ + Album.from_dict(album) + for album in load_json_object_fixture("list_albums.json", DOMAIN)["albums"] + ] + yield mock_list_album_result + + mock_api.list_albums.return_value.__aiter__ = list_albums + mock_api.list_albums.return_value.__anext__ = list_albums + mock_api.list_albums.side_effect = api_error + return mock_api @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry, + mock_api: Mock, ) -> Callable[[], Awaitable[bool]]: """Fixture to set up the integration.""" config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch( + "homeassistant.components.google_photos.GooglePhotosLibraryApi", + return_value=mock_api, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/google_photos/fixtures/api_not_enabled_response.json b/tests/components/google_photos/fixtures/api_not_enabled_response.json deleted file mode 100644 index 8933fcdc7bd..00000000000 --- a/tests/components/google_photos/fixtures/api_not_enabled_response.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "error": { - "code": 403, - "message": "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", - "errors": [ - { - "message": "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", - "domain": "usageLimits", - "reason": "accessNotConfigured", - "extendedHelp": "https://console.developers.google.com" - } - ], - "status": "PERMISSION_DENIED" - } - } -] diff --git a/tests/components/google_photos/fixtures/list_albums.json b/tests/components/google_photos/fixtures/list_albums.json index 57f2873715b..7460e1d36f3 100644 --- a/tests/components/google_photos/fixtures/list_albums.json +++ b/tests/components/google_photos/fixtures/list_albums.json @@ -3,6 +3,7 @@ { "id": "album-media-id-1", "title": "Album title", + "productUrl": "http://photos.google.com/album-media-id-1", "isWriteable": true, "mediaItemsCount": 7, "coverPhotoBaseUrl": "http://img.example.com/id3", diff --git a/tests/components/google_photos/fixtures/not_dict.json b/tests/components/google_photos/fixtures/not_dict.json deleted file mode 100644 index 05e325337d2..00000000000 --- a/tests/components/google_photos/fixtures/not_dict.json +++ /dev/null @@ -1 +0,0 @@ -["not a dictionary"] diff --git a/tests/components/google_photos/test_config_flow.py b/tests/components/google_photos/test_config_flow.py index 2564a8ed134..be97d7658c6 100644 --- a/tests/components/google_photos/test_config_flow.py +++ b/tests/components/google_photos/test_config_flow.py @@ -4,8 +4,7 @@ from collections.abc import Generator from typing import Any from unittest.mock import Mock, patch -from googleapiclient.errors import HttpError -from httplib2 import Response +from google_photos_library_api.exceptions import GooglePhotosApiError import pytest from homeassistant import config_entries @@ -20,7 +19,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from .conftest import EXPIRES_IN, FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, USER_IDENTIFIER -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -37,6 +36,16 @@ def mock_setup_entry() -> Generator[Mock, None, None]: yield mock_setup +@pytest.fixture(autouse=True) +def mock_patch_api(mock_api: Mock) -> Generator[None, None, None]: + """Fixture to patch the config flow api.""" + with patch( + "homeassistant.components.google_photos.config_flow.GooglePhotosLibraryApi", + return_value=mock_api, + ): + yield + + @pytest.fixture(name="updated_token_entry", autouse=True) def mock_updated_token_entry() -> dict[str, Any]: """Fixture to provide any test specific overrides to token data from the oauth token endpoint.""" @@ -60,7 +69,7 @@ def mock_token_request( ) -@pytest.mark.usefixtures("current_request_with_host", "setup_api") +@pytest.mark.usefixtures("current_request_with_host", "mock_api") @pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) async def test_full_flow( hass: HomeAssistant, @@ -126,11 +135,17 @@ async def test_full_flow( @pytest.mark.usefixtures( "current_request_with_host", "setup_credentials", + "mock_api", +) +@pytest.mark.parametrize( + "api_error", + [ + GooglePhotosApiError("some error"), + ], ) async def test_api_not_enabled( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - setup_api: Mock, ) -> None: """Check flow aborts if api is not enabled.""" result = await hass.config_entries.flow.async_init( @@ -160,24 +175,18 @@ async def test_api_not_enabled( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - setup_api.return_value.mediaItems.return_value.list = Mock() - setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = HttpError( - Response({"status": "403"}), - bytes(load_fixture("google_photos/api_not_enabled_response.json"), "utf-8"), - ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "access_not_configured" - assert result["description_placeholders"]["message"].endswith( - "Google Photos API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/library/photoslibrary.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." - ) + assert result["description_placeholders"]["message"].endswith("some error") @pytest.mark.usefixtures("current_request_with_host", "setup_credentials") async def test_general_exception( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, + mock_api: Mock, ) -> None: """Check flow aborts if exception happens.""" result = await hass.config_entries.flow.async_init( @@ -206,17 +215,15 @@ async def test_general_exception( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - with patch( - "homeassistant.components.google_photos.api.build", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + mock_api.list_media_items.side_effect = Exception + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown" -@pytest.mark.usefixtures("current_request_with_host", "setup_api", "setup_integration") +@pytest.mark.usefixtures("current_request_with_host", "mock_api", "setup_integration") @pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) @pytest.mark.parametrize( "updated_token_entry", diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index 1028a34aec1..762a4d5ebd1 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -1,10 +1,8 @@ """Test the Google Photos media source.""" -from typing import Any from unittest.mock import Mock -from googleapiclient.errors import HttpError -from httplib2 import Response +from google_photos_library_api.exceptions import GooglePhotosApiError import pytest from homeassistant.components.google_photos.const import DOMAIN, UPLOAD_SCOPE @@ -46,7 +44,7 @@ async def test_no_config_entries( assert not browse.children -@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.usefixtures("setup_integration", "mock_api") @pytest.mark.parametrize( ("scopes"), [ @@ -64,7 +62,7 @@ async def test_no_read_scopes( assert not browse.children -@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.usefixtures("setup_integration", "mock_api") @pytest.mark.parametrize( ("album_path", "expected_album_title"), [ @@ -135,14 +133,14 @@ async def test_browse_albums( ] == expected_medias -@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.usefixtures("setup_integration", "mock_api") async def test_invalid_config_entry(hass: HomeAssistant) -> None: """Test browsing to a config entry that does not exist.""" with pytest.raises(BrowseError, match="Could not find config entry"): await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/invalid-config-entry") -@pytest.mark.usefixtures("setup_integration", "setup_api") +@pytest.mark.usefixtures("setup_integration", "mock_api") @pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) async def test_browse_invalid_path(hass: HomeAssistant) -> None: """Test browsing to a photo is not possible.""" @@ -161,8 +159,8 @@ async def test_browse_invalid_path(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_integration") -@pytest.mark.parametrize("fixture_name", ["list_mediaitems.json"]) -async def test_invalid_album_id(hass: HomeAssistant, setup_api: Mock) -> None: +@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) +async def test_invalid_album_id(hass: HomeAssistant, mock_api: Mock) -> None: """Test browsing to an album id that does not exist.""" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN @@ -172,11 +170,6 @@ async def test_invalid_album_id(hass: HomeAssistant, setup_api: Mock) -> None: (CONFIG_ENTRY_ID, "Account Name") ] - setup_api.return_value.mediaItems.return_value.search = Mock() - setup_api.return_value.mediaItems.return_value.search.return_value.execute.side_effect = HttpError( - Response({"status": "404"}), b"" - ) - with pytest.raises(BrowseError, match="Error listing media items"): await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/invalid-album-id" @@ -201,18 +194,9 @@ async def test_missing_photo_id( await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/{identifier}", None) -@pytest.mark.usefixtures("setup_integration", "setup_api") -@pytest.mark.parametrize( - "side_effect", - [ - HttpError(Response({"status": "403"}), b""), - ], -) -async def test_list_media_items_failure( - hass: HomeAssistant, - setup_api: Any, - side_effect: HttpError | Response, -) -> None: +@pytest.mark.usefixtures("setup_integration", "mock_api") +@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) +async def test_list_albums_failure(hass: HomeAssistant) -> None: """Test browsing to an album id that does not exist.""" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN @@ -222,24 +206,13 @@ async def test_list_media_items_failure( (CONFIG_ENTRY_ID, "Account Name") ] - setup_api.return_value.mediaItems.return_value.list = Mock() - setup_api.return_value.mediaItems.return_value.list.return_value.execute.side_effect = side_effect - - with pytest.raises(BrowseError, match="Error listing media items"): - await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" - ) + with pytest.raises(BrowseError, match="Error listing albums"): + await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}") -@pytest.mark.usefixtures("setup_integration", "setup_api") -@pytest.mark.parametrize( - "fixture_name", - [ - "api_not_enabled_response.json", - "not_dict.json", - ], -) -async def test_media_items_error_parsing_response(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("setup_integration", "mock_api") +@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) +async def test_list_media_items_failure(hass: HomeAssistant) -> None: """Test browsing to an album id that does not exist.""" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN @@ -248,6 +221,7 @@ async def test_media_items_error_parsing_response(hass: HomeAssistant) -> None: assert [(child.identifier, child.title) for child in browse.children] == [ (CONFIG_ENTRY_ID, "Account Name") ] + with pytest.raises(BrowseError, match="Error listing media items"): await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/recent" diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py index 198de3295a9..10d57e1d178 100644 --- a/tests/components/google_photos/test_services.py +++ b/tests/components/google_photos/test_services.py @@ -1,45 +1,42 @@ """Tests for Google Photos.""" -import http from unittest.mock import Mock, patch -from googleapiclient.errors import HttpError -from httplib2 import Response +from google_photos_library_api.exceptions import GooglePhotosApiError +from google_photos_library_api.model import ( + CreateMediaItemsResult, + MediaItem, + NewMediaItemResult, + Status, +) import pytest -from homeassistant.components.google_photos.api import UPLOAD_API from homeassistant.components.google_photos.const import DOMAIN, READ_SCOPES from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.usefixtures("setup_integration") async def test_upload_service( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - setup_api: Mock, + mock_api: Mock, ) -> None: """Test service call to upload content.""" assert hass.services.has_service(DOMAIN, "upload") - aioclient_mock.post(UPLOAD_API, text="some-upload-token") - setup_api.return_value.mediaItems.return_value.batchCreate.return_value.execute.return_value = { - "newMediaItemResults": [ - { - "status": { - "code": 200, - }, - "mediaItem": { - "id": "new-media-item-id-1", - }, - } + mock_api.create_media_items.return_value = CreateMediaItemsResult( + new_media_item_results=[ + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-1"), + ) ] - } + ) with ( patch( @@ -62,6 +59,7 @@ async def test_upload_service( blocking=True, return_response=True, ) + assert response == {"media_items": [{"media_item_id": "new-media-item-id-1"}]} @@ -157,12 +155,11 @@ async def test_filename_does_not_exist( async def test_upload_service_upload_content_failure( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - setup_api: Mock, + mock_api: Mock, ) -> None: """Test service call to upload content.""" - aioclient_mock.post(UPLOAD_API, status=http.HTTPStatus.SERVICE_UNAVAILABLE) + mock_api.upload_content.side_effect = GooglePhotosApiError() with ( patch( @@ -192,15 +189,11 @@ async def test_upload_service_upload_content_failure( async def test_upload_service_fails_create( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - setup_api: Mock, + mock_api: Mock, ) -> None: """Test service call to upload content.""" - aioclient_mock.post(UPLOAD_API, text="some-upload-token") - setup_api.return_value.mediaItems.return_value.batchCreate.return_value.execute.side_effect = HttpError( - Response({"status": "403"}), b"" - ) + mock_api.create_media_items.side_effect = GooglePhotosApiError() with ( patch( @@ -238,8 +231,6 @@ async def test_upload_service_fails_create( async def test_upload_service_no_scope( hass: HomeAssistant, config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, - setup_api: Mock, ) -> None: """Test service call to upload content but the config entry is read-only.""" From 94f458ff9888615d7e9948c92e24743a340383c2 Mon Sep 17 00:00:00 2001 From: ilan <31193909+iloveicedgreentea@users.noreply.github.com> Date: Tue, 3 Sep 2024 07:56:59 -0400 Subject: [PATCH 0171/1309] Bump py-madvr2 to 1.6.32 (#125049) feat: update lib --- homeassistant/components/madvr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/madvr/manifest.json b/homeassistant/components/madvr/manifest.json index ce6336acabc..0ac906fdbef 100644 --- a/homeassistant/components/madvr/manifest.json +++ b/homeassistant/components/madvr/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/madvr", "integration_type": "device", "iot_class": "local_push", - "requirements": ["py-madvr2==1.6.29"] + "requirements": ["py-madvr2==1.6.32"] } diff --git a/requirements_all.txt b/requirements_all.txt index 19d99787672..b3b60fc000e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1662,7 +1662,7 @@ py-dormakaba-dkey==1.0.5 py-improv-ble-client==1.0.3 # homeassistant.components.madvr -py-madvr2==1.6.29 +py-madvr2==1.6.32 # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6203d295d78..0cfa38b538a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1357,7 +1357,7 @@ py-dormakaba-dkey==1.0.5 py-improv-ble-client==1.0.3 # homeassistant.components.madvr -py-madvr2==1.6.29 +py-madvr2==1.6.32 # homeassistant.components.melissa py-melissa-climate==2.1.4 From 5965d8d503261496a449ce7654807241d02c1ff2 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 3 Sep 2024 13:00:30 +0100 Subject: [PATCH 0172/1309] Pass hass clientsession to ring config flow (#125119) Pass hass clientsession to ring config flow --- homeassistant/components/ring/config_flow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index ee78541dec7..b82b4f22223 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_2FA, DOMAIN @@ -31,7 +32,10 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Any]: """Validate the user input allows us to connect.""" - auth = Auth(f"{APPLICATION_NAME}/{ha_version}") + auth = Auth( + f"{APPLICATION_NAME}/{ha_version}", + http_client_session=async_get_clientsession(hass), + ) try: token = await auth.async_fetch_token( From d12c6f89d2dbe91c4f32ff29b9692f22c09e3f7e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 3 Sep 2024 14:13:43 +0200 Subject: [PATCH 0173/1309] Bump hadolint to 2.12.0 and use matrix for all Dockerfiles (#125131) * Bump hadolint to 2.12.0 and use matrix for all Dockerfiles * Fix * Disable fail fast --- .github/workflows/ci.yaml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 24b204f3f55..5d21c2c7b04 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -429,17 +429,28 @@ jobs: . venv/bin/activate pre-commit run --show-diff-on-failure --hook-stage manual codespell --all-files + lint-hadolint: + name: Check ${{ matrix.file }} + runs-on: ubuntu-24.04 + needs: + - info + - pre-commit + strategy: + fail-fast: false + matrix: + file: + - Dockerfile + - Dockerfile.dev + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.1.7 - name: Register hadolint problem matcher run: | echo "::add-matcher::.github/workflows/matchers/hadolint.json" - - name: Check Dockerfile - uses: docker://hadolint/hadolint:v1.18.2 + - name: Check ${{ matrix.file }} + uses: docker://hadolint/hadolint:v2.12.0 with: - args: hadolint Dockerfile - - name: Check Dockerfile.dev - uses: docker://hadolint/hadolint:v1.18.2 - with: - args: hadolint Dockerfile.dev + args: hadolint ${{ matrix.file }} base: name: Prepare dependencies From c71cf272c859f3763dca80c48738a4e64cd4a23a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 3 Sep 2024 06:21:52 -0600 Subject: [PATCH 0174/1309] Fix unhandled exception with missing IQVIA data (#125114) --- homeassistant/components/iqvia/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index ba3c288b702..af351e0d543 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -244,8 +244,8 @@ class IndexSensor(IQVIAEntity, SensorEntity): key = self.entity_description.key.split("_")[-1].title() try: - [period] = [p for p in data["periods"] if p["Type"] == key] # type: ignore[index] - except TypeError: + period = next(p for p in data["periods"] if p["Type"] == key) # type: ignore[index] + except StopIteration: return data = cast(dict[str, Any], data) From e3896d1f60accb8cac3835812a6d97cb8ba18202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20J=C3=A1l?= Date: Tue, 3 Sep 2024 14:22:39 +0200 Subject: [PATCH 0175/1309] Bump PySwitchbot to 0.48.2 (#125113) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 0cbbd70a805..f97162184c6 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.48.1"] + "requirements": ["PySwitchbot==0.48.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b3b60fc000e..e14ef8af35a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.48.1 +PySwitchbot==0.48.2 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0cfa38b538a..b28d39da203 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.48.1 +PySwitchbot==0.48.2 # homeassistant.components.syncthru PySyncThru==0.7.10 From 851600630c731abdaf50a79a5ce52b00228852df Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Sep 2024 14:28:33 +0200 Subject: [PATCH 0176/1309] Log deprecation warning when `template.Template` is created without `hass` (#125142) * Log deprecation warning when template.Template is created without hass * Improve docstring --- homeassistant/helpers/template.py | 18 +++++++++++++++++- tests/helpers/test_template.py | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 1786194b437..9f8eb628e63 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -507,10 +507,26 @@ class Template: ) def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: - """Instantiate a template.""" + """Instantiate a template. + + Note: A valid hass instance should always be passed in. The hass parameter + will be non optional in Home Assistant Core 2025.10. + """ + # pylint: disable-next=import-outside-toplevel + from .frame import report + if not isinstance(template, str): raise TypeError("Expected template to be a string") + if not hass: + report( + ( + "creates a template object without passing hass, " + "which will stop working in HA Core 2025.10" + ), + error_if_core=False, + ) + self.template: str = template.strip() self._compiled_code: CodeType | None = None self._compiled: jinja2.Template | None = None diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index e4f833b2d1d..339b372f137 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6547,3 +6547,20 @@ async def test_merge_response_with_incorrect_response(hass: HomeAssistant) -> No tpl = template.Template(_template, hass) with pytest.raises(TemplateError, match="TypeError: Response is not a dictionary"): tpl.async_render() + + +def test_warn_no_hass(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test deprecation warning when instantiating Template without hass.""" + + message = "Detected code that creates a template object without passing hass" + template.Template("blah") + assert message in caplog.text + caplog.clear() + + template.Template("blah", None) + assert message in caplog.text + caplog.clear() + + template.Template("blah", hass) + assert message not in caplog.text + caplog.clear() From c321bd70e171151a723fccd3507dcf144c77140d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Sep 2024 14:37:21 +0200 Subject: [PATCH 0177/1309] Log deprecation warning when `cv.template` is called from wrong thread (#125141) Log deprecation warning when cv.template is called from wrong thread --- homeassistant/helpers/config_validation.py | 26 ++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3d3de40a2c6..d88c388f9c7 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -715,8 +715,19 @@ def template(value: Any | None) -> template_helper.Template: raise vol.Invalid("template value is None") if isinstance(value, (list, dict, template_helper.Template)): raise vol.Invalid("template value should be a string") + if not (hass := _async_get_hass_or_none()): + # pylint: disable-next=import-outside-toplevel + from .frame import report - template_value = template_helper.Template(str(value), _async_get_hass_or_none()) + report( + ( + "validates schema outside the event loop, " + "which will stop working in HA Core 2025.10" + ), + error_if_core=False, + ) + + template_value = template_helper.Template(str(value), hass) try: template_value.ensure_valid() @@ -733,8 +744,19 @@ def dynamic_template(value: Any | None) -> template_helper.Template: raise vol.Invalid("template value should be a string") if not template_helper.is_template_string(str(value)): raise vol.Invalid("template value does not contain a dynamic template") + if not (hass := _async_get_hass_or_none()): + # pylint: disable-next=import-outside-toplevel + from .frame import report - template_value = template_helper.Template(str(value), _async_get_hass_or_none()) + report( + ( + "validates schema outside the event loop, " + "which will stop working in HA Core 2025.10" + ), + error_if_core=False, + ) + + template_value = template_helper.Template(str(value), hass) try: template_value.ensure_valid() From 6ecc5c19a29b101c63ed9342512b871523e0ce75 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 3 Sep 2024 22:38:47 +1000 Subject: [PATCH 0178/1309] Add climate platform to Tesla Fleet (#123169) * Add climate * docstring * Add tests * Fix limited scope situation * Add another test * Add icons * Type vehicle data * Replace inline temperatures * Fix handle_vehicle_command type * Fix preset turning HVAC off * Fix cop_mode check * Use constants * Reference docs in command signing error * Move to a read-only check * Remove raise_for * Fixes * Tests * Remove raise_for_signing * Remove unused strings * Fix async_set_temperature * Correct tests * Remove HVAC modes at startup in read-only mode * Fix order of init actions to set hvac_modes correctly * Fix no temp test * Add handle command type * Docstrings * fix matches and fix a bug * Split tests * Fix issues from rebase --- .../components/tesla_fleet/__init__.py | 12 +- .../components/tesla_fleet/climate.py | 330 +++++++++++++ homeassistant/components/tesla_fleet/const.py | 7 + .../components/tesla_fleet/entity.py | 6 + .../components/tesla_fleet/helpers.py | 80 ++++ .../components/tesla_fleet/icons.json | 14 + .../components/tesla_fleet/models.py | 3 + .../components/tesla_fleet/strings.json | 41 +- tests/components/tesla_fleet/conftest.py | 56 ++- .../tesla_fleet/snapshots/test_climate.ambr | 422 ++++++++++++++++ tests/components/tesla_fleet/test_climate.py | 450 ++++++++++++++++++ 11 files changed, 1407 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/tesla_fleet/climate.py create mode 100644 homeassistant/components/tesla_fleet/helpers.py create mode 100644 tests/components/tesla_fleet/snapshots/test_climate.ambr create mode 100644 tests/components/tesla_fleet/test_climate.py diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 183e7e753b5..3bcb0bf7ef9 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -39,7 +39,12 @@ from .coordinator import ( from .models import TeslaFleetData, TeslaFleetEnergyData, TeslaFleetVehicleData from .oauth import TeslaSystemImplementation -PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER, Platform.SENSOR] +PLATFORMS: Final = [ + Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.DEVICE_TRACKER, + Platform.SENSOR, +] type TeslaFleetConfigEntry = ConfigEntry[TeslaFleetData] @@ -53,8 +58,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - session = async_get_clientsession(hass) token = jwt.decode(access_token, options={"verify_signature": False}) - scopes = token["scp"] - region = token["ou_code"].lower() + scopes: list[Scope] = [Scope(s) for s in token["scp"]] + region: str = token["ou_code"].lower() OAuth2FlowHandler.async_register_implementation( hass, @@ -133,6 +138,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - coordinator=coordinator, vin=vin, device=device, + signing=product["command_signing"] == "required", ) ) elif "energy_site_id" in product and hasattr(tesla, "energy"): diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py new file mode 100644 index 00000000000..6199ee112b5 --- /dev/null +++ b/homeassistant/components/tesla_fleet/climate.py @@ -0,0 +1,330 @@ +"""Climate platform for Tesla Fleet integration.""" + +from __future__ import annotations + +from itertools import chain +from typing import Any, cast + +from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + PRECISION_WHOLE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslaFleetConfigEntry +from .const import DOMAIN, TeslaFleetClimateSide +from .entity import TeslaFleetVehicleEntity +from .helpers import handle_vehicle_command +from .models import TeslaFleetVehicleData + +DEFAULT_MIN_TEMP = 15 +DEFAULT_MAX_TEMP = 28 + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslaFleetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tesla Fleet Climate platform from a config entry.""" + + async_add_entities( + chain( + ( + TeslaFleetClimateEntity( + vehicle, TeslaFleetClimateSide.DRIVER, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslaFleetCabinOverheatProtectionEntity( + vehicle, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ) + ) + + +class TeslaFleetClimateEntity(TeslaFleetVehicleEntity, ClimateEntity): + """Tesla Fleet vehicle climate entity.""" + + _attr_precision = PRECISION_HALVES + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] + _attr_supported_features = ( + ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.PRESET_MODE + ) + _attr_preset_modes = ["off", "keep", "dog", "camp"] + _enable_turn_on_off_backwards_compatibility = False + + def __init__( + self, + data: TeslaFleetVehicleData, + side: TeslaFleetClimateSide, + scopes: Scope, + ) -> None: + """Initialize the climate.""" + + self.read_only = Scope.VEHICLE_CMDS not in scopes or data.signing + + if self.read_only: + self._attr_supported_features = ClimateEntityFeature(0) + self._attr_hvac_modes = [] + + super().__init__( + data, + side, + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + value = self.get("climate_state_is_climate_on") + if value is None: + self._attr_hvac_mode = None + elif value: + self._attr_hvac_mode = HVACMode.HEAT_COOL + else: + self._attr_hvac_mode = HVACMode.OFF + + # If not scoped, prevent the user from changing the HVAC mode by making it the only option + if self._attr_hvac_mode and self.read_only: + self._attr_hvac_modes = [self._attr_hvac_mode] + + self._attr_current_temperature = self.get("climate_state_inside_temp") + self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting") + self._attr_preset_mode = self.get("climate_state_climate_keeper_mode") + self._attr_min_temp = cast( + float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP) + ) + self._attr_max_temp = cast( + float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP) + ) + + async def async_turn_on(self) -> None: + """Set the climate state to on.""" + + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.auto_conditioning_start()) + + self._attr_hvac_mode = HVACMode.HEAT_COOL + self.async_write_ha_state() + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.auto_conditioning_stop()) + + self._attr_hvac_mode = HVACMode.OFF + self._attr_preset_mode = self._attr_preset_modes[0] + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the climate temperature.""" + + if ATTR_TEMPERATURE not in kwargs: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_temperature", + ) + + temp = kwargs[ATTR_TEMPERATURE] + await self.wake_up_if_asleep() + await handle_vehicle_command( + self.api.set_temps( + driver_temp=temp, + passenger_temp=temp, + ) + ) + self._attr_target_temperature = temp + + if mode := kwargs.get(ATTR_HVAC_MODE): + # Set HVAC mode will call write_ha_state + await self.async_set_hvac_mode(mode) + else: + self.async_write_ha_state() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + if hvac_mode not in self.hvac_modes: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_hvac_mode", + translation_placeholders={"hvac_mode": hvac_mode}, + ) + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + else: + await self.async_turn_on() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the climate preset mode.""" + await self.wake_up_if_asleep() + await handle_vehicle_command( + self.api.set_climate_keeper_mode( + climate_keeper_mode=self._attr_preset_modes.index(preset_mode) + ) + ) + self._attr_preset_mode = preset_mode + if preset_mode != self._attr_preset_modes[0]: + self._attr_hvac_mode = HVACMode.HEAT_COOL + self.async_write_ha_state() + + +COP_MODES = { + "Off": HVACMode.OFF, + "On": HVACMode.COOL, + "FanOnly": HVACMode.FAN_ONLY, +} + +# String to celsius +COP_LEVELS = { + "Low": 30, + "Medium": 35, + "High": 40, +} + +# Celsius to IntEnum +TEMP_LEVELS = { + 30: CabinOverheatProtectionTemp.LOW, + 35: CabinOverheatProtectionTemp.MEDIUM, + 40: CabinOverheatProtectionTemp.HIGH, +} + + +class TeslaFleetCabinOverheatProtectionEntity(TeslaFleetVehicleEntity, ClimateEntity): + """Tesla Fleet vehicle cabin overheat protection entity.""" + + _attr_precision = PRECISION_WHOLE + _attr_target_temperature_step = 5 + _attr_min_temp = COP_LEVELS["Low"] + _attr_max_temp = COP_LEVELS["High"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = list(COP_MODES.values()) + _enable_turn_on_off_backwards_compatibility = False + _attr_entity_registry_enabled_default = False + + def __init__( + self, + data: TeslaFleetVehicleData, + scopes: Scope, + ) -> None: + """Initialize the cabin overheat climate entity.""" + + # Scopes + self.read_only = Scope.VEHICLE_CMDS not in scopes or data.signing + + # Supported Features + if self.read_only: + self._attr_supported_features = ClimateEntityFeature(0) + self._attr_hvac_modes = [] + else: + self._attr_supported_features = ( + ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + ) + + super().__init__(data, "climate_state_cabin_overheat_protection") + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + if (state := self.get("climate_state_cabin_overheat_protection")) is None: + self._attr_hvac_mode = None + else: + self._attr_hvac_mode = COP_MODES.get(state) + + # If not scoped, prevent the user from changing the HVAC mode by making it the only option + if self._attr_hvac_mode and self.read_only: + self._attr_hvac_modes = [self._attr_hvac_mode] + + if (level := self.get("climate_state_cop_activation_temperature")) is None: + self._attr_target_temperature = None + else: + self._attr_target_temperature = COP_LEVELS.get(level) + + self._attr_current_temperature = self.get("climate_state_inside_temp") + + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" + if not self.read_only and self.get( + "vehicle_config_cop_user_set_temp_supported" + ): + return ( + self._attr_supported_features | ClimateEntityFeature.TARGET_TEMPERATURE + ) + return self._attr_supported_features + + async def async_turn_on(self) -> None: + """Set the climate state to on.""" + await self.async_set_hvac_mode(HVACMode.COOL) + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the climate temperature.""" + + if ATTR_TEMPERATURE not in kwargs: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_temperature", + ) + + temp = kwargs[ATTR_TEMPERATURE] + if (cop_mode := TEMP_LEVELS.get(temp)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_cop_temp", + ) + + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.set_cop_temp(cop_mode)) + self._attr_target_temperature = temp + + if mode := kwargs.get(ATTR_HVAC_MODE): + await self._async_set_cop(mode) + + self.async_write_ha_state() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + await self.wake_up_if_asleep() + await self._async_set_cop(hvac_mode) + self.async_write_ha_state() + + async def _async_set_cop(self, hvac_mode: HVACMode) -> None: + if hvac_mode == HVACMode.OFF: + await handle_vehicle_command( + self.api.set_cabin_overheat_protection(on=False, fan_only=False) + ) + elif hvac_mode == HVACMode.COOL: + await handle_vehicle_command( + self.api.set_cabin_overheat_protection(on=True, fan_only=False) + ) + elif hvac_mode == HVACMode.FAN_ONLY: + await handle_vehicle_command( + self.api.set_cabin_overheat_protection(on=True, fan_only=True) + ) + + self._attr_hvac_mode = hvac_mode diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 081225c296c..53e34092326 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -41,3 +41,10 @@ class TeslaFleetState(StrEnum): ONLINE = "online" ASLEEP = "asleep" OFFLINE = "offline" + + +class TeslaFleetClimateSide(StrEnum): + """Tesla Fleet Climate Keeper Modes.""" + + DRIVER = "driver_temp" + PASSENGER = "passenger_temp" diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index c853bb798b5..103fd216953 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -14,6 +14,7 @@ from .coordinator import ( TeslaFleetEnergySiteLiveCoordinator, TeslaFleetVehicleDataCoordinator, ) +from .helpers import wake_up_vehicle from .models import TeslaFleetEnergyData, TeslaFleetVehicleData @@ -27,6 +28,7 @@ class TeslaFleetEntity( """Parent class for all TeslaFleet entities.""" _attr_has_entity_name = True + read_only: bool def __init__( self, @@ -100,6 +102,10 @@ class TeslaFleetVehicleEntity(TeslaFleetEntity): """Return a specific value from coordinator data.""" return self.coordinator.data.get(self.key) + async def wake_up_if_asleep(self) -> None: + """Wake up the vehicle if its asleep.""" + await wake_up_vehicle(self.vehicle) + class TeslaFleetEnergyLiveEntity(TeslaFleetEntity): """Parent class for TeslaFleet Energy Site Live entities.""" diff --git a/homeassistant/components/tesla_fleet/helpers.py b/homeassistant/components/tesla_fleet/helpers.py new file mode 100644 index 00000000000..d554ccce70c --- /dev/null +++ b/homeassistant/components/tesla_fleet/helpers.py @@ -0,0 +1,80 @@ +"""Tesla Fleet helper functions.""" + +import asyncio +from collections.abc import Awaitable +from typing import Any + +from tesla_fleet_api.exceptions import TeslaFleetError + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN, LOGGER, TeslaFleetState +from .models import TeslaFleetVehicleData + + +async def wake_up_vehicle(vehicle: TeslaFleetVehicleData) -> None: + """Wake up a vehicle.""" + async with vehicle.wakelock: + times = 0 + while vehicle.coordinator.data["state"] != TeslaFleetState.ONLINE: + try: + if times == 0: + cmd = await vehicle.api.wake_up() + else: + cmd = await vehicle.api.vehicle() + state = cmd["response"]["state"] + except TeslaFleetError as e: + raise HomeAssistantError(str(e)) from e + vehicle.coordinator.data["state"] = state + if state != TeslaFleetState.ONLINE: + times += 1 + if times >= 4: # Give up after 30 seconds total + raise HomeAssistantError("Could not wake up vehicle") + await asyncio.sleep(times * 5) + + +async def handle_command(command: Awaitable) -> dict[str, Any]: + """Handle a command.""" + try: + result = await command + except TeslaFleetError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={"message": e.message}, + ) from e + LOGGER.debug("Command result: %s", result) + return result + + +async def handle_vehicle_command(command: Awaitable) -> bool: + """Handle a vehicle command.""" + result = await handle_command(command) + if (response := result.get("response")) is None: + if error := result.get("error"): + # No response with error + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_error", + translation_placeholders={"error": error}, + ) + # No response without error (unexpected) + raise HomeAssistantError(f"Unknown response: {response}") + if (result := response.get("result")) is not True: + if reason := response.get("reason"): + if reason in ("already_set", "not_charging", "requested"): + # Reason is acceptable + return result + # Result of false with reason + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_reason", + translation_placeholders={"reason": reason}, + ) + # Result of false without reason (unexpected) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_no_reason", + ) + # Response with result of true + return result diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index 2dbde45ee08..dc40f282037 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -38,6 +38,20 @@ } } }, + "climate": { + "driver_temp": { + "state_attributes": { + "preset_mode": { + "state": { + "off": "mdi:power", + "keep": "mdi:fan", + "dog": "mdi:dog", + "camp": "mdi:tent" + } + } + } + } + }, "device_tracker": { "location": { "default": "mdi:map-marker" diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py index 1b1f5f083cd..ae945dd96bf 100644 --- a/homeassistant/components/tesla_fleet/models.py +++ b/homeassistant/components/tesla_fleet/models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass from tesla_fleet_api import EnergySpecific, VehicleSpecific @@ -33,6 +34,8 @@ class TeslaFleetVehicleData: coordinator: TeslaFleetVehicleDataCoordinator vin: str device: DeviceInfo + signing: bool + wakelock = asyncio.Lock() @dataclass diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index d4848836689..5b59d3efc5c 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -107,6 +107,24 @@ "name": "Tire pressure warning rear right" } }, + "climate": { + "climate_state_cabin_overheat_protection": { + "name": "Cabin overheat protection" + }, + "driver_temp": { + "name": "[%key:component::climate::title%]", + "state_attributes": { + "preset_mode": { + "state": { + "off": "Normal", + "keep": "Keep mode", + "dog": "Dog mode", + "camp": "Camp mode" + } + } + } + } + }, "device_tracker": { "location": { "name": "Location" @@ -272,7 +290,28 @@ }, "exceptions": { "update_failed": { - "message": "{endpoint} data request failed. {message}" + "message": "{endpoint} data request failed: {message}" + }, + "command_failed": { + "message": "Command failed: {message}" + }, + "command_error": { + "message": "Command returned an error: {error}" + }, + "command_reason": { + "message": "Command was unsuccessful: {reason}" + }, + "command_no_reason": { + "message": "Command was unsuccessful but did not return a reason why." + }, + "invalid_cop_temp": { + "message": "Cabin overheat protection does not support that temperature." + }, + "invalid_hvac_mode": { + "message": "Climate mode {hvac_mode} is not supported." + }, + "missing_temperature": { + "message": "Temperature is required for this action." } } } diff --git a/tests/components/tesla_fleet/conftest.py b/tests/components/tesla_fleet/conftest.py index 615c62fe16e..cc580212233 100644 --- a/tests/components/tesla_fleet/conftest.py +++ b/tests/components/tesla_fleet/conftest.py @@ -9,10 +9,18 @@ from unittest.mock import AsyncMock, patch import jwt import pytest +from tesla_fleet_api.const import Scope from homeassistant.components.tesla_fleet.const import DOMAIN, SCOPES -from .const import LIVE_STATUS, PRODUCTS, SITE_INFO, VEHICLE_DATA, VEHICLE_ONLINE +from .const import ( + COMMAND_OK, + LIVE_STATUS, + PRODUCTS, + SITE_INFO, + VEHICLE_DATA, + VEHICLE_ONLINE, +) from tests.common import MockConfigEntry @@ -25,16 +33,8 @@ def mock_expires_at() -> int: return time.time() + 3600 -@pytest.fixture(name="scopes") -def mock_scopes() -> list[str]: - """Fixture to set the scopes present in the OAuth token.""" - return SCOPES - - -@pytest.fixture -def normal_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: +def create_config_entry(expires_at: int, scopes: list[Scope]) -> MockConfigEntry: """Create Tesla Fleet entry in Home Assistant.""" - access_token = jwt.encode( { "sub": UID, @@ -64,6 +64,32 @@ def normal_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) +@pytest.fixture +def normal_config_entry(expires_at: int) -> MockConfigEntry: + """Create Tesla Fleet entry in Home Assistant.""" + return create_config_entry(expires_at, SCOPES) + + +@pytest.fixture +def noscope_config_entry(expires_at: int) -> MockConfigEntry: + """Create Tesla Fleet entry in Home Assistant without scopes.""" + return create_config_entry(expires_at, [Scope.OPENID, Scope.OFFLINE_ACCESS]) + + +@pytest.fixture +def readonly_config_entry(expires_at: int) -> MockConfigEntry: + """Create Tesla Fleet entry in Home Assistant without scopes.""" + return create_config_entry( + expires_at, + [ + Scope.OPENID, + Scope.OFFLINE_ACCESS, + Scope.VEHICLE_DEVICE_DATA, + Scope.ENERGY_DEVICE_DATA, + ], + ) + + @pytest.fixture(autouse=True) def mock_products() -> Generator[AsyncMock]: """Mock Tesla Fleet Api products method.""" @@ -131,3 +157,13 @@ def mock_find_server() -> Generator[AsyncMock]: "homeassistant.components.tesla_fleet.TeslaFleetApi.find_server", ) as mock_find_server: yield mock_find_server + + +@pytest.fixture +def mock_request(): + """Mock all Tesla Fleet API requests.""" + with patch( + "homeassistant.components.tesla_fleet.TeslaFleetApi._request", + return_value=COMMAND_OK, + ) as mock_request: + yield mock_request diff --git a/tests/components/tesla_fleet/snapshots/test_climate.ambr b/tests/components/tesla_fleet/snapshots/test_climate.ambr new file mode 100644 index 00000000000..696f8c37f08 --- /dev/null +++ b/tests/components/tesla_fleet/snapshots/test_climate.ambr @@ -0,0 +1,422 @@ +# serializer version: 1 +# name: test_climate[climate.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'target_temp_step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + 'temperature': 40, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate[climate.test_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30.0, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': 'keep', + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- +# name: test_climate_alt[climate.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'target_temp_step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_alt[climate.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate_alt[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_alt[climate.test_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30.0, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': 'off', + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate_offline[climate.test_cabin_overheat_protection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'target_temp_step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_cabin_overheat_protection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cabin overheat protection', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'climate_state_cabin_overheat_protection', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_cabin_overheat_protection', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_offline[climate.test_cabin_overheat_protection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Cabin overheat protection', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 40, + 'min_temp': 30, + 'supported_features': , + 'target_temp_step': 5, + }), + 'context': , + 'entity_id': 'climate.test_cabin_overheat_protection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_climate_offline[climate.test_climate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test_climate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': , + 'unique_id': 'LRWXF7EK4KC700000-driver_temp', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_offline[climate.test_climate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Test Climate', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 15.0, + 'preset_mode': None, + 'preset_modes': list([ + 'off', + 'keep', + 'dog', + 'camp', + ]), + 'supported_features': , + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.test_climate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tesla_fleet/test_climate.py b/tests/components/tesla_fleet/test_climate.py new file mode 100644 index 00000000000..902faaba922 --- /dev/null +++ b/tests/components/tesla_fleet/test_climate.py @@ -0,0 +1,450 @@ +"""Test the Tesla Fleet climate platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion +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, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.components.tesla_fleet.coordinator import VEHICLE_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import ( + COMMAND_ERRORS, + COMMAND_IGNORED_REASON, + VEHICLE_ASLEEP, + VEHICLE_DATA_ALT, + VEHICLE_ONLINE, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the climate entities are correct.""" + + await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +async def test_climate_services( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, + mock_request: AsyncMock, +) -> None: + """Tests that the climate services work.""" + + await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) + entity_id = "climate.test_climate" + + # Turn On and Set Temp + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 20, + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 20 + assert state.state == HVACMode.HEAT_COOL + + # Set Temp + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 21, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 21 + + # Set Preset + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: "keep"}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == "keep" + + # Set Preset + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: "off"}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PRESET_MODE] == "off" + + # Turn Off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate_overheat_protection_services( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, + mock_request: AsyncMock, +) -> None: + """Tests that the climate overheat protection services work.""" + + await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) + entity_id = "climate.test_cabin_overheat_protection" + + # Turn On and Set Low + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 30, + ATTR_HVAC_MODE: HVACMode.FAN_ONLY, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 30 + assert state.state == HVACMode.FAN_ONLY + + # Set Temp Medium + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 35, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 35 + + # Set Temp High + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TEMPERATURE: 40, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 40 + + # Turn Off + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.OFF + + # Turn On + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == HVACMode.COOL + + # Call set temp with invalid temperature + with pytest.raises( + ServiceValidationError, + match="Cabin overheat protection does not support that temperature", + ): + # Invalid Temp + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 34}, + blocking=True, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the climate entity is correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate_offline( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the climate entity is correct.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +async def test_invalid_error( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests service error is handled.""" + + await setup_platform(hass, normal_config_entry, platforms=[Platform.CLIMATE]) + entity_id = "climate.test_climate" + + with ( + patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + side_effect=InvalidCommand, + ) as mock_on, + pytest.raises( + HomeAssistantError, + match="Command failed: The data request or command is unknown.", + ), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_on.assert_called_once() + + +@pytest.mark.parametrize("response", COMMAND_ERRORS) +async def test_errors( + hass: HomeAssistant, response: str, normal_config_entry: MockConfigEntry +) -> None: + """Tests service reason is handled.""" + + await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) + entity_id = "climate.test_climate" + + with ( + patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + return_value=response, + ) as mock_on, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_on.assert_called_once() + + +async def test_ignored_error( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests ignored error is handled.""" + + await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) + entity_id = "climate.test_climate" + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + return_value=COMMAND_IGNORED_REASON, + ) as mock_on: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_on.assert_called_once() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_asleep_or_offline( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + mock_wake_up: AsyncMock, + mock_vehicle_state: AsyncMock, + freezer: FrozenDateTimeFactory, + normal_config_entry: MockConfigEntry, + mock_request: AsyncMock, +) -> None: + """Tests asleep is handled.""" + + await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) + entity_id = "climate.test_climate" + mock_vehicle_data.assert_called_once() + + # Put the vehicle alseep + mock_vehicle_data.reset_mock() + mock_vehicle_data.side_effect = VehicleOffline + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_vehicle_data.assert_called_once() + mock_wake_up.reset_mock() + + # Run a command but fail trying to wake up the vehicle + mock_wake_up.side_effect = InvalidCommand + with pytest.raises( + HomeAssistantError, match="The data request or command is unknown." + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_wake_up.assert_called_once() + + mock_wake_up.side_effect = None + mock_wake_up.reset_mock() + + # Run a command but timeout trying to wake up the vehicle + mock_wake_up.return_value = VEHICLE_ASLEEP + mock_vehicle_state.return_value = VEHICLE_ASLEEP + with ( + patch("homeassistant.components.tesla_fleet.helpers.asyncio.sleep"), + pytest.raises(HomeAssistantError, match="Could not wake up vehicle"), + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_wake_up.assert_called_once() + mock_vehicle_state.assert_called() + + mock_wake_up.reset_mock() + mock_vehicle_state.reset_mock() + mock_wake_up.return_value = VEHICLE_ONLINE + mock_vehicle_state.return_value = VEHICLE_ONLINE + + # Run a command and wake up the vehicle immediately + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, blocking=True + ) + await hass.async_block_till_done() + mock_wake_up.assert_called_once() + + +async def test_climate_noscope( + hass: HomeAssistant, + readonly_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Tests with no command scopes.""" + await setup_platform(hass, readonly_config_entry, [Platform.CLIMATE]) + entity_id = "climate.test_climate" + + with pytest.raises( + ServiceValidationError, match="Climate mode off is not supported" + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match="Entity climate.test_climate does not support this service.", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, + blocking=True, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("entity_id", "high", "low"), + [ + ("climate.test_climate", 16, 28), + ("climate.test_cabin_overheat_protection", 30, 40), + ], +) +async def test_climate_notemp( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, + entity_id: str, + high: int, + low: int, +) -> None: + """Tests that set temp fails without a temp attribute.""" + + await setup_platform(hass, normal_config_entry, [Platform.CLIMATE]) + + with pytest.raises( + ServiceValidationError, match="Temperature is required for this action" + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_TARGET_TEMP_HIGH: high, + ATTR_TARGET_TEMP_LOW: low, + }, + blocking=True, + ) From 6cea6be4a7c64d7257f8ff050925ea65f81981a6 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 3 Sep 2024 14:59:01 +0200 Subject: [PATCH 0179/1309] Improve hassfest docker image (#125133) * Improve hassfest docker image * Use fixed uv version * Use cli params instead env * run hassfest * Exclude pycache --- script/hassfest/docker.py | 13 +++++-------- script/hassfest/docker/Dockerfile | 13 +++++-------- script/hassfest/docker/Dockerfile.dockerignore | 3 +++ 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 6e39a5c350b..bce77e1ece0 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -69,7 +69,7 @@ WORKDIR /config _HASSFEST_TEMPLATE = r"""# Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker -FROM python:alpine3.20 +FROM python:alpine ENV \ UV_SYSTEM_PYTHON=true \ @@ -79,20 +79,17 @@ SHELL ["/bin/sh", "-o", "pipefail", "-c"] ENTRYPOINT ["/usr/src/homeassistant/script/hassfest/docker/entrypoint.sh"] WORKDIR "/github/workspace" -# Install uv -COPY --from=ghcr.io/astral-sh/uv:{uv} /uv /bin/uv - COPY . /usr/src/homeassistant -RUN \ +# Uv is only needed during build +RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ - && cd /usr/src/homeassistant \ && uv pip install \ --no-build \ --no-cache \ - -c homeassistant/package_constraints.txt \ - -r requirements.txt \ + -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ + -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree=={pipdeptree} tqdm=={tqdm} ruff=={ruff} \ {required_components_packages} diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 4fc60c0c621..0d99b04c44c 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -1,7 +1,7 @@ # Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker -FROM python:alpine3.20 +FROM python:alpine ENV \ UV_SYSTEM_PYTHON=true \ @@ -11,20 +11,17 @@ SHELL ["/bin/sh", "-o", "pipefail", "-c"] ENTRYPOINT ["/usr/src/homeassistant/script/hassfest/docker/entrypoint.sh"] WORKDIR "/github/workspace" -# Install uv -COPY --from=ghcr.io/astral-sh/uv:0.2.27 /uv /bin/uv - COPY . /usr/src/homeassistant -RUN \ +# Uv is only needed during build +RUN --mount=from=ghcr.io/astral-sh/uv:0.2.27,source=/uv,target=/bin/uv \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ - && cd /usr/src/homeassistant \ && uv pip install \ --no-build \ --no-cache \ - -c homeassistant/package_constraints.txt \ - -r requirements.txt \ + -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ + -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.23.1 tqdm==4.66.4 ruff==0.6.2 \ PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.8.29 mutagen==1.47.0 diff --git a/script/hassfest/docker/Dockerfile.dockerignore b/script/hassfest/docker/Dockerfile.dockerignore index 75ed4f0e5d3..c109421fce1 100644 --- a/script/hassfest/docker/Dockerfile.dockerignore +++ b/script/hassfest/docker/Dockerfile.dockerignore @@ -6,3 +6,6 @@ !script/ script/hassfest/docker/ !script/hassfest/docker/entrypoint.sh + +# Temporary files +**/__pycache__ \ No newline at end of file From eda1656e757ce9d3aa92545fe1d275dc97859e81 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:22:38 +0100 Subject: [PATCH 0180/1309] Abort ring config_flow if account is already configured (#125120) * Abort ring config_flow if account is already configured * Update tests/components/ring/test_config_flow.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/ring/config_flow.py | 3 ++- homeassistant/components/ring/strings.json | 2 +- tests/components/ring/test_config_flow.py | 22 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index b82b4f22223..74546567270 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -65,6 +65,8 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() try: token = await validate_input(self.hass, user_input) except Require2FA: @@ -77,7 +79,6 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input[CONF_USERNAME]) return self.async_create_entry( title=user_input[CONF_USERNAME], data={CONF_USERNAME: user_input[CONF_USERNAME], CONF_TOKEN: token}, diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index ed0319b7a4b..6bd7d194136 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -27,7 +27,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py index bbaec2e37c4..d27c4878aea 100644 --- a/tests/components/ring/test_config_flow.py +++ b/tests/components/ring/test_config_flow.py @@ -220,3 +220,25 @@ async def test_reauth_error( "token": "new-foobar", } assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_account_configured( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_added_config_entry: Mock, +) -> None: + """Test that user cannot configure the same account twice.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "foo@bar.com", "password": "test-password"}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" From 334359bb0a118a917740682c0104a29d06187ec0 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 3 Sep 2024 06:23:07 -0700 Subject: [PATCH 0181/1309] Add Google Cloud Speech-to-Text (STT) (#120854) * Google Cloud * . * fix * mypy * add tests * Update .coveragerc * Update const.py * upload file, reconfigure and import flow * fixes * default to latest_short * mypy * update * Allow clearing options in the UI * update * update * update --- .../components/google_cloud/__init__.py | 2 +- .../components/google_cloud/config_flow.py | 20 ++- .../components/google_cloud/const.py | 164 ++++++++++++++++++ .../components/google_cloud/manifest.json | 5 +- .../components/google_cloud/strings.json | 3 +- homeassistant/components/google_cloud/stt.py | 147 ++++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../google_cloud/test_config_flow.py | 2 + 9 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/google_cloud/stt.py diff --git a/homeassistant/components/google_cloud/__init__.py b/homeassistant/components/google_cloud/__init__.py index 84848543790..9d1923fd87d 100644 --- a/homeassistant/components/google_cloud/__init__.py +++ b/homeassistant/components/google_cloud/__init__.py @@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = [Platform.TTS] +PLATFORMS = [Platform.STT, Platform.TTS] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/google_cloud/config_flow.py b/homeassistant/components/google_cloud/config_flow.py index bf97de67eb1..dec849de4e6 100644 --- a/homeassistant/components/google_cloud/config_flow.py +++ b/homeassistant/components/google_cloud/config_flow.py @@ -26,7 +26,16 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_KEY_FILE, CONF_SERVICE_ACCOUNT_INFO, DEFAULT_LANG, DOMAIN, TITLE +from .const import ( + CONF_KEY_FILE, + CONF_SERVICE_ACCOUNT_INFO, + CONF_STT_MODEL, + DEFAULT_LANG, + DEFAULT_STT_MODEL, + DOMAIN, + SUPPORTED_STT_MODELS, + TITLE, +) from .helpers import ( async_tts_voices, tts_options_schema, @@ -162,6 +171,15 @@ class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry): **tts_options_schema( self.options, voices, from_config_flow=True ).schema, + vol.Optional( + CONF_STT_MODEL, + default=DEFAULT_STT_MODEL, + ): SelectSelector( + SelectSelectorConfig( + mode=SelectSelectorMode.DROPDOWN, + options=SUPPORTED_STT_MODELS, + ) + ), } ), self.options, diff --git a/homeassistant/components/google_cloud/const.py b/homeassistant/components/google_cloud/const.py index 6a718bf35d3..f416d36483a 100644 --- a/homeassistant/components/google_cloud/const.py +++ b/homeassistant/components/google_cloud/const.py @@ -10,6 +10,7 @@ CONF_KEY_FILE = "key_file" DEFAULT_LANG = "en-US" +# TTS constants CONF_GENDER = "gender" CONF_VOICE = "voice" CONF_ENCODING = "encoding" @@ -18,3 +19,166 @@ CONF_PITCH = "pitch" CONF_GAIN = "gain" CONF_PROFILES = "profiles" CONF_TEXT_TYPE = "text_type" + +# STT constants +CONF_STT_MODEL = "stt_model" + +DEFAULT_STT_MODEL = "latest_short" + +# https://cloud.google.com/speech-to-text/docs/transcription-model +SUPPORTED_STT_MODELS = [ + "latest_long", + "latest_short", + "telephony", + "telephony_short", + "medical_dictation", + "medical_conversation", + "command_and_search", + "default", + "phone_call", + "video", +] + +# https://cloud.google.com/speech-to-text/docs/speech-to-text-supported-languages +STT_LANGUAGES = [ + "af-ZA", + "am-ET", + "ar-AE", + "ar-BH", + "ar-DZ", + "ar-EG", + "ar-IL", + "ar-IQ", + "ar-JO", + "ar-KW", + "ar-LB", + "ar-MA", + "ar-MR", + "ar-OM", + "ar-PS", + "ar-QA", + "ar-SA", + "ar-SY", + "ar-TN", + "ar-YE", + "az-AZ", + "bg-BG", + "bn-BD", + "bn-IN", + "bs-BA", + "ca-ES", + "cmn-Hans-CN", + "cmn-Hans-HK", + "cmn-Hant-TW", + "cs-CZ", + "da-DK", + "de-AT", + "de-CH", + "de-DE", + "el-GR", + "en-AU", + "en-CA", + "en-GB", + "en-GH", + "en-HK", + "en-IE", + "en-IN", + "en-KE", + "en-NG", + "en-NZ", + "en-PH", + "en-PK", + "en-SG", + "en-TZ", + "en-US", + "en-ZA", + "es-AR", + "es-BO", + "es-CL", + "es-CO", + "es-CR", + "es-DO", + "es-EC", + "es-ES", + "es-GT", + "es-HN", + "es-MX", + "es-NI", + "es-PA", + "es-PE", + "es-PR", + "es-PY", + "es-SV", + "es-US", + "es-UY", + "es-VE", + "et-EE", + "eu-ES", + "fa-IR", + "fi-FI", + "fil-PH", + "fr-BE", + "fr-CA", + "fr-CH", + "fr-FR", + "gl-ES", + "gu-IN", + "hi-IN", + "hr-HR", + "hu-HU", + "hy-AM", + "id-ID", + "is-IS", + "it-CH", + "it-IT", + "iw-IL", + "ja-JP", + "jv-ID", + "ka-GE", + "kk-KZ", + "km-KH", + "kn-IN", + "ko-KR", + "lo-LA", + "lt-LT", + "lv-LV", + "mk-MK", + "ml-IN", + "mn-MN", + "mr-IN", + "ms-MY", + "my-MM", + "ne-NP", + "nl-BE", + "nl-NL", + "no-NO", + "pa-Guru-IN", + "pl-PL", + "pt-BR", + "pt-PT", + "ro-RO", + "ru-RU", + "si-LK", + "sk-SK", + "sl-SI", + "sq-AL", + "sr-RS", + "su-ID", + "sv-SE", + "sw-KE", + "sw-TZ", + "ta-IN", + "ta-LK", + "ta-MY", + "ta-SG", + "te-IN", + "th-TH", + "tr-TR", + "uk-UA", + "ur-IN", + "ur-PK", + "uz-UZ", + "vi-VN", + "yue-Hant-HK", + "zu-ZA", +] diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index d0dda80a870..3e08b6254db 100644 --- a/homeassistant/components/google_cloud/manifest.json +++ b/homeassistant/components/google_cloud/manifest.json @@ -7,5 +7,8 @@ "documentation": "https://www.home-assistant.io/integrations/google_cloud", "integration_type": "service", "iot_class": "cloud_push", - "requirements": ["google-cloud-texttospeech==2.17.2"] + "requirements": [ + "google-cloud-texttospeech==2.17.2", + "google-cloud-speech==2.27.0" + ] } diff --git a/homeassistant/components/google_cloud/strings.json b/homeassistant/components/google_cloud/strings.json index 0a0804005de..3bf9d8c8489 100644 --- a/homeassistant/components/google_cloud/strings.json +++ b/homeassistant/components/google_cloud/strings.json @@ -24,7 +24,8 @@ "pitch": "Default pitch of the voice", "gain": "Default volume gain (in dB) of the voice", "profiles": "Default audio profiles", - "text_type": "Default text type" + "text_type": "Default text type", + "stt_model": "STT model" } } } diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py new file mode 100644 index 00000000000..13715ae29f8 --- /dev/null +++ b/homeassistant/components/google_cloud/stt.py @@ -0,0 +1,147 @@ +"""Support for the Google Cloud STT service.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, AsyncIterable +import logging + +from google.api_core.exceptions import GoogleAPIError, Unauthenticated +from google.cloud import speech_v1 + +from homeassistant.components.stt import ( + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechMetadata, + SpeechResult, + SpeechResultState, + SpeechToTextEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_SERVICE_ACCOUNT_INFO, + CONF_STT_MODEL, + DEFAULT_STT_MODEL, + DOMAIN, + STT_LANGUAGES, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Google Cloud speech platform via config entry.""" + service_account_info = config_entry.data[CONF_SERVICE_ACCOUNT_INFO] + client = speech_v1.SpeechAsyncClient.from_service_account_info(service_account_info) + async_add_entities([GoogleCloudSpeechToTextEntity(config_entry, client)]) + + +class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): + """Google Cloud STT entity.""" + + def __init__( + self, + entry: ConfigEntry, + client: speech_v1.SpeechAsyncClient, + ) -> None: + """Init Google Cloud STT entity.""" + self._attr_unique_id = f"{entry.entry_id}-stt" + self._attr_name = entry.title + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Google", + model="Cloud", + entry_type=dr.DeviceEntryType.SERVICE, + ) + self._entry = entry + self._client = client + self._model = entry.options.get(CONF_STT_MODEL, DEFAULT_STT_MODEL) + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return STT_LANGUAGES + + @property + def supported_formats(self) -> list[AudioFormats]: + """Return a list of supported formats.""" + return [AudioFormats.WAV, AudioFormats.OGG] + + @property + def supported_codecs(self) -> list[AudioCodecs]: + """Return a list of supported codecs.""" + return [AudioCodecs.PCM, AudioCodecs.OPUS] + + @property + def supported_bit_rates(self) -> list[AudioBitRates]: + """Return a list of supported bitrates.""" + return [AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[AudioSampleRates]: + """Return a list of supported samplerates.""" + return [AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[AudioChannels]: + """Return a list of supported channels.""" + return [AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] + ) -> SpeechResult: + """Process an audio stream to STT service.""" + streaming_config = speech_v1.StreamingRecognitionConfig( + config=speech_v1.RecognitionConfig( + encoding=( + speech_v1.RecognitionConfig.AudioEncoding.OGG_OPUS + if metadata.codec == AudioCodecs.OPUS + else speech_v1.RecognitionConfig.AudioEncoding.LINEAR16 + ), + sample_rate_hertz=metadata.sample_rate, + language_code=metadata.language, + model=self._model, + ) + ) + + async def request_generator() -> ( + AsyncGenerator[speech_v1.StreamingRecognizeRequest] + ): + # The first request must only contain a streaming_config + yield speech_v1.StreamingRecognizeRequest(streaming_config=streaming_config) + # All subsequent requests must only contain audio_content + async for audio_content in stream: + yield speech_v1.StreamingRecognizeRequest(audio_content=audio_content) + + try: + responses = await self._client.streaming_recognize( + requests=request_generator(), + timeout=10, + ) + + transcript = "" + async for response in responses: + _LOGGER.debug("response: %s", response) + if not response.results: + continue + result = response.results[0] + if not result.alternatives: + continue + transcript += response.results[0].alternatives[0].transcript + except GoogleAPIError as err: + _LOGGER.error("Error occurred during Google Cloud STT call: %s", err) + if isinstance(err, Unauthenticated): + self._entry.async_start_reauth(self.hass) + return SpeechResult(None, SpeechResultState.ERROR) + + return SpeechResult(transcript, SpeechResultState.SUCCESS) diff --git a/requirements_all.txt b/requirements_all.txt index e14ef8af35a..ebc621486c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -985,6 +985,9 @@ google-api-python-client==2.71.0 # homeassistant.components.google_pubsub google-cloud-pubsub==2.23.0 +# homeassistant.components.google_cloud +google-cloud-speech==2.27.0 + # homeassistant.components.google_cloud google-cloud-texttospeech==2.17.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b28d39da203..d151c8e6bc2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -835,6 +835,9 @@ google-api-python-client==2.71.0 # homeassistant.components.google_pubsub google-cloud-pubsub==2.23.0 +# homeassistant.components.google_cloud +google-cloud-speech==2.27.0 + # homeassistant.components.google_cloud google-cloud-texttospeech==2.17.2 diff --git a/tests/components/google_cloud/test_config_flow.py b/tests/components/google_cloud/test_config_flow.py index a5a51052e66..e4b4631f223 100644 --- a/tests/components/google_cloud/test_config_flow.py +++ b/tests/components/google_cloud/test_config_flow.py @@ -161,6 +161,7 @@ async def test_options_flow( "gain", "profiles", "text_type", + "stt_model", } assert mock_api_tts_from_service_account_info.list_voices.call_count == 2 @@ -179,5 +180,6 @@ async def test_options_flow( "gain": 0.0, "profiles": [], "text_type": "text", + "stt_model": "latest_short", } assert mock_api_tts_from_service_account_info.list_voices.call_count == 3 From fd01e22ca4c7c132ae2137cb055623f3efe31251 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 3 Sep 2024 15:24:49 +0200 Subject: [PATCH 0182/1309] Fix energy sensor for ThirdReality Matter powerplug (#125140) --- homeassistant/components/matter/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index c3ab18072f0..5d4ad900d8e 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -384,7 +384,7 @@ DISCOVERY_SCHEMAS = [ key="ThirdRealityEnergySensorWattAccumulated", device_class=SensorDeviceClass.ENERGY, entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, measurement_to_ha=lambda x: x / 1000, From cf10549df4b81493636e64c9b2000a7ef65ee140 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Sep 2024 15:25:35 +0200 Subject: [PATCH 0183/1309] Restore unnecessary assignment of Template.hass in event helper (#125143) --- homeassistant/helpers/event.py | 16 ++++++++++++++ tests/helpers/test_event.py | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 38f461d8d7a..97a85fdde89 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -981,6 +981,22 @@ class TrackTemplateResultInfo: self._last_result: dict[Template, bool | str | TemplateError] = {} + for track_template_ in track_templates: + if track_template_.template.hass: + continue + + # pylint: disable-next=import-outside-toplevel + from .frame import report + + report( + ( + "calls async_track_template_result with template without hass, " + "which will stop working in HA Core 2025.10" + ), + error_if_core=False, + ) + track_template_.template.hass = hass + self._rate_limit = KeyedRateLimit(hass) self._info: dict[Template, RenderInfo] = {} self._track_state_changes: _TrackStateChangeFiltered | None = None diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 6c71f1d8a7c..19f1ef5bb76 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4938,3 +4938,43 @@ async def test_async_track_state_report_event(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(tracker_called) == 2 unsub() + + +async def test_async_track_template_no_hass_deprecated( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test async_track_template with a template without hass is deprecated.""" + message = ( + "Detected code that calls async_track_template_result with template without " + "hass, which will stop working in HA Core 2025.10. Please report this issue." + ) + + async_track_template(hass, Template("blah"), lambda x, y, z: None) + assert message in caplog.text + caplog.clear() + + async_track_template(hass, Template("blah", hass), lambda x, y, z: None) + assert message not in caplog.text + caplog.clear() + + +async def test_async_track_template_result_no_hass_deprecated( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test async_track_template_result with a template without hass is deprecated.""" + message = ( + "Detected code that calls async_track_template_result with template without " + "hass, which will stop working in HA Core 2025.10. Please report this issue." + ) + + async_track_template_result( + hass, [TrackTemplate(Template("blah"), None)], lambda x, y, z: None + ) + assert message in caplog.text + caplog.clear() + + async_track_template_result( + hass, [TrackTemplate(Template("blah", hass), None)], lambda x, y, z: None + ) + assert message not in caplog.text + caplog.clear() From fdce5248111fa089912662e1cf1e758e9b4fa308 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 3 Sep 2024 15:27:33 +0200 Subject: [PATCH 0184/1309] Add Onkyo Receiver class to improve typing (#124190) --- .../components/onkyo/media_player.py | 42 ++++++++----------- homeassistant/components/onkyo/receiver.py | 28 +++++++++++++ 2 files changed, 46 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/onkyo/receiver.py diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 8d8f4d3bfd5..df1f25a196b 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass import logging from typing import Any @@ -30,6 +29,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.hass_dict import HassKey +from .receiver import Receiver, ReceiverInfo + _LOGGER = logging.getLogger(__name__) DOMAIN = "onkyo" @@ -143,16 +144,6 @@ ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema( SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output" -@dataclass -class ReceiverInfo: - """Onkyo Receiver information.""" - - host: str - port: int - model_name: str - identifier: str - - async def async_register_services(hass: HomeAssistant) -> None: """Register Onkyo services.""" @@ -189,7 +180,7 @@ async def async_setup_platform( """Set up the Onkyo platform.""" await async_register_services(hass) - receivers: dict[str, pyeiscp.Connection] = {} # indexed by host + receivers: dict[str, Receiver] = {} # indexed by host all_entities = hass.data.setdefault(DATA_MP_ENTITIES, []) host = config.get(CONF_HOST) @@ -234,31 +225,34 @@ async def async_setup_platform( """Receiver (re)connected.""" receiver = receivers[origin] _LOGGER.debug( - "Receiver (re)connected: %s (%s)", receiver.name, receiver.host + "Receiver (re)connected: %s (%s)", receiver.name, receiver.conn.host ) for entity in entities.values(): entity.backfill_state() _LOGGER.debug("Creating receiver: %s (%s)", info.model_name, info.host) - receiver = await pyeiscp.Connection.create( + connection = await pyeiscp.Connection.create( host=info.host, port=info.port, update_callback=async_onkyo_update_callback, connect_callback=async_onkyo_connect_callback, ) - receiver.model_name = info.model_name - receiver.identifier = info.identifier - receiver.name = name or info.model_name - receiver.discovered = discovered + receiver = Receiver( + conn=connection, + model_name=info.model_name, + identifier=info.identifier, + name=name or info.model_name, + discovered=discovered, + ) - receivers[receiver.host] = receiver + receivers[connection.host] = receiver # Discover what zones are available for the receiver by querying the power. # If we get a response for the specific zone, it means it is available. for zone in ZONES: - receiver.query_property(zone, "power") + receiver.conn.query_property(zone, "power") # Add the main zone to entities, since it is always active. _LOGGER.debug("Adding Main Zone on %s", receiver.name) @@ -306,7 +300,7 @@ async def async_setup_platform( @callback def close_receiver(_event): for receiver in receivers.values(): - receiver.close() + receiver.conn.close() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_receiver) @@ -323,7 +317,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity): def __init__( self, - receiver: pyeiscp.Connection, + receiver: Receiver, sources: dict[str, str], zone: str, max_volume: int, @@ -369,12 +363,12 @@ class OnkyoMediaPlayer(MediaPlayerEntity): @callback def _update_receiver(self, propname: str, value: Any) -> None: """Update a property in the receiver.""" - self._receiver.update_property(self._zone, propname, value) + self._receiver.conn.update_property(self._zone, propname, value) @callback def _query_receiver(self, propname: str) -> None: """Cause the receiver to send an update about a property.""" - self._receiver.query_property(self._zone, propname) + self._receiver.conn.query_property(self._zone, propname) async def async_turn_on(self) -> None: """Turn the media player on.""" diff --git a/homeassistant/components/onkyo/receiver.py b/homeassistant/components/onkyo/receiver.py new file mode 100644 index 00000000000..eb20f327b69 --- /dev/null +++ b/homeassistant/components/onkyo/receiver.py @@ -0,0 +1,28 @@ +"""Onkyo receiver.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pyeiscp + + +@dataclass +class Receiver: + """Onkyo receiver.""" + + conn: pyeiscp.Connection + model_name: str + identifier: str + name: str + discovered: bool + + +@dataclass +class ReceiverInfo: + """Onkyo receiver information.""" + + host: str + port: int + model_name: str + identifier: str From 491bde181c9b5c33cfe712edc6da24a6297d1109 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Sep 2024 03:29:02 -1000 Subject: [PATCH 0185/1309] Speed up hassio send_command url check (#125122) * Speed up hassio send_command url check The send_command call checked the resulting path to make sure that the input path was not modified when converting to a URL. Since the host is is pre-set, we only need to check the processed raw_path matches command instead of converting back to a string, and than comparing it against another constructed string. * Speed up hassio send_command url check The send_command call checked the resulting path to make sure that the input path was not modified when converting to a URL. Since the host is is pre-set, we only need to check the processed raw_path matches command instead of converting back to a string, and than comparing it against another constructed string. * adjust --- homeassistant/components/hassio/handler.py | 3 +-- tests/components/hassio/test_handler.py | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 305b9d4961b..c57e43f73f3 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -568,14 +568,13 @@ class HassIO: This method is a coroutine. """ - url = f"http://{self._ip}{command}" joined_url = self._base_url.join(URL(command)) # This check is to make sure the normalized URL string # is the same as the URL string that was passed in. If # they are different, then the passed in command URL # contained characters that were removed by the normalization # such as ../../../../etc/passwd - if url != str(joined_url): + if joined_url.raw_path != command: _LOGGER.error("Invalid request %s", command) raise HassioAPIError diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index c5fa6ff8254..949f96ece38 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -468,4 +468,11 @@ async def test_send_command_invalid_command(hass: HomeAssistant) -> None: """Test send command fails when command is invalid.""" hassio: HassIO = hass.data["hassio"] with pytest.raises(HassioAPIError): + # absolute path await hassio.send_command("/test/../bad") + with pytest.raises(HassioAPIError): + # relative path + await hassio.send_command("test/../bad") + with pytest.raises(HassioAPIError): + # relative path with percent encoding + await hassio.send_command("test/%2E%2E/bad") From d6bd4312ab0028e639431bc2a3da29651ca5f1c9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Sep 2024 15:34:31 +0200 Subject: [PATCH 0186/1309] Add explaining comments in cv.template tests (#125081) --- tests/helpers/test_config_validation.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 57c712e2f10..1608a856de8 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -671,10 +671,12 @@ def test_template(hass: HomeAssistant) -> None: "Hello", "{{ beer }}", "{% if 1 == 1 %}Hello{% else %}World{% endif %}", - # Function added as an extension by Home Assistant + # Function 'expand' added as an extension by Home Assistant "{{ expand('group.foo')|map(attribute='entity_id')|list }}", - # Filter added as an extension by Home Assistant + # Filter 'expand' added as an extension by Home Assistant "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", + # Non existing function 'no_such_function' is not detected by Jinja2 + "{{ no_such_function('group.foo')|map(attribute='entity_id')|list }}", ) for value in options: schema(value) @@ -700,8 +702,11 @@ async def test_template_no_hass(hass: HomeAssistant) -> None: "Hello", "{{ beer }}", "{% if 1 == 1 %}Hello{% else %}World{% endif %}", - # Function added as an extension by Home Assistant + # Function 'expand' added as an extension by Home Assistant, no error + # because non existing functions are not detected by Jinja2 "{{ expand('group.foo')|map(attribute='entity_id')|list }}", + # Non existing function 'no_such_function' is not detected by Jinja2 + "{{ no_such_function('group.foo')|map(attribute='entity_id')|list }}", ) for value in options: await hass.async_add_executor_job(schema, value) @@ -725,10 +730,12 @@ def test_dynamic_template(hass: HomeAssistant) -> None: options = ( "{{ beer }}", "{% if 1 == 1 %}Hello{% else %}World{% endif %}", - # Function added as an extension by Home Assistant + # Function 'expand' added as an extension by Home Assistant "{{ expand('group.foo')|map(attribute='entity_id')|list }}", - # Filter added as an extension by Home Assistant + # Filter 'expand' added as an extension by Home Assistant "{{ ['group.foo']|expand|map(attribute='entity_id')|list }}", + # Non existing function 'no_such_function' is not detected by Jinja2 + "{{ no_such_function('group.foo')|map(attribute='entity_id')|list }}", ) for value in options: schema(value) @@ -754,8 +761,11 @@ async def test_dynamic_template_no_hass(hass: HomeAssistant) -> None: options = ( "{{ beer }}", "{% if 1 == 1 %}Hello{% else %}World{% endif %}", - # Function added as an extension by Home Assistant + # Function 'expand' added as an extension by Home Assistant, no error + # because non existing functions are not detected by Jinja2 "{{ expand('group.foo')|map(attribute='entity_id')|list }}", + # Non existing function 'no_such_function' is not detected by Jinja2 + "{{ no_such_function('group.foo')|map(attribute='entity_id')|list }}", ) for value in options: await hass.async_add_executor_job(schema, value) From 822660732b0b47baf264708eb582e7e7e22133e0 Mon Sep 17 00:00:00 2001 From: Jakob Schlyter Date: Tue, 3 Sep 2024 15:45:37 +0200 Subject: [PATCH 0187/1309] Support setting Amazon Polly engine in service call (#120226) --- homeassistant/components/amazon_polly/tts.py | 23 ++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 1fc972fa3a1..62852848a9c 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections import defaultdict import logging from typing import Any, Final @@ -114,6 +115,8 @@ def get_engine( all_voices: dict[str, dict[str, str]] = {} + all_engines: dict[str, set[str]] = defaultdict(set) + all_voices_req = polly_client.describe_voices() for voice in all_voices_req.get("Voices", []): @@ -124,8 +127,12 @@ def get_engine( language_code: str | None = voice.get("LanguageCode") if language_code is not None and language_code not in supported_languages: supported_languages.append(language_code) + for engine in voice.get("SupportedEngines"): + all_engines[engine].add(voice_id) - return AmazonPollyProvider(polly_client, config, supported_languages, all_voices) + return AmazonPollyProvider( + polly_client, config, supported_languages, all_voices, all_engines + ) class AmazonPollyProvider(Provider): @@ -137,13 +144,16 @@ class AmazonPollyProvider(Provider): config: ConfigType, supported_languages: list[str], all_voices: dict[str, dict[str, str]], + all_engines: dict[str, set[str]], ) -> None: """Initialize Amazon Polly provider for TTS.""" self.client = polly_client self.config = config self.supported_langs = supported_languages self.all_voices = all_voices + self.all_engines = all_engines self.default_voice: str = self.config[CONF_VOICE] + self.default_engine: str = self.config[CONF_ENGINE] self.name = "Amazon Polly" @property @@ -159,12 +169,12 @@ class AmazonPollyProvider(Provider): @property def default_options(self) -> dict[str, str]: """Return dict include default options.""" - return {CONF_VOICE: self.default_voice} + return {CONF_VOICE: self.default_voice, CONF_ENGINE: self.default_engine} @property def supported_options(self) -> list[str]: """Return a list of supported options.""" - return [CONF_VOICE] + return [CONF_VOICE, CONF_ENGINE] def get_tts_audio( self, @@ -179,9 +189,14 @@ class AmazonPollyProvider(Provider): _LOGGER.error("%s does not support the %s language", voice_id, language) return None, None + engine = options.get(CONF_ENGINE, self.default_engine) + if voice_id not in self.all_engines[engine]: + _LOGGER.error("%s does not support the %s engine", voice_id, engine) + return None, None + _LOGGER.debug("Requesting TTS file for text: %s", message) resp = self.client.synthesize_speech( - Engine=self.config[CONF_ENGINE], + Engine=engine, OutputFormat=self.config[CONF_OUTPUT_FORMAT], SampleRate=self.config[CONF_SAMPLE_RATE], Text=message, From 733bbf9cd13b2fdb40540b18dd273ab9a9272f6c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Sep 2024 15:46:05 +0200 Subject: [PATCH 0188/1309] Bump actions/upload-artifact from 4.3.6 to 4.4.0 (#125056) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.6 to 4.4.0. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4.3.6...v4.4.0) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .github/workflows/builder.yml | 2 +- .github/workflows/ci.yaml | 20 ++++++++++---------- .github/workflows/wheels.yml | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 910e179cd8e..ddb204ca42d 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -69,7 +69,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: translations path: translations.tar.gz diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5d21c2c7b04..d35187a3c45 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -634,7 +634,7 @@ jobs: . venv/bin/activate pip-licenses --format=json --output-file=licenses.json - name: Upload licenses - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: licenses path: licenses.json @@ -844,7 +844,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: pytest_buckets path: pytest_buckets.txt @@ -945,14 +945,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1071,7 +1071,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1079,7 +1079,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1198,7 +1198,7 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1206,7 +1206,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1340,14 +1340,14 @@ jobs: 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 735163e3b12..98585a97c6b 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -82,14 +82,14 @@ jobs: ) > .env_file - name: Upload env_file - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: env_file path: ./.env_file overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: requirements_diff path: ./requirements_diff.txt @@ -101,7 +101,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@v4.3.6 + uses: actions/upload-artifact@v4.4.0 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt From 8e3ad2d1f320ecd6670d54f467c629df8a8a9339 Mon Sep 17 00:00:00 2001 From: S <1311577+s0129@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:46:57 +0100 Subject: [PATCH 0189/1309] Extended epson projector integration to include serial connections (#121630) * Extended epson projector integration to include serial connections * Fix review changes * Improve epson types and translations * Fix comment --------- Co-authored-by: Joostlek --- homeassistant/components/epson/__init__.py | 41 +++++++++++++++++-- homeassistant/components/epson/config_flow.py | 20 ++++++++- homeassistant/components/epson/const.py | 2 + homeassistant/components/epson/strings.json | 11 ++++- tests/components/epson/test_config_flow.py | 8 +++- tests/components/epson/test_init.py | 37 +++++++++++++++++ tests/components/epson/test_media_player.py | 4 +- 7 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 tests/components/epson/test_init.py diff --git a/homeassistant/components/epson/__init__.py b/homeassistant/components/epson/__init__.py index 5171865594d..715b55824b4 100644 --- a/homeassistant/components/epson/__init__.py +++ b/homeassistant/components/epson/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, HTTP +from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP from .exceptions import CannotConnect, PoweredOff PLATFORMS = [Platform.MEDIA_PLAYER] @@ -22,13 +22,17 @@ _LOGGER = logging.getLogger(__name__) async def validate_projector( - hass: HomeAssistant, host, check_power=True, check_powered_on=True + hass: HomeAssistant, + host: str, + conn_type: str, + check_power: bool = True, + check_powered_on: bool = True, ): """Validate the given projector host allows us to connect.""" epson_proj = Projector( host=host, websession=async_get_clientsession(hass, verify_ssl=False), - type=HTTP, + type=conn_type, ) if check_power: _power = await epson_proj.get_power() @@ -46,6 +50,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: projector = await validate_projector( hass=hass, host=entry.data[CONF_HOST], + conn_type=entry.data[CONF_CONNECTION_TYPE], check_power=False, check_powered_on=False, ) @@ -60,5 +65,33 @@ 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) + projector = hass.data[DOMAIN].pop(entry.entry_id) + projector.close() return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1 or config_entry.minor_version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1 and config_entry.minor_version == 1: + new_data = {**config_entry.data} + new_data[CONF_CONNECTION_TYPE] = HTTP + + hass.config_entries.async_update_entry( + config_entry, data=new_data, version=1, minor_version=2 + ) + + _LOGGER.debug( + "Migration to configuration version %s successful", config_entry.version + ) + + return True diff --git a/homeassistant/components/epson/config_flow.py b/homeassistant/components/epson/config_flow.py index 1e3b006a984..c54bff2eea9 100644 --- a/homeassistant/components/epson/config_flow.py +++ b/homeassistant/components/epson/config_flow.py @@ -7,13 +7,21 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from . import validate_projector -from .const import DOMAIN +from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP, SERIAL from .exceptions import CannotConnect, PoweredOff +ALLOWED_CONNECTION_TYPE = [HTTP, SERIAL] + DATA_SCHEMA = vol.Schema( { + vol.Required(CONF_CONNECTION_TYPE, default=HTTP): SelectSelector( + SelectSelectorConfig( + options=ALLOWED_CONNECTION_TYPE, translation_key="connection_type" + ) + ), vol.Required(CONF_HOST): str, vol.Required(CONF_NAME, default=DOMAIN): str, } @@ -26,6 +34,7 @@ class EpsonConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for epson.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -33,12 +42,16 @@ class EpsonConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors = {} if user_input is not None: + # Epson projector doesn't appear to need to be on for serial + check_power = user_input[CONF_CONNECTION_TYPE] != SERIAL + projector = None try: projector = await validate_projector( hass=self.hass, + conn_type=user_input[CONF_CONNECTION_TYPE], host=user_input[CONF_HOST], check_power=True, - check_powered_on=True, + check_powered_on=check_power, ) except CannotConnect: errors["base"] = "cannot_connect" @@ -55,6 +68,9 @@ class EpsonConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=user_input.pop(CONF_NAME), data=user_input ) + finally: + if projector: + projector.close() return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) diff --git a/homeassistant/components/epson/const.py b/homeassistant/components/epson/const.py index 06ef9f25e35..5bc5f57cb3f 100644 --- a/homeassistant/components/epson/const.py +++ b/homeassistant/components/epson/const.py @@ -2,6 +2,8 @@ DOMAIN = "epson" SERVICE_SELECT_CMODE = "select_cmode" +CONF_CONNECTION_TYPE = "connection_type" ATTR_CMODE = "cmode" HTTP = "http" +SERIAL = "serial" diff --git a/homeassistant/components/epson/strings.json b/homeassistant/components/epson/strings.json index 94544c32d1d..fb8d7ab5fdd 100644 --- a/homeassistant/components/epson/strings.json +++ b/homeassistant/components/epson/strings.json @@ -3,11 +3,12 @@ "step": { "user": { "data": { + "connection_type": "Connection type", "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" }, "data_description": { - "host": "The hostname or IP address of your Epson projector." + "host": "The hostname, IP address or serial port of your Epson projector." } } }, @@ -30,5 +31,13 @@ } } } + }, + "selector": { + "connection_type": { + "options": { + "http": "HTTP", + "serial": "Serial" + } + } } } diff --git a/tests/components/epson/test_config_flow.py b/tests/components/epson/test_config_flow.py index d485a4bfdef..f727185362c 100644 --- a/tests/components/epson/test_config_flow.py +++ b/tests/components/epson/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch from epson_projector.const import PWR_OFF_STATE from homeassistant import config_entries -from homeassistant.components.epson.const import DOMAIN +from homeassistant.components.epson.const import CONF_CONNECTION_TYPE, DOMAIN, HTTP from homeassistant.const import CONF_HOST, CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -33,6 +33,10 @@ async def test_form(hass: HomeAssistant) -> None: patch( "homeassistant.components.epson.async_setup_entry", return_value=True, + ), + patch( + "homeassistant.components.epson.Projector.close", + return_value=True, ) as mock_setup_entry, ): result2 = await hass.config_entries.flow.async_configure( @@ -43,7 +47,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "test-epson" - assert result2["data"] == {CONF_HOST: "1.1.1.1"} + assert result2["data"] == {CONF_CONNECTION_TYPE: HTTP, CONF_HOST: "1.1.1.1"} assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/epson/test_init.py b/tests/components/epson/test_init.py new file mode 100644 index 00000000000..964f9e915ab --- /dev/null +++ b/tests/components/epson/test_init.py @@ -0,0 +1,37 @@ +"""Test the epson init.""" + +from unittest.mock import patch + +from homeassistant.components.epson.const import CONF_CONNECTION_TYPE, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_migrate_entry(hass: HomeAssistant) -> None: + """Test successful migration of entry data from version 1 to 1.2.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + title="Epson", + version=1, + minor_version=1, + data={CONF_HOST: "1.1.1.1"}, + entry_id="1cb78c095906279574a0442a1f0003ef", + ) + assert mock_entry.version == 1 + + mock_entry.add_to_hass(hass) + + # Create entity entry to migrate to new unique ID + with patch("homeassistant.components.epson.Projector.get_power"): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + # Check that is now has connection_type + assert mock_entry + assert mock_entry.version == 1 + assert mock_entry.minor_version == 2 + assert mock_entry.data.get(CONF_CONNECTION_TYPE) == "http" + assert mock_entry.data.get(CONF_HOST) == "1.1.1.1" diff --git a/tests/components/epson/test_media_player.py b/tests/components/epson/test_media_player.py index e529746dcd0..188fdd5b700 100644 --- a/tests/components/epson/test_media_player.py +++ b/tests/components/epson/test_media_player.py @@ -5,7 +5,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.epson.const import DOMAIN +from homeassistant.components.epson.const import CONF_CONNECTION_TYPE, DOMAIN, HTTP from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,7 +22,7 @@ async def test_set_unique_id( entry = MockConfigEntry( domain=DOMAIN, title="Epson", - data={CONF_HOST: "1.1.1.1"}, + data={CONF_CONNECTION_TYPE: HTTP, CONF_HOST: "1.1.1.1"}, entry_id="1cb78c095906279574a0442a1f0003ef", ) entry.add_to_hass(hass) From 7c15075231b5d46c56f268909417de3a7216b2a8 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 3 Sep 2024 15:49:11 +0200 Subject: [PATCH 0190/1309] Clean up Z-wave error log when raising in service handlers (#125138) --- homeassistant/components/zwave_js/entity.py | 5 +++-- homeassistant/components/zwave_js/sensor.py | 7 +++---- tests/components/zwave_js/test_sensor.py | 7 ++++++- tests/components/zwave_js/test_switch.py | 6 +++++- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 4a6f87cc032..d41c8bb01d0 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -335,5 +335,6 @@ class ZWaveBaseEntity(Entity): value, new_value, options=options, wait_for_result=wait_for_result ) except BaseZwaveJSServerError as err: - LOGGER.error("Unable to set value %s: %s", value.value_id, err) - raise HomeAssistantError from err + raise HomeAssistantError( + f"Unable to set value {value.value_id}: {err}" + ) from err diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index e43c620ff54..428bf504510 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -750,10 +750,9 @@ class ZWaveMeterSensor(ZWaveNumericSensor): CommandClass.METER, "reset", *args, wait_for_result=False ) except BaseZwaveJSServerError as err: - LOGGER.error( - "Failed to reset meters on node %s endpoint %s: %s", node, endpoint, err - ) - raise HomeAssistantError from err + raise HomeAssistantError( + f"Failed to reset meters on node {node} endpoint {endpoint}: {err}" + ) from err LOGGER.debug( "Meters on node %s endpoint %s reset with the following options: %s", node, diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 02b3df17e22..19f8aeece36 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -522,7 +522,7 @@ async def test_reset_meter( "test", 1, "test" ) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( DOMAIN, SERVICE_RESET_METER, @@ -530,6 +530,11 @@ async def test_reset_meter( blocking=True, ) + assert str(err.value) == ( + "Failed to reset meters on node Node(node_id=102) endpoint 0: " + "zwave_error: Z-Wave error 1 - test" + ) + async def test_meter_attributes( hass: HomeAssistant, client, aeon_smart_switch_6, integration diff --git a/tests/components/zwave_js/test_switch.py b/tests/components/zwave_js/test_switch.py index c18c0c4359e..810ce38cf99 100644 --- a/tests/components/zwave_js/test_switch.py +++ b/tests/components/zwave_js/test_switch.py @@ -286,7 +286,11 @@ async def test_config_parameter_switch( client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") # Test turning off error raises proper exception - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {"entity_id": switch_entity_id}, blocking=True ) + + assert str(err.value) == ( + "Unable to set value 32-112-0-20: zwave_error: Z-Wave error 1 - test" + ) From 436ac72b821df40e861040e88a40a5a2ca9a21a9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 3 Sep 2024 16:56:00 +0300 Subject: [PATCH 0191/1309] End deprecation setting attributes directly on config entry (#123729) * End deprecation setting attr directly on config entry * Update ollama test * Fix android_tv --- homeassistant/config_entries.py | 22 +++---------------- .../androidtv_remote/test_media_player.py | 10 +++++---- .../androidtv_remote/test_remote.py | 10 ++++----- tests/components/ollama/test_conversation.py | 4 +++- tests/test_config_entries.py | 9 ++------ 5 files changed, 18 insertions(+), 37 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f3b0aa03383..e64d2001efa 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -434,26 +434,10 @@ class ConfigEntry(Generic[_DataT]): def __setattr__(self, key: str, value: Any) -> None: """Set an attribute.""" if key in UPDATE_ENTRY_CONFIG_ENTRY_ATTRS: - if key == "unique_id": - # Setting unique_id directly will corrupt internal state - # There is no deprecation period for this key - # as changing them will corrupt internal state - # so we raise an error here - raise AttributeError( - "unique_id cannot be changed directly, use async_update_entry instead" - ) - report( - f'sets "{key}" directly to update a config entry. This is deprecated and will' - " stop working in Home Assistant 2024.9, it should be updated to use" - " async_update_entry instead", - error_if_core=False, + raise AttributeError( + f"{key} cannot be changed directly, use async_update_entry instead" ) - - elif key in FROZEN_CONFIG_ENTRY_ATTRS: - # These attributes are frozen and cannot be changed - # There is no deprecation period for these - # as changing them will corrupt internal state - # so we raise an error here + if key in FROZEN_CONFIG_ENTRY_ATTRS: raise AttributeError(f"{key} cannot be changed") super().__setattr__(key, value) diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py index 46678f18fd3..e292a5b273f 100644 --- a/tests/components/androidtv_remote/test_media_player.py +++ b/tests/components/androidtv_remote/test_media_player.py @@ -20,10 +20,11 @@ async def test_media_player_receives_push_updates( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote media player receives push updates and state is updated.""" - mock_config_entry.options = { - "apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}} - } mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, + options={"apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}}}, + ) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -322,7 +323,7 @@ async def test_browse_media( mock_api: MagicMock, ) -> None: """Test the Android TV Remote media player browse media.""" - mock_config_entry.options = { + new_options = { "apps": { "com.google.android.youtube.tv": { "app_name": "YouTube", @@ -332,6 +333,7 @@ async def test_browse_media( } } mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, options=new_options) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index 7ca63685747..b3c3ce1c283 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -19,10 +19,9 @@ async def test_remote_receives_push_updates( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote receives push updates and state is updated.""" - mock_config_entry.options = { - "apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}} - } + new_options = {"apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}}} mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, options=new_options) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -53,10 +52,9 @@ async def test_remote_toggles( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote toggles.""" - mock_config_entry.options = { - "apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}} - } + new_options = {"apps": {"com.google.android.youtube.tv": {"app_name": "YouTube"}}} mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry(mock_config_entry, options=new_options) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index f10805e747d..6c34b8e0052 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -482,8 +482,10 @@ async def test_message_history_unlimited( "ollama.AsyncClient.chat", return_value={"message": {"role": "assistant", "content": "test response"}}, ), - patch.object(mock_config_entry, "options", {ollama.CONF_MAX_HISTORY: 0}), ): + hass.config_entries.async_update_entry( + mock_config_entry, options={ollama.CONF_MAX_HISTORY: 0} + ) for i in range(100): result = await conversation.async_converse( hass, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3042ccb28d9..d01febd6904 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5437,13 +5437,8 @@ async def test_report_direct_mutation_of_config_entry( entry = MockConfigEntry(domain="test") entry.add_to_hass(hass) - setattr(entry, field, "new_value") - - assert ( - f'Detected code that sets "{field}" directly to update a config entry. ' - "This is deprecated and will stop working in Home Assistant 2024.9, " - "it should be updated to use async_update_entry instead. Please report this issue." - ) in caplog.text + with pytest.raises(AttributeError): + setattr(entry, field, "new_value") async def test_updating_non_added_entry_raises(hass: HomeAssistant) -> None: From d827c53a855075f8dbef51e2e147992f6d8452ef Mon Sep 17 00:00:00 2001 From: mvn23 Date: Tue, 3 Sep 2024 15:59:12 +0200 Subject: [PATCH 0192/1309] Remove opentherm_gw options migration (#125046) --- .../components/opentherm_gw/__init__.py | 13 ----- .../opentherm_gw/test_config_flow.py | 53 ------------------- 2 files changed, 66 deletions(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index d8c352f3768..a57ae7db601 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -46,8 +46,6 @@ from .const import ( CONF_CLIMATE, CONF_FLOOR_TEMP, CONF_PRECISION, - CONF_READ_PRECISION, - CONF_SET_PRECISION, CONNECTION_TIMEOUT, DATA_GATEWAYS, DATA_OPENTHERM_GW, @@ -109,17 +107,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b gateway = OpenThermGatewayHub(hass, config_entry) hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway - if config_entry.options.get(CONF_PRECISION): - migrate_options = dict(config_entry.options) - migrate_options.update( - { - CONF_READ_PRECISION: config_entry.options[CONF_PRECISION], - CONF_SET_PRECISION: config_entry.options[CONF_PRECISION], - } - ) - del migrate_options[CONF_PRECISION] - hass.config_entries.async_update_entry(config_entry, options=migrate_options) - # Migration can be removed in 2025.4.0 dev_reg = dr.async_get(hass) if ( diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index e61a87bb55e..504a97dc953 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -8,7 +8,6 @@ from serial import SerialException from homeassistant import config_entries from homeassistant.components.opentherm_gw.const import ( CONF_FLOOR_TEMP, - CONF_PRECISION, CONF_READ_PRECISION, CONF_SET_PRECISION, CONF_TEMPORARY_OVRD_MODE, @@ -204,58 +203,6 @@ async def test_form_connection_error(hass: HomeAssistant) -> None: assert len(mock_connect.mock_calls) == 1 -async def test_options_migration(hass: HomeAssistant) -> None: - """Test migration of precision option after update.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="Mock Gateway", - data={ - CONF_NAME: "Test Entry 1", - CONF_DEVICE: "/dev/ttyUSB0", - CONF_ID: "test_entry_1", - }, - options={ - CONF_FLOOR_TEMP: True, - CONF_PRECISION: PRECISION_TENTHS, - }, - ) - entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.opentherm_gw.OpenThermGatewayHub.connect_and_subscribe", - return_value=True, - ), - patch( - "homeassistant.components.opentherm_gw.async_setup", - return_value=True, - ), - patch( - "pyotgw.status.StatusManager._process_updates", - return_value=None, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init( - entry.entry_id, context={"source": config_entries.SOURCE_USER}, data=None - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={}, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"][CONF_READ_PRECISION] == PRECISION_TENTHS - assert result["data"][CONF_SET_PRECISION] == PRECISION_TENTHS - assert result["data"][CONF_FLOOR_TEMP] is True - - async def test_options_form(hass: HomeAssistant) -> None: """Test the options form.""" entry = MockConfigEntry( From 2fa3b9070c77b4853f369be605cf23536b71b139 Mon Sep 17 00:00:00 2001 From: UltimateGG Date: Tue, 3 Sep 2024 09:31:48 -0500 Subject: [PATCH 0193/1309] Fix updating insteon modem configuration while disconnected (#121918) #121917 Fix updating insteon modem configuration while disconnected --- homeassistant/components/insteon/api/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/insteon/api/config.py b/homeassistant/components/insteon/api/config.py index 8a617911d1e..88c062c3271 100644 --- a/homeassistant/components/insteon/api/config.py +++ b/homeassistant/components/insteon/api/config.py @@ -211,7 +211,7 @@ async def websocket_update_modem_config( """Get the schema for the modem configuration.""" config = msg["config"] config_entry = get_insteon_config_entry(hass) - is_connected = devices.modem.connected + is_connected = devices.modem is not None and devices.modem.connected if not await _async_connect(**config): connection.send_error( From 96be3e25053e39970e89d44c0dd7431a4f1eadb7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:39:06 +0200 Subject: [PATCH 0194/1309] Use SnapshotAssertion in more AVM Fritz!Box Tools tests (#125037) use SnapshotAssertion in more tests --- .../fritz/snapshots/test_button.ambr | 235 ++++++ .../fritz/snapshots/test_diagnostics.ambr | 67 ++ .../fritz/snapshots/test_sensor.ambr | 771 ++++++++++++++++++ .../fritz/snapshots/test_switch.ambr | 424 ++++++++++ .../fritz/snapshots/test_update.ambr | 169 ++++ tests/components/fritz/test_button.py | 27 +- tests/components/fritz/test_diagnostics.py | 67 +- tests/components/fritz/test_sensor.py | 122 +-- tests/components/fritz/test_switch.py | 26 +- tests/components/fritz/test_update.py | 93 +-- 10 files changed, 1771 insertions(+), 230 deletions(-) create mode 100644 tests/components/fritz/snapshots/test_button.ambr create mode 100644 tests/components/fritz/snapshots/test_diagnostics.ambr create mode 100644 tests/components/fritz/snapshots/test_sensor.ambr create mode 100644 tests/components/fritz/snapshots/test_switch.ambr create mode 100644 tests/components/fritz/snapshots/test_update.ambr diff --git a/tests/components/fritz/snapshots/test_button.ambr b/tests/components/fritz/snapshots/test_button.ambr new file mode 100644 index 00000000000..ed0b0e72160 --- /dev/null +++ b/tests/components/fritz/snapshots/test_button.ambr @@ -0,0 +1,235 @@ +# serializer version: 1 +# name: test_button_setup[button.mock_title_cleanup-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_title_cleanup', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cleanup', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cleanup', + 'unique_id': '1C:ED:6F:12:34:11-cleanup', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_setup[button.mock_title_cleanup-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Cleanup', + }), + 'context': , + 'entity_id': 'button.mock_title_cleanup', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_setup[button.mock_title_firmware_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_title_firmware_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware update', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'firmware_update', + 'unique_id': '1C:ED:6F:12:34:11-firmware_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_setup[button.mock_title_firmware_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'update', + 'friendly_name': 'Mock Title Firmware update', + }), + 'context': , + 'entity_id': 'button.mock_title_firmware_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_setup[button.mock_title_reconnect-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_title_reconnect', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reconnect', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reconnect', + 'unique_id': '1C:ED:6F:12:34:11-reconnect', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_setup[button.mock_title_reconnect-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Mock Title Reconnect', + }), + 'context': , + 'entity_id': 'button.mock_title_reconnect', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_setup[button.mock_title_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_title_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_setup[button.mock_title_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Mock Title Restart', + }), + 'context': , + 'entity_id': 'button.mock_title_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_setup[button.printer_wake_on_lan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.printer_wake_on_lan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lan-pending', + 'original_name': 'printer Wake on LAN', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:BB:CC:00:11:22_wake_on_lan', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_setup[button.printer_wake_on_lan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'printer Wake on LAN', + 'icon': 'mdi:lan-pending', + }), + 'context': , + 'entity_id': 'button.printer_wake_on_lan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..4b5b8bdea3b --- /dev/null +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_info': dict({ + 'client_devices': list([ + dict({ + 'connected_to': 'fritz.box', + 'connection_type': 'LAN', + 'hostname': 'printer', + 'is_connected': True, + 'wan_access': True, + }), + ]), + 'connection_type': 'WANPPPConnection', + 'current_firmware': '7.29', + 'discovered_services': list([ + 'DeviceInfo1', + 'Hosts1', + 'LANEthernetInterfaceConfig1', + 'Layer3Forwarding1', + 'UserInterface1', + 'WANCommonIFC1', + 'WANCommonInterfaceConfig1', + 'WANDSLInterfaceConfig1', + 'WANIPConn1', + 'WANPPPConnection1', + 'WLANConfiguration1', + 'X_AVM-DE_Homeauto1', + 'X_AVM-DE_HostFilter1', + ]), + 'is_router': True, + 'last_exception': None, + 'last_update success': True, + 'latest_firmware': None, + 'mesh_role': 'master', + 'model': 'FRITZ!Box 7530 AX', + 'unique_id': '1C:ED:XX:XX:34:11', + 'update_available': False, + 'wan_link_properties': dict({ + 'NewLayer1DownstreamMaxBitRate': 318557000, + 'NewLayer1UpstreamMaxBitRate': 51805000, + 'NewPhysicalLinkStatus': 'Up', + 'NewWANAccessType': 'DSL', + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': 'fake_host', + 'password': '**REDACTED**', + 'port': '1234', + 'ssl': False, + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'fritz', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + }) +# --- diff --git a/tests/components/fritz/snapshots/test_sensor.ambr b/tests/components/fritz/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..50744815aa5 --- /dev/null +++ b/tests/components/fritz/snapshots/test_sensor.ambr @@ -0,0 +1,771 @@ +# serializer version: 1 +# name: test_sensor_setup[sensor.mock_title_connection_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_connection_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection uptime', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'connection_uptime', + 'unique_id': '1C:ED:6F:12:34:11-connection_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup[sensor.mock_title_connection_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Connection uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_title_connection_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-01T10:11:33+00:00', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'kb_s_received', + 'unique_id': '1C:ED:6F:12:34:11-kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup[sensor.mock_title_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Download throughput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '67.6', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_external_ip-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_external_ip', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External IP', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'external_ip', + 'unique_id': '1C:ED:6F:12:34:11-external_ip', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup[sensor.mock_title_external_ip-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title External IP', + }), + 'context': , + 'entity_id': 'sensor.mock_title_external_ip', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2.3.4', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_external_ipv6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_external_ipv6', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'External IPv6', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'external_ipv6', + 'unique_id': '1C:ED:6F:12:34:11-external_ipv6', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup[sensor.mock_title_external_ipv6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title External IPv6', + }), + 'context': , + 'entity_id': 'sensor.mock_title_external_ipv6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fec0::1', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_gb_received-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_gb_received', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GB received', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gb_received', + 'unique_id': '1C:ED:6F:12:34:11-gb_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup[sensor.mock_title_gb_received-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title GB received', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_gb_received', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.2', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_gb_sent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_gb_sent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'GB sent', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'gb_sent', + 'unique_id': '1C:ED:6F:12:34:11-gb_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup[sensor.mock_title_gb_sent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'Mock Title GB sent', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_gb_sent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.7', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_last_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_last_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last restart', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_uptime', + 'unique_id': '1C:ED:6F:12:34:11-device_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup[sensor.mock_title_last_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Last restart', + }), + 'context': , + 'entity_id': 'sensor.mock_title_last_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-08-03T16:30:21+00:00', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_download_noise_margin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_link_download_noise_margin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link download noise margin', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_noise_margin_received', + 'unique_id': '1C:ED:6F:12:34:11-link_noise_margin_received', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_download_noise_margin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link download noise margin', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_noise_margin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_download_power_attenuation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_link_download_power_attenuation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link download power attenuation', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_attenuation_received', + 'unique_id': '1C:ED:6F:12:34:11-link_attenuation_received', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_download_power_attenuation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link download power attenuation', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_power_attenuation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_link_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Link download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_kb_s_received', + 'unique_id': '1C:ED:6F:12:34:11-link_kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Link download throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '318557.0', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_upload_noise_margin-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_link_upload_noise_margin', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link upload noise margin', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_noise_margin_sent', + 'unique_id': '1C:ED:6F:12:34:11-link_noise_margin_sent', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_upload_noise_margin-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link upload noise margin', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_noise_margin', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.0', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_upload_power_attenuation-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Link upload power attenuation', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_attenuation_sent', + 'unique_id': '1C:ED:6F:12:34:11-link_attenuation_sent', + 'unit_of_measurement': 'dB', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_upload_power_attenuation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Link upload power attenuation', + 'unit_of_measurement': 'dB', + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_power_attenuation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7.0', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_link_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Link upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'link_kb_s_sent', + 'unique_id': '1C:ED:6F:12:34:11-link_kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup[sensor.mock_title_link_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Link upload throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_link_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51805.0', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_max_connection_download_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_max_connection_download_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max connection download throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_kb_s_received', + 'unique_id': '1C:ED:6F:12:34:11-max_kb_s_received', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup[sensor.mock_title_max_connection_download_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Max connection download throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_max_connection_download_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10087.0', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_max_connection_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Max connection upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_kb_s_sent', + 'unique_id': '1C:ED:6F:12:34:11-max_kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup[sensor.mock_title_max_connection_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Max connection upload throughput', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_max_connection_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2105.0', + }) +# --- +# name: test_sensor_setup[sensor.mock_title_upload_throughput-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_upload_throughput', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Upload throughput', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'kb_s_sent', + 'unique_id': '1C:ED:6F:12:34:11-kb_s_sent', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_setup[sensor.mock_title_upload_throughput-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title Upload throughput', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_upload_throughput', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.4', + }) +# --- diff --git a/tests/components/fritz/snapshots/test_switch.ambr b/tests/components/fritz/snapshots/test_switch.ambr new file mode 100644 index 00000000000..048f6e005ec --- /dev/null +++ b/tests/components/fritz/snapshots/test_switch.ambr @@ -0,0 +1,424 @@ +# serializer version: 1 +# name: test_switch_setup[fc_data0-expected_wifi_names0][switch.mock_title_wi_fi_wifi_2_4ghz-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_wi_fi_wifi_2_4ghz', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Mock Title Wi-Fi WiFi (2.4Ghz)', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_2_4ghz', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data0-expected_wifi_names0][switch.mock_title_wi_fi_wifi_2_4ghz-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Wi-Fi WiFi (2.4Ghz)', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'switch.mock_title_wi_fi_wifi_2_4ghz', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_setup[fc_data0-expected_wifi_names0][switch.mock_title_wi_fi_wifi_5ghz-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_wi_fi_wifi_5ghz', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Mock Title Wi-Fi WiFi (5Ghz)', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_5ghz', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data0-expected_wifi_names0][switch.mock_title_wi_fi_wifi_5ghz-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Wi-Fi WiFi (5Ghz)', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'switch.mock_title_wi_fi_wifi_5ghz', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_setup[fc_data0-expected_wifi_names0][switch.printer_internet_access-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.printer_internet_access', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:router-wireless-settings', + 'original_name': 'printer Internet Access', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:BB:CC:00:11:22_internet_access', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data0-expected_wifi_names0][switch.printer_internet_access-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'printer Internet Access', + 'icon': 'mdi:router-wireless-settings', + }), + 'context': , + 'entity_id': 'switch.printer_internet_access', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_setup[fc_data1-expected_wifi_names1][switch.mock_title_wi_fi_wifi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_wi_fi_wifi', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Mock Title Wi-Fi WiFi', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data1-expected_wifi_names1][switch.mock_title_wi_fi_wifi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Wi-Fi WiFi', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'switch.mock_title_wi_fi_wifi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_setup[fc_data1-expected_wifi_names1][switch.mock_title_wi_fi_wifi2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_wi_fi_wifi2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Mock Title Wi-Fi WiFi2', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi2', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data1-expected_wifi_names1][switch.mock_title_wi_fi_wifi2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Wi-Fi WiFi2', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'switch.mock_title_wi_fi_wifi2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_setup[fc_data1-expected_wifi_names1][switch.printer_internet_access-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.printer_internet_access', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:router-wireless-settings', + 'original_name': 'printer Internet Access', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:BB:CC:00:11:22_internet_access', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data1-expected_wifi_names1][switch.printer_internet_access-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'printer Internet Access', + 'icon': 'mdi:router-wireless-settings', + }), + 'context': , + 'entity_id': 'switch.printer_internet_access', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_setup[fc_data2-expected_wifi_names2][switch.mock_title_wi_fi_wifi_2_4ghz-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_wi_fi_wifi_2_4ghz', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Mock Title Wi-Fi WiFi (2.4Ghz)', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_2_4ghz', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data2-expected_wifi_names2][switch.mock_title_wi_fi_wifi_2_4ghz-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Wi-Fi WiFi (2.4Ghz)', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'switch.mock_title_wi_fi_wifi_2_4ghz', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_setup[fc_data2-expected_wifi_names2][switch.mock_title_wi_fi_wifi_5ghz-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_wi_fi_wifi_5ghz', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Mock Title Wi-Fi WiFi+ (5Ghz)', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-wi_fi_wifi_5ghz', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data2-expected_wifi_names2][switch.mock_title_wi_fi_wifi_5ghz-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Wi-Fi WiFi+ (5Ghz)', + 'icon': 'mdi:wifi', + }), + 'context': , + 'entity_id': 'switch.mock_title_wi_fi_wifi_5ghz', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_setup[fc_data2-expected_wifi_names2][switch.printer_internet_access-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.printer_internet_access', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:router-wireless-settings', + 'original_name': 'printer Internet Access', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AA:BB:CC:00:11:22_internet_access', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[fc_data2-expected_wifi_names2][switch.printer_internet_access-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'printer Internet Access', + 'icon': 'mdi:router-wireless-settings', + }), + 'context': , + 'entity_id': 'switch.printer_internet_access', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/fritz/snapshots/test_update.ambr b/tests/components/fritz/snapshots/test_update.ambr new file mode 100644 index 00000000000..5544c972499 --- /dev/null +++ b/tests/components/fritz/snapshots/test_update.ambr @@ -0,0 +1,169 @@ +# serializer version: 1 +# name: test_available_update_can_be_installed[update.mock_title_fritz_os-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.mock_title_fritz_os', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FRITZ!OS', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-update', + 'unit_of_measurement': None, + }) +# --- +# name: test_available_update_can_be_installed[update.mock_title_fritz_os-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/fritz/icon.png', + 'friendly_name': 'Mock Title FRITZ!OS', + 'in_progress': False, + 'installed_version': '7.29', + 'latest_version': '7.50', + 'release_summary': None, + 'release_url': 'http://download.avm.de/fritzbox/fritzbox-7530-ax/deutschland/fritz.os/info_de.txt', + 'skipped_version': None, + 'supported_features': , + 'title': 'FRITZ!OS', + }), + 'context': , + 'entity_id': 'update.mock_title_fritz_os', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_available[update.mock_title_fritz_os-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.mock_title_fritz_os', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FRITZ!OS', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_available[update.mock_title_fritz_os-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/fritz/icon.png', + 'friendly_name': 'Mock Title FRITZ!OS', + 'in_progress': False, + 'installed_version': '7.29', + 'latest_version': '7.50', + 'release_summary': None, + 'release_url': 'http://download.avm.de/fritzbox/fritzbox-7530-ax/deutschland/fritz.os/info_de.txt', + 'skipped_version': None, + 'supported_features': , + 'title': 'FRITZ!OS', + }), + 'context': , + 'entity_id': 'update.mock_title_fritz_os', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_entities_initialized[update.mock_title_fritz_os-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.mock_title_fritz_os', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'FRITZ!OS', + 'platform': 'fritz', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1C:ED:6F:12:34:11-update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_entities_initialized[update.mock_title_fritz_os-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/fritz/icon.png', + 'friendly_name': 'Mock Title FRITZ!OS', + 'in_progress': False, + 'installed_version': '7.29', + 'latest_version': '7.29', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': 'FRITZ!OS', + }), + 'context': , + 'entity_id': 'update.mock_title_fritz_os', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/fritz/test_button.py b/tests/components/fritz/test_button.py index 79639835003..507331cde0b 100644 --- a/tests/components/fritz/test_button.py +++ b/tests/components/fritz/test_button.py @@ -5,11 +5,12 @@ from datetime import timedelta from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.fritz.const import DOMAIN, MeshRoles from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow @@ -21,24 +22,30 @@ from .const import ( MOCK_USER_DATA, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_button_setup(hass: HomeAssistant, fc_class_mock, fh_class_mock) -> None: +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + fc_class_mock, + fh_class_mock, + snapshot: SnapshotAssertion, +) -> None: """Test setup of Fritz!Tools buttons.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED + with patch("homeassistant.components.fritz.PLATFORMS", [Platform.BUTTON]): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - buttons = hass.states.async_all(BUTTON_DOMAIN) - assert len(buttons) == 4 + states = hass.states.async_all() + assert len(states) == 5 - for button in buttons: - assert button.state == STATE_UNKNOWN + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/fritz/test_diagnostics.py b/tests/components/fritz/test_diagnostics.py index 55196eb6988..cbcaa57dab4 100644 --- a/tests/components/fritz/test_diagnostics.py +++ b/tests/components/fritz/test_diagnostics.py @@ -2,14 +2,13 @@ from __future__ import annotations -from homeassistant.components.diagnostics import REDACTED +from syrupy import SnapshotAssertion +from syrupy.filters import props + from homeassistant.components.fritz.const import DOMAIN -from homeassistant.components.fritz.coordinator import AvmWrapper -from homeassistant.components.fritz.diagnostics import TO_REDACT -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from .const import MOCK_MESH_MASTER_MAC, MOCK_USER_DATA +from .const import MOCK_USER_DATA from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -21,64 +20,16 @@ async def test_entry_diagnostics( hass_client: ClientSessionGenerator, fc_class_mock, fh_class_mock, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED - entry_dict = entry.as_dict() - for key in TO_REDACT: - entry_dict["data"][key] = REDACTED result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - avm_wrapper: AvmWrapper = hass.data[DOMAIN][entry.entry_id] - assert result == { - "entry": entry_dict, - "device_info": { - "client_devices": [ - { - "connected_to": device.connected_to, - "connection_type": device.connection_type, - "hostname": device.hostname, - "is_connected": device.is_connected, - "last_activity": device.last_activity.isoformat(), - "wan_access": device.wan_access, - } - for _, device in avm_wrapper.devices.items() - ], - "connection_type": "WANPPPConnection", - "current_firmware": "7.29", - "discovered_services": [ - "DeviceInfo1", - "Hosts1", - "LANEthernetInterfaceConfig1", - "Layer3Forwarding1", - "UserInterface1", - "WANCommonIFC1", - "WANCommonInterfaceConfig1", - "WANDSLInterfaceConfig1", - "WANIPConn1", - "WANPPPConnection1", - "WLANConfiguration1", - "X_AVM-DE_Homeauto1", - "X_AVM-DE_HostFilter1", - ], - "is_router": True, - "last_exception": None, - "last_update success": True, - "latest_firmware": None, - "mesh_role": "master", - "model": "FRITZ!Box 7530 AX", - "unique_id": MOCK_MESH_MASTER_MAC.replace("6F:12", "XX:XX"), - "update_available": False, - "wan_link_properties": { - "NewLayer1DownstreamMaxBitRate": 318557000, - "NewLayer1UpstreamMaxBitRate": 51805000, - "NewPhysicalLinkStatus": "Up", - "NewWANAccessType": "DSL", - }, - }, - } + assert result == snapshot( + exclude=props("created_at", "modified_at", "entry_id", "last_activity") + ) diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index f8114238376..fcdb4b63450 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -2,123 +2,47 @@ from __future__ import annotations -from datetime import timedelta -from typing import Any +from datetime import UTC, datetime, timedelta +from unittest.mock import patch from fritzconnection.core.exceptions import FritzConnectionException +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fritz.const import DOMAIN -from homeassistant.components.fritz.sensor import SENSOR_TYPES -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_STATE, - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, -) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .const import MOCK_USER_DATA -from tests.common import MockConfigEntry, async_fire_time_changed - -SENSOR_STATES: dict[str, dict[str, Any]] = { - "sensor.mock_title_external_ip": { - ATTR_STATE: "1.2.3.4", - }, - "sensor.mock_title_external_ipv6": { - ATTR_STATE: "fec0::1", - }, - "sensor.mock_title_last_restart": { - # ATTR_STATE: "2022-02-05T17:46:04+00:00", - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - }, - "sensor.mock_title_connection_uptime": { - # ATTR_STATE: "2022-03-06T11:27:16+00:00", - ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, - }, - "sensor.mock_title_upload_throughput": { - ATTR_STATE: "3.4", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: "kB/s", - }, - "sensor.mock_title_download_throughput": { - ATTR_STATE: "67.6", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: "kB/s", - }, - "sensor.mock_title_max_connection_upload_throughput": { - ATTR_STATE: "2105.0", - ATTR_UNIT_OF_MEASUREMENT: "kbit/s", - }, - "sensor.mock_title_max_connection_download_throughput": { - ATTR_STATE: "10087.0", - ATTR_UNIT_OF_MEASUREMENT: "kbit/s", - }, - "sensor.mock_title_gb_sent": { - ATTR_STATE: "1.7", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIT_OF_MEASUREMENT: "GB", - }, - "sensor.mock_title_gb_received": { - ATTR_STATE: "5.2", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIT_OF_MEASUREMENT: "GB", - }, - "sensor.mock_title_link_upload_throughput": { - ATTR_STATE: "51805.0", - ATTR_UNIT_OF_MEASUREMENT: "kbit/s", - }, - "sensor.mock_title_link_download_throughput": { - ATTR_STATE: "318557.0", - ATTR_UNIT_OF_MEASUREMENT: "kbit/s", - }, - "sensor.mock_title_link_upload_noise_margin": { - ATTR_STATE: "9.0", - ATTR_UNIT_OF_MEASUREMENT: "dB", - }, - "sensor.mock_title_link_download_noise_margin": { - ATTR_STATE: "8.0", - ATTR_UNIT_OF_MEASUREMENT: "dB", - }, - "sensor.mock_title_link_upload_power_attenuation": { - ATTR_STATE: "7.0", - ATTR_UNIT_OF_MEASUREMENT: "dB", - }, - "sensor.mock_title_link_download_power_attenuation": { - ATTR_STATE: "12.0", - ATTR_UNIT_OF_MEASUREMENT: "dB", - }, -} +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_sensor_setup(hass: HomeAssistant, fc_class_mock, fh_class_mock) -> None: +@pytest.mark.freeze_time(datetime(2024, 9, 1, 20, tzinfo=UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + fc_class_mock, + fh_class_mock, + snapshot: SnapshotAssertion, +) -> None: """Test setup of Fritz!Tools sensors.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED + with patch("homeassistant.components.fritz.PLATFORMS", [Platform.SENSOR]): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - sensors = hass.states.async_all(SENSOR_DOMAIN) - assert len(sensors) == len(SENSOR_TYPES) + states = hass.states.async_all() + assert len(states) == 16 - for sensor in sensors: - assert SENSOR_STATES.get(sensor.entity_id) is not None - for key, val in SENSOR_STATES[sensor.entity_id].items(): - if key == ATTR_STATE: - assert sensor.state == val - else: - assert sensor.attributes.get(key) == val + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_sensor_update_fail( diff --git a/tests/components/fritz/test_switch.py b/tests/components/fritz/test_switch.py index b82587d42bd..1542645758e 100644 --- a/tests/components/fritz/test_switch.py +++ b/tests/components/fritz/test_switch.py @@ -2,16 +2,19 @@ from __future__ import annotations +from unittest.mock import patch + import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.fritz.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .const import MOCK_FB_SERVICES, MOCK_USER_DATA -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform MOCK_WLANCONFIGS_SAME_SSID: dict[str, dict] = { "WLANConfiguration1": { @@ -179,23 +182,24 @@ MOCK_WLANCONFIGS_DIFF2_SSID: dict[str, dict] = { ), ], ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switch_setup( hass: HomeAssistant, + entity_registry: er.EntityRegistry, expected_wifi_names: list[str], fc_class_mock, fh_class_mock, + snapshot: SnapshotAssertion, ) -> None: """Test setup of Fritz!Tools switches.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done(wait_background_tasks=True) - assert entry.state is ConfigEntryState.LOADED + with patch("homeassistant.components.fritz.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) - switches = hass.states.async_all(Platform.SWITCH) - assert len(switches) == 3 - assert switches[0].name == f"Mock Title Wi-Fi {expected_wifi_names[0]}" - assert switches[1].name == f"Mock Title Wi-Fi {expected_wifi_names[1]}" - assert switches[2].name == "printer Internet Access" + states = hass.states.async_all() + assert len(states) == 3 + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index 5d7ef852d4c..cca5decbcc4 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -2,10 +2,13 @@ from unittest.mock import patch +import pytest +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.fritz.const import DOMAIN -from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .const import ( MOCK_FB_SERVICES, @@ -14,8 +17,7 @@ from .const import ( MOCK_USER_DATA, ) -from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator +from tests.common import MockConfigEntry, snapshot_platform AVAILABLE_UPDATE = { "UserInterface1": { @@ -27,30 +29,36 @@ AVAILABLE_UPDATE = { } +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_entities_initialized( hass: HomeAssistant, - hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, fc_class_mock, fh_class_mock, + snapshot: SnapshotAssertion, ) -> None: """Test update entities.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED + with patch("homeassistant.components.fritz.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - updates = hass.states.async_all(UPDATE_DOMAIN) - assert len(updates) == 1 + states = hass.states.async_all() + assert len(states) == 1 + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_update_available( hass: HomeAssistant, - hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, fc_class_mock, fh_class_mock, + snapshot: SnapshotAssertion, ) -> None: """Test update entities.""" @@ -59,64 +67,45 @@ async def test_update_available( entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED + with patch("homeassistant.components.fritz.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - update = hass.states.get("update.mock_title_fritz_os") - assert update is not None - assert update.state == "on" - assert update.attributes.get("installed_version") == "7.29" - assert update.attributes.get("latest_version") == MOCK_FIRMWARE_AVAILABLE - assert update.attributes.get("release_url") == MOCK_FIRMWARE_RELEASE_URL - - -async def test_no_update_available( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - fc_class_mock, - fh_class_mock, -) -> None: - """Test update entities.""" - - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED - - update = hass.states.get("update.mock_title_fritz_os") - assert update is not None - assert update.state == "off" - assert update.attributes.get("installed_version") == "7.29" - assert update.attributes.get("latest_version") == "7.29" + states = hass.states.async_all() + assert len(states) == 1 + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_available_update_can_be_installed( hass: HomeAssistant, - hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, fc_class_mock, fh_class_mock, + snapshot: SnapshotAssertion, ) -> None: """Test update entities.""" fc_class_mock().override_services({**MOCK_FB_SERVICES, **AVAILABLE_UPDATE}) - with patch( - "homeassistant.components.fritz.coordinator.FritzBoxTools.async_trigger_firmware_update", - return_value=True, - ) as mocked_update_call: + with ( + patch( + "homeassistant.components.fritz.coordinator.FritzBoxTools.async_trigger_firmware_update", + return_value=True, + ) as mocked_update_call, + patch("homeassistant.components.fritz.PLATFORMS", [Platform.UPDATE]), + ): entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert entry.state is ConfigEntryState.LOADED - update = hass.states.get("update.mock_title_fritz_os") - assert update is not None - assert update.state == "on" + states = hass.states.async_all() + assert len(states) == 1 + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) await hass.services.async_call( "update", From 42ed7fbb0de3c226acd52e5993034404b988a0f3 Mon Sep 17 00:00:00 2001 From: MJJ Date: Tue, 3 Sep 2024 16:50:30 +0200 Subject: [PATCH 0195/1309] Increase timeout for fetching buienradar weather data (#124597) Increase timeout for fetching weather data --- homeassistant/components/buienradar/const.py | 1 + homeassistant/components/buienradar/util.py | 16 ++++++++-------- homeassistant/components/buienradar/weather.py | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index c82970ed318..fd92afd59b0 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -2,6 +2,7 @@ DOMAIN = "buienradar" +DEFAULT_TIMEOUT = 60 DEFAULT_TIMEFRAME = 60 DEFAULT_DIMENSION = 700 diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index b641644cebe..f089fce89b7 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -1,9 +1,9 @@ """Shared utilities for different supported platforms.""" -from asyncio import timeout from datetime import datetime, timedelta from http import HTTPStatus import logging +from typing import Any import aiohttp from buienradar.buienradar import parse_data @@ -27,12 +27,12 @@ from buienradar.constants import ( from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import dt as dt_util -from .const import SCHEDULE_NOK, SCHEDULE_OK +from .const import DEFAULT_TIMEOUT, SCHEDULE_NOK, SCHEDULE_OK __all__ = ["BrData"] _LOGGER = logging.getLogger(__name__) @@ -59,10 +59,10 @@ class BrData: load_error_count: int = WARN_THRESHOLD rain_error_count: int = WARN_THRESHOLD - def __init__(self, hass, coordinates, timeframe, devices): + def __init__(self, hass: HomeAssistant, coordinates, timeframe, devices) -> None: """Initialize the data object.""" self.devices = devices - self.data = {} + self.data: dict[str, Any] | None = {} self.hass = hass self.coordinates = coordinates self.timeframe = timeframe @@ -93,9 +93,9 @@ class BrData: resp = None try: websession = async_get_clientsession(self.hass) - async with timeout(10): - resp = await websession.get(url) - + async with websession.get( + url, timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT) + ) as resp: result[STATUS_CODE] = resp.status result[CONTENT] = await resp.text() if resp.status == HTTPStatus.OK: diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 02e1f444c9c..2af66982fab 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -130,7 +130,7 @@ class BrWeather(WeatherEntity): _attr_should_poll = False _attr_supported_features = WeatherEntityFeature.FORECAST_DAILY - def __init__(self, config, coordinates): + def __init__(self, config, coordinates) -> None: """Initialize the platform with a data instance and station name.""" self._stationname = config.get(CONF_NAME, "Buienradar") self._attr_name = self._stationname or f"BR {'(unknown station)'}" From 78517f75e8b7f2ab529fd670150cc63bf9801c8a Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Tue, 3 Sep 2024 10:50:55 -0400 Subject: [PATCH 0196/1309] Add favorites support to Media Browser for Squeezebox integration (#124732) * Add Favorites support to Media Browser * CI fixes * More CI Fixes * Another CI * Change icons for other library items to use standard LMS icons * Change max favorites to BROWSE_LIMIT * Simplify library_payload to consolidate favorite and non-favorite items * Simplify library_payload to consolidate favorite and non-favorite items * Add support for favorite hierarchy * small fix for icon naming with local albums * Add ability to expand an album from a favorite list * Reformat to fix linting error * and ruff format * Use library calls from pysqueezebox * Folder and playback support * Bump to pysqueezebox 0.8.0 * Bump pysqueezebox version to 0.8.1 * Add unit tests * Improve unit tests * Refactor tests to use websockets and services.async_call * Apply suggestions from code review --------- Co-authored-by: peteS-UK <64092177+peteS-UK@users.noreply.github.com> --- .../components/squeezebox/browse_media.py | 54 ++++- .../components/squeezebox/media_player.py | 2 +- tests/components/squeezebox/conftest.py | 133 ++++++++++++ .../squeezebox/test_media_browser.py | 205 ++++++++++++++++++ 4 files changed, 382 insertions(+), 12 deletions(-) create mode 100644 tests/components/squeezebox/conftest.py create mode 100644 tests/components/squeezebox/test_media_browser.py diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index bc63bcb7f2f..f68624f8f06 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -11,9 +11,10 @@ from homeassistant.components.media_player import ( ) from homeassistant.helpers.network import is_internal_request -LIBRARY = ["Artists", "Albums", "Tracks", "Playlists", "Genres"] +LIBRARY = ["Favorites", "Artists", "Albums", "Tracks", "Playlists", "Genres"] MEDIA_TYPE_TO_SQUEEZEBOX = { + "Favorites": "favorites", "Artists": "artists", "Albums": "albums", "Tracks": "titles", @@ -32,9 +33,11 @@ SQUEEZEBOX_ID_BY_TYPE = { MediaType.TRACK: "track_id", MediaType.PLAYLIST: "playlist_id", MediaType.GENRE: "genre_id", + "Favorites": "item_id", } CONTENT_TYPE_MEDIA_CLASS = { + "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, "Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, "Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, "Tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, @@ -57,6 +60,7 @@ CONTENT_TYPE_TO_CHILD_TYPE = { "Tracks": MediaType.TRACK, "Playlists": MediaType.PLAYLIST, "Genres": MediaType.GENRE, + "Favorites": None, # can only be determined after inspecting the item } BROWSE_LIMIT = 1000 @@ -64,6 +68,7 @@ BROWSE_LIMIT = 1000 async def build_item_response(entity, player, payload): """Create response payload for search described by payload.""" + internal_request = is_internal_request(entity.hass) search_id = payload["search_id"] @@ -71,6 +76,8 @@ async def build_item_response(entity, player, payload): media_class = CONTENT_TYPE_MEDIA_CLASS[search_type] + children = None + if search_id and search_id != search_type: browse_id = (SQUEEZEBOX_ID_BY_TYPE[search_type], search_id) else: @@ -82,16 +89,36 @@ async def build_item_response(entity, player, payload): browse_id=browse_id, ) - children = None - if result is not None and result.get("items"): item_type = CONTENT_TYPE_TO_CHILD_TYPE[search_type] - child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type] children = [] for item in result["items"]: item_id = str(item["id"]) item_thumbnail = None + if item_type: + child_item_type = item_type + child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type] + can_expand = child_media_class["children"] is not None + can_play = True + + if search_type == "Favorites": + if "album_id" in item: + item_id = str(item["album_id"]) + child_item_type = MediaType.ALBUM + child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM] + can_expand = True + can_play = True + elif item["hasitems"]: + child_item_type = "Favorites" + child_media_class = CONTENT_TYPE_MEDIA_CLASS["Favorites"] + can_expand = True + can_play = False + else: + child_item_type = "Favorites" + child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK] + can_expand = False + can_play = True if artwork_track_id := item.get("artwork_track_id"): if internal_request: @@ -102,15 +129,17 @@ async def build_item_response(entity, player, payload): item_thumbnail = entity.get_browse_image_url( item_type, item_id, artwork_track_id ) + else: + item_thumbnail = item.get("image_url") # will not be proxied by HA children.append( BrowseMedia( title=item["title"], media_class=child_media_class["item"], media_content_id=item_id, - media_content_type=item_type, - can_play=True, - can_expand=child_media_class["children"] is not None, + media_content_type=child_item_type, + can_play=can_play, + can_expand=can_expand, thumbnail=item_thumbnail, ) ) @@ -124,7 +153,7 @@ async def build_item_response(entity, player, payload): children_media_class=media_class["children"], media_content_id=search_id, media_content_type=search_type, - can_play=True, + can_play=search_type != "Favorites", children=children, can_expand=True, ) @@ -144,6 +173,7 @@ async def library_payload(hass, player): for item in LIBRARY: media_class = CONTENT_TYPE_MEDIA_CLASS[item] + result = await player.async_browse( MEDIA_TYPE_TO_SQUEEZEBOX[item], limit=1, @@ -155,7 +185,7 @@ async def library_payload(hass, player): media_class=media_class["children"], media_content_id=item, media_content_type=item, - can_play=True, + can_play=item != "Favorites", can_expand=True, ) ) @@ -184,10 +214,12 @@ async def generate_playlist(player, payload): media_id = payload["search_id"] if media_type not in SQUEEZEBOX_ID_BY_TYPE: - return None + raise BrowseError(f"Media type not supported: {media_type}") browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id) result = await player.async_browse( "titles", limit=BROWSE_LIMIT, browse_id=browse_id ) - return result.get("items") + if result and "items" in result: + return result["items"] + raise BrowseError(f"Media not found: {media_type} / {media_id}") diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 552b8ed800c..279e51485f0 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -591,7 +591,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): if media_content_type in [None, "library"]: return await library_payload(self.hass, self._player) - if media_source.is_media_source_id(media_content_id): + if media_content_id and media_source.is_media_source_id(media_content_id): return await media_source.async_browse_media( self.hass, media_content_id, content_filter=media_source_content_filter ) diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py new file mode 100644 index 00000000000..4a4bdc6ae73 --- /dev/null +++ b/tests/components/squeezebox/conftest.py @@ -0,0 +1,133 @@ +"""Setup the squeezebox tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.media_player import MediaType +from homeassistant.components.squeezebox import const +from homeassistant.components.squeezebox.browse_media import ( + MEDIA_TYPE_TO_SQUEEZEBOX, + SQUEEZEBOX_ID_BY_TYPE, +) +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac + +from tests.common import MockConfigEntry + +TEST_HOST = "1.2.3.4" +TEST_PORT = "9000" +TEST_USE_HTTPS = False +SERVER_UUID = "12345678-1234-1234-1234-123456789012" +TEST_MAC = "aa:bb:cc:dd:ee:ff" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.squeezebox.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Add the squeezebox mock config entry to hass.""" + config_entry = MockConfigEntry( + domain=const.DOMAIN, + unique_id=SERVER_UUID, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + const.CONF_HTTPS: TEST_USE_HTTPS, + }, + ) + config_entry.add_to_hass(hass) + return config_entry + + +async def mock_async_browse( + media_type: MediaType, limit: int, browse_id: tuple | None = None +) -> dict | None: + """Mock the async_browse method of pysqueezebox.Player.""" + child_types = { + "favorites": "favorites", + "albums": "album", + "album": "track", + "genres": "genre", + "genre": "album", + "artists": "artist", + "artist": "album", + "titles": "title", + "title": "title", + "playlists": "playlist", + "playlist": "title", + } + fake_items = [ + { + "title": "Fake Item 1", + "id": "1234", + "hasitems": False, + "item_type": child_types[media_type], + "artwork_track_id": "b35bb9e9", + }, + { + "title": "Fake Item 2", + "id": "12345", + "hasitems": media_type == "favorites", + "item_type": child_types[media_type], + "image_url": "http://lms.internal:9000/html/images/favorites.png", + }, + { + "title": "Fake Item 3", + "id": "123456", + "hasitems": media_type == "favorites", + "album_id": "123456" if media_type == "favorites" else None, + }, + ] + + if browse_id: + search_type, search_id = browse_id + if search_id: + if search_type in SQUEEZEBOX_ID_BY_TYPE.values(): + for item in fake_items: + if item["id"] == search_id: + return { + "title": item["title"], + "items": [item], + } + return None + if search_type in SQUEEZEBOX_ID_BY_TYPE.values(): + return { + "title": search_type, + "items": fake_items, + } + return None + if media_type in MEDIA_TYPE_TO_SQUEEZEBOX.values(): + return { + "title": media_type, + "items": fake_items, + } + return None + + +@pytest.fixture +def lms() -> MagicMock: + """Mock a Lyrion Media Server with one mock player attached.""" + lms = MagicMock() + player = MagicMock() + player.player_id = TEST_MAC + player.name = "Test Player" + player.power = False + player.async_browse = AsyncMock(side_effect=mock_async_browse) + player.async_load_playlist = AsyncMock() + player.async_update = AsyncMock() + player.generate_image_url_from_track_id = MagicMock( + return_value="http://lms.internal:9000/html/images/favorites.png" + ) + lms.async_get_players = AsyncMock(return_value=[player]) + lms.async_query = AsyncMock(return_value={"uuid": format_mac(TEST_MAC)}) + return lms diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py new file mode 100644 index 00000000000..62d668ca57b --- /dev/null +++ b/tests/components/squeezebox/test_media_browser.py @@ -0,0 +1,205 @@ +"""Test the media browser interface.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + BrowseError, + MediaType, +) +from homeassistant.components.squeezebox.browse_media import ( + LIBRARY, + MEDIA_TYPE_TO_SQUEEZEBOX, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator + + +@pytest.fixture(autouse=True) +async def setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry, lms: MagicMock +) -> None: + """Fixture for setting up the component.""" + with ( + patch("homeassistant.components.squeezebox.Server", return_value=lms), + patch( + "homeassistant.components.squeezebox.media_player.start_server_discovery" + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + +async def test_async_browse_media_root( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the async_browse_media function at the root level.""" + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "library", + } + ) + response = await client.receive_json() + assert response["success"] + result = response["result"] + for idx, item in enumerate(result["children"]): + assert item["title"] == LIBRARY[idx] + + +async def test_async_browse_media_with_subitems( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test each category with subitems.""" + for category in ("Favorites", "Artists", "Albums", "Playlists", "Genres"): + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=False, + ): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": category, + } + ) + response = await client.receive_json() + assert response["success"] + category_level = response["result"] + assert category_level["title"] == MEDIA_TYPE_TO_SQUEEZEBOX[category] + assert category_level["children"][0]["title"] == "Fake Item 1" + + # Look up a subitem + search_type = category_level["children"][0]["media_content_type"] + search_id = category_level["children"][0]["media_content_id"] + await client.send_json( + { + "id": 2, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": search_id, + "media_content_type": search_type, + } + ) + response = await client.receive_json() + assert response["success"] + search = response["result"] + assert search["title"] == "Fake Item 1" + + +async def test_async_browse_tracks( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test tracks (no subitems).""" + with patch( + "homeassistant.components.squeezebox.browse_media.is_internal_request", + return_value=True, + ): + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "", + "media_content_type": "Tracks", + } + ) + response = await client.receive_json() + assert response["success"] + tracks = response["result"] + assert tracks["title"] == "titles" + assert len(tracks["children"]) == 3 + + +async def test_async_browse_error( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Search for a non-existent item and assert error.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": "media_player.test_player", + "media_content_id": "0", + "media_content_type": MediaType.ALBUM, + } + ) + response = await client.receive_json() + assert not response["success"] + + +async def test_play_browse_item( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test play browse item.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: "1234", + ATTR_MEDIA_CONTENT_TYPE: "album", + }, + ) + + +async def test_play_browse_item_nonexistent( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test trying to play an item that doesn't exist.""" + with pytest.raises(BrowseError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: "0", + ATTR_MEDIA_CONTENT_TYPE: "album", + }, + blocking=True, + ) + + +async def test_play_browse_item_bad_category( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test trying to play an item whose category doesn't exist.""" + with pytest.raises(BrowseError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: "1234", + ATTR_MEDIA_CONTENT_TYPE: "bad_category", + }, + blocking=True, + ) From 5d072d1030c7307edc40d0abfc90f574930fc784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20Kr=C3=B6ner?= Date: Tue, 3 Sep 2024 16:51:13 +0200 Subject: [PATCH 0197/1309] Bump PyMetno to 0.13.0 (#125151) --- homeassistant/components/met/manifest.json | 2 +- homeassistant/components/norway_air/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index e900c5a012a..1a145589a68 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/met", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["PyMetno==0.12.0"] + "requirements": ["PyMetno==0.13.0"] } diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index f787f647db8..0c8f15b9b78 100644 --- a/homeassistant/components/norway_air/manifest.json +++ b/homeassistant/components/norway_air/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/norway_air", "iot_class": "cloud_polling", "loggers": ["metno"], - "requirements": ["PyMetno==0.12.0"] + "requirements": ["PyMetno==0.13.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ebc621486c9..58cded8ad5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -64,7 +64,7 @@ PyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -PyMetno==0.12.0 +PyMetno==0.13.0 # homeassistant.components.keymitt_ble PyMicroBot==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d151c8e6bc2..fdb65eac9a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -61,7 +61,7 @@ PyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -PyMetno==0.12.0 +PyMetno==0.13.0 # homeassistant.components.keymitt_ble PyMicroBot==0.0.17 From 8759a6a14d82313349733afd41d56214e4b93187 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Sep 2024 17:03:36 +0200 Subject: [PATCH 0198/1309] Make optional arguments to frame.report kwarg only (#125062) * Make optional arguments to frame.report kwarg only * Update homeassistant/helpers/frame.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/media_source/__init__.py | 2 +- homeassistant/helpers/frame.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 928e46ab528..732a1d834f0 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -160,7 +160,7 @@ async def async_resolve_media( if target_media_player is UNDEFINED: report( "calls media_source.async_resolve_media without passing an entity_id", - {DOMAIN}, + exclude_integrations={DOMAIN}, ) target_media_player = None diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 8a30c26886e..e8df1cea21b 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -129,15 +129,19 @@ class MissingIntegrationFrame(HomeAssistantError): def report( what: str, - exclude_integrations: set | None = None, + *, + exclude_integrations: set[str] | None = None, error_if_core: bool = True, + error_if_integration: bool = False, level: int = logging.WARNING, log_custom_component_only: bool = False, - error_if_integration: bool = False, ) -> None: """Report incorrect usage. - Async friendly. + If error_if_core is True, raise instead of log if an integration is not found + when unwinding the stack frame. + If error_if_integration is True, raise instead of log if an integration is found + when unwinding the stack frame. """ try: integration_frame = get_integration_frame( From 1dcae0c0a645623aad045d69703edd16bd38855d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Sep 2024 17:04:08 +0200 Subject: [PATCH 0199/1309] Improve some comments in recorder tests (#125118) --- tests/components/recorder/db_schema_32.py | 5 ++++- tests/components/recorder/test_v32_migration.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 60f4f733ec0..6da0272da87 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -224,7 +224,7 @@ class Events(Base): # type: ignore[misc,valid-type] data_id = Column(Integer, ForeignKey("event_data.data_id"), index=True) context_id_bin = Column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) - ) # *** Not originally in v3v320, only added for recorder to startup ok + ) # *** Not originally in v32, only added for recorder to startup ok context_user_id_bin = Column( LargeBinary(CONTEXT_ID_BIN_MAX_LENGTH) ) # *** Not originally in v32, only added for recorder to startup ok @@ -565,6 +565,7 @@ class StatisticsBase: id = Column(Integer, Identity(), primary_key=True) created = Column(DATETIME_TYPE, default=dt_util.utcnow) + # *** Not originally in v32, only added for recorder to startup ok created_ts = Column(TIMESTAMP_TYPE, default=time.time) metadata_id = Column( Integer, @@ -572,11 +573,13 @@ class StatisticsBase: index=True, ) start = Column(DATETIME_TYPE, index=True) + # *** Not originally in v32, only added for recorder to startup ok start_ts = Column(TIMESTAMP_TYPE, index=True) mean = Column(DOUBLE_TYPE) min = Column(DOUBLE_TYPE) max = Column(DOUBLE_TYPE) last_reset = Column(DATETIME_TYPE) + # *** Not originally in v32, only added for recorder to startup ok last_reset_ts = Column(TIMESTAMP_TYPE) state = Column(DOUBLE_TYPE) sum = Column(DOUBLE_TYPE) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 1e00353d02c..56aa6705688 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -69,7 +69,7 @@ async def test_migrate_times( async_test_recorder: RecorderInstanceGenerator, caplog: pytest.LogCaptureFixture, ) -> None: - """Test we can migrate times.""" + """Test we can migrate times in the events and states tables.""" importlib.import_module(SCHEMA_MODULE_30) old_db_schema = sys.modules[SCHEMA_MODULE_30] now = dt_util.utcnow() From 56887747a6d10af198ff46c6bf97e8e3a05cb68c Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 3 Sep 2024 16:09:26 +0100 Subject: [PATCH 0200/1309] Bump aiomealie to 0.9.2 (#125153) Bump mealie version --- 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 d8fe26d97b3..4fabdffadc4 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.1"] + "requirements": ["aiomealie==0.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 58cded8ad5b..39b464c0561 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -288,7 +288,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.1 +aiomealie==0.9.2 # homeassistant.components.modern_forms aiomodernforms==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdb65eac9a7..1fc15b0f78f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -270,7 +270,7 @@ aiolookin==1.0.0 aiolyric==2.0.1 # homeassistant.components.mealie -aiomealie==0.9.1 +aiomealie==0.9.2 # homeassistant.components.modern_forms aiomodernforms==0.1.8 From 470335e27ad8403887e1b2ba3f7004bdd07400ac Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Tue, 3 Sep 2024 17:11:17 +0200 Subject: [PATCH 0201/1309] Add sensors for AsusWRT using http(s) library (#124337) * Additional sensors for AsusWRT using http(s) library * Remove temperature sensors refactor from PR * Fix test function name * Change translation a suggested * Requested changes --- homeassistant/components/asuswrt/bridge.py | 59 +++++ homeassistant/components/asuswrt/const.py | 13 ++ homeassistant/components/asuswrt/icons.json | 15 ++ homeassistant/components/asuswrt/sensor.py | 73 +++++++ homeassistant/components/asuswrt/strings.json | 21 ++ tests/components/asuswrt/conftest.py | 42 +++- tests/components/asuswrt/test_sensor.py | 206 +++++++++++++++--- 7 files changed, 391 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 4e928d63666..bc6f0fe6fd2 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -5,6 +5,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections import namedtuple from collections.abc import Awaitable, Callable, Coroutine +from datetime import datetime import functools import logging from typing import Any, cast @@ -40,17 +41,23 @@ from .const import ( PROTOCOL_HTTPS, PROTOCOL_TELNET, SENSORS_BYTES, + SENSORS_CPU, SENSORS_LOAD_AVG, + SENSORS_MEMORY, SENSORS_RATES, SENSORS_TEMPERATURES, SENSORS_TEMPERATURES_LEGACY, + SENSORS_UPTIME, ) SENSORS_TYPE_BYTES = "sensors_bytes" SENSORS_TYPE_COUNT = "sensors_count" +SENSORS_TYPE_CPU = "sensors_cpu" SENSORS_TYPE_LOAD_AVG = "sensors_load_avg" +SENSORS_TYPE_MEMORY = "sensors_memory" SENSORS_TYPE_RATES = "sensors_rates" SENSORS_TYPE_TEMPERATURES = "sensors_temperatures" +SENSORS_TYPE_UPTIME = "sensors_uptime" WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"]) # noqa: PYI024 @@ -346,6 +353,7 @@ class AsusWrtHttpBridge(AsusWrtBridge): async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: """Return a dictionary of available sensors for this bridge.""" + sensors_cpu = await self._get_available_cpu_sensors() sensors_temperatures = await self._get_available_temperature_sensors() sensors_loadavg = await self._get_loadavg_sensors_availability() return { @@ -353,20 +361,49 @@ class AsusWrtHttpBridge(AsusWrtBridge): KEY_SENSORS: SENSORS_BYTES, KEY_METHOD: self._get_bytes, }, + SENSORS_TYPE_CPU: { + KEY_SENSORS: sensors_cpu, + KEY_METHOD: self._get_cpu_usage, + }, SENSORS_TYPE_LOAD_AVG: { KEY_SENSORS: sensors_loadavg, KEY_METHOD: self._get_load_avg, }, + SENSORS_TYPE_MEMORY: { + KEY_SENSORS: SENSORS_MEMORY, + KEY_METHOD: self._get_memory_usage, + }, SENSORS_TYPE_RATES: { KEY_SENSORS: SENSORS_RATES, KEY_METHOD: self._get_rates, }, + SENSORS_TYPE_UPTIME: { + KEY_SENSORS: SENSORS_UPTIME, + KEY_METHOD: self._get_uptime, + }, SENSORS_TYPE_TEMPERATURES: { KEY_SENSORS: sensors_temperatures, KEY_METHOD: self._get_temperatures, }, } + async def _get_available_cpu_sensors(self) -> list[str]: + """Check which cpu information is available on the router.""" + try: + available_cpu = await self._api.async_get_cpu_usage() + available_sensors = [t for t in SENSORS_CPU if t in available_cpu] + except AsusWrtError as exc: + _LOGGER.warning( + ( + "Failed checking cpu sensor availability for ASUS router" + " %s. Exception: %s" + ), + self.host, + exc, + ) + return [] + return available_sensors + async def _get_available_temperature_sensors(self) -> list[str]: """Check which temperature information is available on the router.""" try: @@ -415,3 +452,25 @@ class AsusWrtHttpBridge(AsusWrtBridge): async def _get_temperatures(self) -> Any: """Fetch temperatures information from the router.""" return await self._api.async_get_temperatures() + + @handle_errors_and_zip(AsusWrtError, None) + async def _get_cpu_usage(self) -> Any: + """Fetch cpu information from the router.""" + return await self._api.async_get_cpu_usage() + + @handle_errors_and_zip(AsusWrtError, None) + async def _get_memory_usage(self) -> Any: + """Fetch memory information from the router.""" + return await self._api.async_get_memory_usage() + + async def _get_uptime(self) -> dict[str, Any]: + """Fetch uptime from the router.""" + try: + uptimes = await self._api.async_get_uptime() + except AsusWrtError as exc: + raise UpdateFailed(exc) from exc + + last_boot = datetime.fromisoformat(uptimes["last_boot"]) + uptime = uptimes["uptime"] + + return dict(zip(SENSORS_UPTIME, [last_boot, uptime], strict=False)) diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py index 5ce37207145..7790750538e 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -27,7 +27,20 @@ PROTOCOL_TELNET = "telnet" # Sensors SENSORS_BYTES = ["sensor_rx_bytes", "sensor_tx_bytes"] SENSORS_CONNECTED_DEVICE = ["sensor_connected_device"] +SENSORS_CPU = [ + "cpu_total_usage", + "cpu1_usage", + "cpu2_usage", + "cpu3_usage", + "cpu4_usage", + "cpu5_usage", + "cpu6_usage", + "cpu7_usage", + "cpu8_usage", +] SENSORS_LOAD_AVG = ["sensor_load_avg1", "sensor_load_avg5", "sensor_load_avg15"] +SENSORS_MEMORY = ["mem_usage_perc", "mem_free", "mem_used"] SENSORS_RATES = ["sensor_rx_rates", "sensor_tx_rates"] SENSORS_TEMPERATURES_LEGACY = ["2.4GHz", "5.0GHz", "CPU"] SENSORS_TEMPERATURES = [*SENSORS_TEMPERATURES_LEGACY, "5.0GHz_2", "6.0GHz"] +SENSORS_UPTIME = ["sensor_last_boot", "sensor_uptime"] diff --git a/homeassistant/components/asuswrt/icons.json b/homeassistant/components/asuswrt/icons.json index a4e44496a2f..b5b2c35f742 100644 --- a/homeassistant/components/asuswrt/icons.json +++ b/homeassistant/components/asuswrt/icons.json @@ -24,6 +24,21 @@ }, "load_avg_15m": { "default": "mdi:cpu-32-bit" + }, + "cpu_usage": { + "default": "mdi:cpu-32-bit" + }, + "cpu_core_usage": { + "default": "mdi:cpu-32-bit" + }, + "memory_usage": { + "default": "mdi:memory" + }, + "memory_free": { + "default": "mdi:memory" + }, + "memory_used": { + "default": "mdi:memory" } } } diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 69470882153..fb43e574379 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -11,10 +11,12 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + PERCENTAGE, EntityCategory, UnitOfDataRate, UnitOfInformation, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,9 +32,12 @@ from .const import ( KEY_SENSORS, SENSORS_BYTES, SENSORS_CONNECTED_DEVICE, + SENSORS_CPU, SENSORS_LOAD_AVG, + SENSORS_MEMORY, SENSORS_RATES, SENSORS_TEMPERATURES, + SENSORS_UPTIME, ) from .router import AsusWrtRouter @@ -46,6 +51,19 @@ class AsusWrtSensorEntityDescription(SensorEntityDescription): UNIT_DEVICES = "Devices" +CPU_CORE_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = tuple( + AsusWrtSensorEntityDescription( + key=sens_key, + translation_key="cpu_core_usage", + translation_placeholders={"core_id": str(core_id)}, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + ) + for core_id, sens_key in enumerate(SENSORS_CPU[1:], start=1) +) CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_CONNECTED_DEVICE[0], @@ -167,6 +185,61 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, suggested_display_precision=1, ), + AsusWrtSensorEntityDescription( + key=SENSORS_MEMORY[0], + translation_key="memory_usage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_MEMORY[1], + translation_key="memory_free", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=2, + factor=1024, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_MEMORY[2], + translation_key="memory_used", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.MEGABYTES, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=2, + factor=1024, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_UPTIME[0], + translation_key="last_boot", + device_class=SensorDeviceClass.TIMESTAMP, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_UPTIME[1], + translation_key="uptime", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_CPU[0], + translation_key="cpu_usage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + ), + *CPU_CORE_SENSORS, ) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index 4c8386dcd00..bab40f281f5 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -88,6 +88,27 @@ }, "6ghz_temperature": { "name": "6GHz Temperature" + }, + "cpu_usage": { + "name": "CPU usage" + }, + "cpu_core_usage": { + "name": "CPU core {core_id} usage" + }, + "memory_usage": { + "name": "Memory usage" + }, + "memory_free": { + "name": "Memory free" + }, + "memory_used": { + "name": "Memory used" + }, + "last_boot": { + "name": "Last boot" + }, + "uptime": { + "name": "Uptime" } } }, diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index 7710e26707c..f850a26b997 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -16,12 +16,30 @@ ASUSWRT_LEGACY_LIB = f"{ASUSWRT_BASE}.bridge.AsusWrtLegacy" MOCK_BYTES_TOTAL = 60000000000, 50000000000 MOCK_BYTES_TOTAL_HTTP = dict(enumerate(MOCK_BYTES_TOTAL)) +MOCK_CPU_USAGE = { + "cpu1_usage": 0.1, + "cpu2_usage": 0.2, + "cpu3_usage": 0.3, + "cpu4_usage": 0.4, + "cpu5_usage": 0.5, + "cpu6_usage": 0.6, + "cpu7_usage": 0.7, + "cpu8_usage": 0.8, + "cpu_total_usage": 0.9, +} MOCK_CURRENT_TRANSFER_RATES = 20000000, 10000000 MOCK_CURRENT_TRANSFER_RATES_HTTP = dict(enumerate(MOCK_CURRENT_TRANSFER_RATES)) MOCK_LOAD_AVG_HTTP = {"load_avg_1": 1.1, "load_avg_5": 1.2, "load_avg_15": 1.3} MOCK_LOAD_AVG = list(MOCK_LOAD_AVG_HTTP.values()) +MOCK_MEMORY_USAGE = { + "mem_usage_perc": 52.4, + "mem_total": 1048576, + "mem_free": 393216, + "mem_used": 655360, +} MOCK_TEMPERATURES = {"2.4GHz": 40.2, "5.0GHz": 0, "CPU": 71.2} MOCK_TEMPERATURES_HTTP = {**MOCK_TEMPERATURES, "5.0GHz_2": 40.3, "6.0GHz": 40.4} +MOCK_UPTIME = {"last_boot": "2024-08-02T00:47:00+00:00", "uptime": 1625927} @pytest.fixture(name="patch_setup_entry") @@ -121,6 +139,11 @@ def mock_controller_connect_http(mock_devices_http): service_mock.return_value.async_get_temperatures.return_value = { k: v for k, v in MOCK_TEMPERATURES_HTTP.items() if k != "5.0GHz" } + service_mock.return_value.async_get_cpu_usage.return_value = MOCK_CPU_USAGE + service_mock.return_value.async_get_memory_usage.return_value = ( + MOCK_MEMORY_USAGE + ) + service_mock.return_value.async_get_uptime.return_value = MOCK_UPTIME yield service_mock @@ -133,13 +156,22 @@ def mock_controller_connect_http_sens_fail(connect_http): connect_http.return_value.async_get_traffic_rates.side_effect = AsusWrtError connect_http.return_value.async_get_loadavg.side_effect = AsusWrtError connect_http.return_value.async_get_temperatures.side_effect = AsusWrtError + connect_http.return_value.async_get_cpu_usage.side_effect = AsusWrtError + connect_http.return_value.async_get_memory_usage.side_effect = AsusWrtError + connect_http.return_value.async_get_uptime.side_effect = AsusWrtError @pytest.fixture(name="connect_http_sens_detect") def mock_controller_connect_http_sens_detect(): """Mock a successful sensor detection using http library.""" - with patch( - f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_temperature_sensors", - return_value=[*MOCK_TEMPERATURES_HTTP], - ) as mock_sens_detect: - yield mock_sens_detect + with ( + patch( + f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_temperature_sensors", + return_value=[*MOCK_TEMPERATURES_HTTP], + ) as mock_sens_temp_detect, + patch( + f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_cpu_sensors", + return_value=[*MOCK_CPU_USAGE], + ) as mock_sens_cpu_detect, + ): + yield mock_sens_temp_detect, mock_sens_cpu_detect diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 3de830f3f34..0036c40a6f2 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta +from freezegun.api import FrozenDateTimeFactory from pyasuswrt.exceptions import AsusWrtError, AsusWrtNotAvailableInfoError import pytest @@ -10,10 +11,13 @@ from homeassistant.components.asuswrt.const import ( CONF_INTERFACE, DOMAIN, SENSORS_BYTES, + SENSORS_CPU, SENSORS_LOAD_AVG, + SENSORS_MEMORY, SENSORS_RATES, SENSORS_TEMPERATURES, SENSORS_TEMPERATURES_LEGACY, + SENSORS_UPTIME, ) from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import ConfigEntryState @@ -26,7 +30,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import slugify -from homeassistant.util.dt import utcnow from .common import ( CONFIG_DATA_HTTP, @@ -42,7 +45,14 @@ from tests.common import MockConfigEntry, async_fire_time_changed SENSORS_DEFAULT = [*SENSORS_BYTES, *SENSORS_RATES] SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES_LEGACY] -SENSORS_ALL_HTTP = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] +SENSORS_ALL_HTTP = [ + *SENSORS_DEFAULT, + *SENSORS_CPU, + *SENSORS_LOAD_AVG, + *SENSORS_MEMORY, + *SENSORS_TEMPERATURES, + *SENSORS_UPTIME, +] @pytest.fixture(name="create_device_registry_devices") @@ -95,6 +105,7 @@ def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None): async def _test_sensors( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_devices, config, entry_unique_id, @@ -125,7 +136,8 @@ async def _test_sensors( # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(f"{device_tracker.DOMAIN}.test").state == STATE_HOME @@ -139,7 +151,8 @@ async def _test_sensors( # remove first tracked device mock_devices.pop(MOCK_MACS[0]) - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() # consider home option set, all devices still home but only 1 device connected @@ -160,7 +173,8 @@ async def _test_sensors( config_entry, options={CONF_CONSIDER_HOME: 0} ) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() # consider home option set to 0, device "test" not home @@ -176,13 +190,16 @@ async def _test_sensors( ) async def test_sensors_legacy( hass: HomeAssistant, - connect_legacy, + freezer: FrozenDateTimeFactory, mock_devices_legacy, - create_device_registry_devices, entry_unique_id, + connect_legacy, + create_device_registry_devices, ) -> None: """Test creating AsusWRT default sensors and tracker with legacy protocol.""" - await _test_sensors(hass, mock_devices_legacy, CONFIG_DATA_TELNET, entry_unique_id) + await _test_sensors( + hass, freezer, mock_devices_legacy, CONFIG_DATA_TELNET, entry_unique_id + ) @pytest.mark.parametrize( @@ -191,16 +208,21 @@ async def test_sensors_legacy( ) async def test_sensors_http( hass: HomeAssistant, - connect_http, + freezer: FrozenDateTimeFactory, mock_devices_http, - create_device_registry_devices, entry_unique_id, + connect_http, + create_device_registry_devices, ) -> None: """Test creating AsusWRT default sensors and tracker with http protocol.""" - await _test_sensors(hass, mock_devices_http, CONFIG_DATA_HTTP, entry_unique_id) + await _test_sensors( + hass, freezer, mock_devices_http, CONFIG_DATA_HTTP, entry_unique_id + ) -async def _test_loadavg_sensors(hass: HomeAssistant, config) -> None: +async def _test_loadavg_sensors( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, config +) -> None: """Test creating an AsusWRT load average sensors.""" config_entry, sensor_prefix = _setup_entry(hass, config, SENSORS_LOAD_AVG) config_entry.add_to_hass(hass) @@ -208,7 +230,8 @@ async def _test_loadavg_sensors(hass: HomeAssistant, config) -> None: # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() # assert temperature sensor available @@ -217,18 +240,22 @@ async def _test_loadavg_sensors(hass: HomeAssistant, config) -> None: assert hass.states.get(f"{sensor_prefix}_sensor_load_avg15").state == "1.3" -async def test_loadavg_sensors_legacy(hass: HomeAssistant, connect_legacy) -> None: +async def test_loadavg_sensors_legacy( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_legacy +) -> None: """Test creating an AsusWRT load average sensors.""" - await _test_loadavg_sensors(hass, CONFIG_DATA_TELNET) + await _test_loadavg_sensors(hass, freezer, CONFIG_DATA_TELNET) -async def test_loadavg_sensors_http(hass: HomeAssistant, connect_http) -> None: +async def test_loadavg_sensors_http( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_http +) -> None: """Test creating an AsusWRT load average sensors.""" - await _test_loadavg_sensors(hass, CONFIG_DATA_HTTP) + await _test_loadavg_sensors(hass, freezer, CONFIG_DATA_HTTP) async def test_loadavg_sensors_unaivalable_http( - hass: HomeAssistant, connect_http + hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_http ) -> None: """Test load average sensors no available using http.""" config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_LOAD_AVG) @@ -241,7 +268,8 @@ async def test_loadavg_sensors_unaivalable_http( # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() # assert load average sensors not available @@ -271,7 +299,9 @@ async def test_temperature_sensors_http_fail( assert not hass.states.get(f"{sensor_prefix}_6_0ghz") -async def _test_temperature_sensors(hass: HomeAssistant, config, sensors) -> str: +async def _test_temperature_sensors( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, config, sensors +) -> str: """Test creating a AsusWRT temperature sensors.""" config_entry, sensor_prefix = _setup_entry(hass, config, sensors) config_entry.add_to_hass(hass) @@ -279,16 +309,19 @@ async def _test_temperature_sensors(hass: HomeAssistant, config, sensors) -> str # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() return sensor_prefix -async def test_temperature_sensors_legacy(hass: HomeAssistant, connect_legacy) -> None: +async def test_temperature_sensors_legacy( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_legacy +) -> None: """Test creating a AsusWRT temperature sensors.""" sensor_prefix = await _test_temperature_sensors( - hass, CONFIG_DATA_TELNET, SENSORS_TEMPERATURES_LEGACY + hass, freezer, CONFIG_DATA_TELNET, SENSORS_TEMPERATURES_LEGACY ) # assert temperature sensor available assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" @@ -296,10 +329,12 @@ async def test_temperature_sensors_legacy(hass: HomeAssistant, connect_legacy) - assert not hass.states.get(f"{sensor_prefix}_5_0ghz") -async def test_temperature_sensors_http(hass: HomeAssistant, connect_http) -> None: +async def test_temperature_sensors_http( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_http +) -> None: """Test creating a AsusWRT temperature sensors.""" sensor_prefix = await _test_temperature_sensors( - hass, CONFIG_DATA_HTTP, SENSORS_TEMPERATURES + hass, freezer, CONFIG_DATA_HTTP, SENSORS_TEMPERATURES ) # assert temperature sensor available assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" @@ -309,6 +344,97 @@ async def test_temperature_sensors_http(hass: HomeAssistant, connect_http) -> No assert not hass.states.get(f"{sensor_prefix}_5_0ghz") +async def test_cpu_sensors_http_fail( + hass: HomeAssistant, connect_http_sens_fail +) -> None: + """Test fail creating AsusWRT cpu sensors.""" + config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_CPU) + config_entry.add_to_hass(hass) + + # initial devices setup + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # assert cpu availability exception is handled correctly + assert not hass.states.get(f"{sensor_prefix}_cpu1_usage") + assert not hass.states.get(f"{sensor_prefix}_cpu2_usage") + assert not hass.states.get(f"{sensor_prefix}_cpu3_usage") + assert not hass.states.get(f"{sensor_prefix}_cpu4_usage") + assert not hass.states.get(f"{sensor_prefix}_cpu5_usage") + assert not hass.states.get(f"{sensor_prefix}_cpu6_usage") + assert not hass.states.get(f"{sensor_prefix}_cpu7_usage") + assert not hass.states.get(f"{sensor_prefix}_cpu8_usage") + assert not hass.states.get(f"{sensor_prefix}_cpu_total_usage") + + +async def test_cpu_sensors_http( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_http +) -> None: + """Test creating AsusWRT cpu sensors.""" + config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_CPU) + config_entry.add_to_hass(hass) + + # initial devices setup + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # assert cpu sensors available + assert hass.states.get(f"{sensor_prefix}_cpu1_usage").state == "0.1" + assert hass.states.get(f"{sensor_prefix}_cpu2_usage").state == "0.2" + assert hass.states.get(f"{sensor_prefix}_cpu3_usage").state == "0.3" + assert hass.states.get(f"{sensor_prefix}_cpu4_usage").state == "0.4" + assert hass.states.get(f"{sensor_prefix}_cpu5_usage").state == "0.5" + assert hass.states.get(f"{sensor_prefix}_cpu6_usage").state == "0.6" + assert hass.states.get(f"{sensor_prefix}_cpu7_usage").state == "0.7" + assert hass.states.get(f"{sensor_prefix}_cpu8_usage").state == "0.8" + assert hass.states.get(f"{sensor_prefix}_cpu_total_usage").state == "0.9" + + +async def test_memory_sensors_http( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_http +) -> None: + """Test creating AsusWRT memory sensors.""" + config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_MEMORY) + config_entry.add_to_hass(hass) + + # initial devices setup + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # assert memory sensors available + assert hass.states.get(f"{sensor_prefix}_mem_usage_perc").state == "52.4" + assert hass.states.get(f"{sensor_prefix}_mem_free").state == "384.0" + assert hass.states.get(f"{sensor_prefix}_mem_used").state == "640.0" + + +async def test_uptime_sensors_http( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_http +) -> None: + """Test creating AsusWRT uptime sensors.""" + config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_HTTP, SENSORS_UPTIME) + config_entry.add_to_hass(hass) + + # initial devices setup + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # assert uptime sensors available + assert ( + hass.states.get(f"{sensor_prefix}_sensor_last_boot").state + == "2024-08-02T00:47:00+00:00" + ) + assert hass.states.get(f"{sensor_prefix}_sensor_uptime").state == "1625927" + + @pytest.mark.parametrize( "side_effect", [OSError, None], @@ -359,7 +485,9 @@ async def test_connect_fail_http( assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def _test_sensors_polling_fails(hass: HomeAssistant, config, sensors) -> None: +async def _test_sensors_polling_fails( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, config, sensors +) -> None: """Test AsusWRT sensors are unavailable when polling fails.""" config_entry, sensor_prefix = _setup_entry(hass, config, sensors) config_entry.add_to_hass(hass) @@ -367,7 +495,8 @@ async def _test_sensors_polling_fails(hass: HomeAssistant, config, sensors) -> N # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() for sensor_name in sensors: @@ -380,22 +509,28 @@ async def _test_sensors_polling_fails(hass: HomeAssistant, config, sensors) -> N async def test_sensors_polling_fails_legacy( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, connect_legacy_sens_fail, ) -> None: """Test AsusWRT sensors are unavailable when polling fails.""" - await _test_sensors_polling_fails(hass, CONFIG_DATA_TELNET, SENSORS_ALL_LEGACY) + await _test_sensors_polling_fails( + hass, freezer, CONFIG_DATA_TELNET, SENSORS_ALL_LEGACY + ) async def test_sensors_polling_fails_http( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, connect_http_sens_fail, connect_http_sens_detect, ) -> None: """Test AsusWRT sensors are unavailable when polling fails.""" - await _test_sensors_polling_fails(hass, CONFIG_DATA_HTTP, SENSORS_ALL_HTTP) + await _test_sensors_polling_fails(hass, freezer, CONFIG_DATA_HTTP, SENSORS_ALL_HTTP) -async def test_options_reload(hass: HomeAssistant, connect_legacy) -> None: +async def test_options_reload( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, connect_legacy +) -> None: """Test AsusWRT integration is reload changing an options that require this.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -408,7 +543,8 @@ async def test_options_reload(hass: HomeAssistant, connect_legacy) -> None: await hass.async_block_till_done() assert connect_legacy.return_value.connection.async_connect.call_count == 1 - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() # change an option that requires integration reload @@ -451,7 +587,10 @@ async def test_unique_id_migration( async def test_decorator_errors( - hass: HomeAssistant, connect_legacy, mock_available_temps + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + connect_legacy, + mock_available_temps, ) -> None: """Test AsusWRT sensors are unavailable on decorator type check error.""" sensors = [*SENSORS_BYTES, *SENSORS_TEMPERATURES_LEGACY] @@ -465,7 +604,8 @@ async def test_decorator_errors( # initial devices setup assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done() for sensor_name in sensors: From 8255728f530df6345b8e043ec291d538a5259553 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Tue, 3 Sep 2024 17:21:13 +0200 Subject: [PATCH 0202/1309] Migrate emoncms to config flow (#121336) * Migrate emoncms to config flow * tests coverage 98% * use runtime_data * Remove pyemoncms bump. * Remove not needed yaml parameters add async_update_data to coordinator * Reduce snapshot size * Remove CONF_UNIT_OF_MEASUREMENT * correct path in emoncms_client mock * Remove init connexion check as done by config_entry_first_refresh since async_update_data catches exceptionand raise UpdateFailed * Remove CONF_EXCLUDE_FEEDID from config flow * Update homeassistant/components/emoncms/__init__.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/emoncms/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/emoncms/strings.json Co-authored-by: Joost Lekkerkerker * Use options in options flow and common strings * Extend the ConfigEntry type * Define the type explicitely * Add data description in strings.json * Update tests/components/emoncms/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/emoncms/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Add test import same yaml conf + corrections * Add test user flow * Use data_description... * Use snapshot_platform in test_sensor * Transfer all fixtures in conftest * Add async_step_choose_feeds to ask flows to user * Test abortion reason in test_flow_import_failure * Add issue when value_template is i yaml conf * make text more expressive in strings.json * Add issue when no feed imported during migration. * Update tests/components/emoncms/test_config_flow.py * Update tests/components/emoncms/test_config_flow.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/emoncms/__init__.py | 39 ++++ .../components/emoncms/config_flow.py | 210 ++++++++++++++++++ homeassistant/components/emoncms/const.py | 3 + .../components/emoncms/coordinator.py | 3 +- .../components/emoncms/manifest.json | 1 + homeassistant/components/emoncms/sensor.py | 150 +++++++------ homeassistant/components/emoncms/strings.json | 40 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/emoncms/__init__.py | 11 + tests/components/emoncms/conftest.py | 105 ++++++++- .../emoncms/snapshots/test_sensor.ambr | 41 +++- tests/components/emoncms/test_config_flow.py | 143 ++++++++++++ tests/components/emoncms/test_init.py | 40 ++++ tests/components/emoncms/test_sensor.py | 133 ++++++++--- 15 files changed, 815 insertions(+), 107 deletions(-) create mode 100644 homeassistant/components/emoncms/config_flow.py create mode 100644 homeassistant/components/emoncms/strings.json create mode 100644 tests/components/emoncms/test_config_flow.py create mode 100644 tests/components/emoncms/test_init.py diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 5e7adbcd6e7..98ed6328578 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -1 +1,40 @@ """The emoncms component.""" + +from pyemoncms import EmoncmsClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import EmoncmsCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type EmonCMSConfigEntry = ConfigEntry[EmoncmsCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: + """Load a config entry.""" + emoncms_client = EmoncmsClient( + entry.data[CONF_URL], + entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + coordinator = EmoncmsCoordinator(hass, emoncms_client) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + entry.async_on_unload(entry.add_update_listener(update_listener)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry): + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py new file mode 100644 index 00000000000..fdd5d29788e --- /dev/null +++ b/homeassistant/components/emoncms/config_flow.py @@ -0,0 +1,210 @@ +"""Configflow for the emoncms integration.""" + +from typing import Any + +from pyemoncms import EmoncmsClient +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import selector +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_MESSAGE, + CONF_ONLY_INCLUDE_FEEDID, + CONF_SUCCESS, + DOMAIN, + FEED_ID, + FEED_NAME, + FEED_TAG, + LOGGER, +) + + +def get_options(feeds: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Build the selector options with the feed list.""" + return [ + { + "value": feed[FEED_ID], + "label": f"{feed[FEED_ID]}|{feed[FEED_TAG]}|{feed[FEED_NAME]}", + } + for feed in feeds + ] + + +def sensor_name(url: str) -> str: + """Return sensor name.""" + sensorip = url.rsplit("//", maxsplit=1)[-1] + return f"emoncms@{sensorip}" + + +async def get_feed_list(hass: HomeAssistant, url: str, api_key: str) -> dict[str, Any]: + """Check connection to emoncms and return feed list if successful.""" + emoncms_client = EmoncmsClient( + url, + api_key, + session=async_get_clientsession(hass), + ) + return await emoncms_client.async_request("/feed/list.json") + + +class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): + """emoncms integration UI config flow.""" + + url: str + api_key: str + include_only_feeds: list | None = None + dropdown: dict = {} + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlowWithConfigEntry: + """Get the options flow for this handler.""" + return EmoncmsOptionsFlow(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Initiate a flow via the UI.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match( + { + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_URL: user_input[CONF_URL], + } + ) + result = await get_feed_list( + self.hass, user_input[CONF_URL], user_input[CONF_API_KEY] + ) + if not result[CONF_SUCCESS]: + errors["base"] = result[CONF_MESSAGE] + else: + self.include_only_feeds = user_input.get(CONF_ONLY_INCLUDE_FEEDID) + self.url = user_input[CONF_URL] + self.api_key = user_input[CONF_API_KEY] + options = get_options(result[CONF_MESSAGE]) + self.dropdown = { + "options": options, + "mode": "dropdown", + "multiple": True, + } + return await self.async_step_choose_feeds() + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_API_KEY): str, + } + ), + user_input, + ), + errors=errors, + ) + + async def async_step_choose_feeds( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Choose feeds to import.""" + errors: dict[str, str] = {} + include_only_feeds: list = [] + if user_input or self.include_only_feeds is not None: + if self.include_only_feeds is not None: + include_only_feeds = self.include_only_feeds + elif user_input: + include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID] + return self.async_create_entry( + title=sensor_name(self.url), + data={ + CONF_URL: self.url, + CONF_API_KEY: self.api_key, + CONF_ONLY_INCLUDE_FEEDID: include_only_feeds, + }, + ) + return self.async_show_form( + step_id="choose_feeds", + data_schema=vol.Schema( + { + vol.Required( + CONF_ONLY_INCLUDE_FEEDID, + default=include_only_feeds, + ): selector({"select": self.dropdown}), + } + ), + errors=errors, + ) + + async def async_step_import(self, import_info: ConfigType) -> ConfigFlowResult: + """Import config from yaml.""" + url = import_info[CONF_URL] + api_key = import_info[CONF_API_KEY] + include_only_feeds = None + if import_info.get(CONF_ONLY_INCLUDE_FEEDID) is not None: + include_only_feeds = list(map(str, import_info[CONF_ONLY_INCLUDE_FEEDID])) + config = { + CONF_API_KEY: api_key, + CONF_ONLY_INCLUDE_FEEDID: include_only_feeds, + CONF_URL: url, + } + LOGGER.debug(config) + result = await self.async_step_user(config) + if errors := result.get("errors"): + return self.async_abort(reason=errors["base"]) + return result + + +class EmoncmsOptionsFlow(OptionsFlowWithConfigEntry): + """Emoncms Options flow handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + data = self.options if self.options else self._config_entry.data + url = data[CONF_URL] + api_key = data[CONF_API_KEY] + include_only_feeds = data.get(CONF_ONLY_INCLUDE_FEEDID, []) + options: list = include_only_feeds + result = await get_feed_list(self.hass, url, api_key) + if not result[CONF_SUCCESS]: + errors["base"] = result[CONF_MESSAGE] + else: + options = get_options(result[CONF_MESSAGE]) + dropdown = {"options": options, "mode": "dropdown", "multiple": True} + if user_input: + include_only_feeds = user_input[CONF_ONLY_INCLUDE_FEEDID] + return self.async_create_entry( + title=sensor_name(url), + data={ + CONF_URL: url, + CONF_API_KEY: api_key, + CONF_ONLY_INCLUDE_FEEDID: include_only_feeds, + }, + ) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_ONLY_INCLUDE_FEEDID, default=include_only_feeds + ): selector({"select": dropdown}), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/emoncms/const.py b/homeassistant/components/emoncms/const.py index 96269218316..256db5726bb 100644 --- a/homeassistant/components/emoncms/const.py +++ b/homeassistant/components/emoncms/const.py @@ -7,6 +7,9 @@ CONF_ONLY_INCLUDE_FEEDID = "include_only_feed_id" CONF_MESSAGE = "message" CONF_SUCCESS = "success" DOMAIN = "emoncms" +FEED_ID = "id" +FEED_NAME = "name" +FEED_TAG = "tag" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/emoncms/coordinator.py b/homeassistant/components/emoncms/coordinator.py index d1f6a2858c7..c6fda5ed7c8 100644 --- a/homeassistant/components/emoncms/coordinator.py +++ b/homeassistant/components/emoncms/coordinator.py @@ -18,14 +18,13 @@ class EmoncmsCoordinator(DataUpdateCoordinator[list[dict[str, Any]] | None]): self, hass: HomeAssistant, emoncms_client: EmoncmsClient, - scan_interval: timedelta, ) -> None: """Initialize the emoncms data coordinator.""" super().__init__( hass, LOGGER, name="emoncms_coordinator", - update_interval=scan_interval, + update_interval=timedelta(seconds=60), ) self.emoncms_client = emoncms_client diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json index 09229d0419a..f8f0f2edb95 100644 --- a/homeassistant/components/emoncms/manifest.json +++ b/homeassistant/components/emoncms/manifest.json @@ -2,6 +2,7 @@ "domain": "emoncms", "name": "Emoncms", "codeowners": ["@borpin", "@alexandrecuer"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/emoncms", "iot_class": "local_polling", "requirements": ["pyemoncms==0.0.7"] diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 3c448391974..4add7c9625d 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -2,10 +2,8 @@ from __future__ import annotations -from datetime import timedelta from typing import Any -from pyemoncms import EmoncmsClient import voluptuous as vol from homeassistant.components.sensor import ( @@ -14,25 +12,33 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_ID, - CONF_SCAN_INTERVAL, CONF_UNIT_OF_MEASUREMENT, CONF_URL, CONF_VALUE_TEMPLATE, - STATE_UNKNOWN, UnitOfPower, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import template -from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_EXCLUDE_FEEDID, CONF_ONLY_INCLUDE_FEEDID +from .config_flow import sensor_name +from .const import ( + CONF_EXCLUDE_FEEDID, + CONF_ONLY_INCLUDE_FEEDID, + DOMAIN, + FEED_ID, + FEED_NAME, + FEED_TAG, +) from .coordinator import EmoncmsCoordinator ATTR_FEEDID = "FeedId" @@ -42,9 +48,7 @@ ATTR_LASTUPDATETIMESTR = "LastUpdatedStr" ATTR_SIZE = "Size" ATTR_TAG = "Tag" ATTR_USERID = "UserId" - CONF_SENSOR_NAMES = "sensor_names" - DECIMALS = 2 DEFAULT_UNIT = UnitOfPower.WATT @@ -76,20 +80,73 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Emoncms sensor.""" - apikey = config[CONF_API_KEY] - url = config[CONF_URL] - sensorid = config[CONF_ID] - value_template = config.get(CONF_VALUE_TEMPLATE) - config_unit = config.get(CONF_UNIT_OF_MEASUREMENT) + """Import config from yaml.""" + if CONF_VALUE_TEMPLATE in config: + async_create_issue( + hass, + DOMAIN, + f"remove_{CONF_VALUE_TEMPLATE}_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.ERROR, + translation_key=f"remove_{CONF_VALUE_TEMPLATE}", + translation_placeholders={ + "domain": DOMAIN, + "parameter": CONF_VALUE_TEMPLATE, + }, + ) + return + if CONF_ONLY_INCLUDE_FEEDID not in config: + async_create_issue( + hass, + DOMAIN, + f"missing_{CONF_ONLY_INCLUDE_FEEDID}_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"missing_{CONF_ONLY_INCLUDE_FEEDID}", + translation_placeholders={ + "domain": DOMAIN, + }, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + if ( + result.get("type") == FlowResultType.CREATE_ENTRY + or result.get("reason") == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2025.3.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "emoncms", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the emoncms sensors.""" + config = entry.options if entry.options else entry.data + name = sensor_name(config[CONF_URL]) exclude_feeds = config.get(CONF_EXCLUDE_FEEDID) include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID) - sensor_names = config.get(CONF_SENSOR_NAMES) - scan_interval = config.get(CONF_SCAN_INTERVAL, timedelta(seconds=30)) - emoncms_client = EmoncmsClient(url, apikey, session=async_get_clientsession(hass)) - coordinator = EmoncmsCoordinator(hass, emoncms_client, scan_interval) - await coordinator.async_refresh() + if exclude_feeds is None and include_only_feeds is None: + return + + coordinator = entry.runtime_data elems = coordinator.data if not elems: return @@ -97,28 +154,15 @@ async def async_setup_platform( sensors: list[EmonCmsSensor] = [] for idx, elem in enumerate(elems): - if exclude_feeds is not None and int(elem["id"]) in exclude_feeds: + if include_only_feeds is not None and elem[FEED_ID] not in include_only_feeds: continue - if include_only_feeds is not None and int(elem["id"]) not in include_only_feeds: - continue - - name = None - if sensor_names is not None: - name = sensor_names.get(int(elem["id"]), None) - - if unit := elem.get("unit"): - unit_of_measurement = unit - else: - unit_of_measurement = config_unit - sensors.append( EmonCmsSensor( coordinator, + entry.entry_id, + elem["unit"], name, - value_template, - unit_of_measurement, - str(sensorid), idx, ) ) @@ -131,10 +175,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): def __init__( self, coordinator: EmoncmsCoordinator, - name: str | None, - value_template: template.Template | None, + entry_id: str, unit_of_measurement: str | None, - sensorid: str, + name: str, idx: int, ) -> None: """Initialize the sensor.""" @@ -143,20 +186,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): elem = {} if self.coordinator.data: elem = self.coordinator.data[self.idx] - if name is None: - # Suppress ID in sensor name if it's 1, since most people won't - # have more than one EmonCMS source and it's redundant to show the - # ID if there's only one. - id_for_name = "" if str(sensorid) == "1" else sensorid - # Use the feed name assigned in EmonCMS or fall back to the feed ID - feed_name = elem.get("name", f"Feed {elem.get('id')}") - self._attr_name = f"EmonCMS{id_for_name} {feed_name}" - else: - self._attr_name = name - self._value_template = value_template + self._attr_name = f"{name} {elem[FEED_NAME]}" self._attr_native_unit_of_measurement = unit_of_measurement - self._sensorid = sensorid - + self._attr_unique_id = f"{entry_id}-{elem[FEED_ID]}" if unit_of_measurement in ("kWh", "Wh"): self._attr_device_class = SensorDeviceClass.ENERGY self._attr_state_class = SensorStateClass.TOTAL_INCREASING @@ -186,9 +218,9 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): def _update_attributes(self, elem: dict[str, Any]) -> None: """Update entity attributes.""" self._attr_extra_state_attributes = { - ATTR_FEEDID: elem["id"], - ATTR_TAG: elem["tag"], - ATTR_FEEDNAME: elem["name"], + ATTR_FEEDID: elem[FEED_ID], + ATTR_TAG: elem[FEED_TAG], + ATTR_FEEDNAME: elem[FEED_NAME], } if elem["value"] is not None: self._attr_extra_state_attributes[ATTR_SIZE] = elem["size"] @@ -199,13 +231,7 @@ class EmonCmsSensor(CoordinatorEntity[EmoncmsCoordinator], SensorEntity): ) self._attr_native_value = None - if self._value_template is not None: - self._attr_native_value = ( - self._value_template.async_render_with_possible_json_value( - elem["value"], STATE_UNKNOWN - ) - ) - elif elem["value"] is not None: + if elem["value"] is not None: self._attr_native_value = round(float(elem["value"]), DECIMALS) @callback diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json new file mode 100644 index 00000000000..4a700cc8981 --- /dev/null +++ b/homeassistant/components/emoncms/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "Server url starting with the protocol (http or https)", + "api_key": "Your 32 bits api key" + } + }, + "choose_feeds": { + "data": { + "include_only_feed_id": "Choose feeds to include" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "include_only_feed_id": "[%key:component::emoncms::config::step::choose_feeds::data::include_only_feed_id%]" + } + } + } + }, + "issues": { + "remove_value_template": { + "title": "The {domain} integration cannot start", + "description": "Configuring {domain} using YAML is being removed and the `{parameter}` parameter cannot be imported.\n\nPlease remove `{parameter}` from your `{domain}` yaml configuration and restart Home Assistant\n\nAlternatively, you may entirely remove the `{domain}` configuration from your configuration.yaml, restart Home Assistant, and add the {domain} integration manually." + }, + "missing_include_only_feed_id": { + "title": "No feed synchronized with the {domain} sensor", + "description": "Configuring {domain} using YAML is being removed.\n\nPlease add manually the feeds you want to synchronize with the `configure` button of the integration." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5f46cb1013e..e78df5ab045 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -157,6 +157,7 @@ FLOWS = { "elkm1", "elmax", "elvia", + "emoncms", "emonitor", "emulated_roku", "energenie_power_sockets", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e379851b37f..879012ae54b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1569,7 +1569,7 @@ "integrations": { "emoncms": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "Emoncms" }, diff --git a/tests/components/emoncms/__init__.py b/tests/components/emoncms/__init__.py index ecf3c54e9ed..59dc4fa08e1 100644 --- a/tests/components/emoncms/__init__.py +++ b/tests/components/emoncms/__init__.py @@ -1 +1,12 @@ """Tests for the emoncms component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Set up the integration.""" + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/emoncms/conftest.py b/tests/components/emoncms/conftest.py index 500fff228e9..29e86f3c59d 100644 --- a/tests/components/emoncms/conftest.py +++ b/tests/components/emoncms/conftest.py @@ -1,10 +1,23 @@ """Fixtures for emoncms integration tests.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator +import copy from unittest.mock import AsyncMock, patch import pytest +from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_ID, + CONF_PLATFORM, + CONF_URL, + CONF_VALUE_TEMPLATE, +) +from homeassistant.helpers.typing import ConfigType + +from tests.common import MockConfigEntry + UNITS = ["kWh", "Wh", "W", "V", "A", "VA", "°C", "°F", "K", "Hz", "hPa", ""] @@ -29,16 +42,102 @@ FEEDS = [get_feed(i + 1, unit=unit) for i, unit in enumerate(UNITS)] EMONCMS_FAILURE = {"success": False, "message": "failure"} +FLOW_RESULT = { + CONF_API_KEY: "my_api_key", + CONF_ONLY_INCLUDE_FEEDID: [str(i + 1) for i in range(len(UNITS))], + CONF_URL: "http://1.1.1.1", +} + +SENSOR_NAME = "emoncms@1.1.1.1" + +YAML_BASE = { + CONF_PLATFORM: "emoncms", + CONF_API_KEY: "my_api_key", + CONF_ID: 1, + CONF_URL: "http://1.1.1.1", +} + +YAML = { + **YAML_BASE, + CONF_ONLY_INCLUDE_FEEDID: [1], +} + + +@pytest.fixture +def emoncms_yaml_config() -> ConfigType: + """Mock emoncms yaml configuration.""" + return {"sensor": YAML} + + +@pytest.fixture +def emoncms_yaml_config_with_template() -> ConfigType: + """Mock emoncms yaml conf with template parameter.""" + return {"sensor": {**YAML, CONF_VALUE_TEMPLATE: "{{ value | float + 1500 }}"}} + + +@pytest.fixture +def emoncms_yaml_config_no_include_only_feed_id() -> ConfigType: + """Mock emoncms yaml configuration without include_only_feed_id parameter.""" + return {"sensor": YAML_BASE} + + +@pytest.fixture +def config_entry() -> MockConfigEntry: + """Mock emoncms config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=SENSOR_NAME, + data=FLOW_RESULT, + ) + + +FLOW_RESULT_NO_FEED = copy.deepcopy(FLOW_RESULT) +FLOW_RESULT_NO_FEED[CONF_ONLY_INCLUDE_FEEDID] = None + + +@pytest.fixture +def config_no_feed() -> MockConfigEntry: + """Mock emoncms config entry with no feed selected.""" + return MockConfigEntry( + domain=DOMAIN, + title=SENSOR_NAME, + data=FLOW_RESULT_NO_FEED, + ) + + +FLOW_RESULT_SINGLE_FEED = copy.deepcopy(FLOW_RESULT) +FLOW_RESULT_SINGLE_FEED[CONF_ONLY_INCLUDE_FEEDID] = ["1"] + + +@pytest.fixture +def config_single_feed() -> MockConfigEntry: + """Mock emoncms config entry with a single feed exposed.""" + return MockConfigEntry( + domain=DOMAIN, + title=SENSOR_NAME, + data=FLOW_RESULT_SINGLE_FEED, + entry_id="XXXXXXXX", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.emoncms.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + @pytest.fixture async def emoncms_client() -> AsyncGenerator[AsyncMock]: """Mock pyemoncms success response.""" with ( patch( - "homeassistant.components.emoncms.sensor.EmoncmsClient", autospec=True + "homeassistant.components.emoncms.EmoncmsClient", autospec=True ) as mock_client, patch( - "homeassistant.components.emoncms.coordinator.EmoncmsClient", + "homeassistant.components.emoncms.config_flow.EmoncmsClient", new=mock_client, ), ): diff --git a/tests/components/emoncms/snapshots/test_sensor.ambr b/tests/components/emoncms/snapshots/test_sensor.ambr index 62c85aaba01..5e718c1d8e8 100644 --- a/tests/components/emoncms/snapshots/test_sensor.ambr +++ b/tests/components/emoncms/snapshots/test_sensor.ambr @@ -1,5 +1,40 @@ # serializer version: 1 -# name: test_coordinator_update[sensor.emoncms_parameter_1] +# name: test_coordinator_update[sensor.emoncms_1_1_1_1_parameter_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'emoncms@1.1.1.1 parameter 1', + 'platform': 'emoncms', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'XXXXXXXX-1', + 'unit_of_measurement': , + }) +# --- +# name: test_coordinator_update[sensor.emoncms_1_1_1_1_parameter_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'FeedId': '1', @@ -10,12 +45,12 @@ 'Tag': 'tag', 'UserId': '1', 'device_class': 'temperature', - 'friendly_name': 'EmonCMS parameter 1', + 'friendly_name': 'emoncms@1.1.1.1 parameter 1', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.emoncms_parameter_1', + 'entity_id': 'sensor.emoncms_1_1_1_1_parameter_1', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py new file mode 100644 index 00000000000..17ec32a9008 --- /dev/null +++ b/tests/components/emoncms/test_config_flow.py @@ -0,0 +1,143 @@ +"""Test emoncms config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration +from .conftest import EMONCMS_FAILURE, FLOW_RESULT_SINGLE_FEED, SENSOR_NAME, YAML + +from tests.common import MockConfigEntry + + +async def test_flow_import_include_feeds( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + emoncms_client: AsyncMock, +) -> None: + """YAML import with included feed - success test.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=YAML, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == SENSOR_NAME + assert result["data"] == FLOW_RESULT_SINGLE_FEED + + +async def test_flow_import_failure( + hass: HomeAssistant, + emoncms_client: AsyncMock, +) -> None: + """YAML import - failure test.""" + emoncms_client.async_request.return_value = EMONCMS_FAILURE + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=YAML, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == EMONCMS_FAILURE["message"] + + +async def test_flow_import_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, + emoncms_client: AsyncMock, +) -> None: + """Test we abort import data set when entry is already configured.""" + config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=YAML, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +USER_INPUT = { + CONF_URL: "http://1.1.1.1", + CONF_API_KEY: "my_api_key", +} + + +async def test_user_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + emoncms_client: AsyncMock, +) -> None: + """Test we get the user form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ONLY_INCLUDE_FEEDID: ["1"]}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == SENSOR_NAME + assert result["data"] == {**USER_INPUT, CONF_ONLY_INCLUDE_FEEDID: ["1"]} + assert len(mock_setup_entry.mock_calls) == 1 + + +USER_OPTIONS = { + CONF_ONLY_INCLUDE_FEEDID: ["1"], +} + +CONFIG_ENTRY = { + CONF_API_KEY: "my_api_key", + CONF_ONLY_INCLUDE_FEEDID: ["1"], + CONF_URL: "http://1.1.1.1", +} + + +async def test_options_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + emoncms_client: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Options flow - success test.""" + await setup_integration(hass, config_entry) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=USER_OPTIONS, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == CONFIG_ENTRY + assert config_entry.options == CONFIG_ENTRY + + +async def test_options_flow_failure( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + emoncms_client: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Options flow - test failure.""" + emoncms_client.async_request.return_value = EMONCMS_FAILURE + await setup_integration(hass, config_entry) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + assert result["errors"]["base"] == "failure" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" diff --git a/tests/components/emoncms/test_init.py b/tests/components/emoncms/test_init.py new file mode 100644 index 00000000000..b89b6e65a66 --- /dev/null +++ b/tests/components/emoncms/test_init.py @@ -0,0 +1,40 @@ +"""Test Emoncms component setup process.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import EMONCMS_FAILURE + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + emoncms_client: AsyncMock, +) -> None: + """Test load and unload entry.""" + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, + emoncms_client: AsyncMock, +) -> None: + """Test load failure.""" + emoncms_client.async_request.return_value = EMONCMS_FAILURE + config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/emoncms/test_sensor.py b/tests/components/emoncms/test_sensor.py index a039239077e..a7bc8059287 100644 --- a/tests/components/emoncms/test_sensor.py +++ b/tests/components/emoncms/test_sensor.py @@ -1,54 +1,112 @@ """Test emoncms sensor.""" -from typing import Any from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN +from homeassistant.components.emoncms.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_PLATFORM, CONF_URL -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from .conftest import EMONCMS_FAILURE, FEEDS, get_feed +from . import setup_integration +from .conftest import EMONCMS_FAILURE, get_feed -from tests.common import async_fire_time_changed - -YAML = { - CONF_PLATFORM: "emoncms", - CONF_API_KEY: "my_api_key", - CONF_ID: 1, - CONF_URL: "http://1.1.1.1", - CONF_ONLY_INCLUDE_FEEDID: [1, 2], - "scan_interval": 30, -} +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -@pytest.fixture -def emoncms_yaml_config() -> ConfigType: - """Mock emoncms configuration from yaml.""" - return {"sensor": YAML} +async def test_deprecated_yaml( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + emoncms_yaml_config: ConfigType, + emoncms_client: AsyncMock, +) -> None: + """Test an issue is created when we import from yaml config.""" + + await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue( + domain=HOMEASSISTANT_DOMAIN, issue_id=f"deprecated_yaml_{DOMAIN}" + ) -def get_entity_ids(feeds: list[dict[str, Any]]) -> list[str]: - """Get emoncms entity ids.""" - return [ - f"{SENSOR_DOMAIN}.{DOMAIN}_{feed["name"].replace(' ', '_')}" for feed in feeds - ] +async def test_yaml_with_template( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + emoncms_yaml_config_with_template: ConfigType, + emoncms_client: AsyncMock, +) -> None: + """Test an issue is created when we import a yaml config with a value_template parameter.""" + + await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config_with_template) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id=f"remove_value_template_{DOMAIN}" + ) -def get_feeds(nbs: list[int]) -> list[dict[str, Any]]: - """Get feeds.""" - return [feed for feed in FEEDS if feed["id"] in str(nbs)] +async def test_yaml_no_include_only_feed_id( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + emoncms_yaml_config_no_include_only_feed_id: ConfigType, + emoncms_client: AsyncMock, +) -> None: + """Test an issue is created when we import a yaml config without a include_only_feed_id parameter.""" + + await async_setup_component( + hass, SENSOR_DOMAIN, emoncms_yaml_config_no_include_only_feed_id + ) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id=f"missing_include_only_feed_id_{DOMAIN}" + ) + + +async def test_no_feed_selected( + hass: HomeAssistant, + config_no_feed: MockConfigEntry, + entity_registry: er.EntityRegistry, + emoncms_client: AsyncMock, +) -> None: + """Test with no feed selected.""" + await setup_integration(hass, config_no_feed) + + assert config_no_feed.state is ConfigEntryState.LOADED + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_no_feed.entry_id + ) + assert entity_entries == [] + + +async def test_no_feed_broadcast( + hass: HomeAssistant, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + emoncms_client: AsyncMock, +) -> None: + """Test with no feed broadcasted.""" + emoncms_client.async_request.return_value = {"success": True, "message": []} + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.LOADED + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == [] async def test_coordinator_update( hass: HomeAssistant, - emoncms_yaml_config: ConfigType, + config_single_feed: MockConfigEntry, + entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, emoncms_client: AsyncMock, caplog: pytest.LogCaptureFixture, @@ -59,12 +117,11 @@ async def test_coordinator_update( "success": True, "message": [get_feed(1, unit="°C")], } - await async_setup_component(hass, SENSOR_DOMAIN, emoncms_yaml_config) - await hass.async_block_till_done() - feeds = get_feeds([1]) - for entity_id in get_entity_ids(feeds): - state = hass.states.get(entity_id) - assert state == snapshot(name=entity_id) + await setup_integration(hass, config_single_feed) + + await snapshot_platform( + hass, entity_registry, snapshot, config_single_feed.entry_id + ) async def skip_time() -> None: freezer.tick(60) @@ -78,8 +135,12 @@ async def test_coordinator_update( await skip_time() - for entity_id in get_entity_ids(feeds): - state = hass.states.get(entity_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_single_feed.entry_id + ) + + for entity_entry in entity_entries: + state = hass.states.get(entity_entry.entity_id) assert state.attributes["LastUpdated"] == 1665509670 assert state.state == "24.04" From 00533bae4ba42be8ad33c50fb8eadcd982af0be5 Mon Sep 17 00:00:00 2001 From: Alex Wijnholds <1023654+Alexwijn@users.noreply.github.com> Date: Tue, 3 Sep 2024 17:44:20 +0200 Subject: [PATCH 0203/1309] Add support for total YouTube views (#123144) * Add support for retrieving the total views of a channel. * Add missing tests * Re-order imports * Another run on code format * Add missing translation * Update YouTube test snapshots --- homeassistant/components/youtube/const.py | 1 + .../components/youtube/coordinator.py | 2 ++ homeassistant/components/youtube/sensor.py | 10 +++++++ homeassistant/components/youtube/strings.json | 3 +- .../youtube/snapshots/test_diagnostics.ambr | 1 + .../youtube/snapshots/test_sensor.ambr | 30 +++++++++++++++++++ tests/components/youtube/test_sensor.py | 15 ++++++++++ 7 files changed, 61 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/youtube/const.py b/homeassistant/components/youtube/const.py index a663c487d0a..da5a554f364 100644 --- a/homeassistant/components/youtube/const.py +++ b/homeassistant/components/youtube/const.py @@ -15,6 +15,7 @@ AUTH = "auth" LOGGER = logging.getLogger(__package__) ATTR_TITLE = "title" +ATTR_TOTAL_VIEWS = "total_views" ATTR_LATEST_VIDEO = "latest_video" ATTR_SUBSCRIBER_COUNT = "subscriber_count" ATTR_DESCRIPTION = "description" diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 4599342c84d..0da480f1169 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -22,6 +22,7 @@ from .const import ( ATTR_SUBSCRIBER_COUNT, ATTR_THUMBNAIL, ATTR_TITLE, + ATTR_TOTAL_VIEWS, ATTR_VIDEO_ID, CONF_CHANNELS, DOMAIN, @@ -68,6 +69,7 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): ATTR_ICON: channel.snippet.thumbnails.get_highest_quality().url, ATTR_LATEST_VIDEO: latest_video, ATTR_SUBSCRIBER_COUNT: channel.statistics.subscriber_count, + ATTR_TOTAL_VIEWS: channel.statistics.view_count, } except UnauthorizedError as err: raise ConfigEntryAuthFailed from err diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index bc69f92e8fd..8832382508c 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -20,6 +20,7 @@ from .const import ( ATTR_SUBSCRIBER_COUNT, ATTR_THUMBNAIL, ATTR_TITLE, + ATTR_TOTAL_VIEWS, ATTR_VIDEO_ID, COORDINATOR, DOMAIN, @@ -58,6 +59,15 @@ SENSOR_TYPES = [ entity_picture_fn=lambda channel: channel[ATTR_ICON], attributes_fn=None, ), + YouTubeSensorEntityDescription( + key="views", + translation_key="views", + native_unit_of_measurement="views", + available_fn=lambda _: True, + value_fn=lambda channel: channel[ATTR_TOTAL_VIEWS], + entity_picture_fn=lambda channel: channel[ATTR_ICON], + attributes_fn=None, + ), ] diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index d664e2f15e7..5902d3a4482 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -46,7 +46,8 @@ "published_at": { "name": "Published at" } } }, - "subscribers": { "name": "Subscribers" } + "subscribers": { "name": "Subscribers" }, + "views": { "name": "Views" } } } } diff --git a/tests/components/youtube/snapshots/test_diagnostics.ambr b/tests/components/youtube/snapshots/test_diagnostics.ambr index a938cb8daad..50dc2757e8c 100644 --- a/tests/components/youtube/snapshots/test_diagnostics.ambr +++ b/tests/components/youtube/snapshots/test_diagnostics.ambr @@ -12,6 +12,7 @@ }), 'subscriber_count': 2290000, 'title': 'Google for Developers', + 'total_views': 214141263, }), }) # --- diff --git a/tests/components/youtube/snapshots/test_sensor.ambr b/tests/components/youtube/snapshots/test_sensor.ambr index cddfa6f6a3d..dce546b4803 100644 --- a/tests/components/youtube/snapshots/test_sensor.ambr +++ b/tests/components/youtube/snapshots/test_sensor.ambr @@ -30,6 +30,21 @@ 'state': '2290000', }) # --- +# name: test_sensor.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj', + 'friendly_name': 'Google for Developers Views', + 'unit_of_measurement': 'views', + }), + 'context': , + 'entity_id': 'sensor.google_for_developers_views', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '214141263', + }) +# --- # name: test_sensor_without_uploaded_video StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -58,3 +73,18 @@ 'state': '2290000', }) # --- +# name: test_sensor_without_uploaded_video.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj', + 'friendly_name': 'Google for Developers Views', + 'unit_of_measurement': 'views', + }), + 'context': , + 'entity_id': 'sensor.google_for_developers_views', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '214141263', + }) +# --- diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index ae0c38306e4..e883347c8db 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -29,6 +29,9 @@ async def test_sensor( state = hass.states.get("sensor.google_for_developers_subscribers") assert state == snapshot + state = hass.states.get("sensor.google_for_developers_views") + assert state == snapshot + async def test_sensor_without_uploaded_video( hass: HomeAssistant, snapshot: SnapshotAssertion, setup_integration: ComponentSetup @@ -52,6 +55,9 @@ async def test_sensor_without_uploaded_video( state = hass.states.get("sensor.google_for_developers_subscribers") assert state == snapshot + state = hass.states.get("sensor.google_for_developers_views") + assert state == snapshot + async def test_sensor_updating( hass: HomeAssistant, setup_integration: ComponentSetup @@ -95,6 +101,9 @@ async def test_sensor_reauth_trigger( state = hass.states.get("sensor.google_for_developers_subscribers") assert state.state == "2290000" + state = hass.states.get("sensor.google_for_developers_views") + assert state.state == "214141263" + mock.set_thrown_exception(UnauthorizedError()) future = dt_util.utcnow() + timedelta(minutes=15) async_fire_time_changed(hass, future) @@ -121,6 +130,9 @@ async def test_sensor_unavailable( state = hass.states.get("sensor.google_for_developers_subscribers") assert state.state == "2290000" + state = hass.states.get("sensor.google_for_developers_views") + assert state.state == "214141263" + mock.set_thrown_exception(YouTubeBackendError()) future = dt_util.utcnow() + timedelta(minutes=15) async_fire_time_changed(hass, future) @@ -131,3 +143,6 @@ async def test_sensor_unavailable( state = hass.states.get("sensor.google_for_developers_subscribers") assert state.state == "unavailable" + + state = hass.states.get("sensor.google_for_developers_views") + assert state.state == "unavailable" From 8f26cff65af78c5bb78c064bb728279a893096ba Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Tue, 3 Sep 2024 13:19:30 -0400 Subject: [PATCH 0204/1309] Enable strict typing for the Squeezebox integration (#125161) * Strict typing for squeezebox * Improve unit tests * Refactor tests to use websockets and services.async_call * Apply suggestions from code review * Fix merge conflict --- .strict-typing | 1 + .../components/squeezebox/browse_media.py | 45 +++++++--- .../components/squeezebox/config_flow.py | 22 +++-- .../components/squeezebox/media_player.py | 88 +++++++++++-------- mypy.ini | 10 +++ 5 files changed, 106 insertions(+), 60 deletions(-) diff --git a/.strict-typing b/.strict-typing index 797a1b51293..1d73b05fdea 100644 --- a/.strict-typing +++ b/.strict-typing @@ -416,6 +416,7 @@ homeassistant.components.solarlog.* homeassistant.components.sonarr.* homeassistant.components.speedtestdotnet.* homeassistant.components.sql.* +homeassistant.components.squeezebox.* homeassistant.components.ssdp.* homeassistant.components.starlink.* homeassistant.components.statistics.* diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index f68624f8f06..61ae7b7a403 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -1,14 +1,21 @@ """Support for media browsing.""" +from __future__ import annotations + import contextlib +from typing import Any + +from pysqueezebox import Player from homeassistant.components import media_source from homeassistant.components.media_player import ( BrowseError, BrowseMedia, MediaClass, + MediaPlayerEntity, MediaType, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.network import is_internal_request LIBRARY = ["Favorites", "Artists", "Albums", "Tracks", "Playlists", "Genres"] @@ -36,7 +43,7 @@ SQUEEZEBOX_ID_BY_TYPE = { "Favorites": "item_id", } -CONTENT_TYPE_MEDIA_CLASS = { +CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = { "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK}, "Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST}, "Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM}, @@ -66,14 +73,18 @@ CONTENT_TYPE_TO_CHILD_TYPE = { BROWSE_LIMIT = 1000 -async def build_item_response(entity, player, payload): +async def build_item_response( + entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None] +) -> BrowseMedia: """Create response payload for search described by payload.""" internal_request = is_internal_request(entity.hass) search_id = payload["search_id"] search_type = payload["search_type"] - + assert ( + search_type is not None + ) # async_browse_media will not call this function if search_type is None media_class = CONTENT_TYPE_MEDIA_CLASS[search_type] children = None @@ -95,9 +106,9 @@ async def build_item_response(entity, player, payload): children = [] for item in result["items"]: item_id = str(item["id"]) - item_thumbnail = None + item_thumbnail: str | None = None if item_type: - child_item_type = item_type + child_item_type: MediaType | str = item_type child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type] can_expand = child_media_class["children"] is not None can_play = True @@ -120,7 +131,7 @@ async def build_item_response(entity, player, payload): can_expand = False can_play = True - if artwork_track_id := item.get("artwork_track_id"): + if artwork_track_id := item.get("artwork_track_id") and item_type: if internal_request: item_thumbnail = player.generate_image_url_from_track_id( artwork_track_id @@ -132,6 +143,7 @@ async def build_item_response(entity, player, payload): else: item_thumbnail = item.get("image_url") # will not be proxied by HA + assert child_media_class["item"] is not None children.append( BrowseMedia( title=item["title"], @@ -147,6 +159,9 @@ async def build_item_response(entity, player, payload): if children is None: raise BrowseError(f"Media not found: {search_type} / {search_id}") + assert media_class["item"] is not None + if not search_id: + search_id = search_type return BrowseMedia( title=result.get("title"), media_class=media_class["item"], @@ -159,9 +174,9 @@ async def build_item_response(entity, player, payload): ) -async def library_payload(hass, player): +async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia: """Create response payload to describe contents of library.""" - library_info = { + library_info: dict[str, Any] = { "title": "Music Library", "media_class": MediaClass.DIRECTORY, "media_content_id": "library", @@ -179,6 +194,7 @@ async def library_payload(hass, player): limit=1, ) if result is not None and result.get("items") is not None: + assert media_class["children"] is not None library_info["children"].append( BrowseMedia( title=item, @@ -191,14 +207,14 @@ async def library_payload(hass, player): ) with contextlib.suppress(media_source.BrowseError): - item = await media_source.async_browse_media( + browse = await media_source.async_browse_media( hass, None, content_filter=media_source_content_filter ) # If domain is None, it's overview of available sources - if item.domain is None: - library_info["children"].extend(item.children) + if browse.domain is None: + library_info["children"].extend(browse.children) else: - library_info["children"].append(item) + library_info["children"].append(browse) return BrowseMedia(**library_info) @@ -208,7 +224,7 @@ def media_source_content_filter(item: BrowseMedia) -> bool: return item.media_content_type.startswith("audio/") -async def generate_playlist(player, payload): +async def generate_playlist(player: Player, payload: dict[str, str]) -> list | None: """Generate playlist from browsing payload.""" media_type = payload["search_type"] media_id = payload["search_id"] @@ -221,5 +237,6 @@ async def generate_playlist(player, payload): "titles", limit=BROWSE_LIMIT, browse_id=browse_id ) if result and "items" in result: - return result["items"] + items: list = result["items"] + return items raise BrowseError(f"Media not found: {media_type} / {media_id}") diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index fe57b12516a..c372c7262d4 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Squeezebox integration.""" +from __future__ import annotations + import asyncio from http import HTTPStatus import logging @@ -24,9 +26,11 @@ _LOGGER = logging.getLogger(__name__) TIMEOUT = 5 -def _base_schema(discovery_info=None): +def _base_schema( + discovery_info: dict[str, Any] | None = None, +) -> vol.Schema: """Generate base schema.""" - base_schema = {} + base_schema: dict[Any, Any] = {} if discovery_info and CONF_HOST in discovery_info: base_schema.update( { @@ -71,14 +75,14 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize an instance of the squeezebox config flow.""" self.data_schema = _base_schema() - self.discovery_info = None + self.discovery_info: dict[str, Any] | None = None - async def _discover(self, uuid=None): + async def _discover(self, uuid: str | None = None) -> None: """Discover an unconfigured LMS server.""" self.discovery_info = None discovery_event = asyncio.Event() - def _discovery_callback(server): + def _discovery_callback(server: Server) -> None: if server.uuid: # ignore already configured uuids for entry in self._async_current_entries(): @@ -156,7 +160,9 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_edit(self, user_input=None): + async def async_step_edit( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Edit a discovered or manually inputted server.""" errors = {} if user_input: @@ -171,7 +177,9 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN): step_id="edit", data_schema=self.data_schema, errors=errors ) - async def async_step_integration_discovery(self, discovery_info): + async def async_step_integration_discovery( + self, discovery_info: dict[str, Any] + ) -> ConfigFlowResult: """Handle discovery of a server.""" _LOGGER.debug("Reached server discovery flow with info: %s", discovery_info) if "uuid" in discovery_info: diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 279e51485f0..5fa132533d1 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -8,12 +8,13 @@ import json import logging from typing import Any -from pysqueezebox import Player, async_discover +from pysqueezebox import Player, Server, async_discover import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, + BrowseMedia, MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, @@ -87,7 +88,7 @@ SQUEEZEBOX_MODE = { async def start_server_discovery(hass: HomeAssistant) -> None: """Start a server discovery task.""" - def _discovered_server(server): + def _discovered_server(server: Server) -> None: discovery_flow.async_create_flow( hass, DOMAIN, @@ -118,10 +119,10 @@ async def async_setup_entry( known_players = hass.data[DOMAIN].setdefault(KNOWN_PLAYERS, []) lms = entry.runtime_data - async def _player_discovery(now=None): + async def _player_discovery(now: datetime | None = None) -> None: """Discover squeezebox players by polling server.""" - async def _discovered_player(player): + async def _discovered_player(player: Player) -> None: """Handle a (re)discovered player.""" entity = next( ( @@ -234,7 +235,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device-specific attributes.""" return { attr: getattr(self, attr) @@ -243,12 +244,13 @@ class SqueezeBoxEntity(MediaPlayerEntity): } @callback - def rediscovered(self, unique_id, connected): + def rediscovered(self, unique_id: str, connected: bool) -> None: """Make a player available again.""" if unique_id == self.unique_id and connected: self._attr_available = True _LOGGER.debug("Player %s is available again", self.name) - self._remove_dispatcher() + if self._remove_dispatcher: + self._remove_dispatcher() @property def state(self) -> MediaPlayerState | None: @@ -288,22 +290,22 @@ class SqueezeBoxEntity(MediaPlayerEntity): return None @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool: """Return true if volume is muted.""" - return self._player.muting + return bool(self._player.muting) @property - def media_content_id(self): + def media_content_id(self) -> str | None: """Content ID of current playing media.""" if not self._player.playlist: return None if len(self._player.playlist) > 1: urls = [{"url": track["url"]} for track in self._player.playlist] return json.dumps({"index": self._player.current_index, "urls": urls}) - return self._player.url + return str(self._player.url) @property - def media_content_type(self): + def media_content_type(self) -> MediaType | None: """Content type of current playing media.""" if not self._player.playlist: return None @@ -312,47 +314,47 @@ class SqueezeBoxEntity(MediaPlayerEntity): return MediaType.MUSIC @property - def media_duration(self): + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" - return self._player.duration + return int(self._player.duration) @property - def media_position(self): + def media_position(self) -> int | None: """Position of current playing media in seconds.""" - return self._player.time + return int(self._player.time) @property - def media_position_updated_at(self): + def media_position_updated_at(self) -> datetime | None: """Last time status was updated.""" return self._last_update @property - def media_image_url(self): + def media_image_url(self) -> str | None: """Image url of current playing media.""" - return self._player.image_url + return str(self._player.image_url) @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" - return self._player.title + return str(self._player.title) @property - def media_channel(self): + def media_channel(self) -> str | None: """Channel (e.g. webradio name) of current playing media.""" - return self._player.remote_title + return str(self._player.remote_title) @property - def media_artist(self): + def media_artist(self) -> str | None: """Artist of current playing media.""" - return self._player.artist + return str(self._player.artist) @property - def media_album_name(self): + def media_album_name(self) -> str | None: """Album of current playing media.""" - return self._player.album + return str(self._player.album) @property - def repeat(self): + def repeat(self) -> RepeatMode: """Repeat setting.""" if self._player.repeat == "song": return RepeatMode.ONE @@ -361,13 +363,13 @@ class SqueezeBoxEntity(MediaPlayerEntity): return RepeatMode.OFF @property - def shuffle(self): + def shuffle(self) -> bool: """Boolean if shuffle is enabled.""" # Squeezebox has a third shuffle mode (album) not recognized by Home Assistant - return self._player.shuffle == "song" + return bool(self._player.shuffle == "song") @property - def group_members(self): + def group_members(self) -> list[str]: """List players we are synced with.""" player_ids = { p.unique_id: p.entity_id for p in self.hass.data[DOMAIN][KNOWN_PLAYERS] @@ -379,12 +381,12 @@ class SqueezeBoxEntity(MediaPlayerEntity): ] @property - def sync_group(self): + def sync_group(self) -> list[str]: """List players we are synced with. Deprecated.""" return self.group_members @property - def query_result(self): + def query_result(self) -> dict | bool: """Return the result from the call_query service.""" return self._query_result @@ -477,7 +479,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): try: # a saved playlist by number payload = { - "search_id": int(media_id), + "search_id": media_id, "search_type": MediaType.PLAYLIST, } playlist = await generate_playlist(self._player, payload) @@ -519,7 +521,9 @@ class SqueezeBoxEntity(MediaPlayerEntity): """Send the media player the command for clear playlist.""" await self._player.async_clear_playlist() - async def async_call_method(self, command, parameters=None): + async def async_call_method( + self, command: str, parameters: list[str] | None = None + ) -> None: """Call Squeezebox JSON/RPC method. Additional parameters are added to the command to form the list of @@ -530,7 +534,9 @@ class SqueezeBoxEntity(MediaPlayerEntity): all_params.extend(parameters) await self._player.async_query(*all_params) - async def async_call_query(self, command, parameters=None): + async def async_call_query( + self, command: str, parameters: list[str] | None = None + ) -> None: """Call Squeezebox JSON/RPC method where we care about the result. Additional parameters are added to the command to form the list of @@ -560,7 +566,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): "Could not find player_id for %s. Not syncing", other_player ) - async def async_sync(self, other_player): + async def async_sync(self, other_player: str) -> None: """Sync this Squeezebox player to another. Deprecated.""" _LOGGER.warning( "Service squeezebox.sync is deprecated; use media_player.join_players" @@ -572,7 +578,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): """Unsync this Squeezebox player.""" await self._player.async_unsync() - async def async_unsync(self): + async def async_unsync(self) -> None: """Unsync this Squeezebox player. Deprecated.""" _LOGGER.warning( "Service squeezebox.unsync is deprecated; use media_player.unjoin_player" @@ -580,7 +586,11 @@ class SqueezeBoxEntity(MediaPlayerEntity): ) await self.async_unjoin_player() - async def async_browse_media(self, media_content_type=None, media_content_id=None): + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: """Implement the websocket media browsing helper.""" _LOGGER.debug( "Reached async_browse_media with content_type %s and content_id %s", diff --git a/mypy.ini b/mypy.ini index c29db45cd53..4ba1f41f4d4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3916,6 +3916,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.squeezebox.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ssdp.*] check_untyped_defs = true disallow_incomplete_defs = true From 8e03f3a04525b40c9d0af52343e578ce19a5a43d Mon Sep 17 00:00:00 2001 From: mvn23 Date: Tue, 3 Sep 2024 19:19:43 +0200 Subject: [PATCH 0205/1309] Update opentherm_gw tests to avoid patching internals (#125152) * Update tests to avoid patching internals * * Use fixtures for tests * Update variable names in tests for clarity * Use hass.config_entries.async_setup instead of setup.async_setup_component --- tests/components/opentherm_gw/conftest.py | 41 ++++ .../opentherm_gw/test_config_flow.py | 223 +++++++----------- tests/components/opentherm_gw/test_init.py | 88 +++---- 3 files changed, 157 insertions(+), 195 deletions(-) create mode 100644 tests/components/opentherm_gw/conftest.py diff --git a/tests/components/opentherm_gw/conftest.py b/tests/components/opentherm_gw/conftest.py new file mode 100644 index 00000000000..057f47a169d --- /dev/null +++ b/tests/components/opentherm_gw/conftest.py @@ -0,0 +1,41 @@ +"""Test configuration for opentherm_gw.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from pyotgw.vars import OTGW, OTGW_ABOUT +import pytest + +VERSION_TEST = "4.2.5" +MINIMAL_STATUS = {OTGW: {OTGW_ABOUT: f"OpenTherm Gateway {VERSION_TEST}"}} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.opentherm_gw.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_pyotgw() -> Generator[MagicMock]: + """Mock a pyotgw.OpenThermGateway object.""" + with ( + patch( + "homeassistant.components.opentherm_gw.OpenThermGateway", + return_value=MagicMock( + connect=AsyncMock(return_value=MINIMAL_STATUS), + set_control_setpoint=AsyncMock(), + set_max_relative_mod=AsyncMock(), + disconnect=AsyncMock(), + ), + ) as mock_gateway, + patch( + "homeassistant.components.opentherm_gw.config_flow.pyotgw.OpenThermGateway", + new=mock_gateway, + ), + ): + yield mock_gateway diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 504a97dc953..4f4a6cfce31 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -1,8 +1,7 @@ """Test the Opentherm Gateway config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock -from pyotgw.vars import OTGW, OTGW_ABOUT from serial import SerialException from homeassistant import config_entries @@ -25,10 +24,12 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -MINIMAL_STATUS = {OTGW: {OTGW_ABOUT: "OpenTherm Gateway 4.2.5"}} - -async def test_form_user(hass: HomeAssistant) -> None: +async def test_form_user( + hass: HomeAssistant, + mock_pyotgw: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -37,27 +38,10 @@ async def test_form_user(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.opentherm_gw.async_setup", - return_value=True, - ) as mock_setup, - patch( - "homeassistant.components.opentherm_gw.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "pyotgw.OpenThermGateway.connect", return_value=MINIMAL_STATUS - ) as mock_pyotgw_connect, - patch( - "pyotgw.OpenThermGateway.disconnect", return_value=None - ) as mock_pyotgw_disconnect, - patch("pyotgw.status.StatusManager._process_updates", return_value=None), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"} - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"} + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Test Entry 1" @@ -66,37 +50,21 @@ async def test_form_user(hass: HomeAssistant) -> None: CONF_DEVICE: "/dev/ttyUSB0", CONF_ID: "test_entry_1", } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pyotgw_connect.mock_calls) == 1 - assert len(mock_pyotgw_disconnect.mock_calls) == 1 + assert mock_pyotgw.return_value.connect.await_count == 1 + assert mock_pyotgw.return_value.disconnect.await_count == 1 -async def test_form_import(hass: HomeAssistant) -> None: +async def test_form_import( + hass: HomeAssistant, + mock_pyotgw: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: """Test import from existing config.""" - - with ( - patch( - "homeassistant.components.opentherm_gw.async_setup", - return_value=True, - ) as mock_setup, - patch( - "homeassistant.components.opentherm_gw.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "pyotgw.OpenThermGateway.connect", return_value=MINIMAL_STATUS - ) as mock_pyotgw_connect, - patch( - "pyotgw.OpenThermGateway.disconnect", return_value=None - ) as mock_pyotgw_disconnect, - patch("pyotgw.status.StatusManager._process_updates", return_value=None), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_ID: "legacy_gateway", CONF_DEVICE: "/dev/ttyUSB1"}, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_ID: "legacy_gateway", CONF_DEVICE: "/dev/ttyUSB1"}, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "legacy_gateway" @@ -105,13 +73,15 @@ async def test_form_import(hass: HomeAssistant) -> None: CONF_DEVICE: "/dev/ttyUSB1", CONF_ID: "legacy_gateway", } - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pyotgw_connect.mock_calls) == 1 - assert len(mock_pyotgw_disconnect.mock_calls) == 1 + assert mock_pyotgw.return_value.connect.await_count == 1 + assert mock_pyotgw.return_value.disconnect.await_count == 1 -async def test_form_duplicate_entries(hass: HomeAssistant) -> None: +async def test_form_duplicate_entries( + hass: HomeAssistant, + mock_pyotgw: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: """Test duplicate device or id errors.""" flow1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -123,87 +93,76 @@ async def test_form_duplicate_entries(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.opentherm_gw.async_setup", - return_value=True, - ) as mock_setup, - patch( - "homeassistant.components.opentherm_gw.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "pyotgw.OpenThermGateway.connect", return_value=MINIMAL_STATUS - ) as mock_pyotgw_connect, - patch( - "pyotgw.OpenThermGateway.disconnect", return_value=None - ) as mock_pyotgw_disconnect, - patch("pyotgw.status.StatusManager._process_updates", return_value=None), - ): - result1 = await hass.config_entries.flow.async_configure( - flow1["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"} - ) - result2 = await hass.config_entries.flow.async_configure( - flow2["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB1"} - ) - result3 = await hass.config_entries.flow.async_configure( - flow3["flow_id"], {CONF_NAME: "Test Entry 2", CONF_DEVICE: "/dev/ttyUSB0"} - ) + result1 = await hass.config_entries.flow.async_configure( + flow1["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"} + ) assert result1["type"] is FlowResultType.CREATE_ENTRY + + result2 = await hass.config_entries.flow.async_configure( + flow2["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB1"} + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "id_exists"} + + result3 = await hass.config_entries.flow.async_configure( + flow3["flow_id"], {CONF_NAME: "Test Entry 2", CONF_DEVICE: "/dev/ttyUSB0"} + ) assert result3["type"] is FlowResultType.FORM assert result3["errors"] == {"base": "already_configured"} - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - assert len(mock_pyotgw_connect.mock_calls) == 1 - assert len(mock_pyotgw_disconnect.mock_calls) == 1 + + assert mock_pyotgw.return_value.connect.await_count == 1 + assert mock_pyotgw.return_value.disconnect.await_count == 1 -async def test_form_connection_timeout(hass: HomeAssistant) -> None: +async def test_form_connection_timeout( + hass: HomeAssistant, + mock_pyotgw: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: """Test we handle connection timeout.""" - result = await hass.config_entries.flow.async_init( + flow = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "pyotgw.OpenThermGateway.connect", side_effect=(TimeoutError) - ) as mock_connect, - patch("pyotgw.status.StatusManager._process_updates", return_value=None), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_NAME: "Test Entry 1", CONF_DEVICE: "socket://192.0.2.254:1234"}, - ) + mock_pyotgw.return_value.connect.side_effect = TimeoutError - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "timeout_connect"} - assert len(mock_connect.mock_calls) == 1 + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + {CONF_NAME: "Test Entry 1", CONF_DEVICE: "socket://192.0.2.254:1234"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "timeout_connect"} + + assert mock_pyotgw.return_value.connect.await_count == 1 -async def test_form_connection_error(hass: HomeAssistant) -> None: +async def test_form_connection_error( + hass: HomeAssistant, + mock_pyotgw: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: """Test we handle serial connection error.""" - result = await hass.config_entries.flow.async_init( + flow = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "pyotgw.OpenThermGateway.connect", side_effect=(SerialException) - ) as mock_connect, - patch("pyotgw.status.StatusManager._process_updates", return_value=None), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"} - ) + mock_pyotgw.return_value.connect.side_effect = SerialException - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - assert len(mock_connect.mock_calls) == 1 + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], {CONF_NAME: "Test Entry 1", CONF_DEVICE: "/dev/ttyUSB0"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + assert mock_pyotgw.return_value.connect.await_count == 1 -async def test_options_form(hass: HomeAssistant) -> None: +async def test_options_form( + hass: HomeAssistant, + mock_pyotgw: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: """Test the options form.""" entry = MockConfigEntry( domain=DOMAIN, @@ -217,23 +176,17 @@ async def test_options_form(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) - with ( - patch("homeassistant.components.opentherm_gw.async_setup", return_value=True), - patch( - "homeassistant.components.opentherm_gw.async_setup_entry", return_value=True - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_init( + flow = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "init" + assert flow["type"] is FlowResultType.FORM + assert flow["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], + flow["flow_id"], user_input={ CONF_FLOOR_TEMP: True, CONF_READ_PRECISION: PRECISION_HALVES, @@ -248,12 +201,12 @@ async def test_options_form(hass: HomeAssistant) -> None: assert result["data"][CONF_TEMPORARY_OVRD_MODE] is True assert result["data"][CONF_FLOOR_TEMP] is True - result = await hass.config_entries.options.async_init( + flow = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None ) result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_READ_PRECISION: 0} + flow["flow_id"], user_input={CONF_READ_PRECISION: 0} ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -262,12 +215,12 @@ async def test_options_form(hass: HomeAssistant) -> None: assert result["data"][CONF_TEMPORARY_OVRD_MODE] is True assert result["data"][CONF_FLOOR_TEMP] is True - result = await hass.config_entries.options.async_init( + flow = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None ) result = await hass.config_entries.options.async_configure( - result["flow_id"], + flow["flow_id"], user_input={ CONF_FLOOR_TEMP: False, CONF_READ_PRECISION: PRECISION_TENTHS, diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index ed829cb1986..2116967d720 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -1,11 +1,9 @@ """Test Opentherm Gateway init.""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock from pyotgw.vars import OTGW, OTGW_ABOUT -import pytest -from homeassistant import setup from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, @@ -14,11 +12,11 @@ from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from .conftest import VERSION_TEST + from tests.common import MockConfigEntry -VERSION_OLD = "4.2.5" VERSION_NEW = "4.2.8.1" -MINIMAL_STATUS = {OTGW: {OTGW_ABOUT: f"OpenTherm Gateway {VERSION_OLD}"}} MINIMAL_STATUS_UPD = {OTGW: {OTGW_ABOUT: f"OpenTherm Gateway {VERSION_NEW}"}} MOCK_GATEWAY_ID = "mock_gateway" MOCK_CONFIG_ENTRY = MockConfigEntry( @@ -33,35 +31,28 @@ MOCK_CONFIG_ENTRY = MockConfigEntry( ) -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_insert( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_pyotgw: MagicMock, ) -> None: """Test that the device registry is initialized correctly.""" MOCK_CONFIG_ENTRY.add_to_hass(hass) - with ( - patch( - "homeassistant.components.opentherm_gw.OpenThermGatewayHub.cleanup", - return_value=None, - ), - patch("pyotgw.OpenThermGateway.connect", return_value=MINIMAL_STATUS), - ): - await setup.async_setup_component(hass, DOMAIN, {}) - + await hass.config_entries.async_setup(MOCK_CONFIG_ENTRY.entry_id) await hass.async_block_till_done() gw_dev = device_registry.async_get_device( identifiers={(DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}")} ) - assert gw_dev.sw_version == VERSION_OLD + assert gw_dev is not None + assert gw_dev.sw_version == VERSION_TEST -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_device_registry_update( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_pyotgw: MagicMock, ) -> None: """Test that the device registry is updated correctly.""" MOCK_CONFIG_ENTRY.add_to_hass(hass) @@ -74,19 +65,14 @@ async def test_device_registry_update( name="Mock Gateway", manufacturer="Schelte Bron", model="OpenTherm Gateway", - sw_version=VERSION_OLD, + sw_version=VERSION_TEST, ) - with ( - patch( - "homeassistant.components.opentherm_gw.OpenThermGatewayHub.cleanup", - return_value=None, - ), - patch("pyotgw.OpenThermGateway.connect", return_value=MINIMAL_STATUS_UPD), - ): - await setup.async_setup_component(hass, DOMAIN, {}) + mock_pyotgw.return_value.connect.return_value = MINIMAL_STATUS_UPD + await hass.config_entries.async_setup(MOCK_CONFIG_ENTRY.entry_id) await hass.async_block_till_done() + gw_dev = device_registry.async_get_device( identifiers={(DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}")} ) @@ -96,7 +82,9 @@ async def test_device_registry_update( # Device migration test can be removed in 2025.4.0 async def test_device_migration( - hass: HomeAssistant, device_registry: dr.DeviceRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_pyotgw: MagicMock, ) -> None: """Test that the device registry is updated correctly.""" MOCK_CONFIG_ENTRY.add_to_hass(hass) @@ -109,22 +97,10 @@ async def test_device_migration( name="Mock Gateway", manufacturer="Schelte Bron", model="OpenTherm Gateway", - sw_version=VERSION_OLD, + sw_version=VERSION_TEST, ) - with ( - patch( - "homeassistant.components.opentherm_gw.OpenThermGateway", - return_value=MagicMock( - connect=AsyncMock(return_value=MINIMAL_STATUS_UPD), - set_control_setpoint=AsyncMock(), - set_max_relative_mod=AsyncMock(), - disconnect=AsyncMock(), - ), - ), - ): - await setup.async_setup_component(hass, DOMAIN, {}) - + await hass.config_entries.async_setup(MOCK_CONFIG_ENTRY.entry_id) await hass.async_block_till_done() assert ( @@ -158,7 +134,9 @@ async def test_device_migration( # Entity migration test can be removed in 2025.4.0 async def test_climate_entity_migration( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_pyotgw: MagicMock, ) -> None: """Test that the climate entity unique_id gets migrated correctly.""" MOCK_CONFIG_ENTRY.add_to_hass(hass) @@ -168,22 +146,12 @@ async def test_climate_entity_migration( unique_id=MOCK_CONFIG_ENTRY.data[CONF_ID], ) - with ( - patch( - "homeassistant.components.opentherm_gw.OpenThermGateway", - return_value=MagicMock( - connect=AsyncMock(return_value=MINIMAL_STATUS_UPD), - set_control_setpoint=AsyncMock(), - set_max_relative_mod=AsyncMock(), - disconnect=AsyncMock(), - ), - ), - ): - await setup.async_setup_component(hass, DOMAIN, {}) - + await hass.config_entries.async_setup(MOCK_CONFIG_ENTRY.entry_id) await hass.async_block_till_done() + updated_entry = entity_registry.async_get(entry.entity_id) + assert updated_entry is not None assert ( - entity_registry.async_get(entry.entity_id).unique_id + updated_entry.unique_id == f"{MOCK_CONFIG_ENTRY.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" ) From 7b35c3036e0b2e918f176473d40908ac7071ebf3 Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Tue, 3 Sep 2024 19:47:00 +0200 Subject: [PATCH 0206/1309] Enhance error handling when changing a timer's duration (#121786) * Update remaining before checking duration * fix comment * calculation based on transient field * lint * remove useless brackets --- homeassistant/components/timer/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index c2057551239..19b1de427ef 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -338,7 +338,9 @@ class Timer(collection.CollectionEntity, RestoreEntity): raise HomeAssistantError( f"Timer {self.entity_id} is not running, only active timers can be changed" ) - if self._remaining and (self._remaining + duration) > self._running_duration: + # Check against new remaining time before checking boundaries + new_remaining = (self._end + duration) - dt_util.utcnow().replace(microsecond=0) + if self._remaining and new_remaining > self._running_duration: raise HomeAssistantError( f"Not possible to change timer {self.entity_id} beyond duration" ) @@ -349,7 +351,7 @@ class Timer(collection.CollectionEntity, RestoreEntity): self._listener() self._end += duration - self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) + self._remaining = new_remaining self.async_write_ha_state() self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id}) self._listener = async_track_point_in_utc_time( From 3137c27e5604b99ea4c548b41c85ccb18668320a Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Tue, 3 Sep 2024 13:50:44 -0400 Subject: [PATCH 0207/1309] Fix type errors in squeezebox (#125166) --- homeassistant/components/squeezebox/media_player.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 5fa132533d1..8607e72a67c 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -314,14 +314,14 @@ class SqueezeBoxEntity(MediaPlayerEntity): return MediaType.MUSIC @property - def media_duration(self) -> int | None: + def media_duration(self) -> int: """Duration of current playing media in seconds.""" - return int(self._player.duration) + return int(self._player.duration) if self._player.duration else 0 @property - def media_position(self) -> int | None: + def media_position(self) -> int: """Position of current playing media in seconds.""" - return int(self._player.time) + return int(self._player.time) if self._player.time else 0 @property def media_position_updated_at(self) -> datetime | None: From 61a722218a019f4a1ac2879285a0ca0beecc2a01 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 3 Sep 2024 19:52:38 +0200 Subject: [PATCH 0208/1309] Update frontend to 20240903.1 (#125160) --- 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 50bcb3b3d97..7b904cba999 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==20240902.0"] + "requirements": ["home-assistant-frontend==20240903.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1729e6e8131..ddb96da6bff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20240902.0 +home-assistant-frontend==20240903.1 home-assistant-intents==2024.8.29 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 39b464c0561..7d72f29b74b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1108,7 +1108,7 @@ hole==0.8.0 holidays==0.55 # homeassistant.components.frontend -home-assistant-frontend==20240902.0 +home-assistant-frontend==20240903.1 # homeassistant.components.conversation home-assistant-intents==2024.8.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fc15b0f78f..b75dfa99638 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -934,7 +934,7 @@ hole==0.8.0 holidays==0.55 # homeassistant.components.frontend -home-assistant-frontend==20240902.0 +home-assistant-frontend==20240903.1 # homeassistant.components.conversation home-assistant-intents==2024.8.29 From 27032c1780f53d4fe8f1071b6d6bf9a2fb5df566 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Sep 2024 07:53:10 -1000 Subject: [PATCH 0209/1309] Bump yalexs to 8.6.2 (#125162) changelog: https://github.com/bdraco/yalexs/compare/v8.6.0...v8.6.2 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index a40c6920136..42f97e56fd2 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.6.0", "yalexs-ble==2.4.3"] + "requirements": ["yalexs==8.6.2", "yalexs-ble==2.4.3"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 030df50a482..0942dcb5dcb 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.6.0", "yalexs-ble==2.4.3"] + "requirements": ["yalexs==8.6.2", "yalexs-ble==2.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7d72f29b74b..dd94c6838d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2991,7 +2991,7 @@ yalexs-ble==2.4.3 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.6.0 +yalexs==8.6.2 # homeassistant.components.yeelight yeelight==0.7.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b75dfa99638..001c9390755 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2374,7 +2374,7 @@ yalexs-ble==2.4.3 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.6.0 +yalexs==8.6.2 # homeassistant.components.yeelight yeelight==0.7.14 From be8f14167fc6e7e06974ba10a1dd532d31a489fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hans=20Kr=C3=B6ner?= Date: Tue, 3 Sep 2024 21:00:44 +0200 Subject: [PATCH 0210/1309] Expose UV Index in Met.no (#124992) UV Index now also appears in forecasts. --- homeassistant/components/met/const.py | 4 +++ homeassistant/components/met/weather.py | 8 ++++++ tests/components/met/conftest.py | 3 ++- tests/components/met/test_weather.py | 33 ++++++++++++++++++++++++- 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index c513e98504e..ccc0662b3c3 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -21,12 +21,14 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, + ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_WIND_BEARING, ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, @@ -190,6 +192,7 @@ FORECAST_MAP = { ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: "wind_gust", ATTR_FORECAST_CLOUD_COVERAGE: "cloudiness", ATTR_FORECAST_HUMIDITY: "humidity", + ATTR_FORECAST_UV_INDEX: "uv_index", } ATTR_MAP = { @@ -202,4 +205,5 @@ ATTR_MAP = { ATTR_WEATHER_WIND_GUST_SPEED: "wind_gust", ATTR_WEATHER_CLOUD_COVERAGE: "cloudiness", ATTR_WEATHER_DEW_POINT: "dew_point", + ATTR_WEATHER_UV_INDEX: "uv_index", } diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 809bb792b2c..7b95567366b 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -13,6 +13,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, @@ -208,6 +209,13 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): ATTR_MAP[ATTR_WEATHER_DEW_POINT] ) + @property + def uv_index(self) -> float | None: + """Return the uv index.""" + return self.coordinator.data.current_weather_data.get( + ATTR_MAP[ATTR_WEATHER_UV_INDEX] + ) + def _forecast(self, hourly: bool) -> list[Forecast] | None: """Return the forecast array.""" if hourly: diff --git a/tests/components/met/conftest.py b/tests/components/met/conftest.py index 699c1c81795..92b81d3d320 100644 --- a/tests/components/met/conftest.py +++ b/tests/components/met/conftest.py @@ -17,8 +17,9 @@ def mock_weather(): "pressure": 100, "humidity": 50, "wind_speed": 10, - "wind_bearing": "NE", + "wind_bearing": 90, "dew_point": 12.1, + "uv_index": 1.1, } mock_data.get_forecast.return_value = {} yield mock_data diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py index 80820ef0186..ac3904684e3 100644 --- a/tests/components/met/test_weather.py +++ b/tests/components/met/test_weather.py @@ -2,10 +2,22 @@ from homeassistant import config_entries from homeassistant.components.met import DOMAIN -from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import init_integration + async def test_new_config_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_weather @@ -36,6 +48,25 @@ async def test_legacy_config_entry( assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 1 +async def test_weather(hass: HomeAssistant, mock_weather) -> None: + """Test states of the weather.""" + + await init_integration(hass) + assert len(hass.states.async_entity_ids("weather")) == 1 + entity_id = hass.states.async_entity_ids("weather")[0] + + state = hass.states.get(entity_id) + assert state + assert state.state == ATTR_CONDITION_CLOUDY + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 15 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 100 + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 50 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 10 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 90 + assert state.attributes[ATTR_WEATHER_DEW_POINT] == 12.1 + assert state.attributes[ATTR_WEATHER_UV_INDEX] == 1.1 + + async def test_tracking_home(hass: HomeAssistant, mock_weather) -> None: """Test we track home.""" await hass.config_entries.flow.async_init("met", context={"source": "onboarding"}) From 14482ff6da4de11403aa1cd01dc2931521949d7f Mon Sep 17 00:00:00 2001 From: mvn23 Date: Tue, 3 Sep 2024 21:18:38 +0200 Subject: [PATCH 0211/1309] Update opentherm_gw tests to prepare for new platforms (#125172) Move MockConfigEntry to a fixture --- tests/components/opentherm_gw/conftest.py | 21 +++++++++++ tests/components/opentherm_gw/test_init.py | 43 +++++++++------------- 2 files changed, 39 insertions(+), 25 deletions(-) diff --git a/tests/components/opentherm_gw/conftest.py b/tests/components/opentherm_gw/conftest.py index 057f47a169d..9c90c74b04b 100644 --- a/tests/components/opentherm_gw/conftest.py +++ b/tests/components/opentherm_gw/conftest.py @@ -6,8 +6,14 @@ from unittest.mock import AsyncMock, MagicMock, patch from pyotgw.vars import OTGW, OTGW_ABOUT import pytest +from homeassistant.components.opentherm_gw import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_NAME + +from tests.common import MockConfigEntry + VERSION_TEST = "4.2.5" MINIMAL_STATUS = {OTGW: {OTGW_ABOUT: f"OpenTherm Gateway {VERSION_TEST}"}} +MOCK_GATEWAY_ID = "mock_gateway" @pytest.fixture @@ -39,3 +45,18 @@ def mock_pyotgw() -> Generator[MagicMock]: ), ): yield mock_gateway + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock an OpenTherm Gateway config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Mock Gateway", + data={ + CONF_NAME: "Mock Gateway", + CONF_DEVICE: "/dev/null", + CONF_ID: MOCK_GATEWAY_ID, + }, + options={}, + ) diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 2116967d720..4085e25c614 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -8,38 +8,28 @@ from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, ) -from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_NAME +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import VERSION_TEST +from .conftest import MOCK_GATEWAY_ID, VERSION_TEST from tests.common import MockConfigEntry VERSION_NEW = "4.2.8.1" MINIMAL_STATUS_UPD = {OTGW: {OTGW_ABOUT: f"OpenTherm Gateway {VERSION_NEW}"}} -MOCK_GATEWAY_ID = "mock_gateway" -MOCK_CONFIG_ENTRY = MockConfigEntry( - domain=DOMAIN, - title="Mock Gateway", - data={ - CONF_NAME: "Mock Gateway", - CONF_DEVICE: "/dev/null", - CONF_ID: MOCK_GATEWAY_ID, - }, - options={}, -) async def test_device_registry_insert( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, mock_pyotgw: MagicMock, ) -> None: """Test that the device registry is initialized correctly.""" - MOCK_CONFIG_ENTRY.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(MOCK_CONFIG_ENTRY.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() gw_dev = device_registry.async_get_device( @@ -52,13 +42,14 @@ async def test_device_registry_insert( async def test_device_registry_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, mock_pyotgw: MagicMock, ) -> None: """Test that the device registry is updated correctly.""" - MOCK_CONFIG_ENTRY.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) device_registry.async_get_or_create( - config_entry_id=MOCK_CONFIG_ENTRY.entry_id, + config_entry_id=mock_config_entry.entry_id, identifiers={ (DOMAIN, f"{MOCK_GATEWAY_ID}-{OpenThermDeviceIdentifier.GATEWAY}") }, @@ -70,7 +61,7 @@ async def test_device_registry_update( mock_pyotgw.return_value.connect.return_value = MINIMAL_STATUS_UPD - await hass.config_entries.async_setup(MOCK_CONFIG_ENTRY.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() gw_dev = device_registry.async_get_device( @@ -84,13 +75,14 @@ async def test_device_registry_update( async def test_device_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, mock_pyotgw: MagicMock, ) -> None: """Test that the device registry is updated correctly.""" - MOCK_CONFIG_ENTRY.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) device_registry.async_get_or_create( - config_entry_id=MOCK_CONFIG_ENTRY.entry_id, + config_entry_id=mock_config_entry.entry_id, identifiers={ (DOMAIN, MOCK_GATEWAY_ID), }, @@ -100,7 +92,7 @@ async def test_device_migration( sw_version=VERSION_TEST, ) - await hass.config_entries.async_setup(MOCK_CONFIG_ENTRY.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert ( @@ -136,22 +128,23 @@ async def test_device_migration( async def test_climate_entity_migration( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, mock_pyotgw: MagicMock, ) -> None: """Test that the climate entity unique_id gets migrated correctly.""" - MOCK_CONFIG_ENTRY.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) entry = entity_registry.async_get_or_create( domain="climate", platform="opentherm_gw", - unique_id=MOCK_CONFIG_ENTRY.data[CONF_ID], + unique_id=mock_config_entry.data[CONF_ID], ) - await hass.config_entries.async_setup(MOCK_CONFIG_ENTRY.entry_id) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() updated_entry = entity_registry.async_get(entry.entity_id) assert updated_entry is not None assert ( updated_entry.unique_id - == f"{MOCK_CONFIG_ENTRY.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" + == f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" ) From e4f9f6447f1a8048be1dafd3822b1f7819eedbaa Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 3 Sep 2024 21:45:43 +0200 Subject: [PATCH 0212/1309] Update gardena_bluetooth dependency to 1.4.3 (#125175) --- homeassistant/components/gardena_bluetooth/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 4812def7dde..6d7566b3edf 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", "loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"], - "requirements": ["gardena-bluetooth==1.4.2"] + "requirements": ["gardena-bluetooth==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd94c6838d9..29375b32d07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -930,7 +930,7 @@ fyta_cli==0.6.6 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.2 +gardena-bluetooth==1.4.3 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 001c9390755..a9e4e868a9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,7 +783,7 @@ fyta_cli==0.6.6 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.2 +gardena-bluetooth==1.4.3 # homeassistant.components.google_assistant_sdk gassist-text==0.0.11 From 3a8039cbc06b3dd93b06676d63ecf70eaa934889 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Sep 2024 10:18:19 -1000 Subject: [PATCH 0213/1309] Bump yalexs to 8.6.3 (#125176) Fixes the battery state not refreshing due to a refactoring error in the library. changelog: https://github.com/bdraco/yalexs/compare/v8.6.2...v8.6.3 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 42f97e56fd2..6635a95f1cf 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.6.2", "yalexs-ble==2.4.3"] + "requirements": ["yalexs==8.6.3", "yalexs-ble==2.4.3"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 0942dcb5dcb..fc93d259891 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.6.2", "yalexs-ble==2.4.3"] + "requirements": ["yalexs==8.6.3", "yalexs-ble==2.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 29375b32d07..6ec38c80689 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2991,7 +2991,7 @@ yalexs-ble==2.4.3 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.6.2 +yalexs==8.6.3 # homeassistant.components.yeelight yeelight==0.7.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9e4e868a9b..00802565a35 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2374,7 +2374,7 @@ yalexs-ble==2.4.3 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.6.2 +yalexs==8.6.3 # homeassistant.components.yeelight yeelight==0.7.14 From 4aa86a574f7b066ded80664b3caf12de26f79ae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 3 Sep 2024 22:23:26 +0200 Subject: [PATCH 0214/1309] Add include-hidden-files to upload env_file artifact (#125179) --- .github/workflows/wheels.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 98585a97c6b..04e4391790a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -86,6 +86,7 @@ jobs: with: name: env_file path: ./.env_file + include-hidden-files: true overwrite: true - name: Upload requirements_diff From cc3d059783551852fa799a5f47a3db103e389dba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Sep 2024 22:37:50 +0200 Subject: [PATCH 0215/1309] Refactor recorder EventIDPostMigration data migrator (#125126) --- .../components/recorder/migration.py | 89 +++++++++---------- .../components/recorder/test_v32_migration.py | 38 +++----- 2 files changed, 53 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 213462e3731..890fc3045b2 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2137,50 +2137,6 @@ def post_migrate_entity_ids(instance: Recorder) -> bool: return is_done -@retryable_database_job("cleanup_legacy_event_ids") -def cleanup_legacy_states_event_ids(instance: Recorder) -> bool: - """Remove old event_id index from states. - - We used to link states to events using the event_id column but we no - longer store state changed events in the events table. - - If all old states have been purged and existing states are in the new - format we can drop the index since it can take up ~10MB per 1M rows. - """ - session_maker = instance.get_session - _LOGGER.debug("Cleanup legacy entity_ids") - with session_scope(session=session_maker()) as session: - result = session.execute(has_used_states_event_ids()).scalar() - # In the future we may migrate existing states to the new format - # but in practice very few of these still exist in production and - # removing the index is the likely all that needs to happen. - all_gone = not result - - if all_gone: - # Only drop the index if there are no more event_ids in the states table - # ex all NULL - assert instance.engine is not None, "engine should never be None" - if instance.dialect_name == SupportedDialect.SQLITE: - # SQLite does not support dropping foreign key constraints - # so we have to rebuild the table - fk_remove_ok = rebuild_sqlite_table(session_maker, instance.engine, States) - else: - try: - _drop_foreign_key_constraints( - session_maker, instance.engine, TABLE_STATES, "event_id" - ) - except (InternalError, OperationalError): - fk_remove_ok = False - else: - fk_remove_ok = True - if fk_remove_ok: - _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) - instance.use_legacy_events_index = False - _mark_migration_done(session, EventIDPostMigration) - - return True - - def _initialize_database(session: Session) -> bool: """Initialize a new database. @@ -2635,9 +2591,50 @@ class EventIDPostMigration(BaseRunTimeMigration): migration_version = 2 @staticmethod + @retryable_database_job("cleanup_legacy_event_ids") def migrate_data(instance: Recorder) -> bool: - """Migrate some data, returns True if migration is completed.""" - return cleanup_legacy_states_event_ids(instance) + """Remove old event_id index from states, returns True if completed. + + We used to link states to events using the event_id column but we no + longer store state changed events in the events table. + + If all old states have been purged and existing states are in the new + format we can drop the index since it can take up ~10MB per 1M rows. + """ + session_maker = instance.get_session + _LOGGER.debug("Cleanup legacy entity_ids") + with session_scope(session=session_maker()) as session: + result = session.execute(has_used_states_event_ids()).scalar() + # In the future we may migrate existing states to the new format + # but in practice very few of these still exist in production and + # removing the index is the likely all that needs to happen. + all_gone = not result + + if all_gone: + # Only drop the index if there are no more event_ids in the states table + # ex all NULL + assert instance.engine is not None, "engine should never be None" + if instance.dialect_name == SupportedDialect.SQLITE: + # SQLite does not support dropping foreign key constraints + # so we have to rebuild the table + fk_remove_ok = rebuild_sqlite_table( + session_maker, instance.engine, States + ) + else: + try: + _drop_foreign_key_constraints( + session_maker, instance.engine, TABLE_STATES, "event_id" + ) + except (InternalError, OperationalError): + fk_remove_ok = False + else: + fk_remove_ok = True + if fk_remove_ok: + _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) + instance.use_legacy_events_index = False + _mark_migration_done(session, EventIDPostMigration) + + return True @staticmethod def _legacy_event_id_foreign_key_exists(instance: Recorder) -> bool: diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 56aa6705688..1932860d845 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -113,6 +113,7 @@ async def test_migrate_times( patch.object(migration.StatesContextIDMigration, "migrate_data"), patch.object(migration.EventTypeIDMigration, "migrate_data"), patch.object(migration.EntityIDMigration, "migrate_data"), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object(core, "EventData", old_db_schema.EventData), @@ -120,9 +121,6 @@ async def test_migrate_times( patch.object(core, "Events", old_db_schema.Events), patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_30)), patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), - patch( - "homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids" - ), ): async with ( async_test_home_assistant() as hass, @@ -264,9 +262,8 @@ async def test_migrate_can_resume_entity_id_post_migration( with ( patch.object(recorder, "db_schema", old_db_schema), - patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object(core, "EventData", old_db_schema.EventData), @@ -274,9 +271,6 @@ async def test_migrate_can_resume_entity_id_post_migration( patch.object(core, "Events", old_db_schema.Events), patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), - patch( - "homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids" - ), ): async with ( async_test_home_assistant() as hass, @@ -386,9 +380,8 @@ async def test_migrate_can_resume_ix_states_event_id_removed( with ( patch.object(recorder, "db_schema", old_db_schema), - patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object(core, "EventData", old_db_schema.EventData), @@ -396,9 +389,6 @@ async def test_migrate_can_resume_ix_states_event_id_removed( patch.object(core, "Events", old_db_schema.Events), patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), - patch( - "homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids" - ), ): async with ( async_test_home_assistant() as hass, @@ -522,9 +512,8 @@ async def test_out_of_disk_space_while_rebuild_states_table( with ( patch.object(recorder, "db_schema", old_db_schema), - patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object(core, "EventData", old_db_schema.EventData), @@ -532,9 +521,6 @@ async def test_out_of_disk_space_while_rebuild_states_table( patch.object(core, "Events", old_db_schema.Events), patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), - patch( - "homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids" - ), ): async with ( async_test_home_assistant() as hass, @@ -654,7 +640,7 @@ async def test_out_of_disk_space_while_removing_foreign_key( Note that the test is somewhat forced; the states.event_id foreign key constraint is removed when migrating to schema version 46, inspecting the schema in - cleanup_legacy_states_event_ids is not likely to fail. + EventIDPostMigration.migrate_data, is not likely to fail. """ importlib.import_module(SCHEMA_MODULE_32) old_db_schema = sys.modules[SCHEMA_MODULE_32] @@ -702,9 +688,8 @@ async def test_out_of_disk_space_while_removing_foreign_key( with ( patch.object(recorder, "db_schema", old_db_schema), - patch.object( - recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION - ), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EventIDPostMigration, "migrate_data"), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object(core, "EventData", old_db_schema.EventData), @@ -712,9 +697,6 @@ async def test_out_of_disk_space_while_removing_foreign_key( patch.object(core, "Events", old_db_schema.Events), patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), - patch( - "homeassistant.components.recorder.migration.cleanup_legacy_states_event_ids" - ), ): async with ( async_test_home_assistant() as hass, From 50c1bf8bb0014de051159c8b30dc1d74e5ff790e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 3 Sep 2024 22:38:07 +0200 Subject: [PATCH 0216/1309] Add re-auth flow to NextDNS integration (#125101) --- homeassistant/components/nextdns/__init__.py | 5 +- .../components/nextdns/config_flow.py | 66 +++++++++++++++---- .../components/nextdns/coordinator.py | 4 +- homeassistant/components/nextdns/strings.json | 8 ++- tests/components/nextdns/test_config_flow.py | 56 ++++++++++++++++ tests/components/nextdns/test_coordinator.py | 46 +++++++++++++ tests/components/nextdns/test_init.py | 34 +++++++++- 7 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 tests/components/nextdns/test_coordinator.py diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index 4256126b3c7..7f0729bca1e 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -15,6 +15,7 @@ from nextdns import ( AnalyticsStatus, ApiError, ConnectionStatus, + InvalidApiKeyError, NextDns, Settings, ) @@ -23,7 +24,7 @@ from tenacity import RetryError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( @@ -88,6 +89,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b nextdns = await NextDns.create(websession, api_key) except (ApiError, ClientConnectorError, RetryError, TimeoutError) as err: raise ConfigEntryNotReady from err + except InvalidApiKeyError as err: + raise ConfigEntryAuthFailed from err tasks = [] coordinators = {} diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index bd79112b1f9..80caba6ec7e 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -2,19 +2,30 @@ from __future__ import annotations -from typing import Any +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any from aiohttp.client_exceptions import ClientConnectorError from nextdns import ApiError, InvalidApiKeyError, NextDns from tenacity import RetryError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_PROFILE_ID, DOMAIN +AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns: + """Check if credentials are valid.""" + websession = async_get_clientsession(hass) + + return await NextDns.create(websession, api_key) + class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for NextDNS.""" @@ -23,8 +34,9 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self.nextdns: NextDns | None = None - self.api_key: str | None = None + self.nextdns: NextDns + self.api_key: str + self.entry: ConfigEntry | None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -32,14 +44,10 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors: dict[str, str] = {} - websession = async_get_clientsession(self.hass) - if user_input is not None: self.api_key = user_input[CONF_API_KEY] try: - self.nextdns = await NextDns.create( - websession, user_input[CONF_API_KEY] - ) + self.nextdns = await async_init_nextdns(self.hass, self.api_key) except InvalidApiKeyError: errors["base"] = "invalid_api_key" except (ApiError, ClientConnectorError, RetryError, TimeoutError): @@ -51,7 +59,7 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + data_schema=AUTH_SCHEMA, errors=errors, ) @@ -61,8 +69,6 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): """Handle the profiles step.""" errors: dict[str, str] = {} - assert self.nextdns is not None - if user_input is not None: profile_name = user_input[CONF_PROFILE_NAME] profile_id = self.nextdns.get_profile_id(profile_name) @@ -86,3 +92,39 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + await async_init_nextdns(self.hass, user_input[CONF_API_KEY]) + except InvalidApiKeyError: + errors["base"] = "invalid_api_key" + except (ApiError, ClientConnectorError, RetryError, TimeoutError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + else: + if TYPE_CHECKING: + assert self.entry is not None + + return self.async_update_reload_and_abort( + self.entry, data={**self.entry.data, **user_input} + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=AUTH_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 5210807bd3c..6b35e35a027 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -21,6 +21,7 @@ from nextdns.model import NextDnsData from tenacity import RetryError from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -62,10 +63,11 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): except ( ApiError, ClientConnectorError, - InvalidApiKeyError, RetryError, ) as err: raise UpdateFailed(err) from err + except InvalidApiKeyError as err: + raise ConfigEntryAuthFailed from err async def _async_update_data_internal(self) -> CoordinatorDataT: """Update data via library.""" diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index e0a37aad03b..9dbc8061849 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -10,6 +10,11 @@ "data": { "profile": "Profile" } + }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } } }, "error": { @@ -18,7 +23,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "This NextDNS profile is already configured." + "already_configured": "This NextDNS profile is already configured.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "system_health": { diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 7571eef347e..2a51c6821fc 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -101,3 +101,59 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_successful(hass: HomeAssistant) -> None: + """Test starting a reauthentication flow.""" + entry = await init_integration(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with ( + patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + return_value=PROFILES, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("exc", "base_error"), + [ + (ApiError("API Error"), "cannot_connect"), + (InvalidApiKeyError, "invalid_api_key"), + (RetryError("Retry Error"), "cannot_connect"), + (TimeoutError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, exc: Exception, base_error: str +) -> None: + """Test reauthentication flow with errors.""" + entry = await init_integration(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.nextdns.NextDns.get_profiles", side_effect=exc + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + await hass.async_block_till_done() + + assert result["errors"] == {"base": base_error} diff --git a/tests/components/nextdns/test_coordinator.py b/tests/components/nextdns/test_coordinator.py new file mode 100644 index 00000000000..9613a6b423f --- /dev/null +++ b/tests/components/nextdns/test_coordinator.py @@ -0,0 +1,46 @@ +"""Tests for NextDNS coordinator.""" + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from nextdns import InvalidApiKeyError + +from homeassistant.components.nextdns.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import async_fire_time_changed + + +async def test_auth_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test authentication error when polling data.""" + entry = await init_integration(hass) + + assert entry.state is ConfigEntryState.LOADED + + freezer.tick(timedelta(minutes=10)) + with patch( + "homeassistant.components.nextdns.NextDns.connection_status", + side_effect=InvalidApiKeyError, + ): + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id diff --git a/tests/components/nextdns/test_init.py b/tests/components/nextdns/test_init.py index 61a487d917c..0a0bf3fc487 100644 --- a/tests/components/nextdns/test_init.py +++ b/tests/components/nextdns/test_init.py @@ -2,12 +2,12 @@ from unittest.mock import patch -from nextdns import ApiError +from nextdns import ApiError, InvalidApiKeyError import pytest from tenacity import RetryError from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_API_KEY, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -59,3 +59,33 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +async def test_config_auth_failed(hass: HomeAssistant) -> None: + """Test for setup failure if the auth fails.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Fake Profile", + unique_id="xyz12", + data={CONF_API_KEY: "fake_api_key", CONF_PROFILE_ID: "xyz12"}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + side_effect=InvalidApiKeyError, + ): + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == entry.entry_id From cfe0c95c97629a61ee7039be43d80c5f777ff04e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 3 Sep 2024 22:43:03 +0200 Subject: [PATCH 0217/1309] Bump python-holidays to 0.56 (#125182) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 0a3064450d4..0a2d98e71c5 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.55", "babel==2.15.0"] + "requirements": ["holidays==0.56", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index fafa870d00a..297b20b8c0e 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.55"] + "requirements": ["holidays==0.56"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6ec38c80689..41b65c55f1a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1105,7 +1105,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.55 +holidays==0.56 # homeassistant.components.frontend home-assistant-frontend==20240903.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00802565a35..7ddac14c2d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -931,7 +931,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.55 +holidays==0.56 # homeassistant.components.frontend home-assistant-frontend==20240903.1 From c4cfff4b3f6b0909756a2202c594d0c78164cc3c Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 3 Sep 2024 22:50:00 +0200 Subject: [PATCH 0218/1309] Add 100% coverage of Reolink update platform (#124521) * Add 100% update test coverage * Add assertion --- homeassistant/components/reolink/update.py | 6 +- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_update.py | 131 +++++++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 tests/components/reolink/test_update.py diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 9b710c6576d..3c1e70612a7 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -137,8 +137,7 @@ class ReolinkUpdateEntity( async def async_release_notes(self) -> str | None: """Return the release notes.""" new_firmware = self._host.api.firmware_update_available(self._channel) - if not isinstance(new_firmware, NewSoftwareVersion): - return None + assert isinstance(new_firmware, NewSoftwareVersion) return ( "If the install button fails, download this" @@ -229,8 +228,7 @@ class ReolinkHostUpdateEntity( async def async_release_notes(self) -> str | None: """Return the release notes.""" new_firmware = self._host.api.firmware_update_available() - if not isinstance(new_firmware, NewSoftwareVersion): - return None + assert isinstance(new_firmware, NewSoftwareVersion) return ( "If the install button fails, download this" diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index be87aac9291..b0f599c28fb 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -35,6 +35,7 @@ TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_CAM_NAME = "test_reolink_cam" TEST_NVR_NAME2 = "test2_reolink_name" +TEST_CAM_NAME = "test_reolink_cam" TEST_USE_HTTPS = True TEST_HOST_MODEL = "RLN8-410" TEST_ITEM_NUMBER = "P000" diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py new file mode 100644 index 00000000000..3ad10a11499 --- /dev/null +++ b/tests/components/reolink/test_update.py @@ -0,0 +1,131 @@ +"""Test the Reolink update platform.""" + +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from reolink_aio.exceptions import ReolinkError +from reolink_aio.software_version import NewSoftwareVersion + +from homeassistant.components.reolink.update import POLL_AFTER_INSTALL +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import TEST_CAM_NAME, TEST_NVR_NAME + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator + +TEST_DOWNLOAD_URL = "https://reolink.com/test" +TEST_RELEASE_NOTES = "bugfix 1, bugfix 2" + + +@pytest.mark.parametrize("entity_name", [TEST_NVR_NAME, TEST_CAM_NAME]) +async def test_no_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_name: str, +) -> None: + """Test update state when no update available.""" + reolink_connect.camera_name.return_value = TEST_CAM_NAME + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.UPDATE}.{entity_name}_firmware" + assert hass.states.get(entity_id).state == STATE_OFF + + +@pytest.mark.parametrize("entity_name", [TEST_NVR_NAME, TEST_CAM_NAME]) +async def test_update_str( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_name: str, +) -> None: + """Test update state when update available with string from API.""" + reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_connect.firmware_update_available.return_value = "New firmware available" + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.UPDATE}.{entity_name}_firmware" + assert hass.states.get(entity_id).state == STATE_ON + + +@pytest.mark.parametrize("entity_name", [TEST_NVR_NAME, TEST_CAM_NAME]) +async def test_update_firm( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + entity_name: str, +) -> None: + """Test update state when update available with firmware info from reolink.com.""" + reolink_connect.camera_name.return_value = TEST_CAM_NAME + new_firmware = NewSoftwareVersion( + version_string="v3.3.0.226_23031644", + download_url=TEST_DOWNLOAD_URL, + release_notes=TEST_RELEASE_NOTES, + ) + reolink_connect.firmware_update_available.return_value = new_firmware + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.UPDATE}.{entity_name}_firmware" + assert hass.states.get(entity_id).state == STATE_ON + + # release notes + client = await hass_ws_client(hass) + await hass.async_block_till_done() + + await client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": entity_id, + } + ) + result = await client.receive_json() + assert TEST_DOWNLOAD_URL in result["result"] + assert TEST_RELEASE_NOTES in result["result"] + + # test install + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.update_firmware.assert_called() + + reolink_connect.update_firmware.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # test _async_update_future + reolink_connect.camera_sw_version.return_value = "v3.3.0.226_23031644" + reolink_connect.firmware_update_available.return_value = False + freezer.tick(POLL_AFTER_INSTALL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_OFF From d8382c6de26ed7e1f49043116a8e5f2c62967583 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Sep 2024 22:56:27 +0200 Subject: [PATCH 0219/1309] Improve recorder tests to check indices are removed (#125164) --- .../components/recorder/test_migration_from_schema_32.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index bc16eae3410..f1613909722 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -35,6 +35,7 @@ from homeassistant.components.recorder.queries import ( from homeassistant.components.recorder.tasks import EntityIDPostMigrationTask from homeassistant.components.recorder.util import ( execute_stmt_lambda_element, + get_index_by_name, session_scope, ) from homeassistant.core import HomeAssistant @@ -333,6 +334,10 @@ async def test_migrate_events_context_ids( == migration.EventsContextIDMigration.migration_version ) + # Check the index which will be removed by the migrator no longer exists + with session_scope(hass=hass) as session: + assert get_index_by_name(session, "states", "ix_states_context_id") is None + @pytest.mark.parametrize("enable_migrate_context_ids", [True]) @pytest.mark.usefixtures("db_schema_32") @@ -531,6 +536,10 @@ async def test_migrate_states_context_ids( == migration.StatesContextIDMigration.migration_version ) + # Check the index which will be removed by the migrator no longer exists + with session_scope(hass=hass) as session: + assert get_index_by_name(session, "states", "ix_states_context_id") is None + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) @pytest.mark.usefixtures("db_schema_32") From d5c2e6ec357c039172e116ebc4dcbbb3ed53ce7e Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 4 Sep 2024 00:20:25 +0300 Subject: [PATCH 0220/1309] Add myself as codeowner for BTHome (#125184) --- CODEOWNERS | 4 ++-- homeassistant/components/bthome/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f4c7d972f7c..596795d4221 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -228,8 +228,8 @@ build.json @home-assistant/supervisor /homeassistant/components/bsblan/ @liudger /tests/components/bsblan/ @liudger /homeassistant/components/bt_smarthub/ @typhoon2099 -/homeassistant/components/bthome/ @Ernst79 -/tests/components/bthome/ @Ernst79 +/homeassistant/components/bthome/ @Ernst79 @thecode +/tests/components/bthome/ @Ernst79 @thecode /homeassistant/components/buienradar/ @mjj4791 @ties @Robbie1221 /tests/components/buienradar/ @mjj4791 @ties @Robbie1221 /homeassistant/components/button/ @home-assistant/core diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 42fbe794918..ad06f648d14 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -15,7 +15,7 @@ "service_data_uuid": "0000fcd2-0000-1000-8000-00805f9b34fb" } ], - "codeowners": ["@Ernst79"], + "codeowners": ["@Ernst79", "@thecode"], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", From af1af6f391cf3efd46646bd42163303ac388b3d4 Mon Sep 17 00:00:00 2001 From: Dian Date: Wed, 4 Sep 2024 14:11:32 +0800 Subject: [PATCH 0221/1309] Bump xiaomi-ble to 0.31.1 to add support for human presence sensor XMOSB01XS (#124751) --- .../components/xiaomi_ble/binary_sensor.py | 4 + .../components/xiaomi_ble/manifest.json | 2 +- homeassistant/components/xiaomi_ble/sensor.py | 18 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/xiaomi_ble/test_sensor.py | 110 ++++++++++++++++++ 6 files changed, 135 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index 5336c4d8f7f..b853f83b967 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -50,6 +50,10 @@ BINARY_SENSOR_DESCRIPTIONS = { key=XiaomiBinarySensorDeviceClass.MOTION, device_class=BinarySensorDeviceClass.MOTION, ), + XiaomiBinarySensorDeviceClass.OCCUPANCY: BinarySensorEntityDescription( + key=XiaomiBinarySensorDeviceClass.OCCUPANCY, + device_class=BinarySensorDeviceClass.OCCUPANCY, + ), XiaomiBinarySensorDeviceClass.OPENING: BinarySensorEntityDescription( key=XiaomiBinarySensorDeviceClass.OPENING, device_class=BinarySensorDeviceClass.OPENING, diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 21e9bc45bb8..da7169635e9 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.30.2"] + "requirements": ["xiaomi-ble==0.31.1"] } diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index 3108c285dbe..891caaf3e68 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -155,6 +155,24 @@ SENSOR_DESCRIPTIONS = { (ExtendedSensorDeviceClass.LOCK_METHOD, None): SensorEntityDescription( key=str(ExtendedSensorDeviceClass.LOCK_METHOD), icon="mdi:key-variant" ), + # Duration of detected status (in minutes) for Occpancy Sensor + ( + ExtendedSensorDeviceClass.DURATION_DETECTED, + Units.TIME_MINUTES, + ): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.DURATION_DETECTED), + native_unit_of_measurement=UnitOfTime.MINUTES, + state_class=SensorStateClass.MEASUREMENT, + ), + # Duration of cleared status (in minutes) for Occpancy Sensor + ( + ExtendedSensorDeviceClass.DURATION_CLEARED, + Units.TIME_MINUTES, + ): SensorEntityDescription( + key=str(ExtendedSensorDeviceClass.DURATION_CLEARED), + native_unit_of_measurement=UnitOfTime.MINUTES, + state_class=SensorStateClass.MEASUREMENT, + ), } diff --git a/requirements_all.txt b/requirements_all.txt index 41b65c55f1a..5ca05016bf4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2963,7 +2963,7 @@ wyoming==1.5.4 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.30.2 +xiaomi-ble==0.31.1 # homeassistant.components.knx xknx==3.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7ddac14c2d8..ae262c5d05a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2349,7 +2349,7 @@ wyoming==1.5.4 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.30.2 +xiaomi-ble==0.31.1 # homeassistant.components.knx xknx==3.1.1 diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 4d9a29e3111..11a20a62d02 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.xiaomi_ble.const import CONF_SLEEPY_DEVICE, DOMAIN from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, + STATE_ON, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant @@ -465,6 +466,115 @@ async def test_xiaomi_hhccjcy01_only_some_sources_connectable( await hass.async_block_till_done() +async def test_xiaomi_xmosb01xs(hass: HomeAssistant) -> None: + """Test XMOSB01XS multiple advertisements. + + This device has multiple advertisements before all sensors are visible. + """ + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="DC:8E:95:23:07:B7", + data={"bindkey": "272b1c920ef435417c49228b8ab9a563"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + "DC:8E:95:23:07:B7", + ( + b"\x58\x59\x83\x46\x91\xb7\x07\x23\x95\x8e\xdc\xc7\x17\x61\xc1" + b"\x24\x03\x00\x25\x44\xb0\x65" + ), + connectable=False, + ), + ) + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + "DC:8E:95:23:07:B7", + b"\x10\x59\x83\x46\x90\xb7\x07\x23\x95\x8e\xdc", + connectable=False, + ), + ) + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + "DC:8E:95:23:07:B7", + b"\x48\x59\x83\x46\x9d\x34\x45\xec\xab\xda\x93\xf9\x24\x03\x00\x9e\x01\x6d\x3d", + connectable=False, + ), + ) + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + "DC:8E:95:23:07:B7", + ( + b"\x58\x59\x83\x46\xa9\xb7\x07\x23\x95\x8e\xdc\xc6\x59\xa2\xdc\xc5" + b"\x24\x03\x00\xa0\x4d\x0d\x45" + ), + connectable=False, + ), + ) + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + "DC:8E:95:23:07:B7", + ( + b"\x58\x59\x83\x46\xa4\xb7\x07\x23\x95\x8e\xdc\x77\x2a\xe2\x5c\x11" + b"\x24\x03\x00\xab\x87\x7b\xd7" + ), + connectable=False, + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 4 + + occupancy_sensor = hass.states.get("binary_sensor.occupancy_sensor_07b7_occupancy") + occupancy_sensor_attribtes = occupancy_sensor.attributes + assert occupancy_sensor.state == STATE_ON + assert ( + occupancy_sensor_attribtes[ATTR_FRIENDLY_NAME] + == "Occupancy Sensor 07B7 Occupancy" + ) + + illum_sensor = hass.states.get("sensor.occupancy_sensor_07b7_illuminance") + illum_sensor_attr = illum_sensor.attributes + assert illum_sensor.state == "111.0" + assert illum_sensor_attr[ATTR_FRIENDLY_NAME] == "Occupancy Sensor 07B7 Illuminance" + assert illum_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "lx" + assert illum_sensor_attr[ATTR_STATE_CLASS] == "measurement" + + illum_sensor = hass.states.get("sensor.occupancy_sensor_07b7_duration_detected") + illum_sensor_attr = illum_sensor.attributes + assert illum_sensor.state == "2" + assert ( + illum_sensor_attr[ATTR_FRIENDLY_NAME] + == "Occupancy Sensor 07B7 Duration detected" + ) + assert illum_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "min" + assert illum_sensor_attr[ATTR_STATE_CLASS] == "measurement" + + illum_sensor = hass.states.get("sensor.occupancy_sensor_07b7_duration_cleared") + illum_sensor_attr = illum_sensor.attributes + assert illum_sensor.state == "2" + assert ( + illum_sensor_attr[ATTR_FRIENDLY_NAME] + == "Occupancy Sensor 07B7 Duration cleared" + ) + assert illum_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "min" + assert illum_sensor_attr[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.data[CONF_SLEEPY_DEVICE] is True + + async def test_xiaomi_cgdk2_bind_key(hass: HomeAssistant) -> None: """Test CGDK2 bind key. From 7788685340fc8dc5256981bb2f7e890f43fc6bfd Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 4 Sep 2024 02:16:56 -0400 Subject: [PATCH 0222/1309] Get zwave_js statistics data from model (#120281) * Get zwave_js statistics data from model * Add migration logic * Update comment * revert change to forward entry --- homeassistant/components/zwave_js/__init__.py | 2 +- homeassistant/components/zwave_js/migrate.py | 83 +++++++----- homeassistant/components/zwave_js/sensor.py | 126 +++++++++++------- tests/components/zwave_js/test_sensor.py | 54 ++++++++ 4 files changed, 185 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index dedae10400f..4844f707201 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -353,7 +353,7 @@ class ControllerEvents: self.discovered_value_ids: dict[str, set[str]] = defaultdict(set) self.driver_events = driver_events self.dev_reg = driver_events.dev_reg - self.registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict( + self.registered_unique_ids: dict[str, dict[Platform, set[str]]] = defaultdict( lambda: defaultdict(set) ) self.node_events = NodeEvents(hass, self) diff --git a/homeassistant/components/zwave_js/migrate.py b/homeassistant/components/zwave_js/migrate.py index bde53137dc1..ac749cb516b 100644 --- a/homeassistant/components/zwave_js/migrate.py +++ b/homeassistant/components/zwave_js/migrate.py @@ -6,20 +6,16 @@ from dataclasses import dataclass import logging from zwave_js_server.model.driver import Driver +from zwave_js_server.model.node import Node from zwave_js_server.model.value import Value as ZwaveValue -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.entity_registry import ( - EntityRegistry, - RegistryEntry, - async_entries_for_device, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo -from .helpers import get_unique_id +from .helpers import get_unique_id, get_valueless_base_unique_id _LOGGER = logging.getLogger(__name__) @@ -62,10 +58,10 @@ class ValueID: @callback def async_migrate_old_entity( hass: HomeAssistant, - ent_reg: EntityRegistry, + ent_reg: er.EntityRegistry, registered_unique_ids: set[str], - platform: str, - device: DeviceEntry, + platform: Platform, + device: dr.DeviceEntry, unique_id: str, ) -> None: """Migrate existing entity if current one can't be found and an old one exists.""" @@ -77,8 +73,8 @@ def async_migrate_old_entity( # Look for existing entities in the registry that could be the same value but on # a different endpoint - existing_entity_entries: list[RegistryEntry] = [] - for entry in async_entries_for_device(ent_reg, device.id): + existing_entity_entries: list[er.RegistryEntry] = [] + for entry in er.async_entries_for_device(ent_reg, device.id): # If entity is not in the domain for this discovery info or entity has already # been processed, skip it if entry.domain != platform or entry.unique_id in registered_unique_ids: @@ -109,35 +105,40 @@ def async_migrate_old_entity( @callback def async_migrate_unique_id( - ent_reg: EntityRegistry, platform: str, old_unique_id: str, new_unique_id: str + ent_reg: er.EntityRegistry, + platform: Platform, + old_unique_id: str, + new_unique_id: str, ) -> None: """Check if entity with old unique ID exists, and if so migrate it to new ID.""" - if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id): + if not (entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id)): + return + + _LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_id, + old_unique_id, + new_unique_id, + ) + try: + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + except ValueError: _LOGGER.debug( - "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + ( + "Entity %s can't be migrated because the unique ID is taken; " + "Cleaning it up since it is likely no longer valid" + ), entity_id, - old_unique_id, - new_unique_id, ) - try: - ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) - except ValueError: - _LOGGER.debug( - ( - "Entity %s can't be migrated because the unique ID is taken; " - "Cleaning it up since it is likely no longer valid" - ), - entity_id, - ) - ent_reg.async_remove(entity_id) + ent_reg.async_remove(entity_id) @callback def async_migrate_discovered_value( hass: HomeAssistant, - ent_reg: EntityRegistry, + ent_reg: er.EntityRegistry, registered_unique_ids: set[str], - device: DeviceEntry, + device: dr.DeviceEntry, driver: Driver, disc_info: ZwaveDiscoveryInfo, ) -> None: @@ -160,7 +161,7 @@ def async_migrate_discovered_value( ] if ( - disc_info.platform == "binary_sensor" + disc_info.platform == Platform.BINARY_SENSOR and disc_info.platform_hint == "notification" ): for state_key in disc_info.primary_value.metadata.states: @@ -211,6 +212,24 @@ def async_migrate_discovered_value( registered_unique_ids.add(new_unique_id) +@callback +def async_migrate_statistics_sensors( + hass: HomeAssistant, driver: Driver, node: Node, key_map: dict[str, str] +) -> None: + """Migrate statistics sensors to new unique IDs. + + - Migrate camel case keys in unique IDs to snake keys. + """ + ent_reg = er.async_get(hass) + base_unique_id = f"{get_valueless_base_unique_id(driver, node)}.statistics" + for new_key, old_key in key_map.items(): + if new_key == old_key: + continue + old_unique_id = f"{base_unique_id}_{old_key}" + new_unique_id = f"{base_unique_id}_{new_key}" + async_migrate_unique_id(ent_reg, Platform.SENSOR, old_unique_id, new_unique_id) + + @callback def get_old_value_ids(value: ZwaveValue) -> list[str]: """Get old value IDs so we can migrate entity unique ID.""" diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 428bf504510..f52801109a1 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping from dataclasses import dataclass -from datetime import datetime from typing import Any import voluptuous as vol @@ -16,10 +15,10 @@ from zwave_js_server.const.command_class.meter import ( ) from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.model.controller import Controller -from zwave_js_server.model.controller.statistics import ControllerStatisticsDataType +from zwave_js_server.model.controller.statistics import ControllerStatistics from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.node.statistics import NodeStatisticsDataType +from zwave_js_server.model.node.statistics import NodeStatistics from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( @@ -90,6 +89,7 @@ from .discovery_data_template import ( ) from .entity import ZWaveBaseEntity from .helpers import get_device_info, get_valueless_base_unique_id +from .migrate import async_migrate_statistics_sensors PARALLEL_UPDATES = 0 @@ -328,152 +328,172 @@ ENTITY_DESCRIPTION_KEY_MAP = { } -def convert_dict_of_dicts( - statistics: ControllerStatisticsDataType | NodeStatisticsDataType, key: str +def convert_nested_attr( + statistics: ControllerStatistics | NodeStatistics, key: str ) -> Any: - """Convert a dictionary of dictionaries to a value.""" - keys = key.split(".") - return statistics.get(keys[0], {}).get(keys[1], {}).get(keys[2]) # type: ignore[attr-defined] + """Convert a string that represents a nested attr to a value.""" + data = statistics + for _key in key.split("."): + if data is None: + return None # type: ignore[unreachable] + data = getattr(data, _key) + return data @dataclass(frozen=True, kw_only=True) class ZWaveJSStatisticsSensorEntityDescription(SensorEntityDescription): """Class to represent a Z-Wave JS statistics sensor entity description.""" - convert: Callable[ - [ControllerStatisticsDataType | NodeStatisticsDataType, str], Any - ] = lambda statistics, key: statistics.get(key) + convert: Callable[[ControllerStatistics | NodeStatistics, str], Any] = getattr entity_registry_enabled_default: bool = False # Controller statistics descriptions ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ZWaveJSStatisticsSensorEntityDescription( - key="messagesTX", + key="messages_tx", translation_key="successful_messages", translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="messagesRX", + key="messages_rx", translation_key="successful_messages", translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="messagesDroppedTX", + key="messages_dropped_tx", translation_key="messages_dropped", translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="messagesDroppedRX", + key="messages_dropped_rx", translation_key="messages_dropped", translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="NAK", translation_key="nak", state_class=SensorStateClass.TOTAL + key="nak", translation_key="nak", state_class=SensorStateClass.TOTAL ), ZWaveJSStatisticsSensorEntityDescription( - key="CAN", translation_key="can", state_class=SensorStateClass.TOTAL + key="can", translation_key="can", state_class=SensorStateClass.TOTAL ), ZWaveJSStatisticsSensorEntityDescription( - key="timeoutACK", + key="timeout_ack", translation_key="timeout_ack", state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="timeoutResponse", + key="timeout_response", translation_key="timeout_response", state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="timeoutCallback", + key="timeout_callback", translation_key="timeout_callback", state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="backgroundRSSI.channel0.average", + key="background_rssi.channel_0.average", translation_key="average_background_rssi", translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, - convert=convert_dict_of_dicts, + convert=convert_nested_attr, ), ZWaveJSStatisticsSensorEntityDescription( - key="backgroundRSSI.channel0.current", + key="background_rssi.channel_0.current", translation_key="current_background_rssi", translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, - convert=convert_dict_of_dicts, + convert=convert_nested_attr, ), ZWaveJSStatisticsSensorEntityDescription( - key="backgroundRSSI.channel1.average", + key="background_rssi.channel_1.average", translation_key="average_background_rssi", translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, - convert=convert_dict_of_dicts, + convert=convert_nested_attr, ), ZWaveJSStatisticsSensorEntityDescription( - key="backgroundRSSI.channel1.current", + key="background_rssi.channel_1.current", translation_key="current_background_rssi", translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, - convert=convert_dict_of_dicts, + convert=convert_nested_attr, ), ZWaveJSStatisticsSensorEntityDescription( - key="backgroundRSSI.channel2.average", + key="background_rssi.channel_2.average", translation_key="average_background_rssi", translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, - convert=convert_dict_of_dicts, + convert=convert_nested_attr, ), ZWaveJSStatisticsSensorEntityDescription( - key="backgroundRSSI.channel2.current", + key="background_rssi.channel_2.current", translation_key="current_background_rssi", translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, - convert=convert_dict_of_dicts, + convert=convert_nested_attr, ), ] +CONTROLLER_STATISTICS_KEY_MAP: dict[str, str] = { + "messages_tx": "messagesTX", + "messages_rx": "messagesRX", + "messages_dropped_tx": "messagesDroppedTX", + "messages_dropped_rx": "messagesDroppedRX", + "nak": "NAK", + "can": "CAN", + "timeout_ack": "timeoutAck", + "timeout_response": "timeoutResponse", + "timeout_callback": "timeoutCallback", + "background_rssi.channel_0.average": "backgroundRSSI.channel0.average", + "background_rssi.channel_0.current": "backgroundRSSI.channel0.current", + "background_rssi.channel_1.average": "backgroundRSSI.channel1.average", + "background_rssi.channel_1.current": "backgroundRSSI.channel1.current", + "background_rssi.channel_2.average": "backgroundRSSI.channel2.average", + "background_rssi.channel_2.current": "backgroundRSSI.channel2.current", +} + # Node statistics descriptions ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ ZWaveJSStatisticsSensorEntityDescription( - key="commandsRX", + key="commands_rx", translation_key="successful_commands", translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="commandsTX", + key="commands_tx", translation_key="successful_commands", translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="commandsDroppedRX", + key="commands_dropped_rx", translation_key="commands_dropped", translation_placeholders={"direction": "RX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="commandsDroppedTX", + key="commands_dropped_tx", translation_key="commands_dropped", translation_placeholders={"direction": "TX"}, state_class=SensorStateClass.TOTAL, ), ZWaveJSStatisticsSensorEntityDescription( - key="timeoutResponse", + key="timeout_response", translation_key="timeout_response", state_class=SensorStateClass.TOTAL, ), @@ -492,20 +512,24 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ state_class=SensorStateClass.MEASUREMENT, ), ZWaveJSStatisticsSensorEntityDescription( - key="lastSeen", + key="last_seen", translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, - convert=( - lambda statistics, key: ( - datetime.fromisoformat(dt) # type: ignore[arg-type] - if (dt := statistics.get(key)) - else None - ) - ), entity_registry_enabled_default=True, ), ] +NODE_STATISTICS_KEY_MAP: dict[str, str] = { + "commands_rx": "commandsRX", + "commands_tx": "commandsTX", + "commands_dropped_rx": "commandsDroppedRX", + "commands_dropped_tx": "commandsDroppedTX", + "timeout_response": "timeoutResponse", + "rtt": "rtt", + "rssi": "rssi", + "last_seen": "lastSeen", +} + def get_entity_description( data: NumericSensorDataTemplateData, @@ -588,6 +612,14 @@ async def async_setup_entry( @callback def async_add_statistics_sensors(node: ZwaveNode) -> None: """Add statistics sensors.""" + async_migrate_statistics_sensors( + hass, + driver, + node, + CONTROLLER_STATISTICS_KEY_MAP + if driver.controller.own_node == node + else NODE_STATISTICS_KEY_MAP, + ) async_add_entities( [ ZWaveStatisticsSensor( @@ -1001,7 +1033,7 @@ class ZWaveStatisticsSensor(SensorEntity): def statistics_updated(self, event_data: dict) -> None: """Call when statistics updated event is received.""" self._attr_native_value = self.entity_description.convert( - event_data["statistics"], self.entity_description.key + event_data["statistics_updated"], self.entity_description.key ) self.async_write_ha_state() @@ -1027,5 +1059,5 @@ class ZWaveStatisticsSensor(SensorEntity): # Set initial state self._attr_native_value = self.entity_description.convert( - self.statistics_src.statistics.data, self.entity_description.key + self.statistics_src.statistics, self.entity_description.key ) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 19f8aeece36..34c50b8d449 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -23,6 +23,10 @@ from homeassistant.components.zwave_js.const import ( SERVICE_RESET_METER, ) from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id +from homeassistant.components.zwave_js.sensor import ( + CONTROLLER_STATISTICS_KEY_MAP, + NODE_STATISTICS_KEY_MAP, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -55,6 +59,8 @@ from .common import ( VOLTAGE_SENSOR, ) +from tests.common import MockConfigEntry + async def test_numeric_sensor( hass: HomeAssistant, @@ -756,6 +762,54 @@ NODE_STATISTICS_SUFFIXES_UNKNOWN = { } +async def test_statistics_sensors_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + zp3111_state, + client, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test statistics migration sensor.""" + node = Node(client, copy.deepcopy(zp3111_state)) + client.driver.controller.nodes[node.node_id] = node + + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + + controller_base_unique_id = f"{client.driver.controller.home_id}.1.statistics" + node_base_unique_id = f"{client.driver.controller.home_id}.22.statistics" + + # Create entity registry records for the old statistics keys + for base_unique_id, key_map in ( + (controller_base_unique_id, CONTROLLER_STATISTICS_KEY_MAP), + (node_base_unique_id, NODE_STATISTICS_KEY_MAP), + ): + # old key + for key in key_map.values(): + entity_registry.async_get_or_create( + "sensor", DOMAIN, f"{base_unique_id}_{key}" + ) + + # Set up integration + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Validate that entity unique ID's have changed + for base_unique_id, key_map in ( + (controller_base_unique_id, CONTROLLER_STATISTICS_KEY_MAP), + (node_base_unique_id, NODE_STATISTICS_KEY_MAP), + ): + for new_key, old_key in key_map.items(): + # If the key has changed, the old entity should not exist + if new_key != old_key: + assert not entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{base_unique_id}_{old_key}" + ) + assert entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{base_unique_id}_{new_key}" + ) + + async def test_statistics_sensors_no_last_seen( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 482bed522fed193a218a3ed7362c50940b244245 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 4 Sep 2024 08:34:51 +0200 Subject: [PATCH 0223/1309] Fix missing patch in nextdns tests (#125195) --- tests/components/nextdns/test_config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 2a51c6821fc..27a6cf1e7e0 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import PROFILES, init_integration +from . import PROFILES, init_integration, mock_nextdns async def test_form_create_entry(hass: HomeAssistant) -> None: @@ -116,6 +116,7 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: "homeassistant.components.nextdns.NextDns.get_profiles", return_value=PROFILES, ), + mock_nextdns(), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], From 7fc0e36b2f5ceedf5caeb4bd84cb806281b90dbb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 4 Sep 2024 08:38:46 +0200 Subject: [PATCH 0224/1309] Move recorder EntityIDPostMigrationTask to migration (#125136) * Move recorder EntityIDPostMigrationTask to migration * Update test --- homeassistant/components/recorder/core.py | 4 ---- homeassistant/components/recorder/migration.py | 13 ++++++++++++- homeassistant/components/recorder/tasks.py | 13 ------------- .../recorder/test_migration_from_schema_32.py | 3 +-- tests/components/recorder/test_v32_migration.py | 10 +++++----- 5 files changed, 18 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index c0ac1fc1277..002d8937e3a 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -1283,10 +1283,6 @@ class Recorder(threading.Thread): self.event_session = self.get_session() self.event_session.expire_on_commit = False - def _post_migrate_entity_ids(self) -> bool: - """Post migrate entity_ids if needed.""" - return migration.post_migrate_entity_ids(self) - def _send_keep_alive(self) -> None: """Send a keep alive to keep the db connection open.""" assert self.event_session is not None diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 890fc3045b2..d2d8fff136e 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -100,7 +100,7 @@ from .queries import ( migrate_single_statistics_row_to_timestamp, ) from .statistics import cleanup_statistics_timestamp_migration, get_start_time -from .tasks import EntityIDPostMigrationTask, RecorderTask +from .tasks import RecorderTask from .util import ( database_job_retry_wrapper, execute_stmt_lambda_element, @@ -2667,6 +2667,17 @@ class EventIDPostMigration(BaseRunTimeMigration): return NeedsMigrateResult(needs_migrate=False, migration_done=True) +@dataclass(slots=True) +class EntityIDPostMigrationTask(RecorderTask): + """An object to insert into the recorder queue to cleanup after entity_ids migration.""" + + def run(self, instance: Recorder) -> None: + """Run entity_id post migration task.""" + if not post_migrate_entity_ids(instance): + # Schedule a new migration task if this one didn't finish + instance.queue_task(EntityIDPostMigrationTask()) + + def _mark_migration_done( session: Session, migration: type[BaseRunTimeMigration] ) -> None: diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index c51ba2b16ca..2529e8012bf 100644 --- a/homeassistant/components/recorder/tasks.py +++ b/homeassistant/components/recorder/tasks.py @@ -333,19 +333,6 @@ class AdjustLRUSizeTask(RecorderTask): instance._adjust_lru_size() # noqa: SLF001 -@dataclass(slots=True) -class EntityIDPostMigrationTask(RecorderTask): - """An object to insert into the recorder queue to cleanup after entity_ids migration.""" - - def run(self, instance: Recorder) -> None: - """Run entity_id post migration task.""" - if ( - not instance._post_migrate_entity_ids() # noqa: SLF001 - ): - # Schedule a new migration task if this one didn't finish - instance.queue_task(EntityIDPostMigrationTask()) - - @dataclass(slots=True) class RefreshEventTypesTask(RecorderTask): """An object to insert into the recorder queue to refresh event types.""" diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index f1613909722..40d18ab51fd 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -32,7 +32,6 @@ from homeassistant.components.recorder.queries import ( get_migration_changes, select_event_type_ids, ) -from homeassistant.components.recorder.tasks import EntityIDPostMigrationTask from homeassistant.components.recorder.util import ( execute_stmt_lambda_element, get_index_by_name, @@ -746,7 +745,7 @@ async def test_post_migrate_entity_ids( await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder - recorder_mock.queue_task(EntityIDPostMigrationTask()) + recorder_mock.queue_task(migration.EntityIDPostMigrationTask()) await _async_wait_migration_done(hass) def _fetch_migrated_states(): diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 1932860d845..58bcabdff51 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -109,6 +109,7 @@ async def test_migrate_times( with ( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration, "post_migrate_entity_ids", return_value=False), patch.object(migration.EventsContextIDMigration, "migrate_data"), patch.object(migration.StatesContextIDMigration, "migrate_data"), patch.object(migration.EventTypeIDMigration, "migrate_data"), @@ -120,7 +121,6 @@ async def test_migrate_times( patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_30)), - patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), ): async with ( async_test_home_assistant() as hass, @@ -264,13 +264,13 @@ async def test_migrate_can_resume_entity_id_post_migration( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EventIDPostMigration, "migrate_data"), + patch.object(migration, "post_migrate_entity_ids", return_value=False), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), - patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), ): async with ( async_test_home_assistant() as hass, @@ -382,13 +382,13 @@ async def test_migrate_can_resume_ix_states_event_id_removed( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EventIDPostMigration, "migrate_data"), + patch.object(migration, "post_migrate_entity_ids", return_value=False), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), - patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), ): async with ( async_test_home_assistant() as hass, @@ -514,13 +514,13 @@ async def test_out_of_disk_space_while_rebuild_states_table( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EventIDPostMigration, "migrate_data"), + patch.object(migration, "post_migrate_entity_ids", return_value=False), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), - patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), ): async with ( async_test_home_assistant() as hass, @@ -690,13 +690,13 @@ async def test_out_of_disk_space_while_removing_foreign_key( patch.object(recorder, "db_schema", old_db_schema), patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), patch.object(migration.EventIDPostMigration, "migrate_data"), + patch.object(migration, "post_migrate_entity_ids", return_value=False), patch.object(core, "StatesMeta", old_db_schema.StatesMeta), patch.object(core, "EventTypes", old_db_schema.EventTypes), patch.object(core, "EventData", old_db_schema.EventData), patch.object(core, "States", old_db_schema.States), patch.object(core, "Events", old_db_schema.Events), patch(CREATE_ENGINE_TARGET, new=_create_engine_test(SCHEMA_MODULE_32)), - patch("homeassistant.components.recorder.Recorder._post_migrate_entity_ids"), ): async with ( async_test_home_assistant() as hass, From 8fd691be6902ef97e0681a7c361c321f1f52fced Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 4 Sep 2024 09:52:41 +0200 Subject: [PATCH 0225/1309] Teach recorder data migrator base class to remove index (#125168) * Teach recorder data migrator base class to remove index * Fix tests --- .../components/recorder/migration.py | 49 +++++++++++++------ .../components/recorder/test_v32_migration.py | 3 ++ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index d2d8fff136e..242e503611c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2191,8 +2191,6 @@ class MigrationTask(RecorderTask): if not self.migrator.migrate_data(instance): # Schedule a new migration task if this one didn't finish instance.queue_task(MigrationTask(self.migrator)) - else: - self.migrator.migration_done(instance, None) @dataclass(slots=True) @@ -2213,6 +2211,7 @@ class NeedsMigrateResult: class BaseRunTimeMigration(ABC): """Base class for run time migrations.""" + index_to_drop: tuple[str, str] | None = None required_schema_version = 0 migration_version = 1 migration_id: str @@ -2230,11 +2229,29 @@ class BaseRunTimeMigration(ABC): else: self.migration_done(instance, session) + def migrate_data(self, instance: Recorder) -> bool: + """Migrate some data, returns True if migration is completed.""" + if result := self.migrate_data_impl(instance): + if self.index_to_drop is not None: + self._remove_index(instance, self.index_to_drop) + self.migration_done(instance, None) + return result + @staticmethod @abstractmethod - def migrate_data(instance: Recorder) -> bool: + def migrate_data_impl(instance: Recorder) -> bool: """Migrate some data, returns True if migration is completed.""" + @staticmethod + @database_job_retry_wrapper("remove index") + def _remove_index(instance: Recorder, index_to_drop: tuple[str, str]) -> None: + """Remove indices. + + Called when migration is completed. + """ + table, index = index_to_drop + _drop_index(instance.get_session, table, index) + def migration_done(self, instance: Recorder, session: Session | None) -> None: """Will be called after migrate returns True or if migration is not needed.""" @@ -2260,8 +2277,14 @@ class BaseRunTimeMigration(ABC): # The migration changes table indicates that the migration has been done return False # We do not know if the migration is done from the - # migration changes table so we must check the data + # migration changes table so we must check the index and data # This is the slow path + if ( + self.index_to_drop is not None + and get_index_by_name(session, self.index_to_drop[0], self.index_to_drop[1]) + is not None + ): + return True needs_migrate = self.needs_migrate_impl(instance, session) if needs_migrate.migration_done: _mark_migration_done(session, self.__class__) @@ -2290,10 +2313,11 @@ class StatesContextIDMigration(BaseRunTimeMigrationWithQuery): required_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION migration_id = "state_context_id_as_binary" + index_to_drop = ("states", "ix_states_context_id") @staticmethod @retryable_database_job("migrate states context_ids to binary format") - def migrate_data(instance: Recorder) -> bool: + def migrate_data_impl(instance: Recorder) -> bool: """Migrate states context_ids to use binary format, return True if completed.""" _to_bytes = _context_id_to_bytes session_maker = instance.get_session @@ -2323,9 +2347,6 @@ class StatesContextIDMigration(BaseRunTimeMigrationWithQuery): if is_done := not states: _mark_migration_done(session, StatesContextIDMigration) - if is_done: - _drop_index(session_maker, "states", "ix_states_context_id") - _LOGGER.debug("Migrating states context_ids to binary format: done=%s", is_done) return is_done @@ -2339,10 +2360,11 @@ class EventsContextIDMigration(BaseRunTimeMigrationWithQuery): required_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION migration_id = "event_context_id_as_binary" + index_to_drop = ("events", "ix_events_context_id") @staticmethod @retryable_database_job("migrate events context_ids to binary format") - def migrate_data(instance: Recorder) -> bool: + def migrate_data_impl(instance: Recorder) -> bool: """Migrate events context_ids to use binary format, return True if completed.""" _to_bytes = _context_id_to_bytes session_maker = instance.get_session @@ -2372,9 +2394,6 @@ class EventsContextIDMigration(BaseRunTimeMigrationWithQuery): if is_done := not events: _mark_migration_done(session, EventsContextIDMigration) - if is_done: - _drop_index(session_maker, "events", "ix_events_context_id") - _LOGGER.debug("Migrating events context_ids to binary format: done=%s", is_done) return is_done @@ -2395,7 +2414,7 @@ class EventTypeIDMigration(BaseRunTimeMigrationWithQuery): @staticmethod @retryable_database_job("migrate events event_types to event_type_ids") - def migrate_data(instance: Recorder) -> bool: + def migrate_data_impl(instance: Recorder) -> bool: """Migrate event_type to event_type_ids, return True if completed.""" session_maker = instance.get_session _LOGGER.debug("Migrating event_types") @@ -2478,7 +2497,7 @@ class EntityIDMigration(BaseRunTimeMigrationWithQuery): @staticmethod @retryable_database_job("migrate states entity_ids to states_meta") - def migrate_data(instance: Recorder) -> bool: + def migrate_data_impl(instance: Recorder) -> bool: """Migrate entity_ids to states_meta, return True if completed. We do this in two steps because we need the history queries to work @@ -2592,7 +2611,7 @@ class EventIDPostMigration(BaseRunTimeMigration): @staticmethod @retryable_database_job("cleanup_legacy_event_ids") - def migrate_data(instance: Recorder) -> bool: + def migrate_data_impl(instance: Recorder) -> bool: """Remove old event_id index from states, returns True if completed. We used to link states to events using the event_id column but we no diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 58bcabdff51..60f223aaa91 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -219,6 +219,7 @@ async def test_migrate_times( await hass.async_stop() +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migrate_can_resume_entity_id_post_migration( @@ -321,6 +322,7 @@ async def test_migrate_can_resume_entity_id_post_migration( await hass.async_stop() +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) @pytest.mark.parametrize("enable_migrate_event_ids", [True]) @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage @@ -625,6 +627,7 @@ async def test_out_of_disk_space_while_rebuild_states_table( @pytest.mark.usefixtures("skip_by_db_engine") @pytest.mark.skip_on_db_engine(["sqlite"]) +@pytest.mark.parametrize("enable_migrate_entity_ids", [True]) @pytest.mark.parametrize("enable_migrate_event_ids", [True]) @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage From 9da3f98c233a291ef43829d0c9773fef0750acd9 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 4 Sep 2024 11:00:02 +0200 Subject: [PATCH 0226/1309] Update knx-frontend to 2024.9.4.64538 (#125196) --- 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 b7efd14fa2a..181dca6f4b8 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.1.1", "xknxproject==3.7.1", - "knx-frontend==2024.8.9.225351" + "knx-frontend==2024.9.4.64538" ], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 5ca05016bf4..cdbbd294eaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1231,7 +1231,7 @@ kiwiki-client==0.1.1 knocki==0.3.1 # homeassistant.components.knx -knx-frontend==2024.8.9.225351 +knx-frontend==2024.9.4.64538 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae262c5d05a..c5363b13b40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1030,7 +1030,7 @@ kegtron-ble==0.4.0 knocki==0.3.1 # homeassistant.components.knx -knx-frontend==2024.8.9.225351 +knx-frontend==2024.9.4.64538 # homeassistant.components.konnected konnected==1.2.0 From daa5268cf21e3d9d0f79b41d0e3f05e0024587c7 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 4 Sep 2024 11:35:14 +0200 Subject: [PATCH 0227/1309] Update frontend to 20240904.0 (#125206) --- 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 7b904cba999..fbdafe6025d 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==20240903.1"] + "requirements": ["home-assistant-frontend==20240904.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ddb96da6bff..73f3452b259 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20240903.1 +home-assistant-frontend==20240904.0 home-assistant-intents==2024.8.29 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index cdbbd294eaa..67d53fb1d26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1108,7 +1108,7 @@ hole==0.8.0 holidays==0.56 # homeassistant.components.frontend -home-assistant-frontend==20240903.1 +home-assistant-frontend==20240904.0 # homeassistant.components.conversation home-assistant-intents==2024.8.29 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c5363b13b40..e21097c1a9d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -934,7 +934,7 @@ hole==0.8.0 holidays==0.56 # homeassistant.components.frontend -home-assistant-frontend==20240903.1 +home-assistant-frontend==20240904.0 # homeassistant.components.conversation home-assistant-intents==2024.8.29 From b26e4d672f5d3a6eb0f1a1636dfb5ea4147ff7fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Sep 2024 23:44:49 -1000 Subject: [PATCH 0228/1309] Bump yarl to 1.9.8 (#125193) changelog: https://github.com/aio-libs/yarl/compare/v1.9.7...v1.9.8 --- 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 73f3452b259..0eb5d6a78e0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ urllib3>=1.26.5,<2 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.9.7 +yarl==1.9.8 zeroconf==0.133.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 69d952f4bc0..2c8e0a432f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.9.7", + "yarl==1.9.8", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index fd6e8815e90..7f28e93cd4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,4 @@ urllib3>=1.26.5,<2 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.9.7 +yarl==1.9.8 From 38a1c97a51d9b99183334465ace03c65ccde5519 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 4 Sep 2024 11:46:41 +0200 Subject: [PATCH 0229/1309] Bump deebot-client to 8.4.0 (#125207) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 560ee4d599c..33977b3b0de 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.10", "deebot-client==8.3.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==8.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 67d53fb1d26..99721e57d61 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -715,7 +715,7 @@ debugpy==1.8.1 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==8.3.0 +deebot-client==8.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e21097c1a9d..c358c8e3445 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -605,7 +605,7 @@ dbus-fast==2.24.0 debugpy==1.8.1 # homeassistant.components.ecovacs -deebot-client==8.3.0 +deebot-client==8.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From fb5afff9d5a147f5b20953205df4bf5360e56e4c Mon Sep 17 00:00:00 2001 From: Lenn <78048721+LennP@users.noreply.github.com> Date: Wed, 4 Sep 2024 12:11:11 +0200 Subject: [PATCH 0230/1309] Add Motionblinds Bluetooth diagnostics (#121899) * Add diagnostics platform * Add diagnostics test * Remove comments * Exclude created_at and modified_at from snapshot * Fix entry_id in mock_config_entry * Add repr to excluded props from snapshot * Improve diagnostics * Use function name instead of number for callback diagnostics * Remove info from diagnostics * Reformat --- .../motionblinds_ble/diagnostics.py | 53 +++++++++++++++++++ tests/components/motionblinds_ble/conftest.py | 1 + .../snapshots/test_diagnostics.ambr | 34 ++++++++++++ .../motionblinds_ble/test_diagnostics.py | 27 ++++++++++ 4 files changed, 115 insertions(+) create mode 100644 homeassistant/components/motionblinds_ble/diagnostics.py create mode 100644 tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr create mode 100644 tests/components/motionblinds_ble/test_diagnostics.py diff --git a/homeassistant/components/motionblinds_ble/diagnostics.py b/homeassistant/components/motionblinds_ble/diagnostics.py new file mode 100644 index 00000000000..c76bef7c2f8 --- /dev/null +++ b/homeassistant/components/motionblinds_ble/diagnostics.py @@ -0,0 +1,53 @@ +"""Diagnostics support for Motionblinds Bluetooth.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from motionblindsble.device import MotionDevice + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +CONF_TITLE = "title" + +TO_REDACT: Iterable[Any] = { + # Config entry title and unique ID may contain sensitive data: + CONF_TITLE, + CONF_UNIQUE_ID, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + device: MotionDevice = hass.data[DOMAIN][entry.entry_id] + + return async_redact_data( + { + "entry": entry.as_dict(), + "device": { + "blind_type": device.blind_type.value, + "timezone": device.timezone, + "position": device._position, # noqa: SLF001 + "tilt": device._tilt, # noqa: SLF001 + "calibration_type": device._calibration_type.value # noqa: SLF001 + if device._calibration_type # noqa: SLF001 + else None, + "connection_type": device._connection_type.value, # noqa: SLF001 + "end_position_info": None + if not device._end_position_info # noqa: SLF001 + else { + "end_positions": device._end_position_info.end_positions.value, # noqa: SLF001 + "favorite": device._end_position_info.favorite_position, # noqa: SLF001 + }, + }, + }, + TO_REDACT, + ) diff --git a/tests/components/motionblinds_ble/conftest.py b/tests/components/motionblinds_ble/conftest.py index f89cf4f305d..ffd3bc5a2ab 100644 --- a/tests/components/motionblinds_ble/conftest.py +++ b/tests/components/motionblinds_ble/conftest.py @@ -109,6 +109,7 @@ def mock_config_entry( return MockConfigEntry( title="mock_title", domain=DOMAIN, + entry_id="mock_entry_id", unique_id=address, data={ CONF_ADDRESS: address, diff --git a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ccb5b1ed87b --- /dev/null +++ b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'device': dict({ + 'blind_type': 'Roller blind', + 'calibration_type': None, + 'connection_type': 'disconnected', + 'end_position_info': None, + 'position': None, + 'tilt': None, + 'timezone': None, + }), + 'entry': dict({ + 'data': dict({ + 'address': 'cc:cc:cc:cc:cc:cc', + 'blind_type': 'roller', + 'local_name': 'Motionblind CCCC', + 'mac_code': 'CCCC', + }), + 'disabled_by': None, + 'domain': 'motionblinds_ble', + 'entry_id': 'mock_entry_id', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': '**REDACTED**', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/motionblinds_ble/test_diagnostics.py b/tests/components/motionblinds_ble/test_diagnostics.py new file mode 100644 index 00000000000..878d2caa326 --- /dev/null +++ b/tests/components/motionblinds_ble/test_diagnostics.py @@ -0,0 +1,27 @@ +"""Test Motionblinds Bluetooth diagnostics.""" + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +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( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + 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(exclude=props("created_at", "modified_at", "repr")) From 4b111008df69012f078ff7f87dcb4a3959d70cd6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 4 Sep 2024 12:16:57 +0200 Subject: [PATCH 0231/1309] Add 100% coverage of Reolink button platform (#124380) * Add 100% button coverage * review comments * fix * Use SERVICE_PRESS constant * Use DOMAIN instead of const.DOMAIN * styling * User entity_registry_enabled_by_default fixture * fixes * Split out ptz_move test * use SERVICE_PTZ_MOVE constant --- homeassistant/components/reolink/button.py | 3 +- tests/components/reolink/conftest.py | 6 +- .../components/reolink/test_binary_sensor.py | 5 +- tests/components/reolink/test_button.py | 112 ++++++++++++++++++ tests/components/reolink/test_config_flow.py | 37 +++--- tests/components/reolink/test_init.py | 51 ++++---- tests/components/reolink/test_media_source.py | 7 +- 7 files changed, 164 insertions(+), 57 deletions(-) create mode 100644 tests/components/reolink/test_button.py diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index eba0570a3fb..3340cbad29a 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -37,6 +37,7 @@ from .entity import ( ATTR_SPEED = "speed" SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM +SERVICE_PTZ_MOVE = "ptz_move" @dataclass(frozen=True, kw_only=True) @@ -172,7 +173,7 @@ async def async_setup_entry( platform = async_get_current_platform() platform.async_register_entity_service( - "ptz_move", + SERVICE_PTZ_MOVE, {vol.Required(ATTR_SPEED): cv.positive_int}, "async_ptz_move", [SUPPORT_PTZ_SPEED], diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index b0f599c28fb..c14a5ee0c32 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -6,8 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.api import Chime -from homeassistant.components.reolink import const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL +from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -137,14 +137,14 @@ def reolink_platforms() -> Generator[None]: def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Add the reolink mock config entry to hass.""" config_entry = MockConfigEntry( - domain=const.DOMAIN, + domain=DOMAIN, unique_id=format_mac(TEST_MAC), data={ CONF_HOST: TEST_HOST, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 0872c3ab3b2..893e58a9512 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -4,7 +4,8 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL, const +from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL +from homeassistant.components.reolink.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant @@ -45,7 +46,7 @@ async def test_motion_sensor( # test webhook callback reolink_connect.motion_detected.return_value = True reolink_connect.ONVIF_event_callback.return_value = [0] - webhook_id = f"{const.DOMAIN}_{TEST_UID.replace(':', '')}_ONVIF" + webhook_id = f"{DOMAIN}_{TEST_UID.replace(':', '')}_ONVIF" client = await hass_client_no_auth() await client.post(f"/api/webhook/{webhook_id}", data="test_data") diff --git a/tests/components/reolink/test_button.py b/tests/components/reolink/test_button.py new file mode 100644 index 00000000000..7c91051c66e --- /dev/null +++ b/tests/components/reolink/test_button.py @@ -0,0 +1,112 @@ +"""Test the Reolink button platform.""" + +from unittest.mock import MagicMock, patch + +import pytest +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.reolink.button import ATTR_SPEED, SERVICE_PTZ_MOVE +from homeassistant.components.reolink.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import TEST_NVR_NAME + +from tests.common import MockConfigEntry + + +async def test_button( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test button entity with ptz up.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.BUTTON}.{TEST_NVR_NAME}_ptz_up" + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_ptz_command.assert_called_once() + + reolink_connect.set_ptz_command.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_ptz_move_service( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test ptz_move entity service using PTZ button entity.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.BUTTON}.{TEST_NVR_NAME}_ptz_up" + + await hass.services.async_call( + DOMAIN, + SERVICE_PTZ_MOVE, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 5}, + blocking=True, + ) + reolink_connect.set_ptz_command.assert_called_with(0, command="Up", speed=5) + + reolink_connect.set_ptz_command.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_PTZ_MOVE, + {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 5}, + blocking=True, + ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_host_button( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test host button entity with reboot.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.BUTTON}.{TEST_NVR_NAME}_restart" + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.reboot.assert_called_once() + + reolink_connect.reboot.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 2d55f62ec74..40695861aaf 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -10,8 +10,9 @@ from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkErr from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL, const +from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL +from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState @@ -50,7 +51,7 @@ async def test_config_flow_manual_success( ) -> None: """Successful flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM @@ -73,7 +74,7 @@ async def test_config_flow_manual_success( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -85,7 +86,7 @@ async def test_config_flow_errors( ) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM @@ -206,7 +207,7 @@ async def test_config_flow_errors( CONF_PASSWORD: TEST_PASSWORD, CONF_HOST: TEST_HOST, CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_USE_HTTPS: TEST_USE_HTTPS, }, ) @@ -217,7 +218,7 @@ async def test_config_flow_errors( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -227,14 +228,14 @@ async def test_config_flow_errors( async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test specifying non default settings using options flow.""" config_entry = MockConfigEntry( - domain=const.DOMAIN, + domain=DOMAIN, unique_id=format_mac(TEST_MAC), data={ CONF_HOST: TEST_HOST, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ CONF_PROTOCOL: "rtsp", @@ -267,14 +268,14 @@ async def test_change_connection_settings( ) -> None: """Test changing connection settings by issuing a second user config flow.""" config_entry = MockConfigEntry( - domain=const.DOMAIN, + domain=DOMAIN, unique_id=format_mac(TEST_MAC), data={ CONF_HOST: TEST_HOST, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -284,7 +285,7 @@ async def test_change_connection_settings( config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM @@ -310,14 +311,14 @@ async def test_change_connection_settings( async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reauth flow.""" config_entry = MockConfigEntry( - domain=const.DOMAIN, + domain=DOMAIN, unique_id=format_mac(TEST_MAC), data={ CONF_HOST: TEST_HOST, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -367,7 +368,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No ) result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data ) assert result["type"] is FlowResultType.FORM @@ -389,7 +390,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -442,14 +443,14 @@ async def test_dhcp_ip_update( ) -> None: """Test dhcp discovery aborts if already configured where the IP is updated if appropriate.""" config_entry = MockConfigEntry( - domain=const.DOMAIN, + domain=DOMAIN, unique_id=format_mac(TEST_MAC), data={ CONF_HOST: TEST_HOST, CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -479,7 +480,7 @@ async def test_dhcp_ip_update( setattr(reolink_connect, attr, value) result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data ) for host in host_call_list: diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index fd54f298966..765b3426249 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -13,8 +13,8 @@ from homeassistant.components.reolink import ( DEVICE_UPDATE_INTERVAL, FIRMWARE_UPDATE_INTERVAL, NUM_CRED_ERRORS, - const, ) +from homeassistant.components.reolink.const import DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform @@ -140,7 +140,7 @@ async def test_credential_error_three( reolink_connect.get_states.side_effect = CredentialsInvalidError("Test error") - issue_id = f"config_entry_reauth_{const.DOMAIN}_{config_entry.entry_id}" + issue_id = f"config_entry_reauth_{DOMAIN}_{config_entry.entry_id}" for _ in range(NUM_CRED_ERRORS): assert (HOMEASSISTANT_DOMAIN, issue_id) not in issue_registry.issues freezer.tick(DEVICE_UPDATE_INTERVAL) @@ -414,14 +414,14 @@ async def test_migrate_entity_ids( reolink_connect.supported = mock_supported dev_entry = device_registry.async_get_or_create( - identifiers={(const.DOMAIN, original_dev_id)}, + identifiers={(DOMAIN, original_dev_id)}, config_entry_id=config_entry.entry_id, disabled_by=None, ) entity_registry.async_get_or_create( domain=domain, - platform=const.DOMAIN, + platform=DOMAIN, unique_id=original_id, config_entry=config_entry, suggested_object_id=original_id, @@ -429,16 +429,13 @@ async def test_migrate_entity_ids( device_id=dev_entry.id, ) - assert entity_registry.async_get_entity_id(domain, const.DOMAIN, original_id) - assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) is None + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) + assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) is None - assert device_registry.async_get_device( - identifiers={(const.DOMAIN, original_dev_id)} - ) + assert device_registry.async_get_device(identifiers={(DOMAIN, original_dev_id)}) if new_dev_id != original_dev_id: assert ( - device_registry.async_get_device(identifiers={(const.DOMAIN, new_dev_id)}) - is None + device_registry.async_get_device(identifiers={(DOMAIN, new_dev_id)}) is None ) # setup CH 0 and host entities/device @@ -446,19 +443,15 @@ async def test_migrate_entity_ids( 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, const.DOMAIN, original_id) is None - ) - assert entity_registry.async_get_entity_id(domain, const.DOMAIN, new_id) + assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None + assert entity_registry.async_get_entity_id(domain, DOMAIN, new_id) if new_dev_id != original_dev_id: assert ( - device_registry.async_get_device( - identifiers={(const.DOMAIN, original_dev_id)} - ) + device_registry.async_get_device(identifiers={(DOMAIN, original_dev_id)}) is None ) - assert device_registry.async_get_device(identifiers={(const.DOMAIN, new_dev_id)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, new_dev_id)}) async def test_no_repair_issue( @@ -472,11 +465,11 @@ async def test_no_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert (const.DOMAIN, "https_webhook") not in issue_registry.issues - assert (const.DOMAIN, "webhook_url") not in issue_registry.issues - assert (const.DOMAIN, "enable_port") not in issue_registry.issues - assert (const.DOMAIN, "firmware_update") not in issue_registry.issues - assert (const.DOMAIN, "ssl") not in issue_registry.issues + assert (DOMAIN, "https_webhook") not in issue_registry.issues + assert (DOMAIN, "webhook_url") not in issue_registry.issues + assert (DOMAIN, "enable_port") not in issue_registry.issues + assert (DOMAIN, "firmware_update") not in issue_registry.issues + assert (DOMAIN, "ssl") not in issue_registry.issues async def test_https_repair_issue( @@ -503,7 +496,7 @@ async def test_https_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert (const.DOMAIN, "https_webhook") in issue_registry.issues + assert (DOMAIN, "https_webhook") in issue_registry.issues async def test_ssl_repair_issue( @@ -533,7 +526,7 @@ async def test_ssl_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert (const.DOMAIN, "ssl") in issue_registry.issues + assert (DOMAIN, "ssl") in issue_registry.issues @pytest.mark.parametrize("protocol", ["rtsp", "rtmp"]) @@ -553,7 +546,7 @@ async def test_port_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert (const.DOMAIN, "enable_port") in issue_registry.issues + assert (DOMAIN, "enable_port") in issue_registry.issues async def test_webhook_repair_issue( @@ -576,7 +569,7 @@ async def test_webhook_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert (const.DOMAIN, "webhook_url") in issue_registry.issues + assert (DOMAIN, "webhook_url") in issue_registry.issues async def test_firmware_repair_issue( @@ -590,4 +583,4 @@ async def test_firmware_repair_issue( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert (const.DOMAIN, "firmware_update_host") in issue_registry.issues + assert (DOMAIN, "firmware_update_host") in issue_registry.issues diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 31985bd10f7..6351f683545 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -14,9 +14,8 @@ from homeassistant.components.media_source import ( async_resolve_media, ) from homeassistant.components.media_source.error import Unresolvable -from homeassistant.components.reolink import const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN from homeassistant.const import ( CONF_HOST, @@ -321,14 +320,14 @@ async def test_browsing_not_loaded( reolink_connect.get_host_data.side_effect = ReolinkError("Test error") config_entry2 = MockConfigEntry( - domain=const.DOMAIN, + domain=DOMAIN, unique_id=format_mac(TEST_MAC2), data={ CONF_HOST: TEST_HOST2, CONF_USERNAME: TEST_USERNAME2, CONF_PASSWORD: TEST_PASSWORD2, CONF_PORT: TEST_PORT, - const.CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, From b5d7eba4f680ed3fdb4ab74fe3ac7689138ca9c2 Mon Sep 17 00:00:00 2001 From: Hessel Date: Wed, 4 Sep 2024 14:00:38 +0200 Subject: [PATCH 0232/1309] Add new number component for setting the wallbox ICP current (#125209) * Add new number component for setting the wallbox ICP current * feat: Add number component for wallbox ICP current control --- homeassistant/components/wallbox/const.py | 4 + .../components/wallbox/coordinator.py | 29 +++++ homeassistant/components/wallbox/number.py | 11 ++ homeassistant/components/wallbox/sensor.py | 8 ++ homeassistant/components/wallbox/strings.json | 6 + tests/components/wallbox/__init__.py | 8 ++ tests/components/wallbox/const.py | 1 + tests/components/wallbox/test_number.py | 105 +++++++++++++++++- 8 files changed, 171 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 69633cbda22..c38b8967776 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -22,11 +22,15 @@ CHARGER_CURRENCY_KEY = "currency" CHARGER_DATA_KEY = "config_data" CHARGER_DEPOT_PRICE_KEY = "depot_price" CHARGER_ENERGY_PRICE_KEY = "energy_price" +CHARGER_FEATURES_KEY = "features" CHARGER_SERIAL_NUMBER_KEY = "serial_number" CHARGER_PART_NUMBER_KEY = "part_number" +CHARGER_PLAN_KEY = "plan" +CHARGER_POWER_BOOST_KEY = "POWER_BOOST" CHARGER_SOFTWARE_KEY = "software" CHARGER_MAX_AVAILABLE_POWER_KEY = "max_available_power" CHARGER_MAX_CHARGING_CURRENT_KEY = "max_charging_current" +CHARGER_MAX_ICP_CURRENT_KEY = "icp_max_current" CHARGER_PAUSE_RESUME_KEY = "paused" CHARGER_LOCKED_UNLOCKED_KEY = "locked" CHARGER_NAME_KEY = "name" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index e24ccd28440..f3679551bc4 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -19,8 +19,12 @@ from .const import ( CHARGER_CURRENCY_KEY, CHARGER_DATA_KEY, CHARGER_ENERGY_PRICE_KEY, + CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_ICP_CURRENT_KEY, + CHARGER_PLAN_KEY, + CHARGER_POWER_BOOST_KEY, CHARGER_STATUS_DESCRIPTION_KEY, CHARGER_STATUS_ID_KEY, CODE_KEY, @@ -130,6 +134,16 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ CHARGER_ENERGY_PRICE_KEY ] + # Only show max_icp_current if power_boost is available in the wallbox unit: + if ( + data[CHARGER_DATA_KEY].get(CHARGER_MAX_ICP_CURRENT_KEY, 0) > 0 + and CHARGER_POWER_BOOST_KEY + in data[CHARGER_DATA_KEY][CHARGER_PLAN_KEY][CHARGER_FEATURES_KEY] + ): + data[CHARGER_MAX_ICP_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_ICP_CURRENT_KEY + ] + data[CHARGER_CURRENCY_KEY] = ( f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" ) @@ -160,6 +174,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) await self.async_request_refresh() + @_require_authentication + def _set_icp_current(self, icp_current: float) -> None: + """Set maximum icp current for Wallbox.""" + try: + self._wallbox.setIcpMaxCurrent(self._station, icp_current) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise + + async def async_set_icp_current(self, icp_current: float) -> None: + """Set maximum icp current for Wallbox.""" + await self.hass.async_add_executor_job(self._set_icp_current, icp_current) + await self.async_request_refresh() + @_require_authentication def _set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 8ae4c473299..24cdd16f99d 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -21,6 +21,7 @@ from .const import ( CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_AVAILABLE_POWER_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_ICP_CURRENT_KEY, CHARGER_PART_NUMBER_KEY, CHARGER_SERIAL_NUMBER_KEY, DOMAIN, @@ -67,6 +68,16 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { set_value_fn=lambda coordinator: coordinator.async_set_energy_cost, native_step=0.01, ), + CHARGER_MAX_ICP_CURRENT_KEY: WallboxNumberEntityDescription( + key=CHARGER_MAX_ICP_CURRENT_KEY, + translation_key="maximum_icp_current", + max_value_fn=lambda coordinator: cast( + float, coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] + ), + min_value_fn=lambda _: 6, + set_value_fn=lambda coordinator: coordinator.async_set_icp_current, + native_step=1, + ), } diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index eadbc04dca2..18d8afb5612 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -38,6 +38,7 @@ from .const import ( CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_AVAILABLE_POWER_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_ICP_CURRENT_KEY, CHARGER_SERIAL_NUMBER_KEY, CHARGER_STATE_OF_CHARGE_KEY, CHARGER_STATUS_DESCRIPTION_KEY, @@ -145,6 +146,13 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), + CHARGER_MAX_ICP_CURRENT_KEY: WallboxSensorEntityDescription( + key=CHARGER_MAX_ICP_CURRENT_KEY, + translation_key=CHARGER_MAX_ICP_CURRENT_KEY, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), } diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index dd96cebf605..f4378b328d8 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -38,6 +38,9 @@ }, "energy_price": { "name": "Energy price" + }, + "maximum_icp_current": { + "name": "Maximum ICP current" } }, "sensor": { @@ -79,6 +82,9 @@ }, "max_charging_current": { "name": "Max charging current" + }, + "icp_max_current": { + "name": "Max ICP current" } }, "switch": { diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index f21e895b3a7..f4258ea0d49 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -14,11 +14,15 @@ from homeassistant.components.wallbox.const import ( CHARGER_CURRENT_VERSION_KEY, CHARGER_DATA_KEY, CHARGER_ENERGY_PRICE_KEY, + CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_AVAILABLE_POWER_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_ICP_CURRENT_KEY, CHARGER_NAME_KEY, CHARGER_PART_NUMBER_KEY, + CHARGER_PLAN_KEY, + CHARGER_POWER_BOOST_KEY, CHARGER_SERIAL_NUMBER_KEY, CHARGER_SOFTWARE_KEY, CHARGER_STATUS_ID_KEY, @@ -45,6 +49,8 @@ test_response = { CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, }, } @@ -64,6 +70,8 @@ test_response_bidir = { CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E", CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, }, } diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index 452b3af0af8..a86ae9fc3b9 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -9,6 +9,7 @@ STATUS = "status" MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current" MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID = "number.wallbox_wallboxname_energy_price" +MOCK_NUMBER_ENTITY_ICP_CURRENT_ID = "number.wallbox_wallboxname_maximum_icp_current" MOCK_LOCK_ENTITY_ID = "lock.wallbox_wallboxname_lock" MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.wallbox_wallboxname_charging_speed" MOCK_SENSOR_CHARGING_POWER_ID = "sensor.wallbox_wallboxname_charging_power" diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 5d782224ce5..0a8b1aa1207 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -6,9 +6,12 @@ import pytest import requests_mock from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN +from homeassistant.components.wallbox import InvalidAuth from homeassistant.components.wallbox.const import ( CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_ICP_CURRENT_KEY, ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -20,7 +23,11 @@ from . import ( setup_integration_bidir, setup_integration_platform_not_ready, ) -from .const import MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, MOCK_NUMBER_ENTITY_ID +from .const import ( + MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + MOCK_NUMBER_ENTITY_ID, +) from tests.common import MockConfigEntry @@ -212,3 +219,99 @@ async def test_wallbox_number_class_platform_not_ready( assert state is None await hass.config_entries.async_unload(entry.entry_id) + + +async def test_wallbox_number_class_icp_energy( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, + status_code=200, + ) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_wallbox_number_class_icp_energy_auth_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, + status_code=403, + ) + + with pytest.raises(InvalidAuth): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_wallbox_number_class_icp_energy_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, + status_code=404, + ) + + with pytest.raises(ConnectionError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) From a1ecefee2104d2f40b42aa3b8033a55b6a9a420e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Sep 2024 02:35:52 -1000 Subject: [PATCH 0233/1309] Bump aioesphomeapi to 25.3.2 (#125188) changelog: https://github.com/esphome/aioesphomeapi/compare/v25.3.1...v25.3.2 --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9d42b7206e3..233015b13ba 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==25.3.1", + "aioesphomeapi==25.3.2", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.0.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 99721e57d61..35dd9071f13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==25.3.1 +aioesphomeapi==25.3.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c358c8e3445..8940fd0649c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -225,7 +225,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==25.3.1 +aioesphomeapi==25.3.2 # homeassistant.components.flo aioflo==2021.11.0 From 5c35ccb9caf44f7b215db17cc23f161f27475c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20J=C3=A1l?= Date: Wed, 4 Sep 2024 15:03:59 +0200 Subject: [PATCH 0234/1309] Allow Switchbot users to force nightlatch (#124326) * Add option to force nightlatch operation mode * Fix format * Make the new option available only for lock pro entry * use senor_type instead of switchbot model + tests * Update homeassistant/components/switchbot/lock.py --------- Co-authored-by: Joost Lekkerkerker --- .../components/switchbot/config_flow.py | 16 ++++- homeassistant/components/switchbot/const.py | 2 + homeassistant/components/switchbot/lock.py | 12 ++-- .../components/switchbot/strings.json | 3 +- .../components/switchbot/test_config_flow.py | 63 +++++++++++++++++++ 5 files changed, 90 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index a1c947fd611..0468db5618a 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -38,13 +38,16 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( CONF_ENCRYPTION_KEY, CONF_KEY_ID, + CONF_LOCK_NIGHTLATCH, CONF_RETRY_COUNT, CONNECTABLE_SUPPORTED_MODEL_TYPES, + DEFAULT_LOCK_NIGHTLATCH, DEFAULT_RETRY_COUNT, DOMAIN, NON_CONNECTABLE_SUPPORTED_MODEL_TYPES, SUPPORTED_LOCK_MODELS, SUPPORTED_MODEL_TYPES, + SupportedModels, ) _LOGGER = logging.getLogger(__name__) @@ -355,7 +358,7 @@ class SwitchbotOptionsFlowHandler(OptionsFlow): # Update common entity options for all other entities. return self.async_create_entry(title="", data=user_input) - options = { + options: dict[vol.Optional, Any] = { vol.Optional( CONF_RETRY_COUNT, default=self.config_entry.options.get( @@ -363,5 +366,16 @@ class SwitchbotOptionsFlowHandler(OptionsFlow): ), ): int } + if self.config_entry.data.get(CONF_SENSOR_TYPE) == SupportedModels.LOCK_PRO: + options.update( + { + vol.Optional( + CONF_LOCK_NIGHTLATCH, + default=self.config_entry.options.get( + CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH + ), + ): bool + } + ) return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 0a1ac01e530..bd727edfea4 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -64,11 +64,13 @@ HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { # Config Defaults DEFAULT_RETRY_COUNT = 3 +DEFAULT_LOCK_NIGHTLATCH = False # Config Options CONF_RETRY_COUNT = "retry_count" CONF_KEY_ID = "key_id" CONF_ENCRYPTION_KEY = "encryption_key" +CONF_LOCK_NIGHTLATCH = "lock_force_nightlatch" # Deprecated config Entry Options to be removed in 2023.4 CONF_TIME_BETWEEN_UPDATE_COMMAND = "update_time" diff --git a/homeassistant/components/switchbot/lock.py b/homeassistant/components/switchbot/lock.py index cb41d14cf66..a3bee5661b2 100644 --- a/homeassistant/components/switchbot/lock.py +++ b/homeassistant/components/switchbot/lock.py @@ -9,6 +9,7 @@ from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator from .entity import SwitchbotEntity @@ -19,7 +20,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Switchbot lock based on a config entry.""" - async_add_entities([(SwitchBotLock(entry.runtime_data))]) + force_nightlatch = entry.options.get(CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH) + async_add_entities([SwitchBotLock(entry.runtime_data, force_nightlatch)]) # noinspection PyAbstractClass @@ -30,11 +32,13 @@ class SwitchBotLock(SwitchbotEntity, LockEntity): _attr_name = None _device: switchbot.SwitchbotLock - def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + def __init__( + self, coordinator: SwitchbotDataUpdateCoordinator, force_nightlatch + ) -> None: """Initialize the entity.""" super().__init__(coordinator) self._async_update_attrs() - if self._device.is_night_latch_enabled(): + if self._device.is_night_latch_enabled() or force_nightlatch: self._attr_supported_features = LockEntityFeature.OPEN def _async_update_attrs(self) -> None: @@ -55,7 +59,7 @@ class SwitchBotLock(SwitchbotEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - if self._device.is_night_latch_enabled(): + if self._attr_supported_features & (LockEntityFeature.OPEN): self._last_run_success = await self._device.unlock_without_unlatch() else: self._last_run_success = await self._device.unlock() diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index a20b4939f8f..80ca32d4826 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -54,7 +54,8 @@ "step": { "init": { "data": { - "retry_count": "Retry count" + "retry_count": "Retry count", + "lock_force_nightlatch": "Force Nightlatch operation mode" } } } diff --git a/tests/components/switchbot/test_config_flow.py b/tests/components/switchbot/test_config_flow.py index 182e9457f22..b0fba2a5f18 100644 --- a/tests/components/switchbot/test_config_flow.py +++ b/tests/components/switchbot/test_config_flow.py @@ -7,6 +7,7 @@ from switchbot import SwitchbotAccountConnectionError, SwitchbotAuthenticationEr from homeassistant.components.switchbot.const import ( CONF_ENCRYPTION_KEY, CONF_KEY_ID, + CONF_LOCK_NIGHTLATCH, CONF_RETRY_COUNT, ) from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER @@ -782,3 +783,65 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 assert entry.options[CONF_RETRY_COUNT] == 6 + + +async def test_options_flow_lock_pro(hass: HomeAssistant) -> None: + """Test updating options.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_NAME: "test-name", + CONF_PASSWORD: "test-password", + CONF_SENSOR_TYPE: "lock_pro", + }, + options={CONF_RETRY_COUNT: 10}, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + # Test Force night_latch should be disabled by default. + with patch_async_setup_entry() as mock_setup_entry: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] is None + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_RETRY_COUNT: 3, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_LOCK_NIGHTLATCH] is False + + assert len(mock_setup_entry.mock_calls) == 1 + + # Test Set force night_latch to be enabled. + + with patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] is None + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_LOCK_NIGHTLATCH: True, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_LOCK_NIGHTLATCH] is True + + assert len(mock_setup_entry.mock_calls) == 0 + + assert entry.options[CONF_LOCK_NIGHTLATCH] is True From 1bc63a61be8057850f68e0ff4e0c94563d5a41c9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 4 Sep 2024 15:05:28 +0200 Subject: [PATCH 0235/1309] Fix enum lookup (#125220) --- homeassistant/components/google_cloud/tts.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index d65a743c015..60cdfbee3ab 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -172,12 +172,10 @@ class BaseGoogleCloudProvider: _LOGGER.error("Error: %s when validating options: %s", err, options) return None, None - encoding: texttospeech.AudioEncoding = texttospeech.AudioEncoding[ - options[CONF_ENCODING] - ] # type: ignore[misc] - gender: texttospeech.SsmlVoiceGender | None = texttospeech.SsmlVoiceGender[ + encoding = texttospeech.AudioEncoding(options[CONF_ENCODING]) + gender: texttospeech.SsmlVoiceGender | None = texttospeech.SsmlVoiceGender( options[CONF_GENDER] - ] # type: ignore[misc] + ) voice = options[CONF_VOICE] if voice: gender = None From 4d96ed4c686d83411b622119f4f137873c151218 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 4 Sep 2024 15:05:51 +0200 Subject: [PATCH 0236/1309] Update modified_at datetime on storage collection changes (#125218) --- homeassistant/helpers/collection.py | 37 +++++++++++-- tests/helpers/test_collection.py | 81 +++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 9151a9dfc6b..86d3450c3a0 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -7,6 +7,7 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine, Iterable from dataclasses import dataclass from functools import partial +from hashlib import md5 from itertools import groupby import logging from operator import attrgetter @@ -25,6 +26,7 @@ from homeassistant.util import slugify from . import entity_registry from .entity import Entity from .entity_component import EntityComponent +from .json import json_bytes from .storage import Store from .typing import ConfigType, VolDictType @@ -50,6 +52,7 @@ class CollectionChange: change_type: str item_id: str item: Any + item_hash: str | None = None type ChangeListener = Callable[ @@ -273,7 +276,9 @@ class StorageCollection[_ItemT, _StoreT: SerializedStorageCollection]( await self.notify_changes( [ - CollectionChange(CHANGE_ADDED, item[CONF_ID], item) + CollectionChange( + CHANGE_ADDED, item[CONF_ID], item, self._hash_item(item) + ) for item in raw_storage["items"] ] ) @@ -313,7 +318,16 @@ class StorageCollection[_ItemT, _StoreT: SerializedStorageCollection]( item = self._create_item(item_id, validated_data) self.data[item_id] = item self._async_schedule_save() - await self.notify_changes([CollectionChange(CHANGE_ADDED, item_id, item)]) + await self.notify_changes( + [ + CollectionChange( + CHANGE_ADDED, + item_id, + item, + self._hash_item(self._serialize_item(item_id, item)), + ) + ] + ) return item async def async_update_item(self, item_id: str, updates: dict) -> _ItemT: @@ -331,7 +345,16 @@ class StorageCollection[_ItemT, _StoreT: SerializedStorageCollection]( self.data[item_id] = updated self._async_schedule_save() - await self.notify_changes([CollectionChange(CHANGE_UPDATED, item_id, updated)]) + await self.notify_changes( + [ + CollectionChange( + CHANGE_UPDATED, + item_id, + updated, + self._hash_item(self._serialize_item(item_id, updated)), + ) + ] + ) return self.data[item_id] @@ -365,6 +388,10 @@ class StorageCollection[_ItemT, _StoreT: SerializedStorageCollection]( def _data_to_save(self) -> _StoreT: """Return JSON-compatible date for storing to file.""" + def _hash_item(self, item: dict) -> str: + """Return a hash of the item.""" + return md5(json_bytes(item)).hexdigest() + class DictStorageCollection(StorageCollection[dict, SerializedStorageCollection]): """A specialized StorageCollection where the items are untyped dicts.""" @@ -464,6 +491,10 @@ class _CollectionLifeCycle(Generic[_EntityT]): async def _update_entity(self, change_set: CollectionChange) -> None: if entity := self.entities.get(change_set.item_id): + if change_set.item_hash: + self.ent_reg.async_update_entity_options( + entity.entity_id, "collection", {"hash": change_set.item_hash} + ) await entity.async_update_config(change_set.item) async def _collection_changed(self, change_set: Iterable[CollectionChange]) -> None: diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index f0287218d7f..f564f85ec3b 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -2,8 +2,10 @@ from __future__ import annotations +from datetime import timedelta import logging +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -15,6 +17,7 @@ from homeassistant.helpers import ( storage, ) from homeassistant.helpers.typing import ConfigType +from homeassistant.util.dt import utcnow from tests.common import flush_store from tests.typing import WebSocketGenerator @@ -254,6 +257,84 @@ async def test_storage_collection(hass: HomeAssistant) -> None: } +async def test_storage_collection_update_modifiet_at( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that updating a storage collection will update the modified_at datetime in the entity registry.""" + + entities: dict[str, TestEntity] = {} + + class TestEntity(MockEntity): + """Entity that is config based.""" + + def __init__(self, config: ConfigType) -> None: + """Initialize entity.""" + super().__init__(config) + self._state = "initial" + + @classmethod + def from_storage(cls, config: ConfigType) -> TestEntity: + """Create instance from storage.""" + obj = super().from_storage(config) + entities[obj.unique_id] = obj + return obj + + @property + def state(self) -> str: + """Return state of entity.""" + return self._state + + def set_state(self, value: str) -> None: + """Set value.""" + self._state = value + self.async_write_ha_state() + + store = storage.Store(hass, 1, "test-data") + data = {"id": "mock-1", "name": "Mock 1", "data": 1} + await store.async_save( + { + "items": [ + data, + ] + } + ) + id_manager = collection.IDManager() + ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) + await ent_comp.async_setup({}) + coll = MockStorageCollection(store, id_manager) + collection.sync_entity_lifecycle(hass, "test", "test", ent_comp, coll, TestEntity) + changes = track_changes(coll) + + await coll.async_load() + assert id_manager.has_id("mock-1") + assert len(changes) == 1 + assert changes[0] == (collection.CHANGE_ADDED, "mock-1", data) + + modified_1 = entity_registry.async_get("test.mock_1").modified_at + assert modified_1 == utcnow() + + freezer.tick(timedelta(minutes=1)) + + updated_item = await coll.async_update_item("mock-1", {"data": 2}) + assert id_manager.has_id("mock-1") + assert updated_item == {"id": "mock-1", "name": "Mock 1", "data": 2} + assert len(changes) == 2 + assert changes[1] == (collection.CHANGE_UPDATED, "mock-1", updated_item) + + modified_2 = entity_registry.async_get("test.mock_1").modified_at + assert modified_2 > modified_1 + assert modified_2 == utcnow() + + freezer.tick(timedelta(minutes=1)) + + entities["mock-1"].set_state("second") + + modified_3 = entity_registry.async_get("test.mock_1").modified_at + assert modified_3 == modified_2 + + async def test_attach_entity_component_collection(hass: HomeAssistant) -> None: """Test attaching collection to entity component.""" ent_comp = entity_component.EntityComponent(_LOGGER, "test", hass) From da0d1b71ced0d15898131038395ec67238ee914d Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 4 Sep 2024 16:30:28 +0300 Subject: [PATCH 0237/1309] Update Anthropic default model to Haiku (#125225) --- homeassistant/components/anthropic/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 4ccf2c88faa..0dbf9c51ac1 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -8,7 +8,7 @@ LOGGER = logging.getLogger(__package__) CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "claude-3-5-sonnet-20240620" +RECOMMENDED_CHAT_MODEL = "claude-3-haiku-20240307" CONF_MAX_TOKENS = "max_tokens" RECOMMENDED_MAX_TOKENS = 1024 CONF_TEMPERATURE = "temperature" From b557e9e8265cbe5c8220cdd4302ef13385be5525 Mon Sep 17 00:00:00 2001 From: Iskra kranj <162285659+iskrakranj@users.noreply.github.com> Date: Wed, 4 Sep 2024 15:33:23 +0200 Subject: [PATCH 0238/1309] Add Iskra integration (#121488) * Add iskra integration * iskra non resettable counters naming fix * added iskra config_flow test * fixed iskra integration according to code review * changed iskra config flow test * iskra integration, fixed codeowners * Removed counters code & minor fixes * added comment * Update homeassistant/components/iskra/__init__.py Co-authored-by: Joost Lekkerkerker * Updated Iskra integration according to review * Update homeassistant/components/iskra/strings.json Co-authored-by: Joost Lekkerkerker * Updated iskra integration according to review * minor iskra integration change * iskra integration changes according to review * iskra integration changes according to review * Changed iskra integration according to review * added iskra config_flow range validation * Fixed tests for iskra integration * Update homeassistant/components/iskra/coordinator.py * Update homeassistant/components/iskra/config_flow.py Co-authored-by: Joost Lekkerkerker * Fixed iskra integration according to review * Changed voluptuous schema for iskra integration and added data_descriptions * Iskra integration tests lint error fix --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/iskra/__init__.py | 100 ++++++ homeassistant/components/iskra/config_flow.py | 253 +++++++++++++++ homeassistant/components/iskra/const.py | 25 ++ homeassistant/components/iskra/coordinator.py | 57 ++++ homeassistant/components/iskra/entity.py | 38 +++ homeassistant/components/iskra/manifest.json | 11 + homeassistant/components/iskra/sensor.py | 229 +++++++++++++ homeassistant/components/iskra/strings.json | 92 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/iskra/__init__.py | 1 + tests/components/iskra/conftest.py | 46 +++ tests/components/iskra/const.py | 10 + tests/components/iskra/test_config_flow.py | 300 ++++++++++++++++++ 17 files changed, 1177 insertions(+) create mode 100644 homeassistant/components/iskra/__init__.py create mode 100644 homeassistant/components/iskra/config_flow.py create mode 100644 homeassistant/components/iskra/const.py create mode 100644 homeassistant/components/iskra/coordinator.py create mode 100644 homeassistant/components/iskra/entity.py create mode 100644 homeassistant/components/iskra/manifest.json create mode 100644 homeassistant/components/iskra/sensor.py create mode 100644 homeassistant/components/iskra/strings.json create mode 100644 tests/components/iskra/__init__.py create mode 100644 tests/components/iskra/conftest.py create mode 100644 tests/components/iskra/const.py create mode 100644 tests/components/iskra/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 596795d4221..42d96ceb941 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -728,6 +728,8 @@ build.json @home-assistant/supervisor /tests/components/iron_os/ @tr4nt0r /homeassistant/components/isal/ @bdraco /tests/components/isal/ @bdraco +/homeassistant/components/iskra/ @iskramis +/tests/components/iskra/ @iskramis /homeassistant/components/islamic_prayer_times/ @engrbm87 @cpfair /tests/components/islamic_prayer_times/ @engrbm87 @cpfair /homeassistant/components/israel_rail/ @shaiu diff --git a/homeassistant/components/iskra/__init__.py b/homeassistant/components/iskra/__init__.py new file mode 100644 index 00000000000..b841da9df26 --- /dev/null +++ b/homeassistant/components/iskra/__init__.py @@ -0,0 +1,100 @@ +"""The iskra integration.""" + +from __future__ import annotations + +from pyiskra.adapters import Modbus, RestAPI +from pyiskra.devices import Device +from pyiskra.exceptions import DeviceConnectionError, DeviceNotSupported, NotAuthorised + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_ADDRESS, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN, MANUFACTURER +from .coordinator import IskraDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +type IskraConfigEntry = ConfigEntry[list[IskraDataUpdateCoordinator]] + + +async def async_setup_entry(hass: HomeAssistant, entry: IskraConfigEntry) -> bool: + """Set up iskra device from a config entry.""" + conf = entry.data + adapter = None + + if conf[CONF_PROTOCOL] == "modbus_tcp": + adapter = Modbus( + ip_address=conf[CONF_HOST], + protocol="tcp", + port=conf[CONF_PORT], + modbus_address=conf[CONF_ADDRESS], + ) + elif conf[CONF_PROTOCOL] == "rest_api": + authentication = None + if (username := conf.get(CONF_USERNAME)) is not None and ( + password := conf.get(CONF_PASSWORD) + ) is not None: + authentication = { + "username": username, + "password": password, + } + adapter = RestAPI(ip_address=conf[CONF_HOST], authentication=authentication) + + # Try connecting to the device and create pyiskra device object + try: + base_device = await Device.create_device(adapter) + except DeviceConnectionError as e: + raise ConfigEntryNotReady("Cannot connect to the device") from e + except NotAuthorised as e: + raise ConfigEntryNotReady("Not authorised to connect to the device") from e + except DeviceNotSupported as e: + raise ConfigEntryNotReady("Device not supported") from e + + # Initialize the device + await base_device.init() + + # if the device is a gateway, add all child devices, otherwise add the device itself. + if base_device.is_gateway: + # Add the gateway device to the device registry + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, base_device.serial)}, + manufacturer=MANUFACTURER, + name=base_device.model, + model=base_device.model, + sw_version=base_device.fw_version, + ) + + coordinators = [ + IskraDataUpdateCoordinator(hass, child_device) + for child_device in base_device.get_child_devices() + ] + else: + coordinators = [IskraDataUpdateCoordinator(hass, base_device)] + + for coordinator in coordinators: + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinators + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: IskraConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/iskra/config_flow.py b/homeassistant/components/iskra/config_flow.py new file mode 100644 index 00000000000..b67b9ba3839 --- /dev/null +++ b/homeassistant/components/iskra/config_flow.py @@ -0,0 +1,253 @@ +"""Config flow for iskra integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyiskra.adapters import Modbus, RestAPI +from pyiskra.exceptions import ( + DeviceConnectionError, + DeviceTimeoutError, + InvalidResponseCode, + NotAuthorised, +) +from pyiskra.helper import BasicInfo +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_ADDRESS, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PROTOCOL, default="rest_api"): SelectSelector( + SelectSelectorConfig( + options=["rest_api", "modbus_tcp"], + mode=SelectSelectorMode.LIST, + translation_key="protocol", + ), + ), + } +) + +STEP_AUTHENTICATION_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + +# CONF_ADDRESS validation is done later in code, as if ranges are set in voluptuous it turns into a slider +STEP_MODBUS_TCP_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_PORT, default=10001): vol.All( + vol.Coerce(int), vol.Range(min=0, max=65535) + ), + vol.Required(CONF_ADDRESS, default=33): NumberSelector( + NumberSelectorConfig(min=1, max=255, mode=NumberSelectorMode.BOX) + ), + } +) + + +async def test_rest_api_connection(host: str, user_input: dict[str, Any]) -> BasicInfo: + """Check if the RestAPI requires authentication.""" + + rest_api = RestAPI(ip_address=host, authentication=user_input) + try: + basic_info = await rest_api.get_basic_info() + except NotAuthorised as e: + raise NotAuthorised from e + except (DeviceConnectionError, DeviceTimeoutError, InvalidResponseCode) as e: + raise CannotConnect from e + except Exception as e: + _LOGGER.error("Unexpected exception: %s", e) + raise UnknownException from e + + return basic_info + + +async def test_modbus_connection(host: str, user_input: dict[str, Any]) -> BasicInfo: + """Test the Modbus connection.""" + modbus_api = Modbus( + ip_address=host, + protocol="tcp", + port=user_input[CONF_PORT], + modbus_address=user_input[CONF_ADDRESS], + ) + + try: + basic_info = await modbus_api.get_basic_info() + except NotAuthorised as e: + raise NotAuthorised from e + except (DeviceConnectionError, DeviceTimeoutError, InvalidResponseCode) as e: + raise CannotConnect from e + except Exception as e: + _LOGGER.error("Unexpected exception: %s", e) + raise UnknownException from e + + return basic_info + + +class IskraConfigFlowFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for iskra.""" + + VERSION = 1 + host: str + protocol: str + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + self.host = user_input[CONF_HOST] + self.protocol = user_input[CONF_PROTOCOL] + if self.protocol == "rest_api": + # Check if authentication is required. + try: + device_info = await test_rest_api_connection(self.host, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except NotAuthorised: + # Proceed to authentication step. + return await self.async_step_authentication() + except UnknownException: + errors["base"] = "unknown" + # If the connection was not successful, show an error. + + # If the connection was successful, create the device. + if not errors: + return await self._create_entry( + host=self.host, + protocol=self.protocol, + device_info=device_info, + user_input=user_input, + ) + + if self.protocol == "modbus_tcp": + # Proceed to modbus step. + return await self.async_step_modbus_tcp() + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_authentication( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the authentication step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + device_info = await test_rest_api_connection(self.host, user_input) + # If the connection failed, abort. + except CannotConnect: + errors["base"] = "cannot_connect" + # If the authentication failed, show an error and authentication form again. + except NotAuthorised: + errors["base"] = "invalid_auth" + except UnknownException: + errors["base"] = "unknown" + + # if the connection was successful, create the device. + if not errors: + return await self._create_entry( + self.host, + self.protocol, + device_info=device_info, + user_input=user_input, + ) + + # If there's no user_input or there was an error, show the authentication form again. + return self.async_show_form( + step_id="authentication", + data_schema=STEP_AUTHENTICATION_DATA_SCHEMA, + errors=errors, + ) + + async def async_step_modbus_tcp( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the Modbus TCP step.""" + errors: dict[str, str] = {} + + # If there's user_input, check the connection. + if user_input is not None: + # convert to integer + user_input[CONF_ADDRESS] = int(user_input[CONF_ADDRESS]) + + try: + device_info = await test_modbus_connection(self.host, user_input) + + # If the connection failed, show an error. + except CannotConnect: + errors["base"] = "cannot_connect" + except UnknownException: + errors["base"] = "unknown" + + # If the connection was successful, create the device. + if not errors: + return await self._create_entry( + host=self.host, + protocol=self.protocol, + device_info=device_info, + user_input=user_input, + ) + + # If there's no user_input or there was an error, show the modbus form again. + return self.async_show_form( + step_id="modbus_tcp", + data_schema=STEP_MODBUS_TCP_DATA_SCHEMA, + errors=errors, + ) + + async def _create_entry( + self, + host: str, + protocol: str, + device_info: BasicInfo, + user_input: dict[str, Any], + ) -> ConfigFlowResult: + """Create the config entry.""" + + await self.async_set_unique_id(device_info.serial) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=device_info.model, + data={CONF_HOST: host, CONF_PROTOCOL: protocol, **user_input}, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class UnknownException(HomeAssistantError): + """Error to indicate an unknown exception occurred.""" diff --git a/homeassistant/components/iskra/const.py b/homeassistant/components/iskra/const.py new file mode 100644 index 00000000000..5fc3b501962 --- /dev/null +++ b/homeassistant/components/iskra/const.py @@ -0,0 +1,25 @@ +"""Constants for the iskra integration.""" + +DOMAIN = "iskra" +MANUFACTURER = "Iskra d.o.o" + +# POWER +ATTR_TOTAL_APPARENT_POWER = "total_apparent_power" +ATTR_TOTAL_REACTIVE_POWER = "total_reactive_power" +ATTR_TOTAL_ACTIVE_POWER = "total_active_power" +ATTR_PHASE1_POWER = "phase1_power" +ATTR_PHASE2_POWER = "phase2_power" +ATTR_PHASE3_POWER = "phase3_power" + +# Voltage +ATTR_PHASE1_VOLTAGE = "phase1_voltage" +ATTR_PHASE2_VOLTAGE = "phase2_voltage" +ATTR_PHASE3_VOLTAGE = "phase3_voltage" + +# Current +ATTR_PHASE1_CURRENT = "phase1_current" +ATTR_PHASE2_CURRENT = "phase2_current" +ATTR_PHASE3_CURRENT = "phase3_current" + +# Frequency +ATTR_FREQUENCY = "frequency" diff --git a/homeassistant/components/iskra/coordinator.py b/homeassistant/components/iskra/coordinator.py new file mode 100644 index 00000000000..175d8ed4c86 --- /dev/null +++ b/homeassistant/components/iskra/coordinator.py @@ -0,0 +1,57 @@ +"""Coordinator for Iskra integration.""" + +from datetime import timedelta +import logging + +from pyiskra.devices import Device +from pyiskra.exceptions import ( + DeviceConnectionError, + DeviceTimeoutError, + InvalidResponseCode, + NotAuthorised, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class IskraDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching Iskra data.""" + + def __init__(self, hass: HomeAssistant, device: Device) -> None: + """Initialize.""" + self.device = device + + update_interval = timedelta(seconds=60) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=update_interval, + ) + + async def _async_update_data(self) -> None: + """Fetch data from Iskra device.""" + try: + await self.device.update_status() + except DeviceTimeoutError as e: + raise UpdateFailed( + f"Timeout error occurred while updating data for device {self.device.serial}" + ) from e + except DeviceConnectionError as e: + raise UpdateFailed( + f"Connection error occurred while updating data for device {self.device.serial}" + ) from e + except NotAuthorised as e: + raise UpdateFailed( + f"Not authorised to fetch data from device {self.device.serial}" + ) from e + except InvalidResponseCode as e: + raise UpdateFailed( + f"Invalid response code from device {self.device.serial}" + ) from e diff --git a/homeassistant/components/iskra/entity.py b/homeassistant/components/iskra/entity.py new file mode 100644 index 00000000000..f1c01d3eaa4 --- /dev/null +++ b/homeassistant/components/iskra/entity.py @@ -0,0 +1,38 @@ +"""Base entity for Iskra devices.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import IskraDataUpdateCoordinator + + +class IskraEntity(CoordinatorEntity[IskraDataUpdateCoordinator]): + """Representation a base Iskra device.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: IskraDataUpdateCoordinator) -> None: + """Initialize the Iskra device.""" + super().__init__(coordinator) + self.device = coordinator.device + gateway = self.device.parent_device + + if gateway is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device.serial)}, + manufacturer=MANUFACTURER, + model=self.device.model, + name=self.device.model, + sw_version=self.device.fw_version, + serial_number=self.device.serial, + via_device=(DOMAIN, gateway.serial), + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device.serial)}, + manufacturer=MANUFACTURER, + model=self.device.model, + sw_version=self.device.fw_version, + serial_number=self.device.serial, + ) diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json new file mode 100644 index 00000000000..7bda12ab615 --- /dev/null +++ b/homeassistant/components/iskra/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "iskra", + "name": "iskra", + "codeowners": ["@iskramis"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/iskra", + "integration_type": "hub", + "iot_class": "local_polling", + "loggers": ["pyiskra"], + "requirements": ["pyiskra==0.1.8"] +} diff --git a/homeassistant/components/iskra/sensor.py b/homeassistant/components/iskra/sensor.py new file mode 100644 index 00000000000..9e9976749a1 --- /dev/null +++ b/homeassistant/components/iskra/sensor.py @@ -0,0 +1,229 @@ +"""Support for Iskra.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyiskra.devices import Device + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfFrequency, + UnitOfPower, + UnitOfReactivePower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IskraConfigEntry +from .const import ( + ATTR_FREQUENCY, + ATTR_PHASE1_CURRENT, + ATTR_PHASE1_POWER, + ATTR_PHASE1_VOLTAGE, + ATTR_PHASE2_CURRENT, + ATTR_PHASE2_POWER, + ATTR_PHASE2_VOLTAGE, + ATTR_PHASE3_CURRENT, + ATTR_PHASE3_POWER, + ATTR_PHASE3_VOLTAGE, + ATTR_TOTAL_ACTIVE_POWER, + ATTR_TOTAL_APPARENT_POWER, + ATTR_TOTAL_REACTIVE_POWER, +) +from .coordinator import IskraDataUpdateCoordinator +from .entity import IskraEntity + + +@dataclass(frozen=True, kw_only=True) +class IskraSensorEntityDescription(SensorEntityDescription): + """Describes Iskra sensor entity.""" + + value_func: Callable[[Device], float | None] + + +SENSOR_TYPES: tuple[IskraSensorEntityDescription, ...] = ( + # Power + IskraSensorEntityDescription( + key=ATTR_TOTAL_ACTIVE_POWER, + translation_key="total_active_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.measurements.total.active_power.value, + ), + IskraSensorEntityDescription( + key=ATTR_TOTAL_REACTIVE_POWER, + translation_key="total_reactive_power", + device_class=SensorDeviceClass.REACTIVE_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfReactivePower.VOLT_AMPERE_REACTIVE, + value_func=lambda device: device.measurements.total.reactive_power.value, + ), + IskraSensorEntityDescription( + key=ATTR_TOTAL_APPARENT_POWER, + translation_key="total_apparent_power", + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + value_func=lambda device: device.measurements.total.apparent_power.value, + ), + IskraSensorEntityDescription( + key=ATTR_PHASE1_POWER, + translation_key="phase1_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.measurements.phases[0].active_power.value, + ), + IskraSensorEntityDescription( + key=ATTR_PHASE2_POWER, + translation_key="phase2_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.measurements.phases[1].active_power.value, + ), + IskraSensorEntityDescription( + key=ATTR_PHASE3_POWER, + translation_key="phase3_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_func=lambda device: device.measurements.phases[2].active_power.value, + ), + # Voltage + IskraSensorEntityDescription( + key=ATTR_PHASE1_VOLTAGE, + translation_key="phase1_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=lambda device: device.measurements.phases[0].voltage.value, + ), + IskraSensorEntityDescription( + key=ATTR_PHASE2_VOLTAGE, + translation_key="phase2_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=lambda device: device.measurements.phases[1].voltage.value, + ), + IskraSensorEntityDescription( + key=ATTR_PHASE3_VOLTAGE, + translation_key="phase3_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value_func=lambda device: device.measurements.phases[2].voltage.value, + ), + # Current + IskraSensorEntityDescription( + key=ATTR_PHASE1_CURRENT, + translation_key="phase1_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=lambda device: device.measurements.phases[0].current.value, + ), + IskraSensorEntityDescription( + key=ATTR_PHASE2_CURRENT, + translation_key="phase2_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=lambda device: device.measurements.phases[1].current.value, + ), + IskraSensorEntityDescription( + key=ATTR_PHASE3_CURRENT, + translation_key="phase3_current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_func=lambda device: device.measurements.phases[2].current.value, + ), + # Frequency + IskraSensorEntityDescription( + key=ATTR_FREQUENCY, + translation_key="frequency", + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfFrequency.HERTZ, + value_func=lambda device: device.measurements.frequency.value, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IskraConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Iskra sensors based on config_entry.""" + + # Device that uses the config entry. + coordinators = entry.runtime_data + + entities: list[IskraSensor] = [] + + # Add sensors for each device. + for coordinator in coordinators: + device = coordinator.device + sensors = [] + + # Add measurement sensors. + if device.supports_measurements: + sensors.append(ATTR_FREQUENCY) + sensors.append(ATTR_TOTAL_APPARENT_POWER) + sensors.append(ATTR_TOTAL_ACTIVE_POWER) + sensors.append(ATTR_TOTAL_REACTIVE_POWER) + if device.phases >= 1: + sensors.append(ATTR_PHASE1_VOLTAGE) + sensors.append(ATTR_PHASE1_POWER) + sensors.append(ATTR_PHASE1_CURRENT) + if device.phases >= 2: + sensors.append(ATTR_PHASE2_VOLTAGE) + sensors.append(ATTR_PHASE2_POWER) + sensors.append(ATTR_PHASE2_CURRENT) + if device.phases >= 3: + sensors.append(ATTR_PHASE3_VOLTAGE) + sensors.append(ATTR_PHASE3_POWER) + sensors.append(ATTR_PHASE3_CURRENT) + + entities.extend( + IskraSensor(coordinator, description) + for description in SENSOR_TYPES + if description.key in sensors + ) + + async_add_entities(entities) + + +class IskraSensor(IskraEntity, SensorEntity): + """Representation of a Sensor.""" + + entity_description: IskraSensorEntityDescription + + def __init__( + self, + coordinator: IskraDataUpdateCoordinator, + description: IskraSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.device.serial}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_func(self.device) diff --git a/homeassistant/components/iskra/strings.json b/homeassistant/components/iskra/strings.json new file mode 100644 index 00000000000..bd70336f637 --- /dev/null +++ b/homeassistant/components/iskra/strings.json @@ -0,0 +1,92 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure Iskra Device", + "description": "Enter the IP address of your Iskra Device and select protocol.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Iskra device." + } + }, + "authentication": { + "title": "Configure Rest API Credentials", + "description": "Enter username and password", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "modbus_tcp": { + "title": "Configure Modbus TCP", + "description": "Enter Modbus TCP port and device's Modbus address.", + "data": { + "port": "[%key:common::config_flow::data::port%]", + "address": "Modbus address" + }, + "data_description": { + "port": "Port number can be found in the device's settings menu.", + "address": "Modbus address can be found in the device's settings menu." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "selector": { + "protocol": { + "options": { + "rest_api": "Rest API", + "modbus_tcp": "Modbus TCP" + } + } + }, + "entity": { + "sensor": { + "total_active_power": { + "name": "Total active power" + }, + "total_apparent_power": { + "name": "Total apparent power" + }, + "total_reactive_power": { + "name": "Total reactive power" + }, + "phase1_power": { + "name": "Phase 1 power" + }, + "phase2_power": { + "name": "Phase 2 power" + }, + "phase3_power": { + "name": "Phase 3 power" + }, + "phase1_voltage": { + "name": "Phase 1 voltage" + }, + "phase2_voltage": { + "name": "Phase 2 voltage" + }, + "phase3_voltage": { + "name": "Phase 3 voltage" + }, + "phase1_current": { + "name": "Phase 1 current" + }, + "phase2_current": { + "name": "Phase 2 current" + }, + "phase3_current": { + "name": "Phase 3 current" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e78df5ab045..c7c8cd0f9f1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -285,6 +285,7 @@ FLOWS = { "ipp", "iqvia", "iron_os", + "iskra", "islamic_prayer_times", "israel_rail", "iss", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 879012ae54b..f6854aeb58d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2908,6 +2908,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "iskra": { + "name": "iskra", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "islamic_prayer_times": { "integration_type": "hub", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 35dd9071f13..b603ce3a3ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1956,6 +1956,9 @@ pyiqvia==2022.04.0 # homeassistant.components.irish_rail_transport pyirishrail==0.0.2 +# homeassistant.components.iskra +pyiskra==0.1.8 + # homeassistant.components.iss pyiss==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8940fd0649c..ba0fff1ac4b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1567,6 +1567,9 @@ pyipp==0.16.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 +# homeassistant.components.iskra +pyiskra==0.1.8 + # homeassistant.components.iss pyiss==1.0.1 diff --git a/tests/components/iskra/__init__.py b/tests/components/iskra/__init__.py new file mode 100644 index 00000000000..ca93572a9e4 --- /dev/null +++ b/tests/components/iskra/__init__.py @@ -0,0 +1 @@ +"""Tests for the Iskra component.""" diff --git a/tests/components/iskra/conftest.py b/tests/components/iskra/conftest.py new file mode 100644 index 00000000000..d9cc6808aaa --- /dev/null +++ b/tests/components/iskra/conftest.py @@ -0,0 +1,46 @@ +"""Fixtures for mocking pyiskra's different protocols. + +Fixtures: +- `mock_pyiskra_rest`: Mock pyiskra Rest API protocol. +- `mock_pyiskra_modbus`: Mock pyiskra Modbus protocol. +""" + +from unittest.mock import patch + +import pytest + +from .const import PQ_MODEL, SERIAL, SG_MODEL + + +class MockBasicInfo: + """Mock BasicInfo class.""" + + def __init__(self, model) -> None: + """Initialize the mock class.""" + self.serial = SERIAL + self.model = model + self.description = "Iskra mock device" + self.location = "imagination" + self.sw_ver = "1.0.0" + + +@pytest.fixture +def mock_pyiskra_rest(): + """Mock Iskra API authenticate with Rest API protocol.""" + + with patch( + "pyiskra.adapters.RestAPI.RestAPI.get_basic_info", + return_value=MockBasicInfo(model=SG_MODEL), + ) as basic_info_mock: + yield basic_info_mock + + +@pytest.fixture +def mock_pyiskra_modbus(): + """Mock Iskra API authenticate with Rest API protocol.""" + + with patch( + "pyiskra.adapters.Modbus.Modbus.get_basic_info", + return_value=MockBasicInfo(model=PQ_MODEL), + ) as basic_info_mock: + yield basic_info_mock diff --git a/tests/components/iskra/const.py b/tests/components/iskra/const.py new file mode 100644 index 00000000000..bf38c9a4a79 --- /dev/null +++ b/tests/components/iskra/const.py @@ -0,0 +1,10 @@ +"""Constants used in the Iskra component tests.""" + +SG_MODEL = "SG-W1" +PQ_MODEL = "MC784" +SERIAL = "XXXXXXX" +HOST = "192.1.0.1" +MODBUS_PORT = 10001 +MODBUS_ADDRESS = 33 +USERNAME = "test_username" +PASSWORD = "test_password" diff --git a/tests/components/iskra/test_config_flow.py b/tests/components/iskra/test_config_flow.py new file mode 100644 index 00000000000..0c128be9850 --- /dev/null +++ b/tests/components/iskra/test_config_flow.py @@ -0,0 +1,300 @@ +"""Tests for the Iskra config flow.""" + +from pyiskra.exceptions import ( + DeviceConnectionError, + DeviceTimeoutError, + InvalidResponseCode, + NotAuthorised, +) +import pytest + +from homeassistant.components.iskra import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + CONF_ADDRESS, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import ( + HOST, + MODBUS_ADDRESS, + MODBUS_PORT, + PASSWORD, + PQ_MODEL, + SERIAL, + SG_MODEL, + USERNAME, +) + +from tests.common import MockConfigEntry + + +# Test step_user with Rest API protocol +async def test_user_rest_no_auth(hass: HomeAssistant, mock_pyiskra_rest) -> None: + """Test the user flow with Rest API protocol.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Test if user form is provided + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Test no authentication required + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"}, + ) + + # Test successful Rest API configuration + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == SG_MODEL + assert result["data"] == {CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"} + + +async def test_user_rest_auth(hass: HomeAssistant, mock_pyiskra_rest) -> None: + """Test the user flow with Rest API protocol and authentication required.""" + mock_pyiskra_rest.side_effect = NotAuthorised + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Test if user form is provided + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Test if prompted to enter username and password if not authorised + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "authentication" + + # Test failed authentication + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + assert result["step_id"] == "authentication" + + # Test successful authentication + mock_pyiskra_rest.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + + # Test successful Rest API configuration + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == SG_MODEL + assert result["data"] == { + CONF_HOST: HOST, + CONF_PROTOCOL: "rest_api", + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + } + + +async def test_user_modbus(hass: HomeAssistant, mock_pyiskra_modbus) -> None: + """Test the user flow with Modbus TCP protocol.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Test if user form is provided + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST, CONF_PROTOCOL: "modbus_tcp"}, + ) + + # Test if propmpted to enter port and address + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "modbus_tcp" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PORT: MODBUS_PORT, + CONF_ADDRESS: MODBUS_ADDRESS, + }, + ) + + # Test successful Modbus TCP configuration + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == PQ_MODEL + assert result["data"] == { + CONF_HOST: HOST, + CONF_PROTOCOL: "modbus_tcp", + CONF_PORT: MODBUS_PORT, + CONF_ADDRESS: MODBUS_ADDRESS, + } + + +async def test_modbus_abort_if_already_setup( + hass: HomeAssistant, mock_pyiskra_modbus +) -> None: + """Test we abort if Iskra is already setup.""" + + MockConfigEntry(domain=DOMAIN, unique_id=SERIAL).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_PROTOCOL: "modbus_tcp"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "modbus_tcp" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PORT: MODBUS_PORT, + CONF_ADDRESS: MODBUS_ADDRESS, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_rest_api_abort_if_already_setup( + hass: HomeAssistant, mock_pyiskra_rest +) -> None: + """Test we abort if Iskra is already setup.""" + + MockConfigEntry(domain=DOMAIN, unique_id=SERIAL).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("s_effect", "reason"), + [ + (DeviceConnectionError, "cannot_connect"), + (DeviceTimeoutError, "cannot_connect"), + (InvalidResponseCode, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_modbus_device_error( + hass: HomeAssistant, + mock_pyiskra_modbus, + s_effect, + reason, +) -> None: + """Test device error with Modbus TCP protocol.""" + mock_pyiskra_modbus.side_effect = s_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_PROTOCOL: "modbus_tcp"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "modbus_tcp" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PORT: MODBUS_PORT, + CONF_ADDRESS: MODBUS_ADDRESS, + }, + ) + + # Test if error returned + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "modbus_tcp" + assert result["errors"] == {"base": reason} + + # Remove side effect + mock_pyiskra_modbus.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PORT: MODBUS_PORT, + CONF_ADDRESS: MODBUS_ADDRESS, + }, + ) + + # Test successful Modbus TCP configuration + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == PQ_MODEL + assert result["data"] == { + CONF_HOST: HOST, + CONF_PROTOCOL: "modbus_tcp", + CONF_PORT: MODBUS_PORT, + CONF_ADDRESS: MODBUS_ADDRESS, + } + + +@pytest.mark.parametrize( + ("s_effect", "reason"), + [ + (DeviceConnectionError, "cannot_connect"), + (DeviceTimeoutError, "cannot_connect"), + (InvalidResponseCode, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_rest_device_error( + hass: HomeAssistant, + mock_pyiskra_rest, + s_effect, + reason, +) -> None: + """Test device error with Modbus TCP protocol.""" + mock_pyiskra_rest.side_effect = s_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"}, + ) + + # Test if error returned + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": reason} + + # Remove side effect + mock_pyiskra_rest.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"}, + ) + + # Test successful Rest API configuration + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == SERIAL + assert result["title"] == SG_MODEL + assert result["data"] == {CONF_HOST: HOST, CONF_PROTOCOL: "rest_api"} From 1e1c3506febee4b839ff00c37aa95bdab753aac7 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 4 Sep 2024 22:52:41 +0900 Subject: [PATCH 0239/1309] Bump thinqconnect to 0.9.6 (#125155) * Refactor LG ThinQ integration * Rename ha_bridge_list to bridge_list * Update for reviews * Correct spells Do not use mqtt related api * Guarantee update status * Update for reviews * Update reviews --------- Co-authored-by: jangwon.lee --- homeassistant/components/lg_thinq/__init__.py | 22 ++-- .../components/lg_thinq/binary_sensor.py | 29 ++--- homeassistant/components/lg_thinq/const.py | 74 +---------- .../components/lg_thinq/coordinator.py | 123 ++++-------------- homeassistant/components/lg_thinq/entity.py | 69 ++++++---- homeassistant/components/lg_thinq/icons.json | 3 + .../components/lg_thinq/manifest.json | 2 +- .../components/lg_thinq/strings.json | 3 + homeassistant/components/lg_thinq/switch.py | 80 +++++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 11 files changed, 134 insertions(+), 275 deletions(-) diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py index a86afc68171..625938564a8 100644 --- a/homeassistant/components/lg_thinq/__init__.py +++ b/homeassistant/components/lg_thinq/__init__.py @@ -6,6 +6,7 @@ import asyncio import logging from thinqconnect import ThinQApi, ThinQAPIException +from thinqconnect.integration import async_get_ha_bridge_list from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY, Platform @@ -26,6 +27,8 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ThinqConfigEntry) -> bool: """Set up an entry.""" + entry.runtime_data = {} + access_token = entry.data[CONF_ACCESS_TOKEN] client_id = entry.data[CONF_CONNECT_CLIENT_ID] country_code = entry.data[CONF_COUNTRY] @@ -55,29 +58,22 @@ async def async_setup_coordinators( thinq_api: ThinQApi, ) -> None: """Set up coordinators and register devices.""" - entry.runtime_data = {} - - # Get a device list from the server. + # Get a list of ha bridge. try: - device_list = await thinq_api.async_get_device_list() + bridge_list = await async_get_ha_bridge_list(thinq_api) except ThinQAPIException as exc: raise ConfigEntryNotReady(exc.message) from exc - if not device_list: + if not bridge_list: return # Setup coordinator per device. - coordinator_list: list[DeviceDataUpdateCoordinator] = [] task_list = [ - hass.async_create_task(async_setup_device_coordinator(hass, thinq_api, device)) - for device in device_list + hass.async_create_task(async_setup_device_coordinator(hass, bridge)) + for bridge in bridge_list ] task_result = await asyncio.gather(*task_list) - for coordinators in task_result: - if coordinators: - coordinator_list += coordinators - - for coordinator in coordinator_list: + for coordinator in task_result: entry.runtime_data[coordinator.unique_id] = coordinator diff --git a/homeassistant/components/lg_thinq/binary_sensor.py b/homeassistant/components/lg_thinq/binary_sensor.py index fc6564c7652..6f856c3055f 100644 --- a/homeassistant/components/lg_thinq/binary_sensor.py +++ b/homeassistant/components/lg_thinq/binary_sensor.py @@ -2,9 +2,10 @@ from __future__ import annotations -from thinqconnect import PROPERTY_READABLE, DeviceType +import logging + +from thinqconnect import DeviceType from thinqconnect.devices.const import Property as ThinQProperty -from thinqconnect.integration.homeassistant.property import create_properties from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -71,6 +72,7 @@ DEVICE_TYPE_BINARY_SENSOR_MAP: dict[ ), DeviceType.WINE_CELLAR: (BINARY_SENSOR_DESC[ThinQProperty.SABBATH_MODE],), } +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -83,22 +85,13 @@ async def async_setup_entry( for coordinator in entry.runtime_data.values(): if ( descriptions := DEVICE_TYPE_BINARY_SENSOR_MAP.get( - coordinator.device_api.device_type + coordinator.api.device.device_type ) ) is not None: for description in descriptions: - properties = create_properties( - device_api=coordinator.device_api, - key=description.key, - children_keys=None, - rw_type=PROPERTY_READABLE, - ) - if not properties: - continue - entities.extend( - ThinQBinarySensorEntity(coordinator, description, prop) - for prop in properties + ThinQBinarySensorEntity(coordinator, description, property_id) + for property_id in coordinator.api.get_active_idx(description.key) ) if entities: @@ -112,4 +105,10 @@ class ThinQBinarySensorEntity(ThinQEntity, BinarySensorEntity): """Update status itself.""" super()._update_status() - self._attr_is_on = self.property.get_value_as_bool() + _LOGGER.debug( + "[%s:%s] update status: %s", + self.coordinator.device_name, + self.property_id, + self.data.is_on, + ) + self._attr_is_on = self.data.is_on diff --git a/homeassistant/components/lg_thinq/const.py b/homeassistant/components/lg_thinq/const.py index 811b7c50340..09f8c0833df 100644 --- a/homeassistant/components/lg_thinq/const.py +++ b/homeassistant/components/lg_thinq/const.py @@ -1,82 +1,12 @@ """Constants for LG ThinQ.""" -# Base component constants. from typing import Final -from thinqconnect import ( - AirConditionerDevice, - AirPurifierDevice, - AirPurifierFanDevice, - CeilingFanDevice, - CooktopDevice, - DehumidifierDevice, - DeviceType, - DishWasherDevice, - DryerDevice, - HomeBrewDevice, - HoodDevice, - HumidifierDevice, - KimchiRefrigeratorDevice, - MicrowaveOvenDevice, - OvenDevice, - PlantCultivatorDevice, - RefrigeratorDevice, - RobotCleanerDevice, - StickCleanerDevice, - StylerDevice, - SystemBoilerDevice, - WashcomboMainDevice, - WashcomboMiniDevice, - WasherDevice, - WashtowerDevice, - WashtowerDryerDevice, - WashtowerWasherDevice, - WaterHeaterDevice, - WaterPurifierDevice, - WineCellarDevice, -) - -# Common +# Config flow DOMAIN = "lg_thinq" COMPANY = "LGE" +DEFAULT_COUNTRY: Final = "US" THINQ_DEFAULT_NAME: Final = "LG ThinQ" THINQ_PAT_URL: Final = "https://connect-pat.lgthinq.com" - -# Config Flow CLIENT_PREFIX: Final = "home-assistant" CONF_CONNECT_CLIENT_ID: Final = "connect_client_id" -DEFAULT_COUNTRY: Final = "US" - -THINQ_DEVICE_ADDED: Final = "thinq_device_added" - -DEVICE_TYPE_API_MAP: Final = { - DeviceType.AIR_CONDITIONER: AirConditionerDevice, - DeviceType.AIR_PURIFIER_FAN: AirPurifierFanDevice, - DeviceType.AIR_PURIFIER: AirPurifierDevice, - DeviceType.CEILING_FAN: CeilingFanDevice, - DeviceType.COOKTOP: CooktopDevice, - DeviceType.DEHUMIDIFIER: DehumidifierDevice, - DeviceType.DISH_WASHER: DishWasherDevice, - DeviceType.DRYER: DryerDevice, - DeviceType.HOME_BREW: HomeBrewDevice, - DeviceType.HOOD: HoodDevice, - DeviceType.HUMIDIFIER: HumidifierDevice, - DeviceType.KIMCHI_REFRIGERATOR: KimchiRefrigeratorDevice, - DeviceType.MICROWAVE_OVEN: MicrowaveOvenDevice, - DeviceType.OVEN: OvenDevice, - DeviceType.PLANT_CULTIVATOR: PlantCultivatorDevice, - DeviceType.REFRIGERATOR: RefrigeratorDevice, - DeviceType.ROBOT_CLEANER: RobotCleanerDevice, - DeviceType.STICK_CLEANER: StickCleanerDevice, - DeviceType.STYLER: StylerDevice, - DeviceType.SYSTEM_BOILER: SystemBoilerDevice, - DeviceType.WASHER: WasherDevice, - DeviceType.WASHCOMBO_MAIN: WashcomboMainDevice, - DeviceType.WASHCOMBO_MINI: WashcomboMiniDevice, - DeviceType.WASHTOWER_DRYER: WashtowerDryerDevice, - DeviceType.WASHTOWER: WashtowerDevice, - DeviceType.WASHTOWER_WASHER: WashtowerWasherDevice, - DeviceType.WATER_HEATER: WaterHeaterDevice, - DeviceType.WATER_PURIFIER: WaterPurifierDevice, - DeviceType.WINE_CELLAR: WineCellarDevice, -} diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py index 1e16ac7ec56..5ba77c648a8 100644 --- a/homeassistant/components/lg_thinq/coordinator.py +++ b/homeassistant/components/lg_thinq/coordinator.py @@ -5,12 +5,13 @@ from __future__ import annotations import logging from typing import Any -from thinqconnect import ConnectBaseDevice, DeviceType, ThinQApi, ThinQAPIException +from thinqconnect import ThinQAPIException +from thinqconnect.integration import HABridge from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEVICE_TYPE_API_MAP, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -18,125 +19,51 @@ _LOGGER = logging.getLogger(__name__) class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """LG Device's Data Update Coordinator.""" - def __init__( - self, - hass: HomeAssistant, - device_api: ConnectBaseDevice, - *, - sub_id: str | None = None, - ) -> None: + def __init__(self, hass: HomeAssistant, ha_bridge: HABridge) -> None: """Initialize data coordinator.""" super().__init__( hass, _LOGGER, - name=f"{DOMAIN}_{device_api.device_id}", + name=f"{DOMAIN}_{ha_bridge.device.device_id}", ) - # For washTower's washer or dryer - self.sub_id = sub_id + self.data = {} + self.api = ha_bridge + self.device_id = ha_bridge.device.device_id + self.sub_id = ha_bridge.sub_id + + alias = ha_bridge.device.alias # The device name is usually set to 'alias'. # But, if the sub_id exists, it will be set to 'alias {sub_id}'. # e.g. alias='MyWashTower', sub_id='dryer' then 'MyWashTower dryer'. - self.device_name = ( - f"{device_api.alias} {self.sub_id}" if self.sub_id else device_api.alias - ) + self.device_name = f"{alias} {self.sub_id}" if self.sub_id else alias # The unique id is usually set to 'device_id'. # But, if the sub_id exists, it will be set to 'device_id_{sub_id}'. # e.g. device_id='TQSXXXX', sub_id='dryer' then 'TQSXXXX_dryer'. self.unique_id = ( - f"{device_api.device_id}_{self.sub_id}" - if self.sub_id - else device_api.device_id + f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id ) - # Get the api instance. - self.device_api = device_api.get_sub_device(self.sub_id) or device_api - async def _async_update_data(self) -> dict[str, Any]: """Request to the server to update the status from full response data.""" try: - data = await self.device_api.thinq_api.async_get_device_status( - self.device_api.device_id - ) - except ThinQAPIException as exc: - raise UpdateFailed(exc) from exc + return await self.api.fetch_data() + except ThinQAPIException as e: + raise UpdateFailed(e) from e - # Full response data into the device api. - self.device_api.set_status(data) - return data + def refresh_status(self) -> None: + """Refresh current status.""" + self.async_set_updated_data(self.data) async def async_setup_device_coordinator( - hass: HomeAssistant, thinq_api: ThinQApi, device: dict[str, Any] -) -> list[DeviceDataUpdateCoordinator] | None: + hass: HomeAssistant, ha_bridge: HABridge +) -> DeviceDataUpdateCoordinator: """Create DeviceDataUpdateCoordinator and device_api per device.""" - device_id = device["deviceId"] - device_info = device["deviceInfo"] + coordinator = DeviceDataUpdateCoordinator(hass, ha_bridge) + await coordinator.async_refresh() - # Get an appropriate class constructor for the device type. - device_type = device_info.get("deviceType") - constructor = DEVICE_TYPE_API_MAP.get(device_type) - if constructor is None: - _LOGGER.error( - "Failed to setup device(%s): not supported device. type=%s", - device_id, - device_type, - ) - return None - - # Get a device profile from the server. - try: - profile = await thinq_api.async_get_device_profile(device_id) - except ThinQAPIException: - _LOGGER.warning("Failed to setup device(%s): no profile", device_id) - return None - - device_group_id = device_info.get("groupId") - - # Create new device api instance. - device_api: ConnectBaseDevice = ( - constructor( - thinq_api=thinq_api, - device_id=device_id, - device_type=device_type, - model_name=device_info.get("modelName"), - alias=device_info.get("alias"), - group_id=device_group_id, - reportable=device_info.get("reportable"), - profile=profile, - ) - if device_group_id - else constructor( - thinq_api=thinq_api, - device_id=device_id, - device_type=device_type, - model_name=device_info.get("modelName"), - alias=device_info.get("alias"), - reportable=device_info.get("reportable"), - profile=profile, - ) - ) - - # Create a list of sub-devices from the profile. - # Note that some devices may have more than two device profiles. - # In this case we should create multiple lg device instance. - # e.g. 'WashTower-Single-Unit' = 'WashTower{dryer}' + 'WashTower{washer}'. - device_sub_ids = ( - list(profile.keys()) - if device_type == DeviceType.WASHTOWER and "property" not in profile - else [None] - ) - - # Create new device coordinator instances. - coordinator_list: list[DeviceDataUpdateCoordinator] = [] - for sub_id in device_sub_ids: - coordinator = DeviceDataUpdateCoordinator(hass, device_api, sub_id=sub_id) - await coordinator.async_refresh() - - # Finally add a device coordinator into the result list. - coordinator_list.append(coordinator) - _LOGGER.debug("Setup device's coordinator: %s", coordinator) - - return coordinator_list + _LOGGER.debug("Setup device's coordinator: %s", coordinator.device_name) + return coordinator diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 151687aabb8..09ff8662efb 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -2,11 +2,13 @@ from __future__ import annotations +from collections.abc import Coroutine import logging from typing import Any from thinqconnect import ThinQAPIException -from thinqconnect.integration.homeassistant.property import Property as ThinQProperty +from thinqconnect.devices.const import Location +from thinqconnect.integration import PropertyState from homeassistant.core import callback from homeassistant.exceptions import ServiceValidationError @@ -19,6 +21,8 @@ from .coordinator import DeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +EMPTY_STATE = PropertyState() + class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """The base implementation of all lg thinq entities.""" @@ -29,43 +33,36 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): self, coordinator: DeviceDataUpdateCoordinator, entity_description: EntityDescription, - property: ThinQProperty, + property_id: str, ) -> None: """Initialize an entity.""" super().__init__(coordinator) self.entity_description = entity_description - self.property = property + self.property_id = property_id + self.location = self.coordinator.api.get_location_for_idx(self.property_id) + self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, coordinator.unique_id)}, manufacturer=COMPANY, - model=coordinator.device_api.model_name, + model=coordinator.api.device.model_name, name=coordinator.device_name, ) + self._attr_unique_id = f"{coordinator.unique_id}_{self.property_id}" + if self.location is not None and self.location not in ( + Location.MAIN, + Location.OVEN, + coordinator.sub_id, + ): + self._attr_translation_placeholders = {"location": self.location} + self._attr_translation_key = ( + f"{entity_description.translation_key}_for_location" + ) - # Set the unique key. If there exist a location, add the prefix location name. - unique_key = ( - f"{entity_description.key}" - if property.location is None - else f"{property.location}_{entity_description.key}" - ) - self._attr_unique_id = f"{coordinator.unique_id}_{unique_key}" - - # Update initial status. - self._update_status() - - async def async_post_value(self, value: Any) -> None: - """Post the value of entity to server.""" - try: - await self.property.async_post_value(value) - except ThinQAPIException as exc: - raise ServiceValidationError( - exc.message, - translation_domain=DOMAIN, - translation_key=exc.code, - ) from exc - finally: - await self.coordinator.async_request_refresh() + @property + def data(self) -> PropertyState: + """Return the state data of entity.""" + return self.coordinator.data.get(self.property_id, EMPTY_STATE) def _update_status(self) -> None: """Update status itself. @@ -78,3 +75,21 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Handle updated data from the coordinator.""" self._update_status() self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_call_api(self, target: Coroutine[Any, Any, Any]) -> None: + """Call the given api and handle exception.""" + try: + await target + except ThinQAPIException as exc: + raise ServiceValidationError( + exc.message, + translation_domain=DOMAIN, + translation_key=exc.code, + ) from exc + finally: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 550d023d278..3cc4ab784c2 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -15,6 +15,9 @@ "remote_control_enabled": { "default": "mdi:remote" }, + "remote_control_enabled_for_location": { + "default": "mdi:remote" + }, "rinse_refill": { "default": "mdi:tune-vertical-variant" }, diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index a49b91892f5..9a594f70f95 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq/", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==0.9.5"] + "requirements": ["thinqconnect==0.9.6"] } diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 472e8b848b7..6649c6b0c13 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -34,6 +34,9 @@ "remote_control_enabled": { "name": "Remote start" }, + "remote_control_enabled_for_location": { + "name": "{location} remote start" + }, "rinse_refill": { "name": "Rinse refill needed" }, diff --git a/homeassistant/components/lg_thinq/switch.py b/homeassistant/components/lg_thinq/switch.py index ee7dfdb02d7..ef85c8ad50e 100644 --- a/homeassistant/components/lg_thinq/switch.py +++ b/homeassistant/components/lg_thinq/switch.py @@ -5,9 +5,8 @@ from __future__ import annotations import logging from typing import Any -from thinqconnect import PROPERTY_WRITABLE, DeviceType +from thinqconnect import DeviceType from thinqconnect.devices.const import Property as ThinQProperty -from thinqconnect.integration.homeassistant.property import create_properties from homeassistant.components.switch import ( SwitchDeviceClass, @@ -20,44 +19,34 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ThinqConfigEntry from .entity import ThinQEntity -OPERATION_SWITCH_DESC: dict[ThinQProperty, SwitchEntityDescription] = { - ThinQProperty.AIR_FAN_OPERATION_MODE: SwitchEntityDescription( - key=ThinQProperty.AIR_FAN_OPERATION_MODE, - translation_key="operation_power", - ), - ThinQProperty.AIR_PURIFIER_OPERATION_MODE: SwitchEntityDescription( - key=ThinQProperty.AIR_PURIFIER_OPERATION_MODE, - translation_key="operation_power", - ), - ThinQProperty.BOILER_OPERATION_MODE: SwitchEntityDescription( - key=ThinQProperty.BOILER_OPERATION_MODE, - translation_key="operation_power", - ), - ThinQProperty.DEHUMIDIFIER_OPERATION_MODE: SwitchEntityDescription( - key=ThinQProperty.DEHUMIDIFIER_OPERATION_MODE, - translation_key="operation_power", - ), - ThinQProperty.HUMIDIFIER_OPERATION_MODE: SwitchEntityDescription( - key=ThinQProperty.HUMIDIFIER_OPERATION_MODE, - translation_key="operation_power", - ), -} - -DEVIE_TYPE_SWITCH_MAP: dict[DeviceType, tuple[SwitchEntityDescription, ...]] = { +DEVICE_TYPE_SWITCH_MAP: dict[DeviceType, tuple[SwitchEntityDescription, ...]] = { DeviceType.AIR_PURIFIER_FAN: ( - OPERATION_SWITCH_DESC[ThinQProperty.AIR_FAN_OPERATION_MODE], + SwitchEntityDescription( + key=ThinQProperty.AIR_FAN_OPERATION_MODE, translation_key="operation_power" + ), ), DeviceType.AIR_PURIFIER: ( - OPERATION_SWITCH_DESC[ThinQProperty.AIR_PURIFIER_OPERATION_MODE], + SwitchEntityDescription( + key=ThinQProperty.AIR_PURIFIER_OPERATION_MODE, + translation_key="operation_power", + ), ), DeviceType.DEHUMIDIFIER: ( - OPERATION_SWITCH_DESC[ThinQProperty.DEHUMIDIFIER_OPERATION_MODE], + SwitchEntityDescription( + key=ThinQProperty.DEHUMIDIFIER_OPERATION_MODE, + translation_key="operation_power", + ), ), DeviceType.HUMIDIFIER: ( - OPERATION_SWITCH_DESC[ThinQProperty.HUMIDIFIER_OPERATION_MODE], + SwitchEntityDescription( + key=ThinQProperty.HUMIDIFIER_OPERATION_MODE, + translation_key="operation_power", + ), ), DeviceType.SYSTEM_BOILER: ( - OPERATION_SWITCH_DESC[ThinQProperty.BOILER_OPERATION_MODE], + SwitchEntityDescription( + key=ThinQProperty.BOILER_OPERATION_MODE, translation_key="operation_power" + ), ), } @@ -73,23 +62,14 @@ async def async_setup_entry( entities: list[ThinQSwitchEntity] = [] for coordinator in entry.runtime_data.values(): if ( - descriptions := DEVIE_TYPE_SWITCH_MAP.get( - coordinator.device_api.device_type + descriptions := DEVICE_TYPE_SWITCH_MAP.get( + coordinator.api.device.device_type ) ) is not None: for description in descriptions: - properties = create_properties( - device_api=coordinator.device_api, - key=description.key, - children_keys=None, - rw_type=PROPERTY_WRITABLE, - ) - if not properties: - continue - entities.extend( - ThinQSwitchEntity(coordinator, description, prop) - for prop in properties + ThinQSwitchEntity(coordinator, description, property_id) + for property_id in coordinator.api.get_active_idx(description.key) ) if entities: @@ -105,14 +85,20 @@ class ThinQSwitchEntity(ThinQEntity, SwitchEntity): """Update status itself.""" super()._update_status() - self._attr_is_on = self.property.get_value_as_bool() + _LOGGER.debug( + "[%s:%s] update status: %s", + self.coordinator.device_name, + self.property_id, + self.data.is_on, + ) + self._attr_is_on = self.data.is_on async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" _LOGGER.debug("[%s] async_turn_on", self.name) - await self.async_post_value("POWER_ON") + await self.async_call_api(self.coordinator.api.async_turn_on(self.property_id)) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" _LOGGER.debug("[%s] async_turn_off", self.name) - await self.async_post_value("POWER_OFF") + await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id)) diff --git a/requirements_all.txt b/requirements_all.txt index b603ce3a3ae..5c6d0dc0ecf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2798,7 +2798,7 @@ thermoworks-smoke==0.1.8 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==0.9.5 +thinqconnect==0.9.6 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba0fff1ac4b..5a5f835082c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2211,7 +2211,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.10.0 # homeassistant.components.lg_thinq -thinqconnect==0.9.5 +thinqconnect==0.9.6 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 3a44098ddff1af5d11a8a2e8d27ca9bc86e79022 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 4 Sep 2024 16:12:57 +0200 Subject: [PATCH 0240/1309] Fix Path.__enter__ DeprecationWarning in tests (#125227) --- tests/components/google_cloud/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/google_cloud/conftest.py b/tests/components/google_cloud/conftest.py index acde62144a9..897c352b402 100644 --- a/tests/components/google_cloud/conftest.py +++ b/tests/components/google_cloud/conftest.py @@ -54,9 +54,11 @@ def mock_process_uploaded_file( create_google_credentials_json: str, ) -> Generator[MagicMock]: """Mock upload certificate files.""" + ctx_mock = MagicMock() + ctx_mock.__enter__.return_value = Path(create_google_credentials_json) with patch( "homeassistant.components.google_cloud.config_flow.process_uploaded_file", - return_value=Path(create_google_credentials_json), + return_value=ctx_mock, ) as mock_upload: yield mock_upload From 638434c103879859b2847de09862a093e34631ac Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 4 Sep 2024 09:36:25 -0500 Subject: [PATCH 0241/1309] Bump intents to 2024.9.4 (#125232) --- 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 5a689485b29..837ac9f9b1f 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.8.29"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.9.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0eb5d6a78e0..767bd206266 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 home-assistant-frontend==20240904.0 -home-assistant-intents==2024.8.29 +home-assistant-intents==2024.9.4 httpx==0.27.0 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 5c6d0dc0ecf..2ea174ebbc3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1111,7 +1111,7 @@ holidays==0.56 home-assistant-frontend==20240904.0 # homeassistant.components.conversation -home-assistant-intents==2024.8.29 +home-assistant-intents==2024.9.4 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a5f835082c..c7a11044f50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -937,7 +937,7 @@ holidays==0.56 home-assistant-frontend==20240904.0 # homeassistant.components.conversation -home-assistant-intents==2024.8.29 +home-assistant-intents==2024.9.4 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 0d99b04c44c..4dbea0e4c95 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.2.27,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.1 tqdm==4.66.4 ruff==0.6.2 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.8.29 mutagen==1.47.0 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.9.4 mutagen==1.47.0 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From eaee8d5b7857644d806a7e9a957ed47a790bc1bd Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 4 Sep 2024 18:34:11 +0300 Subject: [PATCH 0242/1309] Fix BTHome validate triggers for device with multiple buttons (#125183) * Fix BTHome validate triggers for device with multiple buttons * Remove None default --- .../components/bthome/device_trigger.py | 56 +++++--- .../components/bthome/test_device_trigger.py | 124 +++++++++++++++++- 2 files changed, 158 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index c49664b1146..c50ffc05900 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -7,6 +7,9 @@ from typing import Any import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.const import ( CONF_DEVICE_ID, @@ -43,33 +46,46 @@ TRIGGERS_BY_EVENT_CLASS = { EVENT_CLASS_DIMMER: {"rotate_left", "rotate_right"}, } -SCHEMA_BY_EVENT_CLASS = { - EVENT_CLASS_BUTTON: DEVICE_TRIGGER_BASE_SCHEMA.extend( - { - vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_BUTTON]), - vol.Required(CONF_SUBTYPE): vol.In( - TRIGGERS_BY_EVENT_CLASS[EVENT_CLASS_BUTTON] - ), - } - ), - EVENT_CLASS_DIMMER: DEVICE_TRIGGER_BASE_SCHEMA.extend( - { - vol.Required(CONF_TYPE): vol.In([EVENT_CLASS_DIMMER]), - vol.Required(CONF_SUBTYPE): vol.In( - TRIGGERS_BY_EVENT_CLASS[EVENT_CLASS_DIMMER] - ), - } - ), -} +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} +) async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate trigger config.""" - return SCHEMA_BY_EVENT_CLASS.get(config[CONF_TYPE], DEVICE_TRIGGER_BASE_SCHEMA)( # type: ignore[no-any-return] - config + config = TRIGGER_SCHEMA(config) + event_class = config[CONF_TYPE] + event_type = config[CONF_SUBTYPE] + + device_registry = dr.async_get(hass) + device = device_registry.async_get(config[CONF_DEVICE_ID]) + assert device is not None + config_entries = [ + hass.config_entries.async_get_entry(entry_id) + for entry_id in device.config_entries + ] + bthome_config_entry = next( + iter(entry for entry in config_entries if entry and entry.domain == DOMAIN) ) + event_classes: list[str] = bthome_config_entry.data.get( + CONF_DISCOVERED_EVENT_CLASSES, [] + ) + + if event_class not in event_classes: + raise InvalidDeviceAutomationConfig( + f"BTHome trigger {event_class} is not valid for device " + f"{device} ({config[CONF_DEVICE_ID]})" + ) + + if event_type not in TRIGGERS_BY_EVENT_CLASS.get(event_class.split("_")[0], ()): + raise InvalidDeviceAutomationConfig( + f"BTHome trigger {event_type} is not valid for device " + f"{device} ({config[CONF_DEVICE_ID]})" + ) + + return config async def async_get_triggers( diff --git a/tests/components/bthome/test_device_trigger.py b/tests/components/bthome/test_device_trigger.py index 459654826f9..c4c900ef6e1 100644 --- a/tests/components/bthome/test_device_trigger.py +++ b/tests/components/bthome/test_device_trigger.py @@ -1,10 +1,19 @@ """Test BTHome BLE events.""" +import pytest + from homeassistant.components import automation from homeassistant.components.bluetooth import DOMAIN as BLUETOOTH_DOMAIN from homeassistant.components.bthome.const import CONF_SUBTYPE, DOMAIN from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_PLATFORM, + CONF_TYPE, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -121,6 +130,117 @@ async def test_get_triggers_button( await hass.async_block_till_done() +async def test_get_triggers_multiple_buttons( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test that we get the expected triggers for multiple buttons device.""" + mac = "A4:C1:38:8D:18:B2" + entry = await _async_setup_bthome_device(hass, mac) + events = async_capture_events(hass, "bthome_ble_event") + + # Emit button_1 long press and button_2 press events + # so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_bthome_v2_adv(mac, b"\x40\x3a\x04\x3a\x01"), + ) + + # wait for the event + await hass.async_block_till_done() + assert len(events) == 2 + + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) + assert device + expected_trigger1 = { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "button_1", + CONF_SUBTYPE: "long_press", + "metadata": {}, + } + expected_trigger2 = { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "button_2", + CONF_SUBTYPE: "press", + "metadata": {}, + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device.id + ) + assert expected_trigger1 in triggers + assert expected_trigger2 in triggers + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("event_class", "event_type", "expected"), + [ + ("button_1", "long_press", STATE_ON), + ("button_2", "press", STATE_ON), + ("button_3", "long_press", STATE_UNAVAILABLE), + ("button", "long_press", STATE_UNAVAILABLE), + ("button_1", "invalid_press", STATE_UNAVAILABLE), + ], +) +async def test_validate_trigger_config( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + event_class: str, + event_type: str, + expected: str, +) -> None: + """Test unsupported trigger does not return a trigger config.""" + mac = "A4:C1:38:8D:18:B2" + entry = await _async_setup_bthome_device(hass, mac) + + # Emit button_1 long press and button_2 press events + # so it creates the device in the registry + inject_bluetooth_service_info_bleak( + hass, + make_bthome_v2_adv(mac, b"\x40\x3a\x04\x3a\x01"), + ) + + # wait for the event + await hass.async_block_till_done() + + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: event_class, + CONF_SUBTYPE: event_type, + }, + "action": { + "service": "test.automation", + "data_template": {"some": "test_trigger_button_long_press"}, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + automations = hass.states.async_entity_ids(automation.DOMAIN) + assert len(automations) == 1 + assert hass.states.get(automations[0]).state == expected + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_get_triggers_dimmer( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -235,7 +355,7 @@ async def test_if_fires_on_motion_detected( make_bthome_v2_adv(mac, b"\x40\x3a\x03"), ) - # # wait for the event + # wait for the event await hass.async_block_till_done() device = device_registry.async_get_device(identifiers={get_device_id(mac)}) From af51241c0dff53cd8e632d269f8a35f4b2c282a3 Mon Sep 17 00:00:00 2001 From: Martins Sipenko Date: Wed, 4 Sep 2024 19:01:49 +0300 Subject: [PATCH 0243/1309] Reenable Smarty integration (#124148) * Reenable Smarty integration * Updated codeowners to myself * Revert "Updated codeowners to myself" This reverts commit 639fef32b90d22117938f864e6ea3c55b0fc5074. * Upgraded pysmarty2 to version 0.10.1 which is not pinned to specific pymodbus version * Update requirements_all.txt --- homeassistant/components/smarty/__init__.py | 2 +- homeassistant/components/smarty/binary_sensor.py | 2 +- homeassistant/components/smarty/fan.py | 2 +- homeassistant/components/smarty/manifest.json | 6 +++--- homeassistant/components/smarty/sensor.py | 2 +- requirements_all.txt | 3 +++ 6 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index cc2e3850ef9..17c4bd0a26a 100644 --- a/homeassistant/components/smarty/__init__.py +++ b/homeassistant/components/smarty/__init__.py @@ -4,7 +4,7 @@ from datetime import timedelta import ipaddress import logging -from pysmarty import Smarty +from pysmarty2 import Smarty import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_NAME, Platform diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py index cf40dc7b982..b31c51244b8 100644 --- a/homeassistant/components/smarty/binary_sensor.py +++ b/homeassistant/components/smarty/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from pysmarty import Smarty +from pysmarty2 import Smarty from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index 37f7c2e493f..a2d72250197 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -6,7 +6,7 @@ import logging import math from typing import Any -from pysmarty import Smarty +from pysmarty2 import Smarty from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json index 8769aa666a7..b83319b6744 100644 --- a/homeassistant/components/smarty/manifest.json +++ b/homeassistant/components/smarty/manifest.json @@ -2,9 +2,9 @@ "domain": "smarty", "name": "Salda Smarty", "codeowners": ["@z0mbieprocess"], - "disabled": "Dependencies not compatible with the new pip resolver", "documentation": "https://www.home-assistant.io/integrations/smarty", + "integration_type": "hub", "iot_class": "local_polling", - "loggers": ["pymodbus", "pysmarty"], - "requirements": ["pysmarty==0.8"] + "loggers": ["pymodbus", "pysmarty2"], + "requirements": ["pysmarty2==0.10.1"] } diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index a0c15b3825f..3c6873611b4 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations import datetime as dt import logging -from pysmarty import Smarty +from pysmarty2 import Smarty from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature diff --git a/requirements_all.txt b/requirements_all.txt index 2ea174ebbc3..3707b52fc59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2225,6 +2225,9 @@ pysmartapp==0.3.5 # homeassistant.components.smartthings pysmartthings==0.7.8 +# homeassistant.components.smarty +pysmarty2==0.10.1 + # homeassistant.components.edl21 pysml==0.0.12 From 186c9aa33b083c7ebf8d214e2cbcad19fb854c3b Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 4 Sep 2024 18:32:57 +0200 Subject: [PATCH 0244/1309] Remove ExternalDevice migration in HomeWizard (#125197) --- homeassistant/components/homewizard/sensor.py | 18 ------- .../homewizard/snapshots/test_sensor.ambr | 33 ------------ tests/components/homewizard/test_sensor.py | 51 +------------------ 3 files changed, 2 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index c5cf0bc64c7..9bb61a467cb 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -625,26 +625,8 @@ async def async_setup_entry( ) -> None: """Initialize sensors.""" - # Migrate original gas meter sensor to ExternalDevice - # This is sensor that was directly linked to the P1 Meter - # Migration can be removed after 2024.8.0 ent_reg = er.async_get(hass) data = entry.runtime_data.data.data - if ( - entity_id := ent_reg.async_get_entity_id( - Platform.SENSOR, DOMAIN, f"{entry.unique_id}_total_gas_m3" - ) - ) and data.gas_unique_id is not None: - ent_reg.async_update_entity( - entity_id, - new_unique_id=f"{DOMAIN}_gas_meter_{data.gas_unique_id}", - ) - - # Remove old gas_unique_id sensor - if entity_id := ent_reg.async_get_entity_id( - Platform.SENSOR, DOMAIN, f"{entry.unique_id}_gas_unique_id" - ): - ent_reg.async_remove(entity_id) # Initialize default sensors entities: list = [ diff --git a/tests/components/homewizard/snapshots/test_sensor.ambr b/tests/components/homewizard/snapshots/test_sensor.ambr index dd50b098d40..5d5b458dccc 100644 --- a/tests/components/homewizard/snapshots/test_sensor.ambr +++ b/tests/components/homewizard/snapshots/test_sensor.ambr @@ -1,37 +1,4 @@ # serializer version: 1 -# name: test_gas_meter_migrated[sensor.homewizard_aabbccddeeff_total_gas_m3:entity-registry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.homewizard_aabbccddeeff_total_gas_m3', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'homewizard', - 'previous_unique_id': 'aabbccddeeff_total_gas_m3', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'homewizard_gas_meter_01FFEEDDCCBBAA99887766554433221100', - 'unit_of_measurement': None, - }) -# --- # name: test_sensors[HWE-KWH1-entity_ids7][sensor.device_apparent_power:device-registry] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/homewizard/test_sensor.py b/tests/components/homewizard/test_sensor.py index abcd6a879c5..c180c2a4def 100644 --- a/tests/components/homewizard/test_sensor.py +++ b/tests/components/homewizard/test_sensor.py @@ -7,14 +7,13 @@ from homewizard_energy.models import Data import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.homewizard import DOMAIN from homeassistant.components.homewizard.const import UPDATE_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import async_fire_time_changed pytestmark = [ pytest.mark.usefixtures("init_integration"), @@ -815,49 +814,3 @@ async def test_entities_not_created_for_device( """Ensures entities for a specific device are not created.""" for entity_id in entity_ids: assert not hass.states.get(entity_id) - - -async def test_gas_meter_migrated( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - init_integration: MockConfigEntry, - snapshot: SnapshotAssertion, -) -> None: - """Test old gas meter sensor is migrated.""" - entity_registry.async_get_or_create( - Platform.SENSOR, - DOMAIN, - "aabbccddeeff_total_gas_m3", - ) - - await hass.config_entries.async_reload(init_integration.entry_id) - await hass.async_block_till_done() - - entity_id = "sensor.homewizard_aabbccddeeff_total_gas_m3" - - assert (entity_entry := entity_registry.async_get(entity_id)) - assert snapshot(name=f"{entity_id}:entity-registry") == entity_entry - - # Make really sure this happens - assert entity_entry.previous_unique_id == "aabbccddeeff_total_gas_m3" - - -async def test_gas_unique_id_removed( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - init_integration: MockConfigEntry, - snapshot: SnapshotAssertion, -) -> None: - """Test old gas meter id sensor is removed.""" - entity_registry.async_get_or_create( - Platform.SENSOR, - DOMAIN, - "aabbccddeeff_gas_unique_id", - ) - - await hass.config_entries.async_reload(init_integration.entry_id) - await hass.async_block_till_done() - - entity_id = "sensor.homewizard_aabbccddeeff_gas_unique_id" - - assert not entity_registry.async_get(entity_id) From 643fd3447823e533c300e770b08c5f679a7f2086 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 4 Sep 2024 18:38:19 +0200 Subject: [PATCH 0245/1309] Improve config flow type hints in starline (#125202) --- .../components/starline/config_flow.py | 67 +++++++++++-------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py index e27885e6c60..5235bd5230b 100644 --- a/homeassistant/components/starline/config_flow.py +++ b/homeassistant/components/starline/config_flow.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Any - from starline import StarlineAuth import voluptuous as vol @@ -33,6 +31,10 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + _app_code: str + _app_token: str + _captcha_image: str + def __init__(self) -> None: """Initialize flow.""" self._app_id: str | None = None @@ -41,59 +43,64 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): self._password: str | None = None self._mfa_code: str | None = None - self._app_code = None - self._app_token = None self._user_slid = None self._user_id = None self._slnet_token = None self._slnet_token_expires = None - self._captcha_image = None - self._captcha_sid = None - self._captcha_code = None + self._captcha_sid: str | None = None + self._captcha_code: str | None = None self._phone_number = None self._auth = StarlineAuth() async def async_step_user( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" return await self.async_step_auth_app(user_input) - async def async_step_auth_app(self, user_input=None, error=None): + async def async_step_auth_app( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Authenticate application step.""" if user_input is not None: self._app_id = user_input[CONF_APP_ID] self._app_secret = user_input[CONF_APP_SECRET] - return await self._async_authenticate_app(error) - return self._async_form_auth_app(error) + return await self._async_authenticate_app() + return self._async_form_auth_app() - async def async_step_auth_user(self, user_input=None, error=None): + async def async_step_auth_user( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Authenticate user step.""" if user_input is not None: self._username = user_input[CONF_USERNAME] self._password = user_input[CONF_PASSWORD] - return await self._async_authenticate_user(error) - return self._async_form_auth_user(error) + return await self._async_authenticate_user() + return self._async_form_auth_user() - async def async_step_auth_mfa(self, user_input=None, error=None): + async def async_step_auth_mfa( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Authenticate mfa step.""" if user_input is not None: self._mfa_code = user_input[CONF_MFA_CODE] - return await self._async_authenticate_user(error) - return self._async_form_auth_mfa(error) + return await self._async_authenticate_user() + return self._async_form_auth_mfa() - async def async_step_auth_captcha(self, user_input=None, error=None): + async def async_step_auth_captcha( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Captcha verification step.""" if user_input is not None: self._captcha_code = user_input[CONF_CAPTCHA_CODE] - return await self._async_authenticate_user(error) - return self._async_form_auth_captcha(error) + return await self._async_authenticate_user() + return self._async_form_auth_captcha() @callback - def _async_form_auth_app(self, error=None): + def _async_form_auth_app(self, error: str | None = None) -> ConfigFlowResult: """Authenticate application form.""" - errors = {} + errors: dict[str, str] = {} if error is not None: errors["base"] = error @@ -113,7 +120,7 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): ) @callback - def _async_form_auth_user(self, error=None): + def _async_form_auth_user(self, error: str | None = None) -> ConfigFlowResult: """Authenticate user form.""" errors = {} if error is not None: @@ -135,7 +142,7 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): ) @callback - def _async_form_auth_mfa(self, error=None): + def _async_form_auth_mfa(self, error: str | None = None) -> ConfigFlowResult: """Authenticate mfa form.""" errors = {} if error is not None: @@ -155,7 +162,7 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): ) @callback - def _async_form_auth_captcha(self, error=None): + def _async_form_auth_captcha(self, error: str | None = None) -> ConfigFlowResult: """Captcha verification form.""" errors = {} if error is not None: @@ -176,7 +183,9 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def _async_authenticate_app(self, error=None): + async def _async_authenticate_app( + self, error: str | None = None + ) -> ConfigFlowResult: """Authenticate application.""" try: self._app_code = await self.hass.async_add_executor_job( @@ -190,7 +199,9 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error auth StarLine: %s", err) return self._async_form_auth_app(ERROR_AUTH_APP) - async def _async_authenticate_user(self, error=None): + async def _async_authenticate_user( + self, error: str | None = None + ) -> ConfigFlowResult: """Authenticate user.""" try: state, data = await self.hass.async_add_executor_job( @@ -223,7 +234,7 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error auth user: %s", err) return self._async_form_auth_user(ERROR_AUTH_USER) - async def _async_get_entry(self): + async def _async_get_entry(self) -> ConfigFlowResult: """Create entry.""" ( self._slnet_token, From 0fb1fbf0d14e82e371ed9f14784549c325033d53 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 4 Sep 2024 18:38:34 +0200 Subject: [PATCH 0246/1309] Improve config flow type hints (q-s) (#125198) * Improve config flow type hints (q-s) * Revert screenlogic * Revert starline --- .../components/rachio/config_flow.py | 4 ++- .../components/radiotherm/config_flow.py | 4 ++- .../components/reolink/config_flow.py | 2 +- homeassistant/components/roon/config_flow.py | 10 ++++--- homeassistant/components/sense/config_flow.py | 13 ++++----- .../components/smappee/config_flow.py | 14 +++++++--- .../components/smartthings/config_flow.py | 27 ++++++++++++------- .../components/smarttub/config_flow.py | 6 ++++- homeassistant/components/soma/config_flow.py | 2 +- .../components/somfy_mylink/config_flow.py | 14 +++++++--- .../components/songpal/config_flow.py | 20 +++++++------- .../components/soundtouch/config_flow.py | 4 ++- .../components/syncthru/config_flow.py | 4 ++- 13 files changed, 82 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index bdd2f81536d..66811091820 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -118,7 +118,9 @@ class OptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, int] | None = None + ) -> ConfigFlowResult: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index e9904318ae9..6bcbe11872d 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -60,7 +60,9 @@ class RadioThermConfigFlow(ConfigFlow, domain=DOMAIN): self.discovered_ip = discovery_info.ip return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Attempt to confirm.""" ip_address = self.discovered_ip init_data = self.discovered_init_data diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 6d0381b025f..067a7e24b8e 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -48,7 +48,7 @@ DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL} class ReolinkOptionsFlowHandler(OptionsFlow): """Handle Reolink options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize ReolinkOptionsFlowHandler.""" self.config_entry = config_entry diff --git a/homeassistant/components/roon/config_flow.py b/homeassistant/components/roon/config_flow.py index de220454852..b896f6775ae 100644 --- a/homeassistant/components/roon/config_flow.py +++ b/homeassistant/components/roon/config_flow.py @@ -142,9 +142,11 @@ class RoonConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_fallback() - async def async_step_fallback(self, user_input=None): + async def async_step_fallback( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Get host and port details from the user.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: self._host = user_input["host"] @@ -155,7 +157,9 @@ class RoonConfigFlow(ConfigFlow, domain=DOMAIN): step_id="fallback", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_link(self, user_input=None): + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle linking and authenticating with the roon server.""" errors = {} if user_input is not None: diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py index 222c6b30f79..c0df40aec9d 100644 --- a/homeassistant/components/sense/config_flow.py +++ b/homeassistant/components/sense/config_flow.py @@ -3,7 +3,7 @@ from collections.abc import Mapping from functools import partial import logging -from typing import TYPE_CHECKING, Any +from typing import Any from sense_energy import ( ASyncSenseable, @@ -34,9 +34,10 @@ class SenseConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _gateway: ASyncSenseable + def __init__(self) -> None: """Init Config .""" - self._gateway: ASyncSenseable | None = None self._auth_data: dict[str, Any] = {} async def validate_input(self, data: Mapping[str, Any]) -> None: @@ -58,14 +59,12 @@ class SenseConfigFlow(ConfigFlow, domain=DOMAIN): client_session=client_session, ) ) - if TYPE_CHECKING: - assert self._gateway self._gateway.rate_limit = ACTIVE_UPDATE_RATE await self._gateway.authenticate( self._auth_data[CONF_EMAIL], self._auth_data[CONF_PASSWORD] ) - async def create_entry_from_data(self): + async def create_entry_from_data(self) -> ConfigFlowResult: """Create the entry from the config data.""" self._auth_data["access_token"] = self._gateway.sense_access_token self._auth_data["user_id"] = self._gateway.sense_user_id @@ -99,7 +98,9 @@ class SenseConfigFlow(ConfigFlow, domain=DOMAIN): return await self.create_entry_from_data() return None - async def async_step_validation(self, user_input=None): + async def async_step_validation( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle validation (2fa) step.""" errors = {} if user_input: diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py index d5073bd9c34..f92f8b17662 100644 --- a/homeassistant/components/smappee/config_flow.py +++ b/homeassistant/components/smappee/config_flow.py @@ -69,9 +69,11 @@ class SmappeeFlowHandler( return await self.async_step_zeroconf_confirm() - async def async_step_zeroconf_confirm(self, user_input=None): + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Confirm zeroconf flow.""" - errors = {} + errors: dict[str, str] = {} # Check if already configured (cloud) if self.is_cloud_device_already_added(): @@ -118,7 +120,9 @@ class SmappeeFlowHandler( return await self.async_step_environment() - async def async_step_environment(self, user_input=None): + async def async_step_environment( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Decide environment, cloud or local.""" if user_input is None: return self.async_show_form( @@ -144,7 +148,9 @@ class SmappeeFlowHandler( return await self.async_step_pick_implementation() - async def async_step_local(self, user_input=None): + async def async_step_local( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle local flow.""" if user_input is None: return self.async_show_form( diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index df5b7a8acfa..081f833787e 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -42,16 +42,17 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 2 + api: SmartThings + app_id: str + location_id: str + def __init__(self) -> None: """Create a new instance of the flow handler.""" - self.access_token = None - self.app_id = None - self.api = None + self.access_token: str | None = None self.oauth_client_secret = None self.oauth_client_id = None self.installed_app_id = None self.refresh_token = None - self.location_id = None self.endpoints_initialized = False async def async_step_import(self, import_data: None) -> ConfigFlowResult: @@ -91,9 +92,11 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): # Show the next screen return await self.async_step_pat() - async def async_step_pat(self, user_input=None): + async def async_step_pat( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Get the Personal Access Token and validate it.""" - errors = {} + errors: dict[str, str] = {} if user_input is None or CONF_ACCESS_TOKEN not in user_input: return self._show_step_pat(errors) @@ -169,7 +172,9 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_select_location() - async def async_step_select_location(self, user_input=None): + async def async_step_select_location( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Ask user to select the location to setup.""" if user_input is None or CONF_LOCATION_ID not in user_input: # Get available locations @@ -196,7 +201,9 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id)) return await self.async_step_authorize() - async def async_step_authorize(self, user_input=None): + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Wait for the user to authorize the app installation.""" user_input = {} if user_input is None else user_input self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID) @@ -233,7 +240,9 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_install(self, data=None): + async def async_step_install( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Create a config entry at completion of a flow and authorization of the app.""" data = { CONF_ACCESS_TOKEN: self.access_token, diff --git a/homeassistant/components/smarttub/config_flow.py b/homeassistant/components/smarttub/config_flow.py index 827375c907c..5caff953d6d 100644 --- a/homeassistant/components/smarttub/config_flow.py +++ b/homeassistant/components/smarttub/config_flow.py @@ -81,9 +81,13 @@ class SmartTubConfigFlow(ConfigFlow, domain=DOMAIN): ) return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" if user_input is None: + if TYPE_CHECKING: + assert self._reauth_input is not None # same as DATA_SCHEMA but with default email data_schema = vol.Schema( { diff --git a/homeassistant/components/soma/config_flow.py b/homeassistant/components/soma/config_flow.py index 586567611f7..caf361d5c3c 100644 --- a/homeassistant/components/soma/config_flow.py +++ b/homeassistant/components/soma/config_flow.py @@ -39,7 +39,7 @@ class SomaFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_creation(user_input) - async def async_step_creation(self, user_input=None): + async def async_step_creation(self, user_input: dict[str, Any]) -> ConfigFlowResult: """Finish config flow.""" try: api = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index 231f93b0cb7..705db43362e 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -132,7 +132,7 @@ class OptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry self.options = deepcopy(dict(config_entry.options)) - self._target_id = None + self._target_id: str | None = None @callback def _async_callback_targets(self): @@ -150,7 +150,9 @@ class OptionsFlowHandler(OptionsFlow): return cover["name"] raise KeyError - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle options flow.""" if self.config_entry.state is not ConfigEntryState.LOADED: @@ -173,9 +175,13 @@ class OptionsFlowHandler(OptionsFlow): return self.async_show_form(step_id="init", data_schema=data_schema, errors={}) - async def async_step_target_config(self, user_input=None, target_id=None): + async def async_step_target_config( + self, user_input: dict[str, bool] | None = None, target_id: str | None = None + ) -> ConfigFlowResult: """Handle options flow for target.""" - reversed_target_ids = self.options.setdefault(CONF_REVERSED_TARGET_IDS, {}) + reversed_target_ids: dict[str | None, bool] = self.options.setdefault( + CONF_REVERSED_TARGET_IDS, {} + ) if user_input is not None: if user_input[CONF_REVERSE] != reversed_target_ids.get(self._target_id): diff --git a/homeassistant/components/songpal/config_flow.py b/homeassistant/components/songpal/config_flow.py index 9ccf7a8f19c..7f10d22b8c6 100644 --- a/homeassistant/components/songpal/config_flow.py +++ b/homeassistant/components/songpal/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from songpal import Device, SongpalException @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) class SongpalConfig: """Device Configuration.""" - def __init__(self, name, host, endpoint): + def __init__(self, name: str, host: str | None, endpoint: str) -> None: """Initialize Configuration.""" self.name = name self.host = host @@ -33,12 +33,10 @@ class SongpalConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize the flow.""" - self.conf: SongpalConfig | None = None + conf: SongpalConfig async def async_step_user( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" if user_input is None: @@ -75,7 +73,9 @@ class SongpalConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_init(user_input) - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle a flow start.""" # Check if already configured self._async_abort_entries_match({CONF_ENDPOINT: self.conf.endpoint}) @@ -122,14 +122,16 @@ class SongpalConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: parsed_url.hostname, } + if TYPE_CHECKING: + assert isinstance(parsed_url.hostname, str) self.conf = SongpalConfig(friendly_name, parsed_url.hostname, endpoint) return await self.async_step_init() - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + async def async_step_import(self, import_data: dict[str, str]) -> ConfigFlowResult: """Import a config entry.""" name = import_data.get(CONF_NAME) - endpoint = import_data.get(CONF_ENDPOINT) + endpoint = import_data[CONF_ENDPOINT] parsed_url = urlparse(endpoint) # Try to connect to test the endpoint diff --git a/homeassistant/components/soundtouch/config_flow.py b/homeassistant/components/soundtouch/config_flow.py index fea63366db9..7c637d71111 100644 --- a/homeassistant/components/soundtouch/config_flow.py +++ b/homeassistant/components/soundtouch/config_flow.py @@ -68,7 +68,9 @@ class SoundtouchConfigFlow(ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"name": self.name} return await self.async_step_zeroconf_confirm() - async def async_step_zeroconf_confirm(self, user_input=None): + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle user-confirmation of discovered node.""" if user_input is not None: return await self._async_create_soundtouch_entry() diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py index 180ba0d9e34..1fb155a5648 100644 --- a/homeassistant/components/syncthru/config_flow.py +++ b/homeassistant/components/syncthru/config_flow.py @@ -64,7 +64,9 @@ class SyncThruConfigFlow(ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {CONF_NAME: self.name} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle discovery confirmation by user.""" if user_input is not None: return await self._async_check_and_create("confirm", user_input) From 349ea35dc39a0768a6248580df1bf7a22c7d952c Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 4 Sep 2024 18:41:20 +0200 Subject: [PATCH 0247/1309] Fix device identifier in ViCare integration (#124483) * use correct serial * add migration handler * adjust init call * add missing types * adjust init call * adjust init call * adjust init call * adjust init call * Update types.py * fix loop * fix loop * fix parameter order * align parameter naming * remove comment * correct init * update * Update types.py * correct merge * revert type change * add test case * add helper * add test case * update snapshot * add snapshot * add device.serial data point * fix device unique id * update snapshot * add comments * update nmigration * fix missing parameter * move static parameters * fix circuit access * update device.serial * update snapshots * remove test case * Update binary_sensor.py * convert climate entity * Update entity.py * update snapshot * use snake case * add migration test * enhance test case * add test case * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vicare/__init__.py | 76 +++++++++++++- homeassistant/components/vicare/climate.py | 42 ++++---- homeassistant/components/vicare/entity.py | 12 ++- .../components/vicare/fixtures/ViAir300F.json | 2 +- .../vicare/fixtures/Vitodens300W.json | 4 +- .../vicare/snapshots/test_binary_sensor.ambr | 16 +-- .../vicare/snapshots/test_button.ambr | 2 +- .../vicare/snapshots/test_climate.ambr | 4 +- .../vicare/snapshots/test_diagnostics.ambr | 4 +- .../components/vicare/snapshots/test_fan.ambr | 2 +- .../vicare/snapshots/test_number.ambr | 22 ++--- .../vicare/snapshots/test_sensor.ambr | 42 ++++---- .../vicare/snapshots/test_water_heater.ambr | 4 +- tests/components/vicare/test_init.py | 99 +++++++++++++++++++ 14 files changed, 252 insertions(+), 79 deletions(-) create mode 100644 tests/components/vicare/test_init.py diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 0c87cd6f4fe..ead210e2816 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -15,10 +15,12 @@ from PyViCare.PyViCareUtils import ( PyViCareInvalidCredentialsError, ) +from homeassistant.components.climate import DOMAIN as DOMAIN_CLIMATE from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.storage import STORAGE_DIR from .const import ( @@ -47,6 +49,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError) as err: raise ConfigEntryAuthFailed("Authentication failed") from err + for device in hass.data[DOMAIN][entry.entry_id][DEVICE_LIST]: + # Migration can be removed in 2025.4.0 + await async_migrate_devices(hass, entry, device) + # Migration can be removed in 2025.4.0 + await async_migrate_entities(hass, entry, device) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -109,6 +117,72 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_migrate_devices( + hass: HomeAssistant, entry: ConfigEntry, device: ViCareDevice +) -> None: + """Migrate old entry.""" + registry = dr.async_get(hass) + + gateway_serial: str = device.config.getConfig().serial + device_serial: str = device.api.getSerial() + + old_identifier = gateway_serial + new_identifier = f"{gateway_serial}_{device_serial}" + + # Migrate devices + for device_entry in dr.async_entries_for_config_entry(registry, entry.entry_id): + if device_entry.identifiers == {(DOMAIN, old_identifier)}: + _LOGGER.debug("Migrating device %s", device_entry.name) + registry.async_update_device( + device_entry.id, + serial_number=device_serial, + new_identifiers={(DOMAIN, new_identifier)}, + ) + + +async def async_migrate_entities( + hass: HomeAssistant, entry: ConfigEntry, device: ViCareDevice +) -> None: + """Migrate old entry.""" + gateway_serial: str = device.config.getConfig().serial + device_serial: str = device.api.getSerial() + new_identifier = f"{gateway_serial}_{device_serial}" + + @callback + def _update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if not entity_entry.unique_id.startswith(gateway_serial): + # belongs to other device/gateway + return None + if entity_entry.unique_id.startswith(f"{gateway_serial}_"): + # Already correct, nothing to do + return None + + unique_id_parts = entity_entry.unique_id.split("-") + unique_id_parts[0] = new_identifier + + # convert climate entity unique id from `-` to `-heating-` + if entity_entry.domain == DOMAIN_CLIMATE: + unique_id_parts[len(unique_id_parts) - 1] = ( + f"{entity_entry.translation_key}-{unique_id_parts[len(unique_id_parts)-1]}" + ) + + entity_new_unique_id = "-".join(unique_id_parts) + + _LOGGER.debug( + "Migrating entity %s from %s to new id %s", + entity_entry.entity_id, + entity_entry.unique_id, + entity_new_unique_id, + ) + return {"new_unique_id": entity_new_unique_id} + + # Migrate entities + await er.async_migrate_entries(hass, entry.entry_id, _update_unique_id) + + def get_supported_devices( devices: list[PyViCareDeviceConfig], ) -> list[PyViCareDeviceConfig]: diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 4968e565d0b..410395760ea 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -148,10 +148,10 @@ class ViCareClimate(ViCareEntity, ClimateEntity): circuit: PyViCareHeatingCircuit, ) -> None: """Initialize the climate device.""" - super().__init__(circuit.id, device_config, device) - self._circuit = circuit + super().__init__(self._attr_translation_key, device_config, device, circuit) + self._device = device self._attributes: dict[str, Any] = {} - self._attributes["vicare_programs"] = self._circuit.getPrograms() + self._attributes["vicare_programs"] = self._api.getPrograms() self._attr_preset_modes = [ preset for heating_program in self._attributes["vicare_programs"] @@ -163,11 +163,11 @@ class ViCareClimate(ViCareEntity, ClimateEntity): try: _room_temperature = None with suppress(PyViCareNotSupportedFeatureError): - _room_temperature = self._circuit.getRoomTemperature() + _room_temperature = self._api.getRoomTemperature() _supply_temperature = None with suppress(PyViCareNotSupportedFeatureError): - _supply_temperature = self._circuit.getSupplyTemperature() + _supply_temperature = self._api.getSupplyTemperature() if _room_temperature is not None: self._attr_current_temperature = _room_temperature @@ -177,15 +177,13 @@ class ViCareClimate(ViCareEntity, ClimateEntity): self._attr_current_temperature = None with suppress(PyViCareNotSupportedFeatureError): - self._current_program = self._circuit.getActiveProgram() + self._current_program = self._api.getActiveProgram() with suppress(PyViCareNotSupportedFeatureError): - self._attr_target_temperature = ( - self._circuit.getCurrentDesiredTemperature() - ) + self._attr_target_temperature = self._api.getCurrentDesiredTemperature() with suppress(PyViCareNotSupportedFeatureError): - self._current_mode = self._circuit.getActiveMode() + self._current_mode = self._api.getActiveMode() # Update the generic device attributes self._attributes = { @@ -196,25 +194,25 @@ class ViCareClimate(ViCareEntity, ClimateEntity): with suppress(PyViCareNotSupportedFeatureError): self._attributes["heating_curve_slope"] = ( - self._circuit.getHeatingCurveSlope() + self._api.getHeatingCurveSlope() ) with suppress(PyViCareNotSupportedFeatureError): self._attributes["heating_curve_shift"] = ( - self._circuit.getHeatingCurveShift() + self._api.getHeatingCurveShift() ) with suppress(PyViCareNotSupportedFeatureError): - self._attributes["vicare_modes"] = self._circuit.getModes() + self._attributes["vicare_modes"] = self._api.getModes() self._current_action = False # Update the specific device attributes with suppress(PyViCareNotSupportedFeatureError): - for burner in get_burners(self._api): + for burner in get_burners(self._device): self._current_action = self._current_action or burner.getActive() with suppress(PyViCareNotSupportedFeatureError): - for compressor in get_compressors(self._api): + for compressor in get_compressors(self._device): self._current_action = ( self._current_action or compressor.getActive() ) @@ -245,9 +243,9 @@ class ViCareClimate(ViCareEntity, ClimateEntity): raise ValueError(f"Cannot set invalid hvac mode: {hvac_mode}") _LOGGER.debug("Setting hvac mode to %s / %s", hvac_mode, vicare_mode) - self._circuit.setMode(vicare_mode) + self._api.setMode(vicare_mode) - def vicare_mode_from_hvac_mode(self, hvac_mode): + def vicare_mode_from_hvac_mode(self, hvac_mode) -> str | None: """Return the corresponding vicare mode for an hvac_mode.""" if "vicare_modes" not in self._attributes: return None @@ -283,7 +281,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): def set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: - self._circuit.setProgramTemperature(self._current_program, temp) + self._api.setProgramTemperature(self._current_program, temp) self._attr_target_temperature = temp @property @@ -312,7 +310,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): ): _LOGGER.debug("deactivating %s", self._current_program) try: - self._circuit.deactivateProgram(self._current_program) + self._api.deactivateProgram(self._current_program) except PyViCareCommandError as err: raise ServiceValidationError( translation_domain=DOMAIN, @@ -326,7 +324,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): if target_program in CHANGABLE_HEATING_PROGRAMS: _LOGGER.debug("activating %s", target_program) try: - self._circuit.activateProgram(target_program) + self._api.activateProgram(target_program) except PyViCareCommandError as err: raise ServiceValidationError( translation_domain=DOMAIN, @@ -341,9 +339,9 @@ class ViCareClimate(ViCareEntity, ClimateEntity): """Show Device Attributes.""" return self._attributes - def set_vicare_mode(self, vicare_mode): + def set_vicare_mode(self, vicare_mode) -> None: """Service function to set vicare modes directly.""" if vicare_mode not in self._attributes["vicare_modes"]: raise ValueError(f"Cannot set invalid vicare mode: {vicare_mode}.") - self._circuit.setMode(vicare_mode) + self._api.setMode(vicare_mode) diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index eef114b4039..f48243e83e1 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -25,18 +25,20 @@ class ViCareEntity(Entity): component: PyViCareHeatingDeviceComponent | None = None, ) -> None: """Initialize the entity.""" + gateway_serial = device_config.getConfig().serial + device_serial = device.getSerial() + identifier = f"{gateway_serial}_{device_serial}" + self._api: PyViCareDevice | PyViCareHeatingDeviceComponent = ( component if component else device ) - - self._attr_unique_id = f"{device_config.getConfig().serial}-{unique_id_suffix}" - # valid for compressors, circuits, burners (HeatingDeviceWithComponent) + self._attr_unique_id = f"{identifier}-{unique_id_suffix}" if component: self._attr_unique_id += f"-{component.id}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_config.getConfig().serial)}, - serial_number=device.getSerial(), + identifiers={(DOMAIN, identifier)}, + serial_number=device_serial, name=device_config.getModel(), manufacturer="Viessmann", model=device_config.getModel(), diff --git a/tests/components/vicare/fixtures/ViAir300F.json b/tests/components/vicare/fixtures/ViAir300F.json index b1ec747e127..090c7a81ddf 100644 --- a/tests/components/vicare/fixtures/ViAir300F.json +++ b/tests/components/vicare/fixtures/ViAir300F.json @@ -50,7 +50,7 @@ "properties": { "value": { "type": "string", - "value": "################" + "value": "deviceSerialViAir300F" } }, "timestamp": "2024-03-20T01:29:35.549Z", diff --git a/tests/components/vicare/fixtures/Vitodens300W.json b/tests/components/vicare/fixtures/Vitodens300W.json index bb86bda981b..d183146e94d 100644 --- a/tests/components/vicare/fixtures/Vitodens300W.json +++ b/tests/components/vicare/fixtures/Vitodens300W.json @@ -11,10 +11,10 @@ "properties": { "value": { "type": "string", - "value": "################" + "value": "deviceSerialVitodens300W" } }, - "timestamp": "2024-03-20T01:29:35.549Z", + "timestamp": "2024-07-30T20:03:40.073Z", "uri": "https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial" }, { diff --git a/tests/components/vicare/snapshots/test_binary_sensor.ambr b/tests/components/vicare/snapshots/test_binary_sensor.ambr index a03a6150c45..f3e4d4e1c84 100644 --- a/tests/components/vicare/snapshots/test_binary_sensor.ambr +++ b/tests/components/vicare/snapshots/test_binary_sensor.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'burner', - 'unique_id': 'gateway0-burner_active-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-burner_active-0', 'unit_of_measurement': None, }) # --- @@ -75,7 +75,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'circulation_pump', - 'unique_id': 'gateway0-circulationpump_active-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-circulationpump_active-0', 'unit_of_measurement': None, }) # --- @@ -122,7 +122,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'circulation_pump', - 'unique_id': 'gateway0-circulationpump_active-1', + 'unique_id': 'gateway0_deviceSerialVitodens300W-circulationpump_active-1', 'unit_of_measurement': None, }) # --- @@ -169,7 +169,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'domestic_hot_water_charging', - 'unique_id': 'gateway0-charging_active', + 'unique_id': 'gateway0_deviceSerialVitodens300W-charging_active', 'unit_of_measurement': None, }) # --- @@ -216,7 +216,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'domestic_hot_water_circulation_pump', - 'unique_id': 'gateway0-dhw_circulationpump_active', + 'unique_id': 'gateway0_deviceSerialVitodens300W-dhw_circulationpump_active', 'unit_of_measurement': None, }) # --- @@ -263,7 +263,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'domestic_hot_water_pump', - 'unique_id': 'gateway0-dhw_pump_active', + 'unique_id': 'gateway0_deviceSerialVitodens300W-dhw_pump_active', 'unit_of_measurement': None, }) # --- @@ -310,7 +310,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'frost_protection', - 'unique_id': 'gateway0-frost_protection_active-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-frost_protection_active-0', 'unit_of_measurement': None, }) # --- @@ -356,7 +356,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'frost_protection', - 'unique_id': 'gateway0-frost_protection_active-1', + 'unique_id': 'gateway0_deviceSerialVitodens300W-frost_protection_active-1', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/vicare/snapshots/test_button.ambr b/tests/components/vicare/snapshots/test_button.ambr index 01120b8b0d6..9fadc6a983f 100644 --- a/tests/components/vicare/snapshots/test_button.ambr +++ b/tests/components/vicare/snapshots/test_button.ambr @@ -28,7 +28,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'activate_onetimecharge', - 'unique_id': 'gateway0-activate_onetimecharge', + 'unique_id': 'gateway0_deviceSerialVitodens300W-activate_onetimecharge', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/vicare/snapshots/test_climate.ambr b/tests/components/vicare/snapshots/test_climate.ambr index a01d1c43bea..aea0ea879c2 100644 --- a/tests/components/vicare/snapshots/test_climate.ambr +++ b/tests/components/vicare/snapshots/test_climate.ambr @@ -40,7 +40,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'heating', - 'unique_id': 'gateway0-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-heating-0', 'unit_of_measurement': None, }) # --- @@ -123,7 +123,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'heating', - 'unique_id': 'gateway0-1', + 'unique_id': 'gateway0_deviceSerialVitodens300W-heating-1', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index 430b2de35ad..120bdf7a333 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -16,10 +16,10 @@ 'properties': dict({ 'value': dict({ 'type': 'string', - 'value': '################', + 'value': 'deviceSerialVitodens300W', }), }), - 'timestamp': '2024-03-20T01:29:35.549Z', + 'timestamp': '2024-07-30T20:03:40.073Z', 'uri': 'https://api.viessmann.com/iot/v1/features/installations/#######/gateways/################/devices/0/features/device.serial', }), dict({ diff --git a/tests/components/vicare/snapshots/test_fan.ambr b/tests/components/vicare/snapshots/test_fan.ambr index 48c8d728569..8ec4bc41d8d 100644 --- a/tests/components/vicare/snapshots/test_fan.ambr +++ b/tests/components/vicare/snapshots/test_fan.ambr @@ -35,7 +35,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'ventilation', - 'unique_id': 'gateway0-ventilation', + 'unique_id': 'gateway0_deviceSerialViAir300F-ventilation', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/vicare/snapshots/test_number.ambr b/tests/components/vicare/snapshots/test_number.ambr index e6e87ce5dc7..5a030fc0213 100644 --- a/tests/components/vicare/snapshots/test_number.ambr +++ b/tests/components/vicare/snapshots/test_number.ambr @@ -33,7 +33,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'comfort_temperature', - 'unique_id': 'gateway0-comfort_temperature-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-comfort_temperature-0', 'unit_of_measurement': , }) # --- @@ -90,7 +90,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'comfort_temperature', - 'unique_id': 'gateway0-comfort_temperature-1', + 'unique_id': 'gateway0_deviceSerialVitodens300W-comfort_temperature-1', 'unit_of_measurement': , }) # --- @@ -147,7 +147,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'heating_curve_shift', - 'unique_id': 'gateway0-heating curve shift-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-heating curve shift-0', 'unit_of_measurement': , }) # --- @@ -204,7 +204,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'heating_curve_shift', - 'unique_id': 'gateway0-heating curve shift-1', + 'unique_id': 'gateway0_deviceSerialVitodens300W-heating curve shift-1', 'unit_of_measurement': , }) # --- @@ -261,7 +261,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'heating_curve_slope', - 'unique_id': 'gateway0-heating curve slope-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-heating curve slope-0', 'unit_of_measurement': None, }) # --- @@ -316,7 +316,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'heating_curve_slope', - 'unique_id': 'gateway0-heating curve slope-1', + 'unique_id': 'gateway0_deviceSerialVitodens300W-heating curve slope-1', 'unit_of_measurement': None, }) # --- @@ -371,7 +371,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'normal_temperature', - 'unique_id': 'gateway0-normal_temperature-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-normal_temperature-0', 'unit_of_measurement': , }) # --- @@ -428,7 +428,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'normal_temperature', - 'unique_id': 'gateway0-normal_temperature-1', + 'unique_id': 'gateway0_deviceSerialVitodens300W-normal_temperature-1', 'unit_of_measurement': , }) # --- @@ -485,7 +485,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'reduced_temperature', - 'unique_id': 'gateway0-reduced_temperature-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-reduced_temperature-0', 'unit_of_measurement': , }) # --- @@ -542,7 +542,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'reduced_temperature', - 'unique_id': 'gateway0-reduced_temperature-1', + 'unique_id': 'gateway0_deviceSerialVitodens300W-reduced_temperature-1', 'unit_of_measurement': , }) # --- @@ -599,7 +599,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dhw_temperature', - 'unique_id': 'gateway0-dhw_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-dhw_temperature', 'unit_of_measurement': , }) # --- diff --git a/tests/components/vicare/snapshots/test_sensor.ambr b/tests/components/vicare/snapshots/test_sensor.ambr index 7bbac75bedc..43e5b713293 100644 --- a/tests/components/vicare/snapshots/test_sensor.ambr +++ b/tests/components/vicare/snapshots/test_sensor.ambr @@ -30,7 +30,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'boiler_temperature', - 'unique_id': 'gateway0-boiler_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-boiler_temperature', 'unit_of_measurement': , }) # --- @@ -81,7 +81,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'burner_hours', - 'unique_id': 'gateway0-burner_hours-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-burner_hours-0', 'unit_of_measurement': , }) # --- @@ -131,7 +131,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'burner_modulation', - 'unique_id': 'gateway0-burner_modulation-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-burner_modulation-0', 'unit_of_measurement': '%', }) # --- @@ -181,7 +181,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'burner_starts', - 'unique_id': 'gateway0-burner_starts-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-burner_starts-0', 'unit_of_measurement': None, }) # --- @@ -230,7 +230,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hotwater_gas_consumption_heating_this_month', - 'unique_id': 'gateway0-hotwater_gas_consumption_heating_this_month', + 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_gas_consumption_heating_this_month', 'unit_of_measurement': None, }) # --- @@ -279,7 +279,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hotwater_gas_consumption_heating_this_week', - 'unique_id': 'gateway0-hotwater_gas_consumption_heating_this_week', + 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_gas_consumption_heating_this_week', 'unit_of_measurement': None, }) # --- @@ -328,7 +328,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hotwater_gas_consumption_heating_this_year', - 'unique_id': 'gateway0-hotwater_gas_consumption_heating_this_year', + 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_gas_consumption_heating_this_year', 'unit_of_measurement': None, }) # --- @@ -377,7 +377,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hotwater_gas_consumption_today', - 'unique_id': 'gateway0-hotwater_gas_consumption_today', + 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_gas_consumption_today', 'unit_of_measurement': None, }) # --- @@ -426,7 +426,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hotwater_max_temperature', - 'unique_id': 'gateway0-hotwater_max_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_max_temperature', 'unit_of_measurement': , }) # --- @@ -477,7 +477,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hotwater_min_temperature', - 'unique_id': 'gateway0-hotwater_min_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-hotwater_min_temperature', 'unit_of_measurement': , }) # --- @@ -528,7 +528,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power consumption this month', - 'unique_id': 'gateway0-power consumption this month', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this month', 'unit_of_measurement': , }) # --- @@ -579,7 +579,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_consumption_this_year', - 'unique_id': 'gateway0-power consumption this year', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this year', 'unit_of_measurement': , }) # --- @@ -630,7 +630,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_consumption_today', - 'unique_id': 'gateway0-power consumption today', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption today', 'unit_of_measurement': , }) # --- @@ -681,7 +681,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'gas_consumption_heating_this_month', - 'unique_id': 'gateway0-gas_consumption_heating_this_month', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_month', 'unit_of_measurement': None, }) # --- @@ -730,7 +730,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'gas_consumption_heating_this_week', - 'unique_id': 'gateway0-gas_consumption_heating_this_week', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_week', 'unit_of_measurement': None, }) # --- @@ -779,7 +779,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'gas_consumption_heating_this_year', - 'unique_id': 'gateway0-gas_consumption_heating_this_year', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_this_year', 'unit_of_measurement': None, }) # --- @@ -828,7 +828,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'gas_consumption_heating_today', - 'unique_id': 'gateway0-gas_consumption_heating_today', + 'unique_id': 'gateway0_deviceSerialVitodens300W-gas_consumption_heating_today', 'unit_of_measurement': None, }) # --- @@ -877,7 +877,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', - 'unique_id': 'gateway0-outside_temperature', + 'unique_id': 'gateway0_deviceSerialVitodens300W-outside_temperature', 'unit_of_measurement': , }) # --- @@ -928,7 +928,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'power_consumption_this_week', - 'unique_id': 'gateway0-power consumption this week', + 'unique_id': 'gateway0_deviceSerialVitodens300W-power consumption this week', 'unit_of_measurement': , }) # --- @@ -979,7 +979,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'supply_temperature', - 'unique_id': 'gateway0-supply_temperature-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-supply_temperature-0', 'unit_of_measurement': , }) # --- @@ -1030,7 +1030,7 @@ 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'supply_temperature', - 'unique_id': 'gateway0-supply_temperature-1', + 'unique_id': 'gateway0_deviceSerialVitodens300W-supply_temperature-1', 'unit_of_measurement': , }) # --- diff --git a/tests/components/vicare/snapshots/test_water_heater.ambr b/tests/components/vicare/snapshots/test_water_heater.ambr index 5ab4fcc78bd..bca04b1bbfa 100644 --- a/tests/components/vicare/snapshots/test_water_heater.ambr +++ b/tests/components/vicare/snapshots/test_water_heater.ambr @@ -31,7 +31,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'domestic_hot_water', - 'unique_id': 'gateway0-0', + 'unique_id': 'gateway0_deviceSerialVitodens300W-0', 'unit_of_measurement': None, }) # --- @@ -87,7 +87,7 @@ 'previous_unique_id': None, 'supported_features': , 'translation_key': 'domestic_hot_water', - 'unique_id': 'gateway0-1', + 'unique_id': 'gateway0_deviceSerialVitodens300W-1', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/vicare/test_init.py b/tests/components/vicare/test_init.py new file mode 100644 index 00000000000..fea7b5985f1 --- /dev/null +++ b/tests/components/vicare/test_init.py @@ -0,0 +1,99 @@ +"""Test ViCare migration.""" + +from unittest.mock import patch + +from homeassistant.components.vicare.const import DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import MODULE +from .conftest import Fixture, MockPyViCare + +from tests.common import MockConfigEntry + + +# Device migration test can be removed in 2025.4.0 +async def test_device_migration( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the device registry is updated correctly.""" + fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + with ( + patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), + ): + mock_config_entry.add_to_hass(hass) + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={ + (DOMAIN, "gateway0"), + }, + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + assert device_registry.async_get_device(identifiers={(DOMAIN, "gateway0")}) is None + + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, "gateway0_deviceSerialVitodens300W")} + ) + is not None + ) + + +# Entity migration test can be removed in 2025.4.0 +async def test_climate_entity_migration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that the climate entity unique_id gets migrated correctly.""" + fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + with ( + patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), + patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), + ): + mock_config_entry.add_to_hass(hass) + + entry1 = entity_registry.async_get_or_create( + domain=Platform.CLIMATE, + platform=DOMAIN, + config_entry=mock_config_entry, + unique_id="gateway0-0", + translation_key="heating", + ) + entry2 = entity_registry.async_get_or_create( + domain=Platform.CLIMATE, + platform=DOMAIN, + config_entry=mock_config_entry, + unique_id="gateway0_deviceSerialVitodens300W-heating-1", + translation_key="heating", + ) + entry3 = entity_registry.async_get_or_create( + domain=Platform.CLIMATE, + platform=DOMAIN, + config_entry=mock_config_entry, + unique_id="gateway1-0", + translation_key="heating", + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + assert ( + entity_registry.async_get(entry1.entity_id).unique_id + == "gateway0_deviceSerialVitodens300W-heating-0" + ) + assert ( + entity_registry.async_get(entry2.entity_id).unique_id + == "gateway0_deviceSerialVitodens300W-heating-1" + ) + assert entity_registry.async_get(entry3.entity_id).unique_id == "gateway1-0" From 416a2de179d2035cb90499deb85964bdce6f7c18 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 4 Sep 2024 19:09:41 +0200 Subject: [PATCH 0248/1309] Improve config flow type hints in screenlogic (#125199) --- .../components/screenlogic/config_flow.py | 16 +++++++++------- .../components/screenlogic/coordinator.py | 5 ++++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 74a01fdeaa2..4a46756cf2f 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -32,9 +32,9 @@ GATEWAY_MANUAL_ENTRY = "manual" PENTAIR_OUI = "00-C0-33" -async def async_discover_gateways_by_unique_id(hass): +async def async_discover_gateways_by_unique_id() -> dict[str, dict[str, Any]]: """Discover gateways and return a dict of them by unique id.""" - discovered_gateways = {} + discovered_gateways: dict[str, dict[str, Any]] = {} try: hosts = await discovery.async_discover() _LOGGER.debug("Discovered hosts: %s", hosts) @@ -51,16 +51,16 @@ async def async_discover_gateways_by_unique_id(hass): return discovered_gateways -def _extract_mac_from_name(name): +def _extract_mac_from_name(name: str) -> str: return format_mac(f"{PENTAIR_OUI}-{name.split(':')[1].strip()}") -def short_mac(mac): +def short_mac(mac: str) -> str: """Short version of the mac as seen in the app.""" return "-".join(mac.split(":")[3:]).upper() -def name_for_mac(mac): +def name_for_mac(mac: str) -> str: """Derive the gateway name from the mac.""" return f"Pentair: {short_mac(mac)}" @@ -83,9 +83,11 @@ class ScreenlogicConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for ScreenLogic.""" return ScreenLogicOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None) -> ConfigFlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle the start of the config flow.""" - self.discovered_gateways = await async_discover_gateways_by_unique_id(self.hass) + self.discovered_gateways = await async_discover_gateways_by_unique_id() return await self.async_step_gateway_select() async def async_step_dhcp( diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index 281bac86e01..a90c9cb2cf4 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -2,6 +2,7 @@ from datetime import timedelta import logging +from typing import TYPE_CHECKING from screenlogicpy import ScreenLogicGateway from screenlogicpy.const.common import ( @@ -33,11 +34,13 @@ async def async_get_connect_info( """Construct connect_info from configuration entry and returns it to caller.""" mac = entry.unique_id # Attempt to rediscover gateway to follow IP changes - discovered_gateways = await async_discover_gateways_by_unique_id(hass) + discovered_gateways = await async_discover_gateways_by_unique_id() if mac in discovered_gateways: return discovered_gateways[mac] _LOGGER.debug("Gateway rediscovery failed for %s", entry.title) + if TYPE_CHECKING: + assert mac is not None # Static connection defined or fallback from discovery return { SL_GATEWAY_NAME: name_for_mac(mac), From 7266a16295774488e55f410644b18e551ced4088 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 5 Sep 2024 03:10:59 +1000 Subject: [PATCH 0249/1309] Add Button platform for Smlight integration (#124970) * Add button platform for smlight integration * Add strings required for button platform * Add commands api to smlight mock client * Add tests for smlight button platform * Move entity category to class * Disable by default Zigbee flash mode --- homeassistant/components/smlight/__init__.py | 1 + homeassistant/components/smlight/button.py | 87 +++++++++++++++++++ homeassistant/components/smlight/strings.json | 11 +++ tests/components/smlight/conftest.py | 4 +- tests/components/smlight/test_button.py | 77 ++++++++++++++++ 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smlight/button.py create mode 100644 tests/components/smlight/test_button.py diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 16eb60b9c87..47dc943423e 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant from .coordinator import SmDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.BUTTON, Platform.SENSOR, ] type SmConfigEntry = ConfigEntry[SmDataUpdateCoordinator] diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py new file mode 100644 index 00000000000..b6a0c24c2ed --- /dev/null +++ b/homeassistant/components/smlight/button.py @@ -0,0 +1,87 @@ +"""Support for SLZB-06 buttons.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging +from typing import Final + +from pysmlight.web import CmdWrapper + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import SmDataUpdateCoordinator +from .entity import SmEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class SmButtonDescription(ButtonEntityDescription): + """Class to describe a Button entity.""" + + press_fn: Callable[[CmdWrapper], Awaitable[None]] + + +BUTTONS: Final = [ + SmButtonDescription( + key="core_restart", + translation_key="core_restart", + device_class=ButtonDeviceClass.RESTART, + press_fn=lambda cmd: cmd.reboot(), + ), + SmButtonDescription( + key="zigbee_restart", + translation_key="zigbee_restart", + device_class=ButtonDeviceClass.RESTART, + press_fn=lambda cmd: cmd.zb_restart(), + ), + SmButtonDescription( + key="zigbee_flash_mode", + translation_key="zigbee_flash_mode", + entity_registry_enabled_default=False, + press_fn=lambda cmd: cmd.zb_bootloader(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SMLIGHT buttons based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities(SmButton(coordinator, button) for button in BUTTONS) + + +class SmButton(SmEntity, ButtonEntity): + """Defines a SLZB-06 button.""" + + entity_description: SmButtonDescription + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + coordinator: SmDataUpdateCoordinator, + description: SmButtonDescription, + ) -> None: + """Initialize SLZB-06 button entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + + async def async_press(self) -> None: + """Trigger button press.""" + await self.entity_description.press_fn(self.coordinator.client.cmds) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 02b9ebcc4e8..f81e977b40c 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -44,6 +44,17 @@ "ram_usage": { "name": "RAM usage" } + }, + "button": { + "core_restart": { + "name": "Core restart" + }, + "zigbee_restart": { + "name": "Zigbee restart" + }, + "zigbee_flash_mode": { + "name": "Zigbee flash mode" + } } } } diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 93493daf51d..ad4d749c0d2 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -3,7 +3,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch -from pysmlight.web import Info, Sensors +from pysmlight.web import CmdWrapper, Info, Sensors import pytest from homeassistant.components.smlight import PLATFORMS @@ -75,6 +75,8 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: api.check_auth_needed.return_value = False api.authenticate.return_value = True + api.cmds = AsyncMock(spec_set=CmdWrapper) + yield api diff --git a/tests/components/smlight/test_button.py b/tests/components/smlight/test_button.py new file mode 100644 index 00000000000..487351acdea --- /dev/null +++ b/tests/components/smlight/test_button.py @@ -0,0 +1,77 @@ +"""Tests for SMLIGHT SLZB-06 button entities.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> Platform | list[Platform]: + """Platforms, which should be loaded during the test.""" + return [Platform.BUTTON] + + +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("core_restart", "reboot"), + ("zigbee_flash_mode", "zb_bootloader"), + ("zigbee_restart", "zb_restart"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_buttons( + hass: HomeAssistant, + entity_id: str, + entity_registry: er.EntityRegistry, + method: str, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test creation of button entities.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(f"button.mock_title_{entity_id}") + assert state is not None + assert state.state == STATE_UNKNOWN + + entry = entity_registry.async_get(f"button.mock_title_{entity_id}") + assert entry is not None + assert entry.unique_id == f"aa:bb:cc:dd:ee:ff-{entity_id}" + + mock_method = getattr(mock_smlight_client.cmds, method) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: f"button.mock_title_{entity_id}"}, + blocking=True, + ) + + assert len(mock_method.mock_calls) == 1 + mock_method.assert_called_with() + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_disabled_by_default_button( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the disabled by default flash mode button.""" + await setup_integration(hass, mock_config_entry) + + assert not hass.states.get("button.mock_title_zigbee_flash_mode") + + assert (entry := entity_registry.async_get("button.mock_title_zigbee_flash_mode")) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION From bad305dcbfb1f672def2a67ae9174cb94d7f8cf1 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 4 Sep 2024 19:11:34 +0200 Subject: [PATCH 0250/1309] Add Onkyo to strict typing (#124617) --- .strict-typing | 1 + .../components/onkyo/media_player.py | 34 +++++++++++-------- mypy.ini | 10 ++++++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/.strict-typing b/.strict-typing index 1d73b05fdea..e93f1589cc8 100644 --- a/.strict-typing +++ b/.strict-typing @@ -341,6 +341,7 @@ homeassistant.components.nut.* homeassistant.components.onboarding.* homeassistant.components.oncue.* homeassistant.components.onewire.* +homeassistant.components.onkyo.* homeassistant.components.open_meteo.* homeassistant.components.openexchangerates.* homeassistant.components.opensky.* diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index df1f25a196b..1718ecb36be 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import logging -from typing import Any +from typing import Any, Literal import pyeiscp import voluptuous as vol @@ -23,7 +23,7 @@ from homeassistant.const import ( CONF_NAME, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -269,7 +269,7 @@ async def async_setup_platform( _LOGGER.debug("Manually creating receiver: %s (%s)", name, host) @callback - async def async_onkyo_interview_callback(conn: pyeiscp.Connection): + async def async_onkyo_interview_callback(conn: pyeiscp.Connection) -> None: """Receiver interviewed, connection not yet active.""" info = ReceiverInfo(conn.host, conn.port, conn.name, conn.identifier) _LOGGER.debug("Receiver interviewed: %s (%s)", info.model_name, info.host) @@ -285,7 +285,7 @@ async def async_setup_platform( _LOGGER.debug("Discovering receivers") @callback - async def async_onkyo_discovery_callback(conn: pyeiscp.Connection): + async def async_onkyo_discovery_callback(conn: pyeiscp.Connection) -> None: """Receiver discovered, connection not yet active.""" info = ReceiverInfo(conn.host, conn.port, conn.name, conn.identifier) _LOGGER.debug("Receiver discovered: %s (%s)", info.model_name, info.host) @@ -298,7 +298,7 @@ async def async_setup_platform( ) @callback - def close_receiver(_event): + def close_receiver(_event: Event) -> None: for receiver in receivers.values(): receiver.conn.close() @@ -495,19 +495,23 @@ class OnkyoMediaPlayer(MediaPlayerEntity): self.async_write_ha_state() @callback - def _parse_source(self, source): + def _parse_source(self, source_raw: str | int | tuple[str]) -> None: # source is either a tuple of values or a single value, # so we convert to a tuple, when it is a single value. - if not isinstance(source, tuple): - source = (source,) + if isinstance(source_raw, str | int): + source = (str(source_raw),) + else: + source = source_raw for value in source: if value in self._source_mapping: self._attr_source = self._source_mapping[value] - break - self._attr_source = "_".join(source) + return + self._attr_source = "_".join(source) @callback - def _parse_audio_information(self, audio_information): + def _parse_audio_information( + self, audio_information: tuple[str] | Literal["N/A"] + ) -> None: # If audio information is not available, N/A is returned, # so only update the audio information, when it is not N/A. if audio_information == "N/A": @@ -523,7 +527,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity): } @callback - def _parse_video_information(self, video_information): + def _parse_video_information( + self, video_information: tuple[str] | Literal["N/A"] + ) -> None: # If video information is not available, N/A is returned, # so only update the video information, when it is not N/A. if video_information == "N/A": @@ -538,11 +544,11 @@ class OnkyoMediaPlayer(MediaPlayerEntity): if len(value) > 0 } - def _query_av_info_delayed(self): + def _query_av_info_delayed(self) -> None: if self._zone == "main" and not self._query_timer: @callback - def _query_av_info(): + def _query_av_info() -> None: if self._supports_audio_info: self._query_receiver("audio-information") if self._supports_video_info: diff --git a/mypy.ini b/mypy.ini index 4ba1f41f4d4..b352d2747be 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3166,6 +3166,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.onkyo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.open_meteo.*] check_untyped_defs = true disallow_incomplete_defs = true From 892c32c8b7bd125016e5f20c08cb092232ef98c6 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Wed, 4 Sep 2024 19:20:05 +0200 Subject: [PATCH 0251/1309] Add button platform to opentherm_gw (#125185) * Add button platform to opentherm_gw * Add tests for button.py * Update tests/components/opentherm_gw/test_button.py --------- Co-authored-by: Joost Lekkerkerker --- .../components/opentherm_gw/__init__.py | 2 +- .../components/opentherm_gw/button.py | 73 +++++++++++++++++++ tests/components/opentherm_gw/test_button.py | 50 +++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/opentherm_gw/button.py create mode 100644 tests/components/opentherm_gw/test_button.py diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index a57ae7db601..dfce2206df7 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -90,7 +90,7 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] async def options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/opentherm_gw/button.py b/homeassistant/components/opentherm_gw/button.py new file mode 100644 index 00000000000..aa0a3dbcda5 --- /dev/null +++ b/homeassistant/components/opentherm_gw/button.py @@ -0,0 +1,73 @@ +"""Support for OpenTherm Gateway buttons.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +import pyotgw.vars as gw_vars + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OpenThermGatewayHub +from .const import ( + DATA_GATEWAYS, + DATA_OPENTHERM_GW, + GATEWAY_DEVICE_DESCRIPTION, + OpenThermDataSource, +) +from .entity import OpenThermEntity, OpenThermEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class OpenThermButtonEntityDescription( + ButtonEntityDescription, OpenThermEntityDescription +): + """Describes an opentherm_gw button entity.""" + + action: Callable[[OpenThermGatewayHub], Awaitable] + + +BUTTON_DESCRIPTIONS: tuple[OpenThermButtonEntityDescription, ...] = ( + OpenThermButtonEntityDescription( + key="restart_button", + device_class=ButtonDeviceClass.RESTART, + device_description=GATEWAY_DEVICE_DESCRIPTION, + action=lambda hub: hub.gateway.set_mode(gw_vars.OTGW_MODE_RESET), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the OpenTherm Gateway buttons.""" + gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] + + async_add_entities( + OpenThermButton(gw_hub, description) for description in BUTTON_DESCRIPTIONS + ) + + +class OpenThermButton(OpenThermEntity, ButtonEntity): + """Representation of an OpenTherm button.""" + + _attr_entity_category = EntityCategory.CONFIG + entity_description: OpenThermButtonEntityDescription + + @callback + def receive_report(self, status: dict[OpenThermDataSource, dict]) -> None: + """Handle status updates from the component.""" + # We don't need any information from the reports here + + async def async_press(self) -> None: + """Perform button action.""" + await self.entity_description.action(self._gateway) diff --git a/tests/components/opentherm_gw/test_button.py b/tests/components/opentherm_gw/test_button.py new file mode 100644 index 00000000000..b02a9d9fef0 --- /dev/null +++ b/tests/components/opentherm_gw/test_button.py @@ -0,0 +1,50 @@ +"""Test opentherm_gw buttons.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyotgw.vars import OTGW_MODE_RESET + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.opentherm_gw import DOMAIN as OPENTHERM_DOMAIN +from homeassistant.components.opentherm_gw.const import OpenThermDeviceIdentifier +from homeassistant.const import ATTR_ENTITY_ID, CONF_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MINIMAL_STATUS + +from tests.common import MockConfigEntry + + +async def test_restart_button( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, +) -> None: + """Test restart button.""" + + mock_pyotgw.return_value.set_mode = AsyncMock(return_value=MINIMAL_STATUS) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + button_entity_id := entity_registry.async_get_entity_id( + BUTTON_DOMAIN, + OPENTHERM_DOMAIN, + f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}-restart_button", + ) + ) is not None + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: button_entity_id, + }, + blocking=True, + ) + + mock_pyotgw.return_value.set_mode.assert_awaited_once_with(OTGW_MODE_RESET) From 4ecc6555bf5aada9f4923ae5d4d0884edffa70f2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 4 Sep 2024 12:42:41 -0500 Subject: [PATCH 0252/1309] Add support for sample bytes in preferred TTS format (#125235) --- .../components/assist_pipeline/__init__.py | 3 +- .../components/assist_pipeline/pipeline.py | 7 +- homeassistant/components/tts/__init__.py | 26 ++++++ tests/components/assist_pipeline/test_init.py | 84 +++++++++++++++++-- 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 8ee053162b0..0a03402105a 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterable +from typing import Any import voluptuous as vol @@ -99,7 +100,7 @@ async def async_pipeline_from_audio_stream( wake_word_phrase: str | None = None, pipeline_id: str | None = None, conversation_id: str | None = None, - tts_audio_output: str | None = None, + tts_audio_output: str | dict[str, Any] | None = None, wake_word_settings: WakeWordSettings | None = None, audio_settings: AudioSettings | None = None, device_id: str | None = None, diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 342f811c99b..f6a6bc45b57 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -538,7 +538,7 @@ class PipelineRun: language: str = None # type: ignore[assignment] runner_data: Any | None = None intent_agent: str | None = None - tts_audio_output: str | None = None + tts_audio_output: str | dict[str, Any] | None = None wake_word_settings: WakeWordSettings | None = None audio_settings: AudioSettings = field(default_factory=AudioSettings) @@ -1052,12 +1052,15 @@ class PipelineRun: if self.pipeline.tts_voice is not None: tts_options[tts.ATTR_VOICE] = self.pipeline.tts_voice - if self.tts_audio_output is not None: + if isinstance(self.tts_audio_output, dict): + tts_options.update(self.tts_audio_output) + elif isinstance(self.tts_audio_output, str): tts_options[tts.ATTR_PREFERRED_FORMAT] = self.tts_audio_output if self.tts_audio_output == "wav": # 16 Khz, 16-bit mono tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = SAMPLE_RATE tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = SAMPLE_CHANNELS + tts_options[tts.ATTR_PREFERRED_SAMPLE_BYTES] = SAMPLE_WIDTH try: options_supported = await tts.async_support_options( diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 70bb2b4c713..9e3d9f65a76 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -77,6 +77,7 @@ __all__ = [ "ATTR_PREFERRED_FORMAT", "ATTR_PREFERRED_SAMPLE_RATE", "ATTR_PREFERRED_SAMPLE_CHANNELS", + "ATTR_PREFERRED_SAMPLE_BYTES", "CONF_LANG", "DEFAULT_CACHE_DIR", "generate_media_source_id", @@ -95,6 +96,7 @@ ATTR_AUDIO_OUTPUT = "audio_output" ATTR_PREFERRED_FORMAT = "preferred_format" ATTR_PREFERRED_SAMPLE_RATE = "preferred_sample_rate" ATTR_PREFERRED_SAMPLE_CHANNELS = "preferred_sample_channels" +ATTR_PREFERRED_SAMPLE_BYTES = "preferred_sample_bytes" ATTR_MEDIA_PLAYER_ENTITY_ID = "media_player_entity_id" ATTR_VOICE = "voice" @@ -103,6 +105,7 @@ _PREFFERED_FORMAT_OPTIONS: Final[set[str]] = { ATTR_PREFERRED_FORMAT, ATTR_PREFERRED_SAMPLE_RATE, ATTR_PREFERRED_SAMPLE_CHANNELS, + ATTR_PREFERRED_SAMPLE_BYTES, } CONF_LANG = "language" @@ -223,6 +226,7 @@ async def async_convert_audio( to_extension: str, to_sample_rate: int | None = None, to_sample_channels: int | None = None, + to_sample_bytes: int | None = None, ) -> bytes: """Convert audio to a preferred format using ffmpeg.""" ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) @@ -234,6 +238,7 @@ async def async_convert_audio( to_extension, to_sample_rate=to_sample_rate, to_sample_channels=to_sample_channels, + to_sample_bytes=to_sample_bytes, ) ) @@ -245,6 +250,7 @@ def _convert_audio( to_extension: str, to_sample_rate: int | None = None, to_sample_channels: int | None = None, + to_sample_bytes: int | None = None, ) -> bytes: """Convert audio to a preferred format using ffmpeg.""" @@ -277,6 +283,10 @@ def _convert_audio( # Max quality for MP3 command.extend(["-q:a", "0"]) + if to_sample_bytes == 2: + # 16-bit samples + command.extend(["-sample_fmt", "s16"]) + command.append(output_file.name) with subprocess.Popen( @@ -738,11 +748,25 @@ class SpeechManager: else: sample_rate = options.pop(ATTR_PREFERRED_SAMPLE_RATE, None) + if sample_rate is not None: + sample_rate = int(sample_rate) + if ATTR_PREFERRED_SAMPLE_CHANNELS in supported_options: sample_channels = options.get(ATTR_PREFERRED_SAMPLE_CHANNELS) else: sample_channels = options.pop(ATTR_PREFERRED_SAMPLE_CHANNELS, None) + if sample_channels is not None: + sample_channels = int(sample_channels) + + if ATTR_PREFERRED_SAMPLE_BYTES in supported_options: + sample_bytes = options.get(ATTR_PREFERRED_SAMPLE_BYTES) + else: + sample_bytes = options.pop(ATTR_PREFERRED_SAMPLE_BYTES, None) + + if sample_bytes is not None: + sample_bytes = int(sample_bytes) + async def get_tts_data() -> str: """Handle data available.""" if engine_instance.name is None or engine_instance.name is UNDEFINED: @@ -769,6 +793,7 @@ class SpeechManager: (final_extension != extension) or (sample_rate is not None) or (sample_channels is not None) + or (sample_bytes is not None) ) if needs_conversion: @@ -779,6 +804,7 @@ class SpeechManager: to_extension=final_extension, to_sample_rate=sample_rate, to_sample_channels=sample_channels, + to_sample_bytes=sample_bytes, ) # Create file infos diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 31cc1268098..c4696573bad 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -788,13 +788,12 @@ async def test_tts_audio_output( assert len(extra_options) == 0, extra_options -async def test_tts_supports_preferred_format( +async def test_tts_wav_preferred_format( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts_provider: MockTTSProvider, init_components, pipeline_data: assist_pipeline.pipeline.PipelineData, - snapshot: SnapshotAssertion, ) -> None: """Test that preferred format options are given to the TTS system if supported.""" client = await hass_client() @@ -829,6 +828,7 @@ async def test_tts_supports_preferred_format( tts.ATTR_PREFERRED_FORMAT, tts.ATTR_PREFERRED_SAMPLE_RATE, tts.ATTR_PREFERRED_SAMPLE_CHANNELS, + tts.ATTR_PREFERRED_SAMPLE_BYTES, ] ) @@ -850,6 +850,80 @@ async def test_tts_supports_preferred_format( options = mock_get_tts_audio.call_args_list[0].kwargs["options"] # We should have received preferred format options in get_tts_audio - assert tts.ATTR_PREFERRED_FORMAT in options - assert tts.ATTR_PREFERRED_SAMPLE_RATE in options - assert tts.ATTR_PREFERRED_SAMPLE_CHANNELS in options + assert options.get(tts.ATTR_PREFERRED_FORMAT) == "wav" + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 16000 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 1 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 + + +async def test_tts_dict_preferred_format( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts_provider: MockTTSProvider, + init_components, + pipeline_data: assist_pipeline.pipeline.PipelineData, +) -> None: + """Test that preferred format options are given to the TTS system if supported.""" + client = await hass_client() + assert await async_setup_component(hass, media_source.DOMAIN, {}) + + events: list[assist_pipeline.PipelineEvent] = [] + + pipeline_store = pipeline_data.pipeline_store + pipeline_id = pipeline_store.async_get_preferred_item() + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass, pipeline_id) + + pipeline_input = assist_pipeline.pipeline.PipelineInput( + tts_input="This is a test.", + conversation_id=None, + device_id=None, + run=assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.TTS, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=events.append, + tts_audio_output={ + tts.ATTR_PREFERRED_FORMAT: "flac", + tts.ATTR_PREFERRED_SAMPLE_RATE: 48000, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 2, + tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + }, + ), + ) + await pipeline_input.validate() + + # Make the TTS provider support preferred format options + supported_options = list(mock_tts_provider.supported_options or []) + supported_options.extend( + [ + tts.ATTR_PREFERRED_FORMAT, + tts.ATTR_PREFERRED_SAMPLE_RATE, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS, + tts.ATTR_PREFERRED_SAMPLE_BYTES, + ] + ) + + with ( + patch.object(mock_tts_provider, "_supported_options", supported_options), + patch.object(mock_tts_provider, "get_tts_audio") as mock_get_tts_audio, + ): + await pipeline_input.execute() + + for event in events: + if event.type == assist_pipeline.PipelineEventType.TTS_END: + # We must fetch the media URL to trigger the TTS + assert event.data + media_id = event.data["tts_output"]["media_id"] + resolved = await media_source.async_resolve_media(hass, media_id, None) + await client.get(resolved.url) + + assert mock_get_tts_audio.called + options = mock_get_tts_audio.call_args_list[0].kwargs["options"] + + # We should have received preferred format options in get_tts_audio + assert options.get(tts.ATTR_PREFERRED_FORMAT) == "flac" + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_RATE)) == 48000 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_CHANNELS)) == 2 + assert int(options.get(tts.ATTR_PREFERRED_SAMPLE_BYTES)) == 2 From b4e20409def235bbf482a07ed038a16f3619617c Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 4 Sep 2024 14:03:26 -0400 Subject: [PATCH 0253/1309] Add Sonos tests and update error handling for unknown media (#124578) * initial commit * simplify tests --- .../components/sonos/media_player.py | 19 ++++++--- homeassistant/components/sonos/strings.json | 6 +++ tests/components/sonos/test_media_player.py | 39 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 75527bdcb72..c4d417b0394 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -672,14 +672,23 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): soco.play_from_queue(0) elif media_type in PLAYABLE_MEDIA_TYPES: item = media_browser.get_media(self.media.library, media_id, media_type) - if not item: - _LOGGER.error('Could not find "%s" in the library', media_id) - return - + raise ServiceValidationError( + translation_domain=SONOS_DOMAIN, + translation_key="invalid_media", + translation_placeholders={ + "media_id": media_id, + }, + ) self._play_media_queue(soco, item, enqueue) else: - _LOGGER.error('Sonos does not support a media type of "%s"', media_type) + raise ServiceValidationError( + translation_domain=SONOS_DOMAIN, + translation_key="invalid_content_type", + translation_placeholders={ + "media_type": media_type, + }, + ) def _play_media_queue( self, soco: SoCo, item: MusicServiceItem, enqueue: MediaPlayerEnqueue diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 264420ef758..d3774e85213 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -185,6 +185,12 @@ "invalid_sonos_playlist": { "message": "Could not find Sonos playlist: {name}" }, + "invalid_media": { + "message": "Could not find media in library: {media_id}" + }, + "invalid_content_type": { + "message": "Sonos does not support media content type: {media_type}" + }, "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" } diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index ac877f47904..ae3928c5ff6 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -232,6 +232,45 @@ async def test_play_media_library( ) +@pytest.mark.parametrize( + ("media_content_type", "media_content_id", "message"), + [ + ( + "artist", + "A:ALBUM/UnknowAlbum", + "Could not find media in library: A:ALBUM/UnknowAlbum", + ), + ( + "UnknownContent", + "A:ALBUM/UnknowAlbum", + "Sonos does not support media content type: UnknownContent", + ), + ], +) +async def test_play_media_library_content_error( + hass: HomeAssistant, + async_autosetup_sonos, + media_content_type, + media_content_id, + message, +) -> None: + """Test playing local library errors on content and content type.""" + with pytest.raises( + ServiceValidationError, + match=message, + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: media_content_type, + ATTR_MEDIA_CONTENT_ID: media_content_id, + }, + blocking=True, + ) + + _track_url = "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%I%20Should%20Have%20Known%20Better.mp3" From 52320844fcf27b4e30b9ba07aa0062066ec349aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Sep 2024 08:05:13 -1000 Subject: [PATCH 0254/1309] Revert "Disable IPv6 in the opower integration to fix AEP utilities" (#125208) Revert "Disable IPv6 in the opower integration to fix AEP utilities (#107203)" This reverts commit 2a9a046fab2ff3cde2ede62a50c253d5454b62de. --- homeassistant/components/opower/config_flow.py | 3 +-- homeassistant/components/opower/coordinator.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index 574062aca52..a9162b060a2 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Mapping import logging -import socket from typing import Any from opower import ( @@ -40,7 +39,7 @@ async def _validate_login( ) -> dict[str, str]: """Validate login data and return any errors.""" api = Opower( - async_create_clientsession(hass, family=socket.AF_INET), + async_create_clientsession(hass), login_data[CONF_UTILITY], login_data[CONF_USERNAME], login_data[CONF_PASSWORD], diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 3249cf1a375..690e34a9865 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta import logging -import socket from types import MappingProxyType from typing import Any, cast @@ -54,7 +53,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): update_interval=timedelta(hours=12), ) self.api = Opower( - aiohttp_client.async_get_clientsession(hass, family=socket.AF_INET), + aiohttp_client.async_get_clientsession(hass), entry_data[CONF_UTILITY], entry_data[CONF_USERNAME], entry_data[CONF_PASSWORD], From c4029300c26bab0fcccd77eeea95005c18f7764c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 4 Sep 2024 20:28:45 +0200 Subject: [PATCH 0255/1309] Remove deprecated aux_heat from honeywell (#125248) --- homeassistant/components/honeywell/climate.py | 61 +------------- .../components/honeywell/strings.json | 19 ----- .../honeywell/snapshots/test_climate.ambr | 3 +- tests/components/honeywell/test_climate.py | 81 ------------------- 4 files changed, 2 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 141cb87f117..934d41b238e 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -35,11 +35,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter @@ -218,9 +214,6 @@ class HoneywellUSThermostat(ClimateEntity): if device._data.get("canControlHumidification"): # noqa: SLF001 self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY - if device.raw_ui_data.get("SwitchEmergencyHeatAllowed"): - self._attr_supported_features |= ClimateEntityFeature.AUX_HEAT - if not device._data.get("hasFan"): # noqa: SLF001 return @@ -337,11 +330,6 @@ class HoneywellUSThermostat(ClimateEntity): return PRESET_NONE - @property - def is_aux_heat(self) -> bool | None: - """Return true if aux heater.""" - return self._device.system_mode == "emheat" - @property def fan_mode(self) -> str | None: """Return the fan setting.""" @@ -538,53 +526,6 @@ class HoneywellUSThermostat(ClimateEntity): else: await self._turn_away_mode_off() - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation", - breaks_in_ha_version="2024.10.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation", - ) - try: - await self._device.set_system_mode("emheat") - - except SomeComfortError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="set_aux_failed", - ) from err - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - - ir.async_create_issue( - self.hass, - DOMAIN, - "service_deprecation", - breaks_in_ha_version="2024.10.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_deprecation", - ) - - try: - if HVACMode.HEAT in self.hvac_modes: - await self.async_set_hvac_mode(HVACMode.HEAT) - else: - await self.async_set_hvac_mode(HVACMode.OFF) - - except HomeAssistantError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="disable_aux_failed", - ) from err - async def async_update(self) -> None: """Get the latest state from the service.""" diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index d3bc1924e28..aa6e53620a5 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -88,30 +88,11 @@ "stop_hold_failed": { "message": "Honeywell could not stop hold mode" }, - "set_aux_failed": { - "message": "Honeywell could not set system mode to aux heat" - }, - "disable_aux_failed": { - "message": "Honeywell could turn off aux heat mode" - }, "switch_failed_off": { "message": "Honeywell could turn off emergency heat mode." }, "switch_failed_on": { "message": "Honeywell could not set system mode to emergency heat mode." } - }, - "issues": { - "service_deprecation": { - "title": "Honeywell aux heat is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::honeywell::issues::service_deprecation::title%]", - "description": "Use `switch.{name}_emergency_heat` instead to change mode.\n\nPlease adjust your automations and scripts and select **submit** to fix this issue." - } - } - } - } } } diff --git a/tests/components/honeywell/snapshots/test_climate.ambr b/tests/components/honeywell/snapshots/test_climate.ambr index 25bb73851c6..f26064b335a 100644 --- a/tests/components/honeywell/snapshots/test_climate.ambr +++ b/tests/components/honeywell/snapshots/test_climate.ambr @@ -1,7 +1,6 @@ # serializer version: 1 # name: test_static_attributes ReadOnlyDict({ - 'aux_heat': 'off', 'current_humidity': 50, 'current_temperature': 20, 'fan_action': 'idle', @@ -30,7 +29,7 @@ 'away', 'hold', ]), - 'supported_features': , + 'supported_features': , 'target_temp_high': None, 'target_temp_low': None, 'temperature': None, diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 55a55f7d7e7..9485f2f4302 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -10,7 +10,6 @@ from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.climate import ( - ATTR_AUX_HEAT, ATTR_FAN_MODE, ATTR_HVAC_MODE, ATTR_PRESET_MODE, @@ -22,7 +21,6 @@ from homeassistant.components.climate import ( FAN_ON, PRESET_AWAY, PRESET_NONE, - SERVICE_SET_AUX_HEAT, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, @@ -40,7 +38,6 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -221,53 +218,6 @@ async def test_mode_service_calls( ) -async def test_auxheat_service_calls( - hass: HomeAssistant, device: MagicMock, config_entry: MagicMock -) -> None: - """Test controlling the auxheat through service calls.""" - await init_integration(hass, config_entry) - entity_id = f"climate.{device.name}" - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: True}, - blocking=True, - ) - device.set_system_mode.assert_called_once_with("emheat") - - device.set_system_mode.reset_mock() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: False}, - blocking=True, - ) - device.set_system_mode.assert_called_once_with("heat") - - device.set_system_mode.reset_mock() - device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: True}, - blocking=True, - ) - device.set_system_mode.assert_called_once_with("emheat") - - device.set_system_mode.reset_mock() - device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: False}, - blocking=True, - ) - - async def test_fan_modes_service_calls( hass: HomeAssistant, device: MagicMock, config_entry: MagicMock ) -> None: @@ -1240,37 +1190,6 @@ async def test_async_update_errors( assert state.state == "unavailable" -async def test_aux_heat_off_service_call( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - device: MagicMock, - config_entry: MagicMock, -) -> None: - """Test aux heat off turns of system when no heat configured.""" - device.raw_ui_data["SwitchHeatAllowed"] = False - device.raw_ui_data["SwitchAutoAllowed"] = False - device.raw_ui_data["SwitchEmergencyHeatAllowed"] = True - - await init_integration(hass, config_entry) - - entity_id = f"climate.{device.name}" - entry = entity_registry.async_get(entity_id) - assert entry - - state = hass.states.get(entity_id) - assert state is not None - assert state.state != STATE_UNAVAILABLE - assert state.state == HVACMode.OFF - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: False}, - blocking=True, - ) - device.set_system_mode.assert_called_once_with("off") - - async def test_unique_id( hass: HomeAssistant, device: MagicMock, From c4c8e74a8aeb790d99060aa7bd6885b9b20ae654 Mon Sep 17 00:00:00 2001 From: Tal Taub Date: Wed, 4 Sep 2024 21:29:06 +0300 Subject: [PATCH 0256/1309] Add Custom Drink Entities Tami4 Edge (#124506) * Add drinks as button entities instead of using actions * Remove button extensions * Add an extension to create new buttons * Use translation key for buttons names * Change translation key wording * Call async_add_entities once * Add icons * Update homeassistant/components/tami4/button.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/tami4/button.py | 59 +++++++++++++++++---- homeassistant/components/tami4/icons.json | 3 ++ homeassistant/components/tami4/strings.json | 3 ++ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tami4/button.py b/homeassistant/components/tami4/button.py index 2d8af3fcf89..11377a2dcfb 100644 --- a/homeassistant/components/tami4/button.py +++ b/homeassistant/components/tami4/button.py @@ -5,10 +5,12 @@ from dataclasses import dataclass import logging from Tami4EdgeAPI import Tami4EdgeAPI +from Tami4EdgeAPI.drink import Drink from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import API, DOMAIN @@ -24,12 +26,17 @@ class Tami4EdgeButtonEntityDescription(ButtonEntityDescription): press_fn: Callable[[Tami4EdgeAPI], None] -BUTTONS: tuple[Tami4EdgeButtonEntityDescription] = ( - Tami4EdgeButtonEntityDescription( - key="boil_water", - translation_key="boil_water", - press_fn=lambda api: api.boil_water(), - ), +@dataclass(frozen=True, kw_only=True) +class Tami4EdgeDrinkButtonEntityDescription(ButtonEntityDescription): + """A class that describes Tami4Edge Drink button entities.""" + + press_fn: Callable[[Tami4EdgeAPI, Drink], None] + + +BOIL_WATER_BUTTON = Tami4EdgeButtonEntityDescription( + key="boil_water", + translation_key="boil_water", + press_fn=lambda api: api.boil_water(), ) @@ -37,12 +44,29 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Perform the setup for Tami4Edge.""" - api: Tami4EdgeAPI = hass.data[DOMAIN][entry.entry_id][API] - async_add_entities( - Tami4EdgeButton(api, entity_description) for entity_description in BUTTONS + api: Tami4EdgeAPI = hass.data[DOMAIN][entry.entry_id][API] + buttons: list[Tami4EdgeBaseEntity] = [Tami4EdgeButton(api, BOIL_WATER_BUTTON)] + + device = await hass.async_add_executor_job(api.get_device) + drinks = device.drinks + + buttons.extend( + Tami4EdgeDrinkButton( + api=api, + entity_description=Tami4EdgeDrinkButtonEntityDescription( + key=drink.id, + translation_key="prepare_drink", + translation_placeholders={"drink_name": drink.name}, + press_fn=lambda api, drink: api.prepare_drink(drink), + ), + drink=drink, + ) + for drink in drinks ) + async_add_entities(buttons) + class Tami4EdgeButton(Tami4EdgeBaseEntity, ButtonEntity): """Button entity for Tami4Edge.""" @@ -52,3 +76,20 @@ class Tami4EdgeButton(Tami4EdgeBaseEntity, ButtonEntity): def press(self) -> None: """Handle the button press.""" self.entity_description.press_fn(self._api) + + +class Tami4EdgeDrinkButton(Tami4EdgeBaseEntity, ButtonEntity): + """Drink Button entity for Tami4Edge.""" + + entity_description: Tami4EdgeDrinkButtonEntityDescription + + def __init__( + self, api: Tami4EdgeAPI, entity_description: EntityDescription, drink: Drink + ) -> None: + """Initialize the drink button.""" + super().__init__(api=api, entity_description=entity_description) + self.drink = drink + + def press(self) -> None: + """Handle the button press.""" + self.entity_description.press_fn(self._api, self.drink) diff --git a/homeassistant/components/tami4/icons.json b/homeassistant/components/tami4/icons.json index d623bdc6007..803ed9a5016 100644 --- a/homeassistant/components/tami4/icons.json +++ b/homeassistant/components/tami4/icons.json @@ -3,6 +3,9 @@ "button": { "boil_water": { "default": "mdi:kettle-steam" + }, + "prepare_drink": { + "default": "mdi:beer" } }, "sensor": { diff --git a/homeassistant/components/tami4/strings.json b/homeassistant/components/tami4/strings.json index 406964a3bff..9c33b6607e4 100644 --- a/homeassistant/components/tami4/strings.json +++ b/homeassistant/components/tami4/strings.json @@ -26,6 +26,9 @@ "button": { "boil_water": { "name": "Boil water" + }, + "prepare_drink": { + "name": "Prepare {drink_name}" } } }, From c2b24dd3550c1e18884f2f3727862e802a8a8706 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 4 Sep 2024 11:30:24 -0700 Subject: [PATCH 0257/1309] Add debug logging in get_cost_reads in opower (#124473) Add debug statements in get_cost_reads in opower --- homeassistant/components/opower/coordinator.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 690e34a9865..1e00243f657 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -236,9 +236,11 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): else: start = datetime.fromtimestamp(start_time, tz=tz) - timedelta(days=30) end = dt_util.now(tz) + _LOGGER.debug("Getting monthly cost reads: %s - %s", start, end) cost_reads = await self.api.async_get_cost_reads( account, AggregateType.BILL, start, end ) + _LOGGER.debug("Got %s monthly cost reads", len(cost_reads)) if account.read_resolution == ReadResolution.BILLING: return cost_reads @@ -249,9 +251,11 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): start = cost_reads[0].start_time assert start start = max(start, end - timedelta(days=3 * 365)) + _LOGGER.debug("Getting daily cost reads: %s - %s", start, end) daily_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.DAY, start, end ) + _LOGGER.debug("Got %s daily cost reads", len(daily_cost_reads)) _update_with_finer_cost_reads(cost_reads, daily_cost_reads) if account.read_resolution == ReadResolution.DAY: return cost_reads @@ -261,8 +265,11 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): else: assert start start = max(start, end - timedelta(days=2 * 30)) + _LOGGER.debug("Getting hourly cost reads: %s - %s", start, end) hourly_cost_reads = await self.api.async_get_cost_reads( account, AggregateType.HOUR, start, end ) + _LOGGER.debug("Got %s hourly cost reads", len(hourly_cost_reads)) _update_with_finer_cost_reads(cost_reads, hourly_cost_reads) + _LOGGER.debug("Got %s cost reads", len(cost_reads)) return cost_reads From f56c38d69b82fe359539e386f9a1ead72470a0de Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 5 Sep 2024 04:31:56 +1000 Subject: [PATCH 0258/1309] Add uptime sensors for Smlight (#124408) * Add uptime sensor as derived sensor class * Add strings for uptime sensors * Update sensor tests to include uptime sensors * test zigbee uptime when disconnected --- homeassistant/components/smlight/const.py | 1 + homeassistant/components/smlight/sensor.py | 70 ++++++- homeassistant/components/smlight/strings.json | 6 + .../smlight/snapshots/test_sensor.ambr | 188 ++++++++++++++++++ tests/components/smlight/test_sensor.py | 25 ++- 5 files changed, 286 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index de3270fe3be..791b00c3e93 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -9,3 +9,4 @@ ATTR_MANUFACTURER = "SMLIGHT" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) +UPTIME_DEVIATION = timedelta(seconds=5) diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index d9c03760fb8..f5193522c4c 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta +from itertools import chain from pysmlight import Sensors @@ -16,8 +18,10 @@ from homeassistant.components.sensor import ( from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import utcnow from . import SmConfigEntry +from .const import UPTIME_DEVIATION from .coordinator import SmDataUpdateCoordinator from .entity import SmEntity @@ -67,6 +71,23 @@ SENSORS = [ ), ] +UPTIME = [ + SmSensorEntityDescription( + key="core_uptime", + translation_key="core_uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + value_fn=lambda x: x.uptime, + ), + SmSensorEntityDescription( + key="socket_uptime", + translation_key="socket_uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_registry_enabled_default=False, + value_fn=lambda x: x.socket_uptime, + ), +] + async def async_setup_entry( hass: HomeAssistant, @@ -77,7 +98,10 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - SmSensorEntity(coordinator, description) for description in SENSORS + chain( + (SmSensorEntity(coordinator, description) for description in SENSORS), + (SmUptimeSensorEntity(coordinator, description) for description in UPTIME), + ) ) @@ -98,6 +122,48 @@ class SmSensorEntity(SmEntity, SensorEntity): self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" @property - def native_value(self) -> float | None: + def native_value(self) -> datetime | float | None: """Return the sensor value.""" return self.entity_description.value_fn(self.coordinator.data.sensors) + + +class SmUptimeSensorEntity(SmSensorEntity): + """Representation of a slzb uptime sensor.""" + + def __init__( + self, + coordinator: SmDataUpdateCoordinator, + description: SmSensorEntityDescription, + ) -> None: + "Initialize uptime sensor instance." + super().__init__(coordinator, description) + self._last_uptime: datetime | None = None + + def get_uptime(self, uptime: float | None) -> datetime | None: + """Return device uptime or zigbee socket uptime. + + Converts uptime from seconds to a datetime value, allow up to 5 + seconds deviation. This avoids unnecessary updates to sensor state, + that may be caused by clock jitter. + """ + if uptime is None: + # reset to unknown state + self._last_uptime = None + return None + + new_uptime = utcnow() - timedelta(seconds=uptime) + + if ( + not self._last_uptime + or abs(new_uptime - self._last_uptime) > UPTIME_DEVIATION + ): + self._last_uptime = new_uptime + + return self._last_uptime + + @property + def native_value(self) -> datetime | None: + """Return the sensor value.""" + value = self.entity_description.value_fn(self.coordinator.data.sensors) + + return self.get_uptime(value) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index f81e977b40c..41f84c49bf9 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -43,6 +43,12 @@ }, "ram_usage": { "name": "RAM usage" + }, + "core_uptime": { + "name": "Core uptime" + }, + "socket_uptime": { + "name": "Zigbee uptime" } }, "button": { diff --git a/tests/components/smlight/snapshots/test_sensor.ambr b/tests/components/smlight/snapshots/test_sensor.ambr index 0ff3d37b735..6895a8473bd 100644 --- a/tests/components/smlight/snapshots/test_sensor.ambr +++ b/tests/components/smlight/snapshots/test_sensor.ambr @@ -53,6 +53,53 @@ 'state': '35.0', }) # --- +# name: test_sensors[sensor.mock_title_core_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_core_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core uptime', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'core_uptime', + 'unique_id': 'aa:bb:cc:dd:ee:ff_core_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_core_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Core uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_title_core_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-25T02:51:15+00:00', + }) +# --- # name: test_sensors[sensor.mock_title_filesystem_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -149,6 +196,100 @@ 'state': '99', }) # --- +# name: test_sensors[sensor.mock_title_timestamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_timestamp', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Timestamp', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'core_uptime', + 'unique_id': 'aa:bb:cc:dd:ee:ff_core_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_timestamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Timestamp', + }), + 'context': , + 'entity_id': 'sensor.mock_title_timestamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-25T02:51:15+00:00', + }) +# --- +# name: test_sensors[sensor.mock_title_timestamp_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_timestamp_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Timestamp', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'socket_uptime', + 'unique_id': 'aa:bb:cc:dd:ee:ff_socket_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_timestamp_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Timestamp', + }), + 'context': , + 'entity_id': 'sensor.mock_title_timestamp_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-30T23:57:53+00:00', + }) +# --- # name: test_sensors[sensor.mock_title_zigbee_chip_temp-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -203,6 +344,53 @@ 'state': '32.7', }) # --- +# name: test_sensors[sensor.mock_title_zigbee_uptime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_zigbee_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zigbee uptime', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'socket_uptime', + 'unique_id': 'aa:bb:cc:dd:ee:ff_socket_uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_zigbee_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Zigbee uptime', + }), + 'context': , + 'entity_id': 'sensor.mock_title_zigbee_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-06-30T23:57:53+00:00', + }) +# --- # name: test_sensors[sensor.slzb_06_core_chip_temp-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smlight/test_sensor.py b/tests/components/smlight/test_sensor.py index e1239c99e32..f130d7ccf30 100644 --- a/tests/components/smlight/test_sensor.py +++ b/tests/components/smlight/test_sensor.py @@ -1,9 +1,12 @@ """Tests for the SMLIGHT sensor platform.""" +from unittest.mock import MagicMock + +from pysmlight import Sensors import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -25,6 +28,7 @@ def platforms() -> list[Platform]: @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.freeze_time("2024-07-01 00:00:00+00:00") async def test_sensors( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -46,9 +50,26 @@ async def test_disabled_by_default_sensors( """Test the disabled by default SMLIGHT sensors.""" await setup_integration(hass, mock_config_entry) - for sensor in ("ram_usage", "filesystem_usage"): + for sensor in ("core_uptime", "filesystem_usage", "ram_usage", "zigbee_uptime"): assert not hass.states.get(f"sensor.mock_title_{sensor}") assert (entry := entity_registry.async_get(f"sensor.mock_title_{sensor}")) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_zigbee_uptime_disconnected( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test for uptime when zigbee socket is disconnected. + + In this case zigbee uptime state should be unknown. + """ + mock_smlight_client.get_sensors.return_value = Sensors(socket_uptime=0) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("sensor.mock_title_zigbee_uptime") + assert state.state == STATE_UNKNOWN From b23297bb7eb0e4625238cbc74111c727258a9fcf Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 4 Sep 2024 20:32:40 +0200 Subject: [PATCH 0259/1309] Add hysteresis entity for heat pumps via ViCare (#124294) * add hysteresis entity * update PyViCare-neo dependency * add hysteresis switch on / of entities * Revert "add hysteresis entity" This reverts commit dcb5680d0ca1958640e68de36f6befbf6416ab41. --- homeassistant/components/vicare/manifest.json | 2 +- homeassistant/components/vicare/number.py | 28 +++++++++++++++++++ homeassistant/components/vicare/strings.json | 6 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 37 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 186e9ef6289..7a3089d04c3 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare-neo==0.2.1"] + "requirements": ["PyViCare-neo==0.3.0"] } diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index ea64fb174e8..a7f679f7224 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -75,6 +75,34 @@ DEVICE_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( native_max_value=60, native_step=1, ), + ViCareNumberEntityDescription( + key="dhw_hysteresis_switch_on", + translation_key="dhw_hysteresis_switch_on", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.KELVIN, + value_getter=lambda api: api.getDomesticHotWaterHysteresisSwitchOn(), + value_setter=lambda api, value: api.setDomesticHotWaterHysteresisSwitchOn( + value + ), + min_value_getter=lambda api: api.getDomesticHotWaterHysteresisSwitchOnMin(), + max_value_getter=lambda api: api.getDomesticHotWaterHysteresisSwitchOnMax(), + stepping_getter=lambda api: api.getDomesticHotWaterHysteresisSwitchOnStepping(), + ), + ViCareNumberEntityDescription( + key="dhw_hysteresis_switch_off", + translation_key="dhw_hysteresis_switch_off", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.KELVIN, + value_getter=lambda api: api.getDomesticHotWaterHysteresisSwitchOff(), + value_setter=lambda api, value: api.setDomesticHotWaterHysteresisSwitchOff( + value + ), + min_value_getter=lambda api: api.getDomesticHotWaterHysteresisSwitchOffMin(), + max_value_getter=lambda api: api.getDomesticHotWaterHysteresisSwitchOffMax(), + stepping_getter=lambda api: api.getDomesticHotWaterHysteresisSwitchOffStepping(), + ), ) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 1466baab8f3..752645137df 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -110,6 +110,12 @@ }, "dhw_secondary_temperature": { "name": "DHW secondary temperature" + }, + "dhw_hysteresis_switch_on": { + "name": "DHW hysteresis switch on" + }, + "dhw_hysteresis_switch_off": { + "name": "DHW hysteresis switch off" } }, "sensor": { diff --git a/requirements_all.txt b/requirements_all.txt index 3707b52fc59..e95011f247b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare-neo==0.2.1 +PyViCare-neo==0.3.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7a11044f50..1657969b7e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare-neo==0.2.1 +PyViCare-neo==0.3.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From b61678d39c9743cf35897dc5435f0ccc6d4de680 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 4 Sep 2024 21:14:54 +0200 Subject: [PATCH 0260/1309] Fix blocking call in yale_smart_alarm (#125255) --- homeassistant/components/yale_smart_alarm/coordinator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 1067b9279a4..b47545ea88b 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -36,8 +36,10 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_setup(self) -> None: """Set up connection to Yale.""" try: - self.yale = YaleSmartAlarmClient( - self.entry.data[CONF_USERNAME], self.entry.data[CONF_PASSWORD] + self.yale = await self.hass.async_add_executor_job( + YaleSmartAlarmClient, + self.entry.data[CONF_USERNAME], + self.entry.data[CONF_PASSWORD], ) except AuthenticationError as error: raise ConfigEntryAuthFailed from error From 1f59bd9f922df12e4299acea7a8955d34cf70fd5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 4 Sep 2024 21:49:28 +0200 Subject: [PATCH 0261/1309] Don't show input panel if default code provided in envisalink (#125256) --- homeassistant/components/envisalink/alarm_control_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index d4bbe174f20..ea8b6390178 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -119,7 +119,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): self._partition_number = partition_number self._panic_type = panic_type self._alarm_control_panel_option_default_code = code - self._attr_code_format = CodeFormat.NUMBER + self._attr_code_format = CodeFormat.NUMBER if not code else None _LOGGER.debug("Setting up alarm: %s", alarm_name) super().__init__(alarm_name, info, controller) From adda02b6b18848e56a320ec55963bfbe243a175c Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Wed, 4 Sep 2024 22:56:11 +0300 Subject: [PATCH 0262/1309] Add service to 17track to archive package (#123493) * Add service archive package * Update homeassistant/components/seventeentrack/icons.json Co-authored-by: Joost Lekkerkerker * CR fix in tests * CR fix in services.py * string references * extract constant keys --------- Co-authored-by: Joost Lekkerkerker --- .../components/seventeentrack/__init__.py | 122 +-------------- .../components/seventeentrack/const.py | 3 + .../components/seventeentrack/icons.json | 3 + .../components/seventeentrack/services.py | 145 ++++++++++++++++++ .../components/seventeentrack/services.yaml | 11 ++ .../components/seventeentrack/strings.json | 14 ++ tests/components/seventeentrack/conftest.py | 5 + .../seventeentrack/test_services.py | 47 +++++- 8 files changed, 229 insertions(+), 121 deletions(-) create mode 100644 homeassistant/components/seventeentrack/services.py diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index 56d87b1935d..695ca179966 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -1,136 +1,30 @@ """The seventeentrack component.""" -from typing import Final - from pyseventeentrack import Client as SeventeenTrackClient from pyseventeentrack.errors import SeventeenTrackError -from pyseventeentrack.package import PACKAGE_STATUS_MAP -import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_LOCATION, - CONF_PASSWORD, - CONF_USERNAME, - Platform, -) -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) -from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from homeassistant.util import slugify -from .const import ( - ATTR_CONFIG_ENTRY_ID, - ATTR_DESTINATION_COUNTRY, - ATTR_INFO_TEXT, - ATTR_ORIGIN_COUNTRY, - ATTR_PACKAGE_STATE, - ATTR_PACKAGE_TYPE, - ATTR_STATUS, - ATTR_TIMESTAMP, - ATTR_TRACKING_INFO_LANGUAGE, - ATTR_TRACKING_NUMBER, - DOMAIN, - SERVICE_GET_PACKAGES, -) +from .const import DOMAIN from .coordinator import SeventeenTrackCoordinator +from .services import setup_services PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -SERVICE_SCHEMA: Final = vol.Schema( - { - vol.Required(ATTR_CONFIG_ENTRY_ID): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), - vol.Optional(ATTR_PACKAGE_STATE): selector.SelectSelector( - selector.SelectSelectorConfig( - multiple=True, - options=[ - value.lower().replace(" ", "_") - for value in PACKAGE_STATUS_MAP.values() - ], - mode=selector.SelectSelectorMode.DROPDOWN, - translation_key=ATTR_PACKAGE_STATE, - ) - ), - } -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the 17Track component.""" - async def get_packages(call: ServiceCall) -> ServiceResponse: - """Get packages from 17Track.""" - config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - package_states = call.data.get(ATTR_PACKAGE_STATE, []) + setup_services(hass) - entry: ConfigEntry | None = hass.config_entries.async_get_entry(config_entry_id) - - if not entry: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_config_entry", - translation_placeholders={ - "config_entry_id": config_entry_id, - }, - ) - if entry.state != ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unloaded_config_entry", - translation_placeholders={ - "config_entry_id": entry.title, - }, - ) - - seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ - config_entry_id - ] - live_packages = sorted( - await seventeen_coordinator.client.profile.packages( - show_archived=seventeen_coordinator.show_archived - ) - ) - - return { - "packages": [ - { - ATTR_DESTINATION_COUNTRY: package.destination_country, - ATTR_ORIGIN_COUNTRY: package.origin_country, - ATTR_PACKAGE_TYPE: package.package_type, - ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, - ATTR_TRACKING_NUMBER: package.tracking_number, - ATTR_LOCATION: package.location, - ATTR_STATUS: package.status, - ATTR_TIMESTAMP: package.timestamp, - ATTR_INFO_TEXT: package.info_text, - ATTR_FRIENDLY_NAME: package.friendly_name, - } - for package in live_packages - if slugify(package.status) in package_states or package_states == [] - ] - } - - hass.services.async_register( - DOMAIN, - SERVICE_GET_PACKAGES, - get_packages, - schema=SERVICE_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) return True diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py index 584eca507e9..6b888590600 100644 --- a/homeassistant/components/seventeentrack/const.py +++ b/homeassistant/components/seventeentrack/const.py @@ -42,8 +42,11 @@ NOTIFICATION_DELIVERED_MESSAGE = ( VALUE_DELIVERED = "Delivered" SERVICE_GET_PACKAGES = "get_packages" +SERVICE_ARCHIVE_PACKAGE = "archive_package" ATTR_PACKAGE_STATE = "package_state" +ATTR_PACKAGE_TRACKING_NUMBER = "package_tracking_number" ATTR_CONFIG_ENTRY_ID = "config_entry_id" + DEPRECATED_KEY = "deprecated" diff --git a/homeassistant/components/seventeentrack/icons.json b/homeassistant/components/seventeentrack/icons.json index 94ca8cd535a..a5cac0a9f84 100644 --- a/homeassistant/components/seventeentrack/icons.json +++ b/homeassistant/components/seventeentrack/icons.json @@ -30,6 +30,9 @@ "services": { "get_packages": { "service": "mdi:package" + }, + "archive_package": { + "service": "mdi:archive" } } } diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py new file mode 100644 index 00000000000..9a7a4d2d4b6 --- /dev/null +++ b/homeassistant/components/seventeentrack/services.py @@ -0,0 +1,145 @@ +"""Services for the seventeentrack integration.""" + +from typing import Final + +from pyseventeentrack.package import PACKAGE_STATUS_MAP +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.util import slugify + +from . import SeventeenTrackCoordinator +from .const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_DESTINATION_COUNTRY, + ATTR_INFO_TEXT, + ATTR_ORIGIN_COUNTRY, + ATTR_PACKAGE_STATE, + ATTR_PACKAGE_TRACKING_NUMBER, + ATTR_PACKAGE_TYPE, + ATTR_STATUS, + ATTR_TIMESTAMP, + ATTR_TRACKING_INFO_LANGUAGE, + ATTR_TRACKING_NUMBER, + DOMAIN, + SERVICE_ARCHIVE_PACKAGE, + SERVICE_GET_PACKAGES, +) + +SERVICE_ADD_PACKAGES_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Optional(ATTR_PACKAGE_STATE): selector.SelectSelector( + selector.SelectSelectorConfig( + multiple=True, + options=[ + value.lower().replace(" ", "_") + for value in PACKAGE_STATUS_MAP.values() + ], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key=ATTR_PACKAGE_STATE, + ) + ), + } +) + +SERVICE_ARCHIVE_PACKAGE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_PACKAGE_TRACKING_NUMBER): cv.string, + } +) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the seventeentrack integration.""" + + async def get_packages(call: ServiceCall) -> ServiceResponse: + """Get packages from 17Track.""" + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + package_states = call.data.get(ATTR_PACKAGE_STATE, []) + + await _validate_service(config_entry_id) + + seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ + config_entry_id + ] + live_packages = sorted( + await seventeen_coordinator.client.profile.packages( + show_archived=seventeen_coordinator.show_archived + ) + ) + + return { + "packages": [ + { + ATTR_DESTINATION_COUNTRY: package.destination_country, + ATTR_ORIGIN_COUNTRY: package.origin_country, + ATTR_PACKAGE_TYPE: package.package_type, + ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, + ATTR_TRACKING_NUMBER: package.tracking_number, + ATTR_LOCATION: package.location, + ATTR_STATUS: package.status, + ATTR_TIMESTAMP: package.timestamp, + ATTR_INFO_TEXT: package.info_text, + ATTR_FRIENDLY_NAME: package.friendly_name, + } + for package in live_packages + if slugify(package.status) in package_states or package_states == [] + ] + } + + async def archive_package(call: ServiceCall) -> None: + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] + + await _validate_service(config_entry_id) + + seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ + config_entry_id + ] + + await seventeen_coordinator.client.profile.archive_package(tracking_number) + + async def _validate_service(config_entry_id): + entry: ConfigEntry | None = hass.config_entries.async_get_entry(config_entry_id) + if not entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry_id": config_entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry_id": entry.title, + }, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_PACKAGES, + get_packages, + schema=SERVICE_ADD_PACKAGES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ARCHIVE_PACKAGE, + archive_package, + schema=SERVICE_ARCHIVE_PACKAGE_SCHEMA, + ) diff --git a/homeassistant/components/seventeentrack/services.yaml b/homeassistant/components/seventeentrack/services.yaml index 41cb66ada5f..d4592dc8aab 100644 --- a/homeassistant/components/seventeentrack/services.yaml +++ b/homeassistant/components/seventeentrack/services.yaml @@ -18,3 +18,14 @@ get_packages: selector: config_entry: integration: seventeentrack +archive_package: + fields: + package_tracking_number: + required: true + selector: + text: + config_entry_id: + required: true + selector: + config_entry: + integration: seventeentrack diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index 0fbac13736e..fda5575ff95 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -100,6 +100,20 @@ "description": "The packages will be retrieved for the selected service." } } + }, + "archive_package": { + "name": "Archive package", + "description": "Archive a package", + "fields": { + "package_tracking_number": { + "name": "Package tracking number", + "description": "The package will be archived for the specified tracking number." + }, + "config_entry_id": { + "name": "[%key:component::seventeentrack::services::get_packages::fields::config_entry_id::name%]", + "description": "The package will be archived for the selected service." + } + } } }, "selector": { diff --git a/tests/components/seventeentrack/conftest.py b/tests/components/seventeentrack/conftest.py index e2493319b69..0d02a7ab5f1 100644 --- a/tests/components/seventeentrack/conftest.py +++ b/tests/components/seventeentrack/conftest.py @@ -40,6 +40,11 @@ NEW_SUMMARY_DATA = { "Returned": 1, } +ARCHIVE_PACKAGE_NUMBER = "123" +CONFIG_ENTRY_ID_KEY = "config_entry_id" +PACKAGE_TRACKING_NUMBER_KEY = "package_tracking_number" +PACKAGE_STATE_KEY = "package_state" + VALID_CONFIG = { CONF_USERNAME: "test", CONF_PASSWORD: "test", diff --git a/tests/components/seventeentrack/test_services.py b/tests/components/seventeentrack/test_services.py index 4347189a5c0..54c9349c121 100644 --- a/tests/components/seventeentrack/test_services.py +++ b/tests/components/seventeentrack/test_services.py @@ -5,14 +5,24 @@ from unittest.mock import AsyncMock import pytest from syrupy import SnapshotAssertion -from homeassistant.components.seventeentrack import DOMAIN, SERVICE_GET_PACKAGES +from homeassistant.components.seventeentrack import DOMAIN +from homeassistant.components.seventeentrack.const import ( + SERVICE_ARCHIVE_PACKAGE, + SERVICE_GET_PACKAGES, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from . import init_integration -from .conftest import get_package +from .conftest import ( + ARCHIVE_PACKAGE_NUMBER, + CONFIG_ENTRY_ID_KEY, + PACKAGE_STATE_KEY, + PACKAGE_TRACKING_NUMBER_KEY, + get_package, +) from tests.common import MockConfigEntry @@ -30,8 +40,8 @@ async def test_get_packages_from_list( DOMAIN, SERVICE_GET_PACKAGES, { - "config_entry_id": mock_config_entry.entry_id, - "package_state": ["in_transit", "delivered"], + CONFIG_ENTRY_ID_KEY: mock_config_entry.entry_id, + PACKAGE_STATE_KEY: ["in_transit", "delivered"], }, blocking=True, return_response=True, @@ -53,7 +63,7 @@ async def test_get_all_packages( DOMAIN, SERVICE_GET_PACKAGES, { - "config_entry_id": mock_config_entry.entry_id, + CONFIG_ENTRY_ID_KEY: mock_config_entry.entry_id, }, blocking=True, return_response=True, @@ -76,7 +86,7 @@ async def test_service_called_with_unloaded_entry( DOMAIN, SERVICE_GET_PACKAGES, { - "config_entry_id": mock_config_entry.entry_id, + CONFIG_ENTRY_ID_KEY: mock_config_entry.entry_id, }, blocking=True, return_response=True, @@ -110,13 +120,36 @@ async def test_service_called_with_non_17track_device( DOMAIN, SERVICE_GET_PACKAGES, { - "config_entry_id": device_entry.id, + CONFIG_ENTRY_ID_KEY: device_entry.id, }, blocking=True, return_response=True, ) +async def test_archive_package( + hass: HomeAssistant, + mock_seventeentrack: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Ensure service archives package.""" + await _mock_packages(mock_seventeentrack) + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + DOMAIN, + SERVICE_ARCHIVE_PACKAGE, + { + CONFIG_ENTRY_ID_KEY: mock_config_entry.entry_id, + PACKAGE_TRACKING_NUMBER_KEY: ARCHIVE_PACKAGE_NUMBER, + }, + blocking=True, + ) + mock_seventeentrack.return_value.profile.archive_package.assert_called_once_with( + ARCHIVE_PACKAGE_NUMBER + ) + + async def _mock_packages(mock_seventeentrack): package1 = get_package(status=10) package2 = get_package( From ba5d23290a406ac2430839938239e30e4f5e6360 Mon Sep 17 00:00:00 2001 From: ilan <31193909+iloveicedgreentea@users.noreply.github.com> Date: Wed, 4 Sep 2024 15:57:37 -0400 Subject: [PATCH 0263/1309] Add madvr diagnostics (#125109) * feat: add basic diagnostics * fix: add mock data * fix: regen snapshots --- homeassistant/components/madvr/diagnostics.py | 25 ++++++++++ tests/components/madvr/conftest.py | 1 + .../madvr/snapshots/test_diagnostics.ambr | 26 ++++++++++ tests/components/madvr/test_diagnostics.py | 48 +++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 homeassistant/components/madvr/diagnostics.py create mode 100644 tests/components/madvr/snapshots/test_diagnostics.ambr create mode 100644 tests/components/madvr/test_diagnostics.py diff --git a/homeassistant/components/madvr/diagnostics.py b/homeassistant/components/madvr/diagnostics.py new file mode 100644 index 00000000000..f6261d27305 --- /dev/null +++ b/homeassistant/components/madvr/diagnostics.py @@ -0,0 +1,25 @@ +"""Provides diagnostics for madVR.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from . import MadVRConfigEntry + +TO_REDACT = [CONF_HOST] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: MadVRConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = config_entry.runtime_data.data + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "madvr_data": data, + } diff --git a/tests/components/madvr/conftest.py b/tests/components/madvr/conftest.py index 187786c6964..3136e04b06b 100644 --- a/tests/components/madvr/conftest.py +++ b/tests/components/madvr/conftest.py @@ -57,6 +57,7 @@ def mock_config_entry() -> MockConfigEntry: data=MOCK_CONFIG, unique_id=MOCK_MAC, title=DEFAULT_NAME, + entry_id="3bd2acb0e4f0476d40865546d0d91132", ) diff --git a/tests/components/madvr/snapshots/test_diagnostics.ambr b/tests/components/madvr/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f8008a651f2 --- /dev/null +++ b/tests/components/madvr/snapshots/test_diagnostics.ambr @@ -0,0 +1,26 @@ +# serializer version: 1 +# name: test_entry_diagnostics[positive_payload0] + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'port': 44077, + }), + 'disabled_by': None, + 'domain': 'madvr', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91132', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'envy', + 'unique_id': '00:11:22:33:44:55', + 'version': 1, + }), + 'madvr_data': dict({ + 'is_on': True, + }), + }) +# --- diff --git a/tests/components/madvr/test_diagnostics.py b/tests/components/madvr/test_diagnostics.py new file mode 100644 index 00000000000..453eaba8d94 --- /dev/null +++ b/tests/components/madvr/test_diagnostics.py @@ -0,0 +1,48 @@ +"""Test madVR diagnostics.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .conftest import get_update_callback + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("positive_payload"), + [ + {"is_on": True}, + ], +) +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_madvr_client: AsyncMock, + snapshot: SnapshotAssertion, + positive_payload: dict, +) -> None: + """Test config entry diagnostics.""" + with patch("homeassistant.components.madvr.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + update_callback = get_update_callback(mock_madvr_client) + + # Add data to test storing diagnostic data + update_callback(positive_payload) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("created_at", "modified_at")) From baa9473383c72029951e0995eb22f024662bd631 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 4 Sep 2024 23:24:52 +0300 Subject: [PATCH 0264/1309] Address BTHome review comment (#125259) * Address BTHome review comment * Review comment Co-authored-by: Ernst Klamer * generator expression Co-authored-by: Martin Hjelmare --------- Co-authored-by: Ernst Klamer Co-authored-by: Martin Hjelmare --- .../components/bthome/device_trigger.py | 85 +++++++++---------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index c50ffc05900..4eca110e581 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -34,7 +34,7 @@ from .const import ( EVENT_TYPE, ) -TRIGGERS_BY_EVENT_CLASS = { +EVENT_TYPES_BY_EVENT_CLASS = { EVENT_CLASS_BUTTON: { "press", "double_press", @@ -51,6 +51,38 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) +def get_event_classes_by_device_id(hass: HomeAssistant, device_id: str) -> list[str]: + """Get the supported event classes for a device. + + Events for BTHome BLE devices are dynamically discovered + and stored in the device config entry when they are first seen. + """ + device_registry = dr.async_get(hass) + device = device_registry.async_get(device_id) + if TYPE_CHECKING: + assert device is not None + + config_entries = [ + hass.config_entries.async_get_entry(entry_id) + for entry_id in device.config_entries + ] + bthome_config_entry = next( + entry for entry in config_entries if entry and entry.domain == DOMAIN + ) + return bthome_config_entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) + + +def get_event_types_by_event_class(event_class: str) -> set[str]: + """Get the supported event types for an event class. + + If the device has multiple buttons they will have + event classes like button_1 button_2, button_3, etc + but if there is only one button then it will be + button without a number postfix. + """ + return EVENT_TYPES_BY_EVENT_CLASS.get(event_class.split("_")[0], set()) + + async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: @@ -58,31 +90,17 @@ async def async_validate_trigger_config( config = TRIGGER_SCHEMA(config) event_class = config[CONF_TYPE] event_type = config[CONF_SUBTYPE] - - device_registry = dr.async_get(hass) - device = device_registry.async_get(config[CONF_DEVICE_ID]) - assert device is not None - config_entries = [ - hass.config_entries.async_get_entry(entry_id) - for entry_id in device.config_entries - ] - bthome_config_entry = next( - iter(entry for entry in config_entries if entry and entry.domain == DOMAIN) - ) - event_classes: list[str] = bthome_config_entry.data.get( - CONF_DISCOVERED_EVENT_CLASSES, [] - ) + device_id = config[CONF_DEVICE_ID] + event_classes = get_event_classes_by_device_id(hass, device_id) if event_class not in event_classes: raise InvalidDeviceAutomationConfig( - f"BTHome trigger {event_class} is not valid for device " - f"{device} ({config[CONF_DEVICE_ID]})" + f"BTHome trigger {event_class} is not valid for device_id '{device_id}'" ) - if event_type not in TRIGGERS_BY_EVENT_CLASS.get(event_class.split("_")[0], ()): + if event_type not in get_event_types_by_event_class(event_class): raise InvalidDeviceAutomationConfig( - f"BTHome trigger {event_type} is not valid for device " - f"{device} ({config[CONF_DEVICE_ID]})" + f"BTHome trigger {event_type} is not valid for device_id '{device_id}'" ) return config @@ -92,21 +110,7 @@ async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: """Return a list of triggers for BTHome BLE devices.""" - device_registry = dr.async_get(hass) - device = device_registry.async_get(device_id) - assert device is not None - config_entries = [ - hass.config_entries.async_get_entry(entry_id) - for entry_id in device.config_entries - ] - bthome_config_entry = next( - iter(entry for entry in config_entries if entry and entry.domain == DOMAIN), - None, - ) - assert bthome_config_entry is not None - event_classes: list[str] = bthome_config_entry.data.get( - CONF_DISCOVERED_EVENT_CLASSES, [] - ) + event_classes = get_event_classes_by_device_id(hass, device_id) return [ { # Required fields of TRIGGER_BASE_SCHEMA @@ -118,14 +122,7 @@ async def async_get_triggers( CONF_SUBTYPE: event_type, } for event_class in event_classes - for event_type in TRIGGERS_BY_EVENT_CLASS.get( - event_class.split("_")[0], - # If the device has multiple buttons they will have - # event classes like button_1 button_2, button_3, etc - # but if there is only one button then it will be - # button without a number postfix. - (), - ) + for event_type in get_event_types_by_event_class(event_class) ] From 505df84783bfc12be73934868d5090f3acbd3131 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Wed, 4 Sep 2024 17:17:39 -0400 Subject: [PATCH 0265/1309] Squeezebox remove deprecated sync and unsync services (#125271) * Squeezebox remove deprecated sync and unsync * Squeezebox remove sync group attribute --- .../components/squeezebox/media_player.py | 26 ------------------- 1 file changed, 26 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 8607e72a67c..0294c17f50a 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -56,11 +56,8 @@ from .const import DISCOVERY_TASK, DOMAIN, KNOWN_PLAYERS, SQUEEZEBOX_SOURCE_STRI SERVICE_CALL_METHOD = "call_method" SERVICE_CALL_QUERY = "call_query" -SERVICE_SYNC = "sync" -SERVICE_UNSYNC = "unsync" ATTR_QUERY_RESULT = "query_result" -ATTR_SYNC_GROUP = "sync_group" SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered" @@ -75,7 +72,6 @@ ATTR_OTHER_PLAYER = "other_player" ATTR_TO_PROPERTY = [ ATTR_QUERY_RESULT, - ATTR_SYNC_GROUP, ] SQUEEZEBOX_MODE = { @@ -181,12 +177,6 @@ async def async_setup_entry( }, "async_call_query", ) - platform.async_register_entity_service( - SERVICE_SYNC, - {vol.Required(ATTR_OTHER_PLAYER): cv.string}, - "async_sync", - ) - platform.async_register_entity_service(SERVICE_UNSYNC, None, "async_unsync") # Start server discovery task if not already running entry.async_on_unload(async_at_start(hass, start_server_discovery)) @@ -566,26 +556,10 @@ class SqueezeBoxEntity(MediaPlayerEntity): "Could not find player_id for %s. Not syncing", other_player ) - async def async_sync(self, other_player: str) -> None: - """Sync this Squeezebox player to another. Deprecated.""" - _LOGGER.warning( - "Service squeezebox.sync is deprecated; use media_player.join_players" - " instead" - ) - await self.async_join_players([other_player]) - async def async_unjoin_player(self) -> None: """Unsync this Squeezebox player.""" await self._player.async_unsync() - async def async_unsync(self) -> None: - """Unsync this Squeezebox player. Deprecated.""" - _LOGGER.warning( - "Service squeezebox.unsync is deprecated; use media_player.unjoin_player" - " instead" - ) - await self.async_unjoin_player() - async def async_browse_media( self, media_content_type: MediaType | str | None = None, From 199a4b725b63c441ab94ab612ba6da86398c886c Mon Sep 17 00:00:00 2001 From: Jordi Date: Wed, 4 Sep 2024 23:22:31 +0200 Subject: [PATCH 0266/1309] Increase AquaCell timeout and handle timeout exception properly (#125263) * Increase timeout and add handling of timeout exception * Raise update failed instead of config entry error --- homeassistant/components/aquacell/config_flow.py | 2 +- homeassistant/components/aquacell/coordinator.py | 4 ++-- tests/components/aquacell/test_config_flow.py | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aquacell/config_flow.py b/homeassistant/components/aquacell/config_flow.py index 332cd16e749..1ee89035d93 100644 --- a/homeassistant/components/aquacell/config_flow.py +++ b/homeassistant/components/aquacell/config_flow.py @@ -56,7 +56,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN): refresh_token = await api.authenticate( user_input[CONF_EMAIL], user_input[CONF_PASSWORD] ) - except ApiException: + except (ApiException, TimeoutError): errors["base"] = "cannot_connect" except AuthenticationFailed: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/aquacell/coordinator.py b/homeassistant/components/aquacell/coordinator.py index dd5dfcd2d0d..ee4afb451b9 100644 --- a/homeassistant/components/aquacell/coordinator.py +++ b/homeassistant/components/aquacell/coordinator.py @@ -56,7 +56,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]): so entities can quickly look up their data. """ - async with asyncio.timeout(10): + async with asyncio.timeout(30): # Check if the refresh token is expired expiry_time = ( self.refresh_token_creation_time @@ -72,7 +72,7 @@ class AquacellCoordinator(DataUpdateCoordinator[dict[str, Softener]]): softeners = await self.aquacell_api.get_all_softeners() except AuthenticationFailed as err: raise ConfigEntryError from err - except AquacellApiException as err: + except (AquacellApiException, TimeoutError) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err return {softener.dsn: softener for softener in softeners} diff --git a/tests/components/aquacell/test_config_flow.py b/tests/components/aquacell/test_config_flow.py index b73852d513f..f677b3f8348 100644 --- a/tests/components/aquacell/test_config_flow.py +++ b/tests/components/aquacell/test_config_flow.py @@ -79,6 +79,7 @@ async def test_full_flow( ("exception", "error"), [ (ApiException, "cannot_connect"), + (TimeoutError, "cannot_connect"), (AuthenticationFailed, "invalid_auth"), (Exception, "unknown"), ], From a0356f587e37359d684e112eee51d8d3d67cb666 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Sep 2024 11:32:08 -1000 Subject: [PATCH 0267/1309] Fix yarl binary wheel builds for armv7l and armhf (#125270) --- .github/workflows/wheels.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 04e4391790a..fcd71cbec32 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -140,7 +140,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "libffi-dev;openssl-dev;yaml-dev;nasm" - skip-binary: aiohttp + skip-binary: aiohttp;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" @@ -212,7 +212,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_old-cython.txt" @@ -227,7 +227,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -241,7 +241,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -255,7 +255,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" From fbd3bf7a98ef5ac5a912f76fa3cf00afb9ff90e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Sep 2024 11:32:33 -1000 Subject: [PATCH 0268/1309] Bump yarl to 1.9.9 (#125264) --- 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 767bd206266..e489006867f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ urllib3>=1.26.5,<2 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.9.8 +yarl==1.9.9 zeroconf==0.133.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 2c8e0a432f0..e2d5e213811 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.9.8", + "yarl==1.9.9", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 7f28e93cd4f..1d6b4e74d22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,4 @@ urllib3>=1.26.5,<2 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.9.8 +yarl==1.9.9 From c8fd48523fd33a23fe51402dc74a18d8f3da424c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 5 Sep 2024 06:10:21 +0200 Subject: [PATCH 0269/1309] Use TypeVar defaults for Generator (#125228) --- tests/components/fujitsu_fglair/conftest.py | 2 +- tests/components/google_photos/conftest.py | 10 +++------- tests/components/google_photos/test_config_flow.py | 4 ++-- tests/components/intellifire/conftest.py | 14 +++++++------- tests/components/smlight/conftest.py | 4 ++-- tests/components/yale/test_config_flow.py | 2 +- 6 files changed, 16 insertions(+), 20 deletions(-) diff --git a/tests/components/fujitsu_fglair/conftest.py b/tests/components/fujitsu_fglair/conftest.py index b73007a566b..04042fb0b09 100644 --- a/tests/components/fujitsu_fglair/conftest.py +++ b/tests/components/fujitsu_fglair/conftest.py @@ -30,7 +30,7 @@ TEST_PROPERTY_VALUES = { @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.fujitsu_fglair.async_setup_entry", return_value=True diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index 9dbe85bd25b..3ca64471fa1 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -125,7 +125,7 @@ def mock_client_api( fixture_name: str, user_identifier: str, api_error: Exception, -) -> Generator[Mock, None, None]: +) -> Generator[Mock]: """Set up fake Google Photos API responses from fixtures.""" mock_api = AsyncMock(GooglePhotosLibraryApi, autospec=True) mock_api.get_user_info.return_value = UserInfoResult( @@ -136,9 +136,7 @@ def mock_client_api( responses = load_json_array_fixture(fixture_name, DOMAIN) if fixture_name else [] - async def list_media_items( - *args: Any, - ) -> AsyncGenerator[ListMediaItemResult, None, None]: + async def list_media_items(*args: Any) -> AsyncGenerator[ListMediaItemResult]: for response in responses: mock_list_media_items = Mock(ListMediaItemResult) mock_list_media_items.media_items = [ @@ -163,9 +161,7 @@ def mock_client_api( # Emulate an async iterator for returning pages of response objects. We just # return a single page. - async def list_albums( - *args: Any, **kwargs: Any - ) -> AsyncGenerator[ListAlbumResult, None, None]: + async def list_albums(*args: Any, **kwargs: Any) -> AsyncGenerator[ListAlbumResult]: mock_list_album_result = Mock(ListAlbumResult) mock_list_album_result.albums = [ Album.from_dict(album) diff --git a/tests/components/google_photos/test_config_flow.py b/tests/components/google_photos/test_config_flow.py index be97d7658c6..48c8723df3c 100644 --- a/tests/components/google_photos/test_config_flow.py +++ b/tests/components/google_photos/test_config_flow.py @@ -28,7 +28,7 @@ CLIENT_SECRET = "5678" @pytest.fixture(name="mock_setup") -def mock_setup_entry() -> Generator[Mock, None, None]: +def mock_setup_entry() -> Generator[Mock]: """Fixture to mock out integration setup.""" with patch( "homeassistant.components.google_photos.async_setup_entry", return_value=True @@ -37,7 +37,7 @@ def mock_setup_entry() -> Generator[Mock, None, None]: @pytest.fixture(autouse=True) -def mock_patch_api(mock_api: Mock) -> Generator[None, None, None]: +def mock_patch_api(mock_api: Mock) -> Generator[None]: """Fixture to patch the config flow api.""" with patch( "homeassistant.components.google_photos.config_flow.GooglePhotosLibraryApi", diff --git a/tests/components/intellifire/conftest.py b/tests/components/intellifire/conftest.py index 251d5bdde48..0bd7073ee47 100644 --- a/tests/components/intellifire/conftest.py +++ b/tests/components/intellifire/conftest.py @@ -34,7 +34,7 @@ from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Mock setting up a config entry.""" with patch( "homeassistant.components.intellifire.async_setup_entry", return_value=True @@ -43,7 +43,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_fireplace_finder_none() -> Generator[None, MagicMock, None]: +def mock_fireplace_finder_none() -> Generator[MagicMock]: """Mock fireplace finder.""" mock_found_fireplaces = Mock() mock_found_fireplaces.ips = [] @@ -110,7 +110,7 @@ def mock_common_data_local() -> IntelliFireCommonFireplaceData: @pytest.fixture def mock_apis_multifp( mock_cloud_interface, mock_local_interface, mock_fp -) -> Generator[tuple[AsyncMock, AsyncMock, MagicMock], None, None]: +) -> Generator[tuple[AsyncMock, AsyncMock, MagicMock]]: """Multi fireplace version of mocks.""" return mock_local_interface, mock_cloud_interface, mock_fp @@ -118,7 +118,7 @@ def mock_apis_multifp( @pytest.fixture def mock_apis_single_fp( mock_cloud_interface, mock_local_interface, mock_fp -) -> Generator[tuple[AsyncMock, AsyncMock, MagicMock], None, None]: +) -> Generator[tuple[AsyncMock, AsyncMock, MagicMock]]: """Single fire place version of the mocks.""" data_v1 = IntelliFireUserData( **load_json_object_fixture("user_data_1.json", DOMAIN) @@ -131,7 +131,7 @@ def mock_apis_single_fp( @pytest.fixture -def mock_cloud_interface() -> Generator[AsyncMock, None, None]: +def mock_cloud_interface() -> Generator[AsyncMock]: """Mock cloud interface to use for testing.""" user_data = IntelliFireUserData( **load_json_object_fixture("user_data_3.json", DOMAIN) @@ -165,7 +165,7 @@ def mock_cloud_interface() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_local_interface() -> Generator[AsyncMock, None, None]: +def mock_local_interface() -> Generator[AsyncMock]: """Mock version of IntelliFireAPILocal.""" poll_data = IntelliFirePollData( **load_json_object_fixture("intellifire/local_poll.json") @@ -181,7 +181,7 @@ def mock_local_interface() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_fp(mock_common_data_local) -> Generator[AsyncMock, None, None]: +def mock_fp(mock_common_data_local) -> Generator[AsyncMock]: """Mock fireplace.""" local_poll_data = IntelliFirePollData( diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index ad4d749c0d2..c51da5c5ee5 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -39,14 +39,14 @@ def platforms() -> list[Platform]: @pytest.fixture(autouse=True) -async def mock_patch_platforms(platforms: list[str]) -> AsyncGenerator[None, None]: +async def mock_patch_platforms(platforms: list[str]) -> AsyncGenerator[None]: """Fixture to set up platforms for tests.""" with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): yield @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.smlight.async_setup_entry", return_value=True diff --git a/tests/components/yale/test_config_flow.py b/tests/components/yale/test_config_flow.py index 163f8240553..004162c0ebf 100644 --- a/tests/components/yale/test_config_flow.py +++ b/tests/components/yale/test_config_flow.py @@ -25,7 +25,7 @@ CLIENT_ID = "1" @pytest.fixture -def mock_setup_entry() -> Generator[Mock, None, None]: +def mock_setup_entry() -> Generator[Mock]: """Patch setup entry.""" with patch( "homeassistant.components.yale.async_setup_entry", return_value=True From 71d35a03e17b4b48c1c33df541377b92b6cfd3b1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Sep 2024 20:12:43 -1000 Subject: [PATCH 0270/1309] Switch hassio to use with_path where possible (#125268) * Switch hassio to use with_path where possible Any place we are joining to the root url, we can use with_path as its much faster * revert --- homeassistant/components/hassio/handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index c57e43f73f3..7c8d5c61a22 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -568,7 +568,7 @@ class HassIO: This method is a coroutine. """ - joined_url = self._base_url.join(URL(command)) + joined_url = self._base_url.with_path(command) # This check is to make sure the normalized URL string # is the same as the URL string that was passed in. If # they are different, then the passed in command URL From 4c56cbe8c8d55c4169481555ef556e3aff5c8522 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Thu, 5 Sep 2024 08:50:49 +0200 Subject: [PATCH 0271/1309] Add follower to the PlayingMode enum (#125294) Update media_player.py --- homeassistant/components/linkplay/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 0b62b4dbcee..8b2fcf5d52f 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -59,6 +59,7 @@ SOURCE_MAP: dict[PlayingMode, str] = { PlayingMode.FM: "FM Radio", PlayingMode.RCA: "RCA", PlayingMode.UDISK: "USB", + PlayingMode.FOLLOWER: "Follower", } SOURCE_MAP_INV: dict[str, PlayingMode] = {v: k for k, v in SOURCE_MAP.items()} From a8f2204f4f61bdc59bdda3b48bb0f012c62f6924 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Sep 2024 08:56:18 +0200 Subject: [PATCH 0272/1309] Teach recorder data migrator base class to update MigrationChanges (#125214) * Teach recorder data migrator base class to update MigrationChanges * Bump migration version * Improve test coverage * Update migration.py * Revert migrator version bump * Remove unneeded change --- .../components/recorder/migration.py | 113 ++++++------------ homeassistant/components/recorder/util.py | 17 +-- 2 files changed, 47 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 242e503611c..324bdd5ea13 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2201,8 +2201,8 @@ class CommitBeforeMigrationTask(MigrationTask): @dataclass(frozen=True, kw_only=True) -class NeedsMigrateResult: - """Container for the return value of BaseRunTimeMigration.needs_migrate_impl.""" +class DataMigrationStatus: + """Container for data migrator status.""" needs_migrate: bool migration_done: bool @@ -2229,36 +2229,30 @@ class BaseRunTimeMigration(ABC): else: self.migration_done(instance, session) + @retryable_database_job("migrate data", method=True) def migrate_data(self, instance: Recorder) -> bool: """Migrate some data, returns True if migration is completed.""" - if result := self.migrate_data_impl(instance): + status = self.migrate_data_impl(instance) + if status.migration_done: if self.index_to_drop is not None: - self._remove_index(instance, self.index_to_drop) - self.migration_done(instance, None) - return result + table, index = self.index_to_drop + _drop_index(instance.get_session, table, index) + with session_scope(session=instance.get_session()) as session: + self.migration_done(instance, session) + _mark_migration_done(session, self.__class__) + return not status.needs_migrate - @staticmethod @abstractmethod - def migrate_data_impl(instance: Recorder) -> bool: - """Migrate some data, returns True if migration is completed.""" + def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: + """Migrate some data, return if the migration needs to run and if it is done.""" - @staticmethod - @database_job_retry_wrapper("remove index") - def _remove_index(instance: Recorder, index_to_drop: tuple[str, str]) -> None: - """Remove indices. - - Called when migration is completed. - """ - table, index = index_to_drop - _drop_index(instance.get_session, table, index) - - def migration_done(self, instance: Recorder, session: Session | None) -> None: + def migration_done(self, instance: Recorder, session: Session) -> None: """Will be called after migrate returns True or if migration is not needed.""" @abstractmethod def needs_migrate_impl( self, instance: Recorder, session: Session - ) -> NeedsMigrateResult: + ) -> DataMigrationStatus: """Return if the migration needs to run and if it is done.""" def needs_migrate(self, instance: Recorder, session: Session) -> bool: @@ -2300,10 +2294,10 @@ class BaseRunTimeMigrationWithQuery(BaseRunTimeMigration): def needs_migrate_impl( self, instance: Recorder, session: Session - ) -> NeedsMigrateResult: + ) -> DataMigrationStatus: """Return if the migration needs to run.""" needs_migrate = execute_stmt_lambda_element(session, self.needs_migrate_query()) - return NeedsMigrateResult( + return DataMigrationStatus( needs_migrate=bool(needs_migrate), migration_done=not needs_migrate ) @@ -2315,9 +2309,7 @@ class StatesContextIDMigration(BaseRunTimeMigrationWithQuery): migration_id = "state_context_id_as_binary" index_to_drop = ("states", "ix_states_context_id") - @staticmethod - @retryable_database_job("migrate states context_ids to binary format") - def migrate_data_impl(instance: Recorder) -> bool: + def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate states context_ids to use binary format, return True if completed.""" _to_bytes = _context_id_to_bytes session_maker = instance.get_session @@ -2342,13 +2334,10 @@ class StatesContextIDMigration(BaseRunTimeMigrationWithQuery): for state_id, last_updated_ts, context_id, context_user_id, context_parent_id in states ], ) - # If there is more work to do return False - # so that we can be called again - if is_done := not states: - _mark_migration_done(session, StatesContextIDMigration) + is_done = not states _LOGGER.debug("Migrating states context_ids to binary format: done=%s", is_done) - return is_done + return DataMigrationStatus(needs_migrate=not is_done, migration_done=is_done) def needs_migrate_query(self) -> StatementLambdaElement: """Return the query to check if the migration needs to run.""" @@ -2362,9 +2351,7 @@ class EventsContextIDMigration(BaseRunTimeMigrationWithQuery): migration_id = "event_context_id_as_binary" index_to_drop = ("events", "ix_events_context_id") - @staticmethod - @retryable_database_job("migrate events context_ids to binary format") - def migrate_data_impl(instance: Recorder) -> bool: + def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate events context_ids to use binary format, return True if completed.""" _to_bytes = _context_id_to_bytes session_maker = instance.get_session @@ -2389,13 +2376,10 @@ class EventsContextIDMigration(BaseRunTimeMigrationWithQuery): for event_id, time_fired_ts, context_id, context_user_id, context_parent_id in events ], ) - # If there is more work to do return False - # so that we can be called again - if is_done := not events: - _mark_migration_done(session, EventsContextIDMigration) + is_done = not events _LOGGER.debug("Migrating events context_ids to binary format: done=%s", is_done) - return is_done + return DataMigrationStatus(needs_migrate=not is_done, migration_done=is_done) def needs_migrate_query(self) -> StatementLambdaElement: """Return the query to check if the migration needs to run.""" @@ -2412,9 +2396,7 @@ class EventTypeIDMigration(BaseRunTimeMigrationWithQuery): # no new pending event_types about to be added to # the db since this happens live - @staticmethod - @retryable_database_job("migrate events event_types to event_type_ids") - def migrate_data_impl(instance: Recorder) -> bool: + def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate event_type to event_type_ids, return True if completed.""" session_maker = instance.get_session _LOGGER.debug("Migrating event_types") @@ -2467,15 +2449,12 @@ class EventTypeIDMigration(BaseRunTimeMigrationWithQuery): ], ) - # If there is more work to do return False - # so that we can be called again - if is_done := not events: - _mark_migration_done(session, EventTypeIDMigration) + is_done = not events _LOGGER.debug("Migrating event_types done=%s", is_done) - return is_done + return DataMigrationStatus(needs_migrate=not is_done, migration_done=is_done) - def migration_done(self, instance: Recorder, session: Session | None) -> None: + def migration_done(self, instance: Recorder, session: Session) -> None: """Will be called after migrate returns True.""" _LOGGER.debug("Activating event_types manager as all data is migrated") instance.event_type_manager.active = True @@ -2495,9 +2474,7 @@ class EntityIDMigration(BaseRunTimeMigrationWithQuery): # no new pending states_meta about to be added to # the db since this happens live - @staticmethod - @retryable_database_job("migrate states entity_ids to states_meta") - def migrate_data_impl(instance: Recorder) -> bool: + def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Migrate entity_ids to states_meta, return True if completed. We do this in two steps because we need the history queries to work @@ -2560,15 +2537,12 @@ class EntityIDMigration(BaseRunTimeMigrationWithQuery): ], ) - # If there is more work to do return False - # so that we can be called again - if is_done := not states: - _mark_migration_done(session, EntityIDMigration) + is_done = not states _LOGGER.debug("Migrating entity_ids done=%s", is_done) - return is_done + return DataMigrationStatus(needs_migrate=not is_done, migration_done=is_done) - def migration_done(self, instance: Recorder, _session: Session | None) -> None: + def migration_done(self, instance: Recorder, session: Session) -> None: """Will be called after migrate returns True.""" # The migration has finished, now we start the post migration # to remove the old entity_id data from the states table @@ -2576,15 +2550,7 @@ class EntityIDMigration(BaseRunTimeMigrationWithQuery): # so we set active to True _LOGGER.debug("Activating states_meta manager as all data is migrated") instance.states_meta_manager.active = True - session_generator = ( - contextlib.nullcontext(_session) - if _session - else session_scope(session=instance.get_session()) - ) - with ( - contextlib.suppress(SQLAlchemyError), - session_generator as session, - ): + with contextlib.suppress(SQLAlchemyError): # If ix_states_entity_id_last_updated_ts still exists # on the states table it means the entity id migration # finished by the EntityIDPostMigrationTask did not @@ -2609,9 +2575,7 @@ class EventIDPostMigration(BaseRunTimeMigration): task = MigrationTask migration_version = 2 - @staticmethod - @retryable_database_job("cleanup_legacy_event_ids") - def migrate_data_impl(instance: Recorder) -> bool: + def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: """Remove old event_id index from states, returns True if completed. We used to link states to events using the event_id column but we no @@ -2651,9 +2615,8 @@ class EventIDPostMigration(BaseRunTimeMigration): if fk_remove_ok: _drop_index(session_maker, "states", LEGACY_STATES_EVENT_ID_INDEX) instance.use_legacy_events_index = False - _mark_migration_done(session, EventIDPostMigration) - return True + return DataMigrationStatus(needs_migrate=False, migration_done=fk_remove_ok) @staticmethod def _legacy_event_id_foreign_key_exists(instance: Recorder) -> bool: @@ -2674,16 +2637,16 @@ class EventIDPostMigration(BaseRunTimeMigration): def needs_migrate_impl( self, instance: Recorder, session: Session - ) -> NeedsMigrateResult: + ) -> DataMigrationStatus: """Return if the migration needs to run.""" if self.schema_version <= LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION: - return NeedsMigrateResult(needs_migrate=False, migration_done=False) + return DataMigrationStatus(needs_migrate=False, migration_done=False) if get_index_by_name( session, TABLE_STATES, LEGACY_STATES_EVENT_ID_INDEX ) is not None or self._legacy_event_id_foreign_key_exists(instance): instance.use_legacy_events_index = True - return NeedsMigrateResult(needs_migrate=True, migration_done=False) - return NeedsMigrateResult(needs_migrate=False, migration_done=True) + return DataMigrationStatus(needs_migrate=True, migration_done=False) + return DataMigrationStatus(needs_migrate=False, migration_done=True) @dataclass(slots=True) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 4d494aed7d5..9f6cdccd79a 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -645,23 +645,24 @@ def _is_retryable_error(instance: Recorder, err: OperationalError) -> bool: type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R] +type _FuncOrMethType[**_P, _R] = Callable[_P, _R] -def retryable_database_job[_RecorderT: Recorder, **_P]( - description: str, -) -> Callable[[_FuncType[_RecorderT, _P, bool]], _FuncType[_RecorderT, _P, bool]]: +def retryable_database_job[**_P]( + description: str, method: bool = False +) -> Callable[[_FuncOrMethType[_P, bool]], _FuncOrMethType[_P, bool]]: """Try to execute a database job. The job should return True if it finished, and False if it needs to be rescheduled. """ + recorder_pos = 1 if method else 0 - def decorator( - job: _FuncType[_RecorderT, _P, bool], - ) -> _FuncType[_RecorderT, _P, bool]: + def decorator(job: _FuncOrMethType[_P, bool]) -> _FuncOrMethType[_P, bool]: @functools.wraps(job) - def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> bool: + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> bool: + instance: Recorder = args[recorder_pos] # type: ignore[assignment] try: - return job(instance, *args, **kwargs) + return job(*args, **kwargs) except OperationalError as err: if _is_retryable_error(instance, err): assert isinstance(err.orig, BaseException) # noqa: PT017 From f778033bd8c4049d542719e041794148ae463846 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 5 Sep 2024 09:55:57 +0200 Subject: [PATCH 0273/1309] Improve config flow type hints in ukraine_alarm (#125302) --- .../components/ukraine_alarm/config_flow.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ukraine_alarm/config_flow.py b/homeassistant/components/ukraine_alarm/config_flow.py index faaa9240df3..12059124fa2 100644 --- a/homeassistant/components/ukraine_alarm/config_flow.py +++ b/homeassistant/components/ukraine_alarm/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import aiohttp from uasiren.client import Client @@ -25,7 +25,7 @@ class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a new UkraineAlarmConfigFlow.""" - self.states = None + self.states: list[dict[str, Any]] | None = None self.selected_region: dict[str, Any] | None = None async def async_step_user( @@ -69,17 +69,25 @@ class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN): return await self._handle_pick_region("user", "district", user_input) - async def async_step_district(self, user_input=None): + async def async_step_district( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle user-chosen district.""" return await self._handle_pick_region("district", "community", user_input) - async def async_step_community(self, user_input=None): + async def async_step_community( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle user-chosen community.""" return await self._handle_pick_region("community", None, user_input, True) async def _handle_pick_region( - self, step_id: str, next_step: str | None, user_input, last_step=False - ): + self, + step_id: str, + next_step: str | None, + user_input: dict[str, str] | None, + last_step: bool = False, + ) -> ConfigFlowResult: """Handle picking a (sub)region.""" if self.selected_region: source = self.selected_region["regionChildIds"] @@ -121,8 +129,10 @@ class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN): step_id=step_id, data_schema=schema, last_step=last_step ) - async def _async_finish_flow(self): + async def _async_finish_flow(self) -> ConfigFlowResult: """Finish the setup.""" + if TYPE_CHECKING: + assert self.selected_region is not None await self.async_set_unique_id(self.selected_region["regionId"]) self._abort_if_unique_id_configured() @@ -135,10 +145,10 @@ class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN): ) -def _find(regions, region_id): +def _find(regions: list[dict[str, Any]], region_id): return next((region for region in regions if region["regionId"] == region_id), None) -def _make_regions_object(regions): +def _make_regions_object(regions: list[dict[str, Any]]) -> dict[str, str]: regions = sorted(regions, key=lambda region: region["regionName"].lower()) return {region["regionId"]: region["regionName"] for region in regions} From 984eba809c9977b277991ed60ded3189e9d4c4cc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Sep 2024 10:16:44 +0200 Subject: [PATCH 0274/1309] Simplify generic decorators in recorder (#125301) * Simplify generic decorators in recorder * Remove additional case --- homeassistant/components/recorder/util.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 9f6cdccd79a..75e403d8204 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -644,7 +644,7 @@ def _is_retryable_error(instance: Recorder, err: OperationalError) -> bool: ) -type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R] +type _FuncType[**P, R] = Callable[Concatenate[Recorder, P], R] type _FuncOrMethType[**_P, _R] = Callable[_P, _R] @@ -683,9 +683,9 @@ def retryable_database_job[**_P]( return decorator -def database_job_retry_wrapper[_RecorderT: Recorder, **_P]( +def database_job_retry_wrapper[**_P]( description: str, attempts: int = 5 -) -> Callable[[_FuncType[_RecorderT, _P, None]], _FuncType[_RecorderT, _P, None]]: +) -> Callable[[_FuncType[_P, None]], _FuncType[_P, None]]: """Try to execute a database job multiple times. This wrapper handles InnoDB deadlocks and lock timeouts. @@ -695,10 +695,10 @@ def database_job_retry_wrapper[_RecorderT: Recorder, **_P]( """ def decorator( - job: _FuncType[_RecorderT, _P, None], - ) -> _FuncType[_RecorderT, _P, None]: + job: _FuncType[_P, None], + ) -> _FuncType[_P, None]: @functools.wraps(job) - def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> None: + def wrapper(instance: Recorder, *args: _P.args, **kwargs: _P.kwargs) -> None: for attempt in range(attempts): try: job(instance, *args, **kwargs) From b5831344a02f2c1ae72daf42f353e09bb2db973e Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Thu, 5 Sep 2024 18:53:12 +1000 Subject: [PATCH 0275/1309] Add diagnostics to GDACS integration (#125296) * simple diagnostics * add service status information * remove from no diagnostics list * wip * cater for the case where status info is undefined * make test work * code reformatted * add snapshot data * simplify code --- homeassistant/components/gdacs/diagnostics.py | 39 +++++++++++++++++++ script/hassfest/manifest.py | 1 - .../gdacs/snapshots/test_diagnostics.ambr | 21 ++++++++++ tests/components/gdacs/test_diagnostics.py | 33 ++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/gdacs/diagnostics.py create mode 100644 tests/components/gdacs/snapshots/test_diagnostics.ambr create mode 100644 tests/components/gdacs/test_diagnostics.py diff --git a/homeassistant/components/gdacs/diagnostics.py b/homeassistant/components/gdacs/diagnostics.py new file mode 100644 index 00000000000..435e28ca1ae --- /dev/null +++ b/homeassistant/components/gdacs/diagnostics.py @@ -0,0 +1,39 @@ +"""Diagnostics support for GDACS integration.""" + +from __future__ import annotations + +from typing import Any + +from aio_georss_client.status_update import StatusUpdate + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from . import GdacsFeedEntityManager +from .const import DOMAIN, FEED + +TO_REDACT = {CONF_LATITUDE, CONF_LONGITUDE} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data: dict[str, Any] = { + "info": async_redact_data(config_entry.data, TO_REDACT), + } + + manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][config_entry.entry_id] + status_info: StatusUpdate = manager.status_info() + if status_info: + data["service"] = { + "status": status_info.status, + "total": status_info.total, + "last_update": status_info.last_update, + "last_update_successful": status_info.last_update_successful, + "last_timestamp": status_info.last_timestamp, + } + + return data diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 1c01ee7cf58..185b2b178e4 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -117,7 +117,6 @@ NO_IOT_CLASS = [ # https://github.com/home-assistant/developers.home-assistant/pull/1512 NO_DIAGNOSTICS = [ "dlna_dms", - "gdacs", "geonetnz_quakes", "hyperion", "nightscout", diff --git a/tests/components/gdacs/snapshots/test_diagnostics.ambr b/tests/components/gdacs/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5b6154307f7 --- /dev/null +++ b/tests/components/gdacs/snapshots/test_diagnostics.ambr @@ -0,0 +1,21 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'info': dict({ + 'categories': list([ + ]), + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'radius': 25, + 'scan_interval': 300.0, + 'unit_system': 'metric', + }), + 'service': dict({ + 'last_timestamp': None, + 'last_update': '2024-09-05T15:00:00', + 'last_update_successful': '2024-09-05T15:00:00', + 'status': 'OK', + 'total': 0, + }), + }) +# --- diff --git a/tests/components/gdacs/test_diagnostics.py b/tests/components/gdacs/test_diagnostics.py new file mode 100644 index 00000000000..3c6cf4080a6 --- /dev/null +++ b/tests/components/gdacs/test_diagnostics.py @@ -0,0 +1,33 @@ +"""Test GDACS diagnostics.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.freeze_time("2024-09-05 15:00:00") +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, +) -> None: + """Test config entry diagnostics.""" + with patch("aio_georss_client.feed.GeoRssFeed.update") as mock_feed_update: + mock_feed_update.return_value = "OK", [] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot From 511ecf98d5422ea4171a77c5e9c8c30d54185d86 Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 5 Sep 2024 19:02:05 +1000 Subject: [PATCH 0276/1309] Add reauth flow for Smlight (#124418) * Add reauth flow for smlight integration * add strings for reauth * trigger reauth flow on authentication errors * Add tests for reauth flow * test for update failed on auth error * restore name title placeholder * raise config entry error to trigger reauth * Add test for reauth triggered at startup --------- Co-authored-by: Tim Lunn --- .../components/smlight/config_flow.py | 49 ++++++++ .../components/smlight/coordinator.py | 11 +- homeassistant/components/smlight/strings.json | 13 +- tests/components/smlight/conftest.py | 12 ++ tests/components/smlight/test_config_flow.py | 113 ++++++++++++++++++ tests/components/smlight/test_init.py | 24 +++- 6 files changed, 215 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 1b8cc4efeb1..98da153ce75 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from pysmlight import Api2 @@ -14,6 +15,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNA from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from . import SmConfigEntry from .const import DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( @@ -37,6 +39,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self.client: Api2 self.host: str | None = None + self._reauth_entry: SmConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -127,6 +130,52 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth when API Authentication failed.""" + + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + host = entry_data[CONF_HOST] + self.context["title_placeholders"] = { + "host": host, + "name": entry_data.get(CONF_USERNAME, "unknown"), + } + self.client = Api2(host, session=async_get_clientsession(self.hass)) + self.host = host + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle re-authentication of an existing config entry.""" + errors = {} + if user_input is not None: + try: + await self.client.authenticate( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + except SmlightAuthError: + errors["base"] = "invalid_auth" + except SmlightConnectionError: + return self.async_abort(reason="cannot_connect") + else: + assert self._reauth_entry is not None + + return self.async_update_reload_and_abort( + self._reauth_entry, data={**user_input, CONF_HOST: self.host} + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_AUTH_DATA_SCHEMA, + description_placeholders=self.context["title_placeholders"], + errors=errors, + ) + async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool: """Check if auth required and attempt to authenticate.""" if await self.client.check_auth_needed(): diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 6a29f14fafd..380644c81d1 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -8,7 +8,7 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -54,8 +54,10 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): self.config_entry.data[CONF_PASSWORD], ) except SmlightAuthError as err: - LOGGER.error("Failed to authenticate: %s", err) - raise ConfigEntryError from err + raise ConfigEntryAuthFailed from err + else: + # Auth required but no credentials available + raise ConfigEntryAuthFailed info = await self.client.get_info() self.unique_id = format_mac(info.MAC) @@ -67,5 +69,8 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): sensors=await self.client.get_sensors(), info=await self.client.get_info(), ) + except SmlightAuthError as err: + raise ConfigEntryAuthFailed from err + except SmlightConnectionError as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 41f84c49bf9..f22966df904 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -17,6 +17,14 @@ "password": "[%key:common::config_flow::data::password%]" } }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Please enter the correct username and password for host: {host}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, "confirm_discovery": { "description": "Do you want to set up SMLIGHT at {host}?" } @@ -27,7 +35,10 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_failed": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index c51da5c5ee5..a86c7b4c27a 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -32,6 +32,18 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_host() -> MockConfigEntry: + """Return the default mocked config entry, no credentials.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: MOCK_HOST, + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + + @pytest.fixture def platforms() -> list[Platform]: """Platforms, which should be loaded during the test.""" diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index 9a23a8de753..fb07e29edd4 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -363,3 +363,116 @@ async def test_zeroconf_legacy_mac( assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_smlight_client.get_info.mock_calls) == 2 + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_smlight_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth flow completes successfully.""" + mock_smlight_client.check_auth_needed.return_value = True + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_config_entry.data == { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_HOST: MOCK_HOST, + } + + assert len(mock_smlight_client.authenticate.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_auth_error( + hass: HomeAssistant, + mock_smlight_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth flow with authentication error.""" + mock_smlight_client.check_auth_needed.return_value = True + mock_smlight_client.authenticate.side_effect = SmlightAuthError + + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: "test-bad", + }, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "reauth_confirm" + + mock_smlight_client.authenticate.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + assert mock_config_entry.data == { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + CONF_HOST: MOCK_HOST, + } + + assert len(mock_smlight_client.authenticate.mock_calls) == 2 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_connect_error( + hass: HomeAssistant, + mock_smlight_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with error.""" + mock_smlight_client.check_auth_needed.return_value = True + mock_smlight_client.authenticate.side_effect = SmlightConnectionError + + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "cannot_connect" + assert len(mock_smlight_client.authenticate.mock_calls) == 1 diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index 682993cb943..1323c93e6bf 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory -from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError +from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, SmlightError import pytest from syrupy.assertion import SnapshotAssertion @@ -55,19 +55,37 @@ async def test_async_setup_auth_failed( assert entry.state is ConfigEntryState.NOT_LOADED +async def test_async_setup_missing_credentials( + hass: HomeAssistant, + mock_config_entry_host: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test we trigger reauth when credentials are missing.""" + mock_smlight_client.check_auth_needed.return_value = True + + await setup_integration(hass, mock_config_entry_host) + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0]["step_id"] == "reauth_confirm" + assert progress[0]["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" + + +@pytest.mark.parametrize("error", [SmlightConnectionError, SmlightAuthError]) async def test_update_failed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_smlight_client: MagicMock, freezer: FrozenDateTimeFactory, + error: SmlightError, ) -> None: - """Test update failed due to connection error.""" + """Test update failed due to error.""" await setup_integration(hass, mock_config_entry) entity = hass.states.get("sensor.mock_title_core_chip_temp") assert entity.state is not STATE_UNAVAILABLE - mock_smlight_client.get_info.side_effect = SmlightConnectionError + mock_smlight_client.get_info.side_effect = error freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) From ba7f36328dfff5d2ec06988eb38e1ce5172b9ac0 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Thu, 5 Sep 2024 19:35:36 +1000 Subject: [PATCH 0277/1309] Add diagnostics to GeoNet NZ Quakes integration (#125320) * add diagnostics platform * add tests * add snapshot data * remove from no diagnostics list --- .../components/geonetnz_quakes/diagnostics.py | 39 +++++++++++++++++++ script/hassfest/manifest.py | 1 - .../snapshots/test_diagnostics.ambr | 21 ++++++++++ .../geonetnz_quakes/test_diagnostics.py | 33 ++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/geonetnz_quakes/diagnostics.py create mode 100644 tests/components/geonetnz_quakes/snapshots/test_diagnostics.ambr create mode 100644 tests/components/geonetnz_quakes/test_diagnostics.py diff --git a/homeassistant/components/geonetnz_quakes/diagnostics.py b/homeassistant/components/geonetnz_quakes/diagnostics.py new file mode 100644 index 00000000000..fbe9bf511aa --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/diagnostics.py @@ -0,0 +1,39 @@ +"""Diagnostics support for GeoNet NZ Quakes Feeds integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from . import GeonetnzQuakesFeedEntityManager +from .const import DOMAIN, FEED + +TO_REDACT = {CONF_LATITUDE, CONF_LONGITUDE} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data: dict[str, Any] = { + "info": async_redact_data(config_entry.data, TO_REDACT), + } + + manager: GeonetnzQuakesFeedEntityManager = hass.data[DOMAIN][FEED][ + config_entry.entry_id + ] + status_info = manager.status_info() + if status_info: + data["service"] = { + "status": status_info.status, + "total": status_info.total, + "last_update": status_info.last_update, + "last_update_successful": status_info.last_update_successful, + "last_timestamp": status_info.last_timestamp, + } + + return data diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 185b2b178e4..8643e34725f 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -117,7 +117,6 @@ NO_IOT_CLASS = [ # https://github.com/home-assistant/developers.home-assistant/pull/1512 NO_DIAGNOSTICS = [ "dlna_dms", - "geonetnz_quakes", "hyperion", "nightscout", "pvpc_hourly_pricing", diff --git a/tests/components/geonetnz_quakes/snapshots/test_diagnostics.ambr b/tests/components/geonetnz_quakes/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..481a662ccf9 --- /dev/null +++ b/tests/components/geonetnz_quakes/snapshots/test_diagnostics.ambr @@ -0,0 +1,21 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'info': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'minimum_magnitude': 0.0, + 'mmi': 4, + 'radius': 25, + 'scan_interval': 300.0, + 'unit_system': 'metric', + }), + 'service': dict({ + 'last_timestamp': None, + 'last_update': '2024-09-05T15:00:00', + 'last_update_successful': '2024-09-05T15:00:00', + 'status': 'OK', + 'total': 0, + }), + }) +# --- diff --git a/tests/components/geonetnz_quakes/test_diagnostics.py b/tests/components/geonetnz_quakes/test_diagnostics.py new file mode 100644 index 00000000000..db5e1300768 --- /dev/null +++ b/tests/components/geonetnz_quakes/test_diagnostics.py @@ -0,0 +1,33 @@ +"""Test GeoNet NZ Quakes diagnostics.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.freeze_time("2024-09-05 15:00:00") +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, +) -> None: + """Test config entry diagnostics.""" + with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update: + mock_feed_update.return_value = "OK", [] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot From 70966c2b63a34478576d0cef5899d75b39e7b2f0 Mon Sep 17 00:00:00 2001 From: Adam Pasztor Date: Thu, 5 Sep 2024 12:07:19 +0200 Subject: [PATCH 0278/1309] Add new data types to ADS integration (#125201) * feat: Introduce new data types to ADS integration. * refactor: ADS data unpacking based on PLC data type * refactor: handle BOOL and STRING as special cases. --- homeassistant/components/ads/__init__.py | 99 ++++++++++++++++++------ 1 file changed, 74 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index f5742718b12..32d89b5b597 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -29,18 +29,40 @@ DATA_ADS = "data_ads" # Supported Types ADSTYPE_BOOL = "bool" ADSTYPE_BYTE = "byte" -ADSTYPE_DINT = "dint" ADSTYPE_INT = "int" -ADSTYPE_UDINT = "udint" ADSTYPE_UINT = "uint" +ADSTYPE_SINT = "sint" +ADSTYPE_USINT = "usint" +ADSTYPE_DINT = "dint" +ADSTYPE_UDINT = "udint" +ADSTYPE_WORD = "word" +ADSTYPE_DWORD = "dword" +ADSTYPE_LREAL = "lreal" +ADSTYPE_REAL = "real" +ADSTYPE_STRING = "string" +ADSTYPE_TIME = "time" +ADSTYPE_DATE = "date" +ADSTYPE_DATE_AND_TIME = "dt" +ADSTYPE_TOD = "tod" ADS_TYPEMAP = { ADSTYPE_BOOL: pyads.PLCTYPE_BOOL, ADSTYPE_BYTE: pyads.PLCTYPE_BYTE, - ADSTYPE_DINT: pyads.PLCTYPE_DINT, ADSTYPE_INT: pyads.PLCTYPE_INT, - ADSTYPE_UDINT: pyads.PLCTYPE_UDINT, ADSTYPE_UINT: pyads.PLCTYPE_UINT, + ADSTYPE_SINT: pyads.PLCTYPE_SINT, + ADSTYPE_USINT: pyads.PLCTYPE_USINT, + ADSTYPE_DINT: pyads.PLCTYPE_DINT, + ADSTYPE_UDINT: pyads.PLCTYPE_UDINT, + ADSTYPE_WORD: pyads.PLCTYPE_WORD, + ADSTYPE_DWORD: pyads.PLCTYPE_DWORD, + ADSTYPE_REAL: pyads.PLCTYPE_REAL, + ADSTYPE_LREAL: pyads.PLCTYPE_LREAL, + ADSTYPE_STRING: pyads.PLCTYPE_STRING, + ADSTYPE_TIME: pyads.PLCTYPE_TIME, + ADSTYPE_DATE: pyads.PLCTYPE_DATE, + ADSTYPE_DATE_AND_TIME: pyads.PLCTYPE_DT, + ADSTYPE_TOD: pyads.PLCTYPE_TOD, } CONF_ADS_FACTOR = "factor" @@ -75,12 +97,23 @@ SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema( { vol.Required(CONF_ADS_TYPE): vol.In( [ + ADSTYPE_BOOL, + ADSTYPE_BYTE, ADSTYPE_INT, ADSTYPE_UINT, - ADSTYPE_BYTE, - ADSTYPE_BOOL, + ADSTYPE_SINT, + ADSTYPE_USINT, ADSTYPE_DINT, ADSTYPE_UDINT, + ADSTYPE_WORD, + ADSTYPE_DWORD, + ADSTYPE_REAL, + ADSTYPE_LREAL, + ADSTYPE_STRING, + ADSTYPE_TIME, + ADSTYPE_DATE, + ADSTYPE_DATE_AND_TIME, + ADSTYPE_TOD, ] ), vol.Required(CONF_ADS_VALUE): vol.Coerce(int), @@ -222,37 +255,53 @@ class AdsHub: def _device_notification_callback(self, notification, name): """Handle device notifications.""" contents = notification.contents - hnotify = int(contents.hNotification) _LOGGER.debug("Received notification %d", hnotify) - # get dynamically sized data array + # Get dynamically sized data array data_size = contents.cbSampleSize - data = (ctypes.c_ubyte * data_size).from_address( + data_address = ( ctypes.addressof(contents) + pyads.structs.SAdsNotificationHeader.data.offset ) + data = (ctypes.c_ubyte * data_size).from_address(data_address) - try: - with self._lock: - notification_item = self._notification_items[hnotify] - except KeyError: + # Acquire notification item + with self._lock: + notification_item = self._notification_items.get(hnotify) + + if not notification_item: _LOGGER.error("Unknown device notification handle: %d", hnotify) return - # Parse data to desired datatype - if notification_item.plc_datatype == pyads.PLCTYPE_BOOL: + # Data parsing based on PLC data type + plc_datatype = notification_item.plc_datatype + unpack_formats = { + pyads.PLCTYPE_BYTE: " Date: Thu, 5 Sep 2024 13:03:16 +0200 Subject: [PATCH 0279/1309] Split opentherm_gw entity base class (#125330) Add OpenThermStatusEntity to allow entities that don't need status updates --- .../components/opentherm_gw/binary_sensor.py | 4 ++-- homeassistant/components/opentherm_gw/button.py | 14 ++------------ homeassistant/components/opentherm_gw/climate.py | 4 ++-- homeassistant/components/opentherm_gw/entity.py | 14 +++++++++----- homeassistant/components/opentherm_gw/sensor.py | 4 ++-- 5 files changed, 17 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index 00885a18088..5d542bedc07 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -22,7 +22,7 @@ from .const import ( THERMOSTAT_DEVICE_DESCRIPTION, OpenThermDataSource, ) -from .entity import OpenThermEntity, OpenThermEntityDescription +from .entity import OpenThermEntityDescription, OpenThermStatusEntity @dataclass(frozen=True, kw_only=True) @@ -404,7 +404,7 @@ async def async_setup_entry( ) -class OpenThermBinarySensor(OpenThermEntity, BinarySensorEntity): +class OpenThermBinarySensor(OpenThermStatusEntity, BinarySensorEntity): """Represent an OpenTherm Gateway binary sensor.""" _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/opentherm_gw/button.py b/homeassistant/components/opentherm_gw/button.py index aa0a3dbcda5..bac50295199 100644 --- a/homeassistant/components/opentherm_gw/button.py +++ b/homeassistant/components/opentherm_gw/button.py @@ -12,16 +12,11 @@ from homeassistant.components.button import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import OpenThermGatewayHub -from .const import ( - DATA_GATEWAYS, - DATA_OPENTHERM_GW, - GATEWAY_DEVICE_DESCRIPTION, - OpenThermDataSource, -) +from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, GATEWAY_DEVICE_DESCRIPTION from .entity import OpenThermEntity, OpenThermEntityDescription @@ -63,11 +58,6 @@ class OpenThermButton(OpenThermEntity, ButtonEntity): _attr_entity_category = EntityCategory.CONFIG entity_description: OpenThermButtonEntityDescription - @callback - def receive_report(self, status: dict[OpenThermDataSource, dict]) -> None: - """Handle status updates from the component.""" - # We don't need any information from the reports here - async def async_press(self) -> None: """Perform button action.""" await self.entity_description.action(self._gateway) diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index 45f1ca478f5..6edfeb35ec3 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -34,7 +34,7 @@ from .const import ( THERMOSTAT_DEVICE_DESCRIPTION, OpenThermDataSource, ) -from .entity import OpenThermEntity, OpenThermEntityDescription +from .entity import OpenThermEntityDescription, OpenThermStatusEntity _LOGGER = logging.getLogger(__name__) @@ -69,7 +69,7 @@ async def async_setup_entry( async_add_entities(ents) -class OpenThermClimate(OpenThermEntity, ClimateEntity): +class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): """Representation of a climate device.""" _attr_supported_features = ( diff --git a/homeassistant/components/opentherm_gw/entity.py b/homeassistant/components/opentherm_gw/entity.py index b7110fa9e1b..e87a6c182aa 100644 --- a/homeassistant/components/opentherm_gw/entity.py +++ b/homeassistant/components/opentherm_gw/entity.py @@ -52,6 +52,15 @@ class OpenThermEntity(Entity): }, ) + @property + def available(self) -> bool: + """Return connection status of the hub to indicate availability.""" + return self._gateway.connected + + +class OpenThermStatusEntity(OpenThermEntity): + """Represent an OpenTherm entity that receives status updates.""" + async def async_added_to_hass(self) -> None: """Subscribe to updates from the component.""" self.async_on_remove( @@ -60,11 +69,6 @@ class OpenThermEntity(Entity): ) ) - @property - def available(self) -> bool: - """Return connection status of the hub to indicate availability.""" - return self._gateway.connected - @callback def receive_report(self, status: dict[OpenThermDataSource, dict]) -> None: """Handle status updates from the component.""" diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index eeadd5c4ee1..5ccb4166665 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -32,7 +32,7 @@ from .const import ( THERMOSTAT_DEVICE_DESCRIPTION, OpenThermDataSource, ) -from .entity import OpenThermEntity, OpenThermEntityDescription +from .entity import OpenThermEntityDescription, OpenThermStatusEntity SENSOR_FLOAT_SUGGESTED_DISPLAY_PRECISION = 1 @@ -889,7 +889,7 @@ async def async_setup_entry( ) -class OpenThermSensor(OpenThermEntity, SensorEntity): +class OpenThermSensor(OpenThermStatusEntity, SensorEntity): """Representation of an OpenTherm sensor.""" _attr_entity_category = EntityCategory.DIAGNOSTIC From 86ae70780ccbc58e0314e0f9e2ea26eb8a86d1a9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Sep 2024 13:09:27 +0200 Subject: [PATCH 0280/1309] Refactor recorder retryable_database_job decorator (#125306) --- .../components/recorder/migration.py | 3 +- homeassistant/components/recorder/util.py | 72 ++++++++++++------- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 324bdd5ea13..4d9978c641b 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -106,6 +106,7 @@ from .util import ( execute_stmt_lambda_element, get_index_by_name, retryable_database_job, + retryable_database_job_method, session_scope, ) @@ -2229,7 +2230,7 @@ class BaseRunTimeMigration(ABC): else: self.migration_done(instance, session) - @retryable_database_job("migrate data", method=True) + @retryable_database_job_method("migrate data") def migrate_data(self, instance: Recorder) -> bool: """Migrate some data, returns True if migration is completed.""" status = self.migrate_data_impl(instance) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 75e403d8204..d078c32cb88 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -645,44 +645,66 @@ def _is_retryable_error(instance: Recorder, err: OperationalError) -> bool: type _FuncType[**P, R] = Callable[Concatenate[Recorder, P], R] +type _MethType[Self, **P, R] = Callable[Concatenate[Self, Recorder, P], R] type _FuncOrMethType[**_P, _R] = Callable[_P, _R] def retryable_database_job[**_P]( - description: str, method: bool = False -) -> Callable[[_FuncOrMethType[_P, bool]], _FuncOrMethType[_P, bool]]: + description: str, +) -> Callable[[_FuncType[_P, bool]], _FuncType[_P, bool]]: """Try to execute a database job. The job should return True if it finished, and False if it needs to be rescheduled. """ - recorder_pos = 1 if method else 0 - def decorator(job: _FuncOrMethType[_P, bool]) -> _FuncOrMethType[_P, bool]: - @functools.wraps(job) - def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> bool: - instance: Recorder = args[recorder_pos] # type: ignore[assignment] - try: - return job(*args, **kwargs) - except OperationalError as err: - if _is_retryable_error(instance, err): - assert isinstance(err.orig, BaseException) # noqa: PT017 - _LOGGER.info( - "%s; %s not completed, retrying", err.orig.args[1], description - ) - time.sleep(instance.db_retry_wait) - # Failed with retryable error - return False - - _LOGGER.warning("Error executing %s: %s", description, err) - - # Failed with permanent error - return True - - return wrapper + def decorator(job: _FuncType[_P, bool]) -> _FuncType[_P, bool]: + return _wrap_func_or_meth(job, description, False) return decorator +def retryable_database_job_method[_Self, **_P]( + description: str, +) -> Callable[[_MethType[_Self, _P, bool]], _MethType[_Self, _P, bool]]: + """Try to execute a database job. + + The job should return True if it finished, and False if it needs to be rescheduled. + """ + + def decorator(job: _MethType[_Self, _P, bool]) -> _MethType[_Self, _P, bool]: + return _wrap_func_or_meth(job, description, True) + + return decorator + + +def _wrap_func_or_meth[**_P]( + job: _FuncOrMethType[_P, bool], description: str, method: bool +) -> _FuncOrMethType[_P, bool]: + recorder_pos = 1 if method else 0 + + @functools.wraps(job) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> bool: + instance: Recorder = args[recorder_pos] # type: ignore[assignment] + try: + return job(*args, **kwargs) + except OperationalError as err: + if _is_retryable_error(instance, err): + assert isinstance(err.orig, BaseException) # noqa: PT017 + _LOGGER.info( + "%s; %s not completed, retrying", err.orig.args[1], description + ) + time.sleep(instance.db_retry_wait) + # Failed with retryable error + return False + + _LOGGER.warning("Error executing %s: %s", description, err) + + # Failed with permanent error + return True + + return wrapper + + def database_job_retry_wrapper[**_P]( description: str, attempts: int = 5 ) -> Callable[[_FuncType[_P, None]], _FuncType[_P, None]]: From 38f3fa021038b2e96028bc5a452c16c14975e62e Mon Sep 17 00:00:00 2001 From: "Phill (pssc)" Date: Thu, 5 Sep 2024 15:49:07 +0100 Subject: [PATCH 0281/1309] Add Squeezebox server service binary sensors (#122473) * squeezebox add binary sensor + coordinator * squeezebox add connected via for media_player * squeezebox add Player type for player * Add more type info * Fix linter errors * squeezebox use our own status entity * squeezebox rework device handling based on freedback * Fix device creation * squeezebox rework coordinator error handling * Fix lint type error * Correct spelling * Correct spelling * remove large comments * insert small comment * add translation support * Simply sensor * clean update function, minimise comments to the useful bits * Fix after testing * Update homeassistant/components/squeezebox/entity.py Co-authored-by: Joost Lekkerkerker * move data prep out of Device assign for clarity * stop being a generic api * Humans need to read the sensors... * ruff format * Humans need to read the sensors... * Revert "ruff format" This reverts commit 8fcb8143e7c4427e75d31f9dd57f6c2027f8df6a. * ruff format * Humans need to read the sensors... * errors after testing * infered * drop context * cutdown coordinator for the binary sensors * add tests for binary sensors * Fix import * add some basic media_player tests * Fix spelling and file headers * Fix spelling * remove uuid and use service device cat * use diag device * assert execpted value * ruff format * Update homeassistant/components/squeezebox/__init__.py Co-authored-by: Joost Lekkerkerker * Simplify T/F * Fix file header * remove redudant check * remove player tests from this commit * Fix formatting * remove unused * Fix function Type * Fix Any to bool * Fix browser tests * Patch our squeebox componemt not the server in the lib * ruff --------- Co-authored-by: Joost Lekkerkerker --- .../components/squeezebox/__init__.py | 58 ++++++++++++++-- .../components/squeezebox/binary_sensor.py | 54 +++++++++++++++ homeassistant/components/squeezebox/const.py | 17 +++++ .../components/squeezebox/coordinator.py | 59 ++++++++++++++++ homeassistant/components/squeezebox/entity.py | 31 +++++++++ .../components/squeezebox/media_player.py | 7 +- .../components/squeezebox/strings.json | 10 +++ tests/components/squeezebox/__init__.py | 68 +++++++++++++++++++ tests/components/squeezebox/conftest.py | 1 + .../squeezebox/test_binary_sensor.py | 33 +++++++++ .../squeezebox/test_media_browser.py | 6 +- 11 files changed, 334 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/squeezebox/binary_sensor.py create mode 100644 homeassistant/components/squeezebox/coordinator.py create mode 100644 homeassistant/components/squeezebox/entity.py create mode 100644 tests/components/squeezebox/test_binary_sensor.py diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index b6c7f049311..be8c92b18df 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -1,6 +1,7 @@ """The Squeezebox integration.""" from asyncio import timeout +from dataclasses import dataclass import logging from pysqueezebox import Server @@ -15,23 +16,42 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceEntryType, + format_mac, +) from .const import ( CONF_HTTPS, DISCOVERY_TASK, DOMAIN, + MANUFACTURER, + SERVER_MODEL, STATUS_API_TIMEOUT, STATUS_QUERY_LIBRARYNAME, + STATUS_QUERY_MAC, STATUS_QUERY_UUID, + STATUS_QUERY_VERSION, ) +from .coordinator import LMSStatusDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER] -type SqueezeboxConfigEntry = ConfigEntry[Server] +@dataclass +class SqueezeboxData: + """SqueezeboxData data class.""" + + coordinator: LMSStatusDataUpdateCoordinator + server: Server + + +type SqueezeboxConfigEntry = ConfigEntry[SqueezeboxData] async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -> bool: @@ -66,25 +86,51 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - _LOGGER.debug("LMS Status for setup = %s", status) lms.uuid = status[STATUS_QUERY_UUID] + _LOGGER.debug("LMS %s = '%s' with uuid = %s ", lms.name, host, lms.uuid) lms.name = ( (STATUS_QUERY_LIBRARYNAME in status and status[STATUS_QUERY_LIBRARYNAME]) and status[STATUS_QUERY_LIBRARYNAME] or host ) - _LOGGER.debug("LMS %s = '%s' with uuid = %s ", lms.name, host, lms.uuid) + version = STATUS_QUERY_VERSION in status and status[STATUS_QUERY_VERSION] or None + # mac can be missing + mac_connect = ( + {(CONNECTION_NETWORK_MAC, format_mac(status[STATUS_QUERY_MAC]))} + if STATUS_QUERY_MAC in status + else None + ) - entry.runtime_data = lms + device_registry = dr.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, lms.uuid)}, + name=lms.name, + manufacturer=MANUFACTURER, + model=SERVER_MODEL, + sw_version=version, + entry_type=DeviceEntryType.SERVICE, + connections=mac_connect, + ) + _LOGGER.debug("LMS Device %s", device) + coordinator = LMSStatusDataUpdateCoordinator(hass, lms) + + entry.runtime_data = SqueezeboxData( + coordinator=coordinator, + server=lms, + ) + + await coordinator.async_config_entry_first_refresh() await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -> bool: """Unload a config entry.""" # Stop player discovery task for this config entry. _LOGGER.debug( "Reached async_unload_entry for LMS=%s(%s)", - entry.runtime_data.name or "Unknown", + entry.runtime_data.server.name or "Unknown", entry.entry_id, ) diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py new file mode 100644 index 00000000000..ec0bac0fe43 --- /dev/null +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -0,0 +1,54 @@ +"""Binary sensor platform for Squeezebox integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SqueezeboxConfigEntry +from .const import STATUS_SENSOR_NEEDSRESTART, STATUS_SENSOR_RESCAN +from .entity import LMSStatusEntity + +SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key=STATUS_SENSOR_RESCAN, + device_class=BinarySensorDeviceClass.RUNNING, + ), + BinarySensorEntityDescription( + key=STATUS_SENSOR_NEEDSRESTART, + device_class=BinarySensorDeviceClass.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SqueezeboxConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Platform setup using common elements.""" + + async_add_entities( + ServerStatusBinarySensor(entry.runtime_data.coordinator, description) + for description in SENSORS + ) + + +class ServerStatusBinarySensor(LMSStatusEntity, BinarySensorEntity): + """LMS Status based sensor from LMS via cooridnatior.""" + + @property + def is_on(self) -> bool: + """LMS Status directly from coordinator data.""" + return bool(self.coordinator.data[self.entity_description.key]) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index a814cf6ecc4..a4824f2091f 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -5,8 +5,25 @@ DISCOVERY_TASK = "discovery_task" DOMAIN = "squeezebox" DEFAULT_PORT = 9000 KNOWN_PLAYERS = "known_players" +MANUFACTURER = "https://lyrion.org/" +PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" SENSOR_UPDATE_INTERVAL = 60 +SERVER_MODEL = "Lyron Music Server" STATUS_API_TIMEOUT = 10 +STATUS_SENSOR_LASTSCAN = "lastscan" +STATUS_SENSOR_NEEDSRESTART = "needsrestart" +STATUS_SENSOR_NEWVERSION = "newversion" +STATUS_SENSOR_NEWPLUGINS = "newplugins" +STATUS_SENSOR_RESCAN = "rescan" +STATUS_SENSOR_INFO_TOTAL_ALBUMS = "info total albums" +STATUS_SENSOR_INFO_TOTAL_ARTISTS = "info total artists" +STATUS_SENSOR_INFO_TOTAL_DURATION = "info total duration" +STATUS_SENSOR_INFO_TOTAL_GENRES = "info total genres" +STATUS_SENSOR_INFO_TOTAL_SONGS = "info total songs" +STATUS_SENSOR_PLAYER_COUNT = "player count" +STATUS_SENSOR_OTHER_PLAYER_COUNT = "other player count" STATUS_QUERY_LIBRARYNAME = "libraryname" +STATUS_QUERY_MAC = "mac" STATUS_QUERY_UUID = "uuid" +STATUS_QUERY_VERSION = "version" SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:") diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py new file mode 100644 index 00000000000..71c55452004 --- /dev/null +++ b/homeassistant/components/squeezebox/coordinator.py @@ -0,0 +1,59 @@ +"""DataUpdateCoordinator for the Squeezebox integration.""" + +from asyncio import timeout +from datetime import timedelta +import logging + +from pysqueezebox import Server + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + SENSOR_UPDATE_INTERVAL, + STATUS_API_TIMEOUT, + STATUS_SENSOR_NEEDSRESTART, + STATUS_SENSOR_RESCAN, +) + +_LOGGER = logging.getLogger(__name__) + + +class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): + """LMS Status custom coordinator.""" + + def __init__(self, hass: HomeAssistant, lms: Server) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + name=lms.name, + update_interval=timedelta(seconds=SENSOR_UPDATE_INTERVAL), + always_update=False, + ) + self.lms = lms + + async def _async_update_data(self) -> dict: + """Fetch data fromn LMS status call. + + Then we process only a subset to make then nice for HA + """ + async with timeout(STATUS_API_TIMEOUT): + data = await self.lms.async_status() + + if not data: + raise UpdateFailed("No data from status poll") + _LOGGER.debug("Raw serverstatus %s=%s", self.lms.name, data) + + return self._prepare_status_data(data) + + def _prepare_status_data(self, data: dict) -> dict: + """Sensors that need the data changing for HA presentation.""" + + # rescan bool are we rescanning alter poll not present if false + data[STATUS_SENSOR_RESCAN] = STATUS_SENSOR_RESCAN in data + # needsrestart bool pending lms plugin updates not present if false + data[STATUS_SENSOR_NEEDSRESTART] = STATUS_SENSOR_NEEDSRESTART in data + + _LOGGER.debug("Processed serverstatus %s=%s", self.lms.name, data) + return data diff --git a/homeassistant/components/squeezebox/entity.py b/homeassistant/components/squeezebox/entity.py new file mode 100644 index 00000000000..8ac80265369 --- /dev/null +++ b/homeassistant/components/squeezebox/entity.py @@ -0,0 +1,31 @@ +"""Base class for Squeezebox Sensor entities.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, STATUS_QUERY_UUID +from .coordinator import LMSStatusDataUpdateCoordinator + + +class LMSStatusEntity(CoordinatorEntity[LMSStatusDataUpdateCoordinator]): + """Defines a base status sensor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LMSStatusDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize status sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_translation_key = description.key + self._attr_unique_id = ( + f"{coordinator.data[STATUS_QUERY_UUID]}_{description.key}" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data[STATUS_QUERY_UUID])}, + ) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 0294c17f50a..f7f8df55e2c 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -113,7 +113,7 @@ async def async_setup_entry( """Set up an player discovery from a config entry.""" hass.data.setdefault(DOMAIN, {}) known_players = hass.data[DOMAIN].setdefault(KNOWN_PLAYERS, []) - lms = entry.runtime_data + lms = entry.runtime_data.server async def _player_discovery(now: datetime | None = None) -> None: """Discover squeezebox players by polling server.""" @@ -136,7 +136,7 @@ async def async_setup_entry( if not entity: _LOGGER.debug("Adding new entity: %s", player) - entity = SqueezeBoxEntity(player) + entity = SqueezeBoxEntity(player, lms) known_players.append(entity) async_add_entities([entity]) @@ -212,7 +212,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): _last_update: datetime | None = None _attr_available = True - def __init__(self, player: Player) -> None: + def __init__(self, player: Player, server: Server) -> None: """Initialize the SqueezeBox device.""" self._player = player self._query_result: bool | dict = {} @@ -222,6 +222,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): identifiers={(DOMAIN, self._attr_unique_id)}, name=player.name, connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)}, + via_device=(DOMAIN, server.uuid), ) @property diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 899d35813aa..89302951146 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -75,5 +75,15 @@ "name": "Unsync", "description": "Removes this player from its sync group." } + }, + "entity": { + "binary_sensor": { + "rescan": { + "name": "Library rescan" + }, + "needsrestart": { + "name": "Needs restart" + } + } } } diff --git a/tests/components/squeezebox/__init__.py b/tests/components/squeezebox/__init__.py index 34c0363292d..d5faabba32e 100644 --- a/tests/components/squeezebox/__init__.py +++ b/tests/components/squeezebox/__init__.py @@ -1 +1,69 @@ """Tests for the Logitech Squeezebox integration.""" + +from homeassistant.components.squeezebox.const import ( + DOMAIN, + STATUS_QUERY_LIBRARYNAME, + STATUS_QUERY_MAC, + STATUS_QUERY_UUID, + STATUS_QUERY_VERSION, + STATUS_SENSOR_RESCAN, +) +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +# from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + +FAKE_IP = "42.42.42.42" +FAKE_MAC = "deadbeefdead" +FAKE_UUID = "deadbeefdeadbeefbeefdeafbeef42" +FAKE_PORT = 9000 +FAKE_VERSION = "42.0" + +FAKE_QUERY_RESPONSE = { + STATUS_QUERY_UUID: FAKE_UUID, + STATUS_QUERY_MAC: FAKE_MAC, + STATUS_QUERY_VERSION: FAKE_VERSION, + STATUS_SENSOR_RESCAN: 1, + STATUS_QUERY_LIBRARYNAME: "FakeLib", + "players_loop": [ + { + "isplaying": 0, + "name": "SqueezeLite-HA-Addon", + "seq_no": 0, + "modelname": "SqueezeLite-HA-Addon", + "playerindex": "status", + "model": "squeezelite", + "uuid": FAKE_UUID, + "canpoweroff": 1, + "ip": "192.168.78.86:57700", + "displaytype": "none", + "playerid": "f9:23:cd:37:c5:ff", + "power": 0, + "isplayer": 1, + "connected": 1, + "firmware": "v2.0.0-1488", + } + ], + "count": 1, +} + + +async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: + """Mock ConfigEntry in Home Assistant.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FAKE_UUID, + data={ + CONF_HOST: FAKE_IP, + CONF_PORT: FAKE_PORT, + }, + ) + + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 4a4bdc6ae73..26cb0726aca 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -130,4 +130,5 @@ def lms() -> MagicMock: ) lms.async_get_players = AsyncMock(return_value=[player]) lms.async_query = AsyncMock(return_value={"uuid": format_mac(TEST_MAC)}) + lms.async_status = AsyncMock(return_value={"uuid": format_mac(TEST_MAC)}) return lms diff --git a/tests/components/squeezebox/test_binary_sensor.py b/tests/components/squeezebox/test_binary_sensor.py new file mode 100644 index 00000000000..a2de0cbf95e --- /dev/null +++ b/tests/components/squeezebox/test_binary_sensor.py @@ -0,0 +1,33 @@ +"""Test squeezebox binary sensors.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import FAKE_QUERY_RESPONSE, setup_mocked_integration + + +async def test_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test binary sensor states and attributes.""" + + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.BINARY_SENSOR], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=FAKE_QUERY_RESPONSE, + ), + ): + await setup_mocked_integration(hass) + state = hass.states.get("binary_sensor.fakelib_library_rescan") + + assert state is not None + assert state.state == "on" diff --git a/tests/components/squeezebox/test_media_browser.py b/tests/components/squeezebox/test_media_browser.py index 62d668ca57b..c3398d24aa3 100644 --- a/tests/components/squeezebox/test_media_browser.py +++ b/tests/components/squeezebox/test_media_browser.py @@ -16,7 +16,7 @@ from homeassistant.components.squeezebox.browse_media import ( LIBRARY, MEDIA_TYPE_TO_SQUEEZEBOX, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -30,6 +30,10 @@ async def setup_integration( """Fixture for setting up the component.""" with ( patch("homeassistant.components.squeezebox.Server", return_value=lms), + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.MEDIA_PLAYER], + ), patch( "homeassistant.components.squeezebox.media_player.start_server_discovery" ), From b0bfe71b9bf8ba534ad8d954f1321a2efee4a65d Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Thu, 5 Sep 2024 17:44:19 +0100 Subject: [PATCH 0282/1309] Fix typo in squeezebox (#125352) Spelling Correction on SERVER_MODEL --- homeassistant/components/squeezebox/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index a4824f2091f..0bf8c24a5d1 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -8,7 +8,7 @@ KNOWN_PLAYERS = "known_players" MANUFACTURER = "https://lyrion.org/" PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" SENSOR_UPDATE_INTERVAL = 60 -SERVER_MODEL = "Lyron Music Server" +SERVER_MODEL = "Lyrion Music Server" STATUS_API_TIMEOUT = 10 STATUS_SENSOR_LASTSCAN = "lastscan" STATUS_SENSOR_NEEDSRESTART = "needsrestart" From d2d01b337da3cde1935c15ebd9f457e1c465c2bf Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Thu, 5 Sep 2024 20:16:11 +0200 Subject: [PATCH 0283/1309] Bump plugwise to v1.0.0 (#125354) --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 10faf75d0f1..6ac5254b424 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==0.38.3"], + "requirements": ["plugwise==1.0.0"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e95011f247b..311b6aeb8ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1597,7 +1597,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.38.3 +plugwise==1.0.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1657969b7e5..598324ea428 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1304,7 +1304,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.38.3 +plugwise==1.0.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From d686b877b14134caa8f234551b3746a83fd47e47 Mon Sep 17 00:00:00 2001 From: Robert Contreras Date: Thu, 5 Sep 2024 11:52:12 -0700 Subject: [PATCH 0284/1309] Home Connect add FridgeFreezer switch entities (#122881) * Home Connect add FridgeFreezer switch entities * Fix unrelated test * Implemented requested changes from review * Move exist_fn check code to setup * Assign entity_description during init * Resolve issue with functional testing --- .../components/home_connect/const.py | 7 ++ .../components/home_connect/icons.json | 10 ++ .../components/home_connect/switch.py | 106 +++++++++++++++- .../home_connect/fixtures/settings.json | 30 +++++ tests/components/home_connect/test_init.py | 5 +- tests/components/home_connect/test_switch.py | 118 +++++++++++++++++- 6 files changed, 269 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index b54637bb524..4c21201c37a 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -23,6 +23,13 @@ BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" + +REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer" +REFRIGERATION_SUPERMODEREFRIGERATOR = ( + "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator" +) +REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled" + BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled" BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness" BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor" diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 33617f5472e..163c03b297c 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -21,5 +21,15 @@ "change_setting": { "service": "mdi:cog" } + }, + "entity": { + "switch": { + "refrigeration_dispenser": { + "default": "mdi:snowflake", + "state": { + "off": "mdi:snowflake-off" + } + } + } } } diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 8c7ef2eb11a..80e8e4b2d39 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -1,16 +1,18 @@ """Provides a switch for Home Connect.""" +from dataclasses import dataclass import logging from typing import Any from homeconnect.api import HomeConnectError -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .api import ConfigEntryAuth from .const import ( ATTR_VALUE, BSH_ACTIVE_PROGRAM, @@ -19,12 +21,39 @@ from .const import ( BSH_POWER_ON, BSH_POWER_STATE, DOMAIN, + REFRIGERATION_DISPENSER, + REFRIGERATION_SUPERMODEFREEZER, + REFRIGERATION_SUPERMODEREFRIGERATOR, ) -from .entity import HomeConnectEntity +from .entity import HomeConnectDevice, HomeConnectEntity _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class HomeConnectSwitchEntityDescription(SwitchEntityDescription): + """Switch entity description.""" + + on_key: str + + +SWITCHES: tuple[HomeConnectSwitchEntityDescription, ...] = ( + HomeConnectSwitchEntityDescription( + key="Supermode Freezer", + on_key=REFRIGERATION_SUPERMODEFREEZER, + ), + HomeConnectSwitchEntityDescription( + key="Supermode Refrigerator", + on_key=REFRIGERATION_SUPERMODEREFRIGERATOR, + ), + HomeConnectSwitchEntityDescription( + key="Dispenser Enabled", + on_key=REFRIGERATION_DISPENSER, + translation_key="refrigeration_dispenser", + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -35,18 +64,87 @@ async def async_setup_entry( def get_entities(): """Get a list of entities.""" entities = [] - hc_api = hass.data[DOMAIN][config_entry.entry_id] + hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("switch", []) entity_list = [HomeConnectProgramSwitch(**d) for d in entity_dicts] entity_list += [HomeConnectPowerSwitch(device_dict[CONF_DEVICE])] entity_list += [HomeConnectChildLockSwitch(device_dict[CONF_DEVICE])] - entities += entity_list + # Auto-discover entities + hc_device: HomeConnectDevice = device_dict[CONF_DEVICE] + entities.extend( + HomeConnectSwitch(device=hc_device, entity_description=description) + for description in SWITCHES + if description.on_key in hc_device.appliance.status + ) + entities.extend(entity_list) + return entities async_add_entities(await hass.async_add_executor_job(get_entities), True) +class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): + """Generic switch class for Home Connect Binary Settings.""" + + entity_description: HomeConnectSwitchEntityDescription + _attr_available: bool = False + + def __init__( + self, + device: HomeConnectDevice, + entity_description: HomeConnectSwitchEntityDescription, + ) -> None: + """Initialize the entity.""" + self.entity_description = entity_description + super().__init__(device=device, desc=entity_description.key) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on setting.""" + + _LOGGER.debug("Turning on %s", self.entity_description.key) + try: + await self.hass.async_add_executor_job( + self.device.appliance.set_setting, self.entity_description.on_key, True + ) + except HomeConnectError as err: + _LOGGER.error("Error while trying to turn on: %s", err) + self._attr_available = False + return + + self._attr_available = True + self.async_entity_update() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off setting.""" + + _LOGGER.debug("Turning off %s", self.entity_description.key) + try: + await self.hass.async_add_executor_job( + self.device.appliance.set_setting, self.entity_description.on_key, False + ) + except HomeConnectError as err: + _LOGGER.error("Error while trying to turn off: %s", err) + self._attr_available = False + return + + self._attr_available = True + self.async_entity_update() + + async def async_update(self) -> None: + """Update the switch's status.""" + + self._attr_is_on = self.device.appliance.status.get( + self.entity_description.on_key, {} + ).get(ATTR_VALUE) + self._attr_available = True + _LOGGER.debug( + "Updated %s, new state: %s", + self.entity_description.key, + self._attr_is_on, + ) + + class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): """Switch class for Home Connect.""" diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index eb6a5f5ff98..29d431419c6 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -111,5 +111,35 @@ } ] } + }, + "FridgeFreezer": { + "data": { + "settings": [ + { + "key": "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer", + "value": false, + "type": "Boolean", + "constraints": { + "access": "readWrite" + } + }, + { + "key": "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator", + "value": false, + "type": "Boolean", + "constraints": { + "access": "readWrite" + } + }, + { + "key": "Refrigeration.Common.Setting.Dispenser.Enabled", + "value": false, + "type": "Boolean", + "constraints": { + "access": "readWrite" + } + } + ] + } } } diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 02d9bcaa208..adfb4ff7a1d 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -9,6 +9,7 @@ import pytest from requests import HTTPError import requests_mock +from homeassistant.components.home_connect import SCAN_INTERVAL from homeassistant.components.home_connect.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -155,14 +156,14 @@ async def test_update_throttle( # First re-load after 1 minute is not blocked. assert await hass.config_entries.async_unload(config_entry.entry_id) assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(60) + freezer.tick(SCAN_INTERVAL.seconds + 0.1) assert await hass.config_entries.async_setup(config_entry.entry_id) assert get_appliances.call_count == get_appliances_call_count + 1 # Second re-load is blocked by Throttle. assert await hass.config_entries.async_unload(config_entry.entry_id) assert config_entry.state == ConfigEntryState.NOT_LOADED - freezer.tick(59) + freezer.tick(SCAN_INTERVAL.seconds - 0.1) assert await hass.config_entries.async_setup(config_entry.entry_id) assert get_appliances.call_count == get_appliances_call_count + 1 diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index c6a7b384036..3ab550ad0af 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable, Generator from unittest.mock import MagicMock, Mock -from homeconnect.api import HomeConnectError +from homeconnect.api import HomeConnectAppliance, HomeConnectError import pytest from homeassistant.components.home_connect.const import ( @@ -13,10 +13,12 @@ from homeassistant.components.home_connect.const import ( BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STATE, + REFRIGERATION_SUPERMODEFREEZER, ) from homeassistant.components.switch import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -214,3 +216,117 @@ async def test_switch_exception_handling( DOMAIN, service, {"entity_id": entity_id}, blocking=True ) assert getattr(problematic_appliance, mock_attr).call_count == 2 + + +@pytest.mark.parametrize( + ("entity_id", "status", "service", "state", "appliance"), + [ + ( + "switch.fridgefreezer_supermode_freezer", + {REFRIGERATION_SUPERMODEFREEZER: {"value": True}}, + SERVICE_TURN_ON, + STATE_ON, + "FridgeFreezer", + ), + ( + "switch.fridgefreezer_supermode_freezer", + {REFRIGERATION_SUPERMODEFREEZER: {"value": False}}, + SERVICE_TURN_OFF, + STATE_OFF, + "FridgeFreezer", + ), + ], + indirect=["appliance"], +) +async def test_ent_desc_switch_functionality( + entity_id: str, + status: dict, + service: str, + state: str, + bypass_throttle: Generator[None], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + appliance: Mock, + get_appliances: MagicMock, +) -> None: + """Test switch functionality - entity description setup.""" + appliance.status.update( + HomeConnectAppliance.json2dict( + load_json_object_fixture("home_connect/settings.json") + .get(appliance.name) + .get("data") + .get("settings") + ) + ) + get_appliances.return_value = [appliance] + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + appliance.status.update(status) + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert hass.states.is_state(entity_id, state) + + +@pytest.mark.parametrize( + ("entity_id", "status", "service", "mock_attr", "problematic_appliance"), + [ + ( + "switch.fridgefreezer_supermode_freezer", + {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + SERVICE_TURN_ON, + "set_setting", + "FridgeFreezer", + ), + ( + "switch.fridgefreezer_supermode_freezer", + {REFRIGERATION_SUPERMODEFREEZER: {"value": ""}}, + SERVICE_TURN_OFF, + "set_setting", + "FridgeFreezer", + ), + ], + indirect=["problematic_appliance"], +) +async def test_ent_desc_switch_exception_handling( + entity_id: str, + status: dict, + service: str, + mock_attr: str, + bypass_throttle: Generator[None], + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, + problematic_appliance: Mock, + get_appliances: MagicMock, +) -> None: + """Test switch exception handling - entity description setup.""" + problematic_appliance.status.update( + HomeConnectAppliance.json2dict( + load_json_object_fixture("home_connect/settings.json") + .get(problematic_appliance.name) + .get("data") + .get("settings") + ) + ) + get_appliances.return_value = [problematic_appliance] + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + # Assert that an exception is called. + with pytest.raises(HomeConnectError): + getattr(problematic_appliance, mock_attr)() + + problematic_appliance.status.update(status) + await hass.services.async_call( + DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + assert getattr(problematic_appliance, mock_attr).call_count == 2 From 48c9361c01f773e975f42408978e3f1546483b88 Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Thu, 5 Sep 2024 22:34:11 +0300 Subject: [PATCH 0285/1309] Bump aioswitcher to 4.0.3 (#125355) --- homeassistant/components/switcher_kis/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 75ace60e942..f9956621ca6 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "platinum", - "requirements": ["aioswitcher==4.0.2"], + "requirements": ["aioswitcher==4.0.3"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 311b6aeb8ae..77f7e50674a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -374,7 +374,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.switcher_kis -aioswitcher==4.0.2 +aioswitcher==4.0.3 # homeassistant.components.syncthing aiosyncthing==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 598324ea428..e67ff882b1a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -356,7 +356,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.switcher_kis -aioswitcher==4.0.2 +aioswitcher==4.0.3 # homeassistant.components.syncthing aiosyncthing==0.5.1 From bbeecb40aeb6d4dfeed96f3a12ab027731921ac0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 5 Sep 2024 21:35:24 +0200 Subject: [PATCH 0286/1309] Remove deprecated aux_heat from zha (#125247) Remove aux_heat from zha --- homeassistant/components/zha/climate.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index f4fb58c254a..fcf5afb5ac5 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -120,8 +120,6 @@ class Thermostat(ZHAEntity, ClimateEntity): features |= ClimateEntityFeature.FAN_MODE if ZHAClimateEntityFeature.SWING_MODE in zha_features: features |= ClimateEntityFeature.SWING_MODE - if ZHAClimateEntityFeature.AUX_HEAT in zha_features: - features |= ClimateEntityFeature.AUX_HEAT if ZHAClimateEntityFeature.TURN_OFF in zha_features: features |= ClimateEntityFeature.TURN_OFF if ZHAClimateEntityFeature.TURN_ON in zha_features: From 9e312f2063bde5a24e1e349f3c9466a4d419a534 Mon Sep 17 00:00:00 2001 From: Mark Ruys Date: Thu, 5 Sep 2024 21:37:44 +0200 Subject: [PATCH 0287/1309] Add Sensoterra integration (#119642) * Initial version * Baseline release * Refactor based on first PR feedback * Refactoring based on second PR feedback * Initial version * Baseline release * Refactor based on first PR feedback * Refactoring based on second PR feedback * Refactoring based on PR feedback * Refactoring based on PR feedback * Remove extra attribute soil type Soil type isn't really a sensor, but more like a configuration entity. Move soil type to a different PR to keep this PR simpler. * Refactor SensoterraSensor to a named tuple * Implement feedback on PR * Remove .coveragerc * Add async_set_unique_id to config flow * Small fix based on feedback * Add test form unique_id * Fix * Fix --------- Co-authored-by: Joostlek --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/sensoterra/__init__.py | 38 ++++ .../components/sensoterra/config_flow.py | 90 +++++++++ homeassistant/components/sensoterra/const.py | 10 + .../components/sensoterra/coordinator.py | 54 ++++++ .../components/sensoterra/manifest.json | 10 + homeassistant/components/sensoterra/sensor.py | 172 ++++++++++++++++++ .../components/sensoterra/strings.json | 38 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/sensoterra/__init__.py | 1 + tests/components/sensoterra/conftest.py | 32 ++++ tests/components/sensoterra/const.py | 7 + .../components/sensoterra/test_config_flow.py | 123 +++++++++++++ 18 files changed, 601 insertions(+) create mode 100644 homeassistant/components/sensoterra/__init__.py create mode 100644 homeassistant/components/sensoterra/config_flow.py create mode 100644 homeassistant/components/sensoterra/const.py create mode 100644 homeassistant/components/sensoterra/coordinator.py create mode 100644 homeassistant/components/sensoterra/manifest.json create mode 100644 homeassistant/components/sensoterra/sensor.py create mode 100644 homeassistant/components/sensoterra/strings.json create mode 100644 tests/components/sensoterra/__init__.py create mode 100644 tests/components/sensoterra/conftest.py create mode 100644 tests/components/sensoterra/const.py create mode 100644 tests/components/sensoterra/test_config_flow.py diff --git a/.strict-typing b/.strict-typing index e93f1589cc8..1a5133efe89 100644 --- a/.strict-typing +++ b/.strict-typing @@ -401,6 +401,7 @@ homeassistant.components.select.* homeassistant.components.sensibo.* homeassistant.components.sensirion_ble.* homeassistant.components.sensor.* +homeassistant.components.sensoterra.* homeassistant.components.senz.* homeassistant.components.sfr_box.* homeassistant.components.shelly.* diff --git a/CODEOWNERS b/CODEOWNERS index 42d96ceb941..edd10858e8d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1288,6 +1288,8 @@ build.json @home-assistant/supervisor /tests/components/sensorpro/ @bdraco /homeassistant/components/sensorpush/ @bdraco /tests/components/sensorpush/ @bdraco +/homeassistant/components/sensoterra/ @markruys +/tests/components/sensoterra/ @markruys /homeassistant/components/sentry/ @dcramer @frenck /tests/components/sentry/ @dcramer @frenck /homeassistant/components/senz/ @milanmeu diff --git a/homeassistant/components/sensoterra/__init__.py b/homeassistant/components/sensoterra/__init__.py new file mode 100644 index 00000000000..b1428351f09 --- /dev/null +++ b/homeassistant/components/sensoterra/__init__.py @@ -0,0 +1,38 @@ +"""The Sensoterra integration.""" + +from __future__ import annotations + +from sensoterra.customerapi import CustomerApi + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SensoterraCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type SensoterraConfigEntry = ConfigEntry[SensoterraCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: SensoterraConfigEntry) -> bool: + """Set up Sensoterra platform based on a configuration entry.""" + + # Create a coordinator and add an API instance to it. Store the coordinator + # in the configuration entry. + api = CustomerApi() + api.set_language(hass.config.language) + api.set_token(entry.data[CONF_TOKEN]) + + coordinator = SensoterraCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SensoterraConfigEntry) -> bool: + """Unload the configuration entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/sensoterra/config_flow.py b/homeassistant/components/sensoterra/config_flow.py new file mode 100644 index 00000000000..c98710dfa7d --- /dev/null +++ b/homeassistant/components/sensoterra/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for Sensoterra integration.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +from jwt import DecodeError, decode +from sensoterra.customerapi import ( + CustomerApi, + InvalidAuth as StInvalidAuth, + Timeout as StTimeout, +) +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN, LOGGER, TOKEN_EXPIRATION_DAYS + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL, autocomplete="email") + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +class SensoterraConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sensoterra.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Create hub entry based on config flow.""" + errors: dict[str, str] = {} + + if user_input is not None: + api = CustomerApi(user_input[CONF_EMAIL], user_input[CONF_PASSWORD]) + # We need a unique tag per HA instance + uuid = self.hass.data["core.uuid"] + expiration = datetime.now() + timedelta(TOKEN_EXPIRATION_DAYS) + + try: + token: str = await api.get_token( + f"Home Assistant {uuid}", "READONLY", expiration + ) + decoded_token = decode( + token, algorithms=["HS256"], options={"verify_signature": False} + ) + + except StInvalidAuth as exp: + LOGGER.error( + "Login attempt with %s: %s", user_input[CONF_EMAIL], exp.message + ) + errors["base"] = "invalid_auth" + except StTimeout: + LOGGER.error("Login attempt with %s: time out", user_input[CONF_EMAIL]) + errors["base"] = "cannot_connect" + except DecodeError: + LOGGER.error("Login attempt with %s: bad token", user_input[CONF_EMAIL]) + errors["base"] = "invalid_access_token" + else: + device_unique_id = decoded_token["sub"] + await self.async_set_unique_id(device_unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data={ + CONF_TOKEN: token, + CONF_EMAIL: user_input[CONF_EMAIL], + }, + ) + + return self.async_show_form( + step_id=SOURCE_USER, + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/sensoterra/const.py b/homeassistant/components/sensoterra/const.py new file mode 100644 index 00000000000..7c4ccf2944c --- /dev/null +++ b/homeassistant/components/sensoterra/const.py @@ -0,0 +1,10 @@ +"""Constants for the Sensoterra integration.""" + +import logging + +DOMAIN = "sensoterra" +SCAN_INTERVAL_MINUTES = 15 +SENSOR_EXPIRATION_DAYS = 2 +TOKEN_EXPIRATION_DAYS = 10 * 365 +CONFIGURATION_URL = "https://monitor.sensoterra.com" +LOGGER: logging.Logger = logging.getLogger(__package__) diff --git a/homeassistant/components/sensoterra/coordinator.py b/homeassistant/components/sensoterra/coordinator.py new file mode 100644 index 00000000000..2dffdceb443 --- /dev/null +++ b/homeassistant/components/sensoterra/coordinator.py @@ -0,0 +1,54 @@ +"""Polling coordinator for the Sensoterra integration.""" + +from collections.abc import Callable +from datetime import timedelta + +from sensoterra.customerapi import ( + CustomerApi, + InvalidAuth as ApiAuthError, + Timeout as ApiTimeout, +) +from sensoterra.probe import Probe, Sensor + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER, SCAN_INTERVAL_MINUTES + + +class SensoterraCoordinator(DataUpdateCoordinator[list[Probe]]): + """Sensoterra coordinator.""" + + def __init__(self, hass: HomeAssistant, api: CustomerApi) -> None: + """Initialize Sensoterra coordinator.""" + super().__init__( + hass, + LOGGER, + name="Sensoterra probe", + update_interval=timedelta(minutes=SCAN_INTERVAL_MINUTES), + ) + self.api = api + self.add_devices_callback: Callable[[list[Probe]], None] | None = None + + async def _async_update_data(self) -> list[Probe]: + """Fetch data from Sensoterra Customer API endpoint.""" + try: + probes = await self.api.poll() + except ApiAuthError as err: + raise ConfigEntryError(err) from err + except ApiTimeout as err: + raise UpdateFailed("Timeout communicating with Sensotera API") from err + + if self.add_devices_callback is not None: + self.add_devices_callback(probes) + + return probes + + def get_sensor(self, id: str | None) -> Sensor | None: + """Try to find the sensor in the API result.""" + for probe in self.data: + for sensor in probe.sensors(): + if sensor.id == id: + return sensor + return None diff --git a/homeassistant/components/sensoterra/manifest.json b/homeassistant/components/sensoterra/manifest.json new file mode 100644 index 00000000000..942741fdb2f --- /dev/null +++ b/homeassistant/components/sensoterra/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sensoterra", + "name": "Sensoterra", + "codeowners": ["@markruys"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sensoterra", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["sensoterra==2.0.1"] +} diff --git a/homeassistant/components/sensoterra/sensor.py b/homeassistant/components/sensoterra/sensor.py new file mode 100644 index 00000000000..7e9f4d0840e --- /dev/null +++ b/homeassistant/components/sensoterra/sensor.py @@ -0,0 +1,172 @@ +"""Sensoterra devices.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from enum import StrEnum, auto + +from sensoterra.probe import Probe, Sensor + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import SensoterraConfigEntry +from .const import CONFIGURATION_URL, DOMAIN, SENSOR_EXPIRATION_DAYS +from .coordinator import SensoterraCoordinator + + +class ProbeSensorType(StrEnum): + """Generic sensors within a Sensoterra probe.""" + + MOISTURE = auto() + SI = auto() + TEMPERATURE = auto() + BATTERY = auto() + RSSI = auto() + + +SENSORS: dict[ProbeSensorType, SensorEntityDescription] = { + ProbeSensorType.MOISTURE: SensorEntityDescription( + key=ProbeSensorType.MOISTURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.MOISTURE, + native_unit_of_measurement=PERCENTAGE, + translation_key="soil_moisture_at_cm", + ), + ProbeSensorType.SI: SensorEntityDescription( + key=ProbeSensorType.SI, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + translation_key="si_at_cm", + ), + ProbeSensorType.TEMPERATURE: SensorEntityDescription( + key=ProbeSensorType.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), + ProbeSensorType.BATTERY: SensorEntityDescription( + key=ProbeSensorType.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + ProbeSensorType.RSSI: SensorEntityDescription( + key=ProbeSensorType.RSSI, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SensoterraConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up Sensoterra sensor.""" + + coordinator = entry.runtime_data + + @callback + def _async_add_devices(probes: list[Probe]) -> None: + aha = coordinator.async_contexts() + current_sensors = set(aha) + async_add_devices( + SensoterraEntity( + coordinator, + probe, + sensor, + SENSORS[ProbeSensorType[sensor.type]], + ) + for probe in probes + for sensor in probe.sensors() + if sensor.type is not None + and sensor.type.lower() in SENSORS + and sensor.id not in current_sensors + ) + + coordinator.add_devices_callback = _async_add_devices + + _async_add_devices(coordinator.data) + + +class SensoterraEntity(CoordinatorEntity[SensoterraCoordinator], SensorEntity): + """Sensoterra sensor like a soil moisture or temperature sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SensoterraCoordinator, + probe: Probe, + sensor: Sensor, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize entity.""" + super().__init__(coordinator, context=sensor.id) + + self._sensor_id = sensor.id + self._attr_unique_id = self._sensor_id + self._attr_translation_placeholders = { + "depth": "?" if sensor.depth is None else str(sensor.depth) + } + + self.entity_description = entity_description + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, probe.serial)}, + name=probe.name, + model=probe.sku, + manufacturer="Sensoterra", + serial_number=probe.serial, + suggested_area=probe.location, + configuration_url=CONFIGURATION_URL, + ) + + @property + def sensor(self) -> Sensor | None: + """Return the sensor, or None if it doesn't exist.""" + return self.coordinator.get_sensor(self._sensor_id) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + assert self.sensor + return self.sensor.value + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if not super().available or (sensor := self.sensor) is None: + return False + + if sensor.timestamp is None: + return False + + # Expire sensor if no update within the last few days. + expiration = datetime.now(UTC) - timedelta(days=SENSOR_EXPIRATION_DAYS) + return sensor.timestamp >= expiration diff --git a/homeassistant/components/sensoterra/strings.json b/homeassistant/components/sensoterra/strings.json new file mode 100644 index 00000000000..86c4f2c2912 --- /dev/null +++ b/homeassistant/components/sensoterra/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter credentials to obtain a token", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reconfigure": { + "description": "[%key:component::sensoterra::config::step::user::description%]", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "sensor": { + "soil_moisture_at_cm": { + "name": "Soil moisture @ {depth} cm" + }, + "si_at_cm": { + "name": "SI @ {depth} cm" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index c7c8cd0f9f1..9f4b4e42bb0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -513,6 +513,7 @@ FLOWS = { "sensirion_ble", "sensorpro", "sensorpush", + "sensoterra", "sentry", "senz", "seventeentrack", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f6854aeb58d..b4c80aa70b4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5391,6 +5391,12 @@ "config_flow": true, "iot_class": "local_push" }, + "sensoterra": { + "name": "Sensoterra", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "sentry": { "name": "Sentry", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index b352d2747be..3854477b94b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3766,6 +3766,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sensoterra.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.senz.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 77f7e50674a..1a05bcdde77 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2609,6 +2609,9 @@ sensorpro-ble==0.5.3 # homeassistant.components.sensorpush sensorpush-ble==1.6.2 +# homeassistant.components.sensoterra +sensoterra==2.0.1 + # homeassistant.components.sentry sentry-sdk==1.40.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e67ff882b1a..98a26141f1a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2067,6 +2067,9 @@ sensorpro-ble==0.5.3 # homeassistant.components.sensorpush sensorpush-ble==1.6.2 +# homeassistant.components.sensoterra +sensoterra==2.0.1 + # homeassistant.components.sentry sentry-sdk==1.40.3 diff --git a/tests/components/sensoterra/__init__.py b/tests/components/sensoterra/__init__.py new file mode 100644 index 00000000000..f70fede6c09 --- /dev/null +++ b/tests/components/sensoterra/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sensoterra integration.""" diff --git a/tests/components/sensoterra/conftest.py b/tests/components/sensoterra/conftest.py new file mode 100644 index 00000000000..2e19a96543a --- /dev/null +++ b/tests/components/sensoterra/conftest.py @@ -0,0 +1,32 @@ +"""Common fixtures for the Sensoterra tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from .const import API_TOKEN + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sensoterra.async_setup_entry", + return_value=True, + ) as mock_entry: + yield mock_entry + + +@pytest.fixture +def mock_customer_api_client() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with ( + patch( + "homeassistant.components.sensoterra.config_flow.CustomerApi", + autospec=True, + ) as mock_client, + ): + mock = mock_client.return_value + mock.get_token.return_value = API_TOKEN + yield mock diff --git a/tests/components/sensoterra/const.py b/tests/components/sensoterra/const.py new file mode 100644 index 00000000000..c85d675f9d7 --- /dev/null +++ b/tests/components/sensoterra/const.py @@ -0,0 +1,7 @@ +"""Constants for the test Sensoterra integration.""" + +API_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE4NTYzMDQwMDAsInN1YiI6IjM5In0.yxdXXlc1DqopqDRHfAVzFrMqZJl6nKLpu1dV8alHvVY" +API_EMAIL = "test-email@example.com" +API_PASSWORD = "test-password" +HASS_UUID = "phony-unique-id" +SOURCE_USER = "user" diff --git a/tests/components/sensoterra/test_config_flow.py b/tests/components/sensoterra/test_config_flow.py new file mode 100644 index 00000000000..23c57261741 --- /dev/null +++ b/tests/components/sensoterra/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the Sensoterra config flow.""" + +from unittest.mock import AsyncMock + +from jwt import DecodeError +import pytest +from sensoterra.customerapi import InvalidAuth as StInvalidAuth, Timeout as StTimeout + +from homeassistant.components.sensoterra.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import API_EMAIL, API_PASSWORD, API_TOKEN, HASS_UUID, SOURCE_USER + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_customer_api_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can finish a config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + hass.data["core.uuid"] = HASS_UUID + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: API_EMAIL, + CONF_PASSWORD: API_PASSWORD, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == API_EMAIL + assert result["data"] == { + CONF_TOKEN: API_TOKEN, + CONF_EMAIL: API_EMAIL, + } + + assert len(mock_customer_api_client.mock_calls) == 1 + + +async def test_form_unique_id( + hass: HomeAssistant, mock_customer_api_client: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + hass.data["core.uuid"] = HASS_UUID + + entry = MockConfigEntry(unique_id="39", domain=DOMAIN) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: API_EMAIL, + CONF_PASSWORD: API_PASSWORD, + }, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(mock_customer_api_client.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (StTimeout, "cannot_connect"), + (StInvalidAuth("Invalid credentials"), "invalid_auth"), + (DecodeError("Bad API token"), "invalid_access_token"), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_customer_api_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle config form exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + hass.data["core.uuid"] = HASS_UUID + + mock_customer_api_client.get_token.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: API_EMAIL, + CONF_PASSWORD: API_PASSWORD, + }, + ) + assert result["errors"] == {"base": error} + assert result["type"] is FlowResultType.FORM + + mock_customer_api_client.get_token.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: API_EMAIL, + CONF_PASSWORD: API_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == API_EMAIL + assert result["data"] == { + CONF_TOKEN: API_TOKEN, + CONF_EMAIL: API_EMAIL, + } + assert len(mock_customer_api_client.mock_calls) == 2 From 2c0c0b9e2151392763ddf1f925c6391de3c4172b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 5 Sep 2024 22:34:35 +0200 Subject: [PATCH 0288/1309] Extend deprecation of aux_heat in ClimateEntity (#125360) --- homeassistant/components/climate/__init__.py | 4 ++-- tests/components/climate/test_init.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 6097e4f1346..f752a3dcc7a 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -429,7 +429,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ( "%s::%s implements the `is_aux_heat` property or uses the auxiliary " "heater methods in a subclass of ClimateEntity which is " - "deprecated and will be unsupported from Home Assistant 2024.10." + "deprecated and will be unsupported from Home Assistant 2025.4." " Please %s" ), self.platform.platform_name, @@ -451,7 +451,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self.hass, DOMAIN, f"deprecated_climate_aux_{self.platform.platform_name}", - breaks_in_ha_version="2024.10.0", + breaks_in_ha_version="2025.4.0", is_fixable=False, is_persistent=False, issue_domain=self.platform.platform_name, diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 256ecf92b1d..64c94ccfc6f 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -826,7 +826,7 @@ async def test_issue_aux_property_deprecated( assert ( "test::MockClimateEntityWithAux implements the `is_aux_heat` property or uses " "the auxiliary heater methods in a subclass of ClimateEntity which is deprecated " - f"and will be unsupported from Home Assistant 2024.10. Please {report}" + f"and will be unsupported from Home Assistant 2025.4. Please {report}" ) in caplog.text # Assert we only log warning once From 56b4ddc6b457eea7203987e776cce5086d63fce5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Sep 2024 16:52:17 -0400 Subject: [PATCH 0289/1309] Add model ID to Sonos (#125364) --- homeassistant/components/sonos/entity.py | 1 + tests/components/sonos/test_media_player.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index bd7256493e8..98dc8b8b752 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -85,6 +85,7 @@ class SonosEntity(Entity): identifiers={(DOMAIN, self.soco.uid)}, name=self.speaker.zone_name, model=self.speaker.model_name.replace("Sonos ", ""), + model_id=self.speaker.model_number, sw_version=self.speaker.version, connections={ (dr.CONNECTION_NETWORK_MAC, self.speaker.mac_address), diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index ae3928c5ff6..9887601a0a3 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -72,6 +72,7 @@ async def test_device_registry( ) assert reg_device is not None assert reg_device.model == "Model Name" + assert reg_device.model_id == "S12" assert reg_device.sw_version == "13.1" assert reg_device.connections == { (CONNECTION_NETWORK_MAC, "00:11:22:33:44:55"), From 006b2da14ea7b3c573d13e25697583e16ebb48e8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Sep 2024 16:52:45 -0400 Subject: [PATCH 0290/1309] Add model ID to roborock (#125366) --- homeassistant/components/roborock/coordinator.py | 1 + tests/components/roborock/test_vacuum.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 615d18c3019..6b520ba10d6 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -63,6 +63,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, manufacturer="Roborock", model=self.roborock_device_info.product.model, + model_id=self.roborock_device_info.product.model, sw_version=self.roborock_device_info.device.fv, ) self.current_map: int | None = None diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 15a64cbecf3..5080711d0f9 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -24,7 +24,7 @@ from homeassistant.components.vacuum import ( from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .mock_data import PROP @@ -38,12 +38,17 @@ DEVICE_ID = "abc123" async def test_registry_entries( hass: HomeAssistant, entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, bypass_api_fixture, setup_entry: MockConfigEntry, ) -> None: """Tests devices are registered in the entity registry.""" - entry = entity_registry.async_get(ENTITY_ID) - assert entry.unique_id == DEVICE_ID + entity_entry = entity_registry.async_get(ENTITY_ID) + assert entity_entry.unique_id == DEVICE_ID + + device_entry = device_registry.async_get(entity_entry.device_id) + assert device_entry is not None + assert device_entry.model_id == "roborock.vacuum.a27" @pytest.mark.parametrize( From 97ffbf5aad41e7309c31f9170d1ed80d4c9b4892 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Sep 2024 17:03:37 -0400 Subject: [PATCH 0291/1309] Add model ID to samsungtv (#125369) --- homeassistant/components/samsungtv/entity.py | 1 + tests/components/samsungtv/snapshots/test_init.ambr | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 030eaf98d9b..1af7495d78e 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -42,6 +42,7 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) name=config_entry.data.get(CONF_NAME), manufacturer=config_entry.data.get(CONF_MANUFACTURER), model=config_entry.data.get(CONF_MODEL), + model_id=config_entry.data.get(CONF_MODEL), ) if self.unique_id: self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, self.unique_id)} diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index 061b5bc1836..017a2bc3e60 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -72,7 +72,7 @@ }), 'manufacturer': None, 'model': '82GXARRS', - 'model_id': None, + 'model_id': '82GXARRS', 'name': 'fake', 'name_by_user': None, 'primary_config_entry': , From 0677a256ecfe922943b3ce7d2cfd98a10e1549e0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Sep 2024 17:03:50 -0400 Subject: [PATCH 0292/1309] Add model ID to Wemo (#125368) --- homeassistant/components/wemo/coordinator.py | 1 + tests/components/wemo/conftest.py | 1 + tests/components/wemo/test_coordinator.py | 1 + tests/components/wemo/test_init.py | 1 + 4 files changed, 4 insertions(+) diff --git a/homeassistant/components/wemo/coordinator.py b/homeassistant/components/wemo/coordinator.py index a186b666470..1f25c12f7ca 100644 --- a/homeassistant/components/wemo/coordinator.py +++ b/homeassistant/components/wemo/coordinator.py @@ -275,6 +275,7 @@ def _device_info(wemo: WeMoDevice) -> DeviceInfo: identifiers={(DOMAIN, wemo.serial_number)}, manufacturer="Belkin", model=wemo.model_name, + model_id=wemo.model, name=wemo.name, sw_version=wemo.firmware_version, ) diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 64bd89f4793..fee981484ef 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -65,6 +65,7 @@ def create_pywemo_device( device.name = MOCK_NAME device.serial_number = MOCK_SERIAL_NUMBER device.model_name = pywemo_model.replace("LongPress", "") + device.model = device.model_name device.udn = f"uuid:{device.model_name}-1_0-{device.serial_number}" device.firmware_version = MOCK_FIRMWARE_VERSION device.get_state.return_value = 0 # Default to Off diff --git a/tests/components/wemo/test_coordinator.py b/tests/components/wemo/test_coordinator.py index f524633e701..17061aea2f6 100644 --- a/tests/components/wemo/test_coordinator.py +++ b/tests/components/wemo/test_coordinator.py @@ -178,6 +178,7 @@ async def test_device_info( } assert device_entries[0].manufacturer == "Belkin" assert device_entries[0].model == "LightSwitch" + assert device_entries[0].model_id == "LightSwitch" assert device_entries[0].sw_version == MOCK_FIRMWARE_VERSION diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index 48d8f8eac03..4a38775d331 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -201,6 +201,7 @@ async def test_discovery( device.name = f"{MOCK_NAME}_{counter}" device.serial_number = f"{MOCK_SERIAL_NUMBER}_{counter}" device.model_name = "Motion" + device.model = "Motion" device.udn = f"uuid:{device.model_name}-1_0-{device.serial_number}" device.firmware_version = MOCK_FIRMWARE_VERSION device.get_state.return_value = 0 # Default to Off From aa619c5594efd8ce7a3bec181f3a91a36666479b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Sep 2024 19:42:50 -0400 Subject: [PATCH 0293/1309] Add model ID to awair (#125373) * Add model ID to awair * less diff --- homeassistant/components/awair/sensor.py | 1 + tests/components/awair/test_sensor.py | 29 +++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index b9a226e9c2c..a62a15368be 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -293,6 +293,7 @@ class AwairSensor(CoordinatorEntity[AwairDataUpdateCoordinator], SensorEntity): identifiers={(DOMAIN, self._device.uuid)}, manufacturer="Awair", model=self._device.model, + model_id=self._device.device_type, name=( self._device.name or cast(ConfigEntry, self.coordinator.config_entry).title diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index 8af1fdd9c7c..8c9cd6e3a24 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -29,7 +29,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from . import setup_awair @@ -48,16 +48,24 @@ SENSOR_TYPES_MAP = { def assert_expected_properties( hass: HomeAssistant, - registry: er.RegistryEntry, - name, - unique_id, - state_value, + entity_registry: er.RegistryEntry, + name: str, + unique_id: str, + state_value: str, attributes: dict, + model="Awair", + model_id="awair", ): """Assert expected properties from a dict.""" + entity_entry = entity_registry.async_get(name) + assert entity_entry.unique_id == unique_id + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity_entry.device_id) + assert device_entry is not None + assert device_entry.model == model + assert device_entry.model_id == model_id - entry = registry.async_get(name) - assert entry.unique_id == unique_id state = hass.states.get(name) assert state assert state.state == state_value @@ -201,7 +209,10 @@ async def test_awair_gen2_sensors( async def test_local_awair_sensors( - hass: HomeAssistant, entity_registry: er.EntityRegistry, local_devices, local_data + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + local_devices, + local_data, ) -> None: """Test expected sensors on a local Awair.""" @@ -215,6 +226,8 @@ async def test_local_awair_sensors( f"{local_devices['device_uuid']}_{SENSOR_TYPES_MAP[API_SCORE].unique_id_tag}", "94", {}, + model="Awair Element", + model_id="awair-element", ) From c3921f2112d7c8841b0e323f71f7ac0a800f1550 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Sep 2024 19:44:28 -0400 Subject: [PATCH 0294/1309] Add model ID to unifiprotect (#125376) --- homeassistant/components/unifiprotect/entity.py | 3 ++- tests/components/unifiprotect/test_camera.py | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 17b9f7c4fe9..34b4ec085af 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -278,7 +278,8 @@ class ProtectDeviceEntity(BaseProtectEntity): self._attr_device_info = DeviceInfo( name=self.device.display_name, manufacturer=DEFAULT_BRAND, - model=self.device.type, + model=self.device.market_name or self.device.type, + model_id=self.device.type, via_device=(DOMAIN, self.data.api.bootstrap.nvr.mac), sw_version=self.device.firmware_version, connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 9fedb67fea4..ea7a7ae942d 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -32,7 +32,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from .utils import ( @@ -66,6 +66,14 @@ def validate_default_camera_entity( assert entity.disabled is False assert entity.unique_id == unique_id + device_registry = dr.async_get(hass) + device = device_registry.async_get(entity.device_id) + assert device + assert device.manufacturer == "Ubiquiti" + assert device.name == camera_obj.name + assert device.model == camera_obj.market_name or camera_obj.type + assert device.model_id == camera_obj.type + return entity_id From 60b0f0dc5388a426e78c5c19fd0e2239c30dddfd Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 5 Sep 2024 20:16:30 -0500 Subject: [PATCH 0295/1309] Add assist satellite entity component (#125351) * Add assist_satellite * Update homeassistant/components/assist_satellite/manifest.json Co-authored-by: Paulus Schoutsen * Update homeassistant/components/assist_satellite/manifest.json Co-authored-by: Paulus Schoutsen * Add platform constant * Update Dockerfile * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Address comments * Update docstring async_internal_announce * Update CODEOWNERS --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare --- .core_files.yaml | 1 + .strict-typing | 1 + CODEOWNERS | 2 + .../components/assist_pipeline/__init__.py | 2 + .../components/assist_pipeline/const.py | 2 + .../components/assist_pipeline/select.py | 4 +- .../components/assist_satellite/__init__.py | 65 ++++ .../components/assist_satellite/const.py | 12 + .../components/assist_satellite/entity.py | 332 ++++++++++++++++++ .../components/assist_satellite/errors.py | 11 + .../components/assist_satellite/icons.json | 12 + .../components/assist_satellite/manifest.json | 9 + .../components/assist_satellite/services.yaml | 16 + .../components/assist_satellite/strings.json | 30 ++ .../assist_satellite/websocket_api.py | 46 +++ homeassistant/const.py | 1 + mypy.ini | 10 + script/hassfest/docker/Dockerfile | 2 +- tests/components/assist_satellite/__init__.py | 3 + tests/components/assist_satellite/conftest.py | 107 ++++++ .../assist_satellite/test_entity.py | 332 ++++++++++++++++++ .../assist_satellite/test_websocket_api.py | 192 ++++++++++ 22 files changed, 1188 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/assist_satellite/__init__.py create mode 100644 homeassistant/components/assist_satellite/const.py create mode 100644 homeassistant/components/assist_satellite/entity.py create mode 100644 homeassistant/components/assist_satellite/errors.py create mode 100644 homeassistant/components/assist_satellite/icons.json create mode 100644 homeassistant/components/assist_satellite/manifest.json create mode 100644 homeassistant/components/assist_satellite/services.yaml create mode 100644 homeassistant/components/assist_satellite/strings.json create mode 100644 homeassistant/components/assist_satellite/websocket_api.py create mode 100644 tests/components/assist_satellite/__init__.py create mode 100644 tests/components/assist_satellite/conftest.py create mode 100644 tests/components/assist_satellite/test_entity.py create mode 100644 tests/components/assist_satellite/test_websocket_api.py diff --git a/.core_files.yaml b/.core_files.yaml index 4a11d5da27c..e852a567601 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -14,6 +14,7 @@ core: &core base_platforms: &base_platforms - homeassistant/components/air_quality/** - homeassistant/components/alarm_control_panel/** + - homeassistant/components/assist_satellite/** - homeassistant/components/binary_sensor/** - homeassistant/components/button/** - homeassistant/components/calendar/** diff --git a/.strict-typing b/.strict-typing index 1a5133efe89..84c22d1cfca 100644 --- a/.strict-typing +++ b/.strict-typing @@ -95,6 +95,7 @@ homeassistant.components.aruba.* homeassistant.components.arwn.* homeassistant.components.aseko_pool_live.* homeassistant.components.assist_pipeline.* +homeassistant.components.assist_satellite.* homeassistant.components.asuswrt.* homeassistant.components.autarco.* homeassistant.components.auth.* diff --git a/CODEOWNERS b/CODEOWNERS index edd10858e8d..d2a60cbb246 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -143,6 +143,8 @@ build.json @home-assistant/supervisor /tests/components/aseko_pool_live/ @milanmeu /homeassistant/components/assist_pipeline/ @balloob @synesthesiam /tests/components/assist_pipeline/ @balloob @synesthesiam +/homeassistant/components/assist_satellite/ @home-assistant/core @synesthesiam +/tests/components/assist_satellite/ @home-assistant/core @synesthesiam /homeassistant/components/asuswrt/ @kennedyshead @ollo69 /tests/components/asuswrt/ @kennedyshead @ollo69 /homeassistant/components/atag/ @MatsNL diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 0a03402105a..ec6d8a646b6 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -17,6 +17,7 @@ from .const import ( DATA_LAST_WAKE_UP, DOMAIN, EVENT_RECORDING, + OPTION_PREFERRED, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH, @@ -58,6 +59,7 @@ __all__ = ( "PipelineNotFound", "WakeWordSettings", "EVENT_RECORDING", + "OPTION_PREFERRED", "SAMPLES_PER_CHUNK", "SAMPLE_RATE", "SAMPLE_WIDTH", diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py index f7306b89a54..300cb5aad2a 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -22,3 +22,5 @@ SAMPLE_CHANNELS = 1 # mono MS_PER_CHUNK = 10 SAMPLES_PER_CHUNK = SAMPLE_RATE // (1000 // MS_PER_CHUNK) # 10 ms @ 16Khz BYTES_PER_CHUNK = SAMPLES_PER_CHUNK * SAMPLE_WIDTH * SAMPLE_CHANNELS # 16-bit + +OPTION_PREFERRED = "preferred" diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index 5d011424e6e..c7e4846aad7 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -9,12 +9,10 @@ from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection, entity_registry as er, restore_state -from .const import DOMAIN +from .const import DOMAIN, OPTION_PREFERRED from .pipeline import AssistDevice, PipelineData, PipelineStorageCollection from .vad import VadSensitivity -OPTION_PREFERRED = "preferred" - @callback def get_chosen_pipeline( diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py new file mode 100644 index 00000000000..3d6e04bcc75 --- /dev/null +++ b/homeassistant/components/assist_satellite/__init__.py @@ -0,0 +1,65 @@ +"""Base class for assist satellite entities.""" + +import logging + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, AssistSatelliteEntityFeature +from .entity import AssistSatelliteEntity, AssistSatelliteEntityDescription +from .errors import SatelliteBusyError +from .websocket_api import async_register_websocket_api + +__all__ = [ + "DOMAIN", + "AssistSatelliteEntity", + "AssistSatelliteEntityDescription", + "AssistSatelliteEntityFeature", + "SatelliteBusyError", +] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + component = hass.data[DOMAIN] = EntityComponent[AssistSatelliteEntity]( + _LOGGER, DOMAIN, hass + ) + await component.async_setup(config) + + component.async_register_entity_service( + "announce", + vol.All( + cv.make_entity_service_schema( + { + vol.Optional("message"): str, + vol.Optional("media_id"): str, + } + ), + cv.has_at_least_one_key("message", "media_id"), + ), + "async_internal_announce", + [AssistSatelliteEntityFeature.ANNOUNCE], + ) + async_register_websocket_api(hass) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py new file mode 100644 index 00000000000..3a9ce896fb2 --- /dev/null +++ b/homeassistant/components/assist_satellite/const.py @@ -0,0 +1,12 @@ +"""Constants for assist satellite.""" + +from enum import IntFlag + +DOMAIN = "assist_satellite" + + +class AssistSatelliteEntityFeature(IntFlag): + """Supported features of Assist satellite entity.""" + + ANNOUNCE = 1 + """Device supports remotely triggered announcements.""" diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py new file mode 100644 index 00000000000..8364a81b1fb --- /dev/null +++ b/homeassistant/components/assist_satellite/entity.py @@ -0,0 +1,332 @@ +"""Assist satellite entity.""" + +from abc import abstractmethod +import asyncio +from collections.abc import AsyncIterable +from enum import StrEnum +import logging +import time +from typing import Any, Final, final + +from homeassistant.components import media_source, stt, tts +from homeassistant.components.assist_pipeline import ( + OPTION_PREFERRED, + AudioSettings, + PipelineEvent, + PipelineEventType, + PipelineStage, + async_get_pipeline, + async_get_pipelines, + async_pipeline_from_audio_stream, + vad, +) +from homeassistant.components.media_player import async_process_play_media_url +from homeassistant.components.tts.media_source import ( + generate_media_source_id as tts_generate_media_source_id, +) +from homeassistant.core import Context, callback +from homeassistant.helpers import entity +from homeassistant.helpers.entity import EntityDescription +from homeassistant.util import ulid + +from .const import AssistSatelliteEntityFeature +from .errors import AssistSatelliteError, SatelliteBusyError + +_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes + +_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.""" + + LISTENING_COMMAND = "listening_command" + """Device is streaming audio with the voice command to Home Assistant.""" + + PROCESSING = "processing" + """Home Assistant is processing the voice command.""" + + RESPONDING = "responding" + """Device is speaking the response.""" + + +class AssistSatelliteEntityDescription(EntityDescription, frozen_or_thawed=True): + """A class that describes Assist satellite entities.""" + + +class AssistSatelliteEntity(entity.Entity): + """Entity encapsulating the state and functionality of an Assist satellite.""" + + entity_description: AssistSatelliteEntityDescription + _attr_should_poll = False + _attr_supported_features = AssistSatelliteEntityFeature(0) + _attr_pipeline_entity_id: str | None = None + _attr_vad_sensitivity_entity_id: str | None = None + + _conversation_id: str | None = None + _conversation_id_time: float | None = None + + _run_has_tts: bool = False + _is_announcing = False + _wake_word_intercept_future: asyncio.Future[str | None] | None = None + + __assist_satellite_state: AssistSatelliteState | None = None + + @final + @property + def state(self) -> str | None: + """Return state of the entity.""" + return self.__assist_satellite_state + + @property + def pipeline_entity_id(self) -> str | None: + """Entity ID of the pipeline to use for the next conversation.""" + return self._attr_pipeline_entity_id + + @property + def vad_sensitivity_entity_id(self) -> str | None: + """Entity ID of the VAD sensitivity to use for the next conversation.""" + return self._attr_vad_sensitivity_entity_id + + async def async_intercept_wake_word(self) -> str | None: + """Intercept the next wake word from the satellite. + + Returns the detected wake word phrase or None. + """ + if self._wake_word_intercept_future is not None: + raise SatelliteBusyError("Wake word interception already in progress") + + # Will cause next wake word to be intercepted in + # async_accept_pipeline_from_satellite + self._wake_word_intercept_future = asyncio.Future() + + _LOGGER.debug("Next wake word will be intercepted: %s", self.entity_id) + + try: + return await self._wake_word_intercept_future + finally: + self._wake_word_intercept_future = None + + async def async_internal_announce( + self, + message: str | None = None, + media_id: str | None = None, + ) -> None: + """Play and show an announcement on the satellite. + + If media_id is not provided, message is synthesized to + audio with the selected pipeline. + + If media_id is provided, it is played directly. It is possible + to omit the message and the satellite will not show any text. + + Calls async_announce with message and media id. + """ + if message is None: + message = "" + + if not media_id: + # Synthesize audio and get URL + pipeline_id = self._resolve_pipeline() + pipeline = async_get_pipeline(self.hass, pipeline_id) + + tts_options: dict[str, Any] = {} + if pipeline.tts_voice is not None: + tts_options[tts.ATTR_VOICE] = pipeline.tts_voice + + media_id = tts_generate_media_source_id( + self.hass, + message, + engine=pipeline.tts_engine, + language=pipeline.tts_language, + options=tts_options, + ) + + if media_source.is_media_source_id(media_id): + media = await media_source.async_resolve_media( + self.hass, + media_id, + None, + ) + media_id = media.url + + # Resolve to full URL + media_id = async_process_play_media_url(self.hass, media_id) + + if self._is_announcing: + raise SatelliteBusyError + + self._is_announcing = True + + try: + # Block until announcement is finished + await self.async_announce(message, media_id) + finally: + self._is_announcing = False + + async def async_announce(self, message: str, media_id: str) -> None: + """Announce media on the satellite. + + Should block until the announcement is done playing. + """ + raise NotImplementedError + + async def async_accept_pipeline_from_satellite( + self, + audio_stream: AsyncIterable[bytes], + start_stage: PipelineStage = PipelineStage.STT, + end_stage: PipelineStage = PipelineStage.TTS, + wake_word_phrase: str | None = None, + ) -> None: + """Triggers an Assist pipeline in Home Assistant from a satellite.""" + if self._wake_word_intercept_future and start_stage in ( + PipelineStage.WAKE_WORD, + PipelineStage.STT, + ): + if start_stage == PipelineStage.WAKE_WORD: + self._wake_word_intercept_future.set_exception( + AssistSatelliteError( + "Only on-device wake words currently supported" + ) + ) + return + + # Intercepting wake word and immediately end pipeline + _LOGGER.debug( + "Intercepted wake word: %s (entity_id=%s)", + wake_word_phrase, + self.entity_id, + ) + + if wake_word_phrase is None: + self._wake_word_intercept_future.set_exception( + AssistSatelliteError("No wake word phrase provided") + ) + else: + self._wake_word_intercept_future.set_result(wake_word_phrase) + self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END)) + return + + device_id = self.registry_entry.device_id if self.registry_entry else None + + # Refresh context if necessary + if ( + (self._context is None) + or (self._context_set is None) + or ((time.time() - self._context_set) > entity.CONTEXT_RECENT_TIME_SECONDS) + ): + self.async_set_context(Context()) + + assert self._context is not None + + # Reset conversation id if necessary + if (self._conversation_id_time is None) or ( + (time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC + ): + self._conversation_id = None + + if self._conversation_id is None: + self._conversation_id = ulid.ulid() + + # Update timeout + self._conversation_id_time = time.monotonic() + + # Set entity state based on pipeline events + self._run_has_tts = False + + await async_pipeline_from_audio_stream( + self.hass, + context=self._context, + event_callback=self._internal_on_pipeline_event, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_stream, + pipeline_id=self._resolve_pipeline(), + conversation_id=self._conversation_id, + device_id=device_id, + tts_audio_output="wav", + wake_word_phrase=wake_word_phrase, + audio_settings=AudioSettings( + silence_seconds=self._resolve_vad_sensitivity() + ), + start_stage=start_stage, + end_stage=end_stage, + ) + + @abstractmethod + def on_pipeline_event(self, event: PipelineEvent) -> None: + """Handle pipeline events.""" + + @callback + 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) + elif event.type is PipelineEventType.STT_START: + self._set_state(AssistSatelliteState.LISTENING_COMMAND) + elif event.type is PipelineEventType.INTENT_START: + self._set_state(AssistSatelliteState.PROCESSING) + elif event.type is PipelineEventType.TTS_START: + # Wait until tts_response_finished is called to return to waiting state + self._run_has_tts = True + 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.on_pipeline_event(event) + + @callback + def _set_state(self, state: AssistSatelliteState) -> None: + """Set the entity's state.""" + self.__assist_satellite_state = state + self.async_write_ha_state() + + @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) + + @callback + def _resolve_pipeline(self) -> str | None: + """Resolve pipeline from select entity to id. + + Return None to make async_get_pipeline look up the preferred pipeline. + """ + if not (pipeline_entity_id := self.pipeline_entity_id): + return None + + if (pipeline_entity_state := self.hass.states.get(pipeline_entity_id)) is None: + raise RuntimeError("Pipeline entity not found") + + if pipeline_entity_state.state != OPTION_PREFERRED: + # Resolve pipeline by name + for pipeline in async_get_pipelines(self.hass): + if pipeline.name == pipeline_entity_state.state: + return pipeline.id + + return None + + @callback + def _resolve_vad_sensitivity(self) -> float: + """Resolve VAD sensitivity from select entity to enum.""" + vad_sensitivity = vad.VadSensitivity.DEFAULT + + if vad_sensitivity_entity_id := self.vad_sensitivity_entity_id: + if ( + vad_sensitivity_state := self.hass.states.get(vad_sensitivity_entity_id) + ) is None: + raise RuntimeError("VAD sensitivity entity not found") + + vad_sensitivity = vad.VadSensitivity(vad_sensitivity_state.state) + + return vad.VadSensitivity.to_seconds(vad_sensitivity) diff --git a/homeassistant/components/assist_satellite/errors.py b/homeassistant/components/assist_satellite/errors.py new file mode 100644 index 00000000000..cd05f374521 --- /dev/null +++ b/homeassistant/components/assist_satellite/errors.py @@ -0,0 +1,11 @@ +"""Errors for assist satellite.""" + +from homeassistant.exceptions import HomeAssistantError + + +class AssistSatelliteError(HomeAssistantError): + """Base class for assist satellite errors.""" + + +class SatelliteBusyError(AssistSatelliteError): + """Satellite is busy and cannot handle the request.""" diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json new file mode 100644 index 00000000000..a98c3aefc5b --- /dev/null +++ b/homeassistant/components/assist_satellite/icons.json @@ -0,0 +1,12 @@ +{ + "entity_component": { + "_": { + "default": "mdi:account-voice" + } + }, + "services": { + "announce": { + "service": "mdi:bullhorn" + } + } +} diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json new file mode 100644 index 00000000000..b4f89456351 --- /dev/null +++ b/homeassistant/components/assist_satellite/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "assist_satellite", + "name": "Assist Satellite", + "codeowners": ["@home-assistant/core", "@synesthesiam"], + "dependencies": ["assist_pipeline", "stt", "tts"], + "documentation": "https://www.home-assistant.io/integrations/assist_satellite", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml new file mode 100644 index 00000000000..e7fefc4705f --- /dev/null +++ b/homeassistant/components/assist_satellite/services.yaml @@ -0,0 +1,16 @@ +announce: + target: + entity: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + fields: + message: + required: false + example: "Time to wake up!" + selector: + text: + media_id: + required: false + selector: + text: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json new file mode 100644 index 00000000000..1d07882daae --- /dev/null +++ b/homeassistant/components/assist_satellite/strings.json @@ -0,0 +1,30 @@ +{ + "title": "Assist satellite", + "entity_component": { + "_": { + "name": "Assist satellite", + "state": { + "listening_wake_word": "Wake word", + "listening_command": "Voice command", + "responding": "Responding", + "processing": "Processing" + } + } + }, + "services": { + "announce": { + "name": "Announce", + "description": "Let the satellite announce a message.", + "fields": { + "message": { + "name": "Message", + "description": "The message to announce." + }, + "media_id": { + "name": "Media ID", + "description": "The media ID to announce instead of using text-to-speech." + } + } + } + } +} diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py new file mode 100644 index 00000000000..10687f4210e --- /dev/null +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -0,0 +1,46 @@ +"""Assist satellite Websocket API.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent + +from .const import DOMAIN +from .entity import AssistSatelliteEntity + + +@callback +def async_register_websocket_api(hass: HomeAssistant) -> None: + """Register the websocket API.""" + websocket_api.async_register_command(hass, websocket_intercept_wake_word) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_satellite/intercept_wake_word", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_intercept_wake_word( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Intercept the next wake word from a satellite.""" + component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] + satellite = component.get_entity(msg["entity_id"]) + if satellite is None: + connection.send_error( + msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" + ) + return + + wake_word_phrase = await satellite.async_intercept_wake_word() + connection.send_result(msg["id"], {"wake_word_phrase": wake_word_phrase}) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1ee73408f98..ee90ebfc28b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -41,6 +41,7 @@ class Platform(StrEnum): AIR_QUALITY = "air_quality" ALARM_CONTROL_PANEL = "alarm_control_panel" + ASSIST_SATELLITE = "assist_satellite" BINARY_SENSOR = "binary_sensor" BUTTON = "button" CALENDAR = "calendar" diff --git a/mypy.ini b/mypy.ini index 3854477b94b..2686fbe3062 100644 --- a/mypy.ini +++ b/mypy.ini @@ -705,6 +705,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.assist_satellite.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.asuswrt.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 4dbea0e4c95..a37fa9c57fc 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.2.27,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.1 tqdm==4.66.4 ruff==0.6.2 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.9.4 mutagen==1.47.0 + PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.9.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " diff --git a/tests/components/assist_satellite/__init__.py b/tests/components/assist_satellite/__init__.py new file mode 100644 index 00000000000..7e06ea3a4b9 --- /dev/null +++ b/tests/components/assist_satellite/__init__.py @@ -0,0 +1,3 @@ +"""Tests for Assist Satellite.""" + +ENTITY_ID = "assist_satellite.test_entity" diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py new file mode 100644 index 00000000000..a14e9e9452b --- /dev/null +++ b/tests/components/assist_satellite/conftest.py @@ -0,0 +1,107 @@ +"""Test helpers for Assist Satellite.""" + +import pathlib +from unittest.mock import Mock + +import pytest + +from homeassistant.components.assist_pipeline import PipelineEvent +from homeassistant.components.assist_satellite import ( + DOMAIN as AS_DOMAIN, + AssistSatelliteEntity, + AssistSatelliteEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, + setup_test_component_platform, +) + +TEST_DOMAIN = "test" + + +@pytest.fixture(autouse=True) +def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None: + """Mock TTS cache dir fixture.""" + + +class MockAssistSatellite(AssistSatelliteEntity): + """Mock Assist Satellite Entity.""" + + _attr_name = "Test Entity" + _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE + + def __init__(self) -> None: + """Initialize the mock entity.""" + self.events = [] + self.announcements = [] + + def on_pipeline_event(self, event: PipelineEvent) -> None: + """Handle pipeline events.""" + self.events.append(event) + + async def async_announce(self, message: str, media_id: str) -> None: + """Announce media on a device.""" + self.announcements.append((message, media_id)) + + +@pytest.fixture +def entity() -> MockAssistSatellite: + """Mock Assist Satellite Entity.""" + return MockAssistSatellite() + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> ConfigEntry: + """Mock config entry.""" + entry = MockConfigEntry(domain=TEST_DOMAIN) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_components( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Initialize components.""" + assert await async_setup_component(hass, "homeassistant", {}) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [AS_DOMAIN]) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config entry.""" + await hass.config_entries.async_forward_entry_unload(config_entry, AS_DOMAIN) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + setup_test_component_platform(hass, AS_DOMAIN, [entity], from_config_entry=True) + mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) + + with mock_config_flow(TEST_DOMAIN, ConfigFlow): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py new file mode 100644 index 00000000000..f957a826828 --- /dev/null +++ b/tests/components/assist_satellite/test_entity.py @@ -0,0 +1,332 @@ +"""Test the Assist Satellite entity.""" + +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.components import stt +from homeassistant.components.assist_pipeline import ( + OPTION_PREFERRED, + AudioSettings, + Pipeline, + PipelineEvent, + PipelineEventType, + PipelineStage, + async_get_pipeline, + async_update_pipeline, + vad, +) +from homeassistant.components.assist_satellite import SatelliteBusyError +from homeassistant.components.assist_satellite.entity import AssistSatelliteState +from homeassistant.components.media_source import PlayMedia +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import Context, HomeAssistant + +from . import ENTITY_ID +from .conftest import MockAssistSatellite + + +async def test_entity_state( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test entity state represent events.""" + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + audio_stream = object() + + entity.async_set_context(context) + + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream" + ) as mock_start_pipeline: + await entity.async_accept_pipeline_from_satellite(audio_stream) + + assert mock_start_pipeline.called + kwargs = mock_start_pipeline.call_args[1] + assert kwargs["context"] is context + assert kwargs["event_callback"] == entity._internal_on_pipeline_event + assert kwargs["stt_metadata"] == stt.SpeechMetadata( + language="", + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + assert kwargs["stt_stream"] is audio_stream + assert kwargs["pipeline_id"] is None + assert kwargs["device_id"] is None + assert kwargs["tts_audio_output"] == "wav" + assert kwargs["wake_word_phrase"] is None + assert kwargs["audio_settings"] == AudioSettings( + silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT) + ) + assert kwargs["start_stage"] == PipelineStage.STT + assert kwargs["end_stage"] == PipelineStage.TTS + + for event_type, expected_state in ( + (PipelineEventType.RUN_START, STATE_UNKNOWN), + (PipelineEventType.RUN_END, AssistSatelliteState.LISTENING_WAKE_WORD), + (PipelineEventType.WAKE_WORD_START, AssistSatelliteState.LISTENING_WAKE_WORD), + (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.INTENT_START, AssistSatelliteState.PROCESSING), + (PipelineEventType.INTENT_END, AssistSatelliteState.PROCESSING), + (PipelineEventType.TTS_START, AssistSatelliteState.RESPONDING), + (PipelineEventType.TTS_END, AssistSatelliteState.RESPONDING), + (PipelineEventType.ERROR, AssistSatelliteState.RESPONDING), + ): + kwargs["event_callback"](PipelineEvent(event_type, {})) + state = hass.states.get(ENTITY_ID) + assert state.state == expected_state, event_type + + entity.tts_response_finished() + state = hass.states.get(ENTITY_ID) + assert state.state == AssistSatelliteState.LISTENING_WAKE_WORD + + +@pytest.mark.parametrize( + ("service_data", "expected_params"), + [ + ( + {"message": "Hello"}, + ("Hello", "https://www.home-assistant.io/resolved.mp3"), + ), + ( + { + "message": "Hello", + "media_id": "http://example.com/bla.mp3", + }, + ("Hello", "http://example.com/bla.mp3"), + ), + ( + {"media_id": "http://example.com/bla.mp3"}, + ("", "http://example.com/bla.mp3"), + ), + ], +) +async def test_announce( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + service_data: dict, + expected_params: tuple[str, str], +) -> None: + """Test announcing on a device.""" + await async_update_pipeline( + hass, + async_get_pipeline(hass), + tts_engine="tts.mock_entity", + tts_language="en", + tts_voice="test-voice", + ) + + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + ): + await hass.services.async_call( + "assist_satellite", + "announce", + service_data, + target={"entity_id": "assist_satellite.test_entity"}, + blocking=True, + ) + + assert entity.announcements[0] == expected_params + + +async def test_announce_busy( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Test that announcing while an announcement is in progress raises an error.""" + media_id = "https://www.home-assistant.io/resolved.mp3" + announce_started = asyncio.Event() + got_error = asyncio.Event() + + async def async_announce(message, media_id): + announce_started.set() + + # Block so we can do another announcement + await got_error.wait() + + with patch.object(entity, "async_announce", new=async_announce): + announce_task = asyncio.create_task( + entity.async_internal_announce(media_id=media_id) + ) + async with asyncio.timeout(1): + await announce_started.wait() + + # Try to do a second announcement + with pytest.raises(SatelliteBusyError): + await entity.async_internal_announce(media_id=media_id) + + # Avoid lingering task + got_error.set() + await announce_task + + +async def test_context_refresh( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test that the context will be automatically refreshed.""" + audio_stream = object() + + # Remove context + entity._context = None + + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream" + ): + await entity.async_accept_pipeline_from_satellite(audio_stream) + + # Context should have been refreshed + assert entity._context is not None + + +async def test_pipeline_entity( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test getting pipeline from an entity.""" + audio_stream = object() + pipeline = Pipeline( + conversation_engine="test", + conversation_language="en", + language="en", + name="test-pipeline", + stt_engine=None, + stt_language=None, + tts_engine=None, + tts_language=None, + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + pipeline_entity_id = "select.pipeline" + hass.states.async_set(pipeline_entity_id, pipeline.name) + entity._attr_pipeline_entity_id = pipeline_entity_id + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, pipeline_id: str, **kwargs): + assert pipeline_id == pipeline.id + done.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch( + "homeassistant.components.assist_satellite.entity.async_get_pipelines", + return_value=[pipeline], + ), + ): + async with asyncio.timeout(1): + await entity.async_accept_pipeline_from_satellite(audio_stream) + await done.wait() + + +async def test_pipeline_entity_preferred( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test getting pipeline from an entity with a preferred state.""" + audio_stream = object() + + pipeline_entity_id = "select.pipeline" + hass.states.async_set(pipeline_entity_id, OPTION_PREFERRED) + entity._attr_pipeline_entity_id = pipeline_entity_id + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, pipeline_id: str, **kwargs): + # Preferred pipeline + assert pipeline_id is None + done.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + ): + async with asyncio.timeout(1): + await entity.async_accept_pipeline_from_satellite(audio_stream) + await done.wait() + + +async def test_vad_sensitivity_entity( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test getting vad sensitivity from an entity.""" + audio_stream = object() + + vad_sensitivity_entity_id = "select.vad_sensitivity" + hass.states.async_set(vad_sensitivity_entity_id, vad.VadSensitivity.AGGRESSIVE) + entity._attr_vad_sensitivity_entity_id = vad_sensitivity_entity_id + + done = asyncio.Event() + + async def async_pipeline_from_audio_stream( + *args, audio_settings: AudioSettings, **kwargs + ): + # Verify vad sensitivity + assert audio_settings.silence_seconds == vad.VadSensitivity.to_seconds( + vad.VadSensitivity.AGGRESSIVE + ) + done.set() + + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ): + async with asyncio.timeout(1): + await entity.async_accept_pipeline_from_satellite(audio_stream) + await done.wait() + + +async def test_pipeline_entity_not_found( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test that setting the pipeline entity id to a non-existent entity raises an error.""" + audio_stream = object() + + # Set to an entity that doesn't exist + entity._attr_pipeline_entity_id = "select.pipeline" + + with pytest.raises(RuntimeError): + await entity.async_accept_pipeline_from_satellite(audio_stream) + + +async def test_vad_sensitivity_entity_not_found( + hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite +) -> None: + """Test that setting the vad sensitivity entity id to a non-existent entity raises an error.""" + audio_stream = object() + + # Set to an entity that doesn't exist + entity._attr_vad_sensitivity_entity_id = "select.vad_sensitivity" + + with pytest.raises(RuntimeError): + await entity.async_accept_pipeline_from_satellite(audio_stream) diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py new file mode 100644 index 00000000000..af49334e629 --- /dev/null +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -0,0 +1,192 @@ +"""Test WebSocket API.""" + +import asyncio + +from homeassistant.components.assist_pipeline import PipelineStage +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import ENTITY_ID +from .conftest import MockAssistSatellite + +from tests.common import MockUser +from tests.typing import WebSocketGenerator + + +async def test_intercept_wake_word( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test intercepting a wake word.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/intercept_wake_word", + "entity_id": ENTITY_ID, + } + ) + + for _ in range(3): + await asyncio.sleep(0) + + await entity.async_accept_pipeline_from_satellite( + object(), + start_stage=PipelineStage.STT, + wake_word_phrase="ok, nabu", + ) + + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"] == {"wake_word_phrase": "ok, nabu"} + + +async def test_intercept_wake_word_requires_on_device_wake_word( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test intercepting a wake word fails if detection happens in HA.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/intercept_wake_word", + "entity_id": ENTITY_ID, + } + ) + + for _ in range(3): + await asyncio.sleep(0) + + await entity.async_accept_pipeline_from_satellite( + object(), + # Emulate wake word processing in Home Assistant + start_stage=PipelineStage.WAKE_WORD, + ) + + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": "Only on-device wake words currently supported", + } + + +async def test_intercept_wake_word_requires_wake_word_phrase( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test intercepting a wake word fails if detection happens in HA.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/intercept_wake_word", + "entity_id": ENTITY_ID, + } + ) + + for _ in range(3): + await asyncio.sleep(0) + + await entity.async_accept_pipeline_from_satellite( + object(), + start_stage=PipelineStage.STT, + # We are not passing wake word phrase + ) + + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": "No wake word phrase provided", + } + + +async def test_intercept_wake_word_require_admin( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, + hass_admin_user: MockUser, +) -> None: + """Test intercepting a wake word requires admin access.""" + # Remove admin permission and verify we're not allowed + hass_admin_user.groups = [] + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/intercept_wake_word", + "entity_id": ENTITY_ID, + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "unauthorized", + "message": "Unauthorized", + } + + +async def test_intercept_wake_word_invalid_satellite( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test intercepting a wake word requires admin access.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/intercept_wake_word", + "entity_id": "assist_satellite.invalid", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Entity not found", + } + + +async def test_intercept_wake_word_twice( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test intercepting a wake word requires admin access.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/intercept_wake_word", + "entity_id": ENTITY_ID, + } + ) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/intercept_wake_word", + "entity_id": ENTITY_ID, + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "home_assistant_error", + "message": "Wake word interception already in progress", + } From 0ca0836e832bff6a160e9faddc4a5fafc298ac6c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 6 Sep 2024 07:21:41 +0200 Subject: [PATCH 0296/1309] Correct check for removed index in recorder test (#125323) --- tests/components/recorder/test_migration_from_schema_32.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 40d18ab51fd..cdbbd7ec4e4 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -335,7 +335,7 @@ async def test_migrate_events_context_ids( # Check the index which will be removed by the migrator no longer exists with session_scope(hass=hass) as session: - assert get_index_by_name(session, "states", "ix_states_context_id") is None + assert get_index_by_name(session, "events", "ix_events_context_id") is None @pytest.mark.parametrize("enable_migrate_context_ids", [True]) From cf049a07c22e554a8c541e1c04c765f9d03bab04 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 6 Sep 2024 07:59:22 +0200 Subject: [PATCH 0297/1309] Don't allow templating min, max, step in config entry template number (#125342) --- homeassistant/components/template/__init__.py | 20 ++++++-- .../components/template/config_flow.py | 18 +++---- homeassistant/components/template/const.py | 9 ++-- homeassistant/components/template/number.py | 5 +- tests/components/template/test_config_flow.py | 48 +++++++++--------- tests/components/template/test_init.py | 49 ++++++++++++++++--- tests/components/template/test_number.py | 12 ++--- 7 files changed, 106 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index efa99342699..d3cfda2d4eb 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -7,9 +7,14 @@ import logging from homeassistant import config as conf_util from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_UNIQUE_ID, SERVICE_RELOAD +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_NAME, + CONF_UNIQUE_ID, + SERVICE_RELOAD, +) from homeassistant.core import Event, HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryError, HomeAssistantError from homeassistant.helpers import discovery from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_current_device, @@ -19,7 +24,7 @@ from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration -from .const import CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import CONF_MAX, CONF_MIN, CONF_STEP, CONF_TRIGGER, DOMAIN, PLATFORMS from .coordinator import TriggerUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -67,6 +72,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options.get(CONF_DEVICE_ID), ) + for key in (CONF_MAX, CONF_MIN, CONF_STEP): + if key not in entry.options: + continue + if isinstance(entry.options[key], str): + raise ConfigEntryError( + f"The '{entry.options.get(CONF_NAME) or ""}' number template needs to " + f"be reconfigured, {key} must be a number, got '{entry.options[key]}'" + ) + await hass.config_entries.async_forward_entry_setups( entry, (entry.options["template_type"],) ) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 2c12a0d03e9..ba4f4a78f53 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -107,15 +107,15 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: if domain == Platform.NUMBER: schema |= { vol.Required(CONF_STATE): selector.TemplateSelector(), - vol.Required( - CONF_MIN, default=f"{{{{{DEFAULT_MIN_VALUE}}}}}" - ): selector.TemplateSelector(), - vol.Required( - CONF_MAX, default=f"{{{{{DEFAULT_MAX_VALUE}}}}}" - ): selector.TemplateSelector(), - vol.Required( - CONF_STEP, default=f"{{{{{DEFAULT_STEP}}}}}" - ): selector.TemplateSelector(), + vol.Required(CONF_MIN, default=DEFAULT_MIN_VALUE): selector.NumberSelector( + selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX), + ), + vol.Required(CONF_MAX, default=DEFAULT_MAX_VALUE): selector.NumberSelector( + selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX), + ), + vol.Required(CONF_STEP, default=DEFAULT_STEP): selector.NumberSelector( + selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX), + ), vol.Optional(CONF_SET_VALUE): selector.ActionSelector(), } diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 8b4e46ba383..89df87b4031 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -28,11 +28,14 @@ PLATFORMS = [ Platform.WEATHER, ] -CONF_AVAILABILITY = "availability" -CONF_ATTRIBUTES = "attributes" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" +CONF_ATTRIBUTES = "attributes" +CONF_AVAILABILITY = "availability" +CONF_MAX = "max" +CONF_MIN = "min" +CONF_OBJECT_ID = "object_id" CONF_PICTURE = "picture" CONF_PRESS = "press" -CONF_OBJECT_ID = "object_id" +CONF_STEP = "step" CONF_TURN_OFF = "turn_off" CONF_TURN_ON = "turn_on" diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 499ddc192cc..e051f124149 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -31,7 +31,7 @@ from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import DOMAIN +from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN from .template_entity import ( TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_ICON_SCHEMA, @@ -42,9 +42,6 @@ from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) CONF_SET_VALUE = "set_value" -CONF_MIN = "min" -CONF_MAX = "max" -CONF_STEP = "step" DEFAULT_NAME = "Template Number" DEFAULT_OPTIMISTIC = False diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index f8ab190e664..ee748ce41f5 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -98,9 +98,9 @@ from tests.typing import WebSocketGenerator {"one": "30.0", "two": "20.0"}, {}, { - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": "0", + "max": "100", + "step": "0.1", "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -108,9 +108,9 @@ from tests.typing import WebSocketGenerator }, }, { - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": 0, + "max": 100, + "step": 0.1, "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -258,14 +258,14 @@ async def test_config_flow( "number", {"state": "{{ states('number.one') }}"}, { - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": "0", + "max": "100", + "step": "0.1", }, { - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": 0, + "max": 100, + "step": 0.1, }, ), ( @@ -451,9 +451,9 @@ def get_suggested(schema, key): ["30.0", "20.0"], {"one": "30.0", "two": "20.0"}, { - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": 0, + "max": 100, + "step": 0.1, "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -461,9 +461,9 @@ def get_suggested(schema, key): }, }, { - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": 0, + "max": 100, + "step": 0.1, "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -1230,14 +1230,14 @@ async def test_option_flow_sensor_preview_config_entry_removed( "number", {"state": "{{ states('number.one') }}"}, { - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": 0, + "max": 100, + "step": 0.1, }, { - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": 0, + "max": 100, + "step": 0.1, }, ), ( diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index 3b4db4bf668..0de57062984 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -319,9 +319,9 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None: "template_type": "number", "name": "My template", "state": "{{ 10 }}", - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": 0, + "max": 100, + "step": 0.1, "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -330,9 +330,9 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None: }, { "state": "{{ 11 }}", - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": 0, + "max": 100, + "step": 0.1, "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -454,3 +454,40 @@ async def test_change_device( ) == [] ) + + +async def test_fail_non_numerical_number_settings( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that non numerical number options causes config entry setup to fail. + + Support for non numerical max, min and step was added in HA Core 2024.9.0 and + removed in HA Core 2024.9.1. + """ + + options = { + "template_type": "number", + "name": "My template", + "state": "{{ 10 }}", + "min": "{{ 0 }}", + "max": "{{ 100 }}", + "step": "{{ 0.1 }}", + "set_value": { + "action": "input_number.set_value", + "target": {"entity_id": "input_number.test"}, + "data": {"value": "{{ value }}"}, + }, + } + # Setup the config entry + template_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options=options, + title="Template", + ) + template_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(template_config_entry.entry_id) + assert ( + "The 'My template' number template needs to be reconfigured, " + "max must be a number, got '{{ 100 }}'" in caplog.text + ) diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index fdca94d9fa4..43decf848ff 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -58,9 +58,9 @@ async def test_setup_config_entry( "name": "My template", "template_type": "number", "state": "{{ 10 }}", - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": 0, + "max": 100, + "step": 0.1, "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -524,9 +524,9 @@ async def test_device_id( "name": "My template", "template_type": "number", "state": "{{ 10 }}", - "min": "{{ 0 }}", - "max": "{{ 100 }}", - "step": "{{ 0.1 }}", + "min": 0, + "max": 100, + "step": 0.1, "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, From f80acdada04b797696ebe38b5c947bf3974d61b1 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 6 Sep 2024 08:08:40 +0200 Subject: [PATCH 0298/1309] Bump ruff to 0.6.4 (#125385) * Bump ruff to 0.6.4 * fix Dockerfile --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab5e59139cf..d87ccf93aa7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.2 + rev: v0.6.4 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 0c8d2b3796b..1407fda02b5 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.6.2 +ruff==0.6.4 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index a37fa9c57fc..571ae6a7181 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.2.27,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.1 tqdm==4.66.4 ruff==0.6.2 \ + stdlib-list==0.10.0 pipdeptree==2.23.1 tqdm==4.66.4 ruff==0.6.4 \ PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.9.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From a341bfd8cafecedbf382432a629e7da246f91db9 Mon Sep 17 00:00:00 2001 From: TimL Date: Fri, 6 Sep 2024 16:11:50 +1000 Subject: [PATCH 0299/1309] Add binary_sensor platform for Smlight integration (#125284) * Support binary_sensors for SMLight integration * Add strings for binary sensors * Add tests for binary_sensor platform * Update binary sensor docstring Co-authored-by: Shay Levy * Regenerate snapshot --------- Co-authored-by: Shay Levy Co-authored-by: Tim Lunn --- homeassistant/components/smlight/__init__.py | 1 + .../components/smlight/binary_sensor.py | 80 ++++++++++++++++ homeassistant/components/smlight/strings.json | 8 ++ .../smlight/snapshots/test_binary_sensor.ambr | 95 +++++++++++++++++++ .../components/smlight/test_binary_sensor.py | 52 ++++++++++ 5 files changed, 236 insertions(+) create mode 100644 homeassistant/components/smlight/binary_sensor.py create mode 100644 tests/components/smlight/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/smlight/test_binary_sensor.py diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 47dc943423e..4f0f2c0fb02 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant from .coordinator import SmDataUpdateCoordinator PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, ] diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py new file mode 100644 index 00000000000..b010c3f7cbd --- /dev/null +++ b/homeassistant/components/smlight/binary_sensor.py @@ -0,0 +1,80 @@ +"""Support for SLZB-06 binary sensors.""" + +from __future__ import annotations + +from _collections_abc import Callable +from dataclasses import dataclass + +from pysmlight import Sensors + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import SmDataUpdateCoordinator +from .entity import SmEntity + + +@dataclass(frozen=True, kw_only=True) +class SmBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing SMLIGHT binary sensor entities.""" + + value_fn: Callable[[Sensors], bool] + + +SENSORS = [ + SmBinarySensorEntityDescription( + key="ethernet", + translation_key="ethernet", + value_fn=lambda x: x.ethernet, + ), + SmBinarySensorEntityDescription( + key="wifi", + translation_key="wifi", + entity_registry_enabled_default=False, + value_fn=lambda x: x.wifi_connected, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SMLIGHT sensor based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + SmBinarySensorEntity(coordinator, description) for description in SENSORS + ) + + +class SmBinarySensorEntity(SmEntity, BinarySensorEntity): + """Representation of a slzb binary sensor.""" + + entity_description: SmBinarySensorEntityDescription + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: SmDataUpdateCoordinator, + description: SmBinarySensorEntityDescription, + ) -> None: + """Initialize slzb binary sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data.sensors) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index f22966df904..7e17a53a38a 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -42,6 +42,14 @@ } }, "entity": { + "binary_sensor": { + "ethernet": { + "name": "Ethernet" + }, + "wifi": { + "name": "Wi-Fi" + } + }, "sensor": { "zigbee_temperature": { "name": "Zigbee chip temp" diff --git a/tests/components/smlight/snapshots/test_binary_sensor.ambr b/tests/components/smlight/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..5ea936f9647 --- /dev/null +++ b/tests/components/smlight/snapshots/test_binary_sensor.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_all_binary_sensors[binary_sensor.mock_title_ethernet-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mock_title_ethernet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ethernet', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ethernet', + 'unique_id': 'aa:bb:cc:dd:ee:ff_ethernet', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensors[binary_sensor.mock_title_ethernet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Mock Title Ethernet', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_title_ethernet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_binary_sensors[binary_sensor.mock_title_wi_fi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mock_title_wi_fi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi', + 'unique_id': 'aa:bb:cc:dd:ee:ff_wifi', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensors[binary_sensor.mock_title_wi_fi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Mock Title Wi-Fi', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_title_wi_fi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smlight/test_binary_sensor.py b/tests/components/smlight/test_binary_sensor.py new file mode 100644 index 00000000000..ddf9b01bf16 --- /dev/null +++ b/tests/components/smlight/test_binary_sensor.py @@ -0,0 +1,52 @@ +"""Tests for the SMLIGHT binary sensor platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +pytestmark = [ + pytest.mark.usefixtures( + "mock_smlight_client", + ) +] + + +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms, which should be loaded during the test.""" + return [Platform.BINARY_SENSOR] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_binary_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the SMLIGHT binary sensors.""" + entry = await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_disabled_by_default_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test wifi sensor is disabled by default .""" + await setup_integration(hass, mock_config_entry) + + assert not hass.states.get("binary_sensor.mock_title_wi_fi") + + assert (entry := entity_registry.async_get("binary_sensor.mock_title_wi_fi")) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION From 0e515b2e1f57d70a50a656ec9b43b4ad645e0de3 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 6 Sep 2024 08:29:49 +0200 Subject: [PATCH 0300/1309] Bump pypck to 0.7.22 (#125389) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index f8b7d02b103..9023941277f 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.21", "lcn-frontend==0.1.6"] + "requirements": ["pypck==0.7.22", "lcn-frontend==0.1.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a05bcdde77..42fed6f5b5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2124,7 +2124,7 @@ pyownet==0.10.0.post1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.7.21 +pypck==0.7.22 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98a26141f1a..d0f2b31e1eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1705,7 +1705,7 @@ pyoverkiz==1.13.14 pyownet==0.10.0.post1 # homeassistant.components.lcn -pypck==0.7.21 +pypck==0.7.22 # homeassistant.components.pjlink pypjlink2==1.2.1 From b025942a14bf484feb81a60685bdb1e773150154 Mon Sep 17 00:00:00 2001 From: Dave Leaver Date: Fri, 6 Sep 2024 19:06:33 +1200 Subject: [PATCH 0301/1309] Fix controlling AC temperature in airtouch5 (#125394) Fix controlling AC temperature --- homeassistant/components/airtouch5/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airtouch5/climate.py b/homeassistant/components/airtouch5/climate.py index 2d5740b1837..dfc34c1beaf 100644 --- a/homeassistant/components/airtouch5/climate.py +++ b/homeassistant/components/airtouch5/climate.py @@ -262,7 +262,7 @@ class Airtouch5AC(Airtouch5ClimateEntity): _LOGGER.debug("Argument `temperature` is missing in set_temperature") return - await self._control(temp=temp) + await self._control(setpoint=SetpointControl.CHANGE_SETPOINT, temp=temp) class Airtouch5Zone(Airtouch5ClimateEntity): From 187a38c91fea8f8bda798e15cae53bee2ee48066 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 6 Sep 2024 09:51:11 +0200 Subject: [PATCH 0302/1309] Add tests for LCN actions / services (#125391) * Add tests for services/actions * Add snapshots for services/actions * Use constants for service names and parameters * Remove snapshot names --- homeassistant/components/lcn/services.py | 46 +- .../lcn/snapshots/test_services.ambr | 203 +++++++++ tests/components/lcn/test_services.py | 425 ++++++++++++++++++ 3 files changed, 661 insertions(+), 13 deletions(-) create mode 100644 tests/components/lcn/snapshots/test_services.ambr create mode 100644 tests/components/lcn/test_services.py diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 49b54fc0c8d..611a7353bcd 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -1,5 +1,7 @@ """Service calls related dependencies for LCN component.""" +from enum import StrEnum, auto + import pypck import voluptuous as vol @@ -394,18 +396,36 @@ class Pck(LcnServiceCall): await device_connection.pck(pck) +class LcnService(StrEnum): + """LCN service names.""" + + OUTPUT_ABS = auto() + OUTPUT_REL = auto() + OUTPUT_TOGGLE = auto() + RELAYS = auto() + VAR_ABS = auto() + VAR_RESET = auto() + VAR_REL = auto() + LOCK_REGULATOR = auto() + LED = auto() + SEND_KEYS = auto() + LOCK_KEYS = auto() + DYN_TEXT = auto() + PCK = auto() + + SERVICES = ( - ("output_abs", OutputAbs), - ("output_rel", OutputRel), - ("output_toggle", OutputToggle), - ("relays", Relays), - ("var_abs", VarAbs), - ("var_reset", VarReset), - ("var_rel", VarRel), - ("lock_regulator", LockRegulator), - ("led", Led), - ("send_keys", SendKeys), - ("lock_keys", LockKeys), - ("dyn_text", DynText), - ("pck", Pck), + (LcnService.OUTPUT_ABS, OutputAbs), + (LcnService.OUTPUT_REL, OutputRel), + (LcnService.OUTPUT_TOGGLE, OutputToggle), + (LcnService.RELAYS, Relays), + (LcnService.VAR_ABS, VarAbs), + (LcnService.VAR_RESET, VarReset), + (LcnService.VAR_REL, VarRel), + (LcnService.LOCK_REGULATOR, LockRegulator), + (LcnService.LED, Led), + (LcnService.SEND_KEYS, SendKeys), + (LcnService.LOCK_KEYS, LockKeys), + (LcnService.DYN_TEXT, DynText), + (LcnService.PCK, Pck), ) diff --git a/tests/components/lcn/snapshots/test_services.ambr b/tests/components/lcn/snapshots/test_services.ambr new file mode 100644 index 00000000000..29e8da72fd7 --- /dev/null +++ b/tests/components/lcn/snapshots/test_services.ambr @@ -0,0 +1,203 @@ +# serializer version: 1 +# name: test_service_dyn_text + tuple( + 0, + 'text in row 1', + ) +# --- +# name: test_service_led + tuple( + , + , + ) +# --- +# name: test_service_lock_keys + tuple( + 0, + list([ + , + , + , + , + , + , + , + , + ]), + ) +# --- +# name: test_service_lock_keys_tab_a_temporary + tuple( + 10, + , + list([ + , + , + , + , + , + , + , + , + ]), + ) +# --- +# name: test_service_lock_regulator + tuple( + 0, + True, + ) +# --- +# name: test_service_output_abs + tuple( + 0, + 100, + 9, + ) +# --- +# name: test_service_output_rel + tuple( + 0, + 25, + ) +# --- +# name: test_service_output_toggle + tuple( + 0, + 9, + ) +# --- +# name: test_service_pck + tuple( + 'PIN4', + ) +# --- +# name: test_service_relays + tuple( + list([ + , + , + , + , + , + , + , + , + ]), + ) +# --- +# name: test_service_send_keys + tuple( + list([ + list([ + True, + False, + False, + False, + True, + False, + False, + False, + ]), + list([ + False, + False, + False, + False, + False, + False, + False, + False, + ]), + list([ + False, + False, + False, + False, + False, + False, + False, + False, + ]), + list([ + False, + False, + False, + False, + False, + False, + False, + True, + ]), + ]), + , + ) +# --- +# name: test_service_send_keys_hit_deferred + tuple( + list([ + list([ + True, + False, + False, + False, + True, + False, + False, + False, + ]), + list([ + False, + False, + False, + False, + False, + False, + False, + False, + ]), + list([ + False, + False, + False, + False, + False, + False, + False, + False, + ]), + list([ + False, + False, + False, + False, + False, + False, + False, + True, + ]), + ]), + 5, + , + ) +# --- +# name: test_service_var_abs + tuple( + , + 75.0, + , + ) +# --- +# name: test_service_var_rel + tuple( + , + 10.0, + , + , + ) +# --- +# name: test_service_var_reset + tuple( + , + ) +# --- diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py new file mode 100644 index 00000000000..9cb53289065 --- /dev/null +++ b/tests/components/lcn/test_services.py @@ -0,0 +1,425 @@ +"""Test for the LCN services.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.lcn import DOMAIN +from homeassistant.components.lcn.const import ( + CONF_KEYS, + CONF_LED, + CONF_OUTPUT, + CONF_PCK, + CONF_RELVARREF, + CONF_ROW, + CONF_SETPOINT, + CONF_TABLE, + CONF_TEXT, + CONF_TIME, + CONF_TIME_UNIT, + CONF_TRANSITION, + CONF_VALUE, + CONF_VARIABLE, +) +from homeassistant.components.lcn.services import LcnService +from homeassistant.const import ( + CONF_ADDRESS, + CONF_BRIGHTNESS, + CONF_STATE, + CONF_UNIT_OF_MEASUREMENT, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import MockModuleConnection, MockPchkConnectionManager, setup_component + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_output_abs( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test output_abs service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "dim_output") as dim_output: + await hass.services.async_call( + DOMAIN, + LcnService.OUTPUT_ABS, + { + CONF_ADDRESS: "pchk.s0.m7", + CONF_OUTPUT: "output1", + CONF_BRIGHTNESS: 100, + CONF_TRANSITION: 5, + }, + blocking=True, + ) + + assert dim_output.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_output_rel( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test output_rel service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "rel_output") as rel_output: + await hass.services.async_call( + DOMAIN, + LcnService.OUTPUT_REL, + { + CONF_ADDRESS: "pchk.s0.m7", + CONF_OUTPUT: "output1", + CONF_BRIGHTNESS: 25, + }, + blocking=True, + ) + + assert rel_output.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_output_toggle( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test output_toggle service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "toggle_output") as toggle_output: + await hass.services.async_call( + DOMAIN, + LcnService.OUTPUT_TOGGLE, + { + CONF_ADDRESS: "pchk.s0.m7", + CONF_OUTPUT: "output1", + CONF_TRANSITION: 5, + }, + blocking=True, + ) + + assert toggle_output.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_relays(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test relays service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "control_relays") as control_relays: + await hass.services.async_call( + DOMAIN, + LcnService.RELAYS, + {CONF_ADDRESS: "pchk.s0.m7", CONF_STATE: "0011TT--"}, + blocking=True, + ) + + assert control_relays.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_led(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test led service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "control_led") as control_led: + await hass.services.async_call( + DOMAIN, + LcnService.LED, + {CONF_ADDRESS: "pchk.s0.m7", CONF_LED: "led6", CONF_STATE: "blink"}, + blocking=True, + ) + + assert control_led.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_var_abs( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test var_abs service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "var_abs") as var_abs: + await hass.services.async_call( + DOMAIN, + LcnService.VAR_ABS, + { + CONF_ADDRESS: "pchk.s0.m7", + CONF_VARIABLE: "var1", + CONF_VALUE: 75, + CONF_UNIT_OF_MEASUREMENT: "%", + }, + blocking=True, + ) + + assert var_abs.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_var_rel( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test var_rel service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "var_rel") as var_rel: + await hass.services.async_call( + DOMAIN, + LcnService.VAR_REL, + { + CONF_ADDRESS: "pchk.s0.m7", + CONF_VARIABLE: "var1", + CONF_VALUE: 10, + CONF_UNIT_OF_MEASUREMENT: "%", + CONF_RELVARREF: "current", + }, + blocking=True, + ) + + assert var_rel.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_var_reset( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test var_reset service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "var_reset") as var_reset: + await hass.services.async_call( + DOMAIN, + LcnService.VAR_RESET, + {CONF_ADDRESS: "pchk.s0.m7", CONF_VARIABLE: "var1"}, + blocking=True, + ) + + assert var_reset.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_lock_regulator( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test lock_regulator service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: + await hass.services.async_call( + DOMAIN, + LcnService.LOCK_REGULATOR, + { + CONF_ADDRESS: "pchk.s0.m7", + CONF_SETPOINT: "r1varsetpoint", + CONF_STATE: True, + }, + blocking=True, + ) + + assert lock_regulator.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_send_keys( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test send_keys service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "send_keys") as send_keys: + await hass.services.async_call( + DOMAIN, + LcnService.SEND_KEYS, + {CONF_ADDRESS: "pchk.s0.m7", CONF_KEYS: "a1a5d8", CONF_STATE: "hit"}, + blocking=True, + ) + + keys = [[False] * 8 for i in range(4)] + keys[0][0] = True + keys[0][4] = True + keys[3][7] = True + + assert send_keys.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_send_keys_hit_deferred( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test send_keys (hit_deferred) service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + keys = [[False] * 8 for i in range(4)] + keys[0][0] = True + keys[0][4] = True + keys[3][7] = True + + # success + with patch.object( + MockModuleConnection, "send_keys_hit_deferred" + ) as send_keys_hit_deferred: + await hass.services.async_call( + DOMAIN, + LcnService.SEND_KEYS, + { + CONF_ADDRESS: "pchk.s0.m7", + CONF_KEYS: "a1a5d8", + CONF_TIME: 5, + CONF_TIME_UNIT: "s", + }, + blocking=True, + ) + + assert send_keys_hit_deferred.await_args.args == snapshot() + + # wrong key action + with ( + patch.object( + MockModuleConnection, "send_keys_hit_deferred" + ) as send_keys_hit_deferred, + pytest.raises(ValueError), + ): + await hass.services.async_call( + DOMAIN, + LcnService.SEND_KEYS, + { + CONF_ADDRESS: "pchk.s0.m7", + CONF_KEYS: "a1a5d8", + CONF_STATE: "make", + CONF_TIME: 5, + CONF_TIME_UNIT: "s", + }, + blocking=True, + ) + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_lock_keys( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test lock_keys service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "lock_keys") as lock_keys: + await hass.services.async_call( + DOMAIN, + LcnService.LOCK_KEYS, + {CONF_ADDRESS: "pchk.s0.m7", CONF_TABLE: "a", CONF_STATE: "0011TT--"}, + blocking=True, + ) + + assert lock_keys.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_lock_keys_tab_a_temporary( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test lock_keys (tab_a_temporary) service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + # success + with patch.object( + MockModuleConnection, "lock_keys_tab_a_temporary" + ) as lock_keys_tab_a_temporary: + await hass.services.async_call( + DOMAIN, + LcnService.LOCK_KEYS, + { + CONF_ADDRESS: "pchk.s0.m7", + CONF_STATE: "0011TT--", + CONF_TIME: 10, + CONF_TIME_UNIT: "s", + }, + blocking=True, + ) + + assert lock_keys_tab_a_temporary.await_args.args == snapshot() + + # wrong table + with ( + patch.object( + MockModuleConnection, "lock_keys_tab_a_temporary" + ) as lock_keys_tab_a_temporary, + pytest.raises(ValueError), + ): + await hass.services.async_call( + DOMAIN, + LcnService.LOCK_KEYS, + { + CONF_ADDRESS: "pchk.s0.m7", + CONF_TABLE: "b", + CONF_STATE: "0011TT--", + CONF_TIME: 10, + CONF_TIME_UNIT: "s", + }, + blocking=True, + ) + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_dyn_text( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test dyn_text service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "dyn_text") as dyn_text: + await hass.services.async_call( + DOMAIN, + LcnService.DYN_TEXT, + {CONF_ADDRESS: "pchk.s0.m7", CONF_ROW: 1, CONF_TEXT: "text in row 1"}, + blocking=True, + ) + + assert dyn_text.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_pck(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test pck service.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "pck") as pck: + await hass.services.async_call( + DOMAIN, + LcnService.PCK, + {CONF_ADDRESS: "pchk.s0.m7", CONF_PCK: "PIN4"}, + blocking=True, + ) + + assert pck.await_args.args == snapshot() + + +@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_called_with_invalid_host_id(hass: HomeAssistant) -> None: + """Test service was called with non existing host id.""" + await async_setup_component(hass, "persistent_notification", {}) + await setup_component(hass) + + with patch.object(MockModuleConnection, "pck") as pck, pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + LcnService.PCK, + {CONF_ADDRESS: "foobar.s0.m7", CONF_PCK: "PIN4"}, + blocking=True, + ) + + pck.assert_not_awaited() From 0092796fd25536ebdcf55167c3bdc776419709da Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 6 Sep 2024 03:51:53 -0400 Subject: [PATCH 0303/1309] Add model ID to linkplay (#125370) --- homeassistant/components/linkplay/media_player.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 8b2fcf5d52f..b1fa0e2a5c5 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -28,7 +28,7 @@ from homeassistant.util.dt import utcnow from . import LinkPlayConfigEntry from .const import DOMAIN -from .utils import get_info_from_project +from .utils import MANUFACTURER_GENERIC, get_info_from_project _LOGGER = logging.getLogger(__name__) STATE_MAP: dict[PlayingStatus, MediaPlayerState] = { @@ -153,6 +153,9 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): ] manufacturer, model = get_info_from_project(bridge.device.properties["project"]) + if model != MANUFACTURER_GENERIC: + model_id = bridge.device.properties["project"] + self._attr_device_info = dr.DeviceInfo( configuration_url=bridge.endpoint, connections={(dr.CONNECTION_NETWORK_MAC, bridge.device.properties["MAC"])}, @@ -160,6 +163,7 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): identifiers={(DOMAIN, bridge.device.uuid)}, manufacturer=manufacturer, model=model, + model_id=model_id, name=bridge.device.name, sw_version=bridge.device.properties["firmware"], ) From 54c15e7e0a62fb1e5517a00f51df4d29ace1fdd8 Mon Sep 17 00:00:00 2001 From: TimL Date: Fri, 6 Sep 2024 18:20:12 +1000 Subject: [PATCH 0304/1309] Bump pysmlight to 0.0.14 (#125387) Bump pysmlight 0.0.14 for smlight --- 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 72d915666e5..1a91b29234c 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_polling", - "requirements": ["pysmlight==0.0.13"], + "requirements": ["pysmlight==0.0.14"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 42fed6f5b5e..82cabd124d8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2232,7 +2232,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.0.13 +pysmlight==0.0.14 # homeassistant.components.snmp pysnmp==6.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0f2b31e1eb..f0ac4294ada 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1786,7 +1786,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.0.13 +pysmlight==0.0.14 # homeassistant.components.snmp pysnmp==6.2.5 From 7752789c3abda110b864849c658a925c899bf7ee Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Fri, 6 Sep 2024 10:23:30 +0200 Subject: [PATCH 0305/1309] Increase coordinator update_interval for fyta (#125393) * Increase update_interval * Update homeassistant/components/fyta/coordinator.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/fyta/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index c92a96eed63..df607de76b0 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -39,7 +39,7 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, Plant]]): hass, _LOGGER, name="FYTA Coordinator", - update_interval=timedelta(seconds=60), + update_interval=timedelta(minutes=4), ) self.fyta = fyta From 1db68327f971166e3abdb96b78b9ac50c785f57a Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:33:01 +0200 Subject: [PATCH 0306/1309] Enable Ruff PTH for the script directory (#124441) * Enable Ruff PTH for the script directory * Address review comments * Fix translations script * Update script/hassfest/config_flow.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- pyproject.toml | 5 ++++ script/gen_requirements_all.py | 12 ++++----- script/hassfest/__main__.py | 17 +++++------- script/hassfest/bluetooth.py | 18 +++++-------- script/hassfest/codeowners.py | 17 +++++------- script/hassfest/config_flow.py | 38 +++++++++++--------------- script/hassfest/dhcp.py | 18 +++++-------- script/hassfest/docker.py | 8 +++--- script/hassfest/metadata.py | 3 +-- script/hassfest/mqtt.py | 16 +++++------ script/hassfest/ssdp.py | 16 +++++------ script/hassfest/usb.py | 18 +++++-------- script/hassfest/zeroconf.py | 18 +++++-------- script/inspect_schemas.py | 4 +-- script/lint_and_test.py | 11 ++++---- script/split_tests.py | 2 +- script/translations/download.py | 48 +++++++++++++++------------------ script/version_bump.py | 19 +++++-------- 18 files changed, 125 insertions(+), 163 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e2d5e213811..787813bd64a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -734,6 +734,7 @@ select = [ "PIE", # flake8-pie "PL", # pylint "PT", # flake8-pytest-style + "PTH", # flake8-pathlib "PYI", # flake8-pyi "RET", # flake8-return "RSE", # flake8-raise @@ -905,5 +906,9 @@ split-on-trailing-comma = false "homeassistant/scripts/*" = ["T201"] "script/*" = ["T20"] +# Temporary +"homeassistant/**" = ["PTH"] +"tests/**" = ["PTH"] + [tool.ruff.lint.mccabe] max-complexity = 25 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b2165289ad8..47a6412bcfd 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -6,7 +6,6 @@ from __future__ import annotations import difflib import importlib from operator import itemgetter -import os from pathlib import Path import pkgutil import re @@ -82,8 +81,8 @@ URL_PIN = ( ) -CONSTRAINT_PATH = os.path.join( - os.path.dirname(__file__), "../homeassistant/package_constraints.txt" +CONSTRAINT_PATH = ( + Path(__file__).parent.parent / "homeassistant" / "package_constraints.txt" ) CONSTRAINT_BASE = """ # Constrain pycryptodome to avoid vulnerability @@ -256,8 +255,7 @@ def explore_module(package: str, explore_children: bool) -> list[str]: def core_requirements() -> list[str]: """Gather core requirements out of pyproject.toml.""" - with open("pyproject.toml", "rb") as fp: - data = tomllib.load(fp) + data = tomllib.loads(Path("pyproject.toml").read_text()) dependencies: list[str] = data["project"]["dependencies"] return dependencies @@ -528,7 +526,7 @@ def diff_file(filename: str, content: str) -> list[str]: def main(validate: bool, ci: bool) -> int: """Run the script.""" - if not os.path.isfile("requirements_all.txt"): + if not Path("requirements_all.txt").is_file(): print("Run this from HA root dir") return 1 @@ -590,7 +588,7 @@ def main(validate: bool, ci: bool) -> int: def _get_hassfest_config() -> Config: """Get hassfest config.""" return Config( - root=Path(".").absolute(), + root=Path().absolute(), specific_integrations=None, action="validate", requirements=True, diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index b48871b4651..f0b9ad25dd0 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -4,7 +4,7 @@ from __future__ import annotations import argparse from operator import attrgetter -import pathlib +from pathlib import Path import sys from time import monotonic @@ -63,9 +63,9 @@ ALL_PLUGIN_NAMES = [ ] -def valid_integration_path(integration_path: pathlib.Path | str) -> pathlib.Path: +def valid_integration_path(integration_path: Path | str) -> Path: """Test if it's a valid integration.""" - path = pathlib.Path(integration_path) + path = Path(integration_path) if not path.is_dir(): raise argparse.ArgumentTypeError(f"{integration_path} is not a directory.") @@ -109,8 +109,8 @@ def get_config() -> Config: ) parser.add_argument( "--core-integrations-path", - type=pathlib.Path, - default=pathlib.Path("homeassistant/components"), + type=Path, + default=Path("homeassistant/components"), help="Path to core integrations", ) parsed = parser.parse_args() @@ -123,14 +123,11 @@ def get_config() -> Config: "Generate is not allowed when limiting to specific integrations" ) - if ( - not parsed.integration_path - and not pathlib.Path("requirements_all.txt").is_file() - ): + if not parsed.integration_path and not Path("requirements_all.txt").is_file(): raise RuntimeError("Run from Home Assistant root") return Config( - root=pathlib.Path(".").absolute(), + root=Path().absolute(), specific_integrations=parsed.integration_path, action=parsed.action, requirements=parsed.requirements, diff --git a/script/hassfest/bluetooth.py b/script/hassfest/bluetooth.py index 49480d1ed02..94f25588632 100644 --- a/script/hassfest/bluetooth.py +++ b/script/hassfest/bluetooth.py @@ -34,19 +34,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if config.specific_integrations: return - with open(str(bluetooth_path)) as fp: - current = fp.read() - if current != content: - config.add_error( - "bluetooth", - "File bluetooth.py is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) - return + if bluetooth_path.read_text() != content: + config.add_error( + "bluetooth", + "File bluetooth.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate bluetooth file.""" bluetooth_path = config.root / "homeassistant/generated/bluetooth.py" - with open(str(bluetooth_path), "w") as fp: - fp.write(f"{config.cache['bluetooth']}") + bluetooth_path.write_text(f"{config.cache['bluetooth']}") diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py index 04150836dd5..73ea8d02520 100644 --- a/script/hassfest/codeowners.py +++ b/script/hassfest/codeowners.py @@ -98,18 +98,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if config.specific_integrations: return - with open(str(codeowners_path)) as fp: - if fp.read().strip() != content: - config.add_error( - "codeowners", - "File CODEOWNERS is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) - return + if codeowners_path.read_text() != content + "\n": + config.add_error( + "codeowners", + "File CODEOWNERS is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate CODEOWNERS.""" codeowners_path = config.root / "CODEOWNERS" - with open(str(codeowners_path), "w") as fp: - fp.write(f"{config.cache['codeowners']}\n") + codeowners_path.write_text(f"{config.cache['codeowners']}\n") diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 382e77bde74..83d406a0036 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import pathlib from typing import Any from .brand import validate as validate_brands @@ -216,36 +215,31 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if config.specific_integrations: return - brands = Brand.load_dir(pathlib.Path(config.root / "homeassistant/brands"), config) + brands = Brand.load_dir(config.root / "homeassistant/brands", config) validate_brands(brands, integrations, config) - with open(str(config_flow_path)) as fp: - if fp.read() != content: - config.add_error( - "config_flow", - "File config_flows.py is not up to date. " - "Run python3 -m script.hassfest", - fixable=True, - ) + if config_flow_path.read_text() != content: + config.add_error( + "config_flow", + "File config_flows.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) config.cache["integrations"] = content = _generate_integrations( brands, integrations, config ) - with open(str(integrations_path)) as fp: - if fp.read() != content + "\n": - config.add_error( - "config_flow", - "File integrations.json is not up to date. " - "Run python3 -m script.hassfest", - fixable=True, - ) + if integrations_path.read_text() != content + "\n": + config.add_error( + "config_flow", + "File integrations.json is not up to date. " + "Run python3 -m script.hassfest", + fixable=True, + ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate config flow file.""" config_flow_path = config.root / "homeassistant/generated/config_flows.py" integrations_path = config.root / "homeassistant/generated/integrations.json" - with open(str(config_flow_path), "w") as fp: - fp.write(f"{config.cache['config_flow']}") - with open(str(integrations_path), "w") as fp: - fp.write(f"{config.cache['integrations']}\n") + config_flow_path.write_text(f"{config.cache['config_flow']}") + integrations_path.write_text(f"{config.cache['integrations']}\n") diff --git a/script/hassfest/dhcp.py b/script/hassfest/dhcp.py index d1fd0474430..8a8f344f6cb 100644 --- a/script/hassfest/dhcp.py +++ b/script/hassfest/dhcp.py @@ -32,19 +32,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if config.specific_integrations: return - with open(str(dhcp_path)) as fp: - current = fp.read() - if current != content: - config.add_error( - "dhcp", - "File dhcp.py is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) - return + if dhcp_path.read_text() != content: + config.add_error( + "dhcp", + "File dhcp.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate dhcp file.""" dhcp_path = config.root / "homeassistant/generated/dhcp.py" - with open(str(dhcp_path), "w") as fp: - fp.write(f"{config.cache['dhcp']}") + dhcp_path.write_text(f"{config.cache['dhcp']}") diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index bce77e1ece0..5809ea4afa0 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -103,9 +103,9 @@ LABEL "com.github.actions.color"="gray-dark" """ -def _get_package_versions(file: str, packages: set[str]) -> dict[str, str]: +def _get_package_versions(file: Path, packages: set[str]) -> dict[str, str]: package_versions: dict[str, str] = {} - with open(file, encoding="UTF-8") as fp: + with file.open(encoding="UTF-8") as fp: for _, line in enumerate(fp): if package_versions.keys() == packages: return package_versions @@ -173,10 +173,10 @@ def _generate_files(config: Config) -> list[File]: ) * 1000 package_versions = _get_package_versions( - "requirements_test.txt", {"pipdeptree", "tqdm", "uv"} + Path("requirements_test.txt"), {"pipdeptree", "tqdm", "uv"} ) package_versions |= _get_package_versions( - "requirements_test_pre_commit.txt", {"ruff"} + Path("requirements_test_pre_commit.txt"), {"ruff"} ) return [ diff --git a/script/hassfest/metadata.py b/script/hassfest/metadata.py index bd3ac4514e7..0768e875016 100644 --- a/script/hassfest/metadata.py +++ b/script/hassfest/metadata.py @@ -10,8 +10,7 @@ from .model import Config, Integration def validate(integrations: dict[str, Integration], config: Config) -> None: """Validate project metadata keys.""" metadata_path = config.root / "pyproject.toml" - with open(metadata_path, "rb") as fp: - data = tomllib.load(fp) + data = tomllib.loads(metadata_path.read_text()) try: if data["project"]["version"] != __version__: diff --git a/script/hassfest/mqtt.py b/script/hassfest/mqtt.py index b2112d9bb6a..54ee65aaa35 100644 --- a/script/hassfest/mqtt.py +++ b/script/hassfest/mqtt.py @@ -33,17 +33,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if config.specific_integrations: return - with open(str(mqtt_path)) as fp: - if fp.read() != content: - config.add_error( - "mqtt", - "File mqtt.py is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) + if mqtt_path.read_text() != content: + config.add_error( + "mqtt", + "File mqtt.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate MQTT file.""" mqtt_path = config.root / "homeassistant/generated/mqtt.py" - with open(str(mqtt_path), "w") as fp: - fp.write(f"{config.cache['mqtt']}") + mqtt_path.write_text(f"{config.cache['mqtt']}") diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index 0a61284eb46..989b614e43d 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -33,17 +33,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if config.specific_integrations: return - with open(str(ssdp_path)) as fp: - if fp.read() != content: - config.add_error( - "ssdp", - "File ssdp.py is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) + if ssdp_path.read_text() != content: + config.add_error( + "ssdp", + "File ssdp.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate ssdp file.""" ssdp_path = config.root / "homeassistant/generated/ssdp.py" - with open(str(ssdp_path), "w") as fp: - fp.write(f"{config.cache['ssdp']}") + ssdp_path.write_text(f"{config.cache['ssdp']}") diff --git a/script/hassfest/usb.py b/script/hassfest/usb.py index 84cafc973ad..c34f4fd1b62 100644 --- a/script/hassfest/usb.py +++ b/script/hassfest/usb.py @@ -35,19 +35,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if config.specific_integrations: return - with open(str(usb_path)) as fp: - current = fp.read() - if current != content: - config.add_error( - "usb", - "File usb.py is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) - return + if usb_path.read_text() != content: + config.add_error( + "usb", + "File usb.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate usb file.""" usb_path = config.root / "homeassistant/generated/usb.py" - with open(str(usb_path), "w") as fp: - fp.write(f"{config.cache['usb']}") + usb_path.write_text(f"{config.cache['usb']}") diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index 63f10fcf294..48fcc0a4589 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -90,19 +90,15 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: if config.specific_integrations: return - with open(str(zeroconf_path)) as fp: - current = fp.read() - if current != content: - config.add_error( - "zeroconf", - "File zeroconf.py is not up to date. Run python3 -m script.hassfest", - fixable=True, - ) - return + if zeroconf_path.read_text() != content: + config.add_error( + "zeroconf", + "File zeroconf.py is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) def generate(integrations: dict[str, Integration], config: Config) -> None: """Generate zeroconf file.""" zeroconf_path = config.root / "homeassistant/generated/zeroconf.py" - with open(str(zeroconf_path), "w") as fp: - fp.write(f"{config.cache['zeroconf']}") + zeroconf_path.write_text(f"{config.cache['zeroconf']}") diff --git a/script/inspect_schemas.py b/script/inspect_schemas.py index a8ffe0afb60..fa6707e93b2 100755 --- a/script/inspect_schemas.py +++ b/script/inspect_schemas.py @@ -2,7 +2,7 @@ """Inspect all component SCHEMAS.""" import importlib -import os +from pathlib import Path import pkgutil from homeassistant.config import _identify_config_schema @@ -20,7 +20,7 @@ def explore_module(package): def main(): """Run the script.""" - if not os.path.isfile("requirements_all.txt"): + if not Path("requirements_all.txt").is_file(): print("Run this from HA root dir") return diff --git a/script/lint_and_test.py b/script/lint_and_test.py index ff3db8aa1ed..fb350c113b9 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -9,6 +9,7 @@ from collections import namedtuple from contextlib import suppress import itertools import os +from pathlib import Path import re import shlex import sys @@ -63,7 +64,7 @@ async def async_exec(*args, display=False): """Execute, return code & log.""" argsp = [] for arg in args: - if os.path.isfile(arg): + if Path(arg).is_file(): argsp.append(f"\\\n {shlex.quote(arg)}") else: argsp.append(shlex.quote(arg)) @@ -132,7 +133,7 @@ async def ruff(files): async def lint(files): """Perform lint.""" - files = [file for file in files if os.path.isfile(file)] + files = [file for file in files if Path(file).is_file()] res = sorted( itertools.chain( *await asyncio.gather( @@ -164,7 +165,7 @@ async def lint(files): async def main(): """Run the main loop.""" # Ensure we are in the homeassistant root - os.chdir(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) + os.chdir(Path(__file__).parent.parent) files = await git() if not files: @@ -194,7 +195,7 @@ async def main(): gen_req = True # requirements script for components # Find test files... if fname.startswith("tests/"): - if "/test_" in fname and os.path.isfile(fname): + if "/test_" in fname and Path(fname).is_file(): # All test helpers should be excluded test_files.add(fname) else: @@ -207,7 +208,7 @@ async def main(): else: parts[-1] = f"test_{parts[-1]}" fname = "/".join(parts) - if os.path.isfile(fname): + if Path(fname).is_file(): test_files.add(fname) if gen_req: diff --git a/script/split_tests.py b/script/split_tests.py index 8da03bd749b..e124f722552 100755 --- a/script/split_tests.py +++ b/script/split_tests.py @@ -66,7 +66,7 @@ class BucketHolder: def create_ouput_file(self) -> None: """Create output file.""" - with open("pytest_buckets.txt", "w") as file: + with Path("pytest_buckets.txt").open("w") as file: for idx, bucket in enumerate(self._buckets): print(f"Bucket {idx+1} has {bucket.total_tests} tests") file.write(bucket.get_paths_line()) diff --git a/script/translations/download.py b/script/translations/download.py index 8f7327c07ec..756de46fb61 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -4,8 +4,7 @@ from __future__ import annotations import json -import os -import pathlib +from pathlib import Path import re import subprocess @@ -14,7 +13,7 @@ from .error import ExitApp from .util import get_lokalise_token, load_json_from_path FILENAME_FORMAT = re.compile(r"strings\.(?P\w+)\.json") -DOWNLOAD_DIR = pathlib.Path("build/translations-download").absolute() +DOWNLOAD_DIR = Path("build/translations-download").absolute() def run_download_docker(): @@ -56,35 +55,32 @@ def run_download_docker(): raise ExitApp("Failed to download translations") -def save_json(filename: str, data: list | dict): - """Save JSON data to a file. - - Returns True on success. - """ - data = json.dumps(data, sort_keys=True, indent=4) - with open(filename, "w", encoding="utf-8") as fdesc: - fdesc.write(data) - return True - return False +def save_json(filename: Path, data: list | dict) -> None: + """Save JSON data to a file.""" + filename.write_text(json.dumps(data, sort_keys=True, indent=4), encoding="utf-8") -def get_component_path(lang, component): +def get_component_path(lang, component) -> Path | None: """Get the component translation path.""" - if os.path.isdir(os.path.join("homeassistant", "components", component)): - return os.path.join( - "homeassistant", "components", component, "translations", f"{lang}.json" + if (Path("homeassistant") / "components" / component).is_dir(): + return ( + Path("homeassistant") + / "components" + / component + / "translations" + / f"{lang}.json" ) return None -def get_platform_path(lang, component, platform): +def get_platform_path(lang, component, platform) -> Path: """Get the platform translation path.""" - return os.path.join( - "homeassistant", - "components", - component, - "translations", - f"{platform}.{lang}.json", + return ( + Path("homeassistant") + / "components" + / component + / "translations" + / f"{platform}.{lang}.json" ) @@ -107,7 +103,7 @@ def save_language_translations(lang, translations): f"Skipping {lang} for {component}, as the integration doesn't seem to exist." ) continue - os.makedirs(os.path.dirname(path), exist_ok=True) + path.parent.mkdir(parents=True, exist_ok=True) save_json(path, base_translations) if "platform" not in component_translations: @@ -117,7 +113,7 @@ def save_language_translations(lang, translations): "platform" ].items(): path = get_platform_path(lang, component, platform) - os.makedirs(os.path.dirname(path), exist_ok=True) + path.parent.mkdir(parents=True, exist_ok=True) save_json(path, platform_translations) diff --git a/script/version_bump.py b/script/version_bump.py index fb4fe2f7868..ff94c01a5a2 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -2,6 +2,7 @@ """Helper script to bump the current version.""" import argparse +from pathlib import Path import re import subprocess @@ -110,8 +111,7 @@ def bump_version( def write_version(version): """Update Home Assistant constant file with new version.""" - with open("homeassistant/const.py") as fil: - content = fil.read() + content = Path("homeassistant/const.py").read_text() major, minor, patch = str(version).split(".", 2) @@ -125,25 +125,21 @@ def write_version(version): "PATCH_VERSION: Final = .*\n", f'PATCH_VERSION: Final = "{patch}"\n', content ) - with open("homeassistant/const.py", "w") as fil: - fil.write(content) + Path("homeassistant/const.py").write_text(content) def write_version_metadata(version: Version) -> None: """Update pyproject.toml file with new version.""" - with open("pyproject.toml", encoding="utf8") as fp: - content = fp.read() + content = Path("pyproject.toml").read_text(encoding="utf8") content = re.sub(r"(version\W+=\W).+\n", f'\\g<1>"{version}"\n', content, count=1) - with open("pyproject.toml", "w", encoding="utf8") as fp: - fp.write(content) + Path("pyproject.toml").write_text(content, encoding="utf8") def write_ci_workflow(version: Version) -> None: """Update ci workflow with new version.""" - with open(".github/workflows/ci.yaml") as fp: - content = fp.read() + content = Path(".github/workflows/ci.yaml").read_text() short_version = ".".join(str(version).split(".", maxsplit=2)[:2]) content = re.sub( @@ -153,8 +149,7 @@ def write_ci_workflow(version: Version) -> None: count=1, ) - with open(".github/workflows/ci.yaml", "w") as fp: - fp.write(content) + Path(".github/workflows/ci.yaml").write_text(content) def main() -> None: From 84dcfb6ddc79e353e620ef1925a5258ab5ec9d88 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:45:13 +0200 Subject: [PATCH 0307/1309] Replace SW version by model ID in renault device info (#125399) * Replace SW_VERSION by MODEL_ID in renault device info * Simplify PR * Fix tests --- .../components/renault/renault_hub.py | 4 +-- .../components/renault/renault_vehicle.py | 2 +- tests/components/renault/__init__.py | 4 +-- tests/components/renault/const.py | 10 +++--- .../renault/snapshots/test_binary_sensor.ambr | 32 +++++++++---------- .../renault/snapshots/test_button.ambr | 32 +++++++++---------- .../snapshots/test_device_tracker.ambr | 32 +++++++++---------- .../renault/snapshots/test_select.ambr | 32 +++++++++---------- .../renault/snapshots/test_sensor.ambr | 32 +++++++++---------- tests/components/renault/test_services.py | 4 +-- 10 files changed, 92 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index 97a9d080b86..76b197b2aaf 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -16,8 +16,8 @@ from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, + ATTR_MODEL_ID, ATTR_NAME, - ATTR_SW_VERSION, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -106,7 +106,7 @@ class RenaultHub: manufacturer=vehicle.device_info[ATTR_MANUFACTURER], name=vehicle.device_info[ATTR_NAME], model=vehicle.device_info[ATTR_MODEL], - sw_version=vehicle.device_info[ATTR_SW_VERSION], + model_id=vehicle.device_info[ATTR_MODEL_ID], ) self._vehicles[vehicle_link.vin] = vehicle diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index d5c4f78126c..b77442c8331 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -76,8 +76,8 @@ class RenaultVehicleProxy: identifiers={(DOMAIN, cast(str, details.vin))}, manufacturer=(details.get_brand_label() or "").capitalize(), model=(details.get_model_label() or "").capitalize(), + model_id=(details.get_model_code() or ""), name=details.registrationNumber or "", - sw_version=details.get_model_code() or "", ) self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} self.hvac_target_temperature = 21 diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index 86fddfd5bac..a7c6b314ccb 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -10,9 +10,9 @@ from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, + ATTR_MODEL_ID, ATTR_NAME, ATTR_STATE, - ATTR_SW_VERSION, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant @@ -46,7 +46,7 @@ def check_device_registry( assert registry_entry.manufacturer == expected_device[ATTR_MANUFACTURER] assert registry_entry.name == expected_device[ATTR_NAME] assert registry_entry.model == expected_device[ATTR_MODEL] - assert registry_entry.sw_version == expected_device[ATTR_SW_VERSION] + assert registry_entry.model_id == expected_device[ATTR_MODEL_ID] def check_entities( diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 19c40f6ec20..2d0263e40de 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -19,9 +19,9 @@ from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, + ATTR_MODEL_ID, ATTR_NAME, ATTR_STATE, - ATTR_SW_VERSION, ATTR_UNIT_OF_MEASUREMENT, CONF_PASSWORD, CONF_USERNAME, @@ -74,7 +74,7 @@ MOCK_VEHICLES = { ATTR_MANUFACTURER: "Renault", ATTR_MODEL: "Zoe", ATTR_NAME: "REG-NUMBER", - ATTR_SW_VERSION: "X101VE", + ATTR_MODEL_ID: "X101VE", }, "endpoints": { "battery_status": "battery_status_charging.json", @@ -269,7 +269,7 @@ MOCK_VEHICLES = { ATTR_MANUFACTURER: "Renault", ATTR_MODEL: "Zoe", ATTR_NAME: "REG-NUMBER", - ATTR_SW_VERSION: "X102VE", + ATTR_MODEL_ID: "X102VE", }, "endpoints": { "battery_status": "battery_status_not_charging.json", @@ -517,7 +517,7 @@ MOCK_VEHICLES = { ATTR_MANUFACTURER: "Renault", ATTR_MODEL: "Captur ii", ATTR_NAME: "REG-NUMBER", - ATTR_SW_VERSION: "XJB1SU", + ATTR_MODEL_ID: "XJB1SU", }, "endpoints": { "battery_status": "battery_status_charging.json", @@ -755,7 +755,7 @@ MOCK_VEHICLES = { ATTR_MANUFACTURER: "Renault", ATTR_MODEL: "Captur ii", ATTR_NAME: "REG-NUMBER", - ATTR_SW_VERSION: "XJB1SU", + ATTR_MODEL_ID: "XJB1SU", }, "endpoints": { "cockpit": "cockpit_fuel.json", diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index 9dac0c323ce..7142608b977 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -22,13 +22,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -322,13 +322,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -708,13 +708,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X101VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X101VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -878,13 +878,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X102VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X102VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -1306,13 +1306,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -1606,13 +1606,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -1992,13 +1992,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X101VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X101VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -2162,13 +2162,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X102VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X102VE', + 'sw_version': None, 'via_device_id': None, }), ]) diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index c4732ad1458..e61255372c1 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -22,13 +22,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -106,13 +106,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -274,13 +274,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X101VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X101VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -442,13 +442,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X102VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X102VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -610,13 +610,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -694,13 +694,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -862,13 +862,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X101VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X101VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -1030,13 +1030,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X102VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X102VE', + 'sw_version': None, 'via_device_id': None, }), ]) diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index 5e7813316a2..f90cb92cc63 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -22,13 +22,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -107,13 +107,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -192,13 +192,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X101VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X101VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -234,13 +234,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X102VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X102VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -319,13 +319,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -407,13 +407,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -495,13 +495,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X101VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X101VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -537,13 +537,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X102VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X102VE', + 'sw_version': None, 'via_device_id': None, }), ]) diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index ccdc76f0130..9974e21be75 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -22,13 +22,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -64,13 +64,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -161,13 +161,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X101VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X101VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -258,13 +258,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X102VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X102VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -355,13 +355,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -397,13 +397,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -494,13 +494,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X101VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X101VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -591,13 +591,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X102VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X102VE', + 'sw_version': None, 'via_device_id': None, }), ]) diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index e4bb2d74297..80e73347b07 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -22,13 +22,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -332,13 +332,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -1087,13 +1087,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X101VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X101VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -1838,13 +1838,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X102VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X102VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -2632,13 +2632,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -2942,13 +2942,13 @@ }), 'manufacturer': 'Renault', 'model': 'Captur ii', - 'model_id': None, + 'model_id': 'XJB1SU', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'XJB1SU', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -3697,13 +3697,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X101VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X101VE', + 'sw_version': None, 'via_device_id': None, }), ]) @@ -4448,13 +4448,13 @@ }), 'manufacturer': 'Renault', 'model': 'Zoe', - 'model_id': None, + 'model_id': 'X102VE', 'name': 'REG-NUMBER', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'X102VE', + 'sw_version': None, 'via_device_id': None, }), ]) diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index 831204c59b4..aadeec60ebf 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -25,8 +25,8 @@ from homeassistant.const import ( ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_MODEL, + ATTR_MODEL_ID, ATTR_NAME, - ATTR_SW_VERSION, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -268,7 +268,7 @@ async def test_service_invalid_device_id2( manufacturer=extra_vehicle[ATTR_MANUFACTURER], name=extra_vehicle[ATTR_NAME], model=extra_vehicle[ATTR_MODEL], - sw_version=extra_vehicle[ATTR_SW_VERSION], + model_id=extra_vehicle[ATTR_MODEL_ID], ) device_id = device_registry.async_get_device( identifiers=extra_vehicle[ATTR_IDENTIFIERS] From ccbc300b6819496aca7274aebc7eacd2b43d6f02 Mon Sep 17 00:00:00 2001 From: Ryan Mattson Date: Fri, 6 Sep 2024 04:45:39 -0500 Subject: [PATCH 0308/1309] Lyric: fixed missed snake case conversions (#125382) fixed missed snake case conversions --- homeassistant/components/lyric/climate.py | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 1c459c2c66a..bd9cf4997eb 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -358,8 +358,8 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): await self._update_thermostat( self.location, device, - coolSetpoint=target_temp_high, - heatSetpoint=target_temp_low, + cool_setpoint=target_temp_high, + heat_setpoint=target_temp_low, mode=mode, ) except LYRIC_EXCEPTIONS as exception: @@ -371,11 +371,11 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): try: if self.hvac_mode == HVACMode.COOL: await self._update_thermostat( - self.location, device, coolSetpoint=temp + self.location, device, cool_setpoint=temp ) else: await self._update_thermostat( - self.location, device, heatSetpoint=temp + self.location, device, heat_setpoint=temp ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) @@ -410,7 +410,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): self.location, self.device, mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - autoChangeoverActive=False, + auto_changeover_active=False, ) # Sleep 3 seconds before proceeding await asyncio.sleep(3) @@ -422,7 +422,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): self.location, self.device, mode=HVAC_MODES[LYRIC_HVAC_MODE_HEAT], - autoChangeoverActive=True, + auto_changeover_active=True, ) else: _LOGGER.debug( @@ -430,7 +430,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): HVAC_MODES[self.device.changeable_values.mode], ) await self._update_thermostat( - self.location, self.device, autoChangeoverActive=True + self.location, self.device, auto_changeover_active=True ) else: _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]) @@ -438,13 +438,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): self.location, self.device, mode=LYRIC_HVAC_MODES[hvac_mode], - autoChangeoverActive=False, + auto_changeover_active=False, ) async def _async_set_hvac_mode_lcc(self, hvac_mode: HVACMode) -> None: """Set hvac mode for LCC devices (e.g., T5,6).""" _LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]) - # Set autoChangeoverActive to True if the mode being passed is Auto + # Set auto_changeover_active to True if the mode being passed is Auto # otherwise leave unchanged. if ( LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL @@ -458,7 +458,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): self.location, self.device, mode=LYRIC_HVAC_MODES[hvac_mode], - autoChangeoverActive=auto_changeover, + auto_changeover_active=auto_changeover, ) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -466,7 +466,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): _LOGGER.debug("Set preset mode: %s", preset_mode) try: await self._update_thermostat( - self.location, self.device, thermostatSetpointStatus=preset_mode + self.location, self.device, thermostat_setpoint_status=preset_mode ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) @@ -479,8 +479,8 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): await self._update_thermostat( self.location, self.device, - thermostatSetpointStatus=PRESET_HOLD_UNTIL, - nextPeriodTime=time_period, + thermostat_setpoint_status=PRESET_HOLD_UNTIL, + next_period_time=time_period, ) except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) From ff20131af1d4e7c329c91dd28c1fad56257fb05a Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 6 Sep 2024 12:49:10 +0300 Subject: [PATCH 0309/1309] Use smlight discovery hostname as device name (#125359) * Use smlight discovery hostname as device name * Update reauth flow name * Drop host from description --- homeassistant/components/smlight/config_flow.py | 10 ++++------ homeassistant/components/smlight/strings.json | 2 +- tests/components/smlight/test_config_flow.py | 6 +++--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 98da153ce75..e8984300ff1 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -139,10 +139,6 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self.context["entry_id"] ) host = entry_data[CONF_HOST] - self.context["title_placeholders"] = { - "host": host, - "name": entry_data.get(CONF_USERNAME, "unknown"), - } self.client = Api2(host, session=async_get_clientsession(self.hass)) self.host = host @@ -166,7 +162,8 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): assert self._reauth_entry is not None return self.async_update_reload_and_abort( - self._reauth_entry, data={**user_input, CONF_HOST: self.host} + self._reauth_entry, + data={**self._reauth_entry.data, **user_input}, ) return self.async_show_form( @@ -197,4 +194,5 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_HOST] = self.host assert info.model is not None - return self.async_create_entry(title=info.model, data=user_input) + title = self.context.get("title_placeholders", {}).get(CONF_NAME) or info.model + return self.async_create_entry(title=title, data=user_input) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 7e17a53a38a..bca42f642b7 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -19,7 +19,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Please enter the correct username and password for host: {host}", + "description": "Please enter the correct username and password", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index fb07e29edd4..dae727c7a29 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -91,7 +91,7 @@ async def test_zeroconf_flow( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["context"]["source"] == "zeroconf" assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" - assert result2["title"] == "SLZB-06p7" + assert result2["title"] == "slzb-06" assert result2["data"] == { CONF_HOST: MOCK_HOST, } @@ -143,7 +143,7 @@ async def test_zeroconf_flow_auth( assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["context"]["source"] == "zeroconf" assert result3["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" - assert result3["title"] == "SLZB-06p7" + assert result3["title"] == "slzb-06" assert result3["data"] == { CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD, @@ -356,7 +356,7 @@ async def test_zeroconf_legacy_mac( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["context"]["source"] == "zeroconf" assert result2["context"]["unique_id"] == "aa:bb:cc:dd:ee:ff" - assert result2["title"] == "SLZB-06p7" + assert result2["title"] == "slzb-06" assert result2["data"] == { CONF_HOST: MOCK_HOST, } From dfcfe7873208fcea3d03f083e332a793d686e151 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:58:01 +0200 Subject: [PATCH 0310/1309] Add weheat core integration (#123057) * Add empty weheat integration * Add first sensor to weheat integration * Add weheat entity to provide device information * Fixed automatic selection for a single heat pump * Replaced integration specific package and removed status sensor * Update const.py * Add reauthentication support for weheat integration * Add test cases for the config flow of the weheat integration * Changed API and OATH url to weheat production environment * Add empty weheat integration * Add first sensor to weheat integration * Add weheat entity to provide device information * Fixed automatic selection for a single heat pump * Replaced integration specific package and removed status sensor * Add reauthentication support for weheat integration * Update const.py * Add test cases for the config flow of the weheat integration * Changed API and OATH url to weheat production environment * Resolved merge conflict after adding weheat package * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Added translation keys, more type info and version bump the weheat package * Adding native property value for weheat sensor * Removed reauth, added weheat sensor description and changed discovery of heat pumps * Added unique ID of user to entity * Replaced string by constants, added test case for duplicate unique id * Removed duplicate constant * Added offline scope * Removed re-auth related code * Simplified oath implementation * Cleanup tests for weheat integration * Added oath scope to tests --------- Co-authored-by: kjell-van-straaten Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/weheat/__init__.py | 49 +++++++ homeassistant/components/weheat/api.py | 29 ++++ .../weheat/application_credentials.py | 11 ++ .../components/weheat/config_flow.py | 40 +++++ homeassistant/components/weheat/const.py | 25 ++++ .../components/weheat/coordinator.py | 84 +++++++++++ homeassistant/components/weheat/entity.py | 27 ++++ homeassistant/components/weheat/icons.json | 15 ++ homeassistant/components/weheat/manifest.json | 10 ++ homeassistant/components/weheat/sensor.py | 95 ++++++++++++ homeassistant/components/weheat/strings.json | 46 ++++++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/weheat/__init__.py | 1 + tests/components/weheat/conftest.py | 36 +++++ tests/components/weheat/const.py | 11 ++ tests/components/weheat/test_config_flow.py | 137 ++++++++++++++++++ 21 files changed, 632 insertions(+) create mode 100644 homeassistant/components/weheat/__init__.py create mode 100644 homeassistant/components/weheat/api.py create mode 100644 homeassistant/components/weheat/application_credentials.py create mode 100644 homeassistant/components/weheat/config_flow.py create mode 100644 homeassistant/components/weheat/const.py create mode 100644 homeassistant/components/weheat/coordinator.py create mode 100644 homeassistant/components/weheat/entity.py create mode 100644 homeassistant/components/weheat/icons.json create mode 100644 homeassistant/components/weheat/manifest.json create mode 100644 homeassistant/components/weheat/sensor.py create mode 100644 homeassistant/components/weheat/strings.json create mode 100644 tests/components/weheat/__init__.py create mode 100644 tests/components/weheat/conftest.py create mode 100644 tests/components/weheat/const.py create mode 100644 tests/components/weheat/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index d2a60cbb246..92beb8946ba 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1640,6 +1640,8 @@ build.json @home-assistant/supervisor /tests/components/webostv/ @thecode /homeassistant/components/websocket_api/ @home-assistant/core /tests/components/websocket_api/ @home-assistant/core +/homeassistant/components/weheat/ @jesperraemaekers +/tests/components/weheat/ @jesperraemaekers /homeassistant/components/wemo/ @esev /tests/components/wemo/ @esev /homeassistant/components/whirlpool/ @abmantis @mkmer diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py new file mode 100644 index 00000000000..4800046926d --- /dev/null +++ b/homeassistant/components/weheat/__init__.py @@ -0,0 +1,49 @@ +"""The Weheat integration.""" + +from __future__ import annotations + +from weheat.abstractions.discovery import HeatPumpDiscovery + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .const import API_URL, LOGGER +from .coordinator import WeheatDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]] + + +async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bool: + """Set up Weheat from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + + session = OAuth2Session(hass, entry, implementation) + + token = session.token[CONF_ACCESS_TOKEN] + entry.runtime_data = [] + + # fetch a list of the heat pumps the entry can access + for pump_info in await HeatPumpDiscovery.discover_active(API_URL, token): + LOGGER.debug("Adding %s", pump_info) + # for each pump, add a coordinator + new_coordinator = WeheatDataUpdateCoordinator(hass, session, pump_info) + + await new_coordinator.async_config_entry_first_refresh() + + entry.runtime_data.append(new_coordinator) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/weheat/api.py b/homeassistant/components/weheat/api.py new file mode 100644 index 00000000000..1d0828aa41b --- /dev/null +++ b/homeassistant/components/weheat/api.py @@ -0,0 +1,29 @@ +"""API for Weheat bound to Home Assistant OAuth.""" + +from aiohttp import ClientSession +from weheat.abstractions import AbstractAuth + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + +from .const import API_URL + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Weheat authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: OAuth2Session, + ) -> None: + """Initialize Weheat auth.""" + super().__init__(websession, host=API_URL) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return self._oauth_session.token[CONF_ACCESS_TOKEN] diff --git a/homeassistant/components/weheat/application_credentials.py b/homeassistant/components/weheat/application_credentials.py new file mode 100644 index 00000000000..3f85d4b0558 --- /dev/null +++ b/homeassistant/components/weheat/application_credentials.py @@ -0,0 +1,11 @@ +"""application_credentials platform the Weheat integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer(authorize_url=OAUTH2_AUTHORIZE, token_url=OAUTH2_TOKEN) diff --git a/homeassistant/components/weheat/config_flow.py b/homeassistant/components/weheat/config_flow.py new file mode 100644 index 00000000000..707c2f6bc97 --- /dev/null +++ b/homeassistant/components/weheat/config_flow.py @@ -0,0 +1,40 @@ +"""Config flow for Weheat.""" + +import logging + +from weheat.abstractions.user import get_user_id_from_token + +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler + +from .const import API_URL, DOMAIN, ENTRY_TITLE, OAUTH2_SCOPES + + +class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle Weheat OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, str]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + } + + async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: + """Override the create entry method to change to the step to find the heat pumps.""" + # get the user id and use that as unique id for this entry + user_id = await get_user_id_from_token( + API_URL, data[CONF_TOKEN][CONF_ACCESS_TOKEN] + ) + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=ENTRY_TITLE, data=data) diff --git a/homeassistant/components/weheat/const.py b/homeassistant/components/weheat/const.py new file mode 100644 index 00000000000..fa1b17f8c07 --- /dev/null +++ b/homeassistant/components/weheat/const.py @@ -0,0 +1,25 @@ +"""Constants for the Weheat integration.""" + +from logging import Logger, getLogger + +DOMAIN = "weheat" +MANUFACTURER = "Weheat" +ENTRY_TITLE = "Weheat cloud" +ERROR_DESCRIPTION = "error_description" + +OAUTH2_AUTHORIZE = ( + "https://auth.weheat.nl/auth/realms/Weheat/protocol/openid-connect/auth/" +) +OAUTH2_TOKEN = ( + "https://auth.weheat.nl/auth/realms/Weheat/protocol/openid-connect/token/" +) +API_URL = "https://api.weheat.nl" +OAUTH2_SCOPES = ["openid", "offline_access"] + + +UPDATE_INTERVAL = 30 + +LOGGER: Logger = getLogger(__package__) + +DISPLAY_PRECISION_WATTS = 0 +DISPLAY_PRECISION_COP = 1 diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py new file mode 100644 index 00000000000..92c12990371 --- /dev/null +++ b/homeassistant/components/weheat/coordinator.py @@ -0,0 +1,84 @@ +"""Define a custom coordinator for the Weheat heatpump integration.""" + +from datetime import timedelta + +from weheat.abstractions.discovery import HeatPumpDiscovery +from weheat.abstractions.heat_pump import HeatPump +from weheat.exceptions import ( + ApiException, + BadRequestException, + ForbiddenException, + NotFoundException, + ServiceException, + UnauthorizedException, +) + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_URL, DOMAIN, LOGGER, UPDATE_INTERVAL + +EXCEPTIONS = ( + ServiceException, + NotFoundException, + ForbiddenException, + UnauthorizedException, + BadRequestException, + ApiException, +) + + +class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): + """A custom coordinator for the Weheat heatpump integration.""" + + def __init__( + self, + hass: HomeAssistant, + session: OAuth2Session, + heat_pump: HeatPumpDiscovery.HeatPumpInfo, + ) -> None: + """Initialize the data coordinator.""" + super().__init__( + hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + self._heat_pump_info = heat_pump + self._heat_pump_data = HeatPump(API_URL, self._heat_pump_info.uuid) + + self.session = session + + @property + def heatpump_id(self) -> str: + """Return the heat pump id.""" + return self._heat_pump_info.uuid + + @property + def readable_name(self) -> str | None: + """Return the readable name of the heat pump.""" + if self._heat_pump_info.name: + return self._heat_pump_info.name + return self._heat_pump_info.model + + @property + def model(self) -> str: + """Return the model of the heat pump.""" + return self._heat_pump_info.model + + def fetch_data(self) -> HeatPump: + """Get the data from the API.""" + try: + self._heat_pump_data.get_status(self.session.token[CONF_ACCESS_TOKEN]) + except EXCEPTIONS as error: + raise UpdateFailed(error) from error + + return self._heat_pump_data + + async def _async_update_data(self) -> HeatPump: + """Fetch data from the API.""" + await self.session.async_ensure_token_valid() + + return await self.hass.async_add_executor_job(self.fetch_data) diff --git a/homeassistant/components/weheat/entity.py b/homeassistant/components/weheat/entity.py new file mode 100644 index 00000000000..079db596e19 --- /dev/null +++ b/homeassistant/components/weheat/entity.py @@ -0,0 +1,27 @@ +"""Base entity for Weheat.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import WeheatDataUpdateCoordinator + + +class WeheatEntity(CoordinatorEntity[WeheatDataUpdateCoordinator]): + """Defines a base Weheat entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: WeheatDataUpdateCoordinator, + ) -> None: + """Initialize the Weheat entity.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.heatpump_id)}, + name=coordinator.readable_name, + manufacturer=MANUFACTURER, + model=coordinator.model, + ) diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json new file mode 100644 index 00000000000..b1eaf481bfa --- /dev/null +++ b/homeassistant/components/weheat/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "power_output": { + "default": "mdi:heat-wave" + }, + "power_input": { + "default": "mdi:lightning-bolt" + }, + "cop": { + "default": "mdi:speedometer" + } + } + } +} diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json new file mode 100644 index 00000000000..2dfceacb635 --- /dev/null +++ b/homeassistant/components/weheat/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "weheat", + "name": "Weheat", + "codeowners": ["@jesperraemaekers"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/weheat", + "iot_class": "cloud_polling", + "requirements": ["weheat==2024.09.05"] +} diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py new file mode 100644 index 00000000000..a5bbc66001c --- /dev/null +++ b/homeassistant/components/weheat/sensor.py @@ -0,0 +1,95 @@ +"""Platform for sensor integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from weheat.abstractions.heat_pump import HeatPump + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import WeheatConfigEntry +from .const import DISPLAY_PRECISION_COP, DISPLAY_PRECISION_WATTS +from .coordinator import WeheatDataUpdateCoordinator +from .entity import WeheatEntity + + +@dataclass(frozen=True, kw_only=True) +class WeHeatSensorEntityDescription(SensorEntityDescription): + """Describes Weheat sensor entity.""" + + value_fn: Callable[[HeatPump], StateType] + + +SENSORS = [ + WeHeatSensorEntityDescription( + translation_key="power_output", + key="power_output", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_WATTS, + value_fn=lambda status: status.power_output, + ), + WeHeatSensorEntityDescription( + translation_key="power_input", + key="power_input", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_WATTS, + value_fn=lambda status: status.power_input, + ), + WeHeatSensorEntityDescription( + translation_key="cop", + key="cop", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_COP, + value_fn=lambda status: status.cop, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WeheatConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensors for weheat heat pump.""" + async_add_entities( + WeheatHeatPumpSensor(coordinator, entity_description) + for entity_description in SENSORS + for coordinator in entry.runtime_data + ) + + +class WeheatHeatPumpSensor(WeheatEntity, SensorEntity): + """Defines a Weheat heat pump sensor.""" + + coordinator: WeheatDataUpdateCoordinator + entity_description: WeHeatSensorEntityDescription + + def __init__( + self, + coordinator: WeheatDataUpdateCoordinator, + entity_description: WeHeatSensorEntityDescription, + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + + self.entity_description = entity_description + + self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json new file mode 100644 index 00000000000..63871b065b6 --- /dev/null +++ b/homeassistant/components/weheat/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "find_devices": { + "title": "Select your heat pump" + }, + "reauth_confirm": { + "title": "Re-authenticate with WeHeat", + "description": "You need to re-authenticate with WeHeat to continue" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "no_devices_found": "Could not find any heat pumps on this account" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "entity": { + "sensor": { + "power_output": { + "name": "Output power" + }, + "power_input": { + "name": "Input power" + }, + "cop": { + "name": "COP" + } + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index efb6f426d36..359ef656290 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -28,6 +28,7 @@ APPLICATION_CREDENTIALS = [ "spotify", "tesla_fleet", "twitch", + "weheat", "withings", "xbox", "yale", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9f4b4e42bb0..f03c980a2d4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -656,6 +656,7 @@ FLOWS = { "weatherkit", "webmin", "webostv", + "weheat", "wemo", "whirlpool", "whois", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b4c80aa70b4..eab7bf224d2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6854,6 +6854,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "weheat": { + "name": "Weheat", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "wemo": { "name": "Belkin WeMo", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 82cabd124d8..6796a83c9c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2947,6 +2947,9 @@ weatherflow4py==0.2.23 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 +# homeassistant.components.weheat +weheat==2024.09.05 + # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0ac4294ada..df33037cf4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2333,6 +2333,9 @@ weatherflow4py==0.2.23 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 +# homeassistant.components.weheat +weheat==2024.09.05 + # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 diff --git a/tests/components/weheat/__init__.py b/tests/components/weheat/__init__.py new file mode 100644 index 00000000000..c077280ccb5 --- /dev/null +++ b/tests/components/weheat/__init__.py @@ -0,0 +1 @@ +"""Tests for the Weheat integration.""" diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py new file mode 100644 index 00000000000..831d4d460ac --- /dev/null +++ b/tests/components/weheat/conftest.py @@ -0,0 +1,36 @@ +"""Fixtures for Weheat tests.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS, + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.weheat.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import CLIENT_ID, CLIENT_SECRET + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS, {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +def mock_setup_entry(): + """Mock a successful setup.""" + with patch( + "homeassistant.components.weheat.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/weheat/const.py b/tests/components/weheat/const.py new file mode 100644 index 00000000000..01733de1c91 --- /dev/null +++ b/tests/components/weheat/const.py @@ -0,0 +1,11 @@ +"""Constants for weheat tests.""" + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +USER_UUID_1 = "0000-1111-2222-3333" + +CONF_REFRESH_TOKEN = "refresh_token" +CONF_AUTH_IMPLEMENTATION = "auth_implementation" +MOCK_REFRESH_TOKEN = "mock_refresh_token" +MOCK_ACCESS_TOKEN = "mock_access_token" diff --git a/tests/components/weheat/test_config_flow.py b/tests/components/weheat/test_config_flow.py new file mode 100644 index 00000000000..c065d011e42 --- /dev/null +++ b/tests/components/weheat/test_config_flow.py @@ -0,0 +1,137 @@ +"""Test the Weheat config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.weheat.const import ( + DOMAIN, + ENTRY_TITLE, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SOURCE, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import ( + CLIENT_ID, + CONF_AUTH_IMPLEMENTATION, + CONF_REFRESH_TOKEN, + MOCK_ACCESS_TOKEN, + MOCK_REFRESH_TOKEN, + USER_UUID_1, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry, +) -> None: + """Check full of adding a single heat pump.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + await handle_oauth(hass, hass_client_no_auth, aioclient_mock, result) + + with ( + patch( + "homeassistant.components.weheat.config_flow.get_user_id_from_token", + return_value=USER_UUID_1, + ) as mock_weheat, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_weheat.mock_calls) == 1 + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == USER_UUID_1 + assert result["result"].title == ENTRY_TITLE + assert result["data"][CONF_TOKEN][CONF_REFRESH_TOKEN] == MOCK_REFRESH_TOKEN + assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == MOCK_ACCESS_TOKEN + assert result["data"][CONF_AUTH_IMPLEMENTATION] == DOMAIN + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_unique_id( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry, +) -> None: + """Check that the config flow is aborted when an entry with the same ID exists.""" + first_entry = MockConfigEntry( + domain=DOMAIN, + data={}, + unique_id=USER_UUID_1, + ) + + first_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + await handle_oauth(hass, hass_client_no_auth, aioclient_mock, result) + + with ( + patch( + "homeassistant.components.weheat.config_flow.get_user_id_from_token", + return_value=USER_UUID_1, + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # only care that the config flow is aborted + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def handle_oauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + result: ConfigFlowResult, +) -> None: + """Handle the Oauth2 part of the flow.""" + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=openid+offline_access" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": MOCK_REFRESH_TOKEN, + "access_token": MOCK_ACCESS_TOKEN, + "type": "Bearer", + "expires_in": 60, + }, + ) From ff3cabbf3a4c65b526b5d9bddb8ed2e18e2abd90 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 6 Sep 2024 07:36:02 -0400 Subject: [PATCH 0311/1309] Small Assist Satellite fixes (#125384) --- homeassistant/components/assist_pipeline/pipeline.py | 4 +++- homeassistant/components/assist_satellite/entity.py | 2 +- tests/components/assist_satellite/test_entity.py | 5 ++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index f6a6bc45b57..8a5fec83565 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -504,7 +504,7 @@ class AudioSettings: is_vad_enabled: bool = True """True if VAD is used to determine the end of the voice command.""" - silence_seconds: float = 0.5 + silence_seconds: float = 0.7 """Seconds of silence after voice command has ended.""" def __post_init__(self) -> None: @@ -906,6 +906,8 @@ class PipelineRun: metadata, self._speech_to_text_stream(audio_stream=stream, stt_vad=stt_vad), ) + except (asyncio.CancelledError, TimeoutError): + raise # expected except Exception as src_error: _LOGGER.exception("Unexpected error during speech-to-text") raise SpeechToTextError( diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 8364a81b1fb..6ec40ae24f7 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -73,7 +73,7 @@ class AssistSatelliteEntity(entity.Entity): _is_announcing = False _wake_word_intercept_future: asyncio.Future[str | None] | None = None - __assist_satellite_state: AssistSatelliteState | None = None + __assist_satellite_state = AssistSatelliteState.LISTENING_WAKE_WORD @final @property diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index f957a826828..2e4caca030b 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -21,7 +21,6 @@ from homeassistant.components.assist_satellite import SatelliteBusyError from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN from homeassistant.core import Context, HomeAssistant from . import ENTITY_ID @@ -35,7 +34,7 @@ async def test_entity_state( state = hass.states.get(ENTITY_ID) assert state is not None - assert state.state == STATE_UNKNOWN + assert state.state == AssistSatelliteState.LISTENING_WAKE_WORD context = Context() audio_stream = object() @@ -71,7 +70,7 @@ async def test_entity_state( assert kwargs["end_stage"] == PipelineStage.TTS for event_type, expected_state in ( - (PipelineEventType.RUN_START, STATE_UNKNOWN), + (PipelineEventType.RUN_START, AssistSatelliteState.LISTENING_WAKE_WORD), (PipelineEventType.RUN_END, AssistSatelliteState.LISTENING_WAKE_WORD), (PipelineEventType.WAKE_WORD_START, AssistSatelliteState.LISTENING_WAKE_WORD), (PipelineEventType.WAKE_WORD_END, AssistSatelliteState.LISTENING_WAKE_WORD), From 8f38b7191a9e6e22b21c55112e929f69ec112d58 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 6 Sep 2024 14:06:46 +0200 Subject: [PATCH 0312/1309] Fix for Hue sending effect None at turn_on command while no effect is active (#125377) * Fix for Hue sending effect None at turn_on command while no effect is active * typo * update tests --- homeassistant/components/hue/v2/light.py | 6 ++- tests/components/hue/test_light_v2.py | 54 +++++++++++++++++++----- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index b908ec83877..6fd0eea7a0b 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -226,7 +226,11 @@ class HueLight(HueBaseEntity, LightEntity): flash = kwargs.get(ATTR_FLASH) effect = effect_str = kwargs.get(ATTR_EFFECT) if effect_str in (EFFECT_NONE, EFFECT_NONE.lower()): - effect = EffectStatus.NO_EFFECT + # ignore effect if set to "None" and we have no effect active + # the special effect "None" is only used to stop an active effect + # but sending it while no effect is active can actually result in issues + # https://github.com/home-assistant/core/issues/122165 + effect = None if self.effect == EFFECT_NONE else EffectStatus.NO_EFFECT elif effect_str is not None: # work out if we got a regular effect or timed effect effect = EffectStatus(effect_str) diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 417670a3769..2b978ffc33f 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -175,7 +175,7 @@ async def test_light_turn_on_service( assert len(mock_bridge_v2.mock_requests) == 6 assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 500 - # test enable effect + # test enable an effect await hass.services.async_call( "light", "turn_on", @@ -184,8 +184,20 @@ async def test_light_turn_on_service( ) assert len(mock_bridge_v2.mock_requests) == 7 assert mock_bridge_v2.mock_requests[6]["json"]["effects"]["effect"] == "candle" + # fire event to update effect in HA state + event = { + "id": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "type": "light", + "effects": {"status": "candle"}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.attributes["effect"] == "candle" # test disable effect + # it should send a request with effect set to "no_effect" await hass.services.async_call( "light", "turn_on", @@ -194,6 +206,28 @@ async def test_light_turn_on_service( ) assert len(mock_bridge_v2.mock_requests) == 8 assert mock_bridge_v2.mock_requests[7]["json"]["effects"]["effect"] == "no_effect" + # fire event to update effect in HA state + event = { + "id": "3a6710fa-4474-4eba-b533-5e6e72968feb", + "type": "light", + "effects": {"status": "no_effect"}, + } + mock_bridge_v2.api.emit_event("update", event) + await hass.async_block_till_done() + test_light = hass.states.get(test_light_id) + assert test_light is not None + assert test_light.attributes["effect"] == "None" + + # test turn on with useless effect + # it should send a effect in the request if the device has no effect active + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id, "effect": "None"}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 9 + assert "effects" not in mock_bridge_v2.mock_requests[8]["json"] # test timed effect await hass.services.async_call( @@ -202,11 +236,11 @@ async def test_light_turn_on_service( {"entity_id": test_light_id, "effect": "sunrise", "transition": 6}, blocking=True, ) - assert len(mock_bridge_v2.mock_requests) == 9 + assert len(mock_bridge_v2.mock_requests) == 10 assert ( - mock_bridge_v2.mock_requests[8]["json"]["timed_effects"]["effect"] == "sunrise" + mock_bridge_v2.mock_requests[9]["json"]["timed_effects"]["effect"] == "sunrise" ) - assert mock_bridge_v2.mock_requests[8]["json"]["timed_effects"]["duration"] == 6000 + assert mock_bridge_v2.mock_requests[9]["json"]["timed_effects"]["duration"] == 6000 # test enabling effect should ignore color temperature await hass.services.async_call( @@ -215,9 +249,9 @@ async def test_light_turn_on_service( {"entity_id": test_light_id, "effect": "candle", "color_temp": 500}, blocking=True, ) - assert len(mock_bridge_v2.mock_requests) == 10 - assert mock_bridge_v2.mock_requests[9]["json"]["effects"]["effect"] == "candle" - assert "color_temperature" not in mock_bridge_v2.mock_requests[9]["json"] + assert len(mock_bridge_v2.mock_requests) == 11 + assert mock_bridge_v2.mock_requests[10]["json"]["effects"]["effect"] == "candle" + assert "color_temperature" not in mock_bridge_v2.mock_requests[10]["json"] # test enabling effect should ignore xy color await hass.services.async_call( @@ -226,9 +260,9 @@ async def test_light_turn_on_service( {"entity_id": test_light_id, "effect": "candle", "xy_color": [0.123, 0.123]}, blocking=True, ) - assert len(mock_bridge_v2.mock_requests) == 11 - assert mock_bridge_v2.mock_requests[10]["json"]["effects"]["effect"] == "candle" - assert "xy_color" not in mock_bridge_v2.mock_requests[9]["json"] + assert len(mock_bridge_v2.mock_requests) == 12 + assert mock_bridge_v2.mock_requests[11]["json"]["effects"]["effect"] == "candle" + assert "xy_color" not in mock_bridge_v2.mock_requests[11]["json"] async def test_light_turn_off_service( From 0eda451c24221a460223cd572e97512c2857e5f7 Mon Sep 17 00:00:00 2001 From: TimL Date: Fri, 6 Sep 2024 22:25:55 +1000 Subject: [PATCH 0313/1309] Add Switch platform to Smlight integration (#125292) * Add switch platform to Smlight * Add strings for switch platform * Add tests for Smlight switch platform * Regenerate snapshot * Address review comments * Use is_on property for updating switch state * Address review comments --------- Co-authored-by: Tim Lunn --- homeassistant/components/smlight/__init__.py | 1 + homeassistant/components/smlight/strings.json | 11 ++ homeassistant/components/smlight/switch.py | 110 ++++++++++++++ tests/components/smlight/conftest.py | 1 + .../components/smlight/fixtures/sensors.json | 2 +- .../smlight/snapshots/test_switch.ambr | 142 ++++++++++++++++++ tests/components/smlight/test_switch.py | 110 ++++++++++++++ 7 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smlight/switch.py create mode 100644 tests/components/smlight/snapshots/test_switch.ambr create mode 100644 tests/components/smlight/test_switch.py diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 4f0f2c0fb02..58d5b7d343f 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -12,6 +12,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, + Platform.SWITCH, ] type SmConfigEntry = ConfigEntry[SmDataUpdateCoordinator] diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index bca42f642b7..e3e8fee0d4d 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -80,6 +80,17 @@ "zigbee_flash_mode": { "name": "Zigbee flash mode" } + }, + "switch": { + "auto_zigbee_update": { + "name": "Auto Zigbee update" + }, + "disable_led": { + "name": "Disable LEDs" + }, + "night_mode": { + "name": "LED night mode" + } } } } diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py new file mode 100644 index 00000000000..2e7b7e4df7e --- /dev/null +++ b/homeassistant/components/smlight/switch.py @@ -0,0 +1,110 @@ +"""Support for SLZB-06 switches.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Any + +from pysmlight import Sensors +from pysmlight.const import Settings + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SmConfigEntry +from .coordinator import SmDataUpdateCoordinator +from .entity import SmEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class SmSwitchEntityDescription(SwitchEntityDescription): + """Class to describe a Switch entity.""" + + setting: Settings + state_fn: Callable[[Sensors], bool | None] + + +SWITCHES: list[SmSwitchEntityDescription] = [ + SmSwitchEntityDescription( + key="disable_led", + translation_key="disable_led", + setting=Settings.DISABLE_LEDS, + state_fn=lambda x: x.disable_leds, + ), + SmSwitchEntityDescription( + key="night_mode", + translation_key="night_mode", + setting=Settings.NIGHT_MODE, + state_fn=lambda x: x.night_mode, + ), + SmSwitchEntityDescription( + key="auto_zigbee_update", + translation_key="auto_zigbee_update", + entity_category=EntityCategory.CONFIG, + setting=Settings.ZB_AUTOUPDATE, + state_fn=lambda x: x.auto_zigbee, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize switches for SLZB-06 device.""" + coordinator = entry.runtime_data + + async_add_entities(SmSwitch(coordinator, switch) for switch in SWITCHES) + + +class SmSwitch(SmEntity, SwitchEntity): + """Representation of a SLZB-06 switch.""" + + entity_description: SmSwitchEntityDescription + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__( + self, + coordinator: SmDataUpdateCoordinator, + description: SmSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + + self._page, self._toggle = description.setting.value + + async def set_smlight(self, state: bool) -> None: + """Set the state on SLZB device.""" + await self.coordinator.client.set_toggle(self._page, self._toggle, state) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + self._attr_is_on = True + self.async_write_ha_state() + + await self.set_smlight(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + self._attr_is_on = False + self.async_write_ha_state() + + await self.set_smlight(False) + + @property + def is_on(self) -> bool | None: + """Return the state of the switch.""" + return self.entity_description.state_fn(self.coordinator.data.sensors) diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index a86c7b4c27a..b78ec7aa630 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -88,6 +88,7 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: api.authenticate.return_value = True api.cmds = AsyncMock(spec_set=CmdWrapper) + api.set_toggle = AsyncMock() yield api diff --git a/tests/components/smlight/fixtures/sensors.json b/tests/components/smlight/fixtures/sensors.json index 0b2f9055e01..89ec5615f34 100644 --- a/tests/components/smlight/fixtures/sensors.json +++ b/tests/components/smlight/fixtures/sensors.json @@ -9,6 +9,6 @@ "wifi_connected": false, "wifi_status": 255, "disable_leds": false, - "night_mode": false, + "night_mode": true, "auto_zigbee": false } diff --git a/tests/components/smlight/snapshots/test_switch.ambr b/tests/components/smlight/snapshots/test_switch.ambr new file mode 100644 index 00000000000..b8e1c8357ac --- /dev/null +++ b/tests/components/smlight/snapshots/test_switch.ambr @@ -0,0 +1,142 @@ +# serializer version: 1 +# name: test_switch_setup[switch.mock_title_auto_zigbee_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.mock_title_auto_zigbee_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto Zigbee update', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'auto_zigbee_update', + 'unique_id': 'aa:bb:cc:dd:ee:ff-auto_zigbee_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[switch.mock_title_auto_zigbee_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Title Auto Zigbee update', + }), + 'context': , + 'entity_id': 'switch.mock_title_auto_zigbee_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_setup[switch.mock_title_disable_leds-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_disable_leds', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Disable LEDs', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'disable_led', + 'unique_id': 'aa:bb:cc:dd:ee:ff-disable_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[switch.mock_title_disable_leds-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Title Disable LEDs', + }), + 'context': , + 'entity_id': 'switch.mock_title_disable_leds', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_setup[switch.mock_title_led_night_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_led_night_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'LED night mode', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'night_mode', + 'unique_id': 'aa:bb:cc:dd:ee:ff-night_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[switch.mock_title_led_night_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Title LED night mode', + }), + 'context': , + 'entity_id': 'switch.mock_title_led_night_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smlight/test_switch.py b/tests/components/smlight/test_switch.py new file mode 100644 index 00000000000..165024eaa83 --- /dev/null +++ b/tests/components/smlight/test_switch.py @@ -0,0 +1,110 @@ +"""Tests for the SMLIGHT switch platform.""" + +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pysmlight import Sensors +from pysmlight.const import Settings +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smlight.const import SCAN_INTERVAL +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +pytestmark = [ + pytest.mark.usefixtures( + "mock_smlight_client", + ) +] + + +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms, which should be loaded during the test.""" + return [Platform.SWITCH] + + +async def test_switch_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of SMLIGHT switches.""" + entry = await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("entity", "setting", "field"), + [ + ("disable_leds", Settings.DISABLE_LEDS, "disable_leds"), + ("led_night_mode", Settings.NIGHT_MODE, "night_mode"), + ("auto_zigbee_update", Settings.ZB_AUTOUPDATE, "auto_zigbee"), + ], +) +async def test_switches( + hass: HomeAssistant, + entity: str, + field: str, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, + setting: Settings, +) -> None: + """Test the SMLIGHT switches.""" + await setup_integration(hass, mock_config_entry) + + _page, _toggle = setting.value + + entity_id = f"switch.mock_title_{entity}" + state = hass.states.get(entity_id) + assert state is not None + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(mock_smlight_client.set_toggle.mock_calls) == 1 + mock_smlight_client.set_toggle.assert_called_once_with(_page, _toggle, True) + mock_smlight_client.get_sensors.return_value = Sensors(**{field: True}) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(mock_smlight_client.set_toggle.mock_calls) == 2 + mock_smlight_client.set_toggle.assert_called_with(_page, _toggle, False) + mock_smlight_client.get_sensors.return_value = Sensors(**{field: False}) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF From aba23eb5139caa7023f710dc29329532ca3ea80d Mon Sep 17 00:00:00 2001 From: Matrix Date: Fri, 6 Sep 2024 20:47:31 +0800 Subject: [PATCH 0314/1309] Add YoLink temperature sensor YS8008 support (#125408) Add YS8008 support --- homeassistant/components/yolink/const.py | 2 ++ homeassistant/components/yolink/sensor.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 217dd66d063..eb6169eccad 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -19,6 +19,8 @@ DEV_MODEL_WATER_METER_YS5007 = "YS5007" DEV_MODEL_MULTI_OUTLET_YS6801 = "YS6801" DEV_MODEL_TH_SENSOR_YS8004_UC = "YS8004-UC" DEV_MODEL_TH_SENSOR_YS8004_EC = "YS8004-EC" +DEV_MODEL_TH_SENSOR_YS8008_UC = "YS8008-UC" +DEV_MODEL_TH_SENSOR_YS8008_EC = "YS8008-EC" DEV_MODEL_TH_SENSOR_YS8014_UC = "YS8014-UC" DEV_MODEL_TH_SENSOR_YS8014_EC = "YS8014-EC" DEV_MODEL_TH_SENSOR_YS8017_UC = "YS8017-UC" diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index b8f2a77516c..537393d0315 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -57,6 +57,8 @@ from .const import ( DEV_MODEL_PLUG_YS6803_UC, DEV_MODEL_TH_SENSOR_YS8004_EC, DEV_MODEL_TH_SENSOR_YS8004_UC, + DEV_MODEL_TH_SENSOR_YS8008_EC, + DEV_MODEL_TH_SENSOR_YS8008_UC, DEV_MODEL_TH_SENSOR_YS8014_EC, DEV_MODEL_TH_SENSOR_YS8014_UC, DEV_MODEL_TH_SENSOR_YS8017_EC, @@ -125,6 +127,8 @@ MCU_DEV_TEMPERATURE_SENSOR = [ NONE_HUMIDITY_SENSOR_MODELS = [ DEV_MODEL_TH_SENSOR_YS8004_EC, DEV_MODEL_TH_SENSOR_YS8004_UC, + DEV_MODEL_TH_SENSOR_YS8008_EC, + DEV_MODEL_TH_SENSOR_YS8008_UC, DEV_MODEL_TH_SENSOR_YS8014_EC, DEV_MODEL_TH_SENSOR_YS8014_UC, DEV_MODEL_TH_SENSOR_YS8017_UC, From 9777ed2e624f2d0b9a252881ed2080c51f3c86a0 Mon Sep 17 00:00:00 2001 From: Tony <29752086+ms264556@users.noreply.github.com> Date: Sat, 7 Sep 2024 00:48:16 +1200 Subject: [PATCH 0315/1309] Rename "Ruckus Unleashed" integration to "Ruckus" (#125392) --- .../components/ruckus_unleashed/__init__.py | 8 +++---- .../ruckus_unleashed/config_flow.py | 6 ++--- .../components/ruckus_unleashed/const.py | 2 +- .../ruckus_unleashed/coordinator.py | 10 ++++---- .../ruckus_unleashed/device_tracker.py | 24 ++++++++----------- .../components/ruckus_unleashed/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- tests/components/ruckus_unleashed/__init__.py | 6 ++--- .../ruckus_unleashed/test_config_flow.py | 2 +- .../ruckus_unleashed/test_device_tracker.py | 2 +- .../components/ruckus_unleashed/test_init.py | 2 +- 11 files changed, 31 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index c2c46fcc125..4ee870e8322 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -1,4 +1,4 @@ -"""The Ruckus Unleashed integration.""" +"""The Ruckus integration.""" import logging @@ -24,13 +24,13 @@ from .const import ( PLATFORMS, UNDO_UPDATE_LISTENERS, ) -from .coordinator import RuckusUnleashedDataUpdateCoordinator +from .coordinator import RuckusDataUpdateCoordinator _LOGGER = logging.getLogger(__package__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Ruckus Unleashed from a config entry.""" + """Set up Ruckus from a config entry.""" ruckus = AjaxSession.async_create( entry.data[CONF_HOST], @@ -46,7 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await ruckus.close() raise ConfigEntryAuthFailed from autherr - coordinator = RuckusUnleashedDataUpdateCoordinator(hass, ruckus=ruckus) + coordinator = RuckusDataUpdateCoordinator(hass, ruckus=ruckus) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/ruckus_unleashed/config_flow.py b/homeassistant/components/ruckus_unleashed/config_flow.py index d2f27e4ef05..fdfacfc73a7 100644 --- a/homeassistant/components/ruckus_unleashed/config_flow.py +++ b/homeassistant/components/ruckus_unleashed/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Ruckus Unleashed integration.""" +"""Config flow for Ruckus integration.""" from collections.abc import Mapping import logging @@ -59,8 +59,8 @@ async def validate_input(hass: HomeAssistant, data): } -class RuckusUnleashedConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Ruckus Unleashed.""" +class RuckusConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ruckus.""" VERSION = 1 diff --git a/homeassistant/components/ruckus_unleashed/const.py b/homeassistant/components/ruckus_unleashed/const.py index 9076437b8c7..1aae3041e73 100644 --- a/homeassistant/components/ruckus_unleashed/const.py +++ b/homeassistant/components/ruckus_unleashed/const.py @@ -1,4 +1,4 @@ -"""Constants for the Ruckus Unleashed integration.""" +"""Constants for the Ruckus integration.""" from homeassistant.const import Platform diff --git a/homeassistant/components/ruckus_unleashed/coordinator.py b/homeassistant/components/ruckus_unleashed/coordinator.py index 989748af86e..d9f20883559 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -1,4 +1,4 @@ -"""Ruckus Unleashed DataUpdateCoordinator.""" +"""Ruckus DataUpdateCoordinator.""" from datetime import timedelta import logging @@ -15,11 +15,11 @@ from .const import API_CLIENT_MAC, DOMAIN, KEY_SYS_CLIENTS, SCAN_INTERVAL _LOGGER = logging.getLogger(__package__) -class RuckusUnleashedDataUpdateCoordinator(DataUpdateCoordinator): - """Coordinator to manage data from Ruckus Unleashed client.""" +class RuckusDataUpdateCoordinator(DataUpdateCoordinator): + """Coordinator to manage data from Ruckus client.""" def __init__(self, hass: HomeAssistant, *, ruckus: AjaxSession) -> None: - """Initialize global Ruckus Unleashed data updater.""" + """Initialize global Ruckus data updater.""" self.ruckus = ruckus update_interval = timedelta(seconds=SCAN_INTERVAL) @@ -38,7 +38,7 @@ class RuckusUnleashedDataUpdateCoordinator(DataUpdateCoordinator): return {client[API_CLIENT_MAC]: client for client in clients} async def _async_update_data(self) -> dict: - """Fetch Ruckus Unleashed data.""" + """Fetch Ruckus data.""" try: return {KEY_SYS_CLIENTS: await self._fetch_clients()} except AuthenticationError as autherror: diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 233e5cd4945..704272bf4c9 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -1,4 +1,4 @@ -"""Support for Ruckus Unleashed devices.""" +"""Support for Ruckus devices.""" from __future__ import annotations @@ -19,7 +19,7 @@ from .const import ( KEY_SYS_CLIENTS, UNDO_UPDATE_LISTENERS, ) -from .coordinator import RuckusUnleashedDataUpdateCoordinator +from .coordinator import RuckusDataUpdateCoordinator _LOGGER = logging.getLogger(__package__) @@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__package__) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up device tracker for Ruckus Unleashed component.""" + """Set up device tracker for Ruckus component.""" coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] tracked: set[str] = set() @@ -58,9 +58,7 @@ def add_new_entities(coordinator, async_add_entities, tracked): device = coordinator.data[KEY_SYS_CLIENTS][mac] _LOGGER.debug("adding new device: [%s] %s", mac, device[API_CLIENT_HOSTNAME]) - new_tracked.append( - RuckusUnleashedDevice(coordinator, mac, device[API_CLIENT_HOSTNAME]) - ) + new_tracked.append(RuckusDevice(coordinator, mac, device[API_CLIENT_HOSTNAME])) tracked.add(mac) async_add_entities(new_tracked) @@ -69,13 +67,13 @@ def add_new_entities(coordinator, async_add_entities, tracked): @callback def restore_entities( registry: er.EntityRegistry, - coordinator: RuckusUnleashedDataUpdateCoordinator, + coordinator: RuckusDataUpdateCoordinator, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, tracked: set[str], ) -> None: """Restore clients that are not a part of active clients list.""" - missing: list[RuckusUnleashedDevice] = [] + missing: list[RuckusDevice] = [] for entity in registry.entities.get_entries_for_config_entry_id(entry.entry_id): if ( @@ -83,9 +81,7 @@ def restore_entities( and entity.unique_id not in coordinator.data[KEY_SYS_CLIENTS] ): missing.append( - RuckusUnleashedDevice( - coordinator, entity.unique_id, entity.original_name - ) + RuckusDevice(coordinator, entity.unique_id, entity.original_name) ) tracked.add(entity.unique_id) @@ -93,11 +89,11 @@ def restore_entities( async_add_entities(missing) -class RuckusUnleashedDevice(CoordinatorEntity, ScannerEntity): - """Representation of a Ruckus Unleashed client.""" +class RuckusDevice(CoordinatorEntity, ScannerEntity): + """Representation of a Ruckus client.""" def __init__(self, coordinator, mac, name) -> None: - """Initialize a Ruckus Unleashed client.""" + """Initialize a Ruckus client.""" super().__init__(coordinator) self._mac = mac self._name = name diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json index 039840efc14..2066b65221e 100644 --- a/homeassistant/components/ruckus_unleashed/manifest.json +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -1,6 +1,6 @@ { "domain": "ruckus_unleashed", - "name": "Ruckus Unleashed", + "name": "Ruckus", "codeowners": ["@lanrat", "@ms264556", "@gabe565"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ruckus_unleashed", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index eab7bf224d2..a01e20909b6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5208,7 +5208,7 @@ "iot_class": "local_push" }, "ruckus_unleashed": { - "name": "Ruckus Unleashed", + "name": "Ruckus", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" diff --git a/tests/components/ruckus_unleashed/__init__.py b/tests/components/ruckus_unleashed/__init__.py index ccbf404cce0..b6c9c86953a 100644 --- a/tests/components/ruckus_unleashed/__init__.py +++ b/tests/components/ruckus_unleashed/__init__.py @@ -1,4 +1,4 @@ -"""Tests for the Ruckus Unleashed integration.""" +"""Tests for the Ruckus integration.""" from __future__ import annotations @@ -78,7 +78,7 @@ DEFAULT_UNIQUEID = DEFAULT_SYSTEM_INFO[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] def mock_config_entry() -> MockConfigEntry: - """Return a Ruckus Unleashed mock config entry.""" + """Return a Ruckus mock config entry.""" return MockConfigEntry( domain=DOMAIN, title=DEFAULT_TITLE, @@ -89,7 +89,7 @@ def mock_config_entry() -> MockConfigEntry: async def init_integration(hass: HomeAssistant) -> MockConfigEntry: - """Set up the Ruckus Unleashed integration in Home Assistant.""" + """Set up the Ruckus integration in Home Assistant.""" entry = mock_config_entry() entry.add_to_hass(hass) # Make device tied to other integration so device tracker entities get enabled diff --git a/tests/components/ruckus_unleashed/test_config_flow.py b/tests/components/ruckus_unleashed/test_config_flow.py index 89bd72d99e4..61f689f3030 100644 --- a/tests/components/ruckus_unleashed/test_config_flow.py +++ b/tests/components/ruckus_unleashed/test_config_flow.py @@ -1,4 +1,4 @@ -"""Test the Ruckus Unleashed config flow.""" +"""Test the config flow.""" from copy import deepcopy from datetime import timedelta diff --git a/tests/components/ruckus_unleashed/test_device_tracker.py b/tests/components/ruckus_unleashed/test_device_tracker.py index 79d7c2dfda4..460c64c9651 100644 --- a/tests/components/ruckus_unleashed/test_device_tracker.py +++ b/tests/components/ruckus_unleashed/test_device_tracker.py @@ -1,4 +1,4 @@ -"""The sensor tests for the Ruckus Unleashed platform.""" +"""The sensor tests for the Ruckus platform.""" from datetime import timedelta from unittest.mock import AsyncMock diff --git a/tests/components/ruckus_unleashed/test_init.py b/tests/components/ruckus_unleashed/test_init.py index 8147f040bde..a7514677f20 100644 --- a/tests/components/ruckus_unleashed/test_init.py +++ b/tests/components/ruckus_unleashed/test_init.py @@ -1,4 +1,4 @@ -"""Test the Ruckus Unleashed config flow.""" +"""Test the Ruckus config flow.""" from unittest.mock import AsyncMock From f8c94fd83f3ddba592933afce00d09e9774057af Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Fri, 6 Sep 2024 14:49:05 +0200 Subject: [PATCH 0316/1309] Remove attributes from Nuki entities (#125348) * Remove attributes from Nuki entities * remove tests --- homeassistant/components/nuki/binary_sensor.py | 18 +----------------- homeassistant/components/nuki/lock.py | 18 +----------------- homeassistant/components/nuki/sensor.py | 8 +------- .../nuki/snapshots/test_binary_sensor.ambr | 2 -- tests/components/nuki/snapshots/test_lock.ambr | 4 ---- .../components/nuki/snapshots/test_sensor.ambr | 1 - 6 files changed, 3 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 9b4772ee108..731b94e6551 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NukiEntity, NukiEntryData -from .const import ATTR_NUKI_ID, DOMAIN as NUKI_DOMAIN +from .const import DOMAIN as NUKI_DOMAIN async def async_setup_entry( @@ -51,14 +51,6 @@ class NukiDoorsensorEntity(NukiEntity[NukiDevice], BinarySensorEntity): """Return a unique ID.""" return f"{self._nuki_device.nuki_id}_doorsensor" - # Deprecated, can be removed in 2024.10 - @property - def extra_state_attributes(self): - """Return the device specific state attributes.""" - return { - ATTR_NUKI_ID: self._nuki_device.nuki_id, - } - @property def available(self) -> bool: """Return true if door sensor is present and activated.""" @@ -91,14 +83,6 @@ class NukiRingactionEntity(NukiEntity[NukiDevice], BinarySensorEntity): """Return a unique ID.""" return f"{self._nuki_device.nuki_id}_ringaction" - # Deprecated, can be removed in 2024.10 - @property - def extra_state_attributes(self): - """Return the device specific state attributes.""" - return { - ATTR_NUKI_ID: self._nuki_device.nuki_id, - } - @property def is_on(self) -> bool: """Return the value of the ring action state.""" diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 5a8734d5df7..6e1c98bc69c 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -18,14 +18,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NukiEntity, NukiEntryData -from .const import ( - ATTR_BATTERY_CRITICAL, - ATTR_ENABLE, - ATTR_NUKI_ID, - ATTR_UNLATCH, - DOMAIN as NUKI_DOMAIN, - ERROR_STATES, -) +from .const import ATTR_ENABLE, ATTR_UNLATCH, DOMAIN as NUKI_DOMAIN, ERROR_STATES from .helpers import CannotConnect @@ -75,15 +68,6 @@ class NukiDeviceEntity[_NukiDeviceT: NukiDevice](NukiEntity[_NukiDeviceT], LockE """Return a unique ID.""" return self._nuki_device.nuki_id - # Deprecated, can be removed in 2024.10 - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the device specific state attributes.""" - return { - ATTR_BATTERY_CRITICAL: self._nuki_device.battery_critical, - ATTR_NUKI_ID: self._nuki_device.nuki_id, - } - @property def available(self) -> bool: """Return True if entity is available.""" diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 6647eff5c83..628783062d3 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NukiEntity, NukiEntryData -from .const import ATTR_NUKI_ID, DOMAIN as NUKI_DOMAIN +from .const import DOMAIN as NUKI_DOMAIN async def async_setup_entry( @@ -38,12 +38,6 @@ class NukiBatterySensor(NukiEntity[NukiDevice], SensorEntity): """Return a unique ID.""" return f"{self._nuki_device.nuki_id}_battery_level" - # Deprecated, can be removed in 2024.10 - @property - def extra_state_attributes(self): - """Return the device specific state attributes.""" - return {ATTR_NUKI_ID: self._nuki_device.nuki_id} - @property def native_value(self) -> float: """Return the state of the sensor.""" diff --git a/tests/components/nuki/snapshots/test_binary_sensor.ambr b/tests/components/nuki/snapshots/test_binary_sensor.ambr index 4a122fa78f2..55976bcb433 100644 --- a/tests/components/nuki/snapshots/test_binary_sensor.ambr +++ b/tests/components/nuki/snapshots/test_binary_sensor.ambr @@ -83,7 +83,6 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Community door Ring Action', - 'nuki_id': 2, }), 'context': , 'entity_id': 'binary_sensor.community_door_ring_action', @@ -131,7 +130,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'door', 'friendly_name': 'Home', - 'nuki_id': 1, }), 'context': , 'entity_id': 'binary_sensor.home', diff --git a/tests/components/nuki/snapshots/test_lock.ambr b/tests/components/nuki/snapshots/test_lock.ambr index a0013fc37c1..24c80e7b487 100644 --- a/tests/components/nuki/snapshots/test_lock.ambr +++ b/tests/components/nuki/snapshots/test_lock.ambr @@ -35,9 +35,7 @@ # name: test_locks[lock.community_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_critical': False, 'friendly_name': 'Community door', - 'nuki_id': 2, 'supported_features': , }), 'context': , @@ -84,9 +82,7 @@ # name: test_locks[lock.home-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_critical': False, 'friendly_name': 'Home', - 'nuki_id': 1, 'supported_features': , }), 'context': , diff --git a/tests/components/nuki/snapshots/test_sensor.ambr b/tests/components/nuki/snapshots/test_sensor.ambr index 3c1159aecba..a319104fbc3 100644 --- a/tests/components/nuki/snapshots/test_sensor.ambr +++ b/tests/components/nuki/snapshots/test_sensor.ambr @@ -37,7 +37,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Home Battery', - 'nuki_id': 1, 'unit_of_measurement': '%', }), 'context': , From ba81a68982757d3dd3f64ddc9a0ec82e362e85fd Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 6 Sep 2024 14:49:58 +0200 Subject: [PATCH 0317/1309] Update frontend to 20240906.0 (#125409) --- 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 fbdafe6025d..e40832e4733 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==20240904.0"] + "requirements": ["home-assistant-frontend==20240906.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e489006867f..ac7b74429bd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20240904.0 +home-assistant-frontend==20240906.0 home-assistant-intents==2024.9.4 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6796a83c9c6..c6a8eba7b6f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1108,7 +1108,7 @@ hole==0.8.0 holidays==0.56 # homeassistant.components.frontend -home-assistant-frontend==20240904.0 +home-assistant-frontend==20240906.0 # homeassistant.components.conversation home-assistant-intents==2024.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df33037cf4c..da62c029875 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -934,7 +934,7 @@ hole==0.8.0 holidays==0.56 # homeassistant.components.frontend -home-assistant-frontend==20240904.0 +home-assistant-frontend==20240906.0 # homeassistant.components.conversation home-assistant-intents==2024.9.4 From 053e38db38f2852fd0497c1fc7f48eeb2b177e19 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 6 Sep 2024 14:57:04 +0200 Subject: [PATCH 0318/1309] Improve config flow type hints in volumio (#125318) --- .../components/volumio/config_flow.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/volumio/config_flow.py b/homeassistant/components/volumio/config_flow.py index 4c7a48f36c7..7cc58556f3e 100644 --- a/homeassistant/components/volumio/config_flow.py +++ b/homeassistant/components/volumio/config_flow.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -25,7 +25,7 @@ DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass, host, port): +async def validate_input(hass: HomeAssistant, host: str, port: int) -> dict[str, Any]: """Validate the user input allows us to connect.""" volumio = Volumio(host, port, async_get_clientsession(hass)) @@ -40,15 +40,13 @@ class VolumioConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize flow.""" - self._host: str | None = None - self._port: int | None = None - self._name: str | None = None - self._uuid: str | None = None + _host: str + _port: int + _name: str + _uuid: str | None @callback - def _async_get_entry(self): + def _async_get_entry(self) -> ConfigFlowResult: return self.async_create_entry( title=self._name, data={ @@ -103,7 +101,7 @@ class VolumioConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle zeroconf discovery.""" self._host = discovery_info.host - self._port = discovery_info.port + self._port = discovery_info.port or 3000 self._name = discovery_info.properties["volumioName"] self._uuid = discovery_info.properties["UUID"] @@ -111,7 +109,9 @@ class VolumioConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() - async def async_step_discovery_confirm(self, user_input=None): + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle user-confirmation of discovered node.""" if user_input is not None: try: From f3e2c5177435a0fb7dd97f20fe358243cfc8d399 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Fri, 6 Sep 2024 14:59:02 +0200 Subject: [PATCH 0319/1309] Add translations to Xiaomi Miio (#123822) * Add translations to Xiaomi Miio * Deduplicate translations --- .../components/xiaomi_miio/binary_sensor.py | 16 +- .../components/xiaomi_miio/button.py | 12 +- .../components/xiaomi_miio/number.py | 18 +- .../components/xiaomi_miio/sensor.py | 79 +++---- .../components/xiaomi_miio/strings.json | 217 ++++++++++++++++++ .../components/xiaomi_miio/switch.py | 22 +- 6 files changed, 287 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 6d1a81007dc..5d4b2042429 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -56,13 +56,13 @@ class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): BINARY_SENSOR_TYPES = ( XiaomiMiioBinarySensorDescription( key=ATTR_NO_WATER, - name="Water tank empty", + translation_key=ATTR_NO_WATER, icon="mdi:water-off-outline", entity_category=EntityCategory.DIAGNOSTIC, ), XiaomiMiioBinarySensorDescription( key=ATTR_WATER_TANK_DETACHED, - name="Water tank", + translation_key=ATTR_WATER_TANK_DETACHED, icon="mdi:car-coolant-level", device_class=BinarySensorDeviceClass.CONNECTIVITY, value=lambda value: not value, @@ -70,13 +70,13 @@ BINARY_SENSOR_TYPES = ( ), XiaomiMiioBinarySensorDescription( key=ATTR_PTC_STATUS, - name="Auxiliary heat status", + translation_key=ATTR_PTC_STATUS, device_class=BinarySensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, ), XiaomiMiioBinarySensorDescription( key=ATTR_POWERSUPPLY_ATTACHED, - name="Power supply", + translation_key=ATTR_POWERSUPPLY_ATTACHED, device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -88,7 +88,7 @@ FAN_ZA5_BINARY_SENSORS = (ATTR_POWERSUPPLY_ATTACHED,) VACUUM_SENSORS = { ATTR_MOP_ATTACHED: XiaomiMiioBinarySensorDescription( key=ATTR_WATER_BOX_ATTACHED, - name="Mop attached", + translation_key=ATTR_WATER_BOX_ATTACHED, icon="mdi:square-rounded", parent_key=VacuumCoordinatorDataAttributes.status, entity_registry_enabled_default=True, @@ -97,7 +97,7 @@ VACUUM_SENSORS = { ), ATTR_WATER_BOX_ATTACHED: XiaomiMiioBinarySensorDescription( key=ATTR_WATER_BOX_ATTACHED, - name="Water box attached", + translation_key=ATTR_WATER_BOX_ATTACHED, icon="mdi:water", parent_key=VacuumCoordinatorDataAttributes.status, entity_registry_enabled_default=True, @@ -106,7 +106,7 @@ VACUUM_SENSORS = { ), ATTR_WATER_SHORTAGE: XiaomiMiioBinarySensorDescription( key=ATTR_WATER_SHORTAGE, - name="Water shortage", + translation_key=ATTR_WATER_SHORTAGE, icon="mdi:water", parent_key=VacuumCoordinatorDataAttributes.status, entity_registry_enabled_default=True, @@ -119,7 +119,7 @@ VACUUM_SENSORS_SEPARATE_MOP = { **VACUUM_SENSORS, ATTR_MOP_ATTACHED: XiaomiMiioBinarySensorDescription( key=ATTR_MOP_ATTACHED, - name="Mop attached", + translation_key=ATTR_MOP_ATTACHED, icon="mdi:square-rounded", parent_key=VacuumCoordinatorDataAttributes.status, entity_registry_enabled_default=True, diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index 38e6afa5ffb..7496f765fe3 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -51,7 +51,7 @@ BUTTON_TYPES = ( # Fans XiaomiMiioButtonDescription( key=ATTR_RESET_DUST_FILTER, - name="Reset dust filter", + translation_key=ATTR_RESET_DUST_FILTER, icon="mdi:air-filter", method_press="reset_dust_filter", method_press_error_message="Resetting the dust filter lifetime failed", @@ -59,7 +59,7 @@ BUTTON_TYPES = ( ), XiaomiMiioButtonDescription( key=ATTR_RESET_UPPER_FILTER, - name="Reset upper filter", + translation_key=ATTR_RESET_UPPER_FILTER, icon="mdi:air-filter", method_press="reset_upper_filter", method_press_error_message="Resetting the upper filter lifetime failed.", @@ -68,7 +68,7 @@ BUTTON_TYPES = ( # Vacuums XiaomiMiioButtonDescription( key=ATTR_RESET_VACUUM_MAIN_BRUSH, - name="Reset main brush", + translation_key=ATTR_RESET_VACUUM_MAIN_BRUSH, icon="mdi:brush", method_press=METHOD_VACUUM_RESET_CONSUMABLE, method_press_params=Consumable.MainBrush, @@ -77,7 +77,7 @@ BUTTON_TYPES = ( ), XiaomiMiioButtonDescription( key=ATTR_RESET_VACUUM_SIDE_BRUSH, - name="Reset side brush", + translation_key=ATTR_RESET_VACUUM_SIDE_BRUSH, icon="mdi:brush", method_press=METHOD_VACUUM_RESET_CONSUMABLE, method_press_params=Consumable.SideBrush, @@ -86,7 +86,7 @@ BUTTON_TYPES = ( ), XiaomiMiioButtonDescription( key=ATTR_RESET_VACUUM_FILTER, - name="Reset filter", + translation_key=ATTR_RESET_VACUUM_FILTER, icon="mdi:air-filter", method_press=METHOD_VACUUM_RESET_CONSUMABLE, method_press_params=Consumable.Filter, @@ -95,7 +95,7 @@ BUTTON_TYPES = ( ), XiaomiMiioButtonDescription( key=ATTR_RESET_VACUUM_SENSOR_DIRTY, - name="Reset sensor dirty", + translation_key=ATTR_RESET_VACUUM_SENSOR_DIRTY, icon="mdi:eye-outline", method_press=METHOD_VACUUM_RESET_CONSUMABLE, method_press_params=Consumable.SensorDirty, diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index a0ae0ea5078..107debb7a60 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -139,7 +139,7 @@ class FavoriteLevelValues: NUMBER_TYPES = { FEATURE_SET_MOTOR_SPEED: XiaomiMiioNumberDescription( key=ATTR_MOTOR_SPEED, - name="Motor speed", + translation_key=ATTR_MOTOR_SPEED, icon="mdi:fast-forward-outline", native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, native_min_value=200, @@ -151,7 +151,7 @@ NUMBER_TYPES = { ), FEATURE_SET_FAVORITE_LEVEL: XiaomiMiioNumberDescription( key=ATTR_FAVORITE_LEVEL, - name="Favorite level", + translation_key=ATTR_FAVORITE_LEVEL, icon="mdi:star-cog", native_min_value=0, native_max_value=17, @@ -161,7 +161,7 @@ NUMBER_TYPES = { ), FEATURE_SET_FAN_LEVEL: XiaomiMiioNumberDescription( key=ATTR_FAN_LEVEL, - name="Fan level", + translation_key=ATTR_FAN_LEVEL, icon="mdi:fan", native_min_value=1, native_max_value=3, @@ -171,7 +171,7 @@ NUMBER_TYPES = { ), FEATURE_SET_VOLUME: XiaomiMiioNumberDescription( key=ATTR_VOLUME, - name="Volume", + translation_key=ATTR_VOLUME, icon="mdi:volume-high", native_min_value=0, native_max_value=100, @@ -181,7 +181,7 @@ NUMBER_TYPES = { ), FEATURE_SET_OSCILLATION_ANGLE: XiaomiMiioNumberDescription( key=ATTR_OSCILLATION_ANGLE, - name="Oscillation angle", + translation_key=ATTR_OSCILLATION_ANGLE, icon="mdi:angle-acute", native_unit_of_measurement=DEGREE, native_min_value=1, @@ -192,7 +192,7 @@ NUMBER_TYPES = { ), FEATURE_SET_DELAY_OFF_COUNTDOWN: XiaomiMiioNumberDescription( key=ATTR_DELAY_OFF_COUNTDOWN, - name="Delay off countdown", + translation_key=ATTR_DELAY_OFF_COUNTDOWN, icon="mdi:fan-off", native_unit_of_measurement=UnitOfTime.MINUTES, native_min_value=0, @@ -203,7 +203,7 @@ NUMBER_TYPES = { ), FEATURE_SET_LED_BRIGHTNESS: XiaomiMiioNumberDescription( key=ATTR_LED_BRIGHTNESS, - name="LED brightness", + translation_key=ATTR_LED_BRIGHTNESS, icon="mdi:brightness-6", native_min_value=0, native_max_value=100, @@ -213,7 +213,7 @@ NUMBER_TYPES = { ), FEATURE_SET_LED_BRIGHTNESS_LEVEL: XiaomiMiioNumberDescription( key=ATTR_LED_BRIGHTNESS_LEVEL, - name="LED brightness", + translation_key=ATTR_LED_BRIGHTNESS_LEVEL, icon="mdi:brightness-6", native_min_value=0, native_max_value=8, @@ -223,7 +223,7 @@ NUMBER_TYPES = { ), FEATURE_SET_FAVORITE_RPM: XiaomiMiioNumberDescription( key=ATTR_FAVORITE_RPM, - name="Favorite motor speed", + translation_key=ATTR_FAVORITE_RPM, icon="mdi:star-cog", native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, native_min_value=300, diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index ab992a8fe96..9b23e89903f 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -162,34 +162,31 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): SENSOR_TYPES = { ATTR_TEMPERATURE: XiaomiMiioSensorDescription( key=ATTR_TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), ATTR_HUMIDITY: XiaomiMiioSensorDescription( key=ATTR_HUMIDITY, - name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), ATTR_PRESSURE: XiaomiMiioSensorDescription( key=ATTR_PRESSURE, - name="Pressure", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, state_class=SensorStateClass.MEASUREMENT, ), ATTR_LOAD_POWER: XiaomiMiioSensorDescription( key=ATTR_LOAD_POWER, - name="Load power", + translation_key=ATTR_LOAD_POWER, native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, ), ATTR_WATER_LEVEL: XiaomiMiioSensorDescription( key=ATTR_WATER_LEVEL, - name="Water level", + translation_key=ATTR_WATER_LEVEL, native_unit_of_measurement=PERCENTAGE, icon="mdi:water-check", state_class=SensorStateClass.MEASUREMENT, @@ -197,7 +194,7 @@ SENSOR_TYPES = { ), ATTR_ACTUAL_SPEED: XiaomiMiioSensorDescription( key=ATTR_ACTUAL_SPEED, - name="Actual speed", + translation_key=ATTR_ACTUAL_SPEED, native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fast-forward", state_class=SensorStateClass.MEASUREMENT, @@ -205,7 +202,7 @@ SENSOR_TYPES = { ), ATTR_CONTROL_SPEED: XiaomiMiioSensorDescription( key=ATTR_CONTROL_SPEED, - name="Control speed", + translation_key=ATTR_CONTROL_SPEED, native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fast-forward", state_class=SensorStateClass.MEASUREMENT, @@ -213,7 +210,7 @@ SENSOR_TYPES = { ), ATTR_FAVORITE_SPEED: XiaomiMiioSensorDescription( key=ATTR_FAVORITE_SPEED, - name="Favorite speed", + translation_key=ATTR_FAVORITE_SPEED, native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fast-forward", state_class=SensorStateClass.MEASUREMENT, @@ -221,7 +218,7 @@ SENSOR_TYPES = { ), ATTR_MOTOR_SPEED: XiaomiMiioSensorDescription( key=ATTR_MOTOR_SPEED, - name="Motor speed", + translation_key=ATTR_MOTOR_SPEED, native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fast-forward", state_class=SensorStateClass.MEASUREMENT, @@ -229,7 +226,7 @@ SENSOR_TYPES = { ), ATTR_MOTOR2_SPEED: XiaomiMiioSensorDescription( key=ATTR_MOTOR2_SPEED, - name="Second motor speed", + translation_key=ATTR_MOTOR2_SPEED, native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, icon="mdi:fast-forward", state_class=SensorStateClass.MEASUREMENT, @@ -237,7 +234,7 @@ SENSOR_TYPES = { ), ATTR_USE_TIME: XiaomiMiioSensorDescription( key=ATTR_USE_TIME, - name="Use time", + translation_key=ATTR_USE_TIME, native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:progress-clock", device_class=SensorDeviceClass.DURATION, @@ -247,54 +244,52 @@ SENSOR_TYPES = { ), ATTR_ILLUMINANCE: XiaomiMiioSensorDescription( key=ATTR_ILLUMINANCE, - name="Illuminance", + translation_key=ATTR_ILLUMINANCE, native_unit_of_measurement=UNIT_LUMEN, state_class=SensorStateClass.MEASUREMENT, ), ATTR_ILLUMINANCE_LUX: XiaomiMiioSensorDescription( key=ATTR_ILLUMINANCE, - name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), ATTR_AIR_QUALITY: XiaomiMiioSensorDescription( key=ATTR_AIR_QUALITY, + translation_key=ATTR_AIR_QUALITY, native_unit_of_measurement="AQI", icon="mdi:cloud", state_class=SensorStateClass.MEASUREMENT, ), ATTR_TVOC: XiaomiMiioSensorDescription( key=ATTR_TVOC, - name="TVOC", + translation_key=ATTR_TVOC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, ), ATTR_PM10: XiaomiMiioSensorDescription( key=ATTR_PM10, - name="PM10", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, ), ATTR_PM25: XiaomiMiioSensorDescription( key=ATTR_AQI, - name="PM2.5", + translation_key=ATTR_AQI, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, ), ATTR_PM25_2: XiaomiMiioSensorDescription( key=ATTR_PM25, - name="PM2.5", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, ), ATTR_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( key=ATTR_FILTER_LIFE_REMAINING, - name="Filter lifetime remaining", + translation_key=ATTR_FILTER_LIFE_REMAINING, native_unit_of_measurement=PERCENTAGE, icon="mdi:air-filter", state_class=SensorStateClass.MEASUREMENT, @@ -303,7 +298,7 @@ SENSOR_TYPES = { ), ATTR_FILTER_USE: XiaomiMiioSensorDescription( key=ATTR_FILTER_HOURS_USED, - name="Filter use", + translation_key=ATTR_FILTER_HOURS_USED, native_unit_of_measurement=UnitOfTime.HOURS, icon="mdi:clock-outline", device_class=SensorDeviceClass.DURATION, @@ -312,7 +307,7 @@ SENSOR_TYPES = { ), ATTR_FILTER_LEFT_TIME: XiaomiMiioSensorDescription( key=ATTR_FILTER_LEFT_TIME, - name="Filter lifetime left", + translation_key=ATTR_FILTER_LEFT_TIME, native_unit_of_measurement=UnitOfTime.DAYS, icon="mdi:clock-outline", device_class=SensorDeviceClass.DURATION, @@ -321,7 +316,7 @@ SENSOR_TYPES = { ), ATTR_DUST_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( key=ATTR_DUST_FILTER_LIFE_REMAINING, - name="Dust filter lifetime remaining", + translation_key=ATTR_DUST_FILTER_LIFE_REMAINING, native_unit_of_measurement=PERCENTAGE, icon="mdi:air-filter", state_class=SensorStateClass.MEASUREMENT, @@ -330,7 +325,7 @@ SENSOR_TYPES = { ), ATTR_DUST_FILTER_LIFE_REMAINING_DAYS: XiaomiMiioSensorDescription( key=ATTR_DUST_FILTER_LIFE_REMAINING_DAYS, - name="Dust filter lifetime remaining days", + translation_key=ATTR_DUST_FILTER_LIFE_REMAINING_DAYS, native_unit_of_measurement=UnitOfTime.DAYS, icon="mdi:clock-outline", device_class=SensorDeviceClass.DURATION, @@ -339,7 +334,7 @@ SENSOR_TYPES = { ), ATTR_UPPER_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( key=ATTR_UPPER_FILTER_LIFE_REMAINING, - name="Upper filter lifetime remaining", + translation_key=ATTR_UPPER_FILTER_LIFE_REMAINING, native_unit_of_measurement=PERCENTAGE, icon="mdi:air-filter", state_class=SensorStateClass.MEASUREMENT, @@ -348,7 +343,7 @@ SENSOR_TYPES = { ), ATTR_UPPER_FILTER_LIFE_REMAINING_DAYS: XiaomiMiioSensorDescription( key=ATTR_UPPER_FILTER_LIFE_REMAINING_DAYS, - name="Upper filter lifetime remaining days", + translation_key=ATTR_UPPER_FILTER_LIFE_REMAINING_DAYS, native_unit_of_measurement=UnitOfTime.DAYS, icon="mdi:clock-outline", device_class=SensorDeviceClass.DURATION, @@ -357,14 +352,13 @@ SENSOR_TYPES = { ), ATTR_CARBON_DIOXIDE: XiaomiMiioSensorDescription( key=ATTR_CARBON_DIOXIDE, - name="Carbon dioxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), ATTR_PURIFY_VOLUME: XiaomiMiioSensorDescription( key=ATTR_PURIFY_VOLUME, - name="Purify volume", + translation_key=ATTR_PURIFY_VOLUME, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, @@ -373,7 +367,6 @@ SENSOR_TYPES = { ), ATTR_BATTERY: XiaomiMiioSensorDescription( key=ATTR_BATTERY, - name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, @@ -587,7 +580,7 @@ VACUUM_SENSORS = { f"dnd_{ATTR_DND_START}": XiaomiMiioSensorDescription( key=ATTR_DND_START, icon="mdi:minus-circle-off", - name="DnD start", + translation_key="dnd_start", device_class=SensorDeviceClass.TIMESTAMP, parent_key=VacuumCoordinatorDataAttributes.dnd_status, entity_registry_enabled_default=False, @@ -596,7 +589,7 @@ VACUUM_SENSORS = { f"dnd_{ATTR_DND_END}": XiaomiMiioSensorDescription( key=ATTR_DND_END, icon="mdi:minus-circle-off", - name="DnD end", + translation_key="dnd_end", device_class=SensorDeviceClass.TIMESTAMP, parent_key=VacuumCoordinatorDataAttributes.dnd_status, entity_registry_enabled_default=False, @@ -605,7 +598,7 @@ VACUUM_SENSORS = { f"last_clean_{ATTR_LAST_CLEAN_START}": XiaomiMiioSensorDescription( key=ATTR_LAST_CLEAN_START, icon="mdi:clock-time-twelve", - name="Last clean start", + translation_key="last_clean_start", device_class=SensorDeviceClass.TIMESTAMP, parent_key=VacuumCoordinatorDataAttributes.last_clean_details, entity_category=EntityCategory.DIAGNOSTIC, @@ -615,7 +608,7 @@ VACUUM_SENSORS = { icon="mdi:clock-time-twelve", device_class=SensorDeviceClass.TIMESTAMP, parent_key=VacuumCoordinatorDataAttributes.last_clean_details, - name="Last clean end", + translation_key="last_clean_end", entity_category=EntityCategory.DIAGNOSTIC, ), f"last_clean_{ATTR_LAST_CLEAN_TIME}": XiaomiMiioSensorDescription( @@ -624,7 +617,7 @@ VACUUM_SENSORS = { device_class=SensorDeviceClass.DURATION, key=ATTR_LAST_CLEAN_TIME, parent_key=VacuumCoordinatorDataAttributes.last_clean_details, - name="Last clean duration", + translation_key=ATTR_LAST_CLEAN_TIME, entity_category=EntityCategory.DIAGNOSTIC, ), f"last_clean_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription( @@ -632,7 +625,7 @@ VACUUM_SENSORS = { icon="mdi:texture-box", key=ATTR_LAST_CLEAN_AREA, parent_key=VacuumCoordinatorDataAttributes.last_clean_details, - name="Last clean area", + translation_key=ATTR_LAST_CLEAN_AREA, entity_category=EntityCategory.DIAGNOSTIC, ), f"current_{ATTR_STATUS_CLEAN_TIME}": XiaomiMiioSensorDescription( @@ -641,7 +634,7 @@ VACUUM_SENSORS = { device_class=SensorDeviceClass.DURATION, key=ATTR_STATUS_CLEAN_TIME, parent_key=VacuumCoordinatorDataAttributes.status, - name="Current clean duration", + translation_key=ATTR_STATUS_CLEAN_TIME, entity_category=EntityCategory.DIAGNOSTIC, ), f"current_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription( @@ -650,7 +643,7 @@ VACUUM_SENSORS = { key=ATTR_STATUS_CLEAN_AREA, parent_key=VacuumCoordinatorDataAttributes.status, entity_category=EntityCategory.DIAGNOSTIC, - name="Current clean area", + translation_key=ATTR_STATUS_CLEAN_AREA, ), f"clean_history_{ATTR_CLEAN_HISTORY_TOTAL_DURATION}": XiaomiMiioSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -658,7 +651,7 @@ VACUUM_SENSORS = { icon="mdi:timer-sand", key=ATTR_CLEAN_HISTORY_TOTAL_DURATION, parent_key=VacuumCoordinatorDataAttributes.clean_history_status, - name="Total duration", + translation_key=ATTR_CLEAN_HISTORY_TOTAL_DURATION, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -667,7 +660,7 @@ VACUUM_SENSORS = { icon="mdi:texture-box", key=ATTR_CLEAN_HISTORY_TOTAL_AREA, parent_key=VacuumCoordinatorDataAttributes.clean_history_status, - name="Total clean area", + translation_key=ATTR_CLEAN_HISTORY_TOTAL_AREA, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -677,7 +670,7 @@ VACUUM_SENSORS = { state_class=SensorStateClass.TOTAL_INCREASING, key=ATTR_CLEAN_HISTORY_COUNT, parent_key=VacuumCoordinatorDataAttributes.clean_history_status, - name="Total clean count", + translation_key=ATTR_CLEAN_HISTORY_COUNT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -687,7 +680,7 @@ VACUUM_SENSORS = { state_class=SensorStateClass.TOTAL_INCREASING, key=ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT, parent_key=VacuumCoordinatorDataAttributes.clean_history_status, - name="Total dust collection count", + translation_key=ATTR_CLEAN_HISTORY_DUST_COLLECTION_COUNT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -697,7 +690,7 @@ VACUUM_SENSORS = { device_class=SensorDeviceClass.DURATION, key=ATTR_CONSUMABLE_STATUS_MAIN_BRUSH_LEFT, parent_key=VacuumCoordinatorDataAttributes.consumable_status, - name="Main brush left", + translation_key=ATTR_CONSUMABLE_STATUS_MAIN_BRUSH_LEFT, entity_category=EntityCategory.DIAGNOSTIC, ), f"consumable_{ATTR_CONSUMABLE_STATUS_SIDE_BRUSH_LEFT}": XiaomiMiioSensorDescription( @@ -706,7 +699,7 @@ VACUUM_SENSORS = { device_class=SensorDeviceClass.DURATION, key=ATTR_CONSUMABLE_STATUS_SIDE_BRUSH_LEFT, parent_key=VacuumCoordinatorDataAttributes.consumable_status, - name="Side brush left", + translation_key=ATTR_CONSUMABLE_STATUS_SIDE_BRUSH_LEFT, entity_category=EntityCategory.DIAGNOSTIC, ), f"consumable_{ATTR_CONSUMABLE_STATUS_FILTER_LEFT}": XiaomiMiioSensorDescription( @@ -715,7 +708,7 @@ VACUUM_SENSORS = { device_class=SensorDeviceClass.DURATION, key=ATTR_CONSUMABLE_STATUS_FILTER_LEFT, parent_key=VacuumCoordinatorDataAttributes.consumable_status, - name="Filter left", + translation_key=ATTR_CONSUMABLE_STATUS_FILTER_LEFT, entity_category=EntityCategory.DIAGNOSTIC, ), f"consumable_{ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT}": XiaomiMiioSensorDescription( @@ -724,7 +717,7 @@ VACUUM_SENSORS = { device_class=SensorDeviceClass.DURATION, key=ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT, parent_key=VacuumCoordinatorDataAttributes.consumable_status, - name="Sensor dirty left", + translation_key=ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT, entity_category=EntityCategory.DIAGNOSTIC, ), } diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index bbdc3f5737d..6419c9056a5 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -105,6 +105,223 @@ } } } + }, + "binary_sensor": { + "no_water": { + "name": "Water tank empty" + }, + "water_tank_detached": { + "name": "Water tank" + }, + "ptc_status": { + "name": "Auxiliary heat status" + }, + "powersupply_attached": { + "name": "Power supply" + }, + "is_water_box_attached": { + "name": "Mop attached" + }, + "is_water_shortage": { + "name": "Water shortage" + }, + "is_water_box_carriage_attached": { + "name": "[%key:component::xiaomi_miio::entity::binary_sensor::is_water_box_attached::name%]" + } + }, + "button": { + "reset_dust_filter": { + "name": "Reset dust filter" + }, + "reset_upper_filter": { + "name": "Reset upper filter" + }, + "reset_vacuum_main_brush": { + "name": "Reset main brush" + }, + "reset_vacuum_side_brush": { + "name": "Reset side brush" + }, + "reset_vacuum_filter": { + "name": "Reset filter" + }, + "reset_vacuum_sensor_dirty": { + "name": "Reset sensor dirty" + } + }, + "number": { + "motor_speed": { + "name": "Motor speed" + }, + "favorite_level": { + "name": "Favorite level" + }, + "fan_level": { + "name": "Fan level" + }, + "volume": { + "name": "Volume" + }, + "angle": { + "name": "Oscillation angle" + }, + "delay_off_countdown": { + "name": "Delay off countdown" + }, + "led_brightness": { + "name": "LED brightness" + }, + "led_brightness_level": { + "name": "LED brightness" + }, + "favorite_rpm": { + "name": "Favorite motor speed" + } + }, + "sensor": { + "load_power": { + "name": "Load power" + }, + "water_level": { + "name": "Water level" + }, + "actual_speed": { + "name": "Actual speed" + }, + "control_speed": { + "name": "Control speed" + }, + "favorite_speed": { + "name": "Favorite speed" + }, + "motor_speed": { + "name": "[%key:component::xiaomi_miio::entity::number::motor_speed::name%]" + }, + "motor2_speed": { + "name": "Second motor speed" + }, + "use_time": { + "name": "Use time" + }, + "illuminance": { + "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "air_quality": { + "name": "Air quality" + }, + "tvoc": { + "name": "TVOC" + }, + "air_quality_index": { + "name": "Air quality index" + }, + "filter_life_remaining": { + "name": "Filter lifetime remaining" + }, + "filter_hours_used": { + "name": "Filter use" + }, + "filter_left_time": { + "name": "Filter lifetime left" + }, + "dust_filter_life_remaining": { + "name": "Dust filter lifetime remaining" + }, + "dust_filter_life_remaining_days": { + "name": "Dust filter lifetime remaining days" + }, + "upper_filter_life_remaining": { + "name": "Upper filter lifetime remaining" + }, + "upper_filter_life_remaining_days": { + "name": "Upper filter lifetime remaining days" + }, + "purify_volume": { + "name": "Purify volume" + }, + "dnd_start": { + "name": "DnD start" + }, + "dnd_end": { + "name": "DnD end" + }, + "last_clean_start": { + "name": "Last clean start" + }, + "last_clean_end": { + "name": "Last clean end" + }, + "duration": { + "name": "Last clean duration" + }, + "area": { + "name": "Last clean area" + }, + "clean_time": { + "name": "Current clean duration" + }, + "clean_area": { + "name": "Current clean area" + }, + "total_duration": { + "name": "Total duration" + }, + "total_area": { + "name": "Total clean area" + }, + "count": { + "name": "Total clean count" + }, + "dust_collection_count": { + "name": "Total dust collection count" + }, + "main_brush_left": { + "name": "Main brush left" + }, + "side_brush_left": { + "name": "Side brush left" + }, + "filter_left": { + "name": "Filter left" + }, + "sensor_dirty_left": { + "name": "Sensor dirty left" + } + }, + "switch": { + "buzzer": { + "name": "Buzzer" + }, + "child_lock": { + "name": "Child lock" + }, + "display": { + "name": "Display" + }, + "dry": { + "name": "Dry mode" + }, + "clean_mode": { + "name": "Clean mode" + }, + "led": { + "name": "LED" + }, + "learn_mode": { + "name": "Learn mode" + }, + "auto_detect": { + "name": "Auto detect" + }, + "ionizer": { + "name": "Ionizer" + }, + "anion": { + "name": "[%key:component::xiaomi_miio::entity::switch::ionizer::name%]" + }, + "ptc": { + "name": "Auxiliary heat" + } } }, "services": { diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 797a98d9fa1..42eb6cc0838 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -236,7 +236,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_BUZZER, feature=FEATURE_SET_BUZZER, - name="Buzzer", + translation_key=ATTR_BUZZER, icon="mdi:volume-high", method_on="async_set_buzzer_on", method_off="async_set_buzzer_off", @@ -245,7 +245,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_CHILD_LOCK, feature=FEATURE_SET_CHILD_LOCK, - name="Child lock", + translation_key=ATTR_CHILD_LOCK, icon="mdi:lock", method_on="async_set_child_lock_on", method_off="async_set_child_lock_off", @@ -254,7 +254,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_DISPLAY, feature=FEATURE_SET_DISPLAY, - name="Display", + translation_key=ATTR_DISPLAY, icon="mdi:led-outline", method_on="async_set_display_on", method_off="async_set_display_off", @@ -263,7 +263,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_DRY, feature=FEATURE_SET_DRY, - name="Dry mode", + translation_key=ATTR_DRY, icon="mdi:hair-dryer", method_on="async_set_dry_on", method_off="async_set_dry_off", @@ -272,7 +272,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_CLEAN, feature=FEATURE_SET_CLEAN, - name="Clean mode", + translation_key=ATTR_CLEAN, icon="mdi:shimmer", method_on="async_set_clean_on", method_off="async_set_clean_off", @@ -282,7 +282,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_LED, feature=FEATURE_SET_LED, - name="LED", + translation_key=ATTR_LED, icon="mdi:led-outline", method_on="async_set_led_on", method_off="async_set_led_off", @@ -291,7 +291,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_LEARN_MODE, feature=FEATURE_SET_LEARN_MODE, - name="Learn mode", + translation_key=ATTR_LEARN_MODE, icon="mdi:school-outline", method_on="async_set_learn_mode_on", method_off="async_set_learn_mode_off", @@ -300,7 +300,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_AUTO_DETECT, feature=FEATURE_SET_AUTO_DETECT, - name="Auto detect", + translation_key=ATTR_AUTO_DETECT, method_on="async_set_auto_detect_on", method_off="async_set_auto_detect_off", entity_category=EntityCategory.CONFIG, @@ -308,7 +308,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_IONIZER, feature=FEATURE_SET_IONIZER, - name="Ionizer", + translation_key=ATTR_IONIZER, icon="mdi:shimmer", method_on="async_set_ionizer_on", method_off="async_set_ionizer_off", @@ -317,7 +317,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_ANION, feature=FEATURE_SET_ANION, - name="Ionizer", + translation_key=ATTR_ANION, icon="mdi:shimmer", method_on="async_set_anion_on", method_off="async_set_anion_off", @@ -326,7 +326,7 @@ SWITCH_TYPES = ( XiaomiMiioSwitchDescription( key=ATTR_PTC, feature=FEATURE_SET_PTC, - name="Auxiliary heat", + translation_key=ATTR_PTC, icon="mdi:radiator", method_on="async_set_ptc_on", method_off="async_set_ptc_off", From af0a6d2820cdd0424aff742e4794351783da00ea Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:02:38 +0200 Subject: [PATCH 0320/1309] Improve play media support in LinkPlay (#125205) Improve play media support in linkplay --- homeassistant/components/linkplay/media_player.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index b1fa0e2a5c5..20b0f63f6a3 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -20,6 +20,9 @@ from homeassistant.components.media_player import ( MediaType, RepeatMode, ) +from homeassistant.components.media_player.browse_media import ( + async_process_play_media_url, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr @@ -238,10 +241,14 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a piece of media.""" - media = await media_source.async_resolve_media( - self.hass, media_id, self.entity_id - ) - await self._bridge.player.play(media.url) + if media_source.is_media_source_id(media_id): + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) + media_id = play_item.url + + url = async_process_play_media_url(self.hass, media_id) + await self._bridge.player.play(url) def _update_properties(self) -> None: """Update the properties of the media player.""" From 58056c49f7e782548075109df6b9628175c65cf8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:08:13 +0200 Subject: [PATCH 0321/1309] Improve config flow type hints (t-z) (#125315) --- homeassistant/components/wiffi/config_flow.py | 4 +++- homeassistant/components/wilight/config_flow.py | 5 ++++- homeassistant/components/ws66i/config_flow.py | 10 +++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/wiffi/config_flow.py b/homeassistant/components/wiffi/config_flow.py index 6e4872ea400..3fcbef395e6 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -83,7 +83,9 @@ class OptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, int] | None = None + ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py index babc011fc35..8795da19091 100644 --- a/homeassistant/components/wilight/config_flow.py +++ b/homeassistant/components/wilight/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure WiLight.""" +from typing import Any from urllib.parse import urlparse import pywilight @@ -89,7 +90,9 @@ class WiLightFlowHandler(ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"name": self._title} return await self.async_step_confirm() - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle user-confirmation of discovered WiLight.""" if user_input is not None: return self._get_entry() diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index 330e9963f95..9f6f4ca59c2 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -49,7 +49,7 @@ FIRST_ZONE = 11 @callback -def _sources_from_config(data): +def _sources_from_config(data: dict[str, str]) -> dict[str, str]: sources_config = { str(idx + 1): data.get(source) for idx, source in enumerate(SOURCES) } @@ -134,7 +134,9 @@ class WS66iConfigFlow(ConfigFlow, domain=DOMAIN): @callback -def _key_for_source(index, source, previous_sources): +def _key_for_source( + index: int, source: str, previous_sources: dict[str, str] +) -> vol.Required: return vol.Required( source, description={"suggested_value": previous_sources[str(index)]} ) @@ -147,7 +149,9 @@ class Ws66iOptionsFlowHandler(OptionsFlow): """Initialize.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry( From f5f8c44ca6cb3da4726e52da712158d29db25421 Mon Sep 17 00:00:00 2001 From: Eric Shtivelberg <295836+shedokan@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:08:30 +0300 Subject: [PATCH 0322/1309] Add Habitica up/down attributes for tasks (#125356) add: up/down --- homeassistant/components/habitica/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 8762345b597..fed1375c893 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -140,6 +140,8 @@ TASKS_MAP = { "frequency": "frequency", "every_x": "everyX", "streak": "streak", + "up": "up", + "down": "down", "counter_up": "counterUp", "counter_down": "counterDown", "next_due": "nextDue", From 66c6cd2a109d35ca2efb834d30b860b906d31a32 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:16:32 +0200 Subject: [PATCH 0323/1309] Improve config flow type hints in xiaomi_aqara (#125316) --- .../components/xiaomi_aqara/config_flow.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index a89bb8447a3..6252e6849d0 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -2,7 +2,7 @@ import logging from socket import gaierror -from typing import TYPE_CHECKING, Any +from typing import Any import voluptuous as vol from xiaomi_gateway import MULTICAST_PORT, XiaomiGateway, XiaomiGatewayDiscovery @@ -50,13 +50,14 @@ class XiaomiAqaraFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + selected_gateway: XiaomiGateway + gateways: dict[str, XiaomiGateway] + def __init__(self) -> None: """Initialize.""" self.host: str | None = None self.interface = DEFAULT_INTERFACE self.sid: str | None = None - self.gateways: dict[str, XiaomiGateway] | None = None - self.selected_gateway: XiaomiGateway | None = None @callback def async_show_form_step_user(self, errors): @@ -99,8 +100,6 @@ class XiaomiAqaraFlowHandler(ConfigFlow, domain=DOMAIN): None, ) - if TYPE_CHECKING: - assert self.selected_gateway if self.selected_gateway.connection_error: errors[CONF_HOST] = "invalid_host" if self.selected_gateway.mac_error: @@ -120,8 +119,6 @@ class XiaomiAqaraFlowHandler(ConfigFlow, domain=DOMAIN): self.gateways = xiaomi.gateways - if TYPE_CHECKING: - assert self.gateways is not None if len(self.gateways) == 1: self.selected_gateway = list(self.gateways.values())[0] self.sid = self.selected_gateway.sid @@ -132,9 +129,11 @@ class XiaomiAqaraFlowHandler(ConfigFlow, domain=DOMAIN): errors["base"] = "discovery_error" return self.async_show_form_step_user(errors) - async def async_step_select(self, user_input=None): + async def async_step_select( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle multiple aqara gateways found.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: ip_adress = user_input["select_ip"] self.selected_gateway = self.gateways[ip_adress] @@ -192,7 +191,9 @@ class XiaomiAqaraFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() - async def async_step_settings(self, user_input=None): + async def async_step_settings( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Specify settings and connect aqara gateway.""" errors = {} if user_input is not None: From b68c90d59a93953baf9dc43c8a3c55bc1d0488f3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:16:47 +0200 Subject: [PATCH 0324/1309] Improve config flow type hints in vulcan (#125308) * Improve config flow type hints in vulcan * Adjust tests --- .../components/vulcan/config_flow.py | 56 +++++++++++++------ homeassistant/components/vulcan/register.py | 4 +- tests/components/vulcan/test_config_flow.py | 4 +- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/vulcan/config_flow.py b/homeassistant/components/vulcan/config_flow.py index 5938e4ce690..f02adba9f75 100644 --- a/homeassistant/components/vulcan/config_flow.py +++ b/homeassistant/components/vulcan/config_flow.py @@ -2,7 +2,7 @@ from collections.abc import Mapping import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp import ClientConnectionError import voluptuous as vol @@ -16,6 +16,7 @@ from vulcan import ( UnauthorizedCertificateException, Vulcan, ) +from vulcan.model import Student from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PIN, CONF_REGION, CONF_TOKEN @@ -38,11 +39,12 @@ class VulcanFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + account: Account + keystore: Keystore + def __init__(self) -> None: """Initialize config flow.""" - self.account = None - self.keystore = None - self.students = None + self.students: list[Student] | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -53,13 +55,16 @@ class VulcanFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_auth() - async def async_step_auth(self, user_input=None, errors=None): + async def async_step_auth( + self, + user_input: dict[str, str] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: """Authorize integration.""" if user_input is not None: try: credentials = await register( - self.hass, user_input[CONF_TOKEN], user_input[CONF_REGION], user_input[CONF_PIN], @@ -107,16 +112,20 @@ class VulcanFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_select_student(self, user_input=None): + async def async_step_select_student( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Allow user to select student.""" - errors = {} - students = {} + errors: dict[str, str] = {} + students: dict[str, str] = {} if self.students is not None: for student in self.students: students[str(student.pupil.id)] = ( f"{student.pupil.first_name} {student.pupil.last_name}" ) if user_input is not None: + if TYPE_CHECKING: + assert self.keystore is not None student_id = user_input["student"] await self.async_set_unique_id(str(student_id)) self._abort_if_unique_id_configured() @@ -135,17 +144,25 @@ class VulcanFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_select_saved_credentials(self, user_input=None, errors=None): + async def async_step_select_saved_credentials( + self, + user_input: dict[str, str] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: """Allow user to select saved credentials.""" - credentials = {} + credentials: dict[str, Any] = {} for entry in self.hass.config_entries.async_entries(DOMAIN): credentials[entry.entry_id] = entry.data["account"]["UserName"] if user_input is not None: - entry = self.hass.config_entries.async_get_entry(user_input["credentials"]) - keystore = Keystore.load(entry.data["keystore"]) - account = Account.load(entry.data["account"]) + existing_entry = self.hass.config_entries.async_get_entry( + user_input["credentials"] + ) + if TYPE_CHECKING: + assert existing_entry is not None + keystore = Keystore.load(existing_entry.data["keystore"]) + account = Account.load(existing_entry.data["account"]) client = Vulcan(keystore, account, async_get_clientsession(self.hass)) try: students = await client.get_students() @@ -189,12 +206,14 @@ class VulcanFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_add_next_config_entry(self, user_input=None): + async def async_step_add_next_config_entry( + self, user_input: dict[str, bool] | None = None + ) -> ConfigFlowResult: """Flow initialized when user is adding next entry of that integration.""" existing_entries = self.hass.config_entries.async_entries(DOMAIN) - errors = {} + errors: dict[str, str] = {} if user_input is not None: if not user_input["use_saved_credentials"]: @@ -248,13 +267,14 @@ class VulcanFlowHandler(ConfigFlow, domain=DOMAIN): """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Reauthorize integration.""" errors = {} if user_input is not None: try: credentials = await register( - self.hass, user_input[CONF_TOKEN], user_input[CONF_REGION], user_input[CONF_PIN], diff --git a/homeassistant/components/vulcan/register.py b/homeassistant/components/vulcan/register.py index 67cceb8d7b8..a3dec97f622 100644 --- a/homeassistant/components/vulcan/register.py +++ b/homeassistant/components/vulcan/register.py @@ -1,9 +1,11 @@ """Support for register Vulcan account.""" +from typing import Any + from vulcan import Account, Keystore -async def register(hass, token, symbol, pin): +async def register(token: str, symbol: str, pin: str) -> dict[str, Any]: """Register integration and save credentials.""" keystore = await Keystore.create(device_model="Home Assistant") account = await Account.register(keystore, token, symbol, pin) diff --git a/tests/components/vulcan/test_config_flow.py b/tests/components/vulcan/test_config_flow.py index a72e77b32e8..a51d9727126 100644 --- a/tests/components/vulcan/test_config_flow.py +++ b/tests/components/vulcan/test_config_flow.py @@ -310,7 +310,7 @@ async def test_multiple_config_entries( unique_id="123456", data=json.loads(load_fixture("fake_config_entry_data.json", "vulcan")), ).add_to_hass(hass) - await register.register(hass, "token", "region", "000000") + await register.register("token", "region", "000000") result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -703,7 +703,7 @@ async def test_student_already_exists( | {"student_id": "0"}, ).add_to_hass(hass) - await register.register(hass, "token", "region", "000000") + await register.register("token", "region", "000000") result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} From 543f9869550a1a2645825248914f3904e0b38d3a Mon Sep 17 00:00:00 2001 From: GeoffAtHome Date: Fri, 6 Sep 2024 14:17:50 +0100 Subject: [PATCH 0325/1309] Improve geniushub test coverage (#124157) * Add tests for local connection * Test cloud setup * Improve tests. * Simplied coverage test to cloud setup. * Mock out library and add snapshots * Mock out library and add snapshots * Update tests/components/geniushub/conftest.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Attempt to make it nice * Fix --------- Co-authored-by: Joostlek Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- tests/components/geniushub/__init__.py | 12 + tests/components/geniushub/conftest.py | 39 +- .../fixtures/devices_cloud_test_data.json | 151 +++ .../fixtures/zones_cloud_test_data.json | 1069 +++++++++++++++++ .../snapshots/test_binary_sensor.ambr | 50 + .../geniushub/snapshots/test_climate.ambr | 569 +++++++++ .../geniushub/snapshots/test_sensor.ambr | 954 +++++++++++++++ .../geniushub/snapshots/test_switch.ambr | 166 +++ .../geniushub/test_binary_sensor.py | 32 + tests/components/geniushub/test_climate.py | 30 + tests/components/geniushub/test_sensor.py | 30 + tests/components/geniushub/test_switch.py | 30 + 12 files changed, 3130 insertions(+), 2 deletions(-) create mode 100644 tests/components/geniushub/fixtures/devices_cloud_test_data.json create mode 100644 tests/components/geniushub/fixtures/zones_cloud_test_data.json create mode 100644 tests/components/geniushub/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/geniushub/snapshots/test_climate.ambr create mode 100644 tests/components/geniushub/snapshots/test_sensor.ambr create mode 100644 tests/components/geniushub/snapshots/test_switch.ambr create mode 100644 tests/components/geniushub/test_binary_sensor.py create mode 100644 tests/components/geniushub/test_climate.py create mode 100644 tests/components/geniushub/test_sensor.py create mode 100644 tests/components/geniushub/test_switch.py diff --git a/tests/components/geniushub/__init__.py b/tests/components/geniushub/__init__.py index 15886486e38..ed06642d339 100644 --- a/tests/components/geniushub/__init__.py +++ b/tests/components/geniushub/__init__.py @@ -1 +1,13 @@ """Tests for the geniushub integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/geniushub/conftest.py b/tests/components/geniushub/conftest.py index 125f1cfa80c..15938eabc62 100644 --- a/tests/components/geniushub/conftest.py +++ b/tests/components/geniushub/conftest.py @@ -1,14 +1,16 @@ """GeniusHub tests configuration.""" from collections.abc import Generator -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch +from geniushubclient import GeniusDevice, GeniusZone import pytest from homeassistant.components.geniushub.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_array_fixture from tests.components.smhi.common import AsyncMock @@ -38,6 +40,38 @@ def mock_geniushub_client() -> Generator[AsyncMock]: yield client +@pytest.fixture(scope="session") +def zones() -> list[dict[str, Any]]: + """Return a list of zones.""" + return load_json_array_fixture("zones_cloud_test_data.json", DOMAIN) + + +@pytest.fixture(scope="session") +def devices() -> list[dict[str, Any]]: + """Return a list of devices.""" + return load_json_array_fixture("devices_cloud_test_data.json", DOMAIN) + + +@pytest.fixture +def mock_geniushub_cloud( + zones: list[dict[str, Any]], devices: list[dict[str, Any]] +) -> Generator[MagicMock]: + """Mock a GeniusHub.""" + with patch( + "homeassistant.components.geniushub.GeniusHub", + autospec=True, + ) as mock_client: + client = mock_client.return_value + genius_zones = [GeniusZone(z["id"], z, client) for z in zones] + client.zone_objs = genius_zones + client._zones = genius_zones + genius_devices = [GeniusDevice(d["id"], d, client) for d in devices] + client.device_objs = genius_devices + client._devices = genius_devices + client.api_version = 1 + yield client + + @pytest.fixture def mock_local_config_entry() -> MockConfigEntry: """Mock a local config entry.""" @@ -62,4 +96,5 @@ def mock_cloud_config_entry() -> MockConfigEntry: data={ CONF_TOKEN: "abcdef", }, + entry_id="01J71MQF0EC62D620DGYNG2R8H", ) diff --git a/tests/components/geniushub/fixtures/devices_cloud_test_data.json b/tests/components/geniushub/fixtures/devices_cloud_test_data.json new file mode 100644 index 00000000000..92fd2c33811 --- /dev/null +++ b/tests/components/geniushub/fixtures/devices_cloud_test_data.json @@ -0,0 +1,151 @@ +[ + { + "id": "4", + "type": "Smart Plug", + "assignedZones": [{ "name": "Bedroom Socket" }], + "state": { "outputOnOff": "True" } + }, + { + "id": "6", + "type": "Smart Plug", + "assignedZones": [{ "name": "Kitchen Socket" }], + "state": { "outputOnOff": "True" } + }, + { + "id": "11", + "type": "Radiator Valve", + "assignedZones": [{ "name": "Lounge" }], + "state": { "batteryLevel": 43, "setTemperature": 4 } + }, + { + "id": "16", + "type": "Room Sensor", + "assignedZones": [{ "name": "Guest room" }], + "state": { + "batteryLevel": 100, + "measuredTemperature": 21, + "luminance": 29, + "occupancyTrigger": 255 + } + }, + { + "id": "17", + "type": "Room Sensor", + "assignedZones": [{ "name": "Ensuite" }], + "state": { + "batteryLevel": 100, + "measuredTemperature": 21, + "luminance": 32, + "occupancyTrigger": 0 + } + }, + { + "id": "18", + "type": "Room Sensor", + "assignedZones": [{ "name": "Bedroom" }], + "state": { + "batteryLevel": 36, + "measuredTemperature": 21.5, + "luminance": 1, + "occupancyTrigger": 0 + } + }, + { + "id": "20", + "type": "Room Sensor", + "assignedZones": [{ "name": "Kitchen" }], + "state": { + "batteryLevel": 100, + "measuredTemperature": 21.5, + "luminance": 1, + "occupancyTrigger": 0 + } + }, + { + "id": "21", + "type": "Room Sensor", + "assignedZones": [{ "name": "Hall" }], + "state": { + "batteryLevel": 100, + "measuredTemperature": 21, + "luminance": 33, + "occupancyTrigger": 0 + } + }, + { + "id": "22", + "type": "Single Channel Receiver", + "assignedZones": [{ "name": "East Berlin" }], + "state": { "outputOnOff": "False" } + }, + { + "id": "50", + "type": "Room Sensor", + "assignedZones": [{ "name": "Study" }], + "state": { + "batteryLevel": 100, + "measuredTemperature": 22, + "luminance": 34, + "occupancyTrigger": 0 + } + }, + { + "id": "53", + "type": "Room Sensor", + "assignedZones": [{ "name": "Lounge" }], + "state": { + "batteryLevel": 28, + "measuredTemperature": 0, + "luminance": 0, + "occupancyTrigger": 0 + } + }, + { + "id": "56", + "type": "Radiator Valve", + "assignedZones": [{ "name": "Kitchen" }], + "state": { "batteryLevel": 55, "setTemperature": 4 } + }, + { + "id": "68", + "type": "Radiator Valve", + "assignedZones": [{ "name": "Hall" }], + "state": { "batteryLevel": 92, "setTemperature": 4 } + }, + { + "id": "78", + "type": "Radiator Valve", + "assignedZones": [{ "name": "Bedroom" }], + "state": { "batteryLevel": 42, "setTemperature": 4 } + }, + { + "id": "85", + "type": "Radiator Valve", + "assignedZones": [{ "name": "Study" }], + "state": { "batteryLevel": 61, "setTemperature": 4 } + }, + { + "id": "86", + "type": "Smart Plug", + "assignedZones": [{ "name": "Study Socket" }], + "state": { "outputOnOff": "False" } + }, + { + "id": "88", + "type": "Radiator Valve", + "assignedZones": [{ "name": "Ensuite" }], + "state": { "batteryLevel": 49, "setTemperature": 4 } + }, + { + "id": "89", + "type": "Radiator Valve", + "assignedZones": [{ "name": "Kitchen" }], + "state": { "batteryLevel": 48, "setTemperature": 4 } + }, + { + "id": "90", + "type": "Radiator Valve", + "assignedZones": [{ "name": "Guest room" }], + "state": { "batteryLevel": 92, "setTemperature": 4 } + } +] diff --git a/tests/components/geniushub/fixtures/zones_cloud_test_data.json b/tests/components/geniushub/fixtures/zones_cloud_test_data.json new file mode 100644 index 00000000000..00d3109cf6e --- /dev/null +++ b/tests/components/geniushub/fixtures/zones_cloud_test_data.json @@ -0,0 +1,1069 @@ +[ + { + "id": 0, + "name": "West Berlin", + "output": 0, + "type": "manager", + "mode": "off", + "schedule": { "timer": {}, "footprint": {} } + }, + { + "id": 1, + "name": "Lounge", + "output": 0, + "type": "radiator", + "mode": "off", + "temperature": 20, + "setpoint": 4, + "override": { "duration": 0, "setpoint": 20 }, + "schedule": { + "timer": { + "weekly": { + "sunday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 68400, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 68400, "setpoint": 20 }, + { "end": 81000, "start": 75600, "setpoint": 18 } + ] + }, + "monday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 68400, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 68400, "setpoint": 20 }, + { "end": 81000, "start": 75600, "setpoint": 18 } + ] + }, + "tuesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 68400, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 68400, "setpoint": 20 }, + { "end": 81000, "start": 75600, "setpoint": 18 } + ] + }, + "wednesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 68400, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 68400, "setpoint": 20 }, + { "end": 81000, "start": 75600, "setpoint": 18 } + ] + }, + "thursday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 68400, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 68400, "setpoint": 20 }, + { "end": 81000, "start": 75600, "setpoint": 18 } + ] + }, + "friday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 68400, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 68400, "setpoint": 20 }, + { "end": 81000, "start": 75600, "setpoint": 18 } + ] + }, + "saturday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 68400, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 68400, "setpoint": 20 }, + { "end": 81000, "start": 75600, "setpoint": 18 } + ] + } + } + }, + "footprint": { + "weekly": { + "sunday": { + "defaultSetpoint": 17, + "heatingPeriods": [ + { "end": 61200, "start": 0, "setpoint": 4 }, + { "end": 86400, "start": 80100, "setpoint": 4 } + ] + }, + "monday": { + "defaultSetpoint": 17, + "heatingPeriods": [ + { "end": 61200, "start": 0, "setpoint": 4 }, + { "end": 86400, "start": 80100, "setpoint": 4 } + ] + }, + "tuesday": { + "defaultSetpoint": 17, + "heatingPeriods": [ + { "end": 61200, "start": 0, "setpoint": 4 }, + { "end": 86400, "start": 80100, "setpoint": 4 } + ] + }, + "wednesday": { + "defaultSetpoint": 17, + "heatingPeriods": [ + { "end": 61200, "start": 0, "setpoint": 4 }, + { "end": 86400, "start": 80100, "setpoint": 4 } + ] + }, + "thursday": { + "defaultSetpoint": 17, + "heatingPeriods": [ + { "end": 61200, "start": 0, "setpoint": 4 }, + { "end": 86400, "start": 80100, "setpoint": 4 } + ] + }, + "friday": { + "defaultSetpoint": 17, + "heatingPeriods": [ + { "end": 61200, "start": 0, "setpoint": 4 }, + { "end": 86400, "start": 80100, "setpoint": 4 } + ] + }, + "saturday": { + "defaultSetpoint": 17, + "heatingPeriods": [ + { "end": 61200, "start": 0, "setpoint": 4 }, + { "end": 86400, "start": 80100, "setpoint": 4 } + ] + } + } + } + } + }, + { + "id": 2, + "name": "Hall", + "output": 0, + "type": "radiator", + "mode": "off", + "temperature": 21, + "setpoint": 4, + "occupied": "False", + "override": { "duration": 0, "setpoint": 20 }, + "schedule": { + "timer": { + "weekly": { + "sunday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "monday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "tuesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "wednesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "thursday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "friday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "saturday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 73800, "start": 68400, "setpoint": 18.5 } + ] + } + } + }, + "footprint": { + "weekly": { + "sunday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 37800, "start": 32400, "setpoint": 20 }, + { "end": 75600, "start": 56700, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "monday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 43500, "start": 31800, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "tuesday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 34200, "start": 27300, "setpoint": 20 }, + { "end": 75600, "start": 60900, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "wednesday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 48300, "start": 28800, "setpoint": 20 }, + { "end": 75600, "start": 75300, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "thursday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 42000, "start": 28500, "setpoint": 20 }, + { "end": 70800, "start": 53700, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "friday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 64500, "start": 28500, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "saturday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 63900, "start": 53100, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + } + } + } + } + }, + { + "id": 3, + "name": "Kitchen", + "output": 0, + "type": "radiator", + "mode": "off", + "temperature": 21.5, + "setpoint": 4, + "occupied": "False", + "override": { "duration": 0, "setpoint": 20 }, + "schedule": { + "timer": { + "weekly": { + "sunday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "monday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "tuesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "wednesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "thursday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "friday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 70200, "start": 61200, "setpoint": 18.5 } + ] + }, + "saturday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 61200, "start": 29700, "setpoint": 6 }, + { "end": 73800, "start": 68400, "setpoint": 18.5 } + ] + } + } + }, + "footprint": { + "weekly": { + "sunday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 38100, "start": 29100, "setpoint": 20 }, + { "end": 75600, "start": 56700, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "monday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 51600, "start": 32400, "setpoint": 20 }, + { "end": 74400, "start": 60600, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "tuesday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 33300, "start": 27300, "setpoint": 20 }, + { "end": 75600, "start": 58800, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "wednesday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 48600, "start": 28800, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "thursday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 71400, "start": 56400, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "friday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 74400, "start": 40800, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "saturday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 63300, "start": 29700, "setpoint": 20 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + } + } + } + } + }, + { + "id": 5, + "name": "Ensuite", + "output": 0, + "type": "radiator", + "mode": "off", + "temperature": 21, + "setpoint": 4, + "occupied": "False", + "override": { "duration": 0, "setpoint": 28 }, + "schedule": { + "timer": { + "weekly": { + "sunday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 73800, "setpoint": 16 } + ] + }, + "monday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 73800, "setpoint": 16 } + ] + }, + "tuesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 73800, "setpoint": 16 } + ] + }, + "wednesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 73800, "setpoint": 16 } + ] + }, + "thursday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 73800, "setpoint": 16 } + ] + }, + "friday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 73800, "setpoint": 16 } + ] + }, + "saturday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 73800, "setpoint": 16 } + ] + } + } + }, + "footprint": { + "weekly": { + "sunday": { + "defaultSetpoint": 12, + "heatingPeriods": [ + { "end": 28800, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 81000, "setpoint": 16 } + ] + }, + "monday": { + "defaultSetpoint": 12, + "heatingPeriods": [ + { "end": 28800, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 81000, "setpoint": 16 } + ] + }, + "tuesday": { + "defaultSetpoint": 12, + "heatingPeriods": [ + { "end": 28800, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 81000, "setpoint": 16 } + ] + }, + "wednesday": { + "defaultSetpoint": 12, + "heatingPeriods": [ + { "end": 28800, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 81000, "setpoint": 16 } + ] + }, + "thursday": { + "defaultSetpoint": 12, + "heatingPeriods": [ + { "end": 28800, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 81000, "setpoint": 16 } + ] + }, + "friday": { + "defaultSetpoint": 12, + "heatingPeriods": [ + { "end": 28800, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 81000, "setpoint": 16 } + ] + }, + "saturday": { + "defaultSetpoint": 12, + "heatingPeriods": [ + { "end": 28800, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 81000, "setpoint": 16 } + ] + } + } + } + } + }, + { + "id": 7, + "name": "Guest room", + "output": 0, + "type": "radiator", + "mode": "off", + "temperature": 21, + "setpoint": 4, + "occupied": "True", + "override": { "duration": 0, "setpoint": 20 }, + "schedule": { + "timer": { + "weekly": { + "sunday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "monday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "tuesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 75600, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "wednesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "thursday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "friday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "saturday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + } + } + }, + "footprint": { + "weekly": { + "sunday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "monday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "tuesday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "wednesday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "thursday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "friday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "saturday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + } + } + } + } + }, + { + "id": 27, + "name": "Bedroom Socket", + "output": 1, + "type": "on / off", + "mode": "timer", + "setpoint": "True", + "override": { "duration": 0, "setpoint": "True" }, + "schedule": { + "timer": { + "weekly": { + "sunday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "monday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "tuesday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "wednesday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "thursday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "friday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "saturday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + } + } + }, + "footprint": {} + } + }, + { + "id": 28, + "name": "Kitchen Socket", + "output": 1, + "type": "on / off", + "mode": "timer", + "setpoint": "True", + "override": { "duration": 0, "setpoint": "True" }, + "schedule": { + "timer": { + "weekly": { + "sunday": { + "defaultSetpoint": "False", + "heatingPeriods": [ + { "end": 82800, "start": 27000, "setpoint": "True" } + ] + }, + "monday": { + "defaultSetpoint": "False", + "heatingPeriods": [ + { "end": 82800, "start": 27000, "setpoint": "True" } + ] + }, + "tuesday": { + "defaultSetpoint": "False", + "heatingPeriods": [ + { "end": 82800, "start": 27000, "setpoint": "True" } + ] + }, + "wednesday": { + "defaultSetpoint": "False", + "heatingPeriods": [ + { "end": 82800, "start": 27000, "setpoint": "True" } + ] + }, + "thursday": { + "defaultSetpoint": "False", + "heatingPeriods": [ + { "end": 82800, "start": 27000, "setpoint": "True" } + ] + }, + "friday": { + "defaultSetpoint": "False", + "heatingPeriods": [ + { "end": 82800, "start": 27000, "setpoint": "True" } + ] + }, + "saturday": { + "defaultSetpoint": "False", + "heatingPeriods": [ + { "end": 82800, "start": 27000, "setpoint": "True" } + ] + } + } + }, + "footprint": {} + } + }, + { + "id": 29, + "name": "Bedroom", + "output": 0, + "type": "radiator", + "mode": "off", + "temperature": 21.5, + "setpoint": 4, + "override": { "duration": 0, "setpoint": 23.5 }, + "schedule": { + "timer": { + "weekly": { + "sunday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 75600, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "monday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 75600, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "tuesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 75600, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "wednesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 73800, "setpoint": 18.5 } + ] + }, + "thursday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 75600, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "friday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 75600, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 75600, "setpoint": 19.5 } + ] + }, + "saturday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 75600, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + } + } + }, + "footprint": { + "weekly": { + "sunday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "monday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "tuesday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "wednesday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "thursday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "friday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "saturday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + } + } + } + } + }, + { + "id": 30, + "name": "Study", + "output": 0, + "type": "radiator", + "mode": "off", + "temperature": 22, + "setpoint": 4, + "occupied": "False", + "override": { "duration": 0, "setpoint": 28 }, + "schedule": { + "timer": { + "weekly": { + "sunday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "monday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "tuesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 75600, "start": 29700, "setpoint": 6 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "wednesday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "thursday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "friday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + }, + "saturday": { + "defaultSetpoint": 14.5, + "heatingPeriods": [ + { "end": 29700, "start": 27000, "setpoint": 18 }, + { "end": 73800, "start": 29700, "setpoint": 6 }, + { "end": 75600, "start": 73800, "setpoint": 14 }, + { "end": 81000, "start": 75600, "setpoint": 18.5 } + ] + } + } + }, + "footprint": { + "weekly": { + "sunday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "monday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "tuesday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "wednesday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "thursday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "friday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + }, + "saturday": { + "defaultSetpoint": 14, + "heatingPeriods": [ + { "end": 23400, "start": 0, "setpoint": 16 }, + { "end": 86400, "start": 75600, "setpoint": 16 } + ] + } + } + } + } + }, + { + "id": 32, + "name": "Study Socket", + "output": 0, + "type": "on / off", + "mode": "off", + "setpoint": "False", + "override": { "duration": 0, "setpoint": "True" }, + "schedule": { + "timer": { + "weekly": { + "sunday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "monday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "tuesday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "wednesday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "thursday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "friday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + }, + "saturday": { + "defaultSetpoint": "False", + "heatingPeriods": [{ "end": 86400, "start": 0, "setpoint": "True" }] + } + } + }, + "footprint": {} + } + } +] diff --git a/tests/components/geniushub/snapshots/test_binary_sensor.ambr b/tests/components/geniushub/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..fcc256b5232 --- /dev/null +++ b/tests/components/geniushub/snapshots/test_binary_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_cloud_all_sensors[binary_sensor.single_channel_receiver_22-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.single_channel_receiver_22', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Single Channel Receiver 22', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_22', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[binary_sensor.single_channel_receiver_22-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'East Berlin', + 'friendly_name': 'Single Channel Receiver 22', + 'state': dict({ + }), + }), + 'context': , + 'entity_id': 'binary_sensor.single_channel_receiver_22', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/geniushub/snapshots/test_climate.ambr b/tests/components/geniushub/snapshots/test_climate.ambr new file mode 100644 index 00000000000..eb372de784e --- /dev/null +++ b/tests/components/geniushub/snapshots/test_climate.ambr @@ -0,0 +1,569 @@ +# serializer version: 1 +# name: test_cloud_all_sensors[climate.bedroom-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'boost', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bedroom', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator', + 'original_name': 'Bedroom', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_29', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[climate.bedroom-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'Bedroom', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator', + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_mode': None, + 'preset_modes': list([ + 'boost', + ]), + 'status': dict({ + 'mode': 'off', + 'override': dict({ + 'duration': 0, + 'setpoint': 23.5, + }), + 'temperature': 21.5, + 'type': 'radiator', + }), + 'supported_features': , + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.bedroom', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_cloud_all_sensors[climate.ensuite-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'activity', + 'boost', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.ensuite', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator', + 'original_name': 'Ensuite', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_5', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[climate.ensuite-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21, + 'friendly_name': 'Ensuite', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator', + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_mode': None, + 'preset_modes': list([ + 'activity', + 'boost', + ]), + 'status': dict({ + 'mode': 'off', + 'occupied': 'False', + 'override': dict({ + 'duration': 0, + 'setpoint': 28, + }), + 'temperature': 21, + 'type': 'radiator', + }), + 'supported_features': , + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.ensuite', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_cloud_all_sensors[climate.guest_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'activity', + 'boost', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.guest_room', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator', + 'original_name': 'Guest room', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_7', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[climate.guest_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21, + 'friendly_name': 'Guest room', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator', + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_mode': None, + 'preset_modes': list([ + 'activity', + 'boost', + ]), + 'status': dict({ + 'mode': 'off', + 'occupied': 'True', + 'override': dict({ + 'duration': 0, + 'setpoint': 20, + }), + 'temperature': 21, + 'type': 'radiator', + }), + 'supported_features': , + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.guest_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_cloud_all_sensors[climate.hall-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'activity', + 'boost', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.hall', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator', + 'original_name': 'Hall', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[climate.hall-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21, + 'friendly_name': 'Hall', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator', + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_mode': None, + 'preset_modes': list([ + 'activity', + 'boost', + ]), + 'status': dict({ + 'mode': 'off', + 'occupied': 'False', + 'override': dict({ + 'duration': 0, + 'setpoint': 20, + }), + 'temperature': 21, + 'type': 'radiator', + }), + 'supported_features': , + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.hall', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_cloud_all_sensors[climate.kitchen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'activity', + 'boost', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.kitchen', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator', + 'original_name': 'Kitchen', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[climate.kitchen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.5, + 'friendly_name': 'Kitchen', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator', + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_mode': None, + 'preset_modes': list([ + 'activity', + 'boost', + ]), + 'status': dict({ + 'mode': 'off', + 'occupied': 'False', + 'override': dict({ + 'duration': 0, + 'setpoint': 20, + }), + 'temperature': 21.5, + 'type': 'radiator', + }), + 'supported_features': , + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.kitchen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_cloud_all_sensors[climate.lounge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'boost', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.lounge', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator', + 'original_name': 'Lounge', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[climate.lounge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20, + 'friendly_name': 'Lounge', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator', + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_mode': None, + 'preset_modes': list([ + 'boost', + ]), + 'status': dict({ + 'mode': 'off', + 'override': dict({ + 'duration': 0, + 'setpoint': 20, + }), + 'temperature': 20, + 'type': 'radiator', + }), + 'supported_features': , + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.lounge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_cloud_all_sensors[climate.study-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_modes': list([ + 'activity', + 'boost', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.study', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator', + 'original_name': 'Study', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_30', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[climate.study-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22, + 'friendly_name': 'Study', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator', + 'max_temp': 28.0, + 'min_temp': 4.0, + 'preset_mode': None, + 'preset_modes': list([ + 'activity', + 'boost', + ]), + 'status': dict({ + 'mode': 'off', + 'occupied': 'False', + 'override': dict({ + 'duration': 0, + 'setpoint': 28, + }), + 'temperature': 22, + 'type': 'radiator', + }), + 'supported_features': , + 'temperature': 4, + }), + 'context': , + 'entity_id': 'climate.study', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/geniushub/snapshots/test_sensor.ambr b/tests/components/geniushub/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..874f24cff95 --- /dev/null +++ b/tests/components/geniushub/snapshots/test_sensor.ambr @@ -0,0 +1,954 @@ +# serializer version: 1 +# name: test_cloud_all_sensors[sensor.geniushub_errors-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.geniushub_errors', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'GeniusHub Errors', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_Errors', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[sensor.geniushub_errors-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'error_list': list([ + ]), + 'friendly_name': 'GeniusHub Errors', + }), + 'context': , + 'entity_id': 'sensor.geniushub_errors', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_cloud_all_sensors[sensor.geniushub_information-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.geniushub_information', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'GeniusHub Information', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_Information', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[sensor.geniushub_information-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GeniusHub Information', + 'information_list': list([ + ]), + }), + 'context': , + 'entity_id': 'sensor.geniushub_information', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_cloud_all_sensors[sensor.geniushub_warnings-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.geniushub_warnings', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'GeniusHub Warnings', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_Warnings', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[sensor.geniushub_warnings-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GeniusHub Warnings', + 'warning_list': list([ + ]), + }), + 'context': , + 'entity_id': 'sensor.geniushub_warnings', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_11-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_valve_11', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-40', + 'original_name': 'Radiator Valve 11', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_11', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_11-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Lounge', + 'device_class': 'battery', + 'friendly_name': 'Radiator Valve 11', + 'icon': 'mdi:battery-40', + 'state': dict({ + 'set_temperature': 4, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_valve_11', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '43', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_56-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_valve_56', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-50', + 'original_name': 'Radiator Valve 56', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_56', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_56-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Kitchen', + 'device_class': 'battery', + 'friendly_name': 'Radiator Valve 56', + 'icon': 'mdi:battery-50', + 'state': dict({ + 'set_temperature': 4, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_valve_56', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_68-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_valve_68', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-90', + 'original_name': 'Radiator Valve 68', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_68', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_68-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Hall', + 'device_class': 'battery', + 'friendly_name': 'Radiator Valve 68', + 'icon': 'mdi:battery-90', + 'state': dict({ + 'set_temperature': 4, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_valve_68', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '92', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_78-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_valve_78', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-40', + 'original_name': 'Radiator Valve 78', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_78', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_78-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Bedroom', + 'device_class': 'battery', + 'friendly_name': 'Radiator Valve 78', + 'icon': 'mdi:battery-40', + 'state': dict({ + 'set_temperature': 4, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_valve_78', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_85-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_valve_85', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-60', + 'original_name': 'Radiator Valve 85', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_85', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_85-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Study', + 'device_class': 'battery', + 'friendly_name': 'Radiator Valve 85', + 'icon': 'mdi:battery-60', + 'state': dict({ + 'set_temperature': 4, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_valve_85', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '61', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_88-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_valve_88', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-50', + 'original_name': 'Radiator Valve 88', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_88', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_88-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Ensuite', + 'device_class': 'battery', + 'friendly_name': 'Radiator Valve 88', + 'icon': 'mdi:battery-50', + 'state': dict({ + 'set_temperature': 4, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_valve_88', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_89-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_valve_89', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-50', + 'original_name': 'Radiator Valve 89', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_89', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_89-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Kitchen', + 'device_class': 'battery', + 'friendly_name': 'Radiator Valve 89', + 'icon': 'mdi:battery-50', + 'state': dict({ + 'set_temperature': 4, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_valve_89', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_90-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.radiator_valve_90', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-90', + 'original_name': 'Radiator Valve 90', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_90', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.radiator_valve_90-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Guest room', + 'device_class': 'battery', + 'friendly_name': 'Radiator Valve 90', + 'icon': 'mdi:battery-90', + 'state': dict({ + 'set_temperature': 4, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.radiator_valve_90', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '92', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_16-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.room_sensor_16', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery', + 'original_name': 'Room Sensor 16', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_16', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_16-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Guest room', + 'device_class': 'battery', + 'friendly_name': 'Room Sensor 16', + 'icon': 'mdi:battery', + 'state': dict({ + 'luminance': 29, + 'measured_temperature': 21, + 'occupancy_trigger': 255, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.room_sensor_16', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_17-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.room_sensor_17', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery', + 'original_name': 'Room Sensor 17', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_17', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_17-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Ensuite', + 'device_class': 'battery', + 'friendly_name': 'Room Sensor 17', + 'icon': 'mdi:battery', + 'state': dict({ + 'luminance': 32, + 'measured_temperature': 21, + 'occupancy_trigger': 0, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.room_sensor_17', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_18-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.room_sensor_18', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-alert', + 'original_name': 'Room Sensor 18', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_18', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_18-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Bedroom', + 'device_class': 'battery', + 'friendly_name': 'Room Sensor 18', + 'icon': 'mdi:battery-alert', + 'state': dict({ + 'luminance': 1, + 'measured_temperature': 21.5, + 'occupancy_trigger': 0, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.room_sensor_18', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_20-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.room_sensor_20', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery', + 'original_name': 'Room Sensor 20', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_20', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_20-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Kitchen', + 'device_class': 'battery', + 'friendly_name': 'Room Sensor 20', + 'icon': 'mdi:battery', + 'state': dict({ + 'luminance': 1, + 'measured_temperature': 21.5, + 'occupancy_trigger': 0, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.room_sensor_20', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_21-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.room_sensor_21', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery', + 'original_name': 'Room Sensor 21', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_21', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_21-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Hall', + 'device_class': 'battery', + 'friendly_name': 'Room Sensor 21', + 'icon': 'mdi:battery', + 'state': dict({ + 'luminance': 33, + 'measured_temperature': 21, + 'occupancy_trigger': 0, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.room_sensor_21', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_50-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.room_sensor_50', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery', + 'original_name': 'Room Sensor 50', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_50', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_50-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Study', + 'device_class': 'battery', + 'friendly_name': 'Room Sensor 50', + 'icon': 'mdi:battery', + 'state': dict({ + 'luminance': 34, + 'measured_temperature': 22, + 'occupancy_trigger': 0, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.room_sensor_50', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_53-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.room_sensor_53', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-alert', + 'original_name': 'Room Sensor 53', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_device_53', + 'unit_of_measurement': '%', + }) +# --- +# name: test_cloud_all_sensors[sensor.room_sensor_53-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assigned_zone': 'Lounge', + 'device_class': 'battery', + 'friendly_name': 'Room Sensor 53', + 'icon': 'mdi:battery-alert', + 'state': dict({ + 'luminance': 0, + 'measured_temperature': 0, + 'occupancy_trigger': 0, + }), + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.room_sensor_53', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28', + }) +# --- diff --git a/tests/components/geniushub/snapshots/test_switch.ambr b/tests/components/geniushub/snapshots/test_switch.ambr new file mode 100644 index 00000000000..6c3c95af477 --- /dev/null +++ b/tests/components/geniushub/snapshots/test_switch.ambr @@ -0,0 +1,166 @@ +# serializer version: 1 +# name: test_cloud_all_sensors[switch.bedroom_socket-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.bedroom_socket', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Bedroom Socket', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_27', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[switch.bedroom_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Bedroom Socket', + 'status': dict({ + 'mode': 'timer', + 'override': dict({ + 'duration': 0, + 'setpoint': 'True', + }), + 'type': 'on / off', + }), + }), + 'context': , + 'entity_id': 'switch.bedroom_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_cloud_all_sensors[switch.kitchen_socket-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.kitchen_socket', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen Socket', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_28', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[switch.kitchen_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Kitchen Socket', + 'status': dict({ + 'mode': 'timer', + 'override': dict({ + 'duration': 0, + 'setpoint': 'True', + }), + 'type': 'on / off', + }), + }), + 'context': , + 'entity_id': 'switch.kitchen_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_cloud_all_sensors[switch.study_socket-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.study_socket', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Study Socket', + 'platform': 'geniushub', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01J71MQF0EC62D620DGYNG2R8H_zone_32', + 'unit_of_measurement': None, + }) +# --- +# name: test_cloud_all_sensors[switch.study_socket-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'Study Socket', + 'status': dict({ + 'mode': 'off', + 'override': dict({ + 'duration': 0, + 'setpoint': 'True', + }), + 'type': 'on / off', + }), + }), + 'context': , + 'entity_id': 'switch.study_socket', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/geniushub/test_binary_sensor.py b/tests/components/geniushub/test_binary_sensor.py new file mode 100644 index 00000000000..682929eb696 --- /dev/null +++ b/tests/components/geniushub/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the Geniushub binary sensor platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_geniushub_cloud") +async def test_cloud_all_sensors( + hass: HomeAssistant, + mock_cloud_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation of the Genius Hub binary sensors.""" + with patch( + "homeassistant.components.geniushub.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_cloud_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_cloud_config_entry.entry_id + ) diff --git a/tests/components/geniushub/test_climate.py b/tests/components/geniushub/test_climate.py new file mode 100644 index 00000000000..d14e57b9552 --- /dev/null +++ b/tests/components/geniushub/test_climate.py @@ -0,0 +1,30 @@ +"""Tests for the Geniushub climate platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_geniushub_cloud") +async def test_cloud_all_sensors( + hass: HomeAssistant, + mock_cloud_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation of the Genius Hub climate entities.""" + with patch("homeassistant.components.geniushub.PLATFORMS", [Platform.CLIMATE]): + await setup_integration(hass, mock_cloud_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_cloud_config_entry.entry_id + ) diff --git a/tests/components/geniushub/test_sensor.py b/tests/components/geniushub/test_sensor.py new file mode 100644 index 00000000000..a75329ca7fc --- /dev/null +++ b/tests/components/geniushub/test_sensor.py @@ -0,0 +1,30 @@ +"""Tests for the Geniushub sensor platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_geniushub_cloud") +async def test_cloud_all_sensors( + hass: HomeAssistant, + mock_cloud_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation of the Genius Hub sensors.""" + with patch("homeassistant.components.geniushub.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_cloud_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_cloud_config_entry.entry_id + ) diff --git a/tests/components/geniushub/test_switch.py b/tests/components/geniushub/test_switch.py new file mode 100644 index 00000000000..0e88562e381 --- /dev/null +++ b/tests/components/geniushub/test_switch.py @@ -0,0 +1,30 @@ +"""Tests for the Geniushub switch platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_geniushub_cloud") +async def test_cloud_all_sensors( + hass: HomeAssistant, + mock_cloud_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the creation of the Genius Hub switch entities.""" + with patch("homeassistant.components.geniushub.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_cloud_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_cloud_config_entry.entry_id + ) From 2c99f060f060c2994512b189dbb8e7f9de777668 Mon Sep 17 00:00:00 2001 From: Stefano Sonzogni Date: Fri, 6 Sep 2024 15:18:40 +0200 Subject: [PATCH 0326/1309] Add binary sensors for motion detection Comelit simple home (#125200) * Add binary sensors for motion detection * sort platforms * use _attr_device_class property and optimizations * use static _attr_device_class property --- homeassistant/components/comelit/__init__.py | 1 + .../components/comelit/binary_sensor.py | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 homeassistant/components/comelit/binary_sensor.py diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 478be85c1d4..12f28ef206d 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -19,6 +19,7 @@ BRIDGE_PLATFORMS = [ ] VEDO_PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, Platform.SENSOR, ] diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py new file mode 100644 index 00000000000..30b642584f8 --- /dev/null +++ b/homeassistant/components/comelit/binary_sensor.py @@ -0,0 +1,62 @@ +"""Support for sensors.""" + +from __future__ import annotations + +from aiocomelit import ComelitVedoZoneObject +from aiocomelit.const import ALARM_ZONES + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitVedoSystem + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit VEDO presence sensors.""" + + coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[ALARM_ZONES].values() + ) + + +class ComelitVedoBinarySensorEntity( + CoordinatorEntity[ComelitVedoSystem], BinarySensorEntity +): + """Sensor device.""" + + _attr_has_entity_name = True + _attr_device_class = BinarySensorDeviceClass.MOTION + + def __init__( + self, + coordinator: ComelitVedoSystem, + zone: ComelitVedoZoneObject, + config_entry_entry_id: str, + ) -> None: + """Init sensor entity.""" + self._api = coordinator.api + self._zone = zone + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-presence-{zone.index}" + self._attr_device_info = coordinator.platform_device_info(zone, "zone") + + @property + def is_on(self) -> bool: + """Presence detected.""" + return self.coordinator.data[ALARM_ZONES][self._zone.index].status_api == "0001" From f9928a584383bcebc6f4cc57c52bb87c2701c019 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:19:54 +0200 Subject: [PATCH 0327/1309] Fix location_id datatype in totalconnect tests (#125298) Adjust location_id type in totalconnect tests --- tests/components/totalconnect/common.py | 2 +- .../snapshots/test_alarm_control_panel.ambr | 4 +- .../snapshots/test_binary_sensor.ambr | 50 +++++++++---------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 6e9bb28a9b6..4cfbabb2d7d 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -11,7 +11,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -LOCATION_ID = "123456" +LOCATION_ID = 123456 DEVICE_INFO_BASIC_1 = { "DeviceID": "987654", diff --git a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr index 0b8b8bb79ac..ef7cb386b33 100644 --- a/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr +++ b/tests/components/totalconnect/snapshots/test_alarm_control_panel.ambr @@ -41,7 +41,7 @@ 'code_format': None, 'cover_tampered': False, 'friendly_name': 'test', - 'location_id': '123456', + 'location_id': 123456, 'location_name': 'test', 'low_battery': False, 'partition': 1, @@ -99,7 +99,7 @@ 'code_format': None, 'cover_tampered': False, 'friendly_name': 'test Partition 2', - 'location_id': '123456', + 'location_id': 123456, 'location_name': 'test partition 2', 'low_battery': False, 'partition': 2, diff --git a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr index 81cfecbc530..1eccff1dfc3 100644 --- a/tests/components/totalconnect/snapshots/test_binary_sensor.ambr +++ b/tests/components/totalconnect/snapshots/test_binary_sensor.ambr @@ -37,7 +37,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'smoke', 'friendly_name': 'Fire', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '2', }), @@ -87,7 +87,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Fire Battery', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '2', }), @@ -137,7 +137,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Fire Tamper', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '2', }), @@ -187,7 +187,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'gas', 'friendly_name': 'Gas', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '3', }), @@ -237,7 +237,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Gas Battery', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '3', }), @@ -287,7 +287,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Gas Tamper', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '3', }), @@ -337,7 +337,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'safety', 'friendly_name': 'Medical', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '5', }), @@ -387,7 +387,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'motion', 'friendly_name': 'Motion', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '4', }), @@ -437,7 +437,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Motion Battery', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '4', }), @@ -487,7 +487,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Motion Tamper', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '4', }), @@ -537,7 +537,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'door', 'friendly_name': 'Security', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '1', }), @@ -587,7 +587,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Security Battery', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '1', }), @@ -637,7 +637,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Security Tamper', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '1', }), @@ -687,7 +687,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'problem', 'friendly_name': 'Temperature', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': 7, }), @@ -737,7 +737,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Temperature Battery', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': 7, }), @@ -787,7 +787,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Temperature Tamper', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': 7, }), @@ -837,7 +837,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'test Battery', - 'location_id': '123456', + 'location_id': 123456, }), 'context': , 'entity_id': 'binary_sensor.test_battery', @@ -885,7 +885,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'carbon_monoxide', 'friendly_name': 'test Carbon monoxide', - 'location_id': '123456', + 'location_id': 123456, }), 'context': , 'entity_id': 'binary_sensor.test_carbon_monoxide', @@ -932,7 +932,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'test Police emergency', - 'location_id': '123456', + 'location_id': 123456, }), 'context': , 'entity_id': 'binary_sensor.test_police_emergency', @@ -980,7 +980,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'test Power', - 'location_id': '123456', + 'location_id': 123456, }), 'context': , 'entity_id': 'binary_sensor.test_power', @@ -1028,7 +1028,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'smoke', 'friendly_name': 'test Smoke', - 'location_id': '123456', + 'location_id': 123456, }), 'context': , 'entity_id': 'binary_sensor.test_smoke', @@ -1076,7 +1076,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'test Tamper', - 'location_id': '123456', + 'location_id': 123456, }), 'context': , 'entity_id': 'binary_sensor.test_tamper', @@ -1124,7 +1124,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'door', 'friendly_name': 'Unknown', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '6', }), @@ -1174,7 +1174,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'Unknown Battery', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '6', }), @@ -1224,7 +1224,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'tamper', 'friendly_name': 'Unknown Tamper', - 'location_id': '123456', + 'location_id': 123456, 'partition': '1', 'zone_id': '6', }), From 86ef7bab28ecac460f554fbc2f5133288b1d6f85 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:20:11 +0200 Subject: [PATCH 0328/1309] Improve config flow type hints in totalconnect (#125300) --- .../components/totalconnect/config_flow.py | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index 63973fd44e9..2a4c4d421a1 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any from total_connect_client.client import TotalConnectClient from total_connect_client.exceptions import AuthenticationError @@ -17,6 +17,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_LOCATION, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.helpers.typing import VolDictType from .const import AUTO_BYPASS, CONF_USERCODES, DOMAIN @@ -28,15 +29,16 @@ class TotalConnectConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + client: TotalConnectClient + def __init__(self) -> None: """Initialize the config flow.""" - self.username = None - self.password = None - self.usercodes: dict[str, Any] = {} - self.client = None + self.username: str | None = None + self.password: str | None = None + self.usercodes: dict[int, str | None] = {} async def async_step_user( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" errors = {} @@ -70,18 +72,20 @@ class TotalConnectConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=data_schema, errors=errors ) - async def async_step_locations(self, user_entry=None): + async def async_step_locations( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle the user locations and associated usercodes.""" errors = {} - if user_entry is not None: + if user_input is not None: for location_id in self.usercodes: if self.usercodes[location_id] is None: valid = await self.hass.async_add_executor_job( self.client.locations[location_id].set_usercode, - user_entry[CONF_USERCODES], + user_input[CONF_USERCODES], ) if valid: - self.usercodes[location_id] = user_entry[CONF_USERCODES] + self.usercodes[location_id] = user_input[CONF_USERCODES] else: errors[CONF_LOCATION] = "usercode" break @@ -111,11 +115,11 @@ class TotalConnectConfigFlow(ConfigFlow, domain=DOMAIN): self.usercodes[location_id] = None # show the next location that needs a usercode - location_codes = {} + location_codes: VolDictType = {} location_for_user = "" for location_id in self.usercodes: if self.usercodes[location_id] is None: - location_for_user = location_id + location_for_user = str(location_id) location_codes[ vol.Required( CONF_USERCODES, @@ -141,7 +145,9 @@ class TotalConnectConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" errors = {} if user_input is None: @@ -166,6 +172,8 @@ class TotalConnectConfigFlow(ConfigFlow, domain=DOMAIN): ) existing_entry = await self.async_set_unique_id(self.username) + if TYPE_CHECKING: + assert existing_entry is not None new_entry = { CONF_USERNAME: self.username, CONF_PASSWORD: user_input[CONF_PASSWORD], @@ -195,7 +203,9 @@ class TotalConnectOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, bool] | None = None + ) -> ConfigFlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) From 3a5309e9a0a1e6fd23085740426ebf07b8f48c13 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 6 Sep 2024 15:20:39 +0200 Subject: [PATCH 0329/1309] Improve config flow type hints in tellduslive (#125299) --- .../components/tellduslive/config_flow.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index 6b5e7150d67..3bbb34912f9 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -35,14 +35,15 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + _session: Session + def __init__(self) -> None: """Init config flow.""" self._hosts = [CLOUD_NAME] self._host = None - self._session = None self._scan_interval = SCAN_INTERVAL - def _get_auth_url(self): + def _get_auth_url(self) -> str | None: self._session = Session( public_key=PUBLIC_KEY, private_key=NOT_SO_PRIVATE_KEY, @@ -70,7 +71,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ), ) - async def async_step_auth(self, user_input=None): + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle the submitted configuration.""" errors = {} if user_input is not None: @@ -114,7 +117,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_discovery(self, discovery_info): + async def async_step_discovery( + self, + discovery_info: list[str], # type: ignore[override] + ) -> ConfigFlowResult: """Run when a Tellstick is discovered.""" await self._async_handle_discovery_without_unique_id() From 8d239d368b57b888b9b815ca1bf61be3ab36b75c Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 6 Sep 2024 09:22:39 -0400 Subject: [PATCH 0330/1309] Bump aiorussound to 3.0.4 (#125285) feat: bump aiorussound to 3.0.4 --- .../components/russound_rio/__init__.py | 10 ++++----- .../components/russound_rio/config_flow.py | 9 ++++---- .../components/russound_rio/const.py | 4 ++-- .../components/russound_rio/entity.py | 15 +++++++++---- .../components/russound_rio/manifest.json | 2 +- .../components/russound_rio/media_player.py | 22 ++++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/russound_rio/conftest.py | 4 ++-- 9 files changed, 39 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index 8627c636ef2..823d0736037 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -3,7 +3,7 @@ import asyncio import logging -from aiorussound import Russound +from aiorussound import RussoundClient, RussoundTcpConnectionHandler from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform @@ -16,7 +16,7 @@ PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) -type RussoundConfigEntry = ConfigEntry[Russound] +type RussoundConfigEntry = ConfigEntry[RussoundClient] async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> bool: @@ -24,7 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] - russ = Russound(hass.loop, host, port) + russ = RussoundClient(RussoundTcpConnectionHandler(hass.loop, host, port)) @callback def is_connected_updated(connected: bool) -> None: @@ -37,14 +37,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> port, ) - russ.add_connection_callback(is_connected_updated) - + russ.connection_handler.add_connection_callback(is_connected_updated) try: async with asyncio.timeout(CONNECT_TIMEOUT): await russ.connect() except RUSSOUND_RIO_EXCEPTIONS as err: raise ConfigEntryNotReady(f"Error while connecting to {host}:{port}") from err - entry.runtime_data = russ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py index df173d29f61..03e32f39c08 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -6,7 +6,7 @@ import asyncio import logging from typing import Any -from aiorussound import Controller, Russound +from aiorussound import Controller, RussoundClient, RussoundTcpConnectionHandler import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -54,8 +54,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): host = user_input[CONF_HOST] port = user_input[CONF_PORT] - controllers = None - russ = Russound(self.hass.loop, host, port) + russ = RussoundClient( + RussoundTcpConnectionHandler(self.hass.loop, host, port) + ) try: async with asyncio.timeout(CONNECT_TIMEOUT): await russ.connect() @@ -87,7 +88,7 @@ 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 = Russound(self.hass.loop, host, port) + russ = RussoundClient(RussoundTcpConnectionHandler(self.hass.loop, host, port)) try: async with asyncio.timeout(CONNECT_TIMEOUT): await russ.connect() diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py index d1f4e1c4c0e..42a1db5f2ad 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -2,7 +2,7 @@ import asyncio -from aiorussound import CommandException +from aiorussound import CommandError from aiorussound.const import FeatureFlag from homeassistant.components.media_player import MediaPlayerEntityFeature @@ -10,7 +10,7 @@ from homeassistant.components.media_player import MediaPlayerEntityFeature DOMAIN = "russound_rio" RUSSOUND_RIO_EXCEPTIONS = ( - CommandException, + CommandError, ConnectionRefusedError, TimeoutError, asyncio.CancelledError, diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 0e4d5cf7dde..4d458118939 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate -from aiorussound import Controller +from aiorussound import Controller, RussoundTcpConnectionHandler from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -53,7 +53,6 @@ class RussoundBaseEntity(Entity): or f"{self._primary_mac_address}-{self._controller.controller_id}" ) self._attr_device_info = DeviceInfo( - configuration_url=f"http://{self._instance.host}", # Use MAC address of Russound device as identifier identifiers={(DOMAIN, self._device_identifier)}, manufacturer="Russound", @@ -61,6 +60,10 @@ class RussoundBaseEntity(Entity): model=controller.controller_type, sw_version=controller.firmware_version, ) + if isinstance(self._instance.connection_handler, RussoundTcpConnectionHandler): + self._attr_device_info["configuration_url"] = ( + f"http://{self._instance.connection_handler.host}" + ) if controller.parent_controller: self._attr_device_info["via_device"] = ( DOMAIN, @@ -79,8 +82,12 @@ class RussoundBaseEntity(Entity): async def async_added_to_hass(self) -> None: """Register callbacks.""" - self._instance.add_connection_callback(self._is_connected_updated) + self._instance.connection_handler.add_connection_callback( + self._is_connected_updated + ) async def async_will_remove_from_hass(self) -> None: """Remove callbacks.""" - self._instance.remove_connection_callback(self._is_connected_updated) + self._instance.connection_handler.remove_connection_callback( + self._is_connected_updated + ) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 6c473d94874..19273de92ee 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==2.3.2"] + "requirements": ["aiorussound==3.0.4"] } diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 20aaf0f3c08..a5bb392a028 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -84,14 +84,16 @@ async def async_setup_entry( """Set up the Russound RIO platform.""" russ = entry.runtime_data + 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(): - sources = controller.sources - for source in sources.values(): - await source.watch() for zone in controller.zones.values(): await zone.watch() mp = RussoundZoneDevice(zone, sources) @@ -154,7 +156,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): @property def state(self) -> MediaPlayerState | None: """Return the state of the device.""" - status = self._zone.status + status = self._zone.properties.status if status == "ON": return MediaPlayerState.ON if status == "OFF": @@ -174,22 +176,22 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): @property def media_title(self): """Title of current playing media.""" - return self._current_source().song_name + return self._current_source().properties.song_name @property def media_artist(self): """Artist of current playing media, music track only.""" - return self._current_source().artist_name + return self._current_source().properties.artist_name @property def media_album_name(self): """Album name of current playing media, music track only.""" - return self._current_source().album_name + return self._current_source().properties.album_name @property def media_image_url(self): """Image url of current playing media.""" - return self._current_source().cover_art_url + return self._current_source().properties.cover_art_url @property def volume_level(self): @@ -198,7 +200,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.volume or "0") / 50.0 + return float(self._zone.properties.volume or "0") / 50.0 @command async def async_turn_off(self) -> None: @@ -214,7 +216,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): async def async_set_volume_level(self, volume: float) -> None: """Set the volume level.""" rvol = int(volume * 50.0) - await self._zone.set_volume(rvol) + await self._zone.set_volume(str(rvol)) @command async def async_select_source(self, source: str) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index c6a8eba7b6f..6a215b2989c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -350,7 +350,7 @@ aioridwell==2024.01.0 aioruckus==0.41 # homeassistant.components.russound_rio -aiorussound==2.3.2 +aiorussound==3.0.4 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da62c029875..74d792acb15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -332,7 +332,7 @@ aioridwell==2024.01.0 aioruckus==0.41 # homeassistant.components.russound_rio -aiorussound==2.3.2 +aiorussound==3.0.4 # 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 a87d0a74fa8..344c743d0b3 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -37,10 +37,10 @@ def mock_russound() -> Generator[AsyncMock]: """Mock the Russound RIO client.""" with ( patch( - "homeassistant.components.russound_rio.Russound", autospec=True + "homeassistant.components.russound_rio.RussoundClient", autospec=True ) as mock_client, patch( - "homeassistant.components.russound_rio.config_flow.Russound", + "homeassistant.components.russound_rio.config_flow.RussoundClient", return_value=mock_client, ), ): From ff449e77415331edf2df6d727cf9016bef2d26bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Sep 2024 08:23:22 -0500 Subject: [PATCH 0331/1309] Bump yarl to 1.9.11 (#125287) * Bump yarl to 1.9.10 changelog: https://github.com/aio-libs/yarl/compare/v1.9.9...v1.9.10 * 11 --- 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 ac7b74429bd..c8fc265cee8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ urllib3>=1.26.5,<2 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.9.9 +yarl==1.9.11 zeroconf==0.133.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 787813bd64a..a8c43ada99f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.9.9", + "yarl==1.9.11", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 1d6b4e74d22..8d5c01b5c27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,4 @@ urllib3>=1.26.5,<2 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.9.9 +yarl==1.9.11 From 051a28b55a33a930a3f3325da8ef19eba6364bdf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Sep 2024 08:34:52 -0500 Subject: [PATCH 0332/1309] Remove unneeded wrapping of URL in URL in network helper (#125265) * Remove unneeded wrapping of URL in URL in network helper * fix mocks --- homeassistant/helpers/network.py | 2 +- tests/helpers/test_network.py | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index d5891973e40..36c9feb83c4 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -216,7 +216,7 @@ 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 yarl.URL(request.url).host + return request.url.host @bind_hass diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py index 3c9594bca38..5a847e6a29c 100644 --- a/tests/helpers/test_network.py +++ b/tests/helpers/test_network.py @@ -3,6 +3,7 @@ from unittest.mock import Mock, patch import pytest +from yarl import URL from homeassistant.components import cloud from homeassistant.config import async_process_ha_core_config @@ -591,7 +592,7 @@ 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.url = "http://example.com:8123/test/request" + mock_request.url = URL("http://example.com:8123/test/request") mock_request_context.get = Mock(return_value=mock_request) assert _get_request_host() == "example.com" @@ -682,10 +683,12 @@ 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="http://example.local:8123") + mock_current_request.return_value = Mock(url=URL("http://example.local:8123")) assert is_internal_request(hass) - mock_current_request.return_value = Mock(url="http://no_match.example.local:8123") + mock_current_request.return_value = Mock( + url=URL("http://no_match.example.local:8123") + ) assert not is_internal_request(hass) # Test with internal URL: http://192.168.0.1:8123 @@ -697,18 +700,18 @@ 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="http://192.168.0.1:8123") + mock_current_request.return_value = Mock(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=f"http://{allowed}:8123") + mock_current_request.return_value = Mock(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=f"http://{allowed}:8123") + mock_current_request.return_value = Mock(url=URL(f"http://{allowed}:8123")) assert is_internal_request(hass), mock_current_request.return_value.url From 9f469c08d14d04b6ce364822f821706dfd05d130 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 6 Sep 2024 15:35:38 +0200 Subject: [PATCH 0333/1309] Code quality improvement on local_file (#125165) * Code quality improvement on local_file * Fix * No translation * Review comments --- homeassistant/components/local_file/camera.py | 86 +++++++----------- .../components/local_file/services.yaml | 9 +- .../components/local_file/strings.json | 9 +- tests/components/local_file/test_camera.py | 91 ++++++++++++++++--- 4 files changed, 121 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py index 1306751f1a9..74d887b613f 100644 --- a/homeassistant/components/local_file/camera.py +++ b/homeassistant/components/local_file/camera.py @@ -12,13 +12,14 @@ from homeassistant.components.camera import ( PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA, Camera, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_FILE_PATH, CONF_NAME -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_FILE_PATH, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady, ServiceValidationError +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DATA_LOCAL_FILE, DEFAULT_NAME, DOMAIN, SERVICE_UPDATE_FILE_PATH +from .const import DEFAULT_NAME, SERVICE_UPDATE_FILE_PATH _LOGGER = logging.getLogger(__name__) @@ -29,57 +30,45 @@ PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend( } ) -CAMERA_SERVICE_UPDATE_FILE_PATH = vol.Schema( - { - vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids, - vol.Required(CONF_FILE_PATH): cv.string, - } -) + +def check_file_path_access(file_path: str) -> bool: + """Check that filepath given is readable.""" + if not os.access(file_path, os.R_OK): + return False + return True -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Camera that works with local files.""" - if DATA_LOCAL_FILE not in hass.data: - hass.data[DATA_LOCAL_FILE] = [] + file_path: str = config[CONF_FILE_PATH] - file_path = config[CONF_FILE_PATH] - camera = LocalFile(config[CONF_NAME], file_path) - hass.data[DATA_LOCAL_FILE].append(camera) - - def update_file_path_service(call: ServiceCall) -> None: - """Update the file path.""" - file_path = call.data[CONF_FILE_PATH] - entity_ids = call.data[ATTR_ENTITY_ID] - cameras = hass.data[DATA_LOCAL_FILE] - - for camera in cameras: - if camera.entity_id in entity_ids: - camera.update_file_path(file_path) - - hass.services.register( - DOMAIN, + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( SERVICE_UPDATE_FILE_PATH, - update_file_path_service, - schema=CAMERA_SERVICE_UPDATE_FILE_PATH, + { + vol.Required(CONF_FILE_PATH): cv.string, + }, + "update_file_path", ) - add_entities([camera]) + if not await hass.async_add_executor_job(check_file_path_access, file_path): + raise PlatformNotReady(f"File path {file_path} is not readable") + + async_add_entities([LocalFile(config[CONF_NAME], file_path)]) class LocalFile(Camera): """Representation of a local file camera.""" - def __init__(self, name, file_path): + def __init__(self, name: str, file_path: str) -> None: """Initialize Local File Camera component.""" super().__init__() - - self._name = name - self.check_file_path_access(file_path) + self._attr_name = name self._file_path = file_path # Set content type of local file content, _ = mimetypes.guess_type(file_path) @@ -96,30 +85,21 @@ class LocalFile(Camera): except FileNotFoundError: _LOGGER.warning( "Could not read camera %s image from file: %s", - self._name, + self.name, self._file_path, ) return None - def check_file_path_access(self, file_path): - """Check that filepath given is readable.""" - if not os.access(file_path, os.R_OK): - _LOGGER.warning( - "Could not read camera %s image from file: %s", self._name, file_path - ) - - def update_file_path(self, file_path): + async def update_file_path(self, file_path: str) -> None: """Update the file_path.""" - self.check_file_path_access(file_path) + if not await self.hass.async_add_executor_job( + check_file_path_access, file_path + ): + raise ServiceValidationError(f"Path {file_path} is not accessible") self._file_path = file_path self.schedule_update_ha_state() @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the camera state attributes.""" return {"file_path": self._file_path} diff --git a/homeassistant/components/local_file/services.yaml b/homeassistant/components/local_file/services.yaml index 5fc0b11f4c2..1b3000e663e 100644 --- a/homeassistant/components/local_file/services.yaml +++ b/homeassistant/components/local_file/services.yaml @@ -1,10 +1,9 @@ update_file_path: + target: + entity: + integration: local_file + domain: camera fields: - entity_id: - required: true - selector: - entity: - domain: camera file_path: required: true example: "/config/www/images/image.jpg" diff --git a/homeassistant/components/local_file/strings.json b/homeassistant/components/local_file/strings.json index 0db5d709c69..801d85ce1e0 100644 --- a/homeassistant/components/local_file/strings.json +++ b/homeassistant/components/local_file/strings.json @@ -4,15 +4,16 @@ "name": "Updates file path", "description": "Use this action to change the file displayed by the camera.", "fields": { - "entity_id": { - "name": "Entity", - "description": "Name of the entity_id of the camera to update." - }, "file_path": { "name": "File path", "description": "The full path to the new image file to be displayed." } } } + }, + "exceptions": { + "file_path_not_accessible": { + "message": "Path {file_path} is not accessible" + } } } diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py index 4455d47469c..132212df0ec 100644 --- a/tests/components/local_file/test_camera.py +++ b/tests/components/local_file/test_camera.py @@ -6,7 +6,9 @@ from unittest import mock import pytest from homeassistant.components.local_file.const import DOMAIN, SERVICE_UPDATE_FILE_PATH +from homeassistant.const import ATTR_ENTITY_ID, CONF_FILE_PATH from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator @@ -71,9 +73,45 @@ async def test_file_not_readable( ) await hass.async_block_till_done() - assert "Could not read" in caplog.text - assert "config_test" in caplog.text - assert "mock.file" in caplog.text + assert "File path mock.file is not readable;" in caplog.text + + +async def test_file_not_readable_after_setup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test a warning is shown setup when file is not readable.""" + with ( + mock.patch("os.path.isfile", mock.Mock(return_value=True)), + mock.patch("os.access", mock.Mock(return_value=True)), + mock.patch( + "homeassistant.components.local_file.camera.mimetypes.guess_type", + mock.Mock(return_value=(None, None)), + ), + ): + await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "local_file", + "file_path": "mock.file", + } + }, + ) + await hass.async_block_till_done() + + client = await hass_client() + + with mock.patch( + "homeassistant.components.local_file.camera.open", side_effect=FileNotFoundError + ): + resp = await client.get("/api/camera_proxy/camera.config_test") + + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + assert "Could not read camera config_test image from file: mock.file" in caplog.text async def test_camera_content_type( @@ -100,13 +138,23 @@ async def test_camera_content_type( "platform": "local_file", "file_path": "/path/to/image", } - - await async_setup_component( - hass, - "camera", - {"camera": [cam_config_jpg, cam_config_png, cam_config_svg, cam_config_noext]}, - ) - await hass.async_block_till_done() + with ( + mock.patch("os.path.isfile", mock.Mock(return_value=True)), + mock.patch("os.access", mock.Mock(return_value=True)), + ): + await async_setup_component( + hass, + "camera", + { + "camera": [ + cam_config_jpg, + cam_config_png, + cam_config_svg, + cam_config_noext, + ] + }, + ) + await hass.async_block_till_done() client = await hass_client() @@ -169,8 +217,12 @@ async def test_update_file_path(hass: HomeAssistant) -> None: service_data = {"entity_id": "camera.local_file", "file_path": "new/path.jpg"} - await hass.services.async_call(DOMAIN, SERVICE_UPDATE_FILE_PATH, service_data) - await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_FILE_PATH, + service_data, + blocking=True, + ) state = hass.states.get("camera.local_file") assert state.attributes.get("file_path") == "new/path.jpg" @@ -178,3 +230,18 @@ async def test_update_file_path(hass: HomeAssistant) -> None: # Check that local_file_camera_2 file_path is still as configured state = hass.states.get("camera.local_file_camera_2") assert state.attributes.get("file_path") == "mock/path_2.jpg" + + # Assert it fails if file is not readable + service_data = { + ATTR_ENTITY_ID: "camera.local_file", + CONF_FILE_PATH: "new/path2.jpg", + } + with pytest.raises( + ServiceValidationError, match="Path new/path2.jpg is not accessible" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_UPDATE_FILE_PATH, + service_data, + blocking=True, + ) From 73f04e3ede7362ffc2054035803e8dc9c1195529 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 6 Sep 2024 15:41:49 +0200 Subject: [PATCH 0334/1309] Add filter run time for deCONZ air purifiers (#123306) * Add filter run time for deCONZ air purifiers * Add duration and second * Fix review comments * Update tests/components/deconz/snapshots/test_sensor.ambr --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/deconz/sensor.py | 16 ++++++ .../deconz/snapshots/test_sensor.ambr | 54 +++++++++++++++++++ tests/components/deconz/test_sensor.py | 35 ++++++++++++ 3 files changed, 105 insertions(+) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index e67c0129147..8b2b4896cdf 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -10,6 +10,7 @@ from typing import Generic, TypeVar from pydeconz.interfaces.sensors import SensorResources from pydeconz.models.event import EventType from pydeconz.models.sensor import SensorBase as PydeconzSensorBase +from pydeconz.models.sensor.air_purifier import AirPurifier from pydeconz.models.sensor.air_quality import AirQuality from pydeconz.models.sensor.carbon_dioxide import CarbonDioxide from pydeconz.models.sensor.consumption import Consumption @@ -47,6 +48,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -77,6 +79,7 @@ ATTR_EVENT_ID = "event_id" T = TypeVar( "T", + AirPurifier, AirQuality, CarbonDioxide, Consumption, @@ -108,6 +111,19 @@ class DeconzSensorDescription(Generic[T], SensorEntityDescription): ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( + DeconzSensorDescription[AirPurifier]( + key="air_purifier_filter_run_time", + supported_fn=lambda device: True, + update_key="filterruntime", + name_suffix="Filter time", + value_fn=lambda device: device.filter_run_time, + instance_check=AirPurifier, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.DAYS, + suggested_display_precision=1, + ), DeconzSensorDescription[AirQuality]( key="air_quality", supported_fn=lambda device: device.supports_air_quality, diff --git a/tests/components/deconz/snapshots/test_sensor.ambr b/tests/components/deconz/snapshots/test_sensor.ambr index dd097ea1c9a..0b76366b5d1 100644 --- a/tests/components/deconz/snapshots/test_sensor.ambr +++ b/tests/components/deconz/snapshots/test_sensor.ambr @@ -1537,6 +1537,60 @@ 'state': '90', }) # --- +# name: test_sensors[config_entry_options0-sensor_payload21-expected21][sensor.ikea_starkvind_filter_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ikea_starkvind_filter_time', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'IKEA Starkvind Filter time', + 'platform': 'deconz', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0c:43:14:ff:fe:6c:20:12-01-fc7d-air_purifier_filter_run_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[config_entry_options0-sensor_payload21-expected21][sensor.ikea_starkvind_filter_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'IKEA Starkvind Filter time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ikea_starkvind_filter_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.849594907407407', + }) +# --- # name: test_sensors[config_entry_options0-sensor_payload3-expected3][sensor.airquality_1_ch2o-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index e6ae85df615..958cb3b793a 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -602,6 +602,41 @@ TEST_DATA = [ "next_state": "80", }, ), + ( # Air purifier filter time sensor + { + "config": { + "filterlifetime": 259200, + "ledindication": True, + "locked": False, + "mode": "speed_1", + "on": True, + "reachable": True, + }, + "ep": 1, + "etag": "de26d19d9e91b2db3ded6ee7ab6b6a4b", + "lastannounced": None, + "lastseen": "2024-08-07T18:27Z", + "manufacturername": "IKEA of Sweden", + "modelid": "STARKVIND Air purifier", + "name": "IKEA Starkvind", + "productid": "E2007", + "state": { + "deviceruntime": 73405, + "filterruntime": 73405, + "lastupdated": "2024-08-07T18:27:52.543", + "replacefilter": False, + "speed": 20, + }, + "swversion": "1.1.001", + "type": "ZHAAirPurifier", + "uniqueid": "0c:43:14:ff:fe:6c:20:12-01-fc7d", + }, + { + "entity_id": "sensor.ikea_starkvind_filter_time", + "websocket_event": {"state": {"filterruntime": 100000}}, + "next_state": "1.15740740740741", + }, + ), ] From 1e6b6fef7e898119e66a4b7ee060469180f9a621 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 6 Sep 2024 15:42:56 +0200 Subject: [PATCH 0335/1309] Revert #122676 Yamaha discovery (#125216) Revert Yamaha discovery --- .../components/yamaha/media_player.py | 30 +------------------ 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index 58f501b99be..bccb7b437f8 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -2,7 +2,6 @@ from __future__ import annotations -import contextlib import logging from typing import Any @@ -130,34 +129,7 @@ def _discovery(config_info): zones.extend(recv.zone_controllers()) else: _LOGGER.debug("Config Zones") - zones = None - - # Fix for upstream issues in rxv.find() with some hardware. - with contextlib.suppress(AttributeError, ValueError): - for recv in rxv.find(DISCOVER_TIMEOUT): - _LOGGER.debug( - "Found Serial %s %s %s", - recv.serial_number, - recv.ctrl_url, - recv.zone, - ) - if recv.ctrl_url == config_info.ctrl_url: - _LOGGER.debug( - "Config Zones Matched Serial %s: %s", - recv.ctrl_url, - recv.serial_number, - ) - zones = rxv.RXV( - config_info.ctrl_url, - friendly_name=config_info.name, - serial_number=recv.serial_number, - model_name=recv.model_name, - ).zone_controllers() - break - - if not zones: - _LOGGER.debug("Config Zones Fallback") - zones = rxv.RXV(config_info.ctrl_url, config_info.name).zone_controllers() + zones = rxv.RXV(config_info.ctrl_url, config_info.name).zone_controllers() _LOGGER.debug("Returned _discover zones: %s", zones) return zones From 8168b8fce4044fef3af55f73775c8b6dd3e8235a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Fri, 6 Sep 2024 15:43:16 +0200 Subject: [PATCH 0336/1309] Bump pyatv to 0.15.1 (#125412) --- homeassistant/components/apple_tv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 9a053829516..b4e1b354878 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.15.0"], + "requirements": ["pyatv==0.15.1"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 6a215b2989c..8a5c6a34a07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1753,7 +1753,7 @@ pyatag==0.3.5.3 pyatmo==8.1.0 # homeassistant.components.apple_tv -pyatv==0.15.0 +pyatv==0.15.1 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74d792acb15..614fb06b132 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1424,7 +1424,7 @@ pyatag==0.3.5.3 pyatmo==8.1.0 # homeassistant.components.apple_tv -pyatv==0.15.0 +pyatv==0.15.1 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 From 6b1fc00910e776dba53f6c5fd297a892cbd9057b Mon Sep 17 00:00:00 2001 From: TimL Date: Fri, 6 Sep 2024 23:46:08 +1000 Subject: [PATCH 0337/1309] Improve handling of old firmware versions (#125406) * Update Info fixture with new fields from pysmlight 0.0.14 * Create repair if device is running unsupported firmware * Add test for legacy firmware info * Add strings for repair issue --- .../components/smlight/coordinator.py | 22 +++++++++++- homeassistant/components/smlight/strings.json | 6 ++++ tests/components/smlight/fixtures/info.json | 4 ++- .../smlight/snapshots/test_init.ambr | 2 +- tests/components/smlight/test_init.py | 34 ++++++++++++++++++- 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 380644c81d1..094c6ec9cdb 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -9,8 +9,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, SCAN_INTERVAL @@ -40,6 +42,7 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): self.unique_id: str | None = None self.client = Api2(host=host, session=async_get_clientsession(hass)) + self.legacy_api: int = 0 async def _async_setup(self) -> None: """Authenticate if needed during initial setup.""" @@ -62,11 +65,28 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): info = await self.client.get_info() self.unique_id = format_mac(info.MAC) + if info.legacy_api: + self.legacy_api = info.legacy_api + ir.async_create_issue( + self.hass, + DOMAIN, + "unsupported_firmware", + is_fixable=False, + is_persistent=False, + learn_more_url="https://smlight.tech/flasher/#SLZB-06", + severity=IssueSeverity.ERROR, + translation_key="unsupported_firmware", + ) + async def _async_update_data(self) -> SmData: """Fetch data from the SMLIGHT device.""" try: + sensors = Sensors() + if not self.legacy_api: + sensors = await self.client.get_sensors() + return SmData( - sensors=await self.client.get_sensors(), + sensors=sensors, info=await self.client.get_info(), ) except SmlightAuthError as err: diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index e3e8fee0d4d..8628a49a13c 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -92,5 +92,11 @@ "name": "LED night mode" } } + }, + "issues": { + "unsupported_firmware": { + "title": "SLZB core firmware update required", + "description": "Your SMLIGHT SLZB-06x device is running an unsupported core firmware version. Please update it to the latest version to enjoy all the features of this integration." + } } } diff --git a/tests/components/smlight/fixtures/info.json b/tests/components/smlight/fixtures/info.json index 72bb7c1ed9b..070232512f3 100644 --- a/tests/components/smlight/fixtures/info.json +++ b/tests/components/smlight/fixtures/info.json @@ -3,10 +3,12 @@ "device_ip": "192.168.1.161", "fs_total": 3456, "fw_channel": "dev", + "legacy_api": 0, + "hostname": "SLZB-06p7", "MAC": "AA:BB:CC:DD:EE:FF", "model": "SLZB-06p7", "ram_total": 296, - "sw_version": "v2.3.1.dev", + "sw_version": "v2.3.6", "wifi_mode": 0, "zb_flash_size": 704, "zb_hw": "CC2652P7", diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 528a7b7b340..bb6a6c50f9b 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -27,7 +27,7 @@ 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'core: v2.3.1.dev / zigbee: -1', + 'sw_version': 'core: v2.3.6 / zigbee: -1', 'via_device_id': None, }) # --- diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index 1323c93e6bf..eb7b6396d26 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -3,15 +3,17 @@ from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory +from pysmlight import Info from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError, SmlightError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import SCAN_INTERVAL +from homeassistant.components.smlight.const import DOMAIN, SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.issue_registry import IssueRegistry from .conftest import setup_integration @@ -110,3 +112,33 @@ async def test_device_info( ) assert device_entry is not None assert device_entry == snapshot + + +async def test_device_legacy_firmware( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, + device_registry: dr.DeviceRegistry, + issue_registry: IssueRegistry, +) -> None: + """Test device setup for old firmware version that dont support required API.""" + LEGACY_VERSION = "v2.3.1" + mock_smlight_client.get_sensors.side_effect = SmlightError + mock_smlight_client.get_info.return_value = Info( + legacy_api=1, sw_version=LEGACY_VERSION, MAC="AA:BB:CC:DD:EE:FF" + ) + entry = await setup_integration(hass, mock_config_entry) + + assert entry.unique_id == "aa:bb:cc:dd:ee:ff" + + device_entry = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + assert LEGACY_VERSION in device_entry.sw_version + + issue = issue_registry.async_get_issue( + domain=DOMAIN, issue_id="unsupported_firmware" + ) + assert issue is not None + assert issue.domain == DOMAIN + assert issue.issue_id == "unsupported_firmware" From 6976a66758af656be2ab7cc6df0554ac42ab5477 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 6 Sep 2024 10:11:51 -0400 Subject: [PATCH 0338/1309] Migrate VoIP to use assist satellite (#125381) * Migrate VoIP to assist satellite * Fix flaky test --- homeassistant/components/voip/__init__.py | 1 + .../components/voip/assist_satellite.py | 310 +++++++++ .../components/voip/binary_sensor.py | 6 +- homeassistant/components/voip/devices.py | 15 +- homeassistant/components/voip/entity.py | 8 +- homeassistant/components/voip/manifest.json | 2 +- homeassistant/components/voip/strings.json | 10 + homeassistant/components/voip/util.py | 28 + homeassistant/components/voip/voip.py | 421 +----------- tests/components/voip/conftest.py | 3 + .../components/voip/snapshots/test_voip.ambr | 10 + tests/components/voip/test_util.py | 47 ++ tests/components/voip/test_voip.py | 642 ++++++++++++------ 13 files changed, 863 insertions(+), 640 deletions(-) create mode 100644 homeassistant/components/voip/assist_satellite.py create mode 100644 homeassistant/components/voip/util.py create mode 100644 tests/components/voip/snapshots/test_voip.ambr create mode 100644 tests/components/voip/test_util.py diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index 9ab6a8bf0e8..cee0cbb0766 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -20,6 +20,7 @@ from .devices import VoIPDevices from .voip import HassVoipDatagramProtocol PLATFORMS = ( + Platform.ASSIST_SATELLITE, Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH, diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py new file mode 100644 index 00000000000..9f117fc9878 --- /dev/null +++ b/homeassistant/components/voip/assist_satellite.py @@ -0,0 +1,310 @@ +"""Assist satellite entity for VoIP integration.""" + +from __future__ import annotations + +import asyncio +from enum import IntFlag +from functools import partial +import io +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Final +import wave + +from voip_utils import RtpDatagramProtocol + +from homeassistant.components import tts +from homeassistant.components.assist_pipeline import ( + PipelineEvent, + PipelineEventType, + PipelineNotFound, +) +from homeassistant.components.assist_satellite import ( + AssistSatelliteEntity, + AssistSatelliteEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH +from .devices import VoIPDevice +from .entity import VoIPEntity +from .util import queue_to_iterable + +if TYPE_CHECKING: + from . import DomainData + +_LOGGER = logging.getLogger(__name__) + +_PIPELINE_TIMEOUT_SEC: Final = 30 + + +class Tones(IntFlag): + """Feedback tones for specific events.""" + + LISTENING = 1 + PROCESSING = 2 + ERROR = 4 + + +_TONE_FILENAMES: dict[Tones, str] = { + Tones.LISTENING: "tone.pcm", + Tones.PROCESSING: "processing.pcm", + Tones.ERROR: "error.pcm", +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP Assist satellite entity.""" + domain_data: DomainData = hass.data[DOMAIN] + + @callback + def async_add_device(device: VoIPDevice) -> None: + """Add device.""" + async_add_entities([VoipAssistSatellite(hass, device, config_entry)]) + + domain_data.devices.async_add_new_device_listener(async_add_device) + + entities: list[VoIPEntity] = [ + VoipAssistSatellite(hass, device, config_entry) + for device in domain_data.devices + ] + + async_add_entities(entities) + + +class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol): + """Assist satellite for VoIP devices.""" + + entity_description = AssistSatelliteEntityDescription(key="assist_satellite") + _attr_translation_key = "assist_satellite" + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + hass: HomeAssistant, + voip_device: VoIPDevice, + config_entry: ConfigEntry, + tones=Tones.LISTENING | Tones.PROCESSING | Tones.ERROR, + ) -> None: + """Initialize an Assist satellite.""" + VoIPEntity.__init__(self, voip_device) + AssistSatelliteEntity.__init__(self) + RtpDatagramProtocol.__init__(self) + + self.config_entry = config_entry + + self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue() + self._audio_chunk_timeout: float = 2.0 + self._pipeline_task: asyncio.Task | None = None + self._pipeline_had_error: bool = False + self._tts_done = asyncio.Event() + self._tts_extra_timeout: float = 1.0 + self._tone_bytes: dict[Tones, bytes] = {} + self._tones = tones + self._processing_tone_done = asyncio.Event() + + @property + def pipeline_entity_id(self) -> str | None: + """Return the entity ID of the pipeline to use for the next conversation.""" + return self.voip_device.get_pipeline_entity_id(self.hass) + + @property + def vad_sensitivity_entity_id(self) -> str | None: + """Return the entity ID of the VAD sensitivity to use for the next conversation.""" + return self.voip_device.get_vad_sensitivity_entity_id(self.hass) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.voip_device.protocol = self + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + await super().async_will_remove_from_hass() + assert self.voip_device.protocol == self + self.voip_device.protocol = None + + # ------------------------------------------------------------------------- + # VoIP + # ------------------------------------------------------------------------- + + def on_chunk(self, audio_bytes: bytes) -> None: + """Handle raw audio chunk.""" + if self._pipeline_task is None: + self._clear_audio_queue() + + # Run pipeline until voice command finishes, then start over + self._pipeline_task = self.config_entry.async_create_background_task( + self.hass, + self._run_pipeline(), + "voip_pipeline_run", + ) + + self._audio_queue.put_nowait(audio_bytes) + + async def _run_pipeline( + self, + ) -> None: + """Forward audio to pipeline STT and handle TTS.""" + self.async_set_context(Context(user_id=self.config_entry.data["user"])) + self.voip_device.set_is_active(True) + + # Play listening tone at the start of each cycle + await self._play_tone(Tones.LISTENING, silence_before=0.2) + + try: + self._tts_done.clear() + + # Run pipeline with a timeout + _LOGGER.debug("Starting pipeline") + async with asyncio.timeout(_PIPELINE_TIMEOUT_SEC): + await self.async_accept_pipeline_from_satellite( + audio_stream=queue_to_iterable( + self._audio_queue, timeout=self._audio_chunk_timeout + ), + ) + + if self._pipeline_had_error: + self._pipeline_had_error = False + await self._play_tone(Tones.ERROR) + else: + # Block until TTS is done speaking. + # + # This is set in _send_tts and has a timeout that's based on the + # length of the TTS audio. + await self._tts_done.wait() + + _LOGGER.debug("Pipeline finished") + except PipelineNotFound: + _LOGGER.warning("Pipeline not found") + except (asyncio.CancelledError, TimeoutError): + # Expected after caller hangs up + _LOGGER.debug("Pipeline cancelled or timed out") + self.disconnect() + self._clear_audio_queue() + finally: + self.voip_device.set_is_active(False) + + # Allow pipeline to run again + self._pipeline_task = None + + def _clear_audio_queue(self) -> None: + """Ensure audio queue is empty.""" + while not self._audio_queue.empty(): + self._audio_queue.get_nowait() + + def on_pipeline_event(self, event: PipelineEvent) -> None: + """Set state based on pipeline stage.""" + if event.type == PipelineEventType.STT_END: + if (self._tones & Tones.PROCESSING) == Tones.PROCESSING: + self._processing_tone_done.clear() + self.config_entry.async_create_background_task( + self.hass, self._play_tone(Tones.PROCESSING), "voip_process_tone" + ) + elif event.type == PipelineEventType.TTS_END: + # Send TTS audio to caller over RTP + if event.data and (tts_output := event.data["tts_output"]): + media_id = tts_output["media_id"] + self.config_entry.async_create_background_task( + self.hass, + self._send_tts(media_id), + "voip_pipeline_tts", + ) + else: + # Empty TTS response + self._tts_done.set() + elif event.type == PipelineEventType.ERROR: + # Play error tone instead of wait for TTS when pipeline is finished. + self._pipeline_had_error = True + + async def _send_tts(self, media_id: str) -> None: + """Send TTS audio to caller via RTP.""" + try: + if self.transport is None: + return # not connected + + extension, data = await tts.async_get_media_source_audio( + self.hass, + media_id, + ) + + if extension != "wav": + raise ValueError(f"Only WAV audio can be streamed, got {extension}") + + if (self._tones & Tones.PROCESSING) == Tones.PROCESSING: + # Don't overlap TTS and processing beep + await self._processing_tone_done.wait() + + with io.BytesIO(data) as wav_io: + with wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + + if ( + (sample_rate != RATE) + or (sample_width != WIDTH) + or (sample_channels != CHANNELS) + ): + raise ValueError( + f"Expected rate/width/channels as {RATE}/{WIDTH}/{CHANNELS}," + f" got {sample_rate}/{sample_width}/{sample_channels}" + ) + + audio_bytes = wav_file.readframes(wav_file.getnframes()) + + _LOGGER.debug("Sending %s byte(s) of audio", len(audio_bytes)) + + # Time out 1 second after TTS audio should be finished + tts_samples = len(audio_bytes) / (WIDTH * CHANNELS) + tts_seconds = tts_samples / RATE + + async with asyncio.timeout(tts_seconds + self._tts_extra_timeout): + # TTS audio is 16Khz 16-bit mono + await self._async_send_audio(audio_bytes) + except TimeoutError: + _LOGGER.warning("TTS timeout") + raise + finally: + # Signal pipeline to restart + self._tts_done.set() + + # Update satellite state + self.tts_response_finished() + + async def _async_send_audio(self, audio_bytes: bytes, **kwargs): + """Send audio in executor.""" + await self.hass.async_add_executor_job( + partial(self.send_audio, audio_bytes, **RTP_AUDIO_SETTINGS, **kwargs) + ) + + async def _play_tone(self, tone: Tones, silence_before: float = 0.0) -> None: + """Play a tone as feedback to the user if it's enabled.""" + if (self._tones & tone) != tone: + return # not enabled + + if tone not in self._tone_bytes: + # Do I/O in executor + self._tone_bytes[tone] = await self.hass.async_add_executor_job( + self._load_pcm, + _TONE_FILENAMES[tone], + ) + + await self._async_send_audio( + self._tone_bytes[tone], + silence_before=silence_before, + ) + + if tone == Tones.PROCESSING: + self._processing_tone_done.set() + + def _load_pcm(self, file_name: str) -> bytes: + """Load raw audio (16Khz, 16-bit mono).""" + return (Path(__file__).parent / file_name).read_bytes() diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py index 8eeefbd5d94..121de507d7b 100644 --- a/homeassistant/components/voip/binary_sensor.py +++ b/homeassistant/components/voip/binary_sensor.py @@ -51,10 +51,12 @@ class VoIPCallInProgress(VoIPEntity, BinarySensorEntity): """Call when entity about to be added to hass.""" await super().async_added_to_hass() - self.async_on_remove(self._device.async_listen_update(self._is_active_changed)) + self.async_on_remove( + self.voip_device.async_listen_update(self._is_active_changed) + ) @callback def _is_active_changed(self, device: VoIPDevice) -> None: """Call when active state changed.""" - self._attr_is_on = self._device.is_active + self._attr_is_on = self.voip_device.is_active self.async_write_ha_state() diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py index 4e2dca15308..613d05fc614 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator from dataclasses import dataclass, field -from voip_utils import CallInfo +from voip_utils import CallInfo, VoipDatagramProtocol from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant, callback @@ -22,6 +22,7 @@ class VoIPDevice: device_id: str is_active: bool = False update_listeners: list[Callable[[VoIPDevice], None]] = field(default_factory=list) + protocol: VoipDatagramProtocol | None = None @callback def set_is_active(self, active: bool) -> None: @@ -56,6 +57,18 @@ class VoIPDevice: return False + def get_pipeline_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for pipeline select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id("select", DOMAIN, f"{self.voip_id}-pipeline") + + def get_vad_sensitivity_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for VAD sensitivity.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.voip_id}-vad_sensitivity" + ) + class VoIPDevices: """Class to store devices.""" diff --git a/homeassistant/components/voip/entity.py b/homeassistant/components/voip/entity.py index 9e1e067b195..e96784bc218 100644 --- a/homeassistant/components/voip/entity.py +++ b/homeassistant/components/voip/entity.py @@ -15,10 +15,10 @@ class VoIPEntity(entity.Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, device: VoIPDevice) -> None: + def __init__(self, voip_device: VoIPDevice) -> None: """Initialize VoIP entity.""" - self._device = device - self._attr_unique_id = f"{device.voip_id}-{self.entity_description.key}" + self.voip_device = voip_device + self._attr_unique_id = f"{voip_device.voip_id}-{self.entity_description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.voip_id)}, + identifiers={(DOMAIN, voip_device.voip_id)}, ) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 594abc69c13..964193fca53 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -3,7 +3,7 @@ "name": "Voice over IP", "codeowners": ["@balloob", "@synesthesiam"], "config_flow": true, - "dependencies": ["assist_pipeline"], + "dependencies": ["assist_pipeline", "assist_satellite"], "documentation": "https://www.home-assistant.io/integrations/voip", "iot_class": "local_push", "quality_scale": "internal", diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 8bcbb06d4e2..750f526ba1b 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -10,6 +10,16 @@ } }, "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/homeassistant/components/voip/util.py b/homeassistant/components/voip/util.py new file mode 100644 index 00000000000..bfda96ba810 --- /dev/null +++ b/homeassistant/components/voip/util.py @@ -0,0 +1,28 @@ +"""Voip util functions.""" + +from __future__ import annotations + +from asyncio import Queue, timeout as async_timeout +from collections.abc import AsyncIterable +from typing import Any + +from typing_extensions import TypeVar + +_DataT = TypeVar("_DataT", default=Any) + + +async def queue_to_iterable( + queue: Queue[_DataT], timeout: float | None = None +) -> AsyncIterable[_DataT]: + """Stream items from a queue until None with an optional timeout per item.""" + if timeout is None: + while (item := await queue.get()) is not None: + yield item + else: + async with async_timeout(timeout): + item = await queue.get() + + while item is not None: + yield item + async with async_timeout(timeout): + item = await queue.get() diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index be1e58b6eec..6f6cf989d3b 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -3,15 +3,11 @@ from __future__ import annotations import asyncio -from collections import deque -from collections.abc import AsyncIterable, MutableSequence, Sequence from functools import partial -import io import logging from pathlib import Path import time from typing import TYPE_CHECKING -import wave from voip_utils import ( CallInfo, @@ -21,33 +17,19 @@ from voip_utils import ( VoipDatagramProtocol, ) -from homeassistant.components import assist_pipeline, stt, tts from homeassistant.components.assist_pipeline import ( Pipeline, - PipelineEvent, - PipelineEventType, PipelineNotFound, async_get_pipeline, - async_pipeline_from_audio_stream, select as pipeline_select, ) -from homeassistant.components.assist_pipeline.audio_enhancer import ( - AudioEnhancer, - MicroVadSpeexEnhancer, -) -from homeassistant.components.assist_pipeline.vad import ( - AudioBuffer, - VadSensitivity, - VoiceCommandSegmenter, -) from homeassistant.const import __version__ -from homeassistant.core import Context, HomeAssistant -from homeassistant.util.ulid import ulid_now +from homeassistant.core import HomeAssistant from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH if TYPE_CHECKING: - from .devices import VoIPDevice, VoIPDevices + from .devices import VoIPDevices _LOGGER = logging.getLogger(__name__) @@ -60,11 +42,8 @@ def make_protocol( ) -> VoipDatagramProtocol: """Plays a pre-recorded message if pipeline is misconfigured.""" voip_device = devices.async_get_or_create(call_info) - pipeline_id = pipeline_select.get_chosen_pipeline( - hass, - DOMAIN, - voip_device.voip_id, - ) + + pipeline_id = pipeline_select.get_chosen_pipeline(hass, DOMAIN, voip_device.voip_id) try: pipeline: Pipeline | None = async_get_pipeline(hass, pipeline_id) except PipelineNotFound: @@ -83,22 +62,18 @@ def make_protocol( rtcp_state=rtcp_state, ) - vad_sensitivity = pipeline_select.get_vad_sensitivity( - hass, - DOMAIN, - voip_device.voip_id, - ) + if (protocol := voip_device.protocol) is None: + raise ValueError("VoIP satellite not found") - # Pipeline is properly configured - return PipelineRtpDatagramProtocol( - hass, - hass.config.language, - voip_device, - Context(user_id=devices.config_entry.data["user"]), - opus_payload_type=call_info.opus_payload_type, - silence_seconds=VadSensitivity.to_seconds(vad_sensitivity), - rtcp_state=rtcp_state, - ) + protocol._rtp_input.opus_payload_type = call_info.opus_payload_type # noqa: SLF001 + protocol._rtp_output.opus_payload_type = call_info.opus_payload_type # noqa: SLF001 + + protocol.rtcp_state = rtcp_state + if protocol.rtcp_state is not None: + # Automatically disconnect when BYE is received over RTCP + protocol.rtcp_state.bye_callback = protocol.disconnect + + return protocol class HassVoipDatagramProtocol(VoipDatagramProtocol): @@ -143,372 +118,6 @@ class HassVoipDatagramProtocol(VoipDatagramProtocol): await self._closed_event.wait() -class PipelineRtpDatagramProtocol(RtpDatagramProtocol): - """Run a voice assistant pipeline in a loop for a VoIP call.""" - - def __init__( - self, - hass: HomeAssistant, - language: str, - voip_device: VoIPDevice, - context: Context, - opus_payload_type: int, - pipeline_timeout: float = 30.0, - audio_timeout: float = 2.0, - buffered_chunks_before_speech: int = 100, - listening_tone_enabled: bool = True, - processing_tone_enabled: bool = True, - error_tone_enabled: bool = True, - tone_delay: float = 0.2, - tts_extra_timeout: float = 1.0, - silence_seconds: float = 1.0, - rtcp_state: RtcpState | None = None, - ) -> None: - """Set up pipeline RTP server.""" - super().__init__( - rate=RATE, - width=WIDTH, - channels=CHANNELS, - opus_payload_type=opus_payload_type, - rtcp_state=rtcp_state, - ) - - self.hass = hass - self.language = language - self.voip_device = voip_device - self.pipeline: Pipeline | None = None - self.pipeline_timeout = pipeline_timeout - self.audio_timeout = audio_timeout - self.buffered_chunks_before_speech = buffered_chunks_before_speech - self.listening_tone_enabled = listening_tone_enabled - self.processing_tone_enabled = processing_tone_enabled - self.error_tone_enabled = error_tone_enabled - self.tone_delay = tone_delay - self.tts_extra_timeout = tts_extra_timeout - self.silence_seconds = silence_seconds - - self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue() - self._context = context - self._conversation_id: str | None = None - self._pipeline_task: asyncio.Task | None = None - self._tts_done = asyncio.Event() - self._session_id: str | None = None - self._tone_bytes: bytes | None = None - self._processing_bytes: bytes | None = None - self._error_bytes: bytes | None = None - self._pipeline_error: bool = False - - def connection_made(self, transport): - """Server is ready.""" - super().connection_made(transport) - self.voip_device.set_is_active(True) - - def connection_lost(self, exc): - """Handle connection is lost or closed.""" - super().connection_lost(exc) - self.voip_device.set_is_active(False) - - def on_chunk(self, audio_bytes: bytes) -> None: - """Handle raw audio chunk.""" - if self._pipeline_task is None: - self._clear_audio_queue() - - # Run pipeline until voice command finishes, then start over - self._pipeline_task = self.hass.async_create_background_task( - self._run_pipeline(), - "voip_pipeline_run", - ) - - self._audio_queue.put_nowait(audio_bytes) - - async def _run_pipeline( - self, - ) -> None: - """Forward audio to pipeline STT and handle TTS.""" - if self._session_id is None: - self._session_id = ulid_now() - - # Play listening tone at the start of each cycle - if self.listening_tone_enabled: - await self._play_listening_tone() - - try: - # Wait for speech before starting pipeline - segmenter = VoiceCommandSegmenter(silence_seconds=self.silence_seconds) - audio_enhancer = MicroVadSpeexEnhancer(0, 0, True) - chunk_buffer: deque[bytes] = deque( - maxlen=self.buffered_chunks_before_speech, - ) - speech_detected = await self._wait_for_speech( - segmenter, - audio_enhancer, - chunk_buffer, - ) - if not speech_detected: - _LOGGER.debug("No speech detected") - return - - _LOGGER.debug("Starting pipeline") - self._tts_done.clear() - - async def stt_stream(): - try: - async for chunk in self._segment_audio( - segmenter, - audio_enhancer, - chunk_buffer, - ): - yield chunk - - if self.processing_tone_enabled: - await self._play_processing_tone() - except TimeoutError: - # Expected after caller hangs up - _LOGGER.debug("Audio timeout") - self._session_id = None - self.disconnect() - finally: - self._clear_audio_queue() - - # Run pipeline with a timeout - async with asyncio.timeout(self.pipeline_timeout): - await async_pipeline_from_audio_stream( - self.hass, - context=self._context, - event_callback=self._event_callback, - stt_metadata=stt.SpeechMetadata( - language="", # set in async_pipeline_from_audio_stream - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=stt_stream(), - pipeline_id=pipeline_select.get_chosen_pipeline( - self.hass, DOMAIN, self.voip_device.voip_id - ), - conversation_id=self._conversation_id, - device_id=self.voip_device.device_id, - tts_audio_output="wav", - ) - - if self._pipeline_error: - self._pipeline_error = False - if self.error_tone_enabled: - await self._play_error_tone() - else: - # Block until TTS is done speaking. - # - # This is set in _send_tts and has a timeout that's based on the - # length of the TTS audio. - await self._tts_done.wait() - - _LOGGER.debug("Pipeline finished") - except PipelineNotFound: - _LOGGER.warning("Pipeline not found") - except TimeoutError: - # Expected after caller hangs up - _LOGGER.debug("Pipeline timeout") - self._session_id = None - self.disconnect() - finally: - # Allow pipeline to run again - self._pipeline_task = None - - async def _wait_for_speech( - self, - segmenter: VoiceCommandSegmenter, - audio_enhancer: AudioEnhancer, - chunk_buffer: MutableSequence[bytes], - ): - """Buffer audio chunks until speech is detected. - - Returns True if speech was detected, False otherwise. - """ - # Timeout if no audio comes in for a while. - # This means the caller hung up. - async with asyncio.timeout(self.audio_timeout): - chunk = await self._audio_queue.get() - - vad_buffer = AudioBuffer(assist_pipeline.SAMPLES_PER_CHUNK * WIDTH) - - while chunk: - chunk_buffer.append(chunk) - - segmenter.process_with_vad( - chunk, - assist_pipeline.SAMPLES_PER_CHUNK, - lambda x: audio_enhancer.enhance_chunk(x, 0).is_speech is True, - vad_buffer, - ) - if segmenter.in_command: - # Buffer until command starts - if len(vad_buffer) > 0: - chunk_buffer.append(vad_buffer.bytes()) - - return True - - async with asyncio.timeout(self.audio_timeout): - chunk = await self._audio_queue.get() - - return False - - async def _segment_audio( - self, - segmenter: VoiceCommandSegmenter, - audio_enhancer: AudioEnhancer, - chunk_buffer: Sequence[bytes], - ) -> AsyncIterable[bytes]: - """Yield audio chunks until voice command has finished.""" - # Buffered chunks first - for buffered_chunk in chunk_buffer: - yield buffered_chunk - - # Timeout if no audio comes in for a while. - # This means the caller hung up. - async with asyncio.timeout(self.audio_timeout): - chunk = await self._audio_queue.get() - - vad_buffer = AudioBuffer(assist_pipeline.SAMPLES_PER_CHUNK * WIDTH) - - while chunk: - if not segmenter.process_with_vad( - chunk, - assist_pipeline.SAMPLES_PER_CHUNK, - lambda x: audio_enhancer.enhance_chunk(x, 0).is_speech is True, - vad_buffer, - ): - # Voice command is finished - break - - yield chunk - - async with asyncio.timeout(self.audio_timeout): - chunk = await self._audio_queue.get() - - def _clear_audio_queue(self) -> None: - while not self._audio_queue.empty(): - self._audio_queue.get_nowait() - - def _event_callback(self, event: PipelineEvent): - if not event.data: - return - - if event.type == PipelineEventType.INTENT_END: - # Capture conversation id - self._conversation_id = event.data["intent_output"]["conversation_id"] - elif event.type == PipelineEventType.TTS_END: - # Send TTS audio to caller over RTP - tts_output = event.data["tts_output"] - if tts_output: - media_id = tts_output["media_id"] - self.hass.async_create_background_task( - self._send_tts(media_id), - "voip_pipeline_tts", - ) - else: - # Empty TTS response - self._tts_done.set() - elif event.type == PipelineEventType.ERROR: - # Play error tone instead of wait for TTS - self._pipeline_error = True - - async def _send_tts(self, media_id: str) -> None: - """Send TTS audio to caller via RTP.""" - try: - if self.transport is None: - return - - extension, data = await tts.async_get_media_source_audio( - self.hass, - media_id, - ) - - if extension != "wav": - raise ValueError(f"Only WAV audio can be streamed, got {extension}") - - with io.BytesIO(data) as wav_io: - with wave.open(wav_io, "rb") as wav_file: - sample_rate = wav_file.getframerate() - sample_width = wav_file.getsampwidth() - sample_channels = wav_file.getnchannels() - - if ( - (sample_rate != RATE) - or (sample_width != WIDTH) - or (sample_channels != CHANNELS) - ): - raise ValueError( - f"Expected rate/width/channels as {RATE}/{WIDTH}/{CHANNELS}," - f" got {sample_rate}/{sample_width}/{sample_channels}" - ) - - audio_bytes = wav_file.readframes(wav_file.getnframes()) - - _LOGGER.debug("Sending %s byte(s) of audio", len(audio_bytes)) - - # Time out 1 second after TTS audio should be finished - tts_samples = len(audio_bytes) / (WIDTH * CHANNELS) - tts_seconds = tts_samples / RATE - - async with asyncio.timeout(tts_seconds + self.tts_extra_timeout): - # TTS audio is 16Khz 16-bit mono - await self._async_send_audio(audio_bytes) - except TimeoutError: - _LOGGER.warning("TTS timeout") - raise - finally: - # Signal pipeline to restart - self._tts_done.set() - - async def _async_send_audio(self, audio_bytes: bytes, **kwargs): - """Send audio in executor.""" - await self.hass.async_add_executor_job( - partial(self.send_audio, audio_bytes, **RTP_AUDIO_SETTINGS, **kwargs) - ) - - async def _play_listening_tone(self) -> None: - """Play a tone to indicate that Home Assistant is listening.""" - if self._tone_bytes is None: - # Do I/O in executor - self._tone_bytes = await self.hass.async_add_executor_job( - self._load_pcm, - "tone.pcm", - ) - - await self._async_send_audio( - self._tone_bytes, - silence_before=self.tone_delay, - ) - - async def _play_processing_tone(self) -> None: - """Play a tone to indicate that Home Assistant is processing the voice command.""" - if self._processing_bytes is None: - # Do I/O in executor - self._processing_bytes = await self.hass.async_add_executor_job( - self._load_pcm, - "processing.pcm", - ) - - await self._async_send_audio(self._processing_bytes) - - async def _play_error_tone(self) -> None: - """Play a tone to indicate a pipeline error occurred.""" - if self._error_bytes is None: - # Do I/O in executor - self._error_bytes = await self.hass.async_add_executor_job( - self._load_pcm, - "error.pcm", - ) - - await self._async_send_audio(self._error_bytes) - - def _load_pcm(self, file_name: str) -> bytes: - """Load raw audio (16Khz, 16-bit mono).""" - return (Path(__file__).parent / file_name).read_bytes() - - class PreRecordMessageProtocol(RtpDatagramProtocol): """Plays a pre-recorded message on a loop.""" diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index b039a49e0f0..cbca8997797 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -14,6 +14,9 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.components.tts.conftest import ( + mock_tts_cache_dir_fixture_autouse, # noqa: F401 +) @pytest.fixture(autouse=True) diff --git a/tests/components/voip/snapshots/test_voip.ambr b/tests/components/voip/snapshots/test_voip.ambr new file mode 100644 index 00000000000..935dbba51b8 --- /dev/null +++ b/tests/components/voip/snapshots/test_voip.ambr @@ -0,0 +1,10 @@ +# serializer version: 1 +# name: test_calls_not_allowed + b'\xfe\xff\x04\x00\x05\x00\x03\x00\x04\x00\x03\x00\x02\x00\x00\x00\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xfe\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xfe\xff\xfc\xff\xfc\xff\xfc\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xfd\xff\xfe\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x03\x00\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\x00\x00\xff\xff\x00\x00\x00\x00\xfe\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\xfe\xff\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x03\x00\x02\x00\x03\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x03\x00\x02\x00\x02\x00\x01\x00\xff\xff\x01\x00\x01\x00\x01\x00\xfe\xff\xfc\xff\xff\xff\x00\x00\xfe\xff\x00\x00\x00\x00\xfd\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfd\xff\xfe\xff\x00\x00\xff\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfc\xff\xfe\xff\xfd\xff\xfe\xff\xfc\xff\xfc\xff\xfe\xff\xfd\xff\xfc\xff\xfe\xff\xfc\xff\xfc\xff\xfd\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xfe\xff\x00\x00\xff\xff\xff\xff\x00\x00\xfe\xff\xfe\xff\x00\x00\x00\x00\xfe\xff\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\xfe\xff\xfe\xff\x02\x00\x02\x00\x01\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x02\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xfe\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x02\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfd\xff\xfe\xff\xff\xff\xff\xff\xfd\xff\xfd\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xff\xff\x00\x00\xff\xff\xfe\xff\x00\x00\xfe\xff\xfc\xff\xfd\xff\xfe\xff\xfd\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xfd\xff\xff\xff\xff\xff\xfd\xff\xfc\xff\xfd\xff\xfe\xff\xfe\xff\xfc\xff\xfc\xff\xff\xff\xfe\xff\xfc\xff\xfa\xff\xfb\xff\xfb\xff\xfb\xff\xff\xff\xfe\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xfe\xff\x00\x00\xff\xff\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xfa\xff\xfe\xff\x00\x00\xfd\xff\x00\x00\x00\x00\xff\xff\x00\x00\xfd\xff\xfa\xff\xfc\xff\xfc\xff\xfa\xff\xfe\xff\xfd\xff\xf8\xff\xf7\xff\xfa\xff\xfe\xff\xfa\xff\xf8\xff\xf9\xff\xfa\xff\xfd\xff\x00\x00\x00\x00\x00\x00\xfb\xff\xfb\xff\xfa\xff\xfd\xff\xff\xff\xff\xff\x01\x00\xfc\xff\xff\xff\xf8\xff\xff\xff\x00\x00\xf3\xff\xfd\xff\xf3\xff\xfb\xff\x01\x00\xff\xff\xfa\xff\x02\x00\xf4\xff\xeb\xff\xfc\xff\xf7\xff\xe8\xff\xfb\xff\xf8\xff\xf7\xff\r\x00\xfe\xff\x02\x00\xfe\xff\xf9\xff\xfa\xff\xf8\xff\x00\x00\xf6\xff\xfe\xff\x02\x00\x05\x00\x04\x00\xfa\xff\xf4\xff\xe8\xff\xf3\xff\x06\x00\xf9\xff\x06\x00\n\x00\xf8\xff\xfa\xff\x01\x00\xf4\xff\xfd\xff\xf7\xff\xf4\xff\x01\x00\x05\x00\x02\x00\x04\x00\xfc\xff\xef\xff\x03\x00\xf3\xff\xfc\xff\x08\x00\x04\x00\xfd\xff\x08\x00\x04\x00\x00\x00\x00\x00\x06\x00\x03\x00\xfd\xff\x04\x00\x15\x00\x06\x00\x12\x00\x15\x00\x05\x00\x04\x00\x05\x00\x05\x00\x02\x00\x07\x00\x05\x00\xfc\xff\xfd\xff\x06\x00\xff\xff\xf8\xff\x01\x00\xf2\xff\xe6\xff\xf4\xff\xef\xff\xfb\xff\xfc\xff\xf2\xff\xec\xff\xe4\xff\xe6\xff\xf9\xff\xfa\xff\xee\xff\xea\xff\xe9\xff\xf8\xff\x06\x00\x0b\x00\xe9\xff\x03\x00\xea\xff\xfc\xff\x0f\x00\x00\x00\x13\x00\xe6\xff\xfe\xff\x10\x00\x12\x00\xfd\xff\x03\x00\xf1\xff\xfb\xff\x18\x00\x1f\x00\x08\x00\xfa\xff\xf9\xff\xf6\xff\r\x00\x17\x00\x03\x00\xfb\xff\xfc\xff\xf3\xff,\x00\x1c\x00\xf8\xff\xed\xff\x05\x00\x10\x00$\x00@\x00\x19\x00\x00\x00\x19\x004\x00G\x00]\x001\x00\x07\x005\x00J\x00X\x00\\\x00\x03\x00\xf6\xff\x13\x007\x00]\x008\x00\xef\xff\xeb\xff\x00\x00#\x00\x85\x00S\x00\xb6\xff\xcf\xff\x1a\x00\xc3\xff\xb6\x00\x8a\x00^\xff\xe0\xff\xfc\xff\xba\xff4\x00n\x00\xc5\xff5\xff\xf4\xffR\x00\xe8\xff-\x00\x11\x00z\xff\xb0\xff\x92\x00\xeb\xff\xca\xff\t\x00\xa0\xff\xcb\xff6\x00L\x00\x02\x00\x91\xff\xdb\xff\xd3\xff\xed\xff\xc0\xff\x8b\xff\x97\x00\xe2\xff\x16\x00B\x00\xbc\xff\xfb\xff1\x00\xe4\xff\xed\xff\x95\x00\xcc\x00H\x00>\x00\x03\x00g\xff\x18\x01\x8c\x01\xa8\xff?\xff\xc6\xfeO\xff\xaa\x00\x00\x01Q\xff\xaf\xfe\xce\xfe\xd8\xfe\x7f\xff\xce\xfe\x93\xfd\xb6\xfc\x9c\xfd\xb1\xff\xf7\x00H\x00D\xfe\x8d\xfc\xc2\xfco\xffG\x01r\x00\x94\xffG\x007\x01,\x02\xc0\x02\x18\x01\xaa\xff\xf0\xffS\x00\xbf\x029\x03\xa0\x01p\x00/\x00\xc4\xff\xb3\xff\xd4\xffU\xfdB\xfd\x8b\xfe\xfb\xfe\x86\xfe\x0e\xfd\xba\xfd\xb7\xfd\x8e\xfc\xf0\xfc\x88\xfd"\xfe\'\xfe]\xfe\xfb\xfe\x13\x00\x08\x01\xe1\x00&\xff\xf0\xfe\x05\x015\x01E\x02:\x02G\x02*\x02E\x02\xcf\x02\x1f\x03\xcc\x03\x15\x03N\x03\xdf\x03\x82\x04X\x05P\x05f\x04}\x04Q\x06\xe3\x06\x9a\x06\x8e\x06\xc7\x05a\x05\xe6\x05-\x06g\x066\x06\x9e\x05\xf4\x03\x9b\x03\x14\x03e\x02\x99\x01\xdf\xff\xa1\xfe{\xfe%\xfe2\xfd/\xfc\xc3\xfa-\xf9\xe2\xf8\xa2\xf8\x8d\xf8\xa0\xf9B\xf9\x15\xf9\xf3\xf8<\xf9y\xfa\xe1\xfa\xce\xfa#\xfb\xa1\xfc\xf3\xfd\xec\xfeE\xff\xc5\xfe\x9f\xfe8\xff\x19\xff\xff\xfe5\xff\xd8\xfe\x90\xfe\x87\xfd\xb5\xfcR\xfc\x18\xfc\xae\xfaI\xf9/\xf9\x14\xf9>\xf9\xb6\xf8d\xf8o\xf8E\xf8\x18\xf8c\xf8g\xfaA\xfb\xe2\xfak\xfb\xda\xfbM\xfd\xa0\xfe\x1c\xfft\xfe\xee\xfe\xf9\xff\x0e\x00y\x00*\x00P\xff\xfa\xfe\x84\xfe\xef\xfd\xd4\xfe\xb3\xfdf\xfd\xfa\xfbq\xfb\xfa\xfb \xfd{\xfd\xe4\xfc\xb3\xfc\xe5\xfa\x97\xfd\xee\xffP\x00o\x01o\x00\xfc\x01\x13\x04S\x05R\x08\x13\x07\xda\x08\xa6\t`\x0cX\x11\x1c\x0f\x88\x0b\xb5\x04\x17\x08\x8f\x17\x8f)\x9f4G+\xa0\x1c\x9f\x12\xe9\x13\x88#\xac+++\x94"\x8f\x1bM\x1f\xa0\x1e\x05\x17\xf1\x04\x17\xf4V\xec\x13\xf0\x0e\xfaJ\xfe&\xf9\xcb\xe7\x96\xd7\xa6\xcf\xab\xd2\xd2\xd9\x95\xdbT\xd9J\xdb\x84\xe2\x98\xe8\x06\xeb8\xe8J\xe5\x93\xe5\xfa\xea\xa2\xf8:\t\xe9\x11\xd3\x10c\n\x97\x05\x1a\x08\xbb\r\x94\r\x15\x0e\xef\rz\x0eU\x10\xc1\r+\x08\xbd\xfd(\xf24\xeaj\xec\x1f\xf3H\xf7\x0e\xf5\x07\xed\xcb\xe6\x9f\xe2\xe1\xe2\xdc\xe5&\xe87\xed\xcb\xf1\x13\xf8\xf9\xfe*\x039\x04\xda\x00h\xff\xe3\x03\xdf\x0eY\x18\xf4\x1d\xa7\x1d8\x1a=\x16\xad\x12\xa8\x118\x11\xa7\x10\xaa\x0e\xfb\r`\x0c}\n\xd2\x06|\xfe@\xf5\x11\xf1U\xf2K\xf6\x9c\xf9\xf3\xf8\xf8\xf5\xc6\xf2\xb5\xf0\x1a\xf2\xb6\xf4\xa5\xf6\x87\xf7~\xf9\xa3\xfd1\x01{\x03\xff\x01\xfc\xfc\x1c\xfb\xa7\xfb\xf7\xfd\x85\x00\xb1\x00\xaf\xfe\x01\xfc\xf1\xf9x\xf7\xab\xf6c\xf4M\xf2f\xf2?\xf2\x1f\xf7 \xf8\xf7\xf7!\xf6W\xf1@\xf34\xf5\xce\xf8\xdb\xfdA\x00\x9f\x02[\x03\x18\x04\x0b\x01\xb0\x02\x0c\x06>\x08 \x0b\xfa\n\xc6\x0e\xaa\x12K\x13>\x12y\x11o\x11C\x17\xe6\':7w9 /\x0e$\'$\xcb)\x04,\xc8+\xc3+\x9a)t"`\x18\xf0\x0f\x88\x07s\xfc\xe7\xef\xf7\xe7\x9a\xe9\x0c\xed3\xec\xcf\xe4*\xda\x11\xd1\xab\xcc\xf6\xcc\x00\xd1^\xd8\x93\xdf\xb2\xe4\x08\xe75\xea\xad\xefZ\xf3`\xf4\x0c\xf6\x05\xfd/\tU\x13\xaa\x17\x06\x16,\x13-\x10\xdb\r\x18\x0c"\n\xe7\t\x8b\x08\xef\x04\x19\x00P\xfb\xd8\xf5\xbe\xed{\xe4C\xe0\x04\xe1\xff\xe2\xe4\xe2\xaf\xe2\x89\xe2N\xe2\x9e\xe2 \xe4{\xe8\x1e\xef\xf5\xf5\x1a\xfc\xf2\x01\x9e\x08\xba\x0fh\x12\xf5\x14\xcc\x17\xb1\x1cb \xb8 9!\xa6 |\x1f\xbe\x1bu\x17\xef\x13~\x0fo\nt\x05\xef\x00;\xfd\xe3\xf9L\xf6\xab\xf1&\xef\xc7\xed\xf0\xec\x1a\xed\xf4\xec;\xee/\xf0\xa9\xf22\xf68\xf9\xf3\xfax\xfb\xd2\xfd(\xff\x00\x01\x9d\x02\xad\x02T\x03R\x02\xf6\x00$\xff\\\xfd\x9e\xfa\xef\xf6\x91\xf4\x1d\xf3K\xf3\x80\xf2\x88\xf0X\xed#\xec\x8f\xebb\xeaK\xeb\xdc\xec;\xf0\xf2\xf3\x15\xf5\xfe\xf7\xb2\xfa\xf3\xfb(\xff\xc9\x00`\x03]\t\x0b\x0cW\x10O\x14\xbf\x14\x9c\x14\xb6\x11\x81\x11X\x14y\x17f\x1b\xce&c9vB\x96:,&\xd5\x1b\xbc%\xf20H4.3\x983\xaa0P%B\x15\xa8\n/\x03i\xf8#\xf0\xcf\xf03\xf9"\xfc\x8a\xf1\xb0\xde{\xd1\xf9\xcc\xa9\xcc>\xcfw\xd67\xe1\xb8\xe7F\xe7\x06\xe6\xf1\xe7\x0c\xe9C\xe8\x0c\xea\xa2\xf2\x83\x00k\r\x91\x13\xb1\x13X\x0f\xa4\n\x13\x07(\x05\xe9\x06L\n\x00\x0b\xbb\x08\xe9\x04\x01\xffN\xf7\x97\xee\'\xe6\x9c\xe0\x95\xdeu\xdfX\xe3\x14\xe5\xba\xe4\xee\xe1\xad\xddE\xdc+\xe0\x10\xe8\xab\xf0\xbc\xf7\x06\xfet\x05\xdf\n\xc3\x0fH\x13)\x16W\x18\x7f\x1c\xc3"\x14)\xaf+<)k$0\x1e8\x19V\x15\r\x13\xd7\x0f3\x0c\x0e\x08\xb8\x01l\xfa\xe3\xf3\x05\xef\x17\xec_\xeah\xea\xa6\xeb\xfd\xec`\xeex\xef\x82\xf0\xcf\xf0U\xf2\x9d\xf5S\xfa\x89\xff\x1f\x02[\x03>\x039\x02L\x01\x0b\x00\x1e\x00\x11\x00\xb5\xfe~\xfcv\xf9!\xf7\xb5\xf3\xde\xf0Z\xed\xac\xeat\xe9\xc3\xe8\xdd\xe9\xf5\xe9\xe5\xe9\xce\xe8+\xe9_\xeb\x0f\xee\xa8\xf3x\xf6\xc7\xf9\x8d\xfc`\xfe\xd3\x03R\tA\x0f\x15\x12C\x12\xdb\x12\xfd\x14%\x1bK\x1eN\x1f\xd3\x1f\x08#\xa5.\xe7<\xb7D\x99?\x101*&\xc8&O.N3\xac2w/\x03(\xf0\x1c\xbe\x0e\xd9\x03\x90\xfd\x9f\xf5*\xefz\xeb\x04\xed\x81\xee\xac\xe9\x04\xe0\xcd\xd4`\xcdL\xcc7\xd2x\xdb0\xe4?\xea\x03\xeb\xe6\xe9\x1d\xeaH\xed\xd2\xf2\xfe\xf6\xc3\xfc\x11\x04\xa6\x0cc\x12H\x14\'\x12\x06\r!\x08h\x04\x1c\x03\x0c\x03\x10\x03{\x02\x18\xff?\xf8\xe8\xef\x1a\xe7\x00\xe1\x1e\xddb\xdb\xcf\xdby\xdd)\xe0=\xe2T\xe2\xf5\xe1\xac\xe2\xcf\xe4\xd4\xe8\xbc\xef?\xfa\xf3\x03k\x0cF\x12A\x16R\x18F\x19U\x1c\xca w%\x9a(\x99)])S&(!\xa5\x1aD\x14\x12\x0fP\x0b\x12\x08g\x04q\x00\xa2\xfb\xf8\xf5x\xef1\xea^\xe7\x82\xe7\x04\xe9p\xeb\xc0\xed\xf6\xef\x94\xf1\x94\xf2#\xf3v\xf4k\xf7j\xfb\xbf\xff\xcc\x03N\x07\x1f\x08\xbc\x06\x03\x04\xe8\x01\xe7\x00\r\x00\xf5\xfeo\xfdE\xfbl\xf8\x8e\xf5|\xf1;\xed\'\xea\n\xe8,\xe7\xe3\xe7,\xe9d\xea`\xea#\xe9\xf5\xe8\x88\xea\x11\xede\xf2\xcb\xf7`\xfc\xda\xff7\x00l\x01H\x04g\tX\x0f\x93\x13\x1c\x162\x18S\x1c\xa5!\xa5,&=\xe5J\x93L\xaf>[1\x0e1\x819x?j>\x0b;\x8c6\xc3,\\\x1de\x0e\xb3\x04X\xfc8\xf35\xeb_\xe87\xebR\xeb\xf2\xe2\x8e\xd4\x8c\xc9\xad\xc6\xb1\xc9\xe4\xce\xc2\xd5c\xde\x8d\xe5x\xe8\xc8\xe8\xd7\xe9\xc7\xed\x9a\xf2\x15\xf7\xa0\xfc\x87\x04\xc1\x0fd\x19V\x1d\x97\x19a\x12\x93\x0b\xee\x06\xf7\x04E\x05\xe8\x06\x05\x07\xbb\x02\xb6\xf9\xbd\xee|\xe5P\xe0\xa5\xdc\x98\xd9*\xd8\x86\xd9;\xdc\x05\xde>\xde]\xdeg\xdf\xd7\xe1\xea\xe6\r\xeeE\xf7`\x01 \nM\x0fI\x12C\x15T\x1a\x1a \x91$q\'\xa6)\x89*H)\xb4%\xf8 6\x1d\x80\x19\xe1\x14d\x0f|\n\x8f\x06\x83\x02[\xfc\xd1\xf5\'\xf0T\xec\x99\xea4\xea\xad\xeaP\xeb1\xec\xfa\xec\x9a\xed\xb2\xeeo\xf0|\xf3]\xf7!\xfb8\xff\xe3\x02\xed\x05\xd4\x07\xc2\x07b\x06\x9a\x04\xc7\x03\x97\x03\xcc\x03\xad\x02\xe1\xff\xe6\xfb0\xf7X\xf3G\xf0t\xed\xcb\xea\x80\xe8\x08\xe7b\xe6\xab\xe6J\xe7\xef\xe75\xe9\xc1\xea\xde\xec\xcd\xef\xd2\xf2\x93\xf5\xdc\xf7#\xfa\xce\xfd\xc7\x02:\x07Y\x0b\xc3\x0e\xc8\x10\x82\x11]\x11\xb9\x12\x98\x16 \x1c"%b3+C\xd8JVD\x845\xd2*\x89)I-\xca3X;\xc0?\xc3;\xfd,\r\x19\x8f\x08r\xfd\t\xf8c\xf5^\xf2\x06\xf0W\xedR\xea\x0c\xe5\xdb\xdc\xe9\xd2\xee\xca\x87\xc6\xf4\xc5\xe7\xcaP\xd5\xb0\xe1\x8c\xeaM\xec\xdb\xe8\xc5\xe5\x99\xe5U\xe9G\xf1\xd1\xfc\x8b\t\xff\x12+\x16\x90\x15\xc9\x13\xe8\x10J\x0cQ\x07\x99\x04U\x05\x85\x07\x06\t\xb2\x08\xd9\x04\xa1\xfck\xf1)\xe6\xe3\xddA\xda:\xdc\xe1\xe1\xb4\xe7\x93\xe9\x90\xe7\x0f\xe5K\xe3b\xe3\x14\xe6\xc6\xeb=\xf4"\xfdI\x05\xaa\x0c\xd7\x12\xaf\x17}\x1a\xe9\x1a\x8e\x1a\x07\x1b\x97\x1e\xc6#\xc3(M*\'(\xe4"I\x1b\xd4\x13\x9c\r\\\n\xdc\x08\xcd\x07\xaa\x04b\xffH\xf9\xba\xf3f\xef\x0b\xec\xb7\xe9\x89\xe8=\xe8\x8a\xe8\xc4\xe9\x18\xec\xf7\xeeK\xf1\x00\xf3\x02\xf4\x11\xf5\x87\xf6\xd9\xf9\x0b\xfe\xdf\x01b\x04\xee\x04\xd1\x04o\x03\xfc\x00\xd8\xfe\x9f\xfd\x06\xfdd\xfc,\xfa\xa9\xf7A\xf5M\xf3\x85\xf1\xab\xef\xac\xed\xa8\xec\xd0\xecp\xed\xae\xee\xda\xefn\xf1\x8a\xf3"\xf5\xc6\xf6\x19\xf9\x0f\xfc_\xff$\x02\x0e\x05d\x08_\x0c\x01\x10&\x12\x17\x13Q\x13\x14\x142\x15\x0b\x18b\x1fK,\xd89\x86@\x8e<\x991\xf4\'t$\xb5&\xcf+\xfa1\xa16\x036\x7f-s\x1e.\x0e\x1b\x02\x97\xfb\x81\xf93\xf8\x1d\xf55\xf0k\xeat\xe5\x7f\xe0\xaa\xda\x00\xd4\xfb\xcd\xa8\xc9r\xc8i\xcb\x1d\xd2T\xda\xf2\xe0g\xe4\'\xe5]\xe4\xb4\xe3e\xe5\x07\xeb\xf4\xf4\xcd\xffL\x08\x12\rD\x0f\xfc\x0f\xa4\x0f\xea\r\x13\x0cE\x0b\xca\x0b.\rj\x0e\xa3\x0e\xe6\x0c\xf7\x08\xf9\x02\xd0\xfb\x84\xf4\xd9\xee]\xec\xcd\xec\xca\xee \xf0\x16\xf0:\xef\xb4\xed\xfe\xeb\xe5\xea\xdc\xeb\x94\xefW\xf5\xf7\xfb\xe6\x01\x90\x06\x11\n\xf5\x0c?\x0f\xd3\x10\xb1\x11\x0c\x13c\x15\x8a\x18\xef\x1a\xfb\x1bK\x1b+\x19\xa0\x15\xed\x10\xec\x0b/\x07\xe3\x03\xdd\x01\x94\x00\xfc\xfe\xba\xfc\x8d\xf9\xe5\xf5\xef\xf1g\xee\xe3\xeb\xf6\xea\xb1\xebx\xed\xac\xef\xa0\xf1\xea\xf2k\xf3y\xf3q\xf3\xf6\xf3\x0e\xf5\xd4\xf6\xfe\xf8\x7f\xfb\xd0\xfd\xa0\xffX\x00\x1c\x00G\xff&\xfeV\xfd\xbe\xfc\xcb\xfc2\xfd\xe5\xfd\x94\xfe\xce\xfe^\xfep\xfdf\xfc\xb9\xfb\xa3\xfb\x05\xfc\x18\xfdo\xfe\xce\xff\xf0\x00\x88\x01\x8a\x01\x0b\x01B\x00\xea\xff\xa1\x00A\x02|\x04y\x06\xc5\x07V\x08\\\x08\x08\x089\x08\xff\x08\xe5\nj\x0ej\x14\x14\x1ds&\xde,\xc6-\xd4)\x19$\x17 \x8c\x1f\xa9"\xf1\'?-0/\xba+\x88"\xa1\x15\x97\x08\xc7\xfe\x01\xfa\xed\xf8x\xf8\xf3\xf5\x84\xf0$\xe9c\xe1\x19\xdaC\xd4Z\xd0\x97\xce:\xcel\xce\xf4\xceF\xd08\xd3\xe0\xd7\x9d\xdd5\xe3e\xe7\xe5\xe9\xfc\xebr\xef^\xf5\x06\xfd\x90\x05\x86\r\xf0\x13l\x17\x9a\x17\x95\x15P\x13\x88\x12\x99\x13\xdc\x15\xc4\x17$\x18\xed\x15\x94\x11\xd0\x0b\xd4\x05\xb0\x00\xf9\xfc\xcb\xfa^\xf9\xfc\xf7\x05\xf6\xca\xf3\xbd\xf1t\xf0\xab\xef\xfb\xeeA\xee\xcb\xed\'\xeeW\xef\xa4\xf1\x13\xf5Q\xf9\xa3\xfd\xe0\x00\xa2\x02\xd4\x02F\x02\x90\x02[\x04\xb5\x07\xa1\x0b\xd9\x0e\x0f\x11\xc5\x11\xd9\x10\xc1\x0e\x02\x0c\xdf\t\x95\x08E\x08N\x08\x08\x08\x05\x07&\x05\xe8\x02\x0c\x00\x81\xfc\xa2\xf8o\xf5\xc7\xf3\xaf\xf3\x81\xf4\xb5\xf5\xd4\xf6_\xf7\x1a\xf7\xdb\xf5\x07\xf4c\xf2\xc9\xf1\xff\xf2\xd9\xf5\x93\xf9\xcf\xfc\xf4\xfe\x06\x00d\x00M\x00\xd8\xff\xd5\xff\xb2\x00v\x02O\x04\xaa\x05Q\x06;\x06|\x05,\x04\xb0\x02n\x01\xbf\x00n\x00z\x00`\x00\xe1\xff\xdc\xfe\x7f\xfd?\xfca\xfb\xea\xfa\xdf\xfa\xf8\xfa\x13\xfb,\xfbH\xfb\x96\xfb\xce\xfb\x18\xfc\xd4\xfcx\xfe\xc5\x00z\x03\xea\x06$\x0cG\x13\xd5\x1ao \xec"\xea"\xb2!\x7f \xe7\x1f\xfc \x00$=(\xba+\xfd+q\'\xb1\x1e\xe7\x13\xd3\t\\\x02<\xfe\x9c\xfc\xde\xfbO\xfa\\\xf6\x96\xef\xb6\xe6\xa9\xddf\xd6\x93\xd2&\xd2:\xd4:\xd7\xfa\xd9\xed\xdb4\xdd<\xde\x85\xdf\xe4\xe1\x8a\xe5\x88\xea/\xf0.\xf63\xfcv\x02o\x08\xaa\rX\x11:\x13\x80\x13\xc9\x12\xee\x11\x85\x117\x12\xbe\x13\x87\x15\xe7\x15\xdb\x13\xf0\x0e0\x08\x1a\x01\x08\xfb\x16\xf7>\xf5#\xf5;\xf5`\xf4\xcd\xf1\xed\xed\x15\xea\x85\xe7\xa2\xe6;\xe7\xc7\xe8+\xeb"\xeeS\xf1\xb5\xf4\xff\xf7B\xfbA\xfe\xb6\x00\xaf\x02Z\x04\x84\x06\xb6\t\xff\r\x8f\x12R\x16C\x188\x18\xc6\x16\xa0\x14\xb2\x12O\x11\xd0\x10\xdc\x10\x8d\x10\xfb\x0e\xe7\x0b\xd2\x07\x98\x03\xe3\xff\xa1\xfc\xb8\xf9\x18\xf7\x02\xf5\xb3\xf3\x16\xf3\xf5\xf2\x10\xf3#\xf3\xf9\xf2X\xf2h\xf1\xba\xf0A\xf1Q\xf3\xa8\xf62\xfa\xf9\xfcV\xfe~\xfe<\xfe8\xfe\xf7\xfe;\x00\xf0\x01\x80\x03\x81\x04\x84\x04\x8e\x03\x06\x02\x91\x00\xb7\xffK\xff\x0e\xffe\xfe\x8b\xfdg\xfcX\xfb`\xfa\x8a\xf9\x1e\xf9\x11\xf9;\xf9!\xf9o\xf8\x8e\xf7\xf6\xf6f\xf7\x07\xf9m\xfb\xea\xfd6\xff[\xff\xe7\xfe\xf5\xfeS\x00\x83\x03+\t\xe2\x11:\x1c\t%\xfd(\xb5\'\xf1#0!\x82!\xdf$K*\xe3/\xb93\xdc3m/|&\xe7\x1a\xec\x0fb\x08\xd5\x04\xa0\x035\x02.\xff\x08\xfa\x96\xf2Q\xe9o\xdf\x1c\xd70\xd2\'\xd1\xbe\xd2f\xd5\n\xd8H\xda\x0e\xdcd\xdd:\xde.\xdf\x93\xe1(\xe6\n\xed\xd4\xf4V\xfc\xb6\x02\xfc\x07\xdd\x0b9\x0e\xf0\x0e\xb1\x0eI\x0e[\x0e\xfe\x0e\xd1\x0f\xaf\x10\x00\x11\x18\x10\xc5\x0c\xda\x067\xff\xce\xf7b\xf2p\xef\xe7\xee\xa8\xef\x80\xf0\x07\xf0\xbc\xed7\xea\xe2\xe6a\xe5\x92\xe68\xea\x00\xef\xce\xf3\x03\xf8\xab\xfb\xc6\xfe~\x01i\x04\x89\x07Z\x0b\x19\x0f\x9e\x12x\x15\x94\x17\x8d\x19\xea\x1a\x8e\x1b\xe7\x1at\x19\xaf\x17\x00\x16]\x14\x87\x12p\x10\xe7\r\xfc\n\xb4\x076\x04v\x00\xc4\xfcF\xf9\xf0\xf6z\xf5w\xf4J\xf3\xdf\xf1\xce\xf0\xf4\xefj\xef\'\xef\xa8\xef\r\xf1\xd7\xf2\\\xf4U\xf5=\xf6O\xf7\xb8\xf8\x18\xfa \xfb\xa2\xfb\xee\xfbl\xfc\x08\xfd\xb0\xfd\xdb\xfd\xc1\xfd[\xfd\xd5\xfc\xf4\xfb\xd8\xfa\x00\xfa\xcb\xf9/\xfaa\xfa\x0c\xfa\x02\xf9\xdf\xf7\xf5\xf6\xb3\xf6\xe2\xf6_\xf7.\xf8\xf0\xf8m\xf9U\xf9\xc9\xf8~\xf8\xef\xf8c\xfa\xa9\xfcp\xff\x81\x02\xfe\x04\x80\x06E\x07T\x08\xa3\n\xe8\x0e\xec\x15\xf8 E.\x9e8\x88:Z4\x0f,\x91(5,=4\x87<\xe9@v?j7u*z\x1br\x0ey\x06\xef\x03\x94\x03\xd2\x00s\xf9\t\xef\xe5\xe4%\xdcE\xd4\xc1\xcc\xc5\xc6\xa8\xc3I\xc4\xe9\xc7I\xcd\xac\xd2.\xd6\x82\xd7\x9f\xd7\n\xd85\xda\x07\xe0\x92\xea\x99\xf8L\x05\xf8\x0c|\x0f\xb0\x0fK\x0f\x9e\x0e\xd6\r}\x0e\x8c\x11\x9b\x15G\x18\xdc\x17D\x14\xaf\r\xe0\x04J\xfb \xf3\xec\xed\\\xec<\xeeN\xf1\x84\xf2\xdd\xef$\xea\x9a\xe4\xcb\xe1\\\xe2b\xe5\x8d\xeaZ\xf1\x9b\xf8\xe3\xfe\xfb\x02n\x05:\x07\xcb\t\xa2\x0cD\x10\x9e\x14\x11\x1a\xc1\x1f\xfa#\x18%\x8a"\x90\x1e\xee\x1a\x98\x18k\x16\xb7\x14\x80\x14\xea\x14\x8c\x13\x1f\x0e\xbd\x05j\xfd\xa3\xf7\xee\xf4Y\xf4\xd5\xf43\xf5W\xf4E\xf2,\xefg\xeb\x19\xe8K\xe7\xb0\xe9\xde\xed\xe8\xf1 \xf5\x95\xf7\x88\xf8\xa0\xf7\x9b\xf5N\xf4\xe8\xf4)\xf7#\xfa\xfc\xfc\xe9\xfen\xff\x9a\xfe\x8e\xfc\x92\xf9\xd3\xf6y\xf5A\xf6z\xf8\xc1\xfa\xa2\xfb\xdb\xfa\x97\xf9K\xf8\x84\xf7\xe6\xf6\xdc\xf6\x0c\xf8\xaf\xf9\x99\xfb\xe1\xfc\'\xfd\x81\xfdc\xfe\xe5\xff-\x017\x01\xcf\x00W\x01\xf3\x02\x1e\x05\xbd\x06\x7f\x07O\x08g\t\x1d\x0b6\r\xd3\x0f\xfd\x12\xef\x17\xe3\x1e\x16\'\x11/ 414\x84/\xb7)\x1e(\x1e-\x125\xad9\xfc6\xef.\t%\xd0\x1ab\x10?\x07\x8b\x01\xa7\xfe\xba\xfb\xd3\xf5S\xed\xb1\xe3\x96\xda\xd4\xd2\x04\xcd8\xc9\x0e\xc7\xe3\xc6\xc5\xc9\xb4\xce\x9b\xd2I\xd3M\xd2\xe7\xd2\x0e\xd7u\xde\x11\xe8\x8a\xf29\xfc\xf5\x03\xcd\x07j\x086\x08\xba\nO\x105\x16&\x193\x19#\x18\x92\x16\x8d\x13\xe3\x0e\x90\t\xb3\x04\xc0\x00|\xfd\x88\xfa\xad\xf7\xbe\xf4\x03\xf2\xd0\xee\xba\xea\xec\xe6\xaa\xe5\x0f\xe8\x01\xec\xfa\xeeV\xf0D\xf2\xfc\xf5W\xfaN\xfe\xa1\x01\xee\x057\nV\x0ex\x11\xe6\x14\x11\x18\x02\x1b<\x1c\xc0\x1b\x8d\x1a\xee\x18\x80\x18n\x17\x9b\x152\x12\n\x0f\x1b\r\x92\x0bd\x08\xba\x02\x8f\xfc\x14\xf8\t\xf6^\xf5\xe0\xf4\xfb\xf3\xa3\xf2\xb6\xf0\xd1\xee\xb5\xec,\xeb\x96\xea\n\xecr\xefM\xf2\xf7\xf3\xbe\xf46\xf5\xff\xf4\xac\xf3\xc7\xf2/\xf4\x84\xf7\xbd\xfa\xbc\xfc4\xfd\xbc\xfc\x85\xfb\xcf\xf9\x85\xf8P\xf8\x9c\xf9i\xfcP\xffu\x006\xff\xea\xfc\x1e\xfba\xfa\xd4\xf9\xde\xf9\xa2\xfb\x1f\xfey\xff#\x00\x8f\x00T\x00\x10\xfd~\xf8\xe3\xf8\xcf\xfe\xc9\x05B\tW\x07\xfc\x03V\x01P\x01}\x03\xb6\x05=\x08J\n\x04\r\xd1\x0f;\x17S$j/a.\x99!V\x1a<"p0\xb47\xab7N7S6\xe4/\x80%\x86\x1d\xcb\x183\x13z\r\x14\n\xde\x08\xcf\x04\x9d\xfb\xeb\xee^\xe0E\xd5\x90\xd0\x1c\xd2Y\xd5\xde\xd4\xf7\xd1\x14\xcf\xa0\xcc\xdb\xc9\x07\xc8\x8e\xca\xd8\xd1m\xdb\xa0\xe4\x8a\xec\xb9\xf3\xd1\xf7K\xf8\x9b\xf8\'\xfd\x9f\x05\xfd\rr\x13\xa7\x17\\\x1a\t\x1bi\x18\xf2\x12\x86\r\xb0\n\xa8\x0b/\r\xbb\x0bY\x07\xbc\x01\x88\xfb\xb8\xf4r\xee\xf5\xea\x9e\xea\x04\xeb\xf7\xeb-\xedk\xee\xdd\xeda\xec\x1e\xec\x0f\xeeL\xf2\xf7\xf8x\x00\x11\x07\xcf\t\xc3\n\xc7\x0ba\x0e,\x110\x13\x14\x15\xfb\x18\xdc\x1di \xe9\x1e\x1c\x19S\x131\x0f\x11\x0f\xbd\x10\xd7\x10W\r8\t7\x05d\x00\xc4\xfa\xfe\xf5\xda\xf3\x8a\xf2\x0e\xf2\n\xf3Q\xf3r\xf1\x8c\xed\x03\xea\xbb\xe8\x1e\xea\n\xed\xa8\xf0!\xf3\xbb\xf3\xad\xf2O\xf2\x96\xf2\xec\xf2\x98\xf3\xf1\xf5!\xf9\xaf\xfb\x16\xfd\xa3\xfd\xaf\xfc\x95\xfbs\xfb\xfb\xfc\xd8\xff\xfa\x01\xa4\x02b\x01\xe7\xffQ\x00\xa7\x00\xc9\xff\x9d\xfeD\xfdS\xfe7\x00\xd9\xff?\xff\xb6\xfd&\xfd\xdd\xfd\x99\xfe\xdc\x00\x80\x02n\x02\xe9\x02\xc3\x03\xea\x03F\x04\xe7\x04\xe1\x062\t\x01\x0b\x83\r\x8f\x0fH\x15t\x1fJ);\'&\x1e\xc6\x1cU& 0\x0f0p.\x8e1\xc22\xe1,\x06%Z \x9f\x1b\xd7\x13\xe7\rk\x0e\x89\x0e\xe4\x06\xeb\xf9f\xeef\xe7\x0b\xe2A\xdc\xd5\xd8e\xd7\t\xd6\x82\xd3\xbb\xd1\xee\xd0d\xcf?\xcd\xf8\xcc\x14\xd1\xec\xd7\x1b\xdfS\xe5\x06\xea:\xed\x08\xf0\x1b\xf4"\xf9@\xfeO\x02\xb3\x06(\x0c\x8d\x10\xc7\x12\xf6\x12\xa1\x11K\x0f\xa9\x0c\xef\x0be\x0c\x0e\x0c\x06\n\x98\x07\xbb\x04\x08\x00w\xfa\xe9\xf6\xa1\xf4q\xf26\xf1m\xf2\xcc\xf5\xd2\xf5c\xf3.\xf1L\xf1)\xf2m\xf4\x95\xf8~\xfe\x0b\x03/\x060\t\xf0\t\xa6\x08\xe1\x07\x1a\n\xe3\x0e[\x15\xf7\x1a\xfd\x1b\x96\x17\xef\x11\xb7\x0f\x05\x10\x08\x0eM\x0c\xd0\x0be\x0c \x0cK\t\xdc\x03\xa2\xfc\x90\xf5\xc5\xf1\xfa\xf2\xec\xf4r\xf5\xe8\xf3\xdf\xef\xe3\xeb\xf7\xe8p\xe7\xdb\xe6\xd3\xe6\xbc\xe7a\xeb`\xef\xde\xf1\xc6\xf1\x86\xf0M\xef\xf1\xf0D\xf4\xb0\xf7Y\xfb\n\xfe\xa0\x00#\x03\\\x03z\x02\x94\x01P\x01`\x02\xf2\x03/\x08\x92\x0bh\t\xa8\x06\xf5\x03\xcc\x03$\x05\x16\x03\x01\x03\xaf\x05\xea\x06\x85\tW\n\x89\x07}\x01%\xfe\xd2\x01o\x04\xee\x05\x11\tN\n\xb9\x07H\x06v\x06\xd5\x06\xf8\x05\x8d\x05]\x07g\x0b\xee\x12b\x1a\xb1\x1b\x05\x17\\\x14?\x16\n\x19H\x1b\xe8\x1d\x8e!\xf7"l!\xf4\x1f\xbd\x1e\xe0\x19\xfb\x112\r\xa6\r\xf4\r\xec\n%\x07c\x03\x94\xfc\xff\xf3Z\xeeV\xeb{\xe7\xa2\xe2\xd3\xe0Y\xe1w\xe0Q\xdd8\xda\xf4\xd7O\xd6;\xd5\x8b\xd7\xb7\xdc{\xe1+\xe4\xef\xe5-\xe91\xec\xa8\xedc\xf0\xfb\xf4\xb9\xf8-\xfd]\x02\xac\x06\x0f\t\x97\t>\tf\t4\nW\x0b9\r\x02\x0eF\x0e\xb7\rM\x0c\x90\n\x9d\x07\xcc\x04L\x03\xc0\x024\x03$\x04]\x03\xfa\x01\x8e\xff\xe1\xfd\xc4\xfd\xdd\xfd\x8d\xfd\xdc\xfe,\x01J\x02B\x03\xdd\x041\x05J\x03\xfb\x01\x87\x03K\x06R\x07\x15\x08\xd1\x07V\x08m\x06\x84\x03\xdd\x02\x87\x008\xffT\x00\r\x002\xff\xe9\xfd\xcf\xfbJ\xf8e\xf6\xe0\xf60\xf5\xbc\xf2\xf5\xf4\x14\xf8\x12\xf6\xd9\xf0%\xf5\x06\xf8\x96\xefi\xf0\xe3\xf7 \xf9\xd0\xf5\xf1\xf6x\xfe\xed\xf9\xae\xf6z\xfc\xf1\xfb\xf1\xf7\x86\xfbc\x02\xae\xfd\x85\xfb+\x06\xdf\x01\x10\xf7V\x00r\x08\xcb\xfe`\xfa\x85\t\x97\x0f\xce\xfcy\x01\x17\x16\x84\x07\xa1\xfc\xe7\ny\x11\x02\t\xa3\x08\x1e\x12\x93\x0e?\x05\x8b\rQ\x11\x87\n(\x08\xe4\x0b[\x0c\xae\t)\x0c\x8c\x0c\x07\tZ\x03a\x05\x01\x07\x9c\x04\xeb\x03\xf9\x02\x8a\x04\xe4\x04\xdf\x00\xc9\x01e\x05\xf9\xfe\x03\xfe!\x06@\x06S\x01\xac\x05V\x08\xba\x05]\x03\xfd\x04\x8d\x07\xb2\x05\xc0\x01\xba\x04\xd8\x07F\x05\xcb\x02\xd8\x02i\x00\xcd\xfb$\xfb>\xfc\x15\xfb\x02\xf9\xe4\xf6\x04\xf5\x9e\xf4\xdc\xf3\xbd\xf1\xa0\xef\x8c\xefD\xee\xe4\xed\xd3\xf0c\xf2\x82\xef\x1b\xf0q\xf2&\xf4\xf6\xf2F\xf7B\xf9\xe0\xf5j\xfb\xae\xff\xe3\xfe\xa3\xfd\xbd\xff#\x03\xa9\x02<\x02\xab\x04F\x07\x1b\x04\xcb\x02\xb5\x07\xc2\x06\xa6\x03\xc2\x05\xa5\x04\xc9\x04\x96\x05\x13\x04\x10\x05\xcc\x04\xa3\x02r\x03\xb7\x04\xcf\x04\xd3\x01\x83\x040\x02\xb2\x01j\x04:\x01\x1b\xff#\x02\xb1\x01\xd3\xfb\x08\xfc\xc7\x00%\xfd\xec\xf4\xa3\xfb"\xfc\x08\xf7\xd4\xf7?\xfb\xb1\xf4|\xf2\x16\xf9\xd9\xf8Z\xf2\xf4\xf4V\x01\'\xf3\x15\xf4)\x00\x1a\xfdY\xef\xaf\xfb\x9c\xffd\x01\xc5\xf8\xcb\xf8\x95\x0c\xbc\xfea\xf6\xa7\t\xd0\x05%\xfaC\x047\x06C\x04\x06\xff5\t\xe7\x06\x07\xf8\xec\x07}\x0c\xa3\xfa\xbf\xf8"\x08\x81\x08\x03\xf7\xeb\xfb\xcb\x0e\xd2\xff\xf3\xf8\xb8\x05\x88\x03U\xfd\xad\x019\t~\x00\xc4\xff\x98\t\xbb\x04*\xfe\xa1\x0bk\x0c\x97\xfdI\x08M\n\xb9\x04\xe7\x04\xb6\n\xc3\tV\x05\xc3\x08\xed\x05\xf3\x03\xe7\x0cY\x07\xdd\xfbk\x05D\x10\xf5\xfa\x1a\xfd\xee\x10E\x01C\xf7\xf7\x04\x88\x05\xf4\xf4G\x04b\x00\xc3\xf8\xea\x00b\xfb\xc9\xfb\x8d\xfcG\xff\x18\xf7j\xf3\xda\x03,\x03\xd3\xf3\xb8\xf8\xc7\x06\x1a\xfa\x8d\xfb\x08\xfe\xdc\x03\x80\x00\xdc\xfa\xbe\x08\xa8\x02:\x00\xd2\x05\x9d\x05\x1f\x00[\x03\xbd\x06\x91\x03\xf4\x03\xf8\x03P\x04\x18\x02f\x01\xc6\x01\xfd\x01\x8a\x01\xa3\xfd@\xfd\x11\x05#\xfdG\xfa\xd2\x01g\xfdg\xf7\xe8\xfd\xb7\x02\x94\xf7\xf8\xf5q\x02\xf7\xfd\xda\xf3\xa8\xfd:\x01\x86\xf7D\xf5\xc7\x03\xa8\xfd\xc8\xfbt\xfc@\x00 \xfe\x04\xfee\x060\xfa\xf7\xf8\xcc\x06\xdb\x00\\\xfa\x11\x00\x07\x03\xa3\xfb|\xfc\xc7\x04\xef\xf9\x17\xf8\xe5\xfc\xd7\x05\xb0\xef\x81\xfa\x1f\x08\xdd\xf8Q\xf1\xe7\xfd\x88\x01\x89\xf5Q\xf3\x00\x00\x08\x04\xd6\xed\xf1\x01\x8f\x00?\xf4O\x01\xcf\xfa\x89\xff\xfe\x01\xa0\xf8y\x06T\x01\x1c\xfbD\t[\x05\x85\xfa}\t\xf5\x07n\xfd\x0f\n\t\x04\xa1\x02\xa4\t\xff\x01c\x01V\x0bv\x04\x7f\x00\xf9\x08R\xff&\x04]\x07\xfa\xfc\n\x01P\n\xcb\xfc*\xff\xff\x05,\xfdb\xff\xe1\xffH\x06\x1e\xfbS\xf1\xbd\x07@\x10n\xeb\x1c\x01\xcf\x0b0\xf5\x9e\xf2(\x0c\xb1\x06\xb4\xef"\x01\xaf\t\xd3\xfeF\xf7`\x05Y\xffy\xff\x10\xfae\x05\x83\x04\x80\xfc\x97\xfd\x0f\x07I\x02\xfb\xf4\x1e\xfd\xd9\x0b\xa2\xf8\x98\xf6\xd7\x06p\x01\xd6\xfc\x11\xfdB\xff\xc1\xf3\xb6\x04\x98\xfa\xbd\xfb\x95\xffe\x04\xc0\xfe\xc4\xf6\x13\xff9\x06\x18\xff\xc4\xf4e\x0b\xee\x069\xf7g\x01R\x11\xd4\xfd \xfd\x89\t\xc2\nE\xf9D\x06\x07\x0fb\xff\x94\xfd\x9e\x08l\x0b\xc7\xfa\xd5\x03a\x0c\xaa\xf8\x8b\xfb\x9b\x0b%\x02v\xfc,\x04\xf6\x00\'\xff\xfd\x01\xc3\xfd\x94\x03\xfa\xfa\x14\xff,\x03\xe6\x01\xfb\xfc*\xfe+\x01[\xfa^\xff\xbe\xfdQ\x00\xf8\xfd&\xfd\x1e\xfb\x1c\x06\xbf\xfe\x00\xf2\x8e\x0b\xd0\xf8\x96\xf8\xe9\x02\xcc\xfd\xe2\xfeH\xf8\xf8\x04\xc0\xf9\xc0\xfd\x1b\xffh\xfdm\xfc\x0c\x01]\xf5\x90\x00y\x03\xbb\xf5C\x08{\xf6\xe4\xfc\xa2\x00\xca\x00\x8f\xf9\x95\x02\xb6\xf6\xa2\x04&\x00\x85\xf9\xde\x02\'\xffn\x00\x13\xff\xaf\x02\xf8\xf5~\x0b+\xfa6\x02f\x05f\xfd\xe5\xfe6\x07\xff\x02B\xfc\x1e\x00\xc6\x07\xf4\x01\xa9\xfbp\x0bk\xfb\xce\x03\x0b\x05\x96\x01\xb9\xfa8\x05Z\x00\xf7\x02\xcf\x00;\xfc\xf6\x00\xec\x02\xed\xff\xbb\xfe\x85\xfc\xa5\x02\x11\x02\xbd\xf4\xac\x08\x9f\x06\x00\xf1\x17\x01n\x10u\xec\x88\x05x\r\x96\xf3\xdb\xffJ\x0eV\xfa\xa8\xfc\x0c\x07\xe4\xfd\x1b\x06l\xfb\xff\x05\x95\x01[\xfd\xfc\xfeJ\x055\x02\x17\xf6_\x07\xb9\xf7\x15\x04\xa6\x019\xf4\x0e\x06\xb7\xfc\xb7\xf9\xd5\xffV\xfd&\xfbz\x02\xbf\xee\x82\x0f\\\x02x\xea\x17\x08E\x00\xff\xfdo\xf5\x9d\x0f\xc5\xff\xcc\xf9\x8c\xff~\x00\x03\x073\xf49\r\x19\x01\xb7\xfd\xa8\x01!\x02\xf4\x01\xe1\xffh\xfeo\xfcX\n\xa5\xfdi\x03r\x04\x0e\xfd\xed\xffb\x06\xdb\xf9\x02\x00\xba\rL\xf4\xc0\x02I\x08\x92\x038\xf8\xc8\x03T\x03\xd6\xf8\x9d\x08p\x05\x1f\xff\xd8\xf5\x96\x14\xae\xfdH\xf0/\x10\xcf\x07\xfa\xed\xa7\xfd\xff\x15\xa7\xf6\x9f\xf9\xcb\x08\x80\x02\x9f\xf7\t\xf2|\x0c\xca\x00\xd5\xf1I\x00\xe7\x06\xae\xf9s\xef\xe9\x0b@\xf7\x8f\xf2I\x06\xe6\xf4\xe4\xfc&\x05\xaf\xf3`\x02]\xf9\xb7\xfc\xc1\x003\xf9\x1f\x03O\xf9\xeb\x04\xa7\xfa\x1c\x07\x1b\xfb\xd0\xfe5\x05\xbc\xfe\xda\x02\xda\xfdE\x05\x1a\xffH\x08\xfb\xfd\x1e\x05\xbb\x08\x0e\xf4\xa5\x04E\x08(\x02\xbc\xfd\x89\xff\xf9\x0fg\xf3\t\xfe\x90\x12\xa9\xfb\xd6\xef\x18\x06\xd9\x11\x95\xf0B\xfb:\x14\x9a\xfe\xda\xf1\xff\xfc\xb5\x0e\xde\xfd\xe0\xf5]\x0cK\xfd\xc6\xf2\xaa\x14r\x01h\xe9X\x04t\x0b\xaa\xf8\x9f\xf5\xb3\x0b5\x07\x86\xf2\xe0\xfa\x0c\x0f\xce\xf2H\x01\x9d\x00i\xfb\x1c\x05\x10\x00\x7f\xfcs\xfb\x93\x05\x0c\xfa\x90\x00\x1f\xf8O\x05\x17\xff\xb1\xff\xbb\xf2j\x05\xec\x04\'\xec\x99\x10x\xff\xf8\xec\xc3\x06v\x0b\xcc\xe5\x82\x04\xe7\x19`\xe6\x8f\xfa\xe6\x1a\x8a\xf2\x1b\xf7\x88\x05G\x08{\xfbd\xf7\xc7\x16\xc7\xfa\xe0\xf4\xfb\x0c\xd2\x04\xdc\xf9[\x07\xe6\xff"\xfa$\x0f\'\x01P\xfa9\x08\x9e\x04\xe8\xff\x83\xffq\xff\xac\x06R\xff\x80\x01W\x03\x89\x02}\x00\xb0\xfcX\x07x\x00\xed\xf7p\n\x08\xfa\xd0\x00%\x08\xb4\xf6\\\x05o\x02[\xfd&\xf9\x8b\x08\xee\xf9\x89\xfcq\tN\xf8\x80\xf9#\n\x04\xff\x9c\xf2\x9e\r\xcf\xf5\xc1\x005\x05\xe1\xf7\xda\xf9\x91\t\x1b\xfb(\xf4\x1d\x13h\xed\xe2\xfb\xe1\r3\xf5}\xf8\x06\xfe\xf4\x07\x88\xf4\x87\xfa\x02\x05,\xffu\xfa\xd2\xf7\xf4\x04!\x00\x17\xfcQ\xf6\xb9\x0b\xf6\xfc\xb3\xf5\xbc\x06D\x04\xc2\xfbj\xff\\\x06\x8f\xfa\x9c\x01X\x03}\x02K\x00w\x10r\xf5\xeb\xffK\r`\xfbc\xfd\x82\x0c\xb3\x02\x88\xfb*\x00-\ns\xfc\x86\xf9\x01\t*\xfal\x018\xf5\x82\x11#\xfc\xe2\xef\x9b\n^\x06\xae\xf2\x88\x02@\x0c\x1d\xfa\xae\xf6\xb4\x13\x99\x01\x84\xeer\r\x00\x0c\xf1\xf7\x8e\xf1\x0e\x18\x97\xfc\x89\xef#\x0c\xde\x08\xa1\xefk\xff^\x0c\xde\xf2~\xfa\xfd\x08\x84\xf9\x81\xf3\x89\x06\xd0\x06\xf3\xec\x17\xfci\x0e\xe6\xf2\x99\xf3\x8b\x05_\x06\xb1\xf6\x85\xf3\xf5\x14\xa8\xfeH\xe2I\x12\xa8\x0f\xda\xe80\x01\xbd\x14\x8b\xf5\xd6\xf8#\r\x93\x046\xf3\x0e\x06~\x0b\x0b\xfd\x17\xfe\x08\x08\xfc\x06)\xf5p\x07\x0b\x08\xee\xfc\x8b\xf9\xd4\tf\x03\x0e\xfb\xc7\x02\x8c\x02F\x000\xfcQ\x00\x05\x01\x10\xfec\xfb\x03\x08$\x00\x84\xf9\x87\td\xff\xa6\xeeD\x03\xe4\x07L\xfd\x83\xfe\xb7\x02\xff\xfb]\xfd}\x05\x91\xf9\x97\xfer\xff\xad\xfa\x0e\x00\x14\x02d\x01_\xfcJ\xfb\xa8\xfc\x85\xfd\xd6\xfe\xb7\x02\x9a\xf6\x1a\xfd\xe8\x03\xd3\xfb\xb0\xfb\x9b\xfc6\x02\x94\xf5x\xfdn\x07\x81\xf9\x97\xff\x97\x03\xf2\xf6\xa1\xf6\xfa\x10\xe3\x01y\xf1q\x04\xd3\x04\xc7\xfe\xe9\xfb\xb8\x0c\xc5\xffi\xf7E\x03P\nD\tG\xf4\x96\x08\xe5\x08\xb0\xf6(\x07\x06\x0b\xad\x02W\x03\xec\x01\xa4\x012\x052\x02!\x05#\x01\n\x00\xcd\x03r\x02\xb8\x02E\xff\x19\xffL\xfe\xf1\xfd\xeb\x01A\xff\xec\xffz\xf8\n\xfal\x06\xa0\xf7\x90\xfd\xa2\xfe\xcd\xf9\xfb\xfe\x86\x001\xf8\xfa\xfb\xc2\x08\xac\xf3i\xf6\xa7\x07A\xff\xae\xf7\x9f\x00\x85\x01\xf7\xfa\xc9\xfb\xda\x02\xc6\xfb\xa3\xf8m\x03\xce\xfe\x87\xff\xb7\x00n\xff[\xfcd\xfe\x10\xff\xd1\xfdh\xfe \xfc7\xff\x90\x03\xae\x013\xfe\x1e\x01o\x01\xf4\xfdn\x03Y\x07\x17\x03\xe3\x01K\t;\x0b\xf8\x04\x80\nM\x0e\x8a\x04t\x05\xcd\x0f\xe8\x0c\xdf\x08<\x0cc\x08e\x08v\n\x05\n\xaa\x05\x8f\x01\x07\x05V\x02G\xfe\x8f\x02\xad\x01\x9e\xfa\x9b\xfa\xc0\xfa\xcf\xf8\xd9\xfa\xde\xf8I\xf5\x0e\xf8\x05\xf9]\xf7\x13\xfa\xa1\xfa\xaf\xf8e\xf8\x90\xfa\xee\xfd\x1e\xfe\x0f\xff\t\xfe\xa5\xfd\xc9\x00t\x01I\xff&\x00e\x03\xca\xfc\x95\xfa\x13\x02\xd8\x02\xcb\xfb\xa6\xfa\xa9\xfb\x0e\xf9\x0c\xf9\x04\xfat\xf9\xe3\xf4/\xf6\xb0\xf5\xf3\xf5\x80\xf8\xbe\xf5\x14\xf8\xca\xf2\x07\xf3\x0b\xfa\x84\xfc\xb1\xf6V\xf8\xe1\xfc[\xf7\xfb\xfb;\x00\x03\xff\xad\xf9r\xfb)\xffN\xfc\xbb\x01T\x01\xd9\xf9\x9f\xfd\xb9\x00d\xfd\x11\xfd\x0e\x00\xcc\xff\x8a\xf9,\xfc\xb1\x03\x87\x05\n\x02\x9c\xfdx\xffj\x03\x16\x04E\x05v\x05%\x07\x8d\t\'\x0bu\r\xa2\x0e\x90\rB\x0fM\x11=\x13\xcd\x19\xef\x1dH \xa7"k#\xd2"\xcc\x1e$\x1e\xd8\x1f\xef\x1f\xe6\x1b`\x18|\x18>\x17}\x11\xfc\n\x92\x03\'\xfdV\xf7u\xf2\x93\xf1\xbc\xef~\xeb\xb2\xe7\xd2\xe6\xc0\xe5[\xe3E\xe2#\xe0\xca\xde\x93\xdf\x0f\xe3\x14\xe8\xb7\xec\x14\xf0\x1f\xf2\x99\xf3=\xf7\x9c\xfb\xac\xfd\xc5\xfeb\x01\xbb\x02u\x05j\n\xba\x0b\n\x0c\xa5\x0b+\x07\xec\x035\x03=\x02*\xff\xb7\xfa\x08\xf8E\xf7\xb9\xf6\xcf\xf5\x8f\xf5&\xf1\xbc\xec\xa2\xebV\xecq\xee\x9f\xf0\xfd\xf1\xfa\xf2\xdb\xf6\xa1\xfc\x07\x01\x04\x03\x08\x05G\x04q\x06\x05\n\xd5\rJ\x11\x9f\x12R\x12\xc3\x11\xa1\x12\xef\x12\x1c\x10s\x0b\xb0\x07\r\x05u\x03\xb4\x02\x84\x01\x93\xfe\xbf\xfa"\xf7^\xf5\xfc\xf3\x10\xf22\xefG\xed\xa8\xed(\xf0\xd8\xf2\xf4\xf2\x19\xf3\xd4\xf3\xbd\xf3\x10\xf4\xa0\xf5\xca\xf6\xd1\xf7n\xf9\x18\xfbt\xfc\xce\xfdO\xfd\xf3\xfb\xe9\xfa#\xfa\xb0\xfa\x9d\xf9\x9d\xf8\xcc\xf8\xf4\xf9`\xfc\'\xfc\x9a\xfb\xa9\xfb\x87\xfc\x03\xff~\xffs\xfd\xce\x03U\x16O%?*\xc9\'\x0b*\xb52r5\xff2\x872F5T5e2\x9d2\xf14\xc10\x15#\x99\x13\xa4\t\x03\x04\x94\xfco\xf3\xc7\xeb5\xe7\xb2\xe3\xf5\xdf\xfa\xdc\xd7\xd9\xf3\xd5\x89\xd0\x89\xcb#\xcd\x84\xd5\xdf\xdc\x1b\xe1\x94\xe6\x86\xee#\xf6\x80\xfc|\x01D\x06+\x08\xe6\t\xa9\x0e\xb4\x13b\x19\xd5\x1d[\x1cL\x19\xb7\x16\x9f\x13\xe2\x0eR\t)\x03\xb1\xfbl\xf4\xac\xef\x8d\xef\x86\xed^\xe9\x06\xe4;\xde\xc0\xdaU\xd9P\xda\xb9\xdc\x17\xdes\xe1\x1d\xe8\xa2\xee\xa9\xf5\xe0\xfa\xab\xfe\x93\x01y\x05\xfe\t\xbf\x0f\x85\x15\x06\x1a\xc1\x1d\xcb\x1e\xd2\x1d\x16\x1e=\x1d\xfb\x19\xcd\x15v\x11[\x0e\x0c\x0b\xe8\x07\xe4\x04T\x01\xd7\xfc\xa4\xf8?\xf5\xcf\xf2%\xf2~\xf1\x99\xf0\xcc\xef{\xf0\xa0\xf2`\xf4A\xf5m\xf6R\xf7\xf8\xf8H\xfa\xc6\xfa\xe9\xfb4\xfc\xd6\xfb\x12\xfa\xa0\xf8A\xf8\xa8\xf8N\xf6\xc6\xf3\x8b\xf2l\xf1\xd4\xf1q\xf1\xa6\xf0\xd2\xf0\xd3\xf0f\xf2\xae\xf3\xcf\xf3\x9c\xf5\x8c\xf9\x9c\xfcX\x00\n\x05\xcb\x07\x8e\t[\x0b3\x0f\xad\x16\xff n+\xfa2_6\xd86\xc87\xd48\xf96\\2^,\x98(k\'\xa8$\xd1\x1f\xaf\x19\x9d\x11Z\x076\xfc\xa7\xf2\x90\xeb\xc4\xe5\x1d\xdf\xf5\xd8\x97\xd6\x8a\xd7D\xda\x89\xdc\xf8\xdc\x11\xdc\xa2\xdc&\xdf\x86\xe2X\xe7k\xed\xf6\xf3\xe9\xf8N\xfe\x99\x05f\ry\x12T\x14\xcc\x13w\x13\xbb\x13\xf8\x13E\x13\x88\x11\x03\x0f\xd7\x0bg\x08y\x04\x1f\x01\xf0\xfc\xf5\xf6\x9c\xefM\xe9\x85\xe5\xab\xe3\xf8\xe1o\xe0\xd3\xdf6\xe0\x83\xe1\x94\xe3\xb9\xe5~\xe8-\xeb\x9a\xedO\xf18\xf6x\xfcr\x02V\x07"\x0b\xf2\x0e\x18\x12m\x14\xc7\x15\x85\x16\xb2\x16+\x16\xa4\x14\xb0\x13\x12\x13\xcc\x11i\x0f\xaf\x0b\xdd\x07i\x05\xa6\x02T\xff\x05\xfd\xee\xfa\x18\xf9\xd7\xf7\xfd\xf6\x89\xf6\xed\xf6\x86\xf7_\xf7p\xf6\xd7\xf5\xf7\xf6G\xf8\x7f\xf8\xd1\xf8\x0e\xfa\'\xfb\x9a\xfbo\xfc\xa8\xfc\xcb\xfc\xce\xfd\x0b\xfe\x8a\xfdh\xfd:\xfe\xf3\xfe\x88\xffp\x00\xd4\x01\xd0\x02\x8a\x03d\x04\xdc\x04\xde\x05\x8b\x06\xdc\x06t\x07\xe2\x07\x97\x08s\tN\tz\x08z\x083\x08\x9c\x06\xf7\x04\xa1\x044\x05.\x05,\x04\r\x02\x8b\x00\x80\x00\xac\x00\xd5\x00o\x00\xf6\xff\x03\x00D\xff\x99\xfeE\xffr\x00\xc8\x01\xd8\x02\x19\x03.\x031\x04\x8b\x05k\x06i\x07\xf1\x08E\n\xa3\n\x8e\n\xd4\ns\x0b\xb3\x0b\xfa\n\xad\t>\x08\xdf\x06j\x05\xad\x03R\x02(\x01\xa4\xff\xc2\xfd\xb7\xfbT\xfa\x94\xf9g\xf8\xa9\xf6$\xf5c\xf4+\xf4\xda\xf3H\xf3/\xf3@\xf3D\xf3\xbf\xf3\x86\xf4\xbd\xf5\x1f\xf7\x9f\xf7z\xf8\xac\xf9{\xfa\x89\xfb\'\xfc\xbf\xfc\x85\xfd\xd2\xfdR\xfeq\xfey\xfe\xe5\xfe\xdb\xfe\xb3\xfe\x7f\xfe\x1c\xfe\xbf\xfd\xc9\xfd\xd7\xfd\xd7\xfd\x9a\xfd\xc1\xfdp\xfe\x8a\xfe\xf6\xfe\xcf\xff^\x00\x07\x01\x85\x01\xdf\x01\x99\x02i\x03\xf5\x03f\x04\x8a\x04\xa1\x04\xf5\x04]\x05]\x05\x1c\x05\xcb\x04_\x04\xc5\x03\x17\x03\x15\x03\xc9\x02\x1d\x02\xaf\x01\xde\x00j\x006\x00\xc9\xffW\xff;\xff=\xff\xcf\xfe\x95\xfe\xc9\xfe\xd6\xfe\x9f\xfe\xa2\xfe\xf0\xfe\xa3\xfe\xbb\xfe\x08\xff\xbd\xfe\xb4\xfe\xd5\xfe\x97\xfe4\xfe\t\xfe\xcf\xfd:\xfdy\xfc\x0e\xfc\xbf\xfb\x04\xfbl\xfa5\xfa\xca\xf9R\xf92\xf9*\xf9\x19\xf9\\\xf9\x02\xfa\xbc\xfa3\xfb\xfd\xfbA\xfdU\xfee\xff|\x00\xc3\x01\xda\x02?\x04h\x05e\x064\x07h\x08\x01\t\x18\t\x88\t\xb2\t\xc5\t\xb9\t\x9d\t"\t\xc7\x08W\x08\xda\x07\x1c\x07\x8f\x06\x11\x06\x82\x05\xa1\x04\xdc\x03s\x03[\x03\r\x03\x86\x02\x85\x02\x93\x02l\x02:\x02u\x02z\x02w\x02C\x02(\x02\xed\x01\xd7\x01\xac\x01<\x01\xcf\x00N\x00\xd4\xff?\xff\x86\xfe\xac\xfd\x1d\xfd\xb5\xfc\x13\xfc0\xfb3\xfa\xa3\xf9(\xf9v\xf8\xdb\xf7g\xf7\x0c\xf7\x01\xf7B\xf7a\xf7\xa4\xf7\x12\xf8u\xf8\xa6\xf8\x02\xf9\x0b\xfa\xb8\xfa\x0b\xfb\xdb\xfb\xf4\xfc\x01\xfe\xe4\xfe\xdc\xffL\x00\xa5\x00M\x01\xd3\x01\x02\x02v\x02\xb4\x02\xbf\x020\x031\x03\x0c\x035\x03\x1b\x03\xa5\x02\x9c\x02u\x02,\x02\xf8\x01\x00\x02\xdb\x01~\x01~\x01\x98\x01I\x01\x0f\x01/\x01#\x01\xde\x00\xb9\x00\xdf\x00\xe1\x00\xb5\x00\xa3\x00\xad\x00_\x00\xfd\xff\xcf\xffz\xff\x02\xff\xa1\xfe\x0e\xfel\xfd\xe5\xfc\xa0\xfc8\xfc\xbc\xfb,\xfb\xbe\xfa\xb0\xfa\xab\xfa\xa0\xfa\xd5\xfaW\xfb\xbd\xfb)\xfc\xf9\xfc\t\xfe\xa2\xfe0\xff\x04\x00E\x01\x0c\x02\xc1\x02\x8f\x03J\x04\xd5\x04b\x05\xef\x05\x01\x06\xee\x05\xf0\x05\xbd\x05I\x05\x1c\x05\xa8\x04\x15\x04\x97\x03;\x03}\x02\xe9\x01\xb8\x01-\x01\xb0\x00q\x00o\x00;\x00a\x00\x93\x00\x94\x00\xc2\x00_\x01\xc8\x01\xff\x01]\x02\xd4\x02$\x03t\x03\xcb\x03\xe0\x03\x0c\x04\x19\x04\x10\x04\xda\x03\x9e\x03M\x03\x97\x02\xfa\x01[\x01=\x00X\xff\x97\xfe\x88\xfd\x99\xfc\xc2\xfb\xe9\xfa0\xfa\xbf\xf9Y\xf9\xf6\xf8\xae\xf8\xcd\xf8\xbb\xf8\xfe\xf8\x85\xf9\x15\xfa\xf3\xfa\xa9\xfb\xa0\xfc\x99\xfd\x7f\xfe;\xff\x0f\x00\xdb\x00\x82\x01\xf7\x01r\x02\xf8\x02B\x03f\x03\x81\x03f\x03\x14\x03\x1a\x03\xe4\x029\x02\xb2\x01\x8c\x01&\x01\x98\x00\x0f\x00\xf4\xff\xb3\xff\n\xff\xe3\xfe\x01\xff\xef\xfe\xe5\xfe\xf1\xfe\xfa\xfe6\xffv\xff\xa5\xff\xd8\xff\x00\x00=\x00Z\x00\x80\x00\xca\x00\xe8\x00\xed\x00\xb5\x00\xa2\x00\x9e\x00\x7f\x004\x00\xe4\xff\x7f\xff\x1f\xff\xba\xfel\xfe]\xfe\xfc\xfd\xbc\xfd\x87\xfdN\xfd$\xfd2\xfd\'\xfd1\xfdu\xfd\xbc\xfd0\xfe\x90\xfe\xe7\xfeG\xff\xb0\xff/\x00\x80\x00\xe3\x00+\x01&\x01:\x01\x84\x01\xa4\x01w\x01m\x01n\x013\x01\xd9\x00\x97\x00W\x00\x07\x00\xdf\xff\x9c\xff\x8e\xff\x95\xffy\xff`\xff\x98\xff\xb4\xff\xba\xff\xf1\xff9\x00\x96\x00\x0f\x01k\x01\xc4\x015\x02\x96\x02\xc9\x02\t\x03+\x034\x039\x039\x03+\x03\xef\x02\xc5\x02{\x02\x1e\x02\xb5\x014\x01\xb1\x008\x00\xaf\xff\x08\xffX\xfe\xc8\xfd\x9a\xfd\x1d\xfd\x94\xfcB\xfc\x13\xfc\x06\xfc\xfc\xfb\x10\xfc?\xfcb\xfc\xa9\xfc\x06\xfd\\\xfd\xd1\xfdA\xfe\x8f\xfe\xec\xfeU\xff\xa2\xff\xf7\xff2\x00k\x00\xa3\x00\xd4\x00\xd3\x00\xc9\x00\xe1\x00\xdd\x00\xc8\x00\xa8\x00\xab\x00\x9b\x00a\x00S\x00[\x008\x00\x16\x00\x00\x00\xfa\xff\xfa\xff\x1b\x007\x00:\x00K\x00o\x00\x80\x00\xac\x00\xf2\x00\x06\x01\x13\x01?\x01|\x01\xa1\x01\xdb\x01\xd9\x01\xcb\x01\xdd\x01\xbe\x01\xbd\x01\xb8\x01\x95\x01e\x01]\x01"\x01\xd0\x00\xa9\x00\x8e\x00G\x00\xeb\xff\xb1\xfff\xff8\xff)\xff\x04\xff\xd6\xfe\xc8\xfe\xcb\xfe\xbc\xfe\xbc\xfe\xc3\xfe\xdc\xfe\xd1\xfe\xba\xfe\xcb\xfe\xe2\xfe\xf4\xfe\x06\xff\x07\xff\x0b\xff\x1c\xff\x03\xff\xfd\xfe%\xff\x14\xff\x0c\xff\x04\xff\r\xff7\xffa\xffj\xff\x80\xff\x9f\xff\xcf\xff\t\x00<\x00r\x00\xa7\x00\xba\x00\xeb\x00/\x01n\x01\xb0\x01\xde\x01\x11\x02L\x02z\x02\xa4\x02\xe0\x02\xf6\x02\xf8\x02\xea\x02\xd5\x02\xb4\x02\x97\x02\x85\x02;\x02\xeb\x01\x92\x01;\x01\xc7\x00W\x00\xf1\xff\x8d\xff7\xff\xea\xfe\xbb\xfe\xa0\xfe{\xfeZ\xfe8\xfe*\xfe1\xfe0\xfe>\xfeg\xfe\xa1\xfe\xd2\xfe\xf8\xfe \xff=\xffm\xff\x83\xffx\xfff\xffb\xff^\xffZ\xffK\xff1\xff\x0c\xff\xd7\xfe\x9e\xfen\xfe7\xfe\xfc\xfd\xd4\xfd\xbb\xfd\xad\xfd\xae\xfd\xbc\xfd\xe4\xfd\x13\xfeQ\xfe\x9c\xfe\xee\xfeI\xff\xb5\xff\x12\x00f\x00\xbf\x00-\x01\x92\x01\xe0\x01/\x02w\x02\xad\x02\xd9\x02\x04\x03\x18\x03\x19\x03\x18\x03\x07\x03\xed\x02\xca\x02\xa0\x02d\x02\x1b\x02\xcd\x01}\x01+\x01\xde\x00\x85\x001\x00\xe4\xff\x8f\xff>\xff\xf5\xfe\xb0\xfev\xfe;\xfe\x06\xfe\xd7\xfd\xba\xfd\xa7\xfd\x93\xfd\x89\xfd\x80\xfdt\xfdg\xfda\xfdd\xfdm\xfdz\xfd\x97\xfd\xc7\xfd\xe5\xfd\xfb\xfd*\xfeg\xfe\x93\xfe\xc1\xfe\xfc\xfe9\xffo\xff\xb9\xff\x03\x00G\x00\x87\x00\xcf\x00\r\x01E\x01\x8b\x01\xc0\x01\xe2\x01\x05\x02\x18\x02&\x02+\x02%\x02\x1b\x02\xfd\x01\xd9\x01\xbb\x01\xa8\x01\x96\x01u\x01H\x01\x12\x01\xec\x00\xc1\x00\x9c\x00|\x00`\x00C\x007\x006\x00<\x007\x00\x1a\x00\x0b\x00\x03\x00\xf8\xff\xed\xff\xe8\xff\xf3\xff\xef\xff\xed\xff\xe9\xff\xe2\xff\xde\xff\xd3\xff\xb3\xff\x89\xfff\xffD\xff\x1d\xff\xfc\xfe\xd7\xfe\xb2\xfe\x8b\xfeh\xfeP\xfe;\xfe\x1f\xfe\x03\xfe\xf4\xfd\xf2\xfd\xf0\xfd\xfb\xfd\t\xfe\x12\xfe9\xfeo\xfe\x9c\xfe\xd1\xfe\r\xffP\xff\x85\xff\xbc\xff\xfb\xffA\x00\x82\x00\xc0\x00\x08\x01E\x01\x81\x01\xb4\x01\xd7\x01\xf4\x01\x10\x025\x02J\x02S\x02V\x02Y\x02]\x02]\x02W\x02E\x02%\x02\x07\x02\xe5\x01\xc0\x01\x92\x01^\x01 \x01\xde\x00\xa6\x00w\x00>\x00\xfc\xff\xbd\xff\x7f\xffG\xff\x11\xff\xd4\xfe\x96\xfe[\xfe4\xfe\x19\xfe\x05\xfe\xf9\xfd\xf5\xfd\xf4\xfd\xf1\xfd\xfd\xfd\r\xfe\x16\xfe\'\xfe>\xfe^\xfe\x82\xfe\xb5\xfe\xe6\xfe\x14\xffE\xffj\xff\x87\xff\xa9\xff\xcd\xff\xf6\xff\x17\x00:\x00]\x00\x80\x00\xa3\x00\xbf\x00\xdb\x00\xe8\x00\xef\x00\xff\x00\x13\x01 \x01$\x01\'\x01!\x01&\x01\'\x01\x16\x01\x04\x01\xf2\x00\xdb\x00\xd5\x00\xc6\x00\xbd\x00\xad\x00\xa1\x00\x9e\x00\xa2\x00\xaa\x00\xa8\x00\xa5\x00\xa4\x00\xac\x00\xbc\x00\xca\x00\xc8\x00\xc9\x00\xc4\x00\xc3\x00\xc8\x00\xb5\x00\x9c\x00\x81\x00f\x00Z\x00J\x00.\x00\x03\x00\xe4\xff\xd0\xff\xb1\xff\x8d\xffd\xff9\xff\x11\xff\x05\xff\xf6\xfe\xe6\xfe\xc6\xfe\xa4\xfe\x8d\xfe\x91\xfe\x90\xfeh\xfeX\xfeQ\xfeM\xfe^\xfez\xfe\x8d\xfe\x9b\xfe\xbb\xfe\xd7\xfe\xff\xfe\'\xffT\xffy\xff\xa5\xff\xd1\xff\t\x007\x00]\x00\x8c\x00\xa7\x00\xbe\x00\xd5\x00\xeb\x00\xfd\x00\x02\x01\xfe\x00\x00\x01\x05\x01\x04\x01\xfb\x00\xed\x00\xe5\x00\xd4\x00\xbc\x00\xa8\x00\x93\x00~\x00k\x00U\x00?\x00%\x00"\x00\x15\x00\x05\x00\xfc\xff\xf2\xff\xee\xff\xe6\xff\xde\xff\xd8\xff\xcf\xff\xbe\xff\xb8\xff\xad\xff\xab\xff\xac\xff\xa7\xff\x9f\xff\x97\xff\x94\xff\x84\xffu\xffc\xff\\\xffS\xff@\xff2\xff$\xff&\xff#\xff\x1d\xff+\xff3\xff>\xffG\xffT\xffb\xffv\xff\x8c\xff\x9c\xff\xbb\xff\xdc\xff\xf4\xff\x07\x00\x1c\x00,\x000\x00F\x00c\x00a\x00k\x00{\x00\x82\x00\x8b\x00\x90\x00\xa2\x00\x9b\x00\x84\x00\x83\x00}\x00s\x00k\x00j\x00`\x00S\x00M\x00N\x00^\x00U\x00P\x00Q\x00`\x00v\x00z\x00y\x00{\x00\x87\x00\x98\x00\xa3\x00\x9b\x00\x91\x00\x89\x00q\x00_\x00T\x008\x00(\x00\n\x00\xf4\xff\xd8\xff\xc3\xff\xaa\xff\x8d\xff\xa3\xffg\xffj\xff\x7f\xffH\xffe\xffH\xff\n\xffk\xff\x0e\xffU\xff\x1d\xff\x19\xff)\xff\xf5\xfe{\xff\x0b\xff_\xff^\xff,\xffi\xffj\xffp\xff\xad\xff\x94\xff\xcb\xff\xdb\xff\xba\xff\xe1\xff\x1f\x00\x00\x00\xf4\xff\xf6\xff\xfb\xff0\x00)\x00~\x007\x00\xc2\x00l\x00t\x00\xcb\x00\xa3\x00 \x01\xda\x00\xf2\x00\xf7\x00\x12\x01\xe9\x00\x9d\x01\x9c\x00\x1a\x02~\xfe\x1a\xffM\x0e\x90\tk\x02o\xfe\x02\xfd\xc5\xff\xff\xfc\xf2\xfc\x93\xfcQ\xfd\x93\xfc\xda\x00\x82\x03N\x00 \xfd\x00\xfe\x07\x03\xf8\x02x\xfc[\xfc\xae\x01\xc7\n:\x08;\xfe\xa1\xfa\x88\xf8\xfb\xfa\xb6\xff\xef\xfda\xfd#\xfe\x88\x03\x11\nk\xfc\xbc\x02\xb8\x07\xad\x03\n\x06z\x05b\x04[\x03\xb0\x07\xce\xfe\xd9\xf8x\xfc+\x01$\x03C\x03M\xfd\x18\xf5\xb1\xef-\xec\xa1\xeb\x8e\xf2\xc1\xf9\xf0\xfb\n\xff\xf6\xfaH\xf7B\xfb \xff\xc9\x00\xf2\x02\xba\x05\xc2\x07b\x01(\x03\xfc\x04\xd3\x08_\x0e`\x08\xc0\x05\xd5\x00-\x04\x99\x07\x88\x07\xee\x03!\x02\xf0\xffn\xfe\xe2\xfd\xa6\xf9D\xfb|\xfd=\xffX\xfe2\xfc\xa0\xfc\xec\xfbQ\xf8\\\xfba\xfb4\xfc\x14\xff\x90\xfd\x15\x00\x06\x01\x1e\x01\x11\x06\x11\x08!\x08"\x04\xf3\x01}\x02\xc5\xff\xc0\x01\x0c\x02\x10\x05\x1a\x02\xb6\x04\x91\x02\xb3\xffi\x01\x90\xfd \xfb\xbb\xf8\x8e\xfd\xdd\xfcw\xff_\x01\xcc\xfd{\xfa\x07\xf7\x04\xfa]\xfe\x07\x01\xa9\x01\xa5\x02\xd5\x00\x98\x01\xfd\x00\xf7\x00k\x01\xca\x01\x98\x01.\x07\xf0\x12Q\x0f\xcb\x08\xce\xffA\xfep\xfb\xdb\xf6\xa5\xf9\xe1\xf7F\xf8\xec\xfd\x8b\xff\x80\xff\t\x01\xc6\xfe\xee\xff8\xfcu\xfa\x01\xfc5\xfa\xab\xfcE\x003\x02]\x02Q\x02\x90\x03\xba\x03\\\x02\xf5\x02\xab\x02\x08\x01~\xffQ\xfe\x86\xfc{\xfa\x1d\xfe\xc4\xfd\xe8\x00\xe1\x01\xbc\x01`\x011\xfd=\xfa@\xf6D\xf94\xfe\xd1\x00|\x02\xc7\xfe\xd8\xfd\x86\xff\xf5\xff\xc9\x01\xcf\x05Z\x08\xab\x07\xba\x048\x01 \xfdJ\xfb\x03\xfb\xe8\xfa\xe0\x01\xde\x06\x11\x06\xb7\x05t\x02\x14\xfd\xa7\xfd\xf9\xf9Y\xfaW\xff\x7f\xfe\xb6\xff\xd2\x007\x00\x8a\xfeE\x00\xc2\x01p\x04\xe9\x04\xe0\x05\xf1\x05&\x01\x13\xfe)\xfb\xce\xfc@\x03_\x04\xe7\x04C\x043\x00\x97\xfc\x82\xfd\xf6\x00\xec\x01\x90\x00\x01\xff6\xfd\xf6\xfa\x1d\xfa\xf2\xf9#\xfd\xcd\x01\xca\x04\xa6\x06\xf2\x03\x95\xff\x9d\xfd:\xfa\xab\xf8\xe7\xfa\xec\xfd\xb5\x01\xf5\x034\x03L\x01\xac\xfc\xd2\xfc=\x00\x12\x01\x84\x01u\x01"\x03\x0b\x02\xc8\xfeL\xff\xbd\xff\xe8\xff\xf5\x00\xe4\x00\x81\xff\x90\xfco\xfd9\xff\xf7\xfeQ\x04\xb2\x05\x11\x03\xf2\xfe/\xfcA\xffG\xff\xdc\x01\x97\x03\xe8\x00\x9e\x00\x1b\x01\xb5\x00e\x01\xca\xfe\xd8\xfe&\x00\'\x01\xde\x00y\xff\xe6\xfc\xe6\xfc\x8a\x00\n\x01\xcf\x02h\x03\x0c\x04-\x03\xfe\x00<\xff?\xfdb\xfa\n\xfa\xd2\xfc?\x01\xc1\x04\xd9\x04\x9e\x04Y\x02\xd9\xfe\xde\xfb\xe0\xfa\xe7\xfa\xa3\xfe\x08\x03\xf7\x04D\x03#\xfe\xd5\xf8B\xfc\x1b\xff=\xff|\x03\x82\x04\x1d\x01\xd0\xfdN\xfeg\xfdN\xfc\xfc\xfb\xe8\xfcy\x00\xdc\x02V\x00\xa6\xff\xc4\xff\xb8\xfd\xb3\xfcr\xfb;\xfe$\x02\xd3\x05\xaa\x06^\x01\x08\xfe\xe7\xfd\xfe\xfdu\xffb\x00\xbc\x01\xf8\x03\xf6\x04\xed\x03[\x01y\xfeZ\xfe\xe1\xffw\xff>\x03\x03\x04(\x01\x08\xfe\xd5\xfb\x1e\xfd\xce\xff\x9d\x02X\x015\x01\x87\x00\xfc\xfe\xf0\xfd\'\xf8\x9e\xf5\xc8\xf9\xac\xfei\x03%\x05\x05\x03\x8e\x00\\\xfeG\xff[\x00\xec\xfe\xd3\xffI\x03 \x07\xcd\x04\xd9\x01\xae\xff\xec\xfb\xd8\xfb\xad\xfa\xa2\xfd\xdc\x03s\x07\xab\x05L\x00\xa9\xfcy\xf9\x99\xf9\xf9\xfc\x0c\xff\xfd\xff\xe9\x01\x82\x02\xac\x01\x85\xff+\xfc(\xfa<\xf9N\xfau\xfd\xb4\x00\xef\x03Z\x05\x11\x05j\x03\xdd\x00\xd5\xff\xd6\xff\xd5\x00\xc8\x01w\x01\xbf\x00\'\x01\xb8\x01y\x00\x07\xff@\xff\xcb\x01\xb8\x01B\x03n\x04*\x01\x85\xfeK\xfb\x83\xfbN\xfdq\xfd_\xfe\xb1\x00U\x04\x8b\x03\xbf\x01\x18\x00(\xfdH\xfd\x9f\xfdU\xff\xa9\x00&\x00\x97\xff\x9a\xff\x8f\x00\x1e\x00\x81\x00J\x01%\x01\xda\x00+\xff\xb8\xfd=\xfe\x9f\xfe\xdd\xfe\'\xff\xa7\xff-\x00\xf1\xff`\xfe\x80\xfdM\xfe^\xfe\xb0\xff5\x01\xda\x01\x9a\x02\x87\x01\x06\x01\x13\x01\xc7\xff\x83\x01\xfa\x02\x97\x03|\x04\xd6\x02\xd2\x00\xbf\xfe\xc0\xfb\xc6\xfb~\xfeD\xfe\x05\x00\xb1\x02\xfe\x01\xfc\xff\x07\xfe\xfa\xfc\xaa\xfd\xd7\xff\xf0\x01\xda\x02\x8d\x024\x02\xc2\x011\x01\xb1\x00\x19\x00L\xff\xf1\xff\xac\x00|\x00*\xff9\xfe\xa8\xfd\x80\xfc\xf5\xfd\x94\x00\x86\x03^\x047\x043\x03K\x01\xe6\x00\xf0\xff5\xff;\xff4\xfd\xdd\xfc\xb1\xfex\xfe\xd8\xfe\n\xfe\xdb\xfcg\xfd\x8f\xfdC\xfds\xfd\xc2\xfeL\x00\x9d\x01\xc4\x02p\x04\n\x03}\x01\x8d\x00\x01\xff\xc7\xfe!\xff\x97\xff\x1f\xfe(\xff\xe4\xff\x9a\xfd\xc6\xfc\x8f\xfc\xd9\xfd\xe4\xff\x82\x01w\x03+\x06\xd8\x07\x10\x06T\x03\xbb\x00[\xff(\xfe\x1e\xfd\xfd\xfc\x88\xfd%\x00\xba\x01\xe9\x01\x13\x01\xa8\xffj\xff"\xfe\x93\xfd\xc1\xfd;\xfe\x98\xffr\x00\xed\xff\\\xff\x86\xfe\x81\xfeF\xffq\xff\x17\x00\xfb\xffI\xff\xda\x00\xf1\x02\xb0\x02{\x02\xc3\x02\xaa\x02\x1c\x03U\x03\x10\x02k\x01k\xffh\x00\xab\x00\xcd\xff\xf9\xff:\xfe\xaa\xfe\x10\xffE\xffB\xff\x1e\xffu\xff>\x00\xe3\x00\xb5\x00\xd9\xff\xba\xff\xc3\xff\xb5\x00`\x02\xe6\x02 \x04\x8d\x04\xbd\x04>\x04\x99\x03\xc9\x02\x1b\x01\x96\xff]\xff\xfa\xff\xd3\x00\x1c\x01K\x00\x13\xff\x0e\xfeq\xfd\xed\xfcl\xfc\xa3\xfb\x07\xfcb\xfdN\xfd\x05\xfd\xd3\xfc\x88\xfcr\xfc|\xfc\x89\xfc\x8a\xfc\t\xfdy\xfdH\xfe\xfd\xfeh\xfe\x0c\xfe\x8a\xfdM\xfd\xd8\xfc\\\xfb\x85\xfa\x0b\xfa \xfa\x00\xfa\xc1\xf9\xff\xf9B\xf9\xde\xf8\xe7\xf8\xc8\xf8\xa7\xf8\r\xf8\xa9\xf8-\xfak\xfbw\xfc\x85\xfd\xbc\xfe\xd7\xfe\xd7\xfe\xbb\xfe\n\xfeo\xff0\x00G\x01\xc9\x02U\x02\xed\x02#\x03]\x03%\x03d\x01f\x01+\x01>\x02\x9c\x03\n\x04\xb1\x05 \x05r\x05\xf9\x04\xdb\x039\x03>\x02\x85\x02t\x04U\x07\x9c\tW\x0b\xc4\x0c\xeb\r\xd7\x10\x99\x13t\x15t\x18\xda\x1b/ h#\xc6#\x16"Q\x1d\xe4\x17\xe4\x11<\x0b\xd6\x04\xc6\xffZ\xfc\x16\xfa\xcb\xf8\xc0\xf6\xc5\xf3\xcd\xf0\x1d\xee2\xecb\xeb;\xeb\x04\xec\xcd\xed\x01\xf0\xe6\xf1\xf7\xf2n\xf3\x92\xf3\xcc\xf4\x8e\xf6\xf6\xf8\x06\xfc\n\xff\xe6\x01\xf4\x03f\x04c\x03=\x01\x86\xfe\x04\xfc\x00\xfaG\xf8B\xf6c\xf4A\xf2\xd9\xefv\xed\xd4\xea7\xe9\xd0\xe8\x86\xe9\x8d\xeb=\xee\xf6\xf05\xf3F\xf4\xab\xf4\x1d\xf5\x83\xf5H\xf6M\xf8\xfd\xfa\xcd\xfd\xe4\x00\x14\x03\xe9\x03\x84\x03\xf6\x01\xb4\x00\x92\xff\x00\xff\xf0\xfe\xca\xfe\xda\xfe2\xfe\x98\xfdi\xfc\x95\xfa\xf8\xf9\x03\xfa\xb2\xfb\x88\xfeY\x01\x81\x03\x1f\x05\xe3\x05\xd7\x06\xed\x07C\x08\xbc\t\xd9\nF\x0c\xc8\x0eD\x0f\xca\x0eY\x0c\x17\x08\t\x06\xf8\x06L\x0c1\x15%\x1f<)\xe21K7\x837\xf03\xdd-}(\x86%u#G!\x15\x1de\x16\x04\r\xce\x01\x08\xf6\xbf\xebv\xe6\xed\xe55\xe8\xd8\xeb\xe5\xed\xb1\xed\xec\xeb\xaa\xe8\x00\xe6\xc8\xe4O\xe5\xc2\xe8:\xeeW\xf4"\xf9d\xfb\xa8\xfb\xcb\xfb\x8f\xfd\xd1\x00\xab\x05s\ni\x0e\xc0\x10\xdc\x0f\x86\x0bU\x04P\xfcx\xf5\xb0\xf0m\xee:\xed\xbe\xec\xfc\xeb;\xea"\xe8\xed\xe4F\xe2\xe9\xe1\xc0\xe3\xdd\xe7K\xecg\xf0\x8f\xf3\xf8\xf4\x81\xf5\xe5\xf5\xab\xf6\x9b\xf8\x8f\xfb\xf9\xfe\xbc\x01;\x03D\x03z\x01:\xff\x8c\xfd\xcb\xfc7\xfd\xb2\xfdG\xfe\xaf\xfdw\xfcx\xfak\xf7\xbe\xf5j\xf4%\xf5\xc8\xf7\x8f\xf9\x8b\xfc\xe4\xfdD\xfe|\xff\xde\xfe\xc9\xff\xc4\x004\x02\x03\x06\x81\t\xda\r\xd3\x10\x8d\x12>\x12<\x0f\x01\x0b\xe4\x04Y\x00\xf6\xfe\xb5\x01a\n\xa6\x16\xf5$\x822\x11<\x86@\x02A\xe7>\x02\xfa+\xf8\x04\xf6p\xf5\x8a\xf4\xcd\xf3v\xf3S\xf2\x00\xf2\xea\xf1w\xf2\xca\xf4\xbc\xf7\xbd\xfb0\xff\x01\x01\xee\x02\x15\x03\xb5\x03\x11\x05<\x06\xb4\x08\xca\t3\n`\n`\x08\x98\x06V\x03\xca\x00\xa7\x00,\x02z\x07z\x0e\xdf\x17r#\xa9.\xdf9\xdaB!IXL\x08KEF\x91<\xfc/\xb3!\x8e\x11,\x03F\xf5\xe2\xe8\x15\xdf\x83\xd7%\xd4Z\xd4\'\xd8\xc6\xde*\xe6\xf8\xec\x99\xf1\xa3\xf4_\xf6\xce\xf7\x17\xfa\xa8\xfdy\x02\xad\x07\x05\x0c\x19\x0f\xe6\x10\x98\x11\xb0\x11\xc1\x10\xa2\x0e\xd1\n\xce\x04K\xfd\xbe\xf4M\xec|\xe55\xe0\xb3\xdd\x98\xdd\xef\xde\xc9\xe1\xf1\xe4\x05\xe96\xeeA\xf3\xc0\xf7I\xfb-\xfd\x97\xfe\x8f\xff\xf2\xffZ\x00-\x00\xd5\xffv\xffp\xfe\xce\xfc\x10\xfb\x0b\xf9\x07\xf8\xd8\xf7\xbc\xf7{\xf7\xa8\xf6s\xf5\xb8\xf4\xd2\xf4\xaa\xf5\xe8\xf6*\xf8\xaa\xf8\xee\xf7K\xf6k\xf4\x16\xf3\x11\xf3\xda\xf3\xcc\xf5\xbf\xf89\xfbK\xfd\xbd\xfe\xaf\x00\xa6\x03\xd3\x06b\x08|\x08\x80\x06\xd0\x03\xc3\x01.\xff`\xfd\xee\xfbH\xfb\x00\xfb\xe2\xf8,\xf5*\xf2\xb6\xf5\xac\x02a\x17\x0e/\xc5CnS\x8b]\xdcbzc\xe0^8V\xe6J3;\x83&\xad\x0c1\xf1 \xda\xb7\xca\xe3\xc3\x1d\xc4\xb6\xc7{\xcc\x9e\xd2\x80\xd9v\xe2\x01\xec^\xf5R\xfd\x01\x03\xdd\x06\xa3\x07\x8e\x06\xed\x04\xd9\x04\x8b\x08\xf2\x0e\xc8\x151\x1a\x99\x18\xc7\x12\xfa\t\x9b\x01F\xfa\xd4\xf2\x86\xebv\xe2\xbc\xd9\xc3\xd2\x13\xcf?\xd1\xdf\xd6:\xdf\xb0\xe8\xe2\xf0\xba\xf8\xbf\xfe\x80\x03j\x07\xd7\t\xd3\n\xc4\t\xa7\x06\xd0\x02^\xff\xd7\xfc\xc6\xfb\x0f\xfb\xe3\xf9\xf7\xf7\xac\xf5\x8a\xf4}\xf4\xd0\xf5\x90\xf8\xb9\xfa"\xfcn\xfb\xbc\xf8\x17\xf6\x19\xf6\xe5\xf8q\xfc\xfe\xfd \xfbL\xf6%\xf2\xb2\xef"\xf0\xd2\xf1\x9e\xf4\x04\xf8\xa8\xfa\x85\xfdC\x00\x1c\x03\xdb\x06\xba\n\x07\rH\x0c\xc7\x08S\x04\xa1\xff\xde\xfb4\xf8\xfb\xf3c\xf0\x1b\xec\xc4\xe7\xaf\xe4\xe1\xe6\xe0\xf4G\x0e\x06.=IkY\xc9b\'hwm2p\xfdh\xdfX\x11B\xaf&6\t\xdd\xe9\xb6\xce\xc7\xbc\xff\xb5\x08\xb7[\xb8\x83\xbam\xc1\xf4\xd0\xd8\xe7\xd8\xfd;\x0c\x7f\x12\xf0\x14\xc2\x160\x18\xbe\x183\x18u\x17\x03\x176\x16\xa6\x13\xe6\x0e\x8e\n\n\x08L\x06~\x002\xf5\xb2\xe6F\xd9.\xd0O\xcb\x96\xc8I\xc7.\xc8\xb5\xcd#\xd8\xbc\xe6\x04\xf7p\x06N\x13\x1b\x1c?!\xe2!2\x1f\x05\x1b\xa9\x14\xf6\x0b\xa0\x00\x07\xf5\xfa\xeb\x96\xe6t\xe4}\xe4D\xe5y\xe6\xf0\xe8f\xed.\xf3\xe5\xf9o\x009\x05\xfe\x07A\tp\tS\tn\x08k\x06\xde\x02\xe0\xfdl\xf8\xf3\xf2 \xef\xc2\xed\x0b\xee\x9f\xef\x99\xf12\xf4\xdb\xf7\xe0\xfb\xd4\x00\xca\x04\xaf\x06-\x07x\x05\xe7\x01\xaf\xfd\x12\xfa\xdf\xf6\xe2\xf1|\xe9\x08\xe0\xd7\xda\x8a\xde3\xebR\xfbi\x0b\xea\x1e&6?P"h\x90v\xa4zwwJo\x02bAO\xe46\xb0\x17b\xf7\xe1\xdb\x04\xc6j\xb7\xad\xafD\xaeb\xb3\x18\xbeQ\xcdh\xdf\xb0\xf2\xa1\x03~\x0f\xf5\x15\xff\x17,\x18\xe6\x187\x19w\x17\xaa\x13\xbf\x0e|\n\x0e\x08%\x07\x0c\x06\xaf\x01\xbb\xfa\x85\xf2V\xea\x08\xe4\xc6\xde_\xd9\xf8\xd40\xd2\x1a\xd4:\xda)\xe3C\xee\xce\xf9\xcb\x04[\x0fM\x17\xd3\x1b\xb1\x1cd\x19\x1e\x13\xae\n\x80\x01\x94\xf7\xab\xecp\xe3\xa9\xdd\xa7\xdbL\xdeQ\xe3\x1f\xe8\'\xeek\xf5\x7f\xfdz\x05V\x0bY\x0e\xac\x0f\xe7\x0ed\x0cs\x08\x16\x04G\x00v\xfcA\xf7V\xf1\x0b\xeci\xe9\xeb\xe9\x87\xeba\xed\x9f\xef\xf2\xf2\xbe\xf7\xbf\xfc\x90\x01(\x06\x19\n\xbc\x0c\xf0\x0c\xa2\n%\x06w\x01\xc7\xfc\xdf\xf5\xbd\xef\xf5\xe9`\xe4\xdb\xdf\x7f\xda\x9b\xd9\xc3\xe3s\xfar\x18\x9d1fC\xb1P9_%r\xac\x7f\xff\x7f#rSYR?\x06(x\x0e\xfc\xf1"\xd5R\xbc\xc8\xac5\xa8D\xab\x8b\xb3\xf4\xc0\xe1\xcf\'\xe0W\xf2.\x03B\x12Z\x1fb%&$) p\x1c\xd1\x196\x18J\x14/\x0c\x9d\x02[\xfb\x9b\xf7\x99\xf6\x05\xf5\x16\xf0\x1a\xe7\xbc\xde1\xdb\x96\xdb9\xdd\x82\xde^\xdeb\xe0t\xe7&\xf2=\xfdS\x06w\r\xa9\x11\x9b\x11"\x11 \x11\xc1\r\xd8\x07\x91\xff\xed\xf4\xfb\xec\xd9\xe9p\xe8{\xe6\xdc\xe5\xea\xe7\xa7\xec\xbe\xf3\xdb\xfb\xca\x012\x06W\n\x0f\r\xc6\r\xb3\r\xa9\x0b\x81\x06\'\x009\xfa\xde\xf4c\xf0\x17\xedq\xeaV\xe9S\xeb\x8a\xefe\xf4)\xf9\x06\xfe\x98\x02q\x06\x9c\t\xb9\x0bS\x0cR\x0b:\x08}\x03\xef\xfdR\xf8p\xf3\xf0\xee\xcf\xeb=\xeaG\xe7\x94\xe2\xa0\xe0\xe3\xe5\xc9\xf4\xfe\n\xef \xe50o>\xd5P\x18e-t\x0fygr&d\x1fT\x10B\xb8)\xec\x0c\xa7\xf0\x87\xd5+\xc0i\xb3\xaf\xad!\xaf\x9c\xb5s\xbe\xf5\xc9\x81\xdat\xef(\x03\xcd\x10a\x17\xa1\x19\x05\x1c>\x1f\x12 &\x1c\x18\x14\xce\x0bi\x06c\x04\x83\x02\xdc\xfe\xeb\xf9[\xf5\xd1\xf1\x9e\xefS\xed\x1a\xea\xf8\xe5\xfb\xe1\x14\xdf\xb1\xde\\\xe1\x83\xe66\xec\x9f\xf0\xf6\xf4\xaf\xfb\xc5\x04+\r,\x11\xa9\x0f\xfc\x0b\x87\t\xe8\x07L\x04\xda\xfd\xdb\xf5\xe4\xee\xa8\xebm\xeb\x9d\xec\x9e\xee\xa2\xf0\x1e\xf3\xd5\xf7\xc9\xfd\xcc\x03\x0e\x08\xf4\x08T\x07\x84\x05\x82\x03N\x00\xa2\xfb\'\xf6\x08\xf1@\xedj\xeb\x83\xebQ\xed\xb7\xf0\x8e\xf4\x10\xf8?\xfc\xba\x01}\x07\x92\x0b\x8a\x0c#\x0b\xe4\x08\xbb\x06\x95\x04Q\x01V\xfc\xa2\xf55\xf0\xce\xec\xb3\xea\x8b\xe9\xa5\xe5\r\xe0A\xe1\x94\xedP\x02\xd9\x17\xab&\x981N@LVMl\xdbw1v#l``\xf4S\xc9A\xac(\x9d\x0b\x94\xef\x15\xd8`\xc4\x15\xb6\xc4\xae\x05\xae\xa8\xb1\x14\xb8\x8a\xc2\xa2\xd1\'\xe5^\xf8\x06\x04\xd4\tB\x0fL\x15\xf5\x1bm\x1fA\x1c[\x15\xaf\x10\xdf\x0ec\r\x04\x0b~\x07\xc5\x02\xd0\xfe[\xfb\xd4\xf6O\xf1\xb8\xec0\xe8\x93\xe2\x05\xdd\xbc\xd9\xec\xd9\xea\xdd\xb6\xe3\x92\xe7\x86\xea\x81\xf0&\xfa\x98\x03(\t5\nz\t\xa5\t9\x0b\xd0\n+\x06\x19\x00\x1c\xfb\xbf\xf7\xef\xf5\xb7\xf5\xae\xf5\xdc\xf5\xb4\xf6m\xf8\xd1\xfa\xc4\xfd\xd4\x00J\x02*\x01w\xff\x8e\xfd\n\xfb\x1e\xf9d\xf6\x98\xf2\xc1\xef\xb0\xee\x98\xef\x17\xf2C\xf5\x81\xf8\xf7\xfb\xb5\xffR\x04\xb6\x08\xb0\x0b-\r\xdc\x0ck\x0b\x03\t\xaa\x05\x98\x01\xc7\xfc\xaf\xf7\xc4\xf2\x0f\xee\\\xeb\xb1\xe9\x02\xe7R\xe3\xdc\xe0\xaf\xe4I\xf1g\x03\xd5\x14\x1e"\x1b.\x00?\xeaS\xe1e2n\xfck\xacd\x8a][T\x96D/.\xf7\x14g\xfd\xd5\xe9\xeb\xd9\x9e\xccX\xc2\xf9\xbb~\xb9X\xbb6\xc2\x96\xcc\x9e\xd7\xdd\xe0@\xe8\x90\xefA\xf8p\x01f\x08\xa3\x0b-\x0c\x07\r\x14\x10\xbb\x14\x0b\x18\xaf\x18\xa5\x17\x9e\x16\xe7\x15\x99\x14>\x11\x85\x0b\x00\x04\x1d\xfc\x1f\xf4\x96\xec\xcc\xe5\xf6\xdf"\xdb>\xd7o\xd5\xd5\xd6\xd7\xda\xc9\xdf\x1d\xe4\xc9\xe7\x94\xec\x1e\xf3$\xfaO\xff\xe7\x01\x1b\x03\x80\x04\xb8\x06\x9a\x08p\t\xf3\x08\xc2\x07\x85\x06\xf9\x05\x05\x06\xf5\x05\x05\x050\x03\xd9\x00\xe7\xfeg\xfd\xc1\xfb.\xf9\x17\xf6G\xf3/\xf1[\xf0\x94\xf0h\xf1\xa1\xf2I\xf4\xd5\xf6\x91\xfa^\xffg\x04\xca\x08\x00\x0c[\x0e\n\x10%\x11v\x11e\x10\xf0\r\xa6\n<\x07\xdb\x03~\x00,\xfd\x05\xfa \xf7[\xf4J\xf1|\xee\xac\xed;\xf0\xfa\xf5\xd5\xfcO\x03\x9b\t_\x113\x1bq%l-I2\xb14\xf15\x056\xd83\xb5.F\',\x1f%\x17\x19\x0f\xd8\x06\xaf\xfe\xc4\xf7m\xf2<\xee\xef\xeai\xe8\x07\xe7\xda\xe6\x10\xe7\n\xe7\xf0\xe69\xe7+\xe8\x7f\xe9\xa2\xeat\xeb^\xec\xef\xedA\xf0%\xf3\xdf\xf5|\xf8?\xfb<\xfec\x01)\x04W\x06-\x08\xae\t\x9c\n\xa8\n\xc4\tU\x08\xcb\x06\xed\x04\xa7\x02\xdd\xff\x06\xfd\xb6\xfa\xd9\xf8J\xf7\xba\xf5N\xf4b\xf3\xe7\xf2\xea\xf20\xf3\x8d\xf3=\xf4.\xf5.\xf6=\xf7@\xf8z\xf9\xc0\xfa\x0f\xfc7\xfd;\xfe<\xffB\x00R\x01L\x02\xf8\x02@\x03y\x03\xcf\x03^\x04\xf6\x04K\x05t\x05y\x05r\x05T\x05\x13\x05\xcb\x04{\x04\x11\x04\xa5\x03\x0b\x03|\x02\xbe\x01\xea\x008\x00\x81\xff\xb0\xfe\xc8\xfd\xfa\xfc\x80\xfc:\xfc\xf8\xfb\xa9\xfb2\xfb\xea\xfa\xcb\xfa\xc7\xfa\xee\xfa\x1d\xfbh\xfb\xb5\xfb\n\xfc[\xfc\xa9\xfc\x02\xfdH\xfd\x8c\xfd\xb3\xfd\xb5\xfd\xe2\xfd]\xfe\x1f\xff1\x00\x91\x01A\x03#\x05\xf7\x06\xa9\x08I\n\xf5\x0bu\r\xca\x0e\xd4\x0fx\x10\xf4\x10l\x11\xf5\x11a\x12\x90\x12{\x12\x08\x12f\x11\x99\x10\x80\x0f \x0et\x0c\x95\n\x9d\x08s\x06\x05\x04f\x01\xe4\xfe\xae\xfc\xe4\xfa]\xf9\x0c\xf8\x1f\xf7\xcb\xf6\xbc\xf6\xf9\xf6e\xf7\xf5\xf7\xb8\xf8O\xf9\x8e\xf9U\xf9\xc4\xf8\x16\xf8U\xf7\xa1\xf6\xec\xf5u\xf5B\xf5k\xf5\xdc\xf5\xa2\xf6\x96\xf7\xb6\xf8\xbd\xf9\x87\xfa\xfa\xfa\x1e\xfb\x12\xfb\xc1\xfa.\xfak\xf9\x97\xf8\x02\xf8\xd5\xf7\x19\xf8\xc1\xf8\x9d\xf9\xd2\xfa<\xfc\xba\xfd:\xff\x95\x00\xd6\x01\xdd\x02\xaa\x03H\x04\xa0\x04\xaf\x04\xa8\x04k\x04\x0b\x04\x99\x03\x1d\x03m\x02\xa4\x01\xc8\x00\xf3\xff\x1a\xff]\xfe\x9f\xfd\xde\xfc=\xfc\xdd\xfb\xc9\xfb\x1e\xfc\xc0\xfc\x9f\xfd\x94\xfe\x86\xff\x87\x00\x85\x01\x84\x02\x80\x035\x04\xa4\x04\xe3\x04\xf4\x04\xdf\x04\xa0\x04\x17\x04l\x03\xa4\x02\xc9\x01\xec\x00\xe2\xff\xdf\xfe\xf1\xfd\x06\xfd8\xfc|\xfb\xe6\xfa\x87\xfaQ\xfam\xfa\xc6\xfa\x83\xfb\x8d\xfc\xc9\xfda\xff\r\x01\xdf\x02\xc3\x04\xb8\x06\xb5\x08\x92\n8\x0c\x9b\r\xb1\x0ev\x0f\xef\x0f\x1e\x10\xcf\x0f\x0f\x0f\xf7\r\xc7\x0c\x9a\x0be\n\x07\t\x9e\x07q\x06\x99\x05\xe8\x04B\x04\x95\x03\x05\x03\x97\x02+\x02\xab\x01\x08\x01V\x00\x9a\xff\xb9\xfe\xa8\xfd\x84\xfcw\xfb\x86\xfa\xaf\xf9\xbc\xf8\xbf\xf7\xff\xf6\x95\xf6s\xf6]\xf6B\xf6C\xf6y\xf6\xe4\xf6T\xf7\xb8\xf7\xf0\xf7:\xf8\x98\xf8\xf7\xf84\xf94\xf9\x14\xf9\xfc\xf8\xea\xf8\xf8\xf8\x00\xf9!\xf9s\xf9\xfc\xf9\xb3\xfav\xfb^\xfcY\xfdC\xfe\x11\xff\xb0\xffA\x00\xa7\x00\xd5\x00\xbc\x00k\x00\xf8\xff\x8e\xff\x10\xff\x8c\xfe\x1f\xfe\xe0\xfd\xb7\xfd\xbe\xfd\xf4\xfda\xfe\x08\xff\xc8\xfft\x00\x06\x01\x8e\x012\x02\xe4\x02\x98\x03\x15\x04K\x04]\x04z\x04\x9a\x04\xa8\x04\x88\x04-\x04\xb8\x03"\x03\xa3\x02\x0c\x02s\x01\xd5\x00\x16\x00V\xff\xae\xfe^\xfeg\xfe\x92\xfe\xc0\xfe\xe4\xfe\x1c\xff\x8c\xff\xf6\xffE\x00J\x00\x08\x00\x92\xff\xf5\xfeT\xfe\xa5\xfd\xd7\xfc\x0c\xfcg\xfb\x19\xfb.\xfb\xa2\xfbg\xfcG\xfde\xfe\xbe\xff9\x01\xc8\x02)\x04P\x05K\x06)\x07\xf2\x07\x9d\x08\x05\t%\t5\tM\tm\tp\t7\t\xd9\x08\x80\x08!\x08\xcd\x07o\x07\xde\x06>\x06\x87\x05\xce\x04$\x04\x8b\x03\xf8\x02S\x02\xaf\x01"\x01\xa4\x002\x00\xcd\xffR\xff\xc9\xfeI\xfe\xd5\xfdv\xfd\x1a\xfd\xbf\xfcV\xfc\x16\xfc\xd4\xfb\xa4\xfb\xa9\xfb\xb6\xfb\xce\xfb\xde\xfb\xc4\xfb\x92\xfbW\xfb\x0b\xfb\xa6\xfa"\xfa\x97\xf9\n\xf9\x85\xf8\x0f\xf8\xc7\xf7\xba\xf7\xd3\xf7\x08\xf8@\xf8\xbf\xf8w\xf9n\xfam\xfb4\xfc\xe0\xfc\xa8\xfdy\xfe"\xff\x98\xff\xbd\xff\xc8\xff\xe9\xff\x10\x00\x1e\x00\x05\x00\xcb\xff\x9f\xff\x83\xffm\xffd\xff^\xffy\xff\xa9\xff\xf8\xffV\x00\xe2\x00\x8a\x01M\x02&\x03\xec\x03\x9c\x04\x1e\x05o\x05\x8f\x05o\x05 \x05\x95\x04\xde\x03\'\x03i\x02\xad\x01\xfa\x00\\\x00\xf6\xff\xac\xff\x98\xff\x80\xffq\xffe\xff`\xffa\xffc\xffC\xff\x0e\xff\xd4\xfe\xb2\xfe\xbc\xfe\xdb\xfe\x12\xffB\xff\x8b\xff\xdf\xff5\x00t\x00\x8d\x00|\x00G\x00\xf1\xff\x81\xff\x02\xff\x83\xfe"\xfe\xd1\xfd\xa1\xfd\x88\xfd\xa0\xfd\xef\xfd`\xfe\xcf\xfe7\xff\xae\xff.\x00\xbc\x00O\x01\xe2\x01|\x02*\x03\xfd\x03\xf0\x04\xf7\x05\x01\x07\r\x08\x12\t\x0c\n\xde\ni\x0b\xb4\x0b\xaa\x0b\\\x0b\xc8\n\xe0\t\xc7\x08\x8f\x079\x06\xd2\x04q\x03\x1d\x02\xe9\x00\xd8\xff\xef\xfe*\xfe\x90\xfd#\xfd\xe2\xfc\xbf\xfc\xa7\xfc\x91\xfc\x9f\xfc\xac\xfc\xaf\xfc\x8d\xfcE\xfc\xf3\xfb\x89\xfb\x02\xfb_\xfa\x9c\xf9\xdc\xf8+\xf8\x87\xf7\xf4\xf6|\xf6A\xf60\xf6E\xf6t\xf6\xbd\xf6*\xf7\xad\xf7K\xf8\xf2\xf8\x8f\xf9E\xfa\x02\xfb\xbf\xfbt\xfc\x14\xfd\xbc\xfdR\xfe\xd9\xfeG\xff\xa1\xff\xe2\xff\x1d\x00I\x00Y\x00c\x00`\x00`\x00p\x00\x83\x00\xa6\x00\xd1\x00\x0f\x01f\x01\xda\x01[\x02\xde\x02X\x03\xc8\x036\x04\x96\x04\xd5\x04\xfa\x04\xf3\x04\xc5\x04y\x04\t\x04\x8f\x03\r\x03\x86\x02\x06\x02\xa1\x01U\x01,\x01\x1e\x011\x01P\x01w\x01\x9d\x01\xb2\x01\xa0\x01c\x01\xfc\x00v\x00\xc5\xff\xfa\xfe-\xfeh\xfd\xc4\xfc9\xfc\xe3\xfb\xb3\xfb\xa8\xfb\xca\xfb\r\xfc_\xfc\xaa\xfc\xe5\xfc\x07\xfd\x1b\xfd%\xfd\x14\xfd\x01\xfd\xe9\xfc\xd5\xfc\xde\xfc\xf6\xfc-\xfd\x85\xfd\xee\xfde\xfe\xe2\xfek\xff\xf9\xff\x96\x003\x01\xd3\x01~\x028\x03\x08\x04\xf1\x04\xed\x05\xf3\x06\xf6\x07\xee\x08\xe2\t\xbf\nt\x0b\xf6\x0bA\x0cX\x0c@\x0c\xf4\x0by\x0b\xd5\n\t\n\x1e\t6\x08K\x07P\x06k\x05\x8b\x04\xb4\x03\xf1\x022\x02\x85\x01\xde\x002\x00\x88\xff\xdb\xfe5\xfe\x97\xfd\xf3\xfcW\xfc\xd8\xfbl\xfb\x13\xfb\xd2\xfa\x94\xfa\\\xfa7\xfa&\xfa\x15\xfa\xf3\xf9\xc4\xf9\x97\xf9k\xf9.\xf9\xe8\xf8\xa0\xf8j\xf8=\xf8\x17\xf8\xff\xf7\xf9\xf7\t\xf8)\xf8S\xf8\x9a\xf8\xea\xf8M\xf9\xc6\xf99\xfa\xb4\xfa/\xfb\xa7\xfb#\xfc\x9c\xfc\x1b\xfd\xa8\xfd;\xfe\xd3\xfek\xff\x11\x00\xc0\x00u\x010\x02\xde\x02s\x03\xed\x03\\\x04\xa8\x04\xd7\x04\xe8\x04\xc3\x04\x9c\x04\\\x04\x10\x04\xb9\x03Y\x03\xfd\x02\xa3\x02^\x02\x1c\x02\xef\x01\xd0\x01\xa9\x01\x9b\x01\xa6\x01\xb8\x01\xc5\x01\xcf\x01\xd1\x01\xc0\x01\xaf\x01\x8a\x01;\x01\xe7\x00\x8a\x00\x1e\x00\xb0\xff@\xff\xcf\xfeg\xfe\x05\xfe\xb4\xfds\xfdE\xfd\x1e\xfd\x0b\xfd\xfb\xfc\xfa\xfc\x0c\xfd\x1c\xfd1\xfdE\xfdu\xfd\xa4\xfd\xbe\xfd\xe7\xfd\x1f\xfeX\xfe\x93\xfe\xcd\xfe\x03\xff=\xff}\xff\xbf\xff\xfb\xff+\x00P\x00\x84\x00\xb7\x00\xdd\x00\xf8\x00!\x01K\x01\x7f\x01\xc3\x01\xf5\x01\'\x02P\x02\x81\x02\xb5\x02\xcc\x02\xf3\x02\x01\x03\n\x03,\x03\x18\x031\x037\x03=\x03f\x03\x80\x03\xc6\x03\xef\x03(\x04_\x04\x7f\x04\x9f\x04\xad\x04\xa4\x04\x94\x04q\x04?\x04\x02\x04\xaa\x03a\x03\x10\x03\xb0\x02l\x02\x18\x02\xd4\x01\x8b\x019\x01\xfb\x00\xa7\x00W\x00\x03\x00\xbb\xff[\xff\xf2\xfe\x86\xfe\x1b\xfe\xb4\xfd9\xfd\xc1\xfcB\xfc\xd1\xfbT\xfb\xfb\xfa\x9a\xfaR\xfa\x10\xfa\xd9\xf9\xc2\xf9\xaa\xf9\xa2\xf9\xbc\xf9\xd6\xf9\x00\xfa,\xfak\xfa\xca\xfa\x1e\xfbs\xfb\xd5\xfb?\xfc\xa9\xfc\r\xfd~\xfd\xe5\xfd4\xfe~\xfe\xcf\xfe,\xff\x80\xff\xc1\xff\x17\x00g\x00\x9d\x00\xaf\x00\xdb\x00\x0b\x01\x15\x01\x1b\x01(\x01L\x01P\x01.\x01$\x01\xfb\x00\n\x01\x13\x01!\x01_\x01_\x01]\x01c\x01\r\x01\xf3\x00\xcf\x00\x94\x00\x10\x01\x1f\x01\x06\x01\x1b\x01\xcc\x00\xa1\x00\x91\x00u\x00\x85\x00\xe1\x00\xe1\x00\xa4\x00f\x00J\x00\x16\x00\x14\x00\x98\x00\xdf\x00\xe6\x00D\x01P\x01P\x01%\x01%\x01\xcf\x00\xd5\x00T\x01\xb1\x01^\x01\xe9\x00\x01\x01\x10\x01;\x00\xf3\xfe\xe0\x00\xc4\x06\xc3\t\x91\x08j\x01\x94\xf8&\xf58\xf6\xb7\xf7\x8e\xfb\xb8\x00*\x04\x0b\x05\xbf\xfb\xcd\xf9A\xf9\xda\xf7\xb8\xfc\xb4\x01#\x04\xd7\x02e\x02\x7f\x00\xdd\xfe\xce\xff\xd9\x02\xfb\x04\x7f\x06\xab\x07\x14\x08\xe8\x05r\x02\x9e\xff#\xff{\x00:\x02&\x03\xc6\x01\xee\xff\x10\xfe\xc8\xfc\x9f\xfc\xe9\xfc;\xfd\x8a\xfd\x89\xfd\xe2\xfde\xfc\xf1\xfc\xd8\xfc\xed\xfa\xb1\xfbn\xfe\xdc\xffM\x00\xed\x00\x8e\x00\xfe\xff\x05\xff\xf4\x00\x11\x03\xe7\x03\xe0\x03\xa8\x037\x03\x92\x01b\x00\xb1\x01\x8f\x03\xcc\x03\xa7\x04^\x044\x03\xc0\x01i\x00\xd3\xff\x92\x02\x94\x03E\x06\x1c\x06|\x03P\x03L\x00m\x00\x1c\x03i\x06\x04\x06s\x05W\x02l\x00\xce\x01\xdd\x02H\x03\x95\x01\x1a\xffA\xfd\xdd\xfe\xe4\x00\xf9\x00\xa0\xff\xce\xfc\xd1\xfb\xba\xfb+\xfb\x9c\xfbe\xfc\xe5\xfc\xf5\xfc\\\xfc\x04\xfa\xa8\xf7\x1c\xf7\xe3\xf8\xf9\xfb\xdf\xfd\xe1\xfd\xa2\xfb\xa4\xf7\x05\xf7\xe1\xf8\xc7\xfaL\xfd\x92\xfez\xfe\xb8\xfch\xfa\xc7\xf9\xa5\xf92\xfb\xf7\xfdh\x00V\x01\xae\xfe\xd6\xfb\xc0\xfa4\xfc:\x00\x95\x02^\x03G\x02D\xff\xef\xfd_\xfd\xaa\xff\xd2\x01I\x02\x8b\x04\x9d\x03o\x00\x0b\xfe4\xfd\x10\xfe1\x01\x90\x04;\x06\xfb\x03\xe5\xfe\xba\xfb8\xfbi\xfe\x0f\x04\xfd\x06\xc6\x04\xcb\x00N\xfe\xae\xfd\xa4\xff\x02\x01\xbc\x01O\x02\xb7\x01\n\x01+\xffL\xfes\x00\xc8\x02\x81\x00\x19\xfe\xc6\xfd_\xfe\x92\x01\x8e\x02Z\x00\x9b\xfdB\xfb\xa5\xfb\xa3\xfd\xd2\xfe\xf0\xfe~\xfd\xfd\xfa\xb7\xf9\x80\xf9\xcd\xf9\x8a\xfa\xa9\xfbq\xfb\xac\xfaZ\xfa\t\xfbO\xfcP\xfdP\xfe\x91\x01e\x06\xb0\n\x16\x0e\xdd\x10R\x12\xa3\x13g\x15 \x17\xdf\x18&\x1as\x1a<\x19R\x17\x8c\x15q\x14\xf1\x13\x92\x13\xd0\x11\xa7\x0e@\x0b\xee\x07\xf2\x04\x06\x02\x16\xff\xa7\xfb\xbe\xf8A\xf6\x90\xf3\x10\xf1\x12\xef\xe2\xed_\xedm\xed\xa6\xed\x99\xed*\xed\xfa\xec)\xed\xd1\xed\x04\xef\x11\xf0\xf7\xf0\xc8\xf1\xbd\xf2!\xf4\xe6\xf5\xd6\xf7\xe0\xf9{\xfb\x81\xfc4\xfd\xc8\xfdB\xfe\xaa\xfe\xd2\xfe\x95\xfey\xfeb\xfes\xfe\xcf\xfe\xe6\xfe\x18\xffx\xff\xc6\xff/\x00\x81\x00.\x00\xe4\xff\xa2\xff|\xff\xec\xff\xdd\xff\x89\xff\xa6\xff\x17\x00 \x00\xd0\x00{\x01[\x01\x83\x01\xc6\x01\x14\x02\x97\x02"\x03J\x03\x9a\x03\x13\x04q\x04%\x05F\x06\x10\x07\xd6\x07\xbd\x08\x1a\t\xe3\t5\n\x9a\n\xfa\n\xbe\n\xa9\n\x87\n\x1e\nt\t\xb8\x08\xba\x07P\x06\xa3\x05\xd0\x04\xa5\x03V\x02h\x00I\xfe\xce\xfc\xcb\xfb<\xfa\x03\xf9v\xf7\x1a\xf6\xfb\xf4\xfc\xf46\xf5\x14\xf5\\\xf5\xbe\xf5\x07\xf6z\xf6\x1a\xf7\x19\xf7\xb1\xf7\x9f\xf8q\xf9S\xfa\x98\xfaa\xfa\xd4\xfaz\xfc\xb1\xfd\xac\xfe\xae\xfeH\xfe\x84\xfe\x91\xff\xee\x00/\x01(\x00\r\xff\x8b\xfe\x0f\xff\xd4\x00)\x01\x99\xff_\xfeD\xffI\x00\xc9\x00U\x00\xca\xfe[\xfe\xa7\x00\xc9\x02\x89\x03]\x02*\x00K\x01\xc8\x02\xc6\x02\xe7\x01`\x01\x85\x01\x10\x02\xf6\x01y\x000\xff\xee\xfe\xe8\xffo\x00P\x00\xde\xff\x04\xff\xe8\xfe\xa0\xff\x86\x00R\x02\x1a\x05\xbf\x07\x19\n\xcc\x0bs\r\xe6\x0fl\x12%\x14\x8e\x14\x15\x14\n\x13v\x12\xcd\x12\x14\x13\xd3\x126\x11\x01\x0f\x18\x0e\xef\r\x18\r\x15\x0bC\x08O\x059\x02\r\xff\xcf\xfb\xac\xf8r\xf5=\xf2\xe0\xef\x06\xee&\xec1\xea\xfc\xe8s\xe9\x01\xeb\xbc\xebQ\xeb"\xebB\xec\xb0\xeeT\xf1\'\xf3)\xf4\x06\xf5S\xf6\xbf\xf8\xed\xfbw\xfe\xd1\xff\xa1\x00\xff\x01\xce\x03\x07\x05\x1e\x05\xde\x04"\x05=\x05\x89\x04$\x03\xa3\x01\x9b\x00\xfa\xfft\xff\xb5\xfe\xbd\xfd\x99\xfc\xd1\xfb\xab\xfb\xbd\xfb\x8d\xfb\xec\xfa\\\xfa\\\xfa\x81\xfa^\xfam\xfa\xab\xfa\x05\xfb\xfb\xfbm\xfc\xae\xfc\xaf\xfd\xc0\xfe\xc5\xff\'\x01\xdd\x01\xca\x01\x82\x02m\x03\xc2\x04\xb9\x053\x06\xb9\x06\xae\x07\xc0\x08\x9b\tE\n.\n\xb3\n`\x0b2\x0cO\x0c\xaa\x0b\xfa\tG\t6\t\xb4\x08\xb9\x07^\x05\xab\x03\x86\x03_\x03\x01\x02\x1f\xff|\xfco\xfb\x86\xfb\x80\xfbU\xf9\xa3\xf6\xb7\xf4\xfe\xf4\x9c\xf5\xe6\xf44\xf4\x82\xf4\x17\xf5\x81\xf5\x1d\xf6V\xf6m\xf7*\xf9e\xfa\x06\xfb\xc2\xfb\xc3\xfb\xbc\xfc\xcb\xfeB\x01\\\x02\xcd\x00n\xffj\x00,\x04\xfb\x05\x9b\x04\xe9\x01%\x00\x8f\x00\xfc\x01U\x03\xb3\x02\xcf\x00?\xfe+\xfc(\xfc\xf7\xfd\xf5\xfe\xf3\xfd\xd5\xfb`\xfa\xfb\xf9\x96\xfa^\xfbs\xfbN\xfbJ\xfbD\xfb\xb3\xfb;\xfc\x0c\xfd\x80\xfeH\xff\x16\xffz\xfeG\xfeh\xff\x82\x01\xeb\x02\xa7\x02b\x02U\x04\x9d\t\xc8\x0f\x1e\x13\x17\x136\x12[\x14V\x19\xc3\x1c\xef\x1b\xb7\x18\xbf\x16\x9c\x17T\x19G\x19y\x17\x0c\x15@\x13[\x13_\x143\x13\x19\x0f\x16\n\xeb\x06\xca\x057\x03\xf2\xfd\xb5\xf88\xf5\xdd\xf2\xfb\xef\x81\xec\xf1\xe9\xfc\xe8\xd2\xe8\xa0\xe8\xa7\xe8\xd3\xe8\xd9\xe8>\xe9k\xea\xfb\xeb\x03\xed%\xed\x11\xee\xc3\xf0N\xf3T\xf4\xb2\xf4F\xf6\x80\xf9K\xfc$\xfdT\xfd\x15\xfe(\xff\xf8\xff\x99\x00\xe1\x00m\x00<\xff\xa8\xfe%\xff\xb0\xffR\xffh\xfeu\xfe5\xff|\xff\xf3\xfex\xfe\xb0\xfe\x05\xff\xe7\xfek\xfeG\xfeU\xfeL\xfe\x87\xfe\n\xff\xc5\xff-\x00?\x00\x02\x01\xe1\x012\x02\x80\x02{\x02\xe6\x02\xeb\x03"\x04\x19\x04\x92\x04h\x05\x16\x06\x9e\x06\xb7\x06\xdf\x06\xce\x077\x08E\x08\x17\x08r\x07\xa8\x06\x94\x06%\x07~\x07!\x07\xa3\x05\xf2\x04\x80\x053\x06\x0c\x06b\x05!\x04\xf6\x02\x91\x01C\x00\xc3\xff\xa1\xff;\xff\x1e\xfe%\xfc\xdc\xf9\x18\xf8\x8f\xf7\xe9\xf7O\xf85\xf8\x89\xf7\x06\xf7e\xf6\xf7\xf5\x04\xf5Y\xf4\x0b\xf6\x1c\xf9\xee\xfa*\xfa\xe0\xf7\xba\xf6;\xf9\x14\xfdg\xff;\xffI\xfe\x06\xfe\x99\xffy\x01\xa1\x02\x8b\x02\xc5\x00N\x00F\x01/\x03v\x03\xc4\x01\xc3\xffW\xffB\x00\x90\x00+\x00\x7f\xff,\xff\x14\xff\x91\xfe\xc6\xfcR\xfcK\xfdb\xfem\xffS\xff\xb9\xfd\xf4\xfc\x9c\xfd\xee\xfe\x15\x00\xee\xff\xe0\xfe\xff\xfe\xfc\xff\xa1\x01\x1c\x02\x8b\x00\x8e\xff5\x00\x05\x02\xc6\x02\x8e\x01\x1f\x00w\x00\xb4\x02J\x05\xdc\x07\x19\nw\x0b\xe4\x0bh\x0c\x1c\x0e\xb9\x10\xc8\x11\x8c\x10\xa5\x0f\x18\x10W\x11\xb6\x11\x0e\x11%\x11\x06\x12\xcd\x11\xaa\x11\xb0\x11f\x10\x9d\r\xc1\t\x9a\x07"\x07\xb1\x04e\xff&\xfb\x11\xfa0\xfa\xfd\xf7\xcb\xf3\x9f\xf0\xdc\xef*\xf0\x08\xf0\xc7\xefv\xef,\xee(\xedS\xee\x86\xf0R\xf1\x1c\xf0\xf2\xef\xdb\xf27\xf5h\xf4\xa5\xf2\xe2\xf3X\xf8\xa8\xfb(\xfb\x91\xf9\x96\xf9\x92\xfa\x86\xfb\xb9\xfcv\xfe*\xff\x82\xfd.\xfc\n\xfd\xcb\xfe\xfd\xfe\x96\xfd\x81\xfd\xe1\xfet\xff\x8a\xfe!\xfeP\xff\x95\x00D\x00!\x00\x8f\x00\xb9\x00\x98\x00\x00\x00\xd3\x00\xe1\x01\xf1\x00\xb7\xff)\x01\xa3\x02)\x03\x87\x02\xe0\x00\xa1\x01\xcc\x041\x05\x87\x04\xad\x04\xfc\x04O\x05\x1b\x06M\x06\x9c\x06c\x07\x1b\x07\xfc\x06\xcd\x079\x07d\x06\x12\x07s\x08\x10\n\xba\t\xd9\x06_\x05\xb6\x05\xaa\x05\xbb\x05\x05\x05\xa5\x03Y\x03\xb1\x01@\x01\xa0\x00\xd8\xfe\x11\xfe\x93\xfe\xc6\xff\x17\x00\xae\xfd\xa1\xfa\xa9\xf9^\xfb\x17\xfe]\xfe\x95\xfcc\xfa\xf3\xfay\xfc:\xfd\xcf\xfb\xb6\xf9\x8f\xf91\xfb\xbd\xfc\x86\xfc\xa2\xfa}\xf9\xfa\xf9\xc1\xfb\xd6\xfc\x9c\xfbS\xfa\xab\xf9W\xfap\xfb"\xfbp\xfa\xdd\xf9&\xfa\xfa\xfa\xfe\xfa\xa1\xfa\xda\xfa\xe3\xfa-\xfb\x91\xfbJ\xfb\x00\xfbl\xfa\xee\xf96\xfa\xd1\xfa\xf4\xfa\x1c\xfb\xd7\xfaM\xfb\xe3\xfb#\xfc\xe4\xfc\xd8\xfc\x9b\xfc:\xfd\xbb\xfd0\xfeK\xfe\x08\xfew\xff\xdc\x00\xb0\x00\xf8\xff,\x00Y\x01-\x03\xc2\x030\x03\xda\x02+\x02Q\x032\x07\x85\n\x95\x0b\xd8\x0fL\x1d\x0e/X7;2\\-\x9b4\xc5?\xe4>\xc4/\xaa \x05\x1c\x0c\x1b\n\x14\x00\x07w\xfa\xbe\xf2\x12\xefk\xed6\xebT\xe4\x18\xdc\xb8\xd8\x00\xdb\xe4\xdc\xef\xd8S\xd4_\xd6\x83\xdc\xc5\xe0\x97\xe3\xb4\xea\xcc\xf5`\xfe<\x02^\x07\x9b\x10A\x18\xac\x18\x96\x14\xb8\x12\xa7\x13l\x13\x10\x0f\x1f\x08\xc2\x01\xac\xfdT\xfb\xe4\xf8\x99\xf4z\xee~\xe8\n\xe4\xca\xe1I\xe0r\xdd\xc4\xd9?\xd8P\xda\xe9\xde(\xe4\x89\xe9X\xef\x14\xf6\xee\xfd\xfc\x06\x83\x0f\xf9\x14i\x17t\x19\xa9\x1c3\x1fU\x1e\xf7\x19Y\x15\xbd\x12;\x11G\x0eK\ts\x04\x00\x01"\xfe\x91\xfa\xa2\xf6\xc1\xf3\x9e\xf1\x14\xef\xce\xec\xb8\xecF\xefS\xf2o\xf4\x19\xf7\x17\xfc\x9b\x02\x95\x08\xf6\x0c\x07\x10\x8e\x12\x7f\x15x\x18\xfd\x19\x9b\x18]\x15\xde\x13\x82\x13\xf9\x11W\rV\x07\xe1\x03\xe9\x00\x8d\xfcN\xf7]\xf2\x7f\xef\n\xecq\xe8\x0f\xe7T\xe7B\xe9\xf3\xe9\xf3\xea\xf5\xef\xb8\xf7{\x00T\x06T\t\xc3\r\x05\x14\xfe\x1a|\x1d\xdb\x1a\xe3\x17\x18\x18\xcd\x19\x7f\x17h\x10\xb5\tu\x06\xde\x04\x0e\x012\xfa~\xf4\x12\xf1m\xee\xff\xeaC\xe7\xad\xe5|\xe5\n\xe5\xe1\xe45\xe6\xf6\xe9\xbb\xee\x01\xf2{\xf4\xa2\xf7\xfd\xfb\x94\x00g\x03\xb5\x03\xd9\x032\x05\x89\x07P\t\xd2\x08\xd3\x07\xf9\x07\xa3\x08\x93\x08\x11\x07\xa3\x04=\x02\xbf\xff\xfd\xfc^\xfa\xd4\xf7\xd8\xf5\xb3\xf3+\xf2\x1f\xf2U\xf3\xc4\xf4s\xf5]\xf5\x0e\xf6\xad\xf8|\xfb4\xfdz\xfd\xbb\xfe\xbc\x01\t\x05T\x077\t\x83\x0b\x17\r\xae\r\xa3\x0e7\x103\x0e\x0e\nO\x0bS\x17\xc1&\x10,\x8d\'\xba&o1\xbf<\xbe:\xd7,\x91\x1f\x1b\x1b\xe7\x17\x87\r/\xff\x91\xf46\xf0\xc8\xeb\xc8\xe3\x90\xde\xfd\xe0E\xe5\x92\xe2\xb1\xdb9\xdbP\xe4\xf5\xeb\xe1\xe9\'\xe4g\xe6\xeb\xf0Q\xfa>\xfdG\xfe\xc5\x02\xf2\t\xf1\x0f\xd7\x133\x16\x84\x15U\x11\x07\x0c\x7f\x08/\x05}\xfe\xa5\xf4\xdc\xeb\xb8\xe6\xa8\xe4\x04\xe3\xa2\xe0[\xde\xfe\xdd_\xe0\xde\xe3\t\xe7W\xe9\x8d\xebm\xee\x81\xf2\xef\xf6\x14\xfbM\xff \x04\x84\x08.\x0c\xc7\x10\x9c\x16\x96\x1a\xa4\x1a\xf9\x18\xf4\x18\n\x1a"\x18\xeb\x11^\n\xe2\x04V\x01A\xfd\xa7\xf7X\xf2T\xef\xa7\xeeP\xef8\xf09\xf1\xbc\xf2\xfd\xf4\x96\xf7E\xfa\xff\xfc\xb2\xff\xda\x01\xa5\x030\x06\xb8\t0\r\xd4\x0f\xe3\x11\t\x14=\x16\xb9\x17\x18\x18\x06\x17F\x14\xab\x10\xfc\x0c\x17\t<\x04\\\xfe\x87\xf8\xea\xf3j\xf0c\xed\x00\xeb^\xe9\xfc\xe8\xf5\xe9\xe8\xebZ\xee\xd2\xf0\xf8\xf3\xd0\xf7\x1b\xfc\x98\xff\xa1\x02\xd5\x07x\x0e\xa3\x13X\x15\xc3\x15\x88\x18\x19\x1b\x07\x1a\xd3\x15i\x11p\x0e{\n\xb2\x04x\xfe\xae\xf8*\xf4\x8e\xf0\xa0\xedc\xeb\x08\xea\xe2\xe9\xad\xea\x15\xec\xdb\xed\t\xf0\xe4\xf2u\xf5\xef\xf7\xa1\xfa\xc4\xfd`\x01\x1f\x04\xda\x05n\x07@\t\x1c\x0b\xc2\x0b\xc0\ni\ty\x08V\x07n\x05\x85\x02\xdc\xff\x14\xfe)\xfcW\xfa\xba\xf8R\xf7\xc5\xf6 \xf6\x94\xf5\xe4\xf53\xf6\xee\xf6\xa4\xf7!\xf8t\xf9\xe1\xfa\x8b\xfc6\xfe/\xff\xbd\x00\x86\x027\x04\xa8\x05\x86\x06"\x07\xa0\x07\xb7\x07~\x07\x07\x07\xc2\x05;\x04p\x02\xe5\x00\x87\xff\xaa\xfd-\xfb\xbb\xf9\xc1\xf9\xbd\xf9\xd6\xf7\xf5\xf4s\xf5\xb1\xfb\xc2\x02b\x05\xa0\x07\x04\x11a!\xef,\xef,\xa7)G.S8h:\xa2/\xc7!"\x1bB\x19\xb5\x12\x1c\x05E\xf7\x0c\xef\xe2\xeb_\xe9\xce\xe4P\xdf\x8e\xdc\xd2\xddw\xe0f\xe17\xe1\xbe\xe2\x80\xe6d\xe9\x06\xeb\x7f\xee\x98\xf5_\xfc\x0c\xff6\x00\x87\x05\x95\x0eA\x14\xbb\x12\x14\x0fZ\x0f\xad\x11\n\x10|\x087\x00N\xfb9\xf8\x03\xf4&\xee\x03\xe9\xb4\xe6A\xe6\xc9\xe5\x82\xe5\x9c\xe60\xe9\x8d\xebX\xed\x95\xef\xf3\xf2\x1b\xf7\xf2\xfah\xfdj\xff\xf5\x02f\x08\x03\r\xc2\x0e\x9d\x0f\xff\x11+\x15]\x16{\x14D\x11\x9c\x0eN\x0c\xcd\x08\xb6\x03\x87\xfe\xa6\xfa\xe2\xf7\x8b\xf5\xaf\xf3\xee\xf2\xa2\xf3O\xf5;\xf7v\xf9p\xfc\xdb\xff\xe7\x02\x07\x05\xd4\x06\x19\t[\x0b\xdc\x0c~\r\xd3\r\x84\x0eo\x0f\xf8\x0f\x96\x0fM\x0e\xd4\x0cy\x0b\xb3\t\xb9\x06\xb0\x02\x95\xfe$\xfb\xdf\xf7L\xf4\xe4\xf0E\xee\xd8\xece\xec\xa9\xec\xbd\xed\xb4\xef\x83\xf2y\xf5\x7f\xf8\xd8\xfb<\xff<\x02(\x04\x9e\x05\x15\x07c\x08L\t5\t\r\tT\t\xb8\t\xb8\t\xf1\x08\xa8\x08/\t5\t\x1b\x08\x13\x06\x18\x04\xb1\x02\xbf\x00\xd8\xfd\xad\xfa\xf4\xf7e\xf6.\xf5\xda\xf3\xfb\xf2\xec\xf2 \xf4\x0e\xf6\xc5\xf7\x86\xf9\x81\xfb\xcd\xfd<\x00\x07\x02:\x03r\x04z\x05*\x067\x06\xa3\x05,\x05\x9f\x04\xd4\x03\xd2\x02\xb9\x01\xdc\x00\xe4\xff\xe3\xfe\xc8\xfd\x03\xfd}\xfc\x03\xfc~\xfb\xea\xfa\xcd\xfa\x1d\xfb\x81\xfb\xa5\xfb\xb4\xfb\xef\xfbh\xfc\xdb\xfc\t\xfd\xe4\xfc\xf4\xfc\x17\xfd8\xfdI\xfd\'\xfd8\xfdr\xfd\x9a\xfd\x0c\xfef\xfe\xa7\xfe\xfc\xfe\x1c\xff|\xff\xbe\xff\x83\xffN\xffC\xffD\xffU\xff+\xff^\xff)\x00\xdb\x00\xec\x00/\x01B\x02\xb1\x03\xf1\x03\xe9\x02I\x04\xdf\tb\x10s\x13\x18\x14\x8c\x17X\x1fv%\x82%\x8d"m!\x07"\xc1\x1f\x0e\x19q\x11/\x0bx\x05R\xff\xdd\xf8\x88\xf3\t\xef\\\xeb=\xe9z\xe8\x00\xe8l\xe7\x00\xe8\xa1\xe9\xcc\xea5\xebA\xec\xcb\xee\x15\xf1\x16\xf2X\xf3\xae\xf6\xe9\xfa\xdc\xfd\x9b\xff\x82\x02\xfc\x06\x83\nm\x0b%\x0b\xab\x0b(\x0c\x97\n\xec\x067\x03%\x00\xdd\xfc\xcd\xf8\x93\xf4r\xf1\x87\xefI\xeeN\xed\n\xed\xd9\xedv\xef:\xf1\x0e\xf3\x00\xf5%\xf7\x99\xf95\xfci\xfe\xfb\xff\xb6\x01M\x04\xfb\x06\xb2\x08\x07\n\xdb\x0b\x1e\x0e\xc6\x0f;\x10\xdf\x0fn\x0f\xc5\x0e=\r\x82\n\x1c\x07\xe2\x03\x13\x01G\xfee\xfb\xf3\xf8\xb1\xf7\x95\xf7\xe1\xf7G\xf8N\xf9L\xfb\x9e\xfdo\xff\xab\x00\xf8\x01b\x03_\x04\xad\x04\xa9\x04\xab\x04\xe7\x04J\x05u\x05b\x05;\x05h\x05\xcb\x05\xb6\x05\xef\x04\xbf\x03\xa0\x02J\x01e\xff\x13\xfd\xbf\xfa\xc6\xf8+\xf7\xd8\xf5\xd8\xf4q\xf4\xea\xf4\xe0\xf5\x0f\xf7\xa7\xf8\xb1\xfa\x04\xfd\xf9\xfe\x9a\x00,\x02\xba\x03\x0e\x05\xbd\x05\'\x06\x9d\x06)\x07{\x07\x82\x07\x06\x08\x0c\t\xe1\t\xdf\t6\t\x8e\x08\xf7\x07\xd1\x06\xb1\x04\xeb\x01\x0b\xffd\xfc\xd3\xf9O\xf7\xf7\xf46\xf3Z\xf2`\xf2\xed\xf2\xcf\xf3 \xf5\t\xf7b\xf9\xb6\xfb\xd2\xfd\xcf\xff\xb6\x01k\x03\xb4\x04\x89\x05\x18\x06q\x06\x83\x06T\x06\xd7\x053\x05r\x04d\x03/\x02\xfa\x00\xa4\xffJ\xfe\xb0\xfc\xe2\xfaV\xf9\xee\xf7\xc3\xf6\xc0\xf5\xc0\xf4\x1e\xf4\xe5\xf3\xf3\xf3]\xf4\x00\xf5\xd6\xf5\x00\xf7F\xf8\xc3\xf9k\xfb\r\xfd\xb6\xfeP\x00\'\x02\xe5\x03Z\x05j\x06\x9b\x07\xc0\x08\x82\t\x9e\tQ\tn\t.\tN\x08\xb9\x06G\x05\x98\x04p\x03j\x01\xaf\xff\x04\xff\xe0\xfet\xfdC\xfb\x9d\xfb\x8e\xff\x07\x04\xa5\x05>\x06?\n\xac\x11q\x17\xe2\x18\xff\x18\x8b\x1b\x05\x1f\x91\x1f\x8b\x1c\xc5\x18\xff\x15\xa9\x12\xc4\r\x08\x08\xd2\x02A\xfe\xe5\xf9\xd7\xf5Q\xf2>\xef\xeb\xec\xca\xeb\xf9\xea\x97\xe9=\xe8^\xe8\xc4\xe9k\xea\xfc\xe9X\xea\xe9\xec\x10\xf0\xd5\xf1\xec\xf2\xb2\xf5C\xfaO\xfe\x9f\x00\x85\x02\x81\x05\xa3\x08%\n\xf5\tx\t3\t2\x08\xf5\x05\x15\x03\\\x00\x05\xfe\xab\xfb\x19\xf9\xcf\xf6A\xf5|\xf4\xfc\xf3\xae\xf3\xb1\xf3L\xf4\x99\xf5.\xf7\x83\xf8\x96\xf9 \xfb\x99\xfd\xf2\xffV\x01\x84\x02p\x04\xf0\x06\xe3\x08\xf9\t\xc6\n\xea\x0b\n\ru\r\xec\x0c\xfe\x0b#\x0b:\n\xc3\x08\xa2\x06`\x04\xba\x02z\x01\xd0\xff\xc1\xfdh\xfcV\xfc\x84\xfc\xf6\xfbI\xfb\xc6\xfb\x10\xfd\xec\xfd\xeb\xfd\xfb\xfd\xbc\xfe\xce\xffp\x00l\x00R\x00\x95\x00@\x01\xb0\x01u\x01)\x01d\x01\xf4\x01\xed\x01 \x01\x8c\x00\x8b\x00_\x00\x80\xffT\xfe\xa7\xfd\x84\xfd@\xfd\x93\xfc\xfc\xfb\x1b\xfc\xd4\xfca\xfd\x97\xfd\x06\xfe\x06\xff-\x00\xf7\x00z\x01\x18\x02\xe5\x02\x8c\x03\xde\x03\xe7\x03\x10\x04H\x04[\x04A\x04\x0b\x04\xea\x03\xb4\x03g\x03\r\x03\xa7\x020\x02\xac\x01\x11\x01\x8b\x00.\x00\xd6\xff^\xff\xe6\xfe\xe3\xfe\x1e\xffB\xff\x10\xff\xf7\xfeO\xff\xa0\xff\x9a\xffA\xff\x02\xff\xf7\xfe\xe8\xfe\x93\xfe\x0f\xfe\xc1\xfd\xb0\xfd\x9a\xfdM\xfd\x08\xfd\x12\xfd6\xfdD\xfd"\xfd\x0f\xfd7\xfdX\xfd!\xfd\xb7\xfc\xac\xfc\xe6\xfc\xf0\xfc\xba\xfc\x95\xfc\xc5\xfc&\xfdi\xfd\x9f\xfd\x10\xfe\xb4\xfeG\xff\xa3\xff\x0e\x00\xc7\x00\x84\x01\xdd\x01\xf0\x018\x02\x99\x02\xba\x02}\x024\x02)\x029\x02\x08\x02\x9c\x01Y\x012\x01\x06\x01\xc7\x00\x8e\x00e\x007\x00\x00\x00\xc7\xff\x9c\xffw\xffa\xffW\xff@\xff0\xffC\xffw\xff\x8b\xffv\xffw\xff\x9d\xff\xcd\xff\xb9\xff\x8a\xffu\xff\x9c\xff\xab\xff{\xff4\xff\x0b\xffB\xff`\xffj\xffb\xffb\xff\x98\xff\x96\xffx\xff\x7f\xff\x81\xffd\xff\x02\xffh\xfe\x1e\xfe+\xfe\x08\xfeU\xfd\xab\xfc&\xfd\xde\xfe\x0c\x01\xd6\x02<\x04F\x06^\t\xc2\x0c\xb5\x0e\xd2\x0e\xf1\x0eI\x10\x8b\x11w\x10S\r\xf2\nx\n\x9a\to\x06\x96\x02\xee\x00\xf4\x00\xc4\xff\x11\xfdI\xfb\x97\xfb\x07\xfc\xbd\xfa\xc1\xf8/\xf8\xcd\xf8\xb3\xf8R\xf7\xe9\xf5\xde\xf5\xc5\xf6\x1c\xf7\x87\xf6Z\xf6\x8b\xf7x\xf9\x98\xfa\xc3\xfa\x8f\xfbf\xfd\x0c\xfft\xff/\xff|\xff9\x00C\x00N\xffI\xfe\xfb\xfd\xe1\xfdM\xfdf\xfc\xda\xfb\xef\xfb0\xfc\'\xfc\xfc\xfb.\xfc\xd3\xfco\xfd\x8f\xfd\x8b\xfd\xf2\xfd\x9d\xfe\xec\xfe\xc8\xfe\xc5\xfe?\xff\xc9\xff\x0b\x00*\x00u\x00\x1e\x01\xcd\x01S\x02\xb3\x02\x17\x03\xa7\x030\x04Y\x04;\x04\x1c\x04@\x04Q\x04\xe9\x036\x03\xc9\x02\xd3\x02\xcb\x02W\x02\xd8\x01\xda\x01!\x02:\x02\x11\x02\x04\x023\x02X\x02P\x02$\x02\xf9\x01\xec\x01\xee\x01\xca\x01\\\x01\xe3\x00\xc8\x00\xe3\x00\x9e\x00\xf6\xffo\xff_\xfff\xff\xf5\xfeI\xfe\xee\xfd\xf7\xfd\xeb\xfdx\xfd\x08\xfd\x02\xfd1\xfd\x1e\xfd\xd1\xfc\xc4\xfc$\xfd\x8a\xfd\xa6\xfd\xc1\xfd$\xfe\xba\xfe,\xff\x83\xff\xe0\xffF\x00\x96\x00\xd2\x00\x0e\x015\x018\x01,\x012\x01A\x01B\x016\x010\x01H\x01l\x01\x89\x01\x8e\x01}\x01w\x01\x85\x01x\x01\'\x01\xad\x00n\x00C\x00\xea\xffo\xff!\xff*\xffM\xffG\xffY\xff\xac\xff+\x00\xa7\x00\xfc\x00Z\x01\xdd\x01b\x02\xd0\x02\xf4\x02\xcf\x02\xbc\x02\xdb\x02\xc0\x02,\x02w\x01\x18\x01\xf8\x00|\x00\x80\xff\xcd\xfe\x96\xfeO\xfe\x87\xfd\xa5\xfcN\xfcB\xfc\xfc\xfb\x88\xfbQ\xfb\xa6\xfb\x0e\xfc\x1f\xfc*\xfc\x9e\xfcl\xfd\x05\xfej\xfe\x13\xffB\x00\x85\x01a\x02\xfd\x02\xc6\x03\xd3\x04d\x05Y\x05\x13\x05\xf5\x04\xd8\x049\x04=\x03T\x02\xbd\x01\x1a\x019\x00Z\xff\xc4\xfe\x8e\xfeN\xfe\xd8\xfdo\xfdo\xfd\xac\xfd\xb5\xfd}\xfdj\xfd\xa7\xfd\xe9\xfd\xe5\xfd\xb8\xfd\xca\xfd\x15\xfe@\xfe1\xfe%\xfeF\xfes\xfek\xfeB\xfe4\xfe1\xfe$\xfe\xec\xfd\xae\xfd\x98\xfd\x80\xfdt\xfdO\xfd*\xfdO\xfdV\xfdp\xfdc\xfdf\xfd\xa9\xfd\xb6\xfd\xc8\xfd\xd0\xfd\xea\xfd:\xfe^\xfem\xfe\x91\xfe\xba\xfe\xec\xfe\xfd\xfe\xf9\xfe\n\xff)\xffH\xffT\xff[\xffV\xffm\xff\x91\xffz\xffg\xff\xa9\xff(\x00\xaf\x00\xf9\x00\xbb\x01Q\x03\t\x05e\x06\x8f\x07\'\t\x1e\x0bf\x0c\xfe\x0c\xa8\r\x83\x0e\xe8\x0e^\x0e\xeb\r\t\x0e\xd7\r\xdc\x0c\xc7\x0b\x9f\x0b\x96\x0b}\n\xd5\x08\xfa\x07\xa0\x07R\x06\xc1\x03\x7f\x01b\x00D\xff\x00\xfdR\xfa\xdc\xf8\x88\xf8\xd4\xf7I\xf6,\xf5{\xf5Z\xf6k\xf6\xd5\xf5\xc5\xf5\x9d\xf6U\xf7\x1f\xf7\x90\xf6\x98\xf6\t\xf76\xf7\xe1\xf6\x8e\xf6\xc6\xf6O\xf7\xc4\xf7\xf1\xf7/\xf8\xd5\xf8\xc2\xf9\x86\xfa\xfe\xfak\xfb+\xfc\x14\xfd\xa9\xfd\xea\xfdE\xfe\xe3\xfek\xff\x9f\xff\xc0\xff%\x00\x9d\x00\xd7\x00\xe6\x00\x1d\x01\x8b\x01\xe6\x013\x02{\x02\xdc\x02P\x03\xba\x03\x00\x043\x04j\x04\xa0\x04\xb6\x04\xac\x04\x95\x04\x82\x04\x80\x04o\x04J\x04\'\x04\x1b\x04$\x042\x043\x046\x04?\x048\x04\x13\x04\xd5\x03\x91\x03F\x03\xe9\x02\x83\x02\x02\x02w\x01\xf0\x00y\x00\xfb\xffh\xff\xdf\xfeo\xfe\x16\xfe\xaa\xfd1\xfd\xc4\xfc}\xfcN\xfc\n\xfc\xc6\xfb\xb5\xfb\xc6\xfb\xc8\xfb\xb8\xfb\xe4\xfb;\xfcw\xfc\xa0\xfc\xef\xfc[\xfd\xa8\xfd\xbc\xfd\xef\xfd>\xfel\xfeo\xfeo\xfe\xa5\xfe\xd4\xfe\xdd\xfe\xf7\xfeK\xff\xb0\xff\x05\x00_\x00\xcb\x00M\x01\xb4\x01\x15\x02{\x02\xc6\x02\xf0\x02(\x03p\x03s\x034\x03\x18\x03;\x03A\x03\xfc\x02\xd3\x02 \x03\x9b\x03\xb3\x03d\x03G\x03\xa2\x03\xf4\x03\xb5\x037\x03,\x03q\x03F\x03\x9c\x02\x1d\x022\x02B\x02\x99\x01\xbe\x00.\x00\xeb\xffS\xffB\xfea\xfd\xd4\xfcg\xfc\xbe\xfb\xe9\xfao\xfa;\xfa\x1b\xfa\xe8\xf9\xa8\xf9\xa8\xf9\xd4\xf9\x01\xfa\x16\xfa\x1b\xfa]\xfa\xc5\xfa\x18\xfbJ\xfbw\xfb\xe6\xfbk\xfc\xc2\xfc\x02\xfdX\xfd\xe2\xfdi\xfe\xc0\xfe\x08\xffs\xff\x03\x00\x84\x00\xce\x00\x17\x01\x8d\x01\x01\x02E\x02E\x02\\\x02\xbb\x02\xe3\x02\xcb\x02\x80\x02w\x02\xab\x02\x88\x024\x02\xeb\x01\xe8\x01\x05\x02\xbc\x01S\x016\x01>\x01#\x01\xb8\x00Q\x00@\x00@\x00\x0e\x00\xc1\xff\x89\xfft\xffx\xffa\xff\x1e\xff\x0c\xff7\xffm\xffy\xffN\xffu\xff\xf0\xff>\x00`\x00\xcd\x00\xe1\x01"\x03\xdb\x03i\x04\x80\x05\xce\x06w\x07v\x07\xd5\x07\xbc\x08\xfd\x08L\x08\xc4\x07N\x08\xbc\x08\xf8\x07\xef\x06\x12\x07\xa8\x07\xf5\x06%\x05\x18\x04*\x04\x85\x03H\x01\x0e\xffK\xfe\t\xfe\xa5\xfc\x80\xfa\x85\xf9\xff\xf9=\xfaS\xf9n\xf8\xcd\xf8\xbc\xf9\xb1\xf9\xd3\xf8\x91\xf8C\xf9\xac\xf9*\xf9\x8d\xf8\xdd\xf8\x98\xf9\xda\xf9\xd4\xf94\xfa\x04\xfb\xb3\xfb\x10\xfcw\xfc\x12\xfd\x8c\xfd\xd4\xfd\x16\xfeT\xfer\xfe\x8f\xfe\xc0\xfe\xfd\xfe\x1e\xff8\xff\x85\xff\xe5\xff#\x00>\x00t\x00\xc1\x00\xd9\x00\xbf\x00\x9b\x00\xa4\x00\xb5\x00\x8d\x00@\x00&\x00M\x00q\x00P\x00F\x00\x97\x00\xed\x00\x0c\x01\t\x01=\x01\x9f\x01\xd1\x01\xcd\x01\xe2\x01"\x02c\x02y\x02\x81\x02\xad\x02\xeb\x02\x14\x03\x19\x03\x17\x03&\x03&\x03\x01\x03\xb7\x02o\x025\x02\xe7\x01\x87\x01#\x01\xd7\x00\x95\x00K\x00\xf2\xff\xb2\xff\x93\xffp\xff<\xff\x03\xff\xd3\xfe\xaa\xfeu\xfeH\xfe0\xfe\x18\xfe\x0c\xfe"\xfeF\xfeb\xfey\xfe\xb9\xfe\x07\xff2\xffV\xff\x92\xff\xcd\xff\xd0\xff\xb8\xff\xdb\xff%\x00)\x00\x01\x00+\x00\x98\x00\xcd\x00\x9c\x00\xb8\x00v\x01\x0c\x02\xd3\x01j\x01\xaf\x01H\x02$\x02`\x01\x17\x01u\x01{\x01\xa2\x00\xce\xff\xce\xff\x12\x00\xa4\xff\xc2\xfeb\xfe\x9e\xfe\xae\xfe=\xfe\xcf\xfd\xf0\xfd9\xfe(\xfe\xde\xfd\xe3\xfdI\xfel\xfej\xfe\x92\xfe\xda\xfe\x02\xff\x05\xff:\xffv\xff\x89\xff\x86\xff\xa3\xff\xc5\xff\xab\xff\x8a\xff\x89\xff\x99\xff\x97\xffv\xffY\xffV\xffK\xff7\xff3\xff?\xff?\xff\x1b\xff\x1c\xffB\xff9\xff\xff\xfe\xe6\xfe\'\xffT\xff"\xff\xd9\xfe\xe4\xfe(\xff \xff\xcb\xfe\xa2\xfe\xdd\xfe\t\xff\xd6\xfe\x98\xfe\xb4\xfe\x08\xff\n\xff\xe8\xfe\xec\xfe2\xffr\xff}\xff\xa8\xff\xee\xff*\x00R\x00\x92\x00\xff\x00V\x01p\x01\xc8\x01\x91\x02K\x03\xc2\x03E\x04U\x05\x8e\x06\x0e\x07\xfd\x06\x86\x07\xbd\x08K\t\xa7\x08\x18\x08\xb1\x08|\t\x08\t\x00\x08\xf2\x07\xb8\x08\xac\x08`\x07N\x06c\x06\\\x06\x0c\x05\x14\x03\xc3\x01?\x01U\x00\xb2\xfe\t\xfd\x16\xfc\xa9\xfb\xf9\xfa\xdf\xf9\xe8\xf8\x96\xf8\x96\xf83\xf8]\xf7\xbf\xf6\xa7\xf6\xb4\xf6d\xf6\xd6\xf5\xaa\xf5\xe6\xf5C\xf6p\xf6\x90\xf6\xff\xf6\xb8\xf7|\xf8\x03\xf9d\xf9\xe6\xf9\x92\xfa-\xfb\x82\xfb\xb6\xfb.\xfc\xdb\xfcg\xfd\xc2\xfd!\xfe\xc0\xfe\x82\xff\x1b\x00\x91\x00\x01\x01\x85\x01\x05\x02O\x02~\x02\xb6\x02\x04\x03E\x03P\x03R\x03\x82\x03\xd3\x03\xfd\x03\x06\x04"\x04f\x04\xa1\x04\xbd\x04\xdb\x04\xff\x04\x1e\x05!\x05 \x05#\x05\x16\x05\xfd\x04\xf0\x04\xe7\x04\xc8\x04\x90\x04l\x04g\x04Q\x04\x08\x04\xba\x03\x84\x03V\x03\xf5\x02\\\x02\xd3\x01o\x01\xfb\x00K\x00\x86\xff\xfa\xfe\x9d\xfe&\xfex\xfd\xda\xfc\x96\xfcW\xfc\xe8\xfbt\xfb;\xfb+\xfb\xe0\xfa\x8d\xfa\x97\xfa\xe1\xfa\xfa\xfa\xdc\xfa#\xfb\xc4\xfb \xfc\x0f\xfca\xfc\x82\xfd\x9b\xfe\xde\xfe\xde\xfe\x80\xff\x85\x00\xf4\x00\xe5\x00$\x01\xd5\x01t\x02\x86\x02_\x02\x9f\x02!\x03t\x03b\x03*\x03L\x03\x83\x03\x98\x03t\x03+\x03\x01\x03\xfe\x02\xe8\x02\xa9\x02[\x02\x08\x02\x0b\x02(\x02\xea\x01y\x01&\x01\x1e\x01\n\x01\x9c\x00"\x00\xe7\xff\xaa\xff?\xff\xc8\xfep\xfe5\xfe\xec\xfd\x9f\xfd_\xfd\x16\xfd\xb0\xfcV\xfc1\xfc+\xfc\xfa\xfb\xab\xfb\x9d\xfb\xc6\xfb\xcc\xfb\x98\xfb\x88\xfb\xdc\xfbC\xfct\xfcv\xfc\xa7\xfc\x1e\xfd\x8c\xfd\xc0\xfd\xe2\xfd9\xfe\xad\xfe\xf8\xfe\x0c\xff\x1f\xffc\xff\xaf\xff\xc7\xff\xb8\xff\xbd\xff\xfa\xff3\x00.\x00\x11\x00&\x00o\x00\xad\x00\x9e\x00\x94\x00\xe4\x00O\x01|\x01Q\x01r\x01\xfa\x01V\x02J\x02 \x02j\x02\xfa\x02\x1f\x03\xd3\x02\xc2\x02&\x03\x7f\x03Y\x03\x15\x03=\x03\xab\x03\xd4\x03\xbe\x03\xf2\x03n\x04\xc0\x04\xd8\x04\xf9\x04a\x05\x9a\x05x\x05u\x05\xb0\x05\xd2\x05\x91\x05;\x05F\x05w\x05\x1e\x05c\x04\xed\x03\xc4\x03b\x03\x81\x02o\x01\x9f\x00\x01\x00,\xff\t\xfe\xe3\xfc\x1e\xfc\xa0\xfb\xf4\xfa\x07\xfa@\xf9\xe7\xf8\xb8\xf8h\xf8\xea\xf7\xa5\xf7\xb6\xf7\xc7\xf7\xc3\xf7\xb7\xf7\xe1\xf7@\xf8\x94\xf8\xdb\xf84\xf9\xa8\xf9;\xfa\xda\xfak\xfb\xeb\xfbq\xfc\x13\xfd\xb7\xfd9\xfe\x9a\xfe\x13\xff\xa0\xff\xfd\xff3\x00w\x00\xd2\x00\x15\x01&\x011\x01\\\x01\x88\x01\x9d\x01\xa6\x01\xc3\x01\xe5\x01\xeb\x01\xdf\x01\xce\x01\xd1\x01\xd6\x01\xbc\x01\x96\x01\x87\x01\x98\x01\xaa\x01\x9e\x01\x9d\x01\xc1\x01\xe2\x01\xed\x01\xed\x01\x0b\x02A\x02[\x02I\x02@\x02S\x02l\x02j\x02V\x02\\\x02p\x02n\x02N\x025\x023\x02 \x02\xec\x01\xaa\x01{\x01H\x01\x01\x01\xb2\x00n\x00)\x00\xe0\xff\x9b\xfff\xff9\xff\xfd\xfe\xcb\xfe\xb1\xfe\x9e\xfe|\xfeW\xfeQ\xfe_\xfe`\xfeU\xfem\xfe\xa7\xfe\xd3\xfe\xd6\xfe\xe8\xfe9\xff\x8d\xff\xa3\xff\xa4\xff\xd7\xff2\x00\\\x00Y\x00~\x00\xd9\x00\x1f\x01\x1f\x01\x18\x01?\x01n\x01d\x01E\x01@\x01;\x01\x1b\x01\xe9\x00\xcf\x00\xaf\x00v\x00Q\x00D\x00\x1d\x00\xde\xff\xb5\xff\xb2\xff\xad\xff\x7f\xffU\xff]\xffv\xffo\xffH\xffC\xffo\xff\x99\xff\xa1\xff\x9f\xff\xbb\xff\xeb\xff\x05\x00\x08\x00\r\x00\'\x00D\x00D\x00,\x00\x1b\x00\x17\x00\x1b\x00\x06\x00\xd3\xff\xb1\xff\xa7\xff\xa5\xff\x94\xffw\xffh\xfft\xffz\xffa\xff<\xff4\xffP\xff_\xffW\xffR\xffw\xff\xa5\xff\xc0\xff\xd6\xff\xf1\xff!\x00Q\x00y\x00\x8c\x00\x96\x00\xae\x00\xd5\x00\xe1\x00\xc9\x00\xc2\x00\xdc\x00\xfb\x00\xf8\x00\xe4\x00\xf4\x00 \x01:\x01%\x01\x0f\x01\x1f\x012\x01\x1b\x01\xe6\x00\xbe\x00\xa9\x00\x9f\x00x\x00?\x00\x18\x00\x02\x00\xe2\xff\xa9\xffl\xff3\xff\xf5\xfe\xb9\xfet\xfe\x1e\xfe\xc2\xfdp\xfd"\xfd\xdb\xfc\x97\xfce\xfcA\xfc4\xfc9\xfc7\xfc5\xfcM\xfc\x89\xfc\xb8\xfc\xd7\xfc\x04\xfdM\xfd\xa0\xfd\xe1\xfd!\xfe\x80\xfe\xf5\xfec\xff\xb6\xff\x17\x00\x96\x00\x06\x01R\x01\x99\x01\xfb\x01J\x02p\x02\x8b\x02\xa3\x02\xad\x02\xa2\x02\x8d\x02\x84\x02o\x02F\x02\'\x02\n\x02\xd1\x01\x86\x01:\x01\xf5\x00\xaf\x00N\x00\xed\xff\xbc\xff\x8c\xffK\xff\x04\xff\xd7\xfe\xc9\xfe\xa9\xfe\x82\xfeo\xfev\xfe\x84\xfe\x8f\xfe\xa3\xfe\xbe\xfe\xe9\xfe5\xff\x90\xff\xe4\xff>\x00\xbc\x00R\x01\xdc\x01Z\x02\xf0\x02\x97\x03\x1e\x04\x85\x04\xeb\x04]\x05\xb6\x05\xdb\x05\xea\x05\x05\x06\x15\x06\xfb\x05\xbc\x05~\x05K\x05\xe3\x04L\x04\xb6\x032\x03\x9e\x02\xe0\x01\x1f\x01r\x00\xd1\xff$\xffr\xfe\xdd\xfdd\xfd\xf5\xfc\x9e\xfcJ\xfc\x00\xfc\xcd\xfb\xa5\xfb\x8e\xfbv\xfbY\xfbQ\xfbb\xfb}\xfb\x89\xfb\x93\xfb\xad\xfb\xdf\xfb\x0e\xfc*\xfcL\xfct\xfc\xa4\xfc\xd0\xfc\xfa\xfc#\xfdV\xfd\x89\xfd\xb2\xfd\xd8\xfd\xff\xfd+\xfeY\xfe\x7f\xfe\x9f\xfe\xcb\xfe\xf6\xfe&\xffR\xfft\xff\x9c\xff\xbf\xff\xe1\xff\x01\x00+\x00W\x00t\x00\x92\x00\xb0\x00\xd7\x00\xfc\x00\x1d\x01F\x01n\x01\x97\x01\xbd\x01\xe1\x01\x0b\x02,\x02J\x02i\x02\x8d\x02\x9e\x02\xa4\x02\xa6\x02\xa9\x02\xa4\x02\x92\x02w\x02h\x02U\x024\x02\x16\x02\xf5\x01\xd1\x01\xa6\x01w\x01H\x01\x0b\x01\xd9\x00\xb4\x00z\x00*\x00\xe9\xff\xc6\xff\xb1\xffr\xff$\xff\x1b\xff5\xff2\xff\xf9\xfe\xf0\xfeQ\xff\xb2\xff\xba\xff\xa0\xff\xc7\xff\x13\x00!\x00\xfb\xff\xef\xff\x00\x00\x0b\x00\xd2\xff|\xffP\xffB\xff\'\xff\xd9\xfe\x8a\xfex\xfey\xfed\xfe:\xfe&\xfe9\xfeO\xfeN\xfeD\xfeN\xfef\xfe\x8f\xfe\xb8\xfe\xd7\xfe\xfc\xfe+\xffo\xff\xa5\xff\xcc\xff\xed\xff\x1f\x00O\x00d\x00s\x00~\x00\x92\x00\xa2\x00\x9c\x00\x89\x00\x86\x00\x80\x00o\x00N\x006\x002\x00\x1a\x00\xfc\xff\xde\xff\xcb\xff\xb8\xff\x9d\xff\x83\xffu\xffx\xffu\xffn\xffk\xffr\xff\x84\xff\x86\xff\x8b\xff\x89\xff\x93\xff\x9e\xff\x9d\xff\x99\xff\x9a\xff\x9e\xff\xa1\xff\xa2\xff\xa7\xff\xb7\xff\xcc\xff\xd7\xff\xdf\xff\xed\xff\x07\x00.\x00T\x00s\x00\x9a\x00\xbf\x00\xde\x00\xf7\x00\x15\x01:\x01W\x01k\x01t\x01\x83\x01\x85\x01u\x01[\x01H\x01=\x01(\x01\x12\x01\xfb\x00\xe2\x00\xc2\x00\xaa\x00\xa9\x00\xc6\x00\xee\x00\x18\x01@\x01s\x01\xb6\x01\xff\x01I\x02\x9a\x02\x00\x03l\x03\xb5\x03\xd9\x03\xf5\x03\x1b\x04;\x04/\x04\x05\x04\xd9\x03\xa2\x03]\x03\xf2\x02|\x02\x12\x02\xa2\x01$\x01\x96\x00\x07\x00\x82\xff\xfd\xfem\xfe\xe1\xfd\\\xfd\xd9\xfcZ\xfc\xdd\xfbw\xfb\x1f\xfb\xc8\xfa\x7f\xfaH\xfa.\xfa\x1f\xfa\x1d\xfa0\xfaK\xfak\xfa\x92\xfa\xbf\xfa\xfc\xfa8\xfbs\xfb\xb5\xfb\xfa\xfbD\xfc\x8c\xfc\xd7\xfc%\xfdp\xfd\xc1\xfd\x0f\xfeZ\xfe\xa5\xfe\xed\xfe>\xff\x90\xff\xe4\xff,\x00|\x00\xca\x00\x0e\x01N\x01\x89\x01\xc7\x01\x04\x02@\x02n\x02\x8f\x02\xb3\x02\xd7\x02\xee\x02\xf7\x02\xfd\x02\x01\x03\x02\x03\xf6\x02\xe6\x02\xd7\x02\xc7\x02\xaf\x02\x96\x02x\x02[\x02C\x02$\x02\x03\x02\xe2\x01\xc7\x01\xab\x01\x86\x01c\x01H\x016\x01\x1f\x01\x07\x01\xf7\x00\xf1\x00\xe8\x00\xd5\x00\xc0\x00\xb3\x00\xa9\x00\x91\x00q\x00W\x00<\x00\x1f\x00\xf8\xff\xd3\xff\xb3\xff\x93\xffm\xffJ\xff.\xff\x13\xff\xf6\xfe\xd8\xfe\xbd\xfe\x9c\xfe\x7f\xfec\xfeJ\xfe5\xfe\x1d\xfe\r\xfe\x01\xfe\xfc\xfd\xf2\xfd\xf2\xfd\xff\xfd\x17\xfe4\xfeO\xfew\xfe\xa1\xfe\xca\xfe\xf1\xfe\x16\xffE\xfft\xff\x9f\xff\xc2\xff\xe4\xff\x0b\x007\x00\\\x00z\x00\x93\x00\xaf\x00\xc8\x00\xd4\x00\xda\x00\xe3\x00\xeb\x00\xec\x00\xde\x00\xd9\x00\xd5\x00\xcf\x00\xc2\x00\xaf\x00\x99\x00\x88\x00~\x00m\x00]\x00L\x00>\x003\x00&\x00\x1a\x00\x0b\x00\x07\x00\x06\x00\x04\x00\x01\x00\xfc\xff\x01\x00\x00\x00\xff\xff\x02\x00\x06\x00\r\x00\x15\x00\x17\x00\x1b\x00)\x00,\x000\x00/\x001\x00>\x00K\x00K\x00>\x00B\x00G\x00E\x00>\x004\x00-\x00-\x00+\x00\x1b\x00\r\x00\x02\x00\xfa\xff\xee\xff\xdc\xff\xd1\xff\xc3\xff\xb6\xff\xa9\xff\xa9\xff\xb6\xff\xc6\xff\xd7\xff\xea\xff\r\x009\x00b\x00\x95\x00\xd7\x00&\x01m\x01\xaa\x01\xe8\x01(\x02^\x02\x86\x02\xaf\x02\xcf\x02\xdf\x02\xda\x02\xca\x02\xbc\x02\x9e\x02d\x02"\x02\xe0\x01\x98\x01@\x01\xd6\x00i\x00\xfe\xff\x9b\xff0\xff\xbb\xfeQ\xfe\xee\xfd\x9f\xfdM\xfd\xfa\xfc\xb6\xfc\x86\xfce\xfcH\xfc0\xfc+\xfc9\xfcK\xfc`\xfc|\xfc\xa6\xfc\xd2\xfc\xfd\xfc.\xfda\xfd\x9a\xfd\xcb\xfd\xfb\xfd-\xfe\\\xfe\x95\xfe\xc2\xfe\xeb\xfe\x14\xff9\xffh\xff\x8c\xff\xb5\xff\xd3\xff\xf4\xff\x1f\x00D\x00d\x00\x87\x00\xb2\x00\xdf\x00\n\x01+\x01I\x01m\x01\x8f\x01\x9e\x01\xa7\x01\xb5\x01\xc2\x01\xc4\x01\xb6\x01\xb1\x01\xae\x01\xa6\x01\x99\x01\x86\x01p\x01\\\x01P\x01;\x01 \x01\x12\x01\x0c\x01\x04\x01\xf7\x00\xe8\x00\xdb\x00\xd7\x00\xcc\x00\xba\x00\xa3\x00\x8c\x00\x85\x00p\x00E\x00\x16\x00\xef\xff\xc1\xff\x8b\xff[\xff5\xff\x12\xff\xe5\xfe\xb6\xfe\x86\xfe`\xfeB\xfe4\xfe\x1c\xfe\x0f\xfe\x0c\xfe\x0f\xfe\x1d\xfe$\xfe.\xfe>\xfeN\xfei\xfe\x83\xfe\xa7\xfe\xd2\xfe\xf1\xfe\x1c\xffP\xff\x93\xff\xc5\xff\xf6\xff(\x00_\x00\x90\x00\xb4\x00\xee\x00(\x01J\x01[\x01m\x01\x80\x01u\x01z\x01\x8b\x01\x97\x01\xaa\x01\xc6\x01\xb8\x01\x86\x01J\x01/\x01%\x01\x14\x01\x0f\x01\xf3\x00\xc9\x00\xae\x00\x8e\x00N\x00\x0e\x00\xe2\xff\xdf\xff\xf9\xff\x06\x00\xf2\xff\xd8\xff\xca\xff\xbc\xff\xc8\xff\xd4\xff\xda\xff\xd5\xff\xc3\xff\xa4\xffb\xff"\xff\x10\xff\x19\xff\x1d\xff&\xffA\xff\x81\xff\xde\xff\\\x00\xf7\x00\xb4\x01\x90\x02`\x03\x17\x04\xc4\x04i\x05\xfb\x05a\x06\x89\x06\x95\x06\x8e\x06d\x06\x0b\x06\x96\x05\x12\x05y\x04\xca\x03\x14\x03d\x02\xbb\x01\x1a\x01k\x00\xaa\xff\xeb\xfe6\xfe~\xfd\xbb\xfc\x01\xfcb\xfb\xda\xfa\\\xfa\xf2\xf9\xbb\xf9\xb6\xf9\xc7\xf9\xf1\xf9E\xfa\xc1\xfaC\xfb\xca\xfbV\xfc\xe1\xfc]\xfd\xca\xfd"\xfeX\xfep\xfe}\xfe\x8d\xfe\x86\xfen\xfeN\xfe3\xfe \xfe\x04\xfe\xeb\xfd\xdf\xfd\xd6\xfd\xce\xfd\xca\xfd\xc9\xfd\xc4\xfd\xd3\xfd\xd8\xfd\xd3\xfd\xe4\xfd\xfa\xfd\x1b\xfe@\xfey\xfe\xc1\xfe\x12\xffl\xff\xcd\xff1\x00\x90\x00\xf1\x00N\x01\xa6\x01\xef\x01#\x02D\x02l\x02\x84\x02\x84\x02{\x02}\x02\x85\x02\x86\x02\x85\x02\x9b\x02\xda\x02\x1b\x039\x03M\x03\x82\x03\xbc\x03\xd4\x03\xc8\x03\xaf\x03p\x03\x1b\x03\xc9\x02~\x02\x03\x02e\x01\xcf\x00@\x00\xb3\xff&\xff\xb4\xfeO\xfe\xf9\xfd\xaa\xfdA\xfd\xe9\xfc\xb3\xfc\x98\xfcV\xfc\x03\xfc\xe9\xfb\xe4\xfb\xb7\xfb\x85\xfb\x8c\xfb\xd3\xfb\xf7\xfb!\xfc\x8f\xfc\xff\xfc3\xfdI\xfd\x8b\xfd\xe0\xfd\xec\xfd\xe9\xfd;\xfez\xfe]\xfer\xfe\xc2\xfe\xe1\xfe\t\xffX\xff~\xff\x81\xff\x9f\xff\xba\xff\x9a\xff\xc4\xff"\x009\x00,\x00\x88\x00\x07\x01\x15\x01\r\x01b\x01\xea\x01*\x02A\x02\x8b\x02\xe4\x02\x13\x03a\x03\xab\x03\x9b\x03m\x03I\x03I\x03L\x03B\x03^\x03h\x03\xdd\x02j\x02\x8e\x02\xb7\x02\x87\x02P\x02\xa0\x02P\x03\xfd\x03\xcf\x04"\x06\xdb\x07z\t\x90\n&\x0b\xed\x0b\xec\x0c}\r#\r\x81\x0c\x0c\x0cx\x0bC\n\xef\x08\x0b\x08B\x07\xe5\x05\xfb\x03\r\x026\x00R\xfe\\\xfcD\xfa\xfb\xf7\xde\xf57\xf4\xf0\xf2\xca\xf1%\xf1#\xf14\xf1\x11\xf1*\xf1\xe4\xf1\xc9\xf2\x89\xf3K\xf48\xf5\x0f\xf6\x1b\xf7\xb2\xf86\xfal\xfb\xdf\xfc\xc4\xfe:\x00\xe3\x00\xb8\x01\x07\x03\xd0\x03}\x03\xff\x02\x0f\x03\x13\x03s\x02\xc9\x01\x89\x01Y\x01\xb4\x00\xd7\xffG\xff\xe2\xfe6\xfeR\xfdx\xfc\xbd\xfb\x06\xfb\x8b\xfas\xfa\x96\xfa\xe7\xfaQ\xfb\xee\xfb\xb4\xfc\x9a\xfd\xb6\xfe\xc6\xff\xa3\x00F\x01\xe9\x01\xa2\x02r\x03_\x04*\x05\xaf\x05"\x06\x9a\x06\x0b\x07B\x079\x07\x1d\x07\xc1\x060\x06z\x05\xc1\x04;\x04\xbd\x033\x03\x84\x028\x02\xb9\x02\\\x03R\x03\xde\x02\x97\x02\x80\x02\xce\x01\xfd\x00\x8b\x00\x07\x00\x04\xff\xf4\xfdx\xfdT\xfd\x04\xfd\xc6\xfc\x9c\xfc*\xfc\xaa\xfb\x81\xfb\xac\xfb\x9a\xfb_\xfbs\xfb\x9d\xfb\x83\xfbY\xfb\xb7\xfbt\xfc\xd3\xfc\xa0\xfcl\xfc\x85\xfc\xb2\xfc\x8e\xfcm\xfc\x87\xfc\xbb\xfc\xb1\xfc\x97\xfc\x0f\xfd\xd0\xfdM\xfe;\xfe\x0e\xfe\xdc\xfdz\xfd\x14\xfd\xf0\xfc\xf3\xfc\xd2\xfc\x86\xfcV\xfcF\xfch\xfc\xb4\xfc\n\xfd9\xfd\x11\xfd\x1b\xfd5\xfd\x19\xfd\xd0\xfc]\xfd\x1a\xff\xb5\x00-\x01\x82\x01\xb0\x02\xe9\x03\xd9\x03h\x03\n\x04\xf9\x04\xe0\x04u\x04]\x05\x1b\x07\xfd\x07\x8d\x07\x03\x07\t\x073\x07\xcc\x07\xbb\t8\rc\x11\xff\x13y\x14|\x14Z\x15\x00\x16\x81\x14\xdb\x12C\x13\xd4\x13\xbd\x11\x00\x0f>\x0f\xf1\x0f\x93\x0c\x96\x06\xa0\x02=\x00\xce\xfb\x96\xf6X\xf4\x9a\xf3#\xf1\xc1\xed\xbe\xeca\xed\xae\xec\xed\xea\x8b\xe9\xe9\xe8\x8a\xe8\xe0\xe8\x1a\xea.\xecd\xef\xf2\xf2\x94\xf5\xc7\xf7\xe4\xfa,\xfe}\xff\xb5\xff8\x01s\x03n\x04\xd0\x04\xb1\x06\xed\x08\x1b\t\xee\x07C\x07\xbf\x06\xbd\x04\xd0\x01\x90\xff\x15\xfej\xfc\x94\xfaY\xf9\xa0\xf8\xce\xf7\xa1\xf6\\\xf5O\xf4\x82\xf3\x1a\xf3Q\xf3\xeb\xf3,\xf5\x07\xf7F\xf9W\xfb\xfc\xfc\xe5\xfe\xca\x00k\x02\x99\x03\x08\x05\xfe\x06\xea\x08Q\n\xe2\x0bY\ro\x0eJ\x0eK\x0e\x93\x0e\x19\x0e\xfb\x0c.\x0c\x10\x0b\x8b\tq\t\x8d\x0c\xc9\x0e\x87\x0c\x8c\x08\x83\x06\x80\x05n\x02\x17\x00\xc7\xff9\xffQ\xfc\x1e\xfa\xbb\xfa\xc8\xfb^\xfa7\xf7d\xf4\xad\xf2\x16\xf2\xfe\xf2\xd6\xf4[\xf6\xbf\xf6\xab\xf6+\xf7\xd4\xf7\xb0\xf8:\xf9\\\xf9\\\xf9\xc9\xf9\x93\xfb\x00\xfe\xbd\xff\xad\x00N\x00h\xff\xad\xfe)\xffm\x00\x9e\x00/\x00\xdd\xff\x95\xff.\xff\xea\xfeo\xff\x1e\xff\x84\xfd\x0f\xfc\xab\xfb\xd2\xfbT\xfb2\xfbS\xfbr\xfb\x93\xfa1\xfa$\xfb\xb2\xfc6\xfd&\xfc%\xfc\x1d\xfd\xc3\xfe\xb1\xff\xee\x00]\x03\xe8\x04\xb6\x05\x86\x06\xd1\x07d\x08\x98\x08K\t/\n=\ng\x0b5\x11\xbb\x19\xdf\x1d\x92\x1a\x96\x15\x9d\x15\xe5\x17\x1c\x17\xd1\x15|\x19^\x1d\xf9\x19!\x13\xba\x12\xe0\x15\xde\x10\xc8\x05\x18\xff\xf8\xfe\x03\xfd\xb4\xf8\x15\xf9b\xfbn\xf7V\xee>\xe9\x02\xe9\xec\xe7\x8c\xe5\xed\xe45\xe6\x86\xe7m\xe9\x86\xec\xc8\xee1\xefK\xef\x06\xef\x1e\xf0\x86\xf3v\xf9\x13\xfe+\x00)\x022\x04O\x05\x1a\x05\xc6\x046\x05R\x05\x04\x05\xf1\x04\xfd\x05\xaf\x07\xc7\x06"\x03I\xff9\xfdu\xfbD\xf8\xb7\xf6\xea\xf6X\xf6X\xf4\x8a\xf3\xb5\xf4\xdd\xf4\x14\xf3\x80\xf1\x8e\xf1G\xf2\xaa\xf3\xbe\xf6\xe7\xf9T\xfc_\xfdC\xffn\x00\xde\x01\x8a\x03\xb4\x054\x07\x9f\x08\x9a\n\xce\x0cZ\x0eX\x0fl\x0fN\x0e%\x0eX\x0e\xfc\x0eN\x0e\x1d\x0eG\r\n\r\xfc\r!\x0f\x89\r1\t\xf9\x05K\x03\x0e\x01Y\xffe\xff\xaa\xfe\xeb\xfb$\xf9?\xf7(\xf6N\xf4z\xf2\xe2\xf0\t\xf0}\xf1\xab\xf2\xa9\xf3e\xf4\xd7\xf4\xc6\xf4\xa9\xf3\xe3\xf4\xa5\xf6\x8a\xf8}\xf9i\xfa\x16\xfc\x0e\xfdW\xfe\xde\xfe\xaf\xff@\x00L\x00\xcc\x00\x06\x02\xf0\x03~\x05F\x05\xcb\x03!\x03\x19\x02E\x01\xed\x01b\x03\xb0\x01\xb7\xff\xa4\xff\x92\xfeS\xfdA\xfe\x15\xfeB\xfb\x1b\xfb\xbb\xfc\x83\xfd\xdd\xfc\x06\x00O\x02\x95\xff\x02\xff\xc8\x03F\x05M\x04?\x05\xe6\x07\xc3\x07\xde\x06l\x0bI\x0c[\n\x0e\n+\x0c\r\x0b\x1f\n\x18\x0bi\n\r\n\xb4\t\xf1\n\x8b\t\t\nJ\x0bE\x0cn\x0b\xa4\t\x1b\n\xe1\to\t\x0c\tw\x08\xcc\x08\x1f\x08X\x06i\x05^\x04l\x02\xec\xffI\xfe\x9f\xfdN\xfcg\xfb\xdd\xfa\xa3\xf9\x84\xf8w\xf7\x85\xf6\x15\xf5\xd3\xf4\x8f\xf4N\xf4\xcc\xf4V\xf5M\xf6f\xf5\xce\xf5\xa3\xf6R\xf7\xb6\xf7^\xf8\xdc\xf9\xf1\xfa\xd1\xfbY\xfc~\xfd\x15\xfe4\xfe@\xfea\xfe\x83\xfe\x91\xfe\xd8\xfe\x03\xff!\xffP\xfe\xa4\xfd\xee\xfcg\xfc4\xfc;\xfb\xbc\xfa\xad\xfa#\xfa\x87\xfa\xf6\xf9\x14\xfa\xfc\xf9\xbe\xf9\x1c\xfaj\xfa\x8c\xfb\x18\xfc\xb1\xfc\xae\xfd\x13\xfeR\x00\x9d\x01{\x03@\x06\x98\x06\xff\x07Z\x075\t2\tw\n\xf0\n\x04\n\x87\n\xa7\x078\x08\x13\x06\xf9\x04\xfd\x02\x06\x01\xb4\xff\x8a\xfe\xbe\xfe\x05\xfd\xb7\xfd\xb6\xfbI\xfa\x87\xf9\xb0\xf9\xe2\xf9\xbb\xf9P\xfa\xb7\xfa\xe4\xf9\xf7\xfa\xfe\xfaP\xfc\xdc\xfc\xd0\xfb\x01\xfe\xfc\xfc\xe0\xfc\n\x00\xde\x00\x1b\xfej\xff\xd6\x01\x92\xff\xeb\x00?\x02:\x02\x12\xff\xf0\x00\xcc\x03\x8d\xff\xd2\xff\x06\x04\x80\x03\x0c\xfd\xcd\xff~\x01D\xffk\x01\xef\x03\x1f\x00\x0b\xffe\x02\xfe\x01\x85\xffc\x02\xbc\x03\xdc\x00\xd7\x01\x84\x02\x97\x02\x92\x01\x0b\x04\xb4\x00\xd4\x00\xeb\x013\x01\xa1\x01f\x02\xf7\x01\x9b\xfe\x89\x00\xee\x00\xab\x00\xc5\x01\xd9\x01L\x00?\x02G\x00\x07\x02\xbd\x02:\x01\xff\x011\x01\x92\x03G\x01\x0c\x031\x04G\x01f\x03\xe5\x018\x02a\x02f\x00\xe4\x01,\x00\xe0\x00]\x00\xa2\xff\x04\xfe5\xffC\xff\x1a\xfd\xfa\xfd\xfd\xfcc\xfe0\xfd\xa5\xfd\r\xfe,\xfd\xa1\xfd\x86\xfe\x92\xfe\xd0\xfen\x00.\x00j\xffr\x00\xf8\x01j\x00[\x02\x85\x02\x9d\x01\xbd\x02~\x03\xea\x01\xe5\x01\x99\x03 \x01$\x01S\x02\x96\x01\x9a\x00&\x00\xeb\x00D\x00\xed\xfe\x99\xff\x08\xfe\xf4\xfe"\xfe\xf8\xfd\xe5\xfdn\xfdh\xfd\xe2\xfc\xdf\xfe\x8f\xfbY\xfd\xc4\xfd\x85\xfb\xd7\xfc\x82\xfd\x96\xfc\xf1\xfcE\xfe\x1f\xfd(\xfd\x05\xfe\xce\xfd\xf1\xfdM\xfe\xd9\xfex\xff\x0e\xffZ\xff\x1e\xff\x12\x010\xff\x8a\xff\xda\x00 \x001\x00E\x00\xcd\x00\xcf\xff\x07\x01\xf8\x00\xf5\x00\xa4\x00\xc7\xff\x9c\x00\xe4\xff\xfc\xfe\xbc\x00A\x00k\xff=\xff\xa8\x00D\xffP\xff\xe5\xff:\xfe6\x00\xca\xff\xd9\xff\xa0\x00\xe7\x00X\x00%\x00\xad\x00\x10\x01{\x00\xba\x01\x9e\x01\xea\x00\xb6\x01\xaa\x01\xb3\x01\xa0\x00\x03\x01\t\x01\xc1\x00V\x00\xb0\x02\xb2\xff\xb5\xff\xfd\x01\xe0\xfe\xab\xff@\x00\xfa\xff\xd0\xfe\xd4\xff!\x00\xeb\xfe\xcb\xff\x9c\xff?\xff\xa8\xfe3\x00i\xffJ\xff\xf1\xff\x8d\xff\x88\xff{\xff\xa0\x00>\xff-\x00\x0b\x00\x82\xff=\x00\xdf\x00X\x00\xbd\xffp\x00\x1b\x01[\x00\x9b\x01H\x01\xd3\x00\x86\x01\x82\x00\xca\x01q\x01v\x00\x10\x01\xca\x00\xa8\x00R\x01\xba\x00i\x01m\x00\xea\xff\x9e\x00f\x00\xef\x00L\xff\x9c\xff5\xff\xc2\xfe\x1b\x01\x19\xfe\x98\xff\xdb\xff\xd5\xfdR\xff\xd9\xfe6\xff&\xfe\x9f\x00\xd8\xfe\xdb\xfe \x00I\xff\xc6\xffo\xff\x83\x00\x07\x00@\x00\xc9\xff\xb3\x00\x97\x00\x87\x00\x89\x01\x9a\x00\xde\x00\x8c\xff/\x02\x92\x00L\x00\x17\x01H\x00\x98\x00x\xffi\x01\x10\x00\x91\xff\xdc\xff\xcc\xff\x9f\xff\x96\xff\x84\xff>\xff\x90\xfe\xd0\xff#\xff0\xff\x80\xff\x0c\xff;\xff\xc2\xfe\xd7\xffp\xfe\xc6\xff\xc2\xfe\xcb\xffF\xffn\xff\xf3\xff\xa7\xff|\xff]\xff\xc3\xffy\xff\x1a\x00\x99\xffq\x00\x7f\xff_\x00_\x00\xa7\xff\xff\xffu\x00\xce\xff\xdb\xff\xdc\xff\xbd\xff\x9c\xff~\xff\xb1\xff\x18\xff\xc4\xffX\xfe\x1f\xff%\xff\x17\xffM\xff^\xff\xda\xfeY\xff^\xff\x80\xff%\xff\x9e\xff\xe7\xff\x00\xffm\x009\x00+\x00X\x00\xda\x00\xb8\x00\xf1\x00\x9c\x00]\x01\xe1\x00\xa3\x01\x19\x01\xd8\x01P\x01\xfa\x00\\\x02d\x00\xb7\x01\x94\x00\x0c\x01g\x00 \x00\x13\x00Q\x00\xc6\xff}\xff\xca\xff\x0f\xff7\xff\x1b\xff#\xff\xd9\xfe\x04\xff\xdc\xfe?\xff\xa9\xfel\xff*\xff\x81\xff\xb3\xfex\x00\xc7\xfeS\xff#\x01X\xffe\x00)\x00-\x01\x05\x00W\x01D\x00<\x01\x9d\x00\xec\x002\x01\xbc\x00\x96\x01\xe2\x00n\x01\xb1\x00\xe4\x00\x8d\x00\x08\x01/\x00\xc6\x00\x89\x00_\x005\x00\x12\x00\x0f\x00\x8a\xff\xda\xffy\xff\x89\xff[\xffY\xff\xb6\xffq\xff\x16\xff\xa0\xff\x08\xffI\xffo\xff\xa0\xff\x85\xff\xad\xff\xc2\xff\x00\x00\x08\x00\x1d\x00\x92\x00`\x00\xb1\x00\xb5\x00?\x016\x01\xfb\x001\x01\x06\x01\x1e\x01;\x018\x01c\x01\xbb\x00\xd7\x00\xd7\x00s\x00\xed\xff3\x00\'\x00)\xff\xe2\xff1\xff\x17\xff\x0b\xff\xb4\xfe\xe9\xfe2\xfe\xc8\xfe\xb1\xfe$\xfe\xd3\xfe\x9f\xfe\xa9\xfe\xe9\xfe\xec\xfe\r\xffy\xffO\xffs\xff\xdb\xffM\x00N\x00j\x00\xa9\x00\xa1\x00B\x01\xa2\x00\xbd\x00_\x01\xeb\x00\x15\x01P\x01\xcf\x00\x9c\x00\xdd\x00\xdb\xffh\x00\xdc\xffr\xff\x06\x00\\\xff\x1b\xff\x93\xff\xc8\xfe`\xfe\x9e\xfeT\xfe\xbf\xfe\xf5\xfd\xd9\xfe\x84\xfe\n\xfe\x1d\xff\xc1\xfe\xae\xfe\xda\xfeQ\xff\xf2\xfej\xff\xb9\xff\x1a\x00e\x00\x0f\x00\x9a\x00\x10\x01\x85\x00\xeb\x00\'\x01\xe1\x00z\x010\x01\xa6\x01\xb9\x00\x8e\x01_\x01\xda\x00\xfc\x00\xc7\x00\xb2\x00O\x00\xcd\x00\'\x00<\x00P\x00d\xff\xd2\xff\xb8\xff&\xff`\xff\x1a\xff\xf8\xfe\x15\xff\x08\xff\xaa\xfe\xdf\xfe\xea\xfef\xffy\xfe%\xff\x92\xfea\xffo\xff\x92\xfe\x00\x00\x14\xffx\x00\xda\xffZ\x00[\x00\xac\xff\xf3\xff\x02\x00\x00\x01*\x00`\x00\xa5\x00\x9c\x00\x06\x00\x83\x00\xd8\xff\xbc\xff\x17\x00\n\x00R\x00W\xff\x99\x00\x88\xff\x88\xff\xa2\xff{\xff\xdb\xff\xdb\xffk\x00\x86\xff\x1b\x00\xd6\xff$\x00\xea\xff\xfc\xff\xfe\xff\xa1\x00\xa3\x00&\x00K\x01|\x00\x94\x00\x94\x00\xb4\x00\xd1\x00\xce\x00\x05\x01L\x01\xfa\x00\xa1\x00\xee\x00\xdf\x00h\x00\x8f\x00\xa6\x00\xc7\x00\xd3\x00\x80\xff\x92\x00\x16\x00\xd1\xff\xb7\xffA\x00\x0c\xff\x0c\xff\xb3\xff\x0e\xff\x81\xff\xc9\xfeN\xff\xc6\xfex\xff\xa4\xfez\xff\xf3\xfe\xf5\xfe\x8f\xff\xa2\xfe\xbc\xff+\xff\xdf\xff\xce\xff\xdc\xff\x16\x00\xb6\xff\xd2\xff\r\x014\x00\xe5\x00\xea\x00\xac\x00\xa5\x01\xb2\x00\x8a\x01m\x01\x04\x01\xa1\x01\xce\x01\xcf\x00\x83\x02\x10\x02e\x000\x01#\x01\xd5\x00\xa3\x00\x87\x00\x03\x00\x13\x00l\xff\'\xffX\xff\x9a\xfe\xa6\xfeg\xfe\x0f\xfek\xfe9\xfe{\xfeV\xfe\x8f\xfeT\xfe\xa2\xfe\x13\xff\xf1\xfe\x03\xff\x01\xff\xa1\xff\xed\xff\x02\x00}\x00Z\x00@\x00\xaa\x00\xcc\x00D\x01V\x01p\x01\xd7\x00\xb4\x01\x13\x01H\x01\xa4\x01\xa1\x00\xce\x00$\x01\x8a\x00\xa9\x00\n\x01\xae\xff\x10\xff8\x01\x18\xffF\xfe\xe9\x00\x91\xfe>\xff\xcf\xfeG\xff\xa8\xff\xe0\xfe\xa2\xfeu\xffo\xfeG\xff\x0b\x00\x1d\xff\xec\xffi\xff\xd5\xff\xaa\xff\xd6\xff*\x01\xde\xff\xcb\xff\'\x01\x0e\x00\xad\x00\xed\x00\x10\x01\x00\x00\xbf\x00\xf5\x00\xf9\xff@\x01\xc1\xff\xb8\x00\x00\x00\x7f\xffM\x00\x83\xff`\xff\xe4\xff=\xff\xb2\xfe\x1b\x00W\xfe\xc3\xfe~\xff\xcc\xfen\xfe\\\xff\x1b\xff\xa2\xfe\x03\xff\x15\xffJ\xff\xe3\xfe\xa7\x00\xa2\xfe\x00\x00\xcf\xff@\xff8\x00\x12\x00b\x00(\x00\x19\x01\xb5\xff~\x00\xde\x00\xf8\x00d\x00:\x01N\x00+\x01S\x01Q\x01\x12\x01\x9f\x00k\x01\x06\x00\xbd\x00#\x01<\x001\x00\x95\x00\xe7\xff\n\x01\x04\x00\x19\xff\xbc\xff\xb5\xff\x81\xff\xa4\xff5\x00P\xff&\x00\r\x00e\xfe\x98\x00M\xfe\xc8\xffl\x000\xffe\x005\xffL\x00u\xff6\x00O\xff\xc4\xff\x13\x00\x11\x00\xd8\xff\x8d\xff$\x00\xda\xff:\x00]\xfeK\x00\x1b\x00\xc3\xff\xcd\xff\xb4\xff\x17\xff+\xff_\x01\xaa\xffe\xff\xb0\xff\x1e\x00V\xff\xa4\x00\xc4\x00\x12\xffi\x00}\xffc\x00\xa9\xff(\xff\xe4\xff\x00\x00[\x00{\xffT\x00\x95\xfet\xff\xd6\xff<\x00\xb4\x00s\xff\x13\x01#\xfe\xce\x01R\xffZ\x00\xcd\x01E\xff\x05\x01\x83\xff\xc1\x01\xce\xff\xb8\x01\xc9\x008\xff\xed\x00\xc4\x00\xdb\xfe\xe1\x008\xff\x12\x01\xd8\x00t\xff\xa2\x01\xcc\xfch\x01y\x00S\xfe\r\x00\xa4\x00\xfa\xff\xe6\xff\x11\x01=\xfd\\\xfe\x8f\x00s\xff\x85\x00\xeb\x00\xcc\xff-\xff\x96\xff@\x00\xfc\xfe\xb5\x019\xfe\xa8\x02\xa8\x00\xc6\xff\xa0\x00\xb0\xfa\xd6\x01i\x01g\x01\xd6\x00M\xff\x18\xff>\xfe\xb2\x00\xfd\xfe2\x01\x13\x00\x0e\xfe\xbc\x00\x15\xfd\xcf\x034\xfe \xfa\x92\x04X\xfd\xaa\xfe\x89\x00=\x01\x8b\xfd\xb7\x00\x86\x01\xef\xfd\xce\x01*\xff\xb1\xff3\x02\x96\x00\x95\x01\x0e\x01\x90\xffL\x02\xba\x009\xffM\x01\xc3\x01d\x03%\xff\x92\xfe\x92\x04\x18\xfd#\x05i\x00\x8f\xfb\xb3\x03\xf9\xfbi\x05L\xfd\x01\x02\x95\x00*\xf9\xb4\x02u\x01(\xff\xd8\xfe\x9d\xfd\xfc\xffq\x02D\xfc\x0c\x00E\x01\x1a\x03e\xfbA\xffA\x02g\xfd\x92\x03\x8f\x00\x81\xfd\xae\x00\xdf\xfe=\xfec\x02\x81\x00\x95\xffr\xfc\xc7\xfe\xa4\x02t\xff\xde\xfd\x90\xfe\x0e\x01\x1c\xfdv\xfe\x9a\x04,\xfe\xdc\x00\x9e\xfb\xeb\x01\x83\xfe\x16\xff\\\x04\x88\xfb\xdc\x03\x1c\x00\x08\x00X\xfdf\x01\x1e\x00P\xfe\xef\xfeE\x05\xfd\xff@\xfe\t\x01\x96\xfc\xe9\x00\x1b\xfe~\x01g\xff-\xfe\xc1\x01\x08\x02\xde\xfc\x17\x03\xbf\xfe\x07\x00\x03\x01\xae\xfd\x1c\x02t\xfe]\x02\x19\x02\xd6\xff\xaa\xff\xb2\xfe\xf6\xff$\x01\x80\xff\x9b\xffS\xfc\x1c\xff\xad\x00\xa5\xfdL\x02\xb6\xffb\xfdZ\x02\x94\xfe\xc5\xfd\x7f\xfd\xaa\xf7\x9c\x00\xac\x10\x02\x03\x97\x00#\xfe\xf9\xfb\xa6\xffy\x02\x1f\x03N\xff\xd0\x06Y\xfd\x8e\x01\xda\x0bn\x047\xf5&\xf7\xff\xfa1\xfd\x8b\x03}\x00\xda\xfeR\xffC\xfc\t\xf8i\xfc\x7f\x02\xe5\xfb$\xfe\xb6\xffL\x03u\x03\xeb\xfe\xa4\x05\x9e\xfdP\x00\xae\x03 \x02\xd0\x03\xbb\x07\x14\x02\xa9\xff\xc4\x07a\x01\x84\xfb?\x02\x10\x06\x11\x00\xa8\xfd\xaa\x002\xfcM\xfc\xf3\x02\x08\xfds\xfb\xc8\xfa\xed\xfc\xb8\xfb\xf2\x03\x04\x00b\xf9\xf3\xfe\xf7\xf9\x8c\x00`\x00n\x00\x85\x01\xfb\x02\xf2\xfc0\xfeU\xfe\xca\xff\xc1\x04z\xfe\x1f\x01\x9a\xfd\xa5\xfdZ\xffB\x01\r\x01X\xfc\xa0\xff\x10\x00)\xfdU\x01\x8d\x00\xae\xff\xb3\x00\xec\xfe\xf0\xfe\xc7\x00$\x04"\x01\x16\x01\xfb\xffA\x01?\x03s\x02A\x01C\x01\xcc\x01\x89\x01]\xff\x15\x01\xe1\x03%\x01\x1e\xff|\xff\x88\x00\x14\xff\x1e\x01\x9c\x01\xea\xff\xbc\x00\xc2\x00\xdb\xfe\x90\xff\xba\x01\x1a\x01\xe9\x00\x8e\xff\x9b\x00\xea\x01~\x01\xb9\x00\x82\xff\xe1\xffk\x00\xd1\x00\x93\x01\xcc\x01\x9e\x01Z\xff\\\x00v\x00i\x01\x9e\x00\xdb\xff \x01:\x00\xbd\x01w\x01\r\x00\xd4\xfe=\xfe\xeb\xfe \xfe\x14\xfe\x96\xff\xd4\xffS\x00I\xff\xac\xfb\x9b\xfb\x90\xfc\x1d\xfe<\xfd\xd0\xfe\xb9\x00\x11\x00,\xff\x03\xfdu\xfcr\xfb=\xfd\xa5\xfdg\x001\x03m\xfd\xe4\xfb\xf2\xff[\xff\x03\xf9]\xf9\xb4\xfa\x1d\xfd6\xfe[\xfc\x0e\xfe\xcb\xfc\xa7\xf9\xcc\xf6}\xf9\xff\xfc\xa6\xf9\xa0\xf9\xf6\xfbu\xfe\xfd\x00\x89\xfe*\xfbY\xf8\xb0\xfa\xdd\xfd\xb5\xff\xf2\xffl\x00.\xffp\xfc\xbc\xf9\xa0\xf9\x8b\x00\xfa\x07\xfa\x15<\x17\x96\x10\xd9\x0c\x9f\x0f \x18\xc4\x17\xa7\x17\xd7\x1c\xcf!\xef \x92\x1e\xc4\x1f\x19\x1e\x92\x13\\\ti\x07$\x0c\x03\x0c\xdd\x07\x86\x03\xb1\xfd\xc0\xf7\xa3\xf0\x9c\xec\xb4\xeb\xe4\xe8~\xe6 \xe5\n\xe7)\xea\x82\xe9\xac\xe61\xe5\xc9\xe6\xc2\xe8Q\xec:\xf3\xae\xfa~\xfd\xf3\xfb\x8d\xfc*\x00\xf4\x02\xf8\x03]\x04\x05\x08v\n+\x0bd\x0b\xdc\x0b\x0e\x0bj\x04e\xff=\xfd\xac\xfd\xe5\xfe\x83\xfdx\xfbI\xf9\x86\xf3\xf4\xee7\xee\xee\xeeM\xf1\xa4\xf0{\xf0\x86\xf2\xc9\xf4`\xf6q\xf7u\xfa\xc1\xfcK\xff\x04\x023\x06\xd9\x0b\x14\x0e \x0f \x0f\x06\x10\xe3\x11\xfe\x12\x19\x13\xcd\x12\xb9\x12\x92\x10\xa8\x0eG\r\x83\x0b\x90\x08j\x04\x14\x01\x00\x00\xe2\xfe\xa6\xfcg\xfa\xf5\xf7\x92\xf4\x99\xf1\xc2\xf0w\xf08\xf1=\xf2m\xf1\xee\xef\xa2\xef\x82\xef\x81\xf0]\xf2\xfe\xf4\xdb\xf6\t\xf7\x8a\xf8\xab\xf9\xc8\xf9\xae\xfa\x04\xfb\xaf\xfb \xfd\xb1\xfd\xb2\xfe\x88\xfeb\xfe4\xfc9\xf9D\xf8d\xf7\xcc\xf6\x17\xf7\xed\xf8\xf1\xf7\x1b\xf6i\xf5\xc0\xf5\xda\xf4\xd4\xf1\xe7\xf7\xa0\x0c+$"-\x15%\x15\x1b\xe1\x1a\xc4\x1fM%F1DDWN\xa5E\xce5%-_*\xaa \xa4\x14&\x14o\x1a\'\x1bh\x10\xf4\x05O\xfd\x94\xeeI\xdc\x18\xd1\x04\xd4`\xdcy\xe1\x05\xe0i\xdc\x15\xd9b\xd2P\xccn\xcd\x9e\xd7Z\xe4\x04\xed\xa1\xf5\x8f\xfe>\x03\n\xffO\xf9_\xfc\xf8\x05\xf7\x0e\x82\x14#\x19\xac\x1cT\x1a\x8b\x11\x14\n\x0c\x07\xe8\x05\xd8\x02\xc4\xff\x18\x00\xc6\x00\xdc\xfb{\xf2\x19\xea{\xe4\x8f\xdf)\xddJ\xe0\x16\xe8@\xed\xa7\xeb\x99\xe8E\xe6x\xe6\xd9\xe7\xd0\xed8\xf83\x02\xb8\x07$\t9\n\xeb\n\x9c\x0b7\r\x02\x11>\x18\x91\x1d\xfc\x1f\xe5!\xd0 \xdd\x1a\xd3\x11\xed\x0e\x9e\x16\xfd\x1e\xf7\x1e\xad\x19\x9d\x12>\nz\x00\x8c\xfc\x85\x00+\x05d\x03\xfa\xfc\xc4\xf7\x9d\xf2\\\xec\x08\xe9W\xea\xaf\xec\xa7\xec\x16\xebF\xec\xdd\xedx\xed\xea\xeaW\xe8\xbb\xe7\x0c\xea^\xef\x13\xf5\xf0\xf8\xa1\xf8s\xf5-\xf3V\xf2f\xf5\xb5\xfb0\x00\xdd\x02\xd0\x02\x12\x01\xcd\xfdg\xfb\x96\xfb\x93\xfer\x01\r\x02\xea\x02\x9b\x01)\x00\xd3\xfeq\xfe@\xfe\xe0\xfc\x98\xfc\xf0\xfc\x05\x00)\x05\x9d\x0c\xb0\x11\xa6\x10A\x0e}\x0c\xcd\r|\x12\xa2\x1al&\x7f-;,P&4!\x94\x1e~\x1dA \xb8%\xe1(\xe8$L\x1c\xb7\x14\xab\r9\x06S\x00\x9f\xfe\xa3\xff\x98\xfd\x82\xf8\xaf\xf3$\xee|\xe6p\xde"\xdc\x13\xe0\xa7\xe4N\xe6o\xe6:\xe6\x00\xe3\xbb\xde&\xde{\xe4\xff\xec\x97\xf2\xb8\xf5K\xf7\xc9\xf6\xd6\xf4\x80\xf4*\xf8\x9d\xfd\xca\x014\x04I\x05$\x04Z\x01\xca\xfeu\xfe\xce\xff\x82\x01\xfe\x02W\x03\xf0\x01]\xff\xd0\xfc7\xfb\xdf\xf9D\xfab\xfb\xc8\xfc\x0c\xfd\x85\xfc\xef\xfb)\xfb\xcc\xfaB\xfb\x94\xfdM\x00w\x02\n\x03\x02\x03g\x03X\x04\x9a\x06r\tK\x0c,\r\'\x0c,\nP\t.\x0b\x84\r\xc1\x0fx\x10<\x0e\xa3\n\xea\x05\x16\x04\x87\x04\xd0\x04{\x05\xd8\x04\xe6\x02\xb3\xfdf\xf9\xd3\xf7\x8f\xf7c\xf7\x15\xf82\xfau\xf9\xc3\xf5E\xf3\x9f\xf3\xa1\xf4\xcb\xf4\x03\xf60\xf8\x90\xf8\x19\xf7-\xf6\x88\xf6.\xf7\x85\xf7\x04\xf9\x02\xfb~\xfb\xb4\xfa\x02\xfat\xfa\xff\xfa\xb0\xfb\xee\xfc\x83\xfe\x08\xff\xd5\xfe\xba\xfe#\xffQ\x007\x01+\x02\xe3\x02]\x03t\x04U\x05D\x06\x92\x07\x86\x08\xcd\x08\xd6\x07\xbf\x07c\t\xb9\np\x0bU\x0b\xdc\nn\n&\tK\x086\x08\xba\x08\xa0\x08\x05\x08\x04\x07\xce\x05\x1f\x05?\x04X\x04\xdb\x04i\x05\x84\x05\xaa\x04(\x04\x13\x04P\x04\xad\x04\xb7\x05\xb1\x06\xb5\x06\xf2\x05\xe0\x04\xc8\x04\xdd\x04\xa6\x04\xd6\x04\xab\x04\xd0\x03]\x02\xc5\x00\xf4\xff\x08\xff\xd6\xfd\x96\xfc\x85\xfbe\xfa\xb8\xf8\xfb\xf6\xb7\xf5;\xf5\xe0\xf4w\xf4!\xf4\xf8\xf3\x14\xf4K\xf4\x9a\xf4i\xf5\x99\xf6\xb5\xf7\xc3\xf8\xe9\xf9m\xfb\xc7\xfc\xb9\xfd\xbd\xfe\x14\x00X\x01!\x02\x98\x02\x17\x03\x9a\x03\xe6\x03\xf8\x03$\x04D\x04\xe8\x03U\x03\xed\x02m\x02\xcf\x01L\x01\x16\x01\x8c\x00\xbc\xff1\xff\xdd\xfe?\xfe\x98\xfd~\xfd\xa1\xfd{\xfdG\xfdO\xfdS\xfd8\xfdC\xfd\xaf\xfd!\xfem\xfe\x94\xfe\xa1\xfe\xb2\xfe\xce\xfe\x04\xff.\xffB\xff7\xff\x06\xff\xd1\xfe\xc4\xfe\xb5\xfe\xa1\xfee\xfe\x19\xfe\xbc\xfdw\xfd\x91\xfd\xfd\xfd\x1f\xfe\xee\xfd\xe2\xfd\xf4\xfd\xee\xfd\x18\xfe\x9e\xfe+\xffK\xff%\xffS\xff\xa6\xff\xe8\xff+\x00\x87\x00\xca\x00\xda\x00\xfd\x00O\x01\xb6\x01\xee\x01\xe7\x01\xf3\x01!\x02K\x02[\x02i\x02}\x02q\x02I\x02\x0b\x02\x04\x02\xfd\x01\xe2\x01\xca\x01\x98\x01|\x01l\x01T\x01S\x01n\x01\xa4\x01\xa0\x01\x98\x01\x96\x01\xc5\x01\xf9\x01*\x02e\x02\x83\x02\x80\x02G\x021\x020\x02(\x02\x05\x02\xbc\x01~\x01@\x01\xed\x00\x91\x00B\x00\xe3\xffv\xff\xe5\xfem\xfe1\xfe\x13\xfe\xe3\xfd\x8c\xfd&\xfd\xc8\xfc\xa9\xfc\xbe\xfc\xfc\xfc=\xfdr\xfd\x90\xfd\x97\xfd\xaa\xfd\xf7\xfd\x82\xfe*\xff\xc3\xff:\x00\x99\x00\xe6\x004\x01\x98\x01\x18\x02\x8e\x02\xe7\x02.\x03n\x03\x87\x03z\x03B\x03\x10\x03\xd7\x02\xb7\x02\x8c\x02T\x02%\x02\xdb\x01Y\x01\xa4\x00\xfe\xff\xa3\xff\x80\xffn\xffF\xff\xe3\xfe_\xfe\xe1\xfd\x93\xfdw\xfd\x83\xfd\x8b\xfd{\xfd\x82\xfd\x8d\xfd\x9d\xfd\xaa\xfd\xb8\xfd\xd1\xfd\xfb\xfd.\xfe\x94\xfe\x17\xffa\xffh\xffA\xff$\xffX\xff\xbc\xff\x1c\x006\x00\t\x00\xc0\xff\x9c\xffv\xff`\xffY\xff4\xff\xfb\xfe\xcb\xfe\x98\xfeg\xfeQ\xfeF\xfe\x03\xfe\xaf\xfd\xc4\xfd\x15\xfe[\xfe\x83\xfe\x8c\xfe\xab\xfe\xae\xfe\xb7\xfe\x11\xff\xaf\xffG\x00\xad\x00\xd3\x00\xf3\x00C\x01\x89\x01\xce\x01A\x02\x90\x02\xa9\x02\xa4\x02\xc6\x02\xf6\x02\n\x03\x00\x03\xd8\x02\xc2\x02\xc3\x02\xc1\x02\xc7\x02\xbf\x02\x88\x02K\x02(\x02\x01\x02\x05\x02\xed\x01\xa9\x01h\x018\x01\x1c\x01\xf4\x00\x90\x00>\x00\xf5\xff\x93\xff*\xff\xf4\xfe\xeb\xfe\xc7\xfe\x89\xfe&\xfe\xed\xfd\xc0\xfd\x8c\xfd\x83\xfd\x9b\xfd\xcc\xfd\xdb\xfd\xbb\xfd\xd1\xfd\x0f\xfe+\xfe7\xfeJ\xfe\x92\xfe\xf3\xfeL\xff\x90\xff\xe5\xff\x15\x00/\x00U\x00\xa2\x00\x13\x01w\x01\xc2\x01\xe8\x01\xf8\x01\x03\x02\x16\x02:\x02V\x02g\x02d\x02L\x02\'\x02\x00\x02\xd5\x01\xaf\x01w\x012\x01\x01\x01\xe9\x00\xcc\x00\xa9\x00}\x00L\x00\x12\x00\xd8\xff\xae\xff\xaa\xff\xa8\xff\x9e\xff\x7f\xffF\xff\x19\xff\x0f\xff\t\xff\xf2\xfe\xd3\xfe\xb5\xfe\x89\xfeV\xfe2\xfe:\xfe6\xfe\x11\xfe\xec\xfd\xe4\xfd\xdf\xfd\xc7\xfd\xbf\xfd\xda\xfd\xf3\xfd\xfb\xfd\x08\xfe;\xfeu\xfev\xfet\xfe\x92\xfe\xc4\xfe\xe8\xfe\x04\xff,\xffL\xffq\xffw\xffu\xff\x8b\xff\xaf\xff\xd4\xff\xfd\xff\x1e\x00O\x00w\x00\x92\x00\xaa\x00\xdc\x00\xf6\x00\xf0\x00\x00\x01F\x01\x92\x01\xba\x01\xd8\x01\xfc\x01\x0e\x02\x04\x02\x0e\x02F\x02o\x02j\x02Q\x02Q\x02\\\x02Q\x02=\x02"\x02\xef\x01\xa8\x01k\x01K\x01/\x01\x04\x01\xc7\x00\x81\x00/\x00\xe7\xff\xac\xff|\xffF\xff\x10\xff\xd3\xfe\x97\xfeX\xfe!\xfe\xed\xfd\xbd\xfd\x89\xfdT\xfd?\xfd/\xfd\x1f\xfd\x19\xfd\x03\xfd\xef\xfc\xe0\xfc\xe2\xfc\xf6\xfc!\xfd?\xfdb\xfd\x86\xfd\x99\xfd\xbb\xfd\xf1\xfd.\xfet\xfe\xb0\xfe\xfa\xfeA\xff\x85\xff\xc5\xff\x15\x00h\x00\xbd\x00\x1b\x01\x87\x01\xfb\x01^\x02\xb5\x02\x01\x03K\x03\x99\x03\xe5\x031\x04r\x04\x97\x04\xa1\x04\x97\x04\x84\x04{\x04`\x046\x04\xfb\x03\xa9\x03L\x03\xe7\x02v\x02\x05\x02\x93\x01\x1d\x01\xa0\x00!\x00\xa6\xff)\xff\xb0\xfe6\xfe\xc0\xfdY\xfd\x05\xfd\xca\xfc\x8f\xfcN\xfc\x13\xfc\xf1\xfb\xe0\xfb\xe4\xfb\xfd\xfb"\xfcA\xfcV\xfcw\xfc\xbe\xfc\x16\xfdk\xfd\xbb\xfd\x03\xfeJ\xfe\x90\xfe\xde\xfe;\xff\xa0\xff\x01\x00Q\x00\x94\x00\xcf\x00\r\x01A\x01c\x01\x87\x01\xb6\x01\xda\x01\xf7\x01\x0c\x02\x13\x02\x06\x02\xe6\x01\xc7\x01\xb8\x01\xbb\x01\xc7\x01\xc8\x01\xcb\x01\xb4\x01\x94\x01~\x01\x82\x01\x8f\x01\x90\x01\x8f\x01\x8b\x01\x86\x01\x85\x01\x91\x01\x9c\x01\x9f\x01\x92\x01z\x01q\x01g\x01_\x01Y\x01S\x015\x01\xfc\x00\xd4\x00\xab\x00|\x00H\x00\x14\x00\xe5\xff\xab\xff\x85\xffV\xff\x17\xff\xcf\xfe\x86\xfe@\xfe\x04\xfe\xde\xfd\xc4\xfd\x9c\xfdb\xfd"\xfd\xed\xfc\xc6\xfc\xb2\xfc\xb1\xfc\xc0\xfc\xc8\xfc\xd0\xfc\xe7\xfc\x04\xfd!\xfdG\xfd|\xfd\xcb\xfd\x1e\xfex\xfe\xdc\xfe,\xffn\xff\xb4\xff\t\x00q\x00\xd4\x00<\x01\x8a\x01\xc6\x01\xf4\x01\x1e\x02R\x02\x82\x02\xae\x02\xd0\x02\xe2\x02\xf1\x02\xf5\x02\xf9\x02\xed\x02\xd3\x02\xab\x02\x8a\x02v\x02m\x02S\x02&\x02\xee\x01\xb0\x01y\x01=\x01\x17\x01\xed\x00\xb5\x00p\x00*\x00\xf4\xff\xb9\xff\x7f\xffA\xff\t\xff\xcb\xfe\x8c\xfeW\xfe$\xfe\xf4\xfd\xc3\xfd\x9a\xfd\x81\xfdg\xfdI\xfd3\xfd\'\xfd\x1d\xfd!\xfd0\xfdN\xfdd\xfdq\xfd\x85\xfd\xa2\xfd\xc8\xfd\xf2\xfd\x1e\xfeS\xfe\x88\xfe\xb8\xfe\xe0\xfe\r\xff8\xffm\xff\x9f\xff\xd8\xff\x18\x00P\x00\x7f\x00\xa7\x00\xd2\x00\xfb\x00\x18\x014\x01^\x01\x90\x01\xbd\x01\xd3\x01\xd8\x01\xdd\x01\xe0\x01\xe8\x01\xfc\x01\x15\x02\x1d\x02\x0c\x02\x04\x02\xff\x01\xfb\x01\xe4\x01\xd2\x01\xcf\x01\xbb\x01\xb2\x01\xa5\x01\x90\x01q\x01A\x01\x18\x01\x03\x01\xf4\x00\xde\x00\xc6\x00\x9a\x00^\x00 \x00\xe6\xff\xc4\xff\xa4\xff\x89\xffa\xff.\xff\xfd\xfe\xbb\xfev\xfe;\xfe\x05\xfe\xe3\xfd\xc5\xfd\xaa\xfd\x94\xfds\xfdM\xfd"\xfd\x11\xfd!\xfd;\xfdY\xfdn\xfd\x84\xfd\x9b\xfd\xbc\xfd\xea\xfd*\xfeu\xfe\xc2\xfe\x12\xffY\xff\x9b\xff\xe2\xff\x1e\x00h\x00\xc0\x00\x0b\x01R\x01\x92\x01\xc8\x01\xe5\x01\x07\x02!\x02D\x02\x81\x02\x94\x02\xa3\x02\xa7\x02\x92\x02y\x02l\x02V\x02F\x02:\x02,\x02\x11\x02\xe2\x01\xb2\x01~\x01S\x015\x01\x1b\x01\xf0\x00\xca\x00\x94\x00a\x00;\x00\x0e\x00\xf3\xff\xd0\xff\xaa\xff\x8a\xff]\xff1\xff\x03\xff\xe0\xfe\xc5\xfe\xae\xfe\x9a\xfe\x7f\xfee\xfeF\xfe9\xfe,\xfe\x1c\xfe \xfe$\xfe.\xfe:\xfe;\xfeC\xfeR\xfef\xfe{\xfe\x98\xfe\xbd\xfe\xdb\xfe\x01\xff \xff0\xffM\xff\x88\xff\xc6\xff\xdf\xff\xed\xff\x1e\x00W\x00\x84\x00\xad\x00\xce\x00\x00\x01(\x01=\x01M\x01R\x01Y\x01v\x01r\x01r\x01\x86\x01\x99\x01\x9f\x01\x98\x01\xb2\x01\xd8\x01\xe0\x01\xda\x01\xce\x01\x95\x01G\x01.\x01K\x01l\x01\xad\x01\xe3\x01\x96\x01\xdf\x00\xfd\xff\xd9\xff\x03\x00:\x00\x82\x00\x7f\x002\x00\x8b\xff\xdc\xfe\xa1\xfe\xc4\xfe\xe2\xfe\x0c\xff\xf1\xfe\xb6\xfen\xfe#\xfeL\xfe\x88\xfeK\xfeQ\xfe\xdf\xfe\xc2\xfe\xb0\xfe;\xfe\xcb\xfd\x04\xfe\xa8\xfd\xae\xfd\xfd\xfd\x19\xfe\x15\xfe\x8f\xfeJ\xff\x81\xff\xab\xff\xdf\xff7\x00\xe5\xff\x9e\xff\xfd\xfe\x16\xfe\xd4\xfc\xfc\x00\xd9\x0e\xbc\x14P\x05\xc9\xf5\x93\xf8\xdd\xfa\xe7\xf8\x7f\xfe\xaf\t6\t\xbf\x00#\xfd\x86\xfa\xec\xf6S\xf5\xba\xfd$\x064\tY\x08\x98\x02\x1d\xfe\x9d\xfc\xe2\xfb\xcf\x00\xea\x05\xda\x08\xc4\x04n\x01\xf0\xff8\xfe\x13\xff\x00\x00L\x03\xc7\x02\x9a\x00$\xfd\x9b\xfc\xf8\xfc\x95\xfd:\xfe\x97\x00\x86\x00\x7f\xfeJ\xfb4\xfc\x1f\xfb\x0b\xfb\xf8\xfe\xde\xff5\x02\xf2\xfe\x03\xfe\x8b\xfa\x04\xfd\xa0\xff\x81\x05\x8e\x04d\x03f\x02\xaa\xfb\xe4\xfcT\xff`\x07\x16\x07\xf8\x05f\x03\xf3\xfe9\xfc\xe4\xfb\x92\xffK\x02@\x02*\x002\xfc\xf3\xfa\xc3\xf9\xa8\xfb\xf9\xfe\xb0\x00\xfd\xff_\xfe\xf2\xfba\xff\x98\xfb\xd3\xf4%\x06)\x19E\x17H\x06-\xffH\xfb\x0c\xf7\xd7\xfc\xf4\x0bV\x14O\r\xbb\x02\x1a\xf8\x11\xf1\xe0\xf0\xab\xfb\x01\x03\x1e\x03\xcb\x01x\xff0\xf9\x8e\xf3>\xf5+\xfb\x88\xfes\x02\x95\x08\xdf\x02\x15\xf8\xa8\xf7\x9a\xfb\xd4\xfeQ\x04D\x0b\x01\t\x11\xfe\x8a\xf9\xfc\xfaI\x03\xaf\x06a\tk\t\xda\x00k\xfa\xb9\xf8\x9d\xfb\xcd\xfd\\\x02Z\x08\xb2\x04\xa0\xfas\xf59\xf4;\xf9?\xfd\xc8\x01\x9d\x03)\x00U\xfd\xb6\xfbx\xfc\x05\xfe\xae\x03\x1b\x04F\x04\x84\x03V\xffi\xfe\xe3\xff[\x04\xf2\x02\xb0\x01\xbe\x04\x83\x05\x18\x01Y\xfe@\xfe\xd7\xfe5\x02\x8d\x05\xd5\x06\x18\x02\xd9\x00\x05\xfe\xf1\xfe\xd1\x03g\x03g\x02\xa2\xfdL\xfc3\xfb/\xff\xe0\x00\xa1\x03\xec\x02\x18\xfa6\xfb&\xfe\xed\xff\x07\x02\x0f\x03 \x01\xb7\x00\xdb\x01\x07\xffy\xfbw\xff\xe1\x01_\x00\xcf\xffx\x02\xcb\x02!\xfbm\xf9h\xfem\xfer\xfc\xee\xfa\x9b\xfdY\x01\xf1\xfd\xea\xfd\xd2\xfd\xd9\xfb\xaf\xfc\x1f\xffA\xff\xeb\xff\xbf\x03a\x03\x9e\x02\x86\x02I\x01!\x01\xee\xff\xba\xff\xb9\x02#\x03v\x01\xd1\x02\xa0\x07\x1c\x04\xdd\xfd\x1c\xfe\x04\xff\xa1\x02\xbd\x03$\x02\xd6\x03\xaa\x00\x08\xff\xdc\xfe=\x009\x00s\xfe\xeb\xfep\xfec\xff<\xff\xa9\xff\x99\xfc\x80\xf9J\xfa\xab\xfd{\xfe\xce\xfew\x01\xc6\x00h\xfe\x07\xfa\x07\xfa\x1f\xff\xc6\x03\x0f\x06-\x016\xfe\x1c\x00\xe1\x02\x14\x02\xb9\xff"\xfe\xb0\x07D\x05\xb2\xf7\xc9\xfa\xe4\x06\xd9\x07y\xfb7\xfe\xa0\x00&\xfa{\xfb\xfb\x00\xf8\x00\x84\xfco\x01E\x06\xfe\x02\xe2\x00\x12\x00\xe0\xfd\x8c\xfe\xbb\x07\x9f\x0bm\x01\xc8\xfcl\xff \xfb\x9f\xf68\xf9V\x03\x89\x06\xe5\x02\x03\xfd\xa8\xf7,\xf7\xf0\xf8j\xfe\xd4\x05m\nd\x06\xfc\x00s\x01\xb4\xfe\x03\xfb6\x03\xfc\n\xd8\x0eA\n\xc1\x04\xa7\xfa\xed\xf2\xb8\xf8"\x02\xa9\x06m\xfc\xb2\xfe\\\xf9\x10\xf4\x0e\xf7\xce\xf7\xbd\xf9G\xf7\xe4\x00Q\x07\x19\x01\xaa\xfe\xb3\xfea\xfe\xbc\xfa\xc9\xfcj\x08\xa6\x0b`\x07\xf5\x03\x04\x04[\xfde\xfb\xa0\xfe\x0e\x003\x044\x08\xd2\x08\xd5\x00\x9d\xfd^\xfa\xbf\xfb\xbb\xfd\xdd\xfbo\x00\xb1\xfd\xd3\xffS\x01\x89\xf8v\xf6\x07\xf7k\xfbe\x00\xd9\x02W\x08P\x03\xd2\xfa\xf5\xfdh\x00\xc4\x00\xe9\x05\xb5\x12\x93\x0e\xbd\xfc:\xfc\xda\x02\xd3\x06r\x03\xbf\x06-\t}\xff\x01\xfc\x9a\xfe\xfc\x00&\xfb\xf6\xfb\xe2\xff\x17\xfdM\xfa\x1e\xf8v\xfc\xa1\xfe\xe0\xff~\xfdd\xf8\r\xfb\xe8\xff\xd9\x03j\x01u\xfd\x1a\x01f\x055\x03 \x03\xa6\x03I\x04^\xfe\xe0\xfd\xd9\x03\xde\x03\xd6\x01W\x03V\x03s\xfd\x12\xf5\xef\xf8\xc6\x00\xec\xffT\xfd\x98\xfcK\x00W\xfaE\xfaY\x00\xc8\xfeg\xfc\xda\xffp\x04\xd1\xfd)\xfc\xc5\x03\xe2\x04G\x02\x98\x03\xa3\x03\x1d\x00&\xff{\x04\xb9\x04\x1a\x00h\x00\x8b\x03\xe6\x04d\x02\x8a\xfd\n\xfd\xe9\xfc\xac\xfd\x14\xfft\xfeC\x00P\xff\'\xfd%\xf9;\xfc\x92\x00A\xfd\xd3\xfc\xe3\x01\xdb\x05\x00\x01R\xffU\xfe\xdd\x02\xd8\t\xb3\x05\xaa\x02\x00\x01j\xfe\xb3\x01\x91\x04\x8a\x07(\t\x8c\x02\xfc\xfb/\xfbW\xfc\xc6\xfbY\xfc\xeb\xfe\xfa\xfe\x93\xff\xb4\xfe\xa9\xf9\xcd\xf8u\xfb\xa9\xfba\xfaC\xfd\x9b\x01\xb0\x00\x06\xfc\xd0\xfb\x1d\x01/\xff\x11\xfc \xfd\xe8\x02\xc0\x02\xb6\x01\xfa\x04\xed\x02\xb9\xfe\x17\xfeg\x06\x12\x08\xda\x05>\x05J\x03~\xff\xa1\xff\xba\x03=\x01\xed\xfd\xa9\xfe2\x02\x0f\x00s\xfa\x13\xfb\xd1\xfci\xff\x8b\xfb\xea\xfb<\xff2\x00\t\x08<\x00l\xfb\x14\xfeJ\x00\xd2\x02\xb5\x05\x9f\nh\x04%\xfeD\xfa\x9c\xf8\x84\xfc;\x04\x8e\x07\x9c\x06&\x05\xe1\xfe\xfe\xf6\x0b\xf6\xed\xfcY\x01\xd4\x00\x12\x01\xcf\x03}\xff;\xf9\x11\xfa\xfa\xfb\x0e\xfc[\xfag\xfc\xc3\x01\xc3\x05&\x04[\xfeO\xfb\x12\xfd\xdb\x01\xc6\x04\xab\x06\n\x049\x01+\x02\xb3\x02\r\x05\x05\x07\x19\x05\x94\xfd\xa5\xf8a\xfb:\xfe\xd9\xfd\xa9\xfdi\xfe\x84\xfd\x08\xfa\x7f\xfc\xdd\xfe\t\xfe\xdb\xfd\xfd\xfdx\x01:\x03m\x05\xf9\x01#\xff\x8d\x00x\x02\x18\x05\xd2\x04\x89\x06\xe6\x04\x86\x00W\xfd\xf5\xfc\xaa\x01\x13\x05\xea\x07^\x06H\x01\xdf\xfb\x97\xfb\x87\xfd\xff\xf9\xca\xfay\xfeE\x02\xa7\x00\x17\xfd.\xfd\x05\xfai\xf6\x07\xf6\xba\xfc\\\x042\x05\x87\x02\xe2\xfd\xb6\xf9\xb3\xfbH\xff\xe2\x03\xe0\x04\xaa\x03\xe2\x01\xb2\x00\xd4\x02U\x01\xd9\x00\n\x00"\xff-\x01\xe0\x03\xab\x02\xc7\xfe\xbd\xfd\xc5\xfb\xa7\xfa\x91\xfd\x16\x00\x93\x00+\x00m\x00u\xff\x80\xfd\xa2\xfb\x9b\xf9\xc2\xfc?\x00q\x03\xdf\x04m\x04\x06\x03\x82\xfe\xe4\xf9L\xfb;\x01\x8d\x03\xb3\x02C\x02\xd9\x02O\x02E\xff\xf1\xfd\xc0\xfe\xe8\xff\x7f\x00f\xfe\xe8\xfff\x02\xfe\x00m\xff\x00\xff\xf0\xfd(\xfd>\xfa"\xfa\x02\xfc\x05\xfc\xf8\xff\xc0\x01"\x02\x83\x01\x19\xff;\xfd\x0f\xfbQ\xfb\x1a\x02\xda\x06\xdc\x08\xa4\x08\xdb\x05*\x02\x86\xfc\xe1\xfaX\xff\xfd\x04?\x07\x9a\t\x0c\x06\x1e\x01\xb9\xff|\xfeL\xff;\x00\xf8\x04T\x08\x19\t\xf4\x08&\x06:\x03\xa8\x00\x17\x00^\x04\xd7\x08\xf0\n\xa9\tI\x08\xfa\x04R\x00\xee\xfe9\x00\x9d\x04\x8a\x04\xa6\x04c\x05`\x03\x0b\x02\xc0\xff}\xfd\xf3\xfcy\xfe\xe5\xfe\xa1\xfeJ\xff`\xff\xf9\xfc\xf2\xf9\xa8\xf8O\xf6l\xf4\xda\xf6\xbc\xf9\xd5\xfaP\xfb\x80\xf9*\xf7\x14\xf6E\xf7\x0b\xf8\x95\xf7\xc3\xf8\xf2\xf9\xb1\xfa\x14\xf9\x89\xf8Y\xf9\xc3\xf8\x9d\xf9\x8e\xf9\x98\xf8\xb4\xf7\xf5\xf8\x11\xf9i\xf9\xa6\xfb\xa3\xfaZ\xf9\x1d\xf8\x81\xf6\x04\xf5\xe4\xf2\xe3\xf2\xf6\xf32\xf5\xd7\xf7\x1a\xf9U\xf8]\xf9\xf4\xf9\x94\xf78\xf8\xdc\xfc\x8b\x03,\x0b\x15\x0b\x8f\x08K\x07\xf7\x02\x11\x04\xb9\x06\xb4\x08J\r*\x0c\x92\t\xee\x07j\x08\x82\t\xac\x07\xc0\x07N\x05\'\x04\xbd\x03\xa5\x03\x8f\x07\xf9\x04J\x04\t\x07\xb5\x01\x81\xfb1\x00\x92\x15\xd7)\xd5,k$Q\x1bc\x18\x8a\x17\x81\x1a5&01\xa12k)\xe3\x1fx\x18\x91\x11=\x07{\xfe\x96\xfb\x14\xfd\x1b\x00\x06\x01`\x00y\xf4M\xed\xe3\xe3\x12\xd8\xbc\xdaI\xe6\xe2\xf0-\xf2r\xf1\x99\xee\x18\xedc\xe9\x0c\xe8\xd8\xef\x84\xf5A\xfaL\xfd_\xfdM\x04=\x07\xb3\x01Z\xff\xb0\xf9\x01\xf6\x1d\xfaL\xff)\xfe\xdf\xfcN\xfav\xf2\x1b\xe9\x95\xe4\xd9\xe8\xb9\xeb\xbc\xec\xba\xed\xcb\xea\xf0\xe7\xde\xe6k\xe7\xd9\xe8;\xec\xab\xf17\xf5\xf8\xf7\xe0\xf9\x1a\xfb!\xfa\xe4\xf8\x9f\xf9\xec\xfb\xcf\xfeN\x00\xd1\x00G\x01\x11\xff8\xf9\t\xf5\xa5\xf3\xc1\xf3\xaf\xf7\xa2\xfb\xb5\xfc\xf9\xfc\x82\xfb\xcc\xf8\xec\xf5\xa3\xf5\xfa\xfa\xf5\x02U\x05\xcd\x03\x19\x07\xae\r\x06\x13\x1e\x11R\n\xda\x07\xa3\x069\x0bs\x105\x15\xd7\x1a\x14\x16\x90\x0e\x1a\x07\xec\x01z\xfd\xd8\xfb\xeb\x0e\xb9,\x9e>\x1d=\xaa-\xa0"\xaf \xfc\x1bv\x1d\xa1,\xac;@?\x0c8\xb0*h\x1b\x88\x0e\x03\xfd\xf0\xf1\x16\xf3\x9f\xf8\x0b\x03{\x04\x99\xfd\x89\xf2\x10\xe2\x16\xd3\xd2\xcc\xda\xd0\xe4\xdf\xaa\xef/\xf4\xcc\xf5\xe2\xf6\xb3\xf1\\\xec\x88\xe9\xb7\xeb\xf3\xf3\xa1\xfb\x00\x03\xb9\nF\x13\x14\x11\xad\x04\xcf\xf9\x86\xf0\xec\xee\xa1\xf4-\xfb\xc0\xffI\x01\xee\xfd>\xf4\xca\xe9f\xe4\xb5\xe4\xc9\xe6\x13\xe8\xc5\xee~\xf4R\xf7\xbf\xf7\xe3\xf2\xea\xec^\xe8\x95\xe8\x9d\xec\x11\xf5s\xfea\x03j\x03\xb0\x01j\xfd\xde\xf8\t\xf7\xdc\xf8C\xfd\xd9\x02\xca\x06\x17\x04\x17\xfe\xe0\xf7\x8e\xef\xf5\xe9\xcb\xe9P\xec\xf8\xf0k\xf3\xc9\xf6\x80\xf7 \xf4Q\xf2\xa5\xf0n\xf4\x8c\xfb\xa4\x01 \x07~\x08L\x0c\xf3\x0e\xf6\x08@\x03\xec\x04\x81\x07\xd3\x0c\xe2\x10\x91\x10l\x11\xdb\x0b\xa5\x05\x02\x04p\xfe<\xfb\xa0\x0c\xf5.\xefK\x04T\x8dA-*\x94"\'!S\'\x9c9\xebH\xcfJ@;\x90%\xb4\x16\x95\x06\xda\xf8\xea\xed\xfb\xe5*\xe9p\xf0\xdb\xf4S\xf11\xecO\xe0Y\xd0L\xc8Y\xca\xd2\xd8\xb4\xedE\xfb\xda\x01\xce\x05\xc5\x01:\xfb\xb2\xf6\xe5\xf6y\xfa\xab\x00.\x07I\x0e\xb8\x11;\x10T\x0cq\xfeB\xf4\xca\xef\xc2\xe6\x9c\xe3\xdd\xe7K\xee\x95\xf4]\xf5\x8b\xf0\xb4\xe6\x80\xdeW\xdd\x0f\xe2i\xe9\xcd\xf2Z\xfd\xec\x01"\x02\x14\x02\xc3\x01g\x00\x14\xfdn\xfa\xad\xfc\xee\x014\x08o\x0b\t\t!\x05\xd6\xfe\x04\xf8\xb4\xf3\xf2\xf3,\xf9B\xfcW\xfc\x86\xf9\xa8\xf5\xef\xf4*\xf2b\xee<\xeb\xdf\xe9\x1b\xeb)\xed\xb7\xf3\xea\xfa\xfe\xfe\x83\x01\xfb\xfdp\xf8\x88\xf7\xed\xf8\x95\xfc\x94\x028\x057\x06\xe0\x05\xeb\x03\x90\x01\xf7\x00\x98\xfc\xfa\xf5\xa7\xf5\xcd\xf5d\xf2\xdc\xf1\xce\xfd\xef \xb8O\xe6f\x83^\xa6I\xd96F.\x913\x9b?!Pv[\xceR\xb1>\x0c*\x19\x12\x17\xfc\x94\xe5\x19\xcf\xfe\xc6\xbb\xcb\x13\xd4~\xdc\xbd\xe1\xd7\xdfZ\xd8\x9d\xcb\xf5\xc2A\xc8%\xd9\xc1\xee\x0c\x04\x13\x14\x95\x1e\xb4%\xd7\'\x02"\xba\x17\xcb\x0ca\x05\xfc\x04]\x07b\x0e`\x14\x0e\x10&\x07O\xf5\xde\xdeP\xcd\x18\xc0\xea\xbe\xf8\xc6\xd9\xd1C\xdej\xe6*\xe8\xe9\xe5\xa8\xe5\xff\xe6\x1b\xe8\\\xee\x8a\xfa\xef\x08s\x18\x05(\xe9.\x00*2!J\x16e\n\x9e\x04\xb4\x01"\x00^\x02\x01\x03\xf3\xfd\xa7\xf6&\xf1"\xeb0\xe6\xa7\xe2 \xe2I\xe7\xb9\xee\xf0\xf31\xf7\xec\xf6\x1e\xf5\xf8\xf4\xc0\xf14\xf3\x98\xf9\xf3\xfc\xf2\x02\x8e\x06\xc6\x05\xfa\x06U\x04\xed\x00\xc3\xfe\xe5\xf93\xfc9\xfe\x9e\xfe \x02\xda\xfe\xb2\xf9\x90\xf5\x10\xef\x86\xedA\xef\x88\xee\x1a\xf0\xf9\xf2\x84\xed\x07\xe9=\xf6\xa2\x15\xb1@e[\xa1W\x08K>B\xffB\xbaH\x0fL\xd8P\xa0Q\xf3Jk>\xea-\x0b\x1f`\r\x1d\xf3,\xd7\xa2\xc4\x95\xc0\xdc\xc7p\xd0\xfb\xd5D\xd9\x89\xd9\xb2\xd7\x97\xd6)\xd9\x12\xe2\xe1\xed\xfa\xfa\xd5\x08p\x19\x97)\x913\x8f3\x9a(\xcf\x1a/\rk\x03;\xfeH\xfc\xcf\x00f\x00\xc1\xfa!\xf2z\xe1a\xd1\x95\xc4\xa3\xbc;\xbe\xc7\xc7Q\xd5\xeb\xe2\xba\xee\xd4\xf5c\xf9l\xfa(\xfbp\xfe\xc3\x03\xf9\n\x0e\x15\xb2 \x06)M*\xfb#p\x18\xbe\nk\xfe\x1e\xf7\n\xf3\xba\xf0\x95\xf1\x1d\xf1r\xee\xc9\xee\xec\xedX\xec\xa4\xebF\xe9M\xe9\x85\xed\x0e\xf5\x9a\xfbu\x01W\x05I\x04\xfb\x02\xf7\xff@\xfc\xa1\xfb\x1d\xfbM\xfc\xe4\xfe\x0e\x01\xf1\x02\xf0\x01\xc4\xff\xc4\xf9X\xf4\xdc\xf0p\xedl\xf06\xf4\x85\xf5\xc9\xf8\xf5\xf5e\xf0\xa9\xefX\xebZ\xeb*\xf2\x89\xf0<\xec\xed\xf3l\x0cC6\xf5]\xf5f{ZKL\xa2?i<\x8e@\xccDrL!NDBu/\xb7\x1b\x0c\n?\xf7\x9e\xdc\xb3\xc3\xc7\xb7\x00\xba/\xc5\xd6\xd2\xba\xdd\xa7\xe3\xe1\xe2:\xdeh\xdb\x18\xe0W\xec\xaf\xfb\xc8\t\xf6\x16\xb4%\xe8116T/?!\xb9\x12\xe2\x06\xa3\xfdk\xfc\x10\x02\x9d\x04.\x02/\xf5\xde\xe3n\xd5D\xcaw\xc6\x00\xc6?\xc9\xb6\xd2 \xdd\x89\xea\xc6\xf6L\xfe\n\x02d\x00c\xfeY\xff.\x04\xc8\r\xb3\x18\x9f \xa1!Z\x1dg\x15/\nO\x00\x7f\xf8o\xf0\xef\xea\xf9\xe9,\xeb8\xee\xb4\xf2\xb1\xf4\x81\xf3k\xf1\x1b\xee\xa2\xece\xf0F\xf7a\xfe\x8d\x05\xbf\x08)\t\xdf\x07W\x05\x99\x03\x87\x00\xd7\xfdL\xfb\xb0\xfa\xc5\xfc@\xff\xd3\x01\xac\x01/\xfd\xf1\xf7\xd6\xf1\xb9\xebA\xe9Q\xe9.\xea\xc6\xec\x9a\xefH\xf1\x1d\xf2\x94\xf1\xce\xee[\xec\xa9\xeb\x93\xea\x08\xea\xd1\xec\x8f\xf8\xe4\x18\xf1C(d(n\xeccWS$I\xc8D\xdeB\x92E\xecJhM\xd1G\xe76\x11"\x96\x0e\xbd\xf5\x01\xd9\xfa\xbd\xc9\xacz\xadg\xba\xee\xca\r\xd7\x86\xdd\xda\xdd\xc6\xdbH\xd9c\xdar\xe4\xa1\xf4D\x06\x01\x17H%\xb61\xd09\xe980/?\x1f\xe8\x0f\x18\x04\xc3\xfc\xa2\xf9\xec\xf9\x17\xfd\x9a\xfb\xa5\xf4)\xe8\xe0\xd89\xcc$\xc2\xf0\xbe\x9a\xc2s\xcb\x08\xdb:\xeb#\xf8\xb2\xff\xa1\x02\xb1\x02&\x01.\x01i\x03\x0c\tM\x13\x8b\x1e\xcd$]#\x87\x1c\xc8\x12q\x07\xd0\xfd\x8e\xf5/\xefq\xedx\xef\n\xf1\xbd\xf1\xf7\xf2o\xf2\xc0\xf0\x1d\xef\x12\xec\xc0\xeb\x9f\xf0\xb8\xf6\xcd\xfeY\x05\xb7\x07\xc1\t#\x08\x12\x05G\x03\\\x01\xc1\x00d\x01P\x02\xfd\x02o\x03\xf2\x02\xbb\xff\xb3\xfa\xa6\xf6\x84\xf1\xbe\xed\x82\xec7\xebZ\xec\t\xee\xe9\xedQ\xedp\xecR\xe9\xb9\xe8\xe7\xecz\xefR\xf1p\xf0Q\xec4\xf3\x1e\x0c\xfe0\xf1V\x95iif\x9dY\xf6NxK\x9bL\x9cK\xe2H\xe8G\\Bs6l&\x0e\x12\xcf\xfd\xb3\xe9\x8b\xd2$\xbd\xa6\xb1\xcf\xb2Z\xbe\x83\xcc\x01\xd5\xdd\xd6\x92\xd6/\xd5\xe1\xd6T\xde+\xea\x1d\xf9\xec\x08\xd2\x15\x85 g*\xe40\xed2\xcf-\x9d"\xc7\x15\xcb\n\x0e\x04\x97\x01\xf1\x02\xa8\x02E\xfe\xed\xf4\xb3\xe5\x9e\xd6I\xccM\xc7\xdd\xc79\xcb\x82\xd0\x00\xd8\x98\xe1O\xebH\xf4\xb8\xfa\xf6\xfc.\xfd\x1a\xfd\x1f\xfes\x03\xf3\x0cx\x17\x1d\x1f\xce #\x1d|\x16F\x0f}\x083\x02\xba\xfc\x15\xf8p\xf4\xce\xf2\xf9\xf3\r\xf7c\xf9\'\xf9\x01\xf5(\xef\xab\xea\xf4\xe9\x1c\xee\xeb\xf4\xf7\xfa\xe2\xff\xe0\x01b\x02.\x02f\x01\x90\x01i\x01\x0c\x01p\x01*\x02\xce\x04R\x07L\x08[\x07\x92\x01\xee\xfa\x19\xf5?\xf0=\xee\x9a\xec\x8e\xeb\xb2\xec\n\xed\x0b\xecS\xeb\x9e\xea\xc5\xea\xea\xe9\xb4\xe6\xbe\xe6I\xf2\x94\x0c\xa80zP\xd1]\xf2Y\xdfN+E.B{B\x7fC\xb2G/K\x85IH>\x92)*\x13M\x00T\xf0z\xdf\r\xcd[\xbeS\xb9\xcc\xbe\xfd\xc9A\xd4\x13\xd8\xa6\xd6\x8c\xd4s\xd4`\xd8R\xe0i\xebI\xfaK\n\xc3\x178"x(\xd8+=-\x91*\xde"\x12\x18\xea\rq\x08\x90\x084\tT\x06+\xffB\xf4\x92\xe81\xddd\xd2{\xcab\xc7\xd4\xc8\x02\xcez\xd4S\xda\xf7\xe0\x17\xe9`\xf0\x89\xf5S\xf7\xd8\xf7N\xfb[\x02\xa9\x0bD\x15I\x1d\xa7"\x81$\xaa!\xfd\x1a\xca\x12\xa3\x0b\xf0\x06\xa5\x03\x85\x00,\xfd\x04\xfa\xf9\xf7\xef\xf5\x16\xf3\x03\xf0\xa4\xec\xa1\xea\x06\xea\xcd\xe9\x7f\xeb)\xefS\xf3Q\xf9\x92\xfe\xba\x01q\x04Z\x05z\x05p\x07\xc3\x07\x01\t"\x0b\xa3\x0b`\r\x9f\x0c\x1d\t\x9e\x03l\xfc\x1b\xf6\xa3\xf1\xb5\xefB\xee\xe7\xecO\xec\xab\xe9q\xe7X\xe6\xf4\xe3\xd3\xe3~\xe4<\xe5\xed\xecO\xfe\xf0\x16\x9c1^E\xaaM\xe5M\xdbH\xa9A`=4=9A\xc2G\x1fK\x10EU7o%\xa9\x12\xa6\x02\x82\xf1\xd4\xe0\x82\xd4\x97\xcd\xa9\xcc\x87\xce\xf9\xcf$\xd1\xae\xd2\xb4\xd3j\xd4\xb4\xd4\xdc\xd5%\xdbc\xe5\xbc\xf2\xb6\x00\xa2\x0c\x0f\x17\xf3\x1f\xac%!\'\t$\x0b\x1f\xc5\x1a\x9d\x17/\x15\xb6\x12\x95\x0f\xbb\x0b\xf3\x05T\xfd\xe1\xf2k\xe8\xb4\xdf}\xd9\xad\xd4\xeb\xd0\xae\xce3\xcf\xa7\xd2\xf7\xd77\xde,\xe4\x99\xe9d\xee\xe6\xf1\x17\xf5\xa3\xf9b\xffE\x07\xee\x0f\x9b\x16\xf1\x1bL\x1f\xf6\x1f)\x1f\xbd\x1b4\x16\xad\x10\r\x0b\x86\x06\xfd\x03\xaa\x01\t\xff\x10\xfc\x99\xf70\xf24\xedY\xe9\xc3\xe7:\xe8*\xea\xef\xecz\xf0=\xf4q\xf7\x8a\xfa\xde\xfd{\x01\x82\x05b\t|\x0b\\\x0c\x0b\x0c\x96\n"\t\xdd\x06Q\x04x\x01\xe4\xfd\x17\xfa\xe2\xf5a\xf2T\xef\x1f\xed\xf8\xeb\xb5\xea\xe8\xeaR\xeb\x90\xeb\'\xec\xed\xeb\x1c\xee\x0b\xf5\x93\x02\xe3\x16\xed+2<[D\xdbD\xe5A\x16=\x9f8\xb16e7\x9d;\xa9?\xb0>\xff6K)\x87\x19\xed\nO\xfd\x00\xefg\xe1\xe3\xd6\xe7\xd0J\xd0X\xd2\xcb\xd4X\xd7\xa8\xd8M\xd8w\xd6w\xd3F\xd2\xdd\xd5]\xdf\xc7\xed\x87\xfdZ\x0bh\x15=\x1b\xb1\x1d\xf3\x1c\xdf\x1a\xfc\x18\x96\x17c\x17\x10\x17P\x15G\x12\xc9\r\xa2\x08h\x034\xfc\xf7\xf2\xf8\xe8\xc0\xdf\x0b\xd9u\xd5\xd2\xd3\x80\xd4\x89\xd7\x12\xdcg\xe1\xd1\xe5L\xe8$\xe9\x0f\xea\xf3\xeb\xff\xef\xae\xf66\xff.\tE\x13,\x1b}\x1f\xb8\x1f\xb4\x1c\xfb\x18\xbe\x15_\x13\xd2\x11A\x10\xa7\x0e\x9a\x0c}\t\x8a\x05\xe4\x00m\xfb\x1b\xf6@\xf1\x10\xedB\xea&\xe9\r\xea\xdd\xec\xca\xf0\x83\xf44\xf7n\xf8\xf6\xf8o\xf9\x8e\xfa\xa9\xfc\xfa\xfe`\x010\x03\x81\x03}\x03\x92\x02g\x01\x8c\x00\x0c\xff\xd8\xfdZ\xfc\x82\xfa\x1b\xf9\xbf\xf7\xdd\xf6\xcc\xf6\xdd\xf6s\xf6@\xf5#\xf3\xd4\xf2p\xf7c\x02\xc9\x11\x99!Q-F3\xad4t2\x8c.a+\xd1*L.\xfe4;;\xcd\xf63\xf5\x01\xf4\x88\xf2[\xf1\x00\xf1T\xf1\xaf\xf2\xf6\xf44\xf7>\xf9\x91\xfa\xf0\xfa\x00\xfb\xc6\xfa_\xfa\x89\xfa\xc1\xfaZ\xfb\xab\xfc\xe7\xfdR\xff\xee\xff\xa3\xff\xa7\xfe\xd8\xfc\xf9\xfa\t\xf9/\xf8\xc3\xf9\x07\xffo\x08Z\x14\xba\x1f$(\xe3+R+\x9a(}%\xfa#\xe9$\x03(\xdd,11\xc92\x130M(d\x1d\xd0\x11\xca\x07\xac\x00\xde\xfb\x19\xf9d\xf7\xee\xf5\x9b\xf3\x11\xf0\x8b\xeb\xb5\xe6\xf7\xe2\x93\xe0<\xdf\x8e\xde\xfc\xdd\x1f\xde\xb3\xdf\xf2\xe2\xd7\xe7\x9c\xedx\xf3\xc2\xf8p\xfc&\xfe\xf4\xfd\xdc\xfc1\xfc\t\xfd\xff\xffw\x04H\t\xf5\x0cK\x0e\x8b\x0c\xda\x07.\x01r\xfa\x84\xf5<\xf3S\xf3\x89\xf4\xdd\xf5\xef\xf5\xb3\xf4@\xf2\xf4\xee\x0c\xec\x1b\xea\x8c\xe9\xcb\xea<\xed\x90\xf0/\xf4\xc8\xf7\xb3\xfb\xd0\xff\xe3\x03[\x07\n\n\xad\x0br\x0c\x9a\x0c~\x0c\xc1\x0c\xad\rq\x0f\xaf\x11~\x13\xb5\x13\xd6\x11\x19\x0e<\t\x04\x04O\xff\xd1\xfb9\xfa8\xfa\x18\xfb\xeb\xfb\xc3\xfbc\xfa\x05\xf8\xed\xf4\x18\xf2\xea\xef\x0e\xef\xea\xef\x0f\xf2\xde\xf45\xf7v\xf8\x9d\xf8\xc7\xf7\xb7\xf6\xbd\xf5u\xf5\x06\xf6H\xf7P\xf9p\xfbB\xfdX\xfeQ\xfep\xfd\x19\xfc\x94\xfa\xa0\xf9/\xfa\xa8\xfcl\x02\x16\x0b>\x15\xe8\x1ex%\x11(\x9b\'X%{#0#\x7f$\xf3\'\x0e,\xc5/c1\xff.\x01)\r Y\x16\xf7\r/\x076\x02\x89\xfei\xfb\xfe\xf8\n\xf6T\xf2\xd6\xed\xb5\xe8\x87\xe4O\xe1\x19\xdf\xbb\xdd\x80\xdci\xdc\xb2\xdd\xad\xe0,\xe5\x11\xea\xf5\xeee\xf3\xb6\xf6\xe9\xf8\xe3\xf9q\xfak\xfb\x8a\xfd\x1e\x01\x84\x05\xee\t@\r\xac\x0e\xbd\r\x87\n\xb5\x05\xa6\x00\x99\xfcd\xfa\xed\xf95\xfa\xab\xfa-\xfa\xbc\xf8g\xf6`\xf3\x90\xf0M\xee*\xeds\xed\xc9\xee5\xf1\xef\xf3\xe9\xf6:\xfa\x8f\xfd\xfc\x00\xae\x03\xc0\x05\xfa\x06{\x07\xca\x07\xdd\x07r\x08\xc1\t\xa5\x0b\x14\x0e%\x10\xd2\x10\xed\x0fW\r\xa9\t\x99\x05\xca\x01\xe1\xfe\x80\xfdd\xfd\x17\xfe\xf7\xfe\x19\xff\x17\xfe!\xfcC\xf9+\xf6\x94\xf3\xc8\xf13\xf1\xd7\xf1\xf7\xf2S\xf4I\xf5\x88\xf50\xf5~\xf4\x8e\xf3\x17\xf3\xc8\xf2\xe7\xf2\x99\xf3v\xf4,\xf6\x01\xf8\xa0\xf9\x0c\xfb\x90\xfb\xd5\xfb\xcd\xfb\x7f\xfb,\xfc\x93\xfe5\x04\x81\rM\x18\xaa"\xdc)\xe1,\xfe,\x0f+\xff(\x02(\xb1(\t,z0+4\xf34\xbd0\xa9(j\x1e?\x14\x01\x0c\x07\x05R\xff\xcb\xfa"\xf7\xd4\xf3\xef\xefm\xeb\xc2\xe6\xb8\xe2\x97\xdf\xe1\xdc\x88\xdar\xd8/\xd7\xae\xd7\xfd\xd9\x0f\xde\x84\xe3\xe2\xe9\x94\xf0F\xf6X\xfas\xfc^\xfdM\xfe\xfc\xff\xf7\x02\xfd\x06o\x0b\x93\x0fm\x12\x02\x13\xfd\x10\x8d\x0c$\x07^\x02\x08\xff\x06\xfdz\xfb\x13\xfa@\xf8$\xf6\xd0\xf3:\xf1\xd0\xee\xb8\xec,\xeb\xae\xea\x18\xebn\xecj\xee\r\xf1\x91\xf4\xe1\xf8~\xfdx\x01u\x045\x06\x0e\x07U\x07k\x07\xfb\x07U\t\x93\x0bO\x0e\xcc\x10\x1f\x12\xcf\x11\xe8\x0f\xcb\x0c\xd9\x08\xe3\x04{\x01W\xff_\xfe5\xfe`\xfe;\xfe\x8f\xfd=\xfc\x0c\xfaP\xf7b\xf4\xf8\xf1\xb4\xf0\x9d\xf0S\xf1t\xf2f\xf3\x00\xf4\xe9\xf3A\xf3"\xf2*\xf1\xd7\xf0]\xf1\xd5\xf2\x7f\xf4\x9d\xf5.\xf6U\xf6\xb8\xf6d\xf8\x99\xfa0\xfdu\xffK\x00}\x00n\x00j\x01W\x05\xc9\x0c\xa3\x17i$T/"6\x1a7K3\xc3.\xc3+\xc8,\x900\x9a4c7T6=1\xd7(\xa7\x1d\x89\x12\x93\x08d\x006\xfaE\xf4Z\xee\x0e\xe8G\xe2B\xde\xbb\xdb\\\xda-\xd9;\xd7 \xd5\xe2\xd2\xc8\xd1\x04\xd3M\xd7I\xdf[\xe9\\\xf3 \xfb\xd8\xff\xb1\x02\xdd\x04G\x07\xf8\t\xb7\x0c\xe9\x0fb\x13u\x16\xf1\x17\x1b\x17\x88\x14\x12\x11C\r\x07\t\xed\x03\x0c\xfe-\xf8\x05\xf3T\xef\x1a\xed\xd2\xeb;\xeb\xb6\xea\xfe\xe9\xd3\xe8,\xe7\xdf\xe5}\xe5\xf4\xe6\x85\xea\x85\xefg\xf5\x06\xfb\x0b\x00o\x04\xa2\x07\xe2\t\xe6\n\x82\x0b\xac\x0c5\x0ey\x10\x90\x12\xd9\x137\x14>\x13\x89\x11\x0f\x0f\xef\x0b\xd3\x08r\x05b\x02\x92\xff\x04\xfd]\xfb3\xfa\xba\xf9\x81\xf9\xb1\xf8{\xf7\xba\xf5\xc5\xf3H\xf2)\xf1.\xf1\x18\xf2\x97\xf3 \xf5\xd7\xf5\x82\xf5\x9f\xf4\xa3\xf3|\xf3M\xf44\xf5F\xf6\xcb\xf6\xb0\xf6\x9c\xf6i\xf6\x9f\xf6\xc2\xf7\x7f\xf9x\xfc.\xff\xc0\x00e\x01\xb0\x00\x1e\x00\x06\x00\x14\x01\x01\x06D\x0fo\x1cn*6429%9\xa26m4\xff1<1\xdc0\xd30\xb00F.u*\xdd#\xf0\x1a\xf4\x0f\xce\x02\xfd\xf5\x96\xeao\xe2\xce\xdd\x9a\xdbk\xdb[\xdbW\xda\xc5\xd7o\xd4\xd9\xd1b\xd1{\xd3\xcf\xd7\x81\xddI\xe4\xf3\xebI\xf4\xaa\xfd\x9f\x06a\x0e\xea\x13\x94\x16\xe4\x16\xb0\x15\xb8\x13\xe7\x12\xab\x13\xaf\x15\xe0\x17\x12\x18W\x15/\x0f\x9b\x06/\xfd?\xf4\xb6\xecC\xe7\xd4\xe3$\xe2z\xe1\x81\xe1\xc1\xe1\xbf\xe1.\xe1Q\xe0\xba\xdf*\xe0\x8e\xe2Z\xe7m\xee\t\xf7\xaf\xff\x1e\x07\xa5\x0c%\x10&\x12.\x13\xbe\x13+\x14\xa8\x14\xfe\x14?\x15\x1e\x15\xa6\x14w\x13K\x11\xcb\r\x02\tO\x03\xe4\xfd[\xf9\x90\xf6\xcc\xf5\n\xf6\xf3\xf6\x1a\xf7\x81\xf6\xb9\xf5\xc4\xf4<\xf4\xdc\xf3\xfe\xf3\xd5\xf4\xe2\xf5w\xf7\xc5\xf8\xe0\xf9\xcf\xfa\xb3\xfb\xb6\xfc7\xfds\xfc\x9c\xfa\x80\xf8O\xf6\xff\xf4\x03\xf4\xc5\xf3[\xf5\xf3\xf6g\xf8T\xf8\xb3\xf6\xa1\xf6\xd5\xf6\xba\xf8\xcc\xfa\xc0\xfbs\xfd\x96\xfd\x9b\xff\x9a\x05\xa2\x10\xce R0p;\xd4?\xe7=m9N4b1\xcd0\xe81c4\xab3\xf1/p(\x00\x1e\x14\x13\xf1\x05 \xf8\xca\xeb\xf3\xe1$\xdc\x8d\xd9\x1e\xd8\x94\xd8G\xd9\xf5\xd8\xc4\xd7f\xd5\xcd\xd3\xf5\xd4F\xd8;\xde1\xe6q\xef\x8a\xfaR\x05\xe4\x0e\\\x15\xa3\x18\xc5\x19\xa8\x19\x9a\x19\x86\x19\xa5\x19\x0f\x1a\x05\x1a\xe1\x18\xef\x15\x00\x11\xb8\n,\x03\x8e\xfa+\xf1:\xe8\x02\xe1\xb9\xdc\xf9\xda\x10\xdb\xb4\xdb\x1d\xdc\xdc\xdb\xde\xda\xd5\xd9\x04\xdaO\xdcO\xe1T\xe8t\xf0\xa2\xf8S\x00G\x08\xad\x0f\xba\x15\x04\x19\\\x19G\x184\x17\x0b\x17S\x18\xa7\x19r\x1aM\x19\x0f\x16\xb3\x10h\n\xbc\x04\xfb\xff\xd4\xfc\xa0\xf9\xe3\xf6\xfd\xf4p\xf4S\xf5\x92\xf6$\xf7\x9e\xf6\xa8\xf5\xd6\xf4\x02\xf5\xe8\xf5\x90\xf7\xe1\xf9\x8a\xfc\x1d\xff\xfd\x00e\x01\xd2\x00\xa6\xff%\xfe\x93\xfc\x1c\xfa\xe0\xf76\xf6\x1e\xf6\xe6\xf6}\xf7\xde\xf6&\xf5?\xf3[\xf1\xce\xf0R\xf0\xb5\xf18\xf4\xfd\xf6\xa9\xfa\xfa\xfc\xef\xff[\x02\x7f\x039\x04\xdf\x02x\x04n\x0c\xc5\x1a\x85-%:\xdf>\xe8;\x0c6&3\xf91;2\xbe1\x9f03.k)c!\xd3\x17\xdf\x0e<\x06e\xfcQ\xef\xf8\xe1\xfd\xd8$\xd7+\xdaf\xdd\xbc\xdd\xbd\xdb\xbb\xd9\x02\xd9\xac\xd9\xe1\xdbc\xe0c\xe7\x86\xf0\x7f\xf9\x85\x01/\tk\x10\xff\x16\x99\x1a\xf9\x19\xc0\x16\x9e\x13F\x13\x94\x15\xbc\x17\x93\x17N\x14\xa5\x0eY\x07]\xfe\x82\xf4\x13\xeb\xd6\xe33\xdf\xc9\xdbo\xd91\xd8\xfb\xd8\x05\xdb;\xdc\x1b\xdbw\xd8\x02\xd7[\xd9\x02\xe0L\xe9:\xf3\x90\xfc\xe0\x04\x9d\x0b^\x10;\x13,\x15|\x16\x14\x18\xfc\x18(\x1a\x8c\x1b0\x1d0\x1e:\x1c8\x17\x05\x10\xbb\x08\xe2\x02W\xfex\xfb\xf2\xf9\x94\xf9\xb0\xf9\x85\xf8x\xf6\xce\xf3\x88\xf1\'\xf0Y\xef4\xf0\xc7\xf2&\xf7\xce\xfbK\xff\x9c\x00\xd0\x00\x1a\x005\xff\xc0\xfeC\xfe\x86\xff\x8b\x00\x00\x01D\x00\xd5\xfd\\\xfb \xf8\xeb\xf4\xe2\xf1\x9e\xee\x88\xedZ\xed\xc5\xee\xc0\xf0\x83\xf0,\xf1\xaf\xf18\xf3\x99\xf5\xfe\xf6\x84\xf9I\xfc\x8b\x00\xbf\x04\xed\x07!\x0b\x93\r?\x14\xe3\x1fo-5:F>3:\xa93c.\xab.U1\xd01\xba0>-\xe8\'\xc9!]\x18\x82\r\xdc\x01\xe9\xf5\xc7\xebK\xe3<\xdf\x89\xdfb\xe2\t\xe5-\xe4a\xe0&\xdc\xa9\xd9>\xdb\x06\xe0\xc2\xe6\x8d\xee\xe3\xf6\x84\xffG\x07\xa5\r\n\x11<\x12g\x11\xed\x0f_\x0f\xeb\x0f\xc1\x115\x14G\x15\x16\x134\r\x89\x04N\xfb\xe7\xf2\xfa\xeaQ\xe4\xb4\xdf\xb8\xdd\xbb\xde\x9a\xdf\x83\xdf\xf4\xdd\xe2\xdb\xaa\xda1\xda\xf1\xda=\xde5\xe4S\xed\x06\xf7\\\xffW\x06A\x0b\xe5\x0f}\x12z\x13P\x14\xa0\x15\x81\x18\x89\x1b\x9a\x1d\x80\x1e\x82\x1c\x01\x19E\x13\xa0\x0cT\x06\xdf\x00\xc6\xfd\xba\xfb\xc8\xfa?\xfa\x92\xf9\x07\xf8\xd4\xf5N\xf3\t\xf1\xd4\xf0\x10\xf2\xd3\xf4w\xf8\xe7\xfb+\xff9\x01C\x02\xa1\x02(\x02\x99\x01\x9f\x00\x94\xff\xee\xfe\xd5\xfe\x87\xfeo\xfd$\xfb\xa7\xf7U\xf4\xa2\xf0\xfa\xed[\xec\x0b\xeb\xb3\xeb\x13\xec\xa4\xedb\xef+\xf0\x83\xf2\xcf\xf3\xbe\xf6:\xfa\xca\xfd\xa0\x02l\x06\x11\t\xfc\x08\x12\x08\x80\n\x1c\x12\xff\x1f\xa7-\xb25\xbf6\xb12\xd6.\x08,\xeb*8+\xb5+\xcc+V*\xb2&\x06!\x1a\x19\xc6\x0e\x82\x02\x8a\xf62\xed\xce\xe8\xf6\xe8\xb1\xea\xbd\xec\x05\xecm\xe8\x8d\xe3E\xdf\xfb\xdda\xdf\xc7\xe2\n\xe8,\xef\xd7\xf7h\x00d\x06\xa1\x08f\x07\xd5\x05\xc4\x044\x05\x96\x07,\x0b\xe1\x0fx\x12\xe8\x10/\x0c\x19\x05\xd6\xfd\xce\xf6#\xf0\xa5\xeb\xc3\xe9Q\xea\x08\xeb\x0c\xeb\x90\xe9L\xe7%\xe4\xeb\xe0N\xde\x8d\xde\xb5\xe2\xff\xe8\xab\xef\xbb\xf5<\xfb\x97\x00\xf5\x03\xff\x04&\x05\xd6\x05\xc9\x08\xe5\x0b)\x10\xcf\x13\xa4\x17N\x19\n\x17\x98\x12\xe8\x0c\x1c\t\x17\x06"\x03i\x01,\x00\xec\x00\x19\x01\x0e\xff\x1e\xfc"\xf8L\xf5\x9c\xf3\xd7\xf3\xba\xf5\x84\xf9M\xfd\x94\xff\x10\x01A\x01L\x01\xa2\x00\x9b\xffz\xfe0\xfe{\xff\xec\x00\x10\x02\x17\x01f\xfdB\xf9A\xf5\xd9\xf2\xde\xf1\xed\xf0\xb8\xef\x08\xeeq\xed:\xee\x0f\xf0\x1c\xf2\xc3\xf1z\xf1X\xf2h\xf4\x1f\xf9+\xfd\xf7\xff\x98\x02\x0f\x04\xdc\x05\x9d\x08\xf0\x07\x8d\x06\xff\x07\xcb\x0fp\x1f=->2P.\x85\'\x0e&\xb2(0+\xc4*\xd4(^)\x1b)\x89&\xad!\xe9\x19\x1b\x10S\x03\xa9\xf6\xf9\xef\x13\xf1.\xf5\xad\xf6\x15\xf3\x80\xed\x19\xeaB\xe7\x03\xe4\xbb\xe0b\xdfs\xe2\xc0\xe8u\xf0\xdd\xf7\x93\xfd\x98\x00\xfd\xfe\x0c\xfb\x17\xf8v\xfa\x8c\x00\xf7\x05\xcf\x08\xb1\t\xb9\x0b\x9a\x0c!\t\x13\x03\x91\xfc\xfb\xf7\xa5\xf5\xba\xf3I\xf3\xcf\xf4\xe7\xf6>\xf6\xe7\xf1O\xecH\xe9\x98\xe8O\xe8\xf7\xe7\x9e\xe9<\xee\xbb\xf4\xc6\xf9\x99\xfb\xe3\xfc.\xfd\xc0\xfd,\xfe~\xff\xd1\x03\xc9\x08\x84\x0c\x1b\x0e\x8c\x0e\xeb\x0fB\x0f~\x0c\xdf\x07\xe9\x04\xbe\x04\xe5\x05\x7f\x06\x19\x05\xf0\x03\x16\x02\xe5\xff\x0c\xfd[\xfb\xe3\xfb\xb3\xfc;\xfd\x7f\xfd\xa2\xfe\xd2\x00g\x025\x02\x0c\x00\x9e\xfef\xfe$\xff\xe3\xff\'\xff\x7f\xfe\xb6\xfcI\xfb\x82\xf9\xfa\xf6\x9e\xf5\x02\xf4n\xf3\x93\xf1\xb6\xef\xa1\xef\x1e\xf0\xa5\xf1\xce\xf0\xfd\xef\xc1\xf0G\xf2\xae\xf5\xc6\xf7\xe3\xf9-\xfc\x15\xfd\x97\xfe\x17\x00\x99\x02\xb5\x05\x1a\x06\x88\x04\xe2\x02\xde\x07u\x14k"\x80*\xf4(\x0b$o"\x86%\xe3**,*+\xdb*\xf8*\xd4+%)\xda"\x94\x19Q\rR\x02\xb1\xfcX\xfc\xae\xfd\xda\xfb{\xf5\xb1\xee\x00\xea\x1a\xe7-\xe5\x95\xe1 \xde\xed\xdd*\xe1\xb9\xe7\x8b\xef\xea\xf4\x01\xf6\x06\xf3a\xf0\x81\xf2\xff\xf8\x0c\x002\x05\xf5\x07\xf7\x08\x9e\n \x0c\x96\x0c\xa3\n8\x04g\xfe\xca\xfb.\xfdR\x01\xeb\x01\xd8\xfe5\xf9A\xf3-\xef^\xec\x00\xea\xc4\xe8U\xe8\xad\xe8\x86\xeb\xba\xefE\xf3\x07\xf5\xfd\xf2/\xf0\xe1\xefE\xf3f\xfa\xc1\xff\x1f\x04\xa3\x06n\t\x83\x0b\n\x0cR\x0b;\t\x8f\x07\x0e\x07\xb9\x08\xc1\x0b\xf3\r\xb2\rj\n\xfc\x04\x8f\x01\xe1\xfft\xffq\xfe\xc1\xfdQ\xfe\xdb\xff}\x01\xc1\x00\xb7\xfe;\xfcn\xfa\xee\xf9F\xfaS\xfb\x06\xfdx\xfd\xd2\xfck\xfb\xe1\xf9\xc8\xf8\x14\xf7Z\xf5[\xf3\xe2\xf2\xd4\xf3\xac\xf4\x8a\xf4i\xf3T\xf2\xcd\xf2\xde\xf2\x7f\xf3\xdf\xf4\x03\xf6\x1a\xf9\xf8\xf9\xe1\xfa\x18\xfd\x99\xfe\x9c\x01\x00\x02\x18\x02\xe2\x03\x94\x03\xbb\x02\x10\x03\x86\t[\x19\x96%\xe9\'\xc6#j!\x00&u*\xcd)\x9f(\x8e+80a2W/%)\x12!Q\x16\xeb\t3\x01\xbf\xfe\xec\xffH\xfe\xc9\xf7?\xf1\x13\xed\x8b\xe9\x01\xe3g\xdbi\xd7\xb2\xd8\xfd\xdd#\xe4\xdf\xe9B\xef\xa6\xf1\xe3\xf0\'\xeea\xeeC\xf4\x80\xfbE\x02\x1b\x06/\nK\x0f\xda\x11\x02\x11\x1b\r\xa1\x08\x0c\x06H\x04N\x04[\x05\x86\x06\xe2\x05\xc3\x00=\xfa\n\xf4|\xf0\x84\xedH\xea\xac\xe7O\xe7I\xe9-\xec(\xeec\xee\x17\xee\x1b\xed6\xedh\xee\xad\xf2$\xf9Q\xffA\x03[\x06\x14\t\x92\x0b\xf9\x0bM\n\x90\t\x88\n\xd2\x0c[\x0f\x02\x11\xe2\x10.\x0fj\x0bz\x07\x00\x05\xa0\x03\xd9\x02&\x02\xcf\x00\xbb\x00\xe5\x00k\x00\xe2\xfe\x16\xfb2\xf8I\xf7(\xf8T\xfa\x90\xfb\xed\xfb%\xfb"\xf9 \xf7\xbc\xf5\xec\xf4\r\xf5\x80\xf5\xd1\xf6\x9b\xf7m\xf7\x90\xf69\xf5\x0f\xf4\x1f\xf3N\xf3\x82\xf5\xd7\xf7s\xf8\x95\xf8\xa9\xf8\x1c\xfbM\xfd\xcf\xfd\x8d\xfd\xa4\xfc\x02\xff\xcb\x01\x05\x04i\x05\xca\x04\xf7\x05\xad\ni\x13\x12\x1f\xa9%v$\x9d\x1f\x93\x1e\xb9${+\xed,\x1b+O+M-\xcc,E&\x0b\x1d\xb1\x14;\rR\x064\x01D\x00\x0c\x00\x9b\xfb4\xf3\x9b\xeaD\xe5\x82\xe2o\xdf\x00\xdd^\xdc+\xdfU\xe4l\xe9\xd5\xec\xe4\xed\xb7\xed\x0c\xed\xc3\xee{\xf3\x0e\xfb\xbe\x02\x1d\x08\xf6\tk\n\x88\x0b\x11\x0cu\x0b\xd7\x08\xd2\x06\xad\x07#\tI\t)\x07$\x03\x98\xfe\xca\xf8R\xf3;\xefE\xed\x0b\xed:\xec\x00\xebW\xea\xa3\xea}\xeb\xd7\xeaS\xe9\xa3\xe9E\xec\xb7\xf0+\xf5p\xf9\xb3\xfd\xc8\x00\xe6\x01\xe5\x02G\x057\x08\xa8\t\x17\nm\n\xb0\x0c\xf0\x0et\x0f\xa1\x0e\xac\x0b\xbd\tK\x083\x07\xd0\x06\xf4\x05{\x05V\x04~\x02\x1d\x01\xa1\xff\xc5\xfed\xfda\xfb@\xfa*\xfa\x06\xfb\xfe\xfa\x9f\xf9C\xf8\xac\xf6I\xf6\x9b\xf69\xf6K\xf6s\xf5\xeb\xf4\x92\xf5\xd4\xf4:\xf4\xc4\xf39\xf3\xe3\xf4\xeb\xf5/\xf7n\xf8\xf1\xf7\x9b\xf8~\xf9\xd8\xfaB\xfd\xea\xfe>\x01\x11\x03\t\x02\x15\x00r\xff\xed\x02~\t\x95\x10m\x15%\x19\x07\x1ds\x1fg \x11\x1f_\x1e1!\xb8&\x83++,\xd4(&$\xe9\x1f;\x1b\xa5\x14\xd7\r\xc6\x08\xe4\x066\x06\xac\x03\xa7\xff\xfc\xf9i\xf3\xb0\xec$\xe7\xd5\xe4\t\xe55\xe6E\xe7^\xe8M\xea\xe2\xeb_\xec\xdb\xeb\xbf\xeb\xb3\xedJ\xf1\x9d\xf6\xf8\xfc\xa6\x02\xc3\x05Y\x05\xac\x03\xad\x030\x05?\x06\xd2\x05#\x05\xc5\x05(\x07)\x07\xe5\x04\xbd\x00\x82\xfcp\xf9\xa5\xf7\xb6\xf6w\xf5P\xf4\xe1\xf3\xe1\xf3\x8c\xf3G\xf2|\xf0c\xef\xfd\xee\xd2\xefQ\xf2\xca\xf5\xe5\xf9\x17\xfc\xa2\xfc0\xfcG\xfc\xc3\xfd\xcb\xff\xb3\x01\x88\x03\r\x05\xcf\x06\x0b\x08 \x08X\x077\x06\x95\x05\xa9\x05\xc0\x06\xfc\x07\xf5\x08\xb4\x08\x8c\x07;\x06\x95\x05\x10\x05G\x04\x93\x03\x9a\x03x\x04\xff\x04\x82\x04\x84\x02[\x00\xe2\xfd\xac\xfby\xfaa\xf9\xd3\xf8M\xf8p\xf7#\xf7?\xf6\x87\xf4\xc4\xf2x\xf1\xdd\xf1v\xf3\r\xf5a\xf6[\xf7\x19\xf8\x1f\xf9\xd4\xfaf\xfcu\xfdV\xfes\xff\xd9\x01\xa5\x047\x06\xf2\x06\x1e\x06\xb6\x04\xe0\x03\x8f\x04\xf3\x07\xdf\x0b\xdf\rK\r(\x0cn\r\x84\x0f\xbb\x10\x87\x10\xc7\x0f\x00\x110\x13\x1a\x15\x00\x161\x15\xd2\x13\x0f\x12\x0f\x10\xc8\x0e\xa3\rn\x0c\xb7\n\xb8\x08\xf8\x07 \x07\x11\x05*\x01\xe0\xfc\x96\xfa\xbf\xf9>\xf9\t\xf9\xc7\xf8\xa1\xf8r\xf7\xcd\xf54\xf5\xfc\xf4\x81\xf4.\xf3\x80\xf2\x84\xf3I\xf5\x80\xf6\xa8\xf6\x92\xf6w\xf6\x0b\xf6\x92\xf5\r\xf6\x9b\xf7\x1e\xf9\xb0\xf9\xb3\xf9?\xfa\x1e\xfb\x8e\xfb\x7f\xfb\xac\xfb\x1a\xfcx\xfc\x01\xfd\xcb\xfd\x8d\xfe[\xfe[\xfd\xd7\xfc\x01\xfd0\xfd\xe8\xfca\xfc\x9f\xfc\xca\xfcf\xfc\x06\xfcc\xfcp\xfd#\xfe1\xfeC\xfe\x93\xfe\x05\xff?\xff\xd1\xff\xe3\x00\xb4\x01@\x02b\x02\xf4\x02\x12\x04\xc1\x04\xf9\x04:\x05\xb0\x05\x18\x06J\x06J\x06n\x06a\x06\xde\x05(\x05\x98\x04\xf7\x03\xed\x02\x97\x01N\x00!\xff\xd9\xfdu\xfc\x1f\xfb\xf0\xf9\xce\xf8\x92\xf7M\xf6e\xf5\x9e\xf4\xe8\xf3q\xf3R\xf3\xaa\xf3\x17\xf4s\xf4\xfb\xf4\xda\xf5\xe3\xf6\xf3\xf7\xdd\xf8\xcf\xf9\xba\xfa\xa4\xfb\x9b\xfc\xb5\xfd\xc7\xfe\x9b\xff\xd1\xff\x96\xff=\xffO\xff$\x00L\x01\xa0\x02\xe3\x039\x05\xe2\x06\x96\x08[\n~\x0c\x0f\x0f\xc8\x11\x89\x14\x1b\x17\xba\x19!\x1c\x82\x1d#\x1e\xc9\x1eC\x1f\xcc\x1e)\x1d^\x1b\x05\x1au\x18\xe1\x15\xa7\x12\xf8\x0f|\rb\n\xeb\x06(\x045\x02\xcf\xff\xec\xfc:\xfar\xf8\xae\xf6E\xf4<\xf2\xf9\xf0\x1b\xf0\xcc\xeet\xed\x19\xed_\xedk\xed\x19\xed.\xed\x08\xee\xe8\xeea\xef\xea\xef\xd1\xf0\x07\xf2\x17\xf3\xc6\xf3\xa4\xf4\xba\xf5\xdf\xf6\xff\xf7\x05\xf9\xef\xf9z\xfa\xa8\xfa\xab\xfa\xf6\xfa\x98\xfb\x14\xfcz\xfc\x97\xfc\x81\xfc\x8d\xfc\xaf\xfc1\xfd\xfe\xfd\x9e\xfe\x11\xffz\xff0\x00p\x01\x89\x02\r\x03*\x03p\x03\xff\x03\x9c\x04\x18\x05{\x05\xaf\x05\x89\x05$\x05\xdd\x04\xd0\x04\xa0\x04;\x04\xe8\x03\xbd\x03\x91\x03%\x03\x9e\x02d\x02V\x02\xfb\x01m\x01\x10\x01\xd9\x00\x91\x00\xf9\xffm\xff\x19\xff\x95\xfe\xcf\xfd&\xfd\x9e\xfc\xfd\xfb\x1e\xfbL\xfa\xdb\xf9x\xf9\xdd\xf8E\xf8\x16\xf8\x13\xf8\xf8\xf7\xa9\xf7c\xf7\x95\xf7\xee\xf7\x0f\xf8\x1e\xf8D\xf8r\xf8\xac\xf8\x01\xf9R\xf9\x96\xf9\xbd\xf9\xcf\xf9\xa0\xfaL\xfc\x0b\xfe\xe5\xff\xa3\x01\x87\x03#\x06\x15\tm\x0c\x1a\x10d\x13\x1a\x16K\x18l\x1a\xc2\x1c!\x1f\x92 \xc1 . ^\x1f\x84\x1e8\x1d\x1a\x1b\x9a\x18\xdc\x15\xe4\x12\xd9\x0f\xd4\x0c\xf8\t\xc4\x06/\x03\x03\x00\xc5\xfd\x11\xfc\xfc\xf9\xcc\xf7\x08\xf6\xc3\xf4\x8c\xf3\x19\xf2\xfc\xf0H\xf0\xa3\xef"\xefH\xef\xbf\xef\xfb\xef\xd1\xef\x98\xef\x08\xf0\xa6\xf0$\xf1\xa9\xf1\x18\xf2\x89\xf2\x02\xf3\xc5\xf3\xd8\xf4\xc0\xf59\xf6u\xf6\xda\xf6[\xf7\xe2\xf7T\xf8\xac\xf8\x0c\xf9x\xf9\x02\xfa\x9d\xfa\x1c\xfb\x81\xfb\xd5\xfbK\xfc\xf4\xfc\xe8\xfd\xf9\xfe\xe4\xff\x9a\x00:\x01\xfb\x01\xcd\x02\x97\x03I\x04\xc8\x042\x05\x82\x05\xb0\x05\xdc\x05\xd8\x05\xb2\x05p\x05/\x05\x00\x05\xa1\x04\x18\x04\xa2\x03N\x03\r\x03\xc5\x02\x88\x02P\x02\x02\x02\x95\x011\x01\xdb\x00[\x00\xc1\xff\'\xff\xab\xfe \xfek\xfd\xa4\xfc\xfd\xfb^\xfb\xac\xfa\xfc\xf9l\xf9\xf8\xf8\x87\xf8U\xf8D\xf8D\xf8X\xf8^\xf8s\xf8\xba\xf8\xd9\xf8\x03\xf9U\xf9x\xf9\xb5\xf9\x11\xfal\xfa\xe9\xfaU\xfb\x94\xfb\x08\xfc\x95\xfcT\xfdz\xfe\xf5\xff\x05\x02c\x04\xc6\x06`\t\xd7\x0b\x1c\x0eq\x10\xf4\x12\xce\x15\x95\x18\x84\x1a\xdd\x1b\xe6\x1c\xa5\x1d\n\x1e\xde\x1d^\x1dt\x1c\xb6\x1a\x99\x18\x89\x16\xbc\x14\xaa\x12\xdd\x0f\xd2\x0c\xce\t\x1a\x07\x9d\x046\x02?\x00;\xfe2\xfcB\xfa\xa4\xf8u\xf7\x1f\xf6\xcf\xf4z\xf3p\xf2\xa0\xf1\xba\xf0#\xf0\xb4\xefU\xef\x1d\xef\xe4\xee\xe7\xee\xf3\xee\xfb\xee0\xef\x9b\xef\x1b\xf0\xa4\xf0_\xf1S\xf2)\xf3\x02\xf4\xe3\xf4\xbb\xf5q\xf6\xe5\xf6\x96\xf7x\xf84\xf9\xc6\xf9k\xfa5\xfb\xcc\xfb3\xfc\xc2\xfcr\xfd\x0e\xfe\xa8\xfe\x82\xff\xbc\x00\xac\x01>\x02\xc5\x02T\x03\xf5\x03U\x04\xc7\x04d\x05\xbd\x05\xd6\x05\xca\x05\xf5\x05\x10\x06\xd1\x05r\x05D\x050\x05\xd8\x04X\x04\x1a\x04\xf7\x03\xac\x03=\x03\xea\x02\xac\x025\x02\xad\x018\x01\xe9\x00y\x00\xde\xff@\xff\xaa\xfe/\xfe\x93\xfd\xf9\xfcg\xfc\xd9\xfbY\xfb\xea\xfa\x8d\xfa\x1d\xfa\xbf\xf9t\xf99\xf9"\xf9.\xf9R\xf9i\xf9O\xf9=\xf9f\xf9\x91\xf9\x88\xf9\x9c\xf9\xe7\xf96\xfaM\xfaB\xfa\x7f\xfa)\xfb\xcb\xfb\x04\xfc\xa5\xfc\xe1\xfd(\xff\x8e\x00/\x02A\x04\xc2\x06\xf6\x080\x0b\xe5\r\\\x10\\\x12!\x14\x1c\x16\x0c\x18\x99\x19\xa0\x1aS\x1b\xd0\x1b\x95\x1b\xd6\x1a\x13\x1a\x01\x19\xae\x17\xc6\x15\xb8\x13\xe6\x11\xbf\x0f|\r\x05\x0b\xa7\x08w\x06\xfd\x03\x9c\x01j\xffp\xfd\x82\xfbz\xf9\xbd\xf7A\xf6\xc9\xf4W\xf3\xf5\xf1\xde\xf0\n\xf03\xef\x88\xee\x08\xee\x8f\xedC\xed,\xed]\xed\xbb\xed\xd4\xed\xf6\xedb\xee\x0c\xef\xe1\xef\xa6\xf0o\xf1F\xf2#\xf3\r\xf4\x18\xf5&\xf6\t\xf7\xcb\xf7\x89\xf8u\xf9\xa3\xfa\xa2\xfb_\xfc\xfc\xfc\xa5\xfdj\xfe\x1c\xff\xd8\xff\x94\x008\x01\xb4\x01&\x02\xc7\x02h\x03\xd3\x03\x19\x04X\x04\xb3\x04\x00\x05(\x057\x05C\x05M\x05]\x05`\x05R\x058\x05\r\x05\xd6\x04\xd0\x04\xde\x04\xcc\x04\x93\x04I\x04\x1f\x04\xdf\x03z\x03?\x03 \x03\xd2\x02H\x02\xea\x01\xb6\x012\x01X\x00s\xff\xe4\xfe_\xfe\xb0\xfd\xf4\xfca\xfc\xc5\xfb\x0c\xfbz\xfa\x11\xfa\xbd\xf96\xf9\x92\xf81\xf8+\xf8=\xf8G\xf8\\\xf8\x88\xf8\xc7\xf8\x0b\xf9C\xf9\xb4\xf9S\xfa\xdb\xfae\xfb\xf6\xfb\xb8\xfc\x8c\xfdA\xfe\x11\xff\x0e\x00"\x01\x16\x02\xfd\x02\x1a\x04H\x05r\x06\xd8\x07\x80\t\x17\x0bs\x0c\xb3\r\x1d\x0f\x85\x10\xac\x11\xc1\x12\xe1\x13\xbd\x14:\x15\x88\x15\xe1\x15!\x16\xe0\x15#\x15M\x14o\x13Z\x12\xe6\x106\x0f\x8d\r\xd3\x0b\xf6\t\x17\x08?\x06G\x04\x1d\x02\t\x00:\xfe\x9d\xfc\xe1\xfa"\xf9\xac\xf7j\xf6$\xf5\xf5\xf3\xf1\xf2\x1d\xf2L\xf1o\xf0\xe7\xef\x9e\xeff\xef2\xef*\xefw\xef\xca\xef\t\xf0_\xf0\xe4\xf0\x91\xf1"\xf2\xc1\xf2\x8e\xf3^\xf4)\xf5\xe1\xf5\xba\xf6\x9c\xf7R\xf8\xf7\xf8\xa8\xf9p\xfa\'\xfb\xc8\xfbk\xfc\x0f\xfd\xa0\xfd&\xfe\xb5\xfea\xff\xf9\xff\x80\x00\x0e\x01\xc2\x01\x8b\x02.\x03\xb9\x03@\x04\xc0\x041\x05\xa0\x05\'\x06\x96\x06\xca\x06\xe7\x06\x1a\x07Z\x07^\x07%\x07\xe5\x06\xa4\x06T\x06\xf2\x05y\x05\xff\x04h\x04\xcc\x03:\x03\xab\x02\n\x02>\x01]\x00\x96\xff\xf0\xfeU\xfe\xba\xfd\x1d\xfd\x82\xfc\xf7\xfb\x8c\xfb\x1f\xfb\x95\xfa$\xfa\xbb\xf9S\xf9\x10\xf9\r\xf99\xf9?\xf9\x14\xf9\x04\xf9H\xf9\xb8\xf9\r\xfaW\xfa\xc7\xfa[\xfb\xc6\xfb/\xfc\xea\xfc\xd9\xfd\xa8\xfe\x16\xff\xae\xff\xa3\x00\x96\x01g\x02,\x03\xfc\x03\xe0\x04\xa0\x05J\x06,\x07\xf0\x07k\x08\xd9\x08L\t\xc8\tM\n\xb7\n\x15\x0b[\x0b\x88\x0b\xdb\x0b@\x0ch\x0cX\x0cC\x0c=\x0c\x1b\x0c\xc9\x0b\\\x0b\xf2\n\x87\n\xeb\t5\t|\x08\xcd\x07\xf6\x06\xf6\x05\xe8\x04\xe8\x03\xf4\x02\xd8\x01\xaf\x00\x9b\xff\x99\xfe\x9b\xfd\x84\xfc\x85\xfb\x91\xfam\xf9M\xf8U\xf7}\xf6\xac\xf5\xb6\xf4\xf7\xf3o\xf3\xeb\xf2z\xf2#\xf2\xea\xf1\xc7\xf1\xa8\xf1\xb9\xf1\xec\xf18\xf2\xa5\xf21\xf3\xef\xf3\xaa\xf4d\xf5<\xf6K\xf7e\xf8j\xf9r\xfaz\xfb~\xfcn\xfdS\xfeL\xff=\x00\x16\x01\xe2\x01\xb7\x02\x87\x031\x04\xb2\x043\x05\xb1\x05\x19\x06h\x06\x9a\x06\xbc\x06\xde\x06\xe0\x06\xce\x06\xbd\x06\xb7\x06\x9d\x06T\x06\x0e\x06\xc8\x05m\x05\xf2\x04x\x04\x00\x04u\x03\xd9\x02P\x02\xe4\x01b\x01\xb1\x00\xef\xffE\xff\xaf\xfe\x15\xfeO\xfd\x90\xfc\xee\xfbg\xfb\xe2\xfa\x7f\xfaM\xfa(\xfa\xd4\xf9x\xf9l\xf9\x91\xf9\xaa\xf9\xbb\xf9\r\xfa{\xfa\xc3\xfa\xf0\xfaa\xfb\x15\xfc\x9b\xfc\x11\xfd\x98\xfd3\xfe\xc8\xfec\xff\x19\x00\xce\x00^\x01\xca\x01R\x02\x0b\x03\x9a\x03\xef\x03\\\x04\xfc\x04|\x05\xcd\x05\x13\x06t\x06\xc1\x06\xdf\x06\t\x07R\x07\xa8\x07\xec\x07\x14\x08;\x08W\x08:\x08\xfa\x07\xc4\x07\x9e\x07\x85\x07g\x07\x18\x07\xc7\x06\x87\x06\x1f\x06\xad\x05A\x05\xf1\x04\x90\x04\x13\x04\x8b\x03\xf9\x02y\x02\xfa\x01\x8b\x01A\x01\xf9\x00\x93\x00\x1c\x00\x95\xff\x0c\xff\x81\xfe\xf4\xfdq\xfd\x01\xfd\x9a\xfc\x10\xfc{\xfb\xe2\xfaJ\xfa\xc1\xf9^\xf90\xf9\x14\xf9\xf5\xf8\xbd\xf8\x7f\xf8]\xf8C\xf8Y\xf8\x8c\xf8\xc8\xf8\x08\xf9K\xf9\xb8\xf9/\xfa\xa7\xfa\x1d\xfb\xa1\xfb>\xfc\xdf\xfcs\xfd\n\xfe\xa4\xfe\'\xff\xaf\xff:\x00\xc0\x00P\x01\xc9\x014\x02\x98\x02\xf6\x02b\x03\xb1\x03\xe6\x03\xf6\x03\xe4\x03\xe6\x03\xf1\x03\xf7\x03\xfa\x03\xe4\x03\xb7\x03U\x03\xea\x02\x89\x02\x1c\x02\xc1\x01`\x01\xf4\x00\x8e\x00\x1a\x00\xa6\xff>\xff\xc9\xfed\xfe\xfb\xfd\xa1\xfd/\xfd\xb7\xfci\xfc\x1b\xfc\xdc\xfb\xbb\xfbd\xfb-\xfb\x00\xfb\xc3\xfa\xcd\xfa\xa4\xfa\x98\xfa\xbc\xfa\xd9\xfa\xfe\xfaD\xfb\x8e\xfb\xdf\xfbN\xfc\xa1\xfc \xfd\x94\xfd3\xfe\xaa\xfe"\xff\xb5\xffn\x00\x13\x01\xdd\x01U\x02\xf8\x02\x95\x03\xec\x03`\x04\x87\x04\xd3\x04\xed\x04\xef\x04\xfd\x04"\x05$\x05~\x05\x7f\x05\xca\x05\xb4\x05`\x05q\x05\x11\x053\x05\x86\x04\xed\x04|\x04\xe1\x03u\x04\xb3\x03o\x030\x03\x19\x03\xb7\x02Y\x02c\x02\x8f\x02\x9d\x02\xf4\x01\xe9\x01\xd1\x01\'\x01\xcd\x00\r\x00\xe7\xffj\xff\xf3\xfe\xc5\xfe"\xfey\xfbH\xfa\x8d\x01\x9c\x0fV\x15\x8a\x03\x89\xee\x1c\xe8\xd7\xee\\\xf7\x91\x03|\x05\x86\xfd\n\xf3\xe9\xe7\xc7\xeee\xf3=\xf8\xde\xf8\x1e\xf6\x00\xf6\x80\xf6\xe9\xf8\xbd\xfe\n\x04\x1f\x02W\xfb\x17\xf8B\xfcI\x028\r+\r\xcf\x04u\xff\x7f\x00\x93\x06\xa5\x0c\xaf\n\xa1\x02\xbb\x005\x05\n\x0b5\x0b\xe4\x06"\x02\x90\xff\xbf\x04\xb3\x06\x0c\xfff\x04\xde\x02\xa8\xff\x01\x01 \x03\x17\x07\xd3\xff\xed\xfd\xc0\xfe\x97\xfc\xd3\xfe\xbf\x03a\x04\xed\x01\x92\xfc\x02\xfbt\xfe\xfd\x00\xd3\xfe\x80\xfdO\xffj\xfeE\xfc^\xfe\xab\xfcH\xfe\xf4\xfd\x13\xf8\x97\xf7\xa8\xf9\xea\x034\xfcN\xfa\x88\xfbQ\xf6c\xf8S\xfc+\x02\x1d\xf9\xac\xfc3\xfeZ\xfau\xfb\xe8\xfeP\x02\xe3\x00\xd3\xff\xa1\xfc(\xfdf\xfd\xe7\x01t\x07\xe8\x04?\x02\xda\x01\xc2\x00\x8e\x01\xac\x05-\x05\x00\x03\x1c\x04\xae\x07\xb2\x04 \x03\x87\x04f\x04\xce\x06\xa1\x06F\x03\xc3\x01\xb5\x02\x93\x03\xba\x05\xbc\x04\xeb\x04\xb9\x01\xd1\xfe`\x00\xa4\x04\x00\x03\xc0\xffx\xff\x9a\x01\x00\x00w\x00\xa4\x02u\x00\xcd\xfd"\xfd\xda\xfdZ\xfeA\x02\\\x001\xfes\xfb\xfa\xfa\xe1\xff0\x01\x9b\xfd\xdb\xfc_\xfdB\xfc<\xfc\x9e\xfd\xad\xfeG\xfd\xf5\xfb\xb4\xfa\xcb\xfb\x0e\xfdd\xfc\x0c\xfd\xdd\xfd\x8f\xfco\xfb-\xfb\xae\xfc\xc3\xfe\xe5\xfeg\xff\xdc\xfe\x0e\xfe\x80\xfc\x11\x00\xf1\x03\xce\x01\x0f\x03=\x02\xc1\xfe\xbc\xff\xb6\x04\x00\x05\xbd\x025\x03a\x01\xfe\x00\xd1\x05+\x05\xca\x01s\x01\x06\x05\xb6\x03$\x02\xad\x019\x02\x9e\x02\xf0\x01\x84\x01C\xff\xab\xfe\xa2\xfe,\x02z\xff\x0c\xfd\x99\xfb\x85\xfc\xc5\xfe\xb4\xfd\x81\xfc\xd9\xfcn\xfe_\xfd\x88\xfb\x8f\xfc2\xfe\xfc\xfd\xd2\xfb\xbd\xfa\x9c\xfe\xc0\xfb\xa0\xfcC\xfe\xa0\xfc\xfc\xfb?\xfc\xc8\xfe?\xfdw\xfc|\xfd\x8d\xfd!\xff\xe9\xff8\x00V\xfe\xce\xfdO\xff\x0e\xff\x15\x00\xb3\xff\'\x02\xd6\x01\xc4\x00\xc5\x01_\x03F\x01\x94\x00h\x02\xbc\x02\xe1\x04W\x04\x03\x04\x87\x02R\x04v\x04\x0e\x04U\x07n\x05q\x02\x18\x03\xe4\x03\x14\x03\xeb\x03\x8b\x06^\x04\xa4\x02\t\x02F\x02\x92\x00|\x02\'\x03\xc7\xff\x16\x017\x01m\x01\xeb\xff\xc2\xffr\xfd\xf6\xfd\x0c\x00h\xfe$\xff\x92\xffw\xfc\xe0\xfa\x16\xfe+\xfc2\xfbC\xfd\x7f\xfbw\xfb\xa0\xfc?\xfc\xd0\xfb/\xfbY\xfb\xdc\xf9\x05\xfc>\xfdk\xfd\\\xfd\x8a\xfc\x18\xfe\xa8\xfd"\xfe\x06\xff\x19\x00!\xfd\xfa\xfe\xce\x00\xae\x00\x82\x01S\xff\xfa\xff,\x01;\x02X\x03\xf2\x03\xf0\x01\xc2\x01\xf9\x01E\x02\xdf\x03e\x05\xb2\x04\x81\x02\xc6\x03\xdf\x02\xab\x03^\x03\xc2\x03\x19\x02\xa6\x02"\x04\x1c\x02\x84\x02\x95\x01n\x02\x96\x00X\x00:\x00\xf3\x00\xb7\xff\xbe\xfe\xac\xff\xa1\xfe\x90\xfe3\xfe:\xff|\xfe\xe3\xfe4\xfe\x15\xfe\xa5\xfe%\xfe\x82\xfc\x07\xfd\x00\xfe\xbb\xfe\xbf\xfd0\xfc\x1d\xfe\xf9\xfd\xb4\xfd\x8c\xfc\x91\xfd\xe6\xfb\x02\xfc\x86\xfd\n\xfd\xea\xfeb\xfd\xa8\xfd\xd0\xfc\x8f\xfe\x04\x00-\x00r\xfdG\xfd\x15\x01v\xfe{\x00\xff\x00s\x01\xb2\x00W\x01\x7f\x025\x02\x85\x019\x00b\x04\xd1\x03T\x03\xe7\x02\x9c\x02 \x04\xc4\x03\r\x04\xcf\x04j\x04$\x02e\x02\xed\x04\xa9\x03Y\x03\xfc\x04\xe7\x03{\x02\xeb\x01\x9d\x02\xc9\x00c\x02R\x02\x9e\x02\xbd\xffr\x01\xc4\x01\x9f\x00\xda\xfe\xeb\xfcu\xff9\xfc\xca\xfe5\xfeG\xff\xdf\xfd\xe8\xfb\xb0\xfb}\xfbI\xfb\x8b\xfd\xd5\xfa\xad\xfa\x11\xfe\x94\xfb\x0c\xfd\x99\xfdG\xfd\x98\xfa\xec\xfa\x00\xfd\x86\xfe\xc3\xfb\x1e\xff0\xfeX\xfeZ\xfe\x95\xfd\xf4\xfe\x9f\xfe{\x01\xfc\xfe\x0b\x01-\x00\xe3\x00\xf5\x00e\x01b\x02"\x02\x9a\x01\xd7\x01\x82\x01I\x04l\x04.\x01\x91\x03e\x03\xb0\x02\xc9\x03\x9a\x03I\x02\xda\x02;\x01\xc6\x03\xb7\x02\x08\x01\xf1\x02\x8f\x01\xfb\xff\xc4\x00\xe2\x01\xb6\xff"\x00(\x00\x01\x01d\x00c\x00\xba\xffW\xfe\xcc\xfe\xb3\xfe\xaf\x00O\x00\xa8\xfe]\xfe\xb4\xff\xed\xfe$\xfe[\xfe\x93\xfe\xe5\xfe\xdf\xfeF\xff\xe0\xfd\x96\xfdN\xfe\xf3\xfdQ\xfc\xd0\xfdN\xfeQ\xfe\xd8\xfe\xbf\xfb\x17\xfb\x03\xfd\xef\xfc\x81\xfc\xae\xfdM\xfch\xfdd\xfee\xfd\x9f\xfb<\xfd^\xfe\xf9\xfe\xd3\xff%\xfe[\xff\xce\xfe\xb2\x00J\x01\xa6\x00s\x00\xc4\x01\xae\x01\xad\x00H\x03\x8a\x03\x9c\x02w\x02\xbf\x02\x81\x02\xfd\x05:\x033\x01\xcf\x03\x8e\x03\xee\x03\xba\x02\x1e\x03F\x02<\x02p\x03\xc4\x02m\x01\x15\x01\xb7\x01<\x01~\x00_\x00}\x00(\xff\xc2\x00d\xff\xeb\xfd4\x00\\\xfe:\xfd,\xfe\x0e\xfe\x0b\xfe\xc9\xfdX\xfe\xf5\xfc%\xfd\xa9\xfd\xb5\xfc\x1b\xfe=\xfe*\xfd\x85\xfdG\xfet\xfd\x8e\xfe4\xfe\x0b\xfe;\xfd\xb2\xff\xfb\xff[\xff\xe6\x00\xd1\xfe\x05\xff\xa7\xff\xd9\x00\'\x01\xe4\x00P\x00\xf1\x01p\x01t\x01\xbc\x01\x10\x01\xd9\x011\x01;\x02\x9c\x02\x1e\x022\x02s\x01\x86\x01\xab\x013\x01N\x02\xf6\x01\xc1\x00\x94\x01\xe2\x01\x84\x01\xf9\x01#\x01\xf1\x00H\x01\x06\x01\x86\x00\xac\x01F\x02\x01\x01I\x00\x18\x01\xb0\xff5\x00\xd1\x01\xe8\xffH\x01F\xff\xb5\xff\xd7\xffq\xff\xf2\xfe0\xffR\xffD\xfe\x08\xffS\xfd\x0e\xfe\xdd\xfd\xa0\xfdY\xfeD\xfd\xdf\xfc\xae\xfd\xa2\xfd\xaf\xfd>\xfd\xea\xfc\x95\xfc\x95\xfd\xc6\xfd\x8b\xfe\x92\xfe\xc2\xfdb\xfeD\xfd\xb5\xfe\xe0\xfd\x1b\x00\xe6\xfe\x89\xff\xa3\xff~\xff\xf9\x01\x16\x00\'\x01\x0e\x01\x84\x01$\x00J\x02\xed\x01>\x03\xcf\x03\xc8\x02\xfa\x020\x028\x03\xfb\x02\xb2\x02\xc4\x02P\x04j\x03\xa3\x03w\x02\xb6\x03\x85\x01N\x02\xa2\x02\x0c\x03_\x017\x00\xf3\x00t\x00L\x02\xaa\xff\xce\x00\x94\xfe\xc0\xfe]\xfe&\xff\xd8\xfe\x11\xfe\xb2\xfd\'\xfd\xea\xfdr\xfc\xea\xfc\xe3\xfc\x95\xfc\xc8\xfd\x14\xfcT\xfc\x87\xfc\x92\xfc\x9d\xfc\xc7\xfdS\xfe\xdd\xfc<\xffR\xfd\r\xfeJ\xfe2\xffo\xfeG\xff\xc0\xffN\xff\xf2\x007\xff~\x01\x03\x00F\x00\xa5\x00\x7f\x01\x85\x01\xfa\x00\x82\x02\xe2\x00A\x02~\x02\xa2\x01\x8b\x00$\x01J\x02\xe0\x017\x03;\x01\x8a\x00\xc0\x00\x11\x01V\x01c\x02\xff\x01\x05\x00\xd1\x00\xf8\x00Z\x00\xa1\x01_\x01\x06\x00\xb8\x00\x16\x00c\x00\x81\x00{\x00\xbe\xffP\x01V\xff\x95\xff\xf7\xfe\x97\x00\xd8\xfe\xc2\xfe\\\x01<\xfe\xa3\xfeS\xfd\'\xff\xdb\xfe\x0f\xffO\xfdv\xfd\xc8\xfd\xb7\xfc\xd0\xfd\xad\xfd\x84\xfd\xef\xfct\xfd\xe8\xfcX\xfe\x05\xfd\x03\xfd\xbd\xfe+\xfd\xf3\xfe\xd7\xfd\x9f\xffc\xfe\x0c\xff\x15\x011\xfe\xbd\x00\x95\xff~\x01^\x00F\x01\xc2\x00\xf4\x01l\x02[\x00\t\x03\xb2\x01\x06\x03\t\x02\xb8\x02\xdc\x01{\x02\x95\x02|\x02Y\x02<\x02i\x02\t\x02B\x02.\x01e\x02v\x01B\x01\x9a\x01=\x00\xbd\x01\x9d\x00\xcc\x00\x0f\x00\xcd\xfev\x01\xf8\xfe\x8b\xff\xa9\xff"\xff\xb5\xfe\xad\xff\x1d\xfe\xf2\xfdT\xff\n\xfe8\xff>\xfe\xed\xfd\x8f\xfd\x12\xfe[\xfe\xd1\xff\xd7\xfe\xd1\xff\xfc\xfd\x00\xfe2\xfen\xfe\xa5\xffR\xffc\x00Q\xfe\xf5\x00\xad\xfd\x95\x00\x9e\x00\xfa\xff\x17\x00\xaf\xfe(\x01\xf8\x00\x87\x02\x1d\x00w\x02\xcb\x00\x1d\x02\xe6\x02I\x01\x05\x03\x99\x01\xb3\x02\xcf\x01\xe3\x02"\x02?\x02\xe6\x01=\x01\xfb\x01\x7f\x00O\x01u\x00:\x00n\x00\x08\x01T\xff\x98\xff\x16\xff\xbc\xffm\xff\x89\xffV\xff\xd9\xfd\xbb\xff9\xff0\xfe\xc2\xffO\xfe\xa9\xfe\xc5\x00_\xfd\xa7\xff\x02\xfe\xdb\xff<\xfe\x93\xff\x1c\xff\x1b\xff\xdd\xfer\xfe\x8d\xff/\xfe\x1e\x00U\xfd[\xfff\xfd\xa1\xff\x15\xff\x8c\xfe\xb0\xfe\x13\xfew\xfe\x87\xfeW\xff\x07\xff\x15\x00\xc3\xfe\xa5\xff\xfd\xfe\x1f\x00\xae\xff\xa3\xff\xc2\x00Q\x00g\xff\xed\x00\xcf\xff\xac\x01b\x01\xa1\xff<\x01\xd2\x00\xd2\x01S\x00\xee\x01\x91\x00\x0b\x02\xa8\x02\x96\x01\xcf\x01\x88\xff\xe1\x00y\x01G\x01g\x03\xa5\x01c\x00\x19\x01\xeb\x01\xc6\x00E\x02\xcc\x00\x08\x01a\x01\x89\x00\x1c\x01\xad\xffd\x02\x8b\xff\x81\xff\xc3\xff\x86\xfe\xd9\xffy\xff!\xff\x13\xff\x12\xfer\xfe\x19\xff@\xfe\xa6\xfe&\xfd\xe0\xfd9\xffp\xfe=\xfe\xce\xfd\xde\xff\x86\xfe;\xfe\x85\xfft\xfd?\xffq\xff\xa6\x00\x18\xff\xce\xff\xa6\xff~\xfe9\x01u\x00\x83\xff\xbc\x00\xce\x00\xda\xff\x01\x00\x9e\x00\xa1\x00I\x00\x96\x015\x00\x96\x018\xff\x15\x02\x03\x00\xc8\x00\xcf\x01E\xff\xfe\x01\x95\x00\xb7\x01\xfe\xffO\x01\'\x01\xe4\x00\xec\xfe\x15\x01\xed\xff\x9c\xff\xc8\x00\xc8\xff*\x01\xde\xfe*\x00\xc6\xfe\xd0\xffl\xffP\xfe/\x01\xbb\xfe\xaf\xff\xa3\xfe\xf6\xfe2\xff?\xff_\x00\xc4\xfd\xd2\xff\xeb\xfe\xea\xff\x7f\xfe\'\xff\n\xff\xb0\xfe\xc0\xffm\xfe\xf7\xffr\xff\x89\xff\xd9\xfe\xdc\xfe\xab\xfe\x1c\xff\xe8\xff\xf7\xfe7\xfe\xdc\xff8\xff;\x00\xbe\xff\x94\xfe\x94\xff\xc5\xff{\xff\xc6\xff*\x00\xc4\xff\xb8\xff\x8c\x01\xea\xff#\xff\xe6\x00K\x01\xe3\x00Z\x00\xd0\xff\xf8\x01,\x00\xb9\x011\x02b\x00q\x02\x1b\xff\x08\x03$\x00\xb4\x01&\x01\x8c\x00\xee\x01a\x00\x9c\x03_\x01\xf5\xff]\xff\xdc\xff\xc0\xffN\x01\xd5\x00\x93\x00\x10\x00t\xff\x95\xff\xae\x00S\xff\x1c\xff\xa6\xfe\xa9\xfeR\x00g\x00u\xff\x08\xffQ\x00\xaa\xfeD\xff`\xfea\x01\x88\xff\xd6\xffU\xff\xf2\xfek\x00/\xff\xcd\xff\xc4\xffq\x00\x9b\xff\x93\x00\xac\xff\x00\xffR\xff`\x00\xf4\x00N\x00\xef\xff\xbd\x00\r\x00w\xff\x91\x00\xc1\xff\xe5\xff#\x01\x15\x00\xc6\x00\x08\x00\t\x00\xd0\x00F\xffd\x00\x83\xff\xde\x01\x8f\xff\xd4\x00\xa2\x00\x7f\x00\xea\x00\x08\x01\xe2\x01\xe0\xff\xc8\x01\xce\xfe\xa7\x01\xdc\x00/\x01\x1c\x01-\x00Z\x01\xa2\xff4\x00\xcb\xff\xb0\xff\xfe\xfe\xa2\x01F\xff\x81\xff\x9a\xff\xf2\xfe~\xff5\xff\x1b\x00\xf5\xfe\x13\xff\x96\xfe\x14\xfe\xa0\xff\xe2\xffx\xfe\x17\xfe\x94\xfd\xf6\xff\xf6\xff\xba\x00:\xff6\xff\xaa\xff.\xfey\x00\xe8\xfd\x04\xff9\x00\x86\xff.\x02\xba\xfe6\x00(\xff`\x00t\xfe\xe7\xff2\x01*\xff\xe3\x01\x0e\x00\x19\x01\xc9\xfe\x9d\x00\xeb\xfe\x00\xff\xce\x00W\x01\x9d\x01\xdc\xfea\x00\xdd\xff\xd3\xfe\xa6\xff\xe1\x00\xe7\x00\xbe\xff|\x01\x11\x00\\\x00@\x01\xc1\xfe\x94\xff\xb6\xff\x07\x01\x81\xff+\x01\x05\x00\xa1\x00v\xff\x90\xff&\xff\xf7\xff}\x01i\xfe\xe9\xffn\x00`\x00\xde\xfe\xdb\x00Y\xfeh\x00\xac\xff\xd0\xff\xda\xffR\xfe]\xff\xb0\x00\xc9\x00\x90\xff.\xff\xcf\xfe\xbf\x00\xd3\xff\xcc\xff>\xfe3\x00\xde\xffJ\x00\x8e\x00\x8f\x006\xff\xd2\xff\x98\xfe\xea\xfe\x95\x00i\xff7\x01\xc9\xfe:\x02\xbc\xff\x19\xff\xba\xfe$\xff\xb3\x00\n\x00\xd8\x01S\x00K\x00\xbe\x00\x12\xff\xa8\x00\xc8\xff\x91\xfe\xd1\x01L\xff\x9d\x00\xc2\x00\xd2\x01\xb1\x00\n\x00\xf7\xff\xdc\x00\x10\x01\xe6\xfd\xd3\x00W\x00\xa7\x00{\x02\x08\x01}\xfe|\xff\xef\xffI\xff\xb1\xff_\x00\xab\xfe\xdd\xff\x8c\x00\xde\xfe\x8f\xffh\xffI\xff|\xff\xea\xfe\\\x00C\xffG\x00~\x00\xb0\xff\x04\xffJ\xffR\x00\xee\xff\xca\xff\xc0\xfd\xe9\x00\xe3\xffN\xff\xb6\xfew\xff\xf7\xffd\x01\xab\x00t\x00<\x00\xbb\xfe\xaa\xff\x7f\xfeK\xff\x14\x01L\x01m\x01\x10\x01a\xff~\xff.\xffT\x00\x96\x00\xcf\x00G\x00m\x00\x88\xff2\x00R\x00e\x00\x83\x01\xe7\xff\xef\x00}\x00"\x02\xce\xff]\xfe\x10\x01\r\x00\x8a\xff\x0e\x00w\x00\x8b\x00\x07\x02\xd3\x00\xd0\xfe\x8b\xfe\x8a\xff\x80\x00q\x00\xf9\x00\xc9\xff\xcb\xfe\xcf\xfe.\x00\xbe\x01T\xff\xbb\xfc\x80\x00\xca\x00\xa5\xff\'\x00\xc5\xff\x8a\xfd_\xfc\xd7\xff"\x022\x02Q\x00\x95\xff\xf7\xff\x8b\x00\x80\xfe\xbd\xfe\xaa\xff\xbb\xff\xd9\xfe\xbb\xff\xf1\x01<\xff\x03\x01%\x01\xd3\xff\xe5\xff\x85\xff\x88\x01`\xff\x15\xff\x06\x00\x13\x00<\xffV\x01\xe0\x02\xa4\x01\xca\xff\xd4\xff\x03\x000\xfe%\xff\x12\xff\xea\x01\xca\xff\x94\x00\xb2\x00\xd6\xff\xab\x01\x0e\xff\x16\x00x\xff\xa2\x00X\x029\x00\x81\x00*\x01W\x00\x81\xff\xcc\x008\xfe{\xfd\xae\xff\xcd\x02<\x04n\x00\x12\xff*\xfe\xff\xfd\xe5\xffz\x02\x9a\xff\xa4\xfdW\xff\x9c\x01\xc6\x01J\x00\x0b\x00J\xff2\xfd\x00\xfe\x18\x00`\xffk\xff\xaf\xff\x8a\x00\xf5\xff#\x01\xfe\xff\xac\xfe\x99\xff\xe3\xfe\r\x00H\xffn\x01\xeb\x01G\x00\xe2\xff\xc9\xfe\xf2\xfe\x00\xff\x91\x00\xb5\x01\xda\x01\x9f\x01\xd5\x01\xb3\xffN\xfe\xec\xfe\xc1\xffA\x01i\x00\xf7\x00\x00\x01h\x00\x88\x00\xd4\xfei\xfe\x8b\xfe\x86\xff\xf6\x00\xc4\x00\xa9\xff\xf4\x00\xd0\x00a\xfe\xcb\xfe\x1d\x00v\xfe\xe5\xfd\x06\x00C\x00D\x01\xfc\xff\x8c\xfe\x14\xfe\xe3\xfd\\\xfeX\xfdB\xfe(\xff\xb3\xfeh\xff\x97\xff\x83\xfe?\xfd\xed\xfc\x8d\xffO\xfe\x06\xfe\x9c\xfd\x82\xfd\xfd\xff\xec\xffB\xff\xb3\xfd\xb3\xfdy\xfem\xfe\xc1\xff\xee\xfe8\xff\x14\xff\x80\xff1\xff7\xfe\xe5\xfd$\xfc\x1b\xff\xa5\x004\xff\x08\xfe\xce\xfd\x81\xfec\xfe\xce\xfeC\xfe\xc2\xffo\xff\xe7\xff\xb5\x00H\x00G\x00\xb5\xff\xc0\xfe\xe6\xfe\x89\xff7\x00\xb0\xff\xb0\xff\xc5\xff\x83\xfe\x9a\xfeQ\xff\xbb\xfeX\xff\x1a\x00\xf7\x00o\x02\x1c\x04s\x05\x86\x07\xbd\t\xac\x0bH\x0e\xa9\x10\xef\x12U\x14O\x15\xd5\x15\xaf\x15\x1f\x15\x0b\x14h\x12\x14\x10\x10\r8\nG\x07z\x03\xe9\xff\x01\xfdt\xfa\xa5\xf7\x9b\xf5\xdb\xf3\xf8\xf1B\xf1\xc6\xf0$\xf0\x05\xf0,\xf0g\xf0\xd0\xf0\xbf\xf1\\\xf2\x91\xf2\xa7\xf3\t\xf5\x01\xf6\xe2\xf7\x1e\xfa\x92\xfb%\xfd!\xff\xec\x003\x02_\x03\xbe\x03\xc9\x031\x04\x13\x04\xaa\x03\x1c\x03\x06\x02\xd3\x00\xa7\xff\xc0\xfe\xeb\xfd\xa0\xfc\xfe\xfb\xa3\xfb]\xfb7\xfb{\xfb&\xfc9\xfc}\xfcD\xfd\r\xfe\x17\xff\xc6\xff+\x00O\x01t\x02&\x037\x03`\x03\xb9\x034\x04\x01\x04\xb3\x03\x92\x03T\x03\xb7\x02\xc7\x01k\x00\xf0\xfff\xff\xff\xfe\x15\xff\x91\xfe.\xfe6\xfd\xdc\xfb4\xfb\x0c\xfd\xd4\xfeI\xff\x9c\xfd\xcc\xfc\xc8\xfd%\xff\x81\xff8\xfeb\xfd\x9c\xfe\x87\xfff\xfe\x03\xfeO\xfe4\xfe\xb9\xfc\xf8\xfb\xcc\xfbv\xfbT\xfb\xd4\xf9\xe8\xf8Z\xf8\xdc\xf8@\xf8\xe2\xf7\x95\xf7\xe9\xf7\xf1\xf7#\xf8\xd4\xf8\x0f\xf9\x10\xfa\xde\xfa\n\xfcf\xfd$\xff\x16\x01L\x04\xb9\x07\r\x0bR\x0f\xa3\x13\xa1\x18@\x1e"#$\'0*\xb7,I.\x9f.\x82-\x0f+d\'\xb1!R\x1b\xb1\x14\xae\r\xb6\x06^\xff\x05\xf8\xf9\xf1\x02\xed\xdd\xe8r\xe5\x99\xe2\x9a\xe0\xb8\xdf\x9c\xdf\xd8\xdf\x96\xe0\xff\xe1t\xe3\xc3\xe4\x87\xe6\xfb\xe8\x05\xec\xe1\xeej\xf1_\xf47\xf8h\xfcg\x00:\x04\xc2\x07\x00\x0b\xcb\r\xc4\x0f\xdf\x10"\x11{\x10\xd5\x0e;\x0c\xe9\x08|\x05\xa4\x01`\xfd\xf9\xf8\x18\xf5\xef\xf1S\xefW\xed\xec\xebw\xeb\xc9\xeb\xb5\xec\xe3\xedy\xef\xd1\xf17\xf4\'\xf6/\xf8\x9e\xfa6\xfd\t\x00N\x02\x11\x04|\x06)\tD\x0b\xa4\r\xa3\x0f \x11c\x12Y\x13\xd3\x13\xac\x13\x1b\x13\xbe\x11\x91\x0f\x14\r\x8e\n\xcb\x07\xc1\x04\xfd\x01F\xff\x83\xfc\xac\xfa=\xf9\xb8\xf7\xc8\xf6_\xf6!\xf6\xd1\xf5\xde\xf5\xf1\xf5\xe0\xf5)\xf6Z\xf6I\xf64\xf6V\xf6M\xf6\x11\xf6&\xf6Z\xf6\x8f\xf6\xb4\xf6\x89\xf6\xa6\xf6\xe4\xf6N\xf7\xef\xf74\xf8\xa5\xf84\xf9\xe4\xf9]\xfa\xe9\xfa\xf4\xfb\x97\xfc\xc3\xfc\t\xfd^\xfd\xd7\xfd\xfb\xfd"\xfe\x04\xfe\xab\xfd\xfa\xfd\x0f\xfe\x9a\xfe$\x002\x02\xd6\x04{\x085\r\x83\x12\x7f\x18u\x1f2&\x19,\xad1V6)::\x11\xc6\x14[\x17\xbc\x18\xf2\x18\n\x18\xbf\x15\x00\x12)\r\xc7\x07\xd9\x01Q\xfbW\xf4\xcc\xedJ\xe8\xc9\xe3M\xe0\xde\xdd\xbb\xdc\x11\xdd\xdc\xde\xe1\xe1\xca\xe5k\xea\x93\xef\n\xf5X\xfa\x97\xff\xb4\x04p\t\x8a\r\r\x11\xe1\x13]\x16\xa2\x18b\x1a\\\x1b\xd7\x1b\x16\x1c\xd3\x1b\xfa\x1a\xb7\x19\xe0\x17n\x15Z\x12\x93\x0ea\n!\x06\xb1\x01V\xfd#\xf9"\xf5\xc3\xf1E\xef\xa6\xed\xfe\xec\xdc\xecR\xedm\xee\x16\xf0\x1c\xf2V\xf4\xb5\xf6\xcf\xf8|\xfa\xe3\xfb\xf5\xfc\xc5\xfdl\xfe\x86\xfe\'\xfe\xa8\xfd\x07\xfdi\xfc\xb0\xfb\xc7\xfa\xe8\xf9\xf3\xf8\xf6\xf7-\xf7\x8b\xf6\xe2\xf5N\xf5\xcc\xf4E\xf4M\xf4\xaa\xf4E\xf5\xf6\xf5\xe0\xf6\xec\xf7\x0e\xf9\x88\xfa\x05\xfc`\xfd\xd6\xfe\xd3\xff\xbb\x00\xa3\x01\x8a\x02 \x04_\x05Z\x067\x08~\x0b\xf3\x10i\x17\x15\x1d\x87"a(\x02/\xe45\xea:\x96=\xaa>N>\xe9; 7O0\x12(\xa4\x1e\xb0\x13\xbf\x07\x96\xfc\xe4\xf2E\xea\xe9\xe1 \xda\xac\xd4\x15\xd2[\xd1m\xd1\x15\xd2\xfe\xd3\x03\xd7c\xda\xef\xdd\xa4\xe1\x97\xe5^\xe9u\xec`\xef\x12\xf3\xc4\xf7\xb7\xfc\xd3\x00>\x04X\x08C\r\x01\x12\x84\x15\xae\x17\xbe\x18\xb2\x18\xff\x16\x9b\x13\xd8\x0e/\ts\x02\xb2\xfaV\xf2\xb1\xeam\xe4\x1b\xdf\xa3\xdaI\xd7\xd4\xd5\xa9\xd6\x8a\xd9\xb5\xdd\xc1\xe2\xab\xe8J\xef`\xf6V\xfd\xe1\x03\xfd\tH\x0f~\x13\xaa\x16\xf8\x18\xfd\x1aX\x1c\xd8\x1cy\x1c\xad\x1b\xd4\x1a\xf2\x19\x8f\x18\xb8\x16\x89\x14\xf7\x11\x08\x0f\xb5\x0bT\x08\xc0\x04\xcd\x00\xad\xfc\xb8\xf84\xf5l\xf2y\xf0,\xef\x9e\xee\xcd\xee\xde\xef\xbe\xf1.\xf4\xc5\xf6\x93\xf9Z\xfc\xf0\xfe(\x01\xcb\x02\xce\x038\x04\xf5\x03\x12\x03\xb2\x01\x12\x00;\xfe\x10\xfc\xc0\xf9\xb5\xf7\x02\xf6q\xf4\xe6\xf2\x94\xf1\xb9\xf01\xf0\xea\xef\xee\xef3\xf0\xd2\xf0\x98\xf1U\xf2n\xf3 \xf59\xf7\'\xf9\xdf\xfa\xea\xfcZ\xff\xa0\x01\xf2\x02\xb6\x03\\\x04\xb3\x04a\x04\xf4\x02Q\x01V\x00\x1a\xff\xcd\xfd\x1a\xfd\xee\xfe\xa4\x03 \t\xbf\x0eq\x15\xf6\x1e\xb7*!5\xd4<\xa0B\x0fH\x11L\xf7K\xc7G\x00A\x868\xc2-* \xad\x11t\x04Q\xf8C\xec\xa4\xe0p\xd7\r\xd2y\xcf\x0b\xceB\xcd\x12\xce\xd2\xd0\x8c\xd4\x03\xd8\t\xdbo\xde\x16\xe27\xe5\xc0\xe7\xab\xea\xf0\xee$\xf42\xf9\x12\xfe\xfa\x036\x0b\x94\x12\xb5\x18?\x1d\x9a \xb9"\xca"K %\x1b3\x14\xc0\x0b:\x02%\xf8D\xeeR\xe5\x83\xdd\x1b\xd7\xbc\xd2\xc5\xd0Y\xd1\xf7\xd3\xfd\xd7!\xddL\xe3p\xea\n\xf2\x85\xf9K\x00\xeb\x05\xb8\n\x0b\x0f\x1d\x13\xae\x16\\\x19\x02\x1b\x1d\x1c\xe6\x1c\xcc\x1dn\x1e{\x1e\x81\x1d\\\x1bQ\x18\xcc\x14\x0e\x11\xa2\x0c\'\x07\xf8\x00\xd7\xfaJ\xf5\x9d\xf0$\xed\xe9\xea\xbf\xe9\x87\xe9\x99\xeaN\xedp\xf15\xf6\xea\xfa\t\xff\xba\x02\x16\x06+\t\x85\x0b\x83\x0c\x02\x0cr\nj\x08g\x06P\x04\x01\x025\xffA\xfc\x8c\xf9m\xf7\xe7\xf5\x9b\xf4\x1f\xf3I\xf1]\xef\xee\xed\x1b\xed\xb7\xec\x8d\xec\xb3\xec\x04\xed\xf0\xed\xc2\xef^\xf2\xea\xf5\x80\xf9\x9c\xfc\x87\xff&\x02\xde\x04\x1e\x07\xfc\x07\x08\x08\xab\x07s\x06\xc5\x04\xa2\x02~\x00\x03\xff4\xfdq\xfa\x8e\xf7`\xf5V\xf4\xc2\xf3\x85\xf2Y\xf1\x9c\xf1\xa8\xf3X\xf8\n\x01\xb7\r\x0b\x1c\xac(\xf52\'>=K\x89V\xd6[\x1d[\rW\xe5P\xe6F\xf08\\)\xda\x19\xa4\t\x7f\xf8\xe8\xe8\xaf\xddo\xd6\xf4\xd0\x92\xcbx\xc7\xd8\xc5,\xc6]\xc7\xa3\xc8\xc0\xc9\x00\xcb\x88\xcc7\xcf&\xd4\x9d\xdb1\xe5\x82\xef3\xfa\x9c\x05\t\x12\xb5\x1e\xea)t2\x017\x957\xbc4\xfa.\xbe&k\x1c@\x10m\x03q\xf6R\xeaK\xe0\xed\xd8/\xd4\x10\xd1\xf7\xcei\xce\xa2\xcfu\xd2\xef\xd5\x98\xd9\xa2\xdd=\xe2\x88\xe7\x82\xed\xa1\xf4\r\xfd\xeb\x05\x85\x0e\xef\x16*\x1f\x98&\xe5,H1;3{2D/\x14*\x18#]\x1aa\x10\xad\x05\x90\xfb\xe8\xf2\xdc\xebm\xe6\xdc\xe2:\xe1G\xe1\xd0\xe2\xa1\xe5n\xe9\xbd\xed\xa8\xf1.\xf5\xe9\xf8\xca\xfda\x03j\x08T\x0c&\x10\xa5\x146\x19\xa8\x1c\xac\x1em\x1f\xd5\x1eM\x1c\xb6\x17\x04\x12\xcc\x0b\xe2\x04D\xfdB\xf5\xe3\xed\xfb\xe7\xdc\xe3\xf8\xe05\xdf\xc1\xde\xf9\xdf]\xe2\x8d\xe5o\xe9\xb4\xed\x0e\xf2\xd6\xf5_\xf9o\xfd\x9e\x01\x7f\x05\xfd\x07n\tt\x0b\xbb\rR\x0fP\x0f\xdd\r\x07\x0c\xb5\t\t\x06.\x01\x95\xfc0\xf8a\xf3\xec\xed\x07\xe9\xa3\xe6|\xe6\x17\xe6j\xe5\xbe\xe5&\xe8"\xec\x8c\xef\x9f\xf2\xc5\xf6$\xfbA\xff\x96\x05U\x12\x97%\x898\xabDdK\xefR,\\\x7fax^WUmK\x8c?X/b\x1d\n\x0f\\\x04\xa4\xf8\xef\xe9\xb2\xdc\xc6\xd5\xf9\xd2\xc8\xceh\xc8h\xc3\x10\xc11\xc0\xf7\xbf\xad\xc1\x9a\xc7J\xd01\xd9g\xe2\x0b\xee\xd1\xfdP\x0e \x1b\xd3#<*\xe7.\x061\xab0O-\xd4&\x1d\x1e\xd4\x14\xb8\x0b\x07\x03\xaf\xfa\x90\xf3\xb3\xec\x8e\xe5h\xde\xa5\xd8\x9b\xd4\xa8\xd0F\xcc$\xc8]\xc6\xd3\xc7\n\xcc^\xd2x\xdb\xca\xe6[\xf2=\xfdt\x08#\x14*\x1e\xae$\xfb\'\x87)\x9f)\x8c(\x17&\x9e"T\x1e\x97\x19\xcd\x14\xcc\x0fr\n\x88\x04\xc7\xfd\xed\xf6\x99\xf0\xb9\xeaY\xe5\x1b\xe1\xfd\xde\xc2\xde\xe8\xdfv\xe2X\xe7\t\xee\xfd\xf3\xd5\xf8\x02\x00<\r\x1a\x1c\xb5$\xb4%\xdb%\xe8)\xab-p+\xc7$\xf3\x1eY\x1an\x13=\nl\x03\x1c\x01\x97\xfeh\xf7\xaa\xed\x9e\xe62\xe4h\xe2\x01\xde_\xd9g\xd8\xbb\xdb1\xdf\xff\xe1\xc0\xe7\xaa\xf1\xd9\xfb\x14\x01&\x03\xf1\x07=\x0fO\x14^\x13\x07\x10\xf9\x0e\xf5\x0el\x0c\xef\x07\x12\x05-\x04\x9e\x01\x0b\xfb\xdb\xf3\xbb\xefS\xed}\xe8\x89\xe0\x91\xd9\xfd\xd6A\xd8\xcb\xd98\xdc0\xe1L\xe8\xe0\xef\xf1\xf5\xad\xfby\x01\xd7\x05\xef\x08\x18\n\xa8\n\xb8\x0b\xb6\x0eI\x14\x93\x1aO%\x116\x08H\x90S\xbeV\xd6WVX\x86R\xf6C(4\xa2(\xb6\x1d)\x0f>\x01D\xfbx\xfa(\xf6\x1e\xed\x90\xe3l\xda\xac\xd1\x84\xc8\x97\xc1\xa1\xbf\xa1\xc1\xf9\xc6\xe0\xce\xda\xd8\x14\xe6i\xf5n\x02\xdb\n\x9c\x0f$\x12\xe7\x132\x15\x98\x15\x06\x16\xe9\x16\xef\x18\x1e\x1b\xc3\x1aM\x18K\x15+\x10N\x06-\xf8\x16\xea\xe8\xde\xcd\xd5\x17\xce\n\xcak\xcb\x92\xcf\xa9\xd3\xea\xd6W\xdb\xb9\xe0\x11\xe51\xe8\x9e\xeb\xac\xf0\xe7\xf6\xcf\xfe\x9a\x08\xcb\x13\x0f\x1e\xe2%\xc1*\xc7,w+\xa0\'\x94"\xdb\x1c\xa8\x16U\x10\x95\nd\x06\xa8\x03K\x01\xb0\xfd\x9d\xf8-\xf3\x9a\xeeE\xea\x9a\xe6\x03\xe4\x92\xe3\x17\xe5)\xe8s\xedz\xf5P\xfeP\x06\xa7\x0b\xf5\x0e4\x11_\x13\t\x16_\x17U\x19\x02\x1e/%\xb5*\xc7+5) $\xb0\x1b/\x10\r\x04/\xf9\xc2\xef]\xe7\xc2\xe0\xa8\xdc\x99\xdb\xa1\xdb\xac\xdcK\xddu\xdd\x0b\xde\xb9\xde\xb4\xe1\xd3\xe6\x0e\xef"\xf9\xb9\x02M\x0bB\x12\x8f\x17[\x1a\x90\x1a\x1b\x19\x1e\x16\xda\x11\xa2\x0c~\x08\t\x06\xf8\x03\xc1\x00\xbf\xfb\x84\xf5\x87\xee\xd9\xe7k\xe2R\xdf\x1d\xde\xd8\xdd\xf9\xdeA\xe1\x96\xe5m\xeb^\xf0l\xf4r\xf7\x03\xfb\x87\xfe\x03\x02\x94\x06\xa2\n\xc2\rZ\x0f\x0b\x10=\x11\xd5\x12\xbe\x13~\x13\x11\x10I\x0c\x02\n\x8e\x08Q\rf\x1e\xd55\xf9B\xc9;,+_$\x07&\x0c#d\x1b\xd8\x17\xe1\x19\x8a\x18\xd6\x0e\x11\t|\r\xef\r\xc7\xff\xb0\xe9g\xda\'\xd6\xa9\xd6\x83\xd8#\xdf\x9f\xe6\x90\xe8\x04\xe5\x88\xe1\x92\xe3\xfb\xe9\x8a\xed\x0e\xec\x9f\xeaX\xee\x97\xf7\xfa\x02\x04\x0eg\x163\x18\x06\x13\x9b\x0c\r\t\xca\x07\x86\x07\xa2\x06\xc4\x03\xa5\xfe#\xf9\xe3\xf7l\xf9\xc4\xf8\x9b\xf3\xc5\xeb:\xe4\x15\xdfR\xde/\xe2\xe2\xe89\xefX\xf3L\xf5k\xf7z\xfb\x93\x00\x98\x04\x07\x07\xd8\x08\xd5\n\xd0\rS\x12\xe6\x17\x89\x1b\xaf\x1a\x1e\x15\xdf\r}\x07\xde\x025\xffU\xfc#\xfa\x1c\xf8\x8b\xf5\xf0\xf3\x0b\xf4@\xf4)\xf2I\xee\xe5\xeb\x1a\xec\xbb\xee\xc3\xf3\xe7\xfa\x08\x03\x88\x08\xb2\n\xc3\n\xa4\x0b\x18\x0e\xf8\x10\xe4\x12\xe1\x13\xf0\x15\xfb\x18\x1d\x1au\x19\xd9\x17\x0b\x16\xe1\x10\xe0\x07\x85\xff\'\xfaI\xf7\xcf\xf4\xc9\xf2^\xf1\xa7\xef\xd1\xec\xc2\xe9\x1c\xe7\xd7\xe6\\\xe8\x05\xebd\xee\xc9\xf3\xd2\xfaU\x01c\x05\xaf\x07\xce\t6\x0bL\x0b\x8e\n\x80\nh\x0bG\x0c\xfb\x0b\xa0\nW\x08\xf3\x04C\x00\xcd\xfa\xa9\xf6\xb3\xf4=\xf4j\xf4\xa4\xf3_\xf3\xd7\xf3T\xf4\xc3\xf4\xc3\xf3K\xf3\x10\xf4\x91\xf6\xb1\xf9\x9d\xfc!\xff\x8d\xff\x18\xff \xfdQ\xfc\x10\xfd\xd4\xfcn\xfc8\xfb`\xfb\xba\xfbD\xfae\xf7\x98\xf5\xb2\xf5\xf7\xf5V\xf5\xd1\xf5w\xf88\xfc\xb1\x03y\x15D.\x01=\x9c7\xc6\'\x7f#(+\x821\x0f3\xe17uB\xe0@s.\xb7\x1bR\x167\x13x\x03\x02\xf0\x83\xe8\'\xecX\xec\x12\xe6\x9e\xe2\xa5\xde\xb9\xd4:\xc6\xfe\xbeo\xc6-\xd6,\xe4t\xebJ\xf0v\xf5=\xf9G\xfb\x16\xff0\x08\xbc\x10.\x16\xe2\x1b\x81"?\'\x9b&3!\x1f\x18:\r\xc1\x047\x02*\x03o\x02\xac\xfdP\xf5!\xeb\x9d\xe0\xd1\xd8k\xd5\xd4\xd5\xb2\xd7\x97\xd9F\xdc\x92\xe0\xc9\xe5\x05\xe9k\xea\xc8\xec\xe6\xf1,\xf8g\xff\x1c\x08\x1d\x11\x1b\x16/\x16h\x14;\x13\x8e\x12F\x12\x8f\x12)\x13}\x12I\x0f"\n\x88\x04\x16\x00\xca\xfc~\xf9R\xf6\xd6\xf4O\xf5\x82\xf5{\xf5\xbb\xf5\x04\xf7\x9c\xf7\xaf\xf7\x18\xf9\xa1\xfc\xcb\x00y\x04\xf6\x06e\t\'\x0b\xc4\x0c\xfd\r\xda\r\xb9\x0c\x8c\x0c}\x10\xc2\x15\x80\x18d\x16\x92\x11\xcc\x0b\xb9\x05\xc0\x01_\x01\xce\x02\x13\x02\x94\xfd\x0b\xf8\xda\xf3\xfb\xf0\x18\xf0w\xf0\x19\xf1C\xf1D\xf1n\xf2L\xf4P\xf6j\xf8$\xfa\x04\xfb\'\xfc\x88\xfe\xc7\x01\xe8\x03\x10\x05\x9f\x05:\x05\xe9\x03]\x02#\x02\xb4\x02\x9a\x02\x0b\x01\xa2\xfe@\xfc\x17\xfan\xf8(\xf7\r\xf6R\xf4\xe6\xf1:\xf0\t\xf0\xce\xf0(\xf1p\xf1\xea\xf18\xf2\xb3\xf1\x9f\xf1\xb3\xf3c\xf7P\xfa\xe8\xfb0\xfd\xda\xfe&\x00\xeb\xff\x0b\x00\xdd\x00u\x03]\x06\x12\x08\x9b\x08\xef\x06\xfb\x057\x058\x04\xd7\x07\xf7\x16\x801e?\'3\x0b\x1a\x1e\x10,\x1a\x92$\xad+R7MA\x1d5\x8d\x16\xeb\x04I\t\x9a\x0e\xcf\x05-\xfdi\xff\x89\x01\xbb\xf6\xda\xe7^\xe2\xae\xe2z\xdc\xa8\xd2S\xd2\x0b\xe0\x08\xee-\xee\x15\xe6\xd8\xe1z\xe3S\xe5;\xead\xf8X\x086\rq\t\\\x08\xee\x0cn\x0e\xdd\x0c<\x0f\xaf\x14q\x16\xd2\x11\xca\x0e\xb1\x0e\xe2\x0b\xae\x01@\xf6x\xf1\x80\xf1)\xf1U\xefT\xee+\xec?\xe4\xab\xda\xf6\xd7O\xdd]\xe4\xc4\xe8\xa5\xecq\xf0\xb7\xf1\x92\xf0\xa2\xf1y\xf7;\xff\xef\x05S\x0b\xfb\x0fY\x13\x81\x13x\x12\xa1\x12?\x14\xb7\x15@\x16\x0f\x17\xf5\x16\xc8\x13#\x0e>\t]\x06\x9f\x03(\x01:\x00,\x00\xaf\xfdH\xf9\xfa\xf5"\xf5\x88\xf4\x8c\xf4\x81\xf6\xfb\xf9\xc4\xfb\xa9\xfb\xb9\xfc\x05\xff\xa2\xfe6\xfe\x8a\x01Z\nZ\x0f\xa8\r\xff\ni\t\xd5\x07\xa3\x04\x1b\x08r\x0fw\x11J\x0b\xac\x03\xcd\xff~\xfc\xbc\xf9c\xfcM\x01\xff\x00\x87\xfb \xf7\x9f\xf6\xe7\xf5\xd6\xf4\xa7\xf6$\xfaN\xfb\xb2\xfa\xf9\xfb\xe3\xfd\xc2\xfd\xdb\xfb\xd0\xfbk\xfd\xe5\xfe\xa9\x00\xed\x02\x0b\x04\xd8\x017\xfe>\xfc\xc4\xfc\xb5\xfew\x00Z\x00T\xfe\xcb\xfa\x89\xf8\xee\xf7\xd0\xf8\xf0\xf9\xd8\xf9|\xf8}\xf6"\xf62\xf7\xab\xf8]\xf9R\xf9K\xf9A\xf9f\xfa\xf2\xfc%\xff\x94\xff\x9c\xfem\xfd\xef\xfc\x84\xfc\x97\xfdJ\xff\x08\x00\x8b\xfe\x0c\xfc\xab\xfb\\\xfbM\xfb\x9b\xfa\xff\xf9Y\xf7\x94\xf2\x1c\xf4\x81\xff\xd0\x11N\x1f\x0f d\x14\xa0\x03\xf4\x02\xc9\x17\xa15\x88Aa8\xc0+\x9c!\x02\x1a\xd9\x15b!\xcc2F19\x1b=\x07\xe7\x06\x94\x07\x0e\xfd6\xf2\x9c\xf4$\xf82\xee\xf2\xe2n\xe4\x11\xe7\xd9\xda\xb8\xcb\x88\xd0\xd9\xe3A\xee\xc0\xe9S\xe5z\xe6\xa7\xe3\xcb\xe1n\xec\xf0\x01\xd7\r\xf2\x07J\x00w\x01\xfd\x05R\x06R\t?\x13\xad\x1a\x13\x15i\t\x93\x03\x90\x03@\x02\xf5\xfe\xd6\xff\x95\x02[\xff\xb0\xf5\xc0\xed^\xeb2\xea-\xe8\x81\xe9\xd1\xee\xeb\xf1\xac\xed\xe3\xe7?\xe7\xac\xeam\xee\xd0\xf2\x0f\xfal\x00\xb1\x00p\xfd\xf9\xfc\x11\x01\xab\x05\xa3\t\x80\x0e\xec\x12]\x13\xa9\x0f\x96\x0cQ\x0cm\r\x9a\x0ek\x10\x94\x12\x19\x12\xb1\rf\x07<\x03\xa3\x02Q\x04\xcf\x05e\x06U\x05t\x02I\xfd\xf2\xf8O\xf8\xeb\xfa8\xfd.\xfeg\xfeI\xfe`\xfbP\xf8\xb3\xf7\x84\xfa\xe0\xfd\xf8\xff\x0e\x03\x8d\x04\x95\x03\xe6\xfe\xb6\xfc\x9d\xff\xe4\x05W\x0c\xaf\x0f\x9b\x0e8\x08X\x01\x1e\xff\x9d\x02\x97\x08 \x0cx\x0b\x7f\x05\xf4\xfd\x0f\xf9\xaa\xf8h\xfbs\xfd\x95\xfey\xfd\x15\xfa\xa5\xf6\xc7\xf4\x00\xf6X\xf7m\xf8\xb9\xf9\xf6\xfa*\xfb\x15\xfa4\xf9*\xf9\x88\xf9\x93\xfa\xd7\xfcF\xff\xad\xffu\xfd\xe0\xfa\xb0\xf9\xb4\xfa\x9e\xfc\xb7\xfe\xcf\xffJ\xfe\x9e\xfbg\xf8l\xf77\xf8\xad\xfa\xe5\xfc\x9d\xfc\x8a\xfb\xc6\xf8\xab\xf7@\xf6\x86\xf6m\xf8F\xfaz\xfc\x94\xfc\xd2\xfc%\xfc*\xfa\xb0\xf8.\xf9\x98\xfc\x19\xfe\x0b\xfe!\x06\xaf\x15\x02\x1d\x13\x0fn\xff\x9c\x05u\x19\xc3\'&//6\xf3/X\x17\x82\n\xcd\x1c\xa55A7F-x(B\x1d\x07\x07b\xfe_\x0f\xfd\x1b\x06\x0f\xe8\xfd"\xfb\x13\xf7~\xe7P\xdf\xb3\xe7\xbb\xed\xf0\xe3\xf3\xdc\xc4\xe2\xb2\xe5\xeb\xdb\xd2\xd3\xec\xda\x81\xe6\x9d\xec\x8b\xef\xeb\xf3\\\xf4\xea\xedG\xebe\xf4W\x03\xff\r\xae\x0eF\x08$\x02w\x00\x05\x04$\x08\x9a\x0b\x0b\x0eY\x0c\x90\x04\x83\xfc\x01\xfb\xa3\xfcE\xfal\xf5\x85\xf5\x89\xf8\xc7\xf6/\xf03\xec\xcd\xeb\x11\xea\x9f\xe8\x16\xed1\xf5\xae\xf7\x08\xf3\x8f\xef,\xf1\xad\xf4>\xf8q\xfe\x00\x06\xbf\x08\x97\x05\x17\x03\xb1\x05\x1e\nu\x0c\xa6\x0es\x12"\x14\xe4\x10\xcb\x0c\xdb\x0c?\x0f;\x0f\xef\r\x91\x0e\x05\x0f\x9d\x0b\x83\x06\x12\x05(\x06\xba\x04\xba\x02\x03\x03\xe4\x04f\x01y\xfbM\xf9\xeb\xfa\x8f\xfaL\xf9\xdb\xfc\xc9\x02^\x00\x93\xf7P\xf4y\xf9\xd0\xfee\xff\xd0\x02X\x07\x07\x05m\xfd\x86\xfc#\x03\xc5\x07+\x06[\x06\x0f\t\xb3\x06\x12\x01\x00\xff\xf9\x02?\x04\xdc\x01r\x01}\x02&\x00\x9e\xf9\x10\xf7\x1c\xf9\xc4\xfa\xf2\xf9\x08\xf9\xd3\xf8\x8b\xf5f\xf1-\xf0\xc2\xf2\n\xf5(\xf5\x0c\xf5S\xf4\xff\xf26\xf2\xa7\xf30\xf7\x0f\xf9Q\xfa\xbd\xfaQ\xfb\xfd\xfb\xc4\xfc\x05\xff\x9f\x00\x94\x02\x89\x03%\x04\xcb\x03\x10\x03(\x03\xb7\x03\x99\x04W\x05\x12\x06\x86\x05,\x04r\x02c\x02\xa4\x02L\x03!\x04\x8c\x04\x03\x045\x02o\x02\xa5\x03V\x04C\x04\x1d\x04\xdc\x04[\x03\xc8\x03;\x05\xe6\x06\xa4\x07\xbc\x07\xcf\n\xfe\n\\\x0c\x84\x119\x17\x8f\x16\xce\x0f\xef\r\x9d\x12\xe5\x15\x99\x16m\x19q\x1b\xa0\x16\xe0\x0bk\t1\rI\x0e\xe9\n\x0c\x08\x9c\x08\xc8\x02\xe5\xfa\xd0\xf7u\xf9\x9c\xf8\x07\xf3\xf5\xf0%\xf2\xf2\xf0,\xec\x81\xe8\xf8\xe8\xda\xe9E\xe9\xb9\xe9*\xed\x80\xef\xfb\xec\xba\xe9\xcf\xeaQ\xf0\x86\xf3\xd3\xf4V\xf7\x90\xf9P\xf94\xf7\xbe\xf9J\xfe\x12\x00\xa0\xff\xe4\x00\xe7\x03\xa8\x032\x01\xe1\x00\xb8\x02\xd4\x02\xf6\x00\x88\x01\xfe\x03\xc6\x035\x00\xc5\xfdt\xfe\xa2\xfe\x03\xfe`\xfe\x80\x00\x98\xffz\xfc\x11\xfb\xcf\xfcf\xfe\x0f\xfe\r\xffg\x00\x1d\x00L\xfeH\xfe\xfb\x006\x02\xe7\x01\xe7\x01S\x03\x02\x04A\x03\xed\x03a\x05\xf4\x05\xcf\x04\xe4\x04"\x070\x08\xf1\x06\x98\x06\x9d\x07_\x076\x06\r\x06\xa0\x08\xdd\x08\x80\x06\xfe\x04\xc5\x04\xdf\x03E\x02\xf6\x01\xde\x02\xb6\x01\x94\xffX\xfe\xbf\xfd\x97\xfcd\xfb\xf6\xfbE\xfcd\xfc\xc9\xfb\x0c\xfc\x81\xfb;\xfa\xe2\xf9I\xfa\xa3\xfbX\xfcc\xfd\x9b\xfd0\xfd\x12\xfcP\xfb\xe9\xfb\xc7\xfc4\xfe \xff0\xff\x92\xfe7\xfd\xa9\xfc\xfe\xfcA\xfd\xcd\xfd\xc9\xfd\xa6\xfd\xbc\xfc;\xfbj\xfa\x9d\xf9\xfd\xf9[\xfag\xfb]\xfc\r\xfcP\xfa\x96\xfa\\\xfa*\xfbA\xfd\x01\xff}\x01s\x00\xf9\xff\x07\x00\xe4\x00j\x01\xa3\x01\xa1\x02L\x04\x81\x04\xdb\x03*\x05\x05\x05\xb1\x03N\x04\xc3\x05:\x06\xeb\x04\x89\x05\xc9\x07F\x07+\x07\x0e\x07i\x06?\x05\x81\x06B\x08\xbf\x08\xd6\x06\xcd\x030\x04l\x06m\x06\xbc\x052\x07\xb5\x06\xb5\x01o\x00\xff\x03X\x01\xba\xffO\x03\xb0\x06t\x02]\xfb\x9d\xfd\xf7\xfe\xe5\xfd\xf3\xfc\xa5\xfe\xfe\xfe\xb2\xfc\xf0\xfb\xeb\xfe\xa2\x02\x88\xff\xa7\xfb{\xfd\xe1\x00\r\x01Z\xff\xb9\x01\x0c\x04B\x02\xdc\xff/\x02\xee\x03p\x00\xcb\xfep\x01\xed\x02\x1a\x00x\xfd&\xfeG\xfe~\xfbF\xfb/\xfc\xc8\xfa)\xf9\xec\xf8\xcb\xf9\x1b\xfa\xdf\xf9\x84\xf9\xf0\xfaz\xfa \xf9W\xfa4\xfc \xfd6\xfe\xd3\xfe\xfd\xfe\xa4\xfe\xbf\xfc?\xfe\xc3\x00n\x01\x0c\x01l\xff\xb4\x00Q\x02"\x01\x7f\x00~\x00\xde\x00\x1b\x00&\xff\x9b\x019\x02\xd1\xfe\xd4\xfd\xba\xfe&\xfe<\xffr\xffK\xff\'\xfe]\xfd\x10\xfeK\xfeZ\xff\xb5\xfe\xb2\x00\x1a\xff-\xfd\x1f\xff\x92\xffS\xfeg\xff]\x01M\xfd\xd3\xfb\xce\xff,\x02\x12\xfe\xb6\xfe\xd3\x00%\xfeo\xff\x9e\xff\xa0\xffN\xfe\x9d\x02\xfa\x00\x00\x01\xf3\x03/\x00\x92\x00\xaa\x05\xa8\x03\xc8\xff\x98\x01\xce\x04\xbf\x05/\x00U\x06\xdd\x06\x12\x00\xb4\xffq\x02\xee\xff\xb9\x00\xfe\x03\xc5\xffN\x00\xa3\xff6\xff\xc3\xfb?\xfeX\x00\x16\xfd\xde\xfeg\x02\x9a\x00\xce\xfb\xc6\xfc\xdc\xf9W\xfd\xf5\x01b\x00\x00\x03I\xfe\x17\x01\xcb\xfe\x80\xfb\x93\xff\x8f\x002\x08!\x01s\xfd\x0e\x05\xcc\x04\x04\x00\xa3\xfd\'\x07~\x04\xaf\xfe\xa2\x01\xfa\x04X\x04\xf0\xfe_\x02\x1c\x05\xcc\x01\xd3\xfb\xee\x07\x10\x05\xa3\xf8B\xfe\xbb\x04\x90\x03\x9b\xfbW\x06\xc9\x03\xa3\xf5V\xff9\nG\xf9?\xfc\\\n\x90\xfc%\xfc\xd4\x028\x05\x0f\xfco\xfa\x8b\x04\x93\xfe\x1a\xfd%\x04\xba\x04/\xf9\x98\xfeJ\x00p\xf9\x86\xfe\xc0\x00\x04\xfe3\xfd\xcc\x01{\xfd\n\xfa\xba\xfe\x87\xfe\x90\xfa\xcb\xff;\x02\xb6\x00R\xfdx\x01\xd2\xfd\xb7\xfd$\xff\x98\xfd\xb4\x04\x9b\xfen\xfd\xed\xffK\x00\x1a\xfe=\xfe\xe7\xfb\x98\xfeI\x01\x87\xfdj\xff\xec\x03\xf1\xfd~\xfa\xea\x02\x00\xff\x10\xfa\xf5\x00\x1a\x01\x00\xffL\x01\x92\xfe\xc9\xff.\x04\x04\xfc\x8f\xfd\x96\x02I\xfe\x97\x06\x99\x00x\x02\x93\x01\x1d\xfe\xc9\x00F\xff\xca\x04\xf7\x04y\xfe\xef\xff]\x03\xe0\xfa\xd0\x01\xbe\x00&\xfd\x01\xff\xe3\xfc\xaa\x02r\x00S\xfc\xd5\xf8G\xfb\xf9\x00\xbd\xfb\xde\x01\xd9\x03\x88\xf9J\xfbj\xff\xd2\xfb\xc2\xfa|\x03\x15\x04\xcb\xf9=\x00|\x03W\xff\xea\xfb\xcb\x00z\x01\xa7\xfbU\x04\xaa\x07\xed\xfa\x96\x01\xfd\t\xca\xfb#\xf7\x1b\x06\xd6\x03\xe8\xfd\x1f\n\x80\x00\xf5\xf6\xef\t\xf5\x04\x8f\xf99\x02\xb4\x02g\xff,\xfa\xb6\x0bX\x0c\xbd\xf8a\xff[\x04\x16\xf3\x1e\x00\x95\x0e\x83\xf9\x87\x06P\x06\x07\xfc\x1c\xf9\x04\x01\x93\xfe\xd4\xfc\xfa\x06\xfd\xf9>\x02\xc8\x05\x17\x01E\xfa\xa9\xef\xa7\t\x9e\xfd,\xf8t\x0e\xfb\xfa\x1a\xfa\x93\x02\x0e\x01\x1a\xf4\xce\x03\xfa\x021\xf7\xfe\x00\xcb\r~\x01\xc8\xf6\xa5\t}\xfb\xd4\xf0\xa3\x0c\xf0\x07\xc5\xf7\x04\x14R\x02B\xf7\xf3\xfc\xf1\x05\x83\xfb)\xfcJ\n\xea\xfeT\x02v\x01N\x05[\xf8n\xf2\xdf\x01\xdd\x040\xf9\xc2\t9\n\xc8\xf0-\x01\x17\x08\xa9\xf6C\xfeF\x05k\x02\x99\xff\n\x03A\x06\x9b\xfb\x80\xffv\xf7\x07\x02\xf5\x00\x96\xfb\x89\x0c\x06\xfc\xd2\xf6\xec\x02H\x01$\xf7\x0b\t\xf5\xfc\x10\xfcB\x00\xa9\xfc\x0c\x07\x10\xfb\xa3\xfeO\x01\xc5\x04\xaa\xfa\x8b\x02\x8b\x07\xa8\xf8w\xfd\xf1\x03\x07\xff7\x04\x95\n\xa4\xfbs\xfa\x91\x02\x8a\x02\xc2\xff\x95\xfd@\xffx\x06v\xffh\xfc\xcc\xfcM\x04\xb1\xfb\x16\x00\x9a\xfe\x0c\xfbx\xfar\x03\x1e\x06}\xf8Z\x00:\xfd\x1d\xf9\xa3\x00c\x05\xe6\xf3@\x06?\x08\xf4\xf4^\xfe\xd0\x03\xb7\x04\x19\xf9\x8b\xfd\xdf\x05\x00\xfc\xe5\xfe\xd7\n\x85\xfe\xc3\xfc\t\xfe\x18\x00\xfc\x02\xfd\xffz\xfb.\x0c\x7f\x03\xeb\xf3\xf4\x02\x06\r\xfc\xf7D\xf0\x1a\x15\xaf\xfc\xad\xf9\xd3\x06\x0f\x07[\xff\x1b\xf9o\xff\x87\xff\xc1\x01\xac\xfb$\x0b.\xff\x7f\xfe\x8b\x00\x17\x04\xb5\xf7@\xf6\xcf\x0f\xe6\xfe\x14\xf5b\x05e\x13\xf8\xee\xbf\xf1\xbe\x18\xbd\xf9\x15\xe7\x85\x13\x04\x0b\x81\xed\xa7\x05\x00\x0b\xbc\xfc\x8c\xf2l\x06\xbb\x00~\xf3H\t\xa2\t\x14\x00\x8a\xf8D\x05)\xff\xb0\xe8\xa4\n\xee\t\xb3\xf1\\\x0c~\x08\xd2\xef\x0f\xfd}\x03P\xfb\x91\xf7\xce\xff\x17\x0b?\x01g\xf7\x9c\x08V\xfb\x01\xf4\xff\x08K\xf8\x18\x03\x81\x08\xce\xf77\x02o\t\xa1\xf5q\x03\x13\xfd\xee\xf6\'\x0c_\xfeh\x03\xe8\x06\x14\xfa\xf4\xff\xb5\x04\x1b\xf6\xd9\x07\x18\x03A\xf8%\x039\x087\x03o\xf9\xf6\x07\x8f\xfd\xd1\xf8\xb8\x01-\x03\xe2\x00d\x03\xb6\x07\xe0\xf7\xb8\x01\x18\xff\xb5\x00\xa1\xfb3\xfd\xa2\t?\xf9j\x02\x8d\xfd\xc4\x06\xeb\xfci\xf4\xf6\x02\x88\x03\xe7\xfd~\xfe&\x01\x11\xff\xc1\x03[\xfd\x16\xf8\x99\x03\x9b\x03^\xf8H\x06\x8f\xfe\x9f\xfc\xa8\x073\x01\xb0\xf2?\x0b0\x07\xee\xe9\xfa\t{\x06\xa5\xffT\xfe\xa8\xffd\x05\xfe\xfb\xfc\xf9\xcc\x04M\x000\xfcW\x06\x8b\xfb\x9b\x03\x13\x00\xcc\xfbF\x010\x01L\xf2Z\n\xa3\x04~\xf7Z\x07\xd0\xff\xcb\xfc\xb5\xfd(\xfd\xe1\xfc&\x04/\xfd\xe5\x06b\xfc\xa5\xfcR\x08\xa8\xf7f\xf6\\\x0b\xf2\xfeM\xf9s\x07\xfd\x07\xfe\xf9\x08\xf7\xf3\n#\xf9\xaf\xff\xfd\xfe\x8f\r\x7f\xf8q\xfa\xe5\x11\xb9\xf7\xf9\xf3\x1a\x02\xa3\r\xe3\xf3o\x04\xbb\r\t\xf7\x7f\xf8\xcd\x05\x00\x009\xf7\xe8\x05\xc1\x06\xc5\xfd\x9b\xfb\x8f\n\x11\xfdC\xed\xf8\x0c\xf5\x02\xc1\xf3\x14\x06\xb0\n\x1e\xf8\xfa\xf7\xe1\n\xdb\xfe\xbb\xf1\x9f\x01\x11\x0e\xd0\xf2\xff\x00\xc6\x0f\xcc\xf7:\xf7\xc3\x0co\x00\xe4\xefL\x058\x11d\xf3\x07\xfb\xe5\x1f\xd3\xe8\x04\xf5\xfc\x1ah\xf1\x08\xf8\xaf\x0c\x80\x03\x08\xfa6\xfb\x0b\x0b\xb2\xfc`\xf6\t\x08\x95\xfc\xdc\xf3\x90\x07\x1c\t\x9f\xed\xaa\t\xde\x02\xa3\xf4\xef\x008\x04}\xfb\xd2\xfeF\x070\xfb@\x02\xf9\xfe\xf3\x04\x8b\xf7c\x07k\x01Z\xf9O\x03\x0e\t\xc6\xfa\x91\xfcr\n\xc6\xfa\xbd\xfeX\xfc%\x07\xfa\xfcC\xfc!\t\x18\xfdX\xf8V\x0b\x1e\xf7e\x01\xc2\xf8#\x08g\xfe\x05\xf8y\x0c\x95\xf7\x1a\x07\xeb\xf2\x92\x04\xec\x00\xf8\xfd\xcf\x01\x9b\x03\x00\x01a\xfb\xc3\x01Z\x04\xaf\xfa<\xfe\x11\x08=\xfd\x8c\x00&\xfbQ\x08\x9f\x02\x0c\xfa\x02\xfc\xb5\x06\xee\xfd\xfa\xf3g\x0e\x91\xfe\xdb\xf7\xbd\x07\xda\x04j\xf3\xf2\xff\x16\r\x1e\xf35\xf8,\x12\x00\x02!\xf4\r\x07\xba\x07\x03\xf2\x0b\xfa\xe6\n\xda\xffS\xffC\xfb-\x0b\xb1\xffO\xf6:\x03\xcc\xfe\x8e\xfe\xb7\xfd\x82\x04\xed\x00s\x08\xf5\xf3\x14\x02\x81\x07d\xf1/\x03\xee\x05d\xfe\'\xf9\x91\x06\x05\x06\'\xf7\xfc\x00<\x01\xa6\xf8-\xfd\x03\x0b\xbe\xfc)\xfd\x80\x051\xf9\'\x01`\xfdt\x04\x87\xfb\x00\xf9c\x0c\x00\x04&\xf0\xe4\t:\x05M\xef`\n\xf6\x00\xbb\xf8A\x01\xf1\x07Y\xfd\xe5\xf9\x15\x07\x9a\x022\xf2\xbc\x076\t\xd6\xef\xac\x03\xf9\x11U\xf3\xa5\xf7\xab\x0f3\xfe+\xf1\x13\x07\xdf\r\x0b\xf5}\xfa\x16\r\x07\xfe\x8e\xf1\xbd\t\xdb\x07\xe9\xf8\x7f\xfa\x95\r\xd1\xf9B\xf6\xd5\r8\xfd\xf7\xf5\xc2\x05\xad\x07\x82\xf6~\x03d\n\xa3\xf2\xa8\xfel\x00\x85\x07j\xfa\xee\xfb\xaf\x0f\xd1\xf4n\xfc\xbf\x06\x86\xfe\x1d\xfa\xeb\x035\xff\xda\xfd\x93\x02\xd6\x00\xe3\xfeZ\xfb\xfa\x01\xa7\x03m\xfeO\xf3\xa5\x10\x96\xfe\xa5\xf2s\t\x9f\x04\xc8\xf7\xd7\x02\xfb\x03\x0e\xfby\x02\r\xfa\xf8\nx\xfa\xba\xff\x85\x0b3\xf6\x1f\xff+\x04X\x00\xc1\xfa\xd7\x05\xb0\x01P\xfdp\x03\x1f\xfdr\x03e\xfa.\x01S\x01 \xffR\x00\xc1\xfc\xe9\t\xb4\xf9\x7f\xfd\x0b\x02\x9e\xffD\xfaq\x05<\x01V\xf5\xf2\x0bp\x01\xfa\xf7\xe9\x04%\xff5\xf8\xe9\x06\x03\xf88\x06l\x04\x82\xfb\xaf\xff\xad\x02=\x02\xd5\xf2\x98\x07\xa7\x07\x94\xf1\xeb\x02n\t\xab\xfa;\x01L\x04C\xfaJ\xfd-\x008\x00\xd8\x01$\xfc\x02\x06\x1e\x02\xc9\xf8^\x05|\x01G\xf8&\xfb\x99\x08D\xfd\xce\xfa:\x13.\xf5\xf5\xfcU\x08\xf3\xf6y\xfeT\x01\xf2\x07\xe6\xfcP\xff\x0c\x07\x02\x02\xde\xf4\xa1\x04\x17\x01k\xf8s\t_\xfe\xab\x01\xcf\x05\x8c\xf8\xb5\x01\x19\xfe{\xfe\xbc\x055\xf7\xc6\x08\xf9\x04Y\xf9\xaa\xfd\xa2\x08\xc1\xf6\xed\xfd\x0f\x07\xd9\xf8\x16\x08\x83\xfe~\xfc\xf0\x02\x8b\xfe\x92\xf8\xe6\x07\xd0\xf9y\xffF\x08V\xf6t\x05c\x03\x7f\xf5\xf7\xff\xd0\x05s\xf6\x98\x01\x1e\x06\x84\xff\x1a\xfd\xd2\x00\x94\x02\xb4\xf8\n\x01\xc3\x06\xce\xfa\xd4\x02\xbf\x04\x10\xff\x17\xfc\xd7\x03\xdd\x02\x0c\xf5\xd1\t\xe3\xfd\xd9\xfd\xc6\x01\x89\x00\r\xffG\xfe\x0c\xfe\xe3\xff`\x02\x86\xf7\x7f\x0bs\xf8\xfa\x00\xbd\x05\xcb\xf6\xcb\xfd4\x04\xb1\xfe6\xfe\x1c\x06l\xfb\xcf\xff\x1b\x02j\x00\xeb\xfd\x9e\xfc\xdd\x05\xbc\x03\x12\xf75\x07z\x06\xc0\xf5\xe8\xfe\xb0\x0b\xc3\xfad\xf9@\t%\x01\x06\xf8~\x06N\x06\xaa\xf5!\x05\x14\xff\x18\xf7\xbb\x07\x9d\x06\x1c\xf3\xf0\x05\x9d\x05\xb2\xf7}\x02N\xfd\x96\xfe.\x00~\x00:\x01b\x02N\xff\x1e\x00\x08\xfc=\xff\r\xfdo\x03\xe0\xfeN\x00r\x04\r\xfbu\x00\xe4\x01\xe0\xfan\x00>\x01.\xf8\xaf\x075\x01d\xff\xed\xffF\xfd\x15\xff6\x01(\x00\'\xff3\x00\x91\x01\xa3\xff\x1a\xff\xfe\x03|\x03\xa4\xfbi\xfd\xe3\x02\xb7\xfb\x94\x03j\x060\xfc\xce\x00b\x05\xd2\xf9\x02\xff\x9d\x06\xc0\xfb-\xff\xba\x06\xaa\xfa\xfe\x01\x06\x08\x89\xfa\x9e\x00;\xff\xdc\xffV\xfa\xa7\x04-\x07\x98\xf7J\x04l\x03\xcd\xfaz\xfd\x82\x05\xdc\xfb\xbc\xfad\x05\xde\x02\x11\xfbl\x04b\x02\x15\xfa\xdf\xfc\xe5\x00V\x02\xcc\xf9\x97\x060\x00q\xf9v\x06}\x00\\\xfc\xfc\xfc\x1e\x02\r\xfc\xb6\xffl\x05%\xfd\x9e\x01\xcd\xff\x02\x00\x14\xffT\xfe\xf9\xfe\xba\xfc\xde\x02\xb6\xff\'\x01\r\x04\xa3\xff\x81\xfa\x01\x02\x01\xfeo\xfa\x88\x04\x89\x00\x01\x01\xe8\xfd\xd2\x05\xcf\x00;\xf9n\x03\x19\xfb,\xfe\x99\x02S\x00\xcf\x04d\x01\x90\xffD\xff\x1b\xffr\x00\xff\xfb\n\x04<\xff\x9d\xfe\xb4\x07b\xff\xb9\x00\x94\x01\x8e\xfc\xce\xfd\xdb\xfee\x00\x1b\x00\\\x03N\x02\x12\xfe\xb1\x01[\xfdA\xfe\x0e\x00@\xfc\xc7\x00\xab\xff\xf7\x00\xc1\x02\xcc\x01\xb8\xfd\'\xfe\xf9\x00\xa8\xf8\x05\x04-\x03\xf1\xf9\xc3\x04s\x00\x16\xfe\xcf\x01\\\x02\xe5\xfcx\x01\xf9\xfb"\xfd\r\x077\xfd\x0b\x031\x02/\xfd\xc9\xff\x9d\xfey\xfe7\xff\x8c\x00*\x00\xa7\xfe\x87\xff!\x04\xa9\xff\xa9\xfc,\xff\xd1\xfd\xc0\xfen\x01\xd4\x01\x87\x00\x08\x03\xf6\xfd7\xff\xbc\x01h\xff>\x01L\xfd.\xff<\x04r\xff%\x00\xd6\x03u\xfd\xc6\xfc\xbe\x02\x9d\xfd\x1c\xff\n\x03\xcb\xfd]\xfe\xe2\x02\xbd\x00\x08\xfe\x07\x00\xa3\xff\xda\xfd(\xfe\x8a\x01\xe5\x00K\xfe\xf0\x00\xc2\x01\xb7\xfd\'\xff[\x02\xec\xfd^\xfd\x98\x02\x92\x00\xca\xffd\x017\x00\x1e\xff\xd0\xfe\xf8\xff\x1b\xff\xce\x003\x00\xd0\xff3\x01\x94\x00\xb3\xfe\xfc\xff\xc5\xff\xc3\xfc2\x02\xfd\x00#\xff\x8a\x01\x19\x00\xa3\xff8\xff\x12\x00\x18\xffC\xffT\x01\xfa\x00\xef\x01&\x00\x0e\xff\xc4\x00\x80\xff\x80\xfd\xab\x00\x8f\x02x\xfe\xbd\x01\xc1\x01<\xfe*\x00\xe7\x00\xf8\xfd\xd4\xfe=\x01&\x00\xc1\xff\xff\x00\x05\x03G\xff\xbb\xfd\xbf\x00\xdd\xff\xd8\xfc[\x00Y\x04(\xfeF\x00\x8e\x02\x88\xfes\xff\x1e\x00\xc7\xfdD\xfdr\x02\xeb\x01^\xfe<\x01\xd8\x00&\xfe\xaa\xfe\xca\xfeR\xfeQ\x00\x8e\xff\x19\x00\xc7\x00y\xff\x1b\x00\x83\xffi\xfc\r\xff\'\x00\xff\xfc\xe1\xff\x9c\x03\r\x00u\xfc\xf3\xff\xa0\xfe\x98\xfb`\xfe\xaa\xffI\xfd\xbb\xff\xcc\x01\xae\xfd\xf1\xfdO\xff\x8c\xfdt\xfd\x00\x00\x8f\x00\xaa\x00\xe3\x02F\x03\xaf\x03?\x04\x8f\x04Y\x05)\x04\xb9\x05o\x07\xfc\x07\r\tK\t\x90\x07O\x07\x0f\x06\xba\x044\x04@\x03\xd9\x02\xbb\x00\x8a\x00\x81\xff\xce\xfd\xa0\xfc\xa6\xfa\xca\xf8\xc5\xf7\xc7\xf7Q\xf8:\xf8\xca\xf7\x82\xf8\xba\xf8\x7f\xf8\xd4\xf9s\xfa\xa9\xfa>\xfc\x82\xfd.\xff\x84\x00\xfe\x015\x02Z\x02\xa4\x03\xee\x02\xce\x03\xc6\x04\xe5\x04\xac\x04\xb6\x03\xba\x03-\x03\xc8\x02\xa9\x01\x9f\x00[\x00\x04\xff\xd0\xfe\xc9\xfe\xc6\xfd{\xfd\x0b\xfda\xfb\x0c\xfc\xbc\xfc\xc9\xfb9\xfc\xb8\xfc\xcc\xfc\xf5\xfc|\xfe\x1f\xff\xc8\xfe`\xff\xdf\xff:\x00\xea\x00\x8e\x02V\x02\x08\x02\xbc\x02\xe3\x02\xff\x02?\x037\x03\n\x02\xa6\x02\xc6\x02%\x02\x8f\x02\xed\x01\xbf\x00\x00\x01\xce\x00d\x00\xa3\x00c\x00\x90\xff}\xff\xe8\xffI\xff^\xff\x98\xff\xd3\xfe\xc6\xfe0\xff\x8e\xff\xb5\xff\xfe\xfeI\xff\xee\xfe\xaf\xfe\x18\xff\x11\xff\xf8\xfe-\xff \xff\x05\xffe\xff)\xff\xe0\xfe\xe9\xfe\x08\xff\xeb\xfe\x9c\xff\xbd\xff\xfb\xfe\x80\xff\x97\xff0\xff\x97\xff\x06\x00\xc4\xfft\xff8\x00\x1a\x00\x00\x00{\x00`\x00j\x00\x07\x00\xb0\x00\xf0\x00n\x00\xa1\x00\xb9\x00p\x00\x7f\x00\x9a\x00\xbe\x00\xc4\x00e\x00\xbe\x00\x0f\x00\x13\x00\x82\x00\xe9\xff\xff\xff\'\x00\xd8\xff\xf4\xff\x0b\x00X\xff\xa0\xffT\xff.\xff\xd8\xff\x89\xff\xb0\xff\xfc\xff\x88\xff\x83\xffo\xff\x88\xff\xbc\xff{\xff\xf5\xff+\x00\xdc\xff\xef\xff8\x00\xb8\xff\x98\xff\xdc\xff\xbb\xff\n\x00w\x00<\x00`\x00}\x00\xb6\xff;\x00\x02\x00\xdb\xffo\x00K\x00R\x005\x00\x98\x00\xfb\xff\xdd\xff\xdc\xff\xb9\xff\xb7\xff\xb3\xff&\x00\x04\x00\xa9\xff\x03\x00\xb2\xff6\xff\xac\xff\x90\xffm\xff\xb2\xff\xfd\xff\xf5\xff\x02\x00\x1a\x00(\x00\xf5\xff;\x00\x1c\x00E\x00\x96\x00\x95\x00\x87\x00\xb0\x00\xd0\x00y\x00\xca\x00E\x00n\x00\x93\x00J\x00x\x00\x9d\x00D\x00U\x00?\x00\xe3\xff\x1a\x00\x0c\x00\xc8\xff\xde\xff\xbb\xff\x90\xff\x11\x00\xa1\xff\x85\xff\xdb\xff\x1d\xff;\xff6\xff\xe8\xfe\xa2\xff\x8b\xffr\xff\x96\x00\xd1\xffy\x00\xcc\x00\x93\xff=\x00\x15\x00\xfe\xff\x1d\x04\x11\x05\xf4\x03o\x03\x11\x02\x7f\x01W\x01P\x02\xc6\x02\xc3\x02`\x022\x02\xb0\x01]\x00\xda\xfe\x16\xfc\xd2\xfa\xaf\xfb{\xfc\r\xfdD\xfd\xe3\xfc\x1c\xfb\xc5\xfaF\xfbt\xfa\x83\xfb\xb3\xfb\xea\xfa\xab\xfd\xf1\xfe\xae\xfe\xbb\xff\xd6\xfe\xcb\xfd\x0f\xfef\xfe\\\xff\xc2\xff\xe9\xffg\x00\xca\x00\x9f\x00\xe6\x00g\x00t\xff\x86\xffO\x00\x07\x01\x0c\x02+\x03\x8f\x03a\x03\xb7\x038\x04W\x04\xa3\x04\xdd\x04]\x05$\x06\xf3\x06Y\x071\x07^\x06*\x05S\x04\xc3\x03j\x03\xc7\x02\x01\x02t\x01\xbd\x00\x00\x00\xeb\xfe\x9d\xfd`\xfct\xfb\x13\xfb\x00\xfb\x1a\xfb`\xfb|\xfbr\xfbu\xfb\xa3\xfb\xde\xfbs\xfcW\xfdQ\xfe_\xffR\x009\x01\xd3\x01%\x026\x024\x02\x84\x02\x06\x03\x8a\x03\xb8\x03b\x03\xb8\x02\x16\x02M\x01\x82\x00\xe5\xff\x1b\xffP\xfe\xa8\xfd)\xfd\xa7\xfcD\xfc\xb0\xfb\xf5\xfa\x90\xfam\xfau\xfa\t\xfbN\xfbs\xfb\x0f\xfc\x97\xfc\x18\xfd\xef\xfd\xee\xfe\x1b\xff\xb1\xff\x96\x00\x0e\x01\xd7\x01c\x02\xae\x02\xf8\x021\x03F\x03a\x03I\x03\xd3\x02u\x02F\x02\x12\x02\x0e\x02\xab\x01\x06\x01\x8f\x00\xff\xff\x9d\xff\x80\xffF\xff\xe5\xfe\xd4\xfe\xc9\xfe\xca\xfe\xfc\xfe\x0c\xff\xe4\xfe\x17\xffG\xff\x90\xff\x10\x00R\x00o\x00\xba\x00\xbb\x00\xb4\x00\xc1\x00\x13\x01\xfd\x00\xd1\x00\r\x01\x03\x01\xcf\x00\x8b\x00I\x00\xf6\xff\xbf\xff\x90\xffd\xffd\xffI\xff\x16\xff\xeb\xfe\xd3\xfe\xba\xfe\x8c\xfe\x83\xfe\x82\xfe\xa9\xfe\x0e\xff#\xff{\xffb\xff\x80\xff\xbf\xff\xb8\xff\x13\x00L\x00\x89\x00\xe6\x000\x01@\x01A\x01]\x016\x01\x02\x01\x07\x01\x06\x01\xdd\x00\xd2\x00\x8e\x00\x1d\x00\xda\xff\x80\xff\xfa\xfe\xf5\xfe\xcf\xfe\xc0\xfe\xdd\xfe\xf3\xfe\x06\xff\xbc\xfez\xfe\x9c\xfe\x03\xff\xfe\xffZ\x01J\x03g\x02\x10\x01\x1e\x02\x97\x01\x17\x02\xeb\x02}\x02 \x04\xae\x03\xfe\x02/\x03\xc0\x01\x1e\x00\xeb\xfeY\xfe\x9c\xfe\xda\xfe\xae\xfdZ\xfe\xa8\xfd\xf4\xfc\\\xfdP\xfc\x99\xfd\xf0\xfc}\xfb\xbf\xfdY\xfe\x9b\xff\x7f\x03\x80\x03\xde\x02W\x01\x9a\x00\xca\x01\x95\x01\xc8\x01\x83\x02\xa4\x02\x82\x01\xef\x01\x9e\x01\xb2\xfe\x91\xfc\xb9\xfa\t\xfa\xe3\xfap\xfb\x1b\xfc<\xfb\xa0\xfa\xaf\xf9\xcb\xf8\xa2\xf8\x84\xf8W\xf9\x0f\xfas\xfa\xe7\xfbI\xfd%\xfd\xa2\xfd\xc7\xfd)\xfeI\xff\xa5\x00j\x02\x07\x04\xb7\x05\x8e\x07"\tS\n|\x0c\xd3\rq\rc\r>\x0e\x1c\x0f\xb8\x0f\x10\x10r\x0f\xe3\r\xe7\x0b\xf4\t\x16\x08\xd6\x05:\x03\x87\x00\x97\xfe\xdb\xfd\x8b\xfca\xfa\x11\xf8$\xf6\x9e\xf4y\xf38\xf3\xa5\xf3R\xf4\x8b\xf4\xd7\xf4:\xf6\x91\xf7|\xf8i\xf9\xb5\xfaG\xfc\x1d\xfe\xf4\xff\xe2\x01\xc3\x03\xb5\x04\xc9\x04\r\x05\xd8\x05V\x06\x18\x06\xc7\x05Z\x05\xc9\x04\xfe\x03\xe5\x02\xd4\x01P\x003\xfeG\xfcg\xfbu\xfb\x18\xfb\xa6\xf9t\xf8#\xf8\xf8\xf7\xdf\xf7\xf1\xf7k\xf8\xd6\xf8=\xf9~\xfa\x99\xfcR\xfe\xff\xfe\x13\xff\xe4\xffg\x01\xa5\x02\xd2\x03\xcb\x043\x05\xf8\x05\x02\x06\x96\x062\x07\xce\x06\\\x05\xa4\x04\x90\x04A\x04u\x04V\x03E\x02\x97\x01\x98\x003\x00\xa5\xff\x1f\xff"\xfe\x9d\xfc\x0e\xfc\xbf\xfc\x1e\xffr\x00o\xfd8\xfc\xa8\xfc\xb2\xfc\xef\xfd\x1b\xfe\x97\xfeb\xfe\x93\xfe\xb2\x00p\x02\xcb\x02\xd3\x00X\xfe\xb5\xfe\x95\x028\x04\xad\x04\xe3\x04-\x03!\x03\xac\x034\x03`\x03G\x01\n\xff\xad\xff\xab\x00\xa7\x01\x88\x00\xff\xfd\xf1\xfb\xb5\xfam\xfa\x1c\xfae\xfa\xe6\xf9\x82\xf8\x14\xf9>\xfa4\xfas\xfa\x11\xf94\xf8v\xf9\x89\xfa\xa7\xfb\xd0\xfcp\xfc\xb0\xfd>\xfe\xd5\xfd\x12\x00h\x00<\xff\xf2\xff@\x01\x82\x01G\x02a\x01\x16\x01\xd3\x00\x97\x00\xbe\x02\xbd\x01\xa5\x01/\x01\xbb\x00\xf3\x01\x0c\x01c\x01]\x02h\x02\x18\x04\xc1\x08H\x0c\x82\n\xce\x08=\t\xb2\x08m\n\x83\x0b\xe2\x0c\xd2\x0e\x8e\x0b\'\x0b\x00\x0c\xde\x08\xd0\x05B\x02L\x00s\x00\x85\xff\xf1\xfeh\xfe\x06\xfc\x99\xf9L\xf8\xaf\xf7\xa1\xf7\xb5\xf5\x12\xf4j\xf5\xa4\xf6r\xf8 \xfa\xf1\xfal\xfb\xb0\xfa\x11\xfbB\xfd6\xff\xb9\xffr\x00\xb1\x01\x9b\x03\xc5\x04E\x05\x8c\x05\xa4\x04\xa8\x02\xca\x01\x9f\x01V\x02\x82\x01U\x00\xa7\xff\xc3\xfeN\xfey\xfc\xe8\xfa\x18\xf9>\xf7|\xf5\xc9\xf5\xac\xf76\xf9\x08\xf9\xea\xf8E\xf9%\xf9\x98\xfa}\xfb\xa1\xfd\x01\xfe\xd7\xff\x89\x05\xa5\t0\r\xf0\r\x0e\x0c@\x0bH\n\xfe\nd\x0cP\x0e\x01\r\xc3\x0b\x1d\x0e\x88\x0b\x07\x08\xf5\x02\xf1\xfb\xce\xf78\xf7u\xf9\x8b\xfb\x9f\xf8A\xf6\xcb\xf4[\xf2\xba\xf1\xe9\xf1\xd9\xf0\xe4\xef\x8f\xf0\x8e\xf4\x03\xfa\xca\xf9_\xfa\x9e\xf7>\xf6\x16\xf8\x0e\xfb \xfe\xce\xfb\xa9\xfdZ\x00[\xff\x08\xfe\x91\x01\xdd\x01\x9f\xfa\x13\xfb\x96\x01v\x009\xffR\x03H\x05\x15\x02\xf3\xfeA\x05\xba\x05\xe2\x01\xd5\x06\x9f\x08\xa7\x064\x08\xc9\x0b\x1c\n\x0e\x05\xb0\x07\x04\x08[\x05R\x08l\tK\x04\x12\x02i\x06\x1c\x05`\x04o\t\xd8\x08l\x04\x15\x07I\x0bl\n\x15\x0c\x84\x0c0\x0b+\x0b\x1a\r%\x0e\x06\r\xe0\x0b-\x08W\x06\xa4\x06\xfe\x06\xf8\x03\x82\xff,\xfd\xf6\xfb\x9c\xfc<\xfbd\xf9`\xf7N\xf5\x9c\xf3\xd2\xf3\x88\xf48\xf3\xe4\xf2\xc9\xf2\xb7\xf3g\xf5\x81\xf5\xd5\xf6\xc0\xf79\xf6)\xf6n\xf8\xcc\xfa\xc6\xfc`\xfdp\xfdo\xfd\x0e\xfe\x11\xff(\xff\xfd\xfe\x00\xfeG\xfd\r\xfe<\xff_\xfe\xe4\xfd\x0b\xfd\xee\xfb\x1c\xfc9\xfc\xa7\xfc4\xfd\\\xfdE\xfcf\xff\xef\x00\xee\x00\xf9\x02u\x02\x11\x03\xa6\x02\x16\x04\x8d\x05\xe3\x05\xf0\x06\x0c\x06\x90\x05\xec\x05\x00\x04`\x03\xf6\x01\xfd\xff[\x00o\xfd\x18\xfe@\xfe\xc4\xfa\xb7\xfb9\xf8\x88\xf6\x18\xf9\x08\xf7\xca\xf6\x96\xfa\xce\xf9\x12\xf8\n\xf9\xa0\xfb&\xfc\xae\xfa\x0f\xfek\xfd\xf2\xfe\xd3\x00r\x00\xeb\x02\xa7\x00~\xff\\\xff-\xfe\xa4\x01J\x02B\xfe\xe7\x00\xc4\xff\xc6\xfbG\xfc\xd5\x00\xe2\xfb\xe7\xfc+\x02\xfc\xfb;\xff\xc7\x03\xf8\x00\x8f\xff\x1b\x03P\x05<\x04\xdd\x02t\t\xe4\x08O\x04\xa8\t\x9d\n\xb1\x07\x19\x08\x18\n\x07\x08\x17\x06b\x07\x8a\x08!\x07 \x06h\x05\x1c\x04\x11\x04\xf7\x02\xed\x03\xdf\x04\x1d\x02\xd8\xfe\n\x05?\x05}\xfbE\x02\xcc\x04*\xfb\xa4\xfdV\x04\x0b\x00\xa8\xff\xef\x01\x0c\xff+\xff\x95\x01E\xfe\xad\xfd\xa3\x00N\x00\xfc\xff0\x023\x02\xb5\xfe\xde\x00\x1c\xfd\xd4\xfc\xa7\x03T\xfa6\xfcG\x03:\xfd\xb2\xfb\xc9\xfd\xbf\xf9\xf5\xfa\x13\xfc\xe4\xf9}\xfaf\xfd\xf9\xfd\xb1\xf9`\xffg\xfd\xc5\xfb\xc1\xfb\x10\xfdK\x01\x98\xfa!\x01\xe5\x044\xfb#\x02\x03\x06\xdd\xf6\xac\x00\xf4\x061\xfcS\xfa\xd0\n\xc2\x03-\xfb#\x03\xcd\x05\x06\x03\x8f\xf7L\x05\xe4\x04"\xfa\x83\xfdt\x05\x98\x00\xb0\xf9#\x04\x9c\x01\x00\xf8\x1f\xfb_\x04/\xf9N\xf6<\x08\xe2\xfe\x87\xf7\xcb\xfc\x92\t\x91\xf4\xa9\xf8\xe2\x06\x0c\xf7\x88\xf9\xee\x05|\xfe\x8d\xf5\xb4\x0b\xa9\xfd}\xef,\x052\x08N\xf0\x1b\xff\xf1\x0c\xa0\xf6m\xf8P\t\xe9\x04Z\xf3-\x02\xbc\t\x1c\xf5\x8a\xf7W\x13s\xfb\xe4\xef\xd3\r\x11\x06`\xf3 \x01L\x0eT\xf3\x8c\xfe\xd0\x0b\xde\xfa\xdc\xff\xb7\tM\xff\xe3\xfbM\x07\xee\x05!\xf5^\x05\x1f\x0f\xce\xf2\x88\x04\xdb\x0f%\xf1\xc2\x04.\x11C\xfa-\x01\xf9\t\xd4\xfc\x93\xfbB\x0e\x88\x02\xa7\xfeP\x0b\x16\xfe\x02\x00\x1b\n\xbb\xfe7\xf8%\n\x00\x034\xf98\x07#\x08g\xf6\xee\xfdk\x0e\xba\xf0R\xf2\xf9\x16\xa0\xf7S\xeeo\x0b\x04\x0c\x00\xeb\xb3\xfbO\r*\xef\x92\xf4\xa8\x02.\x08%\xf8)\xfd\x00\x02\xd8\x00\xc1\xf1\x0e\x01\xc8\t\xd2\xf4b\xfa+\x0f\xa1\xfeh\xf0\x1b\x10\xec\x02\xf8\xfbV\xf9\xac\x08\xed\xfe\xf9\xfdK\x02\x17\xfe\xff\xfes\x01\x1c\xff\x8a\xfaa\x07\x89\xf7\x8c\xf9\xc3\x02\x1f\x00\xdb\xfb\xfc\n\xc8\xfcd\xf7o\x0c\xe4\xfd@\xfe\x08\x03\\\xfbo\x0e\xe9\xf9j\x00\x1b\x11\xcb\xee[\x02B\x05%\xfb\x0c\xfc\xfd\x07<\x00\xe3\xf8\x7f\x01\xd7\x04\x1c\x05\x9a\xee\xa2\t\x1b\x00.\xf1/\x04K\x05\xc3\xfe-\xfa\x0e\x01H\x02\xb5\xfb7\xf7\xde\t\xd1\xfa\xf5\xf7\xed\x06;\xfen\xfb(\x07]\x06\xfd\xea4\x05\xcc\x0eW\xea\xad\x02\x08\x11+\xf1\xcf\xf9\x15\x0b(\x01\x1d\xef\x87\x068\x06-\xf5 \xff\xfd\x04\xc3\xfb\xc4\xf8\x85\x0c\r\xfa\xab\xf1\xee\x12\x92\xfe~\xf1\xd6\x11"\x00W\xf5\xa2\x08\xb6\x07\xa6\xfc~\xfc\xd4\x0e\xae\x03\x10\xf3\xe9\r2\x06Z\xf9>\x03\xe1\x08#\xfbh\xfe\xc4\x08\xf0\xf9\x8c\n\xdc\xf75\xfd\x13\x0c\xff\xf7\xec\xf8\x0e\x0eQ\xfa\xaf\xf1\xa1\x10\xe1\x00\x8a\xf36\x056\rY\xf6\x0e\xf9\xce\x0c\x89\x02G\xf0\x91\x04\xae\x16C\xf13\xfa\xcd\x0f\xe9\xf6\x08\xff[\x00\x17\x05\xc3\xfb\xaf\x00\xc8\xfb\x8b\x02\x0c\x0b\x97\xe71\x10\x01\xfa+\xf5\xe3\x08\xea\xfe1\xf7\xb5\xf8\x83\x0f\xa7\xfa\n\xf25\nv\x04\xf8\xe8\xa3\t\x9c\n\xaf\xf8*\xfd\xa9\x08`\x01b\xeft\x12\x0e\xfb\xa3\xf9\xfb\r\xba\x034\xf6\xb1\x05a\x05e\xff\xc2\xf3h\x04B\x13\xc6\xeb\x05\x0b\x85\x061\xf7\xe9\x01U\x05\xae\xf6\x15\x03\xdf\x07\x9a\xf1\xf5\x06\xea\x08I\xf2\xde\x04\xfd\x00\xb8\xf3\xcd\x05j\x02]\xf9\xb6\n\xc3\xf1\x8e\x02\xca\x11\x82\xe2\xf6\n\x13\x0c\xc2\xee\xe0\xf9\xe1\x11K\xfdB\xf7\xac\x08I\x03O\xf8\xa3\xf4\xda\r\xa0\xff\xa8\xfaS\x06W\x02\x91\xfa\xe0\xfc\x94\x0cB\xf4S\xfbP\r]\xf5\x06\xff(\t\xb2\xfb\x01\xfe~\x02t\xf9\x0b\x04 \xfe\x8a\x00\x8a\xff\xb1\xfd\xa3\x08\xaa\xfc\xed\xf5\x9f\x063\x07b\xf2_\x03o\x08\xac\xf8\x8d\x02\x0f\x05\x18\xf2d\r\xe5\x00\x0c\xf03\n;\x03\xfd\xfd\x98\xfc9\x05\xbb\xfe\xf1\xfc\xbd\x02V\xff\xc1\x00\x94\xf9\xc2\x040\x00A\xfe\xb7\x04u\xfe\xfa\xffC\x01\xbe\xf8\xcd\x04V\x01V\xfa\x1c\x03\x1d\x03\xd4\xfe\xf5\x00\xba\xfc]\xfc\xbe\xff\xaf\xf6\x8b\n\x13\xfb\x88\xfdv\x08`\xf6\xb9\x00.\x05\xd9\xf5\x98\x01\'\x02\x88\x01\xf3\x01\x81\x006\x03b\xfa\xea\x04$\xfdP\x04\xab\xf9\xfa\x06\x9c\x06\xc1\xf84\x00\xd8\x04\x99\x00\x1b\xfc\x98\x02f\x08S\xf7\xc7\xfe\xa8\x04\x1e\xfe\x13\x02p\xfa\xbe\x07\xb8\x00\xce\xfa\x0f\xfe6\x07\x18\xf4X\x00\xa5\x06\xcf\xfcm\xfe\x9e\xff>\x01\xf0\xf8\x8e\x02i\x02y\xfb1\xf3\xe3\x133\xf5\x91\xf9\x10\x0f\x9a\xf8\xb7\xfbM\x06\x92\x016\xf2\xc1\x0b\xc2\x00=\xfa\x86\x04D\x08\x95\xf6\xa4\x00/\x07\x1f\xf7\xd0\x04r\xfdY\x01\xcc\x05\xf0\xf5\t\x01\xf4\n\xa7\xf2\x89\x04\x05\x01D\xf5\xdd\x02\xf0\x07\xd6\xf2\x1a\x04\x8e\x03\xf5\xf7c\x06\x1d\xf9\xbf\n\x00\xf8\xef\x01\x8c\x04I\xfdS\x02\x7f\x00\x8d\xfe\xd1\x08\xcd\xfcD\xfaH\x0e\x81\x02\xc9\xf0\x12\x0b\xd9\te\xef\xa3\x08\xf2\x02\xfb\xf8H\x06\xf3\x03M\xf3#\r\xc2\xfe\x96\xf6B\x02\x9b\x0b\x1f\xef[\xff\xba\x11\x9b\xeb\xbd\x06W\x03\xa3\x03X\xed\xe7\t\xb0\x02U\xf7X\x04\xf0\x02\x9f\x01Y\xfa\x8a\x01\xe6\xfe\xdc\x04n\xf8E\x07\xfc\x03\xff\xf6\x18\x00:\x07\xf5\xfe\xe8\xfb\xa4\xfeh\x04\\\xf9\x8b\x05\x12\x01\x82\xf2\xd0\x0eT\xfb\x07\xf9\xae\x07\x86\x01\x1f\xfeA\xfb\'\xfd\xa1\r\x99\xfaL\xf8\xfd\x12\xb8\xf52\xfb\xc5\x06\x1e\xff8\xf8\xf2\r\xc6\xf8\xd3\xfbI\r%\xf9?\x02,\xf8\xa5\n\xa4\xfa\xaf\xf9\xe5\x0c\xa7\xfe\xa4\xf6\x00\x0b\x16\xfe\xfd\xf7i\nd\xf6K\x03\xbb\x06\xf3\xf1\x13\t{\t\x03\xf6t\xfdr\x07\xf2\xf6\xc9\xfe\x13\x0e9\xf3\x8e\x01\x16\x05\x00\xfe\x10\xf6n\x0e2\xf7\xb0\xf38\x14\xfa\xfa\x07\xf1\x9c\x11\x98\xfcv\xee.\x16\xc0\xf4\x19\xf4,\x0cD\x03\xf5\xed1\x11\xdf\xfds\xf2\xf0\x0c\xe0\xf7k\x00\xbf\xfe\xd9\x00=\x07\xb3\xfc[\xfe\xfa\xff\x10\x07\xe1\xef\xac\x0c\x85\x06\xca\xec\xa6\x16M\xf6A\xfa]\x0b\x03\xfc\xbf\xfc\xca\n\x80\xf8\xdc\xfe\x7f\n\x9f\xf8\xbc\x017\x04\x8f\xf78\x05q\xfbz\x08\xf1\x06{\xeeO\nJ\x03i\xf3\xe5\x00\xb6\r\x06\xf1\xad\x06~\x01\x80\xfc\x07\x01\x1b\xfc\xab\x03\x1e\xfe\xc6\xf8b\x03\xd4\x08|\xf0\xb7\t\x86\xfb)\xfe\'\x03\xdb\x003\xf5\xce\x06s\x06\x9f\xf2\xa7\x01s\x0f5\xf0\xfc\xfd\xe2\x14\x85\xe92\x066\x03\xf2\x02\x12\xf8B\x05\xb8\t\x95\xee\xd4\n\x9b\xfd\xa5\xff<\xfa\xe1\rR\xf7\x9e\xfb\xeb\x10\xc1\xec\t\x0c\xed\xfb\xff\xfb4\x04\xe2\x00\xe0\xff\xd5\xf5\xa8\x12=\xf9\x85\xf3-\x11\x08\xfa\xa2\xf4$\x0eh\x04t\xe8\x94\x11\x8d\r\x9a\xe85\n\xc0\x05\x10\xf3\xbe\x04\xed\x01V\xfc8\x06\xb7\x02)\xf3\xf5\x10\xcc\xf7\xd9\xf4U\x10\x10\xfd\xb9\xf5\xba\x06\xe5\x04\xbf\xf4\x1b\x0c\xcb\xfd\xd7\xf9\xb5\x032\xfb\xa2\x02\x14\x04\x8c\xf1\x11\nQ\x06,\xf1+\x04\x8e\x0e\xf8\xf0G\xf6\x01\x12\x8d\xf0\xf6\x02\x93\x0c\x90\xf2K\x03\xe4\x08_\xf7\x8e\xfd\xb3\x01 \x01d\x03\xda\xfc\xe5\xffc\nD\xf7\xbb\x01y\xfb\x88\x02\xce\n3\xf2\xe7\x01V\x12D\xf2\xa3\xf5\x15\x18\x08\xf3h\xf9\x0c\x0bm\x03\x95\xf8J\x02\xbd\x06\x9d\xf9\x9a\xfb\xe9\x08\xca\xfe\xae\xf9\xf6\x03\xe1\x07#\xf8\xd8\xfa\xe1\x0c6\xf4\x89\x05\x10\xfbA\x05j\xfcl\xfc\xfa\x0e\x0f\xf1c\x00a\x03U\x03\x96\xec\x8e\r\xb1\x03"\xf9\xb5\x04\x1f\xfbo\x04\xd9\xf8\x00\n\xde\xf3\x93\x07\xfc\x05R\xf7[\x07V\xfb0\x06\xe6\xfb\xd1\xf6\x12\x13\xcb\xf3\xc3\xfcY\x08\x16\xfc\xa1\xfb\x91\x01\xa2\x06\x7f\xf1r\x08\xe8\xfa\xf8\x06\xe7\xf8y\x03*\x04\xf6\xf8\xfa\xf9\xab\x04Z\x03\xab\xfa:\x07\xee\xf3\x90\x0e\xa8\xf6>\xfd\xb1\x08\xf2\xfd\x19\xfe*\xfd;\x06w\x05\xd2\xf5\xf7\x00\x00\nT\xfdF\xf5v\rW\x01\xea\xf0\x82\n\x99\x03\x16\xf9\xb3\x05B\x02\x86\xf4{\x06b\xffE\x03\xb6\xf5\x18\x08\xd7\xfcy\x03e\x00\xab\xf4Q\x0cn\xf7I\x00Y\x01n\x03\x15\x01$\xf9#\xffG\r\xcc\xec\xdc\x05\xf0\x08w\xf6 \x01\x0b\xff\x16\x06\xe3\xf2.\x0f\xd8\xf8\xbf\xf7\x98\x08T\xff\xa8\xfc\xa8\xfcQ\n\xe1\xf8\xe7\xf6U\x13\x92\xf73\xfb`\x06G\xfb\x1b\x00\x80\xfcS\tB\xffO\xfb\x1f\x01G\x04\x85\xfa*\xfa3\x15\xb4\xeew\xf8Y\x1ep\xeao\xfb\xd2\x14\x03\xf72\xf5\x02\x11\xb0\xf9_\xfb\x87\x08\xb2\xf8\x91\x0e\xe5\xf0\x0c\x05H\x026\x006\xfc/\xfe\x91\n\x8c\xf66\x08l\xf5C\x06\xb0\x02`\xf9E\xfb\xc6\x0c$\x002\xef\x84\n\x9f\x06"\xf5\xa8\xfel\x05\xb2\xffK\x01\x1c\xf5\xa9\x06\xb1\xff\x8b\x04N\xfa\x8b\xfd!\x04\xf3\xfe\xe1\xfe\x11\xfb\xac\x0c-\xf0\xa6\x04\xd3\x08X\xfbm\xfcr\x03\x8c\x03S\xefH\x0c\x98\t\xff\xeb\x00\x07\x8d\x0e"\xee\xcd\x05"\x04\xad\xfb\x16\xfdW\x00\xbc\x05}\xfc\xfe\xff\xe0\x036\x03\xd5\xf0\xf8\x08\\\x06 \xf5\xd0\x01U\x03\xda\x01\xc4\xfe\x19\xfc\xb1\x01\x1e\x0b\xfa\xf3?\xfe\xd8\t\x98\xfcv\xfb\xa7\x04\xa3\x02\xff\xf8\xf6\x0b\xee\xf21\x07\x92\x01\xb9\xf8i\x07\xce\xfc\x01\x04\xa4\xf8\xf2\x04\xa1\xff\xa9\xfd\xe5\x01\xd5\x01\xad\xf7\xd0\x06\xdf\x01\x14\xfd\xc9\xfbb\x00\xc8\x07c\xf8\xcd\x01\x8a\xfbd\x10\xb7\xef}\xfc"\t\x02\x02\x93\xff\xfc\xf3j\x10\xba\xf9\x17\xf8\xa9\x0b\x1d\xfa\x8e\x00\xe5\x08:\xf8\xf5\xfa\xdc\x04\x98\x00\x9a\xf9\xfe\x0b7\xfd\xc0\xf71\x04e\x05w\xf5\xf1\xfa\x05\x0fP\xf5\xd3\xfeT\r\x1e\xfe\xd5\xf7\xf9\x05?\xf7\xf9\x027\x07\xb1\xf7\x18\x0f\x8a\xf8\xce\xfe2\x04~\xf6H\x07\xdc\x01C\xf7\x8b\x05\x88\tv\xf3=\x02{\x07r\xf5y\xfe\x92\x0b@\xf5\xc3\x02\xa8\x03\xf7\xfc\x89\xff\xbc\x01p\x04\xf2\xeeC\x0c\x08\x03&\xee\x0c\x0c\x13\x0b\xf4\xee\xa7\x03S\x08-\xef\xbf\ny\x05\x82\xf3\xc3\x08\xd8\xf8"\x05S\x00{\xfcM\x01x\x02\x0e\xfd\xbd\x00\x9f\x02\xd4\xfb\xe5\x06\x03\xf7\xee\x08Q\xfc0\xff\xa3\xff\x99\xfb\x8b\x0c\xbd\xf9\x82\xf8\x92\x040\x0c|\xee\x85\xffG\x11\xd5\xf4\x8b\xfe\xd2\x04k\x00Y\xf8\xbd\x08\x94\xf9\x9b\x02\xef\xfe\xe9\xff\x87\tF\xed\x8e\x0cY\xfe\xe2\xfa\xc1\x00\xdb\t\x04\xf9\x19\xf92\x0e\xe5\xf1\x8b\x08\xc0\xfeL\xfbZ\xfe<\t\xfc\xfb\xf3\xf8.\x13\xe8\xeb\xf0\x06\x1b\xfc\xfc\x03\x15\x06\x99\xee\x99\x10\xba\xfc\xbc\xfa\x13\xff\xe6\x06\x08\xfa\xb9\x01Z\x01\xeb\xf6\x06\x0e\x85\xf9\xaa\xf8\xc0\tv\xfd\xc4\xf9;\x0e_\xee\x9f\x08@\x06&\xf4\xb8\x00\x12\x06\xf6\xff\xb7\x01\xda\xf98\x00(\x0f{\xe8Y\x08\xf6\n\xeb\xfa\xc5\xf8{\x07\x80\xffs\x00\xec\xfb5\xfeC\r\x10\xf3\x00\x027\x08,\xf5\xd4\x03\xbb\x04\x9a\xf6q\t\x97\xfbH\x02 \xf9\xe0\x01N\x08\xbd\xf8\x85\xfb\xc6\x0c\x1f\xf6\xd2\xff&\x0cB\xec\x19\n\x7f\x03\xe4\x01\xe3\xf1\xeb\x0c\x81\x02\x82\xf0\xed\x05d\x08\xee\xfa!\xf7\x9b\n\xd0\x018\xf7\xbb\xfe\xa1\n\xa2\xff\xcb\xf5\xe0\x01\x89\x03\xd7\xfbT\x06w\x01&\xf8\x19\x05\xa7\x03k\xf6\xb8\x02\x8c\x02T\xffK\xfaW\x08Q\x03t\xf6u\x07\x8e\xfd?\xf5/\n\xde\xfd\x85\x03\x05\xfd\xc2\x00\x03\x06\x05\xf7\xd9\x01(\xfe\x1d\x08&\xfb\x92\xfd\x07\x0c+\xfb\x01\xf3\xe2\x12&\xf4\t\xfa\xa5\x0e|\x00i\xf2\xfe\x0c;\xfd2\xfar\x07\x08\xf2L\r\xc2\x00@\xf6\xaa\x08\xc3\x089\xe9\xe4\x11\xe9\xf7P\xfe\x97\x039\xfeW\x02\x19\x01\x93\x00\x96\xfc\x88\x02\x97\xf5-\x13\x16\xeb\x8e\x07\x05\n\x1b\xf4\xd7\x08-\xfe\x0c\xf9\xdc\x03\xd5\x04"\xe7\xfd\x11U\x0fw\xe8\x95\x02w\x122\xf0n\xf7\xbc\x177\xee\xed\xff\x7f\x0b4\x00<\xf7\x82\x04\x07\x0c\n\xef#\xfe\x96\x13+\xf4\x05\xf6\xc6\x0e2\xfb\x90\xfb\xa6\x02\x15\x07\xe8\xf2(\x01\x1d\x01\xef\x07\xab\xf4b\x07\xe7\x05\xf2\xf1\xbe\x01\xde\x00}\x05n\xf9\xf1\x07\xd8\xf3\xd8\x04\x84\x04>\xfet\xfd;\x031\x03\xee\xf0o\x0f\x9d\x01\x1f\xf3a\x08\xdd\xff=\x01\x8d\x00\xb4\xff\x94\xfd\xb5\xfb8\x05\xe0\xffb\xfd\x8a\tc\xfb\x05\xf7\xa5\x06\xc2\xff\xe8\x02M\xf1\'\n\x1b\x02\xa7\xff\xff\x01[\xf6\x01\ti\xf7A\xfe\x89\t\xbb\x01\xeb\xf4\x8e\x0b\x80\xffy\xf1\x1d\x0b\xdf\x01K\xf6I\x03U\t\t\xf6x\xfc\xd9\rT\xf8\x8f\xf7|\x08\xac\xfex\xf8&\t\x80\x06\x9b\xf0\xd6\xff\xe0\t\x89\x01\xe6\xf5"\x05x\x05\t\xf6^\x02\xee\x04\x87\xfe\x11\x00s\x04"\xf9\xa9\xf7G\x15\xb8\xf3\xa9\xfdw\r\xcb\xf1.\x07\xc4\xfc\xbe\x00\xdd\xff{\x04\x90\xfd0\x00^\x01\xf2\xfb\x98\x05\xc3\xf6J\n\xb6\xf5\xd0\x07H\x00\x11\xfa#\x0bU\xf23\x04&\x02\xf9\xfd\x93\xfa0\x0b \xfc=\xf7\xf2\r\x02\x00\xe0\xef\xcc\x01J\r\x9b\xf7\xf5\xf9/\x0c!\x059\xf0A\t\x82\xf6Y\x01\x16\x04\x16\xff\xec\x04\xf1\xf7j\n\xe8\xf6\xc9\xff\x8c\x05\xc5\xfb\x8e\xf5\xf0\x07u\x14\xee\xe8e\x01\xa8\x13\x11\xe6\xc4\x01\x10\x10\x01\xfbM\xf6\xec\x07\xef\x08+\xf3#\x02\x1a\x07%\xf5\xd3\xf9\x87\x12\x08\xf9\xd1\xfcy\x07\x9f\xff\xb9\xf7\x10\x00\x14\t\xb0\xf46\x08\xa4\x02\x16\xf9\xf4\x006\t\xf1\xfc.\xec8\x16\xc4\x07-\xe9\xfb\x05\xad\x0ft\xf6\xbf\xf2\x87\x113\xfe\xbf\xf6t\t\xb7\xfc?\xfa\x8a\x0cA\xf56\xff\xf9\x02\'\x05\xe1\xf9\x0f\xf8C\x0f\xba\xf1\xd8\x06\x15\xfe\xce\x00\x88\x02$\xfe\xdb\xf6\xfb\x06\xb2\x00\xe4\xfd\xa6\xfe%\x02)\tC\xefG\ry\xf4R\x05D\x02\xd4\x00%\xfc\xbc\x07\xee\xfb\xec\xfd\xd5\n\xd9\xf6A\x07t\xfe\x8d\xf5\xc8\x03\x83\x0f}\xef\x12\x06\xe1\tS\xf0\xcd\xfap\x0b\xee\xfd\xf5\xf5O\xfd\x90\nk\x05\xa1\xf3\x07\x05H\x00\xa7\xfd2\xfb\xb1\xfe\xce\r\xba\xff\x16\xfc\r\x00Q\x00\x87\x04\x80\xf8\xd2\xf7X\x0e.\x02z\xf3T\n\xcf\x02R\xf5\x80\x00!\xff\xc8\xfa\n\x0b\xe2\xfd\x05\xfeN\xffL\xff\xd5\x04_\xf2\xff\x02\x83\x0b\x93\xefK\x04\xba\n\x84\xfc\x93\x02\xd7\xee\xdd\x0c\xa8\x05\xe3\xf0\x0e\x0b7\x0b3\xf4\xcb\xfa\x9d\t\xae\xfe\x88\xfa\xeb\x05u\x06\xaa\xf1d\x06\x18\x0f\x12\xef]\xf9\xf9\x17\x01\xf5\xc2\xf5\x8f\n(\x05\xc7\xfa\xbf\xf5}\x0c\x83\x00\xa2\xf6\xdb\x05\x9c\x01@\xfe\xad\xfb\xeb\x00\x8f\x05u\xfb"\xfc\xc4\t\\\xfe\xfd\xf1\x80\x05\xc4\x08\xf5\xfb$\xf8\xc3\x06\xe2\xfe\x98\xf5\xf3\x08\x19\x06\xb2\xf6\\\x00P\x04\x8e\xf7f\x02m\x05\xa9\xfb_\xfeL\x00\xe7\x02\xdb\xfe\x87\x01e\x00\xd8\xf9;\xff\x10\x02d\xffB\x08J\xfaV\xf8\xd5\x0b\x99\xfbO\xfb\xec\x07%\xfd\xb5\xf4*\x04g\rm\xf8\xc0\x00r\x01\xe5\xf6\x9f\x009\x02J\x01\'\x00\xe5\x018\xf9:\x02\x10\x08*\xf7h\x01\xbe\xffQ\xfa\x1a\x05j\x03\xdc\x02\xd7\x01\x80\xfbW\xfa9\x05\xb2\x00\xd9\xff\x8c\x05\x91\xfe\n\xfcO\x05\x13\x01\x0b\xfa\x1d\x04\xd1\x02p\xf7\xd1\x03\x8e\n\xc6\xfa|\xf9+\xff\x07\x05\xc4\xfa\x9f\xfb\x03\x08\xad\x03\xaa\xf6+\xfe4\x02q\xfdk\x02y\xfd\x85\xfe\xc5\xfe|\x02Q\xff\xae\x02\xac\xfc3\xfd2\x01\xbb\xfa\x10\x07\xbe\x03E\xfa9\xfc\xb2\x06\xef\xfeg\xfa\x9b\x01\x03\x04F\xfe\xd1\xfb\x1e\x05\n\x01\x83\xfc\xa2\x00\xea\xfd\x8a\xfe;\x02d\x00L\xff\xfa\x01\xa0\x00\x99\xfc\x1f\xff\x7f\xff\x92\x01\xc6\xff\xa6\xfd=\x03\x9b\x02G\xfe\x14\xfe\x1b\x047\xfdq\xfd\xf7\x02}\x02U\x02\xce\xff\xa7\x00\x0b\xfc\xbb\x00L\x00\x91\x01\r\x01\xf7\xfe\xae\xff\xbb\xfb\xf6\x02\x1b\x03\x1c\xfb\x07\xfd\xda\x01\x05\xfea\x00\x0f\x00\x8f\xff0\xff\xa9\xfbK\x02R\x02\xfe\xff\xd3\xfdi\xffC\x014\x00T\x04\xb7\x00\xb6\xff\xbe\xfd~\x000\x04\x15\x02\x0f\x03\x00\x00\x98\xfe\xba\xfea\x02a\x02\xe9\xff\x0f\x017\x01\xd6\xff\x98\x00\xc5\x02\x85\xfe\xed\xfc+\x02\xf4\x01d\x00A\x02\x1f\x02\xc0\xffI\xfe\x96\x01\x9c\x00z\x01\x98\x02\xa7\x01\xfb\xff\x92\x02\xea\x01i\xff\xfa\xfe#\x00\xe5\x01\x99\xff\xc4\x01\x8a\x01\x89\xff\xf1\xfc\xb4\xfe\xc0\xfe\xf9\xfd1\x00\x15\xff\xff\xfd#\xff\xfb\xfd\x15\xfe\xa9\xfes\xfc\xab\xfe\x9f\xfe~\xfe;\x00]\xff\x7f\xfc\xbb\xfa_\xfe0\x00\xd8\xfd!\xfb\xf1\xfd\xfc\xfb\xf6\xf7x\xfcd\xfa\xc2\xf8\xcd\xf7-\xf9\xec\xf8\xca\xf7b\xf7\x9b\xf3\xb9\xf3N\xf7\xb5\xf9\xd9\xf5L\xfa_\xf99\xf5\xe8\xf8N\xfc\xbe\xff2\xff\t\x03\xdc\x05\xc9\x05\x85\x08\x9b\x0c6\x0f\x85\x11=\x13\x87\x16\x05\x19{\x1c,\x1d\xcd\x1b\xb4\x1b\x83\x1a\xa7\x1c5\x1c\xd1\x1a\xd1\x1aT\x16\xe4\x10\xc1\x0f\x93\r-\t+\x05\x14\x02^\xfe\xd6\xfb\xf2\xf8\xd9\xf4/\xf1W\xed\xf6\xeb\x8c\xec\xb7\xec\xa8\xeb\x03\xea\xf8\xe8e\xea\x96\xec\xa3\xedA\xf0\x91\xf3\xc7\xf3\xb7\xf6\x10\xfa\xac\xfbN\xfe\xc6\xff\xd0\x00d\x04!\x07\x18\x066\x06\x04\x06\xb4\x04\xb0\x02\x9c\x02z\x02T\x00<\xfe\xef\xfb\xd4\xf8_\xf4\xce\xf1!\xf0\x15\xee\xbd\xec\xac\xea;\xea\x12\xe8]\xe5\x13\xe5]\xe5\xa6\xe4\xe5\xe5p\xe7\xaf\xe7F\xea[\xeb\xf3\xe9\x1a\xec{\xf2\x04\xf5\xf7\xf6\x90\xf9\xa0\xf8\xe8\xfd\xab\x01\xd0\x03\xf5\t\xab\r&\x16\xf0\x1f\xc0%\r&\t%\x04(e,\xb75A;\xbc>\xb3AA\n\xb1\n\xed\x0c\xbb\x0cK\ry\x0f\x02\x0f2\x0c\x06\x06\xbc\x01\x15\x01\x92\x00\xbc\xfd\xf0\xf9J\xf3\x92\xeco\xea\x01\xeaP\xe8\x0c\xe6M\xe3b\xe3>\xe5/\xe5j\xe6\x19\xe7s\xe6\xd7\xe8\x8c\xedS\xf1C\xf5}\xf7\xbc\xf6\xad\xf8\xc8\xfb\x11\xfd\x9b\xff\x0b\x00\xc3\xfd\xd7\xfe\xa3\xff\x97\xff\xe1\x01\x15\x00B\xfco\xfd\xdf\xfc\x90\xfdc\xfe\xbe\xfc\xde\xfc\xcd\xfb\xda\xfd8\x08\xdc\x14q\x14\x86\rM\nr\x10\xb0\x1f\xca(L,\x1a-2,d-\xc1/\xa50\r/\x1a*\xa9\'\xa8+\xbb.C)\x0f\x1c\'\x0f\x0e\x08s\x06\xc3\x06\xbf\x06\x01\xff-\xf4\x9d\xec|\xe6l\xe5\x82\xe4\x1b\xe0\xb8\xdd\\\xdf\x9b\xe1\x91\xe3\xa4\xe3o\xe1a\xe1\xf8\xe3\x1d\xea\xb2\xf3?\xf9P\xfb\x82\xfc@\xfd\xc0\xff \x05\xe4\t,\x0bC\x0c\x1d\r\xea\r\xf9\x0eD\x0c\x96\x07\xd6\x02r\x01\x9d\x02\xa8\x012\xfdU\xf7l\xf0\xde\xed\x95\xec\x13\xeax\xe9\xed\xe7{\xe5\xde\xe4\xe3\xe6\xb2\xe6\x11\xe6\x1f\xe7\x16\xe9\x1c\xed6\xf2\xf3\xf4\x90\xf4x\xf4\xf0\xf6\xa2\xf9\xac\xfb0\xff\x7f\x00\x90\x00\xdb\x02\xc9\x01\xbd\xffR\x01\xca\x00\x9b\x02v\x02\x8c\x02=\x03\x8e\x01\xe4\x00\x98\x00Y\x02\xff\x03\xbc\x02\xe4\xff\x1c\xffB\x04,\x0c\xa3\n\x85\x0b\r\x11\x81\x14\x18\x18k\x1au\x1e\x1f \x92\x1f\xb1#\xe8+\x1a1\xe8+-%\x80#\x1e$\x17%\xae#\xff!\t\x1d\xad\x13\x83\x0f\xa2\r\xb5\n\xc6\x05L\xfd\xfd\xf9\xff\xf7)\xf4^\xf2\xef\xedv\xe7u\xe3\xdb\xe1P\xe5\x80\xe7\xd9\xe5\xdb\xe3\x04\xe2M\xe2t\xe6\xe9\xea\x08\xed"\xef\xbe\xef(\xf3\xd5\xf7?\xfb*\xfd\x95\xfc\x82\xfc\xf9\x00\xae\x07\xe0\x07M\x07\x0f\x05\x8f\x01\x16\x02\xcf\x03\xa8\x04g\x02\x89\xfd\xf3\xf9\x07\xf9\x03\xfa\x0e\xf8\x14\xf4z\xf0[\xed\xaa\xebi\xf1\t\xf2x\xee\xcb\xec\xaa\xe89\xec \xf2X\xf26\xf1o\xf4\xa0\xf3,\xf3\x81\xf8A\xfbo\xfa\xec\xf8\xb5\xf9@\xfc\xf7\x00\\\x01\xbd\xff\xbf\xff\xac\xff\xfa\x028\t\xc9\x07Q\x05\x00\x02\xd0\x02u\x07\x97\n6\x11K\x0c\xa6\x05\xf5\x07\xea\x04k\x08\xa9\x0e\x84\x08\xec\x07\x15\x0e.\x15\xf9\x15\xab\x11=\x0b@\x0ba\x10J\x17\xda\x1cD\x1e>\x1b#\x16\xd1\x14v\x14\x92\x17\x8b\x15g\x13\xeb\x14\xcd\x13\x1d\x13<\x0f\x91\x08C\x03<\x00\x0b\x01\xfa\x048\x02:\xfc^\xf8"\xf36\xf0\n\xf0a\xefV\xef\xd4\xee\xb5\xed\'\xec\xf3\xea:\xeb\x91\xe9\xb8\xe82\xee\x88\xf2L\xf2\xc5\xf4\xbb\xf5\x8e\xf3d\xf7\xe9\xf9\xfe\xfc}\x02\xb6\x02-\x03\xb0\x02\xd8\x02\x83\x02V\x02\xeb\x02Y\x02\x98\x01s\x01\xd9\xfey\xfb\xfb\xf6\xcd\xf5N\xf5Y\xf3f\xf3\xfd\xf6\xb1\xf1\xb4\xed\x93\xef\xd4\xeb\x14\xedM\xf1@\xf2\xa4\xf4j\xf6\x08\xf3\xea\xf3\xf6\xf7\x0c\xf6j\xf8\'\xffO\x01/\x03\xcc\x06\xb9\x08\xaa\xff\xe3\xfe[\r\xe5\ra\x0c\x81\x0fd\x0c@\x0c\xe9\n\xa6\x0bi\x0e\xed\x08\\\x06E\x0eY\x0f\xea\n\xe9\x04)\x02.\x04\xdd\xfdw\x06\xe8\x10\x1d\x05\x16\xfa\xba\x00\xb5\x02\x8f\xfan\xff\x96\x07b\xfdp\xf8\x16\x08(\x04\x14\xf9\x14\xfe\x1c\x01\xc6\x00\x7f\x00\xfa\x06\xcb\x08\x88\tu\x08p\x03\x82\n3\x0b\xd5\x08Q\x11\x13\x14\xfd\x0e~\r\xef\x0f\xad\x0e\xb2\n\x82\x0c@\x0cy\x0c\x9c\x0c\x95\x08\xfa\x08\xbd\x04 \xff\xdd\xfd\xdd\xff\x93\xff\x8e\xfb\xaa\xfb\x96\xf9\x92\xf5\xb7\xf4,\xf4`\xf1\xb3\xf1\x96\xf2]\xf09\xf5\x90\xf5!\xf1\x19\xf1_\xf5\x1d\xf4I\xf2\xba\xf7\xce\xf9i\xf8\x1c\xf8\xcd\xfdA\xfa>\xf9O\xfd\xad\xf9\x99\xfb\x12\xffc\x02.\xfag\xfd\x8c\x00M\x00\x92\xf72\xfa!\xff\xb4\xfb\xb6\xfb\xe9\xffe\xf8M\xfc\xad\xfc\xf6\xfa\xc9\xfbk\xfa^\xfa\xb2\xf9=\x02\xbe\xfb\xb8\x00T\xf8\xa5\x00\x8f\xf8v\xfe\'\x01"\xf7\x07\x03\xdb\xfe&\x05\x9a\xf9x\x022\x00\'\xff\r\xfb\xe7\x06(\tT\xfb4\x0f\xaa\x04\x14\xfbX\xfc\x98\x11l\x00$\xfci\x16c\x082\xfaL\x06\xb2\x11\xbe\xf6\xcd\xfd\x1a\x1b\xf2\x03\xc0\xf4\xf7\x10\xb1\n/\xfaH\xfe>\x10\xe9\xfc\xa8\xf9b\x06\xf1\x04h\x06\xc3\xf2m\x07\xbe\x07\xbd\xf4R\x02\x7f\x06\x06\xfc\x0f\xff\xe0\x00\x83\xfe\xe3\x02\xf9\x02X\xffm\xfc\xaa\xfat\x055\x08\x0b\xfa\x89\xfb\xeb\x06\xef\x00\xb9\xfea\x02\x1a\x07\xbc\xffL\xfc\xa6\x02v\x07r\x07\xdd\xfcx\x06\x82\x02\xd1\x02M\x03\xfa\x02\x05\x07\x0b\xfeY\x03\xca\x08\xaa\xfbi\x05\x19\x07i\xfd\xae\xfc(\x04\xe5\x05\x83\xfd\xb9\x01#\xff\x95\xfe\xf5\xf9\xae\x02\t\x00{\xf3\xe6\xfc6\xfe\x14\xf9\x9e\xf2\x07\xfay\xfdf\xebU\xf5\xf8\xfe\x93\xf5S\xf6\xa4\xef\x9a\xfc\x94\xf5\x1a\xf1\x90\x04\xa9\xf7O\xf4\xc4\xf9\xee\xfc\xff\xf9S\xfe\xed\xfc\xed\xf5\x16\x05\xea\x05\xe3\xef2\x05\x94\x05\xdc\xfbv\xfb\xbf\xfc~\x11\x9a\x02Z\xed;\n\x19\x06\\\xfe\xf5\xfbn\x06\xfc\x02O\xf7\xff\x0f\xe5\xf2B\x01\x1b\x12\xfd\xefQ\xfa\xa8\x18\x05\x00a\xf2\xc0\n\x06\x0c)\xf8\x81\xff\x8a\x12\xc5\x07\xfb\xfa[\xff\x87\x12\xe2\x03\x9d\xf4B\x0b\x81\x0f\x0c\xfb\xa4\xfe\x0c\x0e,\x04\xfd\xfeC\xfc \n%\x01\x0e\x00h\x06E\x01\xa0\x04\x9d\x02\x11\xf6Y\t2\t\xca\xedS\x10\xc9\x0b\x11\xe7H\x07\xe9\x19\xb1\xf2\xfe\xf5\xec\x05\xff\x0b2\xf1\x01\x02T\x1a\xed\xeed\xf3\xf6\x0e\xbc\x02e\xec\xea\x04\x7f\n\xe0\xfa\xd7\xf2\x11\x0b\x83\x03\xc2\xe7\x87\xfa\xb9\r\x1a\xf8\x05\xf0\x03\x08S\x04x\xef\x1a\xf8\xf3\xfdl\xfd\x1b\xff\xf4\xf1X\x0c\xd2\xf4@\x00\xc5\xfe\x16\xfd*\xf7\xb5\x01\xd4\n\x05\xef\x8c\t\x96\x05\x9c\x07\xc9\xf0\x9f\x07\x85\x0c\x80\xec\xa8\x07\x01\x10v\xfc`\x07\x1c\x05\xc6\xf8/\x01\xb7\x03\xb1\x02\xaf\x00\xa6\xff\x15\x07\xcd\xfb\xd6\xfe`\x02\x15\x01\xaf\xf3\xee\xfc|\x0f\xc4\xf4\xee\x05{\x00\xda\xfc\xd5\xf86\x06\x9a\xfd\x02\x06n\xfe\xf2\xfb?\n\xff\xfcp\x03\xc5\xf5.\t0\xff\xaf\xf3\x18\x0c\xb4\ta\xf0q\x03\x86\xff\xf2\xff\x1e\x02U\xf5\x1c\x0b\x0c\xff\x82\xfb\xfc\xfa|\t\xcd\x04\xd4\xe8\xb7\x0e}\xff\xe8\xf6F\x0f?\xfb\x80\xfb\t\x08\x81\xfa\xaf\xfa\x93\x04\xe8\rG\xf1\xb4\x01B\t\xba\xf4\x83\x0c\xcf\xf4\xa2\x06\xfe\xf5\xbf\x08\x80\xf5\x9b\x0b\xe6\xf6h\xfbw\x05\xc6\xf8\xb4\x07\'\xf2U\n\x8f\xef*\x0f?\xf9>\xfeQ\xf9\x93\r\x9c\x02\x17\xec\xa9\x0c\t\x0c\xa2\xf0\xb9\xf9e\x1e\xa7\xee\xbf\x00\x9c\n\xe0\xff\xf6\xf4\xe3\x08\x83\x03\xe1\xf8\xcb\t\xd3\xfb)\xfd\xca\x02\x0f\xf96\x07\xa8\xed\xd2\x086\t\xe8\xe5\xbe\x0e\xda\x01Y\xf3\xcb\xee7\x12\xf3\xfe\x0c\xe4\x08\x18\xf3\x01\x19\xef6\x02n\x03K\xfe\xcf\xf1\xb4\x0c\xa5\x059\xf7\x8f\x08K\x00\x91\xf8\xb1\x06s\x04Q\xfc\x87\x02I\x07\xa9\xff\xd8\xfe+\x10\xe0\xf6i\xfeI\x06K\x03C\x04\x1e\xfa\t\x10 \xf5\xfa\xfe>\x18<\xf3\x97\xf8\xf6\x0f\x97\xf6\xb4\x03\x97\x06\x0f\x05S\xff"\xff\xe9\xfdZ\xf9\x7f\x0f\xa2\xedy\rJ\x07\xeb\xf0V\x03\xb8\x01\xc9\xf5\xcc\x00*\xfe7\xf7F\t~\xf9\x9a\xfb\xa7\x00\x05\xfb\x0e\xf2\xc9\x10"\xf1\x1e\xf9S\rZ\xf2\xaf\xf8\xb0\t\xec\xf8@\xfa/\x05C\xefL\n*\x03\x9d\xf8Q\x04-\xf9\x9e\xff?\x0c(\xee\xc0\x07\xa8\x14\xe2\xe8\xab\xfa\xd1\x1a\xa5\xfay\xef\xda\x0e\x83\x0c\\\xf3+\xf70\x16R\x00\xab\xf7\x81\x01\xb7\x0b\x13\xfaw\xf7\\\x1bs\xef}\xfbB\x17\xd5\xed\xac\xfb\xb1\x13\xe9\xf8\x87\xf6q\r\xc5\xfa\x07\x05\x14\xfd\x85\xfb\x92\x07\xdf\xf7\x03\x03\x89\x07\xfe\xfa\x8b\xfa\xdc\x06\xc7\x02j\xf6\xde\x00\xb8\x0e\x18\xedg\x07\x11\x01`\x02/\x04\x19\xf0\x11\nw\xff\x1a\x01X\xf5\xcc\x0e\xf4\xf9\xef\xf8,\x08a\xfd\x0f\x04\xbd\xf3\x9d\x00\xc1\x07\xda\xfa\xc3\x00\x8d\x021\xfb\x8a\ta\xea<\x0c\xdf\x0b\x06\xeb\xc8\x08\xec\x00]\xfe\r\x06\xa4\xfb\x08\xf4\x9f\x10L\xf6v\xfb:\t\xa1\x04\x13\xf7\xc5\xfa\n\x0e\xf5\xfc\xb3\xf3\xe8\x0bp\x04\x91\xf1\x99\x11\xd6\xfb\xe3\xfb\xb9\xfd\x18\tm\xf5B\t\x1b\xff\x95\xf6\xf6\x14\xa1\xf1\xf8\xf4\xf6\x10\xa5\xfc]\xf0Y\x0f\xd3\x01\x8c\xf7\xac\xfd\xab\t\xa3\xf4C\xfeY\x08\x12\xf9U\x01\x1a\x06"\xfa\xf0\xff\xcf\xff\xe6\xfcD\x01:\x01\xb7\x00\xf0\xf7C\x0b\x0f\xf6p\xffM\n\x83\xf7\xeb\xf8\xff\x064\x04h\xfaD\xfd\xd4\x08\xd7\xfeR\xf8\xb1\n\x94\xfc\x99\xfc*\x06\xd1\x04\xc8\xf63\x08\x1b\x06_\xf4+\x05x\x08\x9f\xfa\xa1\xfc\xec\t)\xfc{\xfcR\x05\x14\x00~\xfeT\x03v\xf7^\x01\xe0\x07\xec\xf1P\x07r\x06\xac\xe8\xe7\x12y\x01\xf1\xea9\x0c\xa1\x07X\xec\xcc\x06d\r6\xe9\xc4\x0b\x10\xfa\x0f\x05\x11\xfd\n\xf4g\x13\xa9\xf1{\xfe\xf1\x08\xac\xf6\xeb\xfc~\x0b\xac\xf11\xfb\xf8\x13\x89\xf0E\xf6\xdf\x12\xe3\xf7l\xf6\x92\x0b\xe8\xfc\x93\xf7c\x02\x7f\x05\x12\xfb\xc8\xfe:\x07\xfd\xfeE\xf7/\t\xb0\xff!\xfd\xdc\x02\x15\x07\x17\xfe\xe4\x00\xb5\x06\xca\xf9\xf6\x04t\x06k\xfbD\x07\xca\xfd\xd2\x01q\xfe\xb2\x05\xb2\x06\x93\xeb5\x13\xec\xf9\x1c\xfa\'\x08\x85\xfc\xf5\xf88\x08\x9e\x03\xd7\xf03\x07G\x0f~\xe7a\xfd#\x1f\xd0\xe6e\xfd\xa4\x17\xb5\xf4\x15\xef\\\x15\x8b\xf9\xb6\xf3\x91\x11\x82\xf8\'\xfe\xeb\x02E\x01e\x00\x99\xf8c\x06\xe1\xfb\xa1\xfeZ\x08/\xff\xef\xf2\xcc\x0b\xe1\x04\xe7\xe7\x99\x11\xed\xff_\xf7\xdf\xfa\xb3\x0c6\x01\x7f\xecg\x107\xf8\x05\xf9s\x04\x03\x01\x13\xff\x9d\xfd\xbc\xfd\x97\xf8\xd2\r\x87\xf0\xf3\x01:\t\xa4\xeb\x9f\x0c"\x07G\xef\xc1\xff\xce\x0f\x9b\xf0>\x00\x94\t\x7f\xfb\x90\xf8\xad\x0c\xe7\xfd\xee\xf0\xc8\x19\xfb\xf1\xd9\xfe\xe7\t^\xfbU\x01N\xff\x97\x07\x18\x02\xc0\xfe\xf7\xf7\x80\x11@\xf6\xe2\xfcY\x14\x93\xf1:\x05\xdf\x001\xfeU\x04\\\xfd{\x06"\xf9=\x06k\xfcL\xff;\x03<\xfc^\tV\xeb\xf2\r=\x04\x9f\xf6\xc7\x002\x06\xce\xf9\x1b\xf0t\x18\x1b\xf7\x0c\xf0\xd3\x0f\x9c\xfe\xd1\xf3\x8a\x05\xd1\x04a\xf4\xe0\x00I\x06\x84\xf7,\x01\xc7\x06\x88\xff+\xed\xb0\x10\xa7\x02e\xf2\x8e\x04\xb3\x05\xa1\x00\xc1\xf1\xbd\x16J\xf4w\xfd\xaf\x04\xed\x00\xe1\xfb\xbb\x02~\x0c\xa6\xee\xd7\x0eq\xfdI\xf4i\x0c\xf6\x00\xdd\xf82\x03\xf8\x06\xa7\xf8\x13\x00\xb8\x074\xf7G\xff\xbb\x03\x14\x04\x00\xf9.\x02\xb5\xfd\xc7\x00\x05\x03\xf2\xf6\xaa\x08\x94\xfb\xcb\xf9|\x07\x02\x05\xad\xefW\t5\x05E\xef\x19\x0e_\x03\xaa\xf3J\x07\xe9\x02\x07\xf3\xce\x11=\xfb\x84\xf3\xbb\x12\x80\xf7\xd4\x009\x05\xf2\xf8-\x03\xef\x02\x94\xfe\x8f\xf9\xb2\x0eb\xfe\t\xeb^\x11H\x05\\\xee~\x07k\x07\xb0\xf0\n\t(\x05\xe8\xf0\x06\x06\x19\n/\xf1\xc0\xff\x9f\r\xf5\xf3\x1e\x02^\x00\xdc\x01\x8c\xfe\x8d\xfey\x02(\x01\xd4\xfb\xc0\xfc\xfa\x0b\xfa\xfa\xed\xf6\xfa\x0cS\xfb\xcc\xf8_\x0b\x12\xfd2\xfa}\x03l\x04Z\xf9\x10\x082\xf7\xab\x02^\x05\xdc\xf9(\x004\x02\x8d\x08\x19\xee\x1d\x0c\x0f\x00\x7f\xf6{\x0c`\xfa\xd1\xfa\x85\x07X\xfd\xea\xfd\x90\x005\xff8\x00z\x013\xfa\x91\xfc\x8a\x06\\\xfd\x00\xfa[\x03\x1a\x01e\xf6\xa2\x06s\xfbQ\xfc~\x04\x1d\xfc3\xfe\xe0\x05\x8d\xff\x03\xf9\x8c\x08\x0b\xfcn\xfe`\x04+\x02\xf7\x01/\xff\x0f\x00N\x05h\xfe\x94\xfd \x07\xd8\xfe\x15\xffR\x04\xaa\x00\x17\xff\xf3\xfdi\x045\xff9\xfdL\x043\xfd\xf1\xfe\xec\x02\xed\xfd\xf1\xfd\x04\x03\xf5\xf9\x88\x01\xa6\x00\xd6\xfb\xd4\xff\xe9\x00\xaa\x01u\xf8\x83\x01\xd7\x03\x8e\xf74\x05\x0f\x00\x01\xf9;\x06\xaf\xfde\xfc-\x04z\x025\xf9\x86\x04\xff\x02\x8e\xfa\xb8\x02\xe3\x03^\xfe\xc6\xff\xb3\x04\xa1\xfa\xd5\x03U\x02\x1c\xfdy\x01 \x00\xdf\x00\x16\xfes\x04:\xfc3\xfe\xc3\x03n\xfc\x7f\xfe\x95\x010\xfe`\xfe)\xff\xf3\xfe\xc2\xffS\xfd\xc9\xfe=\x01I\xfd\xb8\xfc1\x04\xe9\xfe\xc4\xfd<\xff\xcf\xff3\x02\xcc\xfe\xeb\xffz\x02\xb9\xff\xe4\x00\xb3\x01r\x01\xe1\x01D\x01\xf2\xff\x1a\x02d\x03`\x00\xd8\x00\x18\x02{\x01k\xff\x7f\x01\xab\x00\xb1\xffH\x00\xf9\xffi\xfe\x98\xff\'\x00q\xfd\x14\xff`\xff\x11\xfe\xb0\xfe\x93\xff\xba\xfdc\xffN\xff|\xfe\'\xff\xdc\x00\x1b\xff\x02\x00L\x01H\xff`\x00\xd8\x00\xf0\x00\xe9\x007\x01\xdd\x00\xe3\x00\x92\x00\xda\x00\xe2\x00\x84\x00\xe9\xff\xa5\x00\x0f\x01\x01\xff2\x003\x00f\xff\x9f\xff\xfa\xffY\xffz\xff\x8a\xffw\xff\x95\xffc\xff\x8c\xff\xa8\xffw\xff\xa1\xff\xf2\xff\xb7\xff\xc8\xff\xb8\xffG\x00\xd1\xff\xd0\xffd\x00\xfa\xff\xa9\xff\x9c\x00M\x00\xca\xff+\x00\xd3\xffa\x00\xda\xff\xfe\xffb\x00\xaf\xff\xb4\xffK\x00\x98\xff\x94\xff[\x00\xb4\xff[\xff\x06\x00\x02\x00r\xff\xdf\xff-\x00\xae\xff\xfa\xff`\x00\x01\x009\x00`\x00\x16\x00\x8b\x00\x96\x00=\x00\xf0\x00\x99\x00i\x00\xf9\x00\x91\x00B\x00r\x00\x8a\x003\x00\x07\x00\x82\x00\x15\x006\xff\xe7\xff?\x00\x04\xff\x8e\xffB\x00\xb0\xfe\r\xff\x0c\x00T\xff\x11\xff\xb1\xffa\xffD\xff\xf2\xff\xbd\xffx\xff\xf9\xff\x02\x00\xf8\xff\xe9\xffj\x00P\x00\xfc\xff]\x00\x87\x00J\x00H\x00\x99\x00K\x00\xec\xffk\x008\x00\xda\xffX\x00\xfe\xff\xb7\xff\xc6\xff\xc8\xffD\x00\xc0\xff9\xff\x11\x00\xc9\xff\xe7\xff\xab\xff\x9b\xffw\x00\xa4\xffY\xff\xd4\x00)\x00(\xff\xef\xff/\x01/\xff.\xff\x05\x01}\xff\x9b\xff\xbe\xff\x13\x00K\x00\xa3\xff\xb9\xff\xaf\xffb\xff\xaa\x00\xb9\xff\xeb\xfeI\x00\x97\xff$\x00\x07\xff\xc7\xff\r\x01\x9c\xfe\x13\xffE\x01Q\x01\x1c\xfe\xaf\xff\x8a\x01\x87\xffl\x00\x8f\xff\x0e\x01r\x01L\xfd\xf7\x00\xc3\x03d\xfe\xd9\xfd4\x02\x00\x01\x00\x00\xa0\xffh\x01.\x01v\xfe\x8c\xff\x18\x02a\x01\xac\xfcM\x01\xd4\x00R\x00\xb7\x00\x15\x01w\xfd\x80\xfd`\x02\xa3\x03\x07\xfe\xeb\xfa\x80\x03\x94\x01p\xfd\\\xff"\x02\x05\xfc,\xfe*\x04\x0f\xfe\x93\xfdK\x03\xf8\xfd\x13\xfb\x85\x04\xd2\x02\xc4\xf8{\xfd\xc4\x08{\xfe\xd5\xfa\xde\x00+\x04\xff\xfd\x02\xfe#\x00\x03\x01\xad\x00\xc6\xfd|\x01\xbc\x02\xbd\xfe\x02\x00\xaf\x00\x04\xfe\x94\x00\'\x01\xba\x03\xbb\xff\x9d\xfc\xbd\x02\xd0\xfd\xa4\x01\xc3\x01z\xf9\xdd\x06\xb3\x04\x95\xf4r\x00q\x04|\xffW\xfeE\xfe\xdd\x03~\xfc\x1b\x00\x12\x02\x07\xfb\xbd\xfe\x18\x07\x97\x00W\xf7\x0b\x05>\x04B\xf7\xe6\x03\x81\x03[\xfd$\xfcM\x03\x85\x07\x05\xfa\x1c\xfd\xac\x01c\x06y\xfe\xcb\xf7\xd5\xff\x08\nx\x03\x85\xf2\xec\x02K\x0b#\xf5@\xf8$\n\xbb\x06H\xf5S\xfc\xa1\x07\x05\x02\xe3\xf9\xe9\xfbB\x07(\x00\x12\xf8\x8e\x02~\x0c(\xfa\xa8\xf3\x04\ta\x05\xe7\xf8\x84\xfe\x97\x06\xc8\x04%\xf3\x92\xfd\xdc\x0e\xa0\xfe\x93\xf2\xd3\x00\x90\x05\x88\x01\xaa\xfbH\x00?\x05?\xfa/\xfd:\x05\xd8\x05\xa5\xf8I\xfb\xfa\nK\x07\xb4\xf2\xf5\xfdX\n\xfe\x03\xb7\xf6z\xff\xf1\x03\x98\xff\x85\x032\xff\xce\xfc\xb4\xfb\xea\x00H\x07\xf4\xf6\xb7\xfd\xe9\x07\xec\xff\x08\xf8\xc4\xffN\x04\x81\x00}\xfcn\xfa\xee\x04\x98\xfc\xce\x03l\xfc\xd0\x04\x0b\xff\xea\xf3e\x06\xb3\x07\xbf\xfc\x86\xf92\t\x82\xff\x10\xfe@\t!\xf9&\xfd\xa2\x07\xf8\xfa\x99\t\x03\x02\xc5\xf7\t\xf8\xaa\x0ci\x07\x0e\xec|\x00/\x05\xe6\t\x07\xf8\x0e\xf17\x07\x18\x03\xaf\xfd8\xff\xbd\x03S\xf2\xaf\xfc\x9a\x15\xd7\xfc.\xf3\x8f\xf7\x9e\x07+\x14\x9c\xf8\xbb\xe8Z\x03D\x1b\x11\xfd\xa7\xe9\x9a\xff\x81\x12\xbd\x05.\xf7\xe3\xf5\xd0\xff>\x04\x99\nu\xfdB\xfa\x14\xfe\xa1\xfcn\r\xc1\xf7Q\xfb\xbd\r\x1b\xfc \xf8i\x04\xeb\n\x08\x00\x0c\xf7\xe9\xfa?\x02?\x01\x06\r(\xff/\xf1\xf5\xff\xb5\x01g\x03\x17\xfe#\xfe\x13\x06\xb9\xf8\x8e\xf4z\x0b\x1b\x0c\x9d\xfc\xc2\xf1\xe2\xf9\x86\n\x9d\n\xad\xf7\xed\xf3\x9f\tZ\tN\xf3\xc2\xf9D\x10\xd9\x02\x88\xf2\xdf\xf6\xa4\x0e3\x0f\xc0\xf5\xdf\xf2\xb2\x0b\xa4\x02\x81\xf4\x18\x06\x07\x06l\xff\x88\xf9\xfe\xfc\x1d\nk\xfc\xee\xf4\xf6\x06\x88\x01\xb4\xf9E\x05\xf1\x05\xd7\xf4\xc2\xfe\x96\x07\xf9\xf59\x03\x9e\x08\xf0\xf5:\x01@\x0e\x93\xfa}\xf2\xb6\x02\t\xff\xcd\x02\x02\x05\xc0\xf6\xaa\x07\xb3\x02\xa3\xf3\x1b\x07\xea\xff\xf4\xf1N\xfe\x92\x10\xfb\x06\xcb\xf5\xb5\xf7#\x00\xf1\x07\xcc\xfd\x92\x03Z\xf9\x17\xf6!\x0f\x80\x07\xd5\xfa\xfd\xfc/\x02\x04\xfa\xcb\xf8\xe8\x07\x86\t\x91\xfe\xf3\xfc\r\x05\xf6\xfdm\xf4 \x00\x93\n\xbf\x07j\xf3|\xf8\xeb\x0eQ\t:\xf5\x19\xf5\x9c\x06\x8a\x02\xda\x00\xc8\xf5\xd5\x06\x1a\x0c\xa9\xed.\xfd<\x0c1\xfff\xedq\x01^\x0e\xdb\xfd\xb3\xf6\xc7\x05\x0c\x08\xef\xf7\xea\xf6o\x06\x10\r\xde\xf9\xd1\xf0\xf8\x07c\x14\xb0\xfc\x08\xf8\xe4\xfb\xd9\x00\xca\x06H\x01\x82\xfb[\x02m\x00\x87\x01\x8b\x03\x03\xfe\x9e\xf7\xcc\xfb\x94\n>\x01q\xf8X\xf99\n:\x08Q\xfd$\xf9\xe9\xfb4\x08\x02\x07\x89\xfau\xf6E\nr\x0b\x16\xfb\x82\xfc\x1b\x01\xff\x03w\x01\x1e\xfb\xf9\xff\x9b\x03\xdb\x00\\\x03U\x02\x85\xfb!\xfcR\x03\x89\x01\x10\xfc\xec\xfe\xd3\x01/\x06L\xfbH\xf6Q\x02\x11\x02\xa9\x01n\xf8\x19\xf9l\x02|\x04\xd8\xfc\xe2\xfbW\xfdQ\xfe\x85\x00R\xfe\x17\x00E\x00\x15\xff\xf0\xfd\xde\xfb2\xff\x17\x08K\xff\x16\xfa\xa9\xfd\x8d\x01?\x03\x98\xfb=\x00\x8e\x04D\xfd\xdb\xf5\x94\x03n\x0b\x91\xfd\xc2\xf0\xf5\xf9\x87\t\xd3\x05\xb4\xfb\x8f\xf2\xf3\xf7u\x06C\x08\x00\xf9i\xf0\xe5\xf6Z\x01\xf5\x07?\xfe\x05\xf6I\xf9t\xff\x7f\xfe/\xf7\xf7\x01\x98\t\xea\xfd\x06\xf4\x19\xf7h\x05\x8f\t\x87\xfd_\xf3\x97\xf5k\x03<\x08I\x05\x91\xfc\x95\xf2+\xf8\xe4\x02\xda\x07w\x07\x91\x08}\x07&\x06\x1f\tP\x11\xa8\x12\xa3\x07\xa6\x06\xce\x15\xfd#y \xdd\x10\x11\x0c2\x12\xa9\x12O\x11\x81\x12\xfd\x108\x0b\x99\x05\xbe\t$\rT\x00\x8f\xf0g\xee\xce\xf6,\xfb\x08\xf6\x93\xf0W\xee2\xea\x17\xe7s\xe80\xebU\xe9\xc9\xe6\xc8\xeb\xaa\xf4\xf3\xf8\r\xf5\xe0\xef\xbf\xf1]\xf6\xd0\xfa\xfb\xff{\x03\x1f\x05v\x044\x045\x07\xf9\x07\xe2\x03\x00\x00\xb0\x02B\t\x85\x0b\x83\x06a\x02\xa8\xff\xbf\xfd \xfc\x8e\xfc\x1b\xfdl\xfb\xa4\xf9\x00\xf9\x13\xfb\xb9\xfa$\xf7U\xf3D\xf3;\xf8s\xfc\xed\xfcT\xfd\xb8\xfc\xd3\xfbA\xfeN\x01\r\x02"\x02\x83\x03\xb3\x06\x97\tf\n\xad\x08m\x07\xb2\x07^\x07C\n\xa9\x0c\xf7\x0c\x96\nn\n\x13\n7\n!\tX\x07\x19\t\x8f\t*\n\xbb\n\xb5\x08\xa7\x03\xc1\x00\x8e\x001\x01\x9d\x01\xec\xff\xa9\xfe+\xfc\x8b\xf9\xe4\xf7f\xf6\xbe\xf4k\xf3(\xf4\xcf\xf5\x98\xf5\t\xf5\xa0\xf4{\xf2^\xf1\xdb\xf3\x0e\xf5\x9a\xf5a\xf7u\xf8\xf2\xf8\xef\xf9\xa5\xfa}\xf9+\xf9\xce\xfb\x0f\xfe)\xffv\x00\xc5\xff\x07\xffE\xff0\xff\x1e\x00\xc7\xfe\xf0\xfd\xe9\xfdi\xfd\x9e\xfc&\xfb\xe5\xf7\x07\xf7C\xf7j\xf8\x94\xfa\x88\xf80\xf7\xc8\xf43\xf3;\xf5\x9f\xfe0\x0c}\x13b\x11T\n\x85\x0e\x8c\x1a|\x1f\x12\x1dr\x1e=,\xf88\xf38\n/\xca$1!\xfa\x1e\xa4!\x07$Y#\x81\x1c\xcc\x12\x98\x0c\xf3\x04\x0f\xfa-\xefH\xeap\xeb\x9f\xeeq\xeeo\xe8\x11\xe0c\xd7\xe7\xd3[\xd5T\xda\x93\xdf\xc4\xe2\xbe\xe7\x1f\xec\x81\xee\x8f\xed\xaf\xecQ\xf0\xee\xf4\xc3\xfb\xeb\x03[\n{\x0c\xd2\x08\xcb\x05\x93\x06\xb1\x08\x02\x08I\x05\xc1\x06i\n|\x0b\x91\x06\x10\x000\xfaT\xf6\x84\xf4\xff\xf5_\xf82\xf7\xae\xf3\x9a\xf1-\xf1\xa5\xef3\xedZ\xeb/\xee\xd7\xf2\xdd\xf6)\xfa\xb3\xfb\xf6\xfa\xfc\xf8\xcd\xf9\xf3\xfe\xc8\x02d\x05\r\x08;\x0b\xa7\x0e\xec\x0e@\r;\x0c/\x0c4\x0c\xfd\x0e^\x12\xbb\x13O\x11\xd3\r\x06\x0bF\n\xee\x08|\x06x\x05,\x05\x8c\x06f\x07\xce\x04;\x00\xb3\xfbZ\xf8>\xfa\xf5\xfc,\xfel\xfe\xc0\xfc\xb9\xfa.\xf9n\xf90\xf9\x9d\xf8-\xf9\xbb\xfb\xbf\xffI\x01\xa4\x00p\xfd\xc9\xfa_\xfc\xee\xfdR\x011\x02s\x02\xc6\x01\xfa\x00\xb5\x00P\xfe\x86\xfc\x9a\xfb\x8e\xfb\r\xfd\xd5\xfe6\xfe\xbe\xfb\xf0\xf8\xb3\xf6\x17\xf7\xd5\xf7\x0e\xf8\x91\xf8v\xf9\xb8\xfa\x07\xfbK\xfa\xb2\xf81\xf7:\xf7v\xf9\x0c\xfc\xb2\xfc\xd4\xfd\xb8\xfd\xb7\xfbs\xfc\xde\xfb\xf9\xfb|\xfd_\xfd\x0c\xff5\xff\xcd\xfe}\xff]\xfc\x90\xfc\xec\xfci\xfc\x9c\xfep\xfe\x83\xff\r\xfe\xc7\xfc\xae\xfd\xde\x04m\x0f\xa9\x13a\x0fq\ne\x0eV\x1a\x1f \x9d\x1c\x99\x1d\xa8#\x1e+Q+\x96$q\x1f\xa6\x1a-\x18\x13\x19\xcd\x1b\x9b\x1bh\x13\xef\x07\xd9\x02\x9d\x00\x83\xfb\x1e\xf4\x07\xee\x97\xec0\xec\xb0\xe9d\xe9K\xe7\xd5\xe1\xcc\xdc\x8b\xdc\x9a\xe2\xdc\xe8e\xeb\xe1\xebS\xee\x1a\xf22\xf5\x0c\xf7d\xf8\xac\xfb\x01\xff\xac\x02\xf7\x07\xa1\x0b?\x0bk\x07m\x05\xe3\x06\xd2\x08\xef\x08\x1e\x06\xab\x04\xcc\x03r\x02G\x00\x01\xfd\xde\xf9b\xf6$\xf4\xbb\xf4\xda\xf6g\xf6\xd7\xf2\xbb\xef\x9f\xef\xd2\xf0\xd5\xf0(\xf1{\xf2\xb3\xf4g\xf6\xf8\xf7\x97\xfa\xc2\xfb\xbb\xfbW\xfc\xe5\xfe\xd8\x02\x06\x06/\x07\xb5\x07\x9d\x08p\t\x9b\n\xa1\n\x96\nW\nG\x0b\x9f\x0c\xd1\x0cO\x0cg\nW\x08\xb9\x06o\x06M\x06@\x06&\x05E\x037\x02\xa9\x01X\x00\x9a\xfe\t\xfdv\xfbZ\xfb\xc0\xfb\xcc\xfb\xde\xfb\x15\xfbh\xf9N\xfa\x02\xfb\x06\xfb+\xfb\x9b\xfa\xc7\xfd\x18\x00\x89\x01)\x03q\x03\xee\x03\x1f\x05\xec\x06k\x08\xa1\t?\t!\n\xf1\x0b_\x0b\x17\x0b8\x08\x95\x06V\x06B\x04\x11\x04\xee\x01R\x01\xed\xfef\xfcp\xfb]\xf9}\xf7n\xf5C\xf4U\xf4\x82\xf4]\xf44\xf4\x7f\xf3N\xf25\xf2\x88\xf2W\xf35\xf4\x86\xf4<\xf6(\xf7\x1c\xf8\xe8\xf8 \xf8\xff\xf7\xe3\xf89\xfa\xa5\xfb\xf3\xfcT\xfd\xd5\xfd\xcb\xfd\xe0\xfd\x11\xfe\xf0\xfd\xa1\xfe[\xfe\xd1\xfe>\x00\xbb\x00\xf5\xfe\xb9\xfe\x94\xfe]\xfe\x93\xff\xbb\xff\x19\x01\xd6\xff/\xff\xcd\xff\xd1\x01\xc9\x05V\n)\r\xae\r\xed\x0c*\x0e\x88\x12n\x176\x1b\xaf\x1b\x91\x1d7 \x9f!\x15 \x8e\x1c\xd7\x1b\xce\x1aI\x19y\x18(\x17\xaf\x14\xe7\x0e\xd5\x08\xc7\x05\x96\x03p\xff\x90\xfa\xa8\xf7c\xf6\xcd\xf4\xb2\xf1\xc1\xef\x1e\xef\xc2\xec\n\xea \xeb]\xee\x08\xef\xd7\xed\xa3\xed\x9b\xefq\xf1\x8d\xf1:\xf3c\xf4I\xf4B\xf4\xf5\xf4|\xf7\x90\xf8\x9f\xf7#\xf7\x06\xf8\xd4\xf8F\xf8\x7f\xf8\x9e\xf8\x84\xf8R\xf8?\xf8~\xf9\xe5\xf9\xcb\xf9!\xfa\xcb\xfaW\xfb\xa7\xfb\xb1\xfb5\xfc\xdf\xfc(\xfdi\xfe\xcc\xff\xce\x00\x9e\x00j\x00\xb7\x00T\x01\xbc\x01\xee\x01\xbe\x02\xb5\x03\xc4\x03\x01\x04\x83\x04\x89\x04\xe2\x03\xa2\x03*\x04\x04\x05\x91\x05\x06\x06\xa1\x06>\x07Q\x07@\x07S\x07a\x07\xc4\x07\xb0\x07q\x081\t\xc4\x08P\x08\xb5\x07\'\x07y\x06\x81\x05\xa6\x04\x0e\x04\x7f\x03~\x02\xf0\x01X\x01+\x00\xf4\xfe\xbb\xfd\x9a\xfcr\xfc\xb3\xfb,\xfb\x19\xfb\xc6\xfao\xfaG\xfaE\xfa\x8c\xfa`\xfa}\xfa#\xfb\xcb\xfb\xe5\xfc\xf5\xfdx\xfe\x8a\x00\xf4\x01\xd8\x01Z\x02\x7f\x03\x80\x05\x9b\x05\xaa\x05q\x06\x8c\x07"\x08\xb4\x067\x06P\x06\x01\x05"\x03\xbe\x02\xcb\x02\x18\x02\xb1\xff\x0c\xfe2\xfe\xb6\xfc\xf8\xfaN\xfa\xbd\xf9X\xf9\xf7\xf7Y\xf7\xe6\xf7t\xf7\x8f\xf6\x1a\xf6\x96\xf6\x82\xf6\x9c\xf6\xd1\xf61\xf7\x92\xf7z\xf7\xbb\xf7x\xf8.\xf9B\xf9j\xf9\\\xfa\xc6\xfbu\xfc\x8e\xfc\xf7\xfc\xb6\xfd\xea\xfeO\xff\x03\x00\xee\x00|\x01K\x019\x01\xe6\x01^\x02\x1f\x02\xf5\x01{\x024\x02l\x02%\x02U\x02\xd9\x02\xc8\x02-\x03\xd4\x03\x1a\x04\xd3\x04I\x08\xb9\n-\x0bS\x0b\xd1\x0c\x9f\x0f)\x11\x88\x12\xcb\x14\t\x16\xff\x15\x14\x16\xab\x16\x10\x17\x0f\x16\x8f\x144\x13\xb6\x11\x9a\x0f\x87\r\xda\x0b\xc4\x08J\x05/\x03\xf4\x000\xfe\x18\xfbE\xf9\xba\xf7\x86\xf4\x0c\xf2\xb8\xf1=\xf27\xf1\xf0\xee\x00\xef\x0b\xf0\xbe\xef\xd4\xee\x04\xef\x9e\xf0\xb1\xf07\xf0b\xf1\x11\xf3\xae\xf3\x9f\xf2\xc3\xf2\x9d\xf4\xb7\xf5\xb4\xf5\xda\xf5\x0c\xf7P\xf8e\xf8\xa4\xf8,\xfa\x94\xfb\xdc\xfb\x81\xfb\xd9\xfc=\xfe\x91\xfe\xa8\xfeK\xff\x95\x00\xe6\x00Q\x00\xaf\x00\xaa\x01\xb7\x01\n\x01\xeb\x00\xa4\x01\xe5\x01\x1a\x01\xae\x00\xa5\x01\xf1\x01\x1b\x01\xee\x00\xc1\x01\xf9\x01\xa5\x01X\x01:\x02Z\x03\xd7\x02\xa2\x02\x9b\x03n\x04n\x04>\x04\xf5\x04\xb1\x05\x7f\x05N\x056\x06\xe9\x06\x91\x06;\x06q\x06\xa7\x06G\x06\xd6\x05\xae\x05\x80\x05\xe1\x04 \x04\xb5\x038\x03O\x02|\x01\xe6\x00\x16\x00\x87\xff\xb6\xfe\xe7\xfdW\xfd\xb6\xfc-\xfc\xe1\xfb\x82\xfbI\xfb\xec\xfa\x0f\xfbB\xfb\xe7\xfa\xe9\xfae\xfb\x97\xfb\t\xfc\x8e\xfd#\xfe\xea\xfd\xa9\xfe\x1e\xff#\x00\xcb\x00,\x01Z\x02\x9d\x02\x85\x02\xb4\x02u\x03?\x04\xa4\x03\\\x03\x9f\x03\xd1\x03*\x03l\x02\xac\x02K\x02\xa3\x01\xe3\x00\xae\x00\xb0\x00\xc1\xff\x8c\xfe0\xfe!\xfel\xfdg\xfc8\xfc0\xfc\xc5\xfb \xfb\xf9\xfaO\xfb*\xfb\x93\xfa\xa4\xfaZ\xfb\xca\xfb\x9d\xfb\x9c\xfbm\xfc\xbe\xfc\x95\xfc\xd6\xfcv\xfd\x13\xfe{\xfe\x83\xfe\xcf\xfe\x90\xff\xc5\xff\xc1\xff~\x00\xba\x00\xbd\x00\xa2\x00\xe8\x00"\x01\xf1\x00\xe7\x00\xf0\x00\xf2\x00\xbe\x007\x00\x95\xffe\xff\x0b\xff\x05\xff\xff\xfe\x08\xff\xe2\xfe\xcb\xfe\x1c\xff\x83\xff]\xff\xc3\xffr\x01-\x03\xe0\x04\x07\x06\x1d\x08@\n"\x0b\x18\x0c\xd3\x0e\xe9\x11L\x13\xc1\x13r\x14\x97\x15\x8a\x15;\x14i\x14`\x15\x19\x14\xef\x104\x0eN\r\xbb\x0b\x16\x08\xce\x04\x1e\x03\x02\x016\xfd\xfa\xf9\xf5\xf8\x12\xf8\xe2\xf4^\xf1\x85\xf0S\xf1\x17\xf0\x1d\xeeM\xee\xa1\xef\x80\xef\x18\xee\xba\xee\xd0\xf0\x1f\xf1I\xf0A\xf1\xcf\xf3\xdb\xf4\x8a\xf4\xeb\xf4\xae\xf6\xb8\xf7y\xf7Y\xf8\x04\xfas\xfa8\xfa\xa1\xfaG\xfcU\xfd\x0e\xfdR\xfdH\xfe\x89\xfeY\xfe\xfb\xfe\xd5\xff\xfb\xff\xae\xff\xef\xff\xa0\x00\xba\x00T\x00m\x00\xc1\x00\x82\x005\x00\xad\x00j\x01(\x01\t\x01\x9a\x01\xea\x01\x03\x02f\x02\x13\x03\xc0\x03\x06\x04k\x04U\x05\xf8\x05Q\x06\x9a\x06\x06\x07z\x07`\x07b\x07\xa8\x07\x84\x072\x07\xdd\x06\xbe\x06\x91\x06\xd5\x05 \x05\xa6\x04\xf4\x03\n\x03?\x02\x90\x01\x0f\x016\x00\x06\xffN\xfe\xc8\xfd\xd2\xfc\xf1\xfbU\xfb\xd3\xfa=\xfa\xa5\xf9\x82\xf9\x89\xf9\x7f\xf9M\xf9\\\xf9\xa9\xf9\xcd\xf9\xf4\xf9e\xfa\xf3\xfaP\xfb\xa7\xfbQ\xfc\r\xfd\x8d\xfd"\xfe\xa0\xfe\xfe\xfe\x9f\xff[\x00+\x01\x0c\x02\xf5\x02\xbc\x033\x04\xdd\x04\xa8\x05\x9e\x06\xfb\x06\xe5\x06|\x07\xf6\x07\xcd\x07C\x07\x10\x07\xfd\x06\x16\x06\xd9\x04\n\x04\x8e\x03\x92\x02\x02\x01\xd8\xff<\xffa\xfe\x1b\xfd\x0e\xfc\xd4\xfbQ\xfbR\xfa\xbe\xf9\xcb\xf9\xa4\xf97\xf9\xfa\xf8&\xf9\xa2\xf9\xc9\xf9\xd4\xf98\xfa\xd2\xfa=\xfb\x98\xfb.\xfc\xf4\xfcx\xfd\xa3\xfd-\xfe\xc2\xfe+\xffv\xffV\xff\x80\xff\x00\x00\x95\xffH\xffp\xffu\xff\x13\xff\xa0\xfek\xfew\xfe:\xfe\xc1\xfd\xc3\xfd\xd6\xfd\xcc\xfd\xe5\xfdD\xfe\xd0\xfe\xf2\xfe\xb8\xfeS\xff%\x00\xe7\x00a\x01&\x02\x10\x03t\x03\x14\x04 \x05\xc7\x05p\x06w\x07\xa9\x08\xe6\t\x8c\n@\x0b5\x0c\xf4\x0c\\\r`\x0e\x88\x0f\x07\x10\x04\x10\xd1\x0f\x02\x10\xe9\x0f\x1c\x0f\xc2\x0e\x96\x0e\xa3\r\xd9\x0b\xfa\t\xdf\x08\x9b\x07K\x05\xf8\x02b\x01\xdf\xff\x94\xfd&\xfb\x8e\xf95\xf8%\xf6\xe8\xf3\xe6\xf2\x92\xf2l\xf1F\xf0\x1d\xf0\x8a\xf0e\xf0\xee\xef_\xf0]\xf1\xd7\xf1\x0e\xf2\x15\xf3\xa5\xf4\x87\xf5\xf3\xf5\xd0\xf6"\xf8 \xf9\x94\xf9Z\xfa}\xfb8\xfc\xa1\xfc8\xfd>\xfe\xf8\xfe+\xff\x88\xff\x1b\x00\x8c\x00\xc7\x00\x1d\x01\xa1\x01\xda\x01\xe8\x01%\x02\x7f\x02\x9c\x02\x8a\x02\x9a\x02\xa8\x02\x89\x02b\x02t\x02\x86\x02\\\x02?\x02F\x026\x02\x01\x02\xea\x01\n\x02\x18\x02\x02\x02\x02\x026\x02a\x02X\x02i\x02\xaf\x02\xd6\x02\xae\x02\x8a\x02\xbc\x02\xe1\x02\xc8\x02\x9b\x02\xa3\x02\xb3\x02m\x02\x1e\x02\x02\x02\xda\x01g\x01\xe4\x00\x9b\x00\x82\x00\x1f\x00\x8c\xff\x1c\xff\xd2\xfed\xfe\xdd\xfdu\xfd.\xfd\xd7\xfck\xfcA\xfc1\xfc\t\xfc\xdc\xfb\xc9\xfb\xed\xfb\x14\xfc*\xfc`\xfc\xa1\xfc\xd8\xfc\x13\xfdm\xfd\xef\xfde\xfe\xcf\xfe\x18\xffl\xff\xe0\xffy\x00\x12\x01\x80\x01\xf2\x01y\x02\xc3\x02P\x03\xd9\x03\x8a\x04\xc1\x04\x84\x04\xe2\x04C\x05q\x050\x05S\x05\x91\x05\x17\x05p\x045\x04r\x04\xd9\x03\xfe\x02\x9b\x02l\x02\xc9\x01\xc1\x00P\x00\xfc\xff7\xffg\xfe"\xfe\xe8\xfd\x08\xfd"\xfc\xda\xfb\xaa\xfb@\xfb\xc4\xfa\xbd\xfa\x98\xfa\x10\xfa\xd8\xf9\x1d\xfaD\xfa\x05\xfa\n\xfa<\xfa\x8c\xfa\x86\xfa\xa0\xfa\x17\xfb6\xfbE\xfb\x8d\xfb\xf3\xfb>\xfc\x87\xfc\xc2\xfc \xfds\xfd\xa9\xfd\x1c\xfe\x7f\xfe\xe4\xfeA\xff\x9d\xff\x0b\x00\x80\x00\x08\x01\x89\x01\xff\x01y\x02\xea\x02R\x03\xd0\x03B\x04\x95\x04\xe5\x04Q\x05\xd7\x05*\x06\x19\x06Z\x06\xb7\x06\xba\x06\x9e\x06\x8d\x06\xb1\x06\xab\x06D\x06\xf4\x05\xd9\x05\x91\x05\x1a\x05\xc2\x04\x80\x04-\x04\xbb\x03B\x03\x1d\x03\xf1\x02\xac\x02\x86\x02{\x02y\x02M\x028\x02_\x02z\x02n\x02T\x02U\x02U\x02\x14\x02\xf3\x01\xd2\x01}\x01\x15\x01\x9e\x00A\x00\xc0\xff)\xff\x89\xfe\xd7\xfd7\xfd\x93\xfc\x0c\xfc\x92\xfb\xfa\xfa\xa5\xfa,\xfa\xd1\xf9\xa3\xf9z\xf9\x8a\xf9a\xf9~\xf9\xca\xf9\xf8\xf9U\xfa\x85\xfa\xda\xfaQ\xfb\xa5\xfb+\xfc\x9b\xfc\x0c\xfde\xfd\xae\xfd5\xfe\xa5\xfe\xfc\xfeH\xff\x88\xff\xd5\xff\x11\x00M\x00\x9b\x00\xcb\x00\xee\x00\x1c\x01M\x01u\x01\x80\x01\x88\x01\x87\x01\x88\x01\x94\x01\x9d\x01\x96\x01\x7f\x01[\x015\x01\x13\x01\xed\x00\xbe\x00\x89\x00X\x00(\x00\x03\x00\xdb\xff\xa7\xffv\xffQ\xff-\xff\x15\xff\x03\xff\xf0\xfe\xf8\xfe\xff\xfe\x06\xff\x0e\xff\x1b\xffE\xffv\xff\xae\xff\xd6\xff\xfe\xff-\x00f\x00\xa4\x00\xd3\x00\x03\x011\x01]\x01\x85\x01\xb2\x01\xdc\x01\xf5\x01\x0b\x02"\x02A\x02N\x02E\x02>\x028\x02.\x02*\x02\x1c\x02\t\x02\xe4\x01\xb8\x01\x9b\x01\x81\x01R\x01\x0c\x01\xd3\x00\xa0\x00l\x00@\x00\x06\x00\xc4\xffv\xff2\xff\n\xff\xdd\xfe\xa9\xfei\xfe7\xfe\x17\xfe\xeb\xfd\xc4\xfd\xa7\xfd\x8b\xfdy\xfdc\xfdV\xfdD\xfd\x1f\xfd\x00\xfd\xfd\xfc\n\xfd\x0f\xfd\xf8\xfc\xea\xfc\xf0\xfc\xed\xfc\xe8\xfc\xf6\xfc\x04\xfd\x08\xfd\x14\xfd(\xfdc\xfd\x86\xfd\x98\xfd\xd7\xfd\x1d\xfei\xfe\xb2\xfe\xf1\xfeW\xff\xb7\xff\xff\xffq\x00\xec\x00E\x01\x9c\x01\xfb\x01d\x02\xca\x02\x06\x03I\x03\x9f\x03\xd6\x03\xfd\x03%\x04L\x04m\x04\x84\x04\x8d\x04\x95\x04{\x04`\x04L\x040\x04\x18\x04\xf0\x03\xb9\x03{\x03.\x03\xe6\x02\xa0\x02Q\x02\x01\x02\xaa\x01Z\x01\xfe\x00\x9a\x00<\x00\xdf\xff\x80\xff\x1c\xff\xbf\xfeg\xfe\x11\xfe\xc8\xfd\x93\xfdd\xfd6\xfd\x13\xfd\xfd\xfc\xe9\xfc\xec\xfc\xfb\xfc\x0b\xfd\x1e\xfd?\xfdc\xfd\x88\xfd\xb5\xfd\xe3\xfd\x04\xfe(\xfeL\xfe\x80\xfe\xb3\xfe\xb1\xfe\xd5\xfe\xf2\xfe\xf5\xfe\x11\xff\x15\xff%\xff=\xff+\xff&\xff:\xff:\xff?\xffH\xffT\xffh\xff\x80\xff\x91\xff\xb6\xff\xde\xff\xf5\xff*\x00[\x00\x92\x00\xd5\x00\x11\x01W\x01\xa4\x01\xd7\x01\t\x02P\x02\x80\x02\xa9\x02\xd8\x02\xfd\x02$\x030\x03&\x03+\x03\t\x03\xfb\x02\xca\x02\x8f\x02b\x02.\x02\xe5\x01\x8c\x01K\x01\xeb\x00\xa1\x00C\x00\xd7\xff\xa1\xffb\xff\x01\xff\xdb\xfe\x97\xfef\xfe-\xfe\x10\xfe\xda\xfd\xcc\xfd\xc0\xfd\xa7\xfd\xa6\xfd\xb0\xfd\xb9\xfd\xc2\xfd\xd4\xfd\xb3\xfd\x05\xfe\xd8\xfd-\xfe+\xfeY\xfe\x85\xfep\xfe\xe7\xfe\xb9\xfe\x15\xff)\xff\x90\xffu\xff\x94\xff\x07\x00\xfd\xff@\x00\x8f\x00\xdc\x00\x00\x01\x0b\x01N\x01\x98\x01\x90\x01\xae\x01\xcb\x01\xf1\x01>\x02\x17\x025\x02\x82\x022\x02\x93\x02\xa3\x01<\x02\x03\x01\xf5\x00\x81\x00`\xff<\x05\x02\x059\x00\xf1\xfb\x98\x04\x9f\xfbB\xfck\x04\x06\xfa\x02\x02\x88\xfa\x8c\xff\xd8\xfdt\xf9*\xfe\xad\xfb\x8c\xfd8\xfdt\xfd;\xffZ\xfd\r\xfe\xe9\xff\xfd\xfd\xa9\xff\x18\xff\x83\x00\xfe\xfe\x82\x02\x85\xfec\x01|\x00x\xff\xb6\x02,\xfft\x01\x00\x00\x9b\x00\x93\x00\xff\x00\xf4\xffk\x01\xe1\xff\xe6\x00\t\x00,\x00\x19\x01_\xff(\x01?\x00\xda\x00H\x00\n\x01\x97\x00\'\x01\x07\xff;\x02=\x00\x1c\x00\xc1\x01"\x00\x9a\x01\xae\x00{\x01\x11\x00,\x01\x00\x00\xaf\x01\x0c\x00\x1e\x02\xc6\xff\x1c\x02\xa8\xff\x06\x01T\x00\xa6\xff\x92\x019\xfds\x04\x16\xfc\xe2\x01\xe9\xfe\x0e\xff\x7f\x00\xcc\xfd\xbb\xff\xea\xfd\xf2\xfe\xe4\xfd\x90\xfe\x86\xfe\x98\xfb\xf5\xffA\xfc:\xfd=\xff,\xfbr\xffi\xfc\xa2\xfe\xad\xfd\x8e\xfec\xfe@\xfe\xd6\xff\xa5\xfe\xed\xff\xdd\xffN\xff\x98\x010\xffO\x02~\xff\x04\x01\xfe\x01\xd4\xfe\xac\x032\xff\xe8\x02\xdd\xff\xf8\x01\xd6\x00\xa6\x01%\x00[\x02\x12\x015\xff\x06\x04a\xfe\x9d\x01.\x01\xaf\x00\xba\x00T\x00\x8d\x02\x8c\xff\xd5\x01\xed\xfe\xfe\x01\xb8\xff\xc1\xffZ\x03\xba\xfd\xe4\x03\xf2\xfd\xd5\xff\xcc\x02\xec\xfeK\x00\x85\x01\xfe\xfc\x83\x01\xd1\xff\x1d\xfd\x03\x04\xa5\xfb\x19\x01\x1a\x00\xa1\xfb\xe2\x02\xfd\xfcO\xfd\xe6\x02`\xfbs\x00\xc1\x00\x8e\xfb\x15\x01T\x00$\xfc5\x02\xdb\xfc,\xffT\x02\xab\xfb\xf6\x02.\xfe\xb9\xff\x82\xffU\x00\x08\xff\xca\x00\xf5\xff\xd5\xff\x15\xff\xf3\x01\xdf\xfe\x10\x03\xd8\xff\x90\xfd\xc0\x04V\xfd"\x01M\x03\xb1\xfe\xad\x00\xb0\x03b\xfcO\x04\xf8\xffD\x00\x93\x010\x01,\xff\xc9\x00t\x02\xf0\xfc\xb2\x03\x89\xff2\x003\x01\xe8\xff^\xfe\xd8\x03\xe0\xfc\xde\x01,\x01\x9c\xfc\xcb\x01\xfd\x02[\xfa\xc1\x04\xf1\xfe\x8c\xfcW\x068\xfb\xe6\xfd\xbd\x03(\xff\xc6\xfd:\x06n\xf9U\x01\xb3\x00\xf3\xfd\xbf\xffO\x03\x0e\xfd\x0b\x01\xfe\x01#\xf9\xfe\x04Q\xf9\x9a\x06.\xfc\xc2\x01P\xfdr\x02\x16\xfdC\xff\xf9\x01\xe0\xf8\xf8\x07\xe5\xfa\xa1\x01\xb3\xfe\xbe\x03/\xf9\x17\x07\xe8\xf9h\x00\x88\x06\x19\xf4\x8e\t3\xfe\xa6\xfeS\x00\x96\x03\xb4\xf8\x9d\x05\xb4\x00\xa3\xfa\xa0\n\xa7\xf78\x04\xc7\x01|\xfcx\x04\xbf\xfeE\x01 \xfc\x92\x06\x04\xfcF\x04\xc8\x01\xd9\xfbn\x06\xda\xfa^\x02r\xff\x1d\x03x\xfd\x8b\x03t\xfe\xd6\xffO\x02=\xfaI\x04\xa2\x00\x82\xf7\x88\x07\x12\xfb\x18\x03>\xfe\xd6\xfcF\x03\xff\xf8\x7f\x08\xe6\xf6\xa7\x05\x0f\xfbH\x04(\xfb&\xffw\x07)\xf5n\x08\'\xfaX\x04\xc9\xf9t\x03R\x01\xa9\xfc\x9e\x03\xe1\xfd\x07\x02\x18\xfb\x06\x04c\x00\x88\xfdW\xff\xd3\x03v\xfb\xff\x03\xf4\xfd#\xfd5\x04\xb4\xf9\xc2\x03\xd3\xfd?\xff\x9f\x00\xbb\x01\x8b\xfd\x1d\xfd\x8e\x03K\xfb\xe9\x02\xf3\xfeG\x00\xf3\xfc\x91\x06[\xfa\x87\xfe\x7f\x05\xf7\xf7\x91\x04k\xfe\xec\x01\x10\x00\xe4\xfds\x02\xb8\x02\xe1\xf7L\x07\x98\xfd\xc0\x01\x98\x01l\xfdQ\x04A\xfb\xbf\x05\x9a\xfc\xf7\x03\xae\xf9\x91\x07\xa9\xfd\x8b\xffn\x00P\xfeX\x00\x94\xfeH\x05\xd5\xfc\xa9\x00\x19\xfc\x8a\x03\xcb\xfb8\x00E\x03o\xfa@\x03\x9e\xffM\xfdr\x04\xa4\xf7J\x02\x88\x03g\xfa\\\x02\x8c\x00T\xfc-\x01\xab\x01]\xfd\xe2\x00(\xffX\xfcr\x06\x9d\xfc!\xfe\x7f\x08"\xf8\x1d\x05,\x00\xd6\xfe\xcd\xff\xd3\x01\x17\x01\x06\xff\x15\x07\xcf\xf7c\x05j\x02D\xf9b\x06d\xfe\xa6\xfeC\x05\xc6\xf8\xb5\x03\xa1\x04\xac\xf7\xa7\x05\xe9\xfd\xbb\xfd\xa8\x00\xaa\x00\x81\xfed\xff\xb8\x00\x9f\xfc;\x06D\xf8\xb5\x05\xc3\xfe\xa1\xfd!\x03\xc0\xfe\xcc\xff\x00\x00\xe3\x00\xca\xff\xfd\x00\x80\xfee\x02\x15\x00\xd3\xfd\x07\x00\x1c\x05a\xf8\xb4\x04l\xff\xd1\xfaB\x08\x15\xf9U\x02\xa9\x00n\xfc\xf3\x04\xa9\xfa\x0e\x04\xca\xfa|\x03\x84\xfeL\xfdz\x06+\xf8\xe8\t\x0f\xf6\x0b\x01\xaf\x05X\xfa\x87\x03\x08\x03\x18\xf9\xa2\x05\xba\x01\xc3\xf8\xb2\x06\xe2\x00\xf4\xfda\x02\x06\xff\x81\xfd\x1a\x05f\xfdu\x038\xfd"\x01\xc5\xffu\xff\xe0\x01\x89\xfb\x1a\x04B\x00\xe9\xfd\x06\x01t\x05J\xf9\xd6\x00\xb9\x01\xb1\x00z\xfe\x07\x00\x1d\x07\x98\xf5\x1b\t\x85\xf9\xbb\x00\x13\x04\x18\xfb\xb9\xff!\x03l\x01Q\xf8m\nl\xf7\x08\x00r\x03\xcd\xfd\r\xff\x97\x02&\xff\x15\xfd\x13\x03\xec\xfe\x03\xff\xc4\xfev\x030\xfd\xfa\xfa\xa9\x06b\xff=\x01@\xff\x17\xf9\xe4\x07\xcc\xfb\xc5\x00\xc2\x02\xa4\xfdM\xfd\x1c\x04\x03\xfe\x14\x00T\x03\xd6\xf7\x0b\x07\x83\x01K\xf7\xa0\t\xf1\xf9\xbe\x00\xd0\x04+\xfa\x8f\x03=\xfb\xd6\x053\xf9\x06\x07\xd7\xfbr\x00\x01\x042\xf7\n\x07\xb9\xfbf\x01\xa5\x05\xd2\xf8\xcd\x02\x8f\x01\xc8\xf8\xe4\x07\xd7\xfb^\xff\xe4\x03\xa5\xfci\x00\x93\xff\xb9\xff\x01\xffi\x01\xfe\x00\xb3\xfdV\x02F\xfc1\x03\x97\x00\xfc\xf9%\x04\x82\xff\xf6\xfd\x15\x00\x8f\x05\x9f\xffL\xf6\xe1\x080\xfb\xd1\xfd\xb8\x07\xb5\xf8\xec\x06\x9f\xfb\xb9\xfd\xf6\x03Y\x00w\xfc\x95\x03-\xfe\x99\xfb\xcc\n\x9e\xf4\xbd\x04\xb7\x00\x04\xfc\xdd\x07Q\xfa \xfc\xfe\t(\xfa{\xfeX\xff\x87\x06\x8f\xfc\x19\xfc\xb8\x0f\xab\xef!\x06U\xfe\xed\x00Y\x04c\xf9#\x08\x0c\xfd\xc4\xfeR\xfe\xe2\x04\xe5\xf9E\x07u\xfa\x8b\x01\xfd\x025\xf9\xcc\nh\xf1\xdf\n\x17\x00\x02\xf7~\t\'\xf9\x1c\x02\xd9\x02\xa2\xfdW\xfe\xad\x00\xcf\x02a\xfa}\x06c\xf8\xa6\x00\xc9\n\xc9\xf6\x8e\x01\xa4\xff\x97\x00T\xfd(\x00\x17\x04\x19\xfc*\x03\x1e\xfc\xdc\x04\xc3\xfc\x9b\xfb\xf8\x08\xc7\xfeo\xf6\x16\x0b\x01\xf9Q\xfe\xf9\x0b8\xf5\xf2\x03g\x00@\xff,\xfb\x97\x0b\xa5\xf3\xc5\x03\xf8\x05w\xf7t\x056\x00E\x04`\xf4\x98\x06\xda\xfeZ\xfbA\nf\xf8[\x03\xde\xffe\x00 \x03\xf5\xf2\x1a\x0e\x96\xff\xb5\xf5\x8c\n\x00\x02t\xf4\x14\x0b\x9a\xfb!\xfce\x07\xc7\xfcu\xfdZ\x07@\xfc\x02\xfb\x8c\t\r\xf5\x9a\x08\x1e\xfb\x9b\x01\x8a\x06\x16\xf7k\x00p\x08\xde\xf6\x8e\xfe%\x0b7\xf4L\x03\xf8\x06\x9c\xf7\x17\x01\xf2\x02\x82\xfb\xfc\x01G\xff\'\xfe\x97\x02\x1b\xfcB\x07D\xfef\xf7h\x07\xf2\xfd\xe0\xf6/\x05b\x08B\xf6\xb5\x07\x95\xfa4\xff\'\x04f\xfc\xe2\xfc\xee\x06\xdf\x03\x8a\xf4\xcd\x0c\x11\xf6?\t\x14\xfd@\xf6e\x14\x92\xed\x84\x06\xda\x03\xb7\xf8\xbb\x03V\x00&\x02\xf3\xf3\x10\x10\xc6\xf3z\x02V\x08n\xf4m\n\x82\xfb:\xf7\x88\t\xcb\xf9}\x04U\x032\xf4\xc7\x0c\xc1\xf7\xdd\xff4\x03u\xff\x85\xfeo\x00\x86\x00\xc9\x02[\xfc\x9d\x00m\x02.\xff\x89\xfb\xc0\x08\x00\xf8\xe0\x00l\x04\x90\xfa1\x06I\xfc\x81\x06\xf5\xef*\r\xbd\xfc\xca\xfc\xba\x00\x0b\x01\xc7\xff+\x01\xb6\x03+\xf1R\x0f\xb6\xf5\xc6\x00*\n\xb8\xf2\xfb\x0b\xe7\xfb"\xf8`\x0eY\xf1\x9d\x060\xff\xc1\x008\x02?\xf8T\x08\xb9\xf4\xe1\x0f\x0b\xf5l\xfb;\n7\xfa\x94\x012\xfc_\ns\xf4#\xfc\\\x13\xdf\xf2\xe1\x01x\x02o\xfbw\x00\xca\x00^\x01w\x02\xba\x00T\xfa~\x06\x84\xf7\xa2\x02+\n\x07\xf3\x81\xff\xb9\x10\xf1\xee\r\x05\xfe\x07w\xf4\x83\x07\x81\xffa\xfb\x00\x03\xf9\x03\xd8\xf6\xd9\x0c\x1e\xf9\xd5\xfc\xb0\x03\x97\x00\x19\xfd\xa3\xff=\x05\x0e\xfaS\x06\n\xf9@\x03\x8b\xfeu\x01\x80\xfd\xde\xffM\x05C\xf9L\x05\xa6\xfc~\xff\x8d\x01\xc4\xfb\xab\x057\x00a\xf9\xd9\xfeB\x06\xa5\x02\xf7\xfam\xfd\xd1\x01\\\x01\x17\x01\xef\xf96\x06\x17\xfe>\xfb)\x07=\xfdl\x03\x11\xfe \xfb\xec\xfd\xce\x0c\x86\xf9C\xf6\xa7\x0f\x11\xffG\xf1x\r\xc8\xfeT\xf8k\x06\x0b\xfb\xd7\x01T\x05\x8e\xf7\xc0\x06x\x06\xf7\xea\x10\x10Z\x00\xad\xf3,\n[\xfeo\xfeT\x02V\xff\x9c\xf9\x91\x10!\xf5z\xf6\xee\x13\xef\xf5M\xfc\xb4\x06\xf6\x01=\xf7C\x0bO\xf5\xa3\x08\\\xfd|\xfa\xe3\x0bn\xf3t\r\x9d\xf0j\x08L\x02\xd1\xf9\xa0\x022\x02\xd6\xfa6\x05L\xff]\xfam\x06\xc3\xf6\xb0\x08}\xfe\xe3\xfep\xfb{\x11W\xeb\xa7\x00\xed\x0b5\xf7\x16\x08\xb9\xf8y\x08\xfd\xf9\x88\xfe\x1f\x07\xb0\xf9\xbc\x01\xde\x06\x17\xfci\xfa\xeb\xff\xfc\x06\xfa\xfa\x1e\x01\x17\x07\xe6\xf5\xf1\x00\xa5\x04z\xf9\'\xfe\xdf\x01\xb9\x04\x8e\xf8\n\x07>\x06\xe5\xf6\x89\x04\xa9\xf2\xbf\x08\xd5\x08\xff\xef]\x11a\x00\x15\xf6\n\x01\x17\x01\xc0\x04]\xf5\xf8\x05\xf5\x04\xd1\xfa\x9f\x02\x87\xff/\xfee\x01\xc2\xfa)\x03O\x05\xdb\xf6E\x01w\n\xc9\xf4n\x03\x91\n"\xeb7\x07\xa0\x0c\x99\xe9l\x07A\x14#\xeb\xd5\x04\xc2\x05\xa3\xf1\x05\tP\x08\xaa\xf1j\x03|\x06c\xf8F\x00\xf7\tD\xf4\xc2\x01\xe5\x08\x1b\xf5\xb4\x04S\x06\xb3\xf7 \xfe,\x0c8\xf82\xfco\x04\x80\xfcv\x03\xfb\x04v\xf4u\x03\xad\x0e2\xe9\xb5\x02\xbb\x11!\xf2\x90\x05O\xff\x00\xfd|\x00\x02\x06,\xf2\xe0\x0bb\x00|\xf1\xb2\x16\xe2\xed\xf5\x03\x0b\x02\xd0\xfdg\xfb\xfc\x0b\n\xfaK\xf8M\x0f\xe7\xec\x1a\x12\xa5\xf4M\xff\xbb\x02\xe0\xfb\x08\x0b\xae\xf4M\x108\xec\xa4\x08\xde\x00\r\xf3<\x17`\xee\xf0\x03=\x07\xd4\xf87\xfe/\x07`\xfa\xe1\xff\xa7\x06\xc5\xef\xd8\x11\xbe\xfc\x1d\xef\xcf\x16\xe2\xf1\x08\xfdQ\x15\x93\xe5\xa7\r}\xfe\x99\x00r\xf8\x19\x03\xd9\t2\xfbX\xfd\xb5\xfa\x8e\x16\xe7\xde{\x10o\r{\xed\t\x02\xa8\x0c\xdf\xf4\xc6\x02-\x03\x9d\xf31\x15.\xedG\x04\xc3\rv\xeb\x85\n\xac\x03\xee\xf3]\r\x99\xf7\xcb\x01(\xfe\x99\xffH\x03\xcd\x04t\xf2\xed\n4\xfd\x88\xfaw\x0c\xda\xe9U\x159\xf6_\nN\xf4\x9d\x01(\x05\xdd\xf7A\x04\x9a\xfa3\x0eH\xf2q\x00T\x0c\x17\xf6[\xf6P\x15\xbc\xf8\x83\xef=\x16r\xef\x17\x04r\x0b\x02\xf7\xd5\x01{\xf8d\x11n\xee\xc7\x04\x1c\x04\xca\xfb\x80\x01\xa5\xfe\x0b\t\xfe\xf7\xbb\x03\x90\xff"\xf6\x95\x07t\xfd-\x046\x01\x9d\xf9\x85\x0b\r\xf6u\x02\xab\xf9\xf9\x0bP\xfc\x8e\xf6\xe6\x15\xba\xef]\xfd\xe7\x10\x8d\xed\xdd\x01Q\x0b\xf3\xfe\x97\xeeW\x19u\xf2\xf7\xf9\xf9\x11\x06\xe7g\x12\x11\xff1\xf9y\x04\xdb\x06\x12\xf0z\x10Q\xf5\xf8\xfd\xee\n\xee\xf4\xcb\x04\xd5\x05&\xfc\x19\xfbM\x07\xbe\xf60\x0b\xd8\xf3\x1f\x02\xf6\x0b\xaf\xf2t\x08\xcf\x02\xd7\xf6\x16\xfc\xf9\r\x0e\xec\x08\x02\x1c\x19\xdb\xe6C\x0bD\x00\xa9\xf8\x8e\x04f\x03\xc9\xf36\x0b\x17\x04\xa3\xf6w\n\xb8\xf6\x8b\x0b\'\xf3\xda\x02,\x10\x9e\xe8\x80\x0cl\xff\xb1\xf5Z\r\r\xf6.\n\xc0\xf1\x02\x02\x90\x06\x12\xfc\xc0\x02\t\xff\x12\x043\xfav\xf9N\x06\xdd\xff\x9b\xfeY\x06\x1c\xf4\xda\x08:\xfc\xac\x00T\x04\x7f\xf7\xa3\x06_\xffQ\xfc\xe8\x08\xb5\xf7\x1b\x03\x0b\x02{\xfb\xdc\x07p\xff\x83\xf44\t\xfe\xfb4\x01I\x05\xbc\xfc\x9f\x07\xfc\xecS\x0bs\x02\x89\xfa\xcd\xf7_\x12-\xef\xf8\x0c@\x03\xbb\xe7\xf3\x18n\xef\x9f\x017\x03F\n\xf9\xf1\xe7\x05\xf9\x04\xed\xf2F\t\x98\xf8\xde\x04\xa4\x01\x06\xfa\xeb\x02\x80\xff2\x00u\x05I\xf8\x95\xfbU\x07\xd6\xfc\xa9\xf9\xbc\x12\xb2\xf2\xd6\xf5\x0e\x0fT\x02x\xfd\xfd\xf8\x1a\x08\xaa\xf6\x0e\x03\xb9\x01\xfc\xfdG\x0f\xa8\xf1\x0f\x02j\x01\x0f\xfe\xd7\xfd\xb2\x04\x99\x02\x95\xf2\x08\x11G\xfa\xa7\xfdQ\x01\x7f\xfe\xb3\x04*\xf8<\x06\xa8\xfb;\x07d\xf8\x97\x06P\xfd\x84\xfc\x8e\x06I\xf5\xa8\x0c^\xf4\xc1\x07\x07\xfe\xfc\x00\xb5\xfe\x87\xff\xa8\xfe\xed\xfb\xee\x0e;\xf1V\x05J\x00\xb5\xfa\x08\n!\xf7\x1d\xfd\xd1\x0e\xf8\xf5\xb3\xfe\xad\xfc\xe6\x06\xa3\xfe\xa7\xfb1\n9\xf5\\\x03R\x03\x1e\xf9\xa0\x07\xf1\xfeP\xf4\xdc\x07\xf0\x06}\xfc\xf2\xf9\xac\x08v\xf9\xed\xfe\xca\x02|\xfd\x07\t\xe2\xf1\x84\x05\x11\ns\xf3>\t\xda\xf4W\x05\'\x01s\xf7\xe5\x0e\x12\xf9\x10\xfe!\xffn\n\xbb\xf1\x95\x08|\x01Q\xf6Z\t\x03\xf73\n]\x02\x97\xf0\x1d\x0e\xaa\x02,\xf15\x0fT\xf5\x89\x01\xf5\x06\x11\xf77\x03Q\x0bP\xf3\xb0\x02E\x00\xdd\x01;\xfes\xfaY\x06\xcb\x02\x9f\xfaT\xfa\x85\x10\x06\xed\x12\n\xca\xfd\xb0\xfb\xc0\t\xf0\xf5+\x017\x03o\xfa\x80\x07\xff\xf9\r\x02\xa0\t\xdc\xeaD\x11w\xf3/\x07V\x03\x19\x01\x80\xf6\x02\x08\xd2\x06\xb2\xe9\x90\x1b\x92\xf3\xd2\xfb\xcd\x10\xbc\xee\x93\xfe^\x11R\xf4\xa7\xfe\xdd\n\xe6\xf2r\xff.\r\x7f\xee\xe7\x04\x1c\x01d\xf5\xb0\n\x08\x014\xff\xf4\xf7\xb9\r\xbb\xf5P\xfb\xab\r\x81\xfd\xc5\xfc\xcd\x06\xe1\xfc(\xff\xbc\x08A\xef\xaf\tW\x07\x0e\xf3x\x06\x12\x01\x99\xfeN\xfb\r\x04\xab\xfb!\xfd\x81\x0b?\xf8b\xfd%\x08\xff\xfd\xb2\xf5\xb1\t0\xfd\x81\xf0X\x11\xac\xfc\x9a\xf99\x10~\xf2\xe5\xfc\xa1\x07^\xfe,\xfe\xa4\x06\x80\x00\xd1\xf4\x07\rR\xfc\xd1\xfb\\\x04N\xfep\xfe\xce\x04\x1f\xff\x17\xfc\xf0\x020\x03\x0f\xfe3\xfc\x86\x03\x10\xfc\xc9\x03v\xfd\xda\x01\x16\x04e\xf8z\t\x07\xf8\x8c\x00[\x01\xc0\xfa\xd1\x03\x07\x03\xea\x00\xe4\x02\x07\x01\xb7\xf2\xd2\xfe\x81\x08\xa9\xff\xa3\xf8\x05\nz\xfe\xaa\xf6s\x07\xd3\x01\xb6\xf5\xd5\x05\x12\xfd\x9e\xf4\x16\x10\x13\xff}\xfd\xe5\x04%\xf7F\x00\x9a\xff\x02\x00%\x06|\xfaI\x01\x92\x06/\xf9\xdd\x056\xff\xf2\xf3\xc0\n\x9c\xfch\xfe\x11\t\xe8\xfd\xc9\xf8\xa2\x00^\x06\xa0\xf7\x18\x05\xf9\x01R\xf78\x02\x1f\x03\xdd\x01\r\xfb\xc8\x03\x83\xfc\xd2\xfb\x13\t\x08\xf9\x8b\x02T\x00\x80\xfa,\x05\xe0\xff\xd7\x01\xee\xff\xe8\xfe\xf4\xfaY\x00.\x02J\x017\x00\xe4\x00A\x00,\xfc\x12\xfe\x9e\x03o\xfd\xff\xfe\x98\xff\x12\x05\xd2\x04\xc4\xf9\x12\x00w\xfd\xdd\x037\xfc\xc7\xfd\xe3\x04^\x07\x13\xfa\xdd\xfe?\x04\x0c\xfb)\x02\xa1\x00?\xfc\x87\xfc\xd7\x03\xd6\xfd\x1d\x05\xeb\xfbs\xfe\x10\x00\xb2\xf8n\x02n\xfe\xa8\x01\x16\xfe\xf4\x00\x98\x02~\xfe\xe3\xfc4\x03\xb0\x00?\xfe\xb8\x02A\x00T\x00i\x04\xd7\x00\x17\xfd\x0c\x00@\x03\x00\xfd\xb7\x00\xc4\x02#\xfcQ\xfe\x16\xfd)\x01Y\xfeW\x00\x14\xff8\xff\xf7\xfcN\x02\xe2\x02e\xfb2\x04V\xff\xca\x02:\x03\x16\x03\x93\x02l\xfd\xb6\xff\xaf\x01n\x03\xf0\x02\xd1\xffx\xfe\xe7\xfc \x00\x94\x02\xe6\xfa\xfa\xfe\xaf\xfd(\xfd\x9b\xff\x03\xfe\x91\xfd\n\xfd8\x00\xe5\x00b\xff-\x02\x96\xfe\xce\xf9]\x04\xa1\x02\x84\x00\x04\x06`\x02\xf7\xfeF\x01\x85\x00\x06\x00\x10\x03.\x03;\xfe!\x00\x8d\x020\x00\xdf\xfe\xde\xfd\xcb\xff\x08\xff"\xfc\x19\x01J\x01Q\xfd\x11\x00l\xffk\xfd!\xff\xf6\x00\xec\xfe\xb6\xff\xda\x04\xf0\xff|\xfe*\x02\xf2\x00\xcb\xff\x17\x03b\x01\xdc\x01\xb8\x00\xed\xff\xce\x004\x00\xc0\x01\xd1\x005\x01\xab\xfe\xb0\x00\xae\xff\x98\xfe\x88\x00\xa7\xfeJ\xff\xd5\x01\x99\x00\x86\x00\x97\x00\r\xfcF\xfd\xfe\xff\xb4\x00\xff\x01]\x02|\xff\xcd\xfe\x87\x00\xfb\xff\xa3\xfeW\xfe\xcf\xff\xc8\x01\xd3\x01\xbc\x02\xe5\xff:\xfe8\xfdc\xfc.\x00\xfc\xff\x02\x00\x8d\x00\x1c\xff\xd9\xff\x1e\x00r\xfd\x8b\xfc\xf1\xfc\xf5\xfc8\x00\x1c\x02\xfe\x00\xfc\xfd\xf4\xfd^\xfd\xf2\xfb\xc3\xfd\x9c\xfe+\xfe\x00\xffW\xff\xcc\xff\x8b\xfd\x94\xfc\x93\xfb\xc6\xfb\xf1\xfco\xfdM\x01\xef\xff\xfa\xfc\xe3\xfe\xee\xfd$\xfdO\xfe\xf0\xfc\xec\xfc\x13\xfd1\xffU\xff\xf5\xfe\x0c\xfe\x7f\xfd\x14\xfd@\xfc\xf6\xfa\xfe\xfb+\xffB\x02{\x07\x1f\x0b\xe1\nB\x08n\n\xdc\x0c\x03\x11c\x12\x19\x14\xd9\x17\x9d\x18\xef\x19f\x19\x9c\x16\xc7\x11\x9e\r\x11\x0b\x95\x0c\xb3\r\xb7\t\xc4\x049\x01\x99\xfb\xf8\xf6\xb5\xf4X\xf1w\xeeN\xeb.\xed\x86\xed_\xee\x11\xee\x93\xea%\xeb\xe0\xea\xec\xed\x16\xf2Z\xf7\xed\xfaA\xfd*\xff\xc6\x025\x06\xa9\x05\xf3\x07\xe4\x06\xa0\tb\x0c\xa7\x0c\x90\x0c\x16\x07\x07\x05m\x01\xf8\xfe+\xfek\xfax\xf6\x0f\xf4/\xf3\xd6\xf1\x0f\xf1\xbc\xed\xa0\xeaU\xea\x07\xea|\xed\xdd\xefM\xf0\x84\xf0x\xf1;\xf3\x7f\xf5\xde\xf8\xe1\xf7\xa3\xf7\x96\xfbd\xfe\x03\x00\xda\x02q\x01F\xfeK\x00`\x00\x06\x02M\x03\xbd\xff:\xfe\x0f\xff\x05\x00\x11\xff\xbd\xfbS\xf9)\xf8\xcd\xfaG\xfdR\xfc\x9d\xfb\xfd\xf8\r\xf9\xbb\xffc\x04\xe2\x048\x03b\x03\xf1\xff\xa3\x016\nY\x15\xd8#\xda(\xd9*\xc8+\xe2,\xad.I+\xac)Q-24\xa68~3\xd2(\xf7\x1b\x8d\x0f\xb3\x08(\x03(\xfe4\xf8E\xf3\xe9\xf1\xe1\xf03\xec\xf2\xe1J\xd7G\xd4\xab\xd6x\xddb\xe5~\xe9\xc9\xeb\xb9\xed]\xf0t\xf3\xd4\xf7\xbd\xf8\x9d\xfb\x9e\x02\xc1\n\xb3\x12\x11\x15\xf0\x11_\x0cc\x07F\x05\xe1\x04\x9e\x03\xbf\x00\x1d\xfe\xb8\xfa\xc2\xf7\xcc\xf4%\xed\x10\xe6\xef\xdf\xee\xdd\xa2\xe0\xe3\xe3\x18\xe6W\xe7\xa8\xe7"\xe9]\xec\x8b\xf0\r\xf5\xf3\xf7\x16\xfd\x82\x04\x0b\rR\x14\x0e\x17f\x16\xc7\x15\x15\x16\x99\x17\xc9\x19C\x19h\x15\xbc\x12\x91\x0f\xed\x0b0\x07\x18\x01\x19\xfb\xed\xf6\x9d\xf6\xd5\xf5R\xf5R\xf3\x0e\xf1+\xef\x9c\xf0\xf4\xf3\x10\xf5}\xf6\xd9\xf8\xf5\xfa\xb7\xfez\x02\x1c\x01\xe0\x00\x99\x00\xa4\x00\x9f\x04\x96\x05\x18\x03S\x01\xab\xfd\xa7\xfc\x86\xfe\xa9\xfa\xfb\xf6\xb1\xf3\xaa\xf1 \xf2M\xf3\xb1\xf1\xff\xee\x06\xeeN\xeb\xdf\xed;\xf2\xea\xf3~\xf6p\xf4U\xf7\xc1\xfb\xde\xff\x19\x03\xae\xff#\x04+\t\xc3\x16\x15-\xb76\xdd8\x1f1\xa1+\xa71p7\x8c6\xd0344\xd73p/\xdb$\xeb\x16\xc9\x04\x9c\xf4\xd0\xed\n\xef\xcc\xf1\x01\xee\xb2\xe5\x8f\xde\xde\xdb\xeb\xd9\xe6\xd7h\xd7Q\xdaC\xe1#\xed"\xfaS\x02\xcf\x03\xc8\x00Y\x00\x04\x04\x11\x0c*\x14\x8a\x18\\\x1a\x0b\x1b\x94\x18y\x13&\x0c^\x00\xec\xf7\xe5\xf4\xb6\xf3"\xf4\xc9\xf0@\xe9\x7f\xdf\xe7\xd6\xde\xd4.\xd3\x80\xd3\xac\xd6>\xdc\xdd\xe4\x1a\xebb\xef#\xf1m\xf1r\xf5\x07\xfd0\x08\x7f\x12,\x18\x06\x1ai\x1a\xd1\x1af\x1a\x8e\x18@\x14\xf9\x11\x00\x13&\x16:\x14\xae\x0c\xe1\x02q\xfa\xb3\xf6s\xf5\x84\xf65\xf5\xe1\xf4M\xf4\x0f\xf4\x0e\xf8\x1c\xf7\xce\xf5\xd6\xf7\xb8\xf9\xb8\x02\xaa\tl\x0c\r\rU\n\xc5\t\xc9\tP\n\x87\x08\x98\x05\xc6\x03\x0c\x02\x17\x01\xe2\xfdj\xf6\xf5\xee.\xeaD\xe7[\xe7h\xe6\xd1\xe4\xa9\xe4\x15\xe4\xee\xe6\x9f\xe81\xe6Q\xe6\x13\xe7\x7f\xec\xb5\xf5\x8b\xfa;\xfc\xe9\xffA\x03\x9f\x06\x94\x0bE\x08\xc7\x05\x18\n;\x0f\xb7\x16\x1c\x199\x19\xe7\x1e\xdb+\x025\x193r*\xeb"\xd6!^#\xbe\'\xbd,R,\xc9"\xe6\x15c\r\xa8\x07\x9c\xfe\xa3\xf3\xb0\xef\xeb\xf12\xf6\xe4\xf7\xc6\xf6\xff\xf1\xb7\xe9\x19\xe3\xcc\xe4\x97\xee\\\xf8\x1b\xfe)\x03H\x07\xb9\x08\x05\x07\xd4\x03\xdc\x01\xb7\x01\xc8\x04\x07\nw\x0f\x8c\x10\xb5\nS\xffM\xf4\x9b\xef\xe1\xeb~\xea\xcf\xea\x1d\xeb\x06\xed\x87\xea\xb7\xe7\xb3\xe3\xb7\xde\xa2\xddN\xe0\xc5\xe8\xce\xf2\xc6\xfaS\xfe\x0f\x00`\x00&\x00l\x00\x95\x02.\x06\x00\x0b\x00\x11\xcc\x12O\x13j\x0e\x92\x06\xea\x01\xdb\xffK\x01\xbf\x02\xd3\x04\x8f\x04s\x02c\x00\x82\xfc\xf0\xfa\xfc\xf95\xfa\x13\xfd\x86\x00y\x05.\x08{\x07\x14\x06\xcd\x04V\x05\x89\x08f\n\x05\rA\x0e\x83\x0e.\x0e\x1d\x0c(\x08\x8d\x03\x10\xff)\xfcS\xfc\x89\xfb\x7f\xf8Y\xf3~\xee\xeb\xea\x8d\xe8X\xe7\xf1\xe4n\xe4W\xe7\x7f\xe9\x06\xed\x05\xef\xf1\xed~\xefF\xf0\xe2\xf2T\xf8\xa7\xfa\xf2\xfd7\x01|\x03T\x05\xb6\x03\x91\x01\xbf\xfe\xc4\xfe\xd0\xfek\x02/\x04H\x03\x84\x02\x00\xfe\xea\xfaV\xf5f\xf55\xffL\x0b:\x19\x86&\xd30\x003\x86+\xa1\x1e-\x1a\xfd#81\xb6:s:t2\xf6%;\x17\xe2\x08.\xfd\x88\xf3U\xed\xa6\xed\x1c\xf2)\xfa\xc9\xf7\x14\xec\x07\xe0J\xd9\xde\xda\xb6\xe1\x05\xec=\xf7\x11\x01\xf0\x07\x10\n\xb7\tE\x07\x05\x02\xb6\x00\xf6\x04\x87\x0c\xf5\x13q\x15\xeb\x10\xee\x07g\xfc\xd8\xf3\xe1\xedL\xe7M\xe5\xbe\xe6\xa1\xe7m\xea)\xe9\xe7\xe3U\xdd\xed\xd8\xe1\xda\xa5\xe1m\xea\xf7\xf1\x7f\xf9\x18\xff\xd4\x02I\x04=\x04%\x03\x11\x05 \t\xa6\x0e\x00\x14\xc0\x14\x0c\x12\xbf\r\x02\n\x0b\x04\xda\xfe:\xfc\x1e\xfft\x05\xfb\x07i\x06\r\x01\xf5\xfcn\xfb\xe9\xfa\xfb\xfc<\x01*\x05\xf3\x07r\n\xec\t\xf9\x065\x03{\x00\xf5\x02\x8e\x06g\n\xf7\n\xf6\t\xcb\x08L\x05\xde\x02\x0b\xffB\xfc\xbb\xfc\xa4\xfc\x9f\xfd\x1f\xfd\xcf\xf8\xcc\xf2\xf8\xee\x9f\xec{\xeb\x8f\xecp\xec\xbe\xec{\xee\x11\xf0\xee\xf0\x8f\xf0r\xf0\x15\xf1\xd4\xf3\xfd\xf7\x98\xfb\x0b\xfd\xea\xfc\x98\xfc!\xfd\xe6\xfdi\xfd}\xfb\xa0\xf9>\xf8\x9b\xf9-\xf9\xd4\xf6\xfa\xf3\x00\xf4\xb8\xf9\xc3\xfb\x19\xfd\xf3\xfb\xcf\xf9k\xfeu\x03Q\x0c4\x1c\xcb+\x954\x817\xd6638\x179\xc16\xe16\x819\x819\x8c5\xb9.\xe4$\xfb\x17P\x06\xad\xf8\xdf\xf2O\xefZ\xed\x95\xea\n\xe9)\xe7\x96\xe2\x98\xdd\xef\xdcy\xdf\x00\xe4\xd2\xeby\xf5\xbd\xfe\xad\x04a\x05\xfc\x04\xc1\x04\x06\x05=\x07H\n\xef\x0e\xb9\x10\xb3\x0e\xde\x08\x94\x01.\xfbo\xf3\xaa\xedW\xeb\xae\xebR\xeb\x03\xe9@\xe6\xba\xe2\xfd\xde\xf9\xdb\x9f\xdc\x0b\xe1\xe1\xe5\xd5\xeb\xca\xf0~\xf4\\\xf7t\xf8\xbc\xf9\xc3\xfc\xc4\x00f\x06\xaa\x0b#\x0e\xfb\x0f)\r\xd5\tz\tg\t\x97\nw\n\xd2\x07\xfa\x06\xb4\x07\xbb\x07\xf8\x07\x8f\x05i\x02\x02\x03\x0b\x05l\x07s\t\xf4\x07j\x07\x14\x08o\x08\x86\x08\xde\x06T\x05\x81\x05\xad\x05\xec\x06e\t\x93\tC\x06\xff\x03x\x02\x1e\x02\x03\x014\xfe/\xfd\xf2\xfd\xdd\xfc\xb5\xf9\xed\xf5s\xf0\\\xec\xd3\xeaH\xe9M\xe9\xf3\xe9%\xe8\x86\xe9\xd9\xe9\x9f\xe8Y\xe9\xfa\xe7\xb3\xe9\x1e\xef\xa3\xf2\xb9\xf6\xbf\xf9\xad\xf9\x14\xfa\xe8\xf8.\xfa\x87\xfc,\xfe\x0e\x00\xfc\x01R\x01\x08\x01j\x00\x18\xfd:\xfeu\xff\x8b\x01\xfa\x05\xba\x06\x02\x06\xa4\x06\x15\x07]\tS\x0bK\x0c\xe0\x0f+\x14\x80\x17]\x1b\x10!\x8f(\xda0\x8401*o(\xcc*\xf2,I+\xdf\'l&Z"f\x1a\x90\x12w\x0by\x04S\xfb\xea\xf52\xf6\xbc\xf6\xb8\xf3p\xecG\xe82\xe7\xd5\xe5\xba\xe5u\xe7\xe3\xe9\xf2\xec\x9b\xee\xbe\xf1\xca\xf5\xec\xf5t\xf4\x06\xf5\xfe\xf7\xff\xfbI\xfe\x9d\xfe\xd0\xfd\t\xfcI\xfaA\xf7\xaf\xf4\xde\xf3\xf6\xf1f\xef\xfe\xee\xa0\xef.\xeeN\xeb>\xe9\xd2\xe8\xb8\xe8Q\xea\x0b\xed\x15\xf0\xd5\xf2c\xf4\xe6\xf6\x1e\xf9\x1b\xfb|\xfd\xef\xffD\x035\x07\xcb\nD\x0e5\x0f\x90\x0f\xaf\x10@\x10}\x104\x11\xd3\x10\xbf\x10m\x11\xb4\x13\x05\x15\\\x10Q\x08_\x04\xc2\x03}\x04c\x050\x03\xcd\x00(\xffD\xfd\xa3\xfc\x9a\xfb\xb7\xf83\xf8\xd0\xf9[\xfd\x93\x01{\x03\xf4\x00A\xfd\xfc\xfb\xf9\xfb\x9d\xfcv\xfc#\xfcZ\xfc\xcf\xfb\xc7\xfa \xf87\xf4\'\xf0>\xee(\xee\x18\xef\xc7\xf0\x1a\xf1\xa0\xf0-\xf0R\xef\xb5\xefK\xf1\xb5\xf2Z\xf5\x00\xf8\xc2\xfb\xf1\xfeB\xfe\x15\xfd(\xfd\xab\xfd\xa4\xff\xc8\x01Z\x02\x95\x05.\x07\r\x06\x8e\x07\x9c\x06E\x04\xc4\x04T\x05C\to\x0b\x06\n\xc4\n\xe5\t\xfc\x07\xd4\x05\x06\x05\xec\x06*\x08\xab\n\x8b\r\x11\x0e\x0b\r\x91\x0cl\x0f)\x14\x14\x18\xa4\x1ac\x1f\x96%\xf2&\x9f%A$\xcb"\xdc"\x0b!\x91\x1f^\x1f2\x1a\xbc\x13\x87\x0e\xbb\x08a\x02\xdf\xfb\xae\xf5\xe6\xf1\x13\xef\xda\xeb\x1a\xe9\xbe\xe5\xb3\xe1G\xdf\x96\xde\xae\xdf\xb5\xe1\xfb\xe2\x11\xe4\xee\xe5v\xe8\x1f\xeb=\xed\xb0\xefM\xf2\t\xf5O\xf8n\xfba\xfe&\xff\xa7\xfe\xa4\xfe\xa8\xffg\x00p\xff\xc5\xfe\xed\xfe\x1c\xfe\x0f\xfcd\xfa\x10\xf9\'\xf7 \xf5\xcd\xf4\xe1\xf5\x9a\xf6p\xf6\xbc\xf6\xca\xf7\x88\xf8y\xf9j\xfbA\xfe\xb4\x00R\x03@\x060\t#\x0b\x1e\x0cE\re\x0f\xd2\x0f\xec\x0f\x7f\x10\xf7\x103\x11X\x0fQ\x0c!\nQ\x08\xfc\x05\xc0\x031\x01k\xff\xc4\xfd\x16\xfc\xfd\xfa\x88\xf9\xe3\xf6:\xf5\xbc\xf5}\xf6\x11\xf7\x80\xf7\xcf\xf7\\\xf8\xde\xf7\x04\xf8j\xf8\x9c\xf7x\xf7\xd5\xf7\x07\xf9u\xf9\xe2\xf8\xe0\xf7\xe4\xf5\x15\xf5\xbb\xf4-\xf5\xcb\xf4\xb2\xf5\x89\xf5\xd9\xf5u\xf7\xbe\xf7<\xf8\xa6\xf7\xb6\xf8\x05\xfd\x00\x01\x97\x00\x15\x01\xc4\x03\x15\x05\xf2\x04\xb9\x04Q\t\x0c\n\xfd\x08\xce\n\xac\x0b-\n\xc3\t-\n-\x08\xfc\x08\x19\x0b\xbd\n\x89\x08\x18\x08\x0c\x08\xb7\x07f\x07\x8d\x08\xb5\x07\xa1\x04\xef\x05\x91\x08<\x08@\x06.\x07\xf3\x07p\x05I\x04\xc9\x04\xab\x04\xd6\x042\x04\xbf\x02d\x04|\x07?\x08&\x07\xeb\x06i\n%\x0c\xb8\x0b\xed\x0bw\x0c2\x0c\x04\x0c\xa3\x0c\xd9\x0b\xb4\t\x95\x06@\x04\xda\x01\xc7\xff\x8e\xfe\xb6\xfcw\xfa\xab\xf7\x1b\xf6U\xf5\xfa\xf3\xff\xf16\xf0k\xf0\xfa\xf1\xa2\xf1\x80\xf1>\xf2>\xf2\xc1\xf1\xa3\xf21\xf5D\xf6S\xf6\xc1\xf7\x1b\xfa\xba\xfb\xe4\xfc\xf1\xfd\xf3\xfe.\xff2\x00(\x02\xbd\x03\xb9\x038\x03\xaa\x02M\x02\x13\x03\x90\x02@\x01O\x00\x14\x00\x84\xff\x1e\xfe\xc2\xfd\xa7\xfcy\xfa\xba\xf9\x8a\xfa\x1b\xfb\x02\xfa\xcb\xf9\xc1\xfa\x8a\xfbr\xfb\xf7\xfbI\xfd#\xff-\xff\x9d\xff\x1f\x02\x04\x04q\x03\x8f\x03"\x04\xae\x03\xb5\x04_\x03\xcb\x01\x9a\x03;\x03\x92\x01E\xfek\xfev\xfe\x82\xfa\xdf\xfb\x93\xfc\xdb\xf9\xee\xf9\xe6\xfa\xf4\xf8\xaa\xfc\x10\xfdP\xf9h\xfe\xc1\xff\x9f\xfd\xd2\x00\r\x03\xc8\x01\xc5\x02L\x03\x18\x07\x8e\x07=\x03\x82\xfeb\x05_\x05\xe4\xff(\x08\xf6\x01\x17\xfe\x8d\x02\xe6\x02\xc1\x03\xd5\x00D\xfc\x83\x00\xca\x03\x84\x01\x9e\x05\x97\x02r\xfd\x89\x02L\x04a\x02y\x03\xc3\x049\x001\x01\xd4\x04\\\x04\xda\x04\x1c\x01\x1d\x00\t\x00\xf4\x00\xa6\x02\xac\x02\xd0\xfeY\xfa\x95\x00\x9a\xff\xfa\xfa\xd8\x01\x05\x00#\xf8\x9b\xf8>\xff\xb2\x01\xd5\xfd\xca\xfc\xde\xfd\x80\xfe6\x00)\x01\xb5\x01\xf9\x00:\xfe"\x02\xc6\x05\xce\x01\xc6\x00\xbb\x01\x89\x02\x04\x01\x81\x03\xdd\x04G\x01\x9e\x00\xb0\x00\xff\x00\xa7\x03\xba\x040\xfeF\xff\xa9\x03\xe5\x02+\x00T\x00\xc7\x02j\xfd]\x01\xfe\x05_\x01f\xffH\x02\xec\x034\x00\xa4\x01\x10\x03\xac\x003\xff\x0e\x02<\x01\xcc\xfd[\x00\x12\xff\x1e\xfd\x87\xfb\xe4\xfbv\xff0\xfe\x86\xfa\x8e\xfb\x8e\xfb\x16\xf9\xa4\xfc$\xfdU\xfc}\xfc\xdb\xfb\xc1\xfc\xb0\xfe^\x00\xe7\xfcE\xfe\xf6\xfeH\xffo\x01Q\x01\xcc\xffW\xff\xfe\xfeN\x00\x8c\xffi\x01\x0e\x01\x7f\xfb>\xfd\xe9\x01\x1e\x01\xf9\xfcz\xfe\x16\xff\x8f\xfe\x1e\xff\xb8\xffP\x00\x93\xfe\xd7\xfc%\xff\x16\x01\xc2\xfe\x8a\xff0\xfe\x89\xff\xe6\xfd\xe1\xff?\x01W\x00Q\x02\xca\xfe\xfd\xfew\x02]\x04f\xff\xa4\xfd\x0b\x00\x9c\x01\xa2\x03%\x00\x9d\xfe\xbf\x00\xba\x03\xee\xfc\xea\xfd\xfa\x01\x89\xfdL\x01\x0c\xfe\x10\xff\x07\x01\xf8\xfd\x07\xfd\x89\xfcO\xff\xef\xff\x01\x008\xfat\xfe\x0e\x01\x9f\xfc\xf6\xfba\xfe\xf3\xff\x98\xf8T\xffR\x02i\xfe\xac\xfc8\xf8\xe0\x03\xad\x01\xf0\xfa>\x00\xc0\x032\xfe\x9a\xff\x87\x04\xc7\x02\xfd\xfc\x1c\x00\x1b\x08\xb5\x01\x94\x00\xce\x07\x0e\x04\xbb\xfe\xb2\x03\xae\x08)\x03D\xfc\xee\x06\xdf\t\xe4\xfb\xef\x03\x98\x08\xb0\xfd\xdf\xff\xc0\x07\x11\x03\xd7\xfd\xd9\x01\xaa\x05\xe5\x03\xfc\xfe\x8b\x05J\x02\xd1\xfd\xbb\x01\xa6\x01\xc2\x05\x97\xfe\xd2\xfd\x9a\x01 \x03\xbc\xffD\xf8|\xfeV\x03\xce\xfc\x83\xfa\x1f\xfe\xde\xfd\xd1\xfcJ\xfa3\xfe\xcf\xfcL\xf9\x0f\xfe4\xfc\xaa\xfc\x08\xfc\x90\xff\x90\xfc\xab\xfd\xe4\xfd\xfb\xfb\xdf\xfd\xa8\xfc\xc8\x02\x10\xfe\xab\xffg\x01\x02\xfe\x00\xfbY\x02\xfd\x02\x12\xff\xd7\xff\xc9\x03\x83\xff\x8b\xfc\x19\x05\x8e\x05v\xff\xbf\xfec\x071\x00\xa9\xff\x1e\x039\x06S\xff\xf7\xfd@\x036\x067\x05~\xfe\xe8\x02}\x01b\x02\xe6\x02\x9e\t\xd4\x03\x89\xff\xb1\x06\xf5\x05\x11\x00&\x03\x84\x06\x00\x00\xe1\xfe\x92\x01\x9e\x05f\xffV\xfb\x0c\xfb\x96\x00\x05\xfd\x1b\xfbn\x02\x82\xfe|\xf3\xf1\xff\x86\x02\xef\xf62\x00?\xfb\x12\xf7W\x00\xaa\xfb\xd1\x00\x89\xfd\xc6\xf6\xb6\xfe\xcd\xff;\xfb!\xfc\x81\x03\x14\xfa\x94\xfb\x08\xff\xe6\xfcQ\x01\xc0\x019\xfbH\xfb(\x03 \xfd\x9e\xffl\xfe\xf8\x01\xf4\xfe\xd4\xfdZ\xfe\xb2\x00\xfb\x03\xc7\xfb\x1c\x01\x18\x03-\xfe\x92\xff\x83\x03\xbb\x03\x00\x01\x04\xfe\x02\x01\xc2\x08!\x04\x86\xfd\x10\x01\x8b\x08\xe7\x02H\xff\xd5\x00d\tU\x04\xfe\xfb[\x05f\x02\x8f\x00\x16\x03\xbd\x00\x06\x02\xa8\x01\xbd\xf8\x86\x05\xd2\x03\xd2\xf8g\xfe\x1f\x06\x9f\xf9\xeb\xf9:\x07O\x00\xed\xf1\x9b\xfb`\ny\xf9\xd3\xfan\x00q\xff\x1e\xfb7\xfcG\xff\xcc\x00W\xf38\xfe\x1f\x08\x03\xfaD\xfb\xdb\xfd\x0b\x00\xaf\xfc\xa0\xfc\x83\x00\x16\xfd\xa1\xfa\x04\x05(\xffD\xfc\xd1\xff\x9d\x00\xe9\xfc\x93\xf8\xf9\x05\xf0\x03\x06\xf9\xc4\x00Q\x00\xea\x04\xf4\xfft\xfcb\x03\n\x00\xc7\xffX\x04\x9b\n\xc3\x01v\xfe\xfe\x01Y\x06G\x01 \x02\xa5\td\x05\xbd\xfe6\xff\'\x0c\x87\x00\xa3\xfaZ\x04Y\x03\xdf\xff\r\x02\xd8\x03\xa7\x00W\x01\xdb\xff\xdd\xfc\x86\xfe\xb8\x05I\xff\xaf\xfd\x03\xfd[\x03\xb2\x02\xb9\xfd\xa7\xfa\xf0\xf9\x05\x06\xcc\x02\xec\xf8\xb1\xfb=\x08\xc2\xfb\xf0\xf3\x0e\x03\x95\x05f\xf6Y\xf7N\x04\xbe\xfe\xe6\xfc\xd0\xfc\xb1\xff\x93\xfe\xb9\xf7\x10\xfd\x90\x01\x88\xff\x9b\xff\x03\xff\xd9\xfc\x08\xfc\xcf\x01:\x03T\xfeX\xfc}\xfdz\x06;\x03O\xfb\xf8\x01\xdb\x04T\xff\x8d\xff\x83\x06\r\x02\xa5\xff\xdc\xfeJ\x08\x1e\x03\xbe\xfc\x14\x055\x00\xbc\x00\xab\x02K\x05\xea\xfd\x82\x00G\x04%\x00\x11\x01\xeb\xff\xd8\x03\xee\xfdD\xff\\\x05\x8c\x00\xcb\xfd\xcc\x02\\\x00\x19\xfa\x98\x07\x86\x006\xff\x0c\xfe\xb4\xf7]\x0bS\x03\r\xf4\x9c\x00\x99\r]\xf6)\xf3\x18\x08J\x08U\xf6\x96\xf7\xf5\x01!\x00\xf8\x01\xb2\xf8\xe9\xfb\xf8\xfax\xfa\x86\x04\xb4\xff\x14\xf9\xd4\xfc \x02\xc9\xfb0\xfe\xae\xfe\x7f\x01L\xfe\x85\xff\xb6\x01\x91\x00\x12\xfde\xfc\x0e\x07g\xfe2\xf8\xb5\x03\xa2\t\xfc\xfb\xe2\xf7\xfc\x04_\x02K\xf7\xad\x04\xe2\n}\xfc\xd7\xf8W\x07\x06\x05\xab\xfb\x9a\xfe\xc6\x07&\x05\xb8\xfd:\x05\xf2\x03\xd1\xff\x07\x00k\x05Q\xfe\xe5\x04\xfd\x061\xf9\x07\xf9\xe7\x07Y\x0c_\xf3\x82\xfa\xef\x08\xb6\xfa\xba\xf8\xa8\x03\xf9\x07\x1a\xf8\xc8\xf5\xa5\x06d\x03v\xf8\x95\xfdC\x02\xcd\xfc\x97\x02\xdf\xfd\xce\xfc\x06\x05\x94\xfc\xf4\xfd\x1c\xfe\xdd\xff\n\x01\xde\x00\xa4\xfb(\xfe\xe3\xfc\xea\x00R\x04}\xf4\x03\xf9{\x01?\t\x95\xf62\xf5.\n\x1d\x03{\xf2\xac\xfc\xff\r\xa6\x02\x05\xf5k\x01\x9f\x0e\xfe\xfa{\xf9x\x08\xf5\x08N\xfd\xb6\xfc*\x08e\x07G\xfd+\x03\x19\xff\xe0\xfeG\x05\xf0\x02\x86\xfei\x04\xac\x03\xbc\xf6l\x01\x81\x07`\xfe\xa9\xfa_\x05\x8c\x00\xfa\xf6~\xfe\xe3\x07\x9b\xfdl\xfbl\x03\xa8\xff\xdb\xf9Y\x02\xff\x01\x80\x00\x06\x00\xeb\xfc\x19\x08\xae\xff)\xfb"\x01\x8f\x04m\xfc\x99\x02\x01\xfd\x8c\xfc\xb9\x03\xb6\xf9\x0c\xfc\xc6\x029\xfd@\xf6\xda\xfe\xa4\xfc\xbb\xf9C\x00\xaa\x00o\xfa\x1a\xfcJ\xff\xd4\xfch\xfb@\x08\xb8\xff\x8e\xf3\'\x03\x18\x074\x00z\xf9\xfc\xff]\xff\xb1\x02A\x03\x9d\x01\xcc\x034\xfd\xc7\xff\xa5\x04\x84\t\xef\xfe3\xfb\xe6\x06)\x07\x83\x05c\xfa\xc4\x05\xc7\x05|\xf8\x98\x04o\t\x1a\xff\x9d\xff\xd6\x01(\x02\xda\xfe[\xfd\x8a\x05>\xfe\xf9\xf9\x8f\x03"\x05\xe7\xf7T\xfc\xd6\xfe\xa0\xfb\xb0\xfc\r\x08\xe9\x00\xbf\xf4\xb9\xfe\xd2\xfeY\x01\xcf\xfd\xc8\x03\n\x02-\xf3F\x02\xc4\x0e\xa5\xfb\xc2\xf53\x02G\x07\xbb\xfa\x90\xfe\x9f\x0e\x05\x04\x91\xf2\xe2\xf7\x8b\x04P\x11`\x01V\xf3\x14\xf8\x86\x06:\x07=\xf7\xb5\x03;\x00\xb1\xf7\\\xf6\x95\xfd<\r\xde\x06\xc6\xf2\xb2\xf5\x83\x03\x91\x04\xa1\xfa\xe4\x01\xe1\x02U\xf8(\x03\x9b\x08\x16\x02\x0e\xfd\xf3\xff\xa6\xfaK\x04\xdf\x07X\xfe\xf0\xfeG\x05\xde\x00\x85\xfd\x03\x06^\xfd\x0f\xfd\x87\x03\x12\x02\xc7\x03\xb8\x02\xa0\xfcn\xfa\x80\x04k\x03\xc0\xfbQ\xfdP\x08\xd7\x00l\xf6\xc7\x00M\x01r\xfc|\x01]\xfd-\xfdV\x03\xd7\xfbL\xfcl\xf9\x0b\x02\x81\x0cm\xf3B\xf2 \x0b\x93\x07\xac\xfdj\xf2\xe6\x00\xa6\n\x1a\xfa\xf8\xf9d\x05(\x0b\xf8\xfc\xb3\xf0p\x00\x15\x0eo\xfd\xc2\xf2S\x02\xc1\x0e\x0c\xfb\x84\xf4D\x06Y\x08L\xfe\x01\xf5\xa7\xffh\x0fn\x080\xfa\xda\xf6u\x00n\x0c@\x03h\xfa\xf5\x01|\x06\x01\x00\xdf\xfb\xec\x03L\x05\xcb\xfd\x1b\xf7\x94\xfd\xbc\x0eA\x04\xf9\xf4\x8d\xfb/\xfdY\xff-\x03}\xfd}\x00\x03\xfe\x0f\xfc\xbf\x02\x8e\x02\x9b\xf9[\xfc+\x02g\x01\xd0\x073\x00\xc9\xfd\xa2\xf7\xc0\xf9+\tg\x04\x16\x02\xbc\xffw\xf8P\xfea\x07\xdc\xfe\x8e\xfcz\xfc"\x00\xbc\x02\xb1\xffI\x05U\x00\x8b\xf5m\xf9-\x07\n\x05\xe0\x01\xf9\xfa\xdc\xfa\x81\xfb6\xff\xd5\x05\x1e\x028\x02\xdd\xf8\x90\xfbU\x02[\x03\xbd\x00W\x01`\x00\x1e\x00%\x02d\x04a\x04m\xfcC\xfc*\xfe\xad\x00+\x08\x8f\x04\xc5\xfb\xb7\xfc\xd3\xfd\xb9\x00@\x00d\xf94\xfe5\x05\x9a\xff]\xff\x1e\x04\xca\x02\xed\xf8\xff\xf4\x11\x03`\r\x0b\x04\xeb\xfc\xbb\xfb\x15\xfd\xac\x00\xf2\xfe:\x04\xb2\x00\xa1\xf9\xb1\xfe~\x011\x04[\x04\x96\xfc%\xf3<\xfb\x8a\x0b\x10\x07\xd6\xffV\xfd\xa9\xf9\x96\xfby\x00\x16\x06s\x04\x86\xfd\x87\xfb\xa1\xfe\xae\x01\xa2\x04D\x01o\xfb\xed\xfd\x9c\x03\xa8\x02"\x05\xdd\x01\xdc\xf9\x82\xfcD\x01\xed\x04\x15\x01?\x00\xfc\xffl\x00\xa9\x01O\xff\x07\x00\xf2\xff\xa8\xfe\xdf\x00\x93\x00\xd3\x01\x87\x03k\xfc\xb8\xfa)\x00\xc3\x01\x18\x02_\x01\xd4\xfd\xd7\xfc.\xfd<\xff\xb8\x00\xff\x05 \xfd\xe5\xf7\xb2\xff\xf3\x04)\x03\xa7\xf9\x1d\xfdL\x00\xe7\x01\xb9\x01?\x03\xb7\xfdD\xfc\x94\xffj\x00:\x05:\x04\xd4\x00\xc2\xf85\xfd;\x031\x03\xbf\x03[\x00B\xfd|\xfd\x8c\x00\xd1\x01\xcf\x00\xe2\xfd\x8b\xfdV\xffB\x03*\x06{\xfeg\xfa)\x00\x9f\x01\xd3\xfee\x01\x92\x03\xb6\x02\xf8\x00\xcb\xfdp\xfe7\x00\xd4\xff2\xff"\x04\xdf\x025\xfd\xe6\xfd\xe9\x00J\x01\x8f\xfb{\xfd!\x02$\x03\xb5\x02\xc1\xfd_\xfb\n\xfe\xfb\xfe6\x00g\x02\x81\x02\x8f\xfe\x94\xfcx\xfe\n\x00K\xfe<\xfd\x19\x00+\x03w\x02\xd7\xfe\xeb\xfb\x8d\xfc\x83\xfe*\xff\xd8\x00V\x02\xdc\x02}\xfdO\xfa\xba\xfe\xbf\x01(\x00\x9b\x00\xbb\x01\xb0\x00\x03\x00\x91\xfe4\xfd\xd8\xff\xf7\x01R\x00 \x02\xd2\x02v\x00(\xfd\xa5\xfcf\x00\x19\x02\xad\x01\x94\x00\x00\x01\xa1\x01r\xfe\x17\xfe\xfe\xfe\xb2\xff\xb3\x01\x12\x01h\x02<\x03\x19\x00P\xfc\xc6\xfb\xad\x01\x8a\x05q\x03\xb2\xff\x86\x00\xb8\xff\xe1\xfd5\xff\xe2\x01\x16\x02g\xff\xef\xfe]\x016\x02\xdf\xfdZ\xfcE\xfe\x19\x00\x95\x01\xd7\x00e\xff\xc0\xfd\xc2\xfb\xbf\xfe\xed\x00&\x00\x03\x01\x8d\xfe/\xfd\xbd\xfd\x9f\x00\xf3\xff\xb7\xff\x03\x02/\xffq\xfc\xa8\xfd\x97\xfe\xbf\xfd\xe3\xfew\xff\xf2\xffM\xfc\x98\xfb\x85\xfd\x02\xfd3\xffr\xff\xcc\x01\xd1\x02T\x03\xd5\x01L\x02\xf5\x04\x85\x07{\n^\n\x0c\x0c\xc0\n3\n,\x0b%\x0b\xd8\x0bI\x0b.\n\x02\x0b\xfe\x07}\x05\xeb\x03\x9e\x00\\\x00\x9d\xff\x0c\xfe@\xfb\xe5\xf9\x83\xf6d\xf4T\xf4\xa7\xf4Z\xf4\xa0\xf3G\xf4\xba\xf2\xee\xf2w\xf4\xa9\xf6\xdf\xf8\x85\xf9<\xfb.\xfc-\xfe\xb8\xff!\x00S\x03\xe4\x04\x0c\x05\xc1\x051\x07\xf4\x06U\x05\xf9\x04p\x04"\x050\x04X\x02\x12\x01\xc8\xfd5\xfcw\xfb\t\xfa\xf5\xf8\xcf\xf7\xf3\xf6\xc0\xf4\x0f\xf5V\xf4\xbe\xf4C\xf4\n\xf5\x95\xf6\x89\xf64\xf8r\xf7\x1f\xf9\xf9\xfae\xfcs\xfe\xef\xfe\x96\x00\xb2\x00\x9b\x00B\x01\xdd\x02\xff\x04\xe6\x03a\x03\xc4\x02\xbe\x02\x1e\x03\xbd\x02\x1e\x03\xfe\x01z\x01\xa3\x01=\x00\xca\xff\xaa\xff\x11\x01\xac\x01\xd3\xff\x1a\xff\xef\xfd\xfa\xfeg\xfd\xfc\xff\xd4\xff\xbd\xfb\x9c\xfd\x9d\xfdh\x00\x11\x01\xc0\xff\x84\xffk\xfcO\xfd\xd6\x08B\x14m\x16~\x0f\t\t\xd1\x0e|\x182\x1f\x0f \xaf\x1f\x98!\x8a\x1f\xf3\x1e}\x1e\x92\x1a\x8c\x16\xf6\x11v\x16\xb1\x18\x1b\x11>\x06\xd1\xfb\x05\xfa\xba\xf9\x9d\xf8\xa9\xf7\xe5\xf1\x9b\xea\xdc\xe4\xbb\xe4\xaa\xe5\xd2\xe5B\xe4\x83\xe5\x90\xe7\xd6\xe8\x97\xea\xa8\xe9+\xeb\x80\xeeL\xf4<\xfaO\xfd\x84\xfe\xc5\xfb\x9e\xfb\xd6\xffw\x04\xa0\x07\x85\x07t\x06\xba\x04<\x03\x12\x03A\x02X\x01\xff\xff\x0f\x004\xff\x97\xfdE\xfa\xba\xf6\xe3\xf4|\xf5\xa4\xf7\xef\xf8\xe0\xf7>\xf52\xf3{\xf3{\xf6\xbc\xf8\xdf\xfa\t\xfcD\xfc\x96\xfd\xae\xfe\xb4\xff\x00\x01\xc3\x02\x98\x05k\x07\xa2\x08i\x08\x86\x07!\x07\xc0\x07}\t\xb1\n\xbf\nl\t\x18\x07\xa3\x05\x90\x05\xbf\x05[\x05\r\x04\x84\x04\xf1\x03\xa2\x01T\x00\t\xff\xa7\xfe\x0c\x01\xa0\x02\x92\x02A\x005\xfd+\xfe\xef\xfe\xac\x01g\x03\xea\x03\xf2\x00\x11\xffK\x02n\x01[\x02\xf7\x00\xa3\x01P\x03\xf0\x00\xda\x04\x90\x03~\xfe\xf5\xfb^\xfc\xf9\x02\xa9\x01\t\xff\x19\xfd\xea\xf9\xdd\xf8\xdb\xf7\x14\xfb\x13\xfc!\xf8\x08\xf5#\xf5\x14\xf70\xf6&\xf5\xbc\xf58\xf6\xef\xf5\x91\xf67\xf8\x99\xf8\x11\xf7D\xf7W\xfa\xd3\xfc]\xfc6\xfb\xdd\xfb\x1d\xfc\x9a\xfc\xb3\xfe\x9f\x00X\x00\xbc\xfe\xae\xfd*\xff@\xff\xd4\xfe\x9f\xfe\xca\xfd\xcd\xfd\xdf\xfd\xf5\xfc\x1a\xfc~\xfb\xf3\xfbh\xfc0\xfb \xfc\x95\xfb\x9d\xfey\x08\x19\x10s\r.\x04\xd9\x03"\x13\x8c\x1f\x96%M&\xe2!*\x1d\xf0\x19\xfe#\x99.\x04/\x07&\xa1\x1d\'\x1b\x07\x18\x8c\x15\xc5\x12\xb8\x0e]\x08\xf6\x02\xf5\xfe.\xf8\xfc\xef\xa6\xean\xe9\x85\xebo\xeb\x9d\xe8\xdf\xe1\x1d\xdd6\xdeL\xe4\xe7\xea\xb1\xee\x89\xef\x11\xedZ\xec\x81\xf0\xcf\xf8\xd0\xff\x1f\x02\xb3\x02\xee\x02\xb9\x04\xd2\x04J\x06\xf0\x08\xa0\t4\tD\x07*\x06r\x03\xcc\xfd\x91\xfb3\xfc\xdc\xfc\xdf\xf9^\xf5\xc5\xf1o\xee\x16\xec\xac\xec\x9a\xef*\xf0\x94\xedq\xeb\xd4\xec\xd8\xee\xd6\xf0 \xf4\x88\xf7\x17\xfa\x87\xfa\xd2\xfc\x00\x007\x02(\x04>\x07E\x0b\x9a\x0cF\x0c\xee\x0b\xc2\x0c\xae\r\x18\x0ed\x0f\xb0\x0f5\x0eO\x0bh\t%\t\x97\x08*\x08\xa2\x06\x07\x06\xf0\x03U\x01\x94\xff<\xff\xa3\xffW\xff\xff\xfd\xc1\xfd\xf2\xfc\xa8\xfb\x84\xfb\x93\xfbU\xfd\x93\xfd\x9c\xfd\x12\xfd(\xfd\xc8\xfc\xce\xfdw\xff\xd9\xff\xd4\x00\xa6\xff\x83\x00\xfd\x00\x91\x01\x06\x02|\x03\x16\x04;\x03\xcc\x02\xc4\x02\xee\x03\xae\x03B\x04\x01\x04\xba\x02\x9c\x01O\x00\x05\x01\x02\x01[\x00b\x00\x08\xfe\xee\xfc\xf5\xfa\xf9\xfcm\xfe\xf9\xfe\x17\xffo\xfcM\xfc\xb9\xfa\xe2\xfd\xab\xff\xc2\x00\xde\x00\x84\xfeF\xfe\xd4\xfc>\xfd\t\xfeR\xffq\xff\xc1\xfd\xd0\xfa!\xf9\'\xf9\xca\xf9{\xfaa\xf9\x01\xf9\x03\xf8\\\xf6C\xf6y\xf6\xeb\xf7\xf4\xf7\x07\xf9`\xfa\xd3\xf9\xcf\xf8\xbc\xf8Q\xfd\x0c\xffP\x01\x08\x03\xd4\x02\x80\x02\x03\x01\x01\x07\xe2\nN\x0b\x99\x0c\xf0\r\x06\x0e\xc9\x07\x8b\x06E\x0c\x91\x12\x86\x14\xfe\x10\x92\x0c\xa9\x04\xc6\x01V\x08V\x11\xbe\x12u\t\xee\x00\xe7\xfej\x01R\x06i\tC\x08\xee\x03\xdd\xfe\xa0\xffY\x02\xc2\x03)\x04\x03\x03:\x05\xf1\x05\xa5\x05V\x04\xd4\x01Y\x02\xf7\x04]\x07T\tt\x05\x15\x00\xd9\xfb\xbc\xfa\xf0\xffX\x00d\xfd\xf6\xf6\xbc\xf1s\xf1\xf1\xef\x9a\xf1d\xf2\xad\xef\x1e\xec\x96\xe9\xf9\xeb\n\xefc\xee\x06\xef\xae\xf1\t\xf3\xab\xf2\xb0\xf3\x86\xf7\x03\xfbT\xfb\xe4\xfby\xff\x87\x00\t\x00\x1e\x00\xb0\x02\xc4\x04\x08\x03\x84\x02n\x03\x8c\x03\x82\x01\xc4\x00\xd2\x01B\x02j\x00\x95\xfe\xc1\x00\xf8\xfeG\xfd\xac\xfe,\x02-\x03\xee\xfe\xa1\xfdS\x020\x05\x13\x05\x0b\x05\xf2\x05\x9f\x06\x1d\x04c\x06\r\t]\x08\xa2\x05\'\x03\xe3\x04\xcc\x04\xbb\x02\xe0\xff<\xff\xbc\xfeI\xfd`\xfc\t\xfd\xe9\xfb\xb7\xf9J\xf9\xe0\xfb\x85\xfce\xfb!\xfe\x1e\x01\xfc\x01\xb0\xfe\xe5\xff\xe1\x05O\x08\x9b\x07\xbf\x06\xef\x07\xef\x06\xed\x05)\tZ\x0b\xda\x06\xad\xff\xc3\xfe_\x03\x1f\x04y\x00_\xfb\xd1\xf7\xb3\xf5\xc0\xf5\x03\xf9E\xf9!\xf5\xd6\xf0_\xf2\xdc\xf5U\xf6+\xf6\x03\xf7\x19\xf9\xbd\xfac\xfe\xde\x01\xae\x00l\xff\x18\x02\xff\t/\x0ey\x0e\xb2\x0e\xf5\x0b9\t\xdc\t\xba\x10R\x16\x14\x12\x98\x0b\xf1\x06\x8d\x04\xd0\x04-\x06O\t\xc8\x05\xfb\xfc\xe4\xf6\x01\xf8\xcc\xfc\xe6\xfd\x14\xfc\xe9\xf9K\xf8\x1f\xf7\x10\xfa?\xff\x0b\x02(\xff7\xfd\x94\xff\xc4\x02W\x04\xfd\x04\xd6\x05u\x03\xdc\x00d\x02\x04\x06&\x06\xce\x02\xdd\x00E\x00\xd9\xff\x84\x00\x1b\x01,\x00T\xfd\x87\xfc\x93\xfe^\xfee\xfd\xba\xfcS\xfc.\xfc\x9c\xfbH\xfe$\xff\x98\xfcL\xfa\xfc\xfaR\xfd\x87\xfd\xa2\xfd\xe8\xfd8\xfdV\xfa`\xfa`\xfdK\xfe\xd5\xfc\x9c\xfa\x11\xfb&\xfb\xce\xfa\x01\xfb\xdd\xfb=\xfc\xb5\xfa\xf2\xfaH\xfc\x1a\xfd\xad\xfc\xe8\xfc\xd8\xfe\x9a\xff\x7f\x00\x8d\x00\xbc\x01\x9d\x02\x84\x02\xcd\x03:\x05\x13\x06\xbf\x05\x14\x05v\x05\xba\x05\xc4\x05\r\x06\xee\x05H\x04\x7f\x02o\x012\x02%\x026\x00f\xfe\xeb\xfc\x06\xfd~\xfcU\xfc\x93\xfc\xc4\xfbI\xfb\x87\xfb\x10\xfee\xfeJ\xfe\xcd\xff\x9a\x02\x99\x03\xb5\x02\x7f\x03)\x06\xfb\x07\xe4\x07\xd2\x08\x92\x08\x91\x07\xc2\x05F\x06\x94\x07h\x06\x85\x03P\x01\xdb\xffK\xfe\x8b\xfd\x0c\xfcC\xfbI\xf9;\xf7\x9d\xf6K\xf6f\xf6\x83\xf5\x99\xf5\xf6\xf5\xf4\xf57\xf6\x8e\xf6\xb2\xf7{\xf8\x10\xf9\x01\xfa\xe0\xfa\xc1\xfb0\xfcx\xfdy\xfe\x81\xff \x00\xa8\x00O\x01N\x01\x03\x02\xda\x02E\x035\x03\xe5\x02\xd5\x02\xd3\x02+\x03\xf4\x02\xe4\x02\x84\x02R\x01\x82\x01\x98\x01\x13\x020\x01e\x00\xf6\xff\x08\x00\x00\x00\x04\x01\x14\x01"\x00\xcd\xff`\xffK\x01\x8b\x01\x9f\x02\x12\x02\x83\x01\x02\x01\x00\x014\x02\xa4\x02\x9e\x02y\x01\x85\x00\xa5\xff\xb1\xff\xf6\xfd\x94\xfd\x05\xfd\xe7\xfc\x92\xfc,\xfc\xcb\xfd\x03\xfdo\xfd]\xff\xf8\x02\x17\x05\x88\x06N\tx\x0c\x14\r2\x0e\xc3\x12&\x17\xdc\x17\xa2\x15\xdf\x15w\x15\x08\x148\x13\x7f\x14e\x13F\x0c\xee\x05\x87\x02_\x01i\xfeD\xfc\x04\xfa\xbc\xf4L\xed\xf6\xe9\xd4\xeb\x89\xed\xfa\xecf\xeb\xaf\xea_\xe9V\xe9\x02\xedd\xf2\xdf\xf5:\xf6\xd6\xf6m\xf89\xfb\x82\xfe[\x02\xdf\x04\xca\x04\xd0\x03\xfe\x03\x7f\x052\x06R\x06\xa1\x05\x9c\x03\x90\x01\x08\x00\xea\xff&\xff`\xfd\x97\xfb?\xfa\x1c\xf9\x88\xf8\xd2\xf8\xd3\xf8\xe5\xf7\xe9\xf6s\xf7\x03\xf9\'\xfa\x1d\xfb\xee\xfb%\xfc\x8b\xfc\xda\xfd\x1a\x00\xef\x01\xa3\x02\xc3\x02\xe3\x02\xff\x02\xea\x03o\x05/\x06\r\x06.\x05\x80\x04/\x04O\x04\xc2\x04\xba\x04\xc8\x03\xc0\x02%\x02\xba\x01\x85\x01\x96\x01\x8d\x01(\x01\xaa\x00\xa0\x00\xfb\x00\xb6\x00\xd1\x007\x01\xd8\x01*\x02"\x02\x1a\x02\x04\x02\xf6\x01#\x02b\x02\x84\x02\xe7\x01\x0e\x01+\x00\xe6\xff\xb0\xff1\xff\xfe\xfe=\xfe\x81\xfdI\xfdQ\xfd4\xfd\xc2\xfds\xfe\xab\xfe\x18\xff\xc2\xff3\x01\xd0\x01e\x02\x86\x03H\x04\x04\x05\x00\x05<\x05\xae\x05;\x05\xc8\x04]\x04h\x03K\x025\x01\x9e\x00\xc6\xffn\xfe\r\xfd\xc3\xfb\r\xfb\x98\xfa9\xfa\'\xfaZ\xf9\xba\xf8\x9b\xf8\x18\xf9\xf6\xf9S\xfaV\xfa\x88\xfa\xe4\xfa\xc6\xfb\xcf\xfc\x87\xfd=\xfe\x97\xfe\x04\xff\xe2\xffZ\x00/\x01\x89\x01\xd8\x01\xf8\x01\xd0\x01\x1e\x02=\x02\xeb\x01\xb1\x01f\x01\xfd\x00j\x00\xee\xff\xa8\xff\xa8\xff-\xffu\xfeK\xfe\xe0\xfd\xb7\xfd\x05\xfe{\xfe\x7f\xfe"\xfe\x0e\xfe\xad\xfe7\xffF\xff9\x00\xb6\x00\xa5\x00\x7f\x00\xf9\x00\xe3\x01\x0e\x02<\x02\xd1\x02\x13\x03\xc2\x02\xac\x02\n\x03\xef\x02\xd0\x02\xae\x02\x1c\x02A\x01\x9f\x00\xb0\x00\xf7\xff"\xff\xdb\xfe\xa1\xfe\x91\xfd\t\xfd(\xfe\xaa\xff\x83\x00\xcc\x01\xaa\x03~\x04\x86\x04\xc8\x05.\nS\r\x96\x0e\xbf\x0e\xd7\x0ev\x0e\x0f\x0e\xb1\x0fU\x11\'\x10n\x0c\x00\t\xba\x060\x05\xd7\x03\xa2\x02\xa1\xff6\xfb\x98\xf7\xb9\xf5K\xf5\xc1\xf4\xdd\xf3\xb7\xf22\xf1H\xf0\xa2\xf0\xec\xf1\xbf\xf3\x91\xf4\xee\xf4\x81\xf5\x9f\xf6~\xf8\x1d\xfa\x85\xfb\x8a\xfc\xe2\xfc\x9a\xfd\x9e\xfe\xb3\xff\x9b\x00\x8b\x007\x00\x04\x00\x1a\x00\xac\x00|\x00\n\x00i\xff\xbc\xfe^\xfe7\xfeE\xfe\xc5\xfd\xe2\xfcB\xfc*\xfcD\xfcV\xfcl\xfcz\xfcW\xfcx\xfc\x01\xfd\xd5\xfd\x81\xfe\xe3\xfeS\xff\x06\x00\xa8\x00)\x01\xbc\x01g\x02\xc5\x02\xdb\x02#\x03}\x03\xa0\x03\xa6\x03\xd1\x03\xf8\x03\xfd\x03\xd1\x03\xc0\x03\xb5\x03\xbc\x03\xe2\x03\xf8\x03\xf0\x03\xbd\x03\x8e\x03l\x03\x7f\x03\x8e\x03v\x03-\x03\xce\x02p\x02A\x02\x0f\x02\xcc\x01k\x01\xf4\x00\x8e\x00\x1d\x00\xd8\xff\x9d\xff;\xff\xe8\xfe\xb8\xfe\x86\xfeV\xfe=\xfe*\xfe\x1c\xfe\x1a\xfe3\xfe\\\xfeo\xfex\xfee\xfe]\xfe\x93\xfe\xc6\xfe\x06\xff(\xffM\xff~\xff\x94\xff\xc4\xffG\x00\xea\x001\x01\'\x01n\x01\xdc\x01v\x02\xef\x02\x89\x03\xa0\x03\x03\x03\x81\x02\x94\x02\xef\x02\xa1\x02\xe9\x01R\x01\xba\x00\xcd\xff\x1e\xff\n\xff\xc7\xfe\xfc\xfd1\xfd\x0c\xfd$\xfd\xd2\xfc\xae\xfc\r\xfdA\xfd\x02\xfd\n\xfd\x87\xfd\xdf\xfd\xe1\xfd\xda\xfd=\xfe\x85\xfe\\\xfel\xfe\xbf\xfe\xd2\xfe\xa6\xfe\xb2\xfe\x14\xffM\xffK\xff9\xff\xa4\xff\xc4\xffo\xff\x81\xff\xca\xff\xe1\xff\x8d\xffa\xfft\xff:\xff\xdf\xfe\xc1\xfe\xd4\xfe\xdb\xfe\xa5\xfe\x81\xfe\x8e\xfe\xd6\xfe\x1d\xffZ\xff\xad\xff\xed\xff\x1f\x00R\x00\x8f\x00\xd6\x00\xf8\x00\x12\x01\x1d\x01\xfd\x00\t\x01\xe1\x00\xc4\x00\xb1\x00\x86\x00\x89\x00l\x00W\x00\\\x00d\x00\x7f\x00k\x00k\x00}\x00F\x005\x006\x002\x00\xfd\xff\xa1\xff\x8b\xffp\xff\xd8\xfez\xfe\xc2\xfen\xff:\x003\x01t\x02T\x03!\x04^\x05c\x07\x8e\tJ\x0b\x91\x0cH\r\x96\r\xe2\r\x1f\x0eV\x0e\x06\x0e\xb0\x0c\xa8\nh\x08\x8a\x06\xc1\x04\x95\x021\x00\xa8\xfd\x1f\xfb\x9b\xf8\xd0\xf6\xfb\xf55\xf5\xfa\xf3\xec\xf2z\xf2\xc4\xf23\xf3\xdf\xf3\x05\xf5\xf6\xf5\x8c\xf6W\xf7\xc5\xf8d\xfaS\xfb\xfc\xfb\x0b\xfd\t\xfe\x97\xfe\x0e\xff\xbc\xffI\x00)\x00\xf7\xff]\x00\xbc\x00x\x00\xfe\xff\xdc\xff\xdc\xffu\xff\x15\xff\x11\xff\xef\xfe\x1f\xfeb\xfdp\xfd\x9e\xfd^\xfd\x0c\xfd\x10\xfd8\xfd\x1a\xfd6\xfd\xe3\xfd\x81\xfe\xa5\xfe\xbf\xfeS\xff\xf7\xffY\x00\xdd\x00k\x01\xb3\x01\xc9\x01\n\x02k\x02\xbe\x02\xcf\x02\xd7\x02\xf1\x02\xeb\x02\xde\x02\xe0\x02\xf8\x02\x08\x03\xf5\x02\xe7\x02\xf4\x02\xe1\x02\xca\x02\xe0\x02\xfa\x02\xed\x02\xcb\x02\xcc\x02\xaf\x02g\x02%\x02\xf2\x01\x9f\x010\x01\xc2\x00Y\x00\xda\xffM\xff\xd3\xfey\xfe\x13\xfe\xac\xfdg\xfd;\xfd\x1c\xfd\x06\xfd"\xfdu\xfd\xb9\xfd\x04\xfek\xfe\xeb\xfeg\xff\xe0\xffe\x00\xe7\x00W\x01\xad\x01\xfe\x01J\x02l\x02i\x02\\\x02Q\x02+\x02\xeb\x01\x96\x013\x01\xc7\x00o\x00\x1e\x00\xcb\xffn\xff@\xff+\xff\xe6\xfe\xd5\xfe(\xff\x98\xff\xa6\xff\xa3\xff\t\x00r\x00\xbb\x00D\x01o\x02)\x03\xd4\x02\x99\x02\n\x03P\x03\xf9\x02\xc9\x02\xf7\x02z\x028\x01e\x00m\x00\xf9\xff\xd7\xfe\x0f\xfe\xd1\xfdC\xfd]\xfc\x12\xfct\xfcC\xfc\xa0\xfb\x97\xfb\x1c\xfcU\xfcD\xfc\x8f\xfc8\xfdt\xfdU\xfd\xbd\xfdq\xfe\x7f\xfeA\xfe\x82\xfe\x02\xff-\xff\'\xffb\xff\xd3\xff\xe3\xff\xd4\xff=\x00\xd5\x00\x16\x01\x16\x01<\x01\x85\x01\xa3\x01\xa9\x01\xc5\x01\xc4\x01\x8e\x015\x01\xfe\x00\xdc\x00\xb1\x00u\x00*\x00\xed\xff\xb7\xff\xa6\xff\xac\xff\xb7\xff\xcd\xff\xd5\xff\x00\x00<\x00v\x00\xaf\x00\xd0\x00\xe8\x00\xee\x00\xfe\x00\x06\x01\xd8\x00\x83\x005\x00\xe3\xff\x84\xff\x13\xff\xaa\xfeE\xfe\xb6\xfd%\xfd\xc6\xfc\x93\xfcS\xfc\xfb\xfb\xcf\xfb\xc6\xfb\xb8\xfb\xca\xfbI\xfcO\xfdj\xfew\xff\xb8\x003\x02\xbb\x03E\x05=\x07q\t<\x0bc\x0cF\r\x1b\x0e\xc4\x0e\xed\x0e\x08\x0f\xd8\x0e\xe4\r.\x0c?\n\xaa\x08\xf1\x06\xcf\x04\x7f\x02/\x00\xd7\xfdi\xfb\x86\xf9]\xf8U\xf7\x13\xf6\xff\xf4|\xf4w\xf4\x97\xf4\n\xf5\xd4\xf5\x8f\xf6\x18\xf7\xd7\xf7\xfb\xf8A\xfa.\xfb\xf8\xfb\xef\xfc\xb6\xfdD\xfe\xcb\xfeW\xff\xb7\xff\xaa\xff\xa3\xff\xdc\xff\xfa\xff\xc3\xff\x82\xffl\xffW\xff\x08\xff\xc9\xfe\xc9\xfe\xa9\xfe4\xfe\xda\xfd\xcf\xfd\xc0\xfd\x9a\xfd\x87\xfd\x91\xfd\xa4\xfd\x9e\xfd\xba\xfd\x18\xfev\xfe\xbf\xfe\x07\xff[\xff\xb3\xff\xf2\xffJ\x00\xa0\x00\xd1\x00\xfd\x00+\x01M\x01g\x01v\x01\x90\x01\xa3\x01\xa1\x01\xa5\x01\xbb\x01\xd0\x01\xe4\x01\xfe\x01(\x02R\x02e\x02\x86\x02\xc7\x02\xe9\x02\xfb\x02\x19\x035\x03&\x03\xf5\x02\xcf\x02\xb9\x02m\x02\x03\x02\xac\x01P\x01\xd0\x00C\x00\xce\xff]\xff\xd6\xfeU\xfe\xff\xfd\xba\xfdw\xfd;\xfd*\xfd9\xfd?\xfd]\xfd\x9e\xfd\xed\xfd3\xfe|\xfe\xe1\xfeM\xff\x98\xff\xea\xffW\x00\xc4\x00\xff\x006\x01\x85\x01\xca\x01\xe4\x01\xf8\x01\x17\x02 \x02\xfe\x01\xf0\x01\xff\x01\xf3\x01\xb6\x01|\x01a\x01A\x01\t\x01\xd5\x00\xb4\x00\x87\x00/\x00\xef\xff\xd8\xff\xc2\xff\x94\xfff\xff^\xffW\xffB\xff1\xffH\xffk\xffi\xffm\xff\x94\xff\xc7\xff\xe3\xff\x05\x002\x00R\x00Y\x00d\x00\x7f\x00\x8c\x00|\x00t\x00m\x00X\x00A\x008\x00\'\x00\xff\xff\xcc\xff\xc5\xff\xb8\xff\x8f\xff\x80\xff\x8f\xff\x82\xffS\xffO\xffm\xffp\xff^\xffh\xff\x95\xff\x96\xff\x89\xff\xa5\xff\xdd\xff\xf1\xff\xe8\xff\xfd\xff5\x00Q\x00Y\x00q\x00\xa7\x00\xc8\x00\xc6\x00\xe8\x00%\x01M\x01V\x01S\x01x\x01\x91\x01\xa1\x01\xc0\x01\xfc\x01\xf5\x01\xb2\x01\x92\x01\x8f\x01h\x01#\x01\xe4\x00\xb2\x00.\x00\xa1\xffD\xff\r\xff\xa7\xfe)\xfe\xd2\xfd\x93\xfd5\xfd\xd7\xfc\xbb\xfc\xca\xfc\xad\xfc|\xfc\x85\xfc\xbb\xfc\xcb\xfc\xc5\xfc\xfe\xfco\xfd\xb5\xfd\xe1\xfdU\xfe\n\xfff\xff\x81\xff\xe1\xffe\x00\xa8\x00\xb8\x00\x03\x01j\x01b\x01\x1f\x01\xfc\x00\xf9\x00\xce\x00\x94\x00u\x00X\x00\t\x00\xa6\xffk\xffd\xff[\xffC\xff\x1f\xff\xf4\xfe\xd4\xfe\xd4\xfe\xe7\xfe\t\xff$\xff.\xff#\xff4\xffd\xff\xa0\xff\xdb\xff\xf4\xff\r\x00,\x00B\x00}\x00\xbd\x00\xfa\x00\x1c\x011\x01M\x01~\x01\xad\x01\xc6\x01\xdb\x01\xf3\x01\xf7\x01\xdb\x01\xdb\x01\xf9\x01\xef\x01\xb5\x01u\x01Q\x01\x07\x01\xaa\x00\x80\x00f\x00\xf9\xffk\xff.\xff"\xff\xf8\xfe\xd2\xfe\x0f\xff]\xffJ\xffN\xff\xce\xfft\x00\xe7\x00y\x01A\x02\xe6\x020\x03\xb3\x03\x8a\x04\x1b\x05J\x05\x8e\x05\xee\x05\n\x06\xe3\x05\xc7\x05\x96\x05\x0b\x054\x04y\x03\xd0\x02\xf8\x01\xdb\x00\xbd\xff\x9c\xfek\xfd`\xfc\x93\xfb\x00\xfbb\xfa\xa9\xf9%\xf9\xdd\xf8\xda\xf8\x10\xf9r\xf9\xd7\xf9.\xfa\xa0\xfa6\xfb\xf7\xfb\xc7\xfc\x8b\xfd=\xfe\xb1\xfe\x1a\xff\x97\xff\x15\x00}\x00\xc9\x00\xfb\x00\x03\x01\xe9\x00\xe7\x00\xf2\x00\xe9\x00\xbd\x00\x83\x00N\x00\x0e\x00\xde\xff\xc7\xff\xb2\xff\x8e\xffW\xffD\xff;\xff7\xff@\xffR\xffR\xffE\xffM\xffY\xffS\xffJ\xffB\xff@\xff7\xff/\xffA\xffd\xff]\xffL\xffT\xffx\xff\x94\xff\xa7\xff\xcd\xff\xf6\xff\xff\xff\xfb\xff#\x00p\x00\x98\x00\x9b\x00\xaa\x00\xc4\x00\xbf\x00\xc2\x00\xdd\x00\x06\x01\x0c\x01\xfc\x00\xfd\x00\x18\x01)\x016\x01M\x01V\x01Z\x01c\x01x\x01\x83\x01\x83\x01\x84\x01n\x01N\x01<\x01&\x01\x02\x01\xd5\x00\xa1\x00i\x00,\x00\xf9\xff\xd9\xff\xbe\xff\x9a\xffx\xff_\xffX\xffW\xff_\xff{\xff\x96\xff\xaa\xff\xbe\xff\xde\xff\x08\x000\x00P\x00q\x00\x88\x00\x94\x00\x9d\x00\xa0\x00\xad\x00\xa3\x00~\x00Y\x009\x00\x1c\x00\x00\x00\xde\xff\xb9\xff\x93\xffy\xffk\xff[\xffT\xffL\xff:\xff3\xff+\xff4\xff@\xffH\xffR\xffQ\xffU\xfff\xff{\xff\x95\xff\xab\xff\xbd\xff\xcf\xff\xde\xff\xf4\xff\x19\x004\x00A\x00R\x00n\x00\x89\x00\x9d\x00\xb2\x00\xce\x00\xde\x00\xe2\x00\xef\x00\x04\x01\x0f\x01\x19\x01\x15\x01\x12\x01\n\x01\xfe\x00\xed\x00\xdd\x00\xc3\x00\x9d\x00v\x00Q\x000\x00\xfe\xff\xca\xff\x98\xffl\xffM\xff#\xff\x01\xff\xdc\xfe\xbe\xfe\xa3\xfe\x9b\xfe\x9d\xfe\x9d\xfe\xa6\xfe\xb3\xfe\xc8\xfe\xe3\xfe\x05\xff&\xffC\xffj\xff\x8e\xff\xa7\xff\xc0\xff\xd4\xff\xed\xff\x00\x00\x01\x00\x1b\x00\x1e\x00,\x004\x00)\x00G\x00i\x00\x8b\x00\xa1\x00\x8c\x00\x9c\x00\x92\x00\xd3\x00P\x01\xe8\x01A\x027\x02[\x02j\x02f\x026\x02/\x021\x02\xb7\x01\'\x01\xac\x00G\x00\xd6\xff=\xff\xb6\xfe0\xfe\x99\xfd\x0e\xfd\x8a\xfcF\xfc\x13\xfc\xe3\xfb\xb2\xfb\x9a\xfb\xc4\xfb\xfe\xfb4\xfco\xfc\xd5\xfc9\xfdz\xfd\xdf\xfdF\xfe\xa7\xfe\xee\xfe\x0c\xffU\xff\x9b\xff\xc7\xff\xeb\xff\xfa\xff\x06\x00\xe5\xff\xbe\xff\xb8\xff\x9e\xff}\xff8\xff\xf4\xfe\xdc\xfe\xb6\xfe\x95\xfe\x88\xfe\x95\xfe\x9c\xfe\x91\xfe\xa9\xfe\xde\xfe\x1c\xffD\xff\x8d\xff\xfa\xff>\x00u\x00\xc1\x009\x01\x91\x01\xc9\x01\x1d\x02{\x02\x90\x02\x90\x02\x9e\x02\xb9\x02\xa6\x02|\x02\x8a\x02\x99\x02\x8f\x02j\x02p\x02\x8b\x02\xa8\x02\xde\x02t\x03g\x04G\x05\xfb\x05\xb4\x06\xaa\x07^\x08\xd0\x08o\t\x0c\n9\n\x0f\n\xd3\t\xa5\t\x0c\t:\x08H\x07.\x06\xd4\x047\x03\xb4\x010\x00\xa8\xfe/\xfd\xc3\xfb\x83\xfa\x8c\xf9\xb4\xf8\x18\xf8\xa0\xf7_\xf7T\xf7L\xf7y\xf7\xce\xf7;\xf8\xbc\xf8O\xf9\x0e\xfa\xca\xfa\x98\xfbg\xfc\xfa\xfc\x84\xfd\xf1\xfdF\xfe\x89\xfe\xa9\xfe\xc3\xfe\xd1\xfe\xbf\xfe\xa3\xfey\xfeH\xfe\x18\xfe\xcb\xfd\x83\xfdA\xfd\x0f\xfd\xd8\xfc\xb7\xfc\xc3\xfc\xd3\xfc\xf8\xfc0\xfdv\xfd\xc6\xfd-\xfe\x9e\xfe\x06\xffk\xff\xce\xff(\x00\x93\x00\xfb\x00J\x01\x9d\x01\xef\x01\x1d\x026\x02E\x02M\x02B\x020\x02\x17\x02\xf9\x01\xda\x01\xc9\x01\xac\x01\x96\x01{\x01[\x01S\x01M\x01F\x01:\x01;\x01B\x01G\x01T\x01[\x01`\x01X\x01P\x01N\x01:\x01\x14\x01\xeb\x00\xbf\x00\x8b\x00N\x00\x0e\x00\xd3\xff\x98\xffQ\xff\x0e\xff\xd2\xfe\xa5\xfer\xfeO\xfe6\xfe&\xfe\x1d\xfe"\xfe5\xfeM\xfel\xfe\x95\xfe\xc1\xfe\xf3\xfe-\xffe\xff\x98\xff\xd3\xff\t\x00I\x00z\x00\xa2\x00\xbb\x00\xcf\x00\xe8\x00\xf1\x00\xf9\x00\x03\x01\xfa\x00\xf4\x00\xe8\x00\xe2\x00\xd6\x00\xbf\x00\xa5\x00\x8b\x00t\x00a\x00J\x00;\x00(\x00\x19\x00\x16\x00\x17\x00 \x00%\x00)\x00<\x00F\x00Y\x00m\x00t\x00\x87\x00\xa0\x00\xa5\x00\xbb\x00\xb0\x00\xb6\x00\xb8\x00\xb0\x00\xb2\x00\x97\x00\x93\x00\x95\x00\x87\x00\x84\x00f\x00?\x00\x1c\x00\xd9\xff\xab\xff\x88\xffg\xffw\xff6\xff\xfd\xfe\x03\xff\xfc\xfe\xef\xfe$\xffk\xff\xa1\xff\x9c\xff\xb8\xff6\x00\xc2\x00\xe8\x01K\x04\xa4\x05\xf7\x05\xd9\x05`\x05\xae\x04\x83\x02v\x01\xe1\x00o\xffH\xfeU\xfd\xe9\xfcH\xfc\xf0\xfa\xcf\xf9d\xf8\xc0\xf6\x15\xf6\x87\xf5\xb6\xf5y\xf6\xf5\xf7\x8f\xf9)\xfa\xb7\xfb\xad\xfd\xa4\xfe\x7f\xfe\xf5\xfe-\x00y\x00\x19\x01\x10\x02\x07\x03q\x03\x15\x03\xab\x03\x14\x04\xa8\x03I\x03\xe3\x01\x11\x01\xa7\x00\xef\xff\xb1\xff\x82\xff\xbe\xffA\xff\xfc\xfe_\xff}\xff\xfa\xfe\x8d\xfe\x88\xfe\xa2\xfe\xf7\xfe\x07\x00\x00\x01\x9f\x01#\x02\xa4\x02\xe0\x03u\x04n\x04m\x04\xd2\x04C\x05\x19\x05}\x05\xcf\x06\xb6\x06\x08\x06\xb8\x05\xa3\x05\xfe\x05\x98\x05\xbe\x05\xd2\x05\xc8\x05\x9a\x05T\x05u\x05#\x05R\x04n\x03\xd6\x02\x96\x020\x02\xb6\x01;\x01\xc2\x00/\x00A\xffi\xfe\xac\xfd\r\xfdJ\xfc\x9b\xfbH\xfbo\xfb\x9e\xfb\x8f\xfbt\xfbZ\xfb3\xfb\x0e\xfb\xe2\xfa\xbf\xfa\xda\xfa\x02\xfbf\xfb\xe8\xfb\xc1\xfcj\xfd\xa4\xfd\xb3\xfd\xb5\xfd\xcc\xfd\xaa\xfd\x9a\xfd\xbd\xfd\x08\xfeP\xfe\x95\xfe\xdf\xfe \xff\x04\xff\x9b\xfe\'\xfe\xcb\xfd\x90\xfdD\xfdT\xfd\xb5\xfd\x19\xfeg\xfe\xc1\xfe8\xffb\xffh\xff\x83\xff\xac\xff\xec\xff0\x00\xb6\x000\x01\x84\x01\xdc\x01 \x02^\x02@\x02\xf9\x01\xb2\x01x\x01J\x01A\x01A\x01\\\x01Q\x016\x01:\x01\xfd\x00\xd3\x00\x8f\x00L\x00\x15\x00\xe8\xff\x00\x00,\x00K\x00g\x00`\x00S\x00M\x00>\x00\x0c\x00\xe5\xff\xcf\xff\xbc\xff\xc4\xff\xdf\xff\x12\x00/\x00,\x00;\x00B\x006\x00=\x00+\x00\x1f\x00\x04\x00\x11\x00E\x00M\x00|\x00\xa0\x00\x8a\x00\x99\x00t\x00U\x00,\x00\xf7\xff\xe9\xff\xb5\xff\xb9\xff\xb0\xff\xb3\xff\xc0\xff\xae\xff\x8e\xffX\xff*\xff\x08\xff\xed\xfe\xb6\xfe\x9d\xfe\xa8\xfe\xae\xfe\x06\xffT\xffw\xff\xae\xff\x9a\xff\xc2\xff\xfc\xff$\x00Y\x00g\x00\x91\x00\xbe\x00 \x01\x8a\x01\x9f\x01\xab\x01G\x01\xd2\x00\xa8\x00S\x00A\x00\x03\x00\xa5\xff\xaa\xff|\xffe\xffy\xffa\xffA\xff\x19\xff\x01\xff!\xfft\xffm\xff\xbc\xffT\x00\xca\x007\x04\x8a\x06p\x07\xfd\x07\xa2\x06\xf5\x05\xbd\x03\xe5\x02\xc5\x02\x03\x02b\x02\xe3\x01\xb7\x01\x8a\x01Q\x00\xdb\xfd\xeb\xfa\r\xf9\x85\xf7\xf9\xf6\xbd\xf7\xa6\xf8\x1e\xfa\x14\xfb\xda\xfb4\xfc\xf0\xfc\xa4\xfci\xfaV\xfb\x1a\xfc4\xfc\xbd\xfe\x00\x00\x19\x01\xfe\x01H\x02\x8c\x02\xa3\x01\xe3\x00\x95\x00\xa3\xff\x8d\xff\xb0\x00,\x01\xb7\x01d\x02\xd1\x02"\x02\xaa\x00\x94\xff\xed\xfeX\xfd\x1a\xfde\xfd\xc5\xfd\xf6\xfd\x15\xff\xc2\xffg\xff\xeb\xff\x8d\xff\x18\xff\x84\xfeg\xff\xe7\xfe\x82\x00Y\x03\xa1\x01\x8e\x03I\x05\xb3\x03\xe8\x02%\x03~\x03\r\x01\x8a\x00\xd0\x03C\x01\x94\xff&\x04l\x02\x90\xff\xa4\x01\xbd\x01\xe4\xfeq\xff\x81\x01\x89\xff\xd3\xff\xc0\x01\xaf\x02\xc2\x01@\x02\xf5\x02\x18\x01\xb4\x00\x84\x02\xf4\x02\x06\x003\x01\xf9\x02\x1e\x005\x00e\x02\xbf\x00\xe2\xfe\xc1\xff\xfe\xff\x8b\xfe9\xff\x9e\x00\x9a\xfe\xb7\xff\x18\x01\x92\xfe\xd8\xff\xce\x01\xa3\xff\xc9\xfd\x83\x00\xd3\x00\x05\xff\xfd\x00\x04\x02*\x00g\x00\xe1\x01\xe9\xff\x1f\xff\x05\x01\xad\xff8\xfdY\x01\x82\x01\x0c\xfd\x9f\xff\xd5\x01\x98\xfe^\xfc\xcf\xff\x83\xfe\xdd\xfaV\xffI\x00\x95\xfb\xfe\xfc]\x00k\xfea\xfd\xbc\xff9\xfe\xb0\xfcZ\xff\xb9\xff\xde\xfd\x92\xff\xa0\x00\x13\xff\xf9\xfe\x1e\x00,\x00\xb0\xfeZ\xffM\xff+\xfe\x17\xff\xcd\xfe\xb0\xff\x11\x00\x03\xffm\xffb\xff\xf1\xfey\xfe\xb8\xfe2\x00\xe2\x00G\xff\x89\x00\xa9\x01\xb8\xff1\x00\xcc\x00j\x00\x9a\x00q\x01\x8d\x00\xfd\x00\x85\x02\x7f\x01\xdd\xff\xab\x00\x00\x00\xbb\xfe\xe0\x00l\x00m\xffD\x00\xe6\x00a\xff4\xfd\xe4\xfeu\xff\x0f\xfc\xc7\xfd\xd9\x02\x96\xfe:\xfdM\x03\xf1\x00\x8b\xfb>\x007\x02)\xfdI\xff\x1c\x05N\x01y\xfeU\x03\xbd\x03\x86\x01\x08\xff\xe8\x02\xff\xfc2\xff\xde\x00\xe7\xff\xe6\xfd\xfe\xfe\x91\x02C\xfc\r\xfeV\x00\xca\xfd?\xfa\x00\xffn\x00\xde\xfb)\xff\xdc\x01\x0b\xfe\xb1\x01e\x01\xb5\xfd\xe0\x00\xaf\x02\xa1\xfd\x9d\xfd\x02\x04\xfb\xffq\xfc\t\x01\xab\x060\xfd(\xff\xaf\x06\x15\xffT\xfc\xbb\x04,\x02\xe9\xfa\n\x03\xb8\x03F\xfd\x9c\xff\xe4\x05\xc4\x00\x08\xff(\x01\x91\x00u\xfft\xfbz\x033\x03\xf7\xfb\xbe\x02\n\x04\x96\x01\xf2\xfe\xe1\x00\x7f\x03\xc8\xfd\x15\xfe(\x01\x96\x020\x01\x19\x02\xe4\x00_\x01\x1e\x01\xd9\xfcS\xfe\xbc\x03\xd6\xfd\xfb\xfc\x9b\x012\x00<\x00}\xfc\xe0\x02\xea\xff\xf2\xfbE\xfe0\x01\xb8\x00C\xfcy\x01\xd0\x00~\xfe\xdc\x01\xc0\x00\r\x01`\xfe_\xfes\x03\x08\xfbT\x01\xc6\x04x\xfb\xc1\xfe\x1c\x05\xbc\xfc=\xfe\xb5\x00\xa1\xfd\xd4\xfd[\xfd\x7f\x01\x85\xfc\x08\x01\xe9\x00\x98\xfc\x05\x00\xdc\x02\x03\xfbR\xfc;\x05\x1e\xff\xa2\xfcF\x02\x17\x03y\xfe]\x01\xbc\x01$\x00\xeb\xfc\x18\x01r\xfd\xcb\x00\xa9\x04\xa4\xfe.\xfeb\x03f\x034\xf9o\x04\xf0\x00\xe8\xfa\x14\xff\x14\x02\xb8\x01\xf9\xfeM\xfd\xde\x03\xf6\x00\xd2\xfa%\x02\x8a\x01\xe0\xfc\xf3\xfc\x82\x03E\x03\x0c\xfen\x02V\x03y\xfd\xcf\xff\x9c\x03\xf2\xfet\x00\xbe\x03\xe6\xfdW\x02\x81\x03\x98\xfa\xe3\x00\xb4\x027\xfe\xfa\xfd\xbf\x03K\x01\x9a\xfa6\x03y\x00\xbc\xfc\x9c\x00p\x01)\xff\xeb\xfe\x07\x03\xa4\xfe?\xfed\x02\xd8\xfa`\x00\xcc\x05\x00\xfb\x9e\x00\x16\x07T\xf9\xf4\xfc\x05\x08\x0e\xfc\xac\xf9\xbe\x07O\xfe\xff\xfb\xfc\x04\xf2\x01\xee\xfc\xe1\xfe\xd9\x03\xdb\xfa\xa1\xfa\xbc\x04"\x07w\xf8\xc3\x01\xe6\x07\x1a\xf8\xec\xfd\xaf\x04o\xfdw\xfb@\x04\x86\x02A\xfc\xf0\x00\xe1\x04\xf0\xfbP\xfc\x96\x01\'\x02\x95\xfb\xb9\x00]\x03\x85\xfe\xa3\x00\x86\xfe\x91\xfd^\x00p\x02\xe5\xfa\xea\x01\xd3\x01:\xfd\x16\x03\xb9\xfe\xda\xfb\x8e\x02z\x00\xc2\xfb\xbf\xfe\x1f\x07w\x01e\xf8\x89\x03E\x05\x96\xf8\xbb\xfe\x04\x08]\xfaz\xfd\xd0\x07W\xfeG\xfe\x18\x05\xa8\xfc\x11\x00\x15\x00\xd7\xfeL\x00\xce\x00{\x01\xa0\xff\x88\x002\x01_\x01\x85\xf9\x98\xff\xd9\x00\xed\xfb|\x01\x9d\x04\xef\xfe\x93\xfc0\x03g\xfe\x96\xfcG\x00\xf7\xff\xe0\xff1\x006\x05\xb2\x00\x1d\xfb\xa1\x02F\x00\x07\xfc_\xff\xc1\x01d\x02+\xff\xff\xffc\x02\x03\xffP\xfd.\xff5\x04l\xfdU\xfa\xb4\x05\xd1\x01X\xfcn\x01\xd8\x03\xac\xfd\x96\xfa|\x06\xe6\xfd\xbf\xf7t\x05\xdd\x01\x90\xfd\xaa\x02l\x03\xdf\xfa\xaa\xfe\xc0\x01\xd1\xfc%\xff\x01\x01\xf7\xfe\x08\x01=\x01\xbd\x01\xa5\xff\x96\xfcQ\x03\xbc\xfe\xeb\xfc6\x00f\x01\xb3\x03N\xfe%\x00\xfc\x06\x83\xfc\x15\xf9[\x07\x11\x01n\xf7\xd7\x03\xe7\x07\xca\xfa\xa5\xfc\x17\x07\xbc\x01\xaf\xf6\xaa\x04\xa7\x02a\xf5(\x05\x99\x04[\xf9\xaf\x03\xb8\x00\xe7\xfb\xab\x02\x82\x02\x11\xfc\xc7\xfd\xd3\x05\x16\xfd)\xffu\x00\xcf\x00\xe4\x03\xd4\xfc\xce\xfex\x02&\x00h\xfe1\xff\xc5\x01c\xff^\xfek\x032\xff\xcf\xfa\xa4\x06;\x01\xbc\xf6\x85\x04\x08\x02\xab\xfc%\xfe\x07\x04\xec\xff\xe4\xfa\xf5\x03s\x00\x1a\xfd\x87\x00\xcd\x01\x94\xfcF\xff\x1d\x02\x12\x011\xfe\x89\x02%\x00\x9f\x00\xc4\xfc\xe5\xfen\x06\xce\xfc)\xfe\xac\x07\xa2\xfe6\xf9\x02\x07q\x00u\xfa%\x01\xa9\x05\xee\xf9\x98\x00\x88\tp\xf7\xde\xfb:\x08\xeb\xfe&\xf7\x9e\x06^\x05d\xf9\x11\xfe\xcb\x07\xa4\xff\xe3\xf7q\x06I\x00v\xf9\xa2\x02j\x04w\xfc\xcc\xfek\x04\x1e\xfe:\xfb\xa5\x04>\x01\x83\xf9B\x02P\x02c\x01_\xfcA\x03\x8f\x02 \xf9\xf7\xff|\x03/\xfd\xca\xfd(\x07G\xfb\xba\xff\x97\x04e\x00d\xf9\x85\xfe\x86\x06^\xfb\xef\xff\xd1\x05\xd1\x00\xb1\xfa\x81\xffy\x04\x06\xfa\x83\xfd\x97\x04\xae\xfe\x99\x01\xd2\x02;\xfb\x13\x02T\x00\xf8\xf9L\x04\xdb\xfe$\xfc\xbc\x05\xdd\x03\xa7\xf6\xf4\x05\xc7\x05d\xf3\xfa\x01\x0e\x06\xf9\xf9\xa1\xfe\x98\x07:\x02\xb0\xfb_\x04\xb7\xfek\xf86\x032\x03\xac\xfei\xfeH\x05a\xfd\x10\xfeB\x05}\xf8\xe6\x01X\x025\xf7\xd3\x06v\x02\x9c\xfbc\xff\xab\x05/\xf9\xaf\xfa~\x08s\x03\xa5\xf5\xcc\x01\xc9\n\xbe\xf4\xf6\xfdM\t.\xfc\x1d\xf8\xb2\x07\xda\x01\xee\xf7L\x03\xc2\x04\xd0\xfcl\xfa\xea\x04\xff\x04q\xf5\xf1\x05\xd8\x03\x04\xf8\xba\x01\x97\x08s\xf8\xec\xfa\x03\x11\xa1\xf5\xa9\xf8Y\rc\x00\x81\xf1\xf0\x08\xd1\x07\xa2\xf6\xd4\xff`\x06?\x02>\xf5Q\x03~\x04\xd0\xfc\x8d\xfd%\x03\xfd\x05j\xf8\xcb\x02\xb2\x02\xbd\xf8\xd6\x01\x1d\x02\x15\x01@\xfc\xe5\x03~\x01D\xfa\xe2\x02}\x04%\xf6n\xff_\n\xb3\xf7\x86\xfc\xd3\x0b(\xfb\xbe\xf7r\n\x84\x01y\xf3+\x05\xf7\x07v\xf1\xb4\x01\xf8\x0fH\xf6\xa5\xf7\x0c\x0e\xca\xfe/\xf46\x06R\x01;\xfb\x8d\xff\xc2\x05\xb2\x03\xe8\xf9D\xff\x91\x06u\xf9b\xf9c\x0c\x97\xfc\xdf\xf5P\n\xce\x06s\xf6\x17\xfd\xe6\n\xe9\xfc\xf5\xf6\x98\x05\x96\x05\x90\xf6\xbc\x00\xb7\x0eU\xf5O\xfd\xbf\x0b\xf2\xf8\x0e\xfd\xd3\x034\x00B\xffM\xffT\x05\xa7\xfe\r\xfb\x8b\x05>\x03\x01\xf7|\xfe\x99\t+\xfb\x81\xfbc\t\xe5\xfd\x97\xf9S\x04x\x03Q\xfa\xd3\xfc\x12\t-\xfd\x9d\xf8\xd4\x03\xff\x07\xa3\xf9`\xfb\xf6\x07-\x01\xda\xf4\x9d\x05\x15\x06q\xf7\\\x02\xea\x05)\xfc(\xf7\x1a\x07\xc7\x05\x1c\xf6\xdd\xff\xf8\n\xb8\xf4\'\xfen\x08\x90\xfc\xb1\xf8{\x01\xf8\t\xca\xf7\x7f\x00r\x07E\xfb\xea\xf8\xc7\x04\xe0\x06!\xf9\x05\x03\x17\x06:\xfb\xb0\xfa\xc8\x06\xe9\x01\xcc\xf8\xd7\x02\xe6\x05\xd5\xfa\x13\x00\xe0\x05{\xf9J\xff\x9a\x04\x0e\xff\xf2\xf8\xf1\x06\xd4\x04\xdc\xf7\x87\x01\xce\x04\x9b\xfa^\xfdS\x05 \xfc\xe8\xfcd\x04\xe2\x04\x8b\xfb\x16\xfb\x84\x06\'\xfc\x97\xfc\xf4\x05\xc8\xfe\xb0\xfc\xc5\x00\xdd\x05z\xfb\xd7\xfcj\x03\xf7\x01\xbf\xfaR\x00{\x04\xa4\xfd\xf9\xfe7\x03\x96\x01\xc1\xf8\xee\x02\xb0\xfe\x83\x01Y\x01\xba\xfa\x0e\x05\x86\xff\xb8\xfa\xee\x06\xdb\xff\xf9\xf2\x80\n\xed\x05G\xf2\xc3\x02+\x0b\xfa\xfa\xe1\xf5\x0b\t\xb9\x038\xf6\x01\x01:\x08g\xfc\xa8\xf7\xac\t\x81\x03l\xf5\xa8\x02\xe5\x08W\xf6\xbe\xfbd\x0bg\xff*\xf6\xd5\x04y\x06\x02\xfbe\xfe\xea\x03\x19\x02G\xf6\xa0\xffK\r$\xf8\x12\xf9\xf3\x0e\x98\xfe\x19\xef\x14\n\xc8\x08w\xf2X\xffs\x08h\x00\x9f\xf8;\x01\xae\x08\xe5\xfb\x93\xf4\xf3\n\xef\x05\x97\xedH\n\x9d\x08\x97\xf4!\xfe\xcd\x071\xff=\xfa\xf7\x04\x80\x00B\xfb\xda\xffi\x05W\xffA\xfc|\x03\xf4\x01?\xfa\x03\x00\x01\x06W\xfd\xb7\xfb\xf2\x05\x9c\x01n\xf9\xee\x02(\x06^\xf5\xb6\xff\n\n}\xfa\xb1\xfc\t\x04A\x04-\xfb{\xfb\x16\x08t\xfc\xab\xf9M\t\xd0\xfd\xd1\xfb\x95\x02:\x07_\xfb\x01\xf9\xb4\x08\xfb\xfa\x1a\xff5\x01\xc3\x00\xde\x02U\xff\xa0\xfd\xf6\x02X\xfe\xfd\xf9\xe2\x05\xb7\x022\xf8\xd3\x03\x9e\x03)\xfb\x9d\x03>\x02\x12\xfb/\xfdI\x04\xd4\xff\x94\xfc\xa9\x01-\x06\xe0\xf9K\xfcF\x0b\xc7\xfd\xff\xf4e\x04\xba\x05\xb5\xf8\x9e\xff\x0c\tE\xfd\xa8\xfb\x86\x03r\x01-\xfa\xd1\xff\xc1\x04a\xff\x14\xfd0\x04\xeb\x03$\xf8\x1b\x04E\xfc*\x00Q\x04r\xfc\xd1\x00X\x02\xcf\x00\x9e\xfc\x03\x01k\x00d\xff~\xff\xe2\xff\xeb\x03c\xff\xe0\xfb-\x04\x9a\xff\xe1\xfc\xa0\xff\xe3\x05&\xfdA\xfb$\x08\x12\xff\'\xfa\x8a\x00x\x07\x9d\xf8\x92\xfe\xd0\x07\x9d\xfb\x8c\xff\xcc\x03\x91\xfd\xb5\xf9\xf9\x06\x08\xfd\xa5\xfa}\x059\x03\x95\xfb\x15\xfe>\x03\x90\x00\xc7\xfct\xfe\xaa\x06\xf3\xfc_\xfe\x94\x04\x7f\x00\xd5\xf9;\x04\xda\x03:\xf9\x95\xfe[\x08\xf1\xfb\x05\xfb\x05\x03\xcb\x03\xad\xfd\xce\xf93\x04\x0e\x02/\xfe\'\xfdx\x06F\xfb$\x00\xb1\x01!\xf8\x95\x04V\x06\x95\xfb\xb4\xfa\xca\x05\x94\x00\x97\xfb\xf0\x00\x84\x04\'\xfd\xdc\xfb\xe9\x05[\x03\xf2\xf8\xd6\xfe\x9a\x07G\xfdJ\xfb\xb5\x04e\xfev\x01\x80\x00\xc9\xfc\xdb\x015\x04\xbd\xfc%\xf9\xbb\x06\xda\x00\x85\xff\x17\xfc\xa0\x03g\xfd\xe6\x01\xea\x04\x15\xf5\xb3\x02\xa9\x03}\x00\x7f\xfaR\x03\xe4\x05\x9c\xf8\x14\xfe\xed\x07z\xfa\x07\xfb\xd5\x07\xd5\x00\x08\xfc\x07\xfc/\x05\xc8\x04\xf6\xf9R\xfa\xb6\x08\x90\xfe6\xf9\x01\x02\xeb\x08\x1c\xfc\xb3\xf3\xdc\x0bi\x05?\xf5~\xff~\tn\xfai\xf7\xb4\nJ\x02\xa4\xf9\x97\x03\x02\x010\xfc\xd8\xfdJ\x03y\x04i\xf9\xe8\xfcV\x0c,\xfb\xab\xf6\r\x0c\xe3\x01M\xf2\'\x05D\x06\x8f\xfap\x00,\x02\x03\x04u\xf8\xc0\x00\xbd\x03\xf1\xfc\x1a\xfef\x01\xd7\x04\xa6\xfa\xc5\x02\xc6\x001\xfd\xd4\xfd\xec\x03M\xfe\xee\xfcx\x04\xb7\x03q\xf8\xb8\xfer\x0bi\xf5\xc8\xfd\x0b\x07[\x01\xb7\xf7V\xff\xe3\x0c\xfb\xf9\xec\xfa~\x02\x19\x03\x94\xfa\xab\xfei\x08-\xfbj\xfc\xb4\x02\x04\x06\xcd\xfb\xaa\xf9k\x08\x88\xfe\x87\xf6W\x05p\x08\x8a\xf8`\xfd\xd9\x07J\xfd\xa1\xfc\x1f\x01\x98\x00\xf9\xfe\xcc\xfe\xde\x00s\x04\xff\xfd\xc5\xfeg\x03#\xfb\xf5\xfe\xe8\x03\xb5\xfc9\x01#\x01\xf7\xff\x99\x03\x94\xfa\xf4\xff]\x05\x12\x00\xbd\xf6a\x03\xe6\x07\xc8\xf9\x87\x00\xfc\x01\xfb\x02\xa6\xfc\xa9\xfb\x9c\x07\xc3\xfd\x94\xf9\xb0\x05\x7f\x05\x15\xf9q\xfc\x8e\t\xaa\xfd\xd2\xf7\x9d\x06\xa0\x02\x17\xf7\xb8\x01~\x08\xf1\xfb8\xfa|\x03R\x05\xa2\xfaQ\xfc9\x06\x8e\x00\x15\xfa\x87\x01{\x02<\x00\x19\xff~\x01\x0c\xfdJ\x03\xf4\xfc\x9b\xff\xb3\x04H\xfc\xa3\x03\x8b\x00$\xfe\xca\xfbm\x01!\x06\xda\xfb\xc3\xfb\x89\x05\x00\x00\xb0\xfb\xd4\xff\xf2\x03d\xfb\xd3\xfb\xdb\x08\x84\xffd\xfa\xcd\x03K\x054\xf7F\xfd\x97\tx\x01e\xfa\xd1\xff\x12\x06l\xfd\xc4\xfc\xaf\x02\x87\x01Y\xfc\xad\x00t\x02\xe3\xff\x88\xffv\xfeL\x01\xf5\xfe\xe9\xfe\xe3\xffG\x03\xa4\xfc\x05\x00I\x03u\xfe\xb2\xfd\x91\xffr\x02\xc1\xfcB\xfd\xde\x04H\x03\xc8\xfaL\x00\xf2\x03-\xf9\xd6\x01\xe6\x06\xbd\xf9Z\xfd\x08\x05\xe1\x02\x9d\xf8\xfd\x02\x03\x05\xf3\xf9;\xfc\xca\x08\x1d\x00\xb5\xf6_\x06\x85\x06O\xf8\x00\xfb\xfe\t\xee\xfd\x9b\xf9\xe4\x03\xc6\x04\xc0\xf9[\xffC\x06\xa6\xfd\xd8\xfa\x81\x02F\x04\xed\xfa\xec\x00\xd9\x02!\xfe\x02\x00\xc7\xfeF\x00^\x017\xfdK\x02\x98\x00p\xfc9\x00\x14\x04\xa4\xff\xf7\xfbX\xff\xb5\x04\x0b\x00\r\xf8a\x05\xb2\x04\xea\xf8n\xfd\xb0\x07\xc3\x00\xd3\xf8i\x02\x9c\x04"\xfb\xcd\xfc\x98\x06q\x00*\xf9w\x03\xa9\x05\x9e\xf8z\xfe\x85\x05J\x00\xa5\xfa\xa0\x00\xd4\x03\xf0\xfd\x93\xfe\xf8\x01l\x00\x8f\xfcg\x00~\x03\x8a\xfdC\xfd\x08\x05\xdc\xfen\xfb\x17\x02\x8b\x05P\xfb3\xfc^\x07{\xfe\xb0\xfa\x90\x02\xb5\x04\x01\xfdX\xfcs\x04\xa7\x01\n\xfcL\x00`\x01\x06\x01\xc6\xfdD\xff\xd6\x04\x02\xfd\xd0\xfeO\x01\x80\x00\r\xff\x9e\xff\x9e\x00\xe8\xff\xd5\x00\xfb\xfd\t\x020\x00\x8c\xfd\xc0\xfe\x0f\x04\xbd\xfe\x19\xfc\xfa\x02\xfe\x03\x02\xfd\xf3\xfa\x9a\x06\xdf\xff\x1e\xfa\xb7\x00.\x05>\xfeb\xfe8\x01K\x00\xa9\xff\x8a\xfeX\x00\xbb\x00}\xffB\x00:\xff\xad\xff\xfe\x02D\x00#\xfcM\x00Q\x03\x15\xfcX\x00\xbb\x03N\xfd\x84\xfd}\x03\xfd\x03H\xfbe\xfc&\x05\xa5\xff\xeb\xfb\x95\x01v\x03\xc5\xfe\x8c\xff\x02\x01\xaf\xfe\x03\x00\x0e\x01\xa5\xff\x98\xfd\xbd\x01\xd0\x03/\xfe\x1f\xfe\xb2\x02\x08\xfe\xce\xffS\x00\x96\x00c\xffY\x00\x80\x01\x16\xff\xe6\xffg\x00\xb2\x00\x17\xfeh\x00\xfb\x01:\xff\xaa\xfd\x0e\x03)\x01\xea\xfc\xec\xff\xfe\x03\xe3\xfc\x82\xfe\x92\x05\x84\xfd\xce\xfb\x0e\x03l\x05u\xfa\x85\xfd\xe2\x05\x8f\xff\xf8\xfb\x0f\x01\xb0\x02\x02\xfc\xe1\xfe\x08\x02\x8f\xff~\xfeI\x01!\x01\x9e\xfd\'\xfe\r\x03\xa9\x01\xaf\xfc\x82\xff\x10\x04\xa1\x00\xf4\xfc\xf4\x01A\x01\xd3\xfeY\xff \x01\x1b\x00\x9c\xfe\xc1\xff\x00\x01\xd0\xfe\xa8\xfe"\x03\xfd\xfdP\xfd`\x01\xa3\x02\xbe\xfe3\xfd\xda\x011\x01\xda\xfc\xdc\xfe\xaf\x02\xbd\x01\x0c\xfe\xb3\xfdQ\x01n\x03.\xfdc\xfe\xb4\x02\x7f\x00\xa6\xfe\xda\x00\x8f\x02~\xfd\x16\xfe#\x02\x98\x02`\xfdh\xfe\x8c\x02r\x01\x11\xfc\x18\x01%\x03\x80\xfd\xa6\xfeN\x00\xef\x01e\xfe.\x01\x18\x00\xfc\xfde\x00\x8c\x01\xc6\xff\x9c\xfe\xdb\x00\xd1\xff\x89\xff\x01\x01\x0b\x01\x81\xff\xfc\xff\x93\xfe1\x00\'\x01\x03\xffA\x01\x1d\x00\xc2\xfe\x03\xff\x03\x01\x9e\x01-\xfe}\xfe\x90\x00\xe9\xfe\x8e\x00\xee\x01\x8e\xff\xd3\xfd\x89\xffg\x01\xec\xffE\x00\xea\xff(\xff,\xff\xa4\x006\x01\xa9\xff\xb3\xff\xcc\x01\xc8\xfd7\xfe\xed\x02+\x00\xc0\xfe\x7f\x00\xf3\xff@\xff\xc5\x01\xd5\xff\xe0\xff\xba\xff)\xff2\x00>\x00\\\x01c\xff\xfb\xffA\x00Y\xfe\x9e\x00\x8f\x01\xba\xfe\xa7\xfe\xe1\x00\xf7\x00\x9d\xffX\xff\xe7\x00\x8a\xff\xd6\xfe\x12\x00\xaf\x00f\x00g\x00f\xff,\xfe\xdf\x00\x89\x01:\xff\x9f\xfe\xde\xffF\x01A\xff\x02\x00\xdb\x00\x94\xff\xad\xfem\xff\x14\x01\xc8\xff\xc0\xff\xd3\x00\x81\xff\xbd\xfe`\x00-\x01s\xff\x8d\xfe\xe5\xff\x91\x00/\x00\x16\x00C\x00-\x00\xe2\xfen\xff\xb7\x00\xb8\xff\xb4\xff\x82\x00\x98\xff\x08\x00U\x00\xcb\xff\x11\x00I\x00V\xff\xfc\xfe\xc1\xffe\x01{\x00\x95\xff\xe7\xff\xdb\xff1\x00\x0e\x00\xd1\x00\xc3\xffb\xff\xde\x00r\x00B\xff\x80\x00\xd1\x01T\xff\xa0\xfe\xae\x00\xf9\x00p\xff\x03\x00\xba\x00\xd4\xfe\xef\xffz\x01T\xff\xb8\xfe\xfb\x00"\x00\xf1\xfeA\x00\x12\x00\xde\xff+\x00Y\x00i\xff*\xff\x0b\x01\x95\x00\xab\xfe\x91\xff\x0c\x01W\x00\x0e\xfe\x8d\x00T\x021\xff\xfb\xfe\x9b\x00S\x00l\xff\xfe\xff\xd4\x00s\x00\xfc\xff\x01\x00\xbe\xff\xec\xff\x87\xff\xd9\xff<\x00i\xff\x00\x00\x7f\xff\x93\x00\xf2\xff\xad\xfe}\x00Y\x00\xd2\xfe\x96\x00E\x01d\xff\xe1\xff\x0e\x01\x1d\x00\x92\xff\x85\x00\x86\x00\xcd\xff\x98\xff\\\x00\xbb\x00Y\xff\x87\xffB\x01J\x00\x9a\xfe\xbd\xff\x19\x01\n\x00\xc0\xfe\xa5\xff\x96\x01?\x00\xa6\xfeU\x00\x95\x00\x06\xff\xca\xff\xad\x00\x91\xff&\x00a\x00\x00\x00 \x00y\x00\x83\x00\x96\xffb\xff\x89\x00^\x00}\x00W\x00\xe8\xfe\x11\x00\xaf\x00\xb0\xff\xb5\xff%\x00\x01\x00\xcb\xffp\x00#\x00L\xff\x10\x00\x9b\x00\xb9\xffv\xff\xf3\xff\xc1\x00\x07\x00\xeb\xff1\x00\xa1\xff\xd7\xff\xd3\x00\xb6\xffL\xff\xa7\x00\xd1\x00\xdd\xff\xb2\xfe\xd4\x00\xd4\x00x\xfe/\xff\xb5\x00\xd1\x00(\xffQ\xff\xc1\x00\x00\x00n\xfe2\x00\xff\x00~\xff\x84\xfe\xb8\x00\x82\x01\x19\xff\xf0\xfe[\x00\xdd\x00\x86\xff(\xff\xfc\x00\x95\x00\xe6\xfe\n\x00\xfe\x00\x14\x00e\xfe\xfd\xff\xcc\x01\xa4\xff}\xfe\x91\x00A\x01\x80\xff\xba\xfej\x00\xe3\x00\xf3\xfe\xd7\xff_\x00\xc3\xff\xfb\xff\x00\x00\xe7\xff\xc3\xff\xae\xff\xff\xff\x08\x00\xf6\xff\x1f\x00\x89\xff\xf2\xff\x9e\x00\x19\x00A\xff\x84\xff\xbc\x00_\x00\xac\xff\x8d\xffM\x00m\x00\xf8\xff\xab\xff\xca\xffX\x00\xc7\xff\xbe\xff\x99\x00\xce\xff\\\xff\x84\x00_\x00<\xff\xa4\xffX\x00\xb6\xffS\xffA\x00;\x00`\xff\xbf\xffq\x00\xec\xffZ\xff\r\x00o\x00\x86\xff\xb4\xff{\x00a\x00\xc7\xff\xff\xff`\x00\xaa\xff\x08\x00\x8a\x00q\xff\x8c\xff\xa5\x00M\x00P\xff\xb3\xff\x95\x00\xed\xff2\xff\x02\x00\xe1\xff\x91\xff\xbb\xff\x99\x00\xf6\xffA\xffj\x00p\x00^\xff\xa4\xff\xdc\x00s\x00k\xff\xce\xff\xc3\x00o\x00\x7f\xff\r\x00\xaa\x003\x00\xb3\xff8\x00h\x00\xfe\xff\xd8\xff\xf7\xff\xf1\xff\xd4\xff\x13\x00-\x00\xf9\xff|\xff\xd5\xffg\x00\xf1\xff\x9b\xff\xbe\xff2\x00\xfb\xff\xd6\xffT\x00B\x00\xb2\xff\x87\xff\xf1\xffa\x00.\x00\xf5\xff\x02\x00\xfe\xff\x14\x00\x1d\x00\x19\x00\x14\x00\xff\xff\xd5\xff\xc1\xff=\x00G\x00\x16\x00\xc5\xff\x9f\xff\xe1\xff\'\x00\x0f\x00\xc2\xff\xba\xff\xdd\xff\x06\x00\x19\x00\t\x00\xd2\xff\x0b\x00\xec\xff\x01\x00-\x00(\x00N\x00\x0b\x00\x0e\x00\x0c\x00^\x00V\x00\x00\x00\xe0\xff\x16\x00N\x006\x00\x02\x00\xff\xff\x13\x00\xf9\xff\xda\xff#\x00;\x00\xfe\xff\xc2\xff\x06\x00 \x00\t\x00*\x00\xe8\xff\x00\x00=\x00\xf4\xff\xdc\xff>\x00M\x00\xf5\xff\r\x00h\x00R\x00\xcd\xff\'\x00\\\x00\xd5\xff\xed\xffB\x00&\x00\xd6\xff\xfe\xff+\x00\xe0\xff\xb2\xff\x10\x00(\x00\xc7\xff\xeb\xff\x0b\x00\xfc\xff\xef\xff\t\x00\x11\x00\xdd\xff\xd3\xff\x16\x000\x00\xcf\xff\xeb\xff5\x00\xea\xff\xf6\xff\x08\x00\xde\xff\xf4\xff\x17\x00\r\x00\xe0\xff\x06\x00!\x00\xe5\xff\xd8\xff\xff\xff\xd1\xff\xbf\xff\xf1\xff\xff\xff\xee\xff\xe8\xff\n\x00\xe1\xff\xf1\xff\xf7\xff\xf6\xff\xda\xff\x12\x00 \x00\xee\xff\xf6\xff:\x00!\x00\xb7\xff\x12\x00\x0f\x00\xf6\xff\x16\x00\r\x00\x1a\x00\x08\x00\x01\x00\x06\x00\x0e\x00\x17\x00\xdd\xff\xdf\xff\'\x00\x05\x00\xf2\xff\xf2\xff\xf9\xff\xea\xff\xe6\xff\x0e\x00\xe2\xff\xcb\xff\x00\x00\x1e\x00\xdc\xff\xdd\xff\x0b\x00\xfa\xff\xee\xff\xdd\xff\xff\xff\xeb\xff\xe9\xff\x05\x00\xdc\xff\xef\xff\x02\x00\xe5\xff\xdf\xff\xf9\xff\x13\x00\xe7\xff\xe1\xff\xd8\xff\t\x00\x11\x00\xcb\xff\xe7\xff\x04\x00\xde\xff\xc4\xff\xf6\xff\x04\x00\xe0\xff\xdf\xff\x1e\x00\xf2\xff\xcb\xff\x1d\x00A\x00\xba\xff\xbe\xff*\x00\x04\x00\xee\xff\xff\xff\x01\x00\xf5\xff\xf1\xff\xfc\xff\x0e\x00\x1b\x00\xf0\xff\xcf\xff\t\x00(\x00\x1a\x00\xf8\xff\xf8\xff\xfa\xff\x05\x00\x0e\x00\xf3\xff\xe0\xff\x00\x00\xf5\xff\xd8\xff\x00\x00\x16\x00\x03\x00\xe7\xff\xd8\xff\xf6\xff\x03\x00\x08\x00\x0f\x00\x00\x00\xf7\xff\x1e\x00\t\x00\x1b\x00\r\x00\xe9\xff\xf7\xff\x0c\x00\x13\x00\x05\x00\x17\x00\xdb\xff\xc5\xff\xe5\xff\x11\x00\xfc\xff\xbb\xff\xeb\xff\t\x00\xe3\xff\xc2\xff\x1a\x00\x1f\x00\xb3\xff\xb9\xff\x04\x00\x16\x00\xdd\xff\xe9\xff\xef\xff\xe1\xff\xf3\xff\xf8\xff\xf6\xff\xfc\xff\xf6\xff\xf0\xff\xf3\xff\x00\x00 \x00\x11\x00\xf5\xff\xf8\xff\x11\x00\r\x00\x16\x00\x0f\x00\x14\x00\xec\xff\x1e\x00)\x00\xf3\xff\x05\x00\xfd\xff\x00\x00\xed\xff\xfb\xff\x13\x00\t\x00\x03\x00\x08\x00 \x00 \x00\xf2\xff\x03\x00$\x00\xf4\xff\x07\x00!\x00\t\x00\xe6\xff!\x006\x00\xec\xff\xf9\xff#\x00\x17\x00\x01\x00\x12\x003\x00"\x00\xf7\xff\x1a\x00<\x00\'\x00\n\x00\n\x00+\x00+\x00\x14\x00\x18\x00=\x00\x12\x00\xf3\xff-\x004\x00\xfc\xff\x10\x00\x1f\x00\xeb\xff\xf4\xff\x14\x00\x16\x00\xe9\xff\xe7\xff\x00\x00\xe5\xff\xf3\xff\x08\x00\xf0\xff\xdb\xff\x01\x00\x19\x00\xf2\xff\xef\xff\x0f\x00\x08\x00\xed\xff\x12\x00\x0e\x00\xf2\xff\x18\x00"\x00\xf4\xff\xef\xff:\x00\x11\x00\xf5\xff\x0f\x00\x08\x00\x15\x00\x04\x00\x07\x00\x02\x00\x07\x00\x11\x00\xff\xff\xf7\xff\xff\xff\x05\x00\xf9\xff\xe2\xff\xfa\xff+\x00\x1a\x00\xf3\xff\xf3\xff/\x00\x1a\x00\xde\xff\xed\xff0\x00\x10\x00\xf5\xff\xf5\xff\x15\x00\x00\x00\xc5\xff\n\x00\xf6\xff\xe4\xff\xf7\xff\xeb\xff\xed\xff\xf3\xff\xf8\xff\xe4\xff\xec\xff\xf8\xff\xe5\xff\xda\xff\x05\x00\x00\x00\xf3\xff\xea\xff\xf7\xff\xfc\xff\xec\xff\x01\x00\xf6\xff\xe6\xff\xe9\xff\xfe\xff\x10\x00\xfa\xff\xef\xff\xf8\xff\x01\x00\xf0\xff\xe1\xff\xff\xff\xf9\xff\xe6\xff\xf3\xff\xf3\xff\xe2\xff\xe3\xff\xf6\xff\xf5\xff\xe4\xff\xea\xff\xf6\xff\xf0\xff\x00\x00\xf8\xff\xf7\xff\x02\x00\xff\xff\xf9\xff\xff\xff\x10\x00\x00\x00\xee\xff\xea\xff\x14\x00\x0e\x00\xe4\xff\r\x00\x13\x00\xd1\xff\xd9\xff\x15\x00\xf6\xff\xd3\xff\xf0\xff\x01\x00\xee\xff\xea\xff\xf6\xff\xf5\xff\xf1\xff\xde\xff\xe7\xff\n\x00\x00\x00\x02\x00\xf0\xff\xf0\xff\x05\x00\x12\x00\xfe\xff\xe4\xff\xfa\xff\x11\x00\xf8\xff\xe9\xff\xfc\xff\r\x00\xe9\xff\xe4\xff\xec\xff\xea\xff\xe0\xff\xf4\xff\x02\x00\xd1\xff\xcf\xff\x00\x00\xff\xff\xd1\xff\xdf\xff\xf6\xff\xe5\xff\xdd\xff\xf1\xff\x0b\x00\x06\x00\xe1\xff\xe3\xff\xff\xff\x18\x00\x08\x00\xf4\xff\r\x00\x1e\x00\n\x00\xfa\xff\x19\x00\x18\x00\x00\x00\xf9\xff\x0c\x00\x19\x00\x08\x00\x01\x00\x01\x00\xfc\xff\xf5\xff\xfd\xff\t\x00\xfa\xff\xeb\xff\xeb\xff\xf5\xff\xf3\xff\xf0\xff\xf0\xff\xe4\xff\xdc\xff\xf2\xff\xf3\xff\xee\xff\x06\x00\x00\x00\xe1\xff\xf5\xff\x12\x00\xf7\xff\xed\xff\x01\x00\x0b\x00\xef\xff\xf7\xff\x17\x00\x05\x00\xf8\xff\xf9\xff\x10\x00\x0c\x00\xfd\xff\xff\xff\x0f\x00\x0e\x00\xf8\xff\x06\x00\x0e\x00\xff\xff\x0f\x00\x15\x00\x0b\x00\r\x00\x15\x00\n\x00\t\x00\x16\x00\x16\x00\x0e\x00\x06\x00\x13\x00\x18\x00\x11\x00\x0f\x00\x0f\x00\x12\x00\x16\x00\x16\x00\x1a\x00\x1b\x00\r\x00\x1d\x00"\x00\x17\x00\x15\x00$\x00\x17\x00\x05\x00\x1b\x00\x19\x00\x13\x00\x17\x00\x12\x00\x10\x00\x10\x00\x1b\x00\x1d\x00\x02\x00\xfc\xff\x1c\x00\x18\x00\x04\x00\x0c\x00\x0e\x00\xfe\xff\n\x00&\x00\xff\xff\xf3\xff\x1a\x00\x0c\x00\xe5\xff\x07\x00\x16\x00\xe5\xff\xf5\xff\r\x00\xee\xff\xf1\xff\x10\x00\xf9\xff\xf2\xff\t\x00\x11\x00\xf5\xff\xfa\xff\x0b\x00\xf7\xff\xf8\xff\xfc\xff\x03\x00\x05\x00\x03\x00\xfc\xff\x00\x00\x13\x00\x01\x00\xf4\xff\xf7\xff\x03\x00\xfc\xff\xff\xff\xfc\xff\xff\xff\xf0\xff\xff\xff\x11\x00\xf4\xff\xee\xff\x05\x00\xfe\xff\xee\xff\x04\x00\x01\x00\xf0\xff\xf6\xff\x07\x00\xf8\xff\xed\xff\x00\x00\xf7\xff\xed\xff\xfe\xff\xff\xff\xf2\xff\xf9\xff\t\x00\x06\x00\xed\xff\xfd\xff\x06\x00\xf5\xff\xf8\xff\x00\x00\xf7\xff\xf8\xff\xf3\xff\xf3\xff\x00\x00\xf6\xff\xe9\xff\xfe\xff\xfb\xff\xe4\xff\xe9\xff\x00\x00\xf7\xff\xe8\xff\xf0\xff\x04\x00\xf8\xff\xe9\xff\xfd\xff\xf7\xff\xf3\xff\xf2\xff\x04\x00\xfa\xff\xf9\xff\xff\xff\xfb\xff\xf6\xff\x03\x00\x02\x00\xf1\xff\xf9\xff\xfc\xff\xed\xff\xe8\xff\xf4\xff\xe1\xff\xee\xff\xef\xff\xe3\xff\xe7\xff\xef\xff\xe1\xff\xe3\xff\xf5\xff\xea\xff\xe4\xff\xf1\xff\xf3\xff\xe8\xff\xe9\xff\xf2\xff\xee\xff\xf0\xff\xf9\xff\xee\xff\xec\xff\xfe\xff\xf0\xff\xee\xff\x01\x00\xfc\xff\xe8\xff\xf4\xff\x05\x00\xf0\xff\xe6\xff\x04\x00\x05\x00\xf6\xff\xf5\xff\n\x00\xfc\xff\xf1\xff\x04\x00\xfd\xff\xeb\xff\xf9\xff\x02\x00\xf3\xff\xf9\xff\x00\x00\xeb\xff\xed\xff\t\x00\xfc\xff\xe4\xff\xff\xff\x10\x00\xea\xff\xeb\xff\x11\x00\xfc\xff\xe2\xff\x02\x00\x12\x00\xf3\xff\xf7\xff\x0b\x00\x00\x00\xf7\xff\x07\x00\x01\x00\xfe\xff\x05\x00\x04\x00\x00\x00\xfe\xff\x04\x00\x07\x00\xfb\xff\x02\x00\x08\x00\x00\x00\x01\x00\x01\x00\x04\x00\xfd\xff\xfc\xff\x04\x00\xff\xff\xfb\xff\x04\x00\x04\x00\xfc\xff\xfc\xff\x07\x00\xf9\xff\xfc\xff\x11\x00\x01\x00\x00\x00\r\x00\x0c\x00\xfc\xff\xfa\xff\x07\x00\x06\x00\xfd\xff\x00\x00\x04\x00\x04\x00\xf7\xff\x02\x00\x05\x00\xf5\xff\xf7\xff\x07\x00\x04\x00\xf6\xff\x00\x00\x10\x00\x0c\x00\x00\x00\n\x00\x11\x00\x00\x00\xff\xff\x1a\x00\x0e\x00\x04\x00\x1a\x00\x0c\x00\x08\x00\x12\x00\x10\x00\t\x00\x0b\x00\x0e\x00\x0f\x00\n\x00\x0c\x00\x0f\x00\x0c\x00\x0c\x00\x13\x00\n\x00\t\x00\x17\x00\x12\x00\x0c\x00\x13\x00\x15\x00\x08\x00\r\x00\x17\x00\x05\x00\x04\x00\x14\x00\x07\x00\xff\xff\x10\x00\x11\x00\x02\x00\r\x00\x1b\x00\x01\x00\xfd\xff\x17\x00\x13\x00\xfe\xff\x07\x00\x11\x00\x04\x00\x01\x00\x0b\x00\x06\x00\xfa\xff\x04\x00\x05\x00\xfe\xff\xf8\xff\x05\x00\x0b\x00\x02\x00\xf8\xff\xfc\xff\x13\x00\x02\x00\xf7\xff\x02\x00\x03\x00\xf7\xff\xff\xff\x00\x00\xf6\xff\xf7\xff\x02\x00\xff\xff\xf6\xff\xfb\xff\xfe\xff\xf7\xff\xf4\xff\x00\x00\xfb\xff\xf2\xff\x00\x00\x03\x00\xfc\xff\xff\xff\x03\x00\xfc\xff\xf9\xff\x04\x00\x00\x00\xfc\xff\x01\x00\x06\x00\x05\x00\xfc\xff\x00\x00\x05\x00\xfd\xff\xff\xff\x08\x00\xfd\xff\xf9\xff\x00\x00\x03\x00\xf7\xff\xf0\xff\xfb\xff\xfd\xff\xf4\xff\xf0\xff\xf2\xff\xf4\xff\xf1\xff\xf0\xff\xf7\xff\xf2\xff\xf1\xff\xfb\xff\xfd\xff\xf4\xff\xf9\xff\x00\x00\x01\x00\xf7\xff\xfc\xff\x07\x00\xf7\xff\xf0\xff\x05\x00\x03\x00\xef\xff\xfc\xff\xff\xff\xef\xff\xf2\xff\xfa\xff\xf1\xff\xf0\xff\xf6\xff\xfb\xff\xf5\xff\xf2\xff\xf7\xff\xf6\xff\xf3\xff\xf6\xff\xf6\xff\xf7\xff\xf9\xff\xf5\xff\xed\xff\xf3\xff\xf3\xff\xf6\xff\xf4\xff\xf4\xff\xf5\xff\xf2\xff\xf3\xff\xf9\xff\xf7\xff\xef\xff\xf6\xff\xfa\xff\xee\xff\xef\xff\xfb\xff\xf9\xff\xf2\xff\xfa\xff\xfd\xff\xf5\xff\xf3\xff\xf7\xff\xf8\xff\xef\xff\xf8\xff\x00\x00\xf9\xff\xf2\xff\xf7\xff\xfd\xff\xf5\xff\xf8\xff\xfc\xff\xfa\xff\xf4\xff\xfd\xff\x01\x00\xf1\xff\xf5\xff\x01\x00\xfb\xff\xf2\xff\xfd\xff\xff\xff\xf3\xff\xee\xff\xfe\xff\xfd\xff\xf1\xff\xfd\xff\xff\xff\xf6\xff\xf8\xff\x00\x00\xf5\xff\xf4\xff\x00\x00\x04\x00\xf9\xff\xfb\xff\x07\x00\x00\x00\xf9\xff\x02\x00\n\x00\xfc\xff\xff\xff\x0b\x00\t\x00\xfc\xff\x07\x00\x0f\x00\xfc\xff\xf9\xff\x08\x00\t\x00\xfe\xff\xff\xff\x02\x00\x01\x00\xfd\xff\x07\x00\x02\x00\xfb\xff\x05\x00\n\x00\x01\x00\xfd\xff\x03\x00\x0b\x00\t\x00\x04\x00\n\x00\n\x00\x03\x00\x02\x00\x03\x00\x02\x00\x02\x00\x03\x00\x00\x00\xff\xff\x00\x00\x00\x00\xfd\xff\xfb\xff\xff\xff\xff\xff\xfc\xff\xfa\xff\xff\xff\x06\x00\x03\x00\x00\x00\x00\x00\xfc\xff\xfa\xff\xfb\xff\xfd\xff\xf5\xff\xf8\xff\x01\x00\xff\xff\xff\xff\x00\x00\x03\x00\x02\x00\x00\x00\x02\x00\x06\x00\x04\x00\x06\x00\n\x00\x0b\x00\x0b\x00\r\x00\x10\x00\x0c\x00\x13\x00\x17\x00\x14\x00\x0e\x00\x17\x00\x13\x00\n\x00\x11\x00\x0e\x00\x06\x00\x06\x00\x11\x00\x0c\x00\x0b\x00\x13\x00\x12\x00\x08\x00\n\x00\x12\x00\x0c\x00\x06\x00\x05\x00\t\x00\x07\x00\n\x00\x06\x00\x00\x00\t\x00\x05\x00\x00\x00\x00\x00\xfe\xff\xfa\xff\xfe\xff\x02\x00\x00\x00\x00\x00\x02\x00\x03\x00\x01\x00\xff\xff\xfd\xff\xfb\xff\xf8\xff\xfd\xff\xfb\xff\xf9\xff\xfc\xff\xfc\xff\xf4\xff\xf1\xff\xfb\xff\xfd\xff\xf7\xff\xfc\xff\x00\x00\xf7\xff\xfb\xff\xff\xff\xfb\xff\xf9\xff\xff\xff\xfe\xff\xfb\xff\xfd\xff\xfe\xff\xf9\xff\xf9\xff\xfc\xff\xff\xff\xfc\xff\xfa\xff\xfc\xff\xfe\xff\xfd\xff\xfa\xff\xfa\xff\xfb\xff\xfb\xff\xfb\xff\xfd\xff\xfd\xff\xf9\xff\xf8\xff\xfb\xff\xfa\xff\xfc\xff\xff\xff\x01\x00\xfc\xff\xf7\xff\x05\x00\x01\x00\xfd\xff\x03\x00\x03\x00\xfd\xff\xfd\xff\x00\x00\xfb\xff\xf8\xff\xfd\xff\xfe\xff\xfc\xff\xfd\xff\xfb\xff\xfa\xff\xf5\xff\xf2\xff\xf5\xff\xf9\xff\xf4\xff\xf5\xff\xf6\xff\xf3\xff\xfa\xff\xf3\xff\xf5\xff\xf7\xff\xf3\xff\xf2\xff\xf5\xff\xf2\xff\xef\xff\xf1\xff\xf4\xff\xf5\xff\xf1\xff\xf6\xff\xf6\xff\xf0\xff\xf4\xff\xf7\xff\xf5\xff\xf3\xff\xfa\xff\xfb\xff\xf4\xff\xf7\xff\xf8\xff\xf8\xff\xf7\xff\xf8\xff\xfd\xff\xf7\xff\xf5\xff\xf5\xff\xf5\xff\xf6\xff\xf2\xff\xf3\xff\xf5\xff\xf7\xff\xf9\xff\xf6\xff\xf6\xff\xfa\xff\xfd\xff\xf9\xff\xfa\xff\xfe\xff\xfe\xff\xfc\xff\xff\xff\xfe\xff\xfa\xff\xfc\xff\xfb\xff\xfb\xff\xf9\xff\xfc\xff\xfa\xff\xf6\xff\xfc\xff\xfb\xff\xfd\xff\x00\x00\xfe\xff\xfa\xff\xf9\xff\xfc\xff\xfd\xff\xfe\xff\xfa\xff\xfb\xff\xfe\xff\xfc\xff\xf9\xff\xfb\xff\xfa\xff\xfe\xff\xfe\xff\x00\x00\xff\xff\xfe\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x03\x00\x01\x00\x02\x00\x04\x00\x04\x00\x04\x00\x02\x00\x01\x00\x00\x00\x01\x00\x03\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x02\x00\x02\x00\x02\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x04\x00\x04\x00\x03\x00\x06\x00\x05\x00\x06\x00\x04\x00\x07\x00\x07\x00\x03\x00\x04\x00\x06\x00\x06\x00\x04\x00\x06\x00\x03\x00\x00\x00\x01\x00\x04\x00\x04\x00\x04\x00\x08\x00\x07\x00\x06\x00\x07\x00\x04\x00\x03\x00\x06\x00\x06\x00\x04\x00\x04\x00\x06\x00\x08\x00\t\x00\x0b\x00\n\x00\x0c\x00\r\x00\x0c\x00\x0b\x00\x0b\x00\x0c\x00\x07\x00\x05\x00\x07\x00\x06\x00\x03\x00\x04\x00\x04\x00\x02\x00\x03\x00\x03\x00\x06\x00\x03\x00\x00\x00\x01\x00\x07\x00\x07\x00\x05\x00\x06\x00\x06\x00\x06\x00\x06\x00\x05\x00\x04\x00\x01\x00\x03\x00\x04\x00\x02\x00\x00\x00\x00\x00\x02\x00\x01\x00\xfe\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfd\xff\xfc\xff\xfb\xff\xfe\xff\xff\xff\xfd\xff\xfc\xff\xfd\xff\xfd\xff\xff\xff\xfe\xff\x01\x00\x00\x00\xff\xff\xfd\xff\xfa\xff\xfb\xff\xfb\xff\xfe\xff\xfd\xff\xfc\xff\xfd\xff\xfb\xff\xfa\xff\xfb\xff\xfa\xff\xf9\xff\xfa\xff\xfa\xff\xfc\xff\xff\xff\x00\x00\xff\xff\xfc\xff\xfd\xff\xfd\xff\xfa\xff\xf5\xff\xf3\xff\xf8\xff\xf7\xff\xf6\xff\xf6\xff\xf5\xff\xf4\xff\xf6\xff\xf6\xff\xf4\xff\xf4\xff\xf7\xff\xf8\xff\xf7\xff\xfa\xff\xf6\xff\xf3\xff\xf3\xff\xf5\xff\xf6\xff\xf7\xff\xf8\xff\xf7\xff\xf9\xff\xf8\xff\xf5\xff\xf6\xff\xf9\xff\xf9\xff\xf9\xff\xfa\xff\xf8\xff\xf9\xff\xfb\xff\xff\xff\xfe\xff\xfc\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xfb\xff\xf8\xff\xfa\xff\xfb\xff\xfa\xff\xf6\xff\xf4\xff\xf3\xff\xf6\xff\xf6\xff\xf8\xff\xf8\xff\xf5\xff\xf7\xff\xfb\xff\xf9\xff\xf8\xff\xfa\xff\xfd\xff\xfe\xff\xf9\xff\xfc\xff\xfa\xff\xf9\xff\xf8\xff\xf5\xff\xf4\xff\xf4\xff\xf1\xff\xf2\xff\xf4\xff\xf4\xff\xf5\xff\xf3\xff\xf5\xff\xf3\xff\xf6\xff\xf8\xff\xf8\xff\xfa\xff\xf9\xff\xfb\xff\xfc\xff\xfe\xff\xfc\xff\xfb\xff\x00\x00\x00\x00\xfb\xff\xfd\xff\xfe\xff\xf9\xff\xfe\xff\xfc\xff\xfa\xff\xfe\xff\xfd\xff\xff\xff\x00\x00\x00\x00\xfe\xff\xff\xff\x01\x00\x02\x00\x00\x00\x01\x00\x02\x00\x01\x00\x02\x00\x03\x00\x04\x00\x05\x00\x01\x00\x03\x00\x05\x00\x05\x00\x04\x00\x02\x00\x03\x00\x02\x00\x03\x00\x02\x00\x01\x00\x03\x00\x03\x00\x02\x00\x03\x00\x03\x00\x03\x00\x02\x00\x03\x00\x03\x00\x02\x00\x04\x00\x07\x00\x03\x00\x04\x00\x06\x00\x06\x00\x07\x00\x03\x00\x03\x00\x02\x00\x04\x00\x07\x00\x05\x00\x02\x00\x03\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfc\xff\x00\x00\xff\xff\x00\x00\x01\x00\x02\x00\x02\x00\x02\x00\x05\x00\x03\x00\x05\x00\x04\x00\x06\x00\x08\x00\x08\x00\t\x00\x08\x00\x06\x00\x05\x00\x05\x00\x03\x00\x05\x00\x07\x00\x08\x00\x06\x00\t\x00\x07\x00\x05\x00\x06\x00\x04\x00\x04\x00\x04\x00\x02\x00\x02\x00\x00\x00\xff\xff\x01\x00\x01\x00\x04\x00\x05\x00\x04\x00\x03\x00\x05\x00\x05\x00\x05\x00\x06\x00\x06\x00\x06\x00\x05\x00\x04\x00\x04\x00\x07\x00\x05\x00\x04\x00\x05\x00\x04\x00\x02\x00\x03\x00\x05\x00\x05\x00\x03\x00\x03\x00\x02\x00\x00\x00\xfe\xff\xf8\xff\xfa\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\x00\x00\xfe\xff\x00\x00\x00\x00\xfe\xff\xff\xff\x00\x00\x02\x00\x03\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00\x03\x00\x05\x00\x05\x00\x04\x00\x02\x00\x01\x00\x00\x00\xfb\xff\xfd\xff\xfd\xff\xfa\xff\xfd\xff\xff\xff\xfd\xff\xf9\xff\xfc\xff\xfd\xff\xfb\xff\xfb\xff\xf9\xff\xf9\xff\xfb\xff\xfa\xff\xf8\xff\xf8\xff\xf9\xff\xf9\xff\xfa\xff\xf7\xff\xf8\xff\xf9\xff\xf7\xff\xf8\xff\xf7\xff\xf7\xff\xf8\xff\xfa\xff\xfa\xff\xf8\xff\xf6\xff\xf6\xff\xf5\xff\xf8\xff\xf7\xff\xf4\xff\xf7\xff\xf5\xff\xf8\xff\xfb\xff\xf9\xff\xfb\xff\xfc\xff\xfa\xff\xf7\xff\xf9\xff\xfa\xff\xfb\xff\xfc\xff\xfc\xff\xf9\xff\xf7\xff\xf6\xff\xf6\xff\xf3\xff\xf0\xff\xf1\xff\xf1\xff\xf1\xff\xf3\xff\xef\xff\xee\xff\xf1\xff\xf3\xff\xf3\xff\xf1\xff\xf2\xff\xf3\xff\xf3\xff\xf4\xff\xf6\xff\xf4\xff\xf7\xff\xf5\xff\xf6\xff\xf6\xff\xf7\xff\xf7\xff\xf8\xff\xf6\xff\xf6\xff\xf6\xff\xf5\xff\xf3\xff\xf8\xff\xfc\xff\xfc\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc9\x00\xb5\x00\x9d\x00/\x00\x16\x01F\x01\xcb\x00#\x01\xc3\x00p\x00\xe7\x00\x9f\x01\x87\x01\t\x01\xd3\x00\xa0\x01`\x00\xc2\xfe9\x00\xae\x01\x9f\x00\xc5\xff\xfe\xff\x85\xff\xea\xfe\xf3\x00;\x00o\xfdQ\xfd\xd3\x00U\x01\xdb\xfeh\x00:\x02\x9d\xffb\xfe\t\x00<\x00\xbb\xffk\x02\r\x01\xd9\xfe5\x00\x7f\x01\x9f\x00\xa5\xfe`\xfe\xcc\xfd\x91\xfe\x9e\xff6\x00\x83\xfed\xfe\x13\xfe\x9c\xfd\xa4\xfe}\xff\xf6\xfe\xb4\xfd\xe9\xff\xff\x00\xd6\xff\xc9\x00L\x01#\x00\xf0\xff\xa4\x00\x16\x02\xfd\x01N\x02\xba\x02\xf1\x01\x9c\x01\xa7\x01,\x02\x9c\x01\x86\x01w\x01\x9f\x01\xca\x01\\\x01\xe4\x00\x9f\x00\xcc\xffh\xff\xa8\xff\xc0\xff\xc3\xffE\xff\xae\xfeE\xfe$\xfe \xfeT\xfe|\xfe!\xfe\x99\xfe\x83\xfe\x16\xff\xbe\xfe\xaf\xfd+\xff\xa9\xff\r\xff\x81\xff\xde\xff\xf9\xfe4\xff\x06\x00a\xffF\xff\x85\xff@\x00J\x00u\x00\xae\xff\xb0\xfe\x9e\xff\x9d\xffP\xff\x82\x00\x1c\x00\xbc\x00 \x01P\xff\x88\xfe\xeb\xfd\xc4\xff\xe3\xffB\x00\xd7\x01\x92\xffr\xfe\x08\x00$\xff&\xfe"\xffe\xffT\xfe\xc5\x00\xbd\x02\x13\x00\xb1\xff7\xff\xcf\xfe\x17\xff\xd9\xff~\x020\x026\xffS\x01\xd0\x01\xaf\xffE\xff\xa9\x00\x8d\x02\xaf\x00#\x00\xe3\xffa\x02n\x02\xdc\x01\xe4\x01\xbf\xff\xc1\xfe\xcc\xfew\xffM\x00#\x02\x82\x02.\xff}\xfc\x13\xff\x03\x01\xbc\xfe\x0b\xfcq\xfb\xc3\xff\xa8\x02\xb2\xfd\xa9\x00\xc1\x00\xd8\xfb\x0e\xfc\xad\xff\x0e\xff\x9b\xfb\xcb\x00\xcc\x01\x82\x00u\x00\xc2\x00F\xff9\xfb\xa2\xfd\xe0\x01\xca\x01\xb8\x00\x8f\x02\xb7\x04\xa7\xff+\xfe\xe0\x01\xfc\x00\xbf\xfc\x80\x00R\x04G\x03|\x01\xa6\x01u\x02\x0f\xfe\xc6\xfe\x1f\x01\x08\xfe\xe4\x00>\x04\x18\x03\x93\xff\xc2\xfeA\x01R\xfd\xdd\xfd=\x003\xff!\x00k\x026\x00\xc1\xff\xe3\x01\x1e\xfco\xfc\xe2\x00"\x01u\x02s\x03*\x01\x17\x00u\xfc\xe9\xfb\xbe\x02\xbb\x02\\\xfe\xfd\x00l\x029\xfe\xd9\xfcl\x00x\xffs\xfe\xb8\x02\xa2\xff\x93\xfd\xb9\x04\xaa\x03\x9c\xfcc\xfe_\xfe2\xfe\x93\x02\xc2\x01\xdf\x01\x0f\xfek\xfb\xf5\xfe\x13\x01\xc3\x00\xf9\xfb\xff\xfe\x8e\x00\x06\xfe\x12\x03\x8a\x02s\x00\xcb\x01\xc8\x00a\xfe>\xff\x86\x02=\x08\xee\x04r\xfd\x9f\xfb\xc0\xfb\xe4\xfd\xde\x01\x16\x05\xa9\x00\x92\xfc\xc4\xfa;\xfc\xda\x01\t\x01`\x01\x16\xfd<\xfd\xbc\x024\x01\xf5\xfe\x00\x02\xad\xfe\x84\xf8C\xfd\x80\x03;\x03%\x02\xbd\x03\x06\xfch\xf9\x00\x03U\x05\xe5\x00\x1a\x02P\x03E\x01C\x02\x1b\xff\x8f\xff\x8b\xfd\x08\xfd\xe1\xff/\xfcg\xffA\x03\xf2\xff\x9a\xfa\xc2\xfa\xee\xfa\x1f\xfbR\x02\xb3\x06D\xfe\x11\xfc\xec\x00\x03\x01H\x04R\x01i\xfd\x8c\xfdl\x03\xdd\x03/\x04!\xff\x9a\xfe\x83\x03\xd3\x01\xce\x04@\x01\x15\xfa\xae\xfb\xef\x01\x12\x03\xc2\x02\x9f\x02\xb0\x02\xe7\xfbg\xfb\x15\xff\x1e\xffu\xff`\x005\x01_\xfec\x01\x92\x03X\x04\xc9\xfd\x8b\xfdN\xff\x04\xfc\x05\x02\xfe\x04\xf6\xff\x7f\x00B\xfe\x05\xfe\xa1\xff\x0f\xfa\xb3\xfd&\xff6\xfc\x02\x02Y\x04J\x02\x9c\xfe\x1c\xfdf\xff~\x01\xbf\x00\xff\xff\x98\x00\xc6\x03d\x06\xce\x00\x89\xff\x1d\xfe$\xfc\xa0\x01\n\x07\xc2\x01\xf5\xf8\xf7\xfd\x03\xfe{\xfd\xaa\x02\xc2\x01\x84\xf8\xaa\xf4\xca\xfb\xba\xfc\xac\xfe\xca\x02\x00\xff\x9b\xfb\x9e\xfd\xcc\x00\x92\x01\xaa\xfd\x14\x00\xf8\x04\xb1\x03\xe7\x02_\x05\xe2\x007\xfb\x10\x04l\x03?\xfd\xb1\xfe\xd6\x03C\x05\xb0\x02\xc3\xfc\xeb\xfaf\xfe(\xfeU\x02\x86\x03\x86\xfd\xc6\xf7V\xfcV\xfe`\x03\x11\n\x1b\xfc\xdd\xf28\xf9\xe2\x01$\x02\xe4\x05\xc5\x01$\xfaA\xfa4\x06\xba\x06)\xfe\xf4\xfb8\xfd\x1b\x04\xda\x04^\x05\xd3\x03I\x03t\x03`\x03M\x04Y\r\x00\x03\xa8\xf4\xdd\xfft\x00:\xfd\xdf\x00\xa1\x01`\x05f\xfc@\xf3{\xf0\xc2\xf3\x9f\x00\x99\x03\x15\x05\xfd\xfb{\xf4\xfe\xf9\xbf\x03\xc7\n-\x07\xe8\x01#\xf8\xe9\xff\x88\x0e\xfa\x053\x03\n\x03\x93\x05\x7f\x03\x8b\xfc\x03\x02\x89\x01m\xfdF\x00-\xfd\xed\xfbX\xfd\xe2\xff\xad\x05f\x008\xf2\xa5\xed\x15\xfa#\x08\'\tm\xfd\xd4\xf9\xbd\xfe1\xff\x7f\x046\x05\xf3\xf9\x15\xf7]\x01\xe0\x0bT\n\xc4\x03\x9d\x011\xfe\x89\xfa\x96\x02u\n\xea\x01\xe1\xfac\xfa"\x05\x90\x04\xa9\x00K\xfe\x98\xf4\xa6\xf4\xb2\xfa\xde\x01f\x02\xed\xff)\xff\x06\xfe4\xff:\xff(\xff^\xfe@\x01x\x06\t\xffB\x01R\x04\xb8\x03\xde\x03J\xfc\xd4\xfb\xf2\x00\x05\x06\xc8\x02d\x06\x10\x03\x9d\xfa,\x00\x89\x06M\x00\xbd\xf6\x1f\xfd\xdc\x02G\x02\xc5\xfbI\xf6\\\xfd*\x03\x95\xfe^\xfa\x0b\xf9)\xfcl\x07\x82\x08v\x03\x87\xfeq\xff\x88\xff\xf3\xfdh\t\x87\x0bz\x00d\xfd\xe4\xff\x16\x02\xfe\x03M\x03\x7f\x01\xff\xfa\x85\xf4\xdf\xfc\x93\x05\xd8\x00"\xfb\x10\xfb\xa4\xfd\xda\xfd\xd4\xfeF\xfa\x00\xf8M\xff=\x04\xda\x05\x8c\x01\xe8\xfd\xed\xfb\xf9\x01^\x05{\x04S\x00\xfc\xfb\x15\x02\x9e\x03\xde\x01g\x03A\xfe\x9c\xfb\xb4\x00,\x00\xa6\xffp\xfe\xc7\xfb\xe7\xfd\xd3\xfe\xdc\x00\xba\x00[\xfd\xde\xfeG\xffF\xfc\xe8\x00\xb8\x02\xa0\x01L\x02n\xff\xf9\xfd\xd4\x00#\x02\xdf\x05\x80\x01\x8e\xfd\x17\x00Q\xff\xc1\x03\xe7\x03\xa5\xfe\x04\xfdo\xfec\xfc\x1a\x00R\x00\xab\xfe\x9b\xfc\xb1\xf8\xe5\xfdh\x01K\x02\x92\xfe\xb5\xfa\xff\xfce\xfe\x8c\x02n\x0by\x04\xd7\xfc0\x01\xa3\xff,\x003\t\xa6\x05\x14\xffU\x00J\xfd\n\x00y\x04\x04\xfe\xc5\xfb,\xfa+\xfe\\\x03\xe8\x00\xf1\xfbh\xfa5\xfdb\xfdU\x00d\x01\x1d\x00\x91\xfa\x8b\xfcG\x02\x16\x02\xf1\xffT\xfc*\x02\xec\x03m\x00\xb9\xfe\xe8\xfd"\x01\xc5\x08\xdc\x05\xdf\xf8\x11\xfb\xfb\x03s\x05\xcb\x04\xe0\xfdy\xf8\xd3\xfa5\x07\x86\x08m\x00H\xfc\xa6\xf9\xbb\xffB\x06\x00\x04d\xfe=\xfe\xc5\xfb\xb0\xfe&\x06\x81\x04\x94\xfe\xbb\xfb"\xfc\xdc\xff\xdf\x00\xba\xff\x82\x00\x0b\xfd\xdf\xfc\xc2\xfe\x91\x00\x9e\x01\x99\xff\x89\xfe\x90\xfcL\xffh\x03\xad\x03\x9f\x02b\xfd\xc4\xfdi\x02\\\x02\xd3\x00O\x01\xce\xfe\xb3\xfc\xd5\x01c\x02a\xffh\xfe\x86\xfc\xa5\xfd+\xffC\x05e\x02\x85\xfa,\xfbE\xff\xb2\x01%\x04E\x03\xb1\xfd\xe8\xfb\xf2\xff\xf5\x04*\x03\xb0\xfe\x94\xf9\xab\xfe\x86\x05\x9d\x01\xec\xff\xea\xfc\xe4\xfdN\x03r\x02\x8b\x00\x0b\xfe8\x00\x98\x02\x92\x01\xf3\xffX\xfdn\xfff\x01\x90\x01)\xfc\xd9\xfc\x91\x02\x0e\xff\xdc\xfc4\xff@\x00\x06\xff~\xfe\xdd\xff\xf3\xff1\x03$\x02\xcc\xfd\xe5\xfcJ\x03\xcd\x04\xac\xff\xcc\xfd\xde\xffh\x02D\x00\xb2\x03\xdb\x029\xfc\x18\xfbV\x02i\x05\xe8\xfdQ\xfc\xa7\xfeM\xff\xe4\xff\x91\xff\x18\xffr\xfer\xfe\xc6\x00_\x00\xc9\xff&\x01\x1c\x01>\x00t\xff\xc2\xfeP\x02s\x02@\x01\xf0\xff\x01\xfe\xa6\x01\x04\x03\x07\x01\xf2\xfc\x8a\xfcG\x00\x1c\x04\x1f\x02\xda\xfdg\xfct\xfeF\x04\xde\x03\\\xfbo\xf9\xab\xfe\x1b\x01\xfa\x03\xf1\x01\xd1\xfdq\xfb\xe8\xfc\xd1\x01\r\x03\xa1\xffz\xff\x1b\x00H\xff\x8c\x03\x83\x05\x87\x01\n\x00\xe2\xfe\xea\xfcB\x03O\x05R\x01;\xfe\x9f\xfc\x88\xfeC\x01f\xfe\xbe\xfd\x8f\xfd\xe1\xfc \xffO\x00\x93\xff\xf3\xfd2\xfe\xd2\xfe\xc6\x01\x99\xff\x81\xfe!\x00\xf3\x02\x12\x02\x01\x017\x01\x19\xfeV\x01\x9c\x02R\x02\x06\x01\xaf\xfd\xa4\xfe\xe7\x00f\x02\xf2\xfe\x11\xfd\x1c\xfck\xfd\xcb\x04\xac\xfe^\xfa\xa7\xfdk\xfc\x85\x02B\x05\x83\xfe\x98\xfbw\xfd\x03\x01w\x05\xbb\x05Z\x00\xef\xfc\x9e\xfdM\x01\x17\x07k\x03\x12\xfc\x9a\xf96\x00\x15\x07\xff\x05\x86\xfe\xa7\xf7%\xfa|\xff+\x05t\x05u\xfd\x9e\xf7\xb4\xf9f\x03\xe0\x07D\xfe<\xf8\x1a\xf9\x16\x01\x99\x04k\x02\xf8\xfd\x80\xf8\xd5\x00\xb2\x03n\x01\x1a\x00\xe7\xff\xf4\xff\x87\x03\x11\x03\xcc\xff\x86\x02\x1e\x031\x03\xdd\x00&\x00Y\x01\xb2\x02y\x01$\xfc.\xfe\'\x01Y\x00_\xfe\x16\xfd\x9f\xfe\x04\xfd%\xfb}\xfb\xd8\xff\x99\xff5\xfb"\xfd\x96\xfd\xd7\xfd\xd6\xff\xc8\x03\xdb\xfd\xc6\xfa\xb1\x03\xdb\x03\x01\x08\x17\x05\x06\xfc\x98\x02\xf1\x03\xd4\x05a\x08<\x027\xffx\xffS\x02A\x03\x80\x00 \xfa\xf9\xf8\x05\xfc\xeb\x00\x81\xff\xe6\xfa%\xf9\xbd\xf8\xe3\xfb\xd5\xfe\x0f\x00\x82\xfc\xbf\xfeH\x01s\x03\x0f\x05\x1b\x03\xd1\x00\x10\x03q\x02\xe4\x03\xba\x08\x9c\x02\xa8\xff\xbb\xff?\x01\xa6\x05\x05\x05;\xfa\xb7\xf9\xd3\x00\x94\x00\x9f\xff\x97\x00\xe8\xfbT\xf9\x87\xff\x89\x00\x87\xff\x1e\xfce\xfc\x86\xfeT\xfe\x9b\x01\x15\x02o\xff\x11\xfc3\xfc*\x03G\x06P\x04#\x01\x93\xff\xb0\xff\t\x04s\tt\x06D\xfb\xcf\xfb\xf5\x05\x89\x04%\x02q\xfft\xfa\xa2\xfa\xd5\x00\x9d\xffa\xfdu\xfa\xc6\xfa6\xff0\xfe\xb8\x00\xdf\xfd\x1f\xf99\xfd\xf6\x06\x13\x06\xfa\xfc\x91\xfcm\xfe\xb7\x01\x1b\x08G\x03\xcb\xfa\x1d\xfe\xcb\x02\xef\x05\xa7\x03\xef\xfde\xfde\xfe\xda\x00\xd3\x02s\x02\xf5\x00\xba\xfc\x13\xfe\xc5\x00\xe5\xff\xf1\xffd\xfe\\\xfc\xab\xfe-\x00#\x01J\x01\xba\xfc\xcc\xfc>\xff\x1c\x01H\x00\xba\xfe\x87\xfe\xa8\x01x\x01\x88\x00\xfd\x04\xac\x01\x1b\xfd/\x00\x8b\x02t\x02\xdf\x01\xbc\x00\xc6\x01\x15\x02Y\x01\xcb\xff\\\x00\x05\xff\x91\xfc\xeb\xfck\x02\xdf\x03\x85\x01\xe5\xfd\xf3\xfb\x96\xfd\xe4\xff(\x03\x02\xff\x7f\xfc\xdb\xff\xcf\x03\xe6\x01\xef\xff\xe8\xfd\xa0\xfb\x1d\xfe\xb1\xfd&\x03\x8e\x02\xf8\xfd\x95\xfb\xed\xfb\x90\x02x\x03\xfe\xfc\x87\xf7v\xfc\xe8\x02\xae\x05`\x00\xa8\xfaC\xfd\xc7\xfe\xd6\xff\x12\xfd\xca\xfd\xcd\xfe\x7f\xff\xc5\x00\x01\x00\xe3\x00\xb2\xfe\x9b\xfbA\xfb\xfc\x02\x19\x04\xe1\x01\xe5\x01`\xffK\x03*\x04\x92\xff\x1d\xfd\xce\xff\xa8\x01\x18\x02t\x02/\xfft\xff\xe4\x009\xfe\\\xffY\xfeH\xfe\xc7\x00\xe7\x00L\x00\xd9\xfe\xc6\x00\xe5\xfe{\x00\xd3\xff\xa8\xfd5\x01\xdc\x00\xe8\x00\x98\x02\x93\xff\xea\xfc\xd3\x02\xc2\x02\xc6\x00\xea\xff\xdb\xff\x13\x01\x00\x01\x15\x03y\x01T\xffc\x00e\x04\xbb\x03\xd7\x00\x88\xffB\xfe\xfb\x00\x98\x02\x18\x04\x10\x02z\xfe\xb3\x00\xa9\x01W\xffe\xfe\xf4\xff\x9c\xfe\xa2\xfe\xb9\x01\xa4\x00\x1b\x01\x1d\x00\x02\xfd\x92\xfb\xb8\xfb\xb5\xff\x19\x01\xc2\xffh\x00\x92\xff\x89\xff\xe1\xfe.\xfd\x93\xfbj\xfbu\xfe\xc4\x00b\x02\x80\x02.\xff\x82\xfcd\xfbP\xfb\xe4\xfc\xf8\xfd\xe3\x01x\x01\xfb\xfe\x1f\x00K\xff\xa9\xfc\x81\xfai\xfa\x10\xfc\x0c\x01!\x02\xb2\x01\xd0\xfe \xfb]\xfb\x8f\xfc\xf4\xfdG\xfc8\xfa\xe3\xfb\x0e\x01\\\x02\xbe\xfe\xaa\xfa{\xf6\xbc\xf8{\xfc\r\xffB\xfe.\xfb^\xfbx\xfc"\xfe\x08\xfd\x0f\xfbz\xf9\x1a\xfbz\xff\x1f\x01\xa2\x027\x016\xfd\xda\xfd\xe2\x02P\x04\x00\x02F\x04\x9d\x06\xca\x05\x08\t&\x08\xe3\x06\x1e\x06\x14\x05t\t\xfd\x0b\x82\t\x12\t\xd6\x07c\x06e\nz\x0by\x08G\x07\xfa\n\xae\x0c\xa4\x0cM\x0e\xa9\x10\xd1\x11"\x11&\x12\xdc\x14L\x17\x95\x15\x8d\x15\x85\x14\x05\x15\n\x17\r\x14"\x10\x12\r\x16\n.\x07\xf7\x04\x95\x02\x1c\xfe\xe7\xf9\xfe\xf6\n\xf4\x82\xf0\xf0\xec\xed\xe8B\xe7Y\xe7v\xe6\x07\xe5 \xe5\xa1\xe4\xb2\xe3\xa1\xe3\x08\xe5\xa6\xe6\xbb\xe8y\xea3\xecq\xef\\\xf0\xbb\xef.\xf1\xf1\xf2r\xf5\xbf\xf6U\xf6\xa5\xf8\xe7\xf9\xc9\xf7*\xf6l\xf7\xe3\xf6\x92\xf4_\xf4\xd6\xf4b\xf4\xe8\xf2}\xf2\xa3\xefQ\xee\x96\xee\xb1\xf2\xe5\xf3<\xf0\xff\xee\xa5\xf0\x11\xf7\x1c\xfd\xe5\xfb\x1f\xf9\xb3\xfc\xc1\x01\xcf\x05C\x08\xda\x08o\t\x18\n\xa8\x0e+\x12\x8d\x12;\x11\xe5\x0e\xe3\x10\x8a\x14\xd4\x16\x93\x15\xb6\x11\x88\x0f\xaa\x12B\x1e\xdc&\xac$C\x1dF\x1c\xc5!\x00)z/\xa21\x920H.n0\x894\x891\x16&\xe6\x1c\xcf\x1d~"\xf7!X\x19\xb4\x0b\x99\xfeK\xf6q\xf3\xee\xf2\x81\xed\n\xe3\xe5\xdae\xd9?\xd8\xb4\xd2O\xcd\x0c\xca\x1c\xcaY\xceA\xd4\xf8\xd7\xa4\xd7\t\xd7\x8a\xda\xd7\xe1;\xea\x1e\xf15\xf3\xf0\xf6U\xff\xa5\x04\xff\x06\x93\tr\n\x02\r=\x10\xbc\x13\x80\x15_\x11\x88\n\xa2\x07\x8c\x08\n\x08/\x03y\xfc\x08\xf8\xf6\xf4\x8d\xf1\xb3\xee\xc7\xea\x11\xe6%\xe2\xe5\xe1\xe8\xe4#\xe5\x05\xe2\x90\xe0\xf4\xe3r\xe9\x94\xeb\xbf\xec\xc1\xefC\xf2/\xf5\x93\xfa4\xff!\x01Z\xff\xd3\xff\xf4\x04w\x08\x1b\t8\x07g\x05\x97\x06\x14\x07\x81\x07\xa6\x07\xfa\x041\x03\x9d\x01\x84\x01\x10\x03\xfe\x00\x10\x01\x90\xffk\xff\t\xff\xee\xffv\x05\xf1\x03\x00\x02<\x01,\x022\t\x9e\x12\xc2\x1dz\x1f\x89\x18\x07\x19\xd4#O-\xd91\xf84\xf57f:2:H:\xfa7O0\x18*\x97*\xca-\xa1,\xf7 \x16\x11\t\x07\xe5\x00s\xfeg\xfc\xdc\xf5\xd1\xecP\xe43\xdd\xe6\xda#\xd9;\xd4\xce\xd0\xb6\xd1\xd6\xd5\xce\xd6\xcb\xd4"\xd4n\xd5\\\xda\xd8\xe1\xde\xe8\x90\xecE\xef\'\xf1\x19\xf3y\xfa\xae\x00%\x03\'\x07\x11\x0b\x03\r5\x0b=\n&\x0b\x1c\x0c\xd5\x0bv\x0c\x1b\x0b\x97\x05\xa7\xfe\x80\xfa\x9d\xf9R\xf8K\xf5\x13\xf29\xef\xfd\xea\xae\xe6\xf6\xe4\x01\xe6o\xe6;\xe6\xe6\xe6\xd6\xe8`\xe93\xe8\xb3\xe8T\xec\xf2\xf0m\xf3V\xf5\xc6\xf6\xb3\xf7(\xf8\xbb\xf9N\xfb\xe9\xfd\xfe\xffJ\x03\xbf\x04T\x04\xd3\x00\xf8\xfe\x97\x03d\x06\xd0\x08l\t\xbb\x07,\x07\xd8\x02\xfe\x02*\x08\xfa\t\xd5\x08J\x081\x0c\t\rh\x0b=\x0c\xfd\x13\x04\x1f\xda"\xd0"\xa9!\n$\xf6)\xa71 8\x0f<\x8c:b4\xfa1>2C1\xb9-X\'S$* \x1b\x17\xce\r\x12\x03\xaa\xf9\x8d\xf4\xaa\xf1s\xef\x12\xe8\xcb\xdc\x9e\xd3\xf2\xcf\x8a\xd1\xcb\xd2\xc0\xd3\xe1\xd15\xcfr\xcf\xc0\xd1\x81\xd6~\xdb\xe2\xe1\xda\xe6\xc1\xe9t\xee\x93\xf3\xf4\xf4\xc5\xf8\x91\x00i\x07\xe5\x0br\x0c5\x0bj\n?\tf\x0c8\x11|\x11g\r\x03\x07^\x02\xc8\x00\x91\xfee\xfd\xdb\xfb\xfe\xf6#\xf2\'\xef\x86\xec\xb9\xe9\xaa\xe6\xfd\xe6\xa5\xea\x0c\xeb\xa8\xe8\xc1\xe5s\xe6c\xe9L\xed\x02\xf1\xc4\xf1\x13\xf2<\xf2\xe8\xf3\xf5\xf6K\xf9\xad\xf9Q\xfbY\xfd\x9f\xfd\xef\xfdG\xff\xbb\x00\n\x01X\x01.\x03\xcc\x03T\x04q\x04\xd7\x03\x06\x04\x93\x03\\\x07)\x08\x9f\x03\x9e\x01\xa1\x02\xad\x06.\x0b\xe2\rM\x0fe\x0b\xb3\t\xbc\x12\xc5\x1d\xb8$\'$|#\x97\'D,\xc90\xaf5\xa07\x9a6\x884\xb13\xe33A/\xc6)i&\x15#\x91\x1e\x90\x16\x9f\x0cy\x04\xb9\xfe\x8c\xfa\xb2\xf6\xbf\xef\xa9\xe5\x85\xde\xfe\xda\xea\xd9\xc5\xd8y\xd5w\xd2F\xcf)\xcf\xce\xd2U\xd6\x1a\xd8\x8d\xd9\x16\xdc\xa8\xdf\x1b\xe4\xb4\xe8\xef\xecg\xf2\x8b\xf7\xfe\xfa\xdf\xfd\x00\x00\x16\x03\x93\x07y\n\xbb\x0c6\r\x12\x0c\xc3\ng\tu\t\x14\n\xaa\x08\x1c\x05\x07\x01\xb6\xfc\xc1\xf9\xa8\xf9n\xf9\x93\xf6s\xf2\x80\xee|\xee\xc7\xee\xeb\xedP\xee\xd2\xed-\xed<\xed\xeb\xed\x83\xf0\x85\xf1T\xf1w\xf2y\xf3f\xf4\x94\xf5\xdc\xf6$\xf8\xfa\xf9J\xfa\'\xfaI\xfbw\xfd\xb9\xfe\x9c\xff\x17\x01Q\x02\x99\x01\xe1\x01X\x04?\x06\x02\x07\x9a\x05\xbd\x07D\t\x89\t\xfb\x0b|\r\xaf\r\xdc\x0el\x15\x9c\x1c/\x1d\x9a\x1a\xa2\x1c\x8a#\xec((-\xc40=0\xa1.\xbe,\xf7.\xf41k0\xc0,)\'\x02#K\x1e\x8c\x18\x8f\x14v\x0f\xb3\x08Y\x01\xe1\xfa\xff\xf4\x8e\xef\xc9\xea\xce\xe6\xbf\xe2\xc4\xde}\xdb\xd4\xd8&\xd7\x99\xd6\x9f\xd8V\xda\x1d\xdb\xea\xdb5\xdd\xc1\xe0@\xe5\x9d\xe9\xa7\xec\x90\xef[\xf3|\xf6\xbb\xf9P\xfdb\x00\xd8\x02x\x03\x7f\x04\x93\x06\x90\x07\xfe\x06%\x06\xbe\x059\x05\xa5\x03\xc8\x015\x00_\xfd\x85\xfa\xca\xf9(\xf9\x87\xf6\xa2\xf2\x00\xf0\xb5\xf0\x8a\xf1u\xf0M\xef\xbd\xed\xd7\xec>\xee)\xf1a\xf3\x98\xf1\x17\xf0\xe0\xf1_\xf4\x94\xf5\xdd\xf5=\xf7\x92\xf7}\xf7n\xf8\xcd\xf9i\xf9B\xfb\xfd\xfe\x1b\xff\xbb\xfc\xfb\xfa=\xfe\x14\x03\xfd\x04\xf2\x02\x14\x00\xde\xff\xeb\x02\x98\x07q\n~\tT\x06t\x05[\t \x11\xd9\x17\xfd\x19}\x18\x8c\x15\xfb\x17\xab!@-L3\xdf,>(\xe0)20[6-7?3\x0b,h&\xa6$\xd0%M#M\x1b\x1a\x12[\x0c\x9f\x06\xa8\xff\\\xfa\x0f\xf7\xf1\xf1d\xea\xf6\xe3\x1b\xe0\x87\xdc\x92\xd9\xb3\xd9\x19\xda\xe1\xd7-\xd3\x1f\xd2\n\xd6\xe6\xdas\xde\xa0\xdfQ\xdf+\xe0\xf9\xe3A\xeb\xb7\xf1\xb6\xf3\x06\xf4\xf2\xf5\xa5\xf8\xe4\xfc2\x02\x8a\x05m\x04\x94\x02v\x04\x8d\x07\xea\x07\xd3\x06c\x06\x8c\x05\xd7\x03\x0e\x027\x01}\xff\xc0\xfc\x93\xfb\xb5\xfb\xa1\xf9b\xf5S\xf3\x17\xf4\xe3\xf4\xb2\xf3\xc2\xf1\x88\xf1w\xf0\xf2\xef\x11\xf2k\xf3\xb3\xf2B\xf1\xf9\xf1l\xf4W\xf5\xde\xf4\xef\xf5&\xf7\xee\xf8\x98\xfa\xbc\xfah\xfbF\xfc0\xfe\xbb\x00\xb6\x01\xd2\x01q\x01\xe8\x01R\x05\'\x07X\x07\xc6\x06<\x05\x88\x06\xb9\x0b\x80\x127\x13T\x0e\x02\r^\x15\xcc\x1f\xb3#\x02"p G#1)\x910\x9b3\x050\x0b+\xfc*\xa0/O1\x91-\x9b%\xd6\x1f\xb9\x1cL\x1a\xce\x16\xaf\x10\xac\x08\xa0\x01\x10\xfd\x0c\xf9\x00\xf4\xa2\xedr\xe95\xe6\xe7\xe2\xd1\xdfL\xdd\xc6\xda3\xd9\xa9\xda\x12\xddw\xdcF\xda\xa1\xdbj\xdf\xb2\xe3\xaf\xe6\xa3\xe8\xfc\xe8\xfb\xea\xe8\xef\x8f\xf5\xed\xf8^\xf9\x0b\xfam\xfc\x90\xff\x0e\x03\xc2\x04\xe3\x03\xdd\x02\t\x04\xcb\x052\x05T\x03V\x02\xdb\x01\x08\x00\x83\xfe#\xfe\x85\xfcG\xf9{\xf7\x1c\xf8\x9c\xf7\xa6\xf4W\xf28\xf2\x99\xf2M\xf2\xae\xf1\x9e\xf1\x86\xf0\xe5\xef\x8b\xf1|\xf3&\xf3&\xf2\x9e\xf3\x9e\xf5"\xf6\xff\xf6\xac\xf8\x84\xfa\xbe\xfa\x1b\xfc\x1b\xff\x1c\x00d\x00\xc0\x026\x05b\x05z\x05\x88\x06\x95\x08S\nS\x0c\x10\rB\x0b\xac\nX\rP\x13\x9c\x18\x83\x19\xe5\x16\xeb\x15\x1c\x1a\xa6"\x1a)b*\xe3\']%y&@+\x120d0s*\xca#\x8c \xc7 ^ \x08\x1c\xbf\x15\xeb\r\x16\x07\x1a\x03\x9d\x00d\xfcD\xf5\x80\xee-\xeb\xbc\xe7\xde\xe3\xcc\xe0\xbd\xdep\xdd\x18\xdbo\xda;\xdbA\xdb]\xdb\x1e\xdd\xc4\xe0\x83\xe2t\xe2l\xe5\x9f\xe90\xed{\xef\xfc\xf1f\xf4g\xf6\xee\xf8\xcc\xfc\xa9\xffs\x00h\x00H\x01\x90\x02\n\x04/\x05 \x05A\x03\xde\x00\xb5\x00\xbe\x017\x01\x9a\xfe\x0e\xfc\x9e\xfa\xca\xf9\x16\xf9F\xf8\xa5\xf6\x18\xf4r\xf3~\xf4\xf6\xf3\x8d\xf1\xad\xf0\xe7\xf19\xf2f\xf1\x9a\xf1$\xf2\xd7\xf1\xc9\xf1\x84\xf3.\xf5\xb0\xf5<\xf6I\xf7\xf0\xf7\x8d\xf8\x1a\xfb4\xff\x12\x00\x0c\xfe\xe0\xfdt\x01\xfc\x05\xaa\x078\x07\xc5\x05\xb8\x04\xbe\x08\xae\x11\x9e\x15\x9d\x12-\x0e\x90\x11\x99\x1bp!\xc0%_&|$[$=*P3\x856\x9c2\x1c.=-3.Z/M.\xc0)\xaf!\x13\x1a\x16\x17\xc1\x14\xca\x0f\x02\x08\x9c\xff_\xf9\xd4\xf3\xac\xefY\xebZ\xe6#\xe1\xe7\xdc\xc2\xda\x10\xd9\x0e\xd8\xbf\xd6k\xd6\x88\xd7\xd2\xd8\xbb\xd9}\xdb\x81\xde\xf1\xe1\xcf\xe4\xc5\xe7~\xea\x86\xedz\xf0>\xf4!\xf8\xd8\xfa\x06\xfc?\xfd\xe9\xfe\xb6\x01\xee\x04\xb0\x06\x7f\x05,\x03M\x03\xb2\x05g\x07\xbf\x06\x05\x04\xe0\x00(\xffW\x00\xf4\x01\x15\x00\xfc\xfb\xda\xf8\x07\xf9\x07\xf9x\xf8\xf8\xf7L\xf6\xb8\xf3l\xf26\xf4\xb1\xf4\x7f\xf2\x17\xf1\x9f\xf2\x10\xf3\x1d\xf2\xc8\xf1\x08\xf3?\xf3\xac\xf3&\xf6}\xf7\x1e\xf6:\xf5o\xf8M\xfcS\xfe\x12\xff\x8c\xfe\\\xfef\xfe\xa0\x02\xb5\t\xea\x0b\xc9\x087\x05@\x05\x0c\x0c`\x17U\x1c\x1d\x18\xab\x10*\x13\x96\x1f\x03*5-\x0b)3&\xd4&\xcf-X5\x837\xc61{*y)\x14+W+\x1a\'\xa9 \x96\x19h\x13H\x0f9\x0b\xcb\x05g\xff\x12\xf97\xf3\xe7\xed\xc8\xe9\x92\xe5k\xe2d\xe0\x9f\xddu\xdaV\xd7\x93\xd7\x95\xd9\xc7\xdb\'\xdc8\xdbM\xdb \xdeE\xe3I\xe8\xf5\xea\x1d\xeb\xbb\xebd\xef\xa8\xf5F\xfby\xfc\xb2\xfb\xff\xfb\xe8\xff\xa3\x04\x9d\x06\xd2\x05\x94\x04{\x04;\x05F\x06\x87\x06\t\x05\xfe\x01\x17\x00:\x00e\xff\x94\xfc2\xfa\xf6\xf8\xc2\xf7!\xf6J\xf4\x9c\xf3C\xf2\x81\xf0\n\xf1\xa7\xf1\xf2\xef\xac\xedl\xee,\xf1\x10\xf1\x98\xef\xaf\xef$\xf1\xbc\xf1\x80\xf3\x10\xf6\xfa\xf5\x15\xf5\xb3\xf7\xb0\xfb\xca\xfd\x8a\xfd5\xfe\xb0\x00\xa0\x01F\x04\x1b\x06\x0c\x07\x9a\x08)\x08D\x06\xe5\x05\xac\x0bb\x144\x15B\x0e\xc2\tr\x0e\xa3\x1b\x15&\x8f&\xb5\x1e\x83\x19\xd8 \xbb/\x8f8;5]+]&-+c3\xaa5\xd3.0#X\x1b\xef\x1a\x8a\x1c>\x19\x96\x0f+\x06\x19\xff&\xfa6\xf7\xea\xf3\xcd\xee\x89\xe7R\xe2,\xe0\xed\xdd\xd8\xdb\x98\xda\xdc\xda<\xda$\xd8*\xd8B\xda\xe2\xddg\xe0\xb9\xe2\xc3\xe3\x19\xe4\xce\xe6\t\xed)\xf3\xf6\xf4\xe1\xf3[\xf4\x11\xf8<\xfe\xad\x02.\x03\x0f\x00=\xfe\x0f\x01T\x06V\x086\x05\x84\x00T\xfe\x1e\x00\x90\x02\x0c\x02\xe1\xfd\'\xf9\xc0\xf7\x1c\xf9\xaa\xf9\\\xf7\x9f\xf4\xef\xf2\xbe\xf1\xa6\xf1V\xf21\xf2\x15\xf0\xb7\xee\x08\xf0\xc3\xf0O\xf0\xf8\xef"\xf17\xf2\xf8\xf2\x1b\xf4\xa7\xf5\xae\xf6\xcb\xf7\xe1\xfa\x0f\xfd\xfa\xfe,\x01\xf4\x02\xb6\x04\x1f\x06\t\x08:\n\xfd\x0c\xe5\x0e\x7f\x0f\x99\x0f!\x10\x95\x11\xb6\x14<\x1a6\x1e\x85\x1d+\x19\xbe\x1a\x03#h+f.\x8d*(\'\x99&\x91+\xaa2\x064\xe1,\x7f#\x98\x1f\xb0"=$\x1f \x01\x17N\x0c\xea\x05F\x03\xdd\x03:\x00P\xf7\x83\xed\xc4\xe7\x85\xe6\x1a\xe6\x10\xe5l\xe1\x87\xdc2\xd8\xd5\xd8(\xdc\xcd\xdd"\xdd\x06\xdd-\xde\x10\xdf\xf5\xe0\x0e\xe6N\xeaE\xec\xce\xec9\xee\xf5\xf0\x1b\xf5\x8e\xfa:\xfeC\xfe\xff\xfc8\xfe_\x02D\x06<\x08\xde\x06\xd4\x03,\x02\x1a\x04\xb0\x06\xf8\x05|\x02 \xff\xa4\xfd\x96\xfcN\xfc6\xfcx\xfa\xb2\xf6\xd5\xf3k\xf4\xe9\xf4\xa5\xf3+\xf2\xb9\xf1\xc2\xf0C\xefN\xefd\xf1t\xf1-\xf0\x82\xf0o\xf1n\xf1\xf1\xf1\xc8\xf4\x16\xf7\xa0\xf6#\xf7\xa1\xf9\x1e\xfc\xde\xfcQ\xff\xe7\x02\x9e\x03h\x03\x93\x05l\t\xce\x0b\xe8\x0b\x18\x0cf\r\x81\x10:\x16\xac\x17s\x15X\x15\x84\x19\x98 |$\xd4%\x81#\x7f"L&\x7f-M1d-\x9b(\x84&Z(u*\x99(U#\xe9\x1aA\x15\x00\x14\xa2\x12\x07\x0e\xde\x05v\xfe\xda\xf7!\xf4\x1f\xf3\x9c\xf0\x10\xead\xe2\xc5\xdfP\xe0Y\xe0\x84\xdey\xdc\x9c\xda\x97\xda\xba\xdc\xff\xdf\xaf\xe1\xad\xe1t\xe2\x8b\xe5\xff\xe9[\xed\xb7\xee\x90\xf0q\xf3\xfe\xf6\xd8\xf9\x9f\xfb\x8e\xfca\xfe\x16\x01\xac\x02\xd1\x01\x14\x01\x02\x02\xe1\x02}\x02m\x01p\xff\x9d\xfc]\xfbs\xfck\xfc\xee\xf8\x03\xf5\xf1\xf3k\xf4:\xf4\x90\xf3~\xf2U\xf0j\xeeB\xef\xd4\xf1\xff\xf1A\xf0`\xefS\xf0\xf9\xf0<\xf2\x9b\xf4\x9b\xf5\x82\xf4.\xf5\xeb\xf7\xec\xfa\xf1\xfb\x14\xfd}\xff\xcb\xff\xfc\x00\xcf\x03\xc0\x06\xd1\x08\xc0\x06\x82\x06$\t\xc5\x0c@\x11\xa1\x0f=\x0c\xd8\x0b\xd0\x10\x93\x19\xd4\x1d\x11\x1b\xfc\x16v\x18}!\xe1+6.\xb3*\x07&\x88\'Y.75M5\xad-\xfd%\xfc#\xbb&$\'\xa1!\xf3\x17\x1b\x10\xa9\ne\x07\xcb\x04\xc6\xff\x99\xf7.\xef\x8a\xea\xa6\xe8\xe3\xe5\x83\xe23\xdf$\xdc\x16\xdae\xd8/\xd9\xb9\xda&\xdcc\xdc\xaa\xdc\x0f\xde\r\xe08\xe3;\xe8\xfa\xecV\xed\xcc\xecG\xef\xa3\xf4\x88\xfaI\xfd\xad\xfd\xc3\xfb\x83\xfc\xd2\x00[\x051\x06\xbc\x03#\x01g\x00(\x02F\x04^\x03[\xff\x18\xfc\xf6\xfb\x94\xfcu\xfb\x01\xf9\'\xf7\xfc\xf5\x01\xf5R\xf4\xfb\xf3\xa2\xf2\xe3\xf0\xec\xf0\xf2\xf1i\xf1\xb4\xef\x87\xef\x0f\xf1\xa0\xf1\xd7\xf1p\xf2w\xf3\xe6\xf3c\xf5\xfe\xf7\xa4\xf9N\xfa\x1f\xfc\x16\xff`\x01\x1b\x03\x12\x04\xc4\x05f\x07d\n\xd4\r\x99\x0e)\x0e\xcd\x0c3\x0f\x15\x16B\x1b\x95\x1b\x84\x177\x16\xc3\x1b\x8b$\xa3*\xc2)\xaf$K"|\'\x9a/~3\xeb.\xaf\'V#\xfc$\xb5(\xf7\'5!\x0b\x17\x1a\x10\xf7\r<\x0eP\x0b\xb6\x03\x06\xfa\xd4\xf2\x0e\xf05\xef\xbf\xedG\xe9\x17\xe3\xd0\xdd\xa2\xdc\x83\xde\x89\xdf\x8a\xde\n\xddC\xdc\xa5\xdc\x84\xdet\xe2\x88\xe5*\xe7\xe6\xe7@\xe9\x90\xeb\x15\xef\xce\xf3\xf8\xf7\x8e\xf9<\xf9\x89\xf9\x8f\xfc\x06\x01\xbf\x04\xdf\x045\x02\x10\x00E\x01\x1d\x04/\x05"\x03r\xffh\xfc\xcd\xfaq\xfbo\xfc\x98\xfa\x06\xf65\xf2\x93\xf1[\xf2\x7f\xf2P\xf1z\xeff\xed\xa4\xec\x83\xed\x1f\xef\x85\xef\x07\xef\xaf\xeeF\xefh\xf0g\xf2\x9a\xf4H\xf5o\xf5Y\xf7\xb2\xf9\xb8\xfb\xea\xfc\xc2\xfe)\x01\xce\x01\x94\x03a\x07:\tR\t\xb0\t\xe9\n\x8d\rb\x11\xbc\x16?\x17v\x13\x92\x13\xa1\x19y"\xb5&\xb6%\x86"\xe4!\xbf&h/\x8b3\xa9/\xf6(\xdc%H)\xc6,\xc9+\x83%\xb5\x1c\xec\x16\xe6\x14\xc1\x14\x10\x11g\t\xe6\x00\xe7\xf9r\xf6\xdf\xf4W\xf2\\\xec\xde\xe5$\xe2\xa9\xe0\xde\xdf\xf3\xdeS\xdeT\xdcp\xdb\xe8\xdb\x0e\xdev\xe0\x13\xe2\xd1\xe3\x9c\xe5M\xe8@\xeb\xaf\xedB\xf1\xbb\xf4Y\xf7\x83\xf8\x0c\xfa\x92\xfc\\\xffD\x01i\x02\x00\x02+\x01\x13\x01\r\x02\x89\x02N\x01\xbc\xfe\x7f\xfc\xad\xfbE\xfb8\xfa0\xf8\xfe\xf5\n\xf4J\xf3E\xf3\xdc\xf2\xc4\xf1\x0f\xf0\x92\xef3\xf0X\xf1B\xf12\xf0\xdd\xef\xff\xf0\x8d\xf2\xb8\xf3/\xf4.\xf43\xf4\xa8\xf5!\xf8\xbe\xfaU\xfbd\xfbF\xfc\xae\xfd\x19\x00\x03\x03\xa7\x05\xf7\x06\xe3\x05\x90\x04W\x07\xfb\x0c6\x126\x13*\x10F\x0e<\x11Q\x19\xfb!\x1e$\x96 \x89\x1c\x88 \xb2)\xd30\xb82\x80-\xf9)o)9.;2\x1f0\x88)B"g\x1f\xbe\x1e\x82\x1c\x80\x176\x11.\nr\x03\'\xff\xfe\xfb\xe0\xf7\xe4\xf1j\xec\xb0\xe8\xa5\xe4\x86\xe1\x8b\xdf\xa7\xde\xd2\xdd`\xdb\'\xdac\xda\x01\xdc\x0e\xde\xfd\xdf\xb5\xe1\x8f\xe2\xc1\xe3\xcf\xe7\x8f\xec0\xf0s\xf1\x95\xf2\x0f\xf5\xaa\xf8a\xfcD\xff\xce\xff~\xff\xd2\xff\xb5\x01\x9e\x03=\x04\xf7\x02\xdc\x00\xb0\xffM\xff\xe7\xfe\x97\xfd\xa0\xfb|\xf9[\xf7\xf1\xf5\xd0\xf4|\xf3)\xf28\xf1\xfc\xef/\xefw\xee"\xee\xc4\xee\xed\xee\x01\xef\xd1\xee\x1f\xef\x0e\xf1\xae\xf2\xb5\xf3R\xf4\x9f\xf5\xc6\xf6\xc1\xf8Z\xfb\xe7\xfd\x7f\xfe \xffL\x01U\x04\xa2\x06\xd1\x06\xc9\x08\x15\x0b\xdf\r\x1f\x0fp\x0f\xdf\x11c\x13\xe0\x18\xf1\x1d\x14\x1e\x1a\x1c\xc5\x1c\xcd#w+\x9a-\\+\xd1(O(=,\xd11\x1d3a-\xb1$\x18";$\n%\x84 1\x18\xb3\x10E\x0b\xf8\x07)\x06_\x02\xf9\xfa\xb1\xf2\xa5\xed\xa2\xeb\x81\xe9r\xe6\xf0\xe2\xd3\xdf\xc9\xdc\xf3\xda\xad\xdb~\xdd\x01\xde\xd0\xdc\xaa\xdd\x91\xdf\xcb\xe0\x06\xe3\xfd\xe7<\xec\xcf\xec\x90\xec~\xef\xbe\xf3\x0f\xf8\xae\xfbh\xfd\x8f\xfc\xf1\xfb\xd1\xfeQ\x03\xf8\x04\xa4\x03\xca\x00\xc4\xfe\x1a\xffF\x01\xc4\x01e\xfe\xe6\xf9@\xf7N\xf7f\xf7\\\xf6@\xf40\xf1\x9b\xee\xc4\xed\xaa\xee\x11\xef1\xee\xaf\xec$\xec\x11\xec4\xed\xa8\xee\x1f\xf0\xc3\xf0\x0c\xf1#\xf2\x92\xf3\x0b\xf5\xef\xf6\x0c\xf9V\xfa\xab\xfb\\\xfd\xfa\xfd\xf4\xfe\xe9\xff\xb2\x02\x99\x07\xc7\x089\x07c\x06.\x08\xdd\x0fk\x16J\x18z\x17\xc6\x152\x1b\xdd#\xa3+\xeb.\xf9+++e/\xc96\xef:\xad9K5q2\x012\xab2.1\t,o$.\x1eo\x1a\x7f\x16:\x10y\x08\x12\x02\xe6\xfb#\xf6\xae\xf0n\xec\x9f\xe6\x11\xe1r\xde\xfa\xdc\xfc\xd9\x8f\xd5\xd2\xd4Z\xd6_\xd7\x9d\xd6\x18\xd7\x0b\xd8I\xda\xc3\xdd\xac\xe2d\xe6\xc4\xe6\x86\xe8\xcd\xec\xe8\xf2\x0c\xf8|\xf9v\xfav\xfb\xa1\xff\x0c\x04x\x05O\x04\xca\x03\x98\x04\xc0\x04r\x04\n\x04\x17\x02\x98\xfe\xdd\xfc\xd9\xfc\x1f\xfb]\xf7\x92\xf4L\xf3$\xf2\xa6\xf0o\xefx\xed\xde\xeb\xbc\xeb\xb5\xec{\xed]\xecE\xec\xcc\xedk\xef\x88\xf0{\xf2c\xf4\x18\xf6\x8c\xf7W\xf9\xc5\xfc$\xfe\x08\xffF\x02n\x043\x06j\x06#\x072\t\x9d\x0b\xa0\x0e\xd2\x0f\xa2\x0e\x17\x0eS\x10\xf9\x15p\x1c[\x1e)\x1c\xff\x19\x00\x1e\xa5\'p.\x93/\x8e-y*h,\x801\xf66[7+0\x1b+\xa4(k)\x9c\'B#\x19\x1d]\x15#\x0f\x07\n\x83\x06\x83\x001\xf9\xd4\xf2N\xee\x03\xe9[\xe3\x12\xdf(\xdcf\xda\x1e\xd7q\xd5\n\xd5\xd7\xd3&\xd3\xf7\xd4C\xd9G\xdb\x08\xdb1\xde\xab\xe2R\xe6\xa3\xe9\x06\xee\x13\xf2\xef\xf4\xf6\xf6\xb2\xfa\xa5\xfe\x80\x01F\x03O\x04j\x04\xb8\x04\xc3\x05\xaf\x06C\x06\x07\x04g\x01\x9f\xff\xc2\xfe\x83\xfd`\xfb\x8b\xf8\xd4\xf5}\xf3\x0b\xf2w\xf1Y\xefh\xec\xa2\xeb\x1a\xecA\xeb\xff\xe9\x97\xe9\xdb\xe9A\xea\x7f\xeb\x10\xed\x15\xee:\xee4\xef\xb5\xf1\xf6\xf4N\xf7\x8f\xf8\xce\xf9\xc2\xfb_\xfd\x9f\x00\x83\x03n\x06\xe8\x08\xee\x08\xd1\x0b\x82\r2\x0fm\x13\xcf\x17-\x1d\n \xdb\x1e\x07\x1f!"/+\xa03\xd73\x0b2\x16/\x96/;4\xbf:?;\xcc4r-m)\x82*\x8f)\xcc%\x86\x1eD\x16\r\x0fV\t\xe3\x05_\x01z\xfa\x89\xf2\x9f\xec\x8c\xe8\x08\xe3\x8d\xde\xab\xdb\xd7\xd9\xce\xd8L\xd5w\xd3\x08\xd36\xd4\xc1\xd6\xa3\xd9]\xdc\xa1\xdd\xe4\xdd \xe1\x8e\xe8\xed\xed%\xf1_\xf3\xa5\xf4\x9b\xf7p\xfb\xb9\x00\x82\x03\x18\x04\xc8\x03\xc4\x03\xf7\x03z\x05\xa2\x06\x1c\x05}\x02\xf7\xff]\xfe\x15\xfd]\xfbm\xf9\n\xf7\x13\xf4\xb4\xf1\xf8\xefr\xeei\xed\xff\xeb\xb9\xeaF\xea\x8d\xe9\x91\xe8B\xe8;\xe9\xfe\xea\xe1\xeb\x0c\xec\x97\xec\x02\xee*\xf0C\xf3\x82\xf5A\xf7\x16\xf8t\xf9\x99\xfc\x90\x00Q\x03\xf9\x03x\x03\xcf\x042\t\xfa\r\\\x12J\x10\x9c\r\xeb\r\x1b\x15/ \x06&\\$\x93\x1e\x10\x1fY&\xaf1\x959\x039\xe53\x08/\xba1}9_<\xc78\xf81\x12.\x8e+](\x91$\x8b\x1eh\x18\x9f\x12\xe6\r\xec\x07\xe0\xfe:\xf6\x90\xf0\x1c\xef\xec\xec\x9e\xe6\xbb\xde\xb8\xd7J\xd4d\xd6(\xd9<\xd9\x90\xd5\x96\xd2\xa0\xd3\x1f\xd7\xd3\xdc\xc6\xe1\xa0\xe2E\xe4\xb0\xe7i\xebT\xf0\xed\xf3c\xf5\xee\xf9\x97\xfe\x87\x00\xc3\x00\x1a\x01\xf3\x01o\x03\x8e\x05\xff\x07\xf0\x06>\x01s\xfd\xb0\xfd;\xff\x84\xfe}\xfb]\xf8\xc8\xf3}\xef\xfb\xee%\xef\x87\xee\xe9\xeb,\xe94\xe8\xed\xe6\xf6\xe5\xf7\xe5\xc3\xe7\xc5\xe8\xc0\xe9\x86\xea\xe7\xea\x97\xeb\xc5\xecz\xf0\xc7\xf5\\\xf83\xf9\x96\xfan\xfc;\xff;\x02\xc6\x04\xa5\x08\xe4\t\x8a\x0b\x01\x10\xfb\x0e\xf8\rz\x0e\x80\x11\x05\x1a\xe0\x1f \x1f\xf6\x1a2\x19(\x1e\x85\'\x81.\xc7/\x11-\xc3)G-w2\xe16\x985j/ .\xc0-\x91.Q+\x8b$\x9d\x1e\x11\x1a\xca\x18\r\x16]\x0fJ\x05\x9a\xfd\xc5\xf9~\xf7x\xf4\x08\xef{\xe8F\xe0\xb7\xdd\xce\xde\x0c\xdd\x1a\xd9\xa9\xd6\x15\xd8\xd1\xd9\xd9\xd9\xd1\xdb\xce\xda\xcf\xdb\x8b\xe0:\xe52\xecf\xed\xe7\xebg\xee,\xf4\xb8\xf9\xc0\xfbp\xfd\x81\xfe\xa9\xff\x02\x01,\x03\x97\x03\x83\x01\x1e\xff\x9b\xfe\xf8\xff\x05\x00\xf0\xfb\xc5\xf6u\xf4\xfa\xf3J\xf3\x00\xf15\xee\xe3\xea\xb8\xe8\xc8\xe8\xe4\xe8\xe8\xe7\xb9\xe61\xe6(\xe6\x17\xe8\x1d\xea4\xea\x9e\xea\x14\xec\x0f\xef\xe1\xf1L\xf4\x87\xf7H\xf7\x99\xf8%\xfeY\x01\xf5\x03\xdb\x06\x9a\x07\xcc\t{\n\x04\r\x1e\x11G\x11\xc4\x12\xc6\x13\xf5\x15\xb0\x16U\x14\x99\x13\x89\x18W \xe8"\xeb!\xbf\x1e\x00\x1f\xd8!\x06&\xad+\xeb.\x94-\xba)\x98)3*\xea)\xbb\'\x7f&\xd5&}$\xc6\x1f\xb8\x1a%\x15\x19\x0f\xcc\x0be\n\x8d\x08\xd6\x02J\xfa\r\xf4\xd9\xef8\xeb\x81\xe8\xb3\xe7*\xe5\xdf\xdf\\\xdcS\xdbQ\xdb\xa8\xd8%\xd8N\xdd8\xe0\xa9\xdf\xe7\xe0\xb2\xe1\xea\xe2\x83\xe7\xff\xec\xc8\xf2O\xf5 \xf5\xa5\xf5\xdc\xf8\xf4\xfc\xfe\xfe[\x01Y\x03\xf1\x02\x8b\x00G\x01\xf8\x01\x0b\x00\xef\xfc\xe1\xfc\x86\xfdz\xfbi\xf7{\xf3\x98\xf2\x85\xf0\xe8\xed\xbe\xeez\xef\xf6\xea\xb9\xe9\xaa\xea\x94\xe8R\xe8\xee\xec\xc8\xeb\xda\xe9a\xf0m\xf3`\xf2\xa4\xf1>\xf4t\xf87\xfc\x17\xff\x9c\x04\xd8\x04c\x04X\x07\xe1\t\x13\x0c\x14\x10u\x12\xff\x0f\xd4\x11D\x14\xad\x11\xcc\x10D\x11\xcb\x11\\\x12[\x14\xc7\x15\x81\x11\xb2\x10\xcf\x10-\x12\xc3\x17\xd1\x1b\x84\x1c\x8e\x19\x9e\x1b\xb0\x1b\x8c\x1d@#\x1b$I#\n"_"\xca!\x80\x1f\xd0\x1d\x8f\x1c\r\x1a\x08\x18E\x15\xc2\x11B\x0c\xf3\x05\x13\x02\xba\x00\x93\xfdH\xf9\xeb\xf4B\xee\xf4\xea\x86\xe7\xc8\xe5C\xe4\x06\xe2\xe0\xe0D\xe0(\xdf\x89\xdf\xea\xde\xe8\xde"\xe3\xe6\xe4\x88\xe8\x05\xecc\xeb\xcc\xedy\xf0\xd5\xf1\x82\xf7\x88\xfb\xb5\xfah\xfd\xc2\xff\xb2\xff\x9d\x00F\x00c\xff\x7f\x00n\x006\x00:\xff\xd0\xfc:\xf9\xf1\xf6\x1f\xf6\x8f\xf4\x89\xf3\x82\xf3\x8d\xf1:\xee\x06\xefR\xec\xbf\xea/\xef\x81\xec@\xec\xc9\xf49\xf0n\xef\x90\xf5\xf1\xf2\x19\xf9\x01\xfb\xdd\xfa;\xfe\xb4\x05j\xff\x98\x03X\x0b/\x06\xac\t\xc0\t\xe7\x0f\x0b\rg\x08K\x11\xbe\x11\x90\x08\x1f\x11\xaf\x11:\x06\x13\x0fu\x0e\x83\n\xd4\r}\n<\x07\xb1\n\xc1\x0cN\x08\x1c\tL\t\xed\x08F\x0b\xbc\x0c\x0c\x0e^\r/\r\xe5\x0ed\x0f\x81\x12\xa7\x13\xee\x13\xbb\x14\xc5\x11\xa4\x13\xf1\x16U\x12\x87\x0f\xc1\x11\xfd\x11y\r\x05\x0f.\x0f_\x08D\x04\xcf\x037\x031\x01\x13\xff\xa6\xfd\xd5\xfbe\xf6\x8b\xf4i\xf4\x18\xf2X\xf0\xe8\xef.\xefv\xee\xea\xee~\xec\xdc\xeb\xcc\xed\x0f\xedG\xeb_\xee\x11\xf2\x89\xf0\x1f\xf0i\xf2Z\xf2f\xf1\x18\xf4\xb7\xf5\x9c\xf7\x1b\xf7\xd8\xf5j\xf6R\xfa\xfd\xf8\xc4\xf7\xda\xf6)\xf8\x1d\xfc\xa3\xfa\x1f\xf7\x11\xf9?\xf80\xf8\xfd\xf6\x8e\xf7.\xfb\x05\xf9F\xf6O\xf7\xb6\xfb\x8b\xf65\xf5c\xfa\xc1\xf5\x1c\xfe\x03\xfa\xe4\xf8\xfe\xfa\x1d\xfdv\xf9\x9d\xf7N\x04\xe1\xfe\x8d\xfc\x8c\xfe\xfa\x03\xaf\xff\x84\x04\x8b\x05\x82\x01\x7f\t\x83\x05\xed\x02z\x0fQ\t\xc3\x05\xc3\x0e\x97\x0f|\t\xa6\x0cp\x0e\xe3\n\xc3\r\xdf\x0b\xde\x12\x8c\x0f3\n\x07\x0f\x03\x08\x87\x06\xd5\r\x1f\rJ\x08`\t\x1d\x0b\xb8\x06\xec\xff\xd2\x06\xd9\x08Y\xff8\x04\xaf\x0c]\x05u\x03\xd7\x07}\x03\x15\x06\x90\x07\xf3\x05R\rW\x0b\xaf\x064\x0c\xbc\x0fQ\x08}\x03\x89\x0bw\x0bs\x04t\n_\x0bc\x05)\x02\x05\xff\x17\xfd\xd8\x00\xd6\xfd\x15\xfb~\xfc\xf8\xf8s\xf4S\xf5|\xf6\t\xf1\xf6\xf1u\xf4\xab\xf4\xb9\xf5 \xf4\xbb\xf3<\xf5\x9c\xf5\xa2\xf6\xcf\xfa\xd3\xfck\xf84\xfd\x05\xfdJ\xfb\x8c\xff\x87\xfd\xe0\xfd\xd8\x01\xf6\xf6o\x00X\xff\x83\xf8j\xf80\xfbb\xfa\xa1\xf1\xb7\xf87\xf8_\xf2\xc8\xee\x82\xf8\xdb\xf3\xcf\xec\xbc\xf2\xab\xf5C\xf4-\xee\x12\xf5\xe2\xf3/\xf4F\xf4\xed\xf8%\xf5\x8e\xf6^\xff\x03\xf9\xad\xf9\xf3\x04\x82\x01N\xf7\x99\x03C\t\x1c\x04i\x03W\rw\x0c\x1c\x02\xb5\x02\x9a\x0c\x9b\t6\t\xc4\x0b\xd8\x08\n\x0b&\tS\x05j\x04\xcd\n\x1b\x08S\x01\x8f\r\xdd\x0b\xf4\x01\xdd\x02O\x08e\x03\x80\x00\x80\x07Z\ne\x01\xca\x04m\t\x8f\xfe\x8d\x00\x13\t\xc8\x03\x8f\x00x\x0ci\r\xde\x001\x03\xcb\t\xc9\x08j\x06\xf3\x0c\x8f\x0e\x81\t@\t\xdc\x0e\x04\x08\xc8\x05x\r\xea\x0b<\x08\x85\x0c9\x0b\xb4\x05|\x06s\x04\xd3\xff\xb4\x02\x95\x07\x1b\x01[\xfd\xa3\x00E\xfd&\xf4\t\xf9\x84\xfc\xeb\xf6\x88\xf4B\xf7\x94\xf7O\xf3\xdc\xf2W\xf8\x93\xf4\xc6\xec_\xf29\xf7e\xf8\xb1\xf5]\xf5)\xf7N\xef\x90\xf0\xd3\xfa1\xf9\xc9\xef\xec\xf8<\xfd?\xf4\xe7\xee\xb3\xf7\x83\xf7X\xee\'\xf4h\xfbJ\xf3\xf5\xfa7\xf6\xde\xefA\xee\x7f\xf6\x97\xf9\xc0\xf3\x16\xfe\x04\xfaA\xf9B\xf4\x8b\x00X\xf7\x8d\xf8\x8e\x06O\xfc\x98\xfe\xbc\x06\xad\rS\xff\xff\xf7\x18\t\x9f\n\x11\xfd\x92\x07\xf1\x18\x03\n=\xf6\xb5\x13Q\n\xeb\xf7\xf4\x0e*\x10g\x02O\x07\xa0\rj\n\x85\x03\xb0\x03\x1e\t \x06\xd5\x06\xf4\x0e\x1c\x10W\x02\xd9\x05\x96\t \r\xc1\x02\x8d\t\xc4\x10\x8e\x04z\x05F\x0c~\x05,\x00_\x08>\x02\xf2\x02\xfd\x05V\x04\x9d\x00\x04\x02`\x02i\xfb\xbd\x03E\x00I\x00;\x05q\xfc\xe2\xfa\xab\x04\x17\x02\xc9\xf9j\x05Z\xff\xf8\xfa\xa8\xfek\x04\x9b\x01\xaa\xf9,\xfe\x0f\xfd\xfb\xfd|\xfd\xa0\x03\x16\xfe\xaf\xf3I\xf9h\x04w\xf9K\xf2\xc0\x06\xb6\xf7\xbb\xf3\x13\xf7c\x03p\xfaF\xefW\xfe5\xfe\xf9\xf0\x89\xf6L\t\xa9\xf9+\xf2\x08\xf5\xf7\x0b\xe9\xfc\'\xf1\x05\x0c\x16\x04\x01\xec?\x08\x06\x13Q\xfa\x1b\xfc\n\x05\xa9\x0e(\xf8v\x00\x13\x12\x8b\x02\xc1\xf8\xec\x04\x00\x049\x03\xcb\xfd\x0f\xf9\xbd\x07\xfa\xf9W\x04\xc9\xfcc\xfaP\x01\xb1\xfc\xc1\xf4\xf6\xfd\x11\nb\x00\x0f\xfa\x85\xfbC\x07\xea\x06\x93\xfbc\xf5\x87\x0f\r\x031\xf9\xd4\x0c\x93\x14J\xf8\xa3\xf4\xd6\x0c6\x06\xf4\x01\xf7\x03\x15\x0bR\xff\xfa\x04h\x02P\x07\xfd\xfeX\xf4f\x08e\x00\xf7\xfd\xaf\x05\x98\x042\xeeV\xf9{\ne\xf0K\xf8\x81\x03\x8e\xfb\x15\xf2\xa7\x00\x1f\x00y\xfa\x0b\xef?\xf9\x08\x03\xcc\xee\x02\xfeS\x08\x02\xfa}\xe8\xee\x05\xeb\xfa\xc8\xed\xcf\x03_\xf9\x03\xf98\x01\xac\xfd\xfe\xf9\'\x01V\xf7\xd5\xf7\xf0\x0c\x0e\xfe\xc9\xfa\x1c\x07\xc2\x07\xe4\xfdw\xf8\x7f\x0f>\th\xf5\xbb\x018\x1b\x7f\x01s\xf4\x8b\x10{\x0f\xf9\xf7\x1b\x03\xf4\x16\xa0\xfct\x01\x9b\x0cI\t\x86\xfa"\t9\t\xf9\xfa\xff\x06k\x0b\xc0\xfa\xeb\xfa\xed\r<\x04\xd6\xfc\xae\xf5\xcf\x0e\x1b\x02\xad\xf25\x04\x06\x0bN\xf8\xcf\xf9\xa0\x06h\xfa+\xfe\n\xfe\xf2\x08N\xf9\xe5\x01\x86\xff\x9b\xfa7\xfdH\xff\xa7\ti\xf9L\xfb*\x06\xb9\x06\x9b\xf5\xd2\xfcC\x0e\x07\xf7\x17\xf6\xaa\x11\xe2\t\xed\xf5\x91\x00\x0e\n-\xfb\xf4\xfd\xb7\nx\x03=\xfb\xc1\x05\xaf\x068\xfe\x87\x03[\x03\x1c\xf7[\xfa\xf0\x0b\xc1\x00L\xf9\x15\x01\x06\x03\xc3\xf3?\xf42\x03\x93\x02E\xf0\x82\xf2\xe0\x07\x1c\xfb\x7f\xf0`\xf9\xf2\xfeL\xf1\xdc\xf2[\x02-\x04n\xf4\xd8\xf8h\x01\x86\xf9?\xf9\x85\xfcP\x04\xc3\xfa]\xf98\x05a\xfen\xf2~\x02\x06\x00\x81\xf2\x81\xfc\xf9\xff\xe9\xfa\xf3\xf9\xd9\xfc\x95\xfb\xd1\xf7 \xf4\x8d\x04\xe3\x01\x8e\xf2&\xf8\n\x0eG\xfbF\xec\xeb\t\xf7\x10(\xf9n\xf1\x9f\x11L\x0c\x89\xf5W\x04A\x0f6\x07\xd4\xf3.\x0e\xf1\x17S\xf1\xb9\xfdW\x1eX\xfe\x05\xedq\x19}\x08\xce\xfa\xa9\x02a\nJ\x00m\x04I\x06.\x06\xaa\xfdm\xfb\x90\x0ft\x06\x8c\x00\xf7\x01\xe2\n\x93\xfc\x13\x00\xa5\x0fz\xffa\xfa\xf8\x0f\xcb\x05\xda\xf5\xd6\x07V\n\xda\xfb(\xf8\x1f\x08\xb8\x02\x91\xfa\xad\x03?\xfeI\x00T\xfb\x9e\xfe\xc9\xff\xc2\xff\x90\x01\x8c\xfe\x0c\xfch\x07\xda\x04\x9c\xf8\n\x01J\nh\x018\x04f\nj\x05\x00\x02\xc4\x08X\x0c\xbb\xfe\xae\x07x\x05N\t\xb9\x06{\x03J\x0cQ\xff\xce\xf8\x9f\x05\xbc\x07\xc8\xf8C\x02\xb3\x00\r\xfa\x8b\xf7\x93\xfe\xba\x01\xaa\xf5\xca\xf1A\xfa6\xfd\xaf\xfd\xa5\xf7D\xf9\x03\xfdG\xf1\xbd\xfb\x81\xfe\xcf\xf8\x13\xfdy\xfc\xa0\xf5\xac\x00&\x03\x05\xf3t\xf6\x12\x00\xaa\xf7\xa0\xf8\xd3\xffa\x00\xe4\xf6!\xf4M\xf4X\xfaC\xfe \xf3x\xf9Z\xf9\x10\xfad\xfb\xf5\xf0~\xfb\xa5\x00\xfc\xf0Z\xf7i\x00,\x01\xa9\xff\xa4\xf1t\xfc\xd1\x0cg\xfc\xe0\xeer\t\xb5\x0e\x98\xf6\x0e\xfb\x80\x0e`\x05h\xfc\xe4\xff\xc8\x08\xdd\xfe\xbf\xff\x1e\x05\xbf\x051\x05\xa6\xfb\r\x0bS\xfb\xb9\xfe\xb1\x06h\xfeC\xfa\x97\x04\xe5\r\x14\xfc\xcf\xfa\x87\x03b\x03M\x00;\xfb]\x07\x15\x04\x14\x05%\x02\x17\x02\xb1\r\xde\x02\xc2\xfbW\x03\xf9\x10\x14\x03K\x00d\t\xe1\x0b4\xff\xf9\x00\xfb\x0b\xc0\xff\x8e\x03_\x02\'\x01\xdb\x08T\x03z\xffv\x050\x02\xae\x00\x91\x01\x0b\x00\x15\x00M\x05K\x02\x1e\x05S\x03\xf9\x00\x05\x02y\x01}\x01\xb5\x03L\x078\x01B\x06\t\x08u\x03\x1e\x03<\x03T\x06\x0c\x04\x1a\x03Y\x07\xe1\x06\xa8\x02\r\x03\xa4\x01\xc8\x01*\x02n\xfd\xdf\x01\x9f\x02\x83\xff%\xfea\xfc\\\xfe\xf7\xfc\x8a\xfb\xb4\xfav\xfc\x81\xfa#\xfb\x0e\xfd\xbc\xf8\xf8\xf8\x9f\xf5\xd7\xf8\xfe\xfa\x04\xf7V\xf9\xae\xf8{\xf4\xad\xf8m\xf8H\xf5O\xf7\x15\xf5\xd7\xf6\x1f\xfb\xa3\xf7s\xf9s\xf5\xa7\xf3\xcb\xf63\xf8\x96\xfb\x1c\xf9\xc0\xf7\xd6\xfa\xc8\xf6\n\xf3\x08\xff^\xfa\x88\xf4\xbc\xfc\xca\xf9\xa4\xfcP\xfc~\xfc?\xfa\xa5\xfa\x06\xf9\x8b\x00\x12\xff\x06\x00\xee\x03\x90\xfa\xe3\x00g\x03}\x01\xeb\xfd\x05\x02c\x00R\x04\xa1\t\xf6\x05\xe0\x01C\x04\xd0\x01\x93\x05\x11\x05e\x06\xb1\x07\xcd\x05\xae\x0c\xbe\x03?\x06\x18\x08v\x05\x8b\x04\x12\x07k\x06\x80\x035\ri\x07\xe8\x02\xfa\x03\x8a\x05\\\x04i\x04D\x05\x88\x04"\x03r\x03\x9a\x07\x15\x03\xaf\x01\xa7\x03\x8b\x02\x05\x03\xb3\x03\xe1\x05\xe0\x03\xd0\x04m\x06\x14\x04(\x05-\x04\x08\x05\x85\x07\x97\x07Y\x05\xd9\x07\xa7\x06\xd7\x05\xcb\x07o\x05\xca\x05\xdf\x03\xb0\x04+\x05C\x05\xfa\x03\x0e\x02\xf7\x00\xb5\x00\xef\x00\xdd\xff5\x00\xec\xfe8\xfd\x1b\xfd\xe7\xff\xfe\xfc,\xfb>\xfd~\xfb\xc4\xf98\xfc\xd0\xfcu\xfa\x89\xfa\xe0\xfb\xc8\xfa\x12\xfaT\xfc\xd9\xfaz\xfa\xfb\xfa\x99\xfc\t\xfbV\xfaT\xfc\xe1\xfc\x15\xfc\x9e\xfa3\xfb)\xfbu\xfc\x9d\xfb\xe0\xfa\xf2\xfa_\xfbq\xfa\xdb\xfb\xa1\xfa\xf4\xf9\x94\xfaP\xfa\xa4\xf9F\xfb\x9a\xfb\x11\xf9m\xfa?\xfb\x99\xf9\x15\xfa\xe3\xfb\xe6\xf7\xa4\xfbA\xfc\xce\xf9i\xfb\xe9\xfc\xc2\xfa\x95\xfaO\xfd\xbf\xfcf\xfb?\xfe\xfb\xff~\xfdL\xff\xcf\xff\x8c\xfev\xff\xcf\x01<\x01\xdb\x02\x9b\x02\x12\x02\xd7\x03 \x04S\x04I\x04\xd8\x03\x1f\x04\xc4\x05\x8d\x06\xda\x057\x04\x8b\x052\x05T\x04\x9a\x03\x83\x05@\x05e\x04\xde\x03b\x03\x93\x02F\x02\xf9\x03\x10\x02\x98\x02\xaa\x02\n\x01\xe9\xff\xf9\x01c\x02\x92\x00\x15\x01\xfe\x00v\x00\xef\xff&\x01\xbf\x00?\x00_\x00\xf0\xff\xea\x00\xed\x00\x8e\x01\xd5\x00t\xff/\x02\xbe\x00X\x00\x17\x02\x05\x03\x1d\x02M\x01\xaf\x01\xe7\x02.\x03\x01\x03\xc1\x03O\x03\x8c\x03;\x03\x9f\x03\xe3\x04\x13\x05\x96\x03\x1f\x04\xb6\x03P\x03~\x03\x98\x03\xcc\x02X\x02\x17\x02\xdc\x00\x17\x01\xa0\x00\xb2\xff\r\xff\x16\xff\xe2\xfd\xad\xfd\xc1\xfd\xd3\xfc`\xfcN\xfcq\xfc;\xfba\xfb>\xfbB\xfb$\xfb\x16\xfb\x03\xfa\xe0\xfa\x13\xfbf\xfb\xef\xfbE\xfa\xfe\xf95\xfa\xad\xfbv\xfb\x9f\xfb\\\xfc%\xfb\x89\xfb\x99\xfbe\xfb\xcd\xfc\xb3\xfc.\xfc\xf0\xfc\x9d\xfc\x97\xfbH\xfdM\xfeP\xfdO\xfb\xe1\xfdK\xfe(\xfd|\xfe\x14\xfep\xfd^\xfdu\xff\xd7\xfe\xa5\xfe\x83\xffS\xff\xf1\xfd\x0f\x01@\x01\n\xff\xcd\x00\xd9\x01\xa5\x00\xc4\x02\x8d\x03\x81\x00\x9a\x02\xe7\x031\x02F\x03\xf1\x04\x89\x03\xbe\x02\xbd\x03\xb0\x04W\x035\x02\xa6\x03\x8b\x04)\x03\xed\x02(\x03\x9f\x02\xfa\x00\x8a\x02\xaa\x02\xc7\x00\xcb\x02\xa4\x01\x9f\xffL\x00\xb9\x00\xd4\x00\xe1\xff\xcc\xfe\xe0\x00\xd2\xff\x80\xff\x9b\x01T\x00\xad\x00\xe3\xffp\x00u\x01\xf5\x02m\x014\x01\x0c\x025\x03\x98\x03\x19\x02\x95\x03\x08\x05\xad\x03\xf1\x01>\x04\xac\x05J\x04r\x03W\x04\xd1\x03\xcb\x03\xb2\x03r\x03r\x03J\x03\xb8\x02\xb7\x01\x99\x02W\x02V\x01:\x00\xa7\x00{\x00\x18\x00Y\x00I\xff{\xff\x05\xff\xb1\xfe\xf2\xfd#\xfeK\xfe\x84\xfe~\xfd\xd7\xfd\xed\xfd\xb7\xfd&\xfdx\xfdN\xfdd\xfc\x81\xfd\xbd\xfc\x0c\xfd\xb1\xfc\x1c\xfd \xfd^\xfd\xb6\xfc\xc0\xfb\x8d\xfb\xbf\xfdT\xfdF\xfc(\xfe\xb5\xfc\x13\xfd\x03\xfcd\xfc\xb6\xfd\xde\xfc)\xfc\xbb\xfc\x85\xfd\xb2\xfbW\xfd\xa0\xfc@\xfc\xe6\xfby\xfc@\xfe\x9b\xfc\xae\xfd_\xfd=\xfd\x10\xfd\xea\xfef\xffM\xfej\x003\xffl\xff\xdc\xff(\x00y\x00\x06\x01\xae\x01\xc7\x00<\x01X\x01\x1c\x02\x0c\x02\xcf\x01\xc1\x01*\x02\xfb\x01 \x02k\x02n\x01\xbd\x01~\x028\x01\x1d\x01\xe5\x01^\x015\x01\xfb\x00;\x01R\x00\xb1\xff\xd6\xffw\x01!\x01x\xfe;\x00\xd1\x00\xb2\xff\xc7\xff\x8f\xff\x88\xff\xda\xfe\xc0\x00k\x00\x16\x00\xc4\xff5\xff\x03\x001\x00\xf3\xff\xb8\x00\xdc\x00\x93\xff\xae\xff\xd6\x00\xb5\x01\x1a\x01\xe5\x00\x05\x00\x99\x00\xd9\x012\x02\xbd\x01\xdc\x01j\x01\xb9\x01S\x02\x83\x02w\x02g\x02\xe8\x028\x02d\x02H\x03\xe5\x02\xbe\x02x\x02{\x02B\x02\x95\x02\xe7\x02|\x02t\x02\xb7\x01Y\x01\'\x01\xc7\x01\xb8\x01\x89\x00\x14\x00\xde\x00\x12\x00\xad\xffe\x00\x0c\x00R\xff\x06\xff\xa7\xff\xba\xfe\x9a\xfe\xda\xfe\xe6\xfe0\xfe\x06\xfey\xfeq\xfe;\xfdK\xfe\xe7\xfd\x86\xfc\xd9\xfdi\xfd\xaa\xfd\x90\xfdW\xfcp\xfd\xee\xfc\xb2\xfc6\xfd\xbd\xfd6\xfc\xea\xfc[\xfd\xb0\xfci\xfdc\xfdf\xfc\x1c\xfe\x07\xff\xae\xfc\x17\xff`\xfd\x9b\xfc.\xff\xe7\xfe\x04\xff\xa9\xff+\x00\x91\xfd"\xfe$\x00\xd9\xfe\t\xff\x0e\x00]\x00\x84\xfe,\x00\xcd\x00v\x006\xfe$\x00\x9a\x01\x9e\xfd\x98\x01X\x01\x8b\x00\xf9\xff\xb0\x00p\x00/\x00\t\x01\xa4\x00\x17\x01M\x00\x92\x02u\x00\x8e\x00\xe8\x00\x8b\x01\x03\x00]\x01\x85\x01e\xff\xa6\x01=\x00g\x00\xa1\x00H\x01\xe8\x003\x00\xa8\x00\xaf\xff\xc2\x00\xd6\x00"\x00\x8f\x01%\x01#\x01b\xff\xe4\x01\xdf\x01\xa6\xff\xd4\x01\xa5\x00\xe0\x01V\x02\xd0\x01&\x00T\x02\xb5\x00\xd5\x01\xd9\x029\x02\xdf\x02v\x01\xfe\x01a\x01\xf4\x03e\x01\x03\x04d\x03\x97\x01\xba\x02.\x03i\x02\xf6\x02\xc0\x03|\x02\x95\x02\xe4\x001\x03\xc7\x01\x15\x02#\x02x\x00\xee\x01\xe8\xffS\x00\xd7\x01L\xff\r\x00\xe3\xff:\x00\xc6\xffN\xff\xd3\xff\x8c\xfe\x13\xff\x1f\xfeT\xfe\xce\xff\xd8\xfe\xe6\xfd\x8e\xfd\xaa\xfc"\xfe\x06\xfd"\xfe{\xfd\x14\xfe\xa8\xfb\xa6\xfcr\xfd\x9a\xfdE\xfd\xb2\xfc\x00\xfe>\xfc@\xfdI\xfc6\xfe\xb6\xfd\x8a\xfb|\xfdJ\xfe\xd3\xfc0\xfd\x03\xffV\xfd\xcb\xfb\x17\xfd"\xfe\xbf\xfe\xea\xfc[\x00k\xfbp\xfc=\xfeG\xfec\xff\\\xfd\xfe\x00q\xf9\xe6\x00^\xfeF\x01\x00\x00W\xfc\x93\x00\xc2\xfb\xea\x02-\x01\x8a\xff\x14\x00\x9e\x00\x8a\xff\xf7\xff\xc8\xfeG\x02;\x01\xd8\x003\x02\xaa\xfd\x8d\xffY\x02@\x03\xf8\x023\x00=\xfd\xce\xff\xe5\x01\xb8\x02\x81\x03\\\x02\xc8\xfd\xa9\xfd}\x02\x83\x02\x85\x00\xd2\x00|\xfec\xff\x0b\x02\xae\xff\xc9\x00z\x00\xb9\x00\x07\x00@\xfe\xc6\x00J\x03\xf9\xffj\x00\x10\x02\x19\x00\x98\x01\x8a\x01\x07\x01\xe4\x03\xd8\x00\x89\x01C\x02\xb2\x021\x04\x84\x02\x05\x04(\x01v\x01{\x04\xa3\x03j\x03\x1f\x04x\x01\xf7\x03\xff\x03y\x03R\x02M\x02B\x02\xa9\x01x\x03\xe3\x02}\x02\xde\x00S\x01$\x00c\x01\x8f\x01\xa4\x00n\x00\x92\xfe?\xfe%\x00\xb2\x00\xfd\xfe\x0e\x00\x00\xfd4\xfd1\xff\x98\xfe}\xfd\x19\x00\xc6\xfd\xe0\xfcG\xfdt\xfc\xe5\xfe9\xfc\xbd\xfd\xc4\xfe\x87\xfc\x99\xff\xb6\xfe\xa6\xfa\xb9\xfbc\xfcV\xfc\x9e\xfd\x8c\xfd\x8c\xfc\x86\xfd-\xfa\x16\xfc\x8e\xfc<\xfd/\xfc\xe2\xfbp\xfc\xaa\xfc|\xfd\xbb\xfai\xfe<\xfc\x8a\xfd\xcf\xfd+\xfd<\xfd\x03\x02\x9c\xfc\xf3\xf6j\x031\x00\x12\xfc\x12\x03\x9e\x02\xf9\xfaH\xfe\x9f\x02\xf1\x00\xbc\xfew\xfc\xab\x02\'\x05\x8e\x05t\x026\xfd\x9c\xff\xeb\xfd\xe5\x02B\x02\xda\x02&\x03\x1f\x05\xdf\x04\x15\xfc\xce\xff\xff\x00!\xffl\xff\xc6\x07\x11\x03\xaa\xfd\x99\x03\x9d\x00\x8b\xff\xed\xffx\x01\\\x03\xa4\x03m\xfe\xbb\xfe\x1b\x02p\x03c\x04_\x00W\x02N\x049\xfez\x00j\x05\xfe\x03\xaf\x02\xab\x04#\x06\xa5\x01\xbc\x00\xb6\x066\x06;\x03\x18\x04\x9c\x02\xc3\x02\xd6\x04\xb0\x07\xc1\x043\x02\xad\x03\x0b\x01\xc0\xffl\x02\x96\x03\xce\x01d\x02\xe1\x02\x84\xfdO\xfe\xc6\x01\x97\xffe\xfe\x8a\xff\xe6\xfd\x01\xfd\x8d\xff\xb4\xff\xc0\x01\x82\x00\x17\xfb\xe0\xf9|\xff\xcf\x00\'\xff\xcb\x00I\x01\xbd\xfe\xb1\xfav\xfc\xa2\xfe\x1e\xff$\xff\x99\xfd\xb8\xfd\x8a\xfdY\xff\x99\xff3\xfd\xf8\xf9\xd4\xfb\xad\xfd\xd4\xf9\xda\xfb`\x02\x08\x00\xc9\xf7_\xf9\x99\xfb\xa0\xfa\xe6\xfc(\xfe\xd4\xf8s\xfa\xef\xfc\xeb\xfa@\xfd\xe8\xfb\xdb\xf8V\xf7\xb0\xffh\xfeD\xf8F\xfc\x02\xfe\x90\xfao\xfcM\xfc&\xf9`\xff\x9c\x01\x07\xfdI\xfb\xb6\xfcg\x00n\x03`\x00\xfe\xfa:\xfd \x01_\x02\x8c\xff\xe5\x00\xe5\x02B\x00\x86\x03\x00\x03\xd3\xfcP\xfd\xb6\xff\xa4\x03r\t\xa5\x05f\xfd|\xfc\xfd\xfc\xb0\x00\x85\x039\x01&\xff\x8e\xff\xd5\xff \xfek\x00\x80\x01\x8f\xff\xd1\xfe+\xff\x9f\x03\xaf\x05-\x06\'\t\x0f\x0b\x1b\x0b\xaa\t\xec\x08\xef\x07C\x0c\xd3\x11\xcb\x11x\x11\x08\x12\x9e\x0f$\r;\x0e\x98\r$\x0c\x06\t\x18\x07\xb2\x07@\x08\xfb\x06\xcf\x05\xd4\x02\xf4\xfeF\xfa.\xf8n\xf9\x9e\xf9x\xf8;\xf7\xe7\xf7\x8a\xf5S\xf6U\xf5\xeb\xf4"\xf7\xe1\xf3\x1a\xf3\x12\xf5\xf8\xf9y\xfc\xbd\xfbr\xfb\xe8\xf9\xdf\xf8\'\xf96\xfb\xe1\xfd\x96\xff\xdb\xfc\t\xfe\xe4\xfd\x80\xfcs\xff%\x00\r\xfe\xd8\xfb\xab\xfb\xfb\xfb\xd7\xfe\xc9\xfdp\xfe)\x00(\xfe}\xfb\xf7\xf9N\xff\x06\x00\x18\xfc\x03\xfe\xb8\xffd\xfdA\xfd\xb9\x00*\x01\x1c\xfc\x94\xfa\x93\xfb\x8a\xfb \xfe\xea\x010\xfe\x95\xfcD\xfd\x18\xf9b\xf9:\xfa9\xfc\x98\xfb\r\xf9\x10\xfa\xff\x00u\x01\xd3\xfc\xb2\xf9\xf5\xf4\xdc\xf4\x17\xfc\xbd\x01\xe4\x01.\x00\xc3\xfe\xff\xfd)\xfb\x9c\xf9\x8e\xfc\xe6\xfb\x8f\xfbW\xfc\xb0\xf9\xad\xfd\r\x03\x84\xffc\xfc\xb7\xf8\x8d\xf0\xff\xeb\xfb\xf1G\x01\x13\x12\x0c\x1c\xe0\x1a|\x12H\x0eu\x0fX\x13g\x1c\xb5)\xa44\xf85\xed482D.\r)5\x1f%\x18\xd0\x15\xf0\x17\t\x1c\xfb\x19/\x11r\x041\xf68\xe8\'\xe0\xa5\xdf\x99\xe2u\xe47\xe3\x89\xe1\xdb\xde\xa3\xda\x1b\xd7\x9e\xd4\xb3\xd4\x90\xda\xb4\xe4T\xef\xbf\xf8>\xffW\xffu\xfa<\xfa-\xfd\xe5\x03\xe0\r\x00\x13\xa4\x16s\x18[\x17\x96\x14v\x0f\x13\t\x9e\x04\xde\x03\xc8\x04\xf1\x07!\x08\xfe\x04\xac\xfdX\xf2D\xea\xc7\xe7\xc1\xe7C\xe9\xd0\xec\x9c\xee\xbf\xee\xfd\xed\xf9\xed\x93\xecw\xeb@\xed\x05\xf1`\xf7D\x00A\x07u\x07\xcd\x04\xe0\x019\xfeM\xfe\xb2\x01\x9b\x05\x86\x08\x19\x07"\x03\xba\xfeK\xfa\x9d\xf6\xa1\xf4\x8a\xf0h\xf0\x96\xf2\x98\xf3\x97\xf6N\xf7\xd6\xf2\xfa\xebK\xe7\xea\xe5\x89\xea\xd8\xf2*\xf9\x98\xf9+\xf7\x19\xf5{\xf7\xd3\xf8@\xf8\xde\xf5F\xeep\xeb\xc1\xf3$\x15\\A\xf5S"D\x9b"\x03\x13\x7f!?r\xe9ZqL\x11Ha?\xdf&\xef\x0cc\x071\x10\xd0\x13\xf0\x07\x84\xee\x9e\xd2\xd4\xbb\x86\xad\x91\xad\x8e\xbbR\xcb\x11\xd0&\xc7\x9f\xbe_\xc0F\xc9\x8b\xd0\x87\xd7u\xe5g\xf6|\x08h\x12\x7f\x19*\x1e\xc1\x1c\xad\x1d\xbe\x1f\x0e"8&\xbd"\xff\x18\xe0\x12\x8b\x0f\x97\t}\xfd\x9e\xec\x90\xde\x9f\xd6\xbd\xcf\xbf\xcd\xe0\xcf\xf2\xd0\xea\xcc\xa8\xc6\x0f\xc5\xf3\xca\x14\xd6Z\xdcb\xe0L\xe7w\xf1\x88\xfe\x0c\n\xf2\x12\xd9\x18?\x19g\x15]\x14\x8b\x18\x00 \xd7#\xf1\x1e\xa9\x16\x0c\x0fd\x07Q\xff\x1a\xf7s\xf0\x95\xed\x96\xec\xc7\xe9\xcb\xe6\xf7\xe1\x1a\xdc"\xd6\xe3\xd2)\xd6f\xdfd\xe8\xf1\xee\xb0\xf0\x02\xf2\x90\xf6\x98\xfa\t\x02q\x08\xe0\x0e*\x13\x1a\x15\xb9\x17\xca\x180\x19\xe1\x12\xd1\x0b\x00\ta\x03\xa0\xfd\x96\xfe\xaa\x14;>\xa9Y\x81PC.9\x17\x03\x1d\xa44\xfcL\x88[\x9ba\xfdV\xc8=e\'5\x1c\xa3\x17\xc3\x0bk\xfb\x1f\xf7\xfd\x01\xc4\x0bi\x01\xff\xe2\xf2\xc1\x95\xaf=\xb1\xee\xc1`\xdaN\xed\xfb\xf0\xd4\xe3\xba\xd4\x9f\xd2*\xdc\xa5\xeb\xbb\xf8\xab\x06)\x16\x00$[)\x81"8\x12`\x03\xa3\xffp\x03\x93\x0e`\x17[\x10L\x01\x07\xed\x89\xdaP\xd4\xca\xd3\xfe\xd2\xa7\xd0\xe3\xd0\x85\xd7\xd5\xe0X\xe4I\xe1\xd8\xdc}\xd9\x1d\xdf\xb9\xf1\xc5\x08\xd5\x19W\x1c;\x13?\x0b\xbf\t\x8a\x0f\xb3\x17\xe3\x1a\xf4\x18\x88\x13\xee\x0c\xcf\x05T\xfe\xdf\xf6\xde\xed\x16\xe6\xfa\xe1Z\xe4\xea\xe9Y\xe9\xcf\xe2G\xd9\x07\xd4\xb2\xd5\xc6\xdb`\xe5\x81\xee\xdc\xf4L\xf8\x81\xf9\xf9\xfc@\x01H\x03\x8c\x05=\t~\x11\xff\x1a\x90\x1f\xa8\x1d\xa5\x14\x85\x08\x92\xfej\xfa\x00\xfe\xf7\x04\xc0\x03\xb2\xf5p\xe4\xf1\xe6\xa0\x0c\xda>\xceS\x03=\xbb\x1b\xa7\x14\xaa,\xd4M\xef^\xbdbs^qN]=\xba0j\'\xcd\x1bT\x05\xb6\xf6j\xfc\x8a\t\xf5\x07\xfa\xee\xf8\xcc\xf6\xb7\'\xb5\xaf\xbf\xcd\xcfa\xde\xbc\xe4k\xe2h\xdbe\xda\xbc\xe3X\xf0\x17\xf9B\xfe\xe6\x08\x96\x1a\xf2*\x06,\x0f!j\x11\xa9\x03V\x03\x02\x08U\r1\x0e0\x04=\xf9|\xebR\xdd\xc5\xd38\xcb\x93\xcc\x1b\xd4b\xdb\xab\xe3\xcd\xe7\x0b\xe7,\xe2J\xde\xa2\xe3\x91\xf3g\x05w\x11q\x17e\x17g\x12\x8f\r\'\x0b,\x0b\xa6\x0c\x87\r\xca\x0c@\x0bW\x08\xa0\xff\x16\xf1a\xe2\xd0\xda\xf6\xdb)\xe2\xd4\xe8\x1c\xeb\xc0\xe7\xb7\xe0\x8f\xd8\xf9\xd6\xe8\xdd\xbe\xe9\xfd\xf4C\xfc\xba\xfex\x02!\x05\xe6\x03\xad\x04\xa4\x05\xdf\t+\x11X\x14\xcf\x14\x9c\x11$\t8\xff\x01\xf9\x18\xf8\x16\xf9\n\xf9"\xf2\x92\xe3\x8b\xd78\xdb\xf1\xfd\xdb1\x95RtO\xfc8\xcf,~8SRYb\xe4h^o\xbcq\xb3i@Xb=S\x1c\\\xfd\x80\xe9\xb8\xeaY\xfag\x01f\xefJ\xcf\xd9\xb2Q\xa9\xce\xae\xaa\xb8d\xc6j\xd7{\xe5\xce\xed\xd7\xf2\xb1\xf6\xe6\xf8!\xf6\xf5\xf5\x93\x04\xfb\x1f\x047w=\xf6-\xfd\x15\x1a\x06b\xfd_\xff\x94\xff\xef\xf8\x1e\xf4\xaf\xeb\x94\xe3\xa4\xdc\x19\xd13\xc6\x18\xbf\xe4\xc0\xae\xcd\'\xdeY\xec\xab\xf2Y\xf1\xa1\xee\x9d\xefr\xf7\xa9\x03\x04\x11\xef\x1b\xa7\x1f\x9e\x1d\x9c\x19\xad\x14\xe6\x0er\n@\t\xf3\x08\x84\x07\xf4\x02\x1e\xf9Z\xee\x89\xe5r\xe0\x1e\xdf\xb2\xde\x90\xe0D\xe1@\xe1\xc4\xe0 \xe0\x8d\xe2\xb5\xe7~\xed\xa2\xf4\x82\xfc\xd4\x03~\x08D\x07\x03\x04\xb7\x03\xe4\x07\x8c\x0f\xb5\x14\xba\x14I\x10r\x079\xfe\x16\xf8\xd2\xf5\xc5\xf4\x9f\xf1\xfb\xeb\xc4\xe9\x8f\xec\xa0\xe9\xdf\xdb\x07\xce\x96\xd4\xc4\xfcE2\x95T\x00Z\x94I\xa1:\x85=1M{c\x14uxyRp\x98\\\xf5E\xc50n\x1a\xf2\xfeB\xe7\x1a\xdf\x85\xe3\xcf\xe9V\xe9\x03\xdbC\xc5<\xb2F\xab\\\xb6\x11\xce]\xe7\x1b\xf8\xb9\xfc\x95\xfba\xfb\xea\x00\xe3\x08\x15\r\xc2\x12\xa6\x1b\xe2%\x11,2(\x10\x1c\xdb\x08\x84\xf3\x1d\xe5\xc7\xe1m\xe6W\xed{\xed\xeb\xe2\xb7\xd49\xc8)\xc4(\xc8\xfb\xce-\xd7\x00\xe0%\xeb9\xf6L\xff\x9e\x03\xbe\x02\xc1\x00,\x00\xcc\x05\xce\x13\xcc \x9f#\xed\x1do\x14\xc2\r6\x0b \x08]\x033\xfe\xc5\xfa\xce\xf6\xe6\xf0v\xe9!\xe3#\xde\xa9\xd8>\xd6\xb7\xd8h\xdf\xb4\xe6\xd1\xe8\xd2\xe7\xa1\xe8*\xee\xb4\xf6\xcf\xfd\x07\x04j\t\xac\rU\x11n\x11\xf7\x0e \r\x9c\n\xd0\x08\xb1\x08D\x07\'\x03\x80\xfc]\xf5|\xf0;\xee@\xea\xbe\xe4\xc4\xe2\xd7\xe1\xd3\xdd\x82\xdd8\xec\x93\x10\x98;qR\xefQ\x82FYB\x89K\x0c\\Fk\xddsQu\xafl\xddW\xb8;\xb6\x1d\xfd\x03Q\xf2<\xe8\xd6\xe7]\xeb\xb9\xe8O\xddG\xcdF\xbd\xe0\xb5\xea\xb9\xf3\xc6Z\xda\xe1\xee<\xff\xc9\x08K\t\xd8\x04\x9e\x00\x9c\x00]\x06\xcf\x10H\x1e\xb8\'M(p\x1bu\x07\x0c\xf57\xe7\x82\xe1\xf5\xdf|\xe1\xa3\xe5Y\xe8\x89\xe8\xe6\xe1(\xd8\xee\xcf^\xcbY\xd0\xf5\xdb]\xeb\x1e\xfaJ\x00&\xff\xd4\xfc\xf1\xfb\x99\xfd\xd0\x00j\x04r\x0c\x9a\x17\xb4\x1e\x80\x1b\t\x10\x1e\x03w\xfa\x88\xf8\x95\xfaW\xfd\x92\xff\x9c\xff\x98\xf9\xa2\xee\x9f\xe3\xf3\xdc\xd0\xdbX\xde-\xe4\xca\xeda\xf7N\xfc\x9f\xf9\xb6\xf16\xec\x9f\xedP\xf5&\x01\xb6\x0bS\x12\xed\x13[\x0f\xc9\x07\xf6\xff\xac\xfb\x9c\xfc\xfb\xfe4\x01>\x01\xd2\xfe\xa2\xfb\x01\xf7t\xf2s\xec6\xe7\xff\xe5s\xe8B\xeew\xeb\xf5\xe6\xa2\xf3\xe4\x145>\xa1T>R\xbcG\x9eC$K\'V\xc3^\xc3c&d\x94`\xa2Q\x8f8\x11\x1b\x98\xff6\xea\xf3\xdaE\xd7z\xde\'\xe7\xde\xe6(\xdb\xb9\xca\xf3\xbfx\xbf\xd0\xc7i\xd7\xb5\xeb\xe8\x00!\x11l\x16\\\x13\xf6\x0b\x10\x06v\x04\x8f\x06\x02\x10\xab\x1a\x9d!\xf1\x1e\xea\x0f\xfe\xfdl\xed\x85\xe3\xc1\xe0\x91\xde\x16\xdfS\xe1\xab\xe3\x11\xe4`\xe0\x14\xd9\x81\xd1P\xd0\x84\xd6\xa9\xe2U\xef\x86\xf8\x96\xfdZ\xfe*\xfer\xff\x92\x03\x1c\t\x91\x0e\x85\x13\xb9\x15\x05\x14\xdf\x0e\xad\x07\x7f\xff\x1e\xf9\xfe\xf6\xe8\xf7\xab\xfb\x97\xfe\xbc\xfb\x08\xf4\xc5\xe9\xbf\xe1\xb8\xde\x01\xe1\xc7\xe7\xf1\xeeI\xf5S\xf9\x94\xf9\xda\xf8\xd4\xf6f\xf4\xbc\xf4\x82\xf7;\xff\xb4\t\xfb\x10\x86\x13\xc4\x0eM\x05*\xfeM\xfa\xa0\xfa\x0c\xfd\xb9\xfe%\xff|\xfc\xc0\xf8p\xf2\xdf\xeb\xcc\xe7\xdf\xe6\xe0\xed\x08\xf8\x1b\xfd\x8c\xf8\x80\xee\xbf\xecV\xff\xfe%9M,_\xd4Y\x03L7G\x90NyU\x1dU\xe2OkIqF~>\x82*\xcc\x10\x0b\xf5\xf4\xde\xce\xd3\x0e\xd3\x9a\xdb\xd9\xe5B\xe9\x01\xe3{\xd7b\xd0\t\xd3\xc7\xdd\x92\xeb\xaa\xf8Y\x03\x8f\x0co\x13s\x15l\x12\x89\x0b\xf3\x03O\xffJ\x01\x1f\t\xfd\x120\x16X\x0f\xbc\x00\xda\xedU\xe2n\xde[\xdf\xbe\xe4.\xe8)\xea)\xe9\xb3\xe3\xb6\xdeR\xdb<\xda>\xdc\xd7\xdfS\xe6\x9e\xf0\x1e\xfc\xf5\x05Q\n\x9f\x07\xd4\x01\x0f\xffy\x02J\n\x90\x11\xf4\x15\x1a\x15\x1b\x0f\x02\x07\x16\xfe\xfe\xf6\x02\xf3~\xf0\xb9\xef\xc3\xefk\xf0W\xf0\xab\xee\xff\xea\xcb\xe6\xfa\xe5.\xe8\x98\xee\xff\xf5\x16\xfdS\x02\xa1\x02\x1f\x01\x07\xfe^\xfbm\xfcT\xff\x15\x05\x19\x0bm\x0e\xa0\x0e\r\t\x08\x00W\xf7\xf6\xf0\x0f\xef\xca\xf0H\xf4\\\xf8\xb4\xfa\xe3\xfa\xff\xf7\xc1\xf2~\xed\xf1\xeb}\xf0\xb5\xf5\xc6\xf9\x10\xff\xfe\t\x8f!\xa7<\xaeP\x8bY\x97SKI\x9e@\xec<\xdeB\x8aK\xa1Q\xc4M\x19<\xb7#.\x0b\xf3\xf7\xd0\xea\x15\xe0\x0c\xd9\xc7\xd8\x86\xde\x8a\xe5\xed\xe8H\xe5+\xdeV\xd8\xa2\xd6\xf0\xda\xe7\xe5\xfd\xf55\x08\xd1\x15\xd1\x19,\x15;\rR\x08\xdf\x07\xd4\x08\xff\x08"\t[\nk\x0c\x10\x0bR\x03\n\xf7e\xe9\xef\xde[\xd8\'\xd6\x80\xd9\'\xe0\\\xe7\xce\xe9\xdc\xe4\x7f\xdd\x03\xd9\xe8\xdac\xe1\x97\xe8\xe7\xef\x98\xf8\xef\x02=\x0c\xcd\x10>\x0fQ\n|\x05\x1a\x03V\x03M\x06\x93\x0b/\x10\xa4\x10\x84\n)\xffQ\xf3\xfc\xebf\xea7\xed\xf3\xf1\x94\xf4\x84\xf5\xda\xf4\xec\xf1\xac\xee\x82\xeb\x81\xe9P\xeb\x06\xf0\x1b\xf7\x91\xff[\x05\xc9\x07\x1d\x06\x90\x00\x9a\xfc\xcd\xfb\x0f\x00\xb3\x07\xf6\r^\x11\x8a\x0eT\x07Y\xfe\xca\xf4\xbc\xf0\xc4\xf0\x9a\xf37\xf7\xbc\xf6\xbd\xf5\x8f\xf4%\xf2\xea\xee(\xec\x86\xec\xed\xee\xa6\xf1I\xf4}\xfc\xaf\x11z/\x1eMp^\xd4\\nP\xb1D\xc2?\xafB\x95H6M{N\xf7F\x905\xb8\x1d\xfe\x05\xbd\xf3Y\xe6\x17\xdc;\xd5\xd0\xd3\xc6\xd8\x1c\xe0\xd4\xe4\xdc\xe3Q\xde\xa5\xd8.\xd68\xd9\xb5\xe2\x92\xf2G\x03\x00\x0fh\x121\x0f\xfe\x0b\xe9\x0b\xe7\x0c\xd3\x0c\xe6\n1\t\xbc\n\xb7\r\xaf\x0el\x0bF\x03\xf2\xf8\xed\xed\xf7\xe3\x7f\xdd\xec\xdc\x05\xe2\xea\xe8?\xec)\xea@\xe3[\xdc\x98\xd9\x82\xda\xac\xdf\xf5\xe6@\xef\xa4\xf7\xd8\xfd\x82\x01\xf3\x03\x0c\x06\x89\x085\n"\t\xae\x06\xc9\x05\x86\x07\x9f\x0b\xda\x0e\xbd\r\x0f\t\x80\x01\xaa\xf9\xaa\xf3V\xef\xef\xeeu\xf1u\xf4I\xf6O\xf5\x8c\xf2\xb6\xf0z\xeff\xf0\xfb\xf2\x85\xf60\xfc^\x02$\x07\xca\t\xe4\x08E\x06\xc7\x04\xba\x04\xf4\x06\x1e\t\xa8\t\x17\x08!\x04z\xfe>\xf8\x07\xf4\xe2\xf1]\xf1\xaf\xf2\x0c\xf3\xe7\xf1b\xef\x9b\xea\xdc\xe9!\xeb\xae\xeb&\xea\xc6\xe6\x8c\xed\xa1\x026$\xf6G&]\x99`wUUE\x17<\xe4:\xccB\xa3NDW\xcdW\xbcJ|2k\x15h\xfa\xf4\xe6\xf2\xdc\x89\xd9T\xda\xf9\xdbD\xdd\xcb\xde,\xdfW\xdd~\xd8\xdb\xd1\xae\xcd\xfe\xcf}\xd9\x9d\xe8\x85\xf9\xc6\tG\x16\x1e\x1cN\x19\x0e\x0f*\x03\xe8\xfc\xf7\x00\x9c\x0c#\x19\x87\x1f\x15\x1d\xc3\x13\xc9\x05m\xf6\xab\xe8=\xdf\xbf\xdc\xe0\xdf\xb3\xe4\xe3\xe6\x17\xe4<\xdf\xe4\xdb%\xdb\xd7\xdb\x1e\xdd\x85\xde\xe0\xe0\x95\xe5\x83\xec\r\xf6\xd5\x00\x00\x0b\x9e\x11\x99\x12\x86\x0eN\x08I\x03\x94\x026\x07\xef\x0e\xba\x15\x80\x17\xec\x12&\t\xb0\xfd4\xf4\xb0\xee\xb3\xed4\xefh\xf1\n\xf3w\xf2A\xf0q\xee\x14\xee\xa9\xef\x82\xf2\xae\xf5K\xf9\xca\xfd\xe9\x02e\x08h\rY\x10\x9a\x11\xed\x10\xf3\r\x91\n\xb0\x06\x1b\x043\x03\x06\x02\xe7\xff\x7f\xfb\xb1\xf5.\xf0\xa3\xeb\t\xe9\x88\xe7\xa3\xe7\x03\xe8\xf6\xe6\xd4\xe3\x1b\xdev\xd8\xf1\xd7\xb5\xe1\xd5\xf8\xba\x18L8yOPXGUmJ\x11@\xe8=\xceEOU\x05b[dJY\x02D\x8f+\x0b\x14\xfd\xffc\xee.\xe0@\xd8\xd5\xd59\xd7\x8d\xd8O\xd8\xe9\xd6\x9f\xd4P\xd0\x0f\xca\xf8\xc4\xd5\xc6P\xd4w\xeaz\x01\xfa\x10\xe6\x16l\x16\xbc\x12\xac\x0e3\x0b\x8a\n/\x0f\x8e\x17\t [#\xce\x1e\x02\x153\t\x10\xfdZ\xf1\xc2\xe5\xdb\xdbO\xd6\xf6\xd5\x02\xd9\'\xdck\xdd#\xdd\x92\xdb\r\xd94\xd6e\xd57\xd9\x0e\xe2\xa9\xee&\xfb-\x05w\x0c\xe2\x11\xe1\x15\x06\x18\x9c\x17\x0c\x15\xe1\x11F\x10]\x11\x10\x14]\x16\xf1\x15\x0e\x12$\n\xed\xfe\xc4\xf2\xda\xe8\x8f\xe4_\xe6^\xec\xb9\xf2\xf9\xf5\xaa\xf4\x95\xf0.\xec_\xea\x0b\xedD\xf4\t\xff}\t\x14\x11-\x14-\x13\x17\x11\x12\x0f\xad\x0eL\x0f\xe3\x0e\xab\r\x0f\n\xcc\x04\x00\xffx\xf8\x8b\xf3k\xef\xec\xeb"\xe8\xc6\xe28\xde#\xdb{\xda\xab\xdc\xbd\xde\xb7\xe2.\xe5\xf9\xe3\xd5\xdf.\xdaN\xde\xe9\xf0\x0f\x11&7\x01U\\dgd|Y\x00M\x01D:C\xbeLFZ/e\x7fdfT\xdd8Q\x19\r\xff\xba\xed\x84\xe3\xe1\xddT\xda\xde\xd8,\xd9\xc2\xd9\x9f\xd9g\xd8~\xd6\x01\xd5\x9a\xd3\xe6\xd2\x10\xd6\xad\xdf\x1a\xf1\x04\x06D\x17p\x1f\xfb\x1d}\x16\x0e\x0ek\x08\xcc\x06\x1a\t\x01\x0e$\x13p\x157\x12\x9c\x08\xac\xfa;\xec\x03\xe0\x83\xd7\xc1\xd2@\xd1\xca\xd2\x9e\xd6\x07\xdb\xbb\xdd\xc8\xdd!\xdc{\xda\xdb\xda\xed\xdd\xff\xe3\x0c\xed\xfe\xf7A\x03\x82\x0c\x80\x12\x95\x15P\x173\x19S\x1b\x12\x1c\xfb\x1a\xa3\x18&\x16Q\x14J\x12\xeb\x0e\x9f\t\xf5\x02?\xfb\xcb\xf2\xc5\xea7\xe5*\xe4,\xe7L\xec\x89\xf0P\xf2\xc2\xf1\xbd\xf0\x0b\xf1:\xf3(\xf8\xb3\xff\xd5\x08]\x11\xf9\x16[\x18\x11\x16\xea\x11P\x0e\xf5\x0b\x86\n\xf1\x08\xe6\x05\xec\x01P\xfc\xfa\xf5^\xef\x04\xe9\x14\xe5\x91\xe2J\xe0\xbb\xddC\xda\x1d\xd9=\xd9\x9b\xda\xed\xdb\xbb\xdcl\xdeB\xde\x8e\xdc\xb6\xdc\xb7\xe5+\xfd\xb7\x1fSBSZ\xf5b\xb4`\x1dY\x02R\xf1NIQ\xa6Y\xa5b\x1bfb^NK:1d\x17\xb5\x02\xfc\xf3\x17\xea\xce\xe1\xfa\xdad\xd6O\xd4\xd3\xd4\x19\xd6H\xd7\xc6\xd7\x02\xd7}\xd5\x9d\xd4\x86\xd7\xc2\xe0o\xf0\xb5\x02\xda\x11l\x19\xfe\x18\x86\x13\x8b\rm\n\x9b\ne\x0c \x0eM\x0eC\x0cU\x07b\xff\x85\xf5\xcb\xeb\x80\xe3_\xdd\x1b\xd9&\xd6\xcf\xd4J\xd5\xa5\xd7O\xda\x8e\xdc\xea\xdd\x19\xdf\xc0\xe0\x08\xe3\xf4\xe6\xae\xec~\xf4\xb4\xfd\xce\x06\xb9\x0e\x13\x15\xf8\x19&\x1eM!0"\xa0 -\x1d\xb2\x19\xa2\x17h\x16r\x14;\x10\x89\t\xb8\x00\x80\xf6{\xec\xf5\xe4\x9b\xe2+\xe5\x8a\xea\xef\xefb\xf2I\xf2\xd1\xf1\x7f\xf2\x9c\xf5\xd8\xfa\xb7\x01\xe9\t\xaf\x10[\x15\xd0\x166\x15[\x13/\x11\xc3\x0f\xf9\r"\n\xb0\x05\xd1\xff$\xfa\xd8\xf4\x92\xeec\xe8+\xe2k\xdd\x12\xda2\xd7t\xd5\x18\xd4\xcb\xd3\x14\xd5?\xd7\x7f\xdc\xe1\xe2\xd9\xe8\xa1\xed\xfa\xef\xce\xf23\xf6#\xfa\x80\x01\xb5\x0f\x95\'SF\xc2`\xe7o\xaep\x1eh\x8b^\x80V2S\xe7R?SqR\xcbK`>\xb9*\xf3\x12!\xfc*\xe8R\xd9\xab\xcf\xfc\xc9\xab\xc8"\xcbV\xd0A\xd6\x03\xda\xb3\xda\x1b\xdav\xda\xf0\xdeJ\xe7\x19\xf2p\xfd\xed\x07p\x10l\x16\x16\x19n\x18\x91\x15\xf2\x11~\x0e\xca\t\xbc\x02\xee\xf9\xc8\xf2\xc6\xef\xf3\xef\xdb\xefX\xec\xb5\xe5B\xde\xf8\xd7\xad\xd38\xd1S\xd1\xa0\xd4\xd7\xda\xe2\xe1\x86\xe7%\xeb\x1d\xee\xa1\xf1\xa8\xf54\xfa\xfe\xfe\xe2\x04\x9e\x0c\x0b\x16Z\x1f\x11&N(Y&?!\xfc\x19\x87\x11\x02\tf\x02\x1e\xff\x13\xfe\xd0\xfc\xf7\xf8\x0f\xf3\xb6\xec\x7f\xe7\x99\xe4\x8d\xe4\x00\xe8:\xee\xb4\xf6\xe9\xff"\x07\x96\x0b\xa6\ro\x0e\xfb\x0e\xc2\x0f\xf4\x10}\x12\xb5\x13\xa1\x14\xff\x13.\x11\xa4\x0b\xc3\x03\xaa\xfb\xb3\xf3R\xed\xfa\xe8\xf1\xe5\xec\xe4\xf4\xe3\xe2\xe13\xde\xca\xd8\xcd\xd4\xb9\xd2\xe3\xd3\x19\xd8n\xdd(\xe4\xce\xea\xbb\xf0\xaf\xf5\x94\xf8%\xfb\xeb\xfdX\x01\x03\x06G\t\x03\n\xa6\x06\x90\x01N\x01g\x0c\xda#uA\xf1Y\xe9e\x03d\xffXNL5C9@(BvF\x87H\xe8C\xc76\xbd#{\x10\x87\x00\x85\xf2\xad\xe3e\xd45\xc9e\xc6\xb1\xcc\xa9\xd7\xa9\xe1W\xe7\xa4\xe7a\xe4(\xdfa\xdb\xb8\xdc\xbb\xe5<\xf5\xec\x05b\x11%\x15r\x13\x81\x10v\x0e\x9d\x0bj\x06\x8a\xffz\xf9V\xf6\x17\xf6u\xf7O\xf9e\xf97\xf6\xf2\xeex\xe4\x95\xda\x9b\xd4\x1f\xd5\x9e\xdb#\xe5\x07\xee\xa6\xf3\xde\xf4\xce\xf2\xe4\xef\x8c\xee\x1b\xf0\xd7\xf3\xd7\xf8\xe8\xfdv\x03\xd3\t\x1b\x10h\x15\xde\x17.\x17\xc4\x13T\x0e\xfb\x08\x06\x05\n\x04\x9b\x05B\x07\xcc\x07Q\x05[\x00\xb0\xfa$\xf5\xd4\xf1\xd3\xefY\xf0f\xf3c\xf7\xb1\xfc_\x01\x89\x05\x03\t\'\x0b\x9f\x0c\xcf\x0cD\x0cH\x0c\x9f\x0c-\r\xc5\x0c?\n\t\x06\xcb\x00\x1c\xfb\xd9\xf5\xad\xf0\xe5\xeb\x1a\xe8\x81\xe5\x11\xe41\xe3\xa0\xe2\x93\xe21\xe3\x9b\xe4\x16\xe6\xeb\xe7\xb3\xea&\xee/\xf3\xc5\xf7Z\xfb\t\xfe\xac\xff(\x01X\x02\xd7\x03\xdf\x06j\n\x08\r\xf9\r\x84\x0c\x18\nj\x05\xbd\xfe\xc5\xf8a\xfa\xea\x08?$;B\x85V7Z\xb8NF?\xa64y2\xea5\xba9|;X9S2\x05\'\xdb\x16{\x05\r\xf4a\xe3\xe9\xd4t\xc9?\xc6\xe9\xcb=\xd8\x0f\xe5@\xeb-\xe99\xe2R\xdb`\xdae\xe0\xf5\xeb\xa4\xfaD\t\x89\x15\x9c\x1d\xb7 \x06\x1f\xa3\x1a\xbb\x14.\x0e4\x07\xe2\x003\xfd\xd3\xfc\xb8\xfek\xff\xa6\xfbL\xf2\x1d\xe6\x0c\xdb\xce\xd3\x19\xd1\x8b\xd2\x0f\xd7|\xdd\xae\xe3i\xe8\x05\xebu\xec\x88\xedx\xee*\xef\xc9\xef%\xf2~\xf7>\x00\'\n\xee\x11/\x15+\x14\xd0\x10\xaa\r\xf4\x0b\xe3\x0b\x10\rD\x0ey\x0e\xd1\x0c\xa7\t\x17\x06\x9d\x02\\\xffL\xfb\xa3\xf6\xd2\xf2F\xf1\xc7\xf2\xa7\xf6s\xfb$\x00v\x03\xcd\x04\x0b\x05\x01\x05\xe8\x05=\x08\x9f\n\xa3\x0c}\r\xcc\x0c\xc1\n\x06\x07\x15\x02J\xfc\xfc\xf6\xa9\xf2\x89\xef\xe6\xed\x8e\xec\xa9\xeb7\xeb\x10\xeb)\xeb"\xeb\xe9\xea\xfd\xeb,\xee\xff\xf0\xff\xf3\x96\xf6,\xf9\xfb\xfbd\xfd*\xfeD\xff\x14\x01\x1a\x04\x06\x06\x83\x06\xbc\x05L\x03\xa3\x00\x16\xfeP\xfbR\xfa\xee\xf6b\xf0\xb5\xea\x9d\xe9\xfc\xf48\n\x0f ?0<5\xfc2a0\x151o5\xf79X=\xf9=\xa3=\x15;\x8a4o,Q!\x9a\x13\x10\x04%\xf4%\xea\xb1\xe8z\xed_\xf4\xe0\xf6\xfd\xf3\x0b\xee\x11\xe8T\xe5!\xe6H\xe9\xc7\xed`\xf2\xb5\xf7D\xfeb\x05\xd2\x0b\xa0\x0e\x16\r\xa8\x08\x05\x04\xb5\x01\x1c\x02\xcc\x03\xd1\x04\xbe\x03\xe5\xffi\xfat\xf4\xe4\xee\xef\xe9Z\xe4\x1e\xde6\xd9^\xd7<\xd9D\xdd\xc8\xe0;\xe2\xf5\xe1q\xe1\xbe\xe2>\xe6t\xeb\xa3\xf1\xb8\xf7\xcd\xfc\xbf\x00\xbc\x03\xe6\x06\xb9\n\xd0\rb\x0f\xf8\x0eq\x0eP\x0f_\x11<\x13\xdf\x12\x12\x10+\x0c\x0e\x08\xcb\x04Y\x01_\xfeA\xfc\x9a\xfa&\xfa\x12\xfa\x01\xfb3\xfd`\xff&\x01\xf1\x01i\x02\x13\x04\xb8\x06\xb1\t5\x0cH\rc\ry\x0c[\nE\x07\x7f\x03\xb5\xff\x8e\xfc\xe7\xf9\xd2\xf7\xc6\xf6\xc4\xf5\x91\xf4\x98\xf2\xc7\xef\xa1\xed\xfd\xebq\xeb\xbe\xeb\x1f\xec,\xed\x83\xef,\xf2\xe1\xf4N\xf6w\xf6\xa3\xf6W\xf7\xae\xf8A\xfa\xbc\xfb`\xfc\xf6\xfb\x13\xfa\xd8\xf6\xfe\xf3\xcf\xf31\xf6u\xfa8\xfe\x8e\xff\xc0\xff\xdf\xfeT\xfeI\xfe\x1b\xfe\xe5\xfd!\xfd2\xfd\x84\x00\xc4\t%\x18\x07(!4O:\x9e;\xf4:\xbc;\xdc=\xe0@rC\xb7DeD\xe9@\x84:\n1s%\x1f\x19\xfc\x0b(\x01\xb7\xf9\xaa\xf5G\xf4\xab\xf1t\xed\xf5\xe72\xe2\xa3\xdd\x1d\xdb\xd4\xda)\xdc\xae\xdeo\xe2\x0b\xe7e\xec\xe1\xf1>\xf6\xaa\xf9\x87\xfbk\xfcY\xfd\x8d\xfe\xd6\xff(\x01\xfb\x00l\xff\xb3\xfc-\xf9\x82\xf5\xa8\xf1\x8c\xed\x08\xea\xac\xe7U\xe6\xa5\xe5\x8d\xe4\x0f\xe3\\\xe1^\xe0z\xe0\xc5\xe1\xb7\xe3\xd9\xe5P\xe8t\xebJ\xef>\xf4U\xfaP\x01\xdc\x08e\x0f\xed\x14\xe1\x18\x87\x1b\xc8\x1c\x01\x1c\xd6\x19\x9c\x16\x80\x13p\x10U\r$\x0b\x87\x08i\x05\xb4\x01\x95\xfd\x8f\xfa\x07\xf9\xbe\xf8W\xf9\x93\xfa\xc7\xfb\xd3\xfc\xba\xfd\x01\xfe;\xfe=\xfe\xa6\xfdo\xfd|\xfdD\xfe\xf8\xff\x00\x01\xa1\x01\xfe\x00\xcd\xff\xcd\xfd\xef\xfb.\xfb\xfd\xf9\x97\xf8\x07\xf7s\xf5z\xf4\xb7\xf3a\xf2\x86\xf1\xb3\xf19\xf0\x8e\xed\xcd\xec\x01\xee"\xf1k\xf46\xf6\xde\xf7-\xfal\xfc\xf9\xfd!\x00#\x02\xb9\x02\x0f\x03^\x057\x08\x18\x0c\xb5\x10\xbd\x12\xff\x12\xbd\x11:\x0f\xfb\r\x86\r\x07\r\x85\rE\r\xcd\x0b\x15\n;\x08k\x06\x9c\x05<\x05\xcd\x04\x15\x04\xdd\x02g\x02K\x03\xf4\x05\xab\t\xdc\r\xff\x11I\x16r\x19\x1f\x1b\x0c\x1c\x81\x1c\xe6\x1c\xba\x1d5\x1f\xb6 \xdf!\xd6\x1f\x05\x1cS\x17:\x11\xc0\x0cc\t\xcd\x06\x02\x05o\x02\x90\xfe\xb9\xf9w\xf5\x98\xf1\xa0\xedO\xea\x0f\xe8\x06\xe7\xb7\xe6V\xe6Q\xe5\xab\xe4\x9f\xe4\xd7\xe4l\xe5V\xe6_\xe7\x89\xe9K\xec\xc4\xeeh\xf1(\xf3\x94\xf4;\xf55\xf5\x03\xf6\x95\xf7\xdd\xf8\x96\xf9\x90\xfa\x0f\xfb\xe0\xfb\xfe\xfcq\xfd\xf0\xfd\x06\xfe\xca\xfd\xad\xfdH\xfe\x81\xff\x99\x00\x16\x01\xdb\x00.\x00\x1e\x00Y\x01\xb0\x01|\x03@\x05\xec\x04C\x05{\x06\xec\x05]\x06\x19\x06\xda\x04\n\x05\x82\x05\xbb\x05>\x04\x92\x02\x8e\x02\r\x02\xfa\xff\xbb\xfep\xfd)\xfc\x0f\xfcR\xfb\xb9\xf8&\xf8\xdd\xfa*\xfb$\xf9\xf3\xf8,\xfa\x15\xfd\x00\xfe4\xfe\xe0\xfb\x8c\xfa/\xfc\xd5\xffA\x01Q\xffx\xfdp\xfd\x1c\xfe\x92\xfd\x93\xfeh\xfc\x92\xfa\xa8\xfb\xa2\xf9!\xfa\xaf\xfcQ\xfbN\xfd\x8d\xfe\x10\xfc\x0f\xfd)\xfd\x81\xfc\x12\xff[\x02\xc0\x04\xc2\x04G\x045\x05\xab\x07\xd6\x07\xa6\x06\xc3\x07\xc3\x07\x96\t\xb5\t\xcc\nH\x0b\xf4\x0b\xd7\r\'\r\t\x10\xca\x0c:\n\xbe\x0b\x0c\n\x0f\n\x8c\nM\x08\xee\x07\xf7\t\xc3\x07\x08\t\x7f\x07#\x02C\x02\x85\x02\xfa\x03!\x059\x08t\x04\x16\x01j\xfe\xac\xfe\x87\x02\xa9\x01\'\xff\xa7\xfci\xfb\xe7\xfb`\xff_\xfb>\xfc\x8b\xfb;\xfaa\xf9G\xf51\xf8\x14\xfa\x89\xfb\x13\xfa\xf7\xf8\xc1\xf9\xf1\xf9\x8d\xf9(\xfaS\xfa\xb6\xfa8\xfap\xfb\x96\xfd\x94\xfe\xed\xff5\xfe\xa8\xfc\xfc\xfc[\xfe;\x00\xe2\x00\x05\x01)\x02w\x01\xb9\x00\xbf\xff\x8b\x00\x1b\x01D\x02\x1b\x02Z\x04\xa2\x03\xe2\x02V\x04\xf7\x03*\x05C\x07\xe1\x04\x13\x01o\x06D\x07\x93\x01\xd9\x01l\x05\xbc\x03\x87\xfd\xe9\x00p\xfe\xd4\xfaV\xfe?\xfe\xe3\xfb\xb8\xfa3\xfd\xf5\xf75\xf6\xef\xf5\xb7\xf7\x8e\xf6\xfd\xf5x\xfb2\xf6(\xf3\x94\xf6B\xf64\xf5~\xf55\xf9\xc6\xf7B\xf6W\xfb\xfb\xf9\xc1\xfaZ\xfb\'\xfa\xda\xf7\xd2\xf7:\xf72\xfat\xfb\xa6\xfa\x0f\xf9\x1a\xfd\xb3\xfaI\xfa/\x00\xf4\xfd\xda\xff\xe9\xfd\xb5\x01V\x04z\x06\xdd\x05\x16\x06\xc9\x05\xfa\x07\x85\x0b\x1f\nq\x08\xd7\t\xf7\r\x9f\x0cL\n\x83\t\xe7\n&\x0c\xba\x06}\x04>\n\x8c\n\x15\x07\xc0\x068\x08&\x04\x06\x03\x95\x05\x1a\x05\xee\x04\xdc\x02\x84\x01\x06\x02\xec\x06\x0c\x05\x99\x02Q\x00\xd4\x00>\x000\xff\xed\xfe+\xfa%\xfd\x1d\xfd\xeb\xfb\xb7\xfb\x97\xfc\xb4\xf9$\xfdU\xfb\x08\xfay\xfb\x01\xf8\x16\xfbr\xf9\x18\xf8\xb1\xf7\x94\xfb\x81\xfc\x1d\xf5I\xf9\xb1\xfau\xfet\xfb\x9b\xf8\xcb\xff\xaf\xfd\x92\xfeU\xffh\x00\xca\x01\xec\x02~\x00\xc7\x02t\x06\xa9\x03\x1d\x03t\x04\x9d\x02H\x02%\x06\xcb\x0c.\x06\x99\x04F\x02\x1e\xff!\x08\x91\x0cY\x06\x06\x04A\x07\xd3\x03\x15\x06~\x03N\n\xf7\x02\x83\x03&\x08\x9d\x06\xae\x03R\xfd\xfa\x06W\x00\xdb\xfd\xdd\xfe\xb2\x00\xcf\xfd\x06\xfdT\xfe\xd0\xf8+\xfa\x15\xfb\xa7\xf6\x0c\xf7\xeb\xf8\xd8\xf7\xf5\xf5@\xf7\x12\xf9\x0f\xf6%\xf9\xa0\xf5\xf7\xf8b\xfbd\xf8\xe5\xfc)\x00f\xfd\x9f\xfd6\x00v\xfc\x92\x01\xc0\x03\xf3\xfd\xc1\xff!\xfe\xeb\xfdz\x02\x91\x02\xca\xfe\xf1\xfe\x01\x00\xe8\xff6\x06\xa4\x06\x9e\x02~\x01\xbb\x06W\x084\x06\xb2\x03\xee\x04\xb3\x065\x04q\x07\xc3\x046\x03q\tK\x05\xe3\x01\xce\x01\xb9\x00\xc2\x03\x9c\x03g\xff\x90\xffp\xff\xba\x02M\x01\x0c\x01\xbe\x02\xcb\xfeE\xfd\x8d\x00N\x00T\xfc\xe6\xfd\xc7\xfd\x14\xfe\xf8\xff~\xfe\x8e\xfet\xfdP\xfeX\xfa\x13\xf9\xa6\xfdZ\xfeB\xfe\xc2\xf87\xfb`\xfaE\x00\x85\xf8\xc2\xf6]\xfb\xdd\xf8\x14\xfc\xba\xf9\xec\xfa\xa0\xfd\xea\xfa|\xf9\x99\xff\xc9\xfc`\xfc\xa1\xf3\x19\xfd\t\x00\xac\x03\x85\x04.\xff\xbf\xfft\xfe\xe9\x06q\x00\xbc\x030\x06$\x06\xa9\x04\x9c\x07\xb6\tg\x05.\x05N\x03\xd8\x00\x92\x05\xa6\x04\xa5\x02\x87\x07\x80\x04\xcf\x02\xf6\xfe`\x04\xcd\x05\x0f\x00\xfc\x03\xc3\x00\xeb\xff\x8b\x04\xb1\x08&\x00\x03\xfdX\xfd#\xfe\xaa\x01\xe1\xfd\x19\x03\xc6\x00\xe4\xfc\xda\xfb\xdf\xf2\xf8\xf8=\xfd:\xfa\x96\xfdt\xf9 \xf9!\xf8p\xf8)\xf8\xa7\xf8\xa8\xf8\xf5\xf9`\xfc\x94\xfd\xd6\xfb\xd5\xfb\x81\xfc\x96\xfe\x8f\x02z\x01\xf0\x00*\x00\x12\x05\x89\xfa@\x02#\x02\x12\x01\xb8\x02\x97\x02O\x02}\xff\xcd\x01\x82\xf8>\x038\x02v\x03H\xffQ\x00\x8a\x04\x99\x04A\x02#\x01m\x042\x03s\x03\xeb\x03\x1e\x08\t\x05\xed\x02_\x01S\x06\x0c\x07\x9f\x04\x7f\x07F\x04\xf9\xff.\x03H\x06\x85\x05J\x06\xc2\x01\x16\xfe\xac\xfe4\x01\xb2\x01\x10\x00\xf1\xff8\xff\xb2\xfe\xee\xfd\x83\xfbO\xfb\'\xfb:\xfd\x83\xfc\x8b\xf9\x1d\xfbN\xffe\xfc\x13\xf9\x96\xfc<\xfb\xb3\xf9_\xf9\x82\xf9I\xfcH\x014\xfbm\xfa\xad\xfa\xcf\xfd\x9a\xfeK\xfb\xa4\xfe\x82\xfd.\xfe \xfb7\x00\x1c\x02\x00\x02\xd4\xfeE\xff\x91\x03`\xffr\x03R\x02/\x03\x1e\x06\xa4\x02\xb0\x05\x04\x08\xf5\x05\x0f\x005\x05\x9e\x08\x91\x05w\x06i\x05\x03\x03"\x07%\t\xf6\x05R\x04(\x02v\x00\x07\x02*\x03\xb9\x02\xf6\x01\x14\xfd\xf2\x00+\x00\\\xffT\xff\xd0\xfa\xd7\xf9!\xfc\xea\xfd\xa7\xfe\x93\xfc\xa4\xf9L\xfd\x88\x00P\xfa1\xfc\xf2\x02a\xf9|\xfa\xba\xfb\x95\xfa\xd8\xfdI\xfe\xf0\xfd\xbd\xfa5\xfaj\xfe\xe0\xfe\n\xfb\xfa\xf9\x9d\xfb\xbe\xfd\x9c\xff:\x02r\xfcd\xfc\xd7\x00\x16\xff\xc8\xff@\x01\xf6\xfe\t\x01\xdb\x01\x0c\x03\xde\x02?\x00\xc2\x04v\xfe(\x02e\x05\xdb\x01\xf7\xfe\x92\x03\x98\x030\x02U\x05\x01\x04\xa7\x02u\x02\xfc\x01\xca\x03\xd9\x07\xa6\x01\x82\x03\xce\x02\x05\x036\xff.\x04W\x02R\x01\n\x05M\x01Q\x03\xeb\xfeW\x03\xb9\x00\xbf\x01\x9c\x00u\xfdW\xff\xa5\x03a\x00\xab\xfd\xf5\xfb\xa1\xfb\xc6\xfdl\xfc\xf0\xfb\x82\xfcG\x00X\xfb\xae\xfaF\xfa\xa9\xfb\'\xfa!\xfb|\xfc\xe7\xfa\xe0\xfa\xc3\xfca\xfd\xb1\xfbE\xfb\xb1\xf9\x88\xfd\x00\xfe\xa4\xfd\xbf\x00\xaa\xff\xf1\xfd\x82\xff\x12\x01\xe6\xfd\xd9\x01`\x04`\xfe\x89\xffg\x01D\x04=\x02!\x05E\x05\xfa\x02Q\xff\x7f\xffN\x06\xc4\x07\xc7\x08W\x012\x04\xad\x00\xf1\x03\x11\x04H\x03\xc2\x02\x85\x02\x06\x03\xae\xfe\x00\x07\xb3\x00\x1e\xfc\xa9\xfd\x12\x01r\x02G\x02\x91\xfe\xf8\xfc\xa4\xfd\xea\xfc\xf1\x01\x95\xff\xf6\xfc\x17\xfd\x8d\xfcd\xff\xdc\xfcC\xfdS\xfc\xe3\xfc\xf2\xfb\x97\xfc\xb8\xfcG\xfe~\xfe\x16\xfd\xe4\x00s\xf8\xb2\xfab\xff\x16\xffE\x00c\x00!\xffR\xfa\xc4\xfb\xd3\x00_\xfe/\xffT\xfe.\xfd\x93\xffW\x00\xac\xfb2\xfd\x94\x01\x84\xfc\xab\x01}\x01\x9a\x01\xe4\xff>\x01\x8b\xff.\x03`\x03\xeb\x00\x8f\x03\xfb\x04^\x046\x03\x13\x05\xcf\x02\xd2\x03\x04\x02\xdf\x06U\x03\x93\x04\xa2\x01a\x01\xb5\x04\xc4\x04{\x02\xe8\x02#\x03\xd6\xfd2\x00 \x04I\x040\xff0\x02\xf2\xff\xf6\xfb\xec\xff\x1a\xff\x97\x01\xcd\x03\xd6\xfb\x00\xf91\xfeY\x01:\xfe"\xfb\xf9\xfaJ\xfdH\xfd[\xffo\xfc\xd6\xf9\xae\xfa\xa5\xfbq\xfe~\xfc\xcc\xfc\xa7\xfb\x9e\xfc\xca\xfe\x85\x03\x81\xfe0\xfb\x11\x00\x8a\x01\xc1\xff\xaf\xff\x9b\xfe\x8e\x02\xa0\xff\xf2\xff\xb7\x03\xc5\xff\xea\x00\xdf\x01\xa6\x00\xed\xfc\xad\x02\xdd\xfe\xb7\x04x\x04\xa6\x00\xe7\x00W\xffv\x03*\x05Y\x06\xcc\xff#\x01-\x04<\x01\xda\x00G\x02r\x06\xca\x019\xffa\x00R\x01\xb3\x04m\x008\x00\xba\xff\xd5\x01\xd7\x00m\xfe\xef\x00\x9a\x02\xc2\xff\x8f\xffv\xff\xcb\xfdK\xfd\xb0\xfe\xac\x01\xd7\xff\xaf\xff\xd2\xfa\xa2\xfd\xe1\xfd5\xff,\xfc\xc6\xfd\x16\xfe\xf0\xfe\xa4\x004\xff\x0c\xffw\xfa,\x01\x7f\xfc\x9b\xfc&\xff*\x00\x9d\xfe/\xff\x9d\xfeQ\xfd\x8d\xfe\x9d\xff\xb1\xfc\xfc\xfc\xd2\x00\xb8\x00\xae\x02\xb8\xfe\xea\xfe\xba\x01\xb9\x02\x8a\xff\x1b\xff\x93\xff\r\x04\xa4\x02P\x02\xb0\x01\xc1\x00\xe9\x00\xdc\xff\xbe\x04\xde\x021\x01%\x01\xaf\x01\x97\x01(\x03\xe8\x02\xa2\x00\xab\x00\xa7\xff\xe7\xfdB\x02&\x00\xa1\x00\x05\x02m\xff\xfd\x01\x9f\x00\xfb\xfe\x00\x01\xe3\xfe\n\xfe4\x00\xc4\x01\xfa\x01\xd7\xff\xd7\xfe4\xff,\xfe\xae\xfc|\xfe\xe6\xfe\xa1\xffO\xfee\xfd|\xfe9\xfc\xe5\xfdV\xffT\xfeP\xfcL\xfc\xce\xfeX\xfd\x85\xfeW\xffE\xff\x14\x01\xe6\xfc8\x00\xd0\xfd\x15\xff\xdd\x04\x01\x00\xd8\x00\xa9\xfe7\xff_\x00\xec\x02\x97\x00\xab\xfe\x18\x04j\x00\xa5\xfd.\x01\x8f\x01\xf8\xff\xba\xfd\xda\x00>\x02\x18\x00\xa7\x02\xba\x01\xb3\xff{\xffj\xffS\x007\x00i\x01F\x03\r\x00\xdc\xfd\x17\xff\x1c\xff\x9a\x01W\x02\xa0\xfe/\x01\x0c\x01+\xfd\xd2\xff\x14\x00\xc3\xfe\xc1\xfft\x02\x10\x05\xd6\xfe\x98\xff=\xfe\x80\xfc!\x01\x99\x00\xf2\xff\xb0\x01>\x01-\x00\xb1\xfe\xe1\xfc\xb8\x01y\xfe\x80\xfd\xc8\xfe0\xff\xea\x00x\xfff\xff\xa8\x00~\xfe\x1e\xfd\x84\xfc\xe8\xfd\xd0\x01\xa7\xff\xd6\xfe`\xff\xb2\xfd\xfe\xff*\x00\x00\x00\x1f\x00`\x00\xb2\xff2\x01\xc4\x01\x85\x00A\x01]\x00\xac\x01\x9f\xffA\x02\xb1\x00\xcc\x01"\x03\x86\x00\xbe\xff\xc0\x01$\x01Z\xff:\xff+\x03$\x01\xb6\x03\t\x02\xa0\xfc\xb0\x01s\xfe\x11\x03<\x00\x1f\xffS\xff!\x01\x02\x03\xba\xffg\xfe\xb4\xfe\xa9\xff\xae\xfdN\xfd\xb7\xffx\x03G\xff\xc8\xfe\xf7\xff$\xffY\xff\x1b\xfe\xc0\x00\x9e\xfe\x7f\x00i\x00d\xff\xf6\x00\xd2\xfd\x86\x00\xd3\xfd\xb8\x00\x0f\xff\xdf\xfe\xc5\xfdf\x03N\x00\xdb\xf9\x06\x03\x19\xfe\xb2\xff\x17\x04l\xfdc\xfd\x98\x03w\x00\x04\x00\xdb\x01q\xffZ\x01\xcc\xff\x8a\x01\xa4\t\x94\xfe\xb6\xfb\r\x00<\x05\xab\x02\xbb\x01H\x04\x0e\xfdP\xfc<\xfd\xa7\xff\xa3\x02\xc1\xfe>\xfd\xfc\xfe\x8c\xfb\xaf\x01r\x03\xef\x018\xfcv\x00\x80\xfc\xd0\xff\xfe\x06\xcf\xff\x19\x01\xc7\xff4\x03n\x025\x00=\xfc5\xfd\xd2\xfb\x1a\x015\x00\x14\xfee\xfe\x10\xff\xb9\x01\x0c\x01M\x01O\xfd\xc0\xfb%\xfd\x9a\xfe5\xfe\xd9\xfe\xf7\xff\x0f\x00V\xff\x85\x00\xb3\xfb\x1e\xfd^\x02e\x02"\x00X\xfaC\xffb\x02s\x01\xca\x021\xffw\xff\xc3\x00B\x02\x01\x01_\x00\xe8\x01\xdf\xff\xb8\xffP\x03\x9f\x03\x9d\x02\xd9\xfc\x1d\xfd\xeb\x01\x1a\x05\x88\x00\x1b\x03N\x07$\xfc\x02\x01\x90\xfd\x97\xfd\x0e\x03N\x04\xc2\x038\xfeu\x00T\x05!\x02\xca\xfb-\xfe\xe5\xff!\xfd\x93\xfe\xf1\x035\x00\xe2\xfc\xcb\xfd\x0f\x017\xfd{\xfa\xb6\xfd\x18\xfe\xe7\xff\x82\xfe\x8b\xfe\xb2\xfc8\xfd\xd7\x00\xbb\x00\x96\xfe%\xfd\xd9\xfa\xe8\xfd?\x01\xbd\xff\xd6\x02\xcf\x00\xb0\xfb\x8f\xff\x8e\x00\xc7\xfd\xbe\xfe-\x00\x9d\xff{\x00~\x04[\x00\x03\xff\xdc\x023\x00\x00\x00-\x01{\x01\xf0\xff\t\xfe\x14\x04\xda\x04\xeb\x010\x01\xa3\xfdd\xfc\x15\xffJ\x01\xc3\xffP\x01[\x03\x91\x03\x1c\x02q\x01\xeb\xfc\xba\xf9\xa6\xfcI\xff\xbe\x01\xc6\xfe$\xff`\x05\xc3\x02\xfb\xff#\x03\xd2\xfb\xf3\xf9\x1b\x00\\\xfck\x01\xd6\x06\x8b\x029\xffS\x01b\xfd\x15\xfe6\x00\xd5\xfc\t\x00O\xffT\xfc\x8d\xfed\xff\xd7\xfct\x00a\xff\x94\x01C\x00\xcc\xf92\xfb\x9f\xfd\xf7\xfe\xa4\xff\xef\x00\xc7\xfd\xf4\xfe\x87\x01~\x01\x17\xfc\x08\xfd\x9b\xffX\xfd\xca\xfe\xcb\xff \x00h\x02\x7f\x04\xcd\x02!\x01i\xfcq\xfe.\x01\xf6\xfe\x16\x02 \x04\xb7\x02x\x05P\x05E\x03\xd9\x01y\x01\xea\xfe\xd5\x00x\x02\xf1\x01\xb9\x03\x1c\x01p\x01R\xff\xb5\x02\xea\x05r\x00(\xfeg\x01\x92\x02J\x00\x1e\xfc\xd9\xfce\xfc\x1f\xff\xe0\x01\x19\x02 \x03z\x012\xffM\xfc\xee\xfa\xce\xfb\xbe\xfd\x0f\xfe\xe9\x00\xd1\x01\xb1\x01\xec\x00\x0f\x01R\x00}\xfc\xca\xf9\x85\xfb\x95\x00\x9c\x00\xec\x00\xea\xfdf\xff1\x00\xec\x00\x04\x02\xe3\xfd"\xfe\xe7\xfez\x03\xe7\x03\x14\x01%\x00\xbf\xfcr\xfc\x99\xff\xe7\xffd\xfek\xff!\x02;\x02\xf6\xff\xb3\xff6\x005\x01\xc1\xfd\xdb\xfd\xec\xff\xe9\x01m\x02\xf3\x03\x96\x05\x0b\x03\\\xffS\xfd\x15\xff\xce\xfeM\xfe\xa3\x000\x03\xad\x01I\x02\n\x01|\x00\xf2\x01\x1b\xff\xc4\xff?\xff\xb0\x00\xe3\x00\x1d\xfed\xfe\x8d\x00\xf1\xffE\xfe\x8e\x00\xc4\xfd\xa6\xfd\x95\xff/\x01\xf8\x01u\xff\x07\xfeY\xfc:\xfd\xb6\xff\x87\x03i\x00r\xfc\xf6\xfb\xa6\xfd\xc4\x00m\x01\x81\xff\xfe\xfcB\xf9\xa2\xfb\x86\x01\x1f\x02:\x017\xff\xda\xfd\xc4\xfe\x03\x04c\x03]\xff\xd5\xffM\xff\xee\xfe\x1f\x017\x03-\x02\x8e\x00\xb3\xfe*\xfe\x03\xff\x9b\x00\x0f\x01<\x00\xb4\xfe\xce\xfe\\\xff\x02\x01;\x01S\x00\x9e\x01\x82\x01V\xffC\x00\x11\x021\x01\x16\x01\xff\xffW\xffU\xff\x81\x02B\x02\xe6\xfe\x9a\xfe3\xff\x86\xfes\xfeP\x00z\x00T\xffX\xff\xc0\x000\x00\x8e\x01\xa5\x01\xd7\xff=\xfec\xff\xd3\xffi\xffd\xff\xed\xfd>\xfe\xd3\xfe\n\xff~\xfeU\xfe(\xfe\xf1\xfe\xd6\xfey\xfe\xa6\xff\xa9\xff\r\x00^\x00\xed\x00P\x01q\x01_\x01\xdf\xff\xa5\xff\x83\xff5\x00\xbe\x00\xa7\x00L\x01\x1c\x01\r\x00(\xffh\x00A\x01\x7f\x01\x08\x01\xa9\xff\x8d\x00c\x01\x00\x01\x9e\x00\xf6\xff_\x008\x01P\x01\x11\x010\x01\xec\x00!\x00\x82\x00\x1c\x01V\x017\x01\x13\x01\xc1\x00\x9c\x00\xbc\x00\x03\x01\xa7\x006\x00O\x00\\\x00@\x00h\xff\xdd\xff\xf8\xff/\xffK\xfe\x15\xfe\xb9\xfe\x14\xffM\xff-\xff9\xfeC\xfe\x85\xff\xba\xffc\xffF\xff\xde\xfe)\xff\xa3\xff\xcf\xff5\x00\xcf\xff\xaa\xfe7\xfd\x1b\xfd\x02\xfd\x10\xfd\'\xfd\xf8\xfc\xde\xfd\x19\xfe\x11\xfeW\xfdN\xfd\xe2\xfe\xc3\xfe[\xfd\xb5\xfc!\xfdv\xfe\xbc\xfea\xfe*\xfe#\xfe\x17\xff\t\xff\xbd\xfe\xfe\xff\x05\x00m\xfe\xb2\xfd\xb7\xfdm\xfe\x9c\xfe\xdc\xfdO\xfd\xc2\xfd\xc8\xfe\xf8\xfe\xbb\xff\x80\x00\xfe\xff\x1b\x009\x00C\x00\xac\xff\xcc\xfe\xfb\xfc6\xfa\xcc\xf8\x95\xf7\x05\xf7\xb3\xf9M\x01`\x0c;\x17Y"\x13,\x8c2u5\xb73\x080\x06*|"Z\x1a\xf8\x11\x94\t\x9b\x01p\xfb\n\xf7\x1f\xf3H\xf0\x0c\xed\xdb\xe9\xf7\xe7\xeb\xe5\xf4\xe56\xe6\xcc\xe6\xc0\xe7W\xe9\xa0\xed\x15\xf3\xaf\xf8J\xfe\xff\x02\xd5\x05;\x08\xb8\t\xd4\tM\x08\x17\x05\x12\x00q\xfb\x96\xf7\xcf\xf4c\xf3|\xf18\xf0\x1d\xf0V\xf0\xa4\xf1\xad\xf2x\xf3U\xf4\xc0\xf4\x87\xf5W\xf6\xa8\xf7&\xf8\x9c\xf8\xb9\xf9\x0b\xfb!\xfd3\xfe\r\xff\xe6\x00\xa1\x02\x9c\x05\xa6\x07\xc0\x08\xfe\t\x87\t\x1c\t\x96\x08e\x08\xd1\n%\x0fQ\x12Z\x15t\x16\x9c\x16\xd8\x15\x0f\x12\xe7\x0bx\x03\xa1\xfb\x9e\xf4&\xef\x86\xea\xb4\xe7\x02\xe6f\xe5&\xe8\xa5\xec\xa2\xf1\xa1\xf6\xe3\xfaT\xfe\xa6\x01\xa4\x04d\x06\xc4\x06\xc9\x05\x9e\x03<\x01H\xfe\x97\xfc\x81\xfa\xcc\xf7\x15\xf6O\xf4\xff\xf2d\xf3\xa2\xf3\xef\xf2\xb3\xf3\xb9\xf4r\xf7\xa5\xfa\x89\xfdi\xff\xec\xff\x15\xffO\xfd\x9c\xfb\x1f\xfa4\xf9%\xf8\xe9\xf7\x9e\xf7\x81\xf8J\xfb\xa5\xff@\x04U\x08m\t\x81\t\xf3\t\x18\n\x9d\t\x18\x05x\x01j\xffW\xfc:\xf93\xf6\x00\xf6n\xf7Q\xfa=\x05V!\x8bJ"i\x0bp;gHd\x0fk\x88gOR\xe40A\x12\xc9\xfa\xda\xe6/\xd7 \xce\xb0\xcci\xcco\xc7\xb4\xc3\x07\xcc\xa7\xdd\x8c\xe9\xd2\xe5\xd9\xdcT\xde\xa7\xecc\xfb\xb1\x00\xe5\xff]\x02\x80\x0b\x04\x15\xbf\x1b\x07\x1e\xfe\x1b\xea\x14;\x07\x85\xf8\xaa\xf0\x88\xeb\x1c\xe4\xa5\xd67\xcb\xef\xc9C\xd0\x9e\xd9\x1c\xdfQ\xe0\x85\xe2\xce\xe7V\xee\x98\xf3\x1a\xf9\xd1\xfe\xdc\x00\xaa\x00\xcc\x02S\nt\x13\x94\x18\xb2\x17>\x13\xa7\x0f\x07\x0e\xb9\n|\x03\xb4\xf9\x8b\xef\x07\xe8\x15\xe5\xa0\xe7J\xed\xe5\xf2\xb7\xf7\xbb\xfdn\x06T\x10E\x18\x06\x1b\x8c\x18\xc1\x13\xa9\x0f\x0e\r\xfb\nx\x07w\x02\x92\xfe\x96\xfd1\x00q\x04d\x07@\x08=\x07\xf7\x054\x06\xc0\x05\xa9\x04X\x01a\xfdy\xfb\x19\xfc\x00\x00\xf4\x03{\x06\xd8\x07\x94\x08-\x0b\xe7\r\x1b\x0e\xcd\n\xe0\x03\x85\xfd\x92\xf8\xac\xf4\xf8\xf1K\xef%\xee\x9c\xed_\xeel\xf1\xa6\xf6\xd6\xfb\xd5\xfe\x98\x00\xd3\x022\x06\x91\t\xed\ni\n5\t\xb3\x08\xcf\x08\xb9\x07\x08\x05\x1a\x01o\xfc\x96\xf7\xb4\xf3 \xf1\x0e\xef \xee\xb3\xedg\xeeZ\xf13\xf6\xb4\xfb\x95\xff(\x02(\x04\x84\x05\x85\x060\x06\x9b\x04\x12\x02\xe9\xffI\xfe\'\xfd\x94\xfc\x14\xfcc\xfb\x0e\xfa\x00\xf9#\xf9M\xfa\xbb\xfan\xfa\x04\xfaX\xfa\x8e\xfb\xc5\xfc\x99\xfd\xec\xfd\x06\xff\xa6\x00x\x02n\x04\xd2\x05+\x06\x10\x05(\x03\xf8\x01n\x01\xd4\xffD\xfc{\xf6\xc9\xee\xbd\xea\xea\xeez\xfb\\\x0b\xd8\x17\r#{7GU\x13i\xfdd\xc5M\x898\x102\x97-\x04\x1d\x92\x015\xe9-\xdf\xee\xdfH\xe0\xf0\xdem\xdf\xf5\xe0W\xe1\xab\xe0\x81\xe3\x7f\xeb>\xf3\x9e\xf5\x0c\xf5*\xfa\xec\x08t\x19\tL\x0e}\x11\x9f\x10\xad\n\x0f\x01)\xf7\x8e\xefH\xeb\xb4\xe9P\xe9/\xea\xd3\xec\xe9\xf2\xdf\xfa>\x01\xea\x03s\x02r\xff\x8b\xfd\xf1\xfc&\xfdi\xfc\'\xfb\xd5\xfa\xd5\xfc\xe5\x00T\x04\xad\x04\x08\x02\x0c\xfe\x86\xfa\x13\xf8\xf2\xf5s\xf3h\xf0\xc8\xee\x13\xf1\xb2\xf6\xe6\xfd!\x03\xab\x05\xef\x06\xda\x07j\t\xd4\t\x9d\x071\x04\x91\x00\x04\xff\x03\xff\xad\xff\xe9\xffS\xff\x89\xfe/\xffW\x01_\x03\x13\x04\x13\x02\xb0\xff\xf7\xfe\xb9\x00o\x03\x1c\x05\xe3\x05\xf3\x06\xfb\x08\x03\n\xfe\x08\x96\x06a\x04\xb7\x02\xe2\x01\xb1\x00\x87\xfe\x88\xfa\x14\xf5\xf1\xf2\x10\xf9\x1a\x07\xc7\x16\xb5%}9JS\xdegDi\xfaV{?0.\xbb\x1e\xb2\x08\xcc\xebZ\xd2f\xc65\xc6\xdd\xc9\xdd\xcd\xae\xd4A\xe0G\xeba\xf1\x06\xf4c\xf8C\xff!\x04\x83\x03\x91\x01\xd7\x04D\r\x8e\x13\xd4\x11\xad\t\x7f\x00\x08\xf8:\xee\xff\xe1\xce\xd6\xc5\xcf\xb1\xcc\x85\xcc\x8c\xd1\x10\xdd\\\xed\xe5\xfc6\x08`\x0f\x90\x14\x15\x19\x1e\x1a!\x14\xf4\x07\xff\xf9\x9f\xef`\xeb\xd6\xebg\xec\x8e\xea\xd6\xe8\x17\xeb\x81\xf1\x89\xf8\x99\xfb`\xf9\xb3\xf5\xc6\xf5\xf8\xfa\xfe\x01V\x07\xd4\n$\x0e\x80\x13\\\x1a\t \x0b!\x83\x1ba\x11\x96\x06\x0b\xfe3\xf8o\xf2\xcd\xeb*\xe6\x00\xe5\x8c\xea/\xf5\xfa\xff\xea\x06\x99\n\xb0\x0e\xbe\x14\xb6\x19$\x1a\xe0\x15\xf2\x0fa\x0c\xfb\x0b\xee\x0c\x16\x0c\x89\x08\x0c\x04\x1c\x00\xc4\xfc\xb1\xf9\xe2\xf5q\xf1$\xed\x17\xeb7\xed9\xf3\\\xfa\xe9\xff3\x03\xa6\x06\xcf\x0b:\x11\xa3\x12\xea\rI\x05\xdd\xfd\x1c\xfa\x80\xf8\x99\xf5n\xf14\xef2\xf2\x82\xf9y\x01\xcc\x06\x07\t<\tb\x08\x95\x06Y\x03#\xfe\xb5\xf7s\xf1s\xed\xa0\xed\xbd\xf1Z\xf7.\xfck\xff/\x02%\x05\x80\x07W\x07\xac\x03\x97\xfd\x8e\xf8\x92\xf6\xc2\xf7j\xf9N\xfa%\xfbR\xfd,\x01\x03\x05\xce\x06u\x05\x0c\x02\x04\xff\x9e\xfd\x93\xfd\x9e\xfd\xbb\xfd"\xfek\xff\x15\x02@\x05 \x07\xbd\x06\x14\x04\x99\x00\x13\xfe@\xfd \xfd\xa4\xfc\xb7\xfbj\xfb\x03\xfd\xf5\x00\xa5\x05}\x08f\x08\x84\x06\xe3\x04\x10\x04\x8d\x03a\x02\x82\xff\x90\xfc\xa6\xfb\xe0\xfc!\xff\x11\x00G\xff\xeb\xfd\xf0\xfc\x87\xfd\xfd\xfe7\x00\x04\x00\xde\xfe\x86\xfe\x8a\xff\xc4\x01j\x03\xdf\x03\x1d\x03\xcd\x01\xe3\x00q\x00R\xff\xf9\xfc/\xfa\x8c\xf8\x94\xf9\xad\xfdW\x02S\x043\x03\x1f\x01\xd4\x00\xa8\x03#\x06\xb0\x03w\xfb?\xf3\xe5\xf5\x01\tn$q8\xcd<\xe6:%A\xa9L\xd0J\xe50\xbf\t|\xec\x15\xe3\x9c\xe4\xab\xe3;\xdbg\xd4\xfa\xd7\x15\xe6\x16\xf5;\xfcX\xfa9\xf3\xd2\xed+\xee\xdf\xf3\x89\xfb\x8c\x009\x02f\x03\xf6\x07/\x0e\xb7\x0f\xa8\x07o\xf7c\xe6\xeb\xda\x8c\xd6\xa1\xd7A\xdbc\xe1\xa7\xe9\x86\xf3n\x00\x1b\x0fB\x1a\xa0\x1b\xe3\x12\xef\x06\x08\xff\xab\xfbB\xf8#\xf1\x99\xe8\xef\xe5\xa3\xeb\x0e\xf5\x1c\xfbH\xfb\xd5\xf8\x8d\xf7\x8b\xf8g\xfa\x90\xfbb\xfc\xfc\xfeL\x04\x10\x0c\xa8\x153\x1e\xb6!\xa7\x1e\xe4\x17P\x11X\x0bi\x03\xab\xf8\xc0\xed\n\xe72\xe7\xdb\xec\xcd\xf3\xac\xf9D\xff\n\x06\x18\r\xdb\x11:\x13[\x11\x9f\rg\t0\x06P\x05,\x06\xef\x06~\x06n\x05w\x04\xf0\x03\xba\x02x\xff\x1f\xfa@\xf5\xbc\xf3\x03\xf6\xf5\xf9\xd5\xfc9\xfe\x1c\x00J\x03\xb0\x06e\x07\xd1\x04\xc8\x00\xd8\xfdV\xfd(\xfe\x7f\xfep\xfd\xd9\xfb\xa2\xfb\x05\xfd\xa0\xff\xd3\x01\xac\x02k\x02\x16\x02\xb0\x02\xcb\x03\xea\x03H\x02\x84\xff\xdb\xfdo\xfe\xed\x00\x1d\x03_\x030\x02\xde\x00,\x00\x80\xff\xac\xfdq\xfaK\xf6\r\xf33\xf2\x12\xf4\xdc\xf6\xe3\xf8\xcd\xf9)\xfb3\xfes\x02\x8b\x05a\x05=\x02\xd3\xfe)\xfd\x9e\xfdW\xfe]\xfe\xa7\xfd@\xfdq\xfe9\x00\x0f\x01\xad\xffU\xfc\x04\xf9\xc2\xf7\x10\xf9B\xfb\x85\xfcq\xfcj\xfc\xe1\xfd\x91\x00\x99\x02b\x02\xcc\xff\xfa\xfc\x12\xfc\x87\xfd\xf6\xff\xe9\x00>\xff]\xfd\x84\xfd\x96\xff\xd8\x01\xc4\x01\xea\xff.\xfe\xec\xfd\x11\xff?\x00\x18\x00f\xfeA\xfc-\xfbA\xfc\x1a\xff\xce\x00\xe9\xff\x9c\xfd\x8d\xfcP\xfd\xc6\xfd\xc1\xfbE\xf7\x18\xf2;\xeei\xed-\xf6:\x0f\xd53\xd7R\x84\\KUJN\x9bK\x94A\xaa\'\xfe\x07\xe7\xf40\xf4\xf8\xfa\x9b\xfb\xdc\xf4d\xeeq\xee\xa6\xefr\xeb\x80\xe2\xc1\xdac\xda\xb1\xe0\xeb\xebi\xfay\t\xf5\x14\xb3\x17\x1d\x13C\x0br\x02\xf2\xf7M\xea[\xdc\t\xd4\xcf\xd4S\xdd3\xe8A\xf1\xeb\xf5\xe3\xf7o\xfbi\x023\x08\xa3\x06\x0c\xff\x1a\xf84\xf8y\xfe[\x04\x98\x05\x83\x010\xfc\x01\xf85\xf4x\xef\xe3\xe8\xff\xe1\xb1\xdd\xe8\xdeb\xe6\xb9\xf1f\xfdx\x06;\r\xfc\x12\xfe\x17\xbf\x1ad\x18?\x11\xcb\x08n\x03\xfa\x01\x01\x02\x07\x01\xbe\xfe\x86\xfd\x91\xfe\xf7\x00\x12\x01v\xfd\xbe\xf8\xef\xf6\xf0\xf9G\xff\xb9\x04(\nn\x10B\x169\x19\xe9\x18\xde\x16h\x14\xa6\x10:\x0b\xa2\x05[\x02\\\x02\x0f\x03\x1e\x02,\x00\xd3\xff\xaa\x01\xbc\x02\x81\x00\xf8\xfb\x0c\xf9\x98\xf9\xa2\xfbG\xfc\x1d\xfc\xb3\xfd\n\x02\xe3\x06\x92\tz\t\xac\x07v\x04\r\x008\xfb\x97\xf7\x86\xf5\x1d\xf4\xfd\xf2I\xf3\xec\xf5\x0b\xfa{\xfcv\xfbg\xf8\xf4\xf6f\xf8\x1d\xfb$\xfc\xc8\xfb\x17\xfd\x8f\x01\xc4\x06C\t}\x07n\x03r\xffh\xfc\xa4\xf9\xb9\xf66\xf4[\xf3\xea\xf4\x82\xf8\xd7\xfc\x90\x00\n\x02,\x01\x06\xff\xaf\xfd\xb5\xfd/\xfe\xbb\xfd\xa3\xfc\x89\xfc\x0b\xfeY\x00\xe2\x01\x8f\x01\xd3\xff\xd0\xfd\xa3\xfcZ\xfc>\xfc\xb5\xfbE\xfb\xe0\xfb/\xfeo\x01d\x04\xa7\x05L\x05F\x04x\x03\x10\x03r\x02Z\x01\x0f\x00\x8a\xff\x03\x00%\x01=\x02g\x02<\x015\xff\x81\xfdL\xfds\xfe\xb8\xff\xcb\xffJ\xfe\xb7\xfcV\xfd\x9d\xff\xa3\x00G\xfe=\xfa\x0b\xf9#\xfc\x08\x01.\x04\xdc\x03\x8a\x02y\x02\xeb\x05~\x0b~\x0f\x7f\x0f\n\r\xba\x0c\xb0\x11\x19\x1al%\xb31\xc7=\x81E\xbfB\x995 #\xed\x11\xde\x04^\xfa\x94\xf2\xe0\xec\x0e\xe8\x07\xe5\xcd\xe3G\xe3\xb1\xe0f\xde#\xe0\xb7\xe5\xee\xec\xbb\xf3\xf0\xfb\xd9\x04\xa1\n\xb6\x0be\t\xf6\x04U\xfd_\xf2\x1b\xe8\x08\xe3\xe2\xe3\xbc\xe6\x06\xe7o\xe6\x14\xeaj\xf3\x86\xfa\xb7\xf7\x97\xef\x12\xee\x8e\xf6L\xff\x1f\x01k\x00\x1b\x04\x97\t\xf7\x08@\x01\xb9\xf8\x8d\xf3T\xef\x7f\xe9\xd7\xe4\x89\xe5\x0c\xec\xf6\xf3\xd2\xf8\xbf\xfa\xa1\xfd=\x03\x97\x08\xed\x08\xd7\x04\xc3\x02Z\x07J\x0f\xe8\x13\xc8\x12Y\x0f\x1d\rT\x0b\\\x07\xc2\x00\n\xfa\xa7\xf5M\xf4\xaf\xf5r\xf9c\xfe\xdf\x01\xdf\x02\xdd\x02)\x05_\n\x98\x0f\xa3\x11\x9f\x10k\x10Q\x13\xd9\x16\x8e\x16\xcb\x10\x0c\x08\x99\x00\x99\xfd\x1c\xfe~\xfe\xd0\xfc\x08\xfb\x94\xfcz\x01%\x05O\x03\x1a\xfd\x9a\xf8[\xfb\xf1\x03s\x0c\xcd\x0fU\x0e\xc9\x0b\xdb\t4\x07=\x01H\xf8\r\xf0%\xec\x18\xee\t\xf4\xf5\xfa\x82\xffv\x00g\xfe\x08\xfcr\xfb\xff\xfbP\xfc\x8a\xfbz\xfb\x16\xfen\x02\xc5\x056\x05c\x00\xed\xf9\xe5\xf4?\xf3\x14\xf4o\xf5C\xf7e\xfaL\xff\x0b\x04~\x06n\x05\x80\x01\xd8\xfc\x90\xf9\x8c\xf8)\xf9Q\xfa\xcf\xfb\xe0\xfcK\xfd|\xfc\x93\xfa\\\xf8s\xf6\xed\xf5\x87\xf7$\xfb\n\x00\x80\x04\xb0\x07}\t\xc3\t\xc0\x089\x06\n\x03\xd5\x00I\x00\xb7\x00\\\x004\xff/\xfeN\xfeU\xfe;\xfd\x89\xfb\xae\xfa;\xfd\x96\x01\x96\x05\xb5\x07-\x08\xa6\x08\n\t\xaf\x07Y\x04F\x00\x82\xfd\x82\xfc\xfa\xfc\x9f\xfdq\xfd\xc4\xfc\xc4\xfb\x15\xfc,\xfd\xbd\xfd\xca\xfe\x00\x02\x93\x08\x00\x0e\xf5\x0e\xc9\x0b\xc0\x08\xb0\t\x80\r{\x0e\x03\t\x9e\x07i\x19\xac:\x18QSF\x1a$B\t\x82\x06A\x12R\x17\xf0\x0f(\x05>\x01U\x04\x1c\x060\xfe\xeb\xebP\xd8\xc0\xcd\x0e\xd13\xdeU\xee\x8d\xfa\xfc\xfe\x94\xfcu\xf7\xf5\xf32\xf2\xa5\xf0\xa5\xef\xd9\xf0z\xf6\x81\xff+\x07^\x08U\x02q\xf7C\xeao\xdeb\xdb\x80\xe5\x87\xf5\x01\xfe-\xfbi\xf5\xae\xf4W\xf7*\xf6\xab\xf0\xb3\xec\xbd\xef\xe7\xf9\x86\x04\x9a\t5\x08\x84\x02\x80\xfb$\xf6\x8c\xf3\xfa\xf3s\xf5\xf6\xf7\xb9\xfbG\x00\x17\x05\x9e\x08\xc0\x08\xa6\x03\xa1\xfc\xd9\xf9j\xfe#\x06L\ng\t\x92\x07\x8a\x07S\x07\xe9\x04\xde\x00\x9e\xfd\x03\xfc\xc9\xfb\x9e\xfd\xdb\x01&\x06\x9a\x07=\x06x\x04\t\x05\x83\x07\x80\n\x88\x0c-\r4\x0eC\x10M\x12\xcf\x11\r\r\xf7\x07\xd4\x05\x9b\x06\xd3\x05[\x01\x02\xfe\xbd\xfe\xd6\x01C\x02\x1e\xfe\xe7\xf8\xbf\xf5\xae\xf4\r\xf5\xb1\xf5]\xf7\x85\xf9\xd8\xfa\xdb\xfb\x91\xfc[\xfd\xd2\xfcd\xfb\x9b\xfa!\xfb\xae\xfc\x1d\xfe`\xff\xe2\xff\x86\xffT\xfes\xfc\x94\xfa"\xf94\xf9^\xfa\xd0\xfb\xae\xfc&\xfd\xe5\xfdC\xfe`\xfe\xb6\xfd\x0e\xfd\xb7\xfc\xd8\xfcn\xfd\xfc\xfd\x9c\xfe\xec\xfe\xd5\xfe\x1c\xfe:\xfd\x9b\xfc\x17\xfc\x8a\xfb\xb4\xfb\xab\xfcE\xfe#\xff\xbe\xfe\x0f\xfe\x85\xfd\x9f\xfd\xe1\xfd}\xfe\xdc\xff.\x02=\x04\xbb\x04\\\x03\xe7\x01\x91\x01\xc9\x02\xc8\x04t\x06\x85\x07\xa3\x07w\x06Q\x04p\x01\xd4\xff$\xff\'\xfe\xd0\xfc\xfa\xfc\xa0\xff\x18\x03\xe3\x02\x00\xff%\xfb\x92\xf9\xe2\xfbw\xff(\x03\x01\x07\xd0\x06\xd6\x02\x83\xfb\x0b\xfc\x14\r\xd2\'V8\xce1\xf7!\x9c\x1bs"\xec*\x7f+l(\xa5\'\xb2(\xdb&\x17\x1e?\x10\x8c\x03\xcb\xf9\xd0\xf1p\xeb(\xea\xe3\xec\x04\xec\x87\xe2\xca\xd8\xae\xd8\x0b\xdf\x1b\xe2+\xe0\x05\xe2s\xeb]\xf4F\xf7\xa1\xf6.\xf8\r\xfc\'\xfe\xb0\xff\x94\x03\x00\n\xef\x0bJ\x07\xc9\x01\xbc\x00\xc1\x01\xed\xfe\xb9\xf8\xca\xf3\x97\xf2A\xf3.\xf26\xee,\xe9\xec\xe5\x12\xe5\xf3\xe4N\xe5A\xe7\x82\xea+\xedt\xee1\xf0\xcc\xf3\x7f\xf8\x1e\xfc\xf5\xfd\xe5\xff\x00\x04*\t\xc8\x0ca\r\x9c\r\x0b\x0f\x8a\x10(\x10\x88\x0e\xa6\r\x95\x0c\xe3\t\xaf\x06\x94\x05\xa1\x06\xfa\x05\n\x03g\xffz\xfd{\xfc\x95\xfbL\xfc\xf6\xfd\xb3\xff\x89\x01\x95\x03\x1e\x05\xa1\x04*\x04\xc8\x06\xb1\n\x14\x0e~\x119\x15o\x16\xb5\x11h\n\xbf\x06<\x07\x89\x08\x1b\x08~\x06\xa0\x03\x1b\xff\x03\xfa#\xf6S\xf3J\xf1\x8f\xf0\x95\xf1\xdb\xf2\xd2\xf2\xd3\xf1\xb5\xf0b\xf07\xf1\x8e\xf3b\xf7\xff\xfa\x02\xfd\xe4\xfd\x0f\xfe"\xfe/\xfe\x16\xff,\x01\xd8\x02;\x034\x02\xa9\x00\xdd\xfe\x01\xfd\xad\xfbg\xfb\x7f\xfb3\xfbX\xfa\xf2\xf8\x88\xf7\x1c\xf6\x1f\xf5\xd2\xf43\xf5\x86\xf6\xa5\xf8P\xfa\xb2\xfaa\xfad\xfaT\xfb\xf0\xfb#\xfd\x1d\x00\xba\x04n\x07\xc9\x06;\x04\x05\x028\x02\x15\x046\x07\xfb\t\xb0\n\xad\t\xab\x05g\x00z\xfdI\x00\x80\x06\x14\t\xb4\x05I\x00s\xfeD\x01\xd7\x05\x94\t"\tq\x05\xdc\x01\x8a\x03\x15\t]\x0e\xc6\x14\xaa \x03,M*m\x1b&\x11\xc2\x16\x93#\xa9+\xcb-\xda-\xca\'\x85\x1a\xfb\r\xdc\x08\xac\x08\xaf\x07C\x05?\x02\x14\xfd\x1f\xf4>\xe9Y\xe0\x13\xdb%\xda\xce\xdcL\xe0\xd6\xe27\xe1\x8f\xdcU\xd8-\xd7a\xdbP\xe45\xefh\xf7\x9f\xf8\xc6\xf5\x98\xf5h\xf9\xca\xfe\xdb\x03Y\n/\x10\xb2\x10J\x0c8\x07[\x05f\x05z\x05W\x05\xb5\x04\x02\x03E\xfem\xf6\xb0\xee\x8d\xeb\xe8\xec\xc1\xee\xf8\xed/\xec\x07\xeb\xc1\xe9\xd5\xe7\xb8\xe7M\xeb\xf3\xf0\x84\xf5\xe2\xf8(\xfc\x83\xff\xbb\x01\x88\x03\x07\x06<\n\xcc\x0e\x8e\x12\xef\x14\xbd\x15V\x14G\x12+\x11`\x12S\x14\x82\x14\x8f\x11H\x0c\x97\x08\xc8\tD\x0e\xaa\x0f\xc9\n\xa7\x03\x0b\x01\x9e\x02\x86\x03\n\x03\xb7\x03t\x04\x1c\x02p\xfdc\xfb\xa3\xfc\xba\xfda\xfd\x1f\xfd\x9d\xfd\xac\xfd\\\xfc7\xfb\xf9\xf9$\xf9j\xf9\xeb\xfa\x1e\xfc\x9a\xfb\x87\xf9S\xf7+\xf6\xbf\xf6\x9d\xf8~\xfaE\xfbi\xfa\xd6\xf8\xd1\xf7\xd4\xf8\xb4\xfa>\xfc\x18\xfd\xf2\xfd]\xffo\xff\xeb\xfdA\xfcg\xfc2\xfe\xaa\xff\xd4\xff\xfe\xfe\xb0\xfd\x15\xfc\xef\xf9<\xf9\x15\xfa_\xfb-\xfc\x93\xfb\x0f\xfb\x0f\xfa\xce\xf8\x91\xf8\x89\xf96\xfb\xea\xfc!\xfe\xc6\xfe\r\xff\xe9\xfe\xc2\xff\xd5\x00q\x01\xfe\x02n\x06\x9b\x08\x01\x08r\x05\x13\x05\xb5\x07&\t\xc8\t\xf9\nT\x0c\xa6\x0b/\x08S\x06\xaa\x07\xa0\n\xe0\x0cr\rm\x0c\xba\nF\n(\x0c\xeb\x10\x9a\x18C\x1f\xd3\x1fB\x18\xe8\x0f\x11\x10\xfd\x17) \xc3!\xb0\x1e\x88\x1a}\x15\x9c\x0f\xeb\n\x99\t\xca\n\x91\t\x93\x05\xcf\x00\xc4\xfb\xda\xf5\x0e\xef\xdd\xea\n\xea\x08\xea\x83\xe9\xf5\xe7\xfb\xe4\x02\xe1T\xddw\xdcX\xde\x81\xe1w\xe5\xaa\xe8?\xe9G\xe7\x97\xe6G\xe9\xb5\xed\x8e\xf1\\\xf5\xfa\xf9k\xfc\x91\xfb\x13\xfa\x9f\xfaR\xfd\xcb\xff\x9e\x02\xdc\x052\x075\x05`\x01\xc5\xfeW\xfeX\xff=\x01\x9b\x02v\x01\xc3\xfd\r\xfa>\xf8\xcf\xf7\xa2\xf7\xcf\xf7\xf4\xf8\xca\xf9\x02\xf9\x0e\xf7/\xf6o\xf7/\xf9v\xfb\xa1\xfdI\x00\x9c\x01\x8b\x02\x92\x04\xa0\x07\xb1\t\xf9\t$\x0b(\x0fL\x14[\x17A\x17\xf8\x13h\x10\xb3\x0fh\x13\xcb\x16\x8b\x15\x02\x11D\r!\x0b\xf3\x07\x9f\x04\xb4\x03\x0e\x04\x98\x02\xee\xfe\xef\xfb\xa5\xf9\xc2\xf6{\xf4\xb1\xf4\xef\xf5q\xf5\x1c\xf4l\xf3\t\xf3\xcf\xf1\xed\xf0z\xf2\xfd\xf4^\xf6\xf4\xf6\xf2\xf7\xfb\xf8%\xf9K\xf9\x7f\xfaI\xfc|\xfd6\xfe\xf9\xfe9\xff\xb5\xfec\xfe\xe3\xfe\xeb\xffu\x00Z\x00 \x00\xdc\xff0\xffO\xfe\x8d\xfdy\xfd\x1e\xfe\x9d\xfeT\xfe`\xfd\xf4\xfc\x86\xfd)\xfeb\xfe\xe4\xfe\xc8\xff\x9f\x00\xa7\x00\x98\x00e\x01\'\x02b\x02(\x02+\x03\x9b\x04U\x04;\x03\x82\x03\x9e\x05\xc5\x06\xda\x05\xd1\x04x\x05\xdb\x06\x0e\x07\xaa\x05k\x04\xa1\x04a\x06\xa9\x07\xc5\x07\x9f\x08\xca\x0bJ\x0fb\x0f\\\r\x94\r\x01\x11\xa2\x13\xb3\x14F\x15\xe1\x15\x8e\x14\x9f\x12\x04\x13\xb8\x14\x94\x14.\x12U\x10\x0f\x0e[\nn\x07\x18\x07a\x07,\x04}\xff?\xfc\xb2\xf9!\xf6&\xf3\x9b\xf3\x12\xf4\xf3\xf0\x07\xec\r\xea\x1e\xebJ\xebp\xea\xc6\xea\x04\xec\xed\xeb\x14\xeb\x07\xec\x8a\xee\x10\xf0\xb8\xf08\xf2g\xf4<\xf5\xe7\xf4\xc4\xf5U\xf8U\xfa\xeb\xfa\x8a\xfb\x96\xfc\xcb\xfc\xf3\xfb!\xfc\xa5\xfe\x1a\x01T\x01\xe6\xff\xbe\xfew\xfe\xd4\xfe\x8e\xff|\x00\x8f\x00H\xff\x89\xfd\xd5\xfcV\xfd\xf2\xfd\x9e\xfd\xa5\xfc\xc0\xfb\xc4\xfb\x11\xfcm\xfc\x98\xfc\xc5\xfc"\xfd\xba\xfd\xed\xfe\x98\x00\'\x02\xf8\x02\x84\x03\xde\x04\xa0\x06v\x08\xb9\t\xb5\n5\x0b6\x0bg\x0b\xe9\x0b\x94\x0ce\x0c\xec\x0bx\x0b\xd0\n\xbe\t\x1d\x08\xfb\x06\x0f\x06\\\x05\x98\x04|\x03>\x02\xaf\x005\xff\xea\xfd\xee\xfcR\xfc\xcd\xfb/\xfb<\xfa\x02\xf9\xa8\xf7\xe9\xf6\xfc\xf6\\\xf7G\xf7\xb6\xf6\x1f\xf6\xc9\xf5\x94\xf5\xf5\xf5\x00\xf7\xfb\xf78\xf8\xf7\xf7\xd5\xf7\x19\xf8\x99\xf8p\xf9\x85\xfa\\\xfb\x8d\xfb\xa4\xfb(\xfc\x0b\xfd~\xfd\x83\xfd\xdf\xfd\x9e\xfeR\xff\xe4\xff\xb4\x00|\x01h\x01\xe2\x00\xdd\x00\x83\x01\x87\x01\xed\x00\r\x01=\x02?\x03\xb0\x02\x8c\x01\x88\x01\x0e\x02J\x02Q\x02\x7f\x02\xa7\x02#\x02\xd9\x01\xc8\x02A\x04\xe1\x04\\\x04W\x04\xe1\x04\x94\x05\'\x06\xa4\x07s\n\xcc\x0cx\rw\r\x80\x0e\xc0\x10\xab\x12\xfd\x13E\x15$\x16\xf4\x15h\x15\xfe\x15\x1c\x17\xeb\x16\x0f\x15F\x13?\x12\xca\x10i\x0e\xbe\x0b\xb7\t\x16\x07\xbf\x03\x82\x00\x03\xfe\xca\xfb\xb1\xf8H\xf5\xc6\xf25\xf1\xd2\xef\xf2\xed\x06\xec\x8e\xea\x9d\xe9A\xe9\x8e\xe9\x1f\xeau\xeae\xea\xa7\xea\x8a\xeb\xee\xecO\xee\xb5\xef#\xf1k\xf2U\xf39\xf4\xac\xf5\x86\xf7%\xf9\'\xfa\xe3\xfa\xe1\xfb\xd7\xfc\xce\xfd\xdd\xfe\xd9\xff;\x00\x0e\x003\x00\xe7\x00\x8a\x01\xae\x01s\x01O\x01J\x01b\x01k\x01_\x014\x01)\x011\x01{\x01\xdc\x01\'\x02\t\x02\xad\x01\xca\x01\xd9\x02R\x04%\x05\x19\x05\x8a\x04\n\x04J\x04T\x05\xac\x06E\x07\x0b\x07\x8a\x06C\x06\x02\x06\x10\x06\xbb\x06h\x07@\x07A\x06w\x052\x05\xcb\x04J\x04%\x04\x0f\x04\x1e\x03\xaf\x01\xb9\x00R\x00\x9f\xff\x88\xfe\xc0\xfdT\xfd\x8f\xfcx\xfb\xc2\xfah\xfa\xeb\xf9k\xf9\x1c\xf9\xe2\xf8k\xf8\x06\xf8\x04\xf8\x13\xf8\xe3\xf7\xc5\xf7\x0e\xf8\xa6\xf84\xf9\x89\xf9\xa9\xf9\xb7\xf9\xc4\xf9[\xfa\x9a\xfb\xf9\xfc\x90\xfd`\xfd\xfc\xfc;\xfd\x1c\xfe$\xff\xde\xff"\x00\x1c\x00!\x00a\x00\xe8\x00q\x01\xcc\x01\xb2\x01\x9c\x01\xe4\x01u\x02\xe7\x02\x07\x03\x14\x03!\x03\xfb\x02\xb7\x02\xc6\x021\x03\x81\x03\x9c\x03\x81\x03Z\x03\x0e\x03\xf1\x02.\x03{\x03\xe3\x03\xe4\x04\xcf\x06\xc8\x08\x8d\t\xa4\t$\nx\x0b<\re\x0f\xc9\x11p\x13\xba\x13\x9e\x13\xfa\x13\x97\x14w\x14*\x14`\x14N\x14\x0b\x13\xd7\x10\xd9\x0e\x01\r\x87\n\x18\x089\x06I\x04I\x01\xe3\xfd%\xfb\x06\xf9\x8c\xf6\xfa\xf3\xed\xf1S\xf0\xa4\xee\x01\xed\x01\xec_\xeb\xae\xeaE\xeaz\xea/\xeb\xa0\xeb\x07\xec\xc6\xec\xc4\xed\xc7\xee\xed\xefm\xf1\x12\xf3O\xf4I\xf5I\xf6a\xf7i\xf8s\xf9\xac\xfa\xf3\xfb\xcf\xfc5\xfd~\xfd\x0b\xfe\xa7\xfe\n\xff6\xff\x92\xff\x17\x00]\x00\x0e\x00\xab\xff\xb0\xff\xcd\xff\xa2\xffj\xff\xcc\xffg\x00_\x00\xf4\xff\xf0\xffq\x00\xb6\x00\xcf\x00|\x01d\x02\xaf\x02\x9e\x024\x03\x1f\x040\x04\xd4\x03-\x04\x1c\x05b\x05\x1c\x05F\x05\xc7\x05\x95\x05\xe1\x04\xe0\x04i\x05~\x05\xfd\x04\xdb\x04I\x05R\x05\xf6\x04\xd3\x04\xb3\x04(\x04\x86\x03\x84\x03\xb1\x03+\x03%\x02"\x01d\x00\x9a\xff\xdf\xfe;\xfex\xfdk\xfcb\xfb\x86\xfa\xed\xf9l\xf9\x02\xf9Y\xf8\x96\xf7"\xf7+\xf7`\xf7l\xf7U\xf7@\xf7@\xf7\x86\xf7!\xf8\xfa\xf8\x90\xf9\x1e\xfa\xb0\xfa(\xfbU\xfb\xc1\xfb\xf8\xfcb\xfe\'\xff=\xffB\xfft\xff\xb4\xffe\x00\x82\x01a\x02\xf7\x01\xfa\x00\xe9\x00%\x02o\x03\xe9\x03\xe9\x03\xa8\x03\x16\x03\xb2\x02w\x03\x1c\x05:\x06\x1f\x06e\x05\x1e\x05~\x05+\x06\xf2\x06\xb5\x07\xde\x07\x88\x07\xb0\x07}\th\x0c\xae\x0e\x07\x0f\x0e\x0e\xce\r\x83\x0f\x90\x12m\x15\x01\x17!\x176\x16\x0f\x15\xc8\x14\xea\x15W\x17M\x17\x0e\x15\x99\x11\x85\x0e~\x0c/\x0b\xd7\t\x93\x07\xda\x03\xfd\xfe\xab\xfa\xbb\xf7\xe1\xf5\xfc\xf3\x80\xf1\xa9\xeey\xeb\xa0\xe8\x12\xe7\xc4\xe6\xd9\xe6C\xe6`\xe5\xee\xe41\xe5W\xe6J\xe8U\xea\xad\xebN\xec \xed\xff\xee\xcb\xf1\xe9\xf4\x9d\xf7\x90\xf9^\xfa\xc6\xfa\xce\xfb\xe2\xfd\x90\x00\xad\x02\xb6\x03\xbd\x033\x03\xef\x02G\x03\x1b\x04\xa4\x04\xad\x04J\x04Y\x03g\x02\x02\x02;\x02\x90\x02.\x02\x80\x01\x97\x00\x0b\x00\xcd\x00\xac\x02E\x04\xa8\x03\xb0\x01x\x00!\x01\xe3\x02\x8e\x04a\x05\x02\x05y\x03\xc2\x01X\x01\x1b\x02\x82\x03\x1b\x04\xc1\x03\xa9\x02\\\x01\x97\x00\x8a\x00\x16\x01\xc8\x01\xef\x01\xa2\x01D\x01\x18\x01\xe9\x00K\x00\xe0\xff\xd8\xff&\x00N\x00_\x00p\x00\xcd\xffY\xfe\xc2\xfc\'\xfc{\xfc\xde\xfc\x10\xfd\xe8\xfc<\xfc\xdc\xfa;\xf9Q\xf8D\xf8\xa3\xf8\xff\xf8V\xf9d\xf9\xb4\xf8\xb1\xf7\x0c\xf7\x17\xf7\x93\xf7O\xf80\xf9\xf9\xf93\xfa\x03\xfa\xed\xf9C\xfa?\xfbp\xfce\xfd\xb1\xfdZ\xfdW\xfd\x0c\xfe\x18\xff\xf6\xff\x80\x00\xa9\x00\x91\x00/\x00P\x00\xfa\x00\xb3\x01\xfd\x01\xc5\x01\xee\x01\x14\x03\xae\x04\xa1\x05C\x05M\x04Y\x040\x06\x85\nz\x10\xac\x15.\x17\x03\x15\xdd\x12W\x14s\x19\xe6\x1fw%\xca(\x06(\xc2#i\x1f\xa9\x1e\x87!\xe0#\\#\xee\x1f:\x1b\x94\x15\xe4\x0fI\x0b\xde\x07\xb1\x04\xb3\xff\xb2\xf9\x0c\xf4\xb5\xef^\xec"\xe8;\xe3\x80\xde\x00\xdb)\xd9\xb9\xd8\x9f\xd9\x82\xda&\xdad\xd89\xd7\xaa\xd8\x90\xdc\xc3\xe1~\xe6\xd3\xe9V\xeb\x8a\xec\'\xef\x9b\xf3\xdf\xf8\x03\xfe\'\x02-\x04|\x04Q\x05Y\x08\x02\x0c\x0c\x0e6\x0e\x0f\x0e\xe9\r+\r\xb6\x0c,\r\x1e\r;\x0b\xf6\x07T\x05\x13\x04\xd5\x02$\x02\xb1\x00\xa2\xfe\xf4\xfb\xfd\xf9_\xf9Q\xf9\x92\xf9\xfb\xf9\x99\xf9\xe1\xf7\xe1\xf5G\xf6\x8d\xf9\x17\xfe0\x01\xd2\x01:\x00J\xfeh\xfe\x93\x00~\x04\xc0\x07\xff\t\x9c\t\xa0\x07!\x06\x80\x06C\x08\xb8\x08G\x08l\x07\xb7\x06\xe1\x05|\x05\xf0\x05l\x05\xe3\x02v\xffU\xfd\x1b\xfdj\xfd\x90\xfdu\xfd\xc1\xfb\xf3\xf8\xe4\xf5\xb4\xf4\xc3\xf4\xe1\xf4\x14\xf5<\xf5\xdc\xf4\xc2\xf3\xe5\xf2\r\xf3s\xf3d\xf3e\xf3:\xf4\xa0\xf5\xba\xf6\xc8\xf7A\xf8`\xf8\xed\xf7%\xf8k\xf9*\xfb\x18\xfd\xad\xfe\x8a\xff=\xff;\xfeg\xfe\\\xffX\x00\xde\x00J\x01\x1f\x02\xcc\x02\xd8\x03\xb0\x04\xf3\x04\xd5\x04\xd3\x04;\x05\xa0\x07\x82\x0ex\x19\xcf!\x89#\xcc!\xe5!\xbc#\xd6%\xf9+\xfd6\xb8?\x1b?\x958\xf33-1\xc3,.(\xe7%\xfd"/\x1b\xa5\x11\x8f\n\xd1\x04\xd2\xfc\x07\xf3\xb7\xe9\x16\xe0\xc3\xd6a\xd0/\xcei\xcez\xcda\xcb\\\xc8\x1e\xc5F\xc4O\xc7G\xcd|\xd3G\xd9\x8a\xdf\xe9\xe5\x19\xec9\xf2\x00\xf9\x16\xffV\x032\x06{\t\xdb\r\xda\x12\x14\x17\xac\x19\xb8\x19\xb2\x16!\x12\x1e\x0e\x9b\x0b\r\n)\x08\xf7\x05\xe2\x02V\xfe\xe9\xf8r\xf4t\xf1\x01\xefO\xecx\xea$\xea\x17\xeb\xe7\xeb\xa2\xed\x95\xefS\xf1=\xf2\x12\xf3\xa4\xf5\xc1\xf98\xff\xcd\x044\t\x11\x0cb\r\xed\r7\x0ec\x0f5\x11\xf2\x12\xf3\x13\xb3\x13A\x13\x05\x12P\x10\xd3\r\x1b\x0b\xd6\x07\x8f\x041\x02\xa7\x00\x1e\x00\x08\xff<\xfd=\xfa\x11\xf7?\xf48\xf2F\xf1%\xf1\xa9\xf1:\xf2\xa5\xf2\x04\xf3\x13\xf3\r\xf3\xe6\xf2\x8c\xf2\x94\xf2\xb2\xf2\x0f\xf4\xba\xf5\x1b\xf7\xbe\xf7\xf1\xf7\xa7\xf7T\xf6\xf6\xf4\xf1\xf4\xf3\xf5\x8b\xf6\x19\xf7\xaf\xf75\xf8\xe6\xf6\xa4\xf6\x9b\xf7e\xf8\xb6\xf7~\xf6J\xf7\xb8\xf8\xde\xfa\x05\xfe6\x01\xab\x02@\x02\xe6\x02\x94\x05\xa5\x08\t\r\xf3\x11\xde\x18B =)w4\x97<\xd7?\t? @\xe8BGD\tFMH\x18JkEz;J2T*\n"\xb2\x15G\t\xd2\xfe\xc3\xf4,\xeaF\xe0\xf1\xdaf\xd62\xcf\xda\xc5[\xbf\xaf\xbcQ\xba\x16\xba\xe2\xbd\xff\xc3\xb3\xc8\x08\xcdW\xd4\x94\xdd\xae\xe5\xb0\xed\x05\xf6\x06\xfd\xb8\x01\x08\x07%\x0eR\x14_\x19C\x1dK\x1f\xfc\x1d;\x1a1\x17\xc7\x13>\x0f\x07\n/\x04a\xfd\x82\xf6x\xf1\xa8\xed\xfa\xe8\x84\xe4\xf5\xe0U\xdd\x9a\xd9:\xd8\xc6\xda)\xde\x8c\xe1\xac\xe5\xd6\xea\x8a\xf0\xba\xf5\xf0\xfc=\x04\x19\x0b\x97\x10G\x15\xdd\x18\xd1\x1d\xf9"K\'N)6)B(\xbf$\xbb!\x91\x1e\x92\x1b%\x17\xd6\x11\xac\x0c\x11\x07\x95\x01n\xfdr\xf9\xb8\xf5#\xf2\xad\xee\xbf\xebM\xe9*\xe9\x19\xea0\xeah\xeav\xebE\xed\x1f\xef\x84\xf0Y\xf3-\xf5=\xf6\xc2\xf6A\xf7\x9d\xf8\x18\xf9\x1a\xf9\x8b\xf8\xf0\xf6\xd8\xf5\xb6\xf4S\xf3\xa4\xf1\x10\xef\x1a\xee\xf9\xec\xf5\xeb\xe4\xea8\xea\xf8\xea\x03\xea\xf4\xe94\xec\x8a\xf0\x82\xf3\xd9\xf2\xaa\xf3\x8c\xf6\x0c\xfa\xba\xfc\xad\x02\xe5\n\xac\x10\xb7\x14w\x1a!"\xa5(\xc80%=7H;NLR\xf4VUY\xafV[T\xf6R\xcfN\x94F`=\x165\xac+#!p\x153\x08=\xfa\xbd\xec"\xe0p\xd5\x00\xcde\xc7\xa1\xc1o\xbc\x80\xb9\x94\xb9X\xbb@\xbe\xef\xc2\xb5\xc7\x9b\xcde\xd4\xe1\xdc\x14\xe6\x0f\xf0\x16\xfb\xae\x03\xc3\t\x8a\x0f\xa9\x15\xc1\x1a\xdb\x1b\xc5\x1b\xf1\x1a\t\x18J\x13\xf6\r\xc2\t{\x04m\xfd\xde\xf5\x9b\xee(\xe8+\xe2\x96\xdc\x04\xd8e\xd4\x9b\xd1\xc8\xcf\x1e\xd0\xb5\xd2\xed\xd6,\xdbr\xe0\xdb\xe6\x98\xedd\xf5$\xfd<\x05\xde\x0b]\x12 \x18\x13\x1d\xc7"M*\t0\xc51\xef0\xd00\xc6.\x86)\x9a%;"k\x1d\xf4\x14L\r\x14\t\x10\x04\xdd\xff&\xfc\xc2\xf8C\xf4\x94\xee4\xec\x00\xea6\xe9Q\xe9\x1f\xe9\xa7\xe9n\xe9b\xec\x9c\xf0\t\xf4\xec\xf6T\xf88\xfa\x97\xf9;\xf9\xc9\xf9D\xfa\xb6\xf9\xd7\xf6\xb2\xf4v\xf3?\xf2\x82\xf0\x7f\xee\xfb\xec8\xea]\xe7\xe6\xe4\x87\xe3.\xe3\x98\xe2\x8b\xe3z\xe3E\xe5\xfa\xe7\xe0\xeb\xa2\xefX\xf2\xa5\xf6\xc7\xf9\xd8\xfc\\\xffK\x04P\n\xf8\x0fX\x17\xf6\x1f\x8a(\xd80B\xbb\x90\xb7\x8a\xb7N\xba\xf3\xbev\xc6m\xcf\xdb\xd7\xc4\xe0\xe8\xea\xb6\xf4\xcc\xfc\xb5\x04\xff\x0c\x0b\x13P\x16\x96\x19o\x1d\x12\x1f\xcb\x1dI\x1b\x9c\x17\x87\x11\xab\t\xd3\x01a\xf9\x1f\xf0\xf2\xe6N\xde\xad\xd6\xa3\xd0\xf4\xcc\xcf\xca\xc5\xc9\xc2\xca\x11\xcd\x9c\xcf7\xd3\x13\xd9\xba\xdf[\xe6!\xee\xe0\xf6\x86\xff/\x08e\x11O\x1a\xdd!\x81(\xc7-\xd90L1I2k1i.L*\x11&\x87!\xc6\x1a\xfd\x14\xdd\x0f\x9f\tG\x03J\xfdE\xf8-\xf3\x9e\xeeO\xec\x05\xea\r\xe9X\xe9\xbc\xea\xd4\xec\xd1\xeeV\xf2\x13\xf6\xc2\xf82\xfb%\xfd\x8e\xfeL\xff\xe0\xfeA\xff\xa0\xfe\xe7\xfd|\xfc`\xfa\xb1\xf8\xd6\xf5\xcc\xf2\xf3\xee\xba\xea,\xe7\xb7\xe3\x01\xe1\xd6\xdeS\xdd\xb1\xddD\xde\x0e\xdf\xf7\xdf\xc4\xe1h\xe4\xbd\xe5\xf9\xe6\x03\xea\x17\xef\x9c\xf3\x08\xf7/\xfb\x9d\x00\xab\x06\x85\x0e\xc2\x18\x86"\x9c+\x1a7\xd4D)P\xf8X\xa2c\xd7l\xb0n\xecj@h\x0beZ[\xbbM\\A 5\x87%@\x14q\x05\xce\xf8\xff\xec4\xe1w\xd5\x85\xcb^\xc4\xd8\xbe\xae\xb9\xf9\xb6\xe7\xb7\xa0\xb9\xc0\xbb\x1b\xc1z\xca\x93\xd4\xef\xdex\xeb\'\xf8p\x02\xab\x0bw\x15n\x1d\x9b!\xf6#w%j#\xb2\x1e\x89\x1aB\x16\xa3\x0f$\x07\xd6\xfe\xef\xf5\xee\xebq\xe2\x92\xda\xdf\xd2\x19\xcb\xd6\xc4\x0e\xc0\xbd\xbcZ\xbc/\xbf\x9e\xc3\x1f\xc9\xc9\xd0\xfe\xd9\xab\xe3\x1a\xee\x94\xf9\xd5\x04\x9c\x0e\xd0\x17\xbf 5(Y.\xb03\xc97:939\xd37\xf84i0\xf4*\x93$\x92\x1c\xff\x13\x8c\x0b)\x03\x83\xfbW\xf5r\xf02\xec\xad\xe8/\xe77\xe7\xf9\xe7\xb1\xe9G\xec2\xef\xde\xf1\x14\xf5\xe8\xf8\xab\xfc\xbe\xff\x92\x02\x87\x04\x00\x06[\x06R\x06U\x05(\x03\x90\x00\xe6\xfc\xab\xf8\xfa\xf3\xc1\xef\xf7\xeb\x0e\xe8^\xe4\xd5\xe1\xbf\xdfN\xdd\xe8\xdb\xd4\xdb=\xdc\xfb\xdb\x10\xdc\xaa\xddl\xdf,\xe1\xe9\xe3\xce\xe7\x9d\xec!\xf0\xf0\xf3\x0c\xf9\x08\xfek\x022\x06}\x0c\xd3\x14\xe3\x1c\xbc%m2LBRP\xe8ZRd7m\x9ar\x80sxp\xaajca\xfaS\x19C\xc31o"\xc7\x13\xd3\x03*\xf4\x96\xe7\x87\xdd\xb3\xd3F\xcb\xfa\xc5?\xc2f\xbe*\xbb\x1a\xba\xc9\xbb\r\xc0A\xc6K\xcd\xe1\xd5\x01\xe1f\xed~\xf9\xee\x05\xd2\x12\xb4\x1d\xdd$b)2,\x83,\xad)d$\x11\x1d\xee\x13"\n\x9f\x00\xbd\xf6\xda\xec\xad\xe3\xca\xda\xc6\xd1\xb3\xc9\x8f\xc3\x8f\xbe\xdb\xbay\xb9\xd4\xb9Q\xbb\x91\xbf\x80\xc7\xba\xd0\xc8\xdaU\xe7T\xf5\xd9\x01\x90\r.\x1aM%\xed,?3k8\x16:\xf98}8n7\xb42\xbb,Q(p"\x06\x1a\\\x12{\r\xfb\x064\xfe\x0f\xf7C\xf2p\xedH\xe8\x16\xe6)\xe6\x80\xe6\x84\xe7\x94\xea<\xef\x96\xf4\xa9\xfay\x00\xe2\x04\xe8\x07m\n\x9d\x0b\x01\x0b)\t\xd1\x06h\x03e\xfei\xf9\x93\xf5\x16\xf2:\xeeb\xea8\xe7\x07\xe4\xc0\xe0\xe5\xdd^\xdb\xb8\xd8z\xd65\xd5|\xd4D\xd4\xa3\xd5R\xd9\x91\xdd\xc0\xe1\xe8\xe6.\xee\xb6\xf5V\xfb\x8b\x00o\x06\x98\x0b\x88\x0e\t\x12\xf2\x18\xb8!\\)\xc71\x04=SI\xf9S8]\x00fDk?k\x9dg\x02a\\WJJq;K+K\x1ap\n\x01\xfd\xa9\xf1\xe4\xe7\x17\xe0g\xda\xa9\xd5\xe0\xd1_\xcf\xae\xcd\x90\xcc\xfa\xcb\xe2\xcbK\xcd\xc8\xd0;\xd6P\xdd\xb6\xe5\xa4\xef\x1e\xfa@\x04\xe6\r\x84\x16V\x1d\xae!\t#\x1b!\xdf\x1c&\x17\x1e\x10.\x07\xbe\xfd\x94\xf5\x16\xee\xd0\xe5F\xde\x13\xd9\xa5\xd4Y\xcf\x80\xca\xc1\xc7\x9c\xc5\xf8\xc2\xe5\xc1\xb8\xc3\xe3\xc6\x05\xcb\xa2\xd1"\xdb\xd3\xe5,\xf1\x1d\xfe\x8d\x0b9\x17\xd4 \xef(\xd0.W2\x813\xc22Z0\xe5,.(\xfa"T\x1e\xe7\x19\x14\x15\x9b\x0f\x01\x0bG\x07"\x03\xc7\xfe\xc2\xfb\xa4\xf9"\xf7\xe3\xf4%\xf4\xc3\xf4\xf9\xf5\x17\xf8\xe2\xfa\x15\xfe\xc5\x00e\x03\xad\x050\x07\xce\x07G\x07i\x05\xa0\x02\x80\xff\x89\xfc\x15\xf9\x0b\xf5+\xf1e\xedi\xe9\xb8\xe5Y\xe3\x89\xe1\x0e\xdfM\xdc\xa9\xda\xd1\xd9\xe6\xd8\xd7\xd8\x10\xda{\xdc\x90\xde\xa6\xe1\x8c\xe7h\xee\x9f\xf4\x0e\xfa\xe4\xff\x88\x05\x82\x08\xbb\n;\x0eS\x11\x86\x12\xc6\x13%\x18X\x1e\xfb$\x17.\x849\x97C\xdcJ\x85Q\x9eW\x83Y\x9eV\xc4QtJ\xaf?H2\xfa%\xc4\x1aU\x0f\x9a\x04\xda\xfb3\xf5\xf1\xef\x00\xecA\xe9\xd8\xe65\xe4<\xe1~\xde\x91\xdc^\xdbq\xda\x92\xda\x85\xdcL\xe0j\xe5\x0e\xecQ\xf4v\xfc\x8d\x03\x94\t\xc7\x0eo\x12\\\x13J\x12d\x0f\x02\x0bF\x05\t\xff\xa9\xf9\xd8\xf4\xd2\xef\xd3\xea\xa3\xe6\x8f\xe3\xb5\xe0\xb0\xdd5\xdb\x17\xd99\xd63\xd3\xa6\xd1\xea\xd1:\xd2q\xd3u\xd7w\xdd#\xe4\xce\xeb\xaa\xf5\xe2\xff\x12\x08\x19\x0fW\x16\xcc\x1b\xa2\x1e\xb1 u"p"\xb7 l\x1fk\x1e2\x1c[\x19j\x17G\x15\xe4\x11\x7f\x0e(\x0c~\t\xe8\x05\xa6\x02\x91\x00\x00\xff\xa6\xfdX\xfd\xf0\xfd\xa2\xfep\xff!\x00\r\x01\xf3\x01Q\x02\x85\x01\xed\xffk\xfe\xea\xfc\xe7\xfa\xda\xf89\xf7\x92\xf5/\xf3\xb7\xf0%\xef\xf8\xed\x8b\xec\r\xeb}\xe90\xe8\xe1\xe6\x0b\xe6\x89\xe5L\xe5\xcd\xe5\xa4\xe6\xe3\xe7\xd0\xe9\xf4\xec\x1c\xf1#\xf5Z\xf9\x82\xfdo\x01\xec\x04\xcd\x07u\nP\x0c\x8c\ra\x0e\xbd\x0e\xb9\x0f\xd9\x11\xee\x14\x8b\x18\xdf\x1c\x8d!\xb2%\x8f)\xc6-~1\xf42Q2\x1d1\xec.++\x8a&\xae"\x98\x1e\x8a\x19z\x14\xac\x10\xb7\rB\n~\x06\xfa\x02q\xffV\xfb\xe1\xf6\xec\xf2\xc0\xef\xe0\xec\xd4\xe9\xba\xe7\x03\xe7(\xe7\x98\xe7\xc0\xe8R\xeb\x1c\xee)\xf0\x04\xf2\x81\xf4\xe8\xf6\xce\xf7\xd4\xf76\xf8j\xf8q\xf7\t\xf6}\xf5\x84\xf5\x84\xf4\xfa\xf2)\xf2\xb4\xf1N\xf0\x96\xee\x9f\xed\xc1\xec\xdf\xea\xb6\xe8\xfc\xe7\xf9\xe7C\xe7.\xe7\xee\xe8\xab\xeb\x18\xee\xfa\xf0\x84\xf5\t\xfa[\xfd7\x00r\x03\x0c\x06I\x07y\x080\n\x96\x0bK\x0c}\rX\x0f\xea\x10\xe9\x11\xec\x12\xb9\x13\x86\x13\x91\x12s\x11\xfb\x0f\xa1\r\xb7\n"\x08\xc6\x05\x9b\x03\xeb\x01\xba\x00\xfd\xffz\xffp\xff\xb6\xff\xf4\xff!\x00\xf3\xff^\xffy\xfek\xfd\x93\xfc\xcd\xfb\xdd\xfa\x14\xfa\x96\xf9L\xf9\xd1\xf8i\xf8Y\xf8\xf8\xf7\x9f\xf6\xd6\xf4\x98\xf3Z\xf2w\xf0\xd8\xee_\xee\xae\xee\x18\xefZ\xf0\xeb\xf2\xd7\xf5\x01\xf8\xba\xf9\x91\xfb&\xfd\xdf\xfd<\xfe\xe5\xfe\x00\x00\xfd\x00G\x02\x01\x04s\x06B\t\x87\x0b\xb8\r\x00\x10*\x12\xbf\x13~\x14C\x158\x16\xf7\x16Q\x17,\x18\xd5\x194\x1b\xcb\x1bS\x1c"\x1d8\x1d\x10\x1cv\x1a\xd0\x18Z\x16\x1c\x13u\x10}\x0ex\x0cq\n\x01\t\xeb\x07z\x06\xe2\x04\xa3\x03#\x02\xfb\xff\xc1\xfd\xfe\xfb\'\xfa\x0f\xf8@\xf6\x98\xf4\x0c\xf3\xa5\xf1\x88\xf0\xf2\xef\x89\xefA\xef\x14\xef\x11\xef\xfd\xee\xb6\xeeD\xee\xb9\xed\x0b\xedh\xec\xd0\xebO\xeb\xe5\xea\xb9\xea\xc5\xea\xd6\xea\xf7\xeav\xebX\xec4\xed\x16\xeed\xef3\xf1\x02\xf3\xba\xf4\r\xf7\xa3\xf9\xfe\xfb\xf1\xfd\xc9\xff\xe0\x01\xa7\x03\xfe\x044\x06s\x07n\x08\x14\t\xbe\t{\n"\x0bx\x0b\xcb\x0bM\x0c\xa1\x0cg\x0c\n\x0c\xea\x0b\x93\x0b~\nk\t\xcd\x08U\x08u\x07\xae\x06\x9d\x06\x8d\x06\x1d\x06\x94\x05\x8c\x05n\x05\xa8\x04\xb0\x03\xef\x02.\x02\xd6\x00\x98\xff\xc3\xfe\x08\xfe\x05\xfd\xf9\xfbV\xfb\xb3\xfa\xb1\xf9\x89\xf8\x90\xf7\x98\xf6:\xf5\x07\xf4;\xf3\x9d\xf2\x14\xf2\xbb\xf1\x01\xf2z\xf2\xd8\xf2]\xf3\x19\xf4\xda\xf4z\xf5S\xf6u\xf7\x8a\xf8\x8b\xf9\xcd\xfaE\xfc\xb1\xfd\xff\xfeL\x00\x86\x01\xb0\x02\xcc\x03,\x05\x8b\x06\xc4\x07\xd5\x08\xe6\t\xdf\n\x9e\x0bF\x0c\xeb\x0cH\rL\rX\r\x94\r\xc5\r\xdd\r\x03\x0e1\x0e,\x0e\xff\r\xe4\r\xaa\r;\r\xa5\x0c\x0f\x0cy\x0b\xb8\n\xf1\t\\\t\xae\x08\xef\x07#\x07N\x06c\x05E\x04T\x03h\x02b\x01[\x00b\xff\x83\xfe\x94\xfd\x9d\xfc\xce\xfb\xed\xfa\xc6\xf9f\xf8\xfd\xf6\xa9\xf5Z\xf4\x08\xf3\xea\xf1\x11\xf1{\xf0\x0e\xf0\xf2\xef2\xf0\xa7\xf0\x14\xf1~\xf1\x02\xf2\xa1\xf2<\xf3\xd4\xf3\x9b\xf4\x89\xf5\x98\xf6\xc4\xf7\x1d\xf9\x9f\xfa\x08\xfcU\xfd\x95\xfe\xd4\xff\xec\x00\xd4\x01\x8f\x024\x03\xf2\x03\xc3\x04p\x05\x17\x06\xcd\x06\xa0\x07O\x08\xc4\x08.\t\x88\t\xa8\t\x7f\tR\t/\t\x07\t\xad\x08k\x08:\x08\xde\x07q\x07\x11\x07\xb4\x06\x13\x064\x05a\x04{\x03m\x02K\x01c\x00\x93\xff{\xfe<\xfd+\xfc2\xfb%\xfa\x00\xf9\n\xf87\xf7P\xf6\x98\xf5d\xf5\x82\xf5\x9e\xf5\xdb\xf5|\xf6,\xf7\x89\xf7\xd0\xf7.\xf8\xa4\xf8\xb3\xf8\xbe\xf8U\xf9\x1b\xfa\xd3\xfa\xaf\xfb\xf5\xfcE\xfe\x1e\xff\xb5\xffP\x00\xc0\x00\xc7\x00\xa9\x00\xb7\x00\x03\x01+\x01m\x01+\x02A\x03=\x04 \x05?\x06J\x07\x01\x08\x92\x08\x0e\tz\t\xba\t\xe1\t@\n\xb5\n\x04\x0bK\x0b\x8c\x0b\x8b\x0bS\x0b\x02\x0b\x8e\n\xf4\tC\t\x91\x08\xfa\x07;\x07q\x06\xc7\x05"\x05N\x04K\x03>\x02\x14\x01\xd5\xff\x92\xfe`\xfdF\xfc.\xfb)\xfah\xf9\xb8\xf8\x0f\xf8x\xf7\xf8\xf6o\xf6\xe3\xf5j\xf5\r\xf5\xc7\xf4\x9d\xf4\xa9\xf4\xfe\xf4i\xf5\xea\xf5\x85\xf60\xf7\xdf\xf7\x86\xf8.\xf9\xc7\xf9e\xfa\x0c\xfb\xbb\xfbr\xfc\x18\xfd\xcb\xfd\x8c\xfeX\xff\x0c\x00\xb3\x00m\x01&\x02\xea\x02\xbb\x03\x88\x04O\x05\xef\x05_\x06\xb8\x06\x02\x07"\x07 \x07\x19\x07\x13\x07\x1b\x07\x0e\x07\x15\x078\x07I\x07D\x075\x07!\x07\xf9\x06\x9e\x063\x06\xcc\x059\x05\x93\x04\xdf\x03$\x03Z\x02c\x01x\x00\x88\xff\x93\xfe\x89\xfdw\xfc\x7f\xfb\x86\xfa\xa1\xf9\xce\xf8\x1c\xf8\x98\xf7D\xf7$\xf7\'\xf7L\xf7\x9a\xf7\r\xf8\xa0\xf8*\xf9\xb0\xf9&\xfa\x8b\xfa\xdd\xfa8\xfb\x93\xfb\xef\xfbH\xfc\x90\xfc\xd8\xfc\x13\xfdN\xfd\x8c\xfd\xcc\xfd\n\xfeQ\xfe\xa0\xfe\xfd\xfe^\xff\xdd\xff\x94\x00P\x01\x02\x02\xba\x02{\x03,\x04\xd0\x04x\x05\x1d\x06\xa5\x06\xfa\x06D\x07\x86\x07\xa3\x07\xbb\x07\xcb\x07\xc9\x07\xb4\x07z\x07P\x073\x07\xfd\x06\xa3\x06O\x06\xe5\x05^\x05\xd8\x04S\x04\xba\x03\x1f\x03o\x02\xa8\x01\xea\x00\x15\x00S\xff\x97\xfe\xe0\xfd6\xfd\x9a\xfc\r\xfc\x8e\xfb\x1a\xfb\xb9\xfal\xfa\x1f\xfa\xd7\xf9\x97\xf9Z\xf9\x1c\xf9\xe0\xf8\xc1\xf8\xa2\xf8\x8b\xf8~\xf8\x91\xf8\xb8\xf8\xdd\xf8&\xf9}\xf9\xd8\xf9:\xfa\xa6\xfa\x1c\xfb\x99\xfb\x16\xfc\xa9\xfcP\xfd\xfe\xfd\xcb\xfe\xb3\xff\xa5\x00\xa0\x01\x93\x02p\x03=\x04\xed\x04|\x05\xe2\x05(\x06`\x06\x87\x06\xa8\x06\xc1\x06\xd5\x06\xd7\x06\xde\x06\xf4\x06\xec\x06\xcd\x06\xa0\x06m\x06+\x06\xd1\x05\x87\x05=\x05\xdf\x04f\x04\xed\x03\x8c\x03\x16\x03z\x02\xdc\x01,\x01t\x00\xa2\xff\xca\xfe\xfc\xfd/\xfdc\xfc\xae\xfb\x11\xfb\x83\xfa\r\xfa\xae\xf9d\xf9&\xf9\x05\xf9\xf4\xf8\xf8\xf8\x05\xf9\x10\xf97\xf9r\xf9\xb8\xf9\x04\xfaI\xfa\xa9\xfa\x11\xfb\x81\xfb\xfd\xfbu\xfc\x01\xfd\x8a\xfd\x17\xfe\x99\xfe\x15\xff\x98\xff\x0c\x00{\x00\xe9\x00a\x01\xd1\x010\x02\x94\x02\x07\x03l\x03\xb9\x03\x0e\x04z\x04\xda\x04/\x05\x8d\x05\xec\x05D\x06\x7f\x06\xb3\x06\xe4\x06\xfe\x06\xf7\x06\xe4\x06\xc7\x06\xa4\x06|\x06U\x06"\x06\xf4\x05\xc1\x05\x86\x057\x05\xd4\x04V\x04\xcd\x033\x03u\x02\xb3\x01\xe3\x00\r\x00.\xffU\xfe\x84\xfd\xbb\xfc\xeb\xfb&\xfb\x86\xfa\xe9\xf9X\xf9\xd7\xf8[\xf8\xfb\xf7\x98\xf7V\xf7,\xf7\x00\xf7\xf2\xf6\x10\xf7=\xf7\x88\xf7\xea\xf7u\xf8)\xf9\xdb\xf9\x8b\xfaD\xfb\xfe\xfb\xba\xfc`\xfd\xf5\xfd\x9c\xfeR\xff\x03\x00\x96\x00c\x01U\x02)\x03\xf8\x03\xc3\x04e\x05\xc1\x05\x01\x06\x1a\x06\'\x06#\x06\x03\x06\xe8\x05\xf8\x05\xf8\x05\xe2\x05\xd9\x05\xc4\x05\xab\x05q\x054\x05\xe7\x04~\x04\xe7\x03e\x03\xf4\x02y\x02\x04\x02\xa6\x01F\x01\xe5\x00\x81\x00\x12\x00\xb3\xff/\xff\x9b\xfe\x01\xfee\xfd\xda\xfcS\xfc\xdb\xfb\x80\xfb.\xfb\xfc\xfa\xe3\xfa\xc5\xfa\xa8\xfa\x94\xfa\x87\xfax\xfa_\xfaT\xfaU\xfaU\xfar\xfa\xbc\xfa\x1a\xfb\x8c\xfb\xfc\xfb\x82\xfc\xfa\xfcl\xfd\xdb\xfdD\xfe\xac\xfe\xff\xfe]\xff\xc4\xff+\x00\x9b\x00\n\x01~\x01\xe2\x01I\x02\x8d\x02\xba\x02\xed\x02\x18\x03T\x03\x86\x03\xbe\x03\xea\x03\x10\x049\x04\\\x04m\x04a\x04e\x04_\x04S\x04i\x04\x8e\x04\xd0\x04\xf9\x04/\x05n\x05\x84\x05\x83\x05g\x054\x05\xe6\x04k\x04\xdf\x03O\x03\xad\x02\xf9\x015\x01~\x00\xaf\xff\xca\xfe\xea\xfd\x15\xfdG\xfc\x84\xfb\xdd\xfa@\xfa\xc5\xf9a\xf9\x1d\xf9\xf0\xf8\xd4\xf8\xe2\xf8\x03\xf9C\xf9\x92\xf9\xe1\xf9d\xfa\xe4\xfaj\xfb\xee\xfbf\xfc\xe1\xfc6\xfd\x97\xfd\xf3\xfdQ\xfe\xc0\xfe9\xff\xb6\xff>\x00\xe9\x00\x8f\x017\x02\xd0\x02L\x03\xbe\x03\xf7\x03 \x04A\x045\x049\x041\x04&\x04\x1c\x04\x04\x04\xde\x03\xc7\x03\x95\x03I\x03\xfd\x02\xa7\x02\\\x02\xf6\x01\xa3\x01y\x01J\x01\x0e\x01\xd8\x00\xd3\x00\x01\x01\xe9\x00\xc0\x00\xb8\x00\xb3\x00\x93\x00M\x00&\x00$\x00\xf0\xff\x9b\xffW\xff\x10\xff\xac\xfe\x14\xfe\x9f\xfdh\xfd\x18\xfd\xac\xfcd\xfcD\xfc\x17\xfc\xe7\xfb\xb9\xfb\x8d\xfb\x99\xfb\xb3\xfb\xc0\xfb\xfd\xfb\x17\xfc\xea\xfb\xc3\xfb\x99\xfb\xbd\xfb%\xfc\xaa\xfc\x16\xfd\x19\xfdh\xfd\x0e\xfe\xa2\xfec\xff\xc9\xff~\x00\x02\x01\x02\x02\xc3\x02\xb7\x02"\x02\x1b\x02\t\x038\x05"\rs\x1a\xb6\x1f\xd3\x13\xf8\x03\xf8\xfd\xba\xfb~\xf5a\xf50\x00c\x0c\xda\x0c\xec\x06\x02\x04\xcd\x01\xcb\xfa6\xf1\x99\xf03\xf8V\xff\xe0\x01H\x06g\x0b\xf0\t\xe8\x01b\xfbU\xf9\xd6\xf86\xf9\xc8\xfc\x03\x02|\x05\x9f\x06\xf4\x03l\xff\xbd\xf9\x05\xf7\xba\xf4\x14\xf7]\xfc\x97\x02\x19\x04\x01\x01&\xff\xbd\xfb\xc0\xf8A\xf6[\xf9\xaf\xfd\x83\x01b\x02\xe3\x01\x80\x00&\xfd\x83\xfa\x9b\xf96\xfb\x8b\xfd\xa6\x01\x1b\x06\xd9\x06\xdc\x02\x80\xfe\x1a\xfdM\xfc\xe5\xfb3\xff\xf0\x04>\x08b\x06\x9d\x03\xd0\x02\xfd\x00\xb1\xfd\'\xfc\x85\x00\xec\x05t\x07C\x06\x87\x078\tJ\x04\xb7\xfd\xcf\xfd\xd7\x02\xeb\x04\x88\x03\xba\x03\x84\x05\xad\x01\x84\xfc.\xfa\x1b\xfc\xd1\xfef\xfe\xed\xfew\xfe\x9c\xff9\x01B\x01\xd3\xfd\xc5\xf9\x96\xfb]\xfd=\xfd\x17\xfc&\xfe\xbb\xfe\x86\xfb\xa5\xfb\x89\x00\xf5\x02\xbe\xfe\xa2\xfcX\x00\x81\x02\x13\xfe\xae\xfa\xd5\xfe]\x05\xa6\x04S\xfd\xa1\xf8.\xf9\xee\xfb\xd7\xfbg\xfc\x0e\x01F\x02\x96\xff\xf1\xfa_\xfb\x17\xfeX\xff\x1f\x00;\x00c\x04\x97\x02\x8e\xff\x8b\xfc\x1e\x00\x87\x01\xd5\xfa\xe4\xf7\xa4\xfdQ\t3\t\xad\x04S\x02\xf0\x03\xed\x00\x99\xfc6\xfe{\x04\x8a\t\xf1\t\xb9\x07>\x06N\x05\xac\x04\xea\x03\x8d\x01H\x01\x19\x03\x86\x04x\x05\xfc\x07\x1b\x07\xc4\x02Y\xfcq\xfa\x00\xfd\'\x02Q\x05Q\x02,\xff{\xfa\xd8\xf5G\xf5\xcf\xfb&\xff\x98\xf7\x18\xf1s\xf8R\x039\x01\x98\xfa\xf0\xfd7\x01H\xfa\xc2\xf3\xa7\xfb6\x06\n\x03o\x00\x90\x046\x05\xb7\xf9\xf5\xf5\x9c\x02\x91\n\xce\x06,\x01\x1e\x042\x01E\xfb\x8f\xfe:\x07:\x07d\xf9\xe4\xf6Q\xfc\xc7\xffo\xfdE\xfau\xfc\x9d\xf8N\xf5\xd5\xf9*\x00\x80\xffW\xfb\x0b\xfd\xe0\x00F\xfe\xb2\xfcN\x00F\x07T\x0b\x92\rt\x10\xc5\x0e\xfc\x08\xa1\x06\xe2\x0b\xac\x0c\x92\t\xb7\t\x1c\tg\x05\xed\x03\xe2\x00\xd9\xf9\xc0\xf4\xff\xf1\x05\xf5\x92\xf5\xc7\xf4x\xf7\x9a\xf5\x80\xf1u\xeez\xf0\xb3\xf2\'\xf5\x05\xfcV\x03^\x02\x00\xfc\xb8\xfdk\x02\x9c\x03\x07\x05a\nc\r\xd9\x0bD\t\xeb\tV\nu\x08\x94\x08\x8c\x08A\x04U\x00L\x01d\x01\x88\xfd\x87\xf8\xd1\xf9\xc9\xf8i\xf5\x94\xf4;\xf7;\xf9\xe0\xf5\x17\xf5\xce\xf8\x91\xfd6\xfe\xe8\x00^\x05A\x05\xc4\x01\x9b\x01T\x07/\x0b\xd2\x08\x14\t\x88\r,\r\x1a\x07\xec\x04\n\x08\xac\x04\x03\x00m\x03O\tr\x05\xbc\xfc,\xfd\xf8\xfc\x8a\xf9S\xfa\xbe\xfe\x8e\xff|\xfba\xf9\xa9\xf7?\xf6\x00\xf7\xcb\xf9>\xfd\xc5\xfc\xea\xf9\x19\xfb\x17\xfc\x9c\xfd\xa7\xfc\n\xfc*\xff\xe6\x006\x02\xa4\x04\xdf\x05\xe3\x00\x85\xfc\xcb\x00Z\x04\xa4\x03\xd6\x03\x95\x06s\x04\xeb\xfc#\xfd\x8a\x02.\x00\x10\xfe\x00\x01D\x03\xcd\x01U\xfe\xf8\xff\xd2\xfe\x81\xfc]\xfe7\x02\xbd\xfe(\xfa,\x00\xe8\x04(\x04\xd1\x00(\x00M\x02\xf2\xfe(\xfb\xac\xfe\x17\x02U\x00|\xff\x1d\x05\xe9\x05\x17\xff;\xfb\xf0\xfd\xc6\x019\x01\xa2\x02"\x05~\x02\x88\xfe\xe3\xfc\xba\x00\x08\x02\x90\x01\xea\x01\xaa\x00)\xff=\xffR\x003\xfe\x12\xfd\xc2\xfe#\x01\x0f\x00|\xff\xe7\x00J\xff\xcc\xfa\x9c\xfa\xcf\xff\xd7\xffM\xfc\x90\xfd\xf9\x00@\xfe\xf2\xf9\xbb\xfb\xd8\xfe\xd0\xfeJ\xfc\x89\xfeP\x02(\xffk\xfd\xf7\xfe/\x03\x1b\x02y\xfe\xbe\x008\x04W\x03&\xff\xfa\xff\xb1\x00;\xff\xae\x00f\x05\x01\x06\xdc\x02\x16\x02\xa6\xffA\xfb\x9a\xfd\xab\x06\xad\x08\xca\x02-\xff\xb4\xfeF\xfc\xd7\xfa\xc3\xff\xf3\x04\xa5\x02e\xfd4\xfe\x0e\x01\x80\xff\x90\xfd6\xfd\xbb\xfc\x0b\xfdF\x01g\x04\xda\x00\x08\xfcA\xfd\xfb\xff[\xfeO\x00\x19\x05b\x03\x89\xfb_\xfb\xc6\x02\xb8\x00\xd2\xfc\xb6\xfdL\x04;\x03\xc2\xfcE\xff\xf7\x00\xaa\xff\x05\xff\xe6\x01W\x04\xc6\x00\x81\x01a\x01\xd2\xfe\xf9\xff\xda\x02$\x03d\xfe\x10\xff@\x016\x00\xbb\xfdr\xfc\xad\xff8\x00\x82\x00X\x00\xef\xff\xa6\xfd?\xfd\x8c\x00\xd6\x00x\x01\xfd\x00\xd8\x02\xc3\x02\xe9\xfez\x01]\x03\x81\x01\x0b\x00R\x03l\x06D\x01\x14\xfe\xce\xffV\x00\x19\x00\xd7\x00I\x04\xa2\x01Y\xfc\xcb\xfa\xf1\xfdQ\x02\x85\x00b\xffy\xfe\x07\xff\xa8\xff%\xff\xfb\xfdG\xfdr\xfdU\xfd\xbb\xfe0\x00^\x00L\xfd\xd6\xf9Z\xf9\xf1\xfb|\xfeF\x00n\xff\xa7\xfe\xe5\xfa%\xf8\xeb\xf9\xd1\xfd\x80\x00x\xff\xf0\xfe\x8a\xfd\xa1\xfcS\xf9\x9b\xf8\xcb\xfc\x00\x00\xba\xffr\xfd^\xfdO\xfd\xba\xfb\xe0\xfa\xf4\xfcu\xff\xc9\xfe\x1a\xfdM\xffk\x02\xf7\x00\x1d\xff#\xfe\xa6\xfe\xcf\xfe\xd7\xff2\x04*\x06O\x03\xb9\xffn\x00\xd3\x03\xb1\x06\xdb\x05\xd2\x05\xc5\x054\x06[\x07\xc6\x08\x89\t\xe9\x07"\x06\x8c\x05-\x07t\x07\n\tR\x05\xe4\x00\x08\x01\xc5\x03\x94\x07\xbc\x05w\x05\xb2\x05\x8a\x04\xbb\x04v\x08\xa0\x0f\xf1\x10%\r\x0c\r\xdf\x0f\xf6\x11\x1d\x11H\x11\x9e\x12\x84\x10\xd8\x0c\x17\x0cv\x0e)\x0c\xb0\x06t\x02)\x01\'\x00\x03\xfd\xd7\xfb\xfc\xfa\x1d\xf7L\xf2\xe5\xef\x99\xef\x8b\xee0\xec\xa0\xebP\xeb-\xea\xac\xe9\xf2\xe9\x99\xea\x0f\xea\xf6\xe9H\xebe\xed\xb1\xef\xbf\xf1\xe7\xf2P\xf3]\xf3\x85\xf4\x03\xf6q\xf7\x0f\xf9\xdc\xf9w\xf9\xb7\xf8C\xf8\xa5\xf7\xda\xf6k\xf6\xbd\xf5B\xf5\xe5\xf4u\xf3\xa4\xf2X\xf1\x1a\xf0\x9d\xef\x8c\xef(\xf0\xdf\xf0]\xf1u\xf11\xf2\xd3\xf2\x00\xf4\\\xf6\xf4\xf8"\xfa\x9a\xfb\xda\xfc\xd6\xff5\x02\xa4\x04\xe3\t\x0c\n\x98\t\x10\x0b.\x0e\x14\x12\x8b\x12\x87\x14\x9f\x14D\x12\xc2\x10\xdb\x10\x08\x16\xdd\x1c\xa0%f&\x8e\x1e^\x1b\x1c#U/\x020e)\x89\'\xc4)\xd8-)1(4\xd1/\xa9#\xa7\x19k\x17~\x1aZ\x1a4\x16}\r\xd7\x03\x94\xfc\xce\xf8\xfb\xf5Z\xf1*\xec\xc9\xe6\x18\xe3\xc5\xe2\x05\xe4X\xe3\xb7\xdf\xe0\xdc\r\xdeT\xe0{\xe3\xd7\xe5\x85\xe7\xe8\xe7\x08\xe8\xff\xea,\xf1&\xf6\xd2\xf6}\xf3\x0f\xf1\xc5\xf5L\xfe=\x01\xdd\xffw\xfc]\xf9O\xf9\x93\xf9\xe0\xfd\x97\x009\xfd\x9f\xf5\xdb\xf2)\xf5\x0f\xf73\xf7?\xf4\x19\xf2m\xef\x13\xefO\xf2|\xf5>\xf5\xdd\xf1b\xef\xbb\xef\xb8\xf3%\xf8\x8c\xfa\xeb\xf9\xac\xf6\x12\xf5v\xf50\xf7\xec\xf9\x07\xfb}\xfa\x04\xf9\x94\xf8Z\xf8\xf3\xf8\xbb\xf8Z\xf8d\xf81\xf9\xa1\xfb+\xfdL\xfd\x06\xfcp\xfa\xa2\xfa(\xfd\x88\x00\x99\x033\x037\x02y\x01\xec\x02\\\x06\xa1\x08\x13\n\xc6\x0b1\r\xf4\r\xbf\x0e\xe6\x0e\xe4\rB\x0f\xf0\x16\xc5!>\'\xdb#\xf0\x1f\xec\x1eU"\x9f\'\xfe+R/\xb8.f,\x13+\x06*\n(i#q\x1d\xe7\x18\x15\x16b\x16\xd4\x15\x18\x11l\x08\x8c\xff\xbb\xf9\xc5\xf6\xc3\xf44\xf2\x17\xf0c\xec\x10\xe8b\xe6\xa9\xe6\xb4\xe7\n\xe6\xe9\xe2\xaa\xe1\xef\xe2h\xe6K\xea\xfa\xec\xd6\xed%\xed\xf8\xec\xc3\xefK\xf3\x7f\xf4T\xf44\xf4\x8a\xf6S\xf9\x8c\xfaU\xfa\xc9\xf8\x10\xf7\x87\xf5*\xf6\xdf\xf8\r\xfa\xbc\xf9;\xf8d\xf7\xed\xf6\x01\xf6\x8d\xf53\xf5\xf2\xf5\x08\xf7\xab\xf7\xcf\xf7\x80\xf7s\xf7\x9f\xf7\xea\xf7\r\xf8\xfd\xf8.\xfa;\xfb-\xfc\x87\xfc\xe4\xfcd\xfd\x12\xfe.\xff;\x00\xe7\x00d\x01\xde\x01\xf8\x01q\x02\xe9\x02\x18\x03-\x03\x9e\x02j\x02\xef\x02\xee\x02@\x02\xae\x01\xe2\x01h\x025\x02\xb4\x01\xc3\x01\x16\x02\xce\x01\xf8\x00"\x01\xa5\x01\xdd\x01i\x01\x84\x00\xe0\xff\xbb\xff\xb1\xffR\xff\xad\xff\xea\xff\x91\xff\x85\xff\x15\x00\x88\x01>\x03\xe5\x04O\x06\xae\x07P\t\x01\x0bt\r\xbc\x10}\x13\xd3\x15\xc3\x16\xab\x17\xeb\x18\xec\x19A\x1b\xb8\x1b\xe7\x1bX\x1b\'\x19\xb5\x17u\x16g\x14P\x126\x0f7\x0c\xa5\t\xab\x06+\x04\xa8\x01:\xff=\xfc\xc6\xf9\xcd\xf7\x18\xf6\xe2\xf4B\xf3\xf9\xf1\xdf\xf0\x8f\xef\xd4\xee\x99\xee\xee\xee1\xef\x8d\xeef\xee\xed\xee\x9f\xef~\xf0B\xf1B\xf2\x1d\xf3\'\xf3Q\xf3q\xf4\xb0\xf5\xe2\xf6\xe2\xf7\x9f\xf8Q\xf9\x13\xfa\xbb\xfa\xbb\xfb5\xfc8\xfc\xe8\xfc\xad\xfd\'\xfez\xfeD\xfe\xe7\xfdv\xfd\xcf\xfc\xd6\xfc\x05\xfd\x92\xfc/\xfc\xde\xfb\x1a\xfb\xc4\xfa\xd1\xfa\x84\xfar\xfa\x06\xfa\x8d\xf9\xb1\xf9\x91\xf9M\xf9\x83\xf9g\xf9,\xf9H\xf9G\xf96\xf9F\xf9\xe2\xf8\xd0\xf8+\xf9\x89\xf9\xfe\xf9`\xfa\xa0\xfa\xee\xfa\xfe\xfa\xfa\xfa\x9e\xfb\x94\xfc\x00\xfd\x0e\xfd;\xfd`\xfd\x8e\xfd\x01\xfeG\xfe\xbc\xfe<\xff\x08\x00r\x01\xf2\x02\x01\x05E\x08\x07\x0c\xdd\x0e\x80\x11\xdc\x14\xbc\x18y\x1cL\x1f\n"N%\xc6\'p(\x80(\xe0(\x93(\x80&\xd5#\xec!\xb9\x1f\xb8\x1b"\x17\x8e\x13$\x10\xa3\x0b\xc3\x061\x03\x08\x01\x1a\xfes\xfa\xd9\xf7=\xf6q\xf4l\xf2.\xf1+\xf1\xa9\xf0$\xefm\xee!\xef\xa1\xef\x8a\xef]\xef\x8d\xef\xc7\xefm\xef"\xef\xba\xef\'\xf0\x08\xf0\xd5\xef=\xf0\r\xf1`\xf1\xd3\xf1z\xf2$\xf3\xc6\xf3\x1a\xf4\xf2\xf4\x10\xf6\x00\xf7\xcc\xf7n\xf8\xd3\xf8Q\xf9\xef\xf9\x81\xfa\xfd\xfa5\xfb_\xfb\xb6\xfb\xf3\xfb\xe2\xfb\xce\xfb\xd2\xfb\x8b\xfb\x8a\xfb\xcf\xfb8\xfc\xa2\xfc\xbe\xfc\xe4\xfc\x1c\xfdV\xfd\x80\xfd\xb6\xfd\x14\xfe[\xfe\x80\xfek\xfeR\xfej\xfet\xfey\xfe\x81\xfep\xfe^\xfe,\xfe\x0f\xfeG\xfe\xa2\xfe\xbc\xfe\xcc\xfe\xaf\xfe\x93\xfe\x9d\xfeR\xfe8\xfee\xfe~\xfe\xc9\xfe\x14\xff&\xff\x1b\xff\xe4\xfe\x99\xfe\xfd\xfep\x00A\x03\x83\x06v\x08\x13\n\x87\x0c;\x0f\xe3\x11\x93\x14i\x18W\x1c\x0f\x1e\xbe\x1e< \x0b"\t#\xed!\xaf v |\x1e\x0e\x1bY\x18G\x16\xf6\x13\x83\x0f\xb2\nt\x08\x90\x06\xc8\x02\x11\xff\xd1\xfc\xbb\xfb\xaa\xf9W\xf7\xd8\xf6\x1e\xf7\xd0\xf5H\xf3\x97\xf2s\xf3\xc8\xf3_\xf3A\xf3\x07\xf4\xbf\xf3j\xf2\x1a\xf2i\xf2]\xf2\x97\xf11\xf1\xf7\xf1,\xf2\xd4\xf1\xc0\xf1\x1c\xf2\'\xf2\x0c\xf2\x97\xf2\xa5\xf3G\xf4q\xf4\xd0\xf4\x7f\xf5%\xf6\xb9\xf6\x08\xf7C\xf7}\xf7\x96\xf7\xe5\xf7M\xf8\xa4\xf8\xfa\xf8\xe9\xf8\xb8\xf8\xd9\xf8\'\xf9Z\xf9\x8f\xf9\x16\xfa\xb2\xfa\x13\xfb\x9d\xfb\x87\xfc[\xfd\xb1\xfd\xed\xfd\x87\xfei\xff\x05\x00R\x00\xbd\x00\x12\x01 \x01+\x01\x80\x01\xc1\x01u\x01\xe8\x00l\x00\xc2\x00\x14\x01\x06\x01\x01\x01\xa9\x00\x0b\x00\xa0\xff\xb7\xff\x04\x00\x1d\x00\xb0\xffA\xffA\xff\xea\xfe\xc6\xfe\xe2\xff\xe5\x00\xcc\x00\x8a\x00%\x01\x04\x03\x8a\x05/\x08S\x0b\x01\x0e\x0c\x0fG\x10\x0b\x13|\x16"\x19\xf7\x1a\xaf\x1c\x19\x1e\xe4\x1d\x1e\x1d\x80\x1d\x1f\x1e\xee\x1c\xfb\x19:\x17I\x15\xb3\x12\x86\x0f\xb1\x0c\n\n\x84\x06S\x028\xff\xba\xfd>\xfc\xd8\xf9I\xf7\xd4\xf5\xf4\xf4-\xf4\x99\xf3\xa3\xf3\xc7\xf3\xfc\xf2\x11\xf2k\xf2\xb9\xf3\xdb\xf4\x15\xf5,\xf5\xf2\xf56\xf6\xbb\xf5\x82\xf5\xe5\xf5j\xf6\x02\xf6V\xf5\xce\xf5Q\xf6\xc4\xf5\xaa\xf4J\xf4\xcc\xf4\xdb\xf4r\xf4\xac\xf4\x85\xf5\x7f\xf5\xb6\xf4\xa4\xf4q\xf5"\xf66\xf6\x85\xf6Q\xf7\xf1\xf7F\xf8\xb5\xf8\x90\xf9O\xfa\xb5\xfa\xe5\xfar\xfb1\xfc\xae\xfc4\xfd\xdb\xfd\x9c\xfe.\xffY\xff\x8d\xff\xc4\xff\x06\x00J\x00\x92\x00\x13\x01\x8a\x01\xa2\x01\x93\x01\x80\x01\xa1\x01\xa9\x01p\x01i\x01\x95\x01\xac\x01\xbe\x01\xd0\x01\xfd\x01\xda\x01c\x01\xf9\x00\xde\x00\xf1\x00\xee\x00\xf4\x00\xc9\x00V\x00\xf0\xff\xc5\xff\xc2\xff\xf4\xff\x0b\x00&\x00s\x00p\x00\x9e\x00\xd5\x01\xc3\x03\xc8\x05\xca\x07y\t\x11\x0b1\r\xc7\x0f\xa1\x12\xeb\x14\xf5\x15Y\x17\x0f\x19>\x1a<\x1b\x98\x1b}\x1bU\x1a\t\x18\x95\x16\xba\x156\x14\xa1\x11\x88\x0e\xbe\x0b\'\tt\x06N\x04\x81\x02I\x00|\xfdB\xfbL\xfa\xed\xf97\xf9\x10\xf8\x18\xf7\x81\xf6\xe2\xf5\x9e\xf5\xf3\xf5n\xf6S\xf6\x91\xf5l\xf5\x18\xf6q\xf6\x18\xf6\x86\xf5c\xf5<\xf5\xbd\xf4\x96\xf4\xe3\xf4\xbf\xf4\xec\xf3?\xf3U\xf3\xc6\xf3\xbc\xf3s\xf3u\xf3\x98\xf3\xdd\xf3L\xf4\xf8\xf4O\xf5V\xf5i\xf5\xc7\xf5\xa3\xf6\x81\xf7G\xf8\xd2\xf8\t\xf9Z\xf9\xd8\xf9{\xfa\xfb\xfa\x94\xfb\n\xfc=\xfc\x8f\xfc\t\xfd\x96\xfd\xcc\xfd\xb2\xfd\xcf\xfdI\xfe\xd5\xfec\xff\xe7\xff\x0e\x00<\x00j\x00\xd8\x00\x87\x01\x0e\x02J\x02n\x02\xa1\x02\xf6\x029\x03;\x03L\x03L\x03\xf9\x02\x8f\x02\x90\x02\xc2\x02\x8b\x02\xf8\x01p\x01s\x01M\x01\xa3\x007\x00\xf3\xff\xc8\xff\xc4\xff\xb4\xff\xf0\xff\xd8\xff^\xffq\xff\x05\x00\n\x01Q\x02\x1b\x045\x06i\x07\x15\x08\x9b\t(\x0c\x99\x0e\xc5\x10\xec\x12\xda\x14\xdb\x15)\x16\xdf\x16\x1a\x18\xc5\x18\x7f\x18\xaf\x17\xa2\x16\x18\x15F\x13}\x11\x00\x10U\x0e\xf4\x0b\n\tU\x06E\x04X\x02%\x00.\xfe\xae\xfcX\xfb}\xf9\xda\xf7,\xf7\xac\xf6\x87\xf53\xf4\x94\xf3e\xf3\xbf\xf2\n\xf2 \xf2H\xf2\xb0\xf1\xf1\xf0\xd5\xf09\xf1\x1c\xf1\xc0\xf0#\xf1\xba\xf1\xbe\xf1\xac\xf1@\xf2[\xf3\xeb\xf3\x15\xf4\xcf\xf4\xd5\xf5\\\xf6\xb3\xf6\x95\xf7\xbe\xf8_\xf9\x92\xf9\xf5\xf9\xb4\xfa\x14\xfb2\xfb\xac\xfbO\xfc\x81\xfcq\xfc\x8a\xfc\xe2\xfc\xf6\xfc\xbf\xfc\xd7\xfc6\xfdt\xfd\x87\xfd\xb5\xfd\xf3\xfd0\xfeh\xfe\xdb\xfe\x7f\xff\x08\x00\x8e\x00\r\x01\xb7\x01R\x02\x08\x03\xbe\x03E\x04\xbd\x048\x05\x98\x05\xe4\x05V\x06\xd6\x06\xe7\x06t\x069\x06T\x06\r\x06N\x05\xdc\x04\xb9\x04j\x04\x9b\x03\xae\x02%\x02\x15\x02\xe3\x01+\x01\x00\x00/\xff"\xff\xc0\xfew\xfe:\xfe\x18\xfem\xfe\xa1\xfed\xfe\xb8\xfe\xb8\xff\xb1\x00\xf8\x00!\x01\x8a\x02Y\x04a\x05\xc3\x06\xe6\x08\x93\n\xfa\na\x0b\xf8\x0c\xd2\x0e\xb2\x0f\x01\x10\xcd\x10\x9e\x11Y\x11\xb2\x10\x9e\x10\xc3\x10\x11\x10\x80\x0eM\r\x82\x0cD\x0b\xa4\t\xfc\x07\x8d\x06\xc5\x04\xc2\x02\x00\x01\xb5\xffz\xfe\xe2\xfc4\xfb\xca\xf9\x9b\xf8\x94\xf7\x90\xf6\xc6\xf5!\xf5T\xf4\xc8\xf3|\xf35\xf3&\xf3#\xf38\xf3A\xf3O\xf3\xb2\xf3+\xf4w\xf4\xc8\xf4H\xf5\xec\xf5p\xf6\xbb\xf6\x19\xf7\x99\xf7\x04\xf8j\xf8\x0c\xf9\xc6\xf90\xfaa\xfa\x90\xfa\xdd\xfa!\xfbI\xfb\x82\xfb\xd4\xfb\x16\xfc=\xfcU\xfc\x83\xfc\xa6\xfc\xae\xfc\x9f\xfc\xb6\xfc1\xfd\xb5\xfd-\xfe\x9f\xfe\xde\xfe\xf5\xfe!\xff\x9a\xffr\x007\x01\xc6\x01&\x02s\x02\n\x03\x94\x03\t\x04r\x04\xda\x04\x1f\x052\x05y\x05\xff\x050\x06\xd2\x05a\x05V\x05w\x05!\x05\xb6\x04`\x04\xa2\x03\xd5\x029\x02+\x02\xfa\x01\x16\x01x\x00Q\x00\xe3\xff>\xff\x0b\xffr\xff\x88\xff\x05\xff\xb1\xfe\r\xff{\xff\xb6\xff\x1f\x00L\x00\x9e\x00\xd8\x00\x1b\x01\x14\x02\xae\x02\xec\x02)\x03\x87\x03\xac\x03\x08\x04\xa8\x04`\x05\xe9\x05\xef\x05\xec\x05,\x06\x18\x06\x12\x06\x01\x06\xc2\x05\xc2\x05M\x05\xa4\x04\x06\x04v\x03\x0c\x03t\x02\x9a\x01\x01\x01\xa2\x00\xcc\xffY\xfe\xa0\xfd\xba\xfd\xaa\xfd\xde\xfc\x1c\xfc\r\xfc[\xfbB\xfa\xeb\xf9\x98\xfa#\xfb\xe7\xfa\xcb\xfa\xee\xfa\xe0\xfa\xcb\xfa9\xfb\xe7\xfb\x83\xfc\x88\xfc\xc0\xfc\xaf\xfd\x8a\xfe\xf2\xfe/\xffo\xff\x86\xff\x9c\xffU\xff\xb5\xffK\x00]\x00e\x00\x1a\x00\xf4\xff\xe6\xff\x99\xff\x99\xffy\xff*\xff!\xff\x11\xff%\xff\xe5\xfe\xb6\xfe\x9b\xfef\xfeD\xfeL\xfe\x98\xfeq\xfe\xcf\xfe\xab\xfe\x8e\xfe\xcf\xfe\xdf\xfe\xb7\xfeV\xff\x9c\xffJ\xff\xa3\xffi\xff\x88\xffV\xff4\xff\x95\xff\xa0\xff\x96\xff\xe9\xfe\xf4\xfe\xc3\xfeV\xfe_\xfe_\xfe8\xff\x80\x00\xca\x02\xb8\x012\xfe\x82\xfc\x89\xfd!\xfei\xffr\x00\xc4\x00l\x004\xfe\xa2\xfe[\xff\x17\xff\x81\xffH\x01\x12\x02Z\x02\x84\x02M\x03\xf5\x03\x1c\x04B\x04Z\x04r\x05\xe9\x05\xdf\x05U\x06\xa3\x06*\x06\xf9\x05\x07\x05.\x05\x02\x05\xed\x03\x7f\x03\xef\x02\xfa\x01G\x01\x08\x01(\x00W\x00\x01\xff\xd9\xfd\x92\xfc^\xfc\xe6\xfc<\xfc+\xfc:\xfcV\xfcw\xfb\x13\xfc\x7f\xfc\x80\xfc\xed\xfc\x87\xfd\xb0\xfd\xca\xfd\x86\xfer\xfe\xb3\xfe\xb6\xfe\x0c\xff \xff\xaf\xff\x92\xff\x18\x00Q\x00\xa0\xff\xc0\xff\r\x00\x92\x00\xd8\x00\x05\x01\\\x01\xc9\x01H\x02\xcd\x02\x98\x02\x13\x03S\x03\x08\x04\xc7\x03N\x044\x04t\x03\xd7\x03%\x03\xd6\x02\x13\x03\xfd\x02E\x02\xbb\x018\x01&\x01%\x00l\xffl\xff\x90\xfe\x1b\xffH\xfe\xd8\xffu\xfe\n\xfdd\xfd\x90\xfc\x05\xfd\xfa\xfb\x0c\xfd\x1c\xfe\xc7\xfdG\xfd\x08\xfdn\xfc\\\xfcI\xfb3\xfd\xae\xfd\x8e\xfe\xcd\xfe\x1f\xfe\x86\xfe\x1b\xfdZ\xfe\xf6\xfd\x05\xff\xc0\xfe\x1f\xff\xc7\xff\x81\xff\xb9\xff#\x00\xee\xff\xba\xff\x9b\xfe\xe5\xfe\x02\x00\xd2\xff1\x00\xd9\x00,\x01\xa8\xff\x02\x00Z\x00\xac\x00u\x00\xc1\x00\x8e\x01V\x01\xdf\x00\x03\x02d\x02,\x01\x9d\x01\xd1\x01/\x02^\x01\xee\x01V\x02\xe5\x01\xec\x01\xb4\x00\xa9\x00\xc3\xffM\x00{\x00\xe1\xff\xb5\xff\xba\xfeh\xfe\x0c\xfe\xd9\xfd1\xfe+\xff\xcc\xfd^\xfd\xb4\xfd,\xfe\xac\xfdB\xfeY\xfe\x8f\xfe,\xfe\xe5\xfc\xe2\xfc\x99\xfd\x05\xfe\xa9\xfd\xf4\xfe\x08\xff0\xfe\xb6\xfc\x9f\xfd\xa5\xfd\x91\xfe\x14\xffg\xff\xcd\xff\x83\x00p\x01\xd5\x00\xe2\xff\x90\x00\x94\x03\xc1\x02$\x03\xbf\x03\xba\x04c\x03\x95\x02\x8e\x03h\x05\x0f\x06\xbf\x046\x03\xa4\x04M\x03,\x02b\x01\x85\x03R\x02\x03\x00\xad\x00\x81\x00\x95\xff\x8e\xfd\xd3\xffs\xff\xcd\xfe\xc5\xfd\x1b\xff\xa9\xfdS\xfem\xfdE\xfeK\xfe2\xfd\r\x00E\xfdy\xff(\xfe@\xfe&\xfdO\xfc.\xfeh\xfe\xe8\xfd\x92\xfe:\xff\xad\xfdK\xfd#\xfe\x83\xfe\x10\xff\x94\x00L\xff\x04\xff\xc8\xff!\xff?\x00\xe7\x00\xe0\x02\xac\x01\x7f\x00\xf5\xff\x00\x01\x9b\x01>\x01\x8d\x02\xdb\x01\xff\x01?\x01p\x01\xb8\x00\xc1\x01\xe4\xff_\x00\xa2\x00\xe8\x01\xfb\x01V\x00/\x02\xe0\xff\t\x006\xff\x83\xff\xd6\xff\xa3\x00\xa4\x00u\x01\x83\x00\xf5\xfe\xc9\xff\x9f\xfe\xe7\xff\x1f\xffW\x00`\x01\xf5\xff\x06\xff\xdc\xfeW\xffh\x00\xf0\x00\x83\xff\xe5\xff^\xfe\x96\xffG\x00\xee\xfe9\xff0\xfee\xffV\xfec\xfe\x9f\xfe|\xfe\xa0\xfe[\xfd\x1b\xfd<\xfeO\xfd\xea\xfe\'\xff\x97\xff\xd3\xffX\xfeO\x01/\x01\xf4\x00\xe6\xff\x9e\x02\xb1\x02\xda\x02\x0b\x05\xc4\x03\x91\x04\xa8\x02G\x02\xc1\x03\x16\x04\xf1\x01a\x03\xff\x01\xdc\x01_\x01\xa2\x01G\xff\xdc\xff\xe8\x00\xca\xfd\xeb\xff\x98\xfd\xf0\xfe\xfd\xfc\x1a\x01\xb0\xfc\x8f\xfd<\xfd\x00\xfdv\xff\x81\xfe\xfd\x00\xe1\xf9\xaf\x008\xff\x89\x00J\x00#\xff\xac\xff\xe0\xfe\xfe\xfe\xba\x01y\x01\xec\xfe\x19\x00\xc5\xff\xc0\x02\xf5\x01\x18\x00\x0c\xfe\xd5\xff\xb4\xfds\x01#\x02\xc4\x00\xd5\xff(\xfe\xf0\xfe2\xff\xf2\xfe\x8f\x005\x00 \xfe\x9a\x01.\xffr\x00\xc1\x00\x93\xff\x89\xff"\x00\x17\x02R\x00\x94\xff\xc9\x01\xa7\xff\xd5\xfe(\x00\xa5\xfd\xdb\xffe\x00\x12\xff\xe8\x01\x9a\xff\x84\xfe}\xff4\xfen\xfd\xed\xfcY\x00#\x03\x1a\x00\x9d\xfe$\x00I\xfc%\xff\n\x00\x7f\x00\xca\xfe\xfa\xff\xd3\x01\xbb\xfd\xbc\xfeO\xff\xe0\x00$\xfe\xd2\xff\xb0\x01\xf6\xfb\n\x01U\x00e\x00\xd3\xfdn\xfdX\x01\x0f\xff\xdf\x00\x06\xff\xad\x02\xaa\xfc\x86\x01`\x01\xba\x01\xdd\xfe\xec\x00\n\x05\xbd\x00\xfd\x01\xb3\xfe\x96\x03`\x02?\x01+\x00\xbe\x02\xbb\x00\x12\xff\xeb\xfec\x02\xf6\x025\xfc8\xfc\x19\x01\xee\xfda\x00L\xfd\x0b\x00Z\xfe1\xfbO\x01\xe8\xfbq\xff\x9f\xfcr\xfe\xb0\xfe\xd7\x00\x7f\x00\xa9\xffJ\xfe\x96\xfdt\xfd\xf3\xfd\x9e\x02)\x01\xc1\xff$\x01\x1d\x01\xbb\xff\x12\xff\xa9\x00G\x03~\x01\xda\xff\xcc\xfc\xcb\x02\x91\x02\x86\x06\x98\x01\xdc\x00\xf5\x00\x89\xfc\x7f\x03\xd7\x00\x08\x03\xdf\x01\x85\x00$\x04F\x00\xba\xfc\x87\x01t\x02B\xff\t\xfff\xfe9\x00\xc0\xfeH\x00:\x02\x01\x02\x91\xfeR\xf9\x99\xff\r\xfe\xee\x00\x17\x00\xc1\xfe\x9b\x00^\xfd\xef\x02\x87\x00\x08\xfe\xb2\x00\xd2\xfd\xba\xfe\xb8\xfe\x94\xff\x04\x02\xf8\xfe\xab\xffA\xff\xac\x03\xb5\xfe\x1d\xfd\n\xfb\xf1\xfb@\x02\xeb\x003\xff\x0b\xff@\x00\xce\xfe\xc6\xfe\x94\xfe\xa3\x04\xe5\xfeT\xfd\x1d\x02e\xfb1\x00\xc4\x04\xbc\x02|\x02 \xff2\x01\xb3\x00)\xff\xb1\xfc\x9d\x00\x94\x04\xf3\x04\\\t\x81\xfb\xb1\xfc\x8e\x01\x1f\x00\xe1\x02\xf6\xfe\x13\x04R\x00\xcb\x00M\x02\xeb\x01h\xfc\x86\xfc\xbf\xff\xae\xfbb\x03>\x01\xa8\xff\x12\xfe\x88\xfc[\xfc\x13\x01\x01\xff\x02\xfb\xb3\x03\x83\x00C\xff\r\x00R\xffn\xfe\xd5\xff\x0b\xfe\x92\xfe\xb3\xfe|\xfd\x9d\xffi\x02s\x00\x17\x02\x04\xfd\x1e\xf8\xed\xfbV\xff\xe1\x03\x96\x04\x95\x04\n\x01\xb8\xfb\xdb\xfc:\xfe@\x00<\x04?\x04r\x01\x85\x00\x8d\x03\x9b\xfd\xd3\x02\xdc\x05"\x00\xf8\x00\x1c\xfe\xc2\x00\xeb\x02\xed\x01\xbe\x02k\x03e\xff\xc1\xfa\x07\xffi\x01h\x00\xc1\xff\xfc\xff~\xfe]\x00\n\x00\xd2\x04\x87\xfbt\xf8\xf6\x02\xc5\xfe!\xfc*\x03\x84\t\xe1\xfe\xc3\xfa*\xfa)\xfc\xb4\xfdw\x02\x14\x05z\x02\xb1\xfe\xfd\x02\x91\xfe\xb8\xf7\xb4\xfe\xb4\xfbg\xfe\xf0\xfe\x1d\x04V\x04\x0e\x02\x1c\x02l\x00\xd1\xf1o\xf0w\xfd\xc3\x07\xd1\x0cI\x08q\x02\xb4\xf7\xb4\xf8\x1c\xf6Z\x01\xd3\x04\xba\x03\xf5\x02h\x03\xed\x00y\xfb\xee\x03I\xfe\x9e\x01\x0e\xff/\xf9z\xff\xef\x04\xb8\n.\x05\x8b\xfd\xb4\xfc\x06\xfb\xfe\xf6(\x00\xa4\t\xce\tn\x02\xb3\xf5c\xfau\xfb\x18\x04\xf6\tA\x03\x1c\xfc\xfc\xf6;\xf9}\xfbS\x06G\t\x15\x03\xd2\xf7i\xf87\x01\xc1\x03\x08\xfd\x14\x00\xbf\xfe\xca\xfa\xd6\xfe\xd4\xf8\xf6\x04\x17\x0f\x88\x02\xbd\xf7\xd1\xfb\x92\xfb\xd3\xfd"\x05\x11\x03\xad\x03\x8b\x02\xde\xff\xab\xfb\xe0\xfem\x05n\x04W\x00\xcc\xfd\x1b\xff/\xff\x7f\xfd\xcb\x03&\x06H\x02\xeb\x00\x14\xfe+\x02\x7f\xff\x8b\xfaJ\x01\xbc\xffr\xfeO\x04\xab\x04\xec\x05.\x00\xbf\xf9@\x00\xd0\xf5g\xf8{\x06/\x0c\x9a\x04\xe9\xf8\xdc\xf9\x91\xfc"\xff \xfc\xf9\x064\x05W\xfb\xfe\xfd \x01<\x03\x11\xfd\xd6\xfc\xaa\xff\xe9\xfcW\x05\x87\x04Y\xff\xe3\xff\xe9\xfdl\xfb\x15\xfb\x8d\x00!\xffK\x01\xe0\x01\x84\x03v\x03\xd6\xfaX\xfb\xdb\x003\x06\xe9\xfd\xf5\xf62\x06,\x05\xa7\xff\xf8\x05{\x06\x1b\xfa\xf7\xf4\x16\xfd\x9e\x03\xa9\x05@\x01\xb3\x04\xfd\x03w\xf9\x9e\xfc\x8c\x01:\xfd&\x00y\x06\xc3\x00\xc5\xf5:\xff\x82\x054\x00\xee\x06\xb9\x05\xb2\xfd,\xf5\xdf\xf65\x01m\x02\xfd\x02\xbe\x06\xce\x06\x1e\x03\xc4\xff\x1b\xfcz\xf8\xb3\xf7[\xff\xc1\x05\xa1\x05o\x08\xc6\t\xa0\xfd-\xf3\x01\xf5\x13\xfa\xd0\xf9\xab\x01\xf2\x0cc\x0cs\tq\xfa&\xf3\xe3\xf3\xbf\xfan\x00_\x02\xf6\t\xa1\n\xf9\x050\xfc\xae\xfc\xce\xf9~\xf8\xfa\xfa\xa7\xfc|\x05w\x0b\x14\r\xaf\x06\xc0\xfd\x89\xf6H\xee\xec\xf5^\x03c\x08\x12\x10\x11\rE\xf9\x00\xec\xfd\xf1S\x00\r\x07\x8d\x06-\x07\x12\x00\xc8\xfa\xf6\xfb\xa9\xfd\xfa\xfc\xca\xff\xe1\xfe\x0e\x01\xfb\t\xc7\x06m\x02\xf1\xfb\x14\xf2\xd7\xf5\xc1\x00$\x07\xd9\x0b\x8d\n\x9b\x01{\xf8\xe2\xf7\x12\xf8\xb5\xfa\xd0\x039\x0c\x05\x0b\xff\xfbk\xf9\xd4\xfb\x12\xfb\xc4\xfd\r\x07\x1c\x05\x86\xfc\x05\xfe\x16\x00\xe5\x00\xcf\x02~\x00\xd5\xfam\xfc\xee\xfd\x87\x05\x14\t\xe6\x014\xfc\xe3\xf7\xc7\xf9\xb7\xff7\x06\xa3\nz\x03\xf4\xfa\xfd\xf9n\xfbN\xfd\x8b\x02\xca\x06\xc9\x06\xdf\x00\x87\xf8v\xf8"\xfe\xf9\x04\xd8\x02X\xffy\xfe\xe5\xffo\xff\x86\xfd\xd4\xfe\x13\x00\xf4\xfe\xb4\xfb\x14\xfe\xc6\x02\xb3\x04\x1d\x02*\xfd\'\xfcR\xfd\x8f\xfb\xb3\xfeL\x02\xd0\x02\xa4\x04\xd9\x01\xf5\xfdU\xfc\x05\xfc\x8c\xfdu\xffu\x03n\x03\xa1\x01\x0c\x01\x83\x00\xd1\xff\x1d\xfe\x96\xfek\xfd\xd9\xfe+\x02{\x04y\x02+\xff\xcf\xfd\x01\xfe\xd6\xfes\xffD\x01\xb7\xff\xc2\xfc\'\xfb\xbf\xfb\xb6\xfe\xda\x02\x99\x01\x11\xfd\xd9\xfa\x90\xfb\xa9\xfa\xe1\xfb.\x00\xfd\xff\x11\x00\x03\xff\xf5\xfe\xa1\xfe)\xfd\x84\xfd\xbd\xfdk\x00\xb1\x03\xd0\x04I\x04\xd0\x03\xe0\x03\xe2\x02\xab\x01\xe4\x04[\n\x02\n\x0f\t\x8a\x08~\x06\xfd\x06u\x08\xa6\t\xd0\x0b\x18\rQ\tB\x06?\x04\xf8\x04\xc0\tE\x0c#\n\t\x05\xee\x02!\x01^\x00\xad\x01\x1b\x03\xf7\x03\xb3\x01Q\xfe~\xfc\xc4\xfbB\xf9}\xf8\x9c\xf8D\xf9|\xfa\x87\xf9\xf3\xf7\x12\xf6K\xf3`\xf0\xbc\xef\xcf\xf2\xab\xf6q\xf7\x16\xf6\x99\xf3\xfb\xf0\x95\xedJ\xec\xa1\xf0\xe0\xf6.\xf9\xbb\xf6\xec\xf2\x03\xf0\xf0\xeeT\xf0\xc9\xf2\x8b\xf4\x99\xf6\x14\xf6C\xf3@\xf2o\xf2+\xf3\xb2\xf2\xdf\xf0\xc0\xf3l\xf7-\xf74\xf5\xa7\xf4\xd3\xf5\xf1\xf4\xb2\xf6\x06\xfa#\xfb\xe5\xfc\x04\xfd\x8c\xfdn\x00s\x03\xc4\x06\n\x08b\x07Q\t(\x11\xc0\x1f\xb6-\x9c2Q*\xac"\x84&\x19/6<\xe7H\x93P\xe6Lc>02\x111\xe85\xa15\xc22i/U)8\x1fn\x12\xb3\x08r\x00p\xf63\xee\x97\xebY\xedI\xeb;\xe2\xb9\xd3\x93\xc8l\xc56\xc6l\xcb\xb8\xd2\xe6\xd7\xea\xd5\x81\xcf@\xcdt\xd2\xbc\xda\xa4\xe01\xe8\x83\xee\x90\xf4\x95\xf8\x18\xf9\x85\xfe\xdc\x03\x11\x05\xa2\x04@\t\xed\x10\xbd\x15\xe2\x153\x13\xe3\x0f[\t\xf1\x05~\x07B\n\xf3\n\xcf\x05\xfa\xfd\xca\xf6\x08\xf1\xf6\xee5\xee\xc6\xecY\xeb\xde\xe71\xe4\xd9\xe1\xe3\xe1\x9a\xe3\x18\xe3\xa0\xe2\xb0\xe3y\xe5<\xe8\t\xeaK\xed\x15\xee\xa7\xed\xa2\xefR\xf3\xd8\xf8$\xfb\x99\xfc+\xfe&\xfe\xe0\xfd/\xfe;\x02\x1e\x06\x04\x06\x86\x04\'\x02\xd1\x04\xaa\x04\xf0\x02{\x01%\x00\xa2\x00\x9c\xfd\x93\x02\xd5\x07w\n\x86\x03\x87\x01\xad\x10\xcf\x1e\x82#"!l(|/o.\xe1,\x8b3\xa7C\xafG\x9bA\xa2?\xc7A\xb9?\xe83P-\x95-5-j\'\xaf\x1eh\x1a\xf8\x11\xbc\x04\xee\xf7&\xef\xc7\xec\x8c\xe9\xcf\xe6\x16\xe4\xf6\xdd\xe3\xd3B\xc9X\xc7\xcf\xcc\x94\xd3\\\xd6\xfd\xd5g\xd6\x0e\xd6%\xd5.\xd9\xca\xe1\x11\xe9\x11\xee#\xf1\xf1\xf3\xf5\xf7\xde\xf9\xcc\xfa\xaf\xfd\xb4\x01\xf5\x07\xa9\x0b\xad\x0c[\x0ba\x07\xbc\x02\x03\x01:\x05\x03\x0b\x08\x0c/\x06\xa9\xff\xbe\xf9N\xf5\xdf\xf4{\xf8\x8a\xfb\x1d\xf9\x90\xf3T\xf0\xdf\xedN\xec_\xedA\xf0+\xf3\xbd\xf2\xfe\xf1\x11\xf2x\xf2\x94\xf1 \xf1\xf2\xf3\x84\xf8\xe5\xfb\x05\xfd*\xfc\xca\xfa\xe1\xf8\xe1\xf7>\xfb\xd6\x00\xb8\x04@\x04K\xff\xfd\xfb\xcf\xfb\xa6\xfd\xbe\xff\x1a\x02\xbc\x01\x13\xffU\xfb\xf5\xf8\x89\xfa@\xf9\xb3\xf7$\xf8\xb7\xf9\xc5\xf94\xf7j\xf4\xcc\xf0\xf9\xef\xf2\xfaK\x12,$\x1b\x1d\xff\x0e\xc6\x0b%\x16d\'\xcf6NJ\x16R\xf2G!5\x13/y;AHDL\x98HfB76\x80%\x94\x1c\x18\x1c\xfc\x1ay\x13\xc0\x08X\x03\x9a\xfc\xd8\xee9\xe0d\xd8[\xd6g\xd5\x9a\xd4a\xd5\n\xd4\xd4\xca(\xc0\x91\xbej\xc7\xea\xd2Y\xd9\xa5\xdb\x05\xdd\x8c\xdb \xda\x9e\xdf\x0c\xeb\x05\xf8\x10\xfe\xf1\xfc{\xfc\x84\xfeK\x01\x0f\x04%\n\xc3\x0f\xff\x10\x87\x0ca\t#\x0b\x82\x0b\xed\x08\x14\x08\x8e\t\x15\t\xb6\x05z\x02\xc9\x00#\xfd\x9a\xf8Q\xf7\xc4\xfa\x11\xfcM\xfa\xfe\xf5\xe3\xf1I\xee\xc8\xedZ\xf2\xce\xf6j\xf9d\xf6,\xf2\xbf\xf0\xce\xf2\xb1\xf7B\xfc_\xff\x7f\xff(\xfec\xfe>\xff0\x02C\x04,\x05\xba\x050\x05\x10\x05\x8d\x04]\x04\x0f\x03\x1d\x02S\x01\xec\x00\x16\x00U\xfc\x14\xfa\x8a\xf8)\xf8\xa5\xf7\'\xf6\xe5\xf4\xa7\xf2\xa9\xf0\xff\xec|\xeb\xa9\xeb\xf0\xec\x13\xf3p\xf8\x9e\xfcP\xfai\xf4m\xf7(\x03!\x13i\x1f\xdc#\n!\xd5\x1a\x81\x1a\x0c&\xf79\x02E\xe1D\xe7<\xff2\x17/81[9\xe4=19l-\xc2"\xf4\x1bu\x17W\x16b\x13\x90\x0b\x97\x00E\xf6\x11\xf2`\xef\x9f\xeb\xfa\xe5\x14\xdf\x85\xd9\xa7\xd6:\xd7\xed\xd7D\xd8\xe7\xd55\xd3\x1d\xd33\xd6\x94\xdc\xb9\xe0\xc2\xe15\xe2\xcf\xe2T\xe5\x01\xe9\xa9\xef\x0b\xf6\x07\xf8\n\xf6\x14\xf5\xf7\xf8W\xfd\xb6\xff|\x02]\x04\xd8\x03-\x01\x8e\x01n\x05\xe3\x06\xbb\x05\xe6\x04H\x05\xd7\x03\x0f\x022\x02\xea\x03A\x021\xff]\xfeS\xff%\xff\\\xfdN\xfc\xdd\xfb\xc6\xf9o\xf8\x8c\xfav\xfc\xe4\xfc\xff\xf9\x80\xf8u\xf8\x1d\xfa\x7f\xfcM\xff\x1a\x00(\xfen\xfc^\xfca\xfe\\\x01K\x03~\x033\x02r\xff\xbd\xfe\xeb\xfe\xfd\xffq\x00\x9a\xffJ\xfe\x90\xfc\xd8\xfar\xf9\xd4\xf8\xe2\xf7\xd9\xf6\xf9\xf4\xdc\xf4\xc4\xf4(\xf3\xc0\xf1k\xf1Z\xf3\xe0\xf4\xbd\xf6t\xf8.\xf7\xef\xf3\xc1\xf6\x0b\x02m\x0e\xc3\x13\x99\x11\xd4\x0e\x1a\r\x8c\x10\x9b\x1d\xe3.\xb48\xa23o)_$R\'~0\xe09\xa3?q9c+& +\x1e\x7f#c&\xae$\xbb\x1c\xba\x10\x9d\x05\xec\xff\xe2\x00K\x01\xdf\xfd\x9a\xf7F\xf1\xa9\xeb\x87\xe6E\xe4<\xe5\x0e\xe6-\xe4"\xe1\xab\xdf\x82\xdf/\xdeG\xddZ\xden\xe1d\xe3a\xe3h\xe4\t\xe5\xd0\xe4J\xe4r\xe6X\xeb2\xee\x15\xef\xfe\xee\x91\xef\x86\xf0B\xf1E\xf4e\xf7\xd1\xf9r\xfa\x93\xfa\x0c\xfco\xfd\xbd\xfen\x00x\x02D\x047\x04\x9d\x03\xdc\x04\xbb\x06\xbd\x08\x87\x08J\x081\x08\xbd\x07\xf0\x07P\x08\x11\n]\n\x00\t\xfd\x06\x93\x06\xa5\x07\x8a\x08\xdd\x07\xfa\x06d\x06\x19\x06\x8f\x05\xed\x05\xf5\x06:\x06\t\x04\xab\x02\n\x038\x04/\x04s\x03s\x02g\xffu\xfc\xcf\xfb0\xfd\xcd\xfd\xf5\xfbr\xf96\xf73\xf5\xce\xf3\x1f\xf4\xe4\xf4\xeb\xf3\xcf\xf1]\xf0u\xf0\xca\xf0\x07\xf1o\xf1\xfb\xf1\xf1\xf1\x15\xf2\xd5\xf3!\xf6c\xf7|\xf7J\xf8\xaa\xfa\xfb\xfd4\x01\xb2\x03)\x05\xd3\x05P\x07\xd1\n\xaa\x0f\xda\x13d\x16k\x17\x0e\x18l\x19X\x1c9 =#\x95#}"\xf9 \xa9 }!N"\x1f"\xd7\x1f1\x1c>\x18\xa6\x15F\x14\xde\x12k\x10\x8a\x0c\x04\x08\x91\x03I\x00P\xfe\xa6\xfc*\xfa\xdb\xf6l\xf3\xc6\xf0*\xef\x08\xee\xfe\xec\xaf\xeb\x1e\xea\xf0\xe8\\\xe8\x84\xe8\xb1\xe8\x8b\xe8\xfc\xe7\xca\xe7\x12\xe8\xce\xe8\xe0\xe9\x90\xea\xd1\xea"\xeb\xbe\xeb\xcc\xec\x16\xee_\xef\xa4\xf0\xa5\xf1\x90\xf2\xf3\xf3\xa2\xf5O\xf7\xad\xf8\xc9\xf9\xef\xfaL\xfc\xc2\xfdN\xff\xa8\x00\xab\x01c\x02\x15\x03\xff\x03\x08\x05\xe6\x05o\x06\xc0\x06\xf7\x06/\x07|\x07\xed\x079\x08.\x08\xd9\x07\x91\x07\x87\x07\x87\x07\x9b\x07\x97\x07Z\x07\xc9\x06\x08\x06\x9a\x05o\x053\x05\xb4\x04\xe6\x03\xfd\x02\x07\x02\x0f\x01Z\x00\xb3\xff\xcb\xfe\xb7\xfd\x7f\xfcr\xfbs\xfa}\xf9\xa5\xf8\xeb\xf7)\xf7K\xf6\xae\xf5j\xf5J\xf55\xf5\x00\xf5\xe2\xf4\xe6\xf4$\xf5\xde\xf5\xf3\xf6\x00\xf8\xe4\xf8\x91\xf9F\xfa/\xfb\x84\xfc$\xfe\xb0\xff(\x01i\x02p\x03e\x04\x8a\x05\xe9\x06\x1f\x08\xff\x08\xac\tN\n\xc1\nY\x0bG\x0cF\r\xe1\r\xf4\r\xc1\r\xe2\rh\x0e\x14\x0f\xd2\x0f,\x10\x11\x10\xa5\x0fM\x0fb\x0f\x88\x0fa\x0f\xff\x0eL\x0eJ\r[\x0c\x8b\x0b\x03\x0bD\n\x02\t\x8b\x07^\x06U\x05Z\x04m\x03c\x02&\x01\xec\xff\x0b\xff\x7f\xfe\xf4\xfd&\xfdY\xfc\x94\xfb\xf3\xfa\x83\xfai\xfa;\xfa\xae\xf9\xe3\xf8\x1d\xf8\xdb\xf7\xa8\xf7\x81\xf71\xf7\xa5\xf6\xce\xf5\t\xf5\xae\xf4\xa5\xf4\xb6\xf4\x9c\xf42\xf4\x9c\xf3\x1f\xf3\xf2\xf22\xf3\xa3\xf3\xf4\xf3\x19\xf4\x0f\xf4\x13\xf4g\xf4\x01\xf5\xe0\xf5\xb4\xf6V\xf7\xf8\xf7\x93\xf8d\xf9N\xfaX\xfbd\xfc]\xfd5\xfe\x0c\xff\xf1\xff\xd7\x00\xaf\x01r\x026\x03\xe7\x03\x80\x04\xe0\x04/\x05}\x05\xb6\x05\xef\x05\x05\x06\xfc\x05\xd1\x05\x8a\x05=\x05\xf1\x04\xac\x04[\x04\xf1\x03}\x03\xff\x02\x87\x02 \x02\xbc\x01h\x01#\x01\xd3\x00j\x00\x00\x00\xbf\xff\x99\xffj\xffC\xff\x17\xff\xcf\xfej\xfe\x15\xfe\xf4\xfd\xd9\xfd\xb9\xfd\x88\xfdF\xfd\xdd\xfcr\xfc5\xfc7\xfc8\xfc\x12\xfc\xbb\xfb<\xfb\xd9\xfa\x9b\xfa\x9c\xfa\xae\xfa\xae\xfa\x94\xfa}\xfa\x87\xfa\xb9\xfa\x1e\xfb\x94\xfb\x1a\xfc\xa7\xfcN\xfd1\xfeS\xff\xb2\x00"\x02\x91\x03\xf0\x047\x06\xa7\x07_\tM\x0b\x1c\r\xb5\x0e!\x10j\x11\x8b\x12\x8a\x13\xb1\x14\xdf\x15\xaa\x16\r\x17\x07\x17\xc0\x16G\x16\xd2\x15T\x15\xa3\x14\x8f\x133\x12\x8b\x10\xbc\x0e\x0f\rZ\x0b\x83\t\x8e\x07r\x05O\x039\x016\xff9\xfd8\xfbA\xf9c\xf7\xb0\xf5(\xf4\xa2\xf2.\xf1\xcb\xef\x97\xee\x99\xed\xc2\xec$\xec\xa0\xeb\'\xeb\xbe\xea\x85\xea\x7f\xea\xa2\xea\xe1\xea:\xeb\xb8\xeb>\xec\xc7\xec\x94\xed\x98\xee\x81\xefY\xf08\xf1U\xf2\x8e\xf3\xcc\xf4\x13\xf6R\xf7\x85\xf8\xbb\xf9)\xfb\xbd\xfcC\xfe\xa0\xff\xd1\x00\x0c\x02[\x03\xae\x04\x02\x062\x07/\x08\xf3\x08\xa9\tr\nH\x0b\xf3\x0bG\x0ck\x0cy\x0cv\x0c_\x0c.\x0c\xd3\x0bC\x0b\x80\n\x9e\t\xcf\x08\x00\x08\x11\x07\x0c\x06\xde\x04\xb3\x03\x80\x02H\x01+\x00\x13\xff\xfa\xfd\xdf\xfc\xce\xfb\xd2\xfa\xe8\xf9\x10\xf9J\xf8\xa3\xf7\r\xf7\x84\xf6\n\xf6\xa9\xf5h\xf5:\xf5:\xf5`\xf5\x97\xf5\xdc\xf5,\xf6\x8d\xf60\xf7\xed\xf7\xde\xf8\xc7\xf9\x94\xfak\xfbQ\xfcr\xfd\xb4\xfe\x03\x007\x01F\x02)\x03\r\x04\x1c\x05^\x06\x91\x07l\x08\x12\t\xaa\t8\n\xee\n\xc6\x0b\x80\x0c\x07\ra\r\xb3\r\xff\r*\x0eD\x0e\x82\x0e\xcd\x0e\xfe\x0e%\x0f\x14\x0f\xdd\x0e\x95\x0e{\x0em\x0eX\x0e\x1f\x0e\xa6\r\xda\x0c\xff\x0bi\x0b\xe6\nQ\nz\t^\x08\x06\x07\xa5\x05m\x04f\x03e\x02\x14\x01\x91\xff\t\xfe\x91\xfc5\xfb\x03\xfa\xff\xf8\xf2\xf7\xbe\xf6t\xf5b\xf4\x8a\xf3\xdd\xf2Y\xf2\xde\xf1e\xf1\xf2\xf0\x9f\xf0\x87\xf0\xa3\xf0\xc5\xf0\xee\xf0\x1b\xf1i\xf1\xe3\xf1c\xf2\xef\xf2\x88\xf3(\xf4\xd0\xf4\x94\xf5f\xf65\xf7\x0f\xf8\xe1\xf8\xb3\xf9\x92\xfau\xfbg\xfcN\xfd$\xfe\xec\xfe\xb4\xff\x87\x00V\x01\t\x02\x88\x02\xeb\x02H\x03\xbd\x03&\x04\x84\x04\xb8\x04\xbc\x04\xa2\x04y\x04x\x04\x87\x04\x7f\x04Q\x04\xfb\x03\x9a\x03:\x03\xf5\x02\xc5\x02\xa0\x02S\x02\xd1\x01U\x01\xff\x00\xd1\x00\x9e\x00u\x00/\x00\xd2\xffi\xff1\xff6\xff2\xff\x18\xff\xcd\xfe\x8e\xfeu\xfep\xfe\x97\xfe\xb6\xfe\x9a\xfeo\xfeP\xfec\xfe\x89\xfe\x94\xfe\x8b\xfem\xfeL\xfeM\xfeg\xfe\x7f\xfes\xfe8\xfe\x15\xfe\t\xfe\x02\xfe\xf5\xfd\xef\xfd\xd2\xfd\xb8\xfd\x8b\xfd\x94\xfd\xd6\xfd\xf3\xfd\xf9\xfd\xe1\xfd\xe9\xfd1\xfe\xad\xfe"\xffr\xff\xc1\xff\'\x00\xbc\x00\x8a\x01\x84\x02u\x03B\x04\xf1\x04\xd0\x05\xf3\x06&\x088\th\n\x98\x0b\x99\x0cO\r\xee\r\xc5\x0e\x96\x0f&\x10m\x10\x8f\x10O\x10\xcb\x0fb\x0f\x1b\x0f\xb0\x0e\xc1\r1\x0c\x8b\n!\t\xfb\x07\xdf\x06h\x05\xa5\x03\x90\x01s\xff\xa2\xfdN\xfc,\xfb\xf2\xf99\xf85\xf6\x88\xf4\xc1\xf3{\xf3\xe7\xf2\xe0\xf1\xc8\xf04\xf0=\xf0\x9a\xf0\t\xf1%\xf1\xff\xf0\xfa\xf0s\xf1{\xf2\xc8\xf3\xa5\xf4\xd8\xf4\x17\xf5\xe3\xf5W\xf7q\xf8#\xf9\x90\xf93\xfa\xe6\xfa\x89\xfb\xb1\xfc\xd1\xfd3\xfe\xd6\xfd\xf1\xfd>\xff\x84\x00\xb5\x00\x7f\x00\x8c\x00\xea\x00B\x01\x12\x02z\x02\xed\x02\xb3\x03\x86\x03\x88\x02\xc1\x02\x12\x04w\x02\x88\x01\x17\x08\xa4\x0f\x9f\np\xfc\\\xfa\xe4\x05l\x0el\r\x15\t\x95\x030\xfb\x1b\xfa1\x07\x9c\x0fO\x08\x03\xfe!\xfa\x98\xfb\xf6\xfe\xa9\x02\xb7\x01\xa0\xfb\xb1\xf6\x91\xf7\x13\xf9\xe8\xf7\x9e\xf8\x05\xfcA\xf9\xa9\xf0?\xee<\xf6\x90\xfd.\xfc\xf8\xf6e\xf42\xf5\xfa\xf6V\xfbJ\x01\xe7\x01\x7f\xfcu\xf8\x0c\xfb\xb0\x01\xc2\x05m\x05\xeb\x02m\x00b\x01\x11\x05#\x08\xa2\t\x8e\tt\x06\xdc\x03\xb1\x064\x0c\xb1\r*\n\x85\x08\xfb\n\x7f\x0c\x1c\x0cV\x0c\x0f\r\xa5\x0c\xb3\x0b\x9e\x0c\x02\x0e\xa8\rf\x0c=\x0b\xdb\n[\nm\nb\x0b:\x0cM\n|\x06\x1d\x05\x19\x07\x9f\t\xb2\x07\xc1\x04\x02\x04\x8f\x03\xbd\x01\xb6\x01\xb8\x03\x96\x01\xc8\xfb\xea\xf9\xd4\xfd\x02\x00\x9b\xfc?\xf8\xce\xf6\x1a\xf6\'\xf5\xeb\xf6\xac\xf9\xda\xf8\x87\xf3\x84\xee\x14\xefk\xf3\xc2\xf6\x02\xf7\xd5\xf4\\\xf1\xe8\xeeW\xf0\xf5\xf5\x82\xfa\xeb\xf9S\xf70\xf6B\xf6\x1b\xf8\xf7\xfc\x9f\x01\x87\x01\xbb\xfd2\xfd\xd8\x00t\x03\x91\x04#\x06e\x07!\x06!\x05\x9b\x07\xa0\t\x16\x08\xd0\x068\x07\x02\x08S\x07\xdc\x06\xd1\x05\x91\x01\xab\xfd\x0c\xff\xf8\x01\xb6\xff\x85\xfb\xf3\xf8D\xf5\xc6\xf0|\xf1\xaa\xf6\x03\xf7\x83\xf0\x02\xebk\xe9O\xeb"\xeew\xf0\x89\xf0\xf3\xed\xcd\xeb\xfc\xeb\xdb\xed\'\xf2\'\xf6c\xf6\x1d\xf4\xf9\xf2\x05\xf5g\xf8\x19\xfcT\xfe\xcc\xfel\xfd\t\xfc\x9c\xfe\xf2\x01\xb3\x03]\x05\xe7\x07\xd2\x08i\x05J\x01@\x02\xc2\x08\xa7\x0e\xb8\x0fh\x0b\x13\x06>\x04^\x06\x7f\t\x8a\x0f*\x14\x9c\x112\x0bk\t\x87\r\xe6\x0f\x98\x14\x12 \xd3,\xe6(\xa4\x18\x7f\x0f?\x19\x9c,\xe26.8\xd42}(\xce\x1b\x9e\x18o%K2\x890f!\x8d\x13q\x0cc\x08\xed\x08W\n\xae\x07\x1e\xfe\xee\xf1\xdb\xe8\xb8\xe3\xd1\xe1\xa8\xdfo\xdcT\xd8\xe8\xd5=\xd4\xa2\xce\x06\xc9D\xc8\xbc\xcd\x93\xd3\xd4\xd6\x03\xd8C\xd7\xcf\xd4\x87\xd4\xfa\xdb+\xe6F\xee\xc0\xf1N\xf1\xe5\xf0C\xf1t\xf5\xb9\xfe\xe3\x06\x8a\n{\t\x1a\x07\xf0\x06\x80\t/\rC\x11?\x13\x9f\x12\xea\x10\xd4\x0e\x9f\rx\r"\x0e\xb7\r\x06\r9\x0b%\t\xf6\x06\x8a\x03\xf1\x00\xf3\xff\x89\xff:\xff\xb1\xfd\xb7\xfb\xeb\xf8 \xf5-\xf4\x19\xf5\x91\xf7\xee\xf7\xba\xf6N\xf5B\xf3\x0f\xf2j\xf3\xfb\xf6r\xfa\'\xfb\x8a\xf9\xb3\xf7A\xf7N\xf8\xc8\xfa\xbb\xfd\x83\xff\xf4\xfe:\xfc\x1e\xfa\xa0\xfa\xaf\xfc|\xfe\xc1\xfe\x9f\xfd\xd8\xfbC\xf9\xce\xf7\x9a\xf7R\xf9\x9a\xf9\xdf\xf7c\xf5\x83\xf3\x10\xf3|\xf2\x12\xf3\x87\xf5d\xf8 \xfa\xed\xf9Z\xf9\x92\xfa\xbd\xfd\xe7\x04;\x11C#\x93+\x03"\xec\x12R\x16}1\xc4I\xc4PQLKG\x8a@F8\x87<\x99M\x9eY\xd1O\t;E.\xe8)\xe2\'\x91"\x9c\x1dC\x17v\n\xce\xfa9\xeeH\xe7N\xe2\x8f\xdb;\xd3\xbe\xcd\xc6\xcb\xc1\xc6\xe3\xbd<\xb6\xa0\xb57\xbb\x01\xc1\x8e\xc4\x0f\xc7\xe9\xc6;\xc5\x92\xc6*\xce\xf6\xda\xf7\xe5f\xec~\xef\xbe\xf0[\xf2y\xf8\xbd\x01@\n\x0c\x0f\x97\x11\t\x13\xa4\x12\xff\x11\xaa\x14\xa9\x18\xdb\x17\xa1\x13L\x12R\x13\x1e\x11\xc3\n\xcc\x05\'\x05p\x03\xea\xff&\xfd\xca\xfbe\xf9\xe3\xf3_\xef:\xef1\xf1\xe7\xf2)\xf2\xc8\xef\x9c\xee\xe2\xee&\xf1\xf6\xf3\x7f\xf7\x9a\xfa\x81\xfc\x88\xfc\xfb\xfd\xd2\x01g\x05-\x07{\x08\xb1\nC\r\x17\rE\x0b\x04\x0b\xf2\x0b2\x0c\x1b\x0b\x18\t\xc1\x06\xda\x03\n\x00H\xfe1\xfdE\xfb\xbb\xf6\xdc\xf1\xb9\xed]\xeb\x07\xea3\xe9S\xe7\xb9\xe3\xdf\xe1\xae\xdfO\xde0\xdd\xf0\xdf\xc1\xe6\x0b\xe9\xde\xe7(\xed$\xf63\xf7:\xf1\xae\xfd\xc0#_>\xae1^\x18\xf6\x1b\x847\xbdJ\xe7O\xeb\\zl\x04ffJ\xfd:3IO[nZrL&Ex?\xc1+\xbd\x12\x05\t0\r\x80\n\xc2\xf9\x15\xea\x06\xe5.\xde_\xcb\xa1\xb9\x95\xb4\x89\xb9\x93\xbev\xbd \xbc\xe2\xb8l\xb2\xb2\xae\x95\xb3[\xc0V\xcf\xb7\xdb\x83\xe1h\xe2$\xe2?\xe6G\xf0\x8f\xfb\xb9\x08\x87\x15Z\x1ak\x17\x91\x12\\\x14<\x1a\xfb\x1d\x9b\x1f\xc1!f!_\x1b\xa7\x12\x1c\r\xa4\n\xc4\x06\xd0\x00<\xfcB\xfbj\xf9\xec\xf3\xf6\xea\x14\xe2\x1d\xdeT\xde\xc5\xe0\x93\xe42\xe7\xe2\xe6\x7f\xe2_\xdd\xbd\xde\xcb\xe6\x92\xf1\xca\xf8\xc6\xfb\xb1\xfc{\xfd`\xff(\x04\xe8\n\x17\x12\x8d\x15n\x155\x15<\x16\x94\x178\x17\xb1\x14\x08\x13\xed\x12\xc9\x12\xc0\x10h\x0c\xce\x06p\x01\x8d\xfc\x8e\xf9\x95\xf8\xa3\xf7\xce\xf5\x8a\xef@\xe94\xe4\xe1\xe1\xf6\xe0\xba\xdfS\xe1F\xe10\xe0\x12\xdf\xa2\xe0\xbd\xe4\xa3\xe5\xa9\xe6\x04\xe8\xa6\xe8\xde\xeb\xcd\xfav\x1d96\xdf/\xc7\x14\xfe\x08\xa2\x1e\xe3>}Z\x9amJs_bqC\xf56\xc9GHb*k|a\tQ\x80=\t,w\x1e\x8e\x16&\x13:\x0c\xee\xff4\xf1r\xe4Y\xdbG\xd1\x08\xbf3\xacR\xa5z\xab\xa3\xb7]\xbb\xa3\xb6y\xad\x9d\xa5\x80\xa4\xbd\xaeA\xc3O\xd9\xc6\xe7\xe7\xe8\xcf\xe5\xf7\xe5\x05\xedH\xfb5\x0e\xce V+o(\xdf \x8c\x1dJ#t+\x9d0\x990\xab,\x01&\xfa\x1c\xb2\x15M\x11=\r-\x06V\xfc\xaf\xf5\x0f\xf3\x83\xf0\x02\xeb\x96\xe2\x80\xdaF\xd4\xcf\xd1\x90\xd4I\xdaV\xdf|\xe03\xde\x93\xdbj\xdc$\xe2\xa8\xec\xd6\xf6\xf5\xfdj\x01s\x03\xbf\x04t\x07E\x0c\x9b\x12\xb0\x17\x15\x1a\xc2\x1a\xe5\x1b\xd4\x1b\x86\x19\xc4\x15\xcf\x12I\x11/\x10]\x0eb\x0b\x14\x07\x0f\x00\x19\xf8\xe5\xf0\x02\xed\xb2\xebS\xeb\x94\xe9\xb9\xe51\xe10\xdd\xab\xda\xfc\xd8\xe1\xda\xbb\xddL\xe1\xa0\xe3\xa0\xe4H\xe7\xe6\xea\x19\xef\xd2\xf3\x15\xf8N\xfb\xa0\xfaU\xf8\x12\xfd\x9b\x13\xa08\x95S\x80M\x99,2\x16\x96&tM.l\x92w\\w\xb5n\xdcW\x97>\xfe8iIXYMU\x8fA\xf3,3\x1fP\x11l\x00G\xefJ\xe3P\xdce\xd6\xe8\xce\x95\xc8r\xc1\xa1\xb2\x8a\x9e?\x8f\xc6\x90y\xa0\x18\xb2?\xbc\xbc\xbc4\xb7\xd6\xb1\x9f\xb3r\xc0\xb0\xd6\xb4\xee\x8d\x00\xe0\x07\xe4\x07\x83\x07\x96\x0cN\x17\xf5$X/\xca3\xe23\xc83\x022\x9f-s(?%\xe7 k\x19\x99\x12u\x10\x8f\x0e\xd7\x06\r\xfa\xd1\xed\x1e\xe6\x96\xe1\x1d\xe1^\xe3\x83\xe4\x11\xe1)\xdb\t\xd7k\xd6\xd9\xd8\xc7\xdf\x85\xe8\\\xeeQ\xf0\x98\xf2-\xf7\xc1\xfbe\xff\'\x03\x12\nY\x0f[\x12m\x14\x8b\x17s\x1a\x95\x1a\xf3\x17\xcf\x15\xb4\x14\xcf\x14#\x15\n\x144\x108\n\xf2\x02(\xfc(\xf7\xae\xf3\x8e\xf2\x82\xf0k\xec}\xe5`\xdeg\xda{\xd9@\xda\x8d\xda\xdb\xdbv\xdcn\xdc\xd8\xdc\xed\xde\x1d\xe3J\xe7v\xe9X\xeb\x9e\xeeh\xf4\x90\xfc~\x04\xba\x08\xc1\x08\xc8\x06\xbb\x05M\x06\xa4\r\xcd&\x80M\x95f|^\xed@\x11/]7vK\x8d^|q\xff\x7f\xbc{\x88b\x88D_5\xcc5\x9a7\xba2\xdd\'\xb3\x1bZ\x10)\x04\xaf\xf4V\xe2\x90\xcf)\xbe\x8c\xaf\xc5\xa5\xa1\xa6\xa4\xb1\xc9\xbb.\xb9O\xa9\xbf\x98\xa6\x91\x05\x97\xbd\xa5\xeb\xba\xc8\xd2\x05\xe5\x13\xeb\xb4\xe6\xe7\xe5\xc1\xf0@\x01\x08\r\xed\x14z\x1e\xc2(\xbe.p1\x0e4@6z2\x10\'\xee\x19D\x11\x9e\x10\xe9\x15\xd6\x1a%\x18,\x0c\x89\xfc\xdb\xeeV\xe3m\xdbI\xdbt\xe2\x91\xe9\xe5\xe8\xba\xe4\xff\xe3\xcd\xe5l\xe5E\xe2\xbb\xe1\xf2\xe6\x8c\xee\x06\xf7\xb7\x016\x0c(\x12i\x11T\x0c3\x08\xef\x08\xab\x0e\x0f\x18i\x1f\xb4!\x8e\x1f\xf0\x1aI\x15a\x10\x12\r\xc5\n\x05\x08\x0e\x04\xc2\x00\x1a\xff\x07\xfd\xb8\xf80\xf2\xe0\xe9A\xe1\xb8\xda7\xd9\x9f\xdc;\xe1\xd8\xe2\xe7\xdf\x93\xdb\xf9\xd6\xa7\xd4\x91\xd7\xa2\xdd\x90\xe4\x01\xea?\xed\xb2\xefS\xf1\x8a\xf21\xf6\xf5\xfb\xc1\x01\x9c\x07!\x0c\xbd\x0f$\x11c\x10\xaf\x0f\x89\x13v \x036OJ\xdcQoK\x9eA\\>\xe0A>G\xdcMgW\x04_\xb0\\`O|@\x816\xb0/\x8f%Q\x18\x9a\x0c\xdd\x03t\xfc\xf1\xf4\xb3\xed\xe5\xe4A\xd9\xd8\xca\xce\xbc\x9e\xb1_\xab\x10\xac\x00\xb34\xbb\x8e\xbf\x9d\xbe\xab\xbb\x85\xba\x02\xbd~\xc4Z\xd0I\xde\xb7\xea\xef\xf4\xeb\xfcQ\x04\xa9\x0bN\x13\x82\x19v\x1d\xab\x1ew\x1e\xc6\x1e) \xa3"0%\xf5%}"\x86\x1a\x95\x0f\xd8\x04\xaf\xfc\x16\xf8\xcf\xf7\xde\xf8\x00\xf8]\xf3\x17\xecf\xe5\xd9\xe0}\xde"\xde\xab\xde\xa8\xe0H\xe3\xc8\xe6E\xeb\x82\xf0\xfc\xf5\x07\xfa\xbc\xfb>\xfc"\xfd\xdb\x00\x17\x07\x83\x0eC\x15+\x19p\x1a?\x19\x14\x18\xb8\x16\xe1\x15=\x16\xb5\x16\x02\x16i\x13\x01\x10?\r\x93\n\x16\x07z\x02\xd6\xfcL\xf6\xd2\xef?\xeb1\xe9\xf0\xe8;\xe8\xc9\xe5&\xe2\xac\xdd\x19\xda\x92\xd8p\xd9*\xdci\xdf\x88\xe2$\xe5\xf8\xe6#\xe8\xcb\xe9;\xed$\xf3\xd0\xf9F\x00\n\x05\x98\x07\xaa\t\x99\x0cP\x11\x93\x16\\\x1a\xd9\x1a\xf9\x19\xdf\x19(\x1e\xb7\'\x9c4\x16@\xa7E\x8fD\n?\xb78e4-4w7\x86;\x9e<\x109\xd31\xb2(\xbd\x1f\xd6\x17+\x10\x0b\x07\xf6\xfb3\xf1!\xe9\x02\xe4X\xe1\xb2\xdf\xb3\xdd4\xd9\xb5\xd1\xa9\xc9\xf5\xc34\xc2\x9d\xc4\x18\xca\xef\xd0\x9e\xd6\xa3\xda\x97\xde\x03\xe4=\xeb\x1f\xf2\xa7\xf7g\xfb\\\xfeq\x01\xa5\x05,\x0b&\x11\x0f\x16\xbf\x18\x86\x18\x88\x15\xd5\x10k\x0c.\t\x0e\x07\x04\x05M\x02*\xff\xe5\xfb\xfe\xf8_\xf6\xca\xf3\t\xf1&\xeem\xeb\xfd\xe8\x0f\xe7\xa2\xe6`\xe8\xbc\xeb\xed\xee\xcb\xf0\xe8\xf1J\xf3\xbd\xf5\xf4\xf8\xee\xfc\xf7\x00\x82\x04D\x07\xc1\t\x95\x0cZ\x0f\xed\x11\x8d\x14\x9a\x16&\x17\xff\x15\xa1\x14B\x14W\x14\xad\x13=\x12\xdd\x10\xed\x0e\xdf\x0b\xe5\x07\x91\x03\x9b\xff\xb6\xfb\xdb\xf7h\xf4%\xf1\xe9\xed6\xeb\x12\xe9_\xe7\xf2\xe5\xf1\xe4\xd8\xe4\x13\xe5l\xe4\xc2\xe2\xac\xe2]\xe5\xc0\xe8Q\xeb\t\xed\xe6\xef\\\xf3\xbb\xf6\\\xf9A\xfc\x1f\x00q\x03Y\x05&\x06\xdd\x07/\x0b\xb5\x0f\xf5\x12G\x14X\x14\xd9\x14\'\x17\xe4\x1b\xf0!\n(\xce,\xa6.\xc1-"+\x1d(\x11&\'%\xff$\x05%\xb3#\x7f \xcc\x1b\\\x16\x89\x11Y\r\xd8\x08\xc1\x03/\xfeo\xf9?\xf6\x04\xf4W\xf2N\xf1\x93\xf0\xdf\xee\xa6\xeb\xff\xe7X\xe5\x9a\xe4\x84\xe5\x1f\xe8\xa8\xeb\xd4\xee\x7f\xf0\x0f\xf1\xcf\xf1\xc5\xf3f\xf6\x15\xf9`\xfb\xe7\xfc\\\xfd\xe8\xfc\x14\xfcO\xfb\x13\xfb0\xfb\x95\xfbM\xfb\xd5\xf9y\xf7\x02\xf5\xce\xf2\n\xf1e\xf0\x9b\xf0V\xf1\xd0\xf1!\xf2N\xf2\xb5\xf2f\xf3\xac\xf4\\\xf6\x06\xf8\x8a\xf9\xda\xfa\x1d\xfcz\xfd\x15\xff,\x01}\x03^\x05]\x06\xf5\x06b\x07\x17\x08\x90\x08J\tq\n\xac\x0bV\x0c\x1d\x0c\xa6\x0b\x19\x0b2\n_\t\x08\t!\t\xd5\x08z\x07\t\x06\xbf\x04\xa1\x03i\x02Q\x01C\x00\x17\xff\xca\xfd\x91\xfcK\xfbO\xf9\xa2\xf65\xf4\xad\xf2q\xf1\'\xf0}\xefg\xf0\xaf\xf1\xc2\xf1Z\xf0\xc3\xee[\xedO\xec\xb8\xecD\xee\xb8\xf0\xfb\xf3\x8a\xf7>\xfa\xa0\xfbq\xfc\x01\xfe\xc5\x00\x18\x04\xb0\x06c\x08}\n\xd9\r\xfd\x11{\x16\xf9\x1a\xd9\x1e\xf5 \x11!\x0c \x8f\x1e\x1d\x1d\xa7\x1b\x93\x1a;\x1a^\x19\xeb\x16!\x13n\x0f\x0f\r4\x0b\x86\t\x1f\x08\x05\x07\x86\x05\n\x03/\x00\x83\xfd\x88\xfb8\xfa\x0c\xfa\x15\xfbu\xfc\xe6\xfc\xc5\xfb\xc1\xf9\xe9\xf7\xde\xf6\xd9\xf6\xde\xf7\xf1\xf9\xd2\xfc\xa5\xff3\x01\xd1\x00\xdb\xfe\xfd\xfb5\xf9T\xf7\x0c\xf7\t\xf8:\xf9\xa9\xf9\xcc\xf8\xc8\xf6\xf8\xf3\x03\xf1\x17\xef\xc3\xee\x1d\xf0\x0f\xf2\x10\xf4\xb1\xf5\x88\xf6R\xf6C\xf5V\xf4\x0b\xf4\xa4\xf44\xf6@\xf8/\xfa\x93\xfb\x1b\xfcO\xfc\xc3\xfc\x85\xfd\x08\xff\xf8\x00\xe3\x02\x14\x04$\x04\xfb\x026\x01\xe7\xff\xd8\xff\xf0\x00Q\x02\x07\x03\xb3\x02\x8e\x01\xe0\xffl\xfe\xc4\xfd-\xfe\x94\xffY\x01\x11\x03\xb0\x045\x05|\x04\xee\x021\x01@\x00U\x00\r\x01g\x02h\x03-\x03*\x02\x03\x01/\x00s\xff\xc8\xfe\x8f\xfe\xf5\xfe\x88\xff]\x00\xdf\x01!\x04\x06\x07\xe3\t\xa6\x0c\xef\x0ev\x10a\x117\x12\xfe\x120\x13\xd9\x12U\x12F\x12\xa8\x12\x13\x13\xa6\x12\xf0\x10$\x0e\xa4\n\xe2\x06\xf0\x02\xfe\xfem\xfbM\xf8\xb4\xf5\x8d\xf3\xd4\xf0\xc3\xed$\xebN\xe9U\xe8\x02\xe8\x17\xe8\xf8\xe8_\xea^\xec\x80\xee\xb4\xf0\x00\xf3\xda\xf5\x94\xf9\x7f\xfd\x0f\x01\x9d\x030\x05w\x06\x0f\x08\x1e\nv\x0c\xd8\x0ea\x11\xe7\x13\xd3\x15,\x16\x9c\x14,\x11\x9f\x0c\xe8\x07\xb9\x03|\x00>\xfe\xf3\xfc%\xfc\x08\xfb\x18\xf9\xf0\xf5\xe9\xf1\x01\xee\xf7\xeaF\xe91\xe9\x8a\xea\xcd\xecx\xef\x84\xf1\x02\xf3-\xf4\x18\xf57\xf6\x96\xf7c\xf9\xa5\xfb\xe1\xfd\xf4\xffh\x01K\x02\xcc\x02%\x03\x91\x03\xfb\x03\xfb\x03:\x03\xa5\x01a\xff\xfe\xfc\xc0\xfa\x13\xf9_\xf8q\xf8\xfa\xf81\xf9\xaf\xf8,\xf7)\xf5\x10\xf3}\xf1\'\xf1k\xf2\n\xf5Q\xf8v\xfb\xca\xfd\xf4\xfeX\xff-\x00\xcf\x02\x0c\x08\x9d\x0f\xb4\x18,"\xe4*\xab1t5\xe45f4\x802T1\x061\x041\xc80S/\x15,\xb3&\x97\x1fe\x17\xcc\x0e\xbd\x06\x83\xff\xf0\xf8\xbf\xf2\xee\xec\xf3\xe7\xe6\xe3\xe1\xe0\x7f\xdeP\xdc\x06\xda!\xd8\xcd\xd6T\xd6\xbc\xd6K\xd8\xaa\xdb\xc9\xe0\xe3\xe6\xe6\xec\xec\xf1\xa8\xf5g\xf8\x8c\xfaB\xfc\xfe\xfd\x12\x00\xd3\x02W\x06\xcd\t\x82\x0c\xbf\r\x80\r\xb6\x0b\xd3\x08G\x05\xa3\x01\xc9\xfe\x08\xfdA\xfc\xee\xfbA\xfb\x9d\xf9\xd8\xf6P\xf3\x83\xefl\xec\xe0\xea\x1d\xeb$\xed\xb5\xef\xa2\xf1"\xf2\xc9\xf1\xa8\xf1%\xf3\xb8\xf6\xc5\xfb\x01\x01"\x05\x98\x07q\x08\xb9\x08B\t2\x0bG\x0e\xc5\x11\xd9\x14$\x16s\x15\xf7\x12?\x0f\xbb\x0b:\t\x92\x07\x92\x06-\x05@\x03\x04\x01\xfd\xfd@\xfaA\xf6?\xf2\xfa\xee\x81\xec\x00\xeb\xfe\xea\xf4\xeb:\xed\xbf\xed\xfe\xecG\xebs\xe9@\xe9\xcb\xeb0\xf1.\xf7o\xfb\x1f\xfd&\xfc\xd1\xfan\xfai\xfc\xc9\x00\xe7\x05p\n\xbf\x0c\xc0\x0c\xac\x0bK\x0c1\x12Q\x1e7.\x91\xf5Q\xf0+\xec\x14\xe9\xd6\xe6c\xe6\x95\xe8\xd0\xec\t\xf2\x91\xf64\xfa\xa8\xfbj\xfaM\xf7\xb3\xf4\xc5\xf4o\xf8\xaa\xfd"\x02b\x02\x16\xfe\xb3\xf6P\xee\xf8\xe7\xc9\xe4w\xe5\x88\xe8%\xebc\xeb(\xe9\xf9\xe4\x9a\xe0\x10\xde\xda\xdf\xf9\xe5\xf7\xed\xdd\xf4\x05\xf9\xef\xfa\x82\xfbT\xfd\x18\x04\x07\x15\xb8/\x89M\xafc\xa2j\xbacrW\xf1P\x05U2a2o*xLu\xbdd\xddH%)\x84\x0e`\xfdQ\xf4\xc9\xef\x9d\xebp\xe4,\xd9\x19\xcaJ\xb9*\xaaB\xa0\x19\x9d\xb6\xa1\x19\xad\xd2\xbd\xb4\xd07\xdfX\xe5Z\xe4\xbe\xe1Q\xe6\xc8\xf4\xe7\x0b\x06&\xe9:5E\xcaB:7\r)\xe3\x1f@\x1ew"\xea%|"\x0f\x17\x1d\x06#\xf4\xe1\xe2\xe4\xd3\xd9\xc80\xc3a\xc1_\xc06\xbf\xb2\xbe\x7f\xbf\xdd\xc0r\xc1\xa6\xc1\xda\xc5\xbe\xd0\x91\xe2\xe0\xf5\xb6\x04-\x0e4\x12G\x14a\x18,\x1f\x88)@4\xda:\xac:\xa23\xc5*Q#\xa3\x1e}\x1bU\x17 \x12\xd7\n\x9d\x01\x8b\xf9\xe6\xf2+\xefr\xed\xa3\xeb\x14\xe9\xa9\xe6\xb1\xe6\xb6\xea\x8e\xf0K\xf5\x8c\xf8d\xfa\x8b\xfb\xc5\xfc\\\xfeg\x02<\x066\x08\xbe\x06i\x02k\xfd\xa5\xf9\x9e\xf7\xb7\xf5\xcf\xf2\x7f\xee\xac\xe8?\xe3\xb3\xdf\xe6\xdd\x88\xde\x92\xdf\xe0\xdf\x18\xe0\xf8\xdf\x08\xe0\xec\xe2\x08\xe7\xb8\xed\xe2\xf4J\xf9\x8d\xfdT\x01\xaa\x06\xa5\x0bu\x0f\xae\x14\xa1\x1b\x89%\xb04JJ\xa8`\x9cg\x15\\\x01I\x8a>}D1Q\r^pd_\\\x8eDP#@\x07V\xfa\x9c\xf8\x9b\xfa\xb5\xf8?\xef[\xe1\xbc\xd2*\xc6z\xbc\xbe\xb8o\xba\x94\xc0\x85\xc6\xc6\xcc\x85\xd6,\xe2\xbd\xe9\xe3\xe9\xee\xe8\x00\xeeM\xfc\x1a\x0e\x98\x1d\x81(L.7-\x85%\xb2\x1c|\x19>\x1d\xe5 \x97\x1f\xf5\x16W\n\xfd\xfb\x13\xee>\xe3\x7f\xdb\x81\xd5w\xcf\x9c\xc8\xc4\xc3\x08\xc2\xd5\xc2\xa3\xc3<\xc3\xaf\xc3\x16\xc5\xb7\xcaa\xd6\x01\xe6\x99\xf3\x12\xfb\xe9\xfe\xf3\x01\xed\x06\xe4\x0f\x01\x1cM(\xdc.\x1c/\n,\xe0(\xb8&#&\x00&\xb2#\xc1\x1e\xdc\x184\x13\x9e\r\x04\x07q\xff\xa5\xf9]\xf4\x15\xf2`\xf2\x04\xf4\x07\xf4&\xf1\x85\xec\xcf\xeau\xed\x87\xf1\xc3\xf65\xfa\xea\xfb2\xfc\x99\xfa\xd2\xf9L\xfa\xf0\xfa\x1d\xfd\xf1\xfd\x11\xfeP\xfc\xd5\xf8\xb3\xf5\xff\xf0O\xed\xd6\xea3\xeb\xd0\xec\xc1\xedg\xec8\xe9\xba\xe6\xeb\xe4\xe2\xe5\xbc\xe9 \xed\x8f\xf3\x95\xf7\xe6\xf8\\\xf9Q\xf4\x8c\xf3\xd2\xf7\xc3\xff\xf6\x0b.\x12\x9d\x11\x9e\x0c\x9f\x0e\xeb \xc9=\xaeP\x9bM:A\xf79\x89=IH\xfaS\xd6_5c\xd8RK:I(b#<&:!\xb2\x15c\x08e\xfd}\xf4\xa8\xeb\xc9\xe08\xd7\x17\xd1\x89\xccW\xccO\xcf"\xd5I\xd9_\xd4\xe6\xcb\xa4\xcaX\xd6>\xe9J\xf7S\xfd\xab\xfe8\xfe\x83\xfd\x89\x015\x0br\x17`\x1e(\x1d\r\x17)\x10$\x0b\x95\x08"\x07\xe2\x03\xb7\xfd\x16\xf5\xcd\xed\x16\xe7\x1c\xe1\x03\xd9\x94\xd1\x95\xcc\x86\xcb\xfc\xcc\x9a\xcf1\xd2\xfd\xd2\xc2\xd0\xac\xcf\x91\xd5\x89\xe0,\xef\xa7\xfa}\xff\x10\x00\x8c\x02\xad\n\xa0\x16s\x1fp& +\x9c)\x06&{%d)\\,G)\xec"\x99\x1d\xff\x18s\x15N\x11F\x0c\xaa\x05\x04\x00U\xfb\x0f\xf9\x90\xf7\x84\xf5;\xf3\xd5\xef\x00\xee-\xee\x08\xef\xb0\xefr\xefL\xf0\x98\xf1\xfe\xf2\x90\xf3\xba\xf33\xf5H\xf5\x00\xf5\x17\xf4)\xf4\xb0\xf5\x02\xf5V\xf2\x01\xef\xbc\xed\xa9\xed\x13\xee&\xedA\xea\x9a\xe7_\xe4\xb5\xe5|\xea\x9f\xed\x12\xf0\x00\xed\xbe\xeb\x0e\xecp\xed\xea\xf0\xfa\xf1t\xf3\x97\xf3\xd7\xf9\xc8\n\x13!\xcc0\xda+\x08!<"z4\xf4L\\]\xc3f\x04g6[kJ\x08D[MaV\xa5R\'Ce1i"4\x13\x1c\x07/\xffG\xf8\x0c\xf0D\xe4\x91\xd9X\xd1c\xca\x97\xc2\xd2\xbb\xb2\xba_\xc0\xff\xc9\xf8\xcf\x92\xd0j\xcf\xe1\xd1z\xda+\xe7S\xf4\x1e\x01?\n\xf1\rF\x0e\xf9\x0e\xf2\x13=\x1c\x05"\x06#\xd1\x1f\x9c\x191\x12)\t6\x03\xc5\xfe\xf3\xfa>\xf5\x0f\xed-\xe2\xaa\xd6\xe3\xcdD\xca\xf9\xca\xf0\xcc\x7f\xcf\x99\xce\xe7\xc9\xf6\xc6\x9d\xcaD\xd5\x10\xe1\xaf\xeb\xc8\xf5N\xfb*\xff\xa3\x02p\x0b\xdd\x15\x03\x1f\xa6\'X-\x840\x11/a-I-\xba,\xd3,(,$*^&Z\x1e\xf1\x15\xc8\r\xa7\x08\xb4\x04.\x01h\xfdU\xf8=\xf2Y\xebl\xe7I\xe6\x15\xe7\xd7\xe7\xdc\xe6\x00\xe7\x8a\xe6+\xe5K\xe5\xf0\xe5\xc6\xe9?\xed\x10\xee&\xee\xc4\xee\x85\xf0R\xf1\xa1\xf0\xbf\xf0$\xf2M\xf3Q\xf3/\xf3\x85\xf3b\xf2Y\xef\xca\xef3\xf1\x1a\xf3\xa5\xf2\xe6\xec\xc8\xeb\xaa\xec0\xf2l\xf6z\xf7]\xfb\xc2\x02\xfa\x0b\x84\x12\\\x1b,+\x84<_DR@^=\x87D\xd6P&[sa;c\x8c]!O)>]6\x076\xd85\xfc/\xac"\x8c\x13\x03\x03\xa7\xf2\x05\xe6\xfd\xdel\xdb\x0f\xd7\xca\xd0\xef\xc8\xfd\xc2\xd1\xbe\x03\xbb\xb5\xba\xd2\xbe\xd5\xc7\x98\xd3\xfe\xd9\xc9\xdc\x1c\xde\x0f\xe2W\xeb\x1f\xf7`\x05T\x11y\x18\xfa\x17\xa9\x14\xd1\x13\xd4\x16\xaf\x1dZ"\xc3"\x9d\x1d\xe3\x12\xb0\x07c\xfe\xeb\xf8\xd2\xf6\xc6\xf4u\xef\xc5\xe6\xa8\xddy\xd4\x19\xcd\xa8\xc8(\xc9(\xce\xae\xd1\xef\xd3\xb6\xd5z\xd6!\xd7\x06\xda\xf9\xe3\r\xf3=\x02~\t\t\n\xdd\x08\xea\x0c\'\x18\xd6#K,\xd4/\x8c.\xca*\x81\'\xc9\'a+\xc5+\x0b*d%\xb0\x1fO\x17\xc1\x0e\x0f\x08\xa4\x04\xb9\x02\xe4\xffV\xfbs\xf3\xbc\xea\xbe\xe2\xe1\xdf\xbd\xe0\x0e\xe4.\xe6\xea\xe4\x89\xe0\xd5\xdb\x0b\xdbn\xdf\xda\xe5s\xeb\x9b\xed\x12\xed\xb2\xeb\\\xeau\xed\r\xf1\x0b\xf7\x8a\xfbY\xfc\xbb\xfay\xf8\r\xf6M\xf6\x1c\xfa\x81\xfe=\x04\xfc\xff\xd4\xf7\x0e\xf2e\xf1\x7f\xf8u\xfd\xe5\xfe\x02\xfc\x0b\xfdC\t/\x1a\x81#\xc9\x1d\xa7\x18\xc8\x1f\x1d/\xd4A:M\xbcQVM[A\xe8<\xffB!M6P2H[9\xdc,\x86#x\x1c\xcc\x17q\x10\\\x07\xfc\xfa8\xed\xf1\xe2\xa2\xdb\xa2\xd6\\\xd1\xe5\xca\xa0\xc63\xc5/\xc6!\xc5\x95\xc3\xf3\xc4\xe5\xca\x93\xd4\x7f\xdc\x03\xe3\xa7\xe7@\xeb\xda\xef\xd2\xf7\xde\x02u\r\x04\x13M\x13%\x12\x15\x12\x10\x15_\x18b\x19p\x17K\x13U\r\x11\x07\xb1\x004\xfdE\xfa^\xf5\xdd\xee\xc2\xe8\xa7\xe3\xad\xdd\xb0\xd9j\xd8}\xd9\xe6\xd9\xd9\xd8\x1b\xd9&\xdb\xec\xdc\xfb\xe0L\xe6\x95\xeeA\xf6s\xfa#\xfeM\x02\x98\x08\xa1\x0f\xe3\x15K\x1b|\x1f\xd0!/#\x9e#\x92$\xb7%\xf7%\x1e%\xcb"\x80\x1f{\x1b\xcf\x16Q\x13A\x0f*\x0b&\x05\x95\xfe]\xfa\xdd\xf5;\xf24\xedY\xe9\xb2\xe6G\xe4\xe5\xe3$\xe3=\xe3N\xe1\xb9\xe1\x83\xe3\x07\xe6\xcc\xe8J\xe9o\xea\xb0\xeb\xd7\xee\xfd\xf2?\xf6F\xf7\x8c\xf8\xf3\xf9\xe3\xfa\xed\xfc\xa6\xffj\x018\x01\xa7\xfe\xde\xff\x11\x01c\x01\x8d\x01_\x01\xcc\x02l\xffT\xff\x05\x01\xee\x08[\x14\x92\x1b\xc2\x1d \x17\x8f\x14Y\x1bs*\xf98\x17=\xf8:\x813I/H0p67>!=\xee5\xf3)| \xd6\x1b\xd9\x19\xe5\x19\x97\x14\xd1\x0b\xa6\x00a\xf6@\xef\xd5\xe9n\xe8\'\xe7\xa3\xe4B\xdfQ\xd9.\xd5\xe4\xd3\xbc\xd5\xdf\xd9\x1a\xdf\xac\xe2U\xe3\x9c\xe2\x98\xe2\x13\xe6G\xec\x00\xf4\x1e\xfaK\xfc\x0e\xfc%\xfa\x15\xfa9\xfc\x16\x01\xaa\x05[\x06J\x02\x8d\xfd\xeb\xfa$\xfa\xd7\xfa1\xfb\xb5\xfa\x8d\xf6\x9a\xf1U\xf0\x7f\xf0\xd6\xef\xeb\xec\x88\xebt\xeeM\xf0!\xf1\x9f\xef@\xee$\xee\x1a\xf1c\xf7\xee\xfc\xbe\xfe\x0e\xfd\r\xfc\xd6\xfdQ\x02\xc9\x08^\x0e\x8f\x10\xa6\x0e\x1c\x0c\xe4\x0ct\x10\xbb\x14\xb1\x16\xed\x16\x04\x15\xa1\x11\xc3\x0e\xb4\r\x19\x0e\x05\x0e\xe9\x0b\xd0\x08`\x04\x1c\x00\xe3\xfc\xd6\xfa\x86\xf9\xa3\xf7\xdb\xf5\x85\xf3\xd6\xef(\xedE\xecl\xec\xe2\xed\'\xed\x12\xed\xf0\xeb\xf6\xea\x17\xeb|\xebN\xed$\xef\xae\xf0c\xf2\xe3\xf2b\xf2\xd2\xf1f\xf1b\xf6\xe9\xfb\x82\xff\x91\xfe\xaa\xf9\xc4\xf9\xe5\xfc\x0b\x04\xaf\x08\xff\x06\xd6\x02/\x01e\x07 \x10.\x15\xfd\x13\xc2\x117\x13y\x1b\xb0%\xcf*N)\xda%\x90\'N-[3\x8b675\x851\x0c-\x9c*\xa7*\x0e*\r([#\xbb\x1c\xaa\x15\x01\x10\xb2\r\xd9\n\xee\x05\x08\xffr\xf8+\xf4\xe5\xef\x0c\xed\xe2\xe9\x0b\xe7T\xe3\xdc\xdf\x15\xdf\x8a\xdf\xa2\xe0\x94\xdf\xd4\xde&\xdf\xf5\xe0\xbf\xe3\x81\xe6\xe6\xe8\xdd\xe9\xd0\xe9\x01\xea\xcd\xeb2\xefj\xf2\x02\xf4c\xf3\x0b\xf2\xe3\xf1\x0f\xf2V\xf3\xa1\xf4\xb0\xf4\x0e\xf4\xe5\xf2\xaa\xf2\xdf\xf2\x0f\xf2S\xf1\r\xf2\xab\xf3+\xf51\xf5P\xf5\x0e\xf6\xd2\xf6\xfd\xf8\t\xfb\x90\xfd\x9b\xff\xdb\x00i\x02*\x04\x8e\x05\x00\x08,\nl\x0c\'\x0e\xc0\x0e\x82\x0f\xf6\x0f\x8b\x10\xe5\x10.\x12\x86\x12m\x12Q\x11u\x0fU\x0eU\ra\x0c\x93\x0b\xe1\t\x82\x06\x88\x04h\x03X\x02\xf7\x00\xa0\xfe\xb8\xfb\xa9\xf9\x9f\xf8\xe5\xf8\x8f\xf7\xd4\xf5\xd6\xf5\xc6\xf5W\xf5\xf2\xf3B\xf0\t\xef\xc7\xf1u\xf5\xfc\xf6\x06\xf57\xf2\x00\xf18\xf1T\xf3\xf7\xf5R\xf6c\xf4\x08\xf4\x1c\xf8\x92\xfd\xb6\xfe\xe4\xf91\xf7\xb4\xf7\xba\xfa#\x00K\x04#\x06S\x05\xf6\x05\xd6\x06x\x07E\x07 \x06\xb7\n\x1b\x14\xbc\x17\xa0\x132\x0b~\t\x83\x11\xc7\x1a3 s\x1b\x08\x11\xc5\x0bx\x10\xbc\x1eO*\r(\xf7\x1b\xa0\x0f\x96\r,\x16\xf3\x1f\x16%\x8d"9\x1a\xb5\x11\x0f\x0e\x8a\x0f\xc8\x11\xdc\x12\x02\x11\x88\x0bK\x06d\x01y\xff_\xff\xcc\xfd\x0e\xfa\xc9\xf5\n\xf3 \xf1\xbf\xee\xef\xea\x92\xe8X\xe7\xcc\xe6\xdb\xe60\xe4\xb8\xe0\xab\xde\xc9\xde\xe1\xe0\x90\xe2c\xe3Z\xe3\x8b\xe2\x88\xe0\x95\xdf\xcf\xe2O\xe8-\xec\xff\xec\x96\xed\x12\xed\\\xee8\xefE\xf0\x05\xf6]\xfc\xa2\xff\xe8\xfe\x8e\xfc\x92\xfd\xe4\x00\x90\x013\x03\xb9\x07\xec\x0bJ\x0c*\nS\x07\xcc\x06\xfa\nm\x0f\xef\x11\xf2\x0e\xc6\n_\n\xa0\x0b\xa4\x0eH\x0f\x82\r\xf7\x07\xb2\x08\xf9\n\x1b\x0b\xeb\x06&\x04\x01\x05Q\x05\xc9\x03\xde\x02h\x01\xbb\x00@\x01_\xfe<\xfb\x89\xf5\xb0\xf9H\x00\xab\x01\x11\xf9\x16\xf2u\xf1 \xf3\x0e\xfb\xf5\xfe\xa5\xfbD\xf0N\xe78\xf0\x7f\xfa\x19\xfc|\xf9\xce\xf8\x9a\xf3^\xec&\xf1R\xfaU\xf5\x83\xf8\xc3\x01\xbd\xfe\xe1\xf6\n\xef\xc8\xf5\xa9\xfe\xdf\x08\xad\t8\x01B\xf7\xf7\xf9\x0e\x0bC\x0e\xd0\x05\x82\x01f\x03\x1c\x0f\xa8\x1e\x9f\x13s\xfc\x1a\xf7\xe6\x05\x9e\x1c\\#I\x1d1\x0eC\xfa\x06\x00y\r\x7f\x12\xc9\x15\x98\x1aW\x11y\x0b\xeb\n\xb5\x01R\x03{\t\r\x16y\x19\x18\x0f\x1c\x08>\x00\x08\x01\xe2\n\x1a\n\xa3\nr\rC\r\xa4\x07\x11\xfd\x0b\xf9\xc5\xff9\x01a\x05\xea\x06\x89\xfb\x95\xf8\xf5\xf6\x9d\xf4L\xf1\x13\xef\xc1\xf1N\xf5d\xfb\xb0\xf3\xb1\xea\xe8\xddJ\xe3\xd5\xf1|\xf5F\xf8\x9a\xef\xaf\xe7\xfa\xe33\xe8;\xf3\xf1\xf8&\xfe\xf7\x00\xad\xf2\xd1\xe9X\xed:\xfbE\t&\n\xc4\x06\x8f\xfel\xfd\xa3\x00\xc4\xfd?\n\x8b\x15\xcb\r\x8b\x06\xe2\x04=\x0f\x15\x11_\x01g\x02\xe0\x0e/\x14\x8d\x17\x10\x10B\xfe\xd7\xf7\x8e\x04\x1b\x15&\x10\x07\nW\x06m\xff<\xfc9\xff\xfd\x03\xe2\x02\xb1\xfcA\xfb\x0c\xf6u\xfa\xe9\x03\xd4\xfd\x12\xef8\xe5#\xec\x82\xf7\xa9\x02c\xf9\x8c\xf0Z\xe4U\xe1\x12\xfb\xeb\x06\x17\xfdH\xf2q\xe8\x8f\xe1\xef\xf2\xda\x05m\x08\xa2\x07\xa2\x06\x12\xef\x10\xdd@\xf3*\x0f\xce\x0f\x98\x0b\xdb\x02\xd6\xf6z\xf6\x85\xfe\xe5\x10F\x0ek\x04"\x02\x10\x05-\x04r\x03\xe6\r\x0f\x13}\x07@\xfa\xeb\xf93\t7\x16\xde\x15\xf1\x07\xf6\xfeU\xf7\x1f\xf4[\x13\xb3\x1c\xae\x15\xa7\x00G\xf1\xa8\xf8\xe1\x04\xe5\x183\x19L\t\xeb\xfa\xaf\xf4\x9f\x01`\x07%\n\x99\x0fO\t\xbb\x082\x042\xf4Z\xf3y\x082\x12Q\x0b\xf7\xfa\xd2\xf6\x87\xfb&\xfc\xe8\x05"\x07\xb3\xec\xae\xf2M\x03z\xf8\xf3\xfa\r\xf5\xc7\xf1m\xf8m\xf9\x9d\xf3\x7f\xedR\xeeq\xf0w\x02\x92\xfd\xec\xf0l\xeb,\xef[\x00\x8e\xf9\x1f\xf4~\xf8\xe0\xf1)\xfc$\x10n\x0b\x90\xf3v\xef\'\xfeS\t\xb6\x11\xc9\x06\x8e\xfc\xf9\x072\x11\xb0\x18H\x04\x07\xf3\x80\x00\x8e\x12\xc9#\x92\x14\xa3\xf3\x87\xfb\xd3\x14\x83\x0cp\x0c\xe7\t\xa3\xfc\xbd\x00\xb5\x05\xaf\x0e\xad\x03\x17\xfbJ\xf8\xb7\x08;\x0f\xd9\x005\xe8\xe0\xe1\xf7\x04\xa7\x12\xe4\xff\xfe\xfaW\xe8\'\xe5\xc9\x03\xbb\xf9/\xf6\x8b\xf5\xa2\xf2\xcb\xf3\xaf\xf7\x0b\xfez\xf9\xa1\xf5\x0f\xf6\x10\xf0|\xe2J\xff\x1b\x17h\x16\xbc\xf5\xc5\xd0\xa8\xdc\xde\x07\xf4$\x15\x12\x8b\t%\xf4?\xdb\xd1\xe6\xa6\x06\xd7/\xe4#\xa3\xff\x89\xdb\xf8\xd36\x11\xae;\n W\xf0\x18\xdb\x16\xf3k\x1dr*\x93\x18E\xfb\xff\xe1L\xeb\x7f\x1a\xd7&P\x12\t\x02\x87\xf2\x05\xfb)\x05\\\r\x9c\x1b3\x06\x0c\xf2K\xf9\xec\xfcd\x15\xe3\x12n\x01\x06\xfc\xc3\xf1c\xfb\x12\x03N\x07\x1b\x06\x1b\x04\xdf\x05\xf1\xf8\x8b\xf5\x99\xe1\xef\xe9\'%\xd8\'\xaf\xf6\xc7\xe0\x93\xe3\xcf\xef\xdc\x07\x9e\x04}\x01}\x04\x04\xebA\xf6_\xf8\xc3\xef3\xf0\x00\xfe\x8c\t\x10\x046\xf2}\xe1\xe4\xef\xb1\x08\xa0\x07\xf7\xfc \xfb\xe4\xee@\x06h\nc\x03\'\xf0\xd6\xf1p\x0f>\x11y\x10\xd9\x08_\x00c\xfd\xcb\x02\xe4\x07H\x07g\x08\x93!\xd7\x1d\xcd\x02L\xeb\x16\xedm\n\xfa\x1eb&\xe1\r\xc0\xfbO\xf5u\xf1k\xfa\xce\x08\xbf\x16\x1d\tx\x02\xbb\x03\xf5\x00\xc7\xf6\x0b\xe7\x7f\xec\xa2\xfaC\x08\x16\x19d\x0b\xc4\xf5\xe7\xe7\x93\xd1\xf6\xe9 \x0b\xbb\tG\x05\xfe\xf3\t\xfb\x10\xfd\x9b\xf5z\xe4x\xda\xd6\xfc4\x0f@\x1a\xb9\x07\xd7\xe4$\xe3/\xed\x0e\x08\xab\x061\xf6\x08\x07\xe4\x0cI\xf9\x1b\xf3\xca\xf9\x17\xfb\xf5\t\x0b\x0c\xb2\n\x05\x104\xfd4\xf8\xab\xfb\xf4\xfb\xac\x0c>\x19S\rv\x17\r\x0b\xae\xf2\xbb\xff<\xf8\x0b\x05\xe1\x16\xf7\x1d\xdb\x18\x9b\x03\x8f\xfc\xa5\xf29\xf4\xde\x0b\x96\x12\x02\x13\xdd\rr\x08\xfb\xf8\xf4\xe69\xfe\xd1\x05\x04\x00\xda\x0f#\t\xa6\xf4\xb5\xf2\xc3\xf8]\x04\x81\xee\xd5\xf2i\r\xb8\xf8\xff\xf9\xb8\x00J\nF\xdd\xcb\xcf\x10\x00X\x17v\x1b\x0b\xf0\x9c\xe5\x96\xe1Q\xea\x15\xfe\'\x0f\xc8\x16\xc1\x05o\xd5w\xd3\xb0\xfa\xb6\x0e\xc5\x1c\xe2\x11\x12\xf3\x1a\xd9\xa4\xee\x0b\x02\x14\x0c\x99\x1c5\x10\xef\xe8\xf8\xdb;\t\x0f$v\x16\xb6\xfb*\xf0\xfc\xf8\xe0\t\x19\x1c\xfb\x11~\xf6v\xfd2\x0b\xff\t\xf5\x0f\x03\xffW\xff\'\x0e\xdb\x10\xd4\x02\x00\xf6\x99\xfe\'\x08K\x0f-\x06\x89\x10\x81\x05\xa9\xe7\x86\xf9\xe4\xfee\xfe;\x17|\x11|\x03!\xee\xbb\xd7g\xf6\xd7\x154\x15*\x01?\xe8-\xec%\xfa\x97\x05\xfa\xf5\xaf\xf4\x95\xf9Z\xff\x9b\xf6$\xf7\xa5\x03}\xf0\x1e\xf8G\xf4\xe7\xefH\xfan\x0e?\nq\xf2f\xf4\x15\xe0K\xf1[$g\n8\xf7s\xffi\xef\x01\xf3\x94\nk\x12\x00\x00W\xfb\xae\xfcp\x03q\x11\xfc\x0c"\xfb\xa9\xf0}\xf04\x14\xa7)Q\x1c\x1d\xf4K\xebQ\xee\xcb\xefw"\x83@\xcf\x16^\xddF\xed\x07\xff>\x04\xb3\x16\x12\x1dV\x02\xb7\xea\xfb\x0b!\x10{\xfaH\t1\x06\xc0\xdc\x7f\xf5s \xc2!\xd8\xf9\xc8\xe7\x03\xf2\xc5\xe2\xcb\n@\x07\xea\xf8J\x10\x0e\x00\xb1\xf54\xd0\x02\xe4\x81\x0c9\x18R\x13\xc7\xebN\xdf\xf2\xe07\xf6\xb1\x079\x1b\xe8\xff\x98\xf1O\xf4\xe9\xe3a\xeeB\x0b\xa5\x130\x12\xb3\xfc\xf9\xda\xbf\xf3\xd2\x03\xf9\r\xe3\x0e=\x14\x1e\xf5t\xec~\xef\xd6\xff\xbd5T\x19\x90\xfb|\xdff\xd9\x17\r\x18*\xf07\x83\x14P\xd8\xbc\xc8\xcc\xef\xaf/\x1d"\x85\x12\xf1\x17\xd6\xe8l\xe8\r\xe8\xcd\xfd\xf80[\x19\xad\x081\xf4]\xdf\xb0\xf2g\x0c1!d\x0b\n\xed\xa6\xf1\xaf\xfb\xd7\xff\xbc\x13t\xf8d\xe2\x90\xfd\xd2\x12V\x04\x96\xe1\x01\xfaN\x17h\xf0\xb3\xe8\xdd\xf9U\xfe\xf9\xfdR\xf1!\x17P%\xcf\xdf\xca\xb7B\xee\xf4\x0f\xcd)\x01-`\xf1\xe9\xd3u\xd98\xf7\x89\t\x93\x10\n\x1b8\x14f\xe6\xcf\xe3d\xed\x06\x04p\'\x14\x13\x1c\xf7H\xdb_\xeb\xf2!\xd6+J\x1e_\xe9\xba\xc6H\xf2\x04\x11\xb8;\xb95\xdb\xffa\xc7\x9c\xd5\xdb\x13\xdb-\xb3#n\xfaa\xf6\xa8\xfc\x0f\x07\x84\xfaV\x00\xea\x15\xde\x02\x8f\xf4\xf8\xffY\xf8l\x0f\xbc\x0f\xb3\xf8\xba\xe6\x9e\xed*&\x14\xfc.\xe4\xcf\x0c\x14\x02\xc8\xf1\xdb\xf5\x84\xe8y\x13e\x0b\x8b\xe5\x89\x11\x05\xf9\xd9\xe9\xbe\xf5\xeb\xf2\xfa\xfd3\x19\xe7\n\xec\xf4x\xec\x00\xf4\xeb\x00a\xf2\xd4\xf3\xb0\x14\xc5\x1e\xff\x07\xb2\xe3V\xdb\xa9\xf1[\x13l*\x97\xf2`\xec@\xf8\x1d\x06\xf97\xe5\x01\x00\xc5\xea\xd4\xe4\t\xc0=\x08A\x1f\xf09\xcb\r\xf0\xbc\xff\xf2\x04\x18\x1f\xd2\r@\x08H\t\xad\xe9\xeb\xf3u\x0b\x1c\x0f;\x01\x98\x0e&\xfb\x80\xffh\xf5\xff\xff\x89!\xe3\xfd\x99\xf8\x04\xf2\x07\xe5B\x0b\x1c!\xf0\x1c\'\xf87\xbf\xd0\xe5\x06\r\xac#\x91\x1c\x10\xee\x07\xecA\xe7\x1f\xec\xa7\x12\xcd\x07\xac\xfa+\x06\xd0\xe2\xa2\xe9f#\xdd\n\xcd\xfaW\x05\xba\xcb\xb5\xdc\xb8 m# \x10\xf4\xfd\'\xe2\x08\xf4E\t\xb3\xf6\x9e\x05\x9d\x05\xc0\x02\x14\nI\x03!\x06\xa3\x00\x03\xe3\xa7\xf8)\x19Z\x16\xca\xf92\xf0\xfe\x02\x9f\tb\x06\xd2\xff.\xf8\xbe\xfb\x91\xfao\x0f\x17\x0eD\x03(\x07M\xea\xd3\x08p\xf0\xd1\xef+%\r\x01\xd4\x00\xbe\x19n\xfec\xe0\x87\xe2\xe3\xff\xa1\x1b\xb2!_\x065\xef \xf0[\xe6\xbf\xf1r\x1b#\x1b\xb8\xff\x80\xfdC\xe2\xa5\xe8\xed\x04H\x12A\x10\xa6\xf6\xb5\xf3t\xf5v\xf4\xec\x05[\x03e\t\xa8\x14\xa2\xd8\x1f\xdd\xbb\x1ct\x0fZ\x00*\x0f#\xea\x19\xe5w\xf8\xc2\x10\xa0!\xbd\x02#\xfa\x16\xed8\xe2\xdc\x03\x84$p\x06f\xff8\x04\'\xf5\x1c\xff\x82\xf1\xd5\xf7^\x14\xaf\x19\xd7\x19j\xe4\xe4\xd7\x18\xfb\x8d#\xd7\x13\x91\t\xc3\xee\x96\xdfI\x0c\xf3\x17z\x10\xa8\xf3y\xe7\xec\xf2\xa3\x1c\\\x02\x85\xfb\x9b\x17\x82\xf7H\xeb\x94\xfa\x90\xff\x00\xf6\xef\xfb\xbc"\xa6!\xe4\xe0f\xe0\x1b\xee\xc5\x03\xf5\x11.\nJ\x01\x10\xfe>\xfa\x85\xfb-\xfc\x00\xe2w\x00\xa8%\x8c\n\xe1\xe2\x84\xf4r\x0bR\n\xb1\x08\xc4\xeac\xd8\xe9\x02\xb2&)\'p\x07\xa6\xc7J\xd0\x83\x18\xc4&\r\x01:\xfe\x0c\xfay\x03\xbd\xfe"\xf6\xc8\x04\x9d\xf2\x85\x00\x0b\x1e\xfb\xf9h\xf4w\x1e3\t\xdf\xd4\x10\xe7\xf5\t\xda\x1ae,\x9c\x04Y\xe0\xce\xd7\xfa\xfdZ3(\x1a\x0b\xe1\xee\xe7\x94\x03A\x12\x0f\x10}\x00\xbe\x04\x90\xe9\xc2\xf4o\x0c|\xf9\xe4\xfb\x8c\x14\xfa\x06\x85\xfe\x01\xef(\xf2\n\xff\xe1\xfd\xdf\x15\x0c\xf9\xd1\xef\xd1\xee\xe1\n\x051\x90\xe3P\xd0\xea\x0c\x9f\x17\x9a\x05$\xf65\xef\xed\xf2E\x12\xe6\x1a\xac\xf3\x9b\xf0\xa4\xf9\x89\x0b\xdc\x08B\xdf\x81\x04%#"\xfb7\xf6@\xfd\xf7\xeel\x07\xd9\x04\xef\t\xc1\x1aL\xe8\xba\xdb2\x04\xb7\x18\x98\x16\x87\xff}\xe3%\xec\x18\t\x16\x1cz\xff4\xf4\xbe\x04\xcb\xf7!\xfaw\x0b\x1e\x02r\xff\x8a\xf7v\xff?\r\x7f\xf7\x01\xfc\xc2\r\xdf\t)\xfc"\xe6\xfb\xee\xc1\x193\x1a\x9c\xffA\xfc\xc9\xebJ\xde\x8d\x16\x8b4^\xfd\xc9\xde\xf9\xe2^\xfdU\x1e\xbe\x13-\x05\x0e\t\x9f\xdf\xc3\xc7\x0f\x0f\xe5/P\x17\xb6\x01\xee\xdb\xdb\xe8g\xfb\x1d\x02\xdb\x15:\x1c\x14\xf1!\xdd\xc5\xf6\xb9\x11\xff\x1e\xae\xefo\xe2\x1a\xf8\xbf\x12\x81\n9\xf6~\x0f\xa7\x05\xf1\xfa\x92\xdd\x95\xe4\x0c e&\xa4\x0e\t\xf0\xc8\xea\x91\xea\x8f\xf9\x90\x1aQ \x81\x04\xc9\xe6\xd9\xea\xf1\x01v\nD\x14d\x17\xf4\xee\xed\xdf\x18\xec<\r 2?\x0e\xcf\xf1\x00\xea\'\xdf\x04\xf5\xf0\x1d\\/\xee\x0b\xa6\xdf\x89\xd5L\xfe\x1c\x18k\x0b+\x0b-\xf2"\xfb\xcc\xfd \xef\xdb\x13\xe2\x03 \xf4&\xff\x98\x04\x19\xfas\xec\x98\x0e\x8c#\x01\xf0\x1b\xd4B\xff\x05\x14\xaa\nQ\x05\x7f\xf9\xa4\xfe\xb1\xec\xcf\xfa0\x13\xaa\xf4K\xfdd\x12\xb0\x12\xf2\xfa\x88\xd9\xfc\xe4\xf5\x0f\xc5*\x9a\x15\xc1\xfd\x0c\xe0g\xd9{\xfd\xa3\x1d~*\xee\xfdE\xea\xf2\xf0\x13\xec\xb1\n\x94\x1e\x92\t\x1c\xf9.\xfa\x1a\xdey\xf7\xb3"\xf4\x1c!\n3\xe6q\xdar\xf3M\x13\xf7\x1a\xf2%\xfa\x00<\xcc^\xe8\xf8\xfdU\x10\x19&\x91\r\xc4\xfd\xda\xe3\xac\xe1\xf2\xfa\x8b\t<"\xe4\x16\t\x00~\xd8\xa0\xe3\xfd\x06\x83\x17\xfd\x1b\xc0\xf1\xc2\xdb\xa2\xfb\xe5\x172\x14\xee\xfc\x7f\xf4X\xea(\xf2\x07\x07>\x04\x9e\x0b\xb3\x18\x1d\x00\xeb\xe4n\xf3#\xf3\x97\x02\xd9\x0b\xe2\r\xd8\x11Y\xf9\xbd\xf1\xe2\xf2\xf4\xf7\xfe\x08\x9a\x10\xf5\x02\xb6\x03i\xf7W\xf0\xab\xfc\x1c\to\x13\xd4\rD\xf5d\xe4+\xfb\xec\x07\x9f\x06\x82\r\x13\x08\x06\xf7\x03\xeb\xc6\xf5\x89\x19m\x1d\x7f\xfc\x99\xdd\x11\xeb\xa4\xfe\xa1\x1e\'\x1d\x8d\x07\xd9\xf6e\xde+\xef\xda\x04\xd6\x0b\xa5\x11\xc4\x12c\xf1\xab\xedN\xef\xcf\xf8\xd6\x167\x14(\xfaA\xe6\x88\xf4\xc5\x0e\xc0\x02\x10\xf9\x05\x13\xb3\xfd\xad\xe2Z\xef\x8d\t\xb8\x11\x94\x12X\n\xb3\xef\xb9\xd8e\xf2]\x1a\xfe\x13^\x04K\x01z\xf6\x8a\xed\xa5\x00\xa8\x06k\nD\x0b\x15\xf6`\xed\x98\x07J\x06+\rY\xff\x8e\xeb\x11\x0c\xd2\xfe\xf6\xf6"\x10\x9a\x06j\xefa\xfd\x8c\t\xe2\x05\xc0\x04v\xfc,\xf9^\xfaT\xfb\x8a\x108\n\x91\xf7v\x06\x87\xf9E\xf9\x98\x03b\xfb\x81\x02\xa0\x0b\x0c\x00\xea\xf2n\x01\xc3\x0f\x01\x04\xae\xe8\xc7\xea\xc8\x05>\x19\xe9\x0e\x18\x00\xa8\xea;\xe3\xdd\x04\xe2\x10\x1e\x0b\x9e\x02c\xef%\xf0\xd3\x08\xb4\n\xad\x03\x16\xf6\xb8\xfe\x14\xf4\xc5\xec\xad\x15\x10\x1a\xca\x08L\xee\xdf\xde\xc1\xf3i\n#\x17*\x1d\xb7\x00\x8e\xdb?\xee\xda\x04\x88\r$\x0f\xe7\x06`\xf3O\xf2s\xfd)\r\xb3\x19\xa3\xf9\xcf\xe24\xf2\x17\x05\xc6\x16\x17\x14(\x08\xbb\xf4\x82\xe2\xe9\xf2\xe6\x04\xc3\x13\x8f\x11\xcd\x08~\xf9\xd7\xea6\xf6\x98\x06\xc3\x0b\xc4\x0eD\xfd\xf1\xee\xc0\xfb[\x07Q\x06\xfb\x08\xa1\x06\x9c\xf2\xfb\xefu\xff\xd9\x05<\x08\x1f\x0b\xfd\x04=\xf9\xed\xe8i\xf5\xc7\x0c\xbf\x0c\x13\x05\x7f\xfb\x93\xf7<\xf6+\x00\x0c\x04\xed\xfe\x07\xfe\xfa\xf95\x00\x9c\x01\x04\xfe\x10\x06.\xfd\xeb\xf6\xa3\xf9\x94\xf6\x17\x03\xb4\x0f/\x0bn\xfc\x0b\xf1\xeb\xf1\x80\x05x\x11\xbe\x01v\xf8\x1c\xfe\x16\x02\xe5\x04\xb0\x04`\x05\xa3\xfd\x01\xf4D\xf7"\x0bF\t@\x08n\x07g\xf9\xb1\xf7\x7f\xf3+\xfc+\x08\x8b\x0e\xd5\x10\x98\xf8\x03\xf4\xcf\xfe\x1e\xf6\xf2\xff\x07\x13\xa9\x03\x1a\xf8\xc3\xf6\xac\xfe\x92\r\xb2\x02\xdf\x02&\x01G\xe86\xef\xe4\x0cE\x17B\x0b\xf5\xfc\xfc\xf0~\xf2\xe4\xfa)\x02\xbe\x0f\xb6\x02\xba\xf7\x8d\xfb*\x004\x07,\xfdx\xf2,\xfd\x8c\x04\xb8\x00\xc1\xfef\xfes\xff4\xff\xbe\xfcq\x00,\x01\xb8\x013\xfd.\xfb\r\x02\xc1\x02\xe6\x00\xbb\x00\x8f\x01"\xfe\xbb\xfd\x82\xfbG\x01\x91\x06P\x02\xec\xfc\x13\xfb\x0c\x05\x9a\x03}\x00\x10\x00\xc7\xffa\xfbB\x00\xdb\t>\x03A\xff\xf6\xffH\xff4\xfd\x11\xff\x91\x03\xc5\x06\xd7\x02\xce\xfd\xf0\xfcj\xfc\xf2\x01D\t#\x04w\xf9\x13\xf9\xe7\x03H\x04\xe9\xfb\x08\xfe\x18\x02\xbd\xff8\xfa\x97\x003\x01\xe9\xffe\x01\xf3\xfc\xb9\xfc\xd5\xf9p\xf7\'\x05o\x0e\xb4\x02\xd5\xf6\x13\xf5e\xfc`\x00R\x06\x05\t/\xff\xfb\xf7,\xf8\x82\xfe\xb9\nU\x05\xec\xfbx\xfcx\xfc2\xfc\xa2\x01\xf0\tT\x03M\xfd\x96\xf7\xbf\xfc\x9b\x04\xdf\x02\xfe\x03\xaa\x00\x06\xffr\x01\xf4\xfd\x93\xfb\x8f\x022\x04\xe9\x00\xcc\x01\x97\x02\xe9\xfc1\xfc\xce\x02\xd9\x03k\xff\xe4\xfb6\x01\xa7\x00~\x00H\x02e\xfe{\x02\xb9\xff\xc0\xf9m\x00\xf5\x02\xcf\x00\xf0\xff\xdb\xfe\x81\xfeA\x019\x01\x00\x00\x13\xfe\xc8\xfa\x8c\x00\xab\x02\xd7\xfd\xe9\x00*\x02\xad\xfe\x87\xfdU\xfc\xfe\xfd/\x01\x04\xff\xf0\xfd\xc2\x02\xaa\x00\x9e\xfda\xffy\xff\x15\xfe,\xfe6\x01\xbc\xff\xd1\xfd\x9c\x01Z\x03w\xff\xc3\x00e\xff\xf1\xfa\x08\x017\x02\xa9\xff+\x00,\xfe \xffk\x03\x00\x03\xcd\xff\xd6\xfa\xc7\xf8Q\x00\x03\x05\t\x04&\x00\xac\xfd\xaf\xfbP\xfdw\x03&\x02\xb5\xfe\xf1\xfb\x97\xfe\xd1\x03\x99\x05\x89\x01\x81\xfb\xdd\xfc\x95\xff\xb6\x00T\x03$\x02\xb6\x02[\x04\x0e\xfd\xc3\xf9\xab\xfdz\x02\xd1\x06\x82\x04F\xfd\xad\xf8\xcf\xfb\xed\x01\xdc\x06\xf1\x04\x1a\xfd&\xf7_\xf7\xe7\xff\xcc\x07\xaf\x06\x8c\xff-\xf8\x87\xf6`\xfc\xb0\x05r\x06Q\x00\x8c\xf8;\xf4\xfa\xfas\x02\xa3\x05 \x003\xf9\x1c\xf7\xb5\xf8\xd9\xff\x7f\x07\xd9\x05\xfe\xfd\xdb\xfbQ\x01B\x08\x16\x0e\xd3\r\xa6\t_\x07r\x08\xd3\x0e\xeb\x15\x01\x15\xcb\x108\x0c\xf4\n\xc1\x0e\xaf\x11r\x0f?\x07\x0b\x03$\x04\xbf\x04^\x04\xa5\xffV\xf9\xc0\xf5l\xf3\xc6\xf3\xb9\xf5\xf3\xf3\xa4\xf0\xa2\xeeV\xee\xa9\xee\xc0\xef\xaf\xf1\xaf\xf3s\xf3\xc5\xf3\x8d\xf6^\xf9h\xfd\xc2\xfe\xb6\xfd+\xfe\xf0\xfe\x8a\x02\xdb\x08\x87\n\x96\x05\xf2\x00\x87\x01\xc3\x06\xde\x08#\x07d\x04\x9a\xfd\xd5\xf9\x8c\xfd\x7f\x05v\x06\xc7\xfb~\xf1\x87\xef\xcc\xf6i\xfe\x08\xff\x06\xf9\xd6\xf0`\xf0\xec\xf4\xbd\xf9\x83\xfe\xf8\xfa\x84\xf6\x93\xf6\xa3\xfcb\x00\xc5\xfd\xab\xfd\xd0\xfdy\xfe\xc2\xff\xc8\xff\x92\x00\xfb\x02\xa6\x02z\x04\xa1\xffy\xfa\xc4\xfb=\xff\xe7\x05\x02\x06\xe0\x00\xda\xfc*\xfc]\xfcp\xff\xf4\xff\xf5\x00_\x01\x00\x00/\xff\xca\xff\xd8\x00\xc3\x00\xde\xffd\xfd\xc7\xfc(\x00\n\x046\x04K\x03U\x01u\xff\xba\x01\xf9\x02\x87\x02f\x05\x8a\x0b\xd7\x11\xc1\x14\xae\x11\xa0\r\xf5\x0e_\x14Q\x1a\x11\x1e\xf0\x1dP\x1c\x1c\x1c\x86\x1c\xca\x1b\xaf\x17\x95\x12M\x10@\x0f\x1f\rA\n\xe5\x06\xbc\x02\x80\xfe7\xf9b\xf3/\xefm\xee\x82\xeeX\xed\xe0\xeb\xd9\xea\x97\xea\xe1\xea/\xec\x07\xed\x8a\xebY\xea\t\xed\xab\xf3\xf0\xf8j\xfaK\xf8\x1e\xf6a\xf7\x1a\xfa\x06\xfdh\xfeM\xfd\x0f\xfb\x1e\xfbl\xfd\x91\xfe\x07\xfd\xfd\xf9"\xf8>\xf7\x90\xf7\xb9\xf8$\xf9\xab\xf8\n\xf7w\xf6\xbd\xf7\xd9\xf8\x01\xf9\xac\xf8\xfb\xf81\xfa\xa6\xfb\x90\xfd\xa2\xff\xb6\xff\xd7\xfe\xc7\xff\xe6\x00\xf0\x01\x17\x03\xe9\x03\x81\x04S\x045\x04\'\x05\xf2\x05\xcf\x05\xef\x04T\x04>\x05-\x06X\x06r\x06\x1c\x06L\x05\x80\x058\x06\xc1\x06_\x07\xb8\x06<\x06l\x07\x0e\x08\xd8\x07\x91\x07\xcf\x06B\x06%\x06\x1c\x06\x11\x06\x15\x06h\x04.\x03\xf3\x02\xbd\x014\x01\x7f\x00\x0b\xff\xed\xfd\x1c\xfe\xd8\xfd\xb9\xfd\xc0\xfd\xbf\xfd\x8d\xfc[\xfc\x9f\xfcF\xfd\x03\xfe\xe6\xfd\x1f\xff\x06\x00\xdf\xffV\xff\xa2\xff\xe6\xff8\xff#\xff\xd4\xff\xcf\xff\\\xff\xbb\xfeO\xfem\xfd\xbd\xfc\x7f\xfc\xb5\xfb\x8e\xfb:\xfb\xe1\xfa\r\xfb\x88\xfa-\xfa\xb1\xf9\xfc\xf8]\xf9\x89\xf9\xc6\xf9\x97\xf9\xa3\xf9>\xfaE\xfa_\xfa\x04\xfas\xfa\x05\xfb\x90\xfb!\xfc/\xfc\xd3\xfc*\xfd\xd3\xfcA\xfd4\xfe\x82\xfe]\xfe\xb2\xfe\x0b\xff9\xff\x9b\xff\xa8\xff,\x00E\x00G\x00\xda\xff\xc0\xff\xe1\xff\x0c\x00\xcb\xff\xb8\xff\xa4\xff\xf6\xfeh\xff\xcf\xff\x92\x00\xf5\xff\xb0\xff:\x01\'\x04\x8b\x07<\n\x85\x0b}\x0c4\x0f\xc0\x12\xf9\x16<\x1bj\x1f\x03 \xaf\x1e\xdd\x1e\xce!4$\xbb"\x15 \xa9\x1dk\x1bt\x18\xe4\x13\x88\x10\xd9\r\x82\t\x07\x04\\\xfe&\xfb\xc3\xf9Q\xf6\xe6\xf1\x87\xee\xb9\xec\xd3\xea\x80\xe9Q\xe9\xc3\xe9\x1d\xea\xe6\xe8\xc1\xe7\x97\xe8L\xea\x85\xeb \xec\xb3\xec\xbd\xee6\xf0F\xf0\xeb\xf0\xec\xf1`\xf3<\xf4\xd1\xf4;\xf6\xc4\xf7\xbb\xf7|\xf7^\xf9\x07\xfby\xfb&\xfb\x84\xfb\x84\xfc\x9b\xfci\xfc\xfe\xfd\xf4\xfeB\xfe\x89\xfdM\xfd\xdb\xfdF\xfe\xc2\xfd\xb5\xfd\x88\xfe\x80\xfe\xf7\xfd\xff\xfd\xe0\xfep\xffK\xff\x14\xff\x19\x00\x05\x01m\x012\x02+\x03\xd1\x03\x07\x04h\x04]\x05\xb6\x06\xbc\x07a\x08\xd8\x08W\t\xee\t\xc1\nb\x0b\xaf\x0b\xdc\x0b\x98\x0b\xb4\x0b\xd4\x0bW\x0b\xde\n\n\n\xb5\x08\xa4\x07\xc5\x06\x8b\x058\x04n\x02N\x00\x10\xffI\xfe+\xfd\x0b\xfcV\xfa\xa9\xf8)\xf8\x9d\xf7Z\xf7\xb5\xf7\xb5\xf7i\xf7\x9a\xf7>\xf8\xf3\xf8\xdd\xf9\xa4\xfa[\xfb!\xfd\x97\xfe\xd3\xff\x1c\x01j\x028\x03\xa9\x03\xab\x04\xdc\x05\xc3\x06\xb6\x06&\x06$\x06M\x06\x8b\x05.\x04q\x03\xc7\x02\x1e\x01I\xff\xfe\xfe\xdc\xfe\x97\xfd\x9b\xfb\x98\xfa0\xfal\xf9\xb6\xf8\xd2\xf8k\xf9u\xf9\xb2\xf8H\xf8&\xf9\x81\xf9y\xf9\xaf\xf9 \xfa\xd7\xfay\xfbw\xfb\xbc\xfb\xa1\xfc\x84\xfc1\xfcl\xfc\x9c\xfd4\xfe\xe6\xfd\xe7\xfd\x07\xfe\x16\xfe\xe8\xfd\x87\xfdT\xfd\xa4\xfd\x7f\xfd\x85\xfd\x03\xfd\xab\xfc\x85\xfc\xef\xfb\x07\xfc\x81\xfc\xcb\xfc\xe3\xfc$\xfd\r\xfd\x85\xfdN\xfd\xf5\xfdA\xff\x17\x00\x08\x01\xff\x01\x8c\x02\x99\x03/\x05\xe4\x05\xbc\x06\x04\t\xde\x0c\x81\x10z\x12.\x14\x83\x17L\x1aY\x1b_\x1cu\x1f\xb0#\x1b%W#\t"\x87"A!\x18\x1d\xde\x19\x90\x19\xff\x16\x99\x0f\x9b\x08\x90\x06Z\x05i\x00\x8f\xf9\x80\xf5\x88\xf3\xaf\xef|\xea\x98\xe8\x0e\xeac\xea \xe7v\xe4F\xe5&\xe7\x1f\xe7c\xe6\xcf\xe7\'\xea\xd6\xea\r\xeb\xac\xecD\xefH\xf0\xc3\xef\xf4\xf0\x8e\xf3\x02\xf5\x87\xf5k\xf6\xdf\xf7\xca\xf8O\xf9\xc8\xfaj\xfc\xa2\xfc\xf9\xfb\xa3\xfc\xef\xfd\x7f\xfeP\xfe\xcc\xfeF\xffL\xfe\xf8\xfd\'\xff\xab\xff\xcc\xfe\xcd\xfd$\xfe6\xff\x82\xfe\x8f\xfe\xff\xffS\x00g\xffE\xffn\x00\xe9\x01\xdd\x01\xfb\x01d\x03\x16\x04z\x044\x05\x81\x06\xcb\x07\x04\x08\xf2\x07\x00\t)\n\x8b\n\xb0\n\xfb\n[\x0b?\x0b\xde\n\xd2\n\xe3\nG\nH\t\x8b\x08\x0e\x08\xa1\x07z\x06[\x05d\x04H\x03\xbd\x01\x91\x00\xf3\xff\xf5\xfeY\xfd\x18\xfc\x82\xfb\xb4\xfal\xf9\xaf\xf8[\xf8\x99\xf7p\xf7e\xf7\xcc\xf6\xa7\xf65\xf7\xab\xf7K\xf8j\xf9\xef\xf9\x82\xfa\x88\xfb\x83\xfc\xe6\xfdI\xffm\x00\x89\x01\x95\x02=\x03\x9d\x03M\x04\x1a\x05\x12\x05\xeb\x04\xb4\x04\xa0\x04a\x04s\x03\x8d\x02\xf1\x01c\x01X\x006\xff\x06\xff\x8c\xfe\x8e\xfd\xd2\xfc\xc9\xfc\xb5\xfcJ\xfc\xf1\xfb\xf7\xfb"\xfcZ\xfc\x88\xfc\xfc\xfcr\xfdu\xfd9\xfd\x8f\xfd6\xfe\x82\xfe\xdb\xfe.\xff,\xff\x0f\xff\x0f\xff1\xff\x9b\xff\x08\x00w\xff"\xffW\xff?\xff\xd3\xfe\xaa\xfe+\xfe\xca\xfdD\xfd\x8c\xfc0\xfc\x02\xfcx\xfb\xc7\xfae\xfa{\xfaK\xfaM\xf96\xf9\xdf\xf9o\xfab\xfaj\xfa\xcb\xfa\xef\xfa\x0b\xfbh\xfb\xf2\xfb\x8c\xfc/\xfd\x95\xfdI\xfe\x1a\xff\x1c\x00\xb1\x00\x8c\x01\x99\x03@\x06\xee\x07\x02\nf\x0e6\x13\x9c\x15Z\x16i\x18Y\x1dL!\xf4!V"\xb2$Y&\x16$\xef \xb4 \xa5 \xff\x1b6\x15\x13\x12\x04\x11\xb6\x0c\x14\x05_\xffj\xfd3\xfa\xb3\xf3\xdf\xee?\xeeh\xed\xfd\xe8\xed\xe4)\xe5j\xe7a\xe6\xab\xe3~\xe4\xe6\xe7\xf6\xe8\x16\xe8A\xe9\xfc\xec2\xef\xd2\xee\xc0\xef\xb6\xf2\xbd\xf4\xd3\xf4C\xf5M\xf7\xbe\xf8\xe6\xf8e\xf9\xb7\xfah\xfbK\xfbw\xfb \xfc\xb3\xfc\x0e\xfdo\xfdy\xfd\x1a\xfd\x1e\xfd\xde\xfd4\xfe\x98\xfdS\xfd\xe1\xfd\xf6\xfd\x17\xfdB\xfd\xab\xfe1\xffN\xfeJ\xfe\xf2\xff\x0b\x01\xc8\x00J\x01\x18\x03?\x048\x04\xf6\x04\xff\x06{\x08u\x08\xdc\x08\x83\n\xb0\x0b\xd4\x0b\xff\x0b\x07\r\xbe\r^\r#\r\xa0\r\xd4\r\x0e\r\x01\x0c\x83\x0b[\x0bh\n\xe3\x08\xc0\x07\xcb\x06;\x05D\x03\xae\x01w\x00\xfa\xfe\xf9\xfcU\xfb8\xfa\x08\xf9\x89\xf7T\xf6\xcd\xf5[\xf5\x80\xf4%\xf4\x81\xf4\xc5\xf4\xcf\xf46\xf5\t\xf6\xe1\xf6\xd0\xf7\xa1\xf8~\xf9\x9e\xfa\xc2\xfb\xf1\xfc>\xfe\xd4\xff\xc4\x00\x84\x01\xba\x02\xa0\x03\xa7\x04\x8a\x05e\x06H\x07\x97\x07\x85\x07\x8a\x07\x03\x08\xe4\x07<\x07\xbf\x062\x06\xbf\x05\xd1\x04\xc2\x03\xd2\x02\xfc\x01\x1f\x01+\x00p\xff\t\xff4\xfej\xfd\x08\xfd\x00\xfd\xd3\xfcn\xfc<\xfcV\xfcx\xfcz\xfc\xb7\xfc-\xfdv\xfdw\xfdm\xfd\xd0\xfd_\xfer\xfe\x81\xfe\xc7\xfe\xe7\xfe\xe7\xfe\xa4\xfe\x89\xfe\xa2\xfet\xfe\xa5\xfd\xf0\xfc\xb9\xfco\xfc\xa8\xfb\xdb\xfa4\xfa\x9e\xf9\xe1\xf8(\xf8\xe5\xf7\xc3\xf7o\xf7.\xf7K\xf7\xb1\xf7\xe7\xf7\x16\xf8\x9d\xf8\x81\xf9s\xfa\x1d\xfb\xeb\xfb\xd0\xfco\xfd=\xfe,\xff\xe2\xff\xb9\x00{\x01\xeb\x01Y\x02\x94\x02\xf6\x02\xab\x03\x1e\x04x\x04"\x05\xf9\x05,\x07\x99\x08\x1c\n\xf2\x0b{\r\x0b\x0f\xda\x11S\x15\x99\x17\x81\x18\x15\x1a\xaf\x1c\x0f\x1e\xbf\x1d6\x1e\xc3\x1f\x15\x1fp\x1b\xa1\x18\xfa\x17#\x16\x04\x11\x02\x0c\x8d\t\x86\x06\x80\x00\xa0\xfaV\xf8\xb5\xf6\xec\xf1X\xec\x10\xea\x02\xea\xf7\xe7\xe9\xe4d\xe4\x1f\xe6G\xe6\xbd\xe4\x04\xe5\xbb\xe7\xdd\xe9\x02\xea\x82\xea\xfc\xec\xa4\xef\xdf\xf0k\xf1\x19\xf3u\xf5\xd6\xf6B\xf7O\xf8|\xfaO\xfcX\xfc6\xfc\xca\xfd\xf8\xff\x84\x00\xe0\xffy\x00\x08\x02)\x02\x0b\x01M\x01\t\x035\x03s\x01\xa8\x00\xe5\x01\x87\x029\x01:\x00\xfe\x00\x92\x01\x83\x00\xbf\xff\xcc\x00\xfd\x01g\x01{\x00.\x01\x89\x02\xc3\x02F\x02\x02\x03\x8a\x04\xf3\x04\xb9\x04n\x05\xd3\x06o\x07\x14\x07T\x07h\x08\xe3\x08\x8b\x08p\x08\xef\x08\x17\tl\x08\xf3\x07\xe2\x07m\x075\x06\x05\x05r\x04\xd9\x03\xa1\x02S\x01J\x001\xff\xc7\xfd\x85\xfc\xbb\xfb\r\xfb\x15\xfa\x1a\xf9\xa3\xf8T\xf8\xf1\xf7\xbe\xf7\xf0\xf7.\xf81\xf8\x86\xf8?\xf9\xfe\xf9\xac\xfar\xfbT\xfc!\xfd\xd8\xfd\xa8\xfe|\xff1\x00\xac\x00H\x01\xde\x016\x02^\x02\x91\x02\x07\x03[\x03g\x03\x87\x03\x9d\x03\xaa\x03n\x03n\x03@\x04N\x05\\\x05\xd7\x040\x05+\x06f\x06\xef\x05T\x06U\x07-\x07\xcf\x05I\x05\x06\x06\xad\x05\xb4\x03\\\x02p\x02\x06\x02"\x00\xa9\xfe\xa0\xfe0\xfex\xfc.\xfb[\xfb\x83\xfb\x91\xfa~\xf9\x95\xf9\xdf\xf9\x84\xf9\x1e\xf9W\xf9\x8f\xf9)\xf9\xc8\xf8\xfe\xf8\x81\xf9\xb1\xf9\x91\xf9\xa9\xf9\xe3\xf9:\xfa\xa4\xfa\x08\xfbl\xfb\xe0\xfbT\xfc\xb9\xfc/\xfd\xe8\xfd\x85\xfe\xc2\xfe\xf2\xfeN\xff\xc8\xff\x05\x00#\x00T\x00|\x00\x89\x00\xa1\x00\xd1\x00\xec\x00\xf4\x00\xd9\x00\xcf\x00\xff\x00#\x01%\x01&\x01\xf1\x00\xc7\x00\xbf\x00\x8f\x00i\x00N\x00(\x00\xf8\xff\xe1\xff!\x00Y\x00E\x000\x00\xc3\x00\xe7\x01*\x03\x99\x04c\x06Y\x08\x0c\nM\x0b\xea\x0cZ\x0f\xcd\x11>\x13$\x14C\x154\x16\x13\x16S\x15\xeb\x14h\x14\x81\x12\xa4\x0fF\rR\x0bl\x08\x93\x04&\x01r\xfeh\xfb\xe4\xf7\x17\xf53\xf3\'\xf1\xdb\xeeD\xed\xe0\xec\x9a\xec\x00\xec\xeb\xeb\xa3\xec~\xed-\xeeR\xef\xff\xf0p\xf2\x87\xf3\xb7\xf4R\xf6\xfd\xf7<\xf9^\xfa\x92\xfb|\xfc_\xfdY\xfe~\xffK\x00\x95\x00\xe8\x00[\x01\xb8\x01\xc9\x01\xe8\x014\x02\x1e\x02\xa9\x01L\x01/\x01\xde\x00\x08\x00G\xff\xf1\xfe\x9b\xfe\x0f\xfer\xfd\x10\xfd\xa7\xfc\x00\xfc\xb4\xfb\xe3\xfb\x10\xfc\xf5\xfb\xd4\xfb\x1f\xfc\x81\xfc\xb2\xfc\x08\xfd\xaa\xfdX\xfe\xdb\xfeT\xff\'\x00\x13\x01\xbb\x01\\\x02&\x03\x0b\x04\xec\x04\xb3\x05w\x061\x07\xb9\x07,\x08\x90\x08\xe5\x08\r\t\x03\t\xe4\x08\x9e\x08&\x08y\x07\xa3\x06\xa8\x05\xa3\x04\x9f\x03\x9b\x02}\x01T\x00+\xff\x12\xfe\x13\xfd;\xfc\x83\xfb\xe8\xfaa\xfa\x15\xfa\x04\xfa\x10\xfa:\xfa\xa0\xfa0\xfb\xce\xfbh\xfc\x01\xfd\xcb\xfd\x97\xfec\xffW\x007\x01\xea\x01^\x02\xa7\x02\t\x03t\x03\xb8\x03\xd8\x03\xdb\x03\xc9\x03\x8e\x03/\x03\xc3\x02H\x02\xa2\x01\xf8\x00f\x00\xee\xffz\xff\xe1\xfe(\xfe\x7f\xfd\xf7\xfc\xa3\xfcg\xfcC\xfcN\xfcL\xfcH\xfc\\\xfc\x9f\xfc\x13\xfd|\xfd\xdd\xfdv\xfe3\xff\xc7\xffH\x00\xe6\x00\x97\x01#\x02\x96\x02(\x03\xc0\x03*\x04a\x04\x90\x04\xd5\x04\xf8\x04\xd3\x04\xa9\x04\xa0\x04\x87\x047\x04\xf0\x03\xa3\x03/\x03\x9b\x02\x1c\x02\xbc\x01[\x01\r\x01\xa2\x00\x0f\x00\xa5\xffb\xff\x1a\xff\xb6\xfeg\xfeT\xfeh\xfeN\xfe\x10\xfe\xd2\xfd\xb3\xfd\xc1\xfd\xc9\xfd\xc6\xfd\xb6\xfd\xb5\xfd\xb1\xfd\xc5\xfd\xe2\xfd\xf2\xfd\xd6\xfd\xa9\xfd\xde\xfdU\xfe\xca\xfe\xf0\xfe\x1f\xff\x9d\xff\'\x00\x87\x00J\x01\xe6\x01\xbe\x00C\xff\x97\x01<\x07\x14\t\xbc\x03~\xfei\xffn\x03\xf0\x04\x80\x04\xed\x03\xe0\x00H\xfbJ\xf9V\xfd)\x00#\xfc\x88\xf6#\xf6\xdc\xf8 \xf9\x9e\xf6\xed\xf4\xc5\xf4\xb8\xf5\xb3\xf7\x0b\xfa\xc8\xfa\xdf\xf8*\xf7\xae\xf8\x08\xfd\x01\x01\x9c\x01\xd3\xff\xe3\xfeW\x006\x03\x01\x05X\x05\x0e\x05q\x04\x16\x04B\x04\xb0\x04\x14\x04\xe8\x01!\x00\x14\x00\xf6\x00\x7f\x00t\xfe\x0b\xfc\x9a\xfa\xc7\xf9\x10\xf9i\xf9V\xfca\x00\x0e\x01\xa1\xfd<\xfb\xe4\xfc\xd1\x01:\x08\t\x0f$\x13c\x0f^\x08\r\x08=\x10\xe0\x17M\x18\xda\x15\x90\x14\x97\x11A\x0c\x08\nk\x0cr\r\xe6\t\xed\x06\xaf\x05\x90\x01-\xf9\xed\xf3\x92\xf6\xfa\xfad\xfb\xe8\xf7X\xf3r\xee\xfd\xea\xf3\xech\xf3\xec\xf7\\\xf7j\xf4\x84\xf2j\xf2\x1e\xf4\xa4\xf8\x0c\xfe\xe0\x00\x88\x00e\xff+\xff\xc8\xff\xba\x01,\x05l\x08$\t\xce\x07\xc7\x05\x81\x03\x0e\x02\xd5\x02\xa9\x05\x05\x07\xda\x04O\x00\x10\xfc\xb9\xf9\xc7\xf9\xfe\xfbG\xfdc\xfb\x89\xf7\xb9\xf4/\xf4\x8d\xf4u\xf5\xee\xf6\x03\xf8\xdb\xf7\xe2\xf6\xa8\xf6\x85\xf7,\xf9\x84\xfb"\xfe\xcb\xff\xde\xff\x8d\xff9\x00^\x02\xb5\x04^\x06P\x07\xa4\x07y\x07\x18\x072\x07\xc2\x07\x8b\x08\xcf\x08j\x08Z\x07\xaf\x051\x04v\x03\x93\x03\xb6\x03\x0f\x03\xa8\x01\x17\x00\xe0\xfe?\xfe\x1a\xfes\xfe\xcd\xfe\xa0\xfe\xf1\xfd\\\xfdi\xfd\xc0\xfdC\xfe\x0c\xff\x06\x00\x93\x00z\x00<\x00d\x00\xdf\x00\x96\x01X\x02\xf0\x02\x02\x03]\x02l\x01\x02\x01M\x01\x02\x02O\x02\xdc\x01\xd7\x00\x94\xff\x08\xffn\xff\x0e\x00h\xff<\xfe\xab\xfe\x15\x00y\xff\xbf\xfcG\xfbB\xfd\x1a\x00\xca\x00\x1f\xff\xa9\xfc<\xfb1\xfc\t\xff\xa4\x00n\xffn\xfd\xf8\xfc\x84\xfd\xb6\xfd \xfe \xff\x92\xff-\xff\xd8\xfe\xd7\xfe\xb2\xfe\xb2\xfe\xd7\xffN\x01\xa4\x01\x16\x01\xb0\x00\xc9\x00\n\x01\x9c\x01\xac\x02\x92\x03\x89\x03\xfb\x02\x8d\x02{\x02\xf2\x02\xd7\x03\x8e\x04F\x04l\x03\xc0\x02\x9c\x02\xac\x02\xf3\x02&\x03\xb5\x02\xae\x01\xce\x00\x93\x00\x8e\x00,\x00\xbe\xffe\xff\xf9\xfei\xfe\x00\xfe\xcb\xfdz\xfdI\xfd~\xfd\xc2\xfdX\xfd\xaf\xfc\xe9\xfc\xb1\xfdv\xfes\xfe\x1a\xfe\x05\xfek\xfeb\xff*\x00;\x00\xe8\xff\x05\x00d\x00\xa5\x00\xb8\x00\x15\x01Q\x01\x01\x01\xc1\x00\xa7\x00\x86\x00*\x00\xf4\xff+\x00;\x00\xc5\xff\xfe\xfei\xfe3\xfe7\xfeZ\xfeh\xfe\x13\xfe}\xfdH\xfdq\xfd\xc8\xfd\n\xfe]\xfe\xc3\xfe\xfb\xfe\x03\xff,\xff\x90\xff#\x00\xb4\x00\x11\x01/\x01\x0e\x01%\x01s\x01\xc7\x01\x08\x02\x16\x02\x02\x02\xaa\x01U\x01K\x01T\x01Q\x01-\x01\xec\x00\x85\x00\xf8\xff\xb9\xff\xeb\xff\x0f\x00\xe5\xff\xa2\xff}\xffc\xff5\xffK\xff\x85\xff\xbc\xff\xcc\xff\xbf\xff\xb3\xff\x9a\xff\xa0\xff\xca\xff\x0f\x003\x00$\x00\xf6\xff\xc6\xff\xba\xff\xc2\xff\xcb\xff\xb3\xff\x97\xffz\xffP\xff.\xff\x15\xff\x13\xff&\xff%\xff:\xff;\xffL\xff_\xff\x97\xff\xe6\xff\x1c\x004\x00F\x00o\x00\xcc\x00\x17\x01)\x01\x19\x01\x0f\x01\x19\x014\x01U\x01r\x01J\x01\xec\x00\xb5\x00\xbd\x00\xcf\x00\x9d\x00U\x00\x01\x00\xb7\xffy\xffh\xffk\xff<\xff\xec\xfe\xaf\xfe\x9a\xfe\x9b\xfe\xab\xfe\xaf\xfe\xc9\xfe\xd2\xfe\xf5\xfe-\xff\\\xff\x83\xff\xbf\xff,\x00\x90\x00\xcb\x00\xe8\x00\r\x01I\x01\x8a\x01\xba\x01\xc8\x01\xa5\x01m\x01j\x01q\x01E\x01\xe2\x00|\x005\x00\xe0\xff\xac\xff\xa5\xffh\xff\xf2\xfeK\xfe\x0f\xfe&\xfe?\xfe\x1a\xfe\x01\xfe5\xfe\xb0\xfen\xfe!\xfeN\xfe&\xfe3\xfe\xc3\xff@\x04\x12\x05\xf6\xff\x86\xfbt\xfe\xae\x05\xb9\x07\xd4\x05\xa6\x03\x88\x01\xb8\xfe\x17\x00\x82\x06\x12\t\xb5\x03$\xfe\x16\xff\xa1\x01\xcc\x00W\xff>\x00S\x00\xd5\xfd\xc6\xfc\x04\xfe\xd4\xfdh\xfb\x17\xfb*\xfe\xbc\xff\x87\xfd\xe9\xfa%\xfb\xf2\xfc0\xfeN\xff&\x00\x8b\xff\x84\xfd/\xfdh\xff\xba\x019\x02\x84\x01\x14\x01\xb1\x00H\x00\xf1\x00I\x02\xbf\x02\xa0\x01\x86\x00\x89\x00o\x00\xbd\xff)\xff\'\xff>\xff\xc5\xfe+\xfeQ\xfds\xfc\x0e\xfc\x94\xfc`\xfdC\xfdz\xfc\x8e\xfb\x07\xfbP\xfb\\\xfcP\xfd,\xfd?\xfc\xbc\xfb\x0c\xfc\x93\xfc\xc4\xfc\x1c\xfd\xa6\xfe\xa4\x00w\x01O\x00W\xff\xc6\x00\xb2\x04\x83\x08\xcc\t\xe9\x08\x9a\x075\x08\xca\n\xd0\r[\x0f\x9a\x0e\xf4\x0c\xa2\x0b0\x0b*\x0b\xa8\n\x97\t\x06\x08\xf5\x05n\x03\x98\x00d\xfe\\\xfd\xdf\xfc\xfe\xfb\xee\xf9:\xf7F\xf5\xda\xf4\xbd\xf5\xe9\xf6y\xf7$\xf7g\xf6j\xf6\xf6\xf7g\xfav\xfc\xb8\xfd_\xfe\xdf\xfe*\xff\xfb\xff\xa0\x01/\x03\xc1\x031\x03m\x02\xba\x01)\x01\x0c\x01\xfe\x00\xa0\x00p\xff\xd6\xfde\xfcU\xfb\xf1\xfa\xfd\xfa\xe9\xfaO\xfas\xf9\xcb\xf8\xac\xf8\r\xf9\x1e\xfa6\xfb\xc6\xfb\xd9\xfb\xe5\xfb\xa3\xfc\xff\xfd\xa8\xff\x0c\x01\x94\x01\x99\x01\xde\x01\xbf\x02\xb0\x03]\x04\xdf\x044\x05\x08\x05\xae\x04\x84\x04\xab\x04\xc9\x04\xa5\x04\x85\x04\x17\x04t\x03\xe1\x02\x88\x02p\x02_\x024\x02\xc8\x01\x1a\x01\x93\x00l\x00\x81\x00\x85\x00]\x00\r\x00\xb1\xffq\xff[\xffO\xff\x13\xff\xec\xfe\xf7\xfe\xe9\xfe\x99\xfe=\xfe\x10\xfe\x0e\xfe\x1a\xfe6\xfeM\xfe=\xfe\x1e\xfe-\xfe\x80\xfe\xe2\xfe\x15\xff6\xffm\xff\xcb\xff"\x00]\x00|\x00\xa7\x00\xce\x00\xf6\x00\x1d\x015\x01/\x01\x0b\x01\xe4\x00\xda\x00\xdc\x00\xac\x00\\\x00\x1a\x00\x02\x00\xea\xff\xc5\xff\x93\xffi\xff0\xff$\xff9\xff^\xff`\xff5\xffB\xff}\xff\xad\xff\xcf\xff\x02\x00.\x00\\\x00\x80\x00\xca\x00\xfb\x00\xf3\x00\xf1\x00\x14\x01c\x01s\x01a\x01S\x01>\x011\x01)\x013\x01#\x01\xd3\x00\x8f\x00\x99\x00\xa1\x00{\x00)\x00\x07\x00\xff\xff\xd6\xff\xb5\xff\xbb\xff\xab\xff\x89\xff6\xff\x0c\xff1\xffW\xff\x05\xff\xdb\xfe`\xff\x11\x00\x97\xff\x04\xff\xb3\xffT\xff<\xfe\x9e\xff\xd0\x04\xd9\x05B\xff\x9e\xfa\xaf\xfe\x81\x05\xe8\x05\x80\x03O\x02\x08\x00c\xfco\xfe\xcf\x05,\x074\x00J\xfb-\xfeM\x01\x0c\x00\xd8\xfe\xea\xffo\xff\x03\xfdU\xfd\x84\xff\xfc\xfeR\xfc\xa6\xfc\x87\xff9\x00\xef\xfd_\xfc \xfd%\xfe\xfb\xfe\x07\x00H\x00\xa3\xfe\xfd\xfc\xcd\xfd\xdb\xff\xb0\x00\\\x00/\x00\xc5\xff\xdb\xfe\xc6\xfeB\x00B\x01\xd4\x00\xec\xff\xdc\xff\n\x00\xd2\xff\xde\xffQ\x00P\x00\xf4\xff\xef\xff(\x00\xc4\xff\xea\xfe\xb7\xfeq\xff=\x00\x0f\x00J\xff\x8d\xfe\'\xfe\xab\xfe\xf0\xff\xab\x00\xdd\xffx\xfeG\xfer\xff.\x00\x0f\x00\x83\xff\x07\xff\xb8\xfe\xf9\xfe\xb3\xff\xb1\xff|\xfe\x9e\xfd\x12\xfe\xdb\xfe#\xff\x15\xff\x86\xff\x89\xff\x18\xff\x02\x00W\x02\xdc\x04X\x05\xf9\x04M\x054\x06C\x08\xab\n\x8f\x0c\xf6\x0bs\t\x8b\x08\xf5\t\x8f\x0b\xfb\n\x94\x08\x00\x06\x1f\x04\xc7\x02\x02\x02\xa3\x00:\xfe\x91\xfb\xfc\xf9|\xf9V\xf8_\xf6\xf5\xf4\xef\xf4\xcf\xf5`\xf6p\xf6T\xf6n\xf6w\xf7\xe7\xf9z\xfc\xcf\xfd\xdf\xfd-\xfe\xac\xff\x9f\x014\x03\x08\x04\x01\x04V\x03\xc7\x02\x17\x03~\x03\xd8\x02\x10\x01\xae\xff\x16\xffD\xfe\xbd\xfc\\\xfb\x8c\xfa\x92\xf9\x9c\xf8[\xf8\x96\xf8&\xf8P\xf7\x88\xf7\xa5\xf8\xc2\xf9\x8e\xfa\x9c\xfb\xa2\xfcc\xfd~\xfeX\x00\x14\x02\xd5\x02g\x03\x8d\x04\xae\x055\x06i\x06\xf3\x067\x07\x14\x07#\x07\\\x07\xf4\x06\n\x06V\x05\x14\x05\x98\x04\x03\x04\x94\x03\x00\x03\x03\x02\x11\x01\x9f\x00\x8d\x00g\x00\t\x00\x92\xff\x08\xff\xc0\xfe\xdf\xfe+\xffn\xffP\xff\x03\xff\xd5\xfe\x04\xffW\xffk\xffy\xff\x92\xff|\xffK\xff\x16\xff\x02\xff\xe2\xfe\xc5\xfe\xc1\xfe\xbf\xfe\x90\xfe2\xfe\xcf\xfd\xab\xfd\xc4\xfd\xdc\xfd\xc9\xfd\xf5\xfd\x82\xfe\x98\xfe\xe0\xfd\x92\xfd\xae\xfe\x14\x00p\x00n\x00\x96\x00L\x00\x01\x00\x8d\x01\xdc\x03\xe3\x03\x03\x02\xbd\x01e\x03\xb7\x03\x89\x02\xaa\x02\x03\x04\xb3\x03\xec\x01R\x01\xc2\x010\x01$\x00\x93\x00w\x01\x83\x00U\xfe\xc6\xfd\xe9\xfec\xff\xbb\xfe\x8c\xfe\x03\xff\xb8\xfe\xdc\xfd-\xfea\xff\xa7\xff\xf8\xfe \xff\xc5\xff\x95\xff\xf0\xfeG\xff#\x00\x00\x00i\xff\x98\xff\xbe\xff\x02\xff\x85\xfe7\xff\x92\xff\x8c\xfe\xd6\xfd\x7f\xfe\xa5\xfe\x97\xfd\xf7\xfc\xaf\xfd\xe4\xfdW\xfd~\xfd\xd6\xfd#\xfdY\xfc8\xfdc\xfe\xe9\xfd\x1c\xfdf\xfd\xd0\xfdU\xfd\x9c\xfd^\xfeO\xfeN\xfdl\xfdU\xfeY\xfem\xfd\xe5\xfcT\xfdY\xfd\xf1\xfc\x90\xfcc\xfc\x07\xfc\xcb\xfb\xcb\xfb@\xfcn\xfc*\xfdB\xfe\xdf\xffj\x02\x0f\x04\x13\x05\xb4\x053\t\xa1\x0e\xaf\x12,\x14\xd3\x13K\x14n\x16\xe4\x19\x86\x1c\xe8\x1b\'\x19\xaf\x161\x15\x01\x14\x12\x12\xed\x0ec\n\xc7\x05\x9b\x02\x8a\xffd\xfb\xb6\xf6<\xf3\r\xf1\xf9\xee\x01\xed\xd7\xea\xec\xe8\x1b\xe8\x01\xe9\x9a\xea\xb6\xeb\xbb\xec,\xee\x02\xf0{\xf2\xd1\xf5\x0b\xf9\x07\xfb\xdb\xfc\xc2\xff\xff\x02_\x04H\x04M\x05\x8f\x07\xd0\x08\x12\x08,\x07\xaf\x060\x05\x04\x03{\x02\x1c\x03V\x01\xeb\xfc&\xfa\x8e\xfa\xa0\xfa\x81\xf8\xa3\xf6\x84\xf6\x1b\xf6\x85\xf4\xc7\xf4"\xf7\xe1\xf7\x84\xf6R\xf6\xd7\xf9\xba\xfc\x9a\xfcF\xfd\xeb\xff\x1d\x02\xf6\x01L\x03G\x07\xb0\x08\x14\x08\xb3\x08y\n\x11\n\xcc\x08/\x0b\xc8\x0c\x15\n\x13\x07D\x07\xf1\x07\xf1\x05Q\x043\x04\x01\x028\xff\x9e\xfe?\xff\xb3\xfd\x0f\xfbQ\xfax\xfa\xa7\xf94\xf9\x80\xf9!\xf9\xd6\xf7\xab\xf7C\xf9e\xfa\x0c\xfa\xba\xf9w\xfaW\xfb\xb1\xfbT\xfc\x95\xfd<\xfe\xbf\xfd\xee\xfd3\xff\xfc\xff)\xffo\xfe\xea\xfeb\xffB\xff\xe5\xfe.\xfe\x0e\xfd6\xfc\xa5\xfc-\xfd\xae\xfc\x96\xfbG\xfa?\xf9I\xf9w\xfbN\xfc9\xfa\xe9\xf7C\xf8\x13\xfa3\xfa\x04\xfb\x08\xfc\xd4\xfb`\xfa\x14\xfc\x1b\x01\xfd\x02\xbc\x01*\x02-\x07\xd4\x0b\x9a\x0e\xeb\x12<\x17\xab\x17u\x15\xf6\x18\xc7"\xa3(\xe5%\xb6 \x82 \xf5"\xa6"f \xf3\x1d\x1f\x1a\xd6\x13\xfa\x0e\xfb\x0c.\ta\x01\x9d\xf9\x85\xf6\xd0\xf5\x89\xf23\xed\xd8\xe7\xac\xe4\xcc\xe3\xa6\xe4\t\xe6 \xe6\xd1\xe5a\xe6\xfa\xe7\x8d\xeb\xde\xef\xe7\xf2\xc2\xf3Q\xf6\xad\xfb\xa0\xff\xd9\xff\xf0\xff=\x03#\x05\xf4\x04&\x06\xa8\x08\x1b\x07\x1c\x01\xb8\xff\x9e\x02\xe4\x01\x08\xfc\xa9\xf9\xee\xfb\xb0\xfa\xb4\xf4M\xf2\x9f\xf4\x8e\xf4\xb3\xf1\xae\xf2\xac\xf6\xc6\xf6\xa2\xf3^\xf4\n\xf9\xb9\xfb\xa8\xfc\x16\xff\xe9\x02\x8f\x03\x04\x03\xa5\x040\x08\xc7\t\xb0\t\x02\x0b\xa7\x0c\xee\x0c\xf2\nS\t\xdd\x08\xba\x08\xa2\x08\x91\x08\xb1\x07!\x05u\x01o\xff2\xff\xf4\xfe\xe0\xfd0\xfd\x8b\xfc&\xfb\xe8\xf8\t\xf87\xf8a\xf8\xd5\xf8Z\xfa8\xfbn\xfa!\xf9Z\xf9\x8d\xfa\xa0\xfb\x1d\xfd(\xfeM\xfeU\xfd\x1c\xfdR\xfd{\xfd\xe6\xfdu\xfe\x8b\xfeE\xfd\xdc\xfb\xa6\xfa>\xfat\xfa\x87\xfa\x9e\xf9\xd9\xf7X\xf6\xe6\xf5\x8f\xf5\x81\xf6\x03\xf7\x1c\xf6\\\xf4>\xf5\x0f\xf9B\xfa\x99\xf8\x93\xf7\xd9\xfa8\x00\x06\x04\x82\x06\x07\x07q\x06\x0c\x08&\x0e\xda\x16\xb5\x1b\x07\x1c\xe0\x1cm \'$\x11%\x8b&i)\x89+\x85*5(y&\n#J\x1d\x96\x17\xc1\x14\xb6\x12N\x0e(\x07\xd7\xff\xc6\xf9\xb8\xf4\xea\xf0\xbb\xedP\xeb\xe3\xe8\xda\xe6`\xe5^\xe4\xfc\xe30\xe4\xf0\xe5T\xe9\xc2\xec\x11\xefS\xf05\xf2\xa1\xf4K\xf7k\xfay\xfd=\xff\x0b\xff*\xff\xad\x00\xc5\x01t\x00\x9c\xfd\xaa\xfc\x84\xfd4\xfde\xfa\x00\xf7\x1b\xf5\xcb\xf3_\xf2\xa1\xf1\x8d\xf1~\xf0\xe9\xee/\xefP\xf1\x18\xf3\x06\xf3\x10\xf4\xa5\xf6O\xf9\xcc\xfb\xe2\xfe\x91\x02\xe4\x03\x80\x04\x94\x06E\n\x99\x0c\xee\x0cc\r\xe6\rr\x0ey\x0e\x8c\x0e\xf8\x0c\xbe\nw\t\xac\t\xe4\x08B\x06\xd1\x03\x1f\x02\x05\x01\xd8\xff\xe5\xfe\xff\xfdC\xfc\xf0\xfa\xc4\xfaO\xfb\xfb\xfb\xc1\xfbd\xfb\x03\xfbF\xfbd\xfct\xfd-\xfe\x14\xfe\xbc\xfd\x12\xfd\r\xfd;\xfd-\xfd\xcd\xfb\x12\xfa\xcd\xf8d\xf8Q\xf8\xf0\xf6n\xf4\xaa\xf1\x98\xf0<\xf2\xa6\xf3\xf0\xf2T\xf0\xab\xee\\\xf0\xa9\xf3O\xf6\xb9\xf6<\xf59\xf5f\xf8g\xfdK\x00\xce\xff\xe1\xfe\x0c\x01\xdd\x05\x8f\tm\nB\n)\x0b%\x0e\x9f\x11Y\x14\x7f\x15\xa5\x15|\x16G\x18\xe5\x1a\xf7\x1cF\x1e\xba\x1e%\x1e\x1d\x1eP\x1f\xf7 \x07!\xa9\x1eH\x1c\x9e\x1b\x84\x1b\x13\x1a\xf6\x16\xa2\x13\x8c\x10|\r\xb3\n\x81\x08\xd3\x05\xa0\x01\x0c\xfd\x1c\xfa\xd9\xf8\xea\xf6\x9a\xf3\x01\xf1\xaf\xefJ\xeea\xec@\xebn\xeb\xc7\xea_\xe9*\xe9g\xea\x93\xebA\xeb\x1e\xeb\xd5\xeb\xe2\xec\xfe\xed\xe0\xee\xe7\xef\x9b\xf0\xc7\xf0P\xf1`\xf2\xbe\xf3Z\xf4\xf9\xf3,\xf4@\xf5\x8f\xf6\xf8\xf6\xc8\xf6\x80\xf7\x95\xf8\x8c\xf9)\xfa!\xfb\xb0\xfcR\xfd\xb6\xfd\xd3\xfe\x87\x00\xe7\x01b\x029\x03\x7f\x04\x80\x05\x1c\x06\x0c\x07H\x08\x80\x08"\x08|\x08\xd4\t\\\n\x85\t\xb6\x08\x9b\x08\xb4\x08\x06\x08\xc5\x07\xbe\x07A\x07\x0b\x06$\x05\x06\x05\xed\x04{\x04\xb7\x03\xf7\x02@\x02\xd8\x01\xb2\x01;\x01+\x00\x1e\xffZ\xfe\xe0\xfda\xfd\xb3\xfc\xaf\xfb=\xfa\xdf\xf8!\xf8\xa1\xf7\xd4\xf6\xa8\xf5f\xf4m\xf3\xb9\xf2E\xf2\xf7\xf1\xa9\xf1(\xf1\xa5\xf0\xa6\xf0 \xf1\x91\xf1\xd8\xf1J\xf2\x05\xf3\xe7\xf3\xee\xf4\x1b\xf6A\xf7L\xf8U\xf9\xb1\xfau\xfc4\xfeq\xffi\x00\xd2\x01\xb5\x03y\x05\xae\x06\x04\x08\xcc\t\x18\x0b\xef\x0b2\r\xc1\x0eb\x10\x8f\x11\x96\x12\xe3\x13\xcd\x14\x9b\x15b\x16\x92\x17\xa4\x187\x19\x1e\x1a1\x1b/\x1c^\x1c\x90\x1cu\x1d\xd8\x1d\x97\x1d$\x1d\x1d\x1d\xc0\x1c\xf1\x1a\xe2\x182\x17o\x15\xed\x12j\x0fC\x0c\xf9\x08\x19\x05I\x01\xf0\xfd\xca\xfa\x0e\xf7\r\xf3\xde\xef\x1f\xed\x97\xeap\xe8\xaf\xe6\xfc\xe4A\xe3.\xe2\x0e\xe2\x1f\xe2\x06\xe2>\xe2\xcc\xe2\x90\xe3~\xe4\xc7\xe5?\xe7\x97\xe8\xb2\xe9\x05\xeb\xb6\xec\x81\xee \xf0b\xf1\xb9\xf2B\xf4\xdb\xf55\xf7q\xf8\xb6\xf9\xef\xfa\xfe\xfb\x12\xfd\x85\xfe\x16\x00=\x01+\x02\x87\x03\xfa\x04\xec\x05j\x066\x07\x8d\x08\x04\n\xf5\n9\x0b*\x0b\xdb\n\x98\n\xba\n\x03\x0b\xdb\n\x17\n%\tI\x08~\x07\xc8\x065\x06d\x05:\x04e\x03\x1b\x03\xce\x02\x19\x02C\x01\xca\x00d\x00\xda\xff\x9d\xff\x9f\xffd\xff\xa6\xfe\x00\xfe\xe8\xfd\xce\xfd;\xfdO\xfcs\xfb\xbd\xfa\xeb\xf9/\xf9`\xf8E\xf7\xf0\xf5\xa8\xf4\xa6\xf3\x03\xf3\x7f\xf2\xf2\xf1`\xf1\x06\xf1%\xf1\x8a\xf1\x05\xf2Q\xf2\xf0\xf2\xe0\xf3\t\xf5U\xf6\xa7\xf7\xf7\xf82\xfaS\xfb\xa2\xfc.\xfe\xc5\xff)\x01j\x02\xd4\x03(\x05\x84\x06\xb2\x07\xf6\x08\x87\n+\x0c\xbe\r\xe1\x0e\'\x10\x18\x11\xb8\x11\xc9\x12\n\x14]\x15\x16\x16\x0b\x16\x14\x16U\x16\xa8\x16j\x17`\x18\xdf\x18\xe4\x18\x11\x19\xbb\x19!\x1a~\x1a:\x1b\xce\x1b\xed\x1a\x1d\x19\x89\x18l\x18>\x17\x8b\x14\xf0\x11\xe3\x0f\xad\x0c~\x08\x1c\x05\x90\x02d\xff\x99\xfa\x8d\xf6?\xf4\xd7\xf1x\xee\'\xebL\xe9\x1d\xe8\xfd\xe5\x06\xe4s\xe3\xa5\xe3=\xe3=\xe2b\xe2\xbf\xe3o\xe4F\xe4\xd0\xe4\xca\xe6\x0c\xe9\xec\xe9\xba\xea\xcf\xec&\xef\xab\xf0\xb2\xf1\xaa\xf3U\xf6\x11\xf8\xae\xf8\xfa\xf9d\xfcs\xfe5\xff\xbc\xffK\x01\xe0\x02-\x03H\x03G\x04\xc8\x05.\x06\xa9\x05\r\x06\xf9\x06\x1a\x07\xff\x05\x93\x05\x83\x06\xd5\x06\t\x06F\x05\x97\x05\xb3\x05q\x04J\x03B\x03\xa5\x03\xff\x02\xe4\x01\xd3\x01$\x02\x92\x01P\x00\xb1\xff\xe6\xff\xa1\xff\xa2\xfe=\xfe\xa0\xfe\x84\xfe\x8c\xfd\xca\xfc\xbf\xfc\x9d\xfc\xb9\xfb\x10\xfb5\xfb?\xfb\x82\xfa\x9a\xf9D\xf9\xfd\xf8;\xf8=\xf7\xc0\xf6\x98\xf6\xfd\xf5N\xf5\'\xf5!\xf5\xd6\xf4\x80\xf4\xab\xf4R\xf5\xe4\xf5|\xf6\x96\xf7\xc2\xf8\xb4\xf9\xb5\xfa>\xfc\xd4\xfd\xfd\xfe*\x00\x96\x01\x17\x03\x1b\x04\xe7\x04b\x06\xb8\x07Z\x08\x06\t\xf2\tL\x0b\xea\x0b\x18\x0c9\ro\x0e\x0b\x0f;\x0f\x8d\x0f\xba\x10\x1d\x11t\x10\xf7\x10`\x11-\x11i\x10\xd3\x0fo\x10"\x10h\x0e\xce\r@\x0e8\x0ea\r1\r\xbb\x0e-\x0f\xf8\r\x86\r$\x0f\xa6\x10\xbb\x0f\xbe\x0eK\x0f\xeb\x0f\xbc\x0e2\x0c\x00\x0ca\x0c\x1b\nC\x06\x98\x03C\x03\x96\x01\xfd\xfc\xb3\xf9\xc1\xf8D\xf7"\xf3b\xef#\xef\xe1\xee\xac\xebx\xe8\xf7\xe8q\xea\x8b\xe8\xe4\xe5\x07\xe7\xc6\xe9\xab\xe9a\xe8\xe7\xe9?\xed\x18\xee\x13\xed/\xef\xf3\xf2j\xf46\xf4\xa1\xf5\xcb\xf8\x1c\xfa\xd7\xf9\x82\xfb\x16\xfe\xb1\xfe\xc3\xfd\x83\xfe\xc5\x00\x98\x01\xf2\x00\x0c\x01Q\x02\xa2\x02<\x02i\x02V\x03\x9e\x03\xee\x02\xdf\x02\x90\x03\xdc\x03_\x03\xc3\x02\xe2\x02\x14\x03\xd1\x02\x07\x02\xba\x01\x9f\x01?\x01x\x00\xd1\xff\xf7\xff\xcf\xff\x1e\xff<\xfe\x07\xfe\xe8\xfdd\xfd\xc3\xfc\xd9\xfc\x07\xfdw\xfc\xb5\xfb\x84\xfb\'\xfcA\xfc\x8d\xfb\x8f\xfb\xea\xfb3\xfcd\xfc\x8a\xfcL\xfd\xa4\xfd#\xfd\x03\xfd\xa4\xfd\x9d\xfe\xbc\xfe\xe9\xfd\xf2\xfb\xdd\xfa\xb3\xfc\x7f\x00\xd4\x03\x9a\x00\x93\xf9\x16\xf6v\xf8O\xfe\xff\x01\t\x01\xc4\xfe\xac\xfd*\xfd\x11\xfd6\xfd{\xfe\x10\x01\xb4\x02 \x03O\x05\x7f\x07{\x08q\x07!\x07<\t3\n\xe5\n#\x0c!\x0f\x03\x12\x16\x11\xf7\x0f\xbf\x0e\x82\x0c\x81\x0b&\x0b\xd5\x0c\xed\r\xfd\x0b\xa8\n\x14\n\x08\t\x13\x06i\x029\x00\xc3\xff\xaa\x00=\x02\xb4\x03\x94\x04\xc0\x02\xf8\xffP\xfeV\x00(\x05\xca\x08>\n\t\x0b\x05\x0eE\x11\xdb\x11#\x0f\xa3\x0cl\r\xf0\x11T\x16B\x18\x9d\x14\x1e\r\xc2\x07\xde\x06\x1e\t|\x07\x8c\x01\x8b\xfc[\xfbn\xfc\x1d\xfaD\xf3\xe9\xea\x0c\xe5\'\xe5\xdb\xe8~\xec\x02\xebP\xe5\xd9\xe1\x95\xe3\xf7\xe7e\xe8X\xe5l\xe6\xf5\xed$\xf6|\xf9\x07\xf7\xd1\xf4\x9f\xf4\x15\xf7O\xfbI\xff0\x02\x03\x03r\x04\xe2\x04G\x03s\xff\xb0\xfc:\xfeb\x01\xa4\x03_\x03V\x01\x8d\xfep\xfa\x93\xf7\x8a\xf6\xea\xf7Y\xf9\xcc\xf9\x18\xfal\xf9\xbb\xf8x\xf6\xf8\xf4\xed\xf3*\xf5z\xf8;\xfc\xe2\xfec\xfe!\xfc\x07\xfa\x98\xf9\n\xfbm\xfc\x12\xfe\xb7\xffL\x01\xeb\x01C\x00\xd7\xfdr\xfb\x84\xfb\xfc\xfc\x19\x00q\x02\x00\x03z\x02\xef\x00\xe1\xff\xa4\xfe\x80\xfe9\x00\xa3\x02\xdb\x044\x05u\x05\xc2\x04^\x02\xb7\xff\xd5\xfe\x98\x01\xe1\x04A\x06\xea\x04\xd2\x02\x04\x01\\\x00\xc6\x02\x07\x03\x86\x01\xb3\xfe\x1a\xff\xbb\x04\xcd\x061\x03\x80\xfe\xbf\xfc\xac\x00\x14\x03p\x02\xcb\x011\x01\x1d\x04\xc5\x06c\x08\x07\x07\xec\x03>\x04\\\x08\xb4\x0cB\x0e\xc2\nR\x083\t\xeb\n\xe2\r\xbe\x0b\xf6\x08m\x07v\x07\\\t\x14\t\xe9\x06b\x04W\x02\x9a\x02\xe4\x03\x89\x04\xd9\x04\r\x03\xa9\x00\xb0\xfe\x1d\xfd\x8d\xfc\x1f\xfd$\x02!\n\x1f\x0f\x16\x0f\xe6\x08\x05\x05\xc5\x05\x01\r\xa5\x15 \x19\xe9\x17\xcf\x13\xc4\x14s\x14\xda\x10\xe4\x08\x99\x02\xfb\x04V\n\xd1\r\x9f\n\xc3\x00\x02\xf7\x0c\xf0\x8b\xef\xd8\xf1\xd1\xf2\xbd\xf3}\xf2>\xf1d\xec\x18\xe71\xe5%\xe6\x1a\xebB\xef\x83\xf2\xf4\xf3\xc9\xf2\xb8\xf2\xeb\xf1\xfb\xf0\x8c\xf1\xff\xf4\xb6\xfc\xe3\x02\xec\x04\xc7\x00$\xfa\xf9\xf6P\xf8\xda\xfd\xa4\x01\xda\x02\xbd\x01(\xfe\xad\xfb%\xf9\x07\xf9u\xf8\xdf\xf6\xff\xf6\x00\xf9O\xfcD\xfcl\xf8\x01\xf3m\xf1\n\xf4\xdf\xf9(\xfe_\xfd*\xfb\x84\xf8p\xfa\x02\xfd\xc1\xfdb\xfd\x14\xfd"\x00$\x03b\x03$\x00\xda\xfb\x1d\xf9\x1c\xfa\xb6\xfd\xb4\x005\x01,\xfe\x11\xfb\xad\xf9k\xfa}\xfbu\xfb+\xfb\xa8\xfd\x91\x01\x9e\x04\x90\x03F\xfeh\xfbj\xfcK\x00\xa2\x03\x98\x02\\\x01\x0c\x02\x16\x04\xc2\x05?\x02\xe2\xfc\xbf\xfee\x03=\to\n\xc5\x07/\x08Y\x06k\x02\xe7\x01\xe6\x01o\x07\x08\n\x8c\x07\xbc\x06\x1d\x03\xc3\x01\x00\x01\xbf\x00\xe5\x033\x07\xa4\nP\x0et\x0b4\x07\xc2\x019\x02g\x08\xb4\x0c\x97\x11\x90\x0f\xbc\x0c\xc0\x08S\x06\xf5\x06\x1e\x05H\x01}\x02\xad\x06=\x0c\xfb\x0cl\x07O\x05"\x01\x10\xff\x9d\xff!\x01\xf0\x05\xb7\x08\x84\t\xd9\t\xe0\x04%\xff\x11\xfb$\xfb\xa3\x01\x15\x08\xdb\x0c\xa8\n\x00\x03~\xfa\x9a\xf7\xb1\xfb\xf5\x00-\x03\x9c\x01\x88\x00\x8d\xffS\xfd\xce\xfa\xa6\xf80\xfb\x90\xfd\x93\x01\xfe\x01\x95\xff\x01\xfd*\xf7\t\xf6>\xf4\x8c\xf7\xa3\xfa\xdc\xf9m\xf8\xc4\xf3\x92\xf3\xcd\xf4\x91\xf7\xb8\xf9y\xf9\xcc\xfa)\xfb\xb0\xfdF\xfe\xeb\xfe(\x00q\xffC\x00X\xfc\x86\xf9\xaa\xf8\x06\xf9\x88\xfbo\xfb2\xf90\xf8^\xf6>\xf6\x89\xf5q\xf4c\xf8\xec\xfc\xca\x01\x84\x00\'\xfbC\xf8k\xf9\xe6\xfdr\x00-\x00%\x00\xdc\x00\xd9\x02\x06\x03!\x00v\xfd\xb6\xfc\xbc\x01\xa6\x08\xf4\t\xea\x04i\xfe8\xfde\x00\x04\x02\xd1\x02G\x01\xbb\x00\xe3\xff\xf4\xfe\x9a\xfc\xec\xf9\xe6\xf8y\xf9u\xfbl\xfa\xc8\xfa@\xfb,\xfc\x90\xfcf\xf9\xe7\xf9\xf4\xfc\xd7\x01\x04\x06L\x05\x0f\x05\xb9\x04x\x05\x84\x07\x93\x08z\x08\xd9\x03\xf2\x01\xf3\x02O\x07\xef\x08m\x06w\x04u\x01>\x02\xe2\x02>\x03\x1e\x03o\xff\xe9\xfd\xd8\xfef\x02#\x07-\x077\x03\x9d\xfd5\xf9\xe0\xf9\x9e\xff\xf0\x05\xc9\t\x9c\x08\xb8\x04\t\x03:\x02\xe8\x03\xe6\x024\x01E\x02\x89\x06\x81\x0b\xb7\t\xed\x03:\xfcr\xf8\xff\xfa\xf7\x00\xed\x06\xc8\x06\x1c\x04\x1a\x00]\xfc\x85\xf9\x9f\xf8\xd9\xfb\x88\x00\x8f\x02\x06\x01\xa4\xfd\xb0\xfbt\xfa\xe9\xf9\xca\xfa,\xfc\xca\xfe\xd3\x01c\x05\x1a\x067\x04j\x01\xad\x00\xd9\x01\xbb\x01\xe9\x01V\x01\x16\x02;\x01\xcb\xfe3\xfd\x8d\xfcR\xfcE\xfc\xe3\xfc\'\xff\xba\x01\xdb\x03}\x05\x1c\x04\xc9\x00\x91\xfc\xeb\xfb\x06\xff\xd2\x03\x19\x07`\x06s\x02\x93\xfd\x90\xfav\xfa\x12\xfd\xf2\xffW\x03\xf7\x06\xe5\n6\x0cI\t\xee\x01=\xfbV\xf9F\xff\xec\x06}\x08\xfc\x02}\xf9\xd1\xf4\x90\xf4\x90\xf7)\xfb\x05\xfc\x80\xfd \xff\xb0\x001\x01\x1c\xfc\xd6\xf6\xd4\xf2\xe1\xf4\xaa\xfb\xba\x00\x82\x03\xa8\x00\xb8\xfb\xcf\xf5\x81\xf3$\xf7K\xfc\xd5\xfe\xb4\xfe\x80\xfe\xa2\xffQ\x01\x17\x00G\xfe\x8b\xfbU\xfb\x04\xff\xc5\x01\xe6\x02\xae\xff\x07\xfd\xff\xfd\x9e\x01\xdc\x04y\x03\xb5\xff\xb0\xfd\xcb\xfdy\xff\x8f\x01\xd7\x02\xad\x01\xa7\xff\x91\xff\x15\x005\x00"\xff\xdc\xff\x80\x00,\x02\x81\x03\x9b\x04\x08\x06\x0f\x04W\x02\x06\x00\xa7\x00j\x03\x90\x04\x99\x04K\x02k\x00\x9b\x00\x7f\x00@\x01\xbc\x00\x17\x00\xf1\x01\xe7\x02b\x03\xc0\x00\xc5\xfd\xc6\xfb?\xfb\xe7\xff\xb9\x04\xc0\x04O\xfe\x1d\xf7i\xf6D\xfcp\x01\x17\x02\xc9\xfe\xcf\xfc\xa0\xfe\xc5\xffZ\xfe"\xfa7\xf7 \xf8\xe5\xfc/\x02\xf4\x04\xad\x06\xbc\x05T\x02\x01\xfd\xb0\xf8D\xfb%\x03i\x0b\xf1\x0c\xc8\x06\x8a\xfe\xce\xf7\xf7\xf4\xfa\xf5v\xfa\xeb\xfd\xa8\xff\x1e\x03*\x05r\x04\x01\x01l\xfdY\xfe\\\x00\xc5\x04\x00\nR\x0c#\x0b\x82\x05\xab\xfe\xf4\xf9J\xfa\xb7\xfdw\x01u\x03Z\x03\x8b\x00_\xfdn\xfdF\x00\xd2\x04p\x08\x83\x06\x10\x02\xdd\xff\x18\x00\xaa\xffa\xfd\x93\xfd\x86\x00\x84\x05\xf3\x07`\x07\x11\x033\xfe\xfa\xfb\x98\xf8\xae\xfa\xfb\xff\x0e\x06q\t\xfe\x04\x05\xfe\xe9\xf6?\xf6\x12\xfc\xbb\x00\xa1\x01j\xff\x92\xff\x1c\x02\xae\x03\x99\x01\x97\xfeM\xfc\xbe\xfb\x11\xfc\xc1\xfe0\x03\x1c\x03\x08\xff\xdc\xf9\xb1\xf8z\xfcq\x00\x7f\x02\xf5\x01\xf0\xff\xf7\xfe\r\xfez\xffr\x01\x8c\x01\xba\x00\xb1\xfe\xca\xff\x81\x00\x1b\xff\x9c\xfbP\xf7\x92\xf8\xa1\xfc\x85\x01\xf2\x04\xd8\x04\xe2\x02\xbe\xfek\xfe6\xff\xcc\xff?\x01L\x04\x88\x07\xed\x04e\x01\x01\xff\x9d\xfeQ\xffj\x01\xe8\x03\x12\x06\xba\x06\x87\x04o\x00\xfc\xfc\xb5\xfc\x1c\xff\xb3\x03\xa5\x057\x01\x87\xf9\x1e\xf7\x99\xfa\x15\xff_\x01\\\x01y\x003\xfdv\xfbQ\xfc\x81\xfe\x87\xff\xfd\xfe`\x01\xdb\x04\xa4\x03k\xff\x1a\xfd\xe9\xfd\t\xfe\xfd\xfc\xb6\xfd\x04\x02\xf4\x04\xf4\x01\x01\xfb\xea\xf3\x1e\xf3\xa3\xf6\x90\xfdP\x04\xe0\x03\xbb\x00\n\xfeb\xfd\xb3\xfe\xdc\xfd\xef\xfd\x93\xfe\xb1\x01 \x06K\x064\x02o\xfa\xfa\xf5\x11\xf5h\xf8^\xfe\xee\x04\x12\t\xb5\x07\xee\x05\xff\x04\x97\x02\xca\xfdB\xfd\x1c\x016\x06U\nq\t\xef\x03\x82\xf9\xdb\xf0\xa8\xf1B\xfc\x89\x08\x9b\x0c`\x08\xed\x00\xf9\xf8\x06\xf6D\xfa\xce\x01\xf0\x06{\t\x86\x0c2\n\xaf\x01\xfa\xf8\x93\xf5{\xf7\x04\xfd\xeb\x04:\x0b\xb5\x0c\x93\x07\xed\xfd\xf3\xf3\xf1\xf1\xc8\xf9\xb8\x02J\ne\x0c\x9f\x07N\xff\x1b\xf7&\xf4\xf7\xf4\xd8\xf9\x8c\x01\x9f\x08o\ro\x0b\x01\x04)\xfa\xb2\xf1l\xf1M\xf7\xc8\x00 \tC\n1\x07*\x02v\xfc.\xf9\xb2\xf6\xa9\xf7B\xfdh\x03\xc1\x08\x82\x06\x12\xff\xf5\xf8\xa6\xf6V\xf9\x0c\xfey\x04k\x086\x05h\xff\x99\xf99\xf8\x99\xfd\xe1\x04\xb4\t\xf0\x08\xc1\x04\x00\x00 \xfc\x7f\xfb\x82\xfc:\xfd\xb3\xfdW\x00\x04\x05j\x08O\x08u\x04\x88\xff\xab\xfa\xe9\xf9\xd5\xfd_\x04\x82\t\xec\x08\r\x07\xdf\x03\xcb\xff\x0b\xfc\xa1\xf7\xcb\xf7\x17\xfb\xc2\xfd\xd3\x01\x16\x06:\x08%\x06n\x00\x95\xfa\xd1\xf4\xee\xf4\xfb\xfa\x17\xff\xac\x01"\x03L\x04\x80\x03n\xfe\xaf\xfa\xa9\xf8\xfe\xf86\xfc\x92\xfe\x16\x03:\x05\x9c\x04\x9b\x02}\xfd\xda\xf9\xfa\xf5\xc0\xf5"\xfd\xfe\x03U\x07>\x03\x9d\xfc\x08\xfb9\xfc4\xfe\x9d\xfe\xb9\xfei\x00\x95\x02\x16\x05\x10\x07[\x03`\xfd\xc4\xfaS\xfc\xa0\xfe\x90\x01\x9b\x05F\x08\xbb\x06\x82\x02j\xffS\xfc\x86\xfa\x0c\xfc\x9f\xff\xa1\x03\xc5\x06\xce\x06\xcf\x04\xba\x00 \xfb*\xf6(\xf5\x81\xfa\xa4\x02\x83\x08\x97\n1\x07>\x01c\xfdn\xfd\xa0\xfd{\xfdA\x01V\x05:\x08\xeb\x07\xd1\x05$\x01@\xfc\x12\xf9M\xf8\x05\xfd\x9a\x04\xa6\x08\xa0\x06\xde\x01\x00\xff1\xfd\x03\xfbq\xf9\x96\xfa\x97\xffe\x03\xad\x05\xec\x04U\x00\xfd\xfa\x8f\xf6\xde\xf6\x96\xfaD\x01\xc7\t\xe4\x0ct\x08v\x013\xfc\x84\xf8I\xf6\x08\xf8\x8b\xfd\xe2\x05\x96\x0c<\r\xf3\x07\xaa\xfe\xb8\xf3E\xec\xf2\xed\xc3\xf5\x88\x01\xaa\x0c\xa7\x10b\x0e\xa8\x06\x8b\xfd\xda\xf6X\xf1\xfd\xf2\x93\xfa\xb7\x041\x0e\x96\x11\xe0\x0c\xf0\xff\xd2\xf3s\xed\x98\xf2\x04\xff\xf5\x06U\tz\x07\xa4\x07\xec\x07\x0c\x05\x16\xfe\x98\xf6\xeb\xf4T\xf9\xcf\x00\xb5\x05\xfa\x06\x9f\x03\x03\xfc\x00\xf7\xe8\xfa\xd1\x01\xfd\x04{\x04j\x02o\xff\t\xfe|\x01\xb8\x04=\x02\x11\xfd\x87\xfb\xc6\xfa\xd2\xfb\x1a\x00F\x03\xa6\xff\xa2\xfa9\xfaA\xfd\x05\x02\x9c\x04i\x04\xfd\x00T\xfe\xe8\xfd\xe9\xfcR\xfdc\xfe\xc4\x00\t\x02q\xfem\xfa<\xf7\x07\xf6\xc6\xf9\x84\xff\x8e\x05\xc7\x07\xc8\x06h\x02\x8c\xf9\x13\xf5\x18\xf6\x08\xfb\x82\x02\xb2\t\xc4\x0f\x1c\x0f\xaa\x07L\xff\xf8\xf6\x1f\xf3\xcc\xf74\xff\xae\x08\xee\x0cj\x0b\x11\x05\x82\xf9\xb9\xf0I\xee>\xf8c\x07=\x11\xcb\x13N\x0f\xcb\x07\xf8\xff\xa9\xf8>\xf4\xe1\xf5/\xfd\xb8\x04G\x08\xe8\x07\xc3\x02\xf3\xf7\xfe\xec\x92\xecj\xf6\xae\x03b\x0e\x03\x14\x9e\x11\xa8\x07T\xfb\xf3\xf1\xdd\xf2\xe7\xfbc\x04\'\n\xd6\n<\x08\x01\x02\xe2\xf6+\xf1\xf0\xf2\xf9\xf6\x8d\xfd-\x07\xbe\x0f\xde\x0f;\x08\x8b\xfc\xed\xf0x\xeeR\xf3|\xf8\xa3\xfe:\x05\x99\x0b\xd6\x0b\xee\x03t\xfd\xf6\xf8\xd7\xf5e\xf6\xd7\xf9\x1a\x03Y\x0b\x8a\r\xaf\tv\xffl\xf8\x84\xf4@\xf3A\xfb\xdc\x03Q\t\xd6\x07V\x03\x00\x02\x97\x01;\x00\x96\xfc\xad\xfa6\xfc \xff\xb4\x019\x04\xec\x02C\xff\xaf\xfd\x00\xfe\x89\xfe]\xff\xaa\x00f\x01\xa2\xff\xa1\x00\xca\x04\x84\x06\x81\x03\xa2\xfe^\xfe*\xff`\x00\xfe\x00x\xff\x16\xfeF\xfb\xea\xf8\x9c\xf7\x1d\xf8\xd2\xfe\xfb\x05\x94\n\xc6\t9\x05V\x01\x8b\xfdE\xfa\xc1\xf9\xe6\xfd\xe9\x01\xf1\x04\x1f\x07\xb3\x07\xda\x04\xd5\xff\x0f\xfb\xfa\xf6\xce\xf8\xd7\xfe\xc3\x03\xe5\x06\xb2\x08\xc2\x08\xd8\x03g\xfc\x98\xf8\x02\xf8\x87\xfaG\xff`\x04\x7f\x07n\x05\xa8\x00\xf1\xfc\xb7\xfaT\xfa\x07\xfb\x91\xfd\xfe\x01\x8b\x06\xfd\x07|\x05\xef\xff\x07\xfb\xad\xf9$\xfb\xd6\xff\x80\x05\xcd\ta\n.\x07\x1b\x01N\xfb*\xf8\xca\xf7%\xfb\xf2\xff\xbb\x03^\x05!\x03\xa5\xfe\xd0\xfb\xfb\xf8h\xf8i\xf9\xcb\xfc\xf3\x01\xc5\x05\x8e\x08^\x08\xa1\x05\x02\x00u\xfb\x06\xfb\xff\xfbU\xfd;\xfe-\x01\xad\x04!\x06\xcc\x02\xd4\xfb\x97\xf9\xfd\xfd\xf1\x00\xb4\x00\x99\x012\x03n\x00A\xfdM\x00\x9a\x02\xa5\x01\x81\x00T\xff\xa1\xfb\x19\xf8\xba\xf8Y\xfb\x03\xfe\r\x01g\x04\xf6\x02\x7f\x00h\xff\xa2\xffF\xfe\xfe\xfcH\xfe\xdb\xfeD\xff\xff\xff\xef\x01b\x02\x80\x00\xbd\xfe\t\xff\x95\x00\x95\x00\t\x01\x02\x03\x1e\x027\xff\xd0\xfez\xff\xaf\xfe\xa4\xfc\xff\xfb@\xfd\xe0\xff\xae\x02t\x02\x17\xffQ\xfb*\xf9\xaf\xfa\xd8\xfd|\x01d\x03\xc5\x04\xc3\x04,\x01\x0b\xfd\x80\xfb\xa8\xfbb\xfd\xf7\xffg\x03D\x05\x80\x03\x1f\x01\xbc\xfe\xdd\xff\x90\x03<\x04\x87\x01\x81\xfe>\xfe\x99\x01\x80\x03&\x02\xe9\x00+\xff6\xfc<\xfa#\xfb\x83\xfb;\xfbM\xffH\x06\\\x06\xf7\x00\xb0\xfd\xd8\xfd\x84\xffJ\xff\x86\xfd\xb1\xfe\xb5\x04-\nH\ta\x02\x00\xfb!\xf8\x9d\xf8M\xfd\xaa\x04Q\x08\x8e\x07\x17\x04"\x01\x9c\xfdj\xfa\xdb\xfb+\xff\xab\x01e\x03\x93\x02\xa9\x01\r\x02\xe3\xff\xe7\xfd\xfe\xfcD\xfc\xbd\xff\x94\x04\xa9\x07\x82\x07\x01\x03\x9d\xfet\xfbY\xfa\xb0\xfe\xf9\x01\x92\x01\xf5\x00f\x00\x05\x02\t\x00\xbe\xfd\xaa\xfc\xfc\xf9\x17\xfb\xbd\xffP\x04x\x08\xa8\x07/\x02\xcd\xfb\xb6\xf8\xc7\xfc\xf5\x02\x11\x07,\x07g\x04\x81\x03\x0f\x04\xb8\x02_\xfe\xf0\xf8\xbe\xf7\xc9\xf9\xd9\xfe\x9a\x04}\x05C\x03\xa4\xfd\x9a\xf9\'\xf7\xe7\xf5\xb5\xf8\xa9\xfb\xaf\xffW\x03%\x04\xc8\x03\xc0\x02u\x028\x01\x03\x00\xb0\x01\xe1\x02\x9a\x02\xc8\x03+\x04%\x02\xfb\xffp\xff\xf9\xff\x95\xff\xba\xff\xc6\x00\x90\xff\xbb\xfe\x94\xfd\xd5\xfc\x19\xfe:\xff\x91\xfe\xf3\xfb\xb1\xfc#\xff\x87\xff%\xff\x84\xfe\x1d\xfd/\xfd\xe4\xfe\x87\x00\n\x01\xe7\x00\x1b\x019\xff6\xfc\x8a\xfb\xd4\xfa\x82\xf9\x87\xfb\xb0\xfc%\xfe\\\xff\x9d\xff\x1d\xff\x18\xfc!\xfb\xe8\xfa\x11\xfc\xfc\xfei\x02\xdf\x04I\x04\xd8\x02\xee\x00Q\xffu\xfe^\xfc\xd0\xf9\xfb\xf9\xea\xfeW\x04\xbe\x05\xb1\x04\x97\x00\xc8\xfa\n\xf9\x15\xfd\xea\x02\xf9\x05\xe0\x06S\x06-\x06\x93\x05\xdf\x01W\xfe\x89\xf9\xd9\xf6\x13\xf9\xfa\xfb\xbf\x01?\t\x01\x0c\x03\x06\xe3\xfc\x99\xf94\xf9.\xfa\xb5\x00P\t\\\x0e\x95\x0f\x82\re\x07\xe6\xfd\xff\xf5\xcb\xf3\x18\xf7\x86\x00\x13\nu\r\xa0\x0ce\x06>\xff\xd4\xfa\xe2\xf8C\xfb\xfb\xfc.\x00\xe5\x04B\x08\xd9\t\xc8\x04\x12\xfc1\xf8\x06\xf7\x85\xf7E\xfc\xaa\x020\x075\x04"\x01\x9d\x01\xce\xfer\xfb\x02\xf8g\xf5l\xf8\x1b\xfb\xab\xfc\xc6\xfcH\x02\xcd\r\x13\x12K\x11D\x0f\xa2\x10\x06\x13\x19\x13=\x15B\x17G\x18\xe8\x16y\x13a\x10P\x0b\xb8\x04\xf4\xfd\x1e\xf9\xe0\xf7\xfb\xf8\xcb\xfa,\xfa\x13\xf8\xbf\xf5\x12\xf5\xb8\xf5\xde\xf5\xb3\xf4$\xf3\x97\xf4|\xf5\x1a\xf4\x10\xf6e\xf8^\xf9\xea\xf7\xa6\xf6p\xf7W\xf8\x98\xfaH\xfcl\xfd\x08\xfe~\xff\xed\xffL\xfe\xf0\xfd_\xfc\\\xf8\r\xf5k\xf4o\xf4\x18\xf4\x1c\xf4C\xf4R\xf3\'\xf2\x05\xf2\xae\xf1\xde\xf1z\xf3M\xf5\x05\xf6\x11\xf7\x8b\xf8K\xf8\xeb\xf6\xd8\xf5\xa5\xf5\xd3\xf59\xf7$\xf9\xee\xf9n\xfa\x10\xfb\x97\xfbj\xfbf\xfb\x0e\xfc\xec\xfc\x9b\xfd@\xfda\xfd\xc0\xfd?\xff\x05\x01\xf8\x01\x9b\x01\x19\x02p\x05\x82\x08\x82\x08@\x07\xcb\x06\xab\x06\xb0\x08\xf7\x0b\x07\r \x0b\x97\nz\x0b\xaa\tr\x03\xbc\x00\xce\x04\x1d\x08\xfa\x08\xdd\nI\x0e\xee\x10\xa9\x0f\x1a\x0b$\x08\x9c\t\x86\x0bz\n\x91\tc\x0bV\r&\x0c\x89\te\x05&\x02\xee\x00?\x00\xac\x00\xc9\xfd.\xf9\xd5\xf9(\x06\xb4\x1d\x9e3\x97:\x994\xe4,i(\xc3#e f!\xc4$A%\xd6!G\x1f\x7f\x1aE\x11r\x01\xe3\xef;\xe3\xc9\xdb\x1b\xdb\x1e\xde@\xe2h\xe4N\xe3\x12\xe2\x96\xe1\xe0\xdf\x10\xdc\x83\xd8~\xd8\x1c\xdd\x10\xe5r\xf0\x00\xfd\x16\x03^\x00\x96\xfb\xd3\xf8\xf4\xf8\xbe\xf88\xfa\xed\xfd\xf2\xff\x11\x02W\x03f\x03R\x01\x14\xfbJ\xf4\x87\xee\xd8\xeb\xbf\xeb\x0c\xebg\xecR\xf0\x0e\xf4Y\xf4\xf0\xf3\xf0\xf4u\xf4e\xf3\xa0\xf4\xad\xfae\x01\xa5\x07\xeb\x0e\xec\x12\xc3\x12\xfe\x12\xf7\x13\xae\x14\xa9\x11c\x0e\xa4\r\xdb\ng\x08\xf6\x05&\x02r\xfd\xf8\xf7\xeb\xf41\xf4\xc3\xf3\x12\xf4\x16\xf3A\xf1\x04\xf1\xf1\xf1\xa7\xf3\xfc\xf4\x82\xf5Q\xf6Y\xf6k\xf6\x14\xf8C\xf9\xd5\xf9R\xf9h\xf9`\xfb\x11\xfd.\xfe\x06\xfed\xfc\xe0\xfa\xce\xf9\xe1\xf8\xef\xf8\xe8\xf8c\xf8\xa7\xf5\x1d\xf1\xf3\xedn\xed\x97\xedO\xed*\xeeA\xf1,\xf5\x07\xf9T\xfc5\xffb\x00\xe9\xff\x82\x01&\x05\x7f\t\xee\x0e\xd8\x11\xfd\x13\x07\x14\xf0\x11\xa6\x0c>\tE\x14\xca.DJ9WJW\xb6S@P\xbbI\xb5A?=\xad;X8\x982\x19.<+\xe7")\x12\xe1\xfaM\xe5\xd0\xd6&\xce\xda\xcbU\xcdC\xd2)\xd8"\xdb\xc5\xdb\xb3\xdb\x8d\xdb+\xda\xda\xd7\x12\xda1\xe4R\xf3\xe5\x01\xa2\x0b6\x10]\x0fz\x0c\xc0\x07\x9b\x03r\x00\x88\xfeL\xfd\xa1\xfb\x03\xfc:\xfcE\xf8\xed\xf0\xd3\xe7\x90\xdfA\xd8\xa4\xd3\xee\xd3X\xd5\xe3\xd7\x8a\xdb>\xe0d\xe4\x15\xe7\x08\xea\xef\xec\xc2\xef\xa6\xf3\xb4\xf9%\x01b\t\\\x11\x05\x17\xf0\x19B\x1c\xaf\x1d\xcd\x1d\xf1\x1b\xb2\x19\xdb\x17g\x16\x96\x17x\x1a\xde\x1a\x0f\x18^\x12\x13\x0b\x97\x02(\xfbQ\xf7Z\xf5\x9c\xf3i\xf3X\xf59\xf8\xeb\xfa\x91\xfc\xdc\xfd\x05\xfe\xc9\xfd\xff\xfe\x00\x02\xd6\x05\x07\t)\x0b\xd2\x0bi\n=\x06h\x00\xf2\xf8\xdc\xf0\x10\xea(\xe5\x83\xe2R\xe1\xf7\xe0\xd8\xe0\xaa\xdfH\xdda\xda\x02\xd8V\xd7\x1a\xd9\x0c\xdd\xfd\xe1-\xe7p\xeb\x8b\xee=\xf1\x85\xf34\xf6\xe9\xf9\x98\xfd\xb7\xffD\x02\x9a\x06\xda\x0c\xb2\x13\xac\x18\xed\x1cF\x1f\xe0\x1fO >#\xa2)\xd30A8\xc5A\xdbK&R\xdaPLJeD\x8b=\xc04H*\xce"\xbc\x1f\xac\x1b\xa3\x14\x12\x0b\xd0\x02\xbb\xfb\xb3\xf2\xdd\xe8\xaa\xe0(\xdc\xe6\xdbB\xdd!\xe0\xe2\xe45\xebn\xf0~\xf2\x91\xf3\x88\xf6g\xfa\x0f\xfc\xf2\xfc2\xff\xd0\x02\xb1\x05\xa9\x07\xc4\x08\xe8\x07\x97\x04\xfc\xff\xeb\xf9\xe7\xf3W\xef\x91\xeb\xd4\xe6I\xe1=\xde\xdc\xdca\xdbj\xd9X\xd8\x83\xd7\x0b\xd6\x83\xd6\t\xdaU\xdf\x87\xe5\xb5\xeb\x07\xf1\xfd\xf5\xf4\xfb\x0b\x03\x05\x083\n\xf2\x0b^\x0e#\x10\xcc\x11\xc7\x13\xd3\x14\x9f\x13\x19\x11\xf7\x0e\x0f\r\xb2\x0b\x00\x0b\xe4\x08-\x068\x04\xd3\x04\xf1\x05\xf3\x05\xb5\x05p\x05]\x05\x92\x05\xe1\x06\xc6\x08x\n\xed\n\x8e\np\n9\x0b\xf9\x0c \x0e\xa4\r\xd6\x0b\xd7\t\xff\x07\xdd\x05-\x03\xc8\xff~\xfb\xaf\xf6G\xf2$\xef\x9e\xec\xce\xe9;\xe6\xae\xe2\x05\xe0\xf9\xde0\xdf\x80\xdfG\xe0\xc7\xe1\xed\xe3Y\xe6T\xe9>\xed\x12\xf1\xb5\xf2+\xf37\xf4\xbf\xf6\xb4\xf8\r\xf8w\xf6\x16\xf6\x9b\xf6\xf8\xf5Y\xf4\xb3\xf3\xdf\xf3\xab\xf3;\xf26\xf1\xb1\xf2\x16\xf6\xce\xf9\xd4\xfd\xe8\x02\xc6\n\x9a\x13\x07\x1d9*\xe7=\xaeR;]\xf5\\\x1a[(_\xd8aJ[\xc6O\xdbH\xc1E\xc5=@0\xed#\x18\x1bT\x0f\n\xfcN\xe7\xeb\xda6\xd6\x9a\xd1!\xca\xd1\xc6\xcb\xca\xf3\xd04\xd4#\xd79\xddZ\xe3\xb0\xe6A\xe9\xfd\xee\xf7\xf7\xa9\x01\xac\x08\x11\r\x9c\x10\x8d\x14\\\x16\x14\x14s\x0f?\n\x97\x02\x92\xf9\xcb\xf2@\xee6\xe9\xc2\xe2\xb1\xdd\xae\xd9\xd7\xd4\x07\xd1\xcf\xcf\x15\xcf\x05\xce\x00\xce\x01\xd1\xf9\xd6\n\xdf]\xe8\x91\xf0k\xf7^\xfe\xae\x05\xb9\x0b}\x10\xf7\x14/\x19\xf8\x1aU\x1b&\x1d7 \xfa!\x94 z\x1c\xa6\x18\xe8\x14\xe3\x11<\r\x82\x08]\x04Z\x01\x1e\xff\xd6\xfcH\xfd`\xffb\x00t\xffY\xff\x04\x03\x11\x07\xaf\x08g\t\x80\n\xf9\x0b\x08\x0cN\x0cu\r\xbd\r\xdf\x0cZ\n\xea\x07\xba\x05\xf9\x03\xfc\x00\xa5\xfbX\xf6*\xf2\x1b\xef\x18\xec\xbe\xe96\xe8~\xe6\xba\xe4.\xe3\xa8\xe2c\xe2\xfc\xe1\xf3\xe1&\xe2\x8b\xe3\x9c\xe6\xbf\xeak\xee\xed\xf0\x04\xf3\xc5\xf4\x95\xf5\x8c\xf5\xae\xf5\xe8\xf5\xbf\xf4\x9a\xf2\xd2\xf0*\xf1\xbf\xf2i\xf4\r\xf5\xf7\xf4\x05\xf5y\xf6u\xf9K\xfc#\xff\xc5\x02q\x06@\n\xba\x104\x1b\x91\'\xf93\x04C#U\xa0a5c{`\xafa\x15d\xcc]GQ\x85G\xedB\xb5;\xa1.B"Q\x18 \x0c+\xfa&\xe7\xad\xd9\xcd\xd1\xd6\xc9\xb3\xc0\xb5\xba\x9f\xbc\x86\xc3j\xc9\x11\xcdl\xd2\xe0\xd9\x89\xdfZ\xe3\xf0\xe8\xe4\xf1\x10\xfap\xff\t\x04y\n\xf1\x11)\x17\\\x17\x01\x14\x97\x0f\x96\n\xc3\x02\x0f\xf9l\xf1B\xec\xa6\xe6\x93\xdf\xe3\xda\xe3\xd9v\xd9\x14\xd7\x0f\xd4"\xd2\xc0\xd1\x11\xd3\x01\xd6\x8e\xda\xd7\xe0\xb9\xe8\xd0\xf0\x1d\xf9l\x02\xd7\x0b\x07\x13a\x17\x03\x1a\x9d\x1c\xa4\x1ej \xc0!N"\xd2!\xb8 \xaf\x1e\x90\x1c,\x1a#\x16K\x0fA\x07@\x01\x92\xfdV\xfa>\xf8=\xf8\x17\xf9\xe3\xf8\x1e\xf9<\xfc\xa8\xff\xca\xff_\xfd5\xfd\xf0\xff\x9a\x02\xf9\x04\x14\x08\xd7\x0b\xe3\r\xac\x0eR\x0f-\x0f\x0f\ru\x08\xae\x02\xeb\xfc\xcf\xf8\x92\xf5\'\xf2O\xee\xc4\xeav\xe8e\xe65\xe4!\xe2a\xe0\xd2\xde9\xdde\xddz\xe0$\xe5\xc6\xe9\xb3\xed\xf6\xf1\x03\xf7\x85\xfb\x04\xff#\x01\'\x02\x93\x02\x9b\x02\xca\x021\x03(\x04\x95\x04r\x03:\x01\xfe\xfe\xf3\xfc[\xfa\xe4\xf6\xa4\xf3\xb4\xf1%\xf1\xed\xf1\xad\xf3\xf4\xf6\xae\xfa\xb7\xfd?\x00\x99\x02<\x06\xa6\x0b\xa3\x13\x86 \x9f1\xf2AJL\x13R>YKaxc\xe6\\\xdfS\xd7M\xe0H\x1b@\xea5\xcf-\xab%_\x19\x8e\t\xd4\xfb\x90\xf0\x99\xe4\x87\xd5\xc4\xc6\xa6\xbc\xd4\xb7\xd9\xb5\x98\xb5a\xb7=\xbc\xb7\xc2;\xc9"\xd0\t\xd8\xf6\xdf\xbc\xe5Y\xea\x85\xf07\xf9\x1b\x03\xeb\x0b\xb8\x12\xbd\x17\xa9\x1b\x9a\x1e&\x1f\xeb\x1cd\x17\xe7\x0f\xda\x06\xc9\xfd^\xf6o\xf0k\xebg\xe66\xe2N\xdf\xa9\xdd\xee\xdc\xab\xdc\xf6\xdb\x88\xdb\x8e\xdcg\xdfN\xe4\xfe\xea\xdf\xf2\xf4\xfa\xb6\x02\xa3\n\xb8\x12\xbc\x19$\x1f\xab!a"v!S +\x1f\xb2\x1d\xca\x1a-\x17\xf5\x12\x8d\x0fx\x0c\x9c\x08v\x04\xee\xfe:\xfa\xd1\xf5B\xf2o\xf0A\xf0n\xf1s\xf2t\xf3 \xf6\x97\xfa\xca\xfe\x96\x01\xac\x02\xc7\x03\x1e\x05E\x06v\x067\x06\x17\x06\x03\x06H\x05\x9f\x04\xce\x04b\x04i\x02\x92\xfe\x11\xfb[\xf8\x95\xf5\xd0\xf2\x19\xf0\x14\xeeC\xed\xb2\xed$\xef\xef\xf0\xb1\xf2t\xf4\xf6\xf5"\xf7N\xf8\x04\xfa\xb6\xfb\xc4\xfc\x7f\xfd\xe8\xfe\x99\x013\x04_\x05[\x05v\x04\x0b\x03\x8b\x00\xce\xfd\xaf\xfb\x04\xfa\x83\xf8\x84\xf6\x19\xf56\xf5[\xf6`\xf7S\xf7\xaf\xf6\x8d\xf6\xf4\xf6\xd4\xf7\x08\xf9\xb2\xfa`\xfc\x88\xfdP\xff:\x02\x9e\x05\xb0\tO\x10\x03\x1a\x7f#_*%1\xdf9\xc7A\xd5C\xecA\x8b@J@\xe2<\xa35\x85.\x19)}"\x08\x19\xe2\x0f\xa8\x08g\x01E\xf7#\xecU\xe3\xc6\xdcu\xd6\xec\xcf\x1f\xcb\xa9\xc9~\xca/\xcc\xe4\xceT\xd46\xdb\x10\xe14\xe6\x14\xec\xcc\xf2\x9e\xf8_\xfdI\x02\xfe\x06\xc2\n\x9b\x0e\xb2\x12}\x15\xc8\x15;\x15\\\x14\n\x12\xad\rr\x08D\x03W\xfd\xad\xf6\x08\xf1\x1d\xed!\xea7\xe7@\xe4\x9a\xe2b\xe2\r\xe3\xbb\xe3\x83\xe4e\xe5\x9e\xe6\xe0\xe8\x11\xec\r\xf0!\xf4@\xf8d\xfc\x94\x00\x88\x04x\x08\xb3\x0b\xab\r\xe0\r\xf7\r\xf8\r*\x0e\xec\r\xf4\x0c\x8d\x0c\xa1\x0b\x06\x0bR\n\x92\t%\t?\x08\x11\x07V\x05\xfd\x03\xb1\x03\xc5\x03\x0c\x04\x1c\x04\x18\x04X\x04\xb6\x04t\x05\xa8\x05\x7f\x05\xeb\x04\x11\x04\x13\x03%\x02\xe0\x01\x85\x01\xe1\x00\xfd\xff@\xff\xf7\xfe\x8f\xfe\x0b\xfe>\xfdQ\xfc\xa4\xfbE\xfbM\xfb(\xfb\x1d\xfbp\xfb\xd7\xfb\'\xfc4\xfcU\xfcC\xfc\xd5\xfb2\xfb\xd3\xfa\xf8\xfa$\xfb\xf4\xfa\xc2\xfa\xb1\xfa\xda\xfa\xb8\xfaI\xfa\xd6\xf9X\xf9\xc2\xf8\xcf\xf7\xd6\xf67\xf6\xd6\xf5\x81\xf5\x00\xf5\xa9\xf4\xc7\xf4\x00\xf5\xc8\xf4I\xf4\xfa\xf3\x05\xf4\xf4\xf3-\xf4K\xf5z\xf7\x18\xfa\x86\xfc\xe2\xfe9\x01\x9a\x03\xfb\x05N\x08.\x0b\xc5\x0fR\x16k\x1dV#\x16()-\x952\xdb6X8G8\xe97\x037\x0e4\xfb/\x8c,\xe7)\xea%\xc1\x1f\x18\x19x\x13m\x0e\xe4\x07\xa9\x00\xea\xf9\x02\xf4\x1c\xee\x1c\xe8/\xe3\x16\xe0\x08\xde/\xdc\xda\xda\xee\xdaj\xdc)\xde\xaf\xdfD\xe1\xa9\xe3G\xe6\xd3\xe8c\xeb\xe4\xedE\xf0\x9b\xf2\xf0\xf4\x17\xf7\xb3\xf8%\xfae\xfb\xdd\xfb\x95\xfb\n\xfb\xb5\xfa\x07\xfa\x8f\xf8\x07\xf7\xcc\xf5\xed\xf4A\xf4\xae\xf3\x96\xf3\xdf\xf3F\xf4\xe7\xf4\xdf\xf5\x0b\xf7;\xf8\x7f\xf9\xd8\xfaM\xfc\xd3\xfdf\xff\xfa\x00\x81\x02\xd5\x03\x17\x05<\x06\x15\x07\xa3\x07\x06\x080\x08/\x08&\x08\xf3\x07\xf2\x07\x10\x08R\x08\xc5\x08\x8e\t\x95\n\xcc\x0b\x1f\r9\x0e\x1a\x0f\x80\x0f\xb9\x0f\xb8\x0f\x80\x0f\xbf\x0e\xdf\r\xe7\x0c\xd2\x0bx\n\x9b\x08\xc3\x06\xed\x04\xeb\x02w\x00\xe1\xfd~\xfbM\xf94\xf7#\xf5W\xf3\xfe\xf1\xf0\xf0\x10\xf0u\xefO\xefj\xef\x85\xef\xb0\xef2\xf0!\xf1\x12\xf2\xd5\xf2x\xf3U\xf4J\xf51\xf6\xf3\xf6\xcb\xf7\xbd\xf8\x8f\xf9&\xfa\x9f\xfa-\xfb\xad\xfb\xea\xfb\xfa\xfb*\xfc\x8b\xfc\x07\xfd~\xfd\xd9\xfdI\xfe\x8e\xfe\xcd\xfe\x16\xffl\xff\xcc\xff!\x00\x7f\x00\xf7\x00X\x01\xb6\x01\x15\x02\x94\x02\'\x03\x91\x03\x1a\x04\xf1\x04\x06\x06\x1b\x07\xfd\x07\xda\x08\r\n1\x0b\xdb\x0b\x19\x0c\x8c\x0cl\r[\x0e^\x0f\xdb\x10\x14\x13(\x15G\x16\xb4\x16b\x17z\x18)\x19\x0c\x19\xe4\x18\xf8\x18\xa6\x18l\x17\xe5\x15\x1a\x15\x81\x14\xe8\x124\x10T\r\xda\n\x0b\x08\x87\x04\x00\x01\xdd\xfd\xf4\xfa\xc2\xf7\x9e\xf4-\xf2\x81\xf0!\xef\x9f\xed \xec\xd6\xea\xe5\xe9=\xe9\xc3\xe8\xb3\xe8\xe1\xe8*\xe9/\xe9A\xe9\xf4\xe9>\xeb\xd6\xecR\xee\xa3\xef\x12\xf1\x9b\xf2\x13\xf4\x99\xf5\x16\xf7\x8d\xf8\xd3\xf9\xda\xfa\xbf\xfb\xcf\xfc%\xfe\x8b\xff\xb9\x00\xbe\x01\xbe\x02\xae\x03v\x04\xe6\x043\x05s\x05e\x05\xfb\x04K\x04\xb1\x03.\x03\x88\x02\xb7\x01\xe5\x00j\x00\x1e\x00\xbc\xffS\xffE\xffy\xffv\xff\'\xff\x1a\xffo\xff\xdf\xff\'\x00\x96\x00f\x01w\x02k\x03T\x04_\x05t\x06\x8d\x07J\x08\x96\x08\xe3\x08%\t4\t\xe8\x08v\x08#\x08\xad\x07\xbb\x06e\x05\x14\x04\xe8\x02\x93\x01\xda\xff!\xfe\xa3\xfcW\xfb\x04\xfa\xd9\xf8.\xf8\xfa\xf7\xcf\xf7\x84\xf7[\xf7\x99\xf7\x18\xf8G\xf8V\xf8\x93\xf8\xee\xf82\xf9O\xf9\x8b\xf9!\xfa\xc1\xfa\x0c\xfb*\xfbw\xfb\xfb\xfbH\xfcB\xfc%\xfc)\xfc\x1e\xfc\xca\xfb\x94\xfb\xab\xfb\x06\xfcA\xfcA\xfcX\xfc\xac\xfc\x1d\xfdU\xfd\x83\xfd\xce\xfd \xfe(\xfe\x17\xfeU\xfe\xca\xfe.\xffj\xff\xb9\xff^\x00\x1d\x01\xaa\x01+\x02\xcc\x02l\x03\xc8\x03\x13\x04\xeb\x04e\x06\xd8\x07I\t\r\x0b\x1a\r\x06\x0f\xb5\x10\xe9\x12\xa6\x15\xe0\x17\n\x19P\x1a\'\x1c\xe1\x1d\xa8\x1e\x1b\x1f\x05 h +\x1f\xfd\x1cD\x1b\xc6\x19(\x17\x19\x13\x03\x0f\x89\x0b\xd9\x07Y\x03\x08\xff\xac\xfb\xe9\xf8\x95\xf5\x01\xf27\xef\x91\xed,\xecK\xea{\xe8x\xe7\x0e\xe7d\xe6\x85\xe5\x83\xe5q\xe6v\xe7\x04\xe8\xb8\xe8a\xeab\xec\xf6\xed2\xef\xbd\xf0\x8b\xf2\xf2\xf3\xe2\xf4\xce\xf5\x06\xf7R\xf8(\xf9\xc9\xf9\x9a\xfa\x92\xfb=\xfcv\xfc\xbe\xfcM\xfd\x95\xfdU\xfd\xe0\xfc\x93\xfc[\xfc\xdd\xfbe\xfb\x17\xfb\xfa\xfa\n\xfb\x1c\xfb]\xfb\xdc\xfb\x8d\xfcY\xfd\n\xfe\xd0\xfe\xcc\xff\xef\x00\x08\x02\x06\x03#\x04{\x05\xd6\x06\x01\x08\x12\tB\nu\x0bz\x0c`\r3\x0e\xd0\x0e*\x0f%\x0f\xe3\x0ee\x0e\xaf\r\xd0\x0c\xcf\x0b\xb2\n\x83\t^\x08O\x07N\x065\x05 \x04+\x03%\x02\xf3\x00\xa7\xffZ\xfe\x0b\xfd\xa4\xfbN\xfaJ\xf9P\xf8\\\xf7\x9f\xf61\xf6\xf4\xf5\xa1\xf5:\xf5\xf6\xf4\xa9\xf4/\xf4\xb2\xf3E\xf3\x01\xf3\xd8\xf2\xba\xf2\xe9\xf2S\xf3\xd9\xf3p\xf4\xfe\xf4\xab\xf5C\xf6\xb4\xf6\x12\xf7d\xf7\xc0\xf7\x1b\xf8u\xf8\xf8\xf8\xab\xf9\x98\xfa\x8f\xfb\x88\xfc\x83\xfd}\xfeY\xff\x05\x00\xd5\x00\xdb\x01\xde\x02\x7f\x03\x15\x04\x0c\x05*\x06\x11\x07\xe7\x07\x05\t\x7f\n\xc5\x0b\xd9\x0ce\x0e\xbf\x10/\x13Y\x15\xba\x17\x87\x1a>\x1d\x02\x1f\x88 J"\xdb#V$n$\xf1$\\%\xc4$R#\xf1!k \xc4\x1d\x00\x1a\x18\x166\x12\xc4\r\xa9\x08\xc4\x03\x87\xff\x96\xfbo\xf7~\xf3\x1f\xf0N\xed\xb8\xeaT\xe8U\xe6\xbf\xe4\x83\xe3I\xe2/\xe1\xb6\xe0\xd0\xe0F\xe1\xa6\xe1[\xe2}\xe3\xce\xe4%\xe6z\xe7\x19\xe9\xab\xea\xcb\xeb\xc3\xec\xe2\xed2\xef\xa4\xf0\xff\xf1g\xf3\xfa\xf4\xa5\xf69\xf8\xb2\xf92\xfb\xba\xfc\t\xfe\x0c\xff\xeb\xff\xde\x00\xd1\x01\x92\x02.\x03\xaf\x034\x04\x8f\x04\xad\x04\xba\x04\xd9\x04\xfa\x04\xce\x04m\x04\x12\x04\xe8\x03\xce\x03\x8d\x03y\x03\xb9\x03/\x04\xab\x04\x19\x05\xc3\x05\xa6\x06h\x07\xe2\x07e\x087\t\xfe\tv\n\xd0\nq\x0bM\x0c\xdf\x0c\x0b\r=\rV\r\xe4\x0c\xbc\x0bD\n\xda\x08G\x07Y\x05\x8e\x03,\x02\x0f\x01\xee\xff\xdb\xfe\xee\xfd\xff\xfc\x06\xfc\xdd\xfa\xaa\xf9e\xf8\x15\xf7\xcf\xf5\x90\xf4\x8a\xf3\xdb\xf2\x82\xf2!\xf2\xd6\xf1\xe5\xf1+\xf2u\xf2\x8d\xf2\xaa\xf2\xcf\xf2\xdb\xf2\xd9\xf2\xed\xf2M\xf3\xd7\xf3y\xf4I\xf5[\xf6{\xf7e\xf80\xf9\xea\xf9\x99\xfa\xfc\xfa\'\xfbd\xfb\xaf\xfb#\xfc\xaf\xfcl\xfdb\xfe5\xff\xc4\xffF\x00\xe6\x00l\x01\xc4\x01\x0e\x02\x9c\x02Z\x03\x00\x04\x0b\x05\xe6\x06&\t\t\x0b\xc5\x0c\xfc\x0e\x8b\x11\xd5\x13\xcc\x15!\x18\xbe\x1a\xce\x1c%\x1e\x98\x1f\x95!?#\xd2#\xd6#&$G$!#\xf6 \xe8\x1e\xe3\x1c\xc1\x19\x8e\x15\x90\x11X\x0e\xd3\np\x06M\x02\t\xff\xf7\xfbN\xf8\x87\xf4\x94\xf1\x1b\xefC\xecO\xe9\xe9\xe63\xe5\xce\xe3s\xe2}\xe1e\xe1\x85\xe1\xbd\xe1\xfa\xe1\x9d\xe2\xcd\xe3\xf2\xe4\x03\xe63\xe7\xb5\xe8f\xea%\xec\x17\xee^\xf0\xc6\xf2\x0f\xf5>\xf7\x84\xf9\x96\xfbu\xfdP\xff\x02\x01\x94\x02\xd2\x03\xda\x04\xc0\x05p\x06\xd5\x06\x02\x07\x08\x07\xd5\x06\x8a\x06\x12\x06_\x05\x93\x04\xd0\x03\x08\x03\x1e\x02-\x01u\x00\x00\x00\x87\xff\xfb\xfe\xb7\xfe\xeb\xfeG\xff\x81\xff\xf6\xff\xf0\x00\'\x02\x15\x03\xfd\x03B\x05\x9b\x06\xb1\x07l\x08D\tK\n\x1c\x0b{\x0b\xc1\x0b\x07\x0c\x18\x0c\xcf\x0bQ\x0b\xce\n@\n~\tb\x08\x1a\x07\xc3\x05Y\x04\xb7\x02\xfc\x00_\xff\xf5\xfd\xa4\xfcZ\xfb+\xfaG\xf9\x91\xf8\xee\xf7S\xf7\xd7\xf6\x88\xf6?\xf6\xf3\xf5\xbc\xf5\xb4\xf5\xc9\xf5\xee\xf5+\xf6\x9b\xf66\xf7\xcb\xf7P\xf8\xcf\xf8\\\xf9\xc2\xf9\x02\xfa6\xfaf\xfa\x8d\xfa\xa5\xfa\xd2\xfa\x10\xfb\\\xfb\x98\xfb\xcf\xfb\x0e\xfc.\xfc4\xfc\x07\xfc\xbf\xfby\xfb\x1f\xfb\xbf\xfan\xfaG\xfa+\xfa\x00\xfa\xfe\xf9u\xfaB\xfb\r\xfc\xc5\xfc\xeb\xfds\xff\x11\x01\xe5\x02f\x05\xb0\x08\xf8\x0b\x9d\x0eB\x11}\x14\xa3\x17\x1e\x1a"\x1c\xa5\x1e&!h"\xa0"K#~$\xa8$N#\xd2!\xe4 (\x1f\x8d\x1b\x92\x17\xac\x14\xc5\x11b\r,\x08\x17\x04\xe8\x00\x1d\xfd\xa1\xf8\x1b\xf5\xcf\xf2u\xf0I\xedQ\xea\x9c\xe8\x90\xe7\x1f\xe6\x89\xe4\xb6\xe3\xe2\xe3\x1d\xe4\xf3\xe3A\xe4\xaa\xe5q\xe7\xae\xe8\xba\xe9J\xebO\xed\xfe\xee>\xf0\xc4\xf1\xb2\xf3h\xf5\xa0\xf6\xe4\xf7|\xf91\xfb\x9d\xfc\xce\xfd\x10\xff4\x009\x01\x06\x02\xab\x02@\x03\xa6\x03\xf5\x03\x15\x04\x0f\x04\xf7\x03\xd0\x03\xa0\x03w\x03\'\x03\xc1\x02T\x02\xe9\x01\x96\x01\x16\x01\xc3\x00\xb1\x00\xac\x00\x96\x00\x80\x00\xd7\x00h\x01\xd5\x01P\x02\'\x03\x13\x04\xcd\x04g\x05#\x06\xed\x06\x84\x07\xfb\x07h\x08\xc8\x08\xe9\x08\xc4\x08y\x08\x04\x08u\x07\xc3\x06\xe1\x05\xee\x04\xf4\x03\xed\x02\xc7\x01\x91\x00r\xfft\xfel\xfdj\xfc\x84\xfb\xb4\xfa\xe3\xf9,\xf9\x95\xf8$\xf8\xef\xf7\xc7\xf7\xbd\xf7\xd2\xf7*\xf8\xa3\xf8\x15\xf9\x81\xf9\xf1\xf9\x96\xfa"\xfb\x95\xfb\x1d\xfc\xd2\xfc\x99\xfd!\xfe\xb1\xfer\xff*\x00\xca\x00%\x01\x9c\x01\x03\x02@\x02O\x02R\x02\x82\x02\xa3\x02\x96\x02u\x02\x81\x02\x9f\x02\xa4\x02\x94\x02\x92\x02\x9b\x02m\x02\x1a\x02\xd1\x01\x96\x01T\x01\xe9\x00s\x00\x06\x00\x94\xff\x16\xff\xa2\xfe\x0b\xfen\xfd\xe0\xfcF\xfc\x91\xfb\xcc\xfa!\xfa\xa0\xf93\xf9\xe2\xf8\r\xf9\xa7\xf9d\xfa)\xfbB\xfc\xdc\xfd\xbb\xffj\x01-\x03<\x05t\x07X\t\x08\x0b\xe1\x0c\x06\x0f\x00\x11U\x12\x7f\x13\xcf\x14%\x16\xd7\x16\xe7\x16\xea\x16\xcd\x16\x05\x16]\x14x\x12\xd3\x10\xff\x0e\x8d\x0c\xc8\ta\x07@\x05\xde\x02)\x00\xd3\xfd\xfc\xfbF\xfa=\xf8-\xf6\xcb\xf4\xdd\xf3\xec\xf2\x0c\xf2\xb1\xf1\x04\xf2`\xf2\x9d\xf2\x1e\xf32\xf4Y\xf5\x1e\xf6\xc6\xf6\xb7\xf7\xa2\xf8\x1e\xf9a\xf9\xda\xf9\x83\xfa\xd6\xfa\xee\xfa\x1a\xfb`\xfbt\xfb\\\xfbO\xfbH\xfb#\xfb\xcc\xfat\xfa6\xfa\xf2\xf9\xba\xf9}\xf9L\xf9\x1e\xf9\xe7\xf8\xda\xf8\xfe\xf8:\xf9l\xf9\x9d\xf9\xc4\xf9\x11\xfaz\xfa\xf1\xfa\x92\xfbG\xfc\xef\xfc\x87\xfdN\xfe<\xffA\x00:\x01"\x02\xff\x02\xd8\x03\x9c\x04E\x05\xe4\x05z\x06\xe3\x06\x16\x070\x07I\x07a\x07g\x07S\x07\x1f\x07\xca\x06\x7f\x06\x15\x06\x98\x05+\x05\xaf\x04*\x04\x8d\x03\xf4\x02u\x02\xf8\x01\x8b\x010\x01\xce\x00o\x00+\x00\xf6\xff\xcb\xff\x99\xff\x82\xffn\xffM\xff&\xff\x0f\xff\r\xff\xf5\xfe\xdc\xfe\xe9\xfe\x0e\xff;\xffc\xff\xbc\xff#\x00A\x001\x00S\x00\x9c\x00\xc5\x00\xab\x00\xba\x00\xfd\x00\x00\x01\xcf\x00\xc3\x00\x04\x012\x01\xd0\x00J\x00\x0c\x00\xd8\xff^\xff\xa9\xfe.\xfe\xd7\xfdP\xfd_\xfc\xa1\xfbM\xfb*\xfb\xe2\xfaY\xfa\xfe\xf9\xe8\xf9\xdd\xf9\xb7\xf9\xb0\xf9\x1a\xfa\xa2\xfa\x01\xfbB\xfb\xe7\xfb\xfb\xfc\x02\xfe\xbe\xfeR\xff\x03\x00\xd4\x00\x94\x01\x0c\x02o\x02\xcb\x02\x0c\x03\xf1\x02\xa1\x02j\x02<\x02\xe4\x01I\x01\x99\x00\x1b\x00\xaa\xffO\xff\xd0\xfeS\xfe\xf0\xfd\xa6\xfdi\xfdE\xfdQ\xfd\xa7\xfdX\xfe7\xff7\x00w\x01\xbc\x02\xc8\x03\xb6\x04\xb8\x05\xd5\x06\xd8\x07\xa1\x08\x8a\t\x9a\n\xca\x0b\xa0\x0c*\r\xd3\r\x1e\x0e\xe6\rp\r\xd3\x0cG\x0co\x0b~\n\xcc\t\x19\t=\x08]\x07\x9d\x06\xc0\x05\x98\x04D\x031\x02!\x01t\xff\xf7\xfd\x19\xfd2\xfc\xd9\xfaM\xf9\x87\xf8F\xf8!\xf7\xb6\xf5{\xf5d\xf5h\xf4n\xf3I\xf3t\xf3\xb6\xf2=\xf2\xc6\xf2M\xf3\xb6\xf3[\xf4\x88\xf5\x9a\xf6\x11\xf7\x9e\xf7\x9f\xf8\x95\xf9T\xfaO\xfb\x91\xfc\xed\xfdJ\xff\xc1\x00\xff\x01\xb1\x02W\x03\xe0\x03\xcf\x03\xbf\x03\xa8\x03:\x03\xeb\x02\'\x03?\x03\xd4\x02o\x02\x03\x02\x12\x01\xe9\xff\x10\xffA\xfej\xfd5\xfdt\xfd\xc5\xfdf\xfe\x8e\xff\xa4\x00\xfe\x00"\x01f\x01O\x01F\x01\x81\x01\xe8\x01\xac\x02\x97\x03~\x04K\x05\xdf\x05d\x06\x06\x06C\x05\x8d\x04\xae\x03\xf9\x02e\x02%\x02\'\x02\x06\x02\xe3\x01t\x01\x17\x01\xe6\x00\x19\x00\x16\xffW\xfe\xed\xfd\xb8\xfd\xa0\xfd\xba\xfd\xfd\xfd9\xfeX\xfep\xfe\x9c\xfe\xa0\xfe\x92\xfer\xfe?\xfe8\xfe\x9e\xfeu\xffh\xff4\xff\x9b\xff\xfc\xff\xf3\xff\xff\xff\x95\x00\xf5\x00\xb5\x00\x99\x00\xf1\x00\x1f\x01\'\x01\xde\x00~\x00\x8e\x00\x02\x01P\x01`\x01\xac\x01\xc7\x01\xf6\x00\x04\x00\x0e\x00\x08\x00K\xff\xd6\xfe \xffU\xffk\xff\xab\xff0\xff\xf3\xfc\x17\xfb\xfb\xf9\x86\xf8~\xf9w\x01\xbd\t\xce\x08I\x04D\x05Q\x088\x04b\xfeR\xfe\\\x00\xb9\x00\xfa\x00\xb8\x03&\x05p\x02\xef\xfc\xe7\xf7Y\xf6\xc2\xf70\xf9\xfa\xf8\xa5\xfa#\xff\x8e\x03C\x051\x06\x97\x07\x92\x05\xad\x00\xfc\xfe\xce\x01q\x05\xd3\x07\x15\x08+\x07\x91\x05\xaf\x03\xaf\xff~\xf8B\xf4\x82\xf3\xfe\xf1Q\xf1\xcf\xf3\\\xf7\xab\xf6\xa3\xf3,\xf1\xff\xed\xd6\xec\xdc\xedU\xef\x10\xf0\xa9\xf3\xb1\xf8\xd2\xfa_\xfa\x1e\xfa\xd5\xf9\xb2\xf7=\xf5\xb5\xf6U\xfb\x83\xff\x17\x05\xfb\x0f,\x1d\xdd!\x8a\x1eo\x1d\xd6\x1f\x85 \x04\x1f\xcb\x1e\xc4\x1fx\x1fV\x1f\xb4\x1c\r\x16l\r\xa4\x03\x1d\xfa\xca\xf1\x91\xed\xa0\xeb\xfe\xe9\xb0\xe9\xae\xeb\xc3\xed\x83\xef\xd4\xf0\xc0\xf0\xe8\xf0\xb0\xf3K\xf9\x90\xff\x08\x05\xb8\tk\rt\x0e\xd4\r\xf7\x0c\x19\x0b\xed\x07:\x04\x85\x02q\x03\xee\x03\x81\x02;\xffF\xfb\x9d\xf8\xbc\xf6g\xf4\xe0\xf1\x9f\xf1\x91\xf3\x00\xf5\x8e\xf6\xad\xf9x\xfb\xf3\xf9\xe4\xf8{\xfa0\xfc\x8e\xfc\xfd\xfc+\xfeX\xff\xe0\x00\xbd\x02\xde\x02\x13\x01\xfb\xfey\xfd\xd9\xfcm\xfdZ\xfe\x90\xfe\xdb\xfe\xeb\xffw\x01\x08\x02m\x01?\x00:\xff\x87\xff\xf9\x00\xcb\x02(\x04\xb4\x04\x85\x04\xc1\x04~\x05\x03\x06\xc4\x04\xf5\x02i\x02"\x03\xac\x04\x80\x05[\x05\x8d\x04n\x03\x9c\x02\x06\x02\xa4\x01\xeb\x00t\xff:\xfe`\xfe\x81\xff\xc0\xffC\xfeA\xfcK\xfb?\xfb#\xfb\xea\xfa\xda\xfa\xb4\xfao\xfav\xfa \xfb\x12\xfc\xcc\xfc\xc8\xfc\x16\xfd\xcf\xfeK\x01\x1e\x03{\x03\x96\x03R\x04u\x05\x80\x06\x00\x07s\x07.\x07\x14\x06K\x05\xc9\x04C\x04\xcb\x02\xc3\x00v\xff\xb6\xfe\x06\xff\x04\xff\xf6\xfd\r\xfd\x94\xfc\xaf\xfc\xe1\xfc\xf6\xfc\xaa\xfd\x10\xfe!\xfe\xe0\xfe,\x00|\x01\x15\x01\xee\xff)\xff\x07\xff\x92\xff\xc6\xff\xab\xff%\xff\x88\xff\x13\x00\x7f\x00\xd2\x00\x05\x01\n\x01\xbe\x00X\x01\x97\x02l\x03\xaf\x03\xad\x03\xdc\x03Z\x04\xd1\x04k\x04\x1c\x037\x01\xa3\xff\x9b\xfep\xfec\xfe\r\xfd\x80\xfb\xbf\xfa\x87\xfa0\xf9*\xf7\x1f\xf6\xb5\xf5\xf6\xf5\x07\xf7\xed\xf8\xf1\xf9\t\xfak\xfaM\xfbQ\xfc\x18\xfdb\xfd*\xfe\xc2\xff\x0e\x02\x9d\x03\xee\x03[\x04\xa7\x04,\x04F\x03\xb1\x02\x0b\x03d\x032\x03W\x03\xda\x03"\x04\xb9\x02\x86\x00@\xffP\xff\xfb\xff\'\x01&\x02#\x02\xc3\x01\x0b\x01;\x01\xe4\x013\x02\x90\x01G\x00/\x02\x0e\x063\x07\xb0\x03\x00\x00\xbc\x01\x12\x04t\x01\x83\xfc\xc4\xfbr\xfe\x96\xff\xc7\xfe\xc5\xfe\xa8\xff5\xff\x02\xffU\x00Z\x01\xec\xff\x8e\xfe\x90\x00[\x04\xf1\x04\xf1\x00\x83\xfc\xee\xfaS\xfcz\xfdL\xfc\x02\xfa\x18\xf9\x96\xfb\x84\xff\xe0\x00\x91\xfe\xe9\xfb<\xfet\x02b\x03>\x01\xdc\x00\xcb\x01\x0e\x00\xc7\xfd)\xfe\xfb\xff9\xff`\xfd\x02\xfd\xc2\xfd\xf7\xfd\x99\xfd[\xfe\xfe\xff_\x02\xf9\x03\xc4\x04}\x05\xb4\x06s\x07?\x06\xa4\x04\x06\x05\x00\tq\r\x1e\x0e\x99\n\xe9\x05\xe7\x03\x06\x03\xd8\x01\x03\x01F\x01b\x01\xda\xff\x17\xffR\x01\x95\x01\x86\xfb\x1f\xf5s\xf6\xe3\xfbO\xfc\xd5\xf8\x96\xf6\xe2\xf6b\xf8\xc0\xf9M\xfa\xa6\xf8\xf3\xf6\\\xf7\xa0\xf86\xfa\x82\xfby\xfd4\x01\xd9\x05x\x08u\x07\xe1\x05\xc1\x04\x0e\x03\x11\x02$\x03\x9c\x04.\x04$\x03\xf5\x01\x0f\x00\xab\xfd`\xfbu\xf9\x8d\xf8>\xf9S\xfa\xdd\xfa\xe8\xfb\xa0\xfd:\xfe\xba\xfd\xaa\xfd\xc0\xfe\x1a\x00q\x00\x9c\x00\x96\x01\xb7\x03\x1c\x05\xa5\x04\xa1\x04I\x05\x83\x04\x83\x02U\x01\xa2\x01=\x02\x87\x02\xef\x01\xd3\x00\xad\x00>\x01\xd6\x00\xfc\xff\x19\x00\x92\x00r\x00\x15\x00p\x00:\x01\x8f\x01\x8b\x01q\x01\x8d\x01c\x01\xaf\x00\xf3\xff\x90\xffy\xff\x91\xff\xac\xff\x87\xfft\xffN\xff\xed\xfe\x9e\xfeh\xfe9\xfe\x19\xfeL\xfe\x80\xfeF\xfe\xd9\xfd\x8d\xfd\x95\xfd\xc2\xfd\x04\xfe/\xfe\x06\xfe\xe6\xfd\x16\xfel\xfe\xc3\xfe\x14\xff\xc8\xff\x8f\x00j\x01S\x02\xd5\x02\xf3\x02\xcb\x02z\x02\x1b\x02\xc2\x01\xb6\x01\x8a\x01\x11\x01@\x01\xc8\x01N\x01\x0b\x00\x11\xff\x82\xfe\x93\xfd\xa5\xfc\xa0\xfcA\xfct\xfd\x0f\x021\x07\xf7\x08\x8d\x06w\x04\x00\x03Z\x01\x1c\x01\xa1\x02e\x04^\x059\x06M\x06^\x05\'\x03\\\xff\xa4\xfct\xfc\x8e\xfe\x9e\x00\x01\x02\x1d\x03\xf7\x01I\xff\xd5\xfd\x19\xfd\xeb\xfb\xd0\xf9\n\xf8\x9e\xf7k\xf8.\xfa\xd7\xfae\xf9-\xf8\xa7\xf7\x1d\xf78\xf7f\xf8\xa1\xf9\xf1\xf9\xb8\xfan\xfc\x12\xfeL\xfee\xfd)\xfc\xf8\xfa\xa8\xfa\xe0\xfaU\xfb.\xfb\x9c\xfa\xa6\xf9:\xf8\xd5\xf7-\xf9\xc9\xf8\x90\xf4\x96\xef)\xef$\xf1*\xf2\xe4\xf1)\xf2\xc1\xf3\xd0\xf3U\xf2\x19\xf2\x18\xf5\xdf\xf9\xd8\xfd\x90\x02_\tj\x11A\x18\x1f\x1f\xae(\xda1\'5a4\xfb6\xad9\xc66\xe8/f*\x1a\'U"$\x1b\xa0\x12\xb2\x08\xe5\xfd`\xf3\x9f\xea\x89\xe6\xc7\xe4\x84\xe3\xa4\xe2\xff\xe2E\xe4\xb2\xe6+\xea\x85\xec\xa5\xed\x96\xf0\x8f\xf6.\xfd\xce\x02_\x07\x04\n\xb2\t%\t\x98\t\x05\t\xeb\x06d\x04\x1a\x03-\x03\x17\x03\xec\x01\xc4\xfe\x1b\xfa\xed\xf4\x87\xf1\xd2\xef\xca\xee\x18\xef%\xf0\xbd\xf0z\xf15\xf3f\xf4\x15\xf4\x90\xf3\xbb\xf3\xc3\xf4\xdb\xf7\x8f\xfc\xdf\xff+\x01\xe1\x01\x89\x029\x02\xac\x01\x8b\x01\xf1\x00+\x00\xf7\x00\xaa\x02\x84\x036\x03\x1f\x02\xbc\x00s\xff\xdc\xfe\xd2\xfe\x95\xfex\xffc\x00\xde\x00\x1b\x01v\x01w\x01\x95\x00\xda\x00\xf4\x01)\x03/\x048\x05f\x06z\x06\xb8\x06=\x07\xd9\x06\xfc\x05\x15\t\x12\x12v\x16 \x12`\x0b\'\x06\xc6\x01\x87\xfe\xc3\xff\x96\x02\xe6\xffP\xfc%\xfe\xbc\xfeb\xf9\xe3\xf2`\xee*\xec\xa4\xee9\xf6\xc8\xfc\x8c\xfey\xfdS\xfd\x1e\xfd\x88\xfc\xb3\xfc\x00\xfb\x1d\xf9\x18\xfb\x02\xff\xa6\x02\x90\x02\xb0\xff&\xfc\xec\xf7\xdc\xf4\x12\xf4\xfb\xf3\xa6\xf3o\xf4\xef\xf5-\xf9\xb1\xf9\xc0\xf6-\xf4\x07\xf12\xf1\xda\xf4@\xf7\x98\xf9\xb2\xf9\x16\xfa\xf9\xfas\xf8\x13\xf9\x03\xfa\x94\xfbZ\x00\xfe\x02\x12\x02\xd9\x02\xeb\x07\xdc\x0e(\x10\xdb\x10g\x14\xd2\x13U\x17\x0c$\xa34y8$0\x1a.\x920o1],]%\xa9 "\x1c\xee\x1c\xd4\x1b\x97\x12\xf0\x06\x0c\xfb\xc7\xf0\xce\xe8j\xe5,\xe5c\xe1T\xdb\xa3\xdc\xaf\xe20\xe5\xbf\xe2\x08\xe0\x90\xe1\x95\xe7\xbe\xed\xb1\xf5\xd4\xfd2\x02\x11\x04\xe0\x05\xa6\n\x18\x0eO\x0c\xd5\t\\\n&\n\xa3\x08\xe2\x06]\x06 \x03\x0f\xfdh\xf7\x8c\xf3\xc1\xef\xc4\xe9J\xe7\xb8\xe7M\xe8\xba\xe8\xc3\xe9\xce\xea!\xea\x88\xeb\x0e\xeff\xf2\x19\xf6\x95\xfb\x96\x01\xfe\x044\x07\x1f\na\x0b\r\n\xd0\t\xb1\n\x86\n\x82\tv\x08\xc0\x07b\x05\xd3\x03\x10\x04>\x02\xae\xfe\xa6\xfd\xd3\xfe\x8b\x00T\x01\xd9\x02\\\x06\xe7\x052\x051\x07\xa3\x08*\t\x11\x07\x08\x07\xa7\x08\xeb\x06\xcf\x04\x93\x03\xdb\x023\x01\x95\xff%\xff\x14\x00\'\xfe\xd7\xfai\xfaq\xf9O\xf7\x08\xf2\xfd\xee\xda\xf0\x8d\xee\xb7\xeb\xfb\xeaR\xe9L\xe8\x8d\xe4\xfd\xe5R\xe9\xce\xe7)\xe9\xf2\xebt\xf1n\xf5\x86\xf6\x18\xf9Z\xfb\x84\xfd<\x00Q\x03Z\x05v\x05\xd9\x05[\t\x8a\x0b)\x0b{\x06\xb9\x03\t\t\x84\r\xe0\x0e/\t\xc2\x06\x06\x08\x8d\x05\xdb\x03\xa1\x04\xd7\x05\x8a\x05\xf7\x08-\x0e\x1b\x12`\x11\x90\x0f\xde\x12\x1c\x15\xaf\x19\xc5\x1c\xa1\x1f1#v"\x16\'\x81/\x993\xae,*$\x91#\xec"\xc4\x1c\x83\x18\xc1\x16\xdf\x0eh\x055\x00\xf8\xfd\x8f\xf4.\xe8\xd2\xe0\x98\xdf\xa1\xe1\x03\xe2\x9d\xe2.\xe2\xca\xe2\xf9\xe1\x01\xe39\xe9\xe8\xed\x1e\xf1N\xf5\x1e\xfdn\x01\t\x03\x8e\x05\x10\x03O\xfd\xe6\xf9Z\xfa\xf5\xfaQ\xf9\x91\xf8\x89\xf7\xda\xf35\xf1\xe1\xefc\xec\xbe\xe9\xcd\xe8\x81\xeb+\xf1d\xf4\x12\xf7/\xf8\xbb\xf9;\xf9]\xf9\x87\xfc,\x00\xd1\x01r\x03\xd9\x07\x8f\x0b\xcc\x0c\xab\x0b`\x0b^\x08\xd7\x06\xf2\x06n\x08\t\x0b\x8f\x08\xef\x06v\x04\xcf\x020\x02\x0f\xfe4\xfc\xd3\xf9\xce\xf6\xf9\xf6\xca\xf5\x1c\xf5d\xf2\xfb\xeep\xeeL\xef\x11\xf1\x14\xf2\x0f\xf2R\xf2i\xf3\xbd\xf4\x9c\xf7\xa5\xfa\xee\xfc\x9f\xfd\xca\xff\x88\x02\xa3\x03\xaa\x04B\x05]\x05\xdb\x03s\x04r\x06\xa6\x06\x10\x06e\x03r\x00\x0f\xffu\xff\xcc\x01!\x017\xff\xe4\xff\xbf\xfc\x7f\x01@\x04\xc5\x02\xde\x036\x01c\x00W\x00\xe3\x05[\x0b\xfd\n@\n\x0b\x0e\xbd\x0f\xb4\x0e\x10\r\r\x0fo\nW\x06\xda\x0e\xae\x10+\r\x9b\rC\r\x9d\x07!\x047\x06\x82\x06\x05\x00b\x02\x81\x08\xd6\x06\xcc\x08\x08\x0c\xb8\nz\x01\\\xffY\x06m\x05\x05\x04\xd6\n\xc1\x08\xe4\x03\x87\x07?\x07\x13\x02\xdd\xfdp\xfd\xd9\xfb\xb7\xfa\xd2\xfd\x89\x01t\x00\xeb\xfaw\xf9\xd1\xf6\xd6\xf3\x8d\xf3\xb5\xf5?\xf7Q\xf2\xc0\xf4$\xf9q\xf7\x9c\xf7!\xf8\x86\xf43\xf5\x8c\xf8z\xfa\x06\xfe]\xffP\xff\'\xfb\x87\xfc~\x00\x15\x00(\xfe\xd5\xff!\x01\x8c\xfe]\x02\n\x04q\x00\r\xff#\xfc+\xf9e\xfdM\xfe\xf2\xfb\xbc\xfa\xe0\xfc\xca\xf8\xab\xf6\xed\xf8\x9a\xfb|\xfb\xf1\xf3p\xf7\xbb\xfd\xfe\xfb\xd7\xfap\xfe\xd4\xfa\n\xfc\xd2\x003\xff@\x02|\x02\x87\xfd\xef\xf9\xd6\x038\x07;\x01\x87\xfe_\x02m\x05\xfa\xfb\x94\x00\x90\x01K\xffu\xfdB\xffM\x03\x87\xfa9\x08\x0e\xff\xdb\xf3G\x01/\xfe\xa5\xf4\x18\x02\xd0\x05\x0c\xf4\xd3\xf8\x12\x02\xed\xff\xd9\xfa\xb0\xfc;\x02~\xf5\x8e\xf53\x0e\xc1\xfc\x01\xfa8\x0c\x01\xfcB\x02z\x0c&\x00R\xfe\x92\x08\xb5\x04\xe1\x05\x8f\r:\x0c\xf3\x01\xbf\x02\xec\x07\xa4\x04\xa2\x01]\x08\xef\x03~\x03\xf1\x04\x85\x03\x16\x05I\n\xc3\x03\xe1\xff\x97\x0b\x99\xfc\xe8\xfb\x18\x03v\x0b\xff\x06]\x02Y\x06?\x015\x05\xa7\xfc\xc7\xfe+\x03"\xfb|\x08\x9e\x05\x17\x03\x8d\x08\xb3\xfc"\xf8\xad\x01\xba\xf8\xfa\xf9\x9b\x0b\xfc\xf3\xa0\xfc\xa9\x06\xa1\xfa\xdb\xf9\xf3\x00\x9a\xef\xbd\xf3#\xff\x12\xfd\xbb\x02*\xfd\xe2\x01)\xfd\x1d\xff\x8a\xf2W\x05#\x03\x95\xf6\xa6\x06b\x06\x81\xfe;\x00\xad\n6\xfbV\x04\xc3\xfc\x9d\x00A\x03q\xfe\xc6\x01\x03\x01B\xf7\xa9\x04\xb3\xfc\xca\xf2\xbc\x03:\xfb,\xfbi\xf9\x9c\x04-\x0bd\xf4\xc6\xfa\xf2\x0c\xbb\xf9\xe1\xff\x1a\nR\xfe/\x02\x0c\x0b\xb6\xf7/\x00B\t\xb9\xf3\xa6\x00\x9b\xfbR\x01g\xff\xbc\x01\x11\x04o\xf8\x8a\x00N\x08s\xfc\xdc\xf6\x13\x0e.\xf3\xc1\xf2{\x13:\xf5\xc4\xfce\nU\xf4\xf2\xfeF\xfd\x0c\xee\xee\x08\x8d\x06\x0f\xee\xdc\x04\x08\xffB\xfb?\x05\xc3\x04\xc6\xf56\xfc]\x08"\xf3\xa7\x06\xa3\t\x02\x03\xba\xf6:\x00N\x03\x92\xfc\xf2\x04\xd6\xf9!\x0b\x82\xfac\xf5\xc0\x15\xd5\xf7\xcc\xf8\xc4\n\x8c\xf56\x03\xbc\x0e\xbd\xff\xf1\xfd|\x07u\xfc\xb8\x07D\xfd\xd7\x03\xba\x0e\xaf\xf6\xb5\x03\n\x06\xc9\x03\x02\x01\xbf\xfe\x8e\x01\xe7\xfe\xe7\x00*\xfc\x90\xfd\x8f\x02R\x02u\xf2\xfa\x054\x02\x0c\xf2\xa4\x02H\x00{\xf3\xd3\x00\xfa\x07^\xf8\xe5\x02\x1a\x00\xd0\xfc\xed\x05\xff\xf9\xec\xf9e\x07\xd7\xf6W\x02\xd9\n\xd2\xf4\x15\xfd\x14\x077\xef\x87\xfb\x11\x10\x0b\xf8@\xf4\xe6\x08\x05\x06\xe5\xef\xdb\r\x07\xfb%\xf5\xf2\x05\x14\xf7\x17\x07\x1b\x06\x90\xf2)\xfej\x10\xd5\xf9\xbb\xf9\x1e\x0b\xec\xf8\x15\x03\xa5\xff\xb8\x08\x8b\x00\xae\x07\xf0\x03\x96\xf6\xc8\n\xdd\x02\xc4\xfbz\x02\'\x11K\xf5\xb4\x03\x1f\x01:\xffL\x03\xe2\xee\xb7\x0f\x84\xfc\xce\xf9\xef\x0e\xa2\xf5\x0b\xf7B\x15{\xf4\xa2\xf0\xce\x17\x1c\xfc#\xf0\xef\x0c\xea\x07m\xf1\xcd\x06\xbe\xfb>\xfb\x8b\x02\xc1\xfe\xfd\x03\xc3\x00*\xf6\x08\xff\x10\x0b\xc5\xef9\xfe\x81\ns\xee\xe2\x04\xb2\x00]\xfa/\x07I\xfd\x00\xf8;\x009\xf8\xbc\xf6\x84\x18T\xf89\xfb\x81\x07\x10\xfd\xf9\xfb\xa8\x02\xca\x05\xa7\xfd\xf2\x04\xda\xfd{\x05\xb6\x08\xd6\xfc\xdf\x02\xb0\x04\xed\xf8\xff\n\x11\x03\xa4\x00y\xfd:\x0b\x16\x04\\\xf7\x02\xfe\xd3\x01\xf3\x06G\xf7z\xfe\x91\x08v\x04\xe8\xf8\x93\xfb\x90\xfa\r\x06\xd1\xf9\xa8\xf4\xdb\x14\x87\xfen\xf2\x16\x08\xa6\xfc\x1c\xfc\xb1\xfe\xdd\t\xb0\xf7\xff\x01\xd5\x03:\xf5\x8f\t\x8b\x00\xcc\xfc3\x00R\x06\xe9\xf1\xd1\x06\x8d\x01\x1b\xf8&\xfb4\x00|\x02\xe3\xf5\xa6\x08\xcf\xfa\xb9\xedT\x02{\xff\x06\xf2\xb3\x0e\xcf\x00\xd7\xf1\x90\x06\xa3\x08\xe2\xf9g\xfc|\x01\xb7\t*\xfbe\t\xc0\x0b\xb2\xf60\x05\xb0\x00\xd1\x06\xff\xf8z\x00\x10\x0f\xf6\xfdi\xf8\x98\na\x04%\xf5W\x08\xd0\x03\xeb\xfc\xdc\xf8\x0b\x06\xfa\x04y\xfc<\xfd\xff\x00f\x0c(\xf4\xbf\x001\x07\x8f\xf4\xce\x01\xdb\x00\xb9\xfc\x9d\x06#\x01\x8f\xf6\xe1\xfb_\x04\x8e\xf9|\xff\\\x03l\xedE\x04\x07\x08Q\xf3O\xf8\xea\x10<\xf2\x92\xfa\x1c\x08x\xf5\xa5\xfc\x93\x06\xd9\xff\xc2\xfe\xc4\x01\xab\xff\xf0\x00k\xf6\xe0\rA\xf6)\x05\xa1\x03B\xfe\xdd\xffI\xfcg\x0e\x91\xefN\x0b\x1f\x01\x04\xef\xda\x07\x88\x0c:\xf45\x04\xfb\xf9\xf8\x05j\x08\xae\xf3I\tu\x08\xf8\xf4\xdc\x02\xe3\x13^\xef%\x06>\x07\x0e\x00\xcd\xff\xce\x01\xab\x06\x98\xff\xb5\x06\xac\xf8\xc9\x02&\t=\xf6\xc8\x00\xd6\x01D\x07\xda\xfcy\xf2a\x19\xb0\xed\x81\xff\x0c\x031\xfcZ\x03\xf1\xec\x7f\x12\xb6\xef$\x00\x9b\x085\xf6\xa8\xf5\xaf\x05\x1b\xfc\xa2\xf7 \x02\xfe\x02g\x03\x06\xf9P\xfc\x0b\x01\xe4\xfc\xa1\xfb\x18\x08\xba\x04\xdd\xf5\x88\x08\x8a\x03\x13\xf0I\x07\xbe\xfe%\x02\xfd\xf8\xc6\r4\xff\x01\xf2\xd7\r\xde\x01\xc7\xf6F\xf61\x1a\xaa\xf3\x9a\xf9\xff\x12\xec\xfb\xca\xfc\x98\x03\xc2\x04H\xf5\x13\x04\x9c\x03\x9b\xfb\x87\x01(\t\xda\xff\xc7\xf5\x84\x04f\x07\xd0\xf6J\x04\xe8\x08`\xefZ\x11G\x02\xb6\xf5\xb1\x04\x13\x07\x04\xf6\xc3\xf2\xd9\x18\x8b\xf9\xbd\xfa\xfa\x04o\x08|\xf5\xa3\xfdf\x0b\x90\xf1\xb2\x03\x94\x03\xf0\xfc\x00\x00\xab\n\xb5\xf2\xdd\xf9\xfd\x07\xfb\xefN\n\x19\xf5\xd9\xf6J\x16R\xf3\x1c\xf5\x80\t\x12\xf6\xe0\xffW\xfe\xcc\xf7\x02\x06\xb4\xfcT\x04\x14\xf5\x86\x06\xd6\t\xc5\xedD\x07A\x01\xea\xf9N\x04\xe6\x01\xa6\n>\x07\xa3\xfa#\xfdV\x05\xe3\xf9\x93\r=\xff\xdf\xff\x9d\tQ\xfc5\xfdB\tV\xffz\xfb\x99\x07\x9b\xf1\x17\x13\x98\xfb\x8f\xfb\xf1\x06E\x05\xab\xeeM\xfe\xac\x12s\xfc\xab\xf7\xdd\x00\x91\nC\xf5\x0f\xfc`\x05\x13\x03v\xee\xc4\x08\x18\x07\xf4\xf5\x14\xfb\x95\n\xfe\xfc\xd5\xee\x8b\x0c\\\x03\x14\xf2\x99\n\xb6\x01\xfe\xee\xf5\np\x05-\xf1\xf2\x00\xb4\x14\x1b\xeb\x90\xfa\x85\x17s\xf5\xd9\xed\xe0\nb\n.\xefJ\x02\xf6\r\xd7\xfc\xef\xf6p\xf8\xc9\x06\x84\x06\xa1\xf4\xe2\xf8\x02\x19E\xf4\xf3\xf3T\x16\x10\xef\xaa\xfb\x11\n\xd5\x01\x80\xf6\xce\x05\xb2\x06\xf8\xf5e\x03~\x10\xdd\xea\x13\xfd\xbe\x16&\xef\xc8\xff\xc8\x12\xd9\xf9c\xf8d\x0e\x81\xff\xc9\xf8\x19\x06\xef\x05\xa1\xf1\xbf\x05d\x18\xc7\xed\x0c\xf5\x06\x1c\x9e\xfc\xeb\xe6\xf4\x0f\xe0\x07\xd8\xf0\xed\x00\xe1\x07Y\x02-\xff)\xf6r\x06\xe8\xfe\xeb\xed"\x15\xf4\xf6#\xf1\xa8\x0b\r\x07?\xf5\xa3\xf5\xb3\x10.\x00\xdd\xed\x1e\x05\x89\x08\xb6\xf6+\xff\x9d\x04\x9a\x04Q\xf8\xdd\x04e\xff\xd4\x00Z\xfb\x06\xffn\nI\xf87\xfe\xc0\x0b!\xf7\xbe\xf8\xa9\x0f\xd7\xfa:\xf7\xdd\x01\xf3\np\xfa\x1b\xffH\xfd\xfb\x030\x02\xb6\xfb^\x01\xaa\x03j\x03\xd7\xfa\xc0\xff\xa8\xff\xe6\r\x15\xfa7\xfc/\r\x9c\xfc\n\xf8\xaf\x10\x80\xf8/\xfc\x8e\x10+\xfc\xf3\xf0\x1f\x08\xd7\x08e\xf6\r\xff\n\x01~\x07\xa5\xed\x90\x04O\x07I\xf1\x9e\x02\x91\xfdn\x06\x14\xff\x1e\xf8\xae\x0bS\xf8o\xf1U\x12a\x02`\xf01\x0f8\t\x18\xed\x88\xfb\x94\x12v\xf2\xbd\xff\xfc\x05\xdd\xfc\xf4\x04Q\xfb2\xff\x10\xfd1\x05\x16\xfcB\xfe\xc4\xfb\xe1\x0f$\xf6\x08\xf7?\x14;\xf6;\xf5\xc2\x06i\x05\xdc\xf0\\\x07\x17\x07\xfc\xfa\xb0\x018\xff\x85\xfd\xe5\xf7\xe2\x0f\x8f\xfd\xa1\xf5\xb5\x06\xec\x01\xd8\x01c\xf5d\r4\xffx\xf5[\x080\x08M\xf2\x92\x02\xe8\n\xef\xf7\xeb\x07\xeb\xf9d\xfe\xa7\x01\xb0\x01\x07\xfe\x9b\xfd5\x04i\x02\xf5\xf1&\rx\x04o\xe7*\x0e\x9b\x0c\xd7\xeb\xf1\xfcC\x12\x80\xfc\x1b\xf0\x97\xfc\x18\x1a\xa7\xf4\x86\xe9\xd1\x16 \x05t\xe2\x11\r\xef\x15\x08\xea\xb5\xfa9\x14\xac\xf3g\xf3\xa6\x15y\xfb\x7f\xf3I\x03Y\x0e\x81\xf83\xf6\xc8\x19\x12\xf0\xe4\xef\xe2\x10\x87\n\xe7\xe7\xde\x08|\x1b\x88\xe36\xf4\x8d\x1e\xdc\xfa\xb9\xe6t\x17a\xfa\x08\xfb\x9b\x05W\xfe\xba\x01\xcf\xfc\xb4\x00Y\xfeR\x06b\xf1g\x0fb\xff\xdd\xf1m\x0c\x81\x00\xbb\xf6O\x06M\x0b|\xec\xcd\x04d\n\'\xf3\xd1\x04\xd6\x0c~\xf3\x85\xffR\x0cB\xf3\xc5\xff\xf6\x08\x13\xfb\xe1\xfe`\x02.\x05\xc7\xf8\x9c\x00}\x05>\xf3\xed\x05\xab\x064\xf6\xb9\xf9\xd3\x13\x11\xfa\x17\xf1S\x0cg\x02w\xf2\xcf\x02\x06\t\xc6\xf7\xbb\xff\xf0\n9\xff"\xf5n\x06\x8e\xfeN\x00\xbb\xf7\xb6\x05\x17\nZ\xf9E\xfdX\x07O\x02U\xebj\x0b_\x12\xe0\xe4F\x03\xc8\x11\xb4\xf2\xe3\x03g\x0b\xe2\xf0\n\x015\x04\xe5\xfa\xbb\xff\xcb\x04\x7f\x02j\xf9_\xff\xec\tM\x04F\xeb\xae\x01\xcb\n\x8f\xf5\xa3\xfbI\x1a(\xf4z\xf4O\x0fD\xf7\xbf\xf9\xfe\x06\x81\x05\xf3\xf3\x15\x07"\x0ch\xf8\xc4\xf7O\x0c\xe0\xf3S\xfc\x80\x0e\xa6\xfbg\xfa,\x10:\xf6\x9f\xf6\xa7\x0fb\xf9`\xfa\x03\x02^\x07\n\x01\x97\xfd\xd1\xfc\xba\ts\xf7\xa8\xfcD\x05H\xfe\xce\x01P\x07\xfa\xf9\xcd\xfeb\x08\x9a\xf0\xb6\x08.\x03`\xf8\xa0\x04,\x02\xe9\xfb\x16\x05`\xfdi\xf8\xf9\x02\xd6\xf7D\x01}\x05N\x07\xb5\xf3\xe3\xff\x16\t5\xf6\'\x006\t\xa3\xfb\x8f\xfd\xb1\x0b\x8f\xf8V\x04#\x02\xb1\xfat\xfe\xcd\x06\xf5\xfao\xfe\xd4\x02\xd4\xfc\xf1\xfdT\xff\xf7\x00\xc7\xfcY\xffU\xfb\xe1\x0ca\xf2\xb8\x02B\n\x96\xf3l\xf8\x17\x04\x86\x07\x81\xf99\x05\xeb\xfe\xa8\xf7:\x064\x04X\xf8-\xff\x1f\t\xd7\xfe\xb1\xf7\xd7\x0e\xbf\x00\xa6\xed\x9f\x07\xbf\x0e\xb9\xf2\x1a\xfd\xac\x0e\xcf\xfap\xf2\xf2\x10P\x06\xb8\xed\xc3\x08\xd1\x00\x8a\xf5\x94\x045\x0c\xf7\xf7\x11\xf9\r\x0b\x8f\xfe\x0f\xf9\x80\x01\xb6\x000\xfe\xcd\xffZ\x06o\x02\xf4\xfa\x1b\x03\xe8\xfa\xeb\xfd\x8f\x00\x9e\x00\xf0\x03\x8d\xffw\xfd\xcc\x03\xc0\xfa\xb4\xfel\x04\x8f\xf8h\xfdJ\x00n\x01\xcd\x04\xd5\x02\xf9\xf5 \x02\x12\xfd\xa2\xfb\'\x07\x80\xfd\x12\xfe\xbd\x03k\xf8\xf6\x05\x82\x06\x8b\xf5\xe4\x04_\xfd6\xf8\x87\x01D\x08\\\x02\xae\xfc\xae\x03\x99\xfb\'\xfd\x18\x03\xbb\x03\xfe\xf8y\x02\xf2\x07\xb6\xf6\x89\x07i\x07\xad\xf8\xa1\xfdb\xfe5\xff\xea\xffe\t\x96\xfe5\xfa\xc8\x06g\x00\x9a\xfbG\xff[\x03\x9d\xf7\x8a\x00\x8b\x05+\x06\x8b\xff\xe5\xfd\x1f\xf9\x90\xfd=\x05N\xfc\xe5\x02\x18\x01\x9d\x01\x90\xfc\x10\x00\xb4\x046\x02S\xf9\x91\xf3\xc4\x08y\t\xbb\xf96\x06\x96\x00\x93\xf5\xb7\x00g\x08Z\xfcX\xfaP\x04\xc6\xf9e\x01\xfd\x05\xfc\x04\xb0\xfb|\xf5\x8b\xfe\xc6\x04c\xff)\xfc{\x058\xff"\xfa\x05\xfe\xb1\t%\xfe\x9a\xf9\x1d\xfe\x06\xfdD\x020\x06\xb3\x00\xbb\xfe\xb9\x00\xff\xfb\x19\x00H\x03\x16\x03N\xfc\xbc\xfe\xb6\x01\xba\x01I\x03\x05\x02\x8b\xfd\xa3\xfd\xa4\x00D\x00t\xff5\x02\xe3\xff\xa4\xfdD\x04\xc4\x00\xa9\xff\xe8\xff\xb5\xfcB\xfdC\x01\xa2\x02\x10\x01D\x01\x15\xfe\xbb\x00\xcc\x00k\xfeQ\x00\xc4\xfcV\xff[\x03\x99\xff\xa1\xff=\x01\xab\xff\xba\xfd\x90\x01\x17\x03\xbf\xff\xd0\xfa\xfe\xfc\xb0\x05\xb2\x01\n\x01B\x04\xba\xfd\x0b\xfc\xbf\x01\x93\x00\xe6\xfc\x91\x01P\x02\x9e\xfd\xc4\x01\x1e\x04\x81\xff\xde\xf9\xa4\xfer\x03c\xfdi\x01\xe3\x02\xc5\xfd\x89\x01\xdb\x00[\xfe\xf5\xff\xe9\xff`\xfdv\xfd\x1a\x03\x98\x05\xd3\xfd\r\xfe\x01\x02x\xfe\x87\xfd\xc9\x02\xd2\x00\xd1\xfe\xa0\x00\xd7\xff}\xffS\x01\xc2\x02\xce\xfd\x0b\xfe"\x018\x016\xfdA\x02o\x01h\xfd\xbe\x00*\x00\xe5\x01u\x02\t\xff,\xfb"\x00^\x01\x08\x00\x80\x04\xdf\xff\x92\xfd1\xff\x06\x01z\xfd\x91\xff\xef\x02a\xfc\x92\x00\x89\x03]\x01!\xfe\x01\xfe\xf9\xfd\xf7\xfd\xe3\x00\r\x00T\x02#\x02\xcc\xfb\x07\x00\x92\x01\x8f\xfc\xb8\xfe\xdc\x00\xbd\xfc\xc1\xfd\t\x05\x0f\x024\xfb(\xfe&\x01\xf3\xfa\x80\xfc\x80\x02k\xff\'\xff%\x00X\xfe\xe9\xffp\xff\xb7\xfb\x12\xffa\xfd\x8d\xfd\x9c\x01\x8b\x01q\xfeG\xff2\xfew\xfbv\xff\xc3\x00\x8a\xfd\xf3\xfd\x1b\x030\x00\xb2\xfd\x82\x01?\x00\xc1\xfbZ\x00\x8a\x00K\xfe"\x04\xc8\x02\x9e\xfe\xfb\x00\xc6\x01\xd4\xff\x96\x00p\x01\x05\x03p\x01\x92\x03&\x06x\x03\x87\x03\xa0\x05T\x03\x93\x05\'\n\xae\x07[\x08\x9d\n\x9a\n\x82\t\xcc\x0b.\n\xcb\x06\x8e\ns\n\xca\x07\xcc\x07a\x08_\x042\x02W\x02I\xfeD\xfc\xdb\xfd@\xfbW\xf8\xa5\xf8\xb6\xf6m\xf4\x8e\xf3\xa5\xf3\xa0\xf2\xeb\xf1\x03\xf4\x8d\xf5\x1c\xf5\x9f\xf5K\xf6-\xf7\xdb\xf8F\xfa\xf1\xfa\x8c\xfd\x05\xfe\x0e\xfe\xc4\x00\xb3\x00\x1d\x00\x83\xff\x81\x00\x1c\x01\x80\x00J\x00\x87\xfd\x02\xfd_\xfd\xb0\xfa\x96\xf9\xf8\xfa9\xf8|\xf5:\xf63\xf5\x14\xf5\xb0\xf3F\xf3\xf5\xf4\xd4\xf4\x88\xf3n\xf5\xbb\xf8\xa1\xf54\xf8G\xfb+\xf9\xb0\xfc\x01\x01\x80\xfeh\xfc\x8f\x03\x0e\x05\x85\xffc\x02[\x07\xf6\x02\xab\xfe3\x05(\x05{\x01u\x02`\x04\x13\x00\x8e\xfe\x90\x00\xb2\xfe?\xfe\xed\x03}\x03\x14\xfe7\x03\x82\x06\x94\x06r\x05\x0b\x07\xa1\t\xb5\x0cT\x148\x19\xf4\x1b\xaa \x9f\x1f\xf6\x1dF!5&Y\'h%4(\xf3*\xba)F%: |\x1b\x0c\x14\xdb\x0c\x8a\x0b\x05\r=\x08X\xfeq\xf8a\xf5\x88\xef\xac\xe7\xce\xe1\r\xe01\xdes\xdc\x0e\xde\x8d\xe0>\xdf\xe2\xda\xbd\xd8\xab\xdc\x8e\xe0|\xe1$\xe6y\xed\x15\xf1j\xf3\xa9\xf6I\xfa\x86\xfc}\xfc!\xff\xf8\x04V\t\x83\x0bx\x0bu\x0cY\x0c\x05\t\x7f\x07:\x07\xa9\x06:\x04&\x03%\x03o\x01\xc1\xfd\xc9\xfa\xaf\xf7\xa3\xf4L\xf3\xc1\xf36\xf5\x97\xf4\'\xf3Z\xf3\xf4\xf5l\xf5^\xf4\x98\xf6|\xf9i\xfb\xe4\xfef\x02#\x04\xc9\x04;\x07B\x084\x08\x82\x0b\xca\ru\rF\x0e\x0e\x10\xd0\x0eM\rD\x0cE\x0b\x0f\t\x9a\x08\xea\x07 \x06\x86\x04\x8d\x02v\x00\n\xfe]\xfc/\xfaR\xf8\xac\xf9#\xf8S\xf6\xc6\xf5x\xf4~\xf3\xba\xf2\xf2\xf2_\xf5\xf3\xf2\x00\xf2\xb1\xf5\x84\xf5s\xf4\x1a\xf6P\xf61\xf5L\xf7e\xf9\x93\xfc!\xf9+\xf9m\x00\xd7\xfe\xb6\xfb\x88\xff\x89\x03\xd2\xfd\x07\x00)\x06\x98\x04A\x01\x03\x04"\x08\x89\x03@\x04\xb8\x05\xd4\x05\xcc\x06\xed\x06\xc6\t\x1e\t\xe4\x08\x10\x08\xf3\x08\x93\n\x1c\r\x8e\x0f\xe9\r\xec\x0f+\x14\xf6\x17m\x16c\x14.\x14d\x15\xb1\x18\xdf\x1b[\x1b\xa8\x18Y\x16u\x14\xd9\x13A\x12\xc1\r\x88\t\xd6\x05\'\x03\xf6\x02\xfc\x00\xcb\xfb\x93\xf6\t\xf2K\xee4\xecO\xeb}\xe9L\xe7\xce\xe5\x9a\xe5w\xe7_\xe8\xd7\xe6\x16\xe63\xe7\x9b\xe9M\xedG\xf1\x16\xf5/\xf6\xa2\xf5\xd0\xf7\x1b\xfb\xe8\xfd\xd4\xfd\xb9\xfe\xd3\x00<\x039\x05\x9f\x05\xe0\x04\x80\x02\xc5\x00\x84\xff\xa8\x01*\x03\xc9\x00\x19\xff\xd3\xfe<\xfe,\xfd\xaa\xfbB\xfbL\xfa\x8c\xf8\x86\xfaf\xfe\n\xff2\xfd\xd3\xfc\xde\xfc\xff\xfdX\xff\xdd\xff\xa9\x01~\x03b\x03^\x03\xed\x07\x14\x07D\x02\xe6\x02\x8e\x04\xb2\x04\xfc\x04\x94\x04\xc5\x06z\x04\'\xff\x1f\x00!\x05C\x00}\xf8\x02\xfe\xc2\xff\xd2\xf9n\xfd\x10\xff\x8e\xf9\x87\xf6\xb0\xfb\x05\xf5x\xf6u\xfb\x9c\xf5\x8e\xf9\xcd\xf4\x9e\xfa>\x02A\xf2,\xf5\xbc\xfe\xbc\xf6\xd8\xf5\xd7\xfb\xd4\x00[\x01\xf5\xfaX\xff\xba\xfe\x12\xfd\x1a\x02N\xfe\xef\xfe\xe8\x07\x07\x07m\x04\x0e\x07z\np\x08\x13\xff\x93\x06\\\x11\xe5\t)\x06\x8e\x10K\x0e\x0e\x087\t\xe0\n\x97\x07B\x06\x98\t\xed\n\x8c\t\x92\x0b)\x07\xbb\x04\xf4\x05\xb5\x07N\x08\xb4\x08B\t\xa7\x0c3\x0f)\n\xe4\x0cS\x0eW\x0b!\re\x10\x8e\x10,\x0fW\x0eq\x0e\xe6\x0c\xca\tj\x08J\x05\xf2\x03\xd1\x02\x05\x01^\xffN\xfb\xc1\xf7\xcb\xf4\x01\xf3 \xf14\xef\xa4\xee#\xee\xb0\xeb\x1c\xebi\xed\xaf\xecR\xeaZ\xed4\xeef\xee\x05\xf1\x8c\xf4o\xf4`\xf5^\xf8\x9d\xf8\'\xfa\xd5\xfb\x9d\xfd\xe9\xfc\x17\xfe\xc8\x01\x9c\x00\x02\xff3\x01x\x00\xdd\xfe@\x00\xa9\xfe \x01\x9d\xff\x00\xfe\xaf\xff\xad\xfe\xd7\xfe\xbc\xffq\xff3\xfd\xc7\xff\xb5\x03\xe1\xfe\xdb\xff\xf8\x05\xca\x01\x87\xff\x92\x06\x0c\x04h\x01\x00\x05\x04\x05Q\xffd\x034\x05\x01\xfe\x1d\x01#\x02\xae\xfd\x8c\xfd\x84\xfd\xb5\x00\xbd\xf76\xfa;\x00M\xf6\xdf\xfbd\xfa\t\xfa*\xfe\x18\xf8\xf2\xf6\xfb\xfd\x0b\xfd_\xfaM\xfd\xd4\xfc\x08\xff,\xf8\xfa\x03C\xfe\x9b\xfa-\x02\xc2\x01\xd3\xfe=\xf9\n\x07!\xfcp\xfa\xd6\xfcc\x02w\x03\x12\xf5\xf8\xfe\x98\t\x04\xf5Q\xf2\xab\n\xc0\x02\xfe\xf6\xb2\xff\xc2\x08\x08\xfe;\xfe\xdc\t0\x00\x0c\xfe\xe3\x0e\xa7\xfe\xa3\x07)\x12/\x04\xa5\x07\x04\n\x86\t\x86\x018\x0f\xc0\x0ft\x03\t\n\xb8\n\x8e\n\xb4\x00:\tT\nd\xfb\xcd\x03(\x10\xba\x08A\xf6~\t\xe3\x04"\xfc6\x00\xeb\x06;\x07\xe4\xf7x\x08\xaf\x03d\xffU\x04\x92\x03\x82\xfb\xf0\xfd\x9a\x08)\x01\xfa\xfe\x08\x01=\x06B\xf9\x95\xfe\xec\x03%\xfc\xa6\xff\xff\xfb\x16\xff\x18\x02\xee\xfdS\xfb\xaa\xfd\x18\xfc\xad\xf9\x8f\xfb\x0e\x02L\xf88\xf5\xac\x03*\xfab\xf2\x06\xfeH\x02l\xf0\x0e\xefo\x08\xa6\xfc\x18\xf4T\xfb\xdc\xfe*\xfd\x14\xf2\xe8\x03\xdf\xfeo\xf4U\xfc\xec\x01\xa0\xff\x84\xfa\x1f\x01>\xfcL\xfa\x1a\x03\xed\xfcZ\xfd\xec\x00\xd4\x07\x8c\xf6\r\xffA\x0b\x9e\x06\xb5\xed[\x02e\x11C\xf9%\xf85\r\xc1\x0br\xe8\xf4\x0b\x94\x05f\xf3\xdc\x01\xe4\xff\xfe\xfe\xbc\xfcC\x01\x96\x03\x9a\xf5\xad\xf5\x7f\x07\xfc\xfa\\\xf3\xdf\t\xe4\x01^\xed\xef\x08\xe0\x01\xce\xf4&\x03[\xf8\xa5\x05\xae\xffx\xfc\x9e\n\x91\x02N\xf4e\x06\xb7\x0cL\xee\xbb\x0b\xb1\nP\xf7i\x08\x8e\x01l\x003\t\xaa\xfc\xc2\x00/\x08\xa9\xf1\x9c\x04\xc6\x19\xf6\xef\x9e\xfcm\x13\x88\xf8\xe6\xec\'\x18\x90\x06\x0e\xe7D\x12\xce\x04\xc4\xfcr\xff\xd1\x08V\xf8\x0e\x04C\xfe\xa5\x03\xe1\x08\xd0\xf5\xb2\x07{\x01*\x04\x1c\xf9\xbd\xfd\xcf\t\x0c\xf8s\xfd\x8c\x06\xa3\xff|\xfeM\xfdq\t\xda\xe9\x16\r\r\x03\x98\xef\x94\x06\x8d\x05\x8a\x03\x99\xefT\r\xae\xfcS\xfc;\x06\xbd\x00I\xfd\xe3\x03\x10\xff:\x03\xfe\x03+\x01*\x02\xe8\xfd\x89\x06U\xfa%\x04z\x05\xe5\xf8>\x00v\x06h\xfb\xd3\x06\x06\xff\xd8\xed\xb3\x12\x11\xf7\xad\xf5\x92\x11\x17\xffL\xf5/\x05\xed\x08\xa9\xf7\xaa\x01\x8f\x00\xd2\x05\xd9\xf4o\x13\x9a\xff\xa9\xf7~\x05g\x01\xf9\x00\x9e\xf7\xa5\nm\xfef\x00\\\xf8\x05\n\xb0\xfd\xd3\xf4A\x07\x18\xfe\xd6\xfc\xff\xf6\x8a\x059\x03\xa6\xeeW\x06\x87\x01j\xf3.\xffK\x05\xdf\xf7r\xfc\xfa\xfe\x98\xfa\\\x03\xa8\xfd\x1e\xf9\x88\x01]\xfdZ\xf9\x85\x06\xa3\xf9\x16\x06\x18\xf2\xc8\x03\xf3\x053\xfa\xb1\xf8\xd1\x078\x02\xd1\xf6\x18\x08@\xfb\x85\x02\x9b\xf6e\x10a\xfd;\xf1\xb2\x0e\xda\x04\x99\xf4M\x01D\x0bT\xfd\xdd\xf4G\r\xf9\x05\x9b\xefb\t\xc3\x0b\xd0\xf1\x07\xff\x13\r\xae\xfa\x0b\xfd\xce\x01\xac\x0b\x9d\xee\x99\x06\xab\x03?\x02&\xf6H\x02\x95\x0c\xcd\xee\xfd\x05\x81\x05\x91\xfd\xd3\xf0p\x15\x97\xf2f\xfd)\t=\xfa^\x00\xb9\xfe|\x03\xc2\xf9J\x01\xfa\xf9\xe3\x07@\xff$\xfa\xd2\x02m\x02C\xf6\xa0\x05\xf6\x02\x88\xfa\xe2\xfa\xb3\x05\x7f\x02\xff\xf5(\t\x03\x00Q\xf4y\x03\xf8\x0b\xb5\xef\x81\x05\xcf\x03\xdb\xfa\xea\x07\x96\xff\xb2\xfbc\x04L\xff\x0b\xfe\xe3\x04s\x07O\xfc\x99\x03\xef\x017\xfa6\n\xeb\xfc\x13\x00\xa7\x05@\x06&\xf7\x80\x06\xea\x04\xf5\xfc\x06\xfc \x02[\x05\x86\xff|\x02\xac\xfa\x16\x06\x1d\x00;\xfa\x80\x03n\x03^\xf7-\x02\x03\x03\xd6\xfdH\xff\xdf\x03\xaa\xf7!\x00\x8b\x040\xfcf\x00\xf1\x00\x9c\xfc=\x03\xb8\x00\x94\xfa\xb2\x06\x16\xfc\xb7\xfe\x9d\xff\xf2\x01\xd1\x02\x0c\xfcX\x00\xd7\x03\x0c\xff\x98\xf9\xbe\x05\x96\xfd\x11\x01\xd3\x01w\xfa\x07\x04\x9a\xff2\xff\xff\xfc\xdb\x01J\x01\x87\xfa5\x01W\x03p\xfe]\xfcu\x04\x88\xfe?\xfd[\x06\xa7\xfd:\xfb7\x06\x04\x02\xe0\xfa\xb0\x02\x83\x04\x11\xfc\xc6\x03\xde\x01\xa8\xfe\x06\x01x\xff\xf5\x01-\x03\xca\x02\xee\xfc\xd3\xfe\xa9\x06a\xfb\x14\x01W\x05\x19\xfb5\x02\xb4\xfd\x96\x00e\x019\xfe\xcb\x00M\xf9h\x06~\xfb\xb5\xfa0\x07\xfd\xfa\xdd\xff\xa5\xfb\xc7\xfe\xd3\x02\xa8\x00\x1f\xf7\x12\x03/\x02X\xf7Y\x04\x98\xfeH\xf9\xcd\x03\x81\xfe\x8b\xf9\xfe\x05\r\xfd\x1c\xfc\x07\x01.\x01\xa6\xfaQ\x01Y\x03\xc6\xfe\n\xfbB\x04R\x03\xcc\xf9\xa0\x01B\x01\xc2\x04\x02\xf9\xc7\x03\x90\x02\x02\x00T\xfdm\xfde\x08,\xfb\x95\x01\x9c\x02\x9a\xff\xb3\xff\xe3\xfe\xb0\x00\x98\x01\x08\x02G\xfd\xd9\x00/\x04*\xfe\xdb\xfe@\x01\xb1\x00\xb6\x00\x80\xfei\x01\x98\x01\xd8\xfc\xdb\x01p\x00T\xfe\x95\x00\xeb\xfd\x13\x00\xf0\xff\x0f\xff|\xff\x03\x00d\xfd\x86\xff\x91\xff\x04\xfe+\xffv\xfd\xe6\xff\r\xff\xf6\xfc\xc3\xffF\xff8\xfc\x04\xff\xd6\xff-\xfc\xbd\xff\xa0\xff\xf5\xfb\xb4\xff\xdd\x00\xd1\xfcT\xfe}\x00\xba\xfe\x96\xfe\x15\x00\xdc\xff\x9c\xff\xe5\xffu\x003\x01\xd7\x00\x06\x01\x18\x00\x90\x01\xa6\x02k\x00\xb0\x01\xec\x02C\x00\xa8\x02r\x03\x08\x00X\x02\xf6\x02\xd1\x00\xa0\x01R\x02N\x01!\x01\x8a\x01\x14\x02^\x00\xb0\x00c\x01\xb0\xff{\x00\x92\x01}\xff\xbd\xfe+\x01\xb3\x00\xce\xfd\xdf\xff\x98\x00\xd7\xfe\xad\xfe>\x00\xbe\xff\xe4\xfdd\x00\xf4\x00C\xfe\xea\xfd\xfe\x00\xa7\x00\xa8\xfd\x9c\xffw\x000\xff\xdd\xfe\xd2\xff\x9b\xff\x13\xfe.\xff\xb9\x007\xfe8\xfe\x93\xff|\xff\xbc\xfd\xdb\xfe*\xff\x07\xfe*\xff\xf7\xfe\x7f\xfe\xa4\xfe+\xff\xc5\xfe\x80\xfe\xd8\xfe\xe5\xffA\xff\xe6\xfex\x00\x85\xffh\xff\xc3\x00\xca\x00\xea\xffb\x00\x81\x01k\x01\xb4\x00\x83\x01C\x02\xf5\x00\xaf\x01\xc0\x02t\x01p\x01\xc4\x02"\x02\x11\x01\xaa\x02\xe0\x01Z\x01\xab\x01\xa0\x01R\x01\xae\x00u\x01v\x01\x10\x00\xce\xff\xea\x009\x00R\xff\x03\x00\xc5\xff\xdb\xfe-\xffZ\xff\xd5\xfes\xfe\xbb\xfe\x0b\xff_\xfe5\xfe\x8c\xfe\xcb\xfe1\xfe7\xfe\xad\xfe\xa4\xfe\'\xfe\xeb\xfe_\xff\x86\xfe\xde\xfe\x89\xffK\xff\xdd\xfe\x17\x00\xe6\xff\x10\xff\x00\x00\xb9\x00w\xff\x1f\x005\x01\xc1\xff*\x00\xf3\x00\xd3\x00j\x00e\x00R\x01\x0b\x01x\x00b\x01\x04\x01\xb7\x00\x10\x01\x01\x01\t\x01\xf7\x008\x01R\x01\x08\x01\xd1\x00\x1f\x01\x0b\x01\x9b\x00\x01\x01\xf7\x00y\x00\xd9\x00\x0c\x01w\x00@\x00\xd8\x00u\x00\x14\x00Q\x00\x12\x00\xd0\xff\xe8\xff\xf0\xffN\xff\xa1\xff\xe7\xff\x13\xff\xd5\xfe\xd3\xff*\xffl\xfe~\xff=\xff\x87\xfeB\xff\x02\xff\xed\xfe$\xff\xd3\xfe&\xff\x1e\xff\xfe\xfe<\xff\x1b\xff\x08\xff\x87\xff\x1e\xff \xff\xce\xff\x85\xffH\xffv\xff\x17\x00b\xffY\xff=\x00\xf7\xff\x92\xff%\x00\x88\x00\xca\xff/\x00\\\x00F\x00T\x00y\x00\x86\x00\x8d\x00\xc2\x00\x9a\x00\xa1\x00\x8c\x00\xbf\x00\xcc\x009\x00\x90\x00?\x01\x88\x00\x14\x00\xff\x00\xb8\x00\xef\xff\x86\x00q\x000\x00\xe3\xffM\x00S\x00\xce\xff\xf8\xff\x0c\x00\x80\xff\xea\xff\x01\x00\x98\xffu\xff\xb6\xff\xd2\xff-\xffo\xff\xc9\xffS\xff\x13\xff\x99\xffL\xff%\xffe\xff,\xff^\xffE\xff.\xffz\xff\x7f\xff\x88\xffh\xffs\xff\xa1\xff^\xff\x8a\xff\xf7\xff\xa3\xff\xc8\xff\xe9\xff\xe2\xff\x03\x00"\x00\xf1\xff\xff\xffi\x00(\x00E\x00\x8b\x00?\x00L\x00\x9b\x00R\x00\x8c\x00\x94\x00\x94\x00T\x00\x7f\x00\xb6\x00C\x00F\x00\x93\x00D\x00\xf6\xff\x83\x00[\x00\xfb\xff*\x00)\x00\xeb\xff \x00\x1b\x00\xd8\xff\n\x00\x0f\x00\xdc\xff\x00\x00\xb9\xff\xdc\xff\x0f\x00d\xff\xc5\xff^\x00v\xff\xe1\xff\xfd\xff\x8f\xff\xe1\xff\xb5\xff\xc4\xff\xf5\xff\x14\x00k\xff\xf6\xff\x02\x00\x94\xff\xaa\xff\x0e\x00\xa9\xff\x86\xff<\x00\xb0\xff\x90\xffN\x00<\x00^\xff\xbd\xff\x86\x00\xe9\xff\x99\xffk\x00M\x00\x1f\x00\xe8\xff8\x00\x91\x00\xd0\xff\r\x00\x98\x00\xb4\x00\xc8\xff!\x00\x87\x00\x1b\x00M\x00B\x00\x0f\x00G\x00\x08\x00r\x00F\x00\xbc\xffJ\x00m\x00\xdf\xff\x06\x00\xa0\x00\xdc\xff4\x00P\x00\xfd\xff`\x00\x12\x00\xf6\xffg\x00\xfd\xff:\x00\x19\x00\x11\x00\x11\x00\xee\xff\x1f\x00,\x00\xf7\xff!\x00\x08\x00\x9d\xffd\x00\x03\x00\x80\xff\xe7\xff*\x00\xc6\xff\xf4\xfe\x15\x00\x1d\x00\xbd\xfe\x90\xff\\\x00h\xff\xba\xfeL\x00;\x00_\xff\x8a\xff\xf8\xff\xc7\xff\x19\xff\x1a\x00\xb6\x004\xffL\xffn\x00\xbb\x00\x99\xff\xf1\xff\x83\x00\xb7\xff\x8e\xff\xdf\x00\x1e\x01\xf4\xfex\xff\r\x02\xfb\x00M\xfd\xbb\x01\xee\x02\xf8\xfc\x95\xff\x18\x03 \x00\xc2\xfe\x08\x00,\x01*\x00\xb3\xff4\x01\xb6\xff[\xff;\x00\x9d\x00\x1c\x00\t\x00\x92\xff,\xff\xaf\x00X\xff,\xfe\x88\x02L\xff\xe4\xfd\xd4\xff\xcc\x00t\xff\xd0\xfd\xd2\x01e\xff\x98\x00<\xfe\xe9\xff\xa0\xff\xd0\xff\xd6\x01r\xfd4\xfe\xe1\x00\x16\x01h\xfe\xd0\xfe\x85\x00|\x01\xe3\xfe\x8d\xfe\x84\xfc&\x00D\x03\xa9\x03\\\x02\xcf\xfe\xf2\xfb\xf5\xfe\xca\x07\xf3\xfe\xdf\xfbv\x04\xa1\x00X\xfc8\x02\xc8\x04\x82\xfcY\xfa\x9c\x02 \x03z\xfd\xef\xfdh\x000\x00\x8e\xfe\xfa\xff{\xffc\x00\xa7\xfe\xd4\xff\xa2\x00\xd0\xfc\xc4\xff\x8e\x02\x1d\x05>\xfc\x18\xfd\x0b\x04\xb3\x02\x82\xfdb\xff\xc4\x03\xe6\xfe\xb5\xff\xd1\x00L\x00\xb4\xfc\xc1\x00\\\xff\xaa\xfd\x18\x02\xde\xfd\x07\x01~\xff\x94\xfd\xea\xfe\xac\x01\xfb\xfcZ\x00\x9e\x00\xf1\xfd\xf4\x00U\x01+\xff}\xfd\n\x00!\xff\x89\x02\xa2\xffD\xff\xcd\x02\xbf\x00\xfa\x01S\xfd\n\xfdr\x00\x88\x05\xeb\x00\\\xfdn\x02\xff\xfdB\x03(\x00\xbe\xf7\xe1\x02\x99\x04\\\xf9)\x00\x1f\t\xd7\xf8\xaa\xf8~\x04\xb7\x04j\xf9\xfb\xfbu\x03\xc9\x03\xa9\xfe\t\xfe\x85\x01\x8e\xfd\\\x00\xf6\x01$\x03*\xfa\xa4\x00E\t\xed\xfb\xc9\xfc\xa9\x04D\x00]\xfc;\x03\x16\xfd\x18\xff\x9c\x03^\x01-\xfb2\x01`\xfeh\x02\x82\xfe\t\xf8\xfc\x06\xd2\x07\xba\xf9<\xf7C\t\xff\x03h\xfb\x88\xfb\xca\x00\x81\x01\x08\x01\xe0\x00w\xff^\xfdN\xfc\xae\x05a\xfe\xfa\xf8\x95\x02k\x07\x89\xf7\xa3\xfc3\x0b\xb1\xfe,\xf8\xad\xfd1\x0b\x1d\x00\xd0\xf7\xe4\x03O\x03\t\xfd\xee\xfd\xb2\x06\xef\x02$\xf9\xe5\xffR\n\xf7\xfb\xba\xf8\x1c\t\xb0\x06\xff\xf7\xd8\xffb\tO\xf9 \xf8\xc1\t\x11\x07\xc2\xfav\xf8\x17\x03\xfc\x01\xe5\xff\xf5\x00E\xfd\xd9\xfct\x03\xb6\x02E\xf8\xb2\x02\x1e\x08q\xfa\xba\xf66\x04\x05\x0b\x8b\xfc\xdb\xf7\xda\x04\xc6\x05\'\xf90\xf4\x95\x0e\xc3\x04\xce\xed\xad\x05\xe6\x0b*\xf48\xf9\x8c\x0f\x16\xf6\x84\xee^\x13\x13\n\xe2\xec|\xf91\x12w\x01K\xea\xaf\x08\xf3\x0b\xee\xf5\x1e\xf7o\r\xb3\x08\x14\xf1\xa5\x02)\x06\xf7\xf5G\xf9e\x13\xb7\x03\x00\xf4m\x02\x88\x02\xd2\xfex\xfe\x7f\xff\x0f\x06\xf3\xfee\xfa\xc7\x03O\x02\xf9\x00l\xfe\xeb\xfdO\xfc\x99\x02\x13\x03\xea\x02j\xfb\n\xfb\xd8\x05A\x00\xed\xfep\xfc\x9c\x02\xd3\xfe\xab\x02\xe7\xfd\xfc\x01g\x08\xdb\xff\xeb\xf4\x88\xfb\xd2\x0bN\x04\xa1\xf6F\xff\x0e\x0fK\xf9\xee\xf0\x1c\x05\xab\re\xf6?\xf2,\x06@\t%\xfc\x95\xf8"\xfe\x80\x02P\xfa\xad\xfcD\x08G\xfc\x9a\xff\x89\xfb\xd3\xfe\xf3\x07\x96\x05\x08\xfc\xb3\xf9\xb4\x01\x99\x07z\x05\x0e\xfaJ\xfb!\x02\xf8\x04\x91\xf9\xc0\xff\x85\x0cp\xfd\x12\xf0\xef\x02\xb9\x08U\xfb\x8f\xf7F\x07$\x04\x03\xfb\x01\xfec\x04h\x02B\xf7\'\xff\xe8\x08&\x03\x88\xf5h\x026\x07\xd6\x01\xe0\xf8/\xfd\n\x05\xfa\xfdN\xfd\xdd\x02\n\x03q\xfdo\xfd=\x00Q\x03v\xff&\xfb\xe7\x00\xbb\x00&\xffT\x070\x00\xf0\xf8\xfe\xffd\x04\x80\xff$\xfey\x04w\x03\'\xf9\x84\xf9\xac\x0cj\x07\x19\xf4\xc8\xf9\'\x08\xd6\x05\x03\xfb\xa4\xfe\xe8\x03\x96\xfd\x1d\xfbg\x03\xb4\x049\xfe\x9b\xfc\xea\xfcg\xffW\x02;\x03~\xfe \xfa\xd1\xffN\x04}\x03\x1e\xfc!\xfd\x06\x01\xef\xfa\xb6\x03\xff\x0b\xea\xfd\x16\xf7\xe8\x00\xb7\x06-\x00p\xfe\xa7\x04N\xffk\xfa\x14\x07>\n\x0c\xfa\xf7\xf5\xcc\x01a\x05\x16\xff&\xff:\x01g\xfa\xf0\xf6\x08\x03e\x08\x84\xfa\xae\xf3\x16\xfd\xdc\x04\x97\xfe#\x00\xe0\x00\xb9\xf5$\xf9\xcf\x06\x88\x06l\xfc\x0b\xfc\xf0\xff\xb4\xfd\xab\xffD\x086\x03\x9a\xf6\x9a\xf9\x0c\x04\xc1\x03\x08\xff+\xfe\x16\xfb\x1b\xf9\xc8\xfe\xda\x02\xfe\xfb\xb1\xf6\x9f\xfa\x01\xfeu\xfcz\xf8=\xf8\x1b\xf9\xde\xf6|\xf9h\xfd\xe4\xfe\x98\xfdJ\xfcM\xfa\xf3\xfd\xc6\x03\xe4\x084\x12\xf8\x16h\x10\x01\x0bB\x14\xb4\x1e\xcd\x1b\x0f\x14\xcb\x19\xf7$\'$0\x1d4\x1a&\x19\x85\x0f\\\x06\x96\n\x10\x12z\x0c\xcd\xfe\x15\xfa\xb1\xfd$\xf7U\xeaG\xe3\xfc\xe5Q\xe8H\xe4V\xe6\xda\xeb\x13\xe8\x9e\xdc\x11\xd9\x90\xe3\'\xec\xf1\xe9\xb8\xe8\xb1\xf2\xfa\xfc}\xfc\x99\xf7X\xf8\xfa\xfd:\xff\x88\x00\x80\x08C\x13\xc2\x11@\x067\x05\x07\x0c\xf9\x0b\xf7\x02\xf4\x00\x9a\x08U\rX\x07r\x00\xd0\xfei\xfa\xa1\xf4\xe9\xf4\xb0\xfa\xea\xfc\xa5\xf8N\xf6\x14\xf8\xf2\xf8\x89\xf3\'\xf0\xb5\xf2{\xf8*\xfe\x8f\x00V\x02\xf7\xfe\x9a\xfb>\xfc\x8f\x01\xc0\x05\xc6\x06\xd3\x08S\x0c\xb5\x0e\xdc\x0cN\x0c\xfb\t\xf4\x07\xbd\t|\x0eH\x13\x18\x12(\x0cy\x08o\x07\xb6\x06\xe7\x05h\x05M\x05\xc2\x04\xe5\x02D\x01\x08\xfe\xb8\xfa\xcb\xf7\xfd\xf5E\xf8\x17\xfa\xf0\xf8\xc9\xf5\x03\xf3\x95\xf1h\xf2\x96\xf1\xbb\xf1#\xf5\x8a\xf6\x1d\xf7\x1e\xf6\xcd\xf7<\xf8\xab\xf6>\xf8\xfa\xfb\xb3\xff\xae\x00a\xff\xa0\xffk\x00\xba\xfe\r\xfe\xa4\xff\xd3\x01(\x02d\x01\x1c\x01\xa0\xff\xed\xfd\x82\xfc\xa8\xfc\xe5\xfc\'\xfd\x02\xfd`\xfb\xb4\xfb\x0b\xf9\x96\xf5\x1c\xf4\x9c\xf2\xa4\xf5\x16\xf6O\xf6\x17\xf6\xe6\xf5L\xf7\xe4\xf7\x1c\xfa\xde\xfb\xfd\xfc\xd9\x01\xb9\x10B"J%L\x18\xf3\x13\xe4"Z1#2I0\xcc6!;\xa88\x825\xd10\xea$\xae\x18\x89\x1a\x9f$\xd3"R\x12s\x02x\xfb?\xf3I\xec\xf0\xe7>\xe4E\xdd\x1a\xd7\xea\xd9\x98\xde\x81\xd8\xa5\xc8\xd0\xc2i\xce\xc8\xdbh\xdei\xdc\x9a\xe1\x98\xe9$\xed\xe4\xec\xfb\xf0\xae\xf8x\xfcy\x00>\t\x10\x15\x91\x15\x0c\n\n\x07P\x0f\xa2\x14x\x0eI\n(\x0e}\x10\xf1\n[\x035\x00\x87\xfb4\xf4\xbe\xf3V\xf9F\xfb\xa1\xf4M\xed\x17\xee*\xf1\xb7\xed\xbf\xea=\xee\xa9\xf4(\xf9\x1b\xfac\xfc\x85\xfc^\xf9z\xf9u\x00\xe0\x07\xcb\n\xa3\x0b\xff\x0cL\x0f\x1d\x0f\xe4\r\x1c\r\xa3\r\x1a\x0f,\x12%\x15\xa7\x14\xd5\x0f\xd6\t\x97\x06S\x05\xda\x05E\x05)\x04\x87\x03O\x01c\xfe\xa0\xfa\xc0\xf7z\xf4;\xf3\x9a\xf6E\xfc\xa0\xffJ\xfe\xbb\xfa;\xf7\x04\xf8\xeb\xf9\x0b\xfd\xb9\x01\xf0\x02\xb8\x03\'\x046\x03\xff\x00j\xfdQ\xfc\xfd\xfd7\x01\x84\x03\x17\x02\x11\x00\xa9\xfc=\xfa\xc8\xf8h\xf9\xe2\xfa\xdb\xfb\xc0\xfcC\xfd\xd4\xfc\x9e\xfa\x9d\xf8\xde\xf7\xd0\xf8\x80\xfbv\xfdv\xfe\xdb\xfft\xfe\xa6\xfcp\xfc"\xfd\x85\xff(\x00\xb2\x012\x04\xbd\x03\xc0\x02\xa8\x00\x9c\xfe\xe5\xfe<\xff\x9e\x00\x1b\x02\x85\x01:\xfff\xfc\xf1\xfa\x0b\xfb\xef\xfak\xfa*\xfc\x0f\xfeT\xff\xf9\xfe\xd0\xfc\xb1\xfb&\xfb\xa0\xfc\xfb\x00\xe3\x01\xcf\x00\x87\xfet\xfc\x9f\xfd\xeb\xfbF\xfc\xab\xfe\x12\x01\xb7\x00I\xff\x1f\xfe\x16\xfb\xc2\xf8\x94\x01\xb9\x18\xa7%\x81\x19\xd5\x06f\x0e\x7f$\x83(G\x1f\x80!\xc91y4\xb7*N(\xdd&~\x18\xf3\x08\xb9\x10E$\xaa!\xe4\t\x89\xf9\xe7\xfa\xf3\xf5s\xe8>\xe1\xb5\xe5\x9b\xe6i\xdfe\xe0h\xe6\xfb\xde8\xcc\xfe\xc6R\xd8\x0b\xe9\xd5\xe8\xe7\xe3a\xe9\xb3\xf1\xa1\xf1\xb8\xee\xa5\xf2\xd8\xfa\xce\xfc\xec\xfe\xf4\x08q\x13\x86\x0fh\x00\xa0\xfd\x11\x08\x88\r\x8b\x06:\x02\xff\x06\x16\t\xad\x013\xfa\xa2\xf9\xf5\xf6(\xf0\xfe\xf0G\xf9\xc6\xfa\x8d\xf1\xfd\xea\xe2\xee\xe1\xf2\x9b\xee\xbc\xec^\xf3\xf3\xf8\x8c\xf8\xe9\xf8\x81\xfe\xf0\xff\xee\xf9\xa9\xf8,\x02B\x0b\xf8\n)\tq\x0b!\x0e\x80\x0c\xab\x0bk\x0ep\x10q\x0f_\x0f\xa0\x12\x8f\x14\x91\x10\xb7\t1\x07\x1c\to\n\xc5\t\x11\x08\xb3\x06\xbd\x03\xd3\xff\xdf\xfd\xdb\xfdZ\xfcm\xf9\xdd\xf8\xd7\xfa\xc0\xfb\x14\xfa\xab\xf6w\xf5\xd0\xf6F\xf7]\xf8\xf6\xfa\xdc\xfcY\xfd\x84\xfc[\xfd\xf7\xff\xb3\x00\x14\x00\xee\x00c\x04\x00\x07\r\x07\xcf\x06\xbc\x06\xd8\x05=\x04\x19\x04x\x05k\x06;\x05k\x039\x02Y\x01\xd6\xff\x04\xfe\x90\xfd\x10\xfe\x08\xfe\xc0\xfc\xc7\xfcD\xfc[\xfb\x89\xfa&\xfa\xf2\xfbG\xfd\xdc\xfd!\xfe\x82\xfe\xd6\xfe\x9a\xfeo\xff\x14\x02\xfb\x04s\x03*\x02G\x04\x1e\t\x13\x0b\xca\x07\x17\x07B\x07\xe7\x07|\x07K\x08\xd1\x08\x11\x07\xe5\x04 \x04\xb5\x02g\xff\x9f\xfdU\xfc\xa7\xfc\x9e\xfc\xae\xfa]\xf9y\xf7\xe2\xf5\x04\xf5\xa2\xf4\xe9\xf5&\xf6\xec\xf5\x82\xf6\xdc\xf6\xeb\xf6"\xf6\xb4\xf6g\xf8v\xfaA\xfbc\xfc\xef\xfd\xea\xfe\x9f\xff\xce\xff*\x01 \x031\x04\x12\x05\xfc\x05\xaf\x06\xb7\x06\xf4\x05L\x06?\x07\xb0\x06\x00\x06\xef\x05\xef\x05\xf8\x041\x03}\x02\xf5\x01r\x006\xffK\xfe\t\xfeW\xfd\x9d\xfb7\xfa\x99\xf9E\xf9\x81\xf9r\xf9\xe8\xf8j\xf9\xf8\xf9V\xfau\xfa[\xfa\x81\xfa\x9d\xfa\xc9\xfa\xe4\xfb\x02\xfc \xfb\xe8\xfa\xe2\xfa\xd6\xfa8\xf9\x1d\xf8(\xfa\xe1\xfc\xcb\xfcK\xfb@\xfc_\x02\x87\x07\x8f\t\x13\x0e\xba\x15\xfc\x18\x9f\x14\xf9\x14m\x1f6(_&\x9f"\x8d&]*l%9\x1d\xdf\x1a\xa1\x1a\xde\x166\x11\xa7\x0f\xdc\x0c\xd6\x03\xa3\xf9k\xf4\xf0\xf1\x81\xed\x91\xe8Y\xe6T\xe5m\xe3\xfc\xe1\xa8\xe1\xe7\xdf\xf9\xdc\xb7\xdd&\xe3\xe0\xe81\xec\xbe\xedF\xf0^\xf3\xbf\xf5d\xf8\xed\xfa\x97\xfdV\x00\xc2\x03u\x07\xfb\t\xa1\to\x06\t\x04\xfb\x03\xe2\x05u\x06}\x05\x10\x04.\x02\x00\x00\xb1\xfd\xc4\xfb1\xf9n\xf6\xb9\xf5\x9c\xf7\xd9\xf8\x13\xf7\x1c\xf5\xd9\xf4\x1b\xf5a\xf4\xa5\xf4\x92\xf7*\xfam\xfa\x05\xfb\xe1\xfd0\x00\x93\xff\xd8\xfe\xd3\x00\x08\x04n\x05$\x06\xf5\x07$\tR\x08I\x07\xf8\x07j\t\x84\t\xc2\x08\xf2\x08\xe0\t\x8f\t\xcc\x07y\x06\r\x061\x05M\x04-\x04R\x04S\x03O\x01\xfe\xff\x9e\xff\xc8\xfe?\xfd[\xfc]\xfc\x99\xfcn\xfc\x9d\xfb\\\xfbO\xfb\xc9\xfa\xd4\xfa\xf3\xfb\x14\xfd\x80\xfd\xb7\xfdC\xfe,\xff\xe9\xff\n\x00\xa6\x00\xdb\x01\xca\x02\xf4\x02\x12\x03\x99\x03\x96\x03<\x03\x1c\x03$\x03\xf8\x02\xe2\x02\xb3\x029\x02\xb4\x01\x0c\x01i\x00\x15\x00\xc9\xff]\xff!\xff/\xff/\xff\xfd\xfe\x9a\xfe\\\xfep\xfer\xfe\x9e\xfe\x07\xff`\xff\xad\xff\xc6\xff\xaf\xff\xb5\xff\xa2\xff\xaa\xff\x18\x00S\x00d\x00\xa8\x00\x94\x00Z\x00M\x00F\x00\x19\x00\xe5\xff\x0f\x00o\x00\xa6\x00\x84\x00\x14\x00\x01\x00>\x00.\x00,\x00\xc6\x00\xbc\x01Q\x02`\x02\xaa\x02\xe7\x02\xfa\x02Q\x03\xf2\x03m\x04?\x04E\x04\x0e\x05\x1a\x06#\x05\xd5\x02\xde\x02b\x04\x16\x04t\x01q\x00\x06\x02\x0b\x02\x13\xffW\xfd\xa2\xfe\xd6\xfe\x9d\xfb\x88\xf9\x98\xfb\xf9\xfc`\xfa\xb9\xf7\x19\xf9\x1c\xfb\xa8\xf9\x8a\xf7y\xf8\x88\xfa:\xfa\xf7\xf8F\xfa\x97\xfc\xae\xfc\x95\xfb\x8c\xfc\xf8\xfe\xdc\xffe\xff\xae\xffx\x01\xe4\x02\xef\x02\xf8\x02\xad\x03\'\x04\xbc\x03-\x03|\x03\r\x04\x88\x03E\x02\xb2\x01\xef\x01t\x01\xe9\xff\xb6\xfe\x8a\xfe0\xfe/\xfdd\xfc:\xfc\xfe\xfb:\xfb\xbf\xfa\x03\xfbe\xfbZ\xfb$\xfbo\xfb7\xfc\xc2\xfc\xfb\xfcr\xfd\x1a\xfe\xa7\xfe\xc0\xfe\xdf\xfe*\xff)\xff\xbc\xfe{\xfe\xa9\xfe\xcf\xfek\xfe\xce\xfd8\xfd\xe5\xfc\xb4\xfc\xa2\xfc&\xfd\x8d\xfe\xca\x00\xf7\x02f\x04b\x05m\x07\xb0\n\xaf\r\x13\x10\xd6\x12\xc9\x15z\x17\xd0\x17k\x18\xc9\x19Y\x1a6\x19e\x17\x15\x16\x9b\x14\xf5\x11\xb5\x0e\xe1\x0b~\tz\x06\xdd\x02\x89\xff\n\xfd\xb7\xfa\x16\xf8\xcb\xf5W\xf4v\xf3\x10\xf2w\xf0\x9c\xef}\xef\x82\xef\x0f\xef\xd2\xeex\xef?\xf0\xb1\xf04\xf1#\xf2$\xf3\xc9\xf3Z\xf4\x8d\xf5\x01\xf7:\xf8C\xf9\x1a\xfa\x14\xfb\x00\xfc\xa4\xfc2\xfd\xde\xfd\x93\xfe\xfc\xfe\xfc\xfe\xe4\xfe\xf2\xfe\xd1\xfel\xfe\x03\xfe\xcb\xfd\x8b\xfd\x18\xfdk\xfc\x00\xfc\x1a\xfc!\xfc&\xfch\xfc\xc0\xfc\xf5\xfc\xf4\xfc#\xfd\x84\xfd\xfc\xfd\x83\xfe\x0f\xff\xa1\xff\x1d\x00s\x00\xbd\x00\x07\x01F\x01\xb9\x01i\x02\x11\x03\xb0\x03X\x04\xda\x04P\x05\xba\x05Y\x06\x0e\x07\x9c\x07\x07\x08\x95\x08B\tq\t\x1a\t\xe2\x08\xd6\x08V\x08{\x07\xf7\x06\x9a\x06\x85\x05\x01\x04\xe1\x02$\x02\xdd\x00?\xff/\xfe\xa7\xfd\xbb\xfc\x8a\xfb\xfd\xfa\xf8\xfa\x8f\xfa\xdc\xf9\xd7\xf9l\xfa\x81\xfa0\xfa\x93\xfa\x85\xfb\xcd\xfb\xc5\xfb9\xfc\xf4\xfc \xfd\x06\xfdj\xfd\x1d\xfe[\xfe7\xfew\xfe(\xffK\xff\x11\xff]\xff\xef\xff$\x00\x1c\x00}\x00\x13\x01F\x01O\x01\x94\x01\xec\x01\xf5\x01\xde\x01\xeb\x01\r\x02\x11\x02\xd7\x01\xaa\x01\xb4\x01\x8d\x01>\x01\x18\x01\x18\x01\x16\x01\x08\x01\x1e\x01:\x01x\x01\x8a\x01\x84\x01\xb7\x01\xed\x01\xfc\x01\t\x02#\x02*\x02\x1a\x02\x08\x02\x01\x02\xd8\x01\x85\x01\x0e\x01\xb8\x00\x99\x00r\x00\x02\x00{\xff\xa2\xff\x06\x00\xdb\xff\xbb\xff\xf2\xff[\x00\x17\x00\xf1\xff|\x01\xb6\x03\xd7\x03\x84\x02\r\x03\x12\x05t\x05\n\x04\x1a\x04\x7f\x05[\x05c\x03\x8f\x02M\x03\x87\x02\xf0\xff\\\xfe\xe9\xfe\xdc\xfe\x8a\xfc~\xfa\x82\xfa\xb1\xfa\xb5\xf9\xc0\xf8\x01\xf9[\xf9\xde\xf8k\xf8Q\xf9\xc8\xfa=\xfbF\xfb\x1b\xfcs\xfds\xfe\x0b\xffi\xff.\x00\xf7\x00\x83\x01\x00\x02_\x02\xb9\x02\xb0\x02q\x02f\x02h\x02\xf9\x01\x17\x01\x81\x00\x82\x00?\x00S\xff\x81\xfe-\xfe\xbb\xfd\x0c\xfd\x82\xfcZ\xfc\x18\xfc\x96\xfb|\xfb\xe0\xfb<\xfc,\xfc\x13\xfcd\xfc\xe7\xfcA\xfd\x97\xfd"\xfe\x9b\xfe\xfc\xfeW\xff\xda\xffi\x00\xab\x00\xae\x00\xdb\x00Z\x01\xc8\x01\xd7\x01\xd5\x01\x12\x02q\x02v\x02[\x02}\x02\xa0\x02u\x02E\x02g\x02o\x02\x1a\x02\xbc\x01\x7f\x01\x14\x01a\x00\xc9\xffL\xff\xa6\xfe\xde\xfdA\xfd\xde\xfcw\xfc\r\xfc\xcd\xfb\xc8\xfb\xc7\xfb9\xfc^\xfd\x04\xff\xa8\x00R\x02N\x04\x88\x06\xa8\x08\xbd\n2\r\xd2\x0f\xca\x11\x04\x13e\x14\xe4\x15o\x16\xd8\x15b\x156\x157\x14\xc7\x11l\x0f\xc8\r\x85\x0b\xec\x07x\x049\x02\xbd\xff\xfe\xfbx\xf8{\xf6\xec\xf4S\xf2\xd0\xef\xcb\xee{\xee\x82\xed[\xecq\xecD\xedy\xedZ\xed%\xee\xbb\xef\xd8\xf0S\xf1c\xf2\x0b\xf4L\xf5\x18\xf6\x15\xf7\xa3\xf8\xe7\xf9\xb1\xfa\xb1\xfb\x04\xfd\x1d\xfe\xbb\xfeQ\xff\x07\x00\x8d\x00\xf2\x00i\x01\xd1\x01\xd6\x01\xa9\x01\xae\x01\xb0\x01Y\x01\xf0\x00\xa1\x00W\x00\xe1\xffy\xffn\xffE\xff\xd0\xfey\xfe\x8f\xfe\xd0\xfe\xab\xfe\x98\xfe\xf2\xfeX\xff\xc6\xff<\x00\xd7\x00g\x01\xd0\x01K\x02\xf7\x02\x9b\x03\xef\x03E\x04\xc0\x04F\x05\xb0\x05\x08\x06K\x06e\x06v\x06\x99\x06\xd9\x06\xf5\x06\xd4\x06\x96\x06G\x06\xe8\x05t\x05\xe5\x04\x18\x04<\x03~\x02\xbe\x01\xcc\x00\xad\xff\x95\xfe\xa5\xfd\xc3\xfc\xe0\xfb"\xfb\x92\xfa\xf3\xf9G\xf9\xd4\xf8\xb6\xf8\xa2\xf8\x84\xf8\x9c\xf8\xf3\xf8e\xf9\xd3\xf9F\xfa\xeb\xfa\x9e\xfbH\xfc\xfb\xfc\xb9\xfd\x93\xfe{\xffL\x00\x1b\x01\xd5\x01\xab\x02x\x03\x02\x04t\x04\xd7\x04C\x05\x9c\x05\xd9\x05\xfb\x05\xf5\x05\xd4\x05\x82\x05%\x05\xd9\x04s\x04\xe3\x03X\x03\xdc\x02Q\x02\xc5\x01@\x01\xb6\x00*\x00\xb3\xff{\xffZ\xff0\xff\x19\xff\n\xff\xfb\xfe\r\xff)\xffD\xffa\xff\x92\xff\xd4\xff\x0b\x00-\x00M\x00l\x00q\x00v\x00\xa7\x00\xd7\x00\xbf\x00\x95\x00\x90\x00\x91\x00~\x00A\x00\x13\x00\xf8\xff\xc4\xfft\xff\'\xff\x0f\xff\xf6\xfe\xb2\xfe|\xfe\x9a\xfe\xc5\xfe\xb7\xfe\xd2\xfe%\xff\x95\xff\xba\xff\xda\xff\x9b\x00\xa5\x01\x03\x02\xee\x01t\x02A\x03p\x03\r\x03"\x03\xa4\x03\x9b\x03\x05\x03\xa1\x02\x85\x02\r\x02\x19\x01\\\x009\x00\xe7\xff\xf5\xfe\x05\xfe\x9e\xfdW\xfd\xa6\xfc\xf4\xfb\x99\xfbg\xfb-\xfb\xd8\xfa\xd2\xfa\xf3\xfa\xe2\xfa\xc4\xfa\xdd\xfa.\xfb{\xfb\xa8\xfb\xc9\xfb\x1d\xfc\x8a\xfc\xdc\xfc\x1b\xfd>\xfdy\xfd\xd4\xfd0\xfe~\xfe\xd8\xfe\x1d\xffI\xffu\xff\xc3\xff\x1d\x00T\x00\x8b\x00\xba\x00\x05\x01x\x01\xdf\x01 \x02V\x02\x99\x02\xeb\x02;\x03}\x03\xe1\x03\x16\x04\x1c\x04B\x04q\x04\x88\x04W\x04\x13\x04\xe7\x03\xca\x03\xaa\x03V\x03\xeb\x02s\x02\x13\x02\xbe\x01o\x01\x1d\x01\xb8\x00`\x00\x1d\x00\xf0\xff\xcf\xff\xc5\xff\xa3\xff\x8a\xff\xa1\xff\xc7\xff\xd5\xff\xdf\xff\x00\x00,\x00^\x00\x87\x00\xae\x00\xc8\x00\xca\x00\xcd\x00\xd5\x00\xe1\x00\xdc\x00\xbb\x00\x9d\x00v\x00`\x00:\x00\xf9\xff\xb9\xff}\xff;\xff\t\xff\xd4\xfe\x9c\xfex\xfeC\xfe\x12\xfe\xee\xfd\xd5\xfd\xce\xfd\xbd\xfd\xc3\xfd\xc8\xfd\xd1\xfd\xdd\xfd\x01\xfe8\xfek\xfe\xa7\xfe\xdd\xfe$\xff^\xff\x97\xff\xd4\xff\x08\x00S\x00\x8b\x00\xa0\x00\xab\x00\xa1\x00\x96\x00\x81\x00Z\x005\x00\xf4\xff\x91\xff!\xff\xca\xfey\xfe+\xfe\xe4\xfd\xb5\xfd\x8a\xfda\xfd3\xfd2\xfdo\xfd\xd4\xfd\x1f\xfeD\xfe\x89\xfe\xe2\xfe2\xff^\xff\xc3\xff6\x00p\x00\x95\x00\xbc\x00\xea\x00\x01\x01\xfe\x00\xef\x00\xff\x00\x00\x01\xf3\x00\xcb\x00\xbb\x00\xbe\x00\xa9\x00\xa0\x00\x91\x00\x90\x00\x93\x00\x98\x00\x97\x00\xa6\x00\xad\x00\xb7\x00\xc1\x00\xc1\x00\xcc\x00\xc6\x00\xb7\x00\xbc\x00\xba\x00\xa4\x00\x83\x00]\x00E\x00$\x00\xfb\xff\xd6\xff\xc1\xff\x92\xffN\xff\x14\xff\xfd\xfe\xec\xfe\xc1\xfe\xb1\xfe\xb3\xfe\xb3\xfe\xa1\xfe\x97\xfe\xb2\xfe\xd4\xfe\xe6\xfe\xfc\xfe4\xffh\xff\x8c\xff\xbd\xff\xfe\xff2\x00=\x00`\x00\x8f\x00\xba\x00\xe5\x00\xfd\x00&\x01=\x01H\x01[\x01c\x01n\x01\x85\x01\x90\x01\xa4\x01\xb7\x01\xbc\x01\xb3\x01\x84\x01~\x01\xa2\x01\xae\x01\xaf\x01\xbe\x01\xdf\x01\xec\x01\xeb\x01\xf7\x01\n\x02"\x02;\x02Q\x02c\x02s\x02t\x02w\x02q\x02]\x02<\x024\x02/\x02\x13\x02\xfc\x01\xb9\x01X\x01\xef\x00\xa5\x00^\x00 \x00\xe7\xff\x98\xffL\xff\xf0\xfe\x97\xfen\xfeG\xfe\x01\xfe\xf0\xfd\xf7\xfd\xdc\xfd\xcc\xfd\xb5\xfd\xa6\xfd\xb5\xfd\x9e\xfd\x96\xfd\xb8\xfd\xb8\xfd\xa5\xfd\x94\xfd\x92\xfd\x98\xfd\x91\xfd}\xfd\x80\xfd\x85\xfdx\xfdh\xfdO\xfdQ\xfdQ\xfd?\xfdN\xfd`\xfdv\xfd\x90\xfd\x93\xfd\xa5\xfd\xc2\xfd\xe3\xfd\xec\xfd\x05\xfe>\xfe]\xfet\xfe\x95\xfe\xdc\xfe\x15\xff\x15\xffB\xff\x92\xff\xc3\xff\xee\xff!\x00d\x00\x94\x00\xc2\x00\x02\x01M\x01z\x01\x99\x01\xca\x01\xf9\x01!\x029\x02H\x02]\x02f\x02i\x02a\x02V\x02D\x025\x02%\x02\x0e\x02\x02\x02\xf0\x01\xe1\x01\xc8\x01\xa8\x01\x8e\x01\x89\x01z\x01j\x01i\x01W\x019\x01\x1f\x01\x05\x01\xf5\x00\xe8\x00\xcd\x00\xc5\x00\xb4\x00\x8e\x00n\x00E\x00\x19\x00\x00\x00\xdc\xff\xaf\xff\x9a\xff~\xffG\xff:\xff(\xff\xf9\xfe\xe2\xfe\xcd\xfe\xbc\xfe\x9c\xfe\x8e\xfe\x8f\xfe}\xfeW\xfeD\xfe]\xfeT\xfeU\xfec\xfe]\xfea\xfeo\xfe|\xfe\x83\xfe\x9c\xfe\xab\xfe\xb8\xfe\xe2\xfe\xf5\xfe\x12\xff.\xff/\xffK\xffx\xff\x96\xff\x9a\xff\xb4\xff\xda\xff\xed\xff\xfa\xff\xee\xff\xfc\xff\x1b\x00\x10\x00\x13\x00/\x00@\x00B\x00I\x00m\x00{\x00\x96\x00\xb4\x00\xd0\x00\xf3\x00\x1a\x018\x01h\x01\x99\x01\xbe\x01\xd0\x01\xe4\x01\x02\x02\x1e\x02$\x02\x07\x02\xfe\x01\xf3\x01\xcd\x01\x9c\x01n\x01F\x01\r\x01\xba\x00w\x00A\x00\x01\x00\xab\xffU\xff)\xff\xf0\xfe\xbf\xfe\x97\xfel\xfeM\xfe+\xfe\x1c\xfe\x15\xfe\x15\xfe\x14\xfe!\xfeS\xfev\xfe\x92\xfe\xb5\xfe\xe7\xfe\x12\xffD\xff|\xff\xb2\xff\xe7\xff\x0c\x000\x00`\x00~\x00\x89\x00\xa1\x00\xb2\x00\xc7\x00\xcb\x00\xc7\x00\xce\x00\xcd\x00\xc3\x00\xb0\x00\xae\x00\xa1\x00~\x00x\x00a\x00K\x00C\x00,\x008\x007\x00!\x00$\x009\x008\x000\x004\x00=\x00Q\x00M\x00A\x00N\x00S\x007\x00-\x006\x00?\x009\x00 \x00\x1e\x00\x17\x00\x08\x00\xfb\xff\xe3\xff\xce\xff\xb5\xff\x9b\xff\x93\xff\x8e\xff\x8a\xffz\xffe\xffW\xff^\xffa\xffa\xffe\xffs\xff\x86\xff\x88\xff\x89\xff\x92\xff\xa5\xff\xbc\xff\xcc\xff\xe2\xff\x07\x00\x10\x00\x1c\x004\x00H\x00W\x00n\x00\x8b\x00\x99\x00\xa2\x00\x9b\x00\x96\x00\x8d\x00\x88\x00\x7f\x00z\x00\x7f\x00v\x00j\x00g\x00[\x00J\x00H\x00B\x00A\x00?\x00<\x000\x00/\x00(\x00\x1c\x00\x13\x00\x06\x00\xfd\xff\xf0\xff\xf1\xff\xdd\xff\xbd\xff\xb2\xff\xab\xff\xa1\xff\x86\xffz\xff}\xffr\xffc\xffe\xffm\xfff\xff]\xffW\xfft\xff\x7f\xff\x80\xff\x86\xff\x8e\xff\x99\xff\xa2\xff\xa5\xff\xa3\xff\xab\xff\xae\xff\xb2\xff\xb3\xff\xb5\xff\xaf\xff\xaf\xff\xb1\xff\xa6\xff\xa3\xff\xb0\xff\xba\xff\xb5\xff\xbc\xff\xb8\xff\xb8\xff\xb7\xff\xc2\xff\xc6\xff\xcb\xff\xd4\xff\xda\xff\xeb\xff\xf3\xff\xff\xff\x08\x00\x14\x00\x17\x00\x1f\x00:\x00B\x00A\x00\\\x00g\x00o\x00\x7f\x00\x85\x00\x83\x00\x8f\x00\xa2\x00\x9b\x00\x91\x00\x91\x00\x8f\x00\x8b\x00\x90\x00\x98\x00\x8e\x00y\x00v\x00m\x00k\x00m\x00X\x00D\x00@\x00;\x00#\x00\x0c\x00\xf4\xff\xe1\xff\xdf\xff\xce\xff\xc0\xff\xab\xff\x96\xff\x85\xff{\xffj\xffh\xffl\xff\\\xffR\xffR\xffN\xffF\xff:\xff9\xff1\xff4\xff@\xffF\xffH\xffG\xffK\xffV\xffl\xff{\xff\x89\xff\x9d\xff\xb3\xff\xc9\xff\xd4\xff\xec\xff\x01\x00\x12\x00&\x00<\x00P\x00e\x00v\x00\x84\x00\x95\x00\x9d\x00\xa7\x00\xb2\x00\xbc\x00\xbc\x00\xb6\x00\xba\x00\xbc\x00\xb2\x00\xa8\x00\xa7\x00\xa5\x00\xa2\x00\x9e\x00\x9b\x00\x8e\x00{\x00t\x00n\x00j\x00[\x00S\x00I\x00C\x009\x00,\x00\'\x00\x1d\x00\x12\x00\x0e\x00\x04\x00\x07\x00\r\x00\x00\x00\xeb\xff\xe1\xff\xd6\xff\xd1\xff\xc4\xff\xb2\xff\xa3\xff\x8a\xff\x8a\xff\x8d\xff\x82\xff}\xff~\xffv\xffr\xff\x83\xffy\xffs\xff~\xffv\xffz\xff\x82\xff}\xffr\xff\x80\xff\x90\xff\x9b\xff\xa6\xff\xb1\xff\xbb\xff\xc8\xff\xce\xff\xd5\xff\xe1\xff\xf6\xff\x0c\x00\x15\x00\x14\x00\x1f\x008\x00G\x00W\x00[\x00b\x00t\x00\x84\x00\x7f\x00\x84\x00\x89\x00\x91\x00\x90\x00\x90\x00\x93\x00\x8d\x00\x90\x00\x81\x00{\x00d\x00c\x00\\\x00B\x00A\x00/\x00\x1c\x00\x1d\x00\t\x00\xf2\xff\xee\xff\xdc\xff\xcf\xff\xc8\xff\xb3\xff\xa5\xff\xb4\xff\xb3\xff\xb4\xff\x9c\xff\x8a\xff\xa4\xff\xa2\xff\x99\xff\xae\xff\x9b\xff\x93\xff\xab\xff\xc8\xff\xae\xff\x93\xff\xb0\xff\xe2\xff\xde\xff\xb4\xff\xb8\xff\x91\xff\x9d\xff\xb9\xff\xb1\xff\xc4\xff\xc0\xff\xad\xffa\xffC\xff\x84\xffn\xffP\xff\x92\xff\xbc\xff\xb5\xff\x9a\xff\x97\xff\xe4\xff\xc0\xff\x86\xff\xa5\xff\xae\xff\xfc\xff\x1d\x00v\x00\x04\x01\xf5\x00\x81\x01\x1e\x01\xef\x01>\x01\xa7\x01\xdd\x00\x00\xfd\xdc\x08\xea\x10G\x04\xce\xf3.\xfe\x19\x05\x10\x04\xaa\x02A\xff\xb1\xfa/\xf7\xce\x00\xc7\xf9[\xfc\xbd\xf9\xa5\xf40\xfe\xd9\x00\xc7\xfe5\xfe)\xfdy\xfb \xfb\xec\x05-\x0b\xb7\xfb\xba\xfeC\x04\xcf\x01\xe2\x03m\x03v\x03\xee\xffM\xfa\xe1\x00\x98\tR\x03`\xfad\x01\xae\x01\xfe\xf8\xb1\x00\x96\x04h\xff\xd1\xfb\xa5\xfb\xaa\x011\x00\xa1\xfc\xf5\x01+\xfeH\xf33\x02]\x05`\x00}\xfa\x9e\xfd\xee\x01\xc6\x00\xae\xfe@\xfb\x18\x07\x1b\x04a\xfa\x19\x02s\x06\xab\xfe}\x02\xd5\x02-\xff4\x02c\xfdu\x06~\x08\xcc\xfe\xf8\xf5\x18\x07a\x07\xc9\xfey\xff\x01\xff:\x01K\xff\xb0\x04\x9e\xfft\x03\x9a\xf9\xe8\xfb\xce\x01\xc3\xff\xdf\x02\xda\xf6 \x03\xcb\x00\x9a\xf6\x96\xfeS\x00\xd5\xff2\xf7&\xfei\x04\x9d\xf9\x03\xfd\xec\x06O\xfei\xf4\xf3\x02=\x06\x90\x01\xad\xfe\x18\x02\xe4\x00\xa4\x01L\xffp\x044\x05\x19\x00\xbb\xff\xa3\xfe2\x04\xec\xff\xdf\x04{\x04c\xfa\xbc\xfd\xc0\x0bG\xf9\xaf\xf9\x8a\x06\xf5\x01n\xfc\x0c\xfap\x068\xfd\xc1\xfa\\\xfcL\x05-\x01]\xf8\xd5\xfd\x99\x039\xf9=\x00\x8b\n\xde\xfbz\xf7A\x04\xd1\np\xf2Z\xff\x9e\x10x\xfb\xb7\xf3\x02\t\xe9\t\x08\xef\xfd\x01\xad\r\xf1\xfa)\xf5\xbc\xffa\x0b\xa8\xfci\xfc\x98\x05O\xfd\x7f\xfa>\x00\x13\t-\x00\xc4\xfa|\xfc\x9a\x05\xd3\x01\x15\xfa\r\x05L\xfe\xce\xf4L\x01\xda\x05\xf3\xfb\x91\x02\x1e\x01\xc5\xefS\x03\x18\x0b\xfe\xf6C\xff\x11\x06\xad\xff\x91\xfe\xfe\xff\x05\x04\xb6\x04\xa1\xf9\x03\xfe\xdf\x05\x99\xfb\x02\x03\xb4\x07u\xf6h\xffd\x05\xe1\xfc\xa1\xfd\x87\xfe\xd1\x01\x94\xff\xe6\x00N\xf8\x1d\x04\xb8\xfeL\xfa\xba\x04\xbb\xfd\x85\xf5\x8b\x07\xc0\x08\xe2\xf5\'\x07\x88\xffD\x01\x1f\x00\xb8\x04\xa8\x05\xca\xf8E\x01z\t\xcf\x00L\xfc\xc2\x02\x06\x01\xef\xf8\xb2\xfbH\x06\xc7\x06%\xf8\x86\xf3A\x0e\x02\xfe\'\xf3\xa5\x00\x14\n\x8a\xfeJ\xf0$\x07\x12\x07\xaa\xfc\x04\xfbf\x01U\x04\xac\xff\x9b\x02\x04\x06J\x01~\xfc\x9c\x00\x1a\x06)\x05\x9a\x05 \x04\x14\xf6\x1e\xfe1\x06O\x03\x9f\x03\x05\xf9,\xfc\x8f\x04P\xfb\xc4\xf8N\x03\xe1\x03o\xf7A\xf9{\xff\xf1\xfe\xcf\x05f\xf9\xe4\xfa#\x04\x82\xfcG\x009\x02\xb3\xfe\xe4\xff\x83\x02\x9e\xfb\x0c\xf9\x97\x05\x87\x04\x02\xfd:\x01\x98\xfa\x7f\xfa\xe2\x07\xab\x05[\xf4[\xfc\xe0\x0br\xfc\xc9\xfc\xdd\t\x16\x00\x93\xf3!\xfa:\x11\xcb\x06\xf9\xf24\x01\xb7\x07:\xf5\xfc\x00\xe4\x10\xf6\xfau\xe8\xdb\x00\xab\x16`\xfd\xea\xf9v\x06\xc3\xfdw\xea\xa8\x02\xff\x13\x10\x03v\xf7\x13\xfaq\x03\xcc\xff\x83\x02J\x06\xa2\xfd\x87\xf3#\xfc\x02\x0c#\x06\xb5\xfc\x86\xfd\xba\xfa\xc3\xf8\x13\xfe`\x0c\xe3\x02\x17\xf5\xb9\x00\x85\x05\x0c\xfeP\xff\x81\x08\xee\x01\xfe\xef8\xfd\xc3\x0b\xd3\x06\xdd\x03\xa1\xfb\xdc\xf7\xbf\xffM\x06\xd0\xfc\xf8\xff\xa0\x04\xaa\xfe\xa2\xfe\x98\xfcG\x01r\x05\xea\xfc\xc3\xf7\xe9\xfc\xd2\x04a\x06x\xfe\xec\xf6c\x01\x07\x03;\xff@\x000\x01\xc3\xfe:\xff\xa0\x05\xb4\x00\xe0\xfe\x1d\x02\xf3\x00\x97\xfd\xcd\xffO\x06\xcb\x02p\xfd\x01\xffS\x02\xbe\x01\x9c\xfd\x81\xfd\xf2\x03C\xff\xcf\xfb^\x01L\x03\xb7\x00e\xfb2\xfe;\x03K\xff\xb7\xf8`\x03\x1e\x04\x04\xfc\xe1\xffO\x01\xcd\xfew\xfb\xcc\x00\xc4\x04\xef\x00\xe6\xfa\x14\x01\x82\x04\xc8\xff\x94\x00\xa1\x00\xbd\xff\xb4\xfaU\x011\x084\x02t\xfc%\xfci\xfcZ\xfe\xf5\x06c\x06q\xf8[\xf6X\x02\x89\x04\xd4\xfeM\xff;\x05\xb6\xfe3\xf8\xbb\x00\xea\x04\xa5\x04U\xff\x98\xfc`\xfa\xf4\x02\x18\n\x92\x01\xe6\xf6\x9e\xfa\x9e\x022\x01A\x02\xd5\x01\xe9\x02D\xfbi\xfbb\x01\xae\x03V\x00!\xfd\x87\x00^\x03\xa2\x00\x07\xfc\xf3\x00\xcd\x00\xd2\xfeO\x00\xca\x02\xef\xfd\x02\xfc\x14\x02\xe9\x05S\x01\x17\xfa\xaf\xfc\xf2\x00j\xff\x0e\x03\x15\x06[\xfe\xaf\xf5z\xfdu\x03Y\x06\t\x01\x14\xf6\x9a\x00\x05\x05/\x04\xbb\xfd\xd0\xfcY\xfer\xfeh\x03\x9a\x03\x98\x00\x8f\xfb\xbe\x00x\x03\x1c\xff\xf8\xffc\x00:\x00\xd4\xfa\xa3\xfd_\t\x14\x08\xb5\xfc?\xf2$\xfb\x19\x08b\x04\xd0\x00T\xfb\xe9\xfa\xa5\xfe\xf6\x03\xd1\x05M\xfc\xb9\xf8N\xfe\x08\x04V\x03.\x03\xf8\x01t\xfa\x8d\xf82\x00\x96\x06\xd9\x04y\x00\x08\xfc\x97\xfb\x99\x02e\x06u\xff\xc0\xfbG\xfb\x14\xfe\xa3\x06M\x06\xcc\xffm\xfc\xf0\xfb\xbe\xfe\x14\x01\xaf\x00&\x03\xcf\x02\xeb\xf9%\xfa2\x05B\x06\xc4\xff\x99\xfa\x9f\xf7,\xff\x9a\x06\xd1\x04e\x01B\xfb\xb7\xfc\xb7\xfd\x1e\x03\xab\x04\xce\x02\x86\xf9+\xf8\x84\x04\x0c\x08`\x05s\xfb\xd6\xf8\xc5\xfe~\x01\x1a\x02e\x05\xd3\xfe@\xf9)\x00a\x06\x1f\x00\xf8\xfe\x83\xfe\xeb\xf7f\xff,\t\xb8\x05{\xfe\xa5\xfa\x16\xfb~\xff\xc5\x02\xdf\x03\n\x01p\xfe\xbc\xfa\xf9\xff)\x05D\x02\xcd\x00H\xfb\x1c\xfa\xaa\x01\xf1\x05\xe2\x00s\xfd\xfb\xfd\t\x01&\x01m\xff\xbf\xfdK\xff\xf1\x00\xe7\x01\x97\x02S\xfc\n\xff4\xffK\xfd%\x02\x92\x04\xac\x01\xff\xfa!\xfc\x8d\x01\x95\x02\xef\xff\x14\xfe\x83\xfeS\x00:\x026\x03%\x01\x18\xfb\xb8\xf8\x80\x00C\x07~\x05>\xfel\xfb\xf7\xff\x0c\x02\x84\x01\x98\x00}\xff\x82\xfd\x15\x00\xed\x01L\x03\xca\x01\x07\xfem\xfc5\xffW\xff\xca\xffI\x04d\x02\x89\xfc]\xfa\xda\x03\x9d\x04\x8f\xfcD\xff\x06\x00\x86\xfd\xa8\x03\x9f\x03q\xfe1\xfd_\xff;\x010\x04\xa8\x01\x87\xfe^\xfe\x0c\xfd\xec\x00I\x03\xa8\x02\xea\x01\xc7\xfd\xe2\xfa\xda\xfe \x04\xfb\x02\x04\xfeh\xfa\xfd\xfcZ\x00\xb2\x00\xf6\x01|\x02n\xfd\xf3\xf8n\xfe\xf0\x02\x9e\x04\x07\x04\xc9\xfc<\xfa`\x03\x0c\x05\x99\x00\xa7\xff\xd6\xfd[\x00s\x01\xd0\x00\x16\xffO\x00d\xffN\xfc\x93\xffw\x02O\x01\xa5\xff\xac\xfc\xae\xfeS\x04W\x00V\xfa\x82\xfdp\x00\t\x01X\x02\x93\x01\x00\xfd\x86\xfcT\x02`\x02\xdb\xfd>\x00\x8f\x02\xdc\xfeb\x00\xa2\x03I\x01\xe9\xfc\xcf\xfc\x02\x02)\x02\xe1\xfed\x00.\x02\x7f\x00\x05\xfd\xeb\xff<\xff\x7f\xfe\x9c\x03h\x00I\xfc@\xfd\x9b\x02\xa8\x04\xe9\x01-\xfd\xb5\xfb\xfa\xfc\xc3\x02\xf8\x03,\x02:\x02m\xff\x1d\xfd\x84\xfd\x88\x02@\x02\xe2\x00K\xfeb\xfe\x07\x01\xc3\x02\xe3\x01\xb1\xfd\xb3\xfc\t\xfdF\xff\xf6\xff\xb3\x00c\x03\x19\x02\xe1\xfc\xa5\xfaS\xfe\x15\x01s\x01:\x02h\xfe\xa4\xfc\x16\x01B\x03~\x00\xb4\xfey\xfd\x8f\xfc/\x01\xd7\x03\x14\x02\xb3\xfd~\xfe\x81\x000\x01\x05\x01R\xffv\xff\xba\xfd\x15\x01\x92\x03\xa2\x02\xf5\x00\n\xfe>\xfc\xe3\xfd\x19\x03E\x04\xa3\x00\x18\xfd\x06\xfe\x91\xff_\x01\x1e\x03\x9f\xfe\xb1\xfb\xc2\xfdU\x01\x81\x02@\x01\xb3\xff\xab\xfd)\xfe\xa7\xff\xca\x00w\x00~\xff\xc0\xffm\x00\xc6\xff\xb3\x00\x00\x03\xd5\xfe\x83\xfc\xa8\xfeN\x00\xfa\x00\xd0\x02\xa4\x024\xfd\x1f\xfe\xb9\x00\x9c\xffb\x00n\x00y\xff`\xff\x8a\x00\x88\x01\x06\x00\t\xff\xfa\xff\x81\xfe\x19\xfe\xf6\xff0\x01$\x01\xc0\xff\x87\xfe\xc7\xff,\x00\x11\x00\xc1\xff\x1c\xff`\xff\xca\x00*\x00>\xff\xc0\x01)\x01U\xfek\xfej\xff\xd2\x00\x9c\x02\xf3\x01j\xff\xd8\xfd\xcf\xfe\xb6\x01\xb8\x01\x94\xff\x9a\xff\xfa\xfe\x99\xff\x95\x01\xa1\x01S\x00\x87\xfe=\xfe\xe9\xfes\x00\xd7\x00\xd6\x01{\x00\xae\xfc\x85\xff\x83\x01\xad\x00!\x00\xdf\xfdU\xfe\xac\x00\xa5\x02\xb4\x01]\xffl\xfe7\xfe\xcd\xff\xeb\x00h\x00\x0b\x00\xbc\xff_\x00H\x01\x8c\x00\xdb\xff\x10\xff\x04\xfe\xe6\xffq\x01\xdf\x00\xfe\x00\x12\x01\xae\x00C\xff\xe8\xfd\x1d\xfe\xa2\xffc\x01\xad\x01\x02\x01\xd4\xff>\xff\xda\xff\xae\xff{\xff\xdb\xff#\x00\xd0\x00%\x01\x17\x00/\x01(\x02E\x00\xa6\xfd\xff\xfc\xa9\xff\xdd\x01\n\x02\x8d\x00\xb0\xfe\xe6\xfe\x0f\x00\xb1\x00#\x00\x1f\xff\x88\xfe\x19\xff\xde\x00\x90\x01\x90\x01\x95\x00\x83\xfe\xa6\xfd\x83\xfe\xbe\x00\xbf\x01\x16\x01F\x00W\xff\x8e\xff\x7f\x00T\x01\xd0\x00Q\xff\xf3\xfe\xb0\xff`\x01J\x01\xed\x00\x9e\xff\x7f\xfd\x03\xfe\x14\x00\xff\x00\x87\x00\xe7\xffY\xff\xe4\xff\xca\x00\x89\x00\x91\xff\xf3\xfe\xc5\xfe\x8a\x00\x93\x01\xaf\x00\x85\x00\x91\x00\xed\xffj\xff\xb1\xff\x93\x00c\x00\xdb\xff_\x00R\x01\x06\x01u\xff\x11\xff\'\xff\xa3\xff\xb0\xff\\\xffq\xffr\xff/\x00\xdc\x00\xd7\xff\xd2\xfe\x80\xfe\xb7\xfel\x00\xab\x01\x10\x01\x10\x00|\xffT\xff\xbc\xff>\x00\xc4\x00\xc4\x00Q\x00\x82\xff\x85\xff^\x00\xb6\x00\x1b\x00%\xff\x0b\xff\xaa\xff\x97\x004\x01\x8e\x00R\xff\xe1\xfeS\xff\xe3\xff+\x00\xff\xff\x02\x00\xe6\xff\xd5\xff\x91\xff8\xffx\xff\xf6\xffE\x00\x08\x00\xed\xff\x83\xff\xaf\xff\xb0\x00\xef\x00\xaf\xff\xcd\xfe\x98\xffs\x00\xc0\x00\x1c\x01\xa0\x00\xf7\xfe\xb4\xfe\xd1\xff\x03\x008\x00\xb7\x00\x89\xff&\xfe^\xff@\x01m\x01O\x00\xa9\xfe\xfe\xfd>\xff\xff\x00}\x01\xa0\x00L\xff\x8e\xfeD\xff8\x00\xe5\x00\xd1\x00C\xff\xfc\xfe\x00\x00\x1b\x01\x10\x01\xa3\xff\xc7\xfee\xff\x89\x00\x03\x01u\x00\xa7\xff\x99\xff\xd9\xff3\x00,\x00\xc4\xffM\xff\x83\xff5\x00h\x00\x04\x00\xc6\xff\x92\xff`\xff\x8d\xffz\xffg\xff\xfa\xff\x82\x00{\x00\x1b\x00\xbc\xff\x93\xffs\xff\xbd\xff6\x00\x9f\x00\x8b\x00\xe1\xff\xf8\xff\x85\x00\xc1\x00,\x00*\xff\xef\xfe\x14\x00>\x01\x19\x01N\x00\xa9\xff\x7f\xfff\xff*\x00\xab\x00\x00\x00\xb1\xff\xe5\xff?\x00\xcc\x00\xcd\x00z\xff~\xfe;\xff\x91\x00\x00\x01\xc3\x00A\x00\x86\xff\xe2\xfe\x91\xff\xea\x00\xb3\x00\xfa\xff{\xff\xa1\xffM\x00\xe5\x00\xda\x00\xf2\xff\x01\xff\xcb\xfe\xc8\xff\xdb\x00\xd2\x00\\\x00\xd4\xffC\xffm\xff\xf6\xff\xf4\xff\xc7\xff\x9b\xff\xaf\xff<\x00\x9a\x00d\x00\xd9\xffT\xffQ\xff\x0e\x00\xea\x00\xc3\x001\x00V\x00\x7f\x009\x00F\x00\x11\x00}\xff\x8c\xff\x95\x006\x01\xd1\x00\x04\x00\x1e\xff\xae\xfeV\xff\x8e\x00\xfc\x00\x8a\x00\x16\x00_\xffr\xff4\x00o\x00<\x00z\xffE\xff\x17\x00/\x01$\x01F\x00h\xff\x15\xff\xb2\xff\xcf\x00M\x01\xf6\x00q\x00\xf6\xff\xcc\xff0\x00k\x00\xb8\xffM\xff\x97\xff!\x00\x97\x00o\x00\xbf\xff\x0c\xff\xef\xfea\xff\xd8\xff\x15\x00\x00\x00\xc5\xff\xba\xff\xcb\xff\xed\xff\xf8\xff\xbf\xff\x94\xff\xa4\xff\xf9\xffT\x00m\x00-\x00\xa7\xff\x8b\xff\xda\xff?\x00\xbc\x00\xc5\x00b\x00\xf4\xff\xec\xff\xf4\xff\xde\xff\xe1\xff\xe9\xff\xfa\xff\n\x00(\x00.\x00\xd2\xffS\xffx\xff\xe8\xff\x0c\x00\x10\x003\x002\x005\x00U\x00\xff\xff\xc0\xff\xdb\xff\x07\x008\x00j\x00s\x00c\x00L\x00\x18\x00\xae\xff\x96\xff\xf4\xff\x05\x001\x00\x8a\x00W\x00\xbf\xff\x88\xff\xbf\xff\xd5\xff\xfe\xff\x03\x00\xc2\xff\xc6\xff9\x00\xa5\x007\x00_\xff\xf6\xfe`\xff$\x00Y\x00\xf0\xff\xae\xff\x9e\xff\xb7\xff\xee\xff\xdf\xff\x89\xffG\xff\x93\xff\xe9\xffJ\x00g\x00\xf8\xff\x95\xff\xa6\xff\xe5\xff\x18\x00.\x00\x0b\x00\xb7\xffx\xff\xec\xffb\x009\x00\x94\xffR\xff\xa0\xff\xcf\xff\x1e\x00U\x00\xf4\xffp\xff\x92\xff\x00\x008\x001\x00\xf5\xff\x8f\xff\x86\xff\xe2\xff:\x005\x00\xe4\xff\x9f\xff\xa1\xff\xeb\xffV\x00K\x00$\x00!\x00D\x00t\x00g\x00\x1e\x00\xf4\xff\x0b\x00F\x00e\x00<\x00\xdd\xff\xb0\xff\xca\xff\xf5\xff\xff\xff\xca\xff\x9b\xff\xa1\xff\xe5\xff:\x00N\x00\xec\xfft\xffs\xff\xd3\xffA\x00;\x00\xee\xff\x98\xffw\xff\xde\xff]\x00$\x00~\xffW\xff\xaa\xff!\x00r\x002\x00\xb3\xffi\xff\xb0\xff3\x00_\x00 \x00\xd8\xff\xc3\xff\xd8\xff*\x00~\x00I\x00\xb5\xff\\\xff\x82\xff\xfc\xffi\x00w\x00\x0c\x00\x8c\xff\x84\xff\xf2\xff|\x00\x8b\x00.\x00\xe0\xff\xfd\xff\\\x00\xa5\x00\x93\x00\'\x00\xd1\xff\xce\xff\x12\x00Y\x00K\x00\x0e\x00\xe4\xff\xd0\xff\xf9\xff/\x00 \x00\xde\xff\xc2\xff\x03\x005\x00?\x00-\x00\r\x00\xee\xff\xf0\xff*\x00S\x00C\x00\r\x00\xf2\xff\x13\x00T\x00}\x00M\x00\xc4\xff\x9c\xff\x05\x00r\x00f\x00$\x00\xf5\xff\xc8\xff\xf2\xffH\x00M\x00\xf0\xff\xb7\xff\xda\xff\t\x00E\x00y\x008\x00\xb9\xff\x9a\xff\xef\xffF\x00M\x00\t\x00\xd4\xff\xdc\xff&\x00]\x00?\x00\xf0\xff\xdb\xff+\x00S\x00M\x00A\x00 \x00\xee\xff\xff\xffA\x00:\x00\xf2\xff\xc0\xff\xcd\xff\xf5\xff\x1b\x00\x1e\x00\xfc\xff\xcf\xff\xd3\xff\x01\x00\n\x00\xdb\xff\xd5\xff\xef\xff\x00\x00\x07\x00\xfc\xff\xe6\xff\xc3\xff\xd5\xff\xe6\xff\xfb\xff\x06\x00\xf1\xff\xd3\xff\xf0\xffG\x002\x00\xf8\xff\xd4\xff\xe7\xff\x15\x00F\x008\x00\xf6\xff\xbd\xff\xcb\xff\x15\x001\x00\x08\x00\xb2\xff\x8d\xff\xb7\xff\x07\x002\x00\x08\x00\xbe\xff\x9a\xff\xb5\xff\xfa\xffD\x00%\x00\xd8\xff\xb9\xff\xda\xff:\x00{\x008\x00\xbd\xff\xa6\xff\xdf\xff8\x00h\x004\x00\xaa\xff\x8c\xff\xf6\xff3\x003\x00\x04\x00\xc7\xff\xa4\xff\xea\xffB\x000\x00\xeb\xff\xb5\xff\xa8\xff\xce\xff\x12\x004\x00\x14\x00\xd9\xff\xbe\xff\xda\xff\x04\x00\x0f\x00\x06\x00\xc5\xff\xb4\xff\xf6\xff0\x008\x00\x0f\x00\xde\xff\xa9\xff\xc2\xff\xf0\xff\x00\x00\xf2\xff\xd2\xff\xbe\xff\xb2\xff\xd6\xff\xf7\xff\xfa\xff\xd8\xff\xc6\xff\xde\xff\x08\x00"\x00\x00\x00\xdb\xff\xcd\xff\xe9\xff\xf9\xff\x1b\x00%\x00\x04\x00\xf9\xff\x06\x00\x0c\x00\xff\xff\xf4\xff\xf4\xff\xed\xff\xe5\xff\xf1\xff\xfc\xff\xe6\xff\xc2\xff\xaf\xff\xb6\xff\xc6\xff\xdc\xff\xe2\xff\xd7\xff\xd3\xff\xd3\xff\xd3\xff\xd3\xff\xdf\xff\xe4\xff\xd4\xff\xea\xff\x03\x00\x07\x00\t\x00\x0b\x00\xf5\xff\xe1\xff\xee\xff\x16\x00*\x00\x1a\x00\x0b\x00\x04\x00\x07\x00\x0c\x00\n\x00\x02\x00\xfe\xff\xf8\xff\xf4\xff\x02\x00\x12\x00\t\x00\x03\x00\x02\x00\xf7\xff\xf4\xff\xfe\xff!\x00\x1e\x00\x05\x00\x0f\x00\x11\x00\x01\x00\xfc\xff\x06\x00\x01\x00\x01\x00\x06\x00\x03\x00\x05\x00\x07\x00\x00\x00\xfe\xff\xfc\xff\x00\x00\x10\x00\x0e\x00\t\x00\n\x00\x14\x00\x19\x00\x1a\x00\x11\x00\x00\x00\xfe\xff\x08\x00\x17\x00\x15\x00\x02\x00\xf2\xff\xfa\xff\x0f\x00\x1c\x00\x04\x00\xfc\xff\x11\x00\x1d\x002\x00=\x00A\x00(\x00\x14\x00\x1d\x00-\x001\x00*\x00\x19\x00\x0e\x00\x16\x00\x1f\x00.\x00\x1d\x00\xfe\xff\xf2\xff\x05\x00,\x005\x00(\x00\t\x00\xfb\xff\r\x00%\x003\x00%\x00\x13\x00\x11\x00\x16\x00"\x00+\x00!\x00\t\x00\x00\x00\x12\x00\x05\x00\x07\x00*\x00\x11\x00\xef\xff\xe4\xff\xf4\xff\xfb\xff\xfa\xff\xeb\xff\xd5\xff\xdf\xff\x0f\x00*\x00\x0c\x00\x00\x00\x03\x00\x05\x00\xfc\xff\xfd\xff\x07\x00\x05\x00\n\x00\x14\x00\x12\x00\x01\x00\xfe\xff\xf3\xff\xe9\xff\xe2\xff\xf8\xff\x05\x00\xff\xff\xfa\xff\xf7\xff\xf4\xff\xf0\xff\xf6\xff\xfc\xff\xfb\xff\xfe\xff\x04\x00\x01\x00\xfe\xff\x02\x00\xfe\xff\xf5\xff\xeb\xff\xef\xff\xfc\xff\x00\x00\xf6\xff\xdf\xff\xd6\xff\xea\xff\xf7\xff\xf9\xff\xea\xff\xd7\xff\xdc\xff\xf8\xff\x06\x00\xfc\xff\xf2\xff\xfc\xff\xf4\xff\xfe\xff\x18\x00\x17\x00\xff\xff\xf5\xff\x01\x00\x08\x00\x0c\x00\r\x00\xfc\xff\xef\xff\xf0\xff\xf4\xff\xf4\xff\xe9\xff\xdf\xff\xcf\xff\xcf\xff\xe8\xff\xf1\xff\xf4\xff\xf0\xff\xed\xff\xe4\xff\xf1\xff\x03\x00\x06\x00\x00\x00\x04\x00\x0c\x00\xfb\xff\x0b\x00"\x00\x0e\x00\xf0\xff\xf8\xff\x0e\x00\x12\x00\x02\x00\x0e\x00\x05\x00\xdf\xff\xea\xff\xf3\xff\xec\xff\xda\xff\xd0\xff\xc9\xff\xc4\xff\xc6\xff\xc9\xff\xbc\xff\xaa\xff\xb6\xff\xc1\xff\xc8\xff\xd3\xff\xc8\xff\xce\xff\xd3\xff\xdf\xff\xec\xff\xeb\xff\xea\xff\xf2\xff\x00\x00\t\x00\x10\x00\n\x00\xf8\xff\xf6\xff\xf7\xff\xf1\xff\xf5\xff\xf9\xff\xf3\xff\xea\xff\xe4\xff\xef\xff\xe3\xff\xd8\xff\xd6\xff\xda\xff\xe9\xff\xf1\xff\xf7\xff\xf0\xff\xe5\xff\xde\xff\xeb\xff\xfe\xff\xfb\xff\xfa\xff\xfe\xff\xfd\xff\x00\x00\n\x00\x0c\x00\xfe\xff\xf2\xff\xfb\xff\x05\x00\x15\x00\x1d\x00\x0c\x00\x03\x00\x05\x00\x0c\x00\r\x00\x10\x00\x11\x00\x10\x00\x0b\x00\x12\x00\x12\x00\x08\x00\x06\x00\x00\x00\x03\x00\x0f\x00"\x00\x1e\x00\x0f\x00\t\x00\x07\x00\x06\x00\x15\x00\x16\x00\x00\x00\xf6\xff\x00\x00\x13\x00\x0b\x00\xf9\xff\xe9\xff\xe1\xff\xe3\xff\xf7\xff\x00\x00\xee\xff\xd9\xff\xd8\xff\xec\xff\xf0\xff\xf7\xff\xfa\xff\xf6\xff\xf8\xff\x05\x00\x16\x00\x14\x00\t\x00\x04\x00\x04\x00\x0e\x00 \x00,\x00&\x00\x1f\x00\x19\x00%\x00)\x00\x1b\x00\x10\x00\n\x00\x07\x00\r\x00\x18\x00\x04\x00\xfc\xff\t\x00\x07\x00\x05\x00\x0b\x00\t\x00\r\x00\x03\x00\x02\x00\x05\x00\r\x00\x1e\x00 \x00 \x00\x1d\x00\'\x00"\x00\x13\x00\x12\x00\x18\x00\x14\x00\x13\x00\x12\x00\x1c\x00 \x00\x1f\x00#\x00\x18\x00\x17\x00\x1a\x00\x1a\x00\x18\x00 \x00\x1c\x00\x10\x00\x13\x00\x04\x00\xf9\xff\xfe\xff\x0e\x00\x11\x00\t\x00\x05\x00\x00\x00\xfe\xff\x08\x00\t\x00\xfe\xff\xf8\xff\xf8\xff\x00\x00\x00\x00\x00\x00\x08\x00\xfb\xff\xfc\xff\x05\x00\x04\x00\x00\x00\xfe\xff\xf8\xff\xf5\xff\xf5\xff\xf7\xff\xfb\xff\x01\x00\x01\x00\xf6\xff\xf6\xff\xfc\xff\xfc\xff\xf7\xff\xf9\xff\xfc\xff\xf9\xff\xf8\xff\xff\xff\xfe\xff\xf7\xff\xf4\xff\xed\xff\xe8\xff\xed\xff\xec\xff\xe3\xff\xe8\xff\xe4\xff\xde\xff\xe1\xff\xe7\xff\xde\xff\xdc\xff\xe2\xff\xe1\xff\xe7\xff\xf6\xff\xfc\xff\xfb\xff\x00\x00\x0c\x00\x07\x00\x04\x00\x06\x00\xfd\xff\xfd\xff\xfe\xff\xfb\xff\xf8\xff\xed\xff\xee\xff\xf3\xff\xf1\xff\xef\xff\xeb\xff\xea\xff\xeb\xff\xf3\xff\xfc\xff\xfa\xff\xf7\xff\xfb\xff\x05\x00\x06\x00\x00\x00\xfe\xff\xff\xff\xfa\xff\xf3\xff\xfa\xff\x01\x00\xfe\xff\xfb\xff\x00\x00\xf7\xff\xf9\xff\xfa\xff\x02\x00\x06\x00\x05\x00\x02\x00\xff\xff\x05\x00\xfd\xff\xfd\xff\xf5\xff\xf4\xff\xf3\xff\xea\xff\xe9\xff\xe6\xff\xe4\xff\xdb\xff\xd6\xff\xdb\xff\xe5\xff\xe4\xff\xe3\xff\xd4\xff\xd4\xff\xe1\xff\xdd\xff\xe6\xff\xe8\xff\xf1\xff\xf5\xff\xf4\xff\xf6\xff\xf4\xff\xf4\xff\xe8\xff\xe5\xff\xeb\xff\xef\xff\xf1\xff\xf1\xff\xe4\xff\xdb\xff\xe0\xff\xe2\xff\xe9\xff\xec\xff\xeb\xff\xeb\xff\xe9\xff\xf0\xff\xf8\xff\xf0\xff\xee\xff\xf5\xff\xfb\xff\xf6\xff\xfc\xff\x00\x00\xfa\xff\x01\x00\x00\x00\x05\x00\x06\x00\x03\x00\xff\xff\xf6\xff\x03\x00\x06\x00\xfe\xff\x02\x00\x06\x00\xfc\xff\xfc\xff\x00\x00\xfe\xff\x00\x00\xfe\xff\xf9\xff\xfc\xff\x03\x00\x03\x00\x00\x00\xff\xff\x02\x00\x10\x00\x11\x00\x10\x00\x13\x00\x1c\x00"\x00\x1b\x00\x14\x00\x13\x00\x11\x00\x10\x00\x0b\x00\x00\x00\xfc\xff\xfd\xff\x00\x00\xfc\xff\xf5\xff\xf2\xff\xf7\xff\xf9\xff\xfe\xff\t\x00\x0c\x00\x12\x00\x10\x00\x06\x00\x02\x00\r\x00\r\x00\x08\x00\x03\x00\x04\x00\x05\x00\xfa\xff\xf9\xff\xfb\xff\xf9\xff\xf7\xff\xff\xff\x02\x00\x01\x00\x02\x00\x01\x00\x01\x00\xfd\xff\xfe\xff\x01\x00\x07\x00\x0c\x00\x12\x00\x18\x00\x11\x00\x14\x00#\x00\x1f\x00\x1f\x00(\x00\x1f\x00\x1e\x00&\x00\x1f\x00\x17\x00\x11\x00\x15\x00\x16\x00\x14\x00\x16\x00\x13\x00\r\x00\x15\x00\x1b\x00\x0f\x00\x16\x00 \x00\x1c\x00\x16\x00\x13\x00\x0f\x00\x0f\x00\x0f\x00\x10\x00\x0e\x00\n\x00\n\x00\x02\x00\x05\x00\x04\x00\x04\x00\x00\x00\x00\x00\x04\x00\x05\x00\x04\x00\xfd\xff\xf8\xff\xfb\xff\xfa\xff\xfa\xff\xf8\xff\xf8\xff\xfb\xff\xfa\xff\xf5\xff\xf7\xff\xf7\xff\xfa\xff\xf6\xff\xf6\xff\xfa\xff\xf9\xff\xf9\xff\xf8\xff\xfb\xff\xff\xff\x00\x00\xfb\xff\xfb\xff\x04\x00\x04\x00\xfd\xff\xfa\xff\x05\x00\x01\x00\x07\x00\x13\x00\r\x00\x03\x00\x04\x00\x01\x00\xff\xff\xfc\xff\xfd\xff\xfb\xff\x02\x00\t\x00\xff\xff\xfd\xff\xfd\xff\xfd\xff\xf4\xff\xee\xff\xfb\xff\x01\x00\xfb\xff\xfb\xff\x00\x00\xff\xff\x01\x00\x06\x00\x07\x00\x00\x00\x02\x00\x07\x00\x00\x00\x01\x00\xfb\xff\xf3\xff\xee\xff\xee\xff\xf1\xff\xf4\xff\xee\xff\xf5\xff\xfb\xff\xef\xff\xea\xff\xe8\xff\xef\xff\xec\xff\xf2\xff\xf1\xff\xec\xff\xee\xff\xeb\xff\xe7\xff\xe4\xff\xed\xff\xef\xff\xec\xff\xec\xff\xea\xff\xef\xff\xeb\xff\xe7\xff\xed\xff\xf7\xff\xf9\xff\xff\xff\xfe\xff\xf3\xff\xef\xff\xee\xff\xf3\xff\xf0\xff\xee\xff\xee\xff\xea\xff\xe6\xff\xe6\xff\xe9\xff\xe7\xff\xf0\xff\xe9\xff\xe3\xff\xe7\xff\xe7\xff\xec\xff\xef\xff\xf1\xff\xf4\xff\xf5\xff\xf1\xff\xf4\xff\x00\x00\xf8\xff\xf9\xff\x00\x00\xfb\xff\xfc\xff\xfd\xff\x00\x00\xf8\xff\xf9\xff\xfb\xff\xf4\xff\xef\xff\xf8\xff\xf6\xff\xf4\xff\xf8\xff\xf9\xff\xfb\xff\xfa\xff\xfc\xff\xf8\xff\xff\xff\x00\x00\xfd\xff\xf7\xff\xf6\xff\xf8\xff\xfc\xff\x06\x00\x04\x00\x02\x00\x08\x00\x05\x00\xfd\xff\xfe\xff\xfe\xff\xfc\xff\xfb\xff\xf8\xff\xef\xff\xf2\xff\xf2\xff\xf1\xff\xf5\xff\xec\xff\xec\xff\xf0\xff\xef\xff\xec\xff\xec\xff\xf1\xff\xf6\xff\xf4\xff\xf5\xff\xfa\xff\xfe\xff\x05\x00\x04\x00\x04\x00\x05\x00\x03\x00\x03\x00\x04\x00\t\x00\x0c\x00\t\x00\t\x00\t\x00\x08\x00\x08\x00\t\x00\r\x00\r\x00\x0c\x00\n\x00\t\x00\x08\x00\x08\x00\x0c\x00\x0c\x00\x0b\x00\n\x00\x08\x00\n\x00\x0c\x00\x03\x00\x04\x00\x00\x00\x05\x00\x0e\x00\r\x00\x0c\x00\r\x00\x15\x00\x0e\x00\t\x00\x0e\x00\x11\x00\x0b\x00\x0b\x00\t\x00\x0e\x00\x15\x00\x14\x00\x15\x00\x0e\x00\x0f\x00\x14\x00\x16\x00\x12\x00\x16\x00\x19\x00\x12\x00\x11\x00\x0b\x00\x01\x00\x01\x00\n\x00\x0b\x00\x0f\x00\x14\x00\x12\x00\x10\x00\x17\x00\x14\x00\x16\x00\x15\x00\n\x00\x0b\x00\x0f\x00\r\x00\x0f\x00\n\x00\r\x00\x10\x00\r\x00\r\x00\x10\x00\r\x00\x04\x00\x00\x00\x07\x00\n\x00\n\x00\n\x00\x08\x00\x05\x00\x02\x00\x06\x00\x05\x00\x07\x00\x04\x00\x02\x00\x05\x00\x02\x00\xff\xff\xfa\xff\xfa\xff\xf9\xff\xf4\xff\xf3\xff\xf6\xff\xf4\xff\xf7\xff\xf7\xff\xf8\xff\xf6\xff\xf8\xff\xee\xff\xea\xff\xee\xff\xf2\xff\xf6\xff\xfa\xff\xfc\xff\x00\x00\x01\x00\x05\x00\x06\x00\x04\x00\x03\x00\x00\x00\x01\x00\xfd\xff\xfb\xff\xfa\xff\xf4\xff\xf4\xff\xf7\xff\xf6\xff\xf3\xff\xf3\xff\xf2\xff\xf3\xff\xf5\xff\xf4\xff\xf4\xff\xfc\xff\xfe\xff\x00\x00\x02\x00\xfe\xff\xfa\xff\xfc\xff\xfc\xff\xf7\xff\xf8\xff\xfd\xff\xfe\xff\xfd\xff\xfd\xff\xfc\xff\xfd\xff\xf6\xff\xf5\xff\xf5\xff\xfa\xff\xfc\xff\xfa\xff\xfc\xff\xf9\xff\xfa\xff\xf4\xff\xf6\xff\xf4\xff\xee\xff\xee\xff\xf0\xff\xee\xff\xed\xff\xea\xff\xea\xff\xea\xff\xe5\xff\xeb\xff\xe8\xff\xed\xff\xf2\xff\xed\xff\xf3\xff\xf1\xff\xf5\xff\xf4\xff\xf7\xff\xfa\xff\xf2\xff\xf6\xff\xf0\xff\xea\xff\xef\xff\xf2\xff\xef\xff\xec\xff\xe6\xff\xe3\xff\xe3\xff\xe1\xff\xe5\xff\xec\xff\xe6\xff\xe6\xff\xea\xff\xee\xff\xf4\xff\xf3\xff\xf5\xff\xfb\xff\xfd\xff\xf9\xff\xfd\xff\xfa\xff\xf5\xff\xfa\xff\xf9\xff\x00\x00\xfc\xff\xff\xff\xff\xff\xf3\xff\xfb\xff\x00\x00\x00\x00\x00\x00\x00\x00\xfd\xff\x00\x00\xff\xff\xfc\xff\x00\x00\xff\xff\xfe\xff\x01\x00\x04\x00\xff\xff\xfc\xff\xff\xff\x02\x00\x03\x00\x05\x00\x03\x00\x03\x00\x06\x00\x07\x00\x04\x00\x03\x00\x07\x00\x03\x00\x00\x00\xfe\xff\xf9\xff\xfb\xff\xf9\xff\xfa\xff\xfa\xff\xf7\xff\xf4\xff\xf5\xff\xf4\xff\xf9\xff\xfe\xff\x00\x00\x07\x00\x05\x00\x04\x00\x02\x00\x04\x00\x08\x00\x03\x00\x01\x00\x01\x00\x04\x00\x01\x00\x02\x00\x05\x00\x02\x00\x00\x00\x06\x00\x08\x00\n\x00\x07\x00\t\x00\n\x00\x02\x00\x04\x00\x05\x00\x07\x00\x04\x00\x07\x00\x02\x00\x00\x00\x04\x00\x0c\x00\t\x00\x08\x00\x0c\x00\x0b\x00\r\x00\r\x00\x0c\x00\x0c\x00\x0f\x00\x10\x00\x11\x00\x12\x00\x13\x00\x18\x00\x16\x00\x18\x00\x15\x00\x13\x00\x14\x00\x13\x00\x12\x00\x0f\x00\r\x00\t\x00\x07\x00\x06\x00\x06\x00\x06\x00\x04\x00\x00\x00\xfc\xff\x01\x00\x03\x00\x07\x00\x02\x00\x02\x00\x07\x00\x07\x00\t\x00\x07\x00\t\x00\t\x00\x06\x00\x08\x00\x05\x00\x03\x00\xff\xff\x00\x00\xff\xff\xff\xff\x02\x00\x04\x00\x07\x00\x06\x00\x02\x00\x02\x00\x04\x00\x01\x00\x01\x00\x05\x00\x03\x00\xff\xff\xfa\xff\xff\xff\xff\xff\xfa\xff\xf9\xff\x00\x00\xff\xff\xfd\xff\x01\x00\x00\x00\xfe\xff\x00\x00\xfd\xff\x00\x00\x00\x00\x02\x00\x02\x00\x06\x00\x08\x00\x02\x00\x01\x00\x04\x00\x07\x00\x03\x00\x00\x00\x05\x00\x03\x00\xff\xff\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x02\x00\xfe\xff\xf9\xff\xfa\xff\xfb\xff\xfd\xff\xf8\xff\xf5\xff\xf3\xff\xed\xff\xf2\xff\xf5\xff\xf3\xff\xf6\xff\xf7\xff\xf6\xff\xf7\xff\xf6\xff\xf8\xff\xf5\xff\xfe\xff\xfd\xff\xff\xff\x04\x00\x00\x00\xfa\xff\xfa\xff\xfc\xff\xf6\xff\xf4\xff\xf8\xff\xf8\xff\xfa\xff\xfa\xff\xf6\xff\xfa\xff\xfc\xff\xfe\xff\x03\x00\x00\x00\xf7\xff\xf4\xff\xf4\xff\xf8\xff\xf8\xff\xf8\xff\xfa\xff\xf4\xff\xf1\xff\xf3\xff\xf4\xff\xf2\xff\xf0\xff\xef\xff\xef\xff\xeb\xff\xee\xff\xee\xff\xf0\xff\xf2\xff\xf3\xff\xf5\xff\xf2\xff\xf2\xff\xf7\xff\xf5\xff\xf1\xff\xf6\xff\xf9\xff\xfa\xff\xf3\xff\xf1\xff\xef\xff\xed\xff\xe8\xff\xe9\xff\xea\xff\xf1\xff\xf1\xff\xf0\xff\xf7\xff\xf6\xff\xfc\xff\xf7\xff\xf8\xff\xf7\xff\xfd\xff\x00\x00\xf6\xff\xf6\xff\xfb\xff\xfb\xff\xfe\xff\x04\x00\xfe\xff\xf9\xff\x01\x00\xff\xff\xfd\xff\xff\xff\xfe\xff\xfa\xff\xfb\xff\xfc\xff\xfc\xff\xff\xff\xfb\xff\xfc\xff\xfe\xff\xfb\xff\xfd\xff\xfd\xff\xfa\xff\xf9\xff\xfa\xff\xfd\xff\xfe\xff\xfd\xff\x00\x00\xff\xff\x00\x00\x03\x00\x02\x00\x03\x00\x02\x00\x03\x00\x04\x00\x04\x00\x06\x00\x05\x00\x03\x00\x01\x00\xff\xff\xff\xff\xfa\xff\xfa\xff\xfd\xff\xfe\xff\xfc\xff\xfe\xff\xfe\xff\xfd\xff\xfb\xff\xfb\xff\xfb\xff\xf9\xff\xf9\xff\xf9\xff\xfc\xff\xfd\xff\xf8\xff\xf9\xff\xf6\xff\xf3\xff\xfa\xff\xfa\xff\xf6\xff\xf8\xff\xfc\xff\xfa\xff\xfa\xff\xf9\xff\xfb\xff\xf9\xff\xff\xff\xff\xff\xfe\xff\x00\x00\xff\xff\xfe\xff\xfc\xff\x00\x00\x01\x00\x06\x00\x00\x00\x03\x00\t\x00\t\x00\t\x00\x06\x00\x04\x00\x02\x00\x0c\x00\t\x00\t\x00\x0f\x00\x12\x00\x13\x00\x17\x00\x19\x00\x1d\x00\x1d\x00\x16\x00\x16\x00\x18\x00\x19\x00\x15\x00\x16\x00\x16\x00\x14\x00\x13\x00\x17\x00\x16\x00\x10\x00\r\x00\n\x00\x0b\x00\t\x00\x04\x00\x03\x00\x04\x00\x03\x00\x03\x00\x05\x00\x02\x00\x03\x00\x04\x00\x02\x00\x02\x00\xff\xff\xfc\xff\xfb\xff\xff\xff\xff\xff\xfb\xff\xfe\xff\x01\x00\x01\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\xfe\xff\xfd\xff\xff\xff\x02\x00\x00\x00\x00\x00\x01\x00\x04\x00\x06\x00\x05\x00\x08\x00\x05\x00\x04\x00\x06\x00\x05\x00\x01\x00\x00\x00\x02\x00\xfc\xff\xfd\xff\xff\xff\xfe\xff\xfa\xff\xfa\xff\xf8\xff\xf4\xff\xfa\xff\xfe\xff\xfa\xff\xfe\xff\xfe\xff\x01\x00\x01\x00\xfc\xff\xfd\xff\xfd\xff\xfc\xff\xfb\xff\xfb\xff\xfd\xff\x00\x00\xfd\xff\xfc\xff\x00\x00\x00\x00\xfb\xff\xf8\xff\xf9\xff\xf9\xff\xf9\xff\xfc\xff\xfc\xff\xfb\xff\xfd\xff\xfa\xff\xf9\xff\xf8\xff\xf6\xff\xf7\xff\xf6\xff\xf7\xff\xf7\xff\xf6\xff\xf8\xff\xf7\xff\xf7\xff\xf7\xff\xfa\xff\xfb\xff\xfa\xff\xfc\xff\x01\x00\xfc\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\xf7\xff\xfa\xff\xfa\xff\xf6\xff\xf7\xff\xf9\xff\xf6\xff\xf4\xff\xf3\xff\xf0\xff\xf2\xff\xf0\xff\xed\xff\xf2\xff\xf2\xff\xf2\xff\xf6\xff\xf6\xff\xf7\xff\xf5\xff\xf3\xff\xf6\xff\xf8\xff\xf7\xff\xf4\xff\xf3\xff\xef\xff\xf2\xff\xf0\xff\xf1\xff\xef\xff\xf2\xff\xf4\xff\xeb\xff\xef\xff\xf3\xff\xf5\xff\xf3\xff\xf6\xff\xf7\xff\xf3\xff\xf5\xff\xf5\xff\xf6\xff\xf5\xff\xf4\xff\xf5\xff\xf7\xff\xf3\xff\xf3\xff\xf4\xff\xf8\xff\xf8\xff\xf6\xff\xfa\xff\xfd\xff\xfa\xff\xf8\xff\xfb\xff\xfc\xff\xfe\xff\xfa\xff\xf8\xff\xf6\xff\xf7\xff\xf9\xff\xf8\xff\xf8\xff\xf9\xff\xf9\xff\xf9\xff\xfc\xff\xf8\xff\xf9\xff\xff\xff\xfe\xff\xfd\xff\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x01\x00\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x01\x00\x01\x00\x05\x00\xff\xff\xfe\xff\xff\xff\xfd\xff\xfa\xff\xfd\xff\xfc\xff\xfa\xff\xfc\xff\xfe\xff\xfb\xff\xfc\xff\xfd\xff\xfe\xff\xff\xff\xfa\xff\xf9\xff\xfb\xff\x00\x00\x00\x00\x02\x00\x01\x00\x04\x00\x06\x00\x04\x00\x05\x00\x05\x00\x06\x00\x06\x00\x07\x00\t\x00\t\x00\t\x00\x03\x00\x01\x00\x04\x00\x02\x00\x01\x00\x02\x00\x02\x00\x00\x00\x00\x00\x03\x00\x06\x00\x02\x00\x05\x00\x08\x00\x05\x00\x07\x00\t\x00\x08\x00\x05\x00\x06\x00\x0c\x00\x0c\x00\x08\x00\t\x00\x0b\x00\x0b\x00\t\x00\n\x00\x08\x00\x07\x00\n\x00\x04\x00\x06\x00\x08\x00\x08\x00\x08\x00\t\x00\x08\x00\x07\x00\x08\x00\x08\x00\x06\x00\x06\x00\t\x00\n\x00\x06\x00\x08\x00\n\x00\x07\x00\t\x00\x0e\x00\r\x00\x0c\x00\n\x00\x08\x00\x05\x00\x06\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfc\xff\xfb\xff\xfd\xff\xfa\xff\xf7\xff\xfd\xff\xfd\xff\xf8\xff\xfc\xff\xfc\xff\xfe\xff\xfc\xff\xfb\xff\xfd\xff\xfe\xff\x00\x00\x00\x00\x00\x00\xfe\xff\xfb\xff\xff\xff\xfe\xff\xff\xff\xfc\xff\xfb\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xfd\xff\xfd\xff\xfb\xff\xfb\xff\xfb\xff\xfb\xff\xfa\xff\xf8\xff\xf7\xff\xf9\xff\xff\xff\x00\x00\x02\x00\x00\x00\xff\xff\xfb\xff\xfd\xff\xfe\xff\xf8\xff\xfa\xff\xff\xff\xfc\xff\xfb\xff\xfd\xff\xf9\xff\xf5\xff\xf6\xff\xfa\xff\xf9\xff\xf7\xff\xf6\xff\xf2\xff\xf7\xff\xf7\xff\xf7\xff\xf8\xff\xf5\xff\xf5\xff\xf7\xff\xf5\xff\xf5\xff\xf5\xff\xf9\xff\xf7\xff\xf0\xff\xf1\xff\xef\xff\xef\xff\xea\xff\xe8\xff\xea\xff\xed\xff\xee\xff\xf0\xff\xf7\xff\xf6\xff\xf8\xff\xf3\xff\xf1\xff\xf2\xff\xf4\xff\xf7\xff\xf5\xff\xf4\xff\xf5\xff\xf4\xff\xf6\xff\xf7\xff\xf4\xff\xf3\xff\xf6\xff\xf5\xff\xf1\xff\xf5\xff\xfa\xff\xf7\xff\xf7\xff\xf8\xff\xfa\xff\xfe\xff\xfc\xff\xfc\xff\xfa\xff\xf7\xff\xf6\xff\xf7\xff\xf7\xff\xfa\xff\xf7\xff\xf7\xff\xfd\xff\xfb\xff\xfb\xff\xfa\xff\xfb\xff\xff\xff\xff\xff\x00\x00\x02\x00\x03\x00\x02\x00\x01\x00\x01\x00\x02\x00\x01\x00\x02\x00\x00\x00\xfe\xff\xfe\xff\xfd\xff\xff\xff\x01\x00\xfd\xff\xfb\xff\xfc\xff\xfe\xff\xfd\xff\xfb\xff\xfc\xff\xf9\xff\xfa\xff\xfb\xff\xfc\xff\xfd\xff\xf8\xff\xf6\xff\xf4\xff\xf4\xff\xf9\xff\xf9\xff\xf6\xff\xfa\xff\xf9\xff\xf8\xff\xfb\xff\xfb\xff\xfa\xff\xf8\xff\xfb\xff\xfd\xff\xfb\xff\xfd\xff\xfb\xff\xf9\xff\xf9\xff\xfd\xff\xfe\xff\xfd\xff\xfb\xff\xfb\xff\xfc\xff\xfd\xff\xfc\xff\xfc\xff\xff\xff\xfe\xff\x00\x00\x00\x00\x03\x00\x06\x00\x06\x00\x07\x00\x08\x00\x08\x00\n\x00\x0b\x00\x08\x00\t\x00\x08\x00\n\x00\x08\x00\x08\x00\x05\x00\x08\x00\t\x00\n\x00\x0b\x00\t\x00\x05\x00\x03\x00\x04\x00\x06\x00\x04\x00\x01\x00\x02\x00\x03\x00\x03\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\xfc\xff\xfc\xff\x03\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\xfe\xff\x01\x00\x01\x00\x02\x00\x01\x00\xfd\xff\x02\x00\x03\x00\x00\x00\x00\x00\x00\x00\x01\x00\x03\x00\x01\x00\x03\x00\x00\x00\x00\x00\x04\x00\x04\x00\x04\x00\x06\x00\n\x00\x03\x00\x03\x00\x04\x00\x05\x00\x06\x00\x03\x00\x02\x00\x03\x00\x06\x00\x08\x00\x04\x00\x07\x00\t\x00\x08\x00\x07\x00\x07\x00\x08\x00\x07\x00\x07\x00\x07\x00\x08\x00\t\x00\t\x00\x07\x00\x05\x00\x06\x00\x05\x00\x01\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x00\xfe\xff\xfc\xff\xfe\xff\xfc\xff\xf9\xff\xf7\xff\xf7\xff\xf8\xff\xf7\xff\xf7\xff\xf8\xff\xf5\xff\xf6\xff\xf4\xff\xf5\xff\xf6\xff\xf7\xff\xf8\xff\xfb\xff\xfd\xff\xfc\xff\xf8\xff\xfa\xff\xfc\xff\xfd\xff\xff\xff\xfb\xff\xfd\xff\xf8\xff\xf4\xff\xf6\xff\xf7\xff\xf5\xff\xf6\xff\xf4\xff\xf4\xff\xf4\xff\xf2\xff\xf4\xff\xf7\xff\xf6\xff\xf4\xff\xfa\xff\xfb\xff\xfa\xff\xf4\xff\xf7\xff\xf8\xff\xfa\xff\xf9\xff\xf6\xff\xfa\xff\xf8\xff\xf9\xff\xf8\xff\xf8\xff\xf8\xff\xfa\xff\xf8\xff\xf5\xff\xf8\xff\xf9\xff\xfc\xff\xfc\xff\xfe\xff\xff\xff\xfc\xff\xfb\xff\xfb\xff\xfc\xff\xf9\xff\xf8\xff\xf9\xff\xf8\xff\xf3\xff\xf2\xff\xf3\xff\xf3\xff\xef\xff\xf0\xff\xf2\xff\xf3\xff\xf2\xff\xf2\xff\xf7\xff\xf7\xff\xfa\xff\xf8\xff\xf7\xff\xf7\xff\xf5\xff\xf7\xff\xf8\xff\xf8\xff\xf9\xff\xfa\xff\xf9\xff\xfc\xff\xfa\xff\xfa\xff\xfc\xff\xfb\xff\xfd\xff\x00\x00\xfe\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\xfb\xff\xff\xff\xfc\xff\xfb\xff\xfd\xff\xfb\xff\xfe\xff\xfd\xff\xfe\xff\x00\x00\xf8\xff\xfc\xff\xfe\xff\xfd\xff\xfb\xff\xfd\xff\xfc\xff\xf8\xff\xf9\xff\xfa\xff\xf7\xff\xf7\xff\xfa\xff\xf9\xff\xfa\xff\xf6\xff\xf6\xff\xf7\xff\xfb\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xfd\xff\xfe\xff\x00\x00\x00\x00\xfb\xff\xf9\xff\xfd\xff\xfc\xff\xfb\xff\xfa\xff\xfa\xff\xf7\xff\xf7\xff\xfc\xff\xfa\xff\xfc\xff\xfd\xff\xfd\xff\x00\x00\x00\x00\xfa\xff\xf8\xff\xfa\xff\xf7\xff\xf3\xff\xf6\xff\xf7\xff\xf6\xff\xf8\xff\xfa\xff\xf7\xff\xf7\xff\xf6\xff\xfb\xff\xfb\xff\xfc\xff\xfd\xff\xfd\xff\x03\x00\x00\x00\x04\x00\t\x00\x05\x00\x07\x00\t\x00\t\x00\x08\x00\t\x00\n\x00\n\x00\x05\x00\x02\x00\x02\x00\x02\x00\x00\x00\x01\x00\x01\x00\x02\x00\x07\x00\x05\x00\x06\x00\x04\x00\x01\x00\x00\x00\x02\x00\x02\x00\x00\x00\x05\x00\t\x00\x0b\x00\n\x00\x0b\x00\x0c\x00\x0b\x00\t\x00\n\x00\t\x00\n\x00\r\x00\x0e\x00\r\x00\x08\x00\x05\x00\x02\x00\x04\x00\x02\x00\x01\x00\x03\x00\x00\x00\xff\xff\x03\x00\x05\x00\x04\x00\x03\x00\x00\x00\x00\x00\x06\x00\t\x00\x04\x00\x01\x00\x01\x00\xfe\xff\x00\x00\x00\x00\x00\x00\xfc\xff\xfd\xff\xfe\xff\xfd\xff\xfe\xff\xff\xff\xfe\xff\x01\x00\x02\x00\x00\x00\x03\x00\x02\x00\x04\x00\x05\x00\x01\x00\x02\x00\x03\x00\x04\x00\x03\x00\x04\x00\x06\x00\x06\x00\x01\x00\xfe\xff\xfd\xff\xfb\xff\xfa\xff\xfb\xff\xfc\xff\xf8\xff\xf5\xff\xf6\xff\xf6\xff\xf5\xff\xf6\xff\xf9\xff\xf8\xff\xf5\xff\xf6\xff\xf4\xff\xf1\xff\xf4\xff\xf4\xff\xf2\xff\xee\xff\xee\xff\xef\xff\xee\xff\xf0\xff\xf2\xff\xf2\xff\xf5\xff\xef\xff\xf0\xff\xee\xff\xf1\xff\xf3\xff\xf1\xff\xf0\xff\xf3\xff\xf3\xff\xf3\xff\xf0\xff\xf4\xff\xf8\xff\xee\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +# --- +# name: test_pipeline_error + b'\'\xff\x9d\xfe\xc7\xfe\x92\xfe\x88\xfe\xe2\xfe\x02\x00\x9a\x00!\x00H\xff$\xff|\xff\x94\xff1\xff\xd6\xfe\xdf\xfe8\xffj\xff*\xff\xba\xfe\x99\xfe\xf1\xfe\\\xff\x87\xff\x84\xffs\xff?\xff\xf5\xfe\xce\xfe\xd7\xfe\x0e\xff\x8e\xff\xed\xff\xea\xff\xd2\xff\xcf\xff\xa4\xffP\xff\x1b\xff=\xff\x8e\xff\xbe\xff\xd1\xff\xe9\xff\x01\x00\xdf\xffe\xff\xc9\xfe\x88\xfe\xd6\xfe[\xff\x9e\xff\x9d\xff\x9c\xff\xbe\xff\xde\xff\xc5\xff\x95\xff\x98\xff\xc7\xff\xf0\xff\n\x00\x15\x00\xf3\xff\xba\xff\x9a\xff\xae\xff\xe5\xff\r\x00\x15\x00!\x00A\x00Z\x00[\x00A\x00\r\x00\xee\xff\r\x00V\x00\x8a\x00\x89\x00p\x00l\x00\x98\x00\xe2\x00\x13\x01\xff\x00\xc6\x00\xa9\x00\xae\x00\x9e\x00x\x00_\x00x\x00\xc9\x00\x10\x01%\x01)\x01\x1c\x01\xea\x00\xa1\x00j\x00\x85\x00\xf7\x00i\x01q\x01\x1e\x01\xe0\x00\xea\x00\n\x01\n\x01\xe0\x00\xb3\x00\xb3\x00\xeb\x00.\x01K\x01=\x01\xff\x00\xae\x00\x81\x00\x97\x00\xd6\x00\x10\x016\x01K\x010\x01\xe6\x00\x9f\x00^\x00\'\x00*\x00|\x00\xdf\x00\xfa\x00\xcc\x00\x94\x00X\x00\xfa\xff\xc0\xff\xfb\xff\x89\x00\xed\x00\xe3\x00\xa5\x00\x81\x00\x88\x00\x95\x00\x89\x00q\x00c\x00S\x00B\x005\x00\'\x000\x00H\x00H\x00<\x007\x00#\x00\xe8\xff\xa3\xff\xba\xff?\x00\x9d\x00l\x00\xf8\xff\xb9\xff\xbf\xff\xd3\xff\xdd\xff\xe6\xff\xf3\xff\x02\x00"\x008\x00+\x00\n\x00\xf8\xff\x04\x00\r\x00\xf4\xff\xc1\xff\xa9\xff\xd8\xffI\x00\xba\x00\xd3\x00u\x00\xf1\xff\x97\xffh\xffY\xff}\xff\xcf\xff8\x00}\x00r\x008\x00\t\x00\xfb\xff\x02\x00\x12\x003\x00l\x00\x8b\x00^\x00!\x004\x00b\x00.\x00\x1a\x00\xa2\x00\xfa\x00\x93\x00\xed\xff\xa7\xff\xd8\xff(\x00<\x00\x04\x00\xd4\xff\xf7\xffR\x00\x88\x00W\x00\xef\xff\x94\xffm\xffW\xff\xde\xfe_\xff\xb3\x01\x86\x02v\x00\x87\xfe\xae\xfe\xb6\xff\xe5\xffg\xff\x1d\xffF\xff\xa4\xff\xe3\xff\xdf\xff\xdb\xff\xed\xff\xf0\xff\xc1\xffl\xffm\xff\xce\xff\xf8\xff\xc1\xff\x8d\xff\xa7\xff\x05\x00\x83\x00\xde\x00\xed\x00\xad\x00\x0c\x00@\xff\xcb\xfe\x0c\xff\xec\xff\xbb\x00\x03\x01\x04\x01\xd6\x00c\x00\xe0\xffz\xff>\xffh\xff\xf7\xffw\x00\xa0\x00{\x00\x0f\x00\\\xff\xb3\xfe\xb3\xfe\xb6\xff\xe7\x00\x0e\x01>\x00\x92\xff\xbc\xffY\x00\xa1\x00N\x00\xcb\xff|\xffn\xff\x81\xff\xb3\xff,\x00\xb9\x00\xc6\x00R\x00\x01\x00\x1e\x00_\x00`\x00 \x00\xd8\xff\xc5\xff\xf4\xff6\x00`\x00v\x00\x8d\x00\xb4\x00\xe4\x00\xf4\x00\xad\x00,\x00\xbc\xff\x96\xff\xde\xff~\x00.\x01m\x01\xea\x00/\x00\xd8\xff\xb5\xff\xa3\xff\xcb\xff\xfc\xff\xee\xff\xa6\xff\x8d\xff\x00\x00\xd2\x00c\x01$\x01\x11\x00\x0c\xff\xe2\xfe;\xfft\xff\x9f\xff$\x00\xd5\x00\x1e\x01\xce\x00E\x00\xda\xffs\xff\xea\xfep\xfe\x80\xfeH\xffW\x00\xf6\x00\x03\x01\xd1\x00S\x00}\xff\xcb\xfe\x8b\xfe\x96\xfe\xcb\xfeB\xff\xee\xff\x86\x00\xd5\x00\xdf\x00y\x00\x94\xff\x9a\xfe\x14\xfe>\xfe\xf1\xfe\xaa\xff\xe9\xff\xe7\xff\x11\x00;\x00\x13\x00\xaa\xffF\xff\x1b\xff%\xffU\xff\xc7\xff\x82\x00\x1a\x01)\x01\xd3\x00\x80\x00C\x00\xde\xffY\xff)\xff\x9c\xffl\x00\x19\x01\\\x017\x01\xc7\x003\x00\xb3\xffq\xffp\xff\xb0\xff,\x00\x9f\x00\xbb\x00\x9b\x00\x91\x00\x8e\x00P\x00\xdb\xffr\xffF\xff]\xff\x9d\xff\xf2\xff/\x00#\x00\xe1\xff\xa8\xff\x8f\xff\x87\xff\x85\xff|\xfff\xffH\xffJ\xff\x85\xff\xd7\xff\x0c\x00\xfe\xff\x98\xff\xe5\xfe6\xfe\x14\xfe\xc7\xfe\xe7\xff\x9a\x00g\x00\xb6\xff=\xff&\xff\x18\xff\xb6\xfe\x11\xfe\xaa\xfd#\xfen\xff\xb7\x002\x01\xc5\x00\xe8\xff(\xff\xd7\xfe\xf4\xfe=\xffl\xfft\xff\x8c\xff\xda\xff\x14\x00\xdc\xffl\xffY\xff\xd0\xffo\x00\xb7\x00m\x00\xb9\xff\x02\xff\x97\xfe\x9b\xfe\x10\xff\xd2\xff\x89\x00\xcb\x00\x8b\x005\x00:\x00\xa8\x00\r\x01\xeb\x00p\x00\x10\x00\xf3\xff\'\x00\x91\x00\xf8\x00V\x01\xa9\x01\xbf\x01j\x01\xd0\x00)\x00\x8c\xff/\xffw\xffg\x00z\x01+\x027\x02\xbf\x01\x19\x01\x85\x00\x05\x00\x80\xff-\xffp\xffD\x00(\x01\xb9\x01\x15\x02[\x026\x02+\x01V\xff\xa0\xfd\n\xfd\xdc\xfd\x92\xff~\x01\xf4\x02G\x03A\x02\x8c\x00U\xff\xed\xfeP\xfe\xeb\xfc\x13\xfc\xa4\xfd/\x01\xec\x03\xc1\x03v\x01\x84\xff\x15\xffZ\xffZ\xffI\xff\xc5\xff\xad\x00?\x01A\x01b\x01\n\x02d\x02|\x01\xe1\xff\x1c\xff\xe5\xff\x89\x01\xb5\x02\xdb\x02V\x02~\x01\\\x00!\xff\x0c\xfeD\xfd\x03\xfd\x84\xfd\xbb\xfeI\x00p\x01\x8d\x01\xac\x00b\xffV\xfe\xbd\xfdZ\xfd\x0c\xfd\x1c\xfd\xe6\xfdO\xff\xda\x00\x07\x02\x93\x02k\x02\xc2\x01\xe2\x00\n\x00w\xffM\xff\xaa\xff\x93\x00\x94\x01*\x02O\x02\'\x02h\x01\xdb\xff\xfa\xfd\xaf\xfc\x8c\xfct\xfd\xd5\xfe@\x00|\x013\x02\xee\x01t\x00L\xfe\x7f\xfc\xd7\xfbR\xfc~\xfd\x03\xff\xae\x00\x0c\x02\x8a\x02\x14\x023\x01H\x00$\xff\xd2\xfd%\xfd\xb6\xfd\xa4\xfe\x95\xfe\xdf\xfdu\xfe\x18\x01i\x03o\x02\x92\xfe$\xfb\xb5\xfa\x03\xfd"\x00\xc4\x02\x8b\x04 \x05\x15\x04\xd2\x01\x86\xff\x13\xfe}\xfdl\xfd\x1b\xfe\t\x00\xa5\x02F\x04\xd2\x03\xde\x01\xdc\xff\xc0\xfey\xfe}\xfe\x9b\xfe\xa9\xfe`\xfe\xc3\xfd2\xfd\xd6\xfc\x97\xfc\xb6\xfc~\xfd\xa9\xfe\xb1\xffS\x00\xad\x00\xd8\x00\x9b\x00\x04\x00v\xff\xe1\xfe\xe9\xfd\xca\xfci\xfc\x8e\xfd\xd4\xff\xba\x018\x02\xb8\x01C\x01_\x01\xd6\x01\xe9\x01\x19\x01\xc3\xff\x8b\xfe\xc6\xfd\xba\xfd\xab\xfe=\x00\x82\x01\xd2\x01a\x01\x02\x01\x0b\x01\xfc\x00]\x00f\xff\xf8\xfez\xff1\x00d\x00F\x00s\x00\x19\x01\xd8\x01%\x02\xe0\x01\x8f\x01\xac\x01\x02\x02N\x02\xd2\x02\xaa\x03T\x04f\x04\xc4\x03\r\x02\xf2\xfe\xc4\xfb*\xfb\xf8\xfd+\x01\x1e\x01\xcb\xfdr\xfa\xdd\xf9\x17\xfcI\xff\xcf\x01\x07\x03\xbd\x02\x06\x01\xc0\xfe \xfd-\xfc\x17\xfb]\xfa\\\xfc2\x02\xce\x08\x1f\x0bj\x073\x01h\xfd\xa7\xfd\x1d\x00\x7f\x02\x95\x03\xdb\x02M\x00\xaa\xfcI\xf9W\xf7\x06\xf7\xd8\xf7~\xf9\xe8\xfb\xcc\xfe`\x01\x87\x02\x88\x01\x05\xff\xb5\xfc\xcc\xfb\xf5\xfbk\xfc<\xfd\xa3\xfe7\x00\x8d\x01\xee\x02t\x04~\x05`\x05w\x04\xe8\x03\x02\x04\xb7\x03\x00\x02G\xffn\xfd\x13\xfe\xb0\x00\xf1\x02J\x03c\x02\xbe\x01\xf2\x01\xbf\x02\xdc\x03\x0f\x05F\x06;\x07%\x07O\x05 \x02\xfc\xfeJ\xfd\xb7\xfd\xa3\xff\x87\x01\x1b\x02m\x01p\x00\xf6\xff_\x000\x01*\x01\xb6\xff\xae\xfd$\xfc\n\xfb\xbe\xf9\x08\xf9T\xfbO\x01\x97\x07\x13\t\x06\x04I\xfc\xce\xf7,\xf9U\xfe\x03\x04O\x08\x02\n6\x08\x83\x03\xe6\xfd!\xf9\r\xf66\xf5=\xf7\x8a\xfb\x07\x00"\x03\xdd\x04\xbe\x05\x82\x05\x98\x03h\x00*\xfd\x94\xfa\xc1\xf8\xf3\xf7y\xf88\xfa\xbc\xfc\x80\xff\xc2\x01\xd2\x02\x98\x02\xb2\x01\x1d\x01\xe0\x00\x93\xffX\xfc\xa3\xf8\x1c\xf7@\xf9"\xfeM\x03\xc1\x06!\x08\x01\x08\xef\x06\xce\x05\xb5\x05\xfa\x06\xfb\x08m\n\xf7\t\x1b\x07\x87\x02\x1d\xfe\xe3\xfbZ\xfc7\xfe\xfb\xff\x1d\x01\xa3\x01}\x01\x88\x00\xfc\xfe\x89\xfd\xe3\xfc\'\xfd\xbb\xfd\x16\xfe\x1d\xfe\x9a\xfd\x9d\xfc\xd2\xfc0\x00h\x05\x10\x08\xa8\x05\xca\x00\xfd\xfd\xf1\xfe\xd4\x01F\x04\x9f\x05\xcf\x05\x9d\x03\xe1\xfd=\xf6C\xf1w\xf2\x1a\xf8\x93\xfcO\xfcE\xf9A\xf8I\xfb\xd1\xff)\x02\x8d\x01\x01\x00\xf5\xfe\xc2\xfd\x83\xfb\xfc\xf8\xec\xf7\\\xf9\x03\xfd\xa2\x01-\x05\x12\x06\xea\x04\xdd\x03,\x04\xd8\x04\xec\x03\x91\x00\xea\xfbJ\xf8s\xf7/\xf9\xf3\xfb\xa9\xfe\xea\x00T\x02\xb1\x02\xa9\x029\x03b\x04h\x05\x9d\x054\x04\xce\x00\xb1\xfc\x1a\xfa\\\xfa\x08\xfd\x98\x00m\x03\xdd\x049\x05\xa2\x04!\x03\\\x01N\x00w\x00z\x01r\x02\x95\x02I\x01o\xfeZ\xfb\xb4\xfa4\xfe\xae\x03s\x06\xaa\x04j\x01J\x004\x01X\x02\xaf\x03\x8c\x05<\x06\xc5\x03\xce\xfe\xea\xf9\xfb\xf6B\xf6r\xf7\r\xfaC\xfd]\x00\xb0\x02f\x03G\x02C\x00\x9f\xfe\xf5\xfd\xa8\xfdN\xfc\x1d\xf9\xf9\xf5\xb2\xf6L\xfc\xb1\x02\xa6\x04f\x01\x13\xfd\x1d\xfc]\xff\x06\x04~\x06}\x05d\x02\x1c\xff\x87\xfc\xa5\xfa\xc0\xf9\xe1\xfa\x9d\xfe\xce\x03\x05\x08\xa6\t6\tH\x08\xc1\x07\xa6\x07\xbf\x07v\x07\xad\x05\xdd\x01F\xfd\xfe\xf9E\xf9\xef\xfa\xab\xfd\x8f\xff\xa9\xff\xdf\xfe\xa5\xfe\n\xff!\xff~\xfej\xfdZ\xfc\x84\xfb\xa8\xfav\xf9\x02\xf9\xb5\xfb\x06\x02M\x08e\nM\x08P\x05\xea\x03H\x04\xe1\x05!\x08g\t\xc9\x07<\x03\xb1\xfdT\xf9\x1e\xf7\xf5\xf6y\xf8\xfd\xfae\xfd\x17\xffD\x00\xc5\x00\xfe\xffl\xfe\xb6\xfdG\xfex\xfe \xfd-\xfb6\xfa\xe3\xfa\xda\xfc\x8c\xff\\\x02\x97\x04\xc6\x05\xd8\x05G\x05x\x04\x0c\x03\xb8\x00\x00\xfe\x85\xfb~\xf9C\xf8`\xf9X\xfe\x1b\x05Z\x08\xa2\x05\xa4\x00|\xfe?\x00\xd6\x038\x07Y\t\xa4\t\xdd\x07_\x04\x1e\x00R\xfc%\xfan\xfa\x17\xfd\xc5\x00\x80\x03.\x04U\x03&\x029\x01\xaa\x00\x99\x00\x0f\x01\xf7\x00u\xfe\x90\xf9\xbd\xf5\x1a\xf7=\xfd\x9e\x02\xf9\x02\x1c\x00\xee\xfe \x01"\x04\x9b\x05G\x052\x03c\xff\x02\xfb\x97\xf7\xb4\xf5^\xf5\xe5\xf6c\xfa\xd7\xfe\xbf\x02\xfb\x04$\x05\xa5\x03\xbd\x01\xc3\x00\xdd\x00\xac\x00\xeb\xfe\x1f\xfc\x0b\xfa\xc1\xf9\xd8\xfa\xcb\xfc\xa0\xff\xac\x02b\x04\xf7\x03f\x02=\x01\xd9\x00\x87\x00\x8e\xff\xcf\xfd.\xfc\xd9\xfb\xfc\xfc\xf4\xfeU\x01\x7f\x03^\x04M\x044\x058\x07\xe2\x07 \x06\xef\x03d\x03=\x04=\x04\x84\x01\xcb\xfca\xf9\x9e\xfa\xba\xffI\x036\x01\xa9\xfbI\xf8\xd3\xf9$\xfe\xc4\x01V\x03\xbb\x03\xc0\x03\x7f\x02\xaf\xfe{\xf9\x0e\xf7\x12\xfa\xdc\xff\xa4\x03%\x04\xa3\x03\xd8\x03\x8c\x04\x85\x05\x1c\x07\x91\x08\xe7\x07\xe7\x03w\xfd\xbf\xf6\xe7\xf1\x89\xf0A\xf3\xb0\xf8\xac\xfd\t\x00n\x00\x81\x00Q\x00\xf6\xfe\xb2\xfc\xa2\xfa7\xf9P\xf8\xf2\xf7\x17\xf8\xbb\xf8N\xfa\x88\xfd[\x02\x06\x07\x1d\t\xdf\x071\x05\x9f\x03\xb0\x03\xc3\x03O\x02}\xff\xe3\xfc\xe4\xfb\xc4\xfc\xff\xfe\xb6\x01\xc6\x03\xb0\x04D\x05:\x06\xb1\x06\xc5\x05^\x04\xc3\x03\xaf\x03\xc9\x02\xc4\x00\x03\xff\x99\xfe\x07\xffm\xff\x98\xffw\xffI\xff\xef\xff}\x01<\x02\xaf\x00 \xfe\x8f\xfd\x02\x00i\x02^\x00\xe8\xf9L\xf5\xde\xf8G\x02\xde\x07\x19\x04\xfe\xfb\x82\xf8\x84\xfc\xd4\x03\x8f\t\xfc\x0b/\x0b\x9f\x077\x02%\xfc~\xf6\xbc\xf2{\xf2\x13\xf6\xf0\xfb\x8f\x01\x07\x05\x88\x05\xa3\x03\xe2\x00\xee\xfe\x9f\xfe>\xff1\xff\x92\xfd\xb2\xfa\x9a\xf7\xd9\xf5\x19\xf7d\xfb%\x00>\x02\x83\x01\x01\x01\xe5\x02k\x05\x05\x05\x8d\x00\x99\xfa\x11\xf7\xb6\xf7\xe4\xfa\x1d\xfe\xd6\x00f\x03\x8f\x05g\x07Z\tz\n*\tw\x06\x98\x05#\x07\n\x08\xfa\x05\x0e\x02\xe0\xfe\xa3\xfd\xcb\xfd`\xfe\x18\xff\xf6\xff\xd4\x00E\x01\xe0\x00\xd5\xff\xd8\xfe\x96\xfeB\xffC\x00V\x00\xd9\xfe\n\xfd<\xfd\xcf\xffK\x02\x9b\x02u\x01f\x01\xab\x031\x06(\x06\xd2\x03\xfa\x01\xf2\x01\xe6\x01S\xff\xdf\xfa\x06\xf8\n\xf9R\xfc)\xfe\x01\xfd\x16\xfb\x99\xfb\xd8\xfe3\x02k\x03\xc3\x02\x9f\x01c\x00\xa4\xfe\x85\xfc\xb1\xfa\xbf\xf9$\xfaJ\xfc\xf7\xff|\x03\xeb\x04\x1d\x04\x9b\x02\xcb\x01\x9c\x01 \x01"\x008\xff\x85\xfev\xfd\x08\xfcc\xfb\x9e\xfc,\xffP\x01\x04\x02\xeb\x01.\x026\x03w\x04\xd7\x04W\x03\'\x00\x13\xfd\xe4\xfb\x80\xfc8\xfd>\xfd\xad\xfd\x8a\xff\xfb\x010\x03S\x02:\x00y\xfeB\xfe\x9a\xff&\x01T\x01\xb8\xffG\xfd\x85\xfb\\\xfb\x8a\xfc\x03\xfeZ\xff\x01\x01\xe7\x02\xf1\x03s\x03?\x02g\x01\xec\x00;\x00\xfa\xfeq\xfd\x93\xfc\xf6\xfc\x1d\xfe\xce\xfe}\xfe\x19\xfe\xe7\xfe\xc1\x00\xe0\x01\xaa\x00\xf6\xfd\xa2\xfc\x0b\xfe0\x00V\x00t\xfe\xb0\xfc]\xfc\xfe\xfc\xfc\xfdb\xff\x0b\x01a\x02\x1c\x03\x96\x03 \x04\x86\x04A\x04\x0e\x03G\x01\x8d\xffG\xfe\xdc\xfd\x8e\xfe\xd9\xff\xfe\x00\xdb\x01\xbc\x02y\x03|\x03\xb7\x02\xab\x01\xb6\x000\x00w\x00\x10\x01\xb0\x00\xb7\xfeI\xfcq\xfb\xf6\xfc`\xff\xb6\x00\xce\x00\xf1\x00\xb4\x01B\x02\xd6\x01\xaf\x00}\xff\xa3\xfe\x1b\xfe\xc2\xfds\xfd#\xfd`\xfd\x0e\xff\xe2\x01\xf0\x03\x84\x03@\x01q\xff\x85\xff\x02\x01g\x02n\x02\xd9\x00\x83\xfe\x9d\xfc\xe1\xfbC\xfc:\xfdT\xfe\xca\xff\xb1\x01\x0f\x03\xac\x02\xb5\x00\xc8\xfeW\xfe=\xff\x1b\x00h\x00\xb7\x00\xa8\x00\x86\xff\xed\xfdR\xfd\x98\xfe\x1f\x018\x03\xa4\x03\xb7\x02\xbe\x01\xbd\x01\x97\x02\r\x03\xdc\x01>\xff\x11\xfd\x0f\xfd\x95\xfes\x00\x08\x03\xbd\x04\t\x04\xd1\x02\xb0\x02(\x03-\x03\x92\x02\xa1\x01\x8b\x00}\xff\xa4\xfe0\xfet\xfeC\xff\x00\x00Z\x00j\x00\x9e\x00\xe4\x00\x96\x00\xa6\xff\xd6\xfe\xd7\xfe\x9b\xffu\x00\x97\x00\xbd\xff\x88\xfe\xbf\xfd\xa3\xfd\x0f\xfe\xc5\xfe\xd6\xff2\x01d\x026\x03a\x03e\x02\x96\x00\xd8\xfe\x9c\xfd\x14\xfd?\xfd\xd1\xfd\x9f\xfe\x8a\xffA\x00:\x006\xff\xeb\xfd\xb5\xfd\x02\xff\xa7\x00j\x01+\x01\x82\x00\xc4\xff\x9f\xfe\xec\xfc\x86\xfb\x8b\xfbO\xfd\x01\x000\x02/\x03Z\x03\xe1\x02\xa7\x014\x00o\xff\xa2\xff\x1e\x00\x00\x00`\xff$\xff\xa0\xff1\x00~\x00\r\x01\x17\x02\xfd\x02\t\x03d\x02\x99\x01\xba\x00\xa8\xff\x9d\xfe\x15\xfeb\xfeZ\xffP\x00\x9d\x00\x82\x00\xc9\x00>\x01?\x01\xfe\x00\xf2\x00\x18\x011\x01\x16\x01\xc5\x00p\x00X\x00g\x00\x0b\x00\x10\xff\x0b\xfe\xbf\xfd\x8d\xfe:\x00\xf3\x01\x94\x02\xb1\x01\x1f\x00.\xffU\xff\x9c\xff\xc8\xfe\x11\xfd\xf4\xfb`\xfc\xde\xfdA\xff\xe9\xff&\x00N\x00P\x00J\x00o\x00\xb4\x00\xe4\x00\xc4\x00D\x00\x8a\xff\xd1\xfeN\xfe\x15\xfe\x15\xfet\xfeW\xffm\x00B\x01\xb9\x01\xbd\x01C\x01\xc1\x00g\x00\xaf\xffN\xfe\xbc\xfc\xc7\xfb\xe4\xfb\x05\xfd\xe2\xfe#\x01\x07\x03\xda\x03\xa6\x03\x00\x03=\x02\x14\x01`\xff\xea\xfd\xa8\xfd\x8f\xfe\xb6\xffW\x00|\x00\x94\x00\xc2\x00\x00\x01 \x01\xef\x00\xaa\x00\xc4\x00\x1b\x01\xfc\x00.\x00/\xffu\xfeD\xfe\xa0\xfe\x12\xff?\xffR\xff\xbd\xff\x9a\x00>\x01\x05\x01[\x00H\x00\x0f\x01\xd9\x01\xab\x01Q\x00\x96\xfe\x94\xfd\xaa\xfd,\xfe\x86\xfe\xff\xfe\xfb\xff\xff\x00\xff\x00\xc6\xffl\xfe%\xfe=\xff\xe9\x00\xf0\x01\x90\x01\x00\x00\xfd\xfd=\xfc\x13\xfb\xa4\xfa\x1d\xfb\xb4\xfcY\xff0\x02\r\x04\x89\x04\xf7\x03\x9e\x02\xb9\x00\xa7\xfe\x03\xfd\x81\xfc9\xfdo\xfe_\xff\xf0\xff\x8d\x00\xa1\x01\x03\x03\xd5\x03~\x03G\x02\xda\x00\x9b\xff\x90\xfe\x8e\xfdp\xfc}\xfbd\xfb\xa1\xfc\xe8\xfeI\x01\xda\x02K\x03\xf6\x02\\\x02\xa8\x01\xfd\x00\x80\x00$\x00\xf0\xff\xf7\xff\x04\x00 \x00u\x00\xd6\x00\xf2\x00\xef\x00\x1e\x01N\x012\x01\xe3\x00\xab\x00\x99\x00\x95\x00\x82\x00L\x00\xfc\xff\xb4\xffu\xff(\xff\x1e\xff\xcd\xff\xec\x00\x9a\x01^\x01\x0e\x01\xb7\x01\x04\x03\xad\x03\xec\x02\xfb\x00\xb6\xfe\x0f\xfd\x82\xfc\x04\xfd\x17\xfe>\xff\x11\x00\x8a\x00\xfb\x00q\x01\x18\x01<\xff\xf6\xfc\xba\xfcM\xffU\x02\x08\x03%\x01\xa6\xfe/\xfd\xc7\xfc\x16\xfdN\xfe~\x00\xc6\x02\xfb\x03\xfe\x03\xcd\x03\xeb\x03F\x03\xaa\x00\x10\xfd\xf1\xfa\xa5\xfb~\xfe\x95\x01\x81\x03,\x04\xfa\x03\xfd\x02i\x01\xb9\xffE\xfel\xfd\x92\xfd\x92\xfe\xa7\xff\x00\x00H\xff\xf0\xfd\xcd\xfc\x7f\xfc(\xfdp\xfed\xff\x87\xff\x97\xff#\x00\xc8\x00&\x01\\\x01\x7f\x01_\x01\xf2\x00\x82\x00+\x00\xd7\xff\xdc\xff\xc9\x00`\x02\x8f\x03\xbf\x03:\x03O\x02\x06\x01\x99\xffj\xfe\xb6\xfdi\xfd<\xfd#\xfd\x92\xfd\xd1\xfeA\x00\xc3\x00\xce\xff!\xfe\x17\xfdG\xfdJ\xfe\x96\xff\x02\x01T\x02\xc2\x02\xe0\x01\\\x00!\xff-\xfe+\xfd\xac\xfc\xa1\xfd\xa9\xff\xfe\x00\xc1\x00X\x00\x81\x01i\x034\x03\xd1\xff\x99\xfb\x7f\xf9N\xfa\xbe\xfc\x8c\xff]\x02\xa9\x04h\x05B\x047\x02\x91\x00\xd3\xff|\xff\x1b\xff\x1f\xff\x00\x007\x01\xa9\x01\xf9\x00\xdd\xfff\xff\xea\xff\xb5\x00\x02\x01\x8e\x00\x91\xff\x92\xfe\x0f\xfe\xf3\xfd\xb5\xfdc\xfd\xa2\xfdi\xfe\xef\xfe\xcd\xfe\x93\xfe8\xff\xcd\x00b\x02\x04\x035\x02#\x00\xb0\xfd\x19\xfcn\xfc\x9a\xfe\t\x01\x0f\x02\xa0\x01\xde\x00\x8f\x00\xbd\x00\x06\x01\xe9\x00Y\x00\xb7\xff7\xff%\xff\xea\xffT\x01h\x02Q\x020\x01\x03\x00\x7f\xffc\xff*\xff\xf9\xfeo\xff\x95\x00d\x01\x05\x01\x17\x00\xe5\xff\xee\x00\\\x02\xe6\x02\x0e\x02\x95\x00\x9d\xffz\xff\xf5\xff\x12\x01\xd1\x02\x9d\x04\xb0\x05\xae\x05[\x04\x8f\x01\r\xfe0\xfc\xa4\xfd|\x00\xff\x00\x1a\xfeC\xfac\xf8j\xf9l\xfc\xdd\xffF\x02\xa0\x02\xe3\x00}\xfe\x85\xfdH\xfe\xa8\xfe+\xfd\xc7\xfb\xf7\xfd\xa0\x03\xfc\x07\x02\x07>\x02\xea\xfe\xd8\xff4\x03\xe4\x05\x91\x06X\x05\x89\x02q\xfe\xe3\xf9>\xf6\x88\xf4\xe3\xf4\xde\xf6\xc2\xf9\xd3\xfcw\xff\x1f\x01*\x01l\xff\xe1\xfc(\xfb\x15\xfb*\xfc\x86\xfd\xc1\xfe\xd5\xff\xf0\x00w\x02<\x043\x05y\x04\xbb\x02\xf9\x01G\x03`\x05\xe3\x05\x9d\x03\xd8\xffn\xfd\xe8\xfd\t\x00\x82\x01\x82\x01\xec\x00\x96\x00\xbf\x00\xb8\x01\x90\x03\x9b\x05\xe4\x06\xb8\x06\xf0\x04E\x02\xee\xff\xc4\xfe\xf2\xfe\xf5\xff\xe2\x00\x13\x01\x98\x00\xcd\xff\xf3\xfew\xfe\xc5\xfeZ\xffd\xff\xd8\xfe\x18\xfe\x10\xfdw\xfb\r\xfa\x0e\xfb\xda\xff\x13\x06\x96\x08J\x04\x1d\xfc\xaf\xf6\xea\xf7\xdd\xfd\x16\x04M\x08B\n\x8a\t\xde\x05\'\x005\xfa\xd1\xf5[\xf4u\xf6\x08\xfbX\xff\x7f\x01(\x02\n\x03g\x04\xde\x04u\x03\xc6\x00\xfa\xfd\x9f\xfb\xfc\xf9B\xf9c\xf9c\xfar\xfc,\xffm\x01S\x02\r\x02\xab\x01\xa7\x01\xe3\x00)\xfeH\xfa\xcc\xf7\xc3\xf8\xef\xfc\xf6\x01s\x05\xcb\x06\xe2\x06\x85\x06(\x06g\x06\x9b\x07_\t\xd8\n.\x0b\xbe\t,\x06D\x01\\\xfdn\xfc\xfd\xfd\xc9\xffW\x00\xd1\xff\x1e\xff\xc2\xfe\x9d\xfe^\xfe\xdd\xfd?\xfd\xdb\xfc\xff\xfc\xc2\xfdw\xfe\x1b\xfeo\xfd#\xff\x05\x04V\x08\xd9\x07B\x03D\xff\t\xff\x8c\x012\x04\xe1\x05\x9f\x06\x95\x05d\x01\xaa\xfa\xe4\xf4\xc3\xf3\x0c\xf7X\xfa\xee\xf9\xcd\xf6C\xf5\x1b\xf8\xb1\xfd\xe2\x01\x8f\x02U\x01\\\x00x\xffs\xfd\x89\xfaq\xf8\xb2\xf8\x7f\xfb\xd0\xff\xca\x03\xa2\x05*\x05\xfe\x03\xce\x03\xbc\x047\x05\x85\x03\x80\xff(\xfb\xfa\xf8y\xf9\x01\xfb<\xfcj\xfd\x0b\xff\xca\x00:\x02\xae\x03[\x05\xd4\x06|\x07\x95\x06\x8a\x03,\xff\x88\xfb?\xfa\xa7\xfb\xea\xfeg\x02\x85\x04\xd8\x04\xdb\x03C\x02\xd3\x00&\x00b\x00\x1b\x01\x9d\x01i\x01O\x00Q\xfe!\xfcs\xfb\xe4\xfd\xa2\x02\xfc\x05"\x05\xc0\x01\xcb\xffo\x00\xc8\x01\xfd\x02\xc8\x04l\x06\x9a\x05\xb0\x01\xb1\xfc\xe7\xf8\xfd\xf6\xa2\xf6\xb8\xf7\xf5\xf9\xbc\xfcE\xff\xc2\x00\xe3\x00.\x00N\xff\xa2\xfe-\xfeO\xfd\xfe\xfa\xb3\xf7\x8c\xf6\xe8\xf9\xa4\xff\xbc\x02\xad\x00#\xfc\xdb\xf9\x1b\xfcM\x01\xdb\x05\x12\x07\xce\x04\xd2\x00\x14\xfd{\xfa\x16\xf9T\xf9\xfd\xfb\xb8\x00\x8c\x05\x84\x08a\t6\t\xdc\x08\xaa\x08\xf1\x08z\t\xd3\x08|\x05\x19\x00C\xfb.\xf9\x15\xfa\x85\xfck\xfe\x9f\xfe\xe2\xfd\xc3\xfd\x9c\xfeJ\xff\xec\xfe\xc6\xfd\x86\xfc\xa0\xfb\x04\xfb1\xfav\xf9\xf1\xfaF\x00(\x07\xfb\n\x1d\n\x03\x07\x9f\x04\x06\x04%\x05\xb0\x07G\ns\n\xd5\x06\xad\x00\xe9\xfa\x90\xf7\x91\xf6\x11\xf7\xa2\xf8\xf4\xfat\xfda\xff\xfd\xff\x13\xff\x94\xfd)\xfdM\xfeB\xffI\xfe\xfa\xfb/\xfa\x0c\xfa\x96\xfbV\xfey\x01\xf1\x03!\x05;\x05\xc7\x04-\x04L\x03\xd2\x01\xd5\xff\x86\xfd\xff\xfa\xd3\xf8\xba\xf8\xc2\xfc\xda\x03\xb8\x08/\x07\x85\x01\xc6\xfd\xd9\xfe\xd9\x02\xda\x06y\t\x98\n\x10\nh\x07\xcc\x02\x91\xfd\xe8\xf9r\xf9\xed\xfb\x84\xffG\x02H\x03\xe4\x02\xf8\x01\'\x01\xc4\x00\xf3\x00\x8f\x01\xbc\x01\xf6\xff\xb2\xfb/\xf7z\xf6\t\xfb\xf7\x00\x03\x03\xe0\x00\x07\xff\x99\x00\x13\x04\x9d\x06O\x07\xe9\x05\xfd\x01\xa7\xfc4\xf8\xe5\xf5\x81\xf5\xb0\xf6\x87\xf9x\xfdH\x01\xdf\x03\xb4\x04\xce\x03\xeb\x01Z\x00\x1b\x00\xb3\x00\\\x00\x19\xfe\x1a\xfbq\xf9\xc6\xf9v\xfb\r\xfe"\x01\x8a\x03\x1d\x04\x17\x03\xd0\x01\r\x01m\x00M\xffy\xfd\xb7\xfb_\xfb\xe2\xfcO\xff\x96\x01>\x03\xe9\x03\xfb\x03\xf8\x04=\x07\x7f\x08\x11\x07\xb5\x04\t\x04+\x05\xba\x05s\x03\x9b\xfe(\xfa\xbe\xf9\x14\xfe\xd3\x02\x93\x02K\xfdc\xf8j\xf8\x8f\xfc\xd1\x00\xae\x02\xe6\x02J\x03\x90\x03q\x01b\xfc\x02\xf8\x9f\xf8\xa8\xfdN\x02\xaa\x03B\x03{\x03\xa3\x04\xf6\x05m\x07\xe1\x08\xe4\x08\xd4\x05\xbb\xffy\xf8\x83\xf2\xcd\xefU\xf1b\xf6\xff\xfbJ\xff0\x00|\x00\xc8\x00\xf2\xff\xa8\xfd7\xfb\x8f\xf9\x8c\xf8$\xf8p\xf8,\xf9J\xfaz\xfcQ\x00\xd3\x04\xc9\x07\xd7\x07\xc8\x05\xd7\x03q\x03\xd5\x03+\x03\x9b\x00K\xfdJ\xfb\xa4\xfb\xfe\xfd\x1a\x01m\x034\x04w\x04\x9c\x05\xfe\x06\xc8\x06\xf6\x04a\x03\x0e\x03\x02\x03\xc8\x01\xc5\xff\xab\xfe\xe5\xfe.\xff\xc9\xfe\x00\xfey\xfd\x00\xfe\xe6\xff\xc8\x01}\x01\xf9\xfe\xff\xfc\x0c\xfe\xc4\x00\xc7\x00\x05\xfc\x93\xf6a\xf7u\xff\x00\x07\t\x06-\xfeX\xf8\x14\xfa\xee\x00\xa3\x07\x96\x0b?\x0c\x90\tR\x04\x12\xfe)\xf8\xcb\xf3K\xf2d\xf4U\xf9*\xff\xc0\x03\x95\x05w\x04\xa0\x01\xeb\xfe\xe1\xfd\x8a\xfe>\xffR\xfe\xac\xfbt\xf82\xf6b\xf6\xc1\xf9\xcd\xfe\x14\x02\xe0\x01\xab\x00\xe6\x011\x05\xa2\x06{\x03;\xfd\x1a\xf8>\xf70\xfa\x0b\xfe\x0e\x01e\x03h\x05V\x07\x86\t\x1d\x0b&\n\xb4\x06\xfa\x03`\x04X\x06\\\x064\x03%\xff\x17\xfd\x8f\xfd\xe0\xfe\x80\xffO\xff,\xff\xc5\xff\xb6\x00\xf5\x000\x00N\xffX\xff*\x00s\x003\xffV\xfd\x1e\xfdj\xff*\x02\xc5\x02z\x01\xf9\x00\xf6\x02\xd9\x05\xa7\x06\xd3\x04\xad\x02\t\x02\x06\x02Y\x00t\xfc\xc6\xf8L\xf81\xfb_\xfe\xcf\xfe\xe7\xfc\xf2\xfb\x05\xfe\x97\x01\x9e\x03*\x03\xb6\x01\xab\x00\xf6\xff\xd8\xfe\xf9\xfc\xe0\xfa\xb8\xf9\xa3\xfa\xec\xfd?\x02E\x05\x92\x05\xef\x03I\x02\x9e\x01M\x01\x86\x00z\xff\xac\xfe\xe4\xfd\xc7\xfc\x0e\xfc\x02\xfd\xaf\xffX\x02Y\x03\xee\x02\xbc\x02\xbf\x03]\x05\x0e\x06\x9e\x04J\x01\xd0\xfd\x1d\xfc\xa7\xfc\n\xfe\xa3\xfe\x8b\xfe\x1c\xff\xbf\x00m\x02\xcf\x02r\x01+\xff\xba\xfdr\xfe\xb1\x006\x02?\x01A\xfe\x83\xfb\xef\xfad\xfcF\xfe\xa4\xff\xf2\x00\xa5\x02\x0e\x04"\x04\xf4\x02\x95\x01\xa3\x00\xe0\xff\x06\xff\x1f\xfel\xfdE\xfd\xb0\xfd\x0f\xfe\xb7\xfd\x10\xfdu\xfdo\xff\x83\x01v\x01\x03\xff\xb5\xfc\x07\xfd$\xff\x1a\x00\xb5\xfe\x82\xfc\x9a\xfbu\xfc\n\xfel\xff\x85\x00\x88\x01\x87\x02|\x03\x1d\x04\x10\x04R\x03;\x02\x1e\x01\x11\x00\x07\xffL\xfe\x88\xfe\xbd\xff\xf3\x00\x81\x01\xe0\x01\x91\x027\x03)\x03c\x02M\x01Y\x00\xd3\xff\xba\xffo\xff$\xfe\x1d\xfc\x1e\xfb\x9d\xfc\xaa\xff\xb6\x01\xa9\x01\xd7\x00\xdf\x00\xb5\x01,\x02\x98\x01N\x00\x00\xff1\xfe\x0f\xfeh\xfe\xaa\xfeq\xfeb\xfe\x83\xff\x85\x01\xa5\x02\xc7\x01\xf3\xff\x1b\xff\xdc\xff \x01h\x01\x03\x00\x83\xfdR\xfb|\xfa\xed\xfa\xfb\xfb5\xfd\xb6\xfe\x88\x00\t\x02?\x02\xf5\x000\xff\'\xfe2\xfe\xd7\xfe|\xff\xb3\xffJ\xffU\xfe<\xfd\xc7\xfc\xa1\xfd\xad\xff\xd0\x01\xd6\x02q\x02V\x01\x95\x00\xbe\x00Q\x01\x1b\x01\x88\xff\xab\xfdY\xfd\r\xff8\x01-\x02(\x02\x8d\x02\x97\x030\x04\xdc\x03K\x03\x03\x03\xa0\x02\xa6\x018\x00\xe3\xfe\x0b\xfe\xc8\xfd2\xfe)\xff)\x00\xc5\x00\x10\x01\x1d\x01\xb7\x00\xe1\xff*\xff\x17\xff\x9a\xff#\x00\x1d\x00{\xff\xaf\xfeD\xfeh\xfe\xd7\xfed\xff\x0e\x00\xee\x00\x01\x02\xd3\x02\xe5\x029\x02G\x01`\x00T\xff\x15\xfe\x1f\xfd\x0c\xfd\xf4\xfdA\xffD\x00\xc2\x00\xa7\x00\x0b\x00`\xff>\xff\xd3\xff\xbc\x00.\x01\x9a\x00D\xff\xe2\xfd\xbf\xfc\xdb\xfb\xb5\xfb\x19\xfd\xdf\xff\x9c\x02\xdd\x03\x95\x03\xb3\x02\xca\x01\xcc\x00\xda\xff^\xffk\xff\x98\xff\xaf\xff\n\x00\xad\x00\xf6\x00\xdd\x006\x01@\x02\xfb\x02\x87\x02d\x01\xbc\x00\xbf\x00r\x00F\xff\xfd\xfd\x9e\xfdV\xfet\xff\x15\x008\x00\xa4\x00\xb1\x01\xa1\x02\x9c\x02\xde\x01O\x01h\x01\xab\x01M\x01;\x009\xff\xf7\xfe`\xff\xb5\xffT\xff\xae\xfe\xd3\xfe\x18\x00\xa7\x017\x02&\x01H\xff#\xfe`\xfe3\xffH\xffA\xfe\x0c\xfd\xda\xfc\xbf\xfd\xc5\xfe?\xff\x87\xff\x12\x00\xe4\x00\xb5\x016\x02C\x02\xe4\x01\x14\x01\xe4\xff\x92\xfez\xfd\xfa\xfc>\xfd$\xfeM\xffd\x00<\x01\xb9\x01\xcf\x01\x93\x01\x00\x01;\x00\xa1\xff"\xffZ\xfeG\xfdt\xfc~\xfcu\xfd\xff\xfe\xcc\x00~\x02v\x03c\x03\xa3\x02\xd1\x01 \x01C\x00\'\xffO\xfe\x16\xfe&\xfe\x1a\xfe!\xfe\xab\xfe\xc0\xff\xf9\x00\xc5\x01\xce\x01=\x01\xa3\x00m\x00d\x00\xf7\xff\xff\xfe\x12\xfe\xdd\xfd\x8b\xfe\x94\xff\x03\x00\xaa\xff\x89\xffv\x00\xd1\x01W\x02\xbf\x01\x04\x01\x04\x01\x83\x01\x86\x01x\x00\xc8\xfe\xaf\xfd\xb0\xfd\xff\xfd\xfc\xfd&\xfe)\xff\xd2\x00\x1e\x02/\x02\x1b\x01\xcb\xff0\xff\x82\xff-\x00p\x00\xeb\xff\xc1\xfeg\xfdg\xfc\x16\xfc\x93\xfc\xe2\xfd\xcb\xff\xb9\x01 \x03\xc2\x03\xad\x03\x04\x03\xd0\x01\x13\x00\x16\xfe\xc7\xfc\x0e\xfd\x91\xfe\xf6\xffs\x00\x86\x00#\x01{\x02\xa6\x03\xab\x03\x81\x02\xda\x00a\xff]\xfe\xae\xfd\xf0\xfc\xf2\xfb)\xfb\x89\xfb\x89\xfd]\x00\x8e\x02,\x03y\x02\x80\x01\x03\x01\r\x01\x18\x01\xaf\x00\xe6\xff \xff\xb0\xfe\xa7\xfe\xcc\xfe\xf4\xfeI\xff\xfb\xff\xcf\x00j\x01\xa3\x01{\x01\xe9\x00\x00\x00\t\xffP\xfe\xef\xfd\xf5\xfdR\xfe\xba\xfe\x18\xff\xd5\xff/\x01\xa2\x02;\x03Q\x02v\x00t\xff:\x00\xcf\x01\xb4\x02Y\x02(\x01\xc5\xffw\xfe\'\xfd\xef\xfbs\xfbU\xfcq\xfe\xc0\x006\x02\x80\x02\x06\x02K\x01\x90\x00\xfb\xff\x89\xff\x01\xffo\xfe8\xfee\xfe\xaa\xfe\x03\xff\xe7\xff\x82\x01-\x03\x1f\x043\x04\xa4\x03\x98\x02\xfd\x00\xc9\xfe\x93\xfc[\xfb\xa5\xfb\x14\xfd\xd8\xfe=\x00\x18\x01\xa1\x01\xf3\x01\t\x02\xef\x01\xa4\x010\x01\xaf\x00\x0e\x00(\xff%\xfed\xfd+\xfd}\xfd\x13\xfe\xeb\xfe\'\x01I\x04\xd0\x04I\x02\xf2\xffv\xff\xac\xffb\xff\xf2\xfe\xe4\xfe\x1b\xffL\xff_\xff{\xff\xf6\xff\xd8\x00\xd0\x01I\x02\xf4\x01g\x01@\x01D\x01\x00\x01~\x00$\x00\xf9\xff{\xffJ\xfe\xd2\xfc,\xfc;\xfd\x8f\xff\x8a\x01\x0e\x02\x7f\x01\xd8\x00\xc0\x00L\x01\xcc\x01\x82\x01l\x00\x12\xff\x1c\xfe\x01\xfe}\xfe\xd4\xfe\xf3\xfe\x9d\xff+\x01\xd4\x02\x8b\x03\x1a\x03\xec\x01\x8d\x00\x8d\xff\x01\xff\x8c\xfe\x02\xfe\x9f\xfd\x9a\xfd\x02\xfe\xd2\xfe\xe1\xff\xe6\x00\x9f\x01\x03\x02<\x02s\x02\x80\x02\x08\x02\xb8\x00\xb8\xfe\xe4\xfc9\xfc\xee\xfc[\xfe\xc4\xff\xe3\x00\xac\x01\x0e\x02\xff\x01\xa1\x01>\x01\xe3\x00Z\x00\xa4\xff?\xff~\xff\xc5\xffj\xff\x9f\xfeC\xfe\x10\xff\xf0\x00\xfb\x02\xca\x03\xc3\x02\x0e\x01\x15\x00\xf6\xff#\x00M\x00i\x00=\x00\x9e\xff\xc9\xfe\x18\xfe\xcb\xfd<\xfeS\xfft\x00C\x01\xbc\x01\xe8\x01\xd8\x01\x9c\x01\x1e\x01v\x00\xd9\xffV\xff\xde\xfea\xfe\x17\xfeg\xfev\xff\xc5\x00\x90\x01}\x01\xcd\x00E\x00u\x00\xf5\x00\x07\x01`\x006\xff\xf4\xfd\x10\xfd\xe6\xfc}\xfd\x88\xfe\xc1\xff\xe5\x00\xaf\x01\x1a\x02F\x02#\x02}\x01\x8d\x00\xad\xff\xe4\xfe<\xfe\xdd\xfd\xc2\xfd\xa8\xfd\xa2\xfd\x0e\xfe\xf2\xfe\xdd\xffS\x00D\x00\x1b\x00P\x00\xcb\x00\xed\x00:\x00\xf3\xfe\xc3\xfd&\xfdN\xfdK\xfe\x05\x00\xfc\x01F\x03P\x03|\x02\x92\x01\xea\x00O\x00\x9e\xff\x1e\xff\x0f\xff\x1d\xff\xb2\xfe\xad\xfd\xaa\xfc\x96\xfc\xad\xfd \xff\xf8\xff\x12\x00\xfe\xff?\x00\xcb\x00+\x01\xfc\x00b\x00\xbf\xffO\xff\x1b\xff\xfb\xfe\xcc\xfe\xb4\xfe\t\xff\xee\xff\x1d\x01\r\x02=\x02\x97\x01\x8e\x00\xad\xff%\xff\xc7\xfeT\xfe\xce\xfdu\xfd\x94\xfdJ\xfeY\xff_\x00\xf3\x00\xe6\x00r\x003\x00\x99\x00W\x01\x94\x01\xe7\x00\xb6\xff\xbc\xfem\xfe\x86\xfek\xfe9\xfe}\xfe:\xff\xfc\xffr\x00\xa9\x00\xbc\x00\xca\x00\xdc\x00\xed\x00\xde\x00\x96\x00\x06\x000\xffd\xfeI\xfe>\xff\xc0\x00\xbc\x01\xb9\x01J\x01;\x01\xaf\x01!\x02\x0f\x02K\x01\x13\x00\xee\xfe7\xfe\xe9\xfd\x05\xfe\xa7\xfe\xac\xff\xb9\x00\x96\x01\'\x02`\x02P\x02\x03\x02\x85\x01\xf5\x00b\x00\xc3\xff/\xff\xd7\xfe\xec\xfev\xff6\x00\xc4\x00\xf5\x00\x04\x01G\x01\xa8\x01\xd6\x01\x9b\x01\x03\x01J\x00\xb5\xffO\xff\x1d\xffS\xff\xf1\xff\xab\x00;\x01\xa3\x01\x0f\x02\x90\x02\xf2\x02\xca\x02\xe8\x01\xb1\x00\xd7\xff\xa7\xff\xca\xff\xaf\xff\x19\xffe\xfe7\xfe\xdb\xfe\xec\xff\x9c\x00L\x004\xffc\xfe\xc8\xfe?\x00\xa7\x01\x05\x02q\x01\xa7\x00!\x00\xdb\xff\xb7\xff\xb9\xff\xf4\xffZ\x00\xba\x00\x1f\x01\xb8\x01M\x02E\x02\x8b\x01\xbf\x00E\x00\xd9\xff\x17\xff\xfd\xfd\xf1\xfc\x83\xfc\x10\xfdp\xfe\xe1\xff\x9c\x00\x98\x00}\x00\xcb\x00F\x01B\x01s\x00+\xff\x01\xfe\x80\xfd\xd3\xfd\x9c\xfeR\xff\xa7\xff\xb7\xff\xe5\xffx\x00G\x01\xbe\x01{\x01\xb6\x00\xee\xffI\xff\x8e\xfe\xc2\xfdw\xfd#\xfeq\xff\x8e\x00\x11\x013\x01[\x01\xad\x01\xf5\x01\xe1\x01f\x01\xac\x00\xc7\xff\xd0\xfe$\xfe\x0e\xfeo\xfe\xda\xfe\x06\xff\x06\xff\x19\xff?\xff>\xff\x0c\xff\xf1\xfe\x1f\xfft\xff\xc8\xff\x13\x00E\x00\x15\x00\\\xffw\xfe,\xfe\xe3\xfe\x19\x00\xc2\x00\x81\x00\xfe\xff\x07\x00\xa0\x000\x01]\x01*\x01\xa9\x00\xd9\xff\xb4\xfer\xfd\x97\xfc\x9d\xfc\x92\xfd\xe3\xfe\xe7\xffi\x00\xad\x00\xf8\x00$\x01\xec\x00R\x00\xac\xffK\xff\x19\xff\xcf\xfeL\xfe\xd0\xfd\xc9\xfdn\xfe\x80\xffk\x00\xda\x00\xec\x00\xec\x00\xf7\x00\xe5\x00r\x00\x9d\xff\xac\xfe\n\xfe\x1b\xfe\xfa\xfe9\x000\x01\x8d\x01\x84\x01\x98\x01\x11\x02\xa5\x02\xc5\x025\x02&\x01\xf5\xff\x07\xff\x9e\xfe\xc7\xfeM\xff\xd0\xff\x05\x00\xf3\xff\xdc\xff\xd9\xff\xd1\xff\xb8\xff\xc3\xff4\x00\xf7\x00\x8c\x01\x8b\x01\xf9\x00C\x00\xde\xff\x02\x00\x7f\x00\xe6\x00\x13\x01*\x01a\x01\xc9\x01 \x02\x04\x02h\x01\xba\x00`\x00(\x00\x9a\xff\xbc\xfe\x11\xfe\x0e\xfe\xa9\xfen\xff\xe1\xff\xea\xff\xea\xffJ\x00\xea\x00.\x01\xd0\x008\x00\xda\xff\xb3\xffz\xff\x12\xff\xc2\xfe\xf9\xfe\xbe\xff\x82\x00\xbd\x00\x83\x00Y\x00\x8e\x00\xda\x00\xc9\x00N\x00\xd2\xff\xb4\xff\xe1\xff\x00\x00\xdd\xff\xa3\xff\xa4\xff\t\x00\xaf\x006\x01f\x01^\x01l\x01\xa8\x01\xc5\x01U\x01I\x00\x1a\xffa\xfec\xfe\xeb\xfeu\xff\xa1\xff\x8d\xff\x82\xff\x98\xff\xbf\xff\xe3\xff\r\x00M\x00\x87\x00~\x00\x16\x00\x8b\xffO\xff\xa5\xffK\x00\xb7\x00\xc8\x00\xcc\x00\t\x01p\x01\xb3\x01\x97\x010\x01\xe2\x00\xe1\x00\xd0\x00&\x00\xfe\xfe&\xfe1\xfe\xd5\xfep\xff\xb0\xff\xbf\xff\xe1\xff&\x00`\x00U\x00\xe1\xff9\xff\xca\xfe\xbb\xfe\xcd\xfe\xc9\xfe\xc8\xfe\xfb\xfeV\xff\x90\xffj\xff\x00\xff\xd3\xfe?\xff\xf1\xffC\x00\x08\x00\xb5\xff\xae\xff\xce\xff\xaf\xffJ\xff\x0f\xffm\xffI\x00\xff\x00\n\x01\x95\x00E\x00u\x00\xdb\x00\xee\x00e\x00\x8d\xff\x04\xff"\xff\x9c\xff\xcc\xff~\xff\x1e\xff\r\xffF\xff~\xff\x8a\xff\x81\xff\xa1\xff\x11\x00\xb5\x00<\x01<\x01\x97\x00\xb3\xff:\xffd\xff\xd1\xff(\x00k\x00\xb2\x00\xde\x00\xd5\x00\xad\x00~\x00W\x00F\x00<\x00\x02\x00\x8e\xff6\xffQ\xff\xb4\xff\xe6\xff\xcb\xff\xb0\xff\xde\xffK\x00\xa7\x00\xc0\x00\xa2\x00y\x00^\x00%\x00\xa1\xff\xf2\xfe\x84\xfe\xa8\xfe>\xff\xcf\xff\xf4\xff\xa1\xffK\xff]\xff\xb7\xff\xe9\xff\xbd\xffx\xff\x81\xff\xdc\xff\x17\x00\xf5\xff\xb5\xff\xbd\xff\x19\x00w\x00y\x00\x19\x00\xc6\xff\xfa\xff\xa3\x00%\x01\x12\x01\x9a\x00"\x00\xbf\xff`\xff\x16\xff\x1d\xff|\xff\x00\x00b\x00]\x00\xf3\xff|\xffY\xff\x9b\xff\x0c\x00r\x00\xa5\x00\x9b\x00m\x00J\x00B\x002\x00\x0e\x00\x17\x00n\x00\xca\x00\xe4\x00\xcc\x00\xbb\x00\xcc\x00\xfb\x00(\x01%\x01\xd2\x00L\x00\xdc\xff\xaf\xff\xaf\xff\xa8\xff\x91\xff\x8c\xff\xa8\xff\xdc\xff\x0e\x00\'\x00)\x00!\x00\t\x00\xd8\xff\x82\xff\x1d\xff\xfc\xfeQ\xff\xe1\xffS\x00p\x00A\x00\x0e\x00\x1f\x00]\x00y\x00^\x00I\x00S\x00T\x00#\x00\xdb\xff\xc0\xff\x06\x00\x8b\x00\xd0\x00\x83\x00\xe8\xff\xa3\xff\xe9\xffG\x00E\x00\xf1\xff\xbb\xff\xe1\xff-\x000\x00\xcd\xffY\xffE\xff\xab\xff\x1c\x00\x1f\x00\xab\xff1\xff\x1f\xffx\xff\xf6\xffN\x00[\x00\x12\x00\xac\xff\x86\xff\xbf\xff\x12\x00<\x00G\x00B\x00\x1c\x00\xe4\xff\xd3\xff\xf6\xff/\x00j\x00\x9b\x00\x91\x00>\x00\xf0\xff\xf8\xff&\x00\x19\x00\xd5\xff\xaa\xff\xc0\xff\xf0\xff\x00\x00\xf2\xff\xfd\xff-\x00K\x00\x16\x00\x8f\xff\xf4\xfe\x9b\xfe\xc2\xfeV\xff\x00\x00O\x00\x14\x00\x88\xff\x1f\xff\x18\xffE\xffe\xff\x83\xff\xd4\xffJ\x00\x91\x00\x85\x00e\x00\x80\x00\xbc\x00\xd5\x00\xae\x00d\x00E\x00j\x00\x8e\x00]\x00\xf1\xff\xd8\xffK\x00\xcf\x00\xba\x00\x0b\x00P\xff\x19\xffw\xff\xf4\xff\t\x00\xaf\xffb\xff\x7f\xff\xdc\xff\x15\x00\xfc\xff\xc4\xff\xb9\xff\xf1\xff:\x00V\x00)\x00\xe9\xff\xed\xff+\x00M\x00\x1c\x00\xc8\xff\xb0\xff\xe6\xff7\x00l\x00q\x00L\x00\x07\x00\xd1\xff\xd6\xff\x03\x00\x1f\x00\x1e\x00\x19\x00\x13\x00\xf0\xff\xbc\xff\x9f\xff\xae\xff\xd4\xff\xe2\xff\xba\xffz\xffS\xffj\xff\xb5\xff\n\x00=\x009\x00\x06\x00\xc9\xff\xa0\xff\x91\xff\x9b\xff\xd0\xffC\x00\xc0\x00\xeb\x00\xa1\x00%\x00\xdf\xff\xfc\xffR\x00\x91\x00\x94\x00v\x00T\x00\x1b\x00\xb2\xffO\xffJ\xff\xb3\xff5\x00p\x00^\x00C\x00Q\x00\x81\x00\x9a\x00t\x00 \x00\xde\xff\xe7\xff)\x00Y\x00W\x00R\x00\x88\x00\xdc\x00\xe5\x00m\x00\xb3\xff=\xff\\\xff\xe9\xffc\x00c\x00\x06\x00\xbf\xff\xd4\xff\x10\x00+\x00\x14\x00\xe9\xff\xbf\xff\xb2\xff\xdb\xff*\x00r\x00\x9b\x00\xb1\x00\x9e\x00:\x00\x95\xff\x12\xff\xfe\xfeA\xff\x97\xff\xc6\xff\xca\xff\xc4\xff\xc6\xff\xb5\xff\x8a\xffh\xffw\xff\xb6\xff\xed\xff\xdf\xff\x89\xff0\xff\x14\xff[\xff\xf1\xffy\x00\x9b\x00_\x00+\x00Q\x00\xb3\x00\xf8\x00\xe0\x00\x86\x001\x00\x08\x00\xea\xff\xbd\xff\x9b\xff\xae\xff\xfb\xffE\x00Y\x00?\x00\x0c\x00\xd6\xff\xb4\xff\xb7\xff\xc8\xff\xc6\xff\xb1\xff\x94\xffu\xffh\xff\x83\xff\xd1\xffD\x00\x9d\x00\x99\x00G\x00\x0c\x001\x00\x93\x00\xca\x00\x92\x00\x10\x00\xb9\xff\xd0\xff&\x00b\x00b\x00J\x009\x002\x00\x14\x00\xd4\xff\x9f\xff\xa6\xff\xf3\xffY\x00y\x00\x17\x00y\xff%\xff8\xfft\xff\x9b\xff\xa7\xff\xb6\xff\xd5\xff\xeb\xff\xe3\xff\xc3\xff\xb3\xff\xd5\xff\x0c\x00\xec\xffZ\xff\xd3\xfe\xc2\xfe\x15\xff}\xff\xbb\xff\xcc\xff\xca\xff\xd3\xff\xf0\xff\x1a\x00>\x00i\x00\xa0\x00\xbc\x00\x91\x00\x1d\x00\x86\xff\t\xff\xf3\xfeO\xff\xce\xff+\x00\\\x00\x80\x00\xab\x00\xd5\x00\xdc\x00\x9f\x00:\x00\xf2\xff\xee\xff\xfc\xff\xda\xff\xac\xff\xba\xff\x16\x00\x8b\x00\xcc\x00\xba\x00}\x00Q\x00<\x003\x00 \x00\xf9\xff\xba\xfft\xffJ\xffX\xff\x91\xff\xca\xff\xed\xff\x14\x00V\x00\x9f\x00\xbe\x00\xa7\x00\x8b\x00\x8d\x00\x93\x00j\x00\x1c\x00\xf0\xff\xf6\xff\xfc\xff\xdd\xff\xba\xff\xca\xff\t\x002\x00\r\x00\xab\xff]\xffj\xff\xcd\xff\x1e\x00\xfc\xff\x88\xff0\xffB\xff\x9f\xff\xec\xff\x03\x00\t\x00-\x00p\x00\x9b\x00\x8c\x00m\x00{\x00\xb7\x00\xd8\x00\xa5\x00\x1d\x00\x86\xff3\xff6\xffd\xff\x8e\xff\xb9\xff\xfc\xffK\x00y\x00x\x00`\x00I\x006\x00.\x00/\x00\x11\x00\xcc\xff\x97\xff\xc3\xffM\x00\xd3\x00\xfd\x00\xdb\x00\xbf\x00\xdc\x00\x06\x01\xf0\x00\x91\x009\x00 \x00\x1b\x00\xf4\xff\xc5\xff\xcd\xff\x07\x001\x00&\x00\x15\x001\x00`\x00o\x00U\x00*\x00\x04\x00\xf6\xff\xf0\xff\xd0\xff\x8d\xffN\xff<\xffb\xff\xc5\xffL\x00\xb0\x00\xb0\x00\\\x00 \x00J\x00\xa0\x00\x92\x00\xfe\xfff\xffN\xff\xa0\xff\xf4\xff\x0e\x00\x11\x00*\x00?\x00)\x00\xeb\xff\xa3\xffz\xff\x9a\xff\xfc\xffI\x001\x00\xb3\xff(\xff\xef\xfe\x19\xffT\xffq\xff\xa0\xff\x19\x00\xab\x00\xe8\x00\xaa\x009\x00\xe9\xff\xd5\xff\xd2\xff\xb8\xff\x86\xffU\xffF\xff^\xff\x8a\xff\xb7\xff\xd2\xff\xd8\xff\xcf\xff\xba\xff\xa5\xff\x97\xff\x8c\xff\x92\xff\xa3\xff\xa3\xff\x8e\xff\x80\xff\x89\xff\x99\xff\xa2\xff\xaf\xff\xd8\xff\x18\x00n\x00\xd6\x00\x1e\x01\x0b\x01\xa5\x004\x00\xda\xffk\xff\xe8\xfe\x9d\xfe\xd6\xfeh\xff\xe6\xff\x14\x00\x08\x00\xe8\xff\xcf\xff\xc5\xff\xc4\xff\xb2\xffx\xff/\xff\x0e\xff:\xff\x82\xff\xac\xff\xb8\xff\xda\xff8\x00\xaa\x00\xde\x00\xb4\x00v\x00\x87\x00\xe4\x00#\x01\xee\x00O\x00\xad\xffj\xff\x8c\xff\xbc\xff\xc2\xff\xc1\xff\xf1\xff;\x00S\x000\x00\x03\x00\x04\x00\'\x00=\x003\x00\x03\x00\xb3\xffm\xffc\xff\xb0\xff3\x00\xa8\x00\xeb\x00\x03\x01\xff\x00\xd8\x00\x96\x00Z\x00A\x00@\x00.\x00\xfa\xff\xc1\xff\x9e\xff\x86\xffg\xffU\xffs\xff\xcd\xff7\x00\x80\x00\x9b\x00\x92\x00w\x00P\x00 \x00\xe5\xff\xa6\xff\x87\xff\x9b\xff\xd4\xff\xf8\xff\xf2\xff\x03\x00b\x00\xf6\x00c\x01f\x01\n\x01\x91\x00-\x00\xef\xff\xd0\xff\xb9\xff\xa7\xff\xa8\xff\xca\xff\r\x00d\x00\x9e\x00\x84\x00\x18\x00\xbc\xff\xc2\xff\x1b\x00j\x00_\x00\x0e\x00\xbd\xff\x94\xff\x8a\xff\x8a\xff\x9d\xff\xe1\xffY\x00\xcd\x00\x05\x01\n\x01\x0c\x01\x17\x01\xff\x00\xae\x00E\x00\xf2\xff\xc7\xff\xaf\xff\x99\xff\x8d\xff\xb2\xff\x0e\x00g\x00}\x00D\x00\xf4\xff\xc9\xff\xd1\xff\xf0\xff\xfa\xff\xdc\xff\xa0\xffk\xffk\xff\x9d\xff\xcd\xff\xe3\xff\x05\x00N\x00\x9d\x00\xcb\x00\xd1\x00\xaa\x00P\x00\xd7\xffp\xffA\xffC\xff^\xffo\xffT\xff\x1a\xff\xff\xfe/\xff\x9a\xff\xff\xff$\x00\x0e\x00\xef\xff\xdc\xff\xc7\xff\x9c\xffk\xff\\\xff\x85\xff\xbd\xff\xdc\xff\xda\xff\xd4\xff\xe7\xff\x17\x00U\x00\x86\x00\x95\x00\x80\x00_\x003\x00\xe7\xff\x88\xffB\xff#\xff\x1b\xff7\xff\x93\xff\x06\x00F\x00@\x00\x1a\x00\xee\xff\xc9\xff\xb3\xff\xb3\xff\xc7\xff\xcd\xff\xad\xffh\xff4\xffK\xff\xbd\xffS\x00\xba\x00\xc6\x00\x98\x00x\x00{\x00s\x00A\x00\x07\x00\x01\x007\x00_\x009\x00\xbe\xff/\xff\xf6\xfe?\xff\xca\xff,\x00G\x00;\x005\x006\x00)\x00\xf2\xff\x8a\xff#\xff\n\xffR\xff\xc0\xff\x0e\x006\x00d\x00\xa4\x00\xc3\x00\x97\x00U\x00<\x00<\x00(\x00\xfe\xff\xe8\xff\xfe\xff\x0e\x00\xca\xffB\xff\xe9\xfe\x14\xff\x85\xff\xd6\xff\xf7\xff\x18\x00W\x00\x89\x00v\x00&\x00\xd5\xff\xa7\xff\x94\xff|\xffg\xff\x81\xff\xdb\xffO\x00\xb0\x00\xfb\x00/\x01-\x01\xf1\x00\xaa\x00\x80\x00T\x00\xfb\xff\x88\xff:\xff;\xff\x8a\xff\xfd\xffd\x00\x99\x00\x8e\x00]\x00!\x00\xdf\xff\xa6\xff\x9a\xff\xc6\xff\x11\x00J\x00J\x00\x05\x00\xb4\xff\xa7\xff\xec\xffP\x00\x98\x00\xb5\x00\xc5\x00\xd6\x00\xce\x00\x90\x00A\x00\x1c\x00$\x00+\x00\x05\x00\xb0\xffR\xff-\xffa\xff\xd4\xff?\x00j\x00\\\x00D\x00>\x00<\x00%\x00\xe6\xff\x99\xffa\xffP\xffj\xff\x9f\xff\xe1\xff\x1d\x00C\x00M\x00J\x00F\x00H\x00G\x002\x00\x11\x00\xfa\xff\xea\xff\xbb\xffS\xff\xe2\xfe\xc2\xfe$\xff\xd8\xff{\x00\xc6\x00\xc1\x00\xa6\x00\x97\x00\x88\x00T\x00\xeb\xffi\xff\t\xff\xf8\xfe4\xff\x99\xff\x06\x00d\x00\xac\x00\xe2\x00\n\x01\xfe\x00\x93\x00\x00\x00\xaf\xff\xd1\xff \x001\x00\xea\xff\x90\xffz\xff\xc7\xffB\x00\x97\x00\x98\x00U\x00\x0b\x00\xf2\xff\r\x005\x00<\x00\x14\x00\xdf\xff\xbd\xff\xa6\xff\x8f\xff\x83\xff\x9d\xff\xea\xffY\x00\xc7\x00\x06\x01\x02\x01\xc9\x00~\x00*\x00\xe4\xff\xbb\xff\xa4\xff\x83\xffU\xff<\xffT\xff\x8c\xff\xbc\xff\xc6\xff\xb4\xff\xac\xff\xc9\xff\x12\x00a\x00\x82\x00H\x00\xdd\xff\x7f\xff:\xff\t\xff\x02\xffT\xff\xf4\xff\x9d\x00\x02\x01\r\x01\xdb\x00\x9c\x00e\x00*\x00\xdf\xff\x98\xffo\xff[\xffA\xff.\xffW\xff\xc1\xff-\x00`\x00b\x00b\x00b\x00I\x00\x19\x00\xfc\xff\x07\x00\x17\x00\xfb\xff\xb0\xffq\xffr\xff\xa0\xff\xbf\xff\xc5\xff\xed\xffS\x00\xc2\x00\xed\x00\xc6\x00\x89\x00`\x00/\x00\xd2\xff_\xff\x17\xff!\xffg\xff\xcc\xffE\x00\xb4\x00\xed\x00\xd3\x00\x80\x00)\x00\xf2\xff\xd6\xff\xb1\xff|\xffH\xff&\xff\x17\xff*\xffY\xff\x90\xff\xc1\xff\xed\xff#\x00S\x00m\x00o\x00W\x00\x1d\x00\xd5\xff\xa1\xff~\xff\\\xff2\xff(\xffo\xff\xf7\xff\x81\x00\xc2\x00\xb2\x00}\x00_\x00[\x00I\x00\x01\x00\x91\xff2\xff\t\xff\x17\xffX\xff\xc8\xff@\x00\x8f\x00\xa1\x00\x8e\x00l\x000\x00\xdb\xff\x9d\xff\xb5\xff\x1c\x00y\x00m\x00\xfd\xff\x83\xffT\xff\x87\xff\xe0\xff!\x00H\x00z\x00\xc0\x00\xf8\x00\xfc\x00\xc2\x00c\x00\xfd\xff\xa8\xffj\xffC\xff9\xff[\xff\xb1\xff\'\x00\x96\x00\xe2\x00\xf7\x00\xdd\x00\xac\x00p\x00.\x00\xeb\xff\xa6\xffc\xff2\xff2\xffc\xff\xb7\xff\x11\x00`\x00\x8e\x00\x84\x00W\x00/\x00\x1c\x00\x0c\x00\xe6\xff\xab\xffp\xffG\xff?\xff^\xff\x9f\xff\xf4\xffV\x00\xc1\x00\x1b\x017\x01\x07\x01\xb3\x00n\x00N\x00G\x002\x00\xf4\xff\x9c\xffh\xff\x85\xff\xe2\xffC\x00z\x00x\x00U\x00/\x00\x18\x00\r\x00\n\x00\x11\x00\x15\x00\x00\x00\xbb\xffk\xffW\xff\x96\xff\x02\x00Q\x00f\x00`\x00_\x00c\x00`\x00_\x00f\x00t\x00\x87\x00~\x00$\x00z\xff\xee\xfe\xf0\xfe\x80\xff3\x00\xa9\x00\xd7\x00\xe6\x00\xe0\x00\xae\x00`\x00\x10\x00\xba\xffZ\xff\n\xff\xe5\xfe\xe7\xfe\xfd\xfe\'\xffg\xff\xbc\xff\x07\x00"\x00\x11\x00\x01\x00\n\x00\x12\x00\xf1\xff\xac\xff_\xff3\xff9\xffl\xff\xbb\xff$\x00\xa2\x00\t\x01!\x01\xdd\x00{\x00F\x00N\x00T\x00"\x00\xc0\xffj\xffR\xffy\xff\xb8\xff\xfb\xffD\x00\x7f\x00\x8b\x00q\x00F\x00\x13\x00\xdc\xff\xcc\xff\x08\x00h\x00\x86\x00/\x00\xad\xff\x84\xff\xce\xff-\x00Q\x00?\x000\x00=\x00_\x00z\x00}\x00m\x00c\x00c\x00K\x00\xfd\xff\x9a\xffs\xff\xaa\xff\x14\x00[\x00]\x004\x00\r\x00\x07\x00\x18\x00#\x00\x16\x00\xf8\xff\xc8\xff}\xff\x16\xff\xb8\xfe\x95\xfe\xd2\xfe\\\xff\xeb\xff6\x00-\x00\x02\x00\xe3\xff\xd4\xff\xc5\xff\xb0\xff\x94\xffh\xff2\xff\x12\xff0\xff\x83\xff\xe9\xff6\x00V\x00L\x00+\x00\x17\x00.\x00]\x00h\x00.\x00\xd4\xff\x8d\xfft\xff\x89\xff\xcb\xff4\x00\xa0\x00\xd0\x00\xab\x00\\\x00\x18\x00\xef\xff\xd4\xff\xc5\xff\xd1\xff\xea\xff\xe5\xff\xba\xff\xa1\xff\xbc\xff\xf5\xff$\x00F\x00e\x00p\x00P\x00!\x00\x1d\x00]\x00\xae\x00\xd0\x00\xa7\x00[\x00\x1a\x00\x06\x00\x19\x00.\x00&\x00\x01\x00\xd6\xff\xca\xff\xf6\xff5\x00H\x00\'\x00\x01\x00\xf6\xff\xed\xff\xb3\xffH\xff\xf1\xfe\xf5\xfeL\xff\xb1\xff\xd5\xff\xac\xffx\xff\x86\xff\xd4\xff\x1f\x005\x00\x17\x00\xcd\xffa\xff\xf3\xfe\xc2\xfe\xf6\xfer\xff\xf3\xff\\\x00\xa9\x00\xcc\x00\xbd\x00\x95\x00~\x00q\x00U\x00+\x00\x0f\x00\xfb\xff\xd6\xff\xa8\xff\xa4\xff\xeb\xffT\x00\x89\x00X\x00\xef\xff\x9b\xff\x80\xff\x9d\xff\xd6\xff\x07\x00\x10\x00\xe4\xff\xb0\xff\xb2\xff\xf5\xffO\x00\x92\x00\xb5\x00\xbc\x00\xa9\x00\x8d\x00{\x00|\x00\x87\x00\xa0\x00\xb5\x00\xa5\x00Z\x00\xfd\xff\xde\xff\x16\x00f\x00x\x00<\x00\xee\xff\xce\xff\xda\xff\xf3\xff\x11\x00;\x00]\x00\\\x00*\x00\xdf\xff\xa6\xff\x92\xff\xa1\xff\xbe\xff\xda\xff\xee\xff\xf6\xff\xf2\xff\xec\xff\xec\xff\xf3\xff\x01\x00\x12\x00\x0f\x00\xdb\xff\x86\xff\\\xff\x88\xff\xeb\xffE\x00c\x00?\x00\x08\x00\x00\x008\x00u\x00x\x00H\x00\x18\x00\xfe\xff\xdb\xff\xa7\xff\x91\xff\xc4\xff\'\x00x\x00\x88\x00W\x00\x12\x00\xdd\xff\xcd\xff\xe3\xff\x0f\x000\x00-\x00\x07\x00\xd8\xff\xbd\xff\xbf\xff\xd9\xff\x0b\x00J\x00i\x00=\x00\xe7\xff\xb8\xff\xda\xff%\x00]\x00b\x004\x00\xf2\xff\xca\xff\xda\xff\x10\x00N\x00n\x00[\x00,\x00\x04\x00\xf6\xff\xfb\xff\xfd\xff\xf3\xff\xea\xff\xe3\xff\xd2\xff\xa9\xff\x84\xff\x8d\xff\xc8\xff\x0c\x00\'\x00\r\x00\xd3\xff\x92\xffk\xffu\xff\xb8\xff\x12\x00>\x00\x18\x00\xba\xffm\xffn\xff\xb6\xff\x04\x00)\x00,\x00"\x00\x13\x00\x01\x00\xf1\xff\xe8\xff\xf4\xff\x15\x00A\x00T\x006\x00\xfc\xff\xe1\xff\xfc\xff\'\x00 \x00\xd7\xff\x83\xffc\xff\x85\xff\xb4\xff\xc7\xff\xc1\xff\xc1\xff\xc7\xff\xc3\xff\xa8\xff\x84\xff}\xff\xb6\xff\x1c\x00n\x00t\x00<\x00\x08\x00\x05\x00,\x00_\x00\x89\x00\x9a\x00\x7f\x005\x00\xde\xff\xac\xff\xba\xff\xf1\xff#\x00$\x00\xe6\xff\x91\xff^\xffg\xff\x95\xff\xbb\xff\xd8\xff\xf4\xff\x0b\x00\x0b\x00\xf9\xff\xea\xff\xea\xff\xf7\xff\x13\x009\x00L\x00.\x00\xef\xff\xc8\xff\xe0\xff\x19\x001\x00\xfe\xff\xa7\xff{\xff\xa9\xff\x0b\x00Y\x00o\x00_\x00;\x00\t\x00\xd9\xff\xd3\xff\x0e\x00k\x00\xb3\x00\xc0\x00\x90\x00P\x005\x00Q\x00v\x00t\x00A\x00\n\x00\xe8\xff\xc8\xff\x9e\xff\x80\xff\x8e\xff\xc3\xff\r\x00K\x00Y\x006\x00\x0b\x00\x10\x00B\x00b\x00<\x00\xec\xff\xbc\xff\xd4\xff\x17\x00I\x00U\x00X\x00j\x00o\x00H\x00\xf6\xff\xb2\xff\xa7\xff\xd0\xff\x07\x00\x18\x00\xec\xff\xaf\xff\xa6\xff\xe6\xffA\x00\x80\x00\x8d\x00y\x00K\x00\x01\x00\xaa\xffn\xffk\xff\xa2\xff\xf0\xff\x13\x00\xe2\xff\x81\xffK\xffc\xff\xa0\xff\xc7\xff\xba\xff\x89\xffY\xffS\xffq\xff\xa2\xff\xdb\xff%\x00t\x00\x9b\x00v\x00\x1f\x00\xde\xff\xe4\xff*\x00l\x00g\x00"\x00\xef\xff\x08\x00W\x00\x8d\x00}\x00<\x00\xfd\xff\xce\xff\x9c\xff]\xff<\xffq\xff\xed\xff`\x00\x8b\x00n\x00>\x00)\x001\x00:\x00,\x00\x0c\x00\xed\xff\xe0\xff\xe2\xff\xf1\xff\x13\x00J\x00\x89\x00\xae\x00\xa0\x00d\x00,\x00\x1d\x00$\x00\x11\x00\xcc\xffr\xffI\xff\x7f\xff\xf3\xffU\x00r\x00c\x00\\\x00c\x00J\x00\x01\x00\xad\xff\x87\xff\xa1\xff\xed\xff8\x00F\x00\x0b\x00\xc4\xff\xb7\xff\xf9\xffL\x00\\\x00\x17\x00\xb9\xff{\xff`\xff[\xffs\xff\xbe\xff"\x00[\x00H\x00\x04\x00\xc7\xff\xa7\xff\x9c\xff\xaa\xff\xd5\xff\x0f\x00<\x00I\x00A\x004\x00#\x00\x12\x00\x0b\x00\x03\x00\xe4\xff\xac\xffz\xffs\xff\xa8\xff\x00\x00O\x00o\x00c\x00O\x00M\x00O\x00E\x002\x00#\x00\x1b\x00\x14\x00\x08\x00\x05\x00%\x00b\x00\x98\x00\xa2\x00\x82\x00Y\x00A\x00\x1c\x00\xd7\xff\x92\xffl\xffd\xffy\xff\xa1\xff\xd0\xff\xf9\xff\x15\x007\x00e\x00\x82\x00g\x00&\x00\xf3\xff\xed\xff\x02\x00\x0f\x00\xfa\xff\xcc\xff\xa8\xff\xb7\xff\xfa\xff9\x00?\x00\n\x00\xc6\xff\x95\xff\x80\xffs\xffj\xffm\xff\x99\xff\xf0\xffF\x00e\x00D\x00\x05\x00\xe0\xff\xe8\xff\r\x00-\x00>\x00B\x004\x00\x13\x00\xf3\xff\xe8\xff\xf4\xff\xf8\xff\xd4\xff\x91\xffQ\xff1\xff6\xffZ\xff\x96\xff\xe3\xff\'\x00<\x00"\x00\xf9\xff\xdb\xff\xd5\xff\xe6\xff\x04\x00\x12\x00\x00\x00\xec\xff\xfe\xff/\x00a\x00s\x00c\x00?\x00\x1e\x00\n\x00\xff\xff\xf7\xff\xea\xff\xdd\xff\xd9\xff\xe6\xff\xee\xff\xe6\xff\xd9\xff\xe9\xff!\x00c\x00\x87\x00\x85\x00i\x008\x00\xfb\xff\xc7\xff\xb6\xff\xc0\xff\xc5\xff\xb5\xff\xa8\xff\xc3\xff\xf8\xff\x1a\x00\x17\x00\x04\x00\xf3\xff\xdf\xff\xbd\xff\xa1\xff\xaf\xff\xe0\xff\r\x00 \x00%\x007\x00]\x00}\x00t\x00B\x00\t\x00\xf8\xff\x12\x008\x00N\x00N\x00A\x00.\x00\x15\x00\xf4\xff\xcb\xff\x9e\xff{\xff\x82\xff\xc0\xff\x1d\x00d\x00n\x00D\x00\x18\x00\x13\x00$\x00.\x00\'\x00\x0e\x00\xf1\xff\xdf\xff\xde\xff\xed\xff\n\x00&\x004\x002\x00#\x00\x07\x00\xe6\xff\xcf\xff\xc7\xff\xc6\xff\xbf\xff\xaf\xff\xa9\xff\xb4\xff\xc5\xff\xd8\xff\xf2\xff\x1b\x00G\x00b\x00i\x00c\x00Y\x00O\x00C\x00>\x003\x00\x10\x00\xde\xff\xc1\xff\xcc\xff\xf0\xff\x15\x002\x00<\x001\x00\x03\x00\xaf\xff`\xffN\xff\x84\xff\xd4\xff\x0f\x00$\x00\x1d\x00\x0f\x00\x08\x00\x05\x00\xfe\xff\xfe\xff\x17\x00E\x00q\x00~\x00j\x00C\x00\x12\x00\xe0\xff\xb8\xff\xa9\xff\xbb\xff\xe4\xff\x04\x00\x03\x00\xf2\xff\xf0\xff\x00\x00\x1a\x006\x00T\x00f\x00\\\x00;\x00\x18\x00\xfb\xff\xe8\xff\xe0\xff\xed\xff\x17\x00N\x00j\x00P\x00\x11\x00\xd7\xff\xca\xff\xe6\xff\xfd\xff\xe4\xff\xa9\xff~\xff\x82\xff\xa3\xff\xc0\xff\xc9\xff\xcf\xff\xde\xff\xf7\xff\x19\x004\x006\x00\x1d\x00\xff\xff\xf9\xff\xfd\xff\xe9\xff\xb5\xff\x89\xff\x91\xff\xc5\xff\xfd\xff\r\x00\xfd\xff\xe9\xff\xe1\xff\xda\xff\xcf\xff\xd3\xff\xf3\xff&\x00F\x00;\x00\x11\x00\xeb\xff\xdf\xff\xe8\xff\xf3\xff\xff\xff\x1a\x00>\x00L\x006\x00\x0b\x00\xde\xff\xb6\xff\x9d\xff\x99\xff\xa3\xff\xae\xff\xb4\xff\xaf\xff\xab\xff\xb9\xff\xe2\xff\x1d\x00V\x00{\x00\x8f\x00\x95\x00\x91\x00x\x00A\x00\n\x00\xf7\xff\x12\x00;\x00X\x00Z\x00C\x00 \x00\x06\x00\xfc\xff\x06\x00\x1a\x00#\x00\x10\x00\xe6\xff\xc2\xff\xb7\xff\xc1\xff\xcd\xff\xd7\xff\xef\xff\x1a\x00H\x00i\x00v\x00p\x00^\x00F\x006\x005\x00,\x00\x0b\x00\xe2\xff\xca\xff\xcf\xff\xda\xff\xce\xff\xae\xff\x9b\xff\xa1\xff\xae\xff\xae\xff\xa0\xff\xa2\xff\xc0\xff\xec\xff\xf4\xff\xc6\xff\x8f\xff\x7f\xff\x9d\xff\xce\xff\xf1\xff\x05\x00\x11\x00\x17\x00\x1c\x00.\x00H\x00S\x00?\x00\x1b\x00\xfe\xff\xeb\xff\xd1\xff\xaa\xff\x8b\xff\x8c\xff\xba\xff\xff\xff>\x00]\x00X\x00>\x00#\x00\x06\x00\xdf\xff\xbb\xff\xb5\xff\xd5\xff\x06\x00)\x00+\x00\x18\x00\x0e\x00\'\x00G\x00G\x00\x1d\x00\xf2\xff\xea\xff\xfe\xff\n\x00\x01\x00\xf6\xff\xfc\xff\x15\x000\x007\x00)\x00\x11\x00\x00\x00\x06\x00#\x00P\x00\x81\x00\x9d\x00\x8d\x00P\x00\x07\x00\xd3\xff\xba\xff\xb6\xff\xc3\xff\xe2\xff\t\x00\'\x00)\x00\r\x00\xdd\xff\xb6\xff\xb4\xff\xd4\xff\xf0\xff\xe5\xff\xbd\xff\x9a\xff\x90\xff\x9b\xff\xaf\xff\xbc\xff\xc1\xff\xc5\xff\xca\xff\xd6\xff\xe6\xff\xef\xff\xea\xff\xde\xff\xd7\xff\xd3\xff\xc4\xff\xa6\xff\x8c\xff\x90\xff\xb6\xff\xf7\xffA\x00u\x00\x85\x00\x84\x00\x8c\x00\x9a\x00\x97\x00n\x000\x00\xff\xff\xf4\xff\x03\x00\t\x00\x01\x00\xff\xff\x1b\x00C\x00L\x00&\x00\xed\xff\xd0\xff\xda\xff\xef\xff\xf3\xff\xe9\xff\xe2\xff\xeb\xff\x05\x00"\x001\x000\x001\x00H\x00l\x00~\x00u\x00o\x00w\x00{\x00W\x00\r\x00\xc3\xff\xa5\xff\xbf\xff\xf3\xff\x16\x00\x1d\x00\x15\x00\t\x00\x03\x00\t\x00\x1e\x002\x00=\x00?\x004\x00\x0e\x00\xd4\xff\x9b\xff\x82\xff\x96\xff\xc9\xff\xff\xff \x00&\x00\x1b\x00\x05\x00\xed\xff\xd0\xff\xb3\xff\x9f\xff\x96\xff\x98\xff\xa3\xff\xb8\xff\xda\xff\x01\x00\x1d\x00 \x00\x11\x00\x0e\x00(\x00R\x00r\x00o\x00M\x00$\x00\x0c\x00\xf6\xff\xce\xff\x9c\xff\x8a\xff\xb5\xff\t\x00B\x004\x00\xfb\xff\xdf\xff\xf9\xff&\x000\x00\n\x00\xda\xff\xbd\xff\xbe\xff\xd1\xff\xf2\xff\x1a\x00@\x00]\x00j\x00b\x00E\x00(\x00\x1d\x00\x1e\x00\x14\x00\xf7\xff\xd5\xff\xc7\xff\xd4\xff\xe6\xff\xe9\xff\xdf\xff\xda\xff\xeb\xff\x06\x00\x0b\x00\xed\xff\xd4\xff\xe6\xff\x1a\x00<\x00\x1e\x00\xd2\xff\x95\xff\x90\xff\xb2\xff\xd9\xff\xee\xff\xf5\xff\xfa\xff\x04\x00\x11\x00\x1e\x00\x1b\x00\x04\x00\xe5\xff\xd7\xff\xde\xff\xe2\xff\xd1\xff\xc3\xff\xd1\xff\xfb\xff#\x00.\x00#\x00\x1c\x00*\x00>\x00@\x00%\x00\xfc\xff\xd4\xff\xb6\xff\xa6\xff\xa8\xff\xba\xff\xd9\xff\xf8\xff\x08\x00\xfa\xff\xd2\xff\xb7\xff\xbf\xff\xe5\xff\x08\x00\x14\x00\x0b\x00\xf9\xff\xdb\xff\xb9\xff\xa8\xff\xbd\xff\xfa\xffA\x00k\x00e\x00D\x00/\x007\x00M\x00N\x00)\x00\xf2\xff\xcb\xff\xc7\xff\xd6\xff\xd7\xff\xc5\xff\xc0\xff\xe0\xff\x10\x00\x1e\x00\xfa\xff\xd1\xff\xd4\xff\x04\x00.\x00\x1d\x00\xe1\xff\xb1\xff\xb3\xff\xd4\xff\xec\xff\xeb\xff\xed\xff\x05\x001\x00M\x00A\x00\x17\x00\xf0\xff\xea\xff\n\x000\x005\x00\x11\x00\xec\xff\xe8\xff\x08\x00/\x00<\x003\x001\x00P\x00~\x00\x93\x00\x81\x00\\\x00>\x00.\x00\x1b\x00\xf2\xff\xc6\xff\xb1\xff\xc2\xff\xe4\xff\xfa\xff\xfb\xff\xf7\xff\xff\xff\x03\x00\xf1\xff\xd9\xff\xd8\xff\xe7\xff\xf3\xff\xf0\xff\xf0\xff\x04\x00$\x00;\x009\x00!\x00\x0c\x00\x11\x009\x00h\x00}\x00c\x000\x00\x03\x00\xe9\xff\xd6\xff\xb5\xff\x8f\xff\x8b\xff\xb6\xff\xf2\xff\x08\x00\xf2\xff\xd5\xff\xdc\xff\x00\x00\x17\x00\x01\x00\xc8\xff\x96\xff\x8f\xff\xb3\xff\xda\xff\xe4\xff\xdc\xff\xe6\xff\x08\x00\'\x00#\x00\x0b\x00\xff\xff\x0f\x000\x00G\x00@\x00$\x00\n\x00\x0e\x00+\x00C\x00C\x007\x002\x006\x005\x00%\x00\x1a\x00#\x00;\x00E\x003\x00\x08\x00\xd7\xff\xad\xff\x91\xff\x96\xff\xb5\xff\xe0\xff\x05\x00-\x00M\x00M\x00+\x00\x08\x00\x04\x00\x1c\x00+\x00\x17\x00\xee\xff\xd2\xff\xde\xff\x04\x00#\x001\x00?\x00O\x00K\x00!\x00\xe6\xff\xbd\xff\xb3\xff\xba\xff\xc7\xff\xd3\xff\xda\xff\xdb\xff\xcd\xff\xb3\xff\x9f\xff\xa0\xff\xb7\xff\xd6\xff\xef\xff\xf7\xff\xf2\xff\xe9\xff\xe8\xff\xec\xff\xde\xff\xbc\xff\x93\xff\x81\xff\x9a\xff\xd8\xff\x11\x00\x1d\x00\x01\x00\xef\xff\xfe\xff\x1a\x00%\x00\x15\x00\xf7\xff\xe6\xff\xf2\xff\x16\x001\x00&\x00\x00\x00\xdd\xff\xd8\xff\xf2\xff\x18\x00J\x00w\x00\x8a\x00r\x00=\x00\r\x00\xee\xff\xd5\xff\xbd\xff\xaf\xff\xbf\xff\xe8\xff\x17\x004\x00>\x004\x00\x19\x00\x02\x00\x00\x00\x1b\x002\x00"\x00\xed\xff\xc0\xff\xc2\xff\xee\xff!\x00D\x00X\x00a\x00W\x008\x00\x0f\x00\xf7\xff\xf3\xff\xf6\xff\xf5\xff\xf9\xff\x02\x00\x00\x00\xec\xff\xd0\xff\xbf\xff\xbb\xff\xba\xff\xb3\xff\xa6\xff\x9a\xff\x9e\xff\xb9\xff\xdb\xff\xf1\xff\xf8\xff\xf5\xff\xe4\xff\xc9\xff\xb1\xff\xab\xff\xb0\xff\xb6\xff\xcb\xff\xfb\xff6\x00\\\x00b\x00_\x00d\x00i\x00Z\x005\x00\r\x00\xf4\xff\xe7\xff\xe9\xff\xfd\xff\x16\x00%\x00\'\x00&\x00+\x00(\x00\x14\x00\xf9\xff\xe3\xff\xd9\xff\xd5\xff\xd0\xff\xca\xff\xc7\xff\xce\xff\xe2\xff\t\x00<\x00f\x00y\x00t\x00e\x00S\x007\x00\x10\x00\xec\xff\xe3\xff\xfd\xff,\x00X\x00k\x00_\x00<\x00\n\x00\xe3\xff\xd8\xff\xe6\xff\xf5\xff\xfb\xff\xfa\xff\xf7\xff\xf6\xff\xee\xff\xdb\xff\xca\xff\xcc\xff\xe0\xff\xf2\xff\xf3\xff\xe8\xff\xdd\xff\xda\xff\xdf\xff\xe9\xff\xf4\xff\xfd\xff\xfc\xff\xee\xff\xdb\xff\xd1\xff\xd2\xff\xd3\xff\xce\xff\xc1\xff\xb9\xff\xc6\xff\x01\x00\\\x00\x8c\x00u\x00=\x00\x0f\x00\xfb\xff\xf5\xff\xed\xff\xde\xff\xd4\xff\xdd\xff\xf7\xff\x19\x00;\x00I\x00@\x00(\x00\x17\x00\x1c\x00"\x00\n\x00\xd3\xff\xa4\xff\xa0\xff\xc0\xff\xe9\xff\x0c\x00(\x00;\x00:\x00/\x00+\x000\x00&\x00\x06\x00\xe2\xff\xd8\xff\xe9\xff\xfb\xff\xfe\xff\xf6\xff\xf3\xff\xfb\xff\x06\x00\x10\x00\x18\x00!\x00\'\x00\x18\x00\xf9\xff\xe0\xff\xd5\xff\xd7\xff\xdf\xff\xec\xff\x00\x00\x13\x00\x12\x00\xfa\xff\xe4\xff\xe9\xff\xfb\xff\xfd\xff\xe8\xff\xd9\xff\xe9\xff\t\x00\x0f\x00\xea\xff\xb7\xff\xa1\xff\xb6\xff\xe0\xff\xfb\xff\xfa\xff\xee\xff\xf1\xff\x10\x00=\x00[\x00O\x00(\x00\x07\x00\x00\x00\x05\x00\x01\x00\xf5\xff\xea\xff\xe7\xff\xec\xff\xf2\xff\xf8\xff\x03\x00\x11\x00\x15\x00\x07\x00\xf0\xff\xda\xff\xcc\xff\xc4\xff\xc0\xff\xc0\xff\xc8\xff\xe4\xff\x1b\x00R\x00e\x00L\x00)\x00\x1c\x005\x00Z\x00g\x00M\x00!\x00\xfb\xff\xf2\xff\x01\x00\x12\x00\x0b\x00\xee\xff\xd0\xff\xc9\xff\xd6\xff\xe2\xff\xe5\xff\xdb\xff\xce\xff\xca\xff\xdf\xff\xf7\xff\xef\xff\xc5\xff\xa1\xff\xa5\xff\xc9\xff\xed\xff\x06\x00\x1d\x001\x008\x00.\x00\x1e\x00\x18\x00\x12\x00\x00\x00\xe5\xff\xda\xff\xeb\xff\x0b\x00\x1f\x00\x16\x00\xf9\xff\xea\xff\x02\x00)\x00A\x00@\x003\x00&\x00\x1b\x00\x03\x00\xd9\xff\xb8\xff\xb7\xff\xd2\xff\xfb\xff\x1b\x00(\x00\x1f\x00\x0c\x00\xf8\xff\xec\xff\xe7\xff\xe3\xff\xe6\xff\xf1\xff\x03\x00\x05\x00\xf8\xff\xe8\xff\xf0\xff\x10\x000\x008\x00&\x00\x18\x00#\x00=\x00K\x00=\x00 \x00\x0b\x00\x04\x00\x04\x00\x03\x00\x00\x00\xf8\xff\xed\xff\xe5\xff\xe2\xff\xe2\xff\xe6\xff\xef\xff\x04\x00\x1a\x00.\x005\x00$\x00\xf3\xff\xb8\xff\x9b\xff\xab\xff\xd3\xff\xf5\xff\x06\x00\x0b\x00\x03\x00\xfe\xff\x02\x00\r\x00\x16\x00\x18\x00\x08\x00\xe0\xff\xb4\xff\xa5\xff\xb8\xff\xd1\xff\xda\xff\xe3\xff\t\x00G\x00v\x00|\x00`\x00;\x00\x1c\x00\xff\xff\xe3\xff\xd4\xff\xdd\xff\xff\xff"\x000\x00\x1f\x00\xff\xff\xec\xff\xeb\xff\xee\xff\xe8\xff\xdf\xff\xdb\xff\xdb\xff\xdd\xff\xdc\xff\xd1\xff\xc0\xff\xbe\xff\xe0\xff\x1d\x00J\x00J\x00,\x00\x10\x00\x15\x003\x00G\x00=\x00*\x00\x1f\x00\x1f\x00 \x00\x1e\x00\x14\x00\xf8\xff\xd1\xff\xad\xff\xa2\xff\xb7\xff\xdd\xff\xf7\xff\xfb\xff\xf9\xff\r\x00*\x00)\x00\xf7\xff\xb9\xff\xa5\xff\xc4\xff\xf3\xff\x13\x00\x1d\x00\x15\x00\x0c\x00\x14\x001\x00S\x00^\x00D\x00\x11\x00\xd7\xff\xab\xff\x9b\xff\xa5\xff\xbc\xff\xdb\xff\x03\x00,\x00D\x00J\x00=\x00\'\x00\x0f\x00\x04\x00\x02\x00\xf7\xff\xe7\xff\xe2\xff\xef\xff\xff\xff\xfc\xff\xef\xff\xf2\xff\t\x00\x1b\x00\x12\x00\xf9\xff\xe9\xff\xea\xff\xed\xff\xe7\xff\xe2\xff\xea\xff\xff\xff\x10\x00\x15\x00\x1d\x00.\x00A\x00D\x008\x003\x00=\x00@\x00/\x00\x14\x00\x05\x00\n\x00\x12\x00\x07\x00\xf1\xff\xd8\xff\xbe\xff\xa5\xff\x9e\xff\xb3\xff\xdc\xff\xfc\xff\x05\x00\x05\x00\x18\x003\x00/\x00\x04\x00\xd8\xff\xcf\xff\xe1\xff\xef\xff\xf2\xff\xf9\xff\x0f\x00(\x00;\x00C\x00?\x00/\x00\x10\x00\xe8\xff\xc8\xff\xbc\xff\xc5\xff\xd3\xff\xdc\xff\xe6\xff\xf7\xff\n\x00\x15\x00\x19\x00\x1a\x00\x16\x00\x10\x00\x0c\x00\x10\x00\x11\x00\x07\x00\xeb\xff\xcb\xff\xb5\xff\xb5\xff\xca\xff\xe6\xff\xf6\xff\xf7\xff\xf2\xff\xf1\xff\xf4\xff\xf8\xff\xfd\xff\xfc\xff\xf1\xff\xe3\xff\xd8\xff\xd9\xff\xe2\xff\xfb\xff\x1f\x00:\x00C\x00>\x005\x004\x007\x009\x002\x00&\x00\x1c\x00\x16\x00\x0b\x00\xe9\xff\xb5\xff\x8d\xff\x96\xff\xca\xff\x04\x00 \x00\x17\x00\x08\x00\x11\x000\x00;\x00\x1a\x00\xe4\xff\xc5\xff\xc8\xff\xdd\xff\xee\xff\xfd\xff\x15\x00,\x004\x002\x00.\x00/\x00-\x00 \x00\x06\x00\xec\xff\xd7\xff\xca\xff\xbf\xff\xb8\xff\xc0\xff\xdd\xff\x05\x00(\x00:\x00?\x008\x00-\x00"\x00\x1b\x00\x10\x00\x01\x00\xeb\xff\xd5\xff\xcd\xff\xd2\xff\xd4\xff\xd1\xff\xd0\xff\xdb\xff\xef\xff\xfa\xff\xf9\xff\xf0\xff\xec\xff\xec\xff\xe6\xff\xd5\xff\xbe\xff\xb4\xff\xc9\xff\xf4\xff\x1f\x005\x009\x00@\x00R\x00d\x00j\x00\\\x00D\x00,\x00\x12\x00\xf7\xff\xda\xff\xbe\xff\xa3\xff\x9a\xff\xac\xff\xd6\xff\x00\x00\r\x00\xff\xff\xf6\xff\x06\x00\x1e\x00 \x00\x08\x00\xf2\xff\xf0\xff\xf5\xff\xf1\xff\xed\xff\x03\x00.\x00R\x00[\x00S\x00Q\x00S\x00K\x000\x00\n\x00\xe9\xff\xca\xff\xae\xff\x9a\xff\x97\xff\xb5\xff\xee\xff+\x00H\x00<\x00\x1e\x00\n\x00\x0b\x00\x11\x00\x11\x00\x06\x00\xfc\xff\xf4\xff\xeb\xff\xe0\xff\xd8\xff\xd9\xff\xe5\xff\xf4\xff\xfc\xff\xfc\xff\xf7\xff\xf6\xff\xfd\xff\x05\x00\x06\x00\xfc\xff\xe9\xff\xd6\xff\xd0\xff\xdc\xff\xf2\xff\xff\xff\x00\x00\x07\x00$\x00I\x00_\x00W\x00?\x00$\x00\x08\x00\xec\xff\xd4\xff\xc2\xff\xb4\xff\xab\xff\xb1\xff\xcf\xff\xf4\xff\x0e\x00\x12\x00\x14\x00+\x00M\x00Y\x00=\x00\n\x00\xe7\xff\xdd\xff\xe0\xff\xe0\xff\xdf\xff\xeb\xff\x07\x00$\x00;\x00G\x00H\x00@\x00.\x00\x14\x00\xf4\xff\xd6\xff\xbb\xff\xa8\xff\xa3\xff\xb7\xff\xe7\xff!\x00M\x00U\x00G\x00:\x006\x00/\x00\x17\x00\xfb\xff\xf0\xff\xf2\xff\xf1\xff\xe2\xff\xcd\xff\xc3\xff\xc7\xff\xd4\xff\xe0\xff\xe5\xff\xe4\xff\xe4\xff\xea\xff\xf6\xff\xff\xff\xff\xff\xf8\xff\xeb\xff\xde\xff\xd6\xff\xdf\xff\xf5\xff\x0c\x00\x1f\x003\x00F\x00O\x00K\x00C\x00C\x00C\x003\x00\x0e\x00\xe6\xff\xc8\xff\xb5\xff\xa9\xff\xa9\xff\xb8\xff\xd1\xff\xeb\xff\xff\xff\x15\x000\x00E\x00=\x00\x17\x00\xeb\xff\xd9\xff\xdd\xff\xe1\xff\xe0\xff\xe8\xff\t\x005\x00U\x00^\x00\\\x00Z\x00V\x00E\x00+\x00\x08\x00\xde\xff\xb2\xff\x8f\xff\x89\xff\xa6\xff\xd4\xff\xfb\xff\x0e\x00\x11\x00\x11\x00\x1b\x00\'\x00/\x00#\x00\x04\x00\xde\xff\xc1\xff\xb5\xff\xbc\xff\xd2\xff\xee\xff\x04\x00\r\x00\x11\x00\x12\x00\x0e\x00\n\x00\x02\x00\xf7\xff\xea\xff\xe3\xff\xde\xff\xd6\xff\xc5\xff\xba\xff\xc4\xff\xde\xff\xf5\xff\x01\x00\x0b\x00\x1a\x000\x00E\x00M\x00A\x00$\x00\x02\x00\xe4\xff\xca\xff\xb8\xff\xb1\xff\xba\xff\xd5\xff\xfa\xff\x19\x00$\x00%\x000\x00E\x00O\x00?\x00\x16\x00\xf3\xff\xe5\xff\xea\xff\xed\xff\xf0\xff\xf9\xff\x05\x00\n\x00\x06\x00\x0b\x00 \x00<\x00I\x00=\x00!\x00\xfc\xff\xd4\xff\xb0\xff\x9b\xff\x9e\xff\xba\xff\xe3\xff\x0f\x00,\x006\x006\x00@\x00R\x00T\x007\x00\x06\x00\xd9\xff\xba\xff\xac\xff\xae\xff\xbb\xff\xcd\xff\xe0\xff\xf0\xff\xf8\xff\xf8\xff\xf7\xff\xfd\xff\x0c\x00\x16\x00\r\x00\xf4\xff\xd5\xff\xbf\xff\xbd\xff\xd1\xff\xf7\xff\x15\x00\x1e\x00\x1c\x00$\x00=\x00Y\x00_\x00K\x00\'\x00\x04\x00\xf1\xff\xe6\xff\xd6\xff\xc5\xff\xc4\xff\xe0\xff\n\x00%\x00%\x00\x1b\x00\x1f\x009\x00V\x00W\x007\x00\x0e\x00\xf5\xff\xf4\xff\xfa\xff\x00\x00\x04\x00\r\x00\x1d\x00*\x00(\x00\x1e\x00\x1b\x00(\x00=\x00G\x00@\x00 \x00\xee\xff\xbd\xff\xa7\xff\xb7\xff\xde\xff\x00\x00\n\x00\xfd\xff\xf3\xff\xfe\xff\x1b\x003\x00+\x00\x07\x00\xdf\xff\xc8\xff\xbc\xff\xaf\xff\x9e\xff\x97\xff\xa5\xff\xc8\xff\xf0\xff\x0b\x00\x12\x00\x15\x00\x1d\x00%\x00\x17\x00\xfb\xff\xde\xff\xc8\xff\xbf\xff\xc7\xff\xe4\xff\x06\x00\x0f\x00\xfd\xff\xeb\xff\xed\xff\x0b\x007\x00S\x00K\x00)\x00\x02\x00\xdd\xff\xbf\xff\xad\xff\xb0\xff\xcb\xff\xf6\xff\x1d\x00*\x00\x1d\x00\x0e\x00\x18\x00:\x00Y\x00Z\x009\x00\x13\x00\xfa\xff\xef\xff\xea\xff\xf1\xff\x08\x00(\x005\x00\'\x00\x0f\x00\x08\x00\x18\x00,\x002\x00+\x00\x1c\x00\x05\x00\xe5\xff\xc9\xff\xbd\xff\xc9\xff\xe5\xff\xff\xff\x0c\x00\r\x00\x07\x00\x07\x00\x10\x00\x1c\x00\x1e\x00\x13\x00\xfc\xff\xda\xff\xb7\xff\xa4\xff\xab\xff\xbf\xff\xd2\xff\xdd\xff\xe0\xff\xdf\xff\xe6\xff\xfa\xff\x0c\x00\x06\x00\xea\xff\xcf\xff\xc9\xff\xd2\xff\xda\xff\xdb\xff\xdf\xff\xe7\xff\xee\xff\xed\xff\xef\xff\xf8\xff\x0e\x00,\x00C\x00F\x00.\x00\x06\x00\xdb\xff\xbb\xff\xb1\xff\xc1\xff\xe8\xff\x0c\x00\x1a\x00\x13\x00\n\x00\x16\x00<\x00f\x00w\x00^\x00/\x00\x06\x00\xf0\xff\xe1\xff\xda\xff\xe6\xff\x08\x00/\x00:\x00)\x00\x13\x00\r\x00\x15\x00!\x00(\x00(\x00\x1b\x00\xfe\xff\xdd\xff\xcc\xff\xd7\xff\xf7\xff\x13\x00\x1e\x00\x19\x00\x15\x00\x1d\x004\x00?\x001\x00\n\x00\xe3\xff\xcc\xff\xc2\xff\xb9\xff\xb4\xff\xbb\xff\xce\xff\xe8\xff\xfd\xff\x07\x00\x01\x00\xf2\xff\xea\xff\xed\xff\xf1\xff\xf3\xff\xf0\xff\xea\xff\xe7\xff\xed\xff\xfb\xff\t\x00\n\x00\xf8\xff\xdf\xff\xca\xff\xcb\xff\xec\xff\x1b\x008\x00.\x00\x08\x00\xe0\xff\xc7\xff\xc1\xff\xc6\xff\xcf\xff\xde\xff\xf3\xff\x0c\x00#\x004\x00@\x00I\x00N\x00N\x00E\x000\x00\x14\x00\xf7\xff\xe6\xff\xee\xff\x0b\x00%\x00,\x00 \x00\x19\x00!\x001\x009\x00.\x00\x1d\x00\x14\x00\x0f\x00\xff\xff\xe1\xff\xcd\xff\xd4\xff\xf6\xff\x16\x00"\x00\x1c\x00\x19\x00!\x00*\x00(\x00\x0f\x00\xee\xff\xd5\xff\xce\xff\xcf\xff\xd4\xff\xde\xff\xee\xff\t\x00\x1e\x00#\x00\x17\x00\x0c\x00\x0f\x00\x1a\x00\x13\x00\xf6\xff\xd2\xff\xbf\xff\xc7\xff\xdb\xff\xec\xff\xfa\xff\x05\x00\x05\x00\xf9\xff\xe7\xff\xdc\xff\xd7\xff\xd9\xff\xe2\xff\xed\xff\xf3\xff\xf4\xff\xf1\xff\xef\xff\xf2\xff\xf7\xff\xf9\xff\xf9\xff\x00\x00\x0c\x00\x12\x00\t\x00\x03\x00\x0e\x00$\x00+\x00\x1f\x00\x0b\x00\xfc\xff\xf2\xff\xe9\xff\xe0\xff\xd6\xff\xda\xff\xf0\xff\x0f\x00-\x00;\x002\x00\x1c\x00\x0b\x00\x08\x00\x0c\x00\x08\x00\xf5\xff\xe2\xff\xe8\xff\t\x00\'\x00,\x00\x1c\x00\x0f\x00\x12\x00"\x00/\x00#\x00\x04\x00\xe5\xff\xd8\xff\xd4\xff\xcc\xff\xc1\xff\xc5\xff\xd9\xff\xf3\xff\x07\x00\x12\x00\x19\x00!\x00"\x00\x15\x00\xfb\xff\xe0\xff\xd3\xff\xd3\xff\xdc\xff\xf0\xff\x0f\x00+\x008\x001\x00\x1c\x00\t\x00\xfb\xff\xf1\xff\xed\xff\xeb\xff\xe6\xff\xe3\xff\xe0\xff\xe3\xff\xe7\xff\xeb\xff\xea\xff\xea\xff\xf6\xff\x0e\x00\x1c\x00\x16\x00\x03\x00\xf6\xff\xf9\xff\x08\x00\x18\x00!\x00#\x00\x1f\x00\x11\x00\xfa\xff\xe2\xff\xd6\xff\xdd\xff\xf3\xff\t\x00\x13\x00\x17\x00\x19\x00\x17\x00\x0e\x00\xfd\xff\xeb\xff\xdc\xff\xd6\xff\xd7\xff\xdf\xff\xe8\xff\xeb\xff\xf1\xff\x05\x00&\x00>\x00?\x00*\x00\x0e\x00\xf7\xff\xe5\xff\xd0\xff\xb9\xff\xae\xff\xc1\xff\xef\xff\x1e\x005\x005\x002\x006\x00>\x009\x00#\x00\x04\x00\xeb\xff\xe0\xff\xe4\xff\xf1\xff\xfc\xff\x06\x00\x12\x00\x1a\x00\x18\x00\x0b\x00\xfe\xff\xf5\xff\xef\xff\xe4\xff\xd5\xff\xcd\xff\xd7\xff\xef\xff\t\x00\x14\x00\x14\x00\x14\x00\x18\x00!\x00)\x00.\x002\x002\x001\x000\x00)\x00\x1c\x00\x0b\x00\xfa\xff\xec\xff\xe3\xff\xe2\xff\xe5\xff\xe9\xff\xeb\xff\xf3\xff\xfb\xff\x03\x00\x07\x00\x02\x00\xfc\xff\xfb\xff\x01\x00\x06\x00\x00\x00\xf2\xff\xe7\xff\xe6\xff\xef\xff\xfd\xff\x0c\x00\x16\x00\x19\x00\x16\x00\x16\x00\x14\x00\x00\x00\xd9\xff\xb1\xff\x98\xff\x98\xff\xad\xff\xcf\xff\xf0\xff\x02\x00\n\x00\x15\x00 \x00$\x00\x1d\x00\x11\x00\x05\x00\xfd\xff\xf5\xff\xeb\xff\xe3\xff\xe6\xff\xfb\xff\x16\x00)\x002\x00,\x00\x1b\x00\n\x00\x02\x00\xfe\xff\xf7\xff\xec\xff\xe3\xff\xe6\xff\xf1\xff\x00\x00\t\x00\x06\x00\xff\xff\x05\x00\x13\x00\x1f\x00!\x00\x1c\x00\x1a\x00\x1b\x00\x17\x00\x06\x00\xf5\xff\xef\xff\xf5\xff\x01\x00\n\x00\x0e\x00\n\x00\xfb\xff\xea\xff\xeb\xff\x04\x00%\x006\x001\x00!\x00\x0f\x00\xfb\xff\xe4\xff\xd5\xff\xdb\xff\xf4\xff\n\x00\r\x00\x07\x00\t\x00\x15\x00!\x00\x1c\x00\n\x00\xf0\xff\xdd\xff\xd7\xff\xd7\xff\xd4\xff\xcb\xff\xc9\xff\xd4\xff\xec\xff\x03\x00\x0c\x00\x0e\x00\r\x00\r\x00\x0e\x00\r\x00\t\x00\x00\x00\xf1\xff\xde\xff\xd4\xff\xde\xff\xf6\xff\x0c\x00\x10\x00\x07\x00\xff\xff\x02\x00\r\x00\x12\x00\x08\x00\xf2\xff\xe0\xff\xde\xff\xe6\xff\xee\xff\xf7\xff\x01\x00\x0f\x00\x1f\x00/\x00B\x00I\x00>\x00%\x00\x16\x00\x1b\x00\'\x00(\x00\x11\x00\xf9\xff\xef\xff\xf6\xff\xf9\xff\xee\xff\xdc\xff\xd3\xff\xda\xff\xee\xff\x07\x00\x17\x00\x18\x00\x05\x00\xf2\xff\xe7\xff\xe6\xff\xe7\xff\xee\xff\x02\x00\x1b\x00$\x00\x1c\x00\x0f\x00\x10\x00$\x009\x00>\x000\x00\x12\x00\xf2\xff\xd6\xff\xbe\xff\xaf\xff\xb7\xff\xd8\xff\x00\x00\x18\x00\x12\x00\xfd\xff\xf3\xff\xfb\xff\x06\x00\x08\x00\xff\xff\xf5\xff\xf2\xff\xf3\xff\xf3\xff\xef\xff\xea\xff\xe6\xff\xe8\xff\xf1\xff\xfd\xff\x03\x00\x04\x00\x05\x00\xff\xff\xf1\xff\xe4\xff\xde\xff\xdf\xff\xdd\xff\xdb\xff\xe0\xff\xf0\xff\x03\x00\x0f\x00\x15\x00\x18\x00\x1c\x00\x1f\x00$\x00*\x00*\x00\x18\x00\xfa\xff\xe0\xff\xdb\xff\xe3\xff\xea\xff\xea\xff\xe9\xff\xef\xff\xfc\xff\x0c\x00\x1c\x00\'\x00"\x00\x11\x00\x01\x00\xfe\xff\x03\x00\x04\x00\xff\xff\x00\x00\n\x00\x12\x00\x0f\x00\x0b\x00\x12\x00!\x00,\x00,\x00$\x00\x13\x00\xfb\xff\xe0\xff\xc8\xff\xba\xff\xc2\xff\xe1\xff\x0b\x00!\x00\x18\x00\x05\x00\x07\x00"\x00>\x00@\x00,\x00\x17\x00\x12\x00\x0e\x00\xff\xff\xe8\xff\xd9\xff\xe2\xff\xfb\xff\x15\x00\x1d\x00\x12\x00\xfe\xff\xed\xff\xe8\xff\xea\xff\xe9\xff\xe2\xff\xd7\xff\xd5\xff\xde\xff\xed\xff\xf8\xff\xfb\xff\xf8\xff\xf9\xff\x06\x00\x16\x00\x1d\x00\x1d\x00 \x00#\x00\x19\x00\x05\x00\xee\xff\xe1\xff\xdd\xff\xdc\xff\xda\xff\xdc\xff\xe0\xff\xe6\xff\xe7\xff\xf1\xff\t\x00 \x00!\x00\r\x00\xf2\xff\xe1\xff\xd9\xff\xd6\xff\xe0\xff\xf4\xff\t\x00\x18\x00#\x00.\x006\x007\x000\x00)\x00!\x00\x13\x00\xff\xff\xe9\xff\xdb\xff\xdb\xff\xf0\xff\x0c\x00\x1c\x00\x16\x00\x08\x00\x08\x00\x16\x00%\x00#\x00\x17\x00\x0b\x00\t\x00\n\x00\x02\x00\xf2\xff\xe9\xff\xf1\xff\x03\x00\x0e\x00\n\x00\x00\x00\xff\xff\x07\x00\x11\x00\x11\x00\x01\x00\xf0\xff\xed\xff\xf1\xff\xe7\xff\xd1\xff\xc3\xff\xd1\xff\xf3\xff\x10\x00\x1b\x00\x1c\x00\x1a\x00\x15\x00\x0f\x00\x0b\x00\x03\x00\xf7\xff\xe8\xff\xe1\xff\xe9\xff\xf5\xff\xf7\xff\xee\xff\xe0\xff\xda\xff\xd8\xff\xd9\xff\xe9\xff\x04\x00\x1c\x00\x1e\x00\x0b\x00\xf4\xff\xe5\xff\xdb\xff\xd5\xff\xd5\xff\xde\xff\xf0\xff\x06\x00\x16\x00\x1c\x00\x1e\x00!\x00)\x00*\x00\x1b\x00\x01\x00\xe4\xff\xcf\xff\xc7\xff\xd1\xff\xe9\xff\x04\x00\x16\x00\x1b\x00\x1b\x00$\x001\x003\x00(\x00\x1c\x00\x1b\x00\x1e\x00\x18\x00\x06\x00\xf3\xff\xf1\xff\xfa\xff\x00\x00\x03\x00\t\x00\x14\x00\x18\x00\x13\x00\t\x00\x01\x00\xf9\xff\xf0\xff\xe4\xff\xd9\xff\xd1\xff\xd4\xff\xe9\xff\t\x00\x1c\x00\x19\x00\x11\x00\x12\x00\x1f\x00*\x00)\x00\x1e\x00\x0e\x00\x06\x00\x07\x00\x03\x00\xed\xff\xd1\xff\xc8\xff\xd7\xff\xf6\xff\x02\x00\xf8\xff\xe6\xff\xe7\xff\xf9\xff\x0c\x00\t\x00\xf5\xff\xe1\xff\xdd\xff\xe9\xff\xf4\xff\xf7\xff\xf4\xff\xf3\xff\xfc\xff\x0c\x00\x16\x00\x1c\x00#\x00,\x00+\x00\x1c\x00\x03\x00\xeb\xff\xd6\xff\xc8\xff\xc7\xff\xd9\xff\xf4\xff\x06\x00\n\x00\x0c\x00\x12\x00\x1c\x00$\x00&\x00$\x00\x1f\x00\n\x00\xe8\xff\xd0\xff\xd6\xff\xee\xff\xfa\xff\xf4\xff\xf3\xff\x07\x00&\x001\x00!\x00\x07\x00\xf8\xff\xf6\xff\xf7\xff\xef\xff\xe1\xff\xda\xff\xe0\xff\xf4\xff\t\x00\x11\x00\x11\x00\x17\x00#\x00.\x00.\x00*\x00%\x00\x19\x00\x03\x00\xe7\xff\xd3\xff\xd1\xff\xe2\xff\xfd\xff\x0e\x00\n\x00\xfa\xff\xec\xff\xed\xff\x00\x00\x13\x00\x10\x00\xff\xff\xf2\xff\xf6\xff\xfb\xff\xee\xff\xd6\xff\xd0\xff\xe4\xff\x06\x00\x1f\x00\'\x00$\x00 \x00\x1f\x00\x1f\x00\x17\x00\x06\x00\xf6\xff\xeb\xff\xe9\xff\xe7\xff\xe7\xff\xed\xff\xfb\xff\x0b\x00\r\x00\x08\x00\x08\x00\x13\x00"\x00(\x00!\x00\n\x00\xf2\xff\xdf\xff\xd9\xff\xde\xff\xe2\xff\xe2\xff\xe5\xff\xed\xff\xfe\xff\r\x00\x13\x00\x17\x00\x18\x00\x16\x00\x0b\x00\xf5\xff\xd6\xff\xbd\xff\xbc\xff\xd8\xff\xf4\xff\xf9\xff\xf6\xff\x06\x00/\x00O\x00J\x00/\x00\x18\x00\x11\x00\t\x00\xf1\xff\xd5\xff\xd0\xff\xe6\xff\t\x00\x1f\x00\x1f\x00\x11\x00\xfe\xff\xf6\xff\xff\xff\x13\x00\x1e\x00\x17\x00\x03\x00\xf0\xff\xe4\xff\xdd\xff\xd6\xff\xd8\xff\xe6\xff\xfe\xff\x13\x00\x1f\x00!\x00 \x00!\x00%\x00!\x00\x14\x00\x06\x00\xfe\xff\xf9\xff\xed\xff\xdd\xff\xdc\xff\xf3\xff\x15\x00+\x00\'\x00\x1b\x00\x17\x00\x1c\x00\x18\x00\n\x00\xfa\xff\xf1\xff\xf0\xff\xef\xff\xf0\xff\xf0\xff\xea\xff\xe2\xff\xe2\xff\xee\xff\xf9\xff\xfd\xff\x00\x00\x05\x00\x0c\x00\x0b\x00\x01\x00\xee\xff\xd7\xff\xc3\xff\xbf\xff\xcb\xff\xdd\xff\xed\xff\xff\xff\x14\x00#\x00)\x00\'\x00*\x002\x00+\x00\x0c\x00\xdf\xff\xc1\xff\xc5\xff\xde\xff\xf6\xff\xfd\xff\xfe\xff\x01\x00\x0b\x00\x15\x00\x13\x00\x0c\x00\x08\x00\x08\x00\x06\x00\xfa\xff\xe8\xff\xdb\xff\xdc\xff\xef\xff\x06\x00\x17\x00\x1d\x00\x1e\x00 \x00#\x00)\x00+\x00\'\x00 \x00\x14\x00\x02\x00\xe6\xff\xcf\xff\xc7\xff\xd6\xff\xf6\xff\x12\x00$\x00*\x00+\x00,\x00-\x00%\x00\x15\x00\xfe\xff\xec\xff\xe3\xff\xe0\xff\xe6\xff\xf3\xff\x02\x00\x0b\x00\n\x00\x05\x00\x01\x00\xff\xff\xff\xff\xfb\xff\xf0\xff\xe4\xff\xe0\xff\xe2\xff\xdf\xff\xd5\xff\xd3\xff\xe1\xff\xf3\xff\xfc\xff\xfe\xff\xfc\xff\xfd\xff\x07\x00\x17\x00(\x00-\x00\x1e\x00\xfe\xff\xde\xff\xcb\xff\xcb\xff\xd7\xff\xe5\xff\xf8\xff\x0b\x00\x17\x00\x11\x00\x06\x00\x04\x00\r\x00\x19\x00\x19\x00\r\x00\xfc\xff\xe7\xff\xd9\xff\xda\xff\xe5\xff\xf6\xff\x03\x00\x0e\x00\x15\x00\x19\x00\x19\x00\x1a\x00\x1f\x00$\x00\x1e\x00\x0c\x00\xf4\xff\xe0\xff\xda\xff\xe1\xff\xf5\xff\x10\x00(\x006\x00:\x009\x003\x00)\x00\x1a\x00\n\x00\xf6\xff\xe3\xff\xd5\xff\xd2\xff\xdc\xff\xec\xff\xf8\xff\x02\x00\n\x00\n\x00\x04\x00\xf9\xff\xf5\xff\xf9\xff\xfc\xff\xfb\xff\xf1\xff\xdf\xff\xcb\xff\xc5\xff\xdb\xff\x01\x00\x1d\x00\x1e\x00\r\x00\x03\x00\x02\x00\x07\x00\x07\x00\x02\x00\xfc\xff\xf9\xff\xfa\xff\xf6\xff\xec\xff\xe6\xff\xea\xff\xf7\xff\t\x00\x13\x00\x0f\x00\x04\x00\x00\x00\x08\x00\x18\x00#\x00\x1b\x00\x07\x00\xee\xff\xdb\xff\xdb\xff\xe2\xff\xed\xff\xf8\xff\x05\x00\x10\x00\x17\x00\x17\x00\x17\x00\x1a\x00\x1f\x00\x1a\x00\x0f\x00\x02\x00\xf7\xff\xf2\xff\xf3\xff\xfd\xff\n\x00\x14\x00\x16\x00\x18\x00\x1c\x00!\x00"\x00\x1c\x00\x14\x00\n\x00\xfa\xff\xe7\xff\xd7\xff\xd2\xff\xd8\xff\xe3\xff\xf3\xff\x03\x00\x0e\x00\r\x00\x07\x00\x04\x00\x05\x00\x02\x00\xf8\xff\xee\xff\xe5\xff\xdd\xff\xd5\xff\xdb\xff\xee\xff\x00\x00\x08\x00\x08\x00\x0b\x00\x0e\x00\r\x00\x08\x00\x06\x00\x0b\x00\x10\x00\x0b\x00\xfe\xff\xf1\xff\xea\xff\xee\xff\xfd\xff\x0f\x00\x1f\x00"\x00\x18\x00\t\x00\x02\x00\x04\x00\x05\x00\x02\x00\x04\x00\n\x00\n\x00\xff\xff\xef\xff\xea\xff\xf3\xff\x00\x00\x06\x00\x03\x00\xfd\xff\x02\x00\r\x00\x16\x00\x19\x00\x17\x00\x14\x00\r\x00\x01\x00\xf5\xff\xee\xff\xef\xff\xf4\xff\x00\x00\x0b\x00\x12\x00\x16\x00\x19\x00\x1c\x00\x1e\x00\x1b\x00\x0b\x00\xf4\xff\xdc\xff\xce\xff\xcf\xff\xdd\xff\xee\xff\xf9\xff\xfb\xff\xf4\xff\xf1\xff\xf4\xff\xfc\xff\xfd\xff\xf8\xff\xf4\xff\xf2\xff\xed\xff\xe0\xff\xd7\xff\xd8\xff\xe8\xff\xfa\xff\x04\x00\t\x00\x08\x00\x04\x00\xfd\xff\xfd\xff\x07\x00\x0f\x00\x0c\x00\x00\x00\xee\xff\xe3\xff\xe5\xff\xf3\xff\x04\x00\x13\x00\x1d\x00!\x00\x1e\x00\x18\x00\x12\x00\x10\x00\x11\x00\x14\x00\x17\x00\x13\x00\t\x00\x00\x00\xff\xff\x08\x00\x0e\x00\r\x00\x03\x00\xfa\xff\xf8\xff\xff\xff\n\x00\x0f\x00\x10\x00\x0f\x00\x11\x00\x11\x00\t\x00\xfa\xff\xee\xff\xf1\xff\x01\x00\x12\x00\x19\x00\x17\x00\x11\x00\x12\x00\x11\x00\t\x00\xfd\xff\xf0\xff\xe6\xff\xdf\xff\xdb\xff\xdb\xff\xde\xff\xe2\xff\xe7\xff\xf2\xff\xfc\xff\x00\x00\xfe\xff\xfd\xff\x00\x00\x03\x00\x02\x00\xfb\xff\xed\xff\xdf\xff\xdb\xff\xe4\xff\xf9\xff\x08\x00\x0c\x00\x04\x00\xf9\xff\xf5\xff\xfb\xff\x02\x00\x08\x00\x07\x00\x01\x00\xf7\xff\xeb\xff\xe8\xff\xec\xff\xf6\xff\x05\x00\x14\x00 \x00#\x00\x1b\x00\r\x00\x02\x00\x03\x00\x0b\x00\x13\x00\x12\x00\x08\x00\xfa\xff\xf3\xff\xf4\xff\xfa\xff\xff\xff\x04\x00\x0b\x00\x16\x00!\x00(\x00"\x00\x14\x00\x0e\x00\x11\x00\x12\x00\x0c\x00\xfd\xff\xf6\xff\xfe\xff\x13\x00#\x00\x1f\x00\x0e\x00\xff\xff\xfe\xff\x04\x00\x02\x00\xf6\xff\xec\xff\xe7\xff\xe5\xff\xe1\xff\xdd\xff\xe1\xff\xed\xff\xfd\xff\x08\x00\x0b\x00\x04\x00\xff\xff\xfd\xff\xfd\xff\xf8\xff\xf0\xff\xec\xff\xed\xff\xf0\xff\xee\xff\xe8\xff\xe8\xff\xf0\xff\xf7\xff\xfa\xff\xf9\xff\xf7\xff\xf6\xff\xf6\xff\xfb\xff\x02\x00\x06\x00\x02\x00\xfa\xff\xf7\xff\xfa\xff\xfd\xff\xff\xff\x04\x00\x10\x00\x1c\x00\x1d\x00\x12\x00\x06\x00\x05\x00\x0c\x00\x14\x00\x12\x00\x07\x00\xfa\xff\xee\xff\xe9\xff\xe7\xff\xe6\xff\xec\xff\xfc\xff\x12\x00#\x00&\x00\x1c\x00\r\x00\x05\x00\x03\x00\x03\x00\x02\x00\xfe\xff\xfb\xff\xfb\xff\xff\xff\x08\x00\x0f\x00\x14\x00\x18\x00\x1d\x00\x1e\x00\x16\x00\x08\x00\xfb\xff\xf5\xff\xf3\xff\xea\xff\xdb\xff\xd9\xff\xed\xff\t\x00\x1a\x00\x16\x00\t\x00\x04\x00\x07\x00\x0b\x00\x05\x00\xf9\xff\xf2\xff\xf3\xff\xf5\xff\xf0\xff\xe7\xff\xe3\xff\xee\xff\x00\x00\x0e\x00\x10\x00\x06\x00\xf9\xff\xf1\xff\xf0\xff\xf6\xff\xfa\xff\xfc\xff\xfb\xff\xfc\xff\xfa\xff\xf4\xff\xed\xff\xe9\xff\xf5\xff\x0c\x00\x1b\x00\x1c\x00\x13\x00\x0b\x00\t\x00\x07\x00\x05\x00\x03\x00\x06\x00\x05\x00\xf9\xff\xe9\xff\xe1\xff\xe9\xff\xfc\xff\x0e\x00\x16\x00\x17\x00\x15\x00\x17\x00\x1a\x00\x16\x00\x0b\x00\xfe\xff\xf6\xff\xf2\xff\xf1\xff\xef\xff\xeb\xff\xef\xff\xfb\xff\x11\x00$\x00&\x00\x18\x00\x03\x00\xf4\xff\xee\xff\xeb\xff\xe1\xff\xd6\xff\xd4\xff\xe1\xff\xf6\xff\x08\x00\x10\x00\x19\x00\x1e\x00#\x00"\x00\x1b\x00\x0f\x00\xff\xff\xf4\xff\xf0\xff\xee\xff\xea\xff\xe7\xff\xef\xff\x01\x00\x0f\x00\x10\x00\x07\x00\xfe\xff\xfc\xff\xfc\xff\xfa\xff\xf6\xff\xf8\xff\xfe\xff\x04\x00\xff\xff\xf2\xff\xe8\xff\xe7\xff\xf6\xff\x0c\x00\x1f\x00\'\x00$\x00\x1b\x00\x0f\x00\x04\x00\xfb\xff\xf7\xff\xf6\xff\xf6\xff\xf2\xff\xe9\xff\xe5\xff\xe8\xff\xee\xff\xf9\xff\x04\x00\x0f\x00\x17\x00\x19\x00\x15\x00\x0e\x00\x04\x00\xfc\xff\xfb\xff\xfb\xff\xf6\xff\xe9\xff\xe1\xff\xec\xff\x04\x00\x1e\x00%\x00\x1e\x00\x12\x00\r\x00\x0b\x00\x04\x00\xf8\xff\xe8\xff\xdd\xff\xdc\xff\xe4\xff\xef\xff\xf7\xff\xfc\xff\x04\x00\x13\x00 \x00#\x00\x18\x00\x08\x00\xfd\xff\xf6\xff\xee\xff\xe3\xff\xdc\xff\xde\xff\xe9\xff\xf7\xff\x03\x00\x0c\x00\x0e\x00\x13\x00\x18\x00\x1a\x00\x14\x00\n\x00\x04\x00\x00\x00\xfd\xff\xf9\xff\xf5\xff\xf5\xff\xfb\xff\x03\x00\x0b\x00\x10\x00\x13\x00\x14\x00\x13\x00\x0e\x00\x06\x00\xfb\xff\xf3\xff\xf3\xff\xf6\xff\xf3\xff\xea\xff\xe4\xff\xe3\xff\xe9\xff\xf2\xff\xfe\xff\x10\x00"\x00.\x00.\x00!\x00\x0e\x00\xfc\xff\xf2\xff\xee\xff\xec\xff\xe8\xff\xe9\xff\xf0\xff\xf9\xff\x06\x00\r\x00\x14\x00\x19\x00\x1a\x00\x13\x00\x02\x00\xf0\xff\xe6\xff\xe0\xff\xe0\xff\xe1\xff\xe4\xff\xed\xff\xf6\xff\x01\x00\r\x00\x18\x00 \x00$\x00\x1f\x00\x14\x00\x06\x00\xf7\xff\xe8\xff\xe0\xff\xe3\xff\xec\xff\xf6\xff\xfd\xff\x00\x00\x04\x00\x0c\x00\x13\x00\x16\x00\x11\x00\x06\x00\x00\x00\xfe\xff\x00\x00\xfd\xff\xf7\xff\xf4\xff\xfa\xff\x01\x00\x03\x00\x04\x00\x06\x00\x0c\x00\x13\x00\x17\x00\x18\x00\x17\x00\x0e\x00\xfe\xff\xf1\xff\xec\xff\xed\xff\xec\xff\xe8\xff\xe7\xff\xec\xff\xf6\xff\x02\x00\x11\x00\x1c\x00\x1f\x00\x1b\x00\x14\x00\x0f\x00\n\x00\xff\xff\xf3\xff\xeb\xff\xed\xff\xf4\xff\xf9\xff\xfb\xff\xfd\xff\x05\x00\x0e\x00\x19\x00\x1a\x00\x13\x00\x07\x00\xf5\xff\xe9\xff\xe1\xff\xdf\xff\xe4\xff\xec\xff\xf2\xff\xf6\xff\xf7\xff\xff\xff\x0e\x00\x1f\x00%\x00\x1e\x00\x0f\x00\xfe\xff\xef\xff\xe2\xff\xdc\xff\xdf\xff\xe7\xff\xef\xff\xf6\xff\xf9\xff\xfc\xff\xff\xff\x05\x00\x0e\x00\x13\x00\x12\x00\x0e\x00\x0b\x00\t\x00\x05\x00\xfc\xff\xf5\xff\xf7\xff\xfb\xff\xfa\xff\xfb\xff\x02\x00\x10\x00\x1a\x00\x1b\x00\x11\x00\xfd\xff\xe9\xff\xda\xff\xd8\xff\xe4\xff\xf1\xff\xf8\xff\xf7\xff\xf2\xff\xf2\xff\xfa\xff\x06\x00\x13\x00 \x00%\x00&\x00"\x00\x1c\x00\x13\x00\x0b\x00\x04\x00\x00\x00\xfe\xff\xfc\xff\xfe\xff\x03\x00\x08\x00\x06\x00\x05\x00\x05\x00\x0c\x00\x11\x00\x0e\x00\x03\x00\xf2\xff\xe4\xff\xe1\xff\xe9\xff\xf3\xff\xf4\xff\xf1\xff\xf5\xff\x08\x00\x1f\x00*\x00$\x00\x15\x00\n\x00\x00\x00\xf5\xff\xec\xff\xe6\xff\xe8\xff\xed\xff\xf3\xff\xf9\xff\x01\x00\x07\x00\n\x00\n\x00\n\x00\t\x00\x0b\x00\x0b\x00\x0b\x00\x01\x00\xf0\xff\xe4\xff\xe4\xff\xed\xff\xf2\xff\xed\xff\xe8\xff\xee\xff\x00\x00\x10\x00\x12\x00\x07\x00\xf6\xff\xe8\xff\xe6\xff\xea\xff\xf2\xff\xf4\xff\xf3\xff\xf1\xff\xf3\xff\xfa\xff\x04\x00\x0f\x00\x17\x00\x1e\x00\x1d\x00\x17\x00\r\x00\x02\x00\xfa\xff\xf7\xff\xfc\xff\x06\x00\n\x00\x05\x00\xfc\xff\xf6\xff\xf9\xff\x01\x00\x0b\x00\x17\x00 \x00\x1f\x00\x14\x00\x08\x00\x03\x00\x04\x00\x02\x00\xfb\xff\xf7\xff\xf9\xff\x04\x00\x0f\x00\x15\x00\x13\x00\x0f\x00\x10\x00\x12\x00\x14\x00\x0c\x00\xfc\xff\xee\xff\xeb\xff\xef\xff\xf0\xff\xec\xff\xea\xff\xf0\xff\x00\x00\x0f\x00\x18\x00\x17\x00\x11\x00\x0f\x00\x13\x00\x13\x00\x08\x00\xf3\xff\xe5\xff\xe8\xff\xef\xff\xf3\xff\xef\xff\xf1\xff\xf8\xff\x00\x00\xff\xff\xfc\xff\xf8\xff\xf3\xff\xea\xff\xe3\xff\xe1\xff\xe5\xff\xe9\xff\xe8\xff\xe7\xff\xe5\xff\xe6\xff\xf2\xff\x03\x00\x16\x00\x1e\x00\x17\x00\x0c\x00\x06\x00\x07\x00\x06\x00\x03\x00\xfc\xff\xf6\xff\xf8\xff\xfb\xff\x02\x00\x06\x00\x04\x00\x02\x00\x07\x00\x0f\x00\x14\x00\x10\x00\x06\x00\x00\x00\xff\xff\x04\x00\x0c\x00\x10\x00\x0c\x00\x04\x00\x02\x00\x08\x00\x13\x00\x1a\x00\x19\x00\x18\x00\x1a\x00\x1a\x00\x12\x00\x04\x00\xfc\xff\xfa\xff\xf5\xff\xef\xff\xec\xff\xf7\xff\x06\x00\x0c\x00\x03\x00\xfb\xff\xfc\xff\t\x00\x16\x00\x1b\x00\x13\x00\x03\x00\xf5\xff\xef\xff\xf0\xff\xf0\xff\xec\xff\xe9\xff\xef\xff\xfb\xff\x03\x00\x02\x00\xfb\xff\xf8\xff\xf6\xff\xf3\xff\xec\xff\xe7\xff\xe7\xff\xec\xff\xf0\xff\xf1\xff\xf2\xff\xf4\xff\xfe\xff\x08\x00\x0f\x00\r\x00\x08\x00\x08\x00\x05\x00\x01\x00\xfa\xff\xf3\xff\xf0\xff\xed\xff\xed\xff\xee\xff\xed\xff\xed\xff\xf2\xff\xfa\xff\x08\x00\x0f\x00\x0f\x00\n\x00\x06\x00\t\x00\x0c\x00\t\x00\x05\x00\x04\x00\x05\x00\n\x00\x10\x00\x19\x00\x1c\x00\x18\x00\x16\x00\x13\x00\x12\x00\x0b\x00\x00\x00\xf8\xff\xf9\xff\xfe\xff\x01\x00\xff\xff\x01\x00\x05\x00\x08\x00\t\x00\x08\x00\x07\x00\n\x00\x0c\x00\x0f\x00\x12\x00\x11\x00\t\x00\x00\x00\xfa\xff\xf8\xff\xf5\xff\xf0\xff\xf1\xff\xf5\xff\xf6\xff\xf0\xff\xee\xff\xf3\xff\xfa\xff\xfb\xff\xf3\xff\xed\xff\xed\xff\xf4\xff\xf9\xff\xfa\xff\xf7\xff\xf8\xff\xfc\xff\x03\x00\x07\x00\x05\x00\x02\x00\x03\x00\x05\x00\x06\x00\x02\x00\x00\x00\x02\x00\x05\x00\x02\x00\xf9\xff\xf3\xff\xf3\xff\xf4\xff\xf5\xff\xf7\xff\xff\xff\t\x00\x10\x00\x11\x00\r\x00\x03\x00\xfa\xff\xf4\xff\xf9\xff\x01\x00\x05\x00\x02\x00\xfd\xff\x01\x00\t\x00\r\x00\n\x00\x04\x00\xfc\xff\xf7\xff\xf6\xff\xf9\xff\xfe\xff\x01\x00\x01\x00\x02\x00\x07\x00\r\x00\x11\x00\x10\x00\x0e\x00\t\x00\x04\x00\x01\x00\x04\x00\x07\x00\t\x00\x07\x00\x06\x00\x06\x00\x05\x00\x01\x00\xfc\xff\xfb\xff\xfa\xff\xf7\xff\xf3\xff\xf3\xff\xf2\xff\xed\xff\xe7\xff\xe9\xff\xf7\xff\x06\x00\x0e\x00\x0b\x00\x02\x00\xfd\xff\xfb\xff\x00\x00\x02\x00\x00\x00\xfc\xff\xfd\xff\x03\x00\x05\x00\x00\x00\xfc\xff\xfd\xff\x02\x00\x03\x00\x00\x00\xfc\xff\xfc\xff\xf9\xff\xf6\xff\xf3\xff\xf3\xff\xfc\xff\x08\x00\x0e\x00\x0e\x00\x08\x00\x02\x00\x02\x00\x0b\x00\x13\x00\x13\x00\x0e\x00\t\x00\x0c\x00\x0f\x00\x0c\x00\x04\x00\xfc\xff\xfd\xff\x00\x00\xfe\xff\xf8\xff\xf1\xff\xee\xff\xf3\xff\xfb\xff\x02\x00\x02\x00\xfb\xff\xf7\xff\xf6\xff\xf9\xff\xfb\xff\xf9\xff\xf8\xff\xfb\xff\xff\xff\x03\x00\x02\x00\x02\x00\x03\x00\x00\x00\xfc\xff\xfb\xff\x01\x00\x04\x00\x01\x00\xf8\xff\xf0\xff\xea\xff\xe6\xff\xe9\xff\xf3\xff\x05\x00\x12\x00\x15\x00\x12\x00\r\x00\n\x00\x06\x00\xfc\xff\xf8\xff\xf9\xff\x03\x00\x0b\x00\x08\x00\xfc\xff\xf2\xff\xf7\xff\t\x00\x1a\x00\x1a\x00\n\x00\xfa\xff\xf1\xff\xf0\xff\xf2\xff\xf3\xff\xf7\xff\x01\x00\x0c\x00\x12\x00\x11\x00\x0b\x00\x06\x00\x02\x00\x04\x00\x0b\x00\x10\x00\x11\x00\x12\x00\x11\x00\x0b\x00\x05\x00\x00\x00\x01\x00\x02\x00\x01\x00\xfd\xff\xfe\xff\x03\x00\x0b\x00\x0f\x00\x0e\x00\x06\x00\x03\x00\x04\x00\t\x00\n\x00\x02\x00\xf4\xff\xed\xff\xf2\xff\xfc\xff\xff\xff\xf8\xff\xf3\xff\xf5\xff\xfe\xff\x01\x00\xfc\xff\xf4\xff\xeb\xff\xe6\xff\xe4\xff\xe5\xff\xe5\xff\xe1\xff\xde\xff\xe2\xff\xf2\xff\x05\x00\r\x00\x0b\x00\t\x00\n\x00\n\x00\x05\x00\x00\x00\xfc\xff\xfb\xff\xfd\xff\xff\xff\xfe\xff\xf9\xff\xfc\xff\x05\x00\x11\x00\x16\x00\x13\x00\x0c\x00\x06\x00\x00\x00\xf5\xff\xeb\xff\xea\xff\xf6\xff\x05\x00\x0e\x00\x0b\x00\x05\x00\x01\x00\x02\x00\x08\x00\x10\x00\x10\x00\x0e\x00\x0e\x00\x11\x00\x11\x00\x0c\x00\x03\x00\xfd\xff\xfe\xff\xff\xff\x01\x00\x03\x00\x06\x00\x02\x00\x00\x00\x05\x00\r\x00\x15\x00\x19\x00\x17\x00\x0f\x00\x07\x00\xfe\xff\xf8\xff\xf3\xff\xef\xff\xee\xff\xef\xff\xf6\xff\x05\x00\x12\x00\x15\x00\n\x00\xfd\xff\xf5\xff\xf3\xff\xed\xff\xe5\xff\xe2\xff\xe1\xff\xe4\xff\xe7\xff\xec\xff\xf2\xff\xf9\xff\x02\x00\x0c\x00\x14\x00\x15\x00\x0c\x00\xfb\xff\xeb\xff\xe2\xff\xe5\xff\xf2\xff\xfd\xff\xfd\xff\xf7\xff\xf6\xff\xfe\xff\x07\x00\t\x00\x06\x00\x02\x00\xff\xff\xfe\xff\xfd\xff\xfc\xff\xfa\xff\xf7\xff\xf8\xff\x01\x00\x0b\x00\x14\x00\x11\x00\n\x00\x06\x00\x08\x00\x0e\x00\x12\x00\x14\x00\x14\x00\x12\x00\x0e\x00\t\x00\x05\x00\xff\xff\xf9\xff\xf5\xff\xfa\xff\xff\xff\x06\x00\x0b\x00\x13\x00\x19\x00\x19\x00\x15\x00\x0e\x00\x07\x00\xfe\xff\xf7\xff\xf0\xff\xef\xff\xf0\xff\xf3\xff\xf4\xff\xf7\xff\xfd\xff\x03\x00\n\x00\r\x00\x0c\x00\x05\x00\xfa\xff\xeb\xff\xe5\xff\xea\xff\xf3\xff\xf5\xff\xf2\xff\xf3\xff\xf9\xff\x04\x00\x0f\x00\x18\x00\x1a\x00\x19\x00\x12\x00\x04\x00\xf7\xff\xed\xff\xe9\xff\xea\xff\xee\xff\xf3\xff\xf5\xff\xf8\xff\xfc\xff\x01\x00\x07\x00\n\x00\n\x00\x07\x00\xfe\xff\xf1\xff\xe8\xff\xe7\xff\xef\xff\xfe\xff\x0c\x00\x10\x00\t\x00\x02\x00\x00\x00\x03\x00\x07\x00\t\x00\n\x00\r\x00\x13\x00\x17\x00\x12\x00\x06\x00\xfa\xff\xf4\xff\xf5\xff\xfa\xff\xff\xff\x03\x00\x06\x00\x0e\x00\x16\x00\x1a\x00\x17\x00\x10\x00\n\x00\x08\x00\x06\x00\xff\xff\xf6\xff\xee\xff\xe9\xff\xe7\xff\xe9\xff\xee\xff\xf9\xff\x05\x00\x0e\x00\x11\x00\n\x00\xfb\xff\xec\xff\xe4\xff\xe6\xff\xe9\xff\xec\xff\xf0\xff\xf5\xff\xfa\xff\xfd\xff\xff\xff\x04\x00\x10\x00\x1c\x00$\x00\x1f\x00\x0f\x00\xfc\xff\xf1\xff\xf0\xff\xf2\xff\xf3\xff\xf3\xff\xf7\xff\x03\x00\x11\x00\x17\x00\x15\x00\x0e\x00\n\x00\t\x00\x07\x00\xfe\xff\xf5\xff\xee\xff\xf2\xff\xfa\xff\x04\x00\x06\x00\x01\x00\xfe\xff\xff\xff\x04\x00\x07\x00\x04\x00\x01\x00\x00\x00\x01\x00\xfe\xff\xfd\xff\xfc\xff\xff\xff\x00\x00\xfc\xff\xf6\xff\xf4\xff\xf8\xff\xff\xff\x08\x00\x11\x00\x17\x00\x1a\x00\x1a\x00\x18\x00\x12\x00\x05\x00\xf7\xff\xec\xff\xe6\xff\xe7\xff\xea\xff\xed\xff\xf5\xff\x01\x00\x0c\x00\x11\x00\x0e\x00\x06\x00\xfc\xff\xf5\xff\xf2\xff\xf1\xff\xf0\xff\xee\xff\xf1\xff\xf5\xff\xf9\xff\xfd\xff\x02\x00\x0b\x00\x14\x00\x1c\x00\x1b\x00\r\x00\xf9\xff\xea\xff\xe8\xff\xec\xff\xf0\xff\xf3\xff\xf7\xff\xfe\xff\x08\x00\x0c\x00\t\x00\x04\x00\x07\x00\x10\x00\x17\x00\x17\x00\x0e\x00\x04\x00\x01\x00\x01\x00\x02\x00\x01\x00\x00\x00\xfd\xff\x00\x00\x07\x00\x0b\x00\x08\x00\x02\x00\xff\xff\x01\x00\x06\x00\x06\x00\x02\x00\x00\x00\xfc\xff\xf6\xff\xf0\xff\xed\xff\xed\xff\xf6\xff\x01\x00\n\x00\x11\x00\x15\x00\x16\x00\x11\x00\x0b\x00\xff\xff\xf2\xff\xe9\xff\xe6\xff\xe8\xff\xe6\xff\xe3\xff\xe5\xff\xf3\xff\x03\x00\x0e\x00\r\x00\t\x00\x06\x00\x03\x00\xfd\xff\xf7\xff\xf3\xff\xf0\xff\xf2\xff\xfa\xff\x03\x00\t\x00\x0c\x00\x0b\x00\r\x00\x13\x00\x15\x00\x0f\x00\x04\x00\xfd\xff\xfc\xff\xff\xff\xfd\xff\xf7\xff\xf2\xff\xf6\xff\xfd\xff\x01\x00\x01\x00\xff\xff\x00\x00\x06\x00\x0c\x00\x0f\x00\x0b\x00\x02\x00\xfd\xff\xfc\xff\x01\x00\x06\x00\n\x00\x08\x00\x02\x00\xfa\xff\xf6\xff\xf7\xff\xfb\xff\x01\x00\n\x00\x13\x00\x15\x00\x16\x00\x12\x00\n\x00\xfd\xff\xf0\xff\xe6\xff\xe8\xff\xf3\xff\x01\x00\x0c\x00\x12\x00\x17\x00\x1c\x00\x1d\x00\x19\x00\x0e\x00\x02\x00\xf3\xff\xe8\xff\xe1\xff\xe0\xff\xe0\xff\xe3\xff\xec\xff\xf8\xff\x00\x00\x02\x00\x03\x00\x01\x00\xfd\xff\xf4\xff\xed\xff\xec\xff\xed\xff\xf3\xff\xfa\xff\x00\x00\x04\x00\x05\x00\x03\x00\x05\x00\n\x00\x10\x00\x11\x00\x0e\x00\x0b\x00\x08\x00\x05\x00\xfd\xff\xf4\xff\xf2\xff\xf6\xff\xfc\xff\xff\xff\xff\xff\x03\x00\x08\x00\x0b\x00\x0b\x00\r\x00\x0e\x00\x0e\x00\x0c\x00\n\x00\x07\x00\x04\x00\x03\x00\x02\x00\x00\x00\xfa\xff\xf4\xff\xf1\xff\xf4\xff\xfa\xff\x03\x00\t\x00\x07\x00\x06\x00\x06\x00\x06\x00\x02\x00\xfb\xff\xf5\xff\xf3\xff\xf8\xff\xfc\xff\xfe\xff\x00\x00\x06\x00\x11\x00\x1e\x00%\x00"\x00\x18\x00\x08\x00\xf5\xff\xe5\xff\xdc\xff\xda\xff\xdd\xff\xe5\xff\xf1\xff\xfe\xff\x07\x00\n\x00\x0b\x00\x08\x00\x04\x00\xff\xff\xfd\xff\xfa\xff\xf7\xff\xf6\xff\xf4\xff\xf5\xff\xf9\xff\x01\x00\x06\x00\x07\x00\t\x00\x0c\x00\x11\x00\r\x00\x03\x00\xf8\xff\xee\xff\xe9\xff\xee\xff\xf6\xff\xfc\xff\xfa\xff\xf7\xff\xf9\xff\x01\x00\x06\x00\x07\x00\x08\x00\x0c\x00\x10\x00\x10\x00\x0b\x00\x03\x00\xfa\xff\xf7\xff\xfd\xff\x01\x00\x00\x00\xf9\xff\xf5\xff\xfa\xff\x02\x00\x07\x00\x06\x00\x05\x00\x07\x00\x0c\x00\x11\x00\x11\x00\t\x00\xff\xff\xf7\xff\xf5\xff\xfa\xff\xfe\xff\x00\x00\x03\x00\t\x00\x14\x00\x1a\x00\x19\x00\x11\x00\x08\x00\xfe\xff\xf1\xff\xe7\xff\xe1\xff\xe3\xff\xe9\xff\xee\xff\xf5\xff\xfb\xff\x01\x00\x07\x00\r\x00\x10\x00\r\x00\x08\x00\x02\x00\xfb\xff\xf4\xff\xee\xff\xed\xff\xf3\xff\xff\xff\t\x00\x0e\x00\x0e\x00\x10\x00\x13\x00\x13\x00\x0e\x00\x04\x00\xfc\xff\xf9\xff\xfb\xff\xfc\xff\xfa\xff\xf4\xff\xf1\xff\xf5\xff\xfe\xff\x04\x00\x04\x00\x05\x00\x07\x00\x0c\x00\x0b\x00\x03\x00\xf4\xff\xea\xff\xea\xff\xf0\xff\xf7\xff\xfa\xff\xf9\xff\xfa\xff\xfc\xff\xfe\xff\xfd\xff\xfe\xff\x03\x00\n\x00\x0e\x00\x0b\x00\x08\x00\x04\x00\xfe\xff\xf9\xff\xf7\xff\xf9\xff\xfe\xff\x03\x00\x06\x00\x0c\x00\x10\x00\x15\x00\x16\x00\x13\x00\x0e\x00\n\x00\x04\x00\xfc\xff\xf2\xff\xeb\xff\xe9\xff\xeb\xff\xf2\xff\xfb\xff\x04\x00\n\x00\x0b\x00\n\x00\x04\x00\xff\xff\xfd\xff\xfa\xff\xf7\xff\xf4\xff\xf5\xff\xfb\xff\x03\x00\x08\x00\x08\x00\x03\x00\x00\x00\x02\x00\x0b\x00\x11\x00\x13\x00\x0e\x00\x07\x00\xff\xff\xf8\xff\xf1\xff\xee\xff\xed\xff\xef\xff\xf8\xff\x03\x00\x08\x00\x08\x00\t\x00\x11\x00\x15\x00\x10\x00\x08\x00\x00\x00\xfc\xff\xfb\xff\xf9\xff\xf4\xff\xf3\xff\xf7\xff\x00\x00\x01\x00\xfd\xff\xf8\xff\xf8\xff\xfd\xff\x01\x00\x00\x00\xfa\xff\xf4\xff\xf0\xff\xee\xff\xf0\xff\xf7\xff\xfe\xff\x04\x00\x05\x00\x07\x00\x08\x00\x0b\x00\r\x00\x10\x00\x12\x00\x0f\x00\x08\x00\xfd\xff\xf5\xff\xf1\xff\xef\xff\xf0\xff\xf2\xff\xf8\xff\xfd\xff\x02\x00\x07\x00\t\x00\n\x00\x07\x00\x04\x00\x02\x00\x02\x00\x04\x00\x02\x00\x02\x00\x02\x00\x07\x00\x0b\x00\x0b\x00\x07\x00\x05\x00\n\x00\x0e\x00\x0c\x00\x07\x00\x02\x00\xff\xff\xfa\xff\xf4\xff\xf2\xff\xf5\xff\xf9\xff\xfd\xff\xfe\xff\xfa\xff\xf7\xff\xf7\xff\xff\xff\x0c\x00\x16\x00\x18\x00\x0f\x00\x03\x00\xfa\xff\xf3\xff\xed\xff\xea\xff\xee\xff\xf8\xff\x00\x00\x03\x00\x02\x00\x01\x00\x04\x00\x08\x00\n\x00\x06\x00\x00\x00\xfc\xff\xf9\xff\xf7\xff\xf4\xff\xf6\xff\xfc\xff\x03\x00\x06\x00\x05\x00\x03\x00\x02\x00\x04\x00\x08\x00\x05\x00\xff\xff\xf6\xff\xef\xff\xec\xff\xef\xff\xf3\xff\xf3\xff\xf3\xff\xf5\xff\xfb\xff\x04\x00\x08\x00\x0b\x00\x0b\x00\r\x00\x10\x00\x10\x00\x08\x00\x00\x00\xf8\xff\xf7\xff\xfb\xff\x04\x00\x0c\x00\x0b\x00\x07\x00\x05\x00\t\x00\r\x00\x10\x00\x0c\x00\x08\x00\x03\x00\xfe\xff\xfb\xff\xf8\xff\xf7\xff\xf8\xff\xfc\xff\xfd\xff\xfe\xff\xff\xff\x03\x00\n\x00\x0e\x00\x0c\x00\x08\x00\x04\x00\x00\x00\xfa\xff\xf4\xff\xf3\xff\xf4\xff\xfb\xff\x00\x00\xff\xff\xfa\xff\xf7\xff\xfa\xff\x04\x00\x0c\x00\x0f\x00\x0b\x00\x03\x00\xfc\xff\xf5\xff\xf1\xff\xef\xff\xf1\xff\xf7\xff\x01\x00\x04\x00\x03\x00\x03\x00\x04\x00\x0b\x00\x0e\x00\r\x00\t\x00\x02\x00\xfd\xff\xf8\xff\xf4\xff\xf1\xff\xf1\xff\xf4\xff\xf9\xff\xff\xff\x01\x00\x02\x00\x04\x00\x08\x00\x0c\x00\x0c\x00\x04\x00\xfc\xff\xf6\xff\xf6\xff\xfa\xff\xfd\xff\x01\x00\x02\x00\x01\x00\x04\x00\t\x00\r\x00\x0e\x00\r\x00\r\x00\n\x00\x04\x00\xf9\xff\xf0\xff\xee\xff\xef\xff\xf5\xff\xf9\xff\xfd\xff\xff\xff\x01\x00\x04\x00\n\x00\x0e\x00\r\x00\x08\x00\x01\x00\xff\xff\xfe\xff\xfc\xff\xf9\xff\xfb\xff\xfd\xff\xff\xff\xff\xff\xfe\xff\x03\x00\x07\x00\x08\x00\x05\x00\x02\x00\x01\x00\x01\x00\xfe\xff\xf8\xff\xf4\xff\xf6\xff\xff\xff\x07\x00\x07\x00\x02\x00\xfe\xff\x01\x00\x07\x00\n\x00\t\x00\x07\x00\x04\x00\x01\x00\xfe\xff\xf8\xff\xf3\xff\xed\xff\xec\xff\xf0\xff\xf6\xff\xfc\xff\x01\x00\x03\x00\x05\x00\t\x00\r\x00\x0e\x00\x0c\x00\x07\x00\x02\x00\xf9\xff\xf3\xff\xf4\xff\xfc\xff\x02\x00\x06\x00\t\x00\x0c\x00\x0f\x00\x11\x00\r\x00\x07\x00\xfd\xff\xf2\xff\xec\xff\xeb\xff\xee\xff\xf2\xff\xf4\xff\xf8\xff\xfc\xff\x03\x00\x08\x00\x0b\x00\x0b\x00\t\x00\x05\x00\x01\x00\xff\xff\xfd\xff\xfb\xff\xf5\xff\xf4\xff\xf8\xff\xfd\xff\x02\x00\x03\x00\x05\x00\x06\x00\x08\x00\x0b\x00\r\x00\t\x00\x02\x00\xf9\xff\xf6\xff\xf6\xff\xfc\xff\x02\x00\x06\x00\x07\x00\x04\x00\x04\x00\t\x00\x0e\x00\r\x00\x08\x00\x02\x00\x04\x00\x07\x00\x06\x00\xff\xff\xf9\xff\xf6\xff\xf7\xff\xfa\xff\xfd\xff\xfc\xff\xfe\xff\x01\x00\x04\x00\t\x00\x0b\x00\x0b\x00\x08\x00\x01\x00\xfc\xff\xf7\xff\xef\xff\xeb\xff\xed\xff\xf4\xff\xfb\xff\x04\x00\r\x00\x13\x00\x12\x00\x0e\x00\x07\x00\x04\x00\xff\xff\xf8\xff\xf1\xff\xec\xff\xea\xff\xeb\xff\xf3\xff\xfc\xff\x05\x00\x0b\x00\x0c\x00\n\x00\x06\x00\x02\x00\xfd\xff\xfa\xff\xf5\xff\xf4\xff\xf5\xff\xf8\xff\xfc\xff\xfd\xff\xfe\xff\x01\x00\x07\x00\x0c\x00\x0e\x00\x0b\x00\x07\x00\x02\x00\x00\x00\xfe\xff\xfb\xff\xf5\xff\xf2\xff\xf1\xff\xf9\xff\x01\x00\x06\x00\x04\x00\x01\x00\x02\x00\x06\x00\n\x00\x0b\x00\x0b\x00\n\x00\x04\x00\xff\xff\xfd\xff\xfd\xff\xff\xff\x01\x00\x02\x00\x03\x00\x06\x00\n\x00\r\x00\x0c\x00\x08\x00\x04\x00\x02\x00\x01\x00\x00\x00\xfc\xff\xf6\xff\xf2\xff\xf6\xff\xfe\xff\x03\x00\x02\x00\x02\x00\x06\x00\x0b\x00\r\x00\x0e\x00\x0c\x00\x04\x00\xfa\xff\xf1\xff\xee\xff\xef\xff\xef\xff\xee\xff\xf1\xff\xf8\xff\x02\x00\x06\x00\n\x00\x08\x00\x04\x00\x01\x00\xfd\xff\xfc\xff\xf8\xff\xf6\xff\xf4\xff\xf4\xff\xf8\xff\xfe\xff\x03\x00\x08\x00\x0c\x00\x0e\x00\x0e\x00\n\x00\x05\x00\x02\x00\xfe\xff\xfa\xff\xf8\xff\xf6\xff\xf7\xff\xfb\xff\x00\x00\x01\x00\xff\xff\xff\xff\x05\x00\x0b\x00\x0c\x00\t\x00\x04\x00\x05\x00\x08\x00\x05\x00\xff\xff\xf7\xff\xf5\xff\xf7\xff\xfe\xff\x07\x00\n\x00\x08\x00\x04\x00\x03\x00\x05\x00\x08\x00\x04\x00\x00\x00\xfc\xff\xfc\xff\xfb\xff\xf9\xff\xfa\xff\xfc\xff\xfd\xff\xfe\xff\xff\xff\x05\x00\x0c\x00\x0e\x00\n\x00\x06\x00\x01\x00\xfe\xff\xf9\xff\xf6\xff\xf4\xff\xf2\xff\xf4\xff\xf9\xff\xff\xff\x02\x00\x00\x00\xfd\xff\xfc\xff\x01\x00\x05\x00\x06\x00\x02\x00\xfb\xff\xf5\xff\xf3\xff\xf9\xff\xfe\xff\x00\x00\xfe\xff\xfa\xff\xfc\xff\x03\x00\t\x00\n\x00\t\x00\x05\x00\x04\x00\x02\x00\xff\xff\xfb\xff\xf6\xff\xf2\xff\xf5\xff\xfa\xff\xff\xff\x00\x00\x02\x00\x07\x00\x0b\x00\x0b\x00\x08\x00\x07\x00\x07\x00\x03\x00\xfd\xff\xf9\xff\xfb\xff\xff\xff\x04\x00\x05\x00\x04\x00\x05\x00\x08\x00\x0b\x00\x0c\x00\x0b\x00\x05\x00\x00\x00\xfe\xff\xfd\xff\xfc\xff\xf8\xff\xf4\xff\xf6\xff\xfb\xff\xff\xff\x01\x00\x03\x00\x06\x00\x08\x00\x08\x00\x06\x00\x01\x00\xfe\xff\xfb\xff\xf8\xff\xf9\xff\xfa\xff\xf7\xff\xf6\xff\xf8\xff\xfd\xff\xfd\xff\xfd\xff\xff\xff\x01\x00\x01\x00\xff\xff\xfb\xff\xf7\xff\xf8\xff\xfa\xff\xfc\xff\xfd\xff\xfe\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x05\x00\x05\x00\x06\x00\x06\x00\x03\x00\x01\x00\xfd\xff\xfb\xff\xfa\xff\xf8\xff\xf4\xff\xf2\xff\xf6\xff\xff\xff\t\x00\x0f\x00\x0e\x00\x0c\x00\x0e\x00\r\x00\x07\x00\xfe\xff\xf8\xff\xf6\xff\xf5\xff\xf6\xff\xfa\xff\x03\x00\x0c\x00\x11\x00\x10\x00\r\x00\x08\x00\x05\x00\x04\x00\xff\xff\xfa\xff\xf5\xff\xf5\xff\xf8\xff\xff\xff\x03\x00\x05\x00\x06\x00\n\x00\x12\x00\x15\x00\x10\x00\x07\x00\x00\x00\xfc\xff\xfc\xff\xfc\xff\xfa\xff\xf5\xff\xf5\xff\xf9\xff\xff\xff\x03\x00\x03\x00\x02\x00\x01\x00\xfe\xff\xf9\xff\xf6\xff\xf7\xff\xfd\xff\xfe\xff\xfd\xff\xfb\xff\xfd\xff\x01\x00\x02\x00\x01\x00\x00\x00\xff\xff\xfe\xff\x02\x00\x04\x00\x03\x00\x00\x00\xfe\xff\xff\xff\x02\x00\x01\x00\xfc\xff\xf4\xff\xf0\xff\xee\xff\xf1\xff\xf6\xff\xfd\xff\x05\x00\t\x00\n\x00\x0b\x00\r\x00\x08\x00\x03\x00\xfe\xff\xfb\xff\xf9\xff\xf7\xff\xf8\xff\xfd\xff\x04\x00\x0b\x00\x0c\x00\x0c\x00\n\x00\x07\x00\x05\x00\x01\x00\xfc\xff\xf7\xff\xf4\xff\xf4\xff\xf5\xff\xf5\xff\xf3\xff\xf8\xff\x04\x00\x11\x00\x13\x00\r\x00\x04\x00\x01\x00\x03\x00\x04\x00\x01\x00\xfd\xff\xfb\xff\xfa\xff\xfc\xff\xff\xff\x04\x00\x06\x00\t\x00\x0b\x00\t\x00\x04\x00\xff\xff\xfb\xff\xfd\xff\xfd\xff\xfa\xff\xf9\xff\xfa\xff\xfd\xff\x01\x00\x06\x00\x06\x00\x03\x00\x03\x00\x03\x00\x04\x00\x00\x00\xfd\xff\xff\xff\x06\x00\n\x00\x08\x00\x00\x00\xf8\xff\xf5\xff\xf9\xff\xfa\xff\xf9\xff\xfb\xff\xff\xff\x02\x00\x06\x00\x05\x00\x02\x00\xff\xff\xfd\xff\xff\xff\x00\x00\xfd\xff\xf9\xff\xf4\xff\xf5\xff\xfb\xff\x01\x00\x06\x00\x07\x00\t\x00\x08\x00\x06\x00\x05\x00\x02\x00\xff\xff\xfd\xff\xfc\xff\xfb\xff\xf8\xff\xf8\xff\xfb\xff\x02\x00\t\x00\n\x00\x06\x00\x02\x00\x02\x00\x06\x00\x05\x00\x01\x00\xfb\xff\xf8\xff\xf6\xff\xf4\xff\xf1\xff\xee\xff\xf3\xff\x00\x00\x0c\x00\x11\x00\t\x00\xff\xff\xfb\xff\xfd\xff\x02\x00\x00\x00\xfd\xff\xfb\xff\xff\xff\x06\x00\x0b\x00\r\x00\x0b\x00\x0b\x00\n\x00\x07\x00\x03\x00\x02\x00\x00\x00\x01\x00\x02\x00\x02\x00\xfe\xff\xf9\xff\xf7\xff\xf8\xff\xf9\xff\xf6\xff\xf7\xff\xfc\xff\x02\x00\x05\x00\x03\x00\x01\x00\x02\x00\x06\x00\x07\x00\x05\x00\x00\x00\xfa\xff\xf9\xff\xfc\xff\x02\x00\x07\x00\t\x00\n\x00\x07\x00\x06\x00\x03\x00\xff\xff\xfb\xff\xf7\xff\xf5\xff\xf9\xff\xfa\xff\xf9\xff\xf9\xff\xf7\xff\xf8\xff\xfc\xff\x00\x00\x02\x00\x02\x00\x05\x00\x07\x00\t\x00\t\x00\x07\x00\x05\x00\x02\x00\xfd\xff\xf7\xff\xf5\xff\xf7\xff\xfc\xff\x04\x00\x07\x00\x04\x00\xff\xff\xfd\xff\xff\xff\x01\x00\xfd\xff\xf8\xff\xf5\xff\xf8\xff\xfb\xff\xfe\xff\xff\xff\x00\x00\x05\x00\x0b\x00\x0f\x00\x0c\x00\x06\x00\x03\x00\x02\x00\x05\x00\x06\x00\x04\x00\xfe\xff\xfb\xff\xfd\xff\xfe\xff\xfc\xff\xf8\xff\xfa\xff\xff\xff\x03\x00\x04\x00\x05\x00\x04\x00\x04\x00\x04\x00\x01\x00\xfd\xff\xf9\xff\xf6\xff\xf7\xff\xf9\xff\xff\xff\x05\x00\x0b\x00\x0e\x00\x0c\x00\x06\x00\x01\x00\xfd\xff\xfb\xff\xf9\xff\xfb\xff\xfb\xff\xfc\xff\xfe\xff\x01\x00\x03\x00\x04\x00\x04\x00\x05\x00\x04\x00\x04\x00\x04\x00\x03\x00\x00\x00\xff\xff\x02\x00\x06\x00\x05\x00\x02\x00\xfc\xff\xf7\xff\xf8\xff\xfb\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\x03\x00\x05\x00\x03\x00\x01\x00\xfe\xff\x00\x00\xff\xff\xfe\xff\xfc\xff\xfc\xff\xff\xff\x02\x00\x05\x00\x05\x00\x03\x00\x02\x00\x04\x00\x04\x00\x02\x00\xff\xff\xfc\xff\xfa\xff\xf7\xff\xf3\xff\xef\xff\xee\xff\xf5\xff\xff\xff\x05\x00\x06\x00\x04\x00\x04\x00\x05\x00\x06\x00\x01\x00\xfa\xff\xf7\xff\xf6\xff\xfb\xff\x00\x00\x01\x00\x04\x00\x07\x00\n\x00\x0b\x00\t\x00\x02\x00\xfc\xff\xf8\xff\xf7\xff\xf8\xff\xfa\xff\xfb\xff\xfd\xff\xff\xff\x00\x00\x01\x00\x02\x00\x06\x00\x08\x00\x07\x00\x05\x00\x03\x00\x04\x00\x06\x00\t\x00\n\x00\n\x00\x07\x00\x04\x00\x04\x00\x02\x00\x01\x00\x01\x00\x03\x00\x03\x00\x01\x00\x00\x00\xff\xff\xfc\xff\xf9\xff\xf9\xff\xfd\xff\x02\x00\x03\x00\xff\xff\xfa\xff\xfa\xff\xfc\xff\xfe\xff\x00\x00\x01\x00\x02\x00\x03\x00\x04\x00\x04\x00\x03\x00\x05\x00\x04\x00\x00\x00\xfb\xff\xf2\xff\xef\xff\xf2\xff\xf8\xff\xfd\xff\xff\xff\xff\xff\x03\x00\x07\x00\x07\x00\x01\x00\xf9\xff\xf6\xff\xf6\xff\xf9\xff\xfa\xff\xf6\xff\xf5\xff\xfa\xff\x04\x00\r\x00\x0e\x00\x08\x00\x00\x00\xff\xff\x00\x00\xff\xff\xfd\xff\xfa\xff\xf9\xff\xfc\xff\x01\x00\x04\x00\x05\x00\x06\x00\x08\x00\n\x00\t\x00\x06\x00\x05\x00\x01\x00\x00\x00\x01\x00\x03\x00\x06\x00\x06\x00\x02\x00\xfe\xff\xfc\xff\xfd\xff\x03\x00\x08\x00\x08\x00\x03\x00\x00\x00\xfe\xff\xff\xff\x02\x00\x03\x00\x01\x00\xfe\xff\xfc\xff\xfe\xff\x02\x00\x02\x00\x01\x00\x00\x00\x03\x00\x07\x00\x07\x00\x02\x00\xfa\xff\xf6\xff\xf8\xff\xfd\xff\x01\x00\x01\x00\xfc\xff\xf7\xff\xf3\xff\xf6\xff\xf9\xff\xfa\xff\xf9\xff\xfb\xff\x02\x00\x06\x00\x05\x00\x02\x00\xff\xff\x00\x00\xff\xff\xfc\xff\xf9\xff\xf8\xff\xfb\xff\x00\x00\x05\x00\x06\x00\x04\x00\x04\x00\x06\x00\x08\x00\x04\x00\xfe\xff\xf7\xff\xf7\xff\xfc\xff\x00\x00\xfe\xff\xfc\xff\xfe\xff\x06\x00\x0b\x00\x0c\x00\n\x00\t\x00\x07\x00\x05\x00\x01\x00\x01\x00\x01\x00\x03\x00\x02\x00\x02\x00\x01\x00\xff\xff\x00\x00\x05\x00\x08\x00\x04\x00\x01\x00\xfe\xff\xfe\xff\xfd\xff\xfc\xff\xfa\xff\xf7\xff\xf7\xff\xf9\xff\xfc\xff\x00\x00\x01\x00\x02\x00\x05\x00\x07\x00\x07\x00\x03\x00\xfb\xff\xf5\xff\xf6\xff\xfa\xff\xfe\xff\x01\x00\x00\x00\xff\xff\xfd\xff\xfc\xff\xfb\xff\xfb\xff\xfd\xff\xff\xff\x01\x00\x02\x00\xff\xff\xfb\xff\xfa\xff\xfc\xff\xff\xff\xff\xff\xfd\xff\xfc\xff\xfd\xff\x00\x00\x04\x00\x04\x00\x03\x00\x01\x00\x04\x00\n\x00\t\x00\x05\x00\xff\xff\xfe\xff\x00\x00\x01\x00\x01\x00\x00\x00\x03\x00\x07\x00\t\x00\x08\x00\x07\x00\x07\x00\n\x00\t\x00\x05\x00\xff\xff\xfd\xff\xfe\xff\x00\x00\x00\x00\xfd\xff\xfa\xff\xfc\xff\x02\x00\x07\x00\t\x00\x06\x00\x02\x00\x01\x00\x01\x00\xff\xff\xfa\xff\xf5\xff\xf4\xff\xf7\xff\xfd\xff\x01\x00\x03\x00\x03\x00\x02\x00\x03\x00\x01\x00\xff\xff\xfd\xff\xfa\xff\xf8\xff\xf6\xff\xf5\xff\xf6\xff\xf7\xff\xf9\xff\xf8\xff\xf7\xff\xf6\xff\xfa\xff\xff\xff\x04\x00\x04\x00\x02\x00\xfe\xff\xf9\xff\xf5\xff\xf5\xff\xfa\xff\x00\x00\x00\x00\x02\x00\x02\x00\x04\x00\x06\x00\x07\x00\x06\x00\x05\x00\x06\x00\x07\x00\x07\x00\x04\x00\x02\x00\x01\x00\x02\x00\x02\x00\x03\x00\x03\x00\x05\x00\x06\x00\x08\x00\x08\x00\x06\x00\x07\x00\x0c\x00\x0e\x00\x0c\x00\x06\x00\x02\x00\x03\x00\x05\x00\x04\x00\x00\x00\xfc\xff\xfb\xff\x00\x00\x05\x00\x07\x00\x06\x00\x04\x00\x04\x00\x03\x00\x00\x00\xf9\xff\xf6\xff\xf5\xff\xf5\xff\xf4\xff\xf4\xff\xf9\xff\xfd\xff\x03\x00\x06\x00\x05\x00\x03\x00\x02\x00\xff\xff\xff\xff\xfc\xff\xf7\xff\xf2\xff\xf2\xff\xf7\xff\xfa\xff\xf9\xff\xf7\xff\xf8\xff\xfd\xff\x03\x00\x05\x00\x03\x00\xfb\xff\xf4\xff\xf1\xff\xee\xff\xf1\xff\xf4\xff\xf8\xff\xfb\xff\xff\xff\x00\x00\x03\x00\x05\x00\x07\x00\x07\x00\x06\x00\x06\x00\x06\x00\x06\x00\x04\x00\x02\x00\x02\x00\x04\x00\x06\x00\x05\x00\x04\x00\x05\x00\x06\x00\x07\x00\x08\x00\t\x00\x0b\x00\r\x00\x0c\x00\x07\x00\x04\x00\x02\x00\x01\x00\x02\x00\x01\x00\x02\x00\x01\x00\x03\x00\x07\x00\n\x00\x0c\x00\x0b\x00\n\x00\x08\x00\x04\x00\xfe\xff\xfb\xff\xf8\xff\xf9\xff\xf6\xff\xf3\xff\xf3\xff\xf8\xff\x00\x00\x04\x00\x04\x00\x01\x00\xff\xff\xff\xff\xff\xff\xfd\xff\xf8\xff\xf0\xff\xee\xff\xef\xff\xf4\xff\xf7\xff\xf9\xff\xfb\xff\xfd\xff\x01\x00\x06\x00\x07\x00\x05\x00\x00\x00\xf8\xff\xf5\xff\xf4\xff\xf6\xff\xf9\xff\xfb\xff\xfe\xff\x00\x00\x01\x00\x02\x00\x04\x00\t\x00\n\x00\x06\x00\x04\x00\x02\x00\x00\x00\xfe\xff\xfc\xff\xfa\xff\xfa\xff\xfd\xff\xff\xff\x04\x00\x04\x00\x03\x00\x02\x00\x04\x00\t\x00\r\x00\x0c\x00\x05\x00\xfc\xff\xfb\xff\xff\xff\x03\x00\x06\x00\x02\x00\x00\x00\x00\x00\x04\x00\x0b\x00\r\x00\x0c\x00\t\x00\x07\x00\x05\x00\x02\x00\xff\xff\xfe\xff\xfd\xff\xfa\xff\xf8\xff\xf9\xff\xfc\xff\x01\x00\x05\x00\x04\x00\x05\x00\x06\x00\x04\x00\x03\x00\xfe\xff\xf9\xff\xf6\xff\xf5\xff\xf7\xff\xf9\xff\xf9\xff\xf6\xff\xf6\xff\xf8\xff\xfd\xff\x02\x00\x03\x00\xff\xff\xfd\xff\xfc\xff\xfb\xff\xf8\xff\xf4\xff\xf5\xff\xf8\xff\xfd\xff\x01\x00\x01\x00\x02\x00\x02\x00\x05\x00\x07\x00\x08\x00\t\x00\n\x00\x08\x00\x05\x00\x01\x00\xff\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\x01\x00\x01\x00\x02\x00\x02\x00\x03\x00\x04\x00\x05\x00\x03\x00\x00\x00\xfb\xff\xfa\xff\xf9\xff\xf7\xff\xf7\xff\xfd\xff\x02\x00\x05\x00\x08\x00\x08\x00\n\x00\x0b\x00\r\x00\n\x00\x02\x00\xfa\xff\xf7\xff\xfa\xff\xfe\xff\xff\xff\xfd\xff\xfd\xff\xfe\xff\x03\x00\x05\x00\x04\x00\x00\x00\xff\xff\xfd\xff\xfd\xff\xfc\xff\xfc\xff\xfb\xff\xfb\xff\xfc\xff\xfd\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x04\x00\x06\x00\x08\x00\x06\x00\xff\xff\xf8\xff\xf5\xff\xf5\xff\xfc\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x04\x00\x07\x00\x06\x00\x04\x00\x02\x00\x03\x00\x04\x00\x05\x00\x04\x00\xff\xff\xfd\xff\xfe\xff\x00\x00\x01\x00\x00\x00\xfe\xff\xfd\xff\xff\xff\x02\x00\x07\x00\x08\x00\x04\x00\x00\x00\xfd\xff\xfc\xff\xfc\xff\xfb\xff\xf9\xff\xf9\xff\x00\x00\x04\x00\t\x00\x0c\x00\n\x00\x08\x00\x06\x00\x02\x00\xfe\xff\xfa\xff\xf7\xff\xf4\xff\xf3\xff\xf4\xff\xf9\xff\xff\xff\x03\x00\x01\x00\x00\x00\xfd\xff\xff\xff\x00\x00\xff\xff\xfb\xff\xfa\xff\xf8\xff\xfa\xff\xfa\xff\xfd\xff\xfd\xff\xff\xff\xff\xff\x01\x00\x01\x00\x03\x00\x04\x00\x01\x00\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xff\xff\x01\x00\x02\x00\x03\x00\x04\x00\x06\x00\x06\x00\x08\x00\n\x00\x0b\x00\x0c\x00\x0b\x00\x07\x00\x03\x00\x02\x00\x02\x00\x03\x00\x02\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xfc\xff\xfa\xff\xfc\xff\xfe\xff\x02\x00\x04\x00\x05\x00\x07\x00\x0b\x00\x0c\x00\r\x00\x07\x00\x01\x00\xfd\xff\xfa\xff\xf9\xff\xf9\xff\xf8\xff\xfa\xff\xfd\xff\xff\xff\x00\x00\x00\x00\xfd\xff\xfd\xff\xfc\xff\xfa\xff\xf8\xff\xf5\xff\xf4\xff\xf5\xff\xf4\xff\xf6\xff\xfa\xff\xff\xff\x01\x00\x04\x00\x04\x00\x07\x00\x04\x00\x02\x00\xfe\xff\xfd\xff\xfd\xff\xfd\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\x01\x00\x02\x00\x03\x00\x04\x00\x06\x00\x05\x00\x04\x00\x03\x00\x05\x00\x07\x00\x07\x00\x06\x00\x05\x00\x04\x00\x04\x00\x04\x00\x05\x00\x05\x00\x03\x00\x01\x00\x00\x00\x02\x00\x05\x00\x05\x00\x02\x00\xff\xff\xfd\xff\xfe\xff\xfd\xff\xfc\xff\xf9\xff\xf8\xff\xfa\xff\xfd\xff\x02\x00\x05\x00\x06\x00\x08\x00\t\x00\x07\x00\x04\x00\x01\x00\xff\xff\xfd\xff\xfa\xff\xf9\xff\xfb\xff\xff\xff\x01\x00\x02\x00\xff\xff\xfe\xff\x00\x00\x02\x00\x04\x00\x03\x00\xfd\xff\xfa\xff\xf8\xff\xf8\xff\xf8\xff\xf9\xff\xf7\xff\xf9\xff\xfd\xff\x01\x00\x05\x00\x06\x00\x04\x00\x00\x00\xfc\xff\xf9\xff\xfa\xff\xfa\xff\xfa\xff\xf8\xff\xfa\xff\xfc\xff\x00\x00\x02\x00\x04\x00\x06\x00\x07\x00\x07\x00\x06\x00\x05\x00\x04\x00\x04\x00\x06\x00\x05\x00\x03\x00\x02\x00\x01\x00\x00\x00\x01\x00\x01\x00\xff\xff\xfd\xff\xfe\xff\xff\xff\x00\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfb\xff\xf9\xff\xfa\xff\xff\xff\x05\x00\t\x00\t\x00\t\x00\x0c\x00\x0c\x00\n\x00\x08\x00\x04\x00\xff\xff\xfa\xff\xf7\xff\xf7\xff\xf9\xff\xfc\xff\xfd\xff\xfc\xff\xfd\xff\xfe\xff\x00\x00\x02\x00\xff\xff\xfb\xff\xfa\xff\xf9\xff\xf9\xff\xfb\xff\xfb\xff\xfc\xff\xfc\xff\xfe\xff\x01\x00\x06\x00\x08\x00\x07\x00\x06\x00\x04\x00\x03\x00\x01\x00\x01\x00\xfe\xff\xfd\xff\xfc\xff\xfb\xff\xfb\xff\xfb\xff\xff\xff\x01\x00\x05\x00\x07\x00\x07\x00\x05\x00\x03\x00\x00\x00\xff\xff\xfd\xff\xfc\xff\xfb\xff\xfa\xff\xfc\xff\xff\xff\x01\x00\x03\x00\x03\x00\x00\x00\xfd\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\x02\x00\xff\xff\xfb\xff\xf8\xff\xf9\xff\xfd\xff\x02\x00\x07\x00\x08\x00\t\x00\x08\x00\x08\x00\t\x00\x07\x00\x07\x00\x06\x00\x04\x00\x02\x00\xfd\xff\xfa\xff\xfb\xff\x00\x00\x04\x00\x07\x00\x06\x00\x03\x00\x02\x00\x01\x00\xfe\xff\xfe\xff\xfd\xff\xfb\xff\xf8\xff\xf8\xff\xf9\xff\xfc\xff\xfd\xff\xfc\xff\xfb\xff\xfd\xff\x01\x00\x03\x00\x04\x00\x01\x00\xfe\xff\xfe\xff\x01\x00\x01\x00\xfe\xff\xfb\xff\xf9\xff\xf9\xff\xfc\xff\xff\xff\x04\x00\x08\x00\x08\x00\t\x00\n\x00\n\x00\x07\x00\x04\x00\x00\x00\xfd\xff\xfb\xff\xfa\xff\xfa\xff\xfd\xff\x00\x00\x02\x00\x02\x00\x00\x00\xfe\xff\xfe\xff\xfc\xff\xf9\xff\xf6\xff\xf7\xff\xf9\xff\xf7\xff\xf5\xff\xf4\xff\xf6\xff\xfc\xff\x02\x00\x07\x00\x07\x00\x06\x00\x05\x00\x06\x00\x06\x00\x08\x00\t\x00\t\x00\x05\x00\x01\x00\xfe\xff\xfe\xff\x01\x00\x03\x00\x05\x00\x06\x00\x02\x00\x02\x00\x01\x00\x02\x00\x02\x00\x00\x00\xff\xff\xfc\xff\xfc\xff\xfa\xff\xfd\xff\xfe\xff\xff\xff\x02\x00\x05\x00\x07\x00\x07\x00\x05\x00\x01\x00\x01\x00\x02\x00\x06\x00\x03\x00\xfe\xff\xf8\xff\xf7\xff\xfa\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x04\x00\x04\x00\x04\x00\x02\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xfc\xff\xfa\xff\xfb\xff\xff\xff\x02\x00\x03\x00\x00\x00\xfd\xff\xfd\xff\x00\x00\x02\x00\x00\x00\xfd\xff\xfd\xff\xfd\xff\xfc\xff\xf9\xff\xf8\xff\xf9\xff\xfd\xff\xff\xff\x03\x00\x05\x00\x06\x00\x07\x00\x06\x00\x02\x00\xff\xff\x00\x00\xff\xff\xfe\xff\xfb\xff\xf9\xff\xfb\xff\xfe\xff\x02\x00\x04\x00\x03\x00\x02\x00\x01\x00\x01\x00\x02\x00\x03\x00\x02\x00\xff\xff\xfc\xff\xfb\xff\xfd\xff\x01\x00\x03\x00\x04\x00\x04\x00\x03\x00\x04\x00\x03\x00\x02\x00\x00\x00\x01\x00\x06\x00\x07\x00\x04\x00\x00\x00\xfc\xff\xfb\xff\xfe\xff\x00\x00\x02\x00\x06\x00\x07\x00\x07\x00\x05\x00\x03\x00\x02\x00\x03\x00\x04\x00\x03\x00\xff\xff\xfc\xff\xfa\xff\xfb\xff\xfe\xff\x01\x00\xff\xff\xfc\xff\xfa\xff\xf9\xff\xfb\xff\xfc\xff\xfc\xff\xfb\xff\xfc\xff\xfe\xff\xfe\xff\xfc\xff\xfa\xff\xf9\xff\xfb\xff\xfe\xff\x03\x00\x04\x00\x04\x00\x06\x00\x08\x00\t\x00\n\x00\t\x00\x07\x00\x04\x00\xff\xff\xfc\xff\xfa\xff\xfd\xff\x00\x00\x02\x00\x01\x00\x00\x00\xfe\xff\xff\xff\x00\x00\xfd\xff\xfb\xff\xf9\xff\xf6\xff\xf7\xff\xf7\xff\xf8\xff\xfc\xff\x00\x00\x02\x00\x02\x00\x04\x00\x03\x00\x01\x00\x01\x00\x01\x00\x04\x00\x07\x00\x08\x00\x04\x00\x02\x00\x02\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x02\x00\x03\x00\x02\x00\x04\x00\x05\x00\x05\x00\x05\x00\x04\x00\x01\x00\xfe\xff\xfd\xff\xfd\xff\x00\x00\x01\x00\x03\x00\x03\x00\x03\x00\x02\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfc\xff\xfa\xff\xf8\xff\xf9\xff\xfa\xff\xfe\xff\x01\x00\x01\x00\x00\x00\x00\x00\x02\x00\x04\x00\x03\x00\x04\x00\x04\x00\x05\x00\x03\x00\xfe\xff\xfb\xff\xf9\xff\xfc\xff\xff\xff\x03\x00\x06\x00\x04\x00\x02\x00\x01\x00\x01\x00\x02\x00\x02\x00\xff\xff\xfb\xff\xf8\xff\xf8\xff\xfc\xff\x00\x00\x02\x00\x02\x00\x02\x00\x02\x00\x02\x00\x00\x00\xfe\xff\xfb\xff\xfb\xff\xfe\xff\x00\x00\xff\xff\xfe\xff\xfc\xff\xfa\xff\xfb\xff\xff\xff\x03\x00\x05\x00\x04\x00\x03\x00\x04\x00\x04\x00\x07\x00\x07\x00\x07\x00\x04\x00\x02\x00\xfe\xff\xfd\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xfd\xff\xfd\xff\xff\xff\xff\xff\xff\xff\xfd\xff\xfb\xff\xfc\xff\xfc\xff\xfd\xff\xfd\xff\xfe\xff\x00\x00\x03\x00\x07\x00\x08\x00\x07\x00\x08\x00\x06\x00\x07\x00\x08\x00\x07\x00\x04\x00\xfe\xff\xfa\xff\xf9\xff\xfa\xff\xfe\xff\xff\xff\xff\xff\xfe\xff\xfd\xff\xfd\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xfb\xff\xfa\xff\xfa\xff\xfb\xff\xfc\xff\xfe\xff\x00\x00\x03\x00\x07\x00\x07\x00\x04\x00\x01\x00\x00\x00\x04\x00\x05\x00\x04\x00\x01\x00\xfb\xff\xfa\xff\xfc\xff\xfe\xff\x00\x00\x01\x00\x03\x00\x02\x00\x02\x00\x02\x00\x00\x00\xff\xff\xfe\xff\x00\x00\x00\x00\xfe\xff\xfd\xff\xfb\xff\xfc\xff\xfe\xff\x01\x00\x02\x00\x01\x00\x00\x00\x01\x00\x02\x00\x03\x00\x02\x00\xfe\xff\xfd\xff\xff\xff\xfe\xff\xfc\xff\xfc\xff\xfd\xff\x00\x00\x03\x00\x04\x00\x01\x00\x01\x00\x03\x00\x05\x00\x07\x00\x06\x00\x06\x00\x04\x00\x00\x00\xfe\xff\xfe\xff\xfe\xff\x00\x00\x03\x00\x04\x00\x05\x00\x05\x00\x02\x00\x01\x00\x01\x00\x02\x00\xff\xff\xfc\xff\xf8\xff\xf8\xff\xf9\xff\xfc\xff\xfe\xff\xfd\xff\xff\xff\xff\xff\x01\x00\x01\x00\xff\xff\xfc\xff\xfd\xff\x00\x00\x02\x00\x02\x00\x02\x00\xfe\xff\xfc\xff\xfc\xff\xff\xff\x02\x00\x04\x00\x05\x00\x05\x00\x05\x00\x06\x00\x05\x00\x05\x00\x04\x00\x03\x00\x00\x00\xfd\xff\xfd\xff\xfe\xff\xff\xff\xff\xff\x01\x00\xff\xff\xfe\xff\xff\xff\x00\x00\xff\xff\xfd\xff\xfa\xff\xfb\xff\xfd\xff\xfd\xff\xfd\xff\xfa\xff\xfa\xff\xfd\xff\xff\xff\x02\x00\x02\x00\x02\x00\x04\x00\x06\x00\x07\x00\x08\x00\x07\x00\x04\x00\x03\x00\x00\x00\xfe\xff\xfc\xff\xfc\xff\xff\xff\x02\x00\x02\x00\x01\x00\xfe\xff\xfe\xff\xff\xff\x00\x00\x01\x00\xfe\xff\xfb\xff\xf9\xff\xfa\xff\xfc\xff\xfe\xff\xff\xff\x01\x00\x03\x00\x06\x00\x05\x00\x02\x00\xff\xff\xff\xff\x00\x00\x02\x00\x01\x00\x00\x00\xfe\xff\xfe\xff\xfc\xff\xfc\xff\xfe\xff\x00\x00\x01\x00\x03\x00\x01\x00\x00\x00\xff\xff\x01\x00\x04\x00\x06\x00\x05\x00\x00\x00\xfd\xff\xfd\xff\xfd\xff\x00\x00\x02\x00\x02\x00\x02\x00\x02\x00\x03\x00\x02\x00\x02\x00\x00\x00\x01\x00\x02\x00\x00\x00\xfe\xff\xfc\xff\xfc\xff\xfe\xff\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x03\x00\x04\x00\x03\x00\x01\x00\x01\x00\x01\x00\x00\x00\xfe\xff\xfe\xff\xfc\xff\xfe\xff\x00\x00\x02\x00\x02\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\xff\xff\xfb\xff\xf9\xff\xf8\xff\xfa\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\x00\x00\x02\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xfe\xff\x02\x00\x05\x00\x06\x00\x04\x00\x01\x00\xff\xff\x00\x00\x01\x00\x04\x00\x03\x00\x01\x00\xfe\xff\xfe\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\x01\x00\x02\x00\x02\x00\x00\x00\xff\xff\xfe\xff\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\x01\x00\x03\x00\x04\x00\x06\x00\x06\x00\x04\x00\x03\x00\x03\x00\x05\x00\x06\x00\x04\x00\x00\x00\xfe\xff\xfd\xff\xfe\xff\xfe\xff\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xfd\xff\xfc\xff\xfb\xff\xfb\xff\xfb\xff\xfc\xff\xfc\xff\xfe\xff\xff\xff\x01\x00\x03\x00\x03\x00\x03\x00\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfd\xff\xfe\xff\xff\xff\x02\x00\x02\x00\x00\x00\xfe\xff\xfc\xff\xfd\xff\x01\x00\x02\x00\x03\x00\x02\x00\x02\x00\x03\x00\x03\x00\x03\x00\x03\x00\x03\x00\x02\x00\x01\x00\x02\x00\x04\x00\x02\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\xfe\xff\xfd\xff\xfe\xff\xfd\xff\xfd\xff\x00\x00\x01\x00\x02\x00\x03\x00\x04\x00\x03\x00\x03\x00\x02\x00\x02\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x03\x00\x03\x00\x01\x00\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xfc\xff\xfb\xff\xfb\xff\xfc\xff\xfe\xff\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\xfd\xff\xfd\xff\xfb\xff\xfb\xff\xfd\xff\xfe\xff\x00\x00\xff\xff\xff\xff\xfe\xff\x00\x00\x03\x00\x04\x00\x03\x00\x00\x00\xfe\xff\xfe\xff\x00\x00\x01\x00\x03\x00\x04\x00\x04\x00\x03\x00\x03\x00\x02\x00\x02\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\x02\x00\x03\x00\x02\x00\x01\x00\xff\xff\xff\xff\xff\xff\x02\x00\x03\x00\x05\x00\x03\x00\x02\x00\x01\x00\x02\x00\x02\x00\x03\x00\x00\x00\x00\x00\xff\xff\xfd\xff\xfc\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xfd\xff\xfc\xff\xfb\xff\xfd\xff\xff\xff\x02\x00\x02\x00\x02\x00\x03\x00\x04\x00\x04\x00\x02\x00\x00\x00\xfe\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\xfd\xff\xfd\xff\xfd\xff\xff\xff\x00\x00\x02\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfd\xff\xfd\xff\xfe\xff\x00\x00\x03\x00\x04\x00\x05\x00\x04\x00\x03\x00\x02\x00\x01\x00\x02\x00\x00\x00\x00\x00\x01\x00\x02\x00\x01\x00\x00\x00\x00\x00\x02\x00\x01\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\x01\x00\x02\x00\x02\x00\x02\x00\x01\x00\x03\x00\x02\x00\x02\x00\x01\x00\x01\x00\x00\x00\xfd\xff\xfe\xff\x01\x00\x01\x00\x03\x00\x03\x00\x00\x00\xfe\xff\xfe\xff\xfd\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x03\x00\x04\x00\x04\x00\x02\x00\xff\xff\xff\xff\xff\xff\x01\x00\x02\x00\x04\x00\x04\x00\x02\x00\x02\x00\x01\x00\x01\x00\xff\xff\xfd\xff\xfe\xff\xfe\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x02\x00\x01\x00\x02\x00\xfe\xff\xfd\xff\xfe\xff\xfd\xff\x00\x00\x02\x00\x03\x00\x03\x00\x02\x00\x02\x00\x04\x00\x02\x00\x02\x00\x01\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\x01\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfc\xff\xfc\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\x01\x00\x02\x00\x04\x00\x05\x00\x04\x00\x03\x00\x00\x00\xff\xff\x00\x00\x03\x00\x02\x00\x03\x00\x02\x00\x01\x00\x01\x00\x01\x00\x02\x00\x03\x00\x02\x00\xff\xff\xff\xff\xfe\xff\xfd\xff\xfe\xff\xfe\xff\x00\x00\x00\x00\x01\x00\x02\x00\x01\x00\x02\x00\x02\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x03\x00\x04\x00\x05\x00\x03\x00\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xff\xff\x01\x00\x01\x00\x01\x00\xff\xff\xfe\xff\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\xff\xff\xfe\xff\xfe\xff\xfc\xff\xfd\xff\xfe\xff\x00\x00\x02\x00\x01\x00\x00\x00\xfd\xff\xfe\xff\xfc\xff\xfc\xff\xfd\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x00\x00\x03\x00\x04\x00\x03\x00\x01\x00\xff\xff\xfe\xff\x00\x00\x02\x00\x04\x00\x02\x00\x03\x00\x02\x00\x02\x00\x02\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x02\x00\x03\x00\x03\x00\x04\x00\x02\x00\xff\xff\xfe\xff\xfe\xff\x00\x00\x01\x00\x01\x00\x02\x00\x04\x00\x03\x00\x02\x00\x01\x00\x01\x00\x03\x00\x03\x00\x03\x00\xff\xff\xfe\xff\xfd\xff\xff\xff\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x02\x00\x00\x00\xfe\xff\xfe\xff\xfc\xff\xfc\xff\xfc\xff\xfc\xff\xfd\xff\x00\x00\x02\x00\x01\x00\x01\x00\x00\x00\x02\x00\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\xff\xff\xfc\xff\xfd\xff\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfc\xff\xfd\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\x01\x00\x02\x00\x02\x00\x02\x00\x03\x00\x04\x00\x04\x00\x03\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x02\x00\x03\x00\x06\x00\x05\x00\x03\x00\x02\x00\x02\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfd\xff\xfd\xff\xfd\xff\xff\xff\x01\x00\x01\x00\xff\xff\xff\xff\x01\x00\x04\x00\x02\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xfc\xff\xfc\xff\xfe\xff\x00\x00\x01\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xfd\xff\xfe\xff\x00\x00\xff\xff\xfe\xff\xfc\xff\xfe\xff\x00\x00\x03\x00\x03\x00\x03\x00\x03\x00\x01\x00\x02\x00\x00\x00\x01\x00\x01\x00\xff\xff\xff\xff\xfe\xff\xfb\xff\xfa\xff\xfb\xff\xfe\xff\x00\x00\x02\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x02\x00\x05\x00\x07\x00\x08\x00\x07\x00\x05\x00\x04\x00\x03\x00\x01\x00\xff\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xff\xff\x01\x00\x02\x00\x01\x00\x02\x00\x01\x00\x01\x00\x02\x00\x00\x00\xfe\xff\xfb\xff\xfa\xff\xfa\xff\xfd\xff\xfe\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00\x02\x00\x01\x00\x00\x00\xfe\xff\x00\x00\x00\x00\xff\xff\xfd\xff\xfb\xff\xfd\xff\x00\x00\x02\x00\x02\x00\x02\x00\x02\x00\x01\x00\x02\x00\x01\x00\x00\x00\xff\xff\xfd\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xfe\xff\x01\x00\x01\x00\x02\x00\x02\x00\x04\x00\x04\x00\x04\x00\x02\x00\x01\x00\x02\x00\x01\x00\x02\x00\xff\xff\xfc\xff\xfe\xff\xff\xff\x02\x00\x04\x00\x07\x00\x05\x00\x03\x00\x03\x00\x01\x00\xff\xff\xfd\xff\xfc\xff\xfc\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\x01\x00\x03\x00\x02\x00\x02\x00\x03\x00\x02\x00\x02\x00\x00\x00\xff\xff\xfd\xff\xfb\xff\xfd\xff\xfc\xff\xfd\xff\xfd\xff\xfe\xff\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xfd\xff\xfc\xff\xfc\xff\xfc\xff\xfd\xff\xff\xff\x01\x00\x02\x00\x03\x00\x05\x00\x05\x00\x02\x00\xff\xff\xfc\xff\xfd\xff\xfe\xff\xff\xff\xfe\xff\xfd\xff\xfd\xff\xfd\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\x00\x00\x03\x00\x04\x00\x03\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x00\x00\xff\xff\x01\x00\x02\x00\x06\x00\x06\x00\x06\x00\x06\x00\x06\x00\x06\x00\x03\x00\x02\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfd\xff\xfc\xff\xfd\xff\xff\xff\x02\x00\x02\x00\x02\x00\x01\x00\x01\x00\x01\x00\xfe\xff\xfe\xff\xfb\xff\xfc\xff\xfc\xff\xfc\xff\xfb\xff\xfd\xff\xff\xff\x02\x00\x03\x00\x03\x00\x03\x00\x03\x00\x01\x00\xfe\xff\xfd\xff\xfb\xff\xfa\xff\xfc\xff\xfc\xff\xfc\xff\xfe\xff\x00\x00\x01\x00\x02\x00\x03\x00\x02\x00\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfc\xff\xfc\xff\xfd\xff\xff\xff\x01\x00\x02\x00\x01\x00\x03\x00\x02\x00\x02\x00\x01\x00\xff\xff\xff\xff\xff\xff\x01\x00\x02\x00\x02\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01\x00\x03\x00\x03\x00\x03\x00\x04\x00\x05\x00\x06\x00\x04\x00\x03\x00\x03\x00\x02\x00\x02\x00\x01\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x02\x00\x03\x00\x03\x00\x04\x00\x04\x00\x03\x00\x02\x00\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfc\xff\xfb\xff\xfe\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xfd\xff\xfd\xff\xfb\xff\xfb\xff\xfb\xff\xfb\xff\xfb\xff\xfa\xff\xfe\xff\x01\x00\x03\x00\x03\x00\x02\x00\x02\x00\x01\x00\xff\xff\xfd\xff\xfc\xff\xfa\xff\xfa\xff\xfc\xff\xfc\xff\xfd\xff\x00\x00\x01\x00\x03\x00\x02\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xfd\xff\xff\xff\xfe\xff\x00\x00\x01\x00\x01\x00\x02\x00\x04\x00\x06\x00\x06\x00\x06\x00\x05\x00\x03\x00\x02\x00\x01\x00\x04\x00\x04\x00\x02\x00\x00\x00\xfe\xff\xff\xff\x01\x00\x03\x00\x02\x00\x02\x00\x03\x00\x04\x00\x04\x00\x02\x00\x00\x00\xfe\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfd\xff\xff\xff\x00\x00\x02\x00\x03\x00\x02\x00\x01\x00\x02\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfd\xff\xfc\xff\xfb\xff\xfb\xff\xfb\xff\xfd\xff\xff\xff\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\xfe\xff\xfd\xff\xfd\xff\xfb\xff\xfb\xff\xfb\xff\xfb\xff\xfc\xff\xff\xff\x02\x00\x02\x00\x03\x00\x02\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xfe\xff\xfd\xff\xfc\xff\xff\xff\x01\x00\x04\x00\x06\x00\x05\x00\x04\x00\x04\x00\x02\x00\x01\x00\x01\x00\x00\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x03\x00\x03\x00\x05\x00\x05\x00\x05\x00\x05\x00\x03\x00\x01\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\xfe\xff\xfc\xff\xfc\xff\xfd\xff\xfd\xff\xfd\xff\xfb\xff\xfb\xff\xfd\xff\xff\xff\x02\x00\x03\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x00\x00\xfe\xff\xfc\xff\xfb\xff\xfd\xff\xfd\xff\xfe\xff\xfd\xff\xfe\xff\x00\x00\x00\x00\x02\x00\x01\x00\xff\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\x02\x00\x05\x00\x04\x00\x05\x00\x06\x00\x05\x00\x04\x00\x04\x00\x02\x00\x02\x00\x00\x00\xfe\xff\xfd\xff\xfc\xff\xfd\xff\xff\xff\x00\x00\x02\x00\x02\x00\x04\x00\x05\x00\x04\x00\x01\x00\xff\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x00\x00\x02\x00\x03\x00\x03\x00\x04\x00\x03\x00\x03\x00\x02\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfd\xff\xfb\xff\xfb\xff\xfb\xff\xfe\xff\xff\xff\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\xff\xff\xfd\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\x01\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xfe\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x02\x00\x04\x00\x06\x00\x04\x00\x04\x00\x02\x00\x03\x00\x04\x00\x03\x00\x00\x00\xff\xff\xfd\xff\xfd\xff\xff\xff\xff\xff\x02\x00\x04\x00\x06\x00\x06\x00\x04\x00\x01\x00\xff\xff\xff\xff\xfe\xff\xfc\xff\xfc\xff\xfd\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x01\x00\x02\x00\x01\x00\x00\x00\xff\xff\xfc\xff\xfb\xff\xfd\xff\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xfe\xff\x00\x00\x01\x00\x02\x00\x02\x00\x02\x00\x03\x00\x02\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x01\x00\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\x02\x00\x02\x00\x01\x00\xff\xff\xff\xff\x01\x00\x00\x00\x02\x00\x04\x00\x06\x00\x06\x00\x06\x00\x05\x00\x03\x00\x03\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xfd\xff\xfd\xff\xfc\xff\xff\xff\x03\x00\x04\x00\x04\x00\x03\x00\x02\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x01\x00\x02\x00\x03\x00\x04\x00\x03\x00\x01\x00\xfe\xff\xfc\xff\xfb\xff\xfd\xff\xfc\xff\xfc\xff\xfc\xff\xfd\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xfd\xff\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x02\x00\x04\x00\x03\x00\x03\x00\x01\x00\x01\x00\x03\x00\x03\x00\x02\x00\x01\x00\xff\xff\xff\xff\xfe\xff\x00\x00\xff\xff\x02\x00\x03\x00\x04\x00\x04\x00\x02\x00\x01\x00\xfe\xff\xfe\xff\xfd\xff\xfe\xff\xfe\xff\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\x01\x00\x02\x00\x02\x00\x01\x00\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x02\x00\x04\x00\x03\x00\x02\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfd\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfc\xff\xfd\xff\xfe\xff\xfe\xff\x00\x00\xfe\xff\xff\xff\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x04\x00\x04\x00\x04\x00\x03\x00\x02\x00\x02\x00\x02\x00\x02\x00\x01\x00\x00\x00\xfe\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\x01\x00\x02\x00\x02\x00\x02\x00\x00\x00\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xfd\xff\xfe\xff\x00\x00\x01\x00\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfe\xff\xfd\xff\xfd\xff\xff\xff\x01\x00\x01\x00\x01\x00\x02\x00\x03\x00\x02\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x02\x00\x02\x00\x01\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\x02\x00\x03\x00\x04\x00\x02\x00\x00\x00\xff\xff\x01\x00\x02\x00\x03\x00\x02\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\xff\xff\x01\x00\x01\x00\x02\x00\x01\x00\x00\x00\x01\x00\x02\x00\x03\x00\x03\x00\x01\x00\x01\x00\xff\xff\x00\x00\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfe\xff\xff\xff\xff\xff\x01\x00\x00\x00\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\x01\x00\x02\x00\x01\x00\x02\x00\x01\x00\x02\x00\x03\x00\x03\x00\x02\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x01\x00\x02\x00\x03\x00\x03\x00\x02\x00\x01\x00\x00\x00\x00\x00\x01\x00\x02\x00\x02\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xfd\xff\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x03\x00\x03\x00\x02\x00\x02\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfd\xff\xfd\xff\xff\xff\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xfc\xff\xfd\xff\xfe\xff\x00\x00\x01\x00\x00\x00\xff\xff\xfe\xff\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x03\x00\x02\x00\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x02\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\xff\xff\x00\x00\xff\xff\xfe\xff\xfd\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x02\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x03\x00\x02\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\xfe\xff\xff\xff\x02\x00\x02\x00\x02\x00\x00\x00\x00\x00\xff\xff\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x03\x00\x03\x00\x03\x00\x01\x00\x01\x00\x02\x00\x02\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\x01\x00\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x03\x00\x03\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\xfd\xff\xfd\xff\xfd\xff\xfe\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xfd\xff\xff\xff\xff\xff\xfe\xff\xfd\xff\xfd\xff\xfe\xff\x00\x00\x01\x00\x02\x00\x02\x00\x02\x00\x02\x00\x02\x00\x01\x00\x00\x00\xff\xff\x01\x00\x01\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\xfe\xff\xfd\xff\xfc\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\x02\x00\x01\x00\x01\x00\x02\x00\xff\xff\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x00\x00\x01\x00\xff\xff\x00\x00\x02\x00\x00\x00\xff\xff\x00\x00\x00\x00\x02\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\xff\xff\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\xff\xff\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfd\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xfe\xff\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x03\x00\x02\x00\x01\x00\x00\x00\xfd\xff\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x02\x00\x02\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x03\x00\x02\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xfd\xff\xfd\xff\xfd\xff\xfc\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x01\x00\x02\x00\x02\x00\x03\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xfe\xff\x00\x00\x01\x00\x03\x00\x03\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x02\x00\x02\x00\x03\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x03\x00\x02\x00\x02\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xfd\xff\xfc\xff\xfd\xff\xfd\xff\xfb\xff\xfd\xff\xfe\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfd\xff\xfe\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\xfe\xff\x00\x00\xff\xff\xfe\xff\xfe\xff\x00\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xfe\xff\xff\xff\xfe\xff\xfd\xff\xfc\xff\xfc\xff\xfe\xff\xfd\xff\xff\xff\xff\xff\xff\xff\x01\x00\x03\x00\x02\x00\x02\x00\x01\x00\x00\x00\x01\x00\x01\x00\x02\x00\x00\x00\xff\xff\xff\xff\x00\x00\x02\x00\x01\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x02\x00\x01\x00\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x02\x00\x02\x00\x02\x00\x02\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01\x00\x01\x00\x02\x00\x03\x00\x03\x00\x02\x00\x02\x00\x01\x00\x01\x00\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x02\x00\x01\x00\x02\x00\x02\x00\x02\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x02\x00\x02\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xff\xff\x01\x00\x02\x00\x02\x00\x01\x00\x02\x00\x02\x00\x01\x00\x03\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\xfe\xff\x00\x00\xff\xff\xff\xff\x00\x00\xfe\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfe\xff\xff\xff\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xfe\xff\xff\xff\xff\xff\xfd\xff\xfd\xff\xfe\xff\xfe\xff\x00\x00\x00\x00\x01\x00\x02\x00\x02\x00\x02\x00\x03\x00\x03\x00\x02\x00\x03\x00\x02\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x01\x00\xff\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x02\x00\x02\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xfe\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x02\x00\x01\x00\x02\x00\x01\x00\x02\x00\x02\x00\x02\x00\x03\x00\x02\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfd\xff\xfe\xff\xfd\xff\xfe\xff\xfd\xff\xfd\xff\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x02\x00\x01\x00\x02\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x02\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x03\x00\x02\x00\x02\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfd\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xfd\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x02\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xfd\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x02\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xfe\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xfe\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xfe\xff\xfd\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x03\x00\x02\x00\x02\x00\x02\x00\x02\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x02\x00\x00\x00\x01\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x02\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x02\x00\x02\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x01\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xfe\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x02\x00\x00\x00\x00\x00\x02\x00\x01\x00\x00\x00\x01\x00\x01\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xfe\xff\xfd\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x02\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x02\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x01\x00\x02\x00\x02\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xfe\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x02\x00\x01\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\x01\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xfe\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x02\x00\x03\x00\x02\x00\x02\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xfe\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x02\x00\x02\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x01\x00\xff\xff\xfe\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x02\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\xff\xff\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x02\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xfe\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xfe\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xfe\xff\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x02\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xfe\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x02\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x02\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xfe\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x02\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x02\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfe\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\xfe\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xfe\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xfe\xff\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\xff\xff\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00' +# --- +# name: test_pre_recorded_message + b'\xfe\xff\x04\x00\x05\x00\x03\x00\x04\x00\x03\x00\x02\x00\x00\x00\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xfe\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xfe\xff\xfc\xff\xfc\xff\xfc\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\xfd\xff\xfe\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x01\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x03\x00\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\x00\x00\xff\xff\x00\x00\x00\x00\xfe\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\xfe\xff\x00\x00\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x03\x00\x02\x00\x03\x00\x00\x00\x00\x00\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x03\x00\x02\x00\x02\x00\x01\x00\xff\xff\x01\x00\x01\x00\x01\x00\xfe\xff\xfc\xff\xff\xff\x00\x00\xfe\xff\x00\x00\x00\x00\xfd\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\xfe\xff\xfd\xff\xfe\xff\x00\x00\xff\xff\xfd\xff\xfd\xff\xfe\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\x00\x00\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\xff\xff\xfe\xff\xfe\xff\xfe\xff\xfe\xff\xfd\xff\xfc\xff\xfe\xff\xfd\xff\xfe\xff\xfc\xff\xfc\xff\xfe\xff\xfd\xff\xfc\xff\xfe\xff\xfc\xff\xfc\xff\xfd\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\xfe\xff\xfe\xff\xff\xff\xff\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xfe\xff\x00\x00\xff\xff\xff\xff\x00\x00\xfe\xff\xfe\xff\x00\x00\x00\x00\xfe\xff\xff\xff\xff\xff\xfe\xff\xff\xff\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\xfe\xff\xfe\xff\x02\x00\x02\x00\x01\x00\x02\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x01\x00\xff\xff\x00\x00\x02\x00\x01\x00\xff\xff\x00\x00\x00\x00\x00\x00\x02\x00\x01\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x00\x00\xff\xff\xff\xff\x00\x00\x01\x00\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xfe\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x02\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\xff\xff\x01\x00\x01\x00\x01\x00\x00\x00\xff\xff\xfd\xff\xfe\xff\xff\xff\xff\xff\xfd\xff\xfd\xff\xfe\xff\xfe\xff\xfe\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\xff\xff\xfe\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xff\xff\x00\x00\xff\xff\xfe\xff\x00\x00\xfe\xff\xfc\xff\xfd\xff\xfe\xff\xfd\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\xfd\xff\xff\xff\xff\xff\xfd\xff\xfc\xff\xfd\xff\xfe\xff\xfe\xff\xfc\xff\xfc\xff\xff\xff\xfe\xff\xfc\xff\xfa\xff\xfb\xff\xfb\xff\xfb\xff\xff\xff\xfe\xff\x00\x00\x00\x00\xff\xff\x00\x00\x00\x00\xfe\xff\x00\x00\xff\xff\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xfa\xff\xfe\xff\x00\x00\xfd\xff\x00\x00\x00\x00\xff\xff\x00\x00\xfd\xff\xfa\xff\xfc\xff\xfc\xff\xfa\xff\xfe\xff\xfd\xff\xf8\xff\xf7\xff\xfa\xff\xfe\xff\xfa\xff\xf8\xff\xf9\xff\xfa\xff\xfd\xff\x00\x00\x00\x00\x00\x00\xfb\xff\xfb\xff\xfa\xff\xfd\xff\xff\xff\xff\xff\x01\x00\xfc\xff\xff\xff\xf8\xff\xff\xff\x00\x00\xf3\xff\xfd\xff\xf3\xff\xfb\xff\x01\x00\xff\xff\xfa\xff\x02\x00\xf4\xff\xeb\xff\xfc\xff\xf7\xff\xe8\xff\xfb\xff\xf8\xff\xf7\xff\r\x00\xfe\xff\x02\x00\xfe\xff\xf9\xff\xfa\xff\xf8\xff\x00\x00\xf6\xff\xfe\xff\x02\x00\x05\x00\x04\x00\xfa\xff\xf4\xff\xe8\xff\xf3\xff\x06\x00\xf9\xff\x06\x00\n\x00\xf8\xff\xfa\xff\x01\x00\xf4\xff\xfd\xff\xf7\xff\xf4\xff\x01\x00\x05\x00\x02\x00\x04\x00\xfc\xff\xef\xff\x03\x00\xf3\xff\xfc\xff\x08\x00\x04\x00\xfd\xff\x08\x00\x04\x00\x00\x00\x00\x00\x06\x00\x03\x00\xfd\xff\x04\x00\x15\x00\x06\x00\x12\x00\x15\x00\x05\x00\x04\x00\x05\x00\x05\x00\x02\x00\x07\x00\x05\x00\xfc\xff\xfd\xff\x06\x00\xff\xff\xf8\xff\x01\x00\xf2\xff\xe6\xff\xf4\xff\xef\xff\xfb\xff\xfc\xff\xf2\xff\xec\xff\xe4\xff\xe6\xff\xf9\xff\xfa\xff\xee\xff\xea\xff\xe9\xff\xf8\xff\x06\x00\x0b\x00\xe9\xff\x03\x00\xea\xff\xfc\xff\x0f\x00\x00\x00\x13\x00\xe6\xff\xfe\xff\x10\x00\x12\x00\xfd\xff\x03\x00\xf1\xff\xfb\xff\x18\x00\x1f\x00\x08\x00\xfa\xff\xf9\xff\xf6\xff\r\x00\x17\x00\x03\x00\xfb\xff\xfc\xff\xf3\xff,\x00\x1c\x00\xf8\xff\xed\xff\x05\x00\x10\x00$\x00@\x00\x19\x00\x00\x00\x19\x004\x00G\x00]\x001\x00\x07\x005\x00J\x00X\x00\\\x00\x03\x00\xf6\xff\x13\x007\x00]\x008\x00\xef\xff\xeb\xff\x00\x00#\x00\x85\x00S\x00\xb6\xff\xcf\xff\x1a\x00\xc3\xff\xb6\x00\x8a\x00^\xff\xe0\xff\xfc\xff\xba\xff4\x00n\x00\xc5\xff5\xff\xf4\xffR\x00\xe8\xff-\x00\x11\x00z\xff\xb0\xff\x92\x00\xeb\xff\xca\xff\t\x00\xa0\xff\xcb\xff6\x00L\x00\x02\x00\x91\xff\xdb\xff\xd3\xff\xed\xff\xc0\xff\x8b\xff\x97\x00\xe2\xff\x16\x00B\x00\xbc\xff\xfb\xff1\x00\xe4\xff\xed\xff\x95\x00\xcc\x00H\x00>\x00\x03\x00g\xff\x18\x01\x8c\x01\xa8\xff?\xff\xc6\xfeO\xff\xaa\x00\x00\x01Q\xff\xaf\xfe\xce\xfe\xd8\xfe\x7f\xff\xce\xfe\x93\xfd\xb6\xfc\x9c\xfd\xb1\xff\xf7\x00H\x00D\xfe\x8d\xfc\xc2\xfco\xffG\x01r\x00\x94\xffG\x007\x01,\x02\xc0\x02\x18\x01\xaa\xff\xf0\xffS\x00\xbf\x029\x03\xa0\x01p\x00/\x00\xc4\xff\xb3\xff\xd4\xffU\xfdB\xfd\x8b\xfe\xfb\xfe\x86\xfe\x0e\xfd\xba\xfd\xb7\xfd\x8e\xfc\xf0\xfc\x88\xfd"\xfe\'\xfe]\xfe\xfb\xfe\x13\x00\x08\x01\xe1\x00&\xff\xf0\xfe\x05\x015\x01E\x02:\x02G\x02*\x02E\x02\xcf\x02\x1f\x03\xcc\x03\x15\x03N\x03\xdf\x03\x82\x04X\x05P\x05f\x04}\x04Q\x06\xe3\x06\x9a\x06\x8e\x06\xc7\x05a\x05\xe6\x05-\x06g\x066\x06\x9e\x05\xf4\x03\x9b\x03\x14\x03e\x02\x99\x01\xdf\xff\xa1\xfe{\xfe%\xfe2\xfd/\xfc\xc3\xfa-\xf9\xe2\xf8\xa2\xf8\x8d\xf8\xa0\xf9B\xf9\x15\xf9\xf3\xf8<\xf9y\xfa\xe1\xfa\xce\xfa#\xfb\xa1\xfc\xf3\xfd\xec\xfeE\xff\xc5\xfe\x9f\xfe8\xff\x19\xff\xff\xfe5\xff\xd8\xfe\x90\xfe\x87\xfd\xb5\xfcR\xfc\x18\xfc\xae\xfaI\xf9/\xf9\x14\xf9>\xf9\xb6\xf8d\xf8o\xf8E\xf8\x18\xf8c\xf8g\xfaA\xfb\xe2\xfak\xfb\xda\xfbM\xfd\xa0\xfe\x1c\xfft\xfe\xee\xfe\xf9\xff\x0e\x00y\x00*\x00P\xff\xfa\xfe\x84\xfe\xef\xfd\xd4\xfe\xb3\xfdf\xfd\xfa\xfbq\xfb\xfa\xfb \xfd{\xfd\xe4\xfc\xb3\xfc\xe5\xfa\x97\xfd\xee\xffP\x00o\x01o\x00\xfc\x01\x13\x04S\x05R\x08\x13\x07\xda\x08\xa6\t`\x0cX\x11\x1c\x0f\x88\x0b\xb5\x04\x17\x08\x8f\x17\x8f)\x9f4G+\xa0\x1c\x9f\x12\xe9\x13\x88#\xac+++\x94"\x8f\x1bM\x1f\xa0\x1e\x05\x17\xf1\x04\x17\xf4V\xec\x13\xf0\x0e\xfaJ\xfe&\xf9\xcb\xe7\x96\xd7\xa6\xcf\xab\xd2\xd2\xd9\x95\xdbT\xd9J\xdb\x84\xe2\x98\xe8\x06\xeb8\xe8J\xe5\x93\xe5\xfa\xea\xa2\xf8:\t\xe9\x11\xd3\x10c\n\x97\x05\x1a\x08\xbb\r\x94\r\x15\x0e\xef\rz\x0eU\x10\xc1\r+\x08\xbd\xfd(\xf24\xeaj\xec\x1f\xf3H\xf7\x0e\xf5\x07\xed\xcb\xe6\x9f\xe2\xe1\xe2\xdc\xe5&\xe87\xed\xcb\xf1\x13\xf8\xf9\xfe*\x039\x04\xda\x00h\xff\xe3\x03\xdf\x0eY\x18\xf4\x1d\xa7\x1d8\x1a=\x16\xad\x12\xa8\x118\x11\xa7\x10\xaa\x0e\xfb\r`\x0c}\n\xd2\x06|\xfe@\xf5\x11\xf1U\xf2K\xf6\x9c\xf9\xf3\xf8\xf8\xf5\xc6\xf2\xb5\xf0\x1a\xf2\xb6\xf4\xa5\xf6\x87\xf7~\xf9\xa3\xfd1\x01{\x03\xff\x01\xfc\xfc\x1c\xfb\xa7\xfb\xf7\xfd\x85\x00\xb1\x00\xaf\xfe\x01\xfc\xf1\xf9x\xf7\xab\xf6c\xf4M\xf2f\xf2?\xf2\x1f\xf7 \xf8\xf7\xf7!\xf6W\xf1@\xf34\xf5\xce\xf8\xdb\xfdA\x00\x9f\x02[\x03\x18\x04\x0b\x01\xb0\x02\x0c\x06>\x08 \x0b\xfa\n\xc6\x0e\xaa\x12K\x13>\x12y\x11o\x11C\x17\xe6\':7w9 /\x0e$\'$\xcb)\x04,\xc8+\xc3+\x9a)t"`\x18\xf0\x0f\x88\x07s\xfc\xe7\xef\xf7\xe7\x9a\xe9\x0c\xed3\xec\xcf\xe4*\xda\x11\xd1\xab\xcc\xf6\xcc\x00\xd1^\xd8\x93\xdf\xb2\xe4\x08\xe75\xea\xad\xefZ\xf3`\xf4\x0c\xf6\x05\xfd/\tU\x13\xaa\x17\x06\x16,\x13-\x10\xdb\r\x18\x0c"\n\xe7\t\x8b\x08\xef\x04\x19\x00P\xfb\xd8\xf5\xbe\xed{\xe4C\xe0\x04\xe1\xff\xe2\xe4\xe2\xaf\xe2\x89\xe2N\xe2\x9e\xe2 \xe4{\xe8\x1e\xef\xf5\xf5\x1a\xfc\xf2\x01\x9e\x08\xba\x0fh\x12\xf5\x14\xcc\x17\xb1\x1cb \xb8 9!\xa6 |\x1f\xbe\x1bu\x17\xef\x13~\x0fo\nt\x05\xef\x00;\xfd\xe3\xf9L\xf6\xab\xf1&\xef\xc7\xed\xf0\xec\x1a\xed\xf4\xec;\xee/\xf0\xa9\xf22\xf68\xf9\xf3\xfax\xfb\xd2\xfd(\xff\x00\x01\x9d\x02\xad\x02T\x03R\x02\xf6\x00$\xff\\\xfd\x9e\xfa\xef\xf6\x91\xf4\x1d\xf3K\xf3\x80\xf2\x88\xf0X\xed#\xec\x8f\xebb\xeaK\xeb\xdc\xec;\xf0\xf2\xf3\x15\xf5\xfe\xf7\xb2\xfa\xf3\xfb(\xff\xc9\x00`\x03]\t\x0b\x0cW\x10O\x14\xbf\x14\x9c\x14\xb6\x11\x81\x11X\x14y\x17f\x1b\xce&c9vB\x96:,&\xd5\x1b\xbc%\xf20H4.3\x983\xaa0P%B\x15\xa8\n/\x03i\xf8#\xf0\xcf\xf03\xf9"\xfc\x8a\xf1\xb0\xde{\xd1\xf9\xcc\xa9\xcc>\xcfw\xd67\xe1\xb8\xe7F\xe7\x06\xe6\xf1\xe7\x0c\xe9C\xe8\x0c\xea\xa2\xf2\x83\x00k\r\x91\x13\xb1\x13X\x0f\xa4\n\x13\x07(\x05\xe9\x06L\n\x00\x0b\xbb\x08\xe9\x04\x01\xffN\xf7\x97\xee\'\xe6\x9c\xe0\x95\xdeu\xdfX\xe3\x14\xe5\xba\xe4\xee\xe1\xad\xddE\xdc+\xe0\x10\xe8\xab\xf0\xbc\xf7\x06\xfet\x05\xdf\n\xc3\x0fH\x13)\x16W\x18\x7f\x1c\xc3"\x14)\xaf+<)k$0\x1e8\x19V\x15\r\x13\xd7\x0f3\x0c\x0e\x08\xb8\x01l\xfa\xe3\xf3\x05\xef\x17\xec_\xeah\xea\xa6\xeb\xfd\xec`\xeex\xef\x82\xf0\xcf\xf0U\xf2\x9d\xf5S\xfa\x89\xff\x1f\x02[\x03>\x039\x02L\x01\x0b\x00\x1e\x00\x11\x00\xb5\xfe~\xfcv\xf9!\xf7\xb5\xf3\xde\xf0Z\xed\xac\xeat\xe9\xc3\xe8\xdd\xe9\xf5\xe9\xe5\xe9\xce\xe8+\xe9_\xeb\x0f\xee\xa8\xf3x\xf6\xc7\xf9\x8d\xfc`\xfe\xd3\x03R\tA\x0f\x15\x12C\x12\xdb\x12\xfd\x14%\x1bK\x1eN\x1f\xd3\x1f\x08#\xa5.\xe7<\xb7D\x99?\x101*&\xc8&O.N3\xac2w/\x03(\xf0\x1c\xbe\x0e\xd9\x03\x90\xfd\x9f\xf5*\xefz\xeb\x04\xed\x81\xee\xac\xe9\x04\xe0\xcd\xd4`\xcdL\xcc7\xd2x\xdb0\xe4?\xea\x03\xeb\xe6\xe9\x1d\xeaH\xed\xd2\xf2\xfe\xf6\xc3\xfc\x11\x04\xa6\x0cc\x12H\x14\'\x12\x06\r!\x08h\x04\x1c\x03\x0c\x03\x10\x03{\x02\x18\xff?\xf8\xe8\xef\x1a\xe7\x00\xe1\x1e\xddb\xdb\xcf\xdby\xdd)\xe0=\xe2T\xe2\xf5\xe1\xac\xe2\xcf\xe4\xd4\xe8\xbc\xef?\xfa\xf3\x03k\x0cF\x12A\x16R\x18F\x19U\x1c\xca w%\x9a(\x99)])S&(!\xa5\x1aD\x14\x12\x0fP\x0b\x12\x08g\x04q\x00\xa2\xfb\xf8\xf5x\xef1\xea^\xe7\x82\xe7\x04\xe9p\xeb\xc0\xed\xf6\xef\x94\xf1\x94\xf2#\xf3v\xf4k\xf7j\xfb\xbf\xff\xcc\x03N\x07\x1f\x08\xbc\x06\x03\x04\xe8\x01\xe7\x00\r\x00\xf5\xfeo\xfdE\xfbl\xf8\x8e\xf5|\xf1;\xed\'\xea\n\xe8,\xe7\xe3\xe7,\xe9d\xea`\xea#\xe9\xf5\xe8\x88\xea\x11\xede\xf2\xcb\xf7`\xfc\xda\xff7\x00l\x01H\x04g\tX\x0f\x93\x13\x1c\x162\x18S\x1c\xa5!\xa5,&=\xe5J\x93L\xaf>[1\x0e1\x819x?j>\x0b;\x8c6\xc3,\\\x1de\x0e\xb3\x04X\xfc8\xf35\xeb_\xe87\xebR\xeb\xf2\xe2\x8e\xd4\x8c\xc9\xad\xc6\xb1\xc9\xe4\xce\xc2\xd5c\xde\x8d\xe5x\xe8\xc8\xe8\xd7\xe9\xc7\xed\x9a\xf2\x15\xf7\xa0\xfc\x87\x04\xc1\x0fd\x19V\x1d\x97\x19a\x12\x93\x0b\xee\x06\xf7\x04E\x05\xe8\x06\x05\x07\xbb\x02\xb6\xf9\xbd\xee|\xe5P\xe0\xa5\xdc\x98\xd9*\xd8\x86\xd9;\xdc\x05\xde>\xde]\xdeg\xdf\xd7\xe1\xea\xe6\r\xeeE\xf7`\x01 \nM\x0fI\x12C\x15T\x1a\x1a \x91$q\'\xa6)\x89*H)\xb4%\xf8 6\x1d\x80\x19\xe1\x14d\x0f|\n\x8f\x06\x83\x02[\xfc\xd1\xf5\'\xf0T\xec\x99\xea4\xea\xad\xeaP\xeb1\xec\xfa\xec\x9a\xed\xb2\xeeo\xf0|\xf3]\xf7!\xfb8\xff\xe3\x02\xed\x05\xd4\x07\xc2\x07b\x06\x9a\x04\xc7\x03\x97\x03\xcc\x03\xad\x02\xe1\xff\xe6\xfb0\xf7X\xf3G\xf0t\xed\xcb\xea\x80\xe8\x08\xe7b\xe6\xab\xe6J\xe7\xef\xe75\xe9\xc1\xea\xde\xec\xcd\xef\xd2\xf2\x93\xf5\xdc\xf7#\xfa\xce\xfd\xc7\x02:\x07Y\x0b\xc3\x0e\xc8\x10\x82\x11]\x11\xb9\x12\x98\x16 \x1c"%b3+C\xd8JVD\x845\xd2*\x89)I-\xca3X;\xc0?\xc3;\xfd,\r\x19\x8f\x08r\xfd\t\xf8c\xf5^\xf2\x06\xf0W\xedR\xea\x0c\xe5\xdb\xdc\xe9\xd2\xee\xca\x87\xc6\xf4\xc5\xe7\xcaP\xd5\xb0\xe1\x8c\xeaM\xec\xdb\xe8\xc5\xe5\x99\xe5U\xe9G\xf1\xd1\xfc\x8b\t\xff\x12+\x16\x90\x15\xc9\x13\xe8\x10J\x0cQ\x07\x99\x04U\x05\x85\x07\x06\t\xb2\x08\xd9\x04\xa1\xfck\xf1)\xe6\xe3\xddA\xda:\xdc\xe1\xe1\xb4\xe7\x93\xe9\x90\xe7\x0f\xe5K\xe3b\xe3\x14\xe6\xc6\xeb=\xf4"\xfdI\x05\xaa\x0c\xd7\x12\xaf\x17}\x1a\xe9\x1a\x8e\x1a\x07\x1b\x97\x1e\xc6#\xc3(M*\'(\xe4"I\x1b\xd4\x13\x9c\r\\\n\xdc\x08\xcd\x07\xaa\x04b\xffH\xf9\xba\xf3f\xef\x0b\xec\xb7\xe9\x89\xe8=\xe8\x8a\xe8\xc4\xe9\x18\xec\xf7\xeeK\xf1\x00\xf3\x02\xf4\x11\xf5\x87\xf6\xd9\xf9\x0b\xfe\xdf\x01b\x04\xee\x04\xd1\x04o\x03\xfc\x00\xd8\xfe\x9f\xfd\x06\xfdd\xfc,\xfa\xa9\xf7A\xf5M\xf3\x85\xf1\xab\xef\xac\xed\xa8\xec\xd0\xecp\xed\xae\xee\xda\xefn\xf1\x8a\xf3"\xf5\xc6\xf6\x19\xf9\x0f\xfc_\xff$\x02\x0e\x05d\x08_\x0c\x01\x10&\x12\x17\x13Q\x13\x14\x142\x15\x0b\x18b\x1fK,\xd89\x86@\x8e<\x991\xf4\'t$\xb5&\xcf+\xfa1\xa16\x036\x7f-s\x1e.\x0e\x1b\x02\x97\xfb\x81\xf93\xf8\x1d\xf55\xf0k\xeat\xe5\x7f\xe0\xaa\xda\x00\xd4\xfb\xcd\xa8\xc9r\xc8i\xcb\x1d\xd2T\xda\xf2\xe0g\xe4\'\xe5]\xe4\xb4\xe3e\xe5\x07\xeb\xf4\xf4\xcd\xffL\x08\x12\rD\x0f\xfc\x0f\xa4\x0f\xea\r\x13\x0cE\x0b\xca\x0b.\rj\x0e\xa3\x0e\xe6\x0c\xf7\x08\xf9\x02\xd0\xfb\x84\xf4\xd9\xee]\xec\xcd\xec\xca\xee \xf0\x16\xf0:\xef\xb4\xed\xfe\xeb\xe5\xea\xdc\xeb\x94\xefW\xf5\xf7\xfb\xe6\x01\x90\x06\x11\n\xf5\x0c?\x0f\xd3\x10\xb1\x11\x0c\x13c\x15\x8a\x18\xef\x1a\xfb\x1bK\x1b+\x19\xa0\x15\xed\x10\xec\x0b/\x07\xe3\x03\xdd\x01\x94\x00\xfc\xfe\xba\xfc\x8d\xf9\xe5\xf5\xef\xf1g\xee\xe3\xeb\xf6\xea\xb1\xebx\xed\xac\xef\xa0\xf1\xea\xf2k\xf3y\xf3q\xf3\xf6\xf3\x0e\xf5\xd4\xf6\xfe\xf8\x7f\xfb\xd0\xfd\xa0\xffX\x00\x1c\x00G\xff&\xfeV\xfd\xbe\xfc\xcb\xfc2\xfd\xe5\xfd\x94\xfe\xce\xfe^\xfep\xfdf\xfc\xb9\xfb\xa3\xfb\x05\xfc\x18\xfdo\xfe\xce\xff\xf0\x00\x88\x01\x8a\x01\x0b\x01B\x00\xea\xff\xa1\x00A\x02|\x04y\x06\xc5\x07V\x08\\\x08\x08\x089\x08\xff\x08\xe5\nj\x0ej\x14\x14\x1ds&\xde,\xc6-\xd4)\x19$\x17 \x8c\x1f\xa9"\xf1\'?-0/\xba+\x88"\xa1\x15\x97\x08\xc7\xfe\x01\xfa\xed\xf8x\xf8\xf3\xf5\x84\xf0$\xe9c\xe1\x19\xdaC\xd4Z\xd0\x97\xce:\xcel\xce\xf4\xceF\xd08\xd3\xe0\xd7\x9d\xdd5\xe3e\xe7\xe5\xe9\xfc\xebr\xef^\xf5\x06\xfd\x90\x05\x86\r\xf0\x13l\x17\x9a\x17\x95\x15P\x13\x88\x12\x99\x13\xdc\x15\xc4\x17$\x18\xed\x15\x94\x11\xd0\x0b\xd4\x05\xb0\x00\xf9\xfc\xcb\xfa^\xf9\xfc\xf7\x05\xf6\xca\xf3\xbd\xf1t\xf0\xab\xef\xfb\xeeA\xee\xcb\xed\'\xeeW\xef\xa4\xf1\x13\xf5Q\xf9\xa3\xfd\xe0\x00\xa2\x02\xd4\x02F\x02\x90\x02[\x04\xb5\x07\xa1\x0b\xd9\x0e\x0f\x11\xc5\x11\xd9\x10\xc1\x0e\x02\x0c\xdf\t\x95\x08E\x08N\x08\x08\x08\x05\x07&\x05\xe8\x02\x0c\x00\x81\xfc\xa2\xf8o\xf5\xc7\xf3\xaf\xf3\x81\xf4\xb5\xf5\xd4\xf6_\xf7\x1a\xf7\xdb\xf5\x07\xf4c\xf2\xc9\xf1\xff\xf2\xd9\xf5\x93\xf9\xcf\xfc\xf4\xfe\x06\x00d\x00M\x00\xd8\xff\xd5\xff\xb2\x00v\x02O\x04\xaa\x05Q\x06;\x06|\x05,\x04\xb0\x02n\x01\xbf\x00n\x00z\x00`\x00\xe1\xff\xdc\xfe\x7f\xfd?\xfca\xfb\xea\xfa\xdf\xfa\xf8\xfa\x13\xfb,\xfbH\xfb\x96\xfb\xce\xfb\x18\xfc\xd4\xfcx\xfe\xc5\x00z\x03\xea\x06$\x0cG\x13\xd5\x1ao \xec"\xea"\xb2!\x7f \xe7\x1f\xfc \x00$=(\xba+\xfd+q\'\xb1\x1e\xe7\x13\xd3\t\\\x02<\xfe\x9c\xfc\xde\xfbO\xfa\\\xf6\x96\xef\xb6\xe6\xa9\xddf\xd6\x93\xd2&\xd2:\xd4:\xd7\xfa\xd9\xed\xdb4\xdd<\xde\x85\xdf\xe4\xe1\x8a\xe5\x88\xea/\xf0.\xf63\xfcv\x02o\x08\xaa\rX\x11:\x13\x80\x13\xc9\x12\xee\x11\x85\x117\x12\xbe\x13\x87\x15\xe7\x15\xdb\x13\xf0\x0e0\x08\x1a\x01\x08\xfb\x16\xf7>\xf5#\xf5;\xf5`\xf4\xcd\xf1\xed\xed\x15\xea\x85\xe7\xa2\xe6;\xe7\xc7\xe8+\xeb"\xeeS\xf1\xb5\xf4\xff\xf7B\xfbA\xfe\xb6\x00\xaf\x02Z\x04\x84\x06\xb6\t\xff\r\x8f\x12R\x16C\x188\x18\xc6\x16\xa0\x14\xb2\x12O\x11\xd0\x10\xdc\x10\x8d\x10\xfb\x0e\xe7\x0b\xd2\x07\x98\x03\xe3\xff\xa1\xfc\xb8\xf9\x18\xf7\x02\xf5\xb3\xf3\x16\xf3\xf5\xf2\x10\xf3#\xf3\xf9\xf2X\xf2h\xf1\xba\xf0A\xf1Q\xf3\xa8\xf62\xfa\xf9\xfcV\xfe~\xfe<\xfe8\xfe\xf7\xfe;\x00\xf0\x01\x80\x03\x81\x04\x84\x04\x8e\x03\x06\x02\x91\x00\xb7\xffK\xff\x0e\xffe\xfe\x8b\xfdg\xfcX\xfb`\xfa\x8a\xf9\x1e\xf9\x11\xf9;\xf9!\xf9o\xf8\x8e\xf7\xf6\xf6f\xf7\x07\xf9m\xfb\xea\xfd6\xff[\xff\xe7\xfe\xf5\xfeS\x00\x83\x03+\t\xe2\x11:\x1c\t%\xfd(\xb5\'\xf1#0!\x82!\xdf$K*\xe3/\xb93\xdc3m/|&\xe7\x1a\xec\x0fb\x08\xd5\x04\xa0\x035\x02.\xff\x08\xfa\x96\xf2Q\xe9o\xdf\x1c\xd70\xd2\'\xd1\xbe\xd2f\xd5\n\xd8H\xda\x0e\xdcd\xdd:\xde.\xdf\x93\xe1(\xe6\n\xed\xd4\xf4V\xfc\xb6\x02\xfc\x07\xdd\x0b9\x0e\xf0\x0e\xb1\x0eI\x0e[\x0e\xfe\x0e\xd1\x0f\xaf\x10\x00\x11\x18\x10\xc5\x0c\xda\x067\xff\xce\xf7b\xf2p\xef\xe7\xee\xa8\xef\x80\xf0\x07\xf0\xbc\xed7\xea\xe2\xe6a\xe5\x92\xe68\xea\x00\xef\xce\xf3\x03\xf8\xab\xfb\xc6\xfe~\x01i\x04\x89\x07Z\x0b\x19\x0f\x9e\x12x\x15\x94\x17\x8d\x19\xea\x1a\x8e\x1b\xe7\x1at\x19\xaf\x17\x00\x16]\x14\x87\x12p\x10\xe7\r\xfc\n\xb4\x076\x04v\x00\xc4\xfcF\xf9\xf0\xf6z\xf5w\xf4J\xf3\xdf\xf1\xce\xf0\xf4\xefj\xef\'\xef\xa8\xef\r\xf1\xd7\xf2\\\xf4U\xf5=\xf6O\xf7\xb8\xf8\x18\xfa \xfb\xa2\xfb\xee\xfbl\xfc\x08\xfd\xb0\xfd\xdb\xfd\xc1\xfd[\xfd\xd5\xfc\xf4\xfb\xd8\xfa\x00\xfa\xcb\xf9/\xfaa\xfa\x0c\xfa\x02\xf9\xdf\xf7\xf5\xf6\xb3\xf6\xe2\xf6_\xf7.\xf8\xf0\xf8m\xf9U\xf9\xc9\xf8~\xf8\xef\xf8c\xfa\xa9\xfcp\xff\x81\x02\xfe\x04\x80\x06E\x07T\x08\xa3\n\xe8\x0e\xec\x15\xf8 E.\x9e8\x88:Z4\x0f,\x91(5,=4\x87<\xe9@v?j7u*z\x1br\x0ey\x06\xef\x03\x94\x03\xd2\x00s\xf9\t\xef\xe5\xe4%\xdcE\xd4\xc1\xcc\xc5\xc6\xa8\xc3I\xc4\xe9\xc7I\xcd\xac\xd2.\xd6\x82\xd7\x9f\xd7\n\xd85\xda\x07\xe0\x92\xea\x99\xf8L\x05\xf8\x0c|\x0f\xb0\x0fK\x0f\x9e\x0e\xd6\r}\x0e\x8c\x11\x9b\x15G\x18\xdc\x17D\x14\xaf\r\xe0\x04J\xfb \xf3\xec\xed\\\xec<\xeeN\xf1\x84\xf2\xdd\xef$\xea\x9a\xe4\xcb\xe1\\\xe2b\xe5\x8d\xeaZ\xf1\x9b\xf8\xe3\xfe\xfb\x02n\x05:\x07\xcb\t\xa2\x0cD\x10\x9e\x14\x11\x1a\xc1\x1f\xfa#\x18%\x8a"\x90\x1e\xee\x1a\x98\x18k\x16\xb7\x14\x80\x14\xea\x14\x8c\x13\x1f\x0e\xbd\x05j\xfd\xa3\xf7\xee\xf4Y\xf4\xd5\xf43\xf5W\xf4E\xf2,\xefg\xeb\x19\xe8K\xe7\xb0\xe9\xde\xed\xe8\xf1 \xf5\x95\xf7\x88\xf8\xa0\xf7\x9b\xf5N\xf4\xe8\xf4)\xf7#\xfa\xfc\xfc\xe9\xfen\xff\x9a\xfe\x8e\xfc\x92\xf9\xd3\xf6y\xf5A\xf6z\xf8\xc1\xfa\xa2\xfb\xdb\xfa\x97\xf9K\xf8\x84\xf7\xe6\xf6\xdc\xf6\x0c\xf8\xaf\xf9\x99\xfb\xe1\xfc\'\xfd\x81\xfdc\xfe\xe5\xff-\x017\x01\xcf\x00W\x01\xf3\x02\x1e\x05\xbd\x06\x7f\x07O\x08g\t\x1d\x0b6\r\xd3\x0f\xfd\x12\xef\x17\xe3\x1e\x16\'\x11/ 414\x84/\xb7)\x1e(\x1e-\x125\xad9\xfc6\xef.\t%\xd0\x1ab\x10?\x07\x8b\x01\xa7\xfe\xba\xfb\xd3\xf5S\xed\xb1\xe3\x96\xda\xd4\xd2\x04\xcd8\xc9\x0e\xc7\xe3\xc6\xc5\xc9\xb4\xce\x9b\xd2I\xd3M\xd2\xe7\xd2\x0e\xd7u\xde\x11\xe8\x8a\xf29\xfc\xf5\x03\xcd\x07j\x086\x08\xba\nO\x105\x16&\x193\x19#\x18\x92\x16\x8d\x13\xe3\x0e\x90\t\xb3\x04\xc0\x00|\xfd\x88\xfa\xad\xf7\xbe\xf4\x03\xf2\xd0\xee\xba\xea\xec\xe6\xaa\xe5\x0f\xe8\x01\xec\xfa\xeeV\xf0D\xf2\xfc\xf5W\xfaN\xfe\xa1\x01\xee\x057\nV\x0ex\x11\xe6\x14\x11\x18\x02\x1b<\x1c\xc0\x1b\x8d\x1a\xee\x18\x80\x18n\x17\x9b\x152\x12\n\x0f\x1b\r\x92\x0bd\x08\xba\x02\x8f\xfc\x14\xf8\t\xf6^\xf5\xe0\xf4\xfb\xf3\xa3\xf2\xb6\xf0\xd1\xee\xb5\xec,\xeb\x96\xea\n\xecr\xefM\xf2\xf7\xf3\xbe\xf46\xf5\xff\xf4\xac\xf3\xc7\xf2/\xf4\x84\xf7\xbd\xfa\xbc\xfc4\xfd\xbc\xfc\x85\xfb\xcf\xf9\x85\xf8P\xf8\x9c\xf9i\xfcP\xffu\x006\xff\xea\xfc\x1e\xfba\xfa\xd4\xf9\xde\xf9\xa2\xfb\x1f\xfey\xff#\x00\x8f\x00T\x00\x10\xfd~\xf8\xe3\xf8\xcf\xfe\xc9\x05B\tW\x07\xfc\x03V\x01P\x01}\x03\xb6\x05=\x08J\n\x04\r\xd1\x0f;\x17S$j/a.\x99!V\x1a<"p0\xb47\xab7N7S6\xe4/\x80%\x86\x1d\xcb\x183\x13z\r\x14\n\xde\x08\xcf\x04\x9d\xfb\xeb\xee^\xe0E\xd5\x90\xd0\x1c\xd2Y\xd5\xde\xd4\xf7\xd1\x14\xcf\xa0\xcc\xdb\xc9\x07\xc8\x8e\xca\xd8\xd1m\xdb\xa0\xe4\x8a\xec\xb9\xf3\xd1\xf7K\xf8\x9b\xf8\'\xfd\x9f\x05\xfd\rr\x13\xa7\x17\\\x1a\t\x1bi\x18\xf2\x12\x86\r\xb0\n\xa8\x0b/\r\xbb\x0bY\x07\xbc\x01\x88\xfb\xb8\xf4r\xee\xf5\xea\x9e\xea\x04\xeb\xf7\xeb-\xedk\xee\xdd\xeda\xec\x1e\xec\x0f\xeeL\xf2\xf7\xf8x\x00\x11\x07\xcf\t\xc3\n\xc7\x0ba\x0e,\x110\x13\x14\x15\xfb\x18\xdc\x1di \xe9\x1e\x1c\x19S\x131\x0f\x11\x0f\xbd\x10\xd7\x10W\r8\t7\x05d\x00\xc4\xfa\xfe\xf5\xda\xf3\x8a\xf2\x0e\xf2\n\xf3Q\xf3r\xf1\x8c\xed\x03\xea\xbb\xe8\x1e\xea\n\xed\xa8\xf0!\xf3\xbb\xf3\xad\xf2O\xf2\x96\xf2\xec\xf2\x98\xf3\xf1\xf5!\xf9\xaf\xfb\x16\xfd\xa3\xfd\xaf\xfc\x95\xfbs\xfb\xfb\xfc\xd8\xff\xfa\x01\xa4\x02b\x01\xe7\xffQ\x00\xa7\x00\xc9\xff\x9d\xfeD\xfdS\xfe7\x00\xd9\xff?\xff\xb6\xfd&\xfd\xdd\xfd\x99\xfe\xdc\x00\x80\x02n\x02\xe9\x02\xc3\x03\xea\x03F\x04\xe7\x04\xe1\x062\t\x01\x0b\x83\r\x8f\x0fH\x15t\x1fJ);\'&\x1e\xc6\x1cU& 0\x0f0p.\x8e1\xc22\xe1,\x06%Z \x9f\x1b\xd7\x13\xe7\rk\x0e\x89\x0e\xe4\x06\xeb\xf9f\xeef\xe7\x0b\xe2A\xdc\xd5\xd8e\xd7\t\xd6\x82\xd3\xbb\xd1\xee\xd0d\xcf?\xcd\xf8\xcc\x14\xd1\xec\xd7\x1b\xdfS\xe5\x06\xea:\xed\x08\xf0\x1b\xf4"\xf9@\xfeO\x02\xb3\x06(\x0c\x8d\x10\xc7\x12\xf6\x12\xa1\x11K\x0f\xa9\x0c\xef\x0be\x0c\x0e\x0c\x06\n\x98\x07\xbb\x04\x08\x00w\xfa\xe9\xf6\xa1\xf4q\xf26\xf1m\xf2\xcc\xf5\xd2\xf5c\xf3.\xf1L\xf1)\xf2m\xf4\x95\xf8~\xfe\x0b\x03/\x060\t\xf0\t\xa6\x08\xe1\x07\x1a\n\xe3\x0e[\x15\xf7\x1a\xfd\x1b\x96\x17\xef\x11\xb7\x0f\x05\x10\x08\x0eM\x0c\xd0\x0be\x0c \x0cK\t\xdc\x03\xa2\xfc\x90\xf5\xc5\xf1\xfa\xf2\xec\xf4r\xf5\xe8\xf3\xdf\xef\xe3\xeb\xf7\xe8p\xe7\xdb\xe6\xd3\xe6\xbc\xe7a\xeb`\xef\xde\xf1\xc6\xf1\x86\xf0M\xef\xf1\xf0D\xf4\xb0\xf7Y\xfb\n\xfe\xa0\x00#\x03\\\x03z\x02\x94\x01P\x01`\x02\xf2\x03/\x08\x92\x0bh\t\xa8\x06\xf5\x03\xcc\x03$\x05\x16\x03\x01\x03\xaf\x05\xea\x06\x85\tW\n\x89\x07}\x01%\xfe\xd2\x01o\x04\xee\x05\x11\tN\n\xb9\x07H\x06v\x06\xd5\x06\xf8\x05\x8d\x05]\x07g\x0b\xee\x12b\x1a\xb1\x1b\x05\x17\\\x14?\x16\n\x19H\x1b\xe8\x1d\x8e!\xf7"l!\xf4\x1f\xbd\x1e\xe0\x19\xfb\x112\r\xa6\r\xf4\r\xec\n%\x07c\x03\x94\xfc\xff\xf3Z\xeeV\xeb{\xe7\xa2\xe2\xd3\xe0Y\xe1w\xe0Q\xdd8\xda\xf4\xd7O\xd6;\xd5\x8b\xd7\xb7\xdc{\xe1+\xe4\xef\xe5-\xe91\xec\xa8\xedc\xf0\xfb\xf4\xb9\xf8-\xfd]\x02\xac\x06\x0f\t\x97\t>\tf\t4\nW\x0b9\r\x02\x0eF\x0e\xb7\rM\x0c\x90\n\x9d\x07\xcc\x04L\x03\xc0\x024\x03$\x04]\x03\xfa\x01\x8e\xff\xe1\xfd\xc4\xfd\xdd\xfd\x8d\xfd\xdc\xfe,\x01J\x02B\x03\xdd\x041\x05J\x03\xfb\x01\x87\x03K\x06R\x07\x15\x08\xd1\x07V\x08m\x06\x84\x03\xdd\x02\x87\x008\xffT\x00\r\x002\xff\xe9\xfd\xcf\xfbJ\xf8e\xf6\xe0\xf60\xf5\xbc\xf2\xf5\xf4\x14\xf8\x12\xf6\xd9\xf0%\xf5\x06\xf8\x96\xefi\xf0\xe3\xf7 \xf9\xd0\xf5\xf1\xf6x\xfe\xed\xf9\xae\xf6z\xfc\xf1\xfb\xf1\xf7\x86\xfbc\x02\xae\xfd\x85\xfb+\x06\xdf\x01\x10\xf7V\x00r\x08\xcb\xfe`\xfa\x85\t\x97\x0f\xce\xfcy\x01\x17\x16\x84\x07\xa1\xfc\xe7\ny\x11\x02\t\xa3\x08\x1e\x12\x93\x0e?\x05\x8b\rQ\x11\x87\n(\x08\xe4\x0b[\x0c\xae\t)\x0c\x8c\x0c\x07\tZ\x03a\x05\x01\x07\x9c\x04\xeb\x03\xf9\x02\x8a\x04\xe4\x04\xdf\x00\xc9\x01e\x05\xf9\xfe\x03\xfe!\x06@\x06S\x01\xac\x05V\x08\xba\x05]\x03\xfd\x04\x8d\x07\xb2\x05\xc0\x01\xba\x04\xd8\x07F\x05\xcb\x02\xd8\x02i\x00\xcd\xfb$\xfb>\xfc\x15\xfb\x02\xf9\xe4\xf6\x04\xf5\x9e\xf4\xdc\xf3\xbd\xf1\xa0\xef\x8c\xefD\xee\xe4\xed\xd3\xf0c\xf2\x82\xef\x1b\xf0q\xf2&\xf4\xf6\xf2F\xf7B\xf9\xe0\xf5j\xfb\xae\xff\xe3\xfe\xa3\xfd\xbd\xff#\x03\xa9\x02<\x02\xab\x04F\x07\x1b\x04\xcb\x02\xb5\x07\xc2\x06\xa6\x03\xc2\x05\xa5\x04\xc9\x04\x96\x05\x13\x04\x10\x05\xcc\x04\xa3\x02r\x03\xb7\x04\xcf\x04\xd3\x01\x83\x040\x02\xb2\x01j\x04:\x01\x1b\xff#\x02\xb1\x01\xd3\xfb\x08\xfc\xc7\x00%\xfd\xec\xf4\xa3\xfb"\xfc\x08\xf7\xd4\xf7?\xfb\xb1\xf4|\xf2\x16\xf9\xd9\xf8Z\xf2\xf4\xf4V\x01\'\xf3\x15\xf4)\x00\x1a\xfdY\xef\xaf\xfb\x9c\xffd\x01\xc5\xf8\xcb\xf8\x95\x0c\xbc\xfea\xf6\xa7\t\xd0\x05%\xfaC\x047\x06C\x04\x06\xff5\t\xe7\x06\x07\xf8\xec\x07}\x0c\xa3\xfa\xbf\xf8"\x08\x81\x08\x03\xf7\xeb\xfb\xcb\x0e\xd2\xff\xf3\xf8\xb8\x05\x88\x03U\xfd\xad\x019\t~\x00\xc4\xff\x98\t\xbb\x04*\xfe\xa1\x0bk\x0c\x97\xfdI\x08M\n\xb9\x04\xe7\x04\xb6\n\xc3\tV\x05\xc3\x08\xed\x05\xf3\x03\xe7\x0cY\x07\xdd\xfbk\x05D\x10\xf5\xfa\x1a\xfd\xee\x10E\x01C\xf7\xf7\x04\x88\x05\xf4\xf4G\x04b\x00\xc3\xf8\xea\x00b\xfb\xc9\xfb\x8d\xfcG\xff\x18\xf7j\xf3\xda\x03,\x03\xd3\xf3\xb8\xf8\xc7\x06\x1a\xfa\x8d\xfb\x08\xfe\xdc\x03\x80\x00\xdc\xfa\xbe\x08\xa8\x02:\x00\xd2\x05\x9d\x05\x1f\x00[\x03\xbd\x06\x91\x03\xf4\x03\xf8\x03P\x04\x18\x02f\x01\xc6\x01\xfd\x01\x8a\x01\xa3\xfd@\xfd\x11\x05#\xfdG\xfa\xd2\x01g\xfdg\xf7\xe8\xfd\xb7\x02\x94\xf7\xf8\xf5q\x02\xf7\xfd\xda\xf3\xa8\xfd:\x01\x86\xf7D\xf5\xc7\x03\xa8\xfd\xc8\xfbt\xfc@\x00 \xfe\x04\xfee\x060\xfa\xf7\xf8\xcc\x06\xdb\x00\\\xfa\x11\x00\x07\x03\xa3\xfb|\xfc\xc7\x04\xef\xf9\x17\xf8\xe5\xfc\xd7\x05\xb0\xef\x81\xfa\x1f\x08\xdd\xf8Q\xf1\xe7\xfd\x88\x01\x89\xf5Q\xf3\x00\x00\x08\x04\xd6\xed\xf1\x01\x8f\x00?\xf4O\x01\xcf\xfa\x89\xff\xfe\x01\xa0\xf8y\x06T\x01\x1c\xfbD\t[\x05\x85\xfa}\t\xf5\x07n\xfd\x0f\n\t\x04\xa1\x02\xa4\t\xff\x01c\x01V\x0bv\x04\x7f\x00\xf9\x08R\xff&\x04]\x07\xfa\xfc\n\x01P\n\xcb\xfc*\xff\xff\x05,\xfdb\xff\xe1\xffH\x06\x1e\xfbS\xf1\xbd\x07@\x10n\xeb\x1c\x01\xcf\x0b0\xf5\x9e\xf2(\x0c\xb1\x06\xb4\xef"\x01\xaf\t\xd3\xfeF\xf7`\x05Y\xffy\xff\x10\xfae\x05\x83\x04\x80\xfc\x97\xfd\x0f\x07I\x02\xfb\xf4\x1e\xfd\xd9\x0b\xa2\xf8\x98\xf6\xd7\x06p\x01\xd6\xfc\x11\xfdB\xff\xc1\xf3\xb6\x04\x98\xfa\xbd\xfb\x95\xffe\x04\xc0\xfe\xc4\xf6\x13\xff9\x06\x18\xff\xc4\xf4e\x0b\xee\x069\xf7g\x01R\x11\xd4\xfd \xfd\x89\t\xc2\nE\xf9D\x06\x07\x0fb\xff\x94\xfd\x9e\x08l\x0b\xc7\xfa\xd5\x03a\x0c\xaa\xf8\x8b\xfb\x9b\x0b%\x02v\xfc,\x04\xf6\x00\'\xff\xfd\x01\xc3\xfd\x94\x03\xfa\xfa\x14\xff,\x03\xe6\x01\xfb\xfc*\xfe+\x01[\xfa^\xff\xbe\xfdQ\x00\xf8\xfd&\xfd\x1e\xfb\x1c\x06\xbf\xfe\x00\xf2\x8e\x0b\xd0\xf8\x96\xf8\xe9\x02\xcc\xfd\xe2\xfeH\xf8\xf8\x04\xc0\xf9\xc0\xfd\x1b\xffh\xfdm\xfc\x0c\x01]\xf5\x90\x00y\x03\xbb\xf5C\x08{\xf6\xe4\xfc\xa2\x00\xca\x00\x8f\xf9\x95\x02\xb6\xf6\xa2\x04&\x00\x85\xf9\xde\x02\'\xffn\x00\x13\xff\xaf\x02\xf8\xf5~\x0b+\xfa6\x02f\x05f\xfd\xe5\xfe6\x07\xff\x02B\xfc\x1e\x00\xc6\x07\xf4\x01\xa9\xfbp\x0bk\xfb\xce\x03\x0b\x05\x96\x01\xb9\xfa8\x05Z\x00\xf7\x02\xcf\x00;\xfc\xf6\x00\xec\x02\xed\xff\xbb\xfe\x85\xfc\xa5\x02\x11\x02\xbd\xf4\xac\x08\x9f\x06\x00\xf1\x17\x01n\x10u\xec\x88\x05x\r\x96\xf3\xdb\xffJ\x0eV\xfa\xa8\xfc\x0c\x07\xe4\xfd\x1b\x06l\xfb\xff\x05\x95\x01[\xfd\xfc\xfeJ\x055\x02\x17\xf6_\x07\xb9\xf7\x15\x04\xa6\x019\xf4\x0e\x06\xb7\xfc\xb7\xf9\xd5\xffV\xfd&\xfbz\x02\xbf\xee\x82\x0f\\\x02x\xea\x17\x08E\x00\xff\xfdo\xf5\x9d\x0f\xc5\xff\xcc\xf9\x8c\xff~\x00\x03\x073\xf49\r\x19\x01\xb7\xfd\xa8\x01!\x02\xf4\x01\xe1\xffh\xfeo\xfcX\n\xa5\xfdi\x03r\x04\x0e\xfd\xed\xffb\x06\xdb\xf9\x02\x00\xba\rL\xf4\xc0\x02I\x08\x92\x038\xf8\xc8\x03T\x03\xd6\xf8\x9d\x08p\x05\x1f\xff\xd8\xf5\x96\x14\xae\xfdH\xf0/\x10\xcf\x07\xfa\xed\xa7\xfd\xff\x15\xa7\xf6\x9f\xf9\xcb\x08\x80\x02\x9f\xf7\t\xf2|\x0c\xca\x00\xd5\xf1I\x00\xe7\x06\xae\xf9s\xef\xe9\x0b@\xf7\x8f\xf2I\x06\xe6\xf4\xe4\xfc&\x05\xaf\xf3`\x02]\xf9\xb7\xfc\xc1\x003\xf9\x1f\x03O\xf9\xeb\x04\xa7\xfa\x1c\x07\x1b\xfb\xd0\xfe5\x05\xbc\xfe\xda\x02\xda\xfdE\x05\x1a\xffH\x08\xfb\xfd\x1e\x05\xbb\x08\x0e\xf4\xa5\x04E\x08(\x02\xbc\xfd\x89\xff\xf9\x0fg\xf3\t\xfe\x90\x12\xa9\xfb\xd6\xef\x18\x06\xd9\x11\x95\xf0B\xfb:\x14\x9a\xfe\xda\xf1\xff\xfc\xb5\x0e\xde\xfd\xe0\xf5]\x0cK\xfd\xc6\xf2\xaa\x14r\x01h\xe9X\x04t\x0b\xaa\xf8\x9f\xf5\xb3\x0b5\x07\x86\xf2\xe0\xfa\x0c\x0f\xce\xf2H\x01\x9d\x00i\xfb\x1c\x05\x10\x00\x7f\xfcs\xfb\x93\x05\x0c\xfa\x90\x00\x1f\xf8O\x05\x17\xff\xb1\xff\xbb\xf2j\x05\xec\x04\'\xec\x99\x10x\xff\xf8\xec\xc3\x06v\x0b\xcc\xe5\x82\x04\xe7\x19`\xe6\x8f\xfa\xe6\x1a\x8a\xf2\x1b\xf7\x88\x05G\x08{\xfbd\xf7\xc7\x16\xc7\xfa\xe0\xf4\xfb\x0c\xd2\x04\xdc\xf9[\x07\xe6\xff"\xfa$\x0f\'\x01P\xfa9\x08\x9e\x04\xe8\xff\x83\xffq\xff\xac\x06R\xff\x80\x01W\x03\x89\x02}\x00\xb0\xfcX\x07x\x00\xed\xf7p\n\x08\xfa\xd0\x00%\x08\xb4\xf6\\\x05o\x02[\xfd&\xf9\x8b\x08\xee\xf9\x89\xfcq\tN\xf8\x80\xf9#\n\x04\xff\x9c\xf2\x9e\r\xcf\xf5\xc1\x005\x05\xe1\xf7\xda\xf9\x91\t\x1b\xfb(\xf4\x1d\x13h\xed\xe2\xfb\xe1\r3\xf5}\xf8\x06\xfe\xf4\x07\x88\xf4\x87\xfa\x02\x05,\xffu\xfa\xd2\xf7\xf4\x04!\x00\x17\xfcQ\xf6\xb9\x0b\xf6\xfc\xb3\xf5\xbc\x06D\x04\xc2\xfbj\xff\\\x06\x8f\xfa\x9c\x01X\x03}\x02K\x00w\x10r\xf5\xeb\xffK\r`\xfbc\xfd\x82\x0c\xb3\x02\x88\xfb*\x00-\ns\xfc\x86\xf9\x01\t*\xfal\x018\xf5\x82\x11#\xfc\xe2\xef\x9b\n^\x06\xae\xf2\x88\x02@\x0c\x1d\xfa\xae\xf6\xb4\x13\x99\x01\x84\xeer\r\x00\x0c\xf1\xf7\x8e\xf1\x0e\x18\x97\xfc\x89\xef#\x0c\xde\x08\xa1\xefk\xff^\x0c\xde\xf2~\xfa\xfd\x08\x84\xf9\x81\xf3\x89\x06\xd0\x06\xf3\xec\x17\xfci\x0e\xe6\xf2\x99\xf3\x8b\x05_\x06\xb1\xf6\x85\xf3\xf5\x14\xa8\xfeH\xe2I\x12\xa8\x0f\xda\xe80\x01\xbd\x14\x8b\xf5\xd6\xf8#\r\x93\x046\xf3\x0e\x06~\x0b\x0b\xfd\x17\xfe\x08\x08\xfc\x06)\xf5p\x07\x0b\x08\xee\xfc\x8b\xf9\xd4\tf\x03\x0e\xfb\xc7\x02\x8c\x02F\x000\xfcQ\x00\x05\x01\x10\xfec\xfb\x03\x08$\x00\x84\xf9\x87\td\xff\xa6\xeeD\x03\xe4\x07L\xfd\x83\xfe\xb7\x02\xff\xfb]\xfd}\x05\x91\xf9\x97\xfer\xff\xad\xfa\x0e\x00\x14\x02d\x01_\xfcJ\xfb\xa8\xfc\x85\xfd\xd6\xfe\xb7\x02\x9a\xf6\x1a\xfd\xe8\x03\xd3\xfb\xb0\xfb\x9b\xfc6\x02\x94\xf5x\xfdn\x07\x81\xf9\x97\xff\x97\x03\xf2\xf6\xa1\xf6\xfa\x10\xe3\x01y\xf1q\x04\xd3\x04\xc7\xfe\xe9\xfb\xb8\x0c\xc5\xffi\xf7E\x03P\nD\tG\xf4\x96\x08\xe5\x08\xb0\xf6(\x07\x06\x0b\xad\x02W\x03\xec\x01\xa4\x012\x052\x02!\x05#\x01\n\x00\xcd\x03r\x02\xb8\x02E\xff\x19\xffL\xfe\xf1\xfd\xeb\x01A\xff\xec\xffz\xf8\n\xfal\x06\xa0\xf7\x90\xfd\xa2\xfe\xcd\xf9\xfb\xfe\x86\x001\xf8\xfa\xfb\xc2\x08\xac\xf3i\xf6\xa7\x07A\xff\xae\xf7\x9f\x00\x85\x01\xf7\xfa\xc9\xfb\xda\x02\xc6\xfb\xa3\xf8m\x03\xce\xfe\x87\xff\xb7\x00n\xff[\xfcd\xfe\x10\xff\xd1\xfdh\xfe \xfc7\xff\x90\x03\xae\x013\xfe\x1e\x01o\x01\xf4\xfdn\x03Y\x07\x17\x03\xe3\x01K\t;\x0b\xf8\x04\x80\nM\x0e\x8a\x04t\x05\xcd\x0f\xe8\x0c\xdf\x08<\x0cc\x08e\x08v\n\x05\n\xaa\x05\x8f\x01\x07\x05V\x02G\xfe\x8f\x02\xad\x01\x9e\xfa\x9b\xfa\xc0\xfa\xcf\xf8\xd9\xfa\xde\xf8I\xf5\x0e\xf8\x05\xf9]\xf7\x13\xfa\xa1\xfa\xaf\xf8e\xf8\x90\xfa\xee\xfd\x1e\xfe\x0f\xff\t\xfe\xa5\xfd\xc9\x00t\x01I\xff&\x00e\x03\xca\xfc\x95\xfa\x13\x02\xd8\x02\xcb\xfb\xa6\xfa\xa9\xfb\x0e\xf9\x0c\xf9\x04\xfat\xf9\xe3\xf4/\xf6\xb0\xf5\xf3\xf5\x80\xf8\xbe\xf5\x14\xf8\xca\xf2\x07\xf3\x0b\xfa\x84\xfc\xb1\xf6V\xf8\xe1\xfc[\xf7\xfb\xfb;\x00\x03\xff\xad\xf9r\xfb)\xffN\xfc\xbb\x01T\x01\xd9\xf9\x9f\xfd\xb9\x00d\xfd\x11\xfd\x0e\x00\xcc\xff\x8a\xf9,\xfc\xb1\x03\x87\x05\n\x02\x9c\xfdx\xffj\x03\x16\x04E\x05v\x05%\x07\x8d\t\'\x0bu\r\xa2\x0e\x90\rB\x0fM\x11=\x13\xcd\x19\xef\x1dH \xa7"k#\xd2"\xcc\x1e$\x1e\xd8\x1f\xef\x1f\xe6\x1b`\x18|\x18>\x17}\x11\xfc\n\x92\x03\'\xfdV\xf7u\xf2\x93\xf1\xbc\xef~\xeb\xb2\xe7\xd2\xe6\xc0\xe5[\xe3E\xe2#\xe0\xca\xde\x93\xdf\x0f\xe3\x14\xe8\xb7\xec\x14\xf0\x1f\xf2\x99\xf3=\xf7\x9c\xfb\xac\xfd\xc5\xfeb\x01\xbb\x02u\x05j\n\xba\x0b\n\x0c\xa5\x0b+\x07\xec\x035\x03=\x02*\xff\xb7\xfa\x08\xf8E\xf7\xb9\xf6\xcf\xf5\x8f\xf5&\xf1\xbc\xec\xa2\xebV\xecq\xee\x9f\xf0\xfd\xf1\xfa\xf2\xdb\xf6\xa1\xfc\x07\x01\x04\x03\x08\x05G\x04q\x06\x05\n\xd5\rJ\x11\x9f\x12R\x12\xc3\x11\xa1\x12\xef\x12\x1c\x10s\x0b\xb0\x07\r\x05u\x03\xb4\x02\x84\x01\x93\xfe\xbf\xfa"\xf7^\xf5\xfc\xf3\x10\xf22\xefG\xed\xa8\xed(\xf0\xd8\xf2\xf4\xf2\x19\xf3\xd4\xf3\xbd\xf3\x10\xf4\xa0\xf5\xca\xf6\xd1\xf7n\xf9\x18\xfbt\xfc\xce\xfdO\xfd\xf3\xfb\xe9\xfa#\xfa\xb0\xfa\x9d\xf9\x9d\xf8\xcc\xf8\xf4\xf9`\xfc\'\xfc\x9a\xfb\xa9\xfb\x87\xfc\x03\xff~\xffs\xfd\xce\x03U\x16O%?*\xc9\'\x0b*\xb52r5\xff2\x872F5T5e2\x9d2\xf14\xc10\x15#\x99\x13\xa4\t\x03\x04\x94\xfco\xf3\xc7\xeb5\xe7\xb2\xe3\xf5\xdf\xfa\xdc\xd7\xd9\xf3\xd5\x89\xd0\x89\xcb#\xcd\x84\xd5\xdf\xdc\x1b\xe1\x94\xe6\x86\xee#\xf6\x80\xfc|\x01D\x06+\x08\xe6\t\xa9\x0e\xb4\x13b\x19\xd5\x1d[\x1cL\x19\xb7\x16\x9f\x13\xe2\x0eR\t)\x03\xb1\xfbl\xf4\xac\xef\x8d\xef\x86\xed^\xe9\x06\xe4;\xde\xc0\xdaU\xd9P\xda\xb9\xdc\x17\xdes\xe1\x1d\xe8\xa2\xee\xa9\xf5\xe0\xfa\xab\xfe\x93\x01y\x05\xfe\t\xbf\x0f\x85\x15\x06\x1a\xc1\x1d\xcb\x1e\xd2\x1d\x16\x1e=\x1d\xfb\x19\xcd\x15v\x11[\x0e\x0c\x0b\xe8\x07\xe4\x04T\x01\xd7\xfc\xa4\xf8?\xf5\xcf\xf2%\xf2~\xf1\x99\xf0\xcc\xef{\xf0\xa0\xf2`\xf4A\xf5m\xf6R\xf7\xf8\xf8H\xfa\xc6\xfa\xe9\xfb4\xfc\xd6\xfb\x12\xfa\xa0\xf8A\xf8\xa8\xf8N\xf6\xc6\xf3\x8b\xf2l\xf1\xd4\xf1q\xf1\xa6\xf0\xd2\xf0\xd3\xf0f\xf2\xae\xf3\xcf\xf3\x9c\xf5\x8c\xf9\x9c\xfcX\x00\n\x05\xcb\x07\x8e\t[\x0b3\x0f\xad\x16\xff n+\xfa2_6\xd86\xc87\xd48\xf96\\2^,\x98(k\'\xa8$\xd1\x1f\xaf\x19\x9d\x11Z\x076\xfc\xa7\xf2\x90\xeb\xc4\xe5\x1d\xdf\xf5\xd8\x97\xd6\x8a\xd7D\xda\x89\xdc\xf8\xdc\x11\xdc\xa2\xdc&\xdf\x86\xe2X\xe7k\xed\xf6\xf3\xe9\xf8N\xfe\x99\x05f\ry\x12T\x14\xcc\x13w\x13\xbb\x13\xf8\x13E\x13\x88\x11\x03\x0f\xd7\x0bg\x08y\x04\x1f\x01\xf0\xfc\xf5\xf6\x9c\xefM\xe9\x85\xe5\xab\xe3\xf8\xe1o\xe0\xd3\xdf6\xe0\x83\xe1\x94\xe3\xb9\xe5~\xe8-\xeb\x9a\xedO\xf18\xf6x\xfcr\x02V\x07"\x0b\xf2\x0e\x18\x12m\x14\xc7\x15\x85\x16\xb2\x16+\x16\xa4\x14\xb0\x13\x12\x13\xcc\x11i\x0f\xaf\x0b\xdd\x07i\x05\xa6\x02T\xff\x05\xfd\xee\xfa\x18\xf9\xd7\xf7\xfd\xf6\x89\xf6\xed\xf6\x86\xf7_\xf7p\xf6\xd7\xf5\xf7\xf6G\xf8\x7f\xf8\xd1\xf8\x0e\xfa\'\xfb\x9a\xfbo\xfc\xa8\xfc\xcb\xfc\xce\xfd\x0b\xfe\x8a\xfdh\xfd:\xfe\xf3\xfe\x88\xffp\x00\xd4\x01\xd0\x02\x8a\x03d\x04\xdc\x04\xde\x05\x8b\x06\xdc\x06t\x07\xe2\x07\x97\x08s\tN\tz\x08z\x083\x08\x9c\x06\xf7\x04\xa1\x044\x05.\x05,\x04\r\x02\x8b\x00\x80\x00\xac\x00\xd5\x00o\x00\xf6\xff\x03\x00D\xff\x99\xfeE\xffr\x00\xc8\x01\xd8\x02\x19\x03.\x031\x04\x8b\x05k\x06i\x07\xf1\x08E\n\xa3\n\x8e\n\xd4\ns\x0b\xb3\x0b\xfa\n\xad\t>\x08\xdf\x06j\x05\xad\x03R\x02(\x01\xa4\xff\xc2\xfd\xb7\xfbT\xfa\x94\xf9g\xf8\xa9\xf6$\xf5c\xf4+\xf4\xda\xf3H\xf3/\xf3@\xf3D\xf3\xbf\xf3\x86\xf4\xbd\xf5\x1f\xf7\x9f\xf7z\xf8\xac\xf9{\xfa\x89\xfb\'\xfc\xbf\xfc\x85\xfd\xd2\xfdR\xfeq\xfey\xfe\xe5\xfe\xdb\xfe\xb3\xfe\x7f\xfe\x1c\xfe\xbf\xfd\xc9\xfd\xd7\xfd\xd7\xfd\x9a\xfd\xc1\xfdp\xfe\x8a\xfe\xf6\xfe\xcf\xff^\x00\x07\x01\x85\x01\xdf\x01\x99\x02i\x03\xf5\x03f\x04\x8a\x04\xa1\x04\xf5\x04]\x05]\x05\x1c\x05\xcb\x04_\x04\xc5\x03\x17\x03\x15\x03\xc9\x02\x1d\x02\xaf\x01\xde\x00j\x006\x00\xc9\xffW\xff;\xff=\xff\xcf\xfe\x95\xfe\xc9\xfe\xd6\xfe\x9f\xfe\xa2\xfe\xf0\xfe\xa3\xfe\xbb\xfe\x08\xff\xbd\xfe\xb4\xfe\xd5\xfe\x97\xfe4\xfe\t\xfe\xcf\xfd:\xfdy\xfc\x0e\xfc\xbf\xfb\x04\xfbl\xfa5\xfa\xca\xf9R\xf92\xf9*\xf9\x19\xf9\\\xf9\x02\xfa\xbc\xfa3\xfb\xfd\xfbA\xfdU\xfee\xff|\x00\xc3\x01\xda\x02?\x04h\x05e\x064\x07h\x08\x01\t\x18\t\x88\t\xb2\t\xc5\t\xb9\t\x9d\t"\t\xc7\x08W\x08\xda\x07\x1c\x07\x8f\x06\x11\x06\x82\x05\xa1\x04\xdc\x03s\x03[\x03\r\x03\x86\x02\x85\x02\x93\x02l\x02:\x02u\x02z\x02w\x02C\x02(\x02\xed\x01\xd7\x01\xac\x01<\x01\xcf\x00N\x00\xd4\xff?\xff\x86\xfe\xac\xfd\x1d\xfd\xb5\xfc\x13\xfc0\xfb3\xfa\xa3\xf9(\xf9v\xf8\xdb\xf7g\xf7\x0c\xf7\x01\xf7B\xf7a\xf7\xa4\xf7\x12\xf8u\xf8\xa6\xf8\x02\xf9\x0b\xfa\xb8\xfa\x0b\xfb\xdb\xfb\xf4\xfc\x01\xfe\xe4\xfe\xdc\xffL\x00\xa5\x00M\x01\xd3\x01\x02\x02v\x02\xb4\x02\xbf\x020\x031\x03\x0c\x035\x03\x1b\x03\xa5\x02\x9c\x02u\x02,\x02\xf8\x01\x00\x02\xdb\x01~\x01~\x01\x98\x01I\x01\x0f\x01/\x01#\x01\xde\x00\xb9\x00\xdf\x00\xe1\x00\xb5\x00\xa3\x00\xad\x00_\x00\xfd\xff\xcf\xffz\xff\x02\xff\xa1\xfe\x0e\xfel\xfd\xe5\xfc\xa0\xfc8\xfc\xbc\xfb,\xfb\xbe\xfa\xb0\xfa\xab\xfa\xa0\xfa\xd5\xfaW\xfb\xbd\xfb)\xfc\xf9\xfc\t\xfe\xa2\xfe0\xff\x04\x00E\x01\x0c\x02\xc1\x02\x8f\x03J\x04\xd5\x04b\x05\xef\x05\x01\x06\xee\x05\xf0\x05\xbd\x05I\x05\x1c\x05\xa8\x04\x15\x04\x97\x03;\x03}\x02\xe9\x01\xb8\x01-\x01\xb0\x00q\x00o\x00;\x00a\x00\x93\x00\x94\x00\xc2\x00_\x01\xc8\x01\xff\x01]\x02\xd4\x02$\x03t\x03\xcb\x03\xe0\x03\x0c\x04\x19\x04\x10\x04\xda\x03\x9e\x03M\x03\x97\x02\xfa\x01[\x01=\x00X\xff\x97\xfe\x88\xfd\x99\xfc\xc2\xfb\xe9\xfa0\xfa\xbf\xf9Y\xf9\xf6\xf8\xae\xf8\xcd\xf8\xbb\xf8\xfe\xf8\x85\xf9\x15\xfa\xf3\xfa\xa9\xfb\xa0\xfc\x99\xfd\x7f\xfe;\xff\x0f\x00\xdb\x00\x82\x01\xf7\x01r\x02\xf8\x02B\x03f\x03\x81\x03f\x03\x14\x03\x1a\x03\xe4\x029\x02\xb2\x01\x8c\x01&\x01\x98\x00\x0f\x00\xf4\xff\xb3\xff\n\xff\xe3\xfe\x01\xff\xef\xfe\xe5\xfe\xf1\xfe\xfa\xfe6\xffv\xff\xa5\xff\xd8\xff\x00\x00=\x00Z\x00\x80\x00\xca\x00\xe8\x00\xed\x00\xb5\x00\xa2\x00\x9e\x00\x7f\x004\x00\xe4\xff\x7f\xff\x1f\xff\xba\xfel\xfe]\xfe\xfc\xfd\xbc\xfd\x87\xfdN\xfd$\xfd2\xfd\'\xfd1\xfdu\xfd\xbc\xfd0\xfe\x90\xfe\xe7\xfeG\xff\xb0\xff/\x00\x80\x00\xe3\x00+\x01&\x01:\x01\x84\x01\xa4\x01w\x01m\x01n\x013\x01\xd9\x00\x97\x00W\x00\x07\x00\xdf\xff\x9c\xff\x8e\xff\x95\xffy\xff`\xff\x98\xff\xb4\xff\xba\xff\xf1\xff9\x00\x96\x00\x0f\x01k\x01\xc4\x015\x02\x96\x02\xc9\x02\t\x03+\x034\x039\x039\x03+\x03\xef\x02\xc5\x02{\x02\x1e\x02\xb5\x014\x01\xb1\x008\x00\xaf\xff\x08\xffX\xfe\xc8\xfd\x9a\xfd\x1d\xfd\x94\xfcB\xfc\x13\xfc\x06\xfc\xfc\xfb\x10\xfc?\xfcb\xfc\xa9\xfc\x06\xfd\\\xfd\xd1\xfdA\xfe\x8f\xfe\xec\xfeU\xff\xa2\xff\xf7\xff2\x00k\x00\xa3\x00\xd4\x00\xd3\x00\xc9\x00\xe1\x00\xdd\x00\xc8\x00\xa8\x00\xab\x00\x9b\x00a\x00S\x00[\x008\x00\x16\x00\x00\x00\xfa\xff\xfa\xff\x1b\x007\x00:\x00K\x00o\x00\x80\x00\xac\x00\xf2\x00\x06\x01\x13\x01?\x01|\x01\xa1\x01\xdb\x01\xd9\x01\xcb\x01\xdd\x01\xbe\x01\xbd\x01\xb8\x01\x95\x01e\x01]\x01"\x01\xd0\x00\xa9\x00\x8e\x00G\x00\xeb\xff\xb1\xfff\xff8\xff)\xff\x04\xff\xd6\xfe\xc8\xfe\xcb\xfe\xbc\xfe\xbc\xfe\xc3\xfe\xdc\xfe\xd1\xfe\xba\xfe\xcb\xfe\xe2\xfe\xf4\xfe\x06\xff\x07\xff\x0b\xff\x1c\xff\x03\xff\xfd\xfe%\xff\x14\xff\x0c\xff\x04\xff\r\xff7\xffa\xffj\xff\x80\xff\x9f\xff\xcf\xff\t\x00<\x00r\x00\xa7\x00\xba\x00\xeb\x00/\x01n\x01\xb0\x01\xde\x01\x11\x02L\x02z\x02\xa4\x02\xe0\x02\xf6\x02\xf8\x02\xea\x02\xd5\x02\xb4\x02\x97\x02\x85\x02;\x02\xeb\x01\x92\x01;\x01\xc7\x00W\x00\xf1\xff\x8d\xff7\xff\xea\xfe\xbb\xfe\xa0\xfe{\xfeZ\xfe8\xfe*\xfe1\xfe0\xfe>\xfeg\xfe\xa1\xfe\xd2\xfe\xf8\xfe \xff=\xffm\xff\x83\xffx\xfff\xffb\xff^\xffZ\xffK\xff1\xff\x0c\xff\xd7\xfe\x9e\xfen\xfe7\xfe\xfc\xfd\xd4\xfd\xbb\xfd\xad\xfd\xae\xfd\xbc\xfd\xe4\xfd\x13\xfeQ\xfe\x9c\xfe\xee\xfeI\xff\xb5\xff\x12\x00f\x00\xbf\x00-\x01\x92\x01\xe0\x01/\x02w\x02\xad\x02\xd9\x02\x04\x03\x18\x03\x19\x03\x18\x03\x07\x03\xed\x02\xca\x02\xa0\x02d\x02\x1b\x02\xcd\x01}\x01+\x01\xde\x00\x85\x001\x00\xe4\xff\x8f\xff>\xff\xf5\xfe\xb0\xfev\xfe;\xfe\x06\xfe\xd7\xfd\xba\xfd\xa7\xfd\x93\xfd\x89\xfd\x80\xfdt\xfdg\xfda\xfdd\xfdm\xfdz\xfd\x97\xfd\xc7\xfd\xe5\xfd\xfb\xfd*\xfeg\xfe\x93\xfe\xc1\xfe\xfc\xfe9\xffo\xff\xb9\xff\x03\x00G\x00\x87\x00\xcf\x00\r\x01E\x01\x8b\x01\xc0\x01\xe2\x01\x05\x02\x18\x02&\x02+\x02%\x02\x1b\x02\xfd\x01\xd9\x01\xbb\x01\xa8\x01\x96\x01u\x01H\x01\x12\x01\xec\x00\xc1\x00\x9c\x00|\x00`\x00C\x007\x006\x00<\x007\x00\x1a\x00\x0b\x00\x03\x00\xf8\xff\xed\xff\xe8\xff\xf3\xff\xef\xff\xed\xff\xe9\xff\xe2\xff\xde\xff\xd3\xff\xb3\xff\x89\xfff\xffD\xff\x1d\xff\xfc\xfe\xd7\xfe\xb2\xfe\x8b\xfeh\xfeP\xfe;\xfe\x1f\xfe\x03\xfe\xf4\xfd\xf2\xfd\xf0\xfd\xfb\xfd\t\xfe\x12\xfe9\xfeo\xfe\x9c\xfe\xd1\xfe\r\xffP\xff\x85\xff\xbc\xff\xfb\xffA\x00\x82\x00\xc0\x00\x08\x01E\x01\x81\x01\xb4\x01\xd7\x01\xf4\x01\x10\x025\x02J\x02S\x02V\x02Y\x02]\x02]\x02W\x02E\x02%\x02\x07\x02\xe5\x01\xc0\x01\x92\x01^\x01 \x01\xde\x00\xa6\x00w\x00>\x00\xfc\xff\xbd\xff\x7f\xffG\xff\x11\xff\xd4\xfe\x96\xfe[\xfe4\xfe\x19\xfe\x05\xfe\xf9\xfd\xf5\xfd\xf4\xfd\xf1\xfd\xfd\xfd\r\xfe\x16\xfe\'\xfe>\xfe^\xfe\x82\xfe\xb5\xfe\xe6\xfe\x14\xffE\xffj\xff\x87\xff\xa9\xff\xcd\xff\xf6\xff\x17\x00:\x00]\x00\x80\x00\xa3\x00\xbf\x00\xdb\x00\xe8\x00\xef\x00\xff\x00\x13\x01 \x01$\x01\'\x01!\x01&\x01\'\x01\x16\x01\x04\x01\xf2\x00\xdb\x00\xd5\x00\xc6\x00\xbd\x00\xad\x00\xa1\x00\x9e\x00\xa2\x00\xaa\x00\xa8\x00\xa5\x00\xa4\x00\xac\x00\xbc\x00\xca\x00\xc8\x00\xc9\x00\xc4\x00\xc3\x00\xc8\x00\xb5\x00\x9c\x00\x81\x00f\x00Z\x00J\x00.\x00\x03\x00\xe4\xff\xd0\xff\xb1\xff\x8d\xffd\xff9\xff\x11\xff\x05\xff\xf6\xfe\xe6\xfe\xc6\xfe\xa4\xfe\x8d\xfe\x91\xfe\x90\xfeh\xfeX\xfeQ\xfeM\xfe^\xfez\xfe\x8d\xfe\x9b\xfe\xbb\xfe\xd7\xfe\xff\xfe\'\xffT\xffy\xff\xa5\xff\xd1\xff\t\x007\x00]\x00\x8c\x00\xa7\x00\xbe\x00\xd5\x00\xeb\x00\xfd\x00\x02\x01\xfe\x00\x00\x01\x05\x01\x04\x01\xfb\x00\xed\x00\xe5\x00\xd4\x00\xbc\x00\xa8\x00\x93\x00~\x00k\x00U\x00?\x00%\x00"\x00\x15\x00\x05\x00\xfc\xff\xf2\xff\xee\xff\xe6\xff\xde\xff\xd8\xff\xcf\xff\xbe\xff\xb8\xff\xad\xff\xab\xff\xac\xff\xa7\xff\x9f\xff\x97\xff\x94\xff\x84\xffu\xffc\xff\\\xffS\xff@\xff2\xff$\xff&\xff#\xff\x1d\xff+\xff3\xff>\xffG\xffT\xffb\xffv\xff\x8c\xff\x9c\xff\xbb\xff\xdc\xff\xf4\xff\x07\x00\x1c\x00,\x000\x00F\x00c\x00a\x00k\x00{\x00\x82\x00\x8b\x00\x90\x00\xa2\x00\x9b\x00\x84\x00\x83\x00}\x00s\x00k\x00j\x00`\x00S\x00M\x00N\x00^\x00U\x00P\x00Q\x00`\x00v\x00z\x00y\x00{\x00\x87\x00\x98\x00\xa3\x00\x9b\x00\x91\x00\x89\x00q\x00_\x00T\x008\x00(\x00\n\x00\xf4\xff\xd8\xff\xc3\xff\xaa\xff\x8d\xff\xa3\xffg\xffj\xff\x7f\xffH\xffe\xffH\xff\n\xffk\xff\x0e\xffU\xff\x1d\xff\x19\xff)\xff\xf5\xfe{\xff\x0b\xff_\xff^\xff,\xffi\xffj\xffp\xff\xad\xff\x94\xff\xcb\xff\xdb\xff\xba\xff\xe1\xff\x1f\x00\x00\x00\xf4\xff\xf6\xff\xfb\xff0\x00)\x00~\x007\x00\xc2\x00l\x00t\x00\xcb\x00\xa3\x00 \x01\xda\x00\xf2\x00\xf7\x00\x12\x01\xe9\x00\x9d\x01\x9c\x00\x1a\x02~\xfe\x1a\xffM\x0e\x90\tk\x02o\xfe\x02\xfd\xc5\xff\xff\xfc\xf2\xfc\x93\xfcQ\xfd\x93\xfc\xda\x00\x82\x03N\x00 \xfd\x00\xfe\x07\x03\xf8\x02x\xfc[\xfc\xae\x01\xc7\n:\x08;\xfe\xa1\xfa\x88\xf8\xfb\xfa\xb6\xff\xef\xfda\xfd#\xfe\x88\x03\x11\nk\xfc\xbc\x02\xb8\x07\xad\x03\n\x06z\x05b\x04[\x03\xb0\x07\xce\xfe\xd9\xf8x\xfc+\x01$\x03C\x03M\xfd\x18\xf5\xb1\xef-\xec\xa1\xeb\x8e\xf2\xc1\xf9\xf0\xfb\n\xff\xf6\xfaH\xf7B\xfb \xff\xc9\x00\xf2\x02\xba\x05\xc2\x07b\x01(\x03\xfc\x04\xd3\x08_\x0e`\x08\xc0\x05\xd5\x00-\x04\x99\x07\x88\x07\xee\x03!\x02\xf0\xffn\xfe\xe2\xfd\xa6\xf9D\xfb|\xfd=\xffX\xfe2\xfc\xa0\xfc\xec\xfbQ\xf8\\\xfba\xfb4\xfc\x14\xff\x90\xfd\x15\x00\x06\x01\x1e\x01\x11\x06\x11\x08!\x08"\x04\xf3\x01}\x02\xc5\xff\xc0\x01\x0c\x02\x10\x05\x1a\x02\xb6\x04\x91\x02\xb3\xffi\x01\x90\xfd \xfb\xbb\xf8\x8e\xfd\xdd\xfcw\xff_\x01\xcc\xfd{\xfa\x07\xf7\x04\xfa]\xfe\x07\x01\xa9\x01\xa5\x02\xd5\x00\x98\x01\xfd\x00\xf7\x00k\x01\xca\x01\x98\x01.\x07\xf0\x12Q\x0f\xcb\x08\xce\xffA\xfep\xfb\xdb\xf6\xa5\xf9\xe1\xf7F\xf8\xec\xfd\x8b\xff\x80\xff\t\x01\xc6\xfe\xee\xff8\xfcu\xfa\x01\xfc5\xfa\xab\xfcE\x003\x02]\x02Q\x02\x90\x03\xba\x03\\\x02\xf5\x02\xab\x02\x08\x01~\xffQ\xfe\x86\xfc{\xfa\x1d\xfe\xc4\xfd\xe8\x00\xe1\x01\xbc\x01`\x011\xfd=\xfa@\xf6D\xf94\xfe\xd1\x00|\x02\xc7\xfe\xd8\xfd\x86\xff\xf5\xff\xc9\x01\xcf\x05Z\x08\xab\x07\xba\x048\x01 \xfdJ\xfb\x03\xfb\xe8\xfa\xe0\x01\xde\x06\x11\x06\xb7\x05t\x02\x14\xfd\xa7\xfd\xf9\xf9Y\xfaW\xff\x7f\xfe\xb6\xff\xd2\x007\x00\x8a\xfeE\x00\xc2\x01p\x04\xe9\x04\xe0\x05\xf1\x05&\x01\x13\xfe)\xfb\xce\xfc@\x03_\x04\xe7\x04C\x043\x00\x97\xfc\x82\xfd\xf6\x00\xec\x01\x90\x00\x01\xff6\xfd\xf6\xfa\x1d\xfa\xf2\xf9#\xfd\xcd\x01\xca\x04\xa6\x06\xf2\x03\x95\xff\x9d\xfd:\xfa\xab\xf8\xe7\xfa\xec\xfd\xb5\x01\xf5\x034\x03L\x01\xac\xfc\xd2\xfc=\x00\x12\x01\x84\x01u\x01"\x03\x0b\x02\xc8\xfeL\xff\xbd\xff\xe8\xff\xf5\x00\xe4\x00\x81\xff\x90\xfco\xfd9\xff\xf7\xfeQ\x04\xb2\x05\x11\x03\xf2\xfe/\xfcA\xffG\xff\xdc\x01\x97\x03\xe8\x00\x9e\x00\x1b\x01\xb5\x00e\x01\xca\xfe\xd8\xfe&\x00\'\x01\xde\x00y\xff\xe6\xfc\xe6\xfc\x8a\x00\n\x01\xcf\x02h\x03\x0c\x04-\x03\xfe\x00<\xff?\xfdb\xfa\n\xfa\xd2\xfc?\x01\xc1\x04\xd9\x04\x9e\x04Y\x02\xd9\xfe\xde\xfb\xe0\xfa\xe7\xfa\xa3\xfe\x08\x03\xf7\x04D\x03#\xfe\xd5\xf8B\xfc\x1b\xff=\xff|\x03\x82\x04\x1d\x01\xd0\xfdN\xfeg\xfdN\xfc\xfc\xfb\xe8\xfcy\x00\xdc\x02V\x00\xa6\xff\xc4\xff\xb8\xfd\xb3\xfcr\xfb;\xfe$\x02\xd3\x05\xaa\x06^\x01\x08\xfe\xe7\xfd\xfe\xfdu\xffb\x00\xbc\x01\xf8\x03\xf6\x04\xed\x03[\x01y\xfeZ\xfe\xe1\xffw\xff>\x03\x03\x04(\x01\x08\xfe\xd5\xfb\x1e\xfd\xce\xff\x9d\x02X\x015\x01\x87\x00\xfc\xfe\xf0\xfd\'\xf8\x9e\xf5\xc8\xf9\xac\xfei\x03%\x05\x05\x03\x8e\x00\\\xfeG\xff[\x00\xec\xfe\xd3\xffI\x03 \x07\xcd\x04\xd9\x01\xae\xff\xec\xfb\xd8\xfb\xad\xfa\xa2\xfd\xdc\x03s\x07\xab\x05L\x00\xa9\xfcy\xf9\x99\xf9\xf9\xfc\x0c\xff\xfd\xff\xe9\x01\x82\x02\xac\x01\x85\xff+\xfc(\xfa<\xf9N\xfau\xfd\xb4\x00\xef\x03Z\x05\x11\x05j\x03\xdd\x00\xd5\xff\xd6\xff\xd5\x00\xc8\x01w\x01\xbf\x00\'\x01\xb8\x01y\x00\x07\xff@\xff\xcb\x01\xb8\x01B\x03n\x04*\x01\x85\xfeK\xfb\x83\xfbN\xfdq\xfd_\xfe\xb1\x00U\x04\x8b\x03\xbf\x01\x18\x00(\xfdH\xfd\x9f\xfdU\xff\xa9\x00&\x00\x97\xff\x9a\xff\x8f\x00\x1e\x00\x81\x00J\x01%\x01\xda\x00+\xff\xb8\xfd=\xfe\x9f\xfe\xdd\xfe\'\xff\xa7\xff-\x00\xf1\xff`\xfe\x80\xfdM\xfe^\xfe\xb0\xff5\x01\xda\x01\x9a\x02\x87\x01\x06\x01\x13\x01\xc7\xff\x83\x01\xfa\x02\x97\x03|\x04\xd6\x02\xd2\x00\xbf\xfe\xc0\xfb\xc6\xfb~\xfeD\xfe\x05\x00\xb1\x02\xfe\x01\xfc\xff\x07\xfe\xfa\xfc\xaa\xfd\xd7\xff\xf0\x01\xda\x02\x8d\x024\x02\xc2\x011\x01\xb1\x00\x19\x00L\xff\xf1\xff\xac\x00|\x00*\xff9\xfe\xa8\xfd\x80\xfc\xf5\xfd\x94\x00\x86\x03^\x047\x043\x03K\x01\xe6\x00\xf0\xff5\xff;\xff4\xfd\xdd\xfc\xb1\xfex\xfe\xd8\xfe\n\xfe\xdb\xfcg\xfd\x8f\xfdC\xfds\xfd\xc2\xfeL\x00\x9d\x01\xc4\x02p\x04\n\x03}\x01\x8d\x00\x01\xff\xc7\xfe!\xff\x97\xff\x1f\xfe(\xff\xe4\xff\x9a\xfd\xc6\xfc\x8f\xfc\xd9\xfd\xe4\xff\x82\x01w\x03+\x06\xd8\x07\x10\x06T\x03\xbb\x00[\xff(\xfe\x1e\xfd\xfd\xfc\x88\xfd%\x00\xba\x01\xe9\x01\x13\x01\xa8\xffj\xff"\xfe\x93\xfd\xc1\xfd;\xfe\x98\xffr\x00\xed\xff\\\xff\x86\xfe\x81\xfeF\xffq\xff\x17\x00\xfb\xffI\xff\xda\x00\xf1\x02\xb0\x02{\x02\xc3\x02\xaa\x02\x1c\x03U\x03\x10\x02k\x01k\xffh\x00\xab\x00\xcd\xff\xf9\xff:\xfe\xaa\xfe\x10\xffE\xffB\xff\x1e\xffu\xff>\x00\xe3\x00\xb5\x00\xd9\xff\xba\xff\xc3\xff\xb5\x00`\x02\xe6\x02 \x04\x8d\x04\xbd\x04>\x04\x99\x03\xc9\x02\x1b\x01\x96\xff]\xff\xfa\xff\xd3\x00\x1c\x01K\x00\x13\xff\x0e\xfeq\xfd\xed\xfcl\xfc\xa3\xfb\x07\xfcb\xfdN\xfd\x05\xfd\xd3\xfc\x88\xfcr\xfc|\xfc\x89\xfc\x8a\xfc\t\xfdy\xfdH\xfe\xfd\xfeh\xfe\x0c\xfe\x8a\xfdM\xfd\xd8\xfc\\\xfb\x85\xfa\x0b\xfa \xfa\x00\xfa\xc1\xf9\xff\xf9B\xf9\xde\xf8\xe7\xf8\xc8\xf8\xa7\xf8\r\xf8\xa9\xf8-\xfak\xfbw\xfc\x85\xfd\xbc\xfe\xd7\xfe\xd7\xfe\xbb\xfe\n\xfeo\xff0\x00G\x01\xc9\x02U\x02\xed\x02#\x03]\x03%\x03d\x01f\x01+\x01>\x02\x9c\x03\n\x04\xb1\x05 \x05r\x05\xf9\x04\xdb\x039\x03>\x02\x85\x02t\x04U\x07\x9c\tW\x0b\xc4\x0c\xeb\r\xd7\x10\x99\x13t\x15t\x18\xda\x1b/ h#\xc6#\x16"Q\x1d\xe4\x17\xe4\x11<\x0b\xd6\x04\xc6\xffZ\xfc\x16\xfa\xcb\xf8\xc0\xf6\xc5\xf3\xcd\xf0\x1d\xee2\xecb\xeb;\xeb\x04\xec\xcd\xed\x01\xf0\xe6\xf1\xf7\xf2n\xf3\x92\xf3\xcc\xf4\x8e\xf6\xf6\xf8\x06\xfc\n\xff\xe6\x01\xf4\x03f\x04c\x03=\x01\x86\xfe\x04\xfc\x00\xfaG\xf8B\xf6c\xf4A\xf2\xd9\xefv\xed\xd4\xea7\xe9\xd0\xe8\x86\xe9\x8d\xeb=\xee\xf6\xf05\xf3F\xf4\xab\xf4\x1d\xf5\x83\xf5H\xf6M\xf8\xfd\xfa\xcd\xfd\xe4\x00\x14\x03\xe9\x03\x84\x03\xf6\x01\xb4\x00\x92\xff\x00\xff\xf0\xfe\xca\xfe\xda\xfe2\xfe\x98\xfdi\xfc\x95\xfa\xf8\xf9\x03\xfa\xb2\xfb\x88\xfeY\x01\x81\x03\x1f\x05\xe3\x05\xd7\x06\xed\x07C\x08\xbc\t\xd9\nF\x0c\xc8\x0eD\x0f\xca\x0eY\x0c\x17\x08\t\x06\xf8\x06L\x0c1\x15%\x1f<)\xe21K7\x837\xf03\xdd-}(\x86%u#G!\x15\x1de\x16\x04\r\xce\x01\x08\xf6\xbf\xebv\xe6\xed\xe55\xe8\xd8\xeb\xe5\xed\xb1\xed\xec\xeb\xaa\xe8\x00\xe6\xc8\xe4O\xe5\xc2\xe8:\xeeW\xf4"\xf9d\xfb\xa8\xfb\xcb\xfb\x8f\xfd\xd1\x00\xab\x05s\ni\x0e\xc0\x10\xdc\x0f\x86\x0bU\x04P\xfcx\xf5\xb0\xf0m\xee:\xed\xbe\xec\xfc\xeb;\xea"\xe8\xed\xe4F\xe2\xe9\xe1\xc0\xe3\xdd\xe7K\xecg\xf0\x8f\xf3\xf8\xf4\x81\xf5\xe5\xf5\xab\xf6\x9b\xf8\x8f\xfb\xf9\xfe\xbc\x01;\x03D\x03z\x01:\xff\x8c\xfd\xcb\xfc7\xfd\xb2\xfdG\xfe\xaf\xfdw\xfcx\xfak\xf7\xbe\xf5j\xf4%\xf5\xc8\xf7\x8f\xf9\x8b\xfc\xe4\xfdD\xfe|\xff\xde\xfe\xc9\xff\xc4\x004\x02\x03\x06\x81\t\xda\r\xd3\x10\x8d\x12>\x12<\x0f\x01\x0b\xe4\x04Y\x00\xf6\xfe\xb5\x01a\n\xa6\x16\xf5$\x822\x11<\x86@\x02A\xe7>\x02\xfa+\xf8\x04\xf6p\xf5\x8a\xf4\xcd\xf3v\xf3S\xf2\x00\xf2\xea\xf1w\xf2\xca\xf4\xbc\xf7\xbd\xfb0\xff\x01\x01\xee\x02\x15\x03\xb5\x03\x11\x05<\x06\xb4\x08\xca\t3\n`\n`\x08\x98\x06V\x03\xca\x00\xa7\x00,\x02z\x07z\x0e\xdf\x17r#\xa9.\xdf9\xdaB!IXL\x08KEF\x91<\xfc/\xb3!\x8e\x11,\x03F\xf5\xe2\xe8\x15\xdf\x83\xd7%\xd4Z\xd4\'\xd8\xc6\xde*\xe6\xf8\xec\x99\xf1\xa3\xf4_\xf6\xce\xf7\x17\xfa\xa8\xfdy\x02\xad\x07\x05\x0c\x19\x0f\xe6\x10\x98\x11\xb0\x11\xc1\x10\xa2\x0e\xd1\n\xce\x04K\xfd\xbe\xf4M\xec|\xe55\xe0\xb3\xdd\x98\xdd\xef\xde\xc9\xe1\xf1\xe4\x05\xe96\xeeA\xf3\xc0\xf7I\xfb-\xfd\x97\xfe\x8f\xff\xf2\xffZ\x00-\x00\xd5\xffv\xffp\xfe\xce\xfc\x10\xfb\x0b\xf9\x07\xf8\xd8\xf7\xbc\xf7{\xf7\xa8\xf6s\xf5\xb8\xf4\xd2\xf4\xaa\xf5\xe8\xf6*\xf8\xaa\xf8\xee\xf7K\xf6k\xf4\x16\xf3\x11\xf3\xda\xf3\xcc\xf5\xbf\xf89\xfbK\xfd\xbd\xfe\xaf\x00\xa6\x03\xd3\x06b\x08|\x08\x80\x06\xd0\x03\xc3\x01.\xff`\xfd\xee\xfbH\xfb\x00\xfb\xe2\xf8,\xf5*\xf2\xb6\xf5\xac\x02a\x17\x0e/\xc5CnS\x8b]\xdcbzc\xe0^8V\xe6J3;\x83&\xad\x0c1\xf1 \xda\xb7\xca\xe3\xc3\x1d\xc4\xb6\xc7{\xcc\x9e\xd2\x80\xd9v\xe2\x01\xec^\xf5R\xfd\x01\x03\xdd\x06\xa3\x07\x8e\x06\xed\x04\xd9\x04\x8b\x08\xf2\x0e\xc8\x151\x1a\x99\x18\xc7\x12\xfa\t\x9b\x01F\xfa\xd4\xf2\x86\xebv\xe2\xbc\xd9\xc3\xd2\x13\xcf?\xd1\xdf\xd6:\xdf\xb0\xe8\xe2\xf0\xba\xf8\xbf\xfe\x80\x03j\x07\xd7\t\xd3\n\xc4\t\xa7\x06\xd0\x02^\xff\xd7\xfc\xc6\xfb\x0f\xfb\xe3\xf9\xf7\xf7\xac\xf5\x8a\xf4}\xf4\xd0\xf5\x90\xf8\xb9\xfa"\xfcn\xfb\xbc\xf8\x17\xf6\x19\xf6\xe5\xf8q\xfc\xfe\xfd \xfbL\xf6%\xf2\xb2\xef"\xf0\xd2\xf1\x9e\xf4\x04\xf8\xa8\xfa\x85\xfdC\x00\x1c\x03\xdb\x06\xba\n\x07\rH\x0c\xc7\x08S\x04\xa1\xff\xde\xfb4\xf8\xfb\xf3c\xf0\x1b\xec\xc4\xe7\xaf\xe4\xe1\xe6\xe0\xf4G\x0e\x06.=IkY\xc9b\'hwm2p\xfdh\xdfX\x11B\xaf&6\t\xdd\xe9\xb6\xce\xc7\xbc\xff\xb5\x08\xb7[\xb8\x83\xbam\xc1\xf4\xd0\xd8\xe7\xd8\xfd;\x0c\x7f\x12\xf0\x14\xc2\x160\x18\xbe\x183\x18u\x17\x03\x176\x16\xa6\x13\xe6\x0e\x8e\n\n\x08L\x06~\x002\xf5\xb2\xe6F\xd9.\xd0O\xcb\x96\xc8I\xc7.\xc8\xb5\xcd#\xd8\xbc\xe6\x04\xf7p\x06N\x13\x1b\x1c?!\xe2!2\x1f\x05\x1b\xa9\x14\xf6\x0b\xa0\x00\x07\xf5\xfa\xeb\x96\xe6t\xe4}\xe4D\xe5y\xe6\xf0\xe8f\xed.\xf3\xe5\xf9o\x009\x05\xfe\x07A\tp\tS\tn\x08k\x06\xde\x02\xe0\xfdl\xf8\xf3\xf2 \xef\xc2\xed\x0b\xee\x9f\xef\x99\xf12\xf4\xdb\xf7\xe0\xfb\xd4\x00\xca\x04\xaf\x06-\x07x\x05\xe7\x01\xaf\xfd\x12\xfa\xdf\xf6\xe2\xf1|\xe9\x08\xe0\xd7\xda\x8a\xde3\xebR\xfbi\x0b\xea\x1e&6?P"h\x90v\xa4zwwJo\x02bAO\xe46\xb0\x17b\xf7\xe1\xdb\x04\xc6j\xb7\xad\xafD\xaeb\xb3\x18\xbeQ\xcdh\xdf\xb0\xf2\xa1\x03~\x0f\xf5\x15\xff\x17,\x18\xe6\x187\x19w\x17\xaa\x13\xbf\x0e|\n\x0e\x08%\x07\x0c\x06\xaf\x01\xbb\xfa\x85\xf2V\xea\x08\xe4\xc6\xde_\xd9\xf8\xd40\xd2\x1a\xd4:\xda)\xe3C\xee\xce\xf9\xcb\x04[\x0fM\x17\xd3\x1b\xb1\x1cd\x19\x1e\x13\xae\n\x80\x01\x94\xf7\xab\xecp\xe3\xa9\xdd\xa7\xdbL\xdeQ\xe3\x1f\xe8\'\xeek\xf5\x7f\xfdz\x05V\x0bY\x0e\xac\x0f\xe7\x0ed\x0cs\x08\x16\x04G\x00v\xfcA\xf7V\xf1\x0b\xeci\xe9\xeb\xe9\x87\xeba\xed\x9f\xef\xf2\xf2\xbe\xf7\xbf\xfc\x90\x01(\x06\x19\n\xbc\x0c\xf0\x0c\xa2\n%\x06w\x01\xc7\xfc\xdf\xf5\xbd\xef\xf5\xe9`\xe4\xdb\xdf\x7f\xda\x9b\xd9\xc3\xe3s\xfar\x18\x9d1fC\xb1P9_%r\xac\x7f\xff\x7f#rSYR?\x06(x\x0e\xfc\xf1"\xd5R\xbc\xc8\xac5\xa8D\xab\x8b\xb3\xf4\xc0\xe1\xcf\'\xe0W\xf2.\x03B\x12Z\x1fb%&$) p\x1c\xd1\x196\x18J\x14/\x0c\x9d\x02[\xfb\x9b\xf7\x99\xf6\x05\xf5\x16\xf0\x1a\xe7\xbc\xde1\xdb\x96\xdb9\xdd\x82\xde^\xdeb\xe0t\xe7&\xf2=\xfdS\x06w\r\xa9\x11\x9b\x11"\x11 \x11\xc1\r\xd8\x07\x91\xff\xed\xf4\xfb\xec\xd9\xe9p\xe8{\xe6\xdc\xe5\xea\xe7\xa7\xec\xbe\xf3\xdb\xfb\xca\x012\x06W\n\x0f\r\xc6\r\xb3\r\xa9\x0b\x81\x06\'\x009\xfa\xde\xf4c\xf0\x17\xedq\xeaV\xe9S\xeb\x8a\xefe\xf4)\xf9\x06\xfe\x98\x02q\x06\x9c\t\xb9\x0bS\x0cR\x0b:\x08}\x03\xef\xfdR\xf8p\xf3\xf0\xee\xcf\xeb=\xeaG\xe7\x94\xe2\xa0\xe0\xe3\xe5\xc9\xf4\xfe\n\xef \xe50o>\xd5P\x18e-t\x0fygr&d\x1fT\x10B\xb8)\xec\x0c\xa7\xf0\x87\xd5+\xc0i\xb3\xaf\xad!\xaf\x9c\xb5s\xbe\xf5\xc9\x81\xdat\xef(\x03\xcd\x10a\x17\xa1\x19\x05\x1c>\x1f\x12 &\x1c\x18\x14\xce\x0bi\x06c\x04\x83\x02\xdc\xfe\xeb\xf9[\xf5\xd1\xf1\x9e\xefS\xed\x1a\xea\xf8\xe5\xfb\xe1\x14\xdf\xb1\xde\\\xe1\x83\xe66\xec\x9f\xf0\xf6\xf4\xaf\xfb\xc5\x04+\r,\x11\xa9\x0f\xfc\x0b\x87\t\xe8\x07L\x04\xda\xfd\xdb\xf5\xe4\xee\xa8\xebm\xeb\x9d\xec\x9e\xee\xa2\xf0\x1e\xf3\xd5\xf7\xc9\xfd\xcc\x03\x0e\x08\xf4\x08T\x07\x84\x05\x82\x03N\x00\xa2\xfb\'\xf6\x08\xf1@\xedj\xeb\x83\xebQ\xed\xb7\xf0\x8e\xf4\x10\xf8?\xfc\xba\x01}\x07\x92\x0b\x8a\x0c#\x0b\xe4\x08\xbb\x06\x95\x04Q\x01V\xfc\xa2\xf55\xf0\xce\xec\xb3\xea\x8b\xe9\xa5\xe5\r\xe0A\xe1\x94\xedP\x02\xd9\x17\xab&\x981N@LVMl\xdbw1v#l``\xf4S\xc9A\xac(\x9d\x0b\x94\xef\x15\xd8`\xc4\x15\xb6\xc4\xae\x05\xae\xa8\xb1\x14\xb8\x8a\xc2\xa2\xd1\'\xe5^\xf8\x06\x04\xd4\tB\x0fL\x15\xf5\x1bm\x1fA\x1c[\x15\xaf\x10\xdf\x0ec\r\x04\x0b~\x07\xc5\x02\xd0\xfe[\xfb\xd4\xf6O\xf1\xb8\xec0\xe8\x93\xe2\x05\xdd\xbc\xd9\xec\xd9\xea\xdd\xb6\xe3\x92\xe7\x86\xea\x81\xf0&\xfa\x98\x03(\t5\nz\t\xa5\t9\x0b\xd0\n+\x06\x19\x00\x1c\xfb\xbf\xf7\xef\xf5\xb7\xf5\xae\xf5\xdc\xf5\xb4\xf6m\xf8\xd1\xfa\xc4\xfd\xd4\x00J\x02*\x01w\xff\x8e\xfd\n\xfb\x1e\xf9d\xf6\x98\xf2\xc1\xef\xb0\xee\x98\xef\x17\xf2C\xf5\x81\xf8\xf7\xfb\xb5\xffR\x04\xb6\x08\xb0\x0b-\r\xdc\x0ck\x0b\x03\t\xaa\x05\x98\x01\xc7\xfc\xaf\xf7\xc4\xf2\x0f\xee\\\xeb\xb1\xe9\x02\xe7R\xe3\xdc\xe0\xaf\xe4I\xf1g\x03\xd5\x14\x1e"\x1b.\x00?\xeaS\xe1e2n\xfck\xacd\x8a][T\x96D/.\xf7\x14g\xfd\xd5\xe9\xeb\xd9\x9e\xccX\xc2\xf9\xbb~\xb9X\xbb6\xc2\x96\xcc\x9e\xd7\xdd\xe0@\xe8\x90\xefA\xf8p\x01f\x08\xa3\x0b-\x0c\x07\r\x14\x10\xbb\x14\x0b\x18\xaf\x18\xa5\x17\x9e\x16\xe7\x15\x99\x14>\x11\x85\x0b\x00\x04\x1d\xfc\x1f\xf4\x96\xec\xcc\xe5\xf6\xdf"\xdb>\xd7o\xd5\xd5\xd6\xd7\xda\xc9\xdf\x1d\xe4\xc9\xe7\x94\xec\x1e\xf3$\xfaO\xff\xe7\x01\x1b\x03\x80\x04\xb8\x06\x9a\x08p\t\xf3\x08\xc2\x07\x85\x06\xf9\x05\x05\x06\xf5\x05\x05\x050\x03\xd9\x00\xe7\xfeg\xfd\xc1\xfb.\xf9\x17\xf6G\xf3/\xf1[\xf0\x94\xf0h\xf1\xa1\xf2I\xf4\xd5\xf6\x91\xfa^\xffg\x04\xca\x08\x00\x0c[\x0e\n\x10%\x11v\x11e\x10\xf0\r\xa6\n<\x07\xdb\x03~\x00,\xfd\x05\xfa \xf7[\xf4J\xf1|\xee\xac\xed;\xf0\xfa\xf5\xd5\xfcO\x03\x9b\t_\x113\x1bq%l-I2\xb14\xf15\x056\xd83\xb5.F\',\x1f%\x17\x19\x0f\xd8\x06\xaf\xfe\xc4\xf7m\xf2<\xee\xef\xeai\xe8\x07\xe7\xda\xe6\x10\xe7\n\xe7\xf0\xe69\xe7+\xe8\x7f\xe9\xa2\xeat\xeb^\xec\xef\xedA\xf0%\xf3\xdf\xf5|\xf8?\xfb<\xfec\x01)\x04W\x06-\x08\xae\t\x9c\n\xa8\n\xc4\tU\x08\xcb\x06\xed\x04\xa7\x02\xdd\xff\x06\xfd\xb6\xfa\xd9\xf8J\xf7\xba\xf5N\xf4b\xf3\xe7\xf2\xea\xf20\xf3\x8d\xf3=\xf4.\xf5.\xf6=\xf7@\xf8z\xf9\xc0\xfa\x0f\xfc7\xfd;\xfe<\xffB\x00R\x01L\x02\xf8\x02@\x03y\x03\xcf\x03^\x04\xf6\x04K\x05t\x05y\x05r\x05T\x05\x13\x05\xcb\x04{\x04\x11\x04\xa5\x03\x0b\x03|\x02\xbe\x01\xea\x008\x00\x81\xff\xb0\xfe\xc8\xfd\xfa\xfc\x80\xfc:\xfc\xf8\xfb\xa9\xfb2\xfb\xea\xfa\xcb\xfa\xc7\xfa\xee\xfa\x1d\xfbh\xfb\xb5\xfb\n\xfc[\xfc\xa9\xfc\x02\xfdH\xfd\x8c\xfd\xb3\xfd\xb5\xfd\xe2\xfd]\xfe\x1f\xff1\x00\x91\x01A\x03#\x05\xf7\x06\xa9\x08I\n\xf5\x0bu\r\xca\x0e\xd4\x0fx\x10\xf4\x10l\x11\xf5\x11a\x12\x90\x12{\x12\x08\x12f\x11\x99\x10\x80\x0f \x0et\x0c\x95\n\x9d\x08s\x06\x05\x04f\x01\xe4\xfe\xae\xfc\xe4\xfa]\xf9\x0c\xf8\x1f\xf7\xcb\xf6\xbc\xf6\xf9\xf6e\xf7\xf5\xf7\xb8\xf8O\xf9\x8e\xf9U\xf9\xc4\xf8\x16\xf8U\xf7\xa1\xf6\xec\xf5u\xf5B\xf5k\xf5\xdc\xf5\xa2\xf6\x96\xf7\xb6\xf8\xbd\xf9\x87\xfa\xfa\xfa\x1e\xfb\x12\xfb\xc1\xfa.\xfak\xf9\x97\xf8\x02\xf8\xd5\xf7\x19\xf8\xc1\xf8\x9d\xf9\xd2\xfa<\xfc\xba\xfd:\xff\x95\x00\xd6\x01\xdd\x02\xaa\x03H\x04\xa0\x04\xaf\x04\xa8\x04k\x04\x0b\x04\x99\x03\x1d\x03m\x02\xa4\x01\xc8\x00\xf3\xff\x1a\xff]\xfe\x9f\xfd\xde\xfc=\xfc\xdd\xfb\xc9\xfb\x1e\xfc\xc0\xfc\x9f\xfd\x94\xfe\x86\xff\x87\x00\x85\x01\x84\x02\x80\x035\x04\xa4\x04\xe3\x04\xf4\x04\xdf\x04\xa0\x04\x17\x04l\x03\xa4\x02\xc9\x01\xec\x00\xe2\xff\xdf\xfe\xf1\xfd\x06\xfd8\xfc|\xfb\xe6\xfa\x87\xfaQ\xfam\xfa\xc6\xfa\x83\xfb\x8d\xfc\xc9\xfda\xff\r\x01\xdf\x02\xc3\x04\xb8\x06\xb5\x08\x92\n8\x0c\x9b\r\xb1\x0ev\x0f\xef\x0f\x1e\x10\xcf\x0f\x0f\x0f\xf7\r\xc7\x0c\x9a\x0be\n\x07\t\x9e\x07q\x06\x99\x05\xe8\x04B\x04\x95\x03\x05\x03\x97\x02+\x02\xab\x01\x08\x01V\x00\x9a\xff\xb9\xfe\xa8\xfd\x84\xfcw\xfb\x86\xfa\xaf\xf9\xbc\xf8\xbf\xf7\xff\xf6\x95\xf6s\xf6]\xf6B\xf6C\xf6y\xf6\xe4\xf6T\xf7\xb8\xf7\xf0\xf7:\xf8\x98\xf8\xf7\xf84\xf94\xf9\x14\xf9\xfc\xf8\xea\xf8\xf8\xf8\x00\xf9!\xf9s\xf9\xfc\xf9\xb3\xfav\xfb^\xfcY\xfdC\xfe\x11\xff\xb0\xffA\x00\xa7\x00\xd5\x00\xbc\x00k\x00\xf8\xff\x8e\xff\x10\xff\x8c\xfe\x1f\xfe\xe0\xfd\xb7\xfd\xbe\xfd\xf4\xfda\xfe\x08\xff\xc8\xfft\x00\x06\x01\x8e\x012\x02\xe4\x02\x98\x03\x15\x04K\x04]\x04z\x04\x9a\x04\xa8\x04\x88\x04-\x04\xb8\x03"\x03\xa3\x02\x0c\x02s\x01\xd5\x00\x16\x00V\xff\xae\xfe^\xfeg\xfe\x92\xfe\xc0\xfe\xe4\xfe\x1c\xff\x8c\xff\xf6\xffE\x00J\x00\x08\x00\x92\xff\xf5\xfeT\xfe\xa5\xfd\xd7\xfc\x0c\xfcg\xfb\x19\xfb.\xfb\xa2\xfbg\xfcG\xfde\xfe\xbe\xff9\x01\xc8\x02)\x04P\x05K\x06)\x07\xf2\x07\x9d\x08\x05\t%\t5\tM\tm\tp\t7\t\xd9\x08\x80\x08!\x08\xcd\x07o\x07\xde\x06>\x06\x87\x05\xce\x04$\x04\x8b\x03\xf8\x02S\x02\xaf\x01"\x01\xa4\x002\x00\xcd\xffR\xff\xc9\xfeI\xfe\xd5\xfdv\xfd\x1a\xfd\xbf\xfcV\xfc\x16\xfc\xd4\xfb\xa4\xfb\xa9\xfb\xb6\xfb\xce\xfb\xde\xfb\xc4\xfb\x92\xfbW\xfb\x0b\xfb\xa6\xfa"\xfa\x97\xf9\n\xf9\x85\xf8\x0f\xf8\xc7\xf7\xba\xf7\xd3\xf7\x08\xf8@\xf8\xbf\xf8w\xf9n\xfam\xfb4\xfc\xe0\xfc\xa8\xfdy\xfe"\xff\x98\xff\xbd\xff\xc8\xff\xe9\xff\x10\x00\x1e\x00\x05\x00\xcb\xff\x9f\xff\x83\xffm\xffd\xff^\xffy\xff\xa9\xff\xf8\xffV\x00\xe2\x00\x8a\x01M\x02&\x03\xec\x03\x9c\x04\x1e\x05o\x05\x8f\x05o\x05 \x05\x95\x04\xde\x03\'\x03i\x02\xad\x01\xfa\x00\\\x00\xf6\xff\xac\xff\x98\xff\x80\xffq\xffe\xff`\xffa\xffc\xffC\xff\x0e\xff\xd4\xfe\xb2\xfe\xbc\xfe\xdb\xfe\x12\xffB\xff\x8b\xff\xdf\xff5\x00t\x00\x8d\x00|\x00G\x00\xf1\xff\x81\xff\x02\xff\x83\xfe"\xfe\xd1\xfd\xa1\xfd\x88\xfd\xa0\xfd\xef\xfd`\xfe\xcf\xfe7\xff\xae\xff.\x00\xbc\x00O\x01\xe2\x01|\x02*\x03\xfd\x03\xf0\x04\xf7\x05\x01\x07\r\x08\x12\t\x0c\n\xde\ni\x0b\xb4\x0b\xaa\x0b\\\x0b\xc8\n\xe0\t\xc7\x08\x8f\x079\x06\xd2\x04q\x03\x1d\x02\xe9\x00\xd8\xff\xef\xfe*\xfe\x90\xfd#\xfd\xe2\xfc\xbf\xfc\xa7\xfc\x91\xfc\x9f\xfc\xac\xfc\xaf\xfc\x8d\xfcE\xfc\xf3\xfb\x89\xfb\x02\xfb_\xfa\x9c\xf9\xdc\xf8+\xf8\x87\xf7\xf4\xf6|\xf6A\xf60\xf6E\xf6t\xf6\xbd\xf6*\xf7\xad\xf7K\xf8\xf2\xf8\x8f\xf9E\xfa\x02\xfb\xbf\xfbt\xfc\x14\xfd\xbc\xfdR\xfe\xd9\xfeG\xff\xa1\xff\xe2\xff\x1d\x00I\x00Y\x00c\x00`\x00`\x00p\x00\x83\x00\xa6\x00\xd1\x00\x0f\x01f\x01\xda\x01[\x02\xde\x02X\x03\xc8\x036\x04\x96\x04\xd5\x04\xfa\x04\xf3\x04\xc5\x04y\x04\t\x04\x8f\x03\r\x03\x86\x02\x06\x02\xa1\x01U\x01,\x01\x1e\x011\x01P\x01w\x01\x9d\x01\xb2\x01\xa0\x01c\x01\xfc\x00v\x00\xc5\xff\xfa\xfe-\xfeh\xfd\xc4\xfc9\xfc\xe3\xfb\xb3\xfb\xa8\xfb\xca\xfb\r\xfc_\xfc\xaa\xfc\xe5\xfc\x07\xfd\x1b\xfd%\xfd\x14\xfd\x01\xfd\xe9\xfc\xd5\xfc\xde\xfc\xf6\xfc-\xfd\x85\xfd\xee\xfde\xfe\xe2\xfek\xff\xf9\xff\x96\x003\x01\xd3\x01~\x028\x03\x08\x04\xf1\x04\xed\x05\xf3\x06\xf6\x07\xee\x08\xe2\t\xbf\nt\x0b\xf6\x0bA\x0cX\x0c@\x0c\xf4\x0by\x0b\xd5\n\t\n\x1e\t6\x08K\x07P\x06k\x05\x8b\x04\xb4\x03\xf1\x022\x02\x85\x01\xde\x002\x00\x88\xff\xdb\xfe5\xfe\x97\xfd\xf3\xfcW\xfc\xd8\xfbl\xfb\x13\xfb\xd2\xfa\x94\xfa\\\xfa7\xfa&\xfa\x15\xfa\xf3\xf9\xc4\xf9\x97\xf9k\xf9.\xf9\xe8\xf8\xa0\xf8j\xf8=\xf8\x17\xf8\xff\xf7\xf9\xf7\t\xf8)\xf8S\xf8\x9a\xf8\xea\xf8M\xf9\xc6\xf99\xfa\xb4\xfa/\xfb\xa7\xfb#\xfc\x9c\xfc\x1b\xfd\xa8\xfd;\xfe\xd3\xfek\xff\x11\x00\xc0\x00u\x010\x02\xde\x02s\x03\xed\x03\\\x04\xa8\x04\xd7\x04\xe8\x04\xc3\x04\x9c\x04\\\x04\x10\x04\xb9\x03Y\x03\xfd\x02\xa3\x02^\x02\x1c\x02\xef\x01\xd0\x01\xa9\x01\x9b\x01\xa6\x01\xb8\x01\xc5\x01\xcf\x01\xd1\x01\xc0\x01\xaf\x01\x8a\x01;\x01\xe7\x00\x8a\x00\x1e\x00\xb0\xff@\xff\xcf\xfeg\xfe\x05\xfe\xb4\xfds\xfdE\xfd\x1e\xfd\x0b\xfd\xfb\xfc\xfa\xfc\x0c\xfd\x1c\xfd1\xfdE\xfdu\xfd\xa4\xfd\xbe\xfd\xe7\xfd\x1f\xfeX\xfe\x93\xfe\xcd\xfe\x03\xff=\xff}\xff\xbf\xff\xfb\xff+\x00P\x00\x84\x00\xb7\x00\xdd\x00\xf8\x00!\x01K\x01\x7f\x01\xc3\x01\xf5\x01\'\x02P\x02\x81\x02\xb5\x02\xcc\x02\xf3\x02\x01\x03\n\x03,\x03\x18\x031\x037\x03=\x03f\x03\x80\x03\xc6\x03\xef\x03(\x04_\x04\x7f\x04\x9f\x04\xad\x04\xa4\x04\x94\x04q\x04?\x04\x02\x04\xaa\x03a\x03\x10\x03\xb0\x02l\x02\x18\x02\xd4\x01\x8b\x019\x01\xfb\x00\xa7\x00W\x00\x03\x00\xbb\xff[\xff\xf2\xfe\x86\xfe\x1b\xfe\xb4\xfd9\xfd\xc1\xfcB\xfc\xd1\xfbT\xfb\xfb\xfa\x9a\xfaR\xfa\x10\xfa\xd9\xf9\xc2\xf9\xaa\xf9\xa2\xf9\xbc\xf9\xd6\xf9\x00\xfa,\xfak\xfa\xca\xfa\x1e\xfbs\xfb\xd5\xfb?\xfc\xa9\xfc\r\xfd~\xfd\xe5\xfd4\xfe~\xfe\xcf\xfe,\xff\x80\xff\xc1\xff\x17\x00g\x00\x9d\x00\xaf\x00\xdb\x00\x0b\x01\x15\x01\x1b\x01(\x01L\x01P\x01.\x01$\x01\xfb\x00\n\x01\x13\x01!\x01_\x01_\x01]\x01c\x01\r\x01\xf3\x00\xcf\x00\x94\x00\x10\x01\x1f\x01\x06\x01\x1b\x01\xcc\x00\xa1\x00\x91\x00u\x00\x85\x00\xe1\x00\xe1\x00\xa4\x00f\x00J\x00\x16\x00\x14\x00\x98\x00\xdf\x00\xe6\x00D\x01P\x01P\x01%\x01%\x01\xcf\x00\xd5\x00T\x01\xb1\x01^\x01\xe9\x00\x01\x01\x10\x01;\x00\xf3\xfe\xe0\x00\xc4\x06\xc3\t\x91\x08j\x01\x94\xf8&\xf58\xf6\xb7\xf7\x8e\xfb\xb8\x00*\x04\x0b\x05\xbf\xfb\xcd\xf9A\xf9\xda\xf7\xb8\xfc\xb4\x01#\x04\xd7\x02e\x02\x7f\x00\xdd\xfe\xce\xff\xd9\x02\xfb\x04\x7f\x06\xab\x07\x14\x08\xe8\x05r\x02\x9e\xff#\xff{\x00:\x02&\x03\xc6\x01\xee\xff\x10\xfe\xc8\xfc\x9f\xfc\xe9\xfc;\xfd\x8a\xfd\x89\xfd\xe2\xfde\xfc\xf1\xfc\xd8\xfc\xed\xfa\xb1\xfbn\xfe\xdc\xffM\x00\xed\x00\x8e\x00\xfe\xff\x05\xff\xf4\x00\x11\x03\xe7\x03\xe0\x03\xa8\x037\x03\x92\x01b\x00\xb1\x01\x8f\x03\xcc\x03\xa7\x04^\x044\x03\xc0\x01i\x00\xd3\xff\x92\x02\x94\x03E\x06\x1c\x06|\x03P\x03L\x00m\x00\x1c\x03i\x06\x04\x06s\x05W\x02l\x00\xce\x01\xdd\x02H\x03\x95\x01\x1a\xffA\xfd\xdd\xfe\xe4\x00\xf9\x00\xa0\xff\xce\xfc\xd1\xfb\xba\xfb+\xfb\x9c\xfbe\xfc\xe5\xfc\xf5\xfc\\\xfc\x04\xfa\xa8\xf7\x1c\xf7\xe3\xf8\xf9\xfb\xdf\xfd\xe1\xfd\xa2\xfb\xa4\xf7\x05\xf7\xe1\xf8\xc7\xfaL\xfd\x92\xfez\xfe\xb8\xfch\xfa\xc7\xf9\xa5\xf92\xfb\xf7\xfdh\x00V\x01\xae\xfe\xd6\xfb\xc0\xfa4\xfc:\x00\x95\x02^\x03G\x02D\xff\xef\xfd_\xfd\xaa\xff\xd2\x01I\x02\x8b\x04\x9d\x03o\x00\x0b\xfe4\xfd\x10\xfe1\x01\x90\x04;\x06\xfb\x03\xe5\xfe\xba\xfb8\xfbi\xfe\x0f\x04\xfd\x06\xc6\x04\xcb\x00N\xfe\xae\xfd\xa4\xff\x02\x01\xbc\x01O\x02\xb7\x01\n\x01+\xffL\xfes\x00\xc8\x02\x81\x00\x19\xfe\xc6\xfd_\xfe\x92\x01\x8e\x02Z\x00\x9b\xfdB\xfb\xa5\xfb\xa3\xfd\xd2\xfe\xf0\xfe~\xfd\xfd\xfa\xb7\xf9\x80\xf9\xcd\xf9\x8a\xfa\xa9\xfbq\xfb\xac\xfaZ\xfa\t\xfbO\xfcP\xfdP\xfe\x91\x01e\x06\xb0\n\x16\x0e\xdd\x10R\x12\xa3\x13g\x15 \x17\xdf\x18&\x1as\x1a<\x19R\x17\x8c\x15q\x14\xf1\x13\x92\x13\xd0\x11\xa7\x0e@\x0b\xee\x07\xf2\x04\x06\x02\x16\xff\xa7\xfb\xbe\xf8A\xf6\x90\xf3\x10\xf1\x12\xef\xe2\xed_\xedm\xed\xa6\xed\x99\xed*\xed\xfa\xec)\xed\xd1\xed\x04\xef\x11\xf0\xf7\xf0\xc8\xf1\xbd\xf2!\xf4\xe6\xf5\xd6\xf7\xe0\xf9{\xfb\x81\xfc4\xfd\xc8\xfdB\xfe\xaa\xfe\xd2\xfe\x95\xfey\xfeb\xfes\xfe\xcf\xfe\xe6\xfe\x18\xffx\xff\xc6\xff/\x00\x81\x00.\x00\xe4\xff\xa2\xff|\xff\xec\xff\xdd\xff\x89\xff\xa6\xff\x17\x00 \x00\xd0\x00{\x01[\x01\x83\x01\xc6\x01\x14\x02\x97\x02"\x03J\x03\x9a\x03\x13\x04q\x04%\x05F\x06\x10\x07\xd6\x07\xbd\x08\x1a\t\xe3\t5\n\x9a\n\xfa\n\xbe\n\xa9\n\x87\n\x1e\nt\t\xb8\x08\xba\x07P\x06\xa3\x05\xd0\x04\xa5\x03V\x02h\x00I\xfe\xce\xfc\xcb\xfb<\xfa\x03\xf9v\xf7\x1a\xf6\xfb\xf4\xfc\xf46\xf5\x14\xf5\\\xf5\xbe\xf5\x07\xf6z\xf6\x1a\xf7\x19\xf7\xb1\xf7\x9f\xf8q\xf9S\xfa\x98\xfaa\xfa\xd4\xfaz\xfc\xb1\xfd\xac\xfe\xae\xfeH\xfe\x84\xfe\x91\xff\xee\x00/\x01(\x00\r\xff\x8b\xfe\x0f\xff\xd4\x00)\x01\x99\xff_\xfeD\xffI\x00\xc9\x00U\x00\xca\xfe[\xfe\xa7\x00\xc9\x02\x89\x03]\x02*\x00K\x01\xc8\x02\xc6\x02\xe7\x01`\x01\x85\x01\x10\x02\xf6\x01y\x000\xff\xee\xfe\xe8\xffo\x00P\x00\xde\xff\x04\xff\xe8\xfe\xa0\xff\x86\x00R\x02\x1a\x05\xbf\x07\x19\n\xcc\x0bs\r\xe6\x0fl\x12%\x14\x8e\x14\x15\x14\n\x13v\x12\xcd\x12\x14\x13\xd3\x126\x11\x01\x0f\x18\x0e\xef\r\x18\r\x15\x0bC\x08O\x059\x02\r\xff\xcf\xfb\xac\xf8r\xf5=\xf2\xe0\xef\x06\xee&\xec1\xea\xfc\xe8s\xe9\x01\xeb\xbc\xebQ\xeb"\xebB\xec\xb0\xeeT\xf1\'\xf3)\xf4\x06\xf5S\xf6\xbf\xf8\xed\xfbw\xfe\xd1\xff\xa1\x00\xff\x01\xce\x03\x07\x05\x1e\x05\xde\x04"\x05=\x05\x89\x04$\x03\xa3\x01\x9b\x00\xfa\xfft\xff\xb5\xfe\xbd\xfd\x99\xfc\xd1\xfb\xab\xfb\xbd\xfb\x8d\xfb\xec\xfa\\\xfa\\\xfa\x81\xfa^\xfam\xfa\xab\xfa\x05\xfb\xfb\xfbm\xfc\xae\xfc\xaf\xfd\xc0\xfe\xc5\xff\'\x01\xdd\x01\xca\x01\x82\x02m\x03\xc2\x04\xb9\x053\x06\xb9\x06\xae\x07\xc0\x08\x9b\tE\n.\n\xb3\n`\x0b2\x0cO\x0c\xaa\x0b\xfa\tG\t6\t\xb4\x08\xb9\x07^\x05\xab\x03\x86\x03_\x03\x01\x02\x1f\xff|\xfco\xfb\x86\xfb\x80\xfbU\xf9\xa3\xf6\xb7\xf4\xfe\xf4\x9c\xf5\xe6\xf44\xf4\x82\xf4\x17\xf5\x81\xf5\x1d\xf6V\xf6m\xf7*\xf9e\xfa\x06\xfb\xc2\xfb\xc3\xfb\xbc\xfc\xcb\xfeB\x01\\\x02\xcd\x00n\xffj\x00,\x04\xfb\x05\x9b\x04\xe9\x01%\x00\x8f\x00\xfc\x01U\x03\xb3\x02\xcf\x00?\xfe+\xfc(\xfc\xf7\xfd\xf5\xfe\xf3\xfd\xd5\xfb`\xfa\xfb\xf9\x96\xfa^\xfbs\xfbN\xfbJ\xfbD\xfb\xb3\xfb;\xfc\x0c\xfd\x80\xfeH\xff\x16\xffz\xfeG\xfeh\xff\x82\x01\xeb\x02\xa7\x02b\x02U\x04\x9d\t\xc8\x0f\x1e\x13\x17\x136\x12[\x14V\x19\xc3\x1c\xef\x1b\xb7\x18\xbf\x16\x9c\x17T\x19G\x19y\x17\x0c\x15@\x13[\x13_\x143\x13\x19\x0f\x16\n\xeb\x06\xca\x057\x03\xf2\xfd\xb5\xf88\xf5\xdd\xf2\xfb\xef\x81\xec\xf1\xe9\xfc\xe8\xd2\xe8\xa0\xe8\xa7\xe8\xd3\xe8\xd9\xe8>\xe9k\xea\xfb\xeb\x03\xed%\xed\x11\xee\xc3\xf0N\xf3T\xf4\xb2\xf4F\xf6\x80\xf9K\xfc$\xfdT\xfd\x15\xfe(\xff\xf8\xff\x99\x00\xe1\x00m\x00<\xff\xa8\xfe%\xff\xb0\xffR\xffh\xfeu\xfe5\xff|\xff\xf3\xfex\xfe\xb0\xfe\x05\xff\xe7\xfek\xfeG\xfeU\xfeL\xfe\x87\xfe\n\xff\xc5\xff-\x00?\x00\x02\x01\xe1\x012\x02\x80\x02{\x02\xe6\x02\xeb\x03"\x04\x19\x04\x92\x04h\x05\x16\x06\x9e\x06\xb7\x06\xdf\x06\xce\x077\x08E\x08\x17\x08r\x07\xa8\x06\x94\x06%\x07~\x07!\x07\xa3\x05\xf2\x04\x80\x053\x06\x0c\x06b\x05!\x04\xf6\x02\x91\x01C\x00\xc3\xff\xa1\xff;\xff\x1e\xfe%\xfc\xdc\xf9\x18\xf8\x8f\xf7\xe9\xf7O\xf85\xf8\x89\xf7\x06\xf7e\xf6\xf7\xf5\x04\xf5Y\xf4\x0b\xf6\x1c\xf9\xee\xfa*\xfa\xe0\xf7\xba\xf6;\xf9\x14\xfdg\xff;\xffI\xfe\x06\xfe\x99\xffy\x01\xa1\x02\x8b\x02\xc5\x00N\x00F\x01/\x03v\x03\xc4\x01\xc3\xffW\xffB\x00\x90\x00+\x00\x7f\xff,\xff\x14\xff\x91\xfe\xc6\xfcR\xfcK\xfdb\xfem\xffS\xff\xb9\xfd\xf4\xfc\x9c\xfd\xee\xfe\x15\x00\xee\xff\xe0\xfe\xff\xfe\xfc\xff\xa1\x01\x1c\x02\x8b\x00\x8e\xff5\x00\x05\x02\xc6\x02\x8e\x01\x1f\x00w\x00\xb4\x02J\x05\xdc\x07\x19\nw\x0b\xe4\x0bh\x0c\x1c\x0e\xb9\x10\xc8\x11\x8c\x10\xa5\x0f\x18\x10W\x11\xb6\x11\x0e\x11%\x11\x06\x12\xcd\x11\xaa\x11\xb0\x11f\x10\x9d\r\xc1\t\x9a\x07"\x07\xb1\x04e\xff&\xfb\x11\xfa0\xfa\xfd\xf7\xcb\xf3\x9f\xf0\xdc\xef*\xf0\x08\xf0\xc7\xefv\xef,\xee(\xedS\xee\x86\xf0R\xf1\x1c\xf0\xf2\xef\xdb\xf27\xf5h\xf4\xa5\xf2\xe2\xf3X\xf8\xa8\xfb(\xfb\x91\xf9\x96\xf9\x92\xfa\x86\xfb\xb9\xfcv\xfe*\xff\x82\xfd.\xfc\n\xfd\xcb\xfe\xfd\xfe\x96\xfd\x81\xfd\xe1\xfet\xff\x8a\xfe!\xfeP\xff\x95\x00D\x00!\x00\x8f\x00\xb9\x00\x98\x00\x00\x00\xd3\x00\xe1\x01\xf1\x00\xb7\xff)\x01\xa3\x02)\x03\x87\x02\xe0\x00\xa1\x01\xcc\x041\x05\x87\x04\xad\x04\xfc\x04O\x05\x1b\x06M\x06\x9c\x06c\x07\x1b\x07\xfc\x06\xcd\x079\x07d\x06\x12\x07s\x08\x10\n\xba\t\xd9\x06_\x05\xb6\x05\xaa\x05\xbb\x05\x05\x05\xa5\x03Y\x03\xb1\x01@\x01\xa0\x00\xd8\xfe\x11\xfe\x93\xfe\xc6\xff\x17\x00\xae\xfd\xa1\xfa\xa9\xf9^\xfb\x17\xfe]\xfe\x95\xfcc\xfa\xf3\xfay\xfc:\xfd\xcf\xfb\xb6\xf9\x8f\xf91\xfb\xbd\xfc\x86\xfc\xa2\xfa}\xf9\xfa\xf9\xc1\xfb\xd6\xfc\x9c\xfbS\xfa\xab\xf9W\xfap\xfb"\xfbp\xfa\xdd\xf9&\xfa\xfa\xfa\xfe\xfa\xa1\xfa\xda\xfa\xe3\xfa-\xfb\x91\xfbJ\xfb\x00\xfbl\xfa\xee\xf96\xfa\xd1\xfa\xf4\xfa\x1c\xfb\xd7\xfaM\xfb\xe3\xfb#\xfc\xe4\xfc\xd8\xfc\x9b\xfc:\xfd\xbb\xfd0\xfeK\xfe\x08\xfew\xff\xdc\x00\xb0\x00\xf8\xff,\x00Y\x01-\x03\xc2\x030\x03\xda\x02+\x02Q\x032\x07\x85\n\x95\x0b\xd8\x0fL\x1d\x0e/X7;2\\-\x9b4\xc5?\xe4>\xc4/\xaa \x05\x1c\x0c\x1b\n\x14\x00\x07w\xfa\xbe\xf2\x12\xefk\xed6\xebT\xe4\x18\xdc\xb8\xd8\x00\xdb\xe4\xdc\xef\xd8S\xd4_\xd6\x83\xdc\xc5\xe0\x97\xe3\xb4\xea\xcc\xf5`\xfe<\x02^\x07\x9b\x10A\x18\xac\x18\x96\x14\xb8\x12\xa7\x13l\x13\x10\x0f\x1f\x08\xc2\x01\xac\xfdT\xfb\xe4\xf8\x99\xf4z\xee~\xe8\n\xe4\xca\xe1I\xe0r\xdd\xc4\xd9?\xd8P\xda\xe9\xde(\xe4\x89\xe9X\xef\x14\xf6\xee\xfd\xfc\x06\x83\x0f\xf9\x14i\x17t\x19\xa9\x1c3\x1fU\x1e\xf7\x19Y\x15\xbd\x12;\x11G\x0eK\ts\x04\x00\x01"\xfe\x91\xfa\xa2\xf6\xc1\xf3\x9e\xf1\x14\xef\xce\xec\xb8\xecF\xefS\xf2o\xf4\x19\xf7\x17\xfc\x9b\x02\x95\x08\xf6\x0c\x07\x10\x8e\x12\x7f\x15x\x18\xfd\x19\x9b\x18]\x15\xde\x13\x82\x13\xf9\x11W\rV\x07\xe1\x03\xe9\x00\x8d\xfcN\xf7]\xf2\x7f\xef\n\xecq\xe8\x0f\xe7T\xe7B\xe9\xf3\xe9\xf3\xea\xf5\xef\xb8\xf7{\x00T\x06T\t\xc3\r\x05\x14\xfe\x1a|\x1d\xdb\x1a\xe3\x17\x18\x18\xcd\x19\x7f\x17h\x10\xb5\tu\x06\xde\x04\x0e\x012\xfa~\xf4\x12\xf1m\xee\xff\xeaC\xe7\xad\xe5|\xe5\n\xe5\xe1\xe45\xe6\xf6\xe9\xbb\xee\x01\xf2{\xf4\xa2\xf7\xfd\xfb\x94\x00g\x03\xb5\x03\xd9\x032\x05\x89\x07P\t\xd2\x08\xd3\x07\xf9\x07\xa3\x08\x93\x08\x11\x07\xa3\x04=\x02\xbf\xff\xfd\xfc^\xfa\xd4\xf7\xd8\xf5\xb3\xf3+\xf2\x1f\xf2U\xf3\xc4\xf4s\xf5]\xf5\x0e\xf6\xad\xf8|\xfb4\xfdz\xfd\xbb\xfe\xbc\x01\t\x05T\x077\t\x83\x0b\x17\r\xae\r\xa3\x0e7\x103\x0e\x0e\nO\x0bS\x17\xc1&\x10,\x8d\'\xba&o1\xbf<\xbe:\xd7,\x91\x1f\x1b\x1b\xe7\x17\x87\r/\xff\x91\xf46\xf0\xc8\xeb\xc8\xe3\x90\xde\xfd\xe0E\xe5\x92\xe2\xb1\xdb9\xdbP\xe4\xf5\xeb\xe1\xe9\'\xe4g\xe6\xeb\xf0Q\xfa>\xfdG\xfe\xc5\x02\xf2\t\xf1\x0f\xd7\x133\x16\x84\x15U\x11\x07\x0c\x7f\x08/\x05}\xfe\xa5\xf4\xdc\xeb\xb8\xe6\xa8\xe4\x04\xe3\xa2\xe0[\xde\xfe\xdd_\xe0\xde\xe3\t\xe7W\xe9\x8d\xebm\xee\x81\xf2\xef\xf6\x14\xfbM\xff \x04\x84\x08.\x0c\xc7\x10\x9c\x16\x96\x1a\xa4\x1a\xf9\x18\xf4\x18\n\x1a"\x18\xeb\x11^\n\xe2\x04V\x01A\xfd\xa7\xf7X\xf2T\xef\xa7\xeeP\xef8\xf09\xf1\xbc\xf2\xfd\xf4\x96\xf7E\xfa\xff\xfc\xb2\xff\xda\x01\xa5\x030\x06\xb8\t0\r\xd4\x0f\xe3\x11\t\x14=\x16\xb9\x17\x18\x18\x06\x17F\x14\xab\x10\xfc\x0c\x17\t<\x04\\\xfe\x87\xf8\xea\xf3j\xf0c\xed\x00\xeb^\xe9\xfc\xe8\xf5\xe9\xe8\xebZ\xee\xd2\xf0\xf8\xf3\xd0\xf7\x1b\xfc\x98\xff\xa1\x02\xd5\x07x\x0e\xa3\x13X\x15\xc3\x15\x88\x18\x19\x1b\x07\x1a\xd3\x15i\x11p\x0e{\n\xb2\x04x\xfe\xae\xf8*\xf4\x8e\xf0\xa0\xedc\xeb\x08\xea\xe2\xe9\xad\xea\x15\xec\xdb\xed\t\xf0\xe4\xf2u\xf5\xef\xf7\xa1\xfa\xc4\xfd`\x01\x1f\x04\xda\x05n\x07@\t\x1c\x0b\xc2\x0b\xc0\ni\ty\x08V\x07n\x05\x85\x02\xdc\xff\x14\xfe)\xfcW\xfa\xba\xf8R\xf7\xc5\xf6 \xf6\x94\xf5\xe4\xf53\xf6\xee\xf6\xa4\xf7!\xf8t\xf9\xe1\xfa\x8b\xfc6\xfe/\xff\xbd\x00\x86\x027\x04\xa8\x05\x86\x06"\x07\xa0\x07\xb7\x07~\x07\x07\x07\xc2\x05;\x04p\x02\xe5\x00\x87\xff\xaa\xfd-\xfb\xbb\xf9\xc1\xf9\xbd\xf9\xd6\xf7\xf5\xf4s\xf5\xb1\xfb\xc2\x02b\x05\xa0\x07\x04\x11a!\xef,\xef,\xa7)G.S8h:\xa2/\xc7!"\x1bB\x19\xb5\x12\x1c\x05E\xf7\x0c\xef\xe2\xeb_\xe9\xce\xe4P\xdf\x8e\xdc\xd2\xddw\xe0f\xe17\xe1\xbe\xe2\x80\xe6d\xe9\x06\xeb\x7f\xee\x98\xf5_\xfc\x0c\xff6\x00\x87\x05\x95\x0eA\x14\xbb\x12\x14\x0fZ\x0f\xad\x11\n\x10|\x087\x00N\xfb9\xf8\x03\xf4&\xee\x03\xe9\xb4\xe6A\xe6\xc9\xe5\x82\xe5\x9c\xe60\xe9\x8d\xebX\xed\x95\xef\xf3\xf2\x1b\xf7\xf2\xfah\xfdj\xff\xf5\x02f\x08\x03\r\xc2\x0e\x9d\x0f\xff\x11+\x15]\x16{\x14D\x11\x9c\x0eN\x0c\xcd\x08\xb6\x03\x87\xfe\xa6\xfa\xe2\xf7\x8b\xf5\xaf\xf3\xee\xf2\xa2\xf3O\xf5;\xf7v\xf9p\xfc\xdb\xff\xe7\x02\x07\x05\xd4\x06\x19\t[\x0b\xdc\x0c~\r\xd3\r\x84\x0eo\x0f\xf8\x0f\x96\x0fM\x0e\xd4\x0cy\x0b\xb3\t\xb9\x06\xb0\x02\x95\xfe$\xfb\xdf\xf7L\xf4\xe4\xf0E\xee\xd8\xece\xec\xa9\xec\xbd\xed\xb4\xef\x83\xf2y\xf5\x7f\xf8\xd8\xfb<\xff<\x02(\x04\x9e\x05\x15\x07c\x08L\t5\t\r\tT\t\xb8\t\xb8\t\xf1\x08\xa8\x08/\t5\t\x1b\x08\x13\x06\x18\x04\xb1\x02\xbf\x00\xd8\xfd\xad\xfa\xf4\xf7e\xf6.\xf5\xda\xf3\xfb\xf2\xec\xf2 \xf4\x0e\xf6\xc5\xf7\x86\xf9\x81\xfb\xcd\xfd<\x00\x07\x02:\x03r\x04z\x05*\x067\x06\xa3\x05,\x05\x9f\x04\xd4\x03\xd2\x02\xb9\x01\xdc\x00\xe4\xff\xe3\xfe\xc8\xfd\x03\xfd}\xfc\x03\xfc~\xfb\xea\xfa\xcd\xfa\x1d\xfb\x81\xfb\xa5\xfb\xb4\xfb\xef\xfbh\xfc\xdb\xfc\t\xfd\xe4\xfc\xf4\xfc\x17\xfd8\xfdI\xfd\'\xfd8\xfdr\xfd\x9a\xfd\x0c\xfef\xfe\xa7\xfe\xfc\xfe\x1c\xff|\xff\xbe\xff\x83\xffN\xffC\xffD\xffU\xff+\xff^\xff)\x00\xdb\x00\xec\x00/\x01B\x02\xb1\x03\xf1\x03\xe9\x02I\x04\xdf\tb\x10s\x13\x18\x14\x8c\x17X\x1fv%\x82%\x8d"m!\x07"\xc1\x1f\x0e\x19q\x11/\x0bx\x05R\xff\xdd\xf8\x88\xf3\t\xef\\\xeb=\xe9z\xe8\x00\xe8l\xe7\x00\xe8\xa1\xe9\xcc\xea5\xebA\xec\xcb\xee\x15\xf1\x16\xf2X\xf3\xae\xf6\xe9\xfa\xdc\xfd\x9b\xff\x82\x02\xfc\x06\x83\nm\x0b%\x0b\xab\x0b(\x0c\x97\n\xec\x067\x03%\x00\xdd\xfc\xcd\xf8\x93\xf4r\xf1\x87\xefI\xeeN\xed\n\xed\xd9\xedv\xef:\xf1\x0e\xf3\x00\xf5%\xf7\x99\xf95\xfci\xfe\xfb\xff\xb6\x01M\x04\xfb\x06\xb2\x08\x07\n\xdb\x0b\x1e\x0e\xc6\x0f;\x10\xdf\x0fn\x0f\xc5\x0e=\r\x82\n\x1c\x07\xe2\x03\x13\x01G\xfee\xfb\xf3\xf8\xb1\xf7\x95\xf7\xe1\xf7G\xf8N\xf9L\xfb\x9e\xfdo\xff\xab\x00\xf8\x01b\x03_\x04\xad\x04\xa9\x04\xab\x04\xe7\x04J\x05u\x05b\x05;\x05h\x05\xcb\x05\xb6\x05\xef\x04\xbf\x03\xa0\x02J\x01e\xff\x13\xfd\xbf\xfa\xc6\xf8+\xf7\xd8\xf5\xd8\xf4q\xf4\xea\xf4\xe0\xf5\x0f\xf7\xa7\xf8\xb1\xfa\x04\xfd\xf9\xfe\x9a\x00,\x02\xba\x03\x0e\x05\xbd\x05\'\x06\x9d\x06)\x07{\x07\x82\x07\x06\x08\x0c\t\xe1\t\xdf\t6\t\x8e\x08\xf7\x07\xd1\x06\xb1\x04\xeb\x01\x0b\xffd\xfc\xd3\xf9O\xf7\xf7\xf46\xf3Z\xf2`\xf2\xed\xf2\xcf\xf3 \xf5\t\xf7b\xf9\xb6\xfb\xd2\xfd\xcf\xff\xb6\x01k\x03\xb4\x04\x89\x05\x18\x06q\x06\x83\x06T\x06\xd7\x053\x05r\x04d\x03/\x02\xfa\x00\xa4\xffJ\xfe\xb0\xfc\xe2\xfaV\xf9\xee\xf7\xc3\xf6\xc0\xf5\xc0\xf4\x1e\xf4\xe5\xf3\xf3\xf3]\xf4\x00\xf5\xd6\xf5\x00\xf7F\xf8\xc3\xf9k\xfb\r\xfd\xb6\xfeP\x00\'\x02\xe5\x03Z\x05j\x06\x9b\x07\xc0\x08\x82\t\x9e\tQ\tn\t.\tN\x08\xb9\x06G\x05\x98\x04p\x03j\x01\xaf\xff\x04\xff\xe0\xfet\xfdC\xfb\x9d\xfb\x8e\xff\x07\x04\xa5\x05>\x06?\n\xac\x11q\x17\xe2\x18\xff\x18\x8b\x1b\x05\x1f\x91\x1f\x8b\x1c\xc5\x18\xff\x15\xa9\x12\xc4\r\x08\x08\xd2\x02A\xfe\xe5\xf9\xd7\xf5Q\xf2>\xef\xeb\xec\xca\xeb\xf9\xea\x97\xe9=\xe8^\xe8\xc4\xe9k\xea\xfc\xe9X\xea\xe9\xec\x10\xf0\xd5\xf1\xec\xf2\xb2\xf5C\xfaO\xfe\x9f\x00\x85\x02\x81\x05\xa3\x08%\n\xf5\tx\t3\t2\x08\xf5\x05\x15\x03\\\x00\x05\xfe\xab\xfb\x19\xf9\xcf\xf6A\xf5|\xf4\xfc\xf3\xae\xf3\xb1\xf3L\xf4\x99\xf5.\xf7\x83\xf8\x96\xf9 \xfb\x99\xfd\xf2\xffV\x01\x84\x02p\x04\xf0\x06\xe3\x08\xf9\t\xc6\n\xea\x0b\n\ru\r\xec\x0c\xfe\x0b#\x0b:\n\xc3\x08\xa2\x06`\x04\xba\x02z\x01\xd0\xff\xc1\xfdh\xfcV\xfc\x84\xfc\xf6\xfbI\xfb\xc6\xfb\x10\xfd\xec\xfd\xeb\xfd\xfb\xfd\xbc\xfe\xce\xffp\x00l\x00R\x00\x95\x00@\x01\xb0\x01u\x01)\x01d\x01\xf4\x01\xed\x01 \x01\x8c\x00\x8b\x00_\x00\x80\xffT\xfe\xa7\xfd\x84\xfd@\xfd\x93\xfc\xfc\xfb\x1b\xfc\xd4\xfca\xfd\x97\xfd\x06\xfe\x06\xff-\x00\xf7\x00z\x01\x18\x02\xe5\x02\x8c\x03\xde\x03\xe7\x03\x10\x04H\x04[\x04A\x04\x0b\x04\xea\x03\xb4\x03g\x03\r\x03\xa7\x020\x02\xac\x01\x11\x01\x8b\x00.\x00\xd6\xff^\xff\xe6\xfe\xe3\xfe\x1e\xffB\xff\x10\xff\xf7\xfeO\xff\xa0\xff\x9a\xffA\xff\x02\xff\xf7\xfe\xe8\xfe\x93\xfe\x0f\xfe\xc1\xfd\xb0\xfd\x9a\xfdM\xfd\x08\xfd\x12\xfd6\xfdD\xfd"\xfd\x0f\xfd7\xfdX\xfd!\xfd\xb7\xfc\xac\xfc\xe6\xfc\xf0\xfc\xba\xfc\x95\xfc\xc5\xfc&\xfdi\xfd\x9f\xfd\x10\xfe\xb4\xfeG\xff\xa3\xff\x0e\x00\xc7\x00\x84\x01\xdd\x01\xf0\x018\x02\x99\x02\xba\x02}\x024\x02)\x029\x02\x08\x02\x9c\x01Y\x012\x01\x06\x01\xc7\x00\x8e\x00e\x007\x00\x00\x00\xc7\xff\x9c\xffw\xffa\xffW\xff@\xff0\xffC\xffw\xff\x8b\xffv\xffw\xff\x9d\xff\xcd\xff\xb9\xff\x8a\xffu\xff\x9c\xff\xab\xff{\xff4\xff\x0b\xffB\xff`\xffj\xffb\xffb\xff\x98\xff\x96\xffx\xff\x7f\xff\x81\xffd\xff\x02\xffh\xfe\x1e\xfe+\xfe\x08\xfeU\xfd\xab\xfc&\xfd\xde\xfe\x0c\x01\xd6\x02<\x04F\x06^\t\xc2\x0c\xb5\x0e\xd2\x0e\xf1\x0eI\x10\x8b\x11w\x10S\r\xf2\nx\n\x9a\to\x06\x96\x02\xee\x00\xf4\x00\xc4\xff\x11\xfdI\xfb\x97\xfb\x07\xfc\xbd\xfa\xc1\xf8/\xf8\xcd\xf8\xb3\xf8R\xf7\xe9\xf5\xde\xf5\xc5\xf6\x1c\xf7\x87\xf6Z\xf6\x8b\xf7x\xf9\x98\xfa\xc3\xfa\x8f\xfbf\xfd\x0c\xfft\xff/\xff|\xff9\x00C\x00N\xffI\xfe\xfb\xfd\xe1\xfdM\xfdf\xfc\xda\xfb\xef\xfb0\xfc\'\xfc\xfc\xfb.\xfc\xd3\xfco\xfd\x8f\xfd\x8b\xfd\xf2\xfd\x9d\xfe\xec\xfe\xc8\xfe\xc5\xfe?\xff\xc9\xff\x0b\x00*\x00u\x00\x1e\x01\xcd\x01S\x02\xb3\x02\x17\x03\xa7\x030\x04Y\x04;\x04\x1c\x04@\x04Q\x04\xe9\x036\x03\xc9\x02\xd3\x02\xcb\x02W\x02\xd8\x01\xda\x01!\x02:\x02\x11\x02\x04\x023\x02X\x02P\x02$\x02\xf9\x01\xec\x01\xee\x01\xca\x01\\\x01\xe3\x00\xc8\x00\xe3\x00\x9e\x00\xf6\xffo\xff_\xfff\xff\xf5\xfeI\xfe\xee\xfd\xf7\xfd\xeb\xfdx\xfd\x08\xfd\x02\xfd1\xfd\x1e\xfd\xd1\xfc\xc4\xfc$\xfd\x8a\xfd\xa6\xfd\xc1\xfd$\xfe\xba\xfe,\xff\x83\xff\xe0\xffF\x00\x96\x00\xd2\x00\x0e\x015\x018\x01,\x012\x01A\x01B\x016\x010\x01H\x01l\x01\x89\x01\x8e\x01}\x01w\x01\x85\x01x\x01\'\x01\xad\x00n\x00C\x00\xea\xffo\xff!\xff*\xffM\xffG\xffY\xff\xac\xff+\x00\xa7\x00\xfc\x00Z\x01\xdd\x01b\x02\xd0\x02\xf4\x02\xcf\x02\xbc\x02\xdb\x02\xc0\x02,\x02w\x01\x18\x01\xf8\x00|\x00\x80\xff\xcd\xfe\x96\xfeO\xfe\x87\xfd\xa5\xfcN\xfcB\xfc\xfc\xfb\x88\xfbQ\xfb\xa6\xfb\x0e\xfc\x1f\xfc*\xfc\x9e\xfcl\xfd\x05\xfej\xfe\x13\xffB\x00\x85\x01a\x02\xfd\x02\xc6\x03\xd3\x04d\x05Y\x05\x13\x05\xf5\x04\xd8\x049\x04=\x03T\x02\xbd\x01\x1a\x019\x00Z\xff\xc4\xfe\x8e\xfeN\xfe\xd8\xfdo\xfdo\xfd\xac\xfd\xb5\xfd}\xfdj\xfd\xa7\xfd\xe9\xfd\xe5\xfd\xb8\xfd\xca\xfd\x15\xfe@\xfe1\xfe%\xfeF\xfes\xfek\xfeB\xfe4\xfe1\xfe$\xfe\xec\xfd\xae\xfd\x98\xfd\x80\xfdt\xfdO\xfd*\xfdO\xfdV\xfdp\xfdc\xfdf\xfd\xa9\xfd\xb6\xfd\xc8\xfd\xd0\xfd\xea\xfd:\xfe^\xfem\xfe\x91\xfe\xba\xfe\xec\xfe\xfd\xfe\xf9\xfe\n\xff)\xffH\xffT\xff[\xffV\xffm\xff\x91\xffz\xffg\xff\xa9\xff(\x00\xaf\x00\xf9\x00\xbb\x01Q\x03\t\x05e\x06\x8f\x07\'\t\x1e\x0bf\x0c\xfe\x0c\xa8\r\x83\x0e\xe8\x0e^\x0e\xeb\r\t\x0e\xd7\r\xdc\x0c\xc7\x0b\x9f\x0b\x96\x0b}\n\xd5\x08\xfa\x07\xa0\x07R\x06\xc1\x03\x7f\x01b\x00D\xff\x00\xfdR\xfa\xdc\xf8\x88\xf8\xd4\xf7I\xf6,\xf5{\xf5Z\xf6k\xf6\xd5\xf5\xc5\xf5\x9d\xf6U\xf7\x1f\xf7\x90\xf6\x98\xf6\t\xf76\xf7\xe1\xf6\x8e\xf6\xc6\xf6O\xf7\xc4\xf7\xf1\xf7/\xf8\xd5\xf8\xc2\xf9\x86\xfa\xfe\xfak\xfb+\xfc\x14\xfd\xa9\xfd\xea\xfdE\xfe\xe3\xfek\xff\x9f\xff\xc0\xff%\x00\x9d\x00\xd7\x00\xe6\x00\x1d\x01\x8b\x01\xe6\x013\x02{\x02\xdc\x02P\x03\xba\x03\x00\x043\x04j\x04\xa0\x04\xb6\x04\xac\x04\x95\x04\x82\x04\x80\x04o\x04J\x04\'\x04\x1b\x04$\x042\x043\x046\x04?\x048\x04\x13\x04\xd5\x03\x91\x03F\x03\xe9\x02\x83\x02\x02\x02w\x01\xf0\x00y\x00\xfb\xffh\xff\xdf\xfeo\xfe\x16\xfe\xaa\xfd1\xfd\xc4\xfc}\xfcN\xfc\n\xfc\xc6\xfb\xb5\xfb\xc6\xfb\xc8\xfb\xb8\xfb\xe4\xfb;\xfcw\xfc\xa0\xfc\xef\xfc[\xfd\xa8\xfd\xbc\xfd\xef\xfd>\xfel\xfeo\xfeo\xfe\xa5\xfe\xd4\xfe\xdd\xfe\xf7\xfeK\xff\xb0\xff\x05\x00_\x00\xcb\x00M\x01\xb4\x01\x15\x02{\x02\xc6\x02\xf0\x02(\x03p\x03s\x034\x03\x18\x03;\x03A\x03\xfc\x02\xd3\x02 \x03\x9b\x03\xb3\x03d\x03G\x03\xa2\x03\xf4\x03\xb5\x037\x03,\x03q\x03F\x03\x9c\x02\x1d\x022\x02B\x02\x99\x01\xbe\x00.\x00\xeb\xffS\xffB\xfea\xfd\xd4\xfcg\xfc\xbe\xfb\xe9\xfao\xfa;\xfa\x1b\xfa\xe8\xf9\xa8\xf9\xa8\xf9\xd4\xf9\x01\xfa\x16\xfa\x1b\xfa]\xfa\xc5\xfa\x18\xfbJ\xfbw\xfb\xe6\xfbk\xfc\xc2\xfc\x02\xfdX\xfd\xe2\xfdi\xfe\xc0\xfe\x08\xffs\xff\x03\x00\x84\x00\xce\x00\x17\x01\x8d\x01\x01\x02E\x02E\x02\\\x02\xbb\x02\xe3\x02\xcb\x02\x80\x02w\x02\xab\x02\x88\x024\x02\xeb\x01\xe8\x01\x05\x02\xbc\x01S\x016\x01>\x01#\x01\xb8\x00Q\x00@\x00@\x00\x0e\x00\xc1\xff\x89\xfft\xffx\xffa\xff\x1e\xff\x0c\xff7\xffm\xffy\xffN\xffu\xff\xf0\xff>\x00`\x00\xcd\x00\xe1\x01"\x03\xdb\x03i\x04\x80\x05\xce\x06w\x07v\x07\xd5\x07\xbc\x08\xfd\x08L\x08\xc4\x07N\x08\xbc\x08\xf8\x07\xef\x06\x12\x07\xa8\x07\xf5\x06%\x05\x18\x04*\x04\x85\x03H\x01\x0e\xffK\xfe\t\xfe\xa5\xfc\x80\xfa\x85\xf9\xff\xf9=\xfaS\xf9n\xf8\xcd\xf8\xbc\xf9\xb1\xf9\xd3\xf8\x91\xf8C\xf9\xac\xf9*\xf9\x8d\xf8\xdd\xf8\x98\xf9\xda\xf9\xd4\xf94\xfa\x04\xfb\xb3\xfb\x10\xfcw\xfc\x12\xfd\x8c\xfd\xd4\xfd\x16\xfeT\xfer\xfe\x8f\xfe\xc0\xfe\xfd\xfe\x1e\xff8\xff\x85\xff\xe5\xff#\x00>\x00t\x00\xc1\x00\xd9\x00\xbf\x00\x9b\x00\xa4\x00\xb5\x00\x8d\x00@\x00&\x00M\x00q\x00P\x00F\x00\x97\x00\xed\x00\x0c\x01\t\x01=\x01\x9f\x01\xd1\x01\xcd\x01\xe2\x01"\x02c\x02y\x02\x81\x02\xad\x02\xeb\x02\x14\x03\x19\x03\x17\x03&\x03&\x03\x01\x03\xb7\x02o\x025\x02\xe7\x01\x87\x01#\x01\xd7\x00\x95\x00K\x00\xf2\xff\xb2\xff\x93\xffp\xff<\xff\x03\xff\xd3\xfe\xaa\xfeu\xfeH\xfe0\xfe\x18\xfe\x0c\xfe"\xfeF\xfeb\xfey\xfe\xb9\xfe\x07\xff2\xffV\xff\x92\xff\xcd\xff\xd0\xff\xb8\xff\xdb\xff%\x00)\x00\x01\x00+\x00\x98\x00\xcd\x00\x9c\x00\xb8\x00v\x01\x0c\x02\xd3\x01j\x01\xaf\x01H\x02$\x02`\x01\x17\x01u\x01{\x01\xa2\x00\xce\xff\xce\xff\x12\x00\xa4\xff\xc2\xfeb\xfe\x9e\xfe\xae\xfe=\xfe\xcf\xfd\xf0\xfd9\xfe(\xfe\xde\xfd\xe3\xfdI\xfel\xfej\xfe\x92\xfe\xda\xfe\x02\xff\x05\xff:\xffv\xff\x89\xff\x86\xff\xa3\xff\xc5\xff\xab\xff\x8a\xff\x89\xff\x99\xff\x97\xffv\xffY\xffV\xffK\xff7\xff3\xff?\xff?\xff\x1b\xff\x1c\xffB\xff9\xff\xff\xfe\xe6\xfe\'\xffT\xff"\xff\xd9\xfe\xe4\xfe(\xff \xff\xcb\xfe\xa2\xfe\xdd\xfe\t\xff\xd6\xfe\x98\xfe\xb4\xfe\x08\xff\n\xff\xe8\xfe\xec\xfe2\xffr\xff}\xff\xa8\xff\xee\xff*\x00R\x00\x92\x00\xff\x00V\x01p\x01\xc8\x01\x91\x02K\x03\xc2\x03E\x04U\x05\x8e\x06\x0e\x07\xfd\x06\x86\x07\xbd\x08K\t\xa7\x08\x18\x08\xb1\x08|\t\x08\t\x00\x08\xf2\x07\xb8\x08\xac\x08`\x07N\x06c\x06\\\x06\x0c\x05\x14\x03\xc3\x01?\x01U\x00\xb2\xfe\t\xfd\x16\xfc\xa9\xfb\xf9\xfa\xdf\xf9\xe8\xf8\x96\xf8\x96\xf83\xf8]\xf7\xbf\xf6\xa7\xf6\xb4\xf6d\xf6\xd6\xf5\xaa\xf5\xe6\xf5C\xf6p\xf6\x90\xf6\xff\xf6\xb8\xf7|\xf8\x03\xf9d\xf9\xe6\xf9\x92\xfa-\xfb\x82\xfb\xb6\xfb.\xfc\xdb\xfcg\xfd\xc2\xfd!\xfe\xc0\xfe\x82\xff\x1b\x00\x91\x00\x01\x01\x85\x01\x05\x02O\x02~\x02\xb6\x02\x04\x03E\x03P\x03R\x03\x82\x03\xd3\x03\xfd\x03\x06\x04"\x04f\x04\xa1\x04\xbd\x04\xdb\x04\xff\x04\x1e\x05!\x05 \x05#\x05\x16\x05\xfd\x04\xf0\x04\xe7\x04\xc8\x04\x90\x04l\x04g\x04Q\x04\x08\x04\xba\x03\x84\x03V\x03\xf5\x02\\\x02\xd3\x01o\x01\xfb\x00K\x00\x86\xff\xfa\xfe\x9d\xfe&\xfex\xfd\xda\xfc\x96\xfcW\xfc\xe8\xfbt\xfb;\xfb+\xfb\xe0\xfa\x8d\xfa\x97\xfa\xe1\xfa\xfa\xfa\xdc\xfa#\xfb\xc4\xfb \xfc\x0f\xfca\xfc\x82\xfd\x9b\xfe\xde\xfe\xde\xfe\x80\xff\x85\x00\xf4\x00\xe5\x00$\x01\xd5\x01t\x02\x86\x02_\x02\x9f\x02!\x03t\x03b\x03*\x03L\x03\x83\x03\x98\x03t\x03+\x03\x01\x03\xfe\x02\xe8\x02\xa9\x02[\x02\x08\x02\x0b\x02(\x02\xea\x01y\x01&\x01\x1e\x01\n\x01\x9c\x00"\x00\xe7\xff\xaa\xff?\xff\xc8\xfep\xfe5\xfe\xec\xfd\x9f\xfd_\xfd\x16\xfd\xb0\xfcV\xfc1\xfc+\xfc\xfa\xfb\xab\xfb\x9d\xfb\xc6\xfb\xcc\xfb\x98\xfb\x88\xfb\xdc\xfbC\xfct\xfcv\xfc\xa7\xfc\x1e\xfd\x8c\xfd\xc0\xfd\xe2\xfd9\xfe\xad\xfe\xf8\xfe\x0c\xff\x1f\xffc\xff\xaf\xff\xc7\xff\xb8\xff\xbd\xff\xfa\xff3\x00.\x00\x11\x00&\x00o\x00\xad\x00\x9e\x00\x94\x00\xe4\x00O\x01|\x01Q\x01r\x01\xfa\x01V\x02J\x02 \x02j\x02\xfa\x02\x1f\x03\xd3\x02\xc2\x02&\x03\x7f\x03Y\x03\x15\x03=\x03\xab\x03\xd4\x03\xbe\x03\xf2\x03n\x04\xc0\x04\xd8\x04\xf9\x04a\x05\x9a\x05x\x05u\x05\xb0\x05\xd2\x05\x91\x05;\x05F\x05w\x05\x1e\x05c\x04\xed\x03\xc4\x03b\x03\x81\x02o\x01\x9f\x00\x01\x00,\xff\t\xfe\xe3\xfc\x1e\xfc\xa0\xfb\xf4\xfa\x07\xfa@\xf9\xe7\xf8\xb8\xf8h\xf8\xea\xf7\xa5\xf7\xb6\xf7\xc7\xf7\xc3\xf7\xb7\xf7\xe1\xf7@\xf8\x94\xf8\xdb\xf84\xf9\xa8\xf9;\xfa\xda\xfak\xfb\xeb\xfbq\xfc\x13\xfd\xb7\xfd9\xfe\x9a\xfe\x13\xff\xa0\xff\xfd\xff3\x00w\x00\xd2\x00\x15\x01&\x011\x01\\\x01\x88\x01\x9d\x01\xa6\x01\xc3\x01\xe5\x01\xeb\x01\xdf\x01\xce\x01\xd1\x01\xd6\x01\xbc\x01\x96\x01\x87\x01\x98\x01\xaa\x01\x9e\x01\x9d\x01\xc1\x01\xe2\x01\xed\x01\xed\x01\x0b\x02A\x02[\x02I\x02@\x02S\x02l\x02j\x02V\x02\\\x02p\x02n\x02N\x025\x023\x02 \x02\xec\x01\xaa\x01{\x01H\x01\x01\x01\xb2\x00n\x00)\x00\xe0\xff\x9b\xfff\xff9\xff\xfd\xfe\xcb\xfe\xb1\xfe\x9e\xfe|\xfeW\xfeQ\xfe_\xfe`\xfeU\xfem\xfe\xa7\xfe\xd3\xfe\xd6\xfe\xe8\xfe9\xff\x8d\xff\xa3\xff\xa4\xff\xd7\xff2\x00\\\x00Y\x00~\x00\xd9\x00\x1f\x01\x1f\x01\x18\x01?\x01n\x01d\x01E\x01@\x01;\x01\x1b\x01\xe9\x00\xcf\x00\xaf\x00v\x00Q\x00D\x00\x1d\x00\xde\xff\xb5\xff\xb2\xff\xad\xff\x7f\xffU\xff]\xffv\xffo\xffH\xffC\xffo\xff\x99\xff\xa1\xff\x9f\xff\xbb\xff\xeb\xff\x05\x00\x08\x00\r\x00\'\x00D\x00D\x00,\x00\x1b\x00\x17\x00\x1b\x00\x06\x00\xd3\xff\xb1\xff\xa7\xff\xa5\xff\x94\xffw\xffh\xfft\xffz\xffa\xff<\xff4\xffP\xff_\xffW\xffR\xffw\xff\xa5\xff\xc0\xff\xd6\xff\xf1\xff!\x00Q\x00y\x00\x8c\x00\x96\x00\xae\x00\xd5\x00\xe1\x00\xc9\x00\xc2\x00\xdc\x00\xfb\x00\xf8\x00\xe4\x00\xf4\x00 \x01:\x01%\x01\x0f\x01\x1f\x012\x01\x1b\x01\xe6\x00\xbe\x00\xa9\x00\x9f\x00x\x00?\x00\x18\x00\x02\x00\xe2\xff\xa9\xffl\xff3\xff\xf5\xfe\xb9\xfet\xfe\x1e\xfe\xc2\xfdp\xfd"\xfd\xdb\xfc\x97\xfce\xfcA\xfc4\xfc9\xfc7\xfc5\xfcM\xfc\x89\xfc\xb8\xfc\xd7\xfc\x04\xfdM\xfd\xa0\xfd\xe1\xfd!\xfe\x80\xfe\xf5\xfec\xff\xb6\xff\x17\x00\x96\x00\x06\x01R\x01\x99\x01\xfb\x01J\x02p\x02\x8b\x02\xa3\x02\xad\x02\xa2\x02\x8d\x02\x84\x02o\x02F\x02\'\x02\n\x02\xd1\x01\x86\x01:\x01\xf5\x00\xaf\x00N\x00\xed\xff\xbc\xff\x8c\xffK\xff\x04\xff\xd7\xfe\xc9\xfe\xa9\xfe\x82\xfeo\xfev\xfe\x84\xfe\x8f\xfe\xa3\xfe\xbe\xfe\xe9\xfe5\xff\x90\xff\xe4\xff>\x00\xbc\x00R\x01\xdc\x01Z\x02\xf0\x02\x97\x03\x1e\x04\x85\x04\xeb\x04]\x05\xb6\x05\xdb\x05\xea\x05\x05\x06\x15\x06\xfb\x05\xbc\x05~\x05K\x05\xe3\x04L\x04\xb6\x032\x03\x9e\x02\xe0\x01\x1f\x01r\x00\xd1\xff$\xffr\xfe\xdd\xfdd\xfd\xf5\xfc\x9e\xfcJ\xfc\x00\xfc\xcd\xfb\xa5\xfb\x8e\xfbv\xfbY\xfbQ\xfbb\xfb}\xfb\x89\xfb\x93\xfb\xad\xfb\xdf\xfb\x0e\xfc*\xfcL\xfct\xfc\xa4\xfc\xd0\xfc\xfa\xfc#\xfdV\xfd\x89\xfd\xb2\xfd\xd8\xfd\xff\xfd+\xfeY\xfe\x7f\xfe\x9f\xfe\xcb\xfe\xf6\xfe&\xffR\xfft\xff\x9c\xff\xbf\xff\xe1\xff\x01\x00+\x00W\x00t\x00\x92\x00\xb0\x00\xd7\x00\xfc\x00\x1d\x01F\x01n\x01\x97\x01\xbd\x01\xe1\x01\x0b\x02,\x02J\x02i\x02\x8d\x02\x9e\x02\xa4\x02\xa6\x02\xa9\x02\xa4\x02\x92\x02w\x02h\x02U\x024\x02\x16\x02\xf5\x01\xd1\x01\xa6\x01w\x01H\x01\x0b\x01\xd9\x00\xb4\x00z\x00*\x00\xe9\xff\xc6\xff\xb1\xffr\xff$\xff\x1b\xff5\xff2\xff\xf9\xfe\xf0\xfeQ\xff\xb2\xff\xba\xff\xa0\xff\xc7\xff\x13\x00!\x00\xfb\xff\xef\xff\x00\x00\x0b\x00\xd2\xff|\xffP\xffB\xff\'\xff\xd9\xfe\x8a\xfex\xfey\xfed\xfe:\xfe&\xfe9\xfeO\xfeN\xfeD\xfeN\xfef\xfe\x8f\xfe\xb8\xfe\xd7\xfe\xfc\xfe+\xffo\xff\xa5\xff\xcc\xff\xed\xff\x1f\x00O\x00d\x00s\x00~\x00\x92\x00\xa2\x00\x9c\x00\x89\x00\x86\x00\x80\x00o\x00N\x006\x002\x00\x1a\x00\xfc\xff\xde\xff\xcb\xff\xb8\xff\x9d\xff\x83\xffu\xffx\xffu\xffn\xffk\xffr\xff\x84\xff\x86\xff\x8b\xff\x89\xff\x93\xff\x9e\xff\x9d\xff\x99\xff\x9a\xff\x9e\xff\xa1\xff\xa2\xff\xa7\xff\xb7\xff\xcc\xff\xd7\xff\xdf\xff\xed\xff\x07\x00.\x00T\x00s\x00\x9a\x00\xbf\x00\xde\x00\xf7\x00\x15\x01:\x01W\x01k\x01t\x01\x83\x01\x85\x01u\x01[\x01H\x01=\x01(\x01\x12\x01\xfb\x00\xe2\x00\xc2\x00\xaa\x00\xa9\x00\xc6\x00\xee\x00\x18\x01@\x01s\x01\xb6\x01\xff\x01I\x02\x9a\x02\x00\x03l\x03\xb5\x03\xd9\x03\xf5\x03\x1b\x04;\x04/\x04\x05\x04\xd9\x03\xa2\x03]\x03\xf2\x02|\x02\x12\x02\xa2\x01$\x01\x96\x00\x07\x00\x82\xff\xfd\xfem\xfe\xe1\xfd\\\xfd\xd9\xfcZ\xfc\xdd\xfbw\xfb\x1f\xfb\xc8\xfa\x7f\xfaH\xfa.\xfa\x1f\xfa\x1d\xfa0\xfaK\xfak\xfa\x92\xfa\xbf\xfa\xfc\xfa8\xfbs\xfb\xb5\xfb\xfa\xfbD\xfc\x8c\xfc\xd7\xfc%\xfdp\xfd\xc1\xfd\x0f\xfeZ\xfe\xa5\xfe\xed\xfe>\xff\x90\xff\xe4\xff,\x00|\x00\xca\x00\x0e\x01N\x01\x89\x01\xc7\x01\x04\x02@\x02n\x02\x8f\x02\xb3\x02\xd7\x02\xee\x02\xf7\x02\xfd\x02\x01\x03\x02\x03\xf6\x02\xe6\x02\xd7\x02\xc7\x02\xaf\x02\x96\x02x\x02[\x02C\x02$\x02\x03\x02\xe2\x01\xc7\x01\xab\x01\x86\x01c\x01H\x016\x01\x1f\x01\x07\x01\xf7\x00\xf1\x00\xe8\x00\xd5\x00\xc0\x00\xb3\x00\xa9\x00\x91\x00q\x00W\x00<\x00\x1f\x00\xf8\xff\xd3\xff\xb3\xff\x93\xffm\xffJ\xff.\xff\x13\xff\xf6\xfe\xd8\xfe\xbd\xfe\x9c\xfe\x7f\xfec\xfeJ\xfe5\xfe\x1d\xfe\r\xfe\x01\xfe\xfc\xfd\xf2\xfd\xf2\xfd\xff\xfd\x17\xfe4\xfeO\xfew\xfe\xa1\xfe\xca\xfe\xf1\xfe\x16\xffE\xfft\xff\x9f\xff\xc2\xff\xe4\xff\x0b\x007\x00\\\x00z\x00\x93\x00\xaf\x00\xc8\x00\xd4\x00\xda\x00\xe3\x00\xeb\x00\xec\x00\xde\x00\xd9\x00\xd5\x00\xcf\x00\xc2\x00\xaf\x00\x99\x00\x88\x00~\x00m\x00]\x00L\x00>\x003\x00&\x00\x1a\x00\x0b\x00\x07\x00\x06\x00\x04\x00\x01\x00\xfc\xff\x01\x00\x00\x00\xff\xff\x02\x00\x06\x00\r\x00\x15\x00\x17\x00\x1b\x00)\x00,\x000\x00/\x001\x00>\x00K\x00K\x00>\x00B\x00G\x00E\x00>\x004\x00-\x00-\x00+\x00\x1b\x00\r\x00\x02\x00\xfa\xff\xee\xff\xdc\xff\xd1\xff\xc3\xff\xb6\xff\xa9\xff\xa9\xff\xb6\xff\xc6\xff\xd7\xff\xea\xff\r\x009\x00b\x00\x95\x00\xd7\x00&\x01m\x01\xaa\x01\xe8\x01(\x02^\x02\x86\x02\xaf\x02\xcf\x02\xdf\x02\xda\x02\xca\x02\xbc\x02\x9e\x02d\x02"\x02\xe0\x01\x98\x01@\x01\xd6\x00i\x00\xfe\xff\x9b\xff0\xff\xbb\xfeQ\xfe\xee\xfd\x9f\xfdM\xfd\xfa\xfc\xb6\xfc\x86\xfce\xfcH\xfc0\xfc+\xfc9\xfcK\xfc`\xfc|\xfc\xa6\xfc\xd2\xfc\xfd\xfc.\xfda\xfd\x9a\xfd\xcb\xfd\xfb\xfd-\xfe\\\xfe\x95\xfe\xc2\xfe\xeb\xfe\x14\xff9\xffh\xff\x8c\xff\xb5\xff\xd3\xff\xf4\xff\x1f\x00D\x00d\x00\x87\x00\xb2\x00\xdf\x00\n\x01+\x01I\x01m\x01\x8f\x01\x9e\x01\xa7\x01\xb5\x01\xc2\x01\xc4\x01\xb6\x01\xb1\x01\xae\x01\xa6\x01\x99\x01\x86\x01p\x01\\\x01P\x01;\x01 \x01\x12\x01\x0c\x01\x04\x01\xf7\x00\xe8\x00\xdb\x00\xd7\x00\xcc\x00\xba\x00\xa3\x00\x8c\x00\x85\x00p\x00E\x00\x16\x00\xef\xff\xc1\xff\x8b\xff[\xff5\xff\x12\xff\xe5\xfe\xb6\xfe\x86\xfe`\xfeB\xfe4\xfe\x1c\xfe\x0f\xfe\x0c\xfe\x0f\xfe\x1d\xfe$\xfe.\xfe>\xfeN\xfei\xfe\x83\xfe\xa7\xfe\xd2\xfe\xf1\xfe\x1c\xffP\xff\x93\xff\xc5\xff\xf6\xff(\x00_\x00\x90\x00\xb4\x00\xee\x00(\x01J\x01[\x01m\x01\x80\x01u\x01z\x01\x8b\x01\x97\x01\xaa\x01\xc6\x01\xb8\x01\x86\x01J\x01/\x01%\x01\x14\x01\x0f\x01\xf3\x00\xc9\x00\xae\x00\x8e\x00N\x00\x0e\x00\xe2\xff\xdf\xff\xf9\xff\x06\x00\xf2\xff\xd8\xff\xca\xff\xbc\xff\xc8\xff\xd4\xff\xda\xff\xd5\xff\xc3\xff\xa4\xffb\xff"\xff\x10\xff\x19\xff\x1d\xff&\xffA\xff\x81\xff\xde\xff\\\x00\xf7\x00\xb4\x01\x90\x02`\x03\x17\x04\xc4\x04i\x05\xfb\x05a\x06\x89\x06\x95\x06\x8e\x06d\x06\x0b\x06\x96\x05\x12\x05y\x04\xca\x03\x14\x03d\x02\xbb\x01\x1a\x01k\x00\xaa\xff\xeb\xfe6\xfe~\xfd\xbb\xfc\x01\xfcb\xfb\xda\xfa\\\xfa\xf2\xf9\xbb\xf9\xb6\xf9\xc7\xf9\xf1\xf9E\xfa\xc1\xfaC\xfb\xca\xfbV\xfc\xe1\xfc]\xfd\xca\xfd"\xfeX\xfep\xfe}\xfe\x8d\xfe\x86\xfen\xfeN\xfe3\xfe \xfe\x04\xfe\xeb\xfd\xdf\xfd\xd6\xfd\xce\xfd\xca\xfd\xc9\xfd\xc4\xfd\xd3\xfd\xd8\xfd\xd3\xfd\xe4\xfd\xfa\xfd\x1b\xfe@\xfey\xfe\xc1\xfe\x12\xffl\xff\xcd\xff1\x00\x90\x00\xf1\x00N\x01\xa6\x01\xef\x01#\x02D\x02l\x02\x84\x02\x84\x02{\x02}\x02\x85\x02\x86\x02\x85\x02\x9b\x02\xda\x02\x1b\x039\x03M\x03\x82\x03\xbc\x03\xd4\x03\xc8\x03\xaf\x03p\x03\x1b\x03\xc9\x02~\x02\x03\x02e\x01\xcf\x00@\x00\xb3\xff&\xff\xb4\xfeO\xfe\xf9\xfd\xaa\xfdA\xfd\xe9\xfc\xb3\xfc\x98\xfcV\xfc\x03\xfc\xe9\xfb\xe4\xfb\xb7\xfb\x85\xfb\x8c\xfb\xd3\xfb\xf7\xfb!\xfc\x8f\xfc\xff\xfc3\xfdI\xfd\x8b\xfd\xe0\xfd\xec\xfd\xe9\xfd;\xfez\xfe]\xfer\xfe\xc2\xfe\xe1\xfe\t\xffX\xff~\xff\x81\xff\x9f\xff\xba\xff\x9a\xff\xc4\xff"\x009\x00,\x00\x88\x00\x07\x01\x15\x01\r\x01b\x01\xea\x01*\x02A\x02\x8b\x02\xe4\x02\x13\x03a\x03\xab\x03\x9b\x03m\x03I\x03I\x03L\x03B\x03^\x03h\x03\xdd\x02j\x02\x8e\x02\xb7\x02\x87\x02P\x02\xa0\x02P\x03\xfd\x03\xcf\x04"\x06\xdb\x07z\t\x90\n&\x0b\xed\x0b\xec\x0c}\r#\r\x81\x0c\x0c\x0cx\x0bC\n\xef\x08\x0b\x08B\x07\xe5\x05\xfb\x03\r\x026\x00R\xfe\\\xfcD\xfa\xfb\xf7\xde\xf57\xf4\xf0\xf2\xca\xf1%\xf1#\xf14\xf1\x11\xf1*\xf1\xe4\xf1\xc9\xf2\x89\xf3K\xf48\xf5\x0f\xf6\x1b\xf7\xb2\xf86\xfal\xfb\xdf\xfc\xc4\xfe:\x00\xe3\x00\xb8\x01\x07\x03\xd0\x03}\x03\xff\x02\x0f\x03\x13\x03s\x02\xc9\x01\x89\x01Y\x01\xb4\x00\xd7\xffG\xff\xe2\xfe6\xfeR\xfdx\xfc\xbd\xfb\x06\xfb\x8b\xfas\xfa\x96\xfa\xe7\xfaQ\xfb\xee\xfb\xb4\xfc\x9a\xfd\xb6\xfe\xc6\xff\xa3\x00F\x01\xe9\x01\xa2\x02r\x03_\x04*\x05\xaf\x05"\x06\x9a\x06\x0b\x07B\x079\x07\x1d\x07\xc1\x060\x06z\x05\xc1\x04;\x04\xbd\x033\x03\x84\x028\x02\xb9\x02\\\x03R\x03\xde\x02\x97\x02\x80\x02\xce\x01\xfd\x00\x8b\x00\x07\x00\x04\xff\xf4\xfdx\xfdT\xfd\x04\xfd\xc6\xfc\x9c\xfc*\xfc\xaa\xfb\x81\xfb\xac\xfb\x9a\xfb_\xfbs\xfb\x9d\xfb\x83\xfbY\xfb\xb7\xfbt\xfc\xd3\xfc\xa0\xfcl\xfc\x85\xfc\xb2\xfc\x8e\xfcm\xfc\x87\xfc\xbb\xfc\xb1\xfc\x97\xfc\x0f\xfd\xd0\xfdM\xfe;\xfe\x0e\xfe\xdc\xfdz\xfd\x14\xfd\xf0\xfc\xf3\xfc\xd2\xfc\x86\xfcV\xfcF\xfch\xfc\xb4\xfc\n\xfd9\xfd\x11\xfd\x1b\xfd5\xfd\x19\xfd\xd0\xfc]\xfd\x1a\xff\xb5\x00-\x01\x82\x01\xb0\x02\xe9\x03\xd9\x03h\x03\n\x04\xf9\x04\xe0\x04u\x04]\x05\x1b\x07\xfd\x07\x8d\x07\x03\x07\t\x073\x07\xcc\x07\xbb\t8\rc\x11\xff\x13y\x14|\x14Z\x15\x00\x16\x81\x14\xdb\x12C\x13\xd4\x13\xbd\x11\x00\x0f>\x0f\xf1\x0f\x93\x0c\x96\x06\xa0\x02=\x00\xce\xfb\x96\xf6X\xf4\x9a\xf3#\xf1\xc1\xed\xbe\xeca\xed\xae\xec\xed\xea\x8b\xe9\xe9\xe8\x8a\xe8\xe0\xe8\x1a\xea.\xecd\xef\xf2\xf2\x94\xf5\xc7\xf7\xe4\xfa,\xfe}\xff\xb5\xff8\x01s\x03n\x04\xd0\x04\xb1\x06\xed\x08\x1b\t\xee\x07C\x07\xbf\x06\xbd\x04\xd0\x01\x90\xff\x15\xfej\xfc\x94\xfaY\xf9\xa0\xf8\xce\xf7\xa1\xf6\\\xf5O\xf4\x82\xf3\x1a\xf3Q\xf3\xeb\xf3,\xf5\x07\xf7F\xf9W\xfb\xfc\xfc\xe5\xfe\xca\x00k\x02\x99\x03\x08\x05\xfe\x06\xea\x08Q\n\xe2\x0bY\ro\x0eJ\x0eK\x0e\x93\x0e\x19\x0e\xfb\x0c.\x0c\x10\x0b\x8b\tq\t\x8d\x0c\xc9\x0e\x87\x0c\x8c\x08\x83\x06\x80\x05n\x02\x17\x00\xc7\xff9\xffQ\xfc\x1e\xfa\xbb\xfa\xc8\xfb^\xfa7\xf7d\xf4\xad\xf2\x16\xf2\xfe\xf2\xd6\xf4[\xf6\xbf\xf6\xab\xf6+\xf7\xd4\xf7\xb0\xf8:\xf9\\\xf9\\\xf9\xc9\xf9\x93\xfb\x00\xfe\xbd\xff\xad\x00N\x00h\xff\xad\xfe)\xffm\x00\x9e\x00/\x00\xdd\xff\x95\xff.\xff\xea\xfeo\xff\x1e\xff\x84\xfd\x0f\xfc\xab\xfb\xd2\xfbT\xfb2\xfbS\xfbr\xfb\x93\xfa1\xfa$\xfb\xb2\xfc6\xfd&\xfc%\xfc\x1d\xfd\xc3\xfe\xb1\xff\xee\x00]\x03\xe8\x04\xb6\x05\x86\x06\xd1\x07d\x08\x98\x08K\t/\n=\ng\x0b5\x11\xbb\x19\xdf\x1d\x92\x1a\x96\x15\x9d\x15\xe5\x17\x1c\x17\xd1\x15|\x19^\x1d\xf9\x19!\x13\xba\x12\xe0\x15\xde\x10\xc8\x05\x18\xff\xf8\xfe\x03\xfd\xb4\xf8\x15\xf9b\xfbn\xf7V\xee>\xe9\x02\xe9\xec\xe7\x8c\xe5\xed\xe45\xe6\x86\xe7m\xe9\x86\xec\xc8\xee1\xefK\xef\x06\xef\x1e\xf0\x86\xf3v\xf9\x13\xfe+\x00)\x022\x04O\x05\x1a\x05\xc6\x046\x05R\x05\x04\x05\xf1\x04\xfd\x05\xaf\x07\xc7\x06"\x03I\xff9\xfdu\xfbD\xf8\xb7\xf6\xea\xf6X\xf6X\xf4\x8a\xf3\xb5\xf4\xdd\xf4\x14\xf3\x80\xf1\x8e\xf1G\xf2\xaa\xf3\xbe\xf6\xe7\xf9T\xfc_\xfdC\xffn\x00\xde\x01\x8a\x03\xb4\x054\x07\x9f\x08\x9a\n\xce\x0cZ\x0eX\x0fl\x0fN\x0e%\x0eX\x0e\xfc\x0eN\x0e\x1d\x0eG\r\n\r\xfc\r!\x0f\x89\r1\t\xf9\x05K\x03\x0e\x01Y\xffe\xff\xaa\xfe\xeb\xfb$\xf9?\xf7(\xf6N\xf4z\xf2\xe2\xf0\t\xf0}\xf1\xab\xf2\xa9\xf3e\xf4\xd7\xf4\xc6\xf4\xa9\xf3\xe3\xf4\xa5\xf6\x8a\xf8}\xf9i\xfa\x16\xfc\x0e\xfdW\xfe\xde\xfe\xaf\xff@\x00L\x00\xcc\x00\x06\x02\xf0\x03~\x05F\x05\xcb\x03!\x03\x19\x02E\x01\xed\x01b\x03\xb0\x01\xb7\xff\xa4\xff\x92\xfeS\xfdA\xfe\x15\xfeB\xfb\x1b\xfb\xbb\xfc\x83\xfd\xdd\xfc\x06\x00O\x02\x95\xff\x02\xff\xc8\x03F\x05M\x04?\x05\xe6\x07\xc3\x07\xde\x06l\x0bI\x0c[\n\x0e\n+\x0c\r\x0b\x1f\n\x18\x0bi\n\r\n\xb4\t\xf1\n\x8b\t\t\nJ\x0bE\x0cn\x0b\xa4\t\x1b\n\xe1\to\t\x0c\tw\x08\xcc\x08\x1f\x08X\x06i\x05^\x04l\x02\xec\xffI\xfe\x9f\xfdN\xfcg\xfb\xdd\xfa\xa3\xf9\x84\xf8w\xf7\x85\xf6\x15\xf5\xd3\xf4\x8f\xf4N\xf4\xcc\xf4V\xf5M\xf6f\xf5\xce\xf5\xa3\xf6R\xf7\xb6\xf7^\xf8\xdc\xf9\xf1\xfa\xd1\xfbY\xfc~\xfd\x15\xfe4\xfe@\xfea\xfe\x83\xfe\x91\xfe\xd8\xfe\x03\xff!\xffP\xfe\xa4\xfd\xee\xfcg\xfc4\xfc;\xfb\xbc\xfa\xad\xfa#\xfa\x87\xfa\xf6\xf9\x14\xfa\xfc\xf9\xbe\xf9\x1c\xfaj\xfa\x8c\xfb\x18\xfc\xb1\xfc\xae\xfd\x13\xfeR\x00\x9d\x01{\x03@\x06\x98\x06\xff\x07Z\x075\t2\tw\n\xf0\n\x04\n\x87\n\xa7\x078\x08\x13\x06\xf9\x04\xfd\x02\x06\x01\xb4\xff\x8a\xfe\xbe\xfe\x05\xfd\xb7\xfd\xb6\xfbI\xfa\x87\xf9\xb0\xf9\xe2\xf9\xbb\xf9P\xfa\xb7\xfa\xe4\xf9\xf7\xfa\xfe\xfaP\xfc\xdc\xfc\xd0\xfb\x01\xfe\xfc\xfc\xe0\xfc\n\x00\xde\x00\x1b\xfej\xff\xd6\x01\x92\xff\xeb\x00?\x02:\x02\x12\xff\xf0\x00\xcc\x03\x8d\xff\xd2\xff\x06\x04\x80\x03\x0c\xfd\xcd\xff~\x01D\xffk\x01\xef\x03\x1f\x00\x0b\xffe\x02\xfe\x01\x85\xffc\x02\xbc\x03\xdc\x00\xd7\x01\x84\x02\x97\x02\x92\x01\x0b\x04\xb4\x00\xd4\x00\xeb\x013\x01\xa1\x01f\x02\xf7\x01\x9b\xfe\x89\x00\xee\x00\xab\x00\xc5\x01\xd9\x01L\x00?\x02G\x00\x07\x02\xbd\x02:\x01\xff\x011\x01\x92\x03G\x01\x0c\x031\x04G\x01f\x03\xe5\x018\x02a\x02f\x00\xe4\x01,\x00\xe0\x00]\x00\xa2\xff\x04\xfe5\xffC\xff\x1a\xfd\xfa\xfd\xfd\xfcc\xfe0\xfd\xa5\xfd\r\xfe,\xfd\xa1\xfd\x86\xfe\x92\xfe\xd0\xfen\x00.\x00j\xffr\x00\xf8\x01j\x00[\x02\x85\x02\x9d\x01\xbd\x02~\x03\xea\x01\xe5\x01\x99\x03 \x01$\x01S\x02\x96\x01\x9a\x00&\x00\xeb\x00D\x00\xed\xfe\x99\xff\x08\xfe\xf4\xfe"\xfe\xf8\xfd\xe5\xfdn\xfdh\xfd\xe2\xfc\xdf\xfe\x8f\xfbY\xfd\xc4\xfd\x85\xfb\xd7\xfc\x82\xfd\x96\xfc\xf1\xfcE\xfe\x1f\xfd(\xfd\x05\xfe\xce\xfd\xf1\xfdM\xfe\xd9\xfex\xff\x0e\xffZ\xff\x1e\xff\x12\x010\xff\x8a\xff\xda\x00 \x001\x00E\x00\xcd\x00\xcf\xff\x07\x01\xf8\x00\xf5\x00\xa4\x00\xc7\xff\x9c\x00\xe4\xff\xfc\xfe\xbc\x00A\x00k\xff=\xff\xa8\x00D\xffP\xff\xe5\xff:\xfe6\x00\xca\xff\xd9\xff\xa0\x00\xe7\x00X\x00%\x00\xad\x00\x10\x01{\x00\xba\x01\x9e\x01\xea\x00\xb6\x01\xaa\x01\xb3\x01\xa0\x00\x03\x01\t\x01\xc1\x00V\x00\xb0\x02\xb2\xff\xb5\xff\xfd\x01\xe0\xfe\xab\xff@\x00\xfa\xff\xd0\xfe\xd4\xff!\x00\xeb\xfe\xcb\xff\x9c\xff?\xff\xa8\xfe3\x00i\xffJ\xff\xf1\xff\x8d\xff\x88\xff{\xff\xa0\x00>\xff-\x00\x0b\x00\x82\xff=\x00\xdf\x00X\x00\xbd\xffp\x00\x1b\x01[\x00\x9b\x01H\x01\xd3\x00\x86\x01\x82\x00\xca\x01q\x01v\x00\x10\x01\xca\x00\xa8\x00R\x01\xba\x00i\x01m\x00\xea\xff\x9e\x00f\x00\xef\x00L\xff\x9c\xff5\xff\xc2\xfe\x1b\x01\x19\xfe\x98\xff\xdb\xff\xd5\xfdR\xff\xd9\xfe6\xff&\xfe\x9f\x00\xd8\xfe\xdb\xfe \x00I\xff\xc6\xffo\xff\x83\x00\x07\x00@\x00\xc9\xff\xb3\x00\x97\x00\x87\x00\x89\x01\x9a\x00\xde\x00\x8c\xff/\x02\x92\x00L\x00\x17\x01H\x00\x98\x00x\xffi\x01\x10\x00\x91\xff\xdc\xff\xcc\xff\x9f\xff\x96\xff\x84\xff>\xff\x90\xfe\xd0\xff#\xff0\xff\x80\xff\x0c\xff;\xff\xc2\xfe\xd7\xffp\xfe\xc6\xff\xc2\xfe\xcb\xffF\xffn\xff\xf3\xff\xa7\xff|\xff]\xff\xc3\xffy\xff\x1a\x00\x99\xffq\x00\x7f\xff_\x00_\x00\xa7\xff\xff\xffu\x00\xce\xff\xdb\xff\xdc\xff\xbd\xff\x9c\xff~\xff\xb1\xff\x18\xff\xc4\xffX\xfe\x1f\xff%\xff\x17\xffM\xff^\xff\xda\xfeY\xff^\xff\x80\xff%\xff\x9e\xff\xe7\xff\x00\xffm\x009\x00+\x00X\x00\xda\x00\xb8\x00\xf1\x00\x9c\x00]\x01\xe1\x00\xa3\x01\x19\x01\xd8\x01P\x01\xfa\x00\\\x02d\x00\xb7\x01\x94\x00\x0c\x01g\x00 \x00\x13\x00Q\x00\xc6\xff}\xff\xca\xff\x0f\xff7\xff\x1b\xff#\xff\xd9\xfe\x04\xff\xdc\xfe?\xff\xa9\xfel\xff*\xff\x81\xff\xb3\xfex\x00\xc7\xfeS\xff#\x01X\xffe\x00)\x00-\x01\x05\x00W\x01D\x00<\x01\x9d\x00\xec\x002\x01\xbc\x00\x96\x01\xe2\x00n\x01\xb1\x00\xe4\x00\x8d\x00\x08\x01/\x00\xc6\x00\x89\x00_\x005\x00\x12\x00\x0f\x00\x8a\xff\xda\xffy\xff\x89\xff[\xffY\xff\xb6\xffq\xff\x16\xff\xa0\xff\x08\xffI\xffo\xff\xa0\xff\x85\xff\xad\xff\xc2\xff\x00\x00\x08\x00\x1d\x00\x92\x00`\x00\xb1\x00\xb5\x00?\x016\x01\xfb\x001\x01\x06\x01\x1e\x01;\x018\x01c\x01\xbb\x00\xd7\x00\xd7\x00s\x00\xed\xff3\x00\'\x00)\xff\xe2\xff1\xff\x17\xff\x0b\xff\xb4\xfe\xe9\xfe2\xfe\xc8\xfe\xb1\xfe$\xfe\xd3\xfe\x9f\xfe\xa9\xfe\xe9\xfe\xec\xfe\r\xffy\xffO\xffs\xff\xdb\xffM\x00N\x00j\x00\xa9\x00\xa1\x00B\x01\xa2\x00\xbd\x00_\x01\xeb\x00\x15\x01P\x01\xcf\x00\x9c\x00\xdd\x00\xdb\xffh\x00\xdc\xffr\xff\x06\x00\\\xff\x1b\xff\x93\xff\xc8\xfe`\xfe\x9e\xfeT\xfe\xbf\xfe\xf5\xfd\xd9\xfe\x84\xfe\n\xfe\x1d\xff\xc1\xfe\xae\xfe\xda\xfeQ\xff\xf2\xfej\xff\xb9\xff\x1a\x00e\x00\x0f\x00\x9a\x00\x10\x01\x85\x00\xeb\x00\'\x01\xe1\x00z\x010\x01\xa6\x01\xb9\x00\x8e\x01_\x01\xda\x00\xfc\x00\xc7\x00\xb2\x00O\x00\xcd\x00\'\x00<\x00P\x00d\xff\xd2\xff\xb8\xff&\xff`\xff\x1a\xff\xf8\xfe\x15\xff\x08\xff\xaa\xfe\xdf\xfe\xea\xfef\xffy\xfe%\xff\x92\xfea\xffo\xff\x92\xfe\x00\x00\x14\xffx\x00\xda\xffZ\x00[\x00\xac\xff\xf3\xff\x02\x00\x00\x01*\x00`\x00\xa5\x00\x9c\x00\x06\x00\x83\x00\xd8\xff\xbc\xff\x17\x00\n\x00R\x00W\xff\x99\x00\x88\xff\x88\xff\xa2\xff{\xff\xdb\xff\xdb\xffk\x00\x86\xff\x1b\x00\xd6\xff$\x00\xea\xff\xfc\xff\xfe\xff\xa1\x00\xa3\x00&\x00K\x01|\x00\x94\x00\x94\x00\xb4\x00\xd1\x00\xce\x00\x05\x01L\x01\xfa\x00\xa1\x00\xee\x00\xdf\x00h\x00\x8f\x00\xa6\x00\xc7\x00\xd3\x00\x80\xff\x92\x00\x16\x00\xd1\xff\xb7\xffA\x00\x0c\xff\x0c\xff\xb3\xff\x0e\xff\x81\xff\xc9\xfeN\xff\xc6\xfex\xff\xa4\xfez\xff\xf3\xfe\xf5\xfe\x8f\xff\xa2\xfe\xbc\xff+\xff\xdf\xff\xce\xff\xdc\xff\x16\x00\xb6\xff\xd2\xff\r\x014\x00\xe5\x00\xea\x00\xac\x00\xa5\x01\xb2\x00\x8a\x01m\x01\x04\x01\xa1\x01\xce\x01\xcf\x00\x83\x02\x10\x02e\x000\x01#\x01\xd5\x00\xa3\x00\x87\x00\x03\x00\x13\x00l\xff\'\xffX\xff\x9a\xfe\xa6\xfeg\xfe\x0f\xfek\xfe9\xfe{\xfeV\xfe\x8f\xfeT\xfe\xa2\xfe\x13\xff\xf1\xfe\x03\xff\x01\xff\xa1\xff\xed\xff\x02\x00}\x00Z\x00@\x00\xaa\x00\xcc\x00D\x01V\x01p\x01\xd7\x00\xb4\x01\x13\x01H\x01\xa4\x01\xa1\x00\xce\x00$\x01\x8a\x00\xa9\x00\n\x01\xae\xff\x10\xff8\x01\x18\xffF\xfe\xe9\x00\x91\xfe>\xff\xcf\xfeG\xff\xa8\xff\xe0\xfe\xa2\xfeu\xffo\xfeG\xff\x0b\x00\x1d\xff\xec\xffi\xff\xd5\xff\xaa\xff\xd6\xff*\x01\xde\xff\xcb\xff\'\x01\x0e\x00\xad\x00\xed\x00\x10\x01\x00\x00\xbf\x00\xf5\x00\xf9\xff@\x01\xc1\xff\xb8\x00\x00\x00\x7f\xffM\x00\x83\xff`\xff\xe4\xff=\xff\xb2\xfe\x1b\x00W\xfe\xc3\xfe~\xff\xcc\xfen\xfe\\\xff\x1b\xff\xa2\xfe\x03\xff\x15\xffJ\xff\xe3\xfe\xa7\x00\xa2\xfe\x00\x00\xcf\xff@\xff8\x00\x12\x00b\x00(\x00\x19\x01\xb5\xff~\x00\xde\x00\xf8\x00d\x00:\x01N\x00+\x01S\x01Q\x01\x12\x01\x9f\x00k\x01\x06\x00\xbd\x00#\x01<\x001\x00\x95\x00\xe7\xff\n\x01\x04\x00\x19\xff\xbc\xff\xb5\xff\x81\xff\xa4\xff5\x00P\xff&\x00\r\x00e\xfe\x98\x00M\xfe\xc8\xffl\x000\xffe\x005\xffL\x00u\xff6\x00O\xff\xc4\xff\x13\x00\x11\x00\xd8\xff\x8d\xff$\x00\xda\xff:\x00]\xfeK\x00\x1b\x00\xc3\xff\xcd\xff\xb4\xff\x17\xff+\xff_\x01\xaa\xffe\xff\xb0\xff\x1e\x00V\xff\xa4\x00\xc4\x00\x12\xffi\x00}\xffc\x00\xa9\xff(\xff\xe4\xff\x00\x00[\x00{\xffT\x00\x95\xfet\xff\xd6\xff<\x00\xb4\x00s\xff\x13\x01#\xfe\xce\x01R\xffZ\x00\xcd\x01E\xff\x05\x01\x83\xff\xc1\x01\xce\xff\xb8\x01\xc9\x008\xff\xed\x00\xc4\x00\xdb\xfe\xe1\x008\xff\x12\x01\xd8\x00t\xff\xa2\x01\xcc\xfch\x01y\x00S\xfe\r\x00\xa4\x00\xfa\xff\xe6\xff\x11\x01=\xfd\\\xfe\x8f\x00s\xff\x85\x00\xeb\x00\xcc\xff-\xff\x96\xff@\x00\xfc\xfe\xb5\x019\xfe\xa8\x02\xa8\x00\xc6\xff\xa0\x00\xb0\xfa\xd6\x01i\x01g\x01\xd6\x00M\xff\x18\xff>\xfe\xb2\x00\xfd\xfe2\x01\x13\x00\x0e\xfe\xbc\x00\x15\xfd\xcf\x034\xfe \xfa\x92\x04X\xfd\xaa\xfe\x89\x00=\x01\x8b\xfd\xb7\x00\x86\x01\xef\xfd\xce\x01*\xff\xb1\xff3\x02\x96\x00\x95\x01\x0e\x01\x90\xffL\x02\xba\x009\xffM\x01\xc3\x01d\x03%\xff\x92\xfe\x92\x04\x18\xfd#\x05i\x00\x8f\xfb\xb3\x03\xf9\xfbi\x05L\xfd\x01\x02\x95\x00*\xf9\xb4\x02u\x01(\xff\xd8\xfe\x9d\xfd\xfc\xffq\x02D\xfc\x0c\x00E\x01\x1a\x03e\xfbA\xffA\x02g\xfd\x92\x03\x8f\x00\x81\xfd\xae\x00\xdf\xfe=\xfec\x02\x81\x00\x95\xffr\xfc\xc7\xfe\xa4\x02t\xff\xde\xfd\x90\xfe\x0e\x01\x1c\xfdv\xfe\x9a\x04,\xfe\xdc\x00\x9e\xfb\xeb\x01\x83\xfe\x16\xff\\\x04\x88\xfb\xdc\x03\x1c\x00\x08\x00X\xfdf\x01\x1e\x00P\xfe\xef\xfeE\x05\xfd\xff@\xfe\t\x01\x96\xfc\xe9\x00\x1b\xfe~\x01g\xff-\xfe\xc1\x01\x08\x02\xde\xfc\x17\x03\xbf\xfe\x07\x00\x03\x01\xae\xfd\x1c\x02t\xfe]\x02\x19\x02\xd6\xff\xaa\xff\xb2\xfe\xf6\xff$\x01\x80\xff\x9b\xffS\xfc\x1c\xff\xad\x00\xa5\xfdL\x02\xb6\xffb\xfdZ\x02\x94\xfe\xc5\xfd\x7f\xfd\xaa\xf7\x9c\x00\xac\x10\x02\x03\x97\x00#\xfe\xf9\xfb\xa6\xffy\x02\x1f\x03N\xff\xd0\x06Y\xfd\x8e\x01\xda\x0bn\x047\xf5&\xf7\xff\xfa1\xfd\x8b\x03}\x00\xda\xfeR\xffC\xfc\t\xf8i\xfc\x7f\x02\xe5\xfb$\xfe\xb6\xffL\x03u\x03\xeb\xfe\xa4\x05\x9e\xfdP\x00\xae\x03 \x02\xd0\x03\xbb\x07\x14\x02\xa9\xff\xc4\x07a\x01\x84\xfb?\x02\x10\x06\x11\x00\xa8\xfd\xaa\x002\xfcM\xfc\xf3\x02\x08\xfds\xfb\xc8\xfa\xed\xfc\xb8\xfb\xf2\x03\x04\x00b\xf9\xf3\xfe\xf7\xf9\x8c\x00`\x00n\x00\x85\x01\xfb\x02\xf2\xfc0\xfeU\xfe\xca\xff\xc1\x04z\xfe\x1f\x01\x9a\xfd\xa5\xfdZ\xffB\x01\r\x01X\xfc\xa0\xff\x10\x00)\xfdU\x01\x8d\x00\xae\xff\xb3\x00\xec\xfe\xf0\xfe\xc7\x00$\x04"\x01\x16\x01\xfb\xffA\x01?\x03s\x02A\x01C\x01\xcc\x01\x89\x01]\xff\x15\x01\xe1\x03%\x01\x1e\xff|\xff\x88\x00\x14\xff\x1e\x01\x9c\x01\xea\xff\xbc\x00\xc2\x00\xdb\xfe\x90\xff\xba\x01\x1a\x01\xe9\x00\x8e\xff\x9b\x00\xea\x01~\x01\xb9\x00\x82\xff\xe1\xffk\x00\xd1\x00\x93\x01\xcc\x01\x9e\x01Z\xff\\\x00v\x00i\x01\x9e\x00\xdb\xff \x01:\x00\xbd\x01w\x01\r\x00\xd4\xfe=\xfe\xeb\xfe \xfe\x14\xfe\x96\xff\xd4\xffS\x00I\xff\xac\xfb\x9b\xfb\x90\xfc\x1d\xfe<\xfd\xd0\xfe\xb9\x00\x11\x00,\xff\x03\xfdu\xfcr\xfb=\xfd\xa5\xfdg\x001\x03m\xfd\xe4\xfb\xf2\xff[\xff\x03\xf9]\xf9\xb4\xfa\x1d\xfd6\xfe[\xfc\x0e\xfe\xcb\xfc\xa7\xf9\xcc\xf6}\xf9\xff\xfc\xa6\xf9\xa0\xf9\xf6\xfbu\xfe\xfd\x00\x89\xfe*\xfbY\xf8\xb0\xfa\xdd\xfd\xb5\xff\xf2\xffl\x00.\xffp\xfc\xbc\xf9\xa0\xf9\x8b\x00\xfa\x07\xfa\x15<\x17\x96\x10\xd9\x0c\x9f\x0f \x18\xc4\x17\xa7\x17\xd7\x1c\xcf!\xef \x92\x1e\xc4\x1f\x19\x1e\x92\x13\\\ti\x07$\x0c\x03\x0c\xdd\x07\x86\x03\xb1\xfd\xc0\xf7\xa3\xf0\x9c\xec\xb4\xeb\xe4\xe8~\xe6 \xe5\n\xe7)\xea\x82\xe9\xac\xe61\xe5\xc9\xe6\xc2\xe8Q\xec:\xf3\xae\xfa~\xfd\xf3\xfb\x8d\xfc*\x00\xf4\x02\xf8\x03]\x04\x05\x08v\n+\x0bd\x0b\xdc\x0b\x0e\x0bj\x04e\xff=\xfd\xac\xfd\xe5\xfe\x83\xfdx\xfbI\xf9\x86\xf3\xf4\xee7\xee\xee\xeeM\xf1\xa4\xf0{\xf0\x86\xf2\xc9\xf4`\xf6q\xf7u\xfa\xc1\xfcK\xff\x04\x023\x06\xd9\x0b\x14\x0e \x0f \x0f\x06\x10\xe3\x11\xfe\x12\x19\x13\xcd\x12\xb9\x12\x92\x10\xa8\x0eG\r\x83\x0b\x90\x08j\x04\x14\x01\x00\x00\xe2\xfe\xa6\xfcg\xfa\xf5\xf7\x92\xf4\x99\xf1\xc2\xf0w\xf08\xf1=\xf2m\xf1\xee\xef\xa2\xef\x82\xef\x81\xf0]\xf2\xfe\xf4\xdb\xf6\t\xf7\x8a\xf8\xab\xf9\xc8\xf9\xae\xfa\x04\xfb\xaf\xfb \xfd\xb1\xfd\xb2\xfe\x88\xfeb\xfe4\xfc9\xf9D\xf8d\xf7\xcc\xf6\x17\xf7\xed\xf8\xf1\xf7\x1b\xf6i\xf5\xc0\xf5\xda\xf4\xd4\xf1\xe7\xf7\xa0\x0c+$"-\x15%\x15\x1b\xe1\x1a\xc4\x1fM%F1DDWN\xa5E\xce5%-_*\xaa \xa4\x14&\x14o\x1a\'\x1bh\x10\xf4\x05O\xfd\x94\xeeI\xdc\x18\xd1\x04\xd4`\xdcy\xe1\x05\xe0i\xdc\x15\xd9b\xd2P\xccn\xcd\x9e\xd7Z\xe4\x04\xed\xa1\xf5\x8f\xfe>\x03\n\xffO\xf9_\xfc\xf8\x05\xf7\x0e\x82\x14#\x19\xac\x1cT\x1a\x8b\x11\x14\n\x0c\x07\xe8\x05\xd8\x02\xc4\xff\x18\x00\xc6\x00\xdc\xfb{\xf2\x19\xea{\xe4\x8f\xdf)\xddJ\xe0\x16\xe8@\xed\xa7\xeb\x99\xe8E\xe6x\xe6\xd9\xe7\xd0\xed8\xf83\x02\xb8\x07$\t9\n\xeb\n\x9c\x0b7\r\x02\x11>\x18\x91\x1d\xfc\x1f\xe5!\xd0 \xdd\x1a\xd3\x11\xed\x0e\x9e\x16\xfd\x1e\xf7\x1e\xad\x19\x9d\x12>\nz\x00\x8c\xfc\x85\x00+\x05d\x03\xfa\xfc\xc4\xf7\x9d\xf2\\\xec\x08\xe9W\xea\xaf\xec\xa7\xec\x16\xebF\xec\xdd\xedx\xed\xea\xeaW\xe8\xbb\xe7\x0c\xea^\xef\x13\xf5\xf0\xf8\xa1\xf8s\xf5-\xf3V\xf2f\xf5\xb5\xfb0\x00\xdd\x02\xd0\x02\x12\x01\xcd\xfdg\xfb\x96\xfb\x93\xfer\x01\r\x02\xea\x02\x9b\x01)\x00\xd3\xfeq\xfe@\xfe\xe0\xfc\x98\xfc\xf0\xfc\x05\x00)\x05\x9d\x0c\xb0\x11\xa6\x10A\x0e}\x0c\xcd\r|\x12\xa2\x1al&\x7f-;,P&4!\x94\x1e~\x1dA \xb8%\xe1(\xe8$L\x1c\xb7\x14\xab\r9\x06S\x00\x9f\xfe\xa3\xff\x98\xfd\x82\xf8\xaf\xf3$\xee|\xe6p\xde"\xdc\x13\xe0\xa7\xe4N\xe6o\xe6:\xe6\x00\xe3\xbb\xde&\xde{\xe4\xff\xec\x97\xf2\xb8\xf5K\xf7\xc9\xf6\xd6\xf4\x80\xf4*\xf8\x9d\xfd\xca\x014\x04I\x05$\x04Z\x01\xca\xfeu\xfe\xce\xff\x82\x01\xfe\x02W\x03\xf0\x01]\xff\xd0\xfc7\xfb\xdf\xf9D\xfab\xfb\xc8\xfc\x0c\xfd\x85\xfc\xef\xfb)\xfb\xcc\xfaB\xfb\x94\xfdM\x00w\x02\n\x03\x02\x03g\x03X\x04\x9a\x06r\tK\x0c,\r\'\x0c,\nP\t.\x0b\x84\r\xc1\x0fx\x10<\x0e\xa3\n\xea\x05\x16\x04\x87\x04\xd0\x04{\x05\xd8\x04\xe6\x02\xb3\xfdf\xf9\xd3\xf7\x8f\xf7c\xf7\x15\xf82\xfau\xf9\xc3\xf5E\xf3\x9f\xf3\xa1\xf4\xcb\xf4\x03\xf60\xf8\x90\xf8\x19\xf7-\xf6\x88\xf6.\xf7\x85\xf7\x04\xf9\x02\xfb~\xfb\xb4\xfa\x02\xfat\xfa\xff\xfa\xb0\xfb\xee\xfc\x83\xfe\x08\xff\xd5\xfe\xba\xfe#\xffQ\x007\x01+\x02\xe3\x02]\x03t\x04U\x05D\x06\x92\x07\x86\x08\xcd\x08\xd6\x07\xbf\x07c\t\xb9\np\x0bU\x0b\xdc\nn\n&\tK\x086\x08\xba\x08\xa0\x08\x05\x08\x04\x07\xce\x05\x1f\x05?\x04X\x04\xdb\x04i\x05\x84\x05\xaa\x04(\x04\x13\x04P\x04\xad\x04\xb7\x05\xb1\x06\xb5\x06\xf2\x05\xe0\x04\xc8\x04\xdd\x04\xa6\x04\xd6\x04\xab\x04\xd0\x03]\x02\xc5\x00\xf4\xff\x08\xff\xd6\xfd\x96\xfc\x85\xfbe\xfa\xb8\xf8\xfb\xf6\xb7\xf5;\xf5\xe0\xf4w\xf4!\xf4\xf8\xf3\x14\xf4K\xf4\x9a\xf4i\xf5\x99\xf6\xb5\xf7\xc3\xf8\xe9\xf9m\xfb\xc7\xfc\xb9\xfd\xbd\xfe\x14\x00X\x01!\x02\x98\x02\x17\x03\x9a\x03\xe6\x03\xf8\x03$\x04D\x04\xe8\x03U\x03\xed\x02m\x02\xcf\x01L\x01\x16\x01\x8c\x00\xbc\xff1\xff\xdd\xfe?\xfe\x98\xfd~\xfd\xa1\xfd{\xfdG\xfdO\xfdS\xfd8\xfdC\xfd\xaf\xfd!\xfem\xfe\x94\xfe\xa1\xfe\xb2\xfe\xce\xfe\x04\xff.\xffB\xff7\xff\x06\xff\xd1\xfe\xc4\xfe\xb5\xfe\xa1\xfee\xfe\x19\xfe\xbc\xfdw\xfd\x91\xfd\xfd\xfd\x1f\xfe\xee\xfd\xe2\xfd\xf4\xfd\xee\xfd\x18\xfe\x9e\xfe+\xffK\xff%\xffS\xff\xa6\xff\xe8\xff+\x00\x87\x00\xca\x00\xda\x00\xfd\x00O\x01\xb6\x01\xee\x01\xe7\x01\xf3\x01!\x02K\x02[\x02i\x02}\x02q\x02I\x02\x0b\x02\x04\x02\xfd\x01\xe2\x01\xca\x01\x98\x01|\x01l\x01T\x01S\x01n\x01\xa4\x01\xa0\x01\x98\x01\x96\x01\xc5\x01\xf9\x01*\x02e\x02\x83\x02\x80\x02G\x021\x020\x02(\x02\x05\x02\xbc\x01~\x01@\x01\xed\x00\x91\x00B\x00\xe3\xffv\xff\xe5\xfem\xfe1\xfe\x13\xfe\xe3\xfd\x8c\xfd&\xfd\xc8\xfc\xa9\xfc\xbe\xfc\xfc\xfc=\xfdr\xfd\x90\xfd\x97\xfd\xaa\xfd\xf7\xfd\x82\xfe*\xff\xc3\xff:\x00\x99\x00\xe6\x004\x01\x98\x01\x18\x02\x8e\x02\xe7\x02.\x03n\x03\x87\x03z\x03B\x03\x10\x03\xd7\x02\xb7\x02\x8c\x02T\x02%\x02\xdb\x01Y\x01\xa4\x00\xfe\xff\xa3\xff\x80\xffn\xffF\xff\xe3\xfe_\xfe\xe1\xfd\x93\xfdw\xfd\x83\xfd\x8b\xfd{\xfd\x82\xfd\x8d\xfd\x9d\xfd\xaa\xfd\xb8\xfd\xd1\xfd\xfb\xfd.\xfe\x94\xfe\x17\xffa\xffh\xffA\xff$\xffX\xff\xbc\xff\x1c\x006\x00\t\x00\xc0\xff\x9c\xffv\xff`\xffY\xff4\xff\xfb\xfe\xcb\xfe\x98\xfeg\xfeQ\xfeF\xfe\x03\xfe\xaf\xfd\xc4\xfd\x15\xfe[\xfe\x83\xfe\x8c\xfe\xab\xfe\xae\xfe\xb7\xfe\x11\xff\xaf\xffG\x00\xad\x00\xd3\x00\xf3\x00C\x01\x89\x01\xce\x01A\x02\x90\x02\xa9\x02\xa4\x02\xc6\x02\xf6\x02\n\x03\x00\x03\xd8\x02\xc2\x02\xc3\x02\xc1\x02\xc7\x02\xbf\x02\x88\x02K\x02(\x02\x01\x02\x05\x02\xed\x01\xa9\x01h\x018\x01\x1c\x01\xf4\x00\x90\x00>\x00\xf5\xff\x93\xff*\xff\xf4\xfe\xeb\xfe\xc7\xfe\x89\xfe&\xfe\xed\xfd\xc0\xfd\x8c\xfd\x83\xfd\x9b\xfd\xcc\xfd\xdb\xfd\xbb\xfd\xd1\xfd\x0f\xfe+\xfe7\xfeJ\xfe\x92\xfe\xf3\xfeL\xff\x90\xff\xe5\xff\x15\x00/\x00U\x00\xa2\x00\x13\x01w\x01\xc2\x01\xe8\x01\xf8\x01\x03\x02\x16\x02:\x02V\x02g\x02d\x02L\x02\'\x02\x00\x02\xd5\x01\xaf\x01w\x012\x01\x01\x01\xe9\x00\xcc\x00\xa9\x00}\x00L\x00\x12\x00\xd8\xff\xae\xff\xaa\xff\xa8\xff\x9e\xff\x7f\xffF\xff\x19\xff\x0f\xff\t\xff\xf2\xfe\xd3\xfe\xb5\xfe\x89\xfeV\xfe2\xfe:\xfe6\xfe\x11\xfe\xec\xfd\xe4\xfd\xdf\xfd\xc7\xfd\xbf\xfd\xda\xfd\xf3\xfd\xfb\xfd\x08\xfe;\xfeu\xfev\xfet\xfe\x92\xfe\xc4\xfe\xe8\xfe\x04\xff,\xffL\xffq\xffw\xffu\xff\x8b\xff\xaf\xff\xd4\xff\xfd\xff\x1e\x00O\x00w\x00\x92\x00\xaa\x00\xdc\x00\xf6\x00\xf0\x00\x00\x01F\x01\x92\x01\xba\x01\xd8\x01\xfc\x01\x0e\x02\x04\x02\x0e\x02F\x02o\x02j\x02Q\x02Q\x02\\\x02Q\x02=\x02"\x02\xef\x01\xa8\x01k\x01K\x01/\x01\x04\x01\xc7\x00\x81\x00/\x00\xe7\xff\xac\xff|\xffF\xff\x10\xff\xd3\xfe\x97\xfeX\xfe!\xfe\xed\xfd\xbd\xfd\x89\xfdT\xfd?\xfd/\xfd\x1f\xfd\x19\xfd\x03\xfd\xef\xfc\xe0\xfc\xe2\xfc\xf6\xfc!\xfd?\xfdb\xfd\x86\xfd\x99\xfd\xbb\xfd\xf1\xfd.\xfet\xfe\xb0\xfe\xfa\xfeA\xff\x85\xff\xc5\xff\x15\x00h\x00\xbd\x00\x1b\x01\x87\x01\xfb\x01^\x02\xb5\x02\x01\x03K\x03\x99\x03\xe5\x031\x04r\x04\x97\x04\xa1\x04\x97\x04\x84\x04{\x04`\x046\x04\xfb\x03\xa9\x03L\x03\xe7\x02v\x02\x05\x02\x93\x01\x1d\x01\xa0\x00!\x00\xa6\xff)\xff\xb0\xfe6\xfe\xc0\xfdY\xfd\x05\xfd\xca\xfc\x8f\xfcN\xfc\x13\xfc\xf1\xfb\xe0\xfb\xe4\xfb\xfd\xfb"\xfcA\xfcV\xfcw\xfc\xbe\xfc\x16\xfdk\xfd\xbb\xfd\x03\xfeJ\xfe\x90\xfe\xde\xfe;\xff\xa0\xff\x01\x00Q\x00\x94\x00\xcf\x00\r\x01A\x01c\x01\x87\x01\xb6\x01\xda\x01\xf7\x01\x0c\x02\x13\x02\x06\x02\xe6\x01\xc7\x01\xb8\x01\xbb\x01\xc7\x01\xc8\x01\xcb\x01\xb4\x01\x94\x01~\x01\x82\x01\x8f\x01\x90\x01\x8f\x01\x8b\x01\x86\x01\x85\x01\x91\x01\x9c\x01\x9f\x01\x92\x01z\x01q\x01g\x01_\x01Y\x01S\x015\x01\xfc\x00\xd4\x00\xab\x00|\x00H\x00\x14\x00\xe5\xff\xab\xff\x85\xffV\xff\x17\xff\xcf\xfe\x86\xfe@\xfe\x04\xfe\xde\xfd\xc4\xfd\x9c\xfdb\xfd"\xfd\xed\xfc\xc6\xfc\xb2\xfc\xb1\xfc\xc0\xfc\xc8\xfc\xd0\xfc\xe7\xfc\x04\xfd!\xfdG\xfd|\xfd\xcb\xfd\x1e\xfex\xfe\xdc\xfe,\xffn\xff\xb4\xff\t\x00q\x00\xd4\x00<\x01\x8a\x01\xc6\x01\xf4\x01\x1e\x02R\x02\x82\x02\xae\x02\xd0\x02\xe2\x02\xf1\x02\xf5\x02\xf9\x02\xed\x02\xd3\x02\xab\x02\x8a\x02v\x02m\x02S\x02&\x02\xee\x01\xb0\x01y\x01=\x01\x17\x01\xed\x00\xb5\x00p\x00*\x00\xf4\xff\xb9\xff\x7f\xffA\xff\t\xff\xcb\xfe\x8c\xfeW\xfe$\xfe\xf4\xfd\xc3\xfd\x9a\xfd\x81\xfdg\xfdI\xfd3\xfd\'\xfd\x1d\xfd!\xfd0\xfdN\xfdd\xfdq\xfd\x85\xfd\xa2\xfd\xc8\xfd\xf2\xfd\x1e\xfeS\xfe\x88\xfe\xb8\xfe\xe0\xfe\r\xff8\xffm\xff\x9f\xff\xd8\xff\x18\x00P\x00\x7f\x00\xa7\x00\xd2\x00\xfb\x00\x18\x014\x01^\x01\x90\x01\xbd\x01\xd3\x01\xd8\x01\xdd\x01\xe0\x01\xe8\x01\xfc\x01\x15\x02\x1d\x02\x0c\x02\x04\x02\xff\x01\xfb\x01\xe4\x01\xd2\x01\xcf\x01\xbb\x01\xb2\x01\xa5\x01\x90\x01q\x01A\x01\x18\x01\x03\x01\xf4\x00\xde\x00\xc6\x00\x9a\x00^\x00 \x00\xe6\xff\xc4\xff\xa4\xff\x89\xffa\xff.\xff\xfd\xfe\xbb\xfev\xfe;\xfe\x05\xfe\xe3\xfd\xc5\xfd\xaa\xfd\x94\xfds\xfdM\xfd"\xfd\x11\xfd!\xfd;\xfdY\xfdn\xfd\x84\xfd\x9b\xfd\xbc\xfd\xea\xfd*\xfeu\xfe\xc2\xfe\x12\xffY\xff\x9b\xff\xe2\xff\x1e\x00h\x00\xc0\x00\x0b\x01R\x01\x92\x01\xc8\x01\xe5\x01\x07\x02!\x02D\x02\x81\x02\x94\x02\xa3\x02\xa7\x02\x92\x02y\x02l\x02V\x02F\x02:\x02,\x02\x11\x02\xe2\x01\xb2\x01~\x01S\x015\x01\x1b\x01\xf0\x00\xca\x00\x94\x00a\x00;\x00\x0e\x00\xf3\xff\xd0\xff\xaa\xff\x8a\xff]\xff1\xff\x03\xff\xe0\xfe\xc5\xfe\xae\xfe\x9a\xfe\x7f\xfee\xfeF\xfe9\xfe,\xfe\x1c\xfe \xfe$\xfe.\xfe:\xfe;\xfeC\xfeR\xfef\xfe{\xfe\x98\xfe\xbd\xfe\xdb\xfe\x01\xff \xff0\xffM\xff\x88\xff\xc6\xff\xdf\xff\xed\xff\x1e\x00W\x00\x84\x00\xad\x00\xce\x00\x00\x01(\x01=\x01M\x01R\x01Y\x01v\x01r\x01r\x01\x86\x01\x99\x01\x9f\x01\x98\x01\xb2\x01\xd8\x01\xe0\x01\xda\x01\xce\x01\x95\x01G\x01.\x01K\x01l\x01\xad\x01\xe3\x01\x96\x01\xdf\x00\xfd\xff\xd9\xff\x03\x00:\x00\x82\x00\x7f\x002\x00\x8b\xff\xdc\xfe\xa1\xfe\xc4\xfe\xe2\xfe\x0c\xff\xf1\xfe\xb6\xfen\xfe#\xfeL\xfe\x88\xfeK\xfeQ\xfe\xdf\xfe\xc2\xfe\xb0\xfe;\xfe\xcb\xfd\x04\xfe\xa8\xfd\xae\xfd\xfd\xfd\x19\xfe\x15\xfe\x8f\xfeJ\xff\x81\xff\xab\xff\xdf\xff7\x00\xe5\xff\x9e\xff\xfd\xfe\x16\xfe\xd4\xfc\xfc\x00\xd9\x0e\xbc\x14P\x05\xc9\xf5\x93\xf8\xdd\xfa\xe7\xf8\x7f\xfe\xaf\t6\t\xbf\x00#\xfd\x86\xfa\xec\xf6S\xf5\xba\xfd$\x064\tY\x08\x98\x02\x1d\xfe\x9d\xfc\xe2\xfb\xcf\x00\xea\x05\xda\x08\xc4\x04n\x01\xf0\xff8\xfe\x13\xff\x00\x00L\x03\xc7\x02\x9a\x00$\xfd\x9b\xfc\xf8\xfc\x95\xfd:\xfe\x97\x00\x86\x00\x7f\xfeJ\xfb4\xfc\x1f\xfb\x0b\xfb\xf8\xfe\xde\xff5\x02\xf2\xfe\x03\xfe\x8b\xfa\x04\xfd\xa0\xff\x81\x05\x8e\x04d\x03f\x02\xaa\xfb\xe4\xfcT\xff`\x07\x16\x07\xf8\x05f\x03\xf3\xfe9\xfc\xe4\xfb\x92\xffK\x02@\x02*\x002\xfc\xf3\xfa\xc3\xf9\xa8\xfb\xf9\xfe\xb0\x00\xfd\xff_\xfe\xf2\xfba\xff\x98\xfb\xd3\xf4%\x06)\x19E\x17H\x06-\xffH\xfb\x0c\xf7\xd7\xfc\xf4\x0bV\x14O\r\xbb\x02\x1a\xf8\x11\xf1\xe0\xf0\xab\xfb\x01\x03\x1e\x03\xcb\x01x\xff0\xf9\x8e\xf3>\xf5+\xfb\x88\xfes\x02\x95\x08\xdf\x02\x15\xf8\xa8\xf7\x9a\xfb\xd4\xfeQ\x04D\x0b\x01\t\x11\xfe\x8a\xf9\xfc\xfaI\x03\xaf\x06a\tk\t\xda\x00k\xfa\xb9\xf8\x9d\xfb\xcd\xfd\\\x02Z\x08\xb2\x04\xa0\xfas\xf59\xf4;\xf9?\xfd\xc8\x01\x9d\x03)\x00U\xfd\xb6\xfbx\xfc\x05\xfe\xae\x03\x1b\x04F\x04\x84\x03V\xffi\xfe\xe3\xff[\x04\xf2\x02\xb0\x01\xbe\x04\x83\x05\x18\x01Y\xfe@\xfe\xd7\xfe5\x02\x8d\x05\xd5\x06\x18\x02\xd9\x00\x05\xfe\xf1\xfe\xd1\x03g\x03g\x02\xa2\xfdL\xfc3\xfb/\xff\xe0\x00\xa1\x03\xec\x02\x18\xfa6\xfb&\xfe\xed\xff\x07\x02\x0f\x03 \x01\xb7\x00\xdb\x01\x07\xffy\xfbw\xff\xe1\x01_\x00\xcf\xffx\x02\xcb\x02!\xfbm\xf9h\xfem\xfer\xfc\xee\xfa\x9b\xfdY\x01\xf1\xfd\xea\xfd\xd2\xfd\xd9\xfb\xaf\xfc\x1f\xffA\xff\xeb\xff\xbf\x03a\x03\x9e\x02\x86\x02I\x01!\x01\xee\xff\xba\xff\xb9\x02#\x03v\x01\xd1\x02\xa0\x07\x1c\x04\xdd\xfd\x1c\xfe\x04\xff\xa1\x02\xbd\x03$\x02\xd6\x03\xaa\x00\x08\xff\xdc\xfe=\x009\x00s\xfe\xeb\xfep\xfec\xff<\xff\xa9\xff\x99\xfc\x80\xf9J\xfa\xab\xfd{\xfe\xce\xfew\x01\xc6\x00h\xfe\x07\xfa\x07\xfa\x1f\xff\xc6\x03\x0f\x06-\x016\xfe\x1c\x00\xe1\x02\x14\x02\xb9\xff"\xfe\xb0\x07D\x05\xb2\xf7\xc9\xfa\xe4\x06\xd9\x07y\xfb7\xfe\xa0\x00&\xfa{\xfb\xfb\x00\xf8\x00\x84\xfco\x01E\x06\xfe\x02\xe2\x00\x12\x00\xe0\xfd\x8c\xfe\xbb\x07\x9f\x0bm\x01\xc8\xfcl\xff \xfb\x9f\xf68\xf9V\x03\x89\x06\xe5\x02\x03\xfd\xa8\xf7,\xf7\xf0\xf8j\xfe\xd4\x05m\nd\x06\xfc\x00s\x01\xb4\xfe\x03\xfb6\x03\xfc\n\xd8\x0eA\n\xc1\x04\xa7\xfa\xed\xf2\xb8\xf8"\x02\xa9\x06m\xfc\xb2\xfe\\\xf9\x10\xf4\x0e\xf7\xce\xf7\xbd\xf9G\xf7\xe4\x00Q\x07\x19\x01\xaa\xfe\xb3\xfea\xfe\xbc\xfa\xc9\xfcj\x08\xa6\x0b`\x07\xf5\x03\x04\x04[\xfde\xfb\xa0\xfe\x0e\x003\x044\x08\xd2\x08\xd5\x00\x9d\xfd^\xfa\xbf\xfb\xbb\xfd\xdd\xfbo\x00\xb1\xfd\xd3\xffS\x01\x89\xf8v\xf6\x07\xf7k\xfbe\x00\xd9\x02W\x08P\x03\xd2\xfa\xf5\xfdh\x00\xc4\x00\xe9\x05\xb5\x12\x93\x0e\xbd\xfc:\xfc\xda\x02\xd3\x06r\x03\xbf\x06-\t}\xff\x01\xfc\x9a\xfe\xfc\x00&\xfb\xf6\xfb\xe2\xff\x17\xfdM\xfa\x1e\xf8v\xfc\xa1\xfe\xe0\xff~\xfdd\xf8\r\xfb\xe8\xff\xd9\x03j\x01u\xfd\x1a\x01f\x055\x03 \x03\xa6\x03I\x04^\xfe\xe0\xfd\xd9\x03\xde\x03\xd6\x01W\x03V\x03s\xfd\x12\xf5\xef\xf8\xc6\x00\xec\xffT\xfd\x98\xfcK\x00W\xfaE\xfaY\x00\xc8\xfeg\xfc\xda\xffp\x04\xd1\xfd)\xfc\xc5\x03\xe2\x04G\x02\x98\x03\xa3\x03\x1d\x00&\xff{\x04\xb9\x04\x1a\x00h\x00\x8b\x03\xe6\x04d\x02\x8a\xfd\n\xfd\xe9\xfc\xac\xfd\x14\xfft\xfeC\x00P\xff\'\xfd%\xf9;\xfc\x92\x00A\xfd\xd3\xfc\xe3\x01\xdb\x05\x00\x01R\xffU\xfe\xdd\x02\xd8\t\xb3\x05\xaa\x02\x00\x01j\xfe\xb3\x01\x91\x04\x8a\x07(\t\x8c\x02\xfc\xfb/\xfbW\xfc\xc6\xfbY\xfc\xeb\xfe\xfa\xfe\x93\xff\xb4\xfe\xa9\xf9\xcd\xf8u\xfb\xa9\xfba\xfaC\xfd\x9b\x01\xb0\x00\x06\xfc\xd0\xfb\x1d\x01/\xff\x11\xfc \xfd\xe8\x02\xc0\x02\xb6\x01\xfa\x04\xed\x02\xb9\xfe\x17\xfeg\x06\x12\x08\xda\x05>\x05J\x03~\xff\xa1\xff\xba\x03=\x01\xed\xfd\xa9\xfe2\x02\x0f\x00s\xfa\x13\xfb\xd1\xfci\xff\x8b\xfb\xea\xfb<\xff2\x00\t\x08<\x00l\xfb\x14\xfeJ\x00\xd2\x02\xb5\x05\x9f\nh\x04%\xfeD\xfa\x9c\xf8\x84\xfc;\x04\x8e\x07\x9c\x06&\x05\xe1\xfe\xfe\xf6\x0b\xf6\xed\xfcY\x01\xd4\x00\x12\x01\xcf\x03}\xff;\xf9\x11\xfa\xfa\xfb\x0e\xfc[\xfag\xfc\xc3\x01\xc3\x05&\x04[\xfeO\xfb\x12\xfd\xdb\x01\xc6\x04\xab\x06\n\x049\x01+\x02\xb3\x02\r\x05\x05\x07\x19\x05\x94\xfd\xa5\xf8a\xfb:\xfe\xd9\xfd\xa9\xfdi\xfe\x84\xfd\x08\xfa\x7f\xfc\xdd\xfe\t\xfe\xdb\xfd\xfd\xfdx\x01:\x03m\x05\xf9\x01#\xff\x8d\x00x\x02\x18\x05\xd2\x04\x89\x06\xe6\x04\x86\x00W\xfd\xf5\xfc\xaa\x01\x13\x05\xea\x07^\x06H\x01\xdf\xfb\x97\xfb\x87\xfd\xff\xf9\xca\xfay\xfeE\x02\xa7\x00\x17\xfd.\xfd\x05\xfai\xf6\x07\xf6\xba\xfc\\\x042\x05\x87\x02\xe2\xfd\xb6\xf9\xb3\xfbH\xff\xe2\x03\xe0\x04\xaa\x03\xe2\x01\xb2\x00\xd4\x02U\x01\xd9\x00\n\x00"\xff-\x01\xe0\x03\xab\x02\xc7\xfe\xbd\xfd\xc5\xfb\xa7\xfa\x91\xfd\x16\x00\x93\x00+\x00m\x00u\xff\x80\xfd\xa2\xfb\x9b\xf9\xc2\xfc?\x00q\x03\xdf\x04m\x04\x06\x03\x82\xfe\xe4\xf9L\xfb;\x01\x8d\x03\xb3\x02C\x02\xd9\x02O\x02E\xff\xf1\xfd\xc0\xfe\xe8\xff\x7f\x00f\xfe\xe8\xfff\x02\xfe\x00m\xff\x00\xff\xf0\xfd(\xfd>\xfa"\xfa\x02\xfc\x05\xfc\xf8\xff\xc0\x01"\x02\x83\x01\x19\xff;\xfd\x0f\xfbQ\xfb\x1a\x02\xda\x06\xdc\x08\xa4\x08\xdb\x05*\x02\x86\xfc\xe1\xfaX\xff\xfd\x04?\x07\x9a\t\x0c\x06\x1e\x01\xb9\xff|\xfeL\xff;\x00\xf8\x04T\x08\x19\t\xf4\x08&\x06:\x03\xa8\x00\x17\x00^\x04\xd7\x08\xf0\n\xa9\tI\x08\xfa\x04R\x00\xee\xfe9\x00\x9d\x04\x8a\x04\xa6\x04c\x05`\x03\x0b\x02\xc0\xff}\xfd\xf3\xfcy\xfe\xe5\xfe\xa1\xfeJ\xff`\xff\xf9\xfc\xf2\xf9\xa8\xf8O\xf6l\xf4\xda\xf6\xbc\xf9\xd5\xfaP\xfb\x80\xf9*\xf7\x14\xf6E\xf7\x0b\xf8\x95\xf7\xc3\xf8\xf2\xf9\xb1\xfa\x14\xf9\x89\xf8Y\xf9\xc3\xf8\x9d\xf9\x8e\xf9\x98\xf8\xb4\xf7\xf5\xf8\x11\xf9i\xf9\xa6\xfb\xa3\xfaZ\xf9\x1d\xf8\x81\xf6\x04\xf5\xe4\xf2\xe3\xf2\xf6\xf32\xf5\xd7\xf7\x1a\xf9U\xf8]\xf9\xf4\xf9\x94\xf78\xf8\xdc\xfc\x8b\x03,\x0b\x15\x0b\x8f\x08K\x07\xf7\x02\x11\x04\xb9\x06\xb4\x08J\r*\x0c\x92\t\xee\x07j\x08\x82\t\xac\x07\xc0\x07N\x05\'\x04\xbd\x03\xa5\x03\x8f\x07\xf9\x04J\x04\t\x07\xb5\x01\x81\xfb1\x00\x92\x15\xd7)\xd5,k$Q\x1bc\x18\x8a\x17\x81\x1a5&01\xa12k)\xe3\x1fx\x18\x91\x11=\x07{\xfe\x96\xfb\x14\xfd\x1b\x00\x06\x01`\x00y\xf4M\xed\xe3\xe3\x12\xd8\xbc\xdaI\xe6\xe2\xf0-\xf2r\xf1\x99\xee\x18\xedc\xe9\x0c\xe8\xd8\xef\x84\xf5A\xfaL\xfd_\xfdM\x04=\x07\xb3\x01Z\xff\xb0\xf9\x01\xf6\x1d\xfaL\xff)\xfe\xdf\xfcN\xfav\xf2\x1b\xe9\x95\xe4\xd9\xe8\xb9\xeb\xbc\xec\xba\xed\xcb\xea\xf0\xe7\xde\xe6k\xe7\xd9\xe8;\xec\xab\xf17\xf5\xf8\xf7\xe0\xf9\x1a\xfb!\xfa\xe4\xf8\x9f\xf9\xec\xfb\xcf\xfeN\x00\xd1\x00G\x01\x11\xff8\xf9\t\xf5\xa5\xf3\xc1\xf3\xaf\xf7\xa2\xfb\xb5\xfc\xf9\xfc\x82\xfb\xcc\xf8\xec\xf5\xa3\xf5\xfa\xfa\xf5\x02U\x05\xcd\x03\x19\x07\xae\r\x06\x13\x1e\x11R\n\xda\x07\xa3\x069\x0bs\x105\x15\xd7\x1a\x14\x16\x90\x0e\x1a\x07\xec\x01z\xfd\xd8\xfb\xeb\x0e\xb9,\x9e>\x1d=\xaa-\xa0"\xaf \xfc\x1bv\x1d\xa1,\xac;@?\x0c8\xb0*h\x1b\x88\x0e\x03\xfd\xf0\xf1\x16\xf3\x9f\xf8\x0b\x03{\x04\x99\xfd\x89\xf2\x10\xe2\x16\xd3\xd2\xcc\xda\xd0\xe4\xdf\xaa\xef/\xf4\xcc\xf5\xe2\xf6\xb3\xf1\\\xec\x88\xe9\xb7\xeb\xf3\xf3\xa1\xfb\x00\x03\xb9\nF\x13\x14\x11\xad\x04\xcf\xf9\x86\xf0\xec\xee\xa1\xf4-\xfb\xc0\xffI\x01\xee\xfd>\xf4\xca\xe9f\xe4\xb5\xe4\xc9\xe6\x13\xe8\xc5\xee~\xf4R\xf7\xbf\xf7\xe3\xf2\xea\xec^\xe8\x95\xe8\x9d\xec\x11\xf5s\xfea\x03j\x03\xb0\x01j\xfd\xde\xf8\t\xf7\xdc\xf8C\xfd\xd9\x02\xca\x06\x17\x04\x17\xfe\xe0\xf7\x8e\xef\xf5\xe9\xcb\xe9P\xec\xf8\xf0k\xf3\xc9\xf6\x80\xf7 \xf4Q\xf2\xa5\xf0n\xf4\x8c\xfb\xa4\x01 \x07~\x08L\x0c\xf3\x0e\xf6\x08@\x03\xec\x04\x81\x07\xd3\x0c\xe2\x10\x91\x10l\x11\xdb\x0b\xa5\x05\x02\x04p\xfe<\xfb\xa0\x0c\xf5.\xefK\x04T\x8dA-*\x94"\'!S\'\x9c9\xebH\xcfJ@;\x90%\xb4\x16\x95\x06\xda\xf8\xea\xed\xfb\xe5*\xe9p\xf0\xdb\xf4S\xf11\xecO\xe0Y\xd0L\xc8Y\xca\xd2\xd8\xb4\xedE\xfb\xda\x01\xce\x05\xc5\x01:\xfb\xb2\xf6\xe5\xf6y\xfa\xab\x00.\x07I\x0e\xb8\x11;\x10T\x0cq\xfeB\xf4\xca\xef\xc2\xe6\x9c\xe3\xdd\xe7K\xee\x95\xf4]\xf5\x8b\xf0\xb4\xe6\x80\xdeW\xdd\x0f\xe2i\xe9\xcd\xf2Z\xfd\xec\x01"\x02\x14\x02\xc3\x01g\x00\x14\xfdn\xfa\xad\xfc\xee\x014\x08o\x0b\t\t!\x05\xd6\xfe\x04\xf8\xb4\xf3\xf2\xf3,\xf9B\xfcW\xfc\x86\xf9\xa8\xf5\xef\xf4*\xf2b\xee<\xeb\xdf\xe9\x1b\xeb)\xed\xb7\xf3\xea\xfa\xfe\xfe\x83\x01\xfb\xfdp\xf8\x88\xf7\xed\xf8\x95\xfc\x94\x028\x057\x06\xe0\x05\xeb\x03\x90\x01\xf7\x00\x98\xfc\xfa\xf5\xa7\xf5\xcd\xf5d\xf2\xdc\xf1\xce\xfd\xef \xb8O\xe6f\x83^\xa6I\xd96F.\x913\x9b?!Pv[\xceR\xb1>\x0c*\x19\x12\x17\xfc\x94\xe5\x19\xcf\xfe\xc6\xbb\xcb\x13\xd4~\xdc\xbd\xe1\xd7\xdfZ\xd8\x9d\xcb\xf5\xc2A\xc8%\xd9\xc1\xee\x0c\x04\x13\x14\x95\x1e\xb4%\xd7\'\x02"\xba\x17\xcb\x0ca\x05\xfc\x04]\x07b\x0e`\x14\x0e\x10&\x07O\xf5\xde\xdeP\xcd\x18\xc0\xea\xbe\xf8\xc6\xd9\xd1C\xdej\xe6*\xe8\xe9\xe5\xa8\xe5\xff\xe6\x1b\xe8\\\xee\x8a\xfa\xef\x08s\x18\x05(\xe9.\x00*2!J\x16e\n\x9e\x04\xb4\x01"\x00^\x02\x01\x03\xf3\xfd\xa7\xf6&\xf1"\xeb0\xe6\xa7\xe2 \xe2I\xe7\xb9\xee\xf0\xf31\xf7\xec\xf6\x1e\xf5\xf8\xf4\xc0\xf14\xf3\x98\xf9\xf3\xfc\xf2\x02\x8e\x06\xc6\x05\xfa\x06U\x04\xed\x00\xc3\xfe\xe5\xf93\xfc9\xfe\x9e\xfe \x02\xda\xfe\xb2\xf9\x90\xf5\x10\xef\x86\xedA\xef\x88\xee\x1a\xf0\xf9\xf2\x84\xed\x07\xe9=\xf6\xa2\x15\xb1@e[\xa1W\x08K>B\xffB\xbaH\x0fL\xd8P\xa0Q\xf3Jk>\xea-\x0b\x1f`\r\x1d\xf3,\xd7\xa2\xc4\x95\xc0\xdc\xc7p\xd0\xfb\xd5D\xd9\x89\xd9\xb2\xd7\x97\xd6)\xd9\x12\xe2\xe1\xed\xfa\xfa\xd5\x08p\x19\x97)\x913\x8f3\x9a(\xcf\x1a/\rk\x03;\xfeH\xfc\xcf\x00f\x00\xc1\xfa!\xf2z\xe1a\xd1\x95\xc4\xa3\xbc;\xbe\xc7\xc7Q\xd5\xeb\xe2\xba\xee\xd4\xf5c\xf9l\xfa(\xfbp\xfe\xc3\x03\xf9\n\x0e\x15\xb2 \x06)M*\xfb#p\x18\xbe\nk\xfe\x1e\xf7\n\xf3\xba\xf0\x95\xf1\x1d\xf1r\xee\xc9\xee\xec\xedX\xec\xa4\xebF\xe9M\xe9\x85\xed\x0e\xf5\x9a\xfbu\x01W\x05I\x04\xfb\x02\xf7\xff@\xfc\xa1\xfb\x1d\xfbM\xfc\xe4\xfe\x0e\x01\xf1\x02\xf0\x01\xc4\xff\xc4\xf9X\xf4\xdc\xf0p\xedl\xf06\xf4\x85\xf5\xc9\xf8\xf5\xf5e\xf0\xa9\xefX\xebZ\xeb*\xf2\x89\xf0<\xec\xed\xf3l\x0cC6\xf5]\xf5f{ZKL\xa2?i<\x8e@\xccDrL!NDBu/\xb7\x1b\x0c\n?\xf7\x9e\xdc\xb3\xc3\xc7\xb7\x00\xba/\xc5\xd6\xd2\xba\xdd\xa7\xe3\xe1\xe2:\xdeh\xdb\x18\xe0W\xec\xaf\xfb\xc8\t\xf6\x16\xb4%\xe8116T/?!\xb9\x12\xe2\x06\xa3\xfdk\xfc\x10\x02\x9d\x04.\x02/\xf5\xde\xe3n\xd5D\xcaw\xc6\x00\xc6?\xc9\xb6\xd2 \xdd\x89\xea\xc6\xf6L\xfe\n\x02d\x00c\xfeY\xff.\x04\xc8\r\xb3\x18\x9f \xa1!Z\x1dg\x15/\nO\x00\x7f\xf8o\xf0\xef\xea\xf9\xe9,\xeb8\xee\xb4\xf2\xb1\xf4\x81\xf3k\xf1\x1b\xee\xa2\xece\xf0F\xf7a\xfe\x8d\x05\xbf\x08)\t\xdf\x07W\x05\x99\x03\x87\x00\xd7\xfdL\xfb\xb0\xfa\xc5\xfc@\xff\xd3\x01\xac\x01/\xfd\xf1\xf7\xd6\xf1\xb9\xebA\xe9Q\xe9.\xea\xc6\xec\x9a\xefH\xf1\x1d\xf2\x94\xf1\xce\xee[\xec\xa9\xeb\x93\xea\x08\xea\xd1\xec\x8f\xf8\xe4\x18\xf1C(d(n\xeccWS$I\xc8D\xdeB\x92E\xecJhM\xd1G\xe76\x11"\x96\x0e\xbd\xf5\x01\xd9\xfa\xbd\xc9\xacz\xadg\xba\xee\xca\r\xd7\x86\xdd\xda\xdd\xc6\xdbH\xd9c\xdar\xe4\xa1\xf4D\x06\x01\x17H%\xb61\xd09\xe980/?\x1f\xe8\x0f\x18\x04\xc3\xfc\xa2\xf9\xec\xf9\x17\xfd\x9a\xfb\xa5\xf4)\xe8\xe0\xd89\xcc$\xc2\xf0\xbe\x9a\xc2s\xcb\x08\xdb:\xeb#\xf8\xb2\xff\xa1\x02\xb1\x02&\x01.\x01i\x03\x0c\tM\x13\x8b\x1e\xcd$]#\x87\x1c\xc8\x12q\x07\xd0\xfd\x8e\xf5/\xefq\xedx\xef\n\xf1\xbd\xf1\xf7\xf2o\xf2\xc0\xf0\x1d\xef\x12\xec\xc0\xeb\x9f\xf0\xb8\xf6\xcd\xfeY\x05\xb7\x07\xc1\t#\x08\x12\x05G\x03\\\x01\xc1\x00d\x01P\x02\xfd\x02o\x03\xf2\x02\xbb\xff\xb3\xfa\xa6\xf6\x84\xf1\xbe\xed\x82\xec7\xebZ\xec\t\xee\xe9\xedQ\xedp\xecR\xe9\xb9\xe8\xe7\xecz\xefR\xf1p\xf0Q\xec4\xf3\x1e\x0c\xfe0\xf1V\x95iif\x9dY\xf6NxK\x9bL\x9cK\xe2H\xe8G\\Bs6l&\x0e\x12\xcf\xfd\xb3\xe9\x8b\xd2$\xbd\xa6\xb1\xcf\xb2Z\xbe\x83\xcc\x01\xd5\xdd\xd6\x92\xd6/\xd5\xe1\xd6T\xde+\xea\x1d\xf9\xec\x08\xd2\x15\x85 g*\xe40\xed2\xcf-\x9d"\xc7\x15\xcb\n\x0e\x04\x97\x01\xf1\x02\xa8\x02E\xfe\xed\xf4\xb3\xe5\x9e\xd6I\xccM\xc7\xdd\xc79\xcb\x82\xd0\x00\xd8\x98\xe1O\xebH\xf4\xb8\xfa\xf6\xfc.\xfd\x1a\xfd\x1f\xfes\x03\xf3\x0cx\x17\x1d\x1f\xce #\x1d|\x16F\x0f}\x083\x02\xba\xfc\x15\xf8p\xf4\xce\xf2\xf9\xf3\r\xf7c\xf9\'\xf9\x01\xf5(\xef\xab\xea\xf4\xe9\x1c\xee\xeb\xf4\xf7\xfa\xe2\xff\xe0\x01b\x02.\x02f\x01\x90\x01i\x01\x0c\x01p\x01*\x02\xce\x04R\x07L\x08[\x07\x92\x01\xee\xfa\x19\xf5?\xf0=\xee\x9a\xec\x8e\xeb\xb2\xec\n\xed\x0b\xecS\xeb\x9e\xea\xc5\xea\xea\xe9\xb4\xe6\xbe\xe6I\xf2\x94\x0c\xa80zP\xd1]\xf2Y\xdfN+E.B{B\x7fC\xb2G/K\x85IH>\x92)*\x13M\x00T\xf0z\xdf\r\xcd[\xbeS\xb9\xcc\xbe\xfd\xc9A\xd4\x13\xd8\xa6\xd6\x8c\xd4s\xd4`\xd8R\xe0i\xebI\xfaK\n\xc3\x178"x(\xd8+=-\x91*\xde"\x12\x18\xea\rq\x08\x90\x084\tT\x06+\xffB\xf4\x92\xe81\xddd\xd2{\xcab\xc7\xd4\xc8\x02\xcez\xd4S\xda\xf7\xe0\x17\xe9`\xf0\x89\xf5S\xf7\xd8\xf7N\xfb[\x02\xa9\x0bD\x15I\x1d\xa7"\x81$\xaa!\xfd\x1a\xca\x12\xa3\x0b\xf0\x06\xa5\x03\x85\x00,\xfd\x04\xfa\xf9\xf7\xef\xf5\x16\xf3\x03\xf0\xa4\xec\xa1\xea\x06\xea\xcd\xe9\x7f\xeb)\xefS\xf3Q\xf9\x92\xfe\xba\x01q\x04Z\x05z\x05p\x07\xc3\x07\x01\t"\x0b\xa3\x0b`\r\x9f\x0c\x1d\t\x9e\x03l\xfc\x1b\xf6\xa3\xf1\xb5\xefB\xee\xe7\xecO\xec\xab\xe9q\xe7X\xe6\xf4\xe3\xd3\xe3~\xe4<\xe5\xed\xecO\xfe\xf0\x16\x9c1^E\xaaM\xe5M\xdbH\xa9A`=4=9A\xc2G\x1fK\x10EU7o%\xa9\x12\xa6\x02\x82\xf1\xd4\xe0\x82\xd4\x97\xcd\xa9\xcc\x87\xce\xf9\xcf$\xd1\xae\xd2\xb4\xd3j\xd4\xb4\xd4\xdc\xd5%\xdbc\xe5\xbc\xf2\xb6\x00\xa2\x0c\x0f\x17\xf3\x1f\xac%!\'\t$\x0b\x1f\xc5\x1a\x9d\x17/\x15\xb6\x12\x95\x0f\xbb\x0b\xf3\x05T\xfd\xe1\xf2k\xe8\xb4\xdf}\xd9\xad\xd4\xeb\xd0\xae\xce3\xcf\xa7\xd2\xf7\xd77\xde,\xe4\x99\xe9d\xee\xe6\xf1\x17\xf5\xa3\xf9b\xffE\x07\xee\x0f\x9b\x16\xf1\x1bL\x1f\xf6\x1f)\x1f\xbd\x1b4\x16\xad\x10\r\x0b\x86\x06\xfd\x03\xaa\x01\t\xff\x10\xfc\x99\xf70\xf24\xedY\xe9\xc3\xe7:\xe8*\xea\xef\xecz\xf0=\xf4q\xf7\x8a\xfa\xde\xfd{\x01\x82\x05b\t|\x0b\\\x0c\x0b\x0c\x96\n"\t\xdd\x06Q\x04x\x01\xe4\xfd\x17\xfa\xe2\xf5a\xf2T\xef\x1f\xed\xf8\xeb\xb5\xea\xe8\xeaR\xeb\x90\xeb\'\xec\xed\xeb\x1c\xee\x0b\xf5\x93\x02\xe3\x16\xed+2<[D\xdbD\xe5A\x16=\x9f8\xb16e7\x9d;\xa9?\xb0>\xff6K)\x87\x19\xed\nO\xfd\x00\xefg\xe1\xe3\xd6\xe7\xd0J\xd0X\xd2\xcb\xd4X\xd7\xa8\xd8M\xd8w\xd6w\xd3F\xd2\xdd\xd5]\xdf\xc7\xed\x87\xfdZ\x0bh\x15=\x1b\xb1\x1d\xf3\x1c\xdf\x1a\xfc\x18\x96\x17c\x17\x10\x17P\x15G\x12\xc9\r\xa2\x08h\x034\xfc\xf7\xf2\xf8\xe8\xc0\xdf\x0b\xd9u\xd5\xd2\xd3\x80\xd4\x89\xd7\x12\xdcg\xe1\xd1\xe5L\xe8$\xe9\x0f\xea\xf3\xeb\xff\xef\xae\xf66\xff.\tE\x13,\x1b}\x1f\xb8\x1f\xb4\x1c\xfb\x18\xbe\x15_\x13\xd2\x11A\x10\xa7\x0e\x9a\x0c}\t\x8a\x05\xe4\x00m\xfb\x1b\xf6@\xf1\x10\xedB\xea&\xe9\r\xea\xdd\xec\xca\xf0\x83\xf44\xf7n\xf8\xf6\xf8o\xf9\x8e\xfa\xa9\xfc\xfa\xfe`\x010\x03\x81\x03}\x03\x92\x02g\x01\x8c\x00\x0c\xff\xd8\xfdZ\xfc\x82\xfa\x1b\xf9\xbf\xf7\xdd\xf6\xcc\xf6\xdd\xf6s\xf6@\xf5#\xf3\xd4\xf2p\xf7c\x02\xc9\x11\x99!Q-F3\xad4t2\x8c.a+\xd1*L.\xfe4;;\xcd\xf63\xf5\x01\xf4\x88\xf2[\xf1\x00\xf1T\xf1\xaf\xf2\xf6\xf44\xf7>\xf9\x91\xfa\xf0\xfa\x00\xfb\xc6\xfa_\xfa\x89\xfa\xc1\xfaZ\xfb\xab\xfc\xe7\xfdR\xff\xee\xff\xa3\xff\xa7\xfe\xd8\xfc\xf9\xfa\t\xf9/\xf8\xc3\xf9\x07\xffo\x08Z\x14\xba\x1f$(\xe3+R+\x9a(}%\xfa#\xe9$\x03(\xdd,11\xc92\x130M(d\x1d\xd0\x11\xca\x07\xac\x00\xde\xfb\x19\xf9d\xf7\xee\xf5\x9b\xf3\x11\xf0\x8b\xeb\xb5\xe6\xf7\xe2\x93\xe0<\xdf\x8e\xde\xfc\xdd\x1f\xde\xb3\xdf\xf2\xe2\xd7\xe7\x9c\xedx\xf3\xc2\xf8p\xfc&\xfe\xf4\xfd\xdc\xfc1\xfc\t\xfd\xff\xffw\x04H\t\xf5\x0cK\x0e\x8b\x0c\xda\x07.\x01r\xfa\x84\xf5<\xf3S\xf3\x89\xf4\xdd\xf5\xef\xf5\xb3\xf4@\xf2\xf4\xee\x0c\xec\x1b\xea\x8c\xe9\xcb\xea<\xed\x90\xf0/\xf4\xc8\xf7\xb3\xfb\xd0\xff\xe3\x03[\x07\n\n\xad\x0br\x0c\x9a\x0c~\x0c\xc1\x0c\xad\rq\x0f\xaf\x11~\x13\xb5\x13\xd6\x11\x19\x0e<\t\x04\x04O\xff\xd1\xfb9\xfa8\xfa\x18\xfb\xeb\xfb\xc3\xfbc\xfa\x05\xf8\xed\xf4\x18\xf2\xea\xef\x0e\xef\xea\xef\x0f\xf2\xde\xf45\xf7v\xf8\x9d\xf8\xc7\xf7\xb7\xf6\xbd\xf5u\xf5\x06\xf6H\xf7P\xf9p\xfbB\xfdX\xfeQ\xfep\xfd\x19\xfc\x94\xfa\xa0\xf9/\xfa\xa8\xfcl\x02\x16\x0b>\x15\xe8\x1ex%\x11(\x9b\'X%{#0#\x7f$\xf3\'\x0e,\xc5/c1\xff.\x01)\r Y\x16\xf7\r/\x076\x02\x89\xfei\xfb\xfe\xf8\n\xf6T\xf2\xd6\xed\xb5\xe8\x87\xe4O\xe1\x19\xdf\xbb\xdd\x80\xdci\xdc\xb2\xdd\xad\xe0,\xe5\x11\xea\xf5\xeee\xf3\xb6\xf6\xe9\xf8\xe3\xf9q\xfak\xfb\x8a\xfd\x1e\x01\x84\x05\xee\t@\r\xac\x0e\xbd\r\x87\n\xb5\x05\xa6\x00\x99\xfcd\xfa\xed\xf95\xfa\xab\xfa-\xfa\xbc\xf8g\xf6`\xf3\x90\xf0M\xee*\xeds\xed\xc9\xee5\xf1\xef\xf3\xe9\xf6:\xfa\x8f\xfd\xfc\x00\xae\x03\xc0\x05\xfa\x06{\x07\xca\x07\xdd\x07r\x08\xc1\t\xa5\x0b\x14\x0e%\x10\xd2\x10\xed\x0fW\r\xa9\t\x99\x05\xca\x01\xe1\xfe\x80\xfdd\xfd\x17\xfe\xf7\xfe\x19\xff\x17\xfe!\xfcC\xf9+\xf6\x94\xf3\xc8\xf13\xf1\xd7\xf1\xf7\xf2S\xf4I\xf5\x88\xf50\xf5~\xf4\x8e\xf3\x17\xf3\xc8\xf2\xe7\xf2\x99\xf3v\xf4,\xf6\x01\xf8\xa0\xf9\x0c\xfb\x90\xfb\xd5\xfb\xcd\xfb\x7f\xfb,\xfc\x93\xfe5\x04\x81\rM\x18\xaa"\xdc)\xe1,\xfe,\x0f+\xff(\x02(\xb1(\t,z0+4\xf34\xbd0\xa9(j\x1e?\x14\x01\x0c\x07\x05R\xff\xcb\xfa"\xf7\xd4\xf3\xef\xefm\xeb\xc2\xe6\xb8\xe2\x97\xdf\xe1\xdc\x88\xdar\xd8/\xd7\xae\xd7\xfd\xd9\x0f\xde\x84\xe3\xe2\xe9\x94\xf0F\xf6X\xfas\xfc^\xfdM\xfe\xfc\xff\xf7\x02\xfd\x06o\x0b\x93\x0fm\x12\x02\x13\xfd\x10\x8d\x0c$\x07^\x02\x08\xff\x06\xfdz\xfb\x13\xfa@\xf8$\xf6\xd0\xf3:\xf1\xd0\xee\xb8\xec,\xeb\xae\xea\x18\xebn\xecj\xee\r\xf1\x91\xf4\xe1\xf8~\xfdx\x01u\x045\x06\x0e\x07U\x07k\x07\xfb\x07U\t\x93\x0bO\x0e\xcc\x10\x1f\x12\xcf\x11\xe8\x0f\xcb\x0c\xd9\x08\xe3\x04{\x01W\xff_\xfe5\xfe`\xfe;\xfe\x8f\xfd=\xfc\x0c\xfaP\xf7b\xf4\xf8\xf1\xb4\xf0\x9d\xf0S\xf1t\xf2f\xf3\x00\xf4\xe9\xf3A\xf3"\xf2*\xf1\xd7\xf0]\xf1\xd5\xf2\x7f\xf4\x9d\xf5.\xf6U\xf6\xb8\xf6d\xf8\x99\xfa0\xfdu\xffK\x00}\x00n\x00j\x01W\x05\xc9\x0c\xa3\x17i$T/"6\x1a7K3\xc3.\xc3+\xc8,\x900\x9a4c7T6=1\xd7(\xa7\x1d\x89\x12\x93\x08d\x006\xfaE\xf4Z\xee\x0e\xe8G\xe2B\xde\xbb\xdb\\\xda-\xd9;\xd7 \xd5\xe2\xd2\xc8\xd1\x04\xd3M\xd7I\xdf[\xe9\\\xf3 \xfb\xd8\xff\xb1\x02\xdd\x04G\x07\xf8\t\xb7\x0c\xe9\x0fb\x13u\x16\xf1\x17\x1b\x17\x88\x14\x12\x11C\r\x07\t\xed\x03\x0c\xfe-\xf8\x05\xf3T\xef\x1a\xed\xd2\xeb;\xeb\xb6\xea\xfe\xe9\xd3\xe8,\xe7\xdf\xe5}\xe5\xf4\xe6\x85\xea\x85\xefg\xf5\x06\xfb\x0b\x00o\x04\xa2\x07\xe2\t\xe6\n\x82\x0b\xac\x0c5\x0ey\x10\x90\x12\xd9\x137\x14>\x13\x89\x11\x0f\x0f\xef\x0b\xd3\x08r\x05b\x02\x92\xff\x04\xfd]\xfb3\xfa\xba\xf9\x81\xf9\xb1\xf8{\xf7\xba\xf5\xc5\xf3H\xf2)\xf1.\xf1\x18\xf2\x97\xf3 \xf5\xd7\xf5\x82\xf5\x9f\xf4\xa3\xf3|\xf3M\xf44\xf5F\xf6\xcb\xf6\xb0\xf6\x9c\xf6i\xf6\x9f\xf6\xc2\xf7\x7f\xf9x\xfc.\xff\xc0\x00e\x01\xb0\x00\x1e\x00\x06\x00\x14\x01\x01\x06D\x0fo\x1cn*6429%9\xa26m4\xff1<1\xdc0\xd30\xb00F.u*\xdd#\xf0\x1a\xf4\x0f\xce\x02\xfd\xf5\x96\xeao\xe2\xce\xdd\x9a\xdbk\xdb[\xdbW\xda\xc5\xd7o\xd4\xd9\xd1b\xd1{\xd3\xcf\xd7\x81\xddI\xe4\xf3\xebI\xf4\xaa\xfd\x9f\x06a\x0e\xea\x13\x94\x16\xe4\x16\xb0\x15\xb8\x13\xe7\x12\xab\x13\xaf\x15\xe0\x17\x12\x18W\x15/\x0f\x9b\x06/\xfd?\xf4\xb6\xecC\xe7\xd4\xe3$\xe2z\xe1\x81\xe1\xc1\xe1\xbf\xe1.\xe1Q\xe0\xba\xdf*\xe0\x8e\xe2Z\xe7m\xee\t\xf7\xaf\xff\x1e\x07\xa5\x0c%\x10&\x12.\x13\xbe\x13+\x14\xa8\x14\xfe\x14?\x15\x1e\x15\xa6\x14w\x13K\x11\xcb\r\x02\tO\x03\xe4\xfd[\xf9\x90\xf6\xcc\xf5\n\xf6\xf3\xf6\x1a\xf7\x81\xf6\xb9\xf5\xc4\xf4<\xf4\xdc\xf3\xfe\xf3\xd5\xf4\xe2\xf5w\xf7\xc5\xf8\xe0\xf9\xcf\xfa\xb3\xfb\xb6\xfc7\xfds\xfc\x9c\xfa\x80\xf8O\xf6\xff\xf4\x03\xf4\xc5\xf3[\xf5\xf3\xf6g\xf8T\xf8\xb3\xf6\xa1\xf6\xd5\xf6\xba\xf8\xcc\xfa\xc0\xfbs\xfd\x96\xfd\x9b\xff\x9a\x05\xa2\x10\xce R0p;\xd4?\xe7=m9N4b1\xcd0\xe81c4\xab3\xf1/p(\x00\x1e\x14\x13\xf1\x05 \xf8\xca\xeb\xf3\xe1$\xdc\x8d\xd9\x1e\xd8\x94\xd8G\xd9\xf5\xd8\xc4\xd7f\xd5\xcd\xd3\xf5\xd4F\xd8;\xde1\xe6q\xef\x8a\xfaR\x05\xe4\x0e\\\x15\xa3\x18\xc5\x19\xa8\x19\x9a\x19\x86\x19\xa5\x19\x0f\x1a\x05\x1a\xe1\x18\xef\x15\x00\x11\xb8\n,\x03\x8e\xfa+\xf1:\xe8\x02\xe1\xb9\xdc\xf9\xda\x10\xdb\xb4\xdb\x1d\xdc\xdc\xdb\xde\xda\xd5\xd9\x04\xdaO\xdcO\xe1T\xe8t\xf0\xa2\xf8S\x00G\x08\xad\x0f\xba\x15\x04\x19\\\x19G\x184\x17\x0b\x17S\x18\xa7\x19r\x1aM\x19\x0f\x16\xb3\x10h\n\xbc\x04\xfb\xff\xd4\xfc\xa0\xf9\xe3\xf6\xfd\xf4p\xf4S\xf5\x92\xf6$\xf7\x9e\xf6\xa8\xf5\xd6\xf4\x02\xf5\xe8\xf5\x90\xf7\xe1\xf9\x8a\xfc\x1d\xff\xfd\x00e\x01\xd2\x00\xa6\xff%\xfe\x93\xfc\x1c\xfa\xe0\xf76\xf6\x1e\xf6\xe6\xf6}\xf7\xde\xf6&\xf5?\xf3[\xf1\xce\xf0R\xf0\xb5\xf18\xf4\xfd\xf6\xa9\xfa\xfa\xfc\xef\xff[\x02\x7f\x039\x04\xdf\x02x\x04n\x0c\xc5\x1a\x85-%:\xdf>\xe8;\x0c6&3\xf91;2\xbe1\x9f03.k)c!\xd3\x17\xdf\x0e<\x06e\xfcQ\xef\xf8\xe1\xfd\xd8$\xd7+\xdaf\xdd\xbc\xdd\xbd\xdb\xbb\xd9\x02\xd9\xac\xd9\xe1\xdbc\xe0c\xe7\x86\xf0\x7f\xf9\x85\x01/\tk\x10\xff\x16\x99\x1a\xf9\x19\xc0\x16\x9e\x13F\x13\x94\x15\xbc\x17\x93\x17N\x14\xa5\x0eY\x07]\xfe\x82\xf4\x13\xeb\xd6\xe33\xdf\xc9\xdbo\xd91\xd8\xfb\xd8\x05\xdb;\xdc\x1b\xdbw\xd8\x02\xd7[\xd9\x02\xe0L\xe9:\xf3\x90\xfc\xe0\x04\x9d\x0b^\x10;\x13,\x15|\x16\x14\x18\xfc\x18(\x1a\x8c\x1b0\x1d0\x1e:\x1c8\x17\x05\x10\xbb\x08\xe2\x02W\xfex\xfb\xf2\xf9\x94\xf9\xb0\xf9\x85\xf8x\xf6\xce\xf3\x88\xf1\'\xf0Y\xef4\xf0\xc7\xf2&\xf7\xce\xfbK\xff\x9c\x00\xd0\x00\x1a\x005\xff\xc0\xfeC\xfe\x86\xff\x8b\x00\x00\x01D\x00\xd5\xfd\\\xfb \xf8\xeb\xf4\xe2\xf1\x9e\xee\x88\xedZ\xed\xc5\xee\xc0\xf0\x83\xf0,\xf1\xaf\xf18\xf3\x99\xf5\xfe\xf6\x84\xf9I\xfc\x8b\x00\xbf\x04\xed\x07!\x0b\x93\r?\x14\xe3\x1fo-5:F>3:\xa93c.\xab.U1\xd01\xba0>-\xe8\'\xc9!]\x18\x82\r\xdc\x01\xe9\xf5\xc7\xebK\xe3<\xdf\x89\xdfb\xe2\t\xe5-\xe4a\xe0&\xdc\xa9\xd9>\xdb\x06\xe0\xc2\xe6\x8d\xee\xe3\xf6\x84\xffG\x07\xa5\r\n\x11<\x12g\x11\xed\x0f_\x0f\xeb\x0f\xc1\x115\x14G\x15\x16\x134\r\x89\x04N\xfb\xe7\xf2\xfa\xeaQ\xe4\xb4\xdf\xb8\xdd\xbb\xde\x9a\xdf\x83\xdf\xf4\xdd\xe2\xdb\xaa\xda1\xda\xf1\xda=\xde5\xe4S\xed\x06\xf7\\\xffW\x06A\x0b\xe5\x0f}\x12z\x13P\x14\xa0\x15\x81\x18\x89\x1b\x9a\x1d\x80\x1e\x82\x1c\x01\x19E\x13\xa0\x0cT\x06\xdf\x00\xc6\xfd\xba\xfb\xc8\xfa?\xfa\x92\xf9\x07\xf8\xd4\xf5N\xf3\t\xf1\xd4\xf0\x10\xf2\xd3\xf4w\xf8\xe7\xfb+\xff9\x01C\x02\xa1\x02(\x02\x99\x01\x9f\x00\x94\xff\xee\xfe\xd5\xfe\x87\xfeo\xfd$\xfb\xa7\xf7U\xf4\xa2\xf0\xfa\xed[\xec\x0b\xeb\xb3\xeb\x13\xec\xa4\xedb\xef+\xf0\x83\xf2\xcf\xf3\xbe\xf6:\xfa\xca\xfd\xa0\x02l\x06\x11\t\xfc\x08\x12\x08\x80\n\x1c\x12\xff\x1f\xa7-\xb25\xbf6\xb12\xd6.\x08,\xeb*8+\xb5+\xcc+V*\xb2&\x06!\x1a\x19\xc6\x0e\x82\x02\x8a\xf62\xed\xce\xe8\xf6\xe8\xb1\xea\xbd\xec\x05\xecm\xe8\x8d\xe3E\xdf\xfb\xdda\xdf\xc7\xe2\n\xe8,\xef\xd7\xf7h\x00d\x06\xa1\x08f\x07\xd5\x05\xc4\x044\x05\x96\x07,\x0b\xe1\x0fx\x12\xe8\x10/\x0c\x19\x05\xd6\xfd\xce\xf6#\xf0\xa5\xeb\xc3\xe9Q\xea\x08\xeb\x0c\xeb\x90\xe9L\xe7%\xe4\xeb\xe0N\xde\x8d\xde\xb5\xe2\xff\xe8\xab\xef\xbb\xf5<\xfb\x97\x00\xf5\x03\xff\x04&\x05\xd6\x05\xc9\x08\xe5\x0b)\x10\xcf\x13\xa4\x17N\x19\n\x17\x98\x12\xe8\x0c\x1c\t\x17\x06"\x03i\x01,\x00\xec\x00\x19\x01\x0e\xff\x1e\xfc"\xf8L\xf5\x9c\xf3\xd7\xf3\xba\xf5\x84\xf9M\xfd\x94\xff\x10\x01A\x01L\x01\xa2\x00\x9b\xffz\xfe0\xfe{\xff\xec\x00\x10\x02\x17\x01f\xfdB\xf9A\xf5\xd9\xf2\xde\xf1\xed\xf0\xb8\xef\x08\xeeq\xed:\xee\x0f\xf0\x1c\xf2\xc3\xf1z\xf1X\xf2h\xf4\x1f\xf9+\xfd\xf7\xff\x98\x02\x0f\x04\xdc\x05\x9d\x08\xf0\x07\x8d\x06\xff\x07\xcb\x0fp\x1f=->2P.\x85\'\x0e&\xb2(0+\xc4*\xd4(^)\x1b)\x89&\xad!\xe9\x19\x1b\x10S\x03\xa9\xf6\xf9\xef\x13\xf1.\xf5\xad\xf6\x15\xf3\x80\xed\x19\xeaB\xe7\x03\xe4\xbb\xe0b\xdfs\xe2\xc0\xe8u\xf0\xdd\xf7\x93\xfd\x98\x00\xfd\xfe\x0c\xfb\x17\xf8v\xfa\x8c\x00\xf7\x05\xcf\x08\xb1\t\xb9\x0b\x9a\x0c!\t\x13\x03\x91\xfc\xfb\xf7\xa5\xf5\xba\xf3I\xf3\xcf\xf4\xe7\xf6>\xf6\xe7\xf1O\xecH\xe9\x98\xe8O\xe8\xf7\xe7\x9e\xe9<\xee\xbb\xf4\xc6\xf9\x99\xfb\xe3\xfc.\xfd\xc0\xfd,\xfe~\xff\xd1\x03\xc9\x08\x84\x0c\x1b\x0e\x8c\x0e\xeb\x0fB\x0f~\x0c\xdf\x07\xe9\x04\xbe\x04\xe5\x05\x7f\x06\x19\x05\xf0\x03\x16\x02\xe5\xff\x0c\xfd[\xfb\xe3\xfb\xb3\xfc;\xfd\x7f\xfd\xa2\xfe\xd2\x00g\x025\x02\x0c\x00\x9e\xfef\xfe$\xff\xe3\xff\'\xff\x7f\xfe\xb6\xfcI\xfb\x82\xf9\xfa\xf6\x9e\xf5\x02\xf4n\xf3\x93\xf1\xb6\xef\xa1\xef\x1e\xf0\xa5\xf1\xce\xf0\xfd\xef\xc1\xf0G\xf2\xae\xf5\xc6\xf7\xe3\xf9-\xfc\x15\xfd\x97\xfe\x17\x00\x99\x02\xb5\x05\x1a\x06\x88\x04\xe2\x02\xde\x07u\x14k"\x80*\xf4(\x0b$o"\x86%\xe3**,*+\xdb*\xf8*\xd4+%)\xda"\x94\x19Q\rR\x02\xb1\xfcX\xfc\xae\xfd\xda\xfb{\xf5\xb1\xee\x00\xea\x1a\xe7-\xe5\x95\xe1 \xde\xed\xdd*\xe1\xb9\xe7\x8b\xef\xea\xf4\x01\xf6\x06\xf3a\xf0\x81\xf2\xff\xf8\x0c\x002\x05\xf5\x07\xf7\x08\x9e\n \x0c\x96\x0c\xa3\n8\x04g\xfe\xca\xfb.\xfdR\x01\xeb\x01\xd8\xfe5\xf9A\xf3-\xef^\xec\x00\xea\xc4\xe8U\xe8\xad\xe8\x86\xeb\xba\xefE\xf3\x07\xf5\xfd\xf2/\xf0\xe1\xefE\xf3f\xfa\xc1\xff\x1f\x04\xa3\x06n\t\x83\x0b\n\x0cR\x0b;\t\x8f\x07\x0e\x07\xb9\x08\xc1\x0b\xf3\r\xb2\rj\n\xfc\x04\x8f\x01\xe1\xfft\xffq\xfe\xc1\xfdQ\xfe\xdb\xff}\x01\xc1\x00\xb7\xfe;\xfcn\xfa\xee\xf9F\xfaS\xfb\x06\xfdx\xfd\xd2\xfck\xfb\xe1\xf9\xc8\xf8\x14\xf7Z\xf5[\xf3\xe2\xf2\xd4\xf3\xac\xf4\x8a\xf4i\xf3T\xf2\xcd\xf2\xde\xf2\x7f\xf3\xdf\xf4\x03\xf6\x1a\xf9\xf8\xf9\xe1\xfa\x18\xfd\x99\xfe\x9c\x01\x00\x02\x18\x02\xe2\x03\x94\x03\xbb\x02\x10\x03\x86\t[\x19\x96%\xe9\'\xc6#j!\x00&u*\xcd)\x9f(\x8e+80a2W/%)\x12!Q\x16\xeb\t3\x01\xbf\xfe\xec\xffH\xfe\xc9\xf7?\xf1\x13\xed\x8b\xe9\x01\xe3g\xdbi\xd7\xb2\xd8\xfd\xdd#\xe4\xdf\xe9B\xef\xa6\xf1\xe3\xf0\'\xeea\xeeC\xf4\x80\xfbE\x02\x1b\x06/\nK\x0f\xda\x11\x02\x11\x1b\r\xa1\x08\x0c\x06H\x04N\x04[\x05\x86\x06\xe2\x05\xc3\x00=\xfa\n\xf4|\xf0\x84\xedH\xea\xac\xe7O\xe7I\xe9-\xec(\xeec\xee\x17\xee\x1b\xed6\xedh\xee\xad\xf2$\xf9Q\xffA\x03[\x06\x14\t\x92\x0b\xf9\x0bM\n\x90\t\x88\n\xd2\x0c[\x0f\x02\x11\xe2\x10.\x0fj\x0bz\x07\x00\x05\xa0\x03\xd9\x02&\x02\xcf\x00\xbb\x00\xe5\x00k\x00\xe2\xfe\x16\xfb2\xf8I\xf7(\xf8T\xfa\x90\xfb\xed\xfb%\xfb"\xf9 \xf7\xbc\xf5\xec\xf4\r\xf5\x80\xf5\xd1\xf6\x9b\xf7m\xf7\x90\xf69\xf5\x0f\xf4\x1f\xf3N\xf3\x82\xf5\xd7\xf7s\xf8\x95\xf8\xa9\xf8\x1c\xfbM\xfd\xcf\xfd\x8d\xfd\xa4\xfc\x02\xff\xcb\x01\x05\x04i\x05\xca\x04\xf7\x05\xad\ni\x13\x12\x1f\xa9%v$\x9d\x1f\x93\x1e\xb9${+\xed,\x1b+O+M-\xcc,E&\x0b\x1d\xb1\x14;\rR\x064\x01D\x00\x0c\x00\x9b\xfb4\xf3\x9b\xeaD\xe5\x82\xe2o\xdf\x00\xdd^\xdc+\xdfU\xe4l\xe9\xd5\xec\xe4\xed\xb7\xed\x0c\xed\xc3\xee{\xf3\x0e\xfb\xbe\x02\x1d\x08\xf6\tk\n\x88\x0b\x11\x0cu\x0b\xd7\x08\xd2\x06\xad\x07#\tI\t)\x07$\x03\x98\xfe\xca\xf8R\xf3;\xefE\xed\x0b\xed:\xec\x00\xebW\xea\xa3\xea}\xeb\xd7\xeaS\xe9\xa3\xe9E\xec\xb7\xf0+\xf5p\xf9\xb3\xfd\xc8\x00\xe6\x01\xe5\x02G\x057\x08\xa8\t\x17\nm\n\xb0\x0c\xf0\x0et\x0f\xa1\x0e\xac\x0b\xbd\tK\x083\x07\xd0\x06\xf4\x05{\x05V\x04~\x02\x1d\x01\xa1\xff\xc5\xfed\xfda\xfb@\xfa*\xfa\x06\xfb\xfe\xfa\x9f\xf9C\xf8\xac\xf6I\xf6\x9b\xf69\xf6K\xf6s\xf5\xeb\xf4\x92\xf5\xd4\xf4:\xf4\xc4\xf39\xf3\xe3\xf4\xeb\xf5/\xf7n\xf8\xf1\xf7\x9b\xf8~\xf9\xd8\xfaB\xfd\xea\xfe>\x01\x11\x03\t\x02\x15\x00r\xff\xed\x02~\t\x95\x10m\x15%\x19\x07\x1ds\x1fg \x11\x1f_\x1e1!\xb8&\x83++,\xd4(&$\xe9\x1f;\x1b\xa5\x14\xd7\r\xc6\x08\xe4\x066\x06\xac\x03\xa7\xff\xfc\xf9i\xf3\xb0\xec$\xe7\xd5\xe4\t\xe55\xe6E\xe7^\xe8M\xea\xe2\xeb_\xec\xdb\xeb\xbf\xeb\xb3\xedJ\xf1\x9d\xf6\xf8\xfc\xa6\x02\xc3\x05Y\x05\xac\x03\xad\x030\x05?\x06\xd2\x05#\x05\xc5\x05(\x07)\x07\xe5\x04\xbd\x00\x82\xfcp\xf9\xa5\xf7\xb6\xf6w\xf5P\xf4\xe1\xf3\xe1\xf3\x8c\xf3G\xf2|\xf0c\xef\xfd\xee\xd2\xefQ\xf2\xca\xf5\xe5\xf9\x17\xfc\xa2\xfc0\xfcG\xfc\xc3\xfd\xcb\xff\xb3\x01\x88\x03\r\x05\xcf\x06\x0b\x08 \x08X\x077\x06\x95\x05\xa9\x05\xc0\x06\xfc\x07\xf5\x08\xb4\x08\x8c\x07;\x06\x95\x05\x10\x05G\x04\x93\x03\x9a\x03x\x04\xff\x04\x82\x04\x84\x02[\x00\xe2\xfd\xac\xfby\xfaa\xf9\xd3\xf8M\xf8p\xf7#\xf7?\xf6\x87\xf4\xc4\xf2x\xf1\xdd\xf1v\xf3\r\xf5a\xf6[\xf7\x19\xf8\x1f\xf9\xd4\xfaf\xfcu\xfdV\xfes\xff\xd9\x01\xa5\x047\x06\xf2\x06\x1e\x06\xb6\x04\xe0\x03\x8f\x04\xf3\x07\xdf\x0b\xdf\rK\r(\x0cn\r\x84\x0f\xbb\x10\x87\x10\xc7\x0f\x00\x110\x13\x1a\x15\x00\x161\x15\xd2\x13\x0f\x12\x0f\x10\xc8\x0e\xa3\rn\x0c\xb7\n\xb8\x08\xf8\x07 \x07\x11\x05*\x01\xe0\xfc\x96\xfa\xbf\xf9>\xf9\t\xf9\xc7\xf8\xa1\xf8r\xf7\xcd\xf54\xf5\xfc\xf4\x81\xf4.\xf3\x80\xf2\x84\xf3I\xf5\x80\xf6\xa8\xf6\x92\xf6w\xf6\x0b\xf6\x92\xf5\r\xf6\x9b\xf7\x1e\xf9\xb0\xf9\xb3\xf9?\xfa\x1e\xfb\x8e\xfb\x7f\xfb\xac\xfb\x1a\xfcx\xfc\x01\xfd\xcb\xfd\x8d\xfe[\xfe[\xfd\xd7\xfc\x01\xfd0\xfd\xe8\xfca\xfc\x9f\xfc\xca\xfcf\xfc\x06\xfcc\xfcp\xfd#\xfe1\xfeC\xfe\x93\xfe\x05\xff?\xff\xd1\xff\xe3\x00\xb4\x01@\x02b\x02\xf4\x02\x12\x04\xc1\x04\xf9\x04:\x05\xb0\x05\x18\x06J\x06J\x06n\x06a\x06\xde\x05(\x05\x98\x04\xf7\x03\xed\x02\x97\x01N\x00!\xff\xd9\xfdu\xfc\x1f\xfb\xf0\xf9\xce\xf8\x92\xf7M\xf6e\xf5\x9e\xf4\xe8\xf3q\xf3R\xf3\xaa\xf3\x17\xf4s\xf4\xfb\xf4\xda\xf5\xe3\xf6\xf3\xf7\xdd\xf8\xcf\xf9\xba\xfa\xa4\xfb\x9b\xfc\xb5\xfd\xc7\xfe\x9b\xff\xd1\xff\x96\xff=\xffO\xff$\x00L\x01\xa0\x02\xe3\x039\x05\xe2\x06\x96\x08[\n~\x0c\x0f\x0f\xc8\x11\x89\x14\x1b\x17\xba\x19!\x1c\x82\x1d#\x1e\xc9\x1eC\x1f\xcc\x1e)\x1d^\x1b\x05\x1au\x18\xe1\x15\xa7\x12\xf8\x0f|\rb\n\xeb\x06(\x045\x02\xcf\xff\xec\xfc:\xfar\xf8\xae\xf6E\xf4<\xf2\xf9\xf0\x1b\xf0\xcc\xeet\xed\x19\xed_\xedk\xed\x19\xed.\xed\x08\xee\xe8\xeea\xef\xea\xef\xd1\xf0\x07\xf2\x17\xf3\xc6\xf3\xa4\xf4\xba\xf5\xdf\xf6\xff\xf7\x05\xf9\xef\xf9z\xfa\xa8\xfa\xab\xfa\xf6\xfa\x98\xfb\x14\xfcz\xfc\x97\xfc\x81\xfc\x8d\xfc\xaf\xfc1\xfd\xfe\xfd\x9e\xfe\x11\xffz\xff0\x00p\x01\x89\x02\r\x03*\x03p\x03\xff\x03\x9c\x04\x18\x05{\x05\xaf\x05\x89\x05$\x05\xdd\x04\xd0\x04\xa0\x04;\x04\xe8\x03\xbd\x03\x91\x03%\x03\x9e\x02d\x02V\x02\xfb\x01m\x01\x10\x01\xd9\x00\x91\x00\xf9\xffm\xff\x19\xff\x95\xfe\xcf\xfd&\xfd\x9e\xfc\xfd\xfb\x1e\xfbL\xfa\xdb\xf9x\xf9\xdd\xf8E\xf8\x16\xf8\x13\xf8\xf8\xf7\xa9\xf7c\xf7\x95\xf7\xee\xf7\x0f\xf8\x1e\xf8D\xf8r\xf8\xac\xf8\x01\xf9R\xf9\x96\xf9\xbd\xf9\xcf\xf9\xa0\xfaL\xfc\x0b\xfe\xe5\xff\xa3\x01\x87\x03#\x06\x15\tm\x0c\x1a\x10d\x13\x1a\x16K\x18l\x1a\xc2\x1c!\x1f\x92 \xc1 . ^\x1f\x84\x1e8\x1d\x1a\x1b\x9a\x18\xdc\x15\xe4\x12\xd9\x0f\xd4\x0c\xf8\t\xc4\x06/\x03\x03\x00\xc5\xfd\x11\xfc\xfc\xf9\xcc\xf7\x08\xf6\xc3\xf4\x8c\xf3\x19\xf2\xfc\xf0H\xf0\xa3\xef"\xefH\xef\xbf\xef\xfb\xef\xd1\xef\x98\xef\x08\xf0\xa6\xf0$\xf1\xa9\xf1\x18\xf2\x89\xf2\x02\xf3\xc5\xf3\xd8\xf4\xc0\xf59\xf6u\xf6\xda\xf6[\xf7\xe2\xf7T\xf8\xac\xf8\x0c\xf9x\xf9\x02\xfa\x9d\xfa\x1c\xfb\x81\xfb\xd5\xfbK\xfc\xf4\xfc\xe8\xfd\xf9\xfe\xe4\xff\x9a\x00:\x01\xfb\x01\xcd\x02\x97\x03I\x04\xc8\x042\x05\x82\x05\xb0\x05\xdc\x05\xd8\x05\xb2\x05p\x05/\x05\x00\x05\xa1\x04\x18\x04\xa2\x03N\x03\r\x03\xc5\x02\x88\x02P\x02\x02\x02\x95\x011\x01\xdb\x00[\x00\xc1\xff\'\xff\xab\xfe \xfek\xfd\xa4\xfc\xfd\xfb^\xfb\xac\xfa\xfc\xf9l\xf9\xf8\xf8\x87\xf8U\xf8D\xf8D\xf8X\xf8^\xf8s\xf8\xba\xf8\xd9\xf8\x03\xf9U\xf9x\xf9\xb5\xf9\x11\xfal\xfa\xe9\xfaU\xfb\x94\xfb\x08\xfc\x95\xfcT\xfdz\xfe\xf5\xff\x05\x02c\x04\xc6\x06`\t\xd7\x0b\x1c\x0eq\x10\xf4\x12\xce\x15\x95\x18\x84\x1a\xdd\x1b\xe6\x1c\xa5\x1d\n\x1e\xde\x1d^\x1dt\x1c\xb6\x1a\x99\x18\x89\x16\xbc\x14\xaa\x12\xdd\x0f\xd2\x0c\xce\t\x1a\x07\x9d\x046\x02?\x00;\xfe2\xfcB\xfa\xa4\xf8u\xf7\x1f\xf6\xcf\xf4z\xf3p\xf2\xa0\xf1\xba\xf0#\xf0\xb4\xefU\xef\x1d\xef\xe4\xee\xe7\xee\xf3\xee\xfb\xee0\xef\x9b\xef\x1b\xf0\xa4\xf0_\xf1S\xf2)\xf3\x02\xf4\xe3\xf4\xbb\xf5q\xf6\xe5\xf6\x96\xf7x\xf84\xf9\xc6\xf9k\xfa5\xfb\xcc\xfb3\xfc\xc2\xfcr\xfd\x0e\xfe\xa8\xfe\x82\xff\xbc\x00\xac\x01>\x02\xc5\x02T\x03\xf5\x03U\x04\xc7\x04d\x05\xbd\x05\xd6\x05\xca\x05\xf5\x05\x10\x06\xd1\x05r\x05D\x050\x05\xd8\x04X\x04\x1a\x04\xf7\x03\xac\x03=\x03\xea\x02\xac\x025\x02\xad\x018\x01\xe9\x00y\x00\xde\xff@\xff\xaa\xfe/\xfe\x93\xfd\xf9\xfcg\xfc\xd9\xfbY\xfb\xea\xfa\x8d\xfa\x1d\xfa\xbf\xf9t\xf99\xf9"\xf9.\xf9R\xf9i\xf9O\xf9=\xf9f\xf9\x91\xf9\x88\xf9\x9c\xf9\xe7\xf96\xfaM\xfaB\xfa\x7f\xfa)\xfb\xcb\xfb\x04\xfc\xa5\xfc\xe1\xfd(\xff\x8e\x00/\x02A\x04\xc2\x06\xf6\x080\x0b\xe5\r\\\x10\\\x12!\x14\x1c\x16\x0c\x18\x99\x19\xa0\x1aS\x1b\xd0\x1b\x95\x1b\xd6\x1a\x13\x1a\x01\x19\xae\x17\xc6\x15\xb8\x13\xe6\x11\xbf\x0f|\r\x05\x0b\xa7\x08w\x06\xfd\x03\x9c\x01j\xffp\xfd\x82\xfbz\xf9\xbd\xf7A\xf6\xc9\xf4W\xf3\xf5\xf1\xde\xf0\n\xf03\xef\x88\xee\x08\xee\x8f\xedC\xed,\xed]\xed\xbb\xed\xd4\xed\xf6\xedb\xee\x0c\xef\xe1\xef\xa6\xf0o\xf1F\xf2#\xf3\r\xf4\x18\xf5&\xf6\t\xf7\xcb\xf7\x89\xf8u\xf9\xa3\xfa\xa2\xfb_\xfc\xfc\xfc\xa5\xfdj\xfe\x1c\xff\xd8\xff\x94\x008\x01\xb4\x01&\x02\xc7\x02h\x03\xd3\x03\x19\x04X\x04\xb3\x04\x00\x05(\x057\x05C\x05M\x05]\x05`\x05R\x058\x05\r\x05\xd6\x04\xd0\x04\xde\x04\xcc\x04\x93\x04I\x04\x1f\x04\xdf\x03z\x03?\x03 \x03\xd2\x02H\x02\xea\x01\xb6\x012\x01X\x00s\xff\xe4\xfe_\xfe\xb0\xfd\xf4\xfca\xfc\xc5\xfb\x0c\xfbz\xfa\x11\xfa\xbd\xf96\xf9\x92\xf81\xf8+\xf8=\xf8G\xf8\\\xf8\x88\xf8\xc7\xf8\x0b\xf9C\xf9\xb4\xf9S\xfa\xdb\xfae\xfb\xf6\xfb\xb8\xfc\x8c\xfdA\xfe\x11\xff\x0e\x00"\x01\x16\x02\xfd\x02\x1a\x04H\x05r\x06\xd8\x07\x80\t\x17\x0bs\x0c\xb3\r\x1d\x0f\x85\x10\xac\x11\xc1\x12\xe1\x13\xbd\x14:\x15\x88\x15\xe1\x15!\x16\xe0\x15#\x15M\x14o\x13Z\x12\xe6\x106\x0f\x8d\r\xd3\x0b\xf6\t\x17\x08?\x06G\x04\x1d\x02\t\x00:\xfe\x9d\xfc\xe1\xfa"\xf9\xac\xf7j\xf6$\xf5\xf5\xf3\xf1\xf2\x1d\xf2L\xf1o\xf0\xe7\xef\x9e\xeff\xef2\xef*\xefw\xef\xca\xef\t\xf0_\xf0\xe4\xf0\x91\xf1"\xf2\xc1\xf2\x8e\xf3^\xf4)\xf5\xe1\xf5\xba\xf6\x9c\xf7R\xf8\xf7\xf8\xa8\xf9p\xfa\'\xfb\xc8\xfbk\xfc\x0f\xfd\xa0\xfd&\xfe\xb5\xfea\xff\xf9\xff\x80\x00\x0e\x01\xc2\x01\x8b\x02.\x03\xb9\x03@\x04\xc0\x041\x05\xa0\x05\'\x06\x96\x06\xca\x06\xe7\x06\x1a\x07Z\x07^\x07%\x07\xe5\x06\xa4\x06T\x06\xf2\x05y\x05\xff\x04h\x04\xcc\x03:\x03\xab\x02\n\x02>\x01]\x00\x96\xff\xf0\xfeU\xfe\xba\xfd\x1d\xfd\x82\xfc\xf7\xfb\x8c\xfb\x1f\xfb\x95\xfa$\xfa\xbb\xf9S\xf9\x10\xf9\r\xf99\xf9?\xf9\x14\xf9\x04\xf9H\xf9\xb8\xf9\r\xfaW\xfa\xc7\xfa[\xfb\xc6\xfb/\xfc\xea\xfc\xd9\xfd\xa8\xfe\x16\xff\xae\xff\xa3\x00\x96\x01g\x02,\x03\xfc\x03\xe0\x04\xa0\x05J\x06,\x07\xf0\x07k\x08\xd9\x08L\t\xc8\tM\n\xb7\n\x15\x0b[\x0b\x88\x0b\xdb\x0b@\x0ch\x0cX\x0cC\x0c=\x0c\x1b\x0c\xc9\x0b\\\x0b\xf2\n\x87\n\xeb\t5\t|\x08\xcd\x07\xf6\x06\xf6\x05\xe8\x04\xe8\x03\xf4\x02\xd8\x01\xaf\x00\x9b\xff\x99\xfe\x9b\xfd\x84\xfc\x85\xfb\x91\xfam\xf9M\xf8U\xf7}\xf6\xac\xf5\xb6\xf4\xf7\xf3o\xf3\xeb\xf2z\xf2#\xf2\xea\xf1\xc7\xf1\xa8\xf1\xb9\xf1\xec\xf18\xf2\xa5\xf21\xf3\xef\xf3\xaa\xf4d\xf5<\xf6K\xf7e\xf8j\xf9r\xfaz\xfb~\xfcn\xfdS\xfeL\xff=\x00\x16\x01\xe2\x01\xb7\x02\x87\x031\x04\xb2\x043\x05\xb1\x05\x19\x06h\x06\x9a\x06\xbc\x06\xde\x06\xe0\x06\xce\x06\xbd\x06\xb7\x06\x9d\x06T\x06\x0e\x06\xc8\x05m\x05\xf2\x04x\x04\x00\x04u\x03\xd9\x02P\x02\xe4\x01b\x01\xb1\x00\xef\xffE\xff\xaf\xfe\x15\xfeO\xfd\x90\xfc\xee\xfbg\xfb\xe2\xfa\x7f\xfaM\xfa(\xfa\xd4\xf9x\xf9l\xf9\x91\xf9\xaa\xf9\xbb\xf9\r\xfa{\xfa\xc3\xfa\xf0\xfaa\xfb\x15\xfc\x9b\xfc\x11\xfd\x98\xfd3\xfe\xc8\xfec\xff\x19\x00\xce\x00^\x01\xca\x01R\x02\x0b\x03\x9a\x03\xef\x03\\\x04\xfc\x04|\x05\xcd\x05\x13\x06t\x06\xc1\x06\xdf\x06\t\x07R\x07\xa8\x07\xec\x07\x14\x08;\x08W\x08:\x08\xfa\x07\xc4\x07\x9e\x07\x85\x07g\x07\x18\x07\xc7\x06\x87\x06\x1f\x06\xad\x05A\x05\xf1\x04\x90\x04\x13\x04\x8b\x03\xf9\x02y\x02\xfa\x01\x8b\x01A\x01\xf9\x00\x93\x00\x1c\x00\x95\xff\x0c\xff\x81\xfe\xf4\xfdq\xfd\x01\xfd\x9a\xfc\x10\xfc{\xfb\xe2\xfaJ\xfa\xc1\xf9^\xf90\xf9\x14\xf9\xf5\xf8\xbd\xf8\x7f\xf8]\xf8C\xf8Y\xf8\x8c\xf8\xc8\xf8\x08\xf9K\xf9\xb8\xf9/\xfa\xa7\xfa\x1d\xfb\xa1\xfb>\xfc\xdf\xfcs\xfd\n\xfe\xa4\xfe\'\xff\xaf\xff:\x00\xc0\x00P\x01\xc9\x014\x02\x98\x02\xf6\x02b\x03\xb1\x03\xe6\x03\xf6\x03\xe4\x03\xe6\x03\xf1\x03\xf7\x03\xfa\x03\xe4\x03\xb7\x03U\x03\xea\x02\x89\x02\x1c\x02\xc1\x01`\x01\xf4\x00\x8e\x00\x1a\x00\xa6\xff>\xff\xc9\xfed\xfe\xfb\xfd\xa1\xfd/\xfd\xb7\xfci\xfc\x1b\xfc\xdc\xfb\xbb\xfbd\xfb-\xfb\x00\xfb\xc3\xfa\xcd\xfa\xa4\xfa\x98\xfa\xbc\xfa\xd9\xfa\xfe\xfaD\xfb\x8e\xfb\xdf\xfbN\xfc\xa1\xfc \xfd\x94\xfd3\xfe\xaa\xfe"\xff\xb5\xffn\x00\x13\x01\xdd\x01U\x02\xf8\x02\x95\x03\xec\x03`\x04\x87\x04\xd3\x04\xed\x04\xef\x04\xfd\x04"\x05$\x05~\x05\x7f\x05\xca\x05\xb4\x05`\x05q\x05\x11\x053\x05\x86\x04\xed\x04|\x04\xe1\x03u\x04\xb3\x03o\x030\x03\x19\x03\xb7\x02Y\x02c\x02\x8f\x02\x9d\x02\xf4\x01\xe9\x01\xd1\x01\'\x01\xcd\x00\r\x00\xe7\xffj\xff\xf3\xfe\xc5\xfe"\xfey\xfbH\xfa\x8d\x01\x9c\x0fV\x15\x8a\x03\x89\xee\x1c\xe8\xd7\xee\\\xf7\x91\x03|\x05\x86\xfd\n\xf3\xe9\xe7\xc7\xeee\xf3=\xf8\xde\xf8\x1e\xf6\x00\xf6\x80\xf6\xe9\xf8\xbd\xfe\n\x04\x1f\x02W\xfb\x17\xf8B\xfcI\x028\r+\r\xcf\x04u\xff\x7f\x00\x93\x06\xa5\x0c\xaf\n\xa1\x02\xbb\x005\x05\n\x0b5\x0b\xe4\x06"\x02\x90\xff\xbf\x04\xb3\x06\x0c\xfff\x04\xde\x02\xa8\xff\x01\x01 \x03\x17\x07\xd3\xff\xed\xfd\xc0\xfe\x97\xfc\xd3\xfe\xbf\x03a\x04\xed\x01\x92\xfc\x02\xfbt\xfe\xfd\x00\xd3\xfe\x80\xfdO\xffj\xfeE\xfc^\xfe\xab\xfcH\xfe\xf4\xfd\x13\xf8\x97\xf7\xa8\xf9\xea\x034\xfcN\xfa\x88\xfbQ\xf6c\xf8S\xfc+\x02\x1d\xf9\xac\xfc3\xfeZ\xfau\xfb\xe8\xfeP\x02\xe3\x00\xd3\xff\xa1\xfc(\xfdf\xfd\xe7\x01t\x07\xe8\x04?\x02\xda\x01\xc2\x00\x8e\x01\xac\x05-\x05\x00\x03\x1c\x04\xae\x07\xb2\x04 \x03\x87\x04f\x04\xce\x06\xa1\x06F\x03\xc3\x01\xb5\x02\x93\x03\xba\x05\xbc\x04\xeb\x04\xb9\x01\xd1\xfe`\x00\xa4\x04\x00\x03\xc0\xffx\xff\x9a\x01\x00\x00w\x00\xa4\x02u\x00\xcd\xfd"\xfd\xda\xfdZ\xfeA\x02\\\x001\xfes\xfb\xfa\xfa\xe1\xff0\x01\x9b\xfd\xdb\xfc_\xfdB\xfc<\xfc\x9e\xfd\xad\xfeG\xfd\xf5\xfb\xb4\xfa\xcb\xfb\x0e\xfdd\xfc\x0c\xfd\xdd\xfd\x8f\xfco\xfb-\xfb\xae\xfc\xc3\xfe\xe5\xfeg\xff\xdc\xfe\x0e\xfe\x80\xfc\x11\x00\xf1\x03\xce\x01\x0f\x03=\x02\xc1\xfe\xbc\xff\xb6\x04\x00\x05\xbd\x025\x03a\x01\xfe\x00\xd1\x05+\x05\xca\x01s\x01\x06\x05\xb6\x03$\x02\xad\x019\x02\x9e\x02\xf0\x01\x84\x01C\xff\xab\xfe\xa2\xfe,\x02z\xff\x0c\xfd\x99\xfb\x85\xfc\xc5\xfe\xb4\xfd\x81\xfc\xd9\xfcn\xfe_\xfd\x88\xfb\x8f\xfc2\xfe\xfc\xfd\xd2\xfb\xbd\xfa\x9c\xfe\xc0\xfb\xa0\xfcC\xfe\xa0\xfc\xfc\xfb?\xfc\xc8\xfe?\xfdw\xfc|\xfd\x8d\xfd!\xff\xe9\xff8\x00V\xfe\xce\xfdO\xff\x0e\xff\x15\x00\xb3\xff\'\x02\xd6\x01\xc4\x00\xc5\x01_\x03F\x01\x94\x00h\x02\xbc\x02\xe1\x04W\x04\x03\x04\x87\x02R\x04v\x04\x0e\x04U\x07n\x05q\x02\x18\x03\xe4\x03\x14\x03\xeb\x03\x8b\x06^\x04\xa4\x02\t\x02F\x02\x92\x00|\x02\'\x03\xc7\xff\x16\x017\x01m\x01\xeb\xff\xc2\xffr\xfd\xf6\xfd\x0c\x00h\xfe$\xff\x92\xffw\xfc\xe0\xfa\x16\xfe+\xfc2\xfbC\xfd\x7f\xfbw\xfb\xa0\xfc?\xfc\xd0\xfb/\xfbY\xfb\xdc\xf9\x05\xfc>\xfdk\xfd\\\xfd\x8a\xfc\x18\xfe\xa8\xfd"\xfe\x06\xff\x19\x00!\xfd\xfa\xfe\xce\x00\xae\x00\x82\x01S\xff\xfa\xff,\x01;\x02X\x03\xf2\x03\xf0\x01\xc2\x01\xf9\x01E\x02\xdf\x03e\x05\xb2\x04\x81\x02\xc6\x03\xdf\x02\xab\x03^\x03\xc2\x03\x19\x02\xa6\x02"\x04\x1c\x02\x84\x02\x95\x01n\x02\x96\x00X\x00:\x00\xf3\x00\xb7\xff\xbe\xfe\xac\xff\xa1\xfe\x90\xfe3\xfe:\xff|\xfe\xe3\xfe4\xfe\x15\xfe\xa5\xfe%\xfe\x82\xfc\x07\xfd\x00\xfe\xbb\xfe\xbf\xfd0\xfc\x1d\xfe\xf9\xfd\xb4\xfd\x8c\xfc\x91\xfd\xe6\xfb\x02\xfc\x86\xfd\n\xfd\xea\xfeb\xfd\xa8\xfd\xd0\xfc\x8f\xfe\x04\x00-\x00r\xfdG\xfd\x15\x01v\xfe{\x00\xff\x00s\x01\xb2\x00W\x01\x7f\x025\x02\x85\x019\x00b\x04\xd1\x03T\x03\xe7\x02\x9c\x02 \x04\xc4\x03\r\x04\xcf\x04j\x04$\x02e\x02\xed\x04\xa9\x03Y\x03\xfc\x04\xe7\x03{\x02\xeb\x01\x9d\x02\xc9\x00c\x02R\x02\x9e\x02\xbd\xffr\x01\xc4\x01\x9f\x00\xda\xfe\xeb\xfcu\xff9\xfc\xca\xfe5\xfeG\xff\xdf\xfd\xe8\xfb\xb0\xfb}\xfbI\xfb\x8b\xfd\xd5\xfa\xad\xfa\x11\xfe\x94\xfb\x0c\xfd\x99\xfdG\xfd\x98\xfa\xec\xfa\x00\xfd\x86\xfe\xc3\xfb\x1e\xff0\xfeX\xfeZ\xfe\x95\xfd\xf4\xfe\x9f\xfe{\x01\xfc\xfe\x0b\x01-\x00\xe3\x00\xf5\x00e\x01b\x02"\x02\x9a\x01\xd7\x01\x82\x01I\x04l\x04.\x01\x91\x03e\x03\xb0\x02\xc9\x03\x9a\x03I\x02\xda\x02;\x01\xc6\x03\xb7\x02\x08\x01\xf1\x02\x8f\x01\xfb\xff\xc4\x00\xe2\x01\xb6\xff"\x00(\x00\x01\x01d\x00c\x00\xba\xffW\xfe\xcc\xfe\xb3\xfe\xaf\x00O\x00\xa8\xfe]\xfe\xb4\xff\xed\xfe$\xfe[\xfe\x93\xfe\xe5\xfe\xdf\xfeF\xff\xe0\xfd\x96\xfdN\xfe\xf3\xfdQ\xfc\xd0\xfdN\xfeQ\xfe\xd8\xfe\xbf\xfb\x17\xfb\x03\xfd\xef\xfc\x81\xfc\xae\xfdM\xfch\xfdd\xfee\xfd\x9f\xfb<\xfd^\xfe\xf9\xfe\xd3\xff%\xfe[\xff\xce\xfe\xb2\x00J\x01\xa6\x00s\x00\xc4\x01\xae\x01\xad\x00H\x03\x8a\x03\x9c\x02w\x02\xbf\x02\x81\x02\xfd\x05:\x033\x01\xcf\x03\x8e\x03\xee\x03\xba\x02\x1e\x03F\x02<\x02p\x03\xc4\x02m\x01\x15\x01\xb7\x01<\x01~\x00_\x00}\x00(\xff\xc2\x00d\xff\xeb\xfd4\x00\\\xfe:\xfd,\xfe\x0e\xfe\x0b\xfe\xc9\xfdX\xfe\xf5\xfc%\xfd\xa9\xfd\xb5\xfc\x1b\xfe=\xfe*\xfd\x85\xfdG\xfet\xfd\x8e\xfe4\xfe\x0b\xfe;\xfd\xb2\xff\xfb\xff[\xff\xe6\x00\xd1\xfe\x05\xff\xa7\xff\xd9\x00\'\x01\xe4\x00P\x00\xf1\x01p\x01t\x01\xbc\x01\x10\x01\xd9\x011\x01;\x02\x9c\x02\x1e\x022\x02s\x01\x86\x01\xab\x013\x01N\x02\xf6\x01\xc1\x00\x94\x01\xe2\x01\x84\x01\xf9\x01#\x01\xf1\x00H\x01\x06\x01\x86\x00\xac\x01F\x02\x01\x01I\x00\x18\x01\xb0\xff5\x00\xd1\x01\xe8\xffH\x01F\xff\xb5\xff\xd7\xffq\xff\xf2\xfe0\xffR\xffD\xfe\x08\xffS\xfd\x0e\xfe\xdd\xfd\xa0\xfdY\xfeD\xfd\xdf\xfc\xae\xfd\xa2\xfd\xaf\xfd>\xfd\xea\xfc\x95\xfc\x95\xfd\xc6\xfd\x8b\xfe\x92\xfe\xc2\xfdb\xfeD\xfd\xb5\xfe\xe0\xfd\x1b\x00\xe6\xfe\x89\xff\xa3\xff~\xff\xf9\x01\x16\x00\'\x01\x0e\x01\x84\x01$\x00J\x02\xed\x01>\x03\xcf\x03\xc8\x02\xfa\x020\x028\x03\xfb\x02\xb2\x02\xc4\x02P\x04j\x03\xa3\x03w\x02\xb6\x03\x85\x01N\x02\xa2\x02\x0c\x03_\x017\x00\xf3\x00t\x00L\x02\xaa\xff\xce\x00\x94\xfe\xc0\xfe]\xfe&\xff\xd8\xfe\x11\xfe\xb2\xfd\'\xfd\xea\xfdr\xfc\xea\xfc\xe3\xfc\x95\xfc\xc8\xfd\x14\xfcT\xfc\x87\xfc\x92\xfc\x9d\xfc\xc7\xfdS\xfe\xdd\xfc<\xffR\xfd\r\xfeJ\xfe2\xffo\xfeG\xff\xc0\xffN\xff\xf2\x007\xff~\x01\x03\x00F\x00\xa5\x00\x7f\x01\x85\x01\xfa\x00\x82\x02\xe2\x00A\x02~\x02\xa2\x01\x8b\x00$\x01J\x02\xe0\x017\x03;\x01\x8a\x00\xc0\x00\x11\x01V\x01c\x02\xff\x01\x05\x00\xd1\x00\xf8\x00Z\x00\xa1\x01_\x01\x06\x00\xb8\x00\x16\x00c\x00\x81\x00{\x00\xbe\xffP\x01V\xff\x95\xff\xf7\xfe\x97\x00\xd8\xfe\xc2\xfe\\\x01<\xfe\xa3\xfeS\xfd\'\xff\xdb\xfe\x0f\xffO\xfdv\xfd\xc8\xfd\xb7\xfc\xd0\xfd\xad\xfd\x84\xfd\xef\xfct\xfd\xe8\xfcX\xfe\x05\xfd\x03\xfd\xbd\xfe+\xfd\xf3\xfe\xd7\xfd\x9f\xffc\xfe\x0c\xff\x15\x011\xfe\xbd\x00\x95\xff~\x01^\x00F\x01\xc2\x00\xf4\x01l\x02[\x00\t\x03\xb2\x01\x06\x03\t\x02\xb8\x02\xdc\x01{\x02\x95\x02|\x02Y\x02<\x02i\x02\t\x02B\x02.\x01e\x02v\x01B\x01\x9a\x01=\x00\xbd\x01\x9d\x00\xcc\x00\x0f\x00\xcd\xfev\x01\xf8\xfe\x8b\xff\xa9\xff"\xff\xb5\xfe\xad\xff\x1d\xfe\xf2\xfdT\xff\n\xfe8\xff>\xfe\xed\xfd\x8f\xfd\x12\xfe[\xfe\xd1\xff\xd7\xfe\xd1\xff\xfc\xfd\x00\xfe2\xfen\xfe\xa5\xffR\xffc\x00Q\xfe\xf5\x00\xad\xfd\x95\x00\x9e\x00\xfa\xff\x17\x00\xaf\xfe(\x01\xf8\x00\x87\x02\x1d\x00w\x02\xcb\x00\x1d\x02\xe6\x02I\x01\x05\x03\x99\x01\xb3\x02\xcf\x01\xe3\x02"\x02?\x02\xe6\x01=\x01\xfb\x01\x7f\x00O\x01u\x00:\x00n\x00\x08\x01T\xff\x98\xff\x16\xff\xbc\xffm\xff\x89\xffV\xff\xd9\xfd\xbb\xff9\xff0\xfe\xc2\xffO\xfe\xa9\xfe\xc5\x00_\xfd\xa7\xff\x02\xfe\xdb\xff<\xfe\x93\xff\x1c\xff\x1b\xff\xdd\xfer\xfe\x8d\xff/\xfe\x1e\x00U\xfd[\xfff\xfd\xa1\xff\x15\xff\x8c\xfe\xb0\xfe\x13\xfew\xfe\x87\xfeW\xff\x07\xff\x15\x00\xc3\xfe\xa5\xff\xfd\xfe\x1f\x00\xae\xff\xa3\xff\xc2\x00Q\x00g\xff\xed\x00\xcf\xff\xac\x01b\x01\xa1\xff<\x01\xd2\x00\xd2\x01S\x00\xee\x01\x91\x00\x0b\x02\xa8\x02\x96\x01\xcf\x01\x88\xff\xe1\x00y\x01G\x01g\x03\xa5\x01c\x00\x19\x01\xeb\x01\xc6\x00E\x02\xcc\x00\x08\x01a\x01\x89\x00\x1c\x01\xad\xffd\x02\x8b\xff\x81\xff\xc3\xff\x86\xfe\xd9\xffy\xff!\xff\x13\xff\x12\xfer\xfe\x19\xff@\xfe\xa6\xfe&\xfd\xe0\xfd9\xffp\xfe=\xfe\xce\xfd\xde\xff\x86\xfe;\xfe\x85\xfft\xfd?\xffq\xff\xa6\x00\x18\xff\xce\xff\xa6\xff~\xfe9\x01u\x00\x83\xff\xbc\x00\xce\x00\xda\xff\x01\x00\x9e\x00\xa1\x00I\x00\x96\x015\x00\x96\x018\xff\x15\x02\x03\x00\xc8\x00\xcf\x01E\xff\xfe\x01\x95\x00\xb7\x01\xfe\xffO\x01\'\x01\xe4\x00\xec\xfe\x15\x01\xed\xff\x9c\xff\xc8\x00\xc8\xff*\x01\xde\xfe*\x00\xc6\xfe\xd0\xffl\xffP\xfe/\x01\xbb\xfe\xaf\xff\xa3\xfe\xf6\xfe2\xff?\xff_\x00\xc4\xfd\xd2\xff\xeb\xfe\xea\xff\x7f\xfe\'\xff\n\xff\xb0\xfe\xc0\xffm\xfe\xf7\xffr\xff\x89\xff\xd9\xfe\xdc\xfe\xab\xfe\x1c\xff\xe8\xff\xf7\xfe7\xfe\xdc\xff8\xff;\x00\xbe\xff\x94\xfe\x94\xff\xc5\xff{\xff\xc6\xff*\x00\xc4\xff\xb8\xff\x8c\x01\xea\xff#\xff\xe6\x00K\x01\xe3\x00Z\x00\xd0\xff\xf8\x01,\x00\xb9\x011\x02b\x00q\x02\x1b\xff\x08\x03$\x00\xb4\x01&\x01\x8c\x00\xee\x01a\x00\x9c\x03_\x01\xf5\xff]\xff\xdc\xff\xc0\xffN\x01\xd5\x00\x93\x00\x10\x00t\xff\x95\xff\xae\x00S\xff\x1c\xff\xa6\xfe\xa9\xfeR\x00g\x00u\xff\x08\xffQ\x00\xaa\xfeD\xff`\xfea\x01\x88\xff\xd6\xffU\xff\xf2\xfek\x00/\xff\xcd\xff\xc4\xffq\x00\x9b\xff\x93\x00\xac\xff\x00\xffR\xff`\x00\xf4\x00N\x00\xef\xff\xbd\x00\r\x00w\xff\x91\x00\xc1\xff\xe5\xff#\x01\x15\x00\xc6\x00\x08\x00\t\x00\xd0\x00F\xffd\x00\x83\xff\xde\x01\x8f\xff\xd4\x00\xa2\x00\x7f\x00\xea\x00\x08\x01\xe2\x01\xe0\xff\xc8\x01\xce\xfe\xa7\x01\xdc\x00/\x01\x1c\x01-\x00Z\x01\xa2\xff4\x00\xcb\xff\xb0\xff\xfe\xfe\xa2\x01F\xff\x81\xff\x9a\xff\xf2\xfe~\xff5\xff\x1b\x00\xf5\xfe\x13\xff\x96\xfe\x14\xfe\xa0\xff\xe2\xffx\xfe\x17\xfe\x94\xfd\xf6\xff\xf6\xff\xba\x00:\xff6\xff\xaa\xff.\xfey\x00\xe8\xfd\x04\xff9\x00\x86\xff.\x02\xba\xfe6\x00(\xff`\x00t\xfe\xe7\xff2\x01*\xff\xe3\x01\x0e\x00\x19\x01\xc9\xfe\x9d\x00\xeb\xfe\x00\xff\xce\x00W\x01\x9d\x01\xdc\xfea\x00\xdd\xff\xd3\xfe\xa6\xff\xe1\x00\xe7\x00\xbe\xff|\x01\x11\x00\\\x00@\x01\xc1\xfe\x94\xff\xb6\xff\x07\x01\x81\xff+\x01\x05\x00\xa1\x00v\xff\x90\xff&\xff\xf7\xff}\x01i\xfe\xe9\xffn\x00`\x00\xde\xfe\xdb\x00Y\xfeh\x00\xac\xff\xd0\xff\xda\xffR\xfe]\xff\xb0\x00\xc9\x00\x90\xff.\xff\xcf\xfe\xbf\x00\xd3\xff\xcc\xff>\xfe3\x00\xde\xffJ\x00\x8e\x00\x8f\x006\xff\xd2\xff\x98\xfe\xea\xfe\x95\x00i\xff7\x01\xc9\xfe:\x02\xbc\xff\x19\xff\xba\xfe$\xff\xb3\x00\n\x00\xd8\x01S\x00K\x00\xbe\x00\x12\xff\xa8\x00\xc8\xff\x91\xfe\xd1\x01L\xff\x9d\x00\xc2\x00\xd2\x01\xb1\x00\n\x00\xf7\xff\xdc\x00\x10\x01\xe6\xfd\xd3\x00W\x00\xa7\x00{\x02\x08\x01}\xfe|\xff\xef\xffI\xff\xb1\xff_\x00\xab\xfe\xdd\xff\x8c\x00\xde\xfe\x8f\xffh\xffI\xff|\xff\xea\xfe\\\x00C\xffG\x00~\x00\xb0\xff\x04\xffJ\xffR\x00\xee\xff\xca\xff\xc0\xfd\xe9\x00\xe3\xffN\xff\xb6\xfew\xff\xf7\xffd\x01\xab\x00t\x00<\x00\xbb\xfe\xaa\xff\x7f\xfeK\xff\x14\x01L\x01m\x01\x10\x01a\xff~\xff.\xffT\x00\x96\x00\xcf\x00G\x00m\x00\x88\xff2\x00R\x00e\x00\x83\x01\xe7\xff\xef\x00}\x00"\x02\xce\xff]\xfe\x10\x01\r\x00\x8a\xff\x0e\x00w\x00\x8b\x00\x07\x02\xd3\x00\xd0\xfe\x8b\xfe\x8a\xff\x80\x00q\x00\xf9\x00\xc9\xff\xcb\xfe\xcf\xfe.\x00\xbe\x01T\xff\xbb\xfc\x80\x00\xca\x00\xa5\xff\'\x00\xc5\xff\x8a\xfd_\xfc\xd7\xff"\x022\x02Q\x00\x95\xff\xf7\xff\x8b\x00\x80\xfe\xbd\xfe\xaa\xff\xbb\xff\xd9\xfe\xbb\xff\xf1\x01<\xff\x03\x01%\x01\xd3\xff\xe5\xff\x85\xff\x88\x01`\xff\x15\xff\x06\x00\x13\x00<\xffV\x01\xe0\x02\xa4\x01\xca\xff\xd4\xff\x03\x000\xfe%\xff\x12\xff\xea\x01\xca\xff\x94\x00\xb2\x00\xd6\xff\xab\x01\x0e\xff\x16\x00x\xff\xa2\x00X\x029\x00\x81\x00*\x01W\x00\x81\xff\xcc\x008\xfe{\xfd\xae\xff\xcd\x02<\x04n\x00\x12\xff*\xfe\xff\xfd\xe5\xffz\x02\x9a\xff\xa4\xfdW\xff\x9c\x01\xc6\x01J\x00\x0b\x00J\xff2\xfd\x00\xfe\x18\x00`\xffk\xff\xaf\xff\x8a\x00\xf5\xff#\x01\xfe\xff\xac\xfe\x99\xff\xe3\xfe\r\x00H\xffn\x01\xeb\x01G\x00\xe2\xff\xc9\xfe\xf2\xfe\x00\xff\x91\x00\xb5\x01\xda\x01\x9f\x01\xd5\x01\xb3\xffN\xfe\xec\xfe\xc1\xffA\x01i\x00\xf7\x00\x00\x01h\x00\x88\x00\xd4\xfei\xfe\x8b\xfe\x86\xff\xf6\x00\xc4\x00\xa9\xff\xf4\x00\xd0\x00a\xfe\xcb\xfe\x1d\x00v\xfe\xe5\xfd\x06\x00C\x00D\x01\xfc\xff\x8c\xfe\x14\xfe\xe3\xfd\\\xfeX\xfdB\xfe(\xff\xb3\xfeh\xff\x97\xff\x83\xfe?\xfd\xed\xfc\x8d\xffO\xfe\x06\xfe\x9c\xfd\x82\xfd\xfd\xff\xec\xffB\xff\xb3\xfd\xb3\xfdy\xfem\xfe\xc1\xff\xee\xfe8\xff\x14\xff\x80\xff1\xff7\xfe\xe5\xfd$\xfc\x1b\xff\xa5\x004\xff\x08\xfe\xce\xfd\x81\xfec\xfe\xce\xfeC\xfe\xc2\xffo\xff\xe7\xff\xb5\x00H\x00G\x00\xb5\xff\xc0\xfe\xe6\xfe\x89\xff7\x00\xb0\xff\xb0\xff\xc5\xff\x83\xfe\x9a\xfeQ\xff\xbb\xfeX\xff\x1a\x00\xf7\x00o\x02\x1c\x04s\x05\x86\x07\xbd\t\xac\x0bH\x0e\xa9\x10\xef\x12U\x14O\x15\xd5\x15\xaf\x15\x1f\x15\x0b\x14h\x12\x14\x10\x10\r8\nG\x07z\x03\xe9\xff\x01\xfdt\xfa\xa5\xf7\x9b\xf5\xdb\xf3\xf8\xf1B\xf1\xc6\xf0$\xf0\x05\xf0,\xf0g\xf0\xd0\xf0\xbf\xf1\\\xf2\x91\xf2\xa7\xf3\t\xf5\x01\xf6\xe2\xf7\x1e\xfa\x92\xfb%\xfd!\xff\xec\x003\x02_\x03\xbe\x03\xc9\x031\x04\x13\x04\xaa\x03\x1c\x03\x06\x02\xd3\x00\xa7\xff\xc0\xfe\xeb\xfd\xa0\xfc\xfe\xfb\xa3\xfb]\xfb7\xfb{\xfb&\xfc9\xfc}\xfcD\xfd\r\xfe\x17\xff\xc6\xff+\x00O\x01t\x02&\x037\x03`\x03\xb9\x034\x04\x01\x04\xb3\x03\x92\x03T\x03\xb7\x02\xc7\x01k\x00\xf0\xfff\xff\xff\xfe\x15\xff\x91\xfe.\xfe6\xfd\xdc\xfb4\xfb\x0c\xfd\xd4\xfeI\xff\x9c\xfd\xcc\xfc\xc8\xfd%\xff\x81\xff8\xfeb\xfd\x9c\xfe\x87\xfff\xfe\x03\xfeO\xfe4\xfe\xb9\xfc\xf8\xfb\xcc\xfbv\xfbT\xfb\xd4\xf9\xe8\xf8Z\xf8\xdc\xf8@\xf8\xe2\xf7\x95\xf7\xe9\xf7\xf1\xf7#\xf8\xd4\xf8\x0f\xf9\x10\xfa\xde\xfa\n\xfcf\xfd$\xff\x16\x01L\x04\xb9\x07\r\x0bR\x0f\xa3\x13\xa1\x18@\x1e"#$\'0*\xb7,I.\x9f.\x82-\x0f+d\'\xb1!R\x1b\xb1\x14\xae\r\xb6\x06^\xff\x05\xf8\xf9\xf1\x02\xed\xdd\xe8r\xe5\x99\xe2\x9a\xe0\xb8\xdf\x9c\xdf\xd8\xdf\x96\xe0\xff\xe1t\xe3\xc3\xe4\x87\xe6\xfb\xe8\x05\xec\xe1\xeej\xf1_\xf47\xf8h\xfcg\x00:\x04\xc2\x07\x00\x0b\xcb\r\xc4\x0f\xdf\x10"\x11{\x10\xd5\x0e;\x0c\xe9\x08|\x05\xa4\x01`\xfd\xf9\xf8\x18\xf5\xef\xf1S\xefW\xed\xec\xebw\xeb\xc9\xeb\xb5\xec\xe3\xedy\xef\xd1\xf17\xf4\'\xf6/\xf8\x9e\xfa6\xfd\t\x00N\x02\x11\x04|\x06)\tD\x0b\xa4\r\xa3\x0f \x11c\x12Y\x13\xd3\x13\xac\x13\x1b\x13\xbe\x11\x91\x0f\x14\r\x8e\n\xcb\x07\xc1\x04\xfd\x01F\xff\x83\xfc\xac\xfa=\xf9\xb8\xf7\xc8\xf6_\xf6!\xf6\xd1\xf5\xde\xf5\xf1\xf5\xe0\xf5)\xf6Z\xf6I\xf64\xf6V\xf6M\xf6\x11\xf6&\xf6Z\xf6\x8f\xf6\xb4\xf6\x89\xf6\xa6\xf6\xe4\xf6N\xf7\xef\xf74\xf8\xa5\xf84\xf9\xe4\xf9]\xfa\xe9\xfa\xf4\xfb\x97\xfc\xc3\xfc\t\xfd^\xfd\xd7\xfd\xfb\xfd"\xfe\x04\xfe\xab\xfd\xfa\xfd\x0f\xfe\x9a\xfe$\x002\x02\xd6\x04{\x085\r\x83\x12\x7f\x18u\x1f2&\x19,\xad1V6)::\x11\xc6\x14[\x17\xbc\x18\xf2\x18\n\x18\xbf\x15\x00\x12)\r\xc7\x07\xd9\x01Q\xfbW\xf4\xcc\xedJ\xe8\xc9\xe3M\xe0\xde\xdd\xbb\xdc\x11\xdd\xdc\xde\xe1\xe1\xca\xe5k\xea\x93\xef\n\xf5X\xfa\x97\xff\xb4\x04p\t\x8a\r\r\x11\xe1\x13]\x16\xa2\x18b\x1a\\\x1b\xd7\x1b\x16\x1c\xd3\x1b\xfa\x1a\xb7\x19\xe0\x17n\x15Z\x12\x93\x0ea\n!\x06\xb1\x01V\xfd#\xf9"\xf5\xc3\xf1E\xef\xa6\xed\xfe\xec\xdc\xecR\xedm\xee\x16\xf0\x1c\xf2V\xf4\xb5\xf6\xcf\xf8|\xfa\xe3\xfb\xf5\xfc\xc5\xfdl\xfe\x86\xfe\'\xfe\xa8\xfd\x07\xfdi\xfc\xb0\xfb\xc7\xfa\xe8\xf9\xf3\xf8\xf6\xf7-\xf7\x8b\xf6\xe2\xf5N\xf5\xcc\xf4E\xf4M\xf4\xaa\xf4E\xf5\xf6\xf5\xe0\xf6\xec\xf7\x0e\xf9\x88\xfa\x05\xfc`\xfd\xd6\xfe\xd3\xff\xbb\x00\xa3\x01\x8a\x02 \x04_\x05Z\x067\x08~\x0b\xf3\x10i\x17\x15\x1d\x87"a(\x02/\xe45\xea:\x96=\xaa>N>\xe9; 7O0\x12(\xa4\x1e\xb0\x13\xbf\x07\x96\xfc\xe4\xf2E\xea\xe9\xe1 \xda\xac\xd4\x15\xd2[\xd1m\xd1\x15\xd2\xfe\xd3\x03\xd7c\xda\xef\xdd\xa4\xe1\x97\xe5^\xe9u\xec`\xef\x12\xf3\xc4\xf7\xb7\xfc\xd3\x00>\x04X\x08C\r\x01\x12\x84\x15\xae\x17\xbe\x18\xb2\x18\xff\x16\x9b\x13\xd8\x0e/\ts\x02\xb2\xfaV\xf2\xb1\xeam\xe4\x1b\xdf\xa3\xdaI\xd7\xd4\xd5\xa9\xd6\x8a\xd9\xb5\xdd\xc1\xe2\xab\xe8J\xef`\xf6V\xfd\xe1\x03\xfd\tH\x0f~\x13\xaa\x16\xf8\x18\xfd\x1aX\x1c\xd8\x1cy\x1c\xad\x1b\xd4\x1a\xf2\x19\x8f\x18\xb8\x16\x89\x14\xf7\x11\x08\x0f\xb5\x0bT\x08\xc0\x04\xcd\x00\xad\xfc\xb8\xf84\xf5l\xf2y\xf0,\xef\x9e\xee\xcd\xee\xde\xef\xbe\xf1.\xf4\xc5\xf6\x93\xf9Z\xfc\xf0\xfe(\x01\xcb\x02\xce\x038\x04\xf5\x03\x12\x03\xb2\x01\x12\x00;\xfe\x10\xfc\xc0\xf9\xb5\xf7\x02\xf6q\xf4\xe6\xf2\x94\xf1\xb9\xf01\xf0\xea\xef\xee\xef3\xf0\xd2\xf0\x98\xf1U\xf2n\xf3 \xf59\xf7\'\xf9\xdf\xfa\xea\xfcZ\xff\xa0\x01\xf2\x02\xb6\x03\\\x04\xb3\x04a\x04\xf4\x02Q\x01V\x00\x1a\xff\xcd\xfd\x1a\xfd\xee\xfe\xa4\x03 \t\xbf\x0eq\x15\xf6\x1e\xb7*!5\xd4<\xa0B\x0fH\x11L\xf7K\xc7G\x00A\x868\xc2-* \xad\x11t\x04Q\xf8C\xec\xa4\xe0p\xd7\r\xd2y\xcf\x0b\xceB\xcd\x12\xce\xd2\xd0\x8c\xd4\x03\xd8\t\xdbo\xde\x16\xe27\xe5\xc0\xe7\xab\xea\xf0\xee$\xf42\xf9\x12\xfe\xfa\x036\x0b\x94\x12\xb5\x18?\x1d\x9a \xb9"\xca"K %\x1b3\x14\xc0\x0b:\x02%\xf8D\xeeR\xe5\x83\xdd\x1b\xd7\xbc\xd2\xc5\xd0Y\xd1\xf7\xd3\xfd\xd7!\xddL\xe3p\xea\n\xf2\x85\xf9K\x00\xeb\x05\xb8\n\x0b\x0f\x1d\x13\xae\x16\\\x19\x02\x1b\x1d\x1c\xe6\x1c\xcc\x1dn\x1e{\x1e\x81\x1d\\\x1bQ\x18\xcc\x14\x0e\x11\xa2\x0c\'\x07\xf8\x00\xd7\xfaJ\xf5\x9d\xf0$\xed\xe9\xea\xbf\xe9\x87\xe9\x99\xeaN\xedp\xf15\xf6\xea\xfa\t\xff\xba\x02\x16\x06+\t\x85\x0b\x83\x0c\x02\x0cr\nj\x08g\x06P\x04\x01\x025\xffA\xfc\x8c\xf9m\xf7\xe7\xf5\x9b\xf4\x1f\xf3I\xf1]\xef\xee\xed\x1b\xed\xb7\xec\x8d\xec\xb3\xec\x04\xed\xf0\xed\xc2\xef^\xf2\xea\xf5\x80\xf9\x9c\xfc\x87\xff&\x02\xde\x04\x1e\x07\xfc\x07\x08\x08\xab\x07s\x06\xc5\x04\xa2\x02~\x00\x03\xff4\xfdq\xfa\x8e\xf7`\xf5V\xf4\xc2\xf3\x85\xf2Y\xf1\x9c\xf1\xa8\xf3X\xf8\n\x01\xb7\r\x0b\x1c\xac(\xf52\'>=K\x89V\xd6[\x1d[\rW\xe5P\xe6F\xf08\\)\xda\x19\xa4\t\x7f\xf8\xe8\xe8\xaf\xddo\xd6\xf4\xd0\x92\xcbx\xc7\xd8\xc5,\xc6]\xc7\xa3\xc8\xc0\xc9\x00\xcb\x88\xcc7\xcf&\xd4\x9d\xdb1\xe5\x82\xef3\xfa\x9c\x05\t\x12\xb5\x1e\xea)t2\x017\x957\xbc4\xfa.\xbe&k\x1c@\x10m\x03q\xf6R\xeaK\xe0\xed\xd8/\xd4\x10\xd1\xf7\xcei\xce\xa2\xcfu\xd2\xef\xd5\x98\xd9\xa2\xdd=\xe2\x88\xe7\x82\xed\xa1\xf4\r\xfd\xeb\x05\x85\x0e\xef\x16*\x1f\x98&\xe5,H1;3{2D/\x14*\x18#]\x1aa\x10\xad\x05\x90\xfb\xe8\xf2\xdc\xebm\xe6\xdc\xe2:\xe1G\xe1\xd0\xe2\xa1\xe5n\xe9\xbd\xed\xa8\xf1.\xf5\xe9\xf8\xca\xfda\x03j\x08T\x0c&\x10\xa5\x146\x19\xa8\x1c\xac\x1em\x1f\xd5\x1eM\x1c\xb6\x17\x04\x12\xcc\x0b\xe2\x04D\xfdB\xf5\xe3\xed\xfb\xe7\xdc\xe3\xf8\xe05\xdf\xc1\xde\xf9\xdf]\xe2\x8d\xe5o\xe9\xb4\xed\x0e\xf2\xd6\xf5_\xf9o\xfd\x9e\x01\x7f\x05\xfd\x07n\tt\x0b\xbb\rR\x0fP\x0f\xdd\r\x07\x0c\xb5\t\t\x06.\x01\x95\xfc0\xf8a\xf3\xec\xed\x07\xe9\xa3\xe6|\xe6\x17\xe6j\xe5\xbe\xe5&\xe8"\xec\x8c\xef\x9f\xf2\xc5\xf6$\xfbA\xff\x96\x05U\x12\x97%\x898\xabDdK\xefR,\\\x7fax^WUmK\x8c?X/b\x1d\n\x0f\\\x04\xa4\xf8\xef\xe9\xb2\xdc\xc6\xd5\xf9\xd2\xc8\xceh\xc8h\xc3\x10\xc11\xc0\xf7\xbf\xad\xc1\x9a\xc7J\xd01\xd9g\xe2\x0b\xee\xd1\xfdP\x0e \x1b\xd3#<*\xe7.\x061\xab0O-\xd4&\x1d\x1e\xd4\x14\xb8\x0b\x07\x03\xaf\xfa\x90\xf3\xb3\xec\x8e\xe5h\xde\xa5\xd8\x9b\xd4\xa8\xd0F\xcc$\xc8]\xc6\xd3\xc7\n\xcc^\xd2x\xdb\xca\xe6[\xf2=\xfdt\x08#\x14*\x1e\xae$\xfb\'\x87)\x9f)\x8c(\x17&\x9e"T\x1e\x97\x19\xcd\x14\xcc\x0fr\n\x88\x04\xc7\xfd\xed\xf6\x99\xf0\xb9\xeaY\xe5\x1b\xe1\xfd\xde\xc2\xde\xe8\xdfv\xe2X\xe7\t\xee\xfd\xf3\xd5\xf8\x02\x00<\r\x1a\x1c\xb5$\xb4%\xdb%\xe8)\xab-p+\xc7$\xf3\x1eY\x1an\x13=\nl\x03\x1c\x01\x97\xfeh\xf7\xaa\xed\x9e\xe62\xe4h\xe2\x01\xde_\xd9g\xd8\xbb\xdb1\xdf\xff\xe1\xc0\xe7\xaa\xf1\xd9\xfb\x14\x01&\x03\xf1\x07=\x0fO\x14^\x13\x07\x10\xf9\x0e\xf5\x0el\x0c\xef\x07\x12\x05-\x04\x9e\x01\x0b\xfb\xdb\xf3\xbb\xefS\xed}\xe8\x89\xe0\x91\xd9\xfd\xd6A\xd8\xcb\xd98\xdc0\xe1L\xe8\xe0\xef\xf1\xf5\xad\xfby\x01\xd7\x05\xef\x08\x18\n\xa8\n\xb8\x0b\xb6\x0eI\x14\x93\x1aO%\x116\x08H\x90S\xbeV\xd6WVX\x86R\xf6C(4\xa2(\xb6\x1d)\x0f>\x01D\xfbx\xfa(\xf6\x1e\xed\x90\xe3l\xda\xac\xd1\x84\xc8\x97\xc1\xa1\xbf\xa1\xc1\xf9\xc6\xe0\xce\xda\xd8\x14\xe6i\xf5n\x02\xdb\n\x9c\x0f$\x12\xe7\x132\x15\x98\x15\x06\x16\xe9\x16\xef\x18\x1e\x1b\xc3\x1aM\x18K\x15+\x10N\x06-\xf8\x16\xea\xe8\xde\xcd\xd5\x17\xce\n\xcak\xcb\x92\xcf\xa9\xd3\xea\xd6W\xdb\xb9\xe0\x11\xe51\xe8\x9e\xeb\xac\xf0\xe7\xf6\xcf\xfe\x9a\x08\xcb\x13\x0f\x1e\xe2%\xc1*\xc7,w+\xa0\'\x94"\xdb\x1c\xa8\x16U\x10\x95\nd\x06\xa8\x03K\x01\xb0\xfd\x9d\xf8-\xf3\x9a\xeeE\xea\x9a\xe6\x03\xe4\x92\xe3\x17\xe5)\xe8s\xedz\xf5P\xfeP\x06\xa7\x0b\xf5\x0e4\x11_\x13\t\x16_\x17U\x19\x02\x1e/%\xb5*\xc7+5) $\xb0\x1b/\x10\r\x04/\xf9\xc2\xef]\xe7\xc2\xe0\xa8\xdc\x99\xdb\xa1\xdb\xac\xdcK\xddu\xdd\x0b\xde\xb9\xde\xb4\xe1\xd3\xe6\x0e\xef"\xf9\xb9\x02M\x0bB\x12\x8f\x17[\x1a\x90\x1a\x1b\x19\x1e\x16\xda\x11\xa2\x0c~\x08\t\x06\xf8\x03\xc1\x00\xbf\xfb\x84\xf5\x87\xee\xd9\xe7k\xe2R\xdf\x1d\xde\xd8\xdd\xf9\xdeA\xe1\x96\xe5m\xeb^\xf0l\xf4r\xf7\x03\xfb\x87\xfe\x03\x02\x94\x06\xa2\n\xc2\rZ\x0f\x0b\x10=\x11\xd5\x12\xbe\x13~\x13\x11\x10I\x0c\x02\n\x8e\x08Q\rf\x1e\xd55\xf9B\xc9;,+_$\x07&\x0c#d\x1b\xd8\x17\xe1\x19\x8a\x18\xd6\x0e\x11\t|\r\xef\r\xc7\xff\xb0\xe9g\xda\'\xd6\xa9\xd6\x83\xd8#\xdf\x9f\xe6\x90\xe8\x04\xe5\x88\xe1\x92\xe3\xfb\xe9\x8a\xed\x0e\xec\x9f\xeaX\xee\x97\xf7\xfa\x02\x04\x0eg\x163\x18\x06\x13\x9b\x0c\r\t\xca\x07\x86\x07\xa2\x06\xc4\x03\xa5\xfe#\xf9\xe3\xf7l\xf9\xc4\xf8\x9b\xf3\xc5\xeb:\xe4\x15\xdfR\xde/\xe2\xe2\xe89\xefX\xf3L\xf5k\xf7z\xfb\x93\x00\x98\x04\x07\x07\xd8\x08\xd5\n\xd0\rS\x12\xe6\x17\x89\x1b\xaf\x1a\x1e\x15\xdf\r}\x07\xde\x025\xffU\xfc#\xfa\x1c\xf8\x8b\xf5\xf0\xf3\x0b\xf4@\xf4)\xf2I\xee\xe5\xeb\x1a\xec\xbb\xee\xc3\xf3\xe7\xfa\x08\x03\x88\x08\xb2\n\xc3\n\xa4\x0b\x18\x0e\xf8\x10\xe4\x12\xe1\x13\xf0\x15\xfb\x18\x1d\x1au\x19\xd9\x17\x0b\x16\xe1\x10\xe0\x07\x85\xff\'\xfaI\xf7\xcf\xf4\xc9\xf2^\xf1\xa7\xef\xd1\xec\xc2\xe9\x1c\xe7\xd7\xe6\\\xe8\x05\xebd\xee\xc9\xf3\xd2\xfaU\x01c\x05\xaf\x07\xce\t6\x0bL\x0b\x8e\n\x80\nh\x0bG\x0c\xfb\x0b\xa0\nW\x08\xf3\x04C\x00\xcd\xfa\xa9\xf6\xb3\xf4=\xf4j\xf4\xa4\xf3_\xf3\xd7\xf3T\xf4\xc3\xf4\xc3\xf3K\xf3\x10\xf4\x91\xf6\xb1\xf9\x9d\xfc!\xff\x8d\xff\x18\xff \xfdQ\xfc\x10\xfd\xd4\xfcn\xfc8\xfb`\xfb\xba\xfbD\xfae\xf7\x98\xf5\xb2\xf5\xf7\xf5V\xf5\xd1\xf5w\xf88\xfc\xb1\x03y\x15D.\x01=\x9c7\xc6\'\x7f#(+\x821\x0f3\xe17uB\xe0@s.\xb7\x1bR\x167\x13x\x03\x02\xf0\x83\xe8\'\xecX\xec\x12\xe6\x9e\xe2\xa5\xde\xb9\xd4:\xc6\xfe\xbeo\xc6-\xd6,\xe4t\xebJ\xf0v\xf5=\xf9G\xfb\x16\xff0\x08\xbc\x10.\x16\xe2\x1b\x81"?\'\x9b&3!\x1f\x18:\r\xc1\x047\x02*\x03o\x02\xac\xfdP\xf5!\xeb\x9d\xe0\xd1\xd8k\xd5\xd4\xd5\xb2\xd7\x97\xd9F\xdc\x92\xe0\xc9\xe5\x05\xe9k\xea\xc8\xec\xe6\xf1,\xf8g\xff\x1c\x08\x1d\x11\x1b\x16/\x16h\x14;\x13\x8e\x12F\x12\x8f\x12)\x13}\x12I\x0f"\n\x88\x04\x16\x00\xca\xfc~\xf9R\xf6\xd6\xf4O\xf5\x82\xf5{\xf5\xbb\xf5\x04\xf7\x9c\xf7\xaf\xf7\x18\xf9\xa1\xfc\xcb\x00y\x04\xf6\x06e\t\'\x0b\xc4\x0c\xfd\r\xda\r\xb9\x0c\x8c\x0c}\x10\xc2\x15\x80\x18d\x16\x92\x11\xcc\x0b\xb9\x05\xc0\x01_\x01\xce\x02\x13\x02\x94\xfd\x0b\xf8\xda\xf3\xfb\xf0\x18\xf0w\xf0\x19\xf1C\xf1D\xf1n\xf2L\xf4P\xf6j\xf8$\xfa\x04\xfb\'\xfc\x88\xfe\xc7\x01\xe8\x03\x10\x05\x9f\x05:\x05\xe9\x03]\x02#\x02\xb4\x02\x9a\x02\x0b\x01\xa2\xfe@\xfc\x17\xfan\xf8(\xf7\r\xf6R\xf4\xe6\xf1:\xf0\t\xf0\xce\xf0(\xf1p\xf1\xea\xf18\xf2\xb3\xf1\x9f\xf1\xb3\xf3c\xf7P\xfa\xe8\xfb0\xfd\xda\xfe&\x00\xeb\xff\x0b\x00\xdd\x00u\x03]\x06\x12\x08\x9b\x08\xef\x06\xfb\x057\x058\x04\xd7\x07\xf7\x16\x801e?\'3\x0b\x1a\x1e\x10,\x1a\x92$\xad+R7MA\x1d5\x8d\x16\xeb\x04I\t\x9a\x0e\xcf\x05-\xfdi\xff\x89\x01\xbb\xf6\xda\xe7^\xe2\xae\xe2z\xdc\xa8\xd2S\xd2\x0b\xe0\x08\xee-\xee\x15\xe6\xd8\xe1z\xe3S\xe5;\xead\xf8X\x086\rq\t\\\x08\xee\x0cn\x0e\xdd\x0c<\x0f\xaf\x14q\x16\xd2\x11\xca\x0e\xb1\x0e\xe2\x0b\xae\x01@\xf6x\xf1\x80\xf1)\xf1U\xefT\xee+\xec?\xe4\xab\xda\xf6\xd7O\xdd]\xe4\xc4\xe8\xa5\xecq\xf0\xb7\xf1\x92\xf0\xa2\xf1y\xf7;\xff\xef\x05S\x0b\xfb\x0fY\x13\x81\x13x\x12\xa1\x12?\x14\xb7\x15@\x16\x0f\x17\xf5\x16\xc8\x13#\x0e>\t]\x06\x9f\x03(\x01:\x00,\x00\xaf\xfdH\xf9\xfa\xf5"\xf5\x88\xf4\x8c\xf4\x81\xf6\xfb\xf9\xc4\xfb\xa9\xfb\xb9\xfc\x05\xff\xa2\xfe6\xfe\x8a\x01Z\nZ\x0f\xa8\r\xff\ni\t\xd5\x07\xa3\x04\x1b\x08r\x0fw\x11J\x0b\xac\x03\xcd\xff~\xfc\xbc\xf9c\xfcM\x01\xff\x00\x87\xfb \xf7\x9f\xf6\xe7\xf5\xd6\xf4\xa7\xf6$\xfaN\xfb\xb2\xfa\xf9\xfb\xe3\xfd\xc2\xfd\xdb\xfb\xd0\xfbk\xfd\xe5\xfe\xa9\x00\xed\x02\x0b\x04\xd8\x017\xfe>\xfc\xc4\xfc\xb5\xfew\x00Z\x00T\xfe\xcb\xfa\x89\xf8\xee\xf7\xd0\xf8\xf0\xf9\xd8\xf9|\xf8}\xf6"\xf62\xf7\xab\xf8]\xf9R\xf9K\xf9A\xf9f\xfa\xf2\xfc%\xff\x94\xff\x9c\xfem\xfd\xef\xfc\x84\xfc\x97\xfdJ\xff\x08\x00\x8b\xfe\x0c\xfc\xab\xfb\\\xfbM\xfb\x9b\xfa\xff\xf9Y\xf7\x94\xf2\x1c\xf4\x81\xff\xd0\x11N\x1f\x0f d\x14\xa0\x03\xf4\x02\xc9\x17\xa15\x88Aa8\xc0+\x9c!\x02\x1a\xd9\x15b!\xcc2F19\x1b=\x07\xe7\x06\x94\x07\x0e\xfd6\xf2\x9c\xf4$\xf82\xee\xf2\xe2n\xe4\x11\xe7\xd9\xda\xb8\xcb\x88\xd0\xd9\xe3A\xee\xc0\xe9S\xe5z\xe6\xa7\xe3\xcb\xe1n\xec\xf0\x01\xd7\r\xf2\x07J\x00w\x01\xfd\x05R\x06R\t?\x13\xad\x1a\x13\x15i\t\x93\x03\x90\x03@\x02\xf5\xfe\xd6\xff\x95\x02[\xff\xb0\xf5\xc0\xed^\xeb2\xea-\xe8\x81\xe9\xd1\xee\xeb\xf1\xac\xed\xe3\xe7?\xe7\xac\xeam\xee\xd0\xf2\x0f\xfal\x00\xb1\x00p\xfd\xf9\xfc\x11\x01\xab\x05\xa3\t\x80\x0e\xec\x12]\x13\xa9\x0f\x96\x0cQ\x0cm\r\x9a\x0ek\x10\x94\x12\x19\x12\xb1\rf\x07<\x03\xa3\x02Q\x04\xcf\x05e\x06U\x05t\x02I\xfd\xf2\xf8O\xf8\xeb\xfa8\xfd.\xfeg\xfeI\xfe`\xfbP\xf8\xb3\xf7\x84\xfa\xe0\xfd\xf8\xff\x0e\x03\x8d\x04\x95\x03\xe6\xfe\xb6\xfc\x9d\xff\xe4\x05W\x0c\xaf\x0f\x9b\x0e8\x08X\x01\x1e\xff\x9d\x02\x97\x08 \x0cx\x0b\x7f\x05\xf4\xfd\x0f\xf9\xaa\xf8h\xfbs\xfd\x95\xfey\xfd\x15\xfa\xa5\xf6\xc7\xf4\x00\xf6X\xf7m\xf8\xb9\xf9\xf6\xfa*\xfb\x15\xfa4\xf9*\xf9\x88\xf9\x93\xfa\xd7\xfcF\xff\xad\xffu\xfd\xe0\xfa\xb0\xf9\xb4\xfa\x9e\xfc\xb7\xfe\xcf\xffJ\xfe\x9e\xfbg\xf8l\xf77\xf8\xad\xfa\xe5\xfc\x9d\xfc\x8a\xfb\xc6\xf8\xab\xf7@\xf6\x86\xf6m\xf8F\xfaz\xfc\x94\xfc\xd2\xfc%\xfc*\xfa\xb0\xf8.\xf9\x98\xfc\x19\xfe\x0b\xfe!\x06\xaf\x15\x02\x1d\x13\x0fn\xff\x9c\x05u\x19\xc3\'&//6\xf3/X\x17\x82\n\xcd\x1c\xa55A7F-x(B\x1d\x07\x07b\xfe_\x0f\xfd\x1b\x06\x0f\xe8\xfd"\xfb\x13\xf7~\xe7P\xdf\xb3\xe7\xbb\xed\xf0\xe3\xf3\xdc\xc4\xe2\xb2\xe5\xeb\xdb\xd2\xd3\xec\xda\x81\xe6\x9d\xec\x8b\xef\xeb\xf3\\\xf4\xea\xedG\xebe\xf4W\x03\xff\r\xae\x0eF\x08$\x02w\x00\x05\x04$\x08\x9a\x0b\x0b\x0eY\x0c\x90\x04\x83\xfc\x01\xfb\xa3\xfcE\xfal\xf5\x85\xf5\x89\xf8\xc7\xf6/\xf03\xec\xcd\xeb\x11\xea\x9f\xe8\x16\xed1\xf5\xae\xf7\x08\xf3\x8f\xef,\xf1\xad\xf4>\xf8q\xfe\x00\x06\xbf\x08\x97\x05\x17\x03\xb1\x05\x1e\nu\x0c\xa6\x0es\x12"\x14\xe4\x10\xcb\x0c\xdb\x0c?\x0f;\x0f\xef\r\x91\x0e\x05\x0f\x9d\x0b\x83\x06\x12\x05(\x06\xba\x04\xba\x02\x03\x03\xe4\x04f\x01y\xfbM\xf9\xeb\xfa\x8f\xfaL\xf9\xdb\xfc\xc9\x02^\x00\x93\xf7P\xf4y\xf9\xd0\xfee\xff\xd0\x02X\x07\x07\x05m\xfd\x86\xfc#\x03\xc5\x07+\x06[\x06\x0f\t\xb3\x06\x12\x01\x00\xff\xf9\x02?\x04\xdc\x01r\x01}\x02&\x00\x9e\xf9\x10\xf7\x1c\xf9\xc4\xfa\xf2\xf9\x08\xf9\xd3\xf8\x8b\xf5f\xf1-\xf0\xc2\xf2\n\xf5(\xf5\x0c\xf5S\xf4\xff\xf26\xf2\xa7\xf30\xf7\x0f\xf9Q\xfa\xbd\xfaQ\xfb\xfd\xfb\xc4\xfc\x05\xff\x9f\x00\x94\x02\x89\x03%\x04\xcb\x03\x10\x03(\x03\xb7\x03\x99\x04W\x05\x12\x06\x86\x05,\x04r\x02c\x02\xa4\x02L\x03!\x04\x8c\x04\x03\x045\x02o\x02\xa5\x03V\x04C\x04\x1d\x04\xdc\x04[\x03\xc8\x03;\x05\xe6\x06\xa4\x07\xbc\x07\xcf\n\xfe\n\\\x0c\x84\x119\x17\x8f\x16\xce\x0f\xef\r\x9d\x12\xe5\x15\x99\x16m\x19q\x1b\xa0\x16\xe0\x0bk\t1\rI\x0e\xe9\n\x0c\x08\x9c\x08\xc8\x02\xe5\xfa\xd0\xf7u\xf9\x9c\xf8\x07\xf3\xf5\xf0%\xf2\xf2\xf0,\xec\x81\xe8\xf8\xe8\xda\xe9E\xe9\xb9\xe9*\xed\x80\xef\xfb\xec\xba\xe9\xcf\xeaQ\xf0\x86\xf3\xd3\xf4V\xf7\x90\xf9P\xf94\xf7\xbe\xf9J\xfe\x12\x00\xa0\xff\xe4\x00\xe7\x03\xa8\x032\x01\xe1\x00\xb8\x02\xd4\x02\xf6\x00\x88\x01\xfe\x03\xc6\x035\x00\xc5\xfdt\xfe\xa2\xfe\x03\xfe`\xfe\x80\x00\x98\xffz\xfc\x11\xfb\xcf\xfcf\xfe\x0f\xfe\r\xffg\x00\x1d\x00L\xfeH\xfe\xfb\x006\x02\xe7\x01\xe7\x01S\x03\x02\x04A\x03\xed\x03a\x05\xf4\x05\xcf\x04\xe4\x04"\x070\x08\xf1\x06\x98\x06\x9d\x07_\x076\x06\r\x06\xa0\x08\xdd\x08\x80\x06\xfe\x04\xc5\x04\xdf\x03E\x02\xf6\x01\xde\x02\xb6\x01\x94\xffX\xfe\xbf\xfd\x97\xfcd\xfb\xf6\xfbE\xfcd\xfc\xc9\xfb\x0c\xfc\x81\xfb;\xfa\xe2\xf9I\xfa\xa3\xfbX\xfcc\xfd\x9b\xfd0\xfd\x12\xfcP\xfb\xe9\xfb\xc7\xfc4\xfe \xff0\xff\x92\xfe7\xfd\xa9\xfc\xfe\xfcA\xfd\xcd\xfd\xc9\xfd\xa6\xfd\xbc\xfc;\xfbj\xfa\x9d\xf9\xfd\xf9[\xfag\xfb]\xfc\r\xfcP\xfa\x96\xfa\\\xfa*\xfbA\xfd\x01\xff}\x01s\x00\xf9\xff\x07\x00\xe4\x00j\x01\xa3\x01\xa1\x02L\x04\x81\x04\xdb\x03*\x05\x05\x05\xb1\x03N\x04\xc3\x05:\x06\xeb\x04\x89\x05\xc9\x07F\x07+\x07\x0e\x07i\x06?\x05\x81\x06B\x08\xbf\x08\xd6\x06\xcd\x030\x04l\x06m\x06\xbc\x052\x07\xb5\x06\xb5\x01o\x00\xff\x03X\x01\xba\xffO\x03\xb0\x06t\x02]\xfb\x9d\xfd\xf7\xfe\xe5\xfd\xf3\xfc\xa5\xfe\xfe\xfe\xb2\xfc\xf0\xfb\xeb\xfe\xa2\x02\x88\xff\xa7\xfb{\xfd\xe1\x00\r\x01Z\xff\xb9\x01\x0c\x04B\x02\xdc\xff/\x02\xee\x03p\x00\xcb\xfep\x01\xed\x02\x1a\x00x\xfd&\xfeG\xfe~\xfbF\xfb/\xfc\xc8\xfa)\xf9\xec\xf8\xcb\xf9\x1b\xfa\xdf\xf9\x84\xf9\xf0\xfaz\xfa \xf9W\xfa4\xfc \xfd6\xfe\xd3\xfe\xfd\xfe\xa4\xfe\xbf\xfc?\xfe\xc3\x00n\x01\x0c\x01l\xff\xb4\x00Q\x02"\x01\x7f\x00~\x00\xde\x00\x1b\x00&\xff\x9b\x019\x02\xd1\xfe\xd4\xfd\xba\xfe&\xfe<\xffr\xffK\xff\'\xfe]\xfd\x10\xfeK\xfeZ\xff\xb5\xfe\xb2\x00\x1a\xff-\xfd\x1f\xff\x92\xffS\xfeg\xff]\x01M\xfd\xd3\xfb\xce\xff,\x02\x12\xfe\xb6\xfe\xd3\x00%\xfeo\xff\x9e\xff\xa0\xffN\xfe\x9d\x02\xfa\x00\x00\x01\xf3\x03/\x00\x92\x00\xaa\x05\xa8\x03\xc8\xff\x98\x01\xce\x04\xbf\x05/\x00U\x06\xdd\x06\x12\x00\xb4\xffq\x02\xee\xff\xb9\x00\xfe\x03\xc5\xffN\x00\xa3\xff6\xff\xc3\xfb?\xfeX\x00\x16\xfd\xde\xfeg\x02\x9a\x00\xce\xfb\xc6\xfc\xdc\xf9W\xfd\xf5\x01b\x00\x00\x03I\xfe\x17\x01\xcb\xfe\x80\xfb\x93\xff\x8f\x002\x08!\x01s\xfd\x0e\x05\xcc\x04\x04\x00\xa3\xfd\'\x07~\x04\xaf\xfe\xa2\x01\xfa\x04X\x04\xf0\xfe_\x02\x1c\x05\xcc\x01\xd3\xfb\xee\x07\x10\x05\xa3\xf8B\xfe\xbb\x04\x90\x03\x9b\xfbW\x06\xc9\x03\xa3\xf5V\xff9\nG\xf9?\xfc\\\n\x90\xfc%\xfc\xd4\x028\x05\x0f\xfco\xfa\x8b\x04\x93\xfe\x1a\xfd%\x04\xba\x04/\xf9\x98\xfeJ\x00p\xf9\x86\xfe\xc0\x00\x04\xfe3\xfd\xcc\x01{\xfd\n\xfa\xba\xfe\x87\xfe\x90\xfa\xcb\xff;\x02\xb6\x00R\xfdx\x01\xd2\xfd\xb7\xfd$\xff\x98\xfd\xb4\x04\x9b\xfen\xfd\xed\xffK\x00\x1a\xfe=\xfe\xe7\xfb\x98\xfeI\x01\x87\xfdj\xff\xec\x03\xf1\xfd~\xfa\xea\x02\x00\xff\x10\xfa\xf5\x00\x1a\x01\x00\xffL\x01\x92\xfe\xc9\xff.\x04\x04\xfc\x8f\xfd\x96\x02I\xfe\x97\x06\x99\x00x\x02\x93\x01\x1d\xfe\xc9\x00F\xff\xca\x04\xf7\x04y\xfe\xef\xff]\x03\xe0\xfa\xd0\x01\xbe\x00&\xfd\x01\xff\xe3\xfc\xaa\x02r\x00S\xfc\xd5\xf8G\xfb\xf9\x00\xbd\xfb\xde\x01\xd9\x03\x88\xf9J\xfbj\xff\xd2\xfb\xc2\xfa|\x03\x15\x04\xcb\xf9=\x00|\x03W\xff\xea\xfb\xcb\x00z\x01\xa7\xfbU\x04\xaa\x07\xed\xfa\x96\x01\xfd\t\xca\xfb#\xf7\x1b\x06\xd6\x03\xe8\xfd\x1f\n\x80\x00\xf5\xf6\xef\t\xf5\x04\x8f\xf99\x02\xb4\x02g\xff,\xfa\xb6\x0bX\x0c\xbd\xf8a\xff[\x04\x16\xf3\x1e\x00\x95\x0e\x83\xf9\x87\x06P\x06\x07\xfc\x1c\xf9\x04\x01\x93\xfe\xd4\xfc\xfa\x06\xfd\xf9>\x02\xc8\x05\x17\x01E\xfa\xa9\xef\xa7\t\x9e\xfd,\xf8t\x0e\xfb\xfa\x1a\xfa\x93\x02\x0e\x01\x1a\xf4\xce\x03\xfa\x021\xf7\xfe\x00\xcb\r~\x01\xc8\xf6\xa5\t}\xfb\xd4\xf0\xa3\x0c\xf0\x07\xc5\xf7\x04\x14R\x02B\xf7\xf3\xfc\xf1\x05\x83\xfb)\xfcJ\n\xea\xfeT\x02v\x01N\x05[\xf8n\xf2\xdf\x01\xdd\x040\xf9\xc2\t9\n\xc8\xf0-\x01\x17\x08\xa9\xf6C\xfeF\x05k\x02\x99\xff\n\x03A\x06\x9b\xfb\x80\xffv\xf7\x07\x02\xf5\x00\x96\xfb\x89\x0c\x06\xfc\xd2\xf6\xec\x02H\x01$\xf7\x0b\t\xf5\xfc\x10\xfcB\x00\xa9\xfc\x0c\x07\x10\xfb\xa3\xfeO\x01\xc5\x04\xaa\xfa\x8b\x02\x8b\x07\xa8\xf8w\xfd\xf1\x03\x07\xff7\x04\x95\n\xa4\xfbs\xfa\x91\x02\x8a\x02\xc2\xff\x95\xfd@\xffx\x06v\xffh\xfc\xcc\xfcM\x04\xb1\xfb\x16\x00\x9a\xfe\x0c\xfbx\xfar\x03\x1e\x06}\xf8Z\x00:\xfd\x1d\xf9\xa3\x00c\x05\xe6\xf3@\x06?\x08\xf4\xf4^\xfe\xd0\x03\xb7\x04\x19\xf9\x8b\xfd\xdf\x05\x00\xfc\xe5\xfe\xd7\n\x85\xfe\xc3\xfc\t\xfe\x18\x00\xfc\x02\xfd\xffz\xfb.\x0c\x7f\x03\xeb\xf3\xf4\x02\x06\r\xfc\xf7D\xf0\x1a\x15\xaf\xfc\xad\xf9\xd3\x06\x0f\x07[\xff\x1b\xf9o\xff\x87\xff\xc1\x01\xac\xfb$\x0b.\xff\x7f\xfe\x8b\x00\x17\x04\xb5\xf7@\xf6\xcf\x0f\xe6\xfe\x14\xf5b\x05e\x13\xf8\xee\xbf\xf1\xbe\x18\xbd\xf9\x15\xe7\x85\x13\x04\x0b\x81\xed\xa7\x05\x00\x0b\xbc\xfc\x8c\xf2l\x06\xbb\x00~\xf3H\t\xa2\t\x14\x00\x8a\xf8D\x05)\xff\xb0\xe8\xa4\n\xee\t\xb3\xf1\\\x0c~\x08\xd2\xef\x0f\xfd}\x03P\xfb\x91\xf7\xce\xff\x17\x0b?\x01g\xf7\x9c\x08V\xfb\x01\xf4\xff\x08K\xf8\x18\x03\x81\x08\xce\xf77\x02o\t\xa1\xf5q\x03\x13\xfd\xee\xf6\'\x0c_\xfeh\x03\xe8\x06\x14\xfa\xf4\xff\xb5\x04\x1b\xf6\xd9\x07\x18\x03A\xf8%\x039\x087\x03o\xf9\xf6\x07\x8f\xfd\xd1\xf8\xb8\x01-\x03\xe2\x00d\x03\xb6\x07\xe0\xf7\xb8\x01\x18\xff\xb5\x00\xa1\xfb3\xfd\xa2\t?\xf9j\x02\x8d\xfd\xc4\x06\xeb\xfci\xf4\xf6\x02\x88\x03\xe7\xfd~\xfe&\x01\x11\xff\xc1\x03[\xfd\x16\xf8\x99\x03\x9b\x03^\xf8H\x06\x8f\xfe\x9f\xfc\xa8\x073\x01\xb0\xf2?\x0b0\x07\xee\xe9\xfa\t{\x06\xa5\xffT\xfe\xa8\xffd\x05\xfe\xfb\xfc\xf9\xcc\x04M\x000\xfcW\x06\x8b\xfb\x9b\x03\x13\x00\xcc\xfbF\x010\x01L\xf2Z\n\xa3\x04~\xf7Z\x07\xd0\xff\xcb\xfc\xb5\xfd(\xfd\xe1\xfc&\x04/\xfd\xe5\x06b\xfc\xa5\xfcR\x08\xa8\xf7f\xf6\\\x0b\xf2\xfeM\xf9s\x07\xfd\x07\xfe\xf9\x08\xf7\xf3\n#\xf9\xaf\xff\xfd\xfe\x8f\r\x7f\xf8q\xfa\xe5\x11\xb9\xf7\xf9\xf3\x1a\x02\xa3\r\xe3\xf3o\x04\xbb\r\t\xf7\x7f\xf8\xcd\x05\x00\x009\xf7\xe8\x05\xc1\x06\xc5\xfd\x9b\xfb\x8f\n\x11\xfdC\xed\xf8\x0c\xf5\x02\xc1\xf3\x14\x06\xb0\n\x1e\xf8\xfa\xf7\xe1\n\xdb\xfe\xbb\xf1\x9f\x01\x11\x0e\xd0\xf2\xff\x00\xc6\x0f\xcc\xf7:\xf7\xc3\x0co\x00\xe4\xefL\x058\x11d\xf3\x07\xfb\xe5\x1f\xd3\xe8\x04\xf5\xfc\x1ah\xf1\x08\xf8\xaf\x0c\x80\x03\x08\xfa6\xfb\x0b\x0b\xb2\xfc`\xf6\t\x08\x95\xfc\xdc\xf3\x90\x07\x1c\t\x9f\xed\xaa\t\xde\x02\xa3\xf4\xef\x008\x04}\xfb\xd2\xfeF\x070\xfb@\x02\xf9\xfe\xf3\x04\x8b\xf7c\x07k\x01Z\xf9O\x03\x0e\t\xc6\xfa\x91\xfcr\n\xc6\xfa\xbd\xfeX\xfc%\x07\xfa\xfcC\xfc!\t\x18\xfdX\xf8V\x0b\x1e\xf7e\x01\xc2\xf8#\x08g\xfe\x05\xf8y\x0c\x95\xf7\x1a\x07\xeb\xf2\x92\x04\xec\x00\xf8\xfd\xcf\x01\x9b\x03\x00\x01a\xfb\xc3\x01Z\x04\xaf\xfa<\xfe\x11\x08=\xfd\x8c\x00&\xfbQ\x08\x9f\x02\x0c\xfa\x02\xfc\xb5\x06\xee\xfd\xfa\xf3g\x0e\x91\xfe\xdb\xf7\xbd\x07\xda\x04j\xf3\xf2\xff\x16\r\x1e\xf35\xf8,\x12\x00\x02!\xf4\r\x07\xba\x07\x03\xf2\x0b\xfa\xe6\n\xda\xffS\xffC\xfb-\x0b\xb1\xffO\xf6:\x03\xcc\xfe\x8e\xfe\xb7\xfd\x82\x04\xed\x00s\x08\xf5\xf3\x14\x02\x81\x07d\xf1/\x03\xee\x05d\xfe\'\xf9\x91\x06\x05\x06\'\xf7\xfc\x00<\x01\xa6\xf8-\xfd\x03\x0b\xbe\xfc)\xfd\x80\x051\xf9\'\x01`\xfdt\x04\x87\xfb\x00\xf9c\x0c\x00\x04&\xf0\xe4\t:\x05M\xef`\n\xf6\x00\xbb\xf8A\x01\xf1\x07Y\xfd\xe5\xf9\x15\x07\x9a\x022\xf2\xbc\x076\t\xd6\xef\xac\x03\xf9\x11U\xf3\xa5\xf7\xab\x0f3\xfe+\xf1\x13\x07\xdf\r\x0b\xf5}\xfa\x16\r\x07\xfe\x8e\xf1\xbd\t\xdb\x07\xe9\xf8\x7f\xfa\x95\r\xd1\xf9B\xf6\xd5\r8\xfd\xf7\xf5\xc2\x05\xad\x07\x82\xf6~\x03d\n\xa3\xf2\xa8\xfel\x00\x85\x07j\xfa\xee\xfb\xaf\x0f\xd1\xf4n\xfc\xbf\x06\x86\xfe\x1d\xfa\xeb\x035\xff\xda\xfd\x93\x02\xd6\x00\xe3\xfeZ\xfb\xfa\x01\xa7\x03m\xfeO\xf3\xa5\x10\x96\xfe\xa5\xf2s\t\x9f\x04\xc8\xf7\xd7\x02\xfb\x03\x0e\xfby\x02\r\xfa\xf8\nx\xfa\xba\xff\x85\x0b3\xf6\x1f\xff+\x04X\x00\xc1\xfa\xd7\x05\xb0\x01P\xfdp\x03\x1f\xfdr\x03e\xfa.\x01S\x01 \xffR\x00\xc1\xfc\xe9\t\xb4\xf9\x7f\xfd\x0b\x02\x9e\xffD\xfaq\x05<\x01V\xf5\xf2\x0bp\x01\xfa\xf7\xe9\x04%\xff5\xf8\xe9\x06\x03\xf88\x06l\x04\x82\xfb\xaf\xff\xad\x02=\x02\xd5\xf2\x98\x07\xa7\x07\x94\xf1\xeb\x02n\t\xab\xfa;\x01L\x04C\xfaJ\xfd-\x008\x00\xd8\x01$\xfc\x02\x06\x1e\x02\xc9\xf8^\x05|\x01G\xf8&\xfb\x99\x08D\xfd\xce\xfa:\x13.\xf5\xf5\xfcU\x08\xf3\xf6y\xfeT\x01\xf2\x07\xe6\xfcP\xff\x0c\x07\x02\x02\xde\xf4\xa1\x04\x17\x01k\xf8s\t_\xfe\xab\x01\xcf\x05\x8c\xf8\xb5\x01\x19\xfe{\xfe\xbc\x055\xf7\xc6\x08\xf9\x04Y\xf9\xaa\xfd\xa2\x08\xc1\xf6\xed\xfd\x0f\x07\xd9\xf8\x16\x08\x83\xfe~\xfc\xf0\x02\x8b\xfe\x92\xf8\xe6\x07\xd0\xf9y\xffF\x08V\xf6t\x05c\x03\x7f\xf5\xf7\xff\xd0\x05s\xf6\x98\x01\x1e\x06\x84\xff\x1a\xfd\xd2\x00\x94\x02\xb4\xf8\n\x01\xc3\x06\xce\xfa\xd4\x02\xbf\x04\x10\xff\x17\xfc\xd7\x03\xdd\x02\x0c\xf5\xd1\t\xe3\xfd\xd9\xfd\xc6\x01\x89\x00\r\xffG\xfe\x0c\xfe\xe3\xff`\x02\x86\xf7\x7f\x0bs\xf8\xfa\x00\xbd\x05\xcb\xf6\xcb\xfd4\x04\xb1\xfe6\xfe\x1c\x06l\xfb\xcf\xff\x1b\x02j\x00\xeb\xfd\x9e\xfc\xdd\x05\xbc\x03\x12\xf75\x07z\x06\xc0\xf5\xe8\xfe\xb0\x0b\xc3\xfad\xf9@\t%\x01\x06\xf8~\x06N\x06\xaa\xf5!\x05\x14\xff\x18\xf7\xbb\x07\x9d\x06\x1c\xf3\xf0\x05\x9d\x05\xb2\xf7}\x02N\xfd\x96\xfe.\x00~\x00:\x01b\x02N\xff\x1e\x00\x08\xfc=\xff\r\xfdo\x03\xe0\xfeN\x00r\x04\r\xfbu\x00\xe4\x01\xe0\xfan\x00>\x01.\xf8\xaf\x075\x01d\xff\xed\xffF\xfd\x15\xff6\x01(\x00\'\xff3\x00\x91\x01\xa3\xff\x1a\xff\xfe\x03|\x03\xa4\xfbi\xfd\xe3\x02\xb7\xfb\x94\x03j\x060\xfc\xce\x00b\x05\xd2\xf9\x02\xff\x9d\x06\xc0\xfb-\xff\xba\x06\xaa\xfa\xfe\x01\x06\x08\x89\xfa\x9e\x00;\xff\xdc\xffV\xfa\xa7\x04-\x07\x98\xf7J\x04l\x03\xcd\xfaz\xfd\x82\x05\xdc\xfb\xbc\xfad\x05\xde\x02\x11\xfbl\x04b\x02\x15\xfa\xdf\xfc\xe5\x00V\x02\xcc\xf9\x97\x060\x00q\xf9v\x06}\x00\\\xfc\xfc\xfc\x1e\x02\r\xfc\xb6\xffl\x05%\xfd\x9e\x01\xcd\xff\x02\x00\x14\xffT\xfe\xf9\xfe\xba\xfc\xde\x02\xb6\xff\'\x01\r\x04\xa3\xff\x81\xfa\x01\x02\x01\xfeo\xfa\x88\x04\x89\x00\x01\x01\xe8\xfd\xd2\x05\xcf\x00;\xf9n\x03\x19\xfb,\xfe\x99\x02S\x00\xcf\x04d\x01\x90\xffD\xff\x1b\xffr\x00\xff\xfb\n\x04<\xff\x9d\xfe\xb4\x07b\xff\xb9\x00\x94\x01\x8e\xfc\xce\xfd\xdb\xfee\x00\x1b\x00\\\x03N\x02\x12\xfe\xb1\x01[\xfdA\xfe\x0e\x00@\xfc\xc7\x00\xab\xff\xf7\x00\xc1\x02\xcc\x01\xb8\xfd\'\xfe\xf9\x00\xa8\xf8\x05\x04-\x03\xf1\xf9\xc3\x04s\x00\x16\xfe\xcf\x01\\\x02\xe5\xfcx\x01\xf9\xfb"\xfd\r\x077\xfd\x0b\x031\x02/\xfd\xc9\xff\x9d\xfey\xfe7\xff\x8c\x00*\x00\xa7\xfe\x87\xff!\x04\xa9\xff\xa9\xfc,\xff\xd1\xfd\xc0\xfen\x01\xd4\x01\x87\x00\x08\x03\xf6\xfd7\xff\xbc\x01h\xff>\x01L\xfd.\xff<\x04r\xff%\x00\xd6\x03u\xfd\xc6\xfc\xbe\x02\x9d\xfd\x1c\xff\n\x03\xcb\xfd]\xfe\xe2\x02\xbd\x00\x08\xfe\x07\x00\xa3\xff\xda\xfd(\xfe\x8a\x01\xe5\x00K\xfe\xf0\x00\xc2\x01\xb7\xfd\'\xff[\x02\xec\xfd^\xfd\x98\x02\x92\x00\xca\xffd\x017\x00\x1e\xff\xd0\xfe\xf8\xff\x1b\xff\xce\x003\x00\xd0\xff3\x01\x94\x00\xb3\xfe\xfc\xff\xc5\xff\xc3\xfc2\x02\xfd\x00#\xff\x8a\x01\x19\x00\xa3\xff8\xff\x12\x00\x18\xffC\xffT\x01\xfa\x00\xef\x01&\x00\x0e\xff\xc4\x00\x80\xff\x80\xfd\xab\x00\x8f\x02x\xfe\xbd\x01\xc1\x01<\xfe*\x00\xe7\x00\xf8\xfd\xd4\xfe=\x01&\x00\xc1\xff\xff\x00\x05\x03G\xff\xbb\xfd\xbf\x00\xdd\xff\xd8\xfc[\x00Y\x04(\xfeF\x00\x8e\x02\x88\xfes\xff\x1e\x00\xc7\xfdD\xfdr\x02\xeb\x01^\xfe<\x01\xd8\x00&\xfe\xaa\xfe\xca\xfeR\xfeQ\x00\x8e\xff\x19\x00\xc7\x00y\xff\x1b\x00\x83\xffi\xfc\r\xff\'\x00\xff\xfc\xe1\xff\x9c\x03\r\x00u\xfc\xf3\xff\xa0\xfe\x98\xfb`\xfe\xaa\xffI\xfd\xbb\xff\xcc\x01\xae\xfd\xf1\xfdO\xff\x8c\xfdt\xfd\x00\x00\x8f\x00\xaa\x00\xe3\x02F\x03\xaf\x03?\x04\x8f\x04Y\x05)\x04\xb9\x05o\x07\xfc\x07\r\tK\t\x90\x07O\x07\x0f\x06\xba\x044\x04@\x03\xd9\x02\xbb\x00\x8a\x00\x81\xff\xce\xfd\xa0\xfc\xa6\xfa\xca\xf8\xc5\xf7\xc7\xf7Q\xf8:\xf8\xca\xf7\x82\xf8\xba\xf8\x7f\xf8\xd4\xf9s\xfa\xa9\xfa>\xfc\x82\xfd.\xff\x84\x00\xfe\x015\x02Z\x02\xa4\x03\xee\x02\xce\x03\xc6\x04\xe5\x04\xac\x04\xb6\x03\xba\x03-\x03\xc8\x02\xa9\x01\x9f\x00[\x00\x04\xff\xd0\xfe\xc9\xfe\xc6\xfd{\xfd\x0b\xfda\xfb\x0c\xfc\xbc\xfc\xc9\xfb9\xfc\xb8\xfc\xcc\xfc\xf5\xfc|\xfe\x1f\xff\xc8\xfe`\xff\xdf\xff:\x00\xea\x00\x8e\x02V\x02\x08\x02\xbc\x02\xe3\x02\xff\x02?\x037\x03\n\x02\xa6\x02\xc6\x02%\x02\x8f\x02\xed\x01\xbf\x00\x00\x01\xce\x00d\x00\xa3\x00c\x00\x90\xff}\xff\xe8\xffI\xff^\xff\x98\xff\xd3\xfe\xc6\xfe0\xff\x8e\xff\xb5\xff\xfe\xfeI\xff\xee\xfe\xaf\xfe\x18\xff\x11\xff\xf8\xfe-\xff \xff\x05\xffe\xff)\xff\xe0\xfe\xe9\xfe\x08\xff\xeb\xfe\x9c\xff\xbd\xff\xfb\xfe\x80\xff\x97\xff0\xff\x97\xff\x06\x00\xc4\xfft\xff8\x00\x1a\x00\x00\x00{\x00`\x00j\x00\x07\x00\xb0\x00\xf0\x00n\x00\xa1\x00\xb9\x00p\x00\x7f\x00\x9a\x00\xbe\x00\xc4\x00e\x00\xbe\x00\x0f\x00\x13\x00\x82\x00\xe9\xff\xff\xff\'\x00\xd8\xff\xf4\xff\x0b\x00X\xff\xa0\xffT\xff.\xff\xd8\xff\x89\xff\xb0\xff\xfc\xff\x88\xff\x83\xffo\xff\x88\xff\xbc\xff{\xff\xf5\xff+\x00\xdc\xff\xef\xff8\x00\xb8\xff\x98\xff\xdc\xff\xbb\xff\n\x00w\x00<\x00`\x00}\x00\xb6\xff;\x00\x02\x00\xdb\xffo\x00K\x00R\x005\x00\x98\x00\xfb\xff\xdd\xff\xdc\xff\xb9\xff\xb7\xff\xb3\xff&\x00\x04\x00\xa9\xff\x03\x00\xb2\xff6\xff\xac\xff\x90\xffm\xff\xb2\xff\xfd\xff\xf5\xff\x02\x00\x1a\x00(\x00\xf5\xff;\x00\x1c\x00E\x00\x96\x00\x95\x00\x87\x00\xb0\x00\xd0\x00y\x00\xca\x00E\x00n\x00\x93\x00J\x00x\x00\x9d\x00D\x00U\x00?\x00\xe3\xff\x1a\x00\x0c\x00\xc8\xff\xde\xff\xbb\xff\x90\xff\x11\x00\xa1\xff\x85\xff\xdb\xff\x1d\xff;\xff6\xff\xe8\xfe\xa2\xff\x8b\xffr\xff\x96\x00\xd1\xffy\x00\xcc\x00\x93\xff=\x00\x15\x00\xfe\xff\x1d\x04\x11\x05\xf4\x03o\x03\x11\x02\x7f\x01W\x01P\x02\xc6\x02\xc3\x02`\x022\x02\xb0\x01]\x00\xda\xfe\x16\xfc\xd2\xfa\xaf\xfb{\xfc\r\xfdD\xfd\xe3\xfc\x1c\xfb\xc5\xfaF\xfbt\xfa\x83\xfb\xb3\xfb\xea\xfa\xab\xfd\xf1\xfe\xae\xfe\xbb\xff\xd6\xfe\xcb\xfd\x0f\xfef\xfe\\\xff\xc2\xff\xe9\xffg\x00\xca\x00\x9f\x00\xe6\x00g\x00t\xff\x86\xffO\x00\x07\x01\x0c\x02+\x03\x8f\x03a\x03\xb7\x038\x04W\x04\xa3\x04\xdd\x04]\x05$\x06\xf3\x06Y\x071\x07^\x06*\x05S\x04\xc3\x03j\x03\xc7\x02\x01\x02t\x01\xbd\x00\x00\x00\xeb\xfe\x9d\xfd`\xfct\xfb\x13\xfb\x00\xfb\x1a\xfb`\xfb|\xfbr\xfbu\xfb\xa3\xfb\xde\xfbs\xfcW\xfdQ\xfe_\xffR\x009\x01\xd3\x01%\x026\x024\x02\x84\x02\x06\x03\x8a\x03\xb8\x03b\x03\xb8\x02\x16\x02M\x01\x82\x00\xe5\xff\x1b\xffP\xfe\xa8\xfd)\xfd\xa7\xfcD\xfc\xb0\xfb\xf5\xfa\x90\xfam\xfau\xfa\t\xfbN\xfbs\xfb\x0f\xfc\x97\xfc\x18\xfd\xef\xfd\xee\xfe\x1b\xff\xb1\xff\x96\x00\x0e\x01\xd7\x01c\x02\xae\x02\xf8\x021\x03F\x03a\x03I\x03\xd3\x02u\x02F\x02\x12\x02\x0e\x02\xab\x01\x06\x01\x8f\x00\xff\xff\x9d\xff\x80\xffF\xff\xe5\xfe\xd4\xfe\xc9\xfe\xca\xfe\xfc\xfe\x0c\xff\xe4\xfe\x17\xffG\xff\x90\xff\x10\x00R\x00o\x00\xba\x00\xbb\x00\xb4\x00\xc1\x00\x13\x01\xfd\x00\xd1\x00\r\x01\x03\x01\xcf\x00\x8b\x00I\x00\xf6\xff\xbf\xff\x90\xffd\xffd\xffI\xff\x16\xff\xeb\xfe\xd3\xfe\xba\xfe\x8c\xfe\x83\xfe\x82\xfe\xa9\xfe\x0e\xff#\xff{\xffb\xff\x80\xff\xbf\xff\xb8\xff\x13\x00L\x00\x89\x00\xe6\x000\x01@\x01A\x01]\x016\x01\x02\x01\x07\x01\x06\x01\xdd\x00\xd2\x00\x8e\x00\x1d\x00\xda\xff\x80\xff\xfa\xfe\xf5\xfe\xcf\xfe\xc0\xfe\xdd\xfe\xf3\xfe\x06\xff\xbc\xfez\xfe\x9c\xfe\x03\xff\xfe\xffZ\x01J\x03g\x02\x10\x01\x1e\x02\x97\x01\x17\x02\xeb\x02}\x02 \x04\xae\x03\xfe\x02/\x03\xc0\x01\x1e\x00\xeb\xfeY\xfe\x9c\xfe\xda\xfe\xae\xfdZ\xfe\xa8\xfd\xf4\xfc\\\xfdP\xfc\x99\xfd\xf0\xfc}\xfb\xbf\xfdY\xfe\x9b\xff\x7f\x03\x80\x03\xde\x02W\x01\x9a\x00\xca\x01\x95\x01\xc8\x01\x83\x02\xa4\x02\x82\x01\xef\x01\x9e\x01\xb2\xfe\x91\xfc\xb9\xfa\t\xfa\xe3\xfap\xfb\x1b\xfc<\xfb\xa0\xfa\xaf\xf9\xcb\xf8\xa2\xf8\x84\xf8W\xf9\x0f\xfas\xfa\xe7\xfbI\xfd%\xfd\xa2\xfd\xc7\xfd)\xfeI\xff\xa5\x00j\x02\x07\x04\xb7\x05\x8e\x07"\tS\n|\x0c\xd3\rq\rc\r>\x0e\x1c\x0f\xb8\x0f\x10\x10r\x0f\xe3\r\xe7\x0b\xf4\t\x16\x08\xd6\x05:\x03\x87\x00\x97\xfe\xdb\xfd\x8b\xfca\xfa\x11\xf8$\xf6\x9e\xf4y\xf38\xf3\xa5\xf3R\xf4\x8b\xf4\xd7\xf4:\xf6\x91\xf7|\xf8i\xf9\xb5\xfaG\xfc\x1d\xfe\xf4\xff\xe2\x01\xc3\x03\xb5\x04\xc9\x04\r\x05\xd8\x05V\x06\x18\x06\xc7\x05Z\x05\xc9\x04\xfe\x03\xe5\x02\xd4\x01P\x003\xfeG\xfcg\xfbu\xfb\x18\xfb\xa6\xf9t\xf8#\xf8\xf8\xf7\xdf\xf7\xf1\xf7k\xf8\xd6\xf8=\xf9~\xfa\x99\xfcR\xfe\xff\xfe\x13\xff\xe4\xffg\x01\xa5\x02\xd2\x03\xcb\x043\x05\xf8\x05\x02\x06\x96\x062\x07\xce\x06\\\x05\xa4\x04\x90\x04A\x04u\x04V\x03E\x02\x97\x01\x98\x003\x00\xa5\xff\x1f\xff"\xfe\x9d\xfc\x0e\xfc\xbf\xfc\x1e\xffr\x00o\xfd8\xfc\xa8\xfc\xb2\xfc\xef\xfd\x1b\xfe\x97\xfeb\xfe\x93\xfe\xb2\x00p\x02\xcb\x02\xd3\x00X\xfe\xb5\xfe\x95\x028\x04\xad\x04\xe3\x04-\x03!\x03\xac\x034\x03`\x03G\x01\n\xff\xad\xff\xab\x00\xa7\x01\x88\x00\xff\xfd\xf1\xfb\xb5\xfam\xfa\x1c\xfae\xfa\xe6\xf9\x82\xf8\x14\xf9>\xfa4\xfas\xfa\x11\xf94\xf8v\xf9\x89\xfa\xa7\xfb\xd0\xfcp\xfc\xb0\xfd>\xfe\xd5\xfd\x12\x00h\x00<\xff\xf2\xff@\x01\x82\x01G\x02a\x01\x16\x01\xd3\x00\x97\x00\xbe\x02\xbd\x01\xa5\x01/\x01\xbb\x00\xf3\x01\x0c\x01c\x01]\x02h\x02\x18\x04\xc1\x08H\x0c\x82\n\xce\x08=\t\xb2\x08m\n\x83\x0b\xe2\x0c\xd2\x0e\x8e\x0b\'\x0b\x00\x0c\xde\x08\xd0\x05B\x02L\x00s\x00\x85\xff\xf1\xfeh\xfe\x06\xfc\x99\xf9L\xf8\xaf\xf7\xa1\xf7\xb5\xf5\x12\xf4j\xf5\xa4\xf6r\xf8 \xfa\xf1\xfal\xfb\xb0\xfa\x11\xfbB\xfd6\xff\xb9\xffr\x00\xb1\x01\x9b\x03\xc5\x04E\x05\x8c\x05\xa4\x04\xa8\x02\xca\x01\x9f\x01V\x02\x82\x01U\x00\xa7\xff\xc3\xfeN\xfey\xfc\xe8\xfa\x18\xf9>\xf7|\xf5\xc9\xf5\xac\xf76\xf9\x08\xf9\xea\xf8E\xf9%\xf9\x98\xfa}\xfb\xa1\xfd\x01\xfe\xd7\xff\x89\x05\xa5\t0\r\xf0\r\x0e\x0c@\x0bH\n\xfe\nd\x0cP\x0e\x01\r\xc3\x0b\x1d\x0e\x88\x0b\x07\x08\xf5\x02\xf1\xfb\xce\xf78\xf7u\xf9\x8b\xfb\x9f\xf8A\xf6\xcb\xf4[\xf2\xba\xf1\xe9\xf1\xd9\xf0\xe4\xef\x8f\xf0\x8e\xf4\x03\xfa\xca\xf9_\xfa\x9e\xf7>\xf6\x16\xf8\x0e\xfb \xfe\xce\xfb\xa9\xfdZ\x00[\xff\x08\xfe\x91\x01\xdd\x01\x9f\xfa\x13\xfb\x96\x01v\x009\xffR\x03H\x05\x15\x02\xf3\xfeA\x05\xba\x05\xe2\x01\xd5\x06\x9f\x08\xa7\x064\x08\xc9\x0b\x1c\n\x0e\x05\xb0\x07\x04\x08[\x05R\x08l\tK\x04\x12\x02i\x06\x1c\x05`\x04o\t\xd8\x08l\x04\x15\x07I\x0bl\n\x15\x0c\x84\x0c0\x0b+\x0b\x1a\r%\x0e\x06\r\xe0\x0b-\x08W\x06\xa4\x06\xfe\x06\xf8\x03\x82\xff,\xfd\xf6\xfb\x9c\xfc<\xfbd\xf9`\xf7N\xf5\x9c\xf3\xd2\xf3\x88\xf48\xf3\xe4\xf2\xc9\xf2\xb7\xf3g\xf5\x81\xf5\xd5\xf6\xc0\xf79\xf6)\xf6n\xf8\xcc\xfa\xc6\xfc`\xfdp\xfdo\xfd\x0e\xfe\x11\xff(\xff\xfd\xfe\x00\xfeG\xfd\r\xfe<\xff_\xfe\xe4\xfd\x0b\xfd\xee\xfb\x1c\xfc9\xfc\xa7\xfc4\xfd\\\xfdE\xfcf\xff\xef\x00\xee\x00\xf9\x02u\x02\x11\x03\xa6\x02\x16\x04\x8d\x05\xe3\x05\xf0\x06\x0c\x06\x90\x05\xec\x05\x00\x04`\x03\xf6\x01\xfd\xff[\x00o\xfd\x18\xfe@\xfe\xc4\xfa\xb7\xfb9\xf8\x88\xf6\x18\xf9\x08\xf7\xca\xf6\x96\xfa\xce\xf9\x12\xf8\n\xf9\xa0\xfb&\xfc\xae\xfa\x0f\xfek\xfd\xf2\xfe\xd3\x00r\x00\xeb\x02\xa7\x00~\xff\\\xff-\xfe\xa4\x01J\x02B\xfe\xe7\x00\xc4\xff\xc6\xfbG\xfc\xd5\x00\xe2\xfb\xe7\xfc+\x02\xfc\xfb;\xff\xc7\x03\xf8\x00\x8f\xff\x1b\x03P\x05<\x04\xdd\x02t\t\xe4\x08O\x04\xa8\t\x9d\n\xb1\x07\x19\x08\x18\n\x07\x08\x17\x06b\x07\x8a\x08!\x07 \x06h\x05\x1c\x04\x11\x04\xf7\x02\xed\x03\xdf\x04\x1d\x02\xd8\xfe\n\x05?\x05}\xfbE\x02\xcc\x04*\xfb\xa4\xfdV\x04\x0b\x00\xa8\xff\xef\x01\x0c\xff+\xff\x95\x01E\xfe\xad\xfd\xa3\x00N\x00\xfc\xff0\x023\x02\xb5\xfe\xde\x00\x1c\xfd\xd4\xfc\xa7\x03T\xfa6\xfcG\x03:\xfd\xb2\xfb\xc9\xfd\xbf\xf9\xf5\xfa\x13\xfc\xe4\xf9}\xfaf\xfd\xf9\xfd\xb1\xf9`\xffg\xfd\xc5\xfb\xc1\xfb\x10\xfdK\x01\x98\xfa!\x01\xe5\x044\xfb#\x02\x03\x06\xdd\xf6\xac\x00\xf4\x061\xfcS\xfa\xd0\n\xc2\x03-\xfb#\x03\xcd\x05\x06\x03\x8f\xf7L\x05\xe4\x04"\xfa\x83\xfdt\x05\x98\x00\xb0\xf9#\x04\x9c\x01\x00\xf8\x1f\xfb_\x04/\xf9N\xf6<\x08\xe2\xfe\x87\xf7\xcb\xfc\x92\t\x91\xf4\xa9\xf8\xe2\x06\x0c\xf7\x88\xf9\xee\x05|\xfe\x8d\xf5\xb4\x0b\xa9\xfd}\xef,\x052\x08N\xf0\x1b\xff\xf1\x0c\xa0\xf6m\xf8P\t\xe9\x04Z\xf3-\x02\xbc\t\x1c\xf5\x8a\xf7W\x13s\xfb\xe4\xef\xd3\r\x11\x06`\xf3 \x01L\x0eT\xf3\x8c\xfe\xd0\x0b\xde\xfa\xdc\xff\xb7\tM\xff\xe3\xfbM\x07\xee\x05!\xf5^\x05\x1f\x0f\xce\xf2\x88\x04\xdb\x0f%\xf1\xc2\x04.\x11C\xfa-\x01\xf9\t\xd4\xfc\x93\xfbB\x0e\x88\x02\xa7\xfeP\x0b\x16\xfe\x02\x00\x1b\n\xbb\xfe7\xf8%\n\x00\x034\xf98\x07#\x08g\xf6\xee\xfdk\x0e\xba\xf0R\xf2\xf9\x16\xa0\xf7S\xeeo\x0b\x04\x0c\x00\xeb\xb3\xfbO\r*\xef\x92\xf4\xa8\x02.\x08%\xf8)\xfd\x00\x02\xd8\x00\xc1\xf1\x0e\x01\xc8\t\xd2\xf4b\xfa+\x0f\xa1\xfeh\xf0\x1b\x10\xec\x02\xf8\xfbV\xf9\xac\x08\xed\xfe\xf9\xfdK\x02\x17\xfe\xff\xfes\x01\x1c\xff\x8a\xfaa\x07\x89\xf7\x8c\xf9\xc3\x02\x1f\x00\xdb\xfb\xfc\n\xc8\xfcd\xf7o\x0c\xe4\xfd@\xfe\x08\x03\\\xfbo\x0e\xe9\xf9j\x00\x1b\x11\xcb\xee[\x02B\x05%\xfb\x0c\xfc\xfd\x07<\x00\xe3\xf8\x7f\x01\xd7\x04\x1c\x05\x9a\xee\xa2\t\x1b\x00.\xf1/\x04K\x05\xc3\xfe-\xfa\x0e\x01H\x02\xb5\xfb7\xf7\xde\t\xd1\xfa\xf5\xf7\xed\x06;\xfen\xfb(\x07]\x06\xfd\xea4\x05\xcc\x0eW\xea\xad\x02\x08\x11+\xf1\xcf\xf9\x15\x0b(\x01\x1d\xef\x87\x068\x06-\xf5 \xff\xfd\x04\xc3\xfb\xc4\xf8\x85\x0c\r\xfa\xab\xf1\xee\x12\x92\xfe~\xf1\xd6\x11"\x00W\xf5\xa2\x08\xb6\x07\xa6\xfc~\xfc\xd4\x0e\xae\x03\x10\xf3\xe9\r2\x06Z\xf9>\x03\xe1\x08#\xfbh\xfe\xc4\x08\xf0\xf9\x8c\n\xdc\xf75\xfd\x13\x0c\xff\xf7\xec\xf8\x0e\x0eQ\xfa\xaf\xf1\xa1\x10\xe1\x00\x8a\xf36\x056\rY\xf6\x0e\xf9\xce\x0c\x89\x02G\xf0\x91\x04\xae\x16C\xf13\xfa\xcd\x0f\xe9\xf6\x08\xff[\x00\x17\x05\xc3\xfb\xaf\x00\xc8\xfb\x8b\x02\x0c\x0b\x97\xe71\x10\x01\xfa+\xf5\xe3\x08\xea\xfe1\xf7\xb5\xf8\x83\x0f\xa7\xfa\n\xf25\nv\x04\xf8\xe8\xa3\t\x9c\n\xaf\xf8*\xfd\xa9\x08`\x01b\xeft\x12\x0e\xfb\xa3\xf9\xfb\r\xba\x034\xf6\xb1\x05a\x05e\xff\xc2\xf3h\x04B\x13\xc6\xeb\x05\x0b\x85\x061\xf7\xe9\x01U\x05\xae\xf6\x15\x03\xdf\x07\x9a\xf1\xf5\x06\xea\x08I\xf2\xde\x04\xfd\x00\xb8\xf3\xcd\x05j\x02]\xf9\xb6\n\xc3\xf1\x8e\x02\xca\x11\x82\xe2\xf6\n\x13\x0c\xc2\xee\xe0\xf9\xe1\x11K\xfdB\xf7\xac\x08I\x03O\xf8\xa3\xf4\xda\r\xa0\xff\xa8\xfaS\x06W\x02\x91\xfa\xe0\xfc\x94\x0cB\xf4S\xfbP\r]\xf5\x06\xff(\t\xb2\xfb\x01\xfe~\x02t\xf9\x0b\x04 \xfe\x8a\x00\x8a\xff\xb1\xfd\xa3\x08\xaa\xfc\xed\xf5\x9f\x063\x07b\xf2_\x03o\x08\xac\xf8\x8d\x02\x0f\x05\x18\xf2d\r\xe5\x00\x0c\xf03\n;\x03\xfd\xfd\x98\xfc9\x05\xbb\xfe\xf1\xfc\xbd\x02V\xff\xc1\x00\x94\xf9\xc2\x040\x00A\xfe\xb7\x04u\xfe\xfa\xffC\x01\xbe\xf8\xcd\x04V\x01V\xfa\x1c\x03\x1d\x03\xd4\xfe\xf5\x00\xba\xfc]\xfc\xbe\xff\xaf\xf6\x8b\n\x13\xfb\x88\xfdv\x08`\xf6\xb9\x00.\x05\xd9\xf5\x98\x01\'\x02\x88\x01\xf3\x01\x81\x006\x03b\xfa\xea\x04$\xfdP\x04\xab\xf9\xfa\x06\x9c\x06\xc1\xf84\x00\xd8\x04\x99\x00\x1b\xfc\x98\x02f\x08S\xf7\xc7\xfe\xa8\x04\x1e\xfe\x13\x02p\xfa\xbe\x07\xb8\x00\xce\xfa\x0f\xfe6\x07\x18\xf4X\x00\xa5\x06\xcf\xfcm\xfe\x9e\xff>\x01\xf0\xf8\x8e\x02i\x02y\xfb1\xf3\xe3\x133\xf5\x91\xf9\x10\x0f\x9a\xf8\xb7\xfbM\x06\x92\x016\xf2\xc1\x0b\xc2\x00=\xfa\x86\x04D\x08\x95\xf6\xa4\x00/\x07\x1f\xf7\xd0\x04r\xfdY\x01\xcc\x05\xf0\xf5\t\x01\xf4\n\xa7\xf2\x89\x04\x05\x01D\xf5\xdd\x02\xf0\x07\xd6\xf2\x1a\x04\x8e\x03\xf5\xf7c\x06\x1d\xf9\xbf\n\x00\xf8\xef\x01\x8c\x04I\xfdS\x02\x7f\x00\x8d\xfe\xd1\x08\xcd\xfcD\xfaH\x0e\x81\x02\xc9\xf0\x12\x0b\xd9\te\xef\xa3\x08\xf2\x02\xfb\xf8H\x06\xf3\x03M\xf3#\r\xc2\xfe\x96\xf6B\x02\x9b\x0b\x1f\xef[\xff\xba\x11\x9b\xeb\xbd\x06W\x03\xa3\x03X\xed\xe7\t\xb0\x02U\xf7X\x04\xf0\x02\x9f\x01Y\xfa\x8a\x01\xe6\xfe\xdc\x04n\xf8E\x07\xfc\x03\xff\xf6\x18\x00:\x07\xf5\xfe\xe8\xfb\xa4\xfeh\x04\\\xf9\x8b\x05\x12\x01\x82\xf2\xd0\x0eT\xfb\x07\xf9\xae\x07\x86\x01\x1f\xfeA\xfb\'\xfd\xa1\r\x99\xfaL\xf8\xfd\x12\xb8\xf52\xfb\xc5\x06\x1e\xff8\xf8\xf2\r\xc6\xf8\xd3\xfbI\r%\xf9?\x02,\xf8\xa5\n\xa4\xfa\xaf\xf9\xe5\x0c\xa7\xfe\xa4\xf6\x00\x0b\x16\xfe\xfd\xf7i\nd\xf6K\x03\xbb\x06\xf3\xf1\x13\t{\t\x03\xf6t\xfdr\x07\xf2\xf6\xc9\xfe\x13\x0e9\xf3\x8e\x01\x16\x05\x00\xfe\x10\xf6n\x0e2\xf7\xb0\xf38\x14\xfa\xfa\x07\xf1\x9c\x11\x98\xfcv\xee.\x16\xc0\xf4\x19\xf4,\x0cD\x03\xf5\xed1\x11\xdf\xfds\xf2\xf0\x0c\xe0\xf7k\x00\xbf\xfe\xd9\x00=\x07\xb3\xfc[\xfe\xfa\xff\x10\x07\xe1\xef\xac\x0c\x85\x06\xca\xec\xa6\x16M\xf6A\xfa]\x0b\x03\xfc\xbf\xfc\xca\n\x80\xf8\xdc\xfe\x7f\n\x9f\xf8\xbc\x017\x04\x8f\xf78\x05q\xfbz\x08\xf1\x06{\xeeO\nJ\x03i\xf3\xe5\x00\xb6\r\x06\xf1\xad\x06~\x01\x80\xfc\x07\x01\x1b\xfc\xab\x03\x1e\xfe\xc6\xf8b\x03\xd4\x08|\xf0\xb7\t\x86\xfb)\xfe\'\x03\xdb\x003\xf5\xce\x06s\x06\x9f\xf2\xa7\x01s\x0f5\xf0\xfc\xfd\xe2\x14\x85\xe92\x066\x03\xf2\x02\x12\xf8B\x05\xb8\t\x95\xee\xd4\n\x9b\xfd\xa5\xff<\xfa\xe1\rR\xf7\x9e\xfb\xeb\x10\xc1\xec\t\x0c\xed\xfb\xff\xfb4\x04\xe2\x00\xe0\xff\xd5\xf5\xa8\x12=\xf9\x85\xf3-\x11\x08\xfa\xa2\xf4$\x0eh\x04t\xe8\x94\x11\x8d\r\x9a\xe85\n\xc0\x05\x10\xf3\xbe\x04\xed\x01V\xfc8\x06\xb7\x02)\xf3\xf5\x10\xcc\xf7\xd9\xf4U\x10\x10\xfd\xb9\xf5\xba\x06\xe5\x04\xbf\xf4\x1b\x0c\xcb\xfd\xd7\xf9\xb5\x032\xfb\xa2\x02\x14\x04\x8c\xf1\x11\nQ\x06,\xf1+\x04\x8e\x0e\xf8\xf0G\xf6\x01\x12\x8d\xf0\xf6\x02\x93\x0c\x90\xf2K\x03\xe4\x08_\xf7\x8e\xfd\xb3\x01 \x01d\x03\xda\xfc\xe5\xffc\nD\xf7\xbb\x01y\xfb\x88\x02\xce\n3\xf2\xe7\x01V\x12D\xf2\xa3\xf5\x15\x18\x08\xf3h\xf9\x0c\x0bm\x03\x95\xf8J\x02\xbd\x06\x9d\xf9\x9a\xfb\xe9\x08\xca\xfe\xae\xf9\xf6\x03\xe1\x07#\xf8\xd8\xfa\xe1\x0c6\xf4\x89\x05\x10\xfbA\x05j\xfcl\xfc\xfa\x0e\x0f\xf1c\x00a\x03U\x03\x96\xec\x8e\r\xb1\x03"\xf9\xb5\x04\x1f\xfbo\x04\xd9\xf8\x00\n\xde\xf3\x93\x07\xfc\x05R\xf7[\x07V\xfb0\x06\xe6\xfb\xd1\xf6\x12\x13\xcb\xf3\xc3\xfcY\x08\x16\xfc\xa1\xfb\x91\x01\xa2\x06\x7f\xf1r\x08\xe8\xfa\xf8\x06\xe7\xf8y\x03*\x04\xf6\xf8\xfa\xf9\xab\x04Z\x03\xab\xfa:\x07\xee\xf3\x90\x0e\xa8\xf6>\xfd\xb1\x08\xf2\xfd\x19\xfe*\xfd;\x06w\x05\xd2\xf5\xf7\x00\x00\nT\xfdF\xf5v\rW\x01\xea\xf0\x82\n\x99\x03\x16\xf9\xb3\x05B\x02\x86\xf4{\x06b\xffE\x03\xb6\xf5\x18\x08\xd7\xfcy\x03e\x00\xab\xf4Q\x0cn\xf7I\x00Y\x01n\x03\x15\x01$\xf9#\xffG\r\xcc\xec\xdc\x05\xf0\x08w\xf6 \x01\x0b\xff\x16\x06\xe3\xf2.\x0f\xd8\xf8\xbf\xf7\x98\x08T\xff\xa8\xfc\xa8\xfcQ\n\xe1\xf8\xe7\xf6U\x13\x92\xf73\xfb`\x06G\xfb\x1b\x00\x80\xfcS\tB\xffO\xfb\x1f\x01G\x04\x85\xfa*\xfa3\x15\xb4\xeew\xf8Y\x1ep\xeao\xfb\xd2\x14\x03\xf72\xf5\x02\x11\xb0\xf9_\xfb\x87\x08\xb2\xf8\x91\x0e\xe5\xf0\x0c\x05H\x026\x006\xfc/\xfe\x91\n\x8c\xf66\x08l\xf5C\x06\xb0\x02`\xf9E\xfb\xc6\x0c$\x002\xef\x84\n\x9f\x06"\xf5\xa8\xfel\x05\xb2\xffK\x01\x1c\xf5\xa9\x06\xb1\xff\x8b\x04N\xfa\x8b\xfd!\x04\xf3\xfe\xe1\xfe\x11\xfb\xac\x0c-\xf0\xa6\x04\xd3\x08X\xfbm\xfcr\x03\x8c\x03S\xefH\x0c\x98\t\xff\xeb\x00\x07\x8d\x0e"\xee\xcd\x05"\x04\xad\xfb\x16\xfdW\x00\xbc\x05}\xfc\xfe\xff\xe0\x036\x03\xd5\xf0\xf8\x08\\\x06 \xf5\xd0\x01U\x03\xda\x01\xc4\xfe\x19\xfc\xb1\x01\x1e\x0b\xfa\xf3?\xfe\xd8\t\x98\xfcv\xfb\xa7\x04\xa3\x02\xff\xf8\xf6\x0b\xee\xf21\x07\x92\x01\xb9\xf8i\x07\xce\xfc\x01\x04\xa4\xf8\xf2\x04\xa1\xff\xa9\xfd\xe5\x01\xd5\x01\xad\xf7\xd0\x06\xdf\x01\x14\xfd\xc9\xfbb\x00\xc8\x07c\xf8\xcd\x01\x8a\xfbd\x10\xb7\xef}\xfc"\t\x02\x02\x93\xff\xfc\xf3j\x10\xba\xf9\x17\xf8\xa9\x0b\x1d\xfa\x8e\x00\xe5\x08:\xf8\xf5\xfa\xdc\x04\x98\x00\x9a\xf9\xfe\x0b7\xfd\xc0\xf71\x04e\x05w\xf5\xf1\xfa\x05\x0fP\xf5\xd3\xfeT\r\x1e\xfe\xd5\xf7\xf9\x05?\xf7\xf9\x027\x07\xb1\xf7\x18\x0f\x8a\xf8\xce\xfe2\x04~\xf6H\x07\xdc\x01C\xf7\x8b\x05\x88\tv\xf3=\x02{\x07r\xf5y\xfe\x92\x0b@\xf5\xc3\x02\xa8\x03\xf7\xfc\x89\xff\xbc\x01p\x04\xf2\xeeC\x0c\x08\x03&\xee\x0c\x0c\x13\x0b\xf4\xee\xa7\x03S\x08-\xef\xbf\ny\x05\x82\xf3\xc3\x08\xd8\xf8"\x05S\x00{\xfcM\x01x\x02\x0e\xfd\xbd\x00\x9f\x02\xd4\xfb\xe5\x06\x03\xf7\xee\x08Q\xfc0\xff\xa3\xff\x99\xfb\x8b\x0c\xbd\xf9\x82\xf8\x92\x040\x0c|\xee\x85\xffG\x11\xd5\xf4\x8b\xfe\xd2\x04k\x00Y\xf8\xbd\x08\x94\xf9\x9b\x02\xef\xfe\xe9\xff\x87\tF\xed\x8e\x0cY\xfe\xe2\xfa\xc1\x00\xdb\t\x04\xf9\x19\xf92\x0e\xe5\xf1\x8b\x08\xc0\xfeL\xfbZ\xfe<\t\xfc\xfb\xf3\xf8.\x13\xe8\xeb\xf0\x06\x1b\xfc\xfc\x03\x15\x06\x99\xee\x99\x10\xba\xfc\xbc\xfa\x13\xff\xe6\x06\x08\xfa\xb9\x01Z\x01\xeb\xf6\x06\x0e\x85\xf9\xaa\xf8\xc0\tv\xfd\xc4\xf9;\x0e_\xee\x9f\x08@\x06&\xf4\xb8\x00\x12\x06\xf6\xff\xb7\x01\xda\xf98\x00(\x0f{\xe8Y\x08\xf6\n\xeb\xfa\xc5\xf8{\x07\x80\xffs\x00\xec\xfb5\xfeC\r\x10\xf3\x00\x027\x08,\xf5\xd4\x03\xbb\x04\x9a\xf6q\t\x97\xfbH\x02 \xf9\xe0\x01N\x08\xbd\xf8\x85\xfb\xc6\x0c\x1f\xf6\xd2\xff&\x0cB\xec\x19\n\x7f\x03\xe4\x01\xe3\xf1\xeb\x0c\x81\x02\x82\xf0\xed\x05d\x08\xee\xfa!\xf7\x9b\n\xd0\x018\xf7\xbb\xfe\xa1\n\xa2\xff\xcb\xf5\xe0\x01\x89\x03\xd7\xfbT\x06w\x01&\xf8\x19\x05\xa7\x03k\xf6\xb8\x02\x8c\x02T\xffK\xfaW\x08Q\x03t\xf6u\x07\x8e\xfd?\xf5/\n\xde\xfd\x85\x03\x05\xfd\xc2\x00\x03\x06\x05\xf7\xd9\x01(\xfe\x1d\x08&\xfb\x92\xfd\x07\x0c+\xfb\x01\xf3\xe2\x12&\xf4\t\xfa\xa5\x0e|\x00i\xf2\xfe\x0c;\xfd2\xfar\x07\x08\xf2L\r\xc2\x00@\xf6\xaa\x08\xc3\x089\xe9\xe4\x11\xe9\xf7P\xfe\x97\x039\xfeW\x02\x19\x01\x93\x00\x96\xfc\x88\x02\x97\xf5-\x13\x16\xeb\x8e\x07\x05\n\x1b\xf4\xd7\x08-\xfe\x0c\xf9\xdc\x03\xd5\x04"\xe7\xfd\x11U\x0fw\xe8\x95\x02w\x122\xf0n\xf7\xbc\x177\xee\xed\xff\x7f\x0b4\x00<\xf7\x82\x04\x07\x0c\n\xef#\xfe\x96\x13+\xf4\x05\xf6\xc6\x0e2\xfb\x90\xfb\xa6\x02\x15\x07\xe8\xf2(\x01\x1d\x01\xef\x07\xab\xf4b\x07\xe7\x05\xf2\xf1\xbe\x01\xde\x00}\x05n\xf9\xf1\x07\xd8\xf3\xd8\x04\x84\x04>\xfet\xfd;\x031\x03\xee\xf0o\x0f\x9d\x01\x1f\xf3a\x08\xdd\xff=\x01\x8d\x00\xb4\xff\x94\xfd\xb5\xfb8\x05\xe0\xffb\xfd\x8a\tc\xfb\x05\xf7\xa5\x06\xc2\xff\xe8\x02M\xf1\'\n\x1b\x02\xa7\xff\xff\x01[\xf6\x01\ti\xf7A\xfe\x89\t\xbb\x01\xeb\xf4\x8e\x0b\x80\xffy\xf1\x1d\x0b\xdf\x01K\xf6I\x03U\t\t\xf6x\xfc\xd9\rT\xf8\x8f\xf7|\x08\xac\xfex\xf8&\t\x80\x06\x9b\xf0\xd6\xff\xe0\t\x89\x01\xe6\xf5"\x05x\x05\t\xf6^\x02\xee\x04\x87\xfe\x11\x00s\x04"\xf9\xa9\xf7G\x15\xb8\xf3\xa9\xfdw\r\xcb\xf1.\x07\xc4\xfc\xbe\x00\xdd\xff{\x04\x90\xfd0\x00^\x01\xf2\xfb\x98\x05\xc3\xf6J\n\xb6\xf5\xd0\x07H\x00\x11\xfa#\x0bU\xf23\x04&\x02\xf9\xfd\x93\xfa0\x0b \xfc=\xf7\xf2\r\x02\x00\xe0\xef\xcc\x01J\r\x9b\xf7\xf5\xf9/\x0c!\x059\xf0A\t\x82\xf6Y\x01\x16\x04\x16\xff\xec\x04\xf1\xf7j\n\xe8\xf6\xc9\xff\x8c\x05\xc5\xfb\x8e\xf5\xf0\x07u\x14\xee\xe8e\x01\xa8\x13\x11\xe6\xc4\x01\x10\x10\x01\xfbM\xf6\xec\x07\xef\x08+\xf3#\x02\x1a\x07%\xf5\xd3\xf9\x87\x12\x08\xf9\xd1\xfcy\x07\x9f\xff\xb9\xf7\x10\x00\x14\t\xb0\xf46\x08\xa4\x02\x16\xf9\xf4\x006\t\xf1\xfc.\xec8\x16\xc4\x07-\xe9\xfb\x05\xad\x0ft\xf6\xbf\xf2\x87\x113\xfe\xbf\xf6t\t\xb7\xfc?\xfa\x8a\x0cA\xf56\xff\xf9\x02\'\x05\xe1\xf9\x0f\xf8C\x0f\xba\xf1\xd8\x06\x15\xfe\xce\x00\x88\x02$\xfe\xdb\xf6\xfb\x06\xb2\x00\xe4\xfd\xa6\xfe%\x02)\tC\xefG\ry\xf4R\x05D\x02\xd4\x00%\xfc\xbc\x07\xee\xfb\xec\xfd\xd5\n\xd9\xf6A\x07t\xfe\x8d\xf5\xc8\x03\x83\x0f}\xef\x12\x06\xe1\tS\xf0\xcd\xfap\x0b\xee\xfd\xf5\xf5O\xfd\x90\nk\x05\xa1\xf3\x07\x05H\x00\xa7\xfd2\xfb\xb1\xfe\xce\r\xba\xff\x16\xfc\r\x00Q\x00\x87\x04\x80\xf8\xd2\xf7X\x0e.\x02z\xf3T\n\xcf\x02R\xf5\x80\x00!\xff\xc8\xfa\n\x0b\xe2\xfd\x05\xfeN\xffL\xff\xd5\x04_\xf2\xff\x02\x83\x0b\x93\xefK\x04\xba\n\x84\xfc\x93\x02\xd7\xee\xdd\x0c\xa8\x05\xe3\xf0\x0e\x0b7\x0b3\xf4\xcb\xfa\x9d\t\xae\xfe\x88\xfa\xeb\x05u\x06\xaa\xf1d\x06\x18\x0f\x12\xef]\xf9\xf9\x17\x01\xf5\xc2\xf5\x8f\n(\x05\xc7\xfa\xbf\xf5}\x0c\x83\x00\xa2\xf6\xdb\x05\x9c\x01@\xfe\xad\xfb\xeb\x00\x8f\x05u\xfb"\xfc\xc4\t\\\xfe\xfd\xf1\x80\x05\xc4\x08\xf5\xfb$\xf8\xc3\x06\xe2\xfe\x98\xf5\xf3\x08\x19\x06\xb2\xf6\\\x00P\x04\x8e\xf7f\x02m\x05\xa9\xfb_\xfeL\x00\xe7\x02\xdb\xfe\x87\x01e\x00\xd8\xf9;\xff\x10\x02d\xffB\x08J\xfaV\xf8\xd5\x0b\x99\xfbO\xfb\xec\x07%\xfd\xb5\xf4*\x04g\rm\xf8\xc0\x00r\x01\xe5\xf6\x9f\x009\x02J\x01\'\x00\xe5\x018\xf9:\x02\x10\x08*\xf7h\x01\xbe\xffQ\xfa\x1a\x05j\x03\xdc\x02\xd7\x01\x80\xfbW\xfa9\x05\xb2\x00\xd9\xff\x8c\x05\x91\xfe\n\xfcO\x05\x13\x01\x0b\xfa\x1d\x04\xd1\x02p\xf7\xd1\x03\x8e\n\xc6\xfa|\xf9+\xff\x07\x05\xc4\xfa\x9f\xfb\x03\x08\xad\x03\xaa\xf6+\xfe4\x02q\xfdk\x02y\xfd\x85\xfe\xc5\xfe|\x02Q\xff\xae\x02\xac\xfc3\xfd2\x01\xbb\xfa\x10\x07\xbe\x03E\xfa9\xfc\xb2\x06\xef\xfeg\xfa\x9b\x01\x03\x04F\xfe\xd1\xfb\x1e\x05\n\x01\x83\xfc\xa2\x00\xea\xfd\x8a\xfe;\x02d\x00L\xff\xfa\x01\xa0\x00\x99\xfc\x1f\xff\x7f\xff\x92\x01\xc6\xff\xa6\xfd=\x03\x9b\x02G\xfe\x14\xfe\x1b\x047\xfdq\xfd\xf7\x02}\x02U\x02\xce\xff\xa7\x00\x0b\xfc\xbb\x00L\x00\x91\x01\r\x01\xf7\xfe\xae\xff\xbb\xfb\xf6\x02\x1b\x03\x1c\xfb\x07\xfd\xda\x01\x05\xfea\x00\x0f\x00\x8f\xff0\xff\xa9\xfbK\x02R\x02\xfe\xff\xd3\xfdi\xffC\x014\x00T\x04\xb7\x00\xb6\xff\xbe\xfd~\x000\x04\x15\x02\x0f\x03\x00\x00\x98\xfe\xba\xfea\x02a\x02\xe9\xff\x0f\x017\x01\xd6\xff\x98\x00\xc5\x02\x85\xfe\xed\xfc+\x02\xf4\x01d\x00A\x02\x1f\x02\xc0\xffI\xfe\x96\x01\x9c\x00z\x01\x98\x02\xa7\x01\xfb\xff\x92\x02\xea\x01i\xff\xfa\xfe#\x00\xe5\x01\x99\xff\xc4\x01\x8a\x01\x89\xff\xf1\xfc\xb4\xfe\xc0\xfe\xf9\xfd1\x00\x15\xff\xff\xfd#\xff\xfb\xfd\x15\xfe\xa9\xfes\xfc\xab\xfe\x9f\xfe~\xfe;\x00]\xff\x7f\xfc\xbb\xfa_\xfe0\x00\xd8\xfd!\xfb\xf1\xfd\xfc\xfb\xf6\xf7x\xfcd\xfa\xc2\xf8\xcd\xf7-\xf9\xec\xf8\xca\xf7b\xf7\x9b\xf3\xb9\xf3N\xf7\xb5\xf9\xd9\xf5L\xfa_\xf99\xf5\xe8\xf8N\xfc\xbe\xff2\xff\t\x03\xdc\x05\xc9\x05\x85\x08\x9b\x0c6\x0f\x85\x11=\x13\x87\x16\x05\x19{\x1c,\x1d\xcd\x1b\xb4\x1b\x83\x1a\xa7\x1c5\x1c\xd1\x1a\xd1\x1aT\x16\xe4\x10\xc1\x0f\x93\r-\t+\x05\x14\x02^\xfe\xd6\xfb\xf2\xf8\xd9\xf4/\xf1W\xed\xf6\xeb\x8c\xec\xb7\xec\xa8\xeb\x03\xea\xf8\xe8e\xea\x96\xec\xa3\xedA\xf0\x91\xf3\xc7\xf3\xb7\xf6\x10\xfa\xac\xfbN\xfe\xc6\xff\xd0\x00d\x04!\x07\x18\x066\x06\x04\x06\xb4\x04\xb0\x02\x9c\x02z\x02T\x00<\xfe\xef\xfb\xd4\xf8_\xf4\xce\xf1!\xf0\x15\xee\xbd\xec\xac\xea;\xea\x12\xe8]\xe5\x13\xe5]\xe5\xa6\xe4\xe5\xe5p\xe7\xaf\xe7F\xea[\xeb\xf3\xe9\x1a\xec{\xf2\x04\xf5\xf7\xf6\x90\xf9\xa0\xf8\xe8\xfd\xab\x01\xd0\x03\xf5\t\xab\r&\x16\xf0\x1f\xc0%\r&\t%\x04(e,\xb75A;\xbc>\xb3AA\n\xb1\n\xed\x0c\xbb\x0cK\ry\x0f\x02\x0f2\x0c\x06\x06\xbc\x01\x15\x01\x92\x00\xbc\xfd\xf0\xf9J\xf3\x92\xeco\xea\x01\xeaP\xe8\x0c\xe6M\xe3b\xe3>\xe5/\xe5j\xe6\x19\xe7s\xe6\xd7\xe8\x8c\xedS\xf1C\xf5}\xf7\xbc\xf6\xad\xf8\xc8\xfb\x11\xfd\x9b\xff\x0b\x00\xc3\xfd\xd7\xfe\xa3\xff\x97\xff\xe1\x01\x15\x00B\xfco\xfd\xdf\xfc\x90\xfdc\xfe\xbe\xfc\xde\xfc\xcd\xfb\xda\xfd8\x08\xdc\x14q\x14\x86\rM\nr\x10\xb0\x1f\xca(L,\x1a-2,d-\xc1/\xa50\r/\x1a*\xa9\'\xa8+\xbb.C)\x0f\x1c\'\x0f\x0e\x08s\x06\xc3\x06\xbf\x06\x01\xff-\xf4\x9d\xec|\xe6l\xe5\x82\xe4\x1b\xe0\xb8\xdd\\\xdf\x9b\xe1\x91\xe3\xa4\xe3o\xe1a\xe1\xf8\xe3\x1d\xea\xb2\xf3?\xf9P\xfb\x82\xfc@\xfd\xc0\xff \x05\xe4\t,\x0bC\x0c\x1d\r\xea\r\xf9\x0eD\x0c\x96\x07\xd6\x02r\x01\x9d\x02\xa8\x012\xfdU\xf7l\xf0\xde\xed\x95\xec\x13\xeax\xe9\xed\xe7{\xe5\xde\xe4\xe3\xe6\xb2\xe6\x11\xe6\x1f\xe7\x16\xe9\x1c\xed6\xf2\xf3\xf4\x90\xf4x\xf4\xf0\xf6\xa2\xf9\xac\xfb0\xff\x7f\x00\x90\x00\xdb\x02\xc9\x01\xbd\xffR\x01\xca\x00\x9b\x02v\x02\x8c\x02=\x03\x8e\x01\xe4\x00\x98\x00Y\x02\xff\x03\xbc\x02\xe4\xff\x1c\xffB\x04,\x0c\xa3\n\x85\x0b\r\x11\x81\x14\x18\x18k\x1au\x1e\x1f \x92\x1f\xb1#\xe8+\x1a1\xe8+-%\x80#\x1e$\x17%\xae#\xff!\t\x1d\xad\x13\x83\x0f\xa2\r\xb5\n\xc6\x05L\xfd\xfd\xf9\xff\xf7)\xf4^\xf2\xef\xedv\xe7u\xe3\xdb\xe1P\xe5\x80\xe7\xd9\xe5\xdb\xe3\x04\xe2M\xe2t\xe6\xe9\xea\x08\xed"\xef\xbe\xef(\xf3\xd5\xf7?\xfb*\xfd\x95\xfc\x82\xfc\xf9\x00\xae\x07\xe0\x07M\x07\x0f\x05\x8f\x01\x16\x02\xcf\x03\xa8\x04g\x02\x89\xfd\xf3\xf9\x07\xf9\x03\xfa\x0e\xf8\x14\xf4z\xf0[\xed\xaa\xebi\xf1\t\xf2x\xee\xcb\xec\xaa\xe89\xec \xf2X\xf26\xf1o\xf4\xa0\xf3,\xf3\x81\xf8A\xfbo\xfa\xec\xf8\xb5\xf9@\xfc\xf7\x00\\\x01\xbd\xff\xbf\xff\xac\xff\xfa\x028\t\xc9\x07Q\x05\x00\x02\xd0\x02u\x07\x97\n6\x11K\x0c\xa6\x05\xf5\x07\xea\x04k\x08\xa9\x0e\x84\x08\xec\x07\x15\x0e.\x15\xf9\x15\xab\x11=\x0b@\x0ba\x10J\x17\xda\x1cD\x1e>\x1b#\x16\xd1\x14v\x14\x92\x17\x8b\x15g\x13\xeb\x14\xcd\x13\x1d\x13<\x0f\x91\x08C\x03<\x00\x0b\x01\xfa\x048\x02:\xfc^\xf8"\xf36\xf0\n\xf0a\xefV\xef\xd4\xee\xb5\xed\'\xec\xf3\xea:\xeb\x91\xe9\xb8\xe82\xee\x88\xf2L\xf2\xc5\xf4\xbb\xf5\x8e\xf3d\xf7\xe9\xf9\xfe\xfc}\x02\xb6\x02-\x03\xb0\x02\xd8\x02\x83\x02V\x02\xeb\x02Y\x02\x98\x01s\x01\xd9\xfey\xfb\xfb\xf6\xcd\xf5N\xf5Y\xf3f\xf3\xfd\xf6\xb1\xf1\xb4\xed\x93\xef\xd4\xeb\x14\xedM\xf1@\xf2\xa4\xf4j\xf6\x08\xf3\xea\xf3\xf6\xf7\x0c\xf6j\xf8\'\xffO\x01/\x03\xcc\x06\xb9\x08\xaa\xff\xe3\xfe[\r\xe5\ra\x0c\x81\x0fd\x0c@\x0c\xe9\n\xa6\x0bi\x0e\xed\x08\\\x06E\x0eY\x0f\xea\n\xe9\x04)\x02.\x04\xdd\xfdw\x06\xe8\x10\x1d\x05\x16\xfa\xba\x00\xb5\x02\x8f\xfan\xff\x96\x07b\xfdp\xf8\x16\x08(\x04\x14\xf9\x14\xfe\x1c\x01\xc6\x00\x7f\x00\xfa\x06\xcb\x08\x88\tu\x08p\x03\x82\n3\x0b\xd5\x08Q\x11\x13\x14\xfd\x0e~\r\xef\x0f\xad\x0e\xb2\n\x82\x0c@\x0cy\x0c\x9c\x0c\x95\x08\xfa\x08\xbd\x04 \xff\xdd\xfd\xdd\xff\x93\xff\x8e\xfb\xaa\xfb\x96\xf9\x92\xf5\xb7\xf4,\xf4`\xf1\xb3\xf1\x96\xf2]\xf09\xf5\x90\xf5!\xf1\x19\xf1_\xf5\x1d\xf4I\xf2\xba\xf7\xce\xf9i\xf8\x1c\xf8\xcd\xfdA\xfa>\xf9O\xfd\xad\xf9\x99\xfb\x12\xffc\x02.\xfag\xfd\x8c\x00M\x00\x92\xf72\xfa!\xff\xb4\xfb\xb6\xfb\xe9\xffe\xf8M\xfc\xad\xfc\xf6\xfa\xc9\xfbk\xfa^\xfa\xb2\xf9=\x02\xbe\xfb\xb8\x00T\xf8\xa5\x00\x8f\xf8v\xfe\'\x01"\xf7\x07\x03\xdb\xfe&\x05\x9a\xf9x\x022\x00\'\xff\r\xfb\xe7\x06(\tT\xfb4\x0f\xaa\x04\x14\xfbX\xfc\x98\x11l\x00$\xfci\x16c\x082\xfaL\x06\xb2\x11\xbe\xf6\xcd\xfd\x1a\x1b\xf2\x03\xc0\xf4\xf7\x10\xb1\n/\xfaH\xfe>\x10\xe9\xfc\xa8\xf9b\x06\xf1\x04h\x06\xc3\xf2m\x07\xbe\x07\xbd\xf4R\x02\x7f\x06\x06\xfc\x0f\xff\xe0\x00\x83\xfe\xe3\x02\xf9\x02X\xffm\xfc\xaa\xfat\x055\x08\x0b\xfa\x89\xfb\xeb\x06\xef\x00\xb9\xfea\x02\x1a\x07\xbc\xffL\xfc\xa6\x02v\x07r\x07\xdd\xfcx\x06\x82\x02\xd1\x02M\x03\xfa\x02\x05\x07\x0b\xfeY\x03\xca\x08\xaa\xfbi\x05\x19\x07i\xfd\xae\xfc(\x04\xe5\x05\x83\xfd\xb9\x01#\xff\x95\xfe\xf5\xf9\xae\x02\t\x00{\xf3\xe6\xfc6\xfe\x14\xf9\x9e\xf2\x07\xfay\xfdf\xebU\xf5\xf8\xfe\x93\xf5S\xf6\xa4\xef\x9a\xfc\x94\xf5\x1a\xf1\x90\x04\xa9\xf7O\xf4\xc4\xf9\xee\xfc\xff\xf9S\xfe\xed\xfc\xed\xf5\x16\x05\xea\x05\xe3\xef2\x05\x94\x05\xdc\xfbv\xfb\xbf\xfc~\x11\x9a\x02Z\xed;\n\x19\x06\\\xfe\xf5\xfbn\x06\xfc\x02O\xf7\xff\x0f\xe5\xf2B\x01\x1b\x12\xfd\xefQ\xfa\xa8\x18\x05\x00a\xf2\xc0\n\x06\x0c)\xf8\x81\xff\x8a\x12\xc5\x07\xfb\xfa[\xff\x87\x12\xe2\x03\x9d\xf4B\x0b\x81\x0f\x0c\xfb\xa4\xfe\x0c\x0e,\x04\xfd\xfeC\xfc \n%\x01\x0e\x00h\x06E\x01\xa0\x04\x9d\x02\x11\xf6Y\t2\t\xca\xedS\x10\xc9\x0b\x11\xe7H\x07\xe9\x19\xb1\xf2\xfe\xf5\xec\x05\xff\x0b2\xf1\x01\x02T\x1a\xed\xeed\xf3\xf6\x0e\xbc\x02e\xec\xea\x04\x7f\n\xe0\xfa\xd7\xf2\x11\x0b\x83\x03\xc2\xe7\x87\xfa\xb9\r\x1a\xf8\x05\xf0\x03\x08S\x04x\xef\x1a\xf8\xf3\xfdl\xfd\x1b\xff\xf4\xf1X\x0c\xd2\xf4@\x00\xc5\xfe\x16\xfd*\xf7\xb5\x01\xd4\n\x05\xef\x8c\t\x96\x05\x9c\x07\xc9\xf0\x9f\x07\x85\x0c\x80\xec\xa8\x07\x01\x10v\xfc`\x07\x1c\x05\xc6\xf8/\x01\xb7\x03\xb1\x02\xaf\x00\xa6\xff\x15\x07\xcd\xfb\xd6\xfe`\x02\x15\x01\xaf\xf3\xee\xfc|\x0f\xc4\xf4\xee\x05{\x00\xda\xfc\xd5\xf86\x06\x9a\xfd\x02\x06n\xfe\xf2\xfb?\n\xff\xfcp\x03\xc5\xf5.\t0\xff\xaf\xf3\x18\x0c\xb4\ta\xf0q\x03\x86\xff\xf2\xff\x1e\x02U\xf5\x1c\x0b\x0c\xff\x82\xfb\xfc\xfa|\t\xcd\x04\xd4\xe8\xb7\x0e}\xff\xe8\xf6F\x0f?\xfb\x80\xfb\t\x08\x81\xfa\xaf\xfa\x93\x04\xe8\rG\xf1\xb4\x01B\t\xba\xf4\x83\x0c\xcf\xf4\xa2\x06\xfe\xf5\xbf\x08\x80\xf5\x9b\x0b\xe6\xf6h\xfbw\x05\xc6\xf8\xb4\x07\'\xf2U\n\x8f\xef*\x0f?\xf9>\xfeQ\xf9\x93\r\x9c\x02\x17\xec\xa9\x0c\t\x0c\xa2\xf0\xb9\xf9e\x1e\xa7\xee\xbf\x00\x9c\n\xe0\xff\xf6\xf4\xe3\x08\x83\x03\xe1\xf8\xcb\t\xd3\xfb)\xfd\xca\x02\x0f\xf96\x07\xa8\xed\xd2\x086\t\xe8\xe5\xbe\x0e\xda\x01Y\xf3\xcb\xee7\x12\xf3\xfe\x0c\xe4\x08\x18\xf3\x01\x19\xef6\x02n\x03K\xfe\xcf\xf1\xb4\x0c\xa5\x059\xf7\x8f\x08K\x00\x91\xf8\xb1\x06s\x04Q\xfc\x87\x02I\x07\xa9\xff\xd8\xfe+\x10\xe0\xf6i\xfeI\x06K\x03C\x04\x1e\xfa\t\x10 \xf5\xfa\xfe>\x18<\xf3\x97\xf8\xf6\x0f\x97\xf6\xb4\x03\x97\x06\x0f\x05S\xff"\xff\xe9\xfdZ\xf9\x7f\x0f\xa2\xedy\rJ\x07\xeb\xf0V\x03\xb8\x01\xc9\xf5\xcc\x00*\xfe7\xf7F\t~\xf9\x9a\xfb\xa7\x00\x05\xfb\x0e\xf2\xc9\x10"\xf1\x1e\xf9S\rZ\xf2\xaf\xf8\xb0\t\xec\xf8@\xfa/\x05C\xefL\n*\x03\x9d\xf8Q\x04-\xf9\x9e\xff?\x0c(\xee\xc0\x07\xa8\x14\xe2\xe8\xab\xfa\xd1\x1a\xa5\xfay\xef\xda\x0e\x83\x0c\\\xf3+\xf70\x16R\x00\xab\xf7\x81\x01\xb7\x0b\x13\xfaw\xf7\\\x1bs\xef}\xfbB\x17\xd5\xed\xac\xfb\xb1\x13\xe9\xf8\x87\xf6q\r\xc5\xfa\x07\x05\x14\xfd\x85\xfb\x92\x07\xdf\xf7\x03\x03\x89\x07\xfe\xfa\x8b\xfa\xdc\x06\xc7\x02j\xf6\xde\x00\xb8\x0e\x18\xedg\x07\x11\x01`\x02/\x04\x19\xf0\x11\nw\xff\x1a\x01X\xf5\xcc\x0e\xf4\xf9\xef\xf8,\x08a\xfd\x0f\x04\xbd\xf3\x9d\x00\xc1\x07\xda\xfa\xc3\x00\x8d\x021\xfb\x8a\ta\xea<\x0c\xdf\x0b\x06\xeb\xc8\x08\xec\x00]\xfe\r\x06\xa4\xfb\x08\xf4\x9f\x10L\xf6v\xfb:\t\xa1\x04\x13\xf7\xc5\xfa\n\x0e\xf5\xfc\xb3\xf3\xe8\x0bp\x04\x91\xf1\x99\x11\xd6\xfb\xe3\xfb\xb9\xfd\x18\tm\xf5B\t\x1b\xff\x95\xf6\xf6\x14\xa1\xf1\xf8\xf4\xf6\x10\xa5\xfc]\xf0Y\x0f\xd3\x01\x8c\xf7\xac\xfd\xab\t\xa3\xf4C\xfeY\x08\x12\xf9U\x01\x1a\x06"\xfa\xf0\xff\xcf\xff\xe6\xfcD\x01:\x01\xb7\x00\xf0\xf7C\x0b\x0f\xf6p\xffM\n\x83\xf7\xeb\xf8\xff\x064\x04h\xfaD\xfd\xd4\x08\xd7\xfeR\xf8\xb1\n\x94\xfc\x99\xfc*\x06\xd1\x04\xc8\xf63\x08\x1b\x06_\xf4+\x05x\x08\x9f\xfa\xa1\xfc\xec\t)\xfc{\xfcR\x05\x14\x00~\xfeT\x03v\xf7^\x01\xe0\x07\xec\xf1P\x07r\x06\xac\xe8\xe7\x12y\x01\xf1\xea9\x0c\xa1\x07X\xec\xcc\x06d\r6\xe9\xc4\x0b\x10\xfa\x0f\x05\x11\xfd\n\xf4g\x13\xa9\xf1{\xfe\xf1\x08\xac\xf6\xeb\xfc~\x0b\xac\xf11\xfb\xf8\x13\x89\xf0E\xf6\xdf\x12\xe3\xf7l\xf6\x92\x0b\xe8\xfc\x93\xf7c\x02\x7f\x05\x12\xfb\xc8\xfe:\x07\xfd\xfeE\xf7/\t\xb0\xff!\xfd\xdc\x02\x15\x07\x17\xfe\xe4\x00\xb5\x06\xca\xf9\xf6\x04t\x06k\xfbD\x07\xca\xfd\xd2\x01q\xfe\xb2\x05\xb2\x06\x93\xeb5\x13\xec\xf9\x1c\xfa\'\x08\x85\xfc\xf5\xf88\x08\x9e\x03\xd7\xf03\x07G\x0f~\xe7a\xfd#\x1f\xd0\xe6e\xfd\xa4\x17\xb5\xf4\x15\xef\\\x15\x8b\xf9\xb6\xf3\x91\x11\x82\xf8\'\xfe\xeb\x02E\x01e\x00\x99\xf8c\x06\xe1\xfb\xa1\xfeZ\x08/\xff\xef\xf2\xcc\x0b\xe1\x04\xe7\xe7\x99\x11\xed\xff_\xf7\xdf\xfa\xb3\x0c6\x01\x7f\xecg\x107\xf8\x05\xf9s\x04\x03\x01\x13\xff\x9d\xfd\xbc\xfd\x97\xf8\xd2\r\x87\xf0\xf3\x01:\t\xa4\xeb\x9f\x0c"\x07G\xef\xc1\xff\xce\x0f\x9b\xf0>\x00\x94\t\x7f\xfb\x90\xf8\xad\x0c\xe7\xfd\xee\xf0\xc8\x19\xfb\xf1\xd9\xfe\xe7\t^\xfbU\x01N\xff\x97\x07\x18\x02\xc0\xfe\xf7\xf7\x80\x11@\xf6\xe2\xfcY\x14\x93\xf1:\x05\xdf\x001\xfeU\x04\\\xfd{\x06"\xf9=\x06k\xfcL\xff;\x03<\xfc^\tV\xeb\xf2\r=\x04\x9f\xf6\xc7\x002\x06\xce\xf9\x1b\xf0t\x18\x1b\xf7\x0c\xf0\xd3\x0f\x9c\xfe\xd1\xf3\x8a\x05\xd1\x04a\xf4\xe0\x00I\x06\x84\xf7,\x01\xc7\x06\x88\xff+\xed\xb0\x10\xa7\x02e\xf2\x8e\x04\xb3\x05\xa1\x00\xc1\xf1\xbd\x16J\xf4w\xfd\xaf\x04\xed\x00\xe1\xfb\xbb\x02~\x0c\xa6\xee\xd7\x0eq\xfdI\xf4i\x0c\xf6\x00\xdd\xf82\x03\xf8\x06\xa7\xf8\x13\x00\xb8\x074\xf7G\xff\xbb\x03\x14\x04\x00\xf9.\x02\xb5\xfd\xc7\x00\x05\x03\xf2\xf6\xaa\x08\x94\xfb\xcb\xf9|\x07\x02\x05\xad\xefW\t5\x05E\xef\x19\x0e_\x03\xaa\xf3J\x07\xe9\x02\x07\xf3\xce\x11=\xfb\x84\xf3\xbb\x12\x80\xf7\xd4\x009\x05\xf2\xf8-\x03\xef\x02\x94\xfe\x8f\xf9\xb2\x0eb\xfe\t\xeb^\x11H\x05\\\xee~\x07k\x07\xb0\xf0\n\t(\x05\xe8\xf0\x06\x06\x19\n/\xf1\xc0\xff\x9f\r\xf5\xf3\x1e\x02^\x00\xdc\x01\x8c\xfe\x8d\xfey\x02(\x01\xd4\xfb\xc0\xfc\xfa\x0b\xfa\xfa\xed\xf6\xfa\x0cS\xfb\xcc\xf8_\x0b\x12\xfd2\xfa}\x03l\x04Z\xf9\x10\x082\xf7\xab\x02^\x05\xdc\xf9(\x004\x02\x8d\x08\x19\xee\x1d\x0c\x0f\x00\x7f\xf6{\x0c`\xfa\xd1\xfa\x85\x07X\xfd\xea\xfd\x90\x005\xff8\x00z\x013\xfa\x91\xfc\x8a\x06\\\xfd\x00\xfa[\x03\x1a\x01e\xf6\xa2\x06s\xfbQ\xfc~\x04\x1d\xfc3\xfe\xe0\x05\x8d\xff\x03\xf9\x8c\x08\x0b\xfcn\xfe`\x04+\x02\xf7\x01/\xff\x0f\x00N\x05h\xfe\x94\xfd \x07\xd8\xfe\x15\xffR\x04\xaa\x00\x17\xff\xf3\xfdi\x045\xff9\xfdL\x043\xfd\xf1\xfe\xec\x02\xed\xfd\xf1\xfd\x04\x03\xf5\xf9\x88\x01\xa6\x00\xd6\xfb\xd4\xff\xe9\x00\xaa\x01u\xf8\x83\x01\xd7\x03\x8e\xf74\x05\x0f\x00\x01\xf9;\x06\xaf\xfde\xfc-\x04z\x025\xf9\x86\x04\xff\x02\x8e\xfa\xb8\x02\xe3\x03^\xfe\xc6\xff\xb3\x04\xa1\xfa\xd5\x03U\x02\x1c\xfdy\x01 \x00\xdf\x00\x16\xfes\x04:\xfc3\xfe\xc3\x03n\xfc\x7f\xfe\x95\x010\xfe`\xfe)\xff\xf3\xfe\xc2\xffS\xfd\xc9\xfe=\x01I\xfd\xb8\xfc1\x04\xe9\xfe\xc4\xfd<\xff\xcf\xff3\x02\xcc\xfe\xeb\xffz\x02\xb9\xff\xe4\x00\xb3\x01r\x01\xe1\x01D\x01\xf2\xff\x1a\x02d\x03`\x00\xd8\x00\x18\x02{\x01k\xff\x7f\x01\xab\x00\xb1\xffH\x00\xf9\xffi\xfe\x98\xff\'\x00q\xfd\x14\xff`\xff\x11\xfe\xb0\xfe\x93\xff\xba\xfdc\xffN\xff|\xfe\'\xff\xdc\x00\x1b\xff\x02\x00L\x01H\xff`\x00\xd8\x00\xf0\x00\xe9\x007\x01\xdd\x00\xe3\x00\x92\x00\xda\x00\xe2\x00\x84\x00\xe9\xff\xa5\x00\x0f\x01\x01\xff2\x003\x00f\xff\x9f\xff\xfa\xffY\xffz\xff\x8a\xffw\xff\x95\xffc\xff\x8c\xff\xa8\xffw\xff\xa1\xff\xf2\xff\xb7\xff\xc8\xff\xb8\xffG\x00\xd1\xff\xd0\xffd\x00\xfa\xff\xa9\xff\x9c\x00M\x00\xca\xff+\x00\xd3\xffa\x00\xda\xff\xfe\xffb\x00\xaf\xff\xb4\xffK\x00\x98\xff\x94\xff[\x00\xb4\xff[\xff\x06\x00\x02\x00r\xff\xdf\xff-\x00\xae\xff\xfa\xff`\x00\x01\x009\x00`\x00\x16\x00\x8b\x00\x96\x00=\x00\xf0\x00\x99\x00i\x00\xf9\x00\x91\x00B\x00r\x00\x8a\x003\x00\x07\x00\x82\x00\x15\x006\xff\xe7\xff?\x00\x04\xff\x8e\xffB\x00\xb0\xfe\r\xff\x0c\x00T\xff\x11\xff\xb1\xffa\xffD\xff\xf2\xff\xbd\xffx\xff\xf9\xff\x02\x00\xf8\xff\xe9\xffj\x00P\x00\xfc\xff]\x00\x87\x00J\x00H\x00\x99\x00K\x00\xec\xffk\x008\x00\xda\xffX\x00\xfe\xff\xb7\xff\xc6\xff\xc8\xffD\x00\xc0\xff9\xff\x11\x00\xc9\xff\xe7\xff\xab\xff\x9b\xffw\x00\xa4\xffY\xff\xd4\x00)\x00(\xff\xef\xff/\x01/\xff.\xff\x05\x01}\xff\x9b\xff\xbe\xff\x13\x00K\x00\xa3\xff\xb9\xff\xaf\xffb\xff\xaa\x00\xb9\xff\xeb\xfeI\x00\x97\xff$\x00\x07\xff\xc7\xff\r\x01\x9c\xfe\x13\xffE\x01Q\x01\x1c\xfe\xaf\xff\x8a\x01\x87\xffl\x00\x8f\xff\x0e\x01r\x01L\xfd\xf7\x00\xc3\x03d\xfe\xd9\xfd4\x02\x00\x01\x00\x00\xa0\xffh\x01.\x01v\xfe\x8c\xff\x18\x02a\x01\xac\xfcM\x01\xd4\x00R\x00\xb7\x00\x15\x01w\xfd\x80\xfd`\x02\xa3\x03\x07\xfe\xeb\xfa\x80\x03\x94\x01p\xfd\\\xff"\x02\x05\xfc,\xfe*\x04\x0f\xfe\x93\xfdK\x03\xf8\xfd\x13\xfb\x85\x04\xd2\x02\xc4\xf8{\xfd\xc4\x08{\xfe\xd5\xfa\xde\x00+\x04\xff\xfd\x02\xfe#\x00\x03\x01\xad\x00\xc6\xfd|\x01\xbc\x02\xbd\xfe\x02\x00\xaf\x00\x04\xfe\x94\x00\'\x01\xba\x03\xbb\xff\x9d\xfc\xbd\x02\xd0\xfd\xa4\x01\xc3\x01z\xf9\xdd\x06\xb3\x04\x95\xf4r\x00q\x04|\xffW\xfeE\xfe\xdd\x03~\xfc\x1b\x00\x12\x02\x07\xfb\xbd\xfe\x18\x07\x97\x00W\xf7\x0b\x05>\x04B\xf7\xe6\x03\x81\x03[\xfd$\xfcM\x03\x85\x07\x05\xfa\x1c\xfd\xac\x01c\x06y\xfe\xcb\xf7\xd5\xff\x08\nx\x03\x85\xf2\xec\x02K\x0b#\xf5@\xf8$\n\xbb\x06H\xf5S\xfc\xa1\x07\x05\x02\xe3\xf9\xe9\xfbB\x07(\x00\x12\xf8\x8e\x02~\x0c(\xfa\xa8\xf3\x04\ta\x05\xe7\xf8\x84\xfe\x97\x06\xc8\x04%\xf3\x92\xfd\xdc\x0e\xa0\xfe\x93\xf2\xd3\x00\x90\x05\x88\x01\xaa\xfbH\x00?\x05?\xfa/\xfd:\x05\xd8\x05\xa5\xf8I\xfb\xfa\nK\x07\xb4\xf2\xf5\xfdX\n\xfe\x03\xb7\xf6z\xff\xf1\x03\x98\xff\x85\x032\xff\xce\xfc\xb4\xfb\xea\x00H\x07\xf4\xf6\xb7\xfd\xe9\x07\xec\xff\x08\xf8\xc4\xffN\x04\x81\x00}\xfcn\xfa\xee\x04\x98\xfc\xce\x03l\xfc\xd0\x04\x0b\xff\xea\xf3e\x06\xb3\x07\xbf\xfc\x86\xf92\t\x82\xff\x10\xfe@\t!\xf9&\xfd\xa2\x07\xf8\xfa\x99\t\x03\x02\xc5\xf7\t\xf8\xaa\x0ci\x07\x0e\xec|\x00/\x05\xe6\t\x07\xf8\x0e\xf17\x07\x18\x03\xaf\xfd8\xff\xbd\x03S\xf2\xaf\xfc\x9a\x15\xd7\xfc.\xf3\x8f\xf7\x9e\x07+\x14\x9c\xf8\xbb\xe8Z\x03D\x1b\x11\xfd\xa7\xe9\x9a\xff\x81\x12\xbd\x05.\xf7\xe3\xf5\xd0\xff>\x04\x99\nu\xfdB\xfa\x14\xfe\xa1\xfcn\r\xc1\xf7Q\xfb\xbd\r\x1b\xfc \xf8i\x04\xeb\n\x08\x00\x0c\xf7\xe9\xfa?\x02?\x01\x06\r(\xff/\xf1\xf5\xff\xb5\x01g\x03\x17\xfe#\xfe\x13\x06\xb9\xf8\x8e\xf4z\x0b\x1b\x0c\x9d\xfc\xc2\xf1\xe2\xf9\x86\n\x9d\n\xad\xf7\xed\xf3\x9f\tZ\tN\xf3\xc2\xf9D\x10\xd9\x02\x88\xf2\xdf\xf6\xa4\x0e3\x0f\xc0\xf5\xdf\xf2\xb2\x0b\xa4\x02\x81\xf4\x18\x06\x07\x06l\xff\x88\xf9\xfe\xfc\x1d\nk\xfc\xee\xf4\xf6\x06\x88\x01\xb4\xf9E\x05\xf1\x05\xd7\xf4\xc2\xfe\x96\x07\xf9\xf59\x03\x9e\x08\xf0\xf5:\x01@\x0e\x93\xfa}\xf2\xb6\x02\t\xff\xcd\x02\x02\x05\xc0\xf6\xaa\x07\xb3\x02\xa3\xf3\x1b\x07\xea\xff\xf4\xf1N\xfe\x92\x10\xfb\x06\xcb\xf5\xb5\xf7#\x00\xf1\x07\xcc\xfd\x92\x03Z\xf9\x17\xf6!\x0f\x80\x07\xd5\xfa\xfd\xfc/\x02\x04\xfa\xcb\xf8\xe8\x07\x86\t\x91\xfe\xf3\xfc\r\x05\xf6\xfdm\xf4 \x00\x93\n\xbf\x07j\xf3|\xf8\xeb\x0eQ\t:\xf5\x19\xf5\x9c\x06\x8a\x02\xda\x00\xc8\xf5\xd5\x06\x1a\x0c\xa9\xed.\xfd<\x0c1\xfff\xedq\x01^\x0e\xdb\xfd\xb3\xf6\xc7\x05\x0c\x08\xef\xf7\xea\xf6o\x06\x10\r\xde\xf9\xd1\xf0\xf8\x07c\x14\xb0\xfc\x08\xf8\xe4\xfb\xd9\x00\xca\x06H\x01\x82\xfb[\x02m\x00\x87\x01\x8b\x03\x03\xfe\x9e\xf7\xcc\xfb\x94\n>\x01q\xf8X\xf99\n:\x08Q\xfd$\xf9\xe9\xfb4\x08\x02\x07\x89\xfau\xf6E\nr\x0b\x16\xfb\x82\xfc\x1b\x01\xff\x03w\x01\x1e\xfb\xf9\xff\x9b\x03\xdb\x00\\\x03U\x02\x85\xfb!\xfcR\x03\x89\x01\x10\xfc\xec\xfe\xd3\x01/\x06L\xfbH\xf6Q\x02\x11\x02\xa9\x01n\xf8\x19\xf9l\x02|\x04\xd8\xfc\xe2\xfbW\xfdQ\xfe\x85\x00R\xfe\x17\x00E\x00\x15\xff\xf0\xfd\xde\xfb2\xff\x17\x08K\xff\x16\xfa\xa9\xfd\x8d\x01?\x03\x98\xfb=\x00\x8e\x04D\xfd\xdb\xf5\x94\x03n\x0b\x91\xfd\xc2\xf0\xf5\xf9\x87\t\xd3\x05\xb4\xfb\x8f\xf2\xf3\xf7u\x06C\x08\x00\xf9i\xf0\xe5\xf6Z\x01\xf5\x07?\xfe\x05\xf6I\xf9t\xff\x7f\xfe/\xf7\xf7\x01\x98\t\xea\xfd\x06\xf4\x19\xf7h\x05\x8f\t\x87\xfd_\xf3\x97\xf5k\x03<\x08I\x05\x91\xfc\x95\xf2+\xf8\xe4\x02\xda\x07w\x07\x91\x08}\x07&\x06\x1f\tP\x11\xa8\x12\xa3\x07\xa6\x06\xce\x15\xfd#y \xdd\x10\x11\x0c2\x12\xa9\x12O\x11\x81\x12\xfd\x108\x0b\x99\x05\xbe\t$\rT\x00\x8f\xf0g\xee\xce\xf6,\xfb\x08\xf6\x93\xf0W\xee2\xea\x17\xe7s\xe80\xebU\xe9\xc9\xe6\xc8\xeb\xaa\xf4\xf3\xf8\r\xf5\xe0\xef\xbf\xf1]\xf6\xd0\xfa\xfb\xff{\x03\x1f\x05v\x044\x045\x07\xf9\x07\xe2\x03\x00\x00\xb0\x02B\t\x85\x0b\x83\x06a\x02\xa8\xff\xbf\xfd \xfc\x8e\xfc\x1b\xfdl\xfb\xa4\xf9\x00\xf9\x13\xfb\xb9\xfa$\xf7U\xf3D\xf3;\xf8s\xfc\xed\xfcT\xfd\xb8\xfc\xd3\xfbA\xfeN\x01\r\x02"\x02\x83\x03\xb3\x06\x97\tf\n\xad\x08m\x07\xb2\x07^\x07C\n\xa9\x0c\xf7\x0c\x96\nn\n\x13\n7\n!\tX\x07\x19\t\x8f\t*\n\xbb\n\xb5\x08\xa7\x03\xc1\x00\x8e\x001\x01\x9d\x01\xec\xff\xa9\xfe+\xfc\x8b\xf9\xe4\xf7f\xf6\xbe\xf4k\xf3(\xf4\xcf\xf5\x98\xf5\t\xf5\xa0\xf4{\xf2^\xf1\xdb\xf3\x0e\xf5\x9a\xf5a\xf7u\xf8\xf2\xf8\xef\xf9\xa5\xfa}\xf9+\xf9\xce\xfb\x0f\xfe)\xffv\x00\xc5\xff\x07\xffE\xff0\xff\x1e\x00\xc7\xfe\xf0\xfd\xe9\xfdi\xfd\x9e\xfc&\xfb\xe5\xf7\x07\xf7C\xf7j\xf8\x94\xfa\x88\xf80\xf7\xc8\xf43\xf3;\xf5\x9f\xfe0\x0c}\x13b\x11T\n\x85\x0e\x8c\x1a|\x1f\x12\x1dr\x1e=,\xf88\xf38\n/\xca$1!\xfa\x1e\xa4!\x07$Y#\x81\x1c\xcc\x12\x98\x0c\xf3\x04\x0f\xfa-\xefH\xeap\xeb\x9f\xeeq\xeeo\xe8\x11\xe0c\xd7\xe7\xd3[\xd5T\xda\x93\xdf\xc4\xe2\xbe\xe7\x1f\xec\x81\xee\x8f\xed\xaf\xecQ\xf0\xee\xf4\xc3\xfb\xeb\x03[\n{\x0c\xd2\x08\xcb\x05\x93\x06\xb1\x08\x02\x08I\x05\xc1\x06i\n|\x0b\x91\x06\x10\x000\xfaT\xf6\x84\xf4\xff\xf5_\xf82\xf7\xae\xf3\x9a\xf1-\xf1\xa5\xef3\xedZ\xeb/\xee\xd7\xf2\xdd\xf6)\xfa\xb3\xfb\xf6\xfa\xfc\xf8\xcd\xf9\xf3\xfe\xc8\x02d\x05\r\x08;\x0b\xa7\x0e\xec\x0e@\r;\x0c/\x0c4\x0c\xfd\x0e^\x12\xbb\x13O\x11\xd3\r\x06\x0bF\n\xee\x08|\x06x\x05,\x05\x8c\x06f\x07\xce\x04;\x00\xb3\xfbZ\xf8>\xfa\xf5\xfc,\xfel\xfe\xc0\xfc\xb9\xfa.\xf9n\xf90\xf9\x9d\xf8-\xf9\xbb\xfb\xbf\xffI\x01\xa4\x00p\xfd\xc9\xfa_\xfc\xee\xfdR\x011\x02s\x02\xc6\x01\xfa\x00\xb5\x00P\xfe\x86\xfc\x9a\xfb\x8e\xfb\r\xfd\xd5\xfe6\xfe\xbe\xfb\xf0\xf8\xb3\xf6\x17\xf7\xd5\xf7\x0e\xf8\x91\xf8v\xf9\xb8\xfa\x07\xfbK\xfa\xb2\xf81\xf7:\xf7v\xf9\x0c\xfc\xb2\xfc\xd4\xfd\xb8\xfd\xb7\xfbs\xfc\xde\xfb\xf9\xfb|\xfd_\xfd\x0c\xff5\xff\xcd\xfe}\xff]\xfc\x90\xfc\xec\xfci\xfc\x9c\xfep\xfe\x83\xff\r\xfe\xc7\xfc\xae\xfd\xde\x04m\x0f\xa9\x13a\x0fq\ne\x0eV\x1a\x1f \x9d\x1c\x99\x1d\xa8#\x1e+Q+\x96$q\x1f\xa6\x1a-\x18\x13\x19\xcd\x1b\x9b\x1bh\x13\xef\x07\xd9\x02\x9d\x00\x83\xfb\x1e\xf4\x07\xee\x97\xec0\xec\xb0\xe9d\xe9K\xe7\xd5\xe1\xcc\xdc\x8b\xdc\x9a\xe2\xdc\xe8e\xeb\xe1\xebS\xee\x1a\xf22\xf5\x0c\xf7d\xf8\xac\xfb\x01\xff\xac\x02\xf7\x07\xa1\x0b?\x0bk\x07m\x05\xe3\x06\xd2\x08\xef\x08\x1e\x06\xab\x04\xcc\x03r\x02G\x00\x01\xfd\xde\xf9b\xf6$\xf4\xbb\xf4\xda\xf6g\xf6\xd7\xf2\xbb\xef\x9f\xef\xd2\xf0\xd5\xf0(\xf1{\xf2\xb3\xf4g\xf6\xf8\xf7\x97\xfa\xc2\xfb\xbb\xfbW\xfc\xe5\xfe\xd8\x02\x06\x06/\x07\xb5\x07\x9d\x08p\t\x9b\n\xa1\n\x96\nW\nG\x0b\x9f\x0c\xd1\x0cO\x0cg\nW\x08\xb9\x06o\x06M\x06@\x06&\x05E\x037\x02\xa9\x01X\x00\x9a\xfe\t\xfdv\xfbZ\xfb\xc0\xfb\xcc\xfb\xde\xfb\x15\xfbh\xf9N\xfa\x02\xfb\x06\xfb+\xfb\x9b\xfa\xc7\xfd\x18\x00\x89\x01)\x03q\x03\xee\x03\x1f\x05\xec\x06k\x08\xa1\t?\t!\n\xf1\x0b_\x0b\x17\x0b8\x08\x95\x06V\x06B\x04\x11\x04\xee\x01R\x01\xed\xfef\xfcp\xfb]\xf9}\xf7n\xf5C\xf4U\xf4\x82\xf4]\xf44\xf4\x7f\xf3N\xf25\xf2\x88\xf2W\xf35\xf4\x86\xf4<\xf6(\xf7\x1c\xf8\xe8\xf8 \xf8\xff\xf7\xe3\xf89\xfa\xa5\xfb\xf3\xfcT\xfd\xd5\xfd\xcb\xfd\xe0\xfd\x11\xfe\xf0\xfd\xa1\xfe[\xfe\xd1\xfe>\x00\xbb\x00\xf5\xfe\xb9\xfe\x94\xfe]\xfe\x93\xff\xbb\xff\x19\x01\xd6\xff/\xff\xcd\xff\xd1\x01\xc9\x05V\n)\r\xae\r\xed\x0c*\x0e\x88\x12n\x176\x1b\xaf\x1b\x91\x1d7 \x9f!\x15 \x8e\x1c\xd7\x1b\xce\x1aI\x19y\x18(\x17\xaf\x14\xe7\x0e\xd5\x08\xc7\x05\x96\x03p\xff\x90\xfa\xa8\xf7c\xf6\xcd\xf4\xb2\xf1\xc1\xef\x1e\xef\xc2\xec\n\xea \xeb]\xee\x08\xef\xd7\xed\xa3\xed\x9b\xefq\xf1\x8d\xf1:\xf3c\xf4I\xf4B\xf4\xf5\xf4|\xf7\x90\xf8\x9f\xf7#\xf7\x06\xf8\xd4\xf8F\xf8\x7f\xf8\x9e\xf8\x84\xf8R\xf8?\xf8~\xf9\xe5\xf9\xcb\xf9!\xfa\xcb\xfaW\xfb\xa7\xfb\xb1\xfb5\xfc\xdf\xfc(\xfdi\xfe\xcc\xff\xce\x00\x9e\x00j\x00\xb7\x00T\x01\xbc\x01\xee\x01\xbe\x02\xb5\x03\xc4\x03\x01\x04\x83\x04\x89\x04\xe2\x03\xa2\x03*\x04\x04\x05\x91\x05\x06\x06\xa1\x06>\x07Q\x07@\x07S\x07a\x07\xc4\x07\xb0\x07q\x081\t\xc4\x08P\x08\xb5\x07\'\x07y\x06\x81\x05\xa6\x04\x0e\x04\x7f\x03~\x02\xf0\x01X\x01+\x00\xf4\xfe\xbb\xfd\x9a\xfcr\xfc\xb3\xfb,\xfb\x19\xfb\xc6\xfao\xfaG\xfaE\xfa\x8c\xfa`\xfa}\xfa#\xfb\xcb\xfb\xe5\xfc\xf5\xfdx\xfe\x8a\x00\xf4\x01\xd8\x01Z\x02\x7f\x03\x80\x05\x9b\x05\xaa\x05q\x06\x8c\x07"\x08\xb4\x067\x06P\x06\x01\x05"\x03\xbe\x02\xcb\x02\x18\x02\xb1\xff\x0c\xfe2\xfe\xb6\xfc\xf8\xfaN\xfa\xbd\xf9X\xf9\xf7\xf7Y\xf7\xe6\xf7t\xf7\x8f\xf6\x1a\xf6\x96\xf6\x82\xf6\x9c\xf6\xd1\xf61\xf7\x92\xf7z\xf7\xbb\xf7x\xf8.\xf9B\xf9j\xf9\\\xfa\xc6\xfbu\xfc\x8e\xfc\xf7\xfc\xb6\xfd\xea\xfeO\xff\x03\x00\xee\x00|\x01K\x019\x01\xe6\x01^\x02\x1f\x02\xf5\x01{\x024\x02l\x02%\x02U\x02\xd9\x02\xc8\x02-\x03\xd4\x03\x1a\x04\xd3\x04I\x08\xb9\n-\x0bS\x0b\xd1\x0c\x9f\x0f)\x11\x88\x12\xcb\x14\t\x16\xff\x15\x14\x16\xab\x16\x10\x17\x0f\x16\x8f\x144\x13\xb6\x11\x9a\x0f\x87\r\xda\x0b\xc4\x08J\x05/\x03\xf4\x000\xfe\x18\xfbE\xf9\xba\xf7\x86\xf4\x0c\xf2\xb8\xf1=\xf27\xf1\xf0\xee\x00\xef\x0b\xf0\xbe\xef\xd4\xee\x04\xef\x9e\xf0\xb1\xf07\xf0b\xf1\x11\xf3\xae\xf3\x9f\xf2\xc3\xf2\x9d\xf4\xb7\xf5\xb4\xf5\xda\xf5\x0c\xf7P\xf8e\xf8\xa4\xf8,\xfa\x94\xfb\xdc\xfb\x81\xfb\xd9\xfc=\xfe\x91\xfe\xa8\xfeK\xff\x95\x00\xe6\x00Q\x00\xaf\x00\xaa\x01\xb7\x01\n\x01\xeb\x00\xa4\x01\xe5\x01\x1a\x01\xae\x00\xa5\x01\xf1\x01\x1b\x01\xee\x00\xc1\x01\xf9\x01\xa5\x01X\x01:\x02Z\x03\xd7\x02\xa2\x02\x9b\x03n\x04n\x04>\x04\xf5\x04\xb1\x05\x7f\x05N\x056\x06\xe9\x06\x91\x06;\x06q\x06\xa7\x06G\x06\xd6\x05\xae\x05\x80\x05\xe1\x04 \x04\xb5\x038\x03O\x02|\x01\xe6\x00\x16\x00\x87\xff\xb6\xfe\xe7\xfdW\xfd\xb6\xfc-\xfc\xe1\xfb\x82\xfbI\xfb\xec\xfa\x0f\xfbB\xfb\xe7\xfa\xe9\xfae\xfb\x97\xfb\t\xfc\x8e\xfd#\xfe\xea\xfd\xa9\xfe\x1e\xff#\x00\xcb\x00,\x01Z\x02\x9d\x02\x85\x02\xb4\x02u\x03?\x04\xa4\x03\\\x03\x9f\x03\xd1\x03*\x03l\x02\xac\x02K\x02\xa3\x01\xe3\x00\xae\x00\xb0\x00\xc1\xff\x8c\xfe0\xfe!\xfel\xfdg\xfc8\xfc0\xfc\xc5\xfb \xfb\xf9\xfaO\xfb*\xfb\x93\xfa\xa4\xfaZ\xfb\xca\xfb\x9d\xfb\x9c\xfbm\xfc\xbe\xfc\x95\xfc\xd6\xfcv\xfd\x13\xfe{\xfe\x83\xfe\xcf\xfe\x90\xff\xc5\xff\xc1\xff~\x00\xba\x00\xbd\x00\xa2\x00\xe8\x00"\x01\xf1\x00\xe7\x00\xf0\x00\xf2\x00\xbe\x007\x00\x95\xffe\xff\x0b\xff\x05\xff\xff\xfe\x08\xff\xe2\xfe\xcb\xfe\x1c\xff\x83\xff]\xff\xc3\xffr\x01-\x03\xe0\x04\x07\x06\x1d\x08@\n"\x0b\x18\x0c\xd3\x0e\xe9\x11L\x13\xc1\x13r\x14\x97\x15\x8a\x15;\x14i\x14`\x15\x19\x14\xef\x104\x0eN\r\xbb\x0b\x16\x08\xce\x04\x1e\x03\x02\x016\xfd\xfa\xf9\xf5\xf8\x12\xf8\xe2\xf4^\xf1\x85\xf0S\xf1\x17\xf0\x1d\xeeM\xee\xa1\xef\x80\xef\x18\xee\xba\xee\xd0\xf0\x1f\xf1I\xf0A\xf1\xcf\xf3\xdb\xf4\x8a\xf4\xeb\xf4\xae\xf6\xb8\xf7y\xf7Y\xf8\x04\xfas\xfa8\xfa\xa1\xfaG\xfcU\xfd\x0e\xfdR\xfdH\xfe\x89\xfeY\xfe\xfb\xfe\xd5\xff\xfb\xff\xae\xff\xef\xff\xa0\x00\xba\x00T\x00m\x00\xc1\x00\x82\x005\x00\xad\x00j\x01(\x01\t\x01\x9a\x01\xea\x01\x03\x02f\x02\x13\x03\xc0\x03\x06\x04k\x04U\x05\xf8\x05Q\x06\x9a\x06\x06\x07z\x07`\x07b\x07\xa8\x07\x84\x072\x07\xdd\x06\xbe\x06\x91\x06\xd5\x05 \x05\xa6\x04\xf4\x03\n\x03?\x02\x90\x01\x0f\x016\x00\x06\xffN\xfe\xc8\xfd\xd2\xfc\xf1\xfbU\xfb\xd3\xfa=\xfa\xa5\xf9\x82\xf9\x89\xf9\x7f\xf9M\xf9\\\xf9\xa9\xf9\xcd\xf9\xf4\xf9e\xfa\xf3\xfaP\xfb\xa7\xfbQ\xfc\r\xfd\x8d\xfd"\xfe\xa0\xfe\xfe\xfe\x9f\xff[\x00+\x01\x0c\x02\xf5\x02\xbc\x033\x04\xdd\x04\xa8\x05\x9e\x06\xfb\x06\xe5\x06|\x07\xf6\x07\xcd\x07C\x07\x10\x07\xfd\x06\x16\x06\xd9\x04\n\x04\x8e\x03\x92\x02\x02\x01\xd8\xff<\xffa\xfe\x1b\xfd\x0e\xfc\xd4\xfbQ\xfbR\xfa\xbe\xf9\xcb\xf9\xa4\xf97\xf9\xfa\xf8&\xf9\xa2\xf9\xc9\xf9\xd4\xf98\xfa\xd2\xfa=\xfb\x98\xfb.\xfc\xf4\xfcx\xfd\xa3\xfd-\xfe\xc2\xfe+\xffv\xffV\xff\x80\xff\x00\x00\x95\xffH\xffp\xffu\xff\x13\xff\xa0\xfek\xfew\xfe:\xfe\xc1\xfd\xc3\xfd\xd6\xfd\xcc\xfd\xe5\xfdD\xfe\xd0\xfe\xf2\xfe\xb8\xfeS\xff%\x00\xe7\x00a\x01&\x02\x10\x03t\x03\x14\x04 \x05\xc7\x05p\x06w\x07\xa9\x08\xe6\t\x8c\n@\x0b5\x0c\xf4\x0c\\\r`\x0e\x88\x0f\x07\x10\x04\x10\xd1\x0f\x02\x10\xe9\x0f\x1c\x0f\xc2\x0e\x96\x0e\xa3\r\xd9\x0b\xfa\t\xdf\x08\x9b\x07K\x05\xf8\x02b\x01\xdf\xff\x94\xfd&\xfb\x8e\xf95\xf8%\xf6\xe8\xf3\xe6\xf2\x92\xf2l\xf1F\xf0\x1d\xf0\x8a\xf0e\xf0\xee\xef_\xf0]\xf1\xd7\xf1\x0e\xf2\x15\xf3\xa5\xf4\x87\xf5\xf3\xf5\xd0\xf6"\xf8 \xf9\x94\xf9Z\xfa}\xfb8\xfc\xa1\xfc8\xfd>\xfe\xf8\xfe+\xff\x88\xff\x1b\x00\x8c\x00\xc7\x00\x1d\x01\xa1\x01\xda\x01\xe8\x01%\x02\x7f\x02\x9c\x02\x8a\x02\x9a\x02\xa8\x02\x89\x02b\x02t\x02\x86\x02\\\x02?\x02F\x026\x02\x01\x02\xea\x01\n\x02\x18\x02\x02\x02\x02\x026\x02a\x02X\x02i\x02\xaf\x02\xd6\x02\xae\x02\x8a\x02\xbc\x02\xe1\x02\xc8\x02\x9b\x02\xa3\x02\xb3\x02m\x02\x1e\x02\x02\x02\xda\x01g\x01\xe4\x00\x9b\x00\x82\x00\x1f\x00\x8c\xff\x1c\xff\xd2\xfed\xfe\xdd\xfdu\xfd.\xfd\xd7\xfck\xfcA\xfc1\xfc\t\xfc\xdc\xfb\xc9\xfb\xed\xfb\x14\xfc*\xfc`\xfc\xa1\xfc\xd8\xfc\x13\xfdm\xfd\xef\xfde\xfe\xcf\xfe\x18\xffl\xff\xe0\xffy\x00\x12\x01\x80\x01\xf2\x01y\x02\xc3\x02P\x03\xd9\x03\x8a\x04\xc1\x04\x84\x04\xe2\x04C\x05q\x050\x05S\x05\x91\x05\x17\x05p\x045\x04r\x04\xd9\x03\xfe\x02\x9b\x02l\x02\xc9\x01\xc1\x00P\x00\xfc\xff7\xffg\xfe"\xfe\xe8\xfd\x08\xfd"\xfc\xda\xfb\xaa\xfb@\xfb\xc4\xfa\xbd\xfa\x98\xfa\x10\xfa\xd8\xf9\x1d\xfaD\xfa\x05\xfa\n\xfa<\xfa\x8c\xfa\x86\xfa\xa0\xfa\x17\xfb6\xfbE\xfb\x8d\xfb\xf3\xfb>\xfc\x87\xfc\xc2\xfc \xfds\xfd\xa9\xfd\x1c\xfe\x7f\xfe\xe4\xfeA\xff\x9d\xff\x0b\x00\x80\x00\x08\x01\x89\x01\xff\x01y\x02\xea\x02R\x03\xd0\x03B\x04\x95\x04\xe5\x04Q\x05\xd7\x05*\x06\x19\x06Z\x06\xb7\x06\xba\x06\x9e\x06\x8d\x06\xb1\x06\xab\x06D\x06\xf4\x05\xd9\x05\x91\x05\x1a\x05\xc2\x04\x80\x04-\x04\xbb\x03B\x03\x1d\x03\xf1\x02\xac\x02\x86\x02{\x02y\x02M\x028\x02_\x02z\x02n\x02T\x02U\x02U\x02\x14\x02\xf3\x01\xd2\x01}\x01\x15\x01\x9e\x00A\x00\xc0\xff)\xff\x89\xfe\xd7\xfd7\xfd\x93\xfc\x0c\xfc\x92\xfb\xfa\xfa\xa5\xfa,\xfa\xd1\xf9\xa3\xf9z\xf9\x8a\xf9a\xf9~\xf9\xca\xf9\xf8\xf9U\xfa\x85\xfa\xda\xfaQ\xfb\xa5\xfb+\xfc\x9b\xfc\x0c\xfde\xfd\xae\xfd5\xfe\xa5\xfe\xfc\xfeH\xff\x88\xff\xd5\xff\x11\x00M\x00\x9b\x00\xcb\x00\xee\x00\x1c\x01M\x01u\x01\x80\x01\x88\x01\x87\x01\x88\x01\x94\x01\x9d\x01\x96\x01\x7f\x01[\x015\x01\x13\x01\xed\x00\xbe\x00\x89\x00X\x00(\x00\x03\x00\xdb\xff\xa7\xffv\xffQ\xff-\xff\x15\xff\x03\xff\xf0\xfe\xf8\xfe\xff\xfe\x06\xff\x0e\xff\x1b\xffE\xffv\xff\xae\xff\xd6\xff\xfe\xff-\x00f\x00\xa4\x00\xd3\x00\x03\x011\x01]\x01\x85\x01\xb2\x01\xdc\x01\xf5\x01\x0b\x02"\x02A\x02N\x02E\x02>\x028\x02.\x02*\x02\x1c\x02\t\x02\xe4\x01\xb8\x01\x9b\x01\x81\x01R\x01\x0c\x01\xd3\x00\xa0\x00l\x00@\x00\x06\x00\xc4\xffv\xff2\xff\n\xff\xdd\xfe\xa9\xfei\xfe7\xfe\x17\xfe\xeb\xfd\xc4\xfd\xa7\xfd\x8b\xfdy\xfdc\xfdV\xfdD\xfd\x1f\xfd\x00\xfd\xfd\xfc\n\xfd\x0f\xfd\xf8\xfc\xea\xfc\xf0\xfc\xed\xfc\xe8\xfc\xf6\xfc\x04\xfd\x08\xfd\x14\xfd(\xfdc\xfd\x86\xfd\x98\xfd\xd7\xfd\x1d\xfei\xfe\xb2\xfe\xf1\xfeW\xff\xb7\xff\xff\xffq\x00\xec\x00E\x01\x9c\x01\xfb\x01d\x02\xca\x02\x06\x03I\x03\x9f\x03\xd6\x03\xfd\x03%\x04L\x04m\x04\x84\x04\x8d\x04\x95\x04{\x04`\x04L\x040\x04\x18\x04\xf0\x03\xb9\x03{\x03.\x03\xe6\x02\xa0\x02Q\x02\x01\x02\xaa\x01Z\x01\xfe\x00\x9a\x00<\x00\xdf\xff\x80\xff\x1c\xff\xbf\xfeg\xfe\x11\xfe\xc8\xfd\x93\xfdd\xfd6\xfd\x13\xfd\xfd\xfc\xe9\xfc\xec\xfc\xfb\xfc\x0b\xfd\x1e\xfd?\xfdc\xfd\x88\xfd\xb5\xfd\xe3\xfd\x04\xfe(\xfeL\xfe\x80\xfe\xb3\xfe\xb1\xfe\xd5\xfe\xf2\xfe\xf5\xfe\x11\xff\x15\xff%\xff=\xff+\xff&\xff:\xff:\xff?\xffH\xffT\xffh\xff\x80\xff\x91\xff\xb6\xff\xde\xff\xf5\xff*\x00[\x00\x92\x00\xd5\x00\x11\x01W\x01\xa4\x01\xd7\x01\t\x02P\x02\x80\x02\xa9\x02\xd8\x02\xfd\x02$\x030\x03&\x03+\x03\t\x03\xfb\x02\xca\x02\x8f\x02b\x02.\x02\xe5\x01\x8c\x01K\x01\xeb\x00\xa1\x00C\x00\xd7\xff\xa1\xffb\xff\x01\xff\xdb\xfe\x97\xfef\xfe-\xfe\x10\xfe\xda\xfd\xcc\xfd\xc0\xfd\xa7\xfd\xa6\xfd\xb0\xfd\xb9\xfd\xc2\xfd\xd4\xfd\xb3\xfd\x05\xfe\xd8\xfd-\xfe+\xfeY\xfe\x85\xfep\xfe\xe7\xfe\xb9\xfe\x15\xff)\xff\x90\xffu\xff\x94\xff\x07\x00\xfd\xff@\x00\x8f\x00\xdc\x00\x00\x01\x0b\x01N\x01\x98\x01\x90\x01\xae\x01\xcb\x01\xf1\x01>\x02\x17\x025\x02\x82\x022\x02\x93\x02\xa3\x01<\x02\x03\x01\xf5\x00\x81\x00`\xff<\x05\x02\x059\x00\xf1\xfb\x98\x04\x9f\xfbB\xfck\x04\x06\xfa\x02\x02\x88\xfa\x8c\xff\xd8\xfdt\xf9*\xfe\xad\xfb\x8c\xfd8\xfdt\xfd;\xffZ\xfd\r\xfe\xe9\xff\xfd\xfd\xa9\xff\x18\xff\x83\x00\xfe\xfe\x82\x02\x85\xfec\x01|\x00x\xff\xb6\x02,\xfft\x01\x00\x00\x9b\x00\x93\x00\xff\x00\xf4\xffk\x01\xe1\xff\xe6\x00\t\x00,\x00\x19\x01_\xff(\x01?\x00\xda\x00H\x00\n\x01\x97\x00\'\x01\x07\xff;\x02=\x00\x1c\x00\xc1\x01"\x00\x9a\x01\xae\x00{\x01\x11\x00,\x01\x00\x00\xaf\x01\x0c\x00\x1e\x02\xc6\xff\x1c\x02\xa8\xff\x06\x01T\x00\xa6\xff\x92\x019\xfds\x04\x16\xfc\xe2\x01\xe9\xfe\x0e\xff\x7f\x00\xcc\xfd\xbb\xff\xea\xfd\xf2\xfe\xe4\xfd\x90\xfe\x86\xfe\x98\xfb\xf5\xffA\xfc:\xfd=\xff,\xfbr\xffi\xfc\xa2\xfe\xad\xfd\x8e\xfec\xfe@\xfe\xd6\xff\xa5\xfe\xed\xff\xdd\xffN\xff\x98\x010\xffO\x02~\xff\x04\x01\xfe\x01\xd4\xfe\xac\x032\xff\xe8\x02\xdd\xff\xf8\x01\xd6\x00\xa6\x01%\x00[\x02\x12\x015\xff\x06\x04a\xfe\x9d\x01.\x01\xaf\x00\xba\x00T\x00\x8d\x02\x8c\xff\xd5\x01\xed\xfe\xfe\x01\xb8\xff\xc1\xffZ\x03\xba\xfd\xe4\x03\xf2\xfd\xd5\xff\xcc\x02\xec\xfeK\x00\x85\x01\xfe\xfc\x83\x01\xd1\xff\x1d\xfd\x03\x04\xa5\xfb\x19\x01\x1a\x00\xa1\xfb\xe2\x02\xfd\xfcO\xfd\xe6\x02`\xfbs\x00\xc1\x00\x8e\xfb\x15\x01T\x00$\xfc5\x02\xdb\xfc,\xffT\x02\xab\xfb\xf6\x02.\xfe\xb9\xff\x82\xffU\x00\x08\xff\xca\x00\xf5\xff\xd5\xff\x15\xff\xf3\x01\xdf\xfe\x10\x03\xd8\xff\x90\xfd\xc0\x04V\xfd"\x01M\x03\xb1\xfe\xad\x00\xb0\x03b\xfcO\x04\xf8\xffD\x00\x93\x010\x01,\xff\xc9\x00t\x02\xf0\xfc\xb2\x03\x89\xff2\x003\x01\xe8\xff^\xfe\xd8\x03\xe0\xfc\xde\x01,\x01\x9c\xfc\xcb\x01\xfd\x02[\xfa\xc1\x04\xf1\xfe\x8c\xfcW\x068\xfb\xe6\xfd\xbd\x03(\xff\xc6\xfd:\x06n\xf9U\x01\xb3\x00\xf3\xfd\xbf\xffO\x03\x0e\xfd\x0b\x01\xfe\x01#\xf9\xfe\x04Q\xf9\x9a\x06.\xfc\xc2\x01P\xfdr\x02\x16\xfdC\xff\xf9\x01\xe0\xf8\xf8\x07\xe5\xfa\xa1\x01\xb3\xfe\xbe\x03/\xf9\x17\x07\xe8\xf9h\x00\x88\x06\x19\xf4\x8e\t3\xfe\xa6\xfeS\x00\x96\x03\xb4\xf8\x9d\x05\xb4\x00\xa3\xfa\xa0\n\xa7\xf78\x04\xc7\x01|\xfcx\x04\xbf\xfeE\x01 \xfc\x92\x06\x04\xfcF\x04\xc8\x01\xd9\xfbn\x06\xda\xfa^\x02r\xff\x1d\x03x\xfd\x8b\x03t\xfe\xd6\xffO\x02=\xfaI\x04\xa2\x00\x82\xf7\x88\x07\x12\xfb\x18\x03>\xfe\xd6\xfcF\x03\xff\xf8\x7f\x08\xe6\xf6\xa7\x05\x0f\xfbH\x04(\xfb&\xffw\x07)\xf5n\x08\'\xfaX\x04\xc9\xf9t\x03R\x01\xa9\xfc\x9e\x03\xe1\xfd\x07\x02\x18\xfb\x06\x04c\x00\x88\xfdW\xff\xd3\x03v\xfb\xff\x03\xf4\xfd#\xfd5\x04\xb4\xf9\xc2\x03\xd3\xfd?\xff\x9f\x00\xbb\x01\x8b\xfd\x1d\xfd\x8e\x03K\xfb\xe9\x02\xf3\xfeG\x00\xf3\xfc\x91\x06[\xfa\x87\xfe\x7f\x05\xf7\xf7\x91\x04k\xfe\xec\x01\x10\x00\xe4\xfds\x02\xb8\x02\xe1\xf7L\x07\x98\xfd\xc0\x01\x98\x01l\xfdQ\x04A\xfb\xbf\x05\x9a\xfc\xf7\x03\xae\xf9\x91\x07\xa9\xfd\x8b\xffn\x00P\xfeX\x00\x94\xfeH\x05\xd5\xfc\xa9\x00\x19\xfc\x8a\x03\xcb\xfb8\x00E\x03o\xfa@\x03\x9e\xffM\xfdr\x04\xa4\xf7J\x02\x88\x03g\xfa\\\x02\x8c\x00T\xfc-\x01\xab\x01]\xfd\xe2\x00(\xffX\xfcr\x06\x9d\xfc!\xfe\x7f\x08"\xf8\x1d\x05,\x00\xd6\xfe\xcd\xff\xd3\x01\x17\x01\x06\xff\x15\x07\xcf\xf7c\x05j\x02D\xf9b\x06d\xfe\xa6\xfeC\x05\xc6\xf8\xb5\x03\xa1\x04\xac\xf7\xa7\x05\xe9\xfd\xbb\xfd\xa8\x00\xaa\x00\x81\xfed\xff\xb8\x00\x9f\xfc;\x06D\xf8\xb5\x05\xc3\xfe\xa1\xfd!\x03\xc0\xfe\xcc\xff\x00\x00\xe3\x00\xca\xff\xfd\x00\x80\xfee\x02\x15\x00\xd3\xfd\x07\x00\x1c\x05a\xf8\xb4\x04l\xff\xd1\xfaB\x08\x15\xf9U\x02\xa9\x00n\xfc\xf3\x04\xa9\xfa\x0e\x04\xca\xfa|\x03\x84\xfeL\xfdz\x06+\xf8\xe8\t\x0f\xf6\x0b\x01\xaf\x05X\xfa\x87\x03\x08\x03\x18\xf9\xa2\x05\xba\x01\xc3\xf8\xb2\x06\xe2\x00\xf4\xfda\x02\x06\xff\x81\xfd\x1a\x05f\xfdu\x038\xfd"\x01\xc5\xffu\xff\xe0\x01\x89\xfb\x1a\x04B\x00\xe9\xfd\x06\x01t\x05J\xf9\xd6\x00\xb9\x01\xb1\x00z\xfe\x07\x00\x1d\x07\x98\xf5\x1b\t\x85\xf9\xbb\x00\x13\x04\x18\xfb\xb9\xff!\x03l\x01Q\xf8m\nl\xf7\x08\x00r\x03\xcd\xfd\r\xff\x97\x02&\xff\x15\xfd\x13\x03\xec\xfe\x03\xff\xc4\xfev\x030\xfd\xfa\xfa\xa9\x06b\xff=\x01@\xff\x17\xf9\xe4\x07\xcc\xfb\xc5\x00\xc2\x02\xa4\xfdM\xfd\x1c\x04\x03\xfe\x14\x00T\x03\xd6\xf7\x0b\x07\x83\x01K\xf7\xa0\t\xf1\xf9\xbe\x00\xd0\x04+\xfa\x8f\x03=\xfb\xd6\x053\xf9\x06\x07\xd7\xfbr\x00\x01\x042\xf7\n\x07\xb9\xfbf\x01\xa5\x05\xd2\xf8\xcd\x02\x8f\x01\xc8\xf8\xe4\x07\xd7\xfb^\xff\xe4\x03\xa5\xfci\x00\x93\xff\xb9\xff\x01\xffi\x01\xfe\x00\xb3\xfdV\x02F\xfc1\x03\x97\x00\xfc\xf9%\x04\x82\xff\xf6\xfd\x15\x00\x8f\x05\x9f\xffL\xf6\xe1\x080\xfb\xd1\xfd\xb8\x07\xb5\xf8\xec\x06\x9f\xfb\xb9\xfd\xf6\x03Y\x00w\xfc\x95\x03-\xfe\x99\xfb\xcc\n\x9e\xf4\xbd\x04\xb7\x00\x04\xfc\xdd\x07Q\xfa \xfc\xfe\t(\xfa{\xfeX\xff\x87\x06\x8f\xfc\x19\xfc\xb8\x0f\xab\xef!\x06U\xfe\xed\x00Y\x04c\xf9#\x08\x0c\xfd\xc4\xfeR\xfe\xe2\x04\xe5\xf9E\x07u\xfa\x8b\x01\xfd\x025\xf9\xcc\nh\xf1\xdf\n\x17\x00\x02\xf7~\t\'\xf9\x1c\x02\xd9\x02\xa2\xfdW\xfe\xad\x00\xcf\x02a\xfa}\x06c\xf8\xa6\x00\xc9\n\xc9\xf6\x8e\x01\xa4\xff\x97\x00T\xfd(\x00\x17\x04\x19\xfc*\x03\x1e\xfc\xdc\x04\xc3\xfc\x9b\xfb\xf8\x08\xc7\xfeo\xf6\x16\x0b\x01\xf9Q\xfe\xf9\x0b8\xf5\xf2\x03g\x00@\xff,\xfb\x97\x0b\xa5\xf3\xc5\x03\xf8\x05w\xf7t\x056\x00E\x04`\xf4\x98\x06\xda\xfeZ\xfbA\nf\xf8[\x03\xde\xffe\x00 \x03\xf5\xf2\x1a\x0e\x96\xff\xb5\xf5\x8c\n\x00\x02t\xf4\x14\x0b\x9a\xfb!\xfce\x07\xc7\xfcu\xfdZ\x07@\xfc\x02\xfb\x8c\t\r\xf5\x9a\x08\x1e\xfb\x9b\x01\x8a\x06\x16\xf7k\x00p\x08\xde\xf6\x8e\xfe%\x0b7\xf4L\x03\xf8\x06\x9c\xf7\x17\x01\xf2\x02\x82\xfb\xfc\x01G\xff\'\xfe\x97\x02\x1b\xfcB\x07D\xfef\xf7h\x07\xf2\xfd\xe0\xf6/\x05b\x08B\xf6\xb5\x07\x95\xfa4\xff\'\x04f\xfc\xe2\xfc\xee\x06\xdf\x03\x8a\xf4\xcd\x0c\x11\xf6?\t\x14\xfd@\xf6e\x14\x92\xed\x84\x06\xda\x03\xb7\xf8\xbb\x03V\x00&\x02\xf3\xf3\x10\x10\xc6\xf3z\x02V\x08n\xf4m\n\x82\xfb:\xf7\x88\t\xcb\xf9}\x04U\x032\xf4\xc7\x0c\xc1\xf7\xdd\xff4\x03u\xff\x85\xfeo\x00\x86\x00\xc9\x02[\xfc\x9d\x00m\x02.\xff\x89\xfb\xc0\x08\x00\xf8\xe0\x00l\x04\x90\xfa1\x06I\xfc\x81\x06\xf5\xef*\r\xbd\xfc\xca\xfc\xba\x00\x0b\x01\xc7\xff+\x01\xb6\x03+\xf1R\x0f\xb6\xf5\xc6\x00*\n\xb8\xf2\xfb\x0b\xe7\xfb"\xf8`\x0eY\xf1\x9d\x060\xff\xc1\x008\x02?\xf8T\x08\xb9\xf4\xe1\x0f\x0b\xf5l\xfb;\n7\xfa\x94\x012\xfc_\ns\xf4#\xfc\\\x13\xdf\xf2\xe1\x01x\x02o\xfbw\x00\xca\x00^\x01w\x02\xba\x00T\xfa~\x06\x84\xf7\xa2\x02+\n\x07\xf3\x81\xff\xb9\x10\xf1\xee\r\x05\xfe\x07w\xf4\x83\x07\x81\xffa\xfb\x00\x03\xf9\x03\xd8\xf6\xd9\x0c\x1e\xf9\xd5\xfc\xb0\x03\x97\x00\x19\xfd\xa3\xff=\x05\x0e\xfaS\x06\n\xf9@\x03\x8b\xfeu\x01\x80\xfd\xde\xffM\x05C\xf9L\x05\xa6\xfc~\xff\x8d\x01\xc4\xfb\xab\x057\x00a\xf9\xd9\xfeB\x06\xa5\x02\xf7\xfam\xfd\xd1\x01\\\x01\x17\x01\xef\xf96\x06\x17\xfe>\xfb)\x07=\xfdl\x03\x11\xfe \xfb\xec\xfd\xce\x0c\x86\xf9C\xf6\xa7\x0f\x11\xffG\xf1x\r\xc8\xfeT\xf8k\x06\x0b\xfb\xd7\x01T\x05\x8e\xf7\xc0\x06x\x06\xf7\xea\x10\x10Z\x00\xad\xf3,\n[\xfeo\xfeT\x02V\xff\x9c\xf9\x91\x10!\xf5z\xf6\xee\x13\xef\xf5M\xfc\xb4\x06\xf6\x01=\xf7C\x0bO\xf5\xa3\x08\\\xfd|\xfa\xe3\x0bn\xf3t\r\x9d\xf0j\x08L\x02\xd1\xf9\xa0\x022\x02\xd6\xfa6\x05L\xff]\xfam\x06\xc3\xf6\xb0\x08}\xfe\xe3\xfep\xfb{\x11W\xeb\xa7\x00\xed\x0b5\xf7\x16\x08\xb9\xf8y\x08\xfd\xf9\x88\xfe\x1f\x07\xb0\xf9\xbc\x01\xde\x06\x17\xfci\xfa\xeb\xff\xfc\x06\xfa\xfa\x1e\x01\x17\x07\xe6\xf5\xf1\x00\xa5\x04z\xf9\'\xfe\xdf\x01\xb9\x04\x8e\xf8\n\x07>\x06\xe5\xf6\x89\x04\xa9\xf2\xbf\x08\xd5\x08\xff\xef]\x11a\x00\x15\xf6\n\x01\x17\x01\xc0\x04]\xf5\xf8\x05\xf5\x04\xd1\xfa\x9f\x02\x87\xff/\xfee\x01\xc2\xfa)\x03O\x05\xdb\xf6E\x01w\n\xc9\xf4n\x03\x91\n"\xeb7\x07\xa0\x0c\x99\xe9l\x07A\x14#\xeb\xd5\x04\xc2\x05\xa3\xf1\x05\tP\x08\xaa\xf1j\x03|\x06c\xf8F\x00\xf7\tD\xf4\xc2\x01\xe5\x08\x1b\xf5\xb4\x04S\x06\xb3\xf7 \xfe,\x0c8\xf82\xfco\x04\x80\xfcv\x03\xfb\x04v\xf4u\x03\xad\x0e2\xe9\xb5\x02\xbb\x11!\xf2\x90\x05O\xff\x00\xfd|\x00\x02\x06,\xf2\xe0\x0bb\x00|\xf1\xb2\x16\xe2\xed\xf5\x03\x0b\x02\xd0\xfdg\xfb\xfc\x0b\n\xfaK\xf8M\x0f\xe7\xec\x1a\x12\xa5\xf4M\xff\xbb\x02\xe0\xfb\x08\x0b\xae\xf4M\x108\xec\xa4\x08\xde\x00\r\xf3<\x17`\xee\xf0\x03=\x07\xd4\xf87\xfe/\x07`\xfa\xe1\xff\xa7\x06\xc5\xef\xd8\x11\xbe\xfc\x1d\xef\xcf\x16\xe2\xf1\x08\xfdQ\x15\x93\xe5\xa7\r}\xfe\x99\x00r\xf8\x19\x03\xd9\t2\xfbX\xfd\xb5\xfa\x8e\x16\xe7\xde{\x10o\r{\xed\t\x02\xa8\x0c\xdf\xf4\xc6\x02-\x03\x9d\xf31\x15.\xedG\x04\xc3\rv\xeb\x85\n\xac\x03\xee\xf3]\r\x99\xf7\xcb\x01(\xfe\x99\xffH\x03\xcd\x04t\xf2\xed\n4\xfd\x88\xfaw\x0c\xda\xe9U\x159\xf6_\nN\xf4\x9d\x01(\x05\xdd\xf7A\x04\x9a\xfa3\x0eH\xf2q\x00T\x0c\x17\xf6[\xf6P\x15\xbc\xf8\x83\xef=\x16r\xef\x17\x04r\x0b\x02\xf7\xd5\x01{\xf8d\x11n\xee\xc7\x04\x1c\x04\xca\xfb\x80\x01\xa5\xfe\x0b\t\xfe\xf7\xbb\x03\x90\xff"\xf6\x95\x07t\xfd-\x046\x01\x9d\xf9\x85\x0b\r\xf6u\x02\xab\xf9\xf9\x0bP\xfc\x8e\xf6\xe6\x15\xba\xef]\xfd\xe7\x10\x8d\xed\xdd\x01Q\x0b\xf3\xfe\x97\xeeW\x19u\xf2\xf7\xf9\xf9\x11\x06\xe7g\x12\x11\xff1\xf9y\x04\xdb\x06\x12\xf0z\x10Q\xf5\xf8\xfd\xee\n\xee\xf4\xcb\x04\xd5\x05&\xfc\x19\xfbM\x07\xbe\xf60\x0b\xd8\xf3\x1f\x02\xf6\x0b\xaf\xf2t\x08\xcf\x02\xd7\xf6\x16\xfc\xf9\r\x0e\xec\x08\x02\x1c\x19\xdb\xe6C\x0bD\x00\xa9\xf8\x8e\x04f\x03\xc9\xf36\x0b\x17\x04\xa3\xf6w\n\xb8\xf6\x8b\x0b\'\xf3\xda\x02,\x10\x9e\xe8\x80\x0cl\xff\xb1\xf5Z\r\r\xf6.\n\xc0\xf1\x02\x02\x90\x06\x12\xfc\xc0\x02\t\xff\x12\x043\xfav\xf9N\x06\xdd\xff\x9b\xfeY\x06\x1c\xf4\xda\x08:\xfc\xac\x00T\x04\x7f\xf7\xa3\x06_\xffQ\xfc\xe8\x08\xb5\xf7\x1b\x03\x0b\x02{\xfb\xdc\x07p\xff\x83\xf44\t\xfe\xfb4\x01I\x05\xbc\xfc\x9f\x07\xfc\xecS\x0bs\x02\x89\xfa\xcd\xf7_\x12-\xef\xf8\x0c@\x03\xbb\xe7\xf3\x18n\xef\x9f\x017\x03F\n\xf9\xf1\xe7\x05\xf9\x04\xed\xf2F\t\x98\xf8\xde\x04\xa4\x01\x06\xfa\xeb\x02\x80\xff2\x00u\x05I\xf8\x95\xfbU\x07\xd6\xfc\xa9\xf9\xbc\x12\xb2\xf2\xd6\xf5\x0e\x0fT\x02x\xfd\xfd\xf8\x1a\x08\xaa\xf6\x0e\x03\xb9\x01\xfc\xfdG\x0f\xa8\xf1\x0f\x02j\x01\x0f\xfe\xd7\xfd\xb2\x04\x99\x02\x95\xf2\x08\x11G\xfa\xa7\xfdQ\x01\x7f\xfe\xb3\x04*\xf8<\x06\xa8\xfb;\x07d\xf8\x97\x06P\xfd\x84\xfc\x8e\x06I\xf5\xa8\x0c^\xf4\xc1\x07\x07\xfe\xfc\x00\xb5\xfe\x87\xff\xa8\xfe\xed\xfb\xee\x0e;\xf1V\x05J\x00\xb5\xfa\x08\n!\xf7\x1d\xfd\xd1\x0e\xf8\xf5\xb3\xfe\xad\xfc\xe6\x06\xa3\xfe\xa7\xfb1\n9\xf5\\\x03R\x03\x1e\xf9\xa0\x07\xf1\xfeP\xf4\xdc\x07\xf0\x06}\xfc\xf2\xf9\xac\x08v\xf9\xed\xfe\xca\x02|\xfd\x07\t\xe2\xf1\x84\x05\x11\ns\xf3>\t\xda\xf4W\x05\'\x01s\xf7\xe5\x0e\x12\xf9\x10\xfe!\xffn\n\xbb\xf1\x95\x08|\x01Q\xf6Z\t\x03\xf73\n]\x02\x97\xf0\x1d\x0e\xaa\x02,\xf15\x0fT\xf5\x89\x01\xf5\x06\x11\xf77\x03Q\x0bP\xf3\xb0\x02E\x00\xdd\x01;\xfes\xfaY\x06\xcb\x02\x9f\xfaT\xfa\x85\x10\x06\xed\x12\n\xca\xfd\xb0\xfb\xc0\t\xf0\xf5+\x017\x03o\xfa\x80\x07\xff\xf9\r\x02\xa0\t\xdc\xeaD\x11w\xf3/\x07V\x03\x19\x01\x80\xf6\x02\x08\xd2\x06\xb2\xe9\x90\x1b\x92\xf3\xd2\xfb\xcd\x10\xbc\xee\x93\xfe^\x11R\xf4\xa7\xfe\xdd\n\xe6\xf2r\xff.\r\x7f\xee\xe7\x04\x1c\x01d\xf5\xb0\n\x08\x014\xff\xf4\xf7\xb9\r\xbb\xf5P\xfb\xab\r\x81\xfd\xc5\xfc\xcd\x06\xe1\xfc(\xff\xbc\x08A\xef\xaf\tW\x07\x0e\xf3x\x06\x12\x01\x99\xfeN\xfb\r\x04\xab\xfb!\xfd\x81\x0b?\xf8b\xfd%\x08\xff\xfd\xb2\xf5\xb1\t0\xfd\x81\xf0X\x11\xac\xfc\x9a\xf99\x10~\xf2\xe5\xfc\xa1\x07^\xfe,\xfe\xa4\x06\x80\x00\xd1\xf4\x07\rR\xfc\xd1\xfb\\\x04N\xfep\xfe\xce\x04\x1f\xff\x17\xfc\xf0\x020\x03\x0f\xfe3\xfc\x86\x03\x10\xfc\xc9\x03v\xfd\xda\x01\x16\x04e\xf8z\t\x07\xf8\x8c\x00[\x01\xc0\xfa\xd1\x03\x07\x03\xea\x00\xe4\x02\x07\x01\xb7\xf2\xd2\xfe\x81\x08\xa9\xff\xa3\xf8\x05\nz\xfe\xaa\xf6s\x07\xd3\x01\xb6\xf5\xd5\x05\x12\xfd\x9e\xf4\x16\x10\x13\xff}\xfd\xe5\x04%\xf7F\x00\x9a\xff\x02\x00%\x06|\xfaI\x01\x92\x06/\xf9\xdd\x056\xff\xf2\xf3\xc0\n\x9c\xfch\xfe\x11\t\xe8\xfd\xc9\xf8\xa2\x00^\x06\xa0\xf7\x18\x05\xf9\x01R\xf78\x02\x1f\x03\xdd\x01\r\xfb\xc8\x03\x83\xfc\xd2\xfb\x13\t\x08\xf9\x8b\x02T\x00\x80\xfa,\x05\xe0\xff\xd7\x01\xee\xff\xe8\xfe\xf4\xfaY\x00.\x02J\x017\x00\xe4\x00A\x00,\xfc\x12\xfe\x9e\x03o\xfd\xff\xfe\x98\xff\x12\x05\xd2\x04\xc4\xf9\x12\x00w\xfd\xdd\x037\xfc\xc7\xfd\xe3\x04^\x07\x13\xfa\xdd\xfe?\x04\x0c\xfb)\x02\xa1\x00?\xfc\x87\xfc\xd7\x03\xd6\xfd\x1d\x05\xeb\xfbs\xfe\x10\x00\xb2\xf8n\x02n\xfe\xa8\x01\x16\xfe\xf4\x00\x98\x02~\xfe\xe3\xfc4\x03\xb0\x00?\xfe\xb8\x02A\x00T\x00i\x04\xd7\x00\x17\xfd\x0c\x00@\x03\x00\xfd\xb7\x00\xc4\x02#\xfcQ\xfe\x16\xfd)\x01Y\xfeW\x00\x14\xff8\xff\xf7\xfcN\x02\xe2\x02e\xfb2\x04V\xff\xca\x02:\x03\x16\x03\x93\x02l\xfd\xb6\xff\xaf\x01n\x03\xf0\x02\xd1\xffx\xfe\xe7\xfc \x00\x94\x02\xe6\xfa\xfa\xfe\xaf\xfd(\xfd\x9b\xff\x03\xfe\x91\xfd\n\xfd8\x00\xe5\x00b\xff-\x02\x96\xfe\xce\xf9]\x04\xa1\x02\x84\x00\x04\x06`\x02\xf7\xfeF\x01\x85\x00\x06\x00\x10\x03.\x03;\xfe!\x00\x8d\x020\x00\xdf\xfe\xde\xfd\xcb\xff\x08\xff"\xfc\x19\x01J\x01Q\xfd\x11\x00l\xffk\xfd!\xff\xf6\x00\xec\xfe\xb6\xff\xda\x04\xf0\xff|\xfe*\x02\xf2\x00\xcb\xff\x17\x03b\x01\xdc\x01\xb8\x00\xed\xff\xce\x004\x00\xc0\x01\xd1\x005\x01\xab\xfe\xb0\x00\xae\xff\x98\xfe\x88\x00\xa7\xfeJ\xff\xd5\x01\x99\x00\x86\x00\x97\x00\r\xfcF\xfd\xfe\xff\xb4\x00\xff\x01]\x02|\xff\xcd\xfe\x87\x00\xfb\xff\xa3\xfeW\xfe\xcf\xff\xc8\x01\xd3\x01\xbc\x02\xe5\xff:\xfe8\xfdc\xfc.\x00\xfc\xff\x02\x00\x8d\x00\x1c\xff\xd9\xff\x1e\x00r\xfd\x8b\xfc\xf1\xfc\xf5\xfc8\x00\x1c\x02\xfe\x00\xfc\xfd\xf4\xfd^\xfd\xf2\xfb\xc3\xfd\x9c\xfe+\xfe\x00\xffW\xff\xcc\xff\x8b\xfd\x94\xfc\x93\xfb\xc6\xfb\xf1\xfco\xfdM\x01\xef\xff\xfa\xfc\xe3\xfe\xee\xfd$\xfdO\xfe\xf0\xfc\xec\xfc\x13\xfd1\xffU\xff\xf5\xfe\x0c\xfe\x7f\xfd\x14\xfd@\xfc\xf6\xfa\xfe\xfb+\xffB\x02{\x07\x1f\x0b\xe1\nB\x08n\n\xdc\x0c\x03\x11c\x12\x19\x14\xd9\x17\x9d\x18\xef\x19f\x19\x9c\x16\xc7\x11\x9e\r\x11\x0b\x95\x0c\xb3\r\xb7\t\xc4\x049\x01\x99\xfb\xf8\xf6\xb5\xf4X\xf1w\xeeN\xeb.\xed\x86\xed_\xee\x11\xee\x93\xea%\xeb\xe0\xea\xec\xed\x16\xf2Z\xf7\xed\xfaA\xfd*\xff\xc6\x025\x06\xa9\x05\xf3\x07\xe4\x06\xa0\tb\x0c\xa7\x0c\x90\x0c\x16\x07\x07\x05m\x01\xf8\xfe+\xfek\xfax\xf6\x0f\xf4/\xf3\xd6\xf1\x0f\xf1\xbc\xed\xa0\xeaU\xea\x07\xea|\xed\xdd\xefM\xf0\x84\xf0x\xf1;\xf3\x7f\xf5\xde\xf8\xe1\xf7\xa3\xf7\x96\xfbd\xfe\x03\x00\xda\x02q\x01F\xfeK\x00`\x00\x06\x02M\x03\xbd\xff:\xfe\x0f\xff\x05\x00\x11\xff\xbd\xfbS\xf9)\xf8\xcd\xfaG\xfdR\xfc\x9d\xfb\xfd\xf8\r\xf9\xbb\xffc\x04\xe2\x048\x03b\x03\xf1\xff\xa3\x016\nY\x15\xd8#\xda(\xd9*\xc8+\xe2,\xad.I+\xac)Q-24\xa68~3\xd2(\xf7\x1b\x8d\x0f\xb3\x08(\x03(\xfe4\xf8E\xf3\xe9\xf1\xe1\xf03\xec\xf2\xe1J\xd7G\xd4\xab\xd6x\xddb\xe5~\xe9\xc9\xeb\xb9\xed]\xf0t\xf3\xd4\xf7\xbd\xf8\x9d\xfb\x9e\x02\xc1\n\xb3\x12\x11\x15\xf0\x11_\x0cc\x07F\x05\xe1\x04\x9e\x03\xbf\x00\x1d\xfe\xb8\xfa\xc2\xf7\xcc\xf4%\xed\x10\xe6\xef\xdf\xee\xdd\xa2\xe0\xe3\xe3\x18\xe6W\xe7\xa8\xe7"\xe9]\xec\x8b\xf0\r\xf5\xf3\xf7\x16\xfd\x82\x04\x0b\rR\x14\x0e\x17f\x16\xc7\x15\x15\x16\x99\x17\xc9\x19C\x19h\x15\xbc\x12\x91\x0f\xed\x0b0\x07\x18\x01\x19\xfb\xed\xf6\x9d\xf6\xd5\xf5R\xf5R\xf3\x0e\xf1+\xef\x9c\xf0\xf4\xf3\x10\xf5}\xf6\xd9\xf8\xf5\xfa\xb7\xfez\x02\x1c\x01\xe0\x00\x99\x00\xa4\x00\x9f\x04\x96\x05\x18\x03S\x01\xab\xfd\xa7\xfc\x86\xfe\xa9\xfa\xfb\xf6\xb1\xf3\xaa\xf1 \xf2M\xf3\xb1\xf1\xff\xee\x06\xeeN\xeb\xdf\xed;\xf2\xea\xf3~\xf6p\xf4U\xf7\xc1\xfb\xde\xff\x19\x03\xae\xff#\x04+\t\xc3\x16\x15-\xb76\xdd8\x1f1\xa1+\xa71p7\x8c6\xd0344\xd73p/\xdb$\xeb\x16\xc9\x04\x9c\xf4\xd0\xed\n\xef\xcc\xf1\x01\xee\xb2\xe5\x8f\xde\xde\xdb\xeb\xd9\xe6\xd7h\xd7Q\xdaC\xe1#\xed"\xfaS\x02\xcf\x03\xc8\x00Y\x00\x04\x04\x11\x0c*\x14\x8a\x18\\\x1a\x0b\x1b\x94\x18y\x13&\x0c^\x00\xec\xf7\xe5\xf4\xb6\xf3"\xf4\xc9\xf0@\xe9\x7f\xdf\xe7\xd6\xde\xd4.\xd3\x80\xd3\xac\xd6>\xdc\xdd\xe4\x1a\xebb\xef#\xf1m\xf1r\xf5\x07\xfd0\x08\x7f\x12,\x18\x06\x1ai\x1a\xd1\x1af\x1a\x8e\x18@\x14\xf9\x11\x00\x13&\x16:\x14\xae\x0c\xe1\x02q\xfa\xb3\xf6s\xf5\x84\xf65\xf5\xe1\xf4M\xf4\x0f\xf4\x0e\xf8\x1c\xf7\xce\xf5\xd6\xf7\xb8\xf9\xb8\x02\xaa\tl\x0c\r\rU\n\xc5\t\xc9\tP\n\x87\x08\x98\x05\xc6\x03\x0c\x02\x17\x01\xe2\xfdj\xf6\xf5\xee.\xeaD\xe7[\xe7h\xe6\xd1\xe4\xa9\xe4\x15\xe4\xee\xe6\x9f\xe81\xe6Q\xe6\x13\xe7\x7f\xec\xb5\xf5\x8b\xfa;\xfc\xe9\xffA\x03\x9f\x06\x94\x0bE\x08\xc7\x05\x18\n;\x0f\xb7\x16\x1c\x199\x19\xe7\x1e\xdb+\x025\x193r*\xeb"\xd6!^#\xbe\'\xbd,R,\xc9"\xe6\x15c\r\xa8\x07\x9c\xfe\xa3\xf3\xb0\xef\xeb\xf12\xf6\xe4\xf7\xc6\xf6\xff\xf1\xb7\xe9\x19\xe3\xcc\xe4\x97\xee\\\xf8\x1b\xfe)\x03H\x07\xb9\x08\x05\x07\xd4\x03\xdc\x01\xb7\x01\xc8\x04\x07\nw\x0f\x8c\x10\xb5\nS\xffM\xf4\x9b\xef\xe1\xeb~\xea\xcf\xea\x1d\xeb\x06\xed\x87\xea\xb7\xe7\xb3\xe3\xb7\xde\xa2\xddN\xe0\xc5\xe8\xce\xf2\xc6\xfaS\xfe\x0f\x00`\x00&\x00l\x00\x95\x02.\x06\x00\x0b\x00\x11\xcc\x12O\x13j\x0e\x92\x06\xea\x01\xdb\xffK\x01\xbf\x02\xd3\x04\x8f\x04s\x02c\x00\x82\xfc\xf0\xfa\xfc\xf95\xfa\x13\xfd\x86\x00y\x05.\x08{\x07\x14\x06\xcd\x04V\x05\x89\x08f\n\x05\rA\x0e\x83\x0e.\x0e\x1d\x0c(\x08\x8d\x03\x10\xff)\xfcS\xfc\x89\xfb\x7f\xf8Y\xf3~\xee\xeb\xea\x8d\xe8X\xe7\xf1\xe4n\xe4W\xe7\x7f\xe9\x06\xed\x05\xef\xf1\xed~\xefF\xf0\xe2\xf2T\xf8\xa7\xfa\xf2\xfd7\x01|\x03T\x05\xb6\x03\x91\x01\xbf\xfe\xc4\xfe\xd0\xfek\x02/\x04H\x03\x84\x02\x00\xfe\xea\xfaV\xf5f\xf55\xffL\x0b:\x19\x86&\xd30\x003\x86+\xa1\x1e-\x1a\xfd#81\xb6:s:t2\xf6%;\x17\xe2\x08.\xfd\x88\xf3U\xed\xa6\xed\x1c\xf2)\xfa\xc9\xf7\x14\xec\x07\xe0J\xd9\xde\xda\xb6\xe1\x05\xec=\xf7\x11\x01\xf0\x07\x10\n\xb7\tE\x07\x05\x02\xb6\x00\xf6\x04\x87\x0c\xf5\x13q\x15\xeb\x10\xee\x07g\xfc\xd8\xf3\xe1\xedL\xe7M\xe5\xbe\xe6\xa1\xe7m\xea)\xe9\xe7\xe3U\xdd\xed\xd8\xe1\xda\xa5\xe1m\xea\xf7\xf1\x7f\xf9\x18\xff\xd4\x02I\x04=\x04%\x03\x11\x05 \t\xa6\x0e\x00\x14\xc0\x14\x0c\x12\xbf\r\x02\n\x0b\x04\xda\xfe:\xfc\x1e\xfft\x05\xfb\x07i\x06\r\x01\xf5\xfcn\xfb\xe9\xfa\xfb\xfc<\x01*\x05\xf3\x07r\n\xec\t\xf9\x065\x03{\x00\xf5\x02\x8e\x06g\n\xf7\n\xf6\t\xcb\x08L\x05\xde\x02\x0b\xffB\xfc\xbb\xfc\xa4\xfc\x9f\xfd\x1f\xfd\xcf\xf8\xcc\xf2\xf8\xee\x9f\xec{\xeb\x8f\xecp\xec\xbe\xec{\xee\x11\xf0\xee\xf0\x8f\xf0r\xf0\x15\xf1\xd4\xf3\xfd\xf7\x98\xfb\x0b\xfd\xea\xfc\x98\xfc!\xfd\xe6\xfdi\xfd}\xfb\xa0\xf9>\xf8\x9b\xf9-\xf9\xd4\xf6\xfa\xf3\x00\xf4\xb8\xf9\xc3\xfb\x19\xfd\xf3\xfb\xcf\xf9k\xfeu\x03Q\x0c4\x1c\xcb+\x954\x817\xd6638\x179\xc16\xe16\x819\x819\x8c5\xb9.\xe4$\xfb\x17P\x06\xad\xf8\xdf\xf2O\xefZ\xed\x95\xea\n\xe9)\xe7\x96\xe2\x98\xdd\xef\xdcy\xdf\x00\xe4\xd2\xeby\xf5\xbd\xfe\xad\x04a\x05\xfc\x04\xc1\x04\x06\x05=\x07H\n\xef\x0e\xb9\x10\xb3\x0e\xde\x08\x94\x01.\xfbo\xf3\xaa\xedW\xeb\xae\xebR\xeb\x03\xe9@\xe6\xba\xe2\xfd\xde\xf9\xdb\x9f\xdc\x0b\xe1\xe1\xe5\xd5\xeb\xca\xf0~\xf4\\\xf7t\xf8\xbc\xf9\xc3\xfc\xc4\x00f\x06\xaa\x0b#\x0e\xfb\x0f)\r\xd5\tz\tg\t\x97\nw\n\xd2\x07\xfa\x06\xb4\x07\xbb\x07\xf8\x07\x8f\x05i\x02\x02\x03\x0b\x05l\x07s\t\xf4\x07j\x07\x14\x08o\x08\x86\x08\xde\x06T\x05\x81\x05\xad\x05\xec\x06e\t\x93\tC\x06\xff\x03x\x02\x1e\x02\x03\x014\xfe/\xfd\xf2\xfd\xdd\xfc\xb5\xf9\xed\xf5s\xf0\\\xec\xd3\xeaH\xe9M\xe9\xf3\xe9%\xe8\x86\xe9\xd9\xe9\x9f\xe8Y\xe9\xfa\xe7\xb3\xe9\x1e\xef\xa3\xf2\xb9\xf6\xbf\xf9\xad\xf9\x14\xfa\xe8\xf8.\xfa\x87\xfc,\xfe\x0e\x00\xfc\x01R\x01\x08\x01j\x00\x18\xfd:\xfeu\xff\x8b\x01\xfa\x05\xba\x06\x02\x06\xa4\x06\x15\x07]\tS\x0bK\x0c\xe0\x0f+\x14\x80\x17]\x1b\x10!\x8f(\xda0\x8401*o(\xcc*\xf2,I+\xdf\'l&Z"f\x1a\x90\x12w\x0by\x04S\xfb\xea\xf52\xf6\xbc\xf6\xb8\xf3p\xecG\xe82\xe7\xd5\xe5\xba\xe5u\xe7\xe3\xe9\xf2\xec\x9b\xee\xbe\xf1\xca\xf5\xec\xf5t\xf4\x06\xf5\xfe\xf7\xff\xfbI\xfe\x9d\xfe\xd0\xfd\t\xfcI\xfaA\xf7\xaf\xf4\xde\xf3\xf6\xf1f\xef\xfe\xee\xa0\xef.\xeeN\xeb>\xe9\xd2\xe8\xb8\xe8Q\xea\x0b\xed\x15\xf0\xd5\xf2c\xf4\xe6\xf6\x1e\xf9\x1b\xfb|\xfd\xef\xffD\x035\x07\xcb\nD\x0e5\x0f\x90\x0f\xaf\x10@\x10}\x104\x11\xd3\x10\xbf\x10m\x11\xb4\x13\x05\x15\\\x10Q\x08_\x04\xc2\x03}\x04c\x050\x03\xcd\x00(\xffD\xfd\xa3\xfc\x9a\xfb\xb7\xf83\xf8\xd0\xf9[\xfd\x93\x01{\x03\xf4\x00A\xfd\xfc\xfb\xf9\xfb\x9d\xfcv\xfc#\xfcZ\xfc\xcf\xfb\xc7\xfa \xf87\xf4\'\xf0>\xee(\xee\x18\xef\xc7\xf0\x1a\xf1\xa0\xf0-\xf0R\xef\xb5\xefK\xf1\xb5\xf2Z\xf5\x00\xf8\xc2\xfb\xf1\xfeB\xfe\x15\xfd(\xfd\xab\xfd\xa4\xff\xc8\x01Z\x02\x95\x05.\x07\r\x06\x8e\x07\x9c\x06E\x04\xc4\x04T\x05C\to\x0b\x06\n\xc4\n\xe5\t\xfc\x07\xd4\x05\x06\x05\xec\x06*\x08\xab\n\x8b\r\x11\x0e\x0b\r\x91\x0cl\x0f)\x14\x14\x18\xa4\x1ac\x1f\x96%\xf2&\x9f%A$\xcb"\xdc"\x0b!\x91\x1f^\x1f2\x1a\xbc\x13\x87\x0e\xbb\x08a\x02\xdf\xfb\xae\xf5\xe6\xf1\x13\xef\xda\xeb\x1a\xe9\xbe\xe5\xb3\xe1G\xdf\x96\xde\xae\xdf\xb5\xe1\xfb\xe2\x11\xe4\xee\xe5v\xe8\x1f\xeb=\xed\xb0\xefM\xf2\t\xf5O\xf8n\xfba\xfe&\xff\xa7\xfe\xa4\xfe\xa8\xffg\x00p\xff\xc5\xfe\xed\xfe\x1c\xfe\x0f\xfcd\xfa\x10\xf9\'\xf7 \xf5\xcd\xf4\xe1\xf5\x9a\xf6p\xf6\xbc\xf6\xca\xf7\x88\xf8y\xf9j\xfbA\xfe\xb4\x00R\x03@\x060\t#\x0b\x1e\x0cE\re\x0f\xd2\x0f\xec\x0f\x7f\x10\xf7\x103\x11X\x0fQ\x0c!\nQ\x08\xfc\x05\xc0\x031\x01k\xff\xc4\xfd\x16\xfc\xfd\xfa\x88\xf9\xe3\xf6:\xf5\xbc\xf5}\xf6\x11\xf7\x80\xf7\xcf\xf7\\\xf8\xde\xf7\x04\xf8j\xf8\x9c\xf7x\xf7\xd5\xf7\x07\xf9u\xf9\xe2\xf8\xe0\xf7\xe4\xf5\x15\xf5\xbb\xf4-\xf5\xcb\xf4\xb2\xf5\x89\xf5\xd9\xf5u\xf7\xbe\xf7<\xf8\xa6\xf7\xb6\xf8\x05\xfd\x00\x01\x97\x00\x15\x01\xc4\x03\x15\x05\xf2\x04\xb9\x04Q\t\x0c\n\xfd\x08\xce\n\xac\x0b-\n\xc3\t-\n-\x08\xfc\x08\x19\x0b\xbd\n\x89\x08\x18\x08\x0c\x08\xb7\x07f\x07\x8d\x08\xb5\x07\xa1\x04\xef\x05\x91\x08<\x08@\x06.\x07\xf3\x07p\x05I\x04\xc9\x04\xab\x04\xd6\x042\x04\xbf\x02d\x04|\x07?\x08&\x07\xeb\x06i\n%\x0c\xb8\x0b\xed\x0bw\x0c2\x0c\x04\x0c\xa3\x0c\xd9\x0b\xb4\t\x95\x06@\x04\xda\x01\xc7\xff\x8e\xfe\xb6\xfcw\xfa\xab\xf7\x1b\xf6U\xf5\xfa\xf3\xff\xf16\xf0k\xf0\xfa\xf1\xa2\xf1\x80\xf1>\xf2>\xf2\xc1\xf1\xa3\xf21\xf5D\xf6S\xf6\xc1\xf7\x1b\xfa\xba\xfb\xe4\xfc\xf1\xfd\xf3\xfe.\xff2\x00(\x02\xbd\x03\xb9\x038\x03\xaa\x02M\x02\x13\x03\x90\x02@\x01O\x00\x14\x00\x84\xff\x1e\xfe\xc2\xfd\xa7\xfcy\xfa\xba\xf9\x8a\xfa\x1b\xfb\x02\xfa\xcb\xf9\xc1\xfa\x8a\xfbr\xfb\xf7\xfbI\xfd#\xff-\xff\x9d\xff\x1f\x02\x04\x04q\x03\x8f\x03"\x04\xae\x03\xb5\x04_\x03\xcb\x01\x9a\x03;\x03\x92\x01E\xfek\xfev\xfe\x82\xfa\xdf\xfb\x93\xfc\xdb\xf9\xee\xf9\xe6\xfa\xf4\xf8\xaa\xfc\x10\xfdP\xf9h\xfe\xc1\xff\x9f\xfd\xd2\x00\r\x03\xc8\x01\xc5\x02L\x03\x18\x07\x8e\x07=\x03\x82\xfeb\x05_\x05\xe4\xff(\x08\xf6\x01\x17\xfe\x8d\x02\xe6\x02\xc1\x03\xd5\x00D\xfc\x83\x00\xca\x03\x84\x01\x9e\x05\x97\x02r\xfd\x89\x02L\x04a\x02y\x03\xc3\x049\x001\x01\xd4\x04\\\x04\xda\x04\x1c\x01\x1d\x00\t\x00\xf4\x00\xa6\x02\xac\x02\xd0\xfeY\xfa\x95\x00\x9a\xff\xfa\xfa\xd8\x01\x05\x00#\xf8\x9b\xf8>\xff\xb2\x01\xd5\xfd\xca\xfc\xde\xfd\x80\xfe6\x00)\x01\xb5\x01\xf9\x00:\xfe"\x02\xc6\x05\xce\x01\xc6\x00\xbb\x01\x89\x02\x04\x01\x81\x03\xdd\x04G\x01\x9e\x00\xb0\x00\xff\x00\xa7\x03\xba\x040\xfeF\xff\xa9\x03\xe5\x02+\x00T\x00\xc7\x02j\xfd]\x01\xfe\x05_\x01f\xffH\x02\xec\x034\x00\xa4\x01\x10\x03\xac\x003\xff\x0e\x02<\x01\xcc\xfd[\x00\x12\xff\x1e\xfd\x87\xfb\xe4\xfbv\xff0\xfe\x86\xfa\x8e\xfb\x8e\xfb\x16\xf9\xa4\xfc$\xfdU\xfc}\xfc\xdb\xfb\xc1\xfc\xb0\xfe^\x00\xe7\xfcE\xfe\xf6\xfeH\xffo\x01Q\x01\xcc\xffW\xff\xfe\xfeN\x00\x8c\xffi\x01\x0e\x01\x7f\xfb>\xfd\xe9\x01\x1e\x01\xf9\xfcz\xfe\x16\xff\x8f\xfe\x1e\xff\xb8\xffP\x00\x93\xfe\xd7\xfc%\xff\x16\x01\xc2\xfe\x8a\xff0\xfe\x89\xff\xe6\xfd\xe1\xff?\x01W\x00Q\x02\xca\xfe\xfd\xfew\x02]\x04f\xff\xa4\xfd\x0b\x00\x9c\x01\xa2\x03%\x00\x9d\xfe\xbf\x00\xba\x03\xee\xfc\xea\xfd\xfa\x01\x89\xfdL\x01\x0c\xfe\x10\xff\x07\x01\xf8\xfd\x07\xfd\x89\xfcO\xff\xef\xff\x01\x008\xfat\xfe\x0e\x01\x9f\xfc\xf6\xfba\xfe\xf3\xff\x98\xf8T\xffR\x02i\xfe\xac\xfc8\xf8\xe0\x03\xad\x01\xf0\xfa>\x00\xc0\x032\xfe\x9a\xff\x87\x04\xc7\x02\xfd\xfc\x1c\x00\x1b\x08\xb5\x01\x94\x00\xce\x07\x0e\x04\xbb\xfe\xb2\x03\xae\x08)\x03D\xfc\xee\x06\xdf\t\xe4\xfb\xef\x03\x98\x08\xb0\xfd\xdf\xff\xc0\x07\x11\x03\xd7\xfd\xd9\x01\xaa\x05\xe5\x03\xfc\xfe\x8b\x05J\x02\xd1\xfd\xbb\x01\xa6\x01\xc2\x05\x97\xfe\xd2\xfd\x9a\x01 \x03\xbc\xffD\xf8|\xfeV\x03\xce\xfc\x83\xfa\x1f\xfe\xde\xfd\xd1\xfcJ\xfa3\xfe\xcf\xfcL\xf9\x0f\xfe4\xfc\xaa\xfc\x08\xfc\x90\xff\x90\xfc\xab\xfd\xe4\xfd\xfb\xfb\xdf\xfd\xa8\xfc\xc8\x02\x10\xfe\xab\xffg\x01\x02\xfe\x00\xfbY\x02\xfd\x02\x12\xff\xd7\xff\xc9\x03\x83\xff\x8b\xfc\x19\x05\x8e\x05v\xff\xbf\xfec\x071\x00\xa9\xff\x1e\x039\x06S\xff\xf7\xfd@\x036\x067\x05~\xfe\xe8\x02}\x01b\x02\xe6\x02\x9e\t\xd4\x03\x89\xff\xb1\x06\xf5\x05\x11\x00&\x03\x84\x06\x00\x00\xe1\xfe\x92\x01\x9e\x05f\xffV\xfb\x0c\xfb\x96\x00\x05\xfd\x1b\xfbn\x02\x82\xfe|\xf3\xf1\xff\x86\x02\xef\xf62\x00?\xfb\x12\xf7W\x00\xaa\xfb\xd1\x00\x89\xfd\xc6\xf6\xb6\xfe\xcd\xff;\xfb!\xfc\x81\x03\x14\xfa\x94\xfb\x08\xff\xe6\xfcQ\x01\xc0\x019\xfbH\xfb(\x03 \xfd\x9e\xffl\xfe\xf8\x01\xf4\xfe\xd4\xfdZ\xfe\xb2\x00\xfb\x03\xc7\xfb\x1c\x01\x18\x03-\xfe\x92\xff\x83\x03\xbb\x03\x00\x01\x04\xfe\x02\x01\xc2\x08!\x04\x86\xfd\x10\x01\x8b\x08\xe7\x02H\xff\xd5\x00d\tU\x04\xfe\xfb[\x05f\x02\x8f\x00\x16\x03\xbd\x00\x06\x02\xa8\x01\xbd\xf8\x86\x05\xd2\x03\xd2\xf8g\xfe\x1f\x06\x9f\xf9\xeb\xf9:\x07O\x00\xed\xf1\x9b\xfb`\ny\xf9\xd3\xfan\x00q\xff\x1e\xfb7\xfcG\xff\xcc\x00W\xf38\xfe\x1f\x08\x03\xfaD\xfb\xdb\xfd\x0b\x00\xaf\xfc\xa0\xfc\x83\x00\x16\xfd\xa1\xfa\x04\x05(\xffD\xfc\xd1\xff\x9d\x00\xe9\xfc\x93\xf8\xf9\x05\xf0\x03\x06\xf9\xc4\x00Q\x00\xea\x04\xf4\xfft\xfcb\x03\n\x00\xc7\xffX\x04\x9b\n\xc3\x01v\xfe\xfe\x01Y\x06G\x01 \x02\xa5\td\x05\xbd\xfe6\xff\'\x0c\x87\x00\xa3\xfaZ\x04Y\x03\xdf\xff\r\x02\xd8\x03\xa7\x00W\x01\xdb\xff\xdd\xfc\x86\xfe\xb8\x05I\xff\xaf\xfd\x03\xfd[\x03\xb2\x02\xb9\xfd\xa7\xfa\xf0\xf9\x05\x06\xcc\x02\xec\xf8\xb1\xfb=\x08\xc2\xfb\xf0\xf3\x0e\x03\x95\x05f\xf6Y\xf7N\x04\xbe\xfe\xe6\xfc\xd0\xfc\xb1\xff\x93\xfe\xb9\xf7\x10\xfd\x90\x01\x88\xff\x9b\xff\x03\xff\xd9\xfc\x08\xfc\xcf\x01:\x03T\xfeX\xfc}\xfdz\x06;\x03O\xfb\xf8\x01\xdb\x04T\xff\x8d\xff\x83\x06\r\x02\xa5\xff\xdc\xfeJ\x08\x1e\x03\xbe\xfc\x14\x055\x00\xbc\x00\xab\x02K\x05\xea\xfd\x82\x00G\x04%\x00\x11\x01\xeb\xff\xd8\x03\xee\xfdD\xff\\\x05\x8c\x00\xcb\xfd\xcc\x02\\\x00\x19\xfa\x98\x07\x86\x006\xff\x0c\xfe\xb4\xf7]\x0bS\x03\r\xf4\x9c\x00\x99\r]\xf6)\xf3\x18\x08J\x08U\xf6\x96\xf7\xf5\x01!\x00\xf8\x01\xb2\xf8\xe9\xfb\xf8\xfax\xfa\x86\x04\xb4\xff\x14\xf9\xd4\xfc \x02\xc9\xfb0\xfe\xae\xfe\x7f\x01L\xfe\x85\xff\xb6\x01\x91\x00\x12\xfde\xfc\x0e\x07g\xfe2\xf8\xb5\x03\xa2\t\xfc\xfb\xe2\xf7\xfc\x04_\x02K\xf7\xad\x04\xe2\n}\xfc\xd7\xf8W\x07\x06\x05\xab\xfb\x9a\xfe\xc6\x07&\x05\xb8\xfd:\x05\xf2\x03\xd1\xff\x07\x00k\x05Q\xfe\xe5\x04\xfd\x061\xf9\x07\xf9\xe7\x07Y\x0c_\xf3\x82\xfa\xef\x08\xb6\xfa\xba\xf8\xa8\x03\xf9\x07\x1a\xf8\xc8\xf5\xa5\x06d\x03v\xf8\x95\xfdC\x02\xcd\xfc\x97\x02\xdf\xfd\xce\xfc\x06\x05\x94\xfc\xf4\xfd\x1c\xfe\xdd\xff\n\x01\xde\x00\xa4\xfb(\xfe\xe3\xfc\xea\x00R\x04}\xf4\x03\xf9{\x01?\t\x95\xf62\xf5.\n\x1d\x03{\xf2\xac\xfc\xff\r\xa6\x02\x05\xf5k\x01\x9f\x0e\xfe\xfa{\xf9x\x08\xf5\x08N\xfd\xb6\xfc*\x08e\x07G\xfd+\x03\x19\xff\xe0\xfeG\x05\xf0\x02\x86\xfei\x04\xac\x03\xbc\xf6l\x01\x81\x07`\xfe\xa9\xfa_\x05\x8c\x00\xfa\xf6~\xfe\xe3\x07\x9b\xfdl\xfbl\x03\xa8\xff\xdb\xf9Y\x02\xff\x01\x80\x00\x06\x00\xeb\xfc\x19\x08\xae\xff)\xfb"\x01\x8f\x04m\xfc\x99\x02\x01\xfd\x8c\xfc\xb9\x03\xb6\xf9\x0c\xfc\xc6\x029\xfd@\xf6\xda\xfe\xa4\xfc\xbb\xf9C\x00\xaa\x00o\xfa\x1a\xfcJ\xff\xd4\xfch\xfb@\x08\xb8\xff\x8e\xf3\'\x03\x18\x074\x00z\xf9\xfc\xff]\xff\xb1\x02A\x03\x9d\x01\xcc\x034\xfd\xc7\xff\xa5\x04\x84\t\xef\xfe3\xfb\xe6\x06)\x07\x83\x05c\xfa\xc4\x05\xc7\x05|\xf8\x98\x04o\t\x1a\xff\x9d\xff\xd6\x01(\x02\xda\xfe[\xfd\x8a\x05>\xfe\xf9\xf9\x8f\x03"\x05\xe7\xf7T\xfc\xd6\xfe\xa0\xfb\xb0\xfc\r\x08\xe9\x00\xbf\xf4\xb9\xfe\xd2\xfeY\x01\xcf\xfd\xc8\x03\n\x02-\xf3F\x02\xc4\x0e\xa5\xfb\xc2\xf53\x02G\x07\xbb\xfa\x90\xfe\x9f\x0e\x05\x04\x91\xf2\xe2\xf7\x8b\x04P\x11`\x01V\xf3\x14\xf8\x86\x06:\x07=\xf7\xb5\x03;\x00\xb1\xf7\\\xf6\x95\xfd<\r\xde\x06\xc6\xf2\xb2\xf5\x83\x03\x91\x04\xa1\xfa\xe4\x01\xe1\x02U\xf8(\x03\x9b\x08\x16\x02\x0e\xfd\xf3\xff\xa6\xfaK\x04\xdf\x07X\xfe\xf0\xfeG\x05\xde\x00\x85\xfd\x03\x06^\xfd\x0f\xfd\x87\x03\x12\x02\xc7\x03\xb8\x02\xa0\xfcn\xfa\x80\x04k\x03\xc0\xfbQ\xfdP\x08\xd7\x00l\xf6\xc7\x00M\x01r\xfc|\x01]\xfd-\xfdV\x03\xd7\xfbL\xfcl\xf9\x0b\x02\x81\x0cm\xf3B\xf2 \x0b\x93\x07\xac\xfdj\xf2\xe6\x00\xa6\n\x1a\xfa\xf8\xf9d\x05(\x0b\xf8\xfc\xb3\xf0p\x00\x15\x0eo\xfd\xc2\xf2S\x02\xc1\x0e\x0c\xfb\x84\xf4D\x06Y\x08L\xfe\x01\xf5\xa7\xffh\x0fn\x080\xfa\xda\xf6u\x00n\x0c@\x03h\xfa\xf5\x01|\x06\x01\x00\xdf\xfb\xec\x03L\x05\xcb\xfd\x1b\xf7\x94\xfd\xbc\x0eA\x04\xf9\xf4\x8d\xfb/\xfdY\xff-\x03}\xfd}\x00\x03\xfe\x0f\xfc\xbf\x02\x8e\x02\x9b\xf9[\xfc+\x02g\x01\xd0\x073\x00\xc9\xfd\xa2\xf7\xc0\xf9+\tg\x04\x16\x02\xbc\xffw\xf8P\xfea\x07\xdc\xfe\x8e\xfcz\xfc"\x00\xbc\x02\xb1\xffI\x05U\x00\x8b\xf5m\xf9-\x07\n\x05\xe0\x01\xf9\xfa\xdc\xfa\x81\xfb6\xff\xd5\x05\x1e\x028\x02\xdd\xf8\x90\xfbU\x02[\x03\xbd\x00W\x01`\x00\x1e\x00%\x02d\x04a\x04m\xfcC\xfc*\xfe\xad\x00+\x08\x8f\x04\xc5\xfb\xb7\xfc\xd3\xfd\xb9\x00@\x00d\xf94\xfe5\x05\x9a\xff]\xff\x1e\x04\xca\x02\xed\xf8\xff\xf4\x11\x03`\r\x0b\x04\xeb\xfc\xbb\xfb\x15\xfd\xac\x00\xf2\xfe:\x04\xb2\x00\xa1\xf9\xb1\xfe~\x011\x04[\x04\x96\xfc%\xf3<\xfb\x8a\x0b\x10\x07\xd6\xffV\xfd\xa9\xf9\x96\xfby\x00\x16\x06s\x04\x86\xfd\x87\xfb\xa1\xfe\xae\x01\xa2\x04D\x01o\xfb\xed\xfd\x9c\x03\xa8\x02"\x05\xdd\x01\xdc\xf9\x82\xfcD\x01\xed\x04\x15\x01?\x00\xfc\xffl\x00\xa9\x01O\xff\x07\x00\xf2\xff\xa8\xfe\xdf\x00\x93\x00\xd3\x01\x87\x03k\xfc\xb8\xfa)\x00\xc3\x01\x18\x02_\x01\xd4\xfd\xd7\xfc.\xfd<\xff\xb8\x00\xff\x05 \xfd\xe5\xf7\xb2\xff\xf3\x04)\x03\xa7\xf9\x1d\xfdL\x00\xe7\x01\xb9\x01?\x03\xb7\xfdD\xfc\x94\xffj\x00:\x05:\x04\xd4\x00\xc2\xf85\xfd;\x031\x03\xbf\x03[\x00B\xfd|\xfd\x8c\x00\xd1\x01\xcf\x00\xe2\xfd\x8b\xfdV\xffB\x03*\x06{\xfeg\xfa)\x00\x9f\x01\xd3\xfee\x01\x92\x03\xb6\x02\xf8\x00\xcb\xfdp\xfe7\x00\xd4\xff2\xff"\x04\xdf\x025\xfd\xe6\xfd\xe9\x00J\x01\x8f\xfb{\xfd!\x02$\x03\xb5\x02\xc1\xfd_\xfb\n\xfe\xfb\xfe6\x00g\x02\x81\x02\x8f\xfe\x94\xfcx\xfe\n\x00K\xfe<\xfd\x19\x00+\x03w\x02\xd7\xfe\xeb\xfb\x8d\xfc\x83\xfe*\xff\xd8\x00V\x02\xdc\x02}\xfdO\xfa\xba\xfe\xbf\x01(\x00\x9b\x00\xbb\x01\xb0\x00\x03\x00\x91\xfe4\xfd\xd8\xff\xf7\x01R\x00 \x02\xd2\x02v\x00(\xfd\xa5\xfcf\x00\x19\x02\xad\x01\x94\x00\x00\x01\xa1\x01r\xfe\x17\xfe\xfe\xfe\xb2\xff\xb3\x01\x12\x01h\x02<\x03\x19\x00P\xfc\xc6\xfb\xad\x01\x8a\x05q\x03\xb2\xff\x86\x00\xb8\xff\xe1\xfd5\xff\xe2\x01\x16\x02g\xff\xef\xfe]\x016\x02\xdf\xfdZ\xfcE\xfe\x19\x00\x95\x01\xd7\x00e\xff\xc0\xfd\xc2\xfb\xbf\xfe\xed\x00&\x00\x03\x01\x8d\xfe/\xfd\xbd\xfd\x9f\x00\xf3\xff\xb7\xff\x03\x02/\xffq\xfc\xa8\xfd\x97\xfe\xbf\xfd\xe3\xfew\xff\xf2\xffM\xfc\x98\xfb\x85\xfd\x02\xfd3\xffr\xff\xcc\x01\xd1\x02T\x03\xd5\x01L\x02\xf5\x04\x85\x07{\n^\n\x0c\x0c\xc0\n3\n,\x0b%\x0b\xd8\x0bI\x0b.\n\x02\x0b\xfe\x07}\x05\xeb\x03\x9e\x00\\\x00\x9d\xff\x0c\xfe@\xfb\xe5\xf9\x83\xf6d\xf4T\xf4\xa7\xf4Z\xf4\xa0\xf3G\xf4\xba\xf2\xee\xf2w\xf4\xa9\xf6\xdf\xf8\x85\xf9<\xfb.\xfc-\xfe\xb8\xff!\x00S\x03\xe4\x04\x0c\x05\xc1\x051\x07\xf4\x06U\x05\xf9\x04p\x04"\x050\x04X\x02\x12\x01\xc8\xfd5\xfcw\xfb\t\xfa\xf5\xf8\xcf\xf7\xf3\xf6\xc0\xf4\x0f\xf5V\xf4\xbe\xf4C\xf4\n\xf5\x95\xf6\x89\xf64\xf8r\xf7\x1f\xf9\xf9\xfae\xfcs\xfe\xef\xfe\x96\x00\xb2\x00\x9b\x00B\x01\xdd\x02\xff\x04\xe6\x03a\x03\xc4\x02\xbe\x02\x1e\x03\xbd\x02\x1e\x03\xfe\x01z\x01\xa3\x01=\x00\xca\xff\xaa\xff\x11\x01\xac\x01\xd3\xff\x1a\xff\xef\xfd\xfa\xfeg\xfd\xfc\xff\xd4\xff\xbd\xfb\x9c\xfd\x9d\xfdh\x00\x11\x01\xc0\xff\x84\xffk\xfcO\xfd\xd6\x08B\x14m\x16~\x0f\t\t\xd1\x0e|\x182\x1f\x0f \xaf\x1f\x98!\x8a\x1f\xf3\x1e}\x1e\x92\x1a\x8c\x16\xf6\x11v\x16\xb1\x18\x1b\x11>\x06\xd1\xfb\x05\xfa\xba\xf9\x9d\xf8\xa9\xf7\xe5\xf1\x9b\xea\xdc\xe4\xbb\xe4\xaa\xe5\xd2\xe5B\xe4\x83\xe5\x90\xe7\xd6\xe8\x97\xea\xa8\xe9+\xeb\x80\xeeL\xf4<\xfaO\xfd\x84\xfe\xc5\xfb\x9e\xfb\xd6\xffw\x04\xa0\x07\x85\x07t\x06\xba\x04<\x03\x12\x03A\x02X\x01\xff\xff\x0f\x004\xff\x97\xfdE\xfa\xba\xf6\xe3\xf4|\xf5\xa4\xf7\xef\xf8\xe0\xf7>\xf52\xf3{\xf3{\xf6\xbc\xf8\xdf\xfa\t\xfcD\xfc\x96\xfd\xae\xfe\xb4\xff\x00\x01\xc3\x02\x98\x05k\x07\xa2\x08i\x08\x86\x07!\x07\xc0\x07}\t\xb1\n\xbf\nl\t\x18\x07\xa3\x05\x90\x05\xbf\x05[\x05\r\x04\x84\x04\xf1\x03\xa2\x01T\x00\t\xff\xa7\xfe\x0c\x01\xa0\x02\x92\x02A\x005\xfd+\xfe\xef\xfe\xac\x01g\x03\xea\x03\xf2\x00\x11\xffK\x02n\x01[\x02\xf7\x00\xa3\x01P\x03\xf0\x00\xda\x04\x90\x03~\xfe\xf5\xfb^\xfc\xf9\x02\xa9\x01\t\xff\x19\xfd\xea\xf9\xdd\xf8\xdb\xf7\x14\xfb\x13\xfc!\xf8\x08\xf5#\xf5\x14\xf70\xf6&\xf5\xbc\xf58\xf6\xef\xf5\x91\xf67\xf8\x99\xf8\x11\xf7D\xf7W\xfa\xd3\xfc]\xfc6\xfb\xdd\xfb\x1d\xfc\x9a\xfc\xb3\xfe\x9f\x00X\x00\xbc\xfe\xae\xfd*\xff@\xff\xd4\xfe\x9f\xfe\xca\xfd\xcd\xfd\xdf\xfd\xf5\xfc\x1a\xfc~\xfb\xf3\xfbh\xfc0\xfb \xfc\x95\xfb\x9d\xfey\x08\x19\x10s\r.\x04\xd9\x03"\x13\x8c\x1f\x96%M&\xe2!*\x1d\xf0\x19\xfe#\x99.\x04/\x07&\xa1\x1d\'\x1b\x07\x18\x8c\x15\xc5\x12\xb8\x0e]\x08\xf6\x02\xf5\xfe.\xf8\xfc\xef\xa6\xean\xe9\x85\xebo\xeb\x9d\xe8\xdf\xe1\x1d\xdd6\xdeL\xe4\xe7\xea\xb1\xee\x89\xef\x11\xedZ\xec\x81\xf0\xcf\xf8\xd0\xff\x1f\x02\xb3\x02\xee\x02\xb9\x04\xd2\x04J\x06\xf0\x08\xa0\t4\tD\x07*\x06r\x03\xcc\xfd\x91\xfb3\xfc\xdc\xfc\xdf\xf9^\xf5\xc5\xf1o\xee\x16\xec\xac\xec\x9a\xef*\xf0\x94\xedq\xeb\xd4\xec\xd8\xee\xd6\xf0 \xf4\x88\xf7\x17\xfa\x87\xfa\xd2\xfc\x00\x007\x02(\x04>\x07E\x0b\x9a\x0cF\x0c\xee\x0b\xc2\x0c\xae\r\x18\x0ed\x0f\xb0\x0f5\x0eO\x0bh\t%\t\x97\x08*\x08\xa2\x06\x07\x06\xf0\x03U\x01\x94\xff<\xff\xa3\xffW\xff\xff\xfd\xc1\xfd\xf2\xfc\xa8\xfb\x84\xfb\x93\xfbU\xfd\x93\xfd\x9c\xfd\x12\xfd(\xfd\xc8\xfc\xce\xfdw\xff\xd9\xff\xd4\x00\xa6\xff\x83\x00\xfd\x00\x91\x01\x06\x02|\x03\x16\x04;\x03\xcc\x02\xc4\x02\xee\x03\xae\x03B\x04\x01\x04\xba\x02\x9c\x01O\x00\x05\x01\x02\x01[\x00b\x00\x08\xfe\xee\xfc\xf5\xfa\xf9\xfcm\xfe\xf9\xfe\x17\xffo\xfcM\xfc\xb9\xfa\xe2\xfd\xab\xff\xc2\x00\xde\x00\x84\xfeF\xfe\xd4\xfc>\xfd\t\xfeR\xffq\xff\xc1\xfd\xd0\xfa!\xf9\'\xf9\xca\xf9{\xfaa\xf9\x01\xf9\x03\xf8\\\xf6C\xf6y\xf6\xeb\xf7\xf4\xf7\x07\xf9`\xfa\xd3\xf9\xcf\xf8\xbc\xf8Q\xfd\x0c\xffP\x01\x08\x03\xd4\x02\x80\x02\x03\x01\x01\x07\xe2\nN\x0b\x99\x0c\xf0\r\x06\x0e\xc9\x07\x8b\x06E\x0c\x91\x12\x86\x14\xfe\x10\x92\x0c\xa9\x04\xc6\x01V\x08V\x11\xbe\x12u\t\xee\x00\xe7\xfej\x01R\x06i\tC\x08\xee\x03\xdd\xfe\xa0\xffY\x02\xc2\x03)\x04\x03\x03:\x05\xf1\x05\xa5\x05V\x04\xd4\x01Y\x02\xf7\x04]\x07T\tt\x05\x15\x00\xd9\xfb\xbc\xfa\xf0\xffX\x00d\xfd\xf6\xf6\xbc\xf1s\xf1\xf1\xef\x9a\xf1d\xf2\xad\xef\x1e\xec\x96\xe9\xf9\xeb\n\xefc\xee\x06\xef\xae\xf1\t\xf3\xab\xf2\xb0\xf3\x86\xf7\x03\xfbT\xfb\xe4\xfby\xff\x87\x00\t\x00\x1e\x00\xb0\x02\xc4\x04\x08\x03\x84\x02n\x03\x8c\x03\x82\x01\xc4\x00\xd2\x01B\x02j\x00\x95\xfe\xc1\x00\xf8\xfeG\xfd\xac\xfe,\x02-\x03\xee\xfe\xa1\xfdS\x020\x05\x13\x05\x0b\x05\xf2\x05\x9f\x06\x1d\x04c\x06\r\t]\x08\xa2\x05\'\x03\xe3\x04\xcc\x04\xbb\x02\xe0\xff<\xff\xbc\xfeI\xfd`\xfc\t\xfd\xe9\xfb\xb7\xf9J\xf9\xe0\xfb\x85\xfce\xfb!\xfe\x1e\x01\xfc\x01\xb0\xfe\xe5\xff\xe1\x05O\x08\x9b\x07\xbf\x06\xef\x07\xef\x06\xed\x05)\tZ\x0b\xda\x06\xad\xff\xc3\xfe_\x03\x1f\x04y\x00_\xfb\xd1\xf7\xb3\xf5\xc0\xf5\x03\xf9E\xf9!\xf5\xd6\xf0_\xf2\xdc\xf5U\xf6+\xf6\x03\xf7\x19\xf9\xbd\xfac\xfe\xde\x01\xae\x00l\xff\x18\x02\xff\t/\x0ey\x0e\xb2\x0e\xf5\x0b9\t\xdc\t\xba\x10R\x16\x14\x12\x98\x0b\xf1\x06\x8d\x04\xd0\x04-\x06O\t\xc8\x05\xfb\xfc\xe4\xf6\x01\xf8\xcc\xfc\xe6\xfd\x14\xfc\xe9\xf9K\xf8\x1f\xf7\x10\xfa?\xff\x0b\x02(\xff7\xfd\x94\xff\xc4\x02W\x04\xfd\x04\xd6\x05u\x03\xdc\x00d\x02\x04\x06&\x06\xce\x02\xdd\x00E\x00\xd9\xff\x84\x00\x1b\x01,\x00T\xfd\x87\xfc\x93\xfe^\xfee\xfd\xba\xfcS\xfc.\xfc\x9c\xfbH\xfe$\xff\x98\xfcL\xfa\xfc\xfaR\xfd\x87\xfd\xa2\xfd\xe8\xfd8\xfdV\xfa`\xfa`\xfdK\xfe\xd5\xfc\x9c\xfa\x11\xfb&\xfb\xce\xfa\x01\xfb\xdd\xfb=\xfc\xb5\xfa\xf2\xfaH\xfc\x1a\xfd\xad\xfc\xe8\xfc\xd8\xfe\x9a\xff\x7f\x00\x8d\x00\xbc\x01\x9d\x02\x84\x02\xcd\x03:\x05\x13\x06\xbf\x05\x14\x05v\x05\xba\x05\xc4\x05\r\x06\xee\x05H\x04\x7f\x02o\x012\x02%\x026\x00f\xfe\xeb\xfc\x06\xfd~\xfcU\xfc\x93\xfc\xc4\xfbI\xfb\x87\xfb\x10\xfee\xfeJ\xfe\xcd\xff\x9a\x02\x99\x03\xb5\x02\x7f\x03)\x06\xfb\x07\xe4\x07\xd2\x08\x92\x08\x91\x07\xc2\x05F\x06\x94\x07h\x06\x85\x03P\x01\xdb\xffK\xfe\x8b\xfd\x0c\xfcC\xfbI\xf9;\xf7\x9d\xf6K\xf6f\xf6\x83\xf5\x99\xf5\xf6\xf5\xf4\xf57\xf6\x8e\xf6\xb2\xf7{\xf8\x10\xf9\x01\xfa\xe0\xfa\xc1\xfb0\xfcx\xfdy\xfe\x81\xff \x00\xa8\x00O\x01N\x01\x03\x02\xda\x02E\x035\x03\xe5\x02\xd5\x02\xd3\x02+\x03\xf4\x02\xe4\x02\x84\x02R\x01\x82\x01\x98\x01\x13\x020\x01e\x00\xf6\xff\x08\x00\x00\x00\x04\x01\x14\x01"\x00\xcd\xff`\xffK\x01\x8b\x01\x9f\x02\x12\x02\x83\x01\x02\x01\x00\x014\x02\xa4\x02\x9e\x02y\x01\x85\x00\xa5\xff\xb1\xff\xf6\xfd\x94\xfd\x05\xfd\xe7\xfc\x92\xfc,\xfc\xcb\xfd\x03\xfdo\xfd]\xff\xf8\x02\x17\x05\x88\x06N\tx\x0c\x14\r2\x0e\xc3\x12&\x17\xdc\x17\xa2\x15\xdf\x15w\x15\x08\x148\x13\x7f\x14e\x13F\x0c\xee\x05\x87\x02_\x01i\xfeD\xfc\x04\xfa\xbc\xf4L\xed\xf6\xe9\xd4\xeb\x89\xed\xfa\xecf\xeb\xaf\xea_\xe9V\xe9\x02\xedd\xf2\xdf\xf5:\xf6\xd6\xf6m\xf89\xfb\x82\xfe[\x02\xdf\x04\xca\x04\xd0\x03\xfe\x03\x7f\x052\x06R\x06\xa1\x05\x9c\x03\x90\x01\x08\x00\xea\xff&\xff`\xfd\x97\xfb?\xfa\x1c\xf9\x88\xf8\xd2\xf8\xd3\xf8\xe5\xf7\xe9\xf6s\xf7\x03\xf9\'\xfa\x1d\xfb\xee\xfb%\xfc\x8b\xfc\xda\xfd\x1a\x00\xef\x01\xa3\x02\xc3\x02\xe3\x02\xff\x02\xea\x03o\x05/\x06\r\x06.\x05\x80\x04/\x04O\x04\xc2\x04\xba\x04\xc8\x03\xc0\x02%\x02\xba\x01\x85\x01\x96\x01\x8d\x01(\x01\xaa\x00\xa0\x00\xfb\x00\xb6\x00\xd1\x007\x01\xd8\x01*\x02"\x02\x1a\x02\x04\x02\xf6\x01#\x02b\x02\x84\x02\xe7\x01\x0e\x01+\x00\xe6\xff\xb0\xff1\xff\xfe\xfe=\xfe\x81\xfdI\xfdQ\xfd4\xfd\xc2\xfds\xfe\xab\xfe\x18\xff\xc2\xff3\x01\xd0\x01e\x02\x86\x03H\x04\x04\x05\x00\x05<\x05\xae\x05;\x05\xc8\x04]\x04h\x03K\x025\x01\x9e\x00\xc6\xffn\xfe\r\xfd\xc3\xfb\r\xfb\x98\xfa9\xfa\'\xfaZ\xf9\xba\xf8\x9b\xf8\x18\xf9\xf6\xf9S\xfaV\xfa\x88\xfa\xe4\xfa\xc6\xfb\xcf\xfc\x87\xfd=\xfe\x97\xfe\x04\xff\xe2\xffZ\x00/\x01\x89\x01\xd8\x01\xf8\x01\xd0\x01\x1e\x02=\x02\xeb\x01\xb1\x01f\x01\xfd\x00j\x00\xee\xff\xa8\xff\xa8\xff-\xffu\xfeK\xfe\xe0\xfd\xb7\xfd\x05\xfe{\xfe\x7f\xfe"\xfe\x0e\xfe\xad\xfe7\xffF\xff9\x00\xb6\x00\xa5\x00\x7f\x00\xf9\x00\xe3\x01\x0e\x02<\x02\xd1\x02\x13\x03\xc2\x02\xac\x02\n\x03\xef\x02\xd0\x02\xae\x02\x1c\x02A\x01\x9f\x00\xb0\x00\xf7\xff"\xff\xdb\xfe\xa1\xfe\x91\xfd\t\xfd(\xfe\xaa\xff\x83\x00\xcc\x01\xaa\x03~\x04\x86\x04\xc8\x05.\nS\r\x96\x0e\xbf\x0e\xd7\x0ev\x0e\x0f\x0e\xb1\x0fU\x11\'\x10n\x0c\x00\t\xba\x060\x05\xd7\x03\xa2\x02\xa1\xff6\xfb\x98\xf7\xb9\xf5K\xf5\xc1\xf4\xdd\xf3\xb7\xf22\xf1H\xf0\xa2\xf0\xec\xf1\xbf\xf3\x91\xf4\xee\xf4\x81\xf5\x9f\xf6~\xf8\x1d\xfa\x85\xfb\x8a\xfc\xe2\xfc\x9a\xfd\x9e\xfe\xb3\xff\x9b\x00\x8b\x007\x00\x04\x00\x1a\x00\xac\x00|\x00\n\x00i\xff\xbc\xfe^\xfe7\xfeE\xfe\xc5\xfd\xe2\xfcB\xfc*\xfcD\xfcV\xfcl\xfcz\xfcW\xfcx\xfc\x01\xfd\xd5\xfd\x81\xfe\xe3\xfeS\xff\x06\x00\xa8\x00)\x01\xbc\x01g\x02\xc5\x02\xdb\x02#\x03}\x03\xa0\x03\xa6\x03\xd1\x03\xf8\x03\xfd\x03\xd1\x03\xc0\x03\xb5\x03\xbc\x03\xe2\x03\xf8\x03\xf0\x03\xbd\x03\x8e\x03l\x03\x7f\x03\x8e\x03v\x03-\x03\xce\x02p\x02A\x02\x0f\x02\xcc\x01k\x01\xf4\x00\x8e\x00\x1d\x00\xd8\xff\x9d\xff;\xff\xe8\xfe\xb8\xfe\x86\xfeV\xfe=\xfe*\xfe\x1c\xfe\x1a\xfe3\xfe\\\xfeo\xfex\xfee\xfe]\xfe\x93\xfe\xc6\xfe\x06\xff(\xffM\xff~\xff\x94\xff\xc4\xffG\x00\xea\x001\x01\'\x01n\x01\xdc\x01v\x02\xef\x02\x89\x03\xa0\x03\x03\x03\x81\x02\x94\x02\xef\x02\xa1\x02\xe9\x01R\x01\xba\x00\xcd\xff\x1e\xff\n\xff\xc7\xfe\xfc\xfd1\xfd\x0c\xfd$\xfd\xd2\xfc\xae\xfc\r\xfdA\xfd\x02\xfd\n\xfd\x87\xfd\xdf\xfd\xe1\xfd\xda\xfd=\xfe\x85\xfe\\\xfel\xfe\xbf\xfe\xd2\xfe\xa6\xfe\xb2\xfe\x14\xffM\xffK\xff9\xff\xa4\xff\xc4\xffo\xff\x81\xff\xca\xff\xe1\xff\x8d\xffa\xfft\xff:\xff\xdf\xfe\xc1\xfe\xd4\xfe\xdb\xfe\xa5\xfe\x81\xfe\x8e\xfe\xd6\xfe\x1d\xffZ\xff\xad\xff\xed\xff\x1f\x00R\x00\x8f\x00\xd6\x00\xf8\x00\x12\x01\x1d\x01\xfd\x00\t\x01\xe1\x00\xc4\x00\xb1\x00\x86\x00\x89\x00l\x00W\x00\\\x00d\x00\x7f\x00k\x00k\x00}\x00F\x005\x006\x002\x00\xfd\xff\xa1\xff\x8b\xffp\xff\xd8\xfez\xfe\xc2\xfen\xff:\x003\x01t\x02T\x03!\x04^\x05c\x07\x8e\tJ\x0b\x91\x0cH\r\x96\r\xe2\r\x1f\x0eV\x0e\x06\x0e\xb0\x0c\xa8\nh\x08\x8a\x06\xc1\x04\x95\x021\x00\xa8\xfd\x1f\xfb\x9b\xf8\xd0\xf6\xfb\xf55\xf5\xfa\xf3\xec\xf2z\xf2\xc4\xf23\xf3\xdf\xf3\x05\xf5\xf6\xf5\x8c\xf6W\xf7\xc5\xf8d\xfaS\xfb\xfc\xfb\x0b\xfd\t\xfe\x97\xfe\x0e\xff\xbc\xffI\x00)\x00\xf7\xff]\x00\xbc\x00x\x00\xfe\xff\xdc\xff\xdc\xffu\xff\x15\xff\x11\xff\xef\xfe\x1f\xfeb\xfdp\xfd\x9e\xfd^\xfd\x0c\xfd\x10\xfd8\xfd\x1a\xfd6\xfd\xe3\xfd\x81\xfe\xa5\xfe\xbf\xfeS\xff\xf7\xffY\x00\xdd\x00k\x01\xb3\x01\xc9\x01\n\x02k\x02\xbe\x02\xcf\x02\xd7\x02\xf1\x02\xeb\x02\xde\x02\xe0\x02\xf8\x02\x08\x03\xf5\x02\xe7\x02\xf4\x02\xe1\x02\xca\x02\xe0\x02\xfa\x02\xed\x02\xcb\x02\xcc\x02\xaf\x02g\x02%\x02\xf2\x01\x9f\x010\x01\xc2\x00Y\x00\xda\xffM\xff\xd3\xfey\xfe\x13\xfe\xac\xfdg\xfd;\xfd\x1c\xfd\x06\xfd"\xfdu\xfd\xb9\xfd\x04\xfek\xfe\xeb\xfeg\xff\xe0\xffe\x00\xe7\x00W\x01\xad\x01\xfe\x01J\x02l\x02i\x02\\\x02Q\x02+\x02\xeb\x01\x96\x013\x01\xc7\x00o\x00\x1e\x00\xcb\xffn\xff@\xff+\xff\xe6\xfe\xd5\xfe(\xff\x98\xff\xa6\xff\xa3\xff\t\x00r\x00\xbb\x00D\x01o\x02)\x03\xd4\x02\x99\x02\n\x03P\x03\xf9\x02\xc9\x02\xf7\x02z\x028\x01e\x00m\x00\xf9\xff\xd7\xfe\x0f\xfe\xd1\xfdC\xfd]\xfc\x12\xfct\xfcC\xfc\xa0\xfb\x97\xfb\x1c\xfcU\xfcD\xfc\x8f\xfc8\xfdt\xfdU\xfd\xbd\xfdq\xfe\x7f\xfeA\xfe\x82\xfe\x02\xff-\xff\'\xffb\xff\xd3\xff\xe3\xff\xd4\xff=\x00\xd5\x00\x16\x01\x16\x01<\x01\x85\x01\xa3\x01\xa9\x01\xc5\x01\xc4\x01\x8e\x015\x01\xfe\x00\xdc\x00\xb1\x00u\x00*\x00\xed\xff\xb7\xff\xa6\xff\xac\xff\xb7\xff\xcd\xff\xd5\xff\x00\x00<\x00v\x00\xaf\x00\xd0\x00\xe8\x00\xee\x00\xfe\x00\x06\x01\xd8\x00\x83\x005\x00\xe3\xff\x84\xff\x13\xff\xaa\xfeE\xfe\xb6\xfd%\xfd\xc6\xfc\x93\xfcS\xfc\xfb\xfb\xcf\xfb\xc6\xfb\xb8\xfb\xca\xfbI\xfcO\xfdj\xfew\xff\xb8\x003\x02\xbb\x03E\x05=\x07q\t<\x0bc\x0cF\r\x1b\x0e\xc4\x0e\xed\x0e\x08\x0f\xd8\x0e\xe4\r.\x0c?\n\xaa\x08\xf1\x06\xcf\x04\x7f\x02/\x00\xd7\xfdi\xfb\x86\xf9]\xf8U\xf7\x13\xf6\xff\xf4|\xf4w\xf4\x97\xf4\n\xf5\xd4\xf5\x8f\xf6\x18\xf7\xd7\xf7\xfb\xf8A\xfa.\xfb\xf8\xfb\xef\xfc\xb6\xfdD\xfe\xcb\xfeW\xff\xb7\xff\xaa\xff\xa3\xff\xdc\xff\xfa\xff\xc3\xff\x82\xffl\xffW\xff\x08\xff\xc9\xfe\xc9\xfe\xa9\xfe4\xfe\xda\xfd\xcf\xfd\xc0\xfd\x9a\xfd\x87\xfd\x91\xfd\xa4\xfd\x9e\xfd\xba\xfd\x18\xfev\xfe\xbf\xfe\x07\xff[\xff\xb3\xff\xf2\xffJ\x00\xa0\x00\xd1\x00\xfd\x00+\x01M\x01g\x01v\x01\x90\x01\xa3\x01\xa1\x01\xa5\x01\xbb\x01\xd0\x01\xe4\x01\xfe\x01(\x02R\x02e\x02\x86\x02\xc7\x02\xe9\x02\xfb\x02\x19\x035\x03&\x03\xf5\x02\xcf\x02\xb9\x02m\x02\x03\x02\xac\x01P\x01\xd0\x00C\x00\xce\xff]\xff\xd6\xfeU\xfe\xff\xfd\xba\xfdw\xfd;\xfd*\xfd9\xfd?\xfd]\xfd\x9e\xfd\xed\xfd3\xfe|\xfe\xe1\xfeM\xff\x98\xff\xea\xffW\x00\xc4\x00\xff\x006\x01\x85\x01\xca\x01\xe4\x01\xf8\x01\x17\x02 \x02\xfe\x01\xf0\x01\xff\x01\xf3\x01\xb6\x01|\x01a\x01A\x01\t\x01\xd5\x00\xb4\x00\x87\x00/\x00\xef\xff\xd8\xff\xc2\xff\x94\xfff\xff^\xffW\xffB\xff1\xffH\xffk\xffi\xffm\xff\x94\xff\xc7\xff\xe3\xff\x05\x002\x00R\x00Y\x00d\x00\x7f\x00\x8c\x00|\x00t\x00m\x00X\x00A\x008\x00\'\x00\xff\xff\xcc\xff\xc5\xff\xb8\xff\x8f\xff\x80\xff\x8f\xff\x82\xffS\xffO\xffm\xffp\xff^\xffh\xff\x95\xff\x96\xff\x89\xff\xa5\xff\xdd\xff\xf1\xff\xe8\xff\xfd\xff5\x00Q\x00Y\x00q\x00\xa7\x00\xc8\x00\xc6\x00\xe8\x00%\x01M\x01V\x01S\x01x\x01\x91\x01\xa1\x01\xc0\x01\xfc\x01\xf5\x01\xb2\x01\x92\x01\x8f\x01h\x01#\x01\xe4\x00\xb2\x00.\x00\xa1\xffD\xff\r\xff\xa7\xfe)\xfe\xd2\xfd\x93\xfd5\xfd\xd7\xfc\xbb\xfc\xca\xfc\xad\xfc|\xfc\x85\xfc\xbb\xfc\xcb\xfc\xc5\xfc\xfe\xfco\xfd\xb5\xfd\xe1\xfdU\xfe\n\xfff\xff\x81\xff\xe1\xffe\x00\xa8\x00\xb8\x00\x03\x01j\x01b\x01\x1f\x01\xfc\x00\xf9\x00\xce\x00\x94\x00u\x00X\x00\t\x00\xa6\xffk\xffd\xff[\xffC\xff\x1f\xff\xf4\xfe\xd4\xfe\xd4\xfe\xe7\xfe\t\xff$\xff.\xff#\xff4\xffd\xff\xa0\xff\xdb\xff\xf4\xff\r\x00,\x00B\x00}\x00\xbd\x00\xfa\x00\x1c\x011\x01M\x01~\x01\xad\x01\xc6\x01\xdb\x01\xf3\x01\xf7\x01\xdb\x01\xdb\x01\xf9\x01\xef\x01\xb5\x01u\x01Q\x01\x07\x01\xaa\x00\x80\x00f\x00\xf9\xffk\xff.\xff"\xff\xf8\xfe\xd2\xfe\x0f\xff]\xffJ\xffN\xff\xce\xfft\x00\xe7\x00y\x01A\x02\xe6\x020\x03\xb3\x03\x8a\x04\x1b\x05J\x05\x8e\x05\xee\x05\n\x06\xe3\x05\xc7\x05\x96\x05\x0b\x054\x04y\x03\xd0\x02\xf8\x01\xdb\x00\xbd\xff\x9c\xfek\xfd`\xfc\x93\xfb\x00\xfbb\xfa\xa9\xf9%\xf9\xdd\xf8\xda\xf8\x10\xf9r\xf9\xd7\xf9.\xfa\xa0\xfa6\xfb\xf7\xfb\xc7\xfc\x8b\xfd=\xfe\xb1\xfe\x1a\xff\x97\xff\x15\x00}\x00\xc9\x00\xfb\x00\x03\x01\xe9\x00\xe7\x00\xf2\x00\xe9\x00\xbd\x00\x83\x00N\x00\x0e\x00\xde\xff\xc7\xff\xb2\xff\x8e\xffW\xffD\xff;\xff7\xff@\xffR\xffR\xffE\xffM\xffY\xffS\xffJ\xffB\xff@\xff7\xff/\xffA\xffd\xff]\xffL\xffT\xffx\xff\x94\xff\xa7\xff\xcd\xff\xf6\xff\xff\xff\xfb\xff#\x00p\x00\x98\x00\x9b\x00\xaa\x00\xc4\x00\xbf\x00\xc2\x00\xdd\x00\x06\x01\x0c\x01\xfc\x00\xfd\x00\x18\x01)\x016\x01M\x01V\x01Z\x01c\x01x\x01\x83\x01\x83\x01\x84\x01n\x01N\x01<\x01&\x01\x02\x01\xd5\x00\xa1\x00i\x00,\x00\xf9\xff\xd9\xff\xbe\xff\x9a\xffx\xff_\xffX\xffW\xff_\xff{\xff\x96\xff\xaa\xff\xbe\xff\xde\xff\x08\x000\x00P\x00q\x00\x88\x00\x94\x00\x9d\x00\xa0\x00\xad\x00\xa3\x00~\x00Y\x009\x00\x1c\x00\x00\x00\xde\xff\xb9\xff\x93\xffy\xffk\xff[\xffT\xffL\xff:\xff3\xff+\xff4\xff@\xffH\xffR\xffQ\xffU\xfff\xff{\xff\x95\xff\xab\xff\xbd\xff\xcf\xff\xde\xff\xf4\xff\x19\x004\x00A\x00R\x00n\x00\x89\x00\x9d\x00\xb2\x00\xce\x00\xde\x00\xe2\x00\xef\x00\x04\x01\x0f\x01\x19\x01\x15\x01\x12\x01\n\x01\xfe\x00\xed\x00\xdd\x00\xc3\x00\x9d\x00v\x00Q\x000\x00\xfe\xff\xca\xff\x98\xffl\xffM\xff#\xff\x01\xff\xdc\xfe\xbe\xfe\xa3\xfe\x9b\xfe\x9d\xfe\x9d\xfe\xa6\xfe\xb3\xfe\xc8\xfe\xe3\xfe\x05\xff&\xffC\xffj\xff\x8e\xff\xa7\xff\xc0\xff\xd4\xff\xed\xff\x00\x00\x01\x00\x1b\x00\x1e\x00,\x004\x00)\x00G\x00i\x00\x8b\x00\xa1\x00\x8c\x00\x9c\x00\x92\x00\xd3\x00P\x01\xe8\x01A\x027\x02[\x02j\x02f\x026\x02/\x021\x02\xb7\x01\'\x01\xac\x00G\x00\xd6\xff=\xff\xb6\xfe0\xfe\x99\xfd\x0e\xfd\x8a\xfcF\xfc\x13\xfc\xe3\xfb\xb2\xfb\x9a\xfb\xc4\xfb\xfe\xfb4\xfco\xfc\xd5\xfc9\xfdz\xfd\xdf\xfdF\xfe\xa7\xfe\xee\xfe\x0c\xffU\xff\x9b\xff\xc7\xff\xeb\xff\xfa\xff\x06\x00\xe5\xff\xbe\xff\xb8\xff\x9e\xff}\xff8\xff\xf4\xfe\xdc\xfe\xb6\xfe\x95\xfe\x88\xfe\x95\xfe\x9c\xfe\x91\xfe\xa9\xfe\xde\xfe\x1c\xffD\xff\x8d\xff\xfa\xff>\x00u\x00\xc1\x009\x01\x91\x01\xc9\x01\x1d\x02{\x02\x90\x02\x90\x02\x9e\x02\xb9\x02\xa6\x02|\x02\x8a\x02\x99\x02\x8f\x02j\x02p\x02\x8b\x02\xa8\x02\xde\x02t\x03g\x04G\x05\xfb\x05\xb4\x06\xaa\x07^\x08\xd0\x08o\t\x0c\n9\n\x0f\n\xd3\t\xa5\t\x0c\t:\x08H\x07.\x06\xd4\x047\x03\xb4\x010\x00\xa8\xfe/\xfd\xc3\xfb\x83\xfa\x8c\xf9\xb4\xf8\x18\xf8\xa0\xf7_\xf7T\xf7L\xf7y\xf7\xce\xf7;\xf8\xbc\xf8O\xf9\x0e\xfa\xca\xfa\x98\xfbg\xfc\xfa\xfc\x84\xfd\xf1\xfdF\xfe\x89\xfe\xa9\xfe\xc3\xfe\xd1\xfe\xbf\xfe\xa3\xfey\xfeH\xfe\x18\xfe\xcb\xfd\x83\xfdA\xfd\x0f\xfd\xd8\xfc\xb7\xfc\xc3\xfc\xd3\xfc\xf8\xfc0\xfdv\xfd\xc6\xfd-\xfe\x9e\xfe\x06\xffk\xff\xce\xff(\x00\x93\x00\xfb\x00J\x01\x9d\x01\xef\x01\x1d\x026\x02E\x02M\x02B\x020\x02\x17\x02\xf9\x01\xda\x01\xc9\x01\xac\x01\x96\x01{\x01[\x01S\x01M\x01F\x01:\x01;\x01B\x01G\x01T\x01[\x01`\x01X\x01P\x01N\x01:\x01\x14\x01\xeb\x00\xbf\x00\x8b\x00N\x00\x0e\x00\xd3\xff\x98\xffQ\xff\x0e\xff\xd2\xfe\xa5\xfer\xfeO\xfe6\xfe&\xfe\x1d\xfe"\xfe5\xfeM\xfel\xfe\x95\xfe\xc1\xfe\xf3\xfe-\xffe\xff\x98\xff\xd3\xff\t\x00I\x00z\x00\xa2\x00\xbb\x00\xcf\x00\xe8\x00\xf1\x00\xf9\x00\x03\x01\xfa\x00\xf4\x00\xe8\x00\xe2\x00\xd6\x00\xbf\x00\xa5\x00\x8b\x00t\x00a\x00J\x00;\x00(\x00\x19\x00\x16\x00\x17\x00 \x00%\x00)\x00<\x00F\x00Y\x00m\x00t\x00\x87\x00\xa0\x00\xa5\x00\xbb\x00\xb0\x00\xb6\x00\xb8\x00\xb0\x00\xb2\x00\x97\x00\x93\x00\x95\x00\x87\x00\x84\x00f\x00?\x00\x1c\x00\xd9\xff\xab\xff\x88\xffg\xffw\xff6\xff\xfd\xfe\x03\xff\xfc\xfe\xef\xfe$\xffk\xff\xa1\xff\x9c\xff\xb8\xff6\x00\xc2\x00\xe8\x01K\x04\xa4\x05\xf7\x05\xd9\x05`\x05\xae\x04\x83\x02v\x01\xe1\x00o\xffH\xfeU\xfd\xe9\xfcH\xfc\xf0\xfa\xcf\xf9d\xf8\xc0\xf6\x15\xf6\x87\xf5\xb6\xf5y\xf6\xf5\xf7\x8f\xf9)\xfa\xb7\xfb\xad\xfd\xa4\xfe\x7f\xfe\xf5\xfe-\x00y\x00\x19\x01\x10\x02\x07\x03q\x03\x15\x03\xab\x03\x14\x04\xa8\x03I\x03\xe3\x01\x11\x01\xa7\x00\xef\xff\xb1\xff\x82\xff\xbe\xffA\xff\xfc\xfe_\xff}\xff\xfa\xfe\x8d\xfe\x88\xfe\xa2\xfe\xf7\xfe\x07\x00\x00\x01\x9f\x01#\x02\xa4\x02\xe0\x03u\x04n\x04m\x04\xd2\x04C\x05\x19\x05}\x05\xcf\x06\xb6\x06\x08\x06\xb8\x05\xa3\x05\xfe\x05\x98\x05\xbe\x05\xd2\x05\xc8\x05\x9a\x05T\x05u\x05#\x05R\x04n\x03\xd6\x02\x96\x020\x02\xb6\x01;\x01\xc2\x00/\x00A\xffi\xfe\xac\xfd\r\xfdJ\xfc\x9b\xfbH\xfbo\xfb\x9e\xfb\x8f\xfbt\xfbZ\xfb3\xfb\x0e\xfb\xe2\xfa\xbf\xfa\xda\xfa\x02\xfbf\xfb\xe8\xfb\xc1\xfcj\xfd\xa4\xfd\xb3\xfd\xb5\xfd\xcc\xfd\xaa\xfd\x9a\xfd\xbd\xfd\x08\xfeP\xfe\x95\xfe\xdf\xfe \xff\x04\xff\x9b\xfe\'\xfe\xcb\xfd\x90\xfdD\xfdT\xfd\xb5\xfd\x19\xfeg\xfe\xc1\xfe8\xffb\xffh\xff\x83\xff\xac\xff\xec\xff0\x00\xb6\x000\x01\x84\x01\xdc\x01 \x02^\x02@\x02\xf9\x01\xb2\x01x\x01J\x01A\x01A\x01\\\x01Q\x016\x01:\x01\xfd\x00\xd3\x00\x8f\x00L\x00\x15\x00\xe8\xff\x00\x00,\x00K\x00g\x00`\x00S\x00M\x00>\x00\x0c\x00\xe5\xff\xcf\xff\xbc\xff\xc4\xff\xdf\xff\x12\x00/\x00,\x00;\x00B\x006\x00=\x00+\x00\x1f\x00\x04\x00\x11\x00E\x00M\x00|\x00\xa0\x00\x8a\x00\x99\x00t\x00U\x00,\x00\xf7\xff\xe9\xff\xb5\xff\xb9\xff\xb0\xff\xb3\xff\xc0\xff\xae\xff\x8e\xffX\xff*\xff\x08\xff\xed\xfe\xb6\xfe\x9d\xfe\xa8\xfe\xae\xfe\x06\xffT\xffw\xff\xae\xff\x9a\xff\xc2\xff\xfc\xff$\x00Y\x00g\x00\x91\x00\xbe\x00 \x01\x8a\x01\x9f\x01\xab\x01G\x01\xd2\x00\xa8\x00S\x00A\x00\x03\x00\xa5\xff\xaa\xff|\xffe\xffy\xffa\xffA\xff\x19\xff\x01\xff!\xfft\xffm\xff\xbc\xffT\x00\xca\x007\x04\x8a\x06p\x07\xfd\x07\xa2\x06\xf5\x05\xbd\x03\xe5\x02\xc5\x02\x03\x02b\x02\xe3\x01\xb7\x01\x8a\x01Q\x00\xdb\xfd\xeb\xfa\r\xf9\x85\xf7\xf9\xf6\xbd\xf7\xa6\xf8\x1e\xfa\x14\xfb\xda\xfb4\xfc\xf0\xfc\xa4\xfci\xfaV\xfb\x1a\xfc4\xfc\xbd\xfe\x00\x00\x19\x01\xfe\x01H\x02\x8c\x02\xa3\x01\xe3\x00\x95\x00\xa3\xff\x8d\xff\xb0\x00,\x01\xb7\x01d\x02\xd1\x02"\x02\xaa\x00\x94\xff\xed\xfeX\xfd\x1a\xfde\xfd\xc5\xfd\xf6\xfd\x15\xff\xc2\xffg\xff\xeb\xff\x8d\xff\x18\xff\x84\xfeg\xff\xe7\xfe\x82\x00Y\x03\xa1\x01\x8e\x03I\x05\xb3\x03\xe8\x02%\x03~\x03\r\x01\x8a\x00\xd0\x03C\x01\x94\xff&\x04l\x02\x90\xff\xa4\x01\xbd\x01\xe4\xfeq\xff\x81\x01\x89\xff\xd3\xff\xc0\x01\xaf\x02\xc2\x01@\x02\xf5\x02\x18\x01\xb4\x00\x84\x02\xf4\x02\x06\x003\x01\xf9\x02\x1e\x005\x00e\x02\xbf\x00\xe2\xfe\xc1\xff\xfe\xff\x8b\xfe9\xff\x9e\x00\x9a\xfe\xb7\xff\x18\x01\x92\xfe\xd8\xff\xce\x01\xa3\xff\xc9\xfd\x83\x00\xd3\x00\x05\xff\xfd\x00\x04\x02*\x00g\x00\xe1\x01\xe9\xff\x1f\xff\x05\x01\xad\xff8\xfdY\x01\x82\x01\x0c\xfd\x9f\xff\xd5\x01\x98\xfe^\xfc\xcf\xff\x83\xfe\xdd\xfaV\xffI\x00\x95\xfb\xfe\xfc]\x00k\xfea\xfd\xbc\xff9\xfe\xb0\xfcZ\xff\xb9\xff\xde\xfd\x92\xff\xa0\x00\x13\xff\xf9\xfe\x1e\x00,\x00\xb0\xfeZ\xffM\xff+\xfe\x17\xff\xcd\xfe\xb0\xff\x11\x00\x03\xffm\xffb\xff\xf1\xfey\xfe\xb8\xfe2\x00\xe2\x00G\xff\x89\x00\xa9\x01\xb8\xff1\x00\xcc\x00j\x00\x9a\x00q\x01\x8d\x00\xfd\x00\x85\x02\x7f\x01\xdd\xff\xab\x00\x00\x00\xbb\xfe\xe0\x00l\x00m\xffD\x00\xe6\x00a\xff4\xfd\xe4\xfeu\xff\x0f\xfc\xc7\xfd\xd9\x02\x96\xfe:\xfdM\x03\xf1\x00\x8b\xfb>\x007\x02)\xfdI\xff\x1c\x05N\x01y\xfeU\x03\xbd\x03\x86\x01\x08\xff\xe8\x02\xff\xfc2\xff\xde\x00\xe7\xff\xe6\xfd\xfe\xfe\x91\x02C\xfc\r\xfeV\x00\xca\xfd?\xfa\x00\xffn\x00\xde\xfb)\xff\xdc\x01\x0b\xfe\xb1\x01e\x01\xb5\xfd\xe0\x00\xaf\x02\xa1\xfd\x9d\xfd\x02\x04\xfb\xffq\xfc\t\x01\xab\x060\xfd(\xff\xaf\x06\x15\xffT\xfc\xbb\x04,\x02\xe9\xfa\n\x03\xb8\x03F\xfd\x9c\xff\xe4\x05\xc4\x00\x08\xff(\x01\x91\x00u\xfft\xfbz\x033\x03\xf7\xfb\xbe\x02\n\x04\x96\x01\xf2\xfe\xe1\x00\x7f\x03\xc8\xfd\x15\xfe(\x01\x96\x020\x01\x19\x02\xe4\x00_\x01\x1e\x01\xd9\xfcS\xfe\xbc\x03\xd6\xfd\xfb\xfc\x9b\x012\x00<\x00}\xfc\xe0\x02\xea\xff\xf2\xfbE\xfe0\x01\xb8\x00C\xfcy\x01\xd0\x00~\xfe\xdc\x01\xc0\x00\r\x01`\xfe_\xfes\x03\x08\xfbT\x01\xc6\x04x\xfb\xc1\xfe\x1c\x05\xbc\xfc=\xfe\xb5\x00\xa1\xfd\xd4\xfd[\xfd\x7f\x01\x85\xfc\x08\x01\xe9\x00\x98\xfc\x05\x00\xdc\x02\x03\xfbR\xfc;\x05\x1e\xff\xa2\xfcF\x02\x17\x03y\xfe]\x01\xbc\x01$\x00\xeb\xfc\x18\x01r\xfd\xcb\x00\xa9\x04\xa4\xfe.\xfeb\x03f\x034\xf9o\x04\xf0\x00\xe8\xfa\x14\xff\x14\x02\xb8\x01\xf9\xfeM\xfd\xde\x03\xf6\x00\xd2\xfa%\x02\x8a\x01\xe0\xfc\xf3\xfc\x82\x03E\x03\x0c\xfen\x02V\x03y\xfd\xcf\xff\x9c\x03\xf2\xfet\x00\xbe\x03\xe6\xfdW\x02\x81\x03\x98\xfa\xe3\x00\xb4\x027\xfe\xfa\xfd\xbf\x03K\x01\x9a\xfa6\x03y\x00\xbc\xfc\x9c\x00p\x01)\xff\xeb\xfe\x07\x03\xa4\xfe?\xfed\x02\xd8\xfa`\x00\xcc\x05\x00\xfb\x9e\x00\x16\x07T\xf9\xf4\xfc\x05\x08\x0e\xfc\xac\xf9\xbe\x07O\xfe\xff\xfb\xfc\x04\xf2\x01\xee\xfc\xe1\xfe\xd9\x03\xdb\xfa\xa1\xfa\xbc\x04"\x07w\xf8\xc3\x01\xe6\x07\x1a\xf8\xec\xfd\xaf\x04o\xfdw\xfb@\x04\x86\x02A\xfc\xf0\x00\xe1\x04\xf0\xfbP\xfc\x96\x01\'\x02\x95\xfb\xb9\x00]\x03\x85\xfe\xa3\x00\x86\xfe\x91\xfd^\x00p\x02\xe5\xfa\xea\x01\xd3\x01:\xfd\x16\x03\xb9\xfe\xda\xfb\x8e\x02z\x00\xc2\xfb\xbf\xfe\x1f\x07w\x01e\xf8\x89\x03E\x05\x96\xf8\xbb\xfe\x04\x08]\xfaz\xfd\xd0\x07W\xfeG\xfe\x18\x05\xa8\xfc\x11\x00\x15\x00\xd7\xfeL\x00\xce\x00{\x01\xa0\xff\x88\x002\x01_\x01\x85\xf9\x98\xff\xd9\x00\xed\xfb|\x01\x9d\x04\xef\xfe\x93\xfc0\x03g\xfe\x96\xfcG\x00\xf7\xff\xe0\xff1\x006\x05\xb2\x00\x1d\xfb\xa1\x02F\x00\x07\xfc_\xff\xc1\x01d\x02+\xff\xff\xffc\x02\x03\xffP\xfd.\xff5\x04l\xfdU\xfa\xb4\x05\xd1\x01X\xfcn\x01\xd8\x03\xac\xfd\x96\xfa|\x06\xe6\xfd\xbf\xf7t\x05\xdd\x01\x90\xfd\xaa\x02l\x03\xdf\xfa\xaa\xfe\xc0\x01\xd1\xfc%\xff\x01\x01\xf7\xfe\x08\x01=\x01\xbd\x01\xa5\xff\x96\xfcQ\x03\xbc\xfe\xeb\xfc6\x00f\x01\xb3\x03N\xfe%\x00\xfc\x06\x83\xfc\x15\xf9[\x07\x11\x01n\xf7\xd7\x03\xe7\x07\xca\xfa\xa5\xfc\x17\x07\xbc\x01\xaf\xf6\xaa\x04\xa7\x02a\xf5(\x05\x99\x04[\xf9\xaf\x03\xb8\x00\xe7\xfb\xab\x02\x82\x02\x11\xfc\xc7\xfd\xd3\x05\x16\xfd)\xffu\x00\xcf\x00\xe4\x03\xd4\xfc\xce\xfex\x02&\x00h\xfe1\xff\xc5\x01c\xff^\xfek\x032\xff\xcf\xfa\xa4\x06;\x01\xbc\xf6\x85\x04\x08\x02\xab\xfc%\xfe\x07\x04\xec\xff\xe4\xfa\xf5\x03s\x00\x1a\xfd\x87\x00\xcd\x01\x94\xfcF\xff\x1d\x02\x12\x011\xfe\x89\x02%\x00\x9f\x00\xc4\xfc\xe5\xfen\x06\xce\xfc)\xfe\xac\x07\xa2\xfe6\xf9\x02\x07q\x00u\xfa%\x01\xa9\x05\xee\xf9\x98\x00\x88\tp\xf7\xde\xfb:\x08\xeb\xfe&\xf7\x9e\x06^\x05d\xf9\x11\xfe\xcb\x07\xa4\xff\xe3\xf7q\x06I\x00v\xf9\xa2\x02j\x04w\xfc\xcc\xfek\x04\x1e\xfe:\xfb\xa5\x04>\x01\x83\xf9B\x02P\x02c\x01_\xfcA\x03\x8f\x02 \xf9\xf7\xff|\x03/\xfd\xca\xfd(\x07G\xfb\xba\xff\x97\x04e\x00d\xf9\x85\xfe\x86\x06^\xfb\xef\xff\xd1\x05\xd1\x00\xb1\xfa\x81\xffy\x04\x06\xfa\x83\xfd\x97\x04\xae\xfe\x99\x01\xd2\x02;\xfb\x13\x02T\x00\xf8\xf9L\x04\xdb\xfe$\xfc\xbc\x05\xdd\x03\xa7\xf6\xf4\x05\xc7\x05d\xf3\xfa\x01\x0e\x06\xf9\xf9\xa1\xfe\x98\x07:\x02\xb0\xfb_\x04\xb7\xfek\xf86\x032\x03\xac\xfei\xfeH\x05a\xfd\x10\xfeB\x05}\xf8\xe6\x01X\x025\xf7\xd3\x06v\x02\x9c\xfbc\xff\xab\x05/\xf9\xaf\xfa~\x08s\x03\xa5\xf5\xcc\x01\xc9\n\xbe\xf4\xf6\xfdM\t.\xfc\x1d\xf8\xb2\x07\xda\x01\xee\xf7L\x03\xc2\x04\xd0\xfcl\xfa\xea\x04\xff\x04q\xf5\xf1\x05\xd8\x03\x04\xf8\xba\x01\x97\x08s\xf8\xec\xfa\x03\x11\xa1\xf5\xa9\xf8Y\rc\x00\x81\xf1\xf0\x08\xd1\x07\xa2\xf6\xd4\xff`\x06?\x02>\xf5Q\x03~\x04\xd0\xfc\x8d\xfd%\x03\xfd\x05j\xf8\xcb\x02\xb2\x02\xbd\xf8\xd6\x01\x1d\x02\x15\x01@\xfc\xe5\x03~\x01D\xfa\xe2\x02}\x04%\xf6n\xff_\n\xb3\xf7\x86\xfc\xd3\x0b(\xfb\xbe\xf7r\n\x84\x01y\xf3+\x05\xf7\x07v\xf1\xb4\x01\xf8\x0fH\xf6\xa5\xf7\x0c\x0e\xca\xfe/\xf46\x06R\x01;\xfb\x8d\xff\xc2\x05\xb2\x03\xe8\xf9D\xff\x91\x06u\xf9b\xf9c\x0c\x97\xfc\xdf\xf5P\n\xce\x06s\xf6\x17\xfd\xe6\n\xe9\xfc\xf5\xf6\x98\x05\x96\x05\x90\xf6\xbc\x00\xb7\x0eU\xf5O\xfd\xbf\x0b\xf2\xf8\x0e\xfd\xd3\x034\x00B\xffM\xffT\x05\xa7\xfe\r\xfb\x8b\x05>\x03\x01\xf7|\xfe\x99\t+\xfb\x81\xfbc\t\xe5\xfd\x97\xf9S\x04x\x03Q\xfa\xd3\xfc\x12\t-\xfd\x9d\xf8\xd4\x03\xff\x07\xa3\xf9`\xfb\xf6\x07-\x01\xda\xf4\x9d\x05\x15\x06q\xf7\\\x02\xea\x05)\xfc(\xf7\x1a\x07\xc7\x05\x1c\xf6\xdd\xff\xf8\n\xb8\xf4\'\xfen\x08\x90\xfc\xb1\xf8{\x01\xf8\t\xca\xf7\x7f\x00r\x07E\xfb\xea\xf8\xc7\x04\xe0\x06!\xf9\x05\x03\x17\x06:\xfb\xb0\xfa\xc8\x06\xe9\x01\xcc\xf8\xd7\x02\xe6\x05\xd5\xfa\x13\x00\xe0\x05{\xf9J\xff\x9a\x04\x0e\xff\xf2\xf8\xf1\x06\xd4\x04\xdc\xf7\x87\x01\xce\x04\x9b\xfa^\xfdS\x05 \xfc\xe8\xfcd\x04\xe2\x04\x8b\xfb\x16\xfb\x84\x06\'\xfc\x97\xfc\xf4\x05\xc8\xfe\xb0\xfc\xc5\x00\xdd\x05z\xfb\xd7\xfcj\x03\xf7\x01\xbf\xfaR\x00{\x04\xa4\xfd\xf9\xfe7\x03\x96\x01\xc1\xf8\xee\x02\xb0\xfe\x83\x01Y\x01\xba\xfa\x0e\x05\x86\xff\xb8\xfa\xee\x06\xdb\xff\xf9\xf2\x80\n\xed\x05G\xf2\xc3\x02+\x0b\xfa\xfa\xe1\xf5\x0b\t\xb9\x038\xf6\x01\x01:\x08g\xfc\xa8\xf7\xac\t\x81\x03l\xf5\xa8\x02\xe5\x08W\xf6\xbe\xfbd\x0bg\xff*\xf6\xd5\x04y\x06\x02\xfbe\xfe\xea\x03\x19\x02G\xf6\xa0\xffK\r$\xf8\x12\xf9\xf3\x0e\x98\xfe\x19\xef\x14\n\xc8\x08w\xf2X\xffs\x08h\x00\x9f\xf8;\x01\xae\x08\xe5\xfb\x93\xf4\xf3\n\xef\x05\x97\xedH\n\x9d\x08\x97\xf4!\xfe\xcd\x071\xff=\xfa\xf7\x04\x80\x00B\xfb\xda\xffi\x05W\xffA\xfc|\x03\xf4\x01?\xfa\x03\x00\x01\x06W\xfd\xb7\xfb\xf2\x05\x9c\x01n\xf9\xee\x02(\x06^\xf5\xb6\xff\n\n}\xfa\xb1\xfc\t\x04A\x04-\xfb{\xfb\x16\x08t\xfc\xab\xf9M\t\xd0\xfd\xd1\xfb\x95\x02:\x07_\xfb\x01\xf9\xb4\x08\xfb\xfa\x1a\xff5\x01\xc3\x00\xde\x02U\xff\xa0\xfd\xf6\x02X\xfe\xfd\xf9\xe2\x05\xb7\x022\xf8\xd3\x03\x9e\x03)\xfb\x9d\x03>\x02\x12\xfb/\xfdI\x04\xd4\xff\x94\xfc\xa9\x01-\x06\xe0\xf9K\xfcF\x0b\xc7\xfd\xff\xf4e\x04\xba\x05\xb5\xf8\x9e\xff\x0c\tE\xfd\xa8\xfb\x86\x03r\x01-\xfa\xd1\xff\xc1\x04a\xff\x14\xfd0\x04\xeb\x03$\xf8\x1b\x04E\xfc*\x00Q\x04r\xfc\xd1\x00X\x02\xcf\x00\x9e\xfc\x03\x01k\x00d\xff~\xff\xe2\xff\xeb\x03c\xff\xe0\xfb-\x04\x9a\xff\xe1\xfc\xa0\xff\xe3\x05&\xfdA\xfb$\x08\x12\xff\'\xfa\x8a\x00x\x07\x9d\xf8\x92\xfe\xd0\x07\x9d\xfb\x8c\xff\xcc\x03\x91\xfd\xb5\xf9\xf9\x06\x08\xfd\xa5\xfa}\x059\x03\x95\xfb\x15\xfe>\x03\x90\x00\xc7\xfct\xfe\xaa\x06\xf3\xfc_\xfe\x94\x04\x7f\x00\xd5\xf9;\x04\xda\x03:\xf9\x95\xfe[\x08\xf1\xfb\x05\xfb\x05\x03\xcb\x03\xad\xfd\xce\xf93\x04\x0e\x02/\xfe\'\xfdx\x06F\xfb$\x00\xb1\x01!\xf8\x95\x04V\x06\x95\xfb\xb4\xfa\xca\x05\x94\x00\x97\xfb\xf0\x00\x84\x04\'\xfd\xdc\xfb\xe9\x05[\x03\xf2\xf8\xd6\xfe\x9a\x07G\xfdJ\xfb\xb5\x04e\xfev\x01\x80\x00\xc9\xfc\xdb\x015\x04\xbd\xfc%\xf9\xbb\x06\xda\x00\x85\xff\x17\xfc\xa0\x03g\xfd\xe6\x01\xea\x04\x15\xf5\xb3\x02\xa9\x03}\x00\x7f\xfaR\x03\xe4\x05\x9c\xf8\x14\xfe\xed\x07z\xfa\x07\xfb\xd5\x07\xd5\x00\x08\xfc\x07\xfc/\x05\xc8\x04\xf6\xf9R\xfa\xb6\x08\x90\xfe6\xf9\x01\x02\xeb\x08\x1c\xfc\xb3\xf3\xdc\x0bi\x05?\xf5~\xff~\tn\xfai\xf7\xb4\nJ\x02\xa4\xf9\x97\x03\x02\x010\xfc\xd8\xfdJ\x03y\x04i\xf9\xe8\xfcV\x0c,\xfb\xab\xf6\r\x0c\xe3\x01M\xf2\'\x05D\x06\x8f\xfap\x00,\x02\x03\x04u\xf8\xc0\x00\xbd\x03\xf1\xfc\x1a\xfef\x01\xd7\x04\xa6\xfa\xc5\x02\xc6\x001\xfd\xd4\xfd\xec\x03M\xfe\xee\xfcx\x04\xb7\x03q\xf8\xb8\xfer\x0bi\xf5\xc8\xfd\x0b\x07[\x01\xb7\xf7V\xff\xe3\x0c\xfb\xf9\xec\xfa~\x02\x19\x03\x94\xfa\xab\xfei\x08-\xfbj\xfc\xb4\x02\x04\x06\xcd\xfb\xaa\xf9k\x08\x88\xfe\x87\xf6W\x05p\x08\x8a\xf8`\xfd\xd9\x07J\xfd\xa1\xfc\x1f\x01\x98\x00\xf9\xfe\xcc\xfe\xde\x00s\x04\xff\xfd\xc5\xfeg\x03#\xfb\xf5\xfe\xe8\x03\xb5\xfc9\x01#\x01\xf7\xff\x99\x03\x94\xfa\xf4\xff]\x05\x12\x00\xbd\xf6a\x03\xe6\x07\xc8\xf9\x87\x00\xfc\x01\xfb\x02\xa6\xfc\xa9\xfb\x9c\x07\xc3\xfd\x94\xf9\xb0\x05\x7f\x05\x15\xf9q\xfc\x8e\t\xaa\xfd\xd2\xf7\x9d\x06\xa0\x02\x17\xf7\xb8\x01~\x08\xf1\xfb8\xfa|\x03R\x05\xa2\xfaQ\xfc9\x06\x8e\x00\x15\xfa\x87\x01{\x02<\x00\x19\xff~\x01\x0c\xfdJ\x03\xf4\xfc\x9b\xff\xb3\x04H\xfc\xa3\x03\x8b\x00$\xfe\xca\xfbm\x01!\x06\xda\xfb\xc3\xfb\x89\x05\x00\x00\xb0\xfb\xd4\xff\xf2\x03d\xfb\xd3\xfb\xdb\x08\x84\xffd\xfa\xcd\x03K\x054\xf7F\xfd\x97\tx\x01e\xfa\xd1\xff\x12\x06l\xfd\xc4\xfc\xaf\x02\x87\x01Y\xfc\xad\x00t\x02\xe3\xff\x88\xffv\xfeL\x01\xf5\xfe\xe9\xfe\xe3\xffG\x03\xa4\xfc\x05\x00I\x03u\xfe\xb2\xfd\x91\xffr\x02\xc1\xfcB\xfd\xde\x04H\x03\xc8\xfaL\x00\xf2\x03-\xf9\xd6\x01\xe6\x06\xbd\xf9Z\xfd\x08\x05\xe1\x02\x9d\xf8\xfd\x02\x03\x05\xf3\xf9;\xfc\xca\x08\x1d\x00\xb5\xf6_\x06\x85\x06O\xf8\x00\xfb\xfe\t\xee\xfd\x9b\xf9\xe4\x03\xc6\x04\xc0\xf9[\xffC\x06\xa6\xfd\xd8\xfa\x81\x02F\x04\xed\xfa\xec\x00\xd9\x02!\xfe\x02\x00\xc7\xfeF\x00^\x017\xfdK\x02\x98\x00p\xfc9\x00\x14\x04\xa4\xff\xf7\xfbX\xff\xb5\x04\x0b\x00\r\xf8a\x05\xb2\x04\xea\xf8n\xfd\xb0\x07\xc3\x00\xd3\xf8i\x02\x9c\x04"\xfb\xcd\xfc\x98\x06q\x00*\xf9w\x03\xa9\x05\x9e\xf8z\xfe\x85\x05J\x00\xa5\xfa\xa0\x00\xd4\x03\xf0\xfd\x93\xfe\xf8\x01l\x00\x8f\xfcg\x00~\x03\x8a\xfdC\xfd\x08\x05\xdc\xfen\xfb\x17\x02\x8b\x05P\xfb3\xfc^\x07{\xfe\xb0\xfa\x90\x02\xb5\x04\x01\xfdX\xfcs\x04\xa7\x01\n\xfcL\x00`\x01\x06\x01\xc6\xfdD\xff\xd6\x04\x02\xfd\xd0\xfeO\x01\x80\x00\r\xff\x9e\xff\x9e\x00\xe8\xff\xd5\x00\xfb\xfd\t\x020\x00\x8c\xfd\xc0\xfe\x0f\x04\xbd\xfe\x19\xfc\xfa\x02\xfe\x03\x02\xfd\xf3\xfa\x9a\x06\xdf\xff\x1e\xfa\xb7\x00.\x05>\xfeb\xfe8\x01K\x00\xa9\xff\x8a\xfeX\x00\xbb\x00}\xffB\x00:\xff\xad\xff\xfe\x02D\x00#\xfcM\x00Q\x03\x15\xfcX\x00\xbb\x03N\xfd\x84\xfd}\x03\xfd\x03H\xfbe\xfc&\x05\xa5\xff\xeb\xfb\x95\x01v\x03\xc5\xfe\x8c\xff\x02\x01\xaf\xfe\x03\x00\x0e\x01\xa5\xff\x98\xfd\xbd\x01\xd0\x03/\xfe\x1f\xfe\xb2\x02\x08\xfe\xce\xffS\x00\x96\x00c\xffY\x00\x80\x01\x16\xff\xe6\xffg\x00\xb2\x00\x17\xfeh\x00\xfb\x01:\xff\xaa\xfd\x0e\x03)\x01\xea\xfc\xec\xff\xfe\x03\xe3\xfc\x82\xfe\x92\x05\x84\xfd\xce\xfb\x0e\x03l\x05u\xfa\x85\xfd\xe2\x05\x8f\xff\xf8\xfb\x0f\x01\xb0\x02\x02\xfc\xe1\xfe\x08\x02\x8f\xff~\xfeI\x01!\x01\x9e\xfd\'\xfe\r\x03\xa9\x01\xaf\xfc\x82\xff\x10\x04\xa1\x00\xf4\xfc\xf4\x01A\x01\xd3\xfeY\xff \x01\x1b\x00\x9c\xfe\xc1\xff\x00\x01\xd0\xfe\xa8\xfe"\x03\xfd\xfdP\xfd`\x01\xa3\x02\xbe\xfe3\xfd\xda\x011\x01\xda\xfc\xdc\xfe\xaf\x02\xbd\x01\x0c\xfe\xb3\xfdQ\x01n\x03.\xfdc\xfe\xb4\x02\x7f\x00\xa6\xfe\xda\x00\x8f\x02~\xfd\x16\xfe#\x02\x98\x02`\xfdh\xfe\x8c\x02r\x01\x11\xfc\x18\x01%\x03\x80\xfd\xa6\xfeN\x00\xef\x01e\xfe.\x01\x18\x00\xfc\xfde\x00\x8c\x01\xc6\xff\x9c\xfe\xdb\x00\xd1\xff\x89\xff\x01\x01\x0b\x01\x81\xff\xfc\xff\x93\xfe1\x00\'\x01\x03\xffA\x01\x1d\x00\xc2\xfe\x03\xff\x03\x01\x9e\x01-\xfe}\xfe\x90\x00\xe9\xfe\x8e\x00\xee\x01\x8e\xff\xd3\xfd\x89\xffg\x01\xec\xffE\x00\xea\xff(\xff,\xff\xa4\x006\x01\xa9\xff\xb3\xff\xcc\x01\xc8\xfd7\xfe\xed\x02+\x00\xc0\xfe\x7f\x00\xf3\xff@\xff\xc5\x01\xd5\xff\xe0\xff\xba\xff)\xff2\x00>\x00\\\x01c\xff\xfb\xffA\x00Y\xfe\x9e\x00\x8f\x01\xba\xfe\xa7\xfe\xe1\x00\xf7\x00\x9d\xffX\xff\xe7\x00\x8a\xff\xd6\xfe\x12\x00\xaf\x00f\x00g\x00f\xff,\xfe\xdf\x00\x89\x01:\xff\x9f\xfe\xde\xffF\x01A\xff\x02\x00\xdb\x00\x94\xff\xad\xfem\xff\x14\x01\xc8\xff\xc0\xff\xd3\x00\x81\xff\xbd\xfe`\x00-\x01s\xff\x8d\xfe\xe5\xff\x91\x00/\x00\x16\x00C\x00-\x00\xe2\xfen\xff\xb7\x00\xb8\xff\xb4\xff\x82\x00\x98\xff\x08\x00U\x00\xcb\xff\x11\x00I\x00V\xff\xfc\xfe\xc1\xffe\x01{\x00\x95\xff\xe7\xff\xdb\xff1\x00\x0e\x00\xd1\x00\xc3\xffb\xff\xde\x00r\x00B\xff\x80\x00\xd1\x01T\xff\xa0\xfe\xae\x00\xf9\x00p\xff\x03\x00\xba\x00\xd4\xfe\xef\xffz\x01T\xff\xb8\xfe\xfb\x00"\x00\xf1\xfeA\x00\x12\x00\xde\xff+\x00Y\x00i\xff*\xff\x0b\x01\x95\x00\xab\xfe\x91\xff\x0c\x01W\x00\x0e\xfe\x8d\x00T\x021\xff\xfb\xfe\x9b\x00S\x00l\xff\xfe\xff\xd4\x00s\x00\xfc\xff\x01\x00\xbe\xff\xec\xff\x87\xff\xd9\xff<\x00i\xff\x00\x00\x7f\xff\x93\x00\xf2\xff\xad\xfe}\x00Y\x00\xd2\xfe\x96\x00E\x01d\xff\xe1\xff\x0e\x01\x1d\x00\x92\xff\x85\x00\x86\x00\xcd\xff\x98\xff\\\x00\xbb\x00Y\xff\x87\xffB\x01J\x00\x9a\xfe\xbd\xff\x19\x01\n\x00\xc0\xfe\xa5\xff\x96\x01?\x00\xa6\xfeU\x00\x95\x00\x06\xff\xca\xff\xad\x00\x91\xff&\x00a\x00\x00\x00 \x00y\x00\x83\x00\x96\xffb\xff\x89\x00^\x00}\x00W\x00\xe8\xfe\x11\x00\xaf\x00\xb0\xff\xb5\xff%\x00\x01\x00\xcb\xffp\x00#\x00L\xff\x10\x00\x9b\x00\xb9\xffv\xff\xf3\xff\xc1\x00\x07\x00\xeb\xff1\x00\xa1\xff\xd7\xff\xd3\x00\xb6\xffL\xff\xa7\x00\xd1\x00\xdd\xff\xb2\xfe\xd4\x00\xd4\x00x\xfe/\xff\xb5\x00\xd1\x00(\xffQ\xff\xc1\x00\x00\x00n\xfe2\x00\xff\x00~\xff\x84\xfe\xb8\x00\x82\x01\x19\xff\xf0\xfe[\x00\xdd\x00\x86\xff(\xff\xfc\x00\x95\x00\xe6\xfe\n\x00\xfe\x00\x14\x00e\xfe\xfd\xff\xcc\x01\xa4\xff}\xfe\x91\x00A\x01\x80\xff\xba\xfej\x00\xe3\x00\xf3\xfe\xd7\xff_\x00\xc3\xff\xfb\xff\x00\x00\xe7\xff\xc3\xff\xae\xff\xff\xff\x08\x00\xf6\xff\x1f\x00\x89\xff\xf2\xff\x9e\x00\x19\x00A\xff\x84\xff\xbc\x00_\x00\xac\xff\x8d\xffM\x00m\x00\xf8\xff\xab\xff\xca\xffX\x00\xc7\xff\xbe\xff\x99\x00\xce\xff\\\xff\x84\x00_\x00<\xff\xa4\xffX\x00\xb6\xffS\xffA\x00;\x00`\xff\xbf\xffq\x00\xec\xffZ\xff\r\x00o\x00\x86\xff\xb4\xff{\x00a\x00\xc7\xff\xff\xff`\x00\xaa\xff\x08\x00\x8a\x00q\xff\x8c\xff\xa5\x00M\x00P\xff\xb3\xff\x95\x00\xed\xff2\xff\x02\x00\xe1\xff\x91\xff\xbb\xff\x99\x00\xf6\xffA\xffj\x00p\x00^\xff\xa4\xff\xdc\x00s\x00k\xff\xce\xff\xc3\x00o\x00\x7f\xff\r\x00\xaa\x003\x00\xb3\xff8\x00h\x00\xfe\xff\xd8\xff\xf7\xff\xf1\xff\xd4\xff\x13\x00-\x00\xf9\xff|\xff\xd5\xffg\x00\xf1\xff\x9b\xff\xbe\xff2\x00\xfb\xff\xd6\xffT\x00B\x00\xb2\xff\x87\xff\xf1\xffa\x00.\x00\xf5\xff\x02\x00\xfe\xff\x14\x00\x1d\x00\x19\x00\x14\x00\xff\xff\xd5\xff\xc1\xff=\x00G\x00\x16\x00\xc5\xff\x9f\xff\xe1\xff\'\x00\x0f\x00\xc2\xff\xba\xff\xdd\xff\x06\x00\x19\x00\t\x00\xd2\xff\x0b\x00\xec\xff\x01\x00-\x00(\x00N\x00\x0b\x00\x0e\x00\x0c\x00^\x00V\x00\x00\x00\xe0\xff\x16\x00N\x006\x00\x02\x00\xff\xff\x13\x00\xf9\xff\xda\xff#\x00;\x00\xfe\xff\xc2\xff\x06\x00 \x00\t\x00*\x00\xe8\xff\x00\x00=\x00\xf4\xff\xdc\xff>\x00M\x00\xf5\xff\r\x00h\x00R\x00\xcd\xff\'\x00\\\x00\xd5\xff\xed\xffB\x00&\x00\xd6\xff\xfe\xff+\x00\xe0\xff\xb2\xff\x10\x00(\x00\xc7\xff\xeb\xff\x0b\x00\xfc\xff\xef\xff\t\x00\x11\x00\xdd\xff\xd3\xff\x16\x000\x00\xcf\xff\xeb\xff5\x00\xea\xff\xf6\xff\x08\x00\xde\xff\xf4\xff\x17\x00\r\x00\xe0\xff\x06\x00!\x00\xe5\xff\xd8\xff\xff\xff\xd1\xff\xbf\xff\xf1\xff\xff\xff\xee\xff\xe8\xff\n\x00\xe1\xff\xf1\xff\xf7\xff\xf6\xff\xda\xff\x12\x00 \x00\xee\xff\xf6\xff:\x00!\x00\xb7\xff\x12\x00\x0f\x00\xf6\xff\x16\x00\r\x00\x1a\x00\x08\x00\x01\x00\x06\x00\x0e\x00\x17\x00\xdd\xff\xdf\xff\'\x00\x05\x00\xf2\xff\xf2\xff\xf9\xff\xea\xff\xe6\xff\x0e\x00\xe2\xff\xcb\xff\x00\x00\x1e\x00\xdc\xff\xdd\xff\x0b\x00\xfa\xff\xee\xff\xdd\xff\xff\xff\xeb\xff\xe9\xff\x05\x00\xdc\xff\xef\xff\x02\x00\xe5\xff\xdf\xff\xf9\xff\x13\x00\xe7\xff\xe1\xff\xd8\xff\t\x00\x11\x00\xcb\xff\xe7\xff\x04\x00\xde\xff\xc4\xff\xf6\xff\x04\x00\xe0\xff\xdf\xff\x1e\x00\xf2\xff\xcb\xff\x1d\x00A\x00\xba\xff\xbe\xff*\x00\x04\x00\xee\xff\xff\xff\x01\x00\xf5\xff\xf1\xff\xfc\xff\x0e\x00\x1b\x00\xf0\xff\xcf\xff\t\x00(\x00\x1a\x00\xf8\xff\xf8\xff\xfa\xff\x05\x00\x0e\x00\xf3\xff\xe0\xff\x00\x00\xf5\xff\xd8\xff\x00\x00\x16\x00\x03\x00\xe7\xff\xd8\xff\xf6\xff\x03\x00\x08\x00\x0f\x00\x00\x00\xf7\xff\x1e\x00\t\x00\x1b\x00\r\x00\xe9\xff\xf7\xff\x0c\x00\x13\x00\x05\x00\x17\x00\xdb\xff\xc5\xff\xe5\xff\x11\x00\xfc\xff\xbb\xff\xeb\xff\t\x00\xe3\xff\xc2\xff\x1a\x00\x1f\x00\xb3\xff\xb9\xff\x04\x00\x16\x00\xdd\xff\xe9\xff\xef\xff\xe1\xff\xf3\xff\xf8\xff\xf6\xff\xfc\xff\xf6\xff\xf0\xff\xf3\xff\x00\x00 \x00\x11\x00\xf5\xff\xf8\xff\x11\x00\r\x00\x16\x00\x0f\x00\x14\x00\xec\xff\x1e\x00)\x00\xf3\xff\x05\x00\xfd\xff\x00\x00\xed\xff\xfb\xff\x13\x00\t\x00\x03\x00\x08\x00 \x00 \x00\xf2\xff\x03\x00$\x00\xf4\xff\x07\x00!\x00\t\x00\xe6\xff!\x006\x00\xec\xff\xf9\xff#\x00\x17\x00\x01\x00\x12\x003\x00"\x00\xf7\xff\x1a\x00<\x00\'\x00\n\x00\n\x00+\x00+\x00\x14\x00\x18\x00=\x00\x12\x00\xf3\xff-\x004\x00\xfc\xff\x10\x00\x1f\x00\xeb\xff\xf4\xff\x14\x00\x16\x00\xe9\xff\xe7\xff\x00\x00\xe5\xff\xf3\xff\x08\x00\xf0\xff\xdb\xff\x01\x00\x19\x00\xf2\xff\xef\xff\x0f\x00\x08\x00\xed\xff\x12\x00\x0e\x00\xf2\xff\x18\x00"\x00\xf4\xff\xef\xff:\x00\x11\x00\xf5\xff\x0f\x00\x08\x00\x15\x00\x04\x00\x07\x00\x02\x00\x07\x00\x11\x00\xff\xff\xf7\xff\xff\xff\x05\x00\xf9\xff\xe2\xff\xfa\xff+\x00\x1a\x00\xf3\xff\xf3\xff/\x00\x1a\x00\xde\xff\xed\xff0\x00\x10\x00\xf5\xff\xf5\xff\x15\x00\x00\x00\xc5\xff\n\x00\xf6\xff\xe4\xff\xf7\xff\xeb\xff\xed\xff\xf3\xff\xf8\xff\xe4\xff\xec\xff\xf8\xff\xe5\xff\xda\xff\x05\x00\x00\x00\xf3\xff\xea\xff\xf7\xff\xfc\xff\xec\xff\x01\x00\xf6\xff\xe6\xff\xe9\xff\xfe\xff\x10\x00\xfa\xff\xef\xff\xf8\xff\x01\x00\xf0\xff\xe1\xff\xff\xff\xf9\xff\xe6\xff\xf3\xff\xf3\xff\xe2\xff\xe3\xff\xf6\xff\xf5\xff\xe4\xff\xea\xff\xf6\xff\xf0\xff\x00\x00\xf8\xff\xf7\xff\x02\x00\xff\xff\xf9\xff\xff\xff\x10\x00\x00\x00\xee\xff\xea\xff\x14\x00\x0e\x00\xe4\xff\r\x00\x13\x00\xd1\xff\xd9\xff\x15\x00\xf6\xff\xd3\xff\xf0\xff\x01\x00\xee\xff\xea\xff\xf6\xff\xf5\xff\xf1\xff\xde\xff\xe7\xff\n\x00\x00\x00\x02\x00\xf0\xff\xf0\xff\x05\x00\x12\x00\xfe\xff\xe4\xff\xfa\xff\x11\x00\xf8\xff\xe9\xff\xfc\xff\r\x00\xe9\xff\xe4\xff\xec\xff\xea\xff\xe0\xff\xf4\xff\x02\x00\xd1\xff\xcf\xff\x00\x00\xff\xff\xd1\xff\xdf\xff\xf6\xff\xe5\xff\xdd\xff\xf1\xff\x0b\x00\x06\x00\xe1\xff\xe3\xff\xff\xff\x18\x00\x08\x00\xf4\xff\r\x00\x1e\x00\n\x00\xfa\xff\x19\x00\x18\x00\x00\x00\xf9\xff\x0c\x00\x19\x00\x08\x00\x01\x00\x01\x00\xfc\xff\xf5\xff\xfd\xff\t\x00\xfa\xff\xeb\xff\xeb\xff\xf5\xff\xf3\xff\xf0\xff\xf0\xff\xe4\xff\xdc\xff\xf2\xff\xf3\xff\xee\xff\x06\x00\x00\x00\xe1\xff\xf5\xff\x12\x00\xf7\xff\xed\xff\x01\x00\x0b\x00\xef\xff\xf7\xff\x17\x00\x05\x00\xf8\xff\xf9\xff\x10\x00\x0c\x00\xfd\xff\xff\xff\x0f\x00\x0e\x00\xf8\xff\x06\x00\x0e\x00\xff\xff\x0f\x00\x15\x00\x0b\x00\r\x00\x15\x00\n\x00\t\x00\x16\x00\x16\x00\x0e\x00\x06\x00\x13\x00\x18\x00\x11\x00\x0f\x00\x0f\x00\x12\x00\x16\x00\x16\x00\x1a\x00\x1b\x00\r\x00\x1d\x00"\x00\x17\x00\x15\x00$\x00\x17\x00\x05\x00\x1b\x00\x19\x00\x13\x00\x17\x00\x12\x00\x10\x00\x10\x00\x1b\x00\x1d\x00\x02\x00\xfc\xff\x1c\x00\x18\x00\x04\x00\x0c\x00\x0e\x00\xfe\xff\n\x00&\x00\xff\xff\xf3\xff\x1a\x00\x0c\x00\xe5\xff\x07\x00\x16\x00\xe5\xff\xf5\xff\r\x00\xee\xff\xf1\xff\x10\x00\xf9\xff\xf2\xff\t\x00\x11\x00\xf5\xff\xfa\xff\x0b\x00\xf7\xff\xf8\xff\xfc\xff\x03\x00\x05\x00\x03\x00\xfc\xff\x00\x00\x13\x00\x01\x00\xf4\xff\xf7\xff\x03\x00\xfc\xff\xff\xff\xfc\xff\xff\xff\xf0\xff\xff\xff\x11\x00\xf4\xff\xee\xff\x05\x00\xfe\xff\xee\xff\x04\x00\x01\x00\xf0\xff\xf6\xff\x07\x00\xf8\xff\xed\xff\x00\x00\xf7\xff\xed\xff\xfe\xff\xff\xff\xf2\xff\xf9\xff\t\x00\x06\x00\xed\xff\xfd\xff\x06\x00\xf5\xff\xf8\xff\x00\x00\xf7\xff\xf8\xff\xf3\xff\xf3\xff\x00\x00\xf6\xff\xe9\xff\xfe\xff\xfb\xff\xe4\xff\xe9\xff\x00\x00\xf7\xff\xe8\xff\xf0\xff\x04\x00\xf8\xff\xe9\xff\xfd\xff\xf7\xff\xf3\xff\xf2\xff\x04\x00\xfa\xff\xf9\xff\xff\xff\xfb\xff\xf6\xff\x03\x00\x02\x00\xf1\xff\xf9\xff\xfc\xff\xed\xff\xe8\xff\xf4\xff\xe1\xff\xee\xff\xef\xff\xe3\xff\xe7\xff\xef\xff\xe1\xff\xe3\xff\xf5\xff\xea\xff\xe4\xff\xf1\xff\xf3\xff\xe8\xff\xe9\xff\xf2\xff\xee\xff\xf0\xff\xf9\xff\xee\xff\xec\xff\xfe\xff\xf0\xff\xee\xff\x01\x00\xfc\xff\xe8\xff\xf4\xff\x05\x00\xf0\xff\xe6\xff\x04\x00\x05\x00\xf6\xff\xf5\xff\n\x00\xfc\xff\xf1\xff\x04\x00\xfd\xff\xeb\xff\xf9\xff\x02\x00\xf3\xff\xf9\xff\x00\x00\xeb\xff\xed\xff\t\x00\xfc\xff\xe4\xff\xff\xff\x10\x00\xea\xff\xeb\xff\x11\x00\xfc\xff\xe2\xff\x02\x00\x12\x00\xf3\xff\xf7\xff\x0b\x00\x00\x00\xf7\xff\x07\x00\x01\x00\xfe\xff\x05\x00\x04\x00\x00\x00\xfe\xff\x04\x00\x07\x00\xfb\xff\x02\x00\x08\x00\x00\x00\x01\x00\x01\x00\x04\x00\xfd\xff\xfc\xff\x04\x00\xff\xff\xfb\xff\x04\x00\x04\x00\xfc\xff\xfc\xff\x07\x00\xf9\xff\xfc\xff\x11\x00\x01\x00\x00\x00\r\x00\x0c\x00\xfc\xff\xfa\xff\x07\x00\x06\x00\xfd\xff\x00\x00\x04\x00\x04\x00\xf7\xff\x02\x00\x05\x00\xf5\xff\xf7\xff\x07\x00\x04\x00\xf6\xff\x00\x00\x10\x00\x0c\x00\x00\x00\n\x00\x11\x00\x00\x00\xff\xff\x1a\x00\x0e\x00\x04\x00\x1a\x00\x0c\x00\x08\x00\x12\x00\x10\x00\t\x00\x0b\x00\x0e\x00\x0f\x00\n\x00\x0c\x00\x0f\x00\x0c\x00\x0c\x00\x13\x00\n\x00\t\x00\x17\x00\x12\x00\x0c\x00\x13\x00\x15\x00\x08\x00\r\x00\x17\x00\x05\x00\x04\x00\x14\x00\x07\x00\xff\xff\x10\x00\x11\x00\x02\x00\r\x00\x1b\x00\x01\x00\xfd\xff\x17\x00\x13\x00\xfe\xff\x07\x00\x11\x00\x04\x00\x01\x00\x0b\x00\x06\x00\xfa\xff\x04\x00\x05\x00\xfe\xff\xf8\xff\x05\x00\x0b\x00\x02\x00\xf8\xff\xfc\xff\x13\x00\x02\x00\xf7\xff\x02\x00\x03\x00\xf7\xff\xff\xff\x00\x00\xf6\xff\xf7\xff\x02\x00\xff\xff\xf6\xff\xfb\xff\xfe\xff\xf7\xff\xf4\xff\x00\x00\xfb\xff\xf2\xff\x00\x00\x03\x00\xfc\xff\xff\xff\x03\x00\xfc\xff\xf9\xff\x04\x00\x00\x00\xfc\xff\x01\x00\x06\x00\x05\x00\xfc\xff\x00\x00\x05\x00\xfd\xff\xff\xff\x08\x00\xfd\xff\xf9\xff\x00\x00\x03\x00\xf7\xff\xf0\xff\xfb\xff\xfd\xff\xf4\xff\xf0\xff\xf2\xff\xf4\xff\xf1\xff\xf0\xff\xf7\xff\xf2\xff\xf1\xff\xfb\xff\xfd\xff\xf4\xff\xf9\xff\x00\x00\x01\x00\xf7\xff\xfc\xff\x07\x00\xf7\xff\xf0\xff\x05\x00\x03\x00\xef\xff\xfc\xff\xff\xff\xef\xff\xf2\xff\xfa\xff\xf1\xff\xf0\xff\xf6\xff\xfb\xff\xf5\xff\xf2\xff\xf7\xff\xf6\xff\xf3\xff\xf6\xff\xf6\xff\xf7\xff\xf9\xff\xf5\xff\xed\xff\xf3\xff\xf3\xff\xf6\xff\xf4\xff\xf4\xff\xf5\xff\xf2\xff\xf3\xff\xf9\xff\xf7\xff\xef\xff\xf6\xff\xfa\xff\xee\xff\xef\xff\xfb\xff\xf9\xff\xf2\xff\xfa\xff\xfd\xff\xf5\xff\xf3\xff\xf7\xff\xf8\xff\xef\xff\xf8\xff\x00\x00\xf9\xff\xf2\xff\xf7\xff\xfd\xff\xf5\xff\xf8\xff\xfc\xff\xfa\xff\xf4\xff\xfd\xff\x01\x00\xf1\xff\xf5\xff\x01\x00\xfb\xff\xf2\xff\xfd\xff\xff\xff\xf3\xff\xee\xff\xfe\xff\xfd\xff\xf1\xff\xfd\xff\xff\xff\xf6\xff\xf8\xff\x00\x00\xf5\xff\xf4\xff\x00\x00\x04\x00\xf9\xff\xfb\xff\x07\x00\x00\x00\xf9\xff\x02\x00\n\x00\xfc\xff\xff\xff\x0b\x00\t\x00\xfc\xff\x07\x00\x0f\x00\xfc\xff\xf9\xff\x08\x00\t\x00\xfe\xff\xff\xff\x02\x00\x01\x00\xfd\xff\x07\x00\x02\x00\xfb\xff\x05\x00\n\x00\x01\x00\xfd\xff\x03\x00\x0b\x00\t\x00\x04\x00\n\x00\n\x00\x03\x00\x02\x00\x03\x00\x02\x00\x02\x00\x03\x00\x00\x00\xff\xff\x00\x00\x00\x00\xfd\xff\xfb\xff\xff\xff\xff\xff\xfc\xff\xfa\xff\xff\xff\x06\x00\x03\x00\x00\x00\x00\x00\xfc\xff\xfa\xff\xfb\xff\xfd\xff\xf5\xff\xf8\xff\x01\x00\xff\xff\xff\xff\x00\x00\x03\x00\x02\x00\x00\x00\x02\x00\x06\x00\x04\x00\x06\x00\n\x00\x0b\x00\x0b\x00\r\x00\x10\x00\x0c\x00\x13\x00\x17\x00\x14\x00\x0e\x00\x17\x00\x13\x00\n\x00\x11\x00\x0e\x00\x06\x00\x06\x00\x11\x00\x0c\x00\x0b\x00\x13\x00\x12\x00\x08\x00\n\x00\x12\x00\x0c\x00\x06\x00\x05\x00\t\x00\x07\x00\n\x00\x06\x00\x00\x00\t\x00\x05\x00\x00\x00\x00\x00\xfe\xff\xfa\xff\xfe\xff\x02\x00\x00\x00\x00\x00\x02\x00\x03\x00\x01\x00\xff\xff\xfd\xff\xfb\xff\xf8\xff\xfd\xff\xfb\xff\xf9\xff\xfc\xff\xfc\xff\xf4\xff\xf1\xff\xfb\xff\xfd\xff\xf7\xff\xfc\xff\x00\x00\xf7\xff\xfb\xff\xff\xff\xfb\xff\xf9\xff\xff\xff\xfe\xff\xfb\xff\xfd\xff\xfe\xff\xf9\xff\xf9\xff\xfc\xff\xff\xff\xfc\xff\xfa\xff\xfc\xff\xfe\xff\xfd\xff\xfa\xff\xfa\xff\xfb\xff\xfb\xff\xfb\xff\xfd\xff\xfd\xff\xf9\xff\xf8\xff\xfb\xff\xfa\xff\xfc\xff\xff\xff\x01\x00\xfc\xff\xf7\xff\x05\x00\x01\x00\xfd\xff\x03\x00\x03\x00\xfd\xff\xfd\xff\x00\x00\xfb\xff\xf8\xff\xfd\xff\xfe\xff\xfc\xff\xfd\xff\xfb\xff\xfa\xff\xf5\xff\xf2\xff\xf5\xff\xf9\xff\xf4\xff\xf5\xff\xf6\xff\xf3\xff\xfa\xff\xf3\xff\xf5\xff\xf7\xff\xf3\xff\xf2\xff\xf5\xff\xf2\xff\xef\xff\xf1\xff\xf4\xff\xf5\xff\xf1\xff\xf6\xff\xf6\xff\xf0\xff\xf4\xff\xf7\xff\xf5\xff\xf3\xff\xfa\xff\xfb\xff\xf4\xff\xf7\xff\xf8\xff\xf8\xff\xf7\xff\xf8\xff\xfd\xff\xf7\xff\xf5\xff\xf5\xff\xf5\xff\xf6\xff\xf2\xff\xf3\xff\xf5\xff\xf7\xff\xf9\xff\xf6\xff\xf6\xff\xfa\xff\xfd\xff\xf9\xff\xfa\xff\xfe\xff\xfe\xff\xfc\xff\xff\xff\xfe\xff\xfa\xff\xfc\xff\xfb\xff\xfb\xff\xf9\xff\xfc\xff\xfa\xff\xf6\xff\xfc\xff\xfb\xff\xfd\xff\x00\x00\xfe\xff\xfa\xff\xf9\xff\xfc\xff\xfd\xff\xfe\xff\xfa\xff\xfb\xff\xfe\xff\xfc\xff\xf9\xff\xfb\xff\xfa\xff\xfe\xff\xfe\xff\x00\x00\xff\xff\xfe\xff\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x03\x00\x01\x00\x02\x00\x04\x00\x04\x00\x04\x00\x02\x00\x01\x00\x00\x00\x01\x00\x03\x00\x00\x00\xff\xff\xff\xff\xff\xff\x01\x00\x02\x00\x02\x00\x02\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x01\x00\x01\x00\x04\x00\x04\x00\x03\x00\x06\x00\x05\x00\x06\x00\x04\x00\x07\x00\x07\x00\x03\x00\x04\x00\x06\x00\x06\x00\x04\x00\x06\x00\x03\x00\x00\x00\x01\x00\x04\x00\x04\x00\x04\x00\x08\x00\x07\x00\x06\x00\x07\x00\x04\x00\x03\x00\x06\x00\x06\x00\x04\x00\x04\x00\x06\x00\x08\x00\t\x00\x0b\x00\n\x00\x0c\x00\r\x00\x0c\x00\x0b\x00\x0b\x00\x0c\x00\x07\x00\x05\x00\x07\x00\x06\x00\x03\x00\x04\x00\x04\x00\x02\x00\x03\x00\x03\x00\x06\x00\x03\x00\x00\x00\x01\x00\x07\x00\x07\x00\x05\x00\x06\x00\x06\x00\x06\x00\x06\x00\x05\x00\x04\x00\x01\x00\x03\x00\x04\x00\x02\x00\x00\x00\x00\x00\x02\x00\x01\x00\xfe\xff\xff\xff\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfd\xff\xfc\xff\xfb\xff\xfe\xff\xff\xff\xfd\xff\xfc\xff\xfd\xff\xfd\xff\xff\xff\xfe\xff\x01\x00\x00\x00\xff\xff\xfd\xff\xfa\xff\xfb\xff\xfb\xff\xfe\xff\xfd\xff\xfc\xff\xfd\xff\xfb\xff\xfa\xff\xfb\xff\xfa\xff\xf9\xff\xfa\xff\xfa\xff\xfc\xff\xff\xff\x00\x00\xff\xff\xfc\xff\xfd\xff\xfd\xff\xfa\xff\xf5\xff\xf3\xff\xf8\xff\xf7\xff\xf6\xff\xf6\xff\xf5\xff\xf4\xff\xf6\xff\xf6\xff\xf4\xff\xf4\xff\xf7\xff\xf8\xff\xf7\xff\xfa\xff\xf6\xff\xf3\xff\xf3\xff\xf5\xff\xf6\xff\xf7\xff\xf8\xff\xf7\xff\xf9\xff\xf8\xff\xf5\xff\xf6\xff\xf9\xff\xf9\xff\xf9\xff\xfa\xff\xf8\xff\xf9\xff\xfb\xff\xff\xff\xfe\xff\xfc\xff\xfd\xff\xfd\xff\xfd\xff\xfd\xff\xfb\xff\xf8\xff\xfa\xff\xfb\xff\xfa\xff\xf6\xff\xf4\xff\xf3\xff\xf6\xff\xf6\xff\xf8\xff\xf8\xff\xf5\xff\xf7\xff\xfb\xff\xf9\xff\xf8\xff\xfa\xff\xfd\xff\xfe\xff\xf9\xff\xfc\xff\xfa\xff\xf9\xff\xf8\xff\xf5\xff\xf4\xff\xf4\xff\xf1\xff\xf2\xff\xf4\xff\xf4\xff\xf5\xff\xf3\xff\xf5\xff\xf3\xff\xf6\xff\xf8\xff\xf8\xff\xfa\xff\xf9\xff\xfb\xff\xfc\xff\xfe\xff\xfc\xff\xfb\xff\x00\x00\x00\x00\xfb\xff\xfd\xff\xfe\xff\xf9\xff\xfe\xff\xfc\xff\xfa\xff\xfe\xff\xfd\xff\xff\xff\x00\x00\x00\x00\xfe\xff\xff\xff\x01\x00\x02\x00\x00\x00\x01\x00\x02\x00\x01\x00\x02\x00\x03\x00\x04\x00\x05\x00\x01\x00\x03\x00\x05\x00\x05\x00\x04\x00\x02\x00\x03\x00\x02\x00\x03\x00\x02\x00\x01\x00\x03\x00\x03\x00\x02\x00\x03\x00\x03\x00\x03\x00\x02\x00\x03\x00\x03\x00\x02\x00\x04\x00\x07\x00\x03\x00\x04\x00\x06\x00\x06\x00\x07\x00\x03\x00\x03\x00\x02\x00\x04\x00\x07\x00\x05\x00\x02\x00\x03\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfc\xff\x00\x00\xff\xff\x00\x00\x01\x00\x02\x00\x02\x00\x02\x00\x05\x00\x03\x00\x05\x00\x04\x00\x06\x00\x08\x00\x08\x00\t\x00\x08\x00\x06\x00\x05\x00\x05\x00\x03\x00\x05\x00\x07\x00\x08\x00\x06\x00\t\x00\x07\x00\x05\x00\x06\x00\x04\x00\x04\x00\x04\x00\x02\x00\x02\x00\x00\x00\xff\xff\x01\x00\x01\x00\x04\x00\x05\x00\x04\x00\x03\x00\x05\x00\x05\x00\x05\x00\x06\x00\x06\x00\x06\x00\x05\x00\x04\x00\x04\x00\x07\x00\x05\x00\x04\x00\x05\x00\x04\x00\x02\x00\x03\x00\x05\x00\x05\x00\x03\x00\x03\x00\x02\x00\x00\x00\xfe\xff\xf8\xff\xfa\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\x00\x00\x00\x00\xfe\xff\x00\x00\x00\x00\xfe\xff\xff\xff\x00\x00\x02\x00\x03\x00\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00\x03\x00\x05\x00\x05\x00\x04\x00\x02\x00\x01\x00\x00\x00\xfb\xff\xfd\xff\xfd\xff\xfa\xff\xfd\xff\xff\xff\xfd\xff\xf9\xff\xfc\xff\xfd\xff\xfb\xff\xfb\xff\xf9\xff\xf9\xff\xfb\xff\xfa\xff\xf8\xff\xf8\xff\xf9\xff\xf9\xff\xfa\xff\xf7\xff\xf8\xff\xf9\xff\xf7\xff\xf8\xff\xf7\xff\xf7\xff\xf8\xff\xfa\xff\xfa\xff\xf8\xff\xf6\xff\xf6\xff\xf5\xff\xf8\xff\xf7\xff\xf4\xff\xf7\xff\xf5\xff\xf8\xff\xfb\xff\xf9\xff\xfb\xff\xfc\xff\xfa\xff\xf7\xff\xf9\xff\xfa\xff\xfb\xff\xfc\xff\xfc\xff\xf9\xff\xf7\xff\xf6\xff\xf6\xff\xf3\xff\xf0\xff\xf1\xff\xf1\xff\xf1\xff\xf3\xff\xef\xff\xee\xff\xf1\xff\xf3\xff\xf3\xff\xf1\xff\xf2\xff\xf3\xff\xf3\xff\xf4\xff\xf6\xff\xf4\xff\xf7\xff\xf5\xff\xf6\xff\xf6\xff\xf7\xff\xf7\xff\xf8\xff\xf6\xff\xf6\xff\xf6\xff\xf5\xff\xf3\xff\xf8\xff\xfc\xff\xfc\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc9\x00\xb5\x00\x9d\x00/\x00\x16\x01F\x01\xcb\x00#\x01\xc3\x00p\x00\xe7\x00\x9f\x01\x87\x01\t\x01\xd3\x00\xa0\x01`\x00\xc2\xfe9\x00\xae\x01\x9f\x00\xc5\xff\xfe\xff\x85\xff\xea\xfe\xf3\x00;\x00o\xfdQ\xfd\xd3\x00U\x01\xdb\xfeh\x00:\x02\x9d\xffb\xfe\t\x00<\x00\xbb\xffk\x02\r\x01\xd9\xfe5\x00\x7f\x01\x9f\x00\xa5\xfe`\xfe\xcc\xfd\x91\xfe\x9e\xff6\x00\x83\xfed\xfe\x13\xfe\x9c\xfd\xa4\xfe}\xff\xf6\xfe\xb4\xfd\xe9\xff\xff\x00\xd6\xff\xc9\x00L\x01#\x00\xf0\xff\xa4\x00\x16\x02\xfd\x01N\x02\xba\x02\xf1\x01\x9c\x01\xa7\x01,\x02\x9c\x01\x86\x01w\x01\x9f\x01\xca\x01\\\x01\xe4\x00\x9f\x00\xcc\xffh\xff\xa8\xff\xc0\xff\xc3\xffE\xff\xae\xfeE\xfe$\xfe \xfeT\xfe|\xfe!\xfe\x99\xfe\x83\xfe\x16\xff\xbe\xfe\xaf\xfd+\xff\xa9\xff\r\xff\x81\xff\xde\xff\xf9\xfe4\xff\x06\x00a\xffF\xff\x85\xff@\x00J\x00u\x00\xae\xff\xb0\xfe\x9e\xff\x9d\xffP\xff\x82\x00\x1c\x00\xbc\x00 \x01P\xff\x88\xfe\xeb\xfd\xc4\xff\xe3\xffB\x00\xd7\x01\x92\xffr\xfe\x08\x00$\xff&\xfe"\xffe\xffT\xfe\xc5\x00\xbd\x02\x13\x00\xb1\xff7\xff\xcf\xfe\x17\xff\xd9\xff~\x020\x026\xffS\x01\xd0\x01\xaf\xffE\xff\xa9\x00\x8d\x02\xaf\x00#\x00\xe3\xffa\x02n\x02\xdc\x01\xe4\x01\xbf\xff\xc1\xfe\xcc\xfew\xffM\x00#\x02\x82\x02.\xff}\xfc\x13\xff\x03\x01\xbc\xfe\x0b\xfcq\xfb\xc3\xff\xa8\x02\xb2\xfd\xa9\x00\xc1\x00\xd8\xfb\x0e\xfc\xad\xff\x0e\xff\x9b\xfb\xcb\x00\xcc\x01\x82\x00u\x00\xc2\x00F\xff9\xfb\xa2\xfd\xe0\x01\xca\x01\xb8\x00\x8f\x02\xb7\x04\xa7\xff+\xfe\xe0\x01\xfc\x00\xbf\xfc\x80\x00R\x04G\x03|\x01\xa6\x01u\x02\x0f\xfe\xc6\xfe\x1f\x01\x08\xfe\xe4\x00>\x04\x18\x03\x93\xff\xc2\xfeA\x01R\xfd\xdd\xfd=\x003\xff!\x00k\x026\x00\xc1\xff\xe3\x01\x1e\xfco\xfc\xe2\x00"\x01u\x02s\x03*\x01\x17\x00u\xfc\xe9\xfb\xbe\x02\xbb\x02\\\xfe\xfd\x00l\x029\xfe\xd9\xfcl\x00x\xffs\xfe\xb8\x02\xa2\xff\x93\xfd\xb9\x04\xaa\x03\x9c\xfcc\xfe_\xfe2\xfe\x93\x02\xc2\x01\xdf\x01\x0f\xfek\xfb\xf5\xfe\x13\x01\xc3\x00\xf9\xfb\xff\xfe\x8e\x00\x06\xfe\x12\x03\x8a\x02s\x00\xcb\x01\xc8\x00a\xfe>\xff\x86\x02=\x08\xee\x04r\xfd\x9f\xfb\xc0\xfb\xe4\xfd\xde\x01\x16\x05\xa9\x00\x92\xfc\xc4\xfa;\xfc\xda\x01\t\x01`\x01\x16\xfd<\xfd\xbc\x024\x01\xf5\xfe\x00\x02\xad\xfe\x84\xf8C\xfd\x80\x03;\x03%\x02\xbd\x03\x06\xfch\xf9\x00\x03U\x05\xe5\x00\x1a\x02P\x03E\x01C\x02\x1b\xff\x8f\xff\x8b\xfd\x08\xfd\xe1\xff/\xfcg\xffA\x03\xf2\xff\x9a\xfa\xc2\xfa\xee\xfa\x1f\xfbR\x02\xb3\x06D\xfe\x11\xfc\xec\x00\x03\x01H\x04R\x01i\xfd\x8c\xfdl\x03\xdd\x03/\x04!\xff\x9a\xfe\x83\x03\xd3\x01\xce\x04@\x01\x15\xfa\xae\xfb\xef\x01\x12\x03\xc2\x02\x9f\x02\xb0\x02\xe7\xfbg\xfb\x15\xff\x1e\xffu\xff`\x005\x01_\xfec\x01\x92\x03X\x04\xc9\xfd\x8b\xfdN\xff\x04\xfc\x05\x02\xfe\x04\xf6\xff\x7f\x00B\xfe\x05\xfe\xa1\xff\x0f\xfa\xb3\xfd&\xff6\xfc\x02\x02Y\x04J\x02\x9c\xfe\x1c\xfdf\xff~\x01\xbf\x00\xff\xff\x98\x00\xc6\x03d\x06\xce\x00\x89\xff\x1d\xfe$\xfc\xa0\x01\n\x07\xc2\x01\xf5\xf8\xf7\xfd\x03\xfe{\xfd\xaa\x02\xc2\x01\x84\xf8\xaa\xf4\xca\xfb\xba\xfc\xac\xfe\xca\x02\x00\xff\x9b\xfb\x9e\xfd\xcc\x00\x92\x01\xaa\xfd\x14\x00\xf8\x04\xb1\x03\xe7\x02_\x05\xe2\x007\xfb\x10\x04l\x03?\xfd\xb1\xfe\xd6\x03C\x05\xb0\x02\xc3\xfc\xeb\xfaf\xfe(\xfeU\x02\x86\x03\x86\xfd\xc6\xf7V\xfcV\xfe`\x03\x11\n\x1b\xfc\xdd\xf28\xf9\xe2\x01$\x02\xe4\x05\xc5\x01$\xfaA\xfa4\x06\xba\x06)\xfe\xf4\xfb8\xfd\x1b\x04\xda\x04^\x05\xd3\x03I\x03t\x03`\x03M\x04Y\r\x00\x03\xa8\xf4\xdd\xfft\x00:\xfd\xdf\x00\xa1\x01`\x05f\xfc@\xf3{\xf0\xc2\xf3\x9f\x00\x99\x03\x15\x05\xfd\xfb{\xf4\xfe\xf9\xbf\x03\xc7\n-\x07\xe8\x01#\xf8\xe9\xff\x88\x0e\xfa\x053\x03\n\x03\x93\x05\x7f\x03\x8b\xfc\x03\x02\x89\x01m\xfdF\x00-\xfd\xed\xfbX\xfd\xe2\xff\xad\x05f\x008\xf2\xa5\xed\x15\xfa#\x08\'\tm\xfd\xd4\xf9\xbd\xfe1\xff\x7f\x046\x05\xf3\xf9\x15\xf7]\x01\xe0\x0bT\n\xc4\x03\x9d\x011\xfe\x89\xfa\x96\x02u\n\xea\x01\xe1\xfac\xfa"\x05\x90\x04\xa9\x00K\xfe\x98\xf4\xa6\xf4\xb2\xfa\xde\x01f\x02\xed\xff)\xff\x06\xfe4\xff:\xff(\xff^\xfe@\x01x\x06\t\xffB\x01R\x04\xb8\x03\xde\x03J\xfc\xd4\xfb\xf2\x00\x05\x06\xc8\x02d\x06\x10\x03\x9d\xfa,\x00\x89\x06M\x00\xbd\xf6\x1f\xfd\xdc\x02G\x02\xc5\xfbI\xf6\\\xfd*\x03\x95\xfe^\xfa\x0b\xf9)\xfcl\x07\x82\x08v\x03\x87\xfeq\xff\x88\xff\xf3\xfdh\t\x87\x0bz\x00d\xfd\xe4\xff\x16\x02\xfe\x03M\x03\x7f\x01\xff\xfa\x85\xf4\xdf\xfc\x93\x05\xd8\x00"\xfb\x10\xfb\xa4\xfd\xda\xfd\xd4\xfeF\xfa\x00\xf8M\xff=\x04\xda\x05\x8c\x01\xe8\xfd\xed\xfb\xf9\x01^\x05{\x04S\x00\xfc\xfb\x15\x02\x9e\x03\xde\x01g\x03A\xfe\x9c\xfb\xb4\x00,\x00\xa6\xffp\xfe\xc7\xfb\xe7\xfd\xd3\xfe\xdc\x00\xba\x00[\xfd\xde\xfeG\xffF\xfc\xe8\x00\xb8\x02\xa0\x01L\x02n\xff\xf9\xfd\xd4\x00#\x02\xdf\x05\x80\x01\x8e\xfd\x17\x00Q\xff\xc1\x03\xe7\x03\xa5\xfe\x04\xfdo\xfec\xfc\x1a\x00R\x00\xab\xfe\x9b\xfc\xb1\xf8\xe5\xfdh\x01K\x02\x92\xfe\xb5\xfa\xff\xfce\xfe\x8c\x02n\x0by\x04\xd7\xfc0\x01\xa3\xff,\x003\t\xa6\x05\x14\xffU\x00J\xfd\n\x00y\x04\x04\xfe\xc5\xfb,\xfa+\xfe\\\x03\xe8\x00\xf1\xfbh\xfa5\xfdb\xfdU\x00d\x01\x1d\x00\x91\xfa\x8b\xfcG\x02\x16\x02\xf1\xffT\xfc*\x02\xec\x03m\x00\xb9\xfe\xe8\xfd"\x01\xc5\x08\xdc\x05\xdf\xf8\x11\xfb\xfb\x03s\x05\xcb\x04\xe0\xfdy\xf8\xd3\xfa5\x07\x86\x08m\x00H\xfc\xa6\xf9\xbb\xffB\x06\x00\x04d\xfe=\xfe\xc5\xfb\xb0\xfe&\x06\x81\x04\x94\xfe\xbb\xfb"\xfc\xdc\xff\xdf\x00\xba\xff\x82\x00\x0b\xfd\xdf\xfc\xc2\xfe\x91\x00\x9e\x01\x99\xff\x89\xfe\x90\xfcL\xffh\x03\xad\x03\x9f\x02b\xfd\xc4\xfdi\x02\\\x02\xd3\x00O\x01\xce\xfe\xb3\xfc\xd5\x01c\x02a\xffh\xfe\x86\xfc\xa5\xfd+\xffC\x05e\x02\x85\xfa,\xfbE\xff\xb2\x01%\x04E\x03\xb1\xfd\xe8\xfb\xf2\xff\xf5\x04*\x03\xb0\xfe\x94\xf9\xab\xfe\x86\x05\x9d\x01\xec\xff\xea\xfc\xe4\xfdN\x03r\x02\x8b\x00\x0b\xfe8\x00\x98\x02\x92\x01\xf3\xffX\xfdn\xfff\x01\x90\x01)\xfc\xd9\xfc\x91\x02\x0e\xff\xdc\xfc4\xff@\x00\x06\xff~\xfe\xdd\xff\xf3\xff1\x03$\x02\xcc\xfd\xe5\xfcJ\x03\xcd\x04\xac\xff\xcc\xfd\xde\xffh\x02D\x00\xb2\x03\xdb\x029\xfc\x18\xfbV\x02i\x05\xe8\xfdQ\xfc\xa7\xfeM\xff\xe4\xff\x91\xff\x18\xffr\xfer\xfe\xc6\x00_\x00\xc9\xff&\x01\x1c\x01>\x00t\xff\xc2\xfeP\x02s\x02@\x01\xf0\xff\x01\xfe\xa6\x01\x04\x03\x07\x01\xf2\xfc\x8a\xfcG\x00\x1c\x04\x1f\x02\xda\xfdg\xfct\xfeF\x04\xde\x03\\\xfbo\xf9\xab\xfe\x1b\x01\xfa\x03\xf1\x01\xd1\xfdq\xfb\xe8\xfc\xd1\x01\r\x03\xa1\xffz\xff\x1b\x00H\xff\x8c\x03\x83\x05\x87\x01\n\x00\xe2\xfe\xea\xfcB\x03O\x05R\x01;\xfe\x9f\xfc\x88\xfeC\x01f\xfe\xbe\xfd\x8f\xfd\xe1\xfc \xffO\x00\x93\xff\xf3\xfd2\xfe\xd2\xfe\xc6\x01\x99\xff\x81\xfe!\x00\xf3\x02\x12\x02\x01\x017\x01\x19\xfeV\x01\x9c\x02R\x02\x06\x01\xaf\xfd\xa4\xfe\xe7\x00f\x02\xf2\xfe\x11\xfd\x1c\xfck\xfd\xcb\x04\xac\xfe^\xfa\xa7\xfdk\xfc\x85\x02B\x05\x83\xfe\x98\xfbw\xfd\x03\x01w\x05\xbb\x05Z\x00\xef\xfc\x9e\xfdM\x01\x17\x07k\x03\x12\xfc\x9a\xf96\x00\x15\x07\xff\x05\x86\xfe\xa7\xf7%\xfa|\xff+\x05t\x05u\xfd\x9e\xf7\xb4\xf9f\x03\xe0\x07D\xfe<\xf8\x1a\xf9\x16\x01\x99\x04k\x02\xf8\xfd\x80\xf8\xd5\x00\xb2\x03n\x01\x1a\x00\xe7\xff\xf4\xff\x87\x03\x11\x03\xcc\xff\x86\x02\x1e\x031\x03\xdd\x00&\x00Y\x01\xb2\x02y\x01$\xfc.\xfe\'\x01Y\x00_\xfe\x16\xfd\x9f\xfe\x04\xfd%\xfb}\xfb\xd8\xff\x99\xff5\xfb"\xfd\x96\xfd\xd7\xfd\xd6\xff\xc8\x03\xdb\xfd\xc6\xfa\xb1\x03\xdb\x03\x01\x08\x17\x05\x06\xfc\x98\x02\xf1\x03\xd4\x05a\x08<\x027\xffx\xffS\x02A\x03\x80\x00 \xfa\xf9\xf8\x05\xfc\xeb\x00\x81\xff\xe6\xfa%\xf9\xbd\xf8\xe3\xfb\xd5\xfe\x0f\x00\x82\xfc\xbf\xfeH\x01s\x03\x0f\x05\x1b\x03\xd1\x00\x10\x03q\x02\xe4\x03\xba\x08\x9c\x02\xa8\xff\xbb\xff?\x01\xa6\x05\x05\x05;\xfa\xb7\xf9\xd3\x00\x94\x00\x9f\xff\x97\x00\xe8\xfbT\xf9\x87\xff\x89\x00\x87\xff\x1e\xfce\xfc\x86\xfeT\xfe\x9b\x01\x15\x02o\xff\x11\xfc3\xfc*\x03G\x06P\x04#\x01\x93\xff\xb0\xff\t\x04s\tt\x06D\xfb\xcf\xfb\xf5\x05\x89\x04%\x02q\xfft\xfa\xa2\xfa\xd5\x00\x9d\xffa\xfdu\xfa\xc6\xfa6\xff0\xfe\xb8\x00\xdf\xfd\x1f\xf99\xfd\xf6\x06\x13\x06\xfa\xfc\x91\xfcm\xfe\xb7\x01\x1b\x08G\x03\xcb\xfa\x1d\xfe\xcb\x02\xef\x05\xa7\x03\xef\xfde\xfde\xfe\xda\x00\xd3\x02s\x02\xf5\x00\xba\xfc\x13\xfe\xc5\x00\xe5\xff\xf1\xffd\xfe\\\xfc\xab\xfe-\x00#\x01J\x01\xba\xfc\xcc\xfc>\xff\x1c\x01H\x00\xba\xfe\x87\xfe\xa8\x01x\x01\x88\x00\xfd\x04\xac\x01\x1b\xfd/\x00\x8b\x02t\x02\xdf\x01\xbc\x00\xc6\x01\x15\x02Y\x01\xcb\xff\\\x00\x05\xff\x91\xfc\xeb\xfck\x02\xdf\x03\x85\x01\xe5\xfd\xf3\xfb\x96\xfd\xe4\xff(\x03\x02\xff\x7f\xfc\xdb\xff\xcf\x03\xe6\x01\xef\xff\xe8\xfd\xa0\xfb\x1d\xfe\xb1\xfd&\x03\x8e\x02\xf8\xfd\x95\xfb\xed\xfb\x90\x02x\x03\xfe\xfc\x87\xf7v\xfc\xe8\x02\xae\x05`\x00\xa8\xfaC\xfd\xc7\xfe\xd6\xff\x12\xfd\xca\xfd\xcd\xfe\x7f\xff\xc5\x00\x01\x00\xe3\x00\xb2\xfe\x9b\xfbA\xfb\xfc\x02\x19\x04\xe1\x01\xe5\x01`\xffK\x03*\x04\x92\xff\x1d\xfd\xce\xff\xa8\x01\x18\x02t\x02/\xfft\xff\xe4\x009\xfe\\\xffY\xfeH\xfe\xc7\x00\xe7\x00L\x00\xd9\xfe\xc6\x00\xe5\xfe{\x00\xd3\xff\xa8\xfd5\x01\xdc\x00\xe8\x00\x98\x02\x93\xff\xea\xfc\xd3\x02\xc2\x02\xc6\x00\xea\xff\xdb\xff\x13\x01\x00\x01\x15\x03y\x01T\xffc\x00e\x04\xbb\x03\xd7\x00\x88\xffB\xfe\xfb\x00\x98\x02\x18\x04\x10\x02z\xfe\xb3\x00\xa9\x01W\xffe\xfe\xf4\xff\x9c\xfe\xa2\xfe\xb9\x01\xa4\x00\x1b\x01\x1d\x00\x02\xfd\x92\xfb\xb8\xfb\xb5\xff\x19\x01\xc2\xffh\x00\x92\xff\x89\xff\xe1\xfe.\xfd\x93\xfbj\xfbu\xfe\xc4\x00b\x02\x80\x02.\xff\x82\xfcd\xfbP\xfb\xe4\xfc\xf8\xfd\xe3\x01x\x01\xfb\xfe\x1f\x00K\xff\xa9\xfc\x81\xfai\xfa\x10\xfc\x0c\x01!\x02\xb2\x01\xd0\xfe \xfb]\xfb\x8f\xfc\xf4\xfdG\xfc8\xfa\xe3\xfb\x0e\x01\\\x02\xbe\xfe\xaa\xfa{\xf6\xbc\xf8{\xfc\r\xffB\xfe.\xfb^\xfbx\xfc"\xfe\x08\xfd\x0f\xfbz\xf9\x1a\xfbz\xff\x1f\x01\xa2\x027\x016\xfd\xda\xfd\xe2\x02P\x04\x00\x02F\x04\x9d\x06\xca\x05\x08\t&\x08\xe3\x06\x1e\x06\x14\x05t\t\xfd\x0b\x82\t\x12\t\xd6\x07c\x06e\nz\x0by\x08G\x07\xfa\n\xae\x0c\xa4\x0cM\x0e\xa9\x10\xd1\x11"\x11&\x12\xdc\x14L\x17\x95\x15\x8d\x15\x85\x14\x05\x15\n\x17\r\x14"\x10\x12\r\x16\n.\x07\xf7\x04\x95\x02\x1c\xfe\xe7\xf9\xfe\xf6\n\xf4\x82\xf0\xf0\xec\xed\xe8B\xe7Y\xe7v\xe6\x07\xe5 \xe5\xa1\xe4\xb2\xe3\xa1\xe3\x08\xe5\xa6\xe6\xbb\xe8y\xea3\xecq\xef\\\xf0\xbb\xef.\xf1\xf1\xf2r\xf5\xbf\xf6U\xf6\xa5\xf8\xe7\xf9\xc9\xf7*\xf6l\xf7\xe3\xf6\x92\xf4_\xf4\xd6\xf4b\xf4\xe8\xf2}\xf2\xa3\xefQ\xee\x96\xee\xb1\xf2\xe5\xf3<\xf0\xff\xee\xa5\xf0\x11\xf7\x1c\xfd\xe5\xfb\x1f\xf9\xb3\xfc\xc1\x01\xcf\x05C\x08\xda\x08o\t\x18\n\xa8\x0e+\x12\x8d\x12;\x11\xe5\x0e\xe3\x10\x8a\x14\xd4\x16\x93\x15\xb6\x11\x88\x0f\xaa\x12B\x1e\xdc&\xac$C\x1dF\x1c\xc5!\x00)z/\xa21\x920H.n0\x894\x891\x16&\xe6\x1c\xcf\x1d~"\xf7!X\x19\xb4\x0b\x99\xfeK\xf6q\xf3\xee\xf2\x81\xed\n\xe3\xe5\xdae\xd9?\xd8\xb4\xd2O\xcd\x0c\xca\x1c\xcaY\xceA\xd4\xf8\xd7\xa4\xd7\t\xd7\x8a\xda\xd7\xe1;\xea\x1e\xf15\xf3\xf0\xf6U\xff\xa5\x04\xff\x06\x93\tr\n\x02\r=\x10\xbc\x13\x80\x15_\x11\x88\n\xa2\x07\x8c\x08\n\x08/\x03y\xfc\x08\xf8\xf6\xf4\x8d\xf1\xb3\xee\xc7\xea\x11\xe6%\xe2\xe5\xe1\xe8\xe4#\xe5\x05\xe2\x90\xe0\xf4\xe3r\xe9\x94\xeb\xbf\xec\xc1\xefC\xf2/\xf5\x93\xfa4\xff!\x01Z\xff\xd3\xff\xf4\x04w\x08\x1b\t8\x07g\x05\x97\x06\x14\x07\x81\x07\xa6\x07\xfa\x041\x03\x9d\x01\x84\x01\x10\x03\xfe\x00\x10\x01\x90\xffk\xff\t\xff\xee\xffv\x05\xf1\x03\x00\x02<\x01,\x022\t\x9e\x12\xc2\x1dz\x1f\x89\x18\x07\x19\xd4#O-\xd91\xf84\xf57f:2:H:\xfa7O0\x18*\x97*\xca-\xa1,\xf7 \x16\x11\t\x07\xe5\x00s\xfeg\xfc\xdc\xf5\xd1\xecP\xe43\xdd\xe6\xda#\xd9;\xd4\xce\xd0\xb6\xd1\xd6\xd5\xce\xd6\xcb\xd4"\xd4n\xd5\\\xda\xd8\xe1\xde\xe8\x90\xecE\xef\'\xf1\x19\xf3y\xfa\xae\x00%\x03\'\x07\x11\x0b\x03\r5\x0b=\n&\x0b\x1c\x0c\xd5\x0bv\x0c\x1b\x0b\x97\x05\xa7\xfe\x80\xfa\x9d\xf9R\xf8K\xf5\x13\xf29\xef\xfd\xea\xae\xe6\xf6\xe4\x01\xe6o\xe6;\xe6\xe6\xe6\xd6\xe8`\xe93\xe8\xb3\xe8T\xec\xf2\xf0m\xf3V\xf5\xc6\xf6\xb3\xf7(\xf8\xbb\xf9N\xfb\xe9\xfd\xfe\xffJ\x03\xbf\x04T\x04\xd3\x00\xf8\xfe\x97\x03d\x06\xd0\x08l\t\xbb\x07,\x07\xd8\x02\xfe\x02*\x08\xfa\t\xd5\x08J\x081\x0c\t\rh\x0b=\x0c\xfd\x13\x04\x1f\xda"\xd0"\xa9!\n$\xf6)\xa71 8\x0f<\x8c:b4\xfa1>2C1\xb9-X\'S$* \x1b\x17\xce\r\x12\x03\xaa\xf9\x8d\xf4\xaa\xf1s\xef\x12\xe8\xcb\xdc\x9e\xd3\xf2\xcf\x8a\xd1\xcb\xd2\xc0\xd3\xe1\xd15\xcfr\xcf\xc0\xd1\x81\xd6~\xdb\xe2\xe1\xda\xe6\xc1\xe9t\xee\x93\xf3\xf4\xf4\xc5\xf8\x91\x00i\x07\xe5\x0br\x0c5\x0bj\n?\tf\x0c8\x11|\x11g\r\x03\x07^\x02\xc8\x00\x91\xfee\xfd\xdb\xfb\xfe\xf6#\xf2\'\xef\x86\xec\xb9\xe9\xaa\xe6\xfd\xe6\xa5\xea\x0c\xeb\xa8\xe8\xc1\xe5s\xe6c\xe9L\xed\x02\xf1\xc4\xf1\x13\xf2<\xf2\xe8\xf3\xf5\xf6K\xf9\xad\xf9Q\xfbY\xfd\x9f\xfd\xef\xfdG\xff\xbb\x00\n\x01X\x01.\x03\xcc\x03T\x04q\x04\xd7\x03\x06\x04\x93\x03\\\x07)\x08\x9f\x03\x9e\x01\xa1\x02\xad\x06.\x0b\xe2\rM\x0fe\x0b\xb3\t\xbc\x12\xc5\x1d\xb8$\'$|#\x97\'D,\xc90\xaf5\xa07\x9a6\x884\xb13\xe33A/\xc6)i&\x15#\x91\x1e\x90\x16\x9f\x0cy\x04\xb9\xfe\x8c\xfa\xb2\xf6\xbf\xef\xa9\xe5\x85\xde\xfe\xda\xea\xd9\xc5\xd8y\xd5w\xd2F\xcf)\xcf\xce\xd2U\xd6\x1a\xd8\x8d\xd9\x16\xdc\xa8\xdf\x1b\xe4\xb4\xe8\xef\xecg\xf2\x8b\xf7\xfe\xfa\xdf\xfd\x00\x00\x16\x03\x93\x07y\n\xbb\x0c6\r\x12\x0c\xc3\ng\tu\t\x14\n\xaa\x08\x1c\x05\x07\x01\xb6\xfc\xc1\xf9\xa8\xf9n\xf9\x93\xf6s\xf2\x80\xee|\xee\xc7\xee\xeb\xedP\xee\xd2\xed-\xed<\xed\xeb\xed\x83\xf0\x85\xf1T\xf1w\xf2y\xf3f\xf4\x94\xf5\xdc\xf6$\xf8\xfa\xf9J\xfa\'\xfaI\xfbw\xfd\xb9\xfe\x9c\xff\x17\x01Q\x02\x99\x01\xe1\x01X\x04?\x06\x02\x07\x9a\x05\xbd\x07D\t\x89\t\xfb\x0b|\r\xaf\r\xdc\x0el\x15\x9c\x1c/\x1d\x9a\x1a\xa2\x1c\x8a#\xec((-\xc40=0\xa1.\xbe,\xf7.\xf41k0\xc0,)\'\x02#K\x1e\x8c\x18\x8f\x14v\x0f\xb3\x08Y\x01\xe1\xfa\xff\xf4\x8e\xef\xc9\xea\xce\xe6\xbf\xe2\xc4\xde}\xdb\xd4\xd8&\xd7\x99\xd6\x9f\xd8V\xda\x1d\xdb\xea\xdb5\xdd\xc1\xe0@\xe5\x9d\xe9\xa7\xec\x90\xef[\xf3|\xf6\xbb\xf9P\xfdb\x00\xd8\x02x\x03\x7f\x04\x93\x06\x90\x07\xfe\x06%\x06\xbe\x059\x05\xa5\x03\xc8\x015\x00_\xfd\x85\xfa\xca\xf9(\xf9\x87\xf6\xa2\xf2\x00\xf0\xb5\xf0\x8a\xf1u\xf0M\xef\xbd\xed\xd7\xec>\xee)\xf1a\xf3\x98\xf1\x17\xf0\xe0\xf1_\xf4\x94\xf5\xdd\xf5=\xf7\x92\xf7}\xf7n\xf8\xcd\xf9i\xf9B\xfb\xfd\xfe\x1b\xff\xbb\xfc\xfb\xfa=\xfe\x14\x03\xfd\x04\xf2\x02\x14\x00\xde\xff\xeb\x02\x98\x07q\n~\tT\x06t\x05[\t \x11\xd9\x17\xfd\x19}\x18\x8c\x15\xfb\x17\xab!@-L3\xdf,>(\xe0)20[6-7?3\x0b,h&\xa6$\xd0%M#M\x1b\x1a\x12[\x0c\x9f\x06\xa8\xff\\\xfa\x0f\xf7\xf1\xf1d\xea\xf6\xe3\x1b\xe0\x87\xdc\x92\xd9\xb3\xd9\x19\xda\xe1\xd7-\xd3\x1f\xd2\n\xd6\xe6\xdas\xde\xa0\xdfQ\xdf+\xe0\xf9\xe3A\xeb\xb7\xf1\xb6\xf3\x06\xf4\xf2\xf5\xa5\xf8\xe4\xfc2\x02\x8a\x05m\x04\x94\x02v\x04\x8d\x07\xea\x07\xd3\x06c\x06\x8c\x05\xd7\x03\x0e\x027\x01}\xff\xc0\xfc\x93\xfb\xb5\xfb\xa1\xf9b\xf5S\xf3\x17\xf4\xe3\xf4\xb2\xf3\xc2\xf1\x88\xf1w\xf0\xf2\xef\x11\xf2k\xf3\xb3\xf2B\xf1\xf9\xf1l\xf4W\xf5\xde\xf4\xef\xf5&\xf7\xee\xf8\x98\xfa\xbc\xfah\xfbF\xfc0\xfe\xbb\x00\xb6\x01\xd2\x01q\x01\xe8\x01R\x05\'\x07X\x07\xc6\x06<\x05\x88\x06\xb9\x0b\x80\x127\x13T\x0e\x02\r^\x15\xcc\x1f\xb3#\x02"p G#1)\x910\x9b3\x050\x0b+\xfc*\xa0/O1\x91-\x9b%\xd6\x1f\xb9\x1cL\x1a\xce\x16\xaf\x10\xac\x08\xa0\x01\x10\xfd\x0c\xf9\x00\xf4\xa2\xedr\xe95\xe6\xe7\xe2\xd1\xdfL\xdd\xc6\xda3\xd9\xa9\xda\x12\xddw\xdcF\xda\xa1\xdbj\xdf\xb2\xe3\xaf\xe6\xa3\xe8\xfc\xe8\xfb\xea\xe8\xef\x8f\xf5\xed\xf8^\xf9\x0b\xfam\xfc\x90\xff\x0e\x03\xc2\x04\xe3\x03\xdd\x02\t\x04\xcb\x052\x05T\x03V\x02\xdb\x01\x08\x00\x83\xfe#\xfe\x85\xfcG\xf9{\xf7\x1c\xf8\x9c\xf7\xa6\xf4W\xf28\xf2\x99\xf2M\xf2\xae\xf1\x9e\xf1\x86\xf0\xe5\xef\x8b\xf1|\xf3&\xf3&\xf2\x9e\xf3\x9e\xf5"\xf6\xff\xf6\xac\xf8\x84\xfa\xbe\xfa\x1b\xfc\x1b\xff\x1c\x00d\x00\xc0\x026\x05b\x05z\x05\x88\x06\x95\x08S\nS\x0c\x10\rB\x0b\xac\nX\rP\x13\x9c\x18\x83\x19\xe5\x16\xeb\x15\x1c\x1a\xa6"\x1a)b*\xe3\']%y&@+\x120d0s*\xca#\x8c \xc7 ^ \x08\x1c\xbf\x15\xeb\r\x16\x07\x1a\x03\x9d\x00d\xfcD\xf5\x80\xee-\xeb\xbc\xe7\xde\xe3\xcc\xe0\xbd\xdep\xdd\x18\xdbo\xda;\xdbA\xdb]\xdb\x1e\xdd\xc4\xe0\x83\xe2t\xe2l\xe5\x9f\xe90\xed{\xef\xfc\xf1f\xf4g\xf6\xee\xf8\xcc\xfc\xa9\xffs\x00h\x00H\x01\x90\x02\n\x04/\x05 \x05A\x03\xde\x00\xb5\x00\xbe\x017\x01\x9a\xfe\x0e\xfc\x9e\xfa\xca\xf9\x16\xf9F\xf8\xa5\xf6\x18\xf4r\xf3~\xf4\xf6\xf3\x8d\xf1\xad\xf0\xe7\xf19\xf2f\xf1\x9a\xf1$\xf2\xd7\xf1\xc9\xf1\x84\xf3.\xf5\xb0\xf5<\xf6I\xf7\xf0\xf7\x8d\xf8\x1a\xfb4\xff\x12\x00\x0c\xfe\xe0\xfdt\x01\xfc\x05\xaa\x078\x07\xc5\x05\xb8\x04\xbe\x08\xae\x11\x9e\x15\x9d\x12-\x0e\x90\x11\x99\x1bp!\xc0%_&|$[$=*P3\x856\x9c2\x1c.=-3.Z/M.\xc0)\xaf!\x13\x1a\x16\x17\xc1\x14\xca\x0f\x02\x08\x9c\xff_\xf9\xd4\xf3\xac\xefY\xebZ\xe6#\xe1\xe7\xdc\xc2\xda\x10\xd9\x0e\xd8\xbf\xd6k\xd6\x88\xd7\xd2\xd8\xbb\xd9}\xdb\x81\xde\xf1\xe1\xcf\xe4\xc5\xe7~\xea\x86\xedz\xf0>\xf4!\xf8\xd8\xfa\x06\xfc?\xfd\xe9\xfe\xb6\x01\xee\x04\xb0\x06\x7f\x05,\x03M\x03\xb2\x05g\x07\xbf\x06\x05\x04\xe0\x00(\xffW\x00\xf4\x01\x15\x00\xfc\xfb\xda\xf8\x07\xf9\x07\xf9x\xf8\xf8\xf7L\xf6\xb8\xf3l\xf26\xf4\xb1\xf4\x7f\xf2\x17\xf1\x9f\xf2\x10\xf3\x1d\xf2\xc8\xf1\x08\xf3?\xf3\xac\xf3&\xf6}\xf7\x1e\xf6:\xf5o\xf8M\xfcS\xfe\x12\xff\x8c\xfe\\\xfef\xfe\xa0\x02\xb5\t\xea\x0b\xc9\x087\x05@\x05\x0c\x0c`\x17U\x1c\x1d\x18\xab\x10*\x13\x96\x1f\x03*5-\x0b)3&\xd4&\xcf-X5\x837\xc61{*y)\x14+W+\x1a\'\xa9 \x96\x19h\x13H\x0f9\x0b\xcb\x05g\xff\x12\xf97\xf3\xe7\xed\xc8\xe9\x92\xe5k\xe2d\xe0\x9f\xddu\xdaV\xd7\x93\xd7\x95\xd9\xc7\xdb\'\xdc8\xdbM\xdb \xdeE\xe3I\xe8\xf5\xea\x1d\xeb\xbb\xebd\xef\xa8\xf5F\xfby\xfc\xb2\xfb\xff\xfb\xe8\xff\xa3\x04\x9d\x06\xd2\x05\x94\x04{\x04;\x05F\x06\x87\x06\t\x05\xfe\x01\x17\x00:\x00e\xff\x94\xfc2\xfa\xf6\xf8\xc2\xf7!\xf6J\xf4\x9c\xf3C\xf2\x81\xf0\n\xf1\xa7\xf1\xf2\xef\xac\xedl\xee,\xf1\x10\xf1\x98\xef\xaf\xef$\xf1\xbc\xf1\x80\xf3\x10\xf6\xfa\xf5\x15\xf5\xb3\xf7\xb0\xfb\xca\xfd\x8a\xfd5\xfe\xb0\x00\xa0\x01F\x04\x1b\x06\x0c\x07\x9a\x08)\x08D\x06\xe5\x05\xac\x0bb\x144\x15B\x0e\xc2\tr\x0e\xa3\x1b\x15&\x8f&\xb5\x1e\x83\x19\xd8 \xbb/\x8f8;5]+]&-+c3\xaa5\xd3.0#X\x1b\xef\x1a\x8a\x1c>\x19\x96\x0f+\x06\x19\xff&\xfa6\xf7\xea\xf3\xcd\xee\x89\xe7R\xe2,\xe0\xed\xdd\xd8\xdb\x98\xda\xdc\xda<\xda$\xd8*\xd8B\xda\xe2\xddg\xe0\xb9\xe2\xc3\xe3\x19\xe4\xce\xe6\t\xed)\xf3\xf6\xf4\xe1\xf3[\xf4\x11\xf8<\xfe\xad\x02.\x03\x0f\x00=\xfe\x0f\x01T\x06V\x086\x05\x84\x00T\xfe\x1e\x00\x90\x02\x0c\x02\xe1\xfd\'\xf9\xc0\xf7\x1c\xf9\xaa\xf9\\\xf7\x9f\xf4\xef\xf2\xbe\xf1\xa6\xf1V\xf21\xf2\x15\xf0\xb7\xee\x08\xf0\xc3\xf0O\xf0\xf8\xef"\xf17\xf2\xf8\xf2\x1b\xf4\xa7\xf5\xae\xf6\xcb\xf7\xe1\xfa\x0f\xfd\xfa\xfe,\x01\xf4\x02\xb6\x04\x1f\x06\t\x08:\n\xfd\x0c\xe5\x0e\x7f\x0f\x99\x0f!\x10\x95\x11\xb6\x14<\x1a6\x1e\x85\x1d+\x19\xbe\x1a\x03#h+f.\x8d*(\'\x99&\x91+\xaa2\x064\xe1,\x7f#\x98\x1f\xb0"=$\x1f \x01\x17N\x0c\xea\x05F\x03\xdd\x03:\x00P\xf7\x83\xed\xc4\xe7\x85\xe6\x1a\xe6\x10\xe5l\xe1\x87\xdc2\xd8\xd5\xd8(\xdc\xcd\xdd"\xdd\x06\xdd-\xde\x10\xdf\xf5\xe0\x0e\xe6N\xeaE\xec\xce\xec9\xee\xf5\xf0\x1b\xf5\x8e\xfa:\xfeC\xfe\xff\xfc8\xfe_\x02D\x06<\x08\xde\x06\xd4\x03,\x02\x1a\x04\xb0\x06\xf8\x05|\x02 \xff\xa4\xfd\x96\xfcN\xfc6\xfcx\xfa\xb2\xf6\xd5\xf3k\xf4\xe9\xf4\xa5\xf3+\xf2\xb9\xf1\xc2\xf0C\xefN\xefd\xf1t\xf1-\xf0\x82\xf0o\xf1n\xf1\xf1\xf1\xc8\xf4\x16\xf7\xa0\xf6#\xf7\xa1\xf9\x1e\xfc\xde\xfcQ\xff\xe7\x02\x9e\x03h\x03\x93\x05l\t\xce\x0b\xe8\x0b\x18\x0cf\r\x81\x10:\x16\xac\x17s\x15X\x15\x84\x19\x98 |$\xd4%\x81#\x7f"L&\x7f-M1d-\x9b(\x84&Z(u*\x99(U#\xe9\x1aA\x15\x00\x14\xa2\x12\x07\x0e\xde\x05v\xfe\xda\xf7!\xf4\x1f\xf3\x9c\xf0\x10\xead\xe2\xc5\xdfP\xe0Y\xe0\x84\xdey\xdc\x9c\xda\x97\xda\xba\xdc\xff\xdf\xaf\xe1\xad\xe1t\xe2\x8b\xe5\xff\xe9[\xed\xb7\xee\x90\xf0q\xf3\xfe\xf6\xd8\xf9\x9f\xfb\x8e\xfca\xfe\x16\x01\xac\x02\xd1\x01\x14\x01\x02\x02\xe1\x02}\x02m\x01p\xff\x9d\xfc]\xfbs\xfck\xfc\xee\xf8\x03\xf5\xf1\xf3k\xf4:\xf4\x90\xf3~\xf2U\xf0j\xeeB\xef\xd4\xf1\xff\xf1A\xf0`\xefS\xf0\xf9\xf0<\xf2\x9b\xf4\x9b\xf5\x82\xf4.\xf5\xeb\xf7\xec\xfa\xf1\xfb\x14\xfd}\xff\xcb\xff\xfc\x00\xcf\x03\xc0\x06\xd1\x08\xc0\x06\x82\x06$\t\xc5\x0c@\x11\xa1\x0f=\x0c\xd8\x0b\xd0\x10\x93\x19\xd4\x1d\x11\x1b\xfc\x16v\x18}!\xe1+6.\xb3*\x07&\x88\'Y.75M5\xad-\xfd%\xfc#\xbb&$\'\xa1!\xf3\x17\x1b\x10\xa9\ne\x07\xcb\x04\xc6\xff\x99\xf7.\xef\x8a\xea\xa6\xe8\xe3\xe5\x83\xe23\xdf$\xdc\x16\xdae\xd8/\xd9\xb9\xda&\xdcc\xdc\xaa\xdc\x0f\xde\r\xe08\xe3;\xe8\xfa\xecV\xed\xcc\xecG\xef\xa3\xf4\x88\xfaI\xfd\xad\xfd\xc3\xfb\x83\xfc\xd2\x00[\x051\x06\xbc\x03#\x01g\x00(\x02F\x04^\x03[\xff\x18\xfc\xf6\xfb\x94\xfcu\xfb\x01\xf9\'\xf7\xfc\xf5\x01\xf5R\xf4\xfb\xf3\xa2\xf2\xe3\xf0\xec\xf0\xf2\xf1i\xf1\xb4\xef\x87\xef\x0f\xf1\xa0\xf1\xd7\xf1p\xf2w\xf3\xe6\xf3c\xf5\xfe\xf7\xa4\xf9N\xfa\x1f\xfc\x16\xff`\x01\x1b\x03\x12\x04\xc4\x05f\x07d\n\xd4\r\x99\x0e)\x0e\xcd\x0c3\x0f\x15\x16B\x1b\x95\x1b\x84\x177\x16\xc3\x1b\x8b$\xa3*\xc2)\xaf$K"|\'\x9a/~3\xeb.\xaf\'V#\xfc$\xb5(\xf7\'5!\x0b\x17\x1a\x10\xf7\r<\x0eP\x0b\xb6\x03\x06\xfa\xd4\xf2\x0e\xf05\xef\xbf\xedG\xe9\x17\xe3\xd0\xdd\xa2\xdc\x83\xde\x89\xdf\x8a\xde\n\xddC\xdc\xa5\xdc\x84\xdet\xe2\x88\xe5*\xe7\xe6\xe7@\xe9\x90\xeb\x15\xef\xce\xf3\xf8\xf7\x8e\xf9<\xf9\x89\xf9\x8f\xfc\x06\x01\xbf\x04\xdf\x045\x02\x10\x00E\x01\x1d\x04/\x05"\x03r\xffh\xfc\xcd\xfaq\xfbo\xfc\x98\xfa\x06\xf65\xf2\x93\xf1[\xf2\x7f\xf2P\xf1z\xeff\xed\xa4\xec\x83\xed\x1f\xef\x85\xef\x07\xef\xaf\xeeF\xefh\xf0g\xf2\x9a\xf4H\xf5o\xf5Y\xf7\xb2\xf9\xb8\xfb\xea\xfc\xc2\xfe)\x01\xce\x01\x94\x03a\x07:\tR\t\xb0\t\xe9\n\x8d\rb\x11\xbc\x16?\x17v\x13\x92\x13\xa1\x19y"\xb5&\xb6%\x86"\xe4!\xbf&h/\x8b3\xa9/\xf6(\xdc%H)\xc6,\xc9+\x83%\xb5\x1c\xec\x16\xe6\x14\xc1\x14\x10\x11g\t\xe6\x00\xe7\xf9r\xf6\xdf\xf4W\xf2\\\xec\xde\xe5$\xe2\xa9\xe0\xde\xdf\xf3\xdeS\xdeT\xdcp\xdb\xe8\xdb\x0e\xdev\xe0\x13\xe2\xd1\xe3\x9c\xe5M\xe8@\xeb\xaf\xedB\xf1\xbb\xf4Y\xf7\x83\xf8\x0c\xfa\x92\xfc\\\xffD\x01i\x02\x00\x02+\x01\x13\x01\r\x02\x89\x02N\x01\xbc\xfe\x7f\xfc\xad\xfbE\xfb8\xfa0\xf8\xfe\xf5\n\xf4J\xf3E\xf3\xdc\xf2\xc4\xf1\x0f\xf0\x92\xef3\xf0X\xf1B\xf12\xf0\xdd\xef\xff\xf0\x8d\xf2\xb8\xf3/\xf4.\xf43\xf4\xa8\xf5!\xf8\xbe\xfaU\xfbd\xfbF\xfc\xae\xfd\x19\x00\x03\x03\xa7\x05\xf7\x06\xe3\x05\x90\x04W\x07\xfb\x0c6\x126\x13*\x10F\x0e<\x11Q\x19\xfb!\x1e$\x96 \x89\x1c\x88 \xb2)\xd30\xb82\x80-\xf9)o)9.;2\x1f0\x88)B"g\x1f\xbe\x1e\x82\x1c\x80\x176\x11.\nr\x03\'\xff\xfe\xfb\xe0\xf7\xe4\xf1j\xec\xb0\xe8\xa5\xe4\x86\xe1\x8b\xdf\xa7\xde\xd2\xdd`\xdb\'\xdac\xda\x01\xdc\x0e\xde\xfd\xdf\xb5\xe1\x8f\xe2\xc1\xe3\xcf\xe7\x8f\xec0\xf0s\xf1\x95\xf2\x0f\xf5\xaa\xf8a\xfcD\xff\xce\xff~\xff\xd2\xff\xb5\x01\x9e\x03=\x04\xf7\x02\xdc\x00\xb0\xffM\xff\xe7\xfe\x97\xfd\xa0\xfb|\xf9[\xf7\xf1\xf5\xd0\xf4|\xf3)\xf28\xf1\xfc\xef/\xefw\xee"\xee\xc4\xee\xed\xee\x01\xef\xd1\xee\x1f\xef\x0e\xf1\xae\xf2\xb5\xf3R\xf4\x9f\xf5\xc6\xf6\xc1\xf8Z\xfb\xe7\xfd\x7f\xfe \xffL\x01U\x04\xa2\x06\xd1\x06\xc9\x08\x15\x0b\xdf\r\x1f\x0fp\x0f\xdf\x11c\x13\xe0\x18\xf1\x1d\x14\x1e\x1a\x1c\xc5\x1c\xcd#w+\x9a-\\+\xd1(O(=,\xd11\x1d3a-\xb1$\x18";$\n%\x84 1\x18\xb3\x10E\x0b\xf8\x07)\x06_\x02\xf9\xfa\xb1\xf2\xa5\xed\xa2\xeb\x81\xe9r\xe6\xf0\xe2\xd3\xdf\xc9\xdc\xf3\xda\xad\xdb~\xdd\x01\xde\xd0\xdc\xaa\xdd\x91\xdf\xcb\xe0\x06\xe3\xfd\xe7<\xec\xcf\xec\x90\xec~\xef\xbe\xf3\x0f\xf8\xae\xfbh\xfd\x8f\xfc\xf1\xfb\xd1\xfeQ\x03\xf8\x04\xa4\x03\xca\x00\xc4\xfe\x1a\xffF\x01\xc4\x01e\xfe\xe6\xf9@\xf7N\xf7f\xf7\\\xf6@\xf40\xf1\x9b\xee\xc4\xed\xaa\xee\x11\xef1\xee\xaf\xec$\xec\x11\xec4\xed\xa8\xee\x1f\xf0\xc3\xf0\x0c\xf1#\xf2\x92\xf3\x0b\xf5\xef\xf6\x0c\xf9V\xfa\xab\xfb\\\xfd\xfa\xfd\xf4\xfe\xe9\xff\xb2\x02\x99\x07\xc7\x089\x07c\x06.\x08\xdd\x0fk\x16J\x18z\x17\xc6\x152\x1b\xdd#\xa3+\xeb.\xf9+++e/\xc96\xef:\xad9K5q2\x012\xab2.1\t,o$.\x1eo\x1a\x7f\x16:\x10y\x08\x12\x02\xe6\xfb#\xf6\xae\xf0n\xec\x9f\xe6\x11\xe1r\xde\xfa\xdc\xfc\xd9\x8f\xd5\xd2\xd4Z\xd6_\xd7\x9d\xd6\x18\xd7\x0b\xd8I\xda\xc3\xdd\xac\xe2d\xe6\xc4\xe6\x86\xe8\xcd\xec\xe8\xf2\x0c\xf8|\xf9v\xfav\xfb\xa1\xff\x0c\x04x\x05O\x04\xca\x03\x98\x04\xc0\x04r\x04\n\x04\x17\x02\x98\xfe\xdd\xfc\xd9\xfc\x1f\xfb]\xf7\x92\xf4L\xf3$\xf2\xa6\xf0o\xefx\xed\xde\xeb\xbc\xeb\xb5\xec{\xed]\xecE\xec\xcc\xedk\xef\x88\xf0{\xf2c\xf4\x18\xf6\x8c\xf7W\xf9\xc5\xfc$\xfe\x08\xffF\x02n\x043\x06j\x06#\x072\t\x9d\x0b\xa0\x0e\xd2\x0f\xa2\x0e\x17\x0eS\x10\xf9\x15p\x1c[\x1e)\x1c\xff\x19\x00\x1e\xa5\'p.\x93/\x8e-y*h,\x801\xf66[7+0\x1b+\xa4(k)\x9c\'B#\x19\x1d]\x15#\x0f\x07\n\x83\x06\x83\x001\xf9\xd4\xf2N\xee\x03\xe9[\xe3\x12\xdf(\xdcf\xda\x1e\xd7q\xd5\n\xd5\xd7\xd3&\xd3\xf7\xd4C\xd9G\xdb\x08\xdb1\xde\xab\xe2R\xe6\xa3\xe9\x06\xee\x13\xf2\xef\xf4\xf6\xf6\xb2\xfa\xa5\xfe\x80\x01F\x03O\x04j\x04\xb8\x04\xc3\x05\xaf\x06C\x06\x07\x04g\x01\x9f\xff\xc2\xfe\x83\xfd`\xfb\x8b\xf8\xd4\xf5}\xf3\x0b\xf2w\xf1Y\xefh\xec\xa2\xeb\x1a\xecA\xeb\xff\xe9\x97\xe9\xdb\xe9A\xea\x7f\xeb\x10\xed\x15\xee:\xee4\xef\xb5\xf1\xf6\xf4N\xf7\x8f\xf8\xce\xf9\xc2\xfb_\xfd\x9f\x00\x83\x03n\x06\xe8\x08\xee\x08\xd1\x0b\x82\r2\x0fm\x13\xcf\x17-\x1d\n \xdb\x1e\x07\x1f!"/+\xa03\xd73\x0b2\x16/\x96/;4\xbf:?;\xcc4r-m)\x82*\x8f)\xcc%\x86\x1eD\x16\r\x0fV\t\xe3\x05_\x01z\xfa\x89\xf2\x9f\xec\x8c\xe8\x08\xe3\x8d\xde\xab\xdb\xd7\xd9\xce\xd8L\xd5w\xd3\x08\xd36\xd4\xc1\xd6\xa3\xd9]\xdc\xa1\xdd\xe4\xdd \xe1\x8e\xe8\xed\xed%\xf1_\xf3\xa5\xf4\x9b\xf7p\xfb\xb9\x00\x82\x03\x18\x04\xc8\x03\xc4\x03\xf7\x03z\x05\xa2\x06\x1c\x05}\x02\xf7\xff]\xfe\x15\xfd]\xfbm\xf9\n\xf7\x13\xf4\xb4\xf1\xf8\xefr\xeei\xed\xff\xeb\xb9\xeaF\xea\x8d\xe9\x91\xe8B\xe8;\xe9\xfe\xea\xe1\xeb\x0c\xec\x97\xec\x02\xee*\xf0C\xf3\x82\xf5A\xf7\x16\xf8t\xf9\x99\xfc\x90\x00Q\x03\xf9\x03x\x03\xcf\x042\t\xfa\r\\\x12J\x10\x9c\r\xeb\r\x1b\x15/ \x06&\\$\x93\x1e\x10\x1fY&\xaf1\x959\x039\xe53\x08/\xba1}9_<\xc78\xf81\x12.\x8e+](\x91$\x8b\x1eh\x18\x9f\x12\xe6\r\xec\x07\xe0\xfe:\xf6\x90\xf0\x1c\xef\xec\xec\x9e\xe6\xbb\xde\xb8\xd7J\xd4d\xd6(\xd9<\xd9\x90\xd5\x96\xd2\xa0\xd3\x1f\xd7\xd3\xdc\xc6\xe1\xa0\xe2E\xe4\xb0\xe7i\xebT\xf0\xed\xf3c\xf5\xee\xf9\x97\xfe\x87\x00\xc3\x00\x1a\x01\xf3\x01o\x03\x8e\x05\xff\x07\xf0\x06>\x01s\xfd\xb0\xfd;\xff\x84\xfe}\xfb]\xf8\xc8\xf3}\xef\xfb\xee%\xef\x87\xee\xe9\xeb,\xe94\xe8\xed\xe6\xf6\xe5\xf7\xe5\xc3\xe7\xc5\xe8\xc0\xe9\x86\xea\xe7\xea\x97\xeb\xc5\xecz\xf0\xc7\xf5\\\xf83\xf9\x96\xfan\xfc;\xff;\x02\xc6\x04\xa5\x08\xe4\t\x8a\x0b\x01\x10\xfb\x0e\xf8\rz\x0e\x80\x11\x05\x1a\xe0\x1f \x1f\xf6\x1a2\x19(\x1e\x85\'\x81.\xc7/\x11-\xc3)G-w2\xe16\x985j/ .\xc0-\x91.Q+\x8b$\x9d\x1e\x11\x1a\xca\x18\r\x16]\x0fJ\x05\x9a\xfd\xc5\xf9~\xf7x\xf4\x08\xef{\xe8F\xe0\xb7\xdd\xce\xde\x0c\xdd\x1a\xd9\xa9\xd6\x15\xd8\xd1\xd9\xd9\xd9\xd1\xdb\xce\xda\xcf\xdb\x8b\xe0:\xe52\xecf\xed\xe7\xebg\xee,\xf4\xb8\xf9\xc0\xfbp\xfd\x81\xfe\xa9\xff\x02\x01,\x03\x97\x03\x83\x01\x1e\xff\x9b\xfe\xf8\xff\x05\x00\xf0\xfb\xc5\xf6u\xf4\xfa\xf3J\xf3\x00\xf15\xee\xe3\xea\xb8\xe8\xc8\xe8\xe4\xe8\xe8\xe7\xb9\xe61\xe6(\xe6\x17\xe8\x1d\xea4\xea\x9e\xea\x14\xec\x0f\xef\xe1\xf1L\xf4\x87\xf7H\xf7\x99\xf8%\xfeY\x01\xf5\x03\xdb\x06\x9a\x07\xcc\t{\n\x04\r\x1e\x11G\x11\xc4\x12\xc6\x13\xf5\x15\xb0\x16U\x14\x99\x13\x89\x18W \xe8"\xeb!\xbf\x1e\x00\x1f\xd8!\x06&\xad+\xeb.\x94-\xba)\x98)3*\xea)\xbb\'\x7f&\xd5&}$\xc6\x1f\xb8\x1a%\x15\x19\x0f\xcc\x0be\n\x8d\x08\xd6\x02J\xfa\r\xf4\xd9\xef8\xeb\x81\xe8\xb3\xe7*\xe5\xdf\xdf\\\xdcS\xdbQ\xdb\xa8\xd8%\xd8N\xdd8\xe0\xa9\xdf\xe7\xe0\xb2\xe1\xea\xe2\x83\xe7\xff\xec\xc8\xf2O\xf5 \xf5\xa5\xf5\xdc\xf8\xf4\xfc\xfe\xfe[\x01Y\x03\xf1\x02\x8b\x00G\x01\xf8\x01\x0b\x00\xef\xfc\xe1\xfc\x86\xfdz\xfbi\xf7{\xf3\x98\xf2\x85\xf0\xe8\xed\xbe\xeez\xef\xf6\xea\xb9\xe9\xaa\xea\x94\xe8R\xe8\xee\xec\xc8\xeb\xda\xe9a\xf0m\xf3`\xf2\xa4\xf1>\xf4t\xf87\xfc\x17\xff\x9c\x04\xd8\x04c\x04X\x07\xe1\t\x13\x0c\x14\x10u\x12\xff\x0f\xd4\x11D\x14\xad\x11\xcc\x10D\x11\xcb\x11\\\x12[\x14\xc7\x15\x81\x11\xb2\x10\xcf\x10-\x12\xc3\x17\xd1\x1b\x84\x1c\x8e\x19\x9e\x1b\xb0\x1b\x8c\x1d@#\x1b$I#\n"_"\xca!\x80\x1f\xd0\x1d\x8f\x1c\r\x1a\x08\x18E\x15\xc2\x11B\x0c\xf3\x05\x13\x02\xba\x00\x93\xfdH\xf9\xeb\xf4B\xee\xf4\xea\x86\xe7\xc8\xe5C\xe4\x06\xe2\xe0\xe0D\xe0(\xdf\x89\xdf\xea\xde\xe8\xde"\xe3\xe6\xe4\x88\xe8\x05\xecc\xeb\xcc\xedy\xf0\xd5\xf1\x82\xf7\x88\xfb\xb5\xfah\xfd\xc2\xff\xb2\xff\x9d\x00F\x00c\xff\x7f\x00n\x006\x00:\xff\xd0\xfc:\xf9\xf1\xf6\x1f\xf6\x8f\xf4\x89\xf3\x82\xf3\x8d\xf1:\xee\x06\xefR\xec\xbf\xea/\xef\x81\xec@\xec\xc9\xf49\xf0n\xef\x90\xf5\xf1\xf2\x19\xf9\x01\xfb\xdd\xfa;\xfe\xb4\x05j\xff\x98\x03X\x0b/\x06\xac\t\xc0\t\xe7\x0f\x0b\rg\x08K\x11\xbe\x11\x90\x08\x1f\x11\xaf\x11:\x06\x13\x0fu\x0e\x83\n\xd4\r}\n<\x07\xb1\n\xc1\x0cN\x08\x1c\tL\t\xed\x08F\x0b\xbc\x0c\x0c\x0e^\r/\r\xe5\x0ed\x0f\x81\x12\xa7\x13\xee\x13\xbb\x14\xc5\x11\xa4\x13\xf1\x16U\x12\x87\x0f\xc1\x11\xfd\x11y\r\x05\x0f.\x0f_\x08D\x04\xcf\x037\x031\x01\x13\xff\xa6\xfd\xd5\xfbe\xf6\x8b\xf4i\xf4\x18\xf2X\xf0\xe8\xef.\xefv\xee\xea\xee~\xec\xdc\xeb\xcc\xed\x0f\xedG\xeb_\xee\x11\xf2\x89\xf0\x1f\xf0i\xf2Z\xf2f\xf1\x18\xf4\xb7\xf5\x9c\xf7\x1b\xf7\xd8\xf5j\xf6R\xfa\xfd\xf8\xc4\xf7\xda\xf6)\xf8\x1d\xfc\xa3\xfa\x1f\xf7\x11\xf9?\xf80\xf8\xfd\xf6\x8e\xf7.\xfb\x05\xf9F\xf6O\xf7\xb6\xfb\x8b\xf65\xf5c\xfa\xc1\xf5\x1c\xfe\x03\xfa\xe4\xf8\xfe\xfa\x1d\xfdv\xf9\x9d\xf7N\x04\xe1\xfe\x8d\xfc\x8c\xfe\xfa\x03\xaf\xff\x84\x04\x8b\x05\x82\x01\x7f\t\x83\x05\xed\x02z\x0fQ\t\xc3\x05\xc3\x0e\x97\x0f|\t\xa6\x0cp\x0e\xe3\n\xc3\r\xdf\x0b\xde\x12\x8c\x0f3\n\x07\x0f\x03\x08\x87\x06\xd5\r\x1f\rJ\x08`\t\x1d\x0b\xb8\x06\xec\xff\xd2\x06\xd9\x08Y\xff8\x04\xaf\x0c]\x05u\x03\xd7\x07}\x03\x15\x06\x90\x07\xf3\x05R\rW\x0b\xaf\x064\x0c\xbc\x0fQ\x08}\x03\x89\x0bw\x0bs\x04t\n_\x0bc\x05)\x02\x05\xff\x17\xfd\xd8\x00\xd6\xfd\x15\xfb~\xfc\xf8\xf8s\xf4S\xf5|\xf6\t\xf1\xf6\xf1u\xf4\xab\xf4\xb9\xf5 \xf4\xbb\xf3<\xf5\x9c\xf5\xa2\xf6\xcf\xfa\xd3\xfck\xf84\xfd\x05\xfdJ\xfb\x8c\xff\x87\xfd\xe0\xfd\xd8\x01\xf6\xf6o\x00X\xff\x83\xf8j\xf80\xfbb\xfa\xa1\xf1\xb7\xf87\xf8_\xf2\xc8\xee\x82\xf8\xdb\xf3\xcf\xec\xbc\xf2\xab\xf5C\xf4-\xee\x12\xf5\xe2\xf3/\xf4F\xf4\xed\xf8%\xf5\x8e\xf6^\xff\x03\xf9\xad\xf9\xf3\x04\x82\x01N\xf7\x99\x03C\t\x1c\x04i\x03W\rw\x0c\x1c\x02\xb5\x02\x9a\x0c\x9b\t6\t\xc4\x0b\xd8\x08\n\x0b&\tS\x05j\x04\xcd\n\x1b\x08S\x01\x8f\r\xdd\x0b\xf4\x01\xdd\x02O\x08e\x03\x80\x00\x80\x07Z\ne\x01\xca\x04m\t\x8f\xfe\x8d\x00\x13\t\xc8\x03\x8f\x00x\x0ci\r\xde\x001\x03\xcb\t\xc9\x08j\x06\xf3\x0c\x8f\x0e\x81\t@\t\xdc\x0e\x04\x08\xc8\x05x\r\xea\x0b<\x08\x85\x0c9\x0b\xb4\x05|\x06s\x04\xd3\xff\xb4\x02\x95\x07\x1b\x01[\xfd\xa3\x00E\xfd&\xf4\t\xf9\x84\xfc\xeb\xf6\x88\xf4B\xf7\x94\xf7O\xf3\xdc\xf2W\xf8\x93\xf4\xc6\xec_\xf29\xf7e\xf8\xb1\xf5]\xf5)\xf7N\xef\x90\xf0\xd3\xfa1\xf9\xc9\xef\xec\xf8<\xfd?\xf4\xe7\xee\xb3\xf7\x83\xf7X\xee\'\xf4h\xfbJ\xf3\xf5\xfa7\xf6\xde\xefA\xee\x7f\xf6\x97\xf9\xc0\xf3\x16\xfe\x04\xfaA\xf9B\xf4\x8b\x00X\xf7\x8d\xf8\x8e\x06O\xfc\x98\xfe\xbc\x06\xad\rS\xff\xff\xf7\x18\t\x9f\n\x11\xfd\x92\x07\xf1\x18\x03\n=\xf6\xb5\x13Q\n\xeb\xf7\xf4\x0e*\x10g\x02O\x07\xa0\rj\n\x85\x03\xb0\x03\x1e\t \x06\xd5\x06\xf4\x0e\x1c\x10W\x02\xd9\x05\x96\t \r\xc1\x02\x8d\t\xc4\x10\x8e\x04z\x05F\x0c~\x05,\x00_\x08>\x02\xf2\x02\xfd\x05V\x04\x9d\x00\x04\x02`\x02i\xfb\xbd\x03E\x00I\x00;\x05q\xfc\xe2\xfa\xab\x04\x17\x02\xc9\xf9j\x05Z\xff\xf8\xfa\xa8\xfek\x04\x9b\x01\xaa\xf9,\xfe\x0f\xfd\xfb\xfd|\xfd\xa0\x03\x16\xfe\xaf\xf3I\xf9h\x04w\xf9K\xf2\xc0\x06\xb6\xf7\xbb\xf3\x13\xf7c\x03p\xfaF\xefW\xfe5\xfe\xf9\xf0\x89\xf6L\t\xa9\xf9+\xf2\x08\xf5\xf7\x0b\xe9\xfc\'\xf1\x05\x0c\x16\x04\x01\xec?\x08\x06\x13Q\xfa\x1b\xfc\n\x05\xa9\x0e(\xf8v\x00\x13\x12\x8b\x02\xc1\xf8\xec\x04\x00\x049\x03\xcb\xfd\x0f\xf9\xbd\x07\xfa\xf9W\x04\xc9\xfcc\xfaP\x01\xb1\xfc\xc1\xf4\xf6\xfd\x11\nb\x00\x0f\xfa\x85\xfbC\x07\xea\x06\x93\xfbc\xf5\x87\x0f\r\x031\xf9\xd4\x0c\x93\x14J\xf8\xa3\xf4\xd6\x0c6\x06\xf4\x01\xf7\x03\x15\x0bR\xff\xfa\x04h\x02P\x07\xfd\xfeX\xf4f\x08e\x00\xf7\xfd\xaf\x05\x98\x042\xeeV\xf9{\ne\xf0K\xf8\x81\x03\x8e\xfb\x15\xf2\xa7\x00\x1f\x00y\xfa\x0b\xef?\xf9\x08\x03\xcc\xee\x02\xfeS\x08\x02\xfa}\xe8\xee\x05\xeb\xfa\xc8\xed\xcf\x03_\xf9\x03\xf98\x01\xac\xfd\xfe\xf9\'\x01V\xf7\xd5\xf7\xf0\x0c\x0e\xfe\xc9\xfa\x1c\x07\xc2\x07\xe4\xfdw\xf8\x7f\x0f>\th\xf5\xbb\x018\x1b\x7f\x01s\xf4\x8b\x10{\x0f\xf9\xf7\x1b\x03\xf4\x16\xa0\xfct\x01\x9b\x0cI\t\x86\xfa"\t9\t\xf9\xfa\xff\x06k\x0b\xc0\xfa\xeb\xfa\xed\r<\x04\xd6\xfc\xae\xf5\xcf\x0e\x1b\x02\xad\xf25\x04\x06\x0bN\xf8\xcf\xf9\xa0\x06h\xfa+\xfe\n\xfe\xf2\x08N\xf9\xe5\x01\x86\xff\x9b\xfa7\xfdH\xff\xa7\ti\xf9L\xfb*\x06\xb9\x06\x9b\xf5\xd2\xfcC\x0e\x07\xf7\x17\xf6\xaa\x11\xe2\t\xed\xf5\x91\x00\x0e\n-\xfb\xf4\xfd\xb7\nx\x03=\xfb\xc1\x05\xaf\x068\xfe\x87\x03[\x03\x1c\xf7[\xfa\xf0\x0b\xc1\x00L\xf9\x15\x01\x06\x03\xc3\xf3?\xf42\x03\x93\x02E\xf0\x82\xf2\xe0\x07\x1c\xfb\x7f\xf0`\xf9\xf2\xfeL\xf1\xdc\xf2[\x02-\x04n\xf4\xd8\xf8h\x01\x86\xf9?\xf9\x85\xfcP\x04\xc3\xfa]\xf98\x05a\xfen\xf2~\x02\x06\x00\x81\xf2\x81\xfc\xf9\xff\xe9\xfa\xf3\xf9\xd9\xfc\x95\xfb\xd1\xf7 \xf4\x8d\x04\xe3\x01\x8e\xf2&\xf8\n\x0eG\xfbF\xec\xeb\t\xf7\x10(\xf9n\xf1\x9f\x11L\x0c\x89\xf5W\x04A\x0f6\x07\xd4\xf3.\x0e\xf1\x17S\xf1\xb9\xfdW\x1eX\xfe\x05\xedq\x19}\x08\xce\xfa\xa9\x02a\nJ\x00m\x04I\x06.\x06\xaa\xfdm\xfb\x90\x0ft\x06\x8c\x00\xf7\x01\xe2\n\x93\xfc\x13\x00\xa5\x0fz\xffa\xfa\xf8\x0f\xcb\x05\xda\xf5\xd6\x07V\n\xda\xfb(\xf8\x1f\x08\xb8\x02\x91\xfa\xad\x03?\xfeI\x00T\xfb\x9e\xfe\xc9\xff\xc2\xff\x90\x01\x8c\xfe\x0c\xfch\x07\xda\x04\x9c\xf8\n\x01J\nh\x018\x04f\nj\x05\x00\x02\xc4\x08X\x0c\xbb\xfe\xae\x07x\x05N\t\xb9\x06{\x03J\x0cQ\xff\xce\xf8\x9f\x05\xbc\x07\xc8\xf8C\x02\xb3\x00\r\xfa\x8b\xf7\x93\xfe\xba\x01\xaa\xf5\xca\xf1A\xfa6\xfd\xaf\xfd\xa5\xf7D\xf9\x03\xfdG\xf1\xbd\xfb\x81\xfe\xcf\xf8\x13\xfdy\xfc\xa0\xf5\xac\x00&\x03\x05\xf3t\xf6\x12\x00\xaa\xf7\xa0\xf8\xd3\xffa\x00\xe4\xf6!\xf4M\xf4X\xfaC\xfe \xf3x\xf9Z\xf9\x10\xfad\xfb\xf5\xf0~\xfb\xa5\x00\xfc\xf0Z\xf7i\x00,\x01\xa9\xff\xa4\xf1t\xfc\xd1\x0cg\xfc\xe0\xeer\t\xb5\x0e\x98\xf6\x0e\xfb\x80\x0e`\x05h\xfc\xe4\xff\xc8\x08\xdd\xfe\xbf\xff\x1e\x05\xbf\x051\x05\xa6\xfb\r\x0bS\xfb\xb9\xfe\xb1\x06h\xfeC\xfa\x97\x04\xe5\r\x14\xfc\xcf\xfa\x87\x03b\x03M\x00;\xfb]\x07\x15\x04\x14\x05%\x02\x17\x02\xb1\r\xde\x02\xc2\xfbW\x03\xf9\x10\x14\x03K\x00d\t\xe1\x0b4\xff\xf9\x00\xfb\x0b\xc0\xff\x8e\x03_\x02\'\x01\xdb\x08T\x03z\xffv\x050\x02\xae\x00\x91\x01\x0b\x00\x15\x00M\x05K\x02\x1e\x05S\x03\xf9\x00\x05\x02y\x01}\x01\xb5\x03L\x078\x01B\x06\t\x08u\x03\x1e\x03<\x03T\x06\x0c\x04\x1a\x03Y\x07\xe1\x06\xa8\x02\r\x03\xa4\x01\xc8\x01*\x02n\xfd\xdf\x01\x9f\x02\x83\xff%\xfea\xfc\\\xfe\xf7\xfc\x8a\xfb\xb4\xfav\xfc\x81\xfa#\xfb\x0e\xfd\xbc\xf8\xf8\xf8\x9f\xf5\xd7\xf8\xfe\xfa\x04\xf7V\xf9\xae\xf8{\xf4\xad\xf8m\xf8H\xf5O\xf7\x15\xf5\xd7\xf6\x1f\xfb\xa3\xf7s\xf9s\xf5\xa7\xf3\xcb\xf63\xf8\x96\xfb\x1c\xf9\xc0\xf7\xd6\xfa\xc8\xf6\n\xf3\x08\xff^\xfa\x88\xf4\xbc\xfc\xca\xf9\xa4\xfcP\xfc~\xfc?\xfa\xa5\xfa\x06\xf9\x8b\x00\x12\xff\x06\x00\xee\x03\x90\xfa\xe3\x00g\x03}\x01\xeb\xfd\x05\x02c\x00R\x04\xa1\t\xf6\x05\xe0\x01C\x04\xd0\x01\x93\x05\x11\x05e\x06\xb1\x07\xcd\x05\xae\x0c\xbe\x03?\x06\x18\x08v\x05\x8b\x04\x12\x07k\x06\x80\x035\ri\x07\xe8\x02\xfa\x03\x8a\x05\\\x04i\x04D\x05\x88\x04"\x03r\x03\x9a\x07\x15\x03\xaf\x01\xa7\x03\x8b\x02\x05\x03\xb3\x03\xe1\x05\xe0\x03\xd0\x04m\x06\x14\x04(\x05-\x04\x08\x05\x85\x07\x97\x07Y\x05\xd9\x07\xa7\x06\xd7\x05\xcb\x07o\x05\xca\x05\xdf\x03\xb0\x04+\x05C\x05\xfa\x03\x0e\x02\xf7\x00\xb5\x00\xef\x00\xdd\xff5\x00\xec\xfe8\xfd\x1b\xfd\xe7\xff\xfe\xfc,\xfb>\xfd~\xfb\xc4\xf98\xfc\xd0\xfcu\xfa\x89\xfa\xe0\xfb\xc8\xfa\x12\xfaT\xfc\xd9\xfaz\xfa\xfb\xfa\x99\xfc\t\xfbV\xfaT\xfc\xe1\xfc\x15\xfc\x9e\xfa3\xfb)\xfbu\xfc\x9d\xfb\xe0\xfa\xf2\xfa_\xfbq\xfa\xdb\xfb\xa1\xfa\xf4\xf9\x94\xfaP\xfa\xa4\xf9F\xfb\x9a\xfb\x11\xf9m\xfa?\xfb\x99\xf9\x15\xfa\xe3\xfb\xe6\xf7\xa4\xfbA\xfc\xce\xf9i\xfb\xe9\xfc\xc2\xfa\x95\xfaO\xfd\xbf\xfcf\xfb?\xfe\xfb\xff~\xfdL\xff\xcf\xff\x8c\xfev\xff\xcf\x01<\x01\xdb\x02\x9b\x02\x12\x02\xd7\x03 \x04S\x04I\x04\xd8\x03\x1f\x04\xc4\x05\x8d\x06\xda\x057\x04\x8b\x052\x05T\x04\x9a\x03\x83\x05@\x05e\x04\xde\x03b\x03\x93\x02F\x02\xf9\x03\x10\x02\x98\x02\xaa\x02\n\x01\xe9\xff\xf9\x01c\x02\x92\x00\x15\x01\xfe\x00v\x00\xef\xff&\x01\xbf\x00?\x00_\x00\xf0\xff\xea\x00\xed\x00\x8e\x01\xd5\x00t\xff/\x02\xbe\x00X\x00\x17\x02\x05\x03\x1d\x02M\x01\xaf\x01\xe7\x02.\x03\x01\x03\xc1\x03O\x03\x8c\x03;\x03\x9f\x03\xe3\x04\x13\x05\x96\x03\x1f\x04\xb6\x03P\x03~\x03\x98\x03\xcc\x02X\x02\x17\x02\xdc\x00\x17\x01\xa0\x00\xb2\xff\r\xff\x16\xff\xe2\xfd\xad\xfd\xc1\xfd\xd3\xfc`\xfcN\xfcq\xfc;\xfba\xfb>\xfbB\xfb$\xfb\x16\xfb\x03\xfa\xe0\xfa\x13\xfbf\xfb\xef\xfbE\xfa\xfe\xf95\xfa\xad\xfbv\xfb\x9f\xfb\\\xfc%\xfb\x89\xfb\x99\xfbe\xfb\xcd\xfc\xb3\xfc.\xfc\xf0\xfc\x9d\xfc\x97\xfbH\xfdM\xfeP\xfdO\xfb\xe1\xfdK\xfe(\xfd|\xfe\x14\xfep\xfd^\xfdu\xff\xd7\xfe\xa5\xfe\x83\xffS\xff\xf1\xfd\x0f\x01@\x01\n\xff\xcd\x00\xd9\x01\xa5\x00\xc4\x02\x8d\x03\x81\x00\x9a\x02\xe7\x031\x02F\x03\xf1\x04\x89\x03\xbe\x02\xbd\x03\xb0\x04W\x035\x02\xa6\x03\x8b\x04)\x03\xed\x02(\x03\x9f\x02\xfa\x00\x8a\x02\xaa\x02\xc7\x00\xcb\x02\xa4\x01\x9f\xffL\x00\xb9\x00\xd4\x00\xe1\xff\xcc\xfe\xe0\x00\xd2\xff\x80\xff\x9b\x01T\x00\xad\x00\xe3\xffp\x00u\x01\xf5\x02m\x014\x01\x0c\x025\x03\x98\x03\x19\x02\x95\x03\x08\x05\xad\x03\xf1\x01>\x04\xac\x05J\x04r\x03W\x04\xd1\x03\xcb\x03\xb2\x03r\x03r\x03J\x03\xb8\x02\xb7\x01\x99\x02W\x02V\x01:\x00\xa7\x00{\x00\x18\x00Y\x00I\xff{\xff\x05\xff\xb1\xfe\xf2\xfd#\xfeK\xfe\x84\xfe~\xfd\xd7\xfd\xed\xfd\xb7\xfd&\xfdx\xfdN\xfdd\xfc\x81\xfd\xbd\xfc\x0c\xfd\xb1\xfc\x1c\xfd \xfd^\xfd\xb6\xfc\xc0\xfb\x8d\xfb\xbf\xfdT\xfdF\xfc(\xfe\xb5\xfc\x13\xfd\x03\xfcd\xfc\xb6\xfd\xde\xfc)\xfc\xbb\xfc\x85\xfd\xb2\xfbW\xfd\xa0\xfc@\xfc\xe6\xfby\xfc@\xfe\x9b\xfc\xae\xfd_\xfd=\xfd\x10\xfd\xea\xfef\xffM\xfej\x003\xffl\xff\xdc\xff(\x00y\x00\x06\x01\xae\x01\xc7\x00<\x01X\x01\x1c\x02\x0c\x02\xcf\x01\xc1\x01*\x02\xfb\x01 \x02k\x02n\x01\xbd\x01~\x028\x01\x1d\x01\xe5\x01^\x015\x01\xfb\x00;\x01R\x00\xb1\xff\xd6\xffw\x01!\x01x\xfe;\x00\xd1\x00\xb2\xff\xc7\xff\x8f\xff\x88\xff\xda\xfe\xc0\x00k\x00\x16\x00\xc4\xff5\xff\x03\x001\x00\xf3\xff\xb8\x00\xdc\x00\x93\xff\xae\xff\xd6\x00\xb5\x01\x1a\x01\xe5\x00\x05\x00\x99\x00\xd9\x012\x02\xbd\x01\xdc\x01j\x01\xb9\x01S\x02\x83\x02w\x02g\x02\xe8\x028\x02d\x02H\x03\xe5\x02\xbe\x02x\x02{\x02B\x02\x95\x02\xe7\x02|\x02t\x02\xb7\x01Y\x01\'\x01\xc7\x01\xb8\x01\x89\x00\x14\x00\xde\x00\x12\x00\xad\xffe\x00\x0c\x00R\xff\x06\xff\xa7\xff\xba\xfe\x9a\xfe\xda\xfe\xe6\xfe0\xfe\x06\xfey\xfeq\xfe;\xfdK\xfe\xe7\xfd\x86\xfc\xd9\xfdi\xfd\xaa\xfd\x90\xfdW\xfcp\xfd\xee\xfc\xb2\xfc6\xfd\xbd\xfd6\xfc\xea\xfc[\xfd\xb0\xfci\xfdc\xfdf\xfc\x1c\xfe\x07\xff\xae\xfc\x17\xff`\xfd\x9b\xfc.\xff\xe7\xfe\x04\xff\xa9\xff+\x00\x91\xfd"\xfe$\x00\xd9\xfe\t\xff\x0e\x00]\x00\x84\xfe,\x00\xcd\x00v\x006\xfe$\x00\x9a\x01\x9e\xfd\x98\x01X\x01\x8b\x00\xf9\xff\xb0\x00p\x00/\x00\t\x01\xa4\x00\x17\x01M\x00\x92\x02u\x00\x8e\x00\xe8\x00\x8b\x01\x03\x00]\x01\x85\x01e\xff\xa6\x01=\x00g\x00\xa1\x00H\x01\xe8\x003\x00\xa8\x00\xaf\xff\xc2\x00\xd6\x00"\x00\x8f\x01%\x01#\x01b\xff\xe4\x01\xdf\x01\xa6\xff\xd4\x01\xa5\x00\xe0\x01V\x02\xd0\x01&\x00T\x02\xb5\x00\xd5\x01\xd9\x029\x02\xdf\x02v\x01\xfe\x01a\x01\xf4\x03e\x01\x03\x04d\x03\x97\x01\xba\x02.\x03i\x02\xf6\x02\xc0\x03|\x02\x95\x02\xe4\x001\x03\xc7\x01\x15\x02#\x02x\x00\xee\x01\xe8\xffS\x00\xd7\x01L\xff\r\x00\xe3\xff:\x00\xc6\xffN\xff\xd3\xff\x8c\xfe\x13\xff\x1f\xfeT\xfe\xce\xff\xd8\xfe\xe6\xfd\x8e\xfd\xaa\xfc"\xfe\x06\xfd"\xfe{\xfd\x14\xfe\xa8\xfb\xa6\xfcr\xfd\x9a\xfdE\xfd\xb2\xfc\x00\xfe>\xfc@\xfdI\xfc6\xfe\xb6\xfd\x8a\xfb|\xfdJ\xfe\xd3\xfc0\xfd\x03\xffV\xfd\xcb\xfb\x17\xfd"\xfe\xbf\xfe\xea\xfc[\x00k\xfbp\xfc=\xfeG\xfec\xff\\\xfd\xfe\x00q\xf9\xe6\x00^\xfeF\x01\x00\x00W\xfc\x93\x00\xc2\xfb\xea\x02-\x01\x8a\xff\x14\x00\x9e\x00\x8a\xff\xf7\xff\xc8\xfeG\x02;\x01\xd8\x003\x02\xaa\xfd\x8d\xffY\x02@\x03\xf8\x023\x00=\xfd\xce\xff\xe5\x01\xb8\x02\x81\x03\\\x02\xc8\xfd\xa9\xfd}\x02\x83\x02\x85\x00\xd2\x00|\xfec\xff\x0b\x02\xae\xff\xc9\x00z\x00\xb9\x00\x07\x00@\xfe\xc6\x00J\x03\xf9\xffj\x00\x10\x02\x19\x00\x98\x01\x8a\x01\x07\x01\xe4\x03\xd8\x00\x89\x01C\x02\xb2\x021\x04\x84\x02\x05\x04(\x01v\x01{\x04\xa3\x03j\x03\x1f\x04x\x01\xf7\x03\xff\x03y\x03R\x02M\x02B\x02\xa9\x01x\x03\xe3\x02}\x02\xde\x00S\x01$\x00c\x01\x8f\x01\xa4\x00n\x00\x92\xfe?\xfe%\x00\xb2\x00\xfd\xfe\x0e\x00\x00\xfd4\xfd1\xff\x98\xfe}\xfd\x19\x00\xc6\xfd\xe0\xfcG\xfdt\xfc\xe5\xfe9\xfc\xbd\xfd\xc4\xfe\x87\xfc\x99\xff\xb6\xfe\xa6\xfa\xb9\xfbc\xfcV\xfc\x9e\xfd\x8c\xfd\x8c\xfc\x86\xfd-\xfa\x16\xfc\x8e\xfc<\xfd/\xfc\xe2\xfbp\xfc\xaa\xfc|\xfd\xbb\xfai\xfe<\xfc\x8a\xfd\xcf\xfd+\xfd<\xfd\x03\x02\x9c\xfc\xf3\xf6j\x031\x00\x12\xfc\x12\x03\x9e\x02\xf9\xfaH\xfe\x9f\x02\xf1\x00\xbc\xfew\xfc\xab\x02\'\x05\x8e\x05t\x026\xfd\x9c\xff\xeb\xfd\xe5\x02B\x02\xda\x02&\x03\x1f\x05\xdf\x04\x15\xfc\xce\xff\xff\x00!\xffl\xff\xc6\x07\x11\x03\xaa\xfd\x99\x03\x9d\x00\x8b\xff\xed\xffx\x01\\\x03\xa4\x03m\xfe\xbb\xfe\x1b\x02p\x03c\x04_\x00W\x02N\x049\xfez\x00j\x05\xfe\x03\xaf\x02\xab\x04#\x06\xa5\x01\xbc\x00\xb6\x066\x06;\x03\x18\x04\x9c\x02\xc3\x02\xd6\x04\xb0\x07\xc1\x043\x02\xad\x03\x0b\x01\xc0\xffl\x02\x96\x03\xce\x01d\x02\xe1\x02\x84\xfdO\xfe\xc6\x01\x97\xffe\xfe\x8a\xff\xe6\xfd\x01\xfd\x8d\xff\xb4\xff\xc0\x01\x82\x00\x17\xfb\xe0\xf9|\xff\xcf\x00\'\xff\xcb\x00I\x01\xbd\xfe\xb1\xfav\xfc\xa2\xfe\x1e\xff$\xff\x99\xfd\xb8\xfd\x8a\xfdY\xff\x99\xff3\xfd\xf8\xf9\xd4\xfb\xad\xfd\xd4\xf9\xda\xfb`\x02\x08\x00\xc9\xf7_\xf9\x99\xfb\xa0\xfa\xe6\xfc(\xfe\xd4\xf8s\xfa\xef\xfc\xeb\xfa@\xfd\xe8\xfb\xdb\xf8V\xf7\xb0\xffh\xfeD\xf8F\xfc\x02\xfe\x90\xfao\xfcM\xfc&\xf9`\xff\x9c\x01\x07\xfdI\xfb\xb6\xfcg\x00n\x03`\x00\xfe\xfa:\xfd \x01_\x02\x8c\xff\xe5\x00\xe5\x02B\x00\x86\x03\x00\x03\xd3\xfcP\xfd\xb6\xff\xa4\x03r\t\xa5\x05f\xfd|\xfc\xfd\xfc\xb0\x00\x85\x039\x01&\xff\x8e\xff\xd5\xff \xfek\x00\x80\x01\x8f\xff\xd1\xfe+\xff\x9f\x03\xaf\x05-\x06\'\t\x0f\x0b\x1b\x0b\xaa\t\xec\x08\xef\x07C\x0c\xd3\x11\xcb\x11x\x11\x08\x12\x9e\x0f$\r;\x0e\x98\r$\x0c\x06\t\x18\x07\xb2\x07@\x08\xfb\x06\xcf\x05\xd4\x02\xf4\xfeF\xfa.\xf8n\xf9\x9e\xf9x\xf8;\xf7\xe7\xf7\x8a\xf5S\xf6U\xf5\xeb\xf4"\xf7\xe1\xf3\x1a\xf3\x12\xf5\xf8\xf9y\xfc\xbd\xfbr\xfb\xe8\xf9\xdf\xf8\'\xf96\xfb\xe1\xfd\x96\xff\xdb\xfc\t\xfe\xe4\xfd\x80\xfcs\xff%\x00\r\xfe\xd8\xfb\xab\xfb\xfb\xfb\xd7\xfe\xc9\xfdp\xfe)\x00(\xfe}\xfb\xf7\xf9N\xff\x06\x00\x18\xfc\x03\xfe\xb8\xffd\xfdA\xfd\xb9\x00*\x01\x1c\xfc\x94\xfa\x93\xfb\x8a\xfb \xfe\xea\x010\xfe\x95\xfcD\xfd\x18\xf9b\xf9:\xfa9\xfc\x98\xfb\r\xf9\x10\xfa\xff\x00u\x01\xd3\xfc\xb2\xf9\xf5\xf4\xdc\xf4\x17\xfc\xbd\x01\xe4\x01.\x00\xc3\xfe\xff\xfd)\xfb\x9c\xf9\x8e\xfc\xe6\xfb\x8f\xfbW\xfc\xb0\xf9\xad\xfd\r\x03\x84\xffc\xfc\xb7\xf8\x8d\xf0\xff\xeb\xfb\xf1G\x01\x13\x12\x0c\x1c\xe0\x1a|\x12H\x0eu\x0fX\x13g\x1c\xb5)\xa44\xf85\xed482D.\r)5\x1f%\x18\xd0\x15\xf0\x17\t\x1c\xfb\x19/\x11r\x041\xf68\xe8\'\xe0\xa5\xdf\x99\xe2u\xe47\xe3\x89\xe1\xdb\xde\xa3\xda\x1b\xd7\x9e\xd4\xb3\xd4\x90\xda\xb4\xe4T\xef\xbf\xf8>\xffW\xffu\xfa<\xfa-\xfd\xe5\x03\xe0\r\x00\x13\xa4\x16s\x18[\x17\x96\x14v\x0f\x13\t\x9e\x04\xde\x03\xc8\x04\xf1\x07!\x08\xfe\x04\xac\xfdX\xf2D\xea\xc7\xe7\xc1\xe7C\xe9\xd0\xec\x9c\xee\xbf\xee\xfd\xed\xf9\xed\x93\xecw\xeb@\xed\x05\xf1`\xf7D\x00A\x07u\x07\xcd\x04\xe0\x019\xfeM\xfe\xb2\x01\x9b\x05\x86\x08\x19\x07"\x03\xba\xfeK\xfa\x9d\xf6\xa1\xf4\x8a\xf0h\xf0\x96\xf2\x98\xf3\x97\xf6N\xf7\xd6\xf2\xfa\xebK\xe7\xea\xe5\x89\xea\xd8\xf2*\xf9\x98\xf9+\xf7\x19\xf5{\xf7\xd3\xf8@\xf8\xde\xf5F\xeep\xeb\xc1\xf3$\x15\\A\xf5S"D\x9b"\x03\x13\x7f!?r\xe9ZqL\x11Ha?\xdf&\xef\x0cc\x071\x10\xd0\x13\xf0\x07\x84\xee\x9e\xd2\xd4\xbb\x86\xad\x91\xad\x8e\xbbR\xcb\x11\xd0&\xc7\x9f\xbe_\xc0F\xc9\x8b\xd0\x87\xd7u\xe5g\xf6|\x08h\x12\x7f\x19*\x1e\xc1\x1c\xad\x1d\xbe\x1f\x0e"8&\xbd"\xff\x18\xe0\x12\x8b\x0f\x97\t}\xfd\x9e\xec\x90\xde\x9f\xd6\xbd\xcf\xbf\xcd\xe0\xcf\xf2\xd0\xea\xcc\xa8\xc6\x0f\xc5\xf3\xca\x14\xd6Z\xdcb\xe0L\xe7w\xf1\x88\xfe\x0c\n\xf2\x12\xd9\x18?\x19g\x15]\x14\x8b\x18\x00 \xd7#\xf1\x1e\xa9\x16\x0c\x0fd\x07Q\xff\x1a\xf7s\xf0\x95\xed\x96\xec\xc7\xe9\xcb\xe6\xf7\xe1\x1a\xdc"\xd6\xe3\xd2)\xd6f\xdfd\xe8\xf1\xee\xb0\xf0\x02\xf2\x90\xf6\x98\xfa\t\x02q\x08\xe0\x0e*\x13\x1a\x15\xb9\x17\xca\x180\x19\xe1\x12\xd1\x0b\x00\ta\x03\xa0\xfd\x96\xfe\xaa\x14;>\xa9Y\x81PC.9\x17\x03\x1d\xa44\xfcL\x88[\x9ba\xfdV\xc8=e\'5\x1c\xa3\x17\xc3\x0bk\xfb\x1f\xf7\xfd\x01\xc4\x0bi\x01\xff\xe2\xf2\xc1\x95\xaf=\xb1\xee\xc1`\xdaN\xed\xfb\xf0\xd4\xe3\xba\xd4\x9f\xd2*\xdc\xa5\xeb\xbb\xf8\xab\x06)\x16\x00$[)\x81"8\x12`\x03\xa3\xffp\x03\x93\x0e`\x17[\x10L\x01\x07\xed\x89\xdaP\xd4\xca\xd3\xfe\xd2\xa7\xd0\xe3\xd0\x85\xd7\xd5\xe0X\xe4I\xe1\xd8\xdc}\xd9\x1d\xdf\xb9\xf1\xc5\x08\xd5\x19W\x1c;\x13?\x0b\xbf\t\x8a\x0f\xb3\x17\xe3\x1a\xf4\x18\x88\x13\xee\x0c\xcf\x05T\xfe\xdf\xf6\xde\xed\x16\xe6\xfa\xe1Z\xe4\xea\xe9Y\xe9\xcf\xe2G\xd9\x07\xd4\xb2\xd5\xc6\xdb`\xe5\x81\xee\xdc\xf4L\xf8\x81\xf9\xf9\xfc@\x01H\x03\x8c\x05=\t~\x11\xff\x1a\x90\x1f\xa8\x1d\xa5\x14\x85\x08\x92\xfej\xfa\x00\xfe\xf7\x04\xc0\x03\xb2\xf5p\xe4\xf1\xe6\xa0\x0c\xda>\xceS\x03=\xbb\x1b\xa7\x14\xaa,\xd4M\xef^\xbdbs^qN]=\xba0j\'\xcd\x1bT\x05\xb6\xf6j\xfc\x8a\t\xf5\x07\xfa\xee\xf8\xcc\xf6\xb7\'\xb5\xaf\xbf\xcd\xcfa\xde\xbc\xe4k\xe2h\xdbe\xda\xbc\xe3X\xf0\x17\xf9B\xfe\xe6\x08\x96\x1a\xf2*\x06,\x0f!j\x11\xa9\x03V\x03\x02\x08U\r1\x0e0\x04=\xf9|\xebR\xdd\xc5\xd38\xcb\x93\xcc\x1b\xd4b\xdb\xab\xe3\xcd\xe7\x0b\xe7,\xe2J\xde\xa2\xe3\x91\xf3g\x05w\x11q\x17e\x17g\x12\x8f\r\'\x0b,\x0b\xa6\x0c\x87\r\xca\x0c@\x0bW\x08\xa0\xff\x16\xf1a\xe2\xd0\xda\xf6\xdb)\xe2\xd4\xe8\x1c\xeb\xc0\xe7\xb7\xe0\x8f\xd8\xf9\xd6\xe8\xdd\xbe\xe9\xfd\xf4C\xfc\xba\xfex\x02!\x05\xe6\x03\xad\x04\xa4\x05\xdf\t+\x11X\x14\xcf\x14\x9c\x11$\t8\xff\x01\xf9\x18\xf8\x16\xf9\n\xf9"\xf2\x92\xe3\x8b\xd78\xdb\xf1\xfd\xdb1\x95RtO\xfc8\xcf,~8SRYb\xe4h^o\xbcq\xb3i@Xb=S\x1c\\\xfd\x80\xe9\xb8\xeaY\xfag\x01f\xefJ\xcf\xd9\xb2Q\xa9\xce\xae\xaa\xb8d\xc6j\xd7{\xe5\xce\xed\xd7\xf2\xb1\xf6\xe6\xf8!\xf6\xf5\xf5\x93\x04\xfb\x1f\x047w=\xf6-\xfd\x15\x1a\x06b\xfd_\xff\x94\xff\xef\xf8\x1e\xf4\xaf\xeb\x94\xe3\xa4\xdc\x19\xd13\xc6\x18\xbf\xe4\xc0\xae\xcd\'\xdeY\xec\xab\xf2Y\xf1\xa1\xee\x9d\xefr\xf7\xa9\x03\x04\x11\xef\x1b\xa7\x1f\x9e\x1d\x9c\x19\xad\x14\xe6\x0er\n@\t\xf3\x08\x84\x07\xf4\x02\x1e\xf9Z\xee\x89\xe5r\xe0\x1e\xdf\xb2\xde\x90\xe0D\xe1@\xe1\xc4\xe0 \xe0\x8d\xe2\xb5\xe7~\xed\xa2\xf4\x82\xfc\xd4\x03~\x08D\x07\x03\x04\xb7\x03\xe4\x07\x8c\x0f\xb5\x14\xba\x14I\x10r\x079\xfe\x16\xf8\xd2\xf5\xc5\xf4\x9f\xf1\xfb\xeb\xc4\xe9\x8f\xec\xa0\xe9\xdf\xdb\x07\xce\x96\xd4\xc4\xfcE2\x95T\x00Z\x94I\xa1:\x85=1M{c\x14uxyRp\x98\\\xf5E\xc50n\x1a\xf2\xfeB\xe7\x1a\xdf\x85\xe3\xcf\xe9V\xe9\x03\xdbC\xc5<\xb2F\xab\\\xb6\x11\xce]\xe7\x1b\xf8\xb9\xfc\x95\xfba\xfb\xea\x00\xe3\x08\x15\r\xc2\x12\xa6\x1b\xe2%\x11,2(\x10\x1c\xdb\x08\x84\xf3\x1d\xe5\xc7\xe1m\xe6W\xed{\xed\xeb\xe2\xb7\xd49\xc8)\xc4(\xc8\xfb\xce-\xd7\x00\xe0%\xeb9\xf6L\xff\x9e\x03\xbe\x02\xc1\x00,\x00\xcc\x05\xce\x13\xcc \x9f#\xed\x1do\x14\xc2\r6\x0b \x08]\x033\xfe\xc5\xfa\xce\xf6\xe6\xf0v\xe9!\xe3#\xde\xa9\xd8>\xd6\xb7\xd8h\xdf\xb4\xe6\xd1\xe8\xd2\xe7\xa1\xe8*\xee\xb4\xf6\xcf\xfd\x07\x04j\t\xac\rU\x11n\x11\xf7\x0e \r\x9c\n\xd0\x08\xb1\x08D\x07\'\x03\x80\xfc]\xf5|\xf0;\xee@\xea\xbe\xe4\xc4\xe2\xd7\xe1\xd3\xdd\x82\xdd8\xec\x93\x10\x98;qR\xefQ\x82FYB\x89K\x0c\\Fk\xddsQu\xafl\xddW\xb8;\xb6\x1d\xfd\x03Q\xf2<\xe8\xd6\xe7]\xeb\xb9\xe8O\xddG\xcdF\xbd\xe0\xb5\xea\xb9\xf3\xc6Z\xda\xe1\xee<\xff\xc9\x08K\t\xd8\x04\x9e\x00\x9c\x00]\x06\xcf\x10H\x1e\xb8\'M(p\x1bu\x07\x0c\xf57\xe7\x82\xe1\xf5\xdf|\xe1\xa3\xe5Y\xe8\x89\xe8\xe6\xe1(\xd8\xee\xcf^\xcbY\xd0\xf5\xdb]\xeb\x1e\xfaJ\x00&\xff\xd4\xfc\xf1\xfb\x99\xfd\xd0\x00j\x04r\x0c\x9a\x17\xb4\x1e\x80\x1b\t\x10\x1e\x03w\xfa\x88\xf8\x95\xfaW\xfd\x92\xff\x9c\xff\x98\xf9\xa2\xee\x9f\xe3\xf3\xdc\xd0\xdbX\xde-\xe4\xca\xeda\xf7N\xfc\x9f\xf9\xb6\xf16\xec\x9f\xedP\xf5&\x01\xb6\x0bS\x12\xed\x13[\x0f\xc9\x07\xf6\xff\xac\xfb\x9c\xfc\xfb\xfe4\x01>\x01\xd2\xfe\xa2\xfb\x01\xf7t\xf2s\xec6\xe7\xff\xe5s\xe8B\xeew\xeb\xf5\xe6\xa2\xf3\xe4\x145>\xa1T>R\xbcG\x9eC$K\'V\xc3^\xc3c&d\x94`\xa2Q\x8f8\x11\x1b\x98\xff6\xea\xf3\xdaE\xd7z\xde\'\xe7\xde\xe6(\xdb\xb9\xca\xf3\xbfx\xbf\xd0\xc7i\xd7\xb5\xeb\xe8\x00!\x11l\x16\\\x13\xf6\x0b\x10\x06v\x04\x8f\x06\x02\x10\xab\x1a\x9d!\xf1\x1e\xea\x0f\xfe\xfdl\xed\x85\xe3\xc1\xe0\x91\xde\x16\xdfS\xe1\xab\xe3\x11\xe4`\xe0\x14\xd9\x81\xd1P\xd0\x84\xd6\xa9\xe2U\xef\x86\xf8\x96\xfdZ\xfe*\xfer\xff\x92\x03\x1c\t\x91\x0e\x85\x13\xb9\x15\x05\x14\xdf\x0e\xad\x07\x7f\xff\x1e\xf9\xfe\xf6\xe8\xf7\xab\xfb\x97\xfe\xbc\xfb\x08\xf4\xc5\xe9\xbf\xe1\xb8\xde\x01\xe1\xc7\xe7\xf1\xeeI\xf5S\xf9\x94\xf9\xda\xf8\xd4\xf6f\xf4\xbc\xf4\x82\xf7;\xff\xb4\t\xfb\x10\x86\x13\xc4\x0eM\x05*\xfeM\xfa\xa0\xfa\x0c\xfd\xb9\xfe%\xff|\xfc\xc0\xf8p\xf2\xdf\xeb\xcc\xe7\xdf\xe6\xe0\xed\x08\xf8\x1b\xfd\x8c\xf8\x80\xee\xbf\xecV\xff\xfe%9M,_\xd4Y\x03L7G\x90NyU\x1dU\xe2OkIqF~>\x82*\xcc\x10\x0b\xf5\xf4\xde\xce\xd3\x0e\xd3\x9a\xdb\xd9\xe5B\xe9\x01\xe3{\xd7b\xd0\t\xd3\xc7\xdd\x92\xeb\xaa\xf8Y\x03\x8f\x0co\x13s\x15l\x12\x89\x0b\xf3\x03O\xffJ\x01\x1f\t\xfd\x120\x16X\x0f\xbc\x00\xda\xedU\xe2n\xde[\xdf\xbe\xe4.\xe8)\xea)\xe9\xb3\xe3\xb6\xdeR\xdb<\xda>\xdc\xd7\xdfS\xe6\x9e\xf0\x1e\xfc\xf5\x05Q\n\x9f\x07\xd4\x01\x0f\xffy\x02J\n\x90\x11\xf4\x15\x1a\x15\x1b\x0f\x02\x07\x16\xfe\xfe\xf6\x02\xf3~\xf0\xb9\xef\xc3\xefk\xf0W\xf0\xab\xee\xff\xea\xcb\xe6\xfa\xe5.\xe8\x98\xee\xff\xf5\x16\xfdS\x02\xa1\x02\x1f\x01\x07\xfe^\xfbm\xfcT\xff\x15\x05\x19\x0bm\x0e\xa0\x0e\r\t\x08\x00W\xf7\xf6\xf0\x0f\xef\xca\xf0H\xf4\\\xf8\xb4\xfa\xe3\xfa\xff\xf7\xc1\xf2~\xed\xf1\xeb}\xf0\xb5\xf5\xc6\xf9\x10\xff\xfe\t\x8f!\xa7<\xaeP\x8bY\x97SKI\x9e@\xec<\xdeB\x8aK\xa1Q\xc4M\x19<\xb7#.\x0b\xf3\xf7\xd0\xea\x15\xe0\x0c\xd9\xc7\xd8\x86\xde\x8a\xe5\xed\xe8H\xe5+\xdeV\xd8\xa2\xd6\xf0\xda\xe7\xe5\xfd\xf55\x08\xd1\x15\xd1\x19,\x15;\rR\x08\xdf\x07\xd4\x08\xff\x08"\t[\nk\x0c\x10\x0bR\x03\n\xf7e\xe9\xef\xde[\xd8\'\xd6\x80\xd9\'\xe0\\\xe7\xce\xe9\xdc\xe4\x7f\xdd\x03\xd9\xe8\xdac\xe1\x97\xe8\xe7\xef\x98\xf8\xef\x02=\x0c\xcd\x10>\x0fQ\n|\x05\x1a\x03V\x03M\x06\x93\x0b/\x10\xa4\x10\x84\n)\xffQ\xf3\xfc\xebf\xea7\xed\xf3\xf1\x94\xf4\x84\xf5\xda\xf4\xec\xf1\xac\xee\x82\xeb\x81\xe9P\xeb\x06\xf0\x1b\xf7\x91\xff[\x05\xc9\x07\x1d\x06\x90\x00\x9a\xfc\xcd\xfb\x0f\x00\xb3\x07\xf6\r^\x11\x8a\x0eT\x07Y\xfe\xca\xf4\xbc\xf0\xc4\xf0\x9a\xf37\xf7\xbc\xf6\xbd\xf5\x8f\xf4%\xf2\xea\xee(\xec\x86\xec\xed\xee\xa6\xf1I\xf4}\xfc\xaf\x11z/\x1eMp^\xd4\\nP\xb1D\xc2?\xafB\x95H6M{N\xf7F\x905\xb8\x1d\xfe\x05\xbd\xf3Y\xe6\x17\xdc;\xd5\xd0\xd3\xc6\xd8\x1c\xe0\xd4\xe4\xdc\xe3Q\xde\xa5\xd8.\xd68\xd9\xb5\xe2\x92\xf2G\x03\x00\x0fh\x121\x0f\xfe\x0b\xe9\x0b\xe7\x0c\xd3\x0c\xe6\n1\t\xbc\n\xb7\r\xaf\x0el\x0bF\x03\xf2\xf8\xed\xed\xf7\xe3\x7f\xdd\xec\xdc\x05\xe2\xea\xe8?\xec)\xea@\xe3[\xdc\x98\xd9\x82\xda\xac\xdf\xf5\xe6@\xef\xa4\xf7\xd8\xfd\x82\x01\xf3\x03\x0c\x06\x89\x085\n"\t\xae\x06\xc9\x05\x86\x07\x9f\x0b\xda\x0e\xbd\r\x0f\t\x80\x01\xaa\xf9\xaa\xf3V\xef\xef\xeeu\xf1u\xf4I\xf6O\xf5\x8c\xf2\xb6\xf0z\xeff\xf0\xfb\xf2\x85\xf60\xfc^\x02$\x07\xca\t\xe4\x08E\x06\xc7\x04\xba\x04\xf4\x06\x1e\t\xa8\t\x17\x08!\x04z\xfe>\xf8\x07\xf4\xe2\xf1]\xf1\xaf\xf2\x0c\xf3\xe7\xf1b\xef\x9b\xea\xdc\xe9!\xeb\xae\xeb&\xea\xc6\xe6\x8c\xed\xa1\x026$\xf6G&]\x99`wUUE\x17<\xe4:\xccB\xa3NDW\xcdW\xbcJ|2k\x15h\xfa\xf4\xe6\xf2\xdc\x89\xd9T\xda\xf9\xdbD\xdd\xcb\xde,\xdfW\xdd~\xd8\xdb\xd1\xae\xcd\xfe\xcf}\xd9\x9d\xe8\x85\xf9\xc6\tG\x16\x1e\x1cN\x19\x0e\x0f*\x03\xe8\xfc\xf7\x00\x9c\x0c#\x19\x87\x1f\x15\x1d\xc3\x13\xc9\x05m\xf6\xab\xe8=\xdf\xbf\xdc\xe0\xdf\xb3\xe4\xe3\xe6\x17\xe4<\xdf\xe4\xdb%\xdb\xd7\xdb\x1e\xdd\x85\xde\xe0\xe0\x95\xe5\x83\xec\r\xf6\xd5\x00\x00\x0b\x9e\x11\x99\x12\x86\x0eN\x08I\x03\x94\x026\x07\xef\x0e\xba\x15\x80\x17\xec\x12&\t\xb0\xfd4\xf4\xb0\xee\xb3\xed4\xefh\xf1\n\xf3w\xf2A\xf0q\xee\x14\xee\xa9\xef\x82\xf2\xae\xf5K\xf9\xca\xfd\xe9\x02e\x08h\rY\x10\x9a\x11\xed\x10\xf3\r\x91\n\xb0\x06\x1b\x043\x03\x06\x02\xe7\xff\x7f\xfb\xb1\xf5.\xf0\xa3\xeb\t\xe9\x88\xe7\xa3\xe7\x03\xe8\xf6\xe6\xd4\xe3\x1b\xdev\xd8\xf1\xd7\xb5\xe1\xd5\xf8\xba\x18L8yOPXGUmJ\x11@\xe8=\xceEOU\x05b[dJY\x02D\x8f+\x0b\x14\xfd\xffc\xee.\xe0@\xd8\xd5\xd59\xd7\x8d\xd8O\xd8\xe9\xd6\x9f\xd4P\xd0\x0f\xca\xf8\xc4\xd5\xc6P\xd4w\xeaz\x01\xfa\x10\xe6\x16l\x16\xbc\x12\xac\x0e3\x0b\x8a\n/\x0f\x8e\x17\t [#\xce\x1e\x02\x153\t\x10\xfdZ\xf1\xc2\xe5\xdb\xdbO\xd6\xf6\xd5\x02\xd9\'\xdck\xdd#\xdd\x92\xdb\r\xd94\xd6e\xd57\xd9\x0e\xe2\xa9\xee&\xfb-\x05w\x0c\xe2\x11\xe1\x15\x06\x18\x9c\x17\x0c\x15\xe1\x11F\x10]\x11\x10\x14]\x16\xf1\x15\x0e\x12$\n\xed\xfe\xc4\xf2\xda\xe8\x8f\xe4_\xe6^\xec\xb9\xf2\xf9\xf5\xaa\xf4\x95\xf0.\xec_\xea\x0b\xedD\xf4\t\xff}\t\x14\x11-\x14-\x13\x17\x11\x12\x0f\xad\x0eL\x0f\xe3\x0e\xab\r\x0f\n\xcc\x04\x00\xffx\xf8\x8b\xf3k\xef\xec\xeb"\xe8\xc6\xe28\xde#\xdb{\xda\xab\xdc\xbd\xde\xb7\xe2.\xe5\xf9\xe3\xd5\xdf.\xdaN\xde\xe9\xf0\x0f\x11&7\x01U\\dgd|Y\x00M\x01D:C\xbeLFZ/e\x7fdfT\xdd8Q\x19\r\xff\xba\xed\x84\xe3\xe1\xddT\xda\xde\xd8,\xd9\xc2\xd9\x9f\xd9g\xd8~\xd6\x01\xd5\x9a\xd3\xe6\xd2\x10\xd6\xad\xdf\x1a\xf1\x04\x06D\x17p\x1f\xfb\x1d}\x16\x0e\x0ek\x08\xcc\x06\x1a\t\x01\x0e$\x13p\x157\x12\x9c\x08\xac\xfa;\xec\x03\xe0\x83\xd7\xc1\xd2@\xd1\xca\xd2\x9e\xd6\x07\xdb\xbb\xdd\xc8\xdd!\xdc{\xda\xdb\xda\xed\xdd\xff\xe3\x0c\xed\xfe\xf7A\x03\x82\x0c\x80\x12\x95\x15P\x173\x19S\x1b\x12\x1c\xfb\x1a\xa3\x18&\x16Q\x14J\x12\xeb\x0e\x9f\t\xf5\x02?\xfb\xcb\xf2\xc5\xea7\xe5*\xe4,\xe7L\xec\x89\xf0P\xf2\xc2\xf1\xbd\xf0\x0b\xf1:\xf3(\xf8\xb3\xff\xd5\x08]\x11\xf9\x16[\x18\x11\x16\xea\x11P\x0e\xf5\x0b\x86\n\xf1\x08\xe6\x05\xec\x01P\xfc\xfa\xf5^\xef\x04\xe9\x14\xe5\x91\xe2J\xe0\xbb\xddC\xda\x1d\xd9=\xd9\x9b\xda\xed\xdb\xbb\xdcl\xdeB\xde\x8e\xdc\xb6\xdc\xb7\xe5+\xfd\xb7\x1fSBSZ\xf5b\xb4`\x1dY\x02R\xf1NIQ\xa6Y\xa5b\x1bfb^NK:1d\x17\xb5\x02\xfc\xf3\x17\xea\xce\xe1\xfa\xdad\xd6O\xd4\xd3\xd4\x19\xd6H\xd7\xc6\xd7\x02\xd7}\xd5\x9d\xd4\x86\xd7\xc2\xe0o\xf0\xb5\x02\xda\x11l\x19\xfe\x18\x86\x13\x8b\rm\n\x9b\ne\x0c \x0eM\x0eC\x0cU\x07b\xff\x85\xf5\xcb\xeb\x80\xe3_\xdd\x1b\xd9&\xd6\xcf\xd4J\xd5\xa5\xd7O\xda\x8e\xdc\xea\xdd\x19\xdf\xc0\xe0\x08\xe3\xf4\xe6\xae\xec~\xf4\xb4\xfd\xce\x06\xb9\x0e\x13\x15\xf8\x19&\x1eM!0"\xa0 -\x1d\xb2\x19\xa2\x17h\x16r\x14;\x10\x89\t\xb8\x00\x80\xf6{\xec\xf5\xe4\x9b\xe2+\xe5\x8a\xea\xef\xefb\xf2I\xf2\xd1\xf1\x7f\xf2\x9c\xf5\xd8\xfa\xb7\x01\xe9\t\xaf\x10[\x15\xd0\x166\x15[\x13/\x11\xc3\x0f\xf9\r"\n\xb0\x05\xd1\xff$\xfa\xd8\xf4\x92\xeec\xe8+\xe2k\xdd\x12\xda2\xd7t\xd5\x18\xd4\xcb\xd3\x14\xd5?\xd7\x7f\xdc\xe1\xe2\xd9\xe8\xa1\xed\xfa\xef\xce\xf23\xf6#\xfa\x80\x01\xb5\x0f\x95\'SF\xc2`\xe7o\xaep\x1eh\x8b^\x80V2S\xe7R?SqR\xcbK`>\xb9*\xf3\x12!\xfc*\xe8R\xd9\xab\xcf\xfc\xc9\xab\xc8"\xcbV\xd0A\xd6\x03\xda\xb3\xda\x1b\xdav\xda\xf0\xdeJ\xe7\x19\xf2p\xfd\xed\x07p\x10l\x16\x16\x19n\x18\x91\x15\xf2\x11~\x0e\xca\t\xbc\x02\xee\xf9\xc8\xf2\xc6\xef\xf3\xef\xdb\xefX\xec\xb5\xe5B\xde\xf8\xd7\xad\xd38\xd1S\xd1\xa0\xd4\xd7\xda\xe2\xe1\x86\xe7%\xeb\x1d\xee\xa1\xf1\xa8\xf54\xfa\xfe\xfe\xe2\x04\x9e\x0c\x0b\x16Z\x1f\x11&N(Y&?!\xfc\x19\x87\x11\x02\tf\x02\x1e\xff\x13\xfe\xd0\xfc\xf7\xf8\x0f\xf3\xb6\xec\x7f\xe7\x99\xe4\x8d\xe4\x00\xe8:\xee\xb4\xf6\xe9\xff"\x07\x96\x0b\xa6\ro\x0e\xfb\x0e\xc2\x0f\xf4\x10}\x12\xb5\x13\xa1\x14\xff\x13.\x11\xa4\x0b\xc3\x03\xaa\xfb\xb3\xf3R\xed\xfa\xe8\xf1\xe5\xec\xe4\xf4\xe3\xe2\xe13\xde\xca\xd8\xcd\xd4\xb9\xd2\xe3\xd3\x19\xd8n\xdd(\xe4\xce\xea\xbb\xf0\xaf\xf5\x94\xf8%\xfb\xeb\xfdX\x01\x03\x06G\t\x03\n\xa6\x06\x90\x01N\x01g\x0c\xda#uA\xf1Y\xe9e\x03d\xffXNL5C9@(BvF\x87H\xe8C\xc76\xbd#{\x10\x87\x00\x85\xf2\xad\xe3e\xd45\xc9e\xc6\xb1\xcc\xa9\xd7\xa9\xe1W\xe7\xa4\xe7a\xe4(\xdfa\xdb\xb8\xdc\xbb\xe5<\xf5\xec\x05b\x11%\x15r\x13\x81\x10v\x0e\x9d\x0bj\x06\x8a\xffz\xf9V\xf6\x17\xf6u\xf7O\xf9e\xf97\xf6\xf2\xeex\xe4\x95\xda\x9b\xd4\x1f\xd5\x9e\xdb#\xe5\x07\xee\xa6\xf3\xde\xf4\xce\xf2\xe4\xef\x8c\xee\x1b\xf0\xd7\xf3\xd7\xf8\xe8\xfdv\x03\xd3\t\x1b\x10h\x15\xde\x17.\x17\xc4\x13T\x0e\xfb\x08\x06\x05\n\x04\x9b\x05B\x07\xcc\x07Q\x05[\x00\xb0\xfa$\xf5\xd4\xf1\xd3\xefY\xf0f\xf3c\xf7\xb1\xfc_\x01\x89\x05\x03\t\'\x0b\x9f\x0c\xcf\x0cD\x0cH\x0c\x9f\x0c-\r\xc5\x0c?\n\t\x06\xcb\x00\x1c\xfb\xd9\xf5\xad\xf0\xe5\xeb\x1a\xe8\x81\xe5\x11\xe41\xe3\xa0\xe2\x93\xe21\xe3\x9b\xe4\x16\xe6\xeb\xe7\xb3\xea&\xee/\xf3\xc5\xf7Z\xfb\t\xfe\xac\xff(\x01X\x02\xd7\x03\xdf\x06j\n\x08\r\xf9\r\x84\x0c\x18\nj\x05\xbd\xfe\xc5\xf8a\xfa\xea\x08?$;B\x85V7Z\xb8NF?\xa64y2\xea5\xba9|;X9S2\x05\'\xdb\x16{\x05\r\xf4a\xe3\xe9\xd4t\xc9?\xc6\xe9\xcb=\xd8\x0f\xe5@\xeb-\xe99\xe2R\xdb`\xdae\xe0\xf5\xeb\xa4\xfaD\t\x89\x15\x9c\x1d\xb7 \x06\x1f\xa3\x1a\xbb\x14.\x0e4\x07\xe2\x003\xfd\xd3\xfc\xb8\xfek\xff\xa6\xfbL\xf2\x1d\xe6\x0c\xdb\xce\xd3\x19\xd1\x8b\xd2\x0f\xd7|\xdd\xae\xe3i\xe8\x05\xebu\xec\x88\xedx\xee*\xef\xc9\xef%\xf2~\xf7>\x00\'\n\xee\x11/\x15+\x14\xd0\x10\xaa\r\xf4\x0b\xe3\x0b\x10\rD\x0ey\x0e\xd1\x0c\xa7\t\x17\x06\x9d\x02\\\xffL\xfb\xa3\xf6\xd2\xf2F\xf1\xc7\xf2\xa7\xf6s\xfb$\x00v\x03\xcd\x04\x0b\x05\x01\x05\xe8\x05=\x08\x9f\n\xa3\x0c}\r\xcc\x0c\xc1\n\x06\x07\x15\x02J\xfc\xfc\xf6\xa9\xf2\x89\xef\xe6\xed\x8e\xec\xa9\xeb7\xeb\x10\xeb)\xeb"\xeb\xe9\xea\xfd\xeb,\xee\xff\xf0\xff\xf3\x96\xf6,\xf9\xfb\xfbd\xfd*\xfeD\xff\x14\x01\x1a\x04\x06\x06\x83\x06\xbc\x05L\x03\xa3\x00\x16\xfeP\xfbR\xfa\xee\xf6b\xf0\xb5\xea\x9d\xe9\xfc\xf48\n\x0f ?0<5\xfc2a0\x151o5\xf79X=\xf9=\xa3=\x15;\x8a4o,Q!\x9a\x13\x10\x04%\xf4%\xea\xb1\xe8z\xed_\xf4\xe0\xf6\xfd\xf3\x0b\xee\x11\xe8T\xe5!\xe6H\xe9\xc7\xed`\xf2\xb5\xf7D\xfeb\x05\xd2\x0b\xa0\x0e\x16\r\xa8\x08\x05\x04\xb5\x01\x1c\x02\xcc\x03\xd1\x04\xbe\x03\xe5\xffi\xfat\xf4\xe4\xee\xef\xe9Z\xe4\x1e\xde6\xd9^\xd7<\xd9D\xdd\xc8\xe0;\xe2\xf5\xe1q\xe1\xbe\xe2>\xe6t\xeb\xa3\xf1\xb8\xf7\xcd\xfc\xbf\x00\xbc\x03\xe6\x06\xb9\n\xd0\rb\x0f\xf8\x0eq\x0eP\x0f_\x11<\x13\xdf\x12\x12\x10+\x0c\x0e\x08\xcb\x04Y\x01_\xfeA\xfc\x9a\xfa&\xfa\x12\xfa\x01\xfb3\xfd`\xff&\x01\xf1\x01i\x02\x13\x04\xb8\x06\xb1\t5\x0cH\rc\ry\x0c[\nE\x07\x7f\x03\xb5\xff\x8e\xfc\xe7\xf9\xd2\xf7\xc6\xf6\xc4\xf5\x91\xf4\x98\xf2\xc7\xef\xa1\xed\xfd\xebq\xeb\xbe\xeb\x1f\xec,\xed\x83\xef,\xf2\xe1\xf4N\xf6w\xf6\xa3\xf6W\xf7\xae\xf8A\xfa\xbc\xfb`\xfc\xf6\xfb\x13\xfa\xd8\xf6\xfe\xf3\xcf\xf31\xf6u\xfa8\xfe\x8e\xff\xc0\xff\xdf\xfeT\xfeI\xfe\x1b\xfe\xe5\xfd!\xfd2\xfd\x84\x00\xc4\t%\x18\x07(!4O:\x9e;\xf4:\xbc;\xdc=\xe0@rC\xb7DeD\xe9@\x84:\n1s%\x1f\x19\xfc\x0b(\x01\xb7\xf9\xaa\xf5G\xf4\xab\xf1t\xed\xf5\xe72\xe2\xa3\xdd\x1d\xdb\xd4\xda)\xdc\xae\xdeo\xe2\x0b\xe7e\xec\xe1\xf1>\xf6\xaa\xf9\x87\xfbk\xfcY\xfd\x8d\xfe\xd6\xff(\x01\xfb\x00l\xff\xb3\xfc-\xf9\x82\xf5\xa8\xf1\x8c\xed\x08\xea\xac\xe7U\xe6\xa5\xe5\x8d\xe4\x0f\xe3\\\xe1^\xe0z\xe0\xc5\xe1\xb7\xe3\xd9\xe5P\xe8t\xebJ\xef>\xf4U\xfaP\x01\xdc\x08e\x0f\xed\x14\xe1\x18\x87\x1b\xc8\x1c\x01\x1c\xd6\x19\x9c\x16\x80\x13p\x10U\r$\x0b\x87\x08i\x05\xb4\x01\x95\xfd\x8f\xfa\x07\xf9\xbe\xf8W\xf9\x93\xfa\xc7\xfb\xd3\xfc\xba\xfd\x01\xfe;\xfe=\xfe\xa6\xfdo\xfd|\xfdD\xfe\xf8\xff\x00\x01\xa1\x01\xfe\x00\xcd\xff\xcd\xfd\xef\xfb.\xfb\xfd\xf9\x97\xf8\x07\xf7s\xf5z\xf4\xb7\xf3a\xf2\x86\xf1\xb3\xf19\xf0\x8e\xed\xcd\xec\x01\xee"\xf1k\xf46\xf6\xde\xf7-\xfal\xfc\xf9\xfd!\x00#\x02\xb9\x02\x0f\x03^\x057\x08\x18\x0c\xb5\x10\xbd\x12\xff\x12\xbd\x11:\x0f\xfb\r\x86\r\x07\r\x85\rE\r\xcd\x0b\x15\n;\x08k\x06\x9c\x05<\x05\xcd\x04\x15\x04\xdd\x02g\x02K\x03\xf4\x05\xab\t\xdc\r\xff\x11I\x16r\x19\x1f\x1b\x0c\x1c\x81\x1c\xe6\x1c\xba\x1d5\x1f\xb6 \xdf!\xd6\x1f\x05\x1cS\x17:\x11\xc0\x0cc\t\xcd\x06\x02\x05o\x02\x90\xfe\xb9\xf9w\xf5\x98\xf1\xa0\xedO\xea\x0f\xe8\x06\xe7\xb7\xe6V\xe6Q\xe5\xab\xe4\x9f\xe4\xd7\xe4l\xe5V\xe6_\xe7\x89\xe9K\xec\xc4\xeeh\xf1(\xf3\x94\xf4;\xf55\xf5\x03\xf6\x95\xf7\xdd\xf8\x96\xf9\x90\xfa\x0f\xfb\xe0\xfb\xfe\xfcq\xfd\xf0\xfd\x06\xfe\xca\xfd\xad\xfdH\xfe\x81\xff\x99\x00\x16\x01\xdb\x00.\x00\x1e\x00Y\x01\xb0\x01|\x03@\x05\xec\x04C\x05{\x06\xec\x05]\x06\x19\x06\xda\x04\n\x05\x82\x05\xbb\x05>\x04\x92\x02\x8e\x02\r\x02\xfa\xff\xbb\xfep\xfd)\xfc\x0f\xfcR\xfb\xb9\xf8&\xf8\xdd\xfa*\xfb$\xf9\xf3\xf8,\xfa\x15\xfd\x00\xfe4\xfe\xe0\xfb\x8c\xfa/\xfc\xd5\xffA\x01Q\xffx\xfdp\xfd\x1c\xfe\x92\xfd\x93\xfeh\xfc\x92\xfa\xa8\xfb\xa2\xf9!\xfa\xaf\xfcQ\xfbN\xfd\x8d\xfe\x10\xfc\x0f\xfd)\xfd\x81\xfc\x12\xff[\x02\xc0\x04\xc2\x04G\x045\x05\xab\x07\xd6\x07\xa6\x06\xc3\x07\xc3\x07\x96\t\xb5\t\xcc\nH\x0b\xf4\x0b\xd7\r\'\r\t\x10\xca\x0c:\n\xbe\x0b\x0c\n\x0f\n\x8c\nM\x08\xee\x07\xf7\t\xc3\x07\x08\t\x7f\x07#\x02C\x02\x85\x02\xfa\x03!\x059\x08t\x04\x16\x01j\xfe\xac\xfe\x87\x02\xa9\x01\'\xff\xa7\xfci\xfb\xe7\xfb`\xff_\xfb>\xfc\x8b\xfb;\xfaa\xf9G\xf51\xf8\x14\xfa\x89\xfb\x13\xfa\xf7\xf8\xc1\xf9\xf1\xf9\x8d\xf9(\xfaS\xfa\xb6\xfa8\xfap\xfb\x96\xfd\x94\xfe\xed\xff5\xfe\xa8\xfc\xfc\xfc[\xfe;\x00\xe2\x00\x05\x01)\x02w\x01\xb9\x00\xbf\xff\x8b\x00\x1b\x01D\x02\x1b\x02Z\x04\xa2\x03\xe2\x02V\x04\xf7\x03*\x05C\x07\xe1\x04\x13\x01o\x06D\x07\x93\x01\xd9\x01l\x05\xbc\x03\x87\xfd\xe9\x00p\xfe\xd4\xfaV\xfe?\xfe\xe3\xfb\xb8\xfa3\xfd\xf5\xf75\xf6\xef\xf5\xb7\xf7\x8e\xf6\xfd\xf5x\xfb2\xf6(\xf3\x94\xf6B\xf64\xf5~\xf55\xf9\xc6\xf7B\xf6W\xfb\xfb\xf9\xc1\xfaZ\xfb\'\xfa\xda\xf7\xd2\xf7:\xf72\xfat\xfb\xa6\xfa\x0f\xf9\x1a\xfd\xb3\xfaI\xfa/\x00\xf4\xfd\xda\xff\xe9\xfd\xb5\x01V\x04z\x06\xdd\x05\x16\x06\xc9\x05\xfa\x07\x85\x0b\x1f\nq\x08\xd7\t\xf7\r\x9f\x0cL\n\x83\t\xe7\n&\x0c\xba\x06}\x04>\n\x8c\n\x15\x07\xc0\x068\x08&\x04\x06\x03\x95\x05\x1a\x05\xee\x04\xdc\x02\x84\x01\x06\x02\xec\x06\x0c\x05\x99\x02Q\x00\xd4\x00>\x000\xff\xed\xfe+\xfa%\xfd\x1d\xfd\xeb\xfb\xb7\xfb\x97\xfc\xb4\xf9$\xfdU\xfb\x08\xfay\xfb\x01\xf8\x16\xfbr\xf9\x18\xf8\xb1\xf7\x94\xfb\x81\xfc\x1d\xf5I\xf9\xb1\xfau\xfet\xfb\x9b\xf8\xcb\xff\xaf\xfd\x92\xfeU\xffh\x00\xca\x01\xec\x02~\x00\xc7\x02t\x06\xa9\x03\x1d\x03t\x04\x9d\x02H\x02%\x06\xcb\x0c.\x06\x99\x04F\x02\x1e\xff!\x08\x91\x0cY\x06\x06\x04A\x07\xd3\x03\x15\x06~\x03N\n\xf7\x02\x83\x03&\x08\x9d\x06\xae\x03R\xfd\xfa\x06W\x00\xdb\xfd\xdd\xfe\xb2\x00\xcf\xfd\x06\xfdT\xfe\xd0\xf8+\xfa\x15\xfb\xa7\xf6\x0c\xf7\xeb\xf8\xd8\xf7\xf5\xf5@\xf7\x12\xf9\x0f\xf6%\xf9\xa0\xf5\xf7\xf8b\xfbd\xf8\xe5\xfc)\x00f\xfd\x9f\xfd6\x00v\xfc\x92\x01\xc0\x03\xf3\xfd\xc1\xff!\xfe\xeb\xfdz\x02\x91\x02\xca\xfe\xf1\xfe\x01\x00\xe8\xff6\x06\xa4\x06\x9e\x02~\x01\xbb\x06W\x084\x06\xb2\x03\xee\x04\xb3\x065\x04q\x07\xc3\x046\x03q\tK\x05\xe3\x01\xce\x01\xb9\x00\xc2\x03\x9c\x03g\xff\x90\xffp\xff\xba\x02M\x01\x0c\x01\xbe\x02\xcb\xfeE\xfd\x8d\x00N\x00T\xfc\xe6\xfd\xc7\xfd\x14\xfe\xf8\xff~\xfe\x8e\xfet\xfdP\xfeX\xfa\x13\xf9\xa6\xfdZ\xfeB\xfe\xc2\xf87\xfb`\xfaE\x00\x85\xf8\xc2\xf6]\xfb\xdd\xf8\x14\xfc\xba\xf9\xec\xfa\xa0\xfd\xea\xfa|\xf9\x99\xff\xc9\xfc`\xfc\xa1\xf3\x19\xfd\t\x00\xac\x03\x85\x04.\xff\xbf\xfft\xfe\xe9\x06q\x00\xbc\x030\x06$\x06\xa9\x04\x9c\x07\xb6\tg\x05.\x05N\x03\xd8\x00\x92\x05\xa6\x04\xa5\x02\x87\x07\x80\x04\xcf\x02\xf6\xfe`\x04\xcd\x05\x0f\x00\xfc\x03\xc3\x00\xeb\xff\x8b\x04\xb1\x08&\x00\x03\xfdX\xfd#\xfe\xaa\x01\xe1\xfd\x19\x03\xc6\x00\xe4\xfc\xda\xfb\xdf\xf2\xf8\xf8=\xfd:\xfa\x96\xfdt\xf9 \xf9!\xf8p\xf8)\xf8\xa7\xf8\xa8\xf8\xf5\xf9`\xfc\x94\xfd\xd6\xfb\xd5\xfb\x81\xfc\x96\xfe\x8f\x02z\x01\xf0\x00*\x00\x12\x05\x89\xfa@\x02#\x02\x12\x01\xb8\x02\x97\x02O\x02}\xff\xcd\x01\x82\xf8>\x038\x02v\x03H\xffQ\x00\x8a\x04\x99\x04A\x02#\x01m\x042\x03s\x03\xeb\x03\x1e\x08\t\x05\xed\x02_\x01S\x06\x0c\x07\x9f\x04\x7f\x07F\x04\xf9\xff.\x03H\x06\x85\x05J\x06\xc2\x01\x16\xfe\xac\xfe4\x01\xb2\x01\x10\x00\xf1\xff8\xff\xb2\xfe\xee\xfd\x83\xfbO\xfb\'\xfb:\xfd\x83\xfc\x8b\xf9\x1d\xfbN\xffe\xfc\x13\xf9\x96\xfc<\xfb\xb3\xf9_\xf9\x82\xf9I\xfcH\x014\xfbm\xfa\xad\xfa\xcf\xfd\x9a\xfeK\xfb\xa4\xfe\x82\xfd.\xfe \xfb7\x00\x1c\x02\x00\x02\xd4\xfeE\xff\x91\x03`\xffr\x03R\x02/\x03\x1e\x06\xa4\x02\xb0\x05\x04\x08\xf5\x05\x0f\x005\x05\x9e\x08\x91\x05w\x06i\x05\x03\x03"\x07%\t\xf6\x05R\x04(\x02v\x00\x07\x02*\x03\xb9\x02\xf6\x01\x14\xfd\xf2\x00+\x00\\\xffT\xff\xd0\xfa\xd7\xf9!\xfc\xea\xfd\xa7\xfe\x93\xfc\xa4\xf9L\xfd\x88\x00P\xfa1\xfc\xf2\x02a\xf9|\xfa\xba\xfb\x95\xfa\xd8\xfdI\xfe\xf0\xfd\xbd\xfa5\xfaj\xfe\xe0\xfe\n\xfb\xfa\xf9\x9d\xfb\xbe\xfd\x9c\xff:\x02r\xfcd\xfc\xd7\x00\x16\xff\xc8\xff@\x01\xf6\xfe\t\x01\xdb\x01\x0c\x03\xde\x02?\x00\xc2\x04v\xfe(\x02e\x05\xdb\x01\xf7\xfe\x92\x03\x98\x030\x02U\x05\x01\x04\xa7\x02u\x02\xfc\x01\xca\x03\xd9\x07\xa6\x01\x82\x03\xce\x02\x05\x036\xff.\x04W\x02R\x01\n\x05M\x01Q\x03\xeb\xfeW\x03\xb9\x00\xbf\x01\x9c\x00u\xfdW\xff\xa5\x03a\x00\xab\xfd\xf5\xfb\xa1\xfb\xc6\xfdl\xfc\xf0\xfb\x82\xfcG\x00X\xfb\xae\xfaF\xfa\xa9\xfb\'\xfa!\xfb|\xfc\xe7\xfa\xe0\xfa\xc3\xfca\xfd\xb1\xfbE\xfb\xb1\xf9\x88\xfd\x00\xfe\xa4\xfd\xbf\x00\xaa\xff\xf1\xfd\x82\xff\x12\x01\xe6\xfd\xd9\x01`\x04`\xfe\x89\xffg\x01D\x04=\x02!\x05E\x05\xfa\x02Q\xff\x7f\xffN\x06\xc4\x07\xc7\x08W\x012\x04\xad\x00\xf1\x03\x11\x04H\x03\xc2\x02\x85\x02\x06\x03\xae\xfe\x00\x07\xb3\x00\x1e\xfc\xa9\xfd\x12\x01r\x02G\x02\x91\xfe\xf8\xfc\xa4\xfd\xea\xfc\xf1\x01\x95\xff\xf6\xfc\x17\xfd\x8d\xfcd\xff\xdc\xfcC\xfdS\xfc\xe3\xfc\xf2\xfb\x97\xfc\xb8\xfcG\xfe~\xfe\x16\xfd\xe4\x00s\xf8\xb2\xfab\xff\x16\xffE\x00c\x00!\xffR\xfa\xc4\xfb\xd3\x00_\xfe/\xffT\xfe.\xfd\x93\xffW\x00\xac\xfb2\xfd\x94\x01\x84\xfc\xab\x01}\x01\x9a\x01\xe4\xff>\x01\x8b\xff.\x03`\x03\xeb\x00\x8f\x03\xfb\x04^\x046\x03\x13\x05\xcf\x02\xd2\x03\x04\x02\xdf\x06U\x03\x93\x04\xa2\x01a\x01\xb5\x04\xc4\x04{\x02\xe8\x02#\x03\xd6\xfd2\x00 \x04I\x040\xff0\x02\xf2\xff\xf6\xfb\xec\xff\x1a\xff\x97\x01\xcd\x03\xd6\xfb\x00\xf91\xfeY\x01:\xfe"\xfb\xf9\xfaJ\xfdH\xfd[\xffo\xfc\xd6\xf9\xae\xfa\xa5\xfbq\xfe~\xfc\xcc\xfc\xa7\xfb\x9e\xfc\xca\xfe\x85\x03\x81\xfe0\xfb\x11\x00\x8a\x01\xc1\xff\xaf\xff\x9b\xfe\x8e\x02\xa0\xff\xf2\xff\xb7\x03\xc5\xff\xea\x00\xdf\x01\xa6\x00\xed\xfc\xad\x02\xdd\xfe\xb7\x04x\x04\xa6\x00\xe7\x00W\xffv\x03*\x05Y\x06\xcc\xff#\x01-\x04<\x01\xda\x00G\x02r\x06\xca\x019\xffa\x00R\x01\xb3\x04m\x008\x00\xba\xff\xd5\x01\xd7\x00m\xfe\xef\x00\x9a\x02\xc2\xff\x8f\xffv\xff\xcb\xfdK\xfd\xb0\xfe\xac\x01\xd7\xff\xaf\xff\xd2\xfa\xa2\xfd\xe1\xfd5\xff,\xfc\xc6\xfd\x16\xfe\xf0\xfe\xa4\x004\xff\x0c\xffw\xfa,\x01\x7f\xfc\x9b\xfc&\xff*\x00\x9d\xfe/\xff\x9d\xfeQ\xfd\x8d\xfe\x9d\xff\xb1\xfc\xfc\xfc\xd2\x00\xb8\x00\xae\x02\xb8\xfe\xea\xfe\xba\x01\xb9\x02\x8a\xff\x1b\xff\x93\xff\r\x04\xa4\x02P\x02\xb0\x01\xc1\x00\xe9\x00\xdc\xff\xbe\x04\xde\x021\x01%\x01\xaf\x01\x97\x01(\x03\xe8\x02\xa2\x00\xab\x00\xa7\xff\xe7\xfdB\x02&\x00\xa1\x00\x05\x02m\xff\xfd\x01\x9f\x00\xfb\xfe\x00\x01\xe3\xfe\n\xfe4\x00\xc4\x01\xfa\x01\xd7\xff\xd7\xfe4\xff,\xfe\xae\xfc|\xfe\xe6\xfe\xa1\xffO\xfee\xfd|\xfe9\xfc\xe5\xfdV\xffT\xfeP\xfcL\xfc\xce\xfeX\xfd\x85\xfeW\xffE\xff\x14\x01\xe6\xfc8\x00\xd0\xfd\x15\xff\xdd\x04\x01\x00\xd8\x00\xa9\xfe7\xff_\x00\xec\x02\x97\x00\xab\xfe\x18\x04j\x00\xa5\xfd.\x01\x8f\x01\xf8\xff\xba\xfd\xda\x00>\x02\x18\x00\xa7\x02\xba\x01\xb3\xff{\xffj\xffS\x007\x00i\x01F\x03\r\x00\xdc\xfd\x17\xff\x1c\xff\x9a\x01W\x02\xa0\xfe/\x01\x0c\x01+\xfd\xd2\xff\x14\x00\xc3\xfe\xc1\xfft\x02\x10\x05\xd6\xfe\x98\xff=\xfe\x80\xfc!\x01\x99\x00\xf2\xff\xb0\x01>\x01-\x00\xb1\xfe\xe1\xfc\xb8\x01y\xfe\x80\xfd\xc8\xfe0\xff\xea\x00x\xfff\xff\xa8\x00~\xfe\x1e\xfd\x84\xfc\xe8\xfd\xd0\x01\xa7\xff\xd6\xfe`\xff\xb2\xfd\xfe\xff*\x00\x00\x00\x1f\x00`\x00\xb2\xff2\x01\xc4\x01\x85\x00A\x01]\x00\xac\x01\x9f\xffA\x02\xb1\x00\xcc\x01"\x03\x86\x00\xbe\xff\xc0\x01$\x01Z\xff:\xff+\x03$\x01\xb6\x03\t\x02\xa0\xfc\xb0\x01s\xfe\x11\x03<\x00\x1f\xffS\xff!\x01\x02\x03\xba\xffg\xfe\xb4\xfe\xa9\xff\xae\xfdN\xfd\xb7\xffx\x03G\xff\xc8\xfe\xf7\xff$\xffY\xff\x1b\xfe\xc0\x00\x9e\xfe\x7f\x00i\x00d\xff\xf6\x00\xd2\xfd\x86\x00\xd3\xfd\xb8\x00\x0f\xff\xdf\xfe\xc5\xfdf\x03N\x00\xdb\xf9\x06\x03\x19\xfe\xb2\xff\x17\x04l\xfdc\xfd\x98\x03w\x00\x04\x00\xdb\x01q\xffZ\x01\xcc\xff\x8a\x01\xa4\t\x94\xfe\xb6\xfb\r\x00<\x05\xab\x02\xbb\x01H\x04\x0e\xfdP\xfc<\xfd\xa7\xff\xa3\x02\xc1\xfe>\xfd\xfc\xfe\x8c\xfb\xaf\x01r\x03\xef\x018\xfcv\x00\x80\xfc\xd0\xff\xfe\x06\xcf\xff\x19\x01\xc7\xff4\x03n\x025\x00=\xfc5\xfd\xd2\xfb\x1a\x015\x00\x14\xfee\xfe\x10\xff\xb9\x01\x0c\x01M\x01O\xfd\xc0\xfb%\xfd\x9a\xfe5\xfe\xd9\xfe\xf7\xff\x0f\x00V\xff\x85\x00\xb3\xfb\x1e\xfd^\x02e\x02"\x00X\xfaC\xffb\x02s\x01\xca\x021\xffw\xff\xc3\x00B\x02\x01\x01_\x00\xe8\x01\xdf\xff\xb8\xffP\x03\x9f\x03\x9d\x02\xd9\xfc\x1d\xfd\xeb\x01\x1a\x05\x88\x00\x1b\x03N\x07$\xfc\x02\x01\x90\xfd\x97\xfd\x0e\x03N\x04\xc2\x038\xfeu\x00T\x05!\x02\xca\xfb-\xfe\xe5\xff!\xfd\x93\xfe\xf1\x035\x00\xe2\xfc\xcb\xfd\x0f\x017\xfd{\xfa\xb6\xfd\x18\xfe\xe7\xff\x82\xfe\x8b\xfe\xb2\xfc8\xfd\xd7\x00\xbb\x00\x96\xfe%\xfd\xd9\xfa\xe8\xfd?\x01\xbd\xff\xd6\x02\xcf\x00\xb0\xfb\x8f\xff\x8e\x00\xc7\xfd\xbe\xfe-\x00\x9d\xff{\x00~\x04[\x00\x03\xff\xdc\x023\x00\x00\x00-\x01{\x01\xf0\xff\t\xfe\x14\x04\xda\x04\xeb\x010\x01\xa3\xfdd\xfc\x15\xffJ\x01\xc3\xffP\x01[\x03\x91\x03\x1c\x02q\x01\xeb\xfc\xba\xf9\xa6\xfcI\xff\xbe\x01\xc6\xfe$\xff`\x05\xc3\x02\xfb\xff#\x03\xd2\xfb\xf3\xf9\x1b\x00\\\xfck\x01\xd6\x06\x8b\x029\xffS\x01b\xfd\x15\xfe6\x00\xd5\xfc\t\x00O\xffT\xfc\x8d\xfed\xff\xd7\xfct\x00a\xff\x94\x01C\x00\xcc\xf92\xfb\x9f\xfd\xf7\xfe\xa4\xff\xef\x00\xc7\xfd\xf4\xfe\x87\x01~\x01\x17\xfc\x08\xfd\x9b\xffX\xfd\xca\xfe\xcb\xff \x00h\x02\x7f\x04\xcd\x02!\x01i\xfcq\xfe.\x01\xf6\xfe\x16\x02 \x04\xb7\x02x\x05P\x05E\x03\xd9\x01y\x01\xea\xfe\xd5\x00x\x02\xf1\x01\xb9\x03\x1c\x01p\x01R\xff\xb5\x02\xea\x05r\x00(\xfeg\x01\x92\x02J\x00\x1e\xfc\xd9\xfce\xfc\x1f\xff\xe0\x01\x19\x02 \x03z\x012\xffM\xfc\xee\xfa\xce\xfb\xbe\xfd\x0f\xfe\xe9\x00\xd1\x01\xb1\x01\xec\x00\x0f\x01R\x00}\xfc\xca\xf9\x85\xfb\x95\x00\x9c\x00\xec\x00\xea\xfdf\xff1\x00\xec\x00\x04\x02\xe3\xfd"\xfe\xe7\xfez\x03\xe7\x03\x14\x01%\x00\xbf\xfcr\xfc\x99\xff\xe7\xffd\xfek\xff!\x02;\x02\xf6\xff\xb3\xff6\x005\x01\xc1\xfd\xdb\xfd\xec\xff\xe9\x01m\x02\xf3\x03\x96\x05\x0b\x03\\\xffS\xfd\x15\xff\xce\xfeM\xfe\xa3\x000\x03\xad\x01I\x02\n\x01|\x00\xf2\x01\x1b\xff\xc4\xff?\xff\xb0\x00\xe3\x00\x1d\xfed\xfe\x8d\x00\xf1\xffE\xfe\x8e\x00\xc4\xfd\xa6\xfd\x95\xff/\x01\xf8\x01u\xff\x07\xfeY\xfc:\xfd\xb6\xff\x87\x03i\x00r\xfc\xf6\xfb\xa6\xfd\xc4\x00m\x01\x81\xff\xfe\xfcB\xf9\xa2\xfb\x86\x01\x1f\x02:\x017\xff\xda\xfd\xc4\xfe\x03\x04c\x03]\xff\xd5\xffM\xff\xee\xfe\x1f\x017\x03-\x02\x8e\x00\xb3\xfe*\xfe\x03\xff\x9b\x00\x0f\x01<\x00\xb4\xfe\xce\xfe\\\xff\x02\x01;\x01S\x00\x9e\x01\x82\x01V\xffC\x00\x11\x021\x01\x16\x01\xff\xffW\xffU\xff\x81\x02B\x02\xe6\xfe\x9a\xfe3\xff\x86\xfes\xfeP\x00z\x00T\xffX\xff\xc0\x000\x00\x8e\x01\xa5\x01\xd7\xff=\xfec\xff\xd3\xffi\xffd\xff\xed\xfd>\xfe\xd3\xfe\n\xff~\xfeU\xfe(\xfe\xf1\xfe\xd6\xfey\xfe\xa6\xff\xa9\xff\r\x00^\x00\xed\x00P\x01q\x01_\x01\xdf\xff\xa5\xff\x83\xff5\x00\xbe\x00\xa7\x00L\x01\x1c\x01\r\x00(\xffh\x00A\x01\x7f\x01\x08\x01\xa9\xff\x8d\x00c\x01\x00\x01\x9e\x00\xf6\xff_\x008\x01P\x01\x11\x010\x01\xec\x00!\x00\x82\x00\x1c\x01V\x017\x01\x13\x01\xc1\x00\x9c\x00\xbc\x00\x03\x01\xa7\x006\x00O\x00\\\x00@\x00h\xff\xdd\xff\xf8\xff/\xffK\xfe\x15\xfe\xb9\xfe\x14\xffM\xff-\xff9\xfeC\xfe\x85\xff\xba\xffc\xffF\xff\xde\xfe)\xff\xa3\xff\xcf\xff5\x00\xcf\xff\xaa\xfe7\xfd\x1b\xfd\x02\xfd\x10\xfd\'\xfd\xf8\xfc\xde\xfd\x19\xfe\x11\xfeW\xfdN\xfd\xe2\xfe\xc3\xfe[\xfd\xb5\xfc!\xfdv\xfe\xbc\xfea\xfe*\xfe#\xfe\x17\xff\t\xff\xbd\xfe\xfe\xff\x05\x00m\xfe\xb2\xfd\xb7\xfdm\xfe\x9c\xfe\xdc\xfdO\xfd\xc2\xfd\xc8\xfe\xf8\xfe\xbb\xff\x80\x00\xfe\xff\x1b\x009\x00C\x00\xac\xff\xcc\xfe\xfb\xfc6\xfa\xcc\xf8\x95\xf7\x05\xf7\xb3\xf9M\x01`\x0c;\x17Y"\x13,\x8c2u5\xb73\x080\x06*|"Z\x1a\xf8\x11\x94\t\x9b\x01p\xfb\n\xf7\x1f\xf3H\xf0\x0c\xed\xdb\xe9\xf7\xe7\xeb\xe5\xf4\xe56\xe6\xcc\xe6\xc0\xe7W\xe9\xa0\xed\x15\xf3\xaf\xf8J\xfe\xff\x02\xd5\x05;\x08\xb8\t\xd4\tM\x08\x17\x05\x12\x00q\xfb\x96\xf7\xcf\xf4c\xf3|\xf18\xf0\x1d\xf0V\xf0\xa4\xf1\xad\xf2x\xf3U\xf4\xc0\xf4\x87\xf5W\xf6\xa8\xf7&\xf8\x9c\xf8\xb9\xf9\x0b\xfb!\xfd3\xfe\r\xff\xe6\x00\xa1\x02\x9c\x05\xa6\x07\xc0\x08\xfe\t\x87\t\x1c\t\x96\x08e\x08\xd1\n%\x0fQ\x12Z\x15t\x16\x9c\x16\xd8\x15\x0f\x12\xe7\x0bx\x03\xa1\xfb\x9e\xf4&\xef\x86\xea\xb4\xe7\x02\xe6f\xe5&\xe8\xa5\xec\xa2\xf1\xa1\xf6\xe3\xfaT\xfe\xa6\x01\xa4\x04d\x06\xc4\x06\xc9\x05\x9e\x03<\x01H\xfe\x97\xfc\x81\xfa\xcc\xf7\x15\xf6O\xf4\xff\xf2d\xf3\xa2\xf3\xef\xf2\xb3\xf3\xb9\xf4r\xf7\xa5\xfa\x89\xfdi\xff\xec\xff\x15\xffO\xfd\x9c\xfb\x1f\xfa4\xf9%\xf8\xe9\xf7\x9e\xf7\x81\xf8J\xfb\xa5\xff@\x04U\x08m\t\x81\t\xf3\t\x18\n\x9d\t\x18\x05x\x01j\xffW\xfc:\xf93\xf6\x00\xf6n\xf7Q\xfa=\x05V!\x8bJ"i\x0bp;gHd\x0fk\x88gOR\xe40A\x12\xc9\xfa\xda\xe6/\xd7 \xce\xb0\xcci\xcco\xc7\xb4\xc3\x07\xcc\xa7\xdd\x8c\xe9\xd2\xe5\xd9\xdcT\xde\xa7\xecc\xfb\xb1\x00\xe5\xff]\x02\x80\x0b\x04\x15\xbf\x1b\x07\x1e\xfe\x1b\xea\x14;\x07\x85\xf8\xaa\xf0\x88\xeb\x1c\xe4\xa5\xd67\xcb\xef\xc9C\xd0\x9e\xd9\x1c\xdfQ\xe0\x85\xe2\xce\xe7V\xee\x98\xf3\x1a\xf9\xd1\xfe\xdc\x00\xaa\x00\xcc\x02S\nt\x13\x94\x18\xb2\x17>\x13\xa7\x0f\x07\x0e\xb9\n|\x03\xb4\xf9\x8b\xef\x07\xe8\x15\xe5\xa0\xe7J\xed\xe5\xf2\xb7\xf7\xbb\xfdn\x06T\x10E\x18\x06\x1b\x8c\x18\xc1\x13\xa9\x0f\x0e\r\xfb\nx\x07w\x02\x92\xfe\x96\xfd1\x00q\x04d\x07@\x08=\x07\xf7\x054\x06\xc0\x05\xa9\x04X\x01a\xfdy\xfb\x19\xfc\x00\x00\xf4\x03{\x06\xd8\x07\x94\x08-\x0b\xe7\r\x1b\x0e\xcd\n\xe0\x03\x85\xfd\x92\xf8\xac\xf4\xf8\xf1K\xef%\xee\x9c\xed_\xeel\xf1\xa6\xf6\xd6\xfb\xd5\xfe\x98\x00\xd3\x022\x06\x91\t\xed\ni\n5\t\xb3\x08\xcf\x08\xb9\x07\x08\x05\x1a\x01o\xfc\x96\xf7\xb4\xf3 \xf1\x0e\xef \xee\xb3\xedg\xeeZ\xf13\xf6\xb4\xfb\x95\xff(\x02(\x04\x84\x05\x85\x060\x06\x9b\x04\x12\x02\xe9\xffI\xfe\'\xfd\x94\xfc\x14\xfcc\xfb\x0e\xfa\x00\xf9#\xf9M\xfa\xbb\xfan\xfa\x04\xfaX\xfa\x8e\xfb\xc5\xfc\x99\xfd\xec\xfd\x06\xff\xa6\x00x\x02n\x04\xd2\x05+\x06\x10\x05(\x03\xf8\x01n\x01\xd4\xffD\xfc{\xf6\xc9\xee\xbd\xea\xea\xeez\xfb\\\x0b\xd8\x17\r#{7GU\x13i\xfdd\xc5M\x898\x102\x97-\x04\x1d\x92\x015\xe9-\xdf\xee\xdfH\xe0\xf0\xdem\xdf\xf5\xe0W\xe1\xab\xe0\x81\xe3\x7f\xeb>\xf3\x9e\xf5\x0c\xf5*\xfa\xec\x08t\x19\tL\x0e}\x11\x9f\x10\xad\n\x0f\x01)\xf7\x8e\xefH\xeb\xb4\xe9P\xe9/\xea\xd3\xec\xe9\xf2\xdf\xfa>\x01\xea\x03s\x02r\xff\x8b\xfd\xf1\xfc&\xfdi\xfc\'\xfb\xd5\xfa\xd5\xfc\xe5\x00T\x04\xad\x04\x08\x02\x0c\xfe\x86\xfa\x13\xf8\xf2\xf5s\xf3h\xf0\xc8\xee\x13\xf1\xb2\xf6\xe6\xfd!\x03\xab\x05\xef\x06\xda\x07j\t\xd4\t\x9d\x071\x04\x91\x00\x04\xff\x03\xff\xad\xff\xe9\xffS\xff\x89\xfe/\xffW\x01_\x03\x13\x04\x13\x02\xb0\xff\xf7\xfe\xb9\x00o\x03\x1c\x05\xe3\x05\xf3\x06\xfb\x08\x03\n\xfe\x08\x96\x06a\x04\xb7\x02\xe2\x01\xb1\x00\x87\xfe\x88\xfa\x14\xf5\xf1\xf2\x10\xf9\x1a\x07\xc7\x16\xb5%}9JS\xdegDi\xfaV{?0.\xbb\x1e\xb2\x08\xcc\xebZ\xd2f\xc65\xc6\xdd\xc9\xdd\xcd\xae\xd4A\xe0G\xeba\xf1\x06\xf4c\xf8C\xff!\x04\x83\x03\x91\x01\xd7\x04D\r\x8e\x13\xd4\x11\xad\t\x7f\x00\x08\xf8:\xee\xff\xe1\xce\xd6\xc5\xcf\xb1\xcc\x85\xcc\x8c\xd1\x10\xdd\\\xed\xe5\xfc6\x08`\x0f\x90\x14\x15\x19\x1e\x1a!\x14\xf4\x07\xff\xf9\x9f\xef`\xeb\xd6\xebg\xec\x8e\xea\xd6\xe8\x17\xeb\x81\xf1\x89\xf8\x99\xfb`\xf9\xb3\xf5\xc6\xf5\xf8\xfa\xfe\x01V\x07\xd4\n$\x0e\x80\x13\\\x1a\t \x0b!\x83\x1ba\x11\x96\x06\x0b\xfe3\xf8o\xf2\xcd\xeb*\xe6\x00\xe5\x8c\xea/\xf5\xfa\xff\xea\x06\x99\n\xb0\x0e\xbe\x14\xb6\x19$\x1a\xe0\x15\xf2\x0fa\x0c\xfb\x0b\xee\x0c\x16\x0c\x89\x08\x0c\x04\x1c\x00\xc4\xfc\xb1\xf9\xe2\xf5q\xf1$\xed\x17\xeb7\xed9\xf3\\\xfa\xe9\xff3\x03\xa6\x06\xcf\x0b:\x11\xa3\x12\xea\rI\x05\xdd\xfd\x1c\xfa\x80\xf8\x99\xf5n\xf14\xef2\xf2\x82\xf9y\x01\xcc\x06\x07\t<\tb\x08\x95\x06Y\x03#\xfe\xb5\xf7s\xf1s\xed\xa0\xed\xbd\xf1Z\xf7.\xfck\xff/\x02%\x05\x80\x07W\x07\xac\x03\x97\xfd\x8e\xf8\x92\xf6\xc2\xf7j\xf9N\xfa%\xfbR\xfd,\x01\x03\x05\xce\x06u\x05\x0c\x02\x04\xff\x9e\xfd\x93\xfd\x9e\xfd\xbb\xfd"\xfek\xff\x15\x02@\x05 \x07\xbd\x06\x14\x04\x99\x00\x13\xfe@\xfd \xfd\xa4\xfc\xb7\xfbj\xfb\x03\xfd\xf5\x00\xa5\x05}\x08f\x08\x84\x06\xe3\x04\x10\x04\x8d\x03a\x02\x82\xff\x90\xfc\xa6\xfb\xe0\xfc!\xff\x11\x00G\xff\xeb\xfd\xf0\xfc\x87\xfd\xfd\xfe7\x00\x04\x00\xde\xfe\x86\xfe\x8a\xff\xc4\x01j\x03\xdf\x03\x1d\x03\xcd\x01\xe3\x00q\x00R\xff\xf9\xfc/\xfa\x8c\xf8\x94\xf9\xad\xfdW\x02S\x043\x03\x1f\x01\xd4\x00\xa8\x03#\x06\xb0\x03w\xfb?\xf3\xe5\xf5\x01\tn$q8\xcd<\xe6:%A\xa9L\xd0J\xe50\xbf\t|\xec\x15\xe3\x9c\xe4\xab\xe3;\xdbg\xd4\xfa\xd7\x15\xe6\x16\xf5;\xfcX\xfa9\xf3\xd2\xed+\xee\xdf\xf3\x89\xfb\x8c\x009\x02f\x03\xf6\x07/\x0e\xb7\x0f\xa8\x07o\xf7c\xe6\xeb\xda\x8c\xd6\xa1\xd7A\xdbc\xe1\xa7\xe9\x86\xf3n\x00\x1b\x0fB\x1a\xa0\x1b\xe3\x12\xef\x06\x08\xff\xab\xfbB\xf8#\xf1\x99\xe8\xef\xe5\xa3\xeb\x0e\xf5\x1c\xfbH\xfb\xd5\xf8\x8d\xf7\x8b\xf8g\xfa\x90\xfbb\xfc\xfc\xfeL\x04\x10\x0c\xa8\x153\x1e\xb6!\xa7\x1e\xe4\x17P\x11X\x0bi\x03\xab\xf8\xc0\xed\n\xe72\xe7\xdb\xec\xcd\xf3\xac\xf9D\xff\n\x06\x18\r\xdb\x11:\x13[\x11\x9f\rg\t0\x06P\x05,\x06\xef\x06~\x06n\x05w\x04\xf0\x03\xba\x02x\xff\x1f\xfa@\xf5\xbc\xf3\x03\xf6\xf5\xf9\xd5\xfc9\xfe\x1c\x00J\x03\xb0\x06e\x07\xd1\x04\xc8\x00\xd8\xfdV\xfd(\xfe\x7f\xfep\xfd\xd9\xfb\xa2\xfb\x05\xfd\xa0\xff\xd3\x01\xac\x02k\x02\x16\x02\xb0\x02\xcb\x03\xea\x03H\x02\x84\xff\xdb\xfdo\xfe\xed\x00\x1d\x03_\x030\x02\xde\x00,\x00\x80\xff\xac\xfdq\xfaK\xf6\r\xf33\xf2\x12\xf4\xdc\xf6\xe3\xf8\xcd\xf9)\xfb3\xfes\x02\x8b\x05a\x05=\x02\xd3\xfe)\xfd\x9e\xfdW\xfe]\xfe\xa7\xfd@\xfdq\xfe9\x00\x0f\x01\xad\xffU\xfc\x04\xf9\xc2\xf7\x10\xf9B\xfb\x85\xfcq\xfcj\xfc\xe1\xfd\x91\x00\x99\x02b\x02\xcc\xff\xfa\xfc\x12\xfc\x87\xfd\xf6\xff\xe9\x00>\xff]\xfd\x84\xfd\x96\xff\xd8\x01\xc4\x01\xea\xff.\xfe\xec\xfd\x11\xff?\x00\x18\x00f\xfeA\xfc-\xfbA\xfc\x1a\xff\xce\x00\xe9\xff\x9c\xfd\x8d\xfcP\xfd\xc6\xfd\xc1\xfbE\xf7\x18\xf2;\xeei\xed-\xf6:\x0f\xd53\xd7R\x84\\KUJN\x9bK\x94A\xaa\'\xfe\x07\xe7\xf40\xf4\xf8\xfa\x9b\xfb\xdc\xf4d\xeeq\xee\xa6\xefr\xeb\x80\xe2\xc1\xdac\xda\xb1\xe0\xeb\xebi\xfay\t\xf5\x14\xb3\x17\x1d\x13C\x0br\x02\xf2\xf7M\xea[\xdc\t\xd4\xcf\xd4S\xdd3\xe8A\xf1\xeb\xf5\xe3\xf7o\xfbi\x023\x08\xa3\x06\x0c\xff\x1a\xf84\xf8y\xfe[\x04\x98\x05\x83\x010\xfc\x01\xf85\xf4x\xef\xe3\xe8\xff\xe1\xb1\xdd\xe8\xdeb\xe6\xb9\xf1f\xfdx\x06;\r\xfc\x12\xfe\x17\xbf\x1ad\x18?\x11\xcb\x08n\x03\xfa\x01\x01\x02\x07\x01\xbe\xfe\x86\xfd\x91\xfe\xf7\x00\x12\x01v\xfd\xbe\xf8\xef\xf6\xf0\xf9G\xff\xb9\x04(\nn\x10B\x169\x19\xe9\x18\xde\x16h\x14\xa6\x10:\x0b\xa2\x05[\x02\\\x02\x0f\x03\x1e\x02,\x00\xd3\xff\xaa\x01\xbc\x02\x81\x00\xf8\xfb\x0c\xf9\x98\xf9\xa2\xfbG\xfc\x1d\xfc\xb3\xfd\n\x02\xe3\x06\x92\tz\t\xac\x07v\x04\r\x008\xfb\x97\xf7\x86\xf5\x1d\xf4\xfd\xf2I\xf3\xec\xf5\x0b\xfa{\xfcv\xfbg\xf8\xf4\xf6f\xf8\x1d\xfb$\xfc\xc8\xfb\x17\xfd\x8f\x01\xc4\x06C\t}\x07n\x03r\xffh\xfc\xa4\xf9\xb9\xf66\xf4[\xf3\xea\xf4\x82\xf8\xd7\xfc\x90\x00\n\x02,\x01\x06\xff\xaf\xfd\xb5\xfd/\xfe\xbb\xfd\xa3\xfc\x89\xfc\x0b\xfeY\x00\xe2\x01\x8f\x01\xd3\xff\xd0\xfd\xa3\xfcZ\xfc>\xfc\xb5\xfbE\xfb\xe0\xfb/\xfeo\x01d\x04\xa7\x05L\x05F\x04x\x03\x10\x03r\x02Z\x01\x0f\x00\x8a\xff\x03\x00%\x01=\x02g\x02<\x015\xff\x81\xfdL\xfds\xfe\xb8\xff\xcb\xffJ\xfe\xb7\xfcV\xfd\x9d\xff\xa3\x00G\xfe=\xfa\x0b\xf9#\xfc\x08\x01.\x04\xdc\x03\x8a\x02y\x02\xeb\x05~\x0b~\x0f\x7f\x0f\n\r\xba\x0c\xb0\x11\x19\x1al%\xb31\xc7=\x81E\xbfB\x995 #\xed\x11\xde\x04^\xfa\x94\xf2\xe0\xec\x0e\xe8\x07\xe5\xcd\xe3G\xe3\xb1\xe0f\xde#\xe0\xb7\xe5\xee\xec\xbb\xf3\xf0\xfb\xd9\x04\xa1\n\xb6\x0be\t\xf6\x04U\xfd_\xf2\x1b\xe8\x08\xe3\xe2\xe3\xbc\xe6\x06\xe7o\xe6\x14\xeaj\xf3\x86\xfa\xb7\xf7\x97\xef\x12\xee\x8e\xf6L\xff\x1f\x01k\x00\x1b\x04\x97\t\xf7\x08@\x01\xb9\xf8\x8d\xf3T\xef\x7f\xe9\xd7\xe4\x89\xe5\x0c\xec\xf6\xf3\xd2\xf8\xbf\xfa\xa1\xfd=\x03\x97\x08\xed\x08\xd7\x04\xc3\x02Z\x07J\x0f\xe8\x13\xc8\x12Y\x0f\x1d\rT\x0b\\\x07\xc2\x00\n\xfa\xa7\xf5M\xf4\xaf\xf5r\xf9c\xfe\xdf\x01\xdf\x02\xdd\x02)\x05_\n\x98\x0f\xa3\x11\x9f\x10k\x10Q\x13\xd9\x16\x8e\x16\xcb\x10\x0c\x08\x99\x00\x99\xfd\x1c\xfe~\xfe\xd0\xfc\x08\xfb\x94\xfcz\x01%\x05O\x03\x1a\xfd\x9a\xf8[\xfb\xf1\x03s\x0c\xcd\x0fU\x0e\xc9\x0b\xdb\t4\x07=\x01H\xf8\r\xf0%\xec\x18\xee\t\xf4\xf5\xfa\x82\xffv\x00g\xfe\x08\xfcr\xfb\xff\xfbP\xfc\x8a\xfbz\xfb\x16\xfen\x02\xc5\x056\x05c\x00\xed\xf9\xe5\xf4?\xf3\x14\xf4o\xf5C\xf7e\xfaL\xff\x0b\x04~\x06n\x05\x80\x01\xd8\xfc\x90\xf9\x8c\xf8)\xf9Q\xfa\xcf\xfb\xe0\xfcK\xfd|\xfc\x93\xfa\\\xf8s\xf6\xed\xf5\x87\xf7$\xfb\n\x00\x80\x04\xb0\x07}\t\xc3\t\xc0\x089\x06\n\x03\xd5\x00I\x00\xb7\x00\\\x004\xff/\xfeN\xfeU\xfe;\xfd\x89\xfb\xae\xfa;\xfd\x96\x01\x96\x05\xb5\x07-\x08\xa6\x08\n\t\xaf\x07Y\x04F\x00\x82\xfd\x82\xfc\xfa\xfc\x9f\xfdq\xfd\xc4\xfc\xc4\xfb\x15\xfc,\xfd\xbd\xfd\xca\xfe\x00\x02\x93\x08\x00\x0e\xf5\x0e\xc9\x0b\xc0\x08\xb0\t\x80\r{\x0e\x03\t\x9e\x07i\x19\xac:\x18QSF\x1a$B\t\x82\x06A\x12R\x17\xf0\x0f(\x05>\x01U\x04\x1c\x060\xfe\xeb\xebP\xd8\xc0\xcd\x0e\xd13\xdeU\xee\x8d\xfa\xfc\xfe\x94\xfcu\xf7\xf5\xf32\xf2\xa5\xf0\xa5\xef\xd9\xf0z\xf6\x81\xff+\x07^\x08U\x02q\xf7C\xeao\xdeb\xdb\x80\xe5\x87\xf5\x01\xfe-\xfbi\xf5\xae\xf4W\xf7*\xf6\xab\xf0\xb3\xec\xbd\xef\xe7\xf9\x86\x04\x9a\t5\x08\x84\x02\x80\xfb$\xf6\x8c\xf3\xfa\xf3s\xf5\xf6\xf7\xb9\xfbG\x00\x17\x05\x9e\x08\xc0\x08\xa6\x03\xa1\xfc\xd9\xf9j\xfe#\x06L\ng\t\x92\x07\x8a\x07S\x07\xe9\x04\xde\x00\x9e\xfd\x03\xfc\xc9\xfb\x9e\xfd\xdb\x01&\x06\x9a\x07=\x06x\x04\t\x05\x83\x07\x80\n\x88\x0c-\r4\x0eC\x10M\x12\xcf\x11\r\r\xf7\x07\xd4\x05\x9b\x06\xd3\x05[\x01\x02\xfe\xbd\xfe\xd6\x01C\x02\x1e\xfe\xe7\xf8\xbf\xf5\xae\xf4\r\xf5\xb1\xf5]\xf7\x85\xf9\xd8\xfa\xdb\xfb\x91\xfc[\xfd\xd2\xfcd\xfb\x9b\xfa!\xfb\xae\xfc\x1d\xfe`\xff\xe2\xff\x86\xffT\xfes\xfc\x94\xfa"\xf94\xf9^\xfa\xd0\xfb\xae\xfc&\xfd\xe5\xfdC\xfe`\xfe\xb6\xfd\x0e\xfd\xb7\xfc\xd8\xfcn\xfd\xfc\xfd\x9c\xfe\xec\xfe\xd5\xfe\x1c\xfe:\xfd\x9b\xfc\x17\xfc\x8a\xfb\xb4\xfb\xab\xfcE\xfe#\xff\xbe\xfe\x0f\xfe\x85\xfd\x9f\xfd\xe1\xfd}\xfe\xdc\xff.\x02=\x04\xbb\x04\\\x03\xe7\x01\x91\x01\xc9\x02\xc8\x04t\x06\x85\x07\xa3\x07w\x06Q\x04p\x01\xd4\xff$\xff\'\xfe\xd0\xfc\xfa\xfc\xa0\xff\x18\x03\xe3\x02\x00\xff%\xfb\x92\xf9\xe2\xfbw\xff(\x03\x01\x07\xd0\x06\xd6\x02\x83\xfb\x0b\xfc\x14\r\xd2\'V8\xce1\xf7!\x9c\x1bs"\xec*\x7f+l(\xa5\'\xb2(\xdb&\x17\x1e?\x10\x8c\x03\xcb\xf9\xd0\xf1p\xeb(\xea\xe3\xec\x04\xec\x87\xe2\xca\xd8\xae\xd8\x0b\xdf\x1b\xe2+\xe0\x05\xe2s\xeb]\xf4F\xf7\xa1\xf6.\xf8\r\xfc\'\xfe\xb0\xff\x94\x03\x00\n\xef\x0bJ\x07\xc9\x01\xbc\x00\xc1\x01\xed\xfe\xb9\xf8\xca\xf3\x97\xf2A\xf3.\xf26\xee,\xe9\xec\xe5\x12\xe5\xf3\xe4N\xe5A\xe7\x82\xea+\xedt\xee1\xf0\xcc\xf3\x7f\xf8\x1e\xfc\xf5\xfd\xe5\xff\x00\x04*\t\xc8\x0ca\r\x9c\r\x0b\x0f\x8a\x10(\x10\x88\x0e\xa6\r\x95\x0c\xe3\t\xaf\x06\x94\x05\xa1\x06\xfa\x05\n\x03g\xffz\xfd{\xfc\x95\xfbL\xfc\xf6\xfd\xb3\xff\x89\x01\x95\x03\x1e\x05\xa1\x04*\x04\xc8\x06\xb1\n\x14\x0e~\x119\x15o\x16\xb5\x11h\n\xbf\x06<\x07\x89\x08\x1b\x08~\x06\xa0\x03\x1b\xff\x03\xfa#\xf6S\xf3J\xf1\x8f\xf0\x95\xf1\xdb\xf2\xd2\xf2\xd3\xf1\xb5\xf0b\xf07\xf1\x8e\xf3b\xf7\xff\xfa\x02\xfd\xe4\xfd\x0f\xfe"\xfe/\xfe\x16\xff,\x01\xd8\x02;\x034\x02\xa9\x00\xdd\xfe\x01\xfd\xad\xfbg\xfb\x7f\xfb3\xfbX\xfa\xf2\xf8\x88\xf7\x1c\xf6\x1f\xf5\xd2\xf43\xf5\x86\xf6\xa5\xf8P\xfa\xb2\xfaa\xfad\xfaT\xfb\xf0\xfb#\xfd\x1d\x00\xba\x04n\x07\xc9\x06;\x04\x05\x028\x02\x15\x046\x07\xfb\t\xb0\n\xad\t\xab\x05g\x00z\xfdI\x00\x80\x06\x14\t\xb4\x05I\x00s\xfeD\x01\xd7\x05\x94\t"\tq\x05\xdc\x01\x8a\x03\x15\t]\x0e\xc6\x14\xaa \x03,M*m\x1b&\x11\xc2\x16\x93#\xa9+\xcb-\xda-\xca\'\x85\x1a\xfb\r\xdc\x08\xac\x08\xaf\x07C\x05?\x02\x14\xfd\x1f\xf4>\xe9Y\xe0\x13\xdb%\xda\xce\xdcL\xe0\xd6\xe27\xe1\x8f\xdcU\xd8-\xd7a\xdbP\xe45\xefh\xf7\x9f\xf8\xc6\xf5\x98\xf5h\xf9\xca\xfe\xdb\x03Y\n/\x10\xb2\x10J\x0c8\x07[\x05f\x05z\x05W\x05\xb5\x04\x02\x03E\xfem\xf6\xb0\xee\x8d\xeb\xe8\xec\xc1\xee\xf8\xed/\xec\x07\xeb\xc1\xe9\xd5\xe7\xb8\xe7M\xeb\xf3\xf0\x84\xf5\xe2\xf8(\xfc\x83\xff\xbb\x01\x88\x03\x07\x06<\n\xcc\x0e\x8e\x12\xef\x14\xbd\x15V\x14G\x12+\x11`\x12S\x14\x82\x14\x8f\x11H\x0c\x97\x08\xc8\tD\x0e\xaa\x0f\xc9\n\xa7\x03\x0b\x01\x9e\x02\x86\x03\n\x03\xb7\x03t\x04\x1c\x02p\xfdc\xfb\xa3\xfc\xba\xfda\xfd\x1f\xfd\x9d\xfd\xac\xfd\\\xfc7\xfb\xf9\xf9$\xf9j\xf9\xeb\xfa\x1e\xfc\x9a\xfb\x87\xf9S\xf7+\xf6\xbf\xf6\x9d\xf8~\xfaE\xfbi\xfa\xd6\xf8\xd1\xf7\xd4\xf8\xb4\xfa>\xfc\x18\xfd\xf2\xfd]\xffo\xff\xeb\xfdA\xfcg\xfc2\xfe\xaa\xff\xd4\xff\xfe\xfe\xb0\xfd\x15\xfc\xef\xf9<\xf9\x15\xfa_\xfb-\xfc\x93\xfb\x0f\xfb\x0f\xfa\xce\xf8\x91\xf8\x89\xf96\xfb\xea\xfc!\xfe\xc6\xfe\r\xff\xe9\xfe\xc2\xff\xd5\x00q\x01\xfe\x02n\x06\x9b\x08\x01\x08r\x05\x13\x05\xb5\x07&\t\xc8\t\xf9\nT\x0c\xa6\x0b/\x08S\x06\xaa\x07\xa0\n\xe0\x0cr\rm\x0c\xba\nF\n(\x0c\xeb\x10\x9a\x18C\x1f\xd3\x1fB\x18\xe8\x0f\x11\x10\xfd\x17) \xc3!\xb0\x1e\x88\x1a}\x15\x9c\x0f\xeb\n\x99\t\xca\n\x91\t\x93\x05\xcf\x00\xc4\xfb\xda\xf5\x0e\xef\xdd\xea\n\xea\x08\xea\x83\xe9\xf5\xe7\xfb\xe4\x02\xe1T\xddw\xdcX\xde\x81\xe1w\xe5\xaa\xe8?\xe9G\xe7\x97\xe6G\xe9\xb5\xed\x8e\xf1\\\xf5\xfa\xf9k\xfc\x91\xfb\x13\xfa\x9f\xfaR\xfd\xcb\xff\x9e\x02\xdc\x052\x075\x05`\x01\xc5\xfeW\xfeX\xff=\x01\x9b\x02v\x01\xc3\xfd\r\xfa>\xf8\xcf\xf7\xa2\xf7\xcf\xf7\xf4\xf8\xca\xf9\x02\xf9\x0e\xf7/\xf6o\xf7/\xf9v\xfb\xa1\xfdI\x00\x9c\x01\x8b\x02\x92\x04\xa0\x07\xb1\t\xf9\t$\x0b(\x0fL\x14[\x17A\x17\xf8\x13h\x10\xb3\x0fh\x13\xcb\x16\x8b\x15\x02\x11D\r!\x0b\xf3\x07\x9f\x04\xb4\x03\x0e\x04\x98\x02\xee\xfe\xef\xfb\xa5\xf9\xc2\xf6{\xf4\xb1\xf4\xef\xf5q\xf5\x1c\xf4l\xf3\t\xf3\xcf\xf1\xed\xf0z\xf2\xfd\xf4^\xf6\xf4\xf6\xf2\xf7\xfb\xf8%\xf9K\xf9\x7f\xfaI\xfc|\xfd6\xfe\xf9\xfe9\xff\xb5\xfec\xfe\xe3\xfe\xeb\xffu\x00Z\x00 \x00\xdc\xff0\xffO\xfe\x8d\xfdy\xfd\x1e\xfe\x9d\xfeT\xfe`\xfd\xf4\xfc\x86\xfd)\xfeb\xfe\xe4\xfe\xc8\xff\x9f\x00\xa7\x00\x98\x00e\x01\'\x02b\x02(\x02+\x03\x9b\x04U\x04;\x03\x82\x03\x9e\x05\xc5\x06\xda\x05\xd1\x04x\x05\xdb\x06\x0e\x07\xaa\x05k\x04\xa1\x04a\x06\xa9\x07\xc5\x07\x9f\x08\xca\x0bJ\x0fb\x0f\\\r\x94\r\x01\x11\xa2\x13\xb3\x14F\x15\xe1\x15\x8e\x14\x9f\x12\x04\x13\xb8\x14\x94\x14.\x12U\x10\x0f\x0e[\nn\x07\x18\x07a\x07,\x04}\xff?\xfc\xb2\xf9!\xf6&\xf3\x9b\xf3\x12\xf4\xf3\xf0\x07\xec\r\xea\x1e\xebJ\xebp\xea\xc6\xea\x04\xec\xed\xeb\x14\xeb\x07\xec\x8a\xee\x10\xf0\xb8\xf08\xf2g\xf4<\xf5\xe7\xf4\xc4\xf5U\xf8U\xfa\xeb\xfa\x8a\xfb\x96\xfc\xcb\xfc\xf3\xfb!\xfc\xa5\xfe\x1a\x01T\x01\xe6\xff\xbe\xfew\xfe\xd4\xfe\x8e\xff|\x00\x8f\x00H\xff\x89\xfd\xd5\xfcV\xfd\xf2\xfd\x9e\xfd\xa5\xfc\xc0\xfb\xc4\xfb\x11\xfcm\xfc\x98\xfc\xc5\xfc"\xfd\xba\xfd\xed\xfe\x98\x00\'\x02\xf8\x02\x84\x03\xde\x04\xa0\x06v\x08\xb9\t\xb5\n5\x0b6\x0bg\x0b\xe9\x0b\x94\x0ce\x0c\xec\x0bx\x0b\xd0\n\xbe\t\x1d\x08\xfb\x06\x0f\x06\\\x05\x98\x04|\x03>\x02\xaf\x005\xff\xea\xfd\xee\xfcR\xfc\xcd\xfb/\xfb<\xfa\x02\xf9\xa8\xf7\xe9\xf6\xfc\xf6\\\xf7G\xf7\xb6\xf6\x1f\xf6\xc9\xf5\x94\xf5\xf5\xf5\x00\xf7\xfb\xf78\xf8\xf7\xf7\xd5\xf7\x19\xf8\x99\xf8p\xf9\x85\xfa\\\xfb\x8d\xfb\xa4\xfb(\xfc\x0b\xfd~\xfd\x83\xfd\xdf\xfd\x9e\xfeR\xff\xe4\xff\xb4\x00|\x01h\x01\xe2\x00\xdd\x00\x83\x01\x87\x01\xed\x00\r\x01=\x02?\x03\xb0\x02\x8c\x01\x88\x01\x0e\x02J\x02Q\x02\x7f\x02\xa7\x02#\x02\xd9\x01\xc8\x02A\x04\xe1\x04\\\x04W\x04\xe1\x04\x94\x05\'\x06\xa4\x07s\n\xcc\x0cx\rw\r\x80\x0e\xc0\x10\xab\x12\xfd\x13E\x15$\x16\xf4\x15h\x15\xfe\x15\x1c\x17\xeb\x16\x0f\x15F\x13?\x12\xca\x10i\x0e\xbe\x0b\xb7\t\x16\x07\xbf\x03\x82\x00\x03\xfe\xca\xfb\xb1\xf8H\xf5\xc6\xf25\xf1\xd2\xef\xf2\xed\x06\xec\x8e\xea\x9d\xe9A\xe9\x8e\xe9\x1f\xeau\xeae\xea\xa7\xea\x8a\xeb\xee\xecO\xee\xb5\xef#\xf1k\xf2U\xf39\xf4\xac\xf5\x86\xf7%\xf9\'\xfa\xe3\xfa\xe1\xfb\xd7\xfc\xce\xfd\xdd\xfe\xd9\xff;\x00\x0e\x003\x00\xe7\x00\x8a\x01\xae\x01s\x01O\x01J\x01b\x01k\x01_\x014\x01)\x011\x01{\x01\xdc\x01\'\x02\t\x02\xad\x01\xca\x01\xd9\x02R\x04%\x05\x19\x05\x8a\x04\n\x04J\x04T\x05\xac\x06E\x07\x0b\x07\x8a\x06C\x06\x02\x06\x10\x06\xbb\x06h\x07@\x07A\x06w\x052\x05\xcb\x04J\x04%\x04\x0f\x04\x1e\x03\xaf\x01\xb9\x00R\x00\x9f\xff\x88\xfe\xc0\xfdT\xfd\x8f\xfcx\xfb\xc2\xfah\xfa\xeb\xf9k\xf9\x1c\xf9\xe2\xf8k\xf8\x06\xf8\x04\xf8\x13\xf8\xe3\xf7\xc5\xf7\x0e\xf8\xa6\xf84\xf9\x89\xf9\xa9\xf9\xb7\xf9\xc4\xf9[\xfa\x9a\xfb\xf9\xfc\x90\xfd`\xfd\xfc\xfc;\xfd\x1c\xfe$\xff\xde\xff"\x00\x1c\x00!\x00a\x00\xe8\x00q\x01\xcc\x01\xb2\x01\x9c\x01\xe4\x01u\x02\xe7\x02\x07\x03\x14\x03!\x03\xfb\x02\xb7\x02\xc6\x021\x03\x81\x03\x9c\x03\x81\x03Z\x03\x0e\x03\xf1\x02.\x03{\x03\xe3\x03\xe4\x04\xcf\x06\xc8\x08\x8d\t\xa4\t$\nx\x0b<\re\x0f\xc9\x11p\x13\xba\x13\x9e\x13\xfa\x13\x97\x14w\x14*\x14`\x14N\x14\x0b\x13\xd7\x10\xd9\x0e\x01\r\x87\n\x18\x089\x06I\x04I\x01\xe3\xfd%\xfb\x06\xf9\x8c\xf6\xfa\xf3\xed\xf1S\xf0\xa4\xee\x01\xed\x01\xec_\xeb\xae\xeaE\xeaz\xea/\xeb\xa0\xeb\x07\xec\xc6\xec\xc4\xed\xc7\xee\xed\xefm\xf1\x12\xf3O\xf4I\xf5I\xf6a\xf7i\xf8s\xf9\xac\xfa\xf3\xfb\xcf\xfc5\xfd~\xfd\x0b\xfe\xa7\xfe\n\xff6\xff\x92\xff\x17\x00]\x00\x0e\x00\xab\xff\xb0\xff\xcd\xff\xa2\xffj\xff\xcc\xffg\x00_\x00\xf4\xff\xf0\xffq\x00\xb6\x00\xcf\x00|\x01d\x02\xaf\x02\x9e\x024\x03\x1f\x040\x04\xd4\x03-\x04\x1c\x05b\x05\x1c\x05F\x05\xc7\x05\x95\x05\xe1\x04\xe0\x04i\x05~\x05\xfd\x04\xdb\x04I\x05R\x05\xf6\x04\xd3\x04\xb3\x04(\x04\x86\x03\x84\x03\xb1\x03+\x03%\x02"\x01d\x00\x9a\xff\xdf\xfe;\xfex\xfdk\xfcb\xfb\x86\xfa\xed\xf9l\xf9\x02\xf9Y\xf8\x96\xf7"\xf7+\xf7`\xf7l\xf7U\xf7@\xf7@\xf7\x86\xf7!\xf8\xfa\xf8\x90\xf9\x1e\xfa\xb0\xfa(\xfbU\xfb\xc1\xfb\xf8\xfcb\xfe\'\xff=\xffB\xfft\xff\xb4\xffe\x00\x82\x01a\x02\xf7\x01\xfa\x00\xe9\x00%\x02o\x03\xe9\x03\xe9\x03\xa8\x03\x16\x03\xb2\x02w\x03\x1c\x05:\x06\x1f\x06e\x05\x1e\x05~\x05+\x06\xf2\x06\xb5\x07\xde\x07\x88\x07\xb0\x07}\th\x0c\xae\x0e\x07\x0f\x0e\x0e\xce\r\x83\x0f\x90\x12m\x15\x01\x17!\x176\x16\x0f\x15\xc8\x14\xea\x15W\x17M\x17\x0e\x15\x99\x11\x85\x0e~\x0c/\x0b\xd7\t\x93\x07\xda\x03\xfd\xfe\xab\xfa\xbb\xf7\xe1\xf5\xfc\xf3\x80\xf1\xa9\xeey\xeb\xa0\xe8\x12\xe7\xc4\xe6\xd9\xe6C\xe6`\xe5\xee\xe41\xe5W\xe6J\xe8U\xea\xad\xebN\xec \xed\xff\xee\xcb\xf1\xe9\xf4\x9d\xf7\x90\xf9^\xfa\xc6\xfa\xce\xfb\xe2\xfd\x90\x00\xad\x02\xb6\x03\xbd\x033\x03\xef\x02G\x03\x1b\x04\xa4\x04\xad\x04J\x04Y\x03g\x02\x02\x02;\x02\x90\x02.\x02\x80\x01\x97\x00\x0b\x00\xcd\x00\xac\x02E\x04\xa8\x03\xb0\x01x\x00!\x01\xe3\x02\x8e\x04a\x05\x02\x05y\x03\xc2\x01X\x01\x1b\x02\x82\x03\x1b\x04\xc1\x03\xa9\x02\\\x01\x97\x00\x8a\x00\x16\x01\xc8\x01\xef\x01\xa2\x01D\x01\x18\x01\xe9\x00K\x00\xe0\xff\xd8\xff&\x00N\x00_\x00p\x00\xcd\xffY\xfe\xc2\xfc\'\xfc{\xfc\xde\xfc\x10\xfd\xe8\xfc<\xfc\xdc\xfa;\xf9Q\xf8D\xf8\xa3\xf8\xff\xf8V\xf9d\xf9\xb4\xf8\xb1\xf7\x0c\xf7\x17\xf7\x93\xf7O\xf80\xf9\xf9\xf93\xfa\x03\xfa\xed\xf9C\xfa?\xfbp\xfce\xfd\xb1\xfdZ\xfdW\xfd\x0c\xfe\x18\xff\xf6\xff\x80\x00\xa9\x00\x91\x00/\x00P\x00\xfa\x00\xb3\x01\xfd\x01\xc5\x01\xee\x01\x14\x03\xae\x04\xa1\x05C\x05M\x04Y\x040\x06\x85\nz\x10\xac\x15.\x17\x03\x15\xdd\x12W\x14s\x19\xe6\x1fw%\xca(\x06(\xc2#i\x1f\xa9\x1e\x87!\xe0#\\#\xee\x1f:\x1b\x94\x15\xe4\x0fI\x0b\xde\x07\xb1\x04\xb3\xff\xb2\xf9\x0c\xf4\xb5\xef^\xec"\xe8;\xe3\x80\xde\x00\xdb)\xd9\xb9\xd8\x9f\xd9\x82\xda&\xdad\xd89\xd7\xaa\xd8\x90\xdc\xc3\xe1~\xe6\xd3\xe9V\xeb\x8a\xec\'\xef\x9b\xf3\xdf\xf8\x03\xfe\'\x02-\x04|\x04Q\x05Y\x08\x02\x0c\x0c\x0e6\x0e\x0f\x0e\xe9\r+\r\xb6\x0c,\r\x1e\r;\x0b\xf6\x07T\x05\x13\x04\xd5\x02$\x02\xb1\x00\xa2\xfe\xf4\xfb\xfd\xf9_\xf9Q\xf9\x92\xf9\xfb\xf9\x99\xf9\xe1\xf7\xe1\xf5G\xf6\x8d\xf9\x17\xfe0\x01\xd2\x01:\x00J\xfeh\xfe\x93\x00~\x04\xc0\x07\xff\t\x9c\t\xa0\x07!\x06\x80\x06C\x08\xb8\x08G\x08l\x07\xb7\x06\xe1\x05|\x05\xf0\x05l\x05\xe3\x02v\xffU\xfd\x1b\xfdj\xfd\x90\xfdu\xfd\xc1\xfb\xf3\xf8\xe4\xf5\xb4\xf4\xc3\xf4\xe1\xf4\x14\xf5<\xf5\xdc\xf4\xc2\xf3\xe5\xf2\r\xf3s\xf3d\xf3e\xf3:\xf4\xa0\xf5\xba\xf6\xc8\xf7A\xf8`\xf8\xed\xf7%\xf8k\xf9*\xfb\x18\xfd\xad\xfe\x8a\xff=\xff;\xfeg\xfe\\\xffX\x00\xde\x00J\x01\x1f\x02\xcc\x02\xd8\x03\xb0\x04\xf3\x04\xd5\x04\xd3\x04;\x05\xa0\x07\x82\x0ex\x19\xcf!\x89#\xcc!\xe5!\xbc#\xd6%\xf9+\xfd6\xb8?\x1b?\x958\xf33-1\xc3,.(\xe7%\xfd"/\x1b\xa5\x11\x8f\n\xd1\x04\xd2\xfc\x07\xf3\xb7\xe9\x16\xe0\xc3\xd6a\xd0/\xcei\xcez\xcda\xcb\\\xc8\x1e\xc5F\xc4O\xc7G\xcd|\xd3G\xd9\x8a\xdf\xe9\xe5\x19\xec9\xf2\x00\xf9\x16\xffV\x032\x06{\t\xdb\r\xda\x12\x14\x17\xac\x19\xb8\x19\xb2\x16!\x12\x1e\x0e\x9b\x0b\r\n)\x08\xf7\x05\xe2\x02V\xfe\xe9\xf8r\xf4t\xf1\x01\xefO\xecx\xea$\xea\x17\xeb\xe7\xeb\xa2\xed\x95\xefS\xf1=\xf2\x12\xf3\xa4\xf5\xc1\xf98\xff\xcd\x044\t\x11\x0cb\r\xed\r7\x0ec\x0f5\x11\xf2\x12\xf3\x13\xb3\x13A\x13\x05\x12P\x10\xd3\r\x1b\x0b\xd6\x07\x8f\x041\x02\xa7\x00\x1e\x00\x08\xff<\xfd=\xfa\x11\xf7?\xf48\xf2F\xf1%\xf1\xa9\xf1:\xf2\xa5\xf2\x04\xf3\x13\xf3\r\xf3\xe6\xf2\x8c\xf2\x94\xf2\xb2\xf2\x0f\xf4\xba\xf5\x1b\xf7\xbe\xf7\xf1\xf7\xa7\xf7T\xf6\xf6\xf4\xf1\xf4\xf3\xf5\x8b\xf6\x19\xf7\xaf\xf75\xf8\xe6\xf6\xa4\xf6\x9b\xf7e\xf8\xb6\xf7~\xf6J\xf7\xb8\xf8\xde\xfa\x05\xfe6\x01\xab\x02@\x02\xe6\x02\x94\x05\xa5\x08\t\r\xf3\x11\xde\x18B =)w4\x97<\xd7?\t? @\xe8BGD\tFMH\x18JkEz;J2T*\n"\xb2\x15G\t\xd2\xfe\xc3\xf4,\xeaF\xe0\xf1\xdaf\xd62\xcf\xda\xc5[\xbf\xaf\xbcQ\xba\x16\xba\xe2\xbd\xff\xc3\xb3\xc8\x08\xcdW\xd4\x94\xdd\xae\xe5\xb0\xed\x05\xf6\x06\xfd\xb8\x01\x08\x07%\x0eR\x14_\x19C\x1dK\x1f\xfc\x1d;\x1a1\x17\xc7\x13>\x0f\x07\n/\x04a\xfd\x82\xf6x\xf1\xa8\xed\xfa\xe8\x84\xe4\xf5\xe0U\xdd\x9a\xd9:\xd8\xc6\xda)\xde\x8c\xe1\xac\xe5\xd6\xea\x8a\xf0\xba\xf5\xf0\xfc=\x04\x19\x0b\x97\x10G\x15\xdd\x18\xd1\x1d\xf9"K\'N)6)B(\xbf$\xbb!\x91\x1e\x92\x1b%\x17\xd6\x11\xac\x0c\x11\x07\x95\x01n\xfdr\xf9\xb8\xf5#\xf2\xad\xee\xbf\xebM\xe9*\xe9\x19\xea0\xeah\xeav\xebE\xed\x1f\xef\x84\xf0Y\xf3-\xf5=\xf6\xc2\xf6A\xf7\x9d\xf8\x18\xf9\x1a\xf9\x8b\xf8\xf0\xf6\xd8\xf5\xb6\xf4S\xf3\xa4\xf1\x10\xef\x1a\xee\xf9\xec\xf5\xeb\xe4\xea8\xea\xf8\xea\x03\xea\xf4\xe94\xec\x8a\xf0\x82\xf3\xd9\xf2\xaa\xf3\x8c\xf6\x0c\xfa\xba\xfc\xad\x02\xe5\n\xac\x10\xb7\x14w\x1a!"\xa5(\xc80%=7H;NLR\xf4VUY\xafV[T\xf6R\xcfN\x94F`=\x165\xac+#!p\x153\x08=\xfa\xbd\xec"\xe0p\xd5\x00\xcde\xc7\xa1\xc1o\xbc\x80\xb9\x94\xb9X\xbb@\xbe\xef\xc2\xb5\xc7\x9b\xcde\xd4\xe1\xdc\x14\xe6\x0f\xf0\x16\xfb\xae\x03\xc3\t\x8a\x0f\xa9\x15\xc1\x1a\xdb\x1b\xc5\x1b\xf1\x1a\t\x18J\x13\xf6\r\xc2\t{\x04m\xfd\xde\xf5\x9b\xee(\xe8+\xe2\x96\xdc\x04\xd8e\xd4\x9b\xd1\xc8\xcf\x1e\xd0\xb5\xd2\xed\xd6,\xdbr\xe0\xdb\xe6\x98\xedd\xf5$\xfd<\x05\xde\x0b]\x12 \x18\x13\x1d\xc7"M*\t0\xc51\xef0\xd00\xc6.\x86)\x9a%;"k\x1d\xf4\x14L\r\x14\t\x10\x04\xdd\xff&\xfc\xc2\xf8C\xf4\x94\xee4\xec\x00\xea6\xe9Q\xe9\x1f\xe9\xa7\xe9n\xe9b\xec\x9c\xf0\t\xf4\xec\xf6T\xf88\xfa\x97\xf9;\xf9\xc9\xf9D\xfa\xb6\xf9\xd7\xf6\xb2\xf4v\xf3?\xf2\x82\xf0\x7f\xee\xfb\xec8\xea]\xe7\xe6\xe4\x87\xe3.\xe3\x98\xe2\x8b\xe3z\xe3E\xe5\xfa\xe7\xe0\xeb\xa2\xefX\xf2\xa5\xf6\xc7\xf9\xd8\xfc\\\xffK\x04P\n\xf8\x0fX\x17\xf6\x1f\x8a(\xd80B\xbb\x90\xb7\x8a\xb7N\xba\xf3\xbev\xc6m\xcf\xdb\xd7\xc4\xe0\xe8\xea\xb6\xf4\xcc\xfc\xb5\x04\xff\x0c\x0b\x13P\x16\x96\x19o\x1d\x12\x1f\xcb\x1dI\x1b\x9c\x17\x87\x11\xab\t\xd3\x01a\xf9\x1f\xf0\xf2\xe6N\xde\xad\xd6\xa3\xd0\xf4\xcc\xcf\xca\xc5\xc9\xc2\xca\x11\xcd\x9c\xcf7\xd3\x13\xd9\xba\xdf[\xe6!\xee\xe0\xf6\x86\xff/\x08e\x11O\x1a\xdd!\x81(\xc7-\xd90L1I2k1i.L*\x11&\x87!\xc6\x1a\xfd\x14\xdd\x0f\x9f\tG\x03J\xfdE\xf8-\xf3\x9e\xeeO\xec\x05\xea\r\xe9X\xe9\xbc\xea\xd4\xec\xd1\xeeV\xf2\x13\xf6\xc2\xf82\xfb%\xfd\x8e\xfeL\xff\xe0\xfeA\xff\xa0\xfe\xe7\xfd|\xfc`\xfa\xb1\xf8\xd6\xf5\xcc\xf2\xf3\xee\xba\xea,\xe7\xb7\xe3\x01\xe1\xd6\xdeS\xdd\xb1\xddD\xde\x0e\xdf\xf7\xdf\xc4\xe1h\xe4\xbd\xe5\xf9\xe6\x03\xea\x17\xef\x9c\xf3\x08\xf7/\xfb\x9d\x00\xab\x06\x85\x0e\xc2\x18\x86"\x9c+\x1a7\xd4D)P\xf8X\xa2c\xd7l\xb0n\xecj@h\x0beZ[\xbbM\\A 5\x87%@\x14q\x05\xce\xf8\xff\xec4\xe1w\xd5\x85\xcb^\xc4\xd8\xbe\xae\xb9\xf9\xb6\xe7\xb7\xa0\xb9\xc0\xbb\x1b\xc1z\xca\x93\xd4\xef\xdex\xeb\'\xf8p\x02\xab\x0bw\x15n\x1d\x9b!\xf6#w%j#\xb2\x1e\x89\x1aB\x16\xa3\x0f$\x07\xd6\xfe\xef\xf5\xee\xebq\xe2\x92\xda\xdf\xd2\x19\xcb\xd6\xc4\x0e\xc0\xbd\xbcZ\xbc/\xbf\x9e\xc3\x1f\xc9\xc9\xd0\xfe\xd9\xab\xe3\x1a\xee\x94\xf9\xd5\x04\x9c\x0e\xd0\x17\xbf 5(Y.\xb03\xc97:939\xd37\xf84i0\xf4*\x93$\x92\x1c\xff\x13\x8c\x0b)\x03\x83\xfbW\xf5r\xf02\xec\xad\xe8/\xe77\xe7\xf9\xe7\xb1\xe9G\xec2\xef\xde\xf1\x14\xf5\xe8\xf8\xab\xfc\xbe\xff\x92\x02\x87\x04\x00\x06[\x06R\x06U\x05(\x03\x90\x00\xe6\xfc\xab\xf8\xfa\xf3\xc1\xef\xf7\xeb\x0e\xe8^\xe4\xd5\xe1\xbf\xdfN\xdd\xe8\xdb\xd4\xdb=\xdc\xfb\xdb\x10\xdc\xaa\xddl\xdf,\xe1\xe9\xe3\xce\xe7\x9d\xec!\xf0\xf0\xf3\x0c\xf9\x08\xfek\x022\x06}\x0c\xd3\x14\xe3\x1c\xbc%m2LBRP\xe8ZRd7m\x9ar\x80sxp\xaajca\xfaS\x19C\xc31o"\xc7\x13\xd3\x03*\xf4\x96\xe7\x87\xdd\xb3\xd3F\xcb\xfa\xc5?\xc2f\xbe*\xbb\x1a\xba\xc9\xbb\r\xc0A\xc6K\xcd\xe1\xd5\x01\xe1f\xed~\xf9\xee\x05\xd2\x12\xb4\x1d\xdd$b)2,\x83,\xad)d$\x11\x1d\xee\x13"\n\x9f\x00\xbd\xf6\xda\xec\xad\xe3\xca\xda\xc6\xd1\xb3\xc9\x8f\xc3\x8f\xbe\xdb\xbay\xb9\xd4\xb9Q\xbb\x91\xbf\x80\xc7\xba\xd0\xc8\xdaU\xe7T\xf5\xd9\x01\x90\r.\x1aM%\xed,?3k8\x16:\xf98}8n7\xb42\xbb,Q(p"\x06\x1a\\\x12{\r\xfb\x064\xfe\x0f\xf7C\xf2p\xedH\xe8\x16\xe6)\xe6\x80\xe6\x84\xe7\x94\xea<\xef\x96\xf4\xa9\xfay\x00\xe2\x04\xe8\x07m\n\x9d\x0b\x01\x0b)\t\xd1\x06h\x03e\xfei\xf9\x93\xf5\x16\xf2:\xeeb\xea8\xe7\x07\xe4\xc0\xe0\xe5\xdd^\xdb\xb8\xd8z\xd65\xd5|\xd4D\xd4\xa3\xd5R\xd9\x91\xdd\xc0\xe1\xe8\xe6.\xee\xb6\xf5V\xfb\x8b\x00o\x06\x98\x0b\x88\x0e\t\x12\xf2\x18\xb8!\\)\xc71\x04=SI\xf9S8]\x00fDk?k\x9dg\x02a\\WJJq;K+K\x1ap\n\x01\xfd\xa9\xf1\xe4\xe7\x17\xe0g\xda\xa9\xd5\xe0\xd1_\xcf\xae\xcd\x90\xcc\xfa\xcb\xe2\xcbK\xcd\xc8\xd0;\xd6P\xdd\xb6\xe5\xa4\xef\x1e\xfa@\x04\xe6\r\x84\x16V\x1d\xae!\t#\x1b!\xdf\x1c&\x17\x1e\x10.\x07\xbe\xfd\x94\xf5\x16\xee\xd0\xe5F\xde\x13\xd9\xa5\xd4Y\xcf\x80\xca\xc1\xc7\x9c\xc5\xf8\xc2\xe5\xc1\xb8\xc3\xe3\xc6\x05\xcb\xa2\xd1"\xdb\xd3\xe5,\xf1\x1d\xfe\x8d\x0b9\x17\xd4 \xef(\xd0.W2\x813\xc22Z0\xe5,.(\xfa"T\x1e\xe7\x19\x14\x15\x9b\x0f\x01\x0bG\x07"\x03\xc7\xfe\xc2\xfb\xa4\xf9"\xf7\xe3\xf4%\xf4\xc3\xf4\xf9\xf5\x17\xf8\xe2\xfa\x15\xfe\xc5\x00e\x03\xad\x050\x07\xce\x07G\x07i\x05\xa0\x02\x80\xff\x89\xfc\x15\xf9\x0b\xf5+\xf1e\xedi\xe9\xb8\xe5Y\xe3\x89\xe1\x0e\xdfM\xdc\xa9\xda\xd1\xd9\xe6\xd8\xd7\xd8\x10\xda{\xdc\x90\xde\xa6\xe1\x8c\xe7h\xee\x9f\xf4\x0e\xfa\xe4\xff\x88\x05\x82\x08\xbb\n;\x0eS\x11\x86\x12\xc6\x13%\x18X\x1e\xfb$\x17.\x849\x97C\xdcJ\x85Q\x9eW\x83Y\x9eV\xc4QtJ\xaf?H2\xfa%\xc4\x1aU\x0f\x9a\x04\xda\xfb3\xf5\xf1\xef\x00\xecA\xe9\xd8\xe65\xe4<\xe1~\xde\x91\xdc^\xdbq\xda\x92\xda\x85\xdcL\xe0j\xe5\x0e\xecQ\xf4v\xfc\x8d\x03\x94\t\xc7\x0eo\x12\\\x13J\x12d\x0f\x02\x0bF\x05\t\xff\xa9\xf9\xd8\xf4\xd2\xef\xd3\xea\xa3\xe6\x8f\xe3\xb5\xe0\xb0\xdd5\xdb\x17\xd99\xd63\xd3\xa6\xd1\xea\xd1:\xd2q\xd3u\xd7w\xdd#\xe4\xce\xeb\xaa\xf5\xe2\xff\x12\x08\x19\x0fW\x16\xcc\x1b\xa2\x1e\xb1 u"p"\xb7 l\x1fk\x1e2\x1c[\x19j\x17G\x15\xe4\x11\x7f\x0e(\x0c~\t\xe8\x05\xa6\x02\x91\x00\x00\xff\xa6\xfdX\xfd\xf0\xfd\xa2\xfep\xff!\x00\r\x01\xf3\x01Q\x02\x85\x01\xed\xffk\xfe\xea\xfc\xe7\xfa\xda\xf89\xf7\x92\xf5/\xf3\xb7\xf0%\xef\xf8\xed\x8b\xec\r\xeb}\xe90\xe8\xe1\xe6\x0b\xe6\x89\xe5L\xe5\xcd\xe5\xa4\xe6\xe3\xe7\xd0\xe9\xf4\xec\x1c\xf1#\xf5Z\xf9\x82\xfdo\x01\xec\x04\xcd\x07u\nP\x0c\x8c\ra\x0e\xbd\x0e\xb9\x0f\xd9\x11\xee\x14\x8b\x18\xdf\x1c\x8d!\xb2%\x8f)\xc6-~1\xf42Q2\x1d1\xec.++\x8a&\xae"\x98\x1e\x8a\x19z\x14\xac\x10\xb7\rB\n~\x06\xfa\x02q\xffV\xfb\xe1\xf6\xec\xf2\xc0\xef\xe0\xec\xd4\xe9\xba\xe7\x03\xe7(\xe7\x98\xe7\xc0\xe8R\xeb\x1c\xee)\xf0\x04\xf2\x81\xf4\xe8\xf6\xce\xf7\xd4\xf76\xf8j\xf8q\xf7\t\xf6}\xf5\x84\xf5\x84\xf4\xfa\xf2)\xf2\xb4\xf1N\xf0\x96\xee\x9f\xed\xc1\xec\xdf\xea\xb6\xe8\xfc\xe7\xf9\xe7C\xe7.\xe7\xee\xe8\xab\xeb\x18\xee\xfa\xf0\x84\xf5\t\xfa[\xfd7\x00r\x03\x0c\x06I\x07y\x080\n\x96\x0bK\x0c}\rX\x0f\xea\x10\xe9\x11\xec\x12\xb9\x13\x86\x13\x91\x12s\x11\xfb\x0f\xa1\r\xb7\n"\x08\xc6\x05\x9b\x03\xeb\x01\xba\x00\xfd\xffz\xffp\xff\xb6\xff\xf4\xff!\x00\xf3\xff^\xffy\xfek\xfd\x93\xfc\xcd\xfb\xdd\xfa\x14\xfa\x96\xf9L\xf9\xd1\xf8i\xf8Y\xf8\xf8\xf7\x9f\xf6\xd6\xf4\x98\xf3Z\xf2w\xf0\xd8\xee_\xee\xae\xee\x18\xefZ\xf0\xeb\xf2\xd7\xf5\x01\xf8\xba\xf9\x91\xfb&\xfd\xdf\xfd<\xfe\xe5\xfe\x00\x00\xfd\x00G\x02\x01\x04s\x06B\t\x87\x0b\xb8\r\x00\x10*\x12\xbf\x13~\x14C\x158\x16\xf7\x16Q\x17,\x18\xd5\x194\x1b\xcb\x1bS\x1c"\x1d8\x1d\x10\x1cv\x1a\xd0\x18Z\x16\x1c\x13u\x10}\x0ex\x0cq\n\x01\t\xeb\x07z\x06\xe2\x04\xa3\x03#\x02\xfb\xff\xc1\xfd\xfe\xfb\'\xfa\x0f\xf8@\xf6\x98\xf4\x0c\xf3\xa5\xf1\x88\xf0\xf2\xef\x89\xefA\xef\x14\xef\x11\xef\xfd\xee\xb6\xeeD\xee\xb9\xed\x0b\xedh\xec\xd0\xebO\xeb\xe5\xea\xb9\xea\xc5\xea\xd6\xea\xf7\xeav\xebX\xec4\xed\x16\xeed\xef3\xf1\x02\xf3\xba\xf4\r\xf7\xa3\xf9\xfe\xfb\xf1\xfd\xc9\xff\xe0\x01\xa7\x03\xfe\x044\x06s\x07n\x08\x14\t\xbe\t{\n"\x0bx\x0b\xcb\x0bM\x0c\xa1\x0cg\x0c\n\x0c\xea\x0b\x93\x0b~\nk\t\xcd\x08U\x08u\x07\xae\x06\x9d\x06\x8d\x06\x1d\x06\x94\x05\x8c\x05n\x05\xa8\x04\xb0\x03\xef\x02.\x02\xd6\x00\x98\xff\xc3\xfe\x08\xfe\x05\xfd\xf9\xfbV\xfb\xb3\xfa\xb1\xf9\x89\xf8\x90\xf7\x98\xf6:\xf5\x07\xf4;\xf3\x9d\xf2\x14\xf2\xbb\xf1\x01\xf2z\xf2\xd8\xf2]\xf3\x19\xf4\xda\xf4z\xf5S\xf6u\xf7\x8a\xf8\x8b\xf9\xcd\xfaE\xfc\xb1\xfd\xff\xfeL\x00\x86\x01\xb0\x02\xcc\x03,\x05\x8b\x06\xc4\x07\xd5\x08\xe6\t\xdf\n\x9e\x0bF\x0c\xeb\x0cH\rL\rX\r\x94\r\xc5\r\xdd\r\x03\x0e1\x0e,\x0e\xff\r\xe4\r\xaa\r;\r\xa5\x0c\x0f\x0cy\x0b\xb8\n\xf1\t\\\t\xae\x08\xef\x07#\x07N\x06c\x05E\x04T\x03h\x02b\x01[\x00b\xff\x83\xfe\x94\xfd\x9d\xfc\xce\xfb\xed\xfa\xc6\xf9f\xf8\xfd\xf6\xa9\xf5Z\xf4\x08\xf3\xea\xf1\x11\xf1{\xf0\x0e\xf0\xf2\xef2\xf0\xa7\xf0\x14\xf1~\xf1\x02\xf2\xa1\xf2<\xf3\xd4\xf3\x9b\xf4\x89\xf5\x98\xf6\xc4\xf7\x1d\xf9\x9f\xfa\x08\xfcU\xfd\x95\xfe\xd4\xff\xec\x00\xd4\x01\x8f\x024\x03\xf2\x03\xc3\x04p\x05\x17\x06\xcd\x06\xa0\x07O\x08\xc4\x08.\t\x88\t\xa8\t\x7f\tR\t/\t\x07\t\xad\x08k\x08:\x08\xde\x07q\x07\x11\x07\xb4\x06\x13\x064\x05a\x04{\x03m\x02K\x01c\x00\x93\xff{\xfe<\xfd+\xfc2\xfb%\xfa\x00\xf9\n\xf87\xf7P\xf6\x98\xf5d\xf5\x82\xf5\x9e\xf5\xdb\xf5|\xf6,\xf7\x89\xf7\xd0\xf7.\xf8\xa4\xf8\xb3\xf8\xbe\xf8U\xf9\x1b\xfa\xd3\xfa\xaf\xfb\xf5\xfcE\xfe\x1e\xff\xb5\xffP\x00\xc0\x00\xc7\x00\xa9\x00\xb7\x00\x03\x01+\x01m\x01+\x02A\x03=\x04 \x05?\x06J\x07\x01\x08\x92\x08\x0e\tz\t\xba\t\xe1\t@\n\xb5\n\x04\x0bK\x0b\x8c\x0b\x8b\x0bS\x0b\x02\x0b\x8e\n\xf4\tC\t\x91\x08\xfa\x07;\x07q\x06\xc7\x05"\x05N\x04K\x03>\x02\x14\x01\xd5\xff\x92\xfe`\xfdF\xfc.\xfb)\xfah\xf9\xb8\xf8\x0f\xf8x\xf7\xf8\xf6o\xf6\xe3\xf5j\xf5\r\xf5\xc7\xf4\x9d\xf4\xa9\xf4\xfe\xf4i\xf5\xea\xf5\x85\xf60\xf7\xdf\xf7\x86\xf8.\xf9\xc7\xf9e\xfa\x0c\xfb\xbb\xfbr\xfc\x18\xfd\xcb\xfd\x8c\xfeX\xff\x0c\x00\xb3\x00m\x01&\x02\xea\x02\xbb\x03\x88\x04O\x05\xef\x05_\x06\xb8\x06\x02\x07"\x07 \x07\x19\x07\x13\x07\x1b\x07\x0e\x07\x15\x078\x07I\x07D\x075\x07!\x07\xf9\x06\x9e\x063\x06\xcc\x059\x05\x93\x04\xdf\x03$\x03Z\x02c\x01x\x00\x88\xff\x93\xfe\x89\xfdw\xfc\x7f\xfb\x86\xfa\xa1\xf9\xce\xf8\x1c\xf8\x98\xf7D\xf7$\xf7\'\xf7L\xf7\x9a\xf7\r\xf8\xa0\xf8*\xf9\xb0\xf9&\xfa\x8b\xfa\xdd\xfa8\xfb\x93\xfb\xef\xfbH\xfc\x90\xfc\xd8\xfc\x13\xfdN\xfd\x8c\xfd\xcc\xfd\n\xfeQ\xfe\xa0\xfe\xfd\xfe^\xff\xdd\xff\x94\x00P\x01\x02\x02\xba\x02{\x03,\x04\xd0\x04x\x05\x1d\x06\xa5\x06\xfa\x06D\x07\x86\x07\xa3\x07\xbb\x07\xcb\x07\xc9\x07\xb4\x07z\x07P\x073\x07\xfd\x06\xa3\x06O\x06\xe5\x05^\x05\xd8\x04S\x04\xba\x03\x1f\x03o\x02\xa8\x01\xea\x00\x15\x00S\xff\x97\xfe\xe0\xfd6\xfd\x9a\xfc\r\xfc\x8e\xfb\x1a\xfb\xb9\xfal\xfa\x1f\xfa\xd7\xf9\x97\xf9Z\xf9\x1c\xf9\xe0\xf8\xc1\xf8\xa2\xf8\x8b\xf8~\xf8\x91\xf8\xb8\xf8\xdd\xf8&\xf9}\xf9\xd8\xf9:\xfa\xa6\xfa\x1c\xfb\x99\xfb\x16\xfc\xa9\xfcP\xfd\xfe\xfd\xcb\xfe\xb3\xff\xa5\x00\xa0\x01\x93\x02p\x03=\x04\xed\x04|\x05\xe2\x05(\x06`\x06\x87\x06\xa8\x06\xc1\x06\xd5\x06\xd7\x06\xde\x06\xf4\x06\xec\x06\xcd\x06\xa0\x06m\x06+\x06\xd1\x05\x87\x05=\x05\xdf\x04f\x04\xed\x03\x8c\x03\x16\x03z\x02\xdc\x01,\x01t\x00\xa2\xff\xca\xfe\xfc\xfd/\xfdc\xfc\xae\xfb\x11\xfb\x83\xfa\r\xfa\xae\xf9d\xf9&\xf9\x05\xf9\xf4\xf8\xf8\xf8\x05\xf9\x10\xf97\xf9r\xf9\xb8\xf9\x04\xfaI\xfa\xa9\xfa\x11\xfb\x81\xfb\xfd\xfbu\xfc\x01\xfd\x8a\xfd\x17\xfe\x99\xfe\x15\xff\x98\xff\x0c\x00{\x00\xe9\x00a\x01\xd1\x010\x02\x94\x02\x07\x03l\x03\xb9\x03\x0e\x04z\x04\xda\x04/\x05\x8d\x05\xec\x05D\x06\x7f\x06\xb3\x06\xe4\x06\xfe\x06\xf7\x06\xe4\x06\xc7\x06\xa4\x06|\x06U\x06"\x06\xf4\x05\xc1\x05\x86\x057\x05\xd4\x04V\x04\xcd\x033\x03u\x02\xb3\x01\xe3\x00\r\x00.\xffU\xfe\x84\xfd\xbb\xfc\xeb\xfb&\xfb\x86\xfa\xe9\xf9X\xf9\xd7\xf8[\xf8\xfb\xf7\x98\xf7V\xf7,\xf7\x00\xf7\xf2\xf6\x10\xf7=\xf7\x88\xf7\xea\xf7u\xf8)\xf9\xdb\xf9\x8b\xfaD\xfb\xfe\xfb\xba\xfc`\xfd\xf5\xfd\x9c\xfeR\xff\x03\x00\x96\x00c\x01U\x02)\x03\xf8\x03\xc3\x04e\x05\xc1\x05\x01\x06\x1a\x06\'\x06#\x06\x03\x06\xe8\x05\xf8\x05\xf8\x05\xe2\x05\xd9\x05\xc4\x05\xab\x05q\x054\x05\xe7\x04~\x04\xe7\x03e\x03\xf4\x02y\x02\x04\x02\xa6\x01F\x01\xe5\x00\x81\x00\x12\x00\xb3\xff/\xff\x9b\xfe\x01\xfee\xfd\xda\xfcS\xfc\xdb\xfb\x80\xfb.\xfb\xfc\xfa\xe3\xfa\xc5\xfa\xa8\xfa\x94\xfa\x87\xfax\xfa_\xfaT\xfaU\xfaU\xfar\xfa\xbc\xfa\x1a\xfb\x8c\xfb\xfc\xfb\x82\xfc\xfa\xfcl\xfd\xdb\xfdD\xfe\xac\xfe\xff\xfe]\xff\xc4\xff+\x00\x9b\x00\n\x01~\x01\xe2\x01I\x02\x8d\x02\xba\x02\xed\x02\x18\x03T\x03\x86\x03\xbe\x03\xea\x03\x10\x049\x04\\\x04m\x04a\x04e\x04_\x04S\x04i\x04\x8e\x04\xd0\x04\xf9\x04/\x05n\x05\x84\x05\x83\x05g\x054\x05\xe6\x04k\x04\xdf\x03O\x03\xad\x02\xf9\x015\x01~\x00\xaf\xff\xca\xfe\xea\xfd\x15\xfdG\xfc\x84\xfb\xdd\xfa@\xfa\xc5\xf9a\xf9\x1d\xf9\xf0\xf8\xd4\xf8\xe2\xf8\x03\xf9C\xf9\x92\xf9\xe1\xf9d\xfa\xe4\xfaj\xfb\xee\xfbf\xfc\xe1\xfc6\xfd\x97\xfd\xf3\xfdQ\xfe\xc0\xfe9\xff\xb6\xff>\x00\xe9\x00\x8f\x017\x02\xd0\x02L\x03\xbe\x03\xf7\x03 \x04A\x045\x049\x041\x04&\x04\x1c\x04\x04\x04\xde\x03\xc7\x03\x95\x03I\x03\xfd\x02\xa7\x02\\\x02\xf6\x01\xa3\x01y\x01J\x01\x0e\x01\xd8\x00\xd3\x00\x01\x01\xe9\x00\xc0\x00\xb8\x00\xb3\x00\x93\x00M\x00&\x00$\x00\xf0\xff\x9b\xffW\xff\x10\xff\xac\xfe\x14\xfe\x9f\xfdh\xfd\x18\xfd\xac\xfcd\xfcD\xfc\x17\xfc\xe7\xfb\xb9\xfb\x8d\xfb\x99\xfb\xb3\xfb\xc0\xfb\xfd\xfb\x17\xfc\xea\xfb\xc3\xfb\x99\xfb\xbd\xfb%\xfc\xaa\xfc\x16\xfd\x19\xfdh\xfd\x0e\xfe\xa2\xfec\xff\xc9\xff~\x00\x02\x01\x02\x02\xc3\x02\xb7\x02"\x02\x1b\x02\t\x038\x05"\rs\x1a\xb6\x1f\xd3\x13\xf8\x03\xf8\xfd\xba\xfb~\xf5a\xf50\x00c\x0c\xda\x0c\xec\x06\x02\x04\xcd\x01\xcb\xfa6\xf1\x99\xf03\xf8V\xff\xe0\x01H\x06g\x0b\xf0\t\xe8\x01b\xfbU\xf9\xd6\xf86\xf9\xc8\xfc\x03\x02|\x05\x9f\x06\xf4\x03l\xff\xbd\xf9\x05\xf7\xba\xf4\x14\xf7]\xfc\x97\x02\x19\x04\x01\x01&\xff\xbd\xfb\xc0\xf8A\xf6[\xf9\xaf\xfd\x83\x01b\x02\xe3\x01\x80\x00&\xfd\x83\xfa\x9b\xf96\xfb\x8b\xfd\xa6\x01\x1b\x06\xd9\x06\xdc\x02\x80\xfe\x1a\xfdM\xfc\xe5\xfb3\xff\xf0\x04>\x08b\x06\x9d\x03\xd0\x02\xfd\x00\xb1\xfd\'\xfc\x85\x00\xec\x05t\x07C\x06\x87\x078\tJ\x04\xb7\xfd\xcf\xfd\xd7\x02\xeb\x04\x88\x03\xba\x03\x84\x05\xad\x01\x84\xfc.\xfa\x1b\xfc\xd1\xfef\xfe\xed\xfew\xfe\x9c\xff9\x01B\x01\xd3\xfd\xc5\xf9\x96\xfb]\xfd=\xfd\x17\xfc&\xfe\xbb\xfe\x86\xfb\xa5\xfb\x89\x00\xf5\x02\xbe\xfe\xa2\xfcX\x00\x81\x02\x13\xfe\xae\xfa\xd5\xfe]\x05\xa6\x04S\xfd\xa1\xf8.\xf9\xee\xfb\xd7\xfbg\xfc\x0e\x01F\x02\x96\xff\xf1\xfa_\xfb\x17\xfeX\xff\x1f\x00;\x00c\x04\x97\x02\x8e\xff\x8b\xfc\x1e\x00\x87\x01\xd5\xfa\xe4\xf7\xa4\xfdQ\t3\t\xad\x04S\x02\xf0\x03\xed\x00\x99\xfc6\xfe{\x04\x8a\t\xf1\t\xb9\x07>\x06N\x05\xac\x04\xea\x03\x8d\x01H\x01\x19\x03\x86\x04x\x05\xfc\x07\x1b\x07\xc4\x02Y\xfcq\xfa\x00\xfd\'\x02Q\x05Q\x02,\xff{\xfa\xd8\xf5G\xf5\xcf\xfb&\xff\x98\xf7\x18\xf1s\xf8R\x039\x01\x98\xfa\xf0\xfd7\x01H\xfa\xc2\xf3\xa7\xfb6\x06\n\x03o\x00\x90\x046\x05\xb7\xf9\xf5\xf5\x9c\x02\x91\n\xce\x06,\x01\x1e\x042\x01E\xfb\x8f\xfe:\x07:\x07d\xf9\xe4\xf6Q\xfc\xc7\xffo\xfdE\xfau\xfc\x9d\xf8N\xf5\xd5\xf9*\x00\x80\xffW\xfb\x0b\xfd\xe0\x00F\xfe\xb2\xfcN\x00F\x07T\x0b\x92\rt\x10\xc5\x0e\xfc\x08\xa1\x06\xe2\x0b\xac\x0c\x92\t\xb7\t\x1c\tg\x05\xed\x03\xe2\x00\xd9\xf9\xc0\xf4\xff\xf1\x05\xf5\x92\xf5\xc7\xf4x\xf7\x9a\xf5\x80\xf1u\xeez\xf0\xb3\xf2\'\xf5\x05\xfcV\x03^\x02\x00\xfc\xb8\xfdk\x02\x9c\x03\x07\x05a\nc\r\xd9\x0bD\t\xeb\tV\nu\x08\x94\x08\x8c\x08A\x04U\x00L\x01d\x01\x88\xfd\x87\xf8\xd1\xf9\xc9\xf8i\xf5\x94\xf4;\xf7;\xf9\xe0\xf5\x17\xf5\xce\xf8\x91\xfd6\xfe\xe8\x00^\x05A\x05\xc4\x01\x9b\x01T\x07/\x0b\xd2\x08\x14\t\x88\r,\r\x1a\x07\xec\x04\n\x08\xac\x04\x03\x00m\x03O\tr\x05\xbc\xfc,\xfd\xf8\xfc\x8a\xf9S\xfa\xbe\xfe\x8e\xff|\xfba\xf9\xa9\xf7?\xf6\x00\xf7\xcb\xf9>\xfd\xc5\xfc\xea\xf9\x19\xfb\x17\xfc\x9c\xfd\xa7\xfc\n\xfc*\xff\xe6\x006\x02\xa4\x04\xdf\x05\xe3\x00\x85\xfc\xcb\x00Z\x04\xa4\x03\xd6\x03\x95\x06s\x04\xeb\xfc#\xfd\x8a\x02.\x00\x10\xfe\x00\x01D\x03\xcd\x01U\xfe\xf8\xff\xd2\xfe\x81\xfc]\xfe7\x02\xbd\xfe(\xfa,\x00\xe8\x04(\x04\xd1\x00(\x00M\x02\xf2\xfe(\xfb\xac\xfe\x17\x02U\x00|\xff\x1d\x05\xe9\x05\x17\xff;\xfb\xf0\xfd\xc6\x019\x01\xa2\x02"\x05~\x02\x88\xfe\xe3\xfc\xba\x00\x08\x02\x90\x01\xea\x01\xaa\x00)\xff=\xffR\x003\xfe\x12\xfd\xc2\xfe#\x01\x0f\x00|\xff\xe7\x00J\xff\xcc\xfa\x9c\xfa\xcf\xff\xd7\xffM\xfc\x90\xfd\xf9\x00@\xfe\xf2\xf9\xbb\xfb\xd8\xfe\xd0\xfeJ\xfc\x89\xfeP\x02(\xffk\xfd\xf7\xfe/\x03\x1b\x02y\xfe\xbe\x008\x04W\x03&\xff\xfa\xff\xb1\x00;\xff\xae\x00f\x05\x01\x06\xdc\x02\x16\x02\xa6\xffA\xfb\x9a\xfd\xab\x06\xad\x08\xca\x02-\xff\xb4\xfeF\xfc\xd7\xfa\xc3\xff\xf3\x04\xa5\x02e\xfd4\xfe\x0e\x01\x80\xff\x90\xfd6\xfd\xbb\xfc\x0b\xfdF\x01g\x04\xda\x00\x08\xfcA\xfd\xfb\xff[\xfeO\x00\x19\x05b\x03\x89\xfb_\xfb\xc6\x02\xb8\x00\xd2\xfc\xb6\xfdL\x04;\x03\xc2\xfcE\xff\xf7\x00\xaa\xff\x05\xff\xe6\x01W\x04\xc6\x00\x81\x01a\x01\xd2\xfe\xf9\xff\xda\x02$\x03d\xfe\x10\xff@\x016\x00\xbb\xfdr\xfc\xad\xff8\x00\x82\x00X\x00\xef\xff\xa6\xfd?\xfd\x8c\x00\xd6\x00x\x01\xfd\x00\xd8\x02\xc3\x02\xe9\xfez\x01]\x03\x81\x01\x0b\x00R\x03l\x06D\x01\x14\xfe\xce\xffV\x00\x19\x00\xd7\x00I\x04\xa2\x01Y\xfc\xcb\xfa\xf1\xfdQ\x02\x85\x00b\xffy\xfe\x07\xff\xa8\xff%\xff\xfb\xfdG\xfdr\xfdU\xfd\xbb\xfe0\x00^\x00L\xfd\xd6\xf9Z\xf9\xf1\xfb|\xfeF\x00n\xff\xa7\xfe\xe5\xfa%\xf8\xeb\xf9\xd1\xfd\x80\x00x\xff\xf0\xfe\x8a\xfd\xa1\xfcS\xf9\x9b\xf8\xcb\xfc\x00\x00\xba\xffr\xfd^\xfdO\xfd\xba\xfb\xe0\xfa\xf4\xfcu\xff\xc9\xfe\x1a\xfdM\xffk\x02\xf7\x00\x1d\xff#\xfe\xa6\xfe\xcf\xfe\xd7\xff2\x04*\x06O\x03\xb9\xffn\x00\xd3\x03\xb1\x06\xdb\x05\xd2\x05\xc5\x054\x06[\x07\xc6\x08\x89\t\xe9\x07"\x06\x8c\x05-\x07t\x07\n\tR\x05\xe4\x00\x08\x01\xc5\x03\x94\x07\xbc\x05w\x05\xb2\x05\x8a\x04\xbb\x04v\x08\xa0\x0f\xf1\x10%\r\x0c\r\xdf\x0f\xf6\x11\x1d\x11H\x11\x9e\x12\x84\x10\xd8\x0c\x17\x0cv\x0e)\x0c\xb0\x06t\x02)\x01\'\x00\x03\xfd\xd7\xfb\xfc\xfa\x1d\xf7L\xf2\xe5\xef\x99\xef\x8b\xee0\xec\xa0\xebP\xeb-\xea\xac\xe9\xf2\xe9\x99\xea\x0f\xea\xf6\xe9H\xebe\xed\xb1\xef\xbf\xf1\xe7\xf2P\xf3]\xf3\x85\xf4\x03\xf6q\xf7\x0f\xf9\xdc\xf9w\xf9\xb7\xf8C\xf8\xa5\xf7\xda\xf6k\xf6\xbd\xf5B\xf5\xe5\xf4u\xf3\xa4\xf2X\xf1\x1a\xf0\x9d\xef\x8c\xef(\xf0\xdf\xf0]\xf1u\xf11\xf2\xd3\xf2\x00\xf4\\\xf6\xf4\xf8"\xfa\x9a\xfb\xda\xfc\xd6\xff5\x02\xa4\x04\xe3\t\x0c\n\x98\t\x10\x0b.\x0e\x14\x12\x8b\x12\x87\x14\x9f\x14D\x12\xc2\x10\xdb\x10\x08\x16\xdd\x1c\xa0%f&\x8e\x1e^\x1b\x1c#U/\x020e)\x89\'\xc4)\xd8-)1(4\xd1/\xa9#\xa7\x19k\x17~\x1aZ\x1a4\x16}\r\xd7\x03\x94\xfc\xce\xf8\xfb\xf5Z\xf1*\xec\xc9\xe6\x18\xe3\xc5\xe2\x05\xe4X\xe3\xb7\xdf\xe0\xdc\r\xdeT\xe0{\xe3\xd7\xe5\x85\xe7\xe8\xe7\x08\xe8\xff\xea,\xf1&\xf6\xd2\xf6}\xf3\x0f\xf1\xc5\xf5L\xfe=\x01\xdd\xffw\xfc]\xf9O\xf9\x93\xf9\xe0\xfd\x97\x009\xfd\x9f\xf5\xdb\xf2)\xf5\x0f\xf73\xf7?\xf4\x19\xf2m\xef\x13\xefO\xf2|\xf5>\xf5\xdd\xf1b\xef\xbb\xef\xb8\xf3%\xf8\x8c\xfa\xeb\xf9\xac\xf6\x12\xf5v\xf50\xf7\xec\xf9\x07\xfb}\xfa\x04\xf9\x94\xf8Z\xf8\xf3\xf8\xbb\xf8Z\xf8d\xf81\xf9\xa1\xfb+\xfdL\xfd\x06\xfcp\xfa\xa2\xfa(\xfd\x88\x00\x99\x033\x037\x02y\x01\xec\x02\\\x06\xa1\x08\x13\n\xc6\x0b1\r\xf4\r\xbf\x0e\xe6\x0e\xe4\rB\x0f\xf0\x16\xc5!>\'\xdb#\xf0\x1f\xec\x1eU"\x9f\'\xfe+R/\xb8.f,\x13+\x06*\n(i#q\x1d\xe7\x18\x15\x16b\x16\xd4\x15\x18\x11l\x08\x8c\xff\xbb\xf9\xc5\xf6\xc3\xf44\xf2\x17\xf0c\xec\x10\xe8b\xe6\xa9\xe6\xb4\xe7\n\xe6\xe9\xe2\xaa\xe1\xef\xe2h\xe6K\xea\xfa\xec\xd6\xed%\xed\xf8\xec\xc3\xefK\xf3\x7f\xf4T\xf44\xf4\x8a\xf6S\xf9\x8c\xfaU\xfa\xc9\xf8\x10\xf7\x87\xf5*\xf6\xdf\xf8\r\xfa\xbc\xf9;\xf8d\xf7\xed\xf6\x01\xf6\x8d\xf53\xf5\xf2\xf5\x08\xf7\xab\xf7\xcf\xf7\x80\xf7s\xf7\x9f\xf7\xea\xf7\r\xf8\xfd\xf8.\xfa;\xfb-\xfc\x87\xfc\xe4\xfcd\xfd\x12\xfe.\xff;\x00\xe7\x00d\x01\xde\x01\xf8\x01q\x02\xe9\x02\x18\x03-\x03\x9e\x02j\x02\xef\x02\xee\x02@\x02\xae\x01\xe2\x01h\x025\x02\xb4\x01\xc3\x01\x16\x02\xce\x01\xf8\x00"\x01\xa5\x01\xdd\x01i\x01\x84\x00\xe0\xff\xbb\xff\xb1\xffR\xff\xad\xff\xea\xff\x91\xff\x85\xff\x15\x00\x88\x01>\x03\xe5\x04O\x06\xae\x07P\t\x01\x0bt\r\xbc\x10}\x13\xd3\x15\xc3\x16\xab\x17\xeb\x18\xec\x19A\x1b\xb8\x1b\xe7\x1bX\x1b\'\x19\xb5\x17u\x16g\x14P\x126\x0f7\x0c\xa5\t\xab\x06+\x04\xa8\x01:\xff=\xfc\xc6\xf9\xcd\xf7\x18\xf6\xe2\xf4B\xf3\xf9\xf1\xdf\xf0\x8f\xef\xd4\xee\x99\xee\xee\xee1\xef\x8d\xeef\xee\xed\xee\x9f\xef~\xf0B\xf1B\xf2\x1d\xf3\'\xf3Q\xf3q\xf4\xb0\xf5\xe2\xf6\xe2\xf7\x9f\xf8Q\xf9\x13\xfa\xbb\xfa\xbb\xfb5\xfc8\xfc\xe8\xfc\xad\xfd\'\xfez\xfeD\xfe\xe7\xfdv\xfd\xcf\xfc\xd6\xfc\x05\xfd\x92\xfc/\xfc\xde\xfb\x1a\xfb\xc4\xfa\xd1\xfa\x84\xfar\xfa\x06\xfa\x8d\xf9\xb1\xf9\x91\xf9M\xf9\x83\xf9g\xf9,\xf9H\xf9G\xf96\xf9F\xf9\xe2\xf8\xd0\xf8+\xf9\x89\xf9\xfe\xf9`\xfa\xa0\xfa\xee\xfa\xfe\xfa\xfa\xfa\x9e\xfb\x94\xfc\x00\xfd\x0e\xfd;\xfd`\xfd\x8e\xfd\x01\xfeG\xfe\xbc\xfe<\xff\x08\x00r\x01\xf2\x02\x01\x05E\x08\x07\x0c\xdd\x0e\x80\x11\xdc\x14\xbc\x18y\x1cL\x1f\n"N%\xc6\'p(\x80(\xe0(\x93(\x80&\xd5#\xec!\xb9\x1f\xb8\x1b"\x17\x8e\x13$\x10\xa3\x0b\xc3\x061\x03\x08\x01\x1a\xfes\xfa\xd9\xf7=\xf6q\xf4l\xf2.\xf1+\xf1\xa9\xf0$\xefm\xee!\xef\xa1\xef\x8a\xef]\xef\x8d\xef\xc7\xefm\xef"\xef\xba\xef\'\xf0\x08\xf0\xd5\xef=\xf0\r\xf1`\xf1\xd3\xf1z\xf2$\xf3\xc6\xf3\x1a\xf4\xf2\xf4\x10\xf6\x00\xf7\xcc\xf7n\xf8\xd3\xf8Q\xf9\xef\xf9\x81\xfa\xfd\xfa5\xfb_\xfb\xb6\xfb\xf3\xfb\xe2\xfb\xce\xfb\xd2\xfb\x8b\xfb\x8a\xfb\xcf\xfb8\xfc\xa2\xfc\xbe\xfc\xe4\xfc\x1c\xfdV\xfd\x80\xfd\xb6\xfd\x14\xfe[\xfe\x80\xfek\xfeR\xfej\xfet\xfey\xfe\x81\xfep\xfe^\xfe,\xfe\x0f\xfeG\xfe\xa2\xfe\xbc\xfe\xcc\xfe\xaf\xfe\x93\xfe\x9d\xfeR\xfe8\xfee\xfe~\xfe\xc9\xfe\x14\xff&\xff\x1b\xff\xe4\xfe\x99\xfe\xfd\xfep\x00A\x03\x83\x06v\x08\x13\n\x87\x0c;\x0f\xe3\x11\x93\x14i\x18W\x1c\x0f\x1e\xbe\x1e< \x0b"\t#\xed!\xaf v |\x1e\x0e\x1bY\x18G\x16\xf6\x13\x83\x0f\xb2\nt\x08\x90\x06\xc8\x02\x11\xff\xd1\xfc\xbb\xfb\xaa\xf9W\xf7\xd8\xf6\x1e\xf7\xd0\xf5H\xf3\x97\xf2s\xf3\xc8\xf3_\xf3A\xf3\x07\xf4\xbf\xf3j\xf2\x1a\xf2i\xf2]\xf2\x97\xf11\xf1\xf7\xf1,\xf2\xd4\xf1\xc0\xf1\x1c\xf2\'\xf2\x0c\xf2\x97\xf2\xa5\xf3G\xf4q\xf4\xd0\xf4\x7f\xf5%\xf6\xb9\xf6\x08\xf7C\xf7}\xf7\x96\xf7\xe5\xf7M\xf8\xa4\xf8\xfa\xf8\xe9\xf8\xb8\xf8\xd9\xf8\'\xf9Z\xf9\x8f\xf9\x16\xfa\xb2\xfa\x13\xfb\x9d\xfb\x87\xfc[\xfd\xb1\xfd\xed\xfd\x87\xfei\xff\x05\x00R\x00\xbd\x00\x12\x01 \x01+\x01\x80\x01\xc1\x01u\x01\xe8\x00l\x00\xc2\x00\x14\x01\x06\x01\x01\x01\xa9\x00\x0b\x00\xa0\xff\xb7\xff\x04\x00\x1d\x00\xb0\xffA\xffA\xff\xea\xfe\xc6\xfe\xe2\xff\xe5\x00\xcc\x00\x8a\x00%\x01\x04\x03\x8a\x05/\x08S\x0b\x01\x0e\x0c\x0fG\x10\x0b\x13|\x16"\x19\xf7\x1a\xaf\x1c\x19\x1e\xe4\x1d\x1e\x1d\x80\x1d\x1f\x1e\xee\x1c\xfb\x19:\x17I\x15\xb3\x12\x86\x0f\xb1\x0c\n\n\x84\x06S\x028\xff\xba\xfd>\xfc\xd8\xf9I\xf7\xd4\xf5\xf4\xf4-\xf4\x99\xf3\xa3\xf3\xc7\xf3\xfc\xf2\x11\xf2k\xf2\xb9\xf3\xdb\xf4\x15\xf5,\xf5\xf2\xf56\xf6\xbb\xf5\x82\xf5\xe5\xf5j\xf6\x02\xf6V\xf5\xce\xf5Q\xf6\xc4\xf5\xaa\xf4J\xf4\xcc\xf4\xdb\xf4r\xf4\xac\xf4\x85\xf5\x7f\xf5\xb6\xf4\xa4\xf4q\xf5"\xf66\xf6\x85\xf6Q\xf7\xf1\xf7F\xf8\xb5\xf8\x90\xf9O\xfa\xb5\xfa\xe5\xfar\xfb1\xfc\xae\xfc4\xfd\xdb\xfd\x9c\xfe.\xffY\xff\x8d\xff\xc4\xff\x06\x00J\x00\x92\x00\x13\x01\x8a\x01\xa2\x01\x93\x01\x80\x01\xa1\x01\xa9\x01p\x01i\x01\x95\x01\xac\x01\xbe\x01\xd0\x01\xfd\x01\xda\x01c\x01\xf9\x00\xde\x00\xf1\x00\xee\x00\xf4\x00\xc9\x00V\x00\xf0\xff\xc5\xff\xc2\xff\xf4\xff\x0b\x00&\x00s\x00p\x00\x9e\x00\xd5\x01\xc3\x03\xc8\x05\xca\x07y\t\x11\x0b1\r\xc7\x0f\xa1\x12\xeb\x14\xf5\x15Y\x17\x0f\x19>\x1a<\x1b\x98\x1b}\x1bU\x1a\t\x18\x95\x16\xba\x156\x14\xa1\x11\x88\x0e\xbe\x0b\'\tt\x06N\x04\x81\x02I\x00|\xfdB\xfbL\xfa\xed\xf97\xf9\x10\xf8\x18\xf7\x81\xf6\xe2\xf5\x9e\xf5\xf3\xf5n\xf6S\xf6\x91\xf5l\xf5\x18\xf6q\xf6\x18\xf6\x86\xf5c\xf5<\xf5\xbd\xf4\x96\xf4\xe3\xf4\xbf\xf4\xec\xf3?\xf3U\xf3\xc6\xf3\xbc\xf3s\xf3u\xf3\x98\xf3\xdd\xf3L\xf4\xf8\xf4O\xf5V\xf5i\xf5\xc7\xf5\xa3\xf6\x81\xf7G\xf8\xd2\xf8\t\xf9Z\xf9\xd8\xf9{\xfa\xfb\xfa\x94\xfb\n\xfc=\xfc\x8f\xfc\t\xfd\x96\xfd\xcc\xfd\xb2\xfd\xcf\xfdI\xfe\xd5\xfec\xff\xe7\xff\x0e\x00<\x00j\x00\xd8\x00\x87\x01\x0e\x02J\x02n\x02\xa1\x02\xf6\x029\x03;\x03L\x03L\x03\xf9\x02\x8f\x02\x90\x02\xc2\x02\x8b\x02\xf8\x01p\x01s\x01M\x01\xa3\x007\x00\xf3\xff\xc8\xff\xc4\xff\xb4\xff\xf0\xff\xd8\xff^\xffq\xff\x05\x00\n\x01Q\x02\x1b\x045\x06i\x07\x15\x08\x9b\t(\x0c\x99\x0e\xc5\x10\xec\x12\xda\x14\xdb\x15)\x16\xdf\x16\x1a\x18\xc5\x18\x7f\x18\xaf\x17\xa2\x16\x18\x15F\x13}\x11\x00\x10U\x0e\xf4\x0b\n\tU\x06E\x04X\x02%\x00.\xfe\xae\xfcX\xfb}\xf9\xda\xf7,\xf7\xac\xf6\x87\xf53\xf4\x94\xf3e\xf3\xbf\xf2\n\xf2 \xf2H\xf2\xb0\xf1\xf1\xf0\xd5\xf09\xf1\x1c\xf1\xc0\xf0#\xf1\xba\xf1\xbe\xf1\xac\xf1@\xf2[\xf3\xeb\xf3\x15\xf4\xcf\xf4\xd5\xf5\\\xf6\xb3\xf6\x95\xf7\xbe\xf8_\xf9\x92\xf9\xf5\xf9\xb4\xfa\x14\xfb2\xfb\xac\xfbO\xfc\x81\xfcq\xfc\x8a\xfc\xe2\xfc\xf6\xfc\xbf\xfc\xd7\xfc6\xfdt\xfd\x87\xfd\xb5\xfd\xf3\xfd0\xfeh\xfe\xdb\xfe\x7f\xff\x08\x00\x8e\x00\r\x01\xb7\x01R\x02\x08\x03\xbe\x03E\x04\xbd\x048\x05\x98\x05\xe4\x05V\x06\xd6\x06\xe7\x06t\x069\x06T\x06\r\x06N\x05\xdc\x04\xb9\x04j\x04\x9b\x03\xae\x02%\x02\x15\x02\xe3\x01+\x01\x00\x00/\xff"\xff\xc0\xfew\xfe:\xfe\x18\xfem\xfe\xa1\xfed\xfe\xb8\xfe\xb8\xff\xb1\x00\xf8\x00!\x01\x8a\x02Y\x04a\x05\xc3\x06\xe6\x08\x93\n\xfa\na\x0b\xf8\x0c\xd2\x0e\xb2\x0f\x01\x10\xcd\x10\x9e\x11Y\x11\xb2\x10\x9e\x10\xc3\x10\x11\x10\x80\x0eM\r\x82\x0cD\x0b\xa4\t\xfc\x07\x8d\x06\xc5\x04\xc2\x02\x00\x01\xb5\xffz\xfe\xe2\xfc4\xfb\xca\xf9\x9b\xf8\x94\xf7\x90\xf6\xc6\xf5!\xf5T\xf4\xc8\xf3|\xf35\xf3&\xf3#\xf38\xf3A\xf3O\xf3\xb2\xf3+\xf4w\xf4\xc8\xf4H\xf5\xec\xf5p\xf6\xbb\xf6\x19\xf7\x99\xf7\x04\xf8j\xf8\x0c\xf9\xc6\xf90\xfaa\xfa\x90\xfa\xdd\xfa!\xfbI\xfb\x82\xfb\xd4\xfb\x16\xfc=\xfcU\xfc\x83\xfc\xa6\xfc\xae\xfc\x9f\xfc\xb6\xfc1\xfd\xb5\xfd-\xfe\x9f\xfe\xde\xfe\xf5\xfe!\xff\x9a\xffr\x007\x01\xc6\x01&\x02s\x02\n\x03\x94\x03\t\x04r\x04\xda\x04\x1f\x052\x05y\x05\xff\x050\x06\xd2\x05a\x05V\x05w\x05!\x05\xb6\x04`\x04\xa2\x03\xd5\x029\x02+\x02\xfa\x01\x16\x01x\x00Q\x00\xe3\xff>\xff\x0b\xffr\xff\x88\xff\x05\xff\xb1\xfe\r\xff{\xff\xb6\xff\x1f\x00L\x00\x9e\x00\xd8\x00\x1b\x01\x14\x02\xae\x02\xec\x02)\x03\x87\x03\xac\x03\x08\x04\xa8\x04`\x05\xe9\x05\xef\x05\xec\x05,\x06\x18\x06\x12\x06\x01\x06\xc2\x05\xc2\x05M\x05\xa4\x04\x06\x04v\x03\x0c\x03t\x02\x9a\x01\x01\x01\xa2\x00\xcc\xffY\xfe\xa0\xfd\xba\xfd\xaa\xfd\xde\xfc\x1c\xfc\r\xfc[\xfbB\xfa\xeb\xf9\x98\xfa#\xfb\xe7\xfa\xcb\xfa\xee\xfa\xe0\xfa\xcb\xfa9\xfb\xe7\xfb\x83\xfc\x88\xfc\xc0\xfc\xaf\xfd\x8a\xfe\xf2\xfe/\xffo\xff\x86\xff\x9c\xffU\xff\xb5\xffK\x00]\x00e\x00\x1a\x00\xf4\xff\xe6\xff\x99\xff\x99\xffy\xff*\xff!\xff\x11\xff%\xff\xe5\xfe\xb6\xfe\x9b\xfef\xfeD\xfeL\xfe\x98\xfeq\xfe\xcf\xfe\xab\xfe\x8e\xfe\xcf\xfe\xdf\xfe\xb7\xfeV\xff\x9c\xffJ\xff\xa3\xffi\xff\x88\xffV\xff4\xff\x95\xff\xa0\xff\x96\xff\xe9\xfe\xf4\xfe\xc3\xfeV\xfe_\xfe_\xfe8\xff\x80\x00\xca\x02\xb8\x012\xfe\x82\xfc\x89\xfd!\xfei\xffr\x00\xc4\x00l\x004\xfe\xa2\xfe[\xff\x17\xff\x81\xffH\x01\x12\x02Z\x02\x84\x02M\x03\xf5\x03\x1c\x04B\x04Z\x04r\x05\xe9\x05\xdf\x05U\x06\xa3\x06*\x06\xf9\x05\x07\x05.\x05\x02\x05\xed\x03\x7f\x03\xef\x02\xfa\x01G\x01\x08\x01(\x00W\x00\x01\xff\xd9\xfd\x92\xfc^\xfc\xe6\xfc<\xfc+\xfc:\xfcV\xfcw\xfb\x13\xfc\x7f\xfc\x80\xfc\xed\xfc\x87\xfd\xb0\xfd\xca\xfd\x86\xfer\xfe\xb3\xfe\xb6\xfe\x0c\xff \xff\xaf\xff\x92\xff\x18\x00Q\x00\xa0\xff\xc0\xff\r\x00\x92\x00\xd8\x00\x05\x01\\\x01\xc9\x01H\x02\xcd\x02\x98\x02\x13\x03S\x03\x08\x04\xc7\x03N\x044\x04t\x03\xd7\x03%\x03\xd6\x02\x13\x03\xfd\x02E\x02\xbb\x018\x01&\x01%\x00l\xffl\xff\x90\xfe\x1b\xffH\xfe\xd8\xffu\xfe\n\xfdd\xfd\x90\xfc\x05\xfd\xfa\xfb\x0c\xfd\x1c\xfe\xc7\xfdG\xfd\x08\xfdn\xfc\\\xfcI\xfb3\xfd\xae\xfd\x8e\xfe\xcd\xfe\x1f\xfe\x86\xfe\x1b\xfdZ\xfe\xf6\xfd\x05\xff\xc0\xfe\x1f\xff\xc7\xff\x81\xff\xb9\xff#\x00\xee\xff\xba\xff\x9b\xfe\xe5\xfe\x02\x00\xd2\xff1\x00\xd9\x00,\x01\xa8\xff\x02\x00Z\x00\xac\x00u\x00\xc1\x00\x8e\x01V\x01\xdf\x00\x03\x02d\x02,\x01\x9d\x01\xd1\x01/\x02^\x01\xee\x01V\x02\xe5\x01\xec\x01\xb4\x00\xa9\x00\xc3\xffM\x00{\x00\xe1\xff\xb5\xff\xba\xfeh\xfe\x0c\xfe\xd9\xfd1\xfe+\xff\xcc\xfd^\xfd\xb4\xfd,\xfe\xac\xfdB\xfeY\xfe\x8f\xfe,\xfe\xe5\xfc\xe2\xfc\x99\xfd\x05\xfe\xa9\xfd\xf4\xfe\x08\xff0\xfe\xb6\xfc\x9f\xfd\xa5\xfd\x91\xfe\x14\xffg\xff\xcd\xff\x83\x00p\x01\xd5\x00\xe2\xff\x90\x00\x94\x03\xc1\x02$\x03\xbf\x03\xba\x04c\x03\x95\x02\x8e\x03h\x05\x0f\x06\xbf\x046\x03\xa4\x04M\x03,\x02b\x01\x85\x03R\x02\x03\x00\xad\x00\x81\x00\x95\xff\x8e\xfd\xd3\xffs\xff\xcd\xfe\xc5\xfd\x1b\xff\xa9\xfdS\xfem\xfdE\xfeK\xfe2\xfd\r\x00E\xfdy\xff(\xfe@\xfe&\xfdO\xfc.\xfeh\xfe\xe8\xfd\x92\xfe:\xff\xad\xfdK\xfd#\xfe\x83\xfe\x10\xff\x94\x00L\xff\x04\xff\xc8\xff!\xff?\x00\xe7\x00\xe0\x02\xac\x01\x7f\x00\xf5\xff\x00\x01\x9b\x01>\x01\x8d\x02\xdb\x01\xff\x01?\x01p\x01\xb8\x00\xc1\x01\xe4\xff_\x00\xa2\x00\xe8\x01\xfb\x01V\x00/\x02\xe0\xff\t\x006\xff\x83\xff\xd6\xff\xa3\x00\xa4\x00u\x01\x83\x00\xf5\xfe\xc9\xff\x9f\xfe\xe7\xff\x1f\xffW\x00`\x01\xf5\xff\x06\xff\xdc\xfeW\xffh\x00\xf0\x00\x83\xff\xe5\xff^\xfe\x96\xffG\x00\xee\xfe9\xff0\xfee\xffV\xfec\xfe\x9f\xfe|\xfe\xa0\xfe[\xfd\x1b\xfd<\xfeO\xfd\xea\xfe\'\xff\x97\xff\xd3\xffX\xfeO\x01/\x01\xf4\x00\xe6\xff\x9e\x02\xb1\x02\xda\x02\x0b\x05\xc4\x03\x91\x04\xa8\x02G\x02\xc1\x03\x16\x04\xf1\x01a\x03\xff\x01\xdc\x01_\x01\xa2\x01G\xff\xdc\xff\xe8\x00\xca\xfd\xeb\xff\x98\xfd\xf0\xfe\xfd\xfc\x1a\x01\xb0\xfc\x8f\xfd<\xfd\x00\xfdv\xff\x81\xfe\xfd\x00\xe1\xf9\xaf\x008\xff\x89\x00J\x00#\xff\xac\xff\xe0\xfe\xfe\xfe\xba\x01y\x01\xec\xfe\x19\x00\xc5\xff\xc0\x02\xf5\x01\x18\x00\x0c\xfe\xd5\xff\xb4\xfds\x01#\x02\xc4\x00\xd5\xff(\xfe\xf0\xfe2\xff\xf2\xfe\x8f\x005\x00 \xfe\x9a\x01.\xffr\x00\xc1\x00\x93\xff\x89\xff"\x00\x17\x02R\x00\x94\xff\xc9\x01\xa7\xff\xd5\xfe(\x00\xa5\xfd\xdb\xffe\x00\x12\xff\xe8\x01\x9a\xff\x84\xfe}\xff4\xfen\xfd\xed\xfcY\x00#\x03\x1a\x00\x9d\xfe$\x00I\xfc%\xff\n\x00\x7f\x00\xca\xfe\xfa\xff\xd3\x01\xbb\xfd\xbc\xfeO\xff\xe0\x00$\xfe\xd2\xff\xb0\x01\xf6\xfb\n\x01U\x00e\x00\xd3\xfdn\xfdX\x01\x0f\xff\xdf\x00\x06\xff\xad\x02\xaa\xfc\x86\x01`\x01\xba\x01\xdd\xfe\xec\x00\n\x05\xbd\x00\xfd\x01\xb3\xfe\x96\x03`\x02?\x01+\x00\xbe\x02\xbb\x00\x12\xff\xeb\xfec\x02\xf6\x025\xfc8\xfc\x19\x01\xee\xfda\x00L\xfd\x0b\x00Z\xfe1\xfbO\x01\xe8\xfbq\xff\x9f\xfcr\xfe\xb0\xfe\xd7\x00\x7f\x00\xa9\xffJ\xfe\x96\xfdt\xfd\xf3\xfd\x9e\x02)\x01\xc1\xff$\x01\x1d\x01\xbb\xff\x12\xff\xa9\x00G\x03~\x01\xda\xff\xcc\xfc\xcb\x02\x91\x02\x86\x06\x98\x01\xdc\x00\xf5\x00\x89\xfc\x7f\x03\xd7\x00\x08\x03\xdf\x01\x85\x00$\x04F\x00\xba\xfc\x87\x01t\x02B\xff\t\xfff\xfe9\x00\xc0\xfeH\x00:\x02\x01\x02\x91\xfeR\xf9\x99\xff\r\xfe\xee\x00\x17\x00\xc1\xfe\x9b\x00^\xfd\xef\x02\x87\x00\x08\xfe\xb2\x00\xd2\xfd\xba\xfe\xb8\xfe\x94\xff\x04\x02\xf8\xfe\xab\xffA\xff\xac\x03\xb5\xfe\x1d\xfd\n\xfb\xf1\xfb@\x02\xeb\x003\xff\x0b\xff@\x00\xce\xfe\xc6\xfe\x94\xfe\xa3\x04\xe5\xfeT\xfd\x1d\x02e\xfb1\x00\xc4\x04\xbc\x02|\x02 \xff2\x01\xb3\x00)\xff\xb1\xfc\x9d\x00\x94\x04\xf3\x04\\\t\x81\xfb\xb1\xfc\x8e\x01\x1f\x00\xe1\x02\xf6\xfe\x13\x04R\x00\xcb\x00M\x02\xeb\x01h\xfc\x86\xfc\xbf\xff\xae\xfbb\x03>\x01\xa8\xff\x12\xfe\x88\xfc[\xfc\x13\x01\x01\xff\x02\xfb\xb3\x03\x83\x00C\xff\r\x00R\xffn\xfe\xd5\xff\x0b\xfe\x92\xfe\xb3\xfe|\xfd\x9d\xffi\x02s\x00\x17\x02\x04\xfd\x1e\xf8\xed\xfbV\xff\xe1\x03\x96\x04\x95\x04\n\x01\xb8\xfb\xdb\xfc:\xfe@\x00<\x04?\x04r\x01\x85\x00\x8d\x03\x9b\xfd\xd3\x02\xdc\x05"\x00\xf8\x00\x1c\xfe\xc2\x00\xeb\x02\xed\x01\xbe\x02k\x03e\xff\xc1\xfa\x07\xffi\x01h\x00\xc1\xff\xfc\xff~\xfe]\x00\n\x00\xd2\x04\x87\xfbt\xf8\xf6\x02\xc5\xfe!\xfc*\x03\x84\t\xe1\xfe\xc3\xfa*\xfa)\xfc\xb4\xfdw\x02\x14\x05z\x02\xb1\xfe\xfd\x02\x91\xfe\xb8\xf7\xb4\xfe\xb4\xfbg\xfe\xf0\xfe\x1d\x04V\x04\x0e\x02\x1c\x02l\x00\xd1\xf1o\xf0w\xfd\xc3\x07\xd1\x0cI\x08q\x02\xb4\xf7\xb4\xf8\x1c\xf6Z\x01\xd3\x04\xba\x03\xf5\x02h\x03\xed\x00y\xfb\xee\x03I\xfe\x9e\x01\x0e\xff/\xf9z\xff\xef\x04\xb8\n.\x05\x8b\xfd\xb4\xfc\x06\xfb\xfe\xf6(\x00\xa4\t\xce\tn\x02\xb3\xf5c\xfau\xfb\x18\x04\xf6\tA\x03\x1c\xfc\xfc\xf6;\xf9}\xfbS\x06G\t\x15\x03\xd2\xf7i\xf87\x01\xc1\x03\x08\xfd\x14\x00\xbf\xfe\xca\xfa\xd6\xfe\xd4\xf8\xf6\x04\x17\x0f\x88\x02\xbd\xf7\xd1\xfb\x92\xfb\xd3\xfd"\x05\x11\x03\xad\x03\x8b\x02\xde\xff\xab\xfb\xe0\xfem\x05n\x04W\x00\xcc\xfd\x1b\xff/\xff\x7f\xfd\xcb\x03&\x06H\x02\xeb\x00\x14\xfe+\x02\x7f\xff\x8b\xfaJ\x01\xbc\xffr\xfeO\x04\xab\x04\xec\x05.\x00\xbf\xf9@\x00\xd0\xf5g\xf8{\x06/\x0c\x9a\x04\xe9\xf8\xdc\xf9\x91\xfc"\xff \xfc\xf9\x064\x05W\xfb\xfe\xfd \x01<\x03\x11\xfd\xd6\xfc\xaa\xff\xe9\xfcW\x05\x87\x04Y\xff\xe3\xff\xe9\xfdl\xfb\x15\xfb\x8d\x00!\xffK\x01\xe0\x01\x84\x03v\x03\xd6\xfaX\xfb\xdb\x003\x06\xe9\xfd\xf5\xf62\x06,\x05\xa7\xff\xf8\x05{\x06\x1b\xfa\xf7\xf4\x16\xfd\x9e\x03\xa9\x05@\x01\xb3\x04\xfd\x03w\xf9\x9e\xfc\x8c\x01:\xfd&\x00y\x06\xc3\x00\xc5\xf5:\xff\x82\x054\x00\xee\x06\xb9\x05\xb2\xfd,\xf5\xdf\xf65\x01m\x02\xfd\x02\xbe\x06\xce\x06\x1e\x03\xc4\xff\x1b\xfcz\xf8\xb3\xf7[\xff\xc1\x05\xa1\x05o\x08\xc6\t\xa0\xfd-\xf3\x01\xf5\x13\xfa\xd0\xf9\xab\x01\xf2\x0cc\x0cs\tq\xfa&\xf3\xe3\xf3\xbf\xfan\x00_\x02\xf6\t\xa1\n\xf9\x050\xfc\xae\xfc\xce\xf9~\xf8\xfa\xfa\xa7\xfc|\x05w\x0b\x14\r\xaf\x06\xc0\xfd\x89\xf6H\xee\xec\xf5^\x03c\x08\x12\x10\x11\rE\xf9\x00\xec\xfd\xf1S\x00\r\x07\x8d\x06-\x07\x12\x00\xc8\xfa\xf6\xfb\xa9\xfd\xfa\xfc\xca\xff\xe1\xfe\x0e\x01\xfb\t\xc7\x06m\x02\xf1\xfb\x14\xf2\xd7\xf5\xc1\x00$\x07\xd9\x0b\x8d\n\x9b\x01{\xf8\xe2\xf7\x12\xf8\xb5\xfa\xd0\x039\x0c\x05\x0b\xff\xfbk\xf9\xd4\xfb\x12\xfb\xc4\xfd\r\x07\x1c\x05\x86\xfc\x05\xfe\x16\x00\xe5\x00\xcf\x02~\x00\xd5\xfam\xfc\xee\xfd\x87\x05\x14\t\xe6\x014\xfc\xe3\xf7\xc7\xf9\xb7\xff7\x06\xa3\nz\x03\xf4\xfa\xfd\xf9n\xfbN\xfd\x8b\x02\xca\x06\xc9\x06\xdf\x00\x87\xf8v\xf8"\xfe\xf9\x04\xd8\x02X\xffy\xfe\xe5\xffo\xff\x86\xfd\xd4\xfe\x13\x00\xf4\xfe\xb4\xfb\x14\xfe\xc6\x02\xb3\x04\x1d\x02*\xfd\'\xfcR\xfd\x8f\xfb\xb3\xfeL\x02\xd0\x02\xa4\x04\xd9\x01\xf5\xfdU\xfc\x05\xfc\x8c\xfdu\xffu\x03n\x03\xa1\x01\x0c\x01\x83\x00\xd1\xff\x1d\xfe\x96\xfek\xfd\xd9\xfe+\x02{\x04y\x02+\xff\xcf\xfd\x01\xfe\xd6\xfes\xffD\x01\xb7\xff\xc2\xfc\'\xfb\xbf\xfb\xb6\xfe\xda\x02\x99\x01\x11\xfd\xd9\xfa\x90\xfb\xa9\xfa\xe1\xfb.\x00\xfd\xff\x11\x00\x03\xff\xf5\xfe\xa1\xfe)\xfd\x84\xfd\xbd\xfdk\x00\xb1\x03\xd0\x04I\x04\xd0\x03\xe0\x03\xe2\x02\xab\x01\xe4\x04[\n\x02\n\x0f\t\x8a\x08~\x06\xfd\x06u\x08\xa6\t\xd0\x0b\x18\rQ\tB\x06?\x04\xf8\x04\xc0\tE\x0c#\n\t\x05\xee\x02!\x01^\x00\xad\x01\x1b\x03\xf7\x03\xb3\x01Q\xfe~\xfc\xc4\xfbB\xf9}\xf8\x9c\xf8D\xf9|\xfa\x87\xf9\xf3\xf7\x12\xf6K\xf3`\xf0\xbc\xef\xcf\xf2\xab\xf6q\xf7\x16\xf6\x99\xf3\xfb\xf0\x95\xedJ\xec\xa1\xf0\xe0\xf6.\xf9\xbb\xf6\xec\xf2\x03\xf0\xf0\xeeT\xf0\xc9\xf2\x8b\xf4\x99\xf6\x14\xf6C\xf3@\xf2o\xf2+\xf3\xb2\xf2\xdf\xf0\xc0\xf3l\xf7-\xf74\xf5\xa7\xf4\xd3\xf5\xf1\xf4\xb2\xf6\x06\xfa#\xfb\xe5\xfc\x04\xfd\x8c\xfdn\x00s\x03\xc4\x06\n\x08b\x07Q\t(\x11\xc0\x1f\xb6-\x9c2Q*\xac"\x84&\x19/6<\xe7H\x93P\xe6Lc>02\x111\xe85\xa15\xc22i/U)8\x1fn\x12\xb3\x08r\x00p\xf63\xee\x97\xebY\xedI\xeb;\xe2\xb9\xd3\x93\xc8l\xc56\xc6l\xcb\xb8\xd2\xe6\xd7\xea\xd5\x81\xcf@\xcdt\xd2\xbc\xda\xa4\xe01\xe8\x83\xee\x90\xf4\x95\xf8\x18\xf9\x85\xfe\xdc\x03\x11\x05\xa2\x04@\t\xed\x10\xbd\x15\xe2\x153\x13\xe3\x0f[\t\xf1\x05~\x07B\n\xf3\n\xcf\x05\xfa\xfd\xca\xf6\x08\xf1\xf6\xee5\xee\xc6\xecY\xeb\xde\xe71\xe4\xd9\xe1\xe3\xe1\x9a\xe3\x18\xe3\xa0\xe2\xb0\xe3y\xe5<\xe8\t\xeaK\xed\x15\xee\xa7\xed\xa2\xefR\xf3\xd8\xf8$\xfb\x99\xfc+\xfe&\xfe\xe0\xfd/\xfe;\x02\x1e\x06\x04\x06\x86\x04\'\x02\xd1\x04\xaa\x04\xf0\x02{\x01%\x00\xa2\x00\x9c\xfd\x93\x02\xd5\x07w\n\x86\x03\x87\x01\xad\x10\xcf\x1e\x82#"!l(|/o.\xe1,\x8b3\xa7C\xafG\x9bA\xa2?\xc7A\xb9?\xe83P-\x95-5-j\'\xaf\x1eh\x1a\xf8\x11\xbc\x04\xee\xf7&\xef\xc7\xec\x8c\xe9\xcf\xe6\x16\xe4\xf6\xdd\xe3\xd3B\xc9X\xc7\xcf\xcc\x94\xd3\\\xd6\xfd\xd5g\xd6\x0e\xd6%\xd5.\xd9\xca\xe1\x11\xe9\x11\xee#\xf1\xf1\xf3\xf5\xf7\xde\xf9\xcc\xfa\xaf\xfd\xb4\x01\xf5\x07\xa9\x0b\xad\x0c[\x0ba\x07\xbc\x02\x03\x01:\x05\x03\x0b\x08\x0c/\x06\xa9\xff\xbe\xf9N\xf5\xdf\xf4{\xf8\x8a\xfb\x1d\xf9\x90\xf3T\xf0\xdf\xedN\xec_\xedA\xf0+\xf3\xbd\xf2\xfe\xf1\x11\xf2x\xf2\x94\xf1 \xf1\xf2\xf3\x84\xf8\xe5\xfb\x05\xfd*\xfc\xca\xfa\xe1\xf8\xe1\xf7>\xfb\xd6\x00\xb8\x04@\x04K\xff\xfd\xfb\xcf\xfb\xa6\xfd\xbe\xff\x1a\x02\xbc\x01\x13\xffU\xfb\xf5\xf8\x89\xfa@\xf9\xb3\xf7$\xf8\xb7\xf9\xc5\xf94\xf7j\xf4\xcc\xf0\xf9\xef\xf2\xfaK\x12,$\x1b\x1d\xff\x0e\xc6\x0b%\x16d\'\xcf6NJ\x16R\xf2G!5\x13/y;AHDL\x98HfB76\x80%\x94\x1c\x18\x1c\xfc\x1ay\x13\xc0\x08X\x03\x9a\xfc\xd8\xee9\xe0d\xd8[\xd6g\xd5\x9a\xd4a\xd5\n\xd4\xd4\xca(\xc0\x91\xbej\xc7\xea\xd2Y\xd9\xa5\xdb\x05\xdd\x8c\xdb \xda\x9e\xdf\x0c\xeb\x05\xf8\x10\xfe\xf1\xfc{\xfc\x84\xfeK\x01\x0f\x04%\n\xc3\x0f\xff\x10\x87\x0ca\t#\x0b\x82\x0b\xed\x08\x14\x08\x8e\t\x15\t\xb6\x05z\x02\xc9\x00#\xfd\x9a\xf8Q\xf7\xc4\xfa\x11\xfcM\xfa\xfe\xf5\xe3\xf1I\xee\xc8\xedZ\xf2\xce\xf6j\xf9d\xf6,\xf2\xbf\xf0\xce\xf2\xb1\xf7B\xfc_\xff\x7f\xff(\xfec\xfe>\xff0\x02C\x04,\x05\xba\x050\x05\x10\x05\x8d\x04]\x04\x0f\x03\x1d\x02S\x01\xec\x00\x16\x00U\xfc\x14\xfa\x8a\xf8)\xf8\xa5\xf7\'\xf6\xe5\xf4\xa7\xf2\xa9\xf0\xff\xec|\xeb\xa9\xeb\xf0\xec\x13\xf3p\xf8\x9e\xfcP\xfai\xf4m\xf7(\x03!\x13i\x1f\xdc#\n!\xd5\x1a\x81\x1a\x0c&\xf79\x02E\xe1D\xe7<\xff2\x17/81[9\xe4=19l-\xc2"\xf4\x1bu\x17W\x16b\x13\x90\x0b\x97\x00E\xf6\x11\xf2`\xef\x9f\xeb\xfa\xe5\x14\xdf\x85\xd9\xa7\xd6:\xd7\xed\xd7D\xd8\xe7\xd55\xd3\x1d\xd33\xd6\x94\xdc\xb9\xe0\xc2\xe15\xe2\xcf\xe2T\xe5\x01\xe9\xa9\xef\x0b\xf6\x07\xf8\n\xf6\x14\xf5\xf7\xf8W\xfd\xb6\xff|\x02]\x04\xd8\x03-\x01\x8e\x01n\x05\xe3\x06\xbb\x05\xe6\x04H\x05\xd7\x03\x0f\x022\x02\xea\x03A\x021\xff]\xfeS\xff%\xff\\\xfdN\xfc\xdd\xfb\xc6\xf9o\xf8\x8c\xfav\xfc\xe4\xfc\xff\xf9\x80\xf8u\xf8\x1d\xfa\x7f\xfcM\xff\x1a\x00(\xfen\xfc^\xfca\xfe\\\x01K\x03~\x033\x02r\xff\xbd\xfe\xeb\xfe\xfd\xffq\x00\x9a\xffJ\xfe\x90\xfc\xd8\xfar\xf9\xd4\xf8\xe2\xf7\xd9\xf6\xf9\xf4\xdc\xf4\xc4\xf4(\xf3\xc0\xf1k\xf1Z\xf3\xe0\xf4\xbd\xf6t\xf8.\xf7\xef\xf3\xc1\xf6\x0b\x02m\x0e\xc3\x13\x99\x11\xd4\x0e\x1a\r\x8c\x10\x9b\x1d\xe3.\xb48\xa23o)_$R\'~0\xe09\xa3?q9c+& +\x1e\x7f#c&\xae$\xbb\x1c\xba\x10\x9d\x05\xec\xff\xe2\x00K\x01\xdf\xfd\x9a\xf7F\xf1\xa9\xeb\x87\xe6E\xe4<\xe5\x0e\xe6-\xe4"\xe1\xab\xdf\x82\xdf/\xdeG\xddZ\xden\xe1d\xe3a\xe3h\xe4\t\xe5\xd0\xe4J\xe4r\xe6X\xeb2\xee\x15\xef\xfe\xee\x91\xef\x86\xf0B\xf1E\xf4e\xf7\xd1\xf9r\xfa\x93\xfa\x0c\xfco\xfd\xbd\xfen\x00x\x02D\x047\x04\x9d\x03\xdc\x04\xbb\x06\xbd\x08\x87\x08J\x081\x08\xbd\x07\xf0\x07P\x08\x11\n]\n\x00\t\xfd\x06\x93\x06\xa5\x07\x8a\x08\xdd\x07\xfa\x06d\x06\x19\x06\x8f\x05\xed\x05\xf5\x06:\x06\t\x04\xab\x02\n\x038\x04/\x04s\x03s\x02g\xffu\xfc\xcf\xfb0\xfd\xcd\xfd\xf5\xfbr\xf96\xf73\xf5\xce\xf3\x1f\xf4\xe4\xf4\xeb\xf3\xcf\xf1]\xf0u\xf0\xca\xf0\x07\xf1o\xf1\xfb\xf1\xf1\xf1\x15\xf2\xd5\xf3!\xf6c\xf7|\xf7J\xf8\xaa\xfa\xfb\xfd4\x01\xb2\x03)\x05\xd3\x05P\x07\xd1\n\xaa\x0f\xda\x13d\x16k\x17\x0e\x18l\x19X\x1c9 =#\x95#}"\xf9 \xa9 }!N"\x1f"\xd7\x1f1\x1c>\x18\xa6\x15F\x14\xde\x12k\x10\x8a\x0c\x04\x08\x91\x03I\x00P\xfe\xa6\xfc*\xfa\xdb\xf6l\xf3\xc6\xf0*\xef\x08\xee\xfe\xec\xaf\xeb\x1e\xea\xf0\xe8\\\xe8\x84\xe8\xb1\xe8\x8b\xe8\xfc\xe7\xca\xe7\x12\xe8\xce\xe8\xe0\xe9\x90\xea\xd1\xea"\xeb\xbe\xeb\xcc\xec\x16\xee_\xef\xa4\xf0\xa5\xf1\x90\xf2\xf3\xf3\xa2\xf5O\xf7\xad\xf8\xc9\xf9\xef\xfaL\xfc\xc2\xfdN\xff\xa8\x00\xab\x01c\x02\x15\x03\xff\x03\x08\x05\xe6\x05o\x06\xc0\x06\xf7\x06/\x07|\x07\xed\x079\x08.\x08\xd9\x07\x91\x07\x87\x07\x87\x07\x9b\x07\x97\x07Z\x07\xc9\x06\x08\x06\x9a\x05o\x053\x05\xb4\x04\xe6\x03\xfd\x02\x07\x02\x0f\x01Z\x00\xb3\xff\xcb\xfe\xb7\xfd\x7f\xfcr\xfbs\xfa}\xf9\xa5\xf8\xeb\xf7)\xf7K\xf6\xae\xf5j\xf5J\xf55\xf5\x00\xf5\xe2\xf4\xe6\xf4$\xf5\xde\xf5\xf3\xf6\x00\xf8\xe4\xf8\x91\xf9F\xfa/\xfb\x84\xfc$\xfe\xb0\xff(\x01i\x02p\x03e\x04\x8a\x05\xe9\x06\x1f\x08\xff\x08\xac\tN\n\xc1\nY\x0bG\x0cF\r\xe1\r\xf4\r\xc1\r\xe2\rh\x0e\x14\x0f\xd2\x0f,\x10\x11\x10\xa5\x0fM\x0fb\x0f\x88\x0fa\x0f\xff\x0eL\x0eJ\r[\x0c\x8b\x0b\x03\x0bD\n\x02\t\x8b\x07^\x06U\x05Z\x04m\x03c\x02&\x01\xec\xff\x0b\xff\x7f\xfe\xf4\xfd&\xfdY\xfc\x94\xfb\xf3\xfa\x83\xfai\xfa;\xfa\xae\xf9\xe3\xf8\x1d\xf8\xdb\xf7\xa8\xf7\x81\xf71\xf7\xa5\xf6\xce\xf5\t\xf5\xae\xf4\xa5\xf4\xb6\xf4\x9c\xf42\xf4\x9c\xf3\x1f\xf3\xf2\xf22\xf3\xa3\xf3\xf4\xf3\x19\xf4\x0f\xf4\x13\xf4g\xf4\x01\xf5\xe0\xf5\xb4\xf6V\xf7\xf8\xf7\x93\xf8d\xf9N\xfaX\xfbd\xfc]\xfd5\xfe\x0c\xff\xf1\xff\xd7\x00\xaf\x01r\x026\x03\xe7\x03\x80\x04\xe0\x04/\x05}\x05\xb6\x05\xef\x05\x05\x06\xfc\x05\xd1\x05\x8a\x05=\x05\xf1\x04\xac\x04[\x04\xf1\x03}\x03\xff\x02\x87\x02 \x02\xbc\x01h\x01#\x01\xd3\x00j\x00\x00\x00\xbf\xff\x99\xffj\xffC\xff\x17\xff\xcf\xfej\xfe\x15\xfe\xf4\xfd\xd9\xfd\xb9\xfd\x88\xfdF\xfd\xdd\xfcr\xfc5\xfc7\xfc8\xfc\x12\xfc\xbb\xfb<\xfb\xd9\xfa\x9b\xfa\x9c\xfa\xae\xfa\xae\xfa\x94\xfa}\xfa\x87\xfa\xb9\xfa\x1e\xfb\x94\xfb\x1a\xfc\xa7\xfcN\xfd1\xfeS\xff\xb2\x00"\x02\x91\x03\xf0\x047\x06\xa7\x07_\tM\x0b\x1c\r\xb5\x0e!\x10j\x11\x8b\x12\x8a\x13\xb1\x14\xdf\x15\xaa\x16\r\x17\x07\x17\xc0\x16G\x16\xd2\x15T\x15\xa3\x14\x8f\x133\x12\x8b\x10\xbc\x0e\x0f\rZ\x0b\x83\t\x8e\x07r\x05O\x039\x016\xff9\xfd8\xfbA\xf9c\xf7\xb0\xf5(\xf4\xa2\xf2.\xf1\xcb\xef\x97\xee\x99\xed\xc2\xec$\xec\xa0\xeb\'\xeb\xbe\xea\x85\xea\x7f\xea\xa2\xea\xe1\xea:\xeb\xb8\xeb>\xec\xc7\xec\x94\xed\x98\xee\x81\xefY\xf08\xf1U\xf2\x8e\xf3\xcc\xf4\x13\xf6R\xf7\x85\xf8\xbb\xf9)\xfb\xbd\xfcC\xfe\xa0\xff\xd1\x00\x0c\x02[\x03\xae\x04\x02\x062\x07/\x08\xf3\x08\xa9\tr\nH\x0b\xf3\x0bG\x0ck\x0cy\x0cv\x0c_\x0c.\x0c\xd3\x0bC\x0b\x80\n\x9e\t\xcf\x08\x00\x08\x11\x07\x0c\x06\xde\x04\xb3\x03\x80\x02H\x01+\x00\x13\xff\xfa\xfd\xdf\xfc\xce\xfb\xd2\xfa\xe8\xf9\x10\xf9J\xf8\xa3\xf7\r\xf7\x84\xf6\n\xf6\xa9\xf5h\xf5:\xf5:\xf5`\xf5\x97\xf5\xdc\xf5,\xf6\x8d\xf60\xf7\xed\xf7\xde\xf8\xc7\xf9\x94\xfak\xfbQ\xfcr\xfd\xb4\xfe\x03\x007\x01F\x02)\x03\r\x04\x1c\x05^\x06\x91\x07l\x08\x12\t\xaa\t8\n\xee\n\xc6\x0b\x80\x0c\x07\ra\r\xb3\r\xff\r*\x0eD\x0e\x82\x0e\xcd\x0e\xfe\x0e%\x0f\x14\x0f\xdd\x0e\x95\x0e{\x0em\x0eX\x0e\x1f\x0e\xa6\r\xda\x0c\xff\x0bi\x0b\xe6\nQ\nz\t^\x08\x06\x07\xa5\x05m\x04f\x03e\x02\x14\x01\x91\xff\t\xfe\x91\xfc5\xfb\x03\xfa\xff\xf8\xf2\xf7\xbe\xf6t\xf5b\xf4\x8a\xf3\xdd\xf2Y\xf2\xde\xf1e\xf1\xf2\xf0\x9f\xf0\x87\xf0\xa3\xf0\xc5\xf0\xee\xf0\x1b\xf1i\xf1\xe3\xf1c\xf2\xef\xf2\x88\xf3(\xf4\xd0\xf4\x94\xf5f\xf65\xf7\x0f\xf8\xe1\xf8\xb3\xf9\x92\xfau\xfbg\xfcN\xfd$\xfe\xec\xfe\xb4\xff\x87\x00V\x01\t\x02\x88\x02\xeb\x02H\x03\xbd\x03&\x04\x84\x04\xb8\x04\xbc\x04\xa2\x04y\x04x\x04\x87\x04\x7f\x04Q\x04\xfb\x03\x9a\x03:\x03\xf5\x02\xc5\x02\xa0\x02S\x02\xd1\x01U\x01\xff\x00\xd1\x00\x9e\x00u\x00/\x00\xd2\xffi\xff1\xff6\xff2\xff\x18\xff\xcd\xfe\x8e\xfeu\xfep\xfe\x97\xfe\xb6\xfe\x9a\xfeo\xfeP\xfec\xfe\x89\xfe\x94\xfe\x8b\xfem\xfeL\xfeM\xfeg\xfe\x7f\xfes\xfe8\xfe\x15\xfe\t\xfe\x02\xfe\xf5\xfd\xef\xfd\xd2\xfd\xb8\xfd\x8b\xfd\x94\xfd\xd6\xfd\xf3\xfd\xf9\xfd\xe1\xfd\xe9\xfd1\xfe\xad\xfe"\xffr\xff\xc1\xff\'\x00\xbc\x00\x8a\x01\x84\x02u\x03B\x04\xf1\x04\xd0\x05\xf3\x06&\x088\th\n\x98\x0b\x99\x0cO\r\xee\r\xc5\x0e\x96\x0f&\x10m\x10\x8f\x10O\x10\xcb\x0fb\x0f\x1b\x0f\xb0\x0e\xc1\r1\x0c\x8b\n!\t\xfb\x07\xdf\x06h\x05\xa5\x03\x90\x01s\xff\xa2\xfdN\xfc,\xfb\xf2\xf99\xf85\xf6\x88\xf4\xc1\xf3{\xf3\xe7\xf2\xe0\xf1\xc8\xf04\xf0=\xf0\x9a\xf0\t\xf1%\xf1\xff\xf0\xfa\xf0s\xf1{\xf2\xc8\xf3\xa5\xf4\xd8\xf4\x17\xf5\xe3\xf5W\xf7q\xf8#\xf9\x90\xf93\xfa\xe6\xfa\x89\xfb\xb1\xfc\xd1\xfd3\xfe\xd6\xfd\xf1\xfd>\xff\x84\x00\xb5\x00\x7f\x00\x8c\x00\xea\x00B\x01\x12\x02z\x02\xed\x02\xb3\x03\x86\x03\x88\x02\xc1\x02\x12\x04w\x02\x88\x01\x17\x08\xa4\x0f\x9f\np\xfc\\\xfa\xe4\x05l\x0el\r\x15\t\x95\x030\xfb\x1b\xfa1\x07\x9c\x0fO\x08\x03\xfe!\xfa\x98\xfb\xf6\xfe\xa9\x02\xb7\x01\xa0\xfb\xb1\xf6\x91\xf7\x13\xf9\xe8\xf7\x9e\xf8\x05\xfcA\xf9\xa9\xf0?\xee<\xf6\x90\xfd.\xfc\xf8\xf6e\xf42\xf5\xfa\xf6V\xfbJ\x01\xe7\x01\x7f\xfcu\xf8\x0c\xfb\xb0\x01\xc2\x05m\x05\xeb\x02m\x00b\x01\x11\x05#\x08\xa2\t\x8e\tt\x06\xdc\x03\xb1\x064\x0c\xb1\r*\n\x85\x08\xfb\n\x7f\x0c\x1c\x0cV\x0c\x0f\r\xa5\x0c\xb3\x0b\x9e\x0c\x02\x0e\xa8\rf\x0c=\x0b\xdb\n[\nm\nb\x0b:\x0cM\n|\x06\x1d\x05\x19\x07\x9f\t\xb2\x07\xc1\x04\x02\x04\x8f\x03\xbd\x01\xb6\x01\xb8\x03\x96\x01\xc8\xfb\xea\xf9\xd4\xfd\x02\x00\x9b\xfc?\xf8\xce\xf6\x1a\xf6\'\xf5\xeb\xf6\xac\xf9\xda\xf8\x87\xf3\x84\xee\x14\xefk\xf3\xc2\xf6\x02\xf7\xd5\xf4\\\xf1\xe8\xeeW\xf0\xf5\xf5\x82\xfa\xeb\xf9S\xf70\xf6B\xf6\x1b\xf8\xf7\xfc\x9f\x01\x87\x01\xbb\xfd2\xfd\xd8\x00t\x03\x91\x04#\x06e\x07!\x06!\x05\x9b\x07\xa0\t\x16\x08\xd0\x068\x07\x02\x08S\x07\xdc\x06\xd1\x05\x91\x01\xab\xfd\x0c\xff\xf8\x01\xb6\xff\x85\xfb\xf3\xf8D\xf5\xc6\xf0|\xf1\xaa\xf6\x03\xf7\x83\xf0\x02\xebk\xe9O\xeb"\xeew\xf0\x89\xf0\xf3\xed\xcd\xeb\xfc\xeb\xdb\xed\'\xf2\'\xf6c\xf6\x1d\xf4\xf9\xf2\x05\xf5g\xf8\x19\xfcT\xfe\xcc\xfel\xfd\t\xfc\x9c\xfe\xf2\x01\xb3\x03]\x05\xe7\x07\xd2\x08i\x05J\x01@\x02\xc2\x08\xa7\x0e\xb8\x0fh\x0b\x13\x06>\x04^\x06\x7f\t\x8a\x0f*\x14\x9c\x112\x0bk\t\x87\r\xe6\x0f\x98\x14\x12 \xd3,\xe6(\xa4\x18\x7f\x0f?\x19\x9c,\xe26.8\xd42}(\xce\x1b\x9e\x18o%K2\x890f!\x8d\x13q\x0cc\x08\xed\x08W\n\xae\x07\x1e\xfe\xee\xf1\xdb\xe8\xb8\xe3\xd1\xe1\xa8\xdfo\xdcT\xd8\xe8\xd5=\xd4\xa2\xce\x06\xc9D\xc8\xbc\xcd\x93\xd3\xd4\xd6\x03\xd8C\xd7\xcf\xd4\x87\xd4\xfa\xdb+\xe6F\xee\xc0\xf1N\xf1\xe5\xf0C\xf1t\xf5\xb9\xfe\xe3\x06\x8a\n{\t\x1a\x07\xf0\x06\x80\t/\rC\x11?\x13\x9f\x12\xea\x10\xd4\x0e\x9f\rx\r"\x0e\xb7\r\x06\r9\x0b%\t\xf6\x06\x8a\x03\xf1\x00\xf3\xff\x89\xff:\xff\xb1\xfd\xb7\xfb\xeb\xf8 \xf5-\xf4\x19\xf5\x91\xf7\xee\xf7\xba\xf6N\xf5B\xf3\x0f\xf2j\xf3\xfb\xf6r\xfa\'\xfb\x8a\xf9\xb3\xf7A\xf7N\xf8\xc8\xfa\xbb\xfd\x83\xff\xf4\xfe:\xfc\x1e\xfa\xa0\xfa\xaf\xfc|\xfe\xc1\xfe\x9f\xfd\xd8\xfbC\xf9\xce\xf7\x9a\xf7R\xf9\x9a\xf9\xdf\xf7c\xf5\x83\xf3\x10\xf3|\xf2\x12\xf3\x87\xf5d\xf8 \xfa\xed\xf9Z\xf9\x92\xfa\xbd\xfd\xe7\x04;\x11C#\x93+\x03"\xec\x12R\x16}1\xc4I\xc4PQLKG\x8a@F8\x87<\x99M\x9eY\xd1O\t;E.\xe8)\xe2\'\x91"\x9c\x1dC\x17v\n\xce\xfa9\xeeH\xe7N\xe2\x8f\xdb;\xd3\xbe\xcd\xc6\xcb\xc1\xc6\xe3\xbd<\xb6\xa0\xb57\xbb\x01\xc1\x8e\xc4\x0f\xc7\xe9\xc6;\xc5\x92\xc6*\xce\xf6\xda\xf7\xe5f\xec~\xef\xbe\xf0[\xf2y\xf8\xbd\x01@\n\x0c\x0f\x97\x11\t\x13\xa4\x12\xff\x11\xaa\x14\xa9\x18\xdb\x17\xa1\x13L\x12R\x13\x1e\x11\xc3\n\xcc\x05\'\x05p\x03\xea\xff&\xfd\xca\xfbe\xf9\xe3\xf3_\xef:\xef1\xf1\xe7\xf2)\xf2\xc8\xef\x9c\xee\xe2\xee&\xf1\xf6\xf3\x7f\xf7\x9a\xfa\x81\xfc\x88\xfc\xfb\xfd\xd2\x01g\x05-\x07{\x08\xb1\nC\r\x17\rE\x0b\x04\x0b\xf2\x0b2\x0c\x1b\x0b\x18\t\xc1\x06\xda\x03\n\x00H\xfe1\xfdE\xfb\xbb\xf6\xdc\xf1\xb9\xed]\xeb\x07\xea3\xe9S\xe7\xb9\xe3\xdf\xe1\xae\xdfO\xde0\xdd\xf0\xdf\xc1\xe6\x0b\xe9\xde\xe7(\xed$\xf63\xf7:\xf1\xae\xfd\xc0#_>\xae1^\x18\xf6\x1b\x847\xbdJ\xe7O\xeb\\zl\x04ffJ\xfd:3IO[nZrL&Ex?\xc1+\xbd\x12\x05\t0\r\x80\n\xc2\xf9\x15\xea\x06\xe5.\xde_\xcb\xa1\xb9\x95\xb4\x89\xb9\x93\xbev\xbd \xbc\xe2\xb8l\xb2\xb2\xae\x95\xb3[\xc0V\xcf\xb7\xdb\x83\xe1h\xe2$\xe2?\xe6G\xf0\x8f\xfb\xb9\x08\x87\x15Z\x1ak\x17\x91\x12\\\x14<\x1a\xfb\x1d\x9b\x1f\xc1!f!_\x1b\xa7\x12\x1c\r\xa4\n\xc4\x06\xd0\x00<\xfcB\xfbj\xf9\xec\xf3\xf6\xea\x14\xe2\x1d\xdeT\xde\xc5\xe0\x93\xe42\xe7\xe2\xe6\x7f\xe2_\xdd\xbd\xde\xcb\xe6\x92\xf1\xca\xf8\xc6\xfb\xb1\xfc{\xfd`\xff(\x04\xe8\n\x17\x12\x8d\x15n\x155\x15<\x16\x94\x178\x17\xb1\x14\x08\x13\xed\x12\xc9\x12\xc0\x10h\x0c\xce\x06p\x01\x8d\xfc\x8e\xf9\x95\xf8\xa3\xf7\xce\xf5\x8a\xef@\xe94\xe4\xe1\xe1\xf6\xe0\xba\xdfS\xe1F\xe10\xe0\x12\xdf\xa2\xe0\xbd\xe4\xa3\xe5\xa9\xe6\x04\xe8\xa6\xe8\xde\xeb\xcd\xfav\x1d96\xdf/\xc7\x14\xfe\x08\xa2\x1e\xe3>}Z\x9amJs_bqC\xf56\xc9GHb*k|a\tQ\x80=\t,w\x1e\x8e\x16&\x13:\x0c\xee\xff4\xf1r\xe4Y\xdbG\xd1\x08\xbf3\xacR\xa5z\xab\xa3\xb7]\xbb\xa3\xb6y\xad\x9d\xa5\x80\xa4\xbd\xaeA\xc3O\xd9\xc6\xe7\xe7\xe8\xcf\xe5\xf7\xe5\x05\xedH\xfb5\x0e\xce V+o(\xdf \x8c\x1dJ#t+\x9d0\x990\xab,\x01&\xfa\x1c\xb2\x15M\x11=\r-\x06V\xfc\xaf\xf5\x0f\xf3\x83\xf0\x02\xeb\x96\xe2\x80\xdaF\xd4\xcf\xd1\x90\xd4I\xdaV\xdf|\xe03\xde\x93\xdbj\xdc$\xe2\xa8\xec\xd6\xf6\xf5\xfdj\x01s\x03\xbf\x04t\x07E\x0c\x9b\x12\xb0\x17\x15\x1a\xc2\x1a\xe5\x1b\xd4\x1b\x86\x19\xc4\x15\xcf\x12I\x11/\x10]\x0eb\x0b\x14\x07\x0f\x00\x19\xf8\xe5\xf0\x02\xed\xb2\xebS\xeb\x94\xe9\xb9\xe51\xe10\xdd\xab\xda\xfc\xd8\xe1\xda\xbb\xddL\xe1\xa0\xe3\xa0\xe4H\xe7\xe6\xea\x19\xef\xd2\xf3\x15\xf8N\xfb\xa0\xfaU\xf8\x12\xfd\x9b\x13\xa08\x95S\x80M\x99,2\x16\x96&tM.l\x92w\\w\xb5n\xdcW\x97>\xfe8iIXYMU\x8fA\xf3,3\x1fP\x11l\x00G\xefJ\xe3P\xdce\xd6\xe8\xce\x95\xc8r\xc1\xa1\xb2\x8a\x9e?\x8f\xc6\x90y\xa0\x18\xb2?\xbc\xbc\xbc4\xb7\xd6\xb1\x9f\xb3r\xc0\xb0\xd6\xb4\xee\x8d\x00\xe0\x07\xe4\x07\x83\x07\x96\x0cN\x17\xf5$X/\xca3\xe23\xc83\x022\x9f-s(?%\xe7 k\x19\x99\x12u\x10\x8f\x0e\xd7\x06\r\xfa\xd1\xed\x1e\xe6\x96\xe1\x1d\xe1^\xe3\x83\xe4\x11\xe1)\xdb\t\xd7k\xd6\xd9\xd8\xc7\xdf\x85\xe8\\\xeeQ\xf0\x98\xf2-\xf7\xc1\xfbe\xff\'\x03\x12\nY\x0f[\x12m\x14\x8b\x17s\x1a\x95\x1a\xf3\x17\xcf\x15\xb4\x14\xcf\x14#\x15\n\x144\x108\n\xf2\x02(\xfc(\xf7\xae\xf3\x8e\xf2\x82\xf0k\xec}\xe5`\xdeg\xda{\xd9@\xda\x8d\xda\xdb\xdbv\xdcn\xdc\xd8\xdc\xed\xde\x1d\xe3J\xe7v\xe9X\xeb\x9e\xeeh\xf4\x90\xfc~\x04\xba\x08\xc1\x08\xc8\x06\xbb\x05M\x06\xa4\r\xcd&\x80M\x95f|^\xed@\x11/]7vK\x8d^|q\xff\x7f\xbc{\x88b\x88D_5\xcc5\x9a7\xba2\xdd\'\xb3\x1bZ\x10)\x04\xaf\xf4V\xe2\x90\xcf)\xbe\x8c\xaf\xc5\xa5\xa1\xa6\xa4\xb1\xc9\xbb.\xb9O\xa9\xbf\x98\xa6\x91\x05\x97\xbd\xa5\xeb\xba\xc8\xd2\x05\xe5\x13\xeb\xb4\xe6\xe7\xe5\xc1\xf0@\x01\x08\r\xed\x14z\x1e\xc2(\xbe.p1\x0e4@6z2\x10\'\xee\x19D\x11\x9e\x10\xe9\x15\xd6\x1a%\x18,\x0c\x89\xfc\xdb\xeeV\xe3m\xdbI\xdbt\xe2\x91\xe9\xe5\xe8\xba\xe4\xff\xe3\xcd\xe5l\xe5E\xe2\xbb\xe1\xf2\xe6\x8c\xee\x06\xf7\xb7\x016\x0c(\x12i\x11T\x0c3\x08\xef\x08\xab\x0e\x0f\x18i\x1f\xb4!\x8e\x1f\xf0\x1aI\x15a\x10\x12\r\xc5\n\x05\x08\x0e\x04\xc2\x00\x1a\xff\x07\xfd\xb8\xf80\xf2\xe0\xe9A\xe1\xb8\xda7\xd9\x9f\xdc;\xe1\xd8\xe2\xe7\xdf\x93\xdb\xf9\xd6\xa7\xd4\x91\xd7\xa2\xdd\x90\xe4\x01\xea?\xed\xb2\xefS\xf1\x8a\xf21\xf6\xf5\xfb\xc1\x01\x9c\x07!\x0c\xbd\x0f$\x11c\x10\xaf\x0f\x89\x13v \x036OJ\xdcQoK\x9eA\\>\xe0A>G\xdcMgW\x04_\xb0\\`O|@\x816\xb0/\x8f%Q\x18\x9a\x0c\xdd\x03t\xfc\xf1\xf4\xb3\xed\xe5\xe4A\xd9\xd8\xca\xce\xbc\x9e\xb1_\xab\x10\xac\x00\xb34\xbb\x8e\xbf\x9d\xbe\xab\xbb\x85\xba\x02\xbd~\xc4Z\xd0I\xde\xb7\xea\xef\xf4\xeb\xfcQ\x04\xa9\x0bN\x13\x82\x19v\x1d\xab\x1ew\x1e\xc6\x1e) \xa3"0%\xf5%}"\x86\x1a\x95\x0f\xd8\x04\xaf\xfc\x16\xf8\xcf\xf7\xde\xf8\x00\xf8]\xf3\x17\xecf\xe5\xd9\xe0}\xde"\xde\xab\xde\xa8\xe0H\xe3\xc8\xe6E\xeb\x82\xf0\xfc\xf5\x07\xfa\xbc\xfb>\xfc"\xfd\xdb\x00\x17\x07\x83\x0eC\x15+\x19p\x1a?\x19\x14\x18\xb8\x16\xe1\x15=\x16\xb5\x16\x02\x16i\x13\x01\x10?\r\x93\n\x16\x07z\x02\xd6\xfcL\xf6\xd2\xef?\xeb1\xe9\xf0\xe8;\xe8\xc9\xe5&\xe2\xac\xdd\x19\xda\x92\xd8p\xd9*\xdci\xdf\x88\xe2$\xe5\xf8\xe6#\xe8\xcb\xe9;\xed$\xf3\xd0\xf9F\x00\n\x05\x98\x07\xaa\t\x99\x0cP\x11\x93\x16\\\x1a\xd9\x1a\xf9\x19\xdf\x19(\x1e\xb7\'\x9c4\x16@\xa7E\x8fD\n?\xb78e4-4w7\x86;\x9e<\x109\xd31\xb2(\xbd\x1f\xd6\x17+\x10\x0b\x07\xf6\xfb3\xf1!\xe9\x02\xe4X\xe1\xb2\xdf\xb3\xdd4\xd9\xb5\xd1\xa9\xc9\xf5\xc34\xc2\x9d\xc4\x18\xca\xef\xd0\x9e\xd6\xa3\xda\x97\xde\x03\xe4=\xeb\x1f\xf2\xa7\xf7g\xfb\\\xfeq\x01\xa5\x05,\x0b&\x11\x0f\x16\xbf\x18\x86\x18\x88\x15\xd5\x10k\x0c.\t\x0e\x07\x04\x05M\x02*\xff\xe5\xfb\xfe\xf8_\xf6\xca\xf3\t\xf1&\xeem\xeb\xfd\xe8\x0f\xe7\xa2\xe6`\xe8\xbc\xeb\xed\xee\xcb\xf0\xe8\xf1J\xf3\xbd\xf5\xf4\xf8\xee\xfc\xf7\x00\x82\x04D\x07\xc1\t\x95\x0cZ\x0f\xed\x11\x8d\x14\x9a\x16&\x17\xff\x15\xa1\x14B\x14W\x14\xad\x13=\x12\xdd\x10\xed\x0e\xdf\x0b\xe5\x07\x91\x03\x9b\xff\xb6\xfb\xdb\xf7h\xf4%\xf1\xe9\xed6\xeb\x12\xe9_\xe7\xf2\xe5\xf1\xe4\xd8\xe4\x13\xe5l\xe4\xc2\xe2\xac\xe2]\xe5\xc0\xe8Q\xeb\t\xed\xe6\xef\\\xf3\xbb\xf6\\\xf9A\xfc\x1f\x00q\x03Y\x05&\x06\xdd\x07/\x0b\xb5\x0f\xf5\x12G\x14X\x14\xd9\x14\'\x17\xe4\x1b\xf0!\n(\xce,\xa6.\xc1-"+\x1d(\x11&\'%\xff$\x05%\xb3#\x7f \xcc\x1b\\\x16\x89\x11Y\r\xd8\x08\xc1\x03/\xfeo\xf9?\xf6\x04\xf4W\xf2N\xf1\x93\xf0\xdf\xee\xa6\xeb\xff\xe7X\xe5\x9a\xe4\x84\xe5\x1f\xe8\xa8\xeb\xd4\xee\x7f\xf0\x0f\xf1\xcf\xf1\xc5\xf3f\xf6\x15\xf9`\xfb\xe7\xfc\\\xfd\xe8\xfc\x14\xfcO\xfb\x13\xfb0\xfb\x95\xfbM\xfb\xd5\xf9y\xf7\x02\xf5\xce\xf2\n\xf1e\xf0\x9b\xf0V\xf1\xd0\xf1!\xf2N\xf2\xb5\xf2f\xf3\xac\xf4\\\xf6\x06\xf8\x8a\xf9\xda\xfa\x1d\xfcz\xfd\x15\xff,\x01}\x03^\x05]\x06\xf5\x06b\x07\x17\x08\x90\x08J\tq\n\xac\x0bV\x0c\x1d\x0c\xa6\x0b\x19\x0b2\n_\t\x08\t!\t\xd5\x08z\x07\t\x06\xbf\x04\xa1\x03i\x02Q\x01C\x00\x17\xff\xca\xfd\x91\xfcK\xfbO\xf9\xa2\xf65\xf4\xad\xf2q\xf1\'\xf0}\xefg\xf0\xaf\xf1\xc2\xf1Z\xf0\xc3\xee[\xedO\xec\xb8\xecD\xee\xb8\xf0\xfb\xf3\x8a\xf7>\xfa\xa0\xfbq\xfc\x01\xfe\xc5\x00\x18\x04\xb0\x06c\x08}\n\xd9\r\xfd\x11{\x16\xf9\x1a\xd9\x1e\xf5 \x11!\x0c \x8f\x1e\x1d\x1d\xa7\x1b\x93\x1a;\x1a^\x19\xeb\x16!\x13n\x0f\x0f\r4\x0b\x86\t\x1f\x08\x05\x07\x86\x05\n\x03/\x00\x83\xfd\x88\xfb8\xfa\x0c\xfa\x15\xfbu\xfc\xe6\xfc\xc5\xfb\xc1\xf9\xe9\xf7\xde\xf6\xd9\xf6\xde\xf7\xf1\xf9\xd2\xfc\xa5\xff3\x01\xd1\x00\xdb\xfe\xfd\xfb5\xf9T\xf7\x0c\xf7\t\xf8:\xf9\xa9\xf9\xcc\xf8\xc8\xf6\xf8\xf3\x03\xf1\x17\xef\xc3\xee\x1d\xf0\x0f\xf2\x10\xf4\xb1\xf5\x88\xf6R\xf6C\xf5V\xf4\x0b\xf4\xa4\xf44\xf6@\xf8/\xfa\x93\xfb\x1b\xfcO\xfc\xc3\xfc\x85\xfd\x08\xff\xf8\x00\xe3\x02\x14\x04$\x04\xfb\x026\x01\xe7\xff\xd8\xff\xf0\x00Q\x02\x07\x03\xb3\x02\x8e\x01\xe0\xffl\xfe\xc4\xfd-\xfe\x94\xffY\x01\x11\x03\xb0\x045\x05|\x04\xee\x021\x01@\x00U\x00\r\x01g\x02h\x03-\x03*\x02\x03\x01/\x00s\xff\xc8\xfe\x8f\xfe\xf5\xfe\x88\xff]\x00\xdf\x01!\x04\x06\x07\xe3\t\xa6\x0c\xef\x0ev\x10a\x117\x12\xfe\x120\x13\xd9\x12U\x12F\x12\xa8\x12\x13\x13\xa6\x12\xf0\x10$\x0e\xa4\n\xe2\x06\xf0\x02\xfe\xfem\xfbM\xf8\xb4\xf5\x8d\xf3\xd4\xf0\xc3\xed$\xebN\xe9U\xe8\x02\xe8\x17\xe8\xf8\xe8_\xea^\xec\x80\xee\xb4\xf0\x00\xf3\xda\xf5\x94\xf9\x7f\xfd\x0f\x01\x9d\x030\x05w\x06\x0f\x08\x1e\nv\x0c\xd8\x0ea\x11\xe7\x13\xd3\x15,\x16\x9c\x14,\x11\x9f\x0c\xe8\x07\xb9\x03|\x00>\xfe\xf3\xfc%\xfc\x08\xfb\x18\xf9\xf0\xf5\xe9\xf1\x01\xee\xf7\xeaF\xe91\xe9\x8a\xea\xcd\xecx\xef\x84\xf1\x02\xf3-\xf4\x18\xf57\xf6\x96\xf7c\xf9\xa5\xfb\xe1\xfd\xf4\xffh\x01K\x02\xcc\x02%\x03\x91\x03\xfb\x03\xfb\x03:\x03\xa5\x01a\xff\xfe\xfc\xc0\xfa\x13\xf9_\xf8q\xf8\xfa\xf81\xf9\xaf\xf8,\xf7)\xf5\x10\xf3}\xf1\'\xf1k\xf2\n\xf5Q\xf8v\xfb\xca\xfd\xf4\xfeX\xff-\x00\xcf\x02\x0c\x08\x9d\x0f\xb4\x18,"\xe4*\xab1t5\xe45f4\x802T1\x061\x041\xc80S/\x15,\xb3&\x97\x1fe\x17\xcc\x0e\xbd\x06\x83\xff\xf0\xf8\xbf\xf2\xee\xec\xf3\xe7\xe6\xe3\xe1\xe0\x7f\xdeP\xdc\x06\xda!\xd8\xcd\xd6T\xd6\xbc\xd6K\xd8\xaa\xdb\xc9\xe0\xe3\xe6\xe6\xec\xec\xf1\xa8\xf5g\xf8\x8c\xfaB\xfc\xfe\xfd\x12\x00\xd3\x02W\x06\xcd\t\x82\x0c\xbf\r\x80\r\xb6\x0b\xd3\x08G\x05\xa3\x01\xc9\xfe\x08\xfdA\xfc\xee\xfbA\xfb\x9d\xf9\xd8\xf6P\xf3\x83\xefl\xec\xe0\xea\x1d\xeb$\xed\xb5\xef\xa2\xf1"\xf2\xc9\xf1\xa8\xf1%\xf3\xb8\xf6\xc5\xfb\x01\x01"\x05\x98\x07q\x08\xb9\x08B\t2\x0bG\x0e\xc5\x11\xd9\x14$\x16s\x15\xf7\x12?\x0f\xbb\x0b:\t\x92\x07\x92\x06-\x05@\x03\x04\x01\xfd\xfd@\xfaA\xf6?\xf2\xfa\xee\x81\xec\x00\xeb\xfe\xea\xf4\xeb:\xed\xbf\xed\xfe\xecG\xebs\xe9@\xe9\xcb\xeb0\xf1.\xf7o\xfb\x1f\xfd&\xfc\xd1\xfan\xfai\xfc\xc9\x00\xe7\x05p\n\xbf\x0c\xc0\x0c\xac\x0bK\x0c1\x12Q\x1e7.\x91\xf5Q\xf0+\xec\x14\xe9\xd6\xe6c\xe6\x95\xe8\xd0\xec\t\xf2\x91\xf64\xfa\xa8\xfbj\xfaM\xf7\xb3\xf4\xc5\xf4o\xf8\xaa\xfd"\x02b\x02\x16\xfe\xb3\xf6P\xee\xf8\xe7\xc9\xe4w\xe5\x88\xe8%\xebc\xeb(\xe9\xf9\xe4\x9a\xe0\x10\xde\xda\xdf\xf9\xe5\xf7\xed\xdd\xf4\x05\xf9\xef\xfa\x82\xfbT\xfd\x18\x04\x07\x15\xb8/\x89M\xafc\xa2j\xbacrW\xf1P\x05U2a2o*xLu\xbdd\xddH%)\x84\x0e`\xfdQ\xf4\xc9\xef\x9d\xebp\xe4,\xd9\x19\xcaJ\xb9*\xaaB\xa0\x19\x9d\xb6\xa1\x19\xad\xd2\xbd\xb4\xd07\xdfX\xe5Z\xe4\xbe\xe1Q\xe6\xc8\xf4\xe7\x0b\x06&\xe9:5E\xcaB:7\r)\xe3\x1f@\x1ew"\xea%|"\x0f\x17\x1d\x06#\xf4\xe1\xe2\xe4\xd3\xd9\xc80\xc3a\xc1_\xc06\xbf\xb2\xbe\x7f\xbf\xdd\xc0r\xc1\xa6\xc1\xda\xc5\xbe\xd0\x91\xe2\xe0\xf5\xb6\x04-\x0e4\x12G\x14a\x18,\x1f\x88)@4\xda:\xac:\xa23\xc5*Q#\xa3\x1e}\x1bU\x17 \x12\xd7\n\x9d\x01\x8b\xf9\xe6\xf2+\xefr\xed\xa3\xeb\x14\xe9\xa9\xe6\xb1\xe6\xb6\xea\x8e\xf0K\xf5\x8c\xf8d\xfa\x8b\xfb\xc5\xfc\\\xfeg\x02<\x066\x08\xbe\x06i\x02k\xfd\xa5\xf9\x9e\xf7\xb7\xf5\xcf\xf2\x7f\xee\xac\xe8?\xe3\xb3\xdf\xe6\xdd\x88\xde\x92\xdf\xe0\xdf\x18\xe0\xf8\xdf\x08\xe0\xec\xe2\x08\xe7\xb8\xed\xe2\xf4J\xf9\x8d\xfdT\x01\xaa\x06\xa5\x0bu\x0f\xae\x14\xa1\x1b\x89%\xb04JJ\xa8`\x9cg\x15\\\x01I\x8a>}D1Q\r^pd_\\\x8eDP#@\x07V\xfa\x9c\xf8\x9b\xfa\xb5\xf8?\xef[\xe1\xbc\xd2*\xc6z\xbc\xbe\xb8o\xba\x94\xc0\x85\xc6\xc6\xcc\x85\xd6,\xe2\xbd\xe9\xe3\xe9\xee\xe8\x00\xeeM\xfc\x1a\x0e\x98\x1d\x81(L.7-\x85%\xb2\x1c|\x19>\x1d\xe5 \x97\x1f\xf5\x16W\n\xfd\xfb\x13\xee>\xe3\x7f\xdb\x81\xd5w\xcf\x9c\xc8\xc4\xc3\x08\xc2\xd5\xc2\xa3\xc3<\xc3\xaf\xc3\x16\xc5\xb7\xcaa\xd6\x01\xe6\x99\xf3\x12\xfb\xe9\xfe\xf3\x01\xed\x06\xe4\x0f\x01\x1cM(\xdc.\x1c/\n,\xe0(\xb8&#&\x00&\xb2#\xc1\x1e\xdc\x184\x13\x9e\r\x04\x07q\xff\xa5\xf9]\xf4\x15\xf2`\xf2\x04\xf4\x07\xf4&\xf1\x85\xec\xcf\xeau\xed\x87\xf1\xc3\xf65\xfa\xea\xfb2\xfc\x99\xfa\xd2\xf9L\xfa\xf0\xfa\x1d\xfd\xf1\xfd\x11\xfeP\xfc\xd5\xf8\xb3\xf5\xff\xf0O\xed\xd6\xea3\xeb\xd0\xec\xc1\xedg\xec8\xe9\xba\xe6\xeb\xe4\xe2\xe5\xbc\xe9 \xed\x8f\xf3\x95\xf7\xe6\xf8\\\xf9Q\xf4\x8c\xf3\xd2\xf7\xc3\xff\xf6\x0b.\x12\x9d\x11\x9e\x0c\x9f\x0e\xeb \xc9=\xaeP\x9bM:A\xf79\x89=IH\xfaS\xd6_5c\xd8RK:I(b#<&:!\xb2\x15c\x08e\xfd}\xf4\xa8\xeb\xc9\xe08\xd7\x17\xd1\x89\xccW\xccO\xcf"\xd5I\xd9_\xd4\xe6\xcb\xa4\xcaX\xd6>\xe9J\xf7S\xfd\xab\xfe8\xfe\x83\xfd\x89\x015\x0br\x17`\x1e(\x1d\r\x17)\x10$\x0b\x95\x08"\x07\xe2\x03\xb7\xfd\x16\xf5\xcd\xed\x16\xe7\x1c\xe1\x03\xd9\x94\xd1\x95\xcc\x86\xcb\xfc\xcc\x9a\xcf1\xd2\xfd\xd2\xc2\xd0\xac\xcf\x91\xd5\x89\xe0,\xef\xa7\xfa}\xff\x10\x00\x8c\x02\xad\n\xa0\x16s\x1fp& +\x9c)\x06&{%d)\\,G)\xec"\x99\x1d\xff\x18s\x15N\x11F\x0c\xaa\x05\x04\x00U\xfb\x0f\xf9\x90\xf7\x84\xf5;\xf3\xd5\xef\x00\xee-\xee\x08\xef\xb0\xefr\xefL\xf0\x98\xf1\xfe\xf2\x90\xf3\xba\xf33\xf5H\xf5\x00\xf5\x17\xf4)\xf4\xb0\xf5\x02\xf5V\xf2\x01\xef\xbc\xed\xa9\xed\x13\xee&\xedA\xea\x9a\xe7_\xe4\xb5\xe5|\xea\x9f\xed\x12\xf0\x00\xed\xbe\xeb\x0e\xecp\xed\xea\xf0\xfa\xf1t\xf3\x97\xf3\xd7\xf9\xc8\n\x13!\xcc0\xda+\x08!<"z4\xf4L\\]\xc3f\x04g6[kJ\x08D[MaV\xa5R\'Ce1i"4\x13\x1c\x07/\xffG\xf8\x0c\xf0D\xe4\x91\xd9X\xd1c\xca\x97\xc2\xd2\xbb\xb2\xba_\xc0\xff\xc9\xf8\xcf\x92\xd0j\xcf\xe1\xd1z\xda+\xe7S\xf4\x1e\x01?\n\xf1\rF\x0e\xf9\x0e\xf2\x13=\x1c\x05"\x06#\xd1\x1f\x9c\x191\x12)\t6\x03\xc5\xfe\xf3\xfa>\xf5\x0f\xed-\xe2\xaa\xd6\xe3\xcdD\xca\xf9\xca\xf0\xcc\x7f\xcf\x99\xce\xe7\xc9\xf6\xc6\x9d\xcaD\xd5\x10\xe1\xaf\xeb\xc8\xf5N\xfb*\xff\xa3\x02p\x0b\xdd\x15\x03\x1f\xa6\'X-\x840\x11/a-I-\xba,\xd3,(,$*^&Z\x1e\xf1\x15\xc8\r\xa7\x08\xb4\x04.\x01h\xfdU\xf8=\xf2Y\xebl\xe7I\xe6\x15\xe7\xd7\xe7\xdc\xe6\x00\xe7\x8a\xe6+\xe5K\xe5\xf0\xe5\xc6\xe9?\xed\x10\xee&\xee\xc4\xee\x85\xf0R\xf1\xa1\xf0\xbf\xf0$\xf2M\xf3Q\xf3/\xf3\x85\xf3b\xf2Y\xef\xca\xef3\xf1\x1a\xf3\xa5\xf2\xe6\xec\xc8\xeb\xaa\xec0\xf2l\xf6z\xf7]\xfb\xc2\x02\xfa\x0b\x84\x12\\\x1b,+\x84<_DR@^=\x87D\xd6P&[sa;c\x8c]!O)>]6\x076\xd85\xfc/\xac"\x8c\x13\x03\x03\xa7\xf2\x05\xe6\xfd\xdel\xdb\x0f\xd7\xca\xd0\xef\xc8\xfd\xc2\xd1\xbe\x03\xbb\xb5\xba\xd2\xbe\xd5\xc7\x98\xd3\xfe\xd9\xc9\xdc\x1c\xde\x0f\xe2W\xeb\x1f\xf7`\x05T\x11y\x18\xfa\x17\xa9\x14\xd1\x13\xd4\x16\xaf\x1dZ"\xc3"\x9d\x1d\xe3\x12\xb0\x07c\xfe\xeb\xf8\xd2\xf6\xc6\xf4u\xef\xc5\xe6\xa8\xddy\xd4\x19\xcd\xa8\xc8(\xc9(\xce\xae\xd1\xef\xd3\xb6\xd5z\xd6!\xd7\x06\xda\xf9\xe3\r\xf3=\x02~\t\t\n\xdd\x08\xea\x0c\'\x18\xd6#K,\xd4/\x8c.\xca*\x81\'\xc9\'a+\xc5+\x0b*d%\xb0\x1fO\x17\xc1\x0e\x0f\x08\xa4\x04\xb9\x02\xe4\xffV\xfbs\xf3\xbc\xea\xbe\xe2\xe1\xdf\xbd\xe0\x0e\xe4.\xe6\xea\xe4\x89\xe0\xd5\xdb\x0b\xdbn\xdf\xda\xe5s\xeb\x9b\xed\x12\xed\xb2\xeb\\\xeau\xed\r\xf1\x0b\xf7\x8a\xfbY\xfc\xbb\xfay\xf8\r\xf6M\xf6\x1c\xfa\x81\xfe=\x04\xfc\xff\xd4\xf7\x0e\xf2e\xf1\x7f\xf8u\xfd\xe5\xfe\x02\xfc\x0b\xfdC\t/\x1a\x81#\xc9\x1d\xa7\x18\xc8\x1f\x1d/\xd4A:M\xbcQVM[A\xe8<\xffB!M6P2H[9\xdc,\x86#x\x1c\xcc\x17q\x10\\\x07\xfc\xfa8\xed\xf1\xe2\xa2\xdb\xa2\xd6\\\xd1\xe5\xca\xa0\xc63\xc5/\xc6!\xc5\x95\xc3\xf3\xc4\xe5\xca\x93\xd4\x7f\xdc\x03\xe3\xa7\xe7@\xeb\xda\xef\xd2\xf7\xde\x02u\r\x04\x13M\x13%\x12\x15\x12\x10\x15_\x18b\x19p\x17K\x13U\r\x11\x07\xb1\x004\xfdE\xfa^\xf5\xdd\xee\xc2\xe8\xa7\xe3\xad\xdd\xb0\xd9j\xd8}\xd9\xe6\xd9\xd9\xd8\x1b\xd9&\xdb\xec\xdc\xfb\xe0L\xe6\x95\xeeA\xf6s\xfa#\xfeM\x02\x98\x08\xa1\x0f\xe3\x15K\x1b|\x1f\xd0!/#\x9e#\x92$\xb7%\xf7%\x1e%\xcb"\x80\x1f{\x1b\xcf\x16Q\x13A\x0f*\x0b&\x05\x95\xfe]\xfa\xdd\xf5;\xf24\xedY\xe9\xb2\xe6G\xe4\xe5\xe3$\xe3=\xe3N\xe1\xb9\xe1\x83\xe3\x07\xe6\xcc\xe8J\xe9o\xea\xb0\xeb\xd7\xee\xfd\xf2?\xf6F\xf7\x8c\xf8\xf3\xf9\xe3\xfa\xed\xfc\xa6\xffj\x018\x01\xa7\xfe\xde\xff\x11\x01c\x01\x8d\x01_\x01\xcc\x02l\xffT\xff\x05\x01\xee\x08[\x14\x92\x1b\xc2\x1d \x17\x8f\x14Y\x1bs*\xf98\x17=\xf8:\x813I/H0p67>!=\xee5\xf3)| \xd6\x1b\xd9\x19\xe5\x19\x97\x14\xd1\x0b\xa6\x00a\xf6@\xef\xd5\xe9n\xe8\'\xe7\xa3\xe4B\xdfQ\xd9.\xd5\xe4\xd3\xbc\xd5\xdf\xd9\x1a\xdf\xac\xe2U\xe3\x9c\xe2\x98\xe2\x13\xe6G\xec\x00\xf4\x1e\xfaK\xfc\x0e\xfc%\xfa\x15\xfa9\xfc\x16\x01\xaa\x05[\x06J\x02\x8d\xfd\xeb\xfa$\xfa\xd7\xfa1\xfb\xb5\xfa\x8d\xf6\x9a\xf1U\xf0\x7f\xf0\xd6\xef\xeb\xec\x88\xebt\xeeM\xf0!\xf1\x9f\xef@\xee$\xee\x1a\xf1c\xf7\xee\xfc\xbe\xfe\x0e\xfd\r\xfc\xd6\xfdQ\x02\xc9\x08^\x0e\x8f\x10\xa6\x0e\x1c\x0c\xe4\x0ct\x10\xbb\x14\xb1\x16\xed\x16\x04\x15\xa1\x11\xc3\x0e\xb4\r\x19\x0e\x05\x0e\xe9\x0b\xd0\x08`\x04\x1c\x00\xe3\xfc\xd6\xfa\x86\xf9\xa3\xf7\xdb\xf5\x85\xf3\xd6\xef(\xedE\xecl\xec\xe2\xed\'\xed\x12\xed\xf0\xeb\xf6\xea\x17\xeb|\xebN\xed$\xef\xae\xf0c\xf2\xe3\xf2b\xf2\xd2\xf1f\xf1b\xf6\xe9\xfb\x82\xff\x91\xfe\xaa\xf9\xc4\xf9\xe5\xfc\x0b\x04\xaf\x08\xff\x06\xd6\x02/\x01e\x07 \x10.\x15\xfd\x13\xc2\x117\x13y\x1b\xb0%\xcf*N)\xda%\x90\'N-[3\x8b675\x851\x0c-\x9c*\xa7*\x0e*\r([#\xbb\x1c\xaa\x15\x01\x10\xb2\r\xd9\n\xee\x05\x08\xffr\xf8+\xf4\xe5\xef\x0c\xed\xe2\xe9\x0b\xe7T\xe3\xdc\xdf\x15\xdf\x8a\xdf\xa2\xe0\x94\xdf\xd4\xde&\xdf\xf5\xe0\xbf\xe3\x81\xe6\xe6\xe8\xdd\xe9\xd0\xe9\x01\xea\xcd\xeb2\xefj\xf2\x02\xf4c\xf3\x0b\xf2\xe3\xf1\x0f\xf2V\xf3\xa1\xf4\xb0\xf4\x0e\xf4\xe5\xf2\xaa\xf2\xdf\xf2\x0f\xf2S\xf1\r\xf2\xab\xf3+\xf51\xf5P\xf5\x0e\xf6\xd2\xf6\xfd\xf8\t\xfb\x90\xfd\x9b\xff\xdb\x00i\x02*\x04\x8e\x05\x00\x08,\nl\x0c\'\x0e\xc0\x0e\x82\x0f\xf6\x0f\x8b\x10\xe5\x10.\x12\x86\x12m\x12Q\x11u\x0fU\x0eU\ra\x0c\x93\x0b\xe1\t\x82\x06\x88\x04h\x03X\x02\xf7\x00\xa0\xfe\xb8\xfb\xa9\xf9\x9f\xf8\xe5\xf8\x8f\xf7\xd4\xf5\xd6\xf5\xc6\xf5W\xf5\xf2\xf3B\xf0\t\xef\xc7\xf1u\xf5\xfc\xf6\x06\xf57\xf2\x00\xf18\xf1T\xf3\xf7\xf5R\xf6c\xf4\x08\xf4\x1c\xf8\x92\xfd\xb6\xfe\xe4\xf91\xf7\xb4\xf7\xba\xfa#\x00K\x04#\x06S\x05\xf6\x05\xd6\x06x\x07E\x07 \x06\xb7\n\x1b\x14\xbc\x17\xa0\x132\x0b~\t\x83\x11\xc7\x1a3 s\x1b\x08\x11\xc5\x0bx\x10\xbc\x1eO*\r(\xf7\x1b\xa0\x0f\x96\r,\x16\xf3\x1f\x16%\x8d"9\x1a\xb5\x11\x0f\x0e\x8a\x0f\xc8\x11\xdc\x12\x02\x11\x88\x0bK\x06d\x01y\xff_\xff\xcc\xfd\x0e\xfa\xc9\xf5\n\xf3 \xf1\xbf\xee\xef\xea\x92\xe8X\xe7\xcc\xe6\xdb\xe60\xe4\xb8\xe0\xab\xde\xc9\xde\xe1\xe0\x90\xe2c\xe3Z\xe3\x8b\xe2\x88\xe0\x95\xdf\xcf\xe2O\xe8-\xec\xff\xec\x96\xed\x12\xed\\\xee8\xefE\xf0\x05\xf6]\xfc\xa2\xff\xe8\xfe\x8e\xfc\x92\xfd\xe4\x00\x90\x013\x03\xb9\x07\xec\x0bJ\x0c*\nS\x07\xcc\x06\xfa\nm\x0f\xef\x11\xf2\x0e\xc6\n_\n\xa0\x0b\xa4\x0eH\x0f\x82\r\xf7\x07\xb2\x08\xf9\n\x1b\x0b\xeb\x06&\x04\x01\x05Q\x05\xc9\x03\xde\x02h\x01\xbb\x00@\x01_\xfe<\xfb\x89\xf5\xb0\xf9H\x00\xab\x01\x11\xf9\x16\xf2u\xf1 \xf3\x0e\xfb\xf5\xfe\xa5\xfbD\xf0N\xe78\xf0\x7f\xfa\x19\xfc|\xf9\xce\xf8\x9a\xf3^\xec&\xf1R\xfaU\xf5\x83\xf8\xc3\x01\xbd\xfe\xe1\xf6\n\xef\xc8\xf5\xa9\xfe\xdf\x08\xad\t8\x01B\xf7\xf7\xf9\x0e\x0bC\x0e\xd0\x05\x82\x01f\x03\x1c\x0f\xa8\x1e\x9f\x13s\xfc\x1a\xf7\xe6\x05\x9e\x1c\\#I\x1d1\x0eC\xfa\x06\x00y\r\x7f\x12\xc9\x15\x98\x1aW\x11y\x0b\xeb\n\xb5\x01R\x03{\t\r\x16y\x19\x18\x0f\x1c\x08>\x00\x08\x01\xe2\n\x1a\n\xa3\nr\rC\r\xa4\x07\x11\xfd\x0b\xf9\xc5\xff9\x01a\x05\xea\x06\x89\xfb\x95\xf8\xf5\xf6\x9d\xf4L\xf1\x13\xef\xc1\xf1N\xf5d\xfb\xb0\xf3\xb1\xea\xe8\xddJ\xe3\xd5\xf1|\xf5F\xf8\x9a\xef\xaf\xe7\xfa\xe33\xe8;\xf3\xf1\xf8&\xfe\xf7\x00\xad\xf2\xd1\xe9X\xed:\xfbE\t&\n\xc4\x06\x8f\xfel\xfd\xa3\x00\xc4\xfd?\n\x8b\x15\xcb\r\x8b\x06\xe2\x04=\x0f\x15\x11_\x01g\x02\xe0\x0e/\x14\x8d\x17\x10\x10B\xfe\xd7\xf7\x8e\x04\x1b\x15&\x10\x07\nW\x06m\xff<\xfc9\xff\xfd\x03\xe2\x02\xb1\xfcA\xfb\x0c\xf6u\xfa\xe9\x03\xd4\xfd\x12\xef8\xe5#\xec\x82\xf7\xa9\x02c\xf9\x8c\xf0Z\xe4U\xe1\x12\xfb\xeb\x06\x17\xfdH\xf2q\xe8\x8f\xe1\xef\xf2\xda\x05m\x08\xa2\x07\xa2\x06\x12\xef\x10\xdd@\xf3*\x0f\xce\x0f\x98\x0b\xdb\x02\xd6\xf6z\xf6\x85\xfe\xe5\x10F\x0ek\x04"\x02\x10\x05-\x04r\x03\xe6\r\x0f\x13}\x07@\xfa\xeb\xf93\t7\x16\xde\x15\xf1\x07\xf6\xfeU\xf7\x1f\xf4[\x13\xb3\x1c\xae\x15\xa7\x00G\xf1\xa8\xf8\xe1\x04\xe5\x183\x19L\t\xeb\xfa\xaf\xf4\x9f\x01`\x07%\n\x99\x0fO\t\xbb\x082\x042\xf4Z\xf3y\x082\x12Q\x0b\xf7\xfa\xd2\xf6\x87\xfb&\xfc\xe8\x05"\x07\xb3\xec\xae\xf2M\x03z\xf8\xf3\xfa\r\xf5\xc7\xf1m\xf8m\xf9\x9d\xf3\x7f\xedR\xeeq\xf0w\x02\x92\xfd\xec\xf0l\xeb,\xef[\x00\x8e\xf9\x1f\xf4~\xf8\xe0\xf1)\xfc$\x10n\x0b\x90\xf3v\xef\'\xfeS\t\xb6\x11\xc9\x06\x8e\xfc\xf9\x072\x11\xb0\x18H\x04\x07\xf3\x80\x00\x8e\x12\xc9#\x92\x14\xa3\xf3\x87\xfb\xd3\x14\x83\x0cp\x0c\xe7\t\xa3\xfc\xbd\x00\xb5\x05\xaf\x0e\xad\x03\x17\xfbJ\xf8\xb7\x08;\x0f\xd9\x005\xe8\xe0\xe1\xf7\x04\xa7\x12\xe4\xff\xfe\xfaW\xe8\'\xe5\xc9\x03\xbb\xf9/\xf6\x8b\xf5\xa2\xf2\xcb\xf3\xaf\xf7\x0b\xfez\xf9\xa1\xf5\x0f\xf6\x10\xf0|\xe2J\xff\x1b\x17h\x16\xbc\xf5\xc5\xd0\xa8\xdc\xde\x07\xf4$\x15\x12\x8b\t%\xf4?\xdb\xd1\xe6\xa6\x06\xd7/\xe4#\xa3\xff\x89\xdb\xf8\xd36\x11\xae;\n W\xf0\x18\xdb\x16\xf3k\x1dr*\x93\x18E\xfb\xff\xe1L\xeb\x7f\x1a\xd7&P\x12\t\x02\x87\xf2\x05\xfb)\x05\\\r\x9c\x1b3\x06\x0c\xf2K\xf9\xec\xfcd\x15\xe3\x12n\x01\x06\xfc\xc3\xf1c\xfb\x12\x03N\x07\x1b\x06\x1b\x04\xdf\x05\xf1\xf8\x8b\xf5\x99\xe1\xef\xe9\'%\xd8\'\xaf\xf6\xc7\xe0\x93\xe3\xcf\xef\xdc\x07\x9e\x04}\x01}\x04\x04\xebA\xf6_\xf8\xc3\xef3\xf0\x00\xfe\x8c\t\x10\x046\xf2}\xe1\xe4\xef\xb1\x08\xa0\x07\xf7\xfc \xfb\xe4\xee@\x06h\nc\x03\'\xf0\xd6\xf1p\x0f>\x11y\x10\xd9\x08_\x00c\xfd\xcb\x02\xe4\x07H\x07g\x08\x93!\xd7\x1d\xcd\x02L\xeb\x16\xedm\n\xfa\x1eb&\xe1\r\xc0\xfbO\xf5u\xf1k\xfa\xce\x08\xbf\x16\x1d\tx\x02\xbb\x03\xf5\x00\xc7\xf6\x0b\xe7\x7f\xec\xa2\xfaC\x08\x16\x19d\x0b\xc4\xf5\xe7\xe7\x93\xd1\xf6\xe9 \x0b\xbb\tG\x05\xfe\xf3\t\xfb\x10\xfd\x9b\xf5z\xe4x\xda\xd6\xfc4\x0f@\x1a\xb9\x07\xd7\xe4$\xe3/\xed\x0e\x08\xab\x061\xf6\x08\x07\xe4\x0cI\xf9\x1b\xf3\xca\xf9\x17\xfb\xf5\t\x0b\x0c\xb2\n\x05\x104\xfd4\xf8\xab\xfb\xf4\xfb\xac\x0c>\x19S\rv\x17\r\x0b\xae\xf2\xbb\xff<\xf8\x0b\x05\xe1\x16\xf7\x1d\xdb\x18\x9b\x03\x8f\xfc\xa5\xf29\xf4\xde\x0b\x96\x12\x02\x13\xdd\rr\x08\xfb\xf8\xf4\xe69\xfe\xd1\x05\x04\x00\xda\x0f#\t\xa6\xf4\xb5\xf2\xc3\xf8]\x04\x81\xee\xd5\xf2i\r\xb8\xf8\xff\xf9\xb8\x00J\nF\xdd\xcb\xcf\x10\x00X\x17v\x1b\x0b\xf0\x9c\xe5\x96\xe1Q\xea\x15\xfe\'\x0f\xc8\x16\xc1\x05o\xd5w\xd3\xb0\xfa\xb6\x0e\xc5\x1c\xe2\x11\x12\xf3\x1a\xd9\xa4\xee\x0b\x02\x14\x0c\x99\x1c5\x10\xef\xe8\xf8\xdb;\t\x0f$v\x16\xb6\xfb*\xf0\xfc\xf8\xe0\t\x19\x1c\xfb\x11~\xf6v\xfd2\x0b\xff\t\xf5\x0f\x03\xffW\xff\'\x0e\xdb\x10\xd4\x02\x00\xf6\x99\xfe\'\x08K\x0f-\x06\x89\x10\x81\x05\xa9\xe7\x86\xf9\xe4\xfee\xfe;\x17|\x11|\x03!\xee\xbb\xd7g\xf6\xd7\x154\x15*\x01?\xe8-\xec%\xfa\x97\x05\xfa\xf5\xaf\xf4\x95\xf9Z\xff\x9b\xf6$\xf7\xa5\x03}\xf0\x1e\xf8G\xf4\xe7\xefH\xfan\x0e?\nq\xf2f\xf4\x15\xe0K\xf1[$g\n8\xf7s\xffi\xef\x01\xf3\x94\nk\x12\x00\x00W\xfb\xae\xfcp\x03q\x11\xfc\x0c"\xfb\xa9\xf0}\xf04\x14\xa7)Q\x1c\x1d\xf4K\xebQ\xee\xcb\xefw"\x83@\xcf\x16^\xddF\xed\x07\xff>\x04\xb3\x16\x12\x1dV\x02\xb7\xea\xfb\x0b!\x10{\xfaH\t1\x06\xc0\xdc\x7f\xf5s \xc2!\xd8\xf9\xc8\xe7\x03\xf2\xc5\xe2\xcb\n@\x07\xea\xf8J\x10\x0e\x00\xb1\xf54\xd0\x02\xe4\x81\x0c9\x18R\x13\xc7\xebN\xdf\xf2\xe07\xf6\xb1\x079\x1b\xe8\xff\x98\xf1O\xf4\xe9\xe3a\xeeB\x0b\xa5\x130\x12\xb3\xfc\xf9\xda\xbf\xf3\xd2\x03\xf9\r\xe3\x0e=\x14\x1e\xf5t\xec~\xef\xd6\xff\xbd5T\x19\x90\xfb|\xdff\xd9\x17\r\x18*\xf07\x83\x14P\xd8\xbc\xc8\xcc\xef\xaf/\x1d"\x85\x12\xf1\x17\xd6\xe8l\xe8\r\xe8\xcd\xfd\xf80[\x19\xad\x081\xf4]\xdf\xb0\xf2g\x0c1!d\x0b\n\xed\xa6\xf1\xaf\xfb\xd7\xff\xbc\x13t\xf8d\xe2\x90\xfd\xd2\x12V\x04\x96\xe1\x01\xfaN\x17h\xf0\xb3\xe8\xdd\xf9U\xfe\xf9\xfdR\xf1!\x17P%\xcf\xdf\xca\xb7B\xee\xf4\x0f\xcd)\x01-`\xf1\xe9\xd3u\xd98\xf7\x89\t\x93\x10\n\x1b8\x14f\xe6\xcf\xe3d\xed\x06\x04p\'\x14\x13\x1c\xf7H\xdb_\xeb\xf2!\xd6+J\x1e_\xe9\xba\xc6H\xf2\x04\x11\xb8;\xb95\xdb\xffa\xc7\x9c\xd5\xdb\x13\xdb-\xb3#n\xfaa\xf6\xa8\xfc\x0f\x07\x84\xfaV\x00\xea\x15\xde\x02\x8f\xf4\xf8\xffY\xf8l\x0f\xbc\x0f\xb3\xf8\xba\xe6\x9e\xed*&\x14\xfc.\xe4\xcf\x0c\x14\x02\xc8\xf1\xdb\xf5\x84\xe8y\x13e\x0b\x8b\xe5\x89\x11\x05\xf9\xd9\xe9\xbe\xf5\xeb\xf2\xfa\xfd3\x19\xe7\n\xec\xf4x\xec\x00\xf4\xeb\x00a\xf2\xd4\xf3\xb0\x14\xc5\x1e\xff\x07\xb2\xe3V\xdb\xa9\xf1[\x13l*\x97\xf2`\xec@\xf8\x1d\x06\xf97\xe5\x01\x00\xc5\xea\xd4\xe4\t\xc0=\x08A\x1f\xf09\xcb\r\xf0\xbc\xff\xf2\x04\x18\x1f\xd2\r@\x08H\t\xad\xe9\xeb\xf3u\x0b\x1c\x0f;\x01\x98\x0e&\xfb\x80\xffh\xf5\xff\xff\x89!\xe3\xfd\x99\xf8\x04\xf2\x07\xe5B\x0b\x1c!\xf0\x1c\'\xf87\xbf\xd0\xe5\x06\r\xac#\x91\x1c\x10\xee\x07\xecA\xe7\x1f\xec\xa7\x12\xcd\x07\xac\xfa+\x06\xd0\xe2\xa2\xe9f#\xdd\n\xcd\xfaW\x05\xba\xcb\xb5\xdc\xb8 m# \x10\xf4\xfd\'\xe2\x08\xf4E\t\xb3\xf6\x9e\x05\x9d\x05\xc0\x02\x14\nI\x03!\x06\xa3\x00\x03\xe3\xa7\xf8)\x19Z\x16\xca\xf92\xf0\xfe\x02\x9f\tb\x06\xd2\xff.\xf8\xbe\xfb\x91\xfao\x0f\x17\x0eD\x03(\x07M\xea\xd3\x08p\xf0\xd1\xef+%\r\x01\xd4\x00\xbe\x19n\xfec\xe0\x87\xe2\xe3\xff\xa1\x1b\xb2!_\x065\xef \xf0[\xe6\xbf\xf1r\x1b#\x1b\xb8\xff\x80\xfdC\xe2\xa5\xe8\xed\x04H\x12A\x10\xa6\xf6\xb5\xf3t\xf5v\xf4\xec\x05[\x03e\t\xa8\x14\xa2\xd8\x1f\xdd\xbb\x1ct\x0fZ\x00*\x0f#\xea\x19\xe5w\xf8\xc2\x10\xa0!\xbd\x02#\xfa\x16\xed8\xe2\xdc\x03\x84$p\x06f\xff8\x04\'\xf5\x1c\xff\x82\xf1\xd5\xf7^\x14\xaf\x19\xd7\x19j\xe4\xe4\xd7\x18\xfb\x8d#\xd7\x13\x91\t\xc3\xee\x96\xdfI\x0c\xf3\x17z\x10\xa8\xf3y\xe7\xec\xf2\xa3\x1c\\\x02\x85\xfb\x9b\x17\x82\xf7H\xeb\x94\xfa\x90\xff\x00\xf6\xef\xfb\xbc"\xa6!\xe4\xe0f\xe0\x1b\xee\xc5\x03\xf5\x11.\nJ\x01\x10\xfe>\xfa\x85\xfb-\xfc\x00\xe2w\x00\xa8%\x8c\n\xe1\xe2\x84\xf4r\x0bR\n\xb1\x08\xc4\xeac\xd8\xe9\x02\xb2&)\'p\x07\xa6\xc7J\xd0\x83\x18\xc4&\r\x01:\xfe\x0c\xfay\x03\xbd\xfe"\xf6\xc8\x04\x9d\xf2\x85\x00\x0b\x1e\xfb\xf9h\xf4w\x1e3\t\xdf\xd4\x10\xe7\xf5\t\xda\x1ae,\x9c\x04Y\xe0\xce\xd7\xfa\xfdZ3(\x1a\x0b\xe1\xee\xe7\x94\x03A\x12\x0f\x10}\x00\xbe\x04\x90\xe9\xc2\xf4o\x0c|\xf9\xe4\xfb\x8c\x14\xfa\x06\x85\xfe\x01\xef(\xf2\n\xff\xe1\xfd\xdf\x15\x0c\xf9\xd1\xef\xd1\xee\xe1\n\x051\x90\xe3P\xd0\xea\x0c\x9f\x17\x9a\x05$\xf65\xef\xed\xf2E\x12\xe6\x1a\xac\xf3\x9b\xf0\xa4\xf9\x89\x0b\xdc\x08B\xdf\x81\x04%#"\xfb7\xf6@\xfd\xf7\xeel\x07\xd9\x04\xef\t\xc1\x1aL\xe8\xba\xdb2\x04\xb7\x18\x98\x16\x87\xff}\xe3%\xec\x18\t\x16\x1cz\xff4\xf4\xbe\x04\xcb\xf7!\xfaw\x0b\x1e\x02r\xff\x8a\xf7v\xff?\r\x7f\xf7\x01\xfc\xc2\r\xdf\t)\xfc"\xe6\xfb\xee\xc1\x193\x1a\x9c\xffA\xfc\xc9\xebJ\xde\x8d\x16\x8b4^\xfd\xc9\xde\xf9\xe2^\xfdU\x1e\xbe\x13-\x05\x0e\t\x9f\xdf\xc3\xc7\x0f\x0f\xe5/P\x17\xb6\x01\xee\xdb\xdb\xe8g\xfb\x1d\x02\xdb\x15:\x1c\x14\xf1!\xdd\xc5\xf6\xb9\x11\xff\x1e\xae\xefo\xe2\x1a\xf8\xbf\x12\x81\n9\xf6~\x0f\xa7\x05\xf1\xfa\x92\xdd\x95\xe4\x0c e&\xa4\x0e\t\xf0\xc8\xea\x91\xea\x8f\xf9\x90\x1aQ \x81\x04\xc9\xe6\xd9\xea\xf1\x01v\nD\x14d\x17\xf4\xee\xed\xdf\x18\xec<\r 2?\x0e\xcf\xf1\x00\xea\'\xdf\x04\xf5\xf0\x1d\\/\xee\x0b\xa6\xdf\x89\xd5L\xfe\x1c\x18k\x0b+\x0b-\xf2"\xfb\xcc\xfd \xef\xdb\x13\xe2\x03 \xf4&\xff\x98\x04\x19\xfas\xec\x98\x0e\x8c#\x01\xf0\x1b\xd4B\xff\x05\x14\xaa\nQ\x05\x7f\xf9\xa4\xfe\xb1\xec\xcf\xfa0\x13\xaa\xf4K\xfdd\x12\xb0\x12\xf2\xfa\x88\xd9\xfc\xe4\xf5\x0f\xc5*\x9a\x15\xc1\xfd\x0c\xe0g\xd9{\xfd\xa3\x1d~*\xee\xfdE\xea\xf2\xf0\x13\xec\xb1\n\x94\x1e\x92\t\x1c\xf9.\xfa\x1a\xdey\xf7\xb3"\xf4\x1c!\n3\xe6q\xdar\xf3M\x13\xf7\x1a\xf2%\xfa\x00<\xcc^\xe8\xf8\xfdU\x10\x19&\x91\r\xc4\xfd\xda\xe3\xac\xe1\xf2\xfa\x8b\t<"\xe4\x16\t\x00~\xd8\xa0\xe3\xfd\x06\x83\x17\xfd\x1b\xc0\xf1\xc2\xdb\xa2\xfb\xe5\x172\x14\xee\xfc\x7f\xf4X\xea(\xf2\x07\x07>\x04\x9e\x0b\xb3\x18\x1d\x00\xeb\xe4n\xf3#\xf3\x97\x02\xd9\x0b\xe2\r\xd8\x11Y\xf9\xbd\xf1\xe2\xf2\xf4\xf7\xfe\x08\x9a\x10\xf5\x02\xb6\x03i\xf7W\xf0\xab\xfc\x1c\to\x13\xd4\rD\xf5d\xe4+\xfb\xec\x07\x9f\x06\x82\r\x13\x08\x06\xf7\x03\xeb\xc6\xf5\x89\x19m\x1d\x7f\xfc\x99\xdd\x11\xeb\xa4\xfe\xa1\x1e\'\x1d\x8d\x07\xd9\xf6e\xde+\xef\xda\x04\xd6\x0b\xa5\x11\xc4\x12c\xf1\xab\xedN\xef\xcf\xf8\xd6\x167\x14(\xfaA\xe6\x88\xf4\xc5\x0e\xc0\x02\x10\xf9\x05\x13\xb3\xfd\xad\xe2Z\xef\x8d\t\xb8\x11\x94\x12X\n\xb3\xef\xb9\xd8e\xf2]\x1a\xfe\x13^\x04K\x01z\xf6\x8a\xed\xa5\x00\xa8\x06k\nD\x0b\x15\xf6`\xed\x98\x07J\x06+\rY\xff\x8e\xeb\x11\x0c\xd2\xfe\xf6\xf6"\x10\x9a\x06j\xefa\xfd\x8c\t\xe2\x05\xc0\x04v\xfc,\xf9^\xfaT\xfb\x8a\x108\n\x91\xf7v\x06\x87\xf9E\xf9\x98\x03b\xfb\x81\x02\xa0\x0b\x0c\x00\xea\xf2n\x01\xc3\x0f\x01\x04\xae\xe8\xc7\xea\xc8\x05>\x19\xe9\x0e\x18\x00\xa8\xea;\xe3\xdd\x04\xe2\x10\x1e\x0b\x9e\x02c\xef%\xf0\xd3\x08\xb4\n\xad\x03\x16\xf6\xb8\xfe\x14\xf4\xc5\xec\xad\x15\x10\x1a\xca\x08L\xee\xdf\xde\xc1\xf3i\n#\x17*\x1d\xb7\x00\x8e\xdb?\xee\xda\x04\x88\r$\x0f\xe7\x06`\xf3O\xf2s\xfd)\r\xb3\x19\xa3\xf9\xcf\xe24\xf2\x17\x05\xc6\x16\x17\x14(\x08\xbb\xf4\x82\xe2\xe9\xf2\xe6\x04\xc3\x13\x8f\x11\xcd\x08~\xf9\xd7\xea6\xf6\x98\x06\xc3\x0b\xc4\x0eD\xfd\xf1\xee\xc0\xfb[\x07Q\x06\xfb\x08\xa1\x06\x9c\xf2\xfb\xefu\xff\xd9\x05<\x08\x1f\x0b\xfd\x04=\xf9\xed\xe8i\xf5\xc7\x0c\xbf\x0c\x13\x05\x7f\xfb\x93\xf7<\xf6+\x00\x0c\x04\xed\xfe\x07\xfe\xfa\xf95\x00\x9c\x01\x04\xfe\x10\x06.\xfd\xeb\xf6\xa3\xf9\x94\xf6\x17\x03\xb4\x0f/\x0bn\xfc\x0b\xf1\xeb\xf1\x80\x05x\x11\xbe\x01v\xf8\x1c\xfe\x16\x02\xe5\x04\xb0\x04`\x05\xa3\xfd\x01\xf4D\xf7"\x0bF\t@\x08n\x07g\xf9\xb1\xf7\x7f\xf3+\xfc+\x08\x8b\x0e\xd5\x10\x98\xf8\x03\xf4\xcf\xfe\x1e\xf6\xf2\xff\x07\x13\xa9\x03\x1a\xf8\xc3\xf6\xac\xfe\x92\r\xb2\x02\xdf\x02&\x01G\xe86\xef\xe4\x0cE\x17B\x0b\xf5\xfc\xfc\xf0~\xf2\xe4\xfa)\x02\xbe\x0f\xb6\x02\xba\xf7\x8d\xfb*\x004\x07,\xfdx\xf2,\xfd\x8c\x04\xb8\x00\xc1\xfef\xfes\xff4\xff\xbe\xfcq\x00,\x01\xb8\x013\xfd.\xfb\r\x02\xc1\x02\xe6\x00\xbb\x00\x8f\x01"\xfe\xbb\xfd\x82\xfbG\x01\x91\x06P\x02\xec\xfc\x13\xfb\x0c\x05\x9a\x03}\x00\x10\x00\xc7\xffa\xfbB\x00\xdb\t>\x03A\xff\xf6\xffH\xff4\xfd\x11\xff\x91\x03\xc5\x06\xd7\x02\xce\xfd\xf0\xfcj\xfc\xf2\x01D\t#\x04w\xf9\x13\xf9\xe7\x03H\x04\xe9\xfb\x08\xfe\x18\x02\xbd\xff8\xfa\x97\x003\x01\xe9\xffe\x01\xf3\xfc\xb9\xfc\xd5\xf9p\xf7\'\x05o\x0e\xb4\x02\xd5\xf6\x13\xf5e\xfc`\x00R\x06\x05\t/\xff\xfb\xf7,\xf8\x82\xfe\xb9\nU\x05\xec\xfbx\xfcx\xfc2\xfc\xa2\x01\xf0\tT\x03M\xfd\x96\xf7\xbf\xfc\x9b\x04\xdf\x02\xfe\x03\xaa\x00\x06\xffr\x01\xf4\xfd\x93\xfb\x8f\x022\x04\xe9\x00\xcc\x01\x97\x02\xe9\xfc1\xfc\xce\x02\xd9\x03k\xff\xe4\xfb6\x01\xa7\x00~\x00H\x02e\xfe{\x02\xb9\xff\xc0\xf9m\x00\xf5\x02\xcf\x00\xf0\xff\xdb\xfe\x81\xfeA\x019\x01\x00\x00\x13\xfe\xc8\xfa\x8c\x00\xab\x02\xd7\xfd\xe9\x00*\x02\xad\xfe\x87\xfdU\xfc\xfe\xfd/\x01\x04\xff\xf0\xfd\xc2\x02\xaa\x00\x9e\xfda\xffy\xff\x15\xfe,\xfe6\x01\xbc\xff\xd1\xfd\x9c\x01Z\x03w\xff\xc3\x00e\xff\xf1\xfa\x08\x017\x02\xa9\xff+\x00,\xfe \xffk\x03\x00\x03\xcd\xff\xd6\xfa\xc7\xf8Q\x00\x03\x05\t\x04&\x00\xac\xfd\xaf\xfbP\xfdw\x03&\x02\xb5\xfe\xf1\xfb\x97\xfe\xd1\x03\x99\x05\x89\x01\x81\xfb\xdd\xfc\x95\xff\xb6\x00T\x03$\x02\xb6\x02[\x04\x0e\xfd\xc3\xf9\xab\xfdz\x02\xd1\x06\x82\x04F\xfd\xad\xf8\xcf\xfb\xed\x01\xdc\x06\xf1\x04\x1a\xfd&\xf7_\xf7\xe7\xff\xcc\x07\xaf\x06\x8c\xff-\xf8\x87\xf6`\xfc\xb0\x05r\x06Q\x00\x8c\xf8;\xf4\xfa\xfas\x02\xa3\x05 \x003\xf9\x1c\xf7\xb5\xf8\xd9\xff\x7f\x07\xd9\x05\xfe\xfd\xdb\xfbQ\x01B\x08\x16\x0e\xd3\r\xa6\t_\x07r\x08\xd3\x0e\xeb\x15\x01\x15\xcb\x108\x0c\xf4\n\xc1\x0e\xaf\x11r\x0f?\x07\x0b\x03$\x04\xbf\x04^\x04\xa5\xffV\xf9\xc0\xf5l\xf3\xc6\xf3\xb9\xf5\xf3\xf3\xa4\xf0\xa2\xeeV\xee\xa9\xee\xc0\xef\xaf\xf1\xaf\xf3s\xf3\xc5\xf3\x8d\xf6^\xf9h\xfd\xc2\xfe\xb6\xfd+\xfe\xf0\xfe\x8a\x02\xdb\x08\x87\n\x96\x05\xf2\x00\x87\x01\xc3\x06\xde\x08#\x07d\x04\x9a\xfd\xd5\xf9\x8c\xfd\x7f\x05v\x06\xc7\xfb~\xf1\x87\xef\xcc\xf6i\xfe\x08\xff\x06\xf9\xd6\xf0`\xf0\xec\xf4\xbd\xf9\x83\xfe\xf8\xfa\x84\xf6\x93\xf6\xa3\xfcb\x00\xc5\xfd\xab\xfd\xd0\xfdy\xfe\xc2\xff\xc8\xff\x92\x00\xfb\x02\xa6\x02z\x04\xa1\xffy\xfa\xc4\xfb=\xff\xe7\x05\x02\x06\xe0\x00\xda\xfc*\xfc]\xfcp\xff\xf4\xff\xf5\x00_\x01\x00\x00/\xff\xca\xff\xd8\x00\xc3\x00\xde\xffd\xfd\xc7\xfc(\x00\n\x046\x04K\x03U\x01u\xff\xba\x01\xf9\x02\x87\x02f\x05\x8a\x0b\xd7\x11\xc1\x14\xae\x11\xa0\r\xf5\x0e_\x14Q\x1a\x11\x1e\xf0\x1dP\x1c\x1c\x1c\x86\x1c\xca\x1b\xaf\x17\x95\x12M\x10@\x0f\x1f\rA\n\xe5\x06\xbc\x02\x80\xfe7\xf9b\xf3/\xefm\xee\x82\xeeX\xed\xe0\xeb\xd9\xea\x97\xea\xe1\xea/\xec\x07\xed\x8a\xebY\xea\t\xed\xab\xf3\xf0\xf8j\xfaK\xf8\x1e\xf6a\xf7\x1a\xfa\x06\xfdh\xfeM\xfd\x0f\xfb\x1e\xfbl\xfd\x91\xfe\x07\xfd\xfd\xf9"\xf8>\xf7\x90\xf7\xb9\xf8$\xf9\xab\xf8\n\xf7w\xf6\xbd\xf7\xd9\xf8\x01\xf9\xac\xf8\xfb\xf81\xfa\xa6\xfb\x90\xfd\xa2\xff\xb6\xff\xd7\xfe\xc7\xff\xe6\x00\xf0\x01\x17\x03\xe9\x03\x81\x04S\x045\x04\'\x05\xf2\x05\xcf\x05\xef\x04T\x04>\x05-\x06X\x06r\x06\x1c\x06L\x05\x80\x058\x06\xc1\x06_\x07\xb8\x06<\x06l\x07\x0e\x08\xd8\x07\x91\x07\xcf\x06B\x06%\x06\x1c\x06\x11\x06\x15\x06h\x04.\x03\xf3\x02\xbd\x014\x01\x7f\x00\x0b\xff\xed\xfd\x1c\xfe\xd8\xfd\xb9\xfd\xc0\xfd\xbf\xfd\x8d\xfc[\xfc\x9f\xfcF\xfd\x03\xfe\xe6\xfd\x1f\xff\x06\x00\xdf\xffV\xff\xa2\xff\xe6\xff8\xff#\xff\xd4\xff\xcf\xff\\\xff\xbb\xfeO\xfem\xfd\xbd\xfc\x7f\xfc\xb5\xfb\x8e\xfb:\xfb\xe1\xfa\r\xfb\x88\xfa-\xfa\xb1\xf9\xfc\xf8]\xf9\x89\xf9\xc6\xf9\x97\xf9\xa3\xf9>\xfaE\xfa_\xfa\x04\xfas\xfa\x05\xfb\x90\xfb!\xfc/\xfc\xd3\xfc*\xfd\xd3\xfcA\xfd4\xfe\x82\xfe]\xfe\xb2\xfe\x0b\xff9\xff\x9b\xff\xa8\xff,\x00E\x00G\x00\xda\xff\xc0\xff\xe1\xff\x0c\x00\xcb\xff\xb8\xff\xa4\xff\xf6\xfeh\xff\xcf\xff\x92\x00\xf5\xff\xb0\xff:\x01\'\x04\x8b\x07<\n\x85\x0b}\x0c4\x0f\xc0\x12\xf9\x16<\x1bj\x1f\x03 \xaf\x1e\xdd\x1e\xce!4$\xbb"\x15 \xa9\x1dk\x1bt\x18\xe4\x13\x88\x10\xd9\r\x82\t\x07\x04\\\xfe&\xfb\xc3\xf9Q\xf6\xe6\xf1\x87\xee\xb9\xec\xd3\xea\x80\xe9Q\xe9\xc3\xe9\x1d\xea\xe6\xe8\xc1\xe7\x97\xe8L\xea\x85\xeb \xec\xb3\xec\xbd\xee6\xf0F\xf0\xeb\xf0\xec\xf1`\xf3<\xf4\xd1\xf4;\xf6\xc4\xf7\xbb\xf7|\xf7^\xf9\x07\xfby\xfb&\xfb\x84\xfb\x84\xfc\x9b\xfci\xfc\xfe\xfd\xf4\xfeB\xfe\x89\xfdM\xfd\xdb\xfdF\xfe\xc2\xfd\xb5\xfd\x88\xfe\x80\xfe\xf7\xfd\xff\xfd\xe0\xfep\xffK\xff\x14\xff\x19\x00\x05\x01m\x012\x02+\x03\xd1\x03\x07\x04h\x04]\x05\xb6\x06\xbc\x07a\x08\xd8\x08W\t\xee\t\xc1\nb\x0b\xaf\x0b\xdc\x0b\x98\x0b\xb4\x0b\xd4\x0bW\x0b\xde\n\n\n\xb5\x08\xa4\x07\xc5\x06\x8b\x058\x04n\x02N\x00\x10\xffI\xfe+\xfd\x0b\xfcV\xfa\xa9\xf8)\xf8\x9d\xf7Z\xf7\xb5\xf7\xb5\xf7i\xf7\x9a\xf7>\xf8\xf3\xf8\xdd\xf9\xa4\xfa[\xfb!\xfd\x97\xfe\xd3\xff\x1c\x01j\x028\x03\xa9\x03\xab\x04\xdc\x05\xc3\x06\xb6\x06&\x06$\x06M\x06\x8b\x05.\x04q\x03\xc7\x02\x1e\x01I\xff\xfe\xfe\xdc\xfe\x97\xfd\x9b\xfb\x98\xfa0\xfal\xf9\xb6\xf8\xd2\xf8k\xf9u\xf9\xb2\xf8H\xf8&\xf9\x81\xf9y\xf9\xaf\xf9 \xfa\xd7\xfay\xfbw\xfb\xbc\xfb\xa1\xfc\x84\xfc1\xfcl\xfc\x9c\xfd4\xfe\xe6\xfd\xe7\xfd\x07\xfe\x16\xfe\xe8\xfd\x87\xfdT\xfd\xa4\xfd\x7f\xfd\x85\xfd\x03\xfd\xab\xfc\x85\xfc\xef\xfb\x07\xfc\x81\xfc\xcb\xfc\xe3\xfc$\xfd\r\xfd\x85\xfdN\xfd\xf5\xfdA\xff\x17\x00\x08\x01\xff\x01\x8c\x02\x99\x03/\x05\xe4\x05\xbc\x06\x04\t\xde\x0c\x81\x10z\x12.\x14\x83\x17L\x1aY\x1b_\x1cu\x1f\xb0#\x1b%W#\t"\x87"A!\x18\x1d\xde\x19\x90\x19\xff\x16\x99\x0f\x9b\x08\x90\x06Z\x05i\x00\x8f\xf9\x80\xf5\x88\xf3\xaf\xef|\xea\x98\xe8\x0e\xeac\xea \xe7v\xe4F\xe5&\xe7\x1f\xe7c\xe6\xcf\xe7\'\xea\xd6\xea\r\xeb\xac\xecD\xefH\xf0\xc3\xef\xf4\xf0\x8e\xf3\x02\xf5\x87\xf5k\xf6\xdf\xf7\xca\xf8O\xf9\xc8\xfaj\xfc\xa2\xfc\xf9\xfb\xa3\xfc\xef\xfd\x7f\xfeP\xfe\xcc\xfeF\xffL\xfe\xf8\xfd\'\xff\xab\xff\xcc\xfe\xcd\xfd$\xfe6\xff\x82\xfe\x8f\xfe\xff\xffS\x00g\xffE\xffn\x00\xe9\x01\xdd\x01\xfb\x01d\x03\x16\x04z\x044\x05\x81\x06\xcb\x07\x04\x08\xf2\x07\x00\t)\n\x8b\n\xb0\n\xfb\n[\x0b?\x0b\xde\n\xd2\n\xe3\nG\nH\t\x8b\x08\x0e\x08\xa1\x07z\x06[\x05d\x04H\x03\xbd\x01\x91\x00\xf3\xff\xf5\xfeY\xfd\x18\xfc\x82\xfb\xb4\xfal\xf9\xaf\xf8[\xf8\x99\xf7p\xf7e\xf7\xcc\xf6\xa7\xf65\xf7\xab\xf7K\xf8j\xf9\xef\xf9\x82\xfa\x88\xfb\x83\xfc\xe6\xfdI\xffm\x00\x89\x01\x95\x02=\x03\x9d\x03M\x04\x1a\x05\x12\x05\xeb\x04\xb4\x04\xa0\x04a\x04s\x03\x8d\x02\xf1\x01c\x01X\x006\xff\x06\xff\x8c\xfe\x8e\xfd\xd2\xfc\xc9\xfc\xb5\xfcJ\xfc\xf1\xfb\xf7\xfb"\xfcZ\xfc\x88\xfc\xfc\xfcr\xfdu\xfd9\xfd\x8f\xfd6\xfe\x82\xfe\xdb\xfe.\xff,\xff\x0f\xff\x0f\xff1\xff\x9b\xff\x08\x00w\xff"\xffW\xff?\xff\xd3\xfe\xaa\xfe+\xfe\xca\xfdD\xfd\x8c\xfc0\xfc\x02\xfcx\xfb\xc7\xfae\xfa{\xfaK\xfaM\xf96\xf9\xdf\xf9o\xfab\xfaj\xfa\xcb\xfa\xef\xfa\x0b\xfbh\xfb\xf2\xfb\x8c\xfc/\xfd\x95\xfdI\xfe\x1a\xff\x1c\x00\xb1\x00\x8c\x01\x99\x03@\x06\xee\x07\x02\nf\x0e6\x13\x9c\x15Z\x16i\x18Y\x1dL!\xf4!V"\xb2$Y&\x16$\xef \xb4 \xa5 \xff\x1b6\x15\x13\x12\x04\x11\xb6\x0c\x14\x05_\xffj\xfd3\xfa\xb3\xf3\xdf\xee?\xeeh\xed\xfd\xe8\xed\xe4)\xe5j\xe7a\xe6\xab\xe3~\xe4\xe6\xe7\xf6\xe8\x16\xe8A\xe9\xfc\xec2\xef\xd2\xee\xc0\xef\xb6\xf2\xbd\xf4\xd3\xf4C\xf5M\xf7\xbe\xf8\xe6\xf8e\xf9\xb7\xfah\xfbK\xfbw\xfb \xfc\xb3\xfc\x0e\xfdo\xfdy\xfd\x1a\xfd\x1e\xfd\xde\xfd4\xfe\x98\xfdS\xfd\xe1\xfd\xf6\xfd\x17\xfdB\xfd\xab\xfe1\xffN\xfeJ\xfe\xf2\xff\x0b\x01\xc8\x00J\x01\x18\x03?\x048\x04\xf6\x04\xff\x06{\x08u\x08\xdc\x08\x83\n\xb0\x0b\xd4\x0b\xff\x0b\x07\r\xbe\r^\r#\r\xa0\r\xd4\r\x0e\r\x01\x0c\x83\x0b[\x0bh\n\xe3\x08\xc0\x07\xcb\x06;\x05D\x03\xae\x01w\x00\xfa\xfe\xf9\xfcU\xfb8\xfa\x08\xf9\x89\xf7T\xf6\xcd\xf5[\xf5\x80\xf4%\xf4\x81\xf4\xc5\xf4\xcf\xf46\xf5\t\xf6\xe1\xf6\xd0\xf7\xa1\xf8~\xf9\x9e\xfa\xc2\xfb\xf1\xfc>\xfe\xd4\xff\xc4\x00\x84\x01\xba\x02\xa0\x03\xa7\x04\x8a\x05e\x06H\x07\x97\x07\x85\x07\x8a\x07\x03\x08\xe4\x07<\x07\xbf\x062\x06\xbf\x05\xd1\x04\xc2\x03\xd2\x02\xfc\x01\x1f\x01+\x00p\xff\t\xff4\xfej\xfd\x08\xfd\x00\xfd\xd3\xfcn\xfc<\xfcV\xfcx\xfcz\xfc\xb7\xfc-\xfdv\xfdw\xfdm\xfd\xd0\xfd_\xfer\xfe\x81\xfe\xc7\xfe\xe7\xfe\xe7\xfe\xa4\xfe\x89\xfe\xa2\xfet\xfe\xa5\xfd\xf0\xfc\xb9\xfco\xfc\xa8\xfb\xdb\xfa4\xfa\x9e\xf9\xe1\xf8(\xf8\xe5\xf7\xc3\xf7o\xf7.\xf7K\xf7\xb1\xf7\xe7\xf7\x16\xf8\x9d\xf8\x81\xf9s\xfa\x1d\xfb\xeb\xfb\xd0\xfco\xfd=\xfe,\xff\xe2\xff\xb9\x00{\x01\xeb\x01Y\x02\x94\x02\xf6\x02\xab\x03\x1e\x04x\x04"\x05\xf9\x05,\x07\x99\x08\x1c\n\xf2\x0b{\r\x0b\x0f\xda\x11S\x15\x99\x17\x81\x18\x15\x1a\xaf\x1c\x0f\x1e\xbf\x1d6\x1e\xc3\x1f\x15\x1fp\x1b\xa1\x18\xfa\x17#\x16\x04\x11\x02\x0c\x8d\t\x86\x06\x80\x00\xa0\xfaV\xf8\xb5\xf6\xec\xf1X\xec\x10\xea\x02\xea\xf7\xe7\xe9\xe4d\xe4\x1f\xe6G\xe6\xbd\xe4\x04\xe5\xbb\xe7\xdd\xe9\x02\xea\x82\xea\xfc\xec\xa4\xef\xdf\xf0k\xf1\x19\xf3u\xf5\xd6\xf6B\xf7O\xf8|\xfaO\xfcX\xfc6\xfc\xca\xfd\xf8\xff\x84\x00\xe0\xffy\x00\x08\x02)\x02\x0b\x01M\x01\t\x035\x03s\x01\xa8\x00\xe5\x01\x87\x029\x01:\x00\xfe\x00\x92\x01\x83\x00\xbf\xff\xcc\x00\xfd\x01g\x01{\x00.\x01\x89\x02\xc3\x02F\x02\x02\x03\x8a\x04\xf3\x04\xb9\x04n\x05\xd3\x06o\x07\x14\x07T\x07h\x08\xe3\x08\x8b\x08p\x08\xef\x08\x17\tl\x08\xf3\x07\xe2\x07m\x075\x06\x05\x05r\x04\xd9\x03\xa1\x02S\x01J\x001\xff\xc7\xfd\x85\xfc\xbb\xfb\r\xfb\x15\xfa\x1a\xf9\xa3\xf8T\xf8\xf1\xf7\xbe\xf7\xf0\xf7.\xf81\xf8\x86\xf8?\xf9\xfe\xf9\xac\xfar\xfbT\xfc!\xfd\xd8\xfd\xa8\xfe|\xff1\x00\xac\x00H\x01\xde\x016\x02^\x02\x91\x02\x07\x03[\x03g\x03\x87\x03\x9d\x03\xaa\x03n\x03n\x03@\x04N\x05\\\x05\xd7\x040\x05+\x06f\x06\xef\x05T\x06U\x07-\x07\xcf\x05I\x05\x06\x06\xad\x05\xb4\x03\\\x02p\x02\x06\x02"\x00\xa9\xfe\xa0\xfe0\xfex\xfc.\xfb[\xfb\x83\xfb\x91\xfa~\xf9\x95\xf9\xdf\xf9\x84\xf9\x1e\xf9W\xf9\x8f\xf9)\xf9\xc8\xf8\xfe\xf8\x81\xf9\xb1\xf9\x91\xf9\xa9\xf9\xe3\xf9:\xfa\xa4\xfa\x08\xfbl\xfb\xe0\xfbT\xfc\xb9\xfc/\xfd\xe8\xfd\x85\xfe\xc2\xfe\xf2\xfeN\xff\xc8\xff\x05\x00#\x00T\x00|\x00\x89\x00\xa1\x00\xd1\x00\xec\x00\xf4\x00\xd9\x00\xcf\x00\xff\x00#\x01%\x01&\x01\xf1\x00\xc7\x00\xbf\x00\x8f\x00i\x00N\x00(\x00\xf8\xff\xe1\xff!\x00Y\x00E\x000\x00\xc3\x00\xe7\x01*\x03\x99\x04c\x06Y\x08\x0c\nM\x0b\xea\x0cZ\x0f\xcd\x11>\x13$\x14C\x154\x16\x13\x16S\x15\xeb\x14h\x14\x81\x12\xa4\x0fF\rR\x0bl\x08\x93\x04&\x01r\xfeh\xfb\xe4\xf7\x17\xf53\xf3\'\xf1\xdb\xeeD\xed\xe0\xec\x9a\xec\x00\xec\xeb\xeb\xa3\xec~\xed-\xeeR\xef\xff\xf0p\xf2\x87\xf3\xb7\xf4R\xf6\xfd\xf7<\xf9^\xfa\x92\xfb|\xfc_\xfdY\xfe~\xffK\x00\x95\x00\xe8\x00[\x01\xb8\x01\xc9\x01\xe8\x014\x02\x1e\x02\xa9\x01L\x01/\x01\xde\x00\x08\x00G\xff\xf1\xfe\x9b\xfe\x0f\xfer\xfd\x10\xfd\xa7\xfc\x00\xfc\xb4\xfb\xe3\xfb\x10\xfc\xf5\xfb\xd4\xfb\x1f\xfc\x81\xfc\xb2\xfc\x08\xfd\xaa\xfdX\xfe\xdb\xfeT\xff\'\x00\x13\x01\xbb\x01\\\x02&\x03\x0b\x04\xec\x04\xb3\x05w\x061\x07\xb9\x07,\x08\x90\x08\xe5\x08\r\t\x03\t\xe4\x08\x9e\x08&\x08y\x07\xa3\x06\xa8\x05\xa3\x04\x9f\x03\x9b\x02}\x01T\x00+\xff\x12\xfe\x13\xfd;\xfc\x83\xfb\xe8\xfaa\xfa\x15\xfa\x04\xfa\x10\xfa:\xfa\xa0\xfa0\xfb\xce\xfbh\xfc\x01\xfd\xcb\xfd\x97\xfec\xffW\x007\x01\xea\x01^\x02\xa7\x02\t\x03t\x03\xb8\x03\xd8\x03\xdb\x03\xc9\x03\x8e\x03/\x03\xc3\x02H\x02\xa2\x01\xf8\x00f\x00\xee\xffz\xff\xe1\xfe(\xfe\x7f\xfd\xf7\xfc\xa3\xfcg\xfcC\xfcN\xfcL\xfcH\xfc\\\xfc\x9f\xfc\x13\xfd|\xfd\xdd\xfdv\xfe3\xff\xc7\xffH\x00\xe6\x00\x97\x01#\x02\x96\x02(\x03\xc0\x03*\x04a\x04\x90\x04\xd5\x04\xf8\x04\xd3\x04\xa9\x04\xa0\x04\x87\x047\x04\xf0\x03\xa3\x03/\x03\x9b\x02\x1c\x02\xbc\x01[\x01\r\x01\xa2\x00\x0f\x00\xa5\xffb\xff\x1a\xff\xb6\xfeg\xfeT\xfeh\xfeN\xfe\x10\xfe\xd2\xfd\xb3\xfd\xc1\xfd\xc9\xfd\xc6\xfd\xb6\xfd\xb5\xfd\xb1\xfd\xc5\xfd\xe2\xfd\xf2\xfd\xd6\xfd\xa9\xfd\xde\xfdU\xfe\xca\xfe\xf0\xfe\x1f\xff\x9d\xff\'\x00\x87\x00J\x01\xe6\x01\xbe\x00C\xff\x97\x01<\x07\x14\t\xbc\x03~\xfei\xffn\x03\xf0\x04\x80\x04\xed\x03\xe0\x00H\xfbJ\xf9V\xfd)\x00#\xfc\x88\xf6#\xf6\xdc\xf8 \xf9\x9e\xf6\xed\xf4\xc5\xf4\xb8\xf5\xb3\xf7\x0b\xfa\xc8\xfa\xdf\xf8*\xf7\xae\xf8\x08\xfd\x01\x01\x9c\x01\xd3\xff\xe3\xfeW\x006\x03\x01\x05X\x05\x0e\x05q\x04\x16\x04B\x04\xb0\x04\x14\x04\xe8\x01!\x00\x14\x00\xf6\x00\x7f\x00t\xfe\x0b\xfc\x9a\xfa\xc7\xf9\x10\xf9i\xf9V\xfca\x00\x0e\x01\xa1\xfd<\xfb\xe4\xfc\xd1\x01:\x08\t\x0f$\x13c\x0f^\x08\r\x08=\x10\xe0\x17M\x18\xda\x15\x90\x14\x97\x11A\x0c\x08\nk\x0cr\r\xe6\t\xed\x06\xaf\x05\x90\x01-\xf9\xed\xf3\x92\xf6\xfa\xfad\xfb\xe8\xf7X\xf3r\xee\xfd\xea\xf3\xech\xf3\xec\xf7\\\xf7j\xf4\x84\xf2j\xf2\x1e\xf4\xa4\xf8\x0c\xfe\xe0\x00\x88\x00e\xff+\xff\xc8\xff\xba\x01,\x05l\x08$\t\xce\x07\xc7\x05\x81\x03\x0e\x02\xd5\x02\xa9\x05\x05\x07\xda\x04O\x00\x10\xfc\xb9\xf9\xc7\xf9\xfe\xfbG\xfdc\xfb\x89\xf7\xb9\xf4/\xf4\x8d\xf4u\xf5\xee\xf6\x03\xf8\xdb\xf7\xe2\xf6\xa8\xf6\x85\xf7,\xf9\x84\xfb"\xfe\xcb\xff\xde\xff\x8d\xff9\x00^\x02\xb5\x04^\x06P\x07\xa4\x07y\x07\x18\x072\x07\xc2\x07\x8b\x08\xcf\x08j\x08Z\x07\xaf\x051\x04v\x03\x93\x03\xb6\x03\x0f\x03\xa8\x01\x17\x00\xe0\xfe?\xfe\x1a\xfes\xfe\xcd\xfe\xa0\xfe\xf1\xfd\\\xfdi\xfd\xc0\xfdC\xfe\x0c\xff\x06\x00\x93\x00z\x00<\x00d\x00\xdf\x00\x96\x01X\x02\xf0\x02\x02\x03]\x02l\x01\x02\x01M\x01\x02\x02O\x02\xdc\x01\xd7\x00\x94\xff\x08\xffn\xff\x0e\x00h\xff<\xfe\xab\xfe\x15\x00y\xff\xbf\xfcG\xfbB\xfd\x1a\x00\xca\x00\x1f\xff\xa9\xfc<\xfb1\xfc\t\xff\xa4\x00n\xffn\xfd\xf8\xfc\x84\xfd\xb6\xfd \xfe \xff\x92\xff-\xff\xd8\xfe\xd7\xfe\xb2\xfe\xb2\xfe\xd7\xffN\x01\xa4\x01\x16\x01\xb0\x00\xc9\x00\n\x01\x9c\x01\xac\x02\x92\x03\x89\x03\xfb\x02\x8d\x02{\x02\xf2\x02\xd7\x03\x8e\x04F\x04l\x03\xc0\x02\x9c\x02\xac\x02\xf3\x02&\x03\xb5\x02\xae\x01\xce\x00\x93\x00\x8e\x00,\x00\xbe\xffe\xff\xf9\xfei\xfe\x00\xfe\xcb\xfdz\xfdI\xfd~\xfd\xc2\xfdX\xfd\xaf\xfc\xe9\xfc\xb1\xfdv\xfes\xfe\x1a\xfe\x05\xfek\xfeb\xff*\x00;\x00\xe8\xff\x05\x00d\x00\xa5\x00\xb8\x00\x15\x01Q\x01\x01\x01\xc1\x00\xa7\x00\x86\x00*\x00\xf4\xff+\x00;\x00\xc5\xff\xfe\xfei\xfe3\xfe7\xfeZ\xfeh\xfe\x13\xfe}\xfdH\xfdq\xfd\xc8\xfd\n\xfe]\xfe\xc3\xfe\xfb\xfe\x03\xff,\xff\x90\xff#\x00\xb4\x00\x11\x01/\x01\x0e\x01%\x01s\x01\xc7\x01\x08\x02\x16\x02\x02\x02\xaa\x01U\x01K\x01T\x01Q\x01-\x01\xec\x00\x85\x00\xf8\xff\xb9\xff\xeb\xff\x0f\x00\xe5\xff\xa2\xff}\xffc\xff5\xffK\xff\x85\xff\xbc\xff\xcc\xff\xbf\xff\xb3\xff\x9a\xff\xa0\xff\xca\xff\x0f\x003\x00$\x00\xf6\xff\xc6\xff\xba\xff\xc2\xff\xcb\xff\xb3\xff\x97\xffz\xffP\xff.\xff\x15\xff\x13\xff&\xff%\xff:\xff;\xffL\xff_\xff\x97\xff\xe6\xff\x1c\x004\x00F\x00o\x00\xcc\x00\x17\x01)\x01\x19\x01\x0f\x01\x19\x014\x01U\x01r\x01J\x01\xec\x00\xb5\x00\xbd\x00\xcf\x00\x9d\x00U\x00\x01\x00\xb7\xffy\xffh\xffk\xff<\xff\xec\xfe\xaf\xfe\x9a\xfe\x9b\xfe\xab\xfe\xaf\xfe\xc9\xfe\xd2\xfe\xf5\xfe-\xff\\\xff\x83\xff\xbf\xff,\x00\x90\x00\xcb\x00\xe8\x00\r\x01I\x01\x8a\x01\xba\x01\xc8\x01\xa5\x01m\x01j\x01q\x01E\x01\xe2\x00|\x005\x00\xe0\xff\xac\xff\xa5\xffh\xff\xf2\xfeK\xfe\x0f\xfe&\xfe?\xfe\x1a\xfe\x01\xfe5\xfe\xb0\xfen\xfe!\xfeN\xfe&\xfe3\xfe\xc3\xff@\x04\x12\x05\xf6\xff\x86\xfbt\xfe\xae\x05\xb9\x07\xd4\x05\xa6\x03\x88\x01\xb8\xfe\x17\x00\x82\x06\x12\t\xb5\x03$\xfe\x16\xff\xa1\x01\xcc\x00W\xff>\x00S\x00\xd5\xfd\xc6\xfc\x04\xfe\xd4\xfdh\xfb\x17\xfb*\xfe\xbc\xff\x87\xfd\xe9\xfa%\xfb\xf2\xfc0\xfeN\xff&\x00\x8b\xff\x84\xfd/\xfdh\xff\xba\x019\x02\x84\x01\x14\x01\xb1\x00H\x00\xf1\x00I\x02\xbf\x02\xa0\x01\x86\x00\x89\x00o\x00\xbd\xff)\xff\'\xff>\xff\xc5\xfe+\xfeQ\xfds\xfc\x0e\xfc\x94\xfc`\xfdC\xfdz\xfc\x8e\xfb\x07\xfbP\xfb\\\xfcP\xfd,\xfd?\xfc\xbc\xfb\x0c\xfc\x93\xfc\xc4\xfc\x1c\xfd\xa6\xfe\xa4\x00w\x01O\x00W\xff\xc6\x00\xb2\x04\x83\x08\xcc\t\xe9\x08\x9a\x075\x08\xca\n\xd0\r[\x0f\x9a\x0e\xf4\x0c\xa2\x0b0\x0b*\x0b\xa8\n\x97\t\x06\x08\xf5\x05n\x03\x98\x00d\xfe\\\xfd\xdf\xfc\xfe\xfb\xee\xf9:\xf7F\xf5\xda\xf4\xbd\xf5\xe9\xf6y\xf7$\xf7g\xf6j\xf6\xf6\xf7g\xfav\xfc\xb8\xfd_\xfe\xdf\xfe*\xff\xfb\xff\xa0\x01/\x03\xc1\x031\x03m\x02\xba\x01)\x01\x0c\x01\xfe\x00\xa0\x00p\xff\xd6\xfde\xfcU\xfb\xf1\xfa\xfd\xfa\xe9\xfaO\xfas\xf9\xcb\xf8\xac\xf8\r\xf9\x1e\xfa6\xfb\xc6\xfb\xd9\xfb\xe5\xfb\xa3\xfc\xff\xfd\xa8\xff\x0c\x01\x94\x01\x99\x01\xde\x01\xbf\x02\xb0\x03]\x04\xdf\x044\x05\x08\x05\xae\x04\x84\x04\xab\x04\xc9\x04\xa5\x04\x85\x04\x17\x04t\x03\xe1\x02\x88\x02p\x02_\x024\x02\xc8\x01\x1a\x01\x93\x00l\x00\x81\x00\x85\x00]\x00\r\x00\xb1\xffq\xff[\xffO\xff\x13\xff\xec\xfe\xf7\xfe\xe9\xfe\x99\xfe=\xfe\x10\xfe\x0e\xfe\x1a\xfe6\xfeM\xfe=\xfe\x1e\xfe-\xfe\x80\xfe\xe2\xfe\x15\xff6\xffm\xff\xcb\xff"\x00]\x00|\x00\xa7\x00\xce\x00\xf6\x00\x1d\x015\x01/\x01\x0b\x01\xe4\x00\xda\x00\xdc\x00\xac\x00\\\x00\x1a\x00\x02\x00\xea\xff\xc5\xff\x93\xffi\xff0\xff$\xff9\xff^\xff`\xff5\xffB\xff}\xff\xad\xff\xcf\xff\x02\x00.\x00\\\x00\x80\x00\xca\x00\xfb\x00\xf3\x00\xf1\x00\x14\x01c\x01s\x01a\x01S\x01>\x011\x01)\x013\x01#\x01\xd3\x00\x8f\x00\x99\x00\xa1\x00{\x00)\x00\x07\x00\xff\xff\xd6\xff\xb5\xff\xbb\xff\xab\xff\x89\xff6\xff\x0c\xff1\xffW\xff\x05\xff\xdb\xfe`\xff\x11\x00\x97\xff\x04\xff\xb3\xffT\xff<\xfe\x9e\xff\xd0\x04\xd9\x05B\xff\x9e\xfa\xaf\xfe\x81\x05\xe8\x05\x80\x03O\x02\x08\x00c\xfco\xfe\xcf\x05,\x074\x00J\xfb-\xfeM\x01\x0c\x00\xd8\xfe\xea\xffo\xff\x03\xfdU\xfd\x84\xff\xfc\xfeR\xfc\xa6\xfc\x87\xff9\x00\xef\xfd_\xfc \xfd%\xfe\xfb\xfe\x07\x00H\x00\xa3\xfe\xfd\xfc\xcd\xfd\xdb\xff\xb0\x00\\\x00/\x00\xc5\xff\xdb\xfe\xc6\xfeB\x00B\x01\xd4\x00\xec\xff\xdc\xff\n\x00\xd2\xff\xde\xffQ\x00P\x00\xf4\xff\xef\xff(\x00\xc4\xff\xea\xfe\xb7\xfeq\xff=\x00\x0f\x00J\xff\x8d\xfe\'\xfe\xab\xfe\xf0\xff\xab\x00\xdd\xffx\xfeG\xfer\xff.\x00\x0f\x00\x83\xff\x07\xff\xb8\xfe\xf9\xfe\xb3\xff\xb1\xff|\xfe\x9e\xfd\x12\xfe\xdb\xfe#\xff\x15\xff\x86\xff\x89\xff\x18\xff\x02\x00W\x02\xdc\x04X\x05\xf9\x04M\x054\x06C\x08\xab\n\x8f\x0c\xf6\x0bs\t\x8b\x08\xf5\t\x8f\x0b\xfb\n\x94\x08\x00\x06\x1f\x04\xc7\x02\x02\x02\xa3\x00:\xfe\x91\xfb\xfc\xf9|\xf9V\xf8_\xf6\xf5\xf4\xef\xf4\xcf\xf5`\xf6p\xf6T\xf6n\xf6w\xf7\xe7\xf9z\xfc\xcf\xfd\xdf\xfd-\xfe\xac\xff\x9f\x014\x03\x08\x04\x01\x04V\x03\xc7\x02\x17\x03~\x03\xd8\x02\x10\x01\xae\xff\x16\xffD\xfe\xbd\xfc\\\xfb\x8c\xfa\x92\xf9\x9c\xf8[\xf8\x96\xf8&\xf8P\xf7\x88\xf7\xa5\xf8\xc2\xf9\x8e\xfa\x9c\xfb\xa2\xfcc\xfd~\xfeX\x00\x14\x02\xd5\x02g\x03\x8d\x04\xae\x055\x06i\x06\xf3\x067\x07\x14\x07#\x07\\\x07\xf4\x06\n\x06V\x05\x14\x05\x98\x04\x03\x04\x94\x03\x00\x03\x03\x02\x11\x01\x9f\x00\x8d\x00g\x00\t\x00\x92\xff\x08\xff\xc0\xfe\xdf\xfe+\xffn\xffP\xff\x03\xff\xd5\xfe\x04\xffW\xffk\xffy\xff\x92\xff|\xffK\xff\x16\xff\x02\xff\xe2\xfe\xc5\xfe\xc1\xfe\xbf\xfe\x90\xfe2\xfe\xcf\xfd\xab\xfd\xc4\xfd\xdc\xfd\xc9\xfd\xf5\xfd\x82\xfe\x98\xfe\xe0\xfd\x92\xfd\xae\xfe\x14\x00p\x00n\x00\x96\x00L\x00\x01\x00\x8d\x01\xdc\x03\xe3\x03\x03\x02\xbd\x01e\x03\xb7\x03\x89\x02\xaa\x02\x03\x04\xb3\x03\xec\x01R\x01\xc2\x010\x01$\x00\x93\x00w\x01\x83\x00U\xfe\xc6\xfd\xe9\xfec\xff\xbb\xfe\x8c\xfe\x03\xff\xb8\xfe\xdc\xfd-\xfea\xff\xa7\xff\xf8\xfe \xff\xc5\xff\x95\xff\xf0\xfeG\xff#\x00\x00\x00i\xff\x98\xff\xbe\xff\x02\xff\x85\xfe7\xff\x92\xff\x8c\xfe\xd6\xfd\x7f\xfe\xa5\xfe\x97\xfd\xf7\xfc\xaf\xfd\xe4\xfdW\xfd~\xfd\xd6\xfd#\xfdY\xfc8\xfdc\xfe\xe9\xfd\x1c\xfdf\xfd\xd0\xfdU\xfd\x9c\xfd^\xfeO\xfeN\xfdl\xfdU\xfeY\xfem\xfd\xe5\xfcT\xfdY\xfd\xf1\xfc\x90\xfcc\xfc\x07\xfc\xcb\xfb\xcb\xfb@\xfcn\xfc*\xfdB\xfe\xdf\xffj\x02\x0f\x04\x13\x05\xb4\x053\t\xa1\x0e\xaf\x12,\x14\xd3\x13K\x14n\x16\xe4\x19\x86\x1c\xe8\x1b\'\x19\xaf\x161\x15\x01\x14\x12\x12\xed\x0ec\n\xc7\x05\x9b\x02\x8a\xffd\xfb\xb6\xf6<\xf3\r\xf1\xf9\xee\x01\xed\xd7\xea\xec\xe8\x1b\xe8\x01\xe9\x9a\xea\xb6\xeb\xbb\xec,\xee\x02\xf0{\xf2\xd1\xf5\x0b\xf9\x07\xfb\xdb\xfc\xc2\xff\xff\x02_\x04H\x04M\x05\x8f\x07\xd0\x08\x12\x08,\x07\xaf\x060\x05\x04\x03{\x02\x1c\x03V\x01\xeb\xfc&\xfa\x8e\xfa\xa0\xfa\x81\xf8\xa3\xf6\x84\xf6\x1b\xf6\x85\xf4\xc7\xf4"\xf7\xe1\xf7\x84\xf6R\xf6\xd7\xf9\xba\xfc\x9a\xfcF\xfd\xeb\xff\x1d\x02\xf6\x01L\x03G\x07\xb0\x08\x14\x08\xb3\x08y\n\x11\n\xcc\x08/\x0b\xc8\x0c\x15\n\x13\x07D\x07\xf1\x07\xf1\x05Q\x043\x04\x01\x028\xff\x9e\xfe?\xff\xb3\xfd\x0f\xfbQ\xfax\xfa\xa7\xf94\xf9\x80\xf9!\xf9\xd6\xf7\xab\xf7C\xf9e\xfa\x0c\xfa\xba\xf9w\xfaW\xfb\xb1\xfbT\xfc\x95\xfd<\xfe\xbf\xfd\xee\xfd3\xff\xfc\xff)\xffo\xfe\xea\xfeb\xffB\xff\xe5\xfe.\xfe\x0e\xfd6\xfc\xa5\xfc-\xfd\xae\xfc\x96\xfbG\xfa?\xf9I\xf9w\xfbN\xfc9\xfa\xe9\xf7C\xf8\x13\xfa3\xfa\x04\xfb\x08\xfc\xd4\xfb`\xfa\x14\xfc\x1b\x01\xfd\x02\xbc\x01*\x02-\x07\xd4\x0b\x9a\x0e\xeb\x12<\x17\xab\x17u\x15\xf6\x18\xc7"\xa3(\xe5%\xb6 \x82 \xf5"\xa6"f \xf3\x1d\x1f\x1a\xd6\x13\xfa\x0e\xfb\x0c.\ta\x01\x9d\xf9\x85\xf6\xd0\xf5\x89\xf23\xed\xd8\xe7\xac\xe4\xcc\xe3\xa6\xe4\t\xe6 \xe6\xd1\xe5a\xe6\xfa\xe7\x8d\xeb\xde\xef\xe7\xf2\xc2\xf3Q\xf6\xad\xfb\xa0\xff\xd9\xff\xf0\xff=\x03#\x05\xf4\x04&\x06\xa8\x08\x1b\x07\x1c\x01\xb8\xff\x9e\x02\xe4\x01\x08\xfc\xa9\xf9\xee\xfb\xb0\xfa\xb4\xf4M\xf2\x9f\xf4\x8e\xf4\xb3\xf1\xae\xf2\xac\xf6\xc6\xf6\xa2\xf3^\xf4\n\xf9\xb9\xfb\xa8\xfc\x16\xff\xe9\x02\x8f\x03\x04\x03\xa5\x040\x08\xc7\t\xb0\t\x02\x0b\xa7\x0c\xee\x0c\xf2\nS\t\xdd\x08\xba\x08\xa2\x08\x91\x08\xb1\x07!\x05u\x01o\xff2\xff\xf4\xfe\xe0\xfd0\xfd\x8b\xfc&\xfb\xe8\xf8\t\xf87\xf8a\xf8\xd5\xf8Z\xfa8\xfbn\xfa!\xf9Z\xf9\x8d\xfa\xa0\xfb\x1d\xfd(\xfeM\xfeU\xfd\x1c\xfdR\xfd{\xfd\xe6\xfdu\xfe\x8b\xfeE\xfd\xdc\xfb\xa6\xfa>\xfat\xfa\x87\xfa\x9e\xf9\xd9\xf7X\xf6\xe6\xf5\x8f\xf5\x81\xf6\x03\xf7\x1c\xf6\\\xf4>\xf5\x0f\xf9B\xfa\x99\xf8\x93\xf7\xd9\xfa8\x00\x06\x04\x82\x06\x07\x07q\x06\x0c\x08&\x0e\xda\x16\xb5\x1b\x07\x1c\xe0\x1cm \'$\x11%\x8b&i)\x89+\x85*5(y&\n#J\x1d\x96\x17\xc1\x14\xb6\x12N\x0e(\x07\xd7\xff\xc6\xf9\xb8\xf4\xea\xf0\xbb\xedP\xeb\xe3\xe8\xda\xe6`\xe5^\xe4\xfc\xe30\xe4\xf0\xe5T\xe9\xc2\xec\x11\xefS\xf05\xf2\xa1\xf4K\xf7k\xfay\xfd=\xff\x0b\xff*\xff\xad\x00\xc5\x01t\x00\x9c\xfd\xaa\xfc\x84\xfd4\xfde\xfa\x00\xf7\x1b\xf5\xcb\xf3_\xf2\xa1\xf1\x8d\xf1~\xf0\xe9\xee/\xefP\xf1\x18\xf3\x06\xf3\x10\xf4\xa5\xf6O\xf9\xcc\xfb\xe2\xfe\x91\x02\xe4\x03\x80\x04\x94\x06E\n\x99\x0c\xee\x0cc\r\xe6\rr\x0ey\x0e\x8c\x0e\xf8\x0c\xbe\nw\t\xac\t\xe4\x08B\x06\xd1\x03\x1f\x02\x05\x01\xd8\xff\xe5\xfe\xff\xfdC\xfc\xf0\xfa\xc4\xfaO\xfb\xfb\xfb\xc1\xfbd\xfb\x03\xfbF\xfbd\xfct\xfd-\xfe\x14\xfe\xbc\xfd\x12\xfd\r\xfd;\xfd-\xfd\xcd\xfb\x12\xfa\xcd\xf8d\xf8Q\xf8\xf0\xf6n\xf4\xaa\xf1\x98\xf0<\xf2\xa6\xf3\xf0\xf2T\xf0\xab\xee\\\xf0\xa9\xf3O\xf6\xb9\xf6<\xf59\xf5f\xf8g\xfdK\x00\xce\xff\xe1\xfe\x0c\x01\xdd\x05\x8f\tm\nB\n)\x0b%\x0e\x9f\x11Y\x14\x7f\x15\xa5\x15|\x16G\x18\xe5\x1a\xf7\x1cF\x1e\xba\x1e%\x1e\x1d\x1eP\x1f\xf7 \x07!\xa9\x1eH\x1c\x9e\x1b\x84\x1b\x13\x1a\xf6\x16\xa2\x13\x8c\x10|\r\xb3\n\x81\x08\xd3\x05\xa0\x01\x0c\xfd\x1c\xfa\xd9\xf8\xea\xf6\x9a\xf3\x01\xf1\xaf\xefJ\xeea\xec@\xebn\xeb\xc7\xea_\xe9*\xe9g\xea\x93\xebA\xeb\x1e\xeb\xd5\xeb\xe2\xec\xfe\xed\xe0\xee\xe7\xef\x9b\xf0\xc7\xf0P\xf1`\xf2\xbe\xf3Z\xf4\xf9\xf3,\xf4@\xf5\x8f\xf6\xf8\xf6\xc8\xf6\x80\xf7\x95\xf8\x8c\xf9)\xfa!\xfb\xb0\xfcR\xfd\xb6\xfd\xd3\xfe\x87\x00\xe7\x01b\x029\x03\x7f\x04\x80\x05\x1c\x06\x0c\x07H\x08\x80\x08"\x08|\x08\xd4\t\\\n\x85\t\xb6\x08\x9b\x08\xb4\x08\x06\x08\xc5\x07\xbe\x07A\x07\x0b\x06$\x05\x06\x05\xed\x04{\x04\xb7\x03\xf7\x02@\x02\xd8\x01\xb2\x01;\x01+\x00\x1e\xffZ\xfe\xe0\xfda\xfd\xb3\xfc\xaf\xfb=\xfa\xdf\xf8!\xf8\xa1\xf7\xd4\xf6\xa8\xf5f\xf4m\xf3\xb9\xf2E\xf2\xf7\xf1\xa9\xf1(\xf1\xa5\xf0\xa6\xf0 \xf1\x91\xf1\xd8\xf1J\xf2\x05\xf3\xe7\xf3\xee\xf4\x1b\xf6A\xf7L\xf8U\xf9\xb1\xfau\xfc4\xfeq\xffi\x00\xd2\x01\xb5\x03y\x05\xae\x06\x04\x08\xcc\t\x18\x0b\xef\x0b2\r\xc1\x0eb\x10\x8f\x11\x96\x12\xe3\x13\xcd\x14\x9b\x15b\x16\x92\x17\xa4\x187\x19\x1e\x1a1\x1b/\x1c^\x1c\x90\x1cu\x1d\xd8\x1d\x97\x1d$\x1d\x1d\x1d\xc0\x1c\xf1\x1a\xe2\x182\x17o\x15\xed\x12j\x0fC\x0c\xf9\x08\x19\x05I\x01\xf0\xfd\xca\xfa\x0e\xf7\r\xf3\xde\xef\x1f\xed\x97\xeap\xe8\xaf\xe6\xfc\xe4A\xe3.\xe2\x0e\xe2\x1f\xe2\x06\xe2>\xe2\xcc\xe2\x90\xe3~\xe4\xc7\xe5?\xe7\x97\xe8\xb2\xe9\x05\xeb\xb6\xec\x81\xee \xf0b\xf1\xb9\xf2B\xf4\xdb\xf55\xf7q\xf8\xb6\xf9\xef\xfa\xfe\xfb\x12\xfd\x85\xfe\x16\x00=\x01+\x02\x87\x03\xfa\x04\xec\x05j\x066\x07\x8d\x08\x04\n\xf5\n9\x0b*\x0b\xdb\n\x98\n\xba\n\x03\x0b\xdb\n\x17\n%\tI\x08~\x07\xc8\x065\x06d\x05:\x04e\x03\x1b\x03\xce\x02\x19\x02C\x01\xca\x00d\x00\xda\xff\x9d\xff\x9f\xffd\xff\xa6\xfe\x00\xfe\xe8\xfd\xce\xfd;\xfdO\xfcs\xfb\xbd\xfa\xeb\xf9/\xf9`\xf8E\xf7\xf0\xf5\xa8\xf4\xa6\xf3\x03\xf3\x7f\xf2\xf2\xf1`\xf1\x06\xf1%\xf1\x8a\xf1\x05\xf2Q\xf2\xf0\xf2\xe0\xf3\t\xf5U\xf6\xa7\xf7\xf7\xf82\xfaS\xfb\xa2\xfc.\xfe\xc5\xff)\x01j\x02\xd4\x03(\x05\x84\x06\xb2\x07\xf6\x08\x87\n+\x0c\xbe\r\xe1\x0e\'\x10\x18\x11\xb8\x11\xc9\x12\n\x14]\x15\x16\x16\x0b\x16\x14\x16U\x16\xa8\x16j\x17`\x18\xdf\x18\xe4\x18\x11\x19\xbb\x19!\x1a~\x1a:\x1b\xce\x1b\xed\x1a\x1d\x19\x89\x18l\x18>\x17\x8b\x14\xf0\x11\xe3\x0f\xad\x0c~\x08\x1c\x05\x90\x02d\xff\x99\xfa\x8d\xf6?\xf4\xd7\xf1x\xee\'\xebL\xe9\x1d\xe8\xfd\xe5\x06\xe4s\xe3\xa5\xe3=\xe3=\xe2b\xe2\xbf\xe3o\xe4F\xe4\xd0\xe4\xca\xe6\x0c\xe9\xec\xe9\xba\xea\xcf\xec&\xef\xab\xf0\xb2\xf1\xaa\xf3U\xf6\x11\xf8\xae\xf8\xfa\xf9d\xfcs\xfe5\xff\xbc\xffK\x01\xe0\x02-\x03H\x03G\x04\xc8\x05.\x06\xa9\x05\r\x06\xf9\x06\x1a\x07\xff\x05\x93\x05\x83\x06\xd5\x06\t\x06F\x05\x97\x05\xb3\x05q\x04J\x03B\x03\xa5\x03\xff\x02\xe4\x01\xd3\x01$\x02\x92\x01P\x00\xb1\xff\xe6\xff\xa1\xff\xa2\xfe=\xfe\xa0\xfe\x84\xfe\x8c\xfd\xca\xfc\xbf\xfc\x9d\xfc\xb9\xfb\x10\xfb5\xfb?\xfb\x82\xfa\x9a\xf9D\xf9\xfd\xf8;\xf8=\xf7\xc0\xf6\x98\xf6\xfd\xf5N\xf5\'\xf5!\xf5\xd6\xf4\x80\xf4\xab\xf4R\xf5\xe4\xf5|\xf6\x96\xf7\xc2\xf8\xb4\xf9\xb5\xfa>\xfc\xd4\xfd\xfd\xfe*\x00\x96\x01\x17\x03\x1b\x04\xe7\x04b\x06\xb8\x07Z\x08\x06\t\xf2\tL\x0b\xea\x0b\x18\x0c9\ro\x0e\x0b\x0f;\x0f\x8d\x0f\xba\x10\x1d\x11t\x10\xf7\x10`\x11-\x11i\x10\xd3\x0fo\x10"\x10h\x0e\xce\r@\x0e8\x0ea\r1\r\xbb\x0e-\x0f\xf8\r\x86\r$\x0f\xa6\x10\xbb\x0f\xbe\x0eK\x0f\xeb\x0f\xbc\x0e2\x0c\x00\x0ca\x0c\x1b\nC\x06\x98\x03C\x03\x96\x01\xfd\xfc\xb3\xf9\xc1\xf8D\xf7"\xf3b\xef#\xef\xe1\xee\xac\xebx\xe8\xf7\xe8q\xea\x8b\xe8\xe4\xe5\x07\xe7\xc6\xe9\xab\xe9a\xe8\xe7\xe9?\xed\x18\xee\x13\xed/\xef\xf3\xf2j\xf46\xf4\xa1\xf5\xcb\xf8\x1c\xfa\xd7\xf9\x82\xfb\x16\xfe\xb1\xfe\xc3\xfd\x83\xfe\xc5\x00\x98\x01\xf2\x00\x0c\x01Q\x02\xa2\x02<\x02i\x02V\x03\x9e\x03\xee\x02\xdf\x02\x90\x03\xdc\x03_\x03\xc3\x02\xe2\x02\x14\x03\xd1\x02\x07\x02\xba\x01\x9f\x01?\x01x\x00\xd1\xff\xf7\xff\xcf\xff\x1e\xff<\xfe\x07\xfe\xe8\xfdd\xfd\xc3\xfc\xd9\xfc\x07\xfdw\xfc\xb5\xfb\x84\xfb\'\xfcA\xfc\x8d\xfb\x8f\xfb\xea\xfb3\xfcd\xfc\x8a\xfcL\xfd\xa4\xfd#\xfd\x03\xfd\xa4\xfd\x9d\xfe\xbc\xfe\xe9\xfd\xf2\xfb\xdd\xfa\xb3\xfc\x7f\x00\xd4\x03\x9a\x00\x93\xf9\x16\xf6v\xf8O\xfe\xff\x01\t\x01\xc4\xfe\xac\xfd*\xfd\x11\xfd6\xfd{\xfe\x10\x01\xb4\x02 \x03O\x05\x7f\x07{\x08q\x07!\x07<\t3\n\xe5\n#\x0c!\x0f\x03\x12\x16\x11\xf7\x0f\xbf\x0e\x82\x0c\x81\x0b&\x0b\xd5\x0c\xed\r\xfd\x0b\xa8\n\x14\n\x08\t\x13\x06i\x029\x00\xc3\xff\xaa\x00=\x02\xb4\x03\x94\x04\xc0\x02\xf8\xffP\xfeV\x00(\x05\xca\x08>\n\t\x0b\x05\x0eE\x11\xdb\x11#\x0f\xa3\x0cl\r\xf0\x11T\x16B\x18\x9d\x14\x1e\r\xc2\x07\xde\x06\x1e\t|\x07\x8c\x01\x8b\xfc[\xfbn\xfc\x1d\xfaD\xf3\xe9\xea\x0c\xe5\'\xe5\xdb\xe8~\xec\x02\xebP\xe5\xd9\xe1\x95\xe3\xf7\xe7e\xe8X\xe5l\xe6\xf5\xed$\xf6|\xf9\x07\xf7\xd1\xf4\x9f\xf4\x15\xf7O\xfbI\xff0\x02\x03\x03r\x04\xe2\x04G\x03s\xff\xb0\xfc:\xfeb\x01\xa4\x03_\x03V\x01\x8d\xfep\xfa\x93\xf7\x8a\xf6\xea\xf7Y\xf9\xcc\xf9\x18\xfal\xf9\xbb\xf8x\xf6\xf8\xf4\xed\xf3*\xf5z\xf8;\xfc\xe2\xfec\xfe!\xfc\x07\xfa\x98\xf9\n\xfbm\xfc\x12\xfe\xb7\xffL\x01\xeb\x01C\x00\xd7\xfdr\xfb\x84\xfb\xfc\xfc\x19\x00q\x02\x00\x03z\x02\xef\x00\xe1\xff\xa4\xfe\x80\xfe9\x00\xa3\x02\xdb\x044\x05u\x05\xc2\x04^\x02\xb7\xff\xd5\xfe\x98\x01\xe1\x04A\x06\xea\x04\xd2\x02\x04\x01\\\x00\xc6\x02\x07\x03\x86\x01\xb3\xfe\x1a\xff\xbb\x04\xcd\x061\x03\x80\xfe\xbf\xfc\xac\x00\x14\x03p\x02\xcb\x011\x01\x1d\x04\xc5\x06c\x08\x07\x07\xec\x03>\x04\\\x08\xb4\x0cB\x0e\xc2\nR\x083\t\xeb\n\xe2\r\xbe\x0b\xf6\x08m\x07v\x07\\\t\x14\t\xe9\x06b\x04W\x02\x9a\x02\xe4\x03\x89\x04\xd9\x04\r\x03\xa9\x00\xb0\xfe\x1d\xfd\x8d\xfc\x1f\xfd$\x02!\n\x1f\x0f\x16\x0f\xe6\x08\x05\x05\xc5\x05\x01\r\xa5\x15 \x19\xe9\x17\xcf\x13\xc4\x14s\x14\xda\x10\xe4\x08\x99\x02\xfb\x04V\n\xd1\r\x9f\n\xc3\x00\x02\xf7\x0c\xf0\x8b\xef\xd8\xf1\xd1\xf2\xbd\xf3}\xf2>\xf1d\xec\x18\xe71\xe5%\xe6\x1a\xebB\xef\x83\xf2\xf4\xf3\xc9\xf2\xb8\xf2\xeb\xf1\xfb\xf0\x8c\xf1\xff\xf4\xb6\xfc\xe3\x02\xec\x04\xc7\x00$\xfa\xf9\xf6P\xf8\xda\xfd\xa4\x01\xda\x02\xbd\x01(\xfe\xad\xfb%\xf9\x07\xf9u\xf8\xdf\xf6\xff\xf6\x00\xf9O\xfcD\xfcl\xf8\x01\xf3m\xf1\n\xf4\xdf\xf9(\xfe_\xfd*\xfb\x84\xf8p\xfa\x02\xfd\xc1\xfdb\xfd\x14\xfd"\x00$\x03b\x03$\x00\xda\xfb\x1d\xf9\x1c\xfa\xb6\xfd\xb4\x005\x01,\xfe\x11\xfb\xad\xf9k\xfa}\xfbu\xfb+\xfb\xa8\xfd\x91\x01\x9e\x04\x90\x03F\xfeh\xfbj\xfcK\x00\xa2\x03\x98\x02\\\x01\x0c\x02\x16\x04\xc2\x05?\x02\xe2\xfc\xbf\xfee\x03=\to\n\xc5\x07/\x08Y\x06k\x02\xe7\x01\xe6\x01o\x07\x08\n\x8c\x07\xbc\x06\x1d\x03\xc3\x01\x00\x01\xbf\x00\xe5\x033\x07\xa4\nP\x0et\x0b4\x07\xc2\x019\x02g\x08\xb4\x0c\x97\x11\x90\x0f\xbc\x0c\xc0\x08S\x06\xf5\x06\x1e\x05H\x01}\x02\xad\x06=\x0c\xfb\x0cl\x07O\x05"\x01\x10\xff\x9d\xff!\x01\xf0\x05\xb7\x08\x84\t\xd9\t\xe0\x04%\xff\x11\xfb$\xfb\xa3\x01\x15\x08\xdb\x0c\xa8\n\x00\x03~\xfa\x9a\xf7\xb1\xfb\xf5\x00-\x03\x9c\x01\x88\x00\x8d\xffS\xfd\xce\xfa\xa6\xf80\xfb\x90\xfd\x93\x01\xfe\x01\x95\xff\x01\xfd*\xf7\t\xf6>\xf4\x8c\xf7\xa3\xfa\xdc\xf9m\xf8\xc4\xf3\x92\xf3\xcd\xf4\x91\xf7\xb8\xf9y\xf9\xcc\xfa)\xfb\xb0\xfdF\xfe\xeb\xfe(\x00q\xffC\x00X\xfc\x86\xf9\xaa\xf8\x06\xf9\x88\xfbo\xfb2\xf90\xf8^\xf6>\xf6\x89\xf5q\xf4c\xf8\xec\xfc\xca\x01\x84\x00\'\xfbC\xf8k\xf9\xe6\xfdr\x00-\x00%\x00\xdc\x00\xd9\x02\x06\x03!\x00v\xfd\xb6\xfc\xbc\x01\xa6\x08\xf4\t\xea\x04i\xfe8\xfde\x00\x04\x02\xd1\x02G\x01\xbb\x00\xe3\xff\xf4\xfe\x9a\xfc\xec\xf9\xe6\xf8y\xf9u\xfbl\xfa\xc8\xfa@\xfb,\xfc\x90\xfcf\xf9\xe7\xf9\xf4\xfc\xd7\x01\x04\x06L\x05\x0f\x05\xb9\x04x\x05\x84\x07\x93\x08z\x08\xd9\x03\xf2\x01\xf3\x02O\x07\xef\x08m\x06w\x04u\x01>\x02\xe2\x02>\x03\x1e\x03o\xff\xe9\xfd\xd8\xfef\x02#\x07-\x077\x03\x9d\xfd5\xf9\xe0\xf9\x9e\xff\xf0\x05\xc9\t\x9c\x08\xb8\x04\t\x03:\x02\xe8\x03\xe6\x024\x01E\x02\x89\x06\x81\x0b\xb7\t\xed\x03:\xfcr\xf8\xff\xfa\xf7\x00\xed\x06\xc8\x06\x1c\x04\x1a\x00]\xfc\x85\xf9\x9f\xf8\xd9\xfb\x88\x00\x8f\x02\x06\x01\xa4\xfd\xb0\xfbt\xfa\xe9\xf9\xca\xfa,\xfc\xca\xfe\xd3\x01c\x05\x1a\x067\x04j\x01\xad\x00\xd9\x01\xbb\x01\xe9\x01V\x01\x16\x02;\x01\xcb\xfe3\xfd\x8d\xfcR\xfcE\xfc\xe3\xfc\'\xff\xba\x01\xdb\x03}\x05\x1c\x04\xc9\x00\x91\xfc\xeb\xfb\x06\xff\xd2\x03\x19\x07`\x06s\x02\x93\xfd\x90\xfav\xfa\x12\xfd\xf2\xffW\x03\xf7\x06\xe5\n6\x0cI\t\xee\x01=\xfbV\xf9F\xff\xec\x06}\x08\xfc\x02}\xf9\xd1\xf4\x90\xf4\x90\xf7)\xfb\x05\xfc\x80\xfd \xff\xb0\x001\x01\x1c\xfc\xd6\xf6\xd4\xf2\xe1\xf4\xaa\xfb\xba\x00\x82\x03\xa8\x00\xb8\xfb\xcf\xf5\x81\xf3$\xf7K\xfc\xd5\xfe\xb4\xfe\x80\xfe\xa2\xffQ\x01\x17\x00G\xfe\x8b\xfbU\xfb\x04\xff\xc5\x01\xe6\x02\xae\xff\x07\xfd\xff\xfd\x9e\x01\xdc\x04y\x03\xb5\xff\xb0\xfd\xcb\xfdy\xff\x8f\x01\xd7\x02\xad\x01\xa7\xff\x91\xff\x15\x005\x00"\xff\xdc\xff\x80\x00,\x02\x81\x03\x9b\x04\x08\x06\x0f\x04W\x02\x06\x00\xa7\x00j\x03\x90\x04\x99\x04K\x02k\x00\x9b\x00\x7f\x00@\x01\xbc\x00\x17\x00\xf1\x01\xe7\x02b\x03\xc0\x00\xc5\xfd\xc6\xfb?\xfb\xe7\xff\xb9\x04\xc0\x04O\xfe\x1d\xf7i\xf6D\xfcp\x01\x17\x02\xc9\xfe\xcf\xfc\xa0\xfe\xc5\xffZ\xfe"\xfa7\xf7 \xf8\xe5\xfc/\x02\xf4\x04\xad\x06\xbc\x05T\x02\x01\xfd\xb0\xf8D\xfb%\x03i\x0b\xf1\x0c\xc8\x06\x8a\xfe\xce\xf7\xf7\xf4\xfa\xf5v\xfa\xeb\xfd\xa8\xff\x1e\x03*\x05r\x04\x01\x01l\xfdY\xfe\\\x00\xc5\x04\x00\nR\x0c#\x0b\x82\x05\xab\xfe\xf4\xf9J\xfa\xb7\xfdw\x01u\x03Z\x03\x8b\x00_\xfdn\xfdF\x00\xd2\x04p\x08\x83\x06\x10\x02\xdd\xff\x18\x00\xaa\xffa\xfd\x93\xfd\x86\x00\x84\x05\xf3\x07`\x07\x11\x033\xfe\xfa\xfb\x98\xf8\xae\xfa\xfb\xff\x0e\x06q\t\xfe\x04\x05\xfe\xe9\xf6?\xf6\x12\xfc\xbb\x00\xa1\x01j\xff\x92\xff\x1c\x02\xae\x03\x99\x01\x97\xfeM\xfc\xbe\xfb\x11\xfc\xc1\xfe0\x03\x1c\x03\x08\xff\xdc\xf9\xb1\xf8z\xfcq\x00\x7f\x02\xf5\x01\xf0\xff\xf7\xfe\r\xfez\xffr\x01\x8c\x01\xba\x00\xb1\xfe\xca\xff\x81\x00\x1b\xff\x9c\xfbP\xf7\x92\xf8\xa1\xfc\x85\x01\xf2\x04\xd8\x04\xe2\x02\xbe\xfek\xfe6\xff\xcc\xff?\x01L\x04\x88\x07\xed\x04e\x01\x01\xff\x9d\xfeQ\xffj\x01\xe8\x03\x12\x06\xba\x06\x87\x04o\x00\xfc\xfc\xb5\xfc\x1c\xff\xb3\x03\xa5\x057\x01\x87\xf9\x1e\xf7\x99\xfa\x15\xff_\x01\\\x01y\x003\xfdv\xfbQ\xfc\x81\xfe\x87\xff\xfd\xfe`\x01\xdb\x04\xa4\x03k\xff\x1a\xfd\xe9\xfd\t\xfe\xfd\xfc\xb6\xfd\x04\x02\xf4\x04\xf4\x01\x01\xfb\xea\xf3\x1e\xf3\xa3\xf6\x90\xfdP\x04\xe0\x03\xbb\x00\n\xfeb\xfd\xb3\xfe\xdc\xfd\xef\xfd\x93\xfe\xb1\x01 \x06K\x064\x02o\xfa\xfa\xf5\x11\xf5h\xf8^\xfe\xee\x04\x12\t\xb5\x07\xee\x05\xff\x04\x97\x02\xca\xfdB\xfd\x1c\x016\x06U\nq\t\xef\x03\x82\xf9\xdb\xf0\xa8\xf1B\xfc\x89\x08\x9b\x0c`\x08\xed\x00\xf9\xf8\x06\xf6D\xfa\xce\x01\xf0\x06{\t\x86\x0c2\n\xaf\x01\xfa\xf8\x93\xf5{\xf7\x04\xfd\xeb\x04:\x0b\xb5\x0c\x93\x07\xed\xfd\xf3\xf3\xf1\xf1\xc8\xf9\xb8\x02J\ne\x0c\x9f\x07N\xff\x1b\xf7&\xf4\xf7\xf4\xd8\xf9\x8c\x01\x9f\x08o\ro\x0b\x01\x04)\xfa\xb2\xf1l\xf1M\xf7\xc8\x00 \tC\n1\x07*\x02v\xfc.\xf9\xb2\xf6\xa9\xf7B\xfdh\x03\xc1\x08\x82\x06\x12\xff\xf5\xf8\xa6\xf6V\xf9\x0c\xfey\x04k\x086\x05h\xff\x99\xf99\xf8\x99\xfd\xe1\x04\xb4\t\xf0\x08\xc1\x04\x00\x00 \xfc\x7f\xfb\x82\xfc:\xfd\xb3\xfdW\x00\x04\x05j\x08O\x08u\x04\x88\xff\xab\xfa\xe9\xf9\xd5\xfd_\x04\x82\t\xec\x08\r\x07\xdf\x03\xcb\xff\x0b\xfc\xa1\xf7\xcb\xf7\x17\xfb\xc2\xfd\xd3\x01\x16\x06:\x08%\x06n\x00\x95\xfa\xd1\xf4\xee\xf4\xfb\xfa\x17\xff\xac\x01"\x03L\x04\x80\x03n\xfe\xaf\xfa\xa9\xf8\xfe\xf86\xfc\x92\xfe\x16\x03:\x05\x9c\x04\x9b\x02}\xfd\xda\xf9\xfa\xf5\xc0\xf5"\xfd\xfe\x03U\x07>\x03\x9d\xfc\x08\xfb9\xfc4\xfe\x9d\xfe\xb9\xfei\x00\x95\x02\x16\x05\x10\x07[\x03`\xfd\xc4\xfaS\xfc\xa0\xfe\x90\x01\x9b\x05F\x08\xbb\x06\x82\x02j\xffS\xfc\x86\xfa\x0c\xfc\x9f\xff\xa1\x03\xc5\x06\xce\x06\xcf\x04\xba\x00 \xfb*\xf6(\xf5\x81\xfa\xa4\x02\x83\x08\x97\n1\x07>\x01c\xfdn\xfd\xa0\xfd{\xfdA\x01V\x05:\x08\xeb\x07\xd1\x05$\x01@\xfc\x12\xf9M\xf8\x05\xfd\x9a\x04\xa6\x08\xa0\x06\xde\x01\x00\xff1\xfd\x03\xfbq\xf9\x96\xfa\x97\xffe\x03\xad\x05\xec\x04U\x00\xfd\xfa\x8f\xf6\xde\xf6\x96\xfaD\x01\xc7\t\xe4\x0ct\x08v\x013\xfc\x84\xf8I\xf6\x08\xf8\x8b\xfd\xe2\x05\x96\x0c<\r\xf3\x07\xaa\xfe\xb8\xf3E\xec\xf2\xed\xc3\xf5\x88\x01\xaa\x0c\xa7\x10b\x0e\xa8\x06\x8b\xfd\xda\xf6X\xf1\xfd\xf2\x93\xfa\xb7\x041\x0e\x96\x11\xe0\x0c\xf0\xff\xd2\xf3s\xed\x98\xf2\x04\xff\xf5\x06U\tz\x07\xa4\x07\xec\x07\x0c\x05\x16\xfe\x98\xf6\xeb\xf4T\xf9\xcf\x00\xb5\x05\xfa\x06\x9f\x03\x03\xfc\x00\xf7\xe8\xfa\xd1\x01\xfd\x04{\x04j\x02o\xff\t\xfe|\x01\xb8\x04=\x02\x11\xfd\x87\xfb\xc6\xfa\xd2\xfb\x1a\x00F\x03\xa6\xff\xa2\xfa9\xfaA\xfd\x05\x02\x9c\x04i\x04\xfd\x00T\xfe\xe8\xfd\xe9\xfcR\xfdc\xfe\xc4\x00\t\x02q\xfem\xfa<\xf7\x07\xf6\xc6\xf9\x84\xff\x8e\x05\xc7\x07\xc8\x06h\x02\x8c\xf9\x13\xf5\x18\xf6\x08\xfb\x82\x02\xb2\t\xc4\x0f\x1c\x0f\xaa\x07L\xff\xf8\xf6\x1f\xf3\xcc\xf74\xff\xae\x08\xee\x0cj\x0b\x11\x05\x82\xf9\xb9\xf0I\xee>\xf8c\x07=\x11\xcb\x13N\x0f\xcb\x07\xf8\xff\xa9\xf8>\xf4\xe1\xf5/\xfd\xb8\x04G\x08\xe8\x07\xc3\x02\xf3\xf7\xfe\xec\x92\xecj\xf6\xae\x03b\x0e\x03\x14\x9e\x11\xa8\x07T\xfb\xf3\xf1\xdd\xf2\xe7\xfbc\x04\'\n\xd6\n<\x08\x01\x02\xe2\xf6+\xf1\xf0\xf2\xf9\xf6\x8d\xfd-\x07\xbe\x0f\xde\x0f;\x08\x8b\xfc\xed\xf0x\xeeR\xf3|\xf8\xa3\xfe:\x05\x99\x0b\xd6\x0b\xee\x03t\xfd\xf6\xf8\xd7\xf5e\xf6\xd7\xf9\x1a\x03Y\x0b\x8a\r\xaf\tv\xffl\xf8\x84\xf4@\xf3A\xfb\xdc\x03Q\t\xd6\x07V\x03\x00\x02\x97\x01;\x00\x96\xfc\xad\xfa6\xfc \xff\xb4\x019\x04\xec\x02C\xff\xaf\xfd\x00\xfe\x89\xfe]\xff\xaa\x00f\x01\xa2\xff\xa1\x00\xca\x04\x84\x06\x81\x03\xa2\xfe^\xfe*\xff`\x00\xfe\x00x\xff\x16\xfeF\xfb\xea\xf8\x9c\xf7\x1d\xf8\xd2\xfe\xfb\x05\x94\n\xc6\t9\x05V\x01\x8b\xfdE\xfa\xc1\xf9\xe6\xfd\xe9\x01\xf1\x04\x1f\x07\xb3\x07\xda\x04\xd5\xff\x0f\xfb\xfa\xf6\xce\xf8\xd7\xfe\xc3\x03\xe5\x06\xb2\x08\xc2\x08\xd8\x03g\xfc\x98\xf8\x02\xf8\x87\xfaG\xff`\x04\x7f\x07n\x05\xa8\x00\xf1\xfc\xb7\xfaT\xfa\x07\xfb\x91\xfd\xfe\x01\x8b\x06\xfd\x07|\x05\xef\xff\x07\xfb\xad\xf9$\xfb\xd6\xff\x80\x05\xcd\ta\n.\x07\x1b\x01N\xfb*\xf8\xca\xf7%\xfb\xf2\xff\xbb\x03^\x05!\x03\xa5\xfe\xd0\xfb\xfb\xf8h\xf8i\xf9\xcb\xfc\xf3\x01\xc5\x05\x8e\x08^\x08\xa1\x05\x02\x00u\xfb\x06\xfb\xff\xfbU\xfd;\xfe-\x01\xad\x04!\x06\xcc\x02\xd4\xfb\x97\xf9\xfd\xfd\xf1\x00\xb4\x00\x99\x012\x03n\x00A\xfdM\x00\x9a\x02\xa5\x01\x81\x00T\xff\xa1\xfb\x19\xf8\xba\xf8Y\xfb\x03\xfe\r\x01g\x04\xf6\x02\x7f\x00h\xff\xa2\xffF\xfe\xfe\xfcH\xfe\xdb\xfeD\xff\xff\xff\xef\x01b\x02\x80\x00\xbd\xfe\t\xff\x95\x00\x95\x00\t\x01\x02\x03\x1e\x027\xff\xd0\xfez\xff\xaf\xfe\xa4\xfc\xff\xfb@\xfd\xe0\xff\xae\x02t\x02\x17\xffQ\xfb*\xf9\xaf\xfa\xd8\xfd|\x01d\x03\xc5\x04\xc3\x04,\x01\x0b\xfd\x80\xfb\xa8\xfbb\xfd\xf7\xffg\x03D\x05\x80\x03\x1f\x01\xbc\xfe\xdd\xff\x90\x03<\x04\x87\x01\x81\xfe>\xfe\x99\x01\x80\x03&\x02\xe9\x00+\xff6\xfc<\xfa#\xfb\x83\xfb;\xfbM\xffH\x06\\\x06\xf7\x00\xb0\xfd\xd8\xfd\x84\xffJ\xff\x86\xfd\xb1\xfe\xb5\x04-\nH\ta\x02\x00\xfb!\xf8\x9d\xf8M\xfd\xaa\x04Q\x08\x8e\x07\x17\x04"\x01\x9c\xfdj\xfa\xdb\xfb+\xff\xab\x01e\x03\x93\x02\xa9\x01\r\x02\xe3\xff\xe7\xfd\xfe\xfcD\xfc\xbd\xff\x94\x04\xa9\x07\x82\x07\x01\x03\x9d\xfet\xfbY\xfa\xb0\xfe\xf9\x01\x92\x01\xf5\x00f\x00\x05\x02\t\x00\xbe\xfd\xaa\xfc\xfc\xf9\x17\xfb\xbd\xffP\x04x\x08\xa8\x07/\x02\xcd\xfb\xb6\xf8\xc7\xfc\xf5\x02\x11\x07,\x07g\x04\x81\x03\x0f\x04\xb8\x02_\xfe\xf0\xf8\xbe\xf7\xc9\xf9\xd9\xfe\x9a\x04}\x05C\x03\xa4\xfd\x9a\xf9\'\xf7\xe7\xf5\xb5\xf8\xa9\xfb\xaf\xffW\x03%\x04\xc8\x03\xc0\x02u\x028\x01\x03\x00\xb0\x01\xe1\x02\x9a\x02\xc8\x03+\x04%\x02\xfb\xffp\xff\xf9\xff\x95\xff\xba\xff\xc6\x00\x90\xff\xbb\xfe\x94\xfd\xd5\xfc\x19\xfe:\xff\x91\xfe\xf3\xfb\xb1\xfc#\xff\x87\xff%\xff\x84\xfe\x1d\xfd/\xfd\xe4\xfe\x87\x00\n\x01\xe7\x00\x1b\x019\xff6\xfc\x8a\xfb\xd4\xfa\x82\xf9\x87\xfb\xb0\xfc%\xfe\\\xff\x9d\xff\x1d\xff\x18\xfc!\xfb\xe8\xfa\x11\xfc\xfc\xfei\x02\xdf\x04I\x04\xd8\x02\xee\x00Q\xffu\xfe^\xfc\xd0\xf9\xfb\xf9\xea\xfeW\x04\xbe\x05\xb1\x04\x97\x00\xc8\xfa\n\xf9\x15\xfd\xea\x02\xf9\x05\xe0\x06S\x06-\x06\x93\x05\xdf\x01W\xfe\x89\xf9\xd9\xf6\x13\xf9\xfa\xfb\xbf\x01?\t\x01\x0c\x03\x06\xe3\xfc\x99\xf94\xf9.\xfa\xb5\x00P\t\\\x0e\x95\x0f\x82\re\x07\xe6\xfd\xff\xf5\xcb\xf3\x18\xf7\x86\x00\x13\nu\r\xa0\x0ce\x06>\xff\xd4\xfa\xe2\xf8C\xfb\xfb\xfc.\x00\xe5\x04B\x08\xd9\t\xc8\x04\x12\xfc1\xf8\x06\xf7\x85\xf7E\xfc\xaa\x020\x075\x04"\x01\x9d\x01\xce\xfer\xfb\x02\xf8g\xf5l\xf8\x1b\xfb\xab\xfc\xc6\xfcH\x02\xcd\r\x13\x12K\x11D\x0f\xa2\x10\x06\x13\x19\x13=\x15B\x17G\x18\xe8\x16y\x13a\x10P\x0b\xb8\x04\xf4\xfd\x1e\xf9\xe0\xf7\xfb\xf8\xcb\xfa,\xfa\x13\xf8\xbf\xf5\x12\xf5\xb8\xf5\xde\xf5\xb3\xf4$\xf3\x97\xf4|\xf5\x1a\xf4\x10\xf6e\xf8^\xf9\xea\xf7\xa6\xf6p\xf7W\xf8\x98\xfaH\xfcl\xfd\x08\xfe~\xff\xed\xffL\xfe\xf0\xfd_\xfc\\\xf8\r\xf5k\xf4o\xf4\x18\xf4\x1c\xf4C\xf4R\xf3\'\xf2\x05\xf2\xae\xf1\xde\xf1z\xf3M\xf5\x05\xf6\x11\xf7\x8b\xf8K\xf8\xeb\xf6\xd8\xf5\xa5\xf5\xd3\xf59\xf7$\xf9\xee\xf9n\xfa\x10\xfb\x97\xfbj\xfbf\xfb\x0e\xfc\xec\xfc\x9b\xfd@\xfda\xfd\xc0\xfd?\xff\x05\x01\xf8\x01\x9b\x01\x19\x02p\x05\x82\x08\x82\x08@\x07\xcb\x06\xab\x06\xb0\x08\xf7\x0b\x07\r \x0b\x97\nz\x0b\xaa\tr\x03\xbc\x00\xce\x04\x1d\x08\xfa\x08\xdd\nI\x0e\xee\x10\xa9\x0f\x1a\x0b$\x08\x9c\t\x86\x0bz\n\x91\tc\x0bV\r&\x0c\x89\te\x05&\x02\xee\x00?\x00\xac\x00\xc9\xfd.\xf9\xd5\xf9(\x06\xb4\x1d\x9e3\x97:\x994\xe4,i(\xc3#e f!\xc4$A%\xd6!G\x1f\x7f\x1aE\x11r\x01\xe3\xef;\xe3\xc9\xdb\x1b\xdb\x1e\xde@\xe2h\xe4N\xe3\x12\xe2\x96\xe1\xe0\xdf\x10\xdc\x83\xd8~\xd8\x1c\xdd\x10\xe5r\xf0\x00\xfd\x16\x03^\x00\x96\xfb\xd3\xf8\xf4\xf8\xbe\xf88\xfa\xed\xfd\xf2\xff\x11\x02W\x03f\x03R\x01\x14\xfbJ\xf4\x87\xee\xd8\xeb\xbf\xeb\x0c\xebg\xecR\xf0\x0e\xf4Y\xf4\xf0\xf3\xf0\xf4u\xf4e\xf3\xa0\xf4\xad\xfae\x01\xa5\x07\xeb\x0e\xec\x12\xc3\x12\xfe\x12\xf7\x13\xae\x14\xa9\x11c\x0e\xa4\r\xdb\ng\x08\xf6\x05&\x02r\xfd\xf8\xf7\xeb\xf41\xf4\xc3\xf3\x12\xf4\x16\xf3A\xf1\x04\xf1\xf1\xf1\xa7\xf3\xfc\xf4\x82\xf5Q\xf6Y\xf6k\xf6\x14\xf8C\xf9\xd5\xf9R\xf9h\xf9`\xfb\x11\xfd.\xfe\x06\xfed\xfc\xe0\xfa\xce\xf9\xe1\xf8\xef\xf8\xe8\xf8c\xf8\xa7\xf5\x1d\xf1\xf3\xedn\xed\x97\xedO\xed*\xeeA\xf1,\xf5\x07\xf9T\xfc5\xffb\x00\xe9\xff\x82\x01&\x05\x7f\t\xee\x0e\xd8\x11\xfd\x13\x07\x14\xf0\x11\xa6\x0c>\tE\x14\xca.DJ9WJW\xb6S@P\xbbI\xb5A?=\xad;X8\x982\x19.<+\xe7")\x12\xe1\xfaM\xe5\xd0\xd6&\xce\xda\xcbU\xcdC\xd2)\xd8"\xdb\xc5\xdb\xb3\xdb\x8d\xdb+\xda\xda\xd7\x12\xda1\xe4R\xf3\xe5\x01\xa2\x0b6\x10]\x0fz\x0c\xc0\x07\x9b\x03r\x00\x88\xfeL\xfd\xa1\xfb\x03\xfc:\xfcE\xf8\xed\xf0\xd3\xe7\x90\xdfA\xd8\xa4\xd3\xee\xd3X\xd5\xe3\xd7\x8a\xdb>\xe0d\xe4\x15\xe7\x08\xea\xef\xec\xc2\xef\xa6\xf3\xb4\xf9%\x01b\t\\\x11\x05\x17\xf0\x19B\x1c\xaf\x1d\xcd\x1d\xf1\x1b\xb2\x19\xdb\x17g\x16\x96\x17x\x1a\xde\x1a\x0f\x18^\x12\x13\x0b\x97\x02(\xfbQ\xf7Z\xf5\x9c\xf3i\xf3X\xf59\xf8\xeb\xfa\x91\xfc\xdc\xfd\x05\xfe\xc9\xfd\xff\xfe\x00\x02\xd6\x05\x07\t)\x0b\xd2\x0bi\n=\x06h\x00\xf2\xf8\xdc\xf0\x10\xea(\xe5\x83\xe2R\xe1\xf7\xe0\xd8\xe0\xaa\xdfH\xdda\xda\x02\xd8V\xd7\x1a\xd9\x0c\xdd\xfd\xe1-\xe7p\xeb\x8b\xee=\xf1\x85\xf34\xf6\xe9\xf9\x98\xfd\xb7\xffD\x02\x9a\x06\xda\x0c\xb2\x13\xac\x18\xed\x1cF\x1f\xe0\x1fO >#\xa2)\xd30A8\xc5A\xdbK&R\xdaPLJeD\x8b=\xc04H*\xce"\xbc\x1f\xac\x1b\xa3\x14\x12\x0b\xd0\x02\xbb\xfb\xb3\xf2\xdd\xe8\xaa\xe0(\xdc\xe6\xdbB\xdd!\xe0\xe2\xe45\xebn\xf0~\xf2\x91\xf3\x88\xf6g\xfa\x0f\xfc\xf2\xfc2\xff\xd0\x02\xb1\x05\xa9\x07\xc4\x08\xe8\x07\x97\x04\xfc\xff\xeb\xf9\xe7\xf3W\xef\x91\xeb\xd4\xe6I\xe1=\xde\xdc\xdca\xdbj\xd9X\xd8\x83\xd7\x0b\xd6\x83\xd6\t\xdaU\xdf\x87\xe5\xb5\xeb\x07\xf1\xfd\xf5\xf4\xfb\x0b\x03\x05\x083\n\xf2\x0b^\x0e#\x10\xcc\x11\xc7\x13\xd3\x14\x9f\x13\x19\x11\xf7\x0e\x0f\r\xb2\x0b\x00\x0b\xe4\x08-\x068\x04\xd3\x04\xf1\x05\xf3\x05\xb5\x05p\x05]\x05\x92\x05\xe1\x06\xc6\x08x\n\xed\n\x8e\np\n9\x0b\xf9\x0c \x0e\xa4\r\xd6\x0b\xd7\t\xff\x07\xdd\x05-\x03\xc8\xff~\xfb\xaf\xf6G\xf2$\xef\x9e\xec\xce\xe9;\xe6\xae\xe2\x05\xe0\xf9\xde0\xdf\x80\xdfG\xe0\xc7\xe1\xed\xe3Y\xe6T\xe9>\xed\x12\xf1\xb5\xf2+\xf37\xf4\xbf\xf6\xb4\xf8\r\xf8w\xf6\x16\xf6\x9b\xf6\xf8\xf5Y\xf4\xb3\xf3\xdf\xf3\xab\xf3;\xf26\xf1\xb1\xf2\x16\xf6\xce\xf9\xd4\xfd\xe8\x02\xc6\n\x9a\x13\x07\x1d9*\xe7=\xaeR;]\xf5\\\x1a[(_\xd8aJ[\xc6O\xdbH\xc1E\xc5=@0\xed#\x18\x1bT\x0f\n\xfcN\xe7\xeb\xda6\xd6\x9a\xd1!\xca\xd1\xc6\xcb\xca\xf3\xd04\xd4#\xd79\xddZ\xe3\xb0\xe6A\xe9\xfd\xee\xf7\xf7\xa9\x01\xac\x08\x11\r\x9c\x10\x8d\x14\\\x16\x14\x14s\x0f?\n\x97\x02\x92\xf9\xcb\xf2@\xee6\xe9\xc2\xe2\xb1\xdd\xae\xd9\xd7\xd4\x07\xd1\xcf\xcf\x15\xcf\x05\xce\x00\xce\x01\xd1\xf9\xd6\n\xdf]\xe8\x91\xf0k\xf7^\xfe\xae\x05\xb9\x0b}\x10\xf7\x14/\x19\xf8\x1aU\x1b&\x1d7 \xfa!\x94 z\x1c\xa6\x18\xe8\x14\xe3\x11<\r\x82\x08]\x04Z\x01\x1e\xff\xd6\xfcH\xfd`\xffb\x00t\xffY\xff\x04\x03\x11\x07\xaf\x08g\t\x80\n\xf9\x0b\x08\x0cN\x0cu\r\xbd\r\xdf\x0cZ\n\xea\x07\xba\x05\xf9\x03\xfc\x00\xa5\xfbX\xf6*\xf2\x1b\xef\x18\xec\xbe\xe96\xe8~\xe6\xba\xe4.\xe3\xa8\xe2c\xe2\xfc\xe1\xf3\xe1&\xe2\x8b\xe3\x9c\xe6\xbf\xeak\xee\xed\xf0\x04\xf3\xc5\xf4\x95\xf5\x8c\xf5\xae\xf5\xe8\xf5\xbf\xf4\x9a\xf2\xd2\xf0*\xf1\xbf\xf2i\xf4\r\xf5\xf7\xf4\x05\xf5y\xf6u\xf9K\xfc#\xff\xc5\x02q\x06@\n\xba\x104\x1b\x91\'\xf93\x04C#U\xa0a5c{`\xafa\x15d\xcc]GQ\x85G\xedB\xb5;\xa1.B"Q\x18 \x0c+\xfa&\xe7\xad\xd9\xcd\xd1\xd6\xc9\xb3\xc0\xb5\xba\x9f\xbc\x86\xc3j\xc9\x11\xcdl\xd2\xe0\xd9\x89\xdfZ\xe3\xf0\xe8\xe4\xf1\x10\xfap\xff\t\x04y\n\xf1\x11)\x17\\\x17\x01\x14\x97\x0f\x96\n\xc3\x02\x0f\xf9l\xf1B\xec\xa6\xe6\x93\xdf\xe3\xda\xe3\xd9v\xd9\x14\xd7\x0f\xd4"\xd2\xc0\xd1\x11\xd3\x01\xd6\x8e\xda\xd7\xe0\xb9\xe8\xd0\xf0\x1d\xf9l\x02\xd7\x0b\x07\x13a\x17\x03\x1a\x9d\x1c\xa4\x1ej \xc0!N"\xd2!\xb8 \xaf\x1e\x90\x1c,\x1a#\x16K\x0fA\x07@\x01\x92\xfdV\xfa>\xf8=\xf8\x17\xf9\xe3\xf8\x1e\xf9<\xfc\xa8\xff\xca\xff_\xfd5\xfd\xf0\xff\x9a\x02\xf9\x04\x14\x08\xd7\x0b\xe3\r\xac\x0eR\x0f-\x0f\x0f\ru\x08\xae\x02\xeb\xfc\xcf\xf8\x92\xf5\'\xf2O\xee\xc4\xeav\xe8e\xe65\xe4!\xe2a\xe0\xd2\xde9\xdde\xddz\xe0$\xe5\xc6\xe9\xb3\xed\xf6\xf1\x03\xf7\x85\xfb\x04\xff#\x01\'\x02\x93\x02\x9b\x02\xca\x021\x03(\x04\x95\x04r\x03:\x01\xfe\xfe\xf3\xfc[\xfa\xe4\xf6\xa4\xf3\xb4\xf1%\xf1\xed\xf1\xad\xf3\xf4\xf6\xae\xfa\xb7\xfd?\x00\x99\x02<\x06\xa6\x0b\xa3\x13\x86 \x9f1\xf2AJL\x13R>YKaxc\xe6\\\xdfS\xd7M\xe0H\x1b@\xea5\xcf-\xab%_\x19\x8e\t\xd4\xfb\x90\xf0\x99\xe4\x87\xd5\xc4\xc6\xa6\xbc\xd4\xb7\xd9\xb5\x98\xb5a\xb7=\xbc\xb7\xc2;\xc9"\xd0\t\xd8\xf6\xdf\xbc\xe5Y\xea\x85\xf07\xf9\x1b\x03\xeb\x0b\xb8\x12\xbd\x17\xa9\x1b\x9a\x1e&\x1f\xeb\x1cd\x17\xe7\x0f\xda\x06\xc9\xfd^\xf6o\xf0k\xebg\xe66\xe2N\xdf\xa9\xdd\xee\xdc\xab\xdc\xf6\xdb\x88\xdb\x8e\xdcg\xdfN\xe4\xfe\xea\xdf\xf2\xf4\xfa\xb6\x02\xa3\n\xb8\x12\xbc\x19$\x1f\xab!a"v!S +\x1f\xb2\x1d\xca\x1a-\x17\xf5\x12\x8d\x0fx\x0c\x9c\x08v\x04\xee\xfe:\xfa\xd1\xf5B\xf2o\xf0A\xf0n\xf1s\xf2t\xf3 \xf6\x97\xfa\xca\xfe\x96\x01\xac\x02\xc7\x03\x1e\x05E\x06v\x067\x06\x17\x06\x03\x06H\x05\x9f\x04\xce\x04b\x04i\x02\x92\xfe\x11\xfb[\xf8\x95\xf5\xd0\xf2\x19\xf0\x14\xeeC\xed\xb2\xed$\xef\xef\xf0\xb1\xf2t\xf4\xf6\xf5"\xf7N\xf8\x04\xfa\xb6\xfb\xc4\xfc\x7f\xfd\xe8\xfe\x99\x013\x04_\x05[\x05v\x04\x0b\x03\x8b\x00\xce\xfd\xaf\xfb\x04\xfa\x83\xf8\x84\xf6\x19\xf56\xf5[\xf6`\xf7S\xf7\xaf\xf6\x8d\xf6\xf4\xf6\xd4\xf7\x08\xf9\xb2\xfa`\xfc\x88\xfdP\xff:\x02\x9e\x05\xb0\tO\x10\x03\x1a\x7f#_*%1\xdf9\xc7A\xd5C\xecA\x8b@J@\xe2<\xa35\x85.\x19)}"\x08\x19\xe2\x0f\xa8\x08g\x01E\xf7#\xecU\xe3\xc6\xdcu\xd6\xec\xcf\x1f\xcb\xa9\xc9~\xca/\xcc\xe4\xceT\xd46\xdb\x10\xe14\xe6\x14\xec\xcc\xf2\x9e\xf8_\xfdI\x02\xfe\x06\xc2\n\x9b\x0e\xb2\x12}\x15\xc8\x15;\x15\\\x14\n\x12\xad\rr\x08D\x03W\xfd\xad\xf6\x08\xf1\x1d\xed!\xea7\xe7@\xe4\x9a\xe2b\xe2\r\xe3\xbb\xe3\x83\xe4e\xe5\x9e\xe6\xe0\xe8\x11\xec\r\xf0!\xf4@\xf8d\xfc\x94\x00\x88\x04x\x08\xb3\x0b\xab\r\xe0\r\xf7\r\xf8\r*\x0e\xec\r\xf4\x0c\x8d\x0c\xa1\x0b\x06\x0bR\n\x92\t%\t?\x08\x11\x07V\x05\xfd\x03\xb1\x03\xc5\x03\x0c\x04\x1c\x04\x18\x04X\x04\xb6\x04t\x05\xa8\x05\x7f\x05\xeb\x04\x11\x04\x13\x03%\x02\xe0\x01\x85\x01\xe1\x00\xfd\xff@\xff\xf7\xfe\x8f\xfe\x0b\xfe>\xfdQ\xfc\xa4\xfbE\xfbM\xfb(\xfb\x1d\xfbp\xfb\xd7\xfb\'\xfc4\xfcU\xfcC\xfc\xd5\xfb2\xfb\xd3\xfa\xf8\xfa$\xfb\xf4\xfa\xc2\xfa\xb1\xfa\xda\xfa\xb8\xfaI\xfa\xd6\xf9X\xf9\xc2\xf8\xcf\xf7\xd6\xf67\xf6\xd6\xf5\x81\xf5\x00\xf5\xa9\xf4\xc7\xf4\x00\xf5\xc8\xf4I\xf4\xfa\xf3\x05\xf4\xf4\xf3-\xf4K\xf5z\xf7\x18\xfa\x86\xfc\xe2\xfe9\x01\x9a\x03\xfb\x05N\x08.\x0b\xc5\x0fR\x16k\x1dV#\x16()-\x952\xdb6X8G8\xe97\x037\x0e4\xfb/\x8c,\xe7)\xea%\xc1\x1f\x18\x19x\x13m\x0e\xe4\x07\xa9\x00\xea\xf9\x02\xf4\x1c\xee\x1c\xe8/\xe3\x16\xe0\x08\xde/\xdc\xda\xda\xee\xdaj\xdc)\xde\xaf\xdfD\xe1\xa9\xe3G\xe6\xd3\xe8c\xeb\xe4\xedE\xf0\x9b\xf2\xf0\xf4\x17\xf7\xb3\xf8%\xfae\xfb\xdd\xfb\x95\xfb\n\xfb\xb5\xfa\x07\xfa\x8f\xf8\x07\xf7\xcc\xf5\xed\xf4A\xf4\xae\xf3\x96\xf3\xdf\xf3F\xf4\xe7\xf4\xdf\xf5\x0b\xf7;\xf8\x7f\xf9\xd8\xfaM\xfc\xd3\xfdf\xff\xfa\x00\x81\x02\xd5\x03\x17\x05<\x06\x15\x07\xa3\x07\x06\x080\x08/\x08&\x08\xf3\x07\xf2\x07\x10\x08R\x08\xc5\x08\x8e\t\x95\n\xcc\x0b\x1f\r9\x0e\x1a\x0f\x80\x0f\xb9\x0f\xb8\x0f\x80\x0f\xbf\x0e\xdf\r\xe7\x0c\xd2\x0bx\n\x9b\x08\xc3\x06\xed\x04\xeb\x02w\x00\xe1\xfd~\xfbM\xf94\xf7#\xf5W\xf3\xfe\xf1\xf0\xf0\x10\xf0u\xefO\xefj\xef\x85\xef\xb0\xef2\xf0!\xf1\x12\xf2\xd5\xf2x\xf3U\xf4J\xf51\xf6\xf3\xf6\xcb\xf7\xbd\xf8\x8f\xf9&\xfa\x9f\xfa-\xfb\xad\xfb\xea\xfb\xfa\xfb*\xfc\x8b\xfc\x07\xfd~\xfd\xd9\xfdI\xfe\x8e\xfe\xcd\xfe\x16\xffl\xff\xcc\xff!\x00\x7f\x00\xf7\x00X\x01\xb6\x01\x15\x02\x94\x02\'\x03\x91\x03\x1a\x04\xf1\x04\x06\x06\x1b\x07\xfd\x07\xda\x08\r\n1\x0b\xdb\x0b\x19\x0c\x8c\x0cl\r[\x0e^\x0f\xdb\x10\x14\x13(\x15G\x16\xb4\x16b\x17z\x18)\x19\x0c\x19\xe4\x18\xf8\x18\xa6\x18l\x17\xe5\x15\x1a\x15\x81\x14\xe8\x124\x10T\r\xda\n\x0b\x08\x87\x04\x00\x01\xdd\xfd\xf4\xfa\xc2\xf7\x9e\xf4-\xf2\x81\xf0!\xef\x9f\xed \xec\xd6\xea\xe5\xe9=\xe9\xc3\xe8\xb3\xe8\xe1\xe8*\xe9/\xe9A\xe9\xf4\xe9>\xeb\xd6\xecR\xee\xa3\xef\x12\xf1\x9b\xf2\x13\xf4\x99\xf5\x16\xf7\x8d\xf8\xd3\xf9\xda\xfa\xbf\xfb\xcf\xfc%\xfe\x8b\xff\xb9\x00\xbe\x01\xbe\x02\xae\x03v\x04\xe6\x043\x05s\x05e\x05\xfb\x04K\x04\xb1\x03.\x03\x88\x02\xb7\x01\xe5\x00j\x00\x1e\x00\xbc\xffS\xffE\xffy\xffv\xff\'\xff\x1a\xffo\xff\xdf\xff\'\x00\x96\x00f\x01w\x02k\x03T\x04_\x05t\x06\x8d\x07J\x08\x96\x08\xe3\x08%\t4\t\xe8\x08v\x08#\x08\xad\x07\xbb\x06e\x05\x14\x04\xe8\x02\x93\x01\xda\xff!\xfe\xa3\xfcW\xfb\x04\xfa\xd9\xf8.\xf8\xfa\xf7\xcf\xf7\x84\xf7[\xf7\x99\xf7\x18\xf8G\xf8V\xf8\x93\xf8\xee\xf82\xf9O\xf9\x8b\xf9!\xfa\xc1\xfa\x0c\xfb*\xfbw\xfb\xfb\xfbH\xfcB\xfc%\xfc)\xfc\x1e\xfc\xca\xfb\x94\xfb\xab\xfb\x06\xfcA\xfcA\xfcX\xfc\xac\xfc\x1d\xfdU\xfd\x83\xfd\xce\xfd \xfe(\xfe\x17\xfeU\xfe\xca\xfe.\xffj\xff\xb9\xff^\x00\x1d\x01\xaa\x01+\x02\xcc\x02l\x03\xc8\x03\x13\x04\xeb\x04e\x06\xd8\x07I\t\r\x0b\x1a\r\x06\x0f\xb5\x10\xe9\x12\xa6\x15\xe0\x17\n\x19P\x1a\'\x1c\xe1\x1d\xa8\x1e\x1b\x1f\x05 h +\x1f\xfd\x1cD\x1b\xc6\x19(\x17\x19\x13\x03\x0f\x89\x0b\xd9\x07Y\x03\x08\xff\xac\xfb\xe9\xf8\x95\xf5\x01\xf27\xef\x91\xed,\xecK\xea{\xe8x\xe7\x0e\xe7d\xe6\x85\xe5\x83\xe5q\xe6v\xe7\x04\xe8\xb8\xe8a\xeab\xec\xf6\xed2\xef\xbd\xf0\x8b\xf2\xf2\xf3\xe2\xf4\xce\xf5\x06\xf7R\xf8(\xf9\xc9\xf9\x9a\xfa\x92\xfb=\xfcv\xfc\xbe\xfcM\xfd\x95\xfdU\xfd\xe0\xfc\x93\xfc[\xfc\xdd\xfbe\xfb\x17\xfb\xfa\xfa\n\xfb\x1c\xfb]\xfb\xdc\xfb\x8d\xfcY\xfd\n\xfe\xd0\xfe\xcc\xff\xef\x00\x08\x02\x06\x03#\x04{\x05\xd6\x06\x01\x08\x12\tB\nu\x0bz\x0c`\r3\x0e\xd0\x0e*\x0f%\x0f\xe3\x0ee\x0e\xaf\r\xd0\x0c\xcf\x0b\xb2\n\x83\t^\x08O\x07N\x065\x05 \x04+\x03%\x02\xf3\x00\xa7\xffZ\xfe\x0b\xfd\xa4\xfbN\xfaJ\xf9P\xf8\\\xf7\x9f\xf61\xf6\xf4\xf5\xa1\xf5:\xf5\xf6\xf4\xa9\xf4/\xf4\xb2\xf3E\xf3\x01\xf3\xd8\xf2\xba\xf2\xe9\xf2S\xf3\xd9\xf3p\xf4\xfe\xf4\xab\xf5C\xf6\xb4\xf6\x12\xf7d\xf7\xc0\xf7\x1b\xf8u\xf8\xf8\xf8\xab\xf9\x98\xfa\x8f\xfb\x88\xfc\x83\xfd}\xfeY\xff\x05\x00\xd5\x00\xdb\x01\xde\x02\x7f\x03\x15\x04\x0c\x05*\x06\x11\x07\xe7\x07\x05\t\x7f\n\xc5\x0b\xd9\x0ce\x0e\xbf\x10/\x13Y\x15\xba\x17\x87\x1a>\x1d\x02\x1f\x88 J"\xdb#V$n$\xf1$\\%\xc4$R#\xf1!k \xc4\x1d\x00\x1a\x18\x166\x12\xc4\r\xa9\x08\xc4\x03\x87\xff\x96\xfbo\xf7~\xf3\x1f\xf0N\xed\xb8\xeaT\xe8U\xe6\xbf\xe4\x83\xe3I\xe2/\xe1\xb6\xe0\xd0\xe0F\xe1\xa6\xe1[\xe2}\xe3\xce\xe4%\xe6z\xe7\x19\xe9\xab\xea\xcb\xeb\xc3\xec\xe2\xed2\xef\xa4\xf0\xff\xf1g\xf3\xfa\xf4\xa5\xf69\xf8\xb2\xf92\xfb\xba\xfc\t\xfe\x0c\xff\xeb\xff\xde\x00\xd1\x01\x92\x02.\x03\xaf\x034\x04\x8f\x04\xad\x04\xba\x04\xd9\x04\xfa\x04\xce\x04m\x04\x12\x04\xe8\x03\xce\x03\x8d\x03y\x03\xb9\x03/\x04\xab\x04\x19\x05\xc3\x05\xa6\x06h\x07\xe2\x07e\x087\t\xfe\tv\n\xd0\nq\x0bM\x0c\xdf\x0c\x0b\r=\rV\r\xe4\x0c\xbc\x0bD\n\xda\x08G\x07Y\x05\x8e\x03,\x02\x0f\x01\xee\xff\xdb\xfe\xee\xfd\xff\xfc\x06\xfc\xdd\xfa\xaa\xf9e\xf8\x15\xf7\xcf\xf5\x90\xf4\x8a\xf3\xdb\xf2\x82\xf2!\xf2\xd6\xf1\xe5\xf1+\xf2u\xf2\x8d\xf2\xaa\xf2\xcf\xf2\xdb\xf2\xd9\xf2\xed\xf2M\xf3\xd7\xf3y\xf4I\xf5[\xf6{\xf7e\xf80\xf9\xea\xf9\x99\xfa\xfc\xfa\'\xfbd\xfb\xaf\xfb#\xfc\xaf\xfcl\xfdb\xfe5\xff\xc4\xffF\x00\xe6\x00l\x01\xc4\x01\x0e\x02\x9c\x02Z\x03\x00\x04\x0b\x05\xe6\x06&\t\t\x0b\xc5\x0c\xfc\x0e\x8b\x11\xd5\x13\xcc\x15!\x18\xbe\x1a\xce\x1c%\x1e\x98\x1f\x95!?#\xd2#\xd6#&$G$!#\xf6 \xe8\x1e\xe3\x1c\xc1\x19\x8e\x15\x90\x11X\x0e\xd3\np\x06M\x02\t\xff\xf7\xfbN\xf8\x87\xf4\x94\xf1\x1b\xefC\xecO\xe9\xe9\xe63\xe5\xce\xe3s\xe2}\xe1e\xe1\x85\xe1\xbd\xe1\xfa\xe1\x9d\xe2\xcd\xe3\xf2\xe4\x03\xe63\xe7\xb5\xe8f\xea%\xec\x17\xee^\xf0\xc6\xf2\x0f\xf5>\xf7\x84\xf9\x96\xfbu\xfdP\xff\x02\x01\x94\x02\xd2\x03\xda\x04\xc0\x05p\x06\xd5\x06\x02\x07\x08\x07\xd5\x06\x8a\x06\x12\x06_\x05\x93\x04\xd0\x03\x08\x03\x1e\x02-\x01u\x00\x00\x00\x87\xff\xfb\xfe\xb7\xfe\xeb\xfeG\xff\x81\xff\xf6\xff\xf0\x00\'\x02\x15\x03\xfd\x03B\x05\x9b\x06\xb1\x07l\x08D\tK\n\x1c\x0b{\x0b\xc1\x0b\x07\x0c\x18\x0c\xcf\x0bQ\x0b\xce\n@\n~\tb\x08\x1a\x07\xc3\x05Y\x04\xb7\x02\xfc\x00_\xff\xf5\xfd\xa4\xfcZ\xfb+\xfaG\xf9\x91\xf8\xee\xf7S\xf7\xd7\xf6\x88\xf6?\xf6\xf3\xf5\xbc\xf5\xb4\xf5\xc9\xf5\xee\xf5+\xf6\x9b\xf66\xf7\xcb\xf7P\xf8\xcf\xf8\\\xf9\xc2\xf9\x02\xfa6\xfaf\xfa\x8d\xfa\xa5\xfa\xd2\xfa\x10\xfb\\\xfb\x98\xfb\xcf\xfb\x0e\xfc.\xfc4\xfc\x07\xfc\xbf\xfby\xfb\x1f\xfb\xbf\xfan\xfaG\xfa+\xfa\x00\xfa\xfe\xf9u\xfaB\xfb\r\xfc\xc5\xfc\xeb\xfds\xff\x11\x01\xe5\x02f\x05\xb0\x08\xf8\x0b\x9d\x0eB\x11}\x14\xa3\x17\x1e\x1a"\x1c\xa5\x1e&!h"\xa0"K#~$\xa8$N#\xd2!\xe4 (\x1f\x8d\x1b\x92\x17\xac\x14\xc5\x11b\r,\x08\x17\x04\xe8\x00\x1d\xfd\xa1\xf8\x1b\xf5\xcf\xf2u\xf0I\xedQ\xea\x9c\xe8\x90\xe7\x1f\xe6\x89\xe4\xb6\xe3\xe2\xe3\x1d\xe4\xf3\xe3A\xe4\xaa\xe5q\xe7\xae\xe8\xba\xe9J\xebO\xed\xfe\xee>\xf0\xc4\xf1\xb2\xf3h\xf5\xa0\xf6\xe4\xf7|\xf91\xfb\x9d\xfc\xce\xfd\x10\xff4\x009\x01\x06\x02\xab\x02@\x03\xa6\x03\xf5\x03\x15\x04\x0f\x04\xf7\x03\xd0\x03\xa0\x03w\x03\'\x03\xc1\x02T\x02\xe9\x01\x96\x01\x16\x01\xc3\x00\xb1\x00\xac\x00\x96\x00\x80\x00\xd7\x00h\x01\xd5\x01P\x02\'\x03\x13\x04\xcd\x04g\x05#\x06\xed\x06\x84\x07\xfb\x07h\x08\xc8\x08\xe9\x08\xc4\x08y\x08\x04\x08u\x07\xc3\x06\xe1\x05\xee\x04\xf4\x03\xed\x02\xc7\x01\x91\x00r\xfft\xfel\xfdj\xfc\x84\xfb\xb4\xfa\xe3\xf9,\xf9\x95\xf8$\xf8\xef\xf7\xc7\xf7\xbd\xf7\xd2\xf7*\xf8\xa3\xf8\x15\xf9\x81\xf9\xf1\xf9\x96\xfa"\xfb\x95\xfb\x1d\xfc\xd2\xfc\x99\xfd!\xfe\xb1\xfer\xff*\x00\xca\x00%\x01\x9c\x01\x03\x02@\x02O\x02R\x02\x82\x02\xa3\x02\x96\x02u\x02\x81\x02\x9f\x02\xa4\x02\x94\x02\x92\x02\x9b\x02m\x02\x1a\x02\xd1\x01\x96\x01T\x01\xe9\x00s\x00\x06\x00\x94\xff\x16\xff\xa2\xfe\x0b\xfen\xfd\xe0\xfcF\xfc\x91\xfb\xcc\xfa!\xfa\xa0\xf93\xf9\xe2\xf8\r\xf9\xa7\xf9d\xfa)\xfbB\xfc\xdc\xfd\xbb\xffj\x01-\x03<\x05t\x07X\t\x08\x0b\xe1\x0c\x06\x0f\x00\x11U\x12\x7f\x13\xcf\x14%\x16\xd7\x16\xe7\x16\xea\x16\xcd\x16\x05\x16]\x14x\x12\xd3\x10\xff\x0e\x8d\x0c\xc8\ta\x07@\x05\xde\x02)\x00\xd3\xfd\xfc\xfbF\xfa=\xf8-\xf6\xcb\xf4\xdd\xf3\xec\xf2\x0c\xf2\xb1\xf1\x04\xf2`\xf2\x9d\xf2\x1e\xf32\xf4Y\xf5\x1e\xf6\xc6\xf6\xb7\xf7\xa2\xf8\x1e\xf9a\xf9\xda\xf9\x83\xfa\xd6\xfa\xee\xfa\x1a\xfb`\xfbt\xfb\\\xfbO\xfbH\xfb#\xfb\xcc\xfat\xfa6\xfa\xf2\xf9\xba\xf9}\xf9L\xf9\x1e\xf9\xe7\xf8\xda\xf8\xfe\xf8:\xf9l\xf9\x9d\xf9\xc4\xf9\x11\xfaz\xfa\xf1\xfa\x92\xfbG\xfc\xef\xfc\x87\xfdN\xfe<\xffA\x00:\x01"\x02\xff\x02\xd8\x03\x9c\x04E\x05\xe4\x05z\x06\xe3\x06\x16\x070\x07I\x07a\x07g\x07S\x07\x1f\x07\xca\x06\x7f\x06\x15\x06\x98\x05+\x05\xaf\x04*\x04\x8d\x03\xf4\x02u\x02\xf8\x01\x8b\x010\x01\xce\x00o\x00+\x00\xf6\xff\xcb\xff\x99\xff\x82\xffn\xffM\xff&\xff\x0f\xff\r\xff\xf5\xfe\xdc\xfe\xe9\xfe\x0e\xff;\xffc\xff\xbc\xff#\x00A\x001\x00S\x00\x9c\x00\xc5\x00\xab\x00\xba\x00\xfd\x00\x00\x01\xcf\x00\xc3\x00\x04\x012\x01\xd0\x00J\x00\x0c\x00\xd8\xff^\xff\xa9\xfe.\xfe\xd7\xfdP\xfd_\xfc\xa1\xfbM\xfb*\xfb\xe2\xfaY\xfa\xfe\xf9\xe8\xf9\xdd\xf9\xb7\xf9\xb0\xf9\x1a\xfa\xa2\xfa\x01\xfbB\xfb\xe7\xfb\xfb\xfc\x02\xfe\xbe\xfeR\xff\x03\x00\xd4\x00\x94\x01\x0c\x02o\x02\xcb\x02\x0c\x03\xf1\x02\xa1\x02j\x02<\x02\xe4\x01I\x01\x99\x00\x1b\x00\xaa\xffO\xff\xd0\xfeS\xfe\xf0\xfd\xa6\xfdi\xfdE\xfdQ\xfd\xa7\xfdX\xfe7\xff7\x00w\x01\xbc\x02\xc8\x03\xb6\x04\xb8\x05\xd5\x06\xd8\x07\xa1\x08\x8a\t\x9a\n\xca\x0b\xa0\x0c*\r\xd3\r\x1e\x0e\xe6\rp\r\xd3\x0cG\x0co\x0b~\n\xcc\t\x19\t=\x08]\x07\x9d\x06\xc0\x05\x98\x04D\x031\x02!\x01t\xff\xf7\xfd\x19\xfd2\xfc\xd9\xfaM\xf9\x87\xf8F\xf8!\xf7\xb6\xf5{\xf5d\xf5h\xf4n\xf3I\xf3t\xf3\xb6\xf2=\xf2\xc6\xf2M\xf3\xb6\xf3[\xf4\x88\xf5\x9a\xf6\x11\xf7\x9e\xf7\x9f\xf8\x95\xf9T\xfaO\xfb\x91\xfc\xed\xfdJ\xff\xc1\x00\xff\x01\xb1\x02W\x03\xe0\x03\xcf\x03\xbf\x03\xa8\x03:\x03\xeb\x02\'\x03?\x03\xd4\x02o\x02\x03\x02\x12\x01\xe9\xff\x10\xffA\xfej\xfd5\xfdt\xfd\xc5\xfdf\xfe\x8e\xff\xa4\x00\xfe\x00"\x01f\x01O\x01F\x01\x81\x01\xe8\x01\xac\x02\x97\x03~\x04K\x05\xdf\x05d\x06\x06\x06C\x05\x8d\x04\xae\x03\xf9\x02e\x02%\x02\'\x02\x06\x02\xe3\x01t\x01\x17\x01\xe6\x00\x19\x00\x16\xffW\xfe\xed\xfd\xb8\xfd\xa0\xfd\xba\xfd\xfd\xfd9\xfeX\xfep\xfe\x9c\xfe\xa0\xfe\x92\xfer\xfe?\xfe8\xfe\x9e\xfeu\xffh\xff4\xff\x9b\xff\xfc\xff\xf3\xff\xff\xff\x95\x00\xf5\x00\xb5\x00\x99\x00\xf1\x00\x1f\x01\'\x01\xde\x00~\x00\x8e\x00\x02\x01P\x01`\x01\xac\x01\xc7\x01\xf6\x00\x04\x00\x0e\x00\x08\x00K\xff\xd6\xfe \xffU\xffk\xff\xab\xff0\xff\xf3\xfc\x17\xfb\xfb\xf9\x86\xf8~\xf9w\x01\xbd\t\xce\x08I\x04D\x05Q\x088\x04b\xfeR\xfe\\\x00\xb9\x00\xfa\x00\xb8\x03&\x05p\x02\xef\xfc\xe7\xf7Y\xf6\xc2\xf70\xf9\xfa\xf8\xa5\xfa#\xff\x8e\x03C\x051\x06\x97\x07\x92\x05\xad\x00\xfc\xfe\xce\x01q\x05\xd3\x07\x15\x08+\x07\x91\x05\xaf\x03\xaf\xff~\xf8B\xf4\x82\xf3\xfe\xf1Q\xf1\xcf\xf3\\\xf7\xab\xf6\xa3\xf3,\xf1\xff\xed\xd6\xec\xdc\xedU\xef\x10\xf0\xa9\xf3\xb1\xf8\xd2\xfa_\xfa\x1e\xfa\xd5\xf9\xb2\xf7=\xf5\xb5\xf6U\xfb\x83\xff\x17\x05\xfb\x0f,\x1d\xdd!\x8a\x1eo\x1d\xd6\x1f\x85 \x04\x1f\xcb\x1e\xc4\x1fx\x1fV\x1f\xb4\x1c\r\x16l\r\xa4\x03\x1d\xfa\xca\xf1\x91\xed\xa0\xeb\xfe\xe9\xb0\xe9\xae\xeb\xc3\xed\x83\xef\xd4\xf0\xc0\xf0\xe8\xf0\xb0\xf3K\xf9\x90\xff\x08\x05\xb8\tk\rt\x0e\xd4\r\xf7\x0c\x19\x0b\xed\x07:\x04\x85\x02q\x03\xee\x03\x81\x02;\xffF\xfb\x9d\xf8\xbc\xf6g\xf4\xe0\xf1\x9f\xf1\x91\xf3\x00\xf5\x8e\xf6\xad\xf9x\xfb\xf3\xf9\xe4\xf8{\xfa0\xfc\x8e\xfc\xfd\xfc+\xfeX\xff\xe0\x00\xbd\x02\xde\x02\x13\x01\xfb\xfey\xfd\xd9\xfcm\xfdZ\xfe\x90\xfe\xdb\xfe\xeb\xffw\x01\x08\x02m\x01?\x00:\xff\x87\xff\xf9\x00\xcb\x02(\x04\xb4\x04\x85\x04\xc1\x04~\x05\x03\x06\xc4\x04\xf5\x02i\x02"\x03\xac\x04\x80\x05[\x05\x8d\x04n\x03\x9c\x02\x06\x02\xa4\x01\xeb\x00t\xff:\xfe`\xfe\x81\xff\xc0\xffC\xfeA\xfcK\xfb?\xfb#\xfb\xea\xfa\xda\xfa\xb4\xfao\xfav\xfa \xfb\x12\xfc\xcc\xfc\xc8\xfc\x16\xfd\xcf\xfeK\x01\x1e\x03{\x03\x96\x03R\x04u\x05\x80\x06\x00\x07s\x07.\x07\x14\x06K\x05\xc9\x04C\x04\xcb\x02\xc3\x00v\xff\xb6\xfe\x06\xff\x04\xff\xf6\xfd\r\xfd\x94\xfc\xaf\xfc\xe1\xfc\xf6\xfc\xaa\xfd\x10\xfe!\xfe\xe0\xfe,\x00|\x01\x15\x01\xee\xff)\xff\x07\xff\x92\xff\xc6\xff\xab\xff%\xff\x88\xff\x13\x00\x7f\x00\xd2\x00\x05\x01\n\x01\xbe\x00X\x01\x97\x02l\x03\xaf\x03\xad\x03\xdc\x03Z\x04\xd1\x04k\x04\x1c\x037\x01\xa3\xff\x9b\xfep\xfec\xfe\r\xfd\x80\xfb\xbf\xfa\x87\xfa0\xf9*\xf7\x1f\xf6\xb5\xf5\xf6\xf5\x07\xf7\xed\xf8\xf1\xf9\t\xfak\xfaM\xfbQ\xfc\x18\xfdb\xfd*\xfe\xc2\xff\x0e\x02\x9d\x03\xee\x03[\x04\xa7\x04,\x04F\x03\xb1\x02\x0b\x03d\x032\x03W\x03\xda\x03"\x04\xb9\x02\x86\x00@\xffP\xff\xfb\xff\'\x01&\x02#\x02\xc3\x01\x0b\x01;\x01\xe4\x013\x02\x90\x01G\x00/\x02\x0e\x063\x07\xb0\x03\x00\x00\xbc\x01\x12\x04t\x01\x83\xfc\xc4\xfbr\xfe\x96\xff\xc7\xfe\xc5\xfe\xa8\xff5\xff\x02\xffU\x00Z\x01\xec\xff\x8e\xfe\x90\x00[\x04\xf1\x04\xf1\x00\x83\xfc\xee\xfaS\xfcz\xfdL\xfc\x02\xfa\x18\xf9\x96\xfb\x84\xff\xe0\x00\x91\xfe\xe9\xfb<\xfet\x02b\x03>\x01\xdc\x00\xcb\x01\x0e\x00\xc7\xfd)\xfe\xfb\xff9\xff`\xfd\x02\xfd\xc2\xfd\xf7\xfd\x99\xfd[\xfe\xfe\xff_\x02\xf9\x03\xc4\x04}\x05\xb4\x06s\x07?\x06\xa4\x04\x06\x05\x00\tq\r\x1e\x0e\x99\n\xe9\x05\xe7\x03\x06\x03\xd8\x01\x03\x01F\x01b\x01\xda\xff\x17\xffR\x01\x95\x01\x86\xfb\x1f\xf5s\xf6\xe3\xfbO\xfc\xd5\xf8\x96\xf6\xe2\xf6b\xf8\xc0\xf9M\xfa\xa6\xf8\xf3\xf6\\\xf7\xa0\xf86\xfa\x82\xfby\xfd4\x01\xd9\x05x\x08u\x07\xe1\x05\xc1\x04\x0e\x03\x11\x02$\x03\x9c\x04.\x04$\x03\xf5\x01\x0f\x00\xab\xfd`\xfbu\xf9\x8d\xf8>\xf9S\xfa\xdd\xfa\xe8\xfb\xa0\xfd:\xfe\xba\xfd\xaa\xfd\xc0\xfe\x1a\x00q\x00\x9c\x00\x96\x01\xb7\x03\x1c\x05\xa5\x04\xa1\x04I\x05\x83\x04\x83\x02U\x01\xa2\x01=\x02\x87\x02\xef\x01\xd3\x00\xad\x00>\x01\xd6\x00\xfc\xff\x19\x00\x92\x00r\x00\x15\x00p\x00:\x01\x8f\x01\x8b\x01q\x01\x8d\x01c\x01\xaf\x00\xf3\xff\x90\xffy\xff\x91\xff\xac\xff\x87\xfft\xffN\xff\xed\xfe\x9e\xfeh\xfe9\xfe\x19\xfeL\xfe\x80\xfeF\xfe\xd9\xfd\x8d\xfd\x95\xfd\xc2\xfd\x04\xfe/\xfe\x06\xfe\xe6\xfd\x16\xfel\xfe\xc3\xfe\x14\xff\xc8\xff\x8f\x00j\x01S\x02\xd5\x02\xf3\x02\xcb\x02z\x02\x1b\x02\xc2\x01\xb6\x01\x8a\x01\x11\x01@\x01\xc8\x01N\x01\x0b\x00\x11\xff\x82\xfe\x93\xfd\xa5\xfc\xa0\xfcA\xfct\xfd\x0f\x021\x07\xf7\x08\x8d\x06w\x04\x00\x03Z\x01\x1c\x01\xa1\x02e\x04^\x059\x06M\x06^\x05\'\x03\\\xff\xa4\xfct\xfc\x8e\xfe\x9e\x00\x01\x02\x1d\x03\xf7\x01I\xff\xd5\xfd\x19\xfd\xeb\xfb\xd0\xf9\n\xf8\x9e\xf7k\xf8.\xfa\xd7\xfae\xf9-\xf8\xa7\xf7\x1d\xf78\xf7f\xf8\xa1\xf9\xf1\xf9\xb8\xfan\xfc\x12\xfeL\xfee\xfd)\xfc\xf8\xfa\xa8\xfa\xe0\xfaU\xfb.\xfb\x9c\xfa\xa6\xf9:\xf8\xd5\xf7-\xf9\xc9\xf8\x90\xf4\x96\xef)\xef$\xf1*\xf2\xe4\xf1)\xf2\xc1\xf3\xd0\xf3U\xf2\x19\xf2\x18\xf5\xdf\xf9\xd8\xfd\x90\x02_\tj\x11A\x18\x1f\x1f\xae(\xda1\'5a4\xfb6\xad9\xc66\xe8/f*\x1a\'U"$\x1b\xa0\x12\xb2\x08\xe5\xfd`\xf3\x9f\xea\x89\xe6\xc7\xe4\x84\xe3\xa4\xe2\xff\xe2E\xe4\xb2\xe6+\xea\x85\xec\xa5\xed\x96\xf0\x8f\xf6.\xfd\xce\x02_\x07\x04\n\xb2\t%\t\x98\t\x05\t\xeb\x06d\x04\x1a\x03-\x03\x17\x03\xec\x01\xc4\xfe\x1b\xfa\xed\xf4\x87\xf1\xd2\xef\xca\xee\x18\xef%\xf0\xbd\xf0z\xf15\xf3f\xf4\x15\xf4\x90\xf3\xbb\xf3\xc3\xf4\xdb\xf7\x8f\xfc\xdf\xff+\x01\xe1\x01\x89\x029\x02\xac\x01\x8b\x01\xf1\x00+\x00\xf7\x00\xaa\x02\x84\x036\x03\x1f\x02\xbc\x00s\xff\xdc\xfe\xd2\xfe\x95\xfex\xffc\x00\xde\x00\x1b\x01v\x01w\x01\x95\x00\xda\x00\xf4\x01)\x03/\x048\x05f\x06z\x06\xb8\x06=\x07\xd9\x06\xfc\x05\x15\t\x12\x12v\x16 \x12`\x0b\'\x06\xc6\x01\x87\xfe\xc3\xff\x96\x02\xe6\xffP\xfc%\xfe\xbc\xfeb\xf9\xe3\xf2`\xee*\xec\xa4\xee9\xf6\xc8\xfc\x8c\xfey\xfdS\xfd\x1e\xfd\x88\xfc\xb3\xfc\x00\xfb\x1d\xf9\x18\xfb\x02\xff\xa6\x02\x90\x02\xb0\xff&\xfc\xec\xf7\xdc\xf4\x12\xf4\xfb\xf3\xa6\xf3o\xf4\xef\xf5-\xf9\xb1\xf9\xc0\xf6-\xf4\x07\xf12\xf1\xda\xf4@\xf7\x98\xf9\xb2\xf9\x16\xfa\xf9\xfas\xf8\x13\xf9\x03\xfa\x94\xfbZ\x00\xfe\x02\x12\x02\xd9\x02\xeb\x07\xdc\x0e(\x10\xdb\x10g\x14\xd2\x13U\x17\x0c$\xa34y8$0\x1a.\x920o1],]%\xa9 "\x1c\xee\x1c\xd4\x1b\x97\x12\xf0\x06\x0c\xfb\xc7\xf0\xce\xe8j\xe5,\xe5c\xe1T\xdb\xa3\xdc\xaf\xe20\xe5\xbf\xe2\x08\xe0\x90\xe1\x95\xe7\xbe\xed\xb1\xf5\xd4\xfd2\x02\x11\x04\xe0\x05\xa6\n\x18\x0eO\x0c\xd5\t\\\n&\n\xa3\x08\xe2\x06]\x06 \x03\x0f\xfdh\xf7\x8c\xf3\xc1\xef\xc4\xe9J\xe7\xb8\xe7M\xe8\xba\xe8\xc3\xe9\xce\xea!\xea\x88\xeb\x0e\xeff\xf2\x19\xf6\x95\xfb\x96\x01\xfe\x044\x07\x1f\na\x0b\r\n\xd0\t\xb1\n\x86\n\x82\tv\x08\xc0\x07b\x05\xd3\x03\x10\x04>\x02\xae\xfe\xa6\xfd\xd3\xfe\x8b\x00T\x01\xd9\x02\\\x06\xe7\x052\x051\x07\xa3\x08*\t\x11\x07\x08\x07\xa7\x08\xeb\x06\xcf\x04\x93\x03\xdb\x023\x01\x95\xff%\xff\x14\x00\'\xfe\xd7\xfai\xfaq\xf9O\xf7\x08\xf2\xfd\xee\xda\xf0\x8d\xee\xb7\xeb\xfb\xeaR\xe9L\xe8\x8d\xe4\xfd\xe5R\xe9\xce\xe7)\xe9\xf2\xebt\xf1n\xf5\x86\xf6\x18\xf9Z\xfb\x84\xfd<\x00Q\x03Z\x05v\x05\xd9\x05[\t\x8a\x0b)\x0b{\x06\xb9\x03\t\t\x84\r\xe0\x0e/\t\xc2\x06\x06\x08\x8d\x05\xdb\x03\xa1\x04\xd7\x05\x8a\x05\xf7\x08-\x0e\x1b\x12`\x11\x90\x0f\xde\x12\x1c\x15\xaf\x19\xc5\x1c\xa1\x1f1#v"\x16\'\x81/\x993\xae,*$\x91#\xec"\xc4\x1c\x83\x18\xc1\x16\xdf\x0eh\x055\x00\xf8\xfd\x8f\xf4.\xe8\xd2\xe0\x98\xdf\xa1\xe1\x03\xe2\x9d\xe2.\xe2\xca\xe2\xf9\xe1\x01\xe39\xe9\xe8\xed\x1e\xf1N\xf5\x1e\xfdn\x01\t\x03\x8e\x05\x10\x03O\xfd\xe6\xf9Z\xfa\xf5\xfaQ\xf9\x91\xf8\x89\xf7\xda\xf35\xf1\xe1\xefc\xec\xbe\xe9\xcd\xe8\x81\xeb+\xf1d\xf4\x12\xf7/\xf8\xbb\xf9;\xf9]\xf9\x87\xfc,\x00\xd1\x01r\x03\xd9\x07\x8f\x0b\xcc\x0c\xab\x0b`\x0b^\x08\xd7\x06\xf2\x06n\x08\t\x0b\x8f\x08\xef\x06v\x04\xcf\x020\x02\x0f\xfe4\xfc\xd3\xf9\xce\xf6\xf9\xf6\xca\xf5\x1c\xf5d\xf2\xfb\xeep\xeeL\xef\x11\xf1\x14\xf2\x0f\xf2R\xf2i\xf3\xbd\xf4\x9c\xf7\xa5\xfa\xee\xfc\x9f\xfd\xca\xff\x88\x02\xa3\x03\xaa\x04B\x05]\x05\xdb\x03s\x04r\x06\xa6\x06\x10\x06e\x03r\x00\x0f\xffu\xff\xcc\x01!\x017\xff\xe4\xff\xbf\xfc\x7f\x01@\x04\xc5\x02\xde\x036\x01c\x00W\x00\xe3\x05[\x0b\xfd\n@\n\x0b\x0e\xbd\x0f\xb4\x0e\x10\r\r\x0fo\nW\x06\xda\x0e\xae\x10+\r\x9b\rC\r\x9d\x07!\x047\x06\x82\x06\x05\x00b\x02\x81\x08\xd6\x06\xcc\x08\x08\x0c\xb8\nz\x01\\\xffY\x06m\x05\x05\x04\xd6\n\xc1\x08\xe4\x03\x87\x07?\x07\x13\x02\xdd\xfdp\xfd\xd9\xfb\xb7\xfa\xd2\xfd\x89\x01t\x00\xeb\xfaw\xf9\xd1\xf6\xd6\xf3\x8d\xf3\xb5\xf5?\xf7Q\xf2\xc0\xf4$\xf9q\xf7\x9c\xf7!\xf8\x86\xf43\xf5\x8c\xf8z\xfa\x06\xfe]\xffP\xff\'\xfb\x87\xfc~\x00\x15\x00(\xfe\xd5\xff!\x01\x8c\xfe]\x02\n\x04q\x00\r\xff#\xfc+\xf9e\xfdM\xfe\xf2\xfb\xbc\xfa\xe0\xfc\xca\xf8\xab\xf6\xed\xf8\x9a\xfb|\xfb\xf1\xf3p\xf7\xbb\xfd\xfe\xfb\xd7\xfap\xfe\xd4\xfa\n\xfc\xd2\x003\xff@\x02|\x02\x87\xfd\xef\xf9\xd6\x038\x07;\x01\x87\xfe_\x02m\x05\xfa\xfb\x94\x00\x90\x01K\xffu\xfdB\xffM\x03\x87\xfa9\x08\x0e\xff\xdb\xf3G\x01/\xfe\xa5\xf4\x18\x02\xd0\x05\x0c\xf4\xd3\xf8\x12\x02\xed\xff\xd9\xfa\xb0\xfc;\x02~\xf5\x8e\xf53\x0e\xc1\xfc\x01\xfa8\x0c\x01\xfcB\x02z\x0c&\x00R\xfe\x92\x08\xb5\x04\xe1\x05\x8f\r:\x0c\xf3\x01\xbf\x02\xec\x07\xa4\x04\xa2\x01]\x08\xef\x03~\x03\xf1\x04\x85\x03\x16\x05I\n\xc3\x03\xe1\xff\x97\x0b\x99\xfc\xe8\xfb\x18\x03v\x0b\xff\x06]\x02Y\x06?\x015\x05\xa7\xfc\xc7\xfe+\x03"\xfb|\x08\x9e\x05\x17\x03\x8d\x08\xb3\xfc"\xf8\xad\x01\xba\xf8\xfa\xf9\x9b\x0b\xfc\xf3\xa0\xfc\xa9\x06\xa1\xfa\xdb\xf9\xf3\x00\x9a\xef\xbd\xf3#\xff\x12\xfd\xbb\x02*\xfd\xe2\x01)\xfd\x1d\xff\x8a\xf2W\x05#\x03\x95\xf6\xa6\x06b\x06\x81\xfe;\x00\xad\n6\xfbV\x04\xc3\xfc\x9d\x00A\x03q\xfe\xc6\x01\x03\x01B\xf7\xa9\x04\xb3\xfc\xca\xf2\xbc\x03:\xfb,\xfbi\xf9\x9c\x04-\x0bd\xf4\xc6\xfa\xf2\x0c\xbb\xf9\xe1\xff\x1a\nR\xfe/\x02\x0c\x0b\xb6\xf7/\x00B\t\xb9\xf3\xa6\x00\x9b\xfbR\x01g\xff\xbc\x01\x11\x04o\xf8\x8a\x00N\x08s\xfc\xdc\xf6\x13\x0e.\xf3\xc1\xf2{\x13:\xf5\xc4\xfce\nU\xf4\xf2\xfeF\xfd\x0c\xee\xee\x08\x8d\x06\x0f\xee\xdc\x04\x08\xffB\xfb?\x05\xc3\x04\xc6\xf56\xfc]\x08"\xf3\xa7\x06\xa3\t\x02\x03\xba\xf6:\x00N\x03\x92\xfc\xf2\x04\xd6\xf9!\x0b\x82\xfac\xf5\xc0\x15\xd5\xf7\xcc\xf8\xc4\n\x8c\xf56\x03\xbc\x0e\xbd\xff\xf1\xfd|\x07u\xfc\xb8\x07D\xfd\xd7\x03\xba\x0e\xaf\xf6\xb5\x03\n\x06\xc9\x03\x02\x01\xbf\xfe\x8e\x01\xe7\xfe\xe7\x00*\xfc\x90\xfd\x8f\x02R\x02u\xf2\xfa\x054\x02\x0c\xf2\xa4\x02H\x00{\xf3\xd3\x00\xfa\x07^\xf8\xe5\x02\x1a\x00\xd0\xfc\xed\x05\xff\xf9\xec\xf9e\x07\xd7\xf6W\x02\xd9\n\xd2\xf4\x15\xfd\x14\x077\xef\x87\xfb\x11\x10\x0b\xf8@\xf4\xe6\x08\x05\x06\xe5\xef\xdb\r\x07\xfb%\xf5\xf2\x05\x14\xf7\x17\x07\x1b\x06\x90\xf2)\xfej\x10\xd5\xf9\xbb\xf9\x1e\x0b\xec\xf8\x15\x03\xa5\xff\xb8\x08\x8b\x00\xae\x07\xf0\x03\x96\xf6\xc8\n\xdd\x02\xc4\xfbz\x02\'\x11K\xf5\xb4\x03\x1f\x01:\xffL\x03\xe2\xee\xb7\x0f\x84\xfc\xce\xf9\xef\x0e\xa2\xf5\x0b\xf7B\x15{\xf4\xa2\xf0\xce\x17\x1c\xfc#\xf0\xef\x0c\xea\x07m\xf1\xcd\x06\xbe\xfb>\xfb\x8b\x02\xc1\xfe\xfd\x03\xc3\x00*\xf6\x08\xff\x10\x0b\xc5\xef9\xfe\x81\ns\xee\xe2\x04\xb2\x00]\xfa/\x07I\xfd\x00\xf8;\x009\xf8\xbc\xf6\x84\x18T\xf89\xfb\x81\x07\x10\xfd\xf9\xfb\xa8\x02\xca\x05\xa7\xfd\xf2\x04\xda\xfd{\x05\xb6\x08\xd6\xfc\xdf\x02\xb0\x04\xed\xf8\xff\n\x11\x03\xa4\x00y\xfd:\x0b\x16\x04\\\xf7\x02\xfe\xd3\x01\xf3\x06G\xf7z\xfe\x91\x08v\x04\xe8\xf8\x93\xfb\x90\xfa\r\x06\xd1\xf9\xa8\xf4\xdb\x14\x87\xfen\xf2\x16\x08\xa6\xfc\x1c\xfc\xb1\xfe\xdd\t\xb0\xf7\xff\x01\xd5\x03:\xf5\x8f\t\x8b\x00\xcc\xfc3\x00R\x06\xe9\xf1\xd1\x06\x8d\x01\x1b\xf8&\xfb4\x00|\x02\xe3\xf5\xa6\x08\xcf\xfa\xb9\xedT\x02{\xff\x06\xf2\xb3\x0e\xcf\x00\xd7\xf1\x90\x06\xa3\x08\xe2\xf9g\xfc|\x01\xb7\t*\xfbe\t\xc0\x0b\xb2\xf60\x05\xb0\x00\xd1\x06\xff\xf8z\x00\x10\x0f\xf6\xfdi\xf8\x98\na\x04%\xf5W\x08\xd0\x03\xeb\xfc\xdc\xf8\x0b\x06\xfa\x04y\xfc<\xfd\xff\x00f\x0c(\xf4\xbf\x001\x07\x8f\xf4\xce\x01\xdb\x00\xb9\xfc\x9d\x06#\x01\x8f\xf6\xe1\xfb_\x04\x8e\xf9|\xff\\\x03l\xedE\x04\x07\x08Q\xf3O\xf8\xea\x10<\xf2\x92\xfa\x1c\x08x\xf5\xa5\xfc\x93\x06\xd9\xff\xc2\xfe\xc4\x01\xab\xff\xf0\x00k\xf6\xe0\rA\xf6)\x05\xa1\x03B\xfe\xdd\xffI\xfcg\x0e\x91\xefN\x0b\x1f\x01\x04\xef\xda\x07\x88\x0c:\xf45\x04\xfb\xf9\xf8\x05j\x08\xae\xf3I\tu\x08\xf8\xf4\xdc\x02\xe3\x13^\xef%\x06>\x07\x0e\x00\xcd\xff\xce\x01\xab\x06\x98\xff\xb5\x06\xac\xf8\xc9\x02&\t=\xf6\xc8\x00\xd6\x01D\x07\xda\xfcy\xf2a\x19\xb0\xed\x81\xff\x0c\x031\xfcZ\x03\xf1\xec\x7f\x12\xb6\xef$\x00\x9b\x085\xf6\xa8\xf5\xaf\x05\x1b\xfc\xa2\xf7 \x02\xfe\x02g\x03\x06\xf9P\xfc\x0b\x01\xe4\xfc\xa1\xfb\x18\x08\xba\x04\xdd\xf5\x88\x08\x8a\x03\x13\xf0I\x07\xbe\xfe%\x02\xfd\xf8\xc6\r4\xff\x01\xf2\xd7\r\xde\x01\xc7\xf6F\xf61\x1a\xaa\xf3\x9a\xf9\xff\x12\xec\xfb\xca\xfc\x98\x03\xc2\x04H\xf5\x13\x04\x9c\x03\x9b\xfb\x87\x01(\t\xda\xff\xc7\xf5\x84\x04f\x07\xd0\xf6J\x04\xe8\x08`\xefZ\x11G\x02\xb6\xf5\xb1\x04\x13\x07\x04\xf6\xc3\xf2\xd9\x18\x8b\xf9\xbd\xfa\xfa\x04o\x08|\xf5\xa3\xfdf\x0b\x90\xf1\xb2\x03\x94\x03\xf0\xfc\x00\x00\xab\n\xb5\xf2\xdd\xf9\xfd\x07\xfb\xefN\n\x19\xf5\xd9\xf6J\x16R\xf3\x1c\xf5\x80\t\x12\xf6\xe0\xffW\xfe\xcc\xf7\x02\x06\xb4\xfcT\x04\x14\xf5\x86\x06\xd6\t\xc5\xedD\x07A\x01\xea\xf9N\x04\xe6\x01\xa6\n>\x07\xa3\xfa#\xfdV\x05\xe3\xf9\x93\r=\xff\xdf\xff\x9d\tQ\xfc5\xfdB\tV\xffz\xfb\x99\x07\x9b\xf1\x17\x13\x98\xfb\x8f\xfb\xf1\x06E\x05\xab\xeeM\xfe\xac\x12s\xfc\xab\xf7\xdd\x00\x91\nC\xf5\x0f\xfc`\x05\x13\x03v\xee\xc4\x08\x18\x07\xf4\xf5\x14\xfb\x95\n\xfe\xfc\xd5\xee\x8b\x0c\\\x03\x14\xf2\x99\n\xb6\x01\xfe\xee\xf5\np\x05-\xf1\xf2\x00\xb4\x14\x1b\xeb\x90\xfa\x85\x17s\xf5\xd9\xed\xe0\nb\n.\xefJ\x02\xf6\r\xd7\xfc\xef\xf6p\xf8\xc9\x06\x84\x06\xa1\xf4\xe2\xf8\x02\x19E\xf4\xf3\xf3T\x16\x10\xef\xaa\xfb\x11\n\xd5\x01\x80\xf6\xce\x05\xb2\x06\xf8\xf5e\x03~\x10\xdd\xea\x13\xfd\xbe\x16&\xef\xc8\xff\xc8\x12\xd9\xf9c\xf8d\x0e\x81\xff\xc9\xf8\x19\x06\xef\x05\xa1\xf1\xbf\x05d\x18\xc7\xed\x0c\xf5\x06\x1c\x9e\xfc\xeb\xe6\xf4\x0f\xe0\x07\xd8\xf0\xed\x00\xe1\x07Y\x02-\xff)\xf6r\x06\xe8\xfe\xeb\xed"\x15\xf4\xf6#\xf1\xa8\x0b\r\x07?\xf5\xa3\xf5\xb3\x10.\x00\xdd\xed\x1e\x05\x89\x08\xb6\xf6+\xff\x9d\x04\x9a\x04Q\xf8\xdd\x04e\xff\xd4\x00Z\xfb\x06\xffn\nI\xf87\xfe\xc0\x0b!\xf7\xbe\xf8\xa9\x0f\xd7\xfa:\xf7\xdd\x01\xf3\np\xfa\x1b\xffH\xfd\xfb\x030\x02\xb6\xfb^\x01\xaa\x03j\x03\xd7\xfa\xc0\xff\xa8\xff\xe6\r\x15\xfa7\xfc/\r\x9c\xfc\n\xf8\xaf\x10\x80\xf8/\xfc\x8e\x10+\xfc\xf3\xf0\x1f\x08\xd7\x08e\xf6\r\xff\n\x01~\x07\xa5\xed\x90\x04O\x07I\xf1\x9e\x02\x91\xfdn\x06\x14\xff\x1e\xf8\xae\x0bS\xf8o\xf1U\x12a\x02`\xf01\x0f8\t\x18\xed\x88\xfb\x94\x12v\xf2\xbd\xff\xfc\x05\xdd\xfc\xf4\x04Q\xfb2\xff\x10\xfd1\x05\x16\xfcB\xfe\xc4\xfb\xe1\x0f$\xf6\x08\xf7?\x14;\xf6;\xf5\xc2\x06i\x05\xdc\xf0\\\x07\x17\x07\xfc\xfa\xb0\x018\xff\x85\xfd\xe5\xf7\xe2\x0f\x8f\xfd\xa1\xf5\xb5\x06\xec\x01\xd8\x01c\xf5d\r4\xffx\xf5[\x080\x08M\xf2\x92\x02\xe8\n\xef\xf7\xeb\x07\xeb\xf9d\xfe\xa7\x01\xb0\x01\x07\xfe\x9b\xfd5\x04i\x02\xf5\xf1&\rx\x04o\xe7*\x0e\x9b\x0c\xd7\xeb\xf1\xfcC\x12\x80\xfc\x1b\xf0\x97\xfc\x18\x1a\xa7\xf4\x86\xe9\xd1\x16 \x05t\xe2\x11\r\xef\x15\x08\xea\xb5\xfa9\x14\xac\xf3g\xf3\xa6\x15y\xfb\x7f\xf3I\x03Y\x0e\x81\xf83\xf6\xc8\x19\x12\xf0\xe4\xef\xe2\x10\x87\n\xe7\xe7\xde\x08|\x1b\x88\xe36\xf4\x8d\x1e\xdc\xfa\xb9\xe6t\x17a\xfa\x08\xfb\x9b\x05W\xfe\xba\x01\xcf\xfc\xb4\x00Y\xfeR\x06b\xf1g\x0fb\xff\xdd\xf1m\x0c\x81\x00\xbb\xf6O\x06M\x0b|\xec\xcd\x04d\n\'\xf3\xd1\x04\xd6\x0c~\xf3\x85\xffR\x0cB\xf3\xc5\xff\xf6\x08\x13\xfb\xe1\xfe`\x02.\x05\xc7\xf8\x9c\x00}\x05>\xf3\xed\x05\xab\x064\xf6\xb9\xf9\xd3\x13\x11\xfa\x17\xf1S\x0cg\x02w\xf2\xcf\x02\x06\t\xc6\xf7\xbb\xff\xf0\n9\xff"\xf5n\x06\x8e\xfeN\x00\xbb\xf7\xb6\x05\x17\nZ\xf9E\xfdX\x07O\x02U\xebj\x0b_\x12\xe0\xe4F\x03\xc8\x11\xb4\xf2\xe3\x03g\x0b\xe2\xf0\n\x015\x04\xe5\xfa\xbb\xff\xcb\x04\x7f\x02j\xf9_\xff\xec\tM\x04F\xeb\xae\x01\xcb\n\x8f\xf5\xa3\xfbI\x1a(\xf4z\xf4O\x0fD\xf7\xbf\xf9\xfe\x06\x81\x05\xf3\xf3\x15\x07"\x0ch\xf8\xc4\xf7O\x0c\xe0\xf3S\xfc\x80\x0e\xa6\xfbg\xfa,\x10:\xf6\x9f\xf6\xa7\x0fb\xf9`\xfa\x03\x02^\x07\n\x01\x97\xfd\xd1\xfc\xba\ts\xf7\xa8\xfcD\x05H\xfe\xce\x01P\x07\xfa\xf9\xcd\xfeb\x08\x9a\xf0\xb6\x08.\x03`\xf8\xa0\x04,\x02\xe9\xfb\x16\x05`\xfdi\xf8\xf9\x02\xd6\xf7D\x01}\x05N\x07\xb5\xf3\xe3\xff\x16\t5\xf6\'\x006\t\xa3\xfb\x8f\xfd\xb1\x0b\x8f\xf8V\x04#\x02\xb1\xfat\xfe\xcd\x06\xf5\xfao\xfe\xd4\x02\xd4\xfc\xf1\xfdT\xff\xf7\x00\xc7\xfcY\xffU\xfb\xe1\x0ca\xf2\xb8\x02B\n\x96\xf3l\xf8\x17\x04\x86\x07\x81\xf99\x05\xeb\xfe\xa8\xf7:\x064\x04X\xf8-\xff\x1f\t\xd7\xfe\xb1\xf7\xd7\x0e\xbf\x00\xa6\xed\x9f\x07\xbf\x0e\xb9\xf2\x1a\xfd\xac\x0e\xcf\xfap\xf2\xf2\x10P\x06\xb8\xed\xc3\x08\xd1\x00\x8a\xf5\x94\x045\x0c\xf7\xf7\x11\xf9\r\x0b\x8f\xfe\x0f\xf9\x80\x01\xb6\x000\xfe\xcd\xffZ\x06o\x02\xf4\xfa\x1b\x03\xe8\xfa\xeb\xfd\x8f\x00\x9e\x00\xf0\x03\x8d\xffw\xfd\xcc\x03\xc0\xfa\xb4\xfel\x04\x8f\xf8h\xfdJ\x00n\x01\xcd\x04\xd5\x02\xf9\xf5 \x02\x12\xfd\xa2\xfb\'\x07\x80\xfd\x12\xfe\xbd\x03k\xf8\xf6\x05\x82\x06\x8b\xf5\xe4\x04_\xfd6\xf8\x87\x01D\x08\\\x02\xae\xfc\xae\x03\x99\xfb\'\xfd\x18\x03\xbb\x03\xfe\xf8y\x02\xf2\x07\xb6\xf6\x89\x07i\x07\xad\xf8\xa1\xfdb\xfe5\xff\xea\xffe\t\x96\xfe5\xfa\xc8\x06g\x00\x9a\xfbG\xff[\x03\x9d\xf7\x8a\x00\x8b\x05+\x06\x8b\xff\xe5\xfd\x1f\xf9\x90\xfd=\x05N\xfc\xe5\x02\x18\x01\x9d\x01\x90\xfc\x10\x00\xb4\x046\x02S\xf9\x91\xf3\xc4\x08y\t\xbb\xf96\x06\x96\x00\x93\xf5\xb7\x00g\x08Z\xfcX\xfaP\x04\xc6\xf9e\x01\xfd\x05\xfc\x04\xb0\xfb|\xf5\x8b\xfe\xc6\x04c\xff)\xfc{\x058\xff"\xfa\x05\xfe\xb1\t%\xfe\x9a\xf9\x1d\xfe\x06\xfdD\x020\x06\xb3\x00\xbb\xfe\xb9\x00\xff\xfb\x19\x00H\x03\x16\x03N\xfc\xbc\xfe\xb6\x01\xba\x01I\x03\x05\x02\x8b\xfd\xa3\xfd\xa4\x00D\x00t\xff5\x02\xe3\xff\xa4\xfdD\x04\xc4\x00\xa9\xff\xe8\xff\xb5\xfcB\xfdC\x01\xa2\x02\x10\x01D\x01\x15\xfe\xbb\x00\xcc\x00k\xfeQ\x00\xc4\xfcV\xff[\x03\x99\xff\xa1\xff=\x01\xab\xff\xba\xfd\x90\x01\x17\x03\xbf\xff\xd0\xfa\xfe\xfc\xb0\x05\xb2\x01\n\x01B\x04\xba\xfd\x0b\xfc\xbf\x01\x93\x00\xe6\xfc\x91\x01P\x02\x9e\xfd\xc4\x01\x1e\x04\x81\xff\xde\xf9\xa4\xfer\x03c\xfdi\x01\xe3\x02\xc5\xfd\x89\x01\xdb\x00[\xfe\xf5\xff\xe9\xff`\xfdv\xfd\x1a\x03\x98\x05\xd3\xfd\r\xfe\x01\x02x\xfe\x87\xfd\xc9\x02\xd2\x00\xd1\xfe\xa0\x00\xd7\xff}\xffS\x01\xc2\x02\xce\xfd\x0b\xfe"\x018\x016\xfdA\x02o\x01h\xfd\xbe\x00*\x00\xe5\x01u\x02\t\xff,\xfb"\x00^\x01\x08\x00\x80\x04\xdf\xff\x92\xfd1\xff\x06\x01z\xfd\x91\xff\xef\x02a\xfc\x92\x00\x89\x03]\x01!\xfe\x01\xfe\xf9\xfd\xf7\xfd\xe3\x00\r\x00T\x02#\x02\xcc\xfb\x07\x00\x92\x01\x8f\xfc\xb8\xfe\xdc\x00\xbd\xfc\xc1\xfd\t\x05\x0f\x024\xfb(\xfe&\x01\xf3\xfa\x80\xfc\x80\x02k\xff\'\xff%\x00X\xfe\xe9\xffp\xff\xb7\xfb\x12\xffa\xfd\x8d\xfd\x9c\x01\x8b\x01q\xfeG\xff2\xfew\xfbv\xff\xc3\x00\x8a\xfd\xf3\xfd\x1b\x030\x00\xb2\xfd\x82\x01?\x00\xc1\xfbZ\x00\x8a\x00K\xfe"\x04\xc8\x02\x9e\xfe\xfb\x00\xc6\x01\xd4\xff\x96\x00p\x01\x05\x03p\x01\x92\x03&\x06x\x03\x87\x03\xa0\x05T\x03\x93\x05\'\n\xae\x07[\x08\x9d\n\x9a\n\x82\t\xcc\x0b.\n\xcb\x06\x8e\ns\n\xca\x07\xcc\x07a\x08_\x042\x02W\x02I\xfeD\xfc\xdb\xfd@\xfbW\xf8\xa5\xf8\xb6\xf6m\xf4\x8e\xf3\xa5\xf3\xa0\xf2\xeb\xf1\x03\xf4\x8d\xf5\x1c\xf5\x9f\xf5K\xf6-\xf7\xdb\xf8F\xfa\xf1\xfa\x8c\xfd\x05\xfe\x0e\xfe\xc4\x00\xb3\x00\x1d\x00\x83\xff\x81\x00\x1c\x01\x80\x00J\x00\x87\xfd\x02\xfd_\xfd\xb0\xfa\x96\xf9\xf8\xfa9\xf8|\xf5:\xf63\xf5\x14\xf5\xb0\xf3F\xf3\xf5\xf4\xd4\xf4\x88\xf3n\xf5\xbb\xf8\xa1\xf54\xf8G\xfb+\xf9\xb0\xfc\x01\x01\x80\xfeh\xfc\x8f\x03\x0e\x05\x85\xffc\x02[\x07\xf6\x02\xab\xfe3\x05(\x05{\x01u\x02`\x04\x13\x00\x8e\xfe\x90\x00\xb2\xfe?\xfe\xed\x03}\x03\x14\xfe7\x03\x82\x06\x94\x06r\x05\x0b\x07\xa1\t\xb5\x0cT\x148\x19\xf4\x1b\xaa \x9f\x1f\xf6\x1dF!5&Y\'h%4(\xf3*\xba)F%: |\x1b\x0c\x14\xdb\x0c\x8a\x0b\x05\r=\x08X\xfeq\xf8a\xf5\x88\xef\xac\xe7\xce\xe1\r\xe01\xdes\xdc\x0e\xde\x8d\xe0>\xdf\xe2\xda\xbd\xd8\xab\xdc\x8e\xe0|\xe1$\xe6y\xed\x15\xf1j\xf3\xa9\xf6I\xfa\x86\xfc}\xfc!\xff\xf8\x04V\t\x83\x0bx\x0bu\x0cY\x0c\x05\t\x7f\x07:\x07\xa9\x06:\x04&\x03%\x03o\x01\xc1\xfd\xc9\xfa\xaf\xf7\xa3\xf4L\xf3\xc1\xf36\xf5\x97\xf4\'\xf3Z\xf3\xf4\xf5l\xf5^\xf4\x98\xf6|\xf9i\xfb\xe4\xfef\x02#\x04\xc9\x04;\x07B\x084\x08\x82\x0b\xca\ru\rF\x0e\x0e\x10\xd0\x0eM\rD\x0cE\x0b\x0f\t\x9a\x08\xea\x07 \x06\x86\x04\x8d\x02v\x00\n\xfe]\xfc/\xfaR\xf8\xac\xf9#\xf8S\xf6\xc6\xf5x\xf4~\xf3\xba\xf2\xf2\xf2_\xf5\xf3\xf2\x00\xf2\xb1\xf5\x84\xf5s\xf4\x1a\xf6P\xf61\xf5L\xf7e\xf9\x93\xfc!\xf9+\xf9m\x00\xd7\xfe\xb6\xfb\x88\xff\x89\x03\xd2\xfd\x07\x00)\x06\x98\x04A\x01\x03\x04"\x08\x89\x03@\x04\xb8\x05\xd4\x05\xcc\x06\xed\x06\xc6\t\x1e\t\xe4\x08\x10\x08\xf3\x08\x93\n\x1c\r\x8e\x0f\xe9\r\xec\x0f+\x14\xf6\x17m\x16c\x14.\x14d\x15\xb1\x18\xdf\x1b[\x1b\xa8\x18Y\x16u\x14\xd9\x13A\x12\xc1\r\x88\t\xd6\x05\'\x03\xf6\x02\xfc\x00\xcb\xfb\x93\xf6\t\xf2K\xee4\xecO\xeb}\xe9L\xe7\xce\xe5\x9a\xe5w\xe7_\xe8\xd7\xe6\x16\xe63\xe7\x9b\xe9M\xedG\xf1\x16\xf5/\xf6\xa2\xf5\xd0\xf7\x1b\xfb\xe8\xfd\xd4\xfd\xb9\xfe\xd3\x00<\x039\x05\x9f\x05\xe0\x04\x80\x02\xc5\x00\x84\xff\xa8\x01*\x03\xc9\x00\x19\xff\xd3\xfe<\xfe,\xfd\xaa\xfbB\xfbL\xfa\x8c\xf8\x86\xfaf\xfe\n\xff2\xfd\xd3\xfc\xde\xfc\xff\xfdX\xff\xdd\xff\xa9\x01~\x03b\x03^\x03\xed\x07\x14\x07D\x02\xe6\x02\x8e\x04\xb2\x04\xfc\x04\x94\x04\xc5\x06z\x04\'\xff\x1f\x00!\x05C\x00}\xf8\x02\xfe\xc2\xff\xd2\xf9n\xfd\x10\xff\x8e\xf9\x87\xf6\xb0\xfb\x05\xf5x\xf6u\xfb\x9c\xf5\x8e\xf9\xcd\xf4\x9e\xfa>\x02A\xf2,\xf5\xbc\xfe\xbc\xf6\xd8\xf5\xd7\xfb\xd4\x00[\x01\xf5\xfaX\xff\xba\xfe\x12\xfd\x1a\x02N\xfe\xef\xfe\xe8\x07\x07\x07m\x04\x0e\x07z\np\x08\x13\xff\x93\x06\\\x11\xe5\t)\x06\x8e\x10K\x0e\x0e\x087\t\xe0\n\x97\x07B\x06\x98\t\xed\n\x8c\t\x92\x0b)\x07\xbb\x04\xf4\x05\xb5\x07N\x08\xb4\x08B\t\xa7\x0c3\x0f)\n\xe4\x0cS\x0eW\x0b!\re\x10\x8e\x10,\x0fW\x0eq\x0e\xe6\x0c\xca\tj\x08J\x05\xf2\x03\xd1\x02\x05\x01^\xffN\xfb\xc1\xf7\xcb\xf4\x01\xf3 \xf14\xef\xa4\xee#\xee\xb0\xeb\x1c\xebi\xed\xaf\xecR\xeaZ\xed4\xeef\xee\x05\xf1\x8c\xf4o\xf4`\xf5^\xf8\x9d\xf8\'\xfa\xd5\xfb\x9d\xfd\xe9\xfc\x17\xfe\xc8\x01\x9c\x00\x02\xff3\x01x\x00\xdd\xfe@\x00\xa9\xfe \x01\x9d\xff\x00\xfe\xaf\xff\xad\xfe\xd7\xfe\xbc\xffq\xff3\xfd\xc7\xff\xb5\x03\xe1\xfe\xdb\xff\xf8\x05\xca\x01\x87\xff\x92\x06\x0c\x04h\x01\x00\x05\x04\x05Q\xffd\x034\x05\x01\xfe\x1d\x01#\x02\xae\xfd\x8c\xfd\x84\xfd\xb5\x00\xbd\xf76\xfa;\x00M\xf6\xdf\xfbd\xfa\t\xfa*\xfe\x18\xf8\xf2\xf6\xfb\xfd\x0b\xfd_\xfaM\xfd\xd4\xfc\x08\xff,\xf8\xfa\x03C\xfe\x9b\xfa-\x02\xc2\x01\xd3\xfe=\xf9\n\x07!\xfcp\xfa\xd6\xfcc\x02w\x03\x12\xf5\xf8\xfe\x98\t\x04\xf5Q\xf2\xab\n\xc0\x02\xfe\xf6\xb2\xff\xc2\x08\x08\xfe;\xfe\xdc\t0\x00\x0c\xfe\xe3\x0e\xa7\xfe\xa3\x07)\x12/\x04\xa5\x07\x04\n\x86\t\x86\x018\x0f\xc0\x0ft\x03\t\n\xb8\n\x8e\n\xb4\x00:\tT\nd\xfb\xcd\x03(\x10\xba\x08A\xf6~\t\xe3\x04"\xfc6\x00\xeb\x06;\x07\xe4\xf7x\x08\xaf\x03d\xffU\x04\x92\x03\x82\xfb\xf0\xfd\x9a\x08)\x01\xfa\xfe\x08\x01=\x06B\xf9\x95\xfe\xec\x03%\xfc\xa6\xff\xff\xfb\x16\xff\x18\x02\xee\xfdS\xfb\xaa\xfd\x18\xfc\xad\xf9\x8f\xfb\x0e\x02L\xf88\xf5\xac\x03*\xfab\xf2\x06\xfeH\x02l\xf0\x0e\xefo\x08\xa6\xfc\x18\xf4T\xfb\xdc\xfe*\xfd\x14\xf2\xe8\x03\xdf\xfeo\xf4U\xfc\xec\x01\xa0\xff\x84\xfa\x1f\x01>\xfcL\xfa\x1a\x03\xed\xfcZ\xfd\xec\x00\xd4\x07\x8c\xf6\r\xffA\x0b\x9e\x06\xb5\xed[\x02e\x11C\xf9%\xf85\r\xc1\x0br\xe8\xf4\x0b\x94\x05f\xf3\xdc\x01\xe4\xff\xfe\xfe\xbc\xfcC\x01\x96\x03\x9a\xf5\xad\xf5\x7f\x07\xfc\xfa\\\xf3\xdf\t\xe4\x01^\xed\xef\x08\xe0\x01\xce\xf4&\x03[\xf8\xa5\x05\xae\xffx\xfc\x9e\n\x91\x02N\xf4e\x06\xb7\x0cL\xee\xbb\x0b\xb1\nP\xf7i\x08\x8e\x01l\x003\t\xaa\xfc\xc2\x00/\x08\xa9\xf1\x9c\x04\xc6\x19\xf6\xef\x9e\xfcm\x13\x88\xf8\xe6\xec\'\x18\x90\x06\x0e\xe7D\x12\xce\x04\xc4\xfcr\xff\xd1\x08V\xf8\x0e\x04C\xfe\xa5\x03\xe1\x08\xd0\xf5\xb2\x07{\x01*\x04\x1c\xf9\xbd\xfd\xcf\t\x0c\xf8s\xfd\x8c\x06\xa3\xff|\xfeM\xfdq\t\xda\xe9\x16\r\r\x03\x98\xef\x94\x06\x8d\x05\x8a\x03\x99\xefT\r\xae\xfcS\xfc;\x06\xbd\x00I\xfd\xe3\x03\x10\xff:\x03\xfe\x03+\x01*\x02\xe8\xfd\x89\x06U\xfa%\x04z\x05\xe5\xf8>\x00v\x06h\xfb\xd3\x06\x06\xff\xd8\xed\xb3\x12\x11\xf7\xad\xf5\x92\x11\x17\xffL\xf5/\x05\xed\x08\xa9\xf7\xaa\x01\x8f\x00\xd2\x05\xd9\xf4o\x13\x9a\xff\xa9\xf7~\x05g\x01\xf9\x00\x9e\xf7\xa5\nm\xfef\x00\\\xf8\x05\n\xb0\xfd\xd3\xf4A\x07\x18\xfe\xd6\xfc\xff\xf6\x8a\x059\x03\xa6\xeeW\x06\x87\x01j\xf3.\xffK\x05\xdf\xf7r\xfc\xfa\xfe\x98\xfa\\\x03\xa8\xfd\x1e\xf9\x88\x01]\xfdZ\xf9\x85\x06\xa3\xf9\x16\x06\x18\xf2\xc8\x03\xf3\x053\xfa\xb1\xf8\xd1\x078\x02\xd1\xf6\x18\x08@\xfb\x85\x02\x9b\xf6e\x10a\xfd;\xf1\xb2\x0e\xda\x04\x99\xf4M\x01D\x0bT\xfd\xdd\xf4G\r\xf9\x05\x9b\xefb\t\xc3\x0b\xd0\xf1\x07\xff\x13\r\xae\xfa\x0b\xfd\xce\x01\xac\x0b\x9d\xee\x99\x06\xab\x03?\x02&\xf6H\x02\x95\x0c\xcd\xee\xfd\x05\x81\x05\x91\xfd\xd3\xf0p\x15\x97\xf2f\xfd)\t=\xfa^\x00\xb9\xfe|\x03\xc2\xf9J\x01\xfa\xf9\xe3\x07@\xff$\xfa\xd2\x02m\x02C\xf6\xa0\x05\xf6\x02\x88\xfa\xe2\xfa\xb3\x05\x7f\x02\xff\xf5(\t\x03\x00Q\xf4y\x03\xf8\x0b\xb5\xef\x81\x05\xcf\x03\xdb\xfa\xea\x07\x96\xff\xb2\xfbc\x04L\xff\x0b\xfe\xe3\x04s\x07O\xfc\x99\x03\xef\x017\xfa6\n\xeb\xfc\x13\x00\xa7\x05@\x06&\xf7\x80\x06\xea\x04\xf5\xfc\x06\xfc \x02[\x05\x86\xff|\x02\xac\xfa\x16\x06\x1d\x00;\xfa\x80\x03n\x03^\xf7-\x02\x03\x03\xd6\xfdH\xff\xdf\x03\xaa\xf7!\x00\x8b\x040\xfcf\x00\xf1\x00\x9c\xfc=\x03\xb8\x00\x94\xfa\xb2\x06\x16\xfc\xb7\xfe\x9d\xff\xf2\x01\xd1\x02\x0c\xfcX\x00\xd7\x03\x0c\xff\x98\xf9\xbe\x05\x96\xfd\x11\x01\xd3\x01w\xfa\x07\x04\x9a\xff2\xff\xff\xfc\xdb\x01J\x01\x87\xfa5\x01W\x03p\xfe]\xfcu\x04\x88\xfe?\xfd[\x06\xa7\xfd:\xfb7\x06\x04\x02\xe0\xfa\xb0\x02\x83\x04\x11\xfc\xc6\x03\xde\x01\xa8\xfe\x06\x01x\xff\xf5\x01-\x03\xca\x02\xee\xfc\xd3\xfe\xa9\x06a\xfb\x14\x01W\x05\x19\xfb5\x02\xb4\xfd\x96\x00e\x019\xfe\xcb\x00M\xf9h\x06~\xfb\xb5\xfa0\x07\xfd\xfa\xdd\xff\xa5\xfb\xc7\xfe\xd3\x02\xa8\x00\x1f\xf7\x12\x03/\x02X\xf7Y\x04\x98\xfeH\xf9\xcd\x03\x81\xfe\x8b\xf9\xfe\x05\r\xfd\x1c\xfc\x07\x01.\x01\xa6\xfaQ\x01Y\x03\xc6\xfe\n\xfbB\x04R\x03\xcc\xf9\xa0\x01B\x01\xc2\x04\x02\xf9\xc7\x03\x90\x02\x02\x00T\xfdm\xfde\x08,\xfb\x95\x01\x9c\x02\x9a\xff\xb3\xff\xe3\xfe\xb0\x00\x98\x01\x08\x02G\xfd\xd9\x00/\x04*\xfe\xdb\xfe@\x01\xb1\x00\xb6\x00\x80\xfei\x01\x98\x01\xd8\xfc\xdb\x01p\x00T\xfe\x95\x00\xeb\xfd\x13\x00\xf0\xff\x0f\xff|\xff\x03\x00d\xfd\x86\xff\x91\xff\x04\xfe+\xffv\xfd\xe6\xff\r\xff\xf6\xfc\xc3\xffF\xff8\xfc\x04\xff\xd6\xff-\xfc\xbd\xff\xa0\xff\xf5\xfb\xb4\xff\xdd\x00\xd1\xfcT\xfe}\x00\xba\xfe\x96\xfe\x15\x00\xdc\xff\x9c\xff\xe5\xffu\x003\x01\xd7\x00\x06\x01\x18\x00\x90\x01\xa6\x02k\x00\xb0\x01\xec\x02C\x00\xa8\x02r\x03\x08\x00X\x02\xf6\x02\xd1\x00\xa0\x01R\x02N\x01!\x01\x8a\x01\x14\x02^\x00\xb0\x00c\x01\xb0\xff{\x00\x92\x01}\xff\xbd\xfe+\x01\xb3\x00\xce\xfd\xdf\xff\x98\x00\xd7\xfe\xad\xfe>\x00\xbe\xff\xe4\xfdd\x00\xf4\x00C\xfe\xea\xfd\xfe\x00\xa7\x00\xa8\xfd\x9c\xffw\x000\xff\xdd\xfe\xd2\xff\x9b\xff\x13\xfe.\xff\xb9\x007\xfe8\xfe\x93\xff|\xff\xbc\xfd\xdb\xfe*\xff\x07\xfe*\xff\xf7\xfe\x7f\xfe\xa4\xfe+\xff\xc5\xfe\x80\xfe\xd8\xfe\xe5\xffA\xff\xe6\xfex\x00\x85\xffh\xff\xc3\x00\xca\x00\xea\xffb\x00\x81\x01k\x01\xb4\x00\x83\x01C\x02\xf5\x00\xaf\x01\xc0\x02t\x01p\x01\xc4\x02"\x02\x11\x01\xaa\x02\xe0\x01Z\x01\xab\x01\xa0\x01R\x01\xae\x00u\x01v\x01\x10\x00\xce\xff\xea\x009\x00R\xff\x03\x00\xc5\xff\xdb\xfe-\xffZ\xff\xd5\xfes\xfe\xbb\xfe\x0b\xff_\xfe5\xfe\x8c\xfe\xcb\xfe1\xfe7\xfe\xad\xfe\xa4\xfe\'\xfe\xeb\xfe_\xff\x86\xfe\xde\xfe\x89\xffK\xff\xdd\xfe\x17\x00\xe6\xff\x10\xff\x00\x00\xb9\x00w\xff\x1f\x005\x01\xc1\xff*\x00\xf3\x00\xd3\x00j\x00e\x00R\x01\x0b\x01x\x00b\x01\x04\x01\xb7\x00\x10\x01\x01\x01\t\x01\xf7\x008\x01R\x01\x08\x01\xd1\x00\x1f\x01\x0b\x01\x9b\x00\x01\x01\xf7\x00y\x00\xd9\x00\x0c\x01w\x00@\x00\xd8\x00u\x00\x14\x00Q\x00\x12\x00\xd0\xff\xe8\xff\xf0\xffN\xff\xa1\xff\xe7\xff\x13\xff\xd5\xfe\xd3\xff*\xffl\xfe~\xff=\xff\x87\xfeB\xff\x02\xff\xed\xfe$\xff\xd3\xfe&\xff\x1e\xff\xfe\xfe<\xff\x1b\xff\x08\xff\x87\xff\x1e\xff \xff\xce\xff\x85\xffH\xffv\xff\x17\x00b\xffY\xff=\x00\xf7\xff\x92\xff%\x00\x88\x00\xca\xff/\x00\\\x00F\x00T\x00y\x00\x86\x00\x8d\x00\xc2\x00\x9a\x00\xa1\x00\x8c\x00\xbf\x00\xcc\x009\x00\x90\x00?\x01\x88\x00\x14\x00\xff\x00\xb8\x00\xef\xff\x86\x00q\x000\x00\xe3\xffM\x00S\x00\xce\xff\xf8\xff\x0c\x00\x80\xff\xea\xff\x01\x00\x98\xffu\xff\xb6\xff\xd2\xff-\xffo\xff\xc9\xffS\xff\x13\xff\x99\xffL\xff%\xffe\xff,\xff^\xffE\xff.\xffz\xff\x7f\xff\x88\xffh\xffs\xff\xa1\xff^\xff\x8a\xff\xf7\xff\xa3\xff\xc8\xff\xe9\xff\xe2\xff\x03\x00"\x00\xf1\xff\xff\xffi\x00(\x00E\x00\x8b\x00?\x00L\x00\x9b\x00R\x00\x8c\x00\x94\x00\x94\x00T\x00\x7f\x00\xb6\x00C\x00F\x00\x93\x00D\x00\xf6\xff\x83\x00[\x00\xfb\xff*\x00)\x00\xeb\xff \x00\x1b\x00\xd8\xff\n\x00\x0f\x00\xdc\xff\x00\x00\xb9\xff\xdc\xff\x0f\x00d\xff\xc5\xff^\x00v\xff\xe1\xff\xfd\xff\x8f\xff\xe1\xff\xb5\xff\xc4\xff\xf5\xff\x14\x00k\xff\xf6\xff\x02\x00\x94\xff\xaa\xff\x0e\x00\xa9\xff\x86\xff<\x00\xb0\xff\x90\xffN\x00<\x00^\xff\xbd\xff\x86\x00\xe9\xff\x99\xffk\x00M\x00\x1f\x00\xe8\xff8\x00\x91\x00\xd0\xff\r\x00\x98\x00\xb4\x00\xc8\xff!\x00\x87\x00\x1b\x00M\x00B\x00\x0f\x00G\x00\x08\x00r\x00F\x00\xbc\xffJ\x00m\x00\xdf\xff\x06\x00\xa0\x00\xdc\xff4\x00P\x00\xfd\xff`\x00\x12\x00\xf6\xffg\x00\xfd\xff:\x00\x19\x00\x11\x00\x11\x00\xee\xff\x1f\x00,\x00\xf7\xff!\x00\x08\x00\x9d\xffd\x00\x03\x00\x80\xff\xe7\xff*\x00\xc6\xff\xf4\xfe\x15\x00\x1d\x00\xbd\xfe\x90\xff\\\x00h\xff\xba\xfeL\x00;\x00_\xff\x8a\xff\xf8\xff\xc7\xff\x19\xff\x1a\x00\xb6\x004\xffL\xffn\x00\xbb\x00\x99\xff\xf1\xff\x83\x00\xb7\xff\x8e\xff\xdf\x00\x1e\x01\xf4\xfex\xff\r\x02\xfb\x00M\xfd\xbb\x01\xee\x02\xf8\xfc\x95\xff\x18\x03 \x00\xc2\xfe\x08\x00,\x01*\x00\xb3\xff4\x01\xb6\xff[\xff;\x00\x9d\x00\x1c\x00\t\x00\x92\xff,\xff\xaf\x00X\xff,\xfe\x88\x02L\xff\xe4\xfd\xd4\xff\xcc\x00t\xff\xd0\xfd\xd2\x01e\xff\x98\x00<\xfe\xe9\xff\xa0\xff\xd0\xff\xd6\x01r\xfd4\xfe\xe1\x00\x16\x01h\xfe\xd0\xfe\x85\x00|\x01\xe3\xfe\x8d\xfe\x84\xfc&\x00D\x03\xa9\x03\\\x02\xcf\xfe\xf2\xfb\xf5\xfe\xca\x07\xf3\xfe\xdf\xfbv\x04\xa1\x00X\xfc8\x02\xc8\x04\x82\xfcY\xfa\x9c\x02 \x03z\xfd\xef\xfdh\x000\x00\x8e\xfe\xfa\xff{\xffc\x00\xa7\xfe\xd4\xff\xa2\x00\xd0\xfc\xc4\xff\x8e\x02\x1d\x05>\xfc\x18\xfd\x0b\x04\xb3\x02\x82\xfdb\xff\xc4\x03\xe6\xfe\xb5\xff\xd1\x00L\x00\xb4\xfc\xc1\x00\\\xff\xaa\xfd\x18\x02\xde\xfd\x07\x01~\xff\x94\xfd\xea\xfe\xac\x01\xfb\xfcZ\x00\x9e\x00\xf1\xfd\xf4\x00U\x01+\xff}\xfd\n\x00!\xff\x89\x02\xa2\xffD\xff\xcd\x02\xbf\x00\xfa\x01S\xfd\n\xfdr\x00\x88\x05\xeb\x00\\\xfdn\x02\xff\xfdB\x03(\x00\xbe\xf7\xe1\x02\x99\x04\\\xf9)\x00\x1f\t\xd7\xf8\xaa\xf8~\x04\xb7\x04j\xf9\xfb\xfbu\x03\xc9\x03\xa9\xfe\t\xfe\x85\x01\x8e\xfd\\\x00\xf6\x01$\x03*\xfa\xa4\x00E\t\xed\xfb\xc9\xfc\xa9\x04D\x00]\xfc;\x03\x16\xfd\x18\xff\x9c\x03^\x01-\xfb2\x01`\xfeh\x02\x82\xfe\t\xf8\xfc\x06\xd2\x07\xba\xf9<\xf7C\t\xff\x03h\xfb\x88\xfb\xca\x00\x81\x01\x08\x01\xe0\x00w\xff^\xfdN\xfc\xae\x05a\xfe\xfa\xf8\x95\x02k\x07\x89\xf7\xa3\xfc3\x0b\xb1\xfe,\xf8\xad\xfd1\x0b\x1d\x00\xd0\xf7\xe4\x03O\x03\t\xfd\xee\xfd\xb2\x06\xef\x02$\xf9\xe5\xffR\n\xf7\xfb\xba\xf8\x1c\t\xb0\x06\xff\xf7\xd8\xffb\tO\xf9 \xf8\xc1\t\x11\x07\xc2\xfav\xf8\x17\x03\xfc\x01\xe5\xff\xf5\x00E\xfd\xd9\xfct\x03\xb6\x02E\xf8\xb2\x02\x1e\x08q\xfa\xba\xf66\x04\x05\x0b\x8b\xfc\xdb\xf7\xda\x04\xc6\x05\'\xf90\xf4\x95\x0e\xc3\x04\xce\xed\xad\x05\xe6\x0b*\xf48\xf9\x8c\x0f\x16\xf6\x84\xee^\x13\x13\n\xe2\xec|\xf91\x12w\x01K\xea\xaf\x08\xf3\x0b\xee\xf5\x1e\xf7o\r\xb3\x08\x14\xf1\xa5\x02)\x06\xf7\xf5G\xf9e\x13\xb7\x03\x00\xf4m\x02\x88\x02\xd2\xfex\xfe\x7f\xff\x0f\x06\xf3\xfee\xfa\xc7\x03O\x02\xf9\x00l\xfe\xeb\xfdO\xfc\x99\x02\x13\x03\xea\x02j\xfb\n\xfb\xd8\x05A\x00\xed\xfep\xfc\x9c\x02\xd3\xfe\xab\x02\xe7\xfd\xfc\x01g\x08\xdb\xff\xeb\xf4\x88\xfb\xd2\x0bN\x04\xa1\xf6F\xff\x0e\x0fK\xf9\xee\xf0\x1c\x05\xab\re\xf6?\xf2,\x06@\t%\xfc\x95\xf8"\xfe\x80\x02P\xfa\xad\xfcD\x08G\xfc\x9a\xff\x89\xfb\xd3\xfe\xf3\x07\x96\x05\x08\xfc\xb3\xf9\xb4\x01\x99\x07z\x05\x0e\xfaJ\xfb!\x02\xf8\x04\x91\xf9\xc0\xff\x85\x0cp\xfd\x12\xf0\xef\x02\xb9\x08U\xfb\x8f\xf7F\x07$\x04\x03\xfb\x01\xfec\x04h\x02B\xf7\'\xff\xe8\x08&\x03\x88\xf5h\x026\x07\xd6\x01\xe0\xf8/\xfd\n\x05\xfa\xfdN\xfd\xdd\x02\n\x03q\xfdo\xfd=\x00Q\x03v\xff&\xfb\xe7\x00\xbb\x00&\xffT\x070\x00\xf0\xf8\xfe\xffd\x04\x80\xff$\xfey\x04w\x03\'\xf9\x84\xf9\xac\x0cj\x07\x19\xf4\xc8\xf9\'\x08\xd6\x05\x03\xfb\xa4\xfe\xe8\x03\x96\xfd\x1d\xfbg\x03\xb4\x049\xfe\x9b\xfc\xea\xfcg\xffW\x02;\x03~\xfe \xfa\xd1\xffN\x04}\x03\x1e\xfc!\xfd\x06\x01\xef\xfa\xb6\x03\xff\x0b\xea\xfd\x16\xf7\xe8\x00\xb7\x06-\x00p\xfe\xa7\x04N\xffk\xfa\x14\x07>\n\x0c\xfa\xf7\xf5\xcc\x01a\x05\x16\xff&\xff:\x01g\xfa\xf0\xf6\x08\x03e\x08\x84\xfa\xae\xf3\x16\xfd\xdc\x04\x97\xfe#\x00\xe0\x00\xb9\xf5$\xf9\xcf\x06\x88\x06l\xfc\x0b\xfc\xf0\xff\xb4\xfd\xab\xffD\x086\x03\x9a\xf6\x9a\xf9\x0c\x04\xc1\x03\x08\xff+\xfe\x16\xfb\x1b\xf9\xc8\xfe\xda\x02\xfe\xfb\xb1\xf6\x9f\xfa\x01\xfeu\xfcz\xf8=\xf8\x1b\xf9\xde\xf6|\xf9h\xfd\xe4\xfe\x98\xfdJ\xfcM\xfa\xf3\xfd\xc6\x03\xe4\x084\x12\xf8\x16h\x10\x01\x0bB\x14\xb4\x1e\xcd\x1b\x0f\x14\xcb\x19\xf7$\'$0\x1d4\x1a&\x19\x85\x0f\\\x06\x96\n\x10\x12z\x0c\xcd\xfe\x15\xfa\xb1\xfd$\xf7U\xeaG\xe3\xfc\xe5Q\xe8H\xe4V\xe6\xda\xeb\x13\xe8\x9e\xdc\x11\xd9\x90\xe3\'\xec\xf1\xe9\xb8\xe8\xb1\xf2\xfa\xfc}\xfc\x99\xf7X\xf8\xfa\xfd:\xff\x88\x00\x80\x08C\x13\xc2\x11@\x067\x05\x07\x0c\xf9\x0b\xf7\x02\xf4\x00\x9a\x08U\rX\x07r\x00\xd0\xfei\xfa\xa1\xf4\xe9\xf4\xb0\xfa\xea\xfc\xa5\xf8N\xf6\x14\xf8\xf2\xf8\x89\xf3\'\xf0\xb5\xf2{\xf8*\xfe\x8f\x00V\x02\xf7\xfe\x9a\xfb>\xfc\x8f\x01\xc0\x05\xc6\x06\xd3\x08S\x0c\xb5\x0e\xdc\x0cN\x0c\xfb\t\xf4\x07\xbd\t|\x0eH\x13\x18\x12(\x0cy\x08o\x07\xb6\x06\xe7\x05h\x05M\x05\xc2\x04\xe5\x02D\x01\x08\xfe\xb8\xfa\xcb\xf7\xfd\xf5E\xf8\x17\xfa\xf0\xf8\xc9\xf5\x03\xf3\x95\xf1h\xf2\x96\xf1\xbb\xf1#\xf5\x8a\xf6\x1d\xf7\x1e\xf6\xcd\xf7<\xf8\xab\xf6>\xf8\xfa\xfb\xb3\xff\xae\x00a\xff\xa0\xffk\x00\xba\xfe\r\xfe\xa4\xff\xd3\x01(\x02d\x01\x1c\x01\xa0\xff\xed\xfd\x82\xfc\xa8\xfc\xe5\xfc\'\xfd\x02\xfd`\xfb\xb4\xfb\x0b\xf9\x96\xf5\x1c\xf4\x9c\xf2\xa4\xf5\x16\xf6O\xf6\x17\xf6\xe6\xf5L\xf7\xe4\xf7\x1c\xfa\xde\xfb\xfd\xfc\xd9\x01\xb9\x10B"J%L\x18\xf3\x13\xe4"Z1#2I0\xcc6!;\xa88\x825\xd10\xea$\xae\x18\x89\x1a\x9f$\xd3"R\x12s\x02x\xfb?\xf3I\xec\xf0\xe7>\xe4E\xdd\x1a\xd7\xea\xd9\x98\xde\x81\xd8\xa5\xc8\xd0\xc2i\xce\xc8\xdbh\xdei\xdc\x9a\xe1\x98\xe9$\xed\xe4\xec\xfb\xf0\xae\xf8x\xfcy\x00>\t\x10\x15\x91\x15\x0c\n\n\x07P\x0f\xa2\x14x\x0eI\n(\x0e}\x10\xf1\n[\x035\x00\x87\xfb4\xf4\xbe\xf3V\xf9F\xfb\xa1\xf4M\xed\x17\xee*\xf1\xb7\xed\xbf\xea=\xee\xa9\xf4(\xf9\x1b\xfac\xfc\x85\xfc^\xf9z\xf9u\x00\xe0\x07\xcb\n\xa3\x0b\xff\x0cL\x0f\x1d\x0f\xe4\r\x1c\r\xa3\r\x1a\x0f,\x12%\x15\xa7\x14\xd5\x0f\xd6\t\x97\x06S\x05\xda\x05E\x05)\x04\x87\x03O\x01c\xfe\xa0\xfa\xc0\xf7z\xf4;\xf3\x9a\xf6E\xfc\xa0\xffJ\xfe\xbb\xfa;\xf7\x04\xf8\xeb\xf9\x0b\xfd\xb9\x01\xf0\x02\xb8\x03\'\x046\x03\xff\x00j\xfdQ\xfc\xfd\xfd7\x01\x84\x03\x17\x02\x11\x00\xa9\xfc=\xfa\xc8\xf8h\xf9\xe2\xfa\xdb\xfb\xc0\xfcC\xfd\xd4\xfc\x9e\xfa\x9d\xf8\xde\xf7\xd0\xf8\x80\xfbv\xfdv\xfe\xdb\xfft\xfe\xa6\xfcp\xfc"\xfd\x85\xff(\x00\xb2\x012\x04\xbd\x03\xc0\x02\xa8\x00\x9c\xfe\xe5\xfe<\xff\x9e\x00\x1b\x02\x85\x01:\xfff\xfc\xf1\xfa\x0b\xfb\xef\xfak\xfa*\xfc\x0f\xfeT\xff\xf9\xfe\xd0\xfc\xb1\xfb&\xfb\xa0\xfc\xfb\x00\xe3\x01\xcf\x00\x87\xfet\xfc\x9f\xfd\xeb\xfbF\xfc\xab\xfe\x12\x01\xb7\x00I\xff\x1f\xfe\x16\xfb\xc2\xf8\x94\x01\xb9\x18\xa7%\x81\x19\xd5\x06f\x0e\x7f$\x83(G\x1f\x80!\xc91y4\xb7*N(\xdd&~\x18\xf3\x08\xb9\x10E$\xaa!\xe4\t\x89\xf9\xe7\xfa\xf3\xf5s\xe8>\xe1\xb5\xe5\x9b\xe6i\xdfe\xe0h\xe6\xfb\xde8\xcc\xfe\xc6R\xd8\x0b\xe9\xd5\xe8\xe7\xe3a\xe9\xb3\xf1\xa1\xf1\xb8\xee\xa5\xf2\xd8\xfa\xce\xfc\xec\xfe\xf4\x08q\x13\x86\x0fh\x00\xa0\xfd\x11\x08\x88\r\x8b\x06:\x02\xff\x06\x16\t\xad\x013\xfa\xa2\xf9\xf5\xf6(\xf0\xfe\xf0G\xf9\xc6\xfa\x8d\xf1\xfd\xea\xe2\xee\xe1\xf2\x9b\xee\xbc\xec^\xf3\xf3\xf8\x8c\xf8\xe9\xf8\x81\xfe\xf0\xff\xee\xf9\xa9\xf8,\x02B\x0b\xf8\n)\tq\x0b!\x0e\x80\x0c\xab\x0bk\x0ep\x10q\x0f_\x0f\xa0\x12\x8f\x14\x91\x10\xb7\t1\x07\x1c\to\n\xc5\t\x11\x08\xb3\x06\xbd\x03\xd3\xff\xdf\xfd\xdb\xfdZ\xfcm\xf9\xdd\xf8\xd7\xfa\xc0\xfb\x14\xfa\xab\xf6w\xf5\xd0\xf6F\xf7]\xf8\xf6\xfa\xdc\xfcY\xfd\x84\xfc[\xfd\xf7\xff\xb3\x00\x14\x00\xee\x00c\x04\x00\x07\r\x07\xcf\x06\xbc\x06\xd8\x05=\x04\x19\x04x\x05k\x06;\x05k\x039\x02Y\x01\xd6\xff\x04\xfe\x90\xfd\x10\xfe\x08\xfe\xc0\xfc\xc7\xfcD\xfc[\xfb\x89\xfa&\xfa\xf2\xfbG\xfd\xdc\xfd!\xfe\x82\xfe\xd6\xfe\x9a\xfeo\xff\x14\x02\xfb\x04s\x03*\x02G\x04\x1e\t\x13\x0b\xca\x07\x17\x07B\x07\xe7\x07|\x07K\x08\xd1\x08\x11\x07\xe5\x04 \x04\xb5\x02g\xff\x9f\xfdU\xfc\xa7\xfc\x9e\xfc\xae\xfa]\xf9y\xf7\xe2\xf5\x04\xf5\xa2\xf4\xe9\xf5&\xf6\xec\xf5\x82\xf6\xdc\xf6\xeb\xf6"\xf6\xb4\xf6g\xf8v\xfaA\xfbc\xfc\xef\xfd\xea\xfe\x9f\xff\xce\xff*\x01 \x031\x04\x12\x05\xfc\x05\xaf\x06\xb7\x06\xf4\x05L\x06?\x07\xb0\x06\x00\x06\xef\x05\xef\x05\xf8\x041\x03}\x02\xf5\x01r\x006\xffK\xfe\t\xfeW\xfd\x9d\xfb7\xfa\x99\xf9E\xf9\x81\xf9r\xf9\xe8\xf8j\xf9\xf8\xf9V\xfau\xfa[\xfa\x81\xfa\x9d\xfa\xc9\xfa\xe4\xfb\x02\xfc \xfb\xe8\xfa\xe2\xfa\xd6\xfa8\xf9\x1d\xf8(\xfa\xe1\xfc\xcb\xfcK\xfb@\xfc_\x02\x87\x07\x8f\t\x13\x0e\xba\x15\xfc\x18\x9f\x14\xf9\x14m\x1f6(_&\x9f"\x8d&]*l%9\x1d\xdf\x1a\xa1\x1a\xde\x166\x11\xa7\x0f\xdc\x0c\xd6\x03\xa3\xf9k\xf4\xf0\xf1\x81\xed\x91\xe8Y\xe6T\xe5m\xe3\xfc\xe1\xa8\xe1\xe7\xdf\xf9\xdc\xb7\xdd&\xe3\xe0\xe81\xec\xbe\xedF\xf0^\xf3\xbf\xf5d\xf8\xed\xfa\x97\xfdV\x00\xc2\x03u\x07\xfb\t\xa1\to\x06\t\x04\xfb\x03\xe2\x05u\x06}\x05\x10\x04.\x02\x00\x00\xb1\xfd\xc4\xfb1\xf9n\xf6\xb9\xf5\x9c\xf7\xd9\xf8\x13\xf7\x1c\xf5\xd9\xf4\x1b\xf5a\xf4\xa5\xf4\x92\xf7*\xfam\xfa\x05\xfb\xe1\xfd0\x00\x93\xff\xd8\xfe\xd3\x00\x08\x04n\x05$\x06\xf5\x07$\tR\x08I\x07\xf8\x07j\t\x84\t\xc2\x08\xf2\x08\xe0\t\x8f\t\xcc\x07y\x06\r\x061\x05M\x04-\x04R\x04S\x03O\x01\xfe\xff\x9e\xff\xc8\xfe?\xfd[\xfc]\xfc\x99\xfcn\xfc\x9d\xfb\\\xfbO\xfb\xc9\xfa\xd4\xfa\xf3\xfb\x14\xfd\x80\xfd\xb7\xfdC\xfe,\xff\xe9\xff\n\x00\xa6\x00\xdb\x01\xca\x02\xf4\x02\x12\x03\x99\x03\x96\x03<\x03\x1c\x03$\x03\xf8\x02\xe2\x02\xb3\x029\x02\xb4\x01\x0c\x01i\x00\x15\x00\xc9\xff]\xff!\xff/\xff/\xff\xfd\xfe\x9a\xfe\\\xfep\xfer\xfe\x9e\xfe\x07\xff`\xff\xad\xff\xc6\xff\xaf\xff\xb5\xff\xa2\xff\xaa\xff\x18\x00S\x00d\x00\xa8\x00\x94\x00Z\x00M\x00F\x00\x19\x00\xe5\xff\x0f\x00o\x00\xa6\x00\x84\x00\x14\x00\x01\x00>\x00.\x00,\x00\xc6\x00\xbc\x01Q\x02`\x02\xaa\x02\xe7\x02\xfa\x02Q\x03\xf2\x03m\x04?\x04E\x04\x0e\x05\x1a\x06#\x05\xd5\x02\xde\x02b\x04\x16\x04t\x01q\x00\x06\x02\x0b\x02\x13\xffW\xfd\xa2\xfe\xd6\xfe\x9d\xfb\x88\xf9\x98\xfb\xf9\xfc`\xfa\xb9\xf7\x19\xf9\x1c\xfb\xa8\xf9\x8a\xf7y\xf8\x88\xfa:\xfa\xf7\xf8F\xfa\x97\xfc\xae\xfc\x95\xfb\x8c\xfc\xf8\xfe\xdc\xffe\xff\xae\xffx\x01\xe4\x02\xef\x02\xf8\x02\xad\x03\'\x04\xbc\x03-\x03|\x03\r\x04\x88\x03E\x02\xb2\x01\xef\x01t\x01\xe9\xff\xb6\xfe\x8a\xfe0\xfe/\xfdd\xfc:\xfc\xfe\xfb:\xfb\xbf\xfa\x03\xfbe\xfbZ\xfb$\xfbo\xfb7\xfc\xc2\xfc\xfb\xfcr\xfd\x1a\xfe\xa7\xfe\xc0\xfe\xdf\xfe*\xff)\xff\xbc\xfe{\xfe\xa9\xfe\xcf\xfek\xfe\xce\xfd8\xfd\xe5\xfc\xb4\xfc\xa2\xfc&\xfd\x8d\xfe\xca\x00\xf7\x02f\x04b\x05m\x07\xb0\n\xaf\r\x13\x10\xd6\x12\xc9\x15z\x17\xd0\x17k\x18\xc9\x19Y\x1a6\x19e\x17\x15\x16\x9b\x14\xf5\x11\xb5\x0e\xe1\x0b~\tz\x06\xdd\x02\x89\xff\n\xfd\xb7\xfa\x16\xf8\xcb\xf5W\xf4v\xf3\x10\xf2w\xf0\x9c\xef}\xef\x82\xef\x0f\xef\xd2\xeex\xef?\xf0\xb1\xf04\xf1#\xf2$\xf3\xc9\xf3Z\xf4\x8d\xf5\x01\xf7:\xf8C\xf9\x1a\xfa\x14\xfb\x00\xfc\xa4\xfc2\xfd\xde\xfd\x93\xfe\xfc\xfe\xfc\xfe\xe4\xfe\xf2\xfe\xd1\xfel\xfe\x03\xfe\xcb\xfd\x8b\xfd\x18\xfdk\xfc\x00\xfc\x1a\xfc!\xfc&\xfch\xfc\xc0\xfc\xf5\xfc\xf4\xfc#\xfd\x84\xfd\xfc\xfd\x83\xfe\x0f\xff\xa1\xff\x1d\x00s\x00\xbd\x00\x07\x01F\x01\xb9\x01i\x02\x11\x03\xb0\x03X\x04\xda\x04P\x05\xba\x05Y\x06\x0e\x07\x9c\x07\x07\x08\x95\x08B\tq\t\x1a\t\xe2\x08\xd6\x08V\x08{\x07\xf7\x06\x9a\x06\x85\x05\x01\x04\xe1\x02$\x02\xdd\x00?\xff/\xfe\xa7\xfd\xbb\xfc\x8a\xfb\xfd\xfa\xf8\xfa\x8f\xfa\xdc\xf9\xd7\xf9l\xfa\x81\xfa0\xfa\x93\xfa\x85\xfb\xcd\xfb\xc5\xfb9\xfc\xf4\xfc \xfd\x06\xfdj\xfd\x1d\xfe[\xfe7\xfew\xfe(\xffK\xff\x11\xff]\xff\xef\xff$\x00\x1c\x00}\x00\x13\x01F\x01O\x01\x94\x01\xec\x01\xf5\x01\xde\x01\xeb\x01\r\x02\x11\x02\xd7\x01\xaa\x01\xb4\x01\x8d\x01>\x01\x18\x01\x18\x01\x16\x01\x08\x01\x1e\x01:\x01x\x01\x8a\x01\x84\x01\xb7\x01\xed\x01\xfc\x01\t\x02#\x02*\x02\x1a\x02\x08\x02\x01\x02\xd8\x01\x85\x01\x0e\x01\xb8\x00\x99\x00r\x00\x02\x00{\xff\xa2\xff\x06\x00\xdb\xff\xbb\xff\xf2\xff[\x00\x17\x00\xf1\xff|\x01\xb6\x03\xd7\x03\x84\x02\r\x03\x12\x05t\x05\n\x04\x1a\x04\x7f\x05[\x05c\x03\x8f\x02M\x03\x87\x02\xf0\xff\\\xfe\xe9\xfe\xdc\xfe\x8a\xfc~\xfa\x82\xfa\xb1\xfa\xb5\xf9\xc0\xf8\x01\xf9[\xf9\xde\xf8k\xf8Q\xf9\xc8\xfa=\xfbF\xfb\x1b\xfcs\xfds\xfe\x0b\xffi\xff.\x00\xf7\x00\x83\x01\x00\x02_\x02\xb9\x02\xb0\x02q\x02f\x02h\x02\xf9\x01\x17\x01\x81\x00\x82\x00?\x00S\xff\x81\xfe-\xfe\xbb\xfd\x0c\xfd\x82\xfcZ\xfc\x18\xfc\x96\xfb|\xfb\xe0\xfb<\xfc,\xfc\x13\xfcd\xfc\xe7\xfcA\xfd\x97\xfd"\xfe\x9b\xfe\xfc\xfeW\xff\xda\xffi\x00\xab\x00\xae\x00\xdb\x00Z\x01\xc8\x01\xd7\x01\xd5\x01\x12\x02q\x02v\x02[\x02}\x02\xa0\x02u\x02E\x02g\x02o\x02\x1a\x02\xbc\x01\x7f\x01\x14\x01a\x00\xc9\xffL\xff\xa6\xfe\xde\xfdA\xfd\xde\xfcw\xfc\r\xfc\xcd\xfb\xc8\xfb\xc7\xfb9\xfc^\xfd\x04\xff\xa8\x00R\x02N\x04\x88\x06\xa8\x08\xbd\n2\r\xd2\x0f\xca\x11\x04\x13e\x14\xe4\x15o\x16\xd8\x15b\x156\x157\x14\xc7\x11l\x0f\xc8\r\x85\x0b\xec\x07x\x049\x02\xbd\xff\xfe\xfbx\xf8{\xf6\xec\xf4S\xf2\xd0\xef\xcb\xee{\xee\x82\xed[\xecq\xecD\xedy\xedZ\xed%\xee\xbb\xef\xd8\xf0S\xf1c\xf2\x0b\xf4L\xf5\x18\xf6\x15\xf7\xa3\xf8\xe7\xf9\xb1\xfa\xb1\xfb\x04\xfd\x1d\xfe\xbb\xfeQ\xff\x07\x00\x8d\x00\xf2\x00i\x01\xd1\x01\xd6\x01\xa9\x01\xae\x01\xb0\x01Y\x01\xf0\x00\xa1\x00W\x00\xe1\xffy\xffn\xffE\xff\xd0\xfey\xfe\x8f\xfe\xd0\xfe\xab\xfe\x98\xfe\xf2\xfeX\xff\xc6\xff<\x00\xd7\x00g\x01\xd0\x01K\x02\xf7\x02\x9b\x03\xef\x03E\x04\xc0\x04F\x05\xb0\x05\x08\x06K\x06e\x06v\x06\x99\x06\xd9\x06\xf5\x06\xd4\x06\x96\x06G\x06\xe8\x05t\x05\xe5\x04\x18\x04<\x03~\x02\xbe\x01\xcc\x00\xad\xff\x95\xfe\xa5\xfd\xc3\xfc\xe0\xfb"\xfb\x92\xfa\xf3\xf9G\xf9\xd4\xf8\xb6\xf8\xa2\xf8\x84\xf8\x9c\xf8\xf3\xf8e\xf9\xd3\xf9F\xfa\xeb\xfa\x9e\xfbH\xfc\xfb\xfc\xb9\xfd\x93\xfe{\xffL\x00\x1b\x01\xd5\x01\xab\x02x\x03\x02\x04t\x04\xd7\x04C\x05\x9c\x05\xd9\x05\xfb\x05\xf5\x05\xd4\x05\x82\x05%\x05\xd9\x04s\x04\xe3\x03X\x03\xdc\x02Q\x02\xc5\x01@\x01\xb6\x00*\x00\xb3\xff{\xffZ\xff0\xff\x19\xff\n\xff\xfb\xfe\r\xff)\xffD\xffa\xff\x92\xff\xd4\xff\x0b\x00-\x00M\x00l\x00q\x00v\x00\xa7\x00\xd7\x00\xbf\x00\x95\x00\x90\x00\x91\x00~\x00A\x00\x13\x00\xf8\xff\xc4\xfft\xff\'\xff\x0f\xff\xf6\xfe\xb2\xfe|\xfe\x9a\xfe\xc5\xfe\xb7\xfe\xd2\xfe%\xff\x95\xff\xba\xff\xda\xff\x9b\x00\xa5\x01\x03\x02\xee\x01t\x02A\x03p\x03\r\x03"\x03\xa4\x03\x9b\x03\x05\x03\xa1\x02\x85\x02\r\x02\x19\x01\\\x009\x00\xe7\xff\xf5\xfe\x05\xfe\x9e\xfdW\xfd\xa6\xfc\xf4\xfb\x99\xfbg\xfb-\xfb\xd8\xfa\xd2\xfa\xf3\xfa\xe2\xfa\xc4\xfa\xdd\xfa.\xfb{\xfb\xa8\xfb\xc9\xfb\x1d\xfc\x8a\xfc\xdc\xfc\x1b\xfd>\xfdy\xfd\xd4\xfd0\xfe~\xfe\xd8\xfe\x1d\xffI\xffu\xff\xc3\xff\x1d\x00T\x00\x8b\x00\xba\x00\x05\x01x\x01\xdf\x01 \x02V\x02\x99\x02\xeb\x02;\x03}\x03\xe1\x03\x16\x04\x1c\x04B\x04q\x04\x88\x04W\x04\x13\x04\xe7\x03\xca\x03\xaa\x03V\x03\xeb\x02s\x02\x13\x02\xbe\x01o\x01\x1d\x01\xb8\x00`\x00\x1d\x00\xf0\xff\xcf\xff\xc5\xff\xa3\xff\x8a\xff\xa1\xff\xc7\xff\xd5\xff\xdf\xff\x00\x00,\x00^\x00\x87\x00\xae\x00\xc8\x00\xca\x00\xcd\x00\xd5\x00\xe1\x00\xdc\x00\xbb\x00\x9d\x00v\x00`\x00:\x00\xf9\xff\xb9\xff}\xff;\xff\t\xff\xd4\xfe\x9c\xfex\xfeC\xfe\x12\xfe\xee\xfd\xd5\xfd\xce\xfd\xbd\xfd\xc3\xfd\xc8\xfd\xd1\xfd\xdd\xfd\x01\xfe8\xfek\xfe\xa7\xfe\xdd\xfe$\xff^\xff\x97\xff\xd4\xff\x08\x00S\x00\x8b\x00\xa0\x00\xab\x00\xa1\x00\x96\x00\x81\x00Z\x005\x00\xf4\xff\x91\xff!\xff\xca\xfey\xfe+\xfe\xe4\xfd\xb5\xfd\x8a\xfda\xfd3\xfd2\xfdo\xfd\xd4\xfd\x1f\xfeD\xfe\x89\xfe\xe2\xfe2\xff^\xff\xc3\xff6\x00p\x00\x95\x00\xbc\x00\xea\x00\x01\x01\xfe\x00\xef\x00\xff\x00\x00\x01\xf3\x00\xcb\x00\xbb\x00\xbe\x00\xa9\x00\xa0\x00\x91\x00\x90\x00\x93\x00\x98\x00\x97\x00\xa6\x00\xad\x00\xb7\x00\xc1\x00\xc1\x00\xcc\x00\xc6\x00\xb7\x00\xbc\x00\xba\x00\xa4\x00\x83\x00]\x00E\x00$\x00\xfb\xff\xd6\xff\xc1\xff\x92\xffN\xff\x14\xff\xfd\xfe\xec\xfe\xc1\xfe\xb1\xfe\xb3\xfe\xb3\xfe\xa1\xfe\x97\xfe\xb2\xfe\xd4\xfe\xe6\xfe\xfc\xfe4\xffh\xff\x8c\xff\xbd\xff\xfe\xff2\x00=\x00`\x00\x8f\x00\xba\x00\xe5\x00\xfd\x00&\x01=\x01H\x01[\x01c\x01n\x01\x85\x01\x90\x01\xa4\x01\xb7\x01\xbc\x01\xb3\x01\x84\x01~\x01\xa2\x01\xae\x01\xaf\x01\xbe\x01\xdf\x01\xec\x01\xeb\x01\xf7\x01\n\x02"\x02;\x02Q\x02c\x02s\x02t\x02w\x02q\x02]\x02<\x024\x02/\x02\x13\x02\xfc\x01\xb9\x01X\x01\xef\x00\xa5\x00^\x00 \x00\xe7\xff\x98\xffL\xff\xf0\xfe\x97\xfen\xfeG\xfe\x01\xfe\xf0\xfd\xf7\xfd\xdc\xfd\xcc\xfd\xb5\xfd\xa6\xfd\xb5\xfd\x9e\xfd\x96\xfd\xb8\xfd\xb8\xfd\xa5\xfd\x94\xfd\x92\xfd\x98\xfd\x91\xfd}\xfd\x80\xfd\x85\xfdx\xfdh\xfdO\xfdQ\xfdQ\xfd?\xfdN\xfd`\xfdv\xfd\x90\xfd\x93\xfd\xa5\xfd\xc2\xfd\xe3\xfd\xec\xfd\x05\xfe>\xfe]\xfet\xfe\x95\xfe\xdc\xfe\x15\xff\x15\xffB\xff\x92\xff\xc3\xff\xee\xff!\x00d\x00\x94\x00\xc2\x00\x02\x01M\x01z\x01\x99\x01\xca\x01\xf9\x01!\x029\x02H\x02]\x02f\x02i\x02a\x02V\x02D\x025\x02%\x02\x0e\x02\x02\x02\xf0\x01\xe1\x01\xc8\x01\xa8\x01\x8e\x01\x89\x01z\x01j\x01i\x01W\x019\x01\x1f\x01\x05\x01\xf5\x00\xe8\x00\xcd\x00\xc5\x00\xb4\x00\x8e\x00n\x00E\x00\x19\x00\x00\x00\xdc\xff\xaf\xff\x9a\xff~\xffG\xff:\xff(\xff\xf9\xfe\xe2\xfe\xcd\xfe\xbc\xfe\x9c\xfe\x8e\xfe\x8f\xfe}\xfeW\xfeD\xfe]\xfeT\xfeU\xfec\xfe]\xfea\xfeo\xfe|\xfe\x83\xfe\x9c\xfe\xab\xfe\xb8\xfe\xe2\xfe\xf5\xfe\x12\xff.\xff/\xffK\xffx\xff\x96\xff\x9a\xff\xb4\xff\xda\xff\xed\xff\xfa\xff\xee\xff\xfc\xff\x1b\x00\x10\x00\x13\x00/\x00@\x00B\x00I\x00m\x00{\x00\x96\x00\xb4\x00\xd0\x00\xf3\x00\x1a\x018\x01h\x01\x99\x01\xbe\x01\xd0\x01\xe4\x01\x02\x02\x1e\x02$\x02\x07\x02\xfe\x01\xf3\x01\xcd\x01\x9c\x01n\x01F\x01\r\x01\xba\x00w\x00A\x00\x01\x00\xab\xffU\xff)\xff\xf0\xfe\xbf\xfe\x97\xfel\xfeM\xfe+\xfe\x1c\xfe\x15\xfe\x15\xfe\x14\xfe!\xfeS\xfev\xfe\x92\xfe\xb5\xfe\xe7\xfe\x12\xffD\xff|\xff\xb2\xff\xe7\xff\x0c\x000\x00`\x00~\x00\x89\x00\xa1\x00\xb2\x00\xc7\x00\xcb\x00\xc7\x00\xce\x00\xcd\x00\xc3\x00\xb0\x00\xae\x00\xa1\x00~\x00x\x00a\x00K\x00C\x00,\x008\x007\x00!\x00$\x009\x008\x000\x004\x00=\x00Q\x00M\x00A\x00N\x00S\x007\x00-\x006\x00?\x009\x00 \x00\x1e\x00\x17\x00\x08\x00\xfb\xff\xe3\xff\xce\xff\xb5\xff\x9b\xff\x93\xff\x8e\xff\x8a\xffz\xffe\xffW\xff^\xffa\xffa\xffe\xffs\xff\x86\xff\x88\xff\x89\xff\x92\xff\xa5\xff\xbc\xff\xcc\xff\xe2\xff\x07\x00\x10\x00\x1c\x004\x00H\x00W\x00n\x00\x8b\x00\x99\x00\xa2\x00\x9b\x00\x96\x00\x8d\x00\x88\x00\x7f\x00z\x00\x7f\x00v\x00j\x00g\x00[\x00J\x00H\x00B\x00A\x00?\x00<\x000\x00/\x00(\x00\x1c\x00\x13\x00\x06\x00\xfd\xff\xf0\xff\xf1\xff\xdd\xff\xbd\xff\xb2\xff\xab\xff\xa1\xff\x86\xffz\xff}\xffr\xffc\xffe\xffm\xfff\xff]\xffW\xfft\xff\x7f\xff\x80\xff\x86\xff\x8e\xff\x99\xff\xa2\xff\xa5\xff\xa3\xff\xab\xff\xae\xff\xb2\xff\xb3\xff\xb5\xff\xaf\xff\xaf\xff\xb1\xff\xa6\xff\xa3\xff\xb0\xff\xba\xff\xb5\xff\xbc\xff\xb8\xff\xb8\xff\xb7\xff\xc2\xff\xc6\xff\xcb\xff\xd4\xff\xda\xff\xeb\xff\xf3\xff\xff\xff\x08\x00\x14\x00\x17\x00\x1f\x00:\x00B\x00A\x00\\\x00g\x00o\x00\x7f\x00\x85\x00\x83\x00\x8f\x00\xa2\x00\x9b\x00\x91\x00\x91\x00\x8f\x00\x8b\x00\x90\x00\x98\x00\x8e\x00y\x00v\x00m\x00k\x00m\x00X\x00D\x00@\x00;\x00#\x00\x0c\x00\xf4\xff\xe1\xff\xdf\xff\xce\xff\xc0\xff\xab\xff\x96\xff\x85\xff{\xffj\xffh\xffl\xff\\\xffR\xffR\xffN\xffF\xff:\xff9\xff1\xff4\xff@\xffF\xffH\xffG\xffK\xffV\xffl\xff{\xff\x89\xff\x9d\xff\xb3\xff\xc9\xff\xd4\xff\xec\xff\x01\x00\x12\x00&\x00<\x00P\x00e\x00v\x00\x84\x00\x95\x00\x9d\x00\xa7\x00\xb2\x00\xbc\x00\xbc\x00\xb6\x00\xba\x00\xbc\x00\xb2\x00\xa8\x00\xa7\x00\xa5\x00\xa2\x00\x9e\x00\x9b\x00\x8e\x00{\x00t\x00n\x00j\x00[\x00S\x00I\x00C\x009\x00,\x00\'\x00\x1d\x00\x12\x00\x0e\x00\x04\x00\x07\x00\r\x00\x00\x00\xeb\xff\xe1\xff\xd6\xff\xd1\xff\xc4\xff\xb2\xff\xa3\xff\x8a\xff\x8a\xff\x8d\xff\x82\xff}\xff~\xffv\xffr\xff\x83\xffy\xffs\xff~\xffv\xffz\xff\x82\xff}\xffr\xff\x80\xff\x90\xff\x9b\xff\xa6\xff\xb1\xff\xbb\xff\xc8\xff\xce\xff\xd5\xff\xe1\xff\xf6\xff\x0c\x00\x15\x00\x14\x00\x1f\x008\x00G\x00W\x00[\x00b\x00t\x00\x84\x00\x7f\x00\x84\x00\x89\x00\x91\x00\x90\x00\x90\x00\x93\x00\x8d\x00\x90\x00\x81\x00{\x00d\x00c\x00\\\x00B\x00A\x00/\x00\x1c\x00\x1d\x00\t\x00\xf2\xff\xee\xff\xdc\xff\xcf\xff\xc8\xff\xb3\xff\xa5\xff\xb4\xff\xb3\xff\xb4\xff\x9c\xff\x8a\xff\xa4\xff\xa2\xff\x99\xff\xae\xff\x9b\xff\x93\xff\xab\xff\xc8\xff\xae\xff\x93\xff\xb0\xff\xe2\xff\xde\xff\xb4\xff\xb8\xff\x91\xff\x9d\xff\xb9\xff\xb1\xff\xc4\xff\xc0\xff\xad\xffa\xffC\xff\x84\xffn\xffP\xff\x92\xff\xbc\xff\xb5\xff\x9a\xff\x97\xff\xe4\xff\xc0\xff\x86\xff\xa5\xff\xae\xff\xfc\xff\x1d\x00v\x00\x04\x01\xf5\x00\x81\x01\x1e\x01\xef\x01>\x01\xa7\x01\xdd\x00\x00\xfd\xdc\x08\xea\x10G\x04\xce\xf3.\xfe\x19\x05\x10\x04\xaa\x02A\xff\xb1\xfa/\xf7\xce\x00\xc7\xf9[\xfc\xbd\xf9\xa5\xf40\xfe\xd9\x00\xc7\xfe5\xfe)\xfdy\xfb \xfb\xec\x05-\x0b\xb7\xfb\xba\xfeC\x04\xcf\x01\xe2\x03m\x03v\x03\xee\xffM\xfa\xe1\x00\x98\tR\x03`\xfad\x01\xae\x01\xfe\xf8\xb1\x00\x96\x04h\xff\xd1\xfb\xa5\xfb\xaa\x011\x00\xa1\xfc\xf5\x01+\xfeH\xf33\x02]\x05`\x00}\xfa\x9e\xfd\xee\x01\xc6\x00\xae\xfe@\xfb\x18\x07\x1b\x04a\xfa\x19\x02s\x06\xab\xfe}\x02\xd5\x02-\xff4\x02c\xfdu\x06~\x08\xcc\xfe\xf8\xf5\x18\x07a\x07\xc9\xfey\xff\x01\xff:\x01K\xff\xb0\x04\x9e\xfft\x03\x9a\xf9\xe8\xfb\xce\x01\xc3\xff\xdf\x02\xda\xf6 \x03\xcb\x00\x9a\xf6\x96\xfeS\x00\xd5\xff2\xf7&\xfei\x04\x9d\xf9\x03\xfd\xec\x06O\xfei\xf4\xf3\x02=\x06\x90\x01\xad\xfe\x18\x02\xe4\x00\xa4\x01L\xffp\x044\x05\x19\x00\xbb\xff\xa3\xfe2\x04\xec\xff\xdf\x04{\x04c\xfa\xbc\xfd\xc0\x0bG\xf9\xaf\xf9\x8a\x06\xf5\x01n\xfc\x0c\xfap\x068\xfd\xc1\xfa\\\xfcL\x05-\x01]\xf8\xd5\xfd\x99\x039\xf9=\x00\x8b\n\xde\xfbz\xf7A\x04\xd1\np\xf2Z\xff\x9e\x10x\xfb\xb7\xf3\x02\t\xe9\t\x08\xef\xfd\x01\xad\r\xf1\xfa)\xf5\xbc\xffa\x0b\xa8\xfci\xfc\x98\x05O\xfd\x7f\xfa>\x00\x13\t-\x00\xc4\xfa|\xfc\x9a\x05\xd3\x01\x15\xfa\r\x05L\xfe\xce\xf4L\x01\xda\x05\xf3\xfb\x91\x02\x1e\x01\xc5\xefS\x03\x18\x0b\xfe\xf6C\xff\x11\x06\xad\xff\x91\xfe\xfe\xff\x05\x04\xb6\x04\xa1\xf9\x03\xfe\xdf\x05\x99\xfb\x02\x03\xb4\x07u\xf6h\xffd\x05\xe1\xfc\xa1\xfd\x87\xfe\xd1\x01\x94\xff\xe6\x00N\xf8\x1d\x04\xb8\xfeL\xfa\xba\x04\xbb\xfd\x85\xf5\x8b\x07\xc0\x08\xe2\xf5\'\x07\x88\xffD\x01\x1f\x00\xb8\x04\xa8\x05\xca\xf8E\x01z\t\xcf\x00L\xfc\xc2\x02\x06\x01\xef\xf8\xb2\xfbH\x06\xc7\x06%\xf8\x86\xf3A\x0e\x02\xfe\'\xf3\xa5\x00\x14\n\x8a\xfeJ\xf0$\x07\x12\x07\xaa\xfc\x04\xfbf\x01U\x04\xac\xff\x9b\x02\x04\x06J\x01~\xfc\x9c\x00\x1a\x06)\x05\x9a\x05 \x04\x14\xf6\x1e\xfe1\x06O\x03\x9f\x03\x05\xf9,\xfc\x8f\x04P\xfb\xc4\xf8N\x03\xe1\x03o\xf7A\xf9{\xff\xf1\xfe\xcf\x05f\xf9\xe4\xfa#\x04\x82\xfcG\x009\x02\xb3\xfe\xe4\xff\x83\x02\x9e\xfb\x0c\xf9\x97\x05\x87\x04\x02\xfd:\x01\x98\xfa\x7f\xfa\xe2\x07\xab\x05[\xf4[\xfc\xe0\x0br\xfc\xc9\xfc\xdd\t\x16\x00\x93\xf3!\xfa:\x11\xcb\x06\xf9\xf24\x01\xb7\x07:\xf5\xfc\x00\xe4\x10\xf6\xfau\xe8\xdb\x00\xab\x16`\xfd\xea\xf9v\x06\xc3\xfdw\xea\xa8\x02\xff\x13\x10\x03v\xf7\x13\xfaq\x03\xcc\xff\x83\x02J\x06\xa2\xfd\x87\xf3#\xfc\x02\x0c#\x06\xb5\xfc\x86\xfd\xba\xfa\xc3\xf8\x13\xfe`\x0c\xe3\x02\x17\xf5\xb9\x00\x85\x05\x0c\xfeP\xff\x81\x08\xee\x01\xfe\xef8\xfd\xc3\x0b\xd3\x06\xdd\x03\xa1\xfb\xdc\xf7\xbf\xffM\x06\xd0\xfc\xf8\xff\xa0\x04\xaa\xfe\xa2\xfe\x98\xfcG\x01r\x05\xea\xfc\xc3\xf7\xe9\xfc\xd2\x04a\x06x\xfe\xec\xf6c\x01\x07\x03;\xff@\x000\x01\xc3\xfe:\xff\xa0\x05\xb4\x00\xe0\xfe\x1d\x02\xf3\x00\x97\xfd\xcd\xffO\x06\xcb\x02p\xfd\x01\xffS\x02\xbe\x01\x9c\xfd\x81\xfd\xf2\x03C\xff\xcf\xfb^\x01L\x03\xb7\x00e\xfb2\xfe;\x03K\xff\xb7\xf8`\x03\x1e\x04\x04\xfc\xe1\xffO\x01\xcd\xfew\xfb\xcc\x00\xc4\x04\xef\x00\xe6\xfa\x14\x01\x82\x04\xc8\xff\x94\x00\xa1\x00\xbd\xff\xb4\xfaU\x011\x084\x02t\xfc%\xfci\xfcZ\xfe\xf5\x06c\x06q\xf8[\xf6X\x02\x89\x04\xd4\xfeM\xff;\x05\xb6\xfe3\xf8\xbb\x00\xea\x04\xa5\x04U\xff\x98\xfc`\xfa\xf4\x02\x18\n\x92\x01\xe6\xf6\x9e\xfa\x9e\x022\x01A\x02\xd5\x01\xe9\x02D\xfbi\xfbb\x01\xae\x03V\x00!\xfd\x87\x00^\x03\xa2\x00\x07\xfc\xf3\x00\xcd\x00\xd2\xfeO\x00\xca\x02\xef\xfd\x02\xfc\x14\x02\xe9\x05S\x01\x17\xfa\xaf\xfc\xf2\x00j\xff\x0e\x03\x15\x06[\xfe\xaf\xf5z\xfdu\x03Y\x06\t\x01\x14\xf6\x9a\x00\x05\x05/\x04\xbb\xfd\xd0\xfcY\xfer\xfeh\x03\x9a\x03\x98\x00\x8f\xfb\xbe\x00x\x03\x1c\xff\xf8\xffc\x00:\x00\xd4\xfa\xa3\xfd_\t\x14\x08\xb5\xfc?\xf2$\xfb\x19\x08b\x04\xd0\x00T\xfb\xe9\xfa\xa5\xfe\xf6\x03\xd1\x05M\xfc\xb9\xf8N\xfe\x08\x04V\x03.\x03\xf8\x01t\xfa\x8d\xf82\x00\x96\x06\xd9\x04y\x00\x08\xfc\x97\xfb\x99\x02e\x06u\xff\xc0\xfbG\xfb\x14\xfe\xa3\x06M\x06\xcc\xffm\xfc\xf0\xfb\xbe\xfe\x14\x01\xaf\x00&\x03\xcf\x02\xeb\xf9%\xfa2\x05B\x06\xc4\xff\x99\xfa\x9f\xf7,\xff\x9a\x06\xd1\x04e\x01B\xfb\xb7\xfc\xb7\xfd\x1e\x03\xab\x04\xce\x02\x86\xf9+\xf8\x84\x04\x0c\x08`\x05s\xfb\xd6\xf8\xc5\xfe~\x01\x1a\x02e\x05\xd3\xfe@\xf9)\x00a\x06\x1f\x00\xf8\xfe\x83\xfe\xeb\xf7f\xff,\t\xb8\x05{\xfe\xa5\xfa\x16\xfb~\xff\xc5\x02\xdf\x03\n\x01p\xfe\xbc\xfa\xf9\xff)\x05D\x02\xcd\x00H\xfb\x1c\xfa\xaa\x01\xf1\x05\xe2\x00s\xfd\xfb\xfd\t\x01&\x01m\xff\xbf\xfdK\xff\xf1\x00\xe7\x01\x97\x02S\xfc\n\xff4\xffK\xfd%\x02\x92\x04\xac\x01\xff\xfa!\xfc\x8d\x01\x95\x02\xef\xff\x14\xfe\x83\xfeS\x00:\x026\x03%\x01\x18\xfb\xb8\xf8\x80\x00C\x07~\x05>\xfel\xfb\xf7\xff\x0c\x02\x84\x01\x98\x00}\xff\x82\xfd\x15\x00\xed\x01L\x03\xca\x01\x07\xfem\xfc5\xffW\xff\xca\xffI\x04d\x02\x89\xfc]\xfa\xda\x03\x9d\x04\x8f\xfcD\xff\x06\x00\x86\xfd\xa8\x03\x9f\x03q\xfe1\xfd_\xff;\x010\x04\xa8\x01\x87\xfe^\xfe\x0c\xfd\xec\x00I\x03\xa8\x02\xea\x01\xc7\xfd\xe2\xfa\xda\xfe \x04\xfb\x02\x04\xfeh\xfa\xfd\xfcZ\x00\xb2\x00\xf6\x01|\x02n\xfd\xf3\xf8n\xfe\xf0\x02\x9e\x04\x07\x04\xc9\xfc<\xfa`\x03\x0c\x05\x99\x00\xa7\xff\xd6\xfd[\x00s\x01\xd0\x00\x16\xffO\x00d\xffN\xfc\x93\xffw\x02O\x01\xa5\xff\xac\xfc\xae\xfeS\x04W\x00V\xfa\x82\xfdp\x00\t\x01X\x02\x93\x01\x00\xfd\x86\xfcT\x02`\x02\xdb\xfd>\x00\x8f\x02\xdc\xfeb\x00\xa2\x03I\x01\xe9\xfc\xcf\xfc\x02\x02)\x02\xe1\xfed\x00.\x02\x7f\x00\x05\xfd\xeb\xff<\xff\x7f\xfe\x9c\x03h\x00I\xfc@\xfd\x9b\x02\xa8\x04\xe9\x01-\xfd\xb5\xfb\xfa\xfc\xc3\x02\xf8\x03,\x02:\x02m\xff\x1d\xfd\x84\xfd\x88\x02@\x02\xe2\x00K\xfeb\xfe\x07\x01\xc3\x02\xe3\x01\xb1\xfd\xb3\xfc\t\xfdF\xff\xf6\xff\xb3\x00c\x03\x19\x02\xe1\xfc\xa5\xfaS\xfe\x15\x01s\x01:\x02h\xfe\xa4\xfc\x16\x01B\x03~\x00\xb4\xfey\xfd\x8f\xfc/\x01\xd7\x03\x14\x02\xb3\xfd~\xfe\x81\x000\x01\x05\x01R\xffv\xff\xba\xfd\x15\x01\x92\x03\xa2\x02\xf5\x00\n\xfe>\xfc\xe3\xfd\x19\x03E\x04\xa3\x00\x18\xfd\x06\xfe\x91\xff_\x01\x1e\x03\x9f\xfe\xb1\xfb\xc2\xfdU\x01\x81\x02@\x01\xb3\xff\xab\xfd)\xfe\xa7\xff\xca\x00w\x00~\xff\xc0\xffm\x00\xc6\xff\xb3\x00\x00\x03\xd5\xfe\x83\xfc\xa8\xfeN\x00\xfa\x00\xd0\x02\xa4\x024\xfd\x1f\xfe\xb9\x00\x9c\xffb\x00n\x00y\xff`\xff\x8a\x00\x88\x01\x06\x00\t\xff\xfa\xff\x81\xfe\x19\xfe\xf6\xff0\x01$\x01\xc0\xff\x87\xfe\xc7\xff,\x00\x11\x00\xc1\xff\x1c\xff`\xff\xca\x00*\x00>\xff\xc0\x01)\x01U\xfek\xfej\xff\xd2\x00\x9c\x02\xf3\x01j\xff\xd8\xfd\xcf\xfe\xb6\x01\xb8\x01\x94\xff\x9a\xff\xfa\xfe\x99\xff\x95\x01\xa1\x01S\x00\x87\xfe=\xfe\xe9\xfes\x00\xd7\x00\xd6\x01{\x00\xae\xfc\x85\xff\x83\x01\xad\x00!\x00\xdf\xfdU\xfe\xac\x00\xa5\x02\xb4\x01]\xffl\xfe7\xfe\xcd\xff\xeb\x00h\x00\x0b\x00\xbc\xff_\x00H\x01\x8c\x00\xdb\xff\x10\xff\x04\xfe\xe6\xffq\x01\xdf\x00\xfe\x00\x12\x01\xae\x00C\xff\xe8\xfd\x1d\xfe\xa2\xffc\x01\xad\x01\x02\x01\xd4\xff>\xff\xda\xff\xae\xff{\xff\xdb\xff#\x00\xd0\x00%\x01\x17\x00/\x01(\x02E\x00\xa6\xfd\xff\xfc\xa9\xff\xdd\x01\n\x02\x8d\x00\xb0\xfe\xe6\xfe\x0f\x00\xb1\x00#\x00\x1f\xff\x88\xfe\x19\xff\xde\x00\x90\x01\x90\x01\x95\x00\x83\xfe\xa6\xfd\x83\xfe\xbe\x00\xbf\x01\x16\x01F\x00W\xff\x8e\xff\x7f\x00T\x01\xd0\x00Q\xff\xf3\xfe\xb0\xff`\x01J\x01\xed\x00\x9e\xff\x7f\xfd\x03\xfe\x14\x00\xff\x00\x87\x00\xe7\xffY\xff\xe4\xff\xca\x00\x89\x00\x91\xff\xf3\xfe\xc5\xfe\x8a\x00\x93\x01\xaf\x00\x85\x00\x91\x00\xed\xffj\xff\xb1\xff\x93\x00c\x00\xdb\xff_\x00R\x01\x06\x01u\xff\x11\xff\'\xff\xa3\xff\xb0\xff\\\xffq\xffr\xff/\x00\xdc\x00\xd7\xff\xd2\xfe\x80\xfe\xb7\xfel\x00\xab\x01\x10\x01\x10\x00|\xffT\xff\xbc\xff>\x00\xc4\x00\xc4\x00Q\x00\x82\xff\x85\xff^\x00\xb6\x00\x1b\x00%\xff\x0b\xff\xaa\xff\x97\x004\x01\x8e\x00R\xff\xe1\xfeS\xff\xe3\xff+\x00\xff\xff\x02\x00\xe6\xff\xd5\xff\x91\xff8\xffx\xff\xf6\xffE\x00\x08\x00\xed\xff\x83\xff\xaf\xff\xb0\x00\xef\x00\xaf\xff\xcd\xfe\x98\xffs\x00\xc0\x00\x1c\x01\xa0\x00\xf7\xfe\xb4\xfe\xd1\xff\x03\x008\x00\xb7\x00\x89\xff&\xfe^\xff@\x01m\x01O\x00\xa9\xfe\xfe\xfd>\xff\xff\x00}\x01\xa0\x00L\xff\x8e\xfeD\xff8\x00\xe5\x00\xd1\x00C\xff\xfc\xfe\x00\x00\x1b\x01\x10\x01\xa3\xff\xc7\xfee\xff\x89\x00\x03\x01u\x00\xa7\xff\x99\xff\xd9\xff3\x00,\x00\xc4\xffM\xff\x83\xff5\x00h\x00\x04\x00\xc6\xff\x92\xff`\xff\x8d\xffz\xffg\xff\xfa\xff\x82\x00{\x00\x1b\x00\xbc\xff\x93\xffs\xff\xbd\xff6\x00\x9f\x00\x8b\x00\xe1\xff\xf8\xff\x85\x00\xc1\x00,\x00*\xff\xef\xfe\x14\x00>\x01\x19\x01N\x00\xa9\xff\x7f\xfff\xff*\x00\xab\x00\x00\x00\xb1\xff\xe5\xff?\x00\xcc\x00\xcd\x00z\xff~\xfe;\xff\x91\x00\x00\x01\xc3\x00A\x00\x86\xff\xe2\xfe\x91\xff\xea\x00\xb3\x00\xfa\xff{\xff\xa1\xffM\x00\xe5\x00\xda\x00\xf2\xff\x01\xff\xcb\xfe\xc8\xff\xdb\x00\xd2\x00\\\x00\xd4\xffC\xffm\xff\xf6\xff\xf4\xff\xc7\xff\x9b\xff\xaf\xff<\x00\x9a\x00d\x00\xd9\xffT\xffQ\xff\x0e\x00\xea\x00\xc3\x001\x00V\x00\x7f\x009\x00F\x00\x11\x00}\xff\x8c\xff\x95\x006\x01\xd1\x00\x04\x00\x1e\xff\xae\xfeV\xff\x8e\x00\xfc\x00\x8a\x00\x16\x00_\xffr\xff4\x00o\x00<\x00z\xffE\xff\x17\x00/\x01$\x01F\x00h\xff\x15\xff\xb2\xff\xcf\x00M\x01\xf6\x00q\x00\xf6\xff\xcc\xff0\x00k\x00\xb8\xffM\xff\x97\xff!\x00\x97\x00o\x00\xbf\xff\x0c\xff\xef\xfea\xff\xd8\xff\x15\x00\x00\x00\xc5\xff\xba\xff\xcb\xff\xed\xff\xf8\xff\xbf\xff\x94\xff\xa4\xff\xf9\xffT\x00m\x00-\x00\xa7\xff\x8b\xff\xda\xff?\x00\xbc\x00\xc5\x00b\x00\xf4\xff\xec\xff\xf4\xff\xde\xff\xe1\xff\xe9\xff\xfa\xff\n\x00(\x00.\x00\xd2\xffS\xffx\xff\xe8\xff\x0c\x00\x10\x003\x002\x005\x00U\x00\xff\xff\xc0\xff\xdb\xff\x07\x008\x00j\x00s\x00c\x00L\x00\x18\x00\xae\xff\x96\xff\xf4\xff\x05\x001\x00\x8a\x00W\x00\xbf\xff\x88\xff\xbf\xff\xd5\xff\xfe\xff\x03\x00\xc2\xff\xc6\xff9\x00\xa5\x007\x00_\xff\xf6\xfe`\xff$\x00Y\x00\xf0\xff\xae\xff\x9e\xff\xb7\xff\xee\xff\xdf\xff\x89\xffG\xff\x93\xff\xe9\xffJ\x00g\x00\xf8\xff\x95\xff\xa6\xff\xe5\xff\x18\x00.\x00\x0b\x00\xb7\xffx\xff\xec\xffb\x009\x00\x94\xffR\xff\xa0\xff\xcf\xff\x1e\x00U\x00\xf4\xffp\xff\x92\xff\x00\x008\x001\x00\xf5\xff\x8f\xff\x86\xff\xe2\xff:\x005\x00\xe4\xff\x9f\xff\xa1\xff\xeb\xffV\x00K\x00$\x00!\x00D\x00t\x00g\x00\x1e\x00\xf4\xff\x0b\x00F\x00e\x00<\x00\xdd\xff\xb0\xff\xca\xff\xf5\xff\xff\xff\xca\xff\x9b\xff\xa1\xff\xe5\xff:\x00N\x00\xec\xfft\xffs\xff\xd3\xffA\x00;\x00\xee\xff\x98\xffw\xff\xde\xff]\x00$\x00~\xffW\xff\xaa\xff!\x00r\x002\x00\xb3\xffi\xff\xb0\xff3\x00_\x00 \x00\xd8\xff\xc3\xff\xd8\xff*\x00~\x00I\x00\xb5\xff\\\xff\x82\xff\xfc\xffi\x00w\x00\x0c\x00\x8c\xff\x84\xff\xf2\xff|\x00\x8b\x00.\x00\xe0\xff\xfd\xff\\\x00\xa5\x00\x93\x00\'\x00\xd1\xff\xce\xff\x12\x00Y\x00K\x00\x0e\x00\xe4\xff\xd0\xff\xf9\xff/\x00 \x00\xde\xff\xc2\xff\x03\x005\x00?\x00-\x00\r\x00\xee\xff\xf0\xff*\x00S\x00C\x00\r\x00\xf2\xff\x13\x00T\x00}\x00M\x00\xc4\xff\x9c\xff\x05\x00r\x00f\x00$\x00\xf5\xff\xc8\xff\xf2\xffH\x00M\x00\xf0\xff\xb7\xff\xda\xff\t\x00E\x00y\x008\x00\xb9\xff\x9a\xff\xef\xffF\x00M\x00\t\x00\xd4\xff\xdc\xff&\x00]\x00?\x00\xf0\xff\xdb\xff+\x00S\x00M\x00A\x00 \x00\xee\xff\xff\xffA\x00:\x00\xf2\xff\xc0\xff\xcd\xff\xf5\xff\x1b\x00\x1e\x00\xfc\xff\xcf\xff\xd3\xff\x01\x00\n\x00\xdb\xff\xd5\xff\xef\xff\x00\x00\x07\x00\xfc\xff\xe6\xff\xc3\xff\xd5\xff\xe6\xff\xfb\xff\x06\x00\xf1\xff\xd3\xff\xf0\xffG\x002\x00\xf8\xff\xd4\xff\xe7\xff\x15\x00F\x008\x00\xf6\xff\xbd\xff\xcb\xff\x15\x001\x00\x08\x00\xb2\xff\x8d\xff\xb7\xff\x07\x002\x00\x08\x00\xbe\xff\x9a\xff\xb5\xff\xfa\xffD\x00%\x00\xd8\xff\xb9\xff\xda\xff:\x00{\x008\x00\xbd\xff\xa6\xff\xdf\xff8\x00h\x004\x00\xaa\xff\x8c\xff\xf6\xff3\x003\x00\x04\x00\xc7\xff\xa4\xff\xea\xffB\x000\x00\xeb\xff\xb5\xff\xa8\xff\xce\xff\x12\x004\x00\x14\x00\xd9\xff\xbe\xff\xda\xff\x04\x00\x0f\x00\x06\x00\xc5\xff\xb4\xff\xf6\xff0\x008\x00\x0f\x00\xde\xff\xa9\xff\xc2\xff\xf0\xff\x00\x00\xf2\xff\xd2\xff\xbe\xff\xb2\xff\xd6\xff\xf7\xff\xfa\xff\xd8\xff\xc6\xff\xde\xff\x08\x00"\x00\x00\x00\xdb\xff\xcd\xff\xe9\xff\xf9\xff\x1b\x00%\x00\x04\x00\xf9\xff\x06\x00\x0c\x00\xff\xff\xf4\xff\xf4\xff\xed\xff\xe5\xff\xf1\xff\xfc\xff\xe6\xff\xc2\xff\xaf\xff\xb6\xff\xc6\xff\xdc\xff\xe2\xff\xd7\xff\xd3\xff\xd3\xff\xd3\xff\xd3\xff\xdf\xff\xe4\xff\xd4\xff\xea\xff\x03\x00\x07\x00\t\x00\x0b\x00\xf5\xff\xe1\xff\xee\xff\x16\x00*\x00\x1a\x00\x0b\x00\x04\x00\x07\x00\x0c\x00\n\x00\x02\x00\xfe\xff\xf8\xff\xf4\xff\x02\x00\x12\x00\t\x00\x03\x00\x02\x00\xf7\xff\xf4\xff\xfe\xff!\x00\x1e\x00\x05\x00\x0f\x00\x11\x00\x01\x00\xfc\xff\x06\x00\x01\x00\x01\x00\x06\x00\x03\x00\x05\x00\x07\x00\x00\x00\xfe\xff\xfc\xff\x00\x00\x10\x00\x0e\x00\t\x00\n\x00\x14\x00\x19\x00\x1a\x00\x11\x00\x00\x00\xfe\xff\x08\x00\x17\x00\x15\x00\x02\x00\xf2\xff\xfa\xff\x0f\x00\x1c\x00\x04\x00\xfc\xff\x11\x00\x1d\x002\x00=\x00A\x00(\x00\x14\x00\x1d\x00-\x001\x00*\x00\x19\x00\x0e\x00\x16\x00\x1f\x00.\x00\x1d\x00\xfe\xff\xf2\xff\x05\x00,\x005\x00(\x00\t\x00\xfb\xff\r\x00%\x003\x00%\x00\x13\x00\x11\x00\x16\x00"\x00+\x00!\x00\t\x00\x00\x00\x12\x00\x05\x00\x07\x00*\x00\x11\x00\xef\xff\xe4\xff\xf4\xff\xfb\xff\xfa\xff\xeb\xff\xd5\xff\xdf\xff\x0f\x00*\x00\x0c\x00\x00\x00\x03\x00\x05\x00\xfc\xff\xfd\xff\x07\x00\x05\x00\n\x00\x14\x00\x12\x00\x01\x00\xfe\xff\xf3\xff\xe9\xff\xe2\xff\xf8\xff\x05\x00\xff\xff\xfa\xff\xf7\xff\xf4\xff\xf0\xff\xf6\xff\xfc\xff\xfb\xff\xfe\xff\x04\x00\x01\x00\xfe\xff\x02\x00\xfe\xff\xf5\xff\xeb\xff\xef\xff\xfc\xff\x00\x00\xf6\xff\xdf\xff\xd6\xff\xea\xff\xf7\xff\xf9\xff\xea\xff\xd7\xff\xdc\xff\xf8\xff\x06\x00\xfc\xff\xf2\xff\xfc\xff\xf4\xff\xfe\xff\x18\x00\x17\x00\xff\xff\xf5\xff\x01\x00\x08\x00\x0c\x00\r\x00\xfc\xff\xef\xff\xf0\xff\xf4\xff\xf4\xff\xe9\xff\xdf\xff\xcf\xff\xcf\xff\xe8\xff\xf1\xff\xf4\xff\xf0\xff\xed\xff\xe4\xff\xf1\xff\x03\x00\x06\x00\x00\x00\x04\x00\x0c\x00\xfb\xff\x0b\x00"\x00\x0e\x00\xf0\xff\xf8\xff\x0e\x00\x12\x00\x02\x00\x0e\x00\x05\x00\xdf\xff\xea\xff\xf3\xff\xec\xff\xda\xff\xd0\xff\xc9\xff\xc4\xff\xc6\xff\xc9\xff\xbc\xff\xaa\xff\xb6\xff\xc1\xff\xc8\xff\xd3\xff\xc8\xff\xce\xff\xd3\xff\xdf\xff\xec\xff\xeb\xff\xea\xff\xf2\xff\x00\x00\t\x00\x10\x00\n\x00\xf8\xff\xf6\xff\xf7\xff\xf1\xff\xf5\xff\xf9\xff\xf3\xff\xea\xff\xe4\xff\xef\xff\xe3\xff\xd8\xff\xd6\xff\xda\xff\xe9\xff\xf1\xff\xf7\xff\xf0\xff\xe5\xff\xde\xff\xeb\xff\xfe\xff\xfb\xff\xfa\xff\xfe\xff\xfd\xff\x00\x00\n\x00\x0c\x00\xfe\xff\xf2\xff\xfb\xff\x05\x00\x15\x00\x1d\x00\x0c\x00\x03\x00\x05\x00\x0c\x00\r\x00\x10\x00\x11\x00\x10\x00\x0b\x00\x12\x00\x12\x00\x08\x00\x06\x00\x00\x00\x03\x00\x0f\x00"\x00\x1e\x00\x0f\x00\t\x00\x07\x00\x06\x00\x15\x00\x16\x00\x00\x00\xf6\xff\x00\x00\x13\x00\x0b\x00\xf9\xff\xe9\xff\xe1\xff\xe3\xff\xf7\xff\x00\x00\xee\xff\xd9\xff\xd8\xff\xec\xff\xf0\xff\xf7\xff\xfa\xff\xf6\xff\xf8\xff\x05\x00\x16\x00\x14\x00\t\x00\x04\x00\x04\x00\x0e\x00 \x00,\x00&\x00\x1f\x00\x19\x00%\x00)\x00\x1b\x00\x10\x00\n\x00\x07\x00\r\x00\x18\x00\x04\x00\xfc\xff\t\x00\x07\x00\x05\x00\x0b\x00\t\x00\r\x00\x03\x00\x02\x00\x05\x00\r\x00\x1e\x00 \x00 \x00\x1d\x00\'\x00"\x00\x13\x00\x12\x00\x18\x00\x14\x00\x13\x00\x12\x00\x1c\x00 \x00\x1f\x00#\x00\x18\x00\x17\x00\x1a\x00\x1a\x00\x18\x00 \x00\x1c\x00\x10\x00\x13\x00\x04\x00\xf9\xff\xfe\xff\x0e\x00\x11\x00\t\x00\x05\x00\x00\x00\xfe\xff\x08\x00\t\x00\xfe\xff\xf8\xff\xf8\xff\x00\x00\x00\x00\x00\x00\x08\x00\xfb\xff\xfc\xff\x05\x00\x04\x00\x00\x00\xfe\xff\xf8\xff\xf5\xff\xf5\xff\xf7\xff\xfb\xff\x01\x00\x01\x00\xf6\xff\xf6\xff\xfc\xff\xfc\xff\xf7\xff\xf9\xff\xfc\xff\xf9\xff\xf8\xff\xff\xff\xfe\xff\xf7\xff\xf4\xff\xed\xff\xe8\xff\xed\xff\xec\xff\xe3\xff\xe8\xff\xe4\xff\xde\xff\xe1\xff\xe7\xff\xde\xff\xdc\xff\xe2\xff\xe1\xff\xe7\xff\xf6\xff\xfc\xff\xfb\xff\x00\x00\x0c\x00\x07\x00\x04\x00\x06\x00\xfd\xff\xfd\xff\xfe\xff\xfb\xff\xf8\xff\xed\xff\xee\xff\xf3\xff\xf1\xff\xef\xff\xeb\xff\xea\xff\xeb\xff\xf3\xff\xfc\xff\xfa\xff\xf7\xff\xfb\xff\x05\x00\x06\x00\x00\x00\xfe\xff\xff\xff\xfa\xff\xf3\xff\xfa\xff\x01\x00\xfe\xff\xfb\xff\x00\x00\xf7\xff\xf9\xff\xfa\xff\x02\x00\x06\x00\x05\x00\x02\x00\xff\xff\x05\x00\xfd\xff\xfd\xff\xf5\xff\xf4\xff\xf3\xff\xea\xff\xe9\xff\xe6\xff\xe4\xff\xdb\xff\xd6\xff\xdb\xff\xe5\xff\xe4\xff\xe3\xff\xd4\xff\xd4\xff\xe1\xff\xdd\xff\xe6\xff\xe8\xff\xf1\xff\xf5\xff\xf4\xff\xf6\xff\xf4\xff\xf4\xff\xe8\xff\xe5\xff\xeb\xff\xef\xff\xf1\xff\xf1\xff\xe4\xff\xdb\xff\xe0\xff\xe2\xff\xe9\xff\xec\xff\xeb\xff\xeb\xff\xe9\xff\xf0\xff\xf8\xff\xf0\xff\xee\xff\xf5\xff\xfb\xff\xf6\xff\xfc\xff\x00\x00\xfa\xff\x01\x00\x00\x00\x05\x00\x06\x00\x03\x00\xff\xff\xf6\xff\x03\x00\x06\x00\xfe\xff\x02\x00\x06\x00\xfc\xff\xfc\xff\x00\x00\xfe\xff\x00\x00\xfe\xff\xf9\xff\xfc\xff\x03\x00\x03\x00\x00\x00\xff\xff\x02\x00\x10\x00\x11\x00\x10\x00\x13\x00\x1c\x00"\x00\x1b\x00\x14\x00\x13\x00\x11\x00\x10\x00\x0b\x00\x00\x00\xfc\xff\xfd\xff\x00\x00\xfc\xff\xf5\xff\xf2\xff\xf7\xff\xf9\xff\xfe\xff\t\x00\x0c\x00\x12\x00\x10\x00\x06\x00\x02\x00\r\x00\r\x00\x08\x00\x03\x00\x04\x00\x05\x00\xfa\xff\xf9\xff\xfb\xff\xf9\xff\xf7\xff\xff\xff\x02\x00\x01\x00\x02\x00\x01\x00\x01\x00\xfd\xff\xfe\xff\x01\x00\x07\x00\x0c\x00\x12\x00\x18\x00\x11\x00\x14\x00#\x00\x1f\x00\x1f\x00(\x00\x1f\x00\x1e\x00&\x00\x1f\x00\x17\x00\x11\x00\x15\x00\x16\x00\x14\x00\x16\x00\x13\x00\r\x00\x15\x00\x1b\x00\x0f\x00\x16\x00 \x00\x1c\x00\x16\x00\x13\x00\x0f\x00\x0f\x00\x0f\x00\x10\x00\x0e\x00\n\x00\n\x00\x02\x00\x05\x00\x04\x00\x04\x00\x00\x00\x00\x00\x04\x00\x05\x00\x04\x00\xfd\xff\xf8\xff\xfb\xff\xfa\xff\xfa\xff\xf8\xff\xf8\xff\xfb\xff\xfa\xff\xf5\xff\xf7\xff\xf7\xff\xfa\xff\xf6\xff\xf6\xff\xfa\xff\xf9\xff\xf9\xff\xf8\xff\xfb\xff\xff\xff\x00\x00\xfb\xff\xfb\xff\x04\x00\x04\x00\xfd\xff\xfa\xff\x05\x00\x01\x00\x07\x00\x13\x00\r\x00\x03\x00\x04\x00\x01\x00\xff\xff\xfc\xff\xfd\xff\xfb\xff\x02\x00\t\x00\xff\xff\xfd\xff\xfd\xff\xfd\xff\xf4\xff\xee\xff\xfb\xff\x01\x00\xfb\xff\xfb\xff\x00\x00\xff\xff\x01\x00\x06\x00\x07\x00\x00\x00\x02\x00\x07\x00\x00\x00\x01\x00\xfb\xff\xf3\xff\xee\xff\xee\xff\xf1\xff\xf4\xff\xee\xff\xf5\xff\xfb\xff\xef\xff\xea\xff\xe8\xff\xef\xff\xec\xff\xf2\xff\xf1\xff\xec\xff\xee\xff\xeb\xff\xe7\xff\xe4\xff\xed\xff\xef\xff\xec\xff\xec\xff\xea\xff\xef\xff\xeb\xff\xe7\xff\xed\xff\xf7\xff\xf9\xff\xff\xff\xfe\xff\xf3\xff\xef\xff\xee\xff\xf3\xff\xf0\xff\xee\xff\xee\xff\xea\xff\xe6\xff\xe6\xff\xe9\xff\xe7\xff\xf0\xff\xe9\xff\xe3\xff\xe7\xff\xe7\xff\xec\xff\xef\xff\xf1\xff\xf4\xff\xf5\xff\xf1\xff\xf4\xff\x00\x00\xf8\xff\xf9\xff\x00\x00\xfb\xff\xfc\xff\xfd\xff\x00\x00\xf8\xff\xf9\xff\xfb\xff\xf4\xff\xef\xff\xf8\xff\xf6\xff\xf4\xff\xf8\xff\xf9\xff\xfb\xff\xfa\xff\xfc\xff\xf8\xff\xff\xff\x00\x00\xfd\xff\xf7\xff\xf6\xff\xf8\xff\xfc\xff\x06\x00\x04\x00\x02\x00\x08\x00\x05\x00\xfd\xff\xfe\xff\xfe\xff\xfc\xff\xfb\xff\xf8\xff\xef\xff\xf2\xff\xf2\xff\xf1\xff\xf5\xff\xec\xff\xec\xff\xf0\xff\xef\xff\xec\xff\xec\xff\xf1\xff\xf6\xff\xf4\xff\xf5\xff\xfa\xff\xfe\xff\x05\x00\x04\x00\x04\x00\x05\x00\x03\x00\x03\x00\x04\x00\t\x00\x0c\x00\t\x00\t\x00\t\x00\x08\x00\x08\x00\t\x00\r\x00\r\x00\x0c\x00\n\x00\t\x00\x08\x00\x08\x00\x0c\x00\x0c\x00\x0b\x00\n\x00\x08\x00\n\x00\x0c\x00\x03\x00\x04\x00\x00\x00\x05\x00\x0e\x00\r\x00\x0c\x00\r\x00\x15\x00\x0e\x00\t\x00\x0e\x00\x11\x00\x0b\x00\x0b\x00\t\x00\x0e\x00\x15\x00\x14\x00\x15\x00\x0e\x00\x0f\x00\x14\x00\x16\x00\x12\x00\x16\x00\x19\x00\x12\x00\x11\x00\x0b\x00\x01\x00\x01\x00\n\x00\x0b\x00\x0f\x00\x14\x00\x12\x00\x10\x00\x17\x00\x14\x00\x16\x00\x15\x00\n\x00\x0b\x00\x0f\x00\r\x00\x0f\x00\n\x00\r\x00\x10\x00\r\x00\r\x00\x10\x00\r\x00\x04\x00\x00\x00\x07\x00\n\x00\n\x00\n\x00\x08\x00\x05\x00\x02\x00\x06\x00\x05\x00\x07\x00\x04\x00\x02\x00\x05\x00\x02\x00\xff\xff\xfa\xff\xfa\xff\xf9\xff\xf4\xff\xf3\xff\xf6\xff\xf4\xff\xf7\xff\xf7\xff\xf8\xff\xf6\xff\xf8\xff\xee\xff\xea\xff\xee\xff\xf2\xff\xf6\xff\xfa\xff\xfc\xff\x00\x00\x01\x00\x05\x00\x06\x00\x04\x00\x03\x00\x00\x00\x01\x00\xfd\xff\xfb\xff\xfa\xff\xf4\xff\xf4\xff\xf7\xff\xf6\xff\xf3\xff\xf3\xff\xf2\xff\xf3\xff\xf5\xff\xf4\xff\xf4\xff\xfc\xff\xfe\xff\x00\x00\x02\x00\xfe\xff\xfa\xff\xfc\xff\xfc\xff\xf7\xff\xf8\xff\xfd\xff\xfe\xff\xfd\xff\xfd\xff\xfc\xff\xfd\xff\xf6\xff\xf5\xff\xf5\xff\xfa\xff\xfc\xff\xfa\xff\xfc\xff\xf9\xff\xfa\xff\xf4\xff\xf6\xff\xf4\xff\xee\xff\xee\xff\xf0\xff\xee\xff\xed\xff\xea\xff\xea\xff\xea\xff\xe5\xff\xeb\xff\xe8\xff\xed\xff\xf2\xff\xed\xff\xf3\xff\xf1\xff\xf5\xff\xf4\xff\xf7\xff\xfa\xff\xf2\xff\xf6\xff\xf0\xff\xea\xff\xef\xff\xf2\xff\xef\xff\xec\xff\xe6\xff\xe3\xff\xe3\xff\xe1\xff\xe5\xff\xec\xff\xe6\xff\xe6\xff\xea\xff\xee\xff\xf4\xff\xf3\xff\xf5\xff\xfb\xff\xfd\xff\xf9\xff\xfd\xff\xfa\xff\xf5\xff\xfa\xff\xf9\xff\x00\x00\xfc\xff\xff\xff\xff\xff\xf3\xff\xfb\xff\x00\x00\x00\x00\x00\x00\x00\x00\xfd\xff\x00\x00\xff\xff\xfc\xff\x00\x00\xff\xff\xfe\xff\x01\x00\x04\x00\xff\xff\xfc\xff\xff\xff\x02\x00\x03\x00\x05\x00\x03\x00\x03\x00\x06\x00\x07\x00\x04\x00\x03\x00\x07\x00\x03\x00\x00\x00\xfe\xff\xf9\xff\xfb\xff\xf9\xff\xfa\xff\xfa\xff\xf7\xff\xf4\xff\xf5\xff\xf4\xff\xf9\xff\xfe\xff\x00\x00\x07\x00\x05\x00\x04\x00\x02\x00\x04\x00\x08\x00\x03\x00\x01\x00\x01\x00\x04\x00\x01\x00\x02\x00\x05\x00\x02\x00\x00\x00\x06\x00\x08\x00\n\x00\x07\x00\t\x00\n\x00\x02\x00\x04\x00\x05\x00\x07\x00\x04\x00\x07\x00\x02\x00\x00\x00\x04\x00\x0c\x00\t\x00\x08\x00\x0c\x00\x0b\x00\r\x00\r\x00\x0c\x00\x0c\x00\x0f\x00\x10\x00\x11\x00\x12\x00\x13\x00\x18\x00\x16\x00\x18\x00\x15\x00\x13\x00\x14\x00\x13\x00\x12\x00\x0f\x00\r\x00\t\x00\x07\x00\x06\x00\x06\x00\x06\x00\x04\x00\x00\x00\xfc\xff\x01\x00\x03\x00\x07\x00\x02\x00\x02\x00\x07\x00\x07\x00\t\x00\x07\x00\t\x00\t\x00\x06\x00\x08\x00\x05\x00\x03\x00\xff\xff\x00\x00\xff\xff\xff\xff\x02\x00\x04\x00\x07\x00\x06\x00\x02\x00\x02\x00\x04\x00\x01\x00\x01\x00\x05\x00\x03\x00\xff\xff\xfa\xff\xff\xff\xff\xff\xfa\xff\xf9\xff\x00\x00\xff\xff\xfd\xff\x01\x00\x00\x00\xfe\xff\x00\x00\xfd\xff\x00\x00\x00\x00\x02\x00\x02\x00\x06\x00\x08\x00\x02\x00\x01\x00\x04\x00\x07\x00\x03\x00\x00\x00\x05\x00\x03\x00\xff\xff\x00\x00\xff\xff\xfe\xff\xff\xff\x00\x00\x02\x00\xfe\xff\xf9\xff\xfa\xff\xfb\xff\xfd\xff\xf8\xff\xf5\xff\xf3\xff\xed\xff\xf2\xff\xf5\xff\xf3\xff\xf6\xff\xf7\xff\xf6\xff\xf7\xff\xf6\xff\xf8\xff\xf5\xff\xfe\xff\xfd\xff\xff\xff\x04\x00\x00\x00\xfa\xff\xfa\xff\xfc\xff\xf6\xff\xf4\xff\xf8\xff\xf8\xff\xfa\xff\xfa\xff\xf6\xff\xfa\xff\xfc\xff\xfe\xff\x03\x00\x00\x00\xf7\xff\xf4\xff\xf4\xff\xf8\xff\xf8\xff\xf8\xff\xfa\xff\xf4\xff\xf1\xff\xf3\xff\xf4\xff\xf2\xff\xf0\xff\xef\xff\xef\xff\xeb\xff\xee\xff\xee\xff\xf0\xff\xf2\xff\xf3\xff\xf5\xff\xf2\xff\xf2\xff\xf7\xff\xf5\xff\xf1\xff\xf6\xff\xf9\xff\xfa\xff\xf3\xff\xf1\xff\xef\xff\xed\xff\xe8\xff\xe9\xff\xea\xff\xf1\xff\xf1\xff\xf0\xff\xf7\xff\xf6\xff\xfc\xff\xf7\xff\xf8\xff\xf7\xff\xfd\xff\x00\x00\xf6\xff\xf6\xff\xfb\xff\xfb\xff\xfe\xff\x04\x00\xfe\xff\xf9\xff\x01\x00\xff\xff\xfd\xff\xff\xff\xfe\xff\xfa\xff\xfb\xff\xfc\xff\xfc\xff\xff\xff\xfb\xff\xfc\xff\xfe\xff\xfb\xff\xfd\xff\xfd\xff\xfa\xff\xf9\xff\xfa\xff\xfd\xff\xfe\xff\xfd\xff\x00\x00\xff\xff\x00\x00\x03\x00\x02\x00\x03\x00\x02\x00\x03\x00\x04\x00\x04\x00\x06\x00\x05\x00\x03\x00\x01\x00\xff\xff\xff\xff\xfa\xff\xfa\xff\xfd\xff\xfe\xff\xfc\xff\xfe\xff\xfe\xff\xfd\xff\xfb\xff\xfb\xff\xfb\xff\xf9\xff\xf9\xff\xf9\xff\xfc\xff\xfd\xff\xf8\xff\xf9\xff\xf6\xff\xf3\xff\xfa\xff\xfa\xff\xf6\xff\xf8\xff\xfc\xff\xfa\xff\xfa\xff\xf9\xff\xfb\xff\xf9\xff\xff\xff\xff\xff\xfe\xff\x00\x00\xff\xff\xfe\xff\xfc\xff\x00\x00\x01\x00\x06\x00\x00\x00\x03\x00\t\x00\t\x00\t\x00\x06\x00\x04\x00\x02\x00\x0c\x00\t\x00\t\x00\x0f\x00\x12\x00\x13\x00\x17\x00\x19\x00\x1d\x00\x1d\x00\x16\x00\x16\x00\x18\x00\x19\x00\x15\x00\x16\x00\x16\x00\x14\x00\x13\x00\x17\x00\x16\x00\x10\x00\r\x00\n\x00\x0b\x00\t\x00\x04\x00\x03\x00\x04\x00\x03\x00\x03\x00\x05\x00\x02\x00\x03\x00\x04\x00\x02\x00\x02\x00\xff\xff\xfc\xff\xfb\xff\xff\xff\xff\xff\xfb\xff\xfe\xff\x01\x00\x01\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\xfe\xff\xfd\xff\xff\xff\x02\x00\x00\x00\x00\x00\x01\x00\x04\x00\x06\x00\x05\x00\x08\x00\x05\x00\x04\x00\x06\x00\x05\x00\x01\x00\x00\x00\x02\x00\xfc\xff\xfd\xff\xff\xff\xfe\xff\xfa\xff\xfa\xff\xf8\xff\xf4\xff\xfa\xff\xfe\xff\xfa\xff\xfe\xff\xfe\xff\x01\x00\x01\x00\xfc\xff\xfd\xff\xfd\xff\xfc\xff\xfb\xff\xfb\xff\xfd\xff\x00\x00\xfd\xff\xfc\xff\x00\x00\x00\x00\xfb\xff\xf8\xff\xf9\xff\xf9\xff\xf9\xff\xfc\xff\xfc\xff\xfb\xff\xfd\xff\xfa\xff\xf9\xff\xf8\xff\xf6\xff\xf7\xff\xf6\xff\xf7\xff\xf7\xff\xf6\xff\xf8\xff\xf7\xff\xf7\xff\xf7\xff\xfa\xff\xfb\xff\xfa\xff\xfc\xff\x01\x00\xfc\xff\xff\xff\xfe\xff\xfe\xff\xff\xff\xf7\xff\xfa\xff\xfa\xff\xf6\xff\xf7\xff\xf9\xff\xf6\xff\xf4\xff\xf3\xff\xf0\xff\xf2\xff\xf0\xff\xed\xff\xf2\xff\xf2\xff\xf2\xff\xf6\xff\xf6\xff\xf7\xff\xf5\xff\xf3\xff\xf6\xff\xf8\xff\xf7\xff\xf4\xff\xf3\xff\xef\xff\xf2\xff\xf0\xff\xf1\xff\xef\xff\xf2\xff\xf4\xff\xeb\xff\xef\xff\xf3\xff\xf5\xff\xf3\xff\xf6\xff\xf7\xff\xf3\xff\xf5\xff\xf5\xff\xf6\xff\xf5\xff\xf4\xff\xf5\xff\xf7\xff\xf3\xff\xf3\xff\xf4\xff\xf8\xff\xf8\xff\xf6\xff\xfa\xff\xfd\xff\xfa\xff\xf8\xff\xfb\xff\xfc\xff\xfe\xff\xfa\xff\xf8\xff\xf6\xff\xf7\xff\xf9\xff\xf8\xff\xf8\xff\xf9\xff\xf9\xff\xf9\xff\xfc\xff\xf8\xff\xf9\xff\xff\xff\xfe\xff\xfd\xff\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x01\x00\x03\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x01\x00\x01\x00\x05\x00\xff\xff\xfe\xff\xff\xff\xfd\xff\xfa\xff\xfd\xff\xfc\xff\xfa\xff\xfc\xff\xfe\xff\xfb\xff\xfc\xff\xfd\xff\xfe\xff\xff\xff\xfa\xff\xf9\xff\xfb\xff\x00\x00\x00\x00\x02\x00\x01\x00\x04\x00\x06\x00\x04\x00\x05\x00\x05\x00\x06\x00\x06\x00\x07\x00\t\x00\t\x00\t\x00\x03\x00\x01\x00\x04\x00\x02\x00\x01\x00\x02\x00\x02\x00\x00\x00\x00\x00\x03\x00\x06\x00\x02\x00\x05\x00\x08\x00\x05\x00\x07\x00\t\x00\x08\x00\x05\x00\x06\x00\x0c\x00\x0c\x00\x08\x00\t\x00\x0b\x00\x0b\x00\t\x00\n\x00\x08\x00\x07\x00\n\x00\x04\x00\x06\x00\x08\x00\x08\x00\x08\x00\t\x00\x08\x00\x07\x00\x08\x00\x08\x00\x06\x00\x06\x00\t\x00\n\x00\x06\x00\x08\x00\n\x00\x07\x00\t\x00\x0e\x00\r\x00\x0c\x00\n\x00\x08\x00\x05\x00\x06\x00\x03\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfc\xff\xfb\xff\xfd\xff\xfa\xff\xf7\xff\xfd\xff\xfd\xff\xf8\xff\xfc\xff\xfc\xff\xfe\xff\xfc\xff\xfb\xff\xfd\xff\xfe\xff\x00\x00\x00\x00\x00\x00\xfe\xff\xfb\xff\xff\xff\xfe\xff\xff\xff\xfc\xff\xfb\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\xfe\xff\xfd\xff\xfd\xff\xfb\xff\xfb\xff\xfb\xff\xfb\xff\xfa\xff\xf8\xff\xf7\xff\xf9\xff\xff\xff\x00\x00\x02\x00\x00\x00\xff\xff\xfb\xff\xfd\xff\xfe\xff\xf8\xff\xfa\xff\xff\xff\xfc\xff\xfb\xff\xfd\xff\xf9\xff\xf5\xff\xf6\xff\xfa\xff\xf9\xff\xf7\xff\xf6\xff\xf2\xff\xf7\xff\xf7\xff\xf7\xff\xf8\xff\xf5\xff\xf5\xff\xf7\xff\xf5\xff\xf5\xff\xf5\xff\xf9\xff\xf7\xff\xf0\xff\xf1\xff\xef\xff\xef\xff\xea\xff\xe8\xff\xea\xff\xed\xff\xee\xff\xf0\xff\xf7\xff\xf6\xff\xf8\xff\xf3\xff\xf1\xff\xf2\xff\xf4\xff\xf7\xff\xf5\xff\xf4\xff\xf5\xff\xf4\xff\xf6\xff\xf7\xff\xf4\xff\xf3\xff\xf6\xff\xf5\xff\xf1\xff\xf5\xff\xfa\xff\xf7\xff\xf7\xff\xf8\xff\xfa\xff\xfe\xff\xfc\xff\xfc\xff\xfa\xff\xf7\xff\xf6\xff\xf7\xff\xf7\xff\xfa\xff\xf7\xff\xf7\xff\xfd\xff\xfb\xff\xfb\xff\xfa\xff\xfb\xff\xff\xff\xff\xff\x00\x00\x02\x00\x03\x00\x02\x00\x01\x00\x01\x00\x02\x00\x01\x00\x02\x00\x00\x00\xfe\xff\xfe\xff\xfd\xff\xff\xff\x01\x00\xfd\xff\xfb\xff\xfc\xff\xfe\xff\xfd\xff\xfb\xff\xfc\xff\xf9\xff\xfa\xff\xfb\xff\xfc\xff\xfd\xff\xf8\xff\xf6\xff\xf4\xff\xf4\xff\xf9\xff\xf9\xff\xf6\xff\xfa\xff\xf9\xff\xf8\xff\xfb\xff\xfb\xff\xfa\xff\xf8\xff\xfb\xff\xfd\xff\xfb\xff\xfd\xff\xfb\xff\xf9\xff\xf9\xff\xfd\xff\xfe\xff\xfd\xff\xfb\xff\xfb\xff\xfc\xff\xfd\xff\xfc\xff\xfc\xff\xff\xff\xfe\xff\x00\x00\x00\x00\x03\x00\x06\x00\x06\x00\x07\x00\x08\x00\x08\x00\n\x00\x0b\x00\x08\x00\t\x00\x08\x00\n\x00\x08\x00\x08\x00\x05\x00\x08\x00\t\x00\n\x00\x0b\x00\t\x00\x05\x00\x03\x00\x04\x00\x06\x00\x04\x00\x01\x00\x02\x00\x03\x00\x03\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\x01\x00\x00\x00\xfc\xff\xfc\xff\x03\x00\x01\x00\xff\xff\x00\x00\x01\x00\x00\x00\xff\xff\xfe\xff\x01\x00\x01\x00\x02\x00\x01\x00\xfd\xff\x02\x00\x03\x00\x00\x00\x00\x00\x00\x00\x01\x00\x03\x00\x01\x00\x03\x00\x00\x00\x00\x00\x04\x00\x04\x00\x04\x00\x06\x00\n\x00\x03\x00\x03\x00\x04\x00\x05\x00\x06\x00\x03\x00\x02\x00\x03\x00\x06\x00\x08\x00\x04\x00\x07\x00\t\x00\x08\x00\x07\x00\x07\x00\x08\x00\x07\x00\x07\x00\x07\x00\x08\x00\t\x00\t\x00\x07\x00\x05\x00\x06\x00\x05\x00\x01\x00\x00\x00\x01\x00\x02\x00\x00\x00\x00\x00\xfe\xff\xfc\xff\xfe\xff\xfc\xff\xf9\xff\xf7\xff\xf7\xff\xf8\xff\xf7\xff\xf7\xff\xf8\xff\xf5\xff\xf6\xff\xf4\xff\xf5\xff\xf6\xff\xf7\xff\xf8\xff\xfb\xff\xfd\xff\xfc\xff\xf8\xff\xfa\xff\xfc\xff\xfd\xff\xff\xff\xfb\xff\xfd\xff\xf8\xff\xf4\xff\xf6\xff\xf7\xff\xf5\xff\xf6\xff\xf4\xff\xf4\xff\xf4\xff\xf2\xff\xf4\xff\xf7\xff\xf6\xff\xf4\xff\xfa\xff\xfb\xff\xfa\xff\xf4\xff\xf7\xff\xf8\xff\xfa\xff\xf9\xff\xf6\xff\xfa\xff\xf8\xff\xf9\xff\xf8\xff\xf8\xff\xf8\xff\xfa\xff\xf8\xff\xf5\xff\xf8\xff\xf9\xff\xfc\xff\xfc\xff\xfe\xff\xff\xff\xfc\xff\xfb\xff\xfb\xff\xfc\xff\xf9\xff\xf8\xff\xf9\xff\xf8\xff\xf3\xff\xf2\xff\xf3\xff\xf3\xff\xef\xff\xf0\xff\xf2\xff\xf3\xff\xf2\xff\xf2\xff\xf7\xff\xf7\xff\xfa\xff\xf8\xff\xf7\xff\xf7\xff\xf5\xff\xf7\xff\xf8\xff\xf8\xff\xf9\xff\xfa\xff\xf9\xff\xfc\xff\xfa\xff\xfa\xff\xfc\xff\xfb\xff\xfd\xff\x00\x00\xfe\xff\x00\x00\xff\xff\x00\x00\x00\x00\x01\x00\x00\x00\xff\xff\xff\xff\xfb\xff\xff\xff\xfc\xff\xfb\xff\xfd\xff\xfb\xff\xfe\xff\xfd\xff\xfe\xff\x00\x00\xf8\xff\xfc\xff\xfe\xff\xfd\xff\xfb\xff\xfd\xff\xfc\xff\xf8\xff\xf9\xff\xfa\xff\xf7\xff\xf7\xff\xfa\xff\xf9\xff\xfa\xff\xf6\xff\xf6\xff\xf7\xff\xfb\xff\xfe\xff\xff\xff\xfe\xff\xff\xff\xff\xff\xff\xff\xfd\xff\xfe\xff\x00\x00\x00\x00\xfb\xff\xf9\xff\xfd\xff\xfc\xff\xfb\xff\xfa\xff\xfa\xff\xf7\xff\xf7\xff\xfc\xff\xfa\xff\xfc\xff\xfd\xff\xfd\xff\x00\x00\x00\x00\xfa\xff\xf8\xff\xfa\xff\xf7\xff\xf3\xff\xf6\xff\xf7\xff\xf6\xff\xf8\xff\xfa\xff\xf7\xff\xf7\xff\xf6\xff\xfb\xff\xfb\xff\xfc\xff\xfd\xff\xfd\xff\x03\x00\x00\x00\x04\x00\t\x00\x05\x00\x07\x00\t\x00\t\x00\x08\x00\t\x00\n\x00\n\x00\x05\x00\x02\x00\x02\x00\x02\x00\x00\x00\x01\x00\x01\x00\x02\x00\x07\x00\x05\x00\x06\x00\x04\x00\x01\x00\x00\x00\x02\x00\x02\x00\x00\x00\x05\x00\t\x00\x0b\x00\n\x00\x0b\x00\x0c\x00\x0b\x00\t\x00\n\x00\t\x00\n\x00\r\x00\x0e\x00\r\x00\x08\x00\x05\x00\x02\x00\x04\x00\x02\x00\x01\x00\x03\x00\x00\x00\xff\xff\x03\x00\x05\x00\x04\x00\x03\x00\x00\x00\x00\x00\x06\x00\t\x00\x04\x00\x01\x00\x01\x00\xfe\xff\x00\x00\x00\x00\x00\x00\xfc\xff\xfd\xff\xfe\xff\xfd\xff\xfe\xff\xff\xff\xfe\xff\x01\x00\x02\x00\x00\x00\x03\x00\x02\x00\x04\x00\x05\x00\x01\x00\x02\x00\x03\x00\x04\x00\x03\x00\x04\x00\x06\x00\x06\x00\x01\x00\xfe\xff\xfd\xff\xfb\xff\xfa\xff\xfb\xff\xfc\xff\xf8\xff\xf5\xff\xf6\xff\xf6\xff\xf5\xff\xf6\xff\xf9\xff\xf8\xff\xf5\xff\xf6\xff\xf4\xff\xf1\xff\xf4\xff\xf4\xff\xf2\xff\xee\xff\xee\xff\xef\xff\xee\xff\xf0\xff\xf2\xff\xf2\xff\xf5\xff\xef\xff\xf0\xff\xee\xff\xf1\xff\xf3\xff\xf1\xff\xf0\xff\xf3\xff\xf3\xff\xf3\xff\xf0\xff\xf4\xff\xf8\xff\xee\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +# --- diff --git a/tests/components/voip/test_util.py b/tests/components/voip/test_util.py new file mode 100644 index 00000000000..85dfdbac2be --- /dev/null +++ b/tests/components/voip/test_util.py @@ -0,0 +1,47 @@ +"""Test VoIP utils.""" + +import asyncio + +import pytest + +from homeassistant.components.voip.util import queue_to_iterable + + +async def test_queue_to_iterable() -> None: + """Test queue_to_iterable.""" + queue: asyncio.Queue[int | None] = asyncio.Queue() + expected_items = list(range(10)) + + for i in expected_items: + await queue.put(i) + + # Will terminate the stream + await queue.put(None) + + actual_items = [item async for item in queue_to_iterable(queue)] + + assert expected_items == actual_items + + # Check timeout + assert queue.empty() + + # Time out on first item + async with asyncio.timeout(1): + with pytest.raises(asyncio.TimeoutError): # noqa: PT012 + # Should time out very quickly + async for _item in queue_to_iterable(queue, timeout=0.01): + await asyncio.sleep(1) + + # Check timeout on second item + assert queue.empty() + await queue.put(12345) + + # Time out on second item + async with asyncio.timeout(1): + with pytest.raises(asyncio.TimeoutError): # noqa: PT012 + # Should time out very quickly + async for item in queue_to_iterable(queue, timeout=0.01): + if item != 12345: + await asyncio.sleep(1) + + assert queue.empty() diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index aab35bfd029..e6a635619a1 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -3,15 +3,26 @@ import asyncio import io from pathlib import Path -import time from unittest.mock import AsyncMock, Mock, patch import wave import pytest +from syrupy.assertion import SnapshotAssertion +from voip_utils import CallInfo -from homeassistant.components import assist_pipeline, voip -from homeassistant.components.voip.devices import VoIPDevice +from homeassistant.components import assist_pipeline, assist_satellite, voip +from homeassistant.components.assist_satellite.entity import ( + AssistSatelliteEntity, + AssistSatelliteState, +) +from homeassistant.components.voip import HassVoipDatagramProtocol +from homeassistant.components.voip.assist_satellite import Tones, VoipAssistSatellite +from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices +from homeassistant.components.voip.voip import PreRecordMessageProtocol, make_protocol +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import EntityComponent from homeassistant.setup import async_setup_component _ONE_SECOND = 16000 * 2 # 16Khz 16-bit @@ -35,33 +46,180 @@ def _empty_wav() -> bytes: return wav_io.getvalue() +def async_get_satellite_entity( + hass: HomeAssistant, domain: str, unique_id_prefix: str +) -> AssistSatelliteEntity | None: + """Get Assist satellite entity.""" + ent_reg = er.async_get(hass) + satellite_entity_id = ent_reg.async_get_entity_id( + Platform.ASSIST_SATELLITE, domain, f"{unique_id_prefix}-assist_satellite" + ) + if satellite_entity_id is None: + return None + + component: EntityComponent[AssistSatelliteEntity] = hass.data[ + assist_satellite.DOMAIN + ] + return component.get_entity(satellite_entity_id) + + +async def test_is_valid_call( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, + call_info: CallInfo, +) -> None: + """Test that a call is now allowed from an unknown device.""" + assert await async_setup_component(hass, "voip", {}) + protocol = HassVoipDatagramProtocol(hass, voip_devices) + assert not protocol.is_valid_call(call_info) + + ent_reg = er.async_get(hass) + allowed_call_entity_id = ent_reg.async_get_entity_id( + "switch", voip.DOMAIN, f"{voip_device.voip_id}-allow_call" + ) + assert allowed_call_entity_id is not None + state = hass.states.get(allowed_call_entity_id) + assert state is not None + assert state.state == STATE_OFF + + # Allow calls + hass.states.async_set(allowed_call_entity_id, STATE_ON) + assert protocol.is_valid_call(call_info) + + +async def test_calls_not_allowed( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, + call_info: CallInfo, + snapshot: SnapshotAssertion, +) -> None: + """Test that a pre-recorded message is played when calls aren't allowed.""" + assert await async_setup_component(hass, "voip", {}) + protocol: PreRecordMessageProtocol = make_protocol(hass, voip_devices, call_info) + assert isinstance(protocol, PreRecordMessageProtocol) + assert protocol.file_name == "problem.pcm" + + # Test the playback + done = asyncio.Event() + played_audio_bytes = b"" + + def send_audio(audio_bytes: bytes, **kwargs): + nonlocal played_audio_bytes + + # Should be problem.pcm from components/voip + played_audio_bytes = audio_bytes + done.set() + + protocol.transport = Mock() + protocol.loop_delay = 0 + with patch.object(protocol, "send_audio", send_audio): + protocol.on_chunk(bytes(_ONE_SECOND)) + + async with asyncio.timeout(1): + await done.wait() + + assert sum(played_audio_bytes) > 0 + assert played_audio_bytes == snapshot() + + +async def test_pipeline_not_found( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, + call_info: CallInfo, + snapshot: SnapshotAssertion, +) -> None: + """Test that a pre-recorded message is played when a pipeline isn't found.""" + assert await async_setup_component(hass, "voip", {}) + + with patch( + "homeassistant.components.voip.voip.async_get_pipeline", return_value=None + ): + protocol: PreRecordMessageProtocol = make_protocol( + hass, voip_devices, call_info + ) + + assert isinstance(protocol, PreRecordMessageProtocol) + assert protocol.file_name == "problem.pcm" + + +async def test_satellite_prepared( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, + call_info: CallInfo, + snapshot: SnapshotAssertion, +) -> None: + """Test that satellite is prepared for a call.""" + assert await async_setup_component(hass, "voip", {}) + + pipeline = assist_pipeline.Pipeline( + conversation_engine="test", + conversation_language="en", + language="en", + name="test", + stt_engine="test", + stt_language="en", + tts_engine="test", + tts_language="en", + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + + with ( + patch( + "homeassistant.components.voip.voip.async_get_pipeline", + return_value=pipeline, + ), + ): + protocol = make_protocol(hass, voip_devices, call_info) + assert protocol == satellite + + async def test_pipeline( hass: HomeAssistant, + voip_devices: VoIPDevices, voip_device: VoIPDevice, + call_info: CallInfo, ) -> None: """Test that pipeline function is called from RTP protocol.""" assert await async_setup_component(hass, "voip", {}) - def process_10ms(self, chunk): - """Anything non-zero is speech.""" - if sum(chunk) > 0: - return 1 + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + voip_user_id = satellite.config_entry.data["user"] + assert voip_user_id - return 0 + # Satellite is muted until a call begins + assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD done = asyncio.Event() # Used to test that audio queue is cleared before pipeline starts bad_chunk = bytes([1, 2, 3, 4]) - async def async_pipeline_from_audio_stream(*args, device_id, **kwargs): + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, context: Context, *args, device_id: str | None, **kwargs + ): + assert context.user_id == voip_user_id assert device_id == voip_device.device_id stt_stream = kwargs["stt_stream"] event_callback = kwargs["event_callback"] - async for _chunk in stt_stream: + in_command = False + async for chunk in stt_stream: # Stream will end when VAD detects end of "speech" - assert _chunk != bad_chunk + assert chunk != bad_chunk + if sum(chunk) > 0: + in_command = True + elif in_command: + break # done with command # Test empty data event_callback( @@ -71,6 +229,38 @@ async def test_pipeline( ) ) + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.STT_START, + data={"engine": "test", "metadata": {}}, + ) + ) + + assert satellite.state == AssistSatelliteState.LISTENING_COMMAND + + # Fake STT result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.STT_END, + data={"stt_output": {"text": "fake-text"}}, + ) + ) + + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.INTENT_START, + data={ + "engine": "test", + "language": hass.config.language, + "intent_input": "fake-text", + "conversation_id": None, + "device_id": None, + }, + ) + ) + + assert satellite.state == AssistSatelliteState.PROCESSING + # Fake intent result event_callback( assist_pipeline.PipelineEvent( @@ -83,6 +273,21 @@ async def test_pipeline( ) ) + # Fake tts result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.TTS_START, + data={ + "engine": "test", + "language": hass.config.language, + "voice": "test", + "tts_input": "fake-text", + }, + ) + ) + + assert satellite.state == AssistSatelliteState.RESPONDING + # Proceed with media output event_callback( assist_pipeline.PipelineEvent( @@ -91,6 +296,18 @@ async def test_pipeline( ) ) + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.RUN_END + ) + ) + + original_tts_response_finished = satellite.tts_response_finished + + def tts_response_finished(): + original_tts_response_finished() + done.set() + async def async_get_media_source_audio( hass: HomeAssistant, media_source_id: str, @@ -100,102 +317,56 @@ async def test_pipeline( with ( patch( - "pymicro_vad.MicroVad.Process10ms", - new=process_10ms, - ), - patch( - "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), patch( - "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", new=async_get_media_source_audio, ), + patch.object(satellite, "tts_response_finished", tts_response_finished), ): - rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( - hass, - hass.config.language, - voip_device, - Context(), - opus_payload_type=123, - listening_tone_enabled=False, - processing_tone_enabled=False, - error_tone_enabled=False, - silence_seconds=assist_pipeline.vad.VadSensitivity.to_seconds("aggressive"), - ) - rtp_protocol.transport = Mock() + satellite._tones = Tones(0) + satellite.transport = Mock() + + satellite.connection_made(satellite.transport) + assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD # Ensure audio queue is cleared before pipeline starts - rtp_protocol._audio_queue.put_nowait(bad_chunk) + satellite._audio_queue.put_nowait(bad_chunk) def send_audio(*args, **kwargs): - # Test finished successfully - done.set() + # Don't send audio + pass - rtp_protocol.send_audio = Mock(side_effect=send_audio) + satellite.send_audio = Mock(side_effect=send_audio) # silence - rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + satellite.on_chunk(bytes(_ONE_SECOND)) # "speech" - rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) - # silence (assumes aggressive VAD sensitivity) - rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + # silence + satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(1): await done.wait() - -async def test_pipeline_timeout(hass: HomeAssistant, voip_device: VoIPDevice) -> None: - """Test timeout during pipeline run.""" - assert await async_setup_component(hass, "voip", {}) - - done = asyncio.Event() - - async def async_pipeline_from_audio_stream(*args, **kwargs): - await asyncio.sleep(10) - - with ( - patch( - "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch( - "homeassistant.components.voip.voip.PipelineRtpDatagramProtocol._wait_for_speech", - return_value=True, - ), - ): - rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( - hass, - hass.config.language, - voip_device, - Context(), - opus_payload_type=123, - pipeline_timeout=0.001, - listening_tone_enabled=False, - processing_tone_enabled=False, - error_tone_enabled=False, - ) - transport = Mock(spec=["close"]) - rtp_protocol.connection_made(transport) - - # Closing the transport will cause the test to succeed - transport.close.side_effect = done.set - - # silence - rtp_protocol.on_chunk(bytes(_ONE_SECOND)) - - # Wait for mock pipeline to time out - async with asyncio.timeout(1): - await done.wait() + # Finished speaking + assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD -async def test_stt_stream_timeout(hass: HomeAssistant, voip_device: VoIPDevice) -> None: +async def test_stt_stream_timeout( + hass: HomeAssistant, voip_devices: VoIPDevices, voip_device: VoIPDevice +) -> None: """Test timeout in STT stream during pipeline run.""" assert await async_setup_component(hass, "voip", {}) + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + done = asyncio.Event() async def async_pipeline_from_audio_stream(*args, **kwargs): @@ -205,28 +376,19 @@ async def test_stt_stream_timeout(hass: HomeAssistant, voip_device: VoIPDevice) pass with patch( - "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ): - rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( - hass, - hass.config.language, - voip_device, - Context(), - opus_payload_type=123, - audio_timeout=0.001, - listening_tone_enabled=False, - processing_tone_enabled=False, - error_tone_enabled=False, - ) + satellite._tones = Tones(0) + satellite._audio_chunk_timeout = 0.001 transport = Mock(spec=["close"]) - rtp_protocol.connection_made(transport) + satellite.connection_made(transport) # Closing the transport will cause the test to succeed transport.close.side_effect = done.set # silence - rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to time out async with asyncio.timeout(1): @@ -235,26 +397,34 @@ async def test_stt_stream_timeout(hass: HomeAssistant, voip_device: VoIPDevice) async def test_tts_timeout( hass: HomeAssistant, + voip_devices: VoIPDevices, voip_device: VoIPDevice, ) -> None: """Test that TTS will time out based on its length.""" assert await async_setup_component(hass, "voip", {}) - def process_10ms(self, chunk): - """Anything non-zero is speech.""" - if sum(chunk) > 0: - return 1 - - return 0 + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) done = asyncio.Event() async def async_pipeline_from_audio_stream(*args, **kwargs): stt_stream = kwargs["stt_stream"] event_callback = kwargs["event_callback"] - async for _chunk in stt_stream: - # Stream will end when VAD detects end of "speech" - pass + in_command = False + async for chunk in stt_stream: + if sum(chunk) > 0: + in_command = True + elif in_command: + break # done with command + + # Fake STT result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.STT_END, + data={"stt_output": {"text": "fake-text"}}, + ) + ) # Fake intent result event_callback( @@ -278,15 +448,7 @@ async def test_tts_timeout( tone_bytes = bytes([1, 2, 3, 4]) - def send_audio(audio_bytes, **kwargs): - if audio_bytes == tone_bytes: - # Not TTS - return - - # Block here to force a timeout in _send_tts - time.sleep(2) - - async def async_send_audio(audio_bytes, **kwargs): + async def async_send_audio(audio_bytes: bytes, **kwargs): if audio_bytes == tone_bytes: # Not TTS return @@ -303,37 +465,22 @@ async def test_tts_timeout( with ( patch( - "pymicro_vad.MicroVad.Process10ms", - new=process_10ms, - ), - patch( - "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), patch( - "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", new=async_get_media_source_audio, ), ): - rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( - hass, - hass.config.language, - voip_device, - Context(), - opus_payload_type=123, - tts_extra_timeout=0.001, - listening_tone_enabled=True, - processing_tone_enabled=True, - error_tone_enabled=True, - silence_seconds=assist_pipeline.vad.VadSensitivity.to_seconds("relaxed"), - ) - rtp_protocol._tone_bytes = tone_bytes - rtp_protocol._processing_bytes = tone_bytes - rtp_protocol._error_bytes = tone_bytes - rtp_protocol.transport = Mock() - rtp_protocol.send_audio = Mock() + satellite._tts_extra_timeout = 0.001 + for tone in Tones: + satellite._tone_bytes[tone] = tone_bytes - original_send_tts = rtp_protocol._send_tts + satellite.transport = Mock() + satellite.send_audio = Mock() + + original_send_tts = satellite._send_tts async def send_tts(*args, **kwargs): # Call original then end test successfully @@ -342,17 +489,17 @@ async def test_tts_timeout( done.set() - rtp_protocol._async_send_audio = AsyncMock(side_effect=async_send_audio) # type: ignore[method-assign] - rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + satellite._async_send_audio = AsyncMock(side_effect=async_send_audio) # type: ignore[method-assign] + satellite._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] # silence - rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + satellite.on_chunk(bytes(_ONE_SECOND)) # "speech" - rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) - # silence (assumes relaxed VAD sensitivity) - rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + # silence + satellite.on_chunk(bytes(_ONE_SECOND)) # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(1): @@ -361,26 +508,34 @@ async def test_tts_timeout( async def test_tts_wrong_extension( hass: HomeAssistant, + voip_devices: VoIPDevices, voip_device: VoIPDevice, ) -> None: """Test that TTS will only stream WAV audio.""" assert await async_setup_component(hass, "voip", {}) - def process_10ms(self, chunk): - """Anything non-zero is speech.""" - if sum(chunk) > 0: - return 1 - - return 0 + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) done = asyncio.Event() async def async_pipeline_from_audio_stream(*args, **kwargs): stt_stream = kwargs["stt_stream"] event_callback = kwargs["event_callback"] - async for _chunk in stt_stream: - # Stream will end when VAD detects end of "speech" - pass + in_command = False + async for chunk in stt_stream: + if sum(chunk) > 0: + in_command = True + elif in_command: + break # done with command + + # Fake STT result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.STT_END, + data={"stt_output": {"text": "fake-text"}}, + ) + ) # Fake intent result event_callback( @@ -411,28 +566,17 @@ async def test_tts_wrong_extension( with ( patch( - "pymicro_vad.MicroVad.Process10ms", - new=process_10ms, - ), - patch( - "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), patch( - "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", new=async_get_media_source_audio, ), ): - rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( - hass, - hass.config.language, - voip_device, - Context(), - opus_payload_type=123, - ) - rtp_protocol.transport = Mock() + satellite.transport = Mock() - original_send_tts = rtp_protocol._send_tts + original_send_tts = satellite._send_tts async def send_tts(*args, **kwargs): # Call original then end test successfully @@ -441,16 +585,16 @@ async def test_tts_wrong_extension( done.set() - rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + satellite._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] # silence - rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + satellite.on_chunk(bytes(_ONE_SECOND)) # "speech" - rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) # silence (assumes relaxed VAD sensitivity) - rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + satellite.on_chunk(bytes(_ONE_SECOND * 4)) # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(1): @@ -459,26 +603,34 @@ async def test_tts_wrong_extension( async def test_tts_wrong_wav_format( hass: HomeAssistant, + voip_devices: VoIPDevices, voip_device: VoIPDevice, ) -> None: """Test that TTS will only stream WAV audio with a specific format.""" assert await async_setup_component(hass, "voip", {}) - def process_10ms(self, chunk): - """Anything non-zero is speech.""" - if sum(chunk) > 0: - return 1 - - return 0 + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) done = asyncio.Event() async def async_pipeline_from_audio_stream(*args, **kwargs): stt_stream = kwargs["stt_stream"] event_callback = kwargs["event_callback"] - async for _chunk in stt_stream: - # Stream will end when VAD detects end of "speech" - pass + in_command = False + async for chunk in stt_stream: + if sum(chunk) > 0: + in_command = True + elif in_command: + break # done with command + + # Fake STT result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.STT_END, + data={"stt_output": {"text": "fake-text"}}, + ) + ) # Fake intent result event_callback( @@ -516,28 +668,17 @@ async def test_tts_wrong_wav_format( with ( patch( - "pymicro_vad.MicroVad.Process10ms", - new=process_10ms, - ), - patch( - "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), patch( - "homeassistant.components.voip.voip.tts.async_get_media_source_audio", + "homeassistant.components.voip.assist_satellite.tts.async_get_media_source_audio", new=async_get_media_source_audio, ), ): - rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( - hass, - hass.config.language, - voip_device, - Context(), - opus_payload_type=123, - ) - rtp_protocol.transport = Mock() + satellite.transport = Mock() - original_send_tts = rtp_protocol._send_tts + original_send_tts = satellite._send_tts async def send_tts(*args, **kwargs): # Call original then end test successfully @@ -546,16 +687,16 @@ async def test_tts_wrong_wav_format( done.set() - rtp_protocol._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] + satellite._send_tts = AsyncMock(side_effect=send_tts) # type: ignore[method-assign] # silence - rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + satellite.on_chunk(bytes(_ONE_SECOND)) # "speech" - rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) # silence (assumes relaxed VAD sensitivity) - rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + satellite.on_chunk(bytes(_ONE_SECOND * 4)) # Wait for mock pipeline to exhaust the audio stream async with asyncio.timeout(1): @@ -564,24 +705,32 @@ async def test_tts_wrong_wav_format( async def test_empty_tts_output( hass: HomeAssistant, + voip_devices: VoIPDevices, voip_device: VoIPDevice, ) -> None: """Test that TTS will not stream when output is empty.""" assert await async_setup_component(hass, "voip", {}) - def process_10ms(self, chunk): - """Anything non-zero is speech.""" - if sum(chunk) > 0: - return 1 - - return 0 + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) async def async_pipeline_from_audio_stream(*args, **kwargs): stt_stream = kwargs["stt_stream"] event_callback = kwargs["event_callback"] - async for _chunk in stt_stream: - # Stream will end when VAD detects end of "speech" - pass + in_command = False + async for chunk in stt_stream: + if sum(chunk) > 0: + in_command = True + elif in_command: + break # done with command + + # Fake STT result + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.STT_END, + data={"stt_output": {"text": "fake-text"}}, + ) + ) # Fake intent result event_callback( @@ -605,37 +754,78 @@ async def test_empty_tts_output( with ( patch( - "pymicro_vad.MicroVad.Process10ms", - new=process_10ms, - ), - patch( - "homeassistant.components.voip.voip.async_pipeline_from_audio_stream", + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", new=async_pipeline_from_audio_stream, ), patch( - "homeassistant.components.voip.voip.PipelineRtpDatagramProtocol._send_tts", + "homeassistant.components.voip.assist_satellite.VoipAssistSatellite._send_tts", ) as mock_send_tts, ): - rtp_protocol = voip.voip.PipelineRtpDatagramProtocol( - hass, - hass.config.language, - voip_device, - Context(), - opus_payload_type=123, - ) - rtp_protocol.transport = Mock() + satellite.transport = Mock() # silence - rtp_protocol.on_chunk(bytes(_ONE_SECOND)) + satellite.on_chunk(bytes(_ONE_SECOND)) # "speech" - rtp_protocol.on_chunk(bytes([255] * _ONE_SECOND * 2)) + satellite.on_chunk(bytes([255] * _ONE_SECOND * 2)) # silence (assumes relaxed VAD sensitivity) - rtp_protocol.on_chunk(bytes(_ONE_SECOND * 4)) + satellite.on_chunk(bytes(_ONE_SECOND * 4)) # Wait for mock pipeline to finish async with asyncio.timeout(1): - await rtp_protocol._tts_done.wait() + await satellite._tts_done.wait() mock_send_tts.assert_not_called() + + +async def test_pipeline_error( + hass: HomeAssistant, + voip_devices: VoIPDevices, + voip_device: VoIPDevice, + snapshot: SnapshotAssertion, +) -> None: + """Test that a pipeline error causes the error tone to be played.""" + assert await async_setup_component(hass, "voip", {}) + + satellite = async_get_satellite_entity(hass, voip.DOMAIN, voip_device.voip_id) + assert isinstance(satellite, VoipAssistSatellite) + + done = asyncio.Event() + played_audio_bytes = b"" + + async def async_pipeline_from_audio_stream(*args, **kwargs): + # Fake error + event_callback = kwargs["event_callback"] + event_callback( + assist_pipeline.PipelineEvent( + type=assist_pipeline.PipelineEventType.ERROR, + data={"code": "error-code", "message": "error message"}, + ) + ) + + async def async_send_audio(audio_bytes: bytes, **kwargs): + nonlocal played_audio_bytes + + # Should be error.pcm from components/voip + played_audio_bytes = audio_bytes + done.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + ): + satellite._tones = Tones.ERROR + satellite.transport = Mock() + satellite._async_send_audio = AsyncMock(side_effect=async_send_audio) # type: ignore[method-assign] + + satellite.on_chunk(bytes(_ONE_SECOND)) + + # Wait for error tone to be played + async with asyncio.timeout(1): + await done.wait() + + assert sum(played_audio_bytes) > 0 + assert played_audio_bytes == snapshot() From e58cf00a96359ba530025e6c2ec2e2d7780dd40a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 6 Sep 2024 16:18:24 +0200 Subject: [PATCH 0339/1309] Remove deprecated aux_heat from ecobee (#125246) --- homeassistant/components/ecobee/climate.py | 48 ------------- tests/components/ecobee/test_climate.py | 84 +--------------------- tests/components/ecobee/test_repairs.py | 35 --------- 3 files changed, 2 insertions(+), 165 deletions(-) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index 8dcc7285590..f9119f05394 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -36,7 +36,6 @@ from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util.unit_conversion import TemperatureConverter from . import EcobeeData @@ -387,8 +386,6 @@ class Thermostat(ClimateEntity): supported = SUPPORT_FLAGS if self.has_humidifier_control: supported = supported | ClimateEntityFeature.TARGET_HUMIDITY - if self.has_aux_heat: - supported = supported | ClimateEntityFeature.AUX_HEAT if len(self.hvac_modes) > 1 and HVACMode.OFF in self.hvac_modes: supported = ( supported | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON @@ -449,11 +446,6 @@ class Thermostat(ClimateEntity): and self.settings.get("humidifierMode") == HUMIDIFIER_MANUAL_MODE ) - @property - def has_aux_heat(self) -> bool: - """Return true if the ecobee has a heat pump.""" - return bool(self.settings.get(HAS_HEAT_PUMP)) - @property def target_humidity(self) -> int | None: """Return the desired humidity set point.""" @@ -573,46 +565,6 @@ class Thermostat(ClimateEntity): "fan_min_on_time": self.settings["fanMinOnTime"], } - @property - def is_aux_heat(self) -> bool: - """Return true if aux heater.""" - return self.settings["hvacMode"] == ECOBEE_AUX_HEAT_ONLY - - async def async_turn_aux_heat_on(self) -> None: - """Turn auxiliary heater on.""" - async_create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2024.10.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - _LOGGER.debug("Setting HVAC mode to auxHeatOnly to turn on aux heat") - self._last_hvac_mode_before_aux_heat = self.hvac_mode - await self.hass.async_add_executor_job( - self.data.ecobee.set_hvac_mode, self.thermostat_index, ECOBEE_AUX_HEAT_ONLY - ) - self.update_without_throttle = True - - async def async_turn_aux_heat_off(self) -> None: - """Turn auxiliary heater off.""" - async_create_issue( - self.hass, - DOMAIN, - "migrate_aux_heat", - breaks_in_ha_version="2024.10.0", - is_fixable=True, - is_persistent=True, - translation_key="migrate_aux_heat", - severity=IssueSeverity.WARNING, - ) - _LOGGER.debug("Setting HVAC mode to last mode to disable aux heat") - await self.async_set_hvac_mode(self._last_hvac_mode_before_aux_heat) - self.update_without_throttle = True - def set_preset_mode(self, preset_mode: str) -> None: """Activate a preset.""" preset_mode = HASS_TO_ECOBEE_PRESET.get(preset_mode, preset_mode) diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py index 1c9dcec0ad2..559153874a5 100644 --- a/tests/components/ecobee/test_climate.py +++ b/tests/components/ecobee/test_climate.py @@ -1,24 +1,16 @@ """The test for the Ecobee thermostat module.""" -import copy from http import HTTPStatus from unittest import mock -from unittest.mock import MagicMock import pytest from homeassistant import const -from homeassistant.components import climate from homeassistant.components.climate import ClimateEntityFeature -from homeassistant.components.ecobee.climate import ( - ECOBEE_AUX_HEAT_ONLY, - PRESET_AWAY_INDEFINITELY, - Thermostat, -) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF +from homeassistant.components.ecobee.climate import PRESET_AWAY_INDEFINITELY, Thermostat +from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF from homeassistant.core import HomeAssistant -from . import GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP from .common import setup_platform ENTITY_ID = "climate.ecobee" @@ -111,25 +103,6 @@ async def test_aux_heat_not_supported_by_default(hass: HomeAssistant) -> None: ) -async def test_aux_heat_supported_with_heat_pump(hass: HomeAssistant) -> None: - """Aux Heat should be supported if thermostat has heatpump.""" - mock_get_thermostat = mock.Mock() - mock_get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP - with mock.patch("pyecobee.Ecobee.get_thermostat", mock_get_thermostat): - await setup_platform(hass, const.Platform.CLIMATE) - state = hass.states.get(ENTITY_ID) - assert ( - state.attributes.get(ATTR_SUPPORTED_FEATURES) - == ClimateEntityFeature.PRESET_MODE - | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.AUX_HEAT - | ClimateEntityFeature.TURN_OFF - | ClimateEntityFeature.TURN_ON - ) - - async def test_current_temperature(ecobee_fixture, thermostat) -> None: """Test current temperature.""" assert thermostat.current_temperature == 30 @@ -255,29 +228,6 @@ async def test_extra_state_attributes(ecobee_fixture, thermostat) -> None: } -async def test_is_aux_heat_on(hass: HomeAssistant) -> None: - """Test aux heat property is only enabled for auxHeatOnly.""" - mock_get_thermostat = mock.Mock() - mock_get_thermostat.return_value = copy.deepcopy( - GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP - ) - mock_get_thermostat.return_value["settings"]["hvacMode"] = "auxHeatOnly" - with mock.patch("pyecobee.Ecobee.get_thermostat", mock_get_thermostat): - await setup_platform(hass, const.Platform.CLIMATE) - state = hass.states.get(ENTITY_ID) - assert state.attributes[climate.ATTR_AUX_HEAT] == "on" - - -async def test_is_aux_heat_off(hass: HomeAssistant) -> None: - """Test aux heat property is only enabled for auxHeatOnly.""" - mock_get_thermostat = mock.Mock() - mock_get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP - with mock.patch("pyecobee.Ecobee.get_thermostat", mock_get_thermostat): - await setup_platform(hass, const.Platform.CLIMATE) - state = hass.states.get(ENTITY_ID) - assert state.attributes[climate.ATTR_AUX_HEAT] == "off" - - async def test_set_temperature(ecobee_fixture, thermostat, data) -> None: """Test set temperature.""" # Auto -> Auto @@ -400,36 +350,6 @@ async def test_set_fan_mode_auto(thermostat, data) -> None: ) -async def test_turn_aux_heat_on(hass: HomeAssistant, mock_ecobee: MagicMock) -> None: - """Test when aux heat is set on. This must change the HVAC mode.""" - mock_ecobee.get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP - mock_ecobee.thermostats = [GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP] - await setup_platform(hass, const.Platform.CLIMATE) - await hass.services.async_call( - climate.DOMAIN, - climate.SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: ENTITY_ID, climate.ATTR_AUX_HEAT: True}, - blocking=True, - ) - assert mock_ecobee.set_hvac_mode.call_count == 1 - assert mock_ecobee.set_hvac_mode.call_args == mock.call(0, ECOBEE_AUX_HEAT_ONLY) - - -async def test_turn_aux_heat_off(hass: HomeAssistant, mock_ecobee: MagicMock) -> None: - """Test when aux heat is tuned off. Must change HVAC mode back to last used.""" - mock_ecobee.get_thermostat.return_value = GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP - mock_ecobee.thermostats = [GENERIC_THERMOSTAT_INFO_WITH_HEATPUMP] - await setup_platform(hass, const.Platform.CLIMATE) - await hass.services.async_call( - climate.DOMAIN, - climate.SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: ENTITY_ID, climate.ATTR_AUX_HEAT: False}, - blocking=True, - ) - assert mock_ecobee.set_hvac_mode.call_count == 1 - assert mock_ecobee.set_hvac_mode.call_args == mock.call(0, "auto") - - async def test_preset_indefinite_away(ecobee_fixture, thermostat) -> None: """Test indefinite away showing correctly, and not as temporary away.""" ecobee_fixture["program"]["currentClimateRef"] = "away" diff --git a/tests/components/ecobee/test_repairs.py b/tests/components/ecobee/test_repairs.py index 1473f8eb3a1..43b3cc5b7d0 100644 --- a/tests/components/ecobee/test_repairs.py +++ b/tests/components/ecobee/test_repairs.py @@ -3,11 +3,6 @@ from http import HTTPStatus from unittest.mock import MagicMock -from homeassistant.components.climate import ( - ATTR_AUX_HEAT, - DOMAIN as CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, -) from homeassistant.components.ecobee import DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.repairs.issue_handler import ( @@ -17,7 +12,6 @@ from homeassistant.components.repairs.websocket_api import ( RepairsFlowIndexView, RepairsFlowResourceView, ) -from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -83,32 +77,3 @@ async def test_ecobee_notify_repair_flow( issue_id=f"migrate_notify_{DOMAIN}_{DOMAIN}", ) assert len(issue_registry.issues) == 0 - - -async def test_ecobee_aux_heat_repair_flow( - hass: HomeAssistant, - mock_ecobee: MagicMock, - hass_client: ClientSessionGenerator, - issue_registry: ir.IssueRegistry, -) -> None: - """Test the ecobee aux_heat service repair flow is triggered.""" - await setup_platform(hass, CLIMATE_DOMAIN) - await async_process_repairs_platforms(hass) - - ENTITY_ID = "climate.ecobee2" - - # Simulate legacy service being used - assert hass.services.has_service(CLIMATE_DOMAIN, SERVICE_SET_AUX_HEAT) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_AUX_HEAT, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_AUX_HEAT: True}, - blocking=True, - ) - - # Assert the issue is present - assert issue_registry.async_get_issue( - domain="ecobee", - issue_id="migrate_aux_heat", - ) - assert len(issue_registry.issues) == 1 From c4cc158a7794e64da4cb0a1bd09c67ea391e5395 Mon Sep 17 00:00:00 2001 From: Alexandre TRUPIN <72858385+AlexT59@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:18:47 +0200 Subject: [PATCH 0340/1309] Bump sfrbox-api to 0.0.10 (#125405) * bump sfr_box requirement to 0.0.10 * upate manifest file * Handle None values --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/sfr_box/__init__.py | 3 ++ .../components/sfr_box/binary_sensor.py | 14 +++++--- homeassistant/components/sfr_box/button.py | 8 +++-- .../components/sfr_box/config_flow.py | 4 ++- .../components/sfr_box/coordinator.py | 6 ++-- .../components/sfr_box/diagnostics.py | 31 ++++++++---------- .../components/sfr_box/manifest.json | 2 +- homeassistant/components/sfr_box/sensor.py | 32 +++++++++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../sfr_box/snapshots/test_diagnostics.ambr | 4 +-- 11 files changed, 68 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index dade1af0e52..d386c670365 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from typing import TYPE_CHECKING from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError @@ -46,6 +47,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Preload system information await data.system.async_config_entry_first_refresh() system_info = data.system.data + if TYPE_CHECKING: + assert system_info is not None # Preload other coordinators (based on net infrastructure) tasks = [data.wan.async_config_entry_first_refresh()] diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index b299af33513..4ef5e87761d 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING from sfrbox_api.models import DslInfo, FtthInfo, SystemInfo, WanInfo @@ -65,19 +66,22 @@ async def async_setup_entry( ) -> None: """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] + system_info = data.system.data + if TYPE_CHECKING: + assert system_info is not None entities: list[SFRBoxBinarySensor] = [ - SFRBoxBinarySensor(data.wan, description, data.system.data) + SFRBoxBinarySensor(data.wan, description, system_info) for description in WAN_SENSOR_TYPES ] - if (net_infra := data.system.data.net_infra) == "adsl": + if (net_infra := system_info.net_infra) == "adsl": entities.extend( - SFRBoxBinarySensor(data.dsl, description, data.system.data) + SFRBoxBinarySensor(data.dsl, description, system_info) for description in DSL_SENSOR_TYPES ) elif net_infra == "ftth": entities.extend( - SFRBoxBinarySensor(data.ftth, description, data.system.data) + SFRBoxBinarySensor(data.ftth, description, system_info) for description in FTTH_SENSOR_TYPES ) @@ -111,4 +115,6 @@ class SFRBoxBinarySensor[_T]( @property def is_on(self) -> bool | None: """Return the native value of the device.""" + if self.coordinator.data is None: + return None return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index f6d3100d692..bddb1e8f926 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from functools import wraps -from typing import Any, Concatenate +from typing import TYPE_CHECKING, Any, Concatenate from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxError @@ -69,10 +69,12 @@ async def async_setup_entry( ) -> None: """Set up the buttons.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] + system_info = data.system.data + if TYPE_CHECKING: + assert system_info is not None entities = [ - SFRBoxButton(data.box, description, data.system.data) - for description in BUTTON_TYPES + SFRBoxButton(data.box, description, system_info) for description in BUTTON_TYPES ] async_add_entities(entities) diff --git a/homeassistant/components/sfr_box/config_flow.py b/homeassistant/components/sfr_box/config_flow.py index f7d72c01ccd..a4f14e59069 100644 --- a/homeassistant/components/sfr_box/config_flow.py +++ b/homeassistant/components/sfr_box/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any from sfrbox_api.bridge import SFRBox from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError @@ -51,6 +51,8 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN): except SFRBoxError: errors["base"] = "cannot_connect" else: + if TYPE_CHECKING: + assert system_info is not None await self.async_set_unique_id(system_info.mac_addr) self._abort_if_unique_id_configured() self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) diff --git a/homeassistant/components/sfr_box/coordinator.py b/homeassistant/components/sfr_box/coordinator.py index af3195723f4..5877d5a454a 100644 --- a/homeassistant/components/sfr_box/coordinator.py +++ b/homeassistant/components/sfr_box/coordinator.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) _SCAN_INTERVAL = timedelta(minutes=1) -class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): +class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT | None]): """Coordinator to manage data updates.""" def __init__( @@ -23,14 +23,14 @@ class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): hass: HomeAssistant, box: SFRBox, name: str, - method: Callable[[SFRBox], Coroutine[Any, Any, _DataT]], + method: Callable[[SFRBox], Coroutine[Any, Any, _DataT | None]], ) -> None: """Initialize coordinator.""" self.box = box self._method = method super().__init__(hass, _LOGGER, name=name, update_interval=_SCAN_INTERVAL) - async def _async_update_data(self) -> _DataT: + async def _async_update_data(self) -> _DataT | None: """Update data.""" try: return await self._method(self.box) diff --git a/homeassistant/components/sfr_box/diagnostics.py b/homeassistant/components/sfr_box/diagnostics.py index b5aca834af5..0553bfe4233 100644 --- a/homeassistant/components/sfr_box/diagnostics.py +++ b/homeassistant/components/sfr_box/diagnostics.py @@ -3,7 +3,7 @@ from __future__ import annotations import dataclasses -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry @@ -12,9 +12,18 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN from .models import DomainData +if TYPE_CHECKING: + from _typeshed import DataclassInstance + TO_REDACT = {"mac_addr", "serial_number", "ip_addr", "ipv6_addr"} +def _async_redact_data(obj: DataclassInstance | None) -> dict[str, Any] | None: + if obj is None: + return None + return async_redact_data(dataclasses.asdict(obj), TO_REDACT) + + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: @@ -27,21 +36,9 @@ async def async_get_config_entry_diagnostics( "data": dict(entry.data), }, "data": { - "dsl": async_redact_data( - dataclasses.asdict(await data.system.box.dsl_get_info()), - TO_REDACT, - ), - "ftth": async_redact_data( - dataclasses.asdict(await data.system.box.ftth_get_info()), - TO_REDACT, - ), - "system": async_redact_data( - dataclasses.asdict(await data.system.box.system_get_info()), - TO_REDACT, - ), - "wan": async_redact_data( - dataclasses.asdict(await data.system.box.wan_get_info()), - TO_REDACT, - ), + "dsl": _async_redact_data(await data.system.box.dsl_get_info()), + "ftth": _async_redact_data(await data.system.box.ftth_get_info()), + "system": _async_redact_data(await data.system.box.system_get_info()), + "wan": _async_redact_data(await data.system.box.wan_get_info()), }, } diff --git a/homeassistant/components/sfr_box/manifest.json b/homeassistant/components/sfr_box/manifest.json index bf4d91a50f1..cd42997cec5 100644 --- a/homeassistant/components/sfr_box/manifest.json +++ b/homeassistant/components/sfr_box/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sfr_box", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["sfrbox-api==0.0.8"] + "requirements": ["sfrbox-api==0.0.10"] } diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index d19ff82b393..ee3285a8f38 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING from sfrbox_api.models import DslInfo, SystemInfo, WanInfo @@ -129,7 +130,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( "unknown", ], translation_key="dsl_line_status", - value_fn=lambda x: x.line_status.lower().replace(" ", "_"), + value_fn=lambda x: _value_to_option(x.line_status), ), SFRBoxSensorEntityDescription[DslInfo]( key="training", @@ -149,7 +150,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = ( "unknown", ], translation_key="dsl_training", - value_fn=lambda x: x.training.lower().replace(" ", "_").replace(".", "_"), + value_fn=lambda x: _value_to_option(x.training), ), ) SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( @@ -181,7 +182,7 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - value_fn=lambda x: None if x.temperature is None else x.temperature / 1000, + value_fn=lambda x: _get_temperature(x.temperature), ), ) WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = ( @@ -203,23 +204,38 @@ WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = ( ) +def _value_to_option(value: str | None) -> str | None: + if value is None: + return value + return value.lower().replace(" ", "_").replace(".", "_") + + +def _get_temperature(value: float | None) -> float | None: + if value is None or value < 1000: + return value + return value / 1000 + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the sensors.""" data: DomainData = hass.data[DOMAIN][entry.entry_id] + system_info = data.system.data + if TYPE_CHECKING: + assert system_info is not None entities: list[SFRBoxSensor] = [ - SFRBoxSensor(data.system, description, data.system.data) + SFRBoxSensor(data.system, description, system_info) for description in SYSTEM_SENSOR_TYPES ] entities.extend( - SFRBoxSensor(data.wan, description, data.system.data) + SFRBoxSensor(data.wan, description, system_info) for description in WAN_SENSOR_TYPES ) - if data.system.data.net_infra == "adsl": + if system_info.net_infra == "adsl": entities.extend( - SFRBoxSensor(data.dsl, description, data.system.data) + SFRBoxSensor(data.dsl, description, system_info) for description in DSL_SENSOR_TYPES ) @@ -251,4 +267,6 @@ class SFRBoxSensor[_T](CoordinatorEntity[SFRDataUpdateCoordinator[_T]], SensorEn @property def native_value(self) -> StateType: """Return the native value of the device.""" + if self.coordinator.data is None: + return None return self.entity_description.value_fn(self.coordinator.data) diff --git a/requirements_all.txt b/requirements_all.txt index 8a5c6a34a07..00ae78cc4bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2616,7 +2616,7 @@ sensoterra==2.0.1 sentry-sdk==1.40.3 # homeassistant.components.sfr_box -sfrbox-api==0.0.8 +sfrbox-api==0.0.10 # homeassistant.components.sharkiq sharkiq==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 614fb06b132..6faedc8ec8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2074,7 +2074,7 @@ sensoterra==2.0.1 sentry-sdk==1.40.3 # homeassistant.components.sfr_box -sfrbox-api==0.0.8 +sfrbox-api==0.0.10 # homeassistant.components.sharkiq sharkiq==1.0.2 diff --git a/tests/components/sfr_box/snapshots/test_diagnostics.ambr b/tests/components/sfr_box/snapshots/test_diagnostics.ambr index 22a914f8a79..69139c2c374 100644 --- a/tests/components/sfr_box/snapshots/test_diagnostics.ambr +++ b/tests/components/sfr_box/snapshots/test_diagnostics.ambr @@ -31,7 +31,7 @@ 'product_id': 'NB6VAC-FXC-r0', 'refclient': '', 'serial_number': '**REDACTED**', - 'temperature': 27560, + 'temperature': 27560.0, 'uptime': 2353575, 'version_bootloader': 'NB6VAC-BOOTLOADER-R4.0.8', 'version_dsldriver': 'NB6VAC-XDSL-A2pv6F039p', @@ -90,7 +90,7 @@ 'product_id': 'NB6VAC-FXC-r0', 'refclient': '', 'serial_number': '**REDACTED**', - 'temperature': 27560, + 'temperature': 27560.0, 'uptime': 2353575, 'version_bootloader': 'NB6VAC-BOOTLOADER-R4.0.8', 'version_dsldriver': 'NB6VAC-XDSL-A2pv6F039p', From f6c681eb5d7eb87c3fb7d8e630738166e064371c Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Fri, 6 Sep 2024 07:46:06 -0700 Subject: [PATCH 0341/1309] Remove support for area, device, or entity targets for screenlogic actions (#123432) * Remove non-configentry service target * Remove unneeded tests * Remove unneeded issue strings --- .../components/screenlogic/services.py | 53 ++------------ .../components/screenlogic/services.yaml | 2 +- .../components/screenlogic/strings.json | 13 ---- tests/components/screenlogic/test_services.py | 72 ------------------- 4 files changed, 7 insertions(+), 133 deletions(-) diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index 3177f27ab2a..44d8ad3ed81 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -10,12 +10,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_validation as cv, - issue_registry as ir, - selector, -) -from homeassistant.helpers.service import async_extract_config_entry_ids +from homeassistant.helpers import selector from .const import ( ATTR_COLOR_MODE, @@ -44,19 +39,10 @@ BASE_SERVICE_SCHEMA = vol.Schema( } ) -SET_COLOR_MODE_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), - **cv.ENTITY_SERVICE_FIELDS, - vol.Required(ATTR_COLOR_MODE): vol.In(SUPPORTED_COLOR_MODES), - } - ), - cv.has_at_least_one_key(ATTR_CONFIG_ENTRY, *cv.ENTITY_SERVICE_FIELDS), +SET_COLOR_MODE_SCHEMA = BASE_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_COLOR_MODE): vol.In(SUPPORTED_COLOR_MODES), + } ) TURN_ON_SUPER_CHLOR_SCHEMA = BASE_SERVICE_SCHEMA.extend( @@ -72,37 +58,10 @@ TURN_ON_SUPER_CHLOR_SCHEMA = BASE_SERVICE_SCHEMA.extend( def async_load_screenlogic_services(hass: HomeAssistant): """Set up services for the ScreenLogic integration.""" - async def extract_screenlogic_config_entry_ids(service_call: ServiceCall): - if not ( - screenlogic_entry_ids := await async_extract_config_entry_ids( - hass, service_call - ) - ): - raise ServiceValidationError( - f"Failed to call service '{service_call.service}'. Config entry for " - "target not found" - ) - return screenlogic_entry_ids - async def get_coordinators( service_call: ServiceCall, ) -> list[ScreenlogicDataUpdateCoordinator]: - entry_ids: set[str] - if entry_id := service_call.data.get(ATTR_CONFIG_ENTRY): - entry_ids = {entry_id} - else: - ir.async_create_issue( - hass, - DOMAIN, - "service_target_deprecation", - breaks_in_ha_version="2024.8.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.WARNING, - translation_key="service_target_deprecation", - ) - entry_ids = await extract_screenlogic_config_entry_ids(service_call) - + entry_ids = {service_call.data[ATTR_CONFIG_ENTRY]} coordinators: list[ScreenlogicDataUpdateCoordinator] = [] for entry_id in entry_ids: config_entry = cast( diff --git a/homeassistant/components/screenlogic/services.yaml b/homeassistant/components/screenlogic/services.yaml index f05537640ca..1dc2e0339f2 100644 --- a/homeassistant/components/screenlogic/services.yaml +++ b/homeassistant/components/screenlogic/services.yaml @@ -2,7 +2,7 @@ set_color_mode: fields: config_entry: - required: false + required: true selector: config_entry: integration: screenlogic diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index 2370d78a6ce..91395a0e86d 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -75,18 +75,5 @@ } } } - }, - "issues": { - "service_target_deprecation": { - "title": "Deprecating use of target for ScreenLogic actions", - "fix_flow": { - "step": { - "confirm": { - "title": "Deprecating target for ScreenLogic actions", - "description": "Use of an Area, Device, or Entity as a target for ScreenLogic actions is being deprecated. Instead, use `config_entry` with the entry_id of the desired ScreenLogic integration.\n\nPlease update your automations and scripts and select **submit** to fix this issue." - } - } - } - } } } diff --git a/tests/components/screenlogic/test_services.py b/tests/components/screenlogic/test_services.py index 0fc79fad0e5..8a414ba2596 100644 --- a/tests/components/screenlogic/test_services.py +++ b/tests/components/screenlogic/test_services.py @@ -18,11 +18,9 @@ from homeassistant.components.screenlogic.const import ( SERVICE_STOP_SUPER_CHLORINATION, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr -from homeassistant.util import slugify from . import ( DATA_FULL_CHEM, @@ -102,22 +100,6 @@ async def setup_screenlogic_services_fixture( }, None, ), - ( - { - ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower(), - }, - { - ATTR_AREA_ID: MOCK_DEVICE_AREA, - }, - ), - ( - { - ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower(), - }, - { - ATTR_ENTITY_ID: f"{Platform.SENSOR}.{slugify(f'{MOCK_ADAPTER_NAME} Air Temperature')}", - }, - ), ], ) async def test_service_set_color_mode( @@ -148,30 +130,6 @@ async def test_service_set_color_mode( mocked_async_set_color_lights.assert_awaited_once() -async def test_service_set_color_mode_with_device( - hass: HomeAssistant, - service_fixture: dict[str, Any], -) -> None: - """Test set_color_mode service with a device target.""" - mocked_async_set_color_lights: AsyncMock = service_fixture["gateway"][ - "async_set_color_lights" - ] - - assert hass.services.has_service(DOMAIN, SERVICE_SET_COLOR_MODE) - - sl_device: dr.DeviceEntry = service_fixture["device"] - - await hass.services.async_call( - DOMAIN, - SERVICE_SET_COLOR_MODE, - service_data={ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower()}, - blocking=True, - target={ATTR_DEVICE_ID: sl_device.id}, - ) - - mocked_async_set_color_lights.assert_awaited_once() - - @pytest.mark.parametrize( ("data", "target", "error_msg"), [ @@ -193,36 +151,6 @@ async def test_service_set_color_mode_with_device( f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry " "'test' is not a screenlogic config", ), - ( - { - ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower(), - }, - { - ATTR_AREA_ID: "invalidareaid", - }, - f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry for " - "target not found", - ), - ( - { - ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower(), - }, - { - ATTR_DEVICE_ID: "invaliddeviceid", - }, - f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry for " - "target not found", - ), - ( - { - ATTR_COLOR_MODE: COLOR_MODE.ALL_ON.name.lower(), - }, - { - ATTR_ENTITY_ID: "sensor.invalidentityid", - }, - f"Failed to call service '{SERVICE_SET_COLOR_MODE}'. Config entry for " - "target not found", - ), ], ) async def test_service_set_color_mode_error( From b6d45a5a07d6e5cb7fb294caaf43f297a905d6c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jaworski?= Date: Fri, 6 Sep 2024 16:46:54 +0200 Subject: [PATCH 0342/1309] Bump blebox_uniapi to v2.5.0 (#124298) blebox: bump blebox_uniapi to v2.5.0 --- homeassistant/components/blebox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index a2c6495cc56..83ec27f6eef 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/blebox", "iot_class": "local_polling", "loggers": ["blebox_uniapi"], - "requirements": ["blebox-uniapi==2.4.2"], + "requirements": ["blebox-uniapi==2.5.0"], "zeroconf": ["_bbxsrv._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 00ae78cc4bf..be2ddf6a97c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -575,7 +575,7 @@ bleak-retry-connector==3.5.0 bleak==0.22.2 # homeassistant.components.blebox -blebox-uniapi==2.4.2 +blebox-uniapi==2.5.0 # homeassistant.components.blink blinkpy==0.23.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6faedc8ec8c..b4472ca9144 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -506,7 +506,7 @@ bleak-retry-connector==3.5.0 bleak==0.22.2 # homeassistant.components.blebox -blebox-uniapi==2.4.2 +blebox-uniapi==2.5.0 # homeassistant.components.blink blinkpy==0.23.0 From f126a6024ed7d4ac57475afc7df2370ce7ea3eb7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 6 Sep 2024 10:48:42 -0400 Subject: [PATCH 0343/1309] Migrate ESPHome to assist satellite (#125383) * Migrate ESPHome to assist satellite * Address comments --- .../components/esphome/assist_satellite.py | 504 +++++++++ homeassistant/components/esphome/manager.py | 114 +-- .../components/esphome/voice_assistant.py | 479 --------- tests/components/esphome/conftest.py | 60 -- .../esphome/test_assist_satellite.py | 822 +++++++++++++++ tests/components/esphome/test_manager.py | 108 +- .../esphome/test_voice_assistant.py | 964 ------------------ 7 files changed, 1337 insertions(+), 1714 deletions(-) create mode 100644 homeassistant/components/esphome/assist_satellite.py delete mode 100644 homeassistant/components/esphome/voice_assistant.py create mode 100644 tests/components/esphome/test_assist_satellite.py delete mode 100644 tests/components/esphome/test_voice_assistant.py diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py new file mode 100644 index 00000000000..48bb9ec5507 --- /dev/null +++ b/homeassistant/components/esphome/assist_satellite.py @@ -0,0 +1,504 @@ +"""Support for assist satellites in ESPHome.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterable +from functools import partial +import io +import logging +import socket +from typing import Any, cast +import wave + +from aioesphomeapi import ( + VoiceAssistantAudioSettings, + VoiceAssistantCommandFlag, + VoiceAssistantEventType, + VoiceAssistantFeature, + VoiceAssistantTimerEventType, +) + +from homeassistant.components import assist_satellite, tts +from homeassistant.components.assist_pipeline import ( + PipelineEvent, + PipelineEventType, + PipelineStage, +) +from homeassistant.components.intent import async_register_timer_handler +from homeassistant.components.intent.timers import TimerEventType, TimerInfo +from homeassistant.components.media_player import async_process_play_media_url +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import EsphomeAssistEntity +from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +from .enum_mapper import EsphomeEnumMapper + +_LOGGER = logging.getLogger(__name__) + +_VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ + VoiceAssistantEventType, PipelineEventType +] = EsphomeEnumMapper( + { + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: PipelineEventType.ERROR, + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: PipelineEventType.RUN_START, + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: PipelineEventType.RUN_END, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END, + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START, + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, + VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_START: PipelineEventType.WAKE_WORD_START, + VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: PipelineEventType.WAKE_WORD_END, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START: PipelineEventType.STT_VAD_START, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END: PipelineEventType.STT_VAD_END, + } +) + +_TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventType] = ( + EsphomeEnumMapper( + { + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED: TimerEventType.STARTED, + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED: TimerEventType.UPDATED, + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED: TimerEventType.CANCELLED, + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED: TimerEventType.FINISHED, + } + ) +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ESPHomeConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Assist satellite entity.""" + entry_data = entry.runtime_data + assert entry_data.device_info is not None + if entry_data.device_info.voice_assistant_feature_flags_compat( + entry_data.api_version + ): + async_add_entities( + [ + EsphomeAssistSatellite(entry, entry_data), + ] + ) + + +class EsphomeAssistSatellite( + EsphomeAssistEntity, assist_satellite.AssistSatelliteEntity +): + """Satellite running ESPHome.""" + + entity_description = assist_satellite.AssistSatelliteEntityDescription( + key="assist_satellite", + translation_key="assist_satellite", + entity_category=EntityCategory.CONFIG, + ) + + def __init__( + self, + config_entry: ConfigEntry, + entry_data: RuntimeEntryData, + ) -> None: + """Initialize satellite.""" + super().__init__(entry_data) + + self.config_entry = config_entry + self.entry_data = entry_data + self.cli = self.entry_data.client + + self._is_running: bool = True + self._pipeline_task: asyncio.Task | None = None + self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + self._tts_streaming_task: asyncio.Task | None = None + self._udp_server: VoiceAssistantUDPServer | None = None + + @property + def pipeline_entity_id(self) -> str | None: + """Return the entity ID of the pipeline to use for the next conversation.""" + assert self.entry_data.device_info is not None + ent_reg = er.async_get(self.hass) + return ent_reg.async_get_entity_id( + Platform.SELECT, + DOMAIN, + f"{self.entry_data.device_info.mac_address}-pipeline", + ) + + @property + def vad_sensitivity_entity_id(self) -> str | None: + """Return the entity ID of the VAD sensitivity to use for the next conversation.""" + assert self.entry_data.device_info is not None + ent_reg = er.async_get(self.hass) + return ent_reg.async_get_entity_id( + Platform.SELECT, + DOMAIN, + f"{self.entry_data.device_info.mac_address}-vad_sensitivity", + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + + assert self.entry_data.device_info is not None + feature_flags = ( + self.entry_data.device_info.voice_assistant_feature_flags_compat( + self.entry_data.api_version + ) + ) + if feature_flags & VoiceAssistantFeature.API_AUDIO: + # TCP audio + self.entry_data.disconnect_callbacks.add( + self.cli.subscribe_voice_assistant( + handle_start=self.handle_pipeline_start, + handle_stop=self.handle_pipeline_stop, + handle_audio=self.handle_audio, + ) + ) + else: + # UDP audio + self.entry_data.disconnect_callbacks.add( + self.cli.subscribe_voice_assistant( + handle_start=self.handle_pipeline_start, + handle_stop=self.handle_pipeline_stop, + ) + ) + + if feature_flags & VoiceAssistantFeature.TIMERS: + # Device supports timers + assert (self.registry_entry is not None) and ( + self.registry_entry.device_id is not None + ) + self.entry_data.disconnect_callbacks.add( + async_register_timer_handler( + self.hass, self.registry_entry.device_id, self.handle_timer_event + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + await super().async_will_remove_from_hass() + + self._is_running = False + self._stop_pipeline() + + def on_pipeline_event(self, event: PipelineEvent) -> None: + """Handle pipeline events.""" + try: + event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type) + except KeyError: + _LOGGER.debug("Received unknown pipeline event type: %s", event.type) + return + + data_to_send: dict[str, Any] = {} + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: + self.entry_data.async_set_assist_pipeline_state(True) + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: + assert event.data is not None + data_to_send = {"text": event.data["stt_output"]["text"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: + assert event.data is not None + data_to_send = { + "conversation_id": event.data["intent_output"]["conversation_id"] or "", + } + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: + assert event.data is not None + data_to_send = {"text": event.data["tts_input"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: + assert event.data is not None + if tts_output := event.data["tts_output"]: + path = tts_output["url"] + url = async_process_play_media_url(self.hass, path) + data_to_send = {"url": url} + + assert self.entry_data.device_info is not None + feature_flags = ( + self.entry_data.device_info.voice_assistant_feature_flags_compat( + self.entry_data.api_version + ) + ) + if feature_flags & VoiceAssistantFeature.SPEAKER: + media_id = tts_output["media_id"] + self._tts_streaming_task = ( + self.config_entry.async_create_background_task( + self.hass, + self._stream_tts_audio(media_id), + "esphome_voice_assistant_tts", + ) + ) + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: + assert event.data is not None + if not event.data["wake_word_output"]: + event_type = VoiceAssistantEventType.VOICE_ASSISTANT_ERROR + data_to_send = { + "code": "no_wake_word", + "message": "No wake word detected", + } + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: + assert event.data is not None + data_to_send = { + "code": event.data["code"], + "message": event.data["message"], + } + + self.cli.send_voice_assistant_event(event_type, data_to_send) + + async def handle_pipeline_start( + self, + conversation_id: str, + flags: int, + audio_settings: VoiceAssistantAudioSettings, + wake_word_phrase: str | None, + ) -> int | None: + """Handle pipeline run request.""" + # Clear audio queue + while not self._audio_queue.empty(): + await self._audio_queue.get() + + if self._tts_streaming_task is not None: + # Cancel current TTS response + self._tts_streaming_task.cancel() + self._tts_streaming_task = None + + # API or UDP output audio + port: int = 0 + assert self.entry_data.device_info is not None + feature_flags = ( + self.entry_data.device_info.voice_assistant_feature_flags_compat( + self.entry_data.api_version + ) + ) + if (feature_flags & VoiceAssistantFeature.SPEAKER) and not ( + feature_flags & VoiceAssistantFeature.API_AUDIO + ): + port = await self._start_udp_server() + _LOGGER.debug("Started UDP server on port %s", port) + + # Device triggered pipeline (wake word, etc.) + if flags & VoiceAssistantCommandFlag.USE_WAKE_WORD: + start_stage = PipelineStage.WAKE_WORD + else: + start_stage = PipelineStage.STT + + end_stage = PipelineStage.TTS + + # 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( + audio_stream=self._wrap_audio_stream(), + start_stage=start_stage, + end_stage=end_stage, + wake_word_phrase=wake_word_phrase, + ), + "esphome_assist_satellite_pipeline", + ) + self._pipeline_task.add_done_callback( + lambda _future: self.handle_pipeline_finished() + ) + + return port + + async def handle_audio(self, data: bytes) -> None: + """Handle incoming audio chunk from API.""" + self._audio_queue.put_nowait(data) + + async def handle_pipeline_stop(self) -> None: + """Handle request for pipeline to stop.""" + self._stop_pipeline() + + 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") + + def handle_timer_event( + self, event_type: TimerEventType, timer_info: TimerInfo + ) -> None: + """Handle timer events.""" + try: + native_event_type = _TIMER_EVENT_TYPES.from_hass(event_type) + except KeyError: + _LOGGER.debug("Received unknown timer event type: %s", event_type) + return + + self.cli.send_voice_assistant_timer_event( + native_event_type, + timer_info.id, + timer_info.name, + timer_info.created_seconds, + timer_info.seconds_left, + timer_info.is_active, + ) + + async def _stream_tts_audio( + self, + media_id: str, + sample_rate: int = 16000, + sample_width: int = 2, + sample_channels: int = 1, + samples_per_chunk: int = 512, + ) -> None: + """Stream TTS audio chunks to device via API or UDP.""" + self.cli.send_voice_assistant_event( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {} + ) + + try: + if not self._is_running: + return + + extension, data = await tts.async_get_media_source_audio( + self.hass, + media_id, + ) + + if extension != "wav": + _LOGGER.error("Only WAV audio can be streamed, got %s", extension) + return + + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + if ( + (wav_file.getframerate() != sample_rate) + or (wav_file.getsampwidth() != sample_width) + or (wav_file.getnchannels() != sample_channels) + ): + _LOGGER.error("Can only stream 16Khz 16-bit mono WAV") + return + + _LOGGER.debug("Streaming %s audio samples", wav_file.getnframes()) + + while self._is_running: + chunk = wav_file.readframes(samples_per_chunk) + if not chunk: + break + + if self._udp_server is not None: + self._udp_server.send_audio_bytes(chunk) + else: + self.cli.send_voice_assistant_audio(chunk) + + # Wait for 90% of the duration of the audio that was + # sent for it to be played. This will overrun the + # device's buffer for very long audio, so using a media + # player is preferred. + samples_in_chunk = len(chunk) // (sample_width * sample_channels) + seconds_in_chunk = samples_in_chunk / sample_rate + await asyncio.sleep(seconds_in_chunk * 0.9) + except asyncio.CancelledError: + return # Don't trigger state change + finally: + self.cli.send_voice_assistant_event( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, {} + ) + + # State change + self.tts_response_finished() + + async def _wrap_audio_stream(self) -> AsyncIterable[bytes]: + """Yield audio chunks from the queue until None.""" + while True: + chunk = await self._audio_queue.get() + if not chunk: + break + + yield chunk + + def _stop_pipeline(self) -> None: + """Request pipeline to be stopped.""" + self._audio_queue.put_nowait(None) + _LOGGER.debug("Requested pipeline stop") + + async def _start_udp_server(self) -> int: + """Start a UDP server on a random free port.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.bind(("", 0)) # random free port + + ( + _transport, + protocol, + ) = await asyncio.get_running_loop().create_datagram_endpoint( + partial(VoiceAssistantUDPServer, self._audio_queue), sock=sock + ) + + assert isinstance(protocol, VoiceAssistantUDPServer) + self._udp_server = protocol + + # Return port + return cast(int, sock.getsockname()[1]) + + def _stop_udp_server(self) -> None: + """Stop the UDP server if it's running.""" + if self._udp_server is None: + return + + try: + self._udp_server.close() + finally: + self._udp_server = None + + _LOGGER.debug("Stopped UDP server") + + +class VoiceAssistantUDPServer(asyncio.DatagramProtocol): + """Receive UDP packets and forward them to the audio queue.""" + + transport: asyncio.DatagramTransport | None = None + remote_addr: tuple[str, int] | None = None + + def __init__( + self, audio_queue: asyncio.Queue[bytes | None], *args: Any, **kwargs: Any + ) -> None: + """Initialize protocol.""" + super().__init__(*args, **kwargs) + self._audio_queue = audio_queue + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """Store transport for later use.""" + self.transport = cast(asyncio.DatagramTransport, transport) + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + """Handle incoming UDP packet.""" + if self.remote_addr is None: + self.remote_addr = addr + + self._audio_queue.put_nowait(data) + + def error_received(self, exc: Exception) -> None: + """Handle when a send or receive operation raises an OSError. + + (Other than BlockingIOError or InterruptedError.) + """ + _LOGGER.error("ESPHome Voice Assistant UDP server error received: %s", exc) + + # Stop pipeline + self._audio_queue.put_nowait(None) + + def close(self) -> None: + """Close the receiver.""" + if self.transport is not None: + self.transport.close() + + self.remote_addr = None + + def send_audio_bytes(self, data: bytes) -> None: + """Send bytes to the device via UDP.""" + if self.transport is None: + _LOGGER.error("No transport to send audio to") + return + + if self.remote_addr is None: + _LOGGER.error("No address to send audio to") + return + + self.transport.sendto(data, self.remote_addr) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 93e8d7b5bc2..09c3cc3b7cb 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -20,19 +20,17 @@ from aioesphomeapi import ( RequiresEncryptionAPIError, UserService, UserServiceArgType, - VoiceAssistantAudioSettings, - VoiceAssistantFeature, ) from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant.components import tag, zeroconf -from homeassistant.components.intent import async_register_timer_handler from homeassistant.const import ( ATTR_DEVICE_ID, CONF_MODE, EVENT_HOMEASSISTANT_CLOSE, EVENT_LOGGING_CHANGED, + Platform, ) from homeassistant.core import ( Event, @@ -73,12 +71,6 @@ from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData -from .voice_assistant import ( - VoiceAssistantAPIPipeline, - VoiceAssistantPipeline, - VoiceAssistantUDPPipeline, - handle_timer_event, -) _LOGGER = logging.getLogger(__name__) @@ -149,7 +141,6 @@ class ESPHomeManager: "cli", "device_id", "domain_data", - "voice_assistant_pipeline", "reconnect_logic", "zeroconf_instance", "entry_data", @@ -173,7 +164,6 @@ class ESPHomeManager: self.cli = cli self.device_id: str | None = None self.domain_data = domain_data - self.voice_assistant_pipeline: VoiceAssistantPipeline | None = None self.reconnect_logic: ReconnectLogic | None = None self.zeroconf_instance = zeroconf_instance self.entry_data = entry.runtime_data @@ -338,77 +328,6 @@ class ESPHomeManager: entity_id, attribute, self.hass.states.get(entity_id) ) - def _handle_pipeline_finished(self) -> None: - self.entry_data.async_set_assist_pipeline_state(False) - - if self.voice_assistant_pipeline is not None: - if isinstance(self.voice_assistant_pipeline, VoiceAssistantUDPPipeline): - self.voice_assistant_pipeline.close() - self.voice_assistant_pipeline = None - - async def _handle_pipeline_start( - self, - conversation_id: str, - flags: int, - audio_settings: VoiceAssistantAudioSettings, - wake_word_phrase: str | None, - ) -> int | None: - """Start a voice assistant pipeline.""" - if self.voice_assistant_pipeline is not None: - _LOGGER.warning("Previous Voice assistant pipeline was not stopped") - self.voice_assistant_pipeline.stop() - self.voice_assistant_pipeline = None - - hass = self.hass - assert self.entry_data.device_info is not None - if ( - self.entry_data.device_info.voice_assistant_feature_flags_compat( - self.entry_data.api_version - ) - & VoiceAssistantFeature.API_AUDIO - ): - self.voice_assistant_pipeline = VoiceAssistantAPIPipeline( - hass, - self.entry_data, - self.cli.send_voice_assistant_event, - self._handle_pipeline_finished, - self.cli, - ) - port = 0 - else: - self.voice_assistant_pipeline = VoiceAssistantUDPPipeline( - hass, - self.entry_data, - self.cli.send_voice_assistant_event, - self._handle_pipeline_finished, - ) - port = await self.voice_assistant_pipeline.start_server() - - assert self.device_id is not None, "Device ID must be set" - hass.async_create_background_task( - self.voice_assistant_pipeline.run_pipeline( - device_id=self.device_id, - conversation_id=conversation_id or None, - flags=flags, - audio_settings=audio_settings, - wake_word_phrase=wake_word_phrase, - ), - "esphome.voice_assistant_pipeline.run_pipeline", - ) - - return port - - async def _handle_pipeline_stop(self) -> None: - """Stop a voice assistant pipeline.""" - if self.voice_assistant_pipeline is not None: - self.voice_assistant_pipeline.stop() - - async def _handle_audio(self, data: bytes) -> None: - if self.voice_assistant_pipeline is None: - return - assert isinstance(self.voice_assistant_pipeline, VoiceAssistantAPIPipeline) - self.voice_assistant_pipeline.receive_audio_bytes(data) - async def on_connect(self) -> None: """Subscribe to states and list entities on successful API login.""" try: @@ -509,29 +428,14 @@ class ESPHomeManager: ) ) - flags = device_info.voice_assistant_feature_flags_compat(api_version) - if flags: - if flags & VoiceAssistantFeature.API_AUDIO: - entry_data.disconnect_callbacks.add( - cli.subscribe_voice_assistant( - handle_start=self._handle_pipeline_start, - handle_stop=self._handle_pipeline_stop, - handle_audio=self._handle_audio, - ) - ) - else: - entry_data.disconnect_callbacks.add( - cli.subscribe_voice_assistant( - handle_start=self._handle_pipeline_start, - handle_stop=self._handle_pipeline_stop, - ) - ) - if flags & VoiceAssistantFeature.TIMERS: - entry_data.disconnect_callbacks.add( - async_register_timer_handler( - hass, self.device_id, partial(handle_timer_event, cli) - ) - ) + if device_info.voice_assistant_feature_flags_compat(api_version) and ( + Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms + ): + # Create assist satellite entity + await self.hass.config_entries.async_forward_entry_setups( + self.entry, [Platform.ASSIST_SATELLITE] + ) + entry_data.loaded_platforms.add(Platform.ASSIST_SATELLITE) cli.subscribe_states(entry_data.async_update_state) cli.subscribe_service_calls(self.async_on_service_call) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py deleted file mode 100644 index eb55be2ced6..00000000000 --- a/homeassistant/components/esphome/voice_assistant.py +++ /dev/null @@ -1,479 +0,0 @@ -"""ESPHome voice assistant support.""" - -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterable, Callable -import io -import logging -import socket -from typing import cast -import wave - -from aioesphomeapi import ( - APIClient, - VoiceAssistantAudioSettings, - VoiceAssistantCommandFlag, - VoiceAssistantEventType, - VoiceAssistantFeature, - VoiceAssistantTimerEventType, -) - -from homeassistant.components import stt, tts -from homeassistant.components.assist_pipeline import ( - AudioSettings, - PipelineEvent, - PipelineEventType, - PipelineNotFound, - PipelineStage, - WakeWordSettings, - async_pipeline_from_audio_stream, - select as pipeline_select, -) -from homeassistant.components.assist_pipeline.error import ( - WakeWordDetectionAborted, - WakeWordDetectionError, -) -from homeassistant.components.assist_pipeline.vad import VadSensitivity -from homeassistant.components.intent.timers import TimerEventType, TimerInfo -from homeassistant.components.media_player import async_process_play_media_url -from homeassistant.core import Context, HomeAssistant, callback - -from .const import DOMAIN -from .entry_data import RuntimeEntryData -from .enum_mapper import EsphomeEnumMapper - -_LOGGER = logging.getLogger(__name__) - -UDP_PORT = 0 # Set to 0 to let the OS pick a free random port -UDP_MAX_PACKET_SIZE = 1024 - -_VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ - VoiceAssistantEventType, PipelineEventType -] = EsphomeEnumMapper( - { - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: PipelineEventType.ERROR, - VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: PipelineEventType.RUN_START, - VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END: PipelineEventType.RUN_END, - VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START, - VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END, - VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START, - VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END, - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START, - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, - VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_START: PipelineEventType.WAKE_WORD_START, - VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: PipelineEventType.WAKE_WORD_END, - VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START: PipelineEventType.STT_VAD_START, - VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END: PipelineEventType.STT_VAD_END, - } -) - -_TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventType] = ( - EsphomeEnumMapper( - { - VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED: TimerEventType.STARTED, - VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED: TimerEventType.UPDATED, - VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_CANCELLED: TimerEventType.CANCELLED, - VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_FINISHED: TimerEventType.FINISHED, - } - ) -) - - -class VoiceAssistantPipeline: - """Base abstract pipeline class.""" - - started = False - stop_requested = False - - def __init__( - self, - hass: HomeAssistant, - entry_data: RuntimeEntryData, - handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], - handle_finished: Callable[[], None], - ) -> None: - """Initialize the pipeline.""" - self.context = Context() - self.hass = hass - self.entry_data = entry_data - assert entry_data.device_info is not None - self.device_info = entry_data.device_info - - self.queue: asyncio.Queue[bytes] = asyncio.Queue() - self.handle_event = handle_event - self.handle_finished = handle_finished - self._tts_done = asyncio.Event() - self._tts_task: asyncio.Task | None = None - - @property - def is_running(self) -> bool: - """True if the pipeline is started and hasn't been asked to stop.""" - return self.started and (not self.stop_requested) - - async def _iterate_packets(self) -> AsyncIterable[bytes]: - """Iterate over incoming packets.""" - while data := await self.queue.get(): - if not self.is_running: - break - - yield data - - def _event_callback(self, event: PipelineEvent) -> None: - """Handle pipeline events.""" - - try: - event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type) - except KeyError: - _LOGGER.debug("Received unknown pipeline event type: %s", event.type) - return - - data_to_send = None - error = False - if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: - self.entry_data.async_set_assist_pipeline_state(True) - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: - assert event.data is not None - data_to_send = {"text": event.data["stt_output"]["text"]} - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: - assert event.data is not None - data_to_send = { - "conversation_id": event.data["intent_output"]["conversation_id"] or "", - } - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: - assert event.data is not None - data_to_send = {"text": event.data["tts_input"]} - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: - assert event.data is not None - tts_output = event.data["tts_output"] - if tts_output: - path = tts_output["url"] - url = async_process_play_media_url(self.hass, path) - data_to_send = {"url": url} - - if ( - self.device_info.voice_assistant_feature_flags_compat( - self.entry_data.api_version - ) - & VoiceAssistantFeature.SPEAKER - ): - media_id = tts_output["media_id"] - self._tts_task = self.hass.async_create_background_task( - self._send_tts(media_id), "esphome_voice_assistant_tts" - ) - else: - self._tts_done.set() - else: - # Empty TTS response - data_to_send = {} - self._tts_done.set() - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: - assert event.data is not None - if not event.data["wake_word_output"]: - event_type = VoiceAssistantEventType.VOICE_ASSISTANT_ERROR - data_to_send = { - "code": "no_wake_word", - "message": "No wake word detected", - } - error = True - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: - assert event.data is not None - data_to_send = { - "code": event.data["code"], - "message": event.data["message"], - } - error = True - - self.handle_event(event_type, data_to_send) - if error: - self._tts_done.set() - self.handle_finished() - - async def run_pipeline( - self, - device_id: str, - conversation_id: str | None, - flags: int = 0, - audio_settings: VoiceAssistantAudioSettings | None = None, - wake_word_phrase: str | None = None, - ) -> None: - """Run the Voice Assistant pipeline.""" - if audio_settings is None or audio_settings.volume_multiplier == 0: - audio_settings = VoiceAssistantAudioSettings() - - if ( - self.device_info.voice_assistant_feature_flags_compat( - self.entry_data.api_version - ) - & VoiceAssistantFeature.SPEAKER - ): - tts_audio_output = "wav" - else: - tts_audio_output = "mp3" - - _LOGGER.debug("Starting pipeline") - if flags & VoiceAssistantCommandFlag.USE_WAKE_WORD: - start_stage = PipelineStage.WAKE_WORD - else: - start_stage = PipelineStage.STT - try: - await async_pipeline_from_audio_stream( - self.hass, - context=self.context, - event_callback=self._event_callback, - stt_metadata=stt.SpeechMetadata( - language="", # set in async_pipeline_from_audio_stream - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, - ), - stt_stream=self._iterate_packets(), - pipeline_id=pipeline_select.get_chosen_pipeline( - self.hass, DOMAIN, self.device_info.mac_address - ), - conversation_id=conversation_id, - device_id=device_id, - tts_audio_output=tts_audio_output, - start_stage=start_stage, - wake_word_settings=WakeWordSettings(timeout=5), - wake_word_phrase=wake_word_phrase, - audio_settings=AudioSettings( - noise_suppression_level=audio_settings.noise_suppression_level, - auto_gain_dbfs=audio_settings.auto_gain, - volume_multiplier=audio_settings.volume_multiplier, - is_vad_enabled=bool(flags & VoiceAssistantCommandFlag.USE_VAD), - silence_seconds=VadSensitivity.to_seconds( - pipeline_select.get_vad_sensitivity( - self.hass, DOMAIN, self.device_info.mac_address - ) - ), - ), - ) - - # Block until TTS is done sending - await self._tts_done.wait() - - _LOGGER.debug("Pipeline finished") - except PipelineNotFound as e: - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, - { - "code": e.code, - "message": e.message, - }, - ) - _LOGGER.warning("Pipeline not found") - except WakeWordDetectionAborted: - pass # Wake word detection was aborted and `handle_finished` is enough. - except WakeWordDetectionError as e: - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, - { - "code": e.code, - "message": e.message, - }, - ) - finally: - self.handle_finished() - - async def _send_tts(self, media_id: str) -> None: - """Send TTS audio to device via UDP.""" - # Always send stream start/end events - self.handle_event(VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {}) - - try: - if not self.is_running: - return - - extension, data = await tts.async_get_media_source_audio( - self.hass, - media_id, - ) - - if extension != "wav": - raise ValueError(f"Only WAV audio can be streamed, got {extension}") - - with io.BytesIO(data) as wav_io: - with wave.open(wav_io, "rb") as wav_file: - sample_rate = wav_file.getframerate() - sample_width = wav_file.getsampwidth() - sample_channels = wav_file.getnchannels() - - if ( - (sample_rate != 16000) - or (sample_width != 2) - or (sample_channels != 1) - ): - raise ValueError( - "Expected rate/width/channels as 16000/2/1," - " got {sample_rate}/{sample_width}/{sample_channels}}" - ) - - audio_bytes = wav_file.readframes(wav_file.getnframes()) - - audio_bytes_size = len(audio_bytes) - - _LOGGER.debug("Sending %d bytes of audio", audio_bytes_size) - - bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8 - sample_offset = 0 - samples_left = audio_bytes_size // bytes_per_sample - - while (samples_left > 0) and self.is_running: - bytes_offset = sample_offset * bytes_per_sample - chunk: bytes = audio_bytes[bytes_offset : bytes_offset + 1024] - samples_in_chunk = len(chunk) // bytes_per_sample - samples_left -= samples_in_chunk - - self.send_audio_bytes(chunk) - await asyncio.sleep( - samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.9 - ) - - sample_offset += samples_in_chunk - finally: - self.handle_event( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, {} - ) - self._tts_task = None - self._tts_done.set() - - def send_audio_bytes(self, data: bytes) -> None: - """Send bytes to the device.""" - raise NotImplementedError - - def stop(self) -> None: - """Stop the pipeline.""" - self.queue.put_nowait(b"") - - -class VoiceAssistantUDPPipeline(asyncio.DatagramProtocol, VoiceAssistantPipeline): - """Receive UDP packets and forward them to the voice assistant.""" - - transport: asyncio.DatagramTransport | None = None - remote_addr: tuple[str, int] | None = None - - async def start_server(self) -> int: - """Start accepting connections.""" - - def accept_connection() -> VoiceAssistantUDPPipeline: - """Accept connection.""" - if self.started: - raise RuntimeError("Can only start once") - if self.stop_requested: - raise RuntimeError("No longer accepting connections") - - self.started = True - return self - - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setblocking(False) - - sock.bind(("", UDP_PORT)) - - await asyncio.get_running_loop().create_datagram_endpoint( - accept_connection, sock=sock - ) - - return cast(int, sock.getsockname()[1]) - - @callback - def connection_made(self, transport: asyncio.BaseTransport) -> None: - """Store transport for later use.""" - self.transport = cast(asyncio.DatagramTransport, transport) - - @callback - def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: - """Handle incoming UDP packet.""" - if not self.is_running: - return - if self.remote_addr is None: - self.remote_addr = addr - self.queue.put_nowait(data) - - def error_received(self, exc: Exception) -> None: - """Handle when a send or receive operation raises an OSError. - - (Other than BlockingIOError or InterruptedError.) - """ - _LOGGER.error("ESPHome Voice Assistant UDP server error received: %s", exc) - self.handle_finished() - - @callback - def stop(self) -> None: - """Stop the receiver.""" - super().stop() - self.close() - - def close(self) -> None: - """Close the receiver.""" - self.started = False - self.stop_requested = True - - if self.transport is not None: - self.transport.close() - - def send_audio_bytes(self, data: bytes) -> None: - """Send bytes to the device via UDP.""" - if self.transport is None: - _LOGGER.error("No transport to send audio to") - return - self.transport.sendto(data, self.remote_addr) - - -class VoiceAssistantAPIPipeline(VoiceAssistantPipeline): - """Send audio to the voice assistant via the API.""" - - def __init__( - self, - hass: HomeAssistant, - entry_data: RuntimeEntryData, - handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], - handle_finished: Callable[[], None], - api_client: APIClient, - ) -> None: - """Initialize the pipeline.""" - super().__init__(hass, entry_data, handle_event, handle_finished) - self.api_client = api_client - self.started = True - - def send_audio_bytes(self, data: bytes) -> None: - """Send bytes to the device via the API.""" - self.api_client.send_voice_assistant_audio(data) - - @callback - def receive_audio_bytes(self, data: bytes) -> None: - """Receive audio bytes from the device.""" - if not self.is_running: - return - self.queue.put_nowait(data) - - @callback - def stop(self) -> None: - """Stop the pipeline.""" - super().stop() - - self.started = False - self.stop_requested = True - - -def handle_timer_event( - api_client: APIClient, event_type: TimerEventType, timer_info: TimerInfo -) -> None: - """Handle timer events.""" - try: - native_event_type = _TIMER_EVENT_TYPES.from_hass(event_type) - except KeyError: - _LOGGER.debug("Received unknown timer event type: %s", event_type) - return - - api_client.send_voice_assistant_timer_event( - native_event_type, - timer_info.id, - timer_info.name, - timer_info.created_seconds, - timer_info.seconds_left, - timer_info.is_active, - ) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index b3966875a31..af68df89360 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -20,7 +20,6 @@ from aioesphomeapi import ( ReconnectLogic, UserService, VoiceAssistantAudioSettings, - VoiceAssistantEventType, VoiceAssistantFeature, ) import pytest @@ -34,11 +33,6 @@ from homeassistant.components.esphome.const import ( DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, ) -from homeassistant.components.esphome.entry_data import RuntimeEntryData -from homeassistant.components.esphome.voice_assistant import ( - VoiceAssistantAPIPipeline, - VoiceAssistantUDPPipeline, -) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -625,57 +619,3 @@ async def mock_esphome_device( ) return _mock_device - - -@pytest.fixture -def mock_voice_assistant_api_pipeline() -> VoiceAssistantAPIPipeline: - """Return the API Pipeline factory.""" - mock_pipeline = Mock(spec=VoiceAssistantAPIPipeline) - - def mock_constructor( - hass: HomeAssistant, - entry_data: RuntimeEntryData, - handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], - handle_finished: Callable[[], None], - api_client: APIClient, - ): - """Fake the constructor.""" - mock_pipeline.hass = hass - mock_pipeline.entry_data = entry_data - mock_pipeline.handle_event = handle_event - mock_pipeline.handle_finished = handle_finished - mock_pipeline.api_client = api_client - return mock_pipeline - - mock_pipeline.side_effect = mock_constructor - with patch( - "homeassistant.components.esphome.voice_assistant.VoiceAssistantAPIPipeline", - new=mock_pipeline, - ): - yield mock_pipeline - - -@pytest.fixture -def mock_voice_assistant_udp_pipeline() -> VoiceAssistantUDPPipeline: - """Return the API Pipeline factory.""" - mock_pipeline = Mock(spec=VoiceAssistantUDPPipeline) - - def mock_constructor( - hass: HomeAssistant, - entry_data: RuntimeEntryData, - handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], - handle_finished: Callable[[], None], - ): - """Fake the constructor.""" - mock_pipeline.hass = hass - mock_pipeline.entry_data = entry_data - mock_pipeline.handle_event = handle_event - mock_pipeline.handle_finished = handle_finished - return mock_pipeline - - mock_pipeline.side_effect = mock_constructor - with patch( - "homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPPipeline", - new=mock_pipeline, - ): - yield mock_pipeline diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py new file mode 100644 index 00000000000..f024ca3b078 --- /dev/null +++ b/tests/components/esphome/test_assist_satellite.py @@ -0,0 +1,822 @@ +"""Test ESPHome voice assistant server.""" + +import asyncio +from collections.abc import Awaitable, Callable +import io +import socket +from unittest.mock import ANY, Mock, patch +import wave + +from aioesphomeapi import ( + APIClient, + EntityInfo, + EntityState, + UserService, + VoiceAssistantAudioSettings, + VoiceAssistantCommandFlag, + VoiceAssistantEventType, + VoiceAssistantFeature, + VoiceAssistantTimerEventType, +) +import pytest + +from homeassistant.components import assist_satellite +from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType +from homeassistant.components.assist_satellite.entity import ( + AssistSatelliteEntity, + AssistSatelliteState, +) +from homeassistant.components.esphome import DOMAIN +from homeassistant.components.esphome.assist_satellite import ( + EsphomeAssistSatellite, + VoiceAssistantUDPServer, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, intent as intent_helper +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.entity_component import EntityComponent + +from .conftest import MockESPHomeDevice + + +def get_satellite_entity( + hass: HomeAssistant, mac_address: str +) -> EsphomeAssistSatellite | None: + """Get the satellite entity for a device.""" + ent_reg = er.async_get(hass) + satellite_entity_id = ent_reg.async_get_entity_id( + Platform.ASSIST_SATELLITE, DOMAIN, f"{mac_address}-assist_satellite" + ) + if satellite_entity_id is None: + return None + + component: EntityComponent[AssistSatelliteEntity] = hass.data[ + assist_satellite.DOMAIN + ] + if (entity := component.get_entity(satellite_entity_id)) is not None: + assert isinstance(entity, EsphomeAssistSatellite) + return entity + + return None + + +@pytest.fixture +def mock_wav() -> bytes: + """Return test WAV audio.""" + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(b"test-wav") + + return wav_io.getvalue() + + +async def test_no_satellite_without_voice_assistant( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that an assist satellite entity is not created if a voice assistant is not present.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={}, + ) + await hass.async_block_till_done() + + # No satellite entity should be created + assert get_satellite_entity(hass, mock_device.device_info.mac_address) is None + + +async def test_pipeline_api_audio( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + mock_wav: bytes, +) -> None: + """Test a complete pipeline run with API audio (over the TCP connection).""" + conversation_id = "test-conversation-id" + media_url = "http://test.url" + media_id = "test-media-id" + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + }, + ) + await hass.async_block_till_done() + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # Block TTS streaming until we're ready. + # This makes it easier to verify the order of pipeline events. + stream_tts_audio_ready = asyncio.Event() + original_stream_tts_audio = satellite._stream_tts_audio + + async def _stream_tts_audio(*args, **kwargs): + await stream_tts_audio_ready.wait() + await original_stream_tts_audio(*args, **kwargs) + + async def async_pipeline_from_audio_stream(*args, device_id, **kwargs): + assert device_id == dev.id + + stt_stream = kwargs["stt_stream"] + + chunks = [chunk async for chunk in stt_stream] + + # Verify test API audio + assert chunks == [b"test-mic"] + + event_callback = kwargs["event_callback"] + + # Test unknown event type + event_callback( + PipelineEvent( + type="unknown-event", + data={}, + ) + ) + + mock_client.send_voice_assistant_event.assert_not_called() + + # Test error event + event_callback( + PipelineEvent( + type=PipelineEventType.ERROR, + data={"code": "test-error-code", "message": "test-error-message"}, + ) + ) + + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + {"code": "test-error-code", "message": "test-error-message"}, + ) + + # Wake word + assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD + + event_callback( + PipelineEvent( + type=PipelineEventType.WAKE_WORD_START, + data={ + "entity_id": "test-wake-word-entity-id", + "metadata": {}, + "timeout": 0, + }, + ) + ) + + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_START, + {}, + ) + + # Test no wake word detected + event_callback( + PipelineEvent( + type=PipelineEventType.WAKE_WORD_END, data={"wake_word_output": {}} + ) + ) + + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + {"code": "no_wake_word", "message": "No wake word detected"}, + ) + + # Correct wake word detection + event_callback( + PipelineEvent( + type=PipelineEventType.WAKE_WORD_END, + data={"wake_word_output": {"wake_word_phrase": "test-wake-word"}}, + ) + ) + + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END, + {}, + ) + + # STT + event_callback( + PipelineEvent( + type=PipelineEventType.STT_START, + data={"engine": "test-stt-engine", "metadata": {}}, + ) + ) + + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_STT_START, + {}, + ) + assert satellite.state == AssistSatelliteState.LISTENING_COMMAND + + event_callback( + PipelineEvent( + type=PipelineEventType.STT_END, + data={"stt_output": {"text": "test-stt-text"}}, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_STT_END, + {"text": "test-stt-text"}, + ) + + # Intent + event_callback( + PipelineEvent( + type=PipelineEventType.INTENT_START, + data={ + "engine": "test-intent-engine", + "language": hass.config.language, + "intent_input": "test-intent-text", + "conversation_id": conversation_id, + "device_id": device_id, + }, + ) + ) + + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START, + {}, + ) + assert satellite.state == AssistSatelliteState.PROCESSING + + event_callback( + PipelineEvent( + type=PipelineEventType.INTENT_END, + data={"intent_output": {"conversation_id": conversation_id}}, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, + {"conversation_id": conversation_id}, + ) + + # TTS + event_callback( + PipelineEvent( + type=PipelineEventType.TTS_START, + data={ + "engine": "test-stt-engine", + "language": hass.config.language, + "voice": "test-voice", + "tts_input": "test-tts-text", + }, + ) + ) + + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START, + {"text": "test-tts-text"}, + ) + assert satellite.state == AssistSatelliteState.RESPONDING + + # Should return mock_wav audio + event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={"tts_output": {"url": media_url, "media_id": media_id}}, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END, + {"url": media_url}, + ) + + event_callback(PipelineEvent(type=PipelineEventType.RUN_END)) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END, + {}, + ) + + # Allow TTS streaming to proceed + stream_tts_audio_ready.set() + + pipeline_finished = asyncio.Event() + original_handle_pipeline_finished = satellite.handle_pipeline_finished + + def handle_pipeline_finished(): + original_handle_pipeline_finished() + pipeline_finished.set() + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + return ("wav", mock_wav) + + tts_finished = asyncio.Event() + original_tts_response_finished = satellite.tts_response_finished + + def tts_response_finished(): + original_tts_response_finished() + tts_finished.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch( + "homeassistant.components.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ), + patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), + patch.object(satellite, "_stream_tts_audio", _stream_tts_audio), + patch.object(satellite, "tts_response_finished", tts_response_finished), + ): + # Should be cleared at pipeline start + satellite._audio_queue.put_nowait(b"leftover-data") + + # Should be cancelled at pipeline start + mock_tts_streaming_task = Mock() + satellite._tts_streaming_task = mock_tts_streaming_task + + async with asyncio.timeout(1): + await satellite.handle_pipeline_start( + conversation_id=conversation_id, + flags=VoiceAssistantCommandFlag.USE_WAKE_WORD, + audio_settings=VoiceAssistantAudioSettings(), + wake_word_phrase="", + ) + mock_tts_streaming_task.cancel.assert_called_once() + await satellite.handle_audio(b"test-mic") + await satellite.handle_pipeline_stop() + await pipeline_finished.wait() + + await tts_finished.wait() + + # Verify TTS streaming events. + # These are definitely the last two events because we blocked TTS streaming + # until after RUN_END above. + assert mock_client.send_voice_assistant_event.call_args_list[-2].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, + {}, + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, + {}, + ) + + # Verify TTS WAV audio chunk came through + mock_client.send_voice_assistant_audio.assert_called_once_with(b"test-wav") + + +@pytest.mark.usefixtures("socket_enabled") +async def test_pipeline_udp_audio( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + mock_wav: bytes, +) -> None: + """Test a complete pipeline run with legacy UDP audio. + + This test is not as comprehensive as test_pipeline_api_audio since we're + mainly focused on the UDP server. + """ + conversation_id = "test-conversation-id" + media_url = "http://test.url" + media_id = "test-media-id" + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + mic_audio_event = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, device_id, **kwargs): + stt_stream = kwargs["stt_stream"] + + chunks = [] + async for chunk in stt_stream: + chunks.append(chunk) + mic_audio_event.set() + + # Verify test UDP audio + assert chunks == [b"test-mic"] + + event_callback = kwargs["event_callback"] + + # STT + event_callback( + PipelineEvent( + type=PipelineEventType.STT_START, + data={"engine": "test-stt-engine", "metadata": {}}, + ) + ) + + event_callback( + PipelineEvent( + type=PipelineEventType.STT_END, + data={"stt_output": {"text": "test-stt-text"}}, + ) + ) + + # Intent + event_callback( + PipelineEvent( + type=PipelineEventType.INTENT_START, + data={ + "engine": "test-intent-engine", + "language": hass.config.language, + "intent_input": "test-intent-text", + "conversation_id": conversation_id, + "device_id": device_id, + }, + ) + ) + + event_callback( + PipelineEvent( + type=PipelineEventType.INTENT_END, + data={"intent_output": {"conversation_id": conversation_id}}, + ) + ) + + # TTS + event_callback( + PipelineEvent( + type=PipelineEventType.TTS_START, + data={ + "engine": "test-stt-engine", + "language": hass.config.language, + "voice": "test-voice", + "tts_input": "test-tts-text", + }, + ) + ) + + # Should return mock_wav audio + event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={"tts_output": {"url": media_url, "media_id": media_id}}, + ) + ) + + event_callback(PipelineEvent(type=PipelineEventType.RUN_END)) + + pipeline_finished = asyncio.Event() + original_handle_pipeline_finished = satellite.handle_pipeline_finished + + def handle_pipeline_finished(): + original_handle_pipeline_finished() + pipeline_finished.set() + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + return ("wav", mock_wav) + + tts_finished = asyncio.Event() + original_tts_response_finished = satellite.tts_response_finished + + def tts_response_finished(): + original_tts_response_finished() + tts_finished.set() + + class TestProtocol(asyncio.DatagramProtocol): + def __init__(self) -> None: + self.transport = None + self.data_received: list[bytes] = [] + + def connection_made(self, transport): + self.transport = transport + + def datagram_received(self, data: bytes, addr): + self.data_received.append(data) + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch( + "homeassistant.components.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ), + patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), + patch.object(satellite, "tts_response_finished", tts_response_finished), + ): + async with asyncio.timeout(1): + port = await satellite.handle_pipeline_start( + conversation_id=conversation_id, + flags=VoiceAssistantCommandFlag(0), # stt + audio_settings=VoiceAssistantAudioSettings(), + wake_word_phrase="", + ) + assert (port is not None) and (port > 0) + + ( + transport, + protocol, + ) = await asyncio.get_running_loop().create_datagram_endpoint( + TestProtocol, remote_addr=("127.0.0.1", port) + ) + assert isinstance(protocol, TestProtocol) + + # Send audio over UDP + transport.sendto(b"test-mic") + + # Wait for audio chunk to be delivered + await mic_audio_event.wait() + + await satellite.handle_pipeline_stop() + await pipeline_finished.wait() + + await tts_finished.wait() + + # Verify TTS audio (from UDP) + assert protocol.data_received == [b"test-wav"] + + # Check that UDP server was stopped + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setblocking(False) + sock.bind(("", port)) # will fail if UDP server is still running + sock.close() + + +async def test_udp_errors() -> None: + """Test UDP protocol error conditions.""" + audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() + protocol = VoiceAssistantUDPServer(audio_queue) + + protocol.datagram_received(b"test", ("", 0)) + assert audio_queue.qsize() == 1 + assert (await audio_queue.get()) == b"test" + + # None will stop the pipeline + protocol.error_received(RuntimeError()) + assert audio_queue.qsize() == 1 + assert (await audio_queue.get()) is None + + # No transport + assert protocol.transport is None + protocol.send_audio_bytes(b"test") + + # No remote address + protocol.transport = Mock() + protocol.remote_addr = None + protocol.send_audio_bytes(b"test") + protocol.transport.sendto.assert_not_called() + + +async def test_timer_events( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that injecting timer events results in the correct api client calls.""" + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.TIMERS + }, + ) + await hass.async_block_till_done() + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + + total_seconds = (1 * 60 * 60) + (2 * 60) + 3 + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "name": {"value": "test timer"}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=dev.id, + ) + + mock_client.send_voice_assistant_timer_event.assert_called_with( + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED, + ANY, + "test timer", + total_seconds, + total_seconds, + True, + ) + + # Increase timer beyond original time and check total_seconds has increased + mock_client.send_voice_assistant_timer_event.reset_mock() + + total_seconds += 5 * 60 + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_INCREASE_TIMER, + { + "name": {"value": "test timer"}, + "minutes": {"value": 5}, + }, + device_id=dev.id, + ) + + mock_client.send_voice_assistant_timer_event.assert_called_with( + VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED, + ANY, + "test timer", + total_seconds, + ANY, + True, + ) + + +async def test_unknown_timer_event( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that unknown (new) timer event types do not result in api calls.""" + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.TIMERS + }, + ) + await hass.async_block_till_done() + assert mock_device.entry.unique_id is not None + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + assert dev is not None + + with patch( + "homeassistant.components.esphome.assist_satellite._TIMER_EVENT_TYPES.from_hass", + side_effect=KeyError, + ): + await intent_helper.async_handle( + hass, + "test", + intent_helper.INTENT_START_TIMER, + { + "name": {"value": "test timer"}, + "hours": {"value": 1}, + "minutes": {"value": 2}, + "seconds": {"value": 3}, + }, + device_id=dev.id, + ) + + mock_client.send_voice_assistant_timer_event.assert_not_called() + + +async def test_streaming_tts_errors( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + mock_wav: bytes, +) -> None: + """Test error conditions for _stream_tts_audio function.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # Should not stream if not running + satellite._is_running = False + await satellite._stream_tts_audio("test-media-id") + mock_client.send_voice_assistant_audio.assert_not_called() + satellite._is_running = True + + # Should only stream WAV + async def get_mp3( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + return ("mp3", b"") + + with patch( + "homeassistant.components.tts.async_get_media_source_audio", new=get_mp3 + ): + await satellite._stream_tts_audio("test-media-id") + mock_client.send_voice_assistant_audio.assert_not_called() + + # Needs to be the correct sample rate, etc. + async def get_bad_wav( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + with io.BytesIO() as wav_io: + with wave.open(wav_io, "wb") as wav_file: + wav_file.setframerate(48000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(b"test-wav") + + return ("wav", wav_io.getvalue()) + + with patch( + "homeassistant.components.tts.async_get_media_source_audio", new=get_bad_wav + ): + await satellite._stream_tts_audio("test-media-id") + mock_client.send_voice_assistant_audio.assert_not_called() + + # Check that TTS_STREAM_* events still get sent after cancel + media_fetched = asyncio.Event() + + async def get_slow_wav( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + media_fetched.set() + await asyncio.sleep(1) + return ("wav", mock_wav) + + mock_client.send_voice_assistant_event.reset_mock() + with patch( + "homeassistant.components.tts.async_get_media_source_audio", new=get_slow_wav + ): + task = asyncio.create_task(satellite._stream_tts_audio("test-media-id")) + async with asyncio.timeout(1): + # Wait for media to be fetched + await media_fetched.wait() + + # Cancel task + task.cancel() + await task + + # No audio should have gone out + mock_client.send_voice_assistant_audio.assert_not_called() + assert len(mock_client.send_voice_assistant_event.call_args_list) == 2 + + # The TTS_STREAM_* events should have gone out + assert mock_client.send_voice_assistant_event.call_args_list[-2].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, + {}, + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, + {}, + ) diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index a14c83bf265..4b322c8744e 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Awaitable, Callable -from unittest.mock import AsyncMock, call, patch +from unittest.mock import AsyncMock, call from aioesphomeapi import ( APIClient, @@ -17,7 +17,6 @@ from aioesphomeapi import ( UserService, UserServiceArg, UserServiceArgType, - VoiceAssistantFeature, ) import pytest @@ -29,10 +28,6 @@ from homeassistant.components.esphome.const import ( DOMAIN, STABLE_BLE_VERSION_STR, ) -from homeassistant.components.esphome.voice_assistant import ( - VoiceAssistantAPIPipeline, - VoiceAssistantUDPPipeline, -) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -44,7 +39,7 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.setup import async_setup_component -from .conftest import _ONE_SECOND, MockESPHomeDevice +from .conftest import MockESPHomeDevice from tests.common import MockConfigEntry, async_capture_events, async_mock_service @@ -1214,102 +1209,3 @@ async def test_entry_missing_unique_id( await mock_esphome_device(mock_client=mock_client, mock_storage=True) await hass.async_block_till_done() assert entry.unique_id == "11:22:33:44:55:aa" - - -async def test_manager_voice_assistant_handlers_api( - hass: HomeAssistant, - mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], - caplog: pytest.LogCaptureFixture, - mock_voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test the handlers are correctly executed in manager.py.""" - - device: MockESPHomeDevice = await mock_esphome_device( - mock_client=mock_client, - entity_info=[], - user_service=[], - states=[], - device_info={ - "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT - | VoiceAssistantFeature.API_AUDIO - }, - ) - - await hass.async_block_till_done() - - with ( - patch( - "homeassistant.components.esphome.manager.VoiceAssistantAPIPipeline", - new=mock_voice_assistant_api_pipeline, - ), - ): - port: int | None = await device.mock_voice_assistant_handle_start( - "", 0, None, None - ) - - assert port == 0 - - port: int | None = await device.mock_voice_assistant_handle_start( - "", 0, None, None - ) - - assert "Previous Voice assistant pipeline was not stopped" in caplog.text - - await device.mock_voice_assistant_handle_audio(bytes(_ONE_SECOND)) - - mock_voice_assistant_api_pipeline.receive_audio_bytes.assert_called_with( - bytes(_ONE_SECOND) - ) - - mock_voice_assistant_api_pipeline.receive_audio_bytes.reset_mock() - - await device.mock_voice_assistant_handle_stop() - mock_voice_assistant_api_pipeline.handle_finished() - - await device.mock_voice_assistant_handle_audio(bytes(_ONE_SECOND)) - - mock_voice_assistant_api_pipeline.receive_audio_bytes.assert_not_called() - - -async def test_manager_voice_assistant_handlers_udp( - hass: HomeAssistant, - mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], - mock_voice_assistant_udp_pipeline: VoiceAssistantUDPPipeline, -) -> None: - """Test the handlers are correctly executed in manager.py.""" - - device: MockESPHomeDevice = await mock_esphome_device( - mock_client=mock_client, - entity_info=[], - user_service=[], - states=[], - device_info={ - "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT - }, - ) - - await hass.async_block_till_done() - - with ( - patch( - "homeassistant.components.esphome.manager.VoiceAssistantUDPPipeline", - new=mock_voice_assistant_udp_pipeline, - ), - ): - await device.mock_voice_assistant_handle_start("", 0, None, None) - - mock_voice_assistant_udp_pipeline.run_pipeline.assert_called() - - await device.mock_voice_assistant_handle_stop() - mock_voice_assistant_udp_pipeline.handle_finished() - - mock_voice_assistant_udp_pipeline.stop.assert_called() - mock_voice_assistant_udp_pipeline.close.assert_called() diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py deleted file mode 100644 index eafc0243dc6..00000000000 --- a/tests/components/esphome/test_voice_assistant.py +++ /dev/null @@ -1,964 +0,0 @@ -"""Test ESPHome voice assistant server.""" - -import asyncio -from collections.abc import Awaitable, Callable -import io -import socket -from unittest.mock import ANY, Mock, patch -import wave - -from aioesphomeapi import ( - APIClient, - EntityInfo, - EntityState, - UserService, - VoiceAssistantEventType, - VoiceAssistantFeature, - VoiceAssistantTimerEventType, -) -import pytest - -from homeassistant.components.assist_pipeline import ( - PipelineEvent, - PipelineEventType, - PipelineStage, -) -from homeassistant.components.assist_pipeline.error import ( - PipelineNotFound, - WakeWordDetectionAborted, - WakeWordDetectionError, -) -from homeassistant.components.esphome import DomainData -from homeassistant.components.esphome.voice_assistant import ( - VoiceAssistantAPIPipeline, - VoiceAssistantUDPPipeline, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent as intent_helper -import homeassistant.helpers.device_registry as dr - -from .conftest import _ONE_SECOND, MockESPHomeDevice - -_TEST_INPUT_TEXT = "This is an input test" -_TEST_OUTPUT_TEXT = "This is an output test" -_TEST_OUTPUT_URL = "output.mp3" -_TEST_MEDIA_ID = "12345" - - -@pytest.fixture -def voice_assistant_udp_pipeline( - hass: HomeAssistant, -) -> VoiceAssistantUDPPipeline: - """Return the UDP pipeline factory.""" - - def _voice_assistant_udp_server(entry): - entry_data = DomainData.get(hass).get_entry_data(entry) - - server: VoiceAssistantUDPPipeline = None - - def handle_finished(): - nonlocal server - assert server is not None - server.close() - - server = VoiceAssistantUDPPipeline(hass, entry_data, Mock(), handle_finished) - return server # noqa: RET504 - - return _voice_assistant_udp_server - - -@pytest.fixture -def voice_assistant_api_pipeline( - hass: HomeAssistant, - mock_client, - mock_voice_assistant_api_entry, -) -> VoiceAssistantAPIPipeline: - """Return the API Pipeline factory.""" - entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_api_entry) - return VoiceAssistantAPIPipeline(hass, entry_data, Mock(), Mock(), mock_client) - - -@pytest.fixture -def voice_assistant_udp_pipeline_v1( - voice_assistant_udp_pipeline, - mock_voice_assistant_v1_entry, -) -> VoiceAssistantUDPPipeline: - """Return the UDP pipeline.""" - return voice_assistant_udp_pipeline(entry=mock_voice_assistant_v1_entry) - - -@pytest.fixture -def voice_assistant_udp_pipeline_v2( - voice_assistant_udp_pipeline, - mock_voice_assistant_v2_entry, -) -> VoiceAssistantUDPPipeline: - """Return the UDP pipeline.""" - return voice_assistant_udp_pipeline(entry=mock_voice_assistant_v2_entry) - - -@pytest.fixture -def mock_wav() -> bytes: - """Return one second of empty WAV audio.""" - with io.BytesIO() as wav_io: - with wave.open(wav_io, "wb") as wav_file: - wav_file.setframerate(16000) - wav_file.setsampwidth(2) - wav_file.setnchannels(1) - wav_file.writeframes(bytes(_ONE_SECOND)) - - return wav_io.getvalue() - - -async def test_pipeline_events( - hass: HomeAssistant, - voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, -) -> None: - """Test that the pipeline function is called.""" - - async def async_pipeline_from_audio_stream(*args, device_id, **kwargs): - assert device_id == "mock-device-id" - - event_callback = kwargs["event_callback"] - - event_callback( - PipelineEvent( - type=PipelineEventType.WAKE_WORD_END, - data={"wake_word_output": {}}, - ) - ) - - # Fake events - event_callback( - PipelineEvent( - type=PipelineEventType.STT_START, - data={}, - ) - ) - - event_callback( - PipelineEvent( - type=PipelineEventType.STT_END, - data={"stt_output": {"text": _TEST_INPUT_TEXT}}, - ) - ) - - event_callback( - PipelineEvent( - type=PipelineEventType.TTS_START, - data={"tts_input": _TEST_OUTPUT_TEXT}, - ) - ) - - event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={"tts_output": {"url": _TEST_OUTPUT_URL}}, - ) - ) - - def handle_event( - event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: - assert data is not None - assert data["text"] == _TEST_INPUT_TEXT - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: - assert data is not None - assert data["text"] == _TEST_OUTPUT_TEXT - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: - assert data is not None - assert data["url"] == _TEST_OUTPUT_URL - elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: - assert data is None - - voice_assistant_udp_pipeline_v1.handle_event = handle_event - - with patch( - "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ): - voice_assistant_udp_pipeline_v1.transport = Mock() - - await voice_assistant_udp_pipeline_v1.run_pipeline( - device_id="mock-device-id", conversation_id=None - ) - - -@pytest.mark.usefixtures("socket_enabled") -async def test_udp_server( - unused_udp_port_factory: Callable[[], int], - voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, -) -> None: - """Test the UDP server runs and queues incoming data.""" - port_to_use = unused_udp_port_factory() - - with patch( - "homeassistant.components.esphome.voice_assistant.UDP_PORT", new=port_to_use - ): - port = await voice_assistant_udp_pipeline_v1.start_server() - assert port == port_to_use - - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - - assert voice_assistant_udp_pipeline_v1.queue.qsize() == 0 - sock.sendto(b"test", ("127.0.0.1", port)) - - # Give the socket some time to send/receive the data - async with asyncio.timeout(1): - while voice_assistant_udp_pipeline_v1.queue.qsize() == 0: - await asyncio.sleep(0.1) - - assert voice_assistant_udp_pipeline_v1.queue.qsize() == 1 - - voice_assistant_udp_pipeline_v1.stop() - voice_assistant_udp_pipeline_v1.close() - - assert voice_assistant_udp_pipeline_v1.transport.is_closing() - - -async def test_udp_server_queue( - hass: HomeAssistant, - voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, -) -> None: - """Test the UDP server queues incoming data.""" - - voice_assistant_udp_pipeline_v1.started = True - - assert voice_assistant_udp_pipeline_v1.queue.qsize() == 0 - - voice_assistant_udp_pipeline_v1.datagram_received(bytes(1024), ("localhost", 0)) - assert voice_assistant_udp_pipeline_v1.queue.qsize() == 1 - - voice_assistant_udp_pipeline_v1.datagram_received(bytes(1024), ("localhost", 0)) - assert voice_assistant_udp_pipeline_v1.queue.qsize() == 2 - - async for data in voice_assistant_udp_pipeline_v1._iterate_packets(): - assert data == bytes(1024) - break - assert voice_assistant_udp_pipeline_v1.queue.qsize() == 1 # One message removed - - voice_assistant_udp_pipeline_v1.stop() - assert ( - voice_assistant_udp_pipeline_v1.queue.qsize() == 2 - ) # An empty message added by stop - - voice_assistant_udp_pipeline_v1.datagram_received(bytes(1024), ("localhost", 0)) - assert ( - voice_assistant_udp_pipeline_v1.queue.qsize() == 2 - ) # No new messages added after stop - - voice_assistant_udp_pipeline_v1.close() - - # Stopping the UDP server should cause _iterate_packets to break out - # immediately without yielding any data. - has_data = False - async for _data in voice_assistant_udp_pipeline_v1._iterate_packets(): - has_data = True - - assert not has_data, "Server was stopped" - - -async def test_api_pipeline_queue( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test the API pipeline queues incoming data.""" - - voice_assistant_api_pipeline.started = True - - assert voice_assistant_api_pipeline.queue.qsize() == 0 - - voice_assistant_api_pipeline.receive_audio_bytes(bytes(1024)) - assert voice_assistant_api_pipeline.queue.qsize() == 1 - - voice_assistant_api_pipeline.receive_audio_bytes(bytes(1024)) - assert voice_assistant_api_pipeline.queue.qsize() == 2 - - async for data in voice_assistant_api_pipeline._iterate_packets(): - assert data == bytes(1024) - break - assert voice_assistant_api_pipeline.queue.qsize() == 1 # One message removed - - voice_assistant_api_pipeline.stop() - assert ( - voice_assistant_api_pipeline.queue.qsize() == 2 - ) # An empty message added by stop - - voice_assistant_api_pipeline.receive_audio_bytes(bytes(1024)) - assert ( - voice_assistant_api_pipeline.queue.qsize() == 2 - ) # No new messages added after stop - - # Stopping the API Pipeline should cause _iterate_packets to break out - # immediately without yielding any data. - has_data = False - async for _data in voice_assistant_api_pipeline._iterate_packets(): - has_data = True - - assert not has_data, "Pipeline was stopped" - - -async def test_error_calls_handle_finished( - hass: HomeAssistant, - voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, -) -> None: - """Test that the handle_finished callback is called when an error occurs.""" - voice_assistant_udp_pipeline_v1.handle_finished = Mock() - - voice_assistant_udp_pipeline_v1.error_received(Exception()) - - voice_assistant_udp_pipeline_v1.handle_finished.assert_called() - - -@pytest.mark.usefixtures("socket_enabled") -async def test_udp_server_multiple( - unused_udp_port_factory: Callable[[], int], - voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, -) -> None: - """Test that the UDP server raises an error if started twice.""" - with patch( - "homeassistant.components.esphome.voice_assistant.UDP_PORT", - new=unused_udp_port_factory(), - ): - await voice_assistant_udp_pipeline_v1.start_server() - - with ( - patch( - "homeassistant.components.esphome.voice_assistant.UDP_PORT", - new=unused_udp_port_factory(), - ), - pytest.raises(RuntimeError), - ): - await voice_assistant_udp_pipeline_v1.start_server() - - -@pytest.mark.usefixtures("socket_enabled") -async def test_udp_server_after_stopped( - unused_udp_port_factory: Callable[[], int], - voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, -) -> None: - """Test that the UDP server raises an error if started after stopped.""" - voice_assistant_udp_pipeline_v1.close() - with ( - patch( - "homeassistant.components.esphome.voice_assistant.UDP_PORT", - new=unused_udp_port_factory(), - ), - pytest.raises(RuntimeError), - ): - await voice_assistant_udp_pipeline_v1.start_server() - - -async def test_events_converted_correctly( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test the pipeline events produce the correct data to send to the device.""" - - with patch( - "homeassistant.components.esphome.voice_assistant.VoiceAssistantPipeline._send_tts", - ): - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.STT_START, - data={}, - ) - ) - - voice_assistant_api_pipeline.handle_event.assert_called_with( - VoiceAssistantEventType.VOICE_ASSISTANT_STT_START, None - ) - - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.STT_END, - data={"stt_output": {"text": "text"}}, - ) - ) - - voice_assistant_api_pipeline.handle_event.assert_called_with( - VoiceAssistantEventType.VOICE_ASSISTANT_STT_END, {"text": "text"} - ) - - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.INTENT_START, - data={}, - ) - ) - - voice_assistant_api_pipeline.handle_event.assert_called_with( - VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START, None - ) - - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.INTENT_END, - data={ - "intent_output": { - "conversation_id": "conversation-id", - } - }, - ) - ) - - voice_assistant_api_pipeline.handle_event.assert_called_with( - VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END, - {"conversation_id": "conversation-id"}, - ) - - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_START, - data={"tts_input": "text"}, - ) - ) - - voice_assistant_api_pipeline.handle_event.assert_called_with( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START, {"text": "text"} - ) - - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={"tts_output": {"url": "url", "media_id": "media-id"}}, - ) - ) - - voice_assistant_api_pipeline.handle_event.assert_called_with( - VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END, {"url": "url"} - ) - - -async def test_unknown_event_type( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test the API pipeline does not call handle_event for unknown events.""" - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type="unknown-event", - data={}, - ) - ) - - assert not voice_assistant_api_pipeline.handle_event.called - - -async def test_error_event_type( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test the API pipeline calls event handler with error.""" - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.ERROR, - data={"code": "code", "message": "message"}, - ) - ) - - voice_assistant_api_pipeline.handle_event.assert_called_with( - VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, - {"code": "code", "message": "message"}, - ) - - -async def test_send_tts_not_called( - hass: HomeAssistant, - voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, -) -> None: - """Test the UDP server with a v1 device does not call _send_tts.""" - with patch( - "homeassistant.components.esphome.voice_assistant.VoiceAssistantPipeline._send_tts" - ) as mock_send_tts: - voice_assistant_udp_pipeline_v1._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} - }, - ) - ) - - mock_send_tts.assert_not_called() - - -async def test_send_tts_called_udp( - hass: HomeAssistant, - voice_assistant_udp_pipeline_v2: VoiceAssistantUDPPipeline, -) -> None: - """Test the UDP server with a v2 device calls _send_tts.""" - with patch( - "homeassistant.components.esphome.voice_assistant.VoiceAssistantPipeline._send_tts" - ) as mock_send_tts: - voice_assistant_udp_pipeline_v2._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} - }, - ) - ) - - mock_send_tts.assert_called_with(_TEST_MEDIA_ID) - - -async def test_send_tts_called_api( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test the API pipeline calls _send_tts.""" - with patch( - "homeassistant.components.esphome.voice_assistant.VoiceAssistantPipeline._send_tts" - ) as mock_send_tts: - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} - }, - ) - ) - - mock_send_tts.assert_called_with(_TEST_MEDIA_ID) - - -async def test_send_tts_not_called_when_empty( - hass: HomeAssistant, - voice_assistant_udp_pipeline_v1: VoiceAssistantUDPPipeline, - voice_assistant_udp_pipeline_v2: VoiceAssistantUDPPipeline, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test the pipelines do not call _send_tts when the output is empty.""" - with patch( - "homeassistant.components.esphome.voice_assistant.VoiceAssistantPipeline._send_tts" - ) as mock_send_tts: - voice_assistant_udp_pipeline_v1._event_callback( - PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) - ) - - mock_send_tts.assert_not_called() - - voice_assistant_udp_pipeline_v2._event_callback( - PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) - ) - - mock_send_tts.assert_not_called() - - voice_assistant_api_pipeline._event_callback( - PipelineEvent(type=PipelineEventType.TTS_END, data={"tts_output": {}}) - ) - - mock_send_tts.assert_not_called() - - -async def test_send_tts_udp( - hass: HomeAssistant, - voice_assistant_udp_pipeline_v2: VoiceAssistantUDPPipeline, - mock_wav: bytes, -) -> None: - """Test the UDP server calls sendto to transmit audio data to device.""" - with patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", mock_wav), - ): - voice_assistant_udp_pipeline_v2.started = True - voice_assistant_udp_pipeline_v2.transport = Mock(spec=asyncio.DatagramTransport) - with patch.object( - voice_assistant_udp_pipeline_v2.transport, "is_closing", return_value=False - ): - voice_assistant_udp_pipeline_v2._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": { - "media_id": _TEST_MEDIA_ID, - "url": _TEST_OUTPUT_URL, - } - }, - ) - ) - - await voice_assistant_udp_pipeline_v2._tts_done.wait() - - voice_assistant_udp_pipeline_v2.transport.sendto.assert_called() - - -async def test_send_tts_api( - hass: HomeAssistant, - mock_client: APIClient, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, - mock_wav: bytes, -) -> None: - """Test the API pipeline calls cli.send_voice_assistant_audio to transmit audio data to device.""" - with patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", mock_wav), - ): - voice_assistant_api_pipeline.started = True - - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": { - "media_id": _TEST_MEDIA_ID, - "url": _TEST_OUTPUT_URL, - } - }, - ) - ) - - await voice_assistant_api_pipeline._tts_done.wait() - - mock_client.send_voice_assistant_audio.assert_called() - - -async def test_send_tts_wrong_sample_rate( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test that only 16000Hz audio will be streamed.""" - with io.BytesIO() as wav_io: - with wave.open(wav_io, "wb") as wav_file: - wav_file.setframerate(22050) - wav_file.setsampwidth(2) - wav_file.setnchannels(1) - wav_file.writeframes(bytes(_ONE_SECOND)) - - wav_bytes = wav_io.getvalue() - with patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", wav_bytes), - ): - voice_assistant_api_pipeline.started = True - voice_assistant_api_pipeline.transport = Mock(spec=asyncio.DatagramTransport) - - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} - }, - ) - ) - - assert voice_assistant_api_pipeline._tts_task is not None - with pytest.raises(ValueError): - await voice_assistant_api_pipeline._tts_task - - -async def test_send_tts_wrong_format( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test that only WAV audio will be streamed.""" - with ( - patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("raw", bytes(1024)), - ), - ): - voice_assistant_api_pipeline.started = True - voice_assistant_api_pipeline.transport = Mock(spec=asyncio.DatagramTransport) - - voice_assistant_api_pipeline._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} - }, - ) - ) - - assert voice_assistant_api_pipeline._tts_task is not None - with pytest.raises(ValueError): - await voice_assistant_api_pipeline._tts_task - - -async def test_send_tts_not_started( - hass: HomeAssistant, - voice_assistant_udp_pipeline_v2: VoiceAssistantUDPPipeline, - mock_wav: bytes, -) -> None: - """Test the UDP server does not call sendto when not started.""" - with patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", mock_wav), - ): - voice_assistant_udp_pipeline_v2.started = False - voice_assistant_udp_pipeline_v2.transport = Mock(spec=asyncio.DatagramTransport) - - voice_assistant_udp_pipeline_v2._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} - }, - ) - ) - - await voice_assistant_udp_pipeline_v2._tts_done.wait() - - voice_assistant_udp_pipeline_v2.transport.sendto.assert_not_called() - - -async def test_send_tts_transport_none( - hass: HomeAssistant, - voice_assistant_udp_pipeline_v2: VoiceAssistantUDPPipeline, - mock_wav: bytes, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the UDP server does not call sendto when transport is None.""" - with patch( - "homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio", - return_value=("wav", mock_wav), - ): - voice_assistant_udp_pipeline_v2.started = True - voice_assistant_udp_pipeline_v2.transport = None - - voice_assistant_udp_pipeline_v2._event_callback( - PipelineEvent( - type=PipelineEventType.TTS_END, - data={ - "tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL} - }, - ) - ) - await voice_assistant_udp_pipeline_v2._tts_done.wait() - - assert "No transport to send audio to" in caplog.text - - -async def test_wake_word( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test that the pipeline is set to start with Wake word.""" - - async def async_pipeline_from_audio_stream(*args, start_stage, **kwargs): - assert start_stage == PipelineStage.WAKE_WORD - - with ( - patch( - "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch("asyncio.Event.wait"), # TTS wait event - ): - await voice_assistant_api_pipeline.run_pipeline( - device_id="mock-device-id", - conversation_id=None, - flags=2, - ) - - -async def test_wake_word_exception( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test that the pipeline is set to start with Wake word.""" - - async def async_pipeline_from_audio_stream(*args, **kwargs): - raise WakeWordDetectionError("pipeline-not-found", "Pipeline not found") - - with patch( - "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ): - - def handle_event( - event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: - assert data is not None - assert data["code"] == "pipeline-not-found" - assert data["message"] == "Pipeline not found" - - voice_assistant_api_pipeline.handle_event = handle_event - - await voice_assistant_api_pipeline.run_pipeline( - device_id="mock-device-id", - conversation_id=None, - flags=2, - ) - - -async def test_wake_word_abort_exception( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test that the pipeline is set to start with Wake word.""" - - async def async_pipeline_from_audio_stream(*args, **kwargs): - raise WakeWordDetectionAborted - - with ( - patch( - "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ), - patch.object(voice_assistant_api_pipeline, "handle_event") as mock_handle_event, - ): - await voice_assistant_api_pipeline.run_pipeline( - device_id="mock-device-id", - conversation_id=None, - flags=2, - ) - - mock_handle_event.assert_not_called() - - -async def test_timer_events( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], -) -> None: - """Test that injecting timer events results in the correct api client calls.""" - - mock_device: MockESPHomeDevice = await mock_esphome_device( - mock_client=mock_client, - entity_info=[], - user_service=[], - states=[], - device_info={ - "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT - | VoiceAssistantFeature.TIMERS - }, - ) - await hass.async_block_till_done() - dev = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} - ) - - total_seconds = (1 * 60 * 60) + (2 * 60) + 3 - await intent_helper.async_handle( - hass, - "test", - intent_helper.INTENT_START_TIMER, - { - "name": {"value": "test timer"}, - "hours": {"value": 1}, - "minutes": {"value": 2}, - "seconds": {"value": 3}, - }, - device_id=dev.id, - ) - - mock_client.send_voice_assistant_timer_event.assert_called_with( - VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_STARTED, - ANY, - "test timer", - total_seconds, - total_seconds, - True, - ) - - # Increase timer beyond original time and check total_seconds has increased - mock_client.send_voice_assistant_timer_event.reset_mock() - - total_seconds += 5 * 60 - await intent_helper.async_handle( - hass, - "test", - intent_helper.INTENT_INCREASE_TIMER, - { - "name": {"value": "test timer"}, - "minutes": {"value": 5}, - }, - device_id=dev.id, - ) - - mock_client.send_voice_assistant_timer_event.assert_called_with( - VoiceAssistantTimerEventType.VOICE_ASSISTANT_TIMER_UPDATED, - ANY, - "test timer", - total_seconds, - ANY, - True, - ) - - -async def test_unknown_timer_event( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_client: APIClient, - mock_esphome_device: Callable[ - [APIClient, list[EntityInfo], list[UserService], list[EntityState]], - Awaitable[MockESPHomeDevice], - ], -) -> None: - """Test that unknown (new) timer event types do not result in api calls.""" - - mock_device: MockESPHomeDevice = await mock_esphome_device( - mock_client=mock_client, - entity_info=[], - user_service=[], - states=[], - device_info={ - "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT - | VoiceAssistantFeature.TIMERS - }, - ) - await hass.async_block_till_done() - dev = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} - ) - - with patch( - "homeassistant.components.esphome.voice_assistant._TIMER_EVENT_TYPES.from_hass", - side_effect=KeyError, - ): - await intent_helper.async_handle( - hass, - "test", - intent_helper.INTENT_START_TIMER, - { - "name": {"value": "test timer"}, - "hours": {"value": 1}, - "minutes": {"value": 2}, - "seconds": {"value": 3}, - }, - device_id=dev.id, - ) - - mock_client.send_voice_assistant_timer_event.assert_not_called() - - -async def test_invalid_pipeline_id( - hass: HomeAssistant, - voice_assistant_api_pipeline: VoiceAssistantAPIPipeline, -) -> None: - """Test that the pipeline is set to start with Wake word.""" - - invalid_pipeline_id = "invalid-pipeline-id" - - async def async_pipeline_from_audio_stream(*args, **kwargs): - raise PipelineNotFound( - "pipeline_not_found", f"Pipeline {invalid_pipeline_id} not found" - ) - - with patch( - "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", - new=async_pipeline_from_audio_stream, - ): - - def handle_event( - event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR: - assert data is not None - assert data["code"] == "pipeline_not_found" - assert data["message"] == f"Pipeline {invalid_pipeline_id} not found" - - voice_assistant_api_pipeline.handle_event = handle_event - - await voice_assistant_api_pipeline.run_pipeline( - device_id="mock-device-id", - conversation_id=None, - flags=2, - ) From 33814d118036152ec260b38529e2611cf9e27fe5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:50:17 +0200 Subject: [PATCH 0344/1309] Add model ID to sfr_box (#125400) --- homeassistant/components/sfr_box/__init__.py | 1 + tests/components/sfr_box/snapshots/test_binary_sensor.ambr | 4 ++-- tests/components/sfr_box/snapshots/test_button.ambr | 2 +- tests/components/sfr_box/snapshots/test_sensor.ambr | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sfr_box/__init__.py b/homeassistant/components/sfr_box/__init__.py index d386c670365..927e3cb0ef2 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -66,6 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: identifiers={(DOMAIN, system_info.mac_addr)}, name="SFR Box", model=system_info.product_id, + model_id=system_info.product_id, sw_version=system_info.version_mainfirmware, configuration_url=f"http://{entry.data[CONF_HOST]}", ) diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index 0023f65c90e..15308fad91f 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -22,7 +22,7 @@ }), 'manufacturer': None, 'model': 'NB6VAC-FXC-r0', - 'model_id': None, + 'model_id': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, 'primary_config_entry': , @@ -150,7 +150,7 @@ }), 'manufacturer': None, 'model': 'NB6VAC-FXC-r0', - 'model_id': None, + 'model_id': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, 'primary_config_entry': , diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index df097b58c51..67b2198fd2b 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -22,7 +22,7 @@ }), 'manufacturer': None, 'model': 'NB6VAC-FXC-r0', - 'model_id': None, + 'model_id': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, 'primary_config_entry': , diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 46b22448d25..7645a4ad8bf 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -22,7 +22,7 @@ }), 'manufacturer': None, 'model': 'NB6VAC-FXC-r0', - 'model_id': None, + 'model_id': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, 'primary_config_entry': , From e3e48ff9b7f4d5c3426373678ffb60cffa59d900 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 6 Sep 2024 16:52:03 +0200 Subject: [PATCH 0345/1309] Use PEP 695 for decorator typing with type aliases in zha (#124235) --- homeassistant/components/zha/helpers.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index f70c8a9cb3e..56e7d481f2c 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -14,7 +14,7 @@ import logging import re import time from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, ParamSpec, TypeVar, cast +from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, cast from zoneinfo import ZoneInfo import voluptuous as vol @@ -172,9 +172,6 @@ if TYPE_CHECKING: _LogFilterType = Filter | Callable[[LogRecord], bool] -_P = ParamSpec("_P") -_EntityT = TypeVar("_EntityT", bound="ZHAEntity") - _LOGGER = logging.getLogger(__name__) DEBUG_COMP_BELLOWS = "bellows" @@ -1277,7 +1274,7 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData: ) -def convert_zha_error_to_ha_error( +def convert_zha_error_to_ha_error[**_P, _EntityT: ZHAEntity]( func: Callable[Concatenate[_EntityT, _P], Awaitable[None]], ) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: """Decorate ZHA commands and re-raises ZHAException as HomeAssistantError.""" From 069b7a45ed95637c1d80f5ba8cc0a5903caae94c Mon Sep 17 00:00:00 2001 From: Marlon Date: Fri, 6 Sep 2024 16:52:32 +0200 Subject: [PATCH 0346/1309] Set min_power similar to max_power to support all inverters from apsystems (#124247) Set min_power similar to max_power to support all inverters from apsystems ez1 series --- homeassistant/components/apsystems/coordinator.py | 5 +++-- homeassistant/components/apsystems/number.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py index 6ba4f01dbc8..b6e951343f7 100644 --- a/homeassistant/components/apsystems/coordinator.py +++ b/homeassistant/components/apsystems/coordinator.py @@ -36,10 +36,11 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]): async def _async_setup(self) -> None: try: - max_power = (await self.api.get_device_info()).maxPower + device_info = await self.api.get_device_info() except (ConnectionError, TimeoutError): raise UpdateFailed from None - self.api.max_power = max_power + self.api.max_power = device_info.maxPower + self.api.min_power = device_info.minPower async def _async_update_data(self) -> ApSystemsSensorData: output_data = await self.api.get_output_data() diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py index 51e7130587f..01e991f5188 100644 --- a/homeassistant/components/apsystems/number.py +++ b/homeassistant/components/apsystems/number.py @@ -26,7 +26,6 @@ async def async_setup_entry( class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity): """Base sensor to be used with description.""" - _attr_native_min_value = 30 _attr_native_step = 1 _attr_device_class = NumberDeviceClass.POWER _attr_mode = NumberMode.BOX @@ -42,6 +41,7 @@ class ApSystemsMaxOutputNumber(ApSystemsEntity, NumberEntity): self._api = data.coordinator.api self._attr_unique_id = f"{data.device_id}_output_limit" self._attr_native_max_value = data.coordinator.api.max_power + self._attr_native_min_value = data.coordinator.api.min_power async def async_update(self) -> None: """Set the state with the value fetched from the inverter.""" From f86bd3dfee7e50bc4a2201b50c35e37962a00fd1 Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Fri, 6 Sep 2024 07:52:49 -0700 Subject: [PATCH 0347/1309] Improve consistency of sensor strings to reduce confusion in NUT (#124184) Improve consistency of sensor strings to reduce confusion --- homeassistant/components/nut/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index d5b9acbdaad..ec5905fc16c 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -127,8 +127,8 @@ "input_l1_current": { "name": "Input L1 current" }, "input_l2_current": { "name": "Input L2 current" }, "input_l3_current": { "name": "Input L3 current" }, - "input_frequency": { "name": "Input line frequency" }, - "input_frequency_nominal": { "name": "Nominal input line frequency" }, + "input_frequency": { "name": "Input frequency" }, + "input_frequency_nominal": { "name": "Input nominal frequency" }, "input_frequency_status": { "name": "Input frequency status" }, "input_l1_frequency": { "name": "Input L1 line frequency" }, "input_l2_frequency": { "name": "Input L2 line frequency" }, From 6b75c86a17110ca1488037f55346ef0aaad52c2f Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Fri, 6 Sep 2024 07:53:05 -0700 Subject: [PATCH 0348/1309] Move ambient sensors (temperature and humidity) to diagnostic in NUT (#124180) Move ambient sensors (temperature and humidity) to Diagnostic --- homeassistant/components/nut/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 7b61342866b..d2398a560b7 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -927,6 +927,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), "ambient.temperature": SensorEntityDescription( key="ambient.temperature", @@ -934,6 +935,7 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), "watts": SensorEntityDescription( key="watts", From 49b07b3749e6f68d5659490b66e7cc9575d586c3 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Fri, 6 Sep 2024 16:56:43 +0200 Subject: [PATCH 0349/1309] Provide same entities for all Enphase_envoy CT types (#124531) Provide same entities for all Enphase_envoy CT types. --- .../components/enphase_envoy/sensor.py | 101 + .../components/enphase_envoy/strings.json | 54 + .../enphase_envoy/snapshots/test_sensor.ambr | 3926 +++++++++++++++++ 3 files changed, 4081 insertions(+) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index e6c7a585eb7..4dd7f158305 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -36,6 +36,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( PERCENTAGE, UnitOfApparentPower, + UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfFrequency, @@ -295,6 +296,28 @@ CT_NET_CONSUMPTION_SENSORS = ( value_fn=attrgetter("voltage"), on_phase=None, ), + EnvoyCTSensorEntityDescription( + key="net_ct_current", + translation_key="net_ct_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("current"), + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="net_ct_powerfactor", + translation_key="net_ct_powerfactor", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + value_fn=attrgetter("power_factor"), + on_phase=None, + ), EnvoyCTSensorEntityDescription( key="net_consumption_ct_metering_status", translation_key="net_ct_metering_status", @@ -331,6 +354,51 @@ CT_NET_CONSUMPTION_PHASE_SENSORS = { } CT_PRODUCTION_SENSORS = ( + EnvoyCTSensorEntityDescription( + key="production_ct_frequency", + translation_key="production_ct_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=attrgetter("frequency"), + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="production_ct_voltage", + translation_key="production_ct_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=attrgetter("voltage"), + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="production_ct_current", + translation_key="production_ct_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("current"), + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="production_ct_powerfactor", + translation_key="production_ct_powerfactor", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + value_fn=attrgetter("power_factor"), + on_phase=None, + ), EnvoyCTSensorEntityDescription( key="production_ct_metering_status", translation_key="production_ct_metering_status", @@ -399,6 +467,17 @@ CT_STORAGE_SENSORS = ( value_fn=attrgetter("active_power"), on_phase=None, ), + EnvoyCTSensorEntityDescription( + key="storage_ct_frequency", + translation_key="storage_ct_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + entity_registry_enabled_default=False, + value_fn=attrgetter("frequency"), + on_phase=None, + ), EnvoyCTSensorEntityDescription( key="storage_voltage", translation_key="storage_ct_voltage", @@ -411,6 +490,28 @@ CT_STORAGE_SENSORS = ( value_fn=attrgetter("voltage"), on_phase=None, ), + EnvoyCTSensorEntityDescription( + key="storage_ct_current", + translation_key="storage_ct_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("current"), + on_phase=None, + ), + EnvoyCTSensorEntityDescription( + key="storage_ct_powerfactor", + translation_key="storage_ct_powerfactor", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + entity_registry_enabled_default=False, + value_fn=attrgetter("power_factor"), + on_phase=None, + ), EnvoyCTSensorEntityDescription( key="storage_ct_metering_status", translation_key="storage_ct_metering_status", diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index f7964bf2f45..3c48776e448 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -180,12 +180,30 @@ "net_ct_voltage": { "name": "Voltage net consumption CT" }, + "net_ct_current": { + "name": "Net consumption CT current" + }, + "net_ct_powerfactor": { + "name": "Powerfactor net consumption CT" + }, "net_ct_metering_status": { "name": "Metering status net consumption CT" }, "net_ct_status_flags": { "name": "Meter status flags active net consumption CT" }, + "production_ct_frequency": { + "name": "Frequency production CT" + }, + "production_ct_voltage": { + "name": "Voltage production CT" + }, + "production_ct_current": { + "name": "Production CT current" + }, + "production_ct_powerfactor": { + "name": "powerfactor production CT" + }, "production_ct_metering_status": { "name": "Metering status production CT" }, @@ -201,9 +219,18 @@ "battery_discharge": { "name": "Current battery discharge" }, + "storage_ct_frequency": { + "name": "Frequency storage CT" + }, "storage_ct_voltage": { "name": "Voltage storage CT" }, + "storage_ct_current": { + "name": "Storage CT current" + }, + "storage_ct_powerfactor": { + "name": "Powerfactor storage CT" + }, "storage_ct_metering_status": { "name": "Metering status storage CT" }, @@ -225,12 +252,30 @@ "net_ct_voltage_phase": { "name": "Voltage net consumption CT {phase_name}" }, + "net_ct_current_phase": { + "name": "Net consumption CT current {phase_name}" + }, + "net_ct_powerfactor_phase": { + "name": "Powerfactor net consumption CT {phase_name}" + }, "net_ct_metering_status_phase": { "name": "Metering status net consumption CT {phase_name}" }, "net_ct_status_flags_phase": { "name": "Meter status flags active net consumption CT {phase_name}" }, + "production_ct_frequency_phase": { + "name": "Frequency production CT {phase_name}" + }, + "production_ct_voltage_phase": { + "name": "Voltage production CT {phase_name}" + }, + "production_ct_current_phase": { + "name": "Production CT current {phase_name}" + }, + "production_ct_powerfactor_phase": { + "name": "Powerfactor production CT {phase_name}" + }, "production_ct_metering_status_phase": { "name": "Metering status production CT {phase_name}" }, @@ -246,9 +291,18 @@ "battery_discharge_phase": { "name": "Current battery discharge {phase_name}" }, + "storage_ct_frequency_phase": { + "name": "Frequency storage CT {phase_name}" + }, "storage_ct_voltage_phase": { "name": "Voltage storage CT {phase_name}" }, + "storage_ct_current_phase": { + "name": "Storage CT current {phase_name}" + }, + "storage_ct_powerfactor_phase": { + "name": "Powerfactor storage CT {phase_name}" + }, "storage_ct_metering_status_phase": { "name": "Metering status storage CT {phase_name}" }, diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index dde6a6add41..ad937b27167 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -783,6 +783,61 @@ 'state': '50.2', }) # --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_frequency_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency', + 'unique_id': '1234_production_ct_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_frequency_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- # name: test_sensor[envoy_1p_metered][sensor.envoy_1234_lifetime_energy_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1227,6 +1282,230 @@ 'state': 'normal', }) # --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_net_consumption_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current', + 'unique_id': '1234_net_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_net_consumption_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor', + 'unique_id': '1234_net_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.21', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'powerfactor production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor', + 'unique_id': '1234_production_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_powerfactor_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.11', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_production_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current', + 'unique_id': '1234_production_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_production_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- # name: test_sensor[envoy_1p_metered][sensor.envoy_1234_voltage_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1285,6 +1564,64 @@ 'state': '112', }) # --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_voltage_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage', + 'unique_id': '1234_production_ct_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_voltage_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- # name: test_sensor[envoy_1p_metered][sensor.inverter_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -3918,6 +4255,446 @@ 'state': '50.2', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency', + 'unique_id': '1234_production_ct_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_storage_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_storage_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency storage CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_frequency', + 'unique_id': '1234_storage_ct_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_storage_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency storage CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_storage_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.3', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_storage_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_storage_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency storage CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_frequency_phase', + 'unique_id': '1234_storage_ct_frequency_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_storage_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency storage CT l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_storage_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.3', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_storage_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_storage_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency storage CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_frequency_phase', + 'unique_id': '1234_storage_ct_frequency_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_storage_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency storage CT l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_storage_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.2', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_storage_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_storage_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency storage CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_frequency_phase', + 'unique_id': '1234_storage_ct_frequency_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_frequency_storage_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency storage CT l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_storage_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.2', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_lifetime_battery_energy_charged-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6582,6 +7359,1118 @@ 'state': 'normal', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_net_consumption_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current', + 'unique_id': '1234_net_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_net_consumption_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_net_consumption_ct_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_net_consumption_ct_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_net_consumption_ct_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_net_consumption_ct_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_net_consumption_ct_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_net_consumption_ct_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor', + 'unique_id': '1234_net_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.21', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.22', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.23', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.24', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'powerfactor production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor', + 'unique_id': '1234_production_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.11', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.13', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.14', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor storage CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_powerfactor', + 'unique_id': '1234_storage_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor storage CT', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.23', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor storage CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_powerfactor_phase', + 'unique_id': '1234_storage_ct_powerfactor_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor storage CT l1', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.32', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor storage CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_powerfactor_phase', + 'unique_id': '1234_storage_ct_powerfactor_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor storage CT l2', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.23', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor storage CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_powerfactor_phase', + 'unique_id': '1234_storage_ct_powerfactor_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_powerfactor_storage_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor storage CT l3', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_storage_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.24', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_production_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current', + 'unique_id': '1234_production_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_production_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_production_ct_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_production_ct_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_production_ct_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_production_ct_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_production_ct_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_production_ct_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_reserve_battery_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6680,6 +8569,238 @@ 'state': '15', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_storage_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_storage_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Storage CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_current', + 'unique_id': '1234_storage_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_storage_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Storage CT current', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_storage_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_storage_ct_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_storage_ct_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Storage CT current l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_current_phase', + 'unique_id': '1234_storage_ct_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_storage_ct_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Storage CT current l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_storage_ct_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.4', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_storage_ct_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_storage_ct_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Storage CT current l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_current_phase', + 'unique_id': '1234_storage_ct_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_storage_ct_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Storage CT current l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_storage_ct_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_storage_ct_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_storage_ct_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Storage CT current l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_ct_current_phase', + 'unique_id': '1234_storage_ct_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_storage_ct_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Storage CT current l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_storage_ct_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_voltage_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6912,6 +9033,238 @@ 'state': '112', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_voltage_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage', + 'unique_id': '1234_production_ct_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_voltage_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_voltage_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_voltage_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_voltage_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_voltage_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_voltage_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_voltage_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_voltage_storage_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9064,6 +11417,226 @@ 'state': '50.2', }) # --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_frequency_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency', + 'unique_id': '1234_production_ct_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_frequency_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_frequency_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_frequency_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_frequency_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_frequency_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_frequency_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_frequency_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- # name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_lifetime_energy_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -10840,6 +13413,902 @@ 'state': 'normal', }) # --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_net_consumption_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current', + 'unique_id': '1234_net_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_net_consumption_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_net_consumption_ct_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_net_consumption_ct_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_net_consumption_ct_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_net_consumption_ct_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_net_consumption_ct_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_net_consumption_ct_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor', + 'unique_id': '1234_net_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.21', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.22', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.23', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.24', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'powerfactor production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor', + 'unique_id': '1234_production_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.11', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.13', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_powerfactor_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.14', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_production_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current', + 'unique_id': '1234_production_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_production_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_production_ct_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_production_ct_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_production_ct_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_production_ct_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_production_ct_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_production_ct_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- # name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_voltage_net_consumption_ct-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11072,6 +14541,238 @@ 'state': '112', }) # --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_voltage_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage', + 'unique_id': '1234_production_ct_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_voltage_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_voltage_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_voltage_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_voltage_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_voltage_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_voltage_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_voltage_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- # name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11343,6 +15044,61 @@ 'state': '1.234', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_frequency_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency', + 'unique_id': '1234_production_ct_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_frequency_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11507,6 +15263,176 @@ 'state': 'normal', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_powerfactor_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'powerfactor production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor', + 'unique_id': '1234_production_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_powerfactor_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.11', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_production_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current', + 'unique_id': '1234_production_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_production_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_voltage_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage', + 'unique_id': '1234_production_ct_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_voltage_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 883e33e72ab75de5a0b01dceb35d9105202d2825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Jaworski?= Date: Fri, 6 Sep 2024 16:59:14 +0200 Subject: [PATCH 0350/1309] Fix mired range in blebox color temp mode lights (#124258) * fix: use default mired range in belbox lights running in color temp mode * fix: ruff --- homeassistant/components/blebox/light.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 1f994db7243..34f9b24b17b 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -60,6 +60,9 @@ COLOR_MODE_MAP = { class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): """Representation of BleBox lights.""" + _attr_max_mireds = 370 # 1,000,000 divided by 2700 Kelvin = 370 Mireds + _attr_min_mireds = 154 # 1,000,000 divided by 6500 Kelvin = 154 Mireds + def __init__(self, feature: blebox_uniapi.light.Light) -> None: """Initialize a BleBox light.""" super().__init__(feature) @@ -87,12 +90,7 @@ class BleBoxLightEntity(BleBoxEntity[blebox_uniapi.light.Light], LightEntity): Set values to _attr_ibutes if needed. """ - color_mode_tmp = COLOR_MODE_MAP.get(self._feature.color_mode, ColorMode.ONOFF) - if color_mode_tmp == ColorMode.COLOR_TEMP: - self._attr_min_mireds = 1 - self._attr_max_mireds = 255 - - return color_mode_tmp + return COLOR_MODE_MAP.get(self._feature.color_mode, ColorMode.ONOFF) @property def supported_color_modes(self): From 09989e6184044782ddf7ecc5f071b7cf4be36d55 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 6 Sep 2024 17:14:25 +0200 Subject: [PATCH 0351/1309] Fix UnboundLocalError in recorder (#125419) --- homeassistant/components/recorder/migration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 4d9978c641b..df7ff5c4fed 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2594,6 +2594,7 @@ class EventIDPostMigration(BaseRunTimeMigration): # removing the index is the likely all that needs to happen. all_gone = not result + fk_remove_ok = False if all_gone: # Only drop the index if there are no more event_ids in the states table # ex all NULL From ea7b2ecec038bae3328a0a37c4e4e22bfc2f6a9c Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Fri, 6 Sep 2024 17:14:37 +0200 Subject: [PATCH 0352/1309] Improve coordinator test coverage for enphase_envoy (#122375) * Improve coordinator test coverage for enphase_envoy * rename to test_coordinator to test_init for enphase_envoy * Mock pyenphase _obtain_token instead of httpx auth requests in enphase_envoy tests. * Move EnvoyTokenAuth patch to mock_envoy of enphase_envoy --- tests/components/enphase_envoy/conftest.py | 9 + tests/components/enphase_envoy/test_init.py | 221 ++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 tests/components/enphase_envoy/test_init.py diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index ab6e0e4f097..58627211344 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -69,6 +69,11 @@ async def mock_envoy( request: pytest.FixtureRequest, ) -> AsyncGenerator[AsyncMock]: """Define a mocked Envoy fixture.""" + new_token = jwt.encode( + payload={"name": "envoy", "exp": 2007837780}, + key="secret", + algorithm="HS256", + ) with ( patch( "homeassistant.components.enphase_envoy.config_flow.Envoy", @@ -78,6 +83,10 @@ async def mock_envoy( "homeassistant.components.enphase_envoy.Envoy", new=mock_client, ), + patch( + "pyenphase.auth.EnvoyTokenAuth._obtain_token", + return_value=new_token, + ), ): mock_envoy = mock_client.return_value # Add the fixtures specified diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py new file mode 100644 index 00000000000..7b10e784d50 --- /dev/null +++ b/tests/components/enphase_envoy/test_init.py @@ -0,0 +1,221 @@ +"""Test Enphase Envoy runtime.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from jwt import encode +from pyenphase import EnvoyAuthenticationError, EnvoyError, EnvoyTokenAuth +from pyenphase.auth import EnvoyLegacyAuth +import pytest +import respx + +from homeassistant.components.enphase_envoy import DOMAIN +from homeassistant.components.enphase_envoy.const import Platform +from homeassistant.components.enphase_envoy.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_with_pre_v7_firmware( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test enphase_envoy coordinator with pre V7 firmware.""" + mock_envoy.firmware = "5.1.1" + mock_envoy.auth = EnvoyLegacyAuth( + "127.0.0.1", username="test-username", password="test-password" + ) + await setup_integration(hass, config_entry) + + assert config_entry.state is ConfigEntryState.LOADED + + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == "1" + + +@pytest.mark.freeze_time("2024-07-23 00:00:00+00:00") +async def test_token_in_config_file( + hass: HomeAssistant, + mock_envoy: AsyncMock, +) -> None: + """Test coordinator with token provided from config.""" + token = encode( + payload={"name": "envoy", "exp": 1907837780}, + key="secret", + algorithm="HS256", + ) + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title="Envoy 1234", + unique_id="1234", + data={ + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_TOKEN: token, + }, + ) + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") + await setup_integration(hass, entry) + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.state is ConfigEntryState.LOADED + + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == "1" + + +@respx.mock +@pytest.mark.freeze_time("2024-07-23 00:00:00+00:00") +async def test_expired_token_in_config( + hass: HomeAssistant, + mock_envoy: AsyncMock, +) -> None: + """Test coordinator with expired token provided from config.""" + current_token = encode( + # some time in 2021 + payload={"name": "envoy", "exp": 1627314600}, + key="secret", + algorithm="HS256", + ) + + # mock envoy with expired token in config + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title="Envoy 1234", + unique_id="1234", + data={ + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_TOKEN: current_token, + }, + ) + # Make sure to mock pyenphase.auth.EnvoyTokenAuth._obtain_token + # when specifying username and password in EnvoyTokenauth + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", + token=current_token, + envoy_serial="1234", + cloud_username="test_username", + cloud_password="test_password", + ) + await setup_integration(hass, entry) + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.state is ConfigEntryState.LOADED + + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == "1" + + +async def test_coordinator_update_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator update error handling.""" + await setup_integration(hass, config_entry) + + assert (entity_state := hass.states.get("sensor.inverter_1")) + original_state = entity_state + + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed 1"} + mock_envoy.update.side_effect = EnvoyError + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == STATE_UNAVAILABLE + + mock_envoy.reset_mock(return_value=True, side_effect=True) + + mock_envoy.data.raw = {"I": "am changed 2"} + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == original_state.state + + +async def test_coordinator_update_authentication_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test enphase_envoy coordinator update authentication error handling.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + # force HA to detect changed data by changing raw + mock_envoy.data.raw = {"I": "am changed 1"} + mock_envoy.update.side_effect = EnvoyAuthenticationError("This must fail") + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == STATE_UNAVAILABLE + + +@pytest.mark.freeze_time("2024-07-23 00:00:00+00:00") +async def test_coordinator_token_refresh_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, +) -> None: + """Test coordinator with token provided from config.""" + # 63, 69-79 _async_try_refresh_token + token = encode( + # some time in 2021 + payload={"name": "envoy", "exp": 1627314600}, + key="secret", + algorithm="HS256", + ) + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title="Envoy 1234", + unique_id="1234", + data={ + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_TOKEN: token, + }, + ) + # token refresh without username and password specified in + # EnvoyTokenAuthwill force token refresh error + mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") + await setup_integration(hass, entry) + await hass.async_block_till_done(wait_background_tasks=True) + assert entry.state is ConfigEntryState.LOADED + + assert (entity_state := hass.states.get("sensor.inverter_1")) + assert entity_state.state == "1" From 20639b0f023aabc4047c64bb51f3a07136e0b1e1 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 6 Sep 2024 17:56:46 +0200 Subject: [PATCH 0353/1309] Add tests for LCN climate and scene platform (#124466) * Add tests for LCN climate and scene platform * Add type hints * Add snapshots for test_climate * Add snapshots for test_scene * Replace await_called assertion with snapshots * Remove snapshots for simple status changes * Test platform setup using snapshot_platform * Fix type hints * Patch homeassistant.components.lcn context instead of pypck module * Fix side effects caused by patching PchkConnectionManager in lcn platform context --- homeassistant/components/lcn/__init__.py | 3 +- tests/components/lcn/conftest.py | 23 +- tests/components/lcn/fixtures/config.json | 29 ++ .../lcn/fixtures/config_entry_pchk.json | 38 +++ .../lcn/snapshots/test_climate.ambr | 63 ++++ .../components/lcn/snapshots/test_scene.ambr | 93 ++++++ tests/components/lcn/test_climate.py | 287 ++++++++++++++++++ tests/components/lcn/test_init.py | 4 +- tests/components/lcn/test_scene.py | 64 ++++ tests/components/lcn/test_services.py | 111 ++++--- 10 files changed, 656 insertions(+), 59 deletions(-) create mode 100644 tests/components/lcn/snapshots/test_climate.ambr create mode 100644 tests/components/lcn/snapshots/test_scene.ambr create mode 100644 tests/components/lcn/test_climate.py create mode 100644 tests/components/lcn/test_scene.py diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 75f417cb3a5..9817a254d59 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -7,6 +7,7 @@ from functools import partial import logging import pypck +from pypck.connection import PchkConnectionManager from homeassistant import config_entries from homeassistant.const import ( @@ -87,7 +88,7 @@ async def async_setup_entry( } # connect to PCHK - lcn_connection = pypck.connection.PchkConnectionManager( + lcn_connection = PchkConnectionManager( config_entry.data[CONF_IP_ADDRESS], config_entry.data[CONF_PORT], config_entry.data[CONF_USERNAME], diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index b1f28b28465..67c5b9c0b9c 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -1,16 +1,15 @@ """Test configuration and mocks for LCN component.""" -from collections.abc import AsyncGenerator import json from typing import Any from unittest.mock import AsyncMock, Mock, patch import pypck -from pypck.connection import PchkConnectionManager import pypck.module from pypck.module import GroupConnection, ModuleConnection import pytest +from homeassistant.components.lcn import PchkConnectionManager from homeassistant.components.lcn.const import DOMAIN from homeassistant.components.lcn.helpers import AddressType, generate_unique_id from homeassistant.const import CONF_ADDRESS, CONF_DEVICES, CONF_ENTITIES, CONF_HOST @@ -76,11 +75,10 @@ def create_config_entry(name: str) -> MockConfigEntry: options = {} title = entry_data[CONF_HOST] - unique_id = fixture_filename return MockConfigEntry( + entry_id=fixture_filename, domain=DOMAIN, title=title, - unique_id=unique_id, data=entry_data, options=options, ) @@ -98,10 +96,9 @@ def create_config_entry_myhome() -> MockConfigEntry: return create_config_entry("myhome") -@pytest.fixture(name="lcn_connection") async def init_integration( hass: HomeAssistant, entry: MockConfigEntry -) -> AsyncGenerator[MockPchkConnectionManager]: +) -> MockPchkConnectionManager: """Set up the LCN integration in Home Assistant.""" hass.http = Mock() # needs to be mocked as hass.http.register_static_path is called when registering the frontend lcn_connection = None @@ -113,12 +110,22 @@ async def init_integration( entry.add_to_hass(hass) with patch( - "pypck.connection.PchkConnectionManager", + "homeassistant.components.lcn.PchkConnectionManager", side_effect=lcn_connection_factory, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - yield lcn_connection + + return lcn_connection + + +@pytest.fixture(name="lcn_connection") +async def init_lcn_connection( + hass: HomeAssistant, + entry: MockConfigEntry, +) -> MockPchkConnectionManager: + """Set up the LCN integration in Home Assistantand yield connection object.""" + return await init_integration(hass, entry) async def setup_component(hass: HomeAssistant) -> None: diff --git a/tests/components/lcn/fixtures/config.json b/tests/components/lcn/fixtures/config.json index 13b3dd5feed..ed3e3500900 100644 --- a/tests/components/lcn/fixtures/config.json +++ b/tests/components/lcn/fixtures/config.json @@ -91,6 +91,35 @@ "motor": "motor1" } ], + "climates": [ + { + "name": "Climate1", + "address": "s0.m7", + "source": "var1", + "setpoint": "r1varsetpoint", + "lockable": true, + "min_temp": 0, + "max_temp": 40, + "unit_of_measurement": "°C" + } + ], + "scenes": [ + { + "name": "Romantic", + "address": "s0.m7", + "register": 0, + "scene": 0, + "outputs": ["output1", "output2", "relay1"] + }, + { + "name": "Romantic Transition", + "address": "s0.m7", + "register": 0, + "scene": 1, + "outputs": ["output1", "output2", "relay1"], + "transition": 10 + } + ], "binary_sensors": [ { "name": "Sensor_LockRegulator1", diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index 08ccd194578..9a8095ff16d 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -121,6 +121,44 @@ "reverse_time": "RT1200" } }, + { + "address": [0, 7, false], + "name": "Climate1", + "resource": "var1.r1varsetpoint", + "domain": "climate", + "domain_data": { + "source": "VAR1", + "setpoint": "R1VARSETPOINT", + "lockable": true, + "min_temp": 0.0, + "max_temp": 40.0, + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Romantic", + "resource": "0.0", + "domain": "scene", + "domain_data": { + "register": 0, + "scene": 0, + "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], + "transition": null + } + }, + { + "address": [0, 7, false], + "name": "Romantic Transition", + "resource": "0.1", + "domain": "scene", + "domain_data": { + "register": 0, + "scene": 1, + "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], + "transition": 10 + } + }, { "address": [0, 7, false], "name": "Sensor_LockRegulator1", diff --git a/tests/components/lcn/snapshots/test_climate.ambr b/tests/components/lcn/snapshots/test_climate.ambr new file mode 100644 index 00000000000..443b13312d1 --- /dev/null +++ b/tests/components/lcn/snapshots/test_climate.ambr @@ -0,0 +1,63 @@ +# serializer version: 1 +# name: test_setup_lcn_climate[climate.climate1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 40.0, + 'min_temp': 0.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.climate1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Climate1', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-var1.r1varsetpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_climate[climate.climate1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'friendly_name': 'Climate1', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 40.0, + 'min_temp': 0.0, + 'supported_features': , + 'temperature': None, + }), + 'context': , + 'entity_id': 'climate.climate1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/lcn/snapshots/test_scene.ambr b/tests/components/lcn/snapshots/test_scene.ambr new file mode 100644 index 00000000000..c039c4ef951 --- /dev/null +++ b/tests/components/lcn/snapshots/test_scene.ambr @@ -0,0 +1,93 @@ +# serializer version: 1 +# name: test_setup_lcn_scene[scene.romantic-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.romantic', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Romantic', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-0.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_scene[scene.romantic-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Romantic', + }), + 'context': , + 'entity_id': 'scene.romantic', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_lcn_scene[scene.romantic_transition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'scene', + 'entity_category': None, + 'entity_id': 'scene.romantic_transition', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Romantic Transition', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-0.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_scene[scene.romantic_transition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Romantic Transition', + }), + 'context': , + 'entity_id': 'scene.romantic_transition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py new file mode 100644 index 00000000000..db9f137d6bf --- /dev/null +++ b/tests/components/lcn/test_climate.py @@ -0,0 +1,287 @@ +"""Test for the LCN climate platform.""" + +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 +from syrupy.assertion import SnapshotAssertion + +# pylint: disable=hass-component-root-import +from homeassistant.components.climate import DOMAIN as DOMAIN_CLIMATE +from homeassistant.components.climate.const import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.components.lcn.helpers import get_device_connection +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MockConfigEntry, MockModuleConnection, init_integration + +from tests.common import snapshot_platform + + +async def test_setup_lcn_climate( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the setup of climate.""" + with patch("homeassistant.components.lcn.PLATFORMS", [Platform.CLIMATE]): + await init_integration(hass, entry) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_set_hvac_mode_heat(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test the hvac mode is set to heat.""" + await init_integration(hass, entry) + + with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: + state = hass.states.get("climate.climate1") + state.state = HVACMode.OFF + + # command failed + lock_regulator.return_value = False + + await hass.services.async_call( + DOMAIN_CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + lock_regulator.assert_awaited_with(0, False) + + state = hass.states.get("climate.climate1") + assert state is not None + assert state.state != HVACMode.HEAT + + # command success + lock_regulator.reset_mock(return_value=True) + lock_regulator.return_value = True + + await hass.services.async_call( + DOMAIN_CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + lock_regulator.assert_awaited_with(0, False) + + state = hass.states.get("climate.climate1") + assert state is not None + assert state.state == HVACMode.HEAT + + +async def test_set_hvac_mode_off(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test the hvac mode is set off.""" + await init_integration(hass, entry) + + with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: + state = hass.states.get("climate.climate1") + state.state = HVACMode.HEAT + + # command failed + lock_regulator.return_value = False + + await hass.services.async_call( + DOMAIN_CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + lock_regulator.assert_awaited_with(0, True) + + state = hass.states.get("climate.climate1") + assert state is not None + assert state.state != HVACMode.OFF + + # command success + lock_regulator.reset_mock(return_value=True) + lock_regulator.return_value = True + + await hass.services.async_call( + DOMAIN_CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: "climate.climate1", ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + + lock_regulator.assert_awaited_with(0, True) + + state = hass.states.get("climate.climate1") + assert state is not None + assert state.state == HVACMode.OFF + + +async def test_set_temperature(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test the temperature is set.""" + await init_integration(hass, entry) + + with patch.object(MockModuleConnection, "var_abs") as var_abs: + state = hass.states.get("climate.climate1") + state.state = HVACMode.HEAT + + # 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, + ) + + var_abs.assert_not_awaited() + + # command failed + var_abs.reset_mock(return_value=True) + var_abs.return_value = False + + await hass.services.async_call( + DOMAIN_CLIMATE, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.climate1", ATTR_TEMPERATURE: 25.5}, + blocking=True, + ) + + var_abs.assert_awaited_with(Var.R1VARSETPOINT, 25.5, VarUnit.CELSIUS) + + state = hass.states.get("climate.climate1") + assert state is not None + assert state.attributes[ATTR_TEMPERATURE] != 25.5 + + # command success + var_abs.reset_mock(return_value=True) + var_abs.return_value = True + + await hass.services.async_call( + DOMAIN_CLIMATE, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "climate.climate1", ATTR_TEMPERATURE: 25.5}, + blocking=True, + ) + + var_abs.assert_awaited_with(Var.R1VARSETPOINT, 25.5, VarUnit.CELSIUS) + + state = hass.states.get("climate.climate1") + assert state is not None + assert state.attributes[ATTR_TEMPERATURE] == 25.5 + + +async def test_pushed_current_temperature_status_change( + hass: HomeAssistant, + entry: MockConfigEntry, +) -> None: + """Test the climate changes its current temperature on status received.""" + await init_integration(hass, entry) + + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + temperature = VarValue.from_celsius(25.5) + + inp = ModStatusVar(address, Var.VAR1, temperature) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("climate.climate1") + assert state is not None + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.5 + assert state.attributes[ATTR_TEMPERATURE] is None + + +async def test_pushed_setpoint_status_change( + hass: HomeAssistant, + entry: MockConfigEntry, +) -> None: + """Test the climate changes its setpoint on status received.""" + await init_integration(hass, entry) + + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + temperature = VarValue.from_celsius(25.5) + + inp = ModStatusVar(address, Var.R1VARSETPOINT, temperature) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("climate.climate1") + assert state is not None + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None + assert state.attributes[ATTR_TEMPERATURE] == 25.5 + + +async def test_pushed_lock_status_change( + hass: HomeAssistant, + entry: MockConfigEntry, +) -> None: + """Test the climate changes its setpoint on status received.""" + await init_integration(hass, entry) + + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + temperature = VarValue(0x8000) + + inp = ModStatusVar(address, Var.R1VARSETPOINT, temperature) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get("climate.climate1") + assert state is not None + assert state.state == HVACMode.OFF + assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None + assert state.attributes[ATTR_TEMPERATURE] is None + + +async def test_pushed_wrong_input( + hass: HomeAssistant, + entry: MockConfigEntry, +) -> None: + """Test the climate handles wrong input correctly.""" + await init_integration(hass, entry) + + device_connection = get_device_connection(hass, (0, 7, False), entry) + + await device_connection.async_process_input(Unknown("input")) + await hass.async_block_till_done() + + state = hass.states.get("climate.climate1") + assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None + assert state.attributes[ATTR_TEMPERATURE] is None + + +async def test_unload_config_entry( + hass: HomeAssistant, + entry: MockConfigEntry, +) -> None: + """Test the climate is removed when the config entry is unloaded.""" + await init_integration(hass, entry) + + await hass.config_entries.async_forward_entry_unload(entry, DOMAIN_CLIMATE) + state = hass.states.get("climate.climate1") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index c118b98ecef..120db8a1333 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -32,7 +32,9 @@ async def test_async_setup_entry(hass: HomeAssistant, entry, lcn_connection) -> async def test_async_setup_multiple_entries(hass: HomeAssistant, entry, entry2) -> None: """Test a successful setup and unload of multiple entries.""" hass.http = Mock() - with patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager): + with patch( + "homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager + ): for config_entry in (entry, entry2): config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/lcn/test_scene.py b/tests/components/lcn/test_scene.py new file mode 100644 index 00000000000..558893bb76f --- /dev/null +++ b/tests/components/lcn/test_scene.py @@ -0,0 +1,64 @@ +"""Test for the LCN scene platform.""" + +from unittest.mock import patch + +from pypck.lcn_defs import OutputPort, RelayPort +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.scene import DOMAIN as DOMAIN_SCENE +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_ON, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MockConfigEntry, MockModuleConnection, init_integration + +from tests.common import snapshot_platform + + +async def test_setup_lcn_scene( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the setup of switch.""" + with patch("homeassistant.components.lcn.PLATFORMS", [Platform.SCENE]): + await init_integration(hass, entry) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_scene_activate( + hass: HomeAssistant, + entry: MockConfigEntry, +) -> None: + """Test the scene is activated.""" + await init_integration(hass, entry) + with patch.object(MockModuleConnection, "activate_scene") as activate_scene: + await hass.services.async_call( + DOMAIN_SCENE, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "scene.romantic"}, + blocking=True, + ) + + state = hass.states.get("scene.romantic") + assert state is not None + + activate_scene.assert_awaited_with( + 0, 0, [OutputPort.OUTPUT1, OutputPort.OUTPUT2], [RelayPort.RELAY1], None + ) + + +async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test the scene is removed when the config entry is unloaded.""" + await init_integration(hass, entry) + await hass.config_entries.async_forward_entry_unload(entry, DOMAIN_SCENE) + + state = hass.states.get("scene.romantic") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index 9cb53289065..27253a0c7e5 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -32,16 +32,21 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .conftest import MockModuleConnection, MockPchkConnectionManager, setup_component +from .conftest import ( + MockConfigEntry, + MockModuleConnection, + MockPchkConnectionManager, + init_integration, +) -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_output_abs( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test output_abs service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "dim_output") as dim_output: await hass.services.async_call( @@ -59,13 +64,13 @@ async def test_service_output_abs( assert dim_output.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_output_rel( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test output_rel service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "rel_output") as rel_output: await hass.services.async_call( @@ -82,13 +87,13 @@ async def test_service_output_rel( assert rel_output.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_output_toggle( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test output_toggle service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "toggle_output") as toggle_output: await hass.services.async_call( @@ -105,11 +110,13 @@ async def test_service_output_toggle( assert toggle_output.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_relays(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_relays( + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: """Test relays service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "control_relays") as control_relays: await hass.services.async_call( @@ -122,11 +129,13 @@ async def test_service_relays(hass: HomeAssistant, snapshot: SnapshotAssertion) assert control_relays.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_led(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_led( + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: """Test led service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "control_led") as control_led: await hass.services.async_call( @@ -139,13 +148,13 @@ async def test_service_led(hass: HomeAssistant, snapshot: SnapshotAssertion) -> assert control_led.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_var_abs( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test var_abs service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "var_abs") as var_abs: await hass.services.async_call( @@ -163,13 +172,13 @@ async def test_service_var_abs( assert var_abs.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_var_rel( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test var_rel service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "var_rel") as var_rel: await hass.services.async_call( @@ -188,13 +197,13 @@ async def test_service_var_rel( assert var_rel.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_var_reset( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test var_reset service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "var_reset") as var_reset: await hass.services.async_call( @@ -207,13 +216,13 @@ async def test_service_var_reset( assert var_reset.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_lock_regulator( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test lock_regulator service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "lock_regulator") as lock_regulator: await hass.services.async_call( @@ -230,13 +239,13 @@ async def test_service_lock_regulator( assert lock_regulator.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_send_keys( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test send_keys service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "send_keys") as send_keys: await hass.services.async_call( @@ -254,13 +263,13 @@ async def test_service_send_keys( assert send_keys.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_send_keys_hit_deferred( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test send_keys (hit_deferred) service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) keys = [[False] * 8 for i in range(4)] keys[0][0] = True @@ -306,13 +315,13 @@ async def test_service_send_keys_hit_deferred( ) -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_lock_keys( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test lock_keys service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "lock_keys") as lock_keys: await hass.services.async_call( @@ -325,13 +334,13 @@ async def test_service_lock_keys( assert lock_keys.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_lock_keys_tab_a_temporary( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test lock_keys (tab_a_temporary) service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) # success with patch.object( @@ -372,13 +381,13 @@ async def test_service_lock_keys_tab_a_temporary( ) -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_dyn_text( - hass: HomeAssistant, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: """Test dyn_text service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "dyn_text") as dyn_text: await hass.services.async_call( @@ -391,11 +400,13 @@ async def test_service_dyn_text( assert dyn_text.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_pck(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_pck( + hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: """Test pck service.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "pck") as pck: await hass.services.async_call( @@ -408,11 +419,13 @@ async def test_service_pck(hass: HomeAssistant, snapshot: SnapshotAssertion) -> assert pck.await_args.args == snapshot() -@patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_called_with_invalid_host_id(hass: HomeAssistant) -> None: +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_service_called_with_invalid_host_id( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test service was called with non existing host id.""" await async_setup_component(hass, "persistent_notification", {}) - await setup_component(hass) + await init_integration(hass, entry) with patch.object(MockModuleConnection, "pck") as pck, pytest.raises(ValueError): await hass.services.async_call( From ee59303d3c60eabe8aba13239af4050353f1d193 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 6 Sep 2024 10:57:09 -0500 Subject: [PATCH 0354/1309] Use first media player announcement format for TTS (#125237) * Use ANNOUNCEMENT format from first media player for tts * Fix formatting --------- Co-authored-by: Paulus Schoutsen --- .../components/assist_satellite/entity.py | 11 ++- .../components/esphome/assist_satellite.py | 27 +++++++ .../components/esphome/entry_data.py | 4 + .../components/esphome/media_player.py | 10 ++- .../assist_satellite/test_entity.py | 2 +- .../esphome/test_assist_satellite.py | 73 ++++++++++++++++++- 6 files changed, 123 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 6ec40ae24f7..38973f15f55 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -72,6 +72,7 @@ class AssistSatelliteEntity(entity.Entity): _run_has_tts: bool = False _is_announcing = False _wake_word_intercept_future: asyncio.Future[str | None] | None = None + _attr_tts_options: dict[str, Any] | None = None __assist_satellite_state = AssistSatelliteState.LISTENING_WAKE_WORD @@ -91,6 +92,11 @@ class AssistSatelliteEntity(entity.Entity): """Entity ID of the VAD sensitivity to use for the next conversation.""" return self._attr_vad_sensitivity_entity_id + @property + def tts_options(self) -> dict[str, Any] | None: + """Options passed for text-to-speech.""" + return self._attr_tts_options + async def async_intercept_wake_word(self) -> str | None: """Intercept the next wake word from the satellite. @@ -137,6 +143,9 @@ class AssistSatelliteEntity(entity.Entity): if pipeline.tts_voice is not None: tts_options[tts.ATTR_VOICE] = pipeline.tts_voice + if self.tts_options is not None: + tts_options.update(self.tts_options) + media_id = tts_generate_media_source_id( self.hass, message, @@ -253,7 +262,7 @@ class AssistSatelliteEntity(entity.Entity): pipeline_id=self._resolve_pipeline(), conversation_id=self._conversation_id, device_id=device_id, - tts_audio_output="wav", + tts_audio_output=self.tts_options, wake_word_phrase=wake_word_phrase, audio_settings=AudioSettings( silence_seconds=self._resolve_vad_sensitivity() diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 48bb9ec5507..f84940eadc4 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -6,12 +6,14 @@ import asyncio from collections.abc import AsyncIterable from functools import partial import io +from itertools import chain import logging import socket from typing import Any, cast import wave from aioesphomeapi import ( + MediaPlayerFormatPurpose, VoiceAssistantAudioSettings, VoiceAssistantCommandFlag, VoiceAssistantEventType, @@ -288,6 +290,18 @@ class EsphomeAssistSatellite( end_stage = PipelineStage.TTS + if feature_flags & VoiceAssistantFeature.SPEAKER: + # Stream WAV audio + self._attr_tts_options = { + tts.ATTR_PREFERRED_FORMAT: "wav", + tts.ATTR_PREFERRED_SAMPLE_RATE: 16000, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 1, + tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + } + else: + # ANNOUNCEMENT format from media player + self._update_tts_format() + # Run the pipeline _LOGGER.debug("Running pipeline from %s to %s", start_stage, end_stage) self.entry_data.async_set_assist_pipeline_state(True) @@ -340,6 +354,19 @@ class EsphomeAssistSatellite( timer_info.is_active, ) + def _update_tts_format(self) -> None: + """Update the TTS format from the first media player.""" + for supported_format in chain(*self.entry_data.media_player_formats.values()): + # Find first announcement format + if supported_format.purpose == MediaPlayerFormatPurpose.ANNOUNCEMENT: + self._attr_tts_options = { + tts.ATTR_PREFERRED_FORMAT: supported_format.format, + tts.ATTR_PREFERRED_SAMPLE_RATE: supported_format.sample_rate, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: supported_format.num_channels, + tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + } + break + async def _stream_tts_audio( self, media_id: str, diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 6fc40612c48..f1b5218eec7 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -31,6 +31,7 @@ from aioesphomeapi import ( LightInfo, LockInfo, MediaPlayerInfo, + MediaPlayerSupportedFormat, NumberInfo, SelectInfo, SensorInfo, @@ -148,6 +149,9 @@ class RuntimeEntryData: tuple[type[EntityInfo], int], list[Callable[[EntityInfo], None]] ] = field(default_factory=dict) original_options: dict[str, Any] = field(default_factory=dict) + media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field( + default_factory=lambda: defaultdict(list) + ) @property def name(self) -> str: diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index f7c5d7011f8..4d57552bb19 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import partial -from typing import Any +from typing import Any, cast from aioesphomeapi import ( EntityInfo, @@ -66,6 +66,9 @@ class EsphomeMediaPlayer( if self._static_info.supports_pause: flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY self._attr_supported_features = flags + self._entry_data.media_player_formats[self.entity_id] = cast( + MediaPlayerInfo, static_info + ).supported_formats @property @esphome_state_property @@ -103,6 +106,11 @@ class EsphomeMediaPlayer( self._key, media_url=media_id, announcement=announcement ) + async def async_will_remove_from_hass(self) -> None: + """Handle entity being removed.""" + await super().async_will_remove_from_hass() + self._entry_data.media_player_formats.pop(self.entity_id, None) + async def async_browse_media( self, media_content_type: MediaType | str | None = None, diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 2e4caca030b..ec52d8abff4 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -61,7 +61,7 @@ async def test_entity_state( assert kwargs["stt_stream"] is audio_stream assert kwargs["pipeline_id"] is None assert kwargs["device_id"] is None - assert kwargs["tts_audio_output"] == "wav" + assert kwargs["tts_audio_output"] is None assert kwargs["wake_word_phrase"] is None assert kwargs["audio_settings"] == AudioSettings( silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT) diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index f024ca3b078..1c7f7320a85 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -11,6 +11,9 @@ from aioesphomeapi import ( APIClient, EntityInfo, EntityState, + MediaPlayerFormatPurpose, + MediaPlayerInfo, + MediaPlayerSupportedFormat, UserService, VoiceAssistantAudioSettings, VoiceAssistantCommandFlag, @@ -20,7 +23,7 @@ from aioesphomeapi import ( ) import pytest -from homeassistant.components import assist_satellite +from homeassistant.components import assist_satellite, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite.entity import ( AssistSatelliteEntity, @@ -820,3 +823,71 @@ async def test_streaming_tts_errors( VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, {}, ) + + +async def test_tts_format_from_media_player( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that the text-to-speech format is pulled from the first media player.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[ + MediaPlayerInfo( + object_id="mymedia_player", + key=1, + name="my media_player", + unique_id="my_media_player", + supports_pause=True, + supported_formats=[ + MediaPlayerSupportedFormat( + format="flac", + sample_rate=48000, + num_channels=2, + purpose=MediaPlayerFormatPurpose.DEFAULT, + ), + # This is the format that should be used for tts + MediaPlayerSupportedFormat( + format="mp3", + sample_rate=22050, + num_channels=1, + purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + ), + ], + ) + ], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + ) as mock_pipeline_from_audio_stream: + await satellite.handle_pipeline_start( + conversation_id="", + flags=0, + audio_settings=VoiceAssistantAudioSettings(), + wake_word_phrase=None, + ) + + mock_pipeline_from_audio_stream.assert_called_once() + kwargs = mock_pipeline_from_audio_stream.call_args_list[0].kwargs + + # Should be ANNOUNCEMENT format from media player + assert kwargs.get("tts_audio_output") == { + tts.ATTR_PREFERRED_FORMAT: "mp3", + tts.ATTR_PREFERRED_SAMPLE_RATE: 22050, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 1, + tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + } From 741add066677000504d43cbb7dafd7ed33bd0bfe Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Fri, 6 Sep 2024 18:09:43 +0200 Subject: [PATCH 0355/1309] Replace strings with constants in Bang & Olufsen testing (#125423) Replace strings with constants in service calls --- .../bang_olufsen/test_media_player.py | 104 ++++++++++-------- 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 9928a626a4f..70743cd2cca 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -36,6 +36,18 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PLAY_PAUSE, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, + SERVICE_MEDIA_STOP, + SERVICE_PLAY_MEDIA, + SERVICE_SELECT_SOURCE, + SERVICE_TURN_OFF, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, MediaPlayerState, MediaType, ) @@ -385,8 +397,8 @@ async def test_async_turn_off( ) await hass.services.async_call( - "media_player", - "turn_off", + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, blocking=True, ) @@ -416,8 +428,8 @@ async def test_async_set_volume_level( assert ATTR_MEDIA_VOLUME_LEVEL not in states.attributes await hass.services.async_call( - "media_player", - "volume_set", + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: TEST_VOLUME_HOME_ASSISTANT_FORMAT, @@ -454,8 +466,8 @@ async def test_async_mute_volume( assert ATTR_MEDIA_VOLUME_MUTED not in states.attributes await hass.services.async_call( - "media_player", - "volume_mute", + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: TEST_VOLUME_HOME_ASSISTANT_FORMAT, @@ -509,8 +521,8 @@ async def test_async_media_play_pause( assert states.state == BANG_OLUFSEN_STATES[initial_state.value] await hass.services.async_call( - "media_player", - "media_play_pause", + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY_PAUSE, {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, blocking=True, ) @@ -539,8 +551,8 @@ async def test_async_media_stop( assert states.state == BANG_OLUFSEN_STATES[TEST_PLAYBACK_STATE_PLAYING.value] await hass.services.async_call( - "media_player", - "media_stop", + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, blocking=True, ) @@ -560,8 +572,8 @@ async def test_async_media_next_track( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "media_next_track", + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, blocking=True, ) @@ -601,8 +613,8 @@ async def test_async_media_seek( # Check results with expected_result: await hass.services.async_call( - "media_player", - "media_seek", + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_SEEK, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_SEEK_POSITION: TEST_SEEK_POSITION_HOME_ASSISTANT_FORMAT, @@ -624,8 +636,8 @@ async def test_async_media_previous_track( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "media_previous_track", + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, blocking=True, ) @@ -644,8 +656,8 @@ async def test_async_clear_playlist( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "clear_playlist", + MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, blocking=True, ) @@ -680,8 +692,8 @@ async def test_async_select_source( with expected_result: await hass.services.async_call( - "media_player", - "select_source", + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_INPUT_SOURCE: source, @@ -705,8 +717,8 @@ async def test_async_play_media_invalid_type( with pytest.raises(ServiceValidationError) as exc_info: await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "test", @@ -734,8 +746,8 @@ async def test_async_play_media_url( await async_setup_component(hass, "media_source", {"media_source": {}}) await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/doorbell.mp3", @@ -760,8 +772,8 @@ async def test_async_play_media_overlay_absolute_volume_uri( await async_setup_component(hass, "media_source", {"media_source": {}}) await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/doorbell.mp3", @@ -792,8 +804,8 @@ async def test_async_play_media_overlay_invalid_offset_volume_tts( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "Dette er en test", @@ -829,8 +841,8 @@ async def test_async_play_media_overlay_offset_volume_tts( volume_callback(TEST_VOLUME) await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "This is a test", @@ -859,8 +871,8 @@ async def test_async_play_media_tts( await async_setup_component(hass, "media_source", {"media_source": {}}) await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/doorbell.mp3", @@ -883,8 +895,8 @@ async def test_async_play_media_radio( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "1234567890123456", @@ -909,8 +921,8 @@ async def test_async_play_media_favourite( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "1", @@ -934,8 +946,8 @@ async def test_async_play_media_deezer_flow( # Send a service call await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "flow", @@ -961,8 +973,8 @@ async def test_async_play_media_deezer_playlist( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "playlist:1234567890", @@ -988,8 +1000,8 @@ async def test_async_play_media_deezer_track( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "1234567890", @@ -1017,8 +1029,8 @@ async def test_async_play_media_invalid_deezer( with pytest.raises(HomeAssistantError) as exc_info: await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "flow", @@ -1054,8 +1066,8 @@ async def test_async_play_media_url_m3u( ), ): await hass.services.async_call( - "media_player", - "play_media", + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, { ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, ATTR_MEDIA_CONTENT_ID: "media-source://media_source/local/doorbell.mp3", From cd3059aa14e5972d829fd24b54bc6fb37777fcf7 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Fri, 6 Sep 2024 12:22:59 -0400 Subject: [PATCH 0356/1309] Nice G.O. code quality improvements (#124319) * Bring Nice G.O. up to platinum * Switch to listen in coordinator * Tests * Remove parallel updates from coordinator * Unsub from events on config entry unload * Detect WS disconnection * Tests * Fix tests * Set unsub to None after unsubbing * Wait 5 seconds before setting update error to prevent excessive errors * Tweaks * More tweaks * Tweaks part 2 * Potential test for hass stopping * Improve reconnect handling and test on Homeassistant stop event * Move event handler to entry init * Patch const instead of asyncio.sleep --------- Co-authored-by: jbouwh --- homeassistant/components/nice_go/__init__.py | 8 +- .../components/nice_go/coordinator.py | 85 +++++++++-- homeassistant/components/nice_go/event.py | 6 +- .../components/nice_go/manifest.json | 3 +- tests/components/nice_go/test_diagnostics.py | 2 + tests/components/nice_go/test_event.py | 4 +- tests/components/nice_go/test_init.py | 141 ++++++++++++++++-- 7 files changed, 221 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/nice_go/__init__.py b/homeassistant/components/nice_go/__init__.py index ab3dc06e3c1..b217112c192 100644 --- a/homeassistant/components/nice_go/__init__.py +++ b/homeassistant/components/nice_go/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from .coordinator import NiceGOUpdateCoordinator @@ -25,8 +25,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NiceGOConfigEntry) -> bo """Set up Nice G.O. from a config entry.""" coordinator = NiceGOUpdateCoordinator(hass) + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, coordinator.async_ha_stop) + ) await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator entry.async_create_background_task( @@ -35,6 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NiceGOConfigEntry) -> bo "nice_go_websocket_task", ) + entry.async_on_unload(coordinator.unsubscribe) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index 323e0a08fe8..d6693db2d8a 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime import json import logging -from typing import Any +from typing import TYPE_CHECKING, Any from nice_go import ( BARRIER_STATUS, @@ -20,7 +21,7 @@ from nice_go import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -35,6 +36,9 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +RECONNECT_ATTEMPTS = 3 +RECONNECT_DELAY = 5 + @dataclass class NiceGODevice: @@ -70,7 +74,16 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): self.email = self.config_entry.data[CONF_EMAIL] self.password = self.config_entry.data[CONF_PASSWORD] self.api = NiceGOApi() - self.ws_connected = False + self._unsub_connected: Callable[[], None] | None = None + self._unsub_data: Callable[[], None] | None = None + self._unsub_connection_lost: Callable[[], None] | None = None + self.connected = False + self._hass_stopping: bool = hass.is_stopping + + @callback + def async_ha_stop(self, event: Event) -> None: + """Stop reconnecting if hass is stopping.""" + self._hass_stopping = True async def _parse_barrier(self, barrier_state: BarrierState) -> NiceGODevice | None: """Parse barrier data.""" @@ -178,16 +191,30 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): async def client_listen(self) -> None: """Listen to the websocket for updates.""" - self.api.event(self.on_connected) - self.api.event(self.on_data) - try: - await self.api.connect(reconnect=True) - except ApiError: - _LOGGER.exception("API error") + self._unsub_connected = self.api.listen("on_connected", self.on_connected) + self._unsub_data = self.api.listen("on_data", self.on_data) + self._unsub_connection_lost = self.api.listen( + "on_connection_lost", self.on_connection_lost + ) - if not self.hass.is_stopping: - await asyncio.sleep(5) - await self.client_listen() + for _ in range(RECONNECT_ATTEMPTS): + if self._hass_stopping: + return + + try: + await self.api.connect(reconnect=True) + except ApiError: + _LOGGER.exception("API error") + else: + return + + await asyncio.sleep(RECONNECT_DELAY) + + self.async_set_update_error( + TimeoutError( + "Failed to connect to the websocket, reconnect attempts exhausted" + ) + ) async def on_data(self, data: dict[str, Any]) -> None: """Handle incoming data from the websocket.""" @@ -220,4 +247,38 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): async def on_connected(self) -> None: """Handle the websocket connection.""" _LOGGER.debug("Connected to the websocket") + self.connected = True + await self.api.subscribe(self.organization_id) + + if not self.last_update_success: + self.async_set_updated_data(self.data) + + async def on_connection_lost(self, data: dict[str, Exception]) -> None: + """Handle the websocket connection loss. Don't need to do much since the library will automatically reconnect.""" + _LOGGER.debug("Connection lost to the websocket") + self.connected = False + + # Give some time for reconnection + await asyncio.sleep(RECONNECT_DELAY) + if self.connected: + _LOGGER.debug("Reconnected, not setting error") + return + + # There's likely a problem with the connection, and not the server being flaky + self.async_set_update_error(data["exception"]) + + def unsubscribe(self) -> None: + """Unsubscribe from the websocket.""" + if TYPE_CHECKING: + assert self._unsub_connected is not None + assert self._unsub_data is not None + assert self._unsub_connection_lost is not None + + self._unsub_connection_lost() + self._unsub_connected() + self._unsub_data() + self._unsub_connected = None + self._unsub_data = None + self._unsub_connection_lost = None + _LOGGER.debug("Unsubscribed from the websocket") diff --git a/homeassistant/components/nice_go/event.py b/homeassistant/components/nice_go/event.py index a19511b0b11..cd9198bcd26 100644 --- a/homeassistant/components/nice_go/event.py +++ b/homeassistant/components/nice_go/event.py @@ -40,7 +40,11 @@ class NiceGOEventEntity(NiceGOEntity, EventEntity): async def async_added_to_hass(self) -> None: """Listen for events.""" await super().async_added_to_hass() - self.coordinator.api.event(self.on_barrier_obstructed) + self.async_on_remove( + self.coordinator.api.listen( + "on_barrier_obstructed", self.on_barrier_obstructed + ) + ) async def on_barrier_obstructed(self, data: dict[str, Any]) -> None: """Handle barrier obstructed event.""" diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index 884f2eb7b18..315f23d949d 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -4,7 +4,8 @@ "codeowners": ["@IceBotYT"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nice_go", + "integration_type": "hub", "iot_class": "cloud_push", - "loggers": ["nice-go"], + "loggers": ["nice_go"], "requirements": ["nice-go==0.3.8"] } diff --git a/tests/components/nice_go/test_diagnostics.py b/tests/components/nice_go/test_diagnostics.py index f91f5748792..5c8647f3d6e 100644 --- a/tests/components/nice_go/test_diagnostics.py +++ b/tests/components/nice_go/test_diagnostics.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from syrupy.filters import props @@ -14,6 +15,7 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +@pytest.mark.freeze_time("2024-08-27") async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/nice_go/test_event.py b/tests/components/nice_go/test_event.py index 0038b2882ad..1c1b70532f4 100644 --- a/tests/components/nice_go/test_event.py +++ b/tests/components/nice_go/test_event.py @@ -19,10 +19,10 @@ async def test_barrier_obstructed( mock_config_entry: MockConfigEntry, ) -> None: """Test barrier obstructed.""" - mock_nice_go.event = MagicMock() + mock_nice_go.listen = MagicMock() await setup_integration(hass, mock_config_entry, [Platform.EVENT]) - await mock_nice_go.event.call_args_list[2][0][0]({"deviceId": "1"}) + await mock_nice_go.listen.call_args_list[3][0][1]({"deviceId": "1"}) await hass.async_block_till_done() event_state = hass.states.get("event.test_garage_1_barrier_obstructed") diff --git a/tests/components/nice_go/test_init.py b/tests/components/nice_go/test_init.py index 5568a7ea62a..9c9bf28ca7a 100644 --- a/tests/components/nice_go/test_init.py +++ b/tests/components/nice_go/test_init.py @@ -1,7 +1,8 @@ """Test Nice G.O. init.""" +import asyncio from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory from nice_go import ApiError, AuthFailedError, Barrier, BarrierState @@ -10,8 +11,8 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.nice_go.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import issue_registry as ir from . import setup_integration @@ -209,11 +210,11 @@ async def test_on_data_none_parsed( ) -> None: """Test on data with None parsed.""" - mock_nice_go.event = MagicMock() + mock_nice_go.listen = MagicMock() await setup_integration(hass, mock_config_entry, [Platform.COVER]) - await mock_nice_go.event.call_args[0][0]( + await mock_nice_go.listen.call_args_list[1][0][1]( { "data": { "devicesStatesUpdateFeed": { @@ -243,18 +244,74 @@ async def test_on_connected( ) -> None: """Test on connected.""" - mock_nice_go.event = MagicMock() + mock_nice_go.listen = MagicMock() await setup_integration(hass, mock_config_entry, [Platform.COVER]) - assert mock_nice_go.event.call_count == 2 + assert mock_nice_go.listen.call_count == 3 mock_nice_go.subscribe = AsyncMock() - await mock_nice_go.event.call_args_list[0][0][0]() + await mock_nice_go.listen.call_args_list[0][0][1]() assert mock_nice_go.subscribe.call_count == 1 +async def test_on_connection_lost( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test on connection lost.""" + + mock_nice_go.listen = MagicMock() + + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + + assert mock_nice_go.listen.call_count == 3 + + with patch("homeassistant.components.nice_go.coordinator.RECONNECT_DELAY", 0): + await mock_nice_go.listen.call_args_list[2][0][1]( + {"exception": ValueError("test")} + ) + + assert hass.states.get("cover.test_garage_1").state == "unavailable" + + # Now fire connected + + mock_nice_go.subscribe = AsyncMock() + + await mock_nice_go.listen.call_args_list[0][0][1]() + + assert mock_nice_go.subscribe.call_count == 1 + + assert hass.states.get("cover.test_garage_1").state == "closed" + + +async def test_on_connection_lost_reconnect( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test on connection lost with reconnect.""" + + mock_nice_go.listen = MagicMock() + + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + + assert mock_nice_go.listen.call_count == 3 + + assert hass.states.get("cover.test_garage_1").state == "closed" + + with patch("homeassistant.components.nice_go.coordinator.RECONNECT_DELAY", 0): + await mock_nice_go.listen.call_args_list[2][0][1]( + {"exception": ValueError("test")} + ) + + assert hass.states.get("cover.test_garage_1").state == "unavailable" + + async def test_no_connection_state( hass: HomeAssistant, mock_nice_go: AsyncMock, @@ -262,13 +319,13 @@ async def test_no_connection_state( ) -> None: """Test parsing barrier with no connection state.""" - mock_nice_go.event = MagicMock() + mock_nice_go.listen = MagicMock() await setup_integration(hass, mock_config_entry, [Platform.COVER]) - assert mock_nice_go.event.call_count == 2 + assert mock_nice_go.listen.call_count == 3 - await mock_nice_go.event.call_args[0][0]( + await mock_nice_go.listen.call_args_list[1][0][1]( { "data": { "devicesStatesUpdateFeed": { @@ -286,3 +343,65 @@ async def test_no_connection_state( ) assert hass.states.get("cover.test_garage_1").state == "unavailable" + + +async def test_connection_attempts_exhausted( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test connection attempts exhausted.""" + + mock_nice_go.connect.side_effect = ApiError + + with ( + patch("homeassistant.components.nice_go.coordinator.RECONNECT_ATTEMPTS", 1), + patch("homeassistant.components.nice_go.coordinator.RECONNECT_DELAY", 0), + ): + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + + assert "API error" in caplog.text + assert "Error requesting Nice G.O. data" in caplog.text + + +async def test_reconnect_hass_stopping( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reconnect with hass stopping.""" + + mock_nice_go.listen = MagicMock() + mock_nice_go.connect.side_effect = ApiError + + wait_for_hass = asyncio.Event() + + @callback + def _async_ha_stop(event: Event) -> None: + """Stop reconnecting if hass is stopping.""" + wait_for_hass.set() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_ha_stop) + + with ( + patch("homeassistant.components.nice_go.coordinator.RECONNECT_DELAY", 0.1), + patch("homeassistant.components.nice_go.coordinator.RECONNECT_ATTEMPTS", 20), + ): + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await wait_for_hass.wait() + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_nice_go.connect.call_count < 10 + + assert len(hass._background_tasks) == 0 + + assert "API error" in caplog.text + assert ( + "Failed to connect to the websocket, reconnect attempts exhausted" + not in caplog.text + ) From b9bd8f6b34e82343ce66e26bb5f583c0f68e0b61 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Fri, 6 Sep 2024 18:30:04 +0200 Subject: [PATCH 0357/1309] Add switch platform to opentherm_gw (#125410) * WIP * * Add switch platform * Add tests for switches * Remove unnecessary block_till_done-s * Test that entities get added in a disabled state separately * Convert to parametrized test * Use fixture to add entities enabled. --- .../components/opentherm_gw/__init__.py | 8 +- .../components/opentherm_gw/strings.json | 5 + .../components/opentherm_gw/switch.py | 79 +++++++++++++ tests/components/opentherm_gw/test_switch.py | 111 ++++++++++++++++++ 4 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/opentherm_gw/switch.py create mode 100644 tests/components/opentherm_gw/test_switch.py diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index dfce2206df7..c7a52e3d5d3 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -90,7 +90,13 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.SENSOR, + Platform.SWITCH, +] async def options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 006ccd1909b..b23e1eb7687 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -309,6 +309,11 @@ "outside_temperature": { "name": "Outside temperature" } + }, + "switch": { + "central_heating_override_n": { + "name": "Force central heating {circuit_number} on" + } } }, "options": { diff --git a/homeassistant/components/opentherm_gw/switch.py b/homeassistant/components/opentherm_gw/switch.py new file mode 100644 index 00000000000..6076634b160 --- /dev/null +++ b/homeassistant/components/opentherm_gw/switch.py @@ -0,0 +1,79 @@ +"""Support for OpenTherm Gateway switches.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OpenThermGatewayHub +from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, GATEWAY_DEVICE_DESCRIPTION +from .entity import OpenThermEntity, OpenThermEntityDescription + + +@dataclass(frozen=True, kw_only=True) +class OpenThermSwitchEntityDescription( + OpenThermEntityDescription, SwitchEntityDescription +): + """Describes opentherm_gw switch entity.""" + + turn_off_action: Callable[[OpenThermGatewayHub], Awaitable[int | None]] + turn_on_action: Callable[[OpenThermGatewayHub], Awaitable[int | None]] + + +SWITCH_DESCRIPTIONS: tuple[OpenThermSwitchEntityDescription, ...] = ( + OpenThermSwitchEntityDescription( + key="central_heating_1_override", + translation_key="central_heating_override_n", + translation_placeholders={"circuit_number": "1"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + turn_off_action=lambda hub: hub.gateway.set_ch_enable_bit(0), + turn_on_action=lambda hub: hub.gateway.set_ch_enable_bit(1), + ), + OpenThermSwitchEntityDescription( + key="central_heating_2_override", + translation_key="central_heating_override_n", + translation_placeholders={"circuit_number": "2"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + turn_off_action=lambda hub: hub.gateway.set_ch2_enable_bit(0), + turn_on_action=lambda hub: hub.gateway.set_ch2_enable_bit(1), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the OpenTherm Gateway switches.""" + gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] + + async_add_entities( + OpenThermSwitch(gw_hub, description) for description in SWITCH_DESCRIPTIONS + ) + + +class OpenThermSwitch(OpenThermEntity, SwitchEntity): + """Represent an OpenTherm Gateway switch.""" + + _attr_assumed_state = True + _attr_entity_category = EntityCategory.CONFIG + _attr_entity_registry_enabled_default = False + entity_description: OpenThermSwitchEntityDescription + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch on.""" + value = await self.entity_description.turn_off_action(self._gateway) + self._attr_is_on = bool(value) if value is not None else None + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + value = await self.entity_description.turn_on_action(self._gateway) + self._attr_is_on = bool(value) if value is not None else None + self.async_write_ha_state() diff --git a/tests/components/opentherm_gw/test_switch.py b/tests/components/opentherm_gw/test_switch.py new file mode 100644 index 00000000000..5eb8e906892 --- /dev/null +++ b/tests/components/opentherm_gw/test_switch.py @@ -0,0 +1,111 @@ +"""Test opentherm_gw switches.""" + +from unittest.mock import AsyncMock, MagicMock, call + +import pytest + +from homeassistant.components.opentherm_gw import DOMAIN as OPENTHERM_DOMAIN +from homeassistant.components.opentherm_gw.const import OpenThermDeviceIdentifier +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "entity_key", ["central_heating_1_override", "central_heating_2_override"] +) +async def test_switch_added_disabled( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, + entity_key: str, +) -> None: + """Test switch gets added in disabled state.""" + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + switch_entity_id := entity_registry.async_get_entity_id( + SWITCH_DOMAIN, + OPENTHERM_DOMAIN, + f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}-{entity_key}", + ) + ) is not None + + assert (entity_entry := entity_registry.async_get(switch_entity_id)) is not None + assert entity_entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("entity_key", "target_func"), + [ + ("central_heating_1_override", "set_ch_enable_bit"), + ("central_heating_2_override", "set_ch2_enable_bit"), + ], +) +async def test_ch_override_switch( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, + entity_key: str, + target_func: str, +) -> None: + """Test central heating override switch.""" + + setattr(mock_pyotgw.return_value, target_func, AsyncMock(side_effect=[0, 1])) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + switch_entity_id := entity_registry.async_get_entity_id( + SWITCH_DOMAIN, + OPENTHERM_DOMAIN, + f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}-{entity_key}", + ) + ) is not None + assert hass.states.get(switch_entity_id).state == STATE_UNKNOWN + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: switch_entity_id, + }, + blocking=True, + ) + assert hass.states.get(switch_entity_id).state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: switch_entity_id, + }, + blocking=True, + ) + assert hass.states.get(switch_entity_id).state == STATE_ON + + mock_func = getattr(mock_pyotgw.return_value, target_func) + assert mock_func.await_count == 2 + mock_func.assert_has_awaits([call(0), call(1)]) From 457e66527a7bd8f5936ddff88ceaeb1869a3cfc6 Mon Sep 17 00:00:00 2001 From: Hessel Date: Fri, 6 Sep 2024 20:40:47 +0200 Subject: [PATCH 0358/1309] Add model ID to WallboxEntity (#125434) * chore: Update WallboxEntity model ID to use CHARGER_PART_NUMBER_KEY The WallboxEntity model ID is updated to use the CHARGER_PART_NUMBER_KEY value from the coordinator data. This change ensures consistency and accuracy in identifying the model of the Wallbox entity. * Update WallboxEntity model ID to use CHARGER_PART_NUMBER_KEY * chore: Update WallboxEntity model ID to use CHARGER_PART_NUMBER_KEY * remove obsolete key from test --- homeassistant/components/wallbox/entity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wallbox/entity.py b/homeassistant/components/wallbox/entity.py index 489e81ed6b0..3fe1865af4a 100644 --- a/homeassistant/components/wallbox/entity.py +++ b/homeassistant/components/wallbox/entity.py @@ -34,7 +34,8 @@ class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): }, name=f"Wallbox {self.coordinator.data[CHARGER_NAME_KEY]}", manufacturer="Wallbox", - model=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY], + model=self.coordinator.data[CHARGER_NAME_KEY].split(" SN")[0], + model_id=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY], sw_version=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SOFTWARE_KEY][ CHARGER_CURRENT_VERSION_KEY ], From ce28d8a92c3bf14be6e9447d93aa40b8716c30ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Sep 2024 19:35:57 -0500 Subject: [PATCH 0359/1309] Bump yalexs to 8.6.4 (#125442) adds a debounce to the updates to ensure we do not request the activities api too often if the websocket sends rapid updates fixes #125277 --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 6635a95f1cf..e2c35fc155f 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==8.6.3", "yalexs-ble==2.4.3"] + "requirements": ["yalexs==8.6.4", "yalexs-ble==2.4.3"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index fc93d259891..8b8095a0863 100644 --- a/homeassistant/components/yale/manifest.json +++ b/homeassistant/components/yale/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale", "iot_class": "cloud_push", "loggers": ["socketio", "engineio", "yalexs"], - "requirements": ["yalexs==8.6.3", "yalexs-ble==2.4.3"] + "requirements": ["yalexs==8.6.4", "yalexs-ble==2.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index be2ddf6a97c..ee9b9b4bedf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3003,7 +3003,7 @@ yalexs-ble==2.4.3 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.6.3 +yalexs==8.6.4 # homeassistant.components.yeelight yeelight==0.7.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4472ca9144..abe84df9270 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2383,7 +2383,7 @@ yalexs-ble==2.4.3 # homeassistant.components.august # homeassistant.components.yale -yalexs==8.6.3 +yalexs==8.6.4 # homeassistant.components.yeelight yeelight==0.7.14 From b8c3a44d81174b4ae1f03628bb44ecec77d580bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Sep 2024 00:36:34 -0500 Subject: [PATCH 0360/1309] Bump yarl to 1.10.0 (#125446) changelog: https://github.com/aio-libs/yarl/compare/v1.9.11...v1.10.0 --- 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 c8fc265cee8..0a4ead9e5b1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ urllib3>=1.26.5,<2 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.9.11 +yarl==1.10.0 zeroconf==0.133.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index a8c43ada99f..358abd934be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.9.11", + "yarl==1.10.0", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 8d5c01b5c27..ba7b89bd9e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,4 @@ urllib3>=1.26.5,<2 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.9.11 +yarl==1.10.0 From cbd884d54a04bd73563de4501295e262c2ec287c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 7 Sep 2024 07:55:08 +0200 Subject: [PATCH 0361/1309] Add discovery schemas for Matter 1.3 power/energy sensors (#125403) * Add missing discovery schemas for (Matter 1.3) Power/Energy measurements * Prevent discovery of custom cluster if 1.3 cluster present * add tests * Use f-strings --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/sensor.py | 83 ++++ .../nodes/eve-energy-plug-patched.json | 382 ++++++++++++++++++ tests/components/matter/test_sensor.py | 81 +++- 3 files changed, 540 insertions(+), 6 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/eve-energy-plug-patched.json diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 5d4ad900d8e..dd8467e24c9 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -175,6 +175,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.Watt,), + absent_attributes=(clusters.ElectricalPowerMeasurement.Attributes.ActivePower,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -188,6 +189,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.Voltage,), + absent_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -201,6 +203,9 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.WattAccumulated,), + absent_attributes=( + clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, + ), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -214,6 +219,9 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.Current,), + absent_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ActiveCurrent, + ), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -377,6 +385,7 @@ DISCOVERY_SCHEMAS = [ required_attributes=( ThirdRealityMeteringCluster.Attributes.InstantaneousDemand, ), + absent_attributes=(clusters.ElectricalPowerMeasurement.Attributes.ActivePower,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -393,6 +402,9 @@ DISCOVERY_SCHEMAS = [ required_attributes=( ThirdRealityMeteringCluster.Attributes.CurrentSummationDelivered, ), + absent_attributes=( + clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, + ), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -407,6 +419,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.Watt,), + absent_attributes=(clusters.ElectricalPowerMeasurement.Attributes.ActivePower,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -420,6 +433,9 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.WattAccumulated,), + absent_attributes=( + clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, + ), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -434,6 +450,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.Voltage,), + absent_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -447,6 +464,9 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.Current,), + absent_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ActiveCurrent, + ), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -462,4 +482,67 @@ DISCOVERY_SCHEMAS = [ required_attributes=(clusters.Switch.Attributes.CurrentPosition,), allow_multi=True, # also used for event entity ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementWatt", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + measurement_to_ha=lambda x: x / 1000, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ActivePower, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementVoltage", + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=0, + state_class=SensorStateClass.MEASUREMENT, + measurement_to_ha=lambda x: x / 1000, + ), + entity_class=MatterSensor, + required_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalPowerMeasurementActiveCurrent", + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + measurement_to_ha=lambda x: x / 1000, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalPowerMeasurement.Attributes.ActiveCurrent, + ), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ElectricalEnergyMeasurementCumulativeEnergyImported", + device_class=SensorDeviceClass.ENERGY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=3, + state_class=SensorStateClass.TOTAL_INCREASING, + # id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh) + measurement_to_ha=lambda x: x.energy / 1000000, + ), + entity_class=MatterSensor, + required_attributes=( + clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, + ), + ), ] diff --git a/tests/components/matter/fixtures/nodes/eve-energy-plug-patched.json b/tests/components/matter/fixtures/nodes/eve-energy-plug-patched.json new file mode 100644 index 00000000000..6b449643e8e --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve-energy-plug-patched.json @@ -0,0 +1,382 @@ +{ + "node_id": 183, + "date_commissioned": "2023-11-30T14:39:37.020026", + "last_interview": "2023-11-30T14:39:37.020029", + "interview_version": 5, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 5 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Eve Systems", + "0/40/2": 4874, + "0/40/3": "Eve Energy Plug Patched", + "0/40/4": 80, + "0/40/5": "", + "0/40/6": "XX", + "0/40/7": 1, + "0/40/8": "1.3", + "0/40/9": 6650, + "0/40/10": "3.2.1", + "0/40/15": "RV44L221A00081", + "0/40/18": "26E822F90561D17C42", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [ + { + "1": 2312386028615903905, + "2": 0, + "254": 1 + } + ], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "cfUKbvsdfsBjT+0=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "cfUKbvBjdsffwT+0=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [], + "0/51/1": 95, + "0/51/2": 268574, + "0/51/3": 4406, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "MyHome23", + "0/53/3": 14707, + "0/53/4": 8211480967175688173, + "0/53/5": "aabbccdd", + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": 1828774034, + "0/53/10": 68, + "0/53/11": 237, + "0/53/12": 170, + "0/53/13": 23, + "0/53/14": 2, + "0/53/15": 1, + "0/53/16": 2, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 2, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 293884, + "0/53/23": 278934, + "0/53/24": 14950, + "0/53/25": 278894, + "0/53/26": 278468, + "0/53/27": 14990, + "0/53/28": 293844, + "0/53/29": 0, + "0/53/30": 40, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 65244, + "0/53/34": 426, + "0/53/35": 0, + "0/53/36": 87, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 6687540, + "0/53/40": 142626, + "0/53/41": 106835, + "0/53/42": 246171, + "0/53/43": 0, + "0/53/44": 541, + "0/53/45": 40, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 6360718, + "0/53/49": 2141, + "0/53/50": 35259, + "0/53/51": 4374, + "0/53/52": 0, + "0/53/53": 568, + "0/53/54": 18599, + "0/53/55": 19143, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [0, 0, 0, 0], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [], + "0/62/1": [], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [ + "FTABAQAkAgE3AycUxofpv3kE1HwkFQEYJgS2Ty8rJgU2gxAtNwYnFMaH6b95BNR8JBUBGCQHASQIATAJQQSG0eCLvAjSHcSkZEo029SymN58wmxVcA645EXuFg6KwojGRyZsqWVtuMAYAB8TaPA9NEFsNvZZbvBR9XjrZhyKNwo1ASkBGCQCYDAEFNnFRJ+9qQIJtsM+LRdMdmCY3bQ4MAUU2cVEn72pAgm2wz4tF0x2YJjdtDgYMAtAFDv6Ouh7ugAGLiCjBQaEXCIAe0AkaaN8dBPskCZXOODjuZ1DCr4/f5IYg0rN2zFDUDTvG3GCxoI1+A7BvSjiNRg=", + "FTABAQAkAgE3AycUjuqR8vTQCmEkFQIYJgTFTy8rJgVFgxAtNwYnFI7qkfL00AphJBUCGCQHASQIATAJQQS5ZOLouMEkPsc/PYweZwUUFFWHWPR9nQVGsBl1VMWtm7CodpPAh4o79bZM9XU4T1wPVCvIzgGfuzIvsuwT7gHINwo1ASkBGCQCYDAEFKEEplpzAvCzsc5ga6CFmqmsv5onMAUUoQSmWnMC8LOxzmBroIWaqay/micYMAtAYkkA8OZFIGpxBEYYT+3A7Okba4WOq4NtwctIIZvCM48VU8pxQNjVvHMcJWPOP1Wh2Bw1VH7/Sg9lt9DL4DAwjBg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEECDlp5HtG4UpmG6QLEwaCUJ3TR0qWHEarwFuN7JkKUrPmQ3Zi3Nq/TFayJYQRvez268whgWhBhQudIm84xNwPXjcKNQEpARgkAmAwBBTJ3+WZAQkWgZboUpiyZL3FV8R8UzAFFMnf5ZkBCRaBluhSmLJkvcVXxHxTGDALQO9QSAdvJkM6b/wIc07MCw1ma46lTyGYG8nvpn0ICI73nuD3QeaWwGIQTkVGEpzF+TuDK7gtTz7YUrR+PSnvMk8Y" + ], + "0/62/5": 5, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 266, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 29, 319486977], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/319486977/319422464": "AAFQCwIAAAMC+xkEDFJWNDRMMUEwMDA4MZwBAP8EAQIA1PkBAWABZNAEAAAAAEUFBQAAAABGCQUAAAAOAABCBkkGBQwIEIABRBEFFAAFAzwAAAAAAAAAAAAAAEcRBSoh/CGWImgjeAAAADwAAABIBgUAAAAAAEoGBQAAAAAA", + "1/319486977/319422466": "BEZiAQAAAAAAAAAABgsCDAINAgcCDgEBAn4PABAAWgAAs8c+AQEA", + "1/319486977/319422467": "EgtaAAB74T4BDwAANwkAAAAA", + "1/319486977/319422471": 0, + "1/319486977/319422472": 238.8000030517578, + "1/319486977/319422473": 0.0, + "1/319486977/319422474": 0.0, + "1/319486977/319422475": 0.2200000286102295, + "1/319486977/319422476": 0, + "1/319486977/319422478": 0, + "1/319486977/319422481": false, + "1/319486977/319422482": 54272, + "1/319486977/65533": 1, + "1/319486977/65528": [], + "1/319486977/65529": [], + "1/319486977/65531": [ + 65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467, + 319422468, 319422469, 319422471, 319422472, 319422473, 319422474, + 319422475, 319422476, 319422478, 319422481, 319422482, 65533 + ], + "1/144/0": 2, + "1/144/1": 3, + "1/144/2": [ + { + "0": 1, + "1": true, + "2": 0, + "3": 100, + "4": [ + { + "0": 0, + "1": 4611686018427387904 + } + ] + }, + { + "0": 2, + "1": true, + "2": 0, + "3": 100, + "4": [ + { + "0": 0, + "1": 4611686018427387904 + } + ] + }, + { + "0": 5, + "1": true, + "2": 0, + "3": 100, + "4": [ + { + "0": 0, + "1": 4611686018427387904 + } + ] + } + ], + "1/144/4": 220000, + "1/144/5": 2000, + "1/144/8": 550000, + "1/144/65533": 1, + "1/144/65532": 2, + "1/144/65531": [0, 1, 2, 4, 5, 8, 65528, 65529, 65530, 65531, 65532, 65533], + "1/144/65530": [], + "1/144/65529": [], + "1/144/65528": [], + "1/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 0, + "4": [ + { + "0": 0, + "1": 4611686018427387904 + } + ] + }, + "1/145/65533": 1, + "1/145/65532": 7, + "1/145/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "1/145/65530": [0], + "1/145/65529": [], + "1/145/65528": [], + "1/145/1": { + "0": 2500 + }, + "1/145/2": null + }, + "attribute_subscriptions": [], + "last_subscription_attempt": 0 +} diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 2c9bfae94ce..17cff38787c 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -74,6 +74,16 @@ async def eve_energy_plug_node_fixture( ) +@pytest.fixture(name="eve_energy_plug_patched_node") +async def eve_energy_plug_patched_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Eve Energy Plug node (patched to include Matter 1.3 energy clusters).""" + return await setup_integration_with_node_fixture( + hass, "eve-energy-plug-patched", matter_client + ) + + @pytest.fixture(name="air_quality_sensor_node") async def air_quality_sensor_node_fixture( hass: HomeAssistant, matter_client: MagicMock @@ -243,14 +253,14 @@ async def test_battery_sensor( # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_eve_energy_sensors( +async def test_energy_sensors_custom_cluster( hass: HomeAssistant, entity_registry: er.EntityRegistry, matter_client: MagicMock, eve_energy_plug_node: MatterNode, ) -> None: - """Test Energy sensors created from Eve Energy custom cluster.""" - # power sensor + """Test Energy sensors created from (Eve) custom cluster (Matter 1.3 energy clusters absent).""" + # power sensor on Eve custom cluster entity_id = "sensor.eve_energy_plug_power" state = hass.states.get(entity_id) assert state @@ -259,7 +269,7 @@ async def test_eve_energy_sensors( assert state.attributes["device_class"] == "power" assert state.attributes["friendly_name"] == "Eve Energy Plug Power" - # voltage sensor + # voltage sensor on Eve custom cluster entity_id = "sensor.eve_energy_plug_voltage" state = hass.states.get(entity_id) assert state @@ -268,7 +278,7 @@ async def test_eve_energy_sensors( assert state.attributes["device_class"] == "voltage" assert state.attributes["friendly_name"] == "Eve Energy Plug Voltage" - # energy sensor + # energy sensor on Eve custom cluster entity_id = "sensor.eve_energy_plug_energy" state = hass.states.get(entity_id) assert state @@ -278,7 +288,7 @@ async def test_eve_energy_sensors( assert state.attributes["friendly_name"] == "Eve Energy Plug Energy" assert state.attributes["state_class"] == "total_increasing" - # current sensor + # current sensor on Eve custom cluster entity_id = "sensor.eve_energy_plug_current" state = hass.states.get(entity_id) assert state @@ -288,6 +298,65 @@ async def test_eve_energy_sensors( assert state.attributes["friendly_name"] == "Eve Energy Plug Current" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_energy_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + eve_energy_plug_patched_node: MatterNode, +) -> None: + """Test Energy sensors created from official Matter 1.3 energy clusters.""" + # power sensor on Matter 1.3 ElectricalPowermeasurement cluster + entity_id = "sensor.eve_energy_plug_patched_power" + state = hass.states.get(entity_id) + assert state + assert state.state == "550.0" + assert state.attributes["unit_of_measurement"] == "W" + assert state.attributes["device_class"] == "power" + assert state.attributes["friendly_name"] == "Eve Energy Plug Patched Power" + # ensure we do not have a duplicated entity from the custom cluster + state = hass.states.get(f"{entity_id}_1") + assert state is None + + # voltage sensor on Matter 1.3 ElectricalPowermeasurement cluster + entity_id = "sensor.eve_energy_plug_patched_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "220.0" + assert state.attributes["unit_of_measurement"] == "V" + assert state.attributes["device_class"] == "voltage" + assert state.attributes["friendly_name"] == "Eve Energy Plug Patched Voltage" + # ensure we do not have a duplicated entity from the custom cluster + state = hass.states.get(f"{entity_id}_1") + assert state is None + + # energy sensor on Matter 1.3 ElectricalEnergymeasurement cluster + entity_id = "sensor.eve_energy_plug_patched_energy" + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0025" + assert state.attributes["unit_of_measurement"] == "kWh" + assert state.attributes["device_class"] == "energy" + assert state.attributes["friendly_name"] == "Eve Energy Plug Patched Energy" + assert state.attributes["state_class"] == "total_increasing" + # ensure we do not have a duplicated entity from the custom cluster + state = hass.states.get(f"{entity_id}_1") + assert state is None + + # current sensor on Matter 1.3 ElectricalPowermeasurement cluster + entity_id = "sensor.eve_energy_plug_patched_current" + state = hass.states.get(entity_id) + assert state + assert state.state == "2.0" + assert state.attributes["unit_of_measurement"] == "A" + assert state.attributes["device_class"] == "current" + assert state.attributes["friendly_name"] == "Eve Energy Plug Patched Current" + # ensure we do not have a duplicated entity from the custom cluster + state = hass.states.get(f"{entity_id}_1") + assert state is None + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_air_quality_sensor( From 17994ff245cc32dd27a8455e49cf530feb5383b6 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 7 Sep 2024 01:47:27 -0700 Subject: [PATCH 0362/1309] Request one data point in statistics_during_period in Opower (#124480) --- homeassistant/components/opower/coordinator.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 1e00243f657..cd2e28ed638 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -128,19 +128,22 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): if not cost_reads: _LOGGER.debug("No recent usage/cost data. Skipping update") continue + start = cost_reads[0].start_time + _LOGGER.debug("Getting statistics at: %s", start) stats = await get_instance(self.hass).async_add_executor_job( statistics_during_period, self.hass, - cost_reads[0].start_time, - None, + start, + start + timedelta(seconds=1), {cost_statistic_id, consumption_statistic_id}, - "hour" if account.meter_type == MeterType.ELEC else "day", + "hour", None, {"sum"}, ) cost_sum = cast(float, stats[cost_statistic_id][0]["sum"]) consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"]) last_stats_time = stats[consumption_statistic_id][0]["start"] + assert last_stats_time == start.timestamp() cost_statistics = [] consumption_statistics = [] From 3e703422655ed227e73b76aec3d4c68d1fddaa11 Mon Sep 17 00:00:00 2001 From: tmenguy Date: Sat, 7 Sep 2024 12:38:59 +0200 Subject: [PATCH 0363/1309] Fix renault plug state (#125421) * Added PlugState 3, that is coming with renault-api 0.2.7, it fixes #124682 HA ticket * Added PlugState 3, that is coming with renault-api 0.2.7, it fixes #124682 HA ticket --- .../components/renault/binary_sensor.py | 16 +++++++++---- homeassistant/components/renault/sensor.py | 8 ++++++- homeassistant/components/renault/strings.json | 1 + tests/components/renault/const.py | 24 ++++++++++++++++--- .../renault/snapshots/test_sensor.ambr | 12 ++++++++++ 5 files changed, 52 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 2041499b711..98c298761ce 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -28,7 +28,7 @@ class RenaultBinarySensorEntityDescription( """Class describing Renault binary sensor entities.""" on_key: str - on_value: StateType + on_value: StateType | list[StateType] async def async_setup_entry( @@ -58,6 +58,9 @@ class RenaultBinarySensor( """Return true if the binary sensor is on.""" if (data := self._get_data_attr(self.entity_description.on_key)) is None: return None + + if isinstance(self.entity_description.on_value, list): + return data in self.entity_description.on_value return data == self.entity_description.on_value @@ -68,7 +71,10 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( coordinator="battery", device_class=BinarySensorDeviceClass.PLUG, on_key="plugStatus", - on_value=PlugState.PLUGGED.value, + on_value=[ + PlugState.PLUGGED.value, + PlugState.PLUGGED_WAITING_FOR_CHARGE.value, + ], ), RenaultBinarySensorEntityDescription( key="charging", @@ -104,13 +110,13 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( ] + [ RenaultBinarySensorEntityDescription( - key=f"{door.replace(' ','_').lower()}_door_status", + key=f"{door.replace(' ', '_').lower()}_door_status", coordinator="lock_status", # On means open, Off means closed device_class=BinarySensorDeviceClass.DOOR, - on_key=f"doorStatus{door.replace(' ','')}", + on_key=f"doorStatus{door.replace(' ', '')}", on_value="open", - translation_key=f"{door.lower().replace(' ','_')}_door_status", + translation_key=f"{door.lower().replace(' ', '_')}_door_status", ) for door in ("Rear Left", "Rear Right", "Driver", "Passenger") ], diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 5cb4ee333cc..78e64ae9acc 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -197,7 +197,13 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( translation_key="plug_state", device_class=SensorDeviceClass.ENUM, entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - options=["unplugged", "plugged", "plug_error", "plug_unknown"], + options=[ + "unplugged", + "plugged", + "plugged_waiting_for_charge", + "plug_error", + "plug_unknown", + ], value_lambda=_get_plug_state_formatted, ), RenaultSensorEntityDescription( diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 5217b4ff65a..54864387869 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -141,6 +141,7 @@ "state": { "unplugged": "Unplugged", "plugged": "Plugged in", + "plugged_waiting_for_charge": "Plugged in, waiting for charge", "plug_error": "Plug error", "plug_unknown": "Plug unknown" } diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 2d0263e40de..c552321ef97 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -246,7 +246,13 @@ MOCK_VEHICLES = { ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, ATTR_ENTITY_ID: "sensor.reg_number_plug_state", ATTR_ICON: "mdi:power-plug", - ATTR_OPTIONS: ["unplugged", "plugged", "plug_error", "plug_unknown"], + ATTR_OPTIONS: [ + "unplugged", + "plugged", + "plugged_waiting_for_charge", + "plug_error", + "plug_unknown", + ], ATTR_STATE: "plugged", ATTR_UNIQUE_ID: "vf1aaaaa555777999_plug_state", }, @@ -487,7 +493,13 @@ MOCK_VEHICLES = { ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, ATTR_ENTITY_ID: "sensor.reg_number_plug_state", ATTR_ICON: "mdi:power-plug-off", - ATTR_OPTIONS: ["unplugged", "plugged", "plug_error", "plug_unknown"], + ATTR_OPTIONS: [ + "unplugged", + "plugged", + "plugged_waiting_for_charge", + "plug_error", + "plug_unknown", + ], ATTR_STATE: "unplugged", ATTR_UNIQUE_ID: "vf1aaaaa555777999_plug_state", }, @@ -725,7 +737,13 @@ MOCK_VEHICLES = { ATTR_DEVICE_CLASS: SensorDeviceClass.ENUM, ATTR_ENTITY_ID: "sensor.reg_number_plug_state", ATTR_ICON: "mdi:power-plug", - ATTR_OPTIONS: ["unplugged", "plugged", "plug_error", "plug_unknown"], + ATTR_OPTIONS: [ + "unplugged", + "plugged", + "plugged_waiting_for_charge", + "plug_error", + "plug_unknown", + ], ATTR_STATE: "plugged", ATTR_UNIQUE_ID: "vf1aaaaa555777123_plug_state", }, diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index 80e73347b07..b092222c9f3 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -494,6 +494,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -921,6 +922,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -1249,6 +1251,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -1674,6 +1677,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -2000,6 +2004,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -2456,6 +2461,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -3104,6 +3110,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -3531,6 +3538,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -3859,6 +3867,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -4284,6 +4293,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -4610,6 +4620,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), @@ -5066,6 +5077,7 @@ 'options': list([ 'unplugged', 'plugged', + 'plugged_waiting_for_charge', 'plug_error', 'plug_unknown', ]), From 066503b838eb4f4b379b439564343771bef039a4 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sat, 7 Sep 2024 13:18:54 +0200 Subject: [PATCH 0364/1309] Fix docstrings in opentherm_gw (#125456) --- homeassistant/components/opentherm_gw/switch.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opentherm_gw/switch.py b/homeassistant/components/opentherm_gw/switch.py index 6076634b160..41ffa03a932 100644 --- a/homeassistant/components/opentherm_gw/switch.py +++ b/homeassistant/components/opentherm_gw/switch.py @@ -19,7 +19,7 @@ from .entity import OpenThermEntity, OpenThermEntityDescription class OpenThermSwitchEntityDescription( OpenThermEntityDescription, SwitchEntityDescription ): - """Describes opentherm_gw switch entity.""" + """Describes an opentherm_gw switch entity.""" turn_off_action: Callable[[OpenThermGatewayHub], Awaitable[int | None]] turn_on_action: Callable[[OpenThermGatewayHub], Awaitable[int | None]] @@ -67,13 +67,13 @@ class OpenThermSwitch(OpenThermEntity, SwitchEntity): entity_description: OpenThermSwitchEntityDescription async def async_turn_off(self, **kwargs: Any) -> None: - """Turn switch on.""" + """Turn the switch off.""" value = await self.entity_description.turn_off_action(self._gateway) self._attr_is_on = bool(value) if value is not None else None self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: - """Turn switch on.""" + """Turn the switch on.""" value = await self.entity_description.turn_on_action(self._gateway) self._attr_is_on = bool(value) if value is not None else None self.async_write_ha_state() From 6e38cf878e82a15d7084242c7c8d2cc252accc5b Mon Sep 17 00:00:00 2001 From: Hessel Date: Sat, 7 Sep 2024 15:34:48 +0200 Subject: [PATCH 0365/1309] Clean up test for Wallbox integration (#125433) feat: Update API requests in wallbox integration tests --- tests/components/wallbox/__init__.py | 7 +++--- tests/components/wallbox/test_config_flow.py | 10 ++++++++- tests/components/wallbox/test_init.py | 4 +--- tests/components/wallbox/test_lock.py | 14 ++---------- tests/components/wallbox/test_number.py | 23 +++++--------------- tests/components/wallbox/test_sensor.py | 2 -- tests/components/wallbox/test_switch.py | 14 +++--------- 7 files changed, 23 insertions(+), 51 deletions(-) diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index f4258ea0d49..9ec10dc72aa 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -1,7 +1,6 @@ """Tests for the Wallbox integration.""" from http import HTTPStatus -import json import requests_mock @@ -121,7 +120,7 @@ async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None ) mock_request.put( "https://api.wall-box.com/v2/charger/12345", - json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, status_code=HTTPStatus.OK, ) @@ -144,7 +143,7 @@ async def setup_integration_bidir(hass: HomeAssistant, entry: MockConfigEntry) - ) mock_request.put( "https://api.wall-box.com/v2/charger/12345", - json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, status_code=HTTPStatus.OK, ) @@ -169,7 +168,7 @@ async def setup_integration_connection_error( ) mock_request.put( "https://api.wall-box.com/v2/charger/12345", - json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, status_code=HTTPStatus.FORBIDDEN, ) diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index cc38576eb2f..467e20c51c1 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -186,7 +186,15 @@ async def test_form_reauth_invalid(hass: HomeAssistant, entry: MockConfigEntry) with requests_mock.Mocker() as mock_request: mock_request.get( "https://user-api.wall-box.com/users/signin", - text='{"jwt":"fakekeyhere","refresh_token": "refresh_fakekeyhere","user_id":12345,"ttl":145656758,"refresh_token_ttl":145756758,"error":false,"status":200}', + json={ + "jwt": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + "user_id": 12345, + "ttl": 145656758, + "refresh_token_ttl": 145756758, + "error": False, + "status": 200, + }, status_code=200, ) mock_request.get( diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index f1362489c50..b4b5a199243 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -1,7 +1,5 @@ """Test Wallbox Init Component.""" -import json - import requests_mock from homeassistant.components.wallbox.const import ( @@ -90,7 +88,7 @@ async def test_wallbox_refresh_failed_invalid_auth( ) mock_request.put( "https://api.wall-box.com/v2/charger/12345", - json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, status_code=403, ) diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index 637f0c827f4..1d48e53b515 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -1,7 +1,5 @@ """Test Wallbox Lock component.""" -import json - import pytest import requests_mock @@ -38,7 +36,7 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - ) mock_request.put( "https://api.wall-box.com/v2/charger/12345", - json=json.loads(json.dumps({CHARGER_LOCKED_UNLOCKED_KEY: False})), + json={CHARGER_LOCKED_UNLOCKED_KEY: False}, status_code=200, ) @@ -60,8 +58,6 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) - async def test_wallbox_lock_class_connection_error( hass: HomeAssistant, entry: MockConfigEntry @@ -78,7 +74,7 @@ async def test_wallbox_lock_class_connection_error( ) mock_request.put( "https://api.wall-box.com/v2/charger/12345", - json=json.loads(json.dumps({CHARGER_LOCKED_UNLOCKED_KEY: False})), + json={CHARGER_LOCKED_UNLOCKED_KEY: False}, status_code=404, ) @@ -101,8 +97,6 @@ async def test_wallbox_lock_class_connection_error( blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) - async def test_wallbox_lock_class_authentication_error( hass: HomeAssistant, entry: MockConfigEntry @@ -115,8 +109,6 @@ async def test_wallbox_lock_class_authentication_error( assert state is None - await hass.config_entries.async_unload(entry.entry_id) - async def test_wallbox_lock_class_platform_not_ready( hass: HomeAssistant, entry: MockConfigEntry @@ -128,5 +120,3 @@ async def test_wallbox_lock_class_platform_not_ready( state = hass.states.get(MOCK_LOCK_ENTITY_ID) assert state is None - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 0a8b1aa1207..c319668c161 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -1,7 +1,5 @@ """Test Wallbox Switch component.""" -import json - import pytest import requests_mock @@ -47,7 +45,7 @@ async def test_wallbox_number_class( ) mock_request.put( "https://api.wall-box.com/v2/charger/12345", - json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, status_code=200, ) state = hass.states.get(MOCK_NUMBER_ENTITY_ID) @@ -63,7 +61,6 @@ async def test_wallbox_number_class( }, blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) async def test_wallbox_number_class_bidir( @@ -76,7 +73,6 @@ async def test_wallbox_number_class_bidir( state = hass.states.get(MOCK_NUMBER_ENTITY_ID) assert state.attributes["min"] == -25 assert state.attributes["max"] == 25 - await hass.config_entries.async_unload(entry.entry_id) async def test_wallbox_number_energy_class( @@ -95,7 +91,7 @@ async def test_wallbox_number_energy_class( mock_request.post( "https://api.wall-box.com/chargers/config/12345", - json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + json={CHARGER_ENERGY_PRICE_KEY: 1.1}, status_code=200, ) @@ -108,7 +104,6 @@ async def test_wallbox_number_energy_class( }, blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) async def test_wallbox_number_class_connection_error( @@ -126,7 +121,7 @@ async def test_wallbox_number_class_connection_error( ) mock_request.put( "https://api.wall-box.com/v2/charger/12345", - json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, status_code=404, ) @@ -140,7 +135,6 @@ async def test_wallbox_number_class_connection_error( }, blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) async def test_wallbox_number_class_energy_price_connection_error( @@ -158,7 +152,7 @@ async def test_wallbox_number_class_energy_price_connection_error( ) mock_request.post( "https://api.wall-box.com/chargers/config/12345", - json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + json={CHARGER_ENERGY_PRICE_KEY: 1.1}, status_code=404, ) @@ -172,7 +166,6 @@ async def test_wallbox_number_class_energy_price_connection_error( }, blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) async def test_wallbox_number_class_energy_price_auth_error( @@ -190,7 +183,7 @@ async def test_wallbox_number_class_energy_price_auth_error( ) mock_request.post( "https://api.wall-box.com/chargers/config/12345", - json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + json={CHARGER_ENERGY_PRICE_KEY: 1.1}, status_code=403, ) @@ -204,7 +197,6 @@ async def test_wallbox_number_class_energy_price_auth_error( }, blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) async def test_wallbox_number_class_platform_not_ready( @@ -218,8 +210,6 @@ async def test_wallbox_number_class_platform_not_ready( assert state is None - await hass.config_entries.async_unload(entry.entry_id) - async def test_wallbox_number_class_icp_energy( hass: HomeAssistant, entry: MockConfigEntry @@ -250,7 +240,6 @@ async def test_wallbox_number_class_icp_energy( }, blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) async def test_wallbox_number_class_icp_energy_auth_error( @@ -282,7 +271,6 @@ async def test_wallbox_number_class_icp_energy_auth_error( }, blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) async def test_wallbox_number_class_icp_energy_connection_error( @@ -314,4 +302,3 @@ async def test_wallbox_number_class_icp_energy_connection_error( }, blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/wallbox/test_sensor.py b/tests/components/wallbox/test_sensor.py index 5a8b3c290c1..69d0cc57340 100644 --- a/tests/components/wallbox/test_sensor.py +++ b/tests/components/wallbox/test_sensor.py @@ -30,5 +30,3 @@ async def test_wallbox_sensor_class( # Test round with precision '0' works state = hass.states.get(MOCK_SENSOR_MAX_AVAILABLE_POWER) assert state.state == "25.0" - - await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index d06251db003..b7c3a81dc73 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -1,7 +1,5 @@ """Test Wallbox Lock component.""" -import json - import pytest import requests_mock @@ -36,7 +34,7 @@ async def test_wallbox_switch_class( ) mock_request.post( "https://api.wall-box.com/v3/chargers/12345/remote-action", - json=json.loads(json.dumps({CHARGER_STATUS_ID_KEY: 193})), + json={CHARGER_STATUS_ID_KEY: 193}, status_code=200, ) @@ -58,8 +56,6 @@ async def test_wallbox_switch_class( blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) - async def test_wallbox_switch_class_connection_error( hass: HomeAssistant, entry: MockConfigEntry @@ -76,7 +72,7 @@ async def test_wallbox_switch_class_connection_error( ) mock_request.post( "https://api.wall-box.com/v3/chargers/12345/remote-action", - json=json.loads(json.dumps({CHARGER_STATUS_ID_KEY: 193})), + json={CHARGER_STATUS_ID_KEY: 193}, status_code=404, ) @@ -99,8 +95,6 @@ async def test_wallbox_switch_class_connection_error( blocking=True, ) - await hass.config_entries.async_unload(entry.entry_id) - async def test_wallbox_switch_class_authentication_error( hass: HomeAssistant, entry: MockConfigEntry @@ -117,7 +111,7 @@ async def test_wallbox_switch_class_authentication_error( ) mock_request.post( "https://api.wall-box.com/v3/chargers/12345/remote-action", - json=json.loads(json.dumps({CHARGER_STATUS_ID_KEY: 193})), + json={CHARGER_STATUS_ID_KEY: 193}, status_code=403, ) @@ -139,5 +133,3 @@ async def test_wallbox_switch_class_authentication_error( }, blocking=True, ) - - await hass.config_entries.async_unload(entry.entry_id) From c53c2d7e640df464257938d6083fb6d2e88dbd69 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Sat, 7 Sep 2024 17:57:57 +0200 Subject: [PATCH 0366/1309] Add model ID to Matter DeviceInfo (#125341) * Add model ID to Matter DeviceInfo * convert to string * Test device registry --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/matter/adapter.py | 1 + tests/components/matter/test_adapter.py | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index a3536435ded..b56c82f8b9a 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -207,6 +207,7 @@ class MatterAdapter: sw_version=basic_info.softwareVersionString, manufacturer=basic_info.vendorName or endpoint.node.device_info.vendorName, model=model, + model_id=str(basic_info.productID) if basic_info.productID else None, serial_number=serial_number, via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None, ) diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index da2ef179c44..522128e5968 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -54,6 +54,7 @@ async def test_device_registry_single_node_device( assert entry.name == name assert entry.manufacturer == "Nabu Casa" assert entry.model == "Mock Light" + assert entry.model_id == "32768" assert entry.hw_version == "v1.0" assert entry.sw_version == "v1.0" assert entry.serial_number == "12345678" From 7e7a6e4937115845ece3db7cd65f7e03215f9e0d Mon Sep 17 00:00:00 2001 From: Dian Date: Sun, 8 Sep 2024 03:33:48 +0800 Subject: [PATCH 0367/1309] Bump xiaomi-ble to 0.32.0 (#125461) --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index da7169635e9..e4c643e491e 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.31.1"] + "requirements": ["xiaomi-ble==0.32.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee9b9b4bedf..3047748dc04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2975,7 +2975,7 @@ wyoming==1.5.4 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.31.1 +xiaomi-ble==0.32.0 # homeassistant.components.knx xknx==3.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index abe84df9270..2f28c496769 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2358,7 +2358,7 @@ wyoming==1.5.4 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.31.1 +xiaomi-ble==0.32.0 # homeassistant.components.knx xknx==3.1.1 From ab29718a455ed6c625a0459400c816f293e695b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 7 Sep 2024 22:32:36 +0200 Subject: [PATCH 0368/1309] Update aioairzone to v0.9.0 (#125476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 31ff7423ad6..a782006efef 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.8.2"] + "requirements": ["aioairzone==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3047748dc04..5844e51260f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.5 # homeassistant.components.airzone -aioairzone==0.8.2 +aioairzone==0.9.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f28c496769..85b6262a8ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.5 # homeassistant.components.airzone -aioairzone==0.8.2 +aioairzone==0.9.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 0a11acf7aed62ce8f76a6fd76bda1dacc89d8a0f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Sep 2024 22:49:44 -0500 Subject: [PATCH 0369/1309] Replace linear search in unit_system with dict lookup (#125485) --- homeassistant/util/unit_system.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index bd31b4286ab..98cfb2f1368 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -58,23 +58,21 @@ WIND_SPEED_UNITS = SpeedConverter.VALID_UNITS TEMPERATURE_UNITS: set[str] = {UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS} +_VALID_BY_TYPE: dict[str, set[str] | set[str | None]] = { + LENGTH: LENGTH_UNITS, + ACCUMULATED_PRECIPITATION: LENGTH_UNITS, + WIND_SPEED: WIND_SPEED_UNITS, + TEMPERATURE: TEMPERATURE_UNITS, + MASS: MASS_UNITS, + VOLUME: VOLUME_UNITS, + PRESSURE: PRESSURE_UNITS, +} + def _is_valid_unit(unit: str, unit_type: str) -> bool: """Check if the unit is valid for it's type.""" - if unit_type == LENGTH: - return unit in LENGTH_UNITS - if unit_type == ACCUMULATED_PRECIPITATION: - return unit in LENGTH_UNITS - if unit_type == WIND_SPEED: - return unit in WIND_SPEED_UNITS - if unit_type == TEMPERATURE: - return unit in TEMPERATURE_UNITS - if unit_type == MASS: - return unit in MASS_UNITS - if unit_type == VOLUME: - return unit in VOLUME_UNITS - if unit_type == PRESSURE: - return unit in PRESSURE_UNITS + if units := _VALID_BY_TYPE.get(unit_type): + return unit in units return False From 03a6eb26bede9086c3fb12bd45f5324c05a404e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Sep 2024 02:10:46 -0500 Subject: [PATCH 0370/1309] Bump zeroconf to 0.134.0 (#125491) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.133.0...0.134.0 --- homeassistant/components/zeroconf/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/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 8b332400805..1176be80839 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.133.0"] + "requirements": ["zeroconf==0.134.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0a4ead9e5b1..e043740e15a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -63,7 +63,7 @@ voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 yarl==1.10.0 -zeroconf==0.133.0 +zeroconf==0.134.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 5844e51260f..16b0cc537d7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3030,7 +3030,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.133.0 +zeroconf==0.134.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 85b6262a8ad..53e29289127 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2404,7 +2404,7 @@ yt-dlp==2024.08.06 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.133.0 +zeroconf==0.134.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 5e1b4b2d23998e763157656486e7b52338f2e091 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sun, 8 Sep 2024 09:20:57 +0200 Subject: [PATCH 0371/1309] Clean up tests for LCN (#125493) * Remove patches on 3rd party module level * Cleanup test_init * Cleanup platform tests * Cleanup test_services * Cleanup test_websockets * Cleanup test_device_trigger * Cleanup test_events * Remove unused fixture --- homeassistant/components/lcn/config_flow.py | 3 +- tests/components/lcn/conftest.py | 21 +- .../lcn/snapshots/test_binary_sensor.ambr | 139 +++++ .../components/lcn/snapshots/test_cover.ambr | 97 ++++ .../components/lcn/snapshots/test_light.ambr | 167 ++++++ .../components/lcn/snapshots/test_sensor.ambr | 187 +++++++ .../lcn/snapshots/test_services.ambr | 203 -------- .../components/lcn/snapshots/test_switch.ambr | 231 +++++++++ tests/components/lcn/test_binary_sensor.py | 76 ++- tests/components/lcn/test_climate.py | 2 +- tests/components/lcn/test_config_flow.py | 27 +- tests/components/lcn/test_cover.py | 490 +++++++++--------- tests/components/lcn/test_device_trigger.py | 40 +- tests/components/lcn/test_events.py | 35 +- tests/components/lcn/test_init.py | 74 ++- tests/components/lcn/test_light.py | 453 ++++++++-------- tests/components/lcn/test_scene.py | 2 +- tests/components/lcn/test_sensor.py | 87 +--- tests/components/lcn/test_services.py | 107 ++-- tests/components/lcn/test_switch.py | 332 ++++++------ tests/components/lcn/test_websocket.py | 60 ++- 21 files changed, 1715 insertions(+), 1118 deletions(-) create mode 100644 tests/components/lcn/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/lcn/snapshots/test_cover.ambr create mode 100644 tests/components/lcn/snapshots/test_light.ambr create mode 100644 tests/components/lcn/snapshots/test_sensor.ambr delete mode 100644 tests/components/lcn/snapshots/test_services.ambr create mode 100644 tests/components/lcn/snapshots/test_switch.ambr diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index c38a16cc21e..e3979effc07 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -25,6 +25,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType +from . import PchkConnectionManager from .const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, DIM_MODES, DOMAIN from .helpers import purge_device_registry, purge_entity_registry @@ -78,7 +79,7 @@ async def validate_connection(data: ConfigType) -> str | None: _LOGGER.debug("Validating connection parameters to PCHK host '%s'", host_name) - connection = pypck.connection.PchkConnectionManager( + connection = PchkConnectionManager( host, port, username, password, settings=settings ) diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index 67c5b9c0b9c..16797f6065d 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -53,12 +53,20 @@ class MockPchkConnectionManager(PchkConnectionManager): async def async_close(self) -> None: """Mock closing a connection to PCHK.""" - @patch.object(pypck.connection, "ModuleConnection", MockModuleConnection) - @patch.object(pypck.connection, "GroupConnection", MockGroupConnection) def get_address_conn(self, addr, request_serials=False): """Get LCN address connection.""" return super().get_address_conn(addr, request_serials) + @patch.object(pypck.connection, "ModuleConnection", MockModuleConnection) + def get_module_conn(self, addr, request_serials=False): + """Get LCN module connection.""" + return super().get_module_conn(addr, request_serials) + + @patch.object(pypck.connection, "GroupConnection", MockGroupConnection) + def get_group_conn(self, addr): + """Get LCN group connection.""" + return super().get_group_conn(addr) + scan_modules = AsyncMock() send_command = AsyncMock() @@ -119,15 +127,6 @@ async def init_integration( return lcn_connection -@pytest.fixture(name="lcn_connection") -async def init_lcn_connection( - hass: HomeAssistant, - entry: MockConfigEntry, -) -> MockPchkConnectionManager: - """Set up the LCN integration in Home Assistantand yield connection object.""" - return await init_integration(hass, entry) - - async def setup_component(hass: HomeAssistant) -> None: """Set up the LCN component.""" fixture_filename = "lcn/config.json" diff --git a/tests/components/lcn/snapshots/test_binary_sensor.ambr b/tests/components/lcn/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..0ad31437dd1 --- /dev/null +++ b/tests/components/lcn/snapshots/test_binary_sensor.ambr @@ -0,0 +1,139 @@ +# serializer version: 1 +# name: test_setup_lcn_binary_sensor[binary_sensor.binary_sensor1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.binary_sensor1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Binary_Sensor1', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-binsensor1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_binary_sensor[binary_sensor.binary_sensor1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Binary_Sensor1', + }), + 'context': , + 'entity_id': 'binary_sensor.binary_sensor1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_keylock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.sensor_keylock', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensor_KeyLock', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-a5', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_keylock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sensor_KeyLock', + }), + 'context': , + 'entity_id': 'binary_sensor.sensor_keylock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_lockregulator1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.sensor_lockregulator1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensor_LockRegulator1', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_binary_sensor[binary_sensor.sensor_lockregulator1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sensor_LockRegulator1', + }), + 'context': , + 'entity_id': 'binary_sensor.sensor_lockregulator1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lcn/snapshots/test_cover.ambr b/tests/components/lcn/snapshots/test_cover.ambr new file mode 100644 index 00000000000..82a19060d73 --- /dev/null +++ b/tests/components/lcn/snapshots/test_cover.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_setup_lcn_cover[cover.cover_outputs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.cover_outputs', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cover_Outputs', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-outputs', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_cover[cover.cover_outputs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'friendly_name': 'Cover_Outputs', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover_outputs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_setup_lcn_cover[cover.cover_relays-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.cover_relays', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cover_Relays', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-motor1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_cover[cover.cover_relays-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'assumed_state': True, + 'friendly_name': 'Cover_Relays', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.cover_relays', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/lcn/snapshots/test_light.ambr b/tests/components/lcn/snapshots/test_light.ambr new file mode 100644 index 00000000000..f53d1fdf2dc --- /dev/null +++ b/tests/components/lcn/snapshots/test_light.ambr @@ -0,0 +1,167 @@ +# serializer version: 1 +# name: test_setup_lcn_light[light.light_output1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.light_output1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light_Output1', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-output1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_light[light.light_output1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Light_Output1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_output1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_lcn_light[light.light_output2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.light_output2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light_Output2', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-output2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_light[light.light_output2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Light_Output2', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_output2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_lcn_light[light.light_relay1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.light_relay1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light_Relay1', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_light[light.light_relay1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': None, + 'friendly_name': 'Light_Relay1', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.light_relay1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lcn/snapshots/test_sensor.ambr b/tests/components/lcn/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..d6ac73b5822 --- /dev/null +++ b/tests/components/lcn/snapshots/test_sensor.ambr @@ -0,0 +1,187 @@ +# serializer version: 1 +# name: test_setup_lcn_sensor[sensor.sensor_led6-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sensor_led6', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensor_Led6', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-led6', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_sensor[sensor.sensor_led6-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sensor_Led6', + }), + 'context': , + 'entity_id': 'sensor.sensor_led6', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_lcn_sensor[sensor.sensor_logicop1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sensor_logicop1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensor_LogicOp1', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-logicop1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_sensor[sensor.sensor_logicop1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sensor_LogicOp1', + }), + 'context': , + 'entity_id': 'sensor.sensor_logicop1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_lcn_sensor[sensor.sensor_setpoint1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sensor_setpoint1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensor_Setpoint1', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-r1varsetpoint', + 'unit_of_measurement': '°C', + }) +# --- +# name: test_setup_lcn_sensor[sensor.sensor_setpoint1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sensor_Setpoint1', + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.sensor_setpoint1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup_lcn_sensor[sensor.sensor_var1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sensor_var1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sensor_Var1', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-var1', + 'unit_of_measurement': '°C', + }) +# --- +# name: test_setup_lcn_sensor[sensor.sensor_var1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sensor_Var1', + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'sensor.sensor_var1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lcn/snapshots/test_services.ambr b/tests/components/lcn/snapshots/test_services.ambr deleted file mode 100644 index 29e8da72fd7..00000000000 --- a/tests/components/lcn/snapshots/test_services.ambr +++ /dev/null @@ -1,203 +0,0 @@ -# serializer version: 1 -# name: test_service_dyn_text - tuple( - 0, - 'text in row 1', - ) -# --- -# name: test_service_led - tuple( - , - , - ) -# --- -# name: test_service_lock_keys - tuple( - 0, - list([ - , - , - , - , - , - , - , - , - ]), - ) -# --- -# name: test_service_lock_keys_tab_a_temporary - tuple( - 10, - , - list([ - , - , - , - , - , - , - , - , - ]), - ) -# --- -# name: test_service_lock_regulator - tuple( - 0, - True, - ) -# --- -# name: test_service_output_abs - tuple( - 0, - 100, - 9, - ) -# --- -# name: test_service_output_rel - tuple( - 0, - 25, - ) -# --- -# name: test_service_output_toggle - tuple( - 0, - 9, - ) -# --- -# name: test_service_pck - tuple( - 'PIN4', - ) -# --- -# name: test_service_relays - tuple( - list([ - , - , - , - , - , - , - , - , - ]), - ) -# --- -# name: test_service_send_keys - tuple( - list([ - list([ - True, - False, - False, - False, - True, - False, - False, - False, - ]), - list([ - False, - False, - False, - False, - False, - False, - False, - False, - ]), - list([ - False, - False, - False, - False, - False, - False, - False, - False, - ]), - list([ - False, - False, - False, - False, - False, - False, - False, - True, - ]), - ]), - , - ) -# --- -# name: test_service_send_keys_hit_deferred - tuple( - list([ - list([ - True, - False, - False, - False, - True, - False, - False, - False, - ]), - list([ - False, - False, - False, - False, - False, - False, - False, - False, - ]), - list([ - False, - False, - False, - False, - False, - False, - False, - False, - ]), - list([ - False, - False, - False, - False, - False, - False, - False, - True, - ]), - ]), - 5, - , - ) -# --- -# name: test_service_var_abs - tuple( - , - 75.0, - , - ) -# --- -# name: test_service_var_rel - tuple( - , - 10.0, - , - , - ) -# --- -# name: test_service_var_reset - tuple( - , - ) -# --- diff --git a/tests/components/lcn/snapshots/test_switch.ambr b/tests/components/lcn/snapshots/test_switch.ambr new file mode 100644 index 00000000000..1f2aac041aa --- /dev/null +++ b/tests/components/lcn/snapshots/test_switch.ambr @@ -0,0 +1,231 @@ +# serializer version: 1 +# name: test_setup_lcn_switch[switch.switch_group5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.switch_group5', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch_Group5', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-g000005-relay1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_switch[switch.switch_group5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Switch_Group5', + }), + 'context': , + 'entity_id': 'switch.switch_group5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_lcn_switch[switch.switch_output1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.switch_output1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch_Output1', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-output1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_switch[switch.switch_output1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Switch_Output1', + }), + 'context': , + 'entity_id': 'switch.switch_output1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_lcn_switch[switch.switch_output2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.switch_output2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch_Output2', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-output2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_switch[switch.switch_output2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Switch_Output2', + }), + 'context': , + 'entity_id': 'switch.switch_output2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_lcn_switch[switch.switch_relay1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.switch_relay1', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch_Relay1', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_switch[switch.switch_relay1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Switch_Relay1', + }), + 'context': , + 'entity_id': 'switch.switch_relay1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup_lcn_switch[switch.switch_relay2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.switch_relay2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Switch_Relay2', + 'platform': 'lcn', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'lcn/config_entry_pchk.json-m000007-relay2', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_lcn_switch[switch.switch_relay2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Switch_Relay2', + }), + 'context': , + 'entity_id': 'switch.switch_relay2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index 9ba04ac94c7..7abae6e0d89 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -1,68 +1,46 @@ """Test for the LCN binary sensor platform.""" +from unittest.mock import patch + from pypck.inputs import ModStatusBinSensors, ModStatusKeyLocks, ModStatusVar from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import Var, VarValue +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lcn.helpers import get_device_connection -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MockConfigEntry, init_integration + +from tests.common import snapshot_platform + BINARY_SENSOR_LOCKREGULATOR1 = "binary_sensor.sensor_lockregulator1" BINARY_SENSOR_SENSOR1 = "binary_sensor.binary_sensor1" BINARY_SENSOR_KEYLOCK = "binary_sensor.sensor_keylock" -async def test_setup_lcn_binary_sensor(hass: HomeAssistant, lcn_connection) -> None: - """Test the setup of binary sensor.""" - for entity_id in ( - BINARY_SENSOR_LOCKREGULATOR1, - BINARY_SENSOR_SENSOR1, - BINARY_SENSOR_KEYLOCK, - ): - state = hass.states.get(entity_id) - assert state is not None - assert state.state == STATE_UNKNOWN - - -async def test_entity_state(hass: HomeAssistant, lcn_connection) -> None: - """Test state of entity.""" - state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) - assert state - - state = hass.states.get(BINARY_SENSOR_SENSOR1) - assert state - - state = hass.states.get(BINARY_SENSOR_KEYLOCK) - assert state - - -async def test_entity_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +async def test_setup_lcn_binary_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: - """Test the attributes of an entity.""" + """Test the setup of binary sensor.""" + with patch("homeassistant.components.lcn.PLATFORMS", [Platform.BINARY_SENSOR]): + await init_integration(hass, entry) - entity_setpoint1 = entity_registry.async_get(BINARY_SENSOR_LOCKREGULATOR1) - assert entity_setpoint1 - assert entity_setpoint1.unique_id == f"{entry.entry_id}-m000007-r1varsetpoint" - assert entity_setpoint1.original_name == "Sensor_LockRegulator1" - - entity_binsensor1 = entity_registry.async_get(BINARY_SENSOR_SENSOR1) - assert entity_binsensor1 - assert entity_binsensor1.unique_id == f"{entry.entry_id}-m000007-binsensor1" - assert entity_binsensor1.original_name == "Binary_Sensor1" - - entity_keylock = entity_registry.async_get(BINARY_SENSOR_KEYLOCK) - assert entity_keylock - assert entity_keylock.unique_id == f"{entry.entry_id}-m000007-a5" - assert entity_keylock.original_name == "Sensor_KeyLock" + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_pushed_lock_setpoint_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, + entry: MockConfigEntry, ) -> None: """Test the lock setpoint sensor changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) @@ -86,9 +64,11 @@ async def test_pushed_lock_setpoint_status_change( async def test_pushed_binsensor_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the binary port sensor changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) states = [False] * 8 @@ -114,9 +94,11 @@ async def test_pushed_binsensor_status_change( async def test_pushed_keylock_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the keylock sensor changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) states = [[False] * 8 for i in range(4)] @@ -141,8 +123,10 @@ async def test_pushed_keylock_status_change( assert state.state == STATE_ON -async def test_unload_config_entry(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the binary sensor is removed when the config entry is unloaded.""" + await init_integration(hass, entry) + await hass.config_entries.async_unload(entry.entry_id) assert hass.states.get(BINARY_SENSOR_LOCKREGULATOR1).state == STATE_UNAVAILABLE assert hass.states.get(BINARY_SENSOR_SENSOR1).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index db9f137d6bf..c1a9d094c6b 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -282,6 +282,6 @@ async def test_unload_config_entry( """Test the climate is removed when the config entry is unloaded.""" await init_integration(hass, entry) - await hass.config_entries.async_forward_entry_unload(entry, DOMAIN_CLIMATE) + await hass.config_entries.async_unload(entry.entry_id) state = hass.states.get("climate.climate1") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index d002c5fe625..9f46202ac8a 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -48,7 +48,7 @@ async def test_step_import( """Test for import step.""" with ( - patch("pypck.connection.PchkConnectionManager.async_connect"), + 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), ): @@ -76,7 +76,7 @@ async def test_step_import_existing_host( mock_entry = MockConfigEntry(domain=DOMAIN, data=mock_data) mock_entry.add_to_hass(hass) # Initialize a config flow with different data but same host address - with patch("pypck.connection.PchkConnectionManager.async_connect"): + with patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"): imported_data = IMPORT_DATA.copy() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=imported_data @@ -105,7 +105,8 @@ async def test_step_import_error( ) -> None: """Test for error in import is handled correctly.""" with patch( - "pypck.connection.PchkConnectionManager.async_connect", side_effect=error + "homeassistant.components.lcn.PchkConnectionManager.async_connect", + side_effect=error, ): data = IMPORT_DATA.copy() data.update({CONF_HOST: "pchk"}) @@ -132,7 +133,7 @@ async def test_show_form(hass: HomeAssistant) -> None: async def test_step_user(hass: HomeAssistant) -> None: """Test for user step.""" with ( - patch("pypck.connection.PchkConnectionManager.async_connect"), + 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), ): @@ -156,7 +157,7 @@ async def test_step_user_existing_host( """Test for user defined host already exists.""" entry.add_to_hass(hass) - with patch("pypck.connection.PchkConnectionManager.async_connect"): + with patch("homeassistant.components.lcn.PchkConnectionManager.async_connect"): config_data = entry.data.copy() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=config_data @@ -179,7 +180,8 @@ async def test_step_user_error( ) -> None: """Test for error in user step is handled correctly.""" with patch( - "pypck.connection.PchkConnectionManager.async_connect", side_effect=error + "homeassistant.components.lcn.PchkConnectionManager.async_connect", + side_effect=error, ): data = CONNECTION_DATA.copy() data.update({CONF_HOST: "pchk"}) @@ -197,7 +199,7 @@ async def test_step_reconfigure(hass: HomeAssistant, entry: MockConfigEntry) -> old_entry_data = entry.data.copy() with ( - patch("pypck.connection.PchkConnectionManager.async_connect"), + 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), ): @@ -235,7 +237,8 @@ async def test_step_reconfigure_error( """Test for error in reconfigure step is handled correctly.""" entry.add_to_hass(hass) with patch( - "pypck.connection.PchkConnectionManager.async_connect", side_effect=error + "homeassistant.components.lcn.PchkConnectionManager.async_connect", + side_effect=error, ): data = {**CONNECTION_DATA, CONF_HOST: "pchk"} result = await hass.config_entries.flow.async_init( @@ -256,8 +259,12 @@ async def test_validate_connection() -> None: data = CONNECTION_DATA.copy() with ( - patch("pypck.connection.PchkConnectionManager.async_connect") as async_connect, - patch("pypck.connection.PchkConnectionManager.async_close") as async_close, + patch( + "homeassistant.components.lcn.PchkConnectionManager.async_connect" + ) as async_connect, + patch( + "homeassistant.components.lcn.PchkConnectionManager.async_close" + ) as async_close, ): result = await validate_connection(data=data) diff --git a/tests/components/lcn/test_cover.py b/tests/components/lcn/test_cover.py index f50921c08a1..0067e755b5a 100644 --- a/tests/components/lcn/test_cover.py +++ b/tests/components/lcn/test_cover.py @@ -5,6 +5,7 @@ from unittest.mock import patch from pypck.inputs import ModStatusOutput, ModStatusRelays from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import MotorReverseTime, MotorStateModifier +from syrupy.assertion import SnapshotAssertion from homeassistant.components.cover import DOMAIN as DOMAIN_COVER from homeassistant.components.lcn.helpers import get_device_connection @@ -18,318 +19,319 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockModuleConnection +from .conftest import MockConfigEntry, MockModuleConnection, init_integration + +from tests.common import snapshot_platform COVER_OUTPUTS = "cover.cover_outputs" COVER_RELAYS = "cover.cover_relays" -async def test_setup_lcn_cover(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_setup_lcn_cover( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: """Test the setup of cover.""" - for entity_id in ( - COVER_OUTPUTS, - COVER_RELAYS, - ): - state = hass.states.get(entity_id) - assert state is not None - assert state.state == STATE_OPEN + with patch("homeassistant.components.lcn.PLATFORMS", [Platform.COVER]): + await init_integration(hass, entry) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_entity_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection -) -> None: - """Test the attributes of an entity.""" - - entity_outputs = entity_registry.async_get(COVER_OUTPUTS) - - assert entity_outputs - assert entity_outputs.unique_id == f"{entry.entry_id}-m000007-outputs" - assert entity_outputs.original_name == "Cover_Outputs" - - entity_relays = entity_registry.async_get(COVER_RELAYS) - - assert entity_relays - assert entity_relays.unique_id == f"{entry.entry_id}-m000007-motor1" - assert entity_relays.original_name == "Cover_Relays" - - -@patch.object(MockModuleConnection, "control_motors_outputs") -async def test_outputs_open( - control_motors_outputs, hass: HomeAssistant, lcn_connection -) -> None: +async def test_outputs_open(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the outputs cover opens.""" - state = hass.states.get(COVER_OUTPUTS) - state.state = STATE_CLOSED + await init_integration(hass, entry) - # command failed - control_motors_outputs.return_value = False + with patch.object( + MockModuleConnection, "control_motors_outputs" + ) as control_motors_outputs: + state = hass.states.get(COVER_OUTPUTS) + state.state = STATE_CLOSED - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: COVER_OUTPUTS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_outputs.assert_awaited_with( - MotorStateModifier.UP, MotorReverseTime.RT1200 - ) + # command failed + control_motors_outputs.return_value = False - state = hass.states.get(COVER_OUTPUTS) - assert state is not None - assert state.state != STATE_OPENING + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, + blocking=True, + ) - # command success - control_motors_outputs.reset_mock(return_value=True) - control_motors_outputs.return_value = True + control_motors_outputs.assert_awaited_with( + MotorStateModifier.UP, MotorReverseTime.RT1200 + ) - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: COVER_OUTPUTS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_outputs.assert_awaited_with( - MotorStateModifier.UP, MotorReverseTime.RT1200 - ) + state = hass.states.get(COVER_OUTPUTS) + assert state is not None + assert state.state != STATE_OPENING - state = hass.states.get(COVER_OUTPUTS) - assert state is not None - assert state.state == STATE_OPENING + # command success + control_motors_outputs.reset_mock(return_value=True) + control_motors_outputs.return_value = True + + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, + blocking=True, + ) + + control_motors_outputs.assert_awaited_with( + MotorStateModifier.UP, MotorReverseTime.RT1200 + ) + + state = hass.states.get(COVER_OUTPUTS) + assert state is not None + assert state.state == STATE_OPENING -@patch.object(MockModuleConnection, "control_motors_outputs") -async def test_outputs_close( - control_motors_outputs, hass: HomeAssistant, lcn_connection -) -> None: +async def test_outputs_close(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the outputs cover closes.""" - state = hass.states.get(COVER_OUTPUTS) - state.state = STATE_OPEN + await init_integration(hass, entry) - # command failed - control_motors_outputs.return_value = False + with patch.object( + MockModuleConnection, "control_motors_outputs" + ) as control_motors_outputs: + state = hass.states.get(COVER_OUTPUTS) + state.state = STATE_OPEN - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: COVER_OUTPUTS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_outputs.assert_awaited_with( - MotorStateModifier.DOWN, MotorReverseTime.RT1200 - ) + # command failed + control_motors_outputs.return_value = False - state = hass.states.get(COVER_OUTPUTS) - assert state is not None - assert state.state != STATE_CLOSING + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, + blocking=True, + ) - # command success - control_motors_outputs.reset_mock(return_value=True) - control_motors_outputs.return_value = True + control_motors_outputs.assert_awaited_with( + MotorStateModifier.DOWN, MotorReverseTime.RT1200 + ) - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: COVER_OUTPUTS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_outputs.assert_awaited_with( - MotorStateModifier.DOWN, MotorReverseTime.RT1200 - ) + state = hass.states.get(COVER_OUTPUTS) + assert state is not None + assert state.state != STATE_CLOSING - state = hass.states.get(COVER_OUTPUTS) - assert state is not None - assert state.state == STATE_CLOSING + # command success + control_motors_outputs.reset_mock(return_value=True) + control_motors_outputs.return_value = True + + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, + blocking=True, + ) + + control_motors_outputs.assert_awaited_with( + MotorStateModifier.DOWN, MotorReverseTime.RT1200 + ) + + state = hass.states.get(COVER_OUTPUTS) + assert state is not None + assert state.state == STATE_CLOSING -@patch.object(MockModuleConnection, "control_motors_outputs") -async def test_outputs_stop( - control_motors_outputs, hass: HomeAssistant, lcn_connection -) -> None: +async def test_outputs_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the outputs cover stops.""" - state = hass.states.get(COVER_OUTPUTS) - state.state = STATE_CLOSING + await init_integration(hass, entry) - # command failed - control_motors_outputs.return_value = False + with patch.object( + MockModuleConnection, "control_motors_outputs" + ) as control_motors_outputs: + state = hass.states.get(COVER_OUTPUTS) + state.state = STATE_CLOSING - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: COVER_OUTPUTS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) + # command failed + control_motors_outputs.return_value = False - state = hass.states.get(COVER_OUTPUTS) - assert state is not None - assert state.state == STATE_CLOSING + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, + blocking=True, + ) - # command success - control_motors_outputs.reset_mock(return_value=True) - control_motors_outputs.return_value = True + control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: COVER_OUTPUTS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) + state = hass.states.get(COVER_OUTPUTS) + assert state is not None + assert state.state == STATE_CLOSING - state = hass.states.get(COVER_OUTPUTS) - assert state is not None - assert state.state not in (STATE_CLOSING, STATE_OPENING) + # command success + control_motors_outputs.reset_mock(return_value=True) + control_motors_outputs.return_value = True + + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, + blocking=True, + ) + + control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) + + state = hass.states.get(COVER_OUTPUTS) + assert state is not None + assert state.state not in (STATE_CLOSING, STATE_OPENING) -@patch.object(MockModuleConnection, "control_motors_relays") -async def test_relays_open( - control_motors_relays, hass: HomeAssistant, lcn_connection -) -> None: +async def test_relays_open(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the relays cover opens.""" - states = [MotorStateModifier.NOCHANGE] * 4 - states[0] = MotorStateModifier.UP + await init_integration(hass, entry) - state = hass.states.get(COVER_RELAYS) - state.state = STATE_CLOSED + with patch.object( + MockModuleConnection, "control_motors_relays" + ) as control_motors_relays: + states = [MotorStateModifier.NOCHANGE] * 4 + states[0] = MotorStateModifier.UP - # command failed - control_motors_relays.return_value = False + state = hass.states.get(COVER_RELAYS) + state.state = STATE_CLOSED - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: COVER_RELAYS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_relays.assert_awaited_with(states) + # command failed + control_motors_relays.return_value = False - state = hass.states.get(COVER_RELAYS) - assert state is not None - assert state.state != STATE_OPENING + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: COVER_RELAYS}, + blocking=True, + ) - # command success - control_motors_relays.reset_mock(return_value=True) - control_motors_relays.return_value = True + control_motors_relays.assert_awaited_with(states) - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: COVER_RELAYS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_relays.assert_awaited_with(states) + state = hass.states.get(COVER_RELAYS) + assert state is not None + assert state.state != STATE_OPENING - state = hass.states.get(COVER_RELAYS) - assert state is not None - assert state.state == STATE_OPENING + # command success + control_motors_relays.reset_mock(return_value=True) + control_motors_relays.return_value = True + + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: COVER_RELAYS}, + blocking=True, + ) + + control_motors_relays.assert_awaited_with(states) + + state = hass.states.get(COVER_RELAYS) + assert state is not None + assert state.state == STATE_OPENING -@patch.object(MockModuleConnection, "control_motors_relays") -async def test_relays_close( - control_motors_relays, hass: HomeAssistant, lcn_connection -) -> None: +async def test_relays_close(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the relays cover closes.""" - states = [MotorStateModifier.NOCHANGE] * 4 - states[0] = MotorStateModifier.DOWN + await init_integration(hass, entry) - state = hass.states.get(COVER_RELAYS) - state.state = STATE_OPEN + with patch.object( + MockModuleConnection, "control_motors_relays" + ) as control_motors_relays: + states = [MotorStateModifier.NOCHANGE] * 4 + states[0] = MotorStateModifier.DOWN - # command failed - control_motors_relays.return_value = False + state = hass.states.get(COVER_RELAYS) + state.state = STATE_OPEN - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: COVER_RELAYS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_relays.assert_awaited_with(states) + # command failed + control_motors_relays.return_value = False - state = hass.states.get(COVER_RELAYS) - assert state is not None - assert state.state != STATE_CLOSING + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: COVER_RELAYS}, + blocking=True, + ) - # command success - control_motors_relays.reset_mock(return_value=True) - control_motors_relays.return_value = True + control_motors_relays.assert_awaited_with(states) - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: COVER_RELAYS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_relays.assert_awaited_with(states) + state = hass.states.get(COVER_RELAYS) + assert state is not None + assert state.state != STATE_CLOSING - state = hass.states.get(COVER_RELAYS) - assert state is not None - assert state.state == STATE_CLOSING + # command success + control_motors_relays.reset_mock(return_value=True) + control_motors_relays.return_value = True + + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: COVER_RELAYS}, + blocking=True, + ) + + control_motors_relays.assert_awaited_with(states) + + state = hass.states.get(COVER_RELAYS) + assert state is not None + assert state.state == STATE_CLOSING -@patch.object(MockModuleConnection, "control_motors_relays") -async def test_relays_stop( - control_motors_relays, hass: HomeAssistant, lcn_connection -) -> None: +async def test_relays_stop(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the relays cover stops.""" - states = [MotorStateModifier.NOCHANGE] * 4 - states[0] = MotorStateModifier.STOP + await init_integration(hass, entry) - state = hass.states.get(COVER_RELAYS) - state.state = STATE_CLOSING + with patch.object( + MockModuleConnection, "control_motors_relays" + ) as control_motors_relays: + states = [MotorStateModifier.NOCHANGE] * 4 + states[0] = MotorStateModifier.STOP - # command failed - control_motors_relays.return_value = False + state = hass.states.get(COVER_RELAYS) + state.state = STATE_CLOSING - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: COVER_RELAYS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_relays.assert_awaited_with(states) + # command failed + control_motors_relays.return_value = False - state = hass.states.get(COVER_RELAYS) - assert state is not None - assert state.state == STATE_CLOSING + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: COVER_RELAYS}, + blocking=True, + ) - # command success - control_motors_relays.reset_mock(return_value=True) - control_motors_relays.return_value = True + control_motors_relays.assert_awaited_with(states) - await hass.services.async_call( - DOMAIN_COVER, - SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: COVER_RELAYS}, - blocking=True, - ) - await hass.async_block_till_done() - control_motors_relays.assert_awaited_with(states) + state = hass.states.get(COVER_RELAYS) + assert state is not None + assert state.state == STATE_CLOSING - state = hass.states.get(COVER_RELAYS) - assert state is not None - assert state.state not in (STATE_CLOSING, STATE_OPENING) + # command success + control_motors_relays.reset_mock(return_value=True) + control_motors_relays.return_value = True + + await hass.services.async_call( + DOMAIN_COVER, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: COVER_RELAYS}, + blocking=True, + ) + + control_motors_relays.assert_awaited_with(states) + + state = hass.states.get(COVER_RELAYS) + assert state is not None + assert state.state not in (STATE_CLOSING, STATE_OPENING) async def test_pushed_outputs_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the outputs cover changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) @@ -365,9 +367,11 @@ async def test_pushed_outputs_status_change( async def test_pushed_relays_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the relays cover changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) states = [False] * 8 @@ -406,8 +410,10 @@ async def test_pushed_relays_status_change( assert state.state == STATE_CLOSING -async def test_unload_config_entry(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the cover is removed when the config entry is unloaded.""" + await init_integration(hass, entry) + await hass.config_entries.async_unload(entry.entry_id) assert hass.states.get(COVER_OUTPUTS).state == STATE_UNAVAILABLE assert hass.states.get(COVER_RELAYS).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index 6c5ab7d6f4e..6537c108981 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -15,15 +15,17 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.setup import async_setup_component -from .conftest import get_device +from .conftest import MockConfigEntry, get_device, init_integration from tests.common import async_get_device_automations async def test_get_triggers_module_device( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test we get the expected triggers from a LCN module device.""" + await init_integration(hass, entry) + device = get_device(hass, entry, (0, 7, False)) expected_triggers = [ @@ -50,9 +52,11 @@ async def test_get_triggers_module_device( async def test_get_triggers_non_module_device( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, entry, lcn_connection + hass: HomeAssistant, device_registry: dr.DeviceRegistry, entry: MockConfigEntry ) -> None: """Test we get the expected triggers from a LCN non-module device.""" + await init_integration(hass, entry) + not_included_types = ("transmitter", "transponder", "fingerprint", "send_keys") host_device = device_registry.async_get_device( @@ -72,9 +76,10 @@ async def test_get_triggers_non_module_device( async def test_if_fires_on_transponder_event( - hass: HomeAssistant, service_calls: list[ServiceCall], entry, lcn_connection + hass: HomeAssistant, service_calls: list[ServiceCall], entry: MockConfigEntry ) -> None: """Test for transponder event triggers firing.""" + lcn_connection = await init_integration(hass, entry) address = (0, 7, False) device = get_device(hass, entry, address) @@ -119,9 +124,10 @@ async def test_if_fires_on_transponder_event( async def test_if_fires_on_fingerprint_event( - hass: HomeAssistant, service_calls: list[ServiceCall], entry, lcn_connection + hass: HomeAssistant, service_calls: list[ServiceCall], entry: MockConfigEntry ) -> None: """Test for fingerprint event triggers firing.""" + lcn_connection = await init_integration(hass, entry) address = (0, 7, False) device = get_device(hass, entry, address) @@ -166,9 +172,10 @@ async def test_if_fires_on_fingerprint_event( async def test_if_fires_on_codelock_event( - hass: HomeAssistant, service_calls: list[ServiceCall], entry, lcn_connection + hass: HomeAssistant, service_calls: list[ServiceCall], entry: MockConfigEntry ) -> None: """Test for codelock event triggers firing.""" + lcn_connection = await init_integration(hass, entry) address = (0, 7, False) device = get_device(hass, entry, address) @@ -213,9 +220,10 @@ async def test_if_fires_on_codelock_event( async def test_if_fires_on_transmitter_event( - hass: HomeAssistant, service_calls: list[ServiceCall], entry, lcn_connection + hass: HomeAssistant, service_calls: list[ServiceCall], entry: MockConfigEntry ) -> None: """Test for transmitter event triggers firing.""" + lcn_connection = await init_integration(hass, entry) address = (0, 7, False) device = get_device(hass, entry, address) @@ -269,9 +277,10 @@ async def test_if_fires_on_transmitter_event( async def test_if_fires_on_send_keys_event( - hass: HomeAssistant, service_calls: list[ServiceCall], entry, lcn_connection + hass: HomeAssistant, service_calls: list[ServiceCall], entry: MockConfigEntry ) -> None: """Test for send_keys event triggers firing.""" + lcn_connection = await init_integration(hass, entry) address = (0, 7, False) device = get_device(hass, entry, address) @@ -318,9 +327,10 @@ async def test_if_fires_on_send_keys_event( async def test_get_transponder_trigger_capabilities( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test we get the expected capabilities from a transponder device trigger.""" + await init_integration(hass, entry) address = (0, 7, False) device = get_device(hass, entry, address) @@ -341,9 +351,10 @@ async def test_get_transponder_trigger_capabilities( async def test_get_fingerprint_trigger_capabilities( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test we get the expected capabilities from a fingerprint device trigger.""" + await init_integration(hass, entry) address = (0, 7, False) device = get_device(hass, entry, address) @@ -364,9 +375,10 @@ async def test_get_fingerprint_trigger_capabilities( async def test_get_transmitter_trigger_capabilities( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test we get the expected capabilities from a transmitter device trigger.""" + await init_integration(hass, entry) address = (0, 7, False) device = get_device(hass, entry, address) @@ -397,9 +409,10 @@ async def test_get_transmitter_trigger_capabilities( async def test_get_send_keys_trigger_capabilities( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test we get the expected capabilities from a send_keys device trigger.""" + await init_integration(hass, entry) address = (0, 7, False) device = get_device(hass, entry, address) @@ -435,9 +448,10 @@ async def test_get_send_keys_trigger_capabilities( async def test_unknown_trigger_capabilities( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test we get empty capabilities if trigger is unknown.""" + await init_integration(hass, entry) address = (0, 7, False) device = get_device(hass, entry, address) diff --git a/tests/components/lcn/test_events.py b/tests/components/lcn/test_events.py index eb62f820103..c6c3559e821 100644 --- a/tests/components/lcn/test_events.py +++ b/tests/components/lcn/test_events.py @@ -3,10 +3,11 @@ from pypck.inputs import Input, ModSendKeysHost, ModStatusAccessControl from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import AccessControlPeriphery, KeyAction, SendKeyCommand -import pytest from homeassistant.core import HomeAssistant +from .conftest import MockConfigEntry, init_integration + from tests.common import async_capture_events LCN_TRANSPONDER = "lcn_transponder" @@ -15,8 +16,11 @@ LCN_TRANSMITTER = "lcn_transmitter" LCN_SEND_KEYS = "lcn_send_keys" -async def test_fire_transponder_event(hass: HomeAssistant, lcn_connection) -> None: +async def test_fire_transponder_event( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test the transponder event is fired.""" + lcn_connection = await init_integration(hass, entry) events = async_capture_events(hass, LCN_TRANSPONDER) inp = ModStatusAccessControl( @@ -33,8 +37,11 @@ async def test_fire_transponder_event(hass: HomeAssistant, lcn_connection) -> No assert events[0].data["code"] == "aabbcc" -async def test_fire_fingerprint_event(hass: HomeAssistant, lcn_connection) -> None: +async def test_fire_fingerprint_event( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test the fingerprint event is fired.""" + lcn_connection = await init_integration(hass, entry) events = async_capture_events(hass, LCN_FINGERPRINT) inp = ModStatusAccessControl( @@ -51,8 +58,9 @@ async def test_fire_fingerprint_event(hass: HomeAssistant, lcn_connection) -> No assert events[0].data["code"] == "aabbcc" -async def test_fire_codelock_event(hass: HomeAssistant, lcn_connection) -> None: +async def test_fire_codelock_event(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the codelock event is fired.""" + lcn_connection = await init_integration(hass, entry) events = async_capture_events(hass, "lcn_codelock") inp = ModStatusAccessControl( @@ -69,8 +77,11 @@ async def test_fire_codelock_event(hass: HomeAssistant, lcn_connection) -> None: assert events[0].data["code"] == "aabbcc" -async def test_fire_transmitter_event(hass: HomeAssistant, lcn_connection) -> None: +async def test_fire_transmitter_event( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test the transmitter event is fired.""" + lcn_connection = await init_integration(hass, entry) events = async_capture_events(hass, LCN_TRANSMITTER) inp = ModStatusAccessControl( @@ -93,8 +104,9 @@ async def test_fire_transmitter_event(hass: HomeAssistant, lcn_connection) -> No assert events[0].data["action"] == "hit" -async def test_fire_sendkeys_event(hass: HomeAssistant, lcn_connection) -> None: +async def test_fire_sendkeys_event(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the send_keys event is fired.""" + lcn_connection = await init_integration(hass, entry) events = async_capture_events(hass, LCN_SEND_KEYS) inp = ModSendKeysHost( @@ -122,9 +134,10 @@ async def test_fire_sendkeys_event(hass: HomeAssistant, lcn_connection) -> None: async def test_dont_fire_on_non_module_input( - hass: HomeAssistant, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test for no event is fired if a non-module input is received.""" + lcn_connection = await init_integration(hass, entry) inp = Input() for event_name in ( @@ -139,16 +152,16 @@ async def test_dont_fire_on_non_module_input( assert len(events) == 0 -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_dont_fire_on_unknown_module(hass: HomeAssistant, lcn_connection) -> None: +async def test_dont_fire_on_unknown_module( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: """Test for no event is fired if an input from an unknown module is received.""" + lcn_connection = await init_integration(hass, entry) inp = ModStatusAccessControl( LcnAddr(0, 10, False), # unknown module periphery=AccessControlPeriphery.FINGERPRINT, code="aabbcc", ) - events = async_capture_events(hass, LCN_FINGERPRINT) await lcn_connection.async_process_input(inp) await hass.async_block_till_done() diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index 120db8a1333..ece0e95e501 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -2,11 +2,8 @@ from unittest.mock import Mock, patch -from pypck.connection import ( - PchkAuthenticationError, - PchkConnectionManager, - PchkLicenseError, -) +from pypck.connection import PchkAuthenticationError, PchkLicenseError +import pytest from homeassistant import config_entries from homeassistant.components.lcn.const import DOMAIN @@ -14,11 +11,18 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import MockPchkConnectionManager, setup_component +from .conftest import ( + MockConfigEntry, + MockPchkConnectionManager, + init_integration, + setup_component, +) -async def test_async_setup_entry(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_async_setup_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test a successful setup entry and unload of entry.""" + await init_integration(hass, entry) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert entry.state is ConfigEntryState.LOADED @@ -29,16 +33,16 @@ async def test_async_setup_entry(hass: HomeAssistant, entry, lcn_connection) -> assert not hass.data.get(DOMAIN) -async def test_async_setup_multiple_entries(hass: HomeAssistant, entry, entry2) -> None: +async def test_async_setup_multiple_entries( + hass: HomeAssistant, entry: MockConfigEntry, entry2 +) -> None: """Test a successful setup and unload of multiple entries.""" hass.http = Mock() with patch( "homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager ): for config_entry in (entry, entry2): - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 2 @@ -56,7 +60,7 @@ async def test_async_setup_entry_update( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - entry, + entry: MockConfigEntry, ) -> None: """Test a successful setup entry if entry with same id already exists.""" # setup first entry @@ -79,7 +83,10 @@ async def test_async_setup_entry_update( assert dummy_device in device_registry.devices.values() # setup new entry with same data via import step (should cleanup dummy device) - with patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager): + with patch( + "homeassistant.components.lcn.config_flow.validate_connection", + return_value=None, + ): await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=entry.data ) @@ -88,12 +95,16 @@ async def test_async_setup_entry_update( assert dummy_entity not in entity_registry.entities.values() +@pytest.mark.parametrize( + "exception", [PchkAuthenticationError, PchkLicenseError, TimeoutError] +) async def test_async_setup_entry_raises_authentication_error( - hass: HomeAssistant, entry + hass: HomeAssistant, entry: MockConfigEntry, exception: Exception ) -> None: """Test that an authentication error is handled properly.""" - with patch.object( - PchkConnectionManager, "async_connect", side_effect=PchkAuthenticationError + with patch( + "homeassistant.components.lcn.PchkConnectionManager.async_connect", + side_effect=exception, ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -102,36 +113,13 @@ async def test_async_setup_entry_raises_authentication_error( assert entry.state is ConfigEntryState.SETUP_ERROR -async def test_async_setup_entry_raises_license_error( - hass: HomeAssistant, entry -) -> None: - """Test that an authentication error is handled properly.""" - with patch.object( - PchkConnectionManager, "async_connect", side_effect=PchkLicenseError - ): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_ERROR - - -async def test_async_setup_entry_raises_timeout_error( - hass: HomeAssistant, entry -) -> None: - """Test that an authentication error is handled properly.""" - with patch.object(PchkConnectionManager, "async_connect", side_effect=TimeoutError): - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is ConfigEntryState.SETUP_ERROR - - async def test_async_setup_from_configuration_yaml(hass: HomeAssistant) -> None: """Test a successful setup using data from configuration.yaml.""" with ( - patch("pypck.connection.PchkConnectionManager", MockPchkConnectionManager), + patch( + "homeassistant.components.lcn.config_flow.validate_connection", + return_value=None, + ), patch("homeassistant.components.lcn.async_setup_entry") as async_setup_entry, ): await setup_component(hass) diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index b91f3d5b17c..4251d997724 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -5,297 +5,278 @@ from unittest.mock import patch from pypck.inputs import ModStatusOutput, ModStatusRelays from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import RelayStateModifier +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, DOMAIN as DOMAIN_LIGHT, - ColorMode, - LightEntityFeature, ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockModuleConnection +from .conftest import MockConfigEntry, MockModuleConnection, init_integration + +from tests.common import snapshot_platform LIGHT_OUTPUT1 = "light.light_output1" LIGHT_OUTPUT2 = "light.light_output2" LIGHT_RELAY1 = "light.light_relay1" -async def test_setup_lcn_light(hass: HomeAssistant, lcn_connection) -> None: +async def test_setup_lcn_light( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: """Test the setup of light.""" - for entity_id in ( - LIGHT_OUTPUT1, - LIGHT_OUTPUT2, - LIGHT_RELAY1, - ): - state = hass.states.get(entity_id) + with patch("homeassistant.components.lcn.PLATFORMS", [Platform.LIGHT]): + await init_integration(hass, entry) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_output_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test the output light turns on.""" + await init_integration(hass, entry) + + with patch.object(MockModuleConnection, "dim_output") as dim_output: + # command failed + dim_output.return_value = False + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, + blocking=True, + ) + + dim_output.assert_awaited_with(0, 100, 9) + + state = hass.states.get(LIGHT_OUTPUT1) + assert state is not None + assert state.state != STATE_ON + + # command success + dim_output.reset_mock(return_value=True) + dim_output.return_value = True + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, + blocking=True, + ) + + dim_output.assert_awaited_with(0, 100, 9) + + state = hass.states.get(LIGHT_OUTPUT1) + assert state is not None + assert state.state == STATE_ON + + +async def test_output_turn_on_with_attributes( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test the output light turns on.""" + await init_integration(hass, entry) + + with patch.object(MockModuleConnection, "dim_output") as dim_output: + dim_output.return_value = True + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: LIGHT_OUTPUT1, + ATTR_BRIGHTNESS: 50, + ATTR_TRANSITION: 2, + }, + blocking=True, + ) + + dim_output.assert_awaited_with(0, 19, 6) + + state = hass.states.get(LIGHT_OUTPUT1) + assert state is not None + assert state.state == STATE_ON + + +async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test the output light turns off.""" + await init_integration(hass, entry) + + with patch.object(MockModuleConnection, "dim_output") as dim_output: + state = hass.states.get(LIGHT_OUTPUT1) + state.state = STATE_ON + + # command failed + dim_output.return_value = False + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, + blocking=True, + ) + + dim_output.assert_awaited_with(0, 0, 9) + + state = hass.states.get(LIGHT_OUTPUT1) + assert state is not None + assert state.state != STATE_OFF + + # command success + dim_output.reset_mock(return_value=True) + dim_output.return_value = True + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, + blocking=True, + ) + + dim_output.assert_awaited_with(0, 0, 9) + + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_OFF -async def test_entity_state(hass: HomeAssistant, lcn_connection) -> None: - """Test state of entity.""" - state = hass.states.get(LIGHT_OUTPUT1) - assert state - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - - state = hass.states.get(LIGHT_OUTPUT2) - assert state - assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] - - -async def test_entity_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection -) -> None: - """Test the attributes of an entity.""" - entity_output = entity_registry.async_get(LIGHT_OUTPUT1) - - assert entity_output - assert entity_output.unique_id == f"{entry.entry_id}-m000007-output1" - assert entity_output.original_name == "Light_Output1" - - entity_relay = entity_registry.async_get(LIGHT_RELAY1) - - assert entity_relay - assert entity_relay.unique_id == f"{entry.entry_id}-m000007-relay1" - assert entity_relay.original_name == "Light_Relay1" - - -@patch.object(MockModuleConnection, "dim_output") -async def test_output_turn_on(dim_output, hass: HomeAssistant, lcn_connection) -> None: - """Test the output light turns on.""" - # command failed - dim_output.return_value = False - - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, - blocking=True, - ) - await hass.async_block_till_done() - dim_output.assert_awaited_with(0, 100, 9) - - state = hass.states.get(LIGHT_OUTPUT1) - assert state is not None - assert state.state != STATE_ON - - # command success - dim_output.reset_mock(return_value=True) - dim_output.return_value = True - - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, - blocking=True, - ) - await hass.async_block_till_done() - dim_output.assert_awaited_with(0, 100, 9) - - state = hass.states.get(LIGHT_OUTPUT1) - assert state is not None - assert state.state == STATE_ON - - -@patch.object(MockModuleConnection, "dim_output") -async def test_output_turn_on_with_attributes( - dim_output, hass: HomeAssistant, lcn_connection -) -> None: - """Test the output light turns on.""" - dim_output.return_value = True - - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: LIGHT_OUTPUT1, - ATTR_BRIGHTNESS: 50, - ATTR_TRANSITION: 2, - }, - blocking=True, - ) - await hass.async_block_till_done() - dim_output.assert_awaited_with(0, 19, 6) - - state = hass.states.get(LIGHT_OUTPUT1) - assert state is not None - assert state.state == STATE_ON - - -@patch.object(MockModuleConnection, "dim_output") -async def test_output_turn_off(dim_output, hass: HomeAssistant, lcn_connection) -> None: - """Test the output light turns off.""" - state = hass.states.get(LIGHT_OUTPUT1) - state.state = STATE_ON - - # command failed - dim_output.return_value = False - - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, - blocking=True, - ) - await hass.async_block_till_done() - dim_output.assert_awaited_with(0, 0, 9) - - state = hass.states.get(LIGHT_OUTPUT1) - assert state is not None - assert state.state != STATE_OFF - - # command success - dim_output.reset_mock(return_value=True) - dim_output.return_value = True - - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, - blocking=True, - ) - await hass.async_block_till_done() - dim_output.assert_awaited_with(0, 0, 9) - - state = hass.states.get(LIGHT_OUTPUT1) - assert state is not None - assert state.state == STATE_OFF - - -@patch.object(MockModuleConnection, "dim_output") async def test_output_turn_off_with_attributes( - dim_output, hass: HomeAssistant, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the output light turns off.""" - dim_output.return_value = True + await init_integration(hass, entry) - state = hass.states.get(LIGHT_OUTPUT1) - state.state = STATE_ON + with patch.object(MockModuleConnection, "dim_output") as dim_output: + dim_output.return_value = True - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: LIGHT_OUTPUT1, - ATTR_TRANSITION: 2, - }, - blocking=True, - ) - await hass.async_block_till_done() - dim_output.assert_awaited_with(0, 0, 6) + state = hass.states.get(LIGHT_OUTPUT1) + state.state = STATE_ON - state = hass.states.get(LIGHT_OUTPUT1) - assert state is not None - assert state.state == STATE_OFF + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: LIGHT_OUTPUT1, + ATTR_TRANSITION: 2, + }, + blocking=True, + ) + + dim_output.assert_awaited_with(0, 0, 6) + + state = hass.states.get(LIGHT_OUTPUT1) + assert state is not None + assert state.state == STATE_OFF -@patch.object(MockModuleConnection, "control_relays") -async def test_relay_turn_on( - control_relays, hass: HomeAssistant, lcn_connection -) -> None: +async def test_relay_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the relay light turns on.""" - states = [RelayStateModifier.NOCHANGE] * 8 - states[0] = RelayStateModifier.ON + await init_integration(hass, entry) - # command failed - control_relays.return_value = False + with patch.object(MockModuleConnection, "control_relays") as control_relays: + states = [RelayStateModifier.NOCHANGE] * 8 + states[0] = RelayStateModifier.ON - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: LIGHT_RELAY1}, - blocking=True, - ) - await hass.async_block_till_done() - control_relays.assert_awaited_with(states) + # command failed + control_relays.return_value = False - state = hass.states.get(LIGHT_RELAY1) - assert state is not None - assert state.state != STATE_ON + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, + blocking=True, + ) - # command success - control_relays.reset_mock(return_value=True) - control_relays.return_value = True + control_relays.assert_awaited_with(states) - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: LIGHT_RELAY1}, - blocking=True, - ) - await hass.async_block_till_done() - control_relays.assert_awaited_with(states) + state = hass.states.get(LIGHT_RELAY1) + assert state is not None + assert state.state != STATE_ON - state = hass.states.get(LIGHT_RELAY1) - assert state is not None - assert state.state == STATE_ON + # command success + control_relays.reset_mock(return_value=True) + control_relays.return_value = True + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, + blocking=True, + ) + + control_relays.assert_awaited_with(states) + + state = hass.states.get(LIGHT_RELAY1) + assert state is not None + assert state.state == STATE_ON -@patch.object(MockModuleConnection, "control_relays") -async def test_relay_turn_off( - control_relays, hass: HomeAssistant, lcn_connection -) -> None: +async def test_relay_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the relay light turns off.""" - states = [RelayStateModifier.NOCHANGE] * 8 - states[0] = RelayStateModifier.OFF + await init_integration(hass, entry) - state = hass.states.get(LIGHT_RELAY1) - state.state = STATE_ON + with patch.object(MockModuleConnection, "control_relays") as control_relays: + states = [RelayStateModifier.NOCHANGE] * 8 + states[0] = RelayStateModifier.OFF - # command failed - control_relays.return_value = False + state = hass.states.get(LIGHT_RELAY1) + state.state = STATE_ON - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: LIGHT_RELAY1}, - blocking=True, - ) - await hass.async_block_till_done() - control_relays.assert_awaited_with(states) + # command failed + control_relays.return_value = False - state = hass.states.get(LIGHT_RELAY1) - assert state is not None - assert state.state != STATE_OFF + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, + blocking=True, + ) - # command success - control_relays.reset_mock(return_value=True) - control_relays.return_value = True + control_relays.assert_awaited_with(states) - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: LIGHT_RELAY1}, - blocking=True, - ) - await hass.async_block_till_done() - control_relays.assert_awaited_with(states) + state = hass.states.get(LIGHT_RELAY1) + assert state is not None + assert state.state != STATE_OFF - state = hass.states.get(LIGHT_RELAY1) - assert state is not None - assert state.state == STATE_OFF + # command success + control_relays.reset_mock(return_value=True) + control_relays.return_value = True + + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, + blocking=True, + ) + + control_relays.assert_awaited_with(states) + + state = hass.states.get(LIGHT_RELAY1) + assert state is not None + assert state.state == STATE_OFF async def test_pushed_output_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the output light changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) @@ -320,9 +301,11 @@ async def test_pushed_output_status_change( async def test_pushed_relay_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the relay light changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) states = [False] * 8 @@ -348,7 +331,9 @@ async def test_pushed_relay_status_change( assert state.state == STATE_OFF -async def test_unload_config_entry(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the light is removed when the config entry is unloaded.""" + await init_integration(hass, entry) + await hass.config_entries.async_unload(entry.entry_id) assert hass.states.get(LIGHT_OUTPUT1).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_scene.py b/tests/components/lcn/test_scene.py index 558893bb76f..fcd59693479 100644 --- a/tests/components/lcn/test_scene.py +++ b/tests/components/lcn/test_scene.py @@ -58,7 +58,7 @@ async def test_scene_activate( async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the scene is removed when the config entry is unloaded.""" await init_integration(hass, entry) - await hass.config_entries.async_forward_entry_unload(entry, DOMAIN_SCENE) + await hass.config_entries.async_unload(entry.entry_id) state = hass.states.get("scene.romantic") assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_sensor.py b/tests/components/lcn/test_sensor.py index cdcd5a195a3..18335f4b073 100644 --- a/tests/components/lcn/test_sensor.py +++ b/tests/components/lcn/test_sensor.py @@ -1,85 +1,46 @@ """Test for the LCN sensor platform.""" +from unittest.mock import patch + from pypck.inputs import ModStatusLedsAndLogicOps, ModStatusVar from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import LedStatus, LogicOpStatus, Var, VarValue +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lcn.helpers import get_device_connection -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - UnitOfTemperature, -) +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .conftest import MockConfigEntry, init_integration + +from tests.common import snapshot_platform + SENSOR_VAR1 = "sensor.sensor_var1" SENSOR_SETPOINT1 = "sensor.sensor_setpoint1" SENSOR_LED6 = "sensor.sensor_led6" SENSOR_LOGICOP1 = "sensor.sensor_logicop1" -async def test_setup_lcn_sensor(hass: HomeAssistant, entry, lcn_connection) -> None: - """Test the setup of sensor.""" - for entity_id in ( - SENSOR_VAR1, - SENSOR_SETPOINT1, - SENSOR_LED6, - SENSOR_LOGICOP1, - ): - state = hass.states.get(entity_id) - assert state is not None - assert state.state == STATE_UNKNOWN - - -async def test_entity_state(hass: HomeAssistant, lcn_connection) -> None: - """Test state of entity.""" - state = hass.states.get(SENSOR_VAR1) - assert state - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - - state = hass.states.get(SENSOR_SETPOINT1) - assert state - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - - state = hass.states.get(SENSOR_LED6) - assert state - - state = hass.states.get(SENSOR_LOGICOP1) - assert state - - -async def test_entity_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection +async def test_setup_lcn_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entry: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: - """Test the attributes of an entity.""" + """Test the setup of sensor.""" + with patch("homeassistant.components.lcn.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, entry) - entity_var1 = entity_registry.async_get(SENSOR_VAR1) - assert entity_var1 - assert entity_var1.unique_id == f"{entry.entry_id}-m000007-var1" - assert entity_var1.original_name == "Sensor_Var1" - - entity_r1varsetpoint = entity_registry.async_get(SENSOR_SETPOINT1) - assert entity_r1varsetpoint - assert entity_r1varsetpoint.unique_id == f"{entry.entry_id}-m000007-r1varsetpoint" - assert entity_r1varsetpoint.original_name == "Sensor_Setpoint1" - - entity_led6 = entity_registry.async_get(SENSOR_LED6) - assert entity_led6 - assert entity_led6.unique_id == f"{entry.entry_id}-m000007-led6" - assert entity_led6.original_name == "Sensor_Led6" - - entity_logicop1 = entity_registry.async_get(SENSOR_LOGICOP1) - assert entity_logicop1 - assert entity_logicop1.unique_id == f"{entry.entry_id}-m000007-logicop1" - assert entity_logicop1.original_name == "Sensor_LogicOp1" + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) async def test_pushed_variable_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the variable sensor changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) @@ -103,9 +64,11 @@ async def test_pushed_variable_status_change( async def test_pushed_ledlogicop_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the led and logicop sensor changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) @@ -129,8 +92,10 @@ async def test_pushed_ledlogicop_status_change( assert state.state == "all" -async def test_unload_config_entry(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the sensor is removed when the config entry is unloaded.""" + await init_integration(hass, entry) + await hass.config_entries.async_unload(entry.entry_id) assert hass.states.get(SENSOR_VAR1).state == STATE_UNAVAILABLE assert hass.states.get(SENSOR_SETPOINT1).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index 27253a0c7e5..a4ea559cd72 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -2,8 +2,8 @@ from unittest.mock import patch +import pypck import pytest -from syrupy import SnapshotAssertion from homeassistant.components.lcn import DOMAIN from homeassistant.components.lcn.const import ( @@ -41,9 +41,7 @@ from .conftest import ( @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_output_abs( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_output_abs(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test output_abs service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -61,13 +59,11 @@ async def test_service_output_abs( blocking=True, ) - assert dim_output.await_args.args == snapshot() + dim_output.assert_awaited_with(0, 100, 9) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_output_rel( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_output_rel(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test output_rel service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -84,12 +80,12 @@ async def test_service_output_rel( blocking=True, ) - assert rel_output.await_args.args == snapshot() + rel_output.assert_awaited_with(0, 25) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_output_toggle( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test output_toggle service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -107,13 +103,11 @@ async def test_service_output_toggle( blocking=True, ) - assert toggle_output.await_args.args == snapshot() + toggle_output.assert_awaited_with(0, 9) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_relays( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_relays(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test relays service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -126,13 +120,14 @@ async def test_service_relays( blocking=True, ) - assert control_relays.await_args.args == snapshot() + states = ["OFF", "OFF", "ON", "ON", "TOGGLE", "TOGGLE", "NOCHANGE", "NOCHANGE"] + relay_states = [pypck.lcn_defs.RelayStateModifier[state] for state in states] + + control_relays.assert_awaited_with(relay_states) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_led( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_led(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test led service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -145,13 +140,14 @@ async def test_service_led( blocking=True, ) - assert control_led.await_args.args == snapshot() + led = pypck.lcn_defs.LedPort["LED6"] + led_state = pypck.lcn_defs.LedStatus["BLINK"] + + control_led.assert_awaited_with(led, led_state) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_var_abs( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_var_abs(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test var_abs service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -169,13 +165,13 @@ async def test_service_var_abs( blocking=True, ) - assert var_abs.await_args.args == snapshot() + var_abs.assert_awaited_with( + pypck.lcn_defs.Var["VAR1"], 75, pypck.lcn_defs.VarUnit.parse("%") + ) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_var_rel( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_var_rel(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test var_rel service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -194,13 +190,16 @@ async def test_service_var_rel( blocking=True, ) - assert var_rel.await_args.args == snapshot() + var_rel.assert_awaited_with( + pypck.lcn_defs.Var["VAR1"], + 10, + pypck.lcn_defs.VarUnit.parse("%"), + pypck.lcn_defs.RelVarRef["CURRENT"], + ) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_var_reset( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_var_reset(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test var_reset service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -213,12 +212,12 @@ async def test_service_var_reset( blocking=True, ) - assert var_reset.await_args.args == snapshot() + var_reset.assert_awaited_with(pypck.lcn_defs.Var["VAR1"]) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_lock_regulator( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test lock_regulator service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -236,13 +235,11 @@ async def test_service_lock_regulator( blocking=True, ) - assert lock_regulator.await_args.args == snapshot() + lock_regulator.assert_awaited_with(0, True) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_send_keys( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_send_keys(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test send_keys service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -260,12 +257,12 @@ async def test_service_send_keys( keys[0][4] = True keys[3][7] = True - assert send_keys.await_args.args == snapshot() + send_keys.assert_awaited_with(keys, pypck.lcn_defs.SendKeyCommand["HIT"]) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_send_keys_hit_deferred( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test send_keys (hit_deferred) service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -292,7 +289,9 @@ async def test_service_send_keys_hit_deferred( blocking=True, ) - assert send_keys_hit_deferred.await_args.args == snapshot() + send_keys_hit_deferred.assert_awaited_with( + keys, 5, pypck.lcn_defs.TimeUnit.parse("S") + ) # wrong key action with ( @@ -316,9 +315,7 @@ async def test_service_send_keys_hit_deferred( @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_lock_keys( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_lock_keys(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test lock_keys service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -331,12 +328,15 @@ async def test_service_lock_keys( blocking=True, ) - assert lock_keys.await_args.args == snapshot() + states = ["OFF", "OFF", "ON", "ON", "TOGGLE", "TOGGLE", "NOCHANGE", "NOCHANGE"] + lock_states = [pypck.lcn_defs.KeyLockStateModifier[state] for state in states] + + lock_keys.assert_awaited_with(0, lock_states) @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) async def test_service_lock_keys_tab_a_temporary( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test lock_keys (tab_a_temporary) service.""" await async_setup_component(hass, "persistent_notification", {}) @@ -358,7 +358,12 @@ async def test_service_lock_keys_tab_a_temporary( blocking=True, ) - assert lock_keys_tab_a_temporary.await_args.args == snapshot() + states = ["OFF", "OFF", "ON", "ON", "TOGGLE", "TOGGLE", "NOCHANGE", "NOCHANGE"] + lock_states = [pypck.lcn_defs.KeyLockStateModifier[state] for state in states] + + lock_keys_tab_a_temporary.assert_awaited_with( + 10, pypck.lcn_defs.TimeUnit.parse("S"), lock_states + ) # wrong table with ( @@ -382,9 +387,7 @@ async def test_service_lock_keys_tab_a_temporary( @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_dyn_text( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_dyn_text(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test dyn_text service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -397,13 +400,11 @@ async def test_service_dyn_text( blocking=True, ) - assert dyn_text.await_args.args == snapshot() + dyn_text.assert_awaited_with(0, "text in row 1") @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) -async def test_service_pck( - hass: HomeAssistant, entry: MockConfigEntry, snapshot: SnapshotAssertion -) -> None: +async def test_service_pck(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test pck service.""" await async_setup_component(hass, "persistent_notification", {}) await init_integration(hass, entry) @@ -416,7 +417,7 @@ async def test_service_pck( blocking=True, ) - assert pck.await_args.args == snapshot() + pck.assert_awaited_with("PIN4") @patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) diff --git a/tests/components/lcn/test_switch.py b/tests/components/lcn/test_switch.py index f24828c5fcb..f57a51bc8a3 100644 --- a/tests/components/lcn/test_switch.py +++ b/tests/components/lcn/test_switch.py @@ -5,6 +5,7 @@ from unittest.mock import patch from pypck.inputs import ModStatusOutput, ModStatusRelays from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import RelayStateModifier +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lcn.helpers import get_device_connection from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH @@ -15,11 +16,14 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockModuleConnection +from .conftest import MockConfigEntry, MockModuleConnection, init_integration + +from tests.common import snapshot_platform SWITCH_OUTPUT1 = "switch.switch_output1" SWITCH_OUTPUT2 = "switch.switch_output2" @@ -27,197 +31,185 @@ SWITCH_RELAY1 = "switch.switch_relay1" SWITCH_RELAY2 = "switch.switch_relay2" -async def test_setup_lcn_switch(hass: HomeAssistant, lcn_connection) -> None: +async def test_setup_lcn_switch( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: """Test the setup of switch.""" - for entity_id in ( - SWITCH_OUTPUT1, - SWITCH_OUTPUT2, - SWITCH_RELAY1, - SWITCH_RELAY2, - ): - state = hass.states.get(entity_id) - assert state is not None + with patch("homeassistant.components.lcn.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, entry) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_output_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test the output switch turns on.""" + await init_integration(hass, entry) + + with patch.object(MockModuleConnection, "dim_output") as dim_output: + # command failed + dim_output.return_value = False + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, + blocking=True, + ) + + dim_output.assert_awaited_with(0, 100, 0) + + state = hass.states.get(SWITCH_OUTPUT1) + assert state.state == STATE_OFF + + # command success + dim_output.reset_mock(return_value=True) + dim_output.return_value = True + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, + blocking=True, + ) + + dim_output.assert_awaited_with(0, 100, 0) + + state = hass.states.get(SWITCH_OUTPUT1) + assert state.state == STATE_ON + + +async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test the output switch turns off.""" + await init_integration(hass, entry) + + with patch.object(MockModuleConnection, "dim_output") as dim_output: + state = hass.states.get(SWITCH_OUTPUT1) + state.state = STATE_ON + + # command failed + dim_output.return_value = False + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, + blocking=True, + ) + + dim_output.assert_awaited_with(0, 0, 0) + + state = hass.states.get(SWITCH_OUTPUT1) + assert state.state == STATE_ON + + # command success + dim_output.reset_mock(return_value=True) + dim_output.return_value = True + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, + blocking=True, + ) + + dim_output.assert_awaited_with(0, 0, 0) + + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_OFF -async def test_entity_attributes( - hass: HomeAssistant, entity_registry: er.EntityRegistry, entry, lcn_connection -) -> None: - """Test the attributes of an entity.""" - - entity_output = entity_registry.async_get(SWITCH_OUTPUT1) - - assert entity_output - assert entity_output.unique_id == f"{entry.entry_id}-m000007-output1" - assert entity_output.original_name == "Switch_Output1" - - entity_relay = entity_registry.async_get(SWITCH_RELAY1) - - assert entity_relay - assert entity_relay.unique_id == f"{entry.entry_id}-m000007-relay1" - assert entity_relay.original_name == "Switch_Relay1" - - -@patch.object(MockModuleConnection, "dim_output") -async def test_output_turn_on(dim_output, hass: HomeAssistant, lcn_connection) -> None: - """Test the output switch turns on.""" - # command failed - dim_output.return_value = False - - await hass.services.async_call( - DOMAIN_SWITCH, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, - blocking=True, - ) - await hass.async_block_till_done() - dim_output.assert_awaited_with(0, 100, 0) - - state = hass.states.get(SWITCH_OUTPUT1) - assert state.state == STATE_OFF - - # command success - dim_output.reset_mock(return_value=True) - dim_output.return_value = True - - await hass.services.async_call( - DOMAIN_SWITCH, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, - blocking=True, - ) - await hass.async_block_till_done() - dim_output.assert_awaited_with(0, 100, 0) - - state = hass.states.get(SWITCH_OUTPUT1) - assert state.state == STATE_ON - - -@patch.object(MockModuleConnection, "dim_output") -async def test_output_turn_off(dim_output, hass: HomeAssistant, lcn_connection) -> None: - """Test the output switch turns off.""" - state = hass.states.get(SWITCH_OUTPUT1) - state.state = STATE_ON - - # command failed - dim_output.return_value = False - - await hass.services.async_call( - DOMAIN_SWITCH, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, - blocking=True, - ) - await hass.async_block_till_done() - dim_output.assert_awaited_with(0, 0, 0) - - state = hass.states.get(SWITCH_OUTPUT1) - assert state.state == STATE_ON - - # command success - dim_output.reset_mock(return_value=True) - dim_output.return_value = True - - await hass.services.async_call( - DOMAIN_SWITCH, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, - blocking=True, - ) - await hass.async_block_till_done() - dim_output.assert_awaited_with(0, 0, 0) - - state = hass.states.get(SWITCH_OUTPUT1) - assert state.state == STATE_OFF - - -@patch.object(MockModuleConnection, "control_relays") -async def test_relay_turn_on( - control_relays, hass: HomeAssistant, lcn_connection -) -> None: +async def test_relay_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the relay switch turns on.""" - states = [RelayStateModifier.NOCHANGE] * 8 - states[0] = RelayStateModifier.ON + await init_integration(hass, entry) - # command failed - control_relays.return_value = False + with patch.object(MockModuleConnection, "control_relays") as control_relays: + states = [RelayStateModifier.NOCHANGE] * 8 + states[0] = RelayStateModifier.ON - await hass.services.async_call( - DOMAIN_SWITCH, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: SWITCH_RELAY1}, - blocking=True, - ) - await hass.async_block_till_done() - control_relays.assert_awaited_with(states) + # command failed + control_relays.return_value = False - state = hass.states.get(SWITCH_RELAY1) - assert state.state == STATE_OFF + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, + blocking=True, + ) - # command success - control_relays.reset_mock(return_value=True) - control_relays.return_value = True + control_relays.assert_awaited_with(states) - await hass.services.async_call( - DOMAIN_SWITCH, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: SWITCH_RELAY1}, - blocking=True, - ) - await hass.async_block_till_done() - control_relays.assert_awaited_with(states) + state = hass.states.get(SWITCH_RELAY1) + assert state.state == STATE_OFF - state = hass.states.get(SWITCH_RELAY1) - assert state.state == STATE_ON + # command success + control_relays.reset_mock(return_value=True) + control_relays.return_value = True + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, + blocking=True, + ) + + control_relays.assert_awaited_with(states) + + state = hass.states.get(SWITCH_RELAY1) + assert state.state == STATE_ON -@patch.object(MockModuleConnection, "control_relays") -async def test_relay_turn_off( - control_relays, hass: HomeAssistant, lcn_connection -) -> None: +async def test_relay_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the relay switch turns off.""" - states = [RelayStateModifier.NOCHANGE] * 8 - states[0] = RelayStateModifier.OFF + await init_integration(hass, entry) - state = hass.states.get(SWITCH_RELAY1) - state.state = STATE_ON + with patch.object(MockModuleConnection, "control_relays") as control_relays: + states = [RelayStateModifier.NOCHANGE] * 8 + states[0] = RelayStateModifier.OFF - # command failed - control_relays.return_value = False + state = hass.states.get(SWITCH_RELAY1) + state.state = STATE_ON - await hass.services.async_call( - DOMAIN_SWITCH, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: SWITCH_RELAY1}, - blocking=True, - ) - await hass.async_block_till_done() - control_relays.assert_awaited_with(states) + # command failed + control_relays.return_value = False - state = hass.states.get(SWITCH_RELAY1) - assert state.state == STATE_ON + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, + blocking=True, + ) - # command success - control_relays.reset_mock(return_value=True) - control_relays.return_value = True + control_relays.assert_awaited_with(states) - await hass.services.async_call( - DOMAIN_SWITCH, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: SWITCH_RELAY1}, - blocking=True, - ) - await hass.async_block_till_done() - control_relays.assert_awaited_with(states) + state = hass.states.get(SWITCH_RELAY1) + assert state.state == STATE_ON - state = hass.states.get(SWITCH_RELAY1) - assert state.state == STATE_OFF + # command success + control_relays.reset_mock(return_value=True) + control_relays.return_value = True + + await hass.services.async_call( + DOMAIN_SWITCH, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, + blocking=True, + ) + + control_relays.assert_awaited_with(states) + + state = hass.states.get(SWITCH_RELAY1) + assert state.state == STATE_OFF async def test_pushed_output_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the output switch changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) @@ -239,9 +231,11 @@ async def test_pushed_output_status_change( async def test_pushed_relay_status_change( - hass: HomeAssistant, entry, lcn_connection + hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test the relay switch changes its state on status received.""" + await init_integration(hass, entry) + device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) states = [False] * 8 @@ -265,7 +259,9 @@ async def test_pushed_relay_status_change( assert state.state == STATE_OFF -async def test_unload_config_entry(hass: HomeAssistant, entry, lcn_connection) -> None: +async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the switch is removed when the config entry is unloaded.""" + await init_integration(hass, entry) + await hass.config_entries.async_unload(entry.entry_id) assert hass.states.get(SWITCH_OUTPUT1).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_websocket.py b/tests/components/lcn/test_websocket.py index f1f0a19b572..2c5fff89e19 100644 --- a/tests/components/lcn/test_websocket.py +++ b/tests/components/lcn/test_websocket.py @@ -1,8 +1,11 @@ """LCN Websocket Tests.""" +from typing import Any + from pypck.lcn_addr import LcnAddr import pytest +from homeassistant.components.lcn import AddressType from homeassistant.components.lcn.const import CONF_DOMAIN_DATA from homeassistant.components.lcn.helpers import get_device_config, get_resource from homeassistant.const import ( @@ -16,6 +19,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant +from .conftest import MockConfigEntry, init_integration + from tests.typing import WebSocketGenerator DEVICES_PAYLOAD = {CONF_TYPE: "lcn/devices", "entry_id": ""} @@ -52,11 +57,12 @@ ENTITIES_DELETE_PAYLOAD = { async def test_lcn_devices_command( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry: MockConfigEntry ) -> None: """Test lcn/devices command.""" - client = await hass_ws_client(hass) + await init_integration(hass, entry) + client = await hass_ws_client(hass) await client.send_json_auto_id({**DEVICES_PAYLOAD, "entry_id": entry.entry_id}) res = await client.receive_json() @@ -79,11 +85,12 @@ async def test_lcn_devices_command( async def test_lcn_entities_command( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - entry, - lcn_connection, + entry: MockConfigEntry, payload, ) -> None: """Test lcn/entities command.""" + await init_integration(hass, entry) + client = await hass_ws_client(hass) await client.send_json_auto_id( { @@ -107,10 +114,11 @@ async def test_lcn_entities_command( async def test_lcn_devices_scan_command( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry: MockConfigEntry ) -> None: """Test lcn/devices/scan command.""" # add new module which is not stored in config_entry + lcn_connection = await init_integration(hass, entry) lcn_connection.get_address_conn(LcnAddr(0, 10, False)) client = await hass_ws_client(hass) @@ -129,9 +137,11 @@ async def test_lcn_devices_scan_command( async def test_lcn_devices_add_command( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry: MockConfigEntry ) -> None: """Test lcn/devices/add command.""" + await init_integration(hass, entry) + client = await hass_ws_client(hass) assert get_device_config((0, 10, False), entry) is None @@ -144,9 +154,11 @@ async def test_lcn_devices_add_command( async def test_lcn_devices_delete_command( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry: MockConfigEntry ) -> None: """Test lcn/devices/delete command.""" + await init_integration(hass, entry) + client = await hass_ws_client(hass) assert get_device_config((0, 7, False), entry) @@ -160,9 +172,11 @@ async def test_lcn_devices_delete_command( async def test_lcn_entities_add_command( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry: MockConfigEntry ) -> None: """Test lcn/entities/add command.""" + await init_integration(hass, entry) + client = await hass_ws_client(hass) entity_config = { @@ -185,9 +199,11 @@ async def test_lcn_entities_add_command( async def test_lcn_entities_delete_command( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry, lcn_connection + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry: MockConfigEntry ) -> None: """Test lcn/entities/delete command.""" + await init_integration(hass, entry) + client = await hass_ws_client(hass) assert ( @@ -239,12 +255,14 @@ async def test_lcn_entities_delete_command( async def test_lcn_command_host_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - lcn_connection, - payload, - entity_id, - result, + entry: MockConfigEntry, + payload: dict[str, str], + entity_id: str, + result: bool, ) -> None: """Test lcn commands for unknown host.""" + await init_integration(hass, entry) + client = await hass_ws_client(hass) await client.send_json_auto_id({**payload, "entry_id": entity_id}) @@ -265,13 +283,14 @@ async def test_lcn_command_host_error( async def test_lcn_command_address_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - entry, - lcn_connection, - payload, - address, - result, + entry: MockConfigEntry, + payload: dict[str, Any], + address: AddressType, + result: bool, ) -> None: """Test lcn commands for address error.""" + await init_integration(hass, entry) + client = await hass_ws_client(hass) await client.send_json_auto_id( {**payload, "entry_id": entry.entry_id, CONF_ADDRESS: address} @@ -285,10 +304,11 @@ async def test_lcn_command_address_error( async def test_lcn_entities_add_existing_error( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - entry, - lcn_connection, + entry: MockConfigEntry, ) -> None: """Test lcn commands for address error.""" + await init_integration(hass, entry) + client = await hass_ws_client(hass) await client.send_json_auto_id( { From 5108e1a1cd08b26bd1968d6edc16a0546f035895 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Sun, 8 Sep 2024 17:53:32 +1000 Subject: [PATCH 0372/1309] Bump aiolifx and aiolifx-themes to support more than 82 zones (#125487) Signed-off-by: Avi Miller --- homeassistant/components/lifx/manifest.json | 4 +- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- tests/components/lifx/__init__.py | 20 +++-- tests/components/lifx/test_diagnostics.py | 33 ++++++++ tests/components/lifx/test_light.py | 85 +++++++++++++-------- 6 files changed, 109 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 3ef70f16467..c7d8a27a1c7 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -48,8 +48,8 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.0.9", + "aiolifx==1.1.1", "aiolifx-effects==0.3.2", - "aiolifx-themes==0.5.0" + "aiolifx-themes==0.5.5" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 16b0cc537d7..adf4f7e064b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -273,10 +273,10 @@ aiokef==0.2.16 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.5.0 +aiolifx-themes==0.5.5 # homeassistant.components.lifx -aiolifx==1.0.9 +aiolifx==1.1.1 # homeassistant.components.livisi aiolivisi==0.0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 53e29289127..8d1e1d24b13 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -255,10 +255,10 @@ aiokafka==0.10.0 aiolifx-effects==0.3.2 # homeassistant.components.lifx -aiolifx-themes==0.5.0 +aiolifx-themes==0.5.5 # homeassistant.components.lifx -aiolifx==1.0.9 +aiolifx==1.1.1 # homeassistant.components.livisi aiolivisi==0.0.19 diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 432e7673db6..81b913da6ce 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -65,10 +65,13 @@ class MockLifxCommand: """Init command.""" self.bulb = bulb self.calls = [] - self.msg_kwargs = kwargs + self.msg_kwargs = { + k.removeprefix("msg_"): v for k, v in kwargs.items() if k.startswith("msg_") + } for k, v in kwargs.items(): - if k != "callb": - setattr(self.bulb, k, v) + if k.startswith("msg_") or k == "callb": + continue + setattr(self.bulb, k, v) def __call__(self, *args, **kwargs): """Call command.""" @@ -156,9 +159,16 @@ def _mocked_infrared_bulb() -> Light: def _mocked_light_strip() -> Light: bulb = _mocked_bulb() bulb.product = 31 # LIFX Z - bulb.color_zones = [MagicMock(), MagicMock()] + bulb.zones_count = 3 + bulb.color_zones = [MagicMock()] * 3 bulb.effect = {"effect": "MOVE", "speed": 3, "duration": 0, "direction": "RIGHT"} - bulb.get_color_zones = MockLifxCommand(bulb) + bulb.get_color_zones = MockLifxCommand( + bulb, + msg_seq_num=bulb.seq_next(), + msg_count=bulb.zones_count, + msg_index=0, + msg_color=bulb.color_zones, + ) bulb.set_color_zones = MockLifxCommand(bulb) bulb.get_multizone_effect = MockLifxCommand(bulb) bulb.set_multizone_effect = MockLifxCommand(bulb) diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py index e3588dd3ed1..22e335612f8 100644 --- a/tests/components/lifx/test_diagnostics.py +++ b/tests/components/lifx/test_diagnostics.py @@ -9,6 +9,7 @@ from . import ( DEFAULT_ENTRY_TITLE, IP_ADDRESS, SERIAL, + MockLifxCommand, _mocked_bulb, _mocked_clean_bulb, _mocked_infrared_bulb, @@ -188,6 +189,22 @@ async def test_legacy_multizone_bulb_diagnostics( ) config_entry.add_to_hass(hass) bulb = _mocked_light_strip() + bulb.get_color_zones = MockLifxCommand( + bulb, + msg_seq_num=0, + msg_count=8, + msg_color=[ + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ], + msg_index=0, + ) bulb.zones_count = 8 bulb.color_zones = [ (54612, 65535, 65535, 3500), @@ -302,6 +319,22 @@ async def test_multizone_bulb_diagnostics( config_entry.add_to_hass(hass) bulb = _mocked_light_strip() bulb.product = 38 + bulb.get_color_zones = MockLifxCommand( + bulb, + msg_seq_num=0, + msg_count=8, + msg_color=[ + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (54612, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + (46420, 65535, 65535, 3500), + ], + msg_index=0, + ) bulb.zones_count = 8 bulb.color_zones = [ (54612, 65535, 65535, 3500), diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index a642347b4e6..1ce7c69d7fa 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -192,15 +192,7 @@ async def test_light_strip(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 100}, blocking=True, ) - call_dict = bulb.set_color_zones.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "apply": 0, - "color": [], - "duration": 0, - "end_index": 0, - "start_index": 0, - } + assert len(bulb.set_color_zones.calls) == 0 bulb.set_color_zones.reset_mock() await hass.services.async_call( @@ -209,15 +201,7 @@ async def test_light_strip(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (10, 30)}, blocking=True, ) - call_dict = bulb.set_color_zones.calls[0][1] - call_dict.pop("callb") - assert call_dict == { - "apply": 0, - "color": [], - "duration": 0, - "end_index": 0, - "start_index": 0, - } + assert len(bulb.set_color_zones.calls) == 0 bulb.set_color_zones.reset_mock() bulb.color_zones = [ @@ -238,7 +222,7 @@ async def test_light_strip(hass: HomeAssistant) -> None: blocking=True, ) # Single color uses the fast path - assert bulb.set_color.calls[0][0][0] == [1820, 19660, 65535, 3500] + assert bulb.set_color.calls[1][0][0] == [1820, 19660, 65535, 3500] bulb.set_color.reset_mock() assert len(bulb.set_color_zones.calls) == 0 @@ -422,7 +406,9 @@ async def test_light_strip(hass: HomeAssistant) -> None: blocking=True, ) - bulb.get_color_zones = MockLifxCommand(bulb) + bulb.get_color_zones = MockLifxCommand( + bulb, msg_seq_num=0, msg_color=[0, 0, 65535, 3500] * 3, msg_index=0, msg_count=3 + ) bulb.get_color = MockFailingLifxCommand(bulb) with pytest.raises(HomeAssistantError): @@ -587,14 +573,14 @@ async def test_extended_multizone_messages(hass: HomeAssistant) -> None: bulb.set_extended_color_zones.reset_mock() bulb.color_zones = [ - (0, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (54612, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), - (46420, 65535, 65535, 3500), + [0, 65535, 65535, 3500], + [54612, 65535, 65535, 3500], + [54612, 65535, 65535, 3500], + [54612, 65535, 65535, 3500], + [46420, 65535, 65535, 3500], + [46420, 65535, 65535, 3500], + [46420, 65535, 65535, 3500], + [46420, 65535, 65535, 3500], ] await hass.services.async_call( @@ -1308,7 +1294,11 @@ async def test_config_zoned_light_strip_fails( def __call__(self, callb=None, *args, **kwargs): """Call command.""" self.call_count += 1 - response = None if self.call_count >= 2 else MockMessage() + response = ( + None + if self.call_count >= 2 + else MockMessage(seq_num=0, color=[], index=0, count=0) + ) if callb: callb(self.bulb, response) @@ -1349,7 +1339,15 @@ async def test_legacy_zoned_light_strip( self.call_count += 1 self.bulb.color_zones = [None] * 12 if callb: - callb(self.bulb, MockMessage()) + callb( + self.bulb, + MockMessage( + seq_num=0, + index=0, + count=self.bulb.zones_count, + color=self.bulb.color_zones, + ), + ) get_color_zones_mock = MockPopulateLifxZonesCommand(light_strip) light_strip.get_color_zones = get_color_zones_mock @@ -1946,6 +1944,33 @@ async def test_light_strip_zones_not_populated_yet(hass: HomeAssistant) -> None: bulb.power_level = 65535 bulb.color_zones = None bulb.color = [65535, 65535, 65535, 65535] + bulb.get_color_zones = next( + iter( + [ + MockLifxCommand( + bulb, + msg_seq_num=0, + msg_color=[0, 0, 65535, 3500] * 8, + msg_index=0, + msg_count=16, + ), + MockLifxCommand( + bulb, + msg_seq_num=1, + msg_color=[0, 0, 65535, 3500] * 8, + msg_index=0, + msg_count=16, + ), + MockLifxCommand( + bulb, + msg_seq_num=2, + msg_color=[0, 0, 65535, 3500] * 8, + msg_index=8, + msg_count=16, + ), + ] + ) + ) assert bulb.get_color_zones.calls == [] with ( From bfe19e82ff5ce024308e310f3803d9612c01fd45 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Sun, 8 Sep 2024 10:41:54 +0200 Subject: [PATCH 0373/1309] Add tests for BSBLAN climate component (#124524) * chore: Add tests for BSBLAN climate component * fix return types * fix MAC data * chore: Update BSBLAN climate component tests used setup from conftest added setup for farhenheit temp unit * chore: Update BSBLAN climate component tests use syrupy to compare results * add test for temp_unit * update climate tests set current_temperature to None in test case. Is this the correct way for testing? * chore: Update BSBLAN diagnostics to handle asynchronous data retrieval * chore: Refactor BSBLAN conftest.py to simplify fixture and patching * chore: Update BSBLAN climate component tests 100% test coverage * chore: Update BSBLAN diagnostics to handle asynchronous data retrieval * chore: Update snapshots * Fix BSBLAN climate test for async_set_preset_mode - Update test_async_set_preset_mode to correctly handle ServiceValidationError - Check for specific translation key instead of full error message - Ensure consistency between local tests and CI environment - Import ServiceValidationError explicitly for clarity * Update homeassistant/components/bsblan/entity.py Co-authored-by: Joost Lekkerkerker * chore: Update BSBLAN conftest.py to simplify fixture and patching * chore: Update BSBLAN integration setup function parameter name * chore: removed set_static_value * refactor: Improve BSBLANClimate async_set_preset_mode method This commit refactors the async_set_preset_mode method in the BSBLANClimate class to improve code readability and maintainability. The method now checks if the HVAC mode is not set to AUTO and the preset mode is not NONE before raising a ServiceValidationError. Co-authored-by: Joost Lekkerkerker * refactor: Improve tests test_celsius_fahrenheit test_climate_entity_properties test_async_set_hvac_mode test_async_set_preset_mode still broken. Not sure why hvac mode will not set. THis causes error with preset mode set * update snapshot * fix DOMAIN bsblan * refactor: Improve BSBLANClimate async_set_data method * refactor: fix last tests * refactor: Simplify async_get_config_entry_diagnostics method * refactor: Improve BSBLANClimate async_set_temperature method This commit improves the async_set_temperature method in the BSBLANClimate class. It removes the unnecessary parameter "expected_result" and simplifies the code by directly calling the service to set the temperature. The method now correctly asserts that the thermostat method is called with the correct temperature. * refactor: Add static data to async_get_config_entry_diagnostics * refactor: Add static data to async_get_config_entry_diagnostics right place * refactor: Improve error message for setting preset mode This commit updates the error message in the BSBLANClimate class when trying to set the preset mode. * refactor: Improve tests * Fix --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/bsblan/climate.py | 17 +- tests/components/bsblan/__init__.py | 17 + tests/components/bsblan/conftest.py | 3 +- .../components/bsblan/fixtures/static_F.json | 20 ++ .../bsblan/snapshots/test_climate.ambr | 220 +++++++++++++ tests/components/bsblan/test_climate.py | 307 ++++++++++++++++++ tests/components/bsblan/test_diagnostics.py | 4 + 7 files changed, 577 insertions(+), 11 deletions(-) create mode 100644 tests/components/bsblan/fixtures/static_F.json create mode 100644 tests/components/bsblan/snapshots/test_climate.ambr create mode 100644 tests/components/bsblan/test_climate.py diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index ae7116143df..3a204a9e0c2 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -126,15 +126,14 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - # only allow preset mode when hvac mode is auto - if self.hvac_mode == HVACMode.AUTO: - await self.async_set_data(preset_mode=preset_mode) - else: + if self.hvac_mode != HVACMode.AUTO and preset_mode != PRESET_NONE: raise ServiceValidationError( + "Preset mode can only be set when HVAC mode is set to 'auto'", translation_domain=DOMAIN, translation_key="set_preset_mode_error", translation_placeholders={"preset_mode": preset_mode}, ) + await self.async_set_data(preset_mode=preset_mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" @@ -148,11 +147,11 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity): if ATTR_HVAC_MODE in kwargs: data[ATTR_HVAC_MODE] = kwargs[ATTR_HVAC_MODE] if ATTR_PRESET_MODE in kwargs: - # If preset mode is None, set hvac to auto - if kwargs[ATTR_PRESET_MODE] == PRESET_NONE: - data[ATTR_HVAC_MODE] = HVACMode.AUTO - else: - data[ATTR_HVAC_MODE] = kwargs[ATTR_PRESET_MODE] + if kwargs[ATTR_PRESET_MODE] == PRESET_ECO: + data[ATTR_HVAC_MODE] = PRESET_ECO + elif kwargs[ATTR_PRESET_MODE] == PRESET_NONE: + data[ATTR_HVAC_MODE] = PRESET_NONE + try: await self.coordinator.client.thermostat(**data) except BSBLANError as err: diff --git a/tests/components/bsblan/__init__.py b/tests/components/bsblan/__init__.py index d233fa068ea..3892fcaaaca 100644 --- a/tests/components/bsblan/__init__.py +++ b/tests/components/bsblan/__init__.py @@ -1 +1,18 @@ """Tests for the bsblan integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the BSBLAN integration with the selected platforms.""" + config_entry.add_to_hass(hass) + with patch("homeassistant.components.bsblan.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index 13d4017d7c8..96445a4bb23 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -40,7 +40,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_bsblan() -> Generator[MagicMock]: +def mock_bsblan() -> Generator[MagicMock, None, None]: """Return a mocked BSBLAN client.""" with ( patch("homeassistant.components.bsblan.BSBLAN", autospec=True) as bsblan_mock, @@ -52,7 +52,6 @@ def mock_bsblan() -> Generator[MagicMock]: load_fixture("device.json", DOMAIN) ) bsblan.state.return_value = State.from_json(load_fixture("state.json", DOMAIN)) - bsblan.static_values.return_value = StaticState.from_json( load_fixture("static.json", DOMAIN) ) diff --git a/tests/components/bsblan/fixtures/static_F.json b/tests/components/bsblan/fixtures/static_F.json new file mode 100644 index 00000000000..a61e870f6e5 --- /dev/null +++ b/tests/components/bsblan/fixtures/static_F.json @@ -0,0 +1,20 @@ +{ + "min_temp": { + "name": "Room temp frost protection setpoint", + "error": 0, + "value": "8.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°F" + }, + "max_temp": { + "name": "Summer/winter changeover temp heat circuit 1", + "error": 0, + "value": "20.0", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°F" + } +} diff --git a/tests/components/bsblan/snapshots/test_climate.ambr b/tests/components/bsblan/snapshots/test_climate.ambr new file mode 100644 index 00000000000..4eb70fe2658 --- /dev/null +++ b/tests/components/bsblan/snapshots/test_climate.ambr @@ -0,0 +1,220 @@ +# serializer version: 1 +# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 20.0, + 'min_temp': 8.0, + 'preset_modes': list([ + 'eco', + 'none', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bsb_lan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bsblan', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:80:41:19:69:90-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_celsius_fahrenheit[static.json][climate.bsb_lan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 18.6, + 'friendly_name': 'BSB-LAN', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 20.0, + 'min_temp': 8.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'eco', + 'none', + ]), + 'supported_features': , + 'temperature': 18.5, + }), + 'context': , + 'entity_id': 'climate.bsb_lan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_celsius_fahrenheit[static_F.json][climate.bsb_lan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': -6.7, + 'min_temp': -13.3, + 'preset_modes': list([ + 'eco', + 'none', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bsb_lan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bsblan', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:80:41:19:69:90-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_celsius_fahrenheit[static_F.json][climate.bsb_lan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': -7.4, + 'friendly_name': 'BSB-LAN', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': -6.7, + 'min_temp': -13.3, + 'preset_mode': 'none', + 'preset_modes': list([ + 'eco', + 'none', + ]), + 'supported_features': , + 'temperature': -7.5, + }), + 'context': , + 'entity_id': 'climate.bsb_lan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate_entity_properties[climate.bsb_lan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 20.0, + 'min_temp': 8.0, + 'preset_modes': list([ + 'eco', + 'none', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.bsb_lan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'bsblan', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:80:41:19:69:90-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entity_properties[climate.bsb_lan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 18.6, + 'friendly_name': 'BSB-LAN', + 'hvac_modes': list([ + , + , + , + ]), + 'max_temp': 20.0, + 'min_temp': 8.0, + 'preset_mode': 'none', + 'preset_modes': list([ + 'eco', + 'none', + ]), + 'supported_features': , + 'temperature': 18.5, + }), + 'context': , + 'entity_id': 'climate.bsb_lan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- diff --git a/tests/components/bsblan/test_climate.py b/tests/components/bsblan/test_climate.py new file mode 100644 index 00000000000..c519c3043da --- /dev/null +++ b/tests/components/bsblan/test_climate.py @@ -0,0 +1,307 @@ +"""Tests for the BSB-Lan climate platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from bsblan import BSBLANError, StaticState +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.bsblan.const import DOMAIN +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_PRESET_MODE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_ECO, + PRESET_NONE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_object_fixture, + snapshot_platform, +) + +ENTITY_ID = "climate.bsb_lan" + + +@pytest.mark.parametrize( + ("static_file"), + [ + ("static.json"), + ("static_F.json"), + ], +) +async def test_celsius_fahrenheit( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + static_file: str, +) -> None: + """Test Celsius and Fahrenheit temperature units.""" + + static_data = load_json_object_fixture(static_file, DOMAIN) + + mock_bsblan.static_values.return_value = StaticState.from_dict(static_data) + + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_climate_entity_properties( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the climate entity properties.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Test when current_temperature is "---" + mock_current_temp = MagicMock() + mock_current_temp.value = "---" + mock_bsblan.state.return_value.current_temperature = mock_current_temp + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes["current_temperature"] is None + + # Test target_temperature + mock_target_temp = MagicMock() + mock_target_temp.value = "23.5" + mock_bsblan.state.return_value.target_temperature = mock_target_temp + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes["temperature"] == 23.5 + + # Test hvac_mode + mock_hvac_mode = MagicMock() + mock_hvac_mode.value = HVACMode.AUTO + mock_bsblan.state.return_value.hvac_mode = mock_hvac_mode + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.AUTO + + # Test preset_mode + mock_hvac_mode.value = PRESET_ECO + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes["preset_mode"] == PRESET_ECO + + +@pytest.mark.parametrize( + "mode", + [HVACMode.HEAT, HVACMode.AUTO, HVACMode.OFF], +) +async def test_async_set_hvac_mode( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + mode: HVACMode, +) -> None: + """Test setting HVAC mode via service call.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Call the service to set HVAC mode + await hass.services.async_call( + domain=CLIMATE_DOMAIN, + service=SERVICE_SET_HVAC_MODE, + service_data={ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: mode}, + blocking=True, + ) + + # Assert that the thermostat method was called + mock_bsblan.thermostat.assert_called_once_with(hvac_mode=mode) + mock_bsblan.thermostat.reset_mock() + + +@pytest.mark.parametrize( + ("hvac_mode", "preset_mode"), + [ + (HVACMode.AUTO, PRESET_ECO), + (HVACMode.AUTO, PRESET_NONE), + ], +) +async def test_async_set_preset_mode_succes( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + preset_mode: str, +) -> None: + """Test setting preset mode via service call.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # patch hvac_mode + mock_hvac_mode = MagicMock() + mock_hvac_mode.value = hvac_mode + mock_bsblan.state.return_value.hvac_mode = mock_hvac_mode + + # Attempt to set the preset mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("hvac_mode", "preset_mode"), + [ + ( + HVACMode.HEAT, + PRESET_ECO, + ) + ], +) +async def test_async_set_preset_mode_error( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + hvac_mode: HVACMode, + preset_mode: str, +) -> None: + """Test setting preset mode via service call.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # patch hvac_mode + mock_hvac_mode = MagicMock() + mock_hvac_mode.value = hvac_mode + mock_bsblan.state.return_value.hvac_mode = mock_hvac_mode + + # Attempt to set the preset mode + error_message = "Preset mode can only be set when HVAC mode is set to 'auto'" + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset_mode}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("target_temp"), + [ + (8.0), # Min temperature + (15.0), # Mid-range temperature + (20.0), # Max temperature + ], +) +async def test_async_set_temperature( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + target_temp: float, +) -> None: + """Test setting temperature via service call.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + await hass.services.async_call( + domain=CLIMATE_DOMAIN, + service=SERVICE_SET_TEMPERATURE, + service_data={ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: target_temp}, + blocking=True, + ) + # Assert that the thermostat method was called with the correct temperature + mock_bsblan.thermostat.assert_called_once_with(target_temperature=target_temp) + + +async def test_async_set_data( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting data via service calls.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + # Test setting temperature + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 19}, + blocking=True, + ) + mock_bsblan.thermostat.assert_called_once_with(target_temperature=19) + mock_bsblan.thermostat.reset_mock() + + # Test setting HVAC mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + mock_bsblan.thermostat.assert_called_once_with(hvac_mode=HVACMode.HEAT) + mock_bsblan.thermostat.reset_mock() + + # Patch HVAC mode to AUTO + mock_hvac_mode = MagicMock() + mock_hvac_mode.value = HVACMode.AUTO + mock_bsblan.state.return_value.hvac_mode = mock_hvac_mode + + # Test setting preset mode to ECO + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, + blocking=True, + ) + mock_bsblan.thermostat.assert_called_once_with(hvac_mode=PRESET_ECO) + mock_bsblan.thermostat.reset_mock() + + # Test setting preset mode to NONE + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + mock_bsblan.thermostat.assert_called_once() + mock_bsblan.thermostat.reset_mock() + + # Test error handling + mock_bsblan.thermostat.side_effect = BSBLANError("Test error") + error_message = "An error occurred while updating the BSBLAN device" + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 20}, + blocking=True, + ) diff --git a/tests/components/bsblan/test_diagnostics.py b/tests/components/bsblan/test_diagnostics.py index 8939456c2ac..aea53f8a1a2 100644 --- a/tests/components/bsblan/test_diagnostics.py +++ b/tests/components/bsblan/test_diagnostics.py @@ -1,5 +1,7 @@ """Tests for the diagnostics data provided by the BSBLan integration.""" +from unittest.mock import AsyncMock + from syrupy import SnapshotAssertion from homeassistant.core import HomeAssistant @@ -11,11 +13,13 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( hass: HomeAssistant, + mock_bsblan: AsyncMock, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" + diagnostics_data = await get_diagnostics_for_config_entry( hass, hass_client, init_integration ) From 3fa24f87c0573b486aa75aace82de99631d2a287 Mon Sep 17 00:00:00 2001 From: Alan Murray Date: Sun, 8 Sep 2024 18:42:54 +1000 Subject: [PATCH 0374/1309] Change of acmeda element unique_id (#124963) * Update base.py Change unique_id to be explicitly a string. * Update __init__.py Add unique id migration * unique_id migration unit tests * Update __init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update __init__.py Fixed ruff formatting issue * Update __init__.py * Update __init__.py * In tests, load entity registries as test fixtures * Fix * Fix --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> Co-authored-by: Joostlek --- homeassistant/components/acmeda/__init__.py | 17 +++++++++ homeassistant/components/acmeda/base.py | 2 +- tests/components/acmeda/conftest.py | 20 +++++++++++ tests/components/acmeda/test_cover.py | 28 +++++++++++++++ tests/components/acmeda/test_sensor.py | 39 +++++++++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/components/acmeda/conftest.py create mode 100644 tests/components/acmeda/test_cover.py create mode 100644 tests/components/acmeda/test_sensor.py diff --git a/homeassistant/components/acmeda/__init__.py b/homeassistant/components/acmeda/__init__.py index d6491767dcc..62a62795a05 100644 --- a/homeassistant/components/acmeda/__init__.py +++ b/homeassistant/components/acmeda/__init__.py @@ -3,6 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er from .hub import PulseHub @@ -17,6 +18,9 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: AcmedaConfigEntry ) -> bool: """Set up Rollease Acmeda Automate hub from a config entry.""" + + await _migrate_unique_ids(hass, config_entry) + hub = PulseHub(hass, config_entry) if not await hub.async_setup(): @@ -28,6 +32,19 @@ async def async_setup_entry( return True +async def _migrate_unique_ids(hass: HomeAssistant, entry: AcmedaConfigEntry) -> None: + """Migrate pre-config flow unique ids.""" + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + for reg_entry in registry_entries: + if isinstance(reg_entry.unique_id, int): # type: ignore[unreachable] + entity_registry.async_update_entity( # type: ignore[unreachable] + reg_entry.entity_id, new_unique_id=str(reg_entry.unique_id) + ) + + async def async_unload_entry( hass: HomeAssistant, config_entry: AcmedaConfigEntry ) -> bool: diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/base.py index 7596374684d..149fceaa2df 100644 --- a/homeassistant/components/acmeda/base.py +++ b/homeassistant/components/acmeda/base.py @@ -67,7 +67,7 @@ class AcmedaBase(entity.Entity): @property def unique_id(self) -> str: """Return the unique ID of this roller.""" - return self.roller.id # type: ignore[no-any-return] + return str(self.roller.id) @property def device_id(self) -> str: diff --git a/tests/components/acmeda/conftest.py b/tests/components/acmeda/conftest.py new file mode 100644 index 00000000000..2c980351c09 --- /dev/null +++ b/tests/components/acmeda/conftest.py @@ -0,0 +1,20 @@ +"""Define fixtures available for all Acmeda tests.""" + +import pytest + +from homeassistant.components.acmeda.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1"}, + ) + mock_config_entry.add_to_hass(hass) + return mock_config_entry diff --git a/tests/components/acmeda/test_cover.py b/tests/components/acmeda/test_cover.py new file mode 100644 index 00000000000..0d908ecc915 --- /dev/null +++ b/tests/components/acmeda/test_cover.py @@ -0,0 +1,28 @@ +"""Define tests for the Acmeda config flow.""" + +from homeassistant.components.acmeda.const import DOMAIN +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_cover_id_migration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migrating unique id.""" + mock_config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + COVER_DOMAIN, DOMAIN, 1234567890123, config_entry=mock_config_entry + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 1 + assert entities[0].unique_id == "1234567890123" diff --git a/tests/components/acmeda/test_sensor.py b/tests/components/acmeda/test_sensor.py new file mode 100644 index 00000000000..bf7c16dda46 --- /dev/null +++ b/tests/components/acmeda/test_sensor.py @@ -0,0 +1,39 @@ +"""Define tests for the Acmeda config flow.""" + +import pytest + +from homeassistant.components.acmeda.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"host": "127.0.0.1"}, + ) + mock_config_entry.add_to_hass(hass) + return mock_config_entry + + +async def test_sensor_id_migration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test migrating unique id.""" + mock_config_entry.add_to_hass(hass) + entity_registry = er.async_get(hass) + entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, 1234567890123, config_entry=mock_config_entry + ) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 1 + assert entities[0].unique_id == "1234567890123" From 2ea41c90b588c2193a7e001041297b702c6225bf Mon Sep 17 00:00:00 2001 From: TimL Date: Sun, 8 Sep 2024 19:00:10 +1000 Subject: [PATCH 0375/1309] Bump pymslight to 0.0.15 (#125455) Bump pymslight 0.0.15 for Smlight integration --- 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 1a91b29234c..6c0a2c39025 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_polling", - "requirements": ["pysmlight==0.0.14"], + "requirements": ["pysmlight==0.0.15"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index adf4f7e064b..9b6814c5c21 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2232,7 +2232,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.0.14 +pysmlight==0.0.15 # homeassistant.components.snmp pysnmp==6.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d1e1d24b13..2c8471b768a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1786,7 +1786,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.0.14 +pysmlight==0.0.15 # homeassistant.components.snmp pysnmp==6.2.5 From 1f80b803f7ac28afb32514c3cb14ca5d93d87cbd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 8 Sep 2024 11:03:18 +0200 Subject: [PATCH 0376/1309] Fix after review comments for Acmeda (#125501) Fix --- tests/components/acmeda/test_sensor.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/tests/components/acmeda/test_sensor.py b/tests/components/acmeda/test_sensor.py index bf7c16dda46..3d7090ce7dd 100644 --- a/tests/components/acmeda/test_sensor.py +++ b/tests/components/acmeda/test_sensor.py @@ -1,7 +1,5 @@ """Define tests for the Acmeda config flow.""" -import pytest - from homeassistant.components.acmeda.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -10,23 +8,13 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry -@pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: - """Return the default mocked config entry.""" - mock_config_entry = MockConfigEntry( - domain=DOMAIN, - data={"host": "127.0.0.1"}, - ) - mock_config_entry.add_to_hass(hass) - return mock_config_entry - - async def test_sensor_id_migration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, ) -> None: """Test migrating unique id.""" mock_config_entry.add_to_hass(hass) - entity_registry = er.async_get(hass) entity_registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, 1234567890123, config_entry=mock_config_entry ) From d3badb88ef291881b5ff51e5d746a6db35dd2910 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:43:50 +0200 Subject: [PATCH 0377/1309] Fix solarlog test RuntimeWarning (#125504) --- tests/components/solarlog/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/solarlog/conftest.py b/tests/components/solarlog/conftest.py index b363f655c57..1b315fa3e8c 100644 --- a/tests/components/solarlog/conftest.py +++ b/tests/components/solarlog/conftest.py @@ -1,7 +1,7 @@ """Test helpers.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from solarlog_cli.solarlog_models import InverterData, SolarlogData @@ -53,6 +53,7 @@ def mock_solarlog_connector(): data.inverter_data = INVERTER_DATA mock_solarlog_api = AsyncMock() + mock_solarlog_api.set_enabled_devices = MagicMock() mock_solarlog_api.test_connection.return_value = True mock_solarlog_api.update_data.return_value = data mock_solarlog_api.update_device_list.return_value = INVERTER_DATA From 2ef1c9632532c25a924b7a9e56cdb04e733e19a9 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:56:23 +0200 Subject: [PATCH 0378/1309] Include all enphase_envoy devices in async_remove_config_entry_device (#124533) * Include all enphase_envoy devices in async_remove_config_entry_device * refactor if tests --- homeassistant/components/enphase_envoy/__init__.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index f6438230789..ba590fa0337 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -60,8 +60,16 @@ async def async_remove_config_entry_device( envoy_serial_num = config_entry.unique_id if envoy_serial_num in dev_ids: return False - if envoy_data and envoy_data.inverters: - for inverter in envoy_data.inverters: - if str(inverter) in dev_ids: + if envoy_data: + if envoy_data.inverters: + for inverter in envoy_data.inverters: + if str(inverter) in dev_ids: + return False + if envoy_data.encharge_inventory: + for encharge in envoy_data.encharge_inventory: + if str(encharge) in dev_ids: + return False + if envoy_data.enpower: + if str(envoy_data.enpower.serial_number) in dev_ids: return False return True From cee695da28f38e87b223dd9190f84f6fc677d0a6 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 8 Sep 2024 12:00:03 +0200 Subject: [PATCH 0379/1309] Add missing previous and next commands in LinkPlay (#125450) Previous / Next commands --- homeassistant/components/linkplay/media_player.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 20b0f63f6a3..af18b018403 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -214,6 +214,16 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): """Send play command.""" await self._bridge.player.resume() + @exception_wrap + async def async_media_next_track(self) -> None: + """Send next command.""" + await self._bridge.player.next() + + @exception_wrap + async def async_media_previous_track(self) -> None: + """Send previous command.""" + await self._bridge.player.previous() + @exception_wrap async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" From f5b754a38285907a8ce88fc818d9ed89423c8eee Mon Sep 17 00:00:00 2001 From: Nerdix <70015952+N3rdix@users.noreply.github.com> Date: Sun, 8 Sep 2024 12:03:14 +0200 Subject: [PATCH 0380/1309] Reorder openweathermap modes according to recommendation in documentation (#125395) Reorder modes and default to new API version 3 --- homeassistant/components/openweathermap/const.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index d34125a2405..81a6544c7ce 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -63,12 +63,12 @@ OWM_MODE_FREE_FORECAST = "forecast" OWM_MODE_V30 = "v3.0" OWM_MODE_V25 = "v2.5" OWM_MODES = [ - OWM_MODE_FREE_CURRENT, - OWM_MODE_FREE_FORECAST, OWM_MODE_V30, OWM_MODE_V25, + OWM_MODE_FREE_CURRENT, + OWM_MODE_FREE_FORECAST, ] -DEFAULT_OWM_MODE = OWM_MODE_FREE_CURRENT +DEFAULT_OWM_MODE = OWM_MODE_V30 LANGUAGES = [ "af", From c0492d4af4c22df4987af1ea140f5d36eb4441d2 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Sun, 8 Sep 2024 12:04:35 +0200 Subject: [PATCH 0381/1309] Add reconfigure for lamarzocco (#122160) * add reconfigure * fix strings, add to label * Update homeassistant/components/lamarzocco/config_flow.py Co-authored-by: G Johansson * Update test_config_flow.py Co-authored-by: Joost Lekkerkerker * ruff --------- Co-authored-by: G Johansson Co-authored-by: Joost Lekkerkerker --- .../components/lamarzocco/config_flow.py | 99 +++++++++++++++++-- .../components/lamarzocco/strings.json | 17 ++++ .../components/lamarzocco/test_config_flow.py | 71 ++++++++++++- 3 files changed, 177 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index b4fed615733..5a5cad00f64 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -10,7 +10,10 @@ from lmcloud.exceptions import AuthFail, RequestNotSuccessful from lmcloud.models import LaMarzoccoDeviceInfo import voluptuous as vol -from homeassistant.components.bluetooth import BluetoothServiceInfo +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -53,6 +56,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self.reauth_entry: ConfigEntry | None = None + self.reconfigure_entry: ConfigEntry | None = None self._config: dict[str, Any] = {} self._fleet: dict[str, LaMarzoccoDeviceInfo] = {} self._discovered: dict[str, str] = {} @@ -92,13 +96,9 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: if self.reauth_entry: - self.hass.config_entries.async_update_entry( - self.reauth_entry, data=data + return self.async_update_reload_and_abort( + self.reauth_entry, data=data, reason="reauth_successful" ) - await self.hass.config_entries.async_reload( - self.reauth_entry.entry_id - ) - return self.async_abort(reason="reauth_successful") if self._discovered: if self._discovered[CONF_MACHINE] not in self._fleet: errors["base"] = "machine_not_found" @@ -134,8 +134,9 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: if not self._discovered: serial_number = user_input[CONF_MACHINE] - await self.async_set_unique_id(serial_number) - self._abort_if_unique_id_configured() + if self.reconfigure_entry is None: + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() else: serial_number = self._discovered[CONF_MACHINE] @@ -153,6 +154,13 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): self._config[CONF_HOST] = user_input[CONF_HOST] if not errors: + if self.reconfigure_entry: + for service_info in async_discovered_service_info(self.hass): + self._discovered[service_info.name] = service_info.address + + if self._discovered: + return await self.async_step_bluetooth_selection() + return self.async_create_entry( title=selected_device.name, data={ @@ -191,6 +199,45 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_bluetooth_selection( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle Bluetooth device selection.""" + + assert self.reconfigure_entry + + if user_input is not None: + return self.async_update_reload_and_abort( + self.reconfigure_entry, + data={ + **self._config, + CONF_MAC: user_input[CONF_MAC], + }, + reason="reconfigure_successful", + ) + + bt_options = [ + SelectOptionDict( + value=device_mac, + label=f"{device_name} ({device_mac})", + ) + for device_name, device_mac in self._discovered.items() + ] + + return self.async_show_form( + step_id="bluetooth_selection", + data_schema=vol.Schema( + { + vol.Required(CONF_MAC): SelectSelector( + SelectSelectorConfig( + options=bt_options, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + }, + ), + ) + async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo ) -> ConfigFlowResult: @@ -240,6 +287,40 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user(user_input) + async def async_step_reconfigure( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reconfiguration of the config entry.""" + self.reconfigure_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: + """Confirm reconfiguration of the device.""" + assert self.reconfigure_entry + + if not user_input: + return self.async_show_form( + step_id="reconfigure_confirm", + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, + default=self.reconfigure_entry.data[CONF_USERNAME], + ): str, + vol.Required( + CONF_PASSWORD, + default=self.reconfigure_entry.data[CONF_PASSWORD], + ): str, + } + ), + ) + + return await self.async_step_user(user_input) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json index 08e3e764379..39cc24388ab 100644 --- a/homeassistant/components/lamarzocco/strings.json +++ b/homeassistant/components/lamarzocco/strings.json @@ -3,6 +3,7 @@ "flow_title": "La Marzocco Espresso {host}", "abort": { "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" }, "error": { @@ -21,6 +22,12 @@ "password": "Your password from the La Marzocco app" } }, + "bluetooth_selection": { + "description": "Select your device from available Bluetooth devices.", + "data": { + "mac": "Bluetooth device" + } + }, "machine_selection": { "description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.", "data": { @@ -39,6 +46,16 @@ "data_description": { "password": "[%key:component::lamarzocco::config::step::user::data_description::password%]" } + }, + "reconfigure_confirm": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::lamarzocco::config::step::user::data_description::username%]", + "password": "[%key:component::lamarzocco::config::step::user::data_description::password%]" + } } } }, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 39896926c61..4bb26fb5d30 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -7,7 +7,12 @@ from lmcloud.models import LaMarzoccoDeviceInfo from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE from homeassistant.components.lamarzocco.const import CONF_USE_BLUETOOTH, DOMAIN -from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER, ConfigEntryState +from homeassistant.config_entries import ( + SOURCE_BLUETOOTH, + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigEntryState, +) from homeassistant.const import ( CONF_HOST, CONF_MAC, @@ -259,6 +264,70 @@ async def test_reauth_flow( assert mock_config_entry.data[CONF_PASSWORD] == "new_password" +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_cloud_client: MagicMock, + mock_config_entry: MockConfigEntry, + mock_device_info: LaMarzoccoDeviceInfo, +) -> None: + """Testing reconfgure flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_RECONFIGURE, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_confirm" + + result2 = await __do_successful_user_step(hass, result, mock_cloud_client) + service_info = get_bluetooth_service_info( + mock_device_info.model, mock_device_info.serial_number + ) + + with ( + patch( + "homeassistant.components.lamarzocco.config_flow.LaMarzoccoLocalClient.validate_connection", + return_value=True, + ), + patch( + "homeassistant.components.lamarzocco.config_flow.async_discovered_service_info", + return_value=[service_info], + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_MACHINE: mock_device_info.serial_number, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] is FlowResultType.FORM + assert result3["step_id"] == "bluetooth_selection" + + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + {CONF_MAC: service_info.address}, + ) + + assert result4["type"] is FlowResultType.ABORT + assert result4["reason"] == "reconfigure_successful" + + assert mock_config_entry.title == "My LaMarzocco" + assert mock_config_entry.data == { + **mock_config_entry.data, + CONF_MAC: service_info.address, + } + + async def test_bluetooth_discovery( hass: HomeAssistant, mock_lamarzocco: MagicMock, From 74b78307eef58eec6efee6251014149acb05d003 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Sun, 8 Sep 2024 12:15:00 +0200 Subject: [PATCH 0382/1309] Add balanced grid import/export to enphase_envoy (#123154) * Add balanced grid import/export to enphase_envoy * rebuild sensor snapshot after dev merge * Cleanup snapshot file --- .../components/enphase_envoy/sensor.py | 91 ++ .../components/enphase_envoy/strings.json | 12 + tests/components/enphase_envoy/conftest.py | 8 + .../enphase_envoy/fixtures/envoy.json | 2 + .../fixtures/envoy_1p_metered.json | 7 + .../fixtures/envoy_metered_batt_relay.json | 26 + .../fixtures/envoy_nobatt_metered_3p.json | 26 + .../fixtures/envoy_tot_cons_metered.json | 7 + .../enphase_envoy/snapshots/test_sensor.ambr | 1160 +++++++++++++++++ tests/components/enphase_envoy/test_sensor.py | 84 ++ 10 files changed, 1423 insertions(+) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 4dd7f158305..20d610e4b71 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -228,6 +228,50 @@ CONSUMPTION_PHASE_SENSORS = { } +NET_CONSUMPTION_SENSORS = ( + EnvoyConsumptionSensorEntityDescription( + key="balanced_net_consumption", + translation_key="balanced_net_consumption", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + suggested_unit_of_measurement=UnitOfPower.KILO_WATT, + suggested_display_precision=3, + value_fn=attrgetter("watts_now"), + on_phase=None, + ), + EnvoyConsumptionSensorEntityDescription( + key="lifetime_balanced_net_consumption", + translation_key="lifetime_balanced_net_consumption", + entity_registry_enabled_default=False, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=3, + value_fn=attrgetter("watt_hours_lifetime"), + on_phase=None, + ), +) + + +NET_CONSUMPTION_PHASE_SENSORS = { + (on_phase := PHASENAMES[phase]): [ + replace( + sensor, + key=f"{sensor.key}_l{phase + 1}", + translation_key=f"{sensor.translation_key}_phase", + entity_registry_enabled_default=False, + on_phase=on_phase, + translation_placeholders={"phase_name": f"l{phase + 1}"}, + ) + for sensor in list(NET_CONSUMPTION_SENSORS) + ] + for phase in range(3) +} + + @dataclass(frozen=True, kw_only=True) class EnvoyCTSensorEntityDescription(SensorEntityDescription): """Describes an Envoy CT sensor entity.""" @@ -697,6 +741,11 @@ async def async_setup_entry( EnvoyConsumptionEntity(coordinator, description) for description in CONSUMPTION_SENSORS ) + if envoy_data.system_net_consumption: + entities.extend( + EnvoyNetConsumptionEntity(coordinator, description) + for description in NET_CONSUMPTION_SENSORS + ) # For each production phase reported add production entities if envoy_data.system_production_phases: entities.extend( @@ -713,6 +762,14 @@ async def async_setup_entry( for description in CONSUMPTION_PHASE_SENSORS[use_phase] if phase is not None ) + # For each net_consumption phase reported add consumption entities + if envoy_data.system_net_consumption_phases: + entities.extend( + EnvoyNetConsumptionPhaseEntity(coordinator, description) + for use_phase, phase in envoy_data.system_net_consumption_phases.items() + for description in NET_CONSUMPTION_PHASE_SENSORS[use_phase] + if phase is not None + ) # Add net consumption CT entities if ctmeter := envoy_data.ctmeter_consumption: entities.extend( @@ -846,6 +903,19 @@ class EnvoyConsumptionEntity(EnvoySystemSensorEntity): return self.entity_description.value_fn(system_consumption) +class EnvoyNetConsumptionEntity(EnvoySystemSensorEntity): + """Envoy consumption entity.""" + + entity_description: EnvoyConsumptionSensorEntityDescription + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + system_net_consumption = self.data.system_net_consumption + assert system_net_consumption is not None + return self.entity_description.value_fn(system_net_consumption) + + class EnvoyProductionPhaseEntity(EnvoySystemSensorEntity): """Envoy phase production entity.""" @@ -888,6 +958,27 @@ class EnvoyConsumptionPhaseEntity(EnvoySystemSensorEntity): return self.entity_description.value_fn(system_consumption) +class EnvoyNetConsumptionPhaseEntity(EnvoySystemSensorEntity): + """Envoy phase consumption entity.""" + + entity_description: EnvoyConsumptionSensorEntityDescription + + @property + def native_value(self) -> int | None: + """Return the state of the sensor.""" + if TYPE_CHECKING: + assert self.entity_description.on_phase + assert self.data.system_net_consumption_phases + + if ( + system_net_consumption := self.data.system_net_consumption_phases[ + self.entity_description.on_phase + ] + ) is None: + return None + return self.entity_description.value_fn(system_net_consumption) + + class EnvoyConsumptionCTEntity(EnvoySystemSensorEntity): """Envoy net consumption CT entity.""" diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 3c48776e448..2e7ce831efc 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -165,6 +165,18 @@ "lifetime_consumption_phase": { "name": "Lifetime energy consumption {phase_name}" }, + "balanced_net_consumption": { + "name": "balanced net power consumption" + }, + "lifetime_balanced_net_consumption": { + "name": "Lifetime balanced net energy consumption" + }, + "balanced_net_consumption_phase": { + "name": "balanced net power consumption {phase_name}" + }, + "lifetime_balanced_net_consumption_phase": { + "name": "Lifetime balanced net energy consumption {phase_name}" + }, "lifetime_net_consumption": { "name": "Lifetime net energy consumption" }, diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index 58627211344..541b6f96e19 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -150,6 +150,8 @@ def _load_json_2_production_data( """Fill envoy production data from fixture.""" if item := json_fixture["data"].get("system_consumption"): mocked_data.system_consumption = EnvoySystemConsumption(**item) + if item := json_fixture["data"].get("system_net_consumption"): + mocked_data.system_net_consumption = EnvoySystemConsumption(**item) if item := json_fixture["data"].get("system_production"): mocked_data.system_production = EnvoySystemProduction(**item) if item := json_fixture["data"].get("system_consumption_phases"): @@ -158,6 +160,12 @@ def _load_json_2_production_data( mocked_data.system_consumption_phases[sub_item] = EnvoySystemConsumption( **item_data ) + if item := json_fixture["data"].get("system_net_consumption_phases"): + mocked_data.system_net_consumption_phases = {} + for sub_item, item_data in item.items(): + mocked_data.system_net_consumption_phases[sub_item] = ( + EnvoySystemConsumption(**item_data) + ) if item := json_fixture["data"].get("system_production_phases"): mocked_data.system_production_phases = {} for sub_item, item_data in item.items(): diff --git a/tests/components/enphase_envoy/fixtures/envoy.json b/tests/components/enphase_envoy/fixtures/envoy.json index 8c9be429931..3431dba6766 100644 --- a/tests/components/enphase_envoy/fixtures/envoy.json +++ b/tests/components/enphase_envoy/fixtures/envoy.json @@ -17,6 +17,7 @@ "encharge_aggregate": null, "enpower": null, "system_consumption": null, + "system_net_consumption": null, "system_production": { "watt_hours_lifetime": 1234, "watt_hours_last_7_days": 1234, @@ -24,6 +25,7 @@ "watts_now": 1234 }, "system_consumption_phases": null, + "system_net_consumption_phases": null, "system_production_phases": null, "ctmeter_production": null, "ctmeter_consumption": null, diff --git a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json index e72829280da..05a6f265dfb 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json @@ -22,6 +22,12 @@ "watt_hours_today": 1234, "watts_now": 1234 }, + "system_net_consumption": { + "watt_hours_lifetime": 4321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 2341 + }, "system_production": { "watt_hours_lifetime": 1234, "watt_hours_last_7_days": 1234, @@ -29,6 +35,7 @@ "watts_now": 1234 }, "system_consumption_phases": null, + "system_net_consumption_phases": null, "system_production_phases": null, "ctmeter_production": { "eid": "100000010", diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 72b510e2328..7affc1bea0d 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -79,6 +79,12 @@ "watt_hours_today": 1234, "watts_now": 1234 }, + "system_net_consumption": { + "watt_hours_lifetime": 4321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 2341 + }, "system_production": { "watt_hours_lifetime": 1234, "watt_hours_last_7_days": 1234, @@ -105,6 +111,26 @@ "watts_now": 3324 } }, + "system_net_consumption_phases": { + "L1": { + "watt_hours_lifetime": 1321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 12341 + }, + "L2": { + "watt_hours_lifetime": 2321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 22341 + }, + "L3": { + "watt_hours_lifetime": 3321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 32341 + } + }, "system_production_phases": { "L1": { "watt_hours_lifetime": 1232, diff --git a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json index f9b6ae31196..ff975b690ed 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json +++ b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json @@ -22,6 +22,12 @@ "watt_hours_today": 1234, "watts_now": 1234 }, + "system_net_consumption": { + "watt_hours_lifetime": 4321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 2341 + }, "system_production": { "watt_hours_lifetime": 1234, "watt_hours_last_7_days": 1234, @@ -48,6 +54,26 @@ "watts_now": 3324 } }, + "system_net_consumption_phases": { + "L1": { + "watt_hours_lifetime": 1321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 12341 + }, + "L2": { + "watt_hours_lifetime": 2321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 22341 + }, + "L3": { + "watt_hours_lifetime": 3321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 32341 + } + }, "system_production_phases": { "L1": { "watt_hours_lifetime": 1232, diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index ca2a976b6d1..62df69c6d88 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -17,6 +17,12 @@ "encharge_aggregate": null, "enpower": null, "system_consumption": null, + "system_net_consumption": { + "watt_hours_lifetime": 4321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 2341 + }, "system_production": { "watt_hours_lifetime": 1234, "watt_hours_last_7_days": 1234, @@ -24,6 +30,7 @@ "watts_now": 1234 }, "system_consumption_phases": null, + "system_net_consumption_phases": null, "system_production_phases": null, "ctmeter_production": { "eid": "100000010", diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index ad937b27167..f0d4006f05c 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -328,6 +328,64 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_balanced_net_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption', + 'unique_id': '1234_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_balanced_net_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.341', + }) +# --- # name: test_sensor[envoy_1p_metered][sensor.envoy_1234_current_net_power_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -838,6 +896,64 @@ 'state': '50.1', }) # --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption', + 'unique_id': '1234_lifetime_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.321', + }) +# --- # name: test_sensor[envoy_1p_metered][sensor.envoy_1234_lifetime_energy_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2105,6 +2221,238 @@ 'state': '525', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_balanced_net_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption', + 'unique_id': '1234_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_balanced_net_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.341', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_balanced_net_power_consumption_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption_phase', + 'unique_id': '1234_balanced_net_consumption_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_balanced_net_power_consumption_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.341', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_balanced_net_power_consumption_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption_phase', + 'unique_id': '1234_balanced_net_consumption_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_balanced_net_power_consumption_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.341', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_balanced_net_power_consumption_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption_phase', + 'unique_id': '1234_balanced_net_consumption_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_balanced_net_power_consumption_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.341', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4695,6 +5043,238 @@ 'state': '50.2', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption', + 'unique_id': '1234_lifetime_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.321', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption_phase', + 'unique_id': '1234_lifetime_balanced_net_consumption_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.321', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption_phase', + 'unique_id': '1234_lifetime_balanced_net_consumption_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.321', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption_phase', + 'unique_id': '1234_lifetime_balanced_net_consumption_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.321', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.envoy_1234_lifetime_battery_energy_charged-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -9597,6 +10177,238 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_balanced_net_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption', + 'unique_id': '1234_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_balanced_net_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.341', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_balanced_net_power_consumption_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption_phase', + 'unique_id': '1234_balanced_net_consumption_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_balanced_net_power_consumption_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.341', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_balanced_net_power_consumption_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption_phase', + 'unique_id': '1234_balanced_net_consumption_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_balanced_net_power_consumption_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.341', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_balanced_net_power_consumption_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption_phase', + 'unique_id': '1234_balanced_net_consumption_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_balanced_net_power_consumption_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.341', + }) +# --- # name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_current_net_power_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11637,6 +12449,238 @@ 'state': '50.1', }) # --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption', + 'unique_id': '1234_lifetime_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.321', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption_phase', + 'unique_id': '1234_lifetime_balanced_net_consumption_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.321', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption_phase', + 'unique_id': '1234_lifetime_balanced_net_consumption_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.321', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption_phase', + 'unique_id': '1234_lifetime_balanced_net_consumption_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.321', + }) +# --- # name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_lifetime_energy_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -14873,6 +15917,64 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_balanced_net_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption', + 'unique_id': '1234_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_balanced_net_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.341', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_current_power_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -15099,6 +16201,64 @@ 'state': '50.1', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption', + 'unique_id': '1234_lifetime_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.321', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index 273f81173ff..90b36e23555 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -179,6 +179,47 @@ async def test_sensor_consumption_data( assert float(entity_state.state) == target +NET_CONSUMPTION_NAMES: tuple[str, ...] = ( + "balanced_net_power_consumption", + "lifetime_balanced_net_energy_consumption", +) + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_1p_metered", + "envoy_metered_batt_relay", + "envoy_nobatt_metered_3p", + "envoy_tot_cons_metered", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_net_consumption_data( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test net consumption entities values.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.serial_number + ENTITY_BASE: str = f"{Platform.SENSOR}.envoy_{sn}" + + data = mock_envoy.data.system_net_consumption + NET_CONSUMPTION_TARGETS = ( + data.watts_now / 1000.0, + data.watt_hours_lifetime / 1000.0, + ) + for name, target in list( + zip(NET_CONSUMPTION_NAMES, NET_CONSUMPTION_TARGETS, strict=False) + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert float(entity_state.state) == target + + CONSUMPTION_PHASE_NAMES: list[str] = [ f"{name}_{phase.lower()}" for phase in PHASENAMES for name in CONSUMPTION_NAMES ] @@ -224,6 +265,48 @@ async def test_sensor_consumption_phase_data( assert float(entity_state.state) == target +NET_CONSUMPTION_PHASE_NAMES: list[str] = [ + f"{name}_{phase.lower()}" for phase in PHASENAMES for name in NET_CONSUMPTION_NAMES +] + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + "envoy_nobatt_metered_3p", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_net_consumption_phase_data( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test consumption phase entities values.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + sn = mock_envoy.serial_number + ENTITY_BASE: str = f"{Platform.SENSOR}.envoy_{sn}" + + NET_CONSUMPTION_PHASE_TARGET = chain( + *[ + ( + phase_data.watts_now / 1000.0, + phase_data.watt_hours_lifetime / 1000.0, + ) + for phase_data in mock_envoy.data.system_net_consumption_phases.values() + ] + ) + for name, target in list( + zip(NET_CONSUMPTION_PHASE_NAMES, NET_CONSUMPTION_PHASE_TARGET, strict=False) + ): + assert (entity_state := hass.states.get(f"{ENTITY_BASE}_{name}")) + assert float(entity_state.state) == target + + CT_PRODUCTION_NAMES_INT = ("meter_status_flags_active_production_ct",) CT_PRODUCTION_NAMES_STR = ("metering_status_production_ct",) @@ -877,6 +960,7 @@ async def test_sensor_missing_data( # force missing data to test 'if == none' code sections mock_envoy.data.system_production_phases["L2"] = None mock_envoy.data.system_consumption_phases["L2"] = None + mock_envoy.data.system_net_consumption_phases["L2"] = None mock_envoy.data.ctmeter_production = None mock_envoy.data.ctmeter_consumption = None mock_envoy.data.ctmeter_storage = None From 943b96e7a1f40a816f844872666084d577522c41 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sun, 8 Sep 2024 12:18:32 +0200 Subject: [PATCH 0383/1309] Fix Bang & Olufsen testing typing (#125427) * Fix test parameter typed as callable instead of context manager * Add missing AsyncMock typing --- tests/components/bang_olufsen/test_media_player.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 70743cd2cca..352a90cd07c 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -1,7 +1,6 @@ """Test the Bang & Olufsen media_player entity.""" -from collections.abc import Callable -from contextlib import nullcontext as does_not_raise +from contextlib import AbstractContextManager, nullcontext as does_not_raise import logging from unittest.mock import AsyncMock, patch @@ -150,7 +149,9 @@ async def test_async_update_sources_outdated_api( async def test_async_update_sources_remote( - hass: HomeAssistant, mock_mozart_client, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, ) -> None: """Test _async_update_sources is called when there are new video sources.""" @@ -595,7 +596,7 @@ async def test_async_media_seek( mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, source: Source, - expected_result: Callable, + expected_result: AbstractContextManager, seek_called_times: int, ) -> None: """Test async_media_seek.""" @@ -681,7 +682,7 @@ async def test_async_select_source( mock_mozart_client: AsyncMock, mock_config_entry: MockConfigEntry, source: str, - expected_result: Callable, + expected_result: AbstractContextManager, audio_source_call: int, video_source_call: int, ) -> None: From 31aef86c0f0bc90bb43338fcea0808b4568e004b Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sun, 8 Sep 2024 12:22:21 +0200 Subject: [PATCH 0384/1309] Add various assertions to Bang & Olufsen testing (#125429) Add various assertions --- .../bang_olufsen/test_media_player.py | 39 ++++++++++--------- .../components/bang_olufsen/test_websocket.py | 14 +++++-- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 352a90cd07c..76f0d842648 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -104,7 +104,7 @@ async def test_initialization( # Check state (The initial state in this test does not contain all that much. # States are tested using simulated WebSocket events.) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes[ATTR_INPUT_SOURCE_LIST] == TEST_SOURCES assert states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] @@ -126,7 +126,7 @@ async def test_async_update_sources_audio_only( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes[ATTR_INPUT_SOURCE_LIST] == TEST_AUDIO_SOURCES @@ -141,7 +141,7 @@ async def test_async_update_sources_outdated_api( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ( states.attributes[ATTR_INPUT_SOURCE_LIST] == TEST_FALLBACK_SOURCES + TEST_VIDEO_SOURCES @@ -187,7 +187,7 @@ async def test_async_update_playback_metadata( mock_mozart_client.get_playback_metadata_notifications.call_args[0][0] ) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ATTR_MEDIA_DURATION not in states.attributes assert ATTR_MEDIA_TITLE not in states.attributes assert ATTR_MEDIA_ALBUM_NAME not in states.attributes @@ -198,7 +198,7 @@ async def test_async_update_playback_metadata( # Send the WebSocket event dispatch playback_metadata_callback(TEST_PLAYBACK_METADATA) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ( states.attributes[ATTR_MEDIA_DURATION] == TEST_PLAYBACK_METADATA.total_duration_seconds @@ -250,14 +250,14 @@ async def test_async_update_playback_progress( mock_mozart_client.get_playback_progress_notifications.call_args[0][0] ) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ATTR_MEDIA_POSITION not in states.attributes old_updated_at = states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] assert old_updated_at playback_progress_callback(TEST_PLAYBACK_PROGRESS) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes[ATTR_MEDIA_POSITION] == TEST_PLAYBACK_PROGRESS.progress new_updated_at = states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] assert new_updated_at @@ -278,12 +278,12 @@ async def test_async_update_playback_state( mock_mozart_client.get_playback_state_notifications.call_args[0][0] ) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.state == MediaPlayerState.PLAYING playback_state_callback(TEST_PLAYBACK_STATE_PAUSED) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.state == TEST_PLAYBACK_STATE_PAUSED.value @@ -366,7 +366,7 @@ async def test_async_update_source_change( mock_mozart_client.get_source_change_notifications.call_args[0][0] ) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ATTR_INPUT_SOURCE not in states.attributes assert states.attributes[ATTR_MEDIA_CONTENT_TYPE] == MediaType.MUSIC @@ -377,7 +377,7 @@ async def test_async_update_source_change( playback_metadata_callback(metadata) source_change_callback(reported_source) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes[ATTR_INPUT_SOURCE] == real_source.name assert states.attributes[ATTR_MEDIA_CONTENT_TYPE] == content_type assert states.attributes[ATTR_MEDIA_POSITION] == progress @@ -406,7 +406,8 @@ async def test_async_turn_off( playback_state_callback(TEST_PLAYBACK_STATE_TURN_OFF) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert TEST_PLAYBACK_STATE_TURN_OFF.value assert states.state == BANG_OLUFSEN_STATES[TEST_PLAYBACK_STATE_TURN_OFF.value] # Check API call @@ -425,7 +426,7 @@ async def test_async_set_volume_level( volume_callback = mock_mozart_client.get_volume_notifications.call_args[0][0] - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ATTR_MEDIA_VOLUME_LEVEL not in states.attributes await hass.services.async_call( @@ -441,7 +442,7 @@ async def test_async_set_volume_level( # The service call will trigger a WebSocket notification volume_callback(TEST_VOLUME) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ( states.attributes[ATTR_MEDIA_VOLUME_LEVEL] == TEST_VOLUME_HOME_ASSISTANT_FORMAT ) @@ -463,7 +464,7 @@ async def test_async_mute_volume( volume_callback = mock_mozart_client.get_volume_notifications.call_args[0][0] - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ATTR_MEDIA_VOLUME_MUTED not in states.attributes await hass.services.async_call( @@ -479,7 +480,7 @@ async def test_async_mute_volume( # The service call will trigger a WebSocket notification volume_callback(TEST_VOLUME_MUTED) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert ( states.attributes[ATTR_MEDIA_VOLUME_MUTED] == TEST_VOLUME_MUTED_HOME_ASSISTANT_FORMAT @@ -518,7 +519,8 @@ async def test_async_media_play_pause( # Set the initial state playback_state_callback(initial_state) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert initial_state.value assert states.state == BANG_OLUFSEN_STATES[initial_state.value] await hass.services.async_call( @@ -548,7 +550,8 @@ async def test_async_media_stop( # Set the state to playing playback_state_callback(TEST_PLAYBACK_STATE_PLAYING) - states = hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID) + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert TEST_PLAYBACK_STATE_PLAYING.value assert states.state == BANG_OLUFSEN_STATES[TEST_PLAYBACK_STATE_PLAYING.value] await hass.services.async_call( diff --git a/tests/components/bang_olufsen/test_websocket.py b/tests/components/bang_olufsen/test_websocket.py index 209550faee5..b17859a4f4e 100644 --- a/tests/components/bang_olufsen/test_websocket.py +++ b/tests/components/bang_olufsen/test_websocket.py @@ -101,8 +101,11 @@ async def test_on_software_update_state( await hass.async_block_till_done() - device = device_registry.async_get_device( - identifiers={(DOMAIN, mock_config_entry.unique_id)} + assert mock_config_entry.unique_id + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) ) assert device.sw_version == "1.0.0" @@ -135,8 +138,11 @@ async def test_on_all_notifications_raw( raw_notification_full = raw_notification # Get device ID for the modified notification that is sent as an event and in the log - device = device_registry.async_get_device( - identifiers={(DOMAIN, mock_config_entry.unique_id)} + assert mock_config_entry.unique_id + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) ) raw_notification_full.update( { From fd0c63fe529249d95f87a9b51144f4c10b2ea4b8 Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Sun, 8 Sep 2024 12:29:42 +0200 Subject: [PATCH 0385/1309] Add text-selector autocomplete in Bring config flow (#124063) Add autocomplete to Bring config flow schema --- homeassistant/components/bring/config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index c675eda3cd2..6a90ff153e5 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -33,11 +33,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Required(CONF_EMAIL): TextSelector( TextSelectorConfig( type=TextSelectorType.EMAIL, + autocomplete="email", ), ), vol.Required(CONF_PASSWORD): TextSelector( TextSelectorConfig( type=TextSelectorType.PASSWORD, + autocomplete="current-password", ), ), } From ec9f50317fcdf351f5f25f71283a444157b76497 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sun, 8 Sep 2024 12:50:04 +0200 Subject: [PATCH 0386/1309] Allow waze_travel_time multiple excl/incl filter (#117252) * Allow multiple excl/incl filter * Use list comprehension for should_include * Do not use mutable object as default param * Inline migration func --- .../components/waze_travel_time/__init__.py | 95 +++++++++++++++---- .../waze_travel_time/config_flow.py | 25 ++++- .../components/waze_travel_time/const.py | 5 +- .../components/waze_travel_time/sensor.py | 4 +- tests/components/waze_travel_time/conftest.py | 2 + .../waze_travel_time/test_config_flow.py | 31 +++--- .../components/waze_travel_time/test_init.py | 79 ++++++++++++++- .../waze_travel_time/test_sensor.py | 15 ++- 8 files changed, 211 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 83b2e2aa7c7..1abcf9d391d 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -1,6 +1,7 @@ """The waze_travel_time component.""" import asyncio +from collections.abc import Collection import logging from pywaze.route_calculator import CalcRoutesResponse, WazeRouteCalculator, WRCError @@ -28,10 +29,13 @@ from .const import ( CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, CONF_DESTINATION, + CONF_EXCL_FILTER, + CONF_INCL_FILTER, CONF_ORIGIN, CONF_REALTIME, CONF_UNITS, CONF_VEHICLE_TYPE, + DEFAULT_FILTER, DEFAULT_VEHICLE_TYPE, DOMAIN, METRIC_UNITS, @@ -86,6 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Load the saved entities.""" if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}): hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async def async_get_travel_times_service(service: ServiceCall) -> ServiceResponse: @@ -124,11 +129,14 @@ async def async_get_travel_times( avoid_subscription_roads: bool, avoid_ferries: bool, realtime: bool, - incl_filter: str | None = None, - excl_filter: str | None = None, + incl_filters: Collection[str] | None = None, + excl_filters: Collection[str] | None = None, ) -> list[CalcRoutesResponse] | None: """Get all available routes.""" + incl_filters = incl_filters or () + excl_filters = excl_filters or () + _LOGGER.debug( "Getting update for origin: %s destination: %s", origin, @@ -147,28 +155,46 @@ async def async_get_travel_times( real_time=realtime, alternatives=3, ) + _LOGGER.debug("Got routes: %s", routes) - if incl_filter not in {None, ""}: - routes = [ - r - for r in routes - if any( - incl_filter.lower() == street_name.lower() # type: ignore[union-attr] - for street_name in r.street_names + incl_routes: list[CalcRoutesResponse] = [] + + def should_include_route(route: CalcRoutesResponse) -> bool: + if len(incl_filters) < 1: + return True + should_include = any( + street_name in incl_filters or "" in incl_filters + for street_name in route.street_names + ) + if not should_include: + _LOGGER.debug( + "Excluding route [%s], because no inclusive filter matched any streetname", + route.name, ) - ] + return False + return True - if excl_filter not in {None, ""}: - routes = [ - r - for r in routes - if not any( - excl_filter.lower() == street_name.lower() # type: ignore[union-attr] - for street_name in r.street_names - ) - ] + incl_routes = [route for route in routes if should_include_route(route)] - if len(routes) < 1: + filtered_routes: list[CalcRoutesResponse] = [] + + def should_exclude_route(route: CalcRoutesResponse) -> bool: + for street_name in route.street_names: + for excl_filter in excl_filters: + if excl_filter == street_name: + _LOGGER.debug( + "Excluding route, because exclusive filter [%s] matched streetname: %s", + excl_filter, + route.name, + ) + return True + return False + + filtered_routes = [ + route for route in incl_routes if not should_exclude_route(route) + ] + + if len(filtered_routes) < 1: _LOGGER.warning("No routes found") return None except WRCError as exp: @@ -176,9 +202,36 @@ async def async_get_travel_times( return None else: - return routes + return filtered_routes async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate an old config entry.""" + + if config_entry.version == 1: + _LOGGER.debug( + "Migrating from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + options = dict(config_entry.options) + if (incl_filters := options.pop(CONF_INCL_FILTER, None)) not in {None, ""}: + options[CONF_INCL_FILTER] = [incl_filters] + else: + options[CONF_INCL_FILTER] = DEFAULT_FILTER + if (excl_filters := options.pop(CONF_EXCL_FILTER, None)) not in {None, ""}: + options[CONF_EXCL_FILTER] = [excl_filters] + else: + options[CONF_EXCL_FILTER] = DEFAULT_FILTER + hass.config_entries.async_update_entry(config_entry, options=options, version=2) + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True diff --git a/homeassistant/components/waze_travel_time/config_flow.py b/homeassistant/components/waze_travel_time/config_flow.py index 12dc8336f92..b684dd0bb80 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -20,6 +20,8 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, TextSelector, + TextSelectorConfig, + TextSelectorType, ) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -34,6 +36,7 @@ from .const import ( CONF_REALTIME, CONF_UNITS, CONF_VEHICLE_TYPE, + DEFAULT_FILTER, DEFAULT_NAME, DEFAULT_OPTIONS, DOMAIN, @@ -46,8 +49,18 @@ from .helpers import is_valid_config_entry OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_INCL_FILTER, default=""): TextSelector(), - vol.Optional(CONF_EXCL_FILTER, default=""): TextSelector(), + vol.Optional(CONF_INCL_FILTER): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + multiple=True, + ), + ), + vol.Optional(CONF_EXCL_FILTER): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + multiple=True, + ), + ), vol.Optional(CONF_REALTIME): BooleanSelector(), vol.Required(CONF_VEHICLE_TYPE): SelectSelector( SelectSelectorConfig( @@ -88,7 +101,7 @@ CONFIG_SCHEMA = vol.Schema( ) -def default_options(hass: HomeAssistant) -> dict[str, str | bool]: +def default_options(hass: HomeAssistant) -> dict[str, str | bool | list[str]]: """Get the default options.""" defaults = DEFAULT_OPTIONS.copy() if hass.config.units is US_CUSTOMARY_SYSTEM: @@ -106,6 +119,10 @@ class WazeOptionsFlow(OptionsFlow): async def async_step_init(self, user_input=None) -> ConfigFlowResult: """Handle the initial step.""" if user_input is not None: + if user_input.get(CONF_INCL_FILTER) is None: + user_input[CONF_INCL_FILTER] = DEFAULT_FILTER + if user_input.get(CONF_EXCL_FILTER) is None: + user_input[CONF_EXCL_FILTER] = DEFAULT_FILTER return self.async_create_entry( title="", data=user_input, @@ -122,7 +139,7 @@ class WazeOptionsFlow(OptionsFlow): class WazeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Waze Travel Time.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Init Config Flow.""" diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 84e41c3963f..7c77f43574d 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -22,6 +22,7 @@ DEFAULT_VEHICLE_TYPE = "car" DEFAULT_AVOID_TOLL_ROADS = False DEFAULT_AVOID_SUBSCRIPTION_ROADS = False DEFAULT_AVOID_FERRIES = False +DEFAULT_FILTER = [""] IMPERIAL_UNITS = "imperial" METRIC_UNITS = "metric" @@ -30,11 +31,13 @@ UNITS = [METRIC_UNITS, IMPERIAL_UNITS] REGIONS = ["us", "na", "eu", "il", "au"] VEHICLE_TYPES = ["car", "taxi", "motorcycle"] -DEFAULT_OPTIONS: dict[str, str | bool] = { +DEFAULT_OPTIONS: dict[str, str | bool | list[str]] = { CONF_REALTIME: DEFAULT_REALTIME, CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, CONF_UNITS: METRIC_UNITS, CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, + CONF_INCL_FILTER: DEFAULT_FILTER, + CONF_EXCL_FILTER: DEFAULT_FILTER, } diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 7663b4a102e..c2d3ee12cf8 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -183,8 +183,8 @@ class WazeTravelTimeData: ) if self.origin is not None and self.destination is not None: # Grab options on every update - incl_filter = self.config_entry.options.get(CONF_INCL_FILTER) - excl_filter = self.config_entry.options.get(CONF_EXCL_FILTER) + incl_filter = self.config_entry.options[CONF_INCL_FILTER] + excl_filter = self.config_entry.options[CONF_EXCL_FILTER] realtime = self.config_entry.options[CONF_REALTIME] vehicle_type = self.config_entry.options[CONF_VEHICLE_TYPE] avoid_toll_roads = self.config_entry.options[CONF_AVOID_TOLL_ROADS] diff --git a/tests/components/waze_travel_time/conftest.py b/tests/components/waze_travel_time/conftest.py index c929fc219f9..c9214ed8b71 100644 --- a/tests/components/waze_travel_time/conftest.py +++ b/tests/components/waze_travel_time/conftest.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest from pywaze.route_calculator import CalcRoutesResponse, WRCError +from homeassistant.components.waze_travel_time.config_flow import WazeConfigFlow from homeassistant.components.waze_travel_time.const import DOMAIN from homeassistant.core import HomeAssistant @@ -19,6 +20,7 @@ async def mock_config_fixture(hass: HomeAssistant, data, options): data=data, options=options, entry_id="test", + version=WazeConfigFlow.VERSION, ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/waze_travel_time/test_config_flow.py b/tests/components/waze_travel_time/test_config_flow.py index 5b1e3417bfc..87cb92f1522 100644 --- a/tests/components/waze_travel_time/test_config_flow.py +++ b/tests/components/waze_travel_time/test_config_flow.py @@ -3,6 +3,7 @@ import pytest from homeassistant import config_entries +from homeassistant.components.waze_travel_time.config_flow import WazeConfigFlow from homeassistant.components.waze_travel_time.const import ( CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, @@ -60,6 +61,7 @@ async def test_reconfigure(hass: HomeAssistant) -> None: domain=DOMAIN, data=MOCK_CONFIG, options=DEFAULT_OPTIONS, + version=WazeConfigFlow.VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -103,6 +105,7 @@ async def test_options(hass: HomeAssistant) -> None: domain=DOMAIN, data=MOCK_CONFIG, options=DEFAULT_OPTIONS, + version=WazeConfigFlow.VERSION, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -119,8 +122,8 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_INCL_FILTER: "include", + CONF_EXCL_FILTER: ["exclude"], + CONF_INCL_FILTER: ["include"], CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", @@ -132,8 +135,8 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_INCL_FILTER: "include", + CONF_EXCL_FILTER: ["exclude"], + CONF_INCL_FILTER: ["include"], CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", @@ -143,8 +146,8 @@ async def test_options(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "exclude", - CONF_INCL_FILTER: "include", + CONF_EXCL_FILTER: ["exclude"], + CONF_INCL_FILTER: ["include"], CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", @@ -209,10 +212,14 @@ async def test_invalid_config_entry( async def test_reset_filters(hass: HomeAssistant) -> None: """Test resetting inclusive and exclusive filters to empty string.""" options = {**DEFAULT_OPTIONS} - options[CONF_INCL_FILTER] = "test" - options[CONF_EXCL_FILTER] = "test" + options[CONF_INCL_FILTER] = ["test"] + options[CONF_EXCL_FILTER] = ["test"] config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, options=options, entry_id="test" + domain=DOMAIN, + data=MOCK_CONFIG, + options=options, + entry_id="test", + version=WazeConfigFlow.VERSION, ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -228,8 +235,6 @@ async def test_reset_filters(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "", - CONF_INCL_FILTER: "", CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", @@ -240,8 +245,8 @@ async def test_reset_filters(hass: HomeAssistant) -> None: CONF_AVOID_FERRIES: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_TOLL_ROADS: True, - CONF_EXCL_FILTER: "", - CONF_INCL_FILTER: "", + CONF_EXCL_FILTER: [""], + CONF_INCL_FILTER: [""], CONF_REALTIME: False, CONF_UNITS: IMPERIAL_UNITS, CONF_VEHICLE_TYPE: "taxi", diff --git a/tests/components/waze_travel_time/test_init.py b/tests/components/waze_travel_time/test_init.py index 58aaa8983a7..9c59278ff99 100644 --- a/tests/components/waze_travel_time/test_init.py +++ b/tests/components/waze_travel_time/test_init.py @@ -2,11 +2,32 @@ import pytest -from homeassistant.components.waze_travel_time.const import DEFAULT_OPTIONS +from homeassistant.components.waze_travel_time.const import ( + CONF_AVOID_FERRIES, + CONF_AVOID_SUBSCRIPTION_ROADS, + CONF_AVOID_TOLL_ROADS, + CONF_EXCL_FILTER, + CONF_INCL_FILTER, + CONF_REALTIME, + CONF_UNITS, + CONF_VEHICLE_TYPE, + DEFAULT_AVOID_FERRIES, + DEFAULT_AVOID_SUBSCRIPTION_ROADS, + DEFAULT_AVOID_TOLL_ROADS, + DEFAULT_FILTER, + DEFAULT_OPTIONS, + DEFAULT_REALTIME, + DEFAULT_VEHICLE_TYPE, + DOMAIN, + METRIC_UNITS, +) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from .const import MOCK_CONFIG +from tests.common import MockConfigEntry + @pytest.mark.parametrize( ("data", "options"), @@ -43,3 +64,59 @@ async def test_service_get_travel_times(hass: HomeAssistant) -> None: }, ] } + + +@pytest.mark.usefixtures("mock_update") +async def test_migrate_entry_v1_v2(hass: HomeAssistant) -> None: + """Test successful migration of entry data.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data=MOCK_CONFIG, + options={ + CONF_REALTIME: DEFAULT_REALTIME, + CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, + CONF_UNITS: METRIC_UNITS, + CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, + CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, + CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, + }, + ) + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 2 + assert updated_entry.options[CONF_INCL_FILTER] == DEFAULT_FILTER + assert updated_entry.options[CONF_EXCL_FILTER] == DEFAULT_FILTER + + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data=MOCK_CONFIG, + options={ + CONF_REALTIME: DEFAULT_REALTIME, + CONF_VEHICLE_TYPE: DEFAULT_VEHICLE_TYPE, + CONF_UNITS: METRIC_UNITS, + CONF_AVOID_FERRIES: DEFAULT_AVOID_FERRIES, + CONF_AVOID_SUBSCRIPTION_ROADS: DEFAULT_AVOID_SUBSCRIPTION_ROADS, + CONF_AVOID_TOLL_ROADS: DEFAULT_AVOID_TOLL_ROADS, + CONF_INCL_FILTER: "include", + CONF_EXCL_FILTER: "exclude", + }, + ) + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + updated_entry = hass.config_entries.async_get_entry(mock_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 2 + assert updated_entry.options[CONF_INCL_FILTER] == ["include"] + assert updated_entry.options[CONF_EXCL_FILTER] == ["exclude"] diff --git a/tests/components/waze_travel_time/test_sensor.py b/tests/components/waze_travel_time/test_sensor.py index e09a7199ff4..94e3a0cf9d7 100644 --- a/tests/components/waze_travel_time/test_sensor.py +++ b/tests/components/waze_travel_time/test_sensor.py @@ -3,6 +3,7 @@ import pytest from pywaze.route_calculator import WRCError +from homeassistant.components.waze_travel_time.config_flow import WazeConfigFlow from homeassistant.components.waze_travel_time.const import ( CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, @@ -74,6 +75,8 @@ async def test_sensor(hass: HomeAssistant) -> None: CONF_AVOID_TOLL_ROADS: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_FERRIES: True, + CONF_INCL_FILTER: [""], + CONF_EXCL_FILTER: [""], }, ) ], @@ -98,7 +101,8 @@ async def test_imperial(hass: HomeAssistant) -> None: CONF_AVOID_TOLL_ROADS: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_FERRIES: True, - CONF_INCL_FILTER: "IncludeThis", + CONF_INCL_FILTER: ["IncludeThis"], + CONF_EXCL_FILTER: [""], }, ) ], @@ -121,7 +125,8 @@ async def test_incl_filter(hass: HomeAssistant) -> None: CONF_AVOID_TOLL_ROADS: True, CONF_AVOID_SUBSCRIPTION_ROADS: True, CONF_AVOID_FERRIES: True, - CONF_EXCL_FILTER: "ExcludeThis", + CONF_INCL_FILTER: [""], + CONF_EXCL_FILTER: ["ExcludeThis"], }, ) ], @@ -138,7 +143,11 @@ async def test_sensor_failed_wrcerror( ) -> None: """Test that sensor update fails with log message.""" config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, options=DEFAULT_OPTIONS, entry_id="test" + domain=DOMAIN, + data=MOCK_CONFIG, + options=DEFAULT_OPTIONS, + entry_id="test", + version=WazeConfigFlow.VERSION, ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) From e7cb646a581699c837622f009c1cd9414d8903f2 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 8 Sep 2024 12:50:29 +0200 Subject: [PATCH 0387/1309] Use json data instead of timedelta for tests in generic hygrostat (#124111) Use json data instead of timedelta for tests --- .../components/generic_hygrostat/test_humidifier.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index 2beaf423201..afe183cae5e 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -920,7 +920,7 @@ async def setup_comp_4(hass: HomeAssistant) -> None: "humidifier": ENT_SWITCH, "target_sensor": ENT_SENSOR, "device_class": "dehumidifier", - "min_cycle_duration": datetime.timedelta(minutes=10), + "min_cycle_duration": {"minutes": 10}, "initial_state": True, "target_humidity": 40, } @@ -1062,7 +1062,7 @@ async def setup_comp_6(hass: HomeAssistant) -> None: "wet_tolerance": 3, "humidifier": ENT_SWITCH, "target_sensor": ENT_SENSOR, - "min_cycle_duration": datetime.timedelta(minutes=10), + "min_cycle_duration": {"minutes": 10}, "initial_state": True, "target_humidity": 40, } @@ -1219,8 +1219,8 @@ async def setup_comp_7(hass: HomeAssistant) -> None: "humidifier": ENT_SWITCH, "target_sensor": ENT_SENSOR, "device_class": "dehumidifier", - "min_cycle_duration": datetime.timedelta(minutes=15), - "keep_alive": datetime.timedelta(minutes=10), + "min_cycle_duration": {"minutes": 15}, + "keep_alive": {"minutes": 10}, "initial_state": True, "target_humidity": 40, } @@ -1285,8 +1285,8 @@ async def setup_comp_8(hass: HomeAssistant) -> None: "wet_tolerance": 3, "humidifier": ENT_SWITCH, "target_sensor": ENT_SENSOR, - "min_cycle_duration": datetime.timedelta(minutes=15), - "keep_alive": datetime.timedelta(minutes=10), + "min_cycle_duration": {"minutes": 15}, + "keep_alive": {"minutes": 10}, "initial_state": True, "target_humidity": 40, } From 3139a7e4313944a00e91be57020e17412db5b838 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 8 Sep 2024 12:51:08 +0200 Subject: [PATCH 0388/1309] Adjust generic hygrostat to detect reported events for stale tracking (#124109) * Listen to reported events for stale check * Always enable stale sensor tracking There is no reason not to have this enabled now that we track reported events for sensors. * Remove default stale code * Adjust for ruff change --- .../generic_hygrostat/humidifier.py | 50 +++++++++---------- .../generic_hygrostat/test_humidifier.py | 28 +++++++++-- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index ab29e587232..0aa4ba2e515 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -36,6 +36,7 @@ from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, Event, EventStateChangedData, + EventStateReportedData, HomeAssistant, State, callback, @@ -45,6 +46,7 @@ from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( async_track_state_change_event, + async_track_state_report_event, async_track_time_interval, ) from homeassistant.helpers.restore_state import RestoreEntity @@ -72,7 +74,6 @@ _LOGGER = logging.getLogger(__name__) ATTR_SAVED_HUMIDITY = "saved_humidity" - PLATFORM_SCHEMA = HUMIDIFIER_PLATFORM_SCHEMA.extend(HYGROSTAT_SCHEMA.schema) @@ -222,18 +223,21 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): """Run when entity about to be added.""" await super().async_added_to_hass() - # Add listener self.async_on_remove( async_track_state_change_event( - self.hass, self._sensor_entity_id, self._async_sensor_changed_event + self.hass, self._sensor_entity_id, self._async_sensor_event + ) + ) + self.async_on_remove( + async_track_state_report_event( + self.hass, self._sensor_entity_id, self._async_sensor_event ) ) self.async_on_remove( async_track_state_change_event( - self.hass, self._switch_entity_id, self._async_switch_changed_event + self.hass, self._switch_entity_id, self._async_switch_event ) ) - if self._keep_alive: self.async_on_remove( async_track_time_interval( @@ -253,7 +257,8 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): sensor_state.state if sensor_state is not None else "None", ) return - await self._async_sensor_changed(self._sensor_entity_id, None, sensor_state) + + await self._async_sensor_update(sensor_state) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) @@ -391,25 +396,23 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): # Get default humidity from super class return super().max_humidity - async def _async_sensor_changed_event( - self, event: Event[EventStateChangedData] - ) -> None: - """Handle ambient humidity changes.""" - data = event.data - await self._async_sensor_changed( - data["entity_id"], data["old_state"], data["new_state"] - ) - - async def _async_sensor_changed( - self, entity_id: str, old_state: State | None, new_state: State | None + async def _async_sensor_event( + self, event: Event[EventStateChangedData] | Event[EventStateReportedData] ) -> None: """Handle ambient humidity changes.""" + new_state = event.data["new_state"] if new_state is None: return + await self._async_sensor_update(new_state) + + async def _async_sensor_update(self, new_state: State) -> None: + """Update state based on humidity sensor.""" + if self._sensor_stale_duration: if self._remove_stale_tracking: self._remove_stale_tracking() + self._remove_stale_tracking = async_track_time_interval( self.hass, self._async_sensor_not_responding, @@ -426,23 +429,18 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): state = self.hass.states.get(self._sensor_entity_id) _LOGGER.debug( "Sensor has not been updated for %s", - now - state.last_updated if now and state else "---", + now - state.last_reported if now and state else "---", ) _LOGGER.warning("Sensor is stalled, call the emergency stop") await self._async_update_humidity("Stalled") @callback - def _async_switch_changed_event(self, event: Event[EventStateChangedData]) -> None: + def _async_switch_event(self, event: Event[EventStateChangedData]) -> None: """Handle humidifier switch state changes.""" - data = event.data - self._async_switch_changed( - data["entity_id"], data["old_state"], data["new_state"] - ) + self._async_switch_changed(event.data["new_state"]) @callback - def _async_switch_changed( - self, entity_id: str, old_state: State | None, new_state: State | None - ) -> None: + def _async_switch_changed(self, new_state: State | None) -> None: """Handle humidifier switch state changes.""" if new_state is None: return diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index afe183cae5e..9cd51baa576 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -3,6 +3,7 @@ import datetime from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -520,6 +521,7 @@ async def test_set_target_humidity_humidifier_on(hass: HomeAssistant) -> None: calls = await _setup_switch(hass, False) _setup_sensor(hass, 36) await hass.async_block_till_done() + calls.clear() await hass.services.async_call( DOMAIN, SERVICE_SET_HUMIDITY, @@ -540,6 +542,7 @@ async def test_set_target_humidity_humidifier_off(hass: HomeAssistant) -> None: calls = await _setup_switch(hass, True) _setup_sensor(hass, 45) await hass.async_block_till_done() + calls.clear() await hass.services.async_call( DOMAIN, SERVICE_SET_HUMIDITY, @@ -1733,7 +1736,9 @@ async def test_away_fixed_humidity_mode(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_comp_1") async def test_sensor_stale_duration( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test turn off on sensor stale.""" @@ -1775,14 +1780,31 @@ async def test_sensor_stale_duration( assert hass.states.get(humidifier_switch).state == STATE_ON # Wait 11 minutes - async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=11)) + freezer.tick(datetime.timedelta(minutes=11)) + async_fire_time_changed(hass) await hass.async_block_till_done() # 11 minutes later, no news from the sensor : emergency cut off assert hass.states.get(humidifier_switch).state == STATE_OFF assert "emergency" in caplog.text - # Updated value from sensor received + # Updated value from sensor received (same value) + _setup_sensor(hass, 23) + await hass.async_block_till_done() + + # A new value has arrived, the humidifier should go ON + assert hass.states.get(humidifier_switch).state == STATE_ON + + # Wait 11 minutes + freezer.tick(datetime.timedelta(minutes=11)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # 11 minutes later, no news from the sensor : emergency cut off + assert hass.states.get(humidifier_switch).state == STATE_OFF + assert "emergency" in caplog.text + + # Updated value from sensor received (new value) _setup_sensor(hass, 24) await hass.async_block_till_done() From 8acc027f383438003e60d2ca8d7376aca0c71e84 Mon Sep 17 00:00:00 2001 From: Simon <80467011+sorgfresser@users.noreply.github.com> Date: Sun, 8 Sep 2024 13:11:26 +0200 Subject: [PATCH 0389/1309] Add voice settings to ElevenLabs options flow (#123265) Add voice settings to options flow --- .../components/elevenlabs/config_flow.py | 87 ++++++++++- homeassistant/components/elevenlabs/const.py | 11 ++ .../components/elevenlabs/strings.json | 22 ++- homeassistant/components/elevenlabs/tts.py | 43 +++++- .../components/elevenlabs/test_config_flow.py | 59 +++++++- tests/components/elevenlabs/test_tts.py | 138 +++++++++++++++++- 6 files changed, 349 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index cf04304510a..6eec35d0583 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -23,7 +23,23 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) -from .const import CONF_MODEL, CONF_VOICE, DEFAULT_MODEL, DOMAIN +from .const import ( + CONF_CONFIGURE_VOICE, + CONF_MODEL, + CONF_OPTIMIZE_LATENCY, + CONF_SIMILARITY, + CONF_STABILITY, + CONF_STYLE, + CONF_USE_SPEAKER_BOOST, + CONF_VOICE, + DEFAULT_MODEL, + DEFAULT_OPTIMIZE_LATENCY, + DEFAULT_SIMILARITY, + DEFAULT_STABILITY, + DEFAULT_STYLE, + DEFAULT_USE_SPEAKER_BOOST, + DOMAIN, +) USER_STEP_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str}) @@ -92,6 +108,8 @@ class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry): # id -> name self.voices: dict[str, str] = {} self.models: dict[str, str] = {} + self.model: str | None = None + self.voice: str | None = None async def async_step_init( self, user_input: dict[str, Any] | None = None @@ -103,6 +121,11 @@ class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry): assert self.models and self.voices if user_input is not None: + self.model = user_input[CONF_MODEL] + self.voice = user_input[CONF_VOICE] + configure_voice = user_input.pop(CONF_CONFIGURE_VOICE) + if configure_voice: + return await self.async_step_voice_settings() return self.async_create_entry( title="ElevenLabs", data=user_input, @@ -139,7 +162,69 @@ class ElevenLabsOptionsFlow(OptionsFlowWithConfigEntry): ] ) ), + vol.Required(CONF_CONFIGURE_VOICE, default=False): bool, } ), self.options, ) + + async def async_step_voice_settings( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle voice settings.""" + assert self.voices and self.models + if user_input is not None: + user_input[CONF_MODEL] = self.model + user_input[CONF_VOICE] = self.voice + return self.async_create_entry( + title="ElevenLabs", + data=user_input, + ) + return self.async_show_form( + step_id="voice_settings", + data_schema=self.elevenlabs_config_options_voice_schema(), + ) + + def elevenlabs_config_options_voice_schema(self) -> vol.Schema: + """Elevenlabs options voice schema.""" + return vol.Schema( + { + vol.Optional( + CONF_STABILITY, + default=self.config_entry.options.get( + CONF_STABILITY, DEFAULT_STABILITY + ), + ): vol.All( + vol.Coerce(float), + vol.Range(min=0, max=1), + ), + vol.Optional( + CONF_SIMILARITY, + default=self.config_entry.options.get( + CONF_SIMILARITY, DEFAULT_SIMILARITY + ), + ): vol.All( + vol.Coerce(float), + vol.Range(min=0, max=1), + ), + vol.Optional( + CONF_OPTIMIZE_LATENCY, + default=self.config_entry.options.get( + CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY + ), + ): vol.All(int, vol.Range(min=0, max=4)), + vol.Optional( + CONF_STYLE, + default=self.config_entry.options.get(CONF_STYLE, DEFAULT_STYLE), + ): vol.All( + vol.Coerce(float), + vol.Range(min=0, max=1), + ), + vol.Optional( + CONF_USE_SPEAKER_BOOST, + default=self.config_entry.options.get( + CONF_USE_SPEAKER_BOOST, DEFAULT_USE_SPEAKER_BOOST + ), + ): bool, + } + ) diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py index c0fc3c7b1b0..040d38d272c 100644 --- a/homeassistant/components/elevenlabs/const.py +++ b/homeassistant/components/elevenlabs/const.py @@ -2,6 +2,17 @@ CONF_VOICE = "voice" CONF_MODEL = "model" +CONF_CONFIGURE_VOICE = "configure_voice" +CONF_STABILITY = "stability" +CONF_SIMILARITY = "similarity" +CONF_OPTIMIZE_LATENCY = "optimize_streaming_latency" +CONF_STYLE = "style" +CONF_USE_SPEAKER_BOOST = "use_speaker_boost" DOMAIN = "elevenlabs" DEFAULT_MODEL = "eleven_multilingual_v2" +DEFAULT_STABILITY = 0.5 +DEFAULT_SIMILARITY = 0.75 +DEFAULT_OPTIMIZE_LATENCY = 0 +DEFAULT_STYLE = 0 +DEFAULT_USE_SPEAKER_BOOST = True diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json index 16b40137090..b346f94a963 100644 --- a/homeassistant/components/elevenlabs/strings.json +++ b/homeassistant/components/elevenlabs/strings.json @@ -19,11 +19,29 @@ "init": { "data": { "voice": "Voice", - "model": "Model" + "model": "Model", + "configure_voice": "Configure advanced voice settings" }, "data_description": { "voice": "Voice to use for the TTS.", - "model": "ElevenLabs model to use. Please note that not all models support all languages equally well." + "model": "ElevenLabs model to use. Please note that not all models support all languages equally well.", + "configure_voice": "Configure advanced voice settings. Find more information in the ElevenLabs documentation." + } + }, + "voice_settings": { + "data": { + "stability": "Stability", + "similarity": "Similarity", + "optimize_streaming_latency": "Latency", + "style": "Style", + "use_speaker_boost": "Speaker boost" + }, + "data_description": { + "stability": "Stability of the generated audio. Higher values lead to less emotional audio.", + "similarity": "Similarity of the generated audio to the original voice. Higher values may result in more similar audio, but may also introduce background noise.", + "optimize_streaming_latency": "Optimize the model for streaming. This may reduce the quality of the generated audio.", + "style": "Style of the generated audio. Recommended to keep at 0 for most almost all use cases.", + "use_speaker_boost": "Use speaker boost to increase the similarity of the generated audio to the original voice." } } } diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index 35ba6053cd8..e7f35775560 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -3,11 +3,12 @@ from __future__ import annotations import logging +from types import MappingProxyType from typing import Any from elevenlabs.client import AsyncElevenLabs from elevenlabs.core import ApiError -from elevenlabs.types import Model, Voice as ElevenLabsVoice +from elevenlabs.types import Model, Voice as ElevenLabsVoice, VoiceSettings from homeassistant.components.tts import ( ATTR_VOICE, @@ -21,11 +22,36 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EleventLabsConfigEntry -from .const import CONF_VOICE, DOMAIN +from .const import ( + CONF_OPTIMIZE_LATENCY, + CONF_SIMILARITY, + CONF_STABILITY, + CONF_STYLE, + CONF_USE_SPEAKER_BOOST, + CONF_VOICE, + DEFAULT_OPTIMIZE_LATENCY, + DEFAULT_SIMILARITY, + DEFAULT_STABILITY, + DEFAULT_STYLE, + DEFAULT_USE_SPEAKER_BOOST, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) +def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings: + """Return voice settings.""" + return VoiceSettings( + stability=options.get(CONF_STABILITY, DEFAULT_STABILITY), + similarity_boost=options.get(CONF_SIMILARITY, DEFAULT_SIMILARITY), + style=options.get(CONF_STYLE, DEFAULT_STYLE), + use_speaker_boost=options.get( + CONF_USE_SPEAKER_BOOST, DEFAULT_USE_SPEAKER_BOOST + ), + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: EleventLabsConfigEntry, @@ -35,6 +61,7 @@ async def async_setup_entry( client = config_entry.runtime_data.client voices = (await client.voices.get_all()).voices default_voice_id = config_entry.options[CONF_VOICE] + voice_settings = to_voice_settings(config_entry.options) async_add_entities( [ ElevenLabsTTSEntity( @@ -44,6 +71,10 @@ async def async_setup_entry( default_voice_id, config_entry.entry_id, config_entry.title, + voice_settings, + config_entry.options.get( + CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY + ), ) ] ) @@ -62,6 +93,8 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): default_voice_id: str, entry_id: str, title: str, + voice_settings: VoiceSettings, + latency: int = 0, ) -> None: """Init ElevenLabs TTS service.""" self._client = client @@ -77,6 +110,10 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): ] if voice_indices: self._voices.insert(0, self._voices.pop(voice_indices[0])) + self._voice_settings = voice_settings + self._latency = latency + + # Entity attributes self._attr_unique_id = entry_id self._attr_name = title self._attr_device_info = DeviceInfo( @@ -105,6 +142,8 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): audio = await self._client.generate( text=message, voice=voice_id, + optimize_streaming_latency=self._latency, + voice_settings=self._voice_settings, model=self._model.model_id, ) bytes_combined = b"".join([byte_seg async for byte_seg in audio]) diff --git a/tests/components/elevenlabs/test_config_flow.py b/tests/components/elevenlabs/test_config_flow.py index 853c49d48ff..971fa75939a 100644 --- a/tests/components/elevenlabs/test_config_flow.py +++ b/tests/components/elevenlabs/test_config_flow.py @@ -3,9 +3,20 @@ from unittest.mock import AsyncMock from homeassistant.components.elevenlabs.const import ( + CONF_CONFIGURE_VOICE, CONF_MODEL, + CONF_OPTIMIZE_LATENCY, + CONF_SIMILARITY, + CONF_STABILITY, + CONF_STYLE, + CONF_USE_SPEAKER_BOOST, CONF_VOICE, DEFAULT_MODEL, + DEFAULT_OPTIMIZE_LATENCY, + DEFAULT_SIMILARITY, + DEFAULT_STABILITY, + DEFAULT_STYLE, + DEFAULT_USE_SPEAKER_BOOST, DOMAIN, ) from homeassistant.config_entries import SOURCE_USER @@ -89,6 +100,52 @@ async def test_options_flow_init( ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert mock_entry.options == {CONF_MODEL: "model1", CONF_VOICE: "voice1"} + assert mock_entry.options == { + CONF_MODEL: "model1", + CONF_VOICE: "voice1", + } mock_setup_entry.assert_called_once() + + +async def test_options_flow_voice_settings_default( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client: AsyncMock, + mock_entry: MockConfigEntry, +) -> None: + """Test options flow voice settings.""" + mock_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MODEL: "model1", + CONF_VOICE: "voice1", + CONF_CONFIGURE_VOICE: True, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "voice_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert mock_entry.options == { + CONF_MODEL: "model1", + CONF_VOICE: "voice1", + CONF_OPTIMIZE_LATENCY: DEFAULT_OPTIMIZE_LATENCY, + CONF_SIMILARITY: DEFAULT_SIMILARITY, + CONF_STABILITY: DEFAULT_STABILITY, + CONF_STYLE: DEFAULT_STYLE, + CONF_USE_SPEAKER_BOOST: DEFAULT_USE_SPEAKER_BOOST, + } diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index 8b14ab26487..9ed96117daa 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -8,11 +8,25 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from elevenlabs.core import ApiError -from elevenlabs.types import GetVoicesResponse +from elevenlabs.types import GetVoicesResponse, VoiceSettings import pytest from homeassistant.components import tts -from homeassistant.components.elevenlabs.const import CONF_MODEL, CONF_VOICE, DOMAIN +from homeassistant.components.elevenlabs.const import ( + CONF_MODEL, + CONF_OPTIMIZE_LATENCY, + CONF_SIMILARITY, + CONF_STABILITY, + CONF_STYLE, + CONF_USE_SPEAKER_BOOST, + CONF_VOICE, + DEFAULT_OPTIMIZE_LATENCY, + DEFAULT_SIMILARITY, + DEFAULT_STABILITY, + DEFAULT_STYLE, + DEFAULT_USE_SPEAKER_BOOST, + DOMAIN, +) from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, @@ -53,17 +67,32 @@ async def setup_internal_url(hass: HomeAssistant) -> None: ) +@pytest.fixture +def mock_similarity(): + """Mock similarity.""" + return DEFAULT_SIMILARITY / 2 + + +@pytest.fixture +def mock_latency(): + """Mock latency.""" + return (DEFAULT_OPTIMIZE_LATENCY + 1) % 5 # 0, 1, 2, 3, 4 + + @pytest.fixture(name="setup") async def setup_fixture( hass: HomeAssistant, config_data: dict[str, Any], config_options: dict[str, Any], + config_options_voice: dict[str, Any], request: pytest.FixtureRequest, mock_async_client: AsyncMock, ) -> AsyncMock: """Set up the test environment.""" if request.param == "mock_config_entry_setup": await mock_config_entry_setup(hass, config_data, config_options) + elif request.param == "mock_config_entry_setup_voice": + await mock_config_entry_setup(hass, config_data, config_options_voice) else: raise RuntimeError("Invalid setup fixture") @@ -83,6 +112,18 @@ def config_options_fixture() -> dict[str, Any]: return {} +@pytest.fixture(name="config_options_voice") +def config_options_voice_fixture(mock_similarity, mock_latency) -> dict[str, Any]: + """Return config options.""" + return { + CONF_OPTIMIZE_LATENCY: mock_latency, + CONF_SIMILARITY: mock_similarity, + CONF_STABILITY: DEFAULT_STABILITY, + CONF_STYLE: DEFAULT_STYLE, + CONF_USE_SPEAKER_BOOST: DEFAULT_USE_SPEAKER_BOOST, + } + + async def mock_config_entry_setup( hass: HomeAssistant, config_data: dict[str, Any], config_options: dict[str, Any] ) -> None: @@ -146,6 +187,12 @@ async def test_tts_service_speak( """Test tts service.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) tts_entity._client.generate.reset_mock() + assert tts_entity._voice_settings == VoiceSettings( + stability=DEFAULT_STABILITY, + similarity_boost=DEFAULT_SIMILARITY, + style=DEFAULT_STYLE, + use_speaker_boost=DEFAULT_USE_SPEAKER_BOOST, + ) await hass.services.async_call( tts.DOMAIN, @@ -161,7 +208,11 @@ async def test_tts_service_speak( ) tts_entity._client.generate.assert_called_once_with( - text="There is a person at the front door.", voice="voice2", model="model1" + text="There is a person at the front door.", + voice="voice2", + model="model1", + voice_settings=tts_entity._voice_settings, + optimize_streaming_latency=tts_entity._latency, ) @@ -219,7 +270,11 @@ async def test_tts_service_speak_lang_config( ) tts_entity._client.generate.assert_called_once_with( - text="There is a person at the front door.", voice="voice1", model="model1" + text="There is a person at the front door.", + voice="voice1", + model="model1", + voice_settings=tts_entity._voice_settings, + optimize_streaming_latency=tts_entity._latency, ) @@ -266,5 +321,78 @@ async def test_tts_service_speak_error( ) tts_entity._client.generate.assert_called_once_with( - text="There is a person at the front door.", voice="voice1", model="model1" + text="There is a person at the front door.", + voice="voice1", + model="model1", + voice_settings=tts_entity._voice_settings, + optimize_streaming_latency=tts_entity._latency, + ) + + +@pytest.mark.parametrize( + "config_data", + [ + {}, + {tts.CONF_LANG: "de"}, + {tts.CONF_LANG: "en"}, + {tts.CONF_LANG: "ja"}, + {tts.CONF_LANG: "es"}, + ], +) +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup_voice", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_voice_settings( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], + mock_similarity: float, + mock_latency: int, +) -> None: + """Test tts service.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._client.generate.reset_mock() + assert tts_entity._voice_settings == VoiceSettings( + stability=DEFAULT_STABILITY, + similarity_boost=mock_similarity, + style=DEFAULT_STYLE, + use_speaker_boost=DEFAULT_USE_SPEAKER_BOOST, + ) + assert tts_entity._latency == mock_latency + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + + tts_entity._client.generate.assert_called_once_with( + text="There is a person at the front door.", + voice="voice2", + model="model1", + voice_settings=tts_entity._voice_settings, + optimize_streaming_latency=tts_entity._latency, ) From 1ffd797e0af32ad5a2452e13cb64df8cba5c9b91 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 8 Sep 2024 13:11:57 +0200 Subject: [PATCH 0390/1309] Clean up Mold indicator (#123080) * Improve code quality on mold_indicator * mypy * Fix mypy --- .strict-typing | 1 + .../components/mold_indicator/sensor.py | 113 +++++++++--------- mypy.ini | 10 ++ 3 files changed, 65 insertions(+), 59 deletions(-) diff --git a/.strict-typing b/.strict-typing index 84c22d1cfca..bea0b1be991 100644 --- a/.strict-typing +++ b/.strict-typing @@ -316,6 +316,7 @@ homeassistant.components.minecraft_server.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* homeassistant.components.modem_callerid.* +homeassistant.components.mold_indicator.* homeassistant.components.monzo.* homeassistant.components.moon.* homeassistant.components.mopeka.* diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 9064e0387e5..2d80bc9f6e1 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -4,13 +4,16 @@ from __future__ import annotations import logging import math +from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant import util from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, + SensorStateClass, ) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -30,7 +33,7 @@ from homeassistant.core import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import METRIC_SYSTEM @@ -67,11 +70,11 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up MoldIndicator sensor.""" - name = config.get(CONF_NAME, DEFAULT_NAME) - indoor_temp_sensor = config.get(CONF_INDOOR_TEMP) - outdoor_temp_sensor = config.get(CONF_OUTDOOR_TEMP) - indoor_humidity_sensor = config.get(CONF_INDOOR_HUMIDITY) - calib_factor = config.get(CONF_CALIBRATION_FACTOR) + name: str = config[CONF_NAME] + indoor_temp_sensor: str = config[CONF_INDOOR_TEMP] + outdoor_temp_sensor: str = config[CONF_OUTDOOR_TEMP] + indoor_humidity_sensor: str = config[CONF_INDOOR_HUMIDITY] + calib_factor: float = config[CONF_CALIBRATION_FACTOR] async_add_entities( [ @@ -92,36 +95,39 @@ class MoldIndicator(SensorEntity): """Represents a MoldIndication sensor.""" _attr_should_poll = False + _attr_native_unit_of_measurement = PERCENTAGE + _attr_device_class = SensorDeviceClass.HUMIDITY + _attr_state_class = SensorStateClass.MEASUREMENT def __init__( self, - name, - is_metric, - indoor_temp_sensor, - outdoor_temp_sensor, - indoor_humidity_sensor, - calib_factor, - ): + name: str, + is_metric: bool, + indoor_temp_sensor: str, + outdoor_temp_sensor: str, + indoor_humidity_sensor: str, + calib_factor: float, + ) -> None: """Initialize the sensor.""" - self._state = None - self._name = name + self._state: str | None = None + self._attr_name = name self._indoor_temp_sensor = indoor_temp_sensor self._indoor_humidity_sensor = indoor_humidity_sensor self._outdoor_temp_sensor = outdoor_temp_sensor self._calib_factor = calib_factor self._is_metric = is_metric - self._available = False + self._attr_available = False self._entities = { - self._indoor_temp_sensor, - self._indoor_humidity_sensor, - self._outdoor_temp_sensor, + indoor_temp_sensor, + indoor_humidity_sensor, + outdoor_temp_sensor, } - self._dewpoint = None - self._indoor_temp = None - self._outdoor_temp = None - self._indoor_hum = None - self._crit_temp = None + self._dewpoint: float | None = None + self._indoor_temp: float | None = None + self._outdoor_temp: float | None = None + self._indoor_hum: float | None = None + self._crit_temp: float | None = None async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -145,7 +151,7 @@ class MoldIndicator(SensorEntity): self.async_schedule_update_ha_state(True) @callback - def mold_indicator_startup(event): + def mold_indicator_startup(event: Event) -> None: """Add listeners and get 1st state.""" _LOGGER.debug("Startup for %s", self.entity_id) @@ -199,11 +205,11 @@ class MoldIndicator(SensorEntity): return False if entity == self._indoor_temp_sensor: - self._indoor_temp = MoldIndicator._update_temp_sensor(new_state) + self._indoor_temp = self._update_temp_sensor(new_state) elif entity == self._outdoor_temp_sensor: - self._outdoor_temp = MoldIndicator._update_temp_sensor(new_state) + self._outdoor_temp = self._update_temp_sensor(new_state) elif entity == self._indoor_humidity_sensor: - self._indoor_hum = MoldIndicator._update_hum_sensor(new_state) + self._indoor_hum = self._update_hum_sensor(new_state) return True @@ -295,7 +301,7 @@ class MoldIndicator(SensorEntity): _LOGGER.debug("Update state for %s", self.entity_id) # check all sensors if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp): - self._available = False + self._attr_available = False self._dewpoint = None self._crit_temp = None return @@ -304,15 +310,17 @@ class MoldIndicator(SensorEntity): self._calc_dewpoint() self._calc_moldindicator() if self._state is None: - self._available = False + self._attr_available = False self._dewpoint = None self._crit_temp = None else: - self._available = True + self._attr_available = True - def _calc_dewpoint(self): + def _calc_dewpoint(self) -> None: """Calculate the dewpoint for the indoor air.""" # Use magnus approximation to calculate the dew point + if TYPE_CHECKING: + assert self._indoor_temp and self._indoor_hum alpha = MAGNUS_K2 * self._indoor_temp / (MAGNUS_K3 + self._indoor_temp) beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._indoor_temp) @@ -326,8 +334,11 @@ class MoldIndicator(SensorEntity): ) _LOGGER.debug("Dewpoint: %f %s", self._dewpoint, UnitOfTemperature.CELSIUS) - def _calc_moldindicator(self): + def _calc_moldindicator(self) -> None: """Calculate the humidity at the (cold) calibration point.""" + if TYPE_CHECKING: + assert self._outdoor_temp and self._indoor_temp and self._dewpoint + if None in (self._dewpoint, self._calib_factor) or self._calib_factor == 0: _LOGGER.debug( "Invalid inputs - dewpoint: %s, calibration-factor: %s", @@ -335,7 +346,7 @@ class MoldIndicator(SensorEntity): self._calib_factor, ) self._state = None - self._available = False + self._attr_available = False self._crit_temp = None return @@ -374,37 +385,21 @@ class MoldIndicator(SensorEntity): _LOGGER.debug("Mold indicator humidity: %s", self._state) @property - def name(self): - """Return the name.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return PERCENTAGE - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the entity.""" return self._state @property - def available(self): - """Return the availability of this sensor.""" - return self._available - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self._is_metric: - return { - ATTR_DEWPOINT: round(self._dewpoint, 2), - ATTR_CRITICAL_TEMP: round(self._crit_temp, 2), - } + convert_to = UnitOfTemperature.CELSIUS + else: + convert_to = UnitOfTemperature.FAHRENHEIT dewpoint = ( TemperatureConverter.convert( - self._dewpoint, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT + self._dewpoint, UnitOfTemperature.CELSIUS, convert_to ) if self._dewpoint is not None else None @@ -412,13 +407,13 @@ class MoldIndicator(SensorEntity): crit_temp = ( TemperatureConverter.convert( - self._crit_temp, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT + self._crit_temp, UnitOfTemperature.CELSIUS, convert_to ) if self._crit_temp is not None else None ) return { - ATTR_DEWPOINT: round(dewpoint, 2), - ATTR_CRITICAL_TEMP: round(crit_temp, 2), + ATTR_DEWPOINT: round(dewpoint, 2) if dewpoint else None, + ATTR_CRITICAL_TEMP: round(crit_temp, 2) if crit_temp else None, } diff --git a/mypy.ini b/mypy.ini index 2686fbe3062..d7604012305 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2916,6 +2916,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.mold_indicator.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.monzo.*] check_untyped_defs = true disallow_incomplete_defs = true From 5b434aae6ed5fdf015cfe5b309071edfb2b50c02 Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Sun, 8 Sep 2024 13:45:12 +0200 Subject: [PATCH 0391/1309] Add DeviceInfo to Bring integration (#122419) * Add DeviceInfo to Bring integration * deeplink to shopping list * Move device info to a entity base class --- homeassistant/components/bring/entity.py | 37 ++++++++++++++++++++++++ homeassistant/components/bring/todo.py | 28 +++--------------- 2 files changed, 41 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/bring/entity.py diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py new file mode 100644 index 00000000000..c5e0b84a190 --- /dev/null +++ b/homeassistant/components/bring/entity.py @@ -0,0 +1,37 @@ +"""Base entity for the Bring! integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import BringData, BringDataUpdateCoordinator + + +class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): + """Bring base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BringDataUpdateCoordinator, + bring_list: BringData, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._list_uuid = bring_list["listUuid"] + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{self._list_uuid}" + + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=bring_list["name"], + identifiers={ + (DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}") + }, + manufacturer="Bring! Labs AG", + model="Bring! Grocery Shopping List", + configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.data.keys()).index(self._list_uuid)}", + ) diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 4fb90860899..97d7eba48bd 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BringConfigEntry from .const import ( @@ -32,7 +31,8 @@ from .const import ( DOMAIN, SERVICE_PUSH_NOTIFICATION, ) -from .coordinator import BringData, BringDataUpdateCoordinator +from .coordinator import BringData +from .entity import BringBaseEntity async def async_setup_entry( @@ -43,16 +43,10 @@ async def async_setup_entry( """Set up the sensor from a config entry created in the integrations UI.""" coordinator = config_entry.runtime_data - unique_id = config_entry.unique_id - - if TYPE_CHECKING: - assert unique_id - async_add_entities( BringTodoListEntity( coordinator, bring_list=bring_list, - unique_id=unique_id, ) for bring_list in coordinator.data.values() ) @@ -71,13 +65,11 @@ async def async_setup_entry( ) -class BringTodoListEntity( - CoordinatorEntity[BringDataUpdateCoordinator], TodoListEntity -): +class BringTodoListEntity(BringBaseEntity, TodoListEntity): """A To-do List representation of the Bring! Shopping List.""" _attr_translation_key = "shopping_list" - _attr_has_entity_name = True + _attr_name = None _attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM @@ -85,18 +77,6 @@ class BringTodoListEntity( | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) - def __init__( - self, - coordinator: BringDataUpdateCoordinator, - bring_list: BringData, - unique_id: str, - ) -> None: - """Initialize BringTodoListEntity.""" - super().__init__(coordinator) - self._list_uuid = bring_list["listUuid"] - self._attr_name = bring_list["name"] - self._attr_unique_id = f"{unique_id}_{self._list_uuid}" - @property def todo_items(self) -> list[TodoItem]: """Return the todo items.""" From d4f0aaa089a306877c18a5cc61234bf3ad1ccc03 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sun, 8 Sep 2024 13:50:36 +0200 Subject: [PATCH 0392/1309] Add last restart sensor to devolo_home_network (#122190) * Add last restart sensor to devolo_home_network * Add missing test * Rename fetch function * Fix mypy --- .../devolo_home_network/__init__.py | 22 +++++ .../components/devolo_home_network/const.py | 1 + .../components/devolo_home_network/entity.py | 1 + .../components/devolo_home_network/sensor.py | 85 ++++++++++++++----- .../devolo_home_network/strings.json | 3 + tests/components/devolo_home_network/const.py | 2 + tests/components/devolo_home_network/mock.py | 2 + .../snapshots/test_sensor.ambr | 59 +++++++++++-- .../devolo_home_network/test_sensor.py | 71 +++++++++++++--- 9 files changed, 207 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 59aafb1eb9c..f8a0f015543 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -39,6 +39,7 @@ from .const import ( CONNECTED_WIFI_CLIENTS, DOMAIN, FIRMWARE_UPDATE_INTERVAL, + LAST_RESTART, LONG_UPDATE_INTERVAL, NEIGHBORING_WIFI_NETWORKS, REGULAR_FIRMWARE, @@ -127,6 +128,19 @@ async def async_setup_entry( except DeviceUnavailable as err: raise UpdateFailed(err) from err + async def async_update_last_restart() -> int: + """Fetch data from API endpoint.""" + assert device.device + update_sw_version(device_registry, device) + try: + return await device.device.async_uptime() + except DeviceUnavailable as err: + raise UpdateFailed(err) from err + except DevicePasswordProtected as err: + raise ConfigEntryAuthFailed( + err, translation_domain=DOMAIN, translation_key="password_wrong" + ) from err + async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]: """Fetch data from API endpoint.""" assert device.device @@ -166,6 +180,14 @@ async def async_setup_entry( update_method=async_update_led_status, update_interval=SHORT_UPDATE_INTERVAL, ) + if device.device and "restart" in device.device.features: + coordinators[LAST_RESTART] = DataUpdateCoordinator( + hass, + _LOGGER, + name=LAST_RESTART, + update_method=async_update_last_restart, + update_interval=SHORT_UPDATE_INTERVAL, + ) if device.device and "update" in device.device.features: coordinators[REGULAR_FIRMWARE] = DataUpdateCoordinator( hass, diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index 4caa4f5b60b..92b97d59423 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -23,6 +23,7 @@ CONNECTED_TO_ROUTER = "connected_to_router" CONNECTED_WIFI_CLIENTS = "connected_wifi_clients" IDENTIFY = "identify" IMAGE_GUEST_WIFI = "image_guest_wifi" +LAST_RESTART = "last_restart" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" PAIRING = "pairing" PLC_RX_RATE = "plc_rx_rate" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 9d469ccfb16..d381f48ca05 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -26,6 +26,7 @@ type _DataType = ( | list[NeighborAPInfo] | WifiGuestAccessGet | bool + | int ) diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 2fd8ab9220c..667bbc2c557 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime, timedelta from enum import StrEnum from typing import Any, Generic, TypeVar @@ -20,11 +21,13 @@ from homeassistant.const import EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util.dt import utcnow from . import DevoloHomeNetworkConfigEntry from .const import ( CONNECTED_PLC_DEVICES, CONNECTED_WIFI_CLIENTS, + LAST_RESTART, NEIGHBORING_WIFI_NETWORKS, PLC_RX_RATE, PLC_TX_RATE, @@ -33,13 +36,36 @@ from .entity import DevoloCoordinatorEntity PARALLEL_UPDATES = 1 + +def _last_restart(runtime: int) -> datetime: + """Calculate uptime. As fetching the data might also take some time, let's floor to the nearest 5 seconds.""" + now = utcnow() + return ( + now + - timedelta(seconds=runtime) + - timedelta(seconds=(now.timestamp() - runtime) % 5) + ) + + _CoordinatorDataT = TypeVar( "_CoordinatorDataT", - bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], + bound=LogicalNetwork + | DataRate + | list[ConnectedStationInfo] + | list[NeighborAPInfo] + | int, ) _ValueDataT = TypeVar( "_ValueDataT", - bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], + bound=LogicalNetwork + | DataRate + | list[ConnectedStationInfo] + | list[NeighborAPInfo] + | int, +) +_SensorDataT = TypeVar( + "_SensorDataT", + bound=int | float | datetime, ) @@ -52,15 +78,15 @@ class DataRateDirection(StrEnum): @dataclass(frozen=True, kw_only=True) class DevoloSensorEntityDescription( - SensorEntityDescription, Generic[_CoordinatorDataT] + SensorEntityDescription, Generic[_CoordinatorDataT, _SensorDataT] ): """Describes devolo sensor entity.""" - value_func: Callable[[_CoordinatorDataT], float] + value_func: Callable[[_CoordinatorDataT], _SensorDataT] -SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { - CONNECTED_PLC_DEVICES: DevoloSensorEntityDescription[LogicalNetwork]( +SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any, Any]] = { + CONNECTED_PLC_DEVICES: DevoloSensorEntityDescription[LogicalNetwork, int]( key=CONNECTED_PLC_DEVICES, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -68,18 +94,20 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { {device.mac_address_from for device in data.data_rates} ), ), - CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]]( + CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[ + list[ConnectedStationInfo], int + ]( key=CONNECTED_WIFI_CLIENTS, state_class=SensorStateClass.MEASUREMENT, value_func=len, ), - NEIGHBORING_WIFI_NETWORKS: DevoloSensorEntityDescription[list[NeighborAPInfo]]( + NEIGHBORING_WIFI_NETWORKS: DevoloSensorEntityDescription[list[NeighborAPInfo], int]( key=NEIGHBORING_WIFI_NETWORKS, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_func=len, ), - PLC_RX_RATE: DevoloSensorEntityDescription[DataRate]( + PLC_RX_RATE: DevoloSensorEntityDescription[DataRate, float]( key=PLC_RX_RATE, entity_category=EntityCategory.DIAGNOSTIC, name="PLC downlink PHY rate", @@ -88,7 +116,7 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { value_func=lambda data: getattr(data, DataRateDirection.RX, 0), suggested_display_precision=0, ), - PLC_TX_RATE: DevoloSensorEntityDescription[DataRate]( + PLC_TX_RATE: DevoloSensorEntityDescription[DataRate, float]( key=PLC_TX_RATE, entity_category=EntityCategory.DIAGNOSTIC, name="PLC uplink PHY rate", @@ -97,6 +125,13 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { value_func=lambda data: getattr(data, DataRateDirection.TX, 0), suggested_display_precision=0, ), + LAST_RESTART: DevoloSensorEntityDescription[int, datetime]( + key=LAST_RESTART, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + value_func=_last_restart, + ), } @@ -109,7 +144,7 @@ async def async_setup_entry( device = entry.runtime_data.device coordinators = entry.runtime_data.coordinators - entities: list[BaseDevoloSensorEntity[Any, Any]] = [] + entities: list[BaseDevoloSensorEntity[Any, Any, Any]] = [] if device.plcnet: entities.append( DevoloSensorEntity( @@ -139,6 +174,14 @@ async def async_setup_entry( peer, ) ) + if device.device and "restart" in device.device.features: + entities.append( + DevoloSensorEntity( + entry, + coordinators[LAST_RESTART], + SENSOR_TYPES[LAST_RESTART], + ) + ) if device.device and "wifi1" in device.device.features: entities.append( DevoloSensorEntity( @@ -158,7 +201,7 @@ async def async_setup_entry( class BaseDevoloSensorEntity( - Generic[_CoordinatorDataT, _ValueDataT], + Generic[_CoordinatorDataT, _ValueDataT, _SensorDataT], DevoloCoordinatorEntity[_CoordinatorDataT], SensorEntity, ): @@ -168,34 +211,38 @@ class BaseDevoloSensorEntity( self, entry: DevoloHomeNetworkConfigEntry, coordinator: DataUpdateCoordinator[_CoordinatorDataT], - description: DevoloSensorEntityDescription[_ValueDataT], + description: DevoloSensorEntityDescription[_ValueDataT, _SensorDataT], ) -> None: """Initialize entity.""" self.entity_description = description super().__init__(entry, coordinator) -class DevoloSensorEntity(BaseDevoloSensorEntity[_CoordinatorDataT, _CoordinatorDataT]): +class DevoloSensorEntity( + BaseDevoloSensorEntity[_CoordinatorDataT, _CoordinatorDataT, _SensorDataT] +): """Representation of a generic devolo sensor.""" - entity_description: DevoloSensorEntityDescription[_CoordinatorDataT] + entity_description: DevoloSensorEntityDescription[_CoordinatorDataT, _SensorDataT] @property - def native_value(self) -> float: + def native_value(self) -> int | float | datetime: """State of the sensor.""" return self.entity_description.value_func(self.coordinator.data) -class DevoloPlcDataRateSensorEntity(BaseDevoloSensorEntity[LogicalNetwork, DataRate]): +class DevoloPlcDataRateSensorEntity( + BaseDevoloSensorEntity[LogicalNetwork, DataRate, float] +): """Representation of a devolo PLC data rate sensor.""" - entity_description: DevoloSensorEntityDescription[DataRate] + entity_description: DevoloSensorEntityDescription[DataRate, float] def __init__( self, entry: DevoloHomeNetworkConfigEntry, coordinator: DataUpdateCoordinator[LogicalNetwork], - description: DevoloSensorEntityDescription[DataRate], + description: DevoloSensorEntityDescription[DataRate, float], peer: str, ) -> None: """Initialize entity.""" diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 97348c5c43c..0799bb14172 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -60,6 +60,9 @@ "connected_wifi_clients": { "name": "Connected Wi-Fi clients" }, + "last_restart": { + "name": "Last restart of the device" + }, "neighboring_wifi_networks": { "name": "Neighboring Wi-Fi networks" }, diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 9d8faab9b13..7b0551b1daf 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -171,3 +171,5 @@ PLCNET_ATTACHED = LogicalNetwork( }, ], ) + +UPTIME = 100 diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py index 4b999667e53..fc7786669b7 100644 --- a/tests/components/devolo_home_network/mock.py +++ b/tests/components/devolo_home_network/mock.py @@ -19,6 +19,7 @@ from .const import ( IP, NEIGHBOR_ACCESS_POINTS, PLCNET, + UPTIME, ) @@ -64,6 +65,7 @@ class MockDevice(Device): ) self.device.async_get_led_setting = AsyncMock(return_value=False) self.device.async_restart = AsyncMock(return_value=True) + self.device.async_uptime = AsyncMock(return_value=UPTIME) self.device.async_start_wps = AsyncMock(return_value=True) self.device.async_get_wifi_connected_station = AsyncMock( return_value=CONNECTED_STATIONS diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index d985ac35495..2e6730cdb21 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor[connected_plc_devices-async_get_network_overview-interval2] +# name: test_sensor[connected_plc_devices-async_get_network_overview-interval2-1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Title Connected PLC devices', @@ -12,7 +12,7 @@ 'state': '1', }) # --- -# name: test_sensor[connected_plc_devices-async_get_network_overview-interval2].1 +# name: test_sensor[connected_plc_devices-async_get_network_overview-interval2-1].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -45,7 +45,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[connected_wi_fi_clients-async_get_wifi_connected_station-interval0] +# name: test_sensor[connected_wi_fi_clients-async_get_wifi_connected_station-interval0-1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Title Connected Wi-Fi clients', @@ -59,7 +59,7 @@ 'state': '1', }) # --- -# name: test_sensor[connected_wi_fi_clients-async_get_wifi_connected_station-interval0].1 +# name: test_sensor[connected_wi_fi_clients-async_get_wifi_connected_station-interval0-1].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -94,7 +94,54 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[neighboring_wi_fi_networks-async_get_wifi_neighbor_access_points-interval1] +# name: test_sensor[last_restart_of_the_device-async_uptime-interval3-2023-01-13T11:58:50+00:00] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Last restart of the device', + }), + 'context': , + 'entity_id': 'sensor.mock_title_last_restart_of_the_device', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-01-13T11:58:20+00:00', + }) +# --- +# name: test_sensor[last_restart_of_the_device-async_uptime-interval3-2023-01-13T11:58:50+00:00].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_last_restart_of_the_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last restart of the device', + 'platform': 'devolo_home_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_restart', + 'unique_id': '1234567890_last_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[neighboring_wi_fi_networks-async_get_wifi_neighbor_access_points-interval1-1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Title Neighboring Wi-Fi networks', @@ -107,7 +154,7 @@ 'state': '1', }) # --- -# name: test_sensor[neighboring_wi_fi_networks-async_get_wifi_neighbor_access_points-interval1].1 +# name: test_sensor[neighboring_wi_fi_networks-async_get_wifi_neighbor_access_points-interval1-1].1 EntityRegistryEntrySnapshot({ 'aliases': set({ }), diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index efcbaa803df..cf0207a2800 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -3,16 +3,18 @@ from datetime import timedelta from unittest.mock import AsyncMock -from devolo_plc_api.exceptions.device import DeviceUnavailable +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.devolo_home_network.const import ( + DOMAIN, LONG_UPDATE_INTERVAL, SHORT_UPDATE_INTERVAL, ) -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import DOMAIN as PLATFORM +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -33,59 +35,74 @@ async def test_sensor_setup(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert ( - hass.states.get(f"{DOMAIN}.{device_name}_connected_wi_fi_clients") is not None + hass.states.get(f"{PLATFORM}.{device_name}_connected_wi_fi_clients") is not None + ) + assert hass.states.get(f"{PLATFORM}.{device_name}_connected_plc_devices") is None + assert ( + hass.states.get(f"{PLATFORM}.{device_name}_neighboring_wi_fi_networks") is None ) - assert hass.states.get(f"{DOMAIN}.{device_name}_connected_plc_devices") is None - assert hass.states.get(f"{DOMAIN}.{device_name}_neighboring_wi_fi_networks") is None assert ( hass.states.get( - f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" ) is not None ) assert ( hass.states.get( - f"{DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" ) is not None ) assert ( hass.states.get( - f"{DOMAIN}.{device_name}_plc_downlink_phyrate_{PLCNET.devices[2].user_device_name}" + f"{PLATFORM}.{device_name}_plc_downlink_phyrate_{PLCNET.devices[2].user_device_name}" ) is None ) assert ( hass.states.get( - f"{DOMAIN}.{device_name}_plc_uplink_phyrate_{PLCNET.devices[2].user_device_name}" + f"{PLATFORM}.{device_name}_plc_uplink_phyrate_{PLCNET.devices[2].user_device_name}" ) is None ) + assert ( + hass.states.get(f"{PLATFORM}.{device_name}_last_restart_of_the_device") is None + ) await hass.config_entries.async_unload(entry.entry_id) @pytest.mark.parametrize( - ("name", "get_method", "interval"), + ("name", "get_method", "interval", "expected_state"), [ ( "connected_wi_fi_clients", "async_get_wifi_connected_station", SHORT_UPDATE_INTERVAL, + "1", ), ( "neighboring_wi_fi_networks", "async_get_wifi_neighbor_access_points", LONG_UPDATE_INTERVAL, + "1", ), ( "connected_plc_devices", "async_get_network_overview", LONG_UPDATE_INTERVAL, + "1", + ), + ( + "last_restart_of_the_device", + "async_uptime", + SHORT_UPDATE_INTERVAL, + "2023-01-13T11:58:50+00:00", ), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") async def test_sensor( hass: HomeAssistant, mock_device: MockDevice, @@ -95,11 +112,12 @@ async def test_sensor( name: str, get_method: str, interval: timedelta, + expected_state: str, ) -> None: """Test state change of a sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{DOMAIN}.{device_name}_{name}" + state_key = f"{PLATFORM}.{device_name}_{name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -125,7 +143,7 @@ async def test_sensor( state = hass.states.get(state_key) assert state is not None - assert state.state == "1" + assert state.state == expected_state await hass.config_entries.async_unload(entry.entry_id) @@ -140,8 +158,8 @@ async def test_update_plc_phyrates( """Test state change of plc_downlink_phyrate and plc_uplink_phyrate sensor devices.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key_downlink = f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" - state_key_uplink = f"{DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + state_key_downlink = f"{PLATFORM}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + state_key_uplink = f"{PLATFORM}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -181,3 +199,28 @@ async def test_update_plc_phyrates( assert state.state == str(PLCNET.data_rates[0].tx_rate) await hass.config_entries.async_unload(entry.entry_id) + + +async def test_update_last_update_auth_failed( + hass: HomeAssistant, mock_device: MockDevice +) -> None: + """Test getting the last update state with wrong password triggers the reauth flow.""" + entry = configure_integration(hass) + mock_device.device.async_uptime.side_effect = DevicePasswordProtected + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow["step_id"] == "reauth_confirm" + assert flow["handler"] == DOMAIN + + assert "context" in flow + assert flow["context"]["source"] == SOURCE_REAUTH + assert flow["context"]["entry_id"] == entry.entry_id + + await hass.config_entries.async_unload(entry.entry_id) From 2b2f5d6693d9be6e239442b5ed86b11d3ecb0d03 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sun, 8 Sep 2024 07:56:42 -0400 Subject: [PATCH 0393/1309] Add sleep to map select for Roborock (#122625) * Add sleep to map select * Update homeassistant/components/roborock/select.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/roborock/select.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index f047ec475c2..d9e87fbcd08 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -1,5 +1,6 @@ """Support for Roborock select.""" +import asyncio from collections.abc import Callable from dataclasses import dataclass @@ -13,6 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RoborockConfigEntry +from .const import MAP_SLEEP from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntityV1 @@ -133,6 +135,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity): RoborockCommand.LOAD_MULTI_MAP, [map_id], ) + # We need to wait after updating the map + # so that other commands will be executed correctly. + await asyncio.sleep(MAP_SLEEP) break @property From 926ffe536cdbeb0ae9f906623a56e8fa931a6e0d Mon Sep 17 00:00:00 2001 From: dougiteixeira <31328123+dougiteixeira@users.noreply.github.com> Date: Sun, 8 Sep 2024 08:59:54 -0300 Subject: [PATCH 0394/1309] Fix UI config validation for button and switch actions in Template (#121810) Fix IU config validation for button and switch actions in Template --- homeassistant/components/template/button.py | 2 +- homeassistant/components/template/switch.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 52435d88971..67ce7e7a16b 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -51,7 +51,7 @@ BUTTON_SCHEMA = ( CONFIG_BUTTON_SCHEMA = vol.Schema( { vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_PRESS): selector.ActionSelector(), + vol.Optional(CONF_PRESS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 9145625f706..bddb51e5e67 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -64,8 +64,8 @@ SWITCH_CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.template, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_TURN_ON): selector.ActionSelector(), - vol.Optional(CONF_TURN_OFF): selector.ActionSelector(), + vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } ) From c0ee12ca415035277d04e1f5b4ab14291fc1bd54 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Sun, 8 Sep 2024 14:00:34 +0200 Subject: [PATCH 0395/1309] Add translation to Jellyfin (#123857) * Add translation to Jellyfin * Fix * Address feedback --- homeassistant/components/jellyfin/sensor.py | 4 ++-- homeassistant/components/jellyfin/strings.json | 7 +++++++ tests/components/jellyfin/test_sensor.py | 12 +++--------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index 3be4ccf2559..37926567b4e 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -35,9 +35,8 @@ SENSOR_TYPES: dict[str, JellyfinSensorEntityDescription] = { "sessions": JellyfinSensorEntityDescription( key="watching", translation_key="watching", - name=None, - native_unit_of_measurement="Watching", value_fn=_count_now_playing, + native_unit_of_measurement="clients", ) } @@ -59,6 +58,7 @@ async def async_setup_entry( class JellyfinSensor(JellyfinEntity, SensorEntity): """Defines a Jellyfin sensor entity.""" + _attr_has_entity_name = True entity_description: JellyfinSensorEntityDescription @property diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index fd11d8fbad2..f2afa0c8ad5 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -26,6 +26,13 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "entity": { + "sensor": { + "watching": { + "name": "Active clients" + } + } + }, "options": { "step": { "init": { diff --git a/tests/components/jellyfin/test_sensor.py b/tests/components/jellyfin/test_sensor.py index 40a3e62a6c0..82d42d7a27a 100644 --- a/tests/components/jellyfin/test_sensor.py +++ b/tests/components/jellyfin/test_sensor.py @@ -4,12 +4,7 @@ from unittest.mock import MagicMock from homeassistant.components.jellyfin.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, -) +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -24,13 +19,12 @@ async def test_watching( mock_jellyfin: MagicMock, ) -> None: """Test the Jellyfin watching sensor.""" - state = hass.states.get("sensor.jellyfin_server") + state = hass.states.get("sensor.jellyfin_server_active_clients") assert state assert state.attributes.get(ATTR_DEVICE_CLASS) is None - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-SERVER" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-SERVER Active clients" assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_STATE_CLASS) is None - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Watching" assert state.state == "3" entry = entity_registry.async_get(state.entity_id) From 26ede9a6790dd8da6104a5f25c29fe23e23ce800 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 8 Sep 2024 14:06:40 +0200 Subject: [PATCH 0396/1309] Fix yale_smart_alarm on missing key (#125508) --- .../yale_smart_alarm/coordinator.py | 13 +- tests/components/yale_smart_alarm/conftest.py | 24 +- .../snapshots/test_diagnostics.ambr | 1644 +++++++++-------- .../components/yale_smart_alarm/test_lock.py | 6 +- 4 files changed, 854 insertions(+), 833 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index b47545ea88b..3bfd13b2152 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -154,10 +154,15 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): except YALE_BASE_ERRORS as error: raise UpdateFailed from error + cycle = data.cycle["data"] if data.cycle else None + status = data.status["data"] if data.status else None + online = data.online["data"] if data.online else None + panel_info = data.panel_info["data"] if data.panel_info else None + return { "arm_status": arm_status, - "cycle": data.cycle, - "status": data.status, - "online": data.online, - "panel_info": data.panel_info, + "cycle": cycle, + "status": status, + "online": online, + "panel_info": panel_info, } diff --git a/tests/components/yale_smart_alarm/conftest.py b/tests/components/yale_smart_alarm/conftest.py index 6ac6dfc6871..0499b6212d6 100644 --- a/tests/components/yale_smart_alarm/conftest.py +++ b/tests/components/yale_smart_alarm/conftest.py @@ -82,10 +82,10 @@ def get_fixture_data() -> dict[str, Any]: def get_update_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData: """Load update data and return.""" - status = loaded_fixture["STATUS"] - cycle = loaded_fixture["CYCLE"] - online = loaded_fixture["ONLINE"] - panel_info = loaded_fixture["PANEL INFO"] + status = {"data": loaded_fixture["STATUS"]} + cycle = {"data": loaded_fixture["CYCLE"]} + online = {"data": loaded_fixture["ONLINE"]} + panel_info = {"data": loaded_fixture["PANEL INFO"]} return YaleSmartAlarmData( status=status, cycle=cycle, @@ -98,14 +98,14 @@ def get_update_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData: def get_diag_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData: """Load all data and return.""" - devices = loaded_fixture["DEVICES"] - mode = loaded_fixture["MODE"] - status = loaded_fixture["STATUS"] - cycle = loaded_fixture["CYCLE"] - online = loaded_fixture["ONLINE"] - history = loaded_fixture["HISTORY"] - panel_info = loaded_fixture["PANEL INFO"] - auth_check = loaded_fixture["AUTH CHECK"] + devices = {"data": loaded_fixture["DEVICES"]} + mode = {"data": loaded_fixture["MODE"]} + status = {"data": loaded_fixture["STATUS"]} + cycle = {"data": loaded_fixture["CYCLE"]} + online = {"data": loaded_fixture["ONLINE"]} + history = {"data": loaded_fixture["HISTORY"]} + panel_info = {"data": loaded_fixture["PANEL INFO"]} + auth_check = {"data": loaded_fixture["AUTH CHECK"]} return YaleSmartAlarmData( devices=devices, mode=mode, diff --git a/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr index d4bbd42aaeb..750430b529a 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr @@ -2,27 +2,661 @@ # name: test_diagnostics dict({ 'auth_check': dict({ - 'agent': False, - 'dealer_group': 'yale', - 'dealer_id': '605', - 'first_login': '1', - 'id': '**REDACTED**', - 'is_auth': '1', - 'mac': '**REDACTED**', - 'mail_address': '**REDACTED**', - 'master': '1', - 'name': '**REDACTED**', - 'token_time': '2023-08-17 16:19:20', - 'user_id': '**REDACTED**', - 'xml_version': '2', + 'data': dict({ + 'agent': False, + 'dealer_group': 'yale', + 'dealer_id': '605', + 'first_login': '1', + 'id': '**REDACTED**', + 'is_auth': '1', + 'mac': '**REDACTED**', + 'mail_address': '**REDACTED**', + 'master': '1', + 'name': '**REDACTED**', + 'token_time': '2023-08-17 16:19:20', + 'user_id': '**REDACTED**', + 'xml_version': '2', + }), }), 'cycle': dict({ - 'alarm_event_latest': None, - 'capture_latest': None, - 'device_status': list([ + 'data': dict({ + 'alarm_event_latest': None, + 'capture_latest': None, + 'device_status': list([ + dict({ + '_state': 'locked', + '_state2': 'closed', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '35', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '1', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'unlocked', + '_state2': 'unknown', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': None, + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '2', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.unlock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'locked', + '_state2': 'unknown', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': None, + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '3', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'closed', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '4', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.dc_close', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.dc_close', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + '_state': 'open', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '5', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.dc_open', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.dc_open', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + '_state': 'unavailable', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '000', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '6', + 'rf': None, + 'rssi': '0', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'unknwon', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_contact', + 'type_no': '4', + }), + dict({ + '_state': 'unlocked', + '_state2': 'closed', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '36', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '7', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.lock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.lock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'unlocked', + '_state2': 'open', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '4', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '8', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.unlock', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.unlock', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + '_state': 'unavailable', + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '002', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '02FF000001000000000000000000001E000100', + 'minigw_lock_status': '10', + 'minigw_number_of_credentials_supported': '10', + 'minigw_product_data': '21020120', + 'minigw_protocol': 'DM', + 'minigw_syncing': '0', + 'name': '**REDACTED**', + 'no': '9', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': 'device_status.error', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + 'device_status.error', + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': None, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.door_lock', + 'type_no': '72', + }), + dict({ + 'address': '**REDACTED**', + 'area': '1', + 'bypass': '0', + 'device_group': '001', + 'device_id': '**REDACTED**', + 'device_id2': '', + 'extension': None, + 'group_id': None, + 'group_name': None, + 'ipcam_trigger_by_zone1': None, + 'ipcam_trigger_by_zone2': None, + 'ipcam_trigger_by_zone3': None, + 'ipcam_trigger_by_zone4': None, + 'mac': '**REDACTED**', + 'minigw_configuration_data': '', + 'minigw_lock_status': '', + 'minigw_number_of_credentials_supported': '', + 'minigw_product_data': '', + 'minigw_protocol': '', + 'minigw_syncing': '', + 'name': '**REDACTED**', + 'no': '8', + 'rf': None, + 'rssi': '9', + 'scene_restore': None, + 'scene_trigger': '0', + 'sresp_button_1': None, + 'sresp_button_2': None, + 'sresp_button_3': None, + 'sresp_button_4': None, + 'status1': '', + 'status2': None, + 'status_dim_level': None, + 'status_fault': list([ + ]), + 'status_hue': None, + 'status_humi': None, + 'status_lux': '', + 'status_open': list([ + ]), + 'status_power': None, + 'status_saturation': None, + 'status_switch': None, + 'status_temp': 21, + 'status_temp_format': 'C', + 'status_total_energy': None, + 'thermo_c_setpoint': None, + 'thermo_c_setpoint_away': None, + 'thermo_fan_mode': None, + 'thermo_mode': None, + 'thermo_schd_setting': None, + 'thermo_setpoint': None, + 'thermo_setpoint_away': None, + 'trigger_by_zone': list([ + ]), + 'type': 'device_type.temperature_sensor', + 'type_no': '40', + }), + ]), + 'model': list([ + dict({ + 'area': '1', + 'mode': 'disarm', + }), + ]), + 'panel_status': dict({ + 'warning_snd_mute': '0', + }), + 'report_event_latest': dict({ + 'cid_code': '1807', + 'event_time': None, + 'id': '**REDACTED**', + 'report_id': '1027299996', + 'time': '1692271914', + 'utc_event_time': None, + }), + }), + }), + 'devices': dict({ + 'data': list([ dict({ - '_state': 'locked', - '_state2': 'closed', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -83,8 +717,6 @@ 'type_no': '72', }), dict({ - '_state': 'unlocked', - '_state2': 'unknown', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -144,8 +776,6 @@ 'type_no': '72', }), dict({ - '_state': 'locked', - '_state2': 'unknown', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -206,7 +836,6 @@ 'type_no': '72', }), dict({ - '_state': 'closed', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -267,7 +896,6 @@ 'type_no': '4', }), dict({ - '_state': 'open', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -328,7 +956,6 @@ 'type_no': '4', }), dict({ - '_state': 'unavailable', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -388,8 +1015,6 @@ 'type_no': '4', }), dict({ - '_state': 'unlocked', - '_state2': 'closed', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -450,8 +1075,6 @@ 'type_no': '72', }), dict({ - '_state': 'unlocked', - '_state2': 'open', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -512,7 +1135,6 @@ 'type_no': '72', }), dict({ - '_state': 'unavailable', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -632,799 +1254,193 @@ 'type_no': '40', }), ]), - 'model': list([ + }), + 'history': dict({ + 'data': list([ + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027299996', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:31:54', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180201101', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1802', + 'name': '**REDACTED**', + 'report_id': '1027299889', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:31:43', + 'type': 'device_type.door_lock', + 'user': 101, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027299587', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:31:11', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180101001', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1801', + 'name': '**REDACTED**', + 'report_id': '1027296099', + 'status_temp_format': 'C', + 'time': '2023/08/17 11:24:52', + 'type': 'device_type.door_lock', + 'user': 1, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027273782', + 'status_temp_format': 'C', + 'time': '2023/08/17 10:43:21', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180201101', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1802', + 'name': '**REDACTED**', + 'report_id': '1027273230', + 'status_temp_format': 'C', + 'time': '2023/08/17 10:42:09', + 'type': 'device_type.door_lock', + 'user': 101, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1027100172', + 'status_temp_format': 'C', + 'time': '2023/08/17 05:28:57', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + dict({ + 'area': 1, + 'cid': '18180101001', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1801', + 'name': '**REDACTED**', + 'report_id': '1027099978', + 'status_temp_format': 'C', + 'time': '2023/08/17 05:28:39', + 'type': 'device_type.door_lock', + 'user': 1, + 'zone': 1, + }), + dict({ + 'area': 0, + 'cid': '18160200000', + 'cid_source': 'SYSTEM', + 'event_time': None, + 'event_type': '1602', + 'name': '', + 'report_id': '1027093266', + 'status_temp_format': 'C', + 'time': '2023/08/17 05:17:12', + 'type': '', + 'user': '', + 'zone': 0, + }), + dict({ + 'area': 1, + 'cid': '18180701000', + 'cid_source': 'DEVICE', + 'event_time': None, + 'event_type': '1807', + 'name': '**REDACTED**', + 'report_id': '1026912623', + 'status_temp_format': 'C', + 'time': '2023/08/16 20:29:36', + 'type': 'device_type.door_lock', + 'user': 0, + 'zone': 1, + }), + ]), + }), + 'mode': dict({ + 'data': list([ dict({ 'area': '1', 'mode': 'disarm', }), ]), - 'panel_status': dict({ - 'warning_snd_mute': '0', - }), - 'report_event_latest': dict({ - 'cid_code': '1807', - 'event_time': None, - 'id': '**REDACTED**', - 'report_id': '1027299996', - 'time': '1692271914', - 'utc_event_time': None, - }), }), - 'devices': list([ - dict({ - 'address': '**REDACTED**', - 'area': '1', - 'bypass': '0', - 'device_group': '002', - 'device_id': '**REDACTED**', - 'device_id2': '', - 'extension': None, - 'group_id': None, - 'group_name': None, - 'ipcam_trigger_by_zone1': None, - 'ipcam_trigger_by_zone2': None, - 'ipcam_trigger_by_zone3': None, - 'ipcam_trigger_by_zone4': None, - 'mac': '**REDACTED**', - 'minigw_configuration_data': '02FF000001000000000000000000001E000100', - 'minigw_lock_status': '35', - 'minigw_number_of_credentials_supported': '10', - 'minigw_product_data': '21020120', - 'minigw_protocol': 'DM', - 'minigw_syncing': '0', - 'name': '**REDACTED**', - 'no': '1', - 'rf': None, - 'rssi': '9', - 'scene_restore': None, - 'scene_trigger': '0', - 'sresp_button_1': None, - 'sresp_button_2': None, - 'sresp_button_3': None, - 'sresp_button_4': None, - 'status1': 'device_status.lock', - 'status2': None, - 'status_dim_level': None, - 'status_fault': list([ - ]), - 'status_hue': None, - 'status_humi': None, - 'status_lux': '', - 'status_open': list([ - 'device_status.lock', - ]), - 'status_power': None, - 'status_saturation': None, - 'status_switch': None, - 'status_temp': None, - 'status_temp_format': 'C', - 'status_total_energy': None, - 'thermo_c_setpoint': None, - 'thermo_c_setpoint_away': None, - 'thermo_fan_mode': None, - 'thermo_mode': None, - 'thermo_schd_setting': None, - 'thermo_setpoint': None, - 'thermo_setpoint_away': None, - 'trigger_by_zone': list([ - ]), - 'type': 'device_type.door_lock', - 'type_no': '72', - }), - dict({ - 'address': '**REDACTED**', - 'area': '1', - 'bypass': '0', - 'device_group': '002', - 'device_id': '**REDACTED**', - 'device_id2': '', - 'extension': None, - 'group_id': None, - 'group_name': None, - 'ipcam_trigger_by_zone1': None, - 'ipcam_trigger_by_zone2': None, - 'ipcam_trigger_by_zone3': None, - 'ipcam_trigger_by_zone4': None, - 'mac': '**REDACTED**', - 'minigw_configuration_data': '02FF000001000000000000000000001E000100', - 'minigw_lock_status': None, - 'minigw_number_of_credentials_supported': '10', - 'minigw_product_data': '21020120', - 'minigw_protocol': 'DM', - 'minigw_syncing': '0', - 'name': '**REDACTED**', - 'no': '2', - 'rf': None, - 'rssi': '9', - 'scene_restore': None, - 'scene_trigger': '0', - 'sresp_button_1': None, - 'sresp_button_2': None, - 'sresp_button_3': None, - 'sresp_button_4': None, - 'status1': 'device_status.unlock', - 'status2': None, - 'status_dim_level': None, - 'status_fault': list([ - ]), - 'status_hue': None, - 'status_humi': None, - 'status_lux': '', - 'status_open': list([ - ]), - 'status_power': None, - 'status_saturation': None, - 'status_switch': None, - 'status_temp': None, - 'status_temp_format': 'C', - 'status_total_energy': None, - 'thermo_c_setpoint': None, - 'thermo_c_setpoint_away': None, - 'thermo_fan_mode': None, - 'thermo_mode': None, - 'thermo_schd_setting': None, - 'thermo_setpoint': None, - 'thermo_setpoint_away': None, - 'trigger_by_zone': list([ - ]), - 'type': 'device_type.door_lock', - 'type_no': '72', - }), - dict({ - 'address': '**REDACTED**', - 'area': '1', - 'bypass': '0', - 'device_group': '002', - 'device_id': '**REDACTED**', - 'device_id2': '', - 'extension': None, - 'group_id': None, - 'group_name': None, - 'ipcam_trigger_by_zone1': None, - 'ipcam_trigger_by_zone2': None, - 'ipcam_trigger_by_zone3': None, - 'ipcam_trigger_by_zone4': None, - 'mac': '**REDACTED**', - 'minigw_configuration_data': '02FF000001000000000000000000001E000100', - 'minigw_lock_status': None, - 'minigw_number_of_credentials_supported': '10', - 'minigw_product_data': '21020120', - 'minigw_protocol': 'DM', - 'minigw_syncing': '0', - 'name': '**REDACTED**', - 'no': '3', - 'rf': None, - 'rssi': '9', - 'scene_restore': None, - 'scene_trigger': '0', - 'sresp_button_1': None, - 'sresp_button_2': None, - 'sresp_button_3': None, - 'sresp_button_4': None, - 'status1': 'device_status.lock', - 'status2': None, - 'status_dim_level': None, - 'status_fault': list([ - ]), - 'status_hue': None, - 'status_humi': None, - 'status_lux': '', - 'status_open': list([ - 'device_status.lock', - ]), - 'status_power': None, - 'status_saturation': None, - 'status_switch': None, - 'status_temp': None, - 'status_temp_format': 'C', - 'status_total_energy': None, - 'thermo_c_setpoint': None, - 'thermo_c_setpoint_away': None, - 'thermo_fan_mode': None, - 'thermo_mode': None, - 'thermo_schd_setting': None, - 'thermo_setpoint': None, - 'thermo_setpoint_away': None, - 'trigger_by_zone': list([ - ]), - 'type': 'device_type.door_lock', - 'type_no': '72', - }), - dict({ - 'address': '**REDACTED**', - 'area': '1', - 'bypass': '0', - 'device_group': '000', - 'device_id': '**REDACTED**', - 'device_id2': '', - 'extension': None, - 'group_id': None, - 'group_name': None, - 'ipcam_trigger_by_zone1': None, - 'ipcam_trigger_by_zone2': None, - 'ipcam_trigger_by_zone3': None, - 'ipcam_trigger_by_zone4': None, - 'mac': '**REDACTED**', - 'minigw_configuration_data': '', - 'minigw_lock_status': '', - 'minigw_number_of_credentials_supported': '', - 'minigw_product_data': '', - 'minigw_protocol': '', - 'minigw_syncing': '', - 'name': '**REDACTED**', - 'no': '4', - 'rf': None, - 'rssi': '0', - 'scene_restore': None, - 'scene_trigger': '0', - 'sresp_button_1': None, - 'sresp_button_2': None, - 'sresp_button_3': None, - 'sresp_button_4': None, - 'status1': 'device_status.dc_close', - 'status2': None, - 'status_dim_level': None, - 'status_fault': list([ - ]), - 'status_hue': None, - 'status_humi': None, - 'status_lux': '', - 'status_open': list([ - 'device_status.dc_close', - ]), - 'status_power': None, - 'status_saturation': None, - 'status_switch': None, - 'status_temp': None, - 'status_temp_format': 'C', - 'status_total_energy': None, - 'thermo_c_setpoint': None, - 'thermo_c_setpoint_away': None, - 'thermo_fan_mode': None, - 'thermo_mode': None, - 'thermo_schd_setting': None, - 'thermo_setpoint': None, - 'thermo_setpoint_away': None, - 'trigger_by_zone': list([ - ]), - 'type': 'device_type.door_contact', - 'type_no': '4', - }), - dict({ - 'address': '**REDACTED**', - 'area': '1', - 'bypass': '0', - 'device_group': '000', - 'device_id': '**REDACTED**', - 'device_id2': '', - 'extension': None, - 'group_id': None, - 'group_name': None, - 'ipcam_trigger_by_zone1': None, - 'ipcam_trigger_by_zone2': None, - 'ipcam_trigger_by_zone3': None, - 'ipcam_trigger_by_zone4': None, - 'mac': '**REDACTED**', - 'minigw_configuration_data': '', - 'minigw_lock_status': '', - 'minigw_number_of_credentials_supported': '', - 'minigw_product_data': '', - 'minigw_protocol': '', - 'minigw_syncing': '', - 'name': '**REDACTED**', - 'no': '5', - 'rf': None, - 'rssi': '0', - 'scene_restore': None, - 'scene_trigger': '0', - 'sresp_button_1': None, - 'sresp_button_2': None, - 'sresp_button_3': None, - 'sresp_button_4': None, - 'status1': 'device_status.dc_open', - 'status2': None, - 'status_dim_level': None, - 'status_fault': list([ - ]), - 'status_hue': None, - 'status_humi': None, - 'status_lux': '', - 'status_open': list([ - 'device_status.dc_open', - ]), - 'status_power': None, - 'status_saturation': None, - 'status_switch': None, - 'status_temp': None, - 'status_temp_format': 'C', - 'status_total_energy': None, - 'thermo_c_setpoint': None, - 'thermo_c_setpoint_away': None, - 'thermo_fan_mode': None, - 'thermo_mode': None, - 'thermo_schd_setting': None, - 'thermo_setpoint': None, - 'thermo_setpoint_away': None, - 'trigger_by_zone': list([ - ]), - 'type': 'device_type.door_contact', - 'type_no': '4', - }), - dict({ - 'address': '**REDACTED**', - 'area': '1', - 'bypass': '0', - 'device_group': '000', - 'device_id': '**REDACTED**', - 'device_id2': '', - 'extension': None, - 'group_id': None, - 'group_name': None, - 'ipcam_trigger_by_zone1': None, - 'ipcam_trigger_by_zone2': None, - 'ipcam_trigger_by_zone3': None, - 'ipcam_trigger_by_zone4': None, - 'mac': '**REDACTED**', - 'minigw_configuration_data': '', - 'minigw_lock_status': '', - 'minigw_number_of_credentials_supported': '', - 'minigw_product_data': '', - 'minigw_protocol': '', - 'minigw_syncing': '', - 'name': '**REDACTED**', - 'no': '6', - 'rf': None, - 'rssi': '0', - 'scene_restore': None, - 'scene_trigger': '0', - 'sresp_button_1': None, - 'sresp_button_2': None, - 'sresp_button_3': None, - 'sresp_button_4': None, - 'status1': 'unknwon', - 'status2': None, - 'status_dim_level': None, - 'status_fault': list([ - ]), - 'status_hue': None, - 'status_humi': None, - 'status_lux': '', - 'status_open': list([ - ]), - 'status_power': None, - 'status_saturation': None, - 'status_switch': None, - 'status_temp': None, - 'status_temp_format': 'C', - 'status_total_energy': None, - 'thermo_c_setpoint': None, - 'thermo_c_setpoint_away': None, - 'thermo_fan_mode': None, - 'thermo_mode': None, - 'thermo_schd_setting': None, - 'thermo_setpoint': None, - 'thermo_setpoint_away': None, - 'trigger_by_zone': list([ - ]), - 'type': 'device_type.door_contact', - 'type_no': '4', - }), - dict({ - 'address': '**REDACTED**', - 'area': '1', - 'bypass': '0', - 'device_group': '002', - 'device_id': '**REDACTED**', - 'device_id2': '', - 'extension': None, - 'group_id': None, - 'group_name': None, - 'ipcam_trigger_by_zone1': None, - 'ipcam_trigger_by_zone2': None, - 'ipcam_trigger_by_zone3': None, - 'ipcam_trigger_by_zone4': None, - 'mac': '**REDACTED**', - 'minigw_configuration_data': '02FF000001000000000000000000001E000100', - 'minigw_lock_status': '36', - 'minigw_number_of_credentials_supported': '10', - 'minigw_product_data': '21020120', - 'minigw_protocol': 'DM', - 'minigw_syncing': '0', - 'name': '**REDACTED**', - 'no': '7', - 'rf': None, - 'rssi': '9', - 'scene_restore': None, - 'scene_trigger': '0', - 'sresp_button_1': None, - 'sresp_button_2': None, - 'sresp_button_3': None, - 'sresp_button_4': None, - 'status1': 'device_status.lock', - 'status2': None, - 'status_dim_level': None, - 'status_fault': list([ - ]), - 'status_hue': None, - 'status_humi': None, - 'status_lux': '', - 'status_open': list([ - 'device_status.lock', - ]), - 'status_power': None, - 'status_saturation': None, - 'status_switch': None, - 'status_temp': None, - 'status_temp_format': 'C', - 'status_total_energy': None, - 'thermo_c_setpoint': None, - 'thermo_c_setpoint_away': None, - 'thermo_fan_mode': None, - 'thermo_mode': None, - 'thermo_schd_setting': None, - 'thermo_setpoint': None, - 'thermo_setpoint_away': None, - 'trigger_by_zone': list([ - ]), - 'type': 'device_type.door_lock', - 'type_no': '72', - }), - dict({ - 'address': '**REDACTED**', - 'area': '1', - 'bypass': '0', - 'device_group': '002', - 'device_id': '**REDACTED**', - 'device_id2': '', - 'extension': None, - 'group_id': None, - 'group_name': None, - 'ipcam_trigger_by_zone1': None, - 'ipcam_trigger_by_zone2': None, - 'ipcam_trigger_by_zone3': None, - 'ipcam_trigger_by_zone4': None, - 'mac': '**REDACTED**', - 'minigw_configuration_data': '02FF000001000000000000000000001E000100', - 'minigw_lock_status': '4', - 'minigw_number_of_credentials_supported': '10', - 'minigw_product_data': '21020120', - 'minigw_protocol': 'DM', - 'minigw_syncing': '0', - 'name': '**REDACTED**', - 'no': '8', - 'rf': None, - 'rssi': '9', - 'scene_restore': None, - 'scene_trigger': '0', - 'sresp_button_1': None, - 'sresp_button_2': None, - 'sresp_button_3': None, - 'sresp_button_4': None, - 'status1': 'device_status.unlock', - 'status2': None, - 'status_dim_level': None, - 'status_fault': list([ - ]), - 'status_hue': None, - 'status_humi': None, - 'status_lux': '', - 'status_open': list([ - 'device_status.unlock', - ]), - 'status_power': None, - 'status_saturation': None, - 'status_switch': None, - 'status_temp': None, - 'status_temp_format': 'C', - 'status_total_energy': None, - 'thermo_c_setpoint': None, - 'thermo_c_setpoint_away': None, - 'thermo_fan_mode': None, - 'thermo_mode': None, - 'thermo_schd_setting': None, - 'thermo_setpoint': None, - 'thermo_setpoint_away': None, - 'trigger_by_zone': list([ - ]), - 'type': 'device_type.door_lock', - 'type_no': '72', - }), - dict({ - 'address': '**REDACTED**', - 'area': '1', - 'bypass': '0', - 'device_group': '002', - 'device_id': '**REDACTED**', - 'device_id2': '', - 'extension': None, - 'group_id': None, - 'group_name': None, - 'ipcam_trigger_by_zone1': None, - 'ipcam_trigger_by_zone2': None, - 'ipcam_trigger_by_zone3': None, - 'ipcam_trigger_by_zone4': None, - 'mac': '**REDACTED**', - 'minigw_configuration_data': '02FF000001000000000000000000001E000100', - 'minigw_lock_status': '10', - 'minigw_number_of_credentials_supported': '10', - 'minigw_product_data': '21020120', - 'minigw_protocol': 'DM', - 'minigw_syncing': '0', - 'name': '**REDACTED**', - 'no': '9', - 'rf': None, - 'rssi': '9', - 'scene_restore': None, - 'scene_trigger': '0', - 'sresp_button_1': None, - 'sresp_button_2': None, - 'sresp_button_3': None, - 'sresp_button_4': None, - 'status1': 'device_status.error', - 'status2': None, - 'status_dim_level': None, - 'status_fault': list([ - ]), - 'status_hue': None, - 'status_humi': None, - 'status_lux': '', - 'status_open': list([ - 'device_status.error', - ]), - 'status_power': None, - 'status_saturation': None, - 'status_switch': None, - 'status_temp': None, - 'status_temp_format': 'C', - 'status_total_energy': None, - 'thermo_c_setpoint': None, - 'thermo_c_setpoint_away': None, - 'thermo_fan_mode': None, - 'thermo_mode': None, - 'thermo_schd_setting': None, - 'thermo_setpoint': None, - 'thermo_setpoint_away': None, - 'trigger_by_zone': list([ - ]), - 'type': 'device_type.door_lock', - 'type_no': '72', - }), - dict({ - 'address': '**REDACTED**', - 'area': '1', - 'bypass': '0', - 'device_group': '001', - 'device_id': '**REDACTED**', - 'device_id2': '', - 'extension': None, - 'group_id': None, - 'group_name': None, - 'ipcam_trigger_by_zone1': None, - 'ipcam_trigger_by_zone2': None, - 'ipcam_trigger_by_zone3': None, - 'ipcam_trigger_by_zone4': None, - 'mac': '**REDACTED**', - 'minigw_configuration_data': '', - 'minigw_lock_status': '', - 'minigw_number_of_credentials_supported': '', - 'minigw_product_data': '', - 'minigw_protocol': '', - 'minigw_syncing': '', - 'name': '**REDACTED**', - 'no': '8', - 'rf': None, - 'rssi': '9', - 'scene_restore': None, - 'scene_trigger': '0', - 'sresp_button_1': None, - 'sresp_button_2': None, - 'sresp_button_3': None, - 'sresp_button_4': None, - 'status1': '', - 'status2': None, - 'status_dim_level': None, - 'status_fault': list([ - ]), - 'status_hue': None, - 'status_humi': None, - 'status_lux': '', - 'status_open': list([ - ]), - 'status_power': None, - 'status_saturation': None, - 'status_switch': None, - 'status_temp': 21, - 'status_temp_format': 'C', - 'status_total_energy': None, - 'thermo_c_setpoint': None, - 'thermo_c_setpoint_away': None, - 'thermo_fan_mode': None, - 'thermo_mode': None, - 'thermo_schd_setting': None, - 'thermo_setpoint': None, - 'thermo_setpoint_away': None, - 'trigger_by_zone': list([ - ]), - 'type': 'device_type.temperature_sensor', - 'type_no': '40', - }), - ]), - 'history': list([ - dict({ - 'area': 1, - 'cid': '18180701000', - 'cid_source': 'DEVICE', - 'event_time': None, - 'event_type': '1807', - 'name': '**REDACTED**', - 'report_id': '1027299996', - 'status_temp_format': 'C', - 'time': '2023/08/17 11:31:54', - 'type': 'device_type.door_lock', - 'user': 0, - 'zone': 1, - }), - dict({ - 'area': 1, - 'cid': '18180201101', - 'cid_source': 'DEVICE', - 'event_time': None, - 'event_type': '1802', - 'name': '**REDACTED**', - 'report_id': '1027299889', - 'status_temp_format': 'C', - 'time': '2023/08/17 11:31:43', - 'type': 'device_type.door_lock', - 'user': 101, - 'zone': 1, - }), - dict({ - 'area': 1, - 'cid': '18180701000', - 'cid_source': 'DEVICE', - 'event_time': None, - 'event_type': '1807', - 'name': '**REDACTED**', - 'report_id': '1027299587', - 'status_temp_format': 'C', - 'time': '2023/08/17 11:31:11', - 'type': 'device_type.door_lock', - 'user': 0, - 'zone': 1, - }), - dict({ - 'area': 1, - 'cid': '18180101001', - 'cid_source': 'DEVICE', - 'event_time': None, - 'event_type': '1801', - 'name': '**REDACTED**', - 'report_id': '1027296099', - 'status_temp_format': 'C', - 'time': '2023/08/17 11:24:52', - 'type': 'device_type.door_lock', - 'user': 1, - 'zone': 1, - }), - dict({ - 'area': 1, - 'cid': '18180701000', - 'cid_source': 'DEVICE', - 'event_time': None, - 'event_type': '1807', - 'name': '**REDACTED**', - 'report_id': '1027273782', - 'status_temp_format': 'C', - 'time': '2023/08/17 10:43:21', - 'type': 'device_type.door_lock', - 'user': 0, - 'zone': 1, - }), - dict({ - 'area': 1, - 'cid': '18180201101', - 'cid_source': 'DEVICE', - 'event_time': None, - 'event_type': '1802', - 'name': '**REDACTED**', - 'report_id': '1027273230', - 'status_temp_format': 'C', - 'time': '2023/08/17 10:42:09', - 'type': 'device_type.door_lock', - 'user': 101, - 'zone': 1, - }), - dict({ - 'area': 1, - 'cid': '18180701000', - 'cid_source': 'DEVICE', - 'event_time': None, - 'event_type': '1807', - 'name': '**REDACTED**', - 'report_id': '1027100172', - 'status_temp_format': 'C', - 'time': '2023/08/17 05:28:57', - 'type': 'device_type.door_lock', - 'user': 0, - 'zone': 1, - }), - dict({ - 'area': 1, - 'cid': '18180101001', - 'cid_source': 'DEVICE', - 'event_time': None, - 'event_type': '1801', - 'name': '**REDACTED**', - 'report_id': '1027099978', - 'status_temp_format': 'C', - 'time': '2023/08/17 05:28:39', - 'type': 'device_type.door_lock', - 'user': 1, - 'zone': 1, - }), - dict({ - 'area': 0, - 'cid': '18160200000', - 'cid_source': 'SYSTEM', - 'event_time': None, - 'event_type': '1602', - 'name': '', - 'report_id': '1027093266', - 'status_temp_format': 'C', - 'time': '2023/08/17 05:17:12', - 'type': '', - 'user': '', - 'zone': 0, - }), - dict({ - 'area': 1, - 'cid': '18180701000', - 'cid_source': 'DEVICE', - 'event_time': None, - 'event_type': '1807', - 'name': '**REDACTED**', - 'report_id': '1026912623', - 'status_temp_format': 'C', - 'time': '2023/08/16 20:29:36', - 'type': 'device_type.door_lock', - 'user': 0, - 'zone': 1, - }), - ]), - 'mode': list([ - dict({ - 'area': '1', - 'mode': 'disarm', - }), - ]), - 'online': 'online', + 'online': dict({ + 'data': 'online', + }), 'panel_info': dict({ - 'SMS_Balance': '50', - 'contact': '', - 'dealer_name': 'Poland', - 'mac': '**REDACTED**', - 'mail_address': '**REDACTED**', - 'name': '', - 'net_version': 'MINIGW-MZ-1_G 1.0.1.29A', - 'phone': 'UK-01902364606 / Sweden-0770373710 / Demark-89887818 / Norway-81569036', - 'report_account': '**REDACTED**', - 'rf51_version': '', - 'service_time': 'UK - Mon to Fri 8:30 til 17:30 / Scandinavia - Mon to Fri 8:00 til 20:00, Sat to Sun 10:00 til 15:00', - 'version': 'MINIGW-MZ-1_G 1.0.1.29A,,4.1.2.6.2,00:1D:94:0B:5E:A7,10111112,ML_yamga', - 'voice_balance': '0', - 'xml_version': '2', - 'zb_version': '4.1.2.6.2', - 'zw_version': '', + 'data': dict({ + 'SMS_Balance': '50', + 'contact': '', + 'dealer_name': 'Poland', + 'mac': '**REDACTED**', + 'mail_address': '**REDACTED**', + 'name': '', + 'net_version': 'MINIGW-MZ-1_G 1.0.1.29A', + 'phone': 'UK-01902364606 / Sweden-0770373710 / Demark-89887818 / Norway-81569036', + 'report_account': '**REDACTED**', + 'rf51_version': '', + 'service_time': 'UK - Mon to Fri 8:30 til 17:30 / Scandinavia - Mon to Fri 8:00 til 20:00, Sat to Sun 10:00 til 15:00', + 'version': 'MINIGW-MZ-1_G 1.0.1.29A,,4.1.2.6.2,00:1D:94:0B:5E:A7,10111112,ML_yamga', + 'voice_balance': '0', + 'xml_version': '2', + 'zb_version': '4.1.2.6.2', + 'zw_version': '', + }), }), 'status': dict({ - 'acfail': 'main.normal', - 'battery': 'main.normal', - 'gsm_rssi': '0', - 'imei': '', - 'imsi': '', - 'jam': 'main.normal', - 'rssi': '1', - 'tamper': 'main.normal', + 'data': dict({ + 'acfail': 'main.normal', + 'battery': 'main.normal', + 'gsm_rssi': '0', + 'imei': '', + 'imsi': '', + 'jam': 'main.normal', + 'rssi': '1', + 'tamper': 'main.normal', + }), }), }) # --- diff --git a/tests/components/yale_smart_alarm/test_lock.py b/tests/components/yale_smart_alarm/test_lock.py index 7c67703924b..b1bbbaabc57 100644 --- a/tests/components/yale_smart_alarm/test_lock.py +++ b/tests/components/yale_smart_alarm/test_lock.py @@ -55,7 +55,7 @@ async def test_lock_service_calls( client = load_config_entry[1] data = deepcopy(get_data.cycle) - data["data"] = data.pop("device_status") + data["data"] = data["data"].pop("device_status") client.auth.get_authenticated = Mock(return_value=data) client.auth.post_authenticated = Mock(return_value={"code": "000"}) @@ -109,7 +109,7 @@ async def test_lock_service_call_fails( client = load_config_entry[1] data = deepcopy(get_data.cycle) - data["data"] = data.pop("device_status") + data["data"] = data["data"].pop("device_status") client.auth.get_authenticated = Mock(return_value=data) client.auth.post_authenticated = Mock(side_effect=UnknownError("test_side_effect")) @@ -161,7 +161,7 @@ async def test_lock_service_call_fails_with_incorrect_status( client = load_config_entry[1] data = deepcopy(get_data.cycle) - data["data"] = data.pop("device_status") + data["data"] = data["data"].pop("device_status") client.auth.get_authenticated = Mock(return_value=data) client.auth.post_authenticated = Mock(return_value={"code": "FFF"}) From 84def0c0414a67dd218aa661f6b49ab45b8da29c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 8 Sep 2024 14:23:00 +0200 Subject: [PATCH 0397/1309] Deprecate aux_heat in elkm1 (#125372) * Deprecate aux_heat in elkm1 * Update homeassistant/components/elkm1/switch.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/elkm1/climate.py | 23 ++++++++++++- homeassistant/components/elkm1/strings.json | 13 ++++++++ homeassistant/components/elkm1/switch.py | 37 +++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 6281cca8592..177f17d6e7e 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -20,8 +20,9 @@ from homeassistant.components.climate import ( from homeassistant.const import PRECISION_WHOLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from . import ElkEntity, ElkM1ConfigEntry, create_elk_entities +from . import DOMAIN, ElkEntity, ElkM1ConfigEntry, create_elk_entities SUPPORT_HVAC = [ HVACMode.OFF, @@ -151,10 +152,30 @@ class ElkThermostat(ElkEntity, ClimateEntity): async def async_turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" + async_create_issue( + self.hass, + DOMAIN, + "migrate_aux_heat", + breaks_in_ha_version="2025.4.0", + is_fixable=True, + is_persistent=True, + translation_key="migrate_aux_heat", + severity=IssueSeverity.WARNING, + ) self._elk_set(ThermostatMode.EMERGENCY_HEAT, None) async def async_turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" + async_create_issue( + self.hass, + DOMAIN, + "migrate_aux_heat", + breaks_in_ha_version="2025.4.0", + is_fixable=True, + is_persistent=True, + translation_key="migrate_aux_heat", + severity=IssueSeverity.WARNING, + ) self._elk_set(ThermostatMode.HEAT, None) async def async_set_fan_mode(self, fan_mode: str) -> None: diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index c854307dd92..302f14b3f44 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -189,5 +189,18 @@ "name": "Sensor zone trigger", "description": "Triggers zone." } + }, + "issues": { + "migrate_aux_heat": { + "title": "Migration of Elk-M1 set_aux_heat action", + "fix_flow": { + "step": { + "confirm": { + "description": "The Elk-M1 `set_aux_heat` action has been migrated. A new emergency heat switch entity is available for each thermostat.\n\nUpdate any automations to use the new emergency heat switch entity. When this is done, Press submit to fix this issue.", + "title": "[%key:component::elkm1::issues::migrate_aux_heat::title%]" + } + } + } + } } } diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py index f4820f57b3d..70b38802a42 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -4,13 +4,18 @@ from __future__ import annotations from typing import Any +from elkm1_lib.const import ThermostatMode, ThermostatSetting +from elkm1_lib.elements import Element +from elkm1_lib.elk import Elk from elkm1_lib.outputs import Output +from elkm1_lib.thermostats import Thermostat from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry, create_elk_entities +from .models import ELKM1Data async def async_setup_entry( @@ -23,6 +28,9 @@ async def async_setup_entry( elk = elk_data.elk entities: list[ElkEntity] = [] create_elk_entities(elk_data, elk.outputs, "output", ElkOutput, entities) + create_elk_entities( + elk_data, elk.thermostats, "thermostat", ElkThermostatEMHeat, entities + ) async_add_entities(entities) @@ -43,3 +51,32 @@ class ElkOutput(ElkAttachedEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the output.""" self._element.turn_off() + + +class ElkThermostatEMHeat(ElkEntity, SwitchEntity): + """Elk Thermostat emergency heat as switch.""" + + _element: Thermostat + + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: + """Initialize the emergency heat switch.""" + super().__init__(element, elk, elk_data) + self._unique_id = f"{self._unique_id}emheat" + self._attr_name = f"{element.name} emergency heat" + + @property + def is_on(self) -> bool: + """Get the current emergency heat status.""" + return self._element.mode == ThermostatMode.EMERGENCY_HEAT + + def _elk_set(self, mode: ThermostatMode) -> None: + """Set the thermostat mode.""" + self._element.set(ThermostatSetting.MODE, mode) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the output.""" + self._elk_set(ThermostatMode.EMERGENCY_HEAT) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the output.""" + self._elk_set(ThermostatMode.EMERGENCY_HEAT) From 2ef37f01b1c6e26f3a462c64eef69a6bb085f2a3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 8 Sep 2024 14:23:24 +0200 Subject: [PATCH 0398/1309] Deprecate aux_heat from Nexia climate entity, implement switch (#125250) * Remove deprecated aux_heat from nexia * Add back aux_heat * Raise issue --- homeassistant/components/nexia/climate.py | 22 ++++++++++++++ homeassistant/components/nexia/strings.json | 13 +++++++++ homeassistant/components/nexia/switch.py | 32 ++++++++++++++++++++- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index a4bcc03c210..9b22607d5a8 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -35,6 +35,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import VolDictType from .const import ( @@ -42,6 +43,7 @@ from .const import ( ATTR_DEHUMIDIFY_SETPOINT, ATTR_HUMIDIFY_SETPOINT, ATTR_RUN_MODE, + DOMAIN, ) from .coordinator import NexiaDataUpdateCoordinator from .entity import NexiaThermostatZoneEntity @@ -378,11 +380,31 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): async def async_turn_aux_heat_off(self) -> None: """Turn Aux Heat off.""" + async_create_issue( + self.hass, + DOMAIN, + "migrate_aux_heat", + breaks_in_ha_version="2025.4.0", + is_fixable=True, + is_persistent=True, + translation_key="migrate_aux_heat", + severity=IssueSeverity.WARNING, + ) await self._thermostat.set_emergency_heat(False) self._signal_thermostat_update() async def async_turn_aux_heat_on(self) -> None: """Turn Aux Heat on.""" + async_create_issue( + self.hass, + DOMAIN, + "migrate_aux_heat", + breaks_in_ha_version="2025.4.0", + is_fixable=True, + is_persistent=True, + translation_key="migrate_aux_heat", + severity=IssueSeverity.WARNING, + ) await self._thermostat.set_emergency_heat(True) self._signal_thermostat_update() diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index 9e49f4bb793..acb57352d24 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -96,5 +96,18 @@ } } } + }, + "issues": { + "migrate_aux_heat": { + "title": "Migration of Nexia set_aux_heat action", + "fix_flow": { + "step": { + "confirm": { + "description": "The Nexia `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new Emergency heat switch entity. When this is done, press submit to fix this issue.", + "title": "[%key:component::nexia::issues::migrate_aux_heat::title%]" + } + } + } + } } } diff --git a/homeassistant/components/nexia/switch.py b/homeassistant/components/nexia/switch.py index 0a874ba1817..f92443517c8 100644 --- a/homeassistant/components/nexia/switch.py +++ b/homeassistant/components/nexia/switch.py @@ -25,12 +25,14 @@ async def async_setup_entry( """Set up switches for a Nexia device.""" coordinator = config_entry.runtime_data nexia_home = coordinator.nexia_home - entities: list[NexiaHoldSwitch] = [] + entities: list[NexiaHoldSwitch | NexiaEmergencyHeatSwitch] = [] for thermostat_id in nexia_home.get_thermostat_ids(): thermostat: NexiaThermostat = nexia_home.get_thermostat_by_id(thermostat_id) for zone_id in thermostat.get_zone_ids(): zone: NexiaThermostatZone = thermostat.get_zone_by_id(zone_id) entities.append(NexiaHoldSwitch(coordinator, zone)) + if thermostat.has_emergency_heat(): + entities.append(NexiaEmergencyHeatSwitch(coordinator, zone)) async_add_entities(entities) @@ -64,3 +66,31 @@ class NexiaHoldSwitch(NexiaThermostatZoneEntity, SwitchEntity): """Disable permanent hold.""" await self._zone.call_return_to_schedule() self._signal_zone_update() + + +class NexiaEmergencyHeatSwitch(NexiaThermostatZoneEntity, SwitchEntity): + """Provides Nexia emergency heat switch support.""" + + _attr_translation_key = "emergency_heat" + + def __init__( + self, coordinator: NexiaDataUpdateCoordinator, zone: NexiaThermostatZone + ) -> None: + """Initialize the emergency heat mode switch.""" + zone_id = zone.zone_id + super().__init__(coordinator, zone, zone_id) + + @property + def is_on(self) -> bool: + """Return if the zone is in hold mode.""" + return self._thermostat.is_emergency_heat_active() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable permanent hold.""" + await self._thermostat.set_emergency_heat(True) + self._signal_thermostat_update() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable permanent hold.""" + await self._thermostat.set_emergency_heat(False) + self._signal_thermostat_update() From c2d5696b5b5175f5ca5ee4555ed03aa975a4ae4c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 8 Sep 2024 14:24:12 +0200 Subject: [PATCH 0399/1309] Add validation to climate hvac mode (#125178) * Add validation to climate hvac mode * Make softer * Remove string --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/climate/__init__.py | 41 +++++++++++++++----- tests/components/climate/test_init.py | 30 ++++++++++++-- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index f752a3dcc7a..38d8e89269a 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -175,7 +175,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_HVAC_MODE, {vol.Required(ATTR_HVAC_MODE): vol.Coerce(HVACMode)}, - "async_set_hvac_mode", + "async_handle_set_hvac_mode_service", ) component.async_register_entity_service( SERVICE_SET_PRESET_MODE, @@ -694,20 +694,35 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @callback def _valid_mode_or_raise( self, - mode_type: Literal["preset", "swing", "fan"], - mode: str, - modes: list[str] | None, + mode_type: Literal["preset", "swing", "fan", "hvac"], + mode: str | HVACMode, + modes: list[str] | list[HVACMode] | None, ) -> None: """Raise ServiceValidationError on invalid modes.""" if modes and mode in modes: return modes_str: str = ", ".join(modes) if modes else "" - if mode_type == "preset": - translation_key = "not_valid_preset_mode" - elif mode_type == "swing": - translation_key = "not_valid_swing_mode" - elif mode_type == "fan": - translation_key = "not_valid_fan_mode" + translation_key = f"not_valid_{mode_type}_mode" + if mode_type == "hvac": + report_issue = async_suggest_report_issue( + self.hass, + integration_domain=self.platform.platform_name, + module=type(self).__module__, + ) + _LOGGER.warning( + ( + "%s::%s sets the hvac_mode %s which is not " + "valid for this entity with modes: %s. " + "This will stop working in 2025.3 and raise an error instead. " + "Please %s" + ), + self.platform.platform_name, + self.__class__.__name__, + mode, + modes_str, + report_issue, + ) + return raise ServiceValidationError( translation_domain=DOMAIN, translation_key=translation_key, @@ -749,6 +764,12 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Set new target fan mode.""" await self.hass.async_add_executor_job(self.set_fan_mode, fan_mode) + @final + async def async_handle_set_hvac_mode_service(self, hvac_mode: HVACMode) -> None: + """Validate and set new preset mode.""" + self._valid_mode_or_raise("hvac", hvac_mode, self.hvac_modes) + await self.async_set_hvac_mode(hvac_mode) + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" raise NotImplementedError diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 64c94ccfc6f..6342313d1da 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -27,6 +27,7 @@ from homeassistant.components.climate.const import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, @@ -138,6 +139,10 @@ class MockClimateEntity(MockEntity, ClimateEntity): """Set swing mode.""" self._attr_swing_mode = swing_mode + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + self._attr_hvac_mode = hvac_mode + class MockClimateEntityTestMethods(MockClimateEntity): """Mock Climate device.""" @@ -237,10 +242,12 @@ def test_deprecated_current_constants( ) -async def test_preset_mode_validation( - hass: HomeAssistant, register_test_integration: MockConfigEntry +async def test_mode_validation( + hass: HomeAssistant, + register_test_integration: MockConfigEntry, + caplog: pytest.LogCaptureFixture, ) -> None: - """Test mode validation for fan, swing and preset.""" + """Test mode validation for hvac_mode, fan, swing and preset.""" climate_entity = MockClimateEntity(name="test", entity_id="climate.test") setup_test_component_platform( @@ -250,6 +257,7 @@ async def test_preset_mode_validation( await hass.async_block_till_done() state = hass.states.get("climate.test") + assert state.state == "heat" assert state.attributes.get(ATTR_PRESET_MODE) == "home" assert state.attributes.get(ATTR_FAN_MODE) == "auto" assert state.attributes.get(ATTR_SWING_MODE) == "auto" @@ -286,6 +294,22 @@ async def test_preset_mode_validation( assert state.attributes.get(ATTR_FAN_MODE) == "off" assert state.attributes.get(ATTR_SWING_MODE) == "off" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + { + "entity_id": "climate.test", + "hvac_mode": "auto", + }, + blocking=True, + ) + + assert ( + "MockClimateEntity sets the hvac_mode auto which is not valid " + "for this entity with modes: off, heat. This will stop working " + "in 2025.3 and raise an error instead. Please" in caplog.text + ) + with pytest.raises( ServiceValidationError, match="Preset mode invalid is not valid. Valid preset modes are: home, away", From a7a219b99bb4057b57c7b58ed2f28d6ab94bf0ba Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 8 Sep 2024 14:24:39 +0200 Subject: [PATCH 0400/1309] Deprecate aux_heat in econet (#125365) * Deprecate aux_heat in econet * strings * Use generator --- homeassistant/components/econet/__init__.py | 1 + homeassistant/components/econet/climate.py | 21 ++++++++ homeassistant/components/econet/strings.json | 13 +++++ homeassistant/components/econet/switch.py | 57 ++++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 homeassistant/components/econet/switch.py diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 84e636e660b..4aba79f779f 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -31,6 +31,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SENSOR, + Platform.SWITCH, Platform.WATER_HEATER, ] PUSH_UPDATE = "econet.push_update" diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index f6bd52c9702..1d6cefc9645 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT @@ -203,10 +204,30 @@ class EcoNetThermostat(EcoNetEntity, ClimateEntity): def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" + async_create_issue( + self.hass, + DOMAIN, + "migrate_aux_heat", + breaks_in_ha_version="2025.4.0", + is_fixable=True, + is_persistent=True, + translation_key="migrate_aux_heat", + severity=IssueSeverity.WARNING, + ) self._econet.set_mode(ThermostatOperationMode.EMERGENCY_HEAT) def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" + async_create_issue( + self.hass, + DOMAIN, + "migrate_aux_heat", + breaks_in_ha_version="2025.4.0", + is_fixable=True, + is_persistent=True, + translation_key="migrate_aux_heat", + severity=IssueSeverity.WARNING, + ) self._econet.set_mode(ThermostatOperationMode.HEATING) @property diff --git a/homeassistant/components/econet/strings.json b/homeassistant/components/econet/strings.json index 6e81085a9bf..83d66dde144 100644 --- a/homeassistant/components/econet/strings.json +++ b/homeassistant/components/econet/strings.json @@ -18,5 +18,18 @@ } } } + }, + "issues": { + "migrate_aux_heat": { + "title": "Migration of EcoNet set_aux_heat action", + "fix_flow": { + "step": { + "confirm": { + "description": "The EcoNet `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, Press submit to fix this issue.", + "title": "[%key:component::econet::issues::migrate_aux_heat::title%]" + } + } + } + } } } diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py new file mode 100644 index 00000000000..107cd7dc586 --- /dev/null +++ b/homeassistant/components/econet/switch.py @@ -0,0 +1,57 @@ +"""Support for using switch with ecoNet thermostats.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyeconet.equipment import EquipmentType +from pyeconet.equipment.thermostat import ThermostatOperationMode + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EcoNetEntity +from .const import DOMAIN, EQUIPMENT + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the ecobee thermostat switch entity.""" + equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + async_add_entities( + EcoNetSwitchAuxHeatOnly(thermostat) + for thermostat in equipment[EquipmentType.THERMOSTAT] + ) + + +class EcoNetSwitchAuxHeatOnly(EcoNetEntity, SwitchEntity): + """Representation of a aux_heat_only EcoNet switch.""" + + def __init__(self, thermostat) -> None: + """Initialize EcoNet ventilator platform.""" + super().__init__(thermostat) + self._attr_name = f"{thermostat.device_name} emergency heat" + self._attr_unique_id = ( + f"{thermostat.device_id}_{thermostat.device_name}_auxheat" + ) + + def turn_on(self, **kwargs: Any) -> None: + """Set the hvacMode to auxHeatOnly.""" + self._econet.set_mode(ThermostatOperationMode.EMERGENCY_HEAT) + + def turn_off(self, **kwargs: Any) -> None: + """Set the hvacMode back to the prior setting.""" + self._econet.set_mode(ThermostatOperationMode.HEATING) + + @property + def is_on(self) -> bool: + """Return true if auxHeatOnly mode is active.""" + return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT From 45ab6e9b063155e6fc8025c0c12a870cad01be65 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Sun, 8 Sep 2024 14:45:37 +0200 Subject: [PATCH 0401/1309] Deprecate opentherm_gw configuration through configuration.yaml (#125045) * Create an issue in the issue registry if deprecated config is found in configuration.yaml * Add deprecation comments to functions that can be removed after deprecation period * Add test for the creation of a deprecation issue Co-authored-by: Joost Lekkerkerker --- .../components/opentherm_gw/__init__.py | 14 +++++++++ .../components/opentherm_gw/config_flow.py | 1 + .../components/opentherm_gw/strings.json | 6 ++++ .../opentherm_gw/test_config_flow.py | 1 + tests/components/opentherm_gw/test_init.py | 29 ++++++++++++++++++- 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index c7a52e3d5d3..d5dae367959 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -32,6 +32,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType @@ -68,6 +69,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +# *_SCHEMA required for deprecated import from configuration.yaml, can be removed in 2025.4.0 CLIMATE_SCHEMA = vol.Schema( { vol.Optional(CONF_PRECISION): vol.In( @@ -159,8 +161,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True +# Deprecated import from configuration.yaml, can be removed in 2025.4.0 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the OpenTherm Gateway component.""" + if DOMAIN in config: + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_import_from_configuration_yaml", + breaks_in_ha_version="2025.4.0", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_import_from_configuration_yaml", + ) if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config: conf = config[DOMAIN] for device_id, device_config in conf.items(): diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 3cf8a1c4594..1f52b47cbad 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -95,6 +95,7 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN): """Handle manual initiation of the config flow.""" return await self.async_step_init(user_input) + # Deprecated import from configuration.yaml, can be removed in 2025.4.0 async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Import an OpenTherm Gateway device as a config entry. diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index b23e1eb7687..f0573db0531 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -316,6 +316,12 @@ } } }, + "issues": { + "deprecated_import_from_configuration_yaml": { + "title": "Deprecated configuration", + "description": "Configuration of the OpenTherm Gateway integration through configuration.yaml is deprecated. Your configuration has been migrated to config entries. Please remove any OpenTherm Gateway configuration from your configuration.yaml." + } + }, "options": { "step": { "init": { diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py index 4f4a6cfce31..57bea4e55dc 100644 --- a/tests/components/opentherm_gw/test_config_flow.py +++ b/tests/components/opentherm_gw/test_config_flow.py @@ -54,6 +54,7 @@ async def test_form_user( assert mock_pyotgw.return_value.disconnect.await_count == 1 +# Deprecated import from configuration.yaml, can be removed in 2025.4.0 async def test_form_import( hass: HomeAssistant, mock_pyotgw: MagicMock, diff --git a/tests/components/opentherm_gw/test_init.py b/tests/components/opentherm_gw/test_init.py index 4085e25c614..3e85afbf782 100644 --- a/tests/components/opentherm_gw/test_init.py +++ b/tests/components/opentherm_gw/test_init.py @@ -4,13 +4,18 @@ from unittest.mock import MagicMock from pyotgw.vars import OTGW, OTGW_ABOUT +from homeassistant import setup from homeassistant.components.opentherm_gw.const import ( DOMAIN, OpenThermDeviceIdentifier, ) from homeassistant.const import CONF_ID 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 .conftest import MOCK_GATEWAY_ID, VERSION_TEST @@ -148,3 +153,25 @@ async def test_climate_entity_migration( updated_entry.unique_id == f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity" ) + + +# Deprecation test, can be removed in 2025.4.0 +async def test_configuration_yaml_deprecation( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, +) -> None: + """Test that existing configuration in configuration.yaml creates an issue.""" + + await setup.async_setup_component( + hass, DOMAIN, {DOMAIN: {"legacy_gateway": {"device": "/dev/null"}}} + ) + + await hass.async_block_till_done() + assert ( + issue_registry.async_get_issue( + DOMAIN, "deprecated_import_from_configuration_yaml" + ) + is not None + ) From af62e8267fbd0a117b62dba592aebd5381a3e03f Mon Sep 17 00:00:00 2001 From: treetip Date: Sun, 8 Sep 2024 16:07:42 +0300 Subject: [PATCH 0402/1309] Add set_profile service for Vallox integration (#120225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add set_profile service for Vallox integration * Merge profile constants, use str input for service * add service test and some related refactoring * Change service uom to 'minutes' Co-authored-by: Sebastian Lövdahl * Update icons.js format after rebase * Translate profile names for service * Fix test using wrong dict --------- Co-authored-by: Sebastian Lövdahl --- homeassistant/components/vallox/__init__.py | 33 +++++++++++++ homeassistant/components/vallox/const.py | 17 +++---- homeassistant/components/vallox/fan.py | 12 ++--- homeassistant/components/vallox/icons.json | 3 ++ homeassistant/components/vallox/sensor.py | 4 +- homeassistant/components/vallox/services.yaml | 21 ++++++++ homeassistant/components/vallox/strings.json | 25 ++++++++++ tests/components/vallox/test_init.py | 48 ++++++++++++++++++- 8 files changed, 146 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 292786e4c0e..09080f1a5f6 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -22,6 +22,7 @@ from .const import ( DEFAULT_FAN_SPEED_HOME, DEFAULT_NAME, DOMAIN, + I18N_KEY_TO_VALLOX_PROFILE, ) from .coordinator import ValloxDataUpdateCoordinator @@ -61,6 +62,18 @@ SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED = vol.Schema( } ) +ATTR_PROFILE = "profile" +ATTR_DURATION = "duration" + +SERVICE_SCHEMA_SET_PROFILE = vol.Schema( + { + vol.Required(ATTR_PROFILE): vol.In(I18N_KEY_TO_VALLOX_PROFILE), + vol.Optional(ATTR_DURATION): vol.All( + vol.Coerce(int), vol.Clamp(min=1, max=65535) + ), + } +) + class ServiceMethodDetails(NamedTuple): """Details for SERVICE_TO_METHOD mapping.""" @@ -72,6 +85,7 @@ class ServiceMethodDetails(NamedTuple): SERVICE_SET_PROFILE_FAN_SPEED_HOME = "set_profile_fan_speed_home" SERVICE_SET_PROFILE_FAN_SPEED_AWAY = "set_profile_fan_speed_away" SERVICE_SET_PROFILE_FAN_SPEED_BOOST = "set_profile_fan_speed_boost" +SERVICE_SET_PROFILE = "set_profile" SERVICE_TO_METHOD = { SERVICE_SET_PROFILE_FAN_SPEED_HOME: ServiceMethodDetails( @@ -86,6 +100,9 @@ SERVICE_TO_METHOD = { method="async_set_profile_fan_speed_boost", schema=SERVICE_SCHEMA_SET_PROFILE_FAN_SPEED, ), + SERVICE_SET_PROFILE: ServiceMethodDetails( + method="async_set_profile", schema=SERVICE_SCHEMA_SET_PROFILE + ), } @@ -183,6 +200,22 @@ class ValloxServiceHandler: return False return True + async def async_set_profile( + self, profile: str, duration: int | None = None + ) -> bool: + """Activate profile for given duration.""" + _LOGGER.debug("Activating profile %s for %s min", profile, duration) + try: + await self._client.set_profile( + I18N_KEY_TO_VALLOX_PROFILE[profile], duration + ) + except ValloxApiException as err: + _LOGGER.error( + "Error setting profile %d for duration %s: %s", profile, duration, err + ) + return False + return True + async def async_handle(self, call: ServiceCall) -> None: """Dispatch a service call.""" service_details = SERVICE_TO_METHOD.get(call.service) diff --git a/homeassistant/components/vallox/const.py b/homeassistant/components/vallox/const.py index a2494c594f5..418f57a22c8 100644 --- a/homeassistant/components/vallox/const.py +++ b/homeassistant/components/vallox/const.py @@ -22,14 +22,15 @@ DEFAULT_FAN_SPEED_HOME = 50 DEFAULT_FAN_SPEED_AWAY = 25 DEFAULT_FAN_SPEED_BOOST = 65 -VALLOX_PROFILE_TO_PRESET_MODE_SETTABLE = { - VALLOX_PROFILE.HOME: "Home", - VALLOX_PROFILE.AWAY: "Away", - VALLOX_PROFILE.BOOST: "Boost", - VALLOX_PROFILE.FIREPLACE: "Fireplace", +I18N_KEY_TO_VALLOX_PROFILE = { + "home": VALLOX_PROFILE.HOME, + "away": VALLOX_PROFILE.AWAY, + "boost": VALLOX_PROFILE.BOOST, + "fireplace": VALLOX_PROFILE.FIREPLACE, + "extra": VALLOX_PROFILE.EXTRA, } -VALLOX_PROFILE_TO_PRESET_MODE_REPORTABLE = { +VALLOX_PROFILE_TO_PRESET_MODE = { VALLOX_PROFILE.HOME: "Home", VALLOX_PROFILE.AWAY: "Away", VALLOX_PROFILE.BOOST: "Boost", @@ -37,8 +38,8 @@ VALLOX_PROFILE_TO_PRESET_MODE_REPORTABLE = { VALLOX_PROFILE.EXTRA: "Extra", } -PRESET_MODE_TO_VALLOX_PROFILE_SETTABLE = { - value: key for (key, value) in VALLOX_PROFILE_TO_PRESET_MODE_SETTABLE.items() +PRESET_MODE_TO_VALLOX_PROFILE = { + value: key for (key, value) in VALLOX_PROFILE_TO_PRESET_MODE.items() } VALLOX_CELL_STATE_TO_STR = { diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 4fe2cfd45d4..c9226110332 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -23,8 +23,8 @@ from .const import ( METRIC_KEY_PROFILE_FAN_SPEED_HOME, MODE_OFF, MODE_ON, - PRESET_MODE_TO_VALLOX_PROFILE_SETTABLE, - VALLOX_PROFILE_TO_PRESET_MODE_REPORTABLE, + PRESET_MODE_TO_VALLOX_PROFILE, + VALLOX_PROFILE_TO_PRESET_MODE, ) from .coordinator import ValloxDataUpdateCoordinator @@ -97,7 +97,7 @@ class ValloxFanEntity(ValloxEntity, FanEntity): self._client = client self._attr_unique_id = str(self._device_uuid) - self._attr_preset_modes = list(PRESET_MODE_TO_VALLOX_PROFILE_SETTABLE) + self._attr_preset_modes = list(PRESET_MODE_TO_VALLOX_PROFILE) @property def is_on(self) -> bool: @@ -108,7 +108,7 @@ class ValloxFanEntity(ValloxEntity, FanEntity): def preset_mode(self) -> str | None: """Return the current preset mode.""" vallox_profile = self.coordinator.data.profile - return VALLOX_PROFILE_TO_PRESET_MODE_REPORTABLE.get(vallox_profile) + return VALLOX_PROFILE_TO_PRESET_MODE.get(vallox_profile) @property def percentage(self) -> int | None: @@ -204,7 +204,7 @@ class ValloxFanEntity(ValloxEntity, FanEntity): return False try: - profile = PRESET_MODE_TO_VALLOX_PROFILE_SETTABLE[preset_mode] + profile = PRESET_MODE_TO_VALLOX_PROFILE[preset_mode] await self._client.set_profile(profile) except ValloxApiException as err: @@ -220,7 +220,7 @@ class ValloxFanEntity(ValloxEntity, FanEntity): Returns true if speed has been changed, false otherwise. """ vallox_profile = ( - PRESET_MODE_TO_VALLOX_PROFILE_SETTABLE[preset_mode] + PRESET_MODE_TO_VALLOX_PROFILE[preset_mode] if preset_mode is not None else self.coordinator.data.profile ) diff --git a/homeassistant/components/vallox/icons.json b/homeassistant/components/vallox/icons.json index f6beb55f1da..9123d1bfe9b 100644 --- a/homeassistant/components/vallox/icons.json +++ b/homeassistant/components/vallox/icons.json @@ -45,6 +45,9 @@ }, "set_profile_fan_speed_boost": { "service": "mdi:speedometer" + }, + "set_profile": { + "service": "mdi:fan" } } } diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index 0bb509a9c5a..fb9977cefaf 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -31,7 +31,7 @@ from .const import ( METRIC_KEY_MODE, MODE_ON, VALLOX_CELL_STATE_TO_STR, - VALLOX_PROFILE_TO_PRESET_MODE_REPORTABLE, + VALLOX_PROFILE_TO_PRESET_MODE, ) from .coordinator import ValloxDataUpdateCoordinator @@ -78,7 +78,7 @@ class ValloxProfileSensor(ValloxSensorEntity): def native_value(self) -> StateType: """Return the value reported by the sensor.""" vallox_profile = self.coordinator.data.profile - return VALLOX_PROFILE_TO_PRESET_MODE_REPORTABLE.get(vallox_profile) + return VALLOX_PROFILE_TO_PRESET_MODE.get(vallox_profile) # There is a quirk with respect to the fan speed reporting. The device keeps on reporting the last diff --git a/homeassistant/components/vallox/services.yaml b/homeassistant/components/vallox/services.yaml index e6bd3edad11..f2a55032b93 100644 --- a/homeassistant/components/vallox/services.yaml +++ b/homeassistant/components/vallox/services.yaml @@ -27,3 +27,24 @@ set_profile_fan_speed_boost: min: 0 max: 100 unit_of_measurement: "%" + +set_profile: + fields: + profile: + required: true + selector: + select: + translation_key: "profile" + options: + - "home" + - "away" + - "boost" + - "fireplace" + - "extra" + duration: + required: false + selector: + number: + min: 1 + max: 65535 + unit_of_measurement: "minutes" diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index 4df57b81bb5..8a30ed4ad01 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -133,6 +133,31 @@ "description": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::description%]" } } + }, + "set_profile": { + "name": "Activate profile for duration", + "description": "Activate a profile and optionally set duration.", + "fields": { + "profile": { + "name": "Profile", + "description": "Profile to activate" + }, + "duration": { + "name": "Duration", + "description": "Activation duration, if omitted device uses stored duration. Duration of 65535 activates profile without timeout. Duration only applies to Boost, Fireplace and Extra profiles." + } + } + } + }, + "selector": { + "profile": { + "options": { + "home": "Home", + "away": "Away", + "boost": "Boost", + "fireplace": "Fireplace", + "extra": "Extra" + } } } } diff --git a/tests/components/vallox/test_init.py b/tests/components/vallox/test_init.py index 58e46acd689..4fbde7e0357 100644 --- a/tests/components/vallox/test_init.py +++ b/tests/components/vallox/test_init.py @@ -4,7 +4,11 @@ import pytest from vallox_websocket_api import Profile from homeassistant.components.vallox import ( + ATTR_DURATION, + ATTR_PROFILE, ATTR_PROFILE_FAN_SPEED, + I18N_KEY_TO_VALLOX_PROFILE, + SERVICE_SET_PROFILE, SERVICE_SET_PROFILE_FAN_SPEED_AWAY, SERVICE_SET_PROFILE_FAN_SPEED_BOOST, SERVICE_SET_PROFILE_FAN_SPEED_HOME, @@ -12,7 +16,7 @@ from homeassistant.components.vallox import ( from homeassistant.components.vallox.const import DOMAIN from homeassistant.core import HomeAssistant -from .conftest import patch_set_fan_speed +from .conftest import patch_set_fan_speed, patch_set_profile from tests.common import MockConfigEntry @@ -47,3 +51,45 @@ async def test_create_service( # Assert set_fan_speed.assert_called_once_with(profile, 30) + + +@pytest.mark.parametrize( + ("profile", "duration"), + [ + ("home", None), + ("home", 15), + ("away", None), + ("away", 15), + ("boost", None), + ("boost", 15), + ("fireplace", None), + ("fireplace", 15), + ("extra", None), + ("extra", 15), + ], +) +async def test_set_profile_service( + hass: HomeAssistant, mock_entry: MockConfigEntry, profile: str, duration: int | None +) -> None: + """Test service for setting profile and duration.""" + # Act + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + with patch_set_profile() as set_profile: + service_data = {ATTR_PROFILE: profile} | ( + {ATTR_DURATION: duration} if duration is not None else {} + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PROFILE, + service_data=service_data, + ) + + await hass.async_block_till_done() + + # Assert + set_profile.assert_called_once_with( + I18N_KEY_TO_VALLOX_PROFILE[profile], duration + ) From 65b48aa90388c3240001f164bc115cf3b0d3cd42 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 8 Sep 2024 15:21:53 +0200 Subject: [PATCH 0403/1309] Add config flow to Mold indicator (#122600) * Add config flow to Mold indicator * strings * Add tests * Is a helper * Add back platform yaml * Fixes * Remove wait --- .../components/mold_indicator/__init__.py | 25 ++++ .../components/mold_indicator/config_flow.py | 96 +++++++++++++++ .../components/mold_indicator/const.py | 12 ++ .../components/mold_indicator/manifest.json | 4 +- .../components/mold_indicator/sensor.py | 44 +++++-- .../components/mold_indicator/strings.json | 42 +++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 12 +- tests/components/mold_indicator/conftest.py | 90 ++++++++++++++ .../mold_indicator/test_config_flow.py | 115 ++++++++++++++++++ tests/components/mold_indicator/test_init.py | 17 +++ .../components/mold_indicator/test_sensor.py | 12 ++ 12 files changed, 456 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/mold_indicator/config_flow.py create mode 100644 homeassistant/components/mold_indicator/const.py create mode 100644 homeassistant/components/mold_indicator/strings.json create mode 100644 tests/components/mold_indicator/conftest.py create mode 100644 tests/components/mold_indicator/test_config_flow.py create mode 100644 tests/components/mold_indicator/test_init.py diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index adadf41b2b0..c426b942af5 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -1 +1,26 @@ """Calculates mold growth indication from temperature and humidity.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Mold indicator from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Mold indicator config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py new file mode 100644 index 00000000000..cc8f05c102d --- /dev/null +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -0,0 +1,96 @@ +"""Config flow for Mold indicator.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import CONF_NAME, Platform +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + EntitySelector, + EntitySelectorConfig, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, +) + +from .const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, + DOMAIN, +) + + +async def validate_duplicate( + 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 + return user_input + + +DATA_SCHEMA_OPTIONS = vol.Schema( + { + vol.Required(CONF_CALIBRATION_FACTOR): NumberSelector( + NumberSelectorConfig(min=0, step="any", mode=NumberSelectorMode.BOX) + ) + } +) + +DATA_SCHEMA_CONFIG = vol.Schema( + { + vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_INDOOR_TEMP): EntitySelector( + EntitySelectorConfig( + domain=Platform.SENSOR, device_class=SensorDeviceClass.TEMPERATURE + ) + ), + vol.Required(CONF_INDOOR_HUMIDITY): EntitySelector( + EntitySelectorConfig( + domain=Platform.SENSOR, device_class=SensorDeviceClass.HUMIDITY + ) + ), + vol.Required(CONF_OUTDOOR_TEMP): EntitySelector( + EntitySelectorConfig( + domain=Platform.SENSOR, device_class=SensorDeviceClass.TEMPERATURE + ) + ), + } +).extend(DATA_SCHEMA_OPTIONS.schema) + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=DATA_SCHEMA_CONFIG, + validate_user_input=validate_duplicate, + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + DATA_SCHEMA_OPTIONS, + validate_user_input=validate_duplicate, + ) +} + + +class MoldIndicatorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Mold indicator.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/mold_indicator/const.py b/homeassistant/components/mold_indicator/const.py new file mode 100644 index 00000000000..15fdf51bce3 --- /dev/null +++ b/homeassistant/components/mold_indicator/const.py @@ -0,0 +1,12 @@ +"""Constants for Mold indicator component.""" + +from __future__ import annotations + +DOMAIN = "mold_indicator" + +CONF_CALIBRATION_FACTOR = "calibration_factor" +CONF_INDOOR_HUMIDITY = "indoor_humidity_sensor" +CONF_INDOOR_TEMP = "indoor_temp_sensor" +CONF_OUTDOOR_TEMP = "outdoor_temp_sensor" + +DEFAULT_NAME = "Mold Indicator" diff --git a/homeassistant/components/mold_indicator/manifest.json b/homeassistant/components/mold_indicator/manifest.json index 5ebccb5f92d..b57f1c471ef 100644 --- a/homeassistant/components/mold_indicator/manifest.json +++ b/homeassistant/components/mold_indicator/manifest.json @@ -2,7 +2,9 @@ "domain": "mold_indicator", "name": "Mold Indicator", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mold_indicator", - "iot_class": "local_polling", + "integration_type": "helper", + "iot_class": "calculated", "quality_scale": "internal" } diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 2d80bc9f6e1..e96f53a17bb 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, @@ -37,17 +38,19 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateTyp from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import METRIC_SYSTEM +from .const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, +) + _LOGGER = logging.getLogger(__name__) ATTR_CRITICAL_TEMP = "estimated_critical_temp" ATTR_DEWPOINT = "dewpoint" -CONF_CALIBRATION_FACTOR = "calibration_factor" -CONF_INDOOR_HUMIDITY = "indoor_humidity_sensor" -CONF_INDOOR_TEMP = "indoor_temp_sensor" -CONF_OUTDOOR_TEMP = "outdoor_temp_sensor" - -DEFAULT_NAME = "Mold Indicator" MAGNUS_K2 = 17.62 MAGNUS_K3 = 243.12 @@ -70,7 +73,7 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up MoldIndicator sensor.""" - name: str = config[CONF_NAME] + name: str = config.get(CONF_NAME, DEFAULT_NAME) indoor_temp_sensor: str = config[CONF_INDOOR_TEMP] outdoor_temp_sensor: str = config[CONF_OUTDOOR_TEMP] indoor_humidity_sensor: str = config[CONF_INDOOR_HUMIDITY] @@ -91,6 +94,33 @@ async def async_setup_platform( ) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Mold indicator sensor entry.""" + name: str = entry.options[CONF_NAME] + indoor_temp_sensor: str = entry.options[CONF_INDOOR_TEMP] + outdoor_temp_sensor: str = entry.options[CONF_OUTDOOR_TEMP] + indoor_humidity_sensor: str = entry.options[CONF_INDOOR_HUMIDITY] + calib_factor: float = entry.options[CONF_CALIBRATION_FACTOR] + + async_add_entities( + [ + MoldIndicator( + name, + hass.config.units is METRIC_SYSTEM, + indoor_temp_sensor, + outdoor_temp_sensor, + indoor_humidity_sensor, + calib_factor, + ) + ], + False, + ) + + class MoldIndicator(SensorEntity): """Represents a MoldIndication sensor.""" diff --git a/homeassistant/components/mold_indicator/strings.json b/homeassistant/components/mold_indicator/strings.json new file mode 100644 index 00000000000..2e34bcc1ba1 --- /dev/null +++ b/homeassistant/components/mold_indicator/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "description": "Add Mold indicator helper", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "indoor_humidity_sensor": "Indoor humidity sensor", + "indoor_temp_sensor": "Indoor temperature sensor", + "outdoor_temp_sensor": "Outdoor temperature sensor", + "calibration_factor": "Calibration factor" + }, + "data_description": { + "name": "Name for the created entity.", + "indoor_humidity_sensor": "The entity ID of the indoor humidity sensor.", + "indoor_temp_sensor": "The entity ID of the indoor temperature sensor.", + "outdoor_temp_sensor": "The entity ID of the outdoor temperature sensor.", + "calibration_factor": "Needs to be calibrated to the critical point in the room." + } + } + } + }, + "options": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "init": { + "description": "Adjust the calibration factor as required", + "data": { + "calibration_factor": "[%key:component::mold_indicator::config::step::user::data::calibration_factor%]" + }, + "data_description": { + "calibration_factor": "[%key:component::mold_indicator::config::step::user::data_description::calibration_factor%]" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f03c980a2d4..2e38d608bd9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -12,6 +12,7 @@ FLOWS = { "history_stats", "integration", "min_max", + "mold_indicator", "random", "statistics", "switch_as_x", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a01e20909b6..4a6be3f0a1a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3790,12 +3790,6 @@ "config_flow": true, "iot_class": "local_push" }, - "mold_indicator": { - "name": "Mold Indicator", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "monessen": { "name": "Monessen", "integration_type": "virtual", @@ -7307,6 +7301,12 @@ "config_flow": true, "iot_class": "calculated" }, + "mold_indicator": { + "name": "Mold Indicator", + "integration_type": "helper", + "config_flow": true, + "iot_class": "calculated" + }, "random": { "name": "Random", "integration_type": "helper", diff --git a/tests/components/mold_indicator/conftest.py b/tests/components/mold_indicator/conftest.py new file mode 100644 index 00000000000..11f07e1db35 --- /dev/null +++ b/tests/components/mold_indicator/conftest.py @@ -0,0 +1,90 @@ +"""Fixtures for the Mold indicator integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.mold_indicator.const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + CONF_NAME, + PERCENTAGE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Automatically path mold indicator.""" + with patch( + "homeassistant.components.mold_indicator.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="get_config") +async def get_config_to_integration_load() -> dict[str, Any]: + """Return configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + return { + 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: 2.0, + } + + +@pytest.fixture(name="loaded_entry") +async def load_integration( + hass: HomeAssistant, get_config: dict[str, Any] +) -> MockConfigEntry: + """Set up the Mold indicator integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + options=get_config, + entry_id="1", + title=DEFAULT_NAME, + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + hass.states.async_set( + "sensor.indoor_temp", + "10", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + hass.states.async_set( + "sensor.outdoor_temp", + "10", + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + ) + hass.states.async_set( + "sensor.indoor_humidity", "0", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE} + ) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/mold_indicator/test_config_flow.py b/tests/components/mold_indicator/test_config_flow.py new file mode 100644 index 00000000000..7a766be11f5 --- /dev/null +++ b/tests/components/mold_indicator/test_config_flow.py @@ -0,0 +1,115 @@ +"""Test the Mold indicator config flow.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant.components.mold_indicator.const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form_sensor(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form for sensor.""" + + 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: 2.0, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["version"] == 1 + 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: 2.0, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test options flow.""" + + result = await hass.config_entries.options.async_init(loaded_entry.entry_id) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CALIBRATION_FACTOR: 3.0, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + 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: 3.0, + } + + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + # 3 input entities + resulting mold indicator sensor + assert len(hass.states.async_all()) == 4 + + state = hass.states.get("sensor.mold_indicator") + assert state is not None + + +async def test_entry_already_exist( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test abort when entry already exist.""" + + 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: 2.0, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py new file mode 100644 index 00000000000..5fd6b11c8fe --- /dev/null +++ b/tests/components/mold_indicator/test_init.py @@ -0,0 +1,17 @@ +"""Test Mold indicator component setup process.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: + """Test unload an entry.""" + + assert loaded_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(loaded_entry.entry_id) + await hass.async_block_till_done() + assert loaded_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/mold_indicator/test_sensor.py b/tests/components/mold_indicator/test_sensor.py index 2de1d34b403..bb3f7c4fc93 100644 --- a/tests/components/mold_indicator/test_sensor.py +++ b/tests/components/mold_indicator/test_sensor.py @@ -16,6 +16,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + @pytest.fixture(autouse=True) def init_sensors_fixture(hass: HomeAssistant) -> None: @@ -52,6 +54,16 @@ async def test_setup(hass: HomeAssistant) -> None: assert moldind.attributes.get("unit_of_measurement") == PERCENTAGE +async def test_setup_from_config_entry( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """Test the mold indicator sensor setup from a config entry.""" + + moldind = hass.states.get("sensor.mold_indicator") + assert moldind + assert moldind.attributes.get("unit_of_measurement") == PERCENTAGE + + async def test_invalidcalib(hass: HomeAssistant) -> None: """Test invalid sensor values.""" hass.states.async_set( From 99a50fe874254e41cdce3ad5a1c5ddba50781d52 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sun, 8 Sep 2024 14:40:53 +0100 Subject: [PATCH 0404/1309] Correct Mastodon IOT class (#125511) * Correct iot class * Fix hassfest --- homeassistant/components/mastodon/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json index 40fd9d2f7b3..20c506e7766 100644 --- a/homeassistant/components/mastodon/manifest.json +++ b/homeassistant/components/mastodon/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mastodon", "integration_type": "service", - "iot_class": "cloud_push", + "iot_class": "cloud_polling", "loggers": ["mastodon"], "requirements": ["Mastodon.py==1.8.1"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4a6be3f0a1a..1265cc842da 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3512,7 +3512,7 @@ "name": "Mastodon", "integration_type": "service", "config_flow": true, - "iot_class": "cloud_push" + "iot_class": "cloud_polling" }, "matrix": { "name": "Matrix", From aa8c4a6eb7b74b3e689a4cc7e0c83436f8b907c0 Mon Sep 17 00:00:00 2001 From: Ian Date: Sun, 8 Sep 2024 06:42:26 -0700 Subject: [PATCH 0405/1309] Add ability to play plex media as the non-primary user (#122039) * Adds ability to play media as the non-primary user * Add return type for set function --- homeassistant/components/plex/server.py | 12 +++++++++++ homeassistant/components/plex/services.py | 5 +++++ tests/components/plex/test_media_search.py | 25 ++++++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index fbb98e8e19f..0716b3606af 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -2,6 +2,7 @@ from __future__ import annotations +from copy import copy import logging import ssl import time @@ -664,3 +665,14 @@ class PlexServer: def sensor_attributes(self): """Return active session information for use in activity sensor.""" return {x.sensor_user: x.sensor_title for x in self.active_sessions.values()} + + def set_plex_server(self, plex_server: PlexServer) -> None: + """Set the PlexServer instance.""" + self._plex_server = plex_server + + def switch_user(self, username: str) -> PlexServer: + """Return a shallow copy of a PlexServer as the provided user.""" + new_server = copy(self) + new_server.set_plex_server(self.plex_server.switchUser(username)) + + return new_server diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index e0fe79be182..cbf72966413 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -161,6 +161,11 @@ def process_plex_payload( if not plex_server: plex_server = get_plex_server(hass) + if isinstance(content, dict): + if plex_user := content.pop("username", None): + _LOGGER.debug("Switching to Plex user: %s", plex_user) + plex_server = plex_server.switch_user(plex_user) + if content_type == "station": if not supports_playqueues: raise HomeAssistantError("Plex stations are not supported on this device") diff --git a/tests/components/plex/test_media_search.py b/tests/components/plex/test_media_search.py index 8219cbe27b6..04d91e8825c 100644 --- a/tests/components/plex/test_media_search.py +++ b/tests/components/plex/test_media_search.py @@ -57,6 +57,31 @@ async def test_media_lookups( ) assert "Media for key 123 not found" in str(excinfo.value) + # Search with a different specified username + with ( + patch( + "plexapi.library.LibrarySection.search", + __qualname__="search", + ) as search, + patch( + "plexapi.myplex.MyPlexAccount.user", + __qualname__="user", + ) as plex_account_user, + ): + plex_account_user.return_value.get_token.return_value = "token" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player_id, + ATTR_MEDIA_CONTENT_TYPE: MediaType.EPISODE, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "TV Shows", "show_name": "TV Show", "username": "Kids"}', + }, + True, + ) + search.assert_called_with(**{"show.title": "TV Show", "libtype": "show"}) + plex_account_user.assert_called_with("Kids") + # TV show searches with pytest.raises(MediaNotFound) as excinfo: await hass.services.async_call( From 7bab3579ecd21305f49dfb99fbc07b606a5b3387 Mon Sep 17 00:00:00 2001 From: Janusz Gregorczyk Date: Sun, 8 Sep 2024 16:50:24 +0200 Subject: [PATCH 0406/1309] Set required attribute when using Todoist Sync API reminder_add command (#122644) * Set type=absolute when using Todoist Sync API reminder_add command. This argument is required: ref.: https://developer.todoist.com/sync/v8/#add-a-reminder * Fix --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/todoist/calendar.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 2acd4ea6dc6..31470633cc6 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -331,7 +331,11 @@ def async_register_services( # noqa: C901 "type": "reminder_add", "temp_id": str(uuid.uuid1()), "uuid": str(uuid.uuid1()), - "args": {"item_id": api_task.id, "due": reminder_due}, + "args": { + "item_id": api_task.id, + "type": "absolute", + "due": reminder_due, + }, } ] } From 6967c7058067e4ab8368b00c1c1bd6603b52b749 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 8 Sep 2024 17:11:19 +0200 Subject: [PATCH 0407/1309] Change Knocki integration type to hub (#124863) * Change Knocki integration type * Fix --- homeassistant/components/knocki/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/knocki/manifest.json b/homeassistant/components/knocki/manifest.json index 4195320f382..fb751d90cac 100644 --- a/homeassistant/components/knocki/manifest.json +++ b/homeassistant/components/knocki/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@joostlek", "@jgatto1", "@JakeBosh"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/knocki", - "integration_type": "device", + "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["knocki"], "requirements": ["knocki==0.3.1"] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1265cc842da..cd37adc3f71 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3089,7 +3089,7 @@ }, "knocki": { "name": "Knocki", - "integration_type": "device", + "integration_type": "hub", "config_flow": true, "iot_class": "cloud_push" }, From 8d0dda652324b84ca18cff1ead9201565b072ff9 Mon Sep 17 00:00:00 2001 From: Whitney Young Date: Sun, 8 Sep 2024 08:31:58 -0700 Subject: [PATCH 0408/1309] Remove notify support for templates (#122820) --- homeassistant/components/notify/__init__.py | 16 ++------- homeassistant/components/notify/const.py | 4 +-- homeassistant/components/notify/legacy.py | 24 +++---------- tests/auth/mfa_modules/test_notify.py | 12 +++---- tests/components/notify/test_legacy.py | 38 ++------------------- 5 files changed, 15 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 31c7b8e4d70..f9b0a64db3d 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -18,7 +18,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -39,7 +38,6 @@ from .legacy import ( # noqa: F401 async_reload, async_reset_platform, async_setup_legacy, - check_templates_warn, ) from .repairs import migrate_notify_issue # noqa: F401 @@ -90,22 +88,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def persistent_notification(service: ServiceCall) -> None: """Send notification via the built-in persistent_notify integration.""" - message: Template = service.data[ATTR_MESSAGE] - check_templates_warn(hass, message) - - title = None - title_tpl: Template | None - if title_tpl := service.data.get(ATTR_TITLE): - check_templates_warn(hass, title_tpl) - title = title_tpl.async_render(parse_result=False) + message: str = service.data[ATTR_MESSAGE] + title: str | None = service.data.get(ATTR_TITLE) notification_id = None if data := service.data.get(ATTR_DATA): notification_id = data.get(pn.ATTR_NOTIFICATION_ID) - pn.async_create( - hass, message.async_render(parse_result=False), title, notification_id - ) + pn.async_create(hass, message, title, notification_id) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/notify/const.py b/homeassistant/components/notify/const.py index 6cd957e3afe..29064f24a66 100644 --- a/homeassistant/components/notify/const.py +++ b/homeassistant/components/notify/const.py @@ -30,8 +30,8 @@ SERVICE_PERSISTENT_NOTIFICATION = "persistent_notification" NOTIFY_SERVICE_SCHEMA = vol.Schema( { - vol.Required(ATTR_MESSAGE): cv.template, - vol.Optional(ATTR_TITLE): cv.template, + vol.Required(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_TITLE): cv.string, vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_DATA): dict, } diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index dcb148a99f5..a210e80242e 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -13,7 +13,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery from homeassistant.helpers.service import async_set_service_schema -from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import ( @@ -155,19 +154,6 @@ def async_setup_legacy( ] -@callback -def check_templates_warn(hass: HomeAssistant, tpl: Template) -> None: - """Warn user that passing templates to notify service is deprecated.""" - if tpl.is_static or hass.data.get("notify_template_warned"): - return - - hass.data["notify_template_warned"] = True - LOGGER.warning( - "Passing templates to notify service is deprecated and will be removed in" - " 2021.12. Automations and scripts handle templates automatically" - ) - - @bind_hass async def async_reload(hass: HomeAssistant, integration_name: str) -> None: """Register notify services for an integration.""" @@ -255,19 +241,17 @@ class BaseNotificationService: async def _async_notify_message_service(self, service: ServiceCall) -> None: """Handle sending notification message service calls.""" kwargs = {} - message: Template = service.data[ATTR_MESSAGE] - title: Template | None + message: str = service.data[ATTR_MESSAGE] + title: str | None if title := service.data.get(ATTR_TITLE): - check_templates_warn(self.hass, title) - kwargs[ATTR_TITLE] = title.async_render(parse_result=False) + kwargs[ATTR_TITLE] = title if self.registered_targets.get(service.service) is not None: kwargs[ATTR_TARGET] = [self.registered_targets[service.service]] elif service.data.get(ATTR_TARGET) is not None: kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET) - check_templates_warn(self.hass, message) - kwargs[ATTR_MESSAGE] = message.async_render(parse_result=False) + kwargs[ATTR_MESSAGE] = message kwargs[ATTR_DATA] = service.data.get(ATTR_DATA) await self.async_send_message(**kwargs) diff --git a/tests/auth/mfa_modules/test_notify.py b/tests/auth/mfa_modules/test_notify.py index d6f4d80f99e..8047ba2fef3 100644 --- a/tests/auth/mfa_modules/test_notify.py +++ b/tests/auth/mfa_modules/test_notify.py @@ -165,8 +165,7 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None: assert notify_call.domain == "notify" assert notify_call.service == "test-notify" message = notify_call.data["message"] - message.hass = hass - assert MOCK_CODE in message.async_render() + assert MOCK_CODE in message with patch("pyotp.HOTP.verify", return_value=False): result = await hass.auth.login_flow.async_configure( @@ -224,8 +223,7 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None: assert notify_call.domain == "notify" assert notify_call.service == "test-notify" message = notify_call.data["message"] - message.hass = hass - assert MOCK_CODE in message.async_render() + assert MOCK_CODE in message with patch("pyotp.HOTP.verify", return_value=True): result = await hass.auth.login_flow.async_configure( @@ -264,8 +262,7 @@ async def test_setup_user_notify_service(hass: HomeAssistant) -> None: assert notify_call.domain == "notify" assert notify_call.service == "test1" message = notify_call.data["message"] - message.hass = hass - assert MOCK_CODE in message.async_render() + assert MOCK_CODE in message with patch("pyotp.HOTP.at", return_value=MOCK_CODE_2): step = await flow.async_step_setup({"code": "invalid"}) @@ -281,8 +278,7 @@ async def test_setup_user_notify_service(hass: HomeAssistant) -> None: assert notify_call.domain == "notify" assert notify_call.service == "test1" message = notify_call.data["message"] - message.hass = hass - assert MOCK_CODE_2 in message.async_render() + assert MOCK_CODE_2 in message with patch("pyotp.HOTP.verify", return_value=True): step = await flow.async_step_setup({"code": MOCK_CODE_2}) diff --git a/tests/components/notify/test_legacy.py b/tests/components/notify/test_legacy.py index 79a1b75dcae..eeacf915b03 100644 --- a/tests/components/notify/test_legacy.py +++ b/tests/components/notify/test_legacy.py @@ -19,7 +19,7 @@ from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component -from tests.common import MockPlatform, async_get_persistent_notifications, mock_platform +from tests.common import MockPlatform, mock_platform class NotificationService(notify.BaseNotificationService): @@ -186,24 +186,6 @@ async def test_remove_targets(hass: HomeAssistant) -> None: assert test.registered_targets == {"test_c": 1} -async def test_warn_template( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test warning when template used.""" - assert await async_setup_component(hass, "notify", {}) - - await hass.services.async_call( - "notify", - "persistent_notification", - {"message": "{{ 1 + 1 }}", "title": "Test notif {{ 1 + 1 }}"}, - blocking=True, - ) - # We should only log it once - assert caplog.text.count("Passing templates to notify service is deprecated") == 1 - notifications = async_get_persistent_notifications(hass) - assert len(notifications) == 1 - - async def test_invalid_platform( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: @@ -550,27 +532,11 @@ async def test_sending_none_message(hass: HomeAssistant, tmp_path: Path) -> None notify.DOMAIN, notify.SERVICE_NOTIFY, {notify.ATTR_MESSAGE: None} ) assert ( - str(exc.value) - == "template value is None for dictionary value @ data['message']" + str(exc.value) == "string value is None for dictionary value @ data['message']" ) send_message_mock.assert_not_called() -async def test_sending_templated_message(hass: HomeAssistant, tmp_path: Path) -> None: - """Send a templated message.""" - send_message_mock = await help_setup_notify(hass, tmp_path) - hass.states.async_set("sensor.temperature", 10) - data = { - notify.ATTR_MESSAGE: "{{states.sensor.temperature.state}}", - notify.ATTR_TITLE: "{{ states.sensor.temperature.name }}", - } - await hass.services.async_call(notify.DOMAIN, notify.SERVICE_NOTIFY, data) - await hass.async_block_till_done() - send_message_mock.assert_called_once_with( - "10", {"title": "temperature", "data": None} - ) - - async def test_method_forwards_correct_data( hass: HomeAssistant, tmp_path: Path ) -> None: From 2c48f9aa4cad0719999cebb625fc862d788dcc2e Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sun, 8 Sep 2024 11:34:27 -0400 Subject: [PATCH 0409/1309] FIx Sonos announce regression issue (#125515) * initial commit * initial commit --- .../components/sonos/media_player.py | 24 +++++++++++++++---- tests/components/sonos/test_media_player.py | 21 ++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index c4d417b0394..7711a1e88ea 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -84,6 +84,7 @@ REPEAT_TO_SONOS = { SONOS_TO_REPEAT = {meaning: mode for mode, meaning in REPEAT_TO_SONOS.items()} UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"] +ANNOUNCE_NOT_SUPPORTED_ERRORS: list[str] = ["globalError"] SERVICE_SNAPSHOT = "snapshot" SERVICE_RESTORE = "restore" @@ -556,11 +557,24 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) from exc if response.get("success"): return - raise HomeAssistantError( - translation_domain=SONOS_DOMAIN, - translation_key="announce_media_error", - translation_placeholders={"media_id": media_id, "response": response}, - ) + if response.get("type") in ANNOUNCE_NOT_SUPPORTED_ERRORS: + # If the speaker does not support announce do not raise and + # fall through to_play_media to play the clip directly. + _LOGGER.debug( + "Speaker %s does not support announce, media_id %s response %s", + self.speaker.zone_name, + media_id, + response, + ) + else: + raise HomeAssistantError( + translation_domain=SONOS_DOMAIN, + translation_key="announce_media_error", + translation_placeholders={ + "media_id": media_id, + "response": response, + }, + ) if spotify.is_spotify_media_type(media_type): media_type = spotify.resolve_spotify_media_type(media_type) diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 9887601a0a3..63b2c8889ec 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1163,6 +1163,27 @@ async def test_play_media_announce( ) assert sonos_websocket.play_clip.call_count == 1 + # Test speakers that do not support announce. This + # will result in playing the clip directly via play_uri + sonos_websocket.play_clip.reset_mock() + sonos_websocket.play_clip.side_effect = None + retval = {"success": 0, "type": "globalError"} + sonos_websocket.play_clip.return_value = [retval, {}] + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: content_id, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + assert sonos_websocket.play_clip.call_count == 1 + soco.play_uri.assert_called_with(content_id, force_radio=False) + async def test_media_get_queue( hass: HomeAssistant, From 634582eab73f8f111d3f6d157f995160551027d9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 8 Sep 2024 11:36:36 -0400 Subject: [PATCH 0410/1309] Ensure Linkplay model_id is always defined (#125488) Linkplay: ensure model_id always defined --- homeassistant/components/linkplay/media_player.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index af18b018403..e6ea5c5f11c 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -156,7 +156,9 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): ] manufacturer, model = get_info_from_project(bridge.device.properties["project"]) - if model != MANUFACTURER_GENERIC: + if model == MANUFACTURER_GENERIC: + model_id = None + else: model_id = bridge.device.properties["project"] self._attr_device_info = dr.DeviceInfo( From 54052792738e62366b2e07ad6dfe3b1d9049b0c1 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sun, 8 Sep 2024 11:39:23 -0400 Subject: [PATCH 0411/1309] Fix Schlage removed locks (#123627) * Fix bugs when a lock is no longer returned by the API * Changes requested during review * Only mark unavailable if lock is not present * Remove stale comment * Remove over-judicious nullability checks * Remove another unnecessary null check --- homeassistant/components/schlage/entity.py | 3 +- homeassistant/components/schlage/lock.py | 5 +- homeassistant/components/schlage/sensor.py | 5 +- .../components/schlage/test_binary_sensor.py | 34 +++++++++--- tests/components/schlage/test_lock.py | 54 +++++++++++++++++-- 5 files changed, 84 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/schlage/entity.py b/homeassistant/components/schlage/entity.py index 61bdbcb7730..cc4745e51cc 100644 --- a/homeassistant/components/schlage/entity.py +++ b/homeassistant/components/schlage/entity.py @@ -42,5 +42,4 @@ class SchlageEntity(CoordinatorEntity[SchlageDataUpdateCoordinator]): @property def available(self) -> bool: """Return if entity is available.""" - # When is_locked is None the lock is unavailable. - return super().available and self._lock.is_locked is not None + return super().available and self.device_id in self.coordinator.data.locks diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index 7e6f60211b0..59ce00e809a 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -42,8 +42,9 @@ class SchlageLockEntity(SchlageEntity, LockEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._update_attrs() - return super()._handle_coordinator_update() + if self.device_id in self.coordinator.data.locks: + self._update_attrs() + super()._handle_coordinator_update() def _update_attrs(self) -> None: """Update our internal state attributes.""" diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py index 2cf1694e111..8de09fa4cbb 100644 --- a/homeassistant/components/schlage/sensor.py +++ b/homeassistant/components/schlage/sensor.py @@ -64,5 +64,6 @@ class SchlageBatterySensor(SchlageEntity, SensorEntity): @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._attr_native_value = getattr(self._lock, self.entity_description.key) - return super()._handle_coordinator_update() + if self.device_id in self.coordinator.data.locks: + self._attr_native_value = getattr(self._lock, self.entity_description.key) + super()._handle_coordinator_update() diff --git a/tests/components/schlage/test_binary_sensor.py b/tests/components/schlage/test_binary_sensor.py index 97f11577b86..dbbc5b07b87 100644 --- a/tests/components/schlage/test_binary_sensor.py +++ b/tests/components/schlage/test_binary_sensor.py @@ -3,37 +3,56 @@ from datetime import timedelta from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory from pyschlage.exceptions import UnknownError from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed async def test_keypad_disabled_binary_sensor( - hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, + mock_schlage: Mock, + mock_lock: Mock, + mock_added_config_entry: ConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test the keypad_disabled binary_sensor.""" mock_lock.keypad_disabled.reset_mock() mock_lock.keypad_disabled.return_value = True # Make the coordinator refresh data. - async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") assert keypad is not None - assert keypad.state == "on" + assert keypad.state == STATE_ON assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM mock_lock.keypad_disabled.assert_called_once_with([]) + mock_schlage.locks.return_value = [] + # Make the coordinator refresh data. + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") + assert keypad is not None + assert keypad.state == STATE_UNAVAILABLE + async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure( - hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, + mock_schlage: Mock, + mock_lock: Mock, + mock_added_config_entry: ConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test the keypad_disabled binary_sensor.""" mock_lock.keypad_disabled.reset_mock() @@ -42,12 +61,13 @@ async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure( mock_lock.logs.side_effect = UnknownError("Cannot load logs") # Make the coordinator refresh data. - async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") assert keypad is not None - assert keypad.state == "on" + assert keypad.state == STATE_ON assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM mock_lock.keypad_disabled.assert_called_once_with([]) diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 6c06f124693..ab0f4f5d863 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -3,12 +3,20 @@ from datetime import timedelta from unittest.mock import Mock +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_UNLOCK, + STATE_JAMMED, + STATE_UNAVAILABLE, + STATE_UNLOCKED, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed @@ -26,6 +34,40 @@ async def test_lock_device_registry( assert device.manufacturer == "Schlage" +async def test_lock_attributes( + hass: HomeAssistant, + mock_added_config_entry: ConfigEntry, + mock_schlage: Mock, + mock_lock: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test lock attributes.""" + lock = hass.states.get("lock.vault_door") + assert lock is not None + assert lock.state == STATE_UNLOCKED + assert lock.attributes["changed_by"] == "thumbturn" + + mock_lock.is_locked = False + mock_lock.is_jammed = True + # Make the coordinator refresh data. + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + lock = hass.states.get("lock.vault_door") + assert lock is not None + assert lock.state == STATE_JAMMED + + mock_schlage.locks.return_value = [] + # Make the coordinator refresh data. + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + lock = hass.states.get("lock.vault_door") + assert lock is not None + assert lock.state == STATE_UNAVAILABLE + assert "changed_by" not in lock.attributes + + async def test_lock_services( hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry ) -> None: @@ -52,14 +94,18 @@ async def test_lock_services( async def test_changed_by( - hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry + hass: HomeAssistant, + mock_lock: Mock, + mock_added_config_entry: ConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test population of the changed_by attribute.""" mock_lock.last_changed_by.reset_mock() mock_lock.last_changed_by.return_value = "access code - foo" # Make the coordinator refresh data. - async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) mock_lock.last_changed_by.assert_called_once_with() From b3d6f8861fb248489af8d348b652a5c7c990b32a Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 8 Sep 2024 17:17:30 +0100 Subject: [PATCH 0412/1309] Fix ring notifications (#124879) * Enable ring event listener to fix missing notifications * Fix pylint test CI fail * Reinstate binary sensor and add deprecation issues * Add tests * Update post review * Remove PropertyMock * Update post review * Split out adding event platform --- homeassistant/components/ring/__init__.py | 53 ++++- .../components/ring/binary_sensor.py | 140 +++++++----- homeassistant/components/ring/config_flow.py | 13 +- homeassistant/components/ring/const.py | 2 +- homeassistant/components/ring/coordinator.py | 142 +++++++++++-- homeassistant/components/ring/entity.py | 95 ++++++++- homeassistant/components/ring/sensor.py | 25 ++- homeassistant/components/ring/strings.json | 15 ++ tests/components/ring/common.py | 16 ++ tests/components/ring/conftest.py | 15 +- tests/components/ring/device_mocks.py | 18 +- tests/components/ring/test_binary_sensor.py | 200 ++++++++++++++++-- tests/components/ring/test_init.py | 68 +++++- tests/components/ring/test_sensor.py | 62 ++++-- 14 files changed, 720 insertions(+), 144 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 3714802b63a..88c7467af91 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -5,18 +5,23 @@ from __future__ import annotations from dataclasses import dataclass import logging from typing import Any, cast +import uuid from ring_doorbell import Auth, Ring, RingDevices from homeassistant.config_entries import ConfigEntry -from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__ +from homeassistant.const import APPLICATION_NAME, CONF_TOKEN from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + instance_id, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import DOMAIN, PLATFORMS -from .coordinator import RingDataCoordinator, RingNotificationsCoordinator +from .const import CONF_LISTEN_CREDENTIALS, DOMAIN, PLATFORMS +from .coordinator import RingDataCoordinator, RingListenCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,12 +33,26 @@ class RingData: api: Ring devices: RingDevices devices_coordinator: RingDataCoordinator - notifications_coordinator: RingNotificationsCoordinator + listen_coordinator: RingListenCoordinator type RingConfigEntry = ConfigEntry[RingData] +async def get_auth_agent_id(hass: HomeAssistant) -> tuple[str, str]: + """Return user-agent and hardware id for Auth instantiation. + + user_agent will be the display name in the ring.com authorised devices. + hardware_id will uniquely describe the authorised HA device. + """ + user_agent = f"{APPLICATION_NAME}/{DOMAIN}-integration" + + # Generate a new uuid from the instance_uuid to keep the HA one private + instance_uuid = uuid.UUID(hex=await instance_id.async_get(hass)) + hardware_id = str(uuid.uuid5(instance_uuid, user_agent)) + return user_agent, hardware_id + + async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool: """Set up a config entry.""" @@ -44,26 +63,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool data={**entry.data, CONF_TOKEN: token}, ) + def listen_credentials_updater(token: dict[str, Any]) -> None: + """Handle from async context when token is updated.""" + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_LISTEN_CREDENTIALS: token}, + ) + + user_agent, hardware_id = await get_auth_agent_id(hass) + client_session = async_get_clientsession(hass) auth = Auth( - f"{APPLICATION_NAME}/{__version__}", + user_agent, entry.data[CONF_TOKEN], token_updater, - http_client_session=async_get_clientsession(hass), + hardware_id=hardware_id, + http_client_session=client_session, ) ring = Ring(auth) await _migrate_old_unique_ids(hass, entry.entry_id) devices_coordinator = RingDataCoordinator(hass, ring) - notifications_coordinator = RingNotificationsCoordinator(hass, ring) + listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS) + listen_coordinator = RingListenCoordinator( + hass, ring, listen_credentials, listen_credentials_updater + ) + await devices_coordinator.async_config_entry_first_refresh() - await notifications_coordinator.async_config_entry_first_refresh() entry.runtime_data = RingData( api=ring, devices=ring.devices(), devices_coordinator=devices_coordinator, - notifications_coordinator=notifications_coordinator, + listen_coordinator=listen_coordinator, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -91,7 +123,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool ) for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN): await loaded_entry.runtime_data.devices_coordinator.async_refresh() - await loaded_entry.runtime_data.notifications_coordinator.async_refresh() # register service hass.services.async_register(DOMAIN, "update", async_refresh_all) diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 2fb557ddde0..85a916e95cd 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -2,46 +2,62 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime -from typing import Any +from typing import Any, Generic -from ring_doorbell import Ring, RingEvent, RingGeneric +from ring_doorbell import RingCapability, RingEvent +from ring_doorbell.const import KIND_DING, KIND_MOTION from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import Platform +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_at from . import RingConfigEntry -from .coordinator import RingNotificationsCoordinator -from .entity import RingBaseEntity +from .coordinator import RingListenCoordinator +from .entity import ( + DeprecatedInfo, + RingBaseEntity, + RingDeviceT, + RingEntityDescription, + async_check_create_deprecated, +) @dataclass(frozen=True, kw_only=True) -class RingBinarySensorEntityDescription(BinarySensorEntityDescription): +class RingBinarySensorEntityDescription( + BinarySensorEntityDescription, RingEntityDescription, Generic[RingDeviceT] +): """Describes Ring binary sensor entity.""" - exists_fn: Callable[[RingGeneric], bool] + capability: RingCapability BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( RingBinarySensorEntityDescription( - key="ding", - translation_key="ding", + key=KIND_DING, + translation_key=KIND_DING, device_class=BinarySensorDeviceClass.OCCUPANCY, - exists_fn=lambda device: device.family - in {"doorbots", "authorized_doorbots", "other"}, + capability=RingCapability.DING, + deprecated_info=DeprecatedInfo( + new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0" + ), ), RingBinarySensorEntityDescription( - key="motion", + key=KIND_MOTION, + translation_key=KIND_MOTION, device_class=BinarySensorDeviceClass.MOTION, - exists_fn=lambda device: device.family - in {"doorbots", "authorized_doorbots", "stickup_cams"}, + capability=RingCapability.MOTION_DETECTION, + deprecated_info=DeprecatedInfo( + new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0" + ), ), ) @@ -53,70 +69,84 @@ async def async_setup_entry( ) -> None: """Set up the Ring binary sensors from a config entry.""" ring_data = entry.runtime_data + listen_coordinator = ring_data.listen_coordinator - entities = [ - RingBinarySensor( - ring_data.api, - device, - ring_data.notifications_coordinator, - description, - ) + async_add_entities( + RingBinarySensor(device, listen_coordinator, description) for description in BINARY_SENSOR_TYPES for device in ring_data.devices.all_devices - if description.exists_fn(device) - ] - - async_add_entities(entities) + if device.has_capability(description.capability) + and async_check_create_deprecated( + hass, + Platform.BINARY_SENSOR, + f"{device.id}-{description.key}", + description, + ) + ) class RingBinarySensor( - RingBaseEntity[RingNotificationsCoordinator], BinarySensorEntity + RingBaseEntity[RingListenCoordinator, RingDeviceT], BinarySensorEntity ): """A binary sensor implementation for Ring device.""" _active_alert: RingEvent | None = None - entity_description: RingBinarySensorEntityDescription + RingBinarySensorEntityDescription[RingDeviceT] def __init__( self, - ring: Ring, - device: RingGeneric, - coordinator: RingNotificationsCoordinator, - description: RingBinarySensorEntityDescription, + device: RingDeviceT, + coordinator: RingListenCoordinator, + description: RingBinarySensorEntityDescription[RingDeviceT], ) -> None: - """Initialize a sensor for Ring device.""" + """Initialize a binary sensor for Ring device.""" super().__init__( device, coordinator, ) self.entity_description = description - self._ring = ring self._attr_unique_id = f"{device.id}-{description.key}" - self._update_alert() + self._attr_is_on = False + self._active_alert: RingEvent | None = None + self._cancel_callback: CALLBACK_TYPE | None = None @callback - def _handle_coordinator_update(self, _: Any = None) -> None: - """Call update method.""" - self._update_alert() - super()._handle_coordinator_update() + def _async_handle_event(self, alert: RingEvent) -> None: + """Handle the event.""" + self._attr_is_on = True + self._active_alert = alert + loop = self.hass.loop + when = loop.time() + alert.expires_in + if self._cancel_callback: + self._cancel_callback() + self._cancel_callback = async_call_at(self.hass, self._async_cancel_event, when) @callback - def _update_alert(self) -> None: - """Update active alert.""" - self._active_alert = next( - ( - alert - for alert in self._ring.active_alerts() - if alert["kind"] == self.entity_description.key - and alert["doorbot_id"] == self._device.id - ), - None, + def _async_cancel_event(self, _now: Any) -> None: + """Clear the event.""" + self._cancel_callback = None + self._attr_is_on = False + self._active_alert = None + self.async_write_ha_state() + + def _get_coordinator_alert(self) -> RingEvent | None: + return self.coordinator.alerts.get( + (self._device.device_api_id, self.entity_description.key) ) + @callback + def _handle_coordinator_update(self) -> None: + if alert := self._get_coordinator_alert(): + self._async_handle_event(alert) + super()._handle_coordinator_update() + @property - def is_on(self) -> bool: - """Return True if the binary sensor is on.""" - return self._active_alert is not None + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.event_listener.started + + async def async_update(self) -> None: + """All updates are passive.""" @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -127,9 +157,9 @@ class RingBinarySensor( return attrs assert isinstance(attrs, dict) - attrs["state"] = self._active_alert["state"] - now = self._active_alert.get("now") - expires_in = self._active_alert.get("expires_in") + attrs["state"] = self._active_alert.state + now = self._active_alert.now + expires_in = self._active_alert.expires_in assert now and expires_in attrs["expires_at"] = datetime.fromtimestamp(now + expires_in).isoformat() diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index 74546567270..8b933e8580d 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -8,17 +8,12 @@ from ring_doorbell import Auth, AuthenticationError, Requires2FAError import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult -from homeassistant.const import ( - APPLICATION_NAME, - CONF_PASSWORD, - CONF_TOKEN, - CONF_USERNAME, - __version__ as ha_version, -) +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import get_auth_agent_id from .const import CONF_2FA, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -32,9 +27,11 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Any]: """Validate the user input allows us to connect.""" + user_agent, hardware_id = await get_auth_agent_id(hass) auth = Auth( - f"{APPLICATION_NAME}/{ha_version}", + user_agent, http_client_session=async_get_clientsession(hass), + hardware_id=hardware_id, ) try: diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index 70813a78c76..c67adbf5984 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -26,6 +26,6 @@ PLATFORMS = [ SCAN_INTERVAL = timedelta(minutes=1) -NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5) CONF_2FA = "2fa" +CONF_LISTEN_CREDENTIALS = "listen_token" diff --git a/homeassistant/components/ring/coordinator.py b/homeassistant/components/ring/coordinator.py index 600743005eb..b143fd3dda0 100644 --- a/homeassistant/components/ring/coordinator.py +++ b/homeassistant/components/ring/coordinator.py @@ -3,15 +3,28 @@ from asyncio import TaskGroup from collections.abc import Callable, Coroutine import logging -from typing import Any +from typing import TYPE_CHECKING, Any -from ring_doorbell import AuthenticationError, Ring, RingDevices, RingError, RingTimeout +from ring_doorbell import ( + AuthenticationError, + Ring, + RingDevices, + RingError, + RingEvent, + RingTimeout, +) +from ring_doorbell.listen import RingEventListener -from homeassistant.core import HomeAssistant +from homeassistant import config_entries +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + BaseDataUpdateCoordinatorProtocol, + DataUpdateCoordinator, + UpdateFailed, +) -from .const import NOTIFICATIONS_SCAN_INTERVAL, SCAN_INTERVAL +from .const import SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -91,19 +104,112 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]): return devices -class RingNotificationsCoordinator(DataUpdateCoordinator[None]): +class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol): """Global notifications coordinator.""" - def __init__(self, hass: HomeAssistant, ring_api: Ring) -> None: - """Initialize my coordinator.""" - super().__init__( - hass, - logger=_LOGGER, - name="active dings", - update_interval=NOTIFICATIONS_SCAN_INTERVAL, - ) - self.ring_api: Ring = ring_api + config_entry: config_entries.ConfigEntry - async def _async_update_data(self) -> None: - """Fetch data from API endpoint.""" - await _call_api(self.hass, self.ring_api.async_update_dings) + def __init__( + self, + hass: HomeAssistant, + ring_api: Ring, + listen_credentials: dict[str, Any] | None, + listen_credentials_updater: Callable[[dict[str, Any]], None], + ) -> None: + """Initialize my coordinator.""" + self.hass = hass + self.logger = _LOGGER + self.ring_api: Ring = ring_api + self.event_listener = RingEventListener( + ring_api, listen_credentials, listen_credentials_updater + ) + self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} + self._listen_callback_id: int | None = None + + config_entry = config_entries.current_entry.get() + if TYPE_CHECKING: + assert config_entry + self.config_entry = config_entry + self.start_timeout = 10 + self.config_entry.async_on_unload(self.async_shutdown) + self.index_alerts() + + def index_alerts(self) -> None: + "Index the active alerts." + self.alerts = { + (alert.doorbot_id, alert.kind): alert + for alert in self.ring_api.active_alerts() + } + + async def async_shutdown(self) -> None: + """Cancel any scheduled call, and ignore new runs.""" + if self.event_listener.started: + await self._async_stop_listen() + + async def _async_stop_listen(self) -> None: + self.logger.debug("Stopped ring listener") + await self.event_listener.stop() + self.logger.debug("Stopped ring listener") + + async def _async_start_listen(self) -> None: + """Start listening for realtime events.""" + self.logger.debug("Starting ring listener.") + await self.event_listener.start( + timeout=self.start_timeout, + ) + if self.event_listener.started is True: + self.logger.debug("Started ring listener") + else: + self.logger.warning( + "Ring event listener failed to start after %s seconds", + self.start_timeout, + ) + self._listen_callback_id = self.event_listener.add_notification_callback( + self._on_event + ) + self.index_alerts() + # Update the listeners so they switch from Unavailable to Unknown + self._async_update_listeners() + + def _on_event(self, event: RingEvent) -> None: + self.logger.debug("Ring event received: %s", event) + self.index_alerts() + self._async_update_listeners(event.doorbot_id) + + @callback + def _async_update_listeners(self, doorbot_id: int | None = None) -> None: + """Update all registered listeners.""" + for update_callback, device_api_id in list(self._listeners.values()): + if not doorbot_id or device_api_id == doorbot_id: + update_callback() + + @callback + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: + """Listen for data updates.""" + start_listen = not self._listeners + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + if not self._listeners: + self.config_entry.async_create_task( + self.hass, + self._async_stop_listen(), + "Ring event listener stop", + eager_start=True, + ) + + self._listeners[remove_listener] = (update_callback, context) + + # This is the first listener, start the event listener. + if start_listen: + self.config_entry.async_create_task( + self.hass, + self._async_start_listen(), + "Ring event listener start", + eager_start=True, + ) + return remove_listener diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 72deb09b76f..0d050e7697f 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -1,6 +1,7 @@ """Base class for Ring entity.""" from collections.abc import Callable, Coroutine +from dataclasses import dataclass from typing import Any, Concatenate, Generic, cast from ring_doorbell import ( @@ -12,22 +13,46 @@ from ring_doorbell import ( ) from typing_extensions import TypeVar -from homeassistant.core import callback +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.update_coordinator import ( + BaseCoordinatorEntity, + CoordinatorEntity, +) from .const import ATTRIBUTION, DOMAIN -from .coordinator import RingDataCoordinator, RingNotificationsCoordinator +from .coordinator import RingDataCoordinator, RingListenCoordinator RingDeviceT = TypeVar("RingDeviceT", bound=RingGeneric, default=RingGeneric) _RingCoordinatorT = TypeVar( "_RingCoordinatorT", - bound=(RingDataCoordinator | RingNotificationsCoordinator), + bound=(RingDataCoordinator | RingListenCoordinator), ) +@dataclass(slots=True) +class DeprecatedInfo: + """Class to define deprecation info for deprecated entities.""" + + new_platform: Platform + breaks_in_ha_version: str + + +@dataclass(frozen=True, kw_only=True) +class RingEntityDescription(EntityDescription): + """Base class for a ring entity description.""" + + deprecated_info: DeprecatedInfo | None = None + + def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R]( async_func: Callable[Concatenate[_RingBaseEntityT, _P], Coroutine[Any, Any, _R]], ) -> Callable[Concatenate[_RingBaseEntityT, _P], Coroutine[Any, Any, _R]]: @@ -51,8 +76,66 @@ def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R]( return _wrap +def async_check_create_deprecated( + hass: HomeAssistant, + platform: Platform, + unique_id: str, + entity_description: RingEntityDescription, +) -> bool: + """Return true if the entitty 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 delete it and return false. + Otherwise will return true and create issues for scripts or automations. + """ + if not entity_description.deprecated_info: + return True + + 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 + if entity_entry.disabled: + # If the entity exists and is disabled then we want to remove + # the entity so that the user is just using the new entity. + ent_reg.async_remove(entity_id) + return False + + # Check for issues that need to be created + entity_automations = automations_with_entity(hass, entity_id) + entity_scripts = scripts_with_entity(hass, entity_id) + if entity_automations or entity_scripts: + deprecated_info = entity_description.deprecated_info + 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, + }, + ) + return True + + class RingBaseEntity( - CoordinatorEntity[_RingCoordinatorT], Generic[_RingCoordinatorT, RingDeviceT] + BaseCoordinatorEntity[_RingCoordinatorT], Generic[_RingCoordinatorT, RingDeviceT] ): """Base implementation for Ring device.""" @@ -77,7 +160,7 @@ class RingBaseEntity( ) -class RingEntity(RingBaseEntity[RingDataCoordinator, RingDeviceT]): +class RingEntity(RingBaseEntity[RingDataCoordinator, RingDeviceT], CoordinatorEntity): """Implementation for Ring devices.""" def _get_coordinator_data(self) -> RingDevices: diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 83d07dbd9b4..219f1b0224c 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -25,6 +25,7 @@ from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + Platform, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -32,7 +33,13 @@ from homeassistant.helpers.typing import StateType from . import RingConfigEntry from .coordinator import RingDataCoordinator -from .entity import RingDeviceT, RingEntity +from .entity import ( + DeprecatedInfo, + RingDeviceT, + RingEntity, + RingEntityDescription, + async_check_create_deprecated, +) async def async_setup_entry( @@ -49,6 +56,12 @@ async def async_setup_entry( for description in SENSOR_TYPES for device in ring_data.devices.all_devices if description.exists_fn(device) + and async_check_create_deprecated( + hass, + Platform.SENSOR, + f"{device.id}-{description.key}", + description, + ) ] async_add_entities(entities) @@ -120,7 +133,9 @@ def _get_last_event_attrs( @dataclass(frozen=True, kw_only=True) -class RingSensorEntityDescription(SensorEntityDescription, Generic[RingDeviceT]): +class RingSensorEntityDescription( + SensorEntityDescription, RingEntityDescription, Generic[RingDeviceT] +): """Describes Ring sensor entity.""" value_fn: Callable[[RingDeviceT], StateType] = lambda _: True @@ -172,6 +187,9 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = ( ) else None, exists_fn=lambda device: device.has_capability(RingCapability.HISTORY), + deprecated_info=DeprecatedInfo( + new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0" + ), ), RingSensorEntityDescription[RingGeneric]( key="last_motion", @@ -188,6 +206,9 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = ( ) else None, exists_fn=lambda device: device.has_capability(RingCapability.HISTORY), + deprecated_info=DeprecatedInfo( + new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0" + ), ), RingSensorEntityDescription[RingDoorBell | RingChime]( key="volume", diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 6bd7d194136..80598eab314 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -35,6 +35,17 @@ "binary_sensor": { "ding": { "name": "Ding" + }, + "motion": { + "name": "Motion" + } + }, + "event": { + "ding": { + "name": "Ding" + }, + "intercom_unlock": { + "name": "Intercom unlock" } }, "button": { @@ -104,6 +115,10 @@ } } } + }, + "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/tests/components/ring/common.py b/tests/components/ring/common.py index 3b78adf0e09..71274fe1ee1 100644 --- a/tests/components/ring/common.py +++ b/tests/components/ring/common.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.ring import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -18,3 +19,18 @@ async def setup_platform(hass: HomeAssistant, platform: Platform) -> None: with patch("homeassistant.components.ring.PLATFORMS", [platform]): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done(wait_background_tasks=True) + + +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": {}}, + } + }, + ) diff --git a/tests/components/ring/conftest.py b/tests/components/ring/conftest.py index 4456a9daa26..90f2fd2a956 100644 --- a/tests/components/ring/conftest.py +++ b/tests/components/ring/conftest.py @@ -11,7 +11,7 @@ from homeassistant.components.ring import DOMAIN from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant -from .device_mocks import get_active_alerts, get_devices_data, get_mock_devices +from .device_mocks import get_devices_data, get_mock_devices from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -103,7 +103,7 @@ def mock_ring_client(mock_ring_auth, mock_ring_devices): mock_client = create_autospec(ring_doorbell.Ring) mock_client.return_value.devices_data = get_devices_data() mock_client.return_value.devices.return_value = mock_ring_devices - mock_client.return_value.active_alerts.side_effect = get_active_alerts + mock_client.return_value.active_alerts.return_value = [] with patch("homeassistant.components.ring.Ring", new=mock_client): yield mock_client.return_value @@ -135,3 +135,14 @@ async def mock_added_config_entry( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() return mock_config_entry + + +@pytest.fixture(autouse=True) +def mock_ring_event_listener_class(): + """Fixture to mock the ring event listener.""" + + with patch( + "homeassistant.components.ring.coordinator.RingEventListener", autospec=True + ) as mock_ring_listener: + mock_ring_listener.return_value.started = True + yield mock_ring_listener diff --git a/tests/components/ring/device_mocks.py b/tests/components/ring/device_mocks.py index d2671c3896d..8ac5948d6a0 100644 --- a/tests/components/ring/device_mocks.py +++ b/tests/components/ring/device_mocks.py @@ -7,9 +7,7 @@ Each device entry in the devices.json will have a MagicMock instead of the RingO Mocks the api calls on the devices such as history() and health(). """ -from copy import deepcopy from datetime import datetime -from time import time from unittest.mock import AsyncMock, MagicMock from ring_doorbell import ( @@ -30,7 +28,10 @@ DOORBOT_HISTORY = load_json_value_fixture("doorbot_history.json", DOMAIN) INTERCOM_HISTORY = load_json_value_fixture("intercom_history.json", DOMAIN) DOORBOT_HEALTH = load_json_value_fixture("doorbot_health_attrs.json", DOMAIN) CHIME_HEALTH = load_json_value_fixture("chime_health_attrs.json", DOMAIN) -DEVICE_ALERTS = load_json_value_fixture("ding_active.json", DOMAIN) + +FRONT_DOOR_DEVICE_ID = 987654 +INGRESS_DEVICE_ID = 185036587 +FRONT_DEVICE_ID = 765432 def get_mock_devices(): @@ -54,14 +55,6 @@ def get_devices_data(): } -def get_active_alerts(): - """Return active alerts set to now.""" - dings_fixture = deepcopy(DEVICE_ALERTS) - for ding in dings_fixture: - ding["now"] = time() - return dings_fixture - - DEVICE_TYPES = { "doorbots": RingDoorBell, "authorized_doorbots": RingDoorBell, @@ -76,6 +69,7 @@ DEVICE_CAPABILITIES = { RingCapability.VOLUME, RingCapability.MOTION_DETECTION, RingCapability.VIDEO, + RingCapability.DING, RingCapability.HISTORY, ], RingStickUpCam: [ @@ -88,7 +82,7 @@ DEVICE_CAPABILITIES = { RingCapability.LIGHT, ], RingChime: [RingCapability.VOLUME], - RingOther: [RingCapability.OPEN, RingCapability.HISTORY], + RingOther: [RingCapability.OPEN, RingCapability.HISTORY, RingCapability.DING], } diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index 16bc6e872c1..6a4ce652573 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -1,24 +1,196 @@ """The tests for the Ring binary sensor platform.""" -from homeassistant.const import Platform +import time +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from ring_doorbell import Ring + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.ring.binary_sensor import RingEvent +from homeassistant.components.ring.const import DOMAIN +from homeassistant.components.ring.coordinator import RingEventListener +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component -from .common import setup_platform +from .common import setup_automation +from .device_mocks import FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID + +from tests.common import async_fire_time_changed -async def test_binary_sensor(hass: HomeAssistant, mock_ring_client) -> None: +@pytest.mark.parametrize( + ("device_id", "device_name", "alert_kind", "device_class"), + [ + pytest.param( + FRONT_DOOR_DEVICE_ID, + "front_door", + "motion", + "motion", + id="front_door_motion", + ), + pytest.param( + FRONT_DOOR_DEVICE_ID, + "front_door", + "ding", + "occupancy", + id="front_door_ding", + ), + pytest.param( + INGRESS_DEVICE_ID, "ingress", "ding", "occupancy", id="ingress_ding" + ), + ], +) +async def test_binary_sensor( + hass: HomeAssistant, + mock_config_entry: ConfigEntry, + mock_ring_client: Ring, + mock_ring_event_listener_class: RingEventListener, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + device_id: int, + device_name: str, + alert_kind: str, + device_class: str, +) -> None: """Test the Ring binary sensors.""" - await setup_platform(hass, Platform.BINARY_SENSOR) + # Create the entity so it is not ignored by the deprecation check + mock_config_entry.add_to_hass(hass) - motion_state = hass.states.get("binary_sensor.front_door_motion") - assert motion_state is not None - assert motion_state.state == "on" - assert motion_state.attributes["device_class"] == "motion" + entity_id = f"binary_sensor.{device_name}_{alert_kind}" + unique_id = f"{device_id}-{alert_kind}" - front_ding_state = hass.states.get("binary_sensor.front_door_ding") - assert front_ding_state is not None - assert front_ding_state.state == "off" + entity_registry.async_get_or_create( + domain=BINARY_SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=unique_id, + suggested_object_id=f"{device_name}_{alert_kind}", + config_entry=mock_config_entry, + ) + with patch("homeassistant.components.ring.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await async_setup_component(hass, DOMAIN, {}) - ingress_ding_state = hass.states.get("binary_sensor.ingress_ding") - assert ingress_ding_state is not None - assert ingress_ding_state.state == "off" + on_event_cb = mock_ring_event_listener_class.return_value.add_notification_callback.call_args.args[ + 0 + ] + + # Default state is set to off + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes["device_class"] == device_class + + # A new alert sets to on + event = RingEvent( + 1234546, device_id, "Foo", "Bar", time.time(), 180, kind=alert_kind, state=None + ) + mock_ring_client.active_alerts.return_value = [event] + on_event_cb(event) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + # Test that another event resets the expiry callback + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + event = RingEvent( + 1234546, device_id, "Foo", "Bar", time.time(), 180, kind=alert_kind, state=None + ) + mock_ring_client.active_alerts.return_value = [event] + on_event_cb(event) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + freezer.tick(120) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_ON + + # Test the second alert has expired + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_OFF + + +async def test_binary_sensor_not_exists_with_deprecation( + hass: HomeAssistant, + mock_config_entry: ConfigEntry, + mock_ring_client: Ring, + entity_registry: er.EntityRegistry, +) -> None: + """Test the deprecated Ring binary sensors are deleted or raise issues.""" + mock_config_entry.add_to_hass(hass) + + entity_id = "binary_sensor.front_door_motion" + + assert not hass.states.get(entity_id) + with patch("homeassistant.components.ring.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await async_setup_component(hass, DOMAIN, {}) + + assert not entity_registry.async_get(entity_id) + assert not er.async_entries_for_config_entry( + entity_registry, mock_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_binary_sensor_exists_with_deprecation( + hass: HomeAssistant, + mock_config_entry: ConfigEntry, + mock_ring_client: Ring, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + entity_disabled: bool, + entity_has_automations: bool, +) -> None: + """Test the deprecated Ring binary sensors are deleted or raise issues.""" + mock_config_entry.add_to_hass(hass) + + entity_id = "binary_sensor.front_door_motion" + unique_id = f"{FRONT_DOOR_DEVICE_ID}-motion" + 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=BINARY_SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=unique_id, + suggested_object_id="front_door_motion", + config_entry=mock_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) + with patch("homeassistant.components.ring.PLATFORMS", [Platform.BINARY_SENSOR]): + assert await async_setup_component(hass, DOMAIN, {}) + + 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 diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 97392e0c93b..10d183a22e9 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -2,19 +2,23 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from ring_doorbell import AuthenticationError, RingError, RingTimeout +from ring_doorbell import AuthenticationError, Ring, RingError, RingTimeout from homeassistant.components import ring +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.ring import DOMAIN -from homeassistant.components.ring.const import SCAN_INTERVAL +from homeassistant.components.ring.const import CONF_LISTEN_CREDENTIALS, SCAN_INTERVAL +from homeassistant.components.ring.coordinator import RingEventListener from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component +from .device_mocks import FRONT_DOOR_DEVICE_ID + from tests.common import MockConfigEntry, async_fire_time_changed @@ -413,3 +417,63 @@ async def test_token_updated( async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_config_entry.data[CONF_TOKEN] == {"access_token": "new-mock-token"} + + +async def test_listen_token_updated( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_ring_client, + mock_ring_event_listener_class, +) -> None: + """Test that the listener token value is updated in the config entry. + + This simulates the api calling the callback. + """ + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_ring_event_listener_class.call_count == 1 + token_updater = mock_ring_event_listener_class.call_args.args[2] + + assert mock_config_entry.data.get(CONF_LISTEN_CREDENTIALS) is None + token_updater({"listen_access_token": "mock-token"}) + assert mock_config_entry.data.get(CONF_LISTEN_CREDENTIALS) == { + "listen_access_token": "mock-token" + } + + +async def test_no_listen_start( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, + mock_ring_event_listener_class: type[RingEventListener], + mock_ring_client: Ring, +) -> None: + """Test behaviour if listener doesn't start.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + version=1, + data={"username": "foo", "token": {}}, + ) + # Create a binary sensor entity so it is not ignored by the deprecation check + # and the listener will start + entity_registry.async_get_or_create( + domain=BINARY_SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=f"{FRONT_DOOR_DEVICE_ID}-motion", + suggested_object_id=f"{FRONT_DOOR_DEVICE_ID}_motion", + config_entry=mock_entry, + ) + mock_ring_event_listener_class.do_not_start = True + + mock_ring_event_listener_class.return_value.started = False + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert "Ring event listener failed to start after 10 seconds" in [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index 1f05c120251..dead52a5acc 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -1,17 +1,26 @@ """The tests for the Ring sensor platform.""" import logging +from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from ring_doorbell import Ring -from homeassistant.components.ring.const import SCAN_INTERVAL -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass +from homeassistant.components.ring.const import DOMAIN, SCAN_INTERVAL +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from .common import setup_platform +from .device_mocks import FRONT_DEVICE_ID, FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID from tests.common import async_fire_time_changed @@ -107,13 +116,23 @@ async def test_health_sensor( @pytest.mark.parametrize( - ("device_name", "sensor_name", "expected_value"), + ("device_id", "device_name", "sensor_name", "expected_value"), [ - ("front_door", "last_motion", "2017-03-05T15:03:40+00:00"), - ("front_door", "last_ding", "2018-03-05T15:03:40+00:00"), - ("front_door", "last_activity", "2018-03-05T15:03:40+00:00"), - ("front", "last_motion", "2017-03-05T15:03:40+00:00"), - ("ingress", "last_activity", "2024-02-02T11:21:24+00:00"), + ( + FRONT_DOOR_DEVICE_ID, + "front_door", + "last_motion", + "2017-03-05T15:03:40+00:00", + ), + (FRONT_DOOR_DEVICE_ID, "front_door", "last_ding", "2018-03-05T15:03:40+00:00"), + ( + FRONT_DOOR_DEVICE_ID, + "front_door", + "last_activity", + "2018-03-05T15:03:40+00:00", + ), + (FRONT_DEVICE_ID, "front", "last_motion", "2017-03-05T15:03:40+00:00"), + (INGRESS_DEVICE_ID, "ingress", "last_activity", "2024-02-02T11:21:24+00:00"), ], ids=[ "doorbell-motion", @@ -125,14 +144,31 @@ async def test_health_sensor( ) async def test_history_sensor( hass: HomeAssistant, - mock_ring_client, + mock_ring_client: Ring, + mock_config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, - device_name, - sensor_name, - expected_value, + device_id: int, + device_name: str, + sensor_name: str, + expected_value: str, ) -> None: """Test the Ring sensors.""" - await setup_platform(hass, "sensor") + # Create the entity so it is not ignored by the deprecation check + mock_config_entry.add_to_hass(hass) + + entity_id = f"sensor.{device_name}_{sensor_name}" + unique_id = f"{device_id}-{sensor_name}" + + entity_registry.async_get_or_create( + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=unique_id, + suggested_object_id=f"{device_name}_{sensor_name}", + config_entry=mock_config_entry, + ) + with patch("homeassistant.components.ring.PLATFORMS", [Platform.SENSOR]): + assert await async_setup_component(hass, DOMAIN, {}) entity_id = f"sensor.{device_name}_{sensor_name}" sensor_state = hass.states.get(entity_id) From 20600123f8b2f6fabe4d89ae866bc467dc24c04d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 8 Sep 2024 18:52:21 +0200 Subject: [PATCH 0413/1309] Update bring todo entity snapshots (#125518) Update bring todo snapshot --- tests/components/bring/snapshots/test_todo.ambr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/bring/snapshots/test_todo.ambr b/tests/components/bring/snapshots/test_todo.ambr index 6a24b4148b7..6a7104727a1 100644 --- a/tests/components/bring/snapshots/test_todo.ambr +++ b/tests/components/bring/snapshots/test_todo.ambr @@ -23,7 +23,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Baumarkt', + 'original_name': None, 'platform': 'bring', 'previous_unique_id': None, 'supported_features': , @@ -70,7 +70,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Einkauf', + 'original_name': None, 'platform': 'bring', 'previous_unique_id': None, 'supported_features': , From 26ac8e35cb1814857538073ec2dd6a1c7d91b83f Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sun, 8 Sep 2024 19:32:34 +0100 Subject: [PATCH 0414/1309] Add event platform to ring (#125506) --- homeassistant/components/ring/const.py | 1 + homeassistant/components/ring/event.py | 109 +++++++++++++++++++++++++ tests/components/ring/test_event.py | 80 ++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 homeassistant/components/ring/event.py create mode 100644 tests/components/ring/test_event.py diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index c67adbf5984..5fac77d63bb 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -18,6 +18,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, + Platform.EVENT, Platform.LIGHT, Platform.SENSOR, Platform.SIREN, diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py new file mode 100644 index 00000000000..e6d9d25542f --- /dev/null +++ b/homeassistant/components/ring/event.py @@ -0,0 +1,109 @@ +"""Component providing support for ring events.""" + +from dataclasses import dataclass +from typing import Generic + +from ring_doorbell import RingCapability, RingEvent as RingAlert +from ring_doorbell.const import KIND_DING, KIND_INTERCOM_UNLOCK, KIND_MOTION + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RingConfigEntry +from .coordinator import RingListenCoordinator +from .entity import RingBaseEntity, RingDeviceT + + +@dataclass(frozen=True, kw_only=True) +class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]): + """Base class for event entity description.""" + + capability: RingCapability + + +EVENT_DESCRIPTIONS: tuple[RingEventEntityDescription, ...] = ( + RingEventEntityDescription( + key=KIND_DING, + translation_key=KIND_DING, + device_class=EventDeviceClass.DOORBELL, + event_types=[KIND_DING], + capability=RingCapability.DING, + ), + RingEventEntityDescription( + key=KIND_MOTION, + translation_key=KIND_MOTION, + device_class=EventDeviceClass.MOTION, + event_types=[KIND_MOTION], + capability=RingCapability.MOTION_DETECTION, + ), + RingEventEntityDescription( + key=KIND_INTERCOM_UNLOCK, + translation_key=KIND_INTERCOM_UNLOCK, + device_class=EventDeviceClass.BUTTON, + event_types=[KIND_INTERCOM_UNLOCK], + capability=RingCapability.OPEN, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RingConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up events for a Ring device.""" + ring_data = entry.runtime_data + listen_coordinator = ring_data.listen_coordinator + + async_add_entities( + RingEvent(device, listen_coordinator, description) + for description in EVENT_DESCRIPTIONS + for device in ring_data.devices.all_devices + if device.has_capability(description.capability) + ) + + +class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity): + """An event implementation for Ring device.""" + + entity_description: RingEventEntityDescription[RingDeviceT] + + def __init__( + self, + device: RingDeviceT, + coordinator: RingListenCoordinator, + description: RingEventEntityDescription[RingDeviceT], + ) -> None: + """Initialize a event entity for Ring device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{device.id}-{description.key}" + + @callback + def _async_handle_event(self, event: str) -> None: + """Handle the event.""" + self._trigger_event(event) + + def _get_coordinator_alert(self) -> RingAlert | None: + return self.coordinator.alerts.get( + (self._device.device_api_id, self.entity_description.key) + ) + + @callback + def _handle_coordinator_update(self) -> None: + if alert := self._get_coordinator_alert(): + self._async_handle_event(alert.kind) + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.event_listener.started + + async def async_update(self) -> None: + """All updates are passive.""" diff --git a/tests/components/ring/test_event.py b/tests/components/ring/test_event.py new file mode 100644 index 00000000000..c546f9ea136 --- /dev/null +++ b/tests/components/ring/test_event.py @@ -0,0 +1,80 @@ +"""The tests for the Ring event platform.""" + +from datetime import datetime +import time + +from freezegun.api import FrozenDateTimeFactory +import pytest +from ring_doorbell import Ring + +from homeassistant.components.ring.binary_sensor import RingEvent +from homeassistant.components.ring.coordinator import RingEventListener +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .common import setup_platform +from .device_mocks import FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID + + +@pytest.mark.parametrize( + ("device_id", "device_name", "alert_kind", "device_class"), + [ + pytest.param( + FRONT_DOOR_DEVICE_ID, + "front_door", + "motion", + "motion", + id="front_door_motion", + ), + pytest.param( + FRONT_DOOR_DEVICE_ID, "front_door", "ding", "doorbell", id="front_door_ding" + ), + pytest.param( + INGRESS_DEVICE_ID, "ingress", "ding", "doorbell", id="ingress_ding" + ), + pytest.param( + INGRESS_DEVICE_ID, + "ingress", + "intercom_unlock", + "button", + id="ingress_unlock", + ), + ], +) +async def test_event( + hass: HomeAssistant, + mock_ring_client: Ring, + mock_ring_event_listener_class: RingEventListener, + freezer: FrozenDateTimeFactory, + device_id: int, + device_name: str, + alert_kind: str, + device_class: str, +) -> None: + """Test the Ring event platforms.""" + + await setup_platform(hass, Platform.EVENT) + + start_time_str = "2024-09-04T15:32:53.892+00:00" + start_time = datetime.strptime(start_time_str, "%Y-%m-%dT%H:%M:%S.%f%z") + freezer.move_to(start_time) + on_event_cb = mock_ring_event_listener_class.return_value.add_notification_callback.call_args.args[ + 0 + ] + + # Default state is unknown + entity_id = f"event.{device_name}_{alert_kind}" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "unknown" + assert state.attributes["device_class"] == device_class + + # A new alert sets to on + event = RingEvent( + 1234546, device_id, "Foo", "Bar", time.time(), 180, kind=alert_kind, state=None + ) + mock_ring_client.active_alerts.return_value = [event] + on_event_cb(event) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == start_time_str From 8b8083a6394d5e1d66de37a88057c02a2ec175cf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:18:08 +0200 Subject: [PATCH 0415/1309] Migrate smappee to use runtime_data (#125529) --- homeassistant/components/smappee/__init__.py | 17 ++++++++--------- .../components/smappee/binary_sensor.py | 6 +++--- homeassistant/components/smappee/sensor.py | 6 +++--- homeassistant/components/smappee/switch.py | 6 +++--- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/smappee/__init__.py b/homeassistant/components/smappee/__init__.py index c7edd46c7e2..7fa30965aa8 100644 --- a/homeassistant/components/smappee/__init__.py +++ b/homeassistant/components/smappee/__init__.py @@ -25,6 +25,8 @@ from .const import ( TOKEN_URL, ) +type SmappeeConfigEntry = ConfigEntry[SmappeeBase] + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -72,7 +74,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SmappeeConfigEntry) -> bool: """Set up Smappee from a zeroconf or config entry.""" if CONF_IP_ADDRESS in entry.data: if helper.is_smappee_genius(entry.data[CONF_SERIALNUMBER]): @@ -103,31 +105,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: smappee = Smappee(api=smappee_api) await hass.async_add_executor_job(smappee.load_service_locations) - hass.data[DOMAIN][entry.entry_id] = SmappeeBase(hass, smappee) + entry.runtime_data = SmappeeBase(hass, smappee) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: SmappeeConfigEntry) -> 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, None) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) class SmappeeBase: """An object to hold the PySmappee instance.""" - def __init__(self, hass, smappee): + def __init__(self, hass: HomeAssistant, smappee: Smappee) -> None: """Initialize the Smappee API wrapper class.""" self.hass = hass self.smappee = smappee @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): + async def async_update(self) -> None: """Update all Smappee trends and appliance states.""" await self.hass.async_add_executor_job( self.smappee.update_trends_and_appliance_states diff --git a/homeassistant/components/smappee/binary_sensor.py b/homeassistant/components/smappee/binary_sensor.py index a653896f1c2..86bc225dba1 100644 --- a/homeassistant/components/smappee/binary_sensor.py +++ b/homeassistant/components/smappee/binary_sensor.py @@ -6,11 +6,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import SmappeeConfigEntry from .const import DOMAIN BINARY_SENSOR_PREFIX = "Appliance" @@ -36,11 +36,11 @@ ICON_MAPPING = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SmappeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Smappee binary sensor.""" - smappee_base = hass.data[DOMAIN][config_entry.entry_id] + smappee_base = config_entry.runtime_data entities: list[BinarySensorEntity] = [] for service_location in smappee_base.smappee.service_locations.values(): diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index c984d936b06..2f9d6443568 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -10,12 +10,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfElectricPotential, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import SmappeeConfigEntry from .const import DOMAIN @@ -188,11 +188,11 @@ VOLTAGE_SENSORS: tuple[SmappeeVoltageSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SmappeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Smappee sensor.""" - smappee_base = hass.data[DOMAIN][config_entry.entry_id] + smappee_base = config_entry.runtime_data entities = [] for service_location in smappee_base.smappee.service_locations.values(): diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index 1bc5d159145..bccf816c823 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -3,11 +3,11 @@ from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import SmappeeConfigEntry from .const import DOMAIN SWITCH_PREFIX = "Switch" @@ -15,11 +15,11 @@ SWITCH_PREFIX = "Switch" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SmappeeConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Smappee Comfort Plugs.""" - smappee_base = hass.data[DOMAIN][config_entry.entry_id] + smappee_base = config_entry.runtime_data entities = [] for service_location in smappee_base.smappee.service_locations.values(): From 8ce236de802d02e1dbf410f3a77c0c02e5e6b3a5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:29:14 +0200 Subject: [PATCH 0416/1309] Migrate amberelectric to use runtime_data (#125533) --- .../components/amberelectric/__init__.py | 16 +++++++--------- .../components/amberelectric/binary_sensor.py | 8 ++++---- homeassistant/components/amberelectric/sensor.py | 8 ++++---- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py index 9d9eef49b36..cd44886c9ef 100644 --- a/homeassistant/components/amberelectric/__init__.py +++ b/homeassistant/components/amberelectric/__init__.py @@ -7,11 +7,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant -from .const import CONF_SITE_ID, DOMAIN, PLATFORMS +from .const import CONF_SITE_ID, PLATFORMS from .coordinator import AmberUpdateCoordinator +type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool: """Set up Amber Electric from a config entry.""" configuration = Configuration(access_token=entry.data[CONF_API_TOKEN]) api_instance = amber_api.AmberApi.create(configuration) @@ -19,15 +21,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = AmberUpdateCoordinator(hass, api_instance, site_id) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> 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) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py index cd06fb04f39..a9fa00d0129 100644 --- a/homeassistant/components/amberelectric/binary_sensor.py +++ b/homeassistant/components/amberelectric/binary_sensor.py @@ -8,12 +8,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN +from . import AmberConfigEntry +from .const import ATTRIBUTION from .coordinator import AmberUpdateCoordinator PRICE_SPIKE_ICONS = { @@ -85,11 +85,11 @@ class AmberDemandWindowBinarySensor(AmberPriceGridSensor): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AmberConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a config entry.""" - coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data price_spike_description = BinarySensorEntityDescription( key="price_spike", diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index aafdd730a0c..52c0c42e7bc 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -17,13 +17,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE, UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN +from . import AmberConfigEntry +from .const import ATTRIBUTION from .coordinator import AmberUpdateCoordinator, normalize_descriptor UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}" @@ -196,11 +196,11 @@ class AmberGridSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AmberConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a config entry.""" - coordinator: AmberUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data current: dict[str, CurrentInterval] = coordinator.data["current"] forecasts: dict[str, list[ForecastInterval]] = coordinator.data["forecasts"] From 513361ef0fd1501cf702ac2e920ca2925a42b97c Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sun, 8 Sep 2024 15:38:31 -0400 Subject: [PATCH 0417/1309] Fix failing template config flow tests (#125534) fix: failing template config flow tests --- tests/components/template/test_config_flow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index ee748ce41f5..eb2c6e57f85 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -63,7 +63,7 @@ from tests.typing import WebSocketGenerator "device_class": "restart", "press": [ { - "service": "input_boolean.toggle", + "action": "input_boolean.toggle", "target": {"entity_id": "input_boolean.test"}, "data": {}, } @@ -73,7 +73,7 @@ from tests.typing import WebSocketGenerator "device_class": "restart", "press": [ { - "service": "input_boolean.toggle", + "action": "input_boolean.toggle", "target": {"entity_id": "input_boolean.test"}, "data": {}, } @@ -410,7 +410,7 @@ def get_suggested(schema, key): "device_class": "restart", "press": [ { - "service": "input_boolean.toggle", + "action": "input_boolean.toggle", "target": {"entity_id": "input_boolean.test"}, "data": {}, } @@ -419,7 +419,7 @@ def get_suggested(schema, key): { "press": [ { - "service": "input_boolean.toggle", + "action": "input_boolean.toggle", "target": {"entity_id": "input_boolean.test"}, "data": {}, } From 7f4fc4d371240aaaac60eeda39c733319892b84e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 8 Sep 2024 21:39:05 +0200 Subject: [PATCH 0418/1309] Migrate airvisual to use runtime_data (#125532) * Migrate airvisual to use runtime_data * Remove usedefault * Adjust --- .../components/airvisual/__init__.py | 30 +++++++++---------- .../components/airvisual/diagnostics.py | 9 +++--- homeassistant/components/airvisual/sensor.py | 10 ++++--- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 60fdbf12ca1..f8f045859b3 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -53,6 +53,8 @@ from .const import ( LOGGER, ) +type AirVisualConfigEntry = ConfigEntry[DataUpdateCoordinator] + # We use a raw string for the airvisual_pro domain (instead of importing the actual # constant) so that we can avoid listing it as a dependency: DOMAIN_AIRVISUAL_PRO = "airvisual_pro" @@ -91,10 +93,9 @@ def async_get_cloud_coordinators_by_api_key( ) -> list[DataUpdateCoordinator]: """Get all DataUpdateCoordinator objects related to a particular API key.""" return [ - coordinator - for entry_id, coordinator in hass.data[DOMAIN].items() - if (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.data.get(CONF_API_KEY) == api_key + entry.runtime_data + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.data.get(CONF_API_KEY) == api_key and hasattr(entry, "runtime_data") ] @@ -172,7 +173,7 @@ def _standardize_geography_config_entry( hass.config_entries.async_update_entry(entry, **entry_updates) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> bool: """Set up AirVisual as config entry.""" if CONF_API_KEY not in entry.data: # If this is a migrated AirVisual Pro entry, there's no actual setup to do; @@ -220,8 +221,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(async_reload_entry)) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator # Reassess the interval between 2 server requests async_sync_geo_coordinator_update_intervals(hass, entry.data[CONF_API_KEY]) @@ -231,7 +231,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> bool: """Migrate an old config entry.""" version = entry.version @@ -388,21 +388,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> bool: """Unload an AirVisual config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - if CONF_API_KEY in entry.data: - # Re-calculate the update interval period for any remaining consumers of - # this API key: - async_sync_geo_coordinator_update_intervals(hass, entry.data[CONF_API_KEY]) + if unload_ok and CONF_API_KEY in entry.data: + # Re-calculate the update interval period for any remaining consumers of + # this API key: + async_sync_geo_coordinator_update_intervals(hass, entry.data[CONF_API_KEY]) return unload_ok -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_reload_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/airvisual/diagnostics.py b/homeassistant/components/airvisual/diagnostics.py index 348bb249b0f..2e7c60364f9 100644 --- a/homeassistant/components/airvisual/diagnostics.py +++ b/homeassistant/components/airvisual/diagnostics.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_COUNTRY, @@ -15,9 +14,9 @@ from homeassistant.const import ( CONF_UNIQUE_ID, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_CITY, DOMAIN +from . import AirVisualConfigEntry +from .const import CONF_CITY CONF_COORDINATES = "coordinates" CONF_TITLE = "title" @@ -37,10 +36,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: AirVisualConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index df0e3da1f45..c9df2f72233 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -26,8 +26,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import AirVisualEntity -from .const import CONF_CITY, DOMAIN +from . import AirVisualConfigEntry, AirVisualEntity +from .const import CONF_CITY ATTR_CITY = "city" ATTR_COUNTRY = "country" @@ -105,10 +105,12 @@ POLLUTANT_UNITS = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AirVisualConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up AirVisual sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( AirVisualGeographySensor(coordinator, entry, description, locale) for locale in GEOGRAPHY_SENSOR_LOCALES From 4bcde36a97de521ffbb34b051c97b02602781984 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 8 Sep 2024 21:42:33 +0200 Subject: [PATCH 0419/1309] Fix failing blebox climate tests (#125522) --- tests/components/blebox/test_climate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/blebox/test_climate.py b/tests/components/blebox/test_climate.py index 8ba0c3f630e..e402a3d5fbd 100644 --- a/tests/components/blebox/test_climate.py +++ b/tests/components/blebox/test_climate.py @@ -21,6 +21,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, STATE_UNKNOWN, @@ -152,6 +153,7 @@ async def test_on_when_below_desired(saunabox, hass: HomeAssistant) -> None: feature_mock.desired = 64.8 feature_mock.current = 25.7 + feature_mock.mode = 1 feature_mock.async_on = AsyncMock(side_effect=turn_on) await hass.services.async_call( "climate", @@ -186,12 +188,13 @@ async def test_on_when_above_desired(saunabox, hass: HomeAssistant) -> None: feature_mock.desired = 23.4 feature_mock.current = 28.7 + feature_mock.mode = 1 feature_mock.async_on = AsyncMock(side_effect=turn_on) await hass.services.async_call( "climate", SERVICE_SET_HVAC_MODE, - {"entity_id": entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) feature_mock.async_off.assert_not_called() From 6f88b6e64efcbc9e6036088c2b28fd1862b56ec7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 8 Sep 2024 22:04:34 +0200 Subject: [PATCH 0420/1309] Migrate anthemav to use runtime_data (#125537) --- homeassistant/components/anthemav/__init__.py | 20 +++++++++---------- .../components/anthemav/media_player.py | 7 +++---- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/anthemav/__init__.py b/homeassistant/components/anthemav/__init__.py index 4efeb9245c8..9616d554424 100644 --- a/homeassistant/components/anthemav/__init__.py +++ b/homeassistant/components/anthemav/__init__.py @@ -13,14 +13,16 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ANTHEMAV_UPDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS, DOMAIN +from .const import ANTHEMAV_UPDATE_SIGNAL, DEVICE_TIMEOUT_SECONDS + +type AnthemavConfigEntry = ConfigEntry[anthemav.Connection] PLATFORMS = [Platform.MEDIA_PLAYER] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AnthemavConfigEntry) -> bool: """Set up Anthem A/V Receivers from a config entry.""" @callback @@ -41,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (OSError, DeviceError) as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = avr + entry.runtime_data = avr await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -56,16 +58,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AnthemavConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - avr = hass.data[DOMAIN][entry.entry_id] + avr = entry.runtime_data + _LOGGER.debug("Close avr connection") + avr.close() - if avr is not None: - _LOGGER.debug("Close avr connection") - avr.close() - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 1dbfdf275f2..be5a6ad2258 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging -from anthemav.connection import Connection from anthemav.protocol import AVR from homeassistant.components.media_player import ( @@ -13,13 +12,13 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import AnthemavConfigEntry from .const import ANTHEMAV_UPDATE_SIGNAL, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -27,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: AnthemavConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" @@ -35,7 +34,7 @@ async def async_setup_entry( mac_address = config_entry.data[CONF_MAC] model = config_entry.data[CONF_MODEL] - avr: Connection = hass.data[DOMAIN][config_entry.entry_id] + avr = config_entry.runtime_data _LOGGER.debug("Connection data dump: %s", avr.dump_conndata) From 7209b3c7d323e8681e2ec6734c7fc7d967bfd281 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 8 Sep 2024 22:05:48 +0200 Subject: [PATCH 0421/1309] Migrate aosmith to use runtime_data (#125538) --- homeassistant/components/aosmith/__init__.py | 13 ++++++------- homeassistant/components/aosmith/diagnostics.py | 8 +++----- homeassistant/components/aosmith/sensor.py | 10 +++++----- homeassistant/components/aosmith/water_heater.py | 10 +++++----- 4 files changed, 19 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py index c42096cd3a7..dd60f69c4b9 100644 --- a/homeassistant/components/aosmith/__init__.py +++ b/homeassistant/components/aosmith/__init__.py @@ -16,6 +16,8 @@ from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER] +type AOSmithConfigEntry = ConfigEntry[AOSmithData] + @dataclass class AOSmithData: @@ -26,7 +28,7 @@ class AOSmithData: energy_coordinator: AOSmithEnergyCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> bool: """Set up A. O. Smith from a config entry.""" email = entry.data[CONF_EMAIL] password = entry.data[CONF_PASSWORD] @@ -55,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await energy_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AOSmithData( + entry.runtime_data = AOSmithData( client, status_coordinator, energy_coordinator, @@ -66,9 +68,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AOSmithConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - 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/aosmith/diagnostics.py b/homeassistant/components/aosmith/diagnostics.py index 96b049b904f..94726731f75 100644 --- a/homeassistant/components/aosmith/diagnostics.py +++ b/homeassistant/components/aosmith/diagnostics.py @@ -5,11 +5,9 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import AOSmithData -from .const import DOMAIN +from . import AOSmithConfigEntry TO_REDACT = { "address", @@ -31,10 +29,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: AOSmithConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: AOSmithData = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data all_device_info = await data.client.get_all_device_info() return async_redact_data(all_device_info, TO_REDACT) diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py index e33c388af8b..89b383744e5 100644 --- a/homeassistant/components/aosmith/sensor.py +++ b/homeassistant/components/aosmith/sensor.py @@ -11,13 +11,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AOSmithData -from .const import DOMAIN +from . import AOSmithConfigEntry from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator from .entity import AOSmithEnergyEntity, AOSmithStatusEntity @@ -49,10 +47,12 @@ HOT_WATER_STATUS_MAP: dict[HotWaterStatus, str] = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AOSmithConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up A. O. Smith sensor platform.""" - data: AOSmithData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( AOSmithStatusSensorEntity(data.status_coordinator, description, junction_id) diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py index dceba13ba34..f3dc8b3413f 100644 --- a/homeassistant/components/aosmith/water_heater.py +++ b/homeassistant/components/aosmith/water_heater.py @@ -12,14 +12,12 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AOSmithData -from .const import DOMAIN +from . import AOSmithConfigEntry from .coordinator import AOSmithStatusCoordinator from .entity import AOSmithStatusEntity @@ -46,10 +44,12 @@ DEFAULT_OPERATION_MODE_PRIORITY = [ async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: AOSmithConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up A. O. Smith water heater platform.""" - data: AOSmithData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( AOSmithWaterHeaterEntity(data.status_coordinator, junction_id) From 4d804649fc636aa6f2b917bb2c4660b025eea1b0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 8 Sep 2024 22:07:19 +0200 Subject: [PATCH 0422/1309] Migrate apcupsd to use runtime_data (#125539) --- homeassistant/components/apcupsd/__init__.py | 18 +++++++----------- .../components/apcupsd/binary_sensor.py | 9 +++------ .../components/apcupsd/diagnostics.py | 10 ++++------ homeassistant/components/apcupsd/sensor.py | 8 ++++---- tests/components/apcupsd/__init__.py | 2 +- tests/components/apcupsd/test_config_flow.py | 2 +- 6 files changed, 20 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 7293a42f7e7..44edc5c151f 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -2,22 +2,22 @@ from __future__ import annotations -import logging from typing import Final from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import APCUPSdCoordinator -_LOGGER = logging.getLogger(__name__) +type APCUPSdConfigEntry = ConfigEntry[APCUPSdCoordinator] PLATFORMS: Final = (Platform.BINARY_SENSOR, Platform.SENSOR) -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: APCUPSdConfigEntry +) -> bool: """Use config values to set up a function enabling status retrieval.""" host, port = config_entry.data[CONF_HOST], config_entry.data[CONF_PORT] coordinator = APCUPSdCoordinator(hass, host, port) @@ -25,17 +25,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await coordinator.async_config_entry_first_refresh() # Store the coordinator for later uses. - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = coordinator + config_entry.runtime_data = coordinator # Forward the config entries to the supported platforms. await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: APCUPSdConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok and DOMAIN in hass.data: - 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/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index 5f86ceb6eec..cd9e60f7ae4 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -2,24 +2,21 @@ from __future__ import annotations -import logging from typing import Final from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from . import APCUPSdConfigEntry from .coordinator import APCUPSdCoordinator PARALLEL_UPDATES = 0 -_LOGGER = logging.getLogger(__name__) _DESCRIPTION = BinarySensorEntityDescription( key="statflag", translation_key="online_status", @@ -30,11 +27,11 @@ _VALUE_ONLINE_MASK: Final = 0b1000 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: APCUPSdConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up an APCUPSd Online Status binary sensor.""" - coordinator: APCUPSdCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Do not create the binary sensor if APCUPSd does not provide STATFLAG field for us # to determine the online status. diff --git a/homeassistant/components/apcupsd/diagnostics.py b/homeassistant/components/apcupsd/diagnostics.py index d375a8bc248..fa0908f3144 100644 --- a/homeassistant/components/apcupsd/diagnostics.py +++ b/homeassistant/components/apcupsd/diagnostics.py @@ -5,19 +5,17 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import APCUPSdCoordinator, APCUPSdData +from . import APCUPSdConfigEntry TO_REDACT = {"SERIALNO", "HOSTNAME"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: APCUPSdConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: APCUPSdCoordinator = hass.data[DOMAIN][entry.entry_id] - data: APCUPSdData = coordinator.data + coordinator = entry.runtime_data + data = coordinator.data return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index d4bbfb148e5..9e0abcb1dd9 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfApparentPower, @@ -25,7 +24,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, LAST_S_TEST +from . import APCUPSdConfigEntry +from .const import LAST_S_TEST from .coordinator import APCUPSdCoordinator PARALLEL_UPDATES = 0 @@ -406,11 +406,11 @@ INFERRED_UNITS = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: APCUPSdConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the APCUPSd sensors from config entries.""" - coordinator: APCUPSdCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # The resource keys in the data dict collected in the coordinator is in upper-case # by default, but we use lower cases throughout this integration. diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index b75f3eab3af..eb8cd594ad7 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -4,7 +4,7 @@ from collections import OrderedDict from typing import Final from unittest.mock import patch -from homeassistant.components.apcupsd import DOMAIN +from homeassistant.components.apcupsd.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 2888771eb01..88594260579 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.apcupsd import DOMAIN +from homeassistant.components.apcupsd.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SOURCE from homeassistant.core import HomeAssistant From 0f2525d47601682cfe718242e252b368ef3068f0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 8 Sep 2024 22:13:32 +0200 Subject: [PATCH 0423/1309] Migrate anova to use runtime_data (#125536) --- homeassistant/components/anova/__init__.py | 15 +++++---------- homeassistant/components/anova/models.py | 4 ++++ homeassistant/components/anova/sensor.py | 8 +++----- tests/components/anova/test_init.py | 2 +- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py index 7503de8ea10..02c468c1319 100644 --- a/homeassistant/components/anova/__init__.py +++ b/homeassistant/components/anova/__init__.py @@ -13,22 +13,20 @@ from anova_wifi import ( WebsocketFailure, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import DOMAIN from .coordinator import AnovaCoordinator -from .models import AnovaData +from .models import AnovaConfigEntry, AnovaData PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> bool: """Set up Anova from a config entry.""" api = AnovaApi( aiohttp_client.async_get_clientsession(hass), @@ -62,17 +60,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: assert api.websocket_handler is not None devices = list(api.websocket_handler.devices.values()) coordinators = [AnovaCoordinator(hass, device) for device in devices] - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnovaData( - api_jwt=api.jwt, coordinators=coordinators, api=api - ) + entry.runtime_data = AnovaData(api_jwt=api.jwt, coordinators=coordinators, api=api) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AnovaConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - anova_data: AnovaData = hass.data[DOMAIN].pop(entry.entry_id) # Disconnect from WS - await anova_data.api.disconnect_websocket() + await entry.runtime_data.api.disconnect_websocket() return unload_ok diff --git a/homeassistant/components/anova/models.py b/homeassistant/components/anova/models.py index 8caf16eeae1..eef8180cf88 100644 --- a/homeassistant/components/anova/models.py +++ b/homeassistant/components/anova/models.py @@ -4,8 +4,12 @@ from dataclasses import dataclass from anova_wifi import AnovaApi +from homeassistant.config_entries import ConfigEntry + from .coordinator import AnovaCoordinator +type AnovaConfigEntry = ConfigEntry[AnovaData] + @dataclass class AnovaData: diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index e5fe9ededfd..aa572a0ee9b 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from anova_wifi import AnovaMode, AnovaState, APCUpdateSensor -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -19,10 +18,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN from .coordinator import AnovaCoordinator from .entity import AnovaDescriptionEntity -from .models import AnovaData +from .models import AnovaConfigEntry @dataclass(frozen=True, kw_only=True) @@ -99,11 +97,11 @@ SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: AnovaConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up Anova device.""" - anova_data: AnovaData = hass.data[DOMAIN][entry.entry_id] + anova_data = entry.runtime_data for coordinator in anova_data.coordinators: setup_coordinator(coordinator, async_add_entities) diff --git a/tests/components/anova/test_init.py b/tests/components/anova/test_init.py index 5fc63fcaf93..66ea11fdaef 100644 --- a/tests/components/anova/test_init.py +++ b/tests/components/anova/test_init.py @@ -2,7 +2,7 @@ from anova_wifi import AnovaApi -from homeassistant.components.anova import DOMAIN +from homeassistant.components.anova.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant From 021878e942e1e49785533b9b9ada8e67db90d598 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Sep 2024 00:01:45 +0200 Subject: [PATCH 0424/1309] Migrate ambient_network to use runtime_data (#125535) --- .../components/ambient_network/__init__.py | 18 ++++++++++-------- .../components/ambient_network/sensor.py | 7 +++---- tests/components/ambient_network/conftest.py | 4 ++-- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/ambient_network/__init__.py b/homeassistant/components/ambient_network/__init__.py index b286fb7fbc9..e9443a676b5 100644 --- a/homeassistant/components/ambient_network/__init__.py +++ b/homeassistant/components/ambient_network/__init__.py @@ -8,28 +8,30 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN from .coordinator import AmbientNetworkDataUpdateCoordinator +type AmbientNetworkConfigEntry = ConfigEntry[AmbientNetworkDataUpdateCoordinator] + PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: AmbientNetworkConfigEntry +) -> bool: """Set up the Ambient Weather Network from a config entry.""" api = OpenAPI() coordinator = AmbientNetworkDataUpdateCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: AmbientNetworkConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - 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/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py index 132fc7dbd0d..336745f88ff 100644 --- a/homeassistant/components/ambient_network/sensor.py +++ b/homeassistant/components/ambient_network/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, @@ -29,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN +from . import AmbientNetworkConfigEntry from .coordinator import AmbientNetworkDataUpdateCoordinator from .entity import AmbientNetworkEntity @@ -271,12 +270,12 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AmbientNetworkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ambient Network sensor entities.""" - coordinator: AmbientNetworkDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if coordinator.config_entry is not None: async_add_entities( AmbientNetworkSensor( diff --git a/tests/components/ambient_network/conftest.py b/tests/components/ambient_network/conftest.py index 9fc001252a0..e728d46aaf6 100644 --- a/tests/components/ambient_network/conftest.py +++ b/tests/components/ambient_network/conftest.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, Mock, patch from aioambient import OpenAPI import pytest -from homeassistant.components import ambient_network +from homeassistant.components.ambient_network.const import DOMAIN from homeassistant.core import HomeAssistant from tests.common import ( @@ -69,7 +69,7 @@ async def mock_aioambient(open_api: OpenAPI): def config_entry_fixture(request: pytest.FixtureRequest) -> MockConfigEntry: """Mock config entry.""" return MockConfigEntry( - domain=ambient_network.DOMAIN, + domain=DOMAIN, title=f"Station {request.param[0]}", data={"mac": request.param}, ) From dca287748da33f815b542a51f84947b5bd401e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 9 Sep 2024 00:56:29 +0200 Subject: [PATCH 0425/1309] Update aioairzone to v0.9.1 (#125547) --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index a782006efef..eb141fc83b4 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.0"] + "requirements": ["aioairzone==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9b6814c5c21..6faecd98f2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.5 # homeassistant.components.airzone -aioairzone==0.9.0 +aioairzone==0.9.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c8471b768a..6f81bba44f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.5 # homeassistant.components.airzone -aioairzone==0.9.0 +aioairzone==0.9.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 0592a39164d964ae16fd9dd010017ff9b9fa40aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Sep 2024 19:24:57 -0500 Subject: [PATCH 0426/1309] Fix building multidict binary wheels on armv7 and armhf (#125550) Fix building multidict wheels on armv7 and armhf This is the same fix as we needed for yarl The armv7 and armhf wheels are missing for multidict 6.0.5 https://wheels.home-assistant.io/musllinux/ --- .github/workflows/wheels.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index fcd71cbec32..20dd2054c6e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -140,7 +140,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "libffi-dev;openssl-dev;yaml-dev;nasm" - skip-binary: aiohttp;yarl + skip-binary: aiohttp;multidict;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" @@ -212,7 +212,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl + skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_old-cython.txt" @@ -227,7 +227,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl + skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -241,7 +241,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl + skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -255,7 +255,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl + skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;protobuf;pydantic;pymicro-vad;yarl constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" From 391de22342185c41fb484f17a5030d546e89b9b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Sep 2024 19:25:10 -0500 Subject: [PATCH 0427/1309] Bump yarl to 1.11.0 (#125549) changelog: https://github.com/aio-libs/yarl/compare/v1.10.0...v1.11.0 --- 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 e043740e15a..af3545b0f1d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ urllib3>=1.26.5,<2 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.10.0 +yarl==1.11.0 zeroconf==0.134.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 358abd934be..c6ec12cc860 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.10.0", + "yarl==1.11.0", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index ba7b89bd9e9..48a9c297373 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,4 @@ urllib3>=1.26.5,<2 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.10.0 +yarl==1.11.0 From a85ccb94e36f31d1a9d122f9191dbbf3cd7afc27 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 9 Sep 2024 04:42:51 +0300 Subject: [PATCH 0428/1309] LLM Tool parameters check (#123621) * LLM Tool parameters check * fix tests --- homeassistant/helpers/llm.py | 5 +++++ .../google_generative_ai_conversation/test_conversation.py | 4 ++-- tests/components/ollama/test_conversation.py | 5 +++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index e37aa0c532d..0c173df81ff 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -177,6 +177,11 @@ class APIInstance: else: raise HomeAssistantError(f'Tool "{tool_input.tool_name}" not found') + tool_input = ToolInput( + tool_name=tool_input.tool_name, + tool_args=tool.parameters(tool_input.tool_args), + ) + return await tool.async_call(self.api.hass, tool_input, self.llm_context) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 1ea5c2ad9b8..4192a60513e 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -212,7 +212,7 @@ async def test_function_call( name="test_tool", args={ "param1": ["test_value", "param1\\'s value"], - "param2": "param2\\'s value", + "param2": 2.7, }, ) @@ -258,7 +258,7 @@ async def test_function_call( tool_name="test_tool", tool_args={ "param1": ["test_value", "param1's value"], - "param2": "param2's value", + "param2": 2.7, }, ), llm.LLMContext( diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 6c34b8e0052..66dc8a0c603 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -121,7 +121,7 @@ async def test_template_variables( ("tool_args", "expected_tool_args"), [ ({"param1": "test_value"}, {"param1": "test_value"}), - ({"param1": 2}, {"param1": 2}), + ({"param2": 2}, {"param2": 2}), ( {"param1": "test_value", "floor": ""}, {"param1": "test_value"}, # Omit empty arguments @@ -153,7 +153,8 @@ async def test_function_call( mock_tool.name = "test_tool" mock_tool.description = "Test function" mock_tool.parameters = vol.Schema( - {vol.Optional("param1", description="Test parameters"): str} + {vol.Optional("param1", description="Test parameters"): str}, + extra=vol.ALLOW_EXTRA, ) mock_tool.async_call.return_value = "Test response" From 8884465262889bab6aefe229780921773bb05c46 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Sun, 8 Sep 2024 21:22:35 -0500 Subject: [PATCH 0429/1309] ESPHome media proxy (#123254) * Add ffmpeg proxy view * Add tests * Add proxy to media player * Add proxy test * Only allow one ffmpeg proc per device * Incorporate feedback * Fix tests * address comments * Fix test * Update paths without auth const --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/esphome/__init__.py | 10 +- homeassistant/components/esphome/const.py | 2 + .../components/esphome/ffmpeg_proxy.py | 227 ++++++++++++++++++ .../components/esphome/manifest.json | 2 +- .../components/esphome/media_player.py | 79 +++++- .../components/media_player/browse_media.py | 2 +- tests/components/esphome/test_ffmpeg_proxy.py | 111 +++++++++ tests/components/esphome/test_media_player.py | 127 +++++++++- 8 files changed, 553 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/esphome/ffmpeg_proxy.py create mode 100644 tests/components/esphome/test_ffmpeg_proxy.py diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index b06fcd4bab0..13e9496a9fd 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from aioesphomeapi import APIClient -from homeassistant.components import zeroconf +from homeassistant.components import ffmpeg, zeroconf from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -15,12 +15,13 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_NOISE_PSK, DOMAIN +from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN from .dashboard import async_setup as async_setup_dashboard from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +from .ffmpeg_proxy import FFmpegProxyData, FFmpegProxyView from .manager import ESPHomeManager, cleanup_instance CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -30,7 +31,12 @@ CLIENT_INFO = f"Home Assistant {ha_version}" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the esphome component.""" + proxy_data = hass.data[DATA_FFMPEG_PROXY] = FFmpegProxyData() + await async_setup_dashboard(hass) + hass.http.register_view( + FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data) + ) return True diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 9c09591f6ea..143aaa6342a 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -18,3 +18,5 @@ PROJECT_URLS = { "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", } DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" + +DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy" diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py new file mode 100644 index 00000000000..d2f538bfbd5 --- /dev/null +++ b/homeassistant/components/esphome/ffmpeg_proxy.py @@ -0,0 +1,227 @@ +"""HTTP view that converts audio from a URL to a preferred format.""" + +import asyncio +from collections import defaultdict +from dataclasses import dataclass, field +from http import HTTPStatus +import logging +import secrets + +from aiohttp import web +from aiohttp.abc import AbstractStreamWriter, BaseRequest + +from homeassistant.components.ffmpeg import FFmpegManager +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant + +from .const import DATA_FFMPEG_PROXY + +_LOGGER = logging.getLogger(__name__) + + +def async_create_proxy_url( + hass: HomeAssistant, + device_id: str, + media_url: str, + media_format: str, + rate: int | None = None, + channels: int | None = None, +) -> str: + """Create a one-time use proxy URL that automatically converts the media.""" + data: FFmpegProxyData = hass.data[DATA_FFMPEG_PROXY] + return data.async_create_proxy_url( + device_id, media_url, media_format, rate, channels + ) + + +@dataclass +class FFmpegConversionInfo: + """Information for ffmpeg conversion.""" + + url: str + """Source URL of media to convert.""" + + media_format: str + """Target format for media (mp3, flac, etc.)""" + + rate: int | None + """Target sample rate (None to keep source rate).""" + + channels: int | None + """Target number of channels (None to keep source channels).""" + + +@dataclass +class FFmpegProxyData: + """Data for ffmpeg proxy conversion.""" + + # device_id -> convert_id -> info + conversions: dict[str, dict[str, FFmpegConversionInfo]] = field( + default_factory=lambda: defaultdict(dict) + ) + + # device_id -> process + processes: dict[str, asyncio.subprocess.Process] = field(default_factory=dict) + + def async_create_proxy_url( + self, + device_id: str, + media_url: str, + media_format: str, + rate: int | None, + channels: int | None, + ) -> str: + """Create a one-time use proxy URL that automatically converts the media.""" + convert_id = secrets.token_urlsafe(16) + self.conversions[device_id][convert_id] = FFmpegConversionInfo( + media_url, media_format, rate, channels + ) + _LOGGER.debug("Media URL allowed by proxy: %s", media_url) + + return f"/api/esphome/ffmpeg_proxy/{device_id}/{convert_id}.{media_format}" + + +class FFmpegConvertResponse(web.StreamResponse): + """HTTP streaming response that uses ffmpeg to convert audio from a URL.""" + + def __init__( + self, + manager: FFmpegManager, + convert_info: FFmpegConversionInfo, + device_id: str, + proxy_data: FFmpegProxyData, + chunk_size: int = 2048, + ) -> None: + """Initialize response. + + Parameters + ---------- + manager: FFmpegManager + ffmpeg manager + convert_info: FFmpegConversionInfo + Information necessary to do the conversion + device_id: str + ESPHome device id + proxy_data: FFmpegProxyData + Data object to store ffmpeg process + chunk_size: int + Number of bytes to read from ffmpeg process at a time + + """ + super().__init__(status=200) + self.hass = manager.hass + self.manager = manager + self.convert_info = convert_info + self.device_id = device_id + self.proxy_data = proxy_data + self.chunk_size = chunk_size + + async def prepare(self, request: BaseRequest) -> AbstractStreamWriter | None: + """Stream url through ffmpeg conversion and out to HTTP client.""" + writer = await super().prepare(request) + assert writer is not None + + command_args = [ + "-i", + self.convert_info.url, + "-f", + self.convert_info.media_format, + ] + + if self.convert_info.rate is not None: + # Sample rate + command_args.extend(["-ar", str(self.convert_info.rate)]) + + if self.convert_info.channels is not None: + # Number of channels + command_args.extend(["-ac", str(self.convert_info.channels)]) + + # Output to stdout + command_args.append("pipe:") + + _LOGGER.debug("%s %s", self.manager.binary, " ".join(command_args)) + proc = await asyncio.create_subprocess_exec( + self.manager.binary, + *command_args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + assert proc.stdout is not None + assert proc.stderr is not None + + # Only one conversion process per device is allowed + self.proxy_data.processes[self.device_id] = proc + + try: + # Pull audio chunks from ffmpeg and pass them to the HTTP client + while ( + self.hass.is_running + and (request.transport is not None) + and (not request.transport.is_closing()) + and (proc.returncode is None) + and (chunk := await proc.stdout.read(self.chunk_size)) + ): + await writer.write(chunk) + await writer.drain() + finally: + # Close connection + await writer.write_eof() + + # Terminate hangs, so kill is used + proc.kill() + + if proc.returncode != 0: + # Process did not exit successfully + stderr_text = "" + while line := await proc.stderr.readline(): + stderr_text += line.decode() + _LOGGER.error("Error shutting down ffmpeg: %s", stderr_text) + else: + _LOGGER.debug("Conversion completed: %s", self.convert_info) + + return writer + + +class FFmpegProxyView(HomeAssistantView): + """FFmpeg web view to convert audio and stream back to client.""" + + requires_auth = False + url = "/api/esphome/ffmpeg_proxy/{device_id}/{filename}" + name = "api:esphome:ffmpeg_proxy" + + def __init__(self, manager: FFmpegManager, proxy_data: FFmpegProxyData) -> None: + """Initialize an ffmpeg view.""" + self.manager = manager + self.proxy_data = proxy_data + + async def get( + self, request: web.Request, device_id: str, filename: str + ) -> web.StreamResponse: + """Start a get request.""" + + # {id}.mp3 -> id + convert_id = filename.rsplit(".")[0] + + try: + convert_info = self.proxy_data.conversions[device_id].pop(convert_id) + except KeyError: + _LOGGER.error( + "Unrecognized convert id %s for device: %s", convert_id, device_id + ) + return web.Response( + body="Convert id not recognized", status=HTTPStatus.BAD_REQUEST + ) + + # Stop any existing process + proc = self.proxy_data.processes.pop(device_id, None) + if (proc is not None) and (proc.returncode is None): + _LOGGER.debug("Stopping existing ffmpeg process for device: %s", device_id) + + # Terminate hangs, so kill is used + proc.kill() + + # Stream converted audio back to client + return FFmpegConvertResponse( + self.manager, convert_info, device_id, self.proxy_data + ) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 233015b13ba..fea443635a4 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -4,7 +4,7 @@ "after_dependencies": ["zeroconf", "tag"], "codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, - "dependencies": ["assist_pipeline", "bluetooth", "intent"], + "dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"], "dhcp": [ { "registered_devices": true diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 4d57552bb19..d742029bcef 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -3,14 +3,18 @@ from __future__ import annotations from functools import partial +import logging from typing import Any, cast +from urllib.parse import urlparse from aioesphomeapi import ( EntityInfo, MediaPlayerCommand, MediaPlayerEntityState, + MediaPlayerFormatPurpose, MediaPlayerInfo, MediaPlayerState as EspMediaPlayerState, + MediaPlayerSupportedFormat, ) from homeassistant.components import media_source @@ -34,6 +38,9 @@ from .entity import ( platform_async_setup_entry, ) from .enum_mapper import EsphomeEnumMapper +from .ffmpeg_proxy import async_create_proxy_url + +_LOGGER = logging.getLogger(__name__) _STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumMapper( { @@ -66,7 +73,7 @@ class EsphomeMediaPlayer( if self._static_info.supports_pause: flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY self._attr_supported_features = flags - self._entry_data.media_player_formats[self.entity_id] = cast( + self._entry_data.media_player_formats[static_info.unique_id] = cast( MediaPlayerInfo, static_info ).supported_formats @@ -102,6 +109,22 @@ class EsphomeMediaPlayer( media_id = async_process_play_media_url(self.hass, media_id) announcement = kwargs.get(ATTR_MEDIA_ANNOUNCE) + supported_formats: list[MediaPlayerSupportedFormat] | None = ( + self._entry_data.media_player_formats.get(self._static_info.unique_id) + ) + + if ( + supported_formats + and _is_url(media_id) + and ( + proxy_url := self._get_proxy_url( + supported_formats, media_id, announcement is True + ) + ) + ): + # Substitute proxy URL + media_id = proxy_url + self._client.media_player_command( self._key, media_url=media_id, announcement=announcement ) @@ -111,6 +134,54 @@ class EsphomeMediaPlayer( await super().async_will_remove_from_hass() self._entry_data.media_player_formats.pop(self.entity_id, None) + def _get_proxy_url( + self, + supported_formats: list[MediaPlayerSupportedFormat], + url: str, + announcement: bool, + ) -> str | None: + """Get URL for ffmpeg proxy.""" + if self.device_entry is None: + # Device id is required + return None + + # Choose the first default or announcement supported format + format_to_use: MediaPlayerSupportedFormat | None = None + for supported_format in supported_formats: + if (format_to_use is None) and ( + supported_format.purpose == MediaPlayerFormatPurpose.DEFAULT + ): + # First default format + format_to_use = supported_format + elif announcement and ( + supported_format.purpose == MediaPlayerFormatPurpose.ANNOUNCEMENT + ): + # First announcement format + format_to_use = supported_format + break + + if format_to_use is None: + # No format for conversion + return None + + # Replace the media URL with a proxy URL pointing to Home + # Assistant. When requested, Home Assistant will use ffmpeg to + # convert the source URL to the supported format. + _LOGGER.debug("Proxying media url %s with format %s", url, format_to_use) + device_id = self.device_entry.id + media_format = format_to_use.format + proxy_url = async_create_proxy_url( + self.hass, + device_id, + url, + media_format=media_format, + rate=format_to_use.sample_rate, + channels=format_to_use.num_channels, + ) + + # Resolve URL + return async_process_play_media_url(self.hass, proxy_url) + async def async_browse_media( self, media_content_type: MediaType | str | None = None, @@ -152,6 +223,12 @@ class EsphomeMediaPlayer( ) +def _is_url(url: str) -> bool: + """Validate the URL can be parsed and at least has scheme + netloc.""" + result = urlparse(url) + return all([result.scheme, result.netloc]) + + async_setup_entry = partial( platform_async_setup_entry, info_type=MediaPlayerInfo, diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index 351d4e9140f..e1c2fa37ca0 100644 --- a/homeassistant/components/media_player/browse_media.py +++ b/homeassistant/components/media_player/browse_media.py @@ -23,7 +23,7 @@ from homeassistant.helpers.network import ( from .const import CONTENT_AUTH_EXPIRY_TIME, MediaClass, MediaType # Paths that we don't need to sign -PATHS_WITHOUT_AUTH = ("/api/tts_proxy/",) +PATHS_WITHOUT_AUTH = ("/api/tts_proxy/", "/api/esphome/ffmpeg_proxy/") @callback diff --git a/tests/components/esphome/test_ffmpeg_proxy.py b/tests/components/esphome/test_ffmpeg_proxy.py new file mode 100644 index 00000000000..577126201df --- /dev/null +++ b/tests/components/esphome/test_ffmpeg_proxy.py @@ -0,0 +1,111 @@ +"""Tests for ffmpeg proxy view.""" + +from http import HTTPStatus +import io +import tempfile +from unittest.mock import patch +from urllib.request import pathname2url +import wave + +import mutagen + +from homeassistant.components import esphome +from homeassistant.components.esphome.ffmpeg_proxy import async_create_proxy_url +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator + + +async def test_async_create_proxy_url(hass: HomeAssistant) -> None: + """Test that async_create_proxy_url returns the correct format.""" + assert await async_setup_component(hass, "esphome", {}) + + device_id = "test-device" + convert_id = "test-id" + media_format = "flac" + media_url = "http://127.0.0.1/test.mp3" + proxy_url = f"/api/esphome/ffmpeg_proxy/{device_id}/{convert_id}.{media_format}" + + with patch( + "homeassistant.components.esphome.ffmpeg_proxy.secrets.token_urlsafe", + return_value=convert_id, + ): + assert ( + async_create_proxy_url(hass, device_id, media_url, media_format) + == proxy_url + ) + + +async def test_proxy_view( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test proxy HTTP view for converting audio.""" + device_id = "1234" + + await async_setup_component(hass, esphome.DOMAIN, {esphome.DOMAIN: {}}) + client = await hass_client() + + with tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as temp_file: + with wave.open(temp_file.name, "wb") as wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(16000 * 2)) # 1s + + temp_file.seek(0) + wav_url = pathname2url(temp_file.name) + convert_id = "test-id" + url = f"/api/esphome/ffmpeg_proxy/{device_id}/{convert_id}.mp3" + + # Should fail because we haven't allowed the URL yet + req = await client.get(url) + assert req.status == HTTPStatus.BAD_REQUEST + + # Allow the URL + with patch( + "homeassistant.components.esphome.ffmpeg_proxy.secrets.token_urlsafe", + return_value=convert_id, + ): + assert ( + async_create_proxy_url( + hass, device_id, wav_url, media_format="mp3", rate=22050, channels=2 + ) + == url + ) + + req = await client.get(url) + assert req.status == HTTPStatus.OK + + mp3_data = await req.content.read() + + # Verify conversion + with io.BytesIO(mp3_data) as mp3_io: + mp3_file = mutagen.File(mp3_io) + assert mp3_file.info.sample_rate == 22050 + assert mp3_file.info.channels == 2 + + # About a second, but not exact + assert round(mp3_file.info.length, 0) == 1 + + +async def test_ffmpeg_error( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test proxy HTTP view with an ffmpeg error.""" + device_id = "1234" + + await async_setup_component(hass, esphome.DOMAIN, {esphome.DOMAIN: {}}) + client = await hass_client() + + # Try to convert a file that doesn't exist + url = async_create_proxy_url(hass, device_id, "missing-file", media_format="mp3") + req = await client.get(url) + + # The HTTP status is OK because the ffmpeg process started, but no data is + # returned. + assert req.status == HTTPStatus.OK + mp3_data = await req.content.read() + assert not mp3_data diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 3879129ccb6..e859324b394 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -1,13 +1,19 @@ """Test ESPHome media_players.""" +from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, Mock, call, patch from aioesphomeapi import ( APIClient, + EntityInfo, + EntityState, MediaPlayerCommand, MediaPlayerEntityState, + MediaPlayerFormatPurpose, MediaPlayerInfo, MediaPlayerState, + MediaPlayerSupportedFormat, + UserService, ) import pytest @@ -31,8 +37,11 @@ from homeassistant.components.media_player import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr from homeassistant.setup import async_setup_component +from .conftest import MockESPHomeDevice + from tests.common import mock_platform from tests.typing import WebSocketGenerator @@ -55,7 +64,7 @@ async def test_media_player_entity( key=1, volume=50, muted=True, state=MediaPlayerState.PAUSED ) ] - user_service = [] + user_service: list[UserService] = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, @@ -200,7 +209,7 @@ async def test_media_player_entity_with_source( key=1, volume=50, muted=True, state=MediaPlayerState.PLAYING ) ] - user_service = [] + user_service: list[UserService] = [] await mock_generic_device_entry( mock_client=mock_client, entity_info=entity_info, @@ -277,3 +286,117 @@ async def test_media_player_entity_with_source( mock_client.media_player_command.assert_has_calls( [call(1, media_url="media-source://tts?message=hello", announcement=True)] ) + + +async def test_media_player_proxy( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a media_player entity with a proxy URL.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[ + MediaPlayerInfo( + object_id="mymedia_player", + key=1, + name="my media_player", + unique_id="my_media_player", + supports_pause=True, + supported_formats=[ + MediaPlayerSupportedFormat( + format="flac", + sample_rate=48000, + num_channels=2, + purpose=MediaPlayerFormatPurpose.DEFAULT, + ), + MediaPlayerSupportedFormat( + format="wav", + sample_rate=16000, + num_channels=1, + purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + ), + MediaPlayerSupportedFormat( + format="mp3", + sample_rate=48000, + num_channels=2, + purpose=MediaPlayerFormatPurpose.DEFAULT, + ), + ], + ) + ], + user_service=[], + states=[ + MediaPlayerEntityState( + key=1, volume=50, muted=False, state=MediaPlayerState.PAUSED + ) + ], + ) + await hass.async_block_till_done() + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + assert dev is not None + state = hass.states.get("media_player.test_mymedia_player") + assert state is not None + assert state.state == "paused" + + media_url = "http://127.0.0.1/test.mp3" + proxy_url = f"/api/esphome/ffmpeg_proxy/{dev.id}/test-id.flac" + + with ( + patch( + "homeassistant.components.esphome.media_player.async_create_proxy_url", + return_value=proxy_url, + ) as mock_async_create_proxy_url, + ): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: media_url, + }, + blocking=True, + ) + + # Should be the default format + mock_async_create_proxy_url.assert_called_once() + device_id = mock_async_create_proxy_url.call_args[0][1] + mock_async_create_proxy_url.assert_called_once_with( + hass, device_id, media_url, media_format="flac", rate=48000, channels=2 + ) + + media_args = mock_client.media_player_command.call_args.kwargs + assert not media_args["announcement"] + + # Reset + mock_async_create_proxy_url.reset_mock() + + # Set announcement flag + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: media_url, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + + # Should be the announcement format + mock_async_create_proxy_url.assert_called_once() + device_id = mock_async_create_proxy_url.call_args[0][1] + mock_async_create_proxy_url.assert_called_once_with( + hass, device_id, media_url, media_format="wav", rate=16000, channels=1 + ) + + media_args = mock_client.media_player_command.call_args.kwargs + assert media_args["announcement"] From d88487e30be24f32a99d958ebb7de597f17710a1 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:54:18 +1200 Subject: [PATCH 0430/1309] Bump aioesphomeapi to 25.4.0 (#125554) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index fea443635a4..f18d6e7cc68 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==25.3.2", + "aioesphomeapi==25.4.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.0.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 6faecd98f2c..6b902f76efa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==25.3.2 +aioesphomeapi==25.4.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f81bba44f9..c6d723135cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -225,7 +225,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==25.3.2 +aioesphomeapi==25.4.0 # homeassistant.components.flo aioflo==2021.11.0 From 2a1df2063df6c0021bb2667dccf9667c6363d5dd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 9 Sep 2024 08:16:30 +0200 Subject: [PATCH 0431/1309] Separate recorder test fixtures disabling context id migration (#125324) * Separate recorder test fixtures disabling context id migration * Fix test --- .../recorder/test_migration_from_schema_32.py | 4 ++-- ..._migration_run_time_migrations_remember.py | 6 ++++-- .../components/recorder/test_v32_migration.py | 3 ++- tests/conftest.py | 21 ++++++++++++++----- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index cdbbd7ec4e4..95146b970f3 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -118,7 +118,7 @@ def db_schema_32(): yield -@pytest.mark.parametrize("enable_migrate_context_ids", [True]) +@pytest.mark.parametrize("enable_migrate_event_context_ids", [True]) @pytest.mark.usefixtures("db_schema_32") async def test_migrate_events_context_ids( hass: HomeAssistant, recorder_mock: Recorder @@ -338,7 +338,7 @@ async def test_migrate_events_context_ids( assert get_index_by_name(session, "events", "ix_events_context_id") is None -@pytest.mark.parametrize("enable_migrate_context_ids", [True]) +@pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) @pytest.mark.usefixtures("db_schema_32") async def test_migrate_states_context_ids( hass: HomeAssistant, recorder_mock: Recorder diff --git a/tests/components/recorder/test_migration_run_time_migrations_remember.py b/tests/components/recorder/test_migration_run_time_migrations_remember.py index bdd881a3a7b..880e4d6d61e 100644 --- a/tests/components/recorder/test_migration_run_time_migrations_remember.py +++ b/tests/components/recorder/test_migration_run_time_migrations_remember.py @@ -72,7 +72,7 @@ def _create_engine_test(*args, **kwargs): return engine -@pytest.mark.parametrize("enable_migrate_context_ids", [True]) +@pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) @pytest.mark.parametrize("persistent_database", [True]) @pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage async def test_migration_changes_prevent_trying_to_migrate_again( @@ -173,4 +173,6 @@ async def test_migration_changes_prevent_trying_to_migrate_again( await hass.async_stop() for task in tasks: - assert not isinstance(task, MigrationTask) + if not isinstance(task, MigrationTask): + continue + assert not isinstance(task.migrator, migration.StatesContextIDMigration) diff --git a/tests/components/recorder/test_v32_migration.py b/tests/components/recorder/test_v32_migration.py index 60f223aaa91..9a616959174 100644 --- a/tests/components/recorder/test_v32_migration.py +++ b/tests/components/recorder/test_v32_migration.py @@ -60,7 +60,8 @@ def _create_engine_test(schema_module: str) -> Callable: return _create_engine_test -@pytest.mark.parametrize("enable_migrate_context_ids", [True]) +@pytest.mark.parametrize("enable_migrate_event_context_ids", [True]) +@pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) @pytest.mark.parametrize("enable_migrate_entity_ids", [True]) @pytest.mark.parametrize("persistent_database", [True]) diff --git a/tests/conftest.py b/tests/conftest.py index df183f955cb..178fdd74a69 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1293,11 +1293,21 @@ def enable_nightly_purge() -> bool: @pytest.fixture -def enable_migrate_context_ids() -> bool: +def enable_migrate_event_context_ids() -> bool: """Fixture to control enabling of recorder's context id migration. To enable context id migration, tests can be marked with: - @pytest.mark.parametrize("enable_migrate_context_ids", [True]) + @pytest.mark.parametrize("enable_migrate_event_context_ids", [True]) + """ + return False + + +@pytest.fixture +def enable_migrate_state_context_ids() -> bool: + """Fixture to control enabling of recorder's context id migration. + + To enable context id migration, tests can be marked with: + @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) """ return False @@ -1465,7 +1475,8 @@ async def async_test_recorder( enable_statistics: bool, enable_missing_statistics: bool, enable_schema_validation: bool, - enable_migrate_context_ids: bool, + enable_migrate_event_context_ids: bool, + enable_migrate_state_context_ids: bool, enable_migrate_event_type_ids: bool, enable_migrate_entity_ids: bool, enable_migrate_event_ids: bool, @@ -1527,12 +1538,12 @@ async def async_test_recorder( ) migrate_states_context_ids = ( migration.StatesContextIDMigration.migrate_data - if enable_migrate_context_ids + if enable_migrate_state_context_ids else None ) migrate_events_context_ids = ( migration.EventsContextIDMigration.migrate_data - if enable_migrate_context_ids + if enable_migrate_event_context_ids else None ) migrate_event_type_ids = ( From 17ab45da43421ed2bfd5aa4e16c7d6681ada1560 Mon Sep 17 00:00:00 2001 From: Chris Brouwer <7203501+cbrouwer@users.noreply.github.com> Date: Mon, 9 Sep 2024 08:36:59 +0200 Subject: [PATCH 0432/1309] Fix support for Heat meters to DSMR integration (#125523) * Fix support for Heat meters to DSMR integration * Fixed test --- homeassistant/components/dsmr/const.py | 1 + homeassistant/components/dsmr/sensor.py | 26 +++++++++- tests/components/dsmr/test_sensor.py | 68 +++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 7f5813cda7f..4c6cb31ca4d 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -26,6 +26,7 @@ DEFAULT_TIME_BETWEEN_UPDATE = 30 DEVICE_NAME_ELECTRICITY = "Electricity Meter" DEVICE_NAME_GAS = "Gas Meter" DEVICE_NAME_WATER = "Water Meter" +DEVICE_NAME_HEAT = "Heat Meter" DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 77c40c5c292..b76736a1101 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -57,6 +57,7 @@ from .const import ( DEFAULT_TIME_BETWEEN_UPDATE, DEVICE_NAME_ELECTRICITY, DEVICE_NAME_GAS, + DEVICE_NAME_HEAT, DEVICE_NAME_WATER, DOMAIN, DSMR_PROTOCOL, @@ -75,6 +76,7 @@ class DSMRSensorEntityDescription(SensorEntityDescription): dsmr_versions: set[str] | None = None is_gas: bool = False is_water: bool = False + is_heat: bool = False obis_reference: str @@ -82,6 +84,7 @@ class MbusDeviceType(IntEnum): """Types of mbus devices (13757-3:2013).""" GAS = 3 + HEAT = 4 WATER = 7 @@ -396,6 +399,16 @@ SENSORS_MBUS_DEVICE_TYPE: dict[int, tuple[DSMRSensorEntityDescription, ...]] = { state_class=SensorStateClass.TOTAL_INCREASING, ), ), + MbusDeviceType.HEAT: ( + DSMRSensorEntityDescription( + key="heat_reading", + translation_key="heat_meter_reading", + obis_reference="MBUS_METER_READING", + is_heat=True, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + ), MbusDeviceType.WATER: ( DSMRSensorEntityDescription( key="water_reading", @@ -490,6 +503,10 @@ def create_mbus_entities( continue type_ = int(device_type.value) + if type_ not in SENSORS_MBUS_DEVICE_TYPE: + LOGGER.warning("Unsupported MBUS_DEVICE_TYPE (%d)", type_) + continue + if identifier := getattr(device, "MBUS_EQUIPMENT_IDENTIFIER", None): serial_ = identifier.value rename_old_gas_to_mbus(hass, entry, serial_) @@ -554,7 +571,10 @@ async def async_setup_entry( ) for description in SENSORS if is_supported_description(telegram, description, dsmr_version) - and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data) + and ( + (not description.is_gas and not description.is_heat) + or CONF_SERIAL_ID_GAS in entry.data + ) ] ) async_add_entities(entities) @@ -743,6 +763,10 @@ class DSMREntity(SensorEntity): if serial_id: device_serial = serial_id device_name = DEVICE_NAME_WATER + if entity_description.is_heat: + if serial_id: + device_serial = serial_id + device_name = DEVICE_NAME_HEAT if device_serial is None: device_serial = entry.entry_id diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index c2c6d48b007..4a2951f4ed8 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -1521,6 +1521,74 @@ async def test_gas_meter_providing_energy_reading( ) +async def test_heat_meter_mbus( + hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] +) -> None: + """Test if heat meter reading is correctly parsed.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + entry_data = { + "port": "/dev/ttyUSB0", + "dsmr_version": "5", + "serial_id": "1234", + "serial_id_gas": None, + } + entry_options = { + "time_between_update": 0, + } + + telegram = Telegram() + telegram.add( + MBUS_DEVICE_TYPE, + CosemObject((0, 1), [{"value": "004", "unit": ""}]), + "MBUS_DEVICE_TYPE", + ) + telegram.add( + MBUS_METER_READING, + MBusObject( + (0, 1), + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": "GJ"}, + ], + ), + "MBUS_METER_READING", + ) + + mock_entry = MockConfigEntry( + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options + ) + + hass.loop.set_debug(True) + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + + # check if gas consumption is parsed correctly + heat_consumption = hass.states.get("sensor.heat_meter_energy") + assert heat_consumption.state == "745.695" + assert ( + heat_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY + ) + assert ( + heat_consumption.attributes.get("unit_of_measurement") + == UnitOfEnergy.GIGA_JOULE + ) + assert ( + heat_consumption.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + ) + + def test_all_obis_references_exists() -> None: """Verify that all attributes exist by name in database.""" for sensor in SENSORS: From 713689491b98cbac978b9a3cc88a6ed976afc9b9 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 9 Sep 2024 09:01:21 +0200 Subject: [PATCH 0433/1309] Remove KNX yaml config from `hass.data` (#124050) * Remove KNX yaml config from `hass.data` * Use HassKey --- homeassistant/components/knx/__init__.py | 52 +++++++++---------- homeassistant/components/knx/binary_sensor.py | 9 ++-- homeassistant/components/knx/button.py | 11 ++-- homeassistant/components/knx/climate.py | 6 +-- homeassistant/components/knx/config_flow.py | 5 +- homeassistant/components/knx/const.py | 9 ++-- homeassistant/components/knx/cover.py | 6 +-- homeassistant/components/knx/date.py | 7 ++- homeassistant/components/knx/datetime.py | 7 ++- .../components/knx/device_trigger.py | 9 ++-- homeassistant/components/knx/diagnostics.py | 3 +- homeassistant/components/knx/fan.py | 6 +-- homeassistant/components/knx/light.py | 6 +-- homeassistant/components/knx/notify.py | 11 ++-- homeassistant/components/knx/number.py | 12 ++--- homeassistant/components/knx/scene.py | 6 +-- homeassistant/components/knx/select.py | 7 ++- homeassistant/components/knx/sensor.py | 6 +-- homeassistant/components/knx/services.py | 3 +- homeassistant/components/knx/switch.py | 6 +-- homeassistant/components/knx/text.py | 12 ++--- homeassistant/components/knx/time.py | 7 ++- homeassistant/components/knx/weather.py | 6 +-- homeassistant/components/knx/websocket.py | 7 ++- tests/components/knx/test_button.py | 8 ++- tests/components/knx/test_telegrams.py | 12 +++-- tests/components/knx/test_websocket.py | 17 +++--- 27 files changed, 124 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 01d5294639c..736c5f6cb9d 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import contextlib import logging from pathlib import Path +from typing import Final import voluptuous as vol from xknx import XKNX @@ -59,9 +60,9 @@ from .const import ( CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DATA_HASS_CONFIG, - DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, + KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI, SUPPORTED_PLATFORMS_YAML, TELEGRAM_LOG_DEFAULT, @@ -97,6 +98,7 @@ from .websocket import register_panel _LOGGER = logging.getLogger(__name__) +_KNX_YAML_CONFIG: Final = "knx_yaml_config" CONFIG_SCHEMA = vol.Schema( { @@ -148,7 +150,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Start the KNX integration.""" hass.data[DATA_HASS_CONFIG] = config if (conf := config.get(DOMAIN)) is not None: - hass.data[DATA_KNX_CONFIG] = dict(conf) + hass.data[_KNX_YAML_CONFIG] = dict(conf) register_knx_services(hass) return True @@ -156,16 +158,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" - # `config` is None when reloading the integration - # or no `knx` key in configuration.yaml - if (config := hass.data.get(DATA_KNX_CONFIG)) is None: + # `_KNX_YAML_CONFIG` is only set in async_setup. + # It's None when reloading the integration or no `knx` key in configuration.yaml + config = hass.data.pop(_KNX_YAML_CONFIG, None) + if config is None: _conf = await async_integration_yaml_config(hass, DOMAIN) if not _conf or DOMAIN not in _conf: - _LOGGER.warning( - "No `knx:` key found in configuration.yaml. See " - "https://www.home-assistant.io/integrations/knx/ " - "for KNX entity configuration documentation" - ) # generate defaults config = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] else: @@ -176,22 +174,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except XKNXException as ex: raise ConfigEntryNotReady from ex - hass.data[DATA_KNX_CONFIG] = config - hass.data[DOMAIN] = knx_module + hass.data[KNX_MODULE_KEY] = knx_module if CONF_KNX_EXPOSE in config: for expose_config in config[CONF_KNX_EXPOSE]: knx_module.exposures.append( create_knx_exposure(hass, knx_module.xknx, expose_config) ) + configured_platforms_yaml = { + platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config + } await hass.config_entries.async_forward_entry_setups( entry, { Platform.SENSOR, # always forward sensor for system entities (telegram counter, etc.) *SUPPORTED_PLATFORMS_UI, # forward all platforms that support UI entity management - *{ # forward yaml-only managed platforms on demand - platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config - }, + *configured_platforms_yaml, # forward yaml-only managed platforms on demand, }, ) @@ -210,30 +208,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unloading the KNX platforms.""" - # if not loaded directly return - if not hass.data.get(DOMAIN): + knx_module = hass.data.get(KNX_MODULE_KEY) + if not knx_module: + # if not loaded directly return return True - knx_module: KNXModule = hass.data[DOMAIN] for exposure in knx_module.exposures: exposure.async_remove() + configured_platforms_yaml = { + platform + for platform in SUPPORTED_PLATFORMS_YAML + if platform in knx_module.config_yaml + } unload_ok = await hass.config_entries.async_unload_platforms( entry, { Platform.SENSOR, # always unload system entities (telegram counter, etc.) *SUPPORTED_PLATFORMS_UI, # unload all platforms that support UI entity management - *{ # unload yaml-only managed platforms if configured - platform - for platform in SUPPORTED_PLATFORMS_YAML - if platform in hass.data[DATA_KNX_CONFIG] - }, + *configured_platforms_yaml, # unload yaml-only managed platforms if configured, }, ) if unload_ok: await knx_module.stop() hass.data.pop(DOMAIN) - hass.data.pop(DATA_KNX_CONFIG) return unload_ok @@ -267,7 +265,7 @@ async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a config entry from a device.""" - knx_module: KNXModule = hass.data[DOMAIN] + knx_module = hass.data[KNX_MODULE_KEY] if not device_entry.identifiers.isdisjoint( knx_module.interface_device.device_info["identifiers"] ): @@ -287,7 +285,7 @@ class KNXModule: ) -> None: """Initialize KNX module.""" self.hass = hass - self.config = config + self.config_yaml = config self.connected = False self.exposures: list[KNXExposeSensor | KNXExposeTime] = [] self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} @@ -489,7 +487,7 @@ class KNXModule: def register_event_callback(self) -> TelegramQueue.Callback: """Register callback for knx_event within XKNX TelegramQueue.""" address_filters = [] - for filter_set in self.config[CONF_EVENT]: + for filter_set in self.config_yaml[CONF_EVENT]: _filters = list(map(AddressFilter, filter_set[KNX_ADDRESS])) address_filters.extend(_filters) if (dpt := filter_set.get(CONF_TYPE)) and ( diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 7d80ca55bf6..ad978dde30e 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import ATTR_COUNTER, ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN +from .const import ATTR_COUNTER, ATTR_SOURCE, KNX_MODULE_KEY from .knx_entity import KnxYamlEntity from .schema import BinarySensorSchema @@ -34,12 +34,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the KNX binary sensor platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: ConfigType = hass.data[DATA_KNX_CONFIG] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.BINARY_SENSOR] async_add_entities( - KNXBinarySensor(knx_module, entity_config) - for entity_config in config[Platform.BINARY_SENSOR] + KNXBinarySensor(knx_module, entity_config) for entity_config in config ) diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index f6627fc527b..9a5700917f9 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import CONF_PAYLOAD_LENGTH, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS +from .const import CONF_PAYLOAD_LENGTH, KNX_ADDRESS, KNX_MODULE_KEY from .knx_entity import KnxYamlEntity @@ -22,13 +22,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the KNX binary sensor platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: ConfigType = hass.data[DATA_KNX_CONFIG] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.BUTTON] - async_add_entities( - KNXButton(knx_module, entity_config) - for entity_config in config[Platform.BUTTON] - ) + async_add_entities(KNXButton(knx_module, entity_config) for entity_config in config) class KNXButton(KnxYamlEntity, ButtonEntity): diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 4932df55087..05f6a80d2d4 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -31,7 +31,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, DATA_KNX_CONFIG, DOMAIN +from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, KNX_MODULE_KEY from .knx_entity import KnxYamlEntity from .schema import ClimateSchema @@ -45,8 +45,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up climate(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.CLIMATE] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.CLIMATE] async_add_entities( KNXClimate(knx_module, entity_config) for entity_config in config diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 7e4db1f889b..4a71c600824 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -58,6 +58,7 @@ from .const import ( CONF_KNX_TUNNELING_TCP_SECURE, DEFAULT_ROUTING_IA, DOMAIN, + KNX_MODULE_KEY, TELEGRAM_LOG_DEFAULT, TELEGRAM_LOG_MAX, KNXConfigEntryData, @@ -182,7 +183,9 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): CONF_KNX_ROUTING: CONF_KNX_ROUTING.capitalize(), } - if isinstance(self, OptionsFlow) and (knx_module := self.hass.data.get(DOMAIN)): + if isinstance(self, OptionsFlow) and ( + knx_module := self.hass.data.get(KNX_MODULE_KEY) + ): xknx = knx_module.xknx else: xknx = XKNX() diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 9ceb18385cb..a7aee794264 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -4,15 +4,20 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from enum import Enum -from typing import Final, TypedDict +from typing import TYPE_CHECKING, Final, TypedDict from xknx.dpt.dpt_20 import HVACControllerMode from xknx.telegram import Telegram from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.const import Platform +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from . import KNXModule DOMAIN: Final = "knx" +KNX_MODULE_KEY: HassKey[KNXModule] = HassKey(DOMAIN) # Address is used for configuration and services by the same functions so the key has to match KNX_ADDRESS: Final = "address" @@ -68,8 +73,6 @@ CONF_RESPOND_TO_READ: Final = "respond_to_read" CONF_STATE_ADDRESS: Final = "state_address" CONF_SYNC_STATE: Final = "sync_state" -# yaml config merged with config entry data -DATA_KNX_CONFIG: Final = "knx_config" # original hass yaml config DATA_HASS_CONFIG: Final = "knx_hass_config" diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 408f746e094..c4b445ff87f 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import DATA_KNX_CONFIG, DOMAIN +from .const import KNX_MODULE_KEY from .knx_entity import KnxYamlEntity from .schema import CoverSchema @@ -37,8 +37,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up cover(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.COVER] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.COVER] async_add_entities(KNXCover(knx_module, entity_config) for entity_config in config) diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index 9f04a4acd7e..d551d4e5b27 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -27,9 +27,8 @@ from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DATA_KNX_CONFIG, - DOMAIN, KNX_ADDRESS, + KNX_MODULE_KEY, ) from .knx_entity import KnxYamlEntity @@ -40,8 +39,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entities for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATE] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.DATE] async_add_entities( KNXDateEntity(knx_module, entity_config) for entity_config in config diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 8f1a25e6e3c..0f98a7be217 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -28,9 +28,8 @@ from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DATA_KNX_CONFIG, - DOMAIN, KNX_ADDRESS, + KNX_MODULE_KEY, ) from .knx_entity import KnxYamlEntity @@ -41,8 +40,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entities for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATETIME] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.DATETIME] async_add_entities( KNXDateTimeEntity(knx_module, entity_config) for entity_config in config diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index ea3cc5faad4..96d8855f479 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -16,9 +16,8 @@ from homeassistant.helpers import selector from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import KNXModule, trigger -from .const import DOMAIN -from .project import KNXProject +from . import trigger +from .const import DOMAIN, KNX_MODULE_KEY from .trigger import ( CONF_KNX_DESTINATION, CONF_KNX_GROUP_VALUE_READ, @@ -47,7 +46,7 @@ async def async_get_triggers( """List device triggers for KNX devices.""" triggers = [] - knx: KNXModule = hass.data[DOMAIN] + knx = hass.data[KNX_MODULE_KEY] if knx.interface_device.device.id == device_id: # Add trigger for KNX telegrams to interface device triggers.append( @@ -67,7 +66,7 @@ async def async_get_trigger_capabilities( hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: """List trigger capabilities.""" - project: KNXProject = hass.data[DOMAIN].project + project = hass.data[KNX_MODULE_KEY].project options = [ selector.SelectOptionDict(value=ga.address, label=f"{ga.address} - {ga.name}") for ga in project.group_addresses.values() diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index 1907539fc61..974a6b3b448 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -18,6 +18,7 @@ from .const import ( CONF_KNX_SECURE_DEVICE_AUTHENTICATION, CONF_KNX_SECURE_USER_PASSWORD, DOMAIN, + KNX_MODULE_KEY, ) TO_REDACT = { @@ -33,7 +34,7 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" diag: dict[str, Any] = {} - knx_module = hass.data[DOMAIN] + knx_module = hass.data[KNX_MODULE_KEY] diag["xknx"] = { "version": knx_module.xknx.version, "current_address": str(knx_module.xknx.current_address), diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 6fd87be97d1..6a026be2edf 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -20,7 +20,7 @@ from homeassistant.util.percentage import ( from homeassistant.util.scaling import int_states_in_range from . import KNXModule -from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS +from .const import KNX_ADDRESS, KNX_MODULE_KEY from .knx_entity import KnxYamlEntity from .schema import FanSchema @@ -33,8 +33,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up fan(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.FAN] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.FAN] async_add_entities(KNXFan(knx_module, entity_config) for entity_config in config) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 0caa3f0a799..a9116f5c282 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -29,7 +29,7 @@ from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util from . import KNXModule -from .const import CONF_SYNC_STATE, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, ColorTempModes +from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .schema import LightSchema from .storage.const import ( @@ -65,7 +65,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up light(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] + knx_module = hass.data[KNX_MODULE_KEY] platform = async_get_current_platform() knx_module.config_store.add_platform( platform=Platform.LIGHT, @@ -77,7 +77,7 @@ async def async_setup_entry( ) entities: list[KnxYamlEntity | KnxUiEntity] = [] - if yaml_platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.LIGHT): + if yaml_platform_config := knx_module.config_yaml.get(Platform.LIGHT): entities.extend( KnxYamlLight(knx_module, entity_config) for entity_config in yaml_platform_config diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 173ab3119a0..ec17cf941f5 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import KNXModule -from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS +from .const import DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY from .knx_entity import KnxYamlEntity @@ -32,8 +32,9 @@ async def async_get_service( if discovery_info is None: return None - if platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.NOTIFY): - xknx: XKNX = hass.data[DOMAIN].xknx + knx_module = hass.data[KNX_MODULE_KEY] + if platform_config := knx_module.config_yaml.get(Platform.NOTIFY): + xknx: XKNX = hass.data[KNX_MODULE_KEY].xknx notification_devices = [ _create_notification_instance(xknx, device_config) @@ -87,8 +88,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up notify(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.NOTIFY] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.NOTIFY] async_add_entities(KNXNotify(knx_module, entity_config) for entity_config in config) diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index cbbe91aba54..1a6c33239c9 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -23,13 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import ( - CONF_RESPOND_TO_READ, - CONF_STATE_ADDRESS, - DATA_KNX_CONFIG, - DOMAIN, - KNX_ADDRESS, -) +from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY from .knx_entity import KnxYamlEntity from .schema import NumberSchema @@ -40,8 +34,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up number(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.NUMBER] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.NUMBER] async_add_entities(KNXNumber(knx_module, entity_config) for entity_config in config) diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index 2de832ae54a..0a0e68239ef 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS +from .const import KNX_ADDRESS, KNX_MODULE_KEY from .knx_entity import KnxYamlEntity from .schema import SceneSchema @@ -25,8 +25,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up scene(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SCENE] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.SCENE] async_add_entities(KNXScene(knx_module, entity_config) for entity_config in config) diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 6c73bf8d573..272db48f14e 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -26,9 +26,8 @@ from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DATA_KNX_CONFIG, - DOMAIN, KNX_ADDRESS, + KNX_MODULE_KEY, ) from .knx_entity import KnxYamlEntity from .schema import SelectSchema @@ -40,8 +39,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up select(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.SELECT] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.SELECT] async_add_entities(KNXSelect(knx_module, entity_config) for entity_config in config) diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index a28c1a339e6..03b3f3f70c3 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -34,7 +34,7 @@ from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.enum import try_parse_enum from . import KNXModule -from .const import ATTR_SOURCE, DATA_KNX_CONFIG, DOMAIN +from .const import ATTR_SOURCE, KNX_MODULE_KEY from .knx_entity import KnxYamlEntity from .schema import SensorSchema @@ -115,13 +115,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] + knx_module = hass.data[KNX_MODULE_KEY] entities: list[SensorEntity] = [] entities.extend( KNXSystemSensor(knx_module, description) for description in SYSTEM_ENTITY_DESCRIPTIONS ) - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG].get(Platform.SENSOR) + config: list[ConfigType] | None = knx_module.config_yaml.get(Platform.SENSOR) if config: entities.extend( KNXSensor(knx_module, entity_config) for entity_config in config diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 8b82671deaa..113be9709ee 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -22,6 +22,7 @@ from homeassistant.helpers.service import async_register_admin_service from .const import ( DOMAIN, KNX_ADDRESS, + KNX_MODULE_KEY, SERVICE_KNX_ATTR_PAYLOAD, SERVICE_KNX_ATTR_REMOVE, SERVICE_KNX_ATTR_RESPONSE, @@ -85,7 +86,7 @@ def register_knx_services(hass: HomeAssistant) -> None: def get_knx_module(hass: HomeAssistant) -> KNXModule: """Return KNXModule instance.""" try: - return hass.data[DOMAIN] # type: ignore[no-any-return] + return hass.data[KNX_MODULE_KEY] except KeyError as err: raise HomeAssistantError("KNX entry not loaded") from err diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index ebe930957d6..9146a98dda4 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -31,9 +31,9 @@ from .const import ( CONF_INVERT, CONF_RESPOND_TO_READ, CONF_SYNC_STATE, - DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS, + KNX_MODULE_KEY, ) from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .schema import SwitchSchema @@ -53,7 +53,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switch(es) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] + knx_module = hass.data[KNX_MODULE_KEY] platform = async_get_current_platform() knx_module.config_store.add_platform( platform=Platform.SWITCH, @@ -65,7 +65,7 @@ async def async_setup_entry( ) entities: list[KnxYamlEntity | KnxUiEntity] = [] - if yaml_platform_config := hass.data[DATA_KNX_CONFIG].get(Platform.SWITCH): + if yaml_platform_config := knx_module.config_yaml.get(Platform.SWITCH): entities.extend( KnxYamlSwitch(knx_module, entity_config) for entity_config in yaml_platform_config diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index 381cb95ad32..1fdfc21bf2b 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -23,13 +23,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import ( - CONF_RESPOND_TO_READ, - CONF_STATE_ADDRESS, - DATA_KNX_CONFIG, - DOMAIN, - KNX_ADDRESS, -) +from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY from .knx_entity import KnxYamlEntity @@ -39,8 +33,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensor(s) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.TEXT] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.TEXT] async_add_entities(KNXText(knx_module, entity_config) for entity_config in config) diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index b4e562a8869..8e57b4a4fb5 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -27,9 +27,8 @@ from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, CONF_SYNC_STATE, - DATA_KNX_CONFIG, - DOMAIN, KNX_ADDRESS, + KNX_MODULE_KEY, ) from .knx_entity import KnxYamlEntity @@ -40,8 +39,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entities for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.TIME] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.TIME] async_add_entities( KNXTimeEntity(knx_module, entity_config) for entity_config in config diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 99f4be962fe..3cf8f163330 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import KNXModule -from .const import DATA_KNX_CONFIG, DOMAIN +from .const import KNX_MODULE_KEY from .knx_entity import KnxYamlEntity from .schema import WeatherSchema @@ -31,8 +31,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switch(es) for KNX platform.""" - knx_module: KNXModule = hass.data[DOMAIN] - config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.WEATHER] + knx_module = hass.data[KNX_MODULE_KEY] + config: list[ConfigType] = knx_module.config_yaml[Platform.WEATHER] async_add_entities( KNXWeather(knx_module, entity_config) for entity_config in config diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 5c21a941484..6cb2218b221 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -21,7 +21,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import UNDEFINED from homeassistant.util.ulid import ulid_now -from .const import DOMAIN +from .const import DOMAIN, KNX_MODULE_KEY from .storage.config_store import ConfigStoreException from .storage.const import CONF_DATA from .storage.entity_store_schema import ( @@ -38,7 +38,6 @@ from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict if TYPE_CHECKING: from . import KNXModule - URL_BASE: Final = "/knx_static" @@ -126,7 +125,7 @@ def provide_knx( ) -> None: """Add KNX Module to call function.""" try: - knx: KNXModule = hass.data[DOMAIN] + knx = hass.data[KNX_MODULE_KEY] except KeyError: _send_not_loaded_error(connection, msg["id"]) return @@ -142,7 +141,7 @@ def provide_knx( ) -> None: """Add KNX Module to call function.""" try: - knx: KNXModule = hass.data[DOMAIN] + knx = hass.data[KNX_MODULE_KEY] except KeyError: _send_not_loaded_error(connection, msg["id"]) return diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index a05752eced1..38ccb36200b 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -6,7 +6,11 @@ import logging from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.knx.const import CONF_PAYLOAD_LENGTH, DOMAIN, KNX_ADDRESS +from homeassistant.components.knx.const import ( + CONF_PAYLOAD_LENGTH, + KNX_ADDRESS, + KNX_MODULE_KEY, +) from homeassistant.components.knx.schema import ButtonSchema from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_TYPE from homeassistant.core import HomeAssistant @@ -134,4 +138,4 @@ async def test_button_invalid( assert record.levelname == "ERROR" assert "Setup failed for 'knx': Invalid config." in record.message assert hass.states.get("button.test") is None - assert hass.data.get(DOMAIN) is None + assert hass.data.get(KNX_MODULE_KEY) is None diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py index 69e3208879c..883e8ccbb2d 100644 --- a/tests/components/knx/test_telegrams.py +++ b/tests/components/knx/test_telegrams.py @@ -6,8 +6,10 @@ from typing import Any import pytest -from homeassistant.components.knx import DOMAIN -from homeassistant.components.knx.const import CONF_KNX_TELEGRAM_LOG_SIZE +from homeassistant.components.knx.const import ( + CONF_KNX_TELEGRAM_LOG_SIZE, + KNX_MODULE_KEY, +) from homeassistant.components.knx.telegrams import TelegramDict from homeassistant.core import HomeAssistant @@ -76,7 +78,7 @@ async def test_store_telegam_history( ) await knx.assert_write("2/2/2", (1, 2, 3, 4)) - assert len(hass.data[DOMAIN].telegrams.recent_telegrams) == 2 + assert len(hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams) == 2 with pytest.raises(KeyError): hass_storage["knx/telegrams_history.json"] @@ -93,7 +95,7 @@ async def test_load_telegam_history( """Test telegram history restoration.""" hass_storage["knx/telegrams_history.json"] = {"version": 1, "data": MOCK_TELEGRAMS} await knx.setup_integration({}) - loaded_telegrams = hass.data[DOMAIN].telegrams.recent_telegrams + loaded_telegrams = hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams assert assert_telegram_history(loaded_telegrams) # TelegramDict "payload" is a tuple, this shall be restored when loading from JSON assert isinstance(loaded_telegrams[1]["payload"], tuple) @@ -114,4 +116,4 @@ async def test_remove_telegam_history( await knx.setup_integration({}, add_entry_to_hass=False) # Store.async_remove() is mocked by hass_storage - check that data was removed. assert "knx/telegrams_history.json" not in hass_storage - assert not hass.data[DOMAIN].telegrams.recent_telegrams + assert not hass.data[KNX_MODULE_KEY].telegrams.recent_telegrams diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index e747b0daade..b3e4b7aaa38 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -5,8 +5,9 @@ from unittest.mock import patch import pytest -from homeassistant.components.knx import DOMAIN, KNX_ADDRESS, SwitchSchema +from homeassistant.components.knx.const import KNX_ADDRESS, KNX_MODULE_KEY from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY +from homeassistant.components.knx.schema import SwitchSchema from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant @@ -66,7 +67,7 @@ async def test_knx_project_file_process( await knx.setup_integration({}) client = await hass_ws_client(hass) - assert not hass.data[DOMAIN].project.loaded + assert not hass.data[KNX_MODULE_KEY].project.loaded await client.send_json( { @@ -89,7 +90,7 @@ async def test_knx_project_file_process( parse_mock.assert_called_once_with() assert res["success"], res - assert hass.data[DOMAIN].project.loaded + assert hass.data[KNX_MODULE_KEY].project.loaded assert hass_storage[KNX_PROJECT_STORAGE_KEY]["data"] == _parse_result @@ -101,7 +102,7 @@ async def test_knx_project_file_process_error( """Test knx/project_file_process exception handling.""" await knx.setup_integration({}) client = await hass_ws_client(hass) - assert not hass.data[DOMAIN].project.loaded + assert not hass.data[KNX_MODULE_KEY].project.loaded await client.send_json( { @@ -122,7 +123,7 @@ async def test_knx_project_file_process_error( parse_mock.assert_called_once_with() assert res["error"], res - assert not hass.data[DOMAIN].project.loaded + assert not hass.data[KNX_MODULE_KEY].project.loaded async def test_knx_project_file_remove( @@ -136,13 +137,13 @@ async def test_knx_project_file_remove( await knx.setup_integration({}) assert hass_storage[KNX_PROJECT_STORAGE_KEY] client = await hass_ws_client(hass) - assert hass.data[DOMAIN].project.loaded + assert hass.data[KNX_MODULE_KEY].project.loaded await client.send_json({"id": 6, "type": "knx/project_file_remove"}) res = await client.receive_json() assert res["success"], res - assert not hass.data[DOMAIN].project.loaded + assert not hass.data[KNX_MODULE_KEY].project.loaded assert not hass_storage.get(KNX_PROJECT_STORAGE_KEY) @@ -155,7 +156,7 @@ async def test_knx_get_project( """Test retrieval of kxnproject from store.""" await knx.setup_integration({}) client = await hass_ws_client(hass) - assert hass.data[DOMAIN].project.loaded + assert hass.data[KNX_MODULE_KEY].project.loaded await client.send_json({"id": 3, "type": "knx/get_knx_project"}) res = await client.receive_json() From 06e876aee0e0c5917f8eae603b31f8f8deec1ac6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Sep 2024 11:07:19 +0200 Subject: [PATCH 0434/1309] Add alias to DOMAIN import in group (#125569) --- homeassistant/components/group/button.py | 6 ++-- homeassistant/components/group/cover.py | 36 +++++++++++++------ homeassistant/components/group/event.py | 4 +-- homeassistant/components/group/fan.py | 8 ++--- homeassistant/components/group/lock.py | 10 +++--- .../components/group/media_player.py | 30 ++++++++-------- homeassistant/components/group/notify.py | 9 +++-- homeassistant/components/group/sensor.py | 18 ++++++---- homeassistant/components/group/switch.py | 8 ++--- 9 files changed, 77 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/group/button.py b/homeassistant/components/group/button.py index d8481686615..a18e074b775 100644 --- a/homeassistant/components/group/button.py +++ b/homeassistant/components/group/button.py @@ -7,7 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant.components.button import ( - DOMAIN, + DOMAIN as BUTTON_DOMAIN, PLATFORM_SCHEMA as BUTTON_PLATFORM_SCHEMA, SERVICE_PRESS, ButtonEntity, @@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = BUTTON_PLATFORM_SCHEMA.extend( { - vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Required(CONF_ENTITIES): cv.entities_domain(BUTTON_DOMAIN), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -113,7 +113,7 @@ class ButtonGroup(GroupEntity, ButtonEntity): async def async_press(self) -> None: """Forward the press to all buttons in the group.""" await self.hass.services.async_call( - DOMAIN, + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: self._entity_ids}, blocking=True, diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 5d7f99012fd..b0b36e11b6b 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, - DOMAIN, + DOMAIN as COVER_DOMAIN, PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA, CoverEntity, CoverEntityFeature, @@ -57,7 +57,7 @@ PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( { - vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Required(CONF_ENTITIES): cv.entities_domain(COVER_DOMAIN), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -181,21 +181,25 @@ class CoverGroup(GroupEntity, CoverEntity): """Move the covers up.""" data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} await self.hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, data, blocking=True, context=self._context + COVER_DOMAIN, SERVICE_OPEN_COVER, data, blocking=True, context=self._context ) async def async_close_cover(self, **kwargs: Any) -> None: """Move the covers down.""" data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]} await self.hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, data, blocking=True, context=self._context + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + data, + blocking=True, + context=self._context, ) async def async_stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" data = {ATTR_ENTITY_ID: self._covers[KEY_STOP]} await self.hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER, data, blocking=True, context=self._context + COVER_DOMAIN, SERVICE_STOP_COVER, data, blocking=True, context=self._context ) async def async_set_cover_position(self, **kwargs: Any) -> None: @@ -205,7 +209,7 @@ class CoverGroup(GroupEntity, CoverEntity): ATTR_POSITION: kwargs[ATTR_POSITION], } await self.hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, data, blocking=True, @@ -216,21 +220,33 @@ class CoverGroup(GroupEntity, CoverEntity): """Tilt covers open.""" data = {ATTR_ENTITY_ID: self._tilts[KEY_OPEN_CLOSE]} await self.hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER_TILT, data, blocking=True, context=self._context + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + data, + blocking=True, + context=self._context, ) async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Tilt covers closed.""" data = {ATTR_ENTITY_ID: self._tilts[KEY_OPEN_CLOSE]} await self.hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, data, blocking=True, context=self._context + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + data, + blocking=True, + context=self._context, ) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" data = {ATTR_ENTITY_ID: self._tilts[KEY_STOP]} await self.hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER_TILT, data, blocking=True, context=self._context + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + data, + blocking=True, + context=self._context, ) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: @@ -240,7 +256,7 @@ class CoverGroup(GroupEntity, CoverEntity): ATTR_TILT_POSITION: kwargs[ATTR_TILT_POSITION], } await self.hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, data, blocking=True, diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py index 67220b878a1..e7f7938edf3 100644 --- a/homeassistant/components/group/event.py +++ b/homeassistant/components/group/event.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.event import ( ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, - DOMAIN, + DOMAIN as EVENT_DOMAIN, PLATFORM_SCHEMA as EVENT_PLATFORM_SCHEMA, EventEntity, ) @@ -40,7 +40,7 @@ PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = EVENT_PLATFORM_SCHEMA.extend( { - vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Required(CONF_ENTITIES): cv.entities_domain(EVENT_DOMAIN), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 93004e8a1b5..03341b0f46b 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -14,7 +14,7 @@ from homeassistant.components.fan import ( ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PERCENTAGE_STEP, - DOMAIN, + DOMAIN as FAN_DOMAIN, PLATFORM_SCHEMA as FAN_PLATFORM_SCHEMA, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, @@ -58,7 +58,7 @@ PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = FAN_PLATFORM_SCHEMA.extend( { - vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Required(CONF_ENTITIES): cv.entities_domain(FAN_DOMAIN), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -218,7 +218,7 @@ class FanGroup(GroupEntity, FanEntity): ) -> None: """Call a service with all entities.""" await self.hass.services.async_call( - DOMAIN, + FAN_DOMAIN, service, {**data, ATTR_ENTITY_ID: self._fans[support_flag]}, blocking=True, @@ -228,7 +228,7 @@ class FanGroup(GroupEntity, FanEntity): async def _async_call_all_entities(self, service: str) -> None: """Call a service with all entities.""" await self.hass.services.async_call( - DOMAIN, + FAN_DOMAIN, service, {ATTR_ENTITY_ID: self._entity_ids}, blocking=True, diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 8bb7b18ce29..73e8c30bfde 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -8,7 +8,7 @@ from typing import Any import voluptuous as vol from homeassistant.components.lock import ( - DOMAIN, + DOMAIN as LOCK_DOMAIN, PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, LockEntityFeature, @@ -45,7 +45,7 @@ PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( { - vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Required(CONF_ENTITIES): cv.entities_domain(LOCK_DOMAIN), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -131,7 +131,7 @@ class LockGroup(GroupEntity, LockEntity): _LOGGER.debug("Forwarded lock command: %s", data) await self.hass.services.async_call( - DOMAIN, + LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True, @@ -142,7 +142,7 @@ class LockGroup(GroupEntity, LockEntity): """Forward the unlock command to all locks in the group.""" data = {ATTR_ENTITY_ID: self._entity_ids} await self.hass.services.async_call( - DOMAIN, + LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True, @@ -153,7 +153,7 @@ class LockGroup(GroupEntity, LockEntity): """Forward the open command to all locks in the group.""" data = {ATTR_ENTITY_ID: self._entity_ids} await self.hass.services.async_call( - DOMAIN, + LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True, diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 7d2ce46b107..ab8ee64b3e1 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -15,7 +15,7 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_SHUFFLE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, - DOMAIN, + DOMAIN as MEDIA_PLAYER_DOMAIN, PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, SERVICE_CLEAR_PLAYLIST, SERVICE_PLAY_MEDIA, @@ -73,7 +73,7 @@ DEFAULT_NAME = "Media Group" PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( { - vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Required(CONF_ENTITIES): cv.entities_domain(MEDIA_PLAYER_DOMAIN), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -274,7 +274,7 @@ class MediaPlayerGroup(MediaPlayerEntity): """Clear players playlist.""" data = {ATTR_ENTITY_ID: self._features[KEY_CLEAR_PLAYLIST]} await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, data, context=self._context, @@ -284,7 +284,7 @@ class MediaPlayerGroup(MediaPlayerEntity): """Send next track command.""" data = {ATTR_ENTITY_ID: self._features[KEY_TRACKS]} await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data, context=self._context, @@ -294,7 +294,7 @@ class MediaPlayerGroup(MediaPlayerEntity): """Send pause command.""" data = {ATTR_ENTITY_ID: self._features[KEY_PAUSE_PLAY_STOP]} await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, data, context=self._context, @@ -304,7 +304,7 @@ class MediaPlayerGroup(MediaPlayerEntity): """Send play command.""" data = {ATTR_ENTITY_ID: self._features[KEY_PAUSE_PLAY_STOP]} await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, data, context=self._context, @@ -314,7 +314,7 @@ class MediaPlayerGroup(MediaPlayerEntity): """Send previous track command.""" data = {ATTR_ENTITY_ID: self._features[KEY_TRACKS]} await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data, context=self._context, @@ -327,7 +327,7 @@ class MediaPlayerGroup(MediaPlayerEntity): ATTR_MEDIA_SEEK_POSITION: position, } await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_SEEK, data, context=self._context, @@ -337,7 +337,7 @@ class MediaPlayerGroup(MediaPlayerEntity): """Send stop command.""" data = {ATTR_ENTITY_ID: self._features[KEY_PAUSE_PLAY_STOP]} await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, data, context=self._context, @@ -350,7 +350,7 @@ class MediaPlayerGroup(MediaPlayerEntity): ATTR_MEDIA_VOLUME_MUTED: mute, } await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, data, context=self._context, @@ -368,7 +368,7 @@ class MediaPlayerGroup(MediaPlayerEntity): if kwargs: data.update(kwargs) await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, data, context=self._context, @@ -381,7 +381,7 @@ class MediaPlayerGroup(MediaPlayerEntity): ATTR_MEDIA_SHUFFLE: shuffle, } await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_SHUFFLE_SET, data, context=self._context, @@ -391,7 +391,7 @@ class MediaPlayerGroup(MediaPlayerEntity): """Forward the turn_on command to all media in the media group.""" data = {ATTR_ENTITY_ID: self._features[KEY_ON_OFF]} await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_TURN_ON, data, context=self._context, @@ -404,7 +404,7 @@ class MediaPlayerGroup(MediaPlayerEntity): ATTR_MEDIA_VOLUME_LEVEL: volume, } await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, data, context=self._context, @@ -414,7 +414,7 @@ class MediaPlayerGroup(MediaPlayerEntity): """Forward the turn_off command to all media in the media group.""" data = {ATTR_ENTITY_ID: self._features[KEY_ON_OFF]} await self.hass.services.async_call( - DOMAIN, + MEDIA_PLAYER_DOMAIN, SERVICE_TURN_OFF, data, context=self._context, diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index ecbfec0bdb8..fdef327cb73 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( ATTR_DATA, ATTR_MESSAGE, ATTR_TITLE, - DOMAIN, + DOMAIN as NOTIFY_DOMAIN, PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, SERVICE_SEND_MESSAGE, BaseNotificationService, @@ -115,7 +115,10 @@ class GroupNotifyPlatform(BaseNotificationService): tasks.append( asyncio.create_task( self.hass.services.async_call( - DOMAIN, entity[CONF_ACTION], sending_payload, blocking=True + NOTIFY_DOMAIN, + entity[CONF_ACTION], + sending_payload, + blocking=True, ) ) ) @@ -172,7 +175,7 @@ class NotifyGroup(GroupEntity, NotifyEntity): async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message to all members of the group.""" await self.hass.services.async_call( - DOMAIN, + NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, { ATTR_MESSAGE: message, diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index a99ed9dad63..32744bebc33 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, - DOMAIN, + DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, UNIT_CONVERTERS, @@ -96,7 +96,7 @@ PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ENTITIES): cv.entities_domain( - [DOMAIN, NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN] + [SENSOR_DOMAIN, NUMBER_DOMAIN, INPUT_NUMBER_DOMAIN] ), vol.Required(CONF_TYPE): vol.All(cv.string, vol.In(SENSOR_TYPES.values())), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -503,7 +503,7 @@ class SensorGroup(GroupEntity, SensorEntity): if all(x == state_classes[0] for x in state_classes): async_delete_issue( - self.hass, DOMAIN, f"{self.entity_id}_state_classes_not_matching" + self.hass, SENSOR_DOMAIN, f"{self.entity_id}_state_classes_not_matching" ) return state_classes[0] async_create_issue( @@ -546,7 +546,9 @@ class SensorGroup(GroupEntity, SensorEntity): if all(x == device_classes[0] for x in device_classes): async_delete_issue( - self.hass, DOMAIN, f"{self.entity_id}_device_classes_not_matching" + self.hass, + SENSOR_DOMAIN, + f"{self.entity_id}_device_classes_not_matching", ) return device_classes[0] async_create_issue( @@ -614,10 +616,14 @@ class SensorGroup(GroupEntity, SensorEntity): ) ): async_delete_issue( - self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_device_class" + self.hass, + SENSOR_DOMAIN, + f"{self.entity_id}_uoms_not_matching_device_class", ) async_delete_issue( - self.hass, DOMAIN, f"{self.entity_id}_uoms_not_matching_no_device_class" + self.hass, + SENSOR_DOMAIN, + f"{self.entity_id}_uoms_not_matching_no_device_class", ) return unit_of_measurements[0] diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 9db264c8041..101c42d354f 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -8,7 +8,7 @@ from typing import Any import voluptuous as vol from homeassistant.components.switch import ( - DOMAIN, + DOMAIN as SWITCH_DOMAIN, PLATFORM_SCHEMA as SWITCH_PLATFORM_SCHEMA, SwitchEntity, ) @@ -39,7 +39,7 @@ PARALLEL_UPDATES = 0 PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( { - vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Required(CONF_ENTITIES): cv.entities_domain(SWITCH_DOMAIN), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_ALL, default=False): cv.boolean, @@ -132,7 +132,7 @@ class SwitchGroup(GroupEntity, SwitchEntity): _LOGGER.debug("Forwarded turn_on command: %s", data) await self.hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, data, blocking=True, @@ -143,7 +143,7 @@ class SwitchGroup(GroupEntity, SwitchEntity): """Forward the turn_off command to all switches in the group.""" data = {ATTR_ENTITY_ID: self._entity_ids} await self.hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, data, blocking=True, From 056e6eae8284dc5f6b1aa705cd79aba7bdcb782f Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 9 Sep 2024 04:51:32 -0700 Subject: [PATCH 0435/1309] Add a syntax for merging lists of triggers (#117698) * Add a syntax for merging lists of triggers * Updating to the new syntax * Update homeassistant/helpers/config_validation.py Co-authored-by: Erik Montnemery * fix suggestion * update test and add comments * not actually json * move test to new file * update tests --------- Co-authored-by: Erik Montnemery --- homeassistant/const.py | 1 + homeassistant/helpers/config_validation.py | 18 ++++- tests/helpers/test_config_validation.py | 77 ++++++++++++++++++++++ tests/helpers/test_trigger.py | 64 ++++++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ee90ebfc28b..45d6a97885b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -282,6 +282,7 @@ CONF_THEN: Final = "then" CONF_TIMEOUT: Final = "timeout" CONF_TIME_ZONE: Final = "time_zone" CONF_TOKEN: Final = "token" +CONF_TRIGGERS: Final = "triggers" CONF_TRIGGER_TIME: Final = "trigger_time" CONF_TTL: Final = "ttl" CONF_TYPE: Final = "type" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index d88c388f9c7..059be3026e5 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -81,6 +81,7 @@ from homeassistant.const import ( CONF_TARGET, CONF_THEN, CONF_TIMEOUT, + CONF_TRIGGERS, CONF_UNTIL, CONF_VALUE_TEMPLATE, CONF_VARIABLES, @@ -1781,6 +1782,19 @@ TRIGGER_BASE_SCHEMA = vol.Schema( _base_trigger_validator_schema = TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) +def _base_trigger_list_flatten(triggers: list[Any]) -> list[Any]: + """Flatten trigger arrays containing 'triggers:' sublists into a single list of triggers.""" + flatlist = [] + for t in triggers: + if CONF_TRIGGERS in t and len(t.keys()) == 1: + triggerlist = ensure_list(t[CONF_TRIGGERS]) + flatlist.extend(triggerlist) + else: + flatlist.append(t) + + return flatlist + + # This is first round of validation, we don't want to process the config here already, # just ensure basics as platform and ID are there. def _base_trigger_validator(value: Any) -> Any: @@ -1788,7 +1802,9 @@ def _base_trigger_validator(value: Any) -> Any: return value -TRIGGER_SCHEMA = vol.All(ensure_list, [_base_trigger_validator]) +TRIGGER_SCHEMA = vol.All( + ensure_list, _base_trigger_list_flatten, [_base_trigger_validator] +) _SCRIPT_DELAY_SCHEMA = vol.Schema( { diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 1608a856de8..0eae0c88581 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -25,6 +25,7 @@ from homeassistant.helpers import ( selector, template, ) +from homeassistant.helpers.config_validation import TRIGGER_SCHEMA def test_boolean() -> None: @@ -1817,6 +1818,82 @@ async def test_async_validate(hass: HomeAssistant, tmpdir: py.path.local) -> Non validator_calls = {} +async def test_nested_trigger_list() -> None: + """Test triggers within nested lists are flattened.""" + + trigger_config = [ + { + "triggers": { + "platform": "event", + "event_type": "trigger_1", + }, + }, + { + "platform": "event", + "event_type": "trigger_2", + }, + {"triggers": []}, + {"triggers": None}, + { + "triggers": [ + { + "platform": "event", + "event_type": "trigger_3", + }, + { + "platform": "event", + "event_type": "trigger_4", + }, + ], + }, + ] + + validated_triggers = TRIGGER_SCHEMA(trigger_config) + + assert validated_triggers == [ + { + "platform": "event", + "event_type": "trigger_1", + }, + { + "platform": "event", + "event_type": "trigger_2", + }, + { + "platform": "event", + "event_type": "trigger_3", + }, + { + "platform": "event", + "event_type": "trigger_4", + }, + ] + + +async def test_nested_trigger_list_extra() -> None: + """Test triggers key with extra keys is not modified.""" + + trigger_config = [ + { + "platform": "other", + "triggers": [ + { + "platform": "event", + "event_type": "trigger_1", + }, + { + "platform": "event", + "event_type": "trigger_2", + }, + ], + }, + ] + + validated_triggers = TRIGGER_SCHEMA(trigger_config) + + assert validated_triggers == trigger_config + + async def test_is_entity_service_schema( hass: HomeAssistant, ) -> None: diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 0bd5da0707c..4fde2d0ee0a 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -159,6 +159,70 @@ async def test_trigger_enabled_templates( assert len(service_calls) == 2 +async def test_nested_trigger_list( + hass: HomeAssistant, service_calls: list[ServiceCall] +) -> None: + """Test triggers within nested list.""" + + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": [ + { + "triggers": { + "platform": "event", + "event_type": "trigger_1", + }, + }, + { + "platform": "event", + "event_type": "trigger_2", + }, + {"triggers": []}, + {"triggers": None}, + { + "triggers": [ + { + "platform": "event", + "event_type": "trigger_3", + }, + { + "platform": "event", + "event_type": "trigger_4", + }, + ], + }, + ], + "action": { + "service": "test.automation", + }, + } + }, + ) + + hass.bus.async_fire("trigger_1") + await hass.async_block_till_done() + assert len(service_calls) == 1 + + hass.bus.async_fire("trigger_2") + await hass.async_block_till_done() + assert len(service_calls) == 2 + + hass.bus.async_fire("trigger_none") + await hass.async_block_till_done() + assert len(service_calls) == 2 + + hass.bus.async_fire("trigger_3") + await hass.async_block_till_done() + assert len(service_calls) == 3 + + hass.bus.async_fire("trigger_4") + await hass.async_block_till_done() + assert len(service_calls) == 4 + + async def test_trigger_enabled_template_limited( hass: HomeAssistant, service_calls: list[ServiceCall], From 1dc496a2dd63fe6c96ce22d3d0952ceddec909dc Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 9 Sep 2024 07:25:25 -0500 Subject: [PATCH 0436/1309] Add announce support to ESPHome Assist Satellite platform (#125157) Rebuild --- .../components/esphome/assist_satellite.py | 22 +++ .../esphome/test_assist_satellite.py | 147 ++++++++++++++++++ 2 files changed, 169 insertions(+) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index f84940eadc4..9d48e96b52e 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -74,6 +74,8 @@ _TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventTy ) ) +_ANNOUNCEMENT_TIMEOUT_SEC = 5 * 60 # 5 minutes + async def async_setup_entry( hass: HomeAssistant, @@ -183,6 +185,12 @@ class EsphomeAssistSatellite( ) ) + if feature_flags & VoiceAssistantFeature.ANNOUNCE: + # Device supports announcements + self._attr_supported_features |= ( + assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE + ) + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() @@ -251,6 +259,20 @@ class EsphomeAssistSatellite( self.cli.send_voice_assistant_event(event_type, data_to_send) + async def async_announce(self, message: str, media_id: str) -> None: + """Announce media on the satellite. + + Should block until the announcement is done playing. + """ + _LOGGER.debug( + "Waiting for announcement to finished (message=%s, media_id=%s)", + message, + media_id, + ) + await self.cli.send_voice_assistant_announcement_await_response( + media_id, _ANNOUNCEMENT_TIMEOUT_SEC, message + ) + async def handle_pipeline_start( self, conversation_id: str, diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 1c7f7320a85..e245cfcf3bf 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -27,6 +27,7 @@ from homeassistant.components import assist_satellite, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite.entity import ( AssistSatelliteEntity, + AssistSatelliteEntityFeature, AssistSatelliteState, ) from homeassistant.components.esphome import DOMAIN @@ -34,6 +35,7 @@ from homeassistant.components.esphome.assist_satellite import ( EsphomeAssistSatellite, VoiceAssistantUDPServer, ) +from homeassistant.components.media_source import PlayMedia from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, intent as intent_helper @@ -891,3 +893,148 @@ async def test_tts_format_from_media_player( tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 1, tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, } + + +async def test_announce_supported_features( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that the announce supported feature is set by flags.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + assert not (satellite.supported_features & AssistSatelliteEntityFeature.ANNOUNCE) + + +async def test_announce_message( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test announcement with message.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, timeout: float, text: str + ): + assert media_id == "https://www.home-assistant.io/resolved.mp3" + assert text == "test-text" + + done.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", + return_value="media-source://bla", + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "announce", + {"entity_id": satellite.entity_id, "message": "test-text"}, + blocking=True, + ) + await done.wait() + + +async def test_announce_media_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test announcement with media id.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.SPEAKER + | VoiceAssistantFeature.API_AUDIO + | VoiceAssistantFeature.ANNOUNCE + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + done = asyncio.Event() + + async def send_voice_assistant_announcement_await_response( + media_id: str, timeout: float, text: str + ): + assert media_id == "https://www.home-assistant.io/resolved.mp3" + + done.set() + + with ( + patch.object( + mock_client, + "send_voice_assistant_announcement_await_response", + new=send_voice_assistant_announcement_await_response, + ), + ): + async with asyncio.timeout(1): + await hass.services.async_call( + assist_satellite.DOMAIN, + "announce", + { + "entity_id": satellite.entity_id, + "media_id": "https://www.home-assistant.io/resolved.mp3", + }, + blocking=True, + ) + await done.wait() From 3889482f0e1a8233a11167e203780dea4a402acd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 9 Sep 2024 14:36:15 +0200 Subject: [PATCH 0437/1309] Do not directy import platform DOMAIN const in MQTT platform tests (#125589) --- tests/components/mqtt/test_humidifier.py | 17 +++++++---- tests/components/mqtt/test_vacuum.py | 37 +++++++++++++++--------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 62de371af4b..f5bdf52c8aa 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -12,7 +12,6 @@ from homeassistant.components.humidifier import ( ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE, - DOMAIN, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, HumidifierAction, @@ -87,7 +86,9 @@ async def async_turn_on(hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL) """Turn all or specified humidifier on.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data, blocking=True) + await hass.services.async_call( + humidifier.DOMAIN, SERVICE_TURN_ON, data, blocking=True + ) async def async_turn_off( @@ -96,7 +97,9 @@ async def async_turn_off( """Turn all or specified humidier off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data, blocking=True) + await hass.services.async_call( + humidifier.DOMAIN, SERVICE_TURN_OFF, data, blocking=True + ) async def async_set_mode( @@ -109,7 +112,9 @@ async def async_set_mode( if value is not None } - await hass.services.async_call(DOMAIN, SERVICE_SET_MODE, data, blocking=True) + await hass.services.async_call( + humidifier.DOMAIN, SERVICE_SET_MODE, data, blocking=True + ) async def async_set_humidity( @@ -122,7 +127,9 @@ async def async_set_humidity( if value is not None } - await hass.services.async_call(DOMAIN, SERVICE_SET_HUMIDITY, data, blocking=True) + await hass.services.async_call( + humidifier.DOMAIN, SERVICE_SET_HUMIDITY, data, blocking=True + ) @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index fbffe062261..9b80d381457 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -21,7 +21,6 @@ from homeassistant.components.vacuum import ( ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, - DOMAIN, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -122,31 +121,34 @@ async def test_all_commands( mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( - DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True ) mqtt_mock.async_publish.assert_called_once_with(COMMAND_TOPIC, "start", 0, False) mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_STOP, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, SERVICE_STOP, {"entity_id": ENTITY_MATCH_ALL}, blocking=True ) mqtt_mock.async_publish.assert_called_once_with(COMMAND_TOPIC, "stop", 0, False) mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_PAUSE, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, SERVICE_PAUSE, {"entity_id": ENTITY_MATCH_ALL}, blocking=True ) mqtt_mock.async_publish.assert_called_once_with(COMMAND_TOPIC, "pause", 0, False) mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_LOCATE, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, SERVICE_LOCATE, {"entity_id": ENTITY_MATCH_ALL}, blocking=True ) mqtt_mock.async_publish.assert_called_once_with(COMMAND_TOPIC, "locate", 0, False) mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_CLEAN_SPOT, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, + SERVICE_CLEAN_SPOT, + {"entity_id": ENTITY_MATCH_ALL}, + blocking=True, ) mqtt_mock.async_publish.assert_called_once_with( COMMAND_TOPIC, "clean_spot", 0, False @@ -154,7 +156,10 @@ async def test_all_commands( mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_RETURN_TO_BASE, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, + SERVICE_RETURN_TO_BASE, + {"entity_id": ENTITY_MATCH_ALL}, + blocking=True, ) mqtt_mock.async_publish.assert_called_once_with( COMMAND_TOPIC, "return_to_base", 0, False @@ -205,37 +210,43 @@ async def test_commands_without_supported_features( mqtt_mock = await mqtt_mock_entry() await hass.services.async_call( - DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True ) mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_PAUSE, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, SERVICE_PAUSE, {"entity_id": ENTITY_MATCH_ALL}, blocking=True ) mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_STOP, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, SERVICE_STOP, {"entity_id": ENTITY_MATCH_ALL}, blocking=True ) mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_RETURN_TO_BASE, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, + SERVICE_RETURN_TO_BASE, + {"entity_id": ENTITY_MATCH_ALL}, + blocking=True, ) mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_LOCATE, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, SERVICE_LOCATE, {"entity_id": ENTITY_MATCH_ALL}, blocking=True ) mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_CLEAN_SPOT, {"entity_id": ENTITY_MATCH_ALL}, blocking=True + vacuum.DOMAIN, + SERVICE_CLEAN_SPOT, + {"entity_id": ENTITY_MATCH_ALL}, + blocking=True, ) mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() From d7caaceb64a090fcff5b26c37bdbec6f34a07488 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 9 Sep 2024 14:47:04 +0200 Subject: [PATCH 0438/1309] Document plant integration development state (#125590) --- homeassistant/components/plant/__init__.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 2a5253d3faa..c6e527290df 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -1,4 +1,8 @@ -"""Support for monitoring plants.""" +"""Support for monitoring plants. + +DEVELOPMENT OF THE PLANT INTEGRATION IS FROZEN +PENDING A DESIGN EVALUATION. +""" from collections import deque from contextlib import suppress @@ -128,6 +132,9 @@ class Plant(Entity): It also checks the measurements against configurable min and max values. + + DEVELOPMENT OF THE PLANT INTEGRATION IS FROZEN + PENDING A DESIGN EVALUATION. """ _attr_should_poll = False @@ -363,6 +370,9 @@ class DailyHistory: """Stores one measurement per day for a maximum number of days. At the moment only the maximum value per day is kept. + + DEVELOPMENT OF THE PLANT INTEGRATION IS FROZEN + PENDING A DESIGN EVALUATION. """ def __init__(self, max_length): From 8fff0075ba3bb75a5a0444b0a6b4dcc1315bd55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 9 Sep 2024 14:50:01 +0200 Subject: [PATCH 0439/1309] Add Matter BatVoltage attribute from PowerSource cluster (#125503) * Add BatVoltage Attribute from PowerSource Cluster * Update sensor.py Remove comment * Update homeassistant/components/matter/sensor.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/sensor.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index dd8467e24c9..da627734be6 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -163,6 +163,19 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PowerSourceBatVoltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + measurement_to_ha=lambda x: x / 1000, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PowerSource.Attributes.BatVoltage,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( From dee4b33c64b6f3bbed494996c8cb276e3fb6b6f7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 9 Sep 2024 15:11:18 +0200 Subject: [PATCH 0440/1309] Sort and remove duplicates from template/const.py (#125591) --- homeassistant/components/template/const.py | 24 ++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 89df87b4031..c320fc545b1 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -3,9 +3,19 @@ from homeassistant.const import Platform CONF_ACTION = "action" -CONF_AVAILABILITY_TEMPLATE = "availability_template" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" +CONF_ATTRIBUTES = "attributes" +CONF_AVAILABILITY = "availability" +CONF_AVAILABILITY_TEMPLATE = "availability_template" +CONF_MAX = "max" +CONF_MIN = "min" +CONF_OBJECT_ID = "object_id" +CONF_PICTURE = "picture" +CONF_PRESS = "press" +CONF_STEP = "step" CONF_TRIGGER = "trigger" +CONF_TURN_OFF = "turn_off" +CONF_TURN_ON = "turn_on" DOMAIN = "template" @@ -27,15 +37,3 @@ PLATFORMS = [ Platform.VACUUM, Platform.WEATHER, ] - -CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" -CONF_ATTRIBUTES = "attributes" -CONF_AVAILABILITY = "availability" -CONF_MAX = "max" -CONF_MIN = "min" -CONF_OBJECT_ID = "object_id" -CONF_PICTURE = "picture" -CONF_PRESS = "press" -CONF_STEP = "step" -CONF_TURN_OFF = "turn_off" -CONF_TURN_ON = "turn_on" From af6434a5334165bef7965e9fcfc17efc86099be5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:14:05 +0200 Subject: [PATCH 0441/1309] Add alias to DOMAIN import in tests [n-z] (#125581) --- tests/components/notify_events/test_notify.py | 12 +- tests/components/picnic/test_todo.py | 6 +- .../components/sleepiq/test_binary_sensor.py | 7 +- tests/components/sleepiq/test_button.py | 10 +- tests/components/sleepiq/test_light.py | 12 +- tests/components/sleepiq/test_number.py | 14 +- tests/components/sleepiq/test_select.py | 19 +-- tests/components/sleepiq/test_sensor.py | 6 +- tests/components/sleepiq/test_switch.py | 12 +- tests/components/template/test_cover.py | 134 +++++++++--------- tests/components/template/test_fan.py | 50 +++---- .../components/tomato/test_device_tracker.py | 26 ++-- tests/components/venstar/util.py | 6 +- .../components/xiaomi/test_device_tracker.py | 16 +-- tests/components/xiaomi_miio/test_button.py | 6 +- tests/components/xiaomi_miio/test_select.py | 4 +- tests/components/xiaomi_miio/test_vacuum.py | 26 ++-- tests/components/zha/test_button.py | 8 +- tests/components/zwave_js/test_cover.py | 56 ++++---- tests/components/zwave_js/test_switch.py | 19 ++- 20 files changed, 240 insertions(+), 209 deletions(-) diff --git a/tests/components/notify_events/test_notify.py b/tests/components/notify_events/test_notify.py index dbfc354404b..df6df078de1 100644 --- a/tests/components/notify_events/test_notify.py +++ b/tests/components/notify_events/test_notify.py @@ -1,6 +1,10 @@ """The tests for notify_events.""" -from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, DOMAIN +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + DOMAIN as NOTIFY_DOMAIN, +) from homeassistant.components.notify_events.notify import ( ATTR_LEVEL, ATTR_PRIORITY, @@ -13,10 +17,10 @@ from tests.common import async_mock_service async def test_send_msg(hass: HomeAssistant) -> None: """Test notify.events service.""" - notify_calls = async_mock_service(hass, DOMAIN, "events") + notify_calls = async_mock_service(hass, NOTIFY_DOMAIN, "events") await hass.services.async_call( - DOMAIN, + NOTIFY_DOMAIN, "events", { ATTR_MESSAGE: "message content", @@ -32,7 +36,7 @@ async def test_send_msg(hass: HomeAssistant) -> None: assert len(notify_calls) == 1 call = notify_calls[-1] - assert call.domain == DOMAIN + assert call.domain == NOTIFY_DOMAIN assert call.service == "events" assert call.data.get(ATTR_MESSAGE) == "message content" assert call.data.get(ATTR_DATA).get(ATTR_TOKEN) == "XYZ" diff --git a/tests/components/picnic/test_todo.py b/tests/components/picnic/test_todo.py index 2db5bc90159..3a6e09f7ac0 100644 --- a/tests/components/picnic/test_todo.py +++ b/tests/components/picnic/test_todo.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, Mock import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.todo import ATTR_ITEM, DOMAIN, TodoServices +from homeassistant.components.todo import ATTR_ITEM, DOMAIN as TODO_DOMAIN, TodoServices from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -91,7 +91,7 @@ async def test_create_todo_list_item( mock_picnic_api.add_product = Mock() await hass.services.async_call( - DOMAIN, + TODO_DOMAIN, TodoServices.ADD_ITEM, {ATTR_ITEM: "Melk"}, target={ATTR_ENTITY_ID: ENTITY_ID}, @@ -119,7 +119,7 @@ async def test_create_todo_list_item_not_found( with pytest.raises(ServiceValidationError): await hass.services.async_call( - DOMAIN, + TODO_DOMAIN, TodoServices.ADD_ITEM, {ATTR_ITEM: "Melk"}, target={ATTR_ENTITY_ID: ENTITY_ID}, diff --git a/tests/components/sleepiq/test_binary_sensor.py b/tests/components/sleepiq/test_binary_sensor.py index 65654de74ac..689834aba35 100644 --- a/tests/components/sleepiq/test_binary_sensor.py +++ b/tests/components/sleepiq/test_binary_sensor.py @@ -1,6 +1,9 @@ """The tests for SleepIQ binary sensor platform.""" -from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceClass +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -28,7 +31,7 @@ async def test_binary_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq ) -> None: """Test the SleepIQ binary sensors.""" - await setup_platform(hass, DOMAIN) + await setup_platform(hass, BINARY_SENSOR_DOMAIN) state = hass.states.get( f"binary_sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_is_in_bed" diff --git a/tests/components/sleepiq/test_button.py b/tests/components/sleepiq/test_button.py index 33ad4d72b46..e1c4203c937 100644 --- a/tests/components/sleepiq/test_button.py +++ b/tests/components/sleepiq/test_button.py @@ -1,6 +1,6 @@ """The tests for SleepIQ binary sensor platform.""" -from homeassistant.components.button import DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -12,7 +12,7 @@ async def test_button_calibrate( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq ) -> None: """Test the SleepIQ calibrate button.""" - await setup_platform(hass, DOMAIN) + await setup_platform(hass, BUTTON_DOMAIN) state = hass.states.get(f"button.sleepnumber_{BED_NAME_LOWER}_calibrate") assert ( @@ -24,7 +24,7 @@ async def test_button_calibrate( assert entity.unique_id == f"{BED_ID}-calibrate" await hass.services.async_call( - DOMAIN, + BUTTON_DOMAIN, "press", {ATTR_ENTITY_ID: f"button.sleepnumber_{BED_NAME_LOWER}_calibrate"}, blocking=True, @@ -38,7 +38,7 @@ async def test_button_stop_pump( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq ) -> None: """Test the SleepIQ stop pump button.""" - await setup_platform(hass, DOMAIN) + await setup_platform(hass, BUTTON_DOMAIN) state = hass.states.get(f"button.sleepnumber_{BED_NAME_LOWER}_stop_pump") assert ( @@ -50,7 +50,7 @@ async def test_button_stop_pump( assert entity.unique_id == f"{BED_ID}-stop-pump" await hass.services.async_call( - DOMAIN, + BUTTON_DOMAIN, "press", {ATTR_ENTITY_ID: f"button.sleepnumber_{BED_NAME_LOWER}_stop_pump"}, blocking=True, diff --git a/tests/components/sleepiq/test_light.py b/tests/components/sleepiq/test_light.py index 9564bca7a99..d1284dc3e41 100644 --- a/tests/components/sleepiq/test_light.py +++ b/tests/components/sleepiq/test_light.py @@ -1,6 +1,6 @@ """The tests for SleepIQ light platform.""" -from homeassistant.components.light import DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.components.sleepiq.coordinator import LONGER_UPDATE_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -16,7 +16,7 @@ async def test_setup( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq ) -> None: """Test for successfully setting up the SleepIQ platform.""" - entry = await setup_platform(hass, DOMAIN) + entry = await setup_platform(hass, LIGHT_DOMAIN) assert len(entity_registry.entities) == 2 @@ -33,10 +33,10 @@ async def test_setup( async def test_light_set_states(hass: HomeAssistant, mock_asyncsleepiq) -> None: """Test light change.""" - await setup_platform(hass, DOMAIN) + await setup_platform(hass, LIGHT_DOMAIN) await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, "turn_on", {ATTR_ENTITY_ID: f"light.sleepnumber_{BED_NAME_LOWER}_light_1"}, blocking=True, @@ -45,7 +45,7 @@ async def test_light_set_states(hass: HomeAssistant, mock_asyncsleepiq) -> None: mock_asyncsleepiq.beds[BED_ID].foundation.lights[0].turn_on.assert_called_once() await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, "turn_off", {ATTR_ENTITY_ID: f"light.sleepnumber_{BED_NAME_LOWER}_light_1"}, blocking=True, @@ -56,7 +56,7 @@ async def test_light_set_states(hass: HomeAssistant, mock_asyncsleepiq) -> None: async def test_switch_get_states(hass: HomeAssistant, mock_asyncsleepiq) -> None: """Test light update.""" - await setup_platform(hass, DOMAIN) + await setup_platform(hass, LIGHT_DOMAIN) assert ( hass.states.get(f"light.sleepnumber_{BED_NAME_LOWER}_light_1").state diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index 52df2eb27aa..f0739aabc9d 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -5,7 +5,7 @@ from homeassistant.components.number import ( ATTR_MIN, ATTR_STEP, ATTR_VALUE, - DOMAIN, + DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON @@ -30,7 +30,7 @@ async def test_firmness( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq ) -> None: """Test the SleepIQ firmness number values for a bed with two sides.""" - entry = await setup_platform(hass, DOMAIN) + entry = await setup_platform(hass, NUMBER_DOMAIN) state = hass.states.get( f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_firmness" @@ -71,7 +71,7 @@ async def test_firmness( assert entry.unique_id == f"{SLEEPER_R_ID}_firmness" await hass.services.async_call( - DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_firmness", @@ -89,7 +89,7 @@ async def test_actuators( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq ) -> None: """Test the SleepIQ actuator position values for a bed with adjustable head and foot.""" - entry = await setup_platform(hass, DOMAIN) + entry = await setup_platform(hass, NUMBER_DOMAIN) state = hass.states.get(f"number.sleepnumber_{BED_NAME_LOWER}_right_head_position") assert state.state == "60.0" @@ -143,7 +143,7 @@ async def test_actuators( assert entry.unique_id == f"{BED_ID}_F" await hass.services.async_call( - DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_right_head_position", @@ -165,7 +165,7 @@ async def test_foot_warmer_timer( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq ) -> None: """Test the SleepIQ foot warmer number values for a bed with two sides.""" - entry = await setup_platform(hass, DOMAIN) + entry = await setup_platform(hass, NUMBER_DOMAIN) state = hass.states.get( f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer" @@ -187,7 +187,7 @@ async def test_foot_warmer_timer( assert entry.unique_id == f"{BED_ID}_L_foot_warming_timer" await hass.services.async_call( - DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, { ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer", diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index ef4c7fb6df0..bbfb612e9cb 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -4,7 +4,10 @@ from unittest.mock import MagicMock from asyncsleepiq import FootWarmingTemps -from homeassistant.components.select import DOMAIN, SERVICE_SELECT_OPTION +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, @@ -37,7 +40,7 @@ async def test_split_foundation_preset( mock_asyncsleepiq: MagicMock, ) -> None: """Test the SleepIQ select entity for split foundation presets.""" - entry = await setup_platform(hass, DOMAIN) + entry = await setup_platform(hass, SELECT_DOMAIN) state = hass.states.get( f"select.sleepnumber_{BED_NAME_LOWER}_foundation_preset_right" @@ -72,7 +75,7 @@ async def test_split_foundation_preset( assert entry.unique_id == f"{BED_ID}_preset_L" await hass.services.async_call( - DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_foundation_preset_left", @@ -94,7 +97,7 @@ async def test_single_foundation_preset( mock_asyncsleepiq_single_foundation: MagicMock, ) -> None: """Test the SleepIQ select entity for single foundation presets.""" - entry = await setup_platform(hass, DOMAIN) + entry = await setup_platform(hass, SELECT_DOMAIN) state = hass.states.get(f"select.sleepnumber_{BED_NAME_LOWER}_foundation_preset") assert state.state == PRESET_R_STATE @@ -111,7 +114,7 @@ async def test_single_foundation_preset( assert entry.unique_id == f"{BED_ID}_preset" await hass.services.async_call( - DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_foundation_preset", @@ -135,7 +138,7 @@ async def test_foot_warmer( mock_asyncsleepiq: MagicMock, ) -> None: """Test the SleepIQ select entity for foot warmers.""" - entry = await setup_platform(hass, DOMAIN) + entry = await setup_platform(hass, SELECT_DOMAIN) state = hass.states.get( f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer" @@ -154,7 +157,7 @@ async def test_foot_warmer( assert entry.unique_id == f"{SLEEPER_L_ID}_foot_warmer" await hass.services.async_call( - DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer", @@ -185,7 +188,7 @@ async def test_foot_warmer( assert entry.unique_id == f"{SLEEPER_R_ID}_foot_warmer" await hass.services.async_call( - DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer", diff --git a/tests/components/sleepiq/test_sensor.py b/tests/components/sleepiq/test_sensor.py index ae25958419c..eb558850fb3 100644 --- a/tests/components/sleepiq/test_sensor.py +++ b/tests/components/sleepiq/test_sensor.py @@ -1,6 +1,6 @@ """The tests for SleepIQ sensor platform.""" -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -22,7 +22,7 @@ async def test_sleepnumber_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq ) -> None: """Test the SleepIQ sleepnumber for a bed with two sides.""" - entry = await setup_platform(hass, DOMAIN) + entry = await setup_platform(hass, SENSOR_DOMAIN) state = hass.states.get( f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_sleepnumber" @@ -61,7 +61,7 @@ async def test_pressure_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq ) -> None: """Test the SleepIQ pressure for a bed with two sides.""" - entry = await setup_platform(hass, DOMAIN) + entry = await setup_platform(hass, SENSOR_DOMAIN) state = hass.states.get( f"sensor.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_pressure" diff --git a/tests/components/sleepiq/test_switch.py b/tests/components/sleepiq/test_switch.py index 7c41b6b9d19..5dd3e77fd66 100644 --- a/tests/components/sleepiq/test_switch.py +++ b/tests/components/sleepiq/test_switch.py @@ -1,7 +1,7 @@ """The tests for SleepIQ switch platform.""" from homeassistant.components.sleepiq.coordinator import LONGER_UPDATE_INTERVAL -from homeassistant.components.switch import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -16,7 +16,7 @@ async def test_setup( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq ) -> None: """Test for successfully setting up the SleepIQ platform.""" - entry = await setup_platform(hass, DOMAIN) + entry = await setup_platform(hass, SWITCH_DOMAIN) assert len(entity_registry.entities) == 1 @@ -28,10 +28,10 @@ async def test_setup( async def test_switch_set_states(hass: HomeAssistant, mock_asyncsleepiq) -> None: """Test button press.""" - await setup_platform(hass, DOMAIN) + await setup_platform(hass, SWITCH_DOMAIN) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, "turn_off", {ATTR_ENTITY_ID: f"switch.sleepnumber_{BED_NAME_LOWER}_pause_mode"}, blocking=True, @@ -40,7 +40,7 @@ async def test_switch_set_states(hass: HomeAssistant, mock_asyncsleepiq) -> None mock_asyncsleepiq.beds[BED_ID].set_pause_mode.assert_called_with(False) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, "turn_on", {ATTR_ENTITY_ID: f"switch.sleepnumber_{BED_NAME_LOWER}_pause_mode"}, blocking=True, @@ -51,7 +51,7 @@ async def test_switch_set_states(hass: HomeAssistant, mock_asyncsleepiq) -> None async def test_switch_get_states(hass: HomeAssistant, mock_asyncsleepiq) -> None: """Test button press.""" - await setup_platform(hass, DOMAIN) + await setup_platform(hass, SWITCH_DOMAIN) assert ( hass.states.get(f"switch.sleepnumber_{BED_NAME_LOWER}_pause_mode").state diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 2674b9697ed..ce409869048 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -5,7 +5,11 @@ from typing import Any import pytest from homeassistant import setup -from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, @@ -51,13 +55,13 @@ OPEN_CLOSE_COVER_CONFIG = { } -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( ("config", "states"), [ ( { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -102,7 +106,7 @@ OPEN_CLOSE_COVER_CONFIG = { ), ( { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -152,13 +156,13 @@ async def test_template_state_text( assert text in caplog.text -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( ("config", "entity", "set_state", "test_state", "attr"), [ ( { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -178,7 +182,7 @@ async def test_template_state_text( ), ( { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -218,12 +222,12 @@ async def test_template_state_text_ignored_if_none_or_empty( assert "ERROR" not in caplog.text -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -241,12 +245,12 @@ async def test_template_state_boolean(hass: HomeAssistant, start_ha) -> None: assert state.state == STATE_OPEN -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -281,12 +285,12 @@ async def test_template_position( assert "ValueError" not in caplog.text -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -304,13 +308,13 @@ async def test_template_not_optimistic(hass: HomeAssistant, start_ha) -> None: assert state.state == STATE_UNKNOWN -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( ("config", "tilt_position"), [ ( { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -325,7 +329,7 @@ async def test_template_not_optimistic(hass: HomeAssistant, start_ha) -> None: ), ( { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -348,12 +352,12 @@ async def test_template_tilt( assert state.attributes.get("current_tilt_position") == tilt_position -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -365,7 +369,7 @@ async def test_template_tilt( } }, { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -391,18 +395,18 @@ async def test_template_out_of_bounds(hass: HomeAssistant, start_ha) -> None: assert state.attributes.get("current_position") is None -@pytest.mark.parametrize(("count", "domain"), [(0, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(0, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": {"test_template_cover": {"value_template": "{{ 1 == 1 }}"}}, } }, { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -428,12 +432,12 @@ async def test_template_open_or_position( assert "Invalid config for 'cover' from integration 'template'" in caplog_setup_text -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -453,7 +457,7 @@ async def test_open_action( assert state.state == STATE_CLOSED await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) await hass.async_block_till_done() @@ -462,12 +466,12 @@ async def test_open_action( assert calls[0].data["caller"] == "cover.test_template_cover" -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -494,12 +498,12 @@ async def test_close_stop_action( assert state.state == STATE_OPEN await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) await hass.async_block_till_done() @@ -554,7 +558,7 @@ async def test_set_position( assert state.state == STATE_UNKNOWN await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) await hass.async_block_till_done() state = hass.states.get("cover.test_template_cover") @@ -565,7 +569,7 @@ async def test_set_position( assert calls[-1].data["position"] == 100 await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) await hass.async_block_till_done() state = hass.states.get("cover.test_template_cover") @@ -576,7 +580,7 @@ async def test_set_position( assert calls[-1].data["position"] == 0 await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) await hass.async_block_till_done() state = hass.states.get("cover.test_template_cover") @@ -587,7 +591,7 @@ async def test_set_position( assert calls[-1].data["position"] == 100 await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) await hass.async_block_till_done() state = hass.states.get("cover.test_template_cover") @@ -598,7 +602,7 @@ async def test_set_position( assert calls[-1].data["position"] == 0 await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 25}, blocking=True, @@ -612,12 +616,12 @@ async def test_set_position( assert calls[-1].data["position"] == 25 -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -658,7 +662,7 @@ async def test_set_tilt_position( ) -> None: """Test the set_tilt_position command.""" await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, service, attr, blocking=True, @@ -671,12 +675,12 @@ async def test_set_tilt_position( assert calls[-1].data["tilt_position"] == tilt_position -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -695,7 +699,7 @@ async def test_set_position_optimistic( assert state.attributes.get("current_position") is None await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 42}, blocking=True, @@ -711,19 +715,19 @@ async def test_set_position_optimistic( (SERVICE_TOGGLE, STATE_OPEN), ): await hass.services.async_call( - DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) await hass.async_block_till_done() state = hass.states.get("cover.test_template_cover") assert state.state == test_state -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -744,7 +748,7 @@ async def test_set_tilt_position_optimistic( assert state.attributes.get("current_tilt_position") is None await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, blocking=True, @@ -760,19 +764,19 @@ async def test_set_tilt_position_optimistic( (SERVICE_TOGGLE_COVER_TILT, 100.0), ): await hass.services.async_call( - DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, service, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) await hass.async_block_till_done() state = hass.states.get("cover.test_template_cover") assert state.attributes.get("current_tilt_position") == pos -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -800,12 +804,12 @@ async def test_icon_template(hass: HomeAssistant, start_ha) -> None: assert state.attributes["icon"] == "mdi:check" -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -835,12 +839,12 @@ async def test_entity_picture_template(hass: HomeAssistant, start_ha) -> None: assert state.attributes["entity_picture"] == "/local/cover.png" -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -868,12 +872,12 @@ async def test_availability_template(hass: HomeAssistant, start_ha) -> None: assert hass.states.get("cover.test_template_cover").state != STATE_UNAVAILABLE -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -893,12 +897,12 @@ async def test_availability_without_availability_template( assert state.state != STATE_UNAVAILABLE -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -919,12 +923,12 @@ async def test_invalid_availability_template_keeps_component_available( assert "UndefinedError: 'x' is undefined" in caplog_setup_text -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -943,12 +947,12 @@ async def test_device_class(hass: HomeAssistant, start_ha) -> None: assert state.attributes.get("device_class") == "door" -@pytest.mark.parametrize(("count", "domain"), [(0, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(0, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover": { @@ -967,12 +971,12 @@ async def test_invalid_device_class(hass: HomeAssistant, start_ha) -> None: assert not state -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "test_template_cover_01": { @@ -995,12 +999,12 @@ async def test_unique_id(hass: HomeAssistant, start_ha) -> None: assert len(hass.states.async_all()) == 1 -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "garage_door": { @@ -1029,12 +1033,12 @@ async def test_state_gets_lowercased(hass: HomeAssistant, start_ha) -> None: assert hass.states.get("cover.garage_door").state == STATE_CLOSED -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, COVER_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + COVER_DOMAIN: { "platform": "template", "covers": { "office": { diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index 40966d5557c..020444a620a 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, DIRECTION_FORWARD, DIRECTION_REVERSE, - DOMAIN, + DOMAIN as FAN_DOMAIN, FanEntityFeature, NotValidPresetModeError, ) @@ -36,12 +36,12 @@ _OSC_INPUT = "input_select.osc" _DIRECTION_INPUT_SELECT = "input_select.direction" -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -59,12 +59,12 @@ async def test_missing_optional_config(hass: HomeAssistant, start_ha) -> None: _verify(hass, STATE_ON, None, None, None, None) -@pytest.mark.parametrize(("count", "domain"), [(0, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(0, FAN_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "platform": "template", @@ -78,7 +78,7 @@ async def test_missing_optional_config(hass: HomeAssistant, start_ha) -> None: } }, { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "platform": "template", @@ -92,7 +92,7 @@ async def test_missing_optional_config(hass: HomeAssistant, start_ha) -> None: } }, { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "platform": "template", @@ -112,12 +112,12 @@ async def test_wrong_template_config(hass: HomeAssistant, start_ha) -> None: assert hass.states.async_all("fan") == [] -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -173,13 +173,13 @@ async def test_templates_with_entities(hass: HomeAssistant, start_ha) -> None: _verify(hass, STATE_OFF, 0, True, DIRECTION_FORWARD, None) -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( ("config", "entity", "tests"), [ ( { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -203,7 +203,7 @@ async def test_templates_with_entities(hass: HomeAssistant, start_ha) -> None: ), ( { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -239,12 +239,12 @@ async def test_templates_with_entities2( _verify(hass, STATE_ON, test_percentage, None, None, test_type) -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -272,13 +272,13 @@ async def test_availability_template_with_entities( assert (hass.states.get(_TEST_FAN).state != STATE_UNAVAILABLE) == test_assert -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( ("config", "states"), [ ( { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -293,7 +293,7 @@ async def test_availability_template_with_entities( ), ( { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -311,7 +311,7 @@ async def test_availability_template_with_entities( ), ( { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -329,7 +329,7 @@ async def test_availability_template_with_entities( ), ( { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -354,12 +354,12 @@ async def test_template_with_unavailable_entities( _verify(hass, states[0], states[1], states[2], states[3], None) -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_fan": { @@ -903,12 +903,12 @@ async def _register_components( await hass.async_block_till_done() -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "test_template_fan_01": { @@ -1024,12 +1024,12 @@ async def test_implemented_percentage( assert attributes.get("supported_features") & FanEntityFeature.SET_SPEED -@pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) +@pytest.mark.parametrize(("count", "domain"), [(1, FAN_DOMAIN)]) @pytest.mark.parametrize( "config", [ { - DOMAIN: { + FAN_DOMAIN: { "platform": "template", "fans": { "mechanical_ventilation": { diff --git a/tests/components/tomato/test_device_tracker.py b/tests/components/tomato/test_device_tracker.py index 9484d3393d7..1747832e0d5 100644 --- a/tests/components/tomato/test_device_tracker.py +++ b/tests/components/tomato/test_device_tracker.py @@ -7,7 +7,7 @@ import requests import requests_mock import voluptuous as vol -from homeassistant.components.device_tracker import DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN import homeassistant.components.tomato.device_tracker as tomato from homeassistant.const import ( CONF_HOST, @@ -68,7 +68,7 @@ def mock_session_send(): def test_config_missing_optional_params(hass: HomeAssistant, mock_session_send) -> None: """Test the setup without optional parameters.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -94,7 +94,7 @@ def test_config_missing_optional_params(hass: HomeAssistant, mock_session_send) def test_config_default_nonssl_port(hass: HomeAssistant, mock_session_send) -> None: """Test the setup without a default port set without ssl enabled.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -113,7 +113,7 @@ def test_config_default_nonssl_port(hass: HomeAssistant, mock_session_send) -> N def test_config_default_ssl_port(hass: HomeAssistant, mock_session_send) -> None: """Test the setup without a default port set with ssl enabled.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -135,7 +135,7 @@ def test_config_verify_ssl_but_no_ssl_enabled( ) -> None: """Test the setup with a string with ssl_verify but ssl not enabled.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -169,7 +169,7 @@ def test_config_valid_verify_ssl_path(hass: HomeAssistant, mock_session_send) -> Representing the absolute path to a CA certificate bundle. """ config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -200,7 +200,7 @@ def test_config_valid_verify_ssl_path(hass: HomeAssistant, mock_session_send) -> def test_config_valid_verify_ssl_bool(hass: HomeAssistant, mock_session_send) -> None: """Test the setup with a bool for ssl_verify.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -301,7 +301,7 @@ def test_config_errors() -> None: def test_config_bad_credentials(hass: HomeAssistant, mock_exception_logger) -> None: """Test the setup with bad credentials.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -324,7 +324,7 @@ def test_config_bad_credentials(hass: HomeAssistant, mock_exception_logger) -> N def test_bad_response(hass: HomeAssistant, mock_exception_logger) -> None: """Test the setup with bad response from router.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -347,7 +347,7 @@ def test_bad_response(hass: HomeAssistant, mock_exception_logger) -> None: def test_scan_devices(hass: HomeAssistant, mock_exception_logger) -> None: """Test scanning for new devices.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -366,7 +366,7 @@ def test_scan_devices(hass: HomeAssistant, mock_exception_logger) -> None: def test_bad_connection(hass: HomeAssistant, mock_exception_logger) -> None: """Test the router with a connection error.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -394,7 +394,7 @@ def test_bad_connection(hass: HomeAssistant, mock_exception_logger) -> None: def test_router_timeout(hass: HomeAssistant, mock_exception_logger) -> None: """Test the router with a timeout error.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", @@ -422,7 +422,7 @@ def test_router_timeout(hass: HomeAssistant, mock_exception_logger) -> None: def test_get_device_name(hass: HomeAssistant, mock_exception_logger) -> None: """Test getting device names.""" config = { - DOMAIN: tomato.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { CONF_PLATFORM: tomato.DOMAIN, CONF_HOST: "tomato-router", diff --git a/tests/components/venstar/util.py b/tests/components/venstar/util.py index f1e85e9019e..44b3efe0720 100644 --- a/tests/components/venstar/util.py +++ b/tests/components/venstar/util.py @@ -2,7 +2,7 @@ import requests_mock -from homeassistant.components.climate import DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.const import CONF_HOST, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -54,7 +54,7 @@ async def async_init_integration( } for model in TEST_MODELS ] - config = {DOMAIN: platform_config} + config = {CLIMATE_DOMAIN: platform_config} - await async_setup_component(hass, DOMAIN, config) + await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/xiaomi/test_device_tracker.py b/tests/components/xiaomi/test_device_tracker.py index 0f1c36d1fba..7d3b35bbda7 100644 --- a/tests/components/xiaomi/test_device_tracker.py +++ b/tests/components/xiaomi/test_device_tracker.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, call, patch import requests -from homeassistant.components.device_tracker import DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN import homeassistant.components.xiaomi.device_tracker as xiaomi from homeassistant.components.xiaomi.device_tracker import get_scanner from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME @@ -154,7 +154,7 @@ def mocked_requests(*args, **kwargs): async def test_config(xiaomi_mock, hass: HomeAssistant) -> None: """Testing minimal configuration.""" config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( { CONF_PLATFORM: xiaomi.DOMAIN, CONF_HOST: "192.168.0.1", @@ -164,7 +164,7 @@ async def test_config(xiaomi_mock, hass: HomeAssistant) -> None: } xiaomi.get_scanner(hass, config) assert xiaomi_mock.call_count == 1 - assert xiaomi_mock.call_args == call(config[DOMAIN]) + assert xiaomi_mock.call_args == call(config[DEVICE_TRACKER_DOMAIN]) call_arg = xiaomi_mock.call_args[0][0] assert call_arg["username"] == "admin" assert call_arg["password"] == "passwordTest" @@ -179,7 +179,7 @@ async def test_config(xiaomi_mock, hass: HomeAssistant) -> None: async def test_config_full(xiaomi_mock, hass: HomeAssistant) -> None: """Testing full configuration.""" config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( { CONF_PLATFORM: xiaomi.DOMAIN, CONF_HOST: "192.168.0.1", @@ -190,7 +190,7 @@ async def test_config_full(xiaomi_mock, hass: HomeAssistant) -> None: } xiaomi.get_scanner(hass, config) assert xiaomi_mock.call_count == 1 - assert xiaomi_mock.call_args == call(config[DOMAIN]) + assert xiaomi_mock.call_args == call(config[DEVICE_TRACKER_DOMAIN]) call_arg = xiaomi_mock.call_args[0][0] assert call_arg["username"] == "alternativeAdminName" assert call_arg["password"] == "passwordTest" @@ -203,7 +203,7 @@ async def test_config_full(xiaomi_mock, hass: HomeAssistant) -> None: async def test_invalid_credential(mock_get, mock_post, hass: HomeAssistant) -> None: """Testing invalid credential handling.""" config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( { CONF_PLATFORM: xiaomi.DOMAIN, CONF_HOST: "192.168.0.1", @@ -220,7 +220,7 @@ async def test_invalid_credential(mock_get, mock_post, hass: HomeAssistant) -> N async def test_valid_credential(mock_get, mock_post, hass: HomeAssistant) -> None: """Testing valid refresh.""" config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( { CONF_PLATFORM: xiaomi.DOMAIN, CONF_HOST: "192.168.0.1", @@ -244,7 +244,7 @@ async def test_token_timed_out(mock_get, mock_post, hass: HomeAssistant) -> None New token is requested and list is downloaded a second time. """ config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA( + DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( { CONF_PLATFORM: xiaomi.DOMAIN, CONF_HOST: "192.168.0.1", diff --git a/tests/components/xiaomi_miio/test_button.py b/tests/components/xiaomi_miio/test_button.py index 8159d7c49e5..1f79a3ec0d0 100644 --- a/tests/components/xiaomi_miio/test_button.py +++ b/tests/components/xiaomi_miio/test_button.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.button import DOMAIN, SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.xiaomi_miio.const import ( CONF_FLOW_TYPE, DOMAIN as XIAOMI_DOMAIN, @@ -68,7 +68,7 @@ async def test_vacuum_button_press(hass: HomeAssistant) -> None: pressed_at = dt_util.utcnow() await hass.services.async_call( - DOMAIN, + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id + "_reset_side_brush"}, blocking=True, @@ -81,7 +81,7 @@ async def test_vacuum_button_press(hass: HomeAssistant) -> None: async def setup_component(hass: HomeAssistant, entity_name: str) -> str: """Set up vacuum component.""" - entity_id = f"{DOMAIN}.{entity_name}" + entity_id = f"{BUTTON_DOMAIN}.{entity_name}" config_entry = MockConfigEntry( domain=XIAOMI_DOMAIN, diff --git a/tests/components/xiaomi_miio/test_select.py b/tests/components/xiaomi_miio/test_select.py index 584ef910c98..566f1516fdf 100644 --- a/tests/components/xiaomi_miio/test_select.py +++ b/tests/components/xiaomi_miio/test_select.py @@ -12,7 +12,7 @@ import pytest from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, - DOMAIN, + DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) from homeassistant.components.xiaomi_miio import UPDATE_INTERVAL @@ -143,7 +143,7 @@ async def test_select_coordinator_update(hass: HomeAssistant, setup_test) -> Non async def setup_component(hass: HomeAssistant, entity_name: str) -> str: """Set up component.""" - entity_id = f"{DOMAIN}.{entity_name}" + entity_id = f"{SELECT_DOMAIN}.{entity_name}" config_entry = MockConfigEntry( domain=XIAOMI_DOMAIN, diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 64612f6f464..76321a1a0a8 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -12,7 +12,7 @@ from homeassistant.components.vacuum import ( ATTR_BATTERY_ICON, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, - DOMAIN, + DOMAIN as VACUUM_DOMAIN, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -283,7 +283,7 @@ async def test_xiaomi_vacuum_services( # Call services await hass.services.async_call( - DOMAIN, SERVICE_START, {"entity_id": entity_id}, blocking=True + VACUUM_DOMAIN, SERVICE_START, {"entity_id": entity_id}, blocking=True ) mock_mirobo_is_got_error.assert_has_calls( [mock.call.resume_or_start()], any_order=True @@ -292,42 +292,42 @@ async def test_xiaomi_vacuum_services( mock_mirobo_is_got_error.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_PAUSE, {"entity_id": entity_id}, blocking=True + VACUUM_DOMAIN, SERVICE_PAUSE, {"entity_id": entity_id}, blocking=True ) mock_mirobo_is_got_error.assert_has_calls([mock.call.pause()], any_order=True) mock_mirobo_is_got_error.assert_has_calls(STATUS_CALLS, any_order=True) mock_mirobo_is_got_error.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_STOP, {"entity_id": entity_id}, blocking=True + VACUUM_DOMAIN, SERVICE_STOP, {"entity_id": entity_id}, blocking=True ) mock_mirobo_is_got_error.assert_has_calls([mock.call.stop()], any_order=True) mock_mirobo_is_got_error.assert_has_calls(STATUS_CALLS, any_order=True) mock_mirobo_is_got_error.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_RETURN_TO_BASE, {"entity_id": entity_id}, blocking=True + VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, {"entity_id": entity_id}, blocking=True ) mock_mirobo_is_got_error.assert_has_calls([mock.call.home()], any_order=True) mock_mirobo_is_got_error.assert_has_calls(STATUS_CALLS, any_order=True) mock_mirobo_is_got_error.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_LOCATE, {"entity_id": entity_id}, blocking=True + VACUUM_DOMAIN, SERVICE_LOCATE, {"entity_id": entity_id}, blocking=True ) mock_mirobo_is_got_error.assert_has_calls([mock.call.find()], any_order=True) mock_mirobo_is_got_error.assert_has_calls(STATUS_CALLS, any_order=True) mock_mirobo_is_got_error.reset_mock() await hass.services.async_call( - DOMAIN, SERVICE_CLEAN_SPOT, {"entity_id": entity_id}, blocking=True + VACUUM_DOMAIN, SERVICE_CLEAN_SPOT, {"entity_id": entity_id}, blocking=True ) mock_mirobo_is_got_error.assert_has_calls([mock.call.spot()], any_order=True) mock_mirobo_is_got_error.assert_has_calls(STATUS_CALLS, any_order=True) mock_mirobo_is_got_error.reset_mock() await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_SEND_COMMAND, {"entity_id": entity_id, "command": "raw"}, blocking=True, @@ -339,7 +339,7 @@ async def test_xiaomi_vacuum_services( mock_mirobo_is_got_error.reset_mock() await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_SEND_COMMAND, {"entity_id": entity_id, "command": "raw", "params": {"k1": 2}}, blocking=True, @@ -498,7 +498,7 @@ async def test_xiaomi_vacuum_fanspeeds( # Set speed service: await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_SET_FAN_SPEED, {"entity_id": entity_id, "fan_speed": 60}, blocking=True, @@ -512,7 +512,7 @@ async def test_xiaomi_vacuum_fanspeeds( fan_speed_dict = mock_mirobo_fanspeeds.fan_speed_presets() await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_SET_FAN_SPEED, {"entity_id": entity_id, "fan_speed": "Medium"}, blocking=True, @@ -525,7 +525,7 @@ async def test_xiaomi_vacuum_fanspeeds( assert "ERROR" not in caplog.text await hass.services.async_call( - DOMAIN, + VACUUM_DOMAIN, SERVICE_SET_FAN_SPEED, {"entity_id": entity_id, "fan_speed": "invent"}, blocking=True, @@ -535,7 +535,7 @@ async def test_xiaomi_vacuum_fanspeeds( async def setup_component(hass: HomeAssistant, entity_name: str) -> str: """Set up vacuum component.""" - entity_id = f"{DOMAIN}.{entity_name}" + entity_id = f"{VACUUM_DOMAIN}.{entity_name}" config_entry = MockConfigEntry( domain=XIAOMI_DOMAIN, diff --git a/tests/components/zha/test_button.py b/tests/components/zha/test_button.py index 574805db5f6..33ed004312b 100644 --- a/tests/components/zha/test_button.py +++ b/tests/components/zha/test_button.py @@ -9,7 +9,11 @@ from zigpy.profiles import zha from zigpy.zcl.clusters import general import zigpy.zcl.foundation as zcl_f -from homeassistant.components.button import DOMAIN, SERVICE_PRESS, ButtonDeviceClass +from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, + SERVICE_PRESS, + ButtonDeviceClass, +) from homeassistant.components.zha.helpers import ( ZHADeviceProxy, ZHAGatewayProxy, @@ -97,7 +101,7 @@ async def test_button( return_value=[0x00, zcl_f.Status.SUCCESS], ): await hass.services.async_call( - DOMAIN, + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True, diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index 4ecd697f4d1..07edb68f1da 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -15,7 +15,7 @@ from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, - DOMAIN, + DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -68,7 +68,7 @@ async def test_window_cover( # Test setting position await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY, ATTR_POSITION: 50}, blocking=True, @@ -89,7 +89,7 @@ async def test_window_cover( # Test setting position await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY, ATTR_POSITION: 0}, blocking=True, @@ -110,7 +110,7 @@ async def test_window_cover( # Test opening await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, @@ -131,7 +131,7 @@ async def test_window_cover( # Test stop after opening await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, @@ -174,7 +174,7 @@ async def test_window_cover( # Test closing await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, @@ -194,7 +194,7 @@ async def test_window_cover( # Test stop after closing await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, @@ -249,7 +249,7 @@ async def test_fibaro_fgr222_shutter_cover( # Test opening tilts await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY}, blocking=True, @@ -271,7 +271,7 @@ async def test_fibaro_fgr222_shutter_cover( # Test closing tilts await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY}, blocking=True, @@ -293,7 +293,7 @@ async def test_fibaro_fgr222_shutter_cover( # Test setting tilt position await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, blocking=True, @@ -350,7 +350,7 @@ async def test_fibaro_fgr223_shutter_cover( # Test opening tilts await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY}, blocking=True, @@ -370,7 +370,7 @@ async def test_fibaro_fgr223_shutter_cover( client.async_send_command.reset_mock() # Test closing tilts await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY}, blocking=True, @@ -390,7 +390,7 @@ async def test_fibaro_fgr223_shutter_cover( client.async_send_command.reset_mock() # Test setting tilt position await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, blocking=True, @@ -446,7 +446,7 @@ async def test_aeotec_nano_shutter_cover( # Test opening await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, @@ -467,7 +467,7 @@ async def test_aeotec_nano_shutter_cover( # Test stop after opening await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, @@ -511,7 +511,7 @@ async def test_aeotec_nano_shutter_cover( # Test closing await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, @@ -531,7 +531,7 @@ async def test_aeotec_nano_shutter_cover( # Test stop after closing await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, @@ -583,7 +583,10 @@ async def test_motor_barrier_cover( # Test open await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: GDC_COVER_ENTITY}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: GDC_COVER_ENTITY}, + blocking=True, ) assert len(client.async_send_command.call_args_list) == 1 @@ -605,7 +608,10 @@ async def test_motor_barrier_cover( # Test close await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: GDC_COVER_ENTITY}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: GDC_COVER_ENTITY}, + blocking=True, ) assert len(client.async_send_command.call_args_list) == 1 @@ -846,7 +852,7 @@ async def test_iblinds_v3_cover( assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: entity_id}, blocking=True, @@ -867,7 +873,7 @@ async def test_iblinds_v3_cover( client.async_send_command.reset_mock() await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: entity_id}, blocking=True, @@ -888,7 +894,7 @@ async def test_iblinds_v3_cover( client.async_send_command.reset_mock() await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, {ATTR_ENTITY_ID: entity_id, ATTR_TILT_POSITION: 12}, blocking=True, @@ -909,7 +915,7 @@ async def test_iblinds_v3_cover( client.async_send_command.reset_mock() await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_STOP_COVER_TILT, {ATTR_ENTITY_ID: entity_id}, blocking=True, @@ -950,7 +956,7 @@ async def test_nice_ibt4zwave_cover( assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.GATE await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: entity_id}, blocking=True, @@ -970,7 +976,7 @@ async def test_nice_ibt4zwave_cover( client.async_send_command.reset_mock() await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: entity_id}, blocking=True, diff --git a/tests/components/zwave_js/test_switch.py b/tests/components/zwave_js/test_switch.py index 810ce38cf99..30486186a4e 100644 --- a/tests/components/zwave_js/test_switch.py +++ b/tests/components/zwave_js/test_switch.py @@ -6,7 +6,11 @@ from zwave_js_server.event import Event from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node -from homeassistant.components.switch import DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant @@ -95,7 +99,7 @@ async def test_barrier_signaling_switch( # Test turning off await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {"entity_id": entity}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {"entity_id": entity}, blocking=True ) assert len(client.async_send_command.call_args_list) == 1 @@ -120,7 +124,7 @@ async def test_barrier_signaling_switch( # Test turning on await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {"entity_id": entity}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {"entity_id": entity}, blocking=True ) # Note: the valueId's value is still 255 because we never @@ -250,7 +254,7 @@ async def test_config_parameter_switch( # Test turning on await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {"entity_id": switch_entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_ON, {"entity_id": switch_entity_id}, blocking=True ) assert len(client.async_send_command.call_args_list) == 1 @@ -268,7 +272,7 @@ async def test_config_parameter_switch( # Test turning off await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {"entity_id": switch_entity_id}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {"entity_id": switch_entity_id}, blocking=True ) assert len(client.async_send_command.call_args_list) == 1 @@ -288,7 +292,10 @@ async def test_config_parameter_switch( # Test turning off error raises proper exception with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {"entity_id": switch_entity_id}, blocking=True + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {"entity_id": switch_entity_id}, + blocking=True, ) assert str(err.value) == ( From 029dbe7d946feae4599a61ba3e53049ce027389b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:14:35 +0200 Subject: [PATCH 0442/1309] Add alias to DOMAIN import in homekit (#125572) --- .../components/homekit/type_covers.py | 16 ++++++++------ homeassistant/components/homekit/type_fans.py | 18 +++++++-------- .../components/homekit/type_humidifiers.py | 6 ++--- .../components/homekit/type_lights.py | 9 +++++--- .../components/homekit/type_locks.py | 4 ++-- .../components/homekit/type_media_players.py | 22 +++++++++---------- .../homekit/type_security_systems.py | 4 ++-- .../components/homekit/type_switches.py | 4 ++-- 8 files changed, 44 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 29dda418665..b2f8bc1f01a 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -17,7 +17,7 @@ from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, - DOMAIN, + DOMAIN as COVER_DOMAIN, CoverEntityFeature, ) from homeassistant.const import ( @@ -181,11 +181,11 @@ class GarageDoorOpener(HomeAccessory): if value == HK_DOOR_OPEN: if self.char_current_state.value != value: self.char_current_state.set_value(HK_DOOR_OPENING) - self.async_call_service(DOMAIN, SERVICE_OPEN_COVER, params) + self.async_call_service(COVER_DOMAIN, SERVICE_OPEN_COVER, params) elif value == HK_DOOR_CLOSED: if self.char_current_state.value != value: self.char_current_state.set_value(HK_DOOR_CLOSING) - self.async_call_service(DOMAIN, SERVICE_CLOSE_COVER, params) + self.async_call_service(COVER_DOMAIN, SERVICE_CLOSE_COVER, params) @callback def async_update_state(self, new_state: State) -> None: @@ -248,7 +248,7 @@ class OpeningDeviceBase(HomeAccessory): if value != 1: return self.async_call_service( - DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.entity_id} + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.entity_id} ) def set_tilt(self, value: float) -> None: @@ -261,7 +261,9 @@ class OpeningDeviceBase(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TILT_POSITION: value} - self.async_call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value) + self.async_call_service( + COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value + ) @callback def async_update_state(self, new_state: State) -> None: @@ -322,7 +324,7 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): """Move cover to value if call came from HomeKit.""" _LOGGER.debug("%s: Set position to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value} - self.async_call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value) + self.async_call_service(COVER_DOMAIN, SERVICE_SET_COVER_POSITION, params, value) @callback def async_update_state(self, new_state: State) -> None: @@ -423,7 +425,7 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): service, position = (SERVICE_STOP_COVER, 50) params = {ATTR_ENTITY_ID: self.entity_id} - self.async_call_service(DOMAIN, service, params) + self.async_call_service(COVER_DOMAIN, service, params) # Snap the current/target position to the expected final position. self.char_current_position.set_value(position) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 64c121878a9..542d4500cbc 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -14,7 +14,7 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODES, DIRECTION_FORWARD, DIRECTION_REVERSE, - DOMAIN, + DOMAIN as FAN_DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, @@ -179,12 +179,12 @@ class Fan(HomeAccessory): "%s: Set auto to 1 (%s)", self.entity_id, self.preset_modes[0] ) params[ATTR_PRESET_MODE] = self.preset_modes[0] - self.async_call_service(DOMAIN, SERVICE_SET_PRESET_MODE, params) + self.async_call_service(FAN_DOMAIN, SERVICE_SET_PRESET_MODE, params) elif current_state := self.hass.states.get(self.entity_id): percentage: float = current_state.attributes.get(ATTR_PERCENTAGE) or 50.0 params[ATTR_PERCENTAGE] = percentage _LOGGER.debug("%s: Set auto to 0", self.entity_id) - self.async_call_service(DOMAIN, SERVICE_TURN_ON, params) + self.async_call_service(FAN_DOMAIN, SERVICE_TURN_ON, params) def set_preset_mode(self, value: int, preset_mode: str) -> None: """Set preset_mode if call came from HomeKit.""" @@ -194,36 +194,36 @@ class Fan(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} if value: params[ATTR_PRESET_MODE] = preset_mode - self.async_call_service(DOMAIN, SERVICE_SET_PRESET_MODE, params) + self.async_call_service(FAN_DOMAIN, SERVICE_SET_PRESET_MODE, params) else: - self.async_call_service(DOMAIN, SERVICE_TURN_ON, params) + self.async_call_service(FAN_DOMAIN, SERVICE_TURN_ON, params) def set_state(self, value: int) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} - self.async_call_service(DOMAIN, service, params) + self.async_call_service(FAN_DOMAIN, service, params) def set_direction(self, value: int) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set direction to %d", self.entity_id, value) direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD params = {ATTR_ENTITY_ID: self.entity_id, ATTR_DIRECTION: direction} - self.async_call_service(DOMAIN, SERVICE_SET_DIRECTION, params, direction) + self.async_call_service(FAN_DOMAIN, SERVICE_SET_DIRECTION, params, direction) def set_oscillating(self, value: int) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set oscillating to %d", self.entity_id, value) oscillating = value == 1 params = {ATTR_ENTITY_ID: self.entity_id, ATTR_OSCILLATING: oscillating} - self.async_call_service(DOMAIN, SERVICE_OSCILLATE, params, oscillating) + self.async_call_service(FAN_DOMAIN, SERVICE_OSCILLATE, params, oscillating) def set_percentage(self, value: float) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set speed to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_PERCENTAGE: value} - self.async_call_service(DOMAIN, SERVICE_SET_PERCENTAGE, params, value) + self.async_call_service(FAN_DOMAIN, SERVICE_SET_PERCENTAGE, params, value) @callback def async_update_state(self, new_state: State) -> None: diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index 5bdf5950f18..a57a5e00974 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -13,7 +13,7 @@ from homeassistant.components.humidifier import ( ATTR_MIN_HUMIDITY, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, - DOMAIN, + DOMAIN as HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, HumidifierDeviceClass, ) @@ -253,7 +253,7 @@ class HumidifierDehumidifier(HomeAccessory): if CHAR_ACTIVE in char_values: self.async_call_service( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_ON if char_values[CHAR_ACTIVE] else SERVICE_TURN_OFF, {ATTR_ENTITY_ID: self.entity_id}, f"{CHAR_ACTIVE} to {char_values[CHAR_ACTIVE]}", @@ -272,7 +272,7 @@ class HumidifierDehumidifier(HomeAccessory): self.char_target_humidity.set_value(humidity) self.async_call_service( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: self.entity_id, ATTR_HUMIDITY: humidity}, ( diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index cb446ea551c..6b57a03153c 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -20,7 +20,7 @@ from homeassistant.components.light import ( ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_WHITE, - DOMAIN, + DOMAIN as LIGHT_DOMAIN, ColorMode, brightness_supported, color_supported, @@ -188,7 +188,10 @@ class Light(HomeAccessory): if service == SERVICE_TURN_OFF: self.async_call_service( - DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id}, ", ".join(events) + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: self.entity_id}, + ", ".join(events), ) return @@ -232,7 +235,7 @@ class Light(HomeAccessory): _LOGGER.debug( "Calling light service with params: %s -> %s", char_values, params ) - self.async_call_service(DOMAIN, service, params, ", ".join(events)) + self.async_call_service(LIGHT_DOMAIN, service, params, ", ".join(events)) @callback def async_update_state(self, new_state: State) -> None: diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index e5b0ad22396..52dc71078d0 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -6,7 +6,7 @@ from typing import Any from pyhap.const import CATEGORY_DOOR_LOCK from homeassistant.components.lock import ( - DOMAIN, + DOMAIN as LOCK_DOMAIN, STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, @@ -89,7 +89,7 @@ class Lock(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} if self._code: params[ATTR_CODE] = self._code - self.async_call_service(DOMAIN, service, params) + self.async_call_service(LOCK_DOMAIN, service, params) @callback def async_update_state(self, new_state: State) -> None: diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 4cdb471b4ff..adb16da5a2d 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -11,7 +11,7 @@ from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, - DOMAIN, + DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, MediaPlayerEntityFeature, ) @@ -151,7 +151,7 @@ class MediaPlayer(HomeAccessory): _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} - self.async_call_service(DOMAIN, service, params) + self.async_call_service(MEDIA_PLAYER_DOMAIN, service, params) def set_play_pause(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" @@ -160,7 +160,7 @@ class MediaPlayer(HomeAccessory): ) service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_PAUSE params = {ATTR_ENTITY_ID: self.entity_id} - self.async_call_service(DOMAIN, service, params) + self.async_call_service(MEDIA_PLAYER_DOMAIN, service, params) def set_play_stop(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" @@ -169,7 +169,7 @@ class MediaPlayer(HomeAccessory): ) service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_STOP params = {ATTR_ENTITY_ID: self.entity_id} - self.async_call_service(DOMAIN, service, params) + self.async_call_service(MEDIA_PLAYER_DOMAIN, service, params) def set_toggle_mute(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" @@ -177,7 +177,7 @@ class MediaPlayer(HomeAccessory): '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value ) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_MUTED: value} - self.async_call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) + self.async_call_service(MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, params) @callback def async_update_state(self, new_state: State) -> None: @@ -286,7 +286,7 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} - self.async_call_service(DOMAIN, service, params) + self.async_call_service(MEDIA_PLAYER_DOMAIN, service, params) def set_mute(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" @@ -294,27 +294,27 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value ) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_MUTED: value} - self.async_call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) + self.async_call_service(MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, params) def set_volume(self, value: bool) -> None: """Send volume step value if call came from HomeKit.""" _LOGGER.debug("%s: Set volume to %s", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_LEVEL: value} - self.async_call_service(DOMAIN, SERVICE_VOLUME_SET, params) + self.async_call_service(MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, params) def set_volume_step(self, value: bool) -> None: """Send volume step value if call came from HomeKit.""" _LOGGER.debug("%s: Step volume by %s", self.entity_id, value) service = SERVICE_VOLUME_DOWN if value else SERVICE_VOLUME_UP params = {ATTR_ENTITY_ID: self.entity_id} - self.async_call_service(DOMAIN, service, params) + self.async_call_service(MEDIA_PLAYER_DOMAIN, service, params) def set_input_source(self, value: int) -> None: """Send input set value if call came from HomeKit.""" _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) source_name = self._mapped_sources[self.sources[value]] params = {ATTR_ENTITY_ID: self.entity_id, ATTR_INPUT_SOURCE: source_name} - self.async_call_service(DOMAIN, SERVICE_SELECT_SOURCE, params) + self.async_call_service(MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, params) def set_remote_key(self, value: int) -> None: """Send remote key value if call came from HomeKit.""" @@ -335,7 +335,7 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): else: service = SERVICE_MEDIA_PLAY_PAUSE params = {ATTR_ENTITY_ID: self.entity_id} - self.async_call_service(DOMAIN, service, params) + self.async_call_service(MEDIA_PLAYER_DOMAIN, service, params) return # Unhandled keys can be handled by listening to the event bus diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 27c479de6ba..6ab521b6727 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -6,7 +6,7 @@ from typing import Any from pyhap.const import CATEGORY_ALARM_SYSTEM from homeassistant.components.alarm_control_panel import ( - DOMAIN, + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelEntityFeature, ) from homeassistant.const import ( @@ -153,7 +153,7 @@ class SecuritySystem(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} if self._alarm_code: params[ATTR_CODE] = self._alarm_code - self.async_call_service(DOMAIN, service, params) + self.async_call_service(ALARM_CONTROL_PANEL_DOMAIN, service, params) @callback def async_update_state(self, new_state: State) -> None: diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 45a823882f7..68df6c38ad6 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -16,7 +16,7 @@ from pyhap.const import ( from homeassistant.components import button, input_button from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION -from homeassistant.components.switch import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.vacuum import ( DOMAIN as VACUUM_DOMAIN, SERVICE_RETURN_TO_BASE, @@ -109,7 +109,7 @@ class Outlet(HomeAccessory): _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.async_call_service(DOMAIN, service, params) + self.async_call_service(SWITCH_DOMAIN, service, params) @callback def async_update_state(self, new_state: State) -> None: From fe2402b61103ea33f637bc8b93f8af4c53ff621e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:18:28 +0200 Subject: [PATCH 0443/1309] Add alias to DOMAIN import in tests [e-g] (#125575) --- tests/components/ecobee/test_number.py | 18 ++- tests/components/ecobee/test_switch.py | 30 ++-- tests/components/flo/test_switch.py | 6 +- .../components/fritzbox/test_binary_sensor.py | 9 +- tests/components/fritzbox/test_button.py | 8 +- tests/components/fritzbox/test_climate.py | 22 +-- tests/components/fritzbox/test_cover.py | 14 +- tests/components/fritzbox/test_light.py | 16 +-- tests/components/fritzbox/test_sensor.py | 10 +- tests/components/fritzbox/test_switch.py | 14 +- .../generic_hygrostat/test_humidifier.py | 128 +++++++++--------- .../generic_thermostat/test_climate.py | 40 +++--- tests/components/goalzero/test_switch.py | 6 +- tests/components/gree/test_bridge.py | 12 +- tests/components/gree/test_climate.py | 60 ++++---- tests/components/gree/test_switch.py | 30 ++-- tests/components/group/test_cover.py | 77 +++++++---- tests/components/group/test_fan.py | 40 +++--- 18 files changed, 288 insertions(+), 252 deletions(-) diff --git a/tests/components/ecobee/test_number.py b/tests/components/ecobee/test_number.py index da5c8135a05..5b01fe8c5ba 100644 --- a/tests/components/ecobee/test_number.py +++ b/tests/components/ecobee/test_number.py @@ -2,7 +2,11 @@ from unittest.mock import patch -from homeassistant.components.number import ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) from homeassistant.const import ATTR_ENTITY_ID, UnitOfTime from homeassistant.core import HomeAssistant @@ -15,7 +19,7 @@ THERMOSTAT_ID = 0 async def test_ventilator_min_on_home_attributes(hass: HomeAssistant) -> None: """Test the ventilator number on home attributes are correct.""" - await setup_platform(hass, DOMAIN) + await setup_platform(hass, NUMBER_DOMAIN) state = hass.states.get(VENTILATOR_MIN_HOME_ID) assert state.state == "20" @@ -28,7 +32,7 @@ async def test_ventilator_min_on_home_attributes(hass: HomeAssistant) -> None: async def test_ventilator_min_on_away_attributes(hass: HomeAssistant) -> None: """Test the ventilator number on away attributes are correct.""" - await setup_platform(hass, DOMAIN) + await setup_platform(hass, NUMBER_DOMAIN) state = hass.states.get(VENTILATOR_MIN_AWAY_ID) assert state.state == "10" @@ -45,10 +49,10 @@ async def test_set_min_time_home(hass: HomeAssistant) -> None: with patch( "homeassistant.components.ecobee.Ecobee.set_ventilator_min_on_time_home" ) as mock_set_min_home_time: - await setup_platform(hass, DOMAIN) + await setup_platform(hass, NUMBER_DOMAIN) await hass.services.async_call( - DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: VENTILATOR_MIN_HOME_ID, ATTR_VALUE: target_value}, blocking=True, @@ -63,10 +67,10 @@ async def test_set_min_time_away(hass: HomeAssistant) -> None: with patch( "homeassistant.components.ecobee.Ecobee.set_ventilator_min_on_time_away" ) as mock_set_min_away_time: - await setup_platform(hass, DOMAIN) + await setup_platform(hass, NUMBER_DOMAIN) await hass.services.async_call( - DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: VENTILATOR_MIN_AWAY_ID, ATTR_VALUE: target_value}, blocking=True, diff --git a/tests/components/ecobee/test_switch.py b/tests/components/ecobee/test_switch.py index 05cea5a5e9d..31c8ce8f72d 100644 --- a/tests/components/ecobee/test_switch.py +++ b/tests/components/ecobee/test_switch.py @@ -8,7 +8,11 @@ from unittest.mock import patch import pytest from homeassistant.components.ecobee.switch import DATE_FORMAT -from homeassistant.components.switch import DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -29,7 +33,7 @@ def data_fixture(): async def test_ventilator_20min_attributes(hass: HomeAssistant) -> None: """Test the ventilator switch on home attributes are correct.""" - await setup_platform(hass, DOMAIN) + await setup_platform(hass, SWITCH_DOMAIN) state = hass.states.get(VENTILATOR_20MIN_ID) assert state.state == "off" @@ -42,7 +46,7 @@ async def test_ventilator_20min_when_on(hass: HomeAssistant, data) -> None: datetime.now() + timedelta(days=1) ).strftime(DATE_FORMAT) with mock.patch("pyecobee.Ecobee.get_thermostat", data): - await setup_platform(hass, DOMAIN) + await setup_platform(hass, SWITCH_DOMAIN) state = hass.states.get(VENTILATOR_20MIN_ID) assert state.state == "on" @@ -57,7 +61,7 @@ async def test_ventilator_20min_when_off(hass: HomeAssistant, data) -> None: datetime.now() - timedelta(days=1) ).strftime(DATE_FORMAT) with mock.patch("pyecobee.Ecobee.get_thermostat", data): - await setup_platform(hass, DOMAIN) + await setup_platform(hass, SWITCH_DOMAIN) state = hass.states.get(VENTILATOR_20MIN_ID) assert state.state == "off" @@ -70,7 +74,7 @@ async def test_ventilator_20min_when_empty(hass: HomeAssistant, data) -> None: data.return_value["settings"]["ventilatorOffDateTime"] = "" with mock.patch("pyecobee.Ecobee.get_thermostat", data): - await setup_platform(hass, DOMAIN) + await setup_platform(hass, SWITCH_DOMAIN) state = hass.states.get(VENTILATOR_20MIN_ID) assert state.state == "off" @@ -84,10 +88,10 @@ async def test_turn_on_20min_ventilator(hass: HomeAssistant) -> None: with patch( "homeassistant.components.ecobee.Ecobee.set_ventilator_timer" ) as mock_set_20min_ventilator: - await setup_platform(hass, DOMAIN) + await setup_platform(hass, SWITCH_DOMAIN) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: VENTILATOR_20MIN_ID}, blocking=True, @@ -102,10 +106,10 @@ async def test_turn_off_20min_ventilator(hass: HomeAssistant) -> None: with patch( "homeassistant.components.ecobee.Ecobee.set_ventilator_timer" ) as mock_set_20min_ventilator: - await setup_platform(hass, DOMAIN) + await setup_platform(hass, SWITCH_DOMAIN) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: VENTILATOR_20MIN_ID}, blocking=True, @@ -120,10 +124,10 @@ DEVICE_ID = "switch.ecobee2_aux_heat_only" async def test_aux_heat_only_turn_on(hass: HomeAssistant) -> None: """Test the switch can be turned on.""" with patch("pyecobee.Ecobee.set_hvac_mode") as mock_turn_on: - await setup_platform(hass, DOMAIN) + await setup_platform(hass, SWITCH_DOMAIN) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True, @@ -134,10 +138,10 @@ async def test_aux_heat_only_turn_on(hass: HomeAssistant) -> None: async def test_aux_heat_only_turn_off(hass: HomeAssistant) -> None: """Test the switch can be turned off.""" with patch("pyecobee.Ecobee.set_hvac_mode") as mock_turn_off: - await setup_platform(hass, DOMAIN) + await setup_platform(hass, SWITCH_DOMAIN) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: DEVICE_ID}, blocking=True, diff --git a/tests/components/flo/test_switch.py b/tests/components/flo/test_switch.py index 02ab93f9e67..5c124d312a7 100644 --- a/tests/components/flo/test_switch.py +++ b/tests/components/flo/test_switch.py @@ -3,7 +3,7 @@ import pytest from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN -from homeassistant.components.switch import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -30,11 +30,11 @@ async def test_valve_switches( assert hass.states.get(entity_id).state == STATE_ON await hass.services.async_call( - DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True ) assert hass.states.get(entity_id).state == STATE_OFF await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True ) assert hass.states.get(entity_id).state == STATE_ON diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index 3e1a2691f67..f4cc1b2e2ca 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -6,7 +6,10 @@ from unittest.mock import Mock from requests.exceptions import HTTPError -from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceClass +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDeviceClass, +) from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( @@ -27,7 +30,7 @@ from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" +ENTITY_ID = f"{BINARY_SENSOR_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: @@ -148,5 +151,5 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(f"{DOMAIN}.new_device_alarm") + state = hass.states.get(f"{BINARY_SENSOR_DOMAIN}.new_device_alarm") assert state diff --git a/tests/components/fritzbox/test_button.py b/tests/components/fritzbox/test_button.py index 89e8d8357dd..913f828efbc 100644 --- a/tests/components/fritzbox/test_button.py +++ b/tests/components/fritzbox/test_button.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest.mock import Mock -from homeassistant.components.button import DOMAIN, SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -19,7 +19,7 @@ from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" +ENTITY_ID = f"{BUTTON_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: @@ -43,7 +43,7 @@ async def test_apply_template(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, True + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert fritz().apply_template.call_count == 1 @@ -67,5 +67,5 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(f"{DOMAIN}.new_template") + state = hass.states.get(f"{BUTTON_DOMAIN}.new_template") assert state diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 358eeaa714e..062ba4f865f 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -15,7 +15,7 @@ from homeassistant.components.climate import ( ATTR_MIN_TEMP, ATTR_PRESET_MODE, ATTR_PRESET_MODES, - DOMAIN, + DOMAIN as CLIMATE_DOMAIN, PRESET_COMFORT, PRESET_ECO, SERVICE_SET_HVAC_MODE, @@ -56,7 +56,7 @@ from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" +ENTITY_ID = f"{CLIMATE_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: @@ -278,7 +278,7 @@ async def test_set_temperature_temperature(hass: HomeAssistant, fritz: Mock) -> ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 23}, True, @@ -294,7 +294,7 @@ async def test_set_temperature_mode_off(hass: HomeAssistant, fritz: Mock) -> Non ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ENTITY_ID, @@ -315,7 +315,7 @@ async def test_set_temperature_mode_heat(hass: HomeAssistant, fritz: Mock) -> No ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ENTITY_ID, @@ -335,7 +335,7 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, True, @@ -351,7 +351,7 @@ async def test_no_reset_hvac_mode_heat(hass: HomeAssistant, fritz: Mock) -> None ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, True, @@ -368,7 +368,7 @@ async def test_set_hvac_mode_heat(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, True, @@ -384,7 +384,7 @@ async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_COMFORT}, True, @@ -400,7 +400,7 @@ async def test_set_preset_mode_eco(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_ECO}, True, @@ -463,7 +463,7 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(f"{DOMAIN}.new_climate") + state = hass.states.get(f"{CLIMATE_DOMAIN}.new_climate") assert state diff --git a/tests/components/fritzbox/test_cover.py b/tests/components/fritzbox/test_cover.py index 6626db2bccf..383a0512565 100644 --- a/tests/components/fritzbox/test_cover.py +++ b/tests/components/fritzbox/test_cover.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, call from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, - DOMAIN, + DOMAIN as COVER_DOMAIN, STATE_OPEN, ) from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN @@ -32,7 +32,7 @@ from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" +ENTITY_ID = f"{COVER_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: @@ -68,7 +68,7 @@ async def test_open_cover(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_blind_open.call_count == 1 @@ -81,7 +81,7 @@ async def test_close_cover(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_blind_close.call_count == 1 @@ -94,7 +94,7 @@ async def test_set_position_cover(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 50}, True, @@ -110,7 +110,7 @@ async def test_stop_cover(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_blind_stop.call_count == 1 @@ -134,5 +134,5 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(f"{DOMAIN}.new_climate") + state = hass.states.get(f"{COVER_DOMAIN}.new_climate") assert state diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 3cafa933fa3..84fafe25521 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -19,7 +19,7 @@ from homeassistant.components.light import ( ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_MIN_COLOR_TEMP_KELVIN, ATTR_SUPPORTED_COLOR_MODES, - DOMAIN, + DOMAIN as LIGHT_DOMAIN, ColorMode, ) from homeassistant.const import ( @@ -38,7 +38,7 @@ from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" +ENTITY_ID = f"{LIGHT_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: @@ -147,7 +147,7 @@ async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_COLOR_TEMP_KELVIN: 3000}, True, @@ -170,7 +170,7 @@ async def test_turn_on_color(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_HS_COLOR: (100, 70)}, True, @@ -204,7 +204,7 @@ async def test_turn_on_color_unsupported_api_method( device.set_unmapped_color.side_effect = error await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_HS_COLOR: (100, 70)}, True, @@ -219,7 +219,7 @@ async def test_turn_on_color_unsupported_api_method( error.response.status_code = 500 with pytest.raises(HTTPError, match="Bad Request"): await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_HS_COLOR: (100, 70)}, True, @@ -237,7 +237,7 @@ async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True + LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_state_off.call_count == 1 @@ -316,5 +316,5 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(f"{DOMAIN}.new_light") + state = hass.states.get(f"{LIGHT_DOMAIN}.new_light") assert state diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 63d0b67d7f4..633049a8a9b 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -6,7 +6,11 @@ from unittest.mock import Mock from requests.exceptions import HTTPError from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN, SensorStateClass +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + SensorStateClass, +) from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, @@ -24,7 +28,7 @@ from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" +ENTITY_ID = f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup( @@ -130,5 +134,5 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(f"{DOMAIN}.new_device_temperature") + state = hass.states.get(f"{SENSOR_DOMAIN}.new_device_temperature") assert state diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index ba3b1de9b2f..e394ccbc7f3 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, SensorStateClass, ) -from homeassistant.components.switch import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, @@ -39,7 +39,7 @@ from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" +ENTITY_ID = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}" async def test_setup( @@ -124,7 +124,7 @@ async def test_turn_on(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_switch_state_on.call_count == 1 @@ -138,7 +138,7 @@ async def test_turn_off(hass: HomeAssistant, fritz: Mock) -> None: ) await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) assert device.set_switch_state_off.call_count == 1 @@ -158,7 +158,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: match="Can't toggle switch while manual switching is disabled for the device", ): await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) with pytest.raises( @@ -166,7 +166,7 @@ async def test_toggle_while_locked(hass: HomeAssistant, fritz: Mock) -> None: match="Can't toggle switch while manual switching is disabled for the device", ): await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -239,5 +239,5 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done(wait_background_tasks=True) - state = hass.states.get(f"{DOMAIN}.new_switch") + state = hass.states.get(f"{SWITCH_DOMAIN}.new_switch") assert state diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index 9cd51baa576..33a8a0f37bd 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -13,7 +13,7 @@ from homeassistant.components.generic_hygrostat import ( ) from homeassistant.components.humidifier import ( ATTR_HUMIDITY, - DOMAIN, + DOMAIN as HUMIDIFIER_DOMAIN, MODE_AWAY, MODE_NORMAL, SERVICE_SET_HUMIDITY, @@ -107,7 +107,7 @@ async def test_humidifier_input_boolean(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -125,7 +125,7 @@ async def test_humidifier_input_boolean(hass: HomeAssistant) -> None: _setup_sensor(hass, 23) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 32}, blocking=True, @@ -151,7 +151,7 @@ async def test_humidifier_switch( assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -170,7 +170,7 @@ async def test_humidifier_switch( await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 32}, blocking=True, @@ -191,7 +191,7 @@ async def test_unique_id( await _setup_switch(hass, True) assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -222,7 +222,7 @@ async def setup_comp_0(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -248,7 +248,7 @@ async def setup_comp_2(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -269,7 +269,7 @@ async def test_unavailable_state(hass: HomeAssistant) -> None: """Test the setting of defaults to unknown.""" await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -296,7 +296,7 @@ async def test_setup_defaults_to_unknown(hass: HomeAssistant) -> None: """Test the setting of defaults to unknown.""" await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -345,7 +345,7 @@ async def test_get_modes(hass: HomeAssistant) -> None: async def test_set_target_humidity(hass: HomeAssistant) -> None: """Test the setting of the target humidity.""" await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 40}, blocking=True, @@ -355,7 +355,7 @@ async def test_set_target_humidity(hass: HomeAssistant) -> None: assert state.attributes.get("humidity") == 40 with pytest.raises(vol.Invalid): await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: None}, blocking=True, @@ -369,14 +369,14 @@ async def test_set_target_humidity(hass: HomeAssistant) -> None: async def test_set_away_mode(hass: HomeAssistant) -> None: """Test the setting away mode.""" await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, blocking=True, @@ -393,14 +393,14 @@ async def test_set_away_mode_and_restore_prev_humidity(hass: HomeAssistant) -> N Verify original humidity is restored. """ await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, blocking=True, @@ -409,7 +409,7 @@ async def test_set_away_mode_and_restore_prev_humidity(hass: HomeAssistant) -> N state = hass.states.get(ENTITY) assert state.attributes.get("humidity") == 35 await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_NORMAL}, blocking=True, @@ -428,21 +428,21 @@ async def test_set_away_mode_twice_and_restore_prev_humidity( Verify original humidity is restored. """ await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, blocking=True, @@ -451,7 +451,7 @@ async def test_set_away_mode_twice_and_restore_prev_humidity( state = hass.states.get(ENTITY) assert state.attributes.get("humidity") == 35 await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_NORMAL}, blocking=True, @@ -523,7 +523,7 @@ async def test_set_target_humidity_humidifier_on(hass: HomeAssistant) -> None: await hass.async_block_till_done() calls.clear() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 45}, blocking=True, @@ -544,7 +544,7 @@ async def test_set_target_humidity_humidifier_off(hass: HomeAssistant) -> None: await hass.async_block_till_done() calls.clear() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 36}, blocking=True, @@ -564,7 +564,7 @@ async def test_humidity_change_humidifier_on_within_tolerance( """Test if humidity change doesn't turn on within tolerance.""" calls = await _setup_switch(hass, False) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, blocking=True, @@ -582,7 +582,7 @@ async def test_humidity_change_humidifier_on_outside_tolerance( """Test if humidity change turn humidifier on outside dry tolerance.""" calls = await _setup_switch(hass, False) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, blocking=True, @@ -604,7 +604,7 @@ async def test_humidity_change_humidifier_off_within_tolerance( """Test if humidity change doesn't turn off within tolerance.""" calls = await _setup_switch(hass, True) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 46}, blocking=True, @@ -622,7 +622,7 @@ async def test_humidity_change_humidifier_off_outside_tolerance( """Test if humidity change turn humidifier off outside wet tolerance.""" calls = await _setup_switch(hass, True) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 46}, blocking=True, @@ -644,14 +644,14 @@ async def test_operation_mode_humidify(hass: HomeAssistant) -> None: Switch turns on when humidity below setpoint and mode changes. """ await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 45}, blocking=True, @@ -661,7 +661,7 @@ async def test_operation_mode_humidify(hass: HomeAssistant) -> None: await hass.async_block_till_done() calls = await _setup_switch(hass, False) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -696,7 +696,7 @@ async def setup_comp_3(hass: HomeAssistant) -> None: """Initialize components.""" assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -722,7 +722,7 @@ async def test_set_target_humidity_dry_off(hass: HomeAssistant) -> None: _setup_sensor(hass, 50) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 55}, blocking=True, @@ -743,14 +743,14 @@ async def test_turn_away_mode_on_drying(hass: HomeAssistant) -> None: _setup_sensor(hass, 50) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 34}, blocking=True, ) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, blocking=True, @@ -771,7 +771,7 @@ async def test_operation_mode_dry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(calls) == 0 await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -781,7 +781,7 @@ async def test_operation_mode_dry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(calls) == 0 await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -875,7 +875,7 @@ async def test_running_when_operating_mode_is_off_2(hass: HomeAssistant) -> None _setup_sensor(hass, 45) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -896,7 +896,7 @@ async def test_no_state_change_when_operation_mode_off_2(hass: HomeAssistant) -> _setup_sensor(hass, 30) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -913,7 +913,7 @@ async def setup_comp_4(hass: HomeAssistant) -> None: """Initialize components.""" assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1008,7 +1008,7 @@ async def test_mode_change_dry_trigger_off_not_long_enough(hass: HomeAssistant) await hass.async_block_till_done() assert len(calls) == 0 await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -1028,7 +1028,7 @@ async def test_mode_change_dry_trigger_on_not_long_enough(hass: HomeAssistant) - _setup_sensor(hass, 35) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -1038,7 +1038,7 @@ async def test_mode_change_dry_trigger_on_not_long_enough(hass: HomeAssistant) - await hass.async_block_till_done() assert len(calls) == 0 await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -1056,7 +1056,7 @@ async def setup_comp_6(hass: HomeAssistant) -> None: """Initialize components.""" assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1157,7 +1157,7 @@ async def test_mode_change_humidifier_trigger_off_not_long_enough( assert len(calls) == 0 await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -1181,7 +1181,7 @@ async def test_mode_change_humidifier_trigger_on_not_long_enough( assert len(calls) == 0 await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -1194,7 +1194,7 @@ async def test_mode_change_humidifier_trigger_on_not_long_enough( assert len(calls) == 0 await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY}, blocking=True, @@ -1212,7 +1212,7 @@ async def setup_comp_7(hass: HomeAssistant) -> None: """Initialize components.""" assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1279,7 +1279,7 @@ async def setup_comp_8(hass: HomeAssistant) -> None: """Initialize components.""" assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1344,7 +1344,7 @@ async def test_float_tolerance_values(hass: HomeAssistant) -> None: """Test if dehumidifier does not turn on within floating point tolerance.""" assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1370,7 +1370,7 @@ async def test_float_tolerance_values_2(hass: HomeAssistant) -> None: """Test if dehumidifier turns off when oudside of floating point tolerance values.""" assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1401,7 +1401,7 @@ async def test_custom_setup_params(hass: HomeAssistant) -> None: await hass.async_block_till_done() result = await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1441,7 +1441,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1479,7 +1479,7 @@ async def test_restore_state_target_humidity(hass: HomeAssistant) -> None: await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1522,7 +1522,7 @@ async def test_restore_state_and_return_to_normal(hass: HomeAssistant) -> None: await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1542,7 +1542,7 @@ async def test_restore_state_and_return_to_normal(hass: HomeAssistant) -> None: assert state.state == STATE_OFF await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_MODE: MODE_NORMAL}, blocking=True, @@ -1577,7 +1577,7 @@ async def test_no_restore_state(hass: HomeAssistant) -> None: await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1623,7 +1623,7 @@ async def test_restore_state_uncoherence_case(hass: HomeAssistant) -> None: async def _setup_humidifier(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1665,7 +1665,7 @@ async def test_away_fixed_humidity_mode(hass: HomeAssistant) -> None: await hass.async_block_till_done() await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1687,7 +1687,7 @@ async def test_away_fixed_humidity_mode(hass: HomeAssistant) -> None: # Switch to Away mode await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_MODE: MODE_AWAY}, blocking=True, @@ -1703,7 +1703,7 @@ async def test_away_fixed_humidity_mode(hass: HomeAssistant) -> None: # Change target humidity await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_HUMIDITY: 42}, blocking=True, @@ -1719,7 +1719,7 @@ async def test_away_fixed_humidity_mode(hass: HomeAssistant) -> None: # Return to Normal mode await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_MODE: MODE_NORMAL}, blocking=True, @@ -1750,7 +1750,7 @@ async def test_sensor_stale_duration( assert await async_setup_component( hass, - DOMAIN, + HUMIDIFIER_DOMAIN, { "humidifier": { "platform": "generic_hygrostat", @@ -1770,7 +1770,7 @@ async def test_sensor_stale_duration( assert hass.states.get(humidifier_switch).state == STATE_OFF await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 32}, blocking=True, @@ -1813,7 +1813,7 @@ async def test_sensor_stale_duration( # Manual turn off await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY}, blocking=True, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index f1c41270a2f..39435f154c4 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -11,7 +11,7 @@ from homeassistant import config as hass_config from homeassistant.components import input_boolean, switch from homeassistant.components.climate import ( ATTR_PRESET_MODE, - DOMAIN, + DOMAIN as CLIMATE_DOMAIN, PRESET_ACTIVITY, PRESET_AWAY, PRESET_COMFORT, @@ -122,7 +122,7 @@ async def test_heater_input_boolean(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -160,7 +160,7 @@ async def test_heater_switch( assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -192,7 +192,7 @@ async def test_unique_id( _setup_switch(hass, True) assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -221,7 +221,7 @@ async def setup_comp_2(hass: HomeAssistant) -> None: hass.config.units = METRIC_SYSTEM assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -248,7 +248,7 @@ async def test_setup_defaults_to_unknown(hass: HomeAssistant) -> None: hass.config.units = METRIC_SYSTEM await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -272,7 +272,7 @@ async def test_setup_gets_current_temp_from_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -618,7 +618,7 @@ async def setup_comp_3(hass: HomeAssistant) -> None: hass.config.temperature_unit = UnitOfTemperature.CELSIUS assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -774,7 +774,7 @@ async def _setup_thermostat_with_min_cycle_duration( hass.config.temperature_unit = UnitOfTemperature.CELSIUS assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -927,7 +927,7 @@ async def setup_comp_7(hass: HomeAssistant) -> None: hass.config.temperature_unit = UnitOfTemperature.CELSIUS assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -1002,7 +1002,7 @@ async def setup_comp_8(hass: HomeAssistant) -> None: hass.config.temperature_unit = UnitOfTemperature.CELSIUS assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -1076,7 +1076,7 @@ async def setup_comp_9(hass: HomeAssistant) -> None: """Initialize components.""" assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -1110,7 +1110,7 @@ async def test_custom_setup_params(hass: HomeAssistant) -> None: """Test the setup with custom parameters.""" result = await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -1151,7 +1151,7 @@ async def test_restore_state(hass: HomeAssistant, hvac_mode) -> None: await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -1189,7 +1189,7 @@ async def test_no_restore_state(hass: HomeAssistant) -> None: await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -1220,7 +1220,7 @@ async def test_initial_hvac_off_force_heater_off(hass: HomeAssistant) -> None: await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -1274,7 +1274,7 @@ async def test_restore_will_turn_off_(hass: HomeAssistant) -> None: await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -1319,7 +1319,7 @@ async def test_restore_will_turn_off_when_loaded_second(hass: HomeAssistant) -> await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -1379,7 +1379,7 @@ async def test_restore_state_uncoherence_case(hass: HomeAssistant) -> None: async def _setup_climate(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", @@ -1415,7 +1415,7 @@ async def test_reload(hass: HomeAssistant) -> None: assert await async_setup_component( hass, - DOMAIN, + CLIMATE_DOMAIN, { "climate": { "platform": "generic_thermostat", diff --git a/tests/components/goalzero/test_switch.py b/tests/components/goalzero/test_switch.py index de2e6035a12..b784cff05aa 100644 --- a/tests/components/goalzero/test_switch.py +++ b/tests/components/goalzero/test_switch.py @@ -1,7 +1,7 @@ """Switch tests for the Goalzero integration.""" from homeassistant.components.goalzero.const import DEFAULT_NAME -from homeassistant.components.switch import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -32,7 +32,7 @@ async def test_switches_states( text=load_fixture("goalzero/state_change.json"), ) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: [entity_id]}, blocking=True, @@ -44,7 +44,7 @@ async def test_switches_states( text=load_fixture("goalzero/state_data.json"), ) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: [entity_id]}, blocking=True, diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index 32372bebf37..ae2f0c74236 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -5,7 +5,7 @@ from datetime import timedelta from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.climate import DOMAIN, HVACMode +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode from homeassistant.components.gree.const import ( COORDINATORS, DOMAIN as GREE, @@ -18,8 +18,8 @@ from .common import async_setup_gree, build_device_mock from tests.common import async_fire_time_changed -ENTITY_ID_1 = f"{DOMAIN}.fake_device_1" -ENTITY_ID_2 = f"{DOMAIN}.fake_device_2" +ENTITY_ID_1 = f"{CLIMATE_DOMAIN}.fake_device_1" +ENTITY_ID_2 = f"{CLIMATE_DOMAIN}.fake_device_2" @pytest.fixture @@ -46,7 +46,7 @@ async def test_discovery_after_setup( await hass.async_block_till_done() assert discovery.return_value.scan_count == 1 - assert len(hass.states.async_all(DOMAIN)) == 2 + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 2 device_infos = [x.device.device_info for x in hass.data[GREE][COORDINATORS]] assert device_infos[0].ip == "1.1.1.1" @@ -68,7 +68,7 @@ async def test_discovery_after_setup( await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 - assert len(hass.states.async_all(DOMAIN)) == 2 + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 2 device_infos = [x.device.device_info for x in hass.data[GREE][COORDINATORS]] assert device_infos[0].ip == "1.1.1.2" @@ -82,7 +82,7 @@ async def test_coordinator_updates( await async_setup_gree(hass) await hass.async_block_till_done() - assert len(hass.states.async_all(DOMAIN)) == 1 + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 1 callback = device().add_handler.call_args_list[0][0][1] diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 1bf49bbca26..0cb187f5a60 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -21,7 +21,7 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_SWING_MODE, - DOMAIN, + DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -71,7 +71,7 @@ from .common import async_setup_gree, build_device_mock from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.fake_device_1" +ENTITY_ID = f"{CLIMATE_DOMAIN}.fake_device_1" async def test_discovery_called_once(hass: HomeAssistant, discovery, device) -> None: @@ -98,7 +98,7 @@ async def test_discovery_setup(hass: HomeAssistant, discovery, device) -> None: await async_setup_gree(hass) await hass.async_block_till_done() assert discovery.call_count == 1 - assert len(hass.states.async_all(DOMAIN)) == 2 + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 2 async def test_discovery_setup_connection_error( @@ -117,7 +117,7 @@ async def test_discovery_setup_connection_error( await async_setup_gree(hass) await hass.async_block_till_done() - assert len(hass.states.async_all(DOMAIN)) == 1 + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 1 state = hass.states.get(ENTITY_ID) assert state.name == "fake-device-1" assert state.state == STATE_UNAVAILABLE @@ -143,7 +143,7 @@ async def test_discovery_after_setup( await async_setup_gree(hass) # Update 1 assert discovery.return_value.scan_count == 1 - assert len(hass.states.async_all(DOMAIN)) == 2 + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 2 # rediscover the same devices shouldn't change anything discovery.return_value.mock_devices = [MockDevice1, MockDevice2] @@ -154,7 +154,7 @@ async def test_discovery_after_setup( await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 - assert len(hass.states.async_all(DOMAIN)) == 2 + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 2 async def test_discovery_add_device_after_setup( @@ -180,7 +180,7 @@ async def test_discovery_add_device_after_setup( await hass.async_block_till_done() assert discovery.return_value.scan_count == 1 - assert len(hass.states.async_all(DOMAIN)) == 1 + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 1 # rediscover the same devices shouldn't change anything discovery.return_value.mock_devices = [MockDevice2] @@ -191,7 +191,7 @@ async def test_discovery_add_device_after_setup( await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 - assert len(hass.states.async_all(DOMAIN)) == 2 + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 2 async def test_discovery_device_bind_after_setup( @@ -209,7 +209,7 @@ async def test_discovery_device_bind_after_setup( await async_setup_gree(hass) # Update 1 - assert len(hass.states.async_all(DOMAIN)) == 1 + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 1 state = hass.states.get(ENTITY_ID) assert state.name == "fake-device-1" assert state.state == STATE_UNAVAILABLE @@ -328,7 +328,7 @@ async def test_send_command_device_timeout( # Send failure should not raise exceptions or change device state await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, @@ -377,7 +377,7 @@ async def test_send_power_on(hass: HomeAssistant, discovery, device) -> None: await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, @@ -397,7 +397,7 @@ async def test_send_power_off_device_timeout( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True, @@ -439,7 +439,7 @@ async def test_send_target_temperature( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: temperature}, blocking=True, @@ -473,7 +473,7 @@ async def test_send_target_temperature_with_hvac_mode( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ENTITY_ID, @@ -509,7 +509,7 @@ async def test_send_target_temperature_device_timeout( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: temperature}, blocking=True, @@ -543,7 +543,7 @@ async def test_update_target_temperature( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: temperature}, blocking=True, @@ -565,7 +565,7 @@ async def test_send_preset_mode(hass: HomeAssistant, discovery, device, preset) await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset}, blocking=True, @@ -582,7 +582,7 @@ async def test_send_invalid_preset_mode(hass: HomeAssistant, discovery, device) with pytest.raises(ServiceValidationError): await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: "invalid"}, blocking=True, @@ -605,7 +605,7 @@ async def test_send_preset_mode_device_timeout( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: preset}, blocking=True, @@ -653,7 +653,7 @@ async def test_send_hvac_mode( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, blocking=True, @@ -677,7 +677,7 @@ async def test_send_hvac_mode_device_timeout( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: hvac_mode}, blocking=True, @@ -722,7 +722,7 @@ async def test_send_fan_mode(hass: HomeAssistant, discovery, device, fan_mode) - await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: fan_mode}, blocking=True, @@ -739,7 +739,7 @@ async def test_send_invalid_fan_mode(hass: HomeAssistant, discovery, device) -> with pytest.raises(ServiceValidationError): await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "invalid"}, blocking=True, @@ -763,7 +763,7 @@ async def test_send_fan_mode_device_timeout( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: fan_mode}, blocking=True, @@ -801,7 +801,7 @@ async def test_send_swing_mode( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: swing_mode}, blocking=True, @@ -818,7 +818,7 @@ async def test_send_invalid_swing_mode(hass: HomeAssistant, discovery, device) - with pytest.raises(ServiceValidationError): await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "invalid"}, blocking=True, @@ -841,7 +841,7 @@ async def test_send_swing_mode_device_timeout( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: swing_mode}, blocking=True, @@ -884,7 +884,7 @@ async def test_coordinator_update_handler( await async_setup_gree(hass) await hass.async_block_till_done() - entity: GreeClimateEntity = hass.data[DOMAIN].get_entity(ENTITY_ID) + entity: GreeClimateEntity = hass.data[CLIMATE_DOMAIN].get_entity(ENTITY_ID) assert entity is not None # Initial state @@ -911,7 +911,7 @@ async def test_coordinator_update_handler( assert entity.max_temp == TEMP_MAX -@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +@patch("homeassistant.components.gree.PLATFORMS", [CLIMATE_DOMAIN]) async def test_registry_settings( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -922,7 +922,7 @@ async def test_registry_settings( assert entries == snapshot -@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +@patch("homeassistant.components.gree.PLATFORMS", [CLIMATE_DOMAIN]) async def test_entity_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: """Test for entity registry settings (unique_id).""" await async_setup_gree(hass) diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index c5684abbf6f..e9491796bdf 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -7,7 +7,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.gree.const import DOMAIN as GREE_DOMAIN -from homeassistant.components.switch import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TOGGLE, @@ -22,23 +22,23 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -ENTITY_ID_LIGHT_PANEL = f"{DOMAIN}.fake_device_1_panel_light" -ENTITY_ID_HEALTH_MODE = f"{DOMAIN}.fake_device_1_health_mode" -ENTITY_ID_QUIET = f"{DOMAIN}.fake_device_1_quiet" -ENTITY_ID_FRESH_AIR = f"{DOMAIN}.fake_device_1_fresh_air" -ENTITY_ID_XFAN = f"{DOMAIN}.fake_device_1_xfan" +ENTITY_ID_LIGHT_PANEL = f"{SWITCH_DOMAIN}.fake_device_1_panel_light" +ENTITY_ID_HEALTH_MODE = f"{SWITCH_DOMAIN}.fake_device_1_health_mode" +ENTITY_ID_QUIET = f"{SWITCH_DOMAIN}.fake_device_1_quiet" +ENTITY_ID_FRESH_AIR = f"{SWITCH_DOMAIN}.fake_device_1_fresh_air" +ENTITY_ID_XFAN = f"{SWITCH_DOMAIN}.fake_device_1_xfan" async def async_setup_gree(hass: HomeAssistant) -> MockConfigEntry: """Set up the gree switch platform.""" entry = MockConfigEntry(domain=GREE_DOMAIN) entry.add_to_hass(hass) - await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {DOMAIN: {}}}) + await async_setup_component(hass, GREE_DOMAIN, {GREE_DOMAIN: {SWITCH_DOMAIN: {}}}) await hass.async_block_till_done() return entry -@patch("homeassistant.components.gree.PLATFORMS", [DOMAIN]) +@patch("homeassistant.components.gree.PLATFORMS", [SWITCH_DOMAIN]) async def test_registry_settings( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -67,7 +67,7 @@ async def test_send_switch_on(hass: HomeAssistant, entity: str) -> None: await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity}, blocking=True, @@ -98,7 +98,7 @@ async def test_send_switch_on_device_timeout( await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity}, blocking=True, @@ -125,7 +125,7 @@ async def test_send_switch_off(hass: HomeAssistant, entity: str) -> None: await async_setup_gree(hass) await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity}, blocking=True, @@ -153,7 +153,7 @@ async def test_send_switch_toggle(hass: HomeAssistant, entity: str) -> None: # Turn the service on first await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity}, blocking=True, @@ -165,7 +165,7 @@ async def test_send_switch_toggle(hass: HomeAssistant, entity: str) -> None: # Toggle it off await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity}, blocking=True, @@ -177,7 +177,7 @@ async def test_send_switch_toggle(hass: HomeAssistant, entity: str) -> None: # Toggle is back on await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity}, blocking=True, @@ -197,5 +197,5 @@ async def test_entity_state( """Test for entity registry settings (disabled_by, unique_id).""" await async_setup_gree(hass) - state = hass.states.async_all(DOMAIN) + state = hass.states.async_all(SWITCH_DOMAIN) assert state == snapshot diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index c687ca21e2d..f89aa9609cc 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, - DOMAIN, + DOMAIN as COVER_DOMAIN, ) from homeassistant.components.group.cover import DEFAULT_NAME from homeassistant.const import ( @@ -52,7 +52,7 @@ DEMO_COVER_TILT = "cover.living_room_window" DEMO_TILT = "cover.tilt_demo" CONFIG_ALL = { - DOMAIN: [ + COVER_DOMAIN: [ {"platform": "demo"}, { "platform": "group", @@ -62,7 +62,7 @@ CONFIG_ALL = { } CONFIG_POS = { - DOMAIN: [ + COVER_DOMAIN: [ {"platform": "demo"}, { "platform": "group", @@ -72,7 +72,7 @@ CONFIG_POS = { } CONFIG_TILT_ONLY = { - DOMAIN: [ + COVER_DOMAIN: [ {"platform": "demo"}, { "platform": "group", @@ -82,7 +82,7 @@ CONFIG_TILT_ONLY = { } CONFIG_ATTRIBUTES = { - DOMAIN: { + COVER_DOMAIN: { "platform": "group", CONF_ENTITIES: [DEMO_COVER, DEMO_COVER_POS, DEMO_COVER_TILT, DEMO_TILT], CONF_UNIQUE_ID: "unique_identifier", @@ -96,8 +96,8 @@ async def setup_comp( ) -> None: """Set up group cover component.""" config, count = config_count - with assert_setup_component(count, DOMAIN): - await async_setup_component(hass, DOMAIN, config) + with assert_setup_component(count, COVER_DOMAIN): + await async_setup_component(hass, COVER_DOMAIN, config) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() @@ -454,7 +454,7 @@ async def test_cover_that_only_supports_tilt_removed(hass: HomeAssistant) -> Non async def test_open_covers(hass: HomeAssistant) -> None: """Test open cover function.""" await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) for _ in range(10): @@ -476,7 +476,7 @@ async def test_open_covers(hass: HomeAssistant) -> None: async def test_close_covers(hass: HomeAssistant) -> None: """Test close cover function.""" await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) for _ in range(10): @@ -499,7 +499,7 @@ async def test_toggle_covers(hass: HomeAssistant) -> None: """Test toggle cover function.""" # Start covers in open state await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) for _ in range(10): future = dt_util.utcnow() + timedelta(seconds=1) @@ -511,7 +511,7 @@ async def test_toggle_covers(hass: HomeAssistant) -> None: # Toggle will close covers await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) for _ in range(10): future = dt_util.utcnow() + timedelta(seconds=1) @@ -528,7 +528,7 @@ async def test_toggle_covers(hass: HomeAssistant) -> None: # Toggle again will open covers await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) for _ in range(10): future = dt_util.utcnow() + timedelta(seconds=1) @@ -549,14 +549,14 @@ async def test_toggle_covers(hass: HomeAssistant) -> None: async def test_stop_covers(hass: HomeAssistant) -> None: """Test stop cover function.""" await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) @@ -576,7 +576,7 @@ async def test_stop_covers(hass: HomeAssistant) -> None: async def test_set_cover_position(hass: HomeAssistant) -> None: """Test set cover position function.""" await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: COVER_GROUP, ATTR_POSITION: 50}, blocking=True, @@ -600,7 +600,10 @@ async def test_set_cover_position(hass: HomeAssistant) -> None: async def test_open_tilts(hass: HomeAssistant) -> None: """Test open tilt function.""" await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, + blocking=True, ) for _ in range(5): future = dt_util.utcnow() + timedelta(seconds=1) @@ -621,7 +624,10 @@ async def test_open_tilts(hass: HomeAssistant) -> None: async def test_close_tilts(hass: HomeAssistant) -> None: """Test close tilt function.""" await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, + blocking=True, ) for _ in range(5): future = dt_util.utcnow() + timedelta(seconds=1) @@ -641,7 +647,10 @@ async def test_toggle_tilts(hass: HomeAssistant) -> None: """Test toggle tilt function.""" # Start tilted open await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, + blocking=True, ) for _ in range(10): future = dt_util.utcnow() + timedelta(seconds=1) @@ -658,7 +667,10 @@ async def test_toggle_tilts(hass: HomeAssistant) -> None: # Toggle will tilt closed await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE_COVER_TILT, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, + blocking=True, ) for _ in range(10): future = dt_util.utcnow() + timedelta(seconds=1) @@ -673,7 +685,10 @@ async def test_toggle_tilts(hass: HomeAssistant) -> None: # Toggle again will tilt open await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE_COVER_TILT, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, + blocking=True, ) for _ in range(10): future = dt_util.utcnow() + timedelta(seconds=1) @@ -694,14 +709,20 @@ async def test_toggle_tilts(hass: HomeAssistant) -> None: async def test_stop_tilts(hass: HomeAssistant) -> None: """Test stop tilts function.""" await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, + blocking=True, ) future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER_TILT, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: COVER_GROUP}, + blocking=True, ) future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) @@ -719,7 +740,7 @@ async def test_stop_tilts(hass: HomeAssistant) -> None: async def test_set_tilt_positions(hass: HomeAssistant) -> None: """Test set tilt position function.""" await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, {ATTR_ENTITY_ID: COVER_GROUP, ATTR_TILT_POSITION: 80}, blocking=True, @@ -741,7 +762,7 @@ async def test_set_tilt_positions(hass: HomeAssistant) -> None: async def test_is_opening_closing(hass: HomeAssistant) -> None: """Test is_opening property.""" await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) await hass.async_block_till_done() @@ -756,7 +777,7 @@ async def test_is_opening_closing(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True ) # Both covers closing -> closing @@ -814,9 +835,9 @@ async def test_nested_group(hass: HomeAssistant) -> None: """Test nested cover group.""" await async_setup_component( hass, - DOMAIN, + COVER_DOMAIN, { - DOMAIN: [ + COVER_DOMAIN: [ {"platform": "demo"}, { "platform": "group", @@ -848,7 +869,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: # Test controlling the nested group async with asyncio.timeout(0.5): await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: "cover.nested_group"}, blocking=True, diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 184693f7618..93509b5a651 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -14,7 +14,7 @@ from homeassistant.components.fan import ( ATTR_PERCENTAGE_STEP, DIRECTION_FORWARD, DIRECTION_REVERSE, - DOMAIN, + DOMAIN as FAN_DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, @@ -60,7 +60,7 @@ FULL_SUPPORT_FEATURES = ( CONFIG_MISSING_FAN = { - DOMAIN: [ + FAN_DOMAIN: [ {"platform": "demo"}, { "platform": "group", @@ -74,7 +74,7 @@ CONFIG_MISSING_FAN = { } CONFIG_FULL_SUPPORT = { - DOMAIN: [ + FAN_DOMAIN: [ {"platform": "demo"}, { "platform": "group", @@ -84,7 +84,7 @@ CONFIG_FULL_SUPPORT = { } CONFIG_LIMITED_SUPPORT = { - DOMAIN: [ + FAN_DOMAIN: [ { "platform": "group", CONF_ENTITIES: [*LIMITED_FAN_ENTITY_IDS], @@ -94,7 +94,7 @@ CONFIG_LIMITED_SUPPORT = { CONFIG_ATTRIBUTES = { - DOMAIN: { + FAN_DOMAIN: { "platform": "group", CONF_ENTITIES: [*FULL_FAN_ENTITY_IDS, *LIMITED_FAN_ENTITY_IDS], CONF_UNIQUE_ID: "unique_identifier", @@ -108,8 +108,8 @@ async def setup_comp( ) -> None: """Set up group fan component.""" config, count = config_count - with assert_setup_component(count, DOMAIN): - await async_setup_component(hass, DOMAIN, config) + with assert_setup_component(count, FAN_DOMAIN): + await async_setup_component(hass, FAN_DOMAIN, config) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() @@ -393,7 +393,7 @@ async def test_state_missing_entity_id(hass: HomeAssistant) -> None: async def test_setup_before_started(hass: HomeAssistant) -> None: """Test we can setup before starting.""" hass.set_state(CoreState.stopped) - assert await async_setup_component(hass, DOMAIN, CONFIG_MISSING_FAN) + assert await async_setup_component(hass, FAN_DOMAIN, CONFIG_MISSING_FAN) await hass.async_block_till_done() await hass.async_start() @@ -431,14 +431,14 @@ async def test_reload(hass: HomeAssistant) -> None: async def test_service_calls(hass: HomeAssistant) -> None: """Test calling services.""" await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_GROUP}, blocking=True + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_GROUP}, blocking=True ) assert hass.states.get(LIVING_ROOM_FAN_ENTITY_ID).state == STATE_ON assert hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID).state == STATE_ON assert hass.states.get(FAN_GROUP).state == STATE_ON await hass.services.async_call( - DOMAIN, + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_GROUP, ATTR_PERCENTAGE: 66}, blocking=True, @@ -452,14 +452,14 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert fan_group_state.attributes[ATTR_PERCENTAGE_STEP] == 100 / 3 await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: FAN_GROUP}, blocking=True + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: FAN_GROUP}, blocking=True ) assert hass.states.get(LIVING_ROOM_FAN_ENTITY_ID).state == STATE_OFF assert hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID).state == STATE_OFF assert hass.states.get(FAN_GROUP).state == STATE_OFF await hass.services.async_call( - DOMAIN, + FAN_DOMAIN, SERVICE_SET_PERCENTAGE, {ATTR_ENTITY_ID: FAN_GROUP, ATTR_PERCENTAGE: 100}, blocking=True, @@ -472,7 +472,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert fan_group_state.attributes[ATTR_PERCENTAGE] == 100 await hass.services.async_call( - DOMAIN, + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_GROUP, ATTR_PERCENTAGE: 0}, blocking=True, @@ -482,7 +482,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert hass.states.get(FAN_GROUP).state == STATE_OFF await hass.services.async_call( - DOMAIN, + FAN_DOMAIN, SERVICE_OSCILLATE, {ATTR_ENTITY_ID: FAN_GROUP, ATTR_OSCILLATING: True}, blocking=True, @@ -495,7 +495,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert fan_group_state.attributes[ATTR_OSCILLATING] is True await hass.services.async_call( - DOMAIN, + FAN_DOMAIN, SERVICE_OSCILLATE, {ATTR_ENTITY_ID: FAN_GROUP, ATTR_OSCILLATING: False}, blocking=True, @@ -508,7 +508,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert fan_group_state.attributes[ATTR_OSCILLATING] is False await hass.services.async_call( - DOMAIN, + FAN_DOMAIN, SERVICE_SET_DIRECTION, {ATTR_ENTITY_ID: FAN_GROUP, ATTR_DIRECTION: DIRECTION_FORWARD}, blocking=True, @@ -521,7 +521,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert fan_group_state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD await hass.services.async_call( - DOMAIN, + FAN_DOMAIN, SERVICE_SET_DIRECTION, {ATTR_ENTITY_ID: FAN_GROUP, ATTR_DIRECTION: DIRECTION_REVERSE}, blocking=True, @@ -538,9 +538,9 @@ async def test_nested_group(hass: HomeAssistant) -> None: """Test nested fan group.""" await async_setup_component( hass, - DOMAIN, + FAN_DOMAIN, { - DOMAIN: [ + FAN_DOMAIN: [ {"platform": "demo"}, { "platform": "group", @@ -578,7 +578,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: # Test controlling the nested group async with asyncio.timeout(0.5): await hass.services.async_call( - DOMAIN, + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "fan.nested_group"}, blocking=True, From aab939cf6cdfed1b8dbe38021a00b704d6967b22 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:20:40 +0200 Subject: [PATCH 0444/1309] Add alias to DOMAIN import in tests [a-d] (#125573) --- tests/components/accuweather/test_init.py | 4 +- .../test_device_tracker.py | 38 ++++++++---- tests/components/comfoconnect/test_sensor.py | 8 +-- tests/components/demo/test_button.py | 8 ++- tests/components/demo/test_climate.py | 49 ++++++++------- tests/components/demo/test_cover.py | 59 +++++++++++++------ tests/components/demo/test_date.py | 12 +++- tests/components/demo/test_datetime.py | 12 +++- tests/components/demo/test_humidifier.py | 47 +++++++++++---- tests/components/demo/test_number.py | 12 ++-- tests/components/demo/test_select.py | 10 ++-- tests/components/demo/test_siren.py | 24 ++++---- tests/components/demo/test_text.py | 8 ++- tests/components/demo/test_time.py | 12 +++- tests/components/demo/test_update.py | 10 ++-- tests/components/demo/test_vacuum.py | 24 ++++---- .../device_sun_light_trigger/test_init.py | 12 ++-- .../devolo_home_control/test_binary_sensor.py | 37 +++++++----- .../devolo_home_control/test_climate.py | 16 ++--- .../devolo_home_control/test_cover.py | 28 +++++---- .../devolo_home_control/test_light.py | 42 ++++++------- .../devolo_home_control/test_sensor.py | 43 ++++++++------ .../devolo_home_control/test_siren.py | 28 ++++----- .../devolo_home_control/test_switch.py | 20 +++---- .../devolo_home_network/test_binary_sensor.py | 9 ++- .../devolo_home_network/test_image.py | 8 ++- tests/components/doorbird/test_button.py | 8 +-- 27 files changed, 353 insertions(+), 235 deletions(-) diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index 340676905d6..f88cde88e7e 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -10,7 +10,7 @@ from homeassistant.components.accuweather.const import ( UPDATE_INTERVAL_DAILY_FORECAST, UPDATE_INTERVAL_OBSERVATION, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -107,7 +107,7 @@ async def test_remove_ozone_sensors( ) -> None: """Test remove ozone sensors from registry.""" entity_registry.async_get_or_create( - SENSOR_PLATFORM, + SENSOR_DOMAIN, DOMAIN, "0123456-ozone-0", suggested_object_id="home_ozone_0d", diff --git a/tests/components/bluetooth_le_tracker/test_device_tracker.py b/tests/components/bluetooth_le_tracker/test_device_tracker.py index 452297e38c2..da90980640b 100644 --- a/tests/components/bluetooth_le_tracker/test_device_tracker.py +++ b/tests/components/bluetooth_le_tracker/test_device_tracker.py @@ -18,7 +18,7 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, CONF_SCAN_INTERVAL, CONF_TRACK_NEW, - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, ) from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant @@ -73,7 +73,7 @@ async def test_do_not_see_device_if_time_not_updated(hass: HomeAssistant) -> Non address = "DE:AD:BE:EF:13:37" name = "Mock device name" - entity_id = f"{DOMAIN}.{slugify(name)}" + entity_id = f"{DEVICE_TRACKER_DOMAIN}.{slugify(name)}" with patch( "homeassistant.components.bluetooth.async_discovered_service_info" @@ -101,7 +101,9 @@ async def test_do_not_see_device_if_time_not_updated(hass: HomeAssistant) -> Non CONF_TRACK_NEW: True, CONF_CONSIDER_HOME: timedelta(minutes=10), } - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + result = await async_setup_component( + hass, DEVICE_TRACKER_DOMAIN, {DEVICE_TRACKER_DOMAIN: config} + ) await hass.async_block_till_done() assert result @@ -136,7 +138,7 @@ async def test_see_device_if_time_updated(hass: HomeAssistant) -> None: address = "DE:AD:BE:EF:13:37" name = "Mock device name" - entity_id = f"{DOMAIN}.{slugify(name)}" + entity_id = f"{DEVICE_TRACKER_DOMAIN}.{slugify(name)}" with patch( "homeassistant.components.bluetooth.async_discovered_service_info" @@ -164,7 +166,9 @@ async def test_see_device_if_time_updated(hass: HomeAssistant) -> None: CONF_TRACK_NEW: True, CONF_CONSIDER_HOME: timedelta(minutes=10), } - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + result = await async_setup_component( + hass, DEVICE_TRACKER_DOMAIN, {DEVICE_TRACKER_DOMAIN: config} + ) assert result # Tick until device seen enough times for to be registered for tracking @@ -215,7 +219,7 @@ async def test_preserve_new_tracked_device_name(hass: HomeAssistant) -> None: address = "DE:AD:BE:EF:13:37" name = "Mock device name" - entity_id = f"{DOMAIN}.{slugify(name)}" + entity_id = f"{DEVICE_TRACKER_DOMAIN}.{slugify(name)}" with patch( "homeassistant.components.bluetooth.async_discovered_service_info" @@ -242,7 +246,9 @@ async def test_preserve_new_tracked_device_name(hass: HomeAssistant) -> None: CONF_SCAN_INTERVAL: timedelta(minutes=1), CONF_TRACK_NEW: True, } - assert await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + assert await async_setup_component( + hass, DEVICE_TRACKER_DOMAIN, {DEVICE_TRACKER_DOMAIN: config} + ) await hass.async_block_till_done() # Seen once here; return without name when seen subsequent times @@ -282,7 +288,7 @@ async def test_tracking_battery_times_out(hass: HomeAssistant) -> None: address = "DE:AD:BE:EF:13:37" name = "Mock device name" - entity_id = f"{DOMAIN}.{slugify(name)}" + entity_id = f"{DEVICE_TRACKER_DOMAIN}.{slugify(name)}" with patch( "homeassistant.components.bluetooth.async_discovered_service_info" @@ -311,7 +317,9 @@ async def test_tracking_battery_times_out(hass: HomeAssistant) -> None: CONF_TRACK_BATTERY_INTERVAL: timedelta(minutes=2), CONF_TRACK_NEW: True, } - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + result = await async_setup_component( + hass, DEVICE_TRACKER_DOMAIN, {DEVICE_TRACKER_DOMAIN: config} + ) await hass.async_block_till_done() assert result @@ -348,7 +356,7 @@ async def test_tracking_battery_fails(hass: HomeAssistant) -> None: address = "DE:AD:BE:EF:13:37" name = "Mock device name" - entity_id = f"{DOMAIN}.{slugify(name)}" + entity_id = f"{DEVICE_TRACKER_DOMAIN}.{slugify(name)}" with patch( "homeassistant.components.bluetooth.async_discovered_service_info" @@ -377,7 +385,9 @@ async def test_tracking_battery_fails(hass: HomeAssistant) -> None: CONF_TRACK_BATTERY_INTERVAL: timedelta(minutes=2), CONF_TRACK_NEW: True, } - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + result = await async_setup_component( + hass, DEVICE_TRACKER_DOMAIN, {DEVICE_TRACKER_DOMAIN: config} + ) assert result # Tick until device seen enough times for to be registered for tracking @@ -413,7 +423,7 @@ async def test_tracking_battery_successful(hass: HomeAssistant) -> None: address = "DE:AD:BE:EF:13:37" name = "Mock device name" - entity_id = f"{DOMAIN}.{slugify(name)}" + entity_id = f"{DEVICE_TRACKER_DOMAIN}.{slugify(name)}" with patch( "homeassistant.components.bluetooth.async_discovered_service_info" @@ -442,7 +452,9 @@ async def test_tracking_battery_successful(hass: HomeAssistant) -> None: CONF_TRACK_BATTERY_INTERVAL: timedelta(minutes=2), CONF_TRACK_NEW: True, } - result = await async_setup_component(hass, DOMAIN, {DOMAIN: config}) + result = await async_setup_component( + hass, DEVICE_TRACKER_DOMAIN, {DEVICE_TRACKER_DOMAIN: config} + ) await hass.async_block_till_done() assert result diff --git a/tests/components/comfoconnect/test_sensor.py b/tests/components/comfoconnect/test_sensor.py index fdecfa5b1c7..5cae566379a 100644 --- a/tests/components/comfoconnect/test_sensor.py +++ b/tests/components/comfoconnect/test_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -14,7 +14,7 @@ from tests.common import assert_setup_component COMPONENT = "comfoconnect" VALID_CONFIG = { COMPONENT: {"host": "1.2.3.4"}, - DOMAIN: { + SENSOR_DOMAIN: { "platform": COMPONENT, "resources": [ "current_humidity", @@ -51,8 +51,8 @@ async def setup_sensor( mock_comfoconnect_command: MagicMock, ) -> None: """Set up demo sensor component.""" - with assert_setup_component(1, DOMAIN): - await async_setup_component(hass, DOMAIN, VALID_CONFIG) + with assert_setup_component(1, SENSOR_DOMAIN): + await async_setup_component(hass, SENSOR_DOMAIN, VALID_CONFIG) await hass.async_block_till_done() diff --git a/tests/components/demo/test_button.py b/tests/components/demo/test_button.py index 6049de12570..702ee3aa3e0 100644 --- a/tests/components/demo/test_button.py +++ b/tests/components/demo/test_button.py @@ -5,7 +5,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.button import DOMAIN, SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,7 +27,9 @@ async def button_only() -> None: @pytest.fixture(autouse=True) async def setup_demo_button(hass: HomeAssistant, button_only) -> None: """Initialize setup demo button entity.""" - assert await async_setup_component(hass, DOMAIN, {"button": {"platform": "demo"}}) + assert await async_setup_component( + hass, BUTTON_DOMAIN, {"button": {"platform": "demo"}} + ) await hass.async_block_till_done() @@ -47,7 +49,7 @@ async def test_press(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> Non now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") freezer.move_to(now) await hass.services.async_call( - DOMAIN, + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: ENTITY_PUSH}, blocking=True, diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index 383e00834b8..42152645ecb 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -22,7 +22,7 @@ from homeassistant.components.climate import ( ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - DOMAIN, + DOMAIN as CLIMATE_DOMAIN, PRESET_AWAY, PRESET_ECO, SERVICE_SET_FAN_MODE, @@ -64,7 +64,9 @@ def climate_only() -> Generator[None]: async def setup_demo_climate(hass: HomeAssistant, climate_only: None) -> None: """Initialize setup demo climate.""" hass.config.units = METRIC_SYSTEM - assert await async_setup_component(hass, DOMAIN, {"climate": {"platform": "demo"}}) + assert await async_setup_component( + hass, CLIMATE_DOMAIN, {"climate": {"platform": "demo"}} + ) await hass.async_block_till_done() @@ -104,7 +106,7 @@ async def test_set_only_target_temp_bad_attr(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_TEMPERATURE: None}, blocking=True, @@ -120,7 +122,7 @@ async def test_set_only_target_temp(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_TEMPERATURE) == 21 await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_TEMPERATURE: 30}, blocking=True, @@ -136,7 +138,7 @@ async def test_set_only_target_temp_with_convert(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_TEMPERATURE) == 20 await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ENTITY_HEATPUMP, ATTR_TEMPERATURE: 21}, blocking=True, @@ -154,7 +156,7 @@ async def test_set_target_temp_range(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 24.0 await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ENTITY_ECOBEE, @@ -179,7 +181,7 @@ async def test_set_target_temp_range_bad_attr(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ENTITY_ECOBEE, @@ -202,7 +204,7 @@ async def test_set_temp_with_hvac_mode(hass: HomeAssistant) -> None: assert state.state == HVACMode.COOL await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: ENTITY_CLIMATE, @@ -224,7 +226,7 @@ async def test_set_target_humidity_bad_attr(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_HUMIDITY: None}, blocking=True, @@ -240,7 +242,7 @@ async def test_set_target_humidity(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_HUMIDITY) == 67.4 await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_HUMIDITY: 64}, blocking=True, @@ -257,7 +259,7 @@ async def test_set_fan_mode_bad_attr(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_FAN_MODE: None}, blocking=True, @@ -273,7 +275,7 @@ async def test_set_fan_mode(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_FAN_MODE) == "on_high" await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_FAN_MODE: "on_low"}, blocking=True, @@ -290,7 +292,7 @@ async def test_set_swing_mode_bad_attr(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_SWING_MODE: None}, blocking=True, @@ -306,7 +308,7 @@ async def test_set_swing(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_SWING_MODE) == "off" await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_SWING_MODE: "auto"}, blocking=True, @@ -327,7 +329,7 @@ async def test_set_hvac_bad_attr_and_state(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_HVAC_MODE: None}, blocking=True, @@ -344,7 +346,7 @@ async def test_set_hvac(hass: HomeAssistant) -> None: assert state.state == HVACMode.COOL await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, @@ -357,7 +359,7 @@ async def test_set_hvac(hass: HomeAssistant) -> None: async def test_set_hold_mode_away(hass: HomeAssistant) -> None: """Test setting the hold mode away.""" await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ECOBEE, ATTR_PRESET_MODE: PRESET_AWAY}, blocking=True, @@ -370,7 +372,7 @@ async def test_set_hold_mode_away(hass: HomeAssistant) -> None: async def test_set_hold_mode_eco(hass: HomeAssistant) -> None: """Test setting the hold mode eco.""" await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, {ATTR_ENTITY_ID: ENTITY_ECOBEE, ATTR_PRESET_MODE: PRESET_ECO}, blocking=True, @@ -383,7 +385,7 @@ async def test_set_hold_mode_eco(hass: HomeAssistant) -> None: async def test_turn_on(hass: HomeAssistant) -> None: """Test turn on device.""" await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, @@ -393,7 +395,7 @@ async def test_turn_on(hass: HomeAssistant) -> None: assert state.state == HVACMode.OFF await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_CLIMATE}, blocking=True + CLIMATE_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_CLIMATE}, blocking=True ) state = hass.states.get(ENTITY_CLIMATE) assert state.state == HVACMode.HEAT @@ -402,7 +404,7 @@ async def test_turn_on(hass: HomeAssistant) -> None: async def test_turn_off(hass: HomeAssistant) -> None: """Test turn on device.""" await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, @@ -412,7 +414,10 @@ async def test_turn_off(hass: HomeAssistant) -> None: assert state.state == HVACMode.HEAT await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_CLIMATE}, blocking=True + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_CLIMATE}, + blocking=True, ) state = hass.states.get(ENTITY_CLIMATE) assert state.state == HVACMode.OFF diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py index 009d2ca2f49..abbbbf0b79a 100644 --- a/tests/components/demo/test_cover.py +++ b/tests/components/demo/test_cover.py @@ -11,7 +11,7 @@ from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, - DOMAIN, + DOMAIN as COVER_DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -55,8 +55,8 @@ def cover_only() -> Generator[None]: @pytest.fixture(autouse=True) async def setup_comp(hass: HomeAssistant, cover_only: None) -> None: """Set up demo cover component.""" - with assert_setup_component(1, DOMAIN): - await async_setup_component(hass, DOMAIN, CONFIG) + with assert_setup_component(1, COVER_DOMAIN): + await async_setup_component(hass, COVER_DOMAIN, CONFIG) await hass.async_block_till_done() @@ -79,7 +79,7 @@ async def test_close_cover(hass: HomeAssistant) -> None: assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) state = hass.states.get(ENTITY_COVER) assert state.state == STATE_CLOSING @@ -99,7 +99,7 @@ async def test_open_cover(hass: HomeAssistant) -> None: assert state.state == STATE_OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) state = hass.states.get(ENTITY_COVER) assert state.state == STATE_OPENING @@ -117,7 +117,7 @@ async def test_toggle_cover(hass: HomeAssistant) -> None: """Test toggling the cover.""" # Start open await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) @@ -129,7 +129,7 @@ async def test_toggle_cover(hass: HomeAssistant) -> None: assert state.attributes["current_position"] == 100 # Toggle closed await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) for _ in range(10): future = dt_util.utcnow() + timedelta(seconds=1) @@ -141,7 +141,7 @@ async def test_toggle_cover(hass: HomeAssistant) -> None: assert state.attributes[ATTR_CURRENT_POSITION] == 0 # Toggle open await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) for _ in range(10): future = dt_util.utcnow() + timedelta(seconds=1) @@ -158,7 +158,7 @@ async def test_set_cover_position(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_COVER) assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 10}, blocking=True, @@ -177,13 +177,13 @@ async def test_stop_cover(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_COVER) assert state.attributes[ATTR_CURRENT_POSITION] == 70 await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True ) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -196,7 +196,10 @@ async def test_close_cover_tilt(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_COVER) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, + blocking=True, ) for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) @@ -212,7 +215,10 @@ async def test_open_cover_tilt(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_COVER) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, + blocking=True, ) for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) @@ -227,7 +233,10 @@ async def test_toggle_cover_tilt(hass: HomeAssistant) -> None: """Test toggling the cover tilt.""" # Start open await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, + blocking=True, ) for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) @@ -238,7 +247,10 @@ async def test_toggle_cover_tilt(hass: HomeAssistant) -> None: assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 # Toggle closed await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, + blocking=True, ) for _ in range(10): future = dt_util.utcnow() + timedelta(seconds=1) @@ -249,7 +261,10 @@ async def test_toggle_cover_tilt(hass: HomeAssistant) -> None: assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 # Toggle Open await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, + blocking=True, ) for _ in range(10): future = dt_util.utcnow() + timedelta(seconds=1) @@ -265,7 +280,7 @@ async def test_set_cover_tilt_position(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_COVER) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 90}, blocking=True, @@ -284,13 +299,19 @@ async def test_stop_cover_tilt(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_COVER) assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, + blocking=True, ) future = dt_util.utcnow() + timedelta(seconds=1) async_fire_time_changed(hass, future) await hass.async_block_till_done() await hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER_TILT, {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, + blocking=True, ) async_fire_time_changed(hass, future) await hass.async_block_till_done() diff --git a/tests/components/demo/test_date.py b/tests/components/demo/test_date.py index 5e0fc2c29cd..228be936599 100644 --- a/tests/components/demo/test_date.py +++ b/tests/components/demo/test_date.py @@ -4,7 +4,11 @@ from unittest.mock import patch import pytest -from homeassistant.components.date import ATTR_DATE, DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.date import ( + ATTR_DATE, + DOMAIN as DATE_DOMAIN, + SERVICE_SET_VALUE, +) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -25,7 +29,9 @@ async def date_only() -> None: @pytest.fixture(autouse=True) async def setup_demo_date(hass: HomeAssistant, date_only) -> None: """Initialize setup demo date.""" - assert await async_setup_component(hass, DOMAIN, {"date": {"platform": "demo"}}) + assert await async_setup_component( + hass, DATE_DOMAIN, {"date": {"platform": "demo"}} + ) await hass.async_block_till_done() @@ -38,7 +44,7 @@ def test_setup_params(hass: HomeAssistant) -> None: async def test_set_datetime(hass: HomeAssistant) -> None: """Test set datetime service.""" await hass.services.async_call( - DOMAIN, + DATE_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: ENTITY_DATE, ATTR_DATE: "2021-02-03"}, blocking=True, diff --git a/tests/components/demo/test_datetime.py b/tests/components/demo/test_datetime.py index bd4adafd695..82cd5044068 100644 --- a/tests/components/demo/test_datetime.py +++ b/tests/components/demo/test_datetime.py @@ -4,7 +4,11 @@ from unittest.mock import patch import pytest -from homeassistant.components.datetime import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.datetime import ( + ATTR_DATETIME, + DOMAIN as DATETIME_DOMAIN, + SERVICE_SET_VALUE, +) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -25,7 +29,9 @@ async def datetime_only() -> None: @pytest.fixture(autouse=True) async def setup_demo_datetime(hass: HomeAssistant, datetime_only) -> None: """Initialize setup demo datetime.""" - assert await async_setup_component(hass, DOMAIN, {"datetime": {"platform": "demo"}}) + assert await async_setup_component( + hass, DATETIME_DOMAIN, {"datetime": {"platform": "demo"}} + ) await hass.async_block_till_done() @@ -39,7 +45,7 @@ async def test_set_datetime(hass: HomeAssistant) -> None: """Test set datetime service.""" await hass.config.async_set_time_zone("UTC") await hass.services.async_call( - DOMAIN, + DATETIME_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: ENTITY_DATETIME, ATTR_DATETIME: "2021-02-03 01:02:03"}, blocking=True, diff --git a/tests/components/demo/test_humidifier.py b/tests/components/demo/test_humidifier.py index 0f0fcaf43fd..93bd2b13743 100644 --- a/tests/components/demo/test_humidifier.py +++ b/tests/components/demo/test_humidifier.py @@ -11,7 +11,7 @@ from homeassistant.components.humidifier import ( ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, - DOMAIN, + DOMAIN as HUMIDITY_DOMAIN, MODE_AWAY, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, @@ -48,7 +48,7 @@ async def humidifier_only() -> None: async def setup_demo_humidifier(hass: HomeAssistant, humidifier_only: None): """Initialize setup demo humidifier.""" assert await async_setup_component( - hass, DOMAIN, {"humidifier": {"platform": "demo"}} + hass, HUMIDITY_DOMAIN, {"humidifier": {"platform": "demo"}} ) await hass.async_block_till_done() @@ -76,7 +76,7 @@ async def test_set_target_humidity_bad_attr(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): await hass.services.async_call( - DOMAIN, + HUMIDITY_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_HUMIDITY: None, ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True, @@ -93,7 +93,7 @@ async def test_set_target_humidity(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_HUMIDITY) == 54.2 await hass.services.async_call( - DOMAIN, + HUMIDITY_DOMAIN, SERVICE_SET_HUMIDITY, {ATTR_HUMIDITY: 64, ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True, @@ -107,7 +107,7 @@ async def test_set_target_humidity(hass: HomeAssistant) -> None: async def test_set_hold_mode_away(hass: HomeAssistant) -> None: """Test setting the hold mode away.""" await hass.services.async_call( - DOMAIN, + HUMIDITY_DOMAIN, SERVICE_SET_MODE, {ATTR_MODE: MODE_AWAY, ATTR_ENTITY_ID: ENTITY_HYGROSTAT}, blocking=True, @@ -121,7 +121,7 @@ async def test_set_hold_mode_away(hass: HomeAssistant) -> None: async def test_set_hold_mode_eco(hass: HomeAssistant) -> None: """Test setting the hold mode eco.""" await hass.services.async_call( - DOMAIN, + HUMIDITY_DOMAIN, SERVICE_SET_MODE, {ATTR_MODE: "eco", ATTR_ENTITY_ID: ENTITY_HYGROSTAT}, blocking=True, @@ -135,14 +135,20 @@ async def test_set_hold_mode_eco(hass: HomeAssistant) -> None: async def test_turn_on(hass: HomeAssistant) -> None: """Test turn on device.""" await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + HUMIDITY_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, + blocking=True, ) state = hass.states.get(ENTITY_DEHUMIDIFIER) assert state.state == STATE_OFF assert state.attributes.get(ATTR_ACTION) == "off" await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + HUMIDITY_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, + blocking=True, ) state = hass.states.get(ENTITY_DEHUMIDIFIER) assert state.state == STATE_ON @@ -152,14 +158,20 @@ async def test_turn_on(hass: HomeAssistant) -> None: async def test_turn_off(hass: HomeAssistant) -> None: """Test turn off device.""" await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + HUMIDITY_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, + blocking=True, ) state = hass.states.get(ENTITY_DEHUMIDIFIER) assert state.state == STATE_ON assert state.attributes.get(ATTR_ACTION) == "drying" await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + HUMIDITY_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, + blocking=True, ) state = hass.states.get(ENTITY_DEHUMIDIFIER) assert state.state == STATE_OFF @@ -169,19 +181,28 @@ async def test_turn_off(hass: HomeAssistant) -> None: async def test_toggle(hass: HomeAssistant) -> None: """Test toggle device.""" await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + HUMIDITY_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, + blocking=True, ) state = hass.states.get(ENTITY_DEHUMIDIFIER) assert state.state == STATE_ON await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + HUMIDITY_DOMAIN, + SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, + blocking=True, ) state = hass.states.get(ENTITY_DEHUMIDIFIER) assert state.state == STATE_OFF await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, blocking=True + HUMIDITY_DOMAIN, + SERVICE_TOGGLE, + {ATTR_ENTITY_ID: ENTITY_DEHUMIDIFIER}, + blocking=True, ) state = hass.states.get(ENTITY_DEHUMIDIFIER) assert state.state == STATE_ON diff --git a/tests/components/demo/test_number.py b/tests/components/demo/test_number.py index 79885fa8581..4b7cbe4864f 100644 --- a/tests/components/demo/test_number.py +++ b/tests/components/demo/test_number.py @@ -11,7 +11,7 @@ from homeassistant.components.number import ( ATTR_MIN, ATTR_STEP, ATTR_VALUE, - DOMAIN, + DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, NumberMode, ) @@ -39,7 +39,9 @@ def number_only() -> Generator[None]: @pytest.fixture(autouse=True) async def setup_demo_number(hass: HomeAssistant, number_only: None) -> None: """Initialize setup demo Number entity.""" - assert await async_setup_component(hass, DOMAIN, {"number": {"platform": "demo"}}) + assert await async_setup_component( + hass, NUMBER_DOMAIN, {"number": {"platform": "demo"}} + ) await hass.async_block_till_done() @@ -83,7 +85,7 @@ async def test_set_value_bad_attr(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): await hass.services.async_call( - DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: None, ATTR_ENTITY_ID: ENTITY_VOLUME}, blocking=True, @@ -101,7 +103,7 @@ async def test_set_value_bad_range(hass: HomeAssistant) -> None: with pytest.raises(ServiceValidationError): await hass.services.async_call( - DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: 1024, ATTR_ENTITY_ID: ENTITY_VOLUME}, blocking=True, @@ -118,7 +120,7 @@ async def test_set_set_value(hass: HomeAssistant) -> None: assert state.state == "42.0" await hass.services.async_call( - DOMAIN, + NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_VALUE: 23, ATTR_ENTITY_ID: ENTITY_VOLUME}, blocking=True, diff --git a/tests/components/demo/test_select.py b/tests/components/demo/test_select.py index f9805f44866..a78f8552ec7 100644 --- a/tests/components/demo/test_select.py +++ b/tests/components/demo/test_select.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, - DOMAIN, + DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) from homeassistant.const import ATTR_ENTITY_ID, Platform @@ -31,7 +31,9 @@ async def select_only() -> None: @pytest.fixture(autouse=True) async def setup_demo_select(hass: HomeAssistant, select_only) -> None: """Initialize setup demo select entity.""" - assert await async_setup_component(hass, DOMAIN, {"select": {"platform": "demo"}}) + assert await async_setup_component( + hass, SELECT_DOMAIN, {"select": {"platform": "demo"}} + ) await hass.async_block_till_done() @@ -55,7 +57,7 @@ async def test_select_option_bad_attr(hass: HomeAssistant) -> None: with pytest.raises(ServiceValidationError): await hass.services.async_call( - DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_OPTION: "slow_speed", ATTR_ENTITY_ID: ENTITY_SPEED}, blocking=True, @@ -74,7 +76,7 @@ async def test_select_option(hass: HomeAssistant) -> None: assert state.state == "ridiculous_speed" await hass.services.async_call( - DOMAIN, + SELECT_DOMAIN, SERVICE_SELECT_OPTION, {ATTR_OPTION: "light_speed", ATTR_ENTITY_ID: ENTITY_SPEED}, blocking=True, diff --git a/tests/components/demo/test_siren.py b/tests/components/demo/test_siren.py index e21cd96efc9..c537e73508d 100644 --- a/tests/components/demo/test_siren.py +++ b/tests/components/demo/test_siren.py @@ -8,7 +8,7 @@ from homeassistant.components.siren import ( ATTR_AVAILABLE_TONES, ATTR_TONE, ATTR_VOLUME_LEVEL, - DOMAIN, + DOMAIN as SIREN_DOMAIN, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -39,7 +39,9 @@ async def siren_only() -> None: @pytest.fixture(autouse=True) async def setup_demo_siren(hass: HomeAssistant, siren_only: None): """Initialize setup demo siren.""" - assert await async_setup_component(hass, DOMAIN, {"siren": {"platform": "demo"}}) + assert await async_setup_component( + hass, SIREN_DOMAIN, {"siren": {"platform": "demo"}} + ) await hass.async_block_till_done() @@ -59,13 +61,13 @@ def test_all_setup_params(hass: HomeAssistant) -> None: async def test_turn_on(hass: HomeAssistant) -> None: """Test turn on device.""" await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + SIREN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True ) state = hass.states.get(ENTITY_SIREN) assert state.state == STATE_OFF await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + SIREN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True ) state = hass.states.get(ENTITY_SIREN) assert state.state == STATE_ON @@ -73,7 +75,7 @@ async def test_turn_on(hass: HomeAssistant) -> None: # Test that an invalid tone will raise a ValueError with pytest.raises(ValueError): await hass.services.async_call( - DOMAIN, + SIREN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN_WITH_ALL_FEATURES, ATTR_TONE: "invalid_tone"}, blocking=True, @@ -83,13 +85,13 @@ async def test_turn_on(hass: HomeAssistant) -> None: async def test_turn_off(hass: HomeAssistant) -> None: """Test turn off device.""" await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + SIREN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True ) state = hass.states.get(ENTITY_SIREN) assert state.state == STATE_ON await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + SIREN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True ) state = hass.states.get(ENTITY_SIREN) assert state.state == STATE_OFF @@ -98,19 +100,19 @@ async def test_turn_off(hass: HomeAssistant) -> None: async def test_toggle(hass: HomeAssistant) -> None: """Test toggle device.""" await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + SIREN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True ) state = hass.states.get(ENTITY_SIREN) assert state.state == STATE_ON await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + SIREN_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True ) state = hass.states.get(ENTITY_SIREN) assert state.state == STATE_OFF await hass.services.async_call( - DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + SIREN_DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True ) state = hass.states.get(ENTITY_SIREN) assert state.state == STATE_ON @@ -122,7 +124,7 @@ async def test_turn_on_strip_attributes(hass: HomeAssistant) -> None: "homeassistant.components.demo.siren.DemoSiren.async_turn_on" ) as svc_call: await hass.services.async_call( - DOMAIN, + SIREN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN, ATTR_VOLUME_LEVEL: 1}, blocking=True, diff --git a/tests/components/demo/test_text.py b/tests/components/demo/test_text.py index 4ca172e5143..b3291012167 100644 --- a/tests/components/demo/test_text.py +++ b/tests/components/demo/test_text.py @@ -10,7 +10,7 @@ from homeassistant.components.text import ( ATTR_MIN, ATTR_PATTERN, ATTR_VALUE, - DOMAIN, + DOMAIN as TEXT_DOMAIN, SERVICE_SET_VALUE, ) from homeassistant.const import ( @@ -38,7 +38,9 @@ def text_only() -> Generator[None]: @pytest.fixture(autouse=True) async def setup_demo_text(hass: HomeAssistant, text_only: None) -> None: """Initialize setup demo text.""" - assert await async_setup_component(hass, DOMAIN, {"text": {"platform": "demo"}}) + assert await async_setup_component( + hass, TEXT_DOMAIN, {"text": {"platform": "demo"}} + ) await hass.async_block_till_done() @@ -55,7 +57,7 @@ def test_setup_params(hass: HomeAssistant) -> None: async def test_set_value(hass: HomeAssistant) -> None: """Test set value service.""" await hass.services.async_call( - DOMAIN, + TEXT_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: ENTITY_TEXT, ATTR_VALUE: "new"}, blocking=True, diff --git a/tests/components/demo/test_time.py b/tests/components/demo/test_time.py index 8ef093a38f3..6997e8392ed 100644 --- a/tests/components/demo/test_time.py +++ b/tests/components/demo/test_time.py @@ -4,7 +4,11 @@ from unittest.mock import patch import pytest -from homeassistant.components.time import ATTR_TIME, DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_DOMAIN, + SERVICE_SET_VALUE, +) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -25,7 +29,9 @@ async def time_only() -> None: @pytest.fixture(autouse=True) async def setup_demo_datetime(hass: HomeAssistant, time_only) -> None: """Initialize setup demo time.""" - assert await async_setup_component(hass, DOMAIN, {"time": {"platform": "demo"}}) + assert await async_setup_component( + hass, TIME_DOMAIN, {"time": {"platform": "demo"}} + ) await hass.async_block_till_done() @@ -38,7 +44,7 @@ def test_setup_params(hass: HomeAssistant) -> None: async def test_set_value(hass: HomeAssistant) -> None: """Test set value service.""" await hass.services.async_call( - DOMAIN, + TIME_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: ENTITY_TIME, ATTR_TIME: "01:02:03"}, blocking=True, diff --git a/tests/components/demo/test_update.py b/tests/components/demo/test_update.py index 0a8886a085d..37fa5a7a2f6 100644 --- a/tests/components/demo/test_update.py +++ b/tests/components/demo/test_update.py @@ -11,7 +11,7 @@ from homeassistant.components.update import ( ATTR_RELEASE_SUMMARY, ATTR_RELEASE_URL, ATTR_TITLE, - DOMAIN, + DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL, UpdateDeviceClass, ) @@ -41,7 +41,9 @@ async def update_only() -> None: @pytest.fixture(autouse=True) async def setup_demo_update(hass: HomeAssistant, update_only) -> None: """Initialize setup demo update entity.""" - assert await async_setup_component(hass, DOMAIN, {"update": {"platform": "demo"}}) + assert await async_setup_component( + hass, UPDATE_DOMAIN, {"update": {"platform": "demo"}} + ) await hass.async_block_till_done() @@ -140,7 +142,7 @@ async def test_update_with_progress(hass: HomeAssistant) -> None: with patch("homeassistant.components.demo.update.FAKE_INSTALL_SLEEP_TIME", new=0): await hass.services.async_call( - DOMAIN, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.demo_update_with_progress"}, blocking=True, @@ -184,7 +186,7 @@ async def test_update_with_progress_raising(hass: HomeAssistant) -> None: pytest.raises(RuntimeError), ): await hass.services.async_call( - DOMAIN, + UPDATE_DOMAIN, SERVICE_INSTALL, {ATTR_ENTITY_ID: "update.demo_update_with_progress"}, blocking=True, diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index a3b982ab70e..a4e4d6f0e1f 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -19,7 +19,7 @@ from homeassistant.components.vacuum import ( ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, ATTR_PARAMS, - DOMAIN, + DOMAIN as VACUUM_DOMAIN, SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, STATE_CLEANING, @@ -42,11 +42,11 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service from tests.components.vacuum import common -ENTITY_VACUUM_BASIC = f"{DOMAIN}.{DEMO_VACUUM_BASIC}".lower() -ENTITY_VACUUM_COMPLETE = f"{DOMAIN}.{DEMO_VACUUM_COMPLETE}".lower() -ENTITY_VACUUM_MINIMAL = f"{DOMAIN}.{DEMO_VACUUM_MINIMAL}".lower() -ENTITY_VACUUM_MOST = f"{DOMAIN}.{DEMO_VACUUM_MOST}".lower() -ENTITY_VACUUM_NONE = f"{DOMAIN}.{DEMO_VACUUM_NONE}".lower() +ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".lower() +ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".lower() +ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".lower() +ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".lower() +ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".lower() @pytest.fixture @@ -62,7 +62,9 @@ async def vacuum_only() -> None: @pytest.fixture(autouse=True) async def setup_demo_vacuum(hass: HomeAssistant, vacuum_only: None): """Initialize setup demo vacuum.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "demo"}}) + assert await async_setup_component( + hass, VACUUM_DOMAIN, {VACUUM_DOMAIN: {CONF_PLATFORM: "demo"}} + ) await hass.async_block_till_done() @@ -189,7 +191,7 @@ async def test_unsupported_methods(hass: HomeAssistant) -> None: async def test_services(hass: HomeAssistant) -> None: """Test vacuum services.""" # Test send_command - send_command_calls = async_mock_service(hass, DOMAIN, SERVICE_SEND_COMMAND) + send_command_calls = async_mock_service(hass, VACUUM_DOMAIN, SERVICE_SEND_COMMAND) params = {"rotate": 150, "speed": 20} await common.async_send_command( @@ -198,20 +200,20 @@ async def test_services(hass: HomeAssistant) -> None: assert len(send_command_calls) == 1 call = send_command_calls[-1] - assert call.domain == DOMAIN + assert call.domain == VACUUM_DOMAIN assert call.service == SERVICE_SEND_COMMAND assert call.data[ATTR_ENTITY_ID] == ENTITY_VACUUM_BASIC assert call.data[ATTR_COMMAND] == "test_command" assert call.data[ATTR_PARAMS] == params # Test set fan speed - set_fan_speed_calls = async_mock_service(hass, DOMAIN, SERVICE_SET_FAN_SPEED) + set_fan_speed_calls = async_mock_service(hass, VACUUM_DOMAIN, SERVICE_SET_FAN_SPEED) await common.async_set_fan_speed(hass, FAN_SPEEDS[0], ENTITY_VACUUM_COMPLETE) assert len(set_fan_speed_calls) == 1 call = set_fan_speed_calls[-1] - assert call.domain == DOMAIN + assert call.domain == VACUUM_DOMAIN assert call.service == SERVICE_SET_FAN_SPEED assert call.data[ATTR_ENTITY_ID] == ENTITY_VACUUM_COMPLETE assert call.data[ATTR_FAN_SPEED] == FAN_SPEEDS[0] diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index f3821eb5af9..1de0794b9ee 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components import ( group, light, ) -from homeassistant.components.device_tracker import DOMAIN +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, @@ -150,21 +150,21 @@ async def test_lights_turn_on_when_coming_home_after_sun_set( hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} ) - hass.states.async_set(f"{DOMAIN}.device_2", STATE_UNKNOWN) + hass.states.async_set(f"{DEVICE_TRACKER_DOMAIN}.device_2", STATE_UNKNOWN) await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == STATE_OFF for ent_id in hass.states.async_entity_ids("light") ) - hass.states.async_set(f"{DOMAIN}.device_2", STATE_NOT_HOME) + hass.states.async_set(f"{DEVICE_TRACKER_DOMAIN}.device_2", STATE_NOT_HOME) await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == STATE_OFF for ent_id in hass.states.async_entity_ids("light") ) - hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) + hass.states.async_set(f"{DEVICE_TRACKER_DOMAIN}.device_2", STATE_HOME) await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == light.STATE_ON @@ -177,8 +177,8 @@ async def test_lights_turn_on_when_coming_home_after_sun_set_person( hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test lights turn on when coming home after sun set.""" - device_1 = f"{DOMAIN}.device_1" - device_2 = f"{DOMAIN}.device_2" + device_1 = f"{DEVICE_TRACKER_DOMAIN}.device_1" + device_2 = f"{DEVICE_TRACKER_DOMAIN}.device_2" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) freezer.move_to(test_time) diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index e809c94c129..fd28ce2fdf6 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import DOMAIN +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -34,24 +34,28 @@ async def test_binary_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test_door") + state = hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test_door") == snapshot + assert entity_registry.async_get(f"{BINARY_SENSOR_DOMAIN}.test_door") == snapshot - state = hass.states.get(f"{DOMAIN}.test_overload") + state = hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_overload") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test_overload") == snapshot + assert ( + entity_registry.async_get(f"{BINARY_SENSOR_DOMAIN}.test_overload") == snapshot + ) # Emulate websocket message: sensor turned on test_gateway.publisher.dispatch("Test", ("Test", True)) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test_door").state == STATE_ON + assert hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door").state == STATE_ON # Emulate websocket message: device went offline test_gateway.devices["Test"].status = 1 test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test_door").state == STATE_UNAVAILABLE + assert ( + hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door").state == STATE_UNAVAILABLE + ) @pytest.mark.usefixtures("mock_zeroconf") @@ -69,25 +73,30 @@ async def test_remote_control( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test_button_1") + state = hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_button_1") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test_button_1") == snapshot + assert ( + entity_registry.async_get(f"{BINARY_SENSOR_DOMAIN}.test_button_1") == snapshot + ) # Emulate websocket message: button pressed test_gateway.publisher.dispatch("Test", ("Test", 1)) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test_button_1").state == STATE_ON + assert hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_button_1").state == STATE_ON # Emulate websocket message: button released test_gateway.publisher.dispatch("Test", ("Test", 0)) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test_button_1").state == STATE_OFF + assert hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_button_1").state == STATE_OFF # Emulate websocket message: device went offline test_gateway.devices["Test"].status = 1 test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test_button_1").state == STATE_UNAVAILABLE + assert ( + hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_button_1").state + == STATE_UNAVAILABLE + ) @pytest.mark.usefixtures("mock_zeroconf") @@ -101,7 +110,7 @@ async def test_disabled(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test_door") is None + assert hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door") is None @pytest.mark.usefixtures("mock_zeroconf") @@ -116,7 +125,7 @@ async def test_remove_from_hass(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test_door") + state = hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door") assert state is not None await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/devolo_home_control/test_climate.py b/tests/components/devolo_home_control/test_climate.py index 953ff835b89..3aedda90e02 100644 --- a/tests/components/devolo_home_control/test_climate.py +++ b/tests/components/devolo_home_control/test_climate.py @@ -6,7 +6,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.climate import ( ATTR_HVAC_MODE, - DOMAIN, + DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, HVACMode, ) @@ -32,14 +32,14 @@ async def test_climate( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{CLIMATE_DOMAIN}.test") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot + assert entity_registry.async_get(f"{CLIMATE_DOMAIN}.test") == snapshot # Emulate websocket message: temperature changed test_gateway.publisher.dispatch("Test", ("Test", 21.0)) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{CLIMATE_DOMAIN}.test") assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 21.0 @@ -48,10 +48,10 @@ async def test_climate( "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" ) as set_value: await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: f"{DOMAIN}.test", + ATTR_ENTITY_ID: f"{CLIMATE_DOMAIN}.test", ATTR_HVAC_MODE: HVACMode.HEAT, ATTR_TEMPERATURE: 20.0, }, @@ -63,7 +63,7 @@ async def test_climate( test_gateway.devices["Test"].status = 1 test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + assert hass.states.get(f"{CLIMATE_DOMAIN}.test").state == STATE_UNAVAILABLE async def test_remove_from_hass(hass: HomeAssistant) -> None: @@ -77,7 +77,7 @@ async def test_remove_from_hass(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{CLIMATE_DOMAIN}.test") assert state is not None await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/devolo_home_control/test_cover.py b/tests/components/devolo_home_control/test_cover.py index c21dabadb1a..4560da9f7b7 100644 --- a/tests/components/devolo_home_control/test_cover.py +++ b/tests/components/devolo_home_control/test_cover.py @@ -4,7 +4,11 @@ from unittest.mock import patch from syrupy.assertion import SnapshotAssertion -from homeassistant.components.cover import ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, @@ -34,14 +38,14 @@ async def test_cover( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{COVER_DOMAIN}.test") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot + assert entity_registry.async_get(f"{COVER_DOMAIN}.test") == snapshot # Emulate websocket message: position changed test_gateway.publisher.dispatch("Test", ("devolo.Blinds", 0.0)) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{COVER_DOMAIN}.test") assert state.state == STATE_CLOSED assert state.attributes[ATTR_CURRENT_POSITION] == 0.0 @@ -50,27 +54,27 @@ async def test_cover( "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" ) as set_value: await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + {ATTR_ENTITY_ID: f"{COVER_DOMAIN}.test"}, blocking=True, ) # In reality, this leads to a websocket message like already tested above set_value.assert_called_once_with(100) set_value.reset_mock() await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + {ATTR_ENTITY_ID: f"{COVER_DOMAIN}.test"}, blocking=True, ) # In reality, this leads to a websocket message like already tested above set_value.assert_called_once_with(0) set_value.reset_mock() await hass.services.async_call( - DOMAIN, + COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: f"{DOMAIN}.test", ATTR_POSITION: 50}, + {ATTR_ENTITY_ID: f"{COVER_DOMAIN}.test", ATTR_POSITION: 50}, blocking=True, ) # In reality, this leads to a websocket message like already tested above set_value.assert_called_once_with(50) @@ -79,7 +83,7 @@ async def test_cover( test_gateway.devices["Test"].status = 1 test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + assert hass.states.get(f"{COVER_DOMAIN}.test").state == STATE_UNAVAILABLE async def test_remove_from_hass(hass: HomeAssistant) -> None: @@ -93,7 +97,7 @@ async def test_remove_from_hass(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{COVER_DOMAIN}.test") assert state is not None await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/devolo_home_control/test_light.py b/tests/components/devolo_home_control/test_light.py index f72136ee287..46c3fbc98f3 100644 --- a/tests/components/devolo_home_control/test_light.py +++ b/tests/components/devolo_home_control/test_light.py @@ -4,7 +4,7 @@ from unittest.mock import patch from syrupy.assertion import SnapshotAssertion -from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -33,18 +33,18 @@ async def test_light_without_binary_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{LIGHT_DOMAIN}.test") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot + assert entity_registry.async_get(f"{LIGHT_DOMAIN}.test") == snapshot # Emulate websocket message: brightness changed test_gateway.publisher.dispatch("Test", ("devolo.Dimmer:Test", 0.0)) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{LIGHT_DOMAIN}.test") assert state.state == STATE_OFF test_gateway.publisher.dispatch("Test", ("devolo.Dimmer:Test", 100.0)) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{LIGHT_DOMAIN}.test") assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 255 @@ -53,27 +53,27 @@ async def test_light_without_binary_sensor( "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" ) as set_value: await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + {ATTR_ENTITY_ID: f"{LIGHT_DOMAIN}.test"}, blocking=True, ) # In reality, this leads to a websocket message like already tested above set_value.assert_called_once_with(100) set_value.reset_mock() await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + {ATTR_ENTITY_ID: f"{LIGHT_DOMAIN}.test"}, blocking=True, ) # In reality, this leads to a websocket message like already tested above set_value.assert_called_once_with(0) set_value.reset_mock() await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: f"{DOMAIN}.test", ATTR_BRIGHTNESS: 50}, + {ATTR_ENTITY_ID: f"{LIGHT_DOMAIN}.test", ATTR_BRIGHTNESS: 50}, blocking=True, ) # In reality, this leads to a websocket message like already tested above set_value.assert_called_once_with(round(50 / 255 * 100)) @@ -82,7 +82,7 @@ async def test_light_without_binary_sensor( test_gateway.devices["Test"].status = 1 test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + assert hass.states.get(f"{LIGHT_DOMAIN}.test").state == STATE_UNAVAILABLE async def test_light_with_binary_sensor( @@ -101,18 +101,18 @@ async def test_light_with_binary_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{LIGHT_DOMAIN}.test") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot + assert entity_registry.async_get(f"{LIGHT_DOMAIN}.test") == snapshot # Emulate websocket message: brightness changed test_gateway.publisher.dispatch("Test", ("devolo.Dimmer:Test", 0.0)) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{LIGHT_DOMAIN}.test") assert state.state == STATE_OFF test_gateway.publisher.dispatch("Test", ("devolo.Dimmer:Test", 100.0)) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{LIGHT_DOMAIN}.test") assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 255 @@ -121,18 +121,18 @@ async def test_light_with_binary_sensor( "devolo_home_control_api.properties.binary_switch_property.BinarySwitchProperty.set" ) as set_value: await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + {ATTR_ENTITY_ID: f"{LIGHT_DOMAIN}.test"}, blocking=True, ) # In reality, this leads to a websocket message like already tested above set_value.assert_called_once_with(True) set_value.reset_mock() await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + {ATTR_ENTITY_ID: f"{LIGHT_DOMAIN}.test"}, blocking=True, ) # In reality, this leads to a websocket message like already tested above set_value.assert_called_once_with(False) @@ -149,7 +149,7 @@ async def test_remove_from_hass(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{LIGHT_DOMAIN}.test") assert state is not None await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/devolo_home_control/test_sensor.py b/tests/components/devolo_home_control/test_sensor.py index 62023982e81..08b53dae865 100644 --- a/tests/components/devolo_home_control/test_sensor.py +++ b/tests/components/devolo_home_control/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -26,9 +26,9 @@ async def test_temperature_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test_temperature") + state = hass.states.get(f"{SENSOR_DOMAIN}.test_temperature") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test_temperature") == snapshot + assert entity_registry.async_get(f"{SENSOR_DOMAIN}.test_temperature") == snapshot async def test_battery_sensor( @@ -45,14 +45,14 @@ async def test_battery_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test_battery_level") + state = hass.states.get(f"{SENSOR_DOMAIN}.test_battery_level") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test_battery_level") == snapshot + assert entity_registry.async_get(f"{SENSOR_DOMAIN}.test_battery_level") == snapshot # Emulate websocket message: value changed test_gateway.publisher.dispatch("Test", ("Test", 10, "battery_level")) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test_battery_level").state == "10" + assert hass.states.get(f"{SENSOR_DOMAIN}.test_battery_level").state == "10" async def test_consumption_sensor( @@ -68,29 +68,36 @@ async def test_consumption_sensor( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test_current_consumption") + state = hass.states.get(f"{SENSOR_DOMAIN}.test_current_consumption") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test_current_consumption") == snapshot + assert ( + entity_registry.async_get(f"{SENSOR_DOMAIN}.test_current_consumption") + == snapshot + ) - state = hass.states.get(f"{DOMAIN}.test_total_consumption") + state = hass.states.get(f"{SENSOR_DOMAIN}.test_total_consumption") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test_total_consumption") == snapshot + assert ( + entity_registry.async_get(f"{SENSOR_DOMAIN}.test_total_consumption") == snapshot + ) # Emulate websocket message: value changed test_gateway.devices["Test"].consumption_property["devolo.Meter:Test"].total = 50.0 test_gateway.publisher.dispatch("Test", ("devolo.Meter:Test", 50.0)) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test_total_consumption").state == "50.0" + assert hass.states.get(f"{SENSOR_DOMAIN}.test_total_consumption").state == "50.0" # Emulate websocket message: device went offline test_gateway.devices["Test"].status = 1 test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() assert ( - hass.states.get(f"{DOMAIN}.test_current_consumption").state == STATE_UNAVAILABLE + hass.states.get(f"{SENSOR_DOMAIN}.test_current_consumption").state + == STATE_UNAVAILABLE ) assert ( - hass.states.get(f"{DOMAIN}.test_total_consumption").state == STATE_UNAVAILABLE + hass.states.get(f"{SENSOR_DOMAIN}.test_total_consumption").state + == STATE_UNAVAILABLE ) @@ -105,7 +112,7 @@ async def test_voltage_sensor(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test_voltage") + state = hass.states.get(f"{SENSOR_DOMAIN}.test_voltage") assert state is None @@ -123,14 +130,16 @@ async def test_sensor_change(hass: HomeAssistant) -> None: # Emulate websocket message: value changed test_gateway.publisher.dispatch("Test", ("devolo.MultiLevelSensor:Test", 50.0)) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test_temperature") + state = hass.states.get(f"{SENSOR_DOMAIN}.test_temperature") assert state.state == "50.0" # Emulate websocket message: device went offline test_gateway.devices["Test"].status = 1 test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test_temperature").state == STATE_UNAVAILABLE + assert ( + hass.states.get(f"{SENSOR_DOMAIN}.test_temperature").state == STATE_UNAVAILABLE + ) async def test_remove_from_hass(hass: HomeAssistant) -> None: @@ -144,7 +153,7 @@ async def test_remove_from_hass(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test_temperature") + state = hass.states.get(f"{SENSOR_DOMAIN}.test_temperature") assert state is not None await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/devolo_home_control/test_siren.py b/tests/components/devolo_home_control/test_siren.py index be662418967..71f4dfdd34d 100644 --- a/tests/components/devolo_home_control/test_siren.py +++ b/tests/components/devolo_home_control/test_siren.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.siren import DOMAIN +from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -29,20 +29,20 @@ async def test_siren( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{SIREN_DOMAIN}.test") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot + assert entity_registry.async_get(f"{SIREN_DOMAIN}.test") == snapshot # Emulate websocket message: sensor turned on test_gateway.publisher.dispatch("Test", ("devolo.SirenMultiLevelSwitch:Test", 1)) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test").state == STATE_ON + assert hass.states.get(f"{SIREN_DOMAIN}.test").state == STATE_ON # Emulate websocket message: device went offline test_gateway.devices["Test"].status = 1 test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + assert hass.states.get(f"{SIREN_DOMAIN}.test").state == STATE_UNAVAILABLE @pytest.mark.usefixtures("mock_zeroconf") @@ -60,9 +60,9 @@ async def test_siren_switching( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{SIREN_DOMAIN}.test") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot + assert entity_registry.async_get(f"{SIREN_DOMAIN}.test") == snapshot with patch( "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" @@ -70,7 +70,7 @@ async def test_siren_switching( await hass.services.async_call( "siren", "turn_on", - {"entity_id": f"{DOMAIN}.test"}, + {"entity_id": f"{SIREN_DOMAIN}.test"}, blocking=True, ) # The real device state is changed by a websocket message @@ -86,7 +86,7 @@ async def test_siren_switching( await hass.services.async_call( "siren", "turn_off", - {"entity_id": f"{DOMAIN}.test"}, + {"entity_id": f"{SIREN_DOMAIN}.test"}, blocking=True, ) # The real device state is changed by a websocket message @@ -94,7 +94,7 @@ async def test_siren_switching( "Test", ("devolo.SirenMultiLevelSwitch:Test", 0) ) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test").state == STATE_OFF + assert hass.states.get(f"{SIREN_DOMAIN}.test").state == STATE_OFF property_set.assert_called_once_with(0) @@ -113,9 +113,9 @@ async def test_siren_change_default_tone( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{SIREN_DOMAIN}.test") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot + assert entity_registry.async_get(f"{SIREN_DOMAIN}.test") == snapshot with patch( "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" @@ -124,7 +124,7 @@ async def test_siren_change_default_tone( await hass.services.async_call( "siren", "turn_on", - {"entity_id": f"{DOMAIN}.test"}, + {"entity_id": f"{SIREN_DOMAIN}.test"}, blocking=True, ) property_set.assert_called_once_with(2) @@ -142,7 +142,7 @@ async def test_remove_from_hass(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{SIREN_DOMAIN}.test") assert state is not None await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/devolo_home_control/test_switch.py b/tests/components/devolo_home_control/test_switch.py index 86f93bfddf6..46adaf8c8b0 100644 --- a/tests/components/devolo_home_control/test_switch.py +++ b/tests/components/devolo_home_control/test_switch.py @@ -4,7 +4,7 @@ from unittest.mock import patch from syrupy.assertion import SnapshotAssertion -from homeassistant.components.switch import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -32,9 +32,9 @@ async def test_switch( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{SWITCH_DOMAIN}.test") assert state == snapshot - assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot + assert entity_registry.async_get(f"{SWITCH_DOMAIN}.test") == snapshot # Emulate websocket message: switched on test_gateway.devices["Test"].binary_switch_property[ @@ -42,24 +42,24 @@ async def test_switch( ].state = True test_gateway.publisher.dispatch("Test", ("devolo.BinarySwitch:Test", True)) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test").state == STATE_ON + assert hass.states.get(f"{SWITCH_DOMAIN}.test").state == STATE_ON with patch( "devolo_home_control_api.properties.binary_switch_property.BinarySwitchProperty.set" ) as set_value: await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + {ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.test"}, blocking=True, ) # In reality, this leads to a websocket message like already tested above set_value.assert_called_once_with(state=True) set_value.reset_mock() await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + {ATTR_ENTITY_ID: f"{SWITCH_DOMAIN}.test"}, blocking=True, ) # In reality, this leads to a websocket message like already tested above set_value.assert_called_once_with(state=False) @@ -68,7 +68,7 @@ async def test_switch( test_gateway.devices["Test"].status = 1 test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + assert hass.states.get(f"{SWITCH_DOMAIN}.test").state == STATE_UNAVAILABLE async def test_remove_from_hass(hass: HomeAssistant) -> None: @@ -82,7 +82,7 @@ async def test_remove_from_hass(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(f"{DOMAIN}.test") + state = hass.states.get(f"{SWITCH_DOMAIN}.test") assert state is not None await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 3e4bf8471c1..8197ec1a1e5 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -7,7 +7,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.binary_sensor import DOMAIN +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.devolo_home_network.const import ( CONNECTED_TO_ROUTER, LONG_UPDATE_INTERVAL, @@ -31,7 +31,10 @@ async def test_binary_sensor_setup(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(f"{DOMAIN}.{device_name}_{CONNECTED_TO_ROUTER}") is None + assert ( + hass.states.get(f"{BINARY_SENSOR_DOMAIN}.{device_name}_{CONNECTED_TO_ROUTER}") + is None + ) await hass.config_entries.async_unload(entry.entry_id) @@ -47,7 +50,7 @@ async def test_update_attached_to_router( """Test state change of a attached_to_router binary sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{DOMAIN}.{device_name}_{CONNECTED_TO_ROUTER}" + state_key = f"{BINARY_SENSOR_DOMAIN}.{device_name}_{CONNECTED_TO_ROUTER}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py index 80efc4fcc09..f13db4fce9d 100644 --- a/tests/components/devolo_home_network/test_image.py +++ b/tests/components/devolo_home_network/test_image.py @@ -9,7 +9,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.devolo_home_network.const import SHORT_UPDATE_INTERVAL -from homeassistant.components.image import DOMAIN +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -32,7 +32,9 @@ async def test_image_setup(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert ( - hass.states.get(f"{DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code") + hass.states.get( + f"{IMAGE_DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" + ) is not None ) @@ -51,7 +53,7 @@ async def test_guest_wifi_qr( """Test showing a QR code of the guest wifi credentials.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" + state_key = f"{IMAGE_DOMAIN}.{device_name}_guest_wi_fi_credentials_as_qr_code" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/doorbird/test_button.py b/tests/components/doorbird/test_button.py index cb4bab656ee..abb490e9180 100644 --- a/tests/components/doorbird/test_button.py +++ b/tests/components/doorbird/test_button.py @@ -1,6 +1,6 @@ """Test DoorBird buttons.""" -from homeassistant.components.button import DOMAIN, SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -16,7 +16,7 @@ async def test_relay_button( relay_1_entity_id = "button.mydoorbird_relay_1" assert hass.states.get(relay_1_entity_id).state == STATE_UNKNOWN await hass.services.async_call( - DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: relay_1_entity_id}, blocking=True + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: relay_1_entity_id}, blocking=True ) assert hass.states.get(relay_1_entity_id).state != STATE_UNKNOWN assert doorbird_entry.api.energize_relay.call_count == 1 @@ -31,7 +31,7 @@ async def test_ir_button( ir_entity_id = "button.mydoorbird_ir" assert hass.states.get(ir_entity_id).state == STATE_UNKNOWN await hass.services.async_call( - DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: ir_entity_id}, blocking=True + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: ir_entity_id}, blocking=True ) assert hass.states.get(ir_entity_id).state != STATE_UNKNOWN assert doorbird_entry.api.turn_light_on.call_count == 1 @@ -46,7 +46,7 @@ async def test_reset_favorites_button( reset_entity_id = "button.mydoorbird_reset_favorites" assert hass.states.get(reset_entity_id).state == STATE_UNKNOWN await hass.services.async_call( - DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: reset_entity_id}, blocking=True + BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: reset_entity_id}, blocking=True ) assert hass.states.get(reset_entity_id).state != STATE_UNKNOWN assert doorbird_entry.api.delete_favorite.call_count == 3 From 6ea59ffa946b5d96352ffa5de4e8393ddebd9311 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:21:01 +0200 Subject: [PATCH 0445/1309] Add alias to DOMAIN import in tests [h-m] (#125577) * Add alias to DOMAIN import in tests [h-m] * Revert changes to mqtt --- tests/components/home_connect/test_light.py | 6 +- tests/components/home_connect/test_switch.py | 10 +-- tests/components/homekit/test_type_covers.py | 24 ++--- tests/components/homekit/test_type_fans.py | 32 +++---- .../homekit/test_type_humidifiers.py | 18 ++-- tests/components/homekit/test_type_lights.py | 32 +++---- tests/components/homekit/test_type_locks.py | 8 +- .../homekit/test_type_media_players.py | 38 ++++---- tests/components/homekit/test_type_remote.py | 6 +- .../homekit/test_type_security_systems.py | 20 +++-- .../homekit_controller/test_climate.py | 90 +++++++++---------- .../homekit_controller/test_humidifier.py | 26 +++--- .../components/homematicip_cloud/test_lock.py | 4 +- tests/components/hydrawise/test_valve.py | 6 +- tests/components/knx/test_date.py | 8 +- tests/components/knx/test_datetime.py | 8 +- tests/components/knx/test_time.py | 8 +- 17 files changed, 189 insertions(+), 155 deletions(-) diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 8d918dc5815..f37eb71b8aa 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -13,7 +13,7 @@ from homeassistant.components.home_connect.const import ( COOKING_LIGHTING, COOKING_LIGHTING_BRIGHTNESS, ) -from homeassistant.components.light import DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( SERVICE_TURN_OFF, @@ -176,7 +176,7 @@ async def test_light_functionality( appliance.status.update(status) service_data["entity_id"] = entity_id await hass.services.async_call( - DOMAIN, + LIGHT_DOMAIN, service, service_data, blocking=True, @@ -294,5 +294,5 @@ async def test_switch_exception_handling( problematic_appliance.status.update(status) service_data["entity_id"] = entity_id - await hass.services.async_call(DOMAIN, service, service_data, blocking=True) + await hass.services.async_call(LIGHT_DOMAIN, service, service_data, blocking=True) assert getattr(problematic_appliance, mock_attr).call_count == len(attr_side_effect) diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 3ab550ad0af..d16a4626e59 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -15,7 +15,7 @@ from homeassistant.components.home_connect.const import ( BSH_POWER_STATE, REFRIGERATION_SUPERMODEFREEZER, ) -from homeassistant.components.switch import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, @@ -139,7 +139,7 @@ async def test_switch_functionality( appliance.status.update(status) await hass.services.async_call( - DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True ) assert hass.states.is_state(entity_id, state) @@ -213,7 +213,7 @@ async def test_switch_exception_handling( problematic_appliance.status.update(status) await hass.services.async_call( - DOMAIN, service, {"entity_id": entity_id}, blocking=True + SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True ) assert getattr(problematic_appliance, mock_attr).call_count == 2 @@ -268,7 +268,7 @@ async def test_ent_desc_switch_functionality( appliance.status.update(status) await hass.services.async_call( - DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) assert hass.states.is_state(entity_id, state) @@ -327,6 +327,6 @@ async def test_ent_desc_switch_exception_handling( problematic_appliance.status.update(status) await hass.services.async_call( - DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True + SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True ) assert getattr(problematic_appliance, mock_attr).call_count == 2 diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index b3125c6581c..8d3b13b1856 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -5,7 +5,7 @@ from homeassistant.components.cover import ( ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, ATTR_TILT_POSITION, - DOMAIN, + DOMAIN as COVER_DOMAIN, CoverEntityFeature, ) from homeassistant.components.homekit.const import ( @@ -92,8 +92,8 @@ async def test_garage_door_open_close( assert acc.available is True # Set from HomeKit - call_close_cover = async_mock_service(hass, DOMAIN, "close_cover") - call_open_cover = async_mock_service(hass, DOMAIN, "open_cover") + call_close_cover = async_mock_service(hass, COVER_DOMAIN, "close_cover") + call_open_cover = async_mock_service(hass, COVER_DOMAIN, "open_cover") acc.char_target_state.client_update_value(1) await hass.async_block_till_done() @@ -272,7 +272,9 @@ async def test_windowcovering_set_cover_position( assert acc.char_position_state.value == 2 # Set from HomeKit - call_set_cover_position = async_mock_service(hass, DOMAIN, "set_cover_position") + call_set_cover_position = async_mock_service( + hass, COVER_DOMAIN, "set_cover_position" + ) acc.char_target_position.client_update_value(25) await hass.async_block_till_done() @@ -389,7 +391,7 @@ async def test_windowcovering_cover_set_tilt( # set from HomeKit call_set_tilt_position = async_mock_service( - hass, DOMAIN, SERVICE_SET_COVER_TILT_POSITION + hass, COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION ) # HomeKit sets tilts between -90 and 90 (degrees), whereas @@ -488,8 +490,8 @@ async def test_windowcovering_open_close( assert acc.char_position_state.value == 2 # Set from HomeKit - call_close_cover = async_mock_service(hass, DOMAIN, "close_cover") - call_open_cover = async_mock_service(hass, DOMAIN, "open_cover") + call_close_cover = async_mock_service(hass, COVER_DOMAIN, "close_cover") + call_open_cover = async_mock_service(hass, COVER_DOMAIN, "open_cover") acc.char_target_position.client_update_value(25) await hass.async_block_till_done() @@ -536,9 +538,9 @@ async def test_windowcovering_open_close_stop( await hass.async_block_till_done() # Set from HomeKit - call_close_cover = async_mock_service(hass, DOMAIN, "close_cover") - call_open_cover = async_mock_service(hass, DOMAIN, "open_cover") - call_stop_cover = async_mock_service(hass, DOMAIN, "stop_cover") + call_close_cover = async_mock_service(hass, COVER_DOMAIN, "close_cover") + call_open_cover = async_mock_service(hass, COVER_DOMAIN, "open_cover") + call_stop_cover = async_mock_service(hass, COVER_DOMAIN, "stop_cover") acc.char_target_position.client_update_value(25) await hass.async_block_till_done() @@ -590,7 +592,7 @@ async def test_windowcovering_open_close_with_position_and_stop( await hass.async_block_till_done() # Set from HomeKit - call_stop_cover = async_mock_service(hass, DOMAIN, "stop_cover") + call_stop_cover = async_mock_service(hass, COVER_DOMAIN, "stop_cover") acc.char_hold_position.client_update_value(0) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index 1808767c614..67392f11f14 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODES, DIRECTION_FORWARD, DIRECTION_REVERSE, - DOMAIN, + DOMAIN as FAN_DOMAIN, FanEntityFeature, ) from homeassistant.components.homekit.const import ATTR_VALUE, PROP_MIN_STEP @@ -63,8 +63,8 @@ async def test_fan_basic(hass: HomeAssistant, hk_driver, events: list[Event]) -> assert acc.char_active.value == 0 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") - call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") + call_turn_on = async_mock_service(hass, FAN_DOMAIN, "turn_on") + call_turn_off = async_mock_service(hass, FAN_DOMAIN, "turn_off") char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] @@ -144,7 +144,7 @@ async def test_fan_direction( assert acc.char_direction.value == 1 # Set from HomeKit - call_set_direction = async_mock_service(hass, DOMAIN, "set_direction") + call_set_direction = async_mock_service(hass, FAN_DOMAIN, "set_direction") char_direction_iid = acc.char_direction.to_HAP()[HAP_REPR_IID] @@ -218,7 +218,7 @@ async def test_fan_oscillate( assert acc.char_swing.value == 1 # Set from HomeKit - call_oscillate = async_mock_service(hass, DOMAIN, "oscillate") + call_oscillate = async_mock_service(hass, FAN_DOMAIN, "oscillate") char_swing_iid = acc.char_swing.to_HAP()[HAP_REPR_IID] @@ -301,7 +301,7 @@ async def test_fan_speed(hass: HomeAssistant, hk_driver, events: list[Event]) -> assert acc.char_speed.value == 100 # Set from HomeKit - call_set_percentage = async_mock_service(hass, DOMAIN, "set_percentage") + call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage") char_speed_iid = acc.char_speed.to_HAP()[HAP_REPR_IID] char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] @@ -343,7 +343,7 @@ async def test_fan_speed(hass: HomeAssistant, hk_driver, events: list[Event]) -> assert acc.char_speed.value == 50 assert acc.char_active.value == 0 - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, FAN_DOMAIN, "turn_on") hk_driver.set_characteristics( { @@ -409,11 +409,11 @@ async def test_fan_set_all_one_shot( assert hass.states.get(entity_id).state == STATE_OFF # Set from HomeKit - call_set_percentage = async_mock_service(hass, DOMAIN, "set_percentage") - call_oscillate = async_mock_service(hass, DOMAIN, "oscillate") - call_set_direction = async_mock_service(hass, DOMAIN, "set_direction") - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") - call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") + call_set_percentage = async_mock_service(hass, FAN_DOMAIN, "set_percentage") + call_oscillate = async_mock_service(hass, FAN_DOMAIN, "oscillate") + call_set_direction = async_mock_service(hass, FAN_DOMAIN, "set_direction") + call_turn_on = async_mock_service(hass, FAN_DOMAIN, "turn_on") + call_turn_off = async_mock_service(hass, FAN_DOMAIN, "turn_off") char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] char_direction_iid = acc.char_direction.to_HAP()[HAP_REPR_IID] @@ -641,8 +641,8 @@ async def test_fan_multiple_preset_modes( assert acc.preset_mode_chars["auto"].value == 0 assert acc.preset_mode_chars["smart"].value == 1 # Set from HomeKit - call_set_preset_mode = async_mock_service(hass, DOMAIN, "set_preset_mode") - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode") + call_turn_on = async_mock_service(hass, FAN_DOMAIN, "turn_on") char_auto_iid = acc.preset_mode_chars["auto"].to_HAP()[HAP_REPR_IID] @@ -711,8 +711,8 @@ async def test_fan_single_preset_mode( await hass.async_block_till_done() # Set from HomeKit - call_set_preset_mode = async_mock_service(hass, DOMAIN, "set_preset_mode") - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_set_preset_mode = async_mock_service(hass, FAN_DOMAIN, "set_preset_mode") + call_turn_on = async_mock_service(hass, FAN_DOMAIN, "turn_on") char_target_fan_state_iid = acc.char_target_fan_state.to_HAP()[HAP_REPR_IID] diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py index fbb72333c9b..de563503b23 100644 --- a/tests/components/homekit/test_type_humidifiers.py +++ b/tests/components/homekit/test_type_humidifiers.py @@ -26,7 +26,7 @@ from homeassistant.components.humidifier import ( ATTR_MIN_HUMIDITY, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, - DOMAIN, + DOMAIN as HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, HumidifierDeviceClass, ) @@ -106,7 +106,9 @@ async def test_humidifier(hass: HomeAssistant, hk_driver, events: list[Event]) - assert acc.char_active.value == 0 # Set from HomeKit - call_set_humidity = async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + call_set_humidity = async_mock_service( + hass, HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY + ) char_target_humidity_iid = acc.char_target_humidity.to_HAP()[HAP_REPR_IID] @@ -194,7 +196,9 @@ async def test_dehumidifier( assert acc.char_active.value == 0 # Set from HomeKit - call_set_humidity = async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + call_set_humidity = async_mock_service( + hass, HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY + ) char_target_humidity_iid = acc.char_target_humidity.to_HAP()[HAP_REPR_IID] @@ -257,7 +261,7 @@ async def test_hygrostat_power_state( assert acc.char_active.value == 0 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + call_turn_on = async_mock_service(hass, HUMIDIFIER_DOMAIN, SERVICE_TURN_ON) char_active_iid = acc.char_active.to_HAP()[HAP_REPR_IID] @@ -281,7 +285,7 @@ async def test_hygrostat_power_state( assert len(events) == 1 assert events[-1].data[ATTR_VALUE] == "Active to 1" - call_turn_off = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF) + call_turn_off = async_mock_service(hass, HUMIDIFIER_DOMAIN, SERVICE_TURN_OFF) hk_driver.set_characteristics( { @@ -323,7 +327,9 @@ async def test_hygrostat_get_humidity_range( await hass.async_block_till_done() # Set from HomeKit - call_set_humidity = async_mock_service(hass, DOMAIN, SERVICE_SET_HUMIDITY) + call_set_humidity = async_mock_service( + hass, HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY + ) char_target_humidity_iid = acc.char_target_humidity.to_HAP()[HAP_REPR_IID] diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 0f85e07c0bb..d365165aca4 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -27,7 +27,7 @@ from homeassistant.components.light import ( ATTR_RGBWW_COLOR, ATTR_SUPPORTED_COLOR_MODES, ATTR_WHITE, - DOMAIN, + DOMAIN as LIGHT_DOMAIN, ColorMode, ) from homeassistant.const import ( @@ -83,8 +83,8 @@ async def test_light_basic(hass: HomeAssistant, hk_driver, events: list[Event]) assert acc.char_on.value == 0 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") - call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + call_turn_off = async_mock_service(hass, LIGHT_DOMAIN, "turn_off") char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] @@ -160,8 +160,8 @@ async def test_light_brightness( assert acc.char_brightness.value == 40 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") - call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + call_turn_off = async_mock_service(hass, LIGHT_DOMAIN, "turn_off") hk_driver.set_characteristics( { @@ -296,7 +296,7 @@ async def test_light_color_temperature( assert acc.char_color_temp.value == 190 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] @@ -372,7 +372,7 @@ async def test_light_color_temperature_and_rgb_color( char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") hk_driver.set_characteristics( { @@ -549,7 +549,7 @@ async def test_light_rgb_color( assert acc.char_saturation.value == 90 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] @@ -671,7 +671,7 @@ async def test_light_rgb_with_color_temp( assert acc.char_brightness.value == 100 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] @@ -791,7 +791,7 @@ async def test_light_rgbwx_with_color_temp_and_brightness( assert acc.char_brightness.value == 100 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID] char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] @@ -858,7 +858,7 @@ async def test_light_rgb_or_w_lights( assert acc.char_color_temp.value == 153 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] @@ -985,7 +985,7 @@ async def test_light_rgb_with_white_switch_to_temp( assert acc.char_brightness.value == 100 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] @@ -1100,7 +1100,7 @@ async def test_light_rgbww_with_color_temp_conversion( assert acc.char_brightness.value == 100 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] @@ -1221,7 +1221,7 @@ async def test_light_rgbw_with_color_temp_conversion( assert acc.char_brightness.value == 100 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] @@ -1325,7 +1325,7 @@ async def test_light_set_brightness_and_color( assert acc.char_saturation.value == 9 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") hk_driver.set_characteristics( { @@ -1432,7 +1432,7 @@ async def test_light_set_brightness_and_color_temp( assert acc.char_color_temp.value == 224 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") + call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") hk_driver.set_characteristics( { diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index 31f03b1964f..5b5b355d10f 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.components.homekit.type_locks import Lock from homeassistant.components.lock import ( - DOMAIN, + DOMAIN as LOCK_DOMAIN, STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING, @@ -98,8 +98,8 @@ async def test_lock_unlock(hass: HomeAssistant, hk_driver, events: list[Event]) assert acc.char_target_state.value == 0 # Set from HomeKit - call_lock = async_mock_service(hass, DOMAIN, "lock") - call_unlock = async_mock_service(hass, DOMAIN, "unlock") + call_lock = async_mock_service(hass, LOCK_DOMAIN, "lock") + call_unlock = async_mock_service(hass, LOCK_DOMAIN, "unlock") acc.char_target_state.client_update_value(1) await hass.async_block_till_done() @@ -132,7 +132,7 @@ async def test_no_code( acc = Lock(hass, hk_driver, "Lock", entity_id, 2, config) # Set from HomeKit - call_lock = async_mock_service(hass, DOMAIN, "lock") + call_lock = async_mock_service(hass, LOCK_DOMAIN, "lock") acc.char_target_state.client_update_value(1) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 14c21f0a5f5..78c35b15790 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -25,7 +25,7 @@ from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, - DOMAIN, + DOMAIN as MEDIA_PLAYER_DOMAIN, MediaPlayerDeviceClass, ) from homeassistant.const import ( @@ -112,12 +112,12 @@ async def test_media_player_set_state( assert acc.chars[FEATURE_PLAY_STOP].value is False # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") - call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") - call_media_play = async_mock_service(hass, DOMAIN, "media_play") - call_media_pause = async_mock_service(hass, DOMAIN, "media_pause") - call_media_stop = async_mock_service(hass, DOMAIN, "media_stop") - call_toggle_mute = async_mock_service(hass, DOMAIN, "volume_mute") + call_turn_on = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "turn_on") + call_turn_off = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "turn_off") + call_media_play = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "media_play") + call_media_pause = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "media_pause") + call_media_stop = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "media_stop") + call_toggle_mute = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "volume_mute") acc.chars[FEATURE_ON_OFF].client_update_value(True) await hass.async_block_till_done() @@ -252,16 +252,18 @@ async def test_media_player_television( assert caplog.records[-2].levelname == "DEBUG" # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") - call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") - call_media_play = async_mock_service(hass, DOMAIN, "media_play") - call_media_pause = async_mock_service(hass, DOMAIN, "media_pause") - call_media_play_pause = async_mock_service(hass, DOMAIN, "media_play_pause") - call_toggle_mute = async_mock_service(hass, DOMAIN, "volume_mute") - call_select_source = async_mock_service(hass, DOMAIN, "select_source") - call_volume_up = async_mock_service(hass, DOMAIN, "volume_up") - call_volume_down = async_mock_service(hass, DOMAIN, "volume_down") - call_volume_set = async_mock_service(hass, DOMAIN, "volume_set") + call_turn_on = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "turn_on") + call_turn_off = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "turn_off") + call_media_play = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "media_play") + call_media_pause = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "media_pause") + call_media_play_pause = async_mock_service( + hass, MEDIA_PLAYER_DOMAIN, "media_play_pause" + ) + call_toggle_mute = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "volume_mute") + call_select_source = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "select_source") + call_volume_up = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "volume_up") + call_volume_down = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "volume_down") + call_volume_set = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "volume_set") acc.char_active.client_update_value(1) await hass.async_block_till_done() @@ -634,7 +636,7 @@ async def test_media_player_television_unsafe_chars( await hass.async_block_till_done() assert acc.char_input_source.value == 1 - call_select_source = async_mock_service(hass, DOMAIN, "select_source") + call_select_source = async_mock_service(hass, MEDIA_PLAYER_DOMAIN, "select_source") acc.char_input_source.client_update_value(3) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_remote.py b/tests/components/homekit/test_type_remote.py index dedf3ae34db..62c45c6ee89 100644 --- a/tests/components/homekit/test_type_remote.py +++ b/tests/components/homekit/test_type_remote.py @@ -16,7 +16,7 @@ from homeassistant.components.remote import ( ATTR_ACTIVITY, ATTR_ACTIVITY_LIST, ATTR_CURRENT_ACTIVITY, - DOMAIN, + DOMAIN as REMOTE_DOMAIN, RemoteEntityFeature, ) from homeassistant.const import ( @@ -91,8 +91,8 @@ async def test_activity_remote( assert acc.char_input_source.value == 1 # Set from HomeKit - call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") - call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") + call_turn_on = async_mock_service(hass, REMOTE_DOMAIN, "turn_on") + call_turn_off = async_mock_service(hass, REMOTE_DOMAIN, "turn_off") acc.char_active.client_update_value(1) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 27580949ec2..eb662823b4c 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -4,7 +4,7 @@ from pyhap.loader import get_loader import pytest from homeassistant.components.alarm_control_panel import ( - DOMAIN, + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelEntityFeature, ) from homeassistant.components.homekit.const import ATTR_VALUE @@ -77,10 +77,16 @@ async def test_switch_set_state( assert acc.char_current_state.value == 4 # Set from HomeKit - call_arm_home = async_mock_service(hass, DOMAIN, "alarm_arm_home") - call_arm_away = async_mock_service(hass, DOMAIN, "alarm_arm_away") - call_arm_night = async_mock_service(hass, DOMAIN, "alarm_arm_night") - call_disarm = async_mock_service(hass, DOMAIN, "alarm_disarm") + call_arm_home = async_mock_service( + hass, ALARM_CONTROL_PANEL_DOMAIN, "alarm_arm_home" + ) + call_arm_away = async_mock_service( + hass, ALARM_CONTROL_PANEL_DOMAIN, "alarm_arm_away" + ) + call_arm_night = async_mock_service( + hass, ALARM_CONTROL_PANEL_DOMAIN, "alarm_arm_night" + ) + call_disarm = async_mock_service(hass, ALARM_CONTROL_PANEL_DOMAIN, "alarm_disarm") acc.char_target_state.client_update_value(0) await hass.async_block_till_done() @@ -131,7 +137,9 @@ async def test_no_alarm_code( acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, config) # Set from HomeKit - call_arm_home = async_mock_service(hass, DOMAIN, "alarm_arm_home") + call_arm_home = async_mock_service( + hass, ALARM_CONTROL_PANEL_DOMAIN, "alarm_arm_home" + ) acc.char_target_state.client_update_value(0) await hass.async_block_till_done() diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index 29033887953..76935d314a5 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -13,7 +13,7 @@ from aiohomekit.model.characteristics import ( from aiohomekit.model.services import ServicesTypes from homeassistant.components.climate import ( - DOMAIN, + DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, @@ -113,7 +113,7 @@ async def test_climate_change_thermostat_state( helper = await setup_test_component(hass, get_next_aid(), create_thermostat_service) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT}, blocking=True, @@ -126,7 +126,7 @@ async def test_climate_change_thermostat_state( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.COOL}, blocking=True, @@ -139,7 +139,7 @@ async def test_climate_change_thermostat_state( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT_COOL}, blocking=True, @@ -152,7 +152,7 @@ async def test_climate_change_thermostat_state( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.OFF}, blocking=True, @@ -165,7 +165,7 @@ async def test_climate_change_thermostat_state( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {"entity_id": "climate.testdevice", "fan_mode": "on"}, blocking=True, @@ -178,7 +178,7 @@ async def test_climate_change_thermostat_state( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {"entity_id": "climate.testdevice", "fan_mode": "auto"}, blocking=True, @@ -198,7 +198,7 @@ async def test_climate_check_min_max_values_per_mode( helper = await setup_test_component(hass, get_next_aid(), create_thermostat_service) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT}, blocking=True, @@ -208,7 +208,7 @@ async def test_climate_check_min_max_values_per_mode( assert climate_state.attributes["max_temp"] == 35 await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.COOL}, blocking=True, @@ -218,7 +218,7 @@ async def test_climate_check_min_max_values_per_mode( assert climate_state.attributes["max_temp"] == 35 await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT_COOL}, blocking=True, @@ -235,7 +235,7 @@ async def test_climate_change_thermostat_temperature( helper = await setup_test_component(hass, get_next_aid(), create_thermostat_service) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {"entity_id": "climate.testdevice", "temperature": 21}, blocking=True, @@ -248,7 +248,7 @@ async def test_climate_change_thermostat_temperature( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {"entity_id": "climate.testdevice", "temperature": 25}, blocking=True, @@ -268,14 +268,14 @@ async def test_climate_change_thermostat_temperature_range( helper = await setup_test_component(hass, get_next_aid(), create_thermostat_service) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT_COOL}, blocking=True, ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.testdevice", @@ -303,14 +303,14 @@ async def test_climate_change_thermostat_temperature_range_iphone( helper = await setup_test_component(hass, get_next_aid(), create_thermostat_service) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT_COOL}, blocking=True, ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.testdevice", @@ -338,14 +338,14 @@ async def test_climate_cannot_set_thermostat_temp_range_in_wrong_mode( helper = await setup_test_component(hass, get_next_aid(), create_thermostat_service) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT}, blocking=True, ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.testdevice", @@ -399,7 +399,7 @@ async def test_climate_check_min_max_values_per_mode_sspa_device( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT}, blocking=True, @@ -409,7 +409,7 @@ async def test_climate_check_min_max_values_per_mode_sspa_device( assert climate_state.attributes["max_temp"] == 35 await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.COOL}, blocking=True, @@ -419,7 +419,7 @@ async def test_climate_check_min_max_values_per_mode_sspa_device( assert climate_state.attributes["max_temp"] == 35 await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT_COOL}, blocking=True, @@ -438,14 +438,14 @@ async def test_climate_set_thermostat_temp_on_sspa_device( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT}, blocking=True, ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {"entity_id": "climate.testdevice", "temperature": 21}, blocking=True, @@ -458,7 +458,7 @@ async def test_climate_set_thermostat_temp_on_sspa_device( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT_COOL}, blocking=True, @@ -471,7 +471,7 @@ async def test_climate_set_thermostat_temp_on_sspa_device( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.testdevice", @@ -496,7 +496,7 @@ async def test_climate_set_mode_via_temp( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.testdevice", @@ -514,7 +514,7 @@ async def test_climate_set_mode_via_temp( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { "entity_id": "climate.testdevice", @@ -539,7 +539,7 @@ async def test_climate_change_thermostat_humidity( helper = await setup_test_component(hass, get_next_aid(), create_thermostat_service) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, {"entity_id": "climate.testdevice", "humidity": 50}, blocking=True, @@ -552,7 +552,7 @@ async def test_climate_change_thermostat_humidity( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, {"entity_id": "climate.testdevice", "humidity": 45}, blocking=True, @@ -768,7 +768,7 @@ async def test_heater_cooler_change_thermostat_state( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT}, blocking=True, @@ -781,7 +781,7 @@ async def test_heater_cooler_change_thermostat_state( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.COOL}, blocking=True, @@ -794,7 +794,7 @@ async def test_heater_cooler_change_thermostat_state( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT_COOL}, blocking=True, @@ -807,7 +807,7 @@ async def test_heater_cooler_change_thermostat_state( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.OFF}, blocking=True, @@ -832,7 +832,7 @@ async def test_can_turn_on_after_off( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.OFF}, blocking=True, @@ -845,7 +845,7 @@ async def test_can_turn_on_after_off( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT}, blocking=True, @@ -868,13 +868,13 @@ async def test_heater_cooler_change_thermostat_temperature( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.HEAT}, blocking=True, ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {"entity_id": "climate.testdevice", "temperature": 20}, blocking=True, @@ -887,13 +887,13 @@ async def test_heater_cooler_change_thermostat_temperature( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.COOL}, blocking=True, ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {"entity_id": "climate.testdevice", "temperature": 26}, blocking=True, @@ -915,13 +915,13 @@ async def test_heater_cooler_change_fan_speed( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {"entity_id": "climate.testdevice", "hvac_mode": HVACMode.COOL}, blocking=True, ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {"entity_id": "climate.testdevice", "fan_mode": "low"}, blocking=True, @@ -933,7 +933,7 @@ async def test_heater_cooler_change_fan_speed( }, ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {"entity_id": "climate.testdevice", "fan_mode": "medium"}, blocking=True, @@ -945,7 +945,7 @@ async def test_heater_cooler_change_fan_speed( }, ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {"entity_id": "climate.testdevice", "fan_mode": "high"}, blocking=True, @@ -1121,7 +1121,7 @@ async def test_heater_cooler_change_swing_mode( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, {"entity_id": "climate.testdevice", "swing_mode": "vertical"}, blocking=True, @@ -1134,7 +1134,7 @@ async def test_heater_cooler_change_swing_mode( ) await hass.services.async_call( - DOMAIN, + CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, {"entity_id": "climate.testdevice", "swing_mode": "off"}, blocking=True, diff --git a/tests/components/homekit_controller/test_humidifier.py b/tests/components/homekit_controller/test_humidifier.py index 4b429959c67..07bdb8a2e38 100644 --- a/tests/components/homekit_controller/test_humidifier.py +++ b/tests/components/homekit_controller/test_humidifier.py @@ -6,7 +6,11 @@ from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from homeassistant.components.humidifier import DOMAIN, MODE_AUTO, MODE_NORMAL +from homeassistant.components.humidifier import ( + DOMAIN as HUMIDIFIER_DOMAIN, + MODE_AUTO, + MODE_NORMAL, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -74,7 +78,7 @@ async def test_humidifier_active_state( helper = await setup_test_component(hass, get_next_aid(), create_humidifier_service) await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": helper.entity_id}, blocking=True + HUMIDIFIER_DOMAIN, "turn_on", {"entity_id": helper.entity_id}, blocking=True ) helper.async_assert_service_values( @@ -83,7 +87,7 @@ async def test_humidifier_active_state( ) await hass.services.async_call( - DOMAIN, "turn_off", {"entity_id": helper.entity_id}, blocking=True + HUMIDIFIER_DOMAIN, "turn_off", {"entity_id": helper.entity_id}, blocking=True ) helper.async_assert_service_values( @@ -101,7 +105,7 @@ async def test_dehumidifier_active_state( ) await hass.services.async_call( - DOMAIN, "turn_on", {"entity_id": helper.entity_id}, blocking=True + HUMIDIFIER_DOMAIN, "turn_on", {"entity_id": helper.entity_id}, blocking=True ) helper.async_assert_service_values( @@ -110,7 +114,7 @@ async def test_dehumidifier_active_state( ) await hass.services.async_call( - DOMAIN, "turn_off", {"entity_id": helper.entity_id}, blocking=True + HUMIDIFIER_DOMAIN, "turn_off", {"entity_id": helper.entity_id}, blocking=True ) helper.async_assert_service_values( @@ -208,7 +212,7 @@ async def test_humidifier_set_humidity( helper = await setup_test_component(hass, get_next_aid(), create_humidifier_service) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, "set_humidity", {"entity_id": helper.entity_id, "humidity": 20}, blocking=True, @@ -228,7 +232,7 @@ async def test_dehumidifier_set_humidity( ) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, "set_humidity", {"entity_id": helper.entity_id, "humidity": 20}, blocking=True, @@ -246,7 +250,7 @@ async def test_humidifier_set_mode( helper = await setup_test_component(hass, get_next_aid(), create_humidifier_service) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, "set_mode", {"entity_id": helper.entity_id, "mode": MODE_AUTO}, blocking=True, @@ -260,7 +264,7 @@ async def test_humidifier_set_mode( ) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, "set_mode", {"entity_id": helper.entity_id, "mode": MODE_NORMAL}, blocking=True, @@ -283,7 +287,7 @@ async def test_dehumidifier_set_mode( ) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, "set_mode", {"entity_id": helper.entity_id, "mode": MODE_AUTO}, blocking=True, @@ -297,7 +301,7 @@ async def test_dehumidifier_set_mode( ) await hass.services.async_call( - DOMAIN, + HUMIDIFIER_DOMAIN, "set_mode", {"entity_id": helper.entity_id, "mode": MODE_NORMAL}, blocking=True, diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py index 7035cf979c4..4eef4526a7a 100644 --- a/tests/components/homematicip_cloud/test_lock.py +++ b/tests/components/homematicip_cloud/test_lock.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.lock import ( - DOMAIN, + DOMAIN as LOCK_DOMAIN, STATE_LOCKING, STATE_UNLOCKING, LockEntityFeature, @@ -23,7 +23,7 @@ from .helper import HomeFactory, async_manipulate_test_data, get_and_check_entit async def test_manually_configured_platform(hass: HomeAssistant) -> None: """Test that we do not set up an access point.""" assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {"platform": HMIPC_DOMAIN}} + hass, LOCK_DOMAIN, {LOCK_DOMAIN: {"platform": HMIPC_DOMAIN}} ) assert not hass.data.get(HMIPC_DOMAIN) diff --git a/tests/components/hydrawise/test_valve.py b/tests/components/hydrawise/test_valve.py index 918fae00017..7d769f920e6 100644 --- a/tests/components/hydrawise/test_valve.py +++ b/tests/components/hydrawise/test_valve.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from pydrawise.schema import Zone from syrupy.assertion import SnapshotAssertion -from homeassistant.components.valve import DOMAIN +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, @@ -42,7 +42,7 @@ async def test_services( ) -> None: """Test valve services.""" await hass.services.async_call( - DOMAIN, + VALVE_DOMAIN, SERVICE_OPEN_VALVE, service_data={ATTR_ENTITY_ID: "valve.zone_one"}, blocking=True, @@ -51,7 +51,7 @@ async def test_services( mock_pydrawise.reset_mock() await hass.services.async_call( - DOMAIN, + VALVE_DOMAIN, SERVICE_CLOSE_VALVE, service_data={ATTR_ENTITY_ID: "valve.zone_one"}, blocking=True, diff --git a/tests/components/knx/test_date.py b/tests/components/knx/test_date.py index d3b1ff2058e..1e6e5102bcf 100644 --- a/tests/components/knx/test_date.py +++ b/tests/components/knx/test_date.py @@ -1,6 +1,10 @@ """Test KNX date.""" -from homeassistant.components.date import ATTR_DATE, DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.date import ( + ATTR_DATE, + DOMAIN as DATE_DOMAIN, + SERVICE_SET_VALUE, +) from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS from homeassistant.components.knx.schema import DateSchema from homeassistant.const import CONF_NAME @@ -24,7 +28,7 @@ async def test_date(hass: HomeAssistant, knx: KNXTestKit) -> None: ) # set value await hass.services.async_call( - DOMAIN, + DATE_DOMAIN, SERVICE_SET_VALUE, {"entity_id": "date.test", ATTR_DATE: "1999-03-31"}, blocking=True, diff --git a/tests/components/knx/test_datetime.py b/tests/components/knx/test_datetime.py index 4b66769a8a3..025145ad1a3 100644 --- a/tests/components/knx/test_datetime.py +++ b/tests/components/knx/test_datetime.py @@ -1,6 +1,10 @@ """Test KNX date.""" -from homeassistant.components.datetime import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.datetime import ( + ATTR_DATETIME, + DOMAIN as DATETIME_DOMAIN, + SERVICE_SET_VALUE, +) from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS from homeassistant.components.knx.schema import DateTimeSchema from homeassistant.const import CONF_NAME @@ -27,7 +31,7 @@ async def test_datetime(hass: HomeAssistant, knx: KNXTestKit) -> None: ) # set value await hass.services.async_call( - DOMAIN, + DATETIME_DOMAIN, SERVICE_SET_VALUE, {"entity_id": "datetime.test", ATTR_DATETIME: "2020-01-02T03:04:05+00:00"}, blocking=True, diff --git a/tests/components/knx/test_time.py b/tests/components/knx/test_time.py index 9dc4c401ed8..05f84339742 100644 --- a/tests/components/knx/test_time.py +++ b/tests/components/knx/test_time.py @@ -2,7 +2,11 @@ from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS from homeassistant.components.knx.schema import TimeSchema -from homeassistant.components.time import ATTR_TIME, DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_DOMAIN, + SERVICE_SET_VALUE, +) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, State @@ -24,7 +28,7 @@ async def test_time(hass: HomeAssistant, knx: KNXTestKit) -> None: ) # set value await hass.services.async_call( - DOMAIN, + TIME_DOMAIN, SERVICE_SET_VALUE, {"entity_id": "time.test", ATTR_TIME: "01:02:03"}, blocking=True, From 0459596e97dd15d9dfd92f310cef7b075bc3b596 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 9 Sep 2024 16:02:44 +0200 Subject: [PATCH 0446/1309] Enable hadolint for hassfest docker image and adjust hadolint job (#125146) --- .github/workflows/ci.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d35187a3c45..84ee815c087 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -434,13 +434,17 @@ jobs: runs-on: ubuntu-24.04 needs: - info - - pre-commit + if: | + github.event.inputs.pylint-only != 'true' + && github.event.inputs.mypy-only != 'true' + && github.event.inputs.audit-licenses-only != 'true' strategy: fail-fast: false matrix: file: - Dockerfile - Dockerfile.dev + - script/hassfest/docker/Dockerfile steps: - name: Check out code from GitHub uses: actions/checkout@v4.1.7 From c5453835c258a7625c93de826103b355ecdaa445 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 9 Sep 2024 17:36:19 +0200 Subject: [PATCH 0447/1309] Bump aioopenexchangerates to 0.6.2 (#125593) --- homeassistant/components/openexchangerates/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openexchangerates/manifest.json b/homeassistant/components/openexchangerates/manifest.json index a93a87a0785..cce90d0fb12 100644 --- a/homeassistant/components/openexchangerates/manifest.json +++ b/homeassistant/components/openexchangerates/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/openexchangerates", "iot_class": "cloud_polling", - "requirements": ["aioopenexchangerates==0.4.0"] + "requirements": ["aioopenexchangerates==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6b902f76efa..5487e858ae5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -309,7 +309,7 @@ aionut==4.3.3 aiooncue==0.3.7 # homeassistant.components.openexchangerates -aioopenexchangerates==0.4.0 +aioopenexchangerates==0.6.2 # homeassistant.components.nmap_tracker aiooui==0.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6d723135cb..25da21335fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -291,7 +291,7 @@ aionut==4.3.3 aiooncue==0.3.7 # homeassistant.components.openexchangerates -aioopenexchangerates==0.4.0 +aioopenexchangerates==0.6.2 # homeassistant.components.nmap_tracker aiooui==0.1.6 From e0a221ba1fd125f39470fb78abf40ec712b4198c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Sep 2024 19:27:06 +0200 Subject: [PATCH 0448/1309] Add alias to DOMAIN import in deconz (#125568) --- .../components/deconz/alarm_control_panel.py | 6 +++--- homeassistant/components/deconz/binary_sensor.py | 6 +++--- homeassistant/components/deconz/button.py | 8 ++++---- homeassistant/components/deconz/climate.py | 6 +++--- homeassistant/components/deconz/cover.py | 6 +++--- homeassistant/components/deconz/fan.py | 10 +++++++--- homeassistant/components/deconz/light.py | 6 +++--- homeassistant/components/deconz/lock.py | 6 +++--- homeassistant/components/deconz/number.py | 6 +++--- homeassistant/components/deconz/scene.py | 6 +++--- homeassistant/components/deconz/select.py | 12 ++++++------ homeassistant/components/deconz/sensor.py | 6 +++--- homeassistant/components/deconz/siren.py | 6 +++--- homeassistant/components/deconz/switch.py | 6 +++--- 14 files changed, 50 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index ae230c783f9..a82081dedd2 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -10,7 +10,7 @@ from pydeconz.models.sensor.ancillary_control import ( ) from homeassistant.components.alarm_control_panel import ( - DOMAIN, + DOMAIN as ALARM_CONTROl_PANEL_DOMAIN, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, CodeFormat, @@ -60,7 +60,7 @@ async def async_setup_entry( ) -> None: """Set up the deCONZ alarm control panel devices.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[ALARM_CONTROl_PANEL_DOMAIN] = set() @callback def async_add_sensor(_: EventType, sensor_id: str) -> None: @@ -79,7 +79,7 @@ class DeconzAlarmControlPanel(DeconzDevice[AncillaryControl], AlarmControlPanelE """Representation of a deCONZ alarm control panel.""" _update_key = "panel" - TYPE = DOMAIN + TYPE = ALARM_CONTROl_PANEL_DOMAIN _attr_code_format = CodeFormat.NUMBER _attr_supported_features = ( diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 0b3461b7a12..d1bf955bb2f 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -18,7 +18,7 @@ from pydeconz.models.sensor.vibration import Vibration from pydeconz.models.sensor.water import Water from homeassistant.components.binary_sensor import ( - DOMAIN, + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -165,7 +165,7 @@ async def async_setup_entry( ) -> None: """Set up the deCONZ binary sensor.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[BINARY_SENSOR_DOMAIN] = set() @callback def async_add_sensor(_: EventType, sensor_id: str) -> None: @@ -189,7 +189,7 @@ async def async_setup_entry( class DeconzBinarySensor(DeconzDevice[SensorResources], BinarySensorEntity): """Representation of a deCONZ binary sensor.""" - TYPE = DOMAIN + TYPE = BINARY_SENSOR_DOMAIN entity_description: DeconzBinarySensorDescription def __init__( diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index a915ca56a33..6089e77de32 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -9,7 +9,7 @@ from pydeconz.models.scene import Scene as PydeconzScene from pydeconz.models.sensor.presence import Presence from homeassistant.components.button import ( - DOMAIN, + DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, @@ -51,7 +51,7 @@ async def async_setup_entry( ) -> None: """Set up the deCONZ button entity.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[BUTTON_DOMAIN] = set() @callback def async_add_scene(_: EventType, scene_id: str) -> None: @@ -83,7 +83,7 @@ async def async_setup_entry( class DeconzSceneButton(DeconzSceneMixin, ButtonEntity): """Representation of a deCONZ button entity.""" - TYPE = DOMAIN + TYPE = BUTTON_DOMAIN def __init__( self, @@ -119,7 +119,7 @@ class DeconzPresenceResetButton(DeconzDevice[Presence], ButtonEntity): _attr_entity_category = EntityCategory.CONFIG _attr_device_class = ButtonDeviceClass.RESTART - TYPE = DOMAIN + TYPE = BUTTON_DOMAIN async def async_press(self) -> None: """Store reset presence state.""" diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 45a50d44e36..0d9ff5db97e 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -13,7 +13,7 @@ from pydeconz.models.sensor.thermostat import ( ) from homeassistant.components.climate import ( - DOMAIN, + DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -81,7 +81,7 @@ async def async_setup_entry( ) -> None: """Set up the deCONZ climate devices.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[CLIMATE_DOMAIN] = set() @callback def async_add_climate(_: EventType, climate_id: str) -> None: @@ -98,7 +98,7 @@ async def async_setup_entry( class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity): """Representation of a deCONZ thermostat.""" - TYPE = DOMAIN + TYPE = CLIMATE_DOMAIN _attr_temperature_unit = UnitOfTemperature.CELSIUS _enable_turn_on_off_backwards_compatibility = False diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index b83c62c3367..1018b27a6a5 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -12,7 +12,7 @@ from pydeconz.models.light.cover import Cover from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, - DOMAIN, + DOMAIN as COVER_DOMAIN, CoverDeviceClass, CoverEntity, CoverEntityFeature, @@ -38,7 +38,7 @@ async def async_setup_entry( ) -> None: """Set up covers for deCONZ component.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[COVER_DOMAIN] = set() @callback def async_add_cover(_: EventType, cover_id: str) -> None: @@ -54,7 +54,7 @@ async def async_setup_entry( class DeconzCover(DeconzDevice[Cover], CoverEntity): """Representation of a deCONZ cover.""" - TYPE = DOMAIN + TYPE = COVER_DOMAIN def __init__(self, cover_id: str, hub: DeconzHub) -> None: """Set up cover device.""" diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index 67c759afeda..77733769d9d 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -7,7 +7,11 @@ from typing import Any from pydeconz.models.event import EventType from pydeconz.models.light.light import Light, LightFanSpeed -from homeassistant.components.fan import DOMAIN, FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + DOMAIN as FAN_DOMAIN, + FanEntity, + FanEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,7 +38,7 @@ async def async_setup_entry( ) -> None: """Set up fans for deCONZ component.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[FAN_DOMAIN] = set() @callback def async_add_fan(_: EventType, fan_id: str) -> None: @@ -53,7 +57,7 @@ async def async_setup_entry( class DeconzFan(DeconzDevice[Light], FanEntity): """Representation of a deCONZ fan.""" - TYPE = DOMAIN + TYPE = FAN_DOMAIN _default_on_speed = LightFanSpeed.PERCENT_50 _attr_supported_features = ( diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index cb834f9eee7..b3e5b4f8157 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, - DOMAIN, + DOMAIN as LIGHT_DOMAIN, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, @@ -125,7 +125,7 @@ async def async_setup_entry( ) -> None: """Set up the deCONZ lights and groups from a config entry.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[LIGHT_DOMAIN] = set() @callback def async_add_light(_: EventType, light_id: str) -> None: @@ -170,7 +170,7 @@ class DeconzBaseLight[_LightDeviceT: Group | Light]( ): """Representation of a deCONZ light.""" - TYPE = DOMAIN + TYPE = LIGHT_DOMAIN _attr_color_mode = ColorMode.UNKNOWN def __init__(self, device: _LightDeviceT, hub: DeconzHub) -> None: diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 8729d7de793..505c894374a 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -8,7 +8,7 @@ from pydeconz.models.event import EventType from pydeconz.models.light.lock import Lock from pydeconz.models.sensor.door_lock import DoorLock -from homeassistant.components.lock import DOMAIN, LockEntity +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,7 +24,7 @@ async def async_setup_entry( ) -> None: """Set up locks for deCONZ component.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[LOCK_DOMAIN] = set() @callback def async_add_lock_from_light(_: EventType, lock_id: str) -> None: @@ -53,7 +53,7 @@ async def async_setup_entry( class DeconzLock(DeconzDevice[DoorLock | Lock], LockEntity): """Representation of a deCONZ lock.""" - TYPE = DOMAIN + TYPE = LOCK_DOMAIN @property def is_locked(self) -> bool: diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index f29caf97b52..c18ef68b2a6 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -13,7 +13,7 @@ from pydeconz.models.sensor import SensorBase as PydeconzSensorBase from pydeconz.models.sensor.presence import Presence from homeassistant.components.number import ( - DOMAIN, + DOMAIN as NUMBER_DOMAIN, NumberEntity, NumberEntityDescription, ) @@ -74,7 +74,7 @@ async def async_setup_entry( ) -> None: """Set up the deCONZ number entity.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[NUMBER_DOMAIN] = set() @callback def async_add_sensor(_: EventType, sensor_id: str) -> None: @@ -99,7 +99,7 @@ async def async_setup_entry( class DeconzNumber(DeconzDevice[SensorResources], NumberEntity): """Representation of a deCONZ number entity.""" - TYPE = DOMAIN + TYPE = NUMBER_DOMAIN entity_description: DeconzNumberDescription def __init__( diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index f121c3107b0..a131add9c28 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -6,7 +6,7 @@ from typing import Any from pydeconz.models.event import EventType -from homeassistant.components.scene import DOMAIN, Scene +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,7 +22,7 @@ async def async_setup_entry( ) -> None: """Set up scenes for deCONZ integration.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[SCENE_DOMAIN] = set() @callback def async_add_scene(_: EventType, scene_id: str) -> None: @@ -39,7 +39,7 @@ async def async_setup_entry( class DeconzScene(DeconzSceneMixin, Scene): """Representation of a deCONZ scene.""" - TYPE = DOMAIN + TYPE = SCENE_DOMAIN async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" diff --git a/homeassistant/components/deconz/select.py b/homeassistant/components/deconz/select.py index 7f3f8cca060..39c266b4a35 100644 --- a/homeassistant/components/deconz/select.py +++ b/homeassistant/components/deconz/select.py @@ -11,7 +11,7 @@ from pydeconz.models.sensor.presence import ( PresenceConfigTriggerDistance, ) -from homeassistant.components.select import DOMAIN, SelectEntity +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -35,7 +35,7 @@ async def async_setup_entry( ) -> None: """Set up the deCONZ button entity.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[SELECT_DOMAIN] = set() @callback def async_add_air_purifier_sensor(_: EventType, sensor_id: str) -> None: @@ -85,7 +85,7 @@ class DeconzAirPurifierFanMode(DeconzDevice[AirPurifier], SelectEntity): AirPurifierFanMode.SPEED_5.value, ] - TYPE = DOMAIN + TYPE = SELECT_DOMAIN @property def current_option(self) -> str: @@ -113,7 +113,7 @@ class DeconzPresenceDeviceModeSelect(DeconzDevice[Presence], SelectEntity): PresenceConfigDeviceMode.UNDIRECTED.value, ] - TYPE = DOMAIN + TYPE = SELECT_DOMAIN @property def current_option(self) -> str | None: @@ -140,7 +140,7 @@ class DeconzPresenceSensitivitySelect(DeconzDevice[Presence], SelectEntity): _attr_entity_category = EntityCategory.CONFIG _attr_options = list(SENSITIVITY_TO_DECONZ) - TYPE = DOMAIN + TYPE = SELECT_DOMAIN @property def current_option(self) -> str | None: @@ -171,7 +171,7 @@ class DeconzPresenceTriggerDistanceSelect(DeconzDevice[Presence], SelectEntity): PresenceConfigTriggerDistance.NEAR.value, ] - TYPE = DOMAIN + TYPE = SELECT_DOMAIN @property def current_option(self) -> str | None: diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 8b2b4896cdf..9f116b5ab0b 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -28,7 +28,7 @@ from pydeconz.models.sensor.temperature import Temperature from pydeconz.models.sensor.time import Time from homeassistant.components.sensor import ( - DOMAIN, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -336,7 +336,7 @@ async def async_setup_entry( ) -> None: """Set up the deCONZ sensors.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[SENSOR_DOMAIN] = set() known_device_entities: dict[str, set[str]] = { description.key: set() @@ -393,7 +393,7 @@ async def async_setup_entry( class DeconzSensor(DeconzDevice[SensorResources], SensorEntity): """Representation of a deCONZ sensor.""" - TYPE = DOMAIN + TYPE = SENSOR_DOMAIN entity_description: DeconzSensorDescription def __init__( diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py index deb1c98f151..aa9a943095d 100644 --- a/homeassistant/components/deconz/siren.py +++ b/homeassistant/components/deconz/siren.py @@ -9,7 +9,7 @@ from pydeconz.models.light.siren import Siren from homeassistant.components.siren import ( ATTR_DURATION, - DOMAIN, + DOMAIN as SIREN_DOMAIN, SirenEntity, SirenEntityFeature, ) @@ -28,7 +28,7 @@ async def async_setup_entry( ) -> None: """Set up sirens for deCONZ component.""" hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[SIREN_DOMAIN] = set() @callback def async_add_siren(_: EventType, siren_id: str) -> None: @@ -45,7 +45,7 @@ async def async_setup_entry( class DeconzSiren(DeconzDevice[Siren], SirenEntity): """Representation of a deCONZ siren.""" - TYPE = DOMAIN + TYPE = SIREN_DOMAIN _attr_supported_features = ( SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index e176d9c7710..2533b5cbfea 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -7,7 +7,7 @@ from typing import Any from pydeconz.models.event import EventType from pydeconz.models.light.light import Light -from homeassistant.components.switch import DOMAIN, SwitchEntity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -27,7 +27,7 @@ async def async_setup_entry( Switches are based on the same device class as lights in deCONZ. """ hub = DeconzHub.get_hub(hass, config_entry) - hub.entities[DOMAIN] = set() + hub.entities[SWITCH_DOMAIN] = set() @callback def async_add_switch(_: EventType, switch_id: str) -> None: @@ -46,7 +46,7 @@ async def async_setup_entry( class DeconzPowerPlug(DeconzDevice[Light], SwitchEntity): """Representation of a deCONZ power plug.""" - TYPE = DOMAIN + TYPE = SWITCH_DOMAIN @property def is_on(self) -> bool: From ded34561b11d84467c515c328c40eaac2473248a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 9 Sep 2024 21:14:41 +0200 Subject: [PATCH 0449/1309] Simplify cv._base_trigger_list_flatten (#125613) --- homeassistant/helpers/config_validation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 059be3026e5..6a92599921b 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1786,7 +1786,7 @@ def _base_trigger_list_flatten(triggers: list[Any]) -> list[Any]: """Flatten trigger arrays containing 'triggers:' sublists into a single list of triggers.""" flatlist = [] for t in triggers: - if CONF_TRIGGERS in t and len(t.keys()) == 1: + if CONF_TRIGGERS in t and len(t) == 1: triggerlist = ensure_list(t[CONF_TRIGGERS]) flatlist.extend(triggerlist) else: From e750f8f457a9ac89b1eb125ab2b927d81393bd69 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Sep 2024 21:32:33 +0200 Subject: [PATCH 0450/1309] Add alias to DOMAIN import (part 4) (#125563) * Add alias to DOMAIN import (part 4) * Simplify * More integration * Apply suggestions from code review Co-authored-by: G Johansson * Revert "Apply suggestions from code review" This reverts commit 07471d3629bd83ddfc2e254fc4cda3053461570d. --------- Co-authored-by: G Johansson --- homeassistant/components/flux/switch.py | 4 ++-- homeassistant/components/heos/media_player.py | 4 ++-- homeassistant/components/iaqualink/binary_sensor.py | 7 +++++-- homeassistant/components/iaqualink/light.py | 5 +++-- homeassistant/components/iaqualink/sensor.py | 9 +++++++-- homeassistant/components/iaqualink/switch.py | 5 +++-- homeassistant/components/lutron_caseta/cover.py | 4 ++-- homeassistant/components/lutron_caseta/fan.py | 8 ++++++-- homeassistant/components/lutron_caseta/light.py | 4 ++-- homeassistant/components/lutron_caseta/switch.py | 4 ++-- homeassistant/components/mystrom/binary_sensor.py | 7 +++++-- .../components/point/alarm_control_panel.py | 6 ++++-- homeassistant/components/point/binary_sensor.py | 6 ++++-- homeassistant/components/point/sensor.py | 6 ++++-- homeassistant/components/push/camera.py | 4 ++-- .../components/screenlogic/binary_sensor.py | 10 +++++++--- homeassistant/components/screenlogic/number.py | 6 +++--- homeassistant/components/screenlogic/sensor.py | 6 +++--- homeassistant/components/unifi/switch.py | 12 +++++++----- homeassistant/components/universal/media_player.py | 12 +++++++++--- 20 files changed, 82 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index fac31d445cc..8a3d7ec7260 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -21,7 +21,7 @@ from homeassistant.components.light import ( VALID_TRANSITION, is_on, ) -from homeassistant.components.switch import DOMAIN, SwitchEntity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.const import ( ATTR_ENTITY_ID, CONF_BRIGHTNESS, @@ -178,7 +178,7 @@ async def async_setup_platform( await flux.async_flux_update() service_name = slugify(f"{name} update") - hass.services.async_register(DOMAIN, service_name, async_update) + hass.services.async_register(SWITCH_DOMAIN, service_name, async_update) class FluxSwitch(SwitchEntity, RestoreEntity): diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 858ebd225b7..0f9f7facd33 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -13,7 +13,7 @@ from pyheos import HeosError, const as heos_const from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, - DOMAIN, + DOMAIN as MEDIA_PLAYER_DOMAIN, BrowseMedia, MediaPlayerEnqueue, MediaPlayerEntity, @@ -83,7 +83,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Add media players for a config entry.""" - players = hass.data[HEOS_DOMAIN][DOMAIN] + players = hass.data[HEOS_DOMAIN][MEDIA_PLAYER_DOMAIN] devices = [HeosMediaPlayer(player) for player in players.values()] async_add_entities(devices, True) diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py index 06dbcf18e4a..92e152701a4 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from iaqualink.device import AqualinkBinarySensor from homeassistant.components.binary_sensor import ( - DOMAIN, + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) @@ -26,7 +26,10 @@ async def async_setup_entry( ) -> None: """Set up discovered binary sensors.""" async_add_entities( - (HassAqualinkBinarySensor(dev) for dev in hass.data[AQUALINK_DOMAIN][DOMAIN]), + ( + HassAqualinkBinarySensor(dev) + for dev in hass.data[AQUALINK_DOMAIN][BINARY_SENSOR_DOMAIN] + ), True, ) diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index bce4f2c9855..74ffe489a51 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -9,7 +9,7 @@ from iaqualink.device import AqualinkLight from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, - DOMAIN, + DOMAIN as LIGHT_DOMAIN, ColorMode, LightEntity, LightEntityFeature, @@ -32,7 +32,8 @@ async def async_setup_entry( ) -> None: """Set up discovered lights.""" async_add_entities( - (HassAqualinkLight(dev) for dev in hass.data[AQUALINK_DOMAIN][DOMAIN]), True + (HassAqualinkLight(dev) for dev in hass.data[AQUALINK_DOMAIN][LIGHT_DOMAIN]), + True, ) diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index 8e3983e9c91..35dc01928ec 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -4,7 +4,11 @@ from __future__ import annotations from iaqualink.device import AqualinkSensor -from homeassistant.components.sensor import DOMAIN, SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, + SensorDeviceClass, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant @@ -23,7 +27,8 @@ async def async_setup_entry( ) -> None: """Set up discovered sensors.""" async_add_entities( - (HassAqualinkSensor(dev) for dev in hass.data[AQUALINK_DOMAIN][DOMAIN]), True + (HassAqualinkSensor(dev) for dev in hass.data[AQUALINK_DOMAIN][SENSOR_DOMAIN]), + True, ) diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index e681879855b..43b35b456a3 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -6,7 +6,7 @@ from typing import Any from iaqualink.device import AqualinkSwitch -from homeassistant.components.switch import DOMAIN, SwitchEntity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,7 +25,8 @@ async def async_setup_entry( ) -> None: """Set up discovered switches.""" async_add_entities( - (HassAqualinkSwitch(dev) for dev in hass.data[AQUALINK_DOMAIN][DOMAIN]), True + (HassAqualinkSwitch(dev) for dev in hass.data[AQUALINK_DOMAIN][SWITCH_DOMAIN]), + True, ) diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 3edb62c0d98..47711abb80e 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -5,7 +5,7 @@ from typing import Any from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, - DOMAIN, + DOMAIN as COVER_DOMAIN, CoverDeviceClass, CoverEntity, CoverEntityFeature, @@ -122,7 +122,7 @@ async def async_setup_entry( """ data = config_entry.runtime_data bridge = data.bridge - cover_devices = bridge.get_devices_by_domain(DOMAIN) + cover_devices = bridge.get_devices_by_domain(COVER_DOMAIN) async_add_entities( # default to standard LutronCasetaCover type if the pylutron type is not yet mapped PYLUTRON_TYPE_TO_CLASSES.get(cover_device["type"], LutronCasetaShade)( diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index 1e7c0b2265c..f15f6d53e15 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -6,7 +6,11 @@ from typing import Any from pylutron_caseta import FAN_HIGH, FAN_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_OFF -from homeassistant.components.fan import DOMAIN, FanEntity, FanEntityFeature +from homeassistant.components.fan import ( + DOMAIN as FAN_DOMAIN, + FanEntity, + FanEntityFeature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -33,7 +37,7 @@ async def async_setup_entry( """ data = config_entry.runtime_data bridge = data.bridge - fan_devices = bridge.get_devices_by_domain(DOMAIN) + fan_devices = bridge.get_devices_by_domain(FAN_DOMAIN) async_add_entities(LutronCasetaFan(fan_device, data) for fan_device in fan_devices) diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index c0cf9449f87..7eed03a1e06 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE, - DOMAIN, + DOMAIN as LIGHT_DOMAIN, ColorMode, LightEntity, LightEntityFeature, @@ -62,7 +62,7 @@ async def async_setup_entry( """ data = config_entry.runtime_data bridge = data.bridge - light_devices = bridge.get_devices_by_domain(DOMAIN) + light_devices = bridge.get_devices_by_domain(LIGHT_DOMAIN) async_add_entities( LutronCasetaLight(light_device, data) for light_device in light_devices ) diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index b7ec5b58b04..b8543309fbf 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -2,7 +2,7 @@ from typing import Any -from homeassistant.components.switch import DOMAIN, SwitchEntity +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,7 +22,7 @@ async def async_setup_entry( """ data = config_entry.runtime_data bridge = data.bridge - switch_devices = bridge.get_devices_by_domain(DOMAIN) + switch_devices = bridge.get_devices_by_domain(SWITCH_DOMAIN) async_add_entities( LutronCasetaLight(switch_device, data) for switch_device in switch_devices ) diff --git a/homeassistant/components/mystrom/binary_sensor.py b/homeassistant/components/mystrom/binary_sensor.py index 17a1da75a96..c63ab4e5f3b 100644 --- a/homeassistant/components/mystrom/binary_sensor.py +++ b/homeassistant/components/mystrom/binary_sensor.py @@ -5,7 +5,10 @@ from __future__ import annotations from http import HTTPStatus import logging -from homeassistant.components.binary_sensor import DOMAIN, BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorEntity, +) from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -55,7 +58,7 @@ class MyStromView(HomeAssistantView): ) button_id = data[button_action] - entity_id = f"{DOMAIN}.{button_id}_{button_action}" + entity_id = f"{BINARY_SENSOR_DOMAIN}.{button_id}_{button_action}" if entity_id not in self.buttons: _LOGGER.info( "New myStrom button/action detected: %s/%s", button_id, button_action diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 844d1eba553..70c19056397 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -6,7 +6,7 @@ from collections.abc import Callable import logging from homeassistant.components.alarm_control_panel import ( - DOMAIN, + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, AlarmControlPanelEntity, AlarmControlPanelEntityFeature, ) @@ -47,7 +47,9 @@ async def async_setup_entry( async_add_entities([MinutPointAlarmControl(client, home_id)], True) async_dispatcher_connect( - hass, POINT_DISCOVERY_NEW.format(DOMAIN, POINT_DOMAIN), async_discover_home + hass, + POINT_DISCOVERY_NEW.format(ALARM_CONTROL_PANEL_DOMAIN, POINT_DOMAIN), + async_discover_home, ) diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index 7a698925db6..db3a7328e00 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -7,7 +7,7 @@ import logging from pypoint import EVENTS from homeassistant.components.binary_sensor import ( - DOMAIN, + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) @@ -60,7 +60,9 @@ async def async_setup_entry( ) async_dispatcher_connect( - hass, POINT_DISCOVERY_NEW.format(DOMAIN, POINT_DOMAIN), async_discover_sensor + hass, + POINT_DISCOVERY_NEW.format(BINARY_SENSOR_DOMAIN, POINT_DOMAIN), + async_discover_sensor, ) diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index f648bb4daf9..446a67273fc 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from homeassistant.components.sensor import ( - DOMAIN, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -64,7 +64,9 @@ async def async_setup_entry( ) async_dispatcher_connect( - hass, POINT_DISCOVERY_NEW.format(DOMAIN, POINT_DOMAIN), async_discover_sensor + hass, + POINT_DISCOVERY_NEW.format(SENSOR_DOMAIN, POINT_DOMAIN), + async_discover_sensor, ) diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index eb51ba49aa2..6e75cbec420 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components import webhook from homeassistant.components.camera import ( - DOMAIN, + DOMAIN as CAMERA_DOMAIN, PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA, STATE_IDLE, Camera, @@ -121,7 +121,7 @@ class PushCamera(Camera): try: webhook.async_register( - self.hass, DOMAIN, self.name, self.webhook_id, handle_webhook + self.hass, CAMERA_DOMAIN, self.name, self.webhook_id, handle_webhook ) except ValueError: _LOGGER.error( diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 13582b81196..fda1c348edf 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -9,7 +9,7 @@ from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.binary_sensor import ( - DOMAIN, + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -202,7 +202,9 @@ async def async_setup_entry( chem_sensor_description.key, ) if EQUIPMENT_FLAG.INTELLICHEM not in gateway.equipment_flags: - cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) + cleanup_excluded_entity( + coordinator, BINARY_SENSOR_DOMAIN, chem_sensor_data_path + ) continue if gateway.get_data(*chem_sensor_data_path): entities.append( @@ -216,7 +218,9 @@ async def async_setup_entry( scg_sensor_description.key, ) if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: - cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) + cleanup_excluded_entity( + coordinator, BINARY_SENSOR_DOMAIN, scg_sensor_data_path + ) continue if gateway.get_data(*scg_sensor_data_path): entities.append( diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index c5d67b8f285..d0eb6a71ec8 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -9,7 +9,7 @@ from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.number import ( - DOMAIN, + DOMAIN as NUMBER_DOMAIN, NumberEntity, NumberEntityDescription, NumberMode, @@ -111,7 +111,7 @@ async def async_setup_entry( chem_number_description.key, ) if EQUIPMENT_FLAG.INTELLICHEM not in gateway.equipment_flags: - cleanup_excluded_entity(coordinator, DOMAIN, chem_number_data_path) + cleanup_excluded_entity(coordinator, NUMBER_DOMAIN, chem_number_data_path) continue if gateway.get_data(*chem_number_data_path): entities.append( @@ -124,7 +124,7 @@ async def async_setup_entry( scg_number_description.key, ) if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: - cleanup_excluded_entity(coordinator, DOMAIN, scg_number_data_path) + cleanup_excluded_entity(coordinator, NUMBER_DOMAIN, scg_number_data_path) continue if gateway.get_data(*scg_number_data_path): entities.append(ScreenLogicSCGNumber(coordinator, scg_number_description)) diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 0b8e4147420..c580204221f 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -12,7 +12,7 @@ from screenlogicpy.device_const.pump import PUMP_TYPE from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.sensor import ( - DOMAIN, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -267,7 +267,7 @@ async def async_setup_entry( chem_sensor_description.key, ) if EQUIPMENT_FLAG.INTELLICHEM not in gateway.equipment_flags: - cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) + cleanup_excluded_entity(coordinator, SENSOR_DOMAIN, chem_sensor_data_path) continue if gateway.get_data(*chem_sensor_data_path): chem_sensor_description = dataclasses.replace( @@ -282,7 +282,7 @@ async def async_setup_entry( scg_sensor_description.key, ) if EQUIPMENT_FLAG.CHLORINATOR not in gateway.equipment_flags: - cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) + cleanup_excluded_entity(coordinator, SENSOR_DOMAIN, scg_sensor_data_path) continue if gateway.get_data(*scg_sensor_data_path): scg_sensor_description = dataclasses.replace( diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 93a0c81a24e..2af610480fc 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -35,7 +35,7 @@ from aiounifi.models.traffic_rule import TrafficRule, TrafficRuleEnableRequest from aiounifi.models.wlan import Wlan, WlanEnableRequest from homeassistant.components.switch import ( - DOMAIN, + DOMAIN as SWITCH_DOMAIN, SwitchDeviceClass, SwitchEntity, SwitchEntityDescription, @@ -88,7 +88,7 @@ def async_dpi_group_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: """Create device registry entry for DPI group.""" return DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, f"unifi_controller_{obj_id}")}, + identifiers={(SWITCH_DOMAIN, f"unifi_controller_{obj_id}")}, manufacturer=ATTR_MANUFACTURER, model="UniFi Network", name="UniFi Network", @@ -102,7 +102,7 @@ def async_unifi_network_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo assert unique_id is not None return DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, unique_id)}, + identifiers={(SWITCH_DOMAIN, unique_id)}, manufacturer=ATTR_MANUFACTURER, model="UniFi Network", name="UniFi Network", @@ -307,12 +307,14 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) def update_unique_id(obj_id: str, type_name: str) -> None: """Rework unique ID.""" new_unique_id = f"{type_name}-{obj_id}" - if ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, new_unique_id): + if ent_reg.async_get_entity_id(SWITCH_DOMAIN, UNIFI_DOMAIN, new_unique_id): return prefix, _, suffix = obj_id.partition("_") unique_id = f"{prefix}-{type_name}-{suffix}" - if entity_id := ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, unique_id): + if entity_id := ent_reg.async_get_entity_id( + SWITCH_DOMAIN, UNIFI_DOMAIN, unique_id + ): ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) for obj_id in hub.api.outlets: diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index c5bd9fb50c4..dda5230466a 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -35,7 +35,7 @@ from homeassistant.components.media_player import ( ATTR_SOUND_MODE, ATTR_SOUND_MODE_LIST, DEVICE_CLASSES_SCHEMA, - DOMAIN, + DOMAIN as MEDIA_PLAYER_DOMAIN, PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, SERVICE_CLEAR_PLAYLIST, SERVICE_PLAY_MEDIA, @@ -292,7 +292,11 @@ class UniversalMediaPlayer(MediaPlayerEntity): service_data[ATTR_ENTITY_ID] = active_child.entity_id await self.hass.services.async_call( - DOMAIN, service_name, service_data, blocking=True, context=self._context + MEDIA_PLAYER_DOMAIN, + service_name, + service_data, + blocking=True, + context=self._context, ) @property @@ -651,7 +655,9 @@ class UniversalMediaPlayer(MediaPlayerEntity): entity_id = self._browse_media_entity if not entity_id and self._child_state: entity_id = self._child_state.entity_id - component: EntityComponent[MediaPlayerEntity] = self.hass.data[DOMAIN] + component: EntityComponent[MediaPlayerEntity] = self.hass.data[ + MEDIA_PLAYER_DOMAIN + ] if entity_id and (entity := component.get_entity(entity_id)): return await entity.async_browse_media(media_content_type, media_content_id) raise NotImplementedError From ce4a62574a7529340d5040c9397183f18359e140 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Sep 2024 22:05:27 +0200 Subject: [PATCH 0451/1309] Add alias to DOMAIN import (part 1) (#125560) * Add alias to DOMAIN import (part 1) * Revert tomato/xiaomi --- .../components/actiontec/device_tracker.py | 4 ++-- .../components/arris_tg2492lg/device_tracker.py | 4 ++-- homeassistant/components/aruba/device_tracker.py | 4 ++-- homeassistant/components/bbox/device_tracker.py | 4 ++-- .../components/bt_home_hub_5/device_tracker.py | 4 ++-- .../components/bt_smarthub/device_tracker.py | 4 ++-- .../components/cisco_ios/device_tracker.py | 4 ++-- .../cisco_mobility_express/device_tracker.py | 4 ++-- homeassistant/components/ddwrt/device_tracker.py | 4 ++-- .../components/hitron_coda/device_tracker.py | 4 ++-- .../components/linksys_smart/device_tracker.py | 4 ++-- homeassistant/components/luci/device_tracker.py | 4 ++-- .../components/owntracks/device_tracker.py | 4 ++-- .../components/quantum_gateway/device_tracker.py | 4 ++-- homeassistant/components/sky_hub/device_tracker.py | 4 ++-- homeassistant/components/snmp/device_tracker.py | 4 ++-- .../components/swisscom/device_tracker.py | 4 ++-- .../components/synology_srm/device_tracker.py | 4 ++-- homeassistant/components/thomson/device_tracker.py | 4 ++-- homeassistant/components/unifi/device_tracker.py | 14 +++++++++++--- .../components/unifi_direct/device_tracker.py | 4 ++-- .../components/upc_connect/device_tracker.py | 4 ++-- 22 files changed, 53 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 8cab6552857..801ddd00a5a 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -9,7 +9,7 @@ from typing import Final import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -36,7 +36,7 @@ def get_scanner( hass: HomeAssistant, config: ConfigType ) -> ActiontecDeviceScanner | None: """Validate the configuration and return an Actiontec scanner.""" - scanner = ActiontecDeviceScanner(config[DOMAIN]) + scanner = ActiontecDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index 58daead34f2..c3650587690 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -7,7 +7,7 @@ from arris_tg2492lg import ConnectBox, Device import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -31,7 +31,7 @@ async def async_get_scanner( hass: HomeAssistant, config: ConfigType ) -> ArrisDeviceScanner | None: """Return the Arris device scanner if successful.""" - conf = config[DOMAIN] + conf = config[DEVICE_TRACKER_DOMAIN] url = f"http://{conf[CONF_HOST]}" websession = async_get_clientsession(hass) connect_box = ConnectBox(websession, url, conf[CONF_PASSWORD]) diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 4959ff7ef03..ef622ef9826 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -10,7 +10,7 @@ import pexpect import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -38,7 +38,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> ArubaDeviceScanner | None: """Validate the configuration and return a Aruba scanner.""" - scanner = ArubaDeviceScanner(config[DOMAIN]) + scanner = ArubaDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index 7157c47830c..20ee0fa2820 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -10,7 +10,7 @@ import pybbox import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -34,7 +34,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> BboxDeviceScanner | None: """Validate the configuration and return a Bbox scanner.""" - scanner = BboxDeviceScanner(config[DOMAIN]) + scanner = BboxDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index 60ded009d5f..10c1b32c310 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -8,7 +8,7 @@ import bthomehub5_devicelist import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -30,7 +30,7 @@ def get_scanner( hass: HomeAssistant, config: ConfigType ) -> BTHomeHub5DeviceScanner | None: """Return a BT Home Hub 5 scanner if successful.""" - scanner = BTHomeHub5DeviceScanner(config[DOMAIN]) + scanner = BTHomeHub5DeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 4b52f38ff31..3e2565e0904 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -9,7 +9,7 @@ from btsmarthub_devicelist import BTSmartHub import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -33,7 +33,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> BTSmartHubScanner | None: """Return a BT Smart Hub scanner if successful.""" - info = config[DOMAIN] + info = config[DEVICE_TRACKER_DOMAIN] smarthub_client = BTSmartHub( router_ip=info[CONF_HOST], smarthub_model=info.get(CONF_SMARTHUB_MODEL) ) diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 485a825b51f..90c3e227615 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -9,7 +9,7 @@ from pexpect import pxssh import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -34,7 +34,7 @@ PLATFORM_SCHEMA = vol.All( def get_scanner(hass: HomeAssistant, config: ConfigType) -> CiscoDeviceScanner | None: """Validate the configuration and return a Cisco scanner.""" - scanner = CiscoDeviceScanner(config[DOMAIN]) + scanner = CiscoDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index 38d2c78c66a..2c7398ae172 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -8,7 +8,7 @@ from ciscomobilityexpress.ciscome import CiscoMobilityExpress import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -42,7 +42,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> CiscoMEDeviceScanner | None: """Validate the configuration and return a Cisco ME scanner.""" - config = config[DOMAIN] + config = config[DEVICE_TRACKER_DOMAIN] controller = CiscoMobilityExpress( config[CONF_HOST], diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py index 5d31d16a530..d72496e4d1e 100644 --- a/homeassistant/components/ddwrt/device_tracker.py +++ b/homeassistant/components/ddwrt/device_tracker.py @@ -10,7 +10,7 @@ import requests import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -50,7 +50,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> DdWrtDeviceScanner | None: """Validate the configuration and return a DD-WRT scanner.""" try: - return DdWrtDeviceScanner(config[DOMAIN]) + return DdWrtDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) except ConnectionError: return None diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index 61199e4b2f7..af1c17689c7 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -10,7 +10,7 @@ import requests import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -37,7 +37,7 @@ def get_scanner( _hass: HomeAssistant, config: ConfigType ) -> HitronCODADeviceScanner | None: """Validate the configuration and return a Hitron CODA-4582U scanner.""" - scanner = HitronCODADeviceScanner(config[DOMAIN]) + scanner = HitronCODADeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py index 45ae1d328dd..3bd47e59d48 100644 --- a/homeassistant/components/linksys_smart/device_tracker.py +++ b/homeassistant/components/linksys_smart/device_tracker.py @@ -9,7 +9,7 @@ import requests import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -32,7 +32,7 @@ def get_scanner( ) -> LinksysSmartWifiDeviceScanner | None: """Validate the configuration and return a Linksys AP scanner.""" try: - return LinksysSmartWifiDeviceScanner(config[DOMAIN]) + return LinksysSmartWifiDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) except ConnectionError: return None diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py index 59d4d12ddf6..cf04cdb292a 100644 --- a/homeassistant/components/luci/device_tracker.py +++ b/homeassistant/components/luci/device_tracker.py @@ -8,7 +8,7 @@ from openwrt_luci_rpc import OpenWrtRpc import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -41,7 +41,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> LuciDeviceScanner | None: """Validate the configuration and return a Luci scanner.""" - scanner = LuciDeviceScanner(config[DOMAIN]) + scanner = LuciDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 31af3d845ae..6a6f0f078b1 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -2,7 +2,7 @@ from homeassistant.components.device_tracker import ( ATTR_SOURCE_TYPE, - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, TrackerEntity, ) @@ -66,7 +66,7 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): """Set up OwnTracks entity.""" self._dev_id = dev_id self._data = data or {} - self.entity_id = f"{DOMAIN}.{dev_id}" + self.entity_id = f"{DEVICE_TRACKER_DOMAIN}.{dev_id}" @property def unique_id(self): diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py index 88cb5d60028..dc68472d94e 100644 --- a/homeassistant/components/quantum_gateway/device_tracker.py +++ b/homeassistant/components/quantum_gateway/device_tracker.py @@ -9,7 +9,7 @@ from requests.exceptions import RequestException import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -35,7 +35,7 @@ def get_scanner( hass: HomeAssistant, config: ConfigType ) -> QuantumGatewayDeviceScanner | None: """Validate the configuration and return a Quantum Gateway scanner.""" - scanner = QuantumGatewayDeviceScanner(config[DOMAIN]) + scanner = QuantumGatewayDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index 140a174cc97..b0ad48ed985 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -8,7 +8,7 @@ from pyskyqhub.skyq_hub import SkyQHub import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -29,7 +29,7 @@ async def async_get_scanner( hass: HomeAssistant, config: ConfigType ) -> SkyHubDeviceScanner | None: """Return a Sky Hub scanner if successful.""" - host = config[DOMAIN].get(CONF_HOST, "192.168.1.254") + host = config[DEVICE_TRACKER_DOMAIN].get(CONF_HOST, "192.168.1.254") websession = async_get_clientsession(hass) hub = SkyQHub(websession, host) diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 9741a48dd9f..3c4a0a0725c 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -18,7 +18,7 @@ from pysnmp.hlapi.asyncio import ( import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -59,7 +59,7 @@ async def async_get_scanner( hass: HomeAssistant, config: ConfigType ) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" - scanner = SnmpScanner(config[DOMAIN]) + scanner = SnmpScanner(config[DEVICE_TRACKER_DOMAIN]) await scanner.async_init(hass) return scanner if scanner.success_init else None diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index c13e5a322aa..94b6ddd4efd 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -9,7 +9,7 @@ import requests import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -31,7 +31,7 @@ def get_scanner( hass: HomeAssistant, config: ConfigType ) -> SwisscomDeviceScanner | None: """Return the Swisscom device scanner.""" - scanner = SwisscomDeviceScanner(config[DOMAIN]) + scanner = SwisscomDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 7c7343e88f6..962849df360 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -8,7 +8,7 @@ import synology_srm import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -75,7 +75,7 @@ def get_scanner( hass: HomeAssistant, config: ConfigType ) -> SynologySrmDeviceScanner | None: """Validate the configuration and return Synology SRM scanner.""" - scanner = SynologySrmDeviceScanner(config[DOMAIN]) + scanner = SynologySrmDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index 339b12f0dc9..f1da5f19f91 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -9,7 +9,7 @@ import telnetlib # pylint: disable=deprecated-module import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -41,7 +41,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> ThomsonDeviceScanner | None: """Validate the configuration and return a THOMSON scanner.""" - scanner = ThomsonDeviceScanner(config[DOMAIN]) + scanner = ThomsonDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index aae1194b70d..eff8d9813db 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -18,7 +18,11 @@ from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey -from homeassistant.components.device_tracker import DOMAIN, ScannerEntity, SourceType +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, + ScannerEntity, + SourceType, +) from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -198,11 +202,15 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry) def update_unique_id(obj_id: str) -> None: """Rework unique ID.""" new_unique_id = f"{hub.site}-{obj_id}" - if ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, new_unique_id): + if ent_reg.async_get_entity_id( + DEVICE_TRACKER_DOMAIN, UNIFI_DOMAIN, new_unique_id + ): return unique_id = f"{obj_id}-{hub.site}" - if entity_id := ent_reg.async_get_entity_id(DOMAIN, UNIFI_DOMAIN, unique_id): + if entity_id := ent_reg.async_get_entity_id( + DEVICE_TRACKER_DOMAIN, UNIFI_DOMAIN, unique_id + ): ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) for obj_id in list(hub.api.clients) + list(hub.api.clients_all): diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index c2cb9eba632..144cbd4dec7 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -9,7 +9,7 @@ from unifi_ap import UniFiAP, UniFiAPConnectionException, UniFiAPDataException import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -34,7 +34,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> UnifiDeviceScanner | None: """Validate the configuration and return a Unifi direct scanner.""" - scanner = UnifiDeviceScanner(config[DOMAIN]) + scanner = UnifiDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.update_clients() else None diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index 1ec6dcd3107..c279be78666 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -9,7 +9,7 @@ from connect_box.exceptions import ConnectBoxError, ConnectBoxLoginError import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -35,7 +35,7 @@ async def async_get_scanner( hass: HomeAssistant, config: ConfigType ) -> UPCDeviceScanner | None: """Return the UPC device scanner.""" - conf = config[DOMAIN] + conf = config[DEVICE_TRACKER_DOMAIN] session = async_get_clientsession(hass) connect_box = ConnectBox(session, conf[CONF_PASSWORD], host=conf[CONF_HOST]) From c9a936f375a3219925cc064d0f63ec95ae51299e Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 9 Sep 2024 22:32:51 +0200 Subject: [PATCH 0452/1309] Catch Forecast.solar ConnectionError when API down (#125621) Catch Forecast.solar connection errors --- homeassistant/components/forecast_solar/coordinator.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/forecast_solar/coordinator.py b/homeassistant/components/forecast_solar/coordinator.py index 1de5edddbef..c9c062a0c88 100644 --- a/homeassistant/components/forecast_solar/coordinator.py +++ b/homeassistant/components/forecast_solar/coordinator.py @@ -4,13 +4,13 @@ from __future__ import annotations from datetime import timedelta -from forecast_solar import Estimate, ForecastSolar +from forecast_solar import Estimate, ForecastSolar, ForecastSolarConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_AZIMUTH, @@ -65,4 +65,7 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]): async def _async_update_data(self) -> Estimate: """Fetch Forecast.Solar estimates.""" - return await self.forecast.estimate() + try: + return await self.forecast.estimate() + except ForecastSolarConnectionError as error: + raise UpdateFailed(error) from error From f1e4229b23ef7837bb4e46877f55d6533f8d4607 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 9 Sep 2024 22:33:08 +0200 Subject: [PATCH 0453/1309] Update frontend to 20240909.1 (#125610) --- 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 e40832e4733..7f394611375 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==20240906.0"] + "requirements": ["home-assistant-frontend==20240909.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index af3545b0f1d..0ea0e90eeea 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -31,7 +31,7 @@ habluetooth==3.4.0 hass-nabucasa==0.81.1 hassil==1.7.4 home-assistant-bluetooth==1.12.2 -home-assistant-frontend==20240906.0 +home-assistant-frontend==20240909.1 home-assistant-intents==2024.9.4 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5487e858ae5..dc9fd383542 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1108,7 +1108,7 @@ hole==0.8.0 holidays==0.56 # homeassistant.components.frontend -home-assistant-frontend==20240906.0 +home-assistant-frontend==20240909.1 # homeassistant.components.conversation home-assistant-intents==2024.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25da21335fb..b587acbc73f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -934,7 +934,7 @@ hole==0.8.0 holidays==0.56 # homeassistant.components.frontend -home-assistant-frontend==20240906.0 +home-assistant-frontend==20240909.1 # homeassistant.components.conversation home-assistant-intents==2024.9.4 From 7508a2b38375fa217989b384078b058849f5275d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Sep 2024 23:38:59 -0500 Subject: [PATCH 0454/1309] Bump yarl to 1.1.11 (#125633) changelog: https://github.com/aio-libs/yarl/compare/v1.11.0...v1.11.1 --- 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 0ea0e90eeea..908f2a48f0d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,7 +62,7 @@ urllib3>=1.26.5,<2 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.11.0 +yarl==1.11.1 zeroconf==0.134.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index c6ec12cc860..f04ebf76664 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", - "yarl==1.11.0", + "yarl==1.11.1", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 48a9c297373..2a46b3170d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -41,4 +41,4 @@ urllib3>=1.26.5,<2 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.11.0 +yarl==1.11.1 From 06e83340e8a9a3034a3b046f06baf98e728deab8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Sep 2024 08:16:04 +0200 Subject: [PATCH 0455/1309] Bump actions/attest-build-provenance from 1.4.2 to 1.4.3 (#125390) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index ddb204ca42d..955e42254e7 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -530,7 +530,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@6149ea5740be74af77f260b9db67e633f6b0a9a1 # v1.4.2 + uses: actions/attest-build-provenance@1c608d11d69870c2092266b3f9a6f3abbf17002c # v1.4.3 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From bd5892f2a660befae4f2c8cc2d39da5858d1632e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 10 Sep 2024 01:22:14 -0500 Subject: [PATCH 0456/1309] Set responding state in assist satellite announcements (#125632) Set responding state in announcements --- .../components/assist_satellite/entity.py | 2 ++ .../assist_satellite/test_entity.py | 27 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 38973f15f55..6f0e588052a 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -169,12 +169,14 @@ class AssistSatelliteEntity(entity.Entity): raise SatelliteBusyError self._is_announcing = True + self._set_state(AssistSatelliteState.RESPONDING) try: # Block until announcement is finished await self.async_announce(message, media_id) finally: self._is_announcing = False + self.tts_response_finished() async def async_announce(self, message: str, media_id: str) -> None: """Announce media on the satellite. diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index ec52d8abff4..a46f754dd4e 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -129,10 +129,33 @@ async def test_announce( tts_voice="test-voice", ) + entity._attr_tts_options = {"test-option": "test-value"} + + original_announce = entity.async_announce + announce_started = asyncio.Event() + + async def async_announce(message, media_id): + # Verify state change + assert entity.state == AssistSatelliteState.RESPONDING + await original_announce(message, media_id) + announce_started.set() + + def tts_generate_media_source_id( + hass: HomeAssistant, + message: str, + engine: str | None = None, + language: str | None = None, + options: dict | None = None, + cache: bool | None = None, + ): + # Check that TTS options are passed here + assert options == {"test-option": "test-value", "voice": "test-voice"} + return "media-source://bla" + with ( patch( "homeassistant.components.assist_satellite.entity.tts_generate_media_source_id", - return_value="media-source://bla", + new=tts_generate_media_source_id, ), patch( "homeassistant.components.media_source.async_resolve_media", @@ -141,6 +164,7 @@ async def test_announce( mime_type="audio/mp3", ), ), + patch.object(entity, "async_announce", new=async_announce), ): await hass.services.async_call( "assist_satellite", @@ -149,6 +173,7 @@ async def test_announce( target={"entity_id": "assist_satellite.test_entity"}, blocking=True, ) + assert entity.state == AssistSatelliteState.LISTENING_WAKE_WORD assert entity.announcements[0] == expected_params From bb566100930ea46813a6f6622bcdedcd346f239c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Sep 2024 01:37:50 -0500 Subject: [PATCH 0457/1309] Make auth safe params a frozenset (#125640) --- homeassistant/components/http/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 0f43aac0115..7e00cc70eaa 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -34,7 +34,7 @@ _LOGGER = logging.getLogger(__name__) DATA_API_PASSWORD: Final = "api_password" DATA_SIGN_SECRET: Final = "http.auth.sign_secret" SIGN_QUERY_PARAM: Final = "authSig" -SAFE_QUERY_PARAMS: Final = ["height", "width"] +SAFE_QUERY_PARAMS: Final = frozenset(("height", "width")) STORAGE_VERSION = 1 STORAGE_KEY = "http.auth" From 130e7317bcf19b4458ed11939705d3c731410dc8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 10 Sep 2024 08:39:19 +0200 Subject: [PATCH 0458/1309] Add alias to DOMAIN import (part 3) (#125562) --- .../components/tomato/device_tracker.py | 4 +-- .../components/xiaomi/device_tracker.py | 4 +-- .../components/tomato/test_device_tracker.py | 34 +++++++++---------- .../components/xiaomi/test_device_tracker.py | 10 +++--- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index aaa1d10d08d..f1527f52c64 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -11,7 +11,7 @@ import requests import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -46,7 +46,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> TomatoDeviceScanner: """Validate the configuration and returns a Tomato scanner.""" - return TomatoDeviceScanner(config[DOMAIN]) + return TomatoDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) class TomatoDeviceScanner(DeviceScanner): diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index b14ec073938..04f3ea6667a 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -9,7 +9,7 @@ import requests import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -31,7 +31,7 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> XiaomiDeviceScanner | None: """Validate the configuration and return a Xiaomi Device Scanner.""" - scanner = XiaomiDeviceScanner(config[DOMAIN]) + scanner = XiaomiDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) return scanner if scanner.success_init else None diff --git a/tests/components/tomato/test_device_tracker.py b/tests/components/tomato/test_device_tracker.py index 1747832e0d5..f50d999548f 100644 --- a/tests/components/tomato/test_device_tracker.py +++ b/tests/components/tomato/test_device_tracker.py @@ -70,7 +70,7 @@ def test_config_missing_optional_params(hass: HomeAssistant, mock_session_send) config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_USERNAME: "foo", CONF_PASSWORD: "password", @@ -96,7 +96,7 @@ def test_config_default_nonssl_port(hass: HomeAssistant, mock_session_send) -> N config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_USERNAME: "foo", CONF_PASSWORD: "password", @@ -115,7 +115,7 @@ def test_config_default_ssl_port(hass: HomeAssistant, mock_session_send) -> None config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_SSL: True, CONF_USERNAME: "foo", @@ -137,7 +137,7 @@ def test_config_verify_ssl_but_no_ssl_enabled( config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_PORT: 1234, CONF_SSL: False, @@ -171,7 +171,7 @@ def test_config_valid_verify_ssl_path(hass: HomeAssistant, mock_session_send) -> config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_PORT: 1234, CONF_SSL: True, @@ -202,7 +202,7 @@ def test_config_valid_verify_ssl_bool(hass: HomeAssistant, mock_session_send) -> config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_PORT: 1234, CONF_SSL: True, @@ -233,7 +233,7 @@ def test_config_errors() -> None: with pytest.raises(vol.Invalid): tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, # No Host, CONF_PORT: 1234, CONF_SSL: True, @@ -246,7 +246,7 @@ def test_config_errors() -> None: with pytest.raises(vol.Invalid): tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_PORT: -123456789, # Bad Port CONF_SSL: True, @@ -259,7 +259,7 @@ def test_config_errors() -> None: with pytest.raises(vol.Invalid): tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_PORT: 1234, CONF_SSL: True, @@ -272,7 +272,7 @@ def test_config_errors() -> None: with pytest.raises(vol.Invalid): tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_PORT: 1234, CONF_SSL: True, @@ -285,7 +285,7 @@ def test_config_errors() -> None: with pytest.raises(vol.Invalid): tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_PORT: 1234, CONF_SSL: True, @@ -303,7 +303,7 @@ def test_config_bad_credentials(hass: HomeAssistant, mock_exception_logger) -> N config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_USERNAME: "i_am", CONF_PASSWORD: "an_imposter", @@ -326,7 +326,7 @@ def test_bad_response(hass: HomeAssistant, mock_exception_logger) -> None: config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_USERNAME: "foo", CONF_PASSWORD: "bar", @@ -349,7 +349,7 @@ def test_scan_devices(hass: HomeAssistant, mock_exception_logger) -> None: config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_USERNAME: "foo", CONF_PASSWORD: "bar", @@ -368,7 +368,7 @@ def test_bad_connection(hass: HomeAssistant, mock_exception_logger) -> None: config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_USERNAME: "foo", CONF_PASSWORD: "bar", @@ -396,7 +396,7 @@ def test_router_timeout(hass: HomeAssistant, mock_exception_logger) -> None: config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_USERNAME: "foo", CONF_PASSWORD: "bar", @@ -424,7 +424,7 @@ def test_get_device_name(hass: HomeAssistant, mock_exception_logger) -> None: config = { DEVICE_TRACKER_DOMAIN: tomato.PLATFORM_SCHEMA( { - CONF_PLATFORM: tomato.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "tomato-router", CONF_USERNAME: "foo", CONF_PASSWORD: "bar", diff --git a/tests/components/xiaomi/test_device_tracker.py b/tests/components/xiaomi/test_device_tracker.py index 7d3b35bbda7..625e6f404ad 100644 --- a/tests/components/xiaomi/test_device_tracker.py +++ b/tests/components/xiaomi/test_device_tracker.py @@ -156,7 +156,7 @@ async def test_config(xiaomi_mock, hass: HomeAssistant) -> None: config = { DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( { - CONF_PLATFORM: xiaomi.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "192.168.0.1", CONF_PASSWORD: "passwordTest", } @@ -181,7 +181,7 @@ async def test_config_full(xiaomi_mock, hass: HomeAssistant) -> None: config = { DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( { - CONF_PLATFORM: xiaomi.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "192.168.0.1", CONF_USERNAME: "alternativeAdminName", CONF_PASSWORD: "passwordTest", @@ -205,7 +205,7 @@ async def test_invalid_credential(mock_get, mock_post, hass: HomeAssistant) -> N config = { DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( { - CONF_PLATFORM: xiaomi.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "192.168.0.1", CONF_USERNAME: INVALID_USERNAME, CONF_PASSWORD: "passwordTest", @@ -222,7 +222,7 @@ async def test_valid_credential(mock_get, mock_post, hass: HomeAssistant) -> Non config = { DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( { - CONF_PLATFORM: xiaomi.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "192.168.0.1", CONF_USERNAME: "admin", CONF_PASSWORD: "passwordTest", @@ -246,7 +246,7 @@ async def test_token_timed_out(mock_get, mock_post, hass: HomeAssistant) -> None config = { DEVICE_TRACKER_DOMAIN: xiaomi.PLATFORM_SCHEMA( { - CONF_PLATFORM: xiaomi.DOMAIN, + CONF_PLATFORM: DEVICE_TRACKER_DOMAIN, CONF_HOST: "192.168.0.1", CONF_USERNAME: TOKEN_TIMEOUT_USERNAME, CONF_PASSWORD: "passwordTest", From 675c467e12e8783fa04416c7c244e0097ee26cf5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 10 Sep 2024 08:40:02 +0200 Subject: [PATCH 0459/1309] Add alias to DOMAIN import (part 2) (#125561) --- .../components/cppm_tracker/device_tracker.py | 10 ++++++---- homeassistant/components/fortios/device_tracker.py | 10 ++++++---- homeassistant/components/ubus/device_tracker.py | 12 +++++++----- .../components/xiaomi_miio/device_tracker.py | 8 +++++--- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py index a7a1a1b99e8..b6fdc0a8889 100644 --- a/homeassistant/components/cppm_tracker/device_tracker.py +++ b/homeassistant/components/cppm_tracker/device_tracker.py @@ -9,7 +9,7 @@ from clearpasspy import ClearPass import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -36,11 +36,13 @@ _LOGGER = logging.getLogger(__name__) def get_scanner(hass: HomeAssistant, config: ConfigType) -> CPPMDeviceScanner | None: """Initialize Scanner.""" + config = config[DEVICE_TRACKER_DOMAIN] + data = { - "server": config[DOMAIN][CONF_HOST], + "server": config[CONF_HOST], "grant_type": GRANT_TYPE, - "secret": config[DOMAIN][CONF_API_KEY], - "client": config[DOMAIN][CONF_CLIENT_ID], + "secret": config[CONF_API_KEY], + "client": config[CONF_CLIENT_ID], } cppm = ClearPass(data) if cppm.access_token is None: diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 192c1e4bc69..af2bc92a065 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -13,7 +13,7 @@ from fortiosapi import FortiOSAPI import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -37,9 +37,11 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> FortiOSDeviceScanner | None: """Validate the configuration and return a FortiOSDeviceScanner.""" - host = config[DOMAIN][CONF_HOST] - verify_ssl = config[DOMAIN][CONF_VERIFY_SSL] - token = config[DOMAIN][CONF_TOKEN] + config = config[DEVICE_TRACKER_DOMAIN] + + host = config[CONF_HOST] + verify_ssl = config[CONF_VERIFY_SSL] + token = config[CONF_TOKEN] fgt = FortiOSAPI() diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 6170ad213a3..84a813f1d37 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -9,7 +9,7 @@ from openwrt.ubus import Ubus import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -38,14 +38,16 @@ PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> DeviceScanner | None: """Validate the configuration and return an ubus scanner.""" - dhcp_sw = config[DOMAIN][CONF_DHCP_SOFTWARE] + config = config[DEVICE_TRACKER_DOMAIN] + + dhcp_sw = config[CONF_DHCP_SOFTWARE] scanner: DeviceScanner if dhcp_sw == "dnsmasq": - scanner = DnsmasqUbusDeviceScanner(config[DOMAIN]) + scanner = DnsmasqUbusDeviceScanner(config) elif dhcp_sw == "odhcpd": - scanner = OdhcpdUbusDeviceScanner(config[DOMAIN]) + scanner = OdhcpdUbusDeviceScanner(config) else: - scanner = UbusDeviceScanner(config[DOMAIN]) + scanner = UbusDeviceScanner(config) return scanner if scanner.success_init else None diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index 4a7e447b8a5..30cbf699646 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -8,7 +8,7 @@ from miio import DeviceException, WifiRepeater import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, + DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) @@ -32,8 +32,10 @@ def get_scanner( ) -> XiaomiMiioDeviceScanner | None: """Return a Xiaomi MiIO device scanner.""" scanner = None - host = config[DOMAIN][CONF_HOST] - token = config[DOMAIN][CONF_TOKEN] + config = config[DEVICE_TRACKER_DOMAIN] + + host = config[CONF_HOST] + token = config[CONF_TOKEN] _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) From 4c59bae1d2aadc420b393354da6542026a68354b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Sep 2024 01:40:18 -0500 Subject: [PATCH 0460/1309] Remove myself from codeowner from lutron_caseta (#125609) --- CODEOWNERS | 4 ++-- homeassistant/components/lutron_caseta/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 92beb8946ba..bd4494b8249 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -858,8 +858,8 @@ build.json @home-assistant/supervisor /tests/components/lupusec/ @majuss @suaveolent /homeassistant/components/lutron/ @cdheiser @wilburCForce /tests/components/lutron/ @cdheiser @wilburCForce -/homeassistant/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151 -/tests/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151 +/homeassistant/components/lutron_caseta/ @swails @danaues @eclair4151 +/tests/components/lutron_caseta/ @swails @danaues @eclair4151 /homeassistant/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001 /homeassistant/components/madvr/ @iloveicedgreentea diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index 3c6348ed4da..776e771b9d3 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -1,7 +1,7 @@ { "domain": "lutron_caseta", "name": "Lutron Cas\u00e9ta", - "codeowners": ["@swails", "@bdraco", "@danaues", "@eclair4151"], + "codeowners": ["@swails", "@danaues", "@eclair4151"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lutron_caseta", "homekit": { From 7e2e3c4780b21298bbcde238a62a782a4e52fe90 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 10 Sep 2024 08:40:49 +0200 Subject: [PATCH 0461/1309] Rename HassEnforceCoordinatorModule (#125592) --- homeassistant/components/tibber/sensor.py | 2 +- homeassistant/components/tolo/__init__.py | 2 +- homeassistant/components/tplink_omada/update.py | 2 +- .../components/ukraine_alarm/__init__.py | 2 +- homeassistant/components/volvooncall/__init__.py | 2 +- .../components/yamaha_musiccast/__init__.py | 2 +- homeassistant/components/zha/update.py | 2 +- ...or_module.py => hass_enforce_class_module.py} | 10 +++++----- pyproject.toml | 2 +- tests/pylint/conftest.py | 16 ++++++++-------- ...or_module.py => test_enforce_class_module.py} | 8 ++++---- 11 files changed, 25 insertions(+), 25 deletions(-) rename pylint/plugins/{hass_enforce_coordinator_module.py => hass_enforce_class_module.py} (79%) rename tests/pylint/{test_enforce_coordinator_module.py => test_enforce_class_module.py} (93%) diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 09b36f41929..adac836aca6 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -610,7 +610,7 @@ class TibberRtEntityCreator: self._async_add_entities(new_entities) -class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-coordinator-module +class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-enforce-class-module """Handle Tibber realtime data.""" def __init__( diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py index ed53015ccb4..a90d23b0e22 100644 --- a/homeassistant/components/tolo/__init__.py +++ b/homeassistant/components/tolo/__init__.py @@ -62,7 +62,7 @@ class ToloSaunaData(NamedTuple): settings: ToloSettings -class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): # pylint: disable=hass-enforce-coordinator-module +class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): # pylint: disable=hass-enforce-class-module """DataUpdateCoordinator for TOLO Sauna.""" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 5e87d11474b..a7552263ff1 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -35,7 +35,7 @@ class FirmwareUpdateStatus(NamedTuple): firmware: OmadaFirmwareUpdate | None -class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): # pylint: disable=hass-enforce-coordinator-module +class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): # pylint: disable=hass-enforce-class-module """Coordinator for getting details about ports on a switch.""" def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: diff --git a/homeassistant/components/ukraine_alarm/__init__.py b/homeassistant/components/ukraine_alarm/__init__.py index b90fb20af75..772eb155fd5 100644 --- a/homeassistant/components/ukraine_alarm/__init__.py +++ b/homeassistant/components/ukraine_alarm/__init__.py @@ -47,7 +47,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-coordinator-module +class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-class-module """Class to manage fetching Ukraine Alarm API.""" def __init__( diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 8bade56fa97..2a99ac3e062 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -168,7 +168,7 @@ class VolvoData: raise InvalidAuth from exc -class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module +class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-class-module """Volvo coordinator.""" def __init__(self, hass: HomeAssistant, volvo_data: VolvoData) -> None: diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 667b411e6c4..f8d9f77f120 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -105,7 +105,7 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): # pylint: disable=hass-enforce-coordinator-module +class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): # pylint: disable=hass-enforce-class-module """Class to manage fetching data from the API.""" def __init__(self, hass: HomeAssistant, client: MusicCastDevice) -> None: diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 3a857f9d89b..151d1c495e8 100644 --- a/homeassistant/components/zha/update.py +++ b/homeassistant/components/zha/update.py @@ -64,7 +64,7 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) -class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-coordinator-module +class ZHAFirmwareUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-class-module """Firmware update coordinator that broadcasts updates network-wide.""" def __init__( diff --git a/pylint/plugins/hass_enforce_coordinator_module.py b/pylint/plugins/hass_enforce_class_module.py similarity index 79% rename from pylint/plugins/hass_enforce_coordinator_module.py rename to pylint/plugins/hass_enforce_class_module.py index 7160a25085d..d9f844f907f 100644 --- a/pylint/plugins/hass_enforce_coordinator_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -7,15 +7,15 @@ from pylint.checkers import BaseChecker from pylint.lint import PyLinter -class HassEnforceCoordinatorModule(BaseChecker): +class HassEnforceClassModule(BaseChecker): """Checker for coordinators own module.""" - name = "hass_enforce_coordinator_module" + name = "hass_enforce_class_module" priority = -1 msgs = { "C7461": ( "Derived data update coordinator is recommended to be placed in the 'coordinator' module", - "hass-enforce-coordinator-module", + "hass-enforce-class-module", "Used when derived data update coordinator should be placed in its own module.", ), } @@ -31,10 +31,10 @@ class HassEnforceCoordinatorModule(BaseChecker): is_coordinator_module = root_name.endswith(".coordinator") for ancestor in node.ancestors(): if ancestor.name == "DataUpdateCoordinator" and not is_coordinator_module: - self.add_message("hass-enforce-coordinator-module", node=node) + self.add_message("hass-enforce-class-module", node=node) return def register(linter: PyLinter) -> None: """Register the checker.""" - linter.register_checker(HassEnforceCoordinatorModule(linter)) + linter.register_checker(HassEnforceClassModule(linter)) diff --git a/pyproject.toml b/pyproject.toml index f04ebf76664..ac362b92483 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,7 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", - "hass_enforce_coordinator_module", + "hass_enforce_class_module", "hass_enforce_sorted_platforms", "hass_enforce_super_call", "hass_enforce_type_hints", diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 90e535a7b0e..38b4188230f 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -104,22 +104,22 @@ def enforce_sorted_platforms_checker_fixture( return enforce_sorted_platforms_checker -@pytest.fixture(name="hass_enforce_coordinator_module", scope="package") -def hass_enforce_coordinator_module_fixture() -> ModuleType: - """Fixture to the content for the hass_enforce_coordinator_module check.""" +@pytest.fixture(name="hass_enforce_class_module", scope="package") +def hass_enforce_class_module_fixture() -> ModuleType: + """Fixture to the content for the hass_enforce_class_module check.""" return _load_plugin_from_file( - "hass_enforce_coordinator_module", - "pylint/plugins/hass_enforce_coordinator_module.py", + "hass_enforce_class_module", + "pylint/plugins/hass_enforce_class_module.py", ) @pytest.fixture(name="enforce_coordinator_module_checker") def enforce_coordinator_module_fixture( - hass_enforce_coordinator_module, linter + hass_enforce_class_module, linter ) -> BaseChecker: - """Fixture to provide a hass_enforce_coordinator_module checker.""" + """Fixture to provide a hass_enforce_class_module checker.""" enforce_coordinator_module_checker = ( - hass_enforce_coordinator_module.HassEnforceCoordinatorModule(linter) + hass_enforce_class_module.HassEnforceClassModule(linter) ) enforce_coordinator_module_checker.module = "homeassistant.components.pylint_test" return enforce_coordinator_module_checker diff --git a/tests/pylint/test_enforce_coordinator_module.py b/tests/pylint/test_enforce_class_module.py similarity index 93% rename from tests/pylint/test_enforce_coordinator_module.py rename to tests/pylint/test_enforce_class_module.py index 90d88246974..5fd6e0e88cc 100644 --- a/tests/pylint/test_enforce_coordinator_module.py +++ b/tests/pylint/test_enforce_class_module.py @@ -1,4 +1,4 @@ -"""Tests for pylint hass_enforce_coordinator_module plugin.""" +"""Tests for pylint hass_enforce_class_module plugin.""" from __future__ import annotations @@ -74,7 +74,7 @@ def test_enforce_coordinator_module_bad_simple( with assert_adds_messages( linter, MessageTest( - msg_id="hass-enforce-coordinator-module", + msg_id="hass-enforce-class-module", line=5, node=root_node.body[1], args=None, @@ -111,7 +111,7 @@ def test_enforce_coordinator_module_bad_nested( with assert_adds_messages( linter, MessageTest( - msg_id="hass-enforce-coordinator-module", + msg_id="hass-enforce-class-module", line=5, node=root_node.body[1], args=None, @@ -121,7 +121,7 @@ def test_enforce_coordinator_module_bad_nested( end_col_offset=21, ), MessageTest( - msg_id="hass-enforce-coordinator-module", + msg_id="hass-enforce-class-module", line=8, node=root_node.body[2], args=None, From 2fa0f283ea0696f285b435bd3c03e77cd093e784 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 10 Sep 2024 08:41:47 +0200 Subject: [PATCH 0462/1309] Add alias to DOMAIN import in config and demo (#125570) --- homeassistant/components/config/automation.py | 10 ++++++---- homeassistant/components/config/scene.py | 8 ++++---- homeassistant/components/config/script.py | 10 ++++++---- homeassistant/components/demo/notify.py | 8 ++++++-- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index ccc36dc4430..519a40450ed 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -5,8 +5,8 @@ from __future__ import annotations from typing import Any import uuid +from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.automation.config import ( - DOMAIN, PLATFORM_SCHEMA, async_validate_config_item, ) @@ -27,13 +27,15 @@ def async_setup(hass: HomeAssistant) -> bool: """post_write_hook for Config View that reloads automations.""" if action != ACTION_DELETE: await hass.services.async_call( - DOMAIN, SERVICE_RELOAD, {CONF_ID: config_key} + AUTOMATION_DOMAIN, SERVICE_RELOAD, {CONF_ID: config_key} ) return ent_reg = er.async_get(hass) - entity_id = ent_reg.async_get_entity_id(DOMAIN, DOMAIN, config_key) + entity_id = ent_reg.async_get_entity_id( + AUTOMATION_DOMAIN, AUTOMATION_DOMAIN, config_key + ) if entity_id is None: return @@ -42,7 +44,7 @@ def async_setup(hass: HomeAssistant) -> bool: hass.http.register_view( EditAutomationConfigView( - DOMAIN, + AUTOMATION_DOMAIN, "config", AUTOMATION_CONFIG_PATH, cv.string, diff --git a/homeassistant/components/config/scene.py b/homeassistant/components/config/scene.py index d44c2bb87b4..e33942e9986 100644 --- a/homeassistant/components/config/scene.py +++ b/homeassistant/components/config/scene.py @@ -6,7 +6,7 @@ from typing import Any import uuid from homeassistant.components.scene import ( - DOMAIN, + DOMAIN as SCENE_DOMAIN, PLATFORM_SCHEMA as SCENE_PLATFORM_SCHEMA, ) from homeassistant.config import SCENE_CONFIG_PATH @@ -27,13 +27,13 @@ def async_setup(hass: HomeAssistant) -> bool: async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads scenes.""" if action != ACTION_DELETE: - await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + await hass.services.async_call(SCENE_DOMAIN, SERVICE_RELOAD) return ent_reg = er.async_get(hass) entity_id = ent_reg.async_get_entity_id( - DOMAIN, HOMEASSISTANT_DOMAIN, config_key + SCENE_DOMAIN, HOMEASSISTANT_DOMAIN, config_key ) if entity_id is None: @@ -43,7 +43,7 @@ def async_setup(hass: HomeAssistant) -> bool: hass.http.register_view( EditSceneConfigView( - DOMAIN, + SCENE_DOMAIN, "config", SCENE_CONFIG_PATH, cv.string, diff --git a/homeassistant/components/config/script.py b/homeassistant/components/config/script.py index c39aad4fcdb..c6aabc5bc54 100644 --- a/homeassistant/components/config/script.py +++ b/homeassistant/components/config/script.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.script import DOMAIN +from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.script.config import ( SCRIPT_ENTITY_SCHEMA, async_validate_config_item, @@ -25,12 +25,14 @@ def async_setup(hass: HomeAssistant) -> bool: async def hook(action: str, config_key: str) -> None: """post_write_hook for Config View that reloads scripts.""" if action != ACTION_DELETE: - await hass.services.async_call(DOMAIN, SERVICE_RELOAD) + await hass.services.async_call(SCRIPT_DOMAIN, SERVICE_RELOAD) return ent_reg = er.async_get(hass) - entity_id = ent_reg.async_get_entity_id(DOMAIN, DOMAIN, config_key) + entity_id = ent_reg.async_get_entity_id( + SCRIPT_DOMAIN, SCRIPT_DOMAIN, config_key + ) if entity_id is None: return @@ -39,7 +41,7 @@ def async_setup(hass: HomeAssistant) -> bool: hass.http.register_view( EditScriptConfigView( - DOMAIN, + SCRIPT_DOMAIN, "config", SCRIPT_CONFIG_PATH, cv.slug, diff --git a/homeassistant/components/demo/notify.py b/homeassistant/components/demo/notify.py index 9aab2572957..7524517e6e8 100644 --- a/homeassistant/components/demo/notify.py +++ b/homeassistant/components/demo/notify.py @@ -2,7 +2,11 @@ from __future__ import annotations -from homeassistant.components.notify import DOMAIN, NotifyEntity, NotifyEntityFeature +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + NotifyEntity, + NotifyEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -35,7 +39,7 @@ class DemoNotifyEntity(NotifyEntity): self._attr_unique_id = unique_id self._attr_supported_features = NotifyEntityFeature.TITLE self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, + identifiers={(NOTIFY_DOMAIN, unique_id)}, name=device_name, ) From 3cc5a29c1bbe78f825ec1b541cb815556c38665d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 10 Sep 2024 08:42:22 +0200 Subject: [PATCH 0463/1309] Link mold_indicator entity to device from humidity sensor (#125528) --- homeassistant/components/mold_indicator/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index e96f53a17bb..8d7842ff718 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -32,6 +32,7 @@ from homeassistant.core import ( callback, ) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device import async_device_info_to_link_from_entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType @@ -82,6 +83,7 @@ async def async_setup_platform( async_add_entities( [ MoldIndicator( + hass, name, hass.config.units is METRIC_SYSTEM, indoor_temp_sensor, @@ -109,6 +111,7 @@ async def async_setup_entry( async_add_entities( [ MoldIndicator( + hass, name, hass.config.units is METRIC_SYSTEM, indoor_temp_sensor, @@ -131,6 +134,7 @@ class MoldIndicator(SensorEntity): def __init__( self, + hass: HomeAssistant, name: str, is_metric: bool, indoor_temp_sensor: str, @@ -158,6 +162,10 @@ class MoldIndicator(SensorEntity): self._outdoor_temp: float | None = None self._indoor_hum: float | None = None self._crit_temp: float | None = None + self._attr_device_info = async_device_info_to_link_from_entity( + hass, + indoor_humidity_sensor, + ) async def async_added_to_hass(self) -> None: """Register callbacks.""" From 7eba111704a555ad330cc4a03a1d6e4a9c3528a7 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 10 Sep 2024 17:32:58 +0900 Subject: [PATCH 0464/1309] Bump thinqconnect to 0.9.7 (#125587) Co-authored-by: jangwon.lee --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index 9a594f70f95..4b880d2544d 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq/", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==0.9.6"] + "requirements": ["thinqconnect==0.9.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc9fd383542..eb1c8a21932 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2804,7 +2804,7 @@ thermoworks-smoke==0.1.8 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==0.9.6 +thinqconnect==0.9.7 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b587acbc73f..fb2d4931173 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2214,7 +2214,7 @@ thermobeacon-ble==0.7.0 thermopro-ble==0.10.0 # homeassistant.components.lg_thinq -thinqconnect==0.9.6 +thinqconnect==0.9.7 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From cb97085e48139c77fa6b70ea509aee2271233075 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Tue, 10 Sep 2024 17:59:07 +0900 Subject: [PATCH 0465/1309] Create property_ids with ActiveMode in LG ThinQ integration (#125638) * Bump thinqconnect to 0.9.7 * Pass a r/w parameter to get active properties id from the cloud --------- Co-authored-by: jangwon.lee --- homeassistant/components/lg_thinq/binary_sensor.py | 5 ++++- homeassistant/components/lg_thinq/switch.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lg_thinq/binary_sensor.py b/homeassistant/components/lg_thinq/binary_sensor.py index 6f856c3055f..c3179ea6948 100644 --- a/homeassistant/components/lg_thinq/binary_sensor.py +++ b/homeassistant/components/lg_thinq/binary_sensor.py @@ -6,6 +6,7 @@ import logging from thinqconnect import DeviceType from thinqconnect.devices.const import Property as ThinQProperty +from thinqconnect.integration import ActiveMode from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -91,7 +92,9 @@ async def async_setup_entry( for description in descriptions: entities.extend( ThinQBinarySensorEntity(coordinator, description, property_id) - for property_id in coordinator.api.get_active_idx(description.key) + for property_id in coordinator.api.get_active_idx( + description.key, ActiveMode.READ_ONLY + ) ) if entities: diff --git a/homeassistant/components/lg_thinq/switch.py b/homeassistant/components/lg_thinq/switch.py index ef85c8ad50e..fe78b7813fa 100644 --- a/homeassistant/components/lg_thinq/switch.py +++ b/homeassistant/components/lg_thinq/switch.py @@ -7,6 +7,7 @@ from typing import Any from thinqconnect import DeviceType from thinqconnect.devices.const import Property as ThinQProperty +from thinqconnect.integration import ActiveMode from homeassistant.components.switch import ( SwitchDeviceClass, @@ -69,7 +70,9 @@ async def async_setup_entry( for description in descriptions: entities.extend( ThinQSwitchEntity(coordinator, description, property_id) - for property_id in coordinator.api.get_active_idx(description.key) + for property_id in coordinator.api.get_active_idx( + description.key, ActiveMode.READ_WRITE + ) ) if entities: From 9616d68e03e106bb8a50cfc5556918d402bb7e1c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 10 Sep 2024 11:42:44 +0200 Subject: [PATCH 0466/1309] Improve config flow type hints in yeelight (#125319) --- .../components/yeelight/config_flow.py | 32 ++++++++++++------- homeassistant/components/yeelight/device.py | 4 +-- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 1b36fba59df..b22774c68c3 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import Any from urllib.parse import urlparse import voluptuous as vol @@ -23,6 +23,7 @@ from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CON from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import VolDictType from .const import ( CONF_DETECTED_MODEL, @@ -52,6 +53,9 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _discovered_ip: str + _discovered_model: str + @staticmethod @callback def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: @@ -61,8 +65,6 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" self._discovered_devices: dict[str, Any] = {} - self._discovered_model = None - self._discovered_ip: str | None = None async def async_step_homekit( self, discovery_info: zeroconf.ZeroconfServiceInfo @@ -96,7 +98,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(discovery_info.ssdp_headers["id"]) return await self._async_handle_discovery_with_unique_id() - async def _async_handle_discovery_with_unique_id(self): + async def _async_handle_discovery_with_unique_id(self) -> ConfigFlowResult: """Handle any discovery with a unique id.""" for entry in self._async_current_entries(include_ignore=False): if entry.unique_id != self.unique_id and self.unique_id != entry.data.get( @@ -117,7 +119,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") return await self._async_handle_discovery() - async def _async_handle_discovery(self): + async def _async_handle_discovery(self) -> ConfigFlowResult: """Handle any discovery.""" self.context[CONF_HOST] = self._discovered_ip for progress in self._async_in_progress(): @@ -140,7 +142,9 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): ) return await self.async_step_discovery_confirm() - async def async_step_discovery_confirm(self, user_input=None): + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Confirm discovery.""" if user_input is not None or not onboarding.async_is_onboarded(self.hass): return self.async_create_entry( @@ -179,8 +183,6 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" else: self._abort_if_unique_id_configured() - if TYPE_CHECKING: - assert self.unique_id return self.async_create_entry( title=async_format_model_id(model, self.unique_id), data={ @@ -199,7 +201,9 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_pick_device(self, user_input=None): + async def async_step_pick_device( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Handle the step to pick discovered device.""" if user_input is not None: unique_id = user_input[CONF_DEVICE] @@ -260,7 +264,9 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return self.async_create_entry(title=import_data[CONF_NAME], data=import_data) - async def _async_try_connect(self, host, raise_on_progress=True): + async def _async_try_connect( + self, host: str, raise_on_progress: bool = True + ) -> str: """Set up with options.""" self._async_abort_entries_match({CONF_HOST: host}) @@ -294,7 +300,9 @@ class OptionsFlowHandler(OptionsFlow): """Initialize the option flow.""" self._config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle the initial step.""" data = self._config_entry.data options = self._config_entry.options @@ -306,7 +314,7 @@ class OptionsFlowHandler(OptionsFlow): title="", data={CONF_MODEL: model, **options, **user_input} ) - schema_dict = {} + schema_dict: VolDictType = {} known_models = get_known_models() if is_unknown_model := model not in known_models: known_models.insert(0, model) diff --git a/homeassistant/components/yeelight/device.py b/homeassistant/components/yeelight/device.py index c42fd072728..09086dc91d9 100644 --- a/homeassistant/components/yeelight/device.py +++ b/homeassistant/components/yeelight/device.py @@ -32,13 +32,13 @@ def async_format_model(model: str) -> str: @callback -def async_format_id(id_: str) -> str: +def async_format_id(id_: str | None) -> str: """Generate a more human readable id.""" return hex(int(id_, 16)) if id_ else "None" @callback -def async_format_model_id(model: str, id_: str) -> str: +def async_format_model_id(model: str, id_: str | None) -> str: """Generate a more human readable name.""" return f"{async_format_model(model)} {async_format_id(id_)}" From 9f284c058219dfd8bb8cf8883d065c9a37d2298e Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Tue, 10 Sep 2024 11:44:17 +0200 Subject: [PATCH 0467/1309] Add model_id to MotionMount integration (#125650) --- homeassistant/components/motionmount/entity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index 8403af05491..d2da2481f1a 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -34,7 +34,8 @@ class MotionMountEntity(Entity): self._attr_device_info = DeviceInfo( name=mm.name, manufacturer="Vogel's", - model="TVM 7675", + model="MotionMount SIGNATURE Pro", + model_id="TVM 7675 Pro", ) if mac == EMPTY_MAC: From dcd7830a35d627d1ac1ea49996fb04f635f08baa Mon Sep 17 00:00:00 2001 From: Sergey Dudanov Date: Tue, 10 Sep 2024 14:22:15 +0400 Subject: [PATCH 0468/1309] Add calories to energy sensor device class (#122796) * added calories to energy class * changes * temporarily solving the problem with conversion accuracy * add tests * added calories to energy class * changes * add tests * Update homeassistant/util/unit_conversion.py Co-authored-by: Robert Resch * Update homeassistant/util/unit_conversion.py Co-authored-by: Robert Resch * apply suggestions * Update homeassistant/util/unit_conversion.py --------- Co-authored-by: Robert Resch Co-authored-by: Erik Montnemery --- homeassistant/components/sensor/const.py | 2 +- homeassistant/const.py | 12 ++++++--- homeassistant/util/unit_conversion.py | 26 +++++++++++-------- tests/components/template/test_config_flow.py | 2 +- tests/util/test_unit_conversion.py | 20 +++++++++++--- 5 files changed, 42 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 8f63e346caf..de30678d9fa 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -182,7 +182,7 @@ class SensorDeviceClass(StrEnum): Use this device class for sensors measuring energy consumption, for example electric energy consumption. - Unit of measurement: `Wh`, `kWh`, `MWh`, `MJ`, `GJ` + Unit of measurement: `J`, `kJ`, `MJ`, `GJ`, `Wh`, `kWh`, `MWh`, `cal`, `kcal`, `Mcal`, `Gcal` """ ENERGY_STORAGE = "energy_storage" diff --git a/homeassistant/const.py b/homeassistant/const.py index 45d6a97885b..acbef5c58cc 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -693,11 +693,17 @@ _DEPRECATED_POWER_VOLT_AMPERE_REACTIVE: Final = DeprecatedConstantEnum( class UnitOfEnergy(StrEnum): """Energy units.""" - GIGA_JOULE = "GJ" - KILO_WATT_HOUR = "kWh" + JOULE = "J" + KILO_JOULE = "kJ" MEGA_JOULE = "MJ" - MEGA_WATT_HOUR = "MWh" + GIGA_JOULE = "GJ" WATT_HOUR = "Wh" + KILO_WATT_HOUR = "kWh" + MEGA_WATT_HOUR = "MWh" + CALORIE = "cal" + KILO_CALORIE = "kcal" + MEGA_CALORIE = "Mcal" + GIGA_CALORIE = "Gcal" _DEPRECATED_ENERGY_KILO_WATT_HOUR: Final = DeprecatedConstantEnum( diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index d5586704fc5..dd6d300a2c1 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -47,6 +47,10 @@ _HRS_TO_MINUTES = 60 # 1 hr = 60 minutes _HRS_TO_SECS = _HRS_TO_MINUTES * _MIN_TO_SEC # 1 hr = 60 minutes = 3600 seconds _DAYS_TO_SECS = 24 * _HRS_TO_SECS # 1 day = 24 hours = 86400 seconds +# Energy conversion constants +_WH_TO_J = 3600 # 1 Wh = 3600 J +_WH_TO_CAL = _WH_TO_J / 4.184 # 1 Wh = 860.42065 cal + # Mass conversion constants _POUND_TO_G = 453.59237 _OUNCE_TO_G = _POUND_TO_G / 16 # 16 ounces to a pound @@ -209,19 +213,19 @@ class EnergyConverter(BaseUnitConverter): UNIT_CLASS = "energy" _UNIT_CONVERSION: dict[str | None, float] = { - UnitOfEnergy.WATT_HOUR: 1 * 1000, + UnitOfEnergy.JOULE: _WH_TO_J * 1e3, + UnitOfEnergy.KILO_JOULE: _WH_TO_J, + UnitOfEnergy.MEGA_JOULE: _WH_TO_J / 1e3, + UnitOfEnergy.GIGA_JOULE: _WH_TO_J / 1e6, + UnitOfEnergy.WATT_HOUR: 1e3, UnitOfEnergy.KILO_WATT_HOUR: 1, - UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1000, - UnitOfEnergy.MEGA_JOULE: 3.6, - UnitOfEnergy.GIGA_JOULE: 3.6 / 1000, - } - VALID_UNITS = { - UnitOfEnergy.WATT_HOUR, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.GIGA_JOULE, + UnitOfEnergy.MEGA_WATT_HOUR: 1 / 1e3, + UnitOfEnergy.CALORIE: _WH_TO_CAL * 1e3, + UnitOfEnergy.KILO_CALORIE: _WH_TO_CAL, + UnitOfEnergy.MEGA_CALORIE: _WH_TO_CAL / 1e3, + UnitOfEnergy.GIGA_CALORIE: _WH_TO_CAL / 1e6, } + VALID_UNITS = set(UnitOfEnergy) class InformationConverter(BaseUnitConverter): diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index eb2c6e57f85..9a89d72dc2e 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -764,7 +764,7 @@ EARLY_END_ERROR = "invalid template (TemplateSyntaxError: unexpected 'end of tem ), "unit_of_measurement": ( "'None' is not a valid unit for device class 'energy'; " - "expected one of 'GJ', 'kWh', 'MJ', 'MWh', 'Wh'" + "expected one of 'cal', 'Gcal', 'GJ', 'J', 'kcal', 'kJ', 'kWh', 'Mcal', 'MJ', 'MWh', 'Wh'" ), }, ), diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 98a6a1da5a6..8342aa732f8 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -282,10 +282,22 @@ _CONVERTED_VALUE: dict[ (10, UnitOfEnergy.KILO_WATT_HOUR, 0.01, UnitOfEnergy.MEGA_WATT_HOUR), (10, UnitOfEnergy.MEGA_WATT_HOUR, 10000000, UnitOfEnergy.WATT_HOUR), (10, UnitOfEnergy.MEGA_WATT_HOUR, 10000, UnitOfEnergy.KILO_WATT_HOUR), - (10, UnitOfEnergy.GIGA_JOULE, 10000 / 3.6, UnitOfEnergy.KILO_WATT_HOUR), - (10, UnitOfEnergy.GIGA_JOULE, 10 / 3.6, UnitOfEnergy.MEGA_WATT_HOUR), - (10, UnitOfEnergy.MEGA_JOULE, 10 / 3.6, UnitOfEnergy.KILO_WATT_HOUR), - (10, UnitOfEnergy.MEGA_JOULE, 0.010 / 3.6, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.GIGA_JOULE, 2777.78, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.GIGA_JOULE, 2.77778, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.MEGA_JOULE, 2.77778, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.MEGA_JOULE, 2.77778e-3, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.KILO_JOULE, 2.77778, UnitOfEnergy.WATT_HOUR), + (10, UnitOfEnergy.KILO_JOULE, 2.77778e-6, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.JOULE, 2.77778e-3, UnitOfEnergy.WATT_HOUR), + (10, UnitOfEnergy.JOULE, 2.390057, UnitOfEnergy.CALORIE), + (10, UnitOfEnergy.CALORIE, 0.01, UnitOfEnergy.KILO_CALORIE), + (10, UnitOfEnergy.CALORIE, 0.011622222, UnitOfEnergy.WATT_HOUR), + (10, UnitOfEnergy.KILO_CALORIE, 0.01, UnitOfEnergy.MEGA_CALORIE), + (10, UnitOfEnergy.KILO_CALORIE, 0.011622222, UnitOfEnergy.KILO_WATT_HOUR), + (10, UnitOfEnergy.MEGA_CALORIE, 0.01, UnitOfEnergy.GIGA_CALORIE), + (10, UnitOfEnergy.MEGA_CALORIE, 0.011622222, UnitOfEnergy.MEGA_WATT_HOUR), + (10, UnitOfEnergy.GIGA_CALORIE, 10000, UnitOfEnergy.MEGA_CALORIE), + (10, UnitOfEnergy.GIGA_CALORIE, 11.622222, UnitOfEnergy.MEGA_WATT_HOUR), ], InformationConverter: [ (8e3, UnitOfInformation.BITS, 8, UnitOfInformation.KILOBITS), From 99122fcb7805e29858d763ae1a236c0e592a9134 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 10 Sep 2024 12:43:08 +0200 Subject: [PATCH 0469/1309] Remove recorder history queries for database schemas < 25 (#125649) --- .../components/recorder/history/legacy.py | 13 - tests/components/recorder/test_history.py | 262 +----------------- .../recorder/test_history_db_schema_42.py | 261 +---------------- 3 files changed, 5 insertions(+), 531 deletions(-) diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 8ee3cd30316..2aa279778b3 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -169,19 +169,6 @@ def _lambda_stmt_and_join_attributes( ), False, ) - # If we in the process of migrating schema we do - # not want to join the state_attributes table as we - # do not know if it will be there yet - if schema_version < 25: - if include_last_changed: - return ( - lambda_stmt(lambda: select(*_QUERY_STATES_PRE_SCHEMA_25)), - False, - ) - return ( - lambda_stmt(lambda: select(*_QUERY_STATES_PRE_SCHEMA_25_NO_LAST_CHANGED)), - False, - ) if schema_version >= 31: if include_last_changed: diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 3923c72107a..28b8275247c 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -5,30 +5,21 @@ from __future__ import annotations from copy import copy from datetime import datetime, timedelta import json -from unittest.mock import patch, sentinel +from unittest.mock import sentinel from freezegun import freeze_time import pytest -from sqlalchemy import text from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder, get_instance, history +from homeassistant.components.recorder import Recorder, history from homeassistant.components.recorder.db_schema import ( - Events, - RecorderRuns, StateAttributes, States, StatesMeta, ) from homeassistant.components.recorder.filters import Filters -from homeassistant.components.recorder.history import legacy from homeassistant.components.recorder.models import process_timestamp -from homeassistant.components.recorder.models.legacy import ( - LegacyLazyState, - LegacyLazyStatePreSchema31, -) from homeassistant.components.recorder.util import session_scope -import homeassistant.core as ha from homeassistant.core import HomeAssistant, State from homeassistant.helpers.json import JSONEncoder import homeassistant.util.dt as dt_util @@ -57,77 +48,6 @@ def setup_recorder(recorder_mock: Recorder) -> recorder.Recorder: """Set up recorder.""" -async def _async_get_states( - hass: HomeAssistant, - utc_point_in_time: datetime, - entity_ids: list[str] | None = None, - run: RecorderRuns | None = None, - no_attributes: bool = False, -): - """Get states from the database.""" - - def _get_states_with_session(): - with session_scope(hass=hass, read_only=True) as session: - attr_cache = {} - pre_31_schema = get_instance(hass).schema_version < 31 - return [ - LegacyLazyStatePreSchema31(row, attr_cache, None) - if pre_31_schema - else LegacyLazyState( - row, - attr_cache, - None, - row.entity_id, - ) - for row in legacy._get_rows_with_session( - hass, - session, - utc_point_in_time, - entity_ids, - run, - no_attributes, - ) - ] - - return await recorder.get_instance(hass).async_add_executor_job( - _get_states_with_session - ) - - -def _add_db_entries( - hass: ha.HomeAssistant, point: datetime, entity_ids: list[str] -) -> None: - with session_scope(hass=hass) as session: - for idx, entity_id in enumerate(entity_ids): - session.add( - Events( - event_id=1001 + idx, - event_type="state_changed", - event_data="{}", - origin="LOCAL", - time_fired=point, - ) - ) - session.add( - States( - entity_id=entity_id, - state="on", - attributes='{"name":"the light"}', - last_changed=None, - last_updated=point, - event_id=1001 + idx, - attributes_id=1002 + idx, - ) - ) - session.add( - StateAttributes( - shared_attrs='{"name":"the shared light"}', - hash=1234 + idx, - attributes_id=1002 + idx, - ) - ) - - async def test_get_full_significant_states_with_session_entity_no_matches( hass: HomeAssistant, ) -> None: @@ -891,184 +811,6 @@ def record_states( return zero, four, states -@pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) -@pytest.mark.usefixtures("skip_by_db_engine") -async def test_state_changes_during_period_query_during_migration_to_schema_25( - hass: HomeAssistant, - recorder_db_url: str, -) -> None: - """Test we can query data prior to schema 25 and during migration to schema 25. - - This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop the - state_attributes table. - """ - - instance = recorder.get_instance(hass) - - with patch.object(instance.states_meta_manager, "active", False): - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - entity_id = "light.test" - await recorder.get_instance(hass).async_add_executor_job( - _add_db_entries, hass, point, [entity_id] - ) - - no_attributes = True - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, include_start_time_state=False - ) - state = hist[entity_id][0] - assert state.attributes == {} - - no_attributes = False - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, include_start_time_state=False - ) - state = hist[entity_id][0] - assert state.attributes == {"name": "the shared light"} - - with instance.engine.connect() as conn: - conn.execute(text("update states set attributes_id=NULL;")) - conn.execute(text("drop table state_attributes;")) - conn.commit() - - with patch.object(instance, "schema_version", 24): - instance.states_meta_manager.active = False - no_attributes = True - hist = history.state_changes_during_period( - hass, - start, - end, - entity_id, - no_attributes, - include_start_time_state=False, - ) - state = hist[entity_id][0] - assert state.attributes == {} - - no_attributes = False - hist = history.state_changes_during_period( - hass, - start, - end, - entity_id, - no_attributes, - include_start_time_state=False, - ) - state = hist[entity_id][0] - assert state.attributes == {"name": "the light"} - - -@pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) -@pytest.mark.usefixtures("skip_by_db_engine") -async def test_get_states_query_during_migration_to_schema_25( - hass: HomeAssistant, - recorder_db_url: str, -) -> None: - """Test we can query data prior to schema 25 and during migration to schema 25. - - This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop the - state_attributes table. - """ - - instance = recorder.get_instance(hass) - - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - entity_id = "light.test" - await instance.async_add_executor_job(_add_db_entries, hass, point, [entity_id]) - assert instance.states_meta_manager.active - - no_attributes = True - hist = await _async_get_states(hass, end, [entity_id], no_attributes=no_attributes) - state = hist[0] - assert state.attributes == {} - - no_attributes = False - hist = await _async_get_states(hass, end, [entity_id], no_attributes=no_attributes) - state = hist[0] - assert state.attributes == {"name": "the shared light"} - - with instance.engine.connect() as conn: - conn.execute(text("update states set attributes_id=NULL;")) - conn.execute(text("drop table state_attributes;")) - conn.commit() - - with patch.object(instance, "schema_version", 24): - instance.states_meta_manager.active = False - no_attributes = True - hist = await _async_get_states( - hass, end, [entity_id], no_attributes=no_attributes - ) - state = hist[0] - assert state.attributes == {} - - no_attributes = False - hist = await _async_get_states( - hass, end, [entity_id], no_attributes=no_attributes - ) - state = hist[0] - assert state.attributes == {"name": "the light"} - - -@pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) -@pytest.mark.usefixtures("skip_by_db_engine") -async def test_get_states_query_during_migration_to_schema_25_multiple_entities( - hass: HomeAssistant, - recorder_db_url: str, -) -> None: - """Test we can query data prior to schema 25 and during migration to schema 25. - - This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop the - state_attributes table. - """ - - instance = recorder.get_instance(hass) - - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - entity_id_1 = "light.test" - entity_id_2 = "switch.test" - entity_ids = [entity_id_1, entity_id_2] - - await instance.async_add_executor_job(_add_db_entries, hass, point, entity_ids) - assert instance.states_meta_manager.active - - no_attributes = True - hist = await _async_get_states(hass, end, entity_ids, no_attributes=no_attributes) - assert hist[0].attributes == {} - assert hist[1].attributes == {} - - no_attributes = False - hist = await _async_get_states(hass, end, entity_ids, no_attributes=no_attributes) - assert hist[0].attributes == {"name": "the shared light"} - assert hist[1].attributes == {"name": "the shared light"} - - with instance.engine.connect() as conn: - conn.execute(text("update states set attributes_id=NULL;")) - conn.execute(text("drop table state_attributes;")) - conn.commit() - - with patch.object(instance, "schema_version", 24): - instance.states_meta_manager.active = False - no_attributes = True - hist = await _async_get_states( - hass, end, entity_ids, no_attributes=no_attributes - ) - assert hist[0].attributes == {} - assert hist[1].attributes == {} - - no_attributes = False - hist = await _async_get_states( - hass, end, entity_ids, no_attributes=no_attributes - ) - assert hist[0].attributes == {"name": "the light"} - assert hist[1].attributes == {"name": "the light"} - - async def test_get_full_significant_states_handles_empty_last_changed( hass: HomeAssistant, ) -> None: diff --git a/tests/components/recorder/test_history_db_schema_42.py b/tests/components/recorder/test_history_db_schema_42.py index 5d9444e9cfe..85badeea281 100644 --- a/tests/components/recorder/test_history_db_schema_42.py +++ b/tests/components/recorder/test_history_db_schema_42.py @@ -5,21 +5,15 @@ from __future__ import annotations from copy import copy from datetime import datetime, timedelta import json -from unittest.mock import patch, sentinel +from unittest.mock import sentinel from freezegun import freeze_time import pytest -from sqlalchemy import text from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder, get_instance, history +from homeassistant.components.recorder import Recorder, history from homeassistant.components.recorder.filters import Filters -from homeassistant.components.recorder.history import legacy from homeassistant.components.recorder.models import process_timestamp -from homeassistant.components.recorder.models.legacy import ( - LegacyLazyState, - LegacyLazyStatePreSchema31, -) from homeassistant.components.recorder.util import session_scope import homeassistant.core as ha from homeassistant.core import HomeAssistant, State @@ -35,7 +29,7 @@ from .common import ( async_wait_recording_done, old_db_schema, ) -from .db_schema_42 import Events, RecorderRuns, StateAttributes, States, StatesMeta +from .db_schema_42 import StateAttributes, States, StatesMeta from tests.typing import RecorderInstanceGenerator @@ -59,77 +53,6 @@ def setup_recorder(db_schema_42, recorder_mock: Recorder) -> recorder.Recorder: """Set up recorder.""" -async def _async_get_states( - hass: HomeAssistant, - utc_point_in_time: datetime, - entity_ids: list[str] | None = None, - run: RecorderRuns | None = None, - no_attributes: bool = False, -): - """Get states from the database.""" - - def _get_states_with_session(): - with session_scope(hass=hass, read_only=True) as session: - attr_cache = {} - pre_31_schema = get_instance(hass).schema_version < 31 - return [ - LegacyLazyStatePreSchema31(row, attr_cache, None) - if pre_31_schema - else LegacyLazyState( - row, - attr_cache, - None, - row.entity_id, - ) - for row in legacy._get_rows_with_session( - hass, - session, - utc_point_in_time, - entity_ids, - run, - no_attributes, - ) - ] - - return await recorder.get_instance(hass).async_add_executor_job( - _get_states_with_session - ) - - -def _add_db_entries( - hass: ha.HomeAssistant, point: datetime, entity_ids: list[str] -) -> None: - with session_scope(hass=hass) as session: - for idx, entity_id in enumerate(entity_ids): - session.add( - Events( - event_id=1001 + idx, - event_type="state_changed", - event_data="{}", - origin="LOCAL", - time_fired=point, - ) - ) - session.add( - States( - entity_id=entity_id, - state="on", - attributes='{"name":"the light"}', - last_changed=None, - last_updated=point, - event_id=1001 + idx, - attributes_id=1002 + idx, - ) - ) - session.add( - StateAttributes( - shared_attrs='{"name":"the shared light"}', - hash=1234 + idx, - attributes_id=1002 + idx, - ) - ) - - async def test_get_full_significant_states_with_session_entity_no_matches( hass: HomeAssistant, ) -> None: @@ -893,184 +816,6 @@ def record_states( return zero, four, states -@pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) -@pytest.mark.usefixtures("skip_by_db_engine") -async def test_state_changes_during_period_query_during_migration_to_schema_25( - hass: HomeAssistant, - recorder_db_url: str, -) -> None: - """Test we can query data prior to schema 25 and during migration to schema 25. - - This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop the - state_attributes table. - """ - - instance = recorder.get_instance(hass) - - with patch.object(instance.states_meta_manager, "active", False): - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - entity_id = "light.test" - await recorder.get_instance(hass).async_add_executor_job( - _add_db_entries, hass, point, [entity_id] - ) - - no_attributes = True - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, include_start_time_state=False - ) - state = hist[entity_id][0] - assert state.attributes == {} - - no_attributes = False - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, include_start_time_state=False - ) - state = hist[entity_id][0] - assert state.attributes == {"name": "the shared light"} - - with instance.engine.connect() as conn: - conn.execute(text("update states set attributes_id=NULL;")) - conn.execute(text("drop table state_attributes;")) - conn.commit() - - with patch.object(instance, "schema_version", 24): - instance.states_meta_manager.active = False - no_attributes = True - hist = history.state_changes_during_period( - hass, - start, - end, - entity_id, - no_attributes, - include_start_time_state=False, - ) - state = hist[entity_id][0] - assert state.attributes == {} - - no_attributes = False - hist = history.state_changes_during_period( - hass, - start, - end, - entity_id, - no_attributes, - include_start_time_state=False, - ) - state = hist[entity_id][0] - assert state.attributes == {"name": "the light"} - - -@pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) -@pytest.mark.usefixtures("skip_by_db_engine") -async def test_get_states_query_during_migration_to_schema_25( - hass: HomeAssistant, - recorder_db_url: str, -) -> None: - """Test we can query data prior to schema 25 and during migration to schema 25. - - This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop the - state_attributes table. - """ - - instance = recorder.get_instance(hass) - - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - entity_id = "light.test" - await instance.async_add_executor_job(_add_db_entries, hass, point, [entity_id]) - assert instance.states_meta_manager.active - - no_attributes = True - hist = await _async_get_states(hass, end, [entity_id], no_attributes=no_attributes) - state = hist[0] - assert state.attributes == {} - - no_attributes = False - hist = await _async_get_states(hass, end, [entity_id], no_attributes=no_attributes) - state = hist[0] - assert state.attributes == {"name": "the shared light"} - - with instance.engine.connect() as conn: - conn.execute(text("update states set attributes_id=NULL;")) - conn.execute(text("drop table state_attributes;")) - conn.commit() - - with patch.object(instance, "schema_version", 24): - instance.states_meta_manager.active = False - no_attributes = True - hist = await _async_get_states( - hass, end, [entity_id], no_attributes=no_attributes - ) - state = hist[0] - assert state.attributes == {} - - no_attributes = False - hist = await _async_get_states( - hass, end, [entity_id], no_attributes=no_attributes - ) - state = hist[0] - assert state.attributes == {"name": "the light"} - - -@pytest.mark.skip_on_db_engine(["mysql", "postgresql"]) -@pytest.mark.usefixtures("skip_by_db_engine") -async def test_get_states_query_during_migration_to_schema_25_multiple_entities( - hass: HomeAssistant, - recorder_db_url: str, -) -> None: - """Test we can query data prior to schema 25 and during migration to schema 25. - - This test doesn't run on MySQL / MariaDB / Postgresql; we can't drop the - state_attributes table. - """ - - instance = recorder.get_instance(hass) - - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - entity_id_1 = "light.test" - entity_id_2 = "switch.test" - entity_ids = [entity_id_1, entity_id_2] - - await instance.async_add_executor_job(_add_db_entries, hass, point, entity_ids) - assert instance.states_meta_manager.active - - no_attributes = True - hist = await _async_get_states(hass, end, entity_ids, no_attributes=no_attributes) - assert hist[0].attributes == {} - assert hist[1].attributes == {} - - no_attributes = False - hist = await _async_get_states(hass, end, entity_ids, no_attributes=no_attributes) - assert hist[0].attributes == {"name": "the shared light"} - assert hist[1].attributes == {"name": "the shared light"} - - with instance.engine.connect() as conn: - conn.execute(text("update states set attributes_id=NULL;")) - conn.execute(text("drop table state_attributes;")) - conn.commit() - - with patch.object(instance, "schema_version", 24): - instance.states_meta_manager.active = False - no_attributes = True - hist = await _async_get_states( - hass, end, entity_ids, no_attributes=no_attributes - ) - assert hist[0].attributes == {} - assert hist[1].attributes == {} - - no_attributes = False - hist = await _async_get_states( - hass, end, entity_ids, no_attributes=no_attributes - ) - assert hist[0].attributes == {"name": "the light"} - assert hist[1].attributes == {"name": "the light"} - - async def test_get_full_significant_states_handles_empty_last_changed( hass: HomeAssistant, ) -> None: From da81efe9c143b093f2366a707c14e569e3bc6de9 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 10 Sep 2024 13:43:25 +0200 Subject: [PATCH 0470/1309] Disable fail-fast on publish container jobs (#125245) --- .github/workflows/builder.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 955e42254e7..d21a1ba73a1 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -316,6 +316,7 @@ jobs: packages: write id-token: write strategy: + fail-fast: false matrix: registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] steps: From 7f7db4efb69592a81949e8696457e1819537cc9b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 10 Sep 2024 14:52:03 +0200 Subject: [PATCH 0471/1309] Disable ThermoWorks Smoke due incompatible dependencies (#125661) --- homeassistant/components/thermoworks_smoke/manifest.json | 1 + requirements_all.txt | 4 ---- requirements_test_all.txt | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/thermoworks_smoke/manifest.json b/homeassistant/components/thermoworks_smoke/manifest.json index 43ce96dd012..7baec9cdb74 100644 --- a/homeassistant/components/thermoworks_smoke/manifest.json +++ b/homeassistant/components/thermoworks_smoke/manifest.json @@ -2,6 +2,7 @@ "domain": "thermoworks_smoke", "name": "ThermoWorks Smoke", "codeowners": [], + "disabled": "This integration is disabled because it creates an unresolvable dependency conflict.", "documentation": "https://www.home-assistant.io/integrations/thermoworks_smoke", "iot_class": "cloud_polling", "loggers": ["thermoworks_smoke"], diff --git a/requirements_all.txt b/requirements_all.txt index eb1c8a21932..f6452b08e72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2719,7 +2719,6 @@ streamlabswater==1.0.1 # homeassistant.components.huawei_lte # homeassistant.components.solaredge -# homeassistant.components.thermoworks_smoke # homeassistant.components.traccar stringcase==1.2.0 @@ -2797,9 +2796,6 @@ thermobeacon-ble==0.7.0 # homeassistant.components.thermopro thermopro-ble==0.10.0 -# homeassistant.components.thermoworks_smoke -thermoworks-smoke==0.1.8 - # homeassistant.components.thingspeak thingspeak==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb2d4931173..c74fe22299d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2159,7 +2159,6 @@ streamlabswater==1.0.1 # homeassistant.components.huawei_lte # homeassistant.components.solaredge -# homeassistant.components.thermoworks_smoke # homeassistant.components.traccar stringcase==1.2.0 From 745a05d9844532127bff37b02bfde4f43536c021 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:02:09 +0200 Subject: [PATCH 0472/1309] Move Hub and Entity to separate module in ADS (#125665) * Move Hub and Entity to separate module in ADS * Missed one --- homeassistant/components/ads/__init__.py | 203 +----------------- homeassistant/components/ads/binary_sensor.py | 3 +- homeassistant/components/ads/cover.py | 2 +- homeassistant/components/ads/entity.py | 64 ++++++ homeassistant/components/ads/hub.py | 151 +++++++++++++ homeassistant/components/ads/light.py | 2 +- homeassistant/components/ads/sensor.py | 10 +- homeassistant/components/ads/switch.py | 3 +- 8 files changed, 225 insertions(+), 213 deletions(-) create mode 100644 homeassistant/components/ads/entity.py create mode 100644 homeassistant/components/ads/hub.py diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 32d89b5b597..c5c3b48499a 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -1,12 +1,6 @@ """Support for Automation Device Specification (ADS).""" -import asyncio -from asyncio import timeout -from collections import namedtuple -import ctypes import logging -import struct -import threading import pyads import voluptuous as vol @@ -19,9 +13,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType +from .hub import AdsHub + _LOGGER = logging.getLogger(__name__) DATA_ADS = "data_ads" @@ -166,197 +161,3 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return True - - -# Tuple to hold data needed for notification -NotificationItem = namedtuple( # noqa: PYI024 - "NotificationItem", "hnotify huser name plc_datatype callback" -) - - -class AdsHub: - """Representation of an ADS connection.""" - - def __init__(self, ads_client): - """Initialize the ADS hub.""" - self._client = ads_client - self._client.open() - - # All ADS devices are registered here - self._devices = [] - self._notification_items = {} - self._lock = threading.Lock() - - def shutdown(self, *args, **kwargs): - """Shutdown ADS connection.""" - - _LOGGER.debug("Shutting down ADS") - for notification_item in self._notification_items.values(): - _LOGGER.debug( - "Deleting device notification %d, %d", - notification_item.hnotify, - notification_item.huser, - ) - try: - self._client.del_device_notification( - notification_item.hnotify, notification_item.huser - ) - except pyads.ADSError as err: - _LOGGER.error(err) - try: - self._client.close() - except pyads.ADSError as err: - _LOGGER.error(err) - - def register_device(self, device): - """Register a new device.""" - self._devices.append(device) - - def write_by_name(self, name, value, plc_datatype): - """Write a value to the device.""" - - with self._lock: - try: - return self._client.write_by_name(name, value, plc_datatype) - except pyads.ADSError as err: - _LOGGER.error("Error writing %s: %s", name, err) - - def read_by_name(self, name, plc_datatype): - """Read a value from the device.""" - - with self._lock: - try: - return self._client.read_by_name(name, plc_datatype) - except pyads.ADSError as err: - _LOGGER.error("Error reading %s: %s", name, err) - - def add_device_notification(self, name, plc_datatype, callback): - """Add a notification to the ADS devices.""" - - attr = pyads.NotificationAttrib(ctypes.sizeof(plc_datatype)) - - with self._lock: - try: - hnotify, huser = self._client.add_device_notification( - name, attr, self._device_notification_callback - ) - except pyads.ADSError as err: - _LOGGER.error("Error subscribing to %s: %s", name, err) - else: - hnotify = int(hnotify) - self._notification_items[hnotify] = NotificationItem( - hnotify, huser, name, plc_datatype, callback - ) - - _LOGGER.debug( - "Added device notification %d for variable %s", hnotify, name - ) - - def _device_notification_callback(self, notification, name): - """Handle device notifications.""" - contents = notification.contents - hnotify = int(contents.hNotification) - _LOGGER.debug("Received notification %d", hnotify) - - # Get dynamically sized data array - data_size = contents.cbSampleSize - data_address = ( - ctypes.addressof(contents) - + pyads.structs.SAdsNotificationHeader.data.offset - ) - data = (ctypes.c_ubyte * data_size).from_address(data_address) - - # Acquire notification item - with self._lock: - notification_item = self._notification_items.get(hnotify) - - if not notification_item: - _LOGGER.error("Unknown device notification handle: %d", hnotify) - return - - # Data parsing based on PLC data type - plc_datatype = notification_item.plc_datatype - unpack_formats = { - pyads.PLCTYPE_BYTE: " bool: - """Return False if state has not been updated yet.""" - return self._state_dict[STATE_KEY_STATE] is not None diff --git a/homeassistant/components/ads/binary_sensor.py b/homeassistant/components/ads/binary_sensor.py index 6ee17e07f0f..fde9ceaa143 100644 --- a/homeassistant/components/ads/binary_sensor.py +++ b/homeassistant/components/ads/binary_sensor.py @@ -17,7 +17,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE, AdsEntity +from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE +from .entity import AdsEntity DEFAULT_NAME = "ADS binary sensor" PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index b0dded8d4d5..be1b0564069 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -26,8 +26,8 @@ from . import ( DATA_ADS, STATE_KEY_POSITION, STATE_KEY_STATE, - AdsEntity, ) +from .entity import AdsEntity DEFAULT_NAME = "ADS Cover" diff --git a/homeassistant/components/ads/entity.py b/homeassistant/components/ads/entity.py new file mode 100644 index 00000000000..407be5c24e8 --- /dev/null +++ b/homeassistant/components/ads/entity.py @@ -0,0 +1,64 @@ +"""Support for Automation Device Specification (ADS).""" + +import asyncio +from asyncio import timeout +import logging + +from homeassistant.helpers.entity import Entity + +from . import STATE_KEY_STATE + +_LOGGER = logging.getLogger(__name__) + + +class AdsEntity(Entity): + """Representation of ADS entity.""" + + _attr_should_poll = False + + def __init__(self, ads_hub, name, ads_var): + """Initialize ADS binary sensor.""" + self._state_dict = {} + self._state_dict[STATE_KEY_STATE] = None + self._ads_hub = ads_hub + self._ads_var = ads_var + self._event = None + self._attr_unique_id = ads_var + self._attr_name = name + + async def async_initialize_device( + self, ads_var, plctype, state_key=STATE_KEY_STATE, factor=None + ): + """Register device notification.""" + + def update(name, value): + """Handle device notifications.""" + _LOGGER.debug("Variable %s changed its value to %d", name, value) + + if factor is None: + self._state_dict[state_key] = value + else: + self._state_dict[state_key] = value / factor + + asyncio.run_coroutine_threadsafe(async_event_set(), self.hass.loop) + self.schedule_update_ha_state() + + async def async_event_set(): + """Set event in async context.""" + self._event.set() + + self._event = asyncio.Event() + + await self.hass.async_add_executor_job( + self._ads_hub.add_device_notification, ads_var, plctype, update + ) + try: + async with timeout(10): + await self._event.wait() + except TimeoutError: + _LOGGER.debug("Variable %s: Timeout during first update", ads_var) + + @property + def available(self) -> bool: + """Return False if state has not been updated yet.""" + return self._state_dict[STATE_KEY_STATE] is not None diff --git a/homeassistant/components/ads/hub.py b/homeassistant/components/ads/hub.py new file mode 100644 index 00000000000..9eb35ab6243 --- /dev/null +++ b/homeassistant/components/ads/hub.py @@ -0,0 +1,151 @@ +"""Support for Automation Device Specification (ADS).""" + +from collections import namedtuple +import ctypes +import logging +import struct +import threading + +import pyads + +_LOGGER = logging.getLogger(__name__) + +# Tuple to hold data needed for notification +NotificationItem = namedtuple( # noqa: PYI024 + "NotificationItem", "hnotify huser name plc_datatype callback" +) + + +class AdsHub: + """Representation of an ADS connection.""" + + def __init__(self, ads_client): + """Initialize the ADS hub.""" + self._client = ads_client + self._client.open() + + # All ADS devices are registered here + self._devices = [] + self._notification_items = {} + self._lock = threading.Lock() + + def shutdown(self, *args, **kwargs): + """Shutdown ADS connection.""" + + _LOGGER.debug("Shutting down ADS") + for notification_item in self._notification_items.values(): + _LOGGER.debug( + "Deleting device notification %d, %d", + notification_item.hnotify, + notification_item.huser, + ) + try: + self._client.del_device_notification( + notification_item.hnotify, notification_item.huser + ) + except pyads.ADSError as err: + _LOGGER.error(err) + try: + self._client.close() + except pyads.ADSError as err: + _LOGGER.error(err) + + def register_device(self, device): + """Register a new device.""" + self._devices.append(device) + + def write_by_name(self, name, value, plc_datatype): + """Write a value to the device.""" + + with self._lock: + try: + return self._client.write_by_name(name, value, plc_datatype) + except pyads.ADSError as err: + _LOGGER.error("Error writing %s: %s", name, err) + + def read_by_name(self, name, plc_datatype): + """Read a value from the device.""" + + with self._lock: + try: + return self._client.read_by_name(name, plc_datatype) + except pyads.ADSError as err: + _LOGGER.error("Error reading %s: %s", name, err) + + def add_device_notification(self, name, plc_datatype, callback): + """Add a notification to the ADS devices.""" + + attr = pyads.NotificationAttrib(ctypes.sizeof(plc_datatype)) + + with self._lock: + try: + hnotify, huser = self._client.add_device_notification( + name, attr, self._device_notification_callback + ) + except pyads.ADSError as err: + _LOGGER.error("Error subscribing to %s: %s", name, err) + else: + hnotify = int(hnotify) + self._notification_items[hnotify] = NotificationItem( + hnotify, huser, name, plc_datatype, callback + ) + + _LOGGER.debug( + "Added device notification %d for variable %s", hnotify, name + ) + + def _device_notification_callback(self, notification, name): + """Handle device notifications.""" + contents = notification.contents + hnotify = int(contents.hNotification) + _LOGGER.debug("Received notification %d", hnotify) + + # Get dynamically sized data array + data_size = contents.cbSampleSize + data_address = ( + ctypes.addressof(contents) + + pyads.structs.SAdsNotificationHeader.data.offset + ) + data = (ctypes.c_ubyte * data_size).from_address(data_address) + + # Acquire notification item + with self._lock: + notification_item = self._notification_items.get(hnotify) + + if not notification_item: + _LOGGER.error("Unknown device notification handle: %d", hnotify) + return + + # Data parsing based on PLC data type + plc_datatype = notification_item.plc_datatype + unpack_formats = { + pyads.PLCTYPE_BYTE: " Date: Tue, 10 Sep 2024 15:16:26 +0200 Subject: [PATCH 0473/1309] Migrate wolflink config_entry unique_id to string (#125653) * Migrate wolflink config_entry unique_id to string * Move CONFIG to const * isinstance * Migrate identifiers * Use async_migrate_entry --- homeassistant/components/wolflink/__init__.py | 28 +++++++++ .../components/wolflink/config_flow.py | 3 +- homeassistant/components/wolflink/sensor.py | 2 +- tests/components/wolflink/const.py | 16 +++++ tests/components/wolflink/test_config_flow.py | 12 +--- tests/components/wolflink/test_init.py | 59 +++++++++++++++++++ 6 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 tests/components/wolflink/const.py create mode 100644 tests/components/wolflink/test_init.py diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index ad1759ba2cb..b897debfede 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -30,6 +31,7 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Wolf SmartSet Service from a config entry.""" + username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] device_name = entry.data[DEVICE_NAME] @@ -125,6 +127,32 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + # convert unique_id to string + if entry.version == 1 and entry.minor_version == 1: + if isinstance(entry.unique_id, int): + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id) + ) + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + new_identifiers = set() + for identifier in device.identifiers: + if identifier[0] == DOMAIN: + new_identifiers.add((DOMAIN, str(identifier[1]))) + else: + new_identifiers.add(identifier) + device_registry.async_update_device( + device.id, new_identifiers=new_identifiers + ) + hass.config_entries.async_update_entry(entry, minor_version=2) + + return True + + async def fetch_parameters(client: WolfClient, gateway_id: int, device_id: int): """Fetch all available parameters with usage of WolfClient. diff --git a/homeassistant/components/wolflink/config_flow.py b/homeassistant/components/wolflink/config_flow.py index a2678580a23..df5d7369a86 100644 --- a/homeassistant/components/wolflink/config_flow.py +++ b/homeassistant/components/wolflink/config_flow.py @@ -24,6 +24,7 @@ class WolfLinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Wolf SmartSet Service.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize with empty username and password.""" @@ -66,7 +67,7 @@ class WolfLinkConfigFlow(ConfigFlow, domain=DOMAIN): device for device in self.fetched_systems if device.name == device_name ] device_id = system[0].id - await self.async_set_unique_id(device_id) + await self.async_set_unique_id(str(device_id)) self._abort_if_unique_id_configured() return self.async_create_entry( title=user_input[DEVICE_NAME], diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 3179a9ff6bd..1f6e6c42464 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -63,7 +63,7 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}" self._state = None self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_id)}, + identifiers={(DOMAIN, str(device_id))}, configuration_url="https://www.wolf-smartset.com/", manufacturer=MANUFACTURER, ) diff --git a/tests/components/wolflink/const.py b/tests/components/wolflink/const.py new file mode 100644 index 00000000000..073faec51b2 --- /dev/null +++ b/tests/components/wolflink/const.py @@ -0,0 +1,16 @@ +"""Constants for the Wolf SmartSet Service tests.""" + +from homeassistant.components.wolflink.const import ( + DEVICE_GATEWAY, + DEVICE_ID, + DEVICE_NAME, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +CONFIG = { + DEVICE_NAME: "test-device", + DEVICE_ID: 1234, + DEVICE_GATEWAY: 5678, + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} diff --git a/tests/components/wolflink/test_config_flow.py b/tests/components/wolflink/test_config_flow.py index bd71d9d3180..d30cc046a85 100644 --- a/tests/components/wolflink/test_config_flow.py +++ b/tests/components/wolflink/test_config_flow.py @@ -17,15 +17,9 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from .const import CONFIG -CONFIG = { - DEVICE_NAME: "test-device", - DEVICE_ID: 1234, - DEVICE_GATEWAY: 5678, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", -} +from tests.common import MockConfigEntry INPUT_CONFIG = { CONF_USERNAME: CONFIG[CONF_USERNAME], @@ -134,7 +128,7 @@ async def test_already_configured_error(hass: HomeAssistant) -> None: patch("homeassistant.components.wolflink.async_setup_entry", return_value=True), ): MockConfigEntry( - domain=DOMAIN, unique_id=CONFIG[DEVICE_ID], data=CONFIG + domain=DOMAIN, unique_id=str(CONFIG[DEVICE_ID]), data=CONFIG ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/wolflink/test_init.py b/tests/components/wolflink/test_init.py new file mode 100644 index 00000000000..ec39619452f --- /dev/null +++ b/tests/components/wolflink/test_init.py @@ -0,0 +1,59 @@ +"""Test the Wolf SmartSet Service.""" + +from unittest.mock import patch + +from httpx import RequestError + +from homeassistant.components.wolflink.const import DEVICE_ID, DOMAIN, MANUFACTURER +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import CONFIG + +from tests.common import MockConfigEntry + + +async def test_unique_id_migration( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: + """Test already configured while creating entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id=CONFIG[DEVICE_ID], data=CONFIG + ) + config_entry.add_to_hass(hass) + + device_id = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, CONFIG[DEVICE_ID])}, + configuration_url="https://www.wolf-smartset.com/", + manufacturer=MANUFACTURER, + ).id + + assert config_entry.version == 1 + assert config_entry.minor_version == 1 + assert config_entry.unique_id == 1234 + assert ( + hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, 1234) + is config_entry + ) + assert hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234") is None + assert device_registry.async_get(device_id).identifiers == {(DOMAIN, 1234)} + + with ( + patch( + "homeassistant.components.wolflink.fetch_parameters", + side_effect=RequestError("Unable to fetch parameters"), + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + + assert config_entry.version == 1 + assert config_entry.minor_version == 2 + assert config_entry.unique_id == "1234" + assert ( + hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, "1234") + is config_entry + ) + assert hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, 1234) is None + + assert device_registry.async_get(device_id).identifiers == {(DOMAIN, "1234")} From 67dc870e522113677b25857a1b921503493cae62 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 10 Sep 2024 15:28:17 +0200 Subject: [PATCH 0474/1309] Bump uv to 0.4.8 (#124867) --- Dockerfile | 2 +- requirements_test.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7ead7bc7e4f..c8a8d9a2172 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.2.27 +RUN pip3 install uv==0.4.8 WORKDIR /usr/src diff --git a/requirements_test.txt b/requirements_test.txt index 87203daae96..6869cc12e11 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -51,4 +51,4 @@ types-pytz==2024.1.0.20240417 types-PyYAML==6.0.12.20240311 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 -uv==0.2.27 +uv==0.4.8 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 571ae6a7181..cf3765288f4 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.2.27,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.4.8,source=/uv,target=/bin/uv \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ From 97c55ae6f1095216df49fb3f7078055e80e81f18 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:30:03 +0200 Subject: [PATCH 0475/1309] Warn on non-string config entry unique IDs (#125662) * Warn on non-string config entry unique IDs * Add comment * isinstance --- homeassistant/config_entries.py | 11 +++++++---- tests/test_config_entries.py | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e64d2001efa..7870964722f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1527,10 +1527,13 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): self._domain_index.setdefault(entry.domain, []).append(entry) if entry.unique_id is not None: unique_id_hash = entry.unique_id - # Guard against integrations using unhashable unique_id - # In HA Core 2024.9, we should remove the guard and instead fail - if not isinstance(entry.unique_id, Hashable): - unique_id_hash = str(entry.unique_id) # type: ignore[unreachable] + if not isinstance(entry.unique_id, str): + # Guard against integrations using unhashable unique_id + # In HA Core 2024.9, we should remove the guard and instead fail + if not isinstance(entry.unique_id, Hashable): # type: ignore[unreachable] + unique_id_hash = str(entry.unique_id) + # Checks for other non-string was added in HA Core 2024.10 + # In HA Core 2025.10, we should remove the error and instead fail report_issue = async_suggest_report_issue( self._hass, integration_domain=entry.domain ) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d01febd6904..abe8ab83952 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -5093,7 +5093,7 @@ async def test_hashable_non_string_unique_id( entries[entry.entry_id] = entry assert ( "Config entry 'title' from integration test has an invalid unique_id" - ) not in caplog.text + ) in caplog.text assert entry.entry_id in entries assert entries[entry.entry_id] is entry From 130b6559a68a6acae79138feae7c70ba78560580 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 10 Sep 2024 15:30:30 +0200 Subject: [PATCH 0476/1309] Add coordinator to Daikin (#124394) * Add coordinator to Daikin * Add coordinator to Daikin * Fix * Add seconds --- homeassistant/components/daikin/__init__.py | 116 +++++------------- homeassistant/components/daikin/climate.py | 73 +++++------ .../components/daikin/coordinator.py | 30 +++++ homeassistant/components/daikin/entity.py | 25 ++++ homeassistant/components/daikin/sensor.py | 20 ++- homeassistant/components/daikin/switch.py | 82 +++++-------- tests/components/daikin/test_init.py | 13 +- 7 files changed, 162 insertions(+), 197 deletions(-) create mode 100644 homeassistant/components/daikin/coordinator.py create mode 100644 homeassistant/components/daikin/entity.py diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index 4da6bcee50b..c58578071ee 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -3,9 +3,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta import logging -from typing import Any from aiohttp import ClientConnectionError from pydaikin.daikin_base import Appliance @@ -23,15 +21,13 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.util import Throttle +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from .const import DOMAIN, KEY_MAC, TIMEOUT +from .coordinator import DaikinCoordinator _LOGGER = logging.getLogger(__name__) -PARALLEL_UPDATES = 0 -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] @@ -43,19 +39,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.unique_id is None or ".local" in entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=conf[KEY_MAC]) - daikin_api = await daikin_api_setup( - hass, - conf[CONF_HOST], - conf.get(CONF_API_KEY), - conf.get(CONF_UUID), - conf.get(CONF_PASSWORD), - ) - if not daikin_api: - return False + session = async_get_clientsession(hass) + host = conf[CONF_HOST] + try: + async with asyncio.timeout(TIMEOUT): + device: Appliance = await DaikinFactory( + host, + session, + key=entry.data.get(CONF_API_KEY), + uuid=entry.data.get(CONF_UUID), + password=entry.data.get(CONF_PASSWORD), + ) + _LOGGER.debug("Connection to %s successful", host) + except TimeoutError as err: + _LOGGER.debug("Connection to %s timed out in 60 seconds", host) + raise ConfigEntryNotReady from err + except ClientConnectionError as err: + _LOGGER.debug("ClientConnectionError to %s", host) + raise ConfigEntryNotReady from err - await async_migrate_unique_id(hass, entry, daikin_api) + coordinator = DaikinCoordinator(hass, device) - hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: daikin_api}) + await coordinator.async_config_entry_first_refresh() + + await async_migrate_unique_id(hass, entry, device) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -70,83 +79,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def daikin_api_setup( - hass: HomeAssistant, - host: str, - key: str | None, - uuid: str | None, - password: str | None, -) -> DaikinApi | None: - """Create a Daikin instance only once.""" - - session = async_get_clientsession(hass) - try: - async with asyncio.timeout(TIMEOUT): - device: Appliance = await DaikinFactory( - host, session, key=key, uuid=uuid, password=password - ) - _LOGGER.debug("Connection to %s successful", host) - except TimeoutError as err: - _LOGGER.debug("Connection to %s timed out", host) - raise ConfigEntryNotReady from err - except ClientConnectionError as err: - _LOGGER.debug("ClientConnectionError to %s", host) - raise ConfigEntryNotReady from err - except Exception: # noqa: BLE001 - _LOGGER.error("Unexpected error creating device %s", host) - return None - - return DaikinApi(device) - - -class DaikinApi: - """Keep the Daikin instance in one place and centralize the update.""" - - def __init__(self, device: Appliance) -> None: - """Initialize the Daikin Handle.""" - self.device = device - self.name = device.values.get("name", "Daikin AC") - self.ip_address = device.device_ip - self._available = True - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self, **kwargs: Any) -> None: - """Pull the latest data from Daikin.""" - try: - await self.device.update_status() - self._available = True - except ClientConnectionError: - _LOGGER.warning("Connection failed for %s", self.ip_address) - self._available = False - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - info = self.device.values - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.device.mac)}, - manufacturer="Daikin", - model=info.get("model"), - name=info.get("name"), - sw_version=info.get("ver", "").replace("_", "."), - ) - - async def async_migrate_unique_id( - hass: HomeAssistant, config_entry: ConfigEntry, api: DaikinApi + hass: HomeAssistant, config_entry: ConfigEntry, device: Appliance ) -> None: """Migrate old entry.""" dev_reg = dr.async_get(hass) ent_reg = er.async_get(hass) old_unique_id = config_entry.unique_id - new_unique_id = api.device.mac + new_unique_id = device.mac new_mac = dr.format_mac(new_unique_id) - new_name = api.name + new_name = device.values.get("name", "Daikin AC") @callback def _update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index fc54d4b0427..22510330cc5 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -34,7 +34,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DAIKIN_DOMAIN, DaikinApi +from . import DOMAIN as DAIKIN_DOMAIN from .const import ( ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, @@ -42,6 +42,8 @@ from .const import ( ATTR_STATE_ON, ATTR_TARGET_TEMPERATURE, ) +from .coordinator import DaikinCoordinator +from .entity import DaikinEntity _LOGGER = logging.getLogger(__name__) @@ -111,7 +113,7 @@ async def async_setup_entry( ) -> None: """Set up Daikin climate based on config_entry.""" daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id) - async_add_entities([DaikinClimate(daikin_api)], update_before_add=True) + async_add_entities([DaikinClimate(daikin_api)]) def format_target_temperature(target_temperature: float) -> str: @@ -119,11 +121,10 @@ def format_target_temperature(target_temperature: float) -> str: return str(round(float(target_temperature) * 2, 0) / 2).rstrip("0").rstrip(".") -class DaikinClimate(ClimateEntity): +class DaikinClimate(DaikinEntity, ClimateEntity): """Representation of a Daikin HVAC.""" _attr_name = None - _attr_has_entity_name = True _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = list(HA_STATE_TO_DAIKIN) _attr_target_temperature_step = 1 @@ -131,13 +132,11 @@ class DaikinClimate(ClimateEntity): _attr_swing_modes: list[str] _enable_turn_on_off_backwards_compatibility = False - def __init__(self, api: DaikinApi) -> None: + def __init__(self, coordinator: DaikinCoordinator) -> None: """Initialize the climate device.""" - - self._api = api - self._attr_fan_modes = api.device.fan_rate - self._attr_swing_modes = api.device.swing_modes - self._attr_device_info = api.device_info + super().__init__(coordinator) + self._attr_fan_modes = self.device.fan_rate + self._attr_swing_modes = self.device.swing_modes self._list: dict[str, list[Any]] = { ATTR_HVAC_MODE: self._attr_hvac_modes, ATTR_FAN_MODE: self._attr_fan_modes, @@ -150,13 +149,13 @@ class DaikinClimate(ClimateEntity): | ClimateEntityFeature.TARGET_TEMPERATURE ) - if api.device.support_away_mode or api.device.support_advanced_modes: + if self.device.support_away_mode or self.device.support_advanced_modes: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE - if api.device.support_fan_rate: + if self.device.support_fan_rate: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE - if api.device.support_swing_mode: + if self.device.support_swing_mode: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE async def _set(self, settings: dict[str, Any]) -> None: @@ -185,22 +184,22 @@ class DaikinClimate(ClimateEntity): _LOGGER.error("Invalid temperature %s", value) if values: - await self._api.device.set(values) + await self.device.set(values) @property def unique_id(self) -> str: """Return a unique ID.""" - return self._api.device.mac + return self.device.mac @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return self._api.device.inside_temperature + return self.device.inside_temperature @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return self._api.device.target_temperature + return self.device.target_temperature async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -212,8 +211,8 @@ class DaikinClimate(ClimateEntity): ret = HA_STATE_TO_CURRENT_HVAC.get(self.hvac_mode) if ( ret in (HVACAction.COOLING, HVACAction.HEATING) - and self._api.device.support_compressor_frequency - and self._api.device.compressor_frequency == 0 + and self.device.support_compressor_frequency + and self.device.compressor_frequency == 0 ): return HVACAction.IDLE return ret @@ -221,7 +220,7 @@ class DaikinClimate(ClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return current operation ie. heat, cool, idle.""" - daikin_mode = self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] + daikin_mode = self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE])[1] return DAIKIN_TO_HA_STATE.get(daikin_mode, HVACMode.HEAT_COOL) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -231,7 +230,7 @@ class DaikinClimate(ClimateEntity): @property def fan_mode(self) -> str: """Return the fan setting.""" - return self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE])[1].title() + return self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE])[1].title() async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" @@ -240,7 +239,7 @@ class DaikinClimate(ClimateEntity): @property def swing_mode(self) -> str: """Return the fan setting.""" - return self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE])[1].title() + return self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE])[1].title() async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target temperature.""" @@ -250,18 +249,18 @@ class DaikinClimate(ClimateEntity): def preset_mode(self) -> str: """Return the preset_mode.""" if ( - self._api.device.represent(HA_ATTR_TO_DAIKIN[ATTR_PRESET_MODE])[1] + self.device.represent(HA_ATTR_TO_DAIKIN[ATTR_PRESET_MODE])[1] == HA_PRESET_TO_DAIKIN[PRESET_AWAY] ): return PRESET_AWAY if ( HA_PRESET_TO_DAIKIN[PRESET_BOOST] - in self._api.device.represent(DAIKIN_ATTR_ADVANCED)[1] + in self.device.represent(DAIKIN_ATTR_ADVANCED)[1] ): return PRESET_BOOST if ( HA_PRESET_TO_DAIKIN[PRESET_ECO] - in self._api.device.represent(DAIKIN_ATTR_ADVANCED)[1] + in self.device.represent(DAIKIN_ATTR_ADVANCED)[1] ): return PRESET_ECO return PRESET_NONE @@ -269,23 +268,23 @@ class DaikinClimate(ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" if preset_mode == PRESET_AWAY: - await self._api.device.set_holiday(ATTR_STATE_ON) + await self.device.set_holiday(ATTR_STATE_ON) elif preset_mode == PRESET_BOOST: - await self._api.device.set_advanced_mode( + await self.device.set_advanced_mode( HA_PRESET_TO_DAIKIN[PRESET_BOOST], ATTR_STATE_ON ) elif preset_mode == PRESET_ECO: - await self._api.device.set_advanced_mode( + await self.device.set_advanced_mode( HA_PRESET_TO_DAIKIN[PRESET_ECO], ATTR_STATE_ON ) elif self.preset_mode == PRESET_AWAY: - await self._api.device.set_holiday(ATTR_STATE_OFF) + await self.device.set_holiday(ATTR_STATE_OFF) elif self.preset_mode == PRESET_BOOST: - await self._api.device.set_advanced_mode( + await self.device.set_advanced_mode( HA_PRESET_TO_DAIKIN[PRESET_BOOST], ATTR_STATE_OFF ) elif self.preset_mode == PRESET_ECO: - await self._api.device.set_advanced_mode( + await self.device.set_advanced_mode( HA_PRESET_TO_DAIKIN[PRESET_ECO], ATTR_STATE_OFF ) @@ -293,22 +292,18 @@ class DaikinClimate(ClimateEntity): def preset_modes(self) -> list[str]: """List of available preset modes.""" ret = [PRESET_NONE] - if self._api.device.support_away_mode: + if self.device.support_away_mode: ret.append(PRESET_AWAY) - if self._api.device.support_advanced_modes: + if self.device.support_advanced_modes: ret += [PRESET_ECO, PRESET_BOOST] return ret - async def async_update(self) -> None: - """Retrieve latest state.""" - await self._api.async_update() - async def async_turn_on(self) -> None: """Turn device on.""" - await self._api.device.set({}) + await self.device.set({}) async def async_turn_off(self) -> None: """Turn device off.""" - await self._api.device.set( + await self.device.set( {HA_ATTR_TO_DAIKIN[ATTR_HVAC_MODE]: HA_STATE_TO_DAIKIN[HVACMode.OFF]} ) diff --git a/homeassistant/components/daikin/coordinator.py b/homeassistant/components/daikin/coordinator.py new file mode 100644 index 00000000000..35d998b4ba2 --- /dev/null +++ b/homeassistant/components/daikin/coordinator.py @@ -0,0 +1,30 @@ +"""Coordinator for Daikin integration.""" + +from datetime import timedelta +import logging + +from pydaikin.daikin_base import Appliance + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DaikinCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching Daikin data.""" + + def __init__(self, hass: HomeAssistant, device: Appliance) -> None: + """Initialize global Daikin data updater.""" + super().__init__( + hass, + _LOGGER, + name=device.values.get("name", DOMAIN), + update_interval=timedelta(seconds=60), + ) + self.device = device + + async def _async_update_data(self) -> None: + await self.device.update_status() diff --git a/homeassistant/components/daikin/entity.py b/homeassistant/components/daikin/entity.py new file mode 100644 index 00000000000..704ce226416 --- /dev/null +++ b/homeassistant/components/daikin/entity.py @@ -0,0 +1,25 @@ +"""Base entity for Daikin.""" + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import DaikinCoordinator + + +class DaikinEntity(CoordinatorEntity[DaikinCoordinator]): + """Base entity for Daikin.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: DaikinCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.device = coordinator.device + info = self.device.values + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self.device.mac)}, + manufacturer="Daikin", + model=info.get("model"), + name=info.get("name"), + sw_version=info.get("ver", "").replace("_", "."), + ) diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index a17a80f2065..bcf23068a63 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DAIKIN_DOMAIN, DaikinApi +from . import DOMAIN as DAIKIN_DOMAIN from .const import ( ATTR_COMPRESSOR_FREQUENCY, ATTR_COOL_ENERGY, @@ -38,6 +38,8 @@ from .const import ( ATTR_TOTAL_ENERGY_TODAY, ATTR_TOTAL_POWER, ) +from .coordinator import DaikinCoordinator +from .entity import DaikinEntity @dataclass(frozen=True, kw_only=True) @@ -173,26 +175,20 @@ async def async_setup_entry( async_add_entities(entities) -class DaikinSensor(SensorEntity): +class DaikinSensor(DaikinEntity, SensorEntity): """Representation of a Sensor.""" - _attr_has_entity_name = True entity_description: DaikinSensorEntityDescription def __init__( - self, api: DaikinApi, description: DaikinSensorEntityDescription + self, coordinator: DaikinCoordinator, description: DaikinSensorEntityDescription ) -> None: """Initialize the sensor.""" + super().__init__(coordinator) self.entity_description = description - self._attr_device_info = api.device_info - self._attr_unique_id = f"{api.device.mac}-{description.key}" - self._api = api + self._attr_unique_id = f"{self.device.mac}-{description.key}" @property def native_value(self) -> float | None: """Return the state of the sensor.""" - return self.entity_description.value_func(self._api.device) - - async def async_update(self) -> None: - """Retrieve latest state.""" - await self._api.async_update() + return self.entity_description.value_func(self.device) diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index af94e98a337..309b21d2cb9 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -10,7 +10,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as DAIKIN_DOMAIN, DaikinApi +from . import DOMAIN +from .coordinator import DaikinCoordinator +from .entity import DaikinEntity DAIKIN_ATTR_ADVANCED = "adv" DAIKIN_ATTR_STREAMER = "streamer" @@ -34,15 +36,13 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Daikin climate based on config_entry.""" - daikin_api: DaikinApi = hass.data[DAIKIN_DOMAIN][entry.entry_id] - switches: list[DaikinZoneSwitch | DaikinStreamerSwitch | DaikinToggleSwitch] = [] + daikin_api: DaikinCoordinator = hass.data[DOMAIN][entry.entry_id] + switches: list[SwitchEntity] = [] if zones := daikin_api.device.zones: switches.extend( - [ - DaikinZoneSwitch(daikin_api, zone_id) - for zone_id, zone in enumerate(zones) - if zone[0] != "-" - ] + DaikinZoneSwitch(daikin_api, zone_id) + for zone_id, zone in enumerate(zones) + if zone[0] != "-" ) if daikin_api.device.support_advanced_modes: # It isn't possible to find out from the API responses if a specific @@ -53,100 +53,80 @@ async def async_setup_entry( async_add_entities(switches) -class DaikinZoneSwitch(SwitchEntity): +class DaikinZoneSwitch(DaikinEntity, SwitchEntity): """Representation of a zone.""" - _attr_has_entity_name = True _attr_translation_key = "zone" - def __init__(self, api: DaikinApi, zone_id: int) -> None: + def __init__(self, coordinator: DaikinCoordinator, zone_id: int) -> None: """Initialize the zone.""" - self._api = api + super().__init__(coordinator) self._zone_id = zone_id - self._attr_device_info = api.device_info - self._attr_unique_id = f"{api.device.mac}-zone{zone_id}" + self._attr_unique_id = f"{self.device.mac}-zone{zone_id}" @property def name(self) -> str: """Return the name of the sensor.""" - return self._api.device.zones[self._zone_id][0] + return self.device.zones[self._zone_id][0] @property def is_on(self) -> bool: """Return the state of the sensor.""" - return self._api.device.zones[self._zone_id][1] == "1" - - async def async_update(self) -> None: - """Retrieve latest state.""" - await self._api.async_update() + return self.device.zones[self._zone_id][1] == "1" async def async_turn_on(self, **kwargs: Any) -> None: """Turn the zone on.""" - await self._api.device.set_zone(self._zone_id, "zone_onoff", "1") + await self.device.set_zone(self._zone_id, "zone_onoff", "1") async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" - await self._api.device.set_zone(self._zone_id, "zone_onoff", "0") + await self.device.set_zone(self._zone_id, "zone_onoff", "0") -class DaikinStreamerSwitch(SwitchEntity): +class DaikinStreamerSwitch(DaikinEntity, SwitchEntity): """Streamer state.""" _attr_name = "Streamer" - _attr_has_entity_name = True _attr_translation_key = "streamer" - def __init__(self, api: DaikinApi) -> None: - """Initialize streamer switch.""" - self._api = api - self._attr_device_info = api.device_info - self._attr_unique_id = f"{api.device.mac}-streamer" + def __init__(self, coordinator: DaikinCoordinator) -> None: + """Initialize switch.""" + super().__init__(coordinator) + self._attr_unique_id = f"{self.device.mac}-streamer" @property def is_on(self) -> bool: """Return the state of the sensor.""" - return ( - DAIKIN_ATTR_STREAMER in self._api.device.represent(DAIKIN_ATTR_ADVANCED)[1] - ) - - async def async_update(self) -> None: - """Retrieve latest state.""" - await self._api.async_update() + return DAIKIN_ATTR_STREAMER in self.device.represent(DAIKIN_ATTR_ADVANCED)[1] async def async_turn_on(self, **kwargs: Any) -> None: """Turn the zone on.""" - await self._api.device.set_streamer("on") + await self.device.set_streamer("on") async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" - await self._api.device.set_streamer("off") + await self.device.set_streamer("off") -class DaikinToggleSwitch(SwitchEntity): +class DaikinToggleSwitch(DaikinEntity, SwitchEntity): """Switch state.""" - _attr_has_entity_name = True _attr_translation_key = "toggle" - def __init__(self, api: DaikinApi) -> None: + def __init__(self, coordinator: DaikinCoordinator) -> None: """Initialize switch.""" - self._api = api - self._attr_device_info = api.device_info - self._attr_unique_id = f"{self._api.device.mac}-toggle" + super().__init__(coordinator) + self._attr_unique_id = f"{self.device.mac}-toggle" @property def is_on(self) -> bool: """Return the state of the sensor.""" - return "off" not in self._api.device.represent(DAIKIN_ATTR_MODE) - - async def async_update(self) -> None: - """Retrieve latest state.""" - await self._api.async_update() + return "off" not in self.device.represent(DAIKIN_ATTR_MODE) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the zone on.""" - await self._api.device.set({}) + await self.device.set({}) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" - await self._api.device.set({DAIKIN_ATTR_MODE: "off"}) + await self.device.set({DAIKIN_ATTR_MODE: "off"}) diff --git a/tests/components/daikin/test_init.py b/tests/components/daikin/test_init.py index b3d18467d33..2380d5ad798 100644 --- a/tests/components/daikin/test_init.py +++ b/tests/components/daikin/test_init.py @@ -7,10 +7,10 @@ from aiohttp import ClientConnectionError from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.daikin import DaikinApi, update_unique_id +from homeassistant.components.daikin import update_unique_id from homeassistant.components.daikin.const import DOMAIN, KEY_MAC from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -183,18 +183,15 @@ async def test_client_update_connection_error( await hass.config_entries.async_setup(config_entry.entry_id) - api: DaikinApi = hass.data[DOMAIN][config_entry.entry_id] - - assert api.available is True + assert hass.states.get("climate.daikinap00000").state != STATE_UNAVAILABLE type(mock_daikin).update_status.side_effect = ClientConnectionError - freezer.tick(timedelta(seconds=90)) + freezer.tick(timedelta(seconds=60)) async_fire_time_changed(hass) - await hass.async_block_till_done() - assert api.available is False + assert hass.states.get("climate.daikinap00000").state == STATE_UNAVAILABLE assert mock_daikin.update_status.call_count == 2 From afeab659e1951f7a237fb309d4981b748aceb25b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:34:30 +0200 Subject: [PATCH 0477/1309] Rename Entity module in tellduslive (#125668) --- homeassistant/components/tellduslive/binary_sensor.py | 10 ++++------ homeassistant/components/tellduslive/cover.py | 8 ++++---- .../components/tellduslive/{entry.py => entity.py} | 0 homeassistant/components/tellduslive/light.py | 8 ++++---- homeassistant/components/tellduslive/sensor.py | 8 ++++---- homeassistant/components/tellduslive/switch.py | 8 ++++---- 6 files changed, 20 insertions(+), 22 deletions(-) rename homeassistant/components/tellduslive/{entry.py => entity.py} (100%) diff --git a/homeassistant/components/tellduslive/binary_sensor.py b/homeassistant/components/tellduslive/binary_sensor.py index 1eead7b55a5..33f936beb54 100644 --- a/homeassistant/components/tellduslive/binary_sensor.py +++ b/homeassistant/components/tellduslive/binary_sensor.py @@ -7,8 +7,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import tellduslive -from .entry import TelldusLiveEntity +from .const import DOMAIN, TELLDUS_DISCOVERY_NEW +from .entity import TelldusLiveEntity async def async_setup_entry( @@ -20,14 +20,12 @@ async def async_setup_entry( async def async_discover_binary_sensor(device_id): """Discover and add a discovered sensor.""" - client = hass.data[tellduslive.DOMAIN] + client = hass.data[DOMAIN] async_add_entities([TelldusLiveSensor(client, device_id)]) async_dispatcher_connect( hass, - tellduslive.TELLDUS_DISCOVERY_NEW.format( - binary_sensor.DOMAIN, tellduslive.DOMAIN - ), + TELLDUS_DISCOVERY_NEW.format(binary_sensor.DOMAIN, DOMAIN), async_discover_binary_sensor, ) diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index de962041333..d55a72cd633 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import tellduslive from . import TelldusLiveClient -from .entry import TelldusLiveEntity +from .const import DOMAIN, TELLDUS_DISCOVERY_NEW +from .entity import TelldusLiveEntity async def async_setup_entry( @@ -23,12 +23,12 @@ async def async_setup_entry( async def async_discover_cover(device_id): """Discover and add a discovered sensor.""" - client: TelldusLiveClient = hass.data[tellduslive.DOMAIN] + client: TelldusLiveClient = hass.data[DOMAIN] async_add_entities([TelldusLiveCover(client, device_id)]) async_dispatcher_connect( hass, - tellduslive.TELLDUS_DISCOVERY_NEW.format(cover.DOMAIN, tellduslive.DOMAIN), + TELLDUS_DISCOVERY_NEW.format(cover.DOMAIN, DOMAIN), async_discover_cover, ) diff --git a/homeassistant/components/tellduslive/entry.py b/homeassistant/components/tellduslive/entity.py similarity index 100% rename from homeassistant/components/tellduslive/entry.py rename to homeassistant/components/tellduslive/entity.py diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 101ccb0dab0..753e9cf9476 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import tellduslive -from .entry import TelldusLiveEntity +from .const import DOMAIN, TELLDUS_DISCOVERY_NEW +from .entity import TelldusLiveEntity _LOGGER = logging.getLogger(__name__) @@ -25,12 +25,12 @@ async def async_setup_entry( async def async_discover_light(device_id): """Discover and add a discovered sensor.""" - client = hass.data[tellduslive.DOMAIN] + client = hass.data[DOMAIN] async_add_entities([TelldusLiveLight(client, device_id)]) async_dispatcher_connect( hass, - tellduslive.TELLDUS_DISCOVERY_NEW.format(light.DOMAIN, tellduslive.DOMAIN), + TELLDUS_DISCOVERY_NEW.format(light.DOMAIN, DOMAIN), async_discover_light, ) diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 36520044101..70c83bb0038 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -25,8 +25,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import tellduslive -from .entry import TelldusLiveEntity +from .const import DOMAIN, TELLDUS_DISCOVERY_NEW +from .entity import TelldusLiveEntity SENSOR_TYPE_TEMPERATURE = "temp" SENSOR_TYPE_HUMIDITY = "humidity" @@ -127,12 +127,12 @@ async def async_setup_entry( async def async_discover_sensor(device_id): """Discover and add a discovered sensor.""" - client = hass.data[tellduslive.DOMAIN] + client = hass.data[DOMAIN] async_add_entities([TelldusLiveSensor(client, device_id)]) async_dispatcher_connect( hass, - tellduslive.TELLDUS_DISCOVERY_NEW.format(sensor.DOMAIN, tellduslive.DOMAIN), + TELLDUS_DISCOVERY_NEW.format(sensor.DOMAIN, DOMAIN), async_discover_sensor, ) diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py index cd28a170442..bd770ab08f5 100644 --- a/homeassistant/components/tellduslive/switch.py +++ b/homeassistant/components/tellduslive/switch.py @@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import tellduslive -from .entry import TelldusLiveEntity +from .const import DOMAIN, TELLDUS_DISCOVERY_NEW +from .entity import TelldusLiveEntity async def async_setup_entry( @@ -22,12 +22,12 @@ async def async_setup_entry( async def async_discover_switch(device_id): """Discover and add a discovered sensor.""" - client = hass.data[tellduslive.DOMAIN] + client = hass.data[DOMAIN] async_add_entities([TelldusLiveSwitch(client, device_id)]) async_dispatcher_connect( hass, - tellduslive.TELLDUS_DISCOVERY_NEW.format(switch.DOMAIN, tellduslive.DOMAIN), + TELLDUS_DISCOVERY_NEW.format(switch.DOMAIN, DOMAIN), async_discover_switch, ) From 3ea4c3b8bfdfffd00700c159e5e1207f7fc48f21 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Tue, 10 Sep 2024 15:36:25 +0200 Subject: [PATCH 0478/1309] Fix malformed response in Bang & Olufsen testing (#125658) Fix malformed SoftwareUpdateStatus object --- tests/components/bang_olufsen/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index dd6c4a73469..291f3cad8d9 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -13,6 +13,7 @@ from mozart_api.models import ( ProductState, RemoteMenuItem, RenderingState, + SoftwareUpdateState, SoftwareUpdateStatus, Source, SourceArray, @@ -79,7 +80,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: ) client.get_softwareupdate_status = AsyncMock() client.get_softwareupdate_status.return_value = SoftwareUpdateStatus( - software_version="1.0.0", state="" + software_version="1.0.0", state=SoftwareUpdateState() ) client.get_product_state = AsyncMock() client.get_product_state.return_value = ProductState( From ed907da19021c8757a9f47dc8845c825c10f60fa Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:38:18 +0200 Subject: [PATCH 0479/1309] Bump aioautomower to 2024.9.0 (#125647) bump aioautomower to 2024.9.0 --- .../husqvarna_automower/manifest.json | 2 +- .../components/husqvarna_automower/number.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../husqvarna_automower/fixtures/mower.json | 15 ++++++++-- .../snapshots/test_diagnostics.ambr | 29 ++++++------------- .../husqvarna_automower/test_number.py | 4 +-- 7 files changed, 26 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 7326408e403..0721d65524e 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", "iot_class": "cloud_push", "loggers": ["aioautomower"], - "requirements": ["aioautomower==2024.8.0"] + "requirements": ["aioautomower==2024.9.0"] } diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index 540f6aa712e..5fc79ea72f7 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -45,7 +45,7 @@ async def async_set_work_area_cutting_height( work_area_id: int, ) -> None: """Set cutting height for work area.""" - await coordinator.api.commands.set_cutting_height_workarea( + await coordinator.api.commands.workarea_settings( mower_id, int(cheight), work_area_id ) diff --git a/requirements_all.txt b/requirements_all.txt index f6452b08e72..02b803f06d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -198,7 +198,7 @@ aioaseko==0.2.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.8.0 +aioautomower==2024.9.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c74fe22299d..bfabfd9a129 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -186,7 +186,7 @@ aioaseko==0.2.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.8.0 +aioautomower==2024.9.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index aa8ea2cbef4..6430dd4a89a 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -70,17 +70,26 @@ { "workAreaId": 123456, "name": "Front lawn", - "cuttingHeight": 50 + "cuttingHeight": 50, + "enabled": true, + "progress": 40, + "lastTimeCompleted": 1723449269 }, { "workAreaId": 654321, "name": "Back lawn", - "cuttingHeight": 25 + "cuttingHeight": 25, + "enabled": true, + "progress": 30, + "lastTimeCompleted": 1722449269 }, { "workAreaId": 0, "name": "", - "cuttingHeight": 50 + "cuttingHeight": 50, + "enabled": false, + "progress": 20, + "lastTimeCompleted": 1723439269 } ], "positions": [ diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 3838f2eb960..5052531efd2 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -5,26 +5,6 @@ 'battery_percent': 100, }), 'calendar': dict({ - 'events': list([ - dict({ - 'end': '2024-03-02T00:00:00', - 'rrule': 'FREQ=WEEKLY;BYDAY=MO,WE,FR', - 'schedule_no': 1, - 'start': '2024-03-01T19:00:00', - 'uid': '1140_300_MO,WE,FR', - 'work_area_id': None, - 'work_area_name': None, - }), - dict({ - 'end': '2024-03-02T08:00:00', - 'rrule': 'FREQ=WEEKLY;BYDAY=TU,TH,SA', - 'schedule_no': 2, - 'start': '2024-03-02T00:00:00', - 'uid': '0_480_TU,TH,SA', - 'work_area_id': None, - 'work_area_name': None, - }), - ]), 'tasks': list([ dict({ 'duration': 300, @@ -135,15 +115,24 @@ 'work_areas': dict({ '0': dict({ 'cutting_height': 50, + 'enabled': False, + 'last_time_completed_naive': '1970-01-20T22:43:59.269000', 'name': 'my_lawn', + 'progress': 20, }), '123456': dict({ 'cutting_height': 50, + 'enabled': True, + 'last_time_completed_naive': '1970-01-20T22:44:09.269000', 'name': 'Front lawn', + 'progress': 40, }), '654321': dict({ 'cutting_height': 25, + 'enabled': True, + 'last_time_completed_naive': '1970-01-20T22:27:29.269000', 'name': 'Back lawn', + 'progress': 30, }), }), }) diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index 9f2f8793bba..10092528866 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -78,9 +78,7 @@ async def test_number_workarea_commands( values[TEST_MOWER_ID].work_areas[123456].cutting_height = 75 mock_automower_client.get_status.return_value = values mocked_method = AsyncMock() - setattr( - mock_automower_client.commands, "set_cutting_height_workarea", mocked_method - ) + setattr(mock_automower_client.commands, "workarea_settings", mocked_method) await hass.services.async_call( domain="number", service="set_value", From 337335bfad97e6f92bb54590c6e6a49e9b393aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Honig?= <5851246+renehonig@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:39:53 +0200 Subject: [PATCH 0480/1309] Add Human Shape Detect to ONVIF (#125335) added Humap Shape Detect --- homeassistant/components/onvif/event.py | 1 + homeassistant/components/onvif/parsers.py | 26 +++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index a8f1b7f702d..95aa0728a19 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -157,6 +157,7 @@ class EventManager: # tns1:RuleEngine/CellMotionDetector/Motion//. # tns1:RuleEngine/CellMotionDetector/Motion # tns1:RuleEngine/CellMotionDetector/Motion/ + # tns1:UserAlarm/IVA/HumanShapeDetect # # Our parser expects the topic to be # tns1:RuleEngine/CellMotionDetector/Motion diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index c67cdceed54..57bd8a974db 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -711,3 +711,29 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: ) except (AttributeError, KeyError): return None + + +@PARSERS.register("tns1:UserAlarm/IVA/HumanShapeDetect") +async def async_parse_human_shape_detect(uid: str, msg) -> Event | None: + """Handle parsing event message. + + Topic: tns1:UserAlarm/IVA/HumanShapeDetect + """ + try: + topic, payload = extract_message(msg) + video_source = "" + for source in payload.Source.SimpleItem: + if source.Name == "VideoSourceConfigurationToken": + video_source = _normalize_video_source(source.Value) + break + + return Event( + f"{uid}_{topic}_{video_source}", + "Human Shape Detect", + "binary_sensor", + "motion", + None, + payload.Data.SimpleItem[0].Value == "true", + ) + except (AttributeError, KeyError): + return None From e261a159d5e4d26a0effcb7009af78c16dd57b9f Mon Sep 17 00:00:00 2001 From: Adam Pasztor Date: Tue, 10 Sep 2024 15:51:15 +0200 Subject: [PATCH 0481/1309] Add new functions to ADS sensor integration (#125331) * feat: Add new functions to ADS sensor integration * fix: use constant for SensorDeviceClass, refactor entity initialisation. * fix: add python typing. * refactor: value conversion based on ADS_TYPE, and in the dedicated data fetching method. * fix: removed unnecessary sensor types. * refactor: optimised the usage of device classes and added state classes. removed unit of measurement * fix: added unit of measurement to ADS sensor * fix: addressing review suggestions. * fix: address review suggestions. --- homeassistant/components/ads/sensor.py | 55 +++++++++++++++++++++----- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 9df2fb1ee84..40a61da6657 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -5,10 +5,15 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, + STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, + SensorDeviceClass, SensorEntity, + SensorStateClass, ) -from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,21 +24,31 @@ from . import ADS_TYPEMAP, CONF_ADS_FACTOR, CONF_ADS_TYPE, CONF_ADS_VAR, STATE_K from .entity import AdsEntity DEFAULT_NAME = "ADS sensor" + PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ADS_VAR): cv.string, vol.Optional(CONF_ADS_FACTOR): cv.positive_int, vol.Optional(CONF_ADS_TYPE, default=ads.ADSTYPE_INT): vol.In( [ + ads.ADSTYPE_BOOL, + ads.ADSTYPE_BYTE, ads.ADSTYPE_INT, ads.ADSTYPE_UINT, - ads.ADSTYPE_BYTE, + ads.ADSTYPE_SINT, + ads.ADSTYPE_USINT, ads.ADSTYPE_DINT, ads.ADSTYPE_UDINT, + ads.ADSTYPE_WORD, + ads.ADSTYPE_DWORD, + ads.ADSTYPE_LREAL, + ads.ADSTYPE_REAL, ] ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=""): cv.string, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } ) @@ -45,15 +60,25 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up an ADS sensor device.""" - ads_hub = hass.data.get(ads.DATA_ADS) - + ads_hub: ads.AdsHub = hass.data[ads.DATA_ADS] ads_var = config[CONF_ADS_VAR] ads_type = config[CONF_ADS_TYPE] name = config[CONF_NAME] - unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) factor = config.get(CONF_ADS_FACTOR) + device_class = config.get(CONF_DEVICE_CLASS) + state_class = config.get(CONF_STATE_CLASS) + unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - entity = AdsSensor(ads_hub, ads_var, ads_type, name, unit_of_measurement, factor) + entity = AdsSensor( + ads_hub, + ads_var, + ads_type, + name, + factor, + device_class, + state_class, + unit_of_measurement, + ) add_entities([entity]) @@ -61,12 +86,24 @@ def setup_platform( class AdsSensor(AdsEntity, SensorEntity): """Representation of an ADS sensor entity.""" - def __init__(self, ads_hub, ads_var, ads_type, name, unit_of_measurement, factor): + def __init__( + self, + ads_hub: ads.AdsHub, + ads_var: str, + ads_type: str, + name: str, + factor: int | None, + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | None, + unit_of_measurement: str | None, + ) -> None: """Initialize AdsSensor entity.""" super().__init__(ads_hub, name, ads_var) - self._attr_native_unit_of_measurement = unit_of_measurement self._ads_type = ads_type self._factor = factor + self._attr_device_class = device_class + self._attr_state_class = state_class + self._attr_native_unit_of_measurement = unit_of_measurement async def async_added_to_hass(self) -> None: """Register device notification.""" From db61f8a0fab31803f9bd404426f46c8a059dbb62 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:58:20 +0200 Subject: [PATCH 0482/1309] Bump python-MotionMount to 2.1.0 (#125660) --- homeassistant/components/motionmount/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index b7ce3ad1fd9..2f7d24142db 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", "iot_class": "local_push", - "requirements": ["python-MotionMount==2.0.0"], + "requirements": ["python-MotionMount==2.1.0"], "zeroconf": ["_tvm._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 02b803f06d4..e79e830009c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2274,7 +2274,7 @@ pytfiac==0.4 pythinkingcleaner==0.0.3 # homeassistant.components.motionmount -python-MotionMount==2.0.0 +python-MotionMount==2.1.0 # homeassistant.components.awair python-awair==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfabfd9a129..b0a4f582225 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1819,7 +1819,7 @@ pytautulli==23.1.1 pytedee-async==0.2.20 # homeassistant.components.motionmount -python-MotionMount==2.0.0 +python-MotionMount==2.1.0 # homeassistant.components.awair python-awair==0.2.4 From d8bb8f1efb0d31d5a932a520906f834b3b6c3cf1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 10 Sep 2024 15:58:53 +0200 Subject: [PATCH 0483/1309] Deprecate Daikin YAML platform setup (#125158) --- homeassistant/components/daikin/climate.py | 28 +--------------------- homeassistant/components/daikin/sensor.py | 14 ----------- homeassistant/components/daikin/switch.py | 14 ----------- 3 files changed, 1 insertion(+), 55 deletions(-) diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index 22510330cc5..f1fc0473115 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -5,14 +5,11 @@ from __future__ import annotations import logging from typing import Any -import voluptuous as vol - from homeassistant.components.climate import ( ATTR_FAN_MODE, ATTR_HVAC_MODE, ATTR_PRESET_MODE, ATTR_SWING_MODE, - PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, PRESET_AWAY, PRESET_BOOST, PRESET_ECO, @@ -23,16 +20,9 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - CONF_NAME, - UnitOfTemperature, -) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as DAIKIN_DOMAIN from .const import ( @@ -47,9 +37,6 @@ from .entity import DaikinEntity _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_NAME): cv.string} -) HA_STATE_TO_DAIKIN = { HVACMode.FAN_ONLY: "fan", @@ -95,19 +82,6 @@ HA_ATTR_TO_DAIKIN = { DAIKIN_ATTR_ADVANCED = "adv" -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Old way of setting up the Daikin HVAC platform. - - Can only be called when a user accidentally mentions the platform in their - config. But even in that case it would have been ignored. - """ - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index bcf23068a63..d2d6ef02fc3 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -23,7 +23,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as DAIKIN_DOMAIN from .const import ( @@ -134,19 +133,6 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Old way of setting up the Daikin sensors. - - Can only be called when a user accidentally mentions the platform in their - config. But even in that case it would have been ignored. - """ - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 309b21d2cb9..23517d085d2 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -8,7 +8,6 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN from .coordinator import DaikinCoordinator @@ -19,19 +18,6 @@ DAIKIN_ATTR_STREAMER = "streamer" DAIKIN_ATTR_MODE = "mode" -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Old way of setting up the platform. - - Can only be called when a user accidentally mentions the platform in their - config. But even in that case it would have been ignored. - """ - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: From 379a8f2f8617eeaa1f673009641f910a6c041702 Mon Sep 17 00:00:00 2001 From: silentguy256 Date: Tue, 10 Sep 2024 16:10:36 +0200 Subject: [PATCH 0484/1309] Add state_class to OHM sensors (#125567) * Minimum change required to get OHW into statistics Not sure if there is any reason not to have this, my only idea would be that there would be that there are A LOT of values, but as far as I can see there are already long term data being stored about them anyway * Update sensor.py Guess that was an old way of doing it -_- * Update sensor.py remove spaces the break ruff -_- * Update sensor.py ruff is rough --- homeassistant/components/openhardwaremonitor/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 4ef71a6c75f..30801a59436 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, + SensorStateClass, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant @@ -60,6 +61,8 @@ def setup_platform( class OpenHardwareMonitorDevice(SensorEntity): """Device used to display information from OpenHardwareMonitor.""" + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, data, name, path, unit_of_measurement): """Initialize an OpenHardwareMonitor sensor.""" self._name = name From a361c01ed671b3d99250bf4f596205560ade090c Mon Sep 17 00:00:00 2001 From: Adam Goode Date: Tue, 10 Sep 2024 10:16:45 -0400 Subject: [PATCH 0485/1309] Parameterize many of the threshold tests (#125521) threshold: Parameterize many of the tests This simplfies the structure of the basic threshold tests, making it easier to subsequently update or add missing test cases. Except for a few removals of an inconsistenly applied assertions on `state.attributes["sensor_value"]`, there are no changes to the existing tests intended. All previous tests are expected to run identically. A few extra test cases for None are added. --- .../threshold/test_binary_sensor.py | 669 +++++++----------- 1 file changed, 273 insertions(+), 396 deletions(-) diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index 53a8446c210..250abdb9baa 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -2,11 +2,23 @@ import pytest -from homeassistant.components.threshold.const import DOMAIN +from homeassistant.components.threshold.const import ( + CONF_HYSTERESIS, + CONF_LOWER, + CONF_UPPER, + DOMAIN, +) from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONF_ENTITY_ID, + CONF_NAME, + CONF_PLATFORM, + STATE_OFF, + STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -16,461 +28,318 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_sensor_upper(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("from_val", "to_val", "expected_position", "expected_state"), + [ + (None, 15, "below", STATE_OFF), # at threshold + (15, 16, "above", STATE_ON), + (16, 14, "below", STATE_OFF), + (14, 15, "below", STATE_OFF), + (15, "cat", "unknown", STATE_UNKNOWN), + ("cat", 15, "below", STATE_OFF), + (15, None, "unknown", STATE_UNKNOWN), + ], +) +async def test_sensor_upper( + hass: HomeAssistant, + from_val: float | str | None, + to_val: float | str, + expected_position: str, + expected_state: str, +) -> None: """Test if source is above threshold.""" config = { - "binary_sensor": { - "platform": "threshold", - "upper": "15", - "entity_id": "sensor.test_monitored", + Platform.BINARY_SENSOR: { + CONF_PLATFORM: "threshold", + CONF_UPPER: "15", + CONF_ENTITY_ID: "sensor.test_monitored", } } - assert await async_setup_component(hass, "binary_sensor", config) + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - # Set the monitored sensor's state to the threshold - hass.states.async_set("sensor.test_monitored", 15) + hass.states.async_set("sensor.test_monitored", from_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" - - hass.states.async_set( - "sensor.test_monitored", - 16, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" + assert state.attributes["upper"] == float( + config[Platform.BINARY_SENSOR][CONF_UPPER] ) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["entity_id"] == "sensor.test_monitored" - assert state.attributes["sensor_value"] == 16 - assert state.attributes["position"] == "above" - assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) assert state.attributes["hysteresis"] == 0.0 assert state.attributes["type"] == "upper" - assert state.state == "on" - hass.states.async_set("sensor.test_monitored", 14) + hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 15) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", "cat") - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" - - hass.states.async_set("sensor.test_monitored", 15) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" + assert state.attributes["position"] == expected_position + assert state.state == expected_state -async def test_sensor_lower(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("from_val", "to_val", "expected_position", "expected_state"), + [ + (None, 15, "above", STATE_OFF), # at threshold + (15, 16, "above", STATE_OFF), + (16, 14, "below", STATE_ON), + (14, 15, "below", STATE_ON), + (15, "cat", "unknown", STATE_UNKNOWN), + ("cat", 15, "above", STATE_OFF), + (15, None, "unknown", STATE_UNKNOWN), + ], +) +async def test_sensor_lower( + hass: HomeAssistant, + from_val: float | str | None, + to_val: float | str, + expected_position: str, + expected_state: str, +) -> None: """Test if source is below threshold.""" config = { - "binary_sensor": { - "platform": "threshold", - "lower": "15", - "entity_id": "sensor.test_monitored", + Platform.BINARY_SENSOR: { + CONF_PLATFORM: "threshold", + CONF_LOWER: "15", + CONF_ENTITY_ID: "sensor.test_monitored", } } - assert await async_setup_component(hass, "binary_sensor", config) + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - # Set the monitored sensor's state to the threshold - hass.states.async_set("sensor.test_monitored", 15) + hass.states.async_set("sensor.test_monitored", from_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 16) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" + assert state.attributes["lower"] == float( + config[Platform.BINARY_SENSOR][CONF_LOWER] + ) assert state.attributes["hysteresis"] == 0.0 assert state.attributes["type"] == "lower" - assert state.state == "off" - hass.states.async_set("sensor.test_monitored", 14) + hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "on" - - hass.states.async_set("sensor.test_monitored", 15) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "on" - - hass.states.async_set("sensor.test_monitored", "cat") - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" - - hass.states.async_set("sensor.test_monitored", 15) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" + assert state.attributes["position"] == expected_position + assert state.state == expected_state -async def test_sensor_upper_hysteresis(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("from_val", "to_val", "expected_position", "expected_state"), + [ + (None, 17.5, "below", STATE_OFF), # threshold + hysteresis + (17.5, 12.5, "below", STATE_OFF), # threshold - hysteresis + (12.5, 20, "above", STATE_ON), + (20, 13, "above", STATE_ON), + (13, 12, "below", STATE_OFF), + (12, 17, "below", STATE_OFF), + (17, 18, "above", STATE_ON), + (18, "cat", "unknown", STATE_UNKNOWN), + ("cat", 18, "above", STATE_ON), + (18, None, "unknown", STATE_UNKNOWN), + ], +) +async def test_sensor_upper_hysteresis( + hass: HomeAssistant, + from_val: float | str | None, + to_val: float | str, + expected_position: str, + expected_state: str, +) -> None: """Test if source is above threshold using hysteresis.""" config = { - "binary_sensor": { - "platform": "threshold", - "upper": "15", - "hysteresis": "2.5", - "entity_id": "sensor.test_monitored", + Platform.BINARY_SENSOR: { + CONF_PLATFORM: "threshold", + CONF_UPPER: "15", + CONF_HYSTERESIS: "2.5", + CONF_ENTITY_ID: "sensor.test_monitored", } } - assert await async_setup_component(hass, "binary_sensor", config) + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - # Set the monitored sensor's state to the threshold + hysteresis - hass.states.async_set("sensor.test_monitored", 17.5) + hass.states.async_set("sensor.test_monitored", from_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" - - # Set the monitored sensor's state to the threshold - hysteresis - hass.states.async_set("sensor.test_monitored", 12.5) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 20) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" + assert state.attributes["upper"] == float( + config[Platform.BINARY_SENSOR][CONF_UPPER] + ) assert state.attributes["hysteresis"] == 2.5 assert state.attributes["type"] == "upper" - assert state.attributes["position"] == "above" - assert state.state == "on" - hass.states.async_set("sensor.test_monitored", 13) + hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "on" - - hass.states.async_set("sensor.test_monitored", 12) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 17) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 18) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "on" - - hass.states.async_set("sensor.test_monitored", "cat") - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" - - hass.states.async_set("sensor.test_monitored", 18) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "on" + assert state.attributes["position"] == expected_position + assert state.state == expected_state -async def test_sensor_lower_hysteresis(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("from_val", "to_val", "expected_position", "expected_state"), + [ + (None, 17.5, "above", STATE_OFF), # threshold + hysteresis + (17.5, 12.5, "above", STATE_OFF), # threshold - hysteresis + (12.5, 20, "above", STATE_OFF), + (20, 13, "above", STATE_OFF), + (13, 12, "below", STATE_ON), + (12, 17, "below", STATE_ON), + (17, 18, "above", STATE_OFF), + (18, "cat", "unknown", STATE_UNKNOWN), + ("cat", 18, "above", STATE_OFF), + (18, None, "unknown", STATE_UNKNOWN), + ], +) +async def test_sensor_lower_hysteresis( + hass: HomeAssistant, + from_val: float | str | None, + to_val: float | str, + expected_position: str, + expected_state: str, +) -> None: """Test if source is below threshold using hysteresis.""" config = { - "binary_sensor": { - "platform": "threshold", - "lower": "15", - "hysteresis": "2.5", - "entity_id": "sensor.test_monitored", + Platform.BINARY_SENSOR: { + CONF_PLATFORM: "threshold", + CONF_LOWER: "15", + CONF_HYSTERESIS: "2.5", + CONF_ENTITY_ID: "sensor.test_monitored", } } - assert await async_setup_component(hass, "binary_sensor", config) + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - # Set the monitored sensor's state to the threshold + hysteresis - hass.states.async_set("sensor.test_monitored", 17.5) + hass.states.async_set("sensor.test_monitored", from_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" - - # Set the monitored sensor's state to the threshold - hysteresis - hass.states.async_set("sensor.test_monitored", 12.5) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 20) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) + assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" + assert state.attributes["lower"] == float( + config[Platform.BINARY_SENSOR][CONF_LOWER] + ) assert state.attributes["hysteresis"] == 2.5 assert state.attributes["type"] == "lower" - assert state.attributes["position"] == "above" - assert state.state == "off" - hass.states.async_set("sensor.test_monitored", 13) + hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 12) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "on" - - hass.states.async_set("sensor.test_monitored", 17) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "on" - - hass.states.async_set("sensor.test_monitored", 18) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", "cat") - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" - - hass.states.async_set("sensor.test_monitored", 18) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" + assert state.attributes["position"] == expected_position + assert state.state == expected_state -async def test_sensor_in_range_no_hysteresis(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("from_val", "to_val", "expected_position", "expected_state"), + [ + (None, 10, "in_range", STATE_ON), # at lower threshold + (10, 20, "in_range", STATE_ON), # at upper threshold + (20, 16, "in_range", STATE_ON), + (16, 9, "below", STATE_OFF), + (9, 21, "above", STATE_OFF), + (21, "cat", "unknown", STATE_UNKNOWN), + ("cat", 21, "above", STATE_OFF), + (21, None, "unknown", STATE_UNKNOWN), + ], +) +async def test_sensor_in_range_no_hysteresis( + hass: HomeAssistant, + from_val: float | str | None, + to_val: float | str, + expected_position: str, + expected_state: str, +) -> None: """Test if source is within the range.""" config = { - "binary_sensor": { - "platform": "threshold", - "lower": "10", - "upper": "20", - "entity_id": "sensor.test_monitored", + Platform.BINARY_SENSOR: { + CONF_PLATFORM: "threshold", + CONF_LOWER: "10", + CONF_UPPER: "20", + CONF_ENTITY_ID: "sensor.test_monitored", } } - assert await async_setup_component(hass, "binary_sensor", config) + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - # Set the monitored sensor's state to the lower threshold - hass.states.async_set("sensor.test_monitored", 10) + hass.states.async_set("sensor.test_monitored", from_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" - - # Set the monitored sensor's state to the upper threshold - hass.states.async_set("sensor.test_monitored", 20) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" - - hass.states.async_set( - "sensor.test_monitored", - 16, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" + assert state.attributes["lower"] == float( + config[Platform.BINARY_SENSOR][CONF_LOWER] + ) + assert state.attributes["upper"] == float( + config[Platform.BINARY_SENSOR][CONF_UPPER] ) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["entity_id"] == "sensor.test_monitored" - assert state.attributes["sensor_value"] == 16 - assert state.attributes["position"] == "in_range" - assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) - assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) assert state.attributes["hysteresis"] == 0.0 assert state.attributes["type"] == "range" - assert state.state == "on" - hass.states.async_set("sensor.test_monitored", 9) + hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 21) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", "cat") - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" - - hass.states.async_set("sensor.test_monitored", 21) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" + assert state.attributes["position"] == expected_position + assert state.state == expected_state -async def test_sensor_in_range_with_hysteresis(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("from_val", "to_val", "expected_position", "expected_state"), + [ + (None, 12, "in_range", STATE_ON), # lower threshold + hysteresis + (12, 22, "in_range", STATE_ON), # upper threshold + hysteresis + (22, 18, "in_range", STATE_ON), # upper threshold - hysteresis + (18, 16, "in_range", STATE_ON), + (16, 8, "in_range", STATE_ON), + (8, 7, "below", STATE_OFF), + (7, 12, "below", STATE_OFF), + (12, 13, "in_range", STATE_ON), + (13, 22, "in_range", STATE_ON), + (22, 23, "above", STATE_OFF), + (23, 18, "above", STATE_OFF), + (18, 17, "in_range", STATE_ON), + (17, "cat", "unknown", STATE_UNKNOWN), + ("cat", 17, "in_range", STATE_ON), + (17, None, "unknown", STATE_UNKNOWN), + ], +) +async def test_sensor_in_range_with_hysteresis( + hass: HomeAssistant, + from_val: float | str | None, + to_val: float | str, + expected_position: str, + expected_state: str, +) -> None: """Test if source is within the range.""" config = { - "binary_sensor": { - "platform": "threshold", - "lower": "10", - "upper": "20", - "hysteresis": "2", - "entity_id": "sensor.test_monitored", + Platform.BINARY_SENSOR: { + CONF_PLATFORM: "threshold", + CONF_LOWER: "10", + CONF_UPPER: "20", + CONF_HYSTERESIS: "2", + CONF_ENTITY_ID: "sensor.test_monitored", } } - assert await async_setup_component(hass, "binary_sensor", config) + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - # Set the monitored sensor's state to the lower threshold - hysteresis - hass.states.async_set("sensor.test_monitored", 8) + hass.states.async_set("sensor.test_monitored", from_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" - - # Set the monitored sensor's state to the lower threshold + hysteresis - hass.states.async_set("sensor.test_monitored", 12) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" - - # Set the monitored sensor's state to the upper threshold + hysteresis - hass.states.async_set("sensor.test_monitored", 22) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" - - # Set the monitored sensor's state to the upper threshold - hysteresis - hass.states.async_set("sensor.test_monitored", 18) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" - - hass.states.async_set( - "sensor.test_monitored", - 16, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, + assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" + assert state.attributes["lower"] == float( + config[Platform.BINARY_SENSOR][CONF_LOWER] ) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.threshold") - - assert state.attributes["entity_id"] == "sensor.test_monitored" - assert state.attributes["sensor_value"] == 16 - assert state.attributes["position"] == "in_range" - assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) - assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) - assert state.attributes["hysteresis"] == float( - config["binary_sensor"]["hysteresis"] + assert state.attributes["upper"] == float( + config[Platform.BINARY_SENSOR][CONF_UPPER] ) + assert state.attributes["hysteresis"] == 2.0 assert state.attributes["type"] == "range" - assert state.state == "on" - hass.states.async_set("sensor.test_monitored", 8) + hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" - - hass.states.async_set("sensor.test_monitored", 7) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 12) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "below" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 13) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" - - hass.states.async_set("sensor.test_monitored", 22) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" - - hass.states.async_set("sensor.test_monitored", 23) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 18) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "above" - assert state.state == "off" - - hass.states.async_set("sensor.test_monitored", 17) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" - - hass.states.async_set("sensor.test_monitored", "cat") - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" - assert state.state == "unknown" - - hass.states.async_set("sensor.test_monitored", 17) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "in_range" - assert state.state == "on" + assert state.attributes["position"] == expected_position + assert state.state == expected_state async def test_sensor_in_range_unknown_state( @@ -478,15 +347,15 @@ async def test_sensor_in_range_unknown_state( ) -> None: """Test if source is within the range.""" config = { - "binary_sensor": { - "platform": "threshold", - "lower": "10", - "upper": "20", - "entity_id": "sensor.test_monitored", + Platform.BINARY_SENSOR: { + CONF_PLATFORM: "threshold", + CONF_LOWER: "10", + CONF_UPPER: "20", + CONF_ENTITY_ID: "sensor.test_monitored", } } - assert await async_setup_component(hass, "binary_sensor", config) + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() hass.states.async_set( @@ -498,26 +367,30 @@ async def test_sensor_in_range_unknown_state( state = hass.states.get("binary_sensor.threshold") - assert state.attributes["entity_id"] == "sensor.test_monitored" + assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" assert state.attributes["sensor_value"] == 16 assert state.attributes["position"] == "in_range" - assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) - assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) + assert state.attributes["lower"] == float( + config[Platform.BINARY_SENSOR][CONF_LOWER] + ) + assert state.attributes["upper"] == float( + config[Platform.BINARY_SENSOR][CONF_UPPER] + ) assert state.attributes["hysteresis"] == 0.0 assert state.attributes["type"] == "range" - assert state.state == "on" + assert state.state == STATE_ON hass.states.async_set("sensor.test_monitored", STATE_UNKNOWN) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["position"] == "unknown" - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert "State is not numerical" not in caplog.text @@ -525,53 +398,57 @@ async def test_sensor_in_range_unknown_state( async def test_sensor_lower_zero_threshold(hass: HomeAssistant) -> None: """Test if a lower threshold of zero is set.""" config = { - "binary_sensor": { - "platform": "threshold", - "lower": "0", - "entity_id": "sensor.test_monitored", + Platform.BINARY_SENSOR: { + CONF_PLATFORM: "threshold", + CONF_LOWER: "0", + CONF_ENTITY_ID: "sensor.test_monitored", } } - assert await async_setup_component(hass, "binary_sensor", config) + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", 16) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["type"] == "lower" - assert state.attributes["lower"] == float(config["binary_sensor"]["lower"]) - assert state.state == "off" + assert state.attributes["lower"] == float( + config[Platform.BINARY_SENSOR][CONF_LOWER] + ) + assert state.state == STATE_OFF hass.states.async_set("sensor.test_monitored", -3) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.state == "on" + assert state.state == STATE_ON async def test_sensor_upper_zero_threshold(hass: HomeAssistant) -> None: """Test if an upper threshold of zero is set.""" config = { - "binary_sensor": { - "platform": "threshold", - "upper": "0", - "entity_id": "sensor.test_monitored", + Platform.BINARY_SENSOR: { + CONF_PLATFORM: "threshold", + CONF_UPPER: "0", + CONF_ENTITY_ID: "sensor.test_monitored", } } - assert await async_setup_component(hass, "binary_sensor", config) + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() hass.states.async_set("sensor.test_monitored", -10) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes["type"] == "upper" - assert state.attributes["upper"] == float(config["binary_sensor"]["upper"]) - assert state.state == "off" + assert state.attributes["upper"] == float( + config[Platform.BINARY_SENSOR][CONF_UPPER] + ) + assert state.state == STATE_OFF hass.states.async_set("sensor.test_monitored", 2) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.state == "on" + assert state.state == STATE_ON async def test_sensor_no_lower_upper( @@ -579,13 +456,13 @@ async def test_sensor_no_lower_upper( ) -> None: """Test if no lower or upper has been provided.""" config = { - "binary_sensor": { - "platform": "threshold", - "entity_id": "sensor.test_monitored", + Platform.BINARY_SENSOR: { + CONF_PLATFORM: "threshold", + CONF_ENTITY_ID: "sensor.test_monitored", } } - await async_setup_component(hass, "binary_sensor", config) + await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() assert "Lower or Upper thresholds not provided" in caplog.text @@ -618,11 +495,11 @@ async def test_device_id( data={}, domain=DOMAIN, options={ - "entity_id": "sensor.test_source", - "hysteresis": 0.0, - "lower": -2.0, - "name": "Threshold", - "upper": None, + CONF_ENTITY_ID: "sensor.test_source", + CONF_HYSTERESIS: 0.0, + CONF_LOWER: -2.0, + CONF_NAME: "Threshold", + CONF_UPPER: None, }, title="Threshold", ) From 5852917a10763cbb6597834c1879e8a7967eaa25 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Tue, 10 Sep 2024 16:43:10 +0200 Subject: [PATCH 0486/1309] Replace Throttle in bluesound integration (#124943) * Replace Throttle with throttled and long-polling * Remove custom throttled --- .../components/bluesound/media_player.py | 251 +++++++++--------- 1 file changed, 129 insertions(+), 122 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index cd1d9510eaa..e7506ea0611 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -46,7 +46,6 @@ from homeassistant.helpers.device_registry import ( ) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle import homeassistant.util.dt as dt_util from .const import ( @@ -66,6 +65,8 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(minutes=15) + DATA_BLUESOUND = DOMAIN DEFAULT_PORT = 11000 @@ -74,9 +75,7 @@ NODE_RETRY_INITIATION = timedelta(minutes=3) SYNC_STATUS_INTERVAL = timedelta(minutes=5) -UPDATE_CAPTURE_INTERVAL = timedelta(minutes=30) -UPDATE_PRESETS_INTERVAL = timedelta(minutes=30) -UPDATE_SERVICES_INTERVAL = timedelta(minutes=30) +POLL_TIMEOUT = 120 PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( { @@ -201,7 +200,7 @@ async def async_setup_entry( ) hass.data[DATA_BLUESOUND].append(bluesound_player) - async_add_entities([bluesound_player]) + async_add_entities([bluesound_player], update_before_add=True) async def async_setup_platform( @@ -237,7 +236,8 @@ class BluesoundPlayer(MediaPlayerEntity): """Initialize the media player.""" self.host = host self.port = port - self._polling_task: Task[None] | None = None # The actual polling task. + self._poll_status_loop_task: Task[None] | None = None + self._poll_sync_status_loop_task: Task[None] | None = None self._id = sync_status.id self._last_status_update: datetime | None = None self._sync_status = sync_status @@ -273,9 +273,127 @@ class BluesoundPlayer(MediaPlayerEntity): via_device=(DOMAIN, format_mac(sync_status.mac)), ) - async def force_update_sync_status(self) -> bool: + async def _poll_status_loop(self) -> None: + """Loop which polls the status of the player.""" + while True: + try: + await self.async_update_status() + except PlayerUnreachableError: + _LOGGER.error( + "Node %s:%s is offline, retrying later", self.host, self.port + ) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) + except CancelledError: + _LOGGER.debug( + "Stopping the polling of node %s:%s", self.host, self.port + ) + return + except: # noqa: E722 - this loop should never stop + _LOGGER.exception( + "Unexpected error for %s:%s, retrying later", self.host, self.port + ) + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) + + async def _poll_sync_status_loop(self) -> None: + """Loop which polls the sync status of the player.""" + while True: + try: + await self.update_sync_status() + except PlayerUnreachableError: + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) + except CancelledError: + raise + except: # noqa: E722 - all errors must be caught for this loop + await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) + + async def async_added_to_hass(self) -> None: + """Start the polling task.""" + await super().async_added_to_hass() + + self._poll_status_loop_task = self.hass.async_create_background_task( + self._poll_status_loop(), + name=f"bluesound.poll_status_loop_{self.host}:{self.port}", + ) + self._poll_sync_status_loop_task = self.hass.async_create_background_task( + self._poll_sync_status_loop(), + name=f"bluesound.poll_sync_status_loop_{self.host}:{self.port}", + ) + + async def async_will_remove_from_hass(self) -> None: + """Stop the polling task.""" + await super().async_will_remove_from_hass() + + assert self._poll_status_loop_task is not None + if self._poll_status_loop_task.cancel(): + # the sleeps in _poll_loop will raise CancelledError + with suppress(CancelledError): + await self._poll_status_loop_task + + assert self._poll_sync_status_loop_task is not None + if self._poll_sync_status_loop_task.cancel(): + # the sleeps in _poll_sync_status_loop will raise CancelledError + with suppress(CancelledError): + await self._poll_sync_status_loop_task + + self.hass.data[DATA_BLUESOUND].remove(self) + + async def async_update(self) -> None: + """Update internal status of the entity.""" + if not self.available: + return + + with suppress(PlayerUnreachableError): + await self.async_update_presets() + await self.async_update_captures() + + async def async_update_status(self) -> None: + """Use the poll session to always get the status of the player.""" + etag = None + if self._status is not None: + etag = self._status.etag + + try: + status = await self._player.status( + etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5 + ) + + self._attr_available = True + self._last_status_update = dt_util.utcnow() + self._status = status + + group_name = status.group_name + if group_name != self._group_name: + _LOGGER.debug("Group name change detected on device: %s", self.id) + self._group_name = group_name + + # rebuild ordered list of entity_ids that are in the group, master is first + self._group_list = self.rebuild_bluesound_group() + + # the sleep is needed to make sure that the + # devices is synced + await asyncio.sleep(1) + await self.async_trigger_sync_on_all() + + self.async_write_ha_state() + except PlayerUnreachableError: + self._attr_available = False + self._last_status_update = None + self._status = None + self.async_write_ha_state() + _LOGGER.error( + "Client connection error, marking %s as offline", + self._bluesound_device_name, + ) + raise + + async def update_sync_status(self) -> None: """Update the internal status.""" - sync_status = await self._player.sync_status() + etag = None + if self._sync_status: + etag = self._sync_status.etag + sync_status = await self._player.sync_status( + etag=etag, poll_timeout=POLL_TIMEOUT, timeout=POLL_TIMEOUT + 5 + ) self._sync_status = sync_status @@ -299,107 +417,7 @@ class BluesoundPlayer(MediaPlayerEntity): slaves = self._sync_status.slaves self._is_master = slaves is not None - return True - - async def _poll_loop(self) -> None: - """Loop which polls the status of the player.""" - while True: - try: - await self.async_update_status() - except PlayerUnreachableError: - _LOGGER.error( - "Node %s:%s is offline, retrying later", self.host, self.port - ) - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - except CancelledError: - _LOGGER.debug( - "Stopping the polling of node %s:%s", self.host, self.port - ) - return - except: # noqa: E722 - this loop should never stop - _LOGGER.exception( - "Unexpected error for %s:%s, retrying later", self.host, self.port - ) - await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) - - async def async_added_to_hass(self) -> None: - """Start the polling task.""" - await super().async_added_to_hass() - - self._polling_task = self.hass.async_create_background_task( - self._poll_loop(), - name=f"bluesound.polling_{self.host}:{self.port}", - ) - - async def async_will_remove_from_hass(self) -> None: - """Stop the polling task.""" - await super().async_will_remove_from_hass() - - assert self._polling_task is not None - if self._polling_task.cancel(): - # the sleeps in _poll_loop will raise CancelledError - with suppress(CancelledError): - await self._polling_task - - self.hass.data[DATA_BLUESOUND].remove(self) - - async def async_update(self) -> None: - """Update internal status of the entity.""" - if not self.available: - return - - with suppress(PlayerUnreachableError): - await self.async_update_sync_status() - await self.async_update_presets() - await self.async_update_captures() - - async def async_update_status(self) -> None: - """Use the poll session to always get the status of the player.""" - etag = None - if self._status is not None: - etag = self._status.etag - - try: - status = await self._player.status(etag=etag, poll_timeout=120, timeout=125) - - self._attr_available = True - self._last_status_update = dt_util.utcnow() - self._status = status - - group_name = status.group_name - if group_name != self._group_name: - _LOGGER.debug("Group name change detected on device: %s", self.id) - self._group_name = group_name - - # rebuild ordered list of entity_ids that are in the group, master is first - self._group_list = self.rebuild_bluesound_group() - - # the sleep is needed to make sure that the - # devices is synced - await asyncio.sleep(1) - await self.async_trigger_sync_on_all() - elif self.is_grouped: - # when player is grouped we need to fetch volume from - # sync_status. We will force an update if the player is - # grouped this isn't a foolproof solution. A better - # solution would be to fetch sync_status more often when - # the device is playing. This would solve a lot of - # problems. This change will be done when the - # communication is moved to a separate library - with suppress(PlayerUnreachableError): - await self.force_update_sync_status() - - self.async_write_ha_state() - except PlayerUnreachableError: - self._attr_available = False - self._last_status_update = None - self._status = None - self.async_write_ha_state() - _LOGGER.error( - "Client connection error, marking %s as offline", - self._bluesound_device_name, - ) - raise + self.async_write_ha_state() async def async_trigger_sync_on_all(self) -> None: """Trigger sync status update on all devices.""" @@ -408,27 +426,16 @@ class BluesoundPlayer(MediaPlayerEntity): for player in self.hass.data[DATA_BLUESOUND]: await player.force_update_sync_status() - @Throttle(SYNC_STATUS_INTERVAL) - async def async_update_sync_status(self) -> None: - """Update sync status.""" - await self.force_update_sync_status() - - @Throttle(UPDATE_CAPTURE_INTERVAL) - async def async_update_captures(self) -> list[Input] | None: + async def async_update_captures(self) -> None: """Update Capture sources.""" inputs = await self._player.inputs() self._inputs = inputs - return inputs - - @Throttle(UPDATE_PRESETS_INTERVAL) - async def async_update_presets(self) -> list[Preset] | None: + async def async_update_presets(self) -> None: """Update Presets.""" presets = await self._player.presets() self._presets = presets - return presets - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" From ebd2034564dbf6b778a126840d9ee2df64548354 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 10 Sep 2024 16:51:32 +0200 Subject: [PATCH 0487/1309] Disable sfr_box diagnostic test (#125678) --- tests/components/sfr_box/test_diagnostics.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/sfr_box/test_diagnostics.py b/tests/components/sfr_box/test_diagnostics.py index d31d97cbcf8..26b7cf175e3 100644 --- a/tests/components/sfr_box/test_diagnostics.py +++ b/tests/components/sfr_box/test_diagnostics.py @@ -26,7 +26,8 @@ def override_platforms() -> Generator[None]: @pytest.mark.parametrize("net_infra", ["adsl", "ftth"]) -async def test_entry_diagnostics( +# Temporarily disable to unblock CI +async def _test_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry, hass_client: ClientSessionGenerator, From 2d0ccf84f9539e9dc5db7b04f7530aad548ac6de Mon Sep 17 00:00:00 2001 From: Jeef Date: Tue, 10 Sep 2024 09:04:10 -0600 Subject: [PATCH 0488/1309] Bump weatherflow4py to 0.3.3 (#125676) version bump --- homeassistant/components/weatherflow_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index aaa5bce2e16..166830717b8 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==0.2.23"] + "requirements": ["weatherflow4py==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index e79e830009c..e2df2fecc91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2938,7 +2938,7 @@ watchdog==2.3.1 waterfurnace==1.1.0 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.2.23 +weatherflow4py==0.3.3 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0a4f582225..13229e6bb11 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2327,7 +2327,7 @@ wallbox==0.7.0 watchdog==2.3.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.2.23 +weatherflow4py==0.3.3 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 From 8324360045d57ae81b2d682638280a7e17f2f9b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20D=C4=85browski?= Date: Tue, 10 Sep 2024 17:04:25 +0200 Subject: [PATCH 0489/1309] Add Roomba last mission sensor (#123048) * Roomba: add last mission sensor * Set sensor as unavailable if last mission timestamp is 0 Previously, if the `mssnStrtTm` was 0, the function would return a 1970-01-01 (Unix epoch start date). With this change, the function will return None if the timestamp is 0 and the sensor will become unavailable. * Update last_mission property to use dt_util.utc_from_timestamp --- homeassistant/components/roomba/icons.json | 3 +++ homeassistant/components/roomba/irobot_base.py | 9 +++++++++ homeassistant/components/roomba/sensor.py | 8 ++++++++ homeassistant/components/roomba/strings.json | 3 +++ 4 files changed, 23 insertions(+) diff --git a/homeassistant/components/roomba/icons.json b/homeassistant/components/roomba/icons.json index cdb36ef97e5..8466ecb51e3 100644 --- a/homeassistant/components/roomba/icons.json +++ b/homeassistant/components/roomba/icons.json @@ -32,6 +32,9 @@ }, "total_cleaned_area": { "default": "mdi:texture-box" + }, + "last_mission": { + "default": "mdi:calendar-clock" } } } diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 4850dc0b7e9..07d05a28b89 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -118,6 +118,15 @@ class IRobotEntity(Entity): """Return the battery stats.""" return self.vacuum_state.get("bbchg3", {}) + @property + def last_mission(self): + """Return last mission start time.""" + if ( + ts := self.vacuum_state.get("cleanMissionStatus", {}).get("mssnStrtTm") + ) is None or ts == 0: + return None + return dt_util.utc_from_timestamp(ts) + @property def _robot_state(self): """Return the state of the vacuum cleaner.""" diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 6e043d237f3..e0aaf5d8c6e 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -116,6 +116,14 @@ SENSORS: list[RoombaSensorEntityDescription] = [ suggested_display_precision=0, entity_registry_enabled_default=False, ), + RoombaSensorEntityDescription( + key="last_mission", + translation_key="last_mission", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.last_mission, + entity_registry_enabled_default=False, + ), ] diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index 088918824d2..0db70a6a141 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -87,6 +87,9 @@ }, "total_cleaned_area": { "name": "Total cleaned area" + }, + "last_mission": { + "name": "Last mission start time" } } } From 300445948e2e76b9df2dd8ad48e15f1b29e26033 Mon Sep 17 00:00:00 2001 From: Kristof Mattei <864376+kristof-mattei@users.noreply.github.com> Date: Tue, 10 Sep 2024 08:06:25 -0700 Subject: [PATCH 0490/1309] Fix Lyric climate Auto mode (#123490) fix: Lyric has an actual "Auto" mode that is exposed if the device has an Auto mode. --- homeassistant/components/lyric/climate.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index bd9cf4997eb..22ab8ba57d4 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -208,10 +208,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): if LYRIC_HVAC_MODE_COOL in device.allowed_modes: self._attr_hvac_modes.append(HVACMode.COOL) - if ( - LYRIC_HVAC_MODE_HEAT in device.allowed_modes - and LYRIC_HVAC_MODE_COOL in device.allowed_modes - ): + if LYRIC_HVAC_MODE_HEAT_COOL in device.allowed_modes: self._attr_hvac_modes.append(HVACMode.HEAT_COOL) # Setup supported features From a16ef5b7ffabe4607a148f1d7384d6abd2c4ab47 Mon Sep 17 00:00:00 2001 From: "Phill (pssc)" Date: Tue, 10 Sep 2024 16:17:26 +0100 Subject: [PATCH 0491/1309] Add squeezebox service sensors (#125349) * Add server sensors * Fix Platforms order * Fix spelling * Fix translations * Add sensor test * Case changes * refactor to use native_value attr override * Fix typing * Fix cast to type * add cast * use update platform for LMS versions * Fix translation * remove update entity * remove possible update entites * Fix and clarify * update to icon trans remove update plaform entitiy supporting items * add UOM to sensors * correct criptic prettier fail * reword other players * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker --- .../components/squeezebox/__init__.py | 6 +- .../components/squeezebox/coordinator.py | 13 +++ homeassistant/components/squeezebox/entity.py | 2 +- .../components/squeezebox/icons.json | 22 +++++ homeassistant/components/squeezebox/sensor.py | 98 +++++++++++++++++++ .../components/squeezebox/strings.json | 26 +++++ tests/components/squeezebox/__init__.py | 16 +++ .../squeezebox/test_binary_sensor.py | 3 +- tests/components/squeezebox/test_sensor.py | 29 ++++++ 9 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/squeezebox/sensor.py create mode 100644 tests/components/squeezebox/test_sensor.py diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index be8c92b18df..c0a5b906474 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -40,7 +40,11 @@ from .coordinator import LMSStatusDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.MEDIA_PLAYER] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.MEDIA_PLAYER, + Platform.SENSOR, +] @dataclass diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 71c55452004..0d958399bcb 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -3,15 +3,18 @@ from asyncio import timeout from datetime import timedelta import logging +import re from pysqueezebox import Server from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util from .const import ( SENSOR_UPDATE_INTERVAL, STATUS_API_TIMEOUT, + STATUS_SENSOR_LASTSCAN, STATUS_SENSOR_NEEDSRESTART, STATUS_SENSOR_RESCAN, ) @@ -32,6 +35,7 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): always_update=False, ) self.lms = lms + self.newversion_regex = re.compile("<.*$") async def _async_update_data(self) -> dict: """Fetch data fromn LMS status call. @@ -50,10 +54,19 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): def _prepare_status_data(self, data: dict) -> dict: """Sensors that need the data changing for HA presentation.""" + # Binary sensors # rescan bool are we rescanning alter poll not present if false data[STATUS_SENSOR_RESCAN] = STATUS_SENSOR_RESCAN in data # needsrestart bool pending lms plugin updates not present if false data[STATUS_SENSOR_NEEDSRESTART] = STATUS_SENSOR_NEEDSRESTART in data + # Sensors that need special handling + # 'lastscan': '1718431678', epoc -> ISO 8601 not always present + data[STATUS_SENSOR_LASTSCAN] = ( + dt_util.utc_from_timestamp(int(data[STATUS_SENSOR_LASTSCAN])) + if STATUS_SENSOR_LASTSCAN in data + else None + ) + _LOGGER.debug("Processed serverstatus %s=%s", self.lms.name, data) return data diff --git a/homeassistant/components/squeezebox/entity.py b/homeassistant/components/squeezebox/entity.py index 8ac80265369..027ca68edc6 100644 --- a/homeassistant/components/squeezebox/entity.py +++ b/homeassistant/components/squeezebox/entity.py @@ -21,7 +21,7 @@ class LMSStatusEntity(CoordinatorEntity[LMSStatusDataUpdateCoordinator]): """Initialize status sensor entity.""" super().__init__(coordinator) self.entity_description = description - self._attr_translation_key = description.key + self._attr_translation_key = description.key.replace(" ", "_") self._attr_unique_id = ( f"{coordinator.data[STATUS_QUERY_UUID]}_{description.key}" ) diff --git a/homeassistant/components/squeezebox/icons.json b/homeassistant/components/squeezebox/icons.json index b11311e1292..e86016329f5 100644 --- a/homeassistant/components/squeezebox/icons.json +++ b/homeassistant/components/squeezebox/icons.json @@ -1,4 +1,26 @@ { + "entity": { + "sensor": { + "info_total_albums": { + "default": "mdi:album" + }, + "info_total_artists": { + "default": "mdi:account-music" + }, + "info_total_genres": { + "default": "mdi:drama-masks" + }, + "info_total_songs": { + "default": "mdi:file-music" + }, + "player_count": { + "default": "mdi:folder-play" + }, + "other_player_count": { + "default": "mdi:folder-play-outline" + } + } + }, "services": { "call_method": { "service": "mdi:console" diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py new file mode 100644 index 00000000000..ff9f86ccf1f --- /dev/null +++ b/homeassistant/components/squeezebox/sensor.py @@ -0,0 +1,98 @@ +"""Platform for sensor integration for squeezebox.""" + +from __future__ import annotations + +import logging +from typing import cast + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import SqueezeboxConfigEntry +from .const import ( + STATUS_SENSOR_INFO_TOTAL_ALBUMS, + STATUS_SENSOR_INFO_TOTAL_ARTISTS, + STATUS_SENSOR_INFO_TOTAL_DURATION, + STATUS_SENSOR_INFO_TOTAL_GENRES, + STATUS_SENSOR_INFO_TOTAL_SONGS, + STATUS_SENSOR_LASTSCAN, + STATUS_SENSOR_OTHER_PLAYER_COUNT, + STATUS_SENSOR_PLAYER_COUNT, +) +from .entity import LMSStatusEntity + +SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=STATUS_SENSOR_INFO_TOTAL_ALBUMS, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="albums", + ), + SensorEntityDescription( + key=STATUS_SENSOR_INFO_TOTAL_ARTISTS, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="artists", + ), + SensorEntityDescription( + key=STATUS_SENSOR_INFO_TOTAL_DURATION, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + ), + SensorEntityDescription( + key=STATUS_SENSOR_INFO_TOTAL_GENRES, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="genres", + ), + SensorEntityDescription( + key=STATUS_SENSOR_INFO_TOTAL_SONGS, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="songs", + ), + SensorEntityDescription( + key=STATUS_SENSOR_LASTSCAN, + device_class=SensorDeviceClass.TIMESTAMP, + ), + SensorEntityDescription( + key=STATUS_SENSOR_PLAYER_COUNT, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="players", + ), + SensorEntityDescription( + key=STATUS_SENSOR_OTHER_PLAYER_COUNT, + state_class=SensorStateClass.TOTAL, + entity_registry_visible_default=False, + native_unit_of_measurement="players", + ), +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SqueezeboxConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Platform setup using common elements.""" + + async_add_entities( + ServerStatusSensor(entry.runtime_data.coordinator, description) + for description in SENSORS + ) + + +class ServerStatusSensor(LMSStatusEntity, SensorEntity): + """LMS Status based sensor from LMS via cooridnatior.""" + + @property + def native_value(self) -> StateType: + """LMS Status directly from coordinator data.""" + return cast(StateType, self.coordinator.data[self.entity_description.key]) diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 89302951146..1a120ee0567 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -84,6 +84,32 @@ "needsrestart": { "name": "Needs restart" } + }, + "sensor": { + "lastscan": { + "name": "Last scan" + }, + "info_total_albums": { + "name": "Total albums" + }, + "info_total_artists": { + "name": "Total artists" + }, + "info_total_duration": { + "name": "Total duration" + }, + "info_total_genres": { + "name": "Total genres" + }, + "info_total_songs": { + "name": "Total songs" + }, + "player_count": { + "name": "Player count" + }, + "other_player_count": { + "name": "Player count off service" + } } } } diff --git a/tests/components/squeezebox/__init__.py b/tests/components/squeezebox/__init__.py index d5faabba32e..3b7a57db459 100644 --- a/tests/components/squeezebox/__init__.py +++ b/tests/components/squeezebox/__init__.py @@ -6,6 +6,14 @@ from homeassistant.components.squeezebox.const import ( STATUS_QUERY_MAC, STATUS_QUERY_UUID, STATUS_QUERY_VERSION, + STATUS_SENSOR_INFO_TOTAL_ALBUMS, + STATUS_SENSOR_INFO_TOTAL_ARTISTS, + STATUS_SENSOR_INFO_TOTAL_DURATION, + STATUS_SENSOR_INFO_TOTAL_GENRES, + STATUS_SENSOR_INFO_TOTAL_SONGS, + STATUS_SENSOR_LASTSCAN, + STATUS_SENSOR_OTHER_PLAYER_COUNT, + STATUS_SENSOR_PLAYER_COUNT, STATUS_SENSOR_RESCAN, ) from homeassistant.const import CONF_HOST, CONF_PORT @@ -25,7 +33,15 @@ FAKE_QUERY_RESPONSE = { STATUS_QUERY_MAC: FAKE_MAC, STATUS_QUERY_VERSION: FAKE_VERSION, STATUS_SENSOR_RESCAN: 1, + STATUS_SENSOR_LASTSCAN: 0, STATUS_QUERY_LIBRARYNAME: "FakeLib", + STATUS_SENSOR_INFO_TOTAL_ALBUMS: 4, + STATUS_SENSOR_INFO_TOTAL_ARTISTS: 2, + STATUS_SENSOR_INFO_TOTAL_DURATION: 500, + STATUS_SENSOR_INFO_TOTAL_GENRES: 1, + STATUS_SENSOR_INFO_TOTAL_SONGS: 42, + STATUS_SENSOR_PLAYER_COUNT: 10, + STATUS_SENSOR_OTHER_PLAYER_COUNT: 0, "players_loop": [ { "isplaying": 0, diff --git a/tests/components/squeezebox/test_binary_sensor.py b/tests/components/squeezebox/test_binary_sensor.py index a2de0cbf95e..450d16a709c 100644 --- a/tests/components/squeezebox/test_binary_sensor.py +++ b/tests/components/squeezebox/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test squeezebox binary sensors.""" +import copy from unittest.mock import patch from homeassistant.const import Platform @@ -23,7 +24,7 @@ async def test_binary_sensor( ), patch( "homeassistant.components.squeezebox.Server.async_query", - return_value=FAKE_QUERY_RESPONSE, + return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), ), ): await setup_mocked_integration(hass) diff --git a/tests/components/squeezebox/test_sensor.py b/tests/components/squeezebox/test_sensor.py new file mode 100644 index 00000000000..b9e9802568c --- /dev/null +++ b/tests/components/squeezebox/test_sensor.py @@ -0,0 +1,29 @@ +"""Test squeezebox sensors.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import FAKE_QUERY_RESPONSE, setup_mocked_integration + + +async def test_sensor(hass: HomeAssistant) -> None: + """Test binary sensor states and attributes.""" + + # Setup component + with ( + patch( + "homeassistant.components.squeezebox.PLATFORMS", + [Platform.SENSOR], + ), + patch( + "homeassistant.components.squeezebox.Server.async_query", + return_value=FAKE_QUERY_RESPONSE, + ), + ): + await setup_mocked_integration(hass) + state = hass.states.get("sensor.fakelib_player_count") + + assert state is not None + assert state.state == "10" From 47bcb214d1d965c692083abb7426c99c403c470a Mon Sep 17 00:00:00 2001 From: Paarth Shah Date: Tue, 10 Sep 2024 08:31:45 -0700 Subject: [PATCH 0492/1309] Bump matrix-nio to 0.25.1 (#125555) --- 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 3c465c44f24..cd4e5327608 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.0", "Pillow==10.4.0"] + "requirements": ["matrix-nio==0.25.1", "Pillow==10.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e2df2fecc91..b1c7746e971 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1315,7 +1315,7 @@ lw12==0.9.2 lxml==5.1.0 # homeassistant.components.matrix -matrix-nio==0.25.0 +matrix-nio==0.25.1 # homeassistant.components.maxcube maxcube-api==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13229e6bb11..2f07f15ed4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1090,7 +1090,7 @@ lupupy==0.3.2 lxml==5.1.0 # homeassistant.components.matrix -matrix-nio==0.25.0 +matrix-nio==0.25.1 # homeassistant.components.maxcube maxcube-api==0.4.3 From dd08a6505e4e01cf02c066cb54fe0a28721ba544 Mon Sep 17 00:00:00 2001 From: Simon <80467011+sorgfresser@users.noreply.github.com> Date: Tue, 10 Sep 2024 17:42:17 +0200 Subject: [PATCH 0493/1309] Use default voice id as fallback in get_tts_audio (#123624) --- homeassistant/components/elevenlabs/tts.py | 2 +- tests/components/elevenlabs/test_tts.py | 46 ++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index e7f35775560..efc2154882a 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -137,7 +137,7 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): """Load tts audio file from the engine.""" _LOGGER.debug("Getting TTS audio for %s", message) _LOGGER.debug("Options: %s", options) - voice_id = options[ATTR_VOICE] + voice_id = options.get(ATTR_VOICE, self._default_voice_id) try: audio = await self._client.generate( text=message, diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index 9ed96117daa..f79244e3c1c 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -396,3 +396,49 @@ async def test_tts_service_speak_voice_settings( voice_settings=tts_entity._voice_settings, optimize_streaming_latency=tts_entity._latency, ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.mock_title", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_without_options( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call say with http response 200.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._client.generate.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + + tts_entity._client.generate.assert_called_once_with( + text="There is a person at the front door.", voice="voice1", model="model1" + ) From 72d546d6c23a8bca9aec91085220abca3a2b7cb7 Mon Sep 17 00:00:00 2001 From: Adam Goode Date: Tue, 10 Sep 2024 11:51:23 -0400 Subject: [PATCH 0494/1309] Move constants in Threshold (#125683) --- .../components/threshold/binary_sensor.py | 41 ++-- homeassistant/components/threshold/const.py | 28 ++- .../threshold/test_binary_sensor.py | 203 ++++++++++-------- 3 files changed, 152 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index a791658f049..9440e251586 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping import logging -from typing import Any +from typing import Any, Final import voluptuous as vol @@ -37,28 +37,29 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_HYSTERESIS, CONF_LOWER, CONF_UPPER +from .const import ( + ATTR_HYSTERESIS, + ATTR_LOWER, + ATTR_POSITION, + ATTR_SENSOR_VALUE, + ATTR_TYPE, + ATTR_UPPER, + CONF_HYSTERESIS, + CONF_LOWER, + CONF_UPPER, + DEFAULT_HYSTERESIS, + POSITION_ABOVE, + POSITION_BELOW, + POSITION_IN_RANGE, + POSITION_UNKNOWN, + TYPE_LOWER, + TYPE_RANGE, + TYPE_UPPER, +) _LOGGER = logging.getLogger(__name__) -ATTR_HYSTERESIS = "hysteresis" -ATTR_LOWER = "lower" -ATTR_POSITION = "position" -ATTR_SENSOR_VALUE = "sensor_value" -ATTR_TYPE = "type" -ATTR_UPPER = "upper" - -DEFAULT_NAME = "Threshold" -DEFAULT_HYSTERESIS = 0.0 - -POSITION_ABOVE = "above" -POSITION_BELOW = "below" -POSITION_IN_RANGE = "in_range" -POSITION_UNKNOWN = "unknown" - -TYPE_LOWER = "lower" -TYPE_RANGE = "range" -TYPE_UPPER = "upper" +DEFAULT_NAME: Final = "Threshold" PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/threshold/const.py b/homeassistant/components/threshold/const.py index 2cb9dc88f0f..7dd44a950ed 100644 --- a/homeassistant/components/threshold/const.py +++ b/homeassistant/components/threshold/const.py @@ -1,9 +1,27 @@ """Constants for the Threshold integration.""" -DOMAIN = "threshold" +from typing import Final -CONF_HYSTERESIS = "hysteresis" -CONF_LOWER = "lower" -CONF_UPPER = "upper" +DOMAIN: Final = "threshold" -DEFAULT_HYSTERESIS = 0.0 +DEFAULT_HYSTERESIS: Final = 0.0 + +ATTR_HYSTERESIS: Final = "hysteresis" +ATTR_LOWER: Final = "lower" +ATTR_POSITION: Final = "position" +ATTR_SENSOR_VALUE: Final = "sensor_value" +ATTR_TYPE: Final = "type" +ATTR_UPPER: Final = "upper" + +CONF_HYSTERESIS: Final = "hysteresis" +CONF_LOWER: Final = "lower" +CONF_UPPER: Final = "upper" + +POSITION_ABOVE: Final = "above" +POSITION_BELOW: Final = "below" +POSITION_IN_RANGE: Final = "in_range" +POSITION_UNKNOWN: Final = "unknown" + +TYPE_LOWER: Final = "lower" +TYPE_RANGE: Final = "range" +TYPE_UPPER: Final = "upper" diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index 250abdb9baa..493d6b859c7 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -3,10 +3,23 @@ import pytest from homeassistant.components.threshold.const import ( + ATTR_HYSTERESIS, + ATTR_LOWER, + ATTR_POSITION, + ATTR_SENSOR_VALUE, + ATTR_TYPE, + ATTR_UPPER, CONF_HYSTERESIS, CONF_LOWER, CONF_UPPER, DOMAIN, + POSITION_ABOVE, + POSITION_BELOW, + POSITION_IN_RANGE, + POSITION_UNKNOWN, + TYPE_LOWER, + TYPE_RANGE, + TYPE_UPPER, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -31,13 +44,13 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( ("from_val", "to_val", "expected_position", "expected_state"), [ - (None, 15, "below", STATE_OFF), # at threshold - (15, 16, "above", STATE_ON), - (16, 14, "below", STATE_OFF), - (14, 15, "below", STATE_OFF), - (15, "cat", "unknown", STATE_UNKNOWN), - ("cat", 15, "below", STATE_OFF), - (15, None, "unknown", STATE_UNKNOWN), + (None, 15, POSITION_BELOW, STATE_OFF), # at threshold + (15, 16, POSITION_ABOVE, STATE_ON), + (16, 14, POSITION_BELOW, STATE_OFF), + (14, 15, POSITION_BELOW, STATE_OFF), + (15, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), + ("cat", 15, POSITION_BELOW, STATE_OFF), + (15, None, POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_upper( @@ -63,29 +76,29 @@ async def test_sensor_upper( await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" - assert state.attributes["upper"] == float( + assert state.attributes[ATTR_UPPER] == float( config[Platform.BINARY_SENSOR][CONF_UPPER] ) - assert state.attributes["hysteresis"] == 0.0 - assert state.attributes["type"] == "upper" + assert state.attributes[ATTR_HYSTERESIS] == 0.0 + assert state.attributes[ATTR_TYPE] == TYPE_UPPER hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == expected_position + assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @pytest.mark.parametrize( ("from_val", "to_val", "expected_position", "expected_state"), [ - (None, 15, "above", STATE_OFF), # at threshold - (15, 16, "above", STATE_OFF), - (16, 14, "below", STATE_ON), - (14, 15, "below", STATE_ON), - (15, "cat", "unknown", STATE_UNKNOWN), - ("cat", 15, "above", STATE_OFF), - (15, None, "unknown", STATE_UNKNOWN), + (None, 15, POSITION_ABOVE, STATE_OFF), # at threshold + (15, 16, POSITION_ABOVE, STATE_OFF), + (16, 14, POSITION_BELOW, STATE_ON), + (14, 15, POSITION_BELOW, STATE_ON), + (15, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), + ("cat", 15, POSITION_ABOVE, STATE_OFF), + (15, None, POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_lower( @@ -111,32 +124,32 @@ async def test_sensor_lower( await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" - assert state.attributes["lower"] == float( + assert state.attributes[ATTR_LOWER] == float( config[Platform.BINARY_SENSOR][CONF_LOWER] ) - assert state.attributes["hysteresis"] == 0.0 - assert state.attributes["type"] == "lower" + assert state.attributes[ATTR_HYSTERESIS] == 0.0 + assert state.attributes[ATTR_TYPE] == TYPE_LOWER hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == expected_position + assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @pytest.mark.parametrize( ("from_val", "to_val", "expected_position", "expected_state"), [ - (None, 17.5, "below", STATE_OFF), # threshold + hysteresis - (17.5, 12.5, "below", STATE_OFF), # threshold - hysteresis - (12.5, 20, "above", STATE_ON), - (20, 13, "above", STATE_ON), - (13, 12, "below", STATE_OFF), - (12, 17, "below", STATE_OFF), - (17, 18, "above", STATE_ON), - (18, "cat", "unknown", STATE_UNKNOWN), - ("cat", 18, "above", STATE_ON), - (18, None, "unknown", STATE_UNKNOWN), + (None, 17.5, POSITION_BELOW, STATE_OFF), # threshold + hysteresis + (17.5, 12.5, POSITION_BELOW, STATE_OFF), # threshold - hysteresis + (12.5, 20, POSITION_ABOVE, STATE_ON), + (20, 13, POSITION_ABOVE, STATE_ON), + (13, 12, POSITION_BELOW, STATE_OFF), + (12, 17, POSITION_BELOW, STATE_OFF), + (17, 18, POSITION_ABOVE, STATE_ON), + (18, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), + ("cat", 18, POSITION_ABOVE, STATE_ON), + (18, None, POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_upper_hysteresis( @@ -163,32 +176,32 @@ async def test_sensor_upper_hysteresis( await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" - assert state.attributes["upper"] == float( + assert state.attributes[ATTR_UPPER] == float( config[Platform.BINARY_SENSOR][CONF_UPPER] ) - assert state.attributes["hysteresis"] == 2.5 - assert state.attributes["type"] == "upper" + assert state.attributes[ATTR_HYSTERESIS] == 2.5 + assert state.attributes[ATTR_TYPE] == TYPE_UPPER hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == expected_position + assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @pytest.mark.parametrize( ("from_val", "to_val", "expected_position", "expected_state"), [ - (None, 17.5, "above", STATE_OFF), # threshold + hysteresis - (17.5, 12.5, "above", STATE_OFF), # threshold - hysteresis - (12.5, 20, "above", STATE_OFF), - (20, 13, "above", STATE_OFF), - (13, 12, "below", STATE_ON), - (12, 17, "below", STATE_ON), - (17, 18, "above", STATE_OFF), - (18, "cat", "unknown", STATE_UNKNOWN), - ("cat", 18, "above", STATE_OFF), - (18, None, "unknown", STATE_UNKNOWN), + (None, 17.5, POSITION_ABOVE, STATE_OFF), # threshold + hysteresis + (17.5, 12.5, POSITION_ABOVE, STATE_OFF), # threshold - hysteresis + (12.5, 20, POSITION_ABOVE, STATE_OFF), + (20, 13, POSITION_ABOVE, STATE_OFF), + (13, 12, POSITION_BELOW, STATE_ON), + (12, 17, POSITION_BELOW, STATE_ON), + (17, 18, POSITION_ABOVE, STATE_OFF), + (18, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), + ("cat", 18, POSITION_ABOVE, STATE_OFF), + (18, None, POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_lower_hysteresis( @@ -215,30 +228,30 @@ async def test_sensor_lower_hysteresis( await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" - assert state.attributes["lower"] == float( + assert state.attributes[ATTR_LOWER] == float( config[Platform.BINARY_SENSOR][CONF_LOWER] ) - assert state.attributes["hysteresis"] == 2.5 - assert state.attributes["type"] == "lower" + assert state.attributes[ATTR_HYSTERESIS] == 2.5 + assert state.attributes[ATTR_TYPE] == TYPE_LOWER hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == expected_position + assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @pytest.mark.parametrize( ("from_val", "to_val", "expected_position", "expected_state"), [ - (None, 10, "in_range", STATE_ON), # at lower threshold - (10, 20, "in_range", STATE_ON), # at upper threshold - (20, 16, "in_range", STATE_ON), - (16, 9, "below", STATE_OFF), - (9, 21, "above", STATE_OFF), - (21, "cat", "unknown", STATE_UNKNOWN), - ("cat", 21, "above", STATE_OFF), - (21, None, "unknown", STATE_UNKNOWN), + (None, 10, POSITION_IN_RANGE, STATE_ON), # at lower threshold + (10, 20, POSITION_IN_RANGE, STATE_ON), # at upper threshold + (20, 16, POSITION_IN_RANGE, STATE_ON), + (16, 9, POSITION_BELOW, STATE_OFF), + (9, 21, POSITION_ABOVE, STATE_OFF), + (21, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), + ("cat", 21, POSITION_ABOVE, STATE_OFF), + (21, None, POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_in_range_no_hysteresis( @@ -265,40 +278,40 @@ async def test_sensor_in_range_no_hysteresis( await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" - assert state.attributes["lower"] == float( + assert state.attributes[ATTR_LOWER] == float( config[Platform.BINARY_SENSOR][CONF_LOWER] ) - assert state.attributes["upper"] == float( + assert state.attributes[ATTR_UPPER] == float( config[Platform.BINARY_SENSOR][CONF_UPPER] ) - assert state.attributes["hysteresis"] == 0.0 - assert state.attributes["type"] == "range" + assert state.attributes[ATTR_HYSTERESIS] == 0.0 + assert state.attributes[ATTR_TYPE] == TYPE_RANGE hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == expected_position + assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @pytest.mark.parametrize( ("from_val", "to_val", "expected_position", "expected_state"), [ - (None, 12, "in_range", STATE_ON), # lower threshold + hysteresis - (12, 22, "in_range", STATE_ON), # upper threshold + hysteresis - (22, 18, "in_range", STATE_ON), # upper threshold - hysteresis - (18, 16, "in_range", STATE_ON), - (16, 8, "in_range", STATE_ON), - (8, 7, "below", STATE_OFF), - (7, 12, "below", STATE_OFF), - (12, 13, "in_range", STATE_ON), - (13, 22, "in_range", STATE_ON), - (22, 23, "above", STATE_OFF), - (23, 18, "above", STATE_OFF), - (18, 17, "in_range", STATE_ON), - (17, "cat", "unknown", STATE_UNKNOWN), - ("cat", 17, "in_range", STATE_ON), - (17, None, "unknown", STATE_UNKNOWN), + (None, 12, POSITION_IN_RANGE, STATE_ON), # lower threshold + hysteresis + (12, 22, POSITION_IN_RANGE, STATE_ON), # upper threshold + hysteresis + (22, 18, POSITION_IN_RANGE, STATE_ON), # upper threshold - hysteresis + (18, 16, POSITION_IN_RANGE, STATE_ON), + (16, 8, POSITION_IN_RANGE, STATE_ON), + (8, 7, POSITION_BELOW, STATE_OFF), + (7, 12, POSITION_BELOW, STATE_OFF), + (12, 13, POSITION_IN_RANGE, STATE_ON), + (13, 22, POSITION_IN_RANGE, STATE_ON), + (22, 23, POSITION_ABOVE, STATE_OFF), + (23, 18, POSITION_ABOVE, STATE_OFF), + (18, 17, POSITION_IN_RANGE, STATE_ON), + (17, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), + ("cat", 17, POSITION_IN_RANGE, STATE_ON), + (17, None, POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_in_range_with_hysteresis( @@ -326,19 +339,19 @@ async def test_sensor_in_range_with_hysteresis( await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" - assert state.attributes["lower"] == float( + assert state.attributes[ATTR_LOWER] == float( config[Platform.BINARY_SENSOR][CONF_LOWER] ) - assert state.attributes["upper"] == float( + assert state.attributes[ATTR_UPPER] == float( config[Platform.BINARY_SENSOR][CONF_UPPER] ) - assert state.attributes["hysteresis"] == 2.0 - assert state.attributes["type"] == "range" + assert state.attributes[ATTR_HYSTERESIS] == 2.0 + assert state.attributes[ATTR_TYPE] == TYPE_RANGE hass.states.async_set("sensor.test_monitored", to_val) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == expected_position + assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @@ -368,28 +381,28 @@ async def test_sensor_in_range_unknown_state( state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" - assert state.attributes["sensor_value"] == 16 - assert state.attributes["position"] == "in_range" - assert state.attributes["lower"] == float( + assert state.attributes[ATTR_SENSOR_VALUE] == 16 + assert state.attributes[ATTR_POSITION] == POSITION_IN_RANGE + assert state.attributes[ATTR_LOWER] == float( config[Platform.BINARY_SENSOR][CONF_LOWER] ) - assert state.attributes["upper"] == float( + assert state.attributes[ATTR_UPPER] == float( config[Platform.BINARY_SENSOR][CONF_UPPER] ) - assert state.attributes["hysteresis"] == 0.0 - assert state.attributes["type"] == "range" + assert state.attributes[ATTR_HYSTERESIS] == 0.0 + assert state.attributes[ATTR_TYPE] == TYPE_RANGE assert state.state == STATE_ON hass.states.async_set("sensor.test_monitored", STATE_UNKNOWN) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" + assert state.attributes[ATTR_POSITION] == POSITION_UNKNOWN assert state.state == STATE_UNKNOWN hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["position"] == "unknown" + assert state.attributes[ATTR_POSITION] == POSITION_UNKNOWN assert state.state == STATE_UNKNOWN assert "State is not numerical" not in caplog.text @@ -411,8 +424,8 @@ async def test_sensor_lower_zero_threshold(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", 16) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["type"] == "lower" - assert state.attributes["lower"] == float( + assert state.attributes[ATTR_TYPE] == TYPE_LOWER + assert state.attributes[ATTR_LOWER] == float( config[Platform.BINARY_SENSOR][CONF_LOWER] ) assert state.state == STATE_OFF @@ -439,8 +452,8 @@ async def test_sensor_upper_zero_threshold(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test_monitored", -10) await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") - assert state.attributes["type"] == "upper" - assert state.attributes["upper"] == float( + assert state.attributes[ATTR_TYPE] == TYPE_UPPER + assert state.attributes[ATTR_UPPER] == float( config[Platform.BINARY_SENSOR][CONF_UPPER] ) assert state.state == STATE_OFF From 90bbe462ff432fea153310e7d1540a7a350f0553 Mon Sep 17 00:00:00 2001 From: Jeef Date: Tue, 10 Sep 2024 09:54:19 -0600 Subject: [PATCH 0495/1309] Bump weatherflow4py to 0.3.4 (#125681) removed print statemnet in backing lib --- homeassistant/components/weatherflow_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 166830717b8..8e3394e1e37 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==0.3.3"] + "requirements": ["weatherflow4py==0.3.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index b1c7746e971..13fed7ae746 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2938,7 +2938,7 @@ watchdog==2.3.1 waterfurnace==1.1.0 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.3.3 +weatherflow4py==0.3.4 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f07f15ed4a..c735d309fbd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2327,7 +2327,7 @@ wallbox==0.7.0 watchdog==2.3.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.3.3 +weatherflow4py==0.3.4 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 From 3e8fe57fc1cb4fbd99a012383ca7e95a3e5ef7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 10 Sep 2024 18:04:00 +0200 Subject: [PATCH 0496/1309] Update aioairzone to v0.9.2 (#125682) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index eb141fc83b4..872b6d4f394 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.1"] + "requirements": ["aioairzone==0.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 13fed7ae746..f5b4cc47acf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.5 # homeassistant.components.airzone -aioairzone==0.9.1 +aioairzone==0.9.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c735d309fbd..63e6b6ce4a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.5 # homeassistant.components.airzone -aioairzone==0.9.1 +aioairzone==0.9.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 457cb7ace05c87e58dc29623be2119a375a54170 Mon Sep 17 00:00:00 2001 From: Roelf Zomerman Date: Tue, 10 Sep 2024 20:10:52 +0400 Subject: [PATCH 0497/1309] Add velbus HVAC options (#106570) * Added HVAC options * Update manifest.json required aio to 2023.12.0 * Update manifest.json * Add files via upload * Update homeassistant/components/velbus/climate.py Co-authored-by: Joost Lekkerkerker * Update climate.py removed unused variables for cool and heat * Update climate.py removed unused functions * Update homeassistant/components/velbus/climate.py Co-authored-by: Erik Montnemery * Update climate.py accepted changes * Update climate.py remove state None for HVAC-MODE * Update climate.py changed set_hvac_mode to remove none and only switch when state /= requested mode * Update climate.py indent on line 94/95 * Update climate.py changed set_hvac_mode attribute type to match superclass ClimateEntity (HVACMode) * Update climate.py changed def hvac_mode to 2 return options (to avoid any) * Update climate.py ruff formatting * Update climate.py added serviceValidationError section in hvac_mode setting * Update climate.py * Update climate.py * Update climate.py * Update climate.py * Update climate.py * Update climate.py * Update climate.py * Update climate.py * Update strings.json * Update strings.json * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Erik Montnemery --- homeassistant/components/velbus/climate.py | 21 ++++++++++++++++++-- homeassistant/components/velbus/strings.json | 5 +++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py index 34a565c2b37..ed47d8b0a91 100644 --- a/homeassistant/components/velbus/climate.py +++ b/homeassistant/components/velbus/climate.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, PRESET_MODES @@ -39,8 +40,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_hvac_mode = HVACMode.HEAT - _attr_hvac_modes = [HVACMode.HEAT] + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.COOL] _attr_preset_modes = list(PRESET_MODES) _enable_turn_on_off_backwards_compatibility = False @@ -66,6 +66,11 @@ class VelbusClimate(VelbusEntity, ClimateEntity): """Return the current temperature.""" return self._channel.get_state() + @property + def hvac_mode(self) -> HVACMode: + """Return the current hvac mode based on cool_mode message.""" + return HVACMode.COOL if self._channel.get_cool_mode() else HVACMode.HEAT + @api_call async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" @@ -79,3 +84,15 @@ class VelbusClimate(VelbusEntity, ClimateEntity): """Set the new preset mode.""" await self._channel.set_preset(PRESET_MODES[preset_mode]) self.async_write_ha_state() + + @api_call + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the new hvac mode.""" + if hvac_mode not in self._attr_hvac_modes: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_hvac_mode", + translation_placeholders={"hvac_mode": str(hvac_mode)}, + ) + await self._channel.set_mode(hvac_mode) + self.async_write_ha_state() diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 948c079444d..55c7fda84ac 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -17,6 +17,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "exceptions": { + "invalid_hvac_mode": { + "message": "Climate mode {hvac_mode} is not supported." + } + }, "services": { "sync_clock": { "name": "Sync clock", From 650c92a3cfb5a6f89c4bb63bcf42ea9497c25ef5 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 10 Sep 2024 12:27:51 -0400 Subject: [PATCH 0498/1309] Add Cambridge Audio integration (#125642) * Add Cambridge Audio integration * Add zeroconf discovery to Cambridge Audio * Bump aiostreammagic to 2.0.1 * Bump aiostreammagic to 2.0.3 * Add tests to Cambridge Audio * Fix package names for Cambridge Audio * Removed unnecessary mock from Cambridge Audio tests * Clean up Cambridge Audio integration * Add additional zeroconf tests for Cambridge Audio * Update tests/components/cambridge_audio/test_config_flow.py --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../components/cambridge_audio/__init__.py | 46 +++++ .../components/cambridge_audio/config_flow.py | 93 +++++++++ .../components/cambridge_audio/const.py | 19 ++ .../components/cambridge_audio/entity.py | 26 +++ .../components/cambridge_audio/manifest.json | 12 ++ .../cambridge_audio/media_player.py | 190 +++++++++++++++++ .../components/cambridge_audio/strings.json | 26 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/cambridge_audio/__init__.py | 13 ++ tests/components/cambridge_audio/conftest.py | 55 +++++ .../cambridge_audio/fixtures/get_info.json | 32 +++ .../cambridge_audio/snapshots/test_init.ambr | 33 +++ .../cambridge_audio/test_config_flow.py | 194 ++++++++++++++++++ tests/components/cambridge_audio/test_init.py | 29 +++ 19 files changed, 793 insertions(+) create mode 100644 homeassistant/components/cambridge_audio/__init__.py create mode 100644 homeassistant/components/cambridge_audio/config_flow.py create mode 100644 homeassistant/components/cambridge_audio/const.py create mode 100644 homeassistant/components/cambridge_audio/entity.py create mode 100644 homeassistant/components/cambridge_audio/manifest.json create mode 100644 homeassistant/components/cambridge_audio/media_player.py create mode 100644 homeassistant/components/cambridge_audio/strings.json create mode 100644 tests/components/cambridge_audio/__init__.py create mode 100644 tests/components/cambridge_audio/conftest.py create mode 100644 tests/components/cambridge_audio/fixtures/get_info.json create mode 100644 tests/components/cambridge_audio/snapshots/test_init.ambr create mode 100644 tests/components/cambridge_audio/test_config_flow.py create mode 100644 tests/components/cambridge_audio/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index bd4494b8249..42a0ab8e55d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -238,6 +238,8 @@ build.json @home-assistant/supervisor /tests/components/button/ @home-assistant/core /homeassistant/components/calendar/ @home-assistant/core /tests/components/calendar/ @home-assistant/core +/homeassistant/components/cambridge_audio/ @noahhusby +/tests/components/cambridge_audio/ @noahhusby /homeassistant/components/camera/ @home-assistant/core /tests/components/camera/ @home-assistant/core /homeassistant/components/cast/ @emontnemery diff --git a/homeassistant/components/cambridge_audio/__init__.py b/homeassistant/components/cambridge_audio/__init__.py new file mode 100644 index 00000000000..344045fe550 --- /dev/null +++ b/homeassistant/components/cambridge_audio/__init__.py @@ -0,0 +1,46 @@ +"""The Cambridge Audio integration.""" + +from __future__ import annotations + +import asyncio + +from aiostreammagic import StreamMagicClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONNECT_TIMEOUT, STREAM_MAGIC_EXCEPTIONS + +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] + +type CambridgeAudioConfigEntry = ConfigEntry[StreamMagicClient] + + +async def async_setup_entry( + hass: HomeAssistant, entry: CambridgeAudioConfigEntry +) -> bool: + """Set up Cambridge Audio integration from a config entry.""" + + client = StreamMagicClient(entry.data[CONF_HOST]) + + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + await client.connect() + except STREAM_MAGIC_EXCEPTIONS as err: + raise ConfigEntryNotReady(f"Error while connecting to {client.host}") from err + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: CambridgeAudioConfigEntry +) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.disconnect() + return unload_ok diff --git a/homeassistant/components/cambridge_audio/config_flow.py b/homeassistant/components/cambridge_audio/config_flow.py new file mode 100644 index 00000000000..201e531608d --- /dev/null +++ b/homeassistant/components/cambridge_audio/config_flow.py @@ -0,0 +1,93 @@ +"""Config flow for Cambridge Audio.""" + +import asyncio +from typing import Any + +from aiostreammagic import StreamMagicClient +import voluptuous as vol + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME + +from .const import CONNECT_TIMEOUT, DOMAIN, STREAM_MAGIC_EXCEPTIONS + + +class CambridgeAudioConfigFlow(ConfigFlow, domain=DOMAIN): + """Cambridge Audio configuration flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self.data[CONF_HOST] = host = discovery_info.host + + await self.async_set_unique_id(discovery_info.properties["serial"]) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + client = StreamMagicClient(host) + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + await client.connect() + except STREAM_MAGIC_EXCEPTIONS: + return self.async_abort(reason="cannot_connect") + + self.data[CONF_NAME] = client.info.name + + self.context["title_placeholders"] = { + "name": self.data[CONF_NAME], + } + await client.disconnect() + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.data[CONF_NAME], + data={CONF_HOST: self.data[CONF_HOST]}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "name": self.data[CONF_NAME], + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input: + client = StreamMagicClient(user_input[CONF_HOST]) + try: + async with asyncio.timeout(CONNECT_TIMEOUT): + await client.connect() + except STREAM_MAGIC_EXCEPTIONS: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id( + client.info.unit_id, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=client.info.name, + data={CONF_HOST: user_input[CONF_HOST]}, + ) + finally: + await client.disconnect() + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) diff --git a/homeassistant/components/cambridge_audio/const.py b/homeassistant/components/cambridge_audio/const.py new file mode 100644 index 00000000000..5a4e5a1f2e0 --- /dev/null +++ b/homeassistant/components/cambridge_audio/const.py @@ -0,0 +1,19 @@ +"""Constants for the Cambridge Audio integration.""" + +import asyncio +import logging + +from aiostreammagic import StreamMagicConnectionError, StreamMagicError + +DOMAIN = "cambridge_audio" + +LOGGER = logging.getLogger(__package__) + +STREAM_MAGIC_EXCEPTIONS = ( + StreamMagicConnectionError, + StreamMagicError, + asyncio.CancelledError, + TimeoutError, +) + +CONNECT_TIMEOUT = 5 diff --git a/homeassistant/components/cambridge_audio/entity.py b/homeassistant/components/cambridge_audio/entity.py new file mode 100644 index 00000000000..5ea9c7ab685 --- /dev/null +++ b/homeassistant/components/cambridge_audio/entity.py @@ -0,0 +1,26 @@ +"""Base class for Cambridge Audio entities.""" + +from aiostreammagic import StreamMagicClient + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class CambridgeAudioEntity(Entity): + """Defines a base Cambridge Audio entity.""" + + _attr_has_entity_name = True + + def __init__(self, client: StreamMagicClient) -> None: + """Initialize Cambridge Audio entity.""" + self.client = client + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, client.info.unit_id)}, + name=client.info.name, + manufacturer="Cambridge Audio", + model=client.info.model, + serial_number=client.info.unit_id, + configuration_url=f"http://{client.host}", + ) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json new file mode 100644 index 00000000000..71c5368b631 --- /dev/null +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "cambridge_audio", + "name": "Cambridge Audio", + "codeowners": ["@noahhusby"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/cambridge_audio", + "integration_type": "device", + "iot_class": "local_push", + "loggers": ["aiostreammagic"], + "requirements": ["aiostreammagic==2.0.3"], + "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] +} diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py new file mode 100644 index 00000000000..a60c5420cd8 --- /dev/null +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -0,0 +1,190 @@ +"""Support for Cambridge Audio AV Receiver.""" + +from __future__ import annotations + +from datetime import datetime + +from aiostreammagic import StreamMagicClient + +from homeassistant.components.media_player import ( + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import CambridgeAudioEntity + +BASE_FEATURES = ( + MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.TURN_ON +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Cambridge Audio device based on a config entry.""" + client: StreamMagicClient = entry.runtime_data + async_add_entities([CambridgeAudioDevice(client)]) + + +class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): + """Representation of a Cambridge Audio Media Player Device.""" + + _attr_name = None + _attr_media_content_type = MediaType.MUSIC + + def __init__(self, client: StreamMagicClient) -> None: + """Initialize an Cambridge Audio entity.""" + super().__init__(client) + self._attr_unique_id = client.info.unit_id + + async def _state_update_callback(self, _client: StreamMagicClient) -> None: + """Call when the device is notified of changes.""" + self.schedule_update_ha_state() + + async def async_added_to_hass(self) -> None: + """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.""" + await self.client.unregister_state_update_callbacks(self._state_update_callback) + + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Supported features for the media player.""" + controls = self.client.now_playing.controls + features = BASE_FEATURES + if "play_pause" in controls: + features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE + if "play" in controls: + features |= MediaPlayerEntityFeature.PLAY + if "pause" in controls: + features |= MediaPlayerEntityFeature.PAUSE + if "track_next" in controls: + features |= MediaPlayerEntityFeature.NEXT_TRACK + if "track_previous" in controls: + features |= MediaPlayerEntityFeature.PREVIOUS_TRACK + return features + + @property + def state(self) -> MediaPlayerState: + """Return the state of the device.""" + media_state = self.client.play_state.state + if media_state == "NETWORK": + return MediaPlayerState.STANDBY + if self.client.state.power: + if media_state == "play": + return MediaPlayerState.PLAYING + if media_state == "pause": + return MediaPlayerState.PAUSED + if media_state == "connecting": + return MediaPlayerState.BUFFERING + if media_state in ("stop", "ready"): + return MediaPlayerState.IDLE + return MediaPlayerState.ON + return MediaPlayerState.OFF + + @property + def source_list(self) -> list[str]: + """Return a list of available input sources.""" + return [item.name for item in self.client.sources] + + @property + def source(self) -> str | None: + """Return the current input source.""" + return next( + ( + item.name + for item in self.client.sources + if item.id == self.client.state.source + ), + None, + ) + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + return self.client.play_state.metadata.title + + @property + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + return self.client.play_state.metadata.artist + + @property + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + return self.client.play_state.metadata.album + + @property + def media_image_url(self) -> str | None: + """Image url of current playing media.""" + return self.client.play_state.metadata.art_url + + @property + def media_duration(self) -> int | None: + """Duration of the current media.""" + return self.client.play_state.metadata.duration + + @property + def media_position(self) -> int | None: + """Position of the current media.""" + return self.client.play_state.position + + @property + def media_position_updated_at(self) -> datetime: + """Last time the media position was updated.""" + return self.client.position_last_updated + + async def async_media_play_pause(self) -> None: + """Toggle play/pause the current media.""" + await self.client.play_pause() + + async def async_media_pause(self) -> None: + """Pause the current media.""" + controls = self.client.now_playing.controls + if "pause" not in controls and "play_pause" in controls: + await self.client.play_pause() + else: + await self.client.pause() + + async def async_media_stop(self) -> None: + """Stop the current media.""" + await self.client.stop() + + async def async_media_play(self) -> None: + """Play the current media.""" + if self.state == MediaPlayerState.PAUSED: + await self.client.play_pause() + + async def async_media_next_track(self) -> None: + """Skip to the next track.""" + await self.client.next_track() + + async def async_media_previous_track(self) -> None: + """Skip to the previous track.""" + await self.client.previous_track() + + async def async_select_source(self, source: str) -> None: + """Select the source.""" + for src in self.client.sources: + if src.name == source: + await self.client.set_source_by_id(src.id) + break + + async def async_turn_on(self) -> None: + """Power on the device.""" + await self.client.power_on() + + async def async_turn_off(self) -> None: + """Power off the device.""" + await self.client.power_off() diff --git a/homeassistant/components/cambridge_audio/strings.json b/homeassistant/components/cambridge_audio/strings.json new file mode 100644 index 00000000000..fa27dc452de --- /dev/null +++ b/homeassistant/components/cambridge_audio/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "Set up your Cambridge Audio Streamer to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Cambridge Audio Streamer." + } + }, + "discovery_confirm": { + "description": "Do you want to setup {name}?" + } + }, + "error": { + "cannot_connect": "Failed to connect to Cambridge Audio device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect." + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2e38d608bd9..2d9d8861155 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -100,6 +100,7 @@ FLOWS = { "bthome", "buienradar", "caldav", + "cambridge_audio", "canary", "cast", "ccm15", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cd37adc3f71..ae77dfdd04e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -849,6 +849,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "cambridge_audio": { + "name": "Cambridge Audio", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "canary": { "name": "Canary", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 2e3ffa23ff5..f627f1f0f47 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -764,6 +764,11 @@ ZEROCONF = { "name": "slzb-06*", }, ], + "_smoip._tcp.local.": [ + { + "domain": "cambridge_audio", + }, + ], "_sonos._tcp.local.": [ { "domain": "sonos", @@ -793,6 +798,11 @@ ZEROCONF = { "name": "smappee50*", }, ], + "_stream-magic._tcp.local.": [ + { + "domain": "cambridge_audio", + }, + ], "_system-bridge._tcp.local.": [ { "domain": "system_bridge", diff --git a/requirements_all.txt b/requirements_all.txt index f5b4cc47acf..86e7e087678 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -373,6 +373,9 @@ aiosolaredge==0.2.0 # homeassistant.components.steamist aiosteamist==1.0.0 +# homeassistant.components.cambridge_audio +aiostreammagic==2.0.3 + # homeassistant.components.switcher_kis aioswitcher==4.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63e6b6ce4a2..f58cac3f00a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -355,6 +355,9 @@ aiosolaredge==0.2.0 # homeassistant.components.steamist aiosteamist==1.0.0 +# homeassistant.components.cambridge_audio +aiostreammagic==2.0.3 + # homeassistant.components.switcher_kis aioswitcher==4.0.3 diff --git a/tests/components/cambridge_audio/__init__.py b/tests/components/cambridge_audio/__init__.py new file mode 100644 index 00000000000..f6b5f48d39d --- /dev/null +++ b/tests/components/cambridge_audio/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Cambridge Audio integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/cambridge_audio/conftest.py b/tests/components/cambridge_audio/conftest.py new file mode 100644 index 00000000000..931c0f30af1 --- /dev/null +++ b/tests/components/cambridge_audio/conftest.py @@ -0,0 +1,55 @@ +"""Cambridge Audio tests configuration.""" + +from collections.abc import Generator +from unittest.mock import Mock, patch + +from aiostreammagic.models import Info +import pytest + +from homeassistant.components.cambridge_audio.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry, load_fixture +from tests.components.smhi.common import AsyncMock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.cambridge_audio.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_stream_magic_client() -> Generator[AsyncMock]: + """Mock an Cambridge Audio client.""" + with ( + patch( + "homeassistant.components.cambridge_audio.StreamMagicClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.cambridge_audio.config_flow.StreamMagicClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.host = "192.168.20.218" + client.info = Info.from_json(load_fixture("get_info.json", DOMAIN)) + client.is_connected = Mock(return_value=True) + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Cambridge Audio CXNv2", + data={CONF_HOST: "192.168.20.218"}, + unique_id="0020c2d8", + ) diff --git a/tests/components/cambridge_audio/fixtures/get_info.json b/tests/components/cambridge_audio/fixtures/get_info.json new file mode 100644 index 00000000000..ee88995412e --- /dev/null +++ b/tests/components/cambridge_audio/fixtures/get_info.json @@ -0,0 +1,32 @@ +{ + "name": "Cambridge Audio CXNv2", + "timezone": "America/Chicago", + "locale": "en_GB", + "usage_reports": true, + "setup": true, + "sources_setup": true, + "versions": [ + { + "component": "cast", + "version": "1.52.272222" + }, + { + "component": "MCU", + "version": "3.1+0.5+36" + }, + { + "component": "service-pack", + "version": "v022-a-151+a" + }, + { + "component": "application", + "version": "1.0+gitAUTOINC+a94a3e2ad8" + } + ], + "udn": "02680b5c-1320-4d54-9f7c-3cfe915ad4c3", + "hcv": 3764, + "model": "CXNv2", + "unit_id": "0020c2d8", + "max_http_body_size": 65536, + "api": "1.8" +} diff --git a/tests/components/cambridge_audio/snapshots/test_init.ambr b/tests/components/cambridge_audio/snapshots/test_init.ambr new file mode 100644 index 00000000000..64182ee2188 --- /dev/null +++ b/tests/components/cambridge_audio/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.168.20.218', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'cambridge_audio', + '0020c2d8', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Cambridge Audio', + 'model': 'CXNv2', + 'model_id': None, + 'name': 'Cambridge Audio CXNv2', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '0020c2d8', + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- \ No newline at end of file diff --git a/tests/components/cambridge_audio/test_config_flow.py b/tests/components/cambridge_audio/test_config_flow.py new file mode 100644 index 00000000000..9a2d077b8f8 --- /dev/null +++ b/tests/components/cambridge_audio/test_config_flow.py @@ -0,0 +1,194 @@ +"""Tests for the Cambridge Audio config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from aiostreammagic import StreamMagicError + +from homeassistant.components.cambridge_audio.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("192.168.20.218"), + ip_addresses=[ip_address("192.168.20.218")], + hostname="cambridge_CXNv2.local.", + name="cambridge_CXNv2._stream-magic._tcp.local.", + port=80, + type="_stream-magic._tcp.local.", + properties={ + "serial": "0020c2d8", + "hcv": "3764", + "software": "v022-a-151+a", + "model": "CXNv2", + "udn": "02680b5c-1320-4d54-9f7c-3cfe915ad4c3", + }, +) + + +async def test_full_flow( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + 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_HOST: "192.168.20.218"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Cambridge Audio CXNv2" + assert result["data"] == { + CONF_HOST: "192.168.20.218", + } + assert result["result"].unique_id == "0020c2d8" + + +async def test_flow_errors( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow errors.""" + mock_stream_magic_client.connect.side_effect = StreamMagicError + + 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_HOST: "192.168.20.218"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_stream_magic_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.20.218"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + 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_HOST: "192.168.20.218"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Cambridge Audio CXNv2" + assert result["data"] == { + CONF_HOST: "192.168.20.218", + } + assert result["result"].unique_id == "0020c2d8" + + +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf flow.""" + mock_stream_magic_client.connect.side_effect = StreamMagicError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + mock_stream_magic_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Cambridge Audio CXNv2" + assert result["data"] == { + CONF_HOST: "192.168.20.218", + } + assert result["result"].unique_id == "0020c2d8" + + +async def test_zeroconf_duplicate( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/cambridge_audio/test_init.py b/tests/components/cambridge_audio/test_init.py new file mode 100644 index 00000000000..7dea193d9fd --- /dev/null +++ b/tests/components/cambridge_audio/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Cambridge Audio integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.cambridge_audio.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> 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 == snapshot From bde92b34dd439cfcbf08108ae70d46fdb3ef82e3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 10 Sep 2024 19:26:19 +0200 Subject: [PATCH 0499/1309] Remove recorder history queries for database schemas < 31 (#125652) --- .../components/recorder/history/common.py | 11 - .../components/recorder/history/legacy.py | 332 ++---- .../components/recorder/models/__init__.py | 2 - .../components/recorder/models/legacy.py | 161 +-- .../components/recorder/models/time.py | 11 - .../history/test_init_db_schema_30.py | 1007 ----------------- .../recorder/test_history_db_schema_30.py | 713 ------------ tests/components/recorder/test_models.py | 75 -- 8 files changed, 88 insertions(+), 2224 deletions(-) delete mode 100644 homeassistant/components/recorder/history/common.py delete mode 100644 tests/components/history/test_init_db_schema_30.py delete mode 100644 tests/components/recorder/test_history_db_schema_30.py diff --git a/homeassistant/components/recorder/history/common.py b/homeassistant/components/recorder/history/common.py deleted file mode 100644 index 3427ee9d7ee..00000000000 --- a/homeassistant/components/recorder/history/common.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Common functions for history.""" - -from __future__ import annotations - -from homeassistant.core import HomeAssistant - -from ... import recorder - - -def _schema_version(hass: HomeAssistant) -> int: - return recorder.get_instance(hass).schema_version diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 2aa279778b3..2b84309f0b9 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -24,19 +24,9 @@ import homeassistant.util.dt as dt_util from ... import recorder from ..db_schema import RecorderRuns, StateAttributes, States from ..filters import Filters -from ..models import ( - process_datetime_to_timestamp, - process_timestamp, - process_timestamp_to_utc_isoformat, -) -from ..models.legacy import ( - LegacyLazyState, - LegacyLazyStatePreSchema31, - legacy_row_to_compressed_state, - legacy_row_to_compressed_state_pre_schema_31, -) +from ..models import process_timestamp, process_timestamp_to_utc_isoformat +from ..models.legacy import LegacyLazyState, legacy_row_to_compressed_state from ..util import execute_stmt_lambda_element, session_scope -from .common import _schema_version from .const import ( LAST_CHANGED_KEY, NEED_ATTRIBUTE_DOMAINS, @@ -137,7 +127,7 @@ _FIELD_MAP_PRE_SCHEMA_31 = { def _lambda_stmt_and_join_attributes( - schema_version: int, no_attributes: bool, include_last_changed: bool = True + no_attributes: bool, include_last_changed: bool = True ) -> tuple[StatementLambdaElement, bool]: """Return the lambda_stmt and if StateAttributes should be joined. @@ -148,41 +138,19 @@ def _lambda_stmt_and_join_attributes( # without the attributes fields and do not join the # state_attributes table if no_attributes: - if schema_version >= 31: - if include_last_changed: - return ( - lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)), - False, - ) - return ( - lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), - False, - ) if include_last_changed: return ( - lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_PRE_SCHEMA_31)), + lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR)), False, ) return ( - lambda_stmt( - lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED_PRE_SCHEMA_31) - ), + lambda_stmt(lambda: select(*_QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), False, ) - if schema_version >= 31: - if include_last_changed: - return lambda_stmt(lambda: select(*_QUERY_STATES)), True - return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)), True - # Finally if no migration is in progress and no_attributes - # was not requested, we query both attributes columns and - # join state_attributes if include_last_changed: - return lambda_stmt(lambda: select(*_QUERY_STATES_PRE_SCHEMA_31)), True - return ( - lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED_PRE_SCHEMA_31)), - True, - ) + return lambda_stmt(lambda: select(*_QUERY_STATES)), True + return lambda_stmt(lambda: select(*_QUERY_STATES_NO_LAST_CHANGED)), True def get_significant_states( @@ -215,7 +183,6 @@ def get_significant_states( def _significant_states_stmt( - schema_version: int, start_time: datetime, end_time: datetime | None, entity_ids: list[str], @@ -224,71 +191,43 @@ def _significant_states_stmt( ) -> StatementLambdaElement: """Query the database for significant state changes.""" stmt, join_attributes = _lambda_stmt_and_join_attributes( - schema_version, no_attributes, include_last_changed=not significant_changes_only + no_attributes, include_last_changed=not significant_changes_only ) if ( len(entity_ids) == 1 and significant_changes_only and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS ): - if schema_version >= 31: - stmt += lambda q: q.filter( - (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) - ) - else: - stmt += lambda q: q.filter( - (States.last_changed == States.last_updated) - | States.last_changed.is_(None) - ) + stmt += lambda q: q.filter( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ) elif significant_changes_only: - if schema_version >= 31: - stmt += lambda q: q.filter( - or_( - *[ - States.entity_id.like(entity_domain) - for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE - ], - ( - (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) - ), - ) - ) - else: - stmt += lambda q: q.filter( - or_( - *[ - States.entity_id.like(entity_domain) - for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE - ], - ( - (States.last_changed == States.last_updated) - | States.last_changed.is_(None) - ), - ) + stmt += lambda q: q.filter( + or_( + *[ + States.entity_id.like(entity_domain) + for entity_domain in SIGNIFICANT_DOMAINS_ENTITY_ID_LIKE + ], + ( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) + ), ) + ) stmt += lambda q: q.filter(States.entity_id.in_(entity_ids)) - if schema_version >= 31: - start_time_ts = start_time.timestamp() - stmt += lambda q: q.filter(States.last_updated_ts > start_time_ts) - if end_time: - end_time_ts = end_time.timestamp() - stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) - else: - stmt += lambda q: q.filter(States.last_updated > start_time) - if end_time: - stmt += lambda q: q.filter(States.last_updated < end_time) + start_time_ts = start_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts > start_time_ts) + if end_time: + end_time_ts = end_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - if schema_version >= 31: - stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts) - else: - stmt += lambda q: q.order_by(States.entity_id, States.last_updated) + stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts) return stmt @@ -321,7 +260,6 @@ def get_significant_states_with_session( if not entity_ids: raise ValueError("entity_ids must be provided") stmt = _significant_states_stmt( - _schema_version(hass), start_time, end_time, entity_ids, @@ -376,7 +314,6 @@ def get_full_significant_states_with_session( def _state_changed_during_period_stmt( - schema_version: int, start_time: datetime, end_time: datetime | None, entity_id: str, @@ -385,47 +322,28 @@ def _state_changed_during_period_stmt( limit: int | None, ) -> StatementLambdaElement: stmt, join_attributes = _lambda_stmt_and_join_attributes( - schema_version, no_attributes, include_last_changed=False + no_attributes, include_last_changed=False ) - if schema_version >= 31: - start_time_ts = start_time.timestamp() - stmt += lambda q: q.filter( - ( - (States.last_changed_ts == States.last_updated_ts) - | States.last_changed_ts.is_(None) - ) - & (States.last_updated_ts > start_time_ts) - ) - else: - stmt += lambda q: q.filter( - ( - (States.last_changed == States.last_updated) - | States.last_changed.is_(None) - ) - & (States.last_updated > start_time) + start_time_ts = start_time.timestamp() + stmt += lambda q: q.filter( + ( + (States.last_changed_ts == States.last_updated_ts) + | States.last_changed_ts.is_(None) ) + & (States.last_updated_ts > start_time_ts) + ) if end_time: - if schema_version >= 31: - end_time_ts = end_time.timestamp() - stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) - else: - stmt += lambda q: q.filter(States.last_updated < end_time) + end_time_ts = end_time.timestamp() + stmt += lambda q: q.filter(States.last_updated_ts < end_time_ts) stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) if descending: - if schema_version >= 31: - stmt += lambda q: q.order_by( - States.entity_id, States.last_updated_ts.desc() - ) - else: - stmt += lambda q: q.order_by(States.entity_id, States.last_updated.desc()) - elif schema_version >= 31: - stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts) + stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts.desc()) else: - stmt += lambda q: q.order_by(States.entity_id, States.last_updated) + stmt += lambda q: q.order_by(States.entity_id, States.last_updated_ts) if limit: stmt += lambda q: q.limit(limit) @@ -448,7 +366,6 @@ def state_changes_during_period( entity_ids = [entity_id.lower()] with session_scope(hass=hass, read_only=True) as session: stmt = _state_changed_during_period_stmt( - _schema_version(hass), start_time, end_time, entity_id, @@ -471,33 +388,21 @@ def state_changes_during_period( def _get_last_state_changes_stmt( - schema_version: int, number_of_states: int, entity_id: str + number_of_states: int, entity_id: str ) -> StatementLambdaElement: stmt, join_attributes = _lambda_stmt_and_join_attributes( - schema_version, False, include_last_changed=False + False, include_last_changed=False + ) + stmt += lambda q: q.where( + States.state_id + == ( + select(States.state_id) + .filter(States.entity_id == entity_id) + .order_by(States.last_updated_ts.desc()) + .limit(number_of_states) + .subquery() + ).c.state_id ) - if schema_version >= 31: - stmt += lambda q: q.where( - States.state_id - == ( - select(States.state_id) - .filter(States.entity_id == entity_id) - .order_by(States.last_updated_ts.desc()) - .limit(number_of_states) - .subquery() - ).c.state_id - ) - else: - stmt += lambda q: q.where( - States.state_id - == ( - select(States.state_id) - .filter(States.entity_id == entity_id) - .order_by(States.last_updated.desc()) - .limit(number_of_states) - .subquery() - ).c.state_id - ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id @@ -515,9 +420,7 @@ def get_last_state_changes( entity_ids = [entity_id_lower] with session_scope(hass=hass, read_only=True) as session: - stmt = _get_last_state_changes_stmt( - _schema_version(hass), number_of_states, entity_id_lower - ) + stmt = _get_last_state_changes_stmt(number_of_states, entity_id_lower) states = list(execute_stmt_lambda_element(session, stmt)) return cast( dict[str, list[State]], @@ -533,7 +436,6 @@ def get_last_state_changes( def _get_states_for_entities_stmt( - schema_version: int, run_start: datetime, utc_point_in_time: datetime, entity_ids: list[str], @@ -541,58 +443,34 @@ def _get_states_for_entities_stmt( ) -> StatementLambdaElement: """Baked query to get states for specific entities.""" stmt, join_attributes = _lambda_stmt_and_join_attributes( - schema_version, no_attributes, include_last_changed=True + no_attributes, include_last_changed=True ) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. - if schema_version >= 31: - run_start_ts = process_timestamp(run_start).timestamp() - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += lambda q: q.join( - ( - most_recent_states_for_entities_by_date := ( - select( - States.entity_id.label("max_entity_id"), - func.max(States.last_updated_ts).label("max_last_updated"), - ) - .filter( - (States.last_updated_ts >= run_start_ts) - & (States.last_updated_ts < utc_point_in_time_ts) - ) - .filter(States.entity_id.in_(entity_ids)) - .group_by(States.entity_id) - .subquery() - ) - ), - and_( - States.entity_id - == most_recent_states_for_entities_by_date.c.max_entity_id, - States.last_updated_ts - == most_recent_states_for_entities_by_date.c.max_last_updated, - ), - ) - else: - stmt += lambda q: q.join( - ( - most_recent_states_for_entities_by_date := select( + run_start_ts = process_timestamp(run_start).timestamp() + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += lambda q: q.join( + ( + most_recent_states_for_entities_by_date := ( + select( States.entity_id.label("max_entity_id"), - func.max(States.last_updated).label("max_last_updated"), + func.max(States.last_updated_ts).label("max_last_updated"), ) .filter( - (States.last_updated >= run_start) - & (States.last_updated < utc_point_in_time) + (States.last_updated_ts >= run_start_ts) + & (States.last_updated_ts < utc_point_in_time_ts) ) .filter(States.entity_id.in_(entity_ids)) .group_by(States.entity_id) .subquery() - ), - and_( - States.entity_id - == most_recent_states_for_entities_by_date.c.max_entity_id, - States.last_updated - == most_recent_states_for_entities_by_date.c.max_last_updated, - ), - ) + ) + ), + and_( + States.entity_id == most_recent_states_for_entities_by_date.c.max_entity_id, + States.last_updated_ts + == most_recent_states_for_entities_by_date.c.max_last_updated, + ), + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) @@ -609,12 +487,11 @@ def _get_rows_with_session( no_attributes: bool = False, ) -> Iterable[Row]: """Return the states at a specific point in time.""" - schema_version = _schema_version(hass) if len(entity_ids) == 1: return execute_stmt_lambda_element( session, _get_single_entity_states_stmt( - schema_version, utc_point_in_time, entity_ids[0], no_attributes + utc_point_in_time, entity_ids[0], no_attributes ), ) @@ -628,13 +505,12 @@ def _get_rows_with_session( # We have more than one entity to look at so we need to do a query on states # since the last recorder run started. stmt = _get_states_for_entities_stmt( - schema_version, run.start, utc_point_in_time, entity_ids, no_attributes + run.start, utc_point_in_time, entity_ids, no_attributes ) return execute_stmt_lambda_element(session, stmt) def _get_single_entity_states_stmt( - schema_version: int, utc_point_in_time: datetime, entity_id: str, no_attributes: bool = False, @@ -642,27 +518,17 @@ def _get_single_entity_states_stmt( # Use an entirely different (and extremely fast) query if we only # have a single entity id stmt, join_attributes = _lambda_stmt_and_join_attributes( - schema_version, no_attributes, include_last_changed=True + no_attributes, include_last_changed=True ) - if schema_version >= 31: - utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) - stmt += ( - lambda q: q.filter( - States.last_updated_ts < utc_point_in_time_ts, - States.entity_id == entity_id, - ) - .order_by(States.last_updated_ts.desc()) - .limit(1) - ) - else: - stmt += ( - lambda q: q.filter( - States.last_updated < utc_point_in_time, - States.entity_id == entity_id, - ) - .order_by(States.last_updated.desc()) - .limit(1) + utc_point_in_time_ts = dt_util.utc_to_timestamp(utc_point_in_time) + stmt += ( + lambda q: q.filter( + States.last_updated_ts < utc_point_in_time_ts, + States.entity_id == entity_id, ) + .order_by(States.last_updated_ts.desc()) + .limit(1) + ) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id @@ -692,26 +558,15 @@ def _sorted_states_to_dict( each list of states, otherwise our graphs won't start on the Y axis correctly. """ - schema_version = _schema_version(hass) - _process_timestamp: Callable[[datetime], float | str] - field_map = _FIELD_MAP if schema_version >= 31 else _FIELD_MAP_PRE_SCHEMA_31 state_class: Callable[ [Row, dict[str, dict[str, Any]], datetime | None], State | dict[str, Any] ] if compressed_state_format: - if schema_version >= 31: - state_class = legacy_row_to_compressed_state - else: - state_class = legacy_row_to_compressed_state_pre_schema_31 - _process_timestamp = process_datetime_to_timestamp + state_class = legacy_row_to_compressed_state attr_time = COMPRESSED_STATE_LAST_UPDATED attr_state = COMPRESSED_STATE_STATE else: - if schema_version >= 31: - state_class = LegacyLazyState - else: - state_class = LegacyLazyStatePreSchema31 - _process_timestamp = process_timestamp_to_utc_isoformat + state_class = LegacyLazyState attr_time = LAST_CHANGED_KEY attr_state = STATE_KEY @@ -768,7 +623,7 @@ def _sorted_states_to_dict( prev_state = first_state.state ent_results.append(state_class(first_state, attr_cache, None)) - state_idx = field_map["state"] + state_idx = _FIELD_MAP["state"] # # minimal_response only makes sense with last_updated == last_updated @@ -777,20 +632,7 @@ def _sorted_states_to_dict( # # With minimal response we do not care about attribute # changes so we can filter out duplicate states - if schema_version < 31: - last_updated_idx = field_map["last_updated"] - for row in group: - if (state := row[state_idx]) != prev_state: - ent_results.append( - { - attr_state: state, - attr_time: _process_timestamp(row[last_updated_idx]), - } - ) - prev_state = state - continue - - last_updated_ts_idx = field_map["last_updated_ts"] + last_updated_ts_idx = _FIELD_MAP["last_updated_ts"] if compressed_state_format: for row in group: if (state := row[state_idx]) != prev_state: diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py index d43a1da161e..ea7a6c86854 100644 --- a/homeassistant/components/recorder/models/__init__.py +++ b/homeassistant/components/recorder/models/__init__.py @@ -23,7 +23,6 @@ from .statistics import ( ) from .time import ( datetime_to_timestamp_or_none, - process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, timestamp_to_datetime_or_none, @@ -47,7 +46,6 @@ __all__ = [ "datetime_to_timestamp_or_none", "extract_event_type_ids", "extract_metadata_ids", - "process_datetime_to_timestamp", "process_timestamp", "process_timestamp_to_utc_isoformat", "row_to_compressed_state", diff --git a/homeassistant/components/recorder/models/legacy.py b/homeassistant/components/recorder/models/legacy.py index 4b32ae65748..b62afc433ef 100644 --- a/homeassistant/components/recorder/models/legacy.py +++ b/homeassistant/components/recorder/models/legacy.py @@ -17,166 +17,7 @@ from homeassistant.core import Context, State import homeassistant.util.dt as dt_util from .state_attributes import decode_attributes_from_source -from .time import ( - process_datetime_to_timestamp, - process_timestamp, - process_timestamp_to_utc_isoformat, -) - - -class LegacyLazyStatePreSchema31(State): - """A lazy version of core State before schema 31.""" - - __slots__ = [ - "_row", - "_attributes", - "_last_changed", - "_last_updated", - "_context", - "attr_cache", - ] - - def __init__( # pylint: disable=super-init-not-called - self, - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, - ) -> None: - """Init the lazy state.""" - self._row = row - self.entity_id: str = self._row.entity_id - self.state = self._row.state or "" - self._attributes: dict[str, Any] | None = None - self._last_changed: datetime | None = start_time - self._last_reported: datetime | None = start_time - self._last_updated: datetime | None = start_time - self._context: Context | None = None - self.attr_cache = attr_cache - - @property # type: ignore[override] - def attributes(self) -> dict[str, Any]: - """State attributes.""" - if self._attributes is None: - self._attributes = decode_attributes_from_row_legacy( - self._row, self.attr_cache - ) - return self._attributes - - @attributes.setter - def attributes(self, value: dict[str, Any]) -> None: - """Set attributes.""" - self._attributes = value - - @property - def context(self) -> Context: - """State context.""" - if self._context is None: - self._context = Context(id=None) - return self._context - - @context.setter - def context(self, value: Context) -> None: - """Set context.""" - self._context = value - - @property - def last_changed(self) -> datetime: - """Last changed datetime.""" - if self._last_changed is None: - if (last_changed := self._row.last_changed) is not None: - self._last_changed = process_timestamp(last_changed) - else: - self._last_changed = self.last_updated - return self._last_changed - - @last_changed.setter - def last_changed(self, value: datetime) -> None: - """Set last changed datetime.""" - self._last_changed = value - - @property - def last_reported(self) -> datetime: - """Last reported datetime.""" - if self._last_reported is None: - self._last_reported = self.last_updated - return self._last_reported - - @last_reported.setter - def last_reported(self, value: datetime) -> None: - """Set last reported datetime.""" - self._last_reported = value - - @property - def last_updated(self) -> datetime: - """Last updated datetime.""" - if self._last_updated is None: - self._last_updated = process_timestamp(self._row.last_updated) - return self._last_updated - - @last_updated.setter - def last_updated(self, value: datetime) -> None: - """Set last updated datetime.""" - self._last_updated = value - - def as_dict(self) -> dict[str, Any]: # type: ignore[override] - """Return a dict representation of the LazyState. - - Async friendly. - - To be used for JSON serialization. - """ - if self._last_changed is None and self._last_updated is None: - last_updated_isoformat = process_timestamp_to_utc_isoformat( - self._row.last_updated - ) - if ( - self._row.last_changed is None - or self._row.last_changed == self._row.last_updated - ): - last_changed_isoformat = last_updated_isoformat - else: - last_changed_isoformat = process_timestamp_to_utc_isoformat( - self._row.last_changed - ) - else: - last_updated_isoformat = self.last_updated.isoformat() - if self.last_changed == self.last_updated: - last_changed_isoformat = last_updated_isoformat - else: - last_changed_isoformat = self.last_changed.isoformat() - return { - "entity_id": self.entity_id, - "state": self.state, - "attributes": self._attributes or self.attributes, - "last_changed": last_changed_isoformat, - "last_updated": last_updated_isoformat, - } - - -def legacy_row_to_compressed_state_pre_schema_31( - row: Row, - attr_cache: dict[str, dict[str, Any]], - start_time: datetime | None, -) -> dict[str, Any]: - """Convert a database row to a compressed state before schema 31.""" - comp_state = { - COMPRESSED_STATE_STATE: row.state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_row_legacy(row, attr_cache), - } - if start_time: - comp_state[COMPRESSED_STATE_LAST_UPDATED] = start_time.timestamp() - else: - row_last_updated: datetime = row.last_updated - comp_state[COMPRESSED_STATE_LAST_UPDATED] = process_datetime_to_timestamp( - row_last_updated - ) - if ( - row_changed_changed := row.last_changed - ) and row_last_updated != row_changed_changed: - comp_state[COMPRESSED_STATE_LAST_CHANGED] = process_datetime_to_timestamp( - row_changed_changed - ) - return comp_state +from .time import process_timestamp class LegacyLazyState(State): diff --git a/homeassistant/components/recorder/models/time.py b/homeassistant/components/recorder/models/time.py index 8f0f89a9ffa..33218000faa 100644 --- a/homeassistant/components/recorder/models/time.py +++ b/homeassistant/components/recorder/models/time.py @@ -52,17 +52,6 @@ def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: return ts.astimezone(dt_util.UTC).isoformat() -def process_datetime_to_timestamp(ts: datetime) -> float: - """Process a datebase datetime to epoch. - - Mirrors the behavior of process_timestamp_to_utc_isoformat - except it returns the epoch time. - """ - if ts.tzinfo is None or ts.tzinfo == dt_util.UTC: - return dt_util.utc_to_timestamp(ts) - return ts.timestamp() - - def datetime_to_timestamp_or_none(dt: datetime | None) -> float | None: """Convert a datetime to a timestamp.""" return None if dt is None else dt.timestamp() diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py deleted file mode 100644 index 1520d5363d5..00000000000 --- a/tests/components/history/test_init_db_schema_30.py +++ /dev/null @@ -1,1007 +0,0 @@ -"""The tests the History component.""" - -from __future__ import annotations - -from datetime import datetime, timedelta -from http import HTTPStatus -import json -from unittest.mock import patch, sentinel - -from freezegun import freeze_time -import pytest - -from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder -from homeassistant.components.recorder.history import get_significant_states -from homeassistant.components.recorder.models import process_timestamp -from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.json import JSONEncoder -from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util - -from tests.components.recorder.common import ( - assert_dict_of_states_equal_without_context_and_last_changed, - assert_multiple_states_equal_without_context, - assert_multiple_states_equal_without_context_and_last_changed, - assert_states_equal_without_context, - async_recorder_block_till_done, - async_wait_recording_done, - old_db_schema, -) -from tests.typing import ClientSessionGenerator, WebSocketGenerator - - -@pytest.fixture(autouse=True) -def db_schema_30(): - """Fixture to initialize the db with the old schema 30.""" - with old_db_schema("30"): - yield - - -@pytest.fixture -def legacy_hass_history(hass: HomeAssistant, hass_history): - """Home Assistant fixture to use legacy history recording.""" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - yield - - -@pytest.mark.usefixtures("legacy_hass_history") -async def test_setup() -> None: - """Test setup method of history.""" - # Verification occurs in the fixture - - -async def test_get_significant_states(hass: HomeAssistant, legacy_hass_history) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - zero, four, states = await async_record_states(hass) - hist = get_significant_states(hass, zero, four, entity_ids=list(states)) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_minimal_response( - hass: HomeAssistant, legacy_hass_history -) -> None: - """Test that only significant states are returned. - - When minimal responses is set only the first and - last states return a complete state. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - zero, four, states = await async_record_states(hass) - hist = get_significant_states( - hass, zero, four, minimal_response=True, entity_ids=list(states) - ) - entites_with_reducable_states = [ - "media_player.test", - "media_player.test3", - ] - - # All states for media_player.test state are reduced - # down to last_changed and state when minimal_response - # is set except for the first state. - # is set. We use JSONEncoder to make sure that are - # pre-encoded last_changed is always the same as what - # will happen with encoding a native state - for entity_id in entites_with_reducable_states: - entity_states = states[entity_id] - for state_idx in range(1, len(entity_states)): - input_state = entity_states[state_idx] - orig_last_changed = json.dumps( - process_timestamp(input_state.last_changed), - cls=JSONEncoder, - ).replace('"', "") - orig_state = input_state.state - entity_states[state_idx] = { - "last_changed": orig_last_changed, - "state": orig_state, - } - - assert len(hist) == len(states) - assert_states_equal_without_context( - states["media_player.test"][0], hist["media_player.test"][0] - ) - assert states["media_player.test"][1] == hist["media_player.test"][1] - assert states["media_player.test"][2] == hist["media_player.test"][2] - - assert_multiple_states_equal_without_context( - states["media_player.test2"], hist["media_player.test2"] - ) - assert_states_equal_without_context( - states["media_player.test3"][0], hist["media_player.test3"][0] - ) - assert states["media_player.test3"][1] == hist["media_player.test3"][1] - - assert_multiple_states_equal_without_context( - states["script.can_cancel_this_one"], hist["script.can_cancel_this_one"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test2"], hist["thermostat.test2"] - ) - - -async def test_get_significant_states_with_initial( - hass: HomeAssistant, legacy_hass_history -) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - zero, four, states = await async_record_states(hass) - one = zero + timedelta(seconds=1) - one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - if entity_id == "media_player.test": - states[entity_id] = states[entity_id][1:] - for state in states[entity_id]: - if state.last_changed in (one, one_with_microsecond): - state.last_changed = one_and_half - state.last_updated = one_and_half - - hist = get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=True, - entity_ids=list(states), - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_without_initial( - hass: HomeAssistant, legacy_hass_history -) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - zero, four, states = await async_record_states(hass) - one = zero + timedelta(seconds=1) - one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - states[entity_id] = list( - filter( - lambda s: s.last_changed not in (one, one_with_microsecond), - states[entity_id], - ) - ) - del states["media_player.test2"] - - hist = get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=False, - entity_ids=list(states), - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_entity_id( - hass: HomeAssistant, hass_history -) -> None: - """Test that only significant states are returned for one entity.""" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = await async_record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - hist = get_significant_states(hass, zero, four, ["media_player.test"]) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_multiple_entity_ids( - hass: HomeAssistant, legacy_hass_history -) -> None: - """Test that only significant states are returned for one entity.""" - zero, four, states = await async_record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - hist = get_significant_states( - hass, - zero, - four, - ["media_player.test", "thermostat.test"], - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_are_ordered( - hass: HomeAssistant, legacy_hass_history -) -> None: - """Test order of results from get_significant_states. - - When entity ids are given, the results should be returned with the data - in the same order. - """ - zero, four, _states = await async_record_states(hass) - entity_ids = ["media_player.test", "media_player.test2"] - hist = get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids - entity_ids = ["media_player.test2", "media_player.test"] - hist = get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids - - -async def test_get_significant_states_only( - hass: HomeAssistant, legacy_hass_history -) -> None: - """Test significant states when significant_states_only is set.""" - entity_id = "sensor.test" - - async def set_state(state, **kwargs): - """Set the state.""" - hass.states.async_set(entity_id, state, **kwargs) - await async_wait_recording_done(hass) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=4) - points = [start + timedelta(minutes=i) for i in range(1, 4)] - - states = [] - with freeze_time(start) as freezer: - await set_state("123", attributes={"attribute": 10.64}) - - freezer.move_to(points[0]) - # Attributes are different, state not - states.append(await set_state("123", attributes={"attribute": 21.42})) - - freezer.move_to(points[1]) - # state is different, attributes not - states.append(await set_state("32", attributes={"attribute": 21.42})) - - freezer.move_to(points[2]) - # everything is different - states.append(await set_state("412", attributes={"attribute": 54.23})) - - hist = get_significant_states( - hass, - start, - significant_changes_only=True, - entity_ids=list({state.entity_id for state in states}), - ) - - assert len(hist[entity_id]) == 2 - assert not any( - state.last_updated == states[0].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[1].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[2].last_updated for state in hist[entity_id] - ) - - hist = get_significant_states( - hass, - start, - significant_changes_only=False, - entity_ids=list({state.entity_id for state in states}), - ) - - assert len(hist[entity_id]) == 3 - assert_multiple_states_equal_without_context_and_last_changed( - states, hist[entity_id] - ) - - -async def async_record_states( - hass: HomeAssistant, -) -> tuple[datetime, datetime, dict[str, list[State | None]]]: - """Record some test states. - - We inject a bunch of state updates from media player, zone and - thermostat. - """ - mp = "media_player.test" - mp2 = "media_player.test2" - mp3 = "media_player.test3" - therm = "thermostat.test" - therm2 = "thermostat.test2" - zone = "zone.home" - script_c = "script.can_cancel_this_one" - - async def async_set_state(entity_id, state, **kwargs): - """Set the state.""" - hass.states.async_set(entity_id, state, **kwargs) - await async_wait_recording_done(hass) - return hass.states.get(entity_id) - - zero = dt_util.utcnow() - one = zero + timedelta(seconds=1) - two = one + timedelta(seconds=1) - three = two + timedelta(seconds=1) - four = three + timedelta(seconds=1) - - states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with freeze_time(one) as freezer: - states[mp].append( - await async_set_state( - mp, "idle", attributes={"media_title": str(sentinel.mt1)} - ) - ) - states[mp2].append( - await async_set_state( - mp2, "YouTube", attributes={"media_title": str(sentinel.mt2)} - ) - ) - states[mp3].append( - await async_set_state( - mp3, "idle", attributes={"media_title": str(sentinel.mt1)} - ) - ) - states[therm].append( - await async_set_state(therm, 20, attributes={"current_temperature": 19.5}) - ) - - freezer.move_to(one + timedelta(microseconds=1)) - states[mp].append( - await async_set_state( - mp, "YouTube", attributes={"media_title": str(sentinel.mt2)} - ) - ) - - freezer.move_to(two) - # This state will be skipped only different in time - await async_set_state( - mp, "YouTube", attributes={"media_title": str(sentinel.mt3)} - ) - # This state will be skipped because domain is excluded - await async_set_state(zone, "zoning") - states[script_c].append( - await async_set_state(script_c, "off", attributes={"can_cancel": True}) - ) - states[therm].append( - await async_set_state(therm, 21, attributes={"current_temperature": 19.8}) - ) - states[therm2].append( - await async_set_state(therm2, 20, attributes={"current_temperature": 19}) - ) - - freezer.move_to(three) - states[mp].append( - await async_set_state( - mp, "Netflix", attributes={"media_title": str(sentinel.mt4)} - ) - ) - states[mp3].append( - await async_set_state( - mp3, "Netflix", attributes={"media_title": str(sentinel.mt3)} - ) - ) - # Attributes changed even though state is the same - states[therm].append( - await async_set_state(therm, 21, attributes={"current_temperature": 20}) - ) - - return zero, four, states - - -async def test_fetch_period_api( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history.""" - await async_setup_component(hass, "history", {}) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=sensor.power" - ) - assert response.status == HTTPStatus.OK - - -async def test_fetch_period_api_with_minimal_response( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history with minimal_response.""" - now = dt_util.utcnow() - await async_setup_component(hass, "history", {}) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - hass.states.async_set("sensor.power", 0, {"attr": "any"}) - await async_wait_recording_done(hass) - hass.states.async_set("sensor.power", 50, {"attr": "any"}) - await async_wait_recording_done(hass) - hass.states.async_set("sensor.power", 23, {"attr": "any"}) - last_changed = hass.states.get("sensor.power").last_changed - await async_wait_recording_done(hass) - hass.states.async_set("sensor.power", 23, {"attr": "any"}) - await async_wait_recording_done(hass) - client = await hass_client() - response = await client.get( - f"/api/history/period/{now.isoformat()}?filter_entity_id=sensor.power&minimal_response&no_attributes" - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json[0]) == 3 - state_list = response_json[0] - - assert state_list[0]["entity_id"] == "sensor.power" - assert state_list[0]["attributes"] == {} - assert state_list[0]["state"] == "0" - - assert "attributes" not in state_list[1] - assert "entity_id" not in state_list[1] - assert state_list[1]["state"] == "50" - - assert "attributes" not in state_list[2] - assert "entity_id" not in state_list[2] - assert state_list[2]["state"] == "23" - assert state_list[2]["last_changed"] == json.dumps( - process_timestamp(last_changed), - cls=JSONEncoder, - ).replace('"', "") - - -async def test_fetch_period_api_with_no_timestamp( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history with no timestamp.""" - await async_setup_component(hass, "history", {}) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - client = await hass_client() - response = await client.get("/api/history/period?filter_entity_id=sensor.power") - assert response.status == HTTPStatus.OK - - -async def test_fetch_period_api_with_include_order( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator -) -> None: - """Test the fetch period view for history.""" - await async_setup_component( - hass, - "history", - { - "history": { - "use_include_order": True, - "include": {"entities": ["light.kitchen"]}, - } - }, - ) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}", - params={"filter_entity_id": "non.existing,something.else"}, - ) - assert response.status == HTTPStatus.OK - - -async def test_entity_ids_limit_via_api( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator -) -> None: - """Test limiting history to entity_ids.""" - await async_setup_component( - hass, - "history", - {"history": {}}, - ) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.nomatch", "on") - - await async_wait_recording_done(hass) - - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=light.kitchen,light.cow", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 2 - assert response_json[0][0]["entity_id"] == "light.kitchen" - assert response_json[1][0]["entity_id"] == "light.cow" - - -async def test_entity_ids_limit_via_api_with_skip_initial_state( - hass: HomeAssistant, recorder_mock: Recorder, hass_client: ClientSessionGenerator -) -> None: - """Test limiting history to entity_ids with skip_initial_state.""" - await async_setup_component( - hass, - "history", - {"history": {}}, - ) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - hass.states.async_set("light.kitchen", "on") - hass.states.async_set("light.cow", "on") - hass.states.async_set("light.nomatch", "on") - - await async_wait_recording_done(hass) - - client = await hass_client() - response = await client.get( - f"/api/history/period/{dt_util.utcnow().isoformat()}?filter_entity_id=light.kitchen,light.cow&skip_initial_state", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 0 - - when = dt_util.utcnow() - timedelta(minutes=1) - response = await client.get( - f"/api/history/period/{when.isoformat()}?filter_entity_id=light.kitchen,light.cow&skip_initial_state", - ) - assert response.status == HTTPStatus.OK - response_json = await response.json() - assert len(response_json) == 2 - assert response_json[0][0]["entity_id"] == "light.kitchen" - assert response_json[1][0]["entity_id"] == "light.cow" - - -async def test_history_during_period( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator -) -> None: - """Test history_during_period.""" - now = dt_util.utcnow() - - await async_setup_component(hass, "history", {}) - await async_setup_component(hass, "sensor", {}) - await async_recorder_block_till_done(hass) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_wait_recording_done(hass) - - await async_wait_recording_done(hass) - - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "end_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {} - - await client.send_json( - { - "id": 2, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - "minimal_response": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 2 - - sensor_test_history = response["result"]["sensor.test"] - assert len(sensor_test_history) == 3 - - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {} - assert isinstance(sensor_test_history[0]["lu"], float) - assert ( - "lc" not in sensor_test_history[0] - ) # skipped if the same a last_updated (lu) - - assert "a" not in sensor_test_history[1] - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert ( - "lc" not in sensor_test_history[1] - ) # skipped if the same a last_updated (lu) - - assert sensor_test_history[2]["s"] == "on" - assert "a" not in sensor_test_history[2] - - await client.send_json( - { - "id": 3, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 3 - sensor_test_history = response["result"]["sensor.test"] - - assert len(sensor_test_history) == 5 - - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"any": "attr"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert ( - "lc" not in sensor_test_history[0] - ) # skipped if the same a last_updated (lu) - - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert ( - "lc" not in sensor_test_history[1] - ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"any": "attr"} - - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {"any": "attr"} - - await client.send_json( - { - "id": 4, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 4 - sensor_test_history = response["result"]["sensor.test"] - - assert len(sensor_test_history) == 3 - - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"any": "attr"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert ( - "lc" not in sensor_test_history[0] - ) # skipped if the same a last_updated (lu) - - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert ( - "lc" not in sensor_test_history[1] - ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"any": "attr"} - - assert sensor_test_history[2]["s"] == "on" - assert sensor_test_history[2]["a"] == {"any": "attr"} - - -async def test_history_during_period_impossible_conditions( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator -) -> None: - """Test history_during_period returns when condition cannot be true.""" - await async_setup_component(hass, "history", {}) - await async_setup_component(hass, "sensor", {}) - await async_recorder_block_till_done(hass) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "attr"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "changed"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "off", attributes={"any": "again"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("sensor.test", "on", attributes={"any": "attr"}) - await async_wait_recording_done(hass) - - await async_wait_recording_done(hass) - - after = dt_util.utcnow() - - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": after.isoformat(), - "end_time": after.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": False, - "significant_changes_only": False, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 1 - assert response["result"] == {} - - future = dt_util.utcnow() + timedelta(hours=10) - - await client.send_json( - { - "id": 2, - "type": "history/history_during_period", - "start_time": future.isoformat(), - "entity_ids": ["sensor.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 2 - assert response["result"] == {} - - -@pytest.mark.parametrize( - "time_zone", ["UTC", "Europe/Berlin", "America/Chicago", "US/Hawaii"] -) -async def test_history_during_period_significant_domain( - hass: HomeAssistant, - recorder_mock: Recorder, - hass_ws_client: WebSocketGenerator, - time_zone, -) -> None: - """Test history_during_period with climate domain.""" - await hass.config.async_set_time_zone(time_zone) - now = dt_util.utcnow() - - await async_setup_component(hass, "history", {}) - await async_setup_component(hass, "sensor", {}) - await async_recorder_block_till_done(hass) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - hass.states.async_set("climate.test", "on", attributes={"temperature": "1"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "off", attributes={"temperature": "2"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "off", attributes={"temperature": "3"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "off", attributes={"temperature": "4"}) - await async_recorder_block_till_done(hass) - hass.states.async_set("climate.test", "on", attributes={"temperature": "5"}) - await async_wait_recording_done(hass) - - await async_wait_recording_done(hass) - - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "end_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == {} - - await client.send_json( - { - "id": 2, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": True, - "minimal_response": True, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 2 - - sensor_test_history = response["result"]["climate.test"] - assert len(sensor_test_history) == 5 - - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {} - assert isinstance(sensor_test_history[0]["lu"], float) - assert ( - "lc" not in sensor_test_history[0] - ) # skipped if the same a last_updated (lu) - - assert "a" in sensor_test_history[1] - assert sensor_test_history[1]["s"] == "off" - assert ( - "lc" not in sensor_test_history[1] - ) # skipped if the same a last_updated (lu) - - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {} - - await client.send_json( - { - "id": 3, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": False, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 3 - sensor_test_history = response["result"]["climate.test"] - - assert len(sensor_test_history) == 5 - - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"temperature": "1"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert ( - "lc" not in sensor_test_history[0] - ) # skipped if the same a last_updated (lu) - - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert ( - "lc" not in sensor_test_history[1] - ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"temperature": "2"} - - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {"temperature": "5"} - - await client.send_json( - { - "id": 4, - "type": "history/history_during_period", - "start_time": now.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 4 - sensor_test_history = response["result"]["climate.test"] - - assert len(sensor_test_history) == 5 - - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"temperature": "1"} - assert isinstance(sensor_test_history[0]["lu"], float) - assert ( - "lc" not in sensor_test_history[0] - ) # skipped if the same a last_updated (lu) - - assert sensor_test_history[1]["s"] == "off" - assert isinstance(sensor_test_history[1]["lu"], float) - assert ( - "lc" not in sensor_test_history[1] - ) # skipped if the same a last_updated (lu) - assert sensor_test_history[1]["a"] == {"temperature": "2"} - - assert sensor_test_history[2]["s"] == "off" - assert sensor_test_history[2]["a"] == {"temperature": "3"} - - assert sensor_test_history[3]["s"] == "off" - assert sensor_test_history[3]["a"] == {"temperature": "4"} - - assert sensor_test_history[4]["s"] == "on" - assert sensor_test_history[4]["a"] == {"temperature": "5"} - - # Test we impute the state time state - later = dt_util.utcnow() - await client.send_json( - { - "id": 5, - "type": "history/history_during_period", - "start_time": later.isoformat(), - "entity_ids": ["climate.test"], - "include_start_time_state": True, - "significant_changes_only": True, - "no_attributes": False, - } - ) - response = await client.receive_json() - assert response["success"] - assert response["id"] == 5 - sensor_test_history = response["result"]["climate.test"] - - assert len(sensor_test_history) == 1 - - assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {"temperature": "5"} - assert sensor_test_history[0]["lu"] == later.timestamp() - assert ( - "lc" not in sensor_test_history[0] - ) # skipped if the same a last_updated (lu) - - -async def test_history_during_period_bad_start_time( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator -) -> None: - """Test history_during_period bad state time.""" - await async_setup_component( - hass, - "history", - {"history": {}}, - ) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "entity_ids": ["sensor.pet"], - "start_time": "cats", - } - ) - response = await client.receive_json() - assert not response["success"] - assert response["error"]["code"] == "invalid_start_time" - - -async def test_history_during_period_bad_end_time( - hass: HomeAssistant, recorder_mock: Recorder, hass_ws_client: WebSocketGenerator -) -> None: - """Test history_during_period bad end time.""" - now = dt_util.utcnow() - - await async_setup_component( - hass, - "history", - {"history": {}}, - ) - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - client = await hass_ws_client() - await client.send_json( - { - "id": 1, - "type": "history/history_during_period", - "entity_ids": ["sensor.pet"], - "start_time": now.isoformat(), - "end_time": "dogs", - } - ) - response = await client.receive_json() - assert not response["success"] - assert response["error"]["code"] == "invalid_end_time" diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py deleted file mode 100644 index 0e5f6cf7f79..00000000000 --- a/tests/components/recorder/test_history_db_schema_30.py +++ /dev/null @@ -1,713 +0,0 @@ -"""The tests the History component.""" - -from __future__ import annotations - -from copy import copy -from datetime import datetime, timedelta -import json -from unittest.mock import patch, sentinel - -from freezegun import freeze_time -import pytest - -from homeassistant.components import recorder -from homeassistant.components.recorder import Recorder, history -from homeassistant.components.recorder.filters import Filters -from homeassistant.components.recorder.models import process_timestamp -from homeassistant.components.recorder.util import session_scope -from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.json import JSONEncoder -import homeassistant.util.dt as dt_util - -from .common import ( - assert_dict_of_states_equal_without_context_and_last_changed, - assert_multiple_states_equal_without_context, - assert_multiple_states_equal_without_context_and_last_changed, - assert_states_equal_without_context, - async_wait_recording_done, - old_db_schema, -) - -from tests.typing import RecorderInstanceGenerator - - -@pytest.fixture -async def mock_recorder_before_hass( - async_test_recorder: RecorderInstanceGenerator, -) -> None: - """Set up recorder.""" - - -@pytest.fixture(autouse=True) -def db_schema_30(): - """Fixture to initialize the db with the old schema 30.""" - with old_db_schema("30"): - yield - - -@pytest.fixture(autouse=True) -def setup_recorder(db_schema_30, recorder_mock: Recorder) -> recorder.Recorder: - """Set up recorder.""" - - -async def test_get_full_significant_states_with_session_entity_no_matches( - hass: HomeAssistant, -) -> None: - """Test getting states at a specific point in time for entities that never have been recorded.""" - now = dt_util.utcnow() - time_before_recorder_ran = now - timedelta(days=1000) - instance = recorder.get_instance(hass) - with ( - session_scope(hass=hass) as session, - patch.object(instance.states_meta_manager, "active", False), - ): - assert ( - history.get_full_significant_states_with_session( - hass, session, time_before_recorder_ran, now, entity_ids=["demo.id"] - ) - == {} - ) - assert ( - history.get_full_significant_states_with_session( - hass, - session, - time_before_recorder_ran, - now, - entity_ids=["demo.id", "demo.id2"], - ) - == {} - ) - - -async def test_significant_states_with_session_entity_minimal_response_no_matches( - hass: HomeAssistant, -) -> None: - """Test getting states at a specific point in time for entities that never have been recorded.""" - now = dt_util.utcnow() - time_before_recorder_ran = now - timedelta(days=1000) - instance = recorder.get_instance(hass) - with ( - session_scope(hass=hass) as session, - patch.object(instance.states_meta_manager, "active", False), - ): - assert ( - history.get_significant_states_with_session( - hass, - session, - time_before_recorder_ran, - now, - entity_ids=["demo.id"], - minimal_response=True, - ) - == {} - ) - assert ( - history.get_significant_states_with_session( - hass, - session, - time_before_recorder_ran, - now, - entity_ids=["demo.id", "demo.id2"], - minimal_response=True, - ) - == {} - ) - - -@pytest.mark.parametrize( - ("attributes", "no_attributes", "limit"), - [ - ({"attr": True}, False, 5000), - ({}, True, 5000), - ({"attr": True}, False, 3), - ({}, True, 3), - ], -) -async def test_state_changes_during_period( - hass: HomeAssistant, attributes, no_attributes, limit -) -> None: - """Test state change during period.""" - entity_id = "media_player.test" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state, attributes) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - end = point + timedelta(seconds=1) - - with freeze_time(start) as freezer: - set_state("idle") - set_state("YouTube") - - freezer.move_to(point) - states = [ - set_state("idle"), - set_state("Netflix"), - set_state("Plex"), - set_state("YouTube"), - ] - - freezer.move_to(end) - set_state("Netflix") - set_state("Plex") - await async_wait_recording_done(hass) - - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes, limit=limit - ) - - assert_multiple_states_equal_without_context(states[:limit], hist[entity_id]) - - -async def test_state_changes_during_period_descending( - hass: HomeAssistant, -) -> None: - """Test state change during period descending.""" - entity_id = "media_player.test" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state, {"any": 1}) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - point = start + timedelta(seconds=1) - point2 = start + timedelta(seconds=1, microseconds=2) - point3 = start + timedelta(seconds=1, microseconds=3) - point4 = start + timedelta(seconds=1, microseconds=4) - end = point + timedelta(seconds=1) - - with freeze_time(start) as freezer: - set_state("idle") - set_state("YouTube") - - freezer.move_to(point) - - states = [set_state("idle")] - freezer.move_to(point2) - - states.append(set_state("Netflix")) - - freezer.move_to(point3) - states.append(set_state("Plex")) - - freezer.move_to(point4) - states.append(set_state("YouTube")) - - freezer.move_to(end) - set_state("Netflix") - set_state("Plex") - await async_wait_recording_done(hass) - - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes=False, descending=False - ) - assert_multiple_states_equal_without_context(states, hist[entity_id]) - - hist = history.state_changes_during_period( - hass, start, end, entity_id, no_attributes=False, descending=True - ) - assert_multiple_states_equal_without_context( - states, list(reversed(list(hist[entity_id]))) - ) - - -async def test_get_last_state_changes(hass: HomeAssistant) -> None: - """Test number of state changes.""" - entity_id = "sensor.test" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) - point2 = point + timedelta(minutes=1, seconds=1) - states = [] - - with freeze_time(start) as freezer: - set_state("1") - - freezer.move_to(point) - states.append(set_state("2")) - - freezer.move_to(point2) - states.append(set_state("3")) - await async_wait_recording_done(hass) - - hist = history.get_last_state_changes(hass, 2, entity_id) - - assert_multiple_states_equal_without_context(states, hist[entity_id]) - - -async def test_ensure_state_can_be_copied( - hass: HomeAssistant, -) -> None: - """Ensure a state can pass though copy(). - - The filter integration uses copy() on states - from history. - """ - entity_id = "sensor.test" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - - def set_state(state): - """Set the state.""" - hass.states.async_set(entity_id, state) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=2) - point = start + timedelta(minutes=1) - - with freeze_time(start) as freezer: - set_state("1") - - freezer.move_to(point) - set_state("2") - await async_wait_recording_done(hass) - - hist = history.get_last_state_changes(hass, 2, entity_id) - - assert_states_equal_without_context( - copy(hist[entity_id][0]), hist[entity_id][0] - ) - assert_states_equal_without_context( - copy(hist[entity_id][1]), hist[entity_id][1] - ) - - -async def test_get_significant_states(hass: HomeAssistant) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - hist = history.get_significant_states(hass, zero, four, entity_ids=list(states)) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_minimal_response(hass: HomeAssistant) -> None: - """Test that only significant states are returned. - - When minimal responses is set only the first and - last states return a complete state. - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - hist = history.get_significant_states( - hass, zero, four, minimal_response=True, entity_ids=list(states) - ) - entites_with_reducable_states = [ - "media_player.test", - "media_player.test3", - ] - - # All states for media_player.test state are reduced - # down to last_changed and state when minimal_response - # is set except for the first state. - # is set. We use JSONEncoder to make sure that are - # pre-encoded last_changed is always the same as what - # will happen with encoding a native state - for entity_id in entites_with_reducable_states: - entity_states = states[entity_id] - for state_idx in range(1, len(entity_states)): - input_state = entity_states[state_idx] - orig_last_changed = json.dumps( - process_timestamp(input_state.last_changed), - cls=JSONEncoder, - ).replace('"', "") - orig_state = input_state.state - entity_states[state_idx] = { - "last_changed": orig_last_changed, - "state": orig_state, - } - - assert len(hist) == len(states) - assert_states_equal_without_context( - states["media_player.test"][0], hist["media_player.test"][0] - ) - assert states["media_player.test"][1] == hist["media_player.test"][1] - assert states["media_player.test"][2] == hist["media_player.test"][2] - - assert_multiple_states_equal_without_context( - states["media_player.test2"], hist["media_player.test2"] - ) - assert_states_equal_without_context( - states["media_player.test3"][0], hist["media_player.test3"][0] - ) - assert states["media_player.test3"][1] == hist["media_player.test3"][1] - - assert_multiple_states_equal_without_context( - states["script.can_cancel_this_one"], hist["script.can_cancel_this_one"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test2"], hist["thermostat.test2"] - ) - - -async def test_get_significant_states_with_initial(hass: HomeAssistant) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - one = zero + timedelta(seconds=1) - one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - if entity_id == "media_player.test": - states[entity_id] = states[entity_id][1:] - for state in states[entity_id]: - if state.last_changed in (one, one_with_microsecond): - state.last_changed = one_and_half - state.last_updated = one_and_half - - hist = history.get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=True, - entity_ids=list(states), - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_without_initial(hass: HomeAssistant) -> None: - """Test that only significant states are returned. - - We should get back every thermostat change that - includes an attribute change, but only the state updates for - media player (attribute changes are not significant and not returned). - """ - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - one = zero + timedelta(seconds=1) - one_with_microsecond = zero + timedelta(seconds=1, microseconds=1) - one_and_half = zero + timedelta(seconds=1.5) - for entity_id in states: - states[entity_id] = [ - s - for s in states[entity_id] - if s.last_changed not in (one, one_with_microsecond) - ] - del states["media_player.test2"] - - hist = history.get_significant_states( - hass, - one_and_half, - four, - include_start_time_state=False, - entity_ids=list(states), - ) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_entity_id(hass: HomeAssistant) -> None: - """Test that only significant states are returned for one entity.""" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - hist = history.get_significant_states(hass, zero, four, ["media_player.test"]) - assert_dict_of_states_equal_without_context_and_last_changed(states, hist) - - -async def test_get_significant_states_multiple_entity_ids(hass: HomeAssistant) -> None: - """Test that only significant states are returned for one entity.""" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, states = record_states(hass) - await async_wait_recording_done(hass) - - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test2"] - del states["script.can_cancel_this_one"] - - hist = history.get_significant_states( - hass, - zero, - four, - ["media_player.test", "thermostat.test"], - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["media_player.test"], hist["media_player.test"] - ) - assert_multiple_states_equal_without_context_and_last_changed( - states["thermostat.test"], hist["thermostat.test"] - ) - - -async def test_get_significant_states_are_ordered(hass: HomeAssistant) -> None: - """Test order of results from get_significant_states. - - When entity ids are given, the results should be returned with the data - in the same order. - """ - - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - zero, four, _states = record_states(hass) - await async_wait_recording_done(hass) - - entity_ids = ["media_player.test", "media_player.test2"] - hist = history.get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids - entity_ids = ["media_player.test2", "media_player.test"] - hist = history.get_significant_states(hass, zero, four, entity_ids) - assert list(hist.keys()) == entity_ids - - -async def test_get_significant_states_only(hass: HomeAssistant) -> None: - """Test significant states when significant_states_only is set.""" - entity_id = "sensor.test" - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - - def set_state(state, **kwargs): - """Set the state.""" - hass.states.async_set(entity_id, state, **kwargs) - return hass.states.get(entity_id) - - start = dt_util.utcnow() - timedelta(minutes=4) - points = [start + timedelta(minutes=i) for i in range(1, 4)] - - states = [] - with freeze_time(start) as freezer: - set_state("123", attributes={"attribute": 10.64}) - - freezer.move_to(points[0]) - # Attributes are different, state not - states.append(set_state("123", attributes={"attribute": 21.42})) - - freezer.move_to(points[1]) - # state is different, attributes not - states.append(set_state("32", attributes={"attribute": 21.42})) - - freezer.move_to(points[2]) - # everything is different - states.append(set_state("412", attributes={"attribute": 54.23})) - await async_wait_recording_done(hass) - - hist = history.get_significant_states( - hass, - start, - significant_changes_only=True, - entity_ids=list({state.entity_id for state in states}), - ) - - assert len(hist[entity_id]) == 2 - assert not any( - state.last_updated == states[0].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[1].last_updated for state in hist[entity_id] - ) - assert any( - state.last_updated == states[2].last_updated for state in hist[entity_id] - ) - - hist = history.get_significant_states( - hass, - start, - significant_changes_only=False, - entity_ids=list({state.entity_id for state in states}), - ) - - assert len(hist[entity_id]) == 3 - assert_multiple_states_equal_without_context_and_last_changed( - states, hist[entity_id] - ) - - -def record_states( - hass: HomeAssistant, -) -> tuple[datetime, datetime, dict[str, list[State]]]: - """Record some test states. - - We inject a bunch of state updates from media player, zone and - thermostat. - """ - mp = "media_player.test" - mp2 = "media_player.test2" - mp3 = "media_player.test3" - therm = "thermostat.test" - therm2 = "thermostat.test2" - zone = "zone.home" - script_c = "script.can_cancel_this_one" - - def set_state(entity_id, state, **kwargs): - """Set the state.""" - hass.states.async_set(entity_id, state, **kwargs) - return hass.states.get(entity_id) - - zero = dt_util.utcnow() - one = zero + timedelta(seconds=1) - two = one + timedelta(seconds=1) - three = two + timedelta(seconds=1) - four = three + timedelta(seconds=1) - - states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with freeze_time(one) as freezer: - states[mp].append( - set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) - ) - states[mp2].append( - set_state(mp2, "YouTube", attributes={"media_title": str(sentinel.mt2)}) - ) - states[mp3].append( - set_state(mp3, "idle", attributes={"media_title": str(sentinel.mt1)}) - ) - states[therm].append( - set_state(therm, 20, attributes={"current_temperature": 19.5}) - ) - - freezer.move_to(one + timedelta(microseconds=1)) - states[mp].append( - set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) - ) - - freezer.move_to(two) - # This state will be skipped only different in time - set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) - # This state will be skipped because domain is excluded - set_state(zone, "zoning") - states[script_c].append( - set_state(script_c, "off", attributes={"can_cancel": True}) - ) - states[therm].append( - set_state(therm, 21, attributes={"current_temperature": 19.8}) - ) - states[therm2].append( - set_state(therm2, 20, attributes={"current_temperature": 19}) - ) - - freezer.move_to(three) - states[mp].append( - set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) - ) - states[mp3].append( - set_state(mp3, "Netflix", attributes={"media_title": str(sentinel.mt3)}) - ) - # Attributes changed even though state is the same - states[therm].append( - set_state(therm, 21, attributes={"current_temperature": 20}) - ) - - return zero, four, states - - -async def test_state_changes_during_period_multiple_entities_single_test( - hass: HomeAssistant, -) -> None: - """Test state change during period with multiple entities in the same test. - - This test ensures the sqlalchemy query cache does not - generate incorrect results. - """ - instance = recorder.get_instance(hass) - with patch.object(instance.states_meta_manager, "active", False): - start = dt_util.utcnow() - test_entites = {f"sensor.{i}": str(i) for i in range(30)} - for entity_id, value in test_entites.items(): - hass.states.async_set(entity_id, value) - await async_wait_recording_done(hass) - - end = dt_util.utcnow() - - for entity_id, value in test_entites.items(): - hist = history.state_changes_during_period(hass, start, end, entity_id) - assert len(hist) == 1 - assert hist[entity_id][0].state == value - - -def test_get_significant_states_without_entity_ids_raises(hass: HomeAssistant) -> None: - """Test at least one entity id is required for get_significant_states.""" - now = dt_util.utcnow() - with pytest.raises(ValueError, match="entity_ids must be provided"): - history.get_significant_states(hass, now, None) - - -def test_state_changes_during_period_without_entity_ids_raises( - hass: HomeAssistant, -) -> None: - """Test at least one entity id is required for state_changes_during_period.""" - now = dt_util.utcnow() - with pytest.raises(ValueError, match="entity_id must be provided"): - history.state_changes_during_period(hass, now, None) - - -def test_get_significant_states_with_filters_raises(hass: HomeAssistant) -> None: - """Test passing filters is no longer supported.""" - now = dt_util.utcnow() - with pytest.raises(NotImplementedError, match="Filters are no longer supported"): - history.get_significant_states( - hass, now, None, ["media_player.test"], Filters() - ) - - -def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass: HomeAssistant, -) -> None: - """Test get_significant_states returns an empty dict when entities not in the db.""" - now = dt_util.utcnow() - assert history.get_significant_states(hass, now, None, ["nonexistent.entity"]) == {} - - -def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass: HomeAssistant, -) -> None: - """Test state_changes_during_period returns an empty dict when entities not in the db.""" - now = dt_util.utcnow() - assert ( - history.state_changes_during_period(hass, now, None, "nonexistent.entity") == {} - ) - - -def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass: HomeAssistant, -) -> None: - """Test get_last_state_changes returns an empty dict when entities not in the db.""" - assert history.get_last_state_changes(hass, 1, "nonexistent.entity") == {} diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 975d67a8e99..c8ab64c7d89 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -3,7 +3,6 @@ from datetime import datetime, timedelta from unittest.mock import PropertyMock -from freezegun import freeze_time import pytest from homeassistant.components.recorder.const import SupportedDialect @@ -15,13 +14,11 @@ from homeassistant.components.recorder.db_schema import ( ) from homeassistant.components.recorder.models import ( LazyState, - process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, ) from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.core as ha -from homeassistant.core import HomeAssistant from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.util import dt as dt_util @@ -354,75 +351,3 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( "last_updated": "2021-06-12T03:04:01.000323+00:00", "state": "off", } - - -@pytest.mark.parametrize( - "time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii", "UTC"] -) -async def test_process_datetime_to_timestamp(time_zone, hass: HomeAssistant) -> None: - """Test we can handle processing database datatimes to timestamps.""" - await hass.config.async_set_time_zone(time_zone) - utc_now = dt_util.utcnow() - assert process_datetime_to_timestamp(utc_now) == utc_now.timestamp() - now = dt_util.now() - assert process_datetime_to_timestamp(now) == now.timestamp() - - -@pytest.mark.parametrize( - "time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii", "UTC"] -) -async def test_process_datetime_to_timestamp_freeze_time( - time_zone, hass: HomeAssistant -) -> None: - """Test we can handle processing database datatimes to timestamps. - - This test freezes time to make sure everything matches. - """ - await hass.config.async_set_time_zone(time_zone) - utc_now = dt_util.utcnow() - with freeze_time(utc_now): - epoch = utc_now.timestamp() - assert process_datetime_to_timestamp(dt_util.utcnow()) == epoch - now = dt_util.now() - assert process_datetime_to_timestamp(now) == epoch - - -@pytest.mark.parametrize( - "time_zone", ["Europe/Berlin", "America/Chicago", "US/Hawaii", "UTC"] -) -async def test_process_datetime_to_timestamp_mirrors_utc_isoformat_behavior( - time_zone, hass: HomeAssistant -) -> None: - """Test process_datetime_to_timestamp mirrors process_timestamp_to_utc_isoformat.""" - await hass.config.async_set_time_zone(time_zone) - datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC) - datetime_without_tzinfo = datetime(2016, 7, 9, 11, 0, 0) - est = dt_util.get_time_zone("US/Eastern") - datetime_est_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=est) - est = dt_util.get_time_zone("US/Eastern") - datetime_est_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=est) - nst = dt_util.get_time_zone("Canada/Newfoundland") - datetime_nst_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=nst) - hst = dt_util.get_time_zone("US/Hawaii") - datetime_hst_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=hst) - - assert ( - process_datetime_to_timestamp(datetime_with_tzinfo) - == dt_util.parse_datetime("2016-07-09T11:00:00+00:00").timestamp() - ) - assert ( - process_datetime_to_timestamp(datetime_without_tzinfo) - == dt_util.parse_datetime("2016-07-09T11:00:00+00:00").timestamp() - ) - assert ( - process_datetime_to_timestamp(datetime_est_timezone) - == dt_util.parse_datetime("2016-07-09T15:00:00+00:00").timestamp() - ) - assert ( - process_datetime_to_timestamp(datetime_nst_timezone) - == dt_util.parse_datetime("2016-07-09T13:30:00+00:00").timestamp() - ) - assert ( - process_datetime_to_timestamp(datetime_hst_timezone) - == dt_util.parse_datetime("2016-07-09T21:00:00+00:00").timestamp() - ) From aa8f98392d3d32b1cc35b7a969d3d1e862dd861c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 10 Sep 2024 18:35:18 +0100 Subject: [PATCH 0500/1309] Bump tplink python-kasa lib to 0.7.3 (#125686) --- 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 0d9761ec8ce..b655f2e646a 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.2"] + "requirements": ["python-kasa[speedups]==0.7.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 86e7e087678..042eec8bd7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2334,7 +2334,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.2 +python-kasa[speedups]==0.7.3 # homeassistant.components.linkplay python-linkplay==0.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f58cac3f00a..6466c13dbe3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1852,7 +1852,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.7.2 +python-kasa[speedups]==0.7.3 # homeassistant.components.linkplay python-linkplay==0.0.9 From 2b3a6e5361db71cff3e44ce0c957f6951515a103 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Tue, 10 Sep 2024 19:38:40 +0200 Subject: [PATCH 0501/1309] Refactor LcnEntity signature (#124411) * Refactorings due to change of LcnEntity signature * Fix PR comments * Move parent class LcnEntity to entity.py --- homeassistant/components/lcn/__init__.py | 96 +------------------ homeassistant/components/lcn/binary_sensor.py | 46 +++------ homeassistant/components/lcn/climate.py | 25 ++--- homeassistant/components/lcn/cover.py | 32 ++----- homeassistant/components/lcn/entity.py | 90 +++++++++++++++++ homeassistant/components/lcn/helpers.py | 2 +- homeassistant/components/lcn/light.py | 32 ++----- homeassistant/components/lcn/scene.py | 25 ++--- homeassistant/components/lcn/sensor.py | 35 ++----- homeassistant/components/lcn/switch.py | 32 ++----- 10 files changed, 157 insertions(+), 258 deletions(-) create mode 100644 homeassistant/components/lcn/entity.py diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 9817a254d59..96ffaddfb93 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -2,35 +2,27 @@ from __future__ import annotations -from collections.abc import Callable from functools import partial import logging import pypck from pypck.connection import PchkConnectionManager -from homeassistant import config_entries +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - CONF_ADDRESS, CONF_DEVICE_ID, - CONF_DOMAIN, CONF_IP_ADDRESS, - CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_RESOURCE, CONF_USERNAME, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from .const import ( ADD_ENTITIES_CALLBACKS, CONF_DIM_MODE, - CONF_DOMAIN_DATA, CONF_SK_NUM_TRIES, CONNECTION, DOMAIN, @@ -38,11 +30,9 @@ from .const import ( ) from .helpers import ( AddressType, - DeviceConnectionType, InputType, async_update_config_entry, generate_unique_id, - get_device_model, import_lcn_config, register_lcn_address_devices, register_lcn_host_device, @@ -67,16 +57,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, + context={"source": SOURCE_IMPORT}, data=config_entry_data, ) ) return True -async def async_setup_entry( - hass: HomeAssistant, config_entry: config_entries.ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up a connection to PCHK host from a config entry.""" hass.data.setdefault(DOMAIN, {}) if config_entry.entry_id in hass.data[DOMAIN]: @@ -149,9 +137,7 @@ async def async_setup_entry( return True -async def async_unload_entry( - hass: HomeAssistant, config_entry: config_entries.ConfigEntry -) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Close connection to PCHK host represented by config_entry.""" # forward unloading to platforms unload_ok = await hass.config_entries.async_unload_platforms( @@ -172,7 +158,7 @@ async def async_unload_entry( def async_host_input_received( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: ConfigEntry, device_registry: dr.DeviceRegistry, inp: pypck.inputs.Input, ) -> None: @@ -242,75 +228,3 @@ def _async_fire_send_keys_event( event_data.update({CONF_DEVICE_ID: device.id}) hass.bus.async_fire("lcn_send_keys", event_data) - - -class LcnEntity(Entity): - """Parent class for all entities associated with the LCN component.""" - - _attr_should_poll = False - - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: - """Initialize the LCN device.""" - self.config = config - self.entry_id = entry_id - self.device_connection = device_connection - self._unregister_for_inputs: Callable | None = None - self._name: str = config[CONF_NAME] - - @property - def address(self) -> AddressType: - """Return LCN address.""" - return ( - self.device_connection.seg_id, - self.device_connection.addr_id, - self.device_connection.is_group, - ) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return generate_unique_id( - self.entry_id, self.address, self.config[CONF_RESOURCE] - ) - - @property - def device_info(self) -> DeviceInfo | None: - """Return device specific attributes.""" - address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}" - model = ( - "LCN resource" - f" ({get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])})" - ) - - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - name=f"{address}.{self.config[CONF_RESOURCE]}", - model=model, - manufacturer="Issendorff", - via_device=( - DOMAIN, - generate_unique_id(self.entry_id, self.config[CONF_ADDRESS]), - ), - ) - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - if not self.device_connection.is_group: - self._unregister_for_inputs = self.device_connection.register_for_inputs( - self.input_received - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - if self._unregister_for_inputs is not None: - self._unregister_for_inputs() - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - def input_received(self, input_obj: InputType) -> None: - """Set state/value when LCN input object (command) is received.""" diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index a0f8e1cf360..106e74fd060 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -10,12 +10,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE +from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import LcnEntity from .const import ( ADD_ENTITIES_CALLBACKS, BINSENSOR_PORTS, @@ -23,11 +22,11 @@ from .const import ( DOMAIN, SETPOINTS, ) -from .helpers import DeviceConnectionType, InputType, get_device_connection +from .entity import LcnEntity +from .helpers import InputType def add_lcn_entities( - hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, entity_configs: Iterable[ConfigType], @@ -35,26 +34,12 @@ def add_lcn_entities( """Add entities for this domain.""" entities: list[LcnRegulatorLockSensor | LcnBinarySensor | LcnLockKeysSensor] = [] for entity_config in entity_configs: - device_connection = get_device_connection( - hass, entity_config[CONF_ADDRESS], config_entry - ) - if entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] in SETPOINTS: - entities.append( - LcnRegulatorLockSensor( - entity_config, config_entry.entry_id, device_connection - ) - ) + entities.append(LcnRegulatorLockSensor(entity_config, config_entry)) elif entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] in BINSENSOR_PORTS: - entities.append( - LcnBinarySensor(entity_config, config_entry.entry_id, device_connection) - ) + entities.append(LcnBinarySensor(entity_config, config_entry)) else: # in KEY - entities.append( - LcnLockKeysSensor( - entity_config, config_entry.entry_id, device_connection - ) - ) + entities.append(LcnLockKeysSensor(entity_config, config_entry)) async_add_entities(entities) @@ -67,7 +52,6 @@ async def async_setup_entry( """Set up LCN switch entities from a config entry.""" add_entities = partial( add_lcn_entities, - hass, config_entry, async_add_entities, ) @@ -88,11 +72,9 @@ async def async_setup_entry( class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): """Representation of a LCN binary sensor for regulator locks.""" - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN binary sensor.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.setpoint_variable = pypck.lcn_defs.Var[ config[CONF_DOMAIN_DATA][CONF_SOURCE] @@ -129,11 +111,9 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): class LcnBinarySensor(LcnEntity, BinarySensorEntity): """Representation of a LCN binary sensor for binary sensor ports.""" - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN binary sensor.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.bin_sensor_port = pypck.lcn_defs.BinSensorPort[ config[CONF_DOMAIN_DATA][CONF_SOURCE] @@ -167,11 +147,9 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): """Representation of a LCN sensor for key locks.""" - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN sensor.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.source = pypck.lcn_defs.Key[config[CONF_DOMAIN_DATA][CONF_SOURCE]] diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 0142894a16b..1c7472bc4e3 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -15,7 +15,6 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, - CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE, @@ -26,7 +25,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import LcnEntity from .const import ( ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, @@ -36,27 +34,21 @@ from .const import ( CONF_SETPOINT, DOMAIN, ) -from .helpers import DeviceConnectionType, InputType, get_device_connection +from .entity import LcnEntity +from .helpers import InputType PARALLEL_UPDATES = 0 def add_lcn_entities( - hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" - entities: list[LcnClimate] = [] - for entity_config in entity_configs: - device_connection = get_device_connection( - hass, entity_config[CONF_ADDRESS], config_entry - ) - - entities.append( - LcnClimate(entity_config, config_entry.entry_id, device_connection) - ) + entities = [ + LcnClimate(entity_config, config_entry) for entity_config in entity_configs + ] async_add_entities(entities) @@ -69,7 +61,6 @@ async def async_setup_entry( """Set up LCN switch entities from a config entry.""" add_entities = partial( add_lcn_entities, - hass, config_entry, async_add_entities, ) @@ -92,11 +83,9 @@ class LcnClimate(LcnEntity, ClimateEntity): _enable_turn_on_off_backwards_compatibility = False - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize of a LCN climate device.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.variable = pypck.lcn_defs.Var[config[CONF_DOMAIN_DATA][CONF_SOURCE]] self.setpoint = pypck.lcn_defs.Var[config[CONF_DOMAIN_DATA][CONF_SETPOINT]] diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index 1e428a350d6..042461b6af2 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -8,12 +8,11 @@ import pypck from homeassistant.components.cover import DOMAIN as DOMAIN_COVER, CoverEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES +from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import LcnEntity from .const import ( ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, @@ -21,13 +20,13 @@ from .const import ( CONF_REVERSE_TIME, DOMAIN, ) -from .helpers import DeviceConnectionType, InputType, get_device_connection +from .entity import LcnEntity +from .helpers import InputType PARALLEL_UPDATES = 0 def add_lcn_entities( - hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, entity_configs: Iterable[ConfigType], @@ -35,18 +34,10 @@ def add_lcn_entities( """Add entities for this domain.""" entities: list[LcnOutputsCover | LcnRelayCover] = [] for entity_config in entity_configs: - device_connection = get_device_connection( - hass, entity_config[CONF_ADDRESS], config_entry - ) - if entity_config[CONF_DOMAIN_DATA][CONF_MOTOR] in "OUTPUTS": - entities.append( - LcnOutputsCover(entity_config, config_entry.entry_id, device_connection) - ) + entities.append(LcnOutputsCover(entity_config, config_entry)) else: # in RELAYS - entities.append( - LcnRelayCover(entity_config, config_entry.entry_id, device_connection) - ) + entities.append(LcnRelayCover(entity_config, config_entry)) async_add_entities(entities) @@ -59,7 +50,6 @@ async def async_setup_entry( """Set up LCN cover entities from a config entry.""" add_entities = partial( add_lcn_entities, - hass, config_entry, async_add_entities, ) @@ -85,11 +75,9 @@ class LcnOutputsCover(LcnEntity, CoverEntity): _attr_is_opening = False _attr_assumed_state = True - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN cover.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.output_ids = [ pypck.lcn_defs.OutputPort["OUTPUTUP"].value, @@ -189,11 +177,9 @@ class LcnRelayCover(LcnEntity, CoverEntity): _attr_is_opening = False _attr_assumed_state = True - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN cover.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.motor = pypck.lcn_defs.MotorPort[config[CONF_DOMAIN_DATA][CONF_MOTOR]] self.motor_port_onoff = self.motor.value * 2 diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py new file mode 100644 index 00000000000..12d8f966801 --- /dev/null +++ b/homeassistant/components/lcn/entity.py @@ -0,0 +1,90 @@ +"""LCN parent entity class.""" + +from collections.abc import Callable + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME, CONF_RESOURCE +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_DOMAIN_DATA, DOMAIN +from .helpers import ( + AddressType, + DeviceConnectionType, + InputType, + generate_unique_id, + get_device_connection, + get_device_model, +) + + +class LcnEntity(Entity): + """Parent class for all entities associated with the LCN component.""" + + _attr_should_poll = False + device_connection: DeviceConnectionType + + def __init__( + self, + config: ConfigType, + config_entry: ConfigEntry, + ) -> None: + """Initialize the LCN device.""" + self.config = config + self.config_entry = config_entry + self.address: AddressType = config[CONF_ADDRESS] + self._unregister_for_inputs: Callable | None = None + self._name: str = config[CONF_NAME] + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return generate_unique_id( + self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE] + ) + + @property + def device_info(self) -> DeviceInfo | None: + """Return device specific attributes.""" + address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}" + model = ( + "LCN resource" + f" ({get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])})" + ) + + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=f"{address}.{self.config[CONF_RESOURCE]}", + model=model, + manufacturer="Issendorff", + via_device=( + DOMAIN, + generate_unique_id( + self.config_entry.entry_id, self.config[CONF_ADDRESS] + ), + ), + ) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self.device_connection = get_device_connection( + self.hass, self.config[CONF_ADDRESS], self.config_entry + ) + if not self.device_connection.is_group: + self._unregister_for_inputs = self.device_connection.register_for_inputs( + self.input_received + ) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._unregister_for_inputs is not None: + self._unregister_for_inputs() + + @property + def name(self) -> str: + """Return the name of the device.""" + return self._name + + def input_received(self, input_obj: InputType) -> None: + """Set state/value when LCN input object (command) is received.""" diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index fd8c59ad46f..70034e9020a 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -84,7 +84,7 @@ DOMAIN_LOOKUP = { def get_device_connection( hass: HomeAssistant, address: AddressType, config_entry: ConfigEntry -) -> DeviceConnectionType | None: +) -> DeviceConnectionType: """Return a lcn device_connection.""" host_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] addr = pypck.lcn_addr.LcnAddr(*address) diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 799ed0036d8..943e3c69acf 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -15,12 +15,11 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES +from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import LcnEntity from .const import ( ADD_ENTITIES_CALLBACKS, CONF_DIMMABLE, @@ -30,13 +29,13 @@ from .const import ( DOMAIN, OUTPUT_PORTS, ) -from .helpers import DeviceConnectionType, InputType, get_device_connection +from .entity import LcnEntity +from .helpers import InputType PARALLEL_UPDATES = 0 def add_lcn_entities( - hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, entity_configs: Iterable[ConfigType], @@ -44,18 +43,10 @@ def add_lcn_entities( """Add entities for this domain.""" entities: list[LcnOutputLight | LcnRelayLight] = [] for entity_config in entity_configs: - device_connection = get_device_connection( - hass, entity_config[CONF_ADDRESS], config_entry - ) - if entity_config[CONF_DOMAIN_DATA][CONF_OUTPUT] in OUTPUT_PORTS: - entities.append( - LcnOutputLight(entity_config, config_entry.entry_id, device_connection) - ) + entities.append(LcnOutputLight(entity_config, config_entry)) else: # in RELAY_PORTS - entities.append( - LcnRelayLight(entity_config, config_entry.entry_id, device_connection) - ) + entities.append(LcnRelayLight(entity_config, config_entry)) async_add_entities(entities) @@ -68,7 +59,6 @@ async def async_setup_entry( """Set up LCN light entities from a config entry.""" add_entities = partial( add_lcn_entities, - hass, config_entry, async_add_entities, ) @@ -93,11 +83,9 @@ class LcnOutputLight(LcnEntity, LightEntity): _attr_is_on = False _attr_brightness = 255 - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN light.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.output = pypck.lcn_defs.OutputPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] @@ -187,11 +175,9 @@ class LcnRelayLight(LcnEntity, LightEntity): _attr_supported_color_modes = {ColorMode.ONOFF} _attr_is_on = False - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN light.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py index 52ec0262b55..241493ec108 100644 --- a/homeassistant/components/lcn/scene.py +++ b/homeassistant/components/lcn/scene.py @@ -8,12 +8,11 @@ import pypck from homeassistant.components.scene import DOMAIN as DOMAIN_SCENE, Scene from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES, CONF_SCENE +from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SCENE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import LcnEntity from .const import ( ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, @@ -23,27 +22,20 @@ from .const import ( DOMAIN, OUTPUT_PORTS, ) -from .helpers import DeviceConnectionType, get_device_connection +from .entity import LcnEntity PARALLEL_UPDATES = 0 def add_lcn_entities( - hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" - entities: list[LcnScene] = [] - for entity_config in entity_configs: - device_connection = get_device_connection( - hass, entity_config[CONF_ADDRESS], config_entry - ) - - entities.append( - LcnScene(entity_config, config_entry.entry_id, device_connection) - ) + entities = [ + LcnScene(entity_config, config_entry) for entity_config in entity_configs + ] async_add_entities(entities) @@ -56,7 +48,6 @@ async def async_setup_entry( """Set up LCN switch entities from a config entry.""" add_entities = partial( add_lcn_entities, - hass, config_entry, async_add_entities, ) @@ -77,11 +68,9 @@ async def async_setup_entry( class LcnScene(LcnEntity, Scene): """Representation of a LCN scene.""" - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN scene.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.register_id = config[CONF_DOMAIN_DATA][CONF_REGISTER] self.scene_id = config[CONF_DOMAIN_DATA][CONF_SCENE] diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 7e8941a0bf9..341182c0639 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -10,7 +10,6 @@ import pypck from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE, @@ -20,7 +19,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import LcnEntity from .const import ( ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, @@ -31,11 +29,11 @@ from .const import ( THRESHOLDS, VARIABLES, ) -from .helpers import DeviceConnectionType, InputType, get_device_connection +from .entity import LcnEntity +from .helpers import InputType def add_lcn_entities( - hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, entity_configs: Iterable[ConfigType], @@ -43,24 +41,12 @@ def add_lcn_entities( """Add entities for this domain.""" entities: list[LcnVariableSensor | LcnLedLogicSensor] = [] for entity_config in entity_configs: - device_connection = get_device_connection( - hass, entity_config[CONF_ADDRESS], config_entry - ) - if entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] in chain( VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS ): - entities.append( - LcnVariableSensor( - entity_config, config_entry.entry_id, device_connection - ) - ) + entities.append(LcnVariableSensor(entity_config, config_entry)) else: # in LED_PORTS + LOGICOP_PORTS - entities.append( - LcnLedLogicSensor( - entity_config, config_entry.entry_id, device_connection - ) - ) + entities.append(LcnLedLogicSensor(entity_config, config_entry)) async_add_entities(entities) @@ -73,7 +59,6 @@ async def async_setup_entry( """Set up LCN switch entities from a config entry.""" add_entities = partial( add_lcn_entities, - hass, config_entry, async_add_entities, ) @@ -94,11 +79,9 @@ async def async_setup_entry( class LcnVariableSensor(LcnEntity, SensorEntity): """Representation of a LCN sensor for variables.""" - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN sensor.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.variable = pypck.lcn_defs.Var[config[CONF_DOMAIN_DATA][CONF_SOURCE]] self.unit = pypck.lcn_defs.VarUnit.parse( @@ -133,11 +116,9 @@ class LcnVariableSensor(LcnEntity, SensorEntity): class LcnLedLogicSensor(LcnEntity, SensorEntity): """Representation of a LCN sensor for leds and logicops.""" - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN sensor.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) if config[CONF_DOMAIN_DATA][CONF_SOURCE] in LED_PORTS: self.source = pypck.lcn_defs.LedPort[config[CONF_DOMAIN_DATA][CONF_SOURCE]] diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index 4c316cef547..6ad5977855e 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -8,12 +8,11 @@ import pypck from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES +from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import LcnEntity from .const import ( ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, @@ -21,13 +20,13 @@ from .const import ( DOMAIN, OUTPUT_PORTS, ) -from .helpers import DeviceConnectionType, InputType, get_device_connection +from .entity import LcnEntity +from .helpers import InputType PARALLEL_UPDATES = 0 def add_lcn_switch_entities( - hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, entity_configs: Iterable[ConfigType], @@ -35,18 +34,10 @@ def add_lcn_switch_entities( """Add entities for this domain.""" entities: list[LcnOutputSwitch | LcnRelaySwitch] = [] for entity_config in entity_configs: - device_connection = get_device_connection( - hass, entity_config[CONF_ADDRESS], config_entry - ) - if entity_config[CONF_DOMAIN_DATA][CONF_OUTPUT] in OUTPUT_PORTS: - entities.append( - LcnOutputSwitch(entity_config, config_entry.entry_id, device_connection) - ) + entities.append(LcnOutputSwitch(entity_config, config_entry)) else: # in RELAY_PORTS - entities.append( - LcnRelaySwitch(entity_config, config_entry.entry_id, device_connection) - ) + entities.append(LcnRelaySwitch(entity_config, config_entry)) async_add_entities(entities) @@ -59,7 +50,6 @@ async def async_setup_entry( """Set up LCN switch entities from a config entry.""" add_entities = partial( add_lcn_switch_entities, - hass, config_entry, async_add_entities, ) @@ -82,11 +72,9 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): _attr_is_on = False - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN switch.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.output = pypck.lcn_defs.OutputPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] @@ -133,11 +121,9 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): _attr_is_on = False - def __init__( - self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType - ) -> None: + def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: """Initialize the LCN switch.""" - super().__init__(config, entry_id, device_connection) + super().__init__(config, config_entry) self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] From 15e5851383f7f53cf3e315a1c4c272f274c1a027 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 10 Sep 2024 20:38:45 +0200 Subject: [PATCH 0502/1309] Extend deprecation period for hass.components by 6 months (#125659) --- homeassistant/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 90b88ba2109..f248a942be9 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1557,7 +1557,7 @@ class Components: report( ( f"accesses hass.components.{comp_name}." - " This is deprecated and will stop working in Home Assistant 2024.9, it" + " This is deprecated and will stop working in Home Assistant 2025.3, it" f" should be updated to import functions used from {comp_name} directly" ), error_if_core=False, From 3536ba43f540b29e0a210f57dd09ac99aead0ac5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 10 Sep 2024 20:39:51 +0200 Subject: [PATCH 0503/1309] End deprecation setting disabled_by as string (#125646) --- homeassistant/config_entries.py | 37 +++++++------------- tests/components/analytics/test_analytics.py | 4 +-- tests/test_config_entries.py | 23 ++++++------ 3 files changed, 25 insertions(+), 39 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7870964722f..797fcc5f345 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -271,6 +271,16 @@ class ConfigFlowResult(FlowResult, total=False): version: int +def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> None: + """Validate config entry item.""" + + # Deprecated in 2022.1, stopped working in 2024.10 + if disabled_by is not None and not isinstance(disabled_by, ConfigEntryDisabler): + raise TypeError( + f"disabled_by must be a ConfigEntryDisabler value, got {disabled_by}" + ) + + class ConfigEntry(Generic[_DataT]): """Hold a configuration entry.""" @@ -369,18 +379,7 @@ class ConfigEntry(Generic[_DataT]): _setter(self, "unique_id", unique_id) # Config entry is disabled - if isinstance(disabled_by, str) and not isinstance( - disabled_by, ConfigEntryDisabler - ): - report( # type: ignore[unreachable] - ( - "uses str for config entry disabled_by. This is deprecated and will" - " stop working in Home Assistant 2022.3, it should be updated to" - " use ConfigEntryDisabler instead" - ), - error_if_core=False, - ) - disabled_by = ConfigEntryDisabler(disabled_by) + _validate_item(disabled_by=disabled_by) _setter(self, "disabled_by", disabled_by) # Supports unload @@ -1958,19 +1957,7 @@ class ConfigEntries: if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry - if isinstance(disabled_by, str) and not isinstance( - disabled_by, ConfigEntryDisabler - ): - report( # type: ignore[unreachable] - ( - "uses str for config entry disabled_by. This is deprecated and will" - " stop working in Home Assistant 2022.3, it should be updated to" - " use ConfigEntryDisabler instead" - ), - error_if_core=False, - ) - disabled_by = ConfigEntryDisabler(disabled_by) - + _validate_item(disabled_by=disabled_by) if entry.disabled_by is disabled_by: return True diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 28272cd8866..4b4fdc159de 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -19,7 +19,7 @@ from homeassistant.components.analytics.const import ( ATTR_STATISTICS, ATTR_USAGE, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import IntegrationNotFound @@ -863,7 +863,7 @@ async def test_not_check_config_entries_if_yaml( domain="ignored_integration", state=ConfigEntryState.LOADED, source="ignore", - disabled_by="user", + disabled_by=ConfigEntryDisabler.USER, ) mock_config_entry.add_to_hass(hass) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index abe8ab83952..faa1c4c5bcc 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4297,29 +4297,28 @@ async def test_loading_old_data( assert entry.pref_disable_new_entities is True -async def test_deprecated_disabled_by_str_ctor( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +async def test_deprecated_disabled_by_str_ctor() -> None: """Test deprecated str disabled_by constructor enumizes and logs a warning.""" - entry = MockConfigEntry(disabled_by=config_entries.ConfigEntryDisabler.USER.value) - assert entry.disabled_by is config_entries.ConfigEntryDisabler.USER - assert " str for config entry disabled_by. This is deprecated " in caplog.text + with pytest.raises( + TypeError, match="disabled_by must be a ConfigEntryDisabler value, got user" + ): + MockConfigEntry(disabled_by=config_entries.ConfigEntryDisabler.USER.value) async def test_deprecated_disabled_by_str_set( hass: HomeAssistant, manager: config_entries.ConfigEntries, - caplog: pytest.LogCaptureFixture, ) -> None: """Test deprecated str set disabled_by enumizes and logs a warning.""" entry = MockConfigEntry(domain="comp") entry.add_to_manager(manager) hass.config.components.add("comp") - assert await manager.async_set_disabled_by( - entry.entry_id, config_entries.ConfigEntryDisabler.USER.value - ) - assert entry.disabled_by is config_entries.ConfigEntryDisabler.USER - assert " str for config entry disabled_by. This is deprecated " in caplog.text + with pytest.raises( + TypeError, match="disabled_by must be a ConfigEntryDisabler value, got user" + ): + await manager.async_set_disabled_by( + entry.entry_id, config_entries.ConfigEntryDisabler.USER.value + ) async def test_entry_reload_concurrency( From 44ca43c7ee0817cb9086c1c16b44064bf6dc7f2d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 10 Sep 2024 20:41:04 +0200 Subject: [PATCH 0504/1309] Add pylint check for DOMAIN alias (#125559) --- pylint/plugins/hass_imports.py | 38 +++++++++++++++++-------- tests/pylint/test_imports.py | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 57b71560b53..afe307dce42 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -460,6 +460,11 @@ class HassImportsFormatChecker(BaseChecker): "hass-helper-namespace-import", "Used when a helper should be used via the namespace", ), + "W7426": ( + "`%s` should be imported using an alias, such as `%s as %s`", + "hass-import-constant-alias", + "Used when a constant should be imported as an alias", + ), } options = () @@ -540,19 +545,30 @@ class HassImportsFormatChecker(BaseChecker): if node.modname.startswith(f"{root}.components.{current_component}."): self.add_message("hass-relative-import", node=node) return - if node.modname.startswith("homeassistant.components.") and ( - node.modname.endswith(".const") - or "const" in {names[0] for names in node.names} + + if node.modname.startswith("homeassistant.components.") and not ( + self.current_package.startswith("tests.components.") + and self.current_package.split(".")[2] == node.modname.split(".")[2] ): - if ( - self.current_package.startswith("tests.components.") - and self.current_package.split(".")[2] == node.modname.split(".")[2] - ): - # Ignore check if the component being tested matches - # the component being imported from + if node.modname.endswith(".const"): + self.add_message("hass-component-root-import", node=node) return - self.add_message("hass-component-root-import", node=node) - return + for name, alias in node.names: + if name == "const": + self.add_message("hass-component-root-import", node=node) + return + if name == "DOMAIN" and (alias is None or alias == "DOMAIN"): + self.add_message( + "hass-import-constant-alias", + node=node, + args=( + "DOMAIN", + "DOMAIN", + f"{node.modname.split(".")[2].upper()}_DOMAIN", + ), + ) + return + if obsolete_imports := _OBSOLETE_IMPORT.get(node.modname): for name_tuple in node.names: for obsolete_import in obsolete_imports: diff --git a/tests/pylint/test_imports.py b/tests/pylint/test_imports.py index e53b8206848..980b9ead74c 100644 --- a/tests/pylint/test_imports.py +++ b/tests/pylint/test_imports.py @@ -309,3 +309,54 @@ def test_bad_namespace_import( ), ): imports_checker.visit_importfrom(node) + + +@pytest.mark.parametrize( + ("module_name", "import_string", "end_col_offset"), + [ + ( + "homeassistant.components.pylint_test.sensor", + "from homeassistant.components.other import DOMAIN as OTHER_DOMAIN", + -1, + ), + ( + "homeassistant.components.pylint_test.sensor", + "from homeassistant.components.other import DOMAIN", + 49, + ), + ], +) +def test_domain_alias( + linter: UnittestLinter, + imports_checker: BaseChecker, + module_name: str, + import_string: str, + end_col_offset: int, +) -> None: + """Ensure good imports pass through ok.""" + + import_node = astroid.extract_node( + f"{import_string} #@", + module_name, + ) + imports_checker.visit_module(import_node.parent) + + expected_messages = [] + if end_col_offset > 0: + expected_messages.append( + pylint.testutils.MessageTest( + msg_id="hass-import-constant-alias", + node=import_node, + args=("DOMAIN", "DOMAIN", "OTHER_DOMAIN"), + line=1, + col_offset=0, + end_line=1, + end_col_offset=end_col_offset, + ) + ) + + with assert_adds_messages(linter, *expected_messages): + if import_string.startswith("import"): + imports_checker.visit_import(import_node) + else: + imports_checker.visit_importfrom(import_node) From 40ee39f25800817d89b64f1db714771fb73015a1 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 10 Sep 2024 19:52:10 +0100 Subject: [PATCH 0505/1309] Update tplink config to include aes keys (#125685) --- homeassistant/components/tplink/__init__.py | 116 ++-- .../components/tplink/config_flow.py | 77 +-- homeassistant/components/tplink/const.py | 6 +- tests/components/tplink/__init__.py | 46 +- tests/components/tplink/conftest.py | 10 +- tests/components/tplink/test_config_flow.py | 548 +++++++++++------- tests/components/tplink/test_init.py | 117 +++- 7 files changed, 598 insertions(+), 322 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 83cfc733716..ceeb1120ed8 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -26,6 +26,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ALIAS, CONF_AUTHENTICATION, + CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, @@ -44,8 +45,12 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from .const import ( + CONF_AES_KEYS, + CONF_CONFIG_ENTRY_MINOR_VERSION, + CONF_CONNECTION_PARAMETERS, CONF_CREDENTIALS_HASH, CONF_DEVICE_CONFIG, + CONF_USES_HTTP, CONNECT_TIMEOUT, DISCOVERY_TIMEOUT, DOMAIN, @@ -85,9 +90,7 @@ def async_trigger_discovery( CONF_ALIAS: device.alias or mac_alias(device.mac), CONF_HOST: device.host, CONF_MAC: formatted_mac, - CONF_DEVICE_CONFIG: device.config.to_dict( - exclude_credentials=True, - ), + CONF_DEVICE: device, }, ) @@ -136,25 +139,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo host: str = entry.data[CONF_HOST] credentials = await get_credentials(hass) entry_credentials_hash = entry.data.get(CONF_CREDENTIALS_HASH) + entry_use_http = entry.data.get(CONF_USES_HTTP, False) + entry_aes_keys = entry.data.get(CONF_AES_KEYS) - config: DeviceConfig | None = None - if config_dict := entry.data.get(CONF_DEVICE_CONFIG): + conn_params: Device.ConnectionParameters | None = None + if conn_params_dict := entry.data.get(CONF_CONNECTION_PARAMETERS): try: - config = DeviceConfig.from_dict(config_dict) + conn_params = Device.ConnectionParameters.from_dict(conn_params_dict) except KasaException: _LOGGER.warning( - "Invalid connection type dict for %s: %s", host, config_dict + "Invalid connection parameters dict for %s: %s", host, conn_params_dict ) - if not config: - config = DeviceConfig(host) - else: - config.host = host - - config.timeout = CONNECT_TIMEOUT - if config.uses_http is True: - config.http_client = create_async_tplink_clientsession(hass) - + client = create_async_tplink_clientsession(hass) if entry_use_http else None + config = DeviceConfig( + host, + timeout=CONNECT_TIMEOUT, + http_client=client, + aes_keys=entry_aes_keys, + ) + if conn_params: + config.connection_type = conn_params # If we have in memory credentials use them otherwise check for credentials_hash if credentials: config.credentials = credentials @@ -173,14 +178,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TPLinkConfigEntry) -> bo raise ConfigEntryNotReady from ex device_credentials_hash = device.credentials_hash - device_config_dict = device.config.to_dict(exclude_credentials=True) - # Do not store the credentials hash inside the device_config - device_config_dict.pop(CONF_CREDENTIALS_HASH, None) + + # We not need to update the connection parameters or the use_http here + # because if they were wrong we would have failed to connect. + # Discovery will update those if necessary. updates: dict[str, Any] = {} if device_credentials_hash and device_credentials_hash != entry_credentials_hash: updates[CONF_CREDENTIALS_HASH] = device_credentials_hash - if device_config_dict != config_dict: - updates[CONF_DEVICE_CONFIG] = device_config_dict + if entry_aes_keys != device.config.aes_keys: + updates[CONF_AES_KEYS] = device.config.aes_keys if entry.data.get(CONF_ALIAS) != device.alias: updates[CONF_ALIAS] = device.alias if entry.data.get(CONF_MODEL) != device.model: @@ -307,12 +313,20 @@ def _device_id_is_mac_or_none(mac: str, device_ids: Iterable[str]) -> str | None async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" - version = config_entry.version - minor_version = config_entry.minor_version + entry_version = config_entry.version + entry_minor_version = config_entry.minor_version + # having a condition to check for the current version allows + # tests to be written per migration step. + config_flow_minor_version = CONF_CONFIG_ENTRY_MINOR_VERSION - _LOGGER.debug("Migrating from version %s.%s", version, minor_version) - - if version == 1 and minor_version < 3: + new_minor_version = 3 + if ( + entry_version == 1 + and entry_minor_version < new_minor_version <= config_flow_minor_version + ): + _LOGGER.debug( + "Migrating from version %s.%s", entry_version, entry_minor_version + ) # Previously entities on child devices added themselves to the parent # device and set their device id as identifiers along with mac # as a connection which creates a single device entry linked by all @@ -359,12 +373,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> new_identifiers, ) - minor_version = 3 - hass.config_entries.async_update_entry(config_entry, minor_version=3) + hass.config_entries.async_update_entry( + config_entry, minor_version=new_minor_version + ) - _LOGGER.debug("Migration to version %s.%s complete", version, minor_version) + _LOGGER.debug( + "Migration to version %s.%s complete", entry_version, new_minor_version + ) - if version == 1 and minor_version == 3: + new_minor_version = 4 + if ( + entry_version == 1 + and entry_minor_version < new_minor_version <= config_flow_minor_version + ): # credentials_hash stored in the device_config should be moved to data. updates: dict[str, Any] = {} if config_dict := config_entry.data.get(CONF_DEVICE_CONFIG): @@ -372,15 +393,44 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if credentials_hash := config_dict.pop(CONF_CREDENTIALS_HASH, None): updates[CONF_CREDENTIALS_HASH] = credentials_hash updates[CONF_DEVICE_CONFIG] = config_dict - minor_version = 4 hass.config_entries.async_update_entry( config_entry, data={ **config_entry.data, **updates, }, - minor_version=minor_version, + minor_version=new_minor_version, + ) + _LOGGER.debug( + "Migration to version %s.%s complete", entry_version, new_minor_version ) - _LOGGER.debug("Migration to version %s.%s complete", version, minor_version) + new_minor_version = 5 + if ( + entry_version == 1 + and entry_minor_version < new_minor_version <= config_flow_minor_version + ): + # complete device config no longer to be stored, only required + # attributes like connection parameters and aes_keys + updates = {} + entry_data = { + k: v for k, v in config_entry.data.items() if k != CONF_DEVICE_CONFIG + } + if config_dict := config_entry.data.get(CONF_DEVICE_CONFIG): + assert isinstance(config_dict, dict) + if connection_parameters := config_dict.get("connection_type"): + updates[CONF_CONNECTION_PARAMETERS] = connection_parameters + if (use_http := config_dict.get(CONF_USES_HTTP)) is not None: + updates[CONF_USES_HTTP] = use_http + hass.config_entries.async_update_entry( + config_entry, + data={ + **entry_data, + **updates, + }, + minor_version=new_minor_version, + ) + _LOGGER.debug( + "Migration to version %s.%s complete", entry_version, new_minor_version + ) return True diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py index 1c02466aef1..03234d545b5 100644 --- a/homeassistant/components/tplink/config_flow.py +++ b/homeassistant/components/tplink/config_flow.py @@ -46,9 +46,11 @@ from . import ( set_credentials, ) from .const import ( - CONF_CONNECTION_TYPE, + CONF_AES_KEYS, + CONF_CONFIG_ENTRY_MINOR_VERSION, + CONF_CONNECTION_PARAMETERS, CONF_CREDENTIALS_HASH, - CONF_DEVICE_CONFIG, + CONF_USES_HTTP, CONNECT_TIMEOUT, DOMAIN, ) @@ -64,7 +66,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for tplink.""" VERSION = 1 - MINOR_VERSION = 4 + MINOR_VERSION = CONF_CONFIG_ENTRY_MINOR_VERSION reauth_entry: ConfigEntry | None = None def __init__(self) -> None: @@ -87,38 +89,43 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): return await self._async_handle_discovery( discovery_info[CONF_HOST], discovery_info[CONF_MAC], - discovery_info[CONF_DEVICE_CONFIG], + discovery_info[CONF_DEVICE], ) @callback def _get_config_updates( - self, entry: ConfigEntry, host: str, config: dict + self, entry: ConfigEntry, host: str, device: Device | None ) -> dict | None: """Return updates if the host or device config has changed.""" entry_data = entry.data - entry_config_dict = entry_data.get(CONF_DEVICE_CONFIG) - if entry_config_dict == config and entry_data[CONF_HOST] == host: + updates: dict[str, Any] = {} + new_connection_params = False + if entry_data[CONF_HOST] != host: + updates[CONF_HOST] = host + if device: + device_conn_params_dict = device.config.connection_type.to_dict() + entry_conn_params_dict = entry_data.get(CONF_CONNECTION_PARAMETERS) + if device_conn_params_dict != entry_conn_params_dict: + new_connection_params = True + updates[CONF_CONNECTION_PARAMETERS] = device_conn_params_dict + updates[CONF_USES_HTTP] = device.config.uses_http + if not updates: return None - updates = {**entry.data, CONF_DEVICE_CONFIG: config, CONF_HOST: host} + updates = {**entry.data, **updates} # If the connection parameters have changed the credentials_hash will be invalid. - if ( - entry_config_dict - and isinstance(entry_config_dict, dict) - and entry_config_dict.get(CONF_CONNECTION_TYPE) - != config.get(CONF_CONNECTION_TYPE) - ): + if new_connection_params: updates.pop(CONF_CREDENTIALS_HASH, None) _LOGGER.debug( "Connection type changed for %s from %s to: %s", host, - entry_config_dict.get(CONF_CONNECTION_TYPE), - config.get(CONF_CONNECTION_TYPE), + entry_conn_params_dict, + device_conn_params_dict, ) return updates @callback def _update_config_if_entry_in_setup_error( - self, entry: ConfigEntry, host: str, config: dict + self, entry: ConfigEntry, host: str, device: Device | None ) -> ConfigFlowResult | None: """If discovery encounters a device that is in SETUP_ERROR or SETUP_RETRY update the device config.""" if entry.state not in ( @@ -126,7 +133,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): ConfigEntryState.SETUP_RETRY, ): return None - if updates := self._get_config_updates(entry, host, config): + if updates := self._get_config_updates(entry, host, device): return self.async_update_reload_and_abort( entry, data=updates, @@ -135,19 +142,15 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): return None async def _async_handle_discovery( - self, host: str, formatted_mac: str, config: dict | None = None + self, host: str, formatted_mac: str, device: Device | None = None ) -> ConfigFlowResult: """Handle any discovery.""" current_entry = await self.async_set_unique_id( formatted_mac, raise_on_progress=False ) - if ( - config - and current_entry - and ( - result := self._update_config_if_entry_in_setup_error( - current_entry, host, config - ) + if current_entry and ( + result := self._update_config_if_entry_in_setup_error( + current_entry, host, device ) ): return result @@ -159,9 +162,13 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_in_progress") credentials = await get_credentials(self.hass) try: - await self._async_try_discover_and_update( - host, credentials, raise_on_progress=True - ) + if device: + self._discovered_device = device + await self._async_try_connect(device, credentials) + else: + await self._async_try_discover_and_update( + host, credentials, raise_on_progress=True + ) except AuthenticationError: return await self.async_step_discovery_auth_confirm() except KasaException: @@ -381,14 +388,15 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): # This is only ever called after a successful device update so we know that # the credential_hash is correct and should be saved. self._abort_if_unique_id_configured(updates={CONF_HOST: device.host}) - data = { + data: dict[str, Any] = { CONF_HOST: device.host, CONF_ALIAS: device.alias, CONF_MODEL: device.model, - CONF_DEVICE_CONFIG: device.config.to_dict( - exclude_credentials=True, - ), + CONF_CONNECTION_PARAMETERS: device.config.connection_type.to_dict(), + CONF_USES_HTTP: device.config.uses_http, } + if device.config.aes_keys: + data[CONF_AES_KEYS] = device.config.aes_keys if device.credentials_hash: data[CONF_CREDENTIALS_HASH] = device.credentials_hash return self.async_create_entry( @@ -494,8 +502,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN): placeholders["error"] = str(ex) else: await set_credentials(self.hass, username, password) - config = device.config.to_dict(exclude_credentials=True) - if updates := self._get_config_updates(reauth_entry, host, config): + if updates := self._get_config_updates(reauth_entry, host, device): self.hass.config_entries.async_update_entry( reauth_entry, data=updates ) diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index babd92e2c34..91085edb5a2 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -21,7 +21,11 @@ ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" CONF_DEVICE_CONFIG: Final = "device_config" CONF_CREDENTIALS_HASH: Final = "credentials_hash" -CONF_CONNECTION_TYPE: Final = "connection_type" +CONF_CONNECTION_PARAMETERS: Final = "connection_parameters" +CONF_USES_HTTP: Final = "uses_http" +CONF_AES_KEYS: Final = "aes_keys" + +CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 5 PLATFORMS: Final = [ Platform.BINARY_SENSOR, diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index c63ca9139f1..93c3a35a2e9 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -21,11 +21,13 @@ from kasa.protocol import BaseProtocol from syrupy import SnapshotAssertion from homeassistant.components.tplink import ( + CONF_AES_KEYS, CONF_ALIAS, + CONF_CONNECTION_PARAMETERS, CONF_CREDENTIALS_HASH, - CONF_DEVICE_CONFIG, CONF_HOST, CONF_MODEL, + CONF_USES_HTTP, Credentials, ) from homeassistant.components.tplink.const import DOMAIN @@ -54,35 +56,42 @@ DHCP_FORMATTED_MAC_ADDRESS = MAC_ADDRESS.replace(":", "") MAC_ADDRESS2 = "11:22:33:44:55:66" DEFAULT_ENTRY_TITLE = f"{ALIAS} {MODEL}" CREDENTIALS_HASH_LEGACY = "" +CONN_PARAMS_LEGACY = DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor +) DEVICE_CONFIG_LEGACY = DeviceConfig(IP_ADDRESS) DEVICE_CONFIG_DICT_LEGACY = DEVICE_CONFIG_LEGACY.to_dict(exclude_credentials=True) CREDENTIALS = Credentials("foo", "bar") CREDENTIALS_HASH_AES = "AES/abcdefghijklmnopqrstuvabcdefghijklmnopqrstuv==" CREDENTIALS_HASH_KLAP = "KLAP/abcdefghijklmnopqrstuv==" +CONN_PARAMS_KLAP = DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap +) DEVICE_CONFIG_KLAP = DeviceConfig( IP_ADDRESS, credentials=CREDENTIALS, - connection_type=DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap - ), + connection_type=CONN_PARAMS_KLAP, uses_http=True, ) +CONN_PARAMS_AES = DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes +) +AES_KEYS = {"private": "foo", "public": "bar"} DEVICE_CONFIG_AES = DeviceConfig( IP_ADDRESS2, credentials=CREDENTIALS, - connection_type=DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes - ), + connection_type=CONN_PARAMS_AES, uses_http=True, + aes_keys=AES_KEYS, ) DEVICE_CONFIG_DICT_KLAP = DEVICE_CONFIG_KLAP.to_dict(exclude_credentials=True) DEVICE_CONFIG_DICT_AES = DEVICE_CONFIG_AES.to_dict(exclude_credentials=True) - CREATE_ENTRY_DATA_LEGACY = { CONF_HOST: IP_ADDRESS, CONF_ALIAS: ALIAS, CONF_MODEL: MODEL, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_LEGACY.to_dict(), + CONF_USES_HTTP: False, } CREATE_ENTRY_DATA_KLAP = { @@ -90,23 +99,18 @@ CREATE_ENTRY_DATA_KLAP = { CONF_ALIAS: ALIAS, CONF_MODEL: MODEL, CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_KLAP, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_KLAP, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_KLAP.to_dict(), + CONF_USES_HTTP: True, } CREATE_ENTRY_DATA_AES = { CONF_HOST: IP_ADDRESS2, CONF_ALIAS: ALIAS, CONF_MODEL: MODEL, CONF_CREDENTIALS_HASH: CREDENTIALS_HASH_AES, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_AES, + CONF_CONNECTION_PARAMETERS: CONN_PARAMS_AES.to_dict(), + CONF_USES_HTTP: True, + CONF_AES_KEYS: AES_KEYS, } -CONNECTION_TYPE_KLAP = DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap -) -CONNECTION_TYPE_KLAP_DICT = CONNECTION_TYPE_KLAP.to_dict() -CONNECTION_TYPE_AES = DeviceConnectionParameters( - DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Aes -) -CONNECTION_TYPE_AES_DICT = CONNECTION_TYPE_AES.to_dict() def _load_feature_fixtures(): @@ -452,11 +456,11 @@ MODULE_TO_MOCK_GEN = { } -def _patch_discovery(device=None, no_device=False): +def _patch_discovery(device=None, no_device=False, ip_address=IP_ADDRESS): async def _discovery(*args, **kwargs): if no_device: return {} - return {IP_ADDRESS: _mocked_device()} + return {ip_address: device if device else _mocked_device()} return patch("homeassistant.components.tplink.Discover.discover", new=_discovery) diff --git a/tests/components/tplink/conftest.py b/tests/components/tplink/conftest.py index ee4530575ce..f1586ee4a0a 100644 --- a/tests/components/tplink/conftest.py +++ b/tests/components/tplink/conftest.py @@ -1,9 +1,9 @@ """tplink conftest.""" from collections.abc import Generator -import copy from unittest.mock import DEFAULT, AsyncMock, patch +from kasa import DeviceConfig import pytest from homeassistant.components.tplink import DOMAIN @@ -34,13 +34,13 @@ def mock_discovery(): discover_single=DEFAULT, ) as mock_discovery: device = _mocked_device( - device_config=copy.deepcopy(DEVICE_CONFIG_KLAP), + device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()), credentials_hash=CREDENTIALS_HASH_KLAP, alias=None, ) devices = { "127.0.0.1": _mocked_device( - device_config=copy.deepcopy(DEVICE_CONFIG_KLAP), + device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()), credentials_hash=CREDENTIALS_HASH_KLAP, alias=None, ) @@ -57,12 +57,12 @@ def mock_connect(): with patch("homeassistant.components.tplink.Device.connect") as mock_connect: devices = { IP_ADDRESS: _mocked_device( - device_config=DEVICE_CONFIG_KLAP, + device_config=DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()), credentials_hash=CREDENTIALS_HASH_KLAP, ip_address=IP_ADDRESS, ), IP_ADDRESS2: _mocked_device( - device_config=DEVICE_CONFIG_AES, + device_config=DeviceConfig.from_dict(DEVICE_CONFIG_AES.to_dict()), credentials_hash=CREDENTIALS_HASH_AES, mac=MAC_ADDRESS2, ip_address=IP_ADDRESS2, diff --git a/tests/components/tplink/test_config_flow.py b/tests/components/tplink/test_config_flow.py index f90eb985d38..7b24769c858 100644 --- a/tests/components/tplink/test_config_flow.py +++ b/tests/components/tplink/test_config_flow.py @@ -1,5 +1,6 @@ """Test the tplink config flow.""" +from contextlib import contextmanager import logging from unittest.mock import AsyncMock, patch @@ -17,7 +18,7 @@ from homeassistant.components.tplink import ( KasaException, ) from homeassistant.components.tplink.const import ( - CONF_CONNECTION_TYPE, + CONF_CONNECTION_PARAMETERS, CONF_CREDENTIALS_HASH, CONF_DEVICE_CONFIG, ) @@ -34,17 +35,21 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( + AES_KEYS, ALIAS, - CONNECTION_TYPE_KLAP_DICT, + CONN_PARAMS_AES, + CONN_PARAMS_KLAP, + CONN_PARAMS_LEGACY, CREATE_ENTRY_DATA_AES, CREATE_ENTRY_DATA_KLAP, CREATE_ENTRY_DATA_LEGACY, CREDENTIALS_HASH_AES, CREDENTIALS_HASH_KLAP, DEFAULT_ENTRY_TITLE, - DEVICE_CONFIG_DICT_AES, + DEVICE_CONFIG_AES, DEVICE_CONFIG_DICT_KLAP, - DEVICE_CONFIG_DICT_LEGACY, + DEVICE_CONFIG_KLAP, + DEVICE_CONFIG_LEGACY, DHCP_FORMATTED_MAC_ADDRESS, IP_ADDRESS, MAC_ADDRESS, @@ -59,9 +64,44 @@ from . import ( from tests.common import MockConfigEntry -async def test_discovery(hass: HomeAssistant) -> None: +@contextmanager +def override_side_effect(mock: AsyncMock, effect): + """Temporarily override a mock side effect and replace afterwards.""" + try: + default_side_effect = mock.side_effect + mock.side_effect = effect + yield mock + finally: + mock.side_effect = default_side_effect + + +@pytest.mark.parametrize( + ("device_config", "expected_entry_data", "credentials_hash"), + [ + pytest.param( + DEVICE_CONFIG_KLAP, CREATE_ENTRY_DATA_KLAP, CREDENTIALS_HASH_KLAP, id="KLAP" + ), + pytest.param( + DEVICE_CONFIG_AES, CREATE_ENTRY_DATA_AES, CREDENTIALS_HASH_AES, id="AES" + ), + pytest.param(DEVICE_CONFIG_LEGACY, CREATE_ENTRY_DATA_LEGACY, None, id="Legacy"), + ], +) +async def test_discovery( + hass: HomeAssistant, device_config, expected_entry_data, credentials_hash +) -> None: """Test setting up discovery.""" - with _patch_discovery(), _patch_single_discovery(), _patch_connect(): + ip_address = device_config.host + device = _mocked_device( + device_config=device_config, + credentials_hash=credentials_hash, + ip_address=ip_address, + ) + with ( + _patch_discovery(device, ip_address=ip_address), + _patch_single_discovery(device), + _patch_connect(device), + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -91,9 +131,9 @@ async def test_discovery(hass: HomeAssistant) -> None: assert not result2["errors"] with ( - _patch_discovery(), - _patch_single_discovery(), - _patch_connect(), + _patch_discovery(device, ip_address=ip_address), + _patch_single_discovery(device), + _patch_connect(device), patch(f"{MODULE}.async_setup", return_value=True) as mock_setup, patch(f"{MODULE}.async_setup_entry", return_value=True) as mock_setup_entry, ): @@ -105,7 +145,7 @@ async def test_discovery(hass: HomeAssistant) -> None: assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["title"] == DEFAULT_ENTRY_TITLE - assert result3["data"] == CREATE_ENTRY_DATA_LEGACY + assert result3["data"] == expected_entry_data mock_setup.assert_called_once() mock_setup_entry.assert_called_once() @@ -130,24 +170,25 @@ async def test_discovery_auth( ) -> None: """Test authenticated discovery.""" - mock_discovery["mock_device"].update.side_effect = AuthenticationError + mock_device = mock_connect["mock_devices"][IP_ADDRESS] + assert mock_device.config == DEVICE_CONFIG_KLAP - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_HOST: IP_ADDRESS, - CONF_MAC: MAC_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_KLAP, - }, - ) + with override_side_effect(mock_connect["connect"], AuthenticationError): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE: mock_device, + }, + ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_auth_confirm" assert not result["errors"] - mock_discovery["mock_device"].update.reset_mock(side_effect=True) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -172,40 +213,43 @@ async def test_discovery_auth( ) async def test_discovery_auth_errors( hass: HomeAssistant, - mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init, error_type, errors_msg, error_placement, ) -> None: - """Test handling of discovery authentication errors.""" - mock_discovery["mock_device"].update.side_effect = AuthenticationError - default_connect_side_effect = mock_connect["connect"].side_effect - mock_connect["connect"].side_effect = error_type + """Test handling of discovery authentication errors. - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_HOST: IP_ADDRESS, - CONF_MAC: MAC_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_KLAP, - }, - ) - await hass.async_block_till_done() + Tests for errors received during credential + entry during discovery_auth_confirm. + """ + mock_device = mock_connect["mock_devices"][IP_ADDRESS] + + with override_side_effect(mock_connect["connect"], AuthenticationError): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE: mock_device, + }, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_auth_confirm" assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - }, - ) + with override_side_effect(mock_connect["connect"], error_type): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {error_placement: errors_msg} @@ -213,7 +257,6 @@ async def test_discovery_auth_errors( await hass.async_block_till_done() - mock_connect["connect"].side_effect = default_connect_side_effect result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { @@ -228,29 +271,29 @@ async def test_discovery_auth_errors( async def test_discovery_new_credentials( hass: HomeAssistant, - mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init, ) -> None: """Test setting up discovery with new credentials.""" - mock_discovery["mock_device"].update.side_effect = AuthenticationError + mock_device = mock_connect["mock_devices"][IP_ADDRESS] - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_HOST: IP_ADDRESS, - CONF_MAC: MAC_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_KLAP, - }, - ) - await hass.async_block_till_done() + with override_side_effect(mock_connect["connect"], AuthenticationError): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE: mock_device, + }, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_auth_confirm" assert not result["errors"] - assert mock_connect["connect"].call_count == 0 + assert mock_connect["connect"].call_count == 1 with patch( "homeassistant.components.tplink.config_flow.get_credentials", @@ -260,7 +303,7 @@ async def test_discovery_new_credentials( result["flow_id"], ) - assert mock_connect["connect"].call_count == 1 + assert mock_connect["connect"].call_count == 2 assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "discovery_confirm" @@ -277,48 +320,54 @@ async def test_discovery_new_credentials( async def test_discovery_new_credentials_invalid( hass: HomeAssistant, - mock_discovery: AsyncMock, mock_connect: AsyncMock, mock_init, ) -> None: """Test setting up discovery with new invalid credentials.""" - mock_discovery["mock_device"].update.side_effect = AuthenticationError - default_connect_side_effect = mock_connect["connect"].side_effect + mock_device = mock_connect["mock_devices"][IP_ADDRESS] - mock_connect["connect"].side_effect = AuthenticationError - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_HOST: IP_ADDRESS, - CONF_MAC: MAC_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_KLAP, - }, - ) - await hass.async_block_till_done() + with ( + patch("homeassistant.components.tplink.Discover.discover", return_value={}), + patch( + "homeassistant.components.tplink.config_flow.get_credentials", + return_value=None, + ), + override_side_effect(mock_connect["connect"], AuthenticationError), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE: mock_device, + }, + ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "discovery_auth_confirm" assert not result["errors"] - assert mock_connect["connect"].call_count == 0 + assert mock_connect["connect"].call_count == 1 - with patch( - "homeassistant.components.tplink.config_flow.get_credentials", - return_value=Credentials("fake_user", "fake_pass"), + with ( + patch( + "homeassistant.components.tplink.config_flow.get_credentials", + return_value=Credentials("fake_user", "fake_pass"), + ), + override_side_effect(mock_connect["connect"], AuthenticationError), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], ) - assert mock_connect["connect"].call_count == 1 + assert mock_connect["connect"].call_count == 2 assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "discovery_auth_confirm" await hass.async_block_till_done() - mock_connect["connect"].side_effect = default_connect_side_effect result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], { @@ -577,32 +626,30 @@ async def test_manual_auth_errors( assert not result["errors"] mock_discovery["mock_device"].update.side_effect = AuthenticationError - default_connect_side_effect = mock_connect["connect"].side_effect - mock_connect["connect"].side_effect = error_type - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_HOST: IP_ADDRESS} - ) + with override_side_effect(mock_connect["connect"], error_type): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: IP_ADDRESS} + ) assert result2["type"] is FlowResultType.FORM assert result2["step_id"] == "user_auth_confirm" assert not result2["errors"] await hass.async_block_till_done() - - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - user_input={ - CONF_USERNAME: "fake_username", - CONF_PASSWORD: "fake_password", - }, - ) - await hass.async_block_till_done() + with override_side_effect(mock_connect["connect"], error_type): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_USERNAME: "fake_username", + CONF_PASSWORD: "fake_password", + }, + ) + await hass.async_block_till_done() assert result3["type"] is FlowResultType.FORM assert result3["step_id"] == "user_auth_confirm" assert result3["errors"] == {error_placement: errors_msg} assert result3["description_placeholders"]["error"] == str(error_type) - mock_connect["connect"].side_effect = default_connect_side_effect result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], { @@ -628,7 +675,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + CONF_DEVICE: _mocked_device(device_config=DEVICE_CONFIG_LEGACY), }, ) await hass.async_block_till_done() @@ -691,7 +738,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + CONF_DEVICE: _mocked_device(device_config=DEVICE_CONFIG_LEGACY), }, ), ], @@ -745,7 +792,7 @@ async def test_discovered_by_dhcp_or_discovery( CONF_HOST: IP_ADDRESS, CONF_MAC: MAC_ADDRESS, CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_LEGACY, + CONF_DEVICE: _mocked_device(device_config=DEVICE_CONFIG_LEGACY), }, ), ], @@ -775,9 +822,11 @@ async def test_integration_discovery_with_ip_change( mock_connect: AsyncMock, ) -> None: """Test reauth flow.""" - mock_connect["connect"].side_effect = KasaException() mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.tplink.Discover.discover", return_value={}): + with ( + patch("homeassistant.components.tplink.Discover.discover", return_value={}), + override_side_effect(mock_connect["connect"], KasaException()), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -785,39 +834,57 @@ async def test_integration_discovery_with_ip_change( flows = hass.config_entries.flow.async_progress() assert len(flows) == 0 - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY - assert mock_config_entry.data[CONF_DEVICE_CONFIG].get(CONF_HOST) == "127.0.0.1" - - discovery_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_HOST: "127.0.0.2", - CONF_MAC: MAC_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_KLAP, - }, + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] + == CONN_PARAMS_LEGACY.to_dict() ) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + mocked_device = _mocked_device(device_config=DEVICE_CONFIG_KLAP) + with override_side_effect(mock_connect["connect"], lambda *_, **__: mocked_device): + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: "127.0.0.2", + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE: mocked_device, + }, + ) await hass.async_block_till_done() assert discovery_result["type"] is FlowResultType.ABORT assert discovery_result["reason"] == "already_configured" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_KLAP + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() + ) assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" config = DeviceConfig.from_dict(DEVICE_CONFIG_DICT_KLAP) + # Do a reload here and check that the + # new config is picked up in setup_entry mock_connect["connect"].reset_mock(side_effect=True) bulb = _mocked_device( device_config=config, mac=mock_config_entry.unique_id, ) - mock_connect["connect"].return_value = bulb - await hass.config_entries.async_reload(mock_config_entry.entry_id) - await hass.async_block_till_done() + + with ( + patch( + "homeassistant.components.tplink.async_create_clientsession", + return_value="Foo", + ), + override_side_effect(mock_connect["connect"], lambda *_, **__: bulb), + ): + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED # Check that init set the new host correctly before calling connect assert config.host == "127.0.0.1" config.host = "127.0.0.2" + config.uses_http = False # Not passed in to new config class + config.http_client = "Foo" mock_connect["connect"].assert_awaited_once_with(config=config) @@ -831,8 +898,6 @@ async def test_integration_discovery_with_connection_change( And that connection_hash is removed as it will be invalid. """ - mock_connect["connect"].side_effect = KasaException() - mock_config_entry = MockConfigEntry( title="TPLink", domain=DOMAIN, @@ -840,7 +905,10 @@ async def test_integration_discovery_with_connection_change( unique_id=MAC_ADDRESS2, ) mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.tplink.Discover.discover", return_value={}): + with ( + patch("homeassistant.components.tplink.Discover.discover", return_value={}), + override_side_effect(mock_connect["connect"], KasaException()), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) @@ -854,43 +922,57 @@ async def test_integration_discovery_with_connection_change( == 0 ) assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AES - assert mock_config_entry.data[CONF_DEVICE_CONFIG].get(CONF_HOST) == "127.0.0.2" + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_AES.to_dict() + ) assert mock_config_entry.data[CONF_CREDENTIALS_HASH] == CREDENTIALS_HASH_AES + mock_connect["connect"].reset_mock() NEW_DEVICE_CONFIG = { **DEVICE_CONFIG_DICT_KLAP, - CONF_CONNECTION_TYPE: CONNECTION_TYPE_KLAP_DICT, + "connection_type": CONN_PARAMS_KLAP.to_dict(), CONF_HOST: "127.0.0.2", } config = DeviceConfig.from_dict(NEW_DEVICE_CONFIG) # Reset the connect mock so when the config flow reloads the entry it succeeds - mock_connect["connect"].reset_mock(side_effect=True) + bulb = _mocked_device( device_config=config, mac=mock_config_entry.unique_id, ) - mock_connect["connect"].return_value = bulb - discovery_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_HOST: "127.0.0.2", - CONF_MAC: MAC_ADDRESS2, - CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: NEW_DEVICE_CONFIG, - }, - ) + with ( + patch( + "homeassistant.components.tplink.async_create_clientsession", + return_value="Foo", + ), + override_side_effect(mock_connect["connect"], lambda *_, **__: bulb), + ): + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: "127.0.0.2", + CONF_MAC: MAC_ADDRESS2, + CONF_ALIAS: ALIAS, + CONF_DEVICE: bulb, + }, + ) await hass.async_block_till_done(wait_background_tasks=True) assert discovery_result["type"] is FlowResultType.ABORT assert discovery_result["reason"] == "already_configured" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == NEW_DEVICE_CONFIG + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() + ) assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" assert CREDENTIALS_HASH_AES not in mock_config_entry.data assert mock_config_entry.state is ConfigEntryState.LOADED + config.host = "127.0.0.2" + config.uses_http = False # Not passed in to new config class + config.http_client = "Foo" + config.aes_keys = AES_KEYS mock_connect["connect"].assert_awaited_once_with(config=config) @@ -901,17 +983,18 @@ async def test_dhcp_discovery_with_ip_change( mock_connect: AsyncMock, ) -> None: """Test dhcp discovery with an IP change.""" - mock_connect["connect"].side_effect = KasaException() mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.tplink.Discover.discover", return_value={}): + with ( + patch("homeassistant.components.tplink.Discover.discover", return_value={}), + override_side_effect(mock_connect["connect"], KasaException()), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY flows = hass.config_entries.flow.async_progress() assert len(flows) == 0 - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY - assert mock_config_entry.data[CONF_DEVICE_CONFIG].get(CONF_HOST) == "127.0.0.1" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" discovery_result = await hass.config_entries.flow.async_init( DOMAIN, @@ -966,8 +1049,7 @@ async def test_reauth_update_with_encryption_change( caplog: pytest.LogCaptureFixture, ) -> None: """Test reauth flow.""" - orig_side_effect = mock_connect["connect"].side_effect - mock_connect["connect"].side_effect = AuthenticationError() + mock_config_entry = MockConfigEntry( title="TPLink", domain=DOMAIN, @@ -975,10 +1057,15 @@ async def test_reauth_update_with_encryption_change( unique_id=MAC_ADDRESS2, ) mock_config_entry.add_to_hass(hass) - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AES + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_AES.to_dict() + ) assert mock_config_entry.data[CONF_CREDENTIALS_HASH] == CREDENTIALS_HASH_AES - with patch("homeassistant.components.tplink.Discover.discover", return_value={}): + with ( + patch("homeassistant.components.tplink.Discover.discover", return_value={}), + override_side_effect(mock_connect["connect"], AuthenticationError()), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR @@ -988,7 +1075,9 @@ async def test_reauth_update_with_encryption_change( assert len(flows) == 1 [result] = flows assert result["step_id"] == "reauth_confirm" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_AES + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_AES.to_dict() + ) assert CONF_CREDENTIALS_HASH not in mock_config_entry.data new_config = DeviceConfig( @@ -1005,7 +1094,6 @@ async def test_reauth_update_with_encryption_change( mock_connect["mock_devices"]["127.0.0.2"].config = new_config mock_connect["mock_devices"]["127.0.0.2"].credentials_hash = CREDENTIALS_HASH_KLAP - mock_connect["connect"].side_effect = orig_side_effect result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ @@ -1023,10 +1111,10 @@ async def test_reauth_update_with_encryption_change( assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == { - **DEVICE_CONFIG_DICT_KLAP, - CONF_HOST: "127.0.0.2", - } + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() + ) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" assert mock_config_entry.data[CONF_CREDENTIALS_HASH] == CREDENTIALS_HASH_KLAP @@ -1037,9 +1125,11 @@ async def test_reauth_update_from_discovery( mock_connect: AsyncMock, ) -> None: """Test reauth flow.""" - mock_connect["connect"].side_effect = AuthenticationError mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.tplink.Discover.discover", return_value={}): + with ( + patch("homeassistant.components.tplink.Discover.discover", return_value={}), + override_side_effect(mock_connect["connect"], AuthenticationError()), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -1049,22 +1139,32 @@ async def test_reauth_update_from_discovery( assert len(flows) == 1 [result] = flows assert result["step_id"] == "reauth_confirm" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY - - discovery_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_HOST: IP_ADDRESS, - CONF_MAC: MAC_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_KLAP, - }, + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] + == CONN_PARAMS_LEGACY.to_dict() ) + + device = _mocked_device( + device_config=DEVICE_CONFIG_KLAP, + mac=mock_config_entry.unique_id, + ) + with override_side_effect(mock_connect["connect"], lambda *_, **__: device): + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE: device, + }, + ) await hass.async_block_till_done() assert discovery_result["type"] is FlowResultType.ABORT assert discovery_result["reason"] == "already_configured" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_KLAP + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() + ) async def test_reauth_update_from_discovery_with_ip_change( @@ -1074,9 +1174,11 @@ async def test_reauth_update_from_discovery_with_ip_change( mock_connect: AsyncMock, ) -> None: """Test reauth flow.""" - mock_connect["connect"].side_effect = AuthenticationError() mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.tplink.Discover.discover", return_value={}): + with ( + patch("homeassistant.components.tplink.Discover.discover", return_value={}), + override_side_effect(mock_connect["connect"], AuthenticationError()), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR @@ -1085,22 +1187,32 @@ async def test_reauth_update_from_discovery_with_ip_change( assert len(flows) == 1 [result] = flows assert result["step_id"] == "reauth_confirm" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_LEGACY - - discovery_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_HOST: "127.0.0.2", - CONF_MAC: MAC_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_KLAP, - }, + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] + == CONN_PARAMS_LEGACY.to_dict() ) + + device = _mocked_device( + device_config=DEVICE_CONFIG_KLAP, + mac=mock_config_entry.unique_id, + ) + with override_side_effect(mock_connect["connect"], lambda *_, **__: device): + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: "127.0.0.2", + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE: device, + }, + ) await hass.async_block_till_done() assert discovery_result["type"] is FlowResultType.ABORT assert discovery_result["reason"] == "already_configured" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_KLAP + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() + ) assert mock_config_entry.data[CONF_HOST] == "127.0.0.2" @@ -1111,8 +1223,8 @@ async def test_reauth_no_update_if_config_and_ip_the_same( mock_connect: AsyncMock, ) -> None: """Test reauth discovery does not update when the host and config are the same.""" - mock_connect["connect"].side_effect = AuthenticationError() mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( mock_config_entry, data={ @@ -1120,30 +1232,40 @@ async def test_reauth_no_update_if_config_and_ip_the_same( CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_KLAP, }, ) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + with override_side_effect(mock_connect["connect"], AuthenticationError()): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 [result] = flows assert result["step_id"] == "reauth_confirm" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_KLAP - - discovery_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={ - CONF_HOST: IP_ADDRESS, - CONF_MAC: MAC_ADDRESS, - CONF_ALIAS: ALIAS, - CONF_DEVICE_CONFIG: DEVICE_CONFIG_DICT_KLAP, - }, + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() ) + + device = _mocked_device( + device_config=DEVICE_CONFIG_KLAP, + mac=mock_config_entry.unique_id, + ) + with override_side_effect(mock_connect["connect"], lambda *_, **__: device): + discovery_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data={ + CONF_HOST: IP_ADDRESS, + CONF_MAC: MAC_ADDRESS, + CONF_ALIAS: ALIAS, + CONF_DEVICE: device, + }, + ) await hass.async_block_till_done() assert discovery_result["type"] is FlowResultType.ABORT assert discovery_result["reason"] == "already_configured" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_KLAP + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() + ) assert mock_config_entry.data[CONF_HOST] == IP_ADDRESS @@ -1241,17 +1363,15 @@ async def test_pick_device_errors( assert result2["step_id"] == "pick_device" assert not result2["errors"] - default_connect_side_effect = mock_connect["connect"].side_effect - mock_connect["connect"].side_effect = error_type - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_DEVICE: MAC_ADDRESS}, - ) - await hass.async_block_till_done() + with override_side_effect(mock_connect["connect"], error_type): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_DEVICE: MAC_ADDRESS}, + ) + await hass.async_block_till_done() assert result3["type"] == expected_flow if expected_flow != FlowResultType.ABORT: - mock_connect["connect"].side_effect = default_connect_side_effect result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], user_input={ @@ -1300,17 +1420,17 @@ async def test_discovery_timeout_connect_legacy_error( DOMAIN, context={"source": config_entries.SOURCE_USER} ) mock_discovery["discover_single"].side_effect = TimeoutError - mock_connect["connect"].side_effect = KasaException await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert not result["errors"] assert mock_connect["connect"].call_count == 0 - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: IP_ADDRESS} - ) - await hass.async_block_till_done() + with override_side_effect(mock_connect["connect"], KasaException): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: IP_ADDRESS} + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} assert mock_connect["connect"].call_count == 1 @@ -1334,17 +1454,17 @@ async def test_reauth_update_other_flows( data={**CREATE_ENTRY_DATA_AES}, unique_id=MAC_ADDRESS2, ) - default_side_effect = mock_connect["connect"].side_effect - mock_connect["connect"].side_effect = AuthenticationError() mock_config_entry.add_to_hass(hass) mock_config_entry2.add_to_hass(hass) - with patch("homeassistant.components.tplink.Discover.discover", return_value={}): + with ( + patch("homeassistant.components.tplink.Discover.discover", return_value={}), + override_side_effect(mock_connect["connect"], AuthenticationError()), + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry2.state is ConfigEntryState.SETUP_ERROR assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR - mock_connect["connect"].side_effect = default_side_effect await hass.async_block_till_done() @@ -1353,7 +1473,9 @@ async def test_reauth_update_other_flows( flows_by_entry_id = {flow["context"]["entry_id"]: flow for flow in flows} result = flows_by_entry_id[mock_config_entry.entry_id] assert result["step_id"] == "reauth_confirm" - assert mock_config_entry.data[CONF_DEVICE_CONFIG] == DEVICE_CONFIG_DICT_KLAP + assert ( + mock_config_entry.data[CONF_CONNECTION_PARAMETERS] == CONN_PARAMS_KLAP.to_dict() + ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 986aaebd170..dd01c381adf 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -4,6 +4,7 @@ from __future__ import annotations import copy from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from freezegun.api import FrozenDateTimeFactory @@ -13,14 +14,18 @@ import pytest from homeassistant import setup from homeassistant.components import tplink from homeassistant.components.tplink.const import ( + CONF_AES_KEYS, + CONF_CONNECTION_PARAMETERS, CONF_CREDENTIALS_HASH, CONF_DEVICE_CONFIG, DOMAIN, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( + CONF_ALIAS, CONF_AUTHENTICATION, CONF_HOST, + CONF_MODEL, CONF_PASSWORD, CONF_USERNAME, STATE_ON, @@ -33,13 +38,20 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import ( + ALIAS, + CREATE_ENTRY_DATA_AES, CREATE_ENTRY_DATA_KLAP, CREATE_ENTRY_DATA_LEGACY, + CREDENTIALS_HASH_AES, + CREDENTIALS_HASH_KLAP, + DEVICE_CONFIG_AES, DEVICE_CONFIG_KLAP, + DEVICE_CONFIG_LEGACY, DEVICE_ID, DEVICE_ID_MAC, IP_ADDRESS, MAC_ADDRESS, + MODEL, _mocked_device, _patch_connect, _patch_discovery, @@ -207,16 +219,21 @@ async def test_config_entry_with_stored_credentials( hass.data.setdefault(DOMAIN, {})[CONF_AUTHENTICATION] = auth mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) + with patch( + "homeassistant.components.tplink.async_create_clientsession", return_value="Foo" + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED - config = DEVICE_CONFIG_KLAP + config = DeviceConfig.from_dict(DEVICE_CONFIG_KLAP.to_dict()) + config.uses_http = False + config.http_client = "Foo" assert config.credentials != stored_credentials config.credentials = stored_credentials mock_connect["connect"].assert_called_once_with(config=config) -async def test_config_entry_device_config_invalid( +async def test_config_entry_conn_params_invalid( hass: HomeAssistant, mock_discovery: AsyncMock, mock_connect: AsyncMock, @@ -224,7 +241,7 @@ async def test_config_entry_device_config_invalid( ) -> None: """Test that an invalid device config logs an error and loads the config entry.""" entry_data = copy.deepcopy(CREATE_ENTRY_DATA_KLAP) - entry_data[CONF_DEVICE_CONFIG] = {"foo": "bar"} + entry_data[CONF_CONNECTION_PARAMETERS] = {"foo": "bar"} mock_config_entry = MockConfigEntry( title="TPLink", domain=DOMAIN, @@ -237,7 +254,7 @@ async def test_config_entry_device_config_invalid( assert mock_config_entry.state is ConfigEntryState.LOADED assert ( - f"Invalid connection type dict for {IP_ADDRESS}: {entry_data.get(CONF_DEVICE_CONFIG)}" + f"Invalid connection parameters dict for {IP_ADDRESS}: {entry_data.get(CONF_CONNECTION_PARAMETERS)}" in caplog.text ) @@ -495,8 +512,9 @@ async def test_unlink_devices( } assert device_entries[0].identifiers == set(test_identifiers) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.tplink.CONF_CONFIG_ENTRY_MINOR_VERSION", 3): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) @@ -504,7 +522,7 @@ async def test_unlink_devices( assert device_entries[0].identifiers == set(expected_identifiers) assert entry.version == 1 - assert entry.minor_version == 4 + assert entry.minor_version == 3 assert update_msg in caplog.text assert "Migration to version 1.3 complete" in caplog.text @@ -545,6 +563,7 @@ async def test_move_credentials_hash( with ( patch("homeassistant.components.tplink.Device.connect", new=_connect), patch("homeassistant.components.tplink.PLATFORMS", []), + patch("homeassistant.components.tplink.CONF_CONFIG_ENTRY_MINOR_VERSION", 4), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -589,6 +608,7 @@ async def test_move_credentials_hash_auth_error( side_effect=AuthenticationError, ), patch("homeassistant.components.tplink.PLATFORMS", []), + patch("homeassistant.components.tplink.CONF_CONFIG_ENTRY_MINOR_VERSION", 4), ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -631,6 +651,7 @@ async def test_move_credentials_hash_other_error( "homeassistant.components.tplink.Device.connect", side_effect=KasaException ), patch("homeassistant.components.tplink.PLATFORMS", []), + patch("homeassistant.components.tplink.CONF_CONFIG_ENTRY_MINOR_VERSION", 4), ): entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -647,10 +668,8 @@ async def test_credentials_hash( hass: HomeAssistant, ) -> None: """Test credentials_hash used to call connect.""" - device_config = {**DEVICE_CONFIG_KLAP.to_dict(exclude_credentials=True)} entry_data = { **CREATE_ENTRY_DATA_KLAP, - CONF_DEVICE_CONFIG: device_config, CONF_CREDENTIALS_HASH: "theHash", } @@ -674,9 +693,7 @@ async def test_credentials_hash( await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - assert CONF_CREDENTIALS_HASH not in entry.data[CONF_DEVICE_CONFIG] assert CONF_CREDENTIALS_HASH in entry.data - assert entry.data[CONF_DEVICE_CONFIG] == device_config assert entry.data[CONF_CREDENTIALS_HASH] == "theHash" @@ -684,10 +701,8 @@ async def test_credentials_hash_auth_error( hass: HomeAssistant, ) -> None: """Test credentials_hash is deleted after an auth failure.""" - device_config = {**DEVICE_CONFIG_KLAP.to_dict(exclude_credentials=True)} entry_data = { **CREATE_ENTRY_DATA_KLAP, - CONF_DEVICE_CONFIG: device_config, CONF_CREDENTIALS_HASH: "theHash", } @@ -700,6 +715,10 @@ async def test_credentials_hash_auth_error( with ( patch("homeassistant.components.tplink.PLATFORMS", []), + patch( + "homeassistant.components.tplink.async_create_clientsession", + return_value="Foo", + ), patch( "homeassistant.components.tplink.Device.connect", side_effect=AuthenticationError, @@ -712,6 +731,76 @@ async def test_credentials_hash_auth_error( expected_config = DeviceConfig.from_dict( DEVICE_CONFIG_KLAP.to_dict(exclude_credentials=True, credentials_hash="theHash") ) + expected_config.uses_http = False + expected_config.http_client = "Foo" connect_mock.assert_called_with(config=expected_config) assert entry.state is ConfigEntryState.SETUP_ERROR assert CONF_CREDENTIALS_HASH not in entry.data + + +@pytest.mark.parametrize( + ("device_config", "expected_entry_data", "credentials_hash"), + [ + pytest.param( + DEVICE_CONFIG_KLAP, CREATE_ENTRY_DATA_KLAP, CREDENTIALS_HASH_KLAP, id="KLAP" + ), + pytest.param( + DEVICE_CONFIG_AES, CREATE_ENTRY_DATA_AES, CREDENTIALS_HASH_AES, id="AES" + ), + pytest.param(DEVICE_CONFIG_LEGACY, CREATE_ENTRY_DATA_LEGACY, None, id="Legacy"), + ], +) +async def test_migrate_remove_device_config( + hass: HomeAssistant, + mock_connect: AsyncMock, + caplog: pytest.LogCaptureFixture, + device_config: DeviceConfig, + expected_entry_data: dict[str, Any], + credentials_hash: str, +) -> None: + """Test credentials hash moved to parent. + + As async_setup_entry will succeed the hash on the parent is updated + from the device. + """ + OLD_CREATE_ENTRY_DATA = { + CONF_HOST: expected_entry_data[CONF_HOST], + CONF_ALIAS: ALIAS, + CONF_MODEL: MODEL, + CONF_DEVICE_CONFIG: device_config.to_dict(exclude_credentials=True), + } + + entry = MockConfigEntry( + title="TPLink", + domain=DOMAIN, + data=OLD_CREATE_ENTRY_DATA, + entry_id="123456", + unique_id=MAC_ADDRESS, + version=1, + minor_version=4, + ) + entry.add_to_hass(hass) + + async def _connect(config): + config.credentials_hash = credentials_hash + config.aes_keys = expected_entry_data.get(CONF_AES_KEYS) + return _mocked_device(device_config=config, credentials_hash=credentials_hash) + + with ( + patch("homeassistant.components.tplink.Device.connect", new=_connect), + patch("homeassistant.components.tplink.PLATFORMS", []), + patch( + "homeassistant.components.tplink.async_create_clientsession", + return_value="Foo", + ), + patch("homeassistant.components.tplink.CONF_CONFIG_ENTRY_MINOR_VERSION", 5), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.minor_version == 5 + assert entry.state is ConfigEntryState.LOADED + assert CONF_DEVICE_CONFIG not in entry.data + assert entry.data == expected_entry_data + + assert "Migration to version 1.5 complete" in caplog.text From dd4f1a0d0f3babd2720c112343aaad05741ec035 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 10 Sep 2024 21:00:06 +0200 Subject: [PATCH 0506/1309] Simplify recorder statistics_meta_manager (#125648) --- .../recorder/auto_repairs/statistics/duplicates.py | 5 ++--- homeassistant/components/recorder/const.py | 1 - homeassistant/components/recorder/core.py | 5 +---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py index 06a5c5258f1..b73744ef0d1 100644 --- a/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py +++ b/homeassistant/components/recorder/auto_repairs/statistics/duplicates.py @@ -247,12 +247,11 @@ def delete_statistics_meta_duplicates(instance: Recorder, session: Session) -> N """Identify and delete duplicated statistics_meta. This is used when migrating from schema version 28 to schema version 29. + Note: If this needs to be called during live schema migration it needs to + be modified to reload the statistics_meta_manager. """ deleted_statistics_rows = _delete_statistics_meta_duplicates(session) if deleted_statistics_rows: - statistics_meta_manager = instance.statistics_meta_manager - statistics_meta_manager.reset() - statistics_meta_manager.load(session) _LOGGER.info( "Deleted %s duplicated statistics_meta rows", deleted_statistics_rows ) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 066ae938971..bc909448317 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -54,7 +54,6 @@ ATTR_APPLY_FILTER = "apply_filter" KEEPALIVE_TIME = 30 -STATISTICS_ROWS_SCHEMA_VERSION = 23 CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36 EVENT_TYPE_IDS_SCHEMA_VERSION = 37 STATES_META_SCHEMA_VERSION = 38 diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 002d8937e3a..0c80d979268 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -63,7 +63,6 @@ from .const import ( MYSQLDB_URL_PREFIX, SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, - STATISTICS_ROWS_SCHEMA_VERSION, SupportedDialect, ) from .db_schema import ( @@ -797,9 +796,7 @@ class Recorder(threading.Thread): # since we want the frontend queries to avoid a thundering # herd of queries to find the statistics meta data if # there are a lot of statistics graphs on the frontend. - schema_version = self.schema_version - if schema_version >= STATISTICS_ROWS_SCHEMA_VERSION: - self.statistics_meta_manager.load(session) + self.statistics_meta_manager.load(session) migration_changes: dict[str, int] = { row[0]: row[1] From 9bbd59438ee86aaf2c0a42b3a68b5be65306f762 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 10 Sep 2024 21:20:43 +0200 Subject: [PATCH 0507/1309] Bump nextdns to version 3.3.0 (#125688) --- homeassistant/components/nextdns/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index be9eee5049c..f3ed62a2f0c 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==3.2.0"] + "requirements": ["nextdns==3.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 042eec8bd7b..c0a47663b73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1435,7 +1435,7 @@ nextcloudmonitor==1.5.1 nextcord==2.6.0 # homeassistant.components.nextdns -nextdns==3.2.0 +nextdns==3.3.0 # homeassistant.components.nibe_heatpump nibe==2.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6466c13dbe3..7d92bae18a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1195,7 +1195,7 @@ nextcloudmonitor==1.5.1 nextcord==2.6.0 # homeassistant.components.nextdns -nextdns==3.2.0 +nextdns==3.3.0 # homeassistant.components.nibe_heatpump nibe==2.11.0 From 377ae75e607d922d29307755157d44019550896b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 10 Sep 2024 21:53:04 +0200 Subject: [PATCH 0508/1309] Disbale Tfiac integration due invalid wheel (#125692) --- homeassistant/components/tfiac/manifest.json | 1 + requirements_all.txt | 3 --- script/licenses.py | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/tfiac/manifest.json b/homeassistant/components/tfiac/manifest.json index 4cac4807ea4..243710241a2 100644 --- a/homeassistant/components/tfiac/manifest.json +++ b/homeassistant/components/tfiac/manifest.json @@ -2,6 +2,7 @@ "domain": "tfiac", "name": "Tfiac", "codeowners": ["@fredrike", "@mellado"], + "disabled": "This integration is disabled because we cannot build a valid wheel.", "documentation": "https://www.home-assistant.io/integrations/tfiac", "iot_class": "local_polling", "requirements": ["pytfiac==0.4"] diff --git a/requirements_all.txt b/requirements_all.txt index c0a47663b73..cd5804cc0eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2270,9 +2270,6 @@ pytautulli==23.1.1 # homeassistant.components.tedee pytedee-async==0.2.20 -# homeassistant.components.tfiac -pytfiac==0.4 - # homeassistant.components.thinkingcleaner pythinkingcleaner==0.0.3 diff --git a/script/licenses.py b/script/licenses.py index 84797372309..a6805b0a3ca 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -165,7 +165,6 @@ EXCEPTIONS = { "sensirion-ble", # https://github.com/akx/sensirion-ble/pull/9 "sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14 "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 - "tellsticknet", # https://github.com/molobrakos/tellsticknet/pull/33 "vincenty", # Public domain "zeversolar", # https://github.com/kvanzuijlen/zeversolar/pull/46 } From 688da5389c06293f97e335ffcbaeffed04710de1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 10 Sep 2024 22:02:46 +0200 Subject: [PATCH 0509/1309] Mark UVC as strict typed (#123239) --- .strict-typing | 1 + homeassistant/components/uvc/camera.py | 90 +++++++++++--------------- mypy.ini | 10 +++ tests/components/uvc/test_camera.py | 5 +- 4 files changed, 52 insertions(+), 54 deletions(-) diff --git a/.strict-typing b/.strict-typing index bea0b1be991..706a99cc0c3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -480,6 +480,7 @@ homeassistant.components.update.* homeassistant.components.uptime.* homeassistant.components.uptimerobot.* homeassistant.components.usb.* +homeassistant.components.uvc.* homeassistant.components.vacuum.* homeassistant.components.vallox.* homeassistant.components.valve.* diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py index cd9594c7d31..a6f0202ee25 100644 --- a/homeassistant/components/uvc/camera.py +++ b/homeassistant/components/uvc/camera.py @@ -5,9 +5,11 @@ from __future__ import annotations from datetime import datetime import logging import re +from typing import Any, cast -import requests from uvcclient import camera as uvc_camera, nvr +from uvcclient.camera import UVCCameraClient +from uvcclient.nvr import UVCRemote import voluptuous as vol from homeassistant.components.camera import ( @@ -57,11 +59,11 @@ def setup_platform( ssl = config[CONF_SSL] try: - # Exceptions may be raised in all method calls to the nvr library. nvrconn = nvr.UVCRemote(addr, port, key, ssl=ssl) + # Exceptions may be raised in all method calls to the nvr library. cameras = nvrconn.index() - identifier = "id" if nvrconn.server_version >= (3, 2, 0) else "uuid" + identifier = nvrconn.camera_identifier # Filter out airCam models, which are not supported in the latest # version of UnifiVideo and which are EOL by Ubiquiti cameras = [ @@ -75,15 +77,12 @@ def setup_platform( except nvr.NvrError as ex: _LOGGER.error("NVR refuses to talk to me: %s", str(ex)) raise PlatformNotReady from ex - except requests.exceptions.ConnectionError as ex: - _LOGGER.error("Unable to connect to NVR: %s", str(ex)) - raise PlatformNotReady from ex add_entities( - [ + ( UnifiVideoCamera(nvrconn, camera[identifier], camera["name"], password) for camera in cameras - ], + ), True, ) @@ -92,24 +91,19 @@ class UnifiVideoCamera(Camera): """A Ubiquiti Unifi Video Camera.""" _attr_should_poll = True # Cameras default to False + _attr_brand = "Ubiquiti" + _attr_is_streaming = False + _caminfo: dict[str, Any] - def __init__(self, camera, uuid, name, password): + def __init__(self, camera: UVCRemote, uuid: str, name: str, password: str) -> None: """Initialize an Unifi camera.""" super().__init__() self._nvr = camera - self._uuid = uuid - self._name = name + self._uuid = self._attr_unique_id = uuid + self._attr_name = name self._password = password - self._attr_is_streaming = False - self._connect_addr = None - self._camera = None - self._motion_status = False - self._caminfo = None - - @property - def name(self): - """Return the name of this camera.""" - return self._name + self._connect_addr: str | None = None + self._camera: UVCCameraClient | None = None @property def supported_features(self) -> CameraEntityFeature: @@ -122,7 +116,7 @@ class UnifiVideoCamera(Camera): return CameraEntityFeature(0) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the camera state attributes.""" attr = {} if self.motion_detection_enabled: @@ -145,24 +139,14 @@ class UnifiVideoCamera(Camera): @property def motion_detection_enabled(self) -> bool: """Camera Motion Detection Status.""" - return self._caminfo["recordingSettings"]["motionRecordEnabled"] + return bool(self._caminfo["recordingSettings"]["motionRecordEnabled"]) @property - def unique_id(self) -> str: - """Return a unique identifier for this client.""" - return self._uuid - - @property - def brand(self): - """Return the brand of this camera.""" - return "Ubiquiti" - - @property - def model(self): + def model(self) -> str: """Return the model of this camera.""" - return self._caminfo["model"] + return cast(str, self._caminfo["model"]) - def _login(self): + def _login(self) -> bool: """Login to the camera.""" caminfo = self._caminfo if self._connect_addr: @@ -170,6 +154,7 @@ class UnifiVideoCamera(Camera): else: addrs = [caminfo["host"], caminfo["internalHost"]] + client_cls: type[uvc_camera.UVCCameraClient] if self._nvr.server_version >= (3, 2, 0): client_cls = uvc_camera.UVCCameraClientV320 else: @@ -178,15 +163,14 @@ class UnifiVideoCamera(Camera): if caminfo["username"] is None: caminfo["username"] = "ubnt" + assert isinstance(caminfo["username"], str) + camera = None for addr in addrs: try: camera = client_cls(addr, caminfo["username"], self._password) camera.login() - _LOGGER.debug( - "Logged into UVC camera %(name)s via %(addr)s", - {"name": self._name, "addr": addr}, - ) + _LOGGER.debug("Logged into UVC camera %s via %s", self._attr_name, addr) self._connect_addr = addr break except OSError: @@ -197,7 +181,7 @@ class UnifiVideoCamera(Camera): pass if not self._connect_addr: _LOGGER.error("Unable to login to camera") - return None + return False self._camera = camera self._caminfo = caminfo @@ -210,11 +194,13 @@ class UnifiVideoCamera(Camera): if not self._camera and not self._login(): return None - def _get_image(retry=True): + def _get_image(retry: bool = True) -> bytes | None: + assert self._camera is not None try: return self._camera.get_snapshot() except uvc_camera.CameraConnectError: _LOGGER.error("Unable to contact camera") + return None except uvc_camera.CameraAuthError: if retry: self._login() @@ -224,13 +210,12 @@ class UnifiVideoCamera(Camera): return _get_image() - def set_motion_detection(self, mode): + def set_motion_detection(self, mode: bool) -> None: """Set motion detection on or off.""" set_mode = "motion" if mode is True else "none" try: self._nvr.set_recordmode(self._uuid, set_mode) - self._motion_status = mode except nvr.NvrError as err: _LOGGER.error("Unable to set recordmode to %s", set_mode) _LOGGER.debug(err) @@ -243,16 +228,19 @@ class UnifiVideoCamera(Camera): """Disable motion detection in camera.""" self.set_motion_detection(False) - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the source of the stream.""" for channel in self._caminfo["channels"]: if channel["isRtspEnabled"]: - return next( - ( - uri - for i, uri in enumerate(channel["rtspUris"]) - if re.search(self._nvr._host, uri) # noqa: SLF001 - ) + return cast( + str, + next( + ( + uri + for i, uri in enumerate(channel["rtspUris"]) + if re.search(self._nvr._host, uri) # noqa: SLF001 + ) + ), ) return None diff --git a/mypy.ini b/mypy.ini index d7604012305..579658155c3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4558,6 +4558,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.uvc.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.vacuum.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/uvc/test_camera.py b/tests/components/uvc/test_camera.py index 5ce8baf9919..3d41e725209 100644 --- a/tests/components/uvc/test_camera.py +++ b/tests/components/uvc/test_camera.py @@ -4,7 +4,6 @@ from datetime import UTC, datetime, timedelta from unittest.mock import call, patch import pytest -import requests from uvcclient import camera, nvr from homeassistant.components.camera import ( @@ -46,6 +45,7 @@ def mock_remote_fixture(camera_info): ] mock_remote.return_value.index.return_value = mock_cameras mock_remote.return_value.server_version = (3, 2, 0) + mock_remote.return_value.camera_identifier = "id" yield mock_remote @@ -205,6 +205,7 @@ async def test_setup_partial_config_v31x( """Test the setup with a v3.1.x server.""" config = {"platform": "uvc", "nvr": "foo", "key": "secret"} mock_remote.return_value.server_version = (3, 1, 3) + mock_remote.return_value.camera_identifier = "uuid" assert await async_setup_component(hass, "camera", {"camera": config}) await hass.async_block_till_done() @@ -260,7 +261,6 @@ async def test_setup_incomplete_config( [ (nvr.NotAuthorized, 0), (nvr.NvrError, 2), - (requests.exceptions.ConnectionError, 2), ], ) async def test_setup_nvr_errors_during_indexing( @@ -293,7 +293,6 @@ async def test_setup_nvr_errors_during_indexing( [ (nvr.NotAuthorized, 0), (nvr.NvrError, 2), - (requests.exceptions.ConnectionError, 2), ], ) async def test_setup_nvr_errors_during_initialization( From 5c2d7b8fa55fd67cd4f4b1f72697fe90ef52eb2d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 10 Sep 2024 22:04:53 +0200 Subject: [PATCH 0510/1309] Fix incomfort invalid setpoint if override is reported as 0.0 (#125694) --- homeassistant/components/incomfort/climate.py | 4 +- .../incomfort/snapshots/test_climate.ambr | 70 ++++++++++++++++++- tests/components/incomfort/test_climate.py | 15 +++- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index dc08ce8a6c0..eccf03588dc 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -90,8 +90,10 @@ class InComfortClimate(IncomfortEntity, ClimateEntity): As we set the override, we report back the override. The actual set point is is returned at a later time. + Some older thermostats return 0.0 as override, in that case we fallback to + the actual setpoint. """ - return self._room.override + return self._room.override or self._room.setpoint async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature for this zone.""" diff --git a/tests/components/incomfort/snapshots/test_climate.ambr b/tests/components/incomfort/snapshots/test_climate.ambr index 05b2d4878d0..17adcbb3bab 100644 --- a/tests/components/incomfort/snapshots/test_climate.ambr +++ b/tests/components/incomfort/snapshots/test_climate.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_platform[climate.thermostat_1-entry] +# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -38,7 +38,73 @@ 'unit_of_measurement': None, }) # --- -# name: test_setup_platform[climate.thermostat_1-state] +# name: test_setup_platform[legacy_thermostat][climate.thermostat_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 21.4, + 'friendly_name': 'Thermostat 1', + 'hvac_action': , + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + 'status': dict({ + 'override': 0.0, + 'room_temp': 21.42, + 'setpoint': 18.0, + }), + 'supported_features': , + 'temperature': 18.0, + }), + 'context': , + 'entity_id': 'climate.thermostat_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_setup_platform[new_thermostat][climate.thermostat_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 30.0, + 'min_temp': 5.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.thermostat_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'incomfort', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'c0ffeec0ffee_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup_platform[new_thermostat][climate.thermostat_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_temperature': 21.4, diff --git a/tests/components/incomfort/test_climate.py b/tests/components/incomfort/test_climate.py index d5f7397aaaf..ae4c1cf31f7 100644 --- a/tests/components/incomfort/test_climate.py +++ b/tests/components/incomfort/test_climate.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy import SnapshotAssertion from homeassistant.config_entries import ConfigEntry @@ -13,6 +14,14 @@ from tests.common import snapshot_platform @patch("homeassistant.components.incomfort.PLATFORMS", [Platform.CLIMATE]) +@pytest.mark.parametrize( + "mock_room_status", + [ + {"room_temp": 21.42, "setpoint": 18.0, "override": 18.0}, + {"room_temp": 21.42, "setpoint": 18.0, "override": 0.0}, + ], + ids=["new_thermostat", "legacy_thermostat"], +) async def test_setup_platform( hass: HomeAssistant, mock_incomfort: MagicMock, @@ -20,6 +29,10 @@ async def test_setup_platform( snapshot: SnapshotAssertion, mock_config_entry: ConfigEntry, ) -> None: - """Test the incomfort entities are set up correctly.""" + """Test the incomfort entities are set up correctly. + + Legacy thermostats report 0.0 as override if no override is set, + but new thermostat sync the override with the actual setpoint instead. + """ await hass.config_entries.async_setup(mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From b640efa2095771f83334ceecb2ec76b7d6f96671 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:23:34 -0400 Subject: [PATCH 0511/1309] Bump aiostreammagic to 2.1.0 (#125696) --- homeassistant/components/cambridge_audio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 71c5368b631..8fc28a6e47e 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], - "requirements": ["aiostreammagic==2.0.3"], + "requirements": ["aiostreammagic==2.1.0"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index cd5804cc0eb..08e8fd3856e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -374,7 +374,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.0.3 +aiostreammagic==2.1.0 # homeassistant.components.switcher_kis aioswitcher==4.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d92bae18a0..d38d09c8fe6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -356,7 +356,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.0.3 +aiostreammagic==2.1.0 # homeassistant.components.switcher_kis aioswitcher==4.0.3 From 69530a5c94f6e42b690205986c1de985e492870e Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 10 Sep 2024 16:34:51 -0400 Subject: [PATCH 0512/1309] Add pre-amp support for Cambridge Audio (#125699) --- .../cambridge_audio/media_player.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index a60c5420cd8..27be2a60e52 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -24,6 +24,12 @@ BASE_FEATURES = ( | MediaPlayerEntityFeature.TURN_ON ) +PREAMP_FEATURES = ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP +) + async def async_setup_entry( hass: HomeAssistant, @@ -63,6 +69,8 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): """Supported features for the media player.""" controls = self.client.now_playing.controls features = BASE_FEATURES + if self.client.state.pre_amp_mode: + features |= PREAMP_FEATURES if "play_pause" in controls: features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE if "play" in controls: @@ -145,6 +153,17 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): """Last time the media position was updated.""" return self.client.position_last_updated + @property + def is_volume_muted(self) -> bool | None: + """Volume mute status.""" + return self.client.state.mute + + @property + def volume_level(self) -> float | None: + """Current pre-amp volume level.""" + volume = self.client.state.volume_percent or 0 + return volume / 100 + async def async_media_play_pause(self) -> None: """Toggle play/pause the current media.""" await self.client.play_pause() @@ -188,3 +207,22 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): async def async_turn_off(self) -> None: """Power off the device.""" await self.client.power_off() + + async def async_volume_up(self) -> None: + """Step the volume up.""" + await self.client.volume_up() + + async def async_volume_down(self) -> None: + """Step the volume down.""" + await self.client.volume_down() + + async def async_set_volume_level(self, volume: float) -> None: + """Set the volume level.""" + await self.client.set_volume(int(volume * 100)) + + async def async_mute_volume(self, mute: bool) -> None: + """Set the mute state.""" + if mute: + await self.client.mute() + else: + await self.client.unmute() From 6c5dfd0bbc821f93f084c14e304da77768a17620 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 10 Sep 2024 22:35:24 +0200 Subject: [PATCH 0513/1309] Fix failing elevenlabs tts test (#125698) --- tests/components/elevenlabs/test_tts.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index f79244e3c1c..37866a53c5b 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -440,5 +440,11 @@ async def test_tts_service_speak_without_options( ) tts_entity._client.generate.assert_called_once_with( - text="There is a person at the front door.", voice="voice1", model="model1" + text="There is a person at the front door.", + voice="voice1", + optimize_streaming_latency=0, + voice_settings=VoiceSettings( + stability=0.5, similarity_boost=0.75, style=0.0, use_speaker_boost=True + ), + model="model1", ) From 2e54967a6da0b42d97f95157ca540a9d0ed12f66 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Tue, 10 Sep 2024 22:49:45 +0200 Subject: [PATCH 0514/1309] Add select platform to opentherm_gw (#125585) * * Add select platform to opentherm_gw * Add tests for select entities * Address capitalization feedback * Add initial state on startup and status update support * Wrap lambdas in parentheses --- .../components/opentherm_gw/__init__.py | 1 + .../components/opentherm_gw/select.py | 148 ++++++++++++++++++ .../components/opentherm_gw/strings.json | 16 ++ tests/components/opentherm_gw/test_select.py | 120 ++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 homeassistant/components/opentherm_gw/select.py create mode 100644 tests/components/opentherm_gw/test_select.py diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index d5dae367959..5ce9d808b21 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -96,6 +96,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/opentherm_gw/select.py b/homeassistant/components/opentherm_gw/select.py new file mode 100644 index 00000000000..49878d6d839 --- /dev/null +++ b/homeassistant/components/opentherm_gw/select.py @@ -0,0 +1,148 @@ +"""Support for OpenTherm Gateway select entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from enum import IntEnum, StrEnum +from functools import partial + +from pyotgw.vars import OTGW_GPIO_A, OTGW_GPIO_B + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OpenThermGatewayHub +from .const import ( + DATA_GATEWAYS, + DATA_OPENTHERM_GW, + GATEWAY_DEVICE_DESCRIPTION, + OpenThermDataSource, +) +from .entity import OpenThermEntityDescription, OpenThermStatusEntity + + +class OpenThermSelectGPIOMode(StrEnum): + """OpenTherm Gateway GPIO modes.""" + + INPUT = "input" + GROUND = "ground" + VCC = "vcc" + LED_E = "led_e" + LED_F = "led_f" + HOME = "home" + AWAY = "away" + DS1820 = "ds1820" + DHW_BLOCK = "dhw_block" + + +class PyotgwGPIOMode(IntEnum): + """pyotgw GPIO modes.""" + + INPUT = 0 + GROUND = 1 + VCC = 2 + LED_E = 3 + LED_F = 4 + HOME = 5 + AWAY = 6 + DS1820 = 7 + DHW_BLOCK = 8 + + +async def set_gpio_mode( + gpio_id: str, gw_hub: OpenThermGatewayHub, mode: str +) -> OpenThermSelectGPIOMode | None: + """Set gpio mode, return selected option or None.""" + value = await gw_hub.gateway.set_gpio_mode( + gpio_id, PyotgwGPIOMode[OpenThermSelectGPIOMode(mode).name] + ) + return ( + OpenThermSelectGPIOMode[PyotgwGPIOMode(value).name] + if value in PyotgwGPIOMode + else None + ) + + +@dataclass(frozen=True, kw_only=True) +class OpenThermSelectEntityDescription( + OpenThermEntityDescription, SelectEntityDescription +): + """Describes an opentherm_gw select entity.""" + + select_action: Callable[[OpenThermGatewayHub, str], Awaitable] + convert_pyotgw_state_to_ha_state: Callable + + +SELECT_DESCRIPTIONS: tuple[OpenThermSelectEntityDescription, ...] = ( + OpenThermSelectEntityDescription( + key=OTGW_GPIO_A, + translation_key="gpio_mode_n", + translation_placeholders={"gpio_id": "A"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + options=[ + mode + for mode in OpenThermSelectGPIOMode + if mode != OpenThermSelectGPIOMode.DS1820 + ], + select_action=partial(set_gpio_mode, "A"), + convert_pyotgw_state_to_ha_state=( + lambda state: OpenThermSelectGPIOMode[PyotgwGPIOMode(state).name] + if state in PyotgwGPIOMode + else None + ), + ), + OpenThermSelectEntityDescription( + key=OTGW_GPIO_B, + translation_key="gpio_mode_n", + translation_placeholders={"gpio_id": "B"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + options=list(OpenThermSelectGPIOMode), + select_action=partial(set_gpio_mode, "B"), + convert_pyotgw_state_to_ha_state=( + lambda state: OpenThermSelectGPIOMode[PyotgwGPIOMode(state).name] + if state in PyotgwGPIOMode + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the OpenTherm Gateway select entities.""" + gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] + + async_add_entities( + OpenThermSelect(gw_hub, description) for description in SELECT_DESCRIPTIONS + ) + + +class OpenThermSelect(OpenThermStatusEntity, SelectEntity): + """Represent an OpenTherm Gateway select.""" + + _attr_current_option = None + _attr_entity_category = EntityCategory.CONFIG + entity_description: OpenThermSelectEntityDescription + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + new_option = await self.entity_description.select_action(self._gateway, option) + if new_option is not None: + self._attr_current_option = new_option + self.async_write_ha_state() + + @callback + def receive_report(self, status: dict[OpenThermDataSource, dict]) -> None: + """Handle status updates from the component.""" + state = status[self.entity_description.device_description.data_source].get( + self.entity_description.key + ) + self._attr_current_option = ( + self.entity_description.convert_pyotgw_state_to_ha_state(state) + ) + self.async_write_ha_state() diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index f0573db0531..e4d72ad8fb5 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -158,6 +158,22 @@ "name": "Programmed change has priority over override" } }, + "select": { + "gpio_mode_n": { + "name": "GPIO {gpio_id} mode", + "state": { + "input": "Input", + "ground": "Ground", + "vcc": "Vcc (5V)", + "led_e": "LED E", + "led_f": "LED F", + "home": "Home", + "away": "Away", + "ds1820": "DS1820", + "dhw_block": "Block hot water" + } + } + }, "sensor": { "control_setpoint_n": { "name": "Control setpoint {circuit_number}" diff --git a/tests/components/opentherm_gw/test_select.py b/tests/components/opentherm_gw/test_select.py new file mode 100644 index 00000000000..e0c4630b036 --- /dev/null +++ b/tests/components/opentherm_gw/test_select.py @@ -0,0 +1,120 @@ +"""Test opentherm_gw select entities.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyotgw.vars import OTGW_GPIO_A, OTGW_GPIO_B +import pytest + +from homeassistant.components.opentherm_gw import DOMAIN as OPENTHERM_DOMAIN +from homeassistant.components.opentherm_gw.const import ( + DATA_GATEWAYS, + DATA_OPENTHERM_GW, + OpenThermDeviceIdentifier, +) +from homeassistant.components.opentherm_gw.select import ( + OpenThermSelectGPIOMode, + PyotgwGPIOMode, +) +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_key", "gpio_id"), + [ + (OTGW_GPIO_A, "A"), + (OTGW_GPIO_B, "B"), + ], +) +async def test_gpio_mode_select( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, + entity_key: str, + gpio_id: str, +) -> None: + """Test GPIO mode selector.""" + + mock_pyotgw.return_value.set_gpio_mode = AsyncMock(return_value=PyotgwGPIOMode.VCC) + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + select_entity_id := entity_registry.async_get_entity_id( + SELECT_DOMAIN, + OPENTHERM_DOMAIN, + f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}-{entity_key}", + ) + ) is not None + assert hass.states.get(select_entity_id).state == STATE_UNKNOWN + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: select_entity_id, ATTR_OPTION: OpenThermSelectGPIOMode.VCC}, + blocking=True, + ) + assert hass.states.get(select_entity_id).state == OpenThermSelectGPIOMode.VCC + + mock_pyotgw.return_value.set_gpio_mode.assert_awaited_once_with( + gpio_id, PyotgwGPIOMode.VCC.value + ) + + +@pytest.mark.parametrize( + ("entity_key"), + [ + (OTGW_GPIO_A), + (OTGW_GPIO_B), + ], +) +async def test_gpio_mode_state_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_pyotgw: MagicMock, + entity_key: str, +) -> None: + """Test GPIO mode selector.""" + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + select_entity_id := entity_registry.async_get_entity_id( + SELECT_DOMAIN, + OPENTHERM_DOMAIN, + f"{mock_config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}-{entity_key}", + ) + ) is not None + assert hass.states.get(select_entity_id).state == STATE_UNKNOWN + + gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][ + mock_config_entry.data[CONF_ID] + ] + async_dispatcher_send( + hass, + gw_hub.update_signal, + { + OpenThermDeviceIdentifier.BOILER: {}, + OpenThermDeviceIdentifier.GATEWAY: {entity_key: 4}, + OpenThermDeviceIdentifier.THERMOSTAT: {}, + }, + ) + await hass.async_block_till_done() + + assert hass.states.get(select_entity_id).state == OpenThermSelectGPIOMode.LED_F From 1be455e0e0f052bb4dbc8ee7b45e8cf842a5b795 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Tue, 10 Sep 2024 23:59:50 +0300 Subject: [PATCH 0515/1309] Add URL description for Sabnzbd integration (#125414) * Create pull.yml * Add URL description * remove file --- homeassistant/components/sabnzbd/strings.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index f8c831cd95a..5b7312e3b0d 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -6,6 +6,9 @@ "api_key": "[%key:common::config_flow::data::api_key%]", "name": "[%key:common::config_flow::data::name%]", "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The full URL, including port, of the SABnzbd server. Example: `http://localhost:8080` or `http://a02368d7-sabnzbd:8080`" } } }, From 2611f72f5d26efde15d5523506dd1264c8736761 Mon Sep 17 00:00:00 2001 From: mvn23 Date: Wed, 11 Sep 2024 00:12:09 +0200 Subject: [PATCH 0516/1309] Add LED mode select entities to opentherm_gw (#125702) Add select entities for LED mode to opentherm_gw --- .../components/opentherm_gw/select.py | 124 ++++++++++++++- .../components/opentherm_gw/strings.json | 17 +++ tests/components/opentherm_gw/test_select.py | 142 +++++++++++++++--- 3 files changed, 264 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/opentherm_gw/select.py b/homeassistant/components/opentherm_gw/select.py index 49878d6d839..cee1632dc48 100644 --- a/homeassistant/components/opentherm_gw/select.py +++ b/homeassistant/components/opentherm_gw/select.py @@ -5,7 +5,16 @@ from dataclasses import dataclass from enum import IntEnum, StrEnum from functools import partial -from pyotgw.vars import OTGW_GPIO_A, OTGW_GPIO_B +from pyotgw.vars import ( + OTGW_GPIO_A, + OTGW_GPIO_B, + OTGW_LED_A, + OTGW_LED_B, + OTGW_LED_C, + OTGW_LED_D, + OTGW_LED_E, + OTGW_LED_F, +) from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -37,6 +46,23 @@ class OpenThermSelectGPIOMode(StrEnum): DHW_BLOCK = "dhw_block" +class OpenThermSelectLEDMode(StrEnum): + """OpenThermGateway LED modes.""" + + RX_ANY = "receive_any" + TX_ANY = "transmit_any" + THERMOSTAT_TRAFFIC = "thermostat_traffic" + BOILER_TRAFFIC = "boiler_traffic" + SETPOINT_OVERRIDE_ACTIVE = "setpoint_override_active" + FLAME_ON = "flame_on" + CENTRAL_HEATING_ON = "central_heating_on" + HOT_WATER_ON = "hot_water_on" + COMFORT_MODE_ON = "comfort_mode_on" + TX_ERROR_DETECTED = "transmit_error_detected" + BOILER_MAINTENANCE_REQUIRED = "boiler_maintenance_required" + RAISED_POWER_MODE_ACTIVE = "raised_power_mode_active" + + class PyotgwGPIOMode(IntEnum): """pyotgw GPIO modes.""" @@ -51,6 +77,34 @@ class PyotgwGPIOMode(IntEnum): DHW_BLOCK = 8 +class PyotgwLEDMode(StrEnum): + """pyotgw LED modes.""" + + RX_ANY = "R" + TX_ANY = "X" + THERMOSTAT_TRAFFIC = "T" + BOILER_TRAFFIC = "B" + SETPOINT_OVERRIDE_ACTIVE = "O" + FLAME_ON = "F" + CENTRAL_HEATING_ON = "H" + HOT_WATER_ON = "W" + COMFORT_MODE_ON = "C" + TX_ERROR_DETECTED = "E" + BOILER_MAINTENANCE_REQUIRED = "M" + RAISED_POWER_MODE_ACTIVE = "P" + + +def pyotgw_led_mode_to_ha_led_mode( + pyotgw_led_mode: PyotgwLEDMode, +) -> OpenThermSelectLEDMode | None: + """Convert pyotgw LED mode to Home Assistant LED mode.""" + return ( + OpenThermSelectLEDMode[PyotgwLEDMode(pyotgw_led_mode).name] + if pyotgw_led_mode in PyotgwLEDMode + else None + ) + + async def set_gpio_mode( gpio_id: str, gw_hub: OpenThermGatewayHub, mode: str ) -> OpenThermSelectGPIOMode | None: @@ -65,6 +119,20 @@ async def set_gpio_mode( ) +async def set_led_mode( + led_id: str, gw_hub: OpenThermGatewayHub, mode: str +) -> OpenThermSelectLEDMode | None: + """Set gpio mode, return selected option or None.""" + value = await gw_hub.gateway.set_led_mode( + led_id, PyotgwLEDMode[OpenThermSelectLEDMode(mode).name] + ) + return ( + OpenThermSelectLEDMode[PyotgwLEDMode(value).name] + if value in PyotgwLEDMode + else None + ) + + @dataclass(frozen=True, kw_only=True) class OpenThermSelectEntityDescription( OpenThermEntityDescription, SelectEntityDescription @@ -106,6 +174,60 @@ SELECT_DESCRIPTIONS: tuple[OpenThermSelectEntityDescription, ...] = ( else None ), ), + OpenThermSelectEntityDescription( + key=OTGW_LED_A, + translation_key="led_mode_n", + translation_placeholders={"led_id": "A"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + options=list(OpenThermSelectLEDMode), + select_action=partial(set_led_mode, "A"), + convert_pyotgw_state_to_ha_state=pyotgw_led_mode_to_ha_led_mode, + ), + OpenThermSelectEntityDescription( + key=OTGW_LED_B, + translation_key="led_mode_n", + translation_placeholders={"led_id": "B"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + options=list(OpenThermSelectLEDMode), + select_action=partial(set_led_mode, "B"), + convert_pyotgw_state_to_ha_state=pyotgw_led_mode_to_ha_led_mode, + ), + OpenThermSelectEntityDescription( + key=OTGW_LED_C, + translation_key="led_mode_n", + translation_placeholders={"led_id": "C"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + options=list(OpenThermSelectLEDMode), + select_action=partial(set_led_mode, "C"), + convert_pyotgw_state_to_ha_state=pyotgw_led_mode_to_ha_led_mode, + ), + OpenThermSelectEntityDescription( + key=OTGW_LED_D, + translation_key="led_mode_n", + translation_placeholders={"led_id": "D"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + options=list(OpenThermSelectLEDMode), + select_action=partial(set_led_mode, "D"), + convert_pyotgw_state_to_ha_state=pyotgw_led_mode_to_ha_led_mode, + ), + OpenThermSelectEntityDescription( + key=OTGW_LED_E, + translation_key="led_mode_n", + translation_placeholders={"led_id": "E"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + options=list(OpenThermSelectLEDMode), + select_action=partial(set_led_mode, "E"), + convert_pyotgw_state_to_ha_state=pyotgw_led_mode_to_ha_led_mode, + ), + OpenThermSelectEntityDescription( + key=OTGW_LED_F, + translation_key="led_mode_n", + translation_placeholders={"led_id": "F"}, + device_description=GATEWAY_DEVICE_DESCRIPTION, + options=list(OpenThermSelectLEDMode), + select_action=partial(set_led_mode, "F"), + convert_pyotgw_state_to_ha_state=pyotgw_led_mode_to_ha_led_mode, + ), ) diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index e4d72ad8fb5..834168eb113 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -172,6 +172,23 @@ "ds1820": "DS1820", "dhw_block": "Block hot water" } + }, + "led_mode_n": { + "name": "LED {led_id} mode", + "state": { + "receive_any": "Receiving on any interface", + "transmit_any": "Transmitting on any interface", + "thermostat_traffic": "Traffic on the thermostat interface", + "boiler_traffic": "Traffic on the boiler interface", + "setpoint_override_active": "Setpoint override is active", + "flame_on": "Boiler flame is on", + "central_heating_on": "Central heating is on", + "hot_water_on": "Hot water is on", + "comfort_mode_on": "Comfort mode is on", + "transmit_error_detected": "Transmit error detected", + "boiler_maintenance_required": "Boiler maintenance required", + "raised_power_mode_active": "Raised power mode active" + } } }, "sensor": { diff --git a/tests/components/opentherm_gw/test_select.py b/tests/components/opentherm_gw/test_select.py index e0c4630b036..f89224b3874 100644 --- a/tests/components/opentherm_gw/test_select.py +++ b/tests/components/opentherm_gw/test_select.py @@ -1,8 +1,18 @@ """Test opentherm_gw select entities.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock -from pyotgw.vars import OTGW_GPIO_A, OTGW_GPIO_B +from pyotgw.vars import ( + OTGW_GPIO_A, + OTGW_GPIO_B, + OTGW_LED_A, + OTGW_LED_B, + OTGW_LED_C, + OTGW_LED_D, + OTGW_LED_E, + OTGW_LED_F, +) import pytest from homeassistant.components.opentherm_gw import DOMAIN as OPENTHERM_DOMAIN @@ -13,7 +23,9 @@ from homeassistant.components.opentherm_gw.const import ( ) from homeassistant.components.opentherm_gw.select import ( OpenThermSelectGPIOMode, + OpenThermSelectLEDMode, PyotgwGPIOMode, + PyotgwLEDMode, ) from homeassistant.components.select import ( ATTR_OPTION, @@ -29,23 +41,90 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( - ("entity_key", "gpio_id"), + ( + "entity_key", + "target_func_name", + "target_param_1", + "target_param_2", + "resulting_state", + ), [ - (OTGW_GPIO_A, "A"), - (OTGW_GPIO_B, "B"), + ( + OTGW_GPIO_A, + "set_gpio_mode", + "A", + PyotgwGPIOMode.VCC, + OpenThermSelectGPIOMode.VCC, + ), + ( + OTGW_GPIO_B, + "set_gpio_mode", + "B", + PyotgwGPIOMode.HOME, + OpenThermSelectGPIOMode.HOME, + ), + ( + OTGW_LED_A, + "set_led_mode", + "A", + PyotgwLEDMode.TX_ANY, + OpenThermSelectLEDMode.TX_ANY, + ), + ( + OTGW_LED_B, + "set_led_mode", + "B", + PyotgwLEDMode.RX_ANY, + OpenThermSelectLEDMode.RX_ANY, + ), + ( + OTGW_LED_C, + "set_led_mode", + "C", + PyotgwLEDMode.BOILER_TRAFFIC, + OpenThermSelectLEDMode.BOILER_TRAFFIC, + ), + ( + OTGW_LED_D, + "set_led_mode", + "D", + PyotgwLEDMode.THERMOSTAT_TRAFFIC, + OpenThermSelectLEDMode.THERMOSTAT_TRAFFIC, + ), + ( + OTGW_LED_E, + "set_led_mode", + "E", + PyotgwLEDMode.FLAME_ON, + OpenThermSelectLEDMode.FLAME_ON, + ), + ( + OTGW_LED_F, + "set_led_mode", + "F", + PyotgwLEDMode.BOILER_MAINTENANCE_REQUIRED, + OpenThermSelectLEDMode.BOILER_MAINTENANCE_REQUIRED, + ), ], ) -async def test_gpio_mode_select( +async def test_select_change_value( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_pyotgw: MagicMock, entity_key: str, - gpio_id: str, + target_func_name: str, + target_param_1: str, + target_param_2: str | int, + resulting_state: str, ) -> None: """Test GPIO mode selector.""" - mock_pyotgw.return_value.set_gpio_mode = AsyncMock(return_value=PyotgwGPIOMode.VCC) + setattr( + mock_pyotgw.return_value, + target_func_name, + AsyncMock(return_value=target_param_2), + ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -63,29 +142,56 @@ async def test_gpio_mode_select( await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: select_entity_id, ATTR_OPTION: OpenThermSelectGPIOMode.VCC}, + {ATTR_ENTITY_ID: select_entity_id, ATTR_OPTION: resulting_state}, blocking=True, ) - assert hass.states.get(select_entity_id).state == OpenThermSelectGPIOMode.VCC + assert hass.states.get(select_entity_id).state == resulting_state - mock_pyotgw.return_value.set_gpio_mode.assert_awaited_once_with( - gpio_id, PyotgwGPIOMode.VCC.value - ) + target = getattr(mock_pyotgw.return_value, target_func_name) + target.assert_awaited_once_with(target_param_1, target_param_2) @pytest.mark.parametrize( - ("entity_key"), + ("entity_key", "test_value", "resulting_state"), [ - (OTGW_GPIO_A), - (OTGW_GPIO_B), + (OTGW_GPIO_A, PyotgwGPIOMode.AWAY, OpenThermSelectGPIOMode.AWAY), + (OTGW_GPIO_B, PyotgwGPIOMode.LED_F, OpenThermSelectGPIOMode.LED_F), + ( + OTGW_LED_A, + PyotgwLEDMode.SETPOINT_OVERRIDE_ACTIVE, + OpenThermSelectLEDMode.SETPOINT_OVERRIDE_ACTIVE, + ), + ( + OTGW_LED_B, + PyotgwLEDMode.CENTRAL_HEATING_ON, + OpenThermSelectLEDMode.CENTRAL_HEATING_ON, + ), + (OTGW_LED_C, PyotgwLEDMode.HOT_WATER_ON, OpenThermSelectLEDMode.HOT_WATER_ON), + ( + OTGW_LED_D, + PyotgwLEDMode.COMFORT_MODE_ON, + OpenThermSelectLEDMode.COMFORT_MODE_ON, + ), + ( + OTGW_LED_E, + PyotgwLEDMode.TX_ERROR_DETECTED, + OpenThermSelectLEDMode.TX_ERROR_DETECTED, + ), + ( + OTGW_LED_F, + PyotgwLEDMode.RAISED_POWER_MODE_ACTIVE, + OpenThermSelectLEDMode.RAISED_POWER_MODE_ACTIVE, + ), ], ) -async def test_gpio_mode_state_update( +async def test_select_state_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_pyotgw: MagicMock, entity_key: str, + test_value: Any, + resulting_state: str, ) -> None: """Test GPIO mode selector.""" @@ -111,10 +217,10 @@ async def test_gpio_mode_state_update( gw_hub.update_signal, { OpenThermDeviceIdentifier.BOILER: {}, - OpenThermDeviceIdentifier.GATEWAY: {entity_key: 4}, + OpenThermDeviceIdentifier.GATEWAY: {entity_key: test_value}, OpenThermDeviceIdentifier.THERMOSTAT: {}, }, ) await hass.async_block_till_done() - assert hass.states.get(select_entity_id).state == OpenThermSelectGPIOMode.LED_F + assert hass.states.get(select_entity_id).state == resulting_state From c01bdd860abf96cf903dd2d3b6b2e34167c6686b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 10 Sep 2024 19:50:22 -0500 Subject: [PATCH 0517/1309] Unload assist satellite platform on disconnect (#125697) --- homeassistant/components/esphome/manager.py | 7 ++++ .../esphome/test_assist_satellite.py | 37 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 09c3cc3b7cb..c36a55d1f55 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -476,6 +476,13 @@ class ESPHomeManager: # will be cleared anyway. entry_data.async_update_device_state() + if Platform.ASSIST_SATELLITE in self.entry_data.loaded_platforms: + await self.hass.config_entries.async_unload_platforms( + self.entry, [Platform.ASSIST_SATELLITE] + ) + + self.entry_data.loaded_platforms.remove(Platform.ASSIST_SATELLITE) + async def on_connect_error(self, err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" if isinstance( diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index e245cfcf3bf..89840daf454 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -36,7 +36,7 @@ from homeassistant.components.esphome.assist_satellite import ( VoiceAssistantUDPServer, ) from homeassistant.components.media_source import PlayMedia -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, intent as intent_helper import homeassistant.helpers.device_registry as dr @@ -1038,3 +1038,38 @@ async def test_announce_media_id( blocking=True, ) await done.wait() + + +async def test_satellite_unloaded_on_disconnect( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test that the assist satellite platform is unloaded on disconnect.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + state = hass.states.get(satellite.entity_id) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + # Device will be unavailable after disconnect + await mock_device.mock_disconnect(True) + + state = hass.states.get(satellite.entity_id) + assert state is not None + assert state.state == STATE_UNAVAILABLE From 8e0b2b752cf465394a9257aa26899aed68397547 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 10 Sep 2024 19:56:15 -0500 Subject: [PATCH 0518/1309] Cancel running pipeline on new pipeline or announcement (#125687) * Cancel running pipeline * Incorporate feedback * Change to async_create_task --- .../components/assist_satellite/entity.py | 69 +++++++++----- .../assist_satellite/test_entity.py | 91 +++++++++++++++++++ 2 files changed, 138 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 6f0e588052a..897f9ed244b 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -3,6 +3,7 @@ from abc import abstractmethod import asyncio from collections.abc import AsyncIterable +import contextlib from enum import StrEnum import logging import time @@ -73,6 +74,7 @@ class AssistSatelliteEntity(entity.Entity): _is_announcing = False _wake_word_intercept_future: asyncio.Future[str | None] | None = None _attr_tts_options: dict[str, Any] | None = None + _pipeline_task: asyncio.Task | None = None __assist_satellite_state = AssistSatelliteState.LISTENING_WAKE_WORD @@ -131,6 +133,8 @@ class AssistSatelliteEntity(entity.Entity): Calls async_announce with message and media id. """ + await self._cancel_running_pipeline() + if message is None: message = "" @@ -176,7 +180,7 @@ class AssistSatelliteEntity(entity.Entity): await self.async_announce(message, media_id) finally: self._is_announcing = False - self.tts_response_finished() + self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD) async def async_announce(self, message: str, media_id: str) -> None: """Announce media on the satellite. @@ -193,6 +197,8 @@ class AssistSatelliteEntity(entity.Entity): wake_word_phrase: str | None = None, ) -> None: """Triggers an Assist pipeline in Home Assistant from a satellite.""" + await self._cancel_running_pipeline() + if self._wake_word_intercept_future and start_stage in ( PipelineStage.WAKE_WORD, PipelineStage.STT, @@ -248,31 +254,50 @@ class AssistSatelliteEntity(entity.Entity): # Set entity state based on pipeline events self._run_has_tts = False - await async_pipeline_from_audio_stream( + assert self.platform.config_entry is not None + self._pipeline_task = self.platform.config_entry.async_create_background_task( self.hass, - context=self._context, - event_callback=self._internal_on_pipeline_event, - stt_metadata=stt.SpeechMetadata( - language="", # set in async_pipeline_from_audio_stream - format=stt.AudioFormats.WAV, - codec=stt.AudioCodecs.PCM, - bit_rate=stt.AudioBitRates.BITRATE_16, - sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, - channel=stt.AudioChannels.CHANNEL_MONO, + async_pipeline_from_audio_stream( + self.hass, + context=self._context, + event_callback=self._internal_on_pipeline_event, + stt_metadata=stt.SpeechMetadata( + language="", # set in async_pipeline_from_audio_stream + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ), + stt_stream=audio_stream, + pipeline_id=self._resolve_pipeline(), + conversation_id=self._conversation_id, + device_id=device_id, + tts_audio_output=self.tts_options, + wake_word_phrase=wake_word_phrase, + audio_settings=AudioSettings( + silence_seconds=self._resolve_vad_sensitivity() + ), + start_stage=start_stage, + end_stage=end_stage, ), - stt_stream=audio_stream, - pipeline_id=self._resolve_pipeline(), - conversation_id=self._conversation_id, - device_id=device_id, - tts_audio_output=self.tts_options, - wake_word_phrase=wake_word_phrase, - audio_settings=AudioSettings( - silence_seconds=self._resolve_vad_sensitivity() - ), - start_stage=start_stage, - end_stage=end_stage, + f"{self.entity_id}_pipeline", ) + try: + await self._pipeline_task + finally: + self._pipeline_task = None + + async def _cancel_running_pipeline(self) -> None: + """Cancel the current pipeline if it's running.""" + if self._pipeline_task is not None: + self._pipeline_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._pipeline_task + + self._pipeline_task = None + @abstractmethod def on_pipeline_event(self, event: PipelineEvent) -> None: """Handle pipeline events.""" diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index a46f754dd4e..3e58239f921 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -93,6 +93,55 @@ async def test_entity_state( assert state.state == AssistSatelliteState.LISTENING_WAKE_WORD +async def test_new_pipeline_cancels_pipeline( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Test that a new pipeline run cancels any running pipeline.""" + pipeline1_started = asyncio.Event() + pipeline1_finished = asyncio.Event() + pipeline1_cancelled = asyncio.Event() + pipeline2_finished = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + if not pipeline1_started.is_set(): + # First pipeline run + pipeline1_started.set() + + # Wait for pipeline to be cancelled + try: + await pipeline1_finished.wait() + except asyncio.CancelledError: + pipeline1_cancelled.set() + raise + else: + # Second pipeline run + pipeline2_finished.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + ): + hass.async_create_task( + entity.async_accept_pipeline_from_satellite( + object(), # type: ignore[arg-type] + ) + ) + + async with asyncio.timeout(1): + await pipeline1_started.wait() + + # Start a second pipeline + await entity.async_accept_pipeline_from_satellite( + object(), # type: ignore[arg-type] + ) + await pipeline1_cancelled.wait() + await pipeline2_finished.wait() + + @pytest.mark.parametrize( ("service_data", "expected_params"), [ @@ -210,6 +259,48 @@ async def test_announce_busy( await announce_task +async def test_announce_cancels_pipeline( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, +) -> None: + """Test that announcements cancel any running pipeline.""" + media_id = "https://www.home-assistant.io/resolved.mp3" + pipeline_started = asyncio.Event() + pipeline_finished = asyncio.Event() + pipeline_cancelled = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + pipeline_started.set() + + # Wait for pipeline to be cancelled + try: + await pipeline_finished.wait() + except asyncio.CancelledError: + pipeline_cancelled.set() + raise + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch.object(entity, "async_announce") as mock_async_announce, + ): + hass.async_create_task( + entity.async_accept_pipeline_from_satellite( + object(), # type: ignore[arg-type] + ) + ) + + async with asyncio.timeout(1): + await pipeline_started.wait() + await entity.async_internal_announce(None, media_id) + await pipeline_cancelled.wait() + + mock_async_announce.assert_called_once() + + async def test_context_refresh( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: From 56dfb2c73453dfa25107ed887b2bbfbfeb5693a0 Mon Sep 17 00:00:00 2001 From: chammp <57918757+chammp@users.noreply.github.com> Date: Wed, 11 Sep 2024 08:47:17 +0200 Subject: [PATCH 0519/1309] Add unit_of_measurement to template numbers (#122862) --- .../components/template/config_flow.py | 5 +++ homeassistant/components/template/number.py | 7 ++++ .../components/template/strings.json | 3 +- tests/components/template/test_config_flow.py | 4 +++ tests/components/template/test_number.py | 35 +++++++++++++------ 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index ba4f4a78f53..a8a7c1b9971 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -116,6 +116,11 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: vol.Required(CONF_STEP, default=DEFAULT_STEP): selector.NumberSelector( selector.NumberSelectorConfig(mode=selector.NumberSelectorMode.BOX), ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.TEXT, multiline=False + ) + ), vol.Optional(CONF_SET_VALUE): selector.ActionSelector(), } diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index e051f124149..90dd555ca42 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -22,6 +22,7 @@ from homeassistant.const import ( CONF_OPTIMISTIC, CONF_STATE, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector @@ -55,6 +56,7 @@ NUMBER_SCHEMA = ( vol.Required(CONF_STEP): cv.template, vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_UNIQUE_ID): cv.string, } @@ -70,6 +72,7 @@ NUMBER_CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, vol.Optional(CONF_MIN): cv.template, vol.Optional(CONF_MAX): cv.template, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } ) @@ -159,6 +162,7 @@ class TemplateNumber(TemplateEntity, NumberEntity): self._min_value_template = config[CONF_MIN] self._max_value_template = config[CONF_MAX] self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_native_step = DEFAULT_STEP self._attr_native_min_value = DEFAULT_MIN_VALUE self._attr_native_max_value = DEFAULT_MAX_VALUE @@ -230,6 +234,7 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) + self._command_set_value = Script( hass, config[CONF_SET_VALUE], @@ -237,6 +242,8 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): DOMAIN, ) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + @property def native_value(self) -> float | None: """Return the currently selected option.""" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index fa365bf3cfd..4a79ee62d30 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -45,7 +45,8 @@ "step": "Step value", "set_value": "Actions on set value", "max": "Maximum value", - "min": "Minimum value" + "min": "Minimum value", + "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" }, "data_description": { "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 9a89d72dc2e..380a0a8f53e 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -101,6 +101,7 @@ from tests.typing import WebSocketGenerator "min": "0", "max": "100", "step": "0.1", + "unit_of_measurement": "cm", "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -111,6 +112,7 @@ from tests.typing import WebSocketGenerator "min": 0, "max": 100, "step": 0.1, + "unit_of_measurement": "cm", "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -454,6 +456,7 @@ def get_suggested(schema, key): "min": 0, "max": 100, "step": 0.1, + "unit_of_measurement": "cm", "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, @@ -464,6 +467,7 @@ def get_suggested(schema, key): "min": 0, "max": 100, "step": 0.1, + "unit_of_measurement": "cm", "set_value": { "action": "input_number.set_value", "target": {"entity_id": "input_number.test"}, diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index 43decf848ff..ec96245b4d0 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -17,7 +17,12 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE as NUMBER_SERVICE_SET_VALUE, ) from homeassistant.components.template import DOMAIN -from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ICON, + CONF_ENTITY_ID, + CONF_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, +) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -100,7 +105,7 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None: await hass.async_start() await hass.async_block_till_done() - _verify(hass, 4, 1, 0.0, 100.0) + _verify(hass, 4, 1, 0.0, 100.0, None) async def test_missing_required_keys(hass: HomeAssistant) -> None: @@ -152,6 +157,7 @@ async def test_all_optional_config(hass: HomeAssistant) -> None: "min": "{{ 3 }}", "max": "{{ 5 }}", "step": "{{ 1 }}", + "unit_of_measurement": "beer", } } }, @@ -161,7 +167,7 @@ async def test_all_optional_config(hass: HomeAssistant) -> None: await hass.async_start() await hass.async_block_till_done() - _verify(hass, 4, 1, 3, 5) + _verify(hass, 4, 1, 3, 5, "beer") async def test_templates_with_entities( @@ -249,7 +255,7 @@ async def test_templates_with_entities( assert entry assert entry.unique_id == "b-a" - _verify(hass, 4, 1, 3, 5) + _verify(hass, 4, 1, 3, 5, None) await hass.services.async_call( INPUT_NUMBER_DOMAIN, @@ -258,7 +264,7 @@ async def test_templates_with_entities( blocking=True, ) await hass.async_block_till_done() - _verify(hass, 5, 1, 3, 5) + _verify(hass, 5, 1, 3, 5, None) await hass.services.async_call( INPUT_NUMBER_DOMAIN, @@ -267,7 +273,7 @@ async def test_templates_with_entities( blocking=True, ) await hass.async_block_till_done() - _verify(hass, 5, 2, 3, 5) + _verify(hass, 5, 2, 3, 5, None) await hass.services.async_call( INPUT_NUMBER_DOMAIN, @@ -276,7 +282,7 @@ async def test_templates_with_entities( blocking=True, ) await hass.async_block_till_done() - _verify(hass, 5, 2, 2, 5) + _verify(hass, 5, 2, 2, 5, None) await hass.services.async_call( INPUT_NUMBER_DOMAIN, @@ -285,7 +291,7 @@ async def test_templates_with_entities( blocking=True, ) await hass.async_block_till_done() - _verify(hass, 5, 2, 2, 6) + _verify(hass, 5, 2, 2, 6, None) await hass.services.async_call( NUMBER_DOMAIN, @@ -293,7 +299,7 @@ async def test_templates_with_entities( {CONF_ENTITY_ID: _TEST_NUMBER, NUMBER_ATTR_VALUE: 2}, blocking=True, ) - _verify(hass, 2, 2, 2, 6) + _verify(hass, 2, 2, 2, 6, None) # Check this variable can be used in set_value script assert len(calls) == 1 @@ -323,6 +329,7 @@ async def test_trigger_number(hass: HomeAssistant) -> None: "min": "{{ trigger.event.data.min_beers }}", "max": "{{ trigger.event.data.max_beers }}", "step": "{{ trigger.event.data.step }}", + "unit_of_measurement": "beer", "set_value": {"event": "test_number_event"}, "optimistic": True, }, @@ -342,11 +349,17 @@ async def test_trigger_number(hass: HomeAssistant) -> None: assert state.attributes["min"] == 0.0 assert state.attributes["max"] == 100.0 assert state.attributes["step"] == 1.0 + assert state.attributes["unit_of_measurement"] == "beer" context = Context() hass.bus.async_fire( "test_event", - {"beers_drank": 3, "min_beers": 1.0, "max_beers": 5.0, "step": 0.5}, + { + "beers_drank": 3, + "min_beers": 1.0, + "max_beers": 5.0, + "step": 0.5, + }, context=context, ) await hass.async_block_till_done() @@ -374,6 +387,7 @@ def _verify( expected_step: int, expected_minimum: int, expected_maximum: int, + expected_unit_of_measurement: str | None, ) -> None: """Verify number's state.""" state = hass.states.get(_TEST_NUMBER) @@ -382,6 +396,7 @@ def _verify( assert attributes.get(ATTR_STEP) == float(expected_step) assert attributes.get(ATTR_MAX) == float(expected_maximum) assert attributes.get(ATTR_MIN) == float(expected_minimum) + assert attributes.get(CONF_UNIT_OF_MEASUREMENT) == expected_unit_of_measurement async def test_icon_template(hass: HomeAssistant) -> None: From 74834b2d88265e9559a19b4e188afbf464f99b6a Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Wed, 11 Sep 2024 03:35:05 -0400 Subject: [PATCH 0520/1309] Pin pyasn1 until fixed (#125712) * pin pyasn1 until fixed * add to gen requirements --- homeassistant/package_constraints.txt | 6 ++++++ script/gen_requirements_all.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 908f2a48f0d..8731a0158b7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -185,3 +185,9 @@ tuf>=4.0.0 # https://github.com/jd/tenacity/issues/471 tenacity!=8.4.0 + +# pyasn1.compat.octets was removed in pyasn1 0.6.1 and breaks some integrations +# and tests that import it directly +# https://github.com/pyasn1/pyasn1/pull/60 +# https://github.com/lextudio/pysnmp/issues/114 +pyasn1==0.6.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 47a6412bcfd..20d6dd3c014 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -205,6 +205,12 @@ tuf>=4.0.0 # https://github.com/jd/tenacity/issues/471 tenacity!=8.4.0 + +# pyasn1.compat.octets was removed in pyasn1 0.6.1 and breaks some integrations +# and tests that import it directly +# https://github.com/pyasn1/pyasn1/pull/60 +# https://github.com/lextudio/pysnmp/issues/114 +pyasn1==0.6.0 """ GENERATED_MESSAGE = ( From b3377fe5fb9f16c69da55b1a93300cd6f6a75dc7 Mon Sep 17 00:00:00 2001 From: chammp <57918757+chammp@users.noreply.github.com> Date: Wed, 11 Sep 2024 09:36:49 +0200 Subject: [PATCH 0521/1309] Add condition to trigger template entities (#119689) * Add conditions to trigger template entities * Add tests * Fix ruff error * Ruff * Apply suggestions from code review * Deduplicate * Tweak name used in debug message * Add and improve type annotations of modified code * Adjust typing * Adjust typing * Add typing and remove unused parameter * Adjust typing Co-authored-by: Martin Hjelmare * Adjust return type Co-authored-by: Martin Hjelmare --------- Co-authored-by: Erik Montnemery Co-authored-by: Martin Hjelmare --- .../components/automation/__init__.py | 48 +---- homeassistant/components/template/config.py | 9 +- homeassistant/components/template/const.py | 1 + .../components/template/coordinator.py | 49 +++++- homeassistant/helpers/condition.py | 41 +++++ homeassistant/helpers/script.py | 2 +- tests/components/template/test_sensor.py | 164 ++++++++++++++++++ 7 files changed, 265 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 2081ea938ae..dacbe074e95 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -47,14 +47,7 @@ from homeassistant.core import ( split_entity_id, valid_entity_id, ) -from homeassistant.exceptions import ( - ConditionError, - ConditionErrorContainer, - ConditionErrorIndex, - HomeAssistantError, - ServiceNotFound, - TemplateError, -) +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, TemplateError from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import ( @@ -1146,38 +1139,13 @@ async def _async_process_if( """Process if checks.""" if_configs = config[CONF_CONDITION] - checks: list[condition.ConditionCheckerType] = [] - for if_config in if_configs: - try: - checks.append(await condition.async_from_config(hass, if_config)) - except HomeAssistantError as ex: - LOGGER.warning("Invalid condition: %s", ex) - return None - - def if_action(variables: Mapping[str, Any] | None = None) -> bool: - """AND all conditions.""" - errors: list[ConditionErrorIndex] = [] - for index, check in enumerate(checks): - try: - with trace_path(["condition", str(index)]): - if check(hass, variables) is False: - return False - except ConditionError as ex: - errors.append( - ConditionErrorIndex( - "condition", index=index, total=len(checks), error=ex - ) - ) - - if errors: - LOGGER.warning( - "Error evaluating condition in '%s':\n%s", - name, - ConditionErrorContainer("condition", errors=errors), - ) - return False - - return True + try: + if_action = await condition.async_conditions_from_config( + hass, if_configs, LOGGER, name + ) + except HomeAssistantError as ex: + LOGGER.warning("Invalid condition: %s", ex) + return None result: IfAction = if_action # type: ignore[assignment] result.config = if_configs diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index e2015743a0e..d75b111a6d0 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -15,6 +15,7 @@ from homeassistant.config import async_log_schema_error, config_without_domain from homeassistant.const import CONF_BINARY_SENSORS, CONF_SENSORS, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_notify_setup_error @@ -28,7 +29,7 @@ from . import ( sensor as sensor_platform, weather as weather_platform, ) -from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN +from .const import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN PACKAGE_MERGE_HINT = "list" @@ -36,6 +37,7 @@ CONFIG_SECTION_SCHEMA = vol.Schema( { vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA, vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(NUMBER_DOMAIN): vol.All( cv.ensure_list, [number_platform.NUMBER_SCHEMA] @@ -83,6 +85,11 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf cfg[CONF_TRIGGER] = await async_validate_trigger_config( hass, cfg[CONF_TRIGGER] ) + + if CONF_CONDITION in cfg: + cfg[CONF_CONDITION] = await async_validate_conditions_config( + hass, cfg[CONF_CONDITION] + ) except vol.Invalid as err: async_log_schema_error(err, DOMAIN, cfg, hass) async_notify_setup_error(hass, DOMAIN) diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index c320fc545b1..fc3f3c84b38 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -7,6 +7,7 @@ CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_ATTRIBUTES = "attributes" CONF_AVAILABILITY = "availability" CONF_AVAILABILITY_TEMPLATE = "availability_template" +CONF_CONDITION = "condition" CONF_MAX = "max" CONF_MIN = "min" CONF_OBJECT_ID = "object_id" diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index d2ce44a0ad1..50481d79d5b 100644 --- a/homeassistant/components/template/coordinator.py +++ b/homeassistant/components/template/coordinator.py @@ -1,16 +1,18 @@ """Data update coordinator for trigger based template entities.""" -from collections.abc import Callable +from collections.abc import Callable, Mapping import logging +from typing import TYPE_CHECKING, Any from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.core import Context, CoreState, callback -from homeassistant.helpers import discovery, trigger as trigger_helper +from homeassistant.helpers import condition, discovery, trigger as trigger_helper from homeassistant.helpers.script import Script -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.trace import trace_get +from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import CONF_ACTION, CONF_CONDITION, CONF_TRIGGER, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -24,6 +26,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): """Instantiate trigger data.""" super().__init__(hass, _LOGGER, name="Trigger Update Coordinator") self.config = config + self._cond_func: Callable[[Mapping[str, Any] | None], bool] | None = None self._unsub_start: Callable[[], None] | None = None self._unsub_trigger: Callable[[], None] | None = None self._script: Script | None = None @@ -73,6 +76,11 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): DOMAIN, ) + if CONF_CONDITION in self.config: + self._cond_func = await condition.async_conditions_from_config( + self.hass, self.config[CONF_CONDITION], _LOGGER, "template entity" + ) + if start_event is not None: self._unsub_start = None @@ -91,16 +99,43 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator): start_event is not None, ) - async def _handle_triggered_with_script(self, run_variables, context=None): + async def _handle_triggered_with_script( + self, run_variables: TemplateVarsType, context: Context | None = None + ) -> None: + if not self._check_condition(run_variables): + return # Create a context referring to the trigger context. trigger_context_id = None if context is None else context.id script_context = Context(parent_id=trigger_context_id) + if TYPE_CHECKING: + # This method is only called if there's a script + assert self._script is not None if script_result := await self._script.async_run(run_variables, script_context): run_variables = script_result.variables - self._handle_triggered(run_variables, context) + self._execute_update(run_variables, context) + + async def _handle_triggered( + self, run_variables: TemplateVarsType, context: Context | None = None + ) -> None: + if not self._check_condition(run_variables): + return + self._execute_update(run_variables, context) + + def _check_condition(self, run_variables: TemplateVarsType) -> bool: + if not self._cond_func: + return True + condition_result = self._cond_func(run_variables) + if condition_result is False: + _LOGGER.debug( + "Conditions not met, aborting template trigger update. Condition summary: %s", + trace_get(clear=False), + ) + return condition_result @callback - def _handle_triggered(self, run_variables, context=None): + def _execute_update( + self, run_variables: TemplateVarsType, context: Context | None = None + ) -> None: self.async_set_updated_data( {"run_variables": run_variables, "context": context} ) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 629cdeef942..86965f86d40 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Container, Generator from contextlib import contextmanager from datetime import datetime, time as dt_time, timedelta import functools as ft +import logging import re import sys from typing import Any, Protocol, cast @@ -1064,6 +1065,46 @@ async def async_validate_conditions_config( return [await async_validate_condition_config(hass, cond) for cond in conditions] +async def async_conditions_from_config( + hass: HomeAssistant, + condition_configs: list[ConfigType], + logger: logging.Logger, + name: str, +) -> Callable[[TemplateVarsType], bool]: + """AND all conditions.""" + checks: list[ConditionCheckerType] = [ + await async_from_config(hass, condition_config) + for condition_config in condition_configs + ] + + def check_conditions(variables: TemplateVarsType = None) -> bool: + """AND all conditions.""" + errors: list[ConditionErrorIndex] = [] + for index, check in enumerate(checks): + try: + with trace_path(["condition", str(index)]): + if check(hass, variables) is False: + return False + except ConditionError as ex: + errors.append( + ConditionErrorIndex( + "condition", index=index, total=len(checks), error=ex + ) + ) + + if errors: + logger.warning( + "Error evaluating condition in '%s':\n%s", + name, + ConditionErrorContainer("condition", errors=errors), + ) + return False + + return True + + return check_conditions + + @callback def async_extract_entities(config: ConfigType | Template) -> set[str]: """Extract entities from a condition.""" diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 26a9b6e069e..0b5c0b99c35 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1349,7 +1349,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> ) -type _VarsType = dict[str, Any] | MappingProxyType[str, Any] +type _VarsType = dict[str, Any] | Mapping[str, Any] | MappingProxyType[str, Any] def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None: diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index fb352ebcb8c..e5e6eba1068 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1207,6 +1207,124 @@ async def test_trigger_entity( assert state.context is context +@pytest.mark.parametrize(("count", "domain"), [(1, template.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": [ + { + "condition": "template", + "value_template": "{{ trigger.event.data.beer >= 42 }}", + } + ], + "sensor": [ + { + "name": "Enough Name", + "unique_id": "enough-id", + "state": "You had enough Beer.", + } + ], + }, + ], + }, + ], +) +async def test_trigger_conditional_entity(hass: HomeAssistant, start_ha) -> None: + """Test conditional trigger entity works.""" + state = hass.states.get("sensor.enough_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.enough_name") + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 42}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.enough_name") + assert state.state == "You had enough Beer." + + +@pytest.mark.parametrize(("count", "domain"), [(1, template.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": [ + { + "condition": "template", + "value_template": "{{ trigger.event.data.beer / 0 == 'narf' }}", + } + ], + "sensor": [ + { + "name": "Enough Name", + "unique_id": "enough-id", + "state": "You had enough Beer.", + } + ], + }, + ], + }, + ], +) +async def test_trigger_conditional_entity_evaluation_error( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, start_ha +) -> None: + """Test trigger entity is not updated when condition evaluation fails.""" + hass.bus.async_fire("test_event", {"beer": 1}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.enough_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + assert "Error evaluating condition in 'template entity'" in caplog.text + + +@pytest.mark.parametrize(("count", "domain"), [(0, template.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": [ + {"condition": "template", "value_template": "{{ invalid"} + ], + "sensor": [ + { + "name": "Will Not Exist Name", + "state": "Unimportant", + } + ], + }, + ], + }, + ], +) +async def test_trigger_conditional_entity_invalid_condition( + hass: HomeAssistant, start_ha +) -> None: + """Test trigger entity is not created when condition is invalid.""" + state = hass.states.get("sensor.will_not_exist_name") + assert state is None + + @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( "config", @@ -1903,6 +2021,52 @@ async def test_trigger_action( assert events[0].context.parent_id == context.id +@pytest.mark.parametrize(("count", "domain"), [(1, template.DOMAIN)]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": [ + { + "condition": "template", + "value_template": "{{ trigger.event.data.beer >= 42 }}", + } + ], + "action": [ + {"event": "test_event_by_action"}, + ], + "sensor": [ + { + "name": "Not That Important", + "state": "Really not.", + } + ], + }, + ], + }, + ], +) +async def test_trigger_conditional_action(hass: HomeAssistant, start_ha) -> None: + """Test conditional trigger entity with an action works.""" + + event = "test_event_by_action" + events = async_capture_events(hass, event) + + hass.bus.async_fire("test_event", {"beer": 1}) + await hass.async_block_till_done() + + assert len(events) == 0 + + hass.bus.async_fire("test_event", {"beer": 42}) + await hass.async_block_till_done() + + assert len(events) == 1 + + async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 7555f209b6d59ad2e02745dff4d2bf21e9e92177 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Sep 2024 09:43:26 +0200 Subject: [PATCH 0522/1309] Use uv at runtime too (#125110) --- .github/workflows/builder.yml | 2 +- .github/workflows/ci.yaml | 6 +-- .github/workflows/wheels.yml | 2 +- .pre-commit-config.yaml | 2 +- homeassistant/package_constraints.txt | 2 +- homeassistant/util/package.py | 7 ++- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_test.txt | 1 - script/hassfest/docker.py | 5 ++- tests/util/test_package.py | 63 +++++++++++++++++---------- 11 files changed, 56 insertions(+), 38 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index d21a1ba73a1..01827fce4a6 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -126,7 +126,7 @@ jobs: env: UV_PRERELEASE: allow run: | - python3 -m pip install "$(grep '^uv' < requirements_test.txt)" + python3 -m pip install "$(grep '^uv' < requirements.txt)" uv pip install packaging tomli uv pip install . python3 script/version_bump.py nightly --set-nightly-version "${{ needs.init.outputs.version }}" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 84ee815c087..45e7ec77a8e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -252,7 +252,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install "$(grep '^uv' < requirements_test.txt)" + pip install "$(grep '^uv' < requirements.txt)" uv pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit @@ -476,7 +476,7 @@ jobs: - name: Generate partial uv restore key id: generate-uv-key run: | - uv_version=$(cat requirements_test.txt | grep uv | cut -d '=' -f 3) + uv_version=$(cat requirements.txt | grep uv | cut -d '=' -f 3) echo "version=${uv_version}" >> $GITHUB_OUTPUT echo "key=uv-${{ env.UV_CACHE_VERSION }}-${uv_version}-${{ env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT @@ -525,7 +525,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install "$(grep '^uv' < requirements_test.txt)" + pip install "$(grep '^uv' < requirements.txt)" uv pip install -U "pip>=21.3.1" setuptools wheel uv pip install -r requirements.txt python -m script.gen_requirements_all ci diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 20dd2054c6e..2ba72411330 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -46,7 +46,7 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install "$(grep '^uv' < requirements_test.txt)" + pip install "$(grep '^uv' < requirements.txt)" uv pip install -r requirements.txt - name: Get information diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d87ccf93aa7..98a4eecb641 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,7 +83,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements_test.txt)$ + files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$ - id: hassfest-metadata name: hassfest-metadata entry: script/run-in-env.sh python3 -m script.hassfest -p metadata diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8731a0158b7..a416eab6506 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -42,7 +42,6 @@ orjson==3.10.7 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.4.0 -pip>=21.3.1 psutil-home-assistant==0.0.1 PyJWT==2.9.0 pymicro-vad==1.0.1 @@ -59,6 +58,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 +uv==0.4.8 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 067bf5ff36d..4d87e51badc 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -94,12 +94,11 @@ def install_package( Return boolean if install successful. """ - # Not using 'import pip; pip.main([])' because it breaks the logger _LOGGER.info("Attempting install of %s", package) env = os.environ.copy() - args = [sys.executable, "-m", "pip", "install", "--quiet", package] + args = ["uv", "pip", "install", "--quiet", package] if timeout: - args += ["--timeout", str(timeout)] + env["HTTP_TIMEOUT"] = str(timeout) if upgrade: args.append("--upgrade") if constraints is not None: @@ -109,7 +108,7 @@ def install_package( # This only works if not running in venv args += ["--user"] env["PYTHONUSERBASE"] = os.path.abspath(target) - _LOGGER.debug("Running pip command: args=%s", args) + _LOGGER.debug("Running uv pip command: args=%s", args) with Popen( args, stdin=PIPE, diff --git a/pyproject.toml b/pyproject.toml index ac362b92483..e0f427454b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,6 @@ dependencies = [ "pyOpenSSL==24.2.1", "orjson==3.10.7", "packaging>=23.1", - "pip>=21.3.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", "PyYAML==6.0.2", @@ -66,6 +65,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", + "uv==0.4.8", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", diff --git a/requirements.txt b/requirements.txt index 2a46b3170d1..eb39a94559a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,7 +29,6 @@ Pillow==10.4.0 pyOpenSSL==24.2.1 orjson==3.10.7 packaging>=23.1 -pip>=21.3.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 PyYAML==6.0.2 @@ -38,6 +37,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 +uv==0.4.8 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 diff --git a/requirements_test.txt b/requirements_test.txt index 6869cc12e11..7579a654d40 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -51,4 +51,3 @@ types-pytz==2024.1.0.20240417 types-PyYAML==6.0.12.20240311 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 -uv==0.4.8 diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 5809ea4afa0..bcafbdb53c0 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -172,8 +172,9 @@ def _generate_files(config: Config) -> list[File]: + 10 ) * 1000 - package_versions = _get_package_versions( - Path("requirements_test.txt"), {"pipdeptree", "tqdm", "uv"} + package_versions = _get_package_versions(Path("requirements.txt"), {"uv"}) + package_versions |= _get_package_versions( + Path("requirements_test.txt"), {"pipdeptree", "tqdm"} ) package_versions |= _get_package_versions( Path("requirements_test_pre_commit.txt"), {"ruff"} diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 2ead327bf10..72600f94890 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -1,12 +1,13 @@ """Test Home Assistant package util methods.""" import asyncio +from collections.abc import Generator from importlib.metadata import metadata import logging import os from subprocess import PIPE import sys -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, Mock, call, patch import pytest @@ -24,7 +25,7 @@ TEST_ZIP_REQ = "file://{}#{}".format( @pytest.fixture -def mock_sys(): +def mock_sys() -> Generator[MagicMock]: """Mock sys.""" with patch("homeassistant.util.package.sys", spec=object) as sys_mock: sys_mock.executable = "python3" @@ -32,19 +33,19 @@ def mock_sys(): @pytest.fixture -def deps_dir(): +def deps_dir() -> str: """Return path to deps directory.""" return os.path.abspath("/deps_dir") @pytest.fixture -def lib_dir(deps_dir): +def lib_dir(deps_dir) -> str: """Return path to lib directory.""" return os.path.join(deps_dir, "lib_dir") @pytest.fixture -def mock_popen(lib_dir): +def mock_popen(lib_dir) -> Generator[MagicMock]: """Return a Popen mock.""" with patch("homeassistant.util.package.Popen") as popen_mock: popen_mock.return_value.__enter__ = popen_mock @@ -57,7 +58,7 @@ def mock_popen(lib_dir): @pytest.fixture -def mock_env_copy(): +def mock_env_copy() -> Generator[Mock]: """Mock os.environ.copy.""" with patch("homeassistant.util.package.os.environ.copy") as env_copy: env_copy.return_value = {} @@ -65,14 +66,14 @@ def mock_env_copy(): @pytest.fixture -def mock_venv(): +def mock_venv() -> Generator[MagicMock]: """Mock homeassistant.util.package.is_virtual_env.""" with patch("homeassistant.util.package.is_virtual_env") as mock: mock.return_value = True yield mock -def mock_async_subprocess(): +def mock_async_subprocess() -> Generator[MagicMock]: """Return an async Popen mock.""" async_popen = MagicMock() @@ -85,13 +86,14 @@ def mock_async_subprocess(): return async_popen -def test_install(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: +@pytest.mark.usefixtures("mock_sys", "mock_venv") +def test_install(mock_popen: MagicMock, mock_env_copy: MagicMock) -> None: """Test an install attempt on a package that doesn't exist.""" env = mock_env_copy() assert package.install_package(TEST_NEW_REQ, False) assert mock_popen.call_count == 2 assert mock_popen.mock_calls[0] == call( - [mock_sys.executable, "-m", "pip", "install", "--quiet", TEST_NEW_REQ], + ["uv", "pip", "install", "--quiet", TEST_NEW_REQ], stdin=PIPE, stdout=PIPE, stderr=PIPE, @@ -101,15 +103,33 @@ def test_install(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: assert mock_popen.return_value.communicate.call_count == 1 -def test_install_upgrade(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: +@pytest.mark.usefixtures("mock_sys", "mock_venv") +def test_install_with_timeout(mock_popen: MagicMock, mock_env_copy: MagicMock) -> None: + """Test an install attempt on a package that doesn't exist with a timeout set.""" + env = mock_env_copy() + assert package.install_package(TEST_NEW_REQ, False, timeout=10) + assert mock_popen.call_count == 2 + env["HTTP_TIMEOUT"] = "10" + assert mock_popen.mock_calls[0] == call( + ["uv", "pip", "install", "--quiet", TEST_NEW_REQ], + stdin=PIPE, + stdout=PIPE, + stderr=PIPE, + env=env, + close_fds=False, + ) + assert mock_popen.return_value.communicate.call_count == 1 + + +@pytest.mark.usefixtures("mock_sys", "mock_venv") +def test_install_upgrade(mock_popen, mock_env_copy) -> None: """Test an upgrade attempt on a package.""" env = mock_env_copy() assert package.install_package(TEST_NEW_REQ) assert mock_popen.call_count == 2 assert mock_popen.mock_calls[0] == call( [ - mock_sys.executable, - "-m", + "uv", "pip", "install", "--quiet", @@ -133,8 +153,7 @@ def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: mock_venv.return_value = False mock_sys.platform = "linux" args = [ - mock_sys.executable, - "-m", + "uv", "pip", "install", "--quiet", @@ -150,16 +169,16 @@ def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: assert mock_popen.return_value.communicate.call_count == 1 -def test_install_target_venv(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: +@pytest.mark.usefixtures("mock_sys", "mock_popen", "mock_env_copy", "mock_venv") +def test_install_target_venv() -> None: """Test an install with a target in a virtual environment.""" target = "target_folder" with pytest.raises(AssertionError): package.install_package(TEST_NEW_REQ, False, target=target) -def test_install_error( - caplog: pytest.LogCaptureFixture, mock_sys, mock_popen, mock_venv -) -> None: +@pytest.mark.usefixtures("mock_sys", "mock_venv") +def test_install_error(caplog: pytest.LogCaptureFixture, mock_popen) -> None: """Test an install that errors out.""" caplog.set_level(logging.WARNING) mock_popen.return_value.returncode = 1 @@ -169,7 +188,8 @@ def test_install_error( assert record.levelname == "ERROR" -def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: +@pytest.mark.usefixtures("mock_sys", "mock_venv") +def test_install_constraint(mock_popen, mock_env_copy) -> None: """Test install with constraint file on not installed package.""" env = mock_env_copy() constraints = "constraints_file.txt" @@ -177,8 +197,7 @@ def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv) -> N assert mock_popen.call_count == 2 assert mock_popen.mock_calls[0] == call( [ - mock_sys.executable, - "-m", + "uv", "pip", "install", "--quiet", From da1003ac416308b15cac61972bca7aa3cad7602e Mon Sep 17 00:00:00 2001 From: Matrix Date: Wed, 11 Sep 2024 16:27:48 +0800 Subject: [PATCH 0523/1309] Improve yolink code readability (#125724) Improve code readability --- homeassistant/components/yolink/sensor.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 537393d0315..8f263cdae07 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -175,8 +175,10 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_TH_SENSOR] - and device.device_model_name not in NONE_HUMIDITY_SENSOR_MODELS, + exists_fn=lambda device: ( + device.device_type in [ATTR_DEVICE_TH_SENSOR] + and device.device_model_name not in NONE_HUMIDITY_SENSOR_MODELS + ), ), YoLinkSensorEntityDescription( key="temperature", @@ -248,8 +250,9 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL_INCREASING, should_update_entity=lambda value: value is not None, - exists_fn=lambda device: device.device_type - in ATTR_DEVICE_WATER_METER_CONTROLLER, + exists_fn=lambda device: ( + device.device_type in ATTR_DEVICE_WATER_METER_CONTROLLER + ), ), YoLinkSensorEntityDescription( key="power", From acc046def6cd20990238a671f59d60ac447604da Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Sep 2024 10:41:36 +0200 Subject: [PATCH 0524/1309] Bump uv to 0.4.9 (#125726) --- .pre-commit-config.yaml | 2 +- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 98a4eecb641..4a494ee36c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,7 +83,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements.+\.txt)$ + files: ^(homeassistant/.+/(icons|manifest|strings)\.json|homeassistant/brands/.*\.json|homeassistant/.+/services\.yaml|script/hassfest/(?!metadata|mypy_config).+\.py|requirements\.txt)$ - id: hassfest-metadata name: hassfest-metadata entry: script/run-in-env.sh python3 -m script.hassfest -p metadata diff --git a/Dockerfile b/Dockerfile index c8a8d9a2172..416a7ee91b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.4.8 +RUN pip3 install uv==0.4.9 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a416eab6506..b6132523bf8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.4.8 +uv==0.4.9 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index e0f427454b4..c3dc607afc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.4.8", + "uv==0.4.9", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", diff --git a/requirements.txt b/requirements.txt index eb39a94559a..bdba105011f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,7 +37,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.4.8 +uv==0.4.9 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index cf3765288f4..4894e333840 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.4.8,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.4.9,source=/uv,target=/bin/uv \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ From 618586c577b944cc4c8138634b6e13d3aaee4db6 Mon Sep 17 00:00:00 2001 From: shapournemati-iotty <130070037+shapournemati-iotty@users.noreply.github.com> Date: Wed, 11 Sep 2024 11:21:59 +0200 Subject: [PATCH 0525/1309] Upgrade iottycloud to 0.2.1 (#125731) upgrade iottycloud lib to 0.2.1 --- homeassistant/components/iotty/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iotty/manifest.json b/homeassistant/components/iotty/manifest.json index 66baddc6b47..1c0d5cc3df2 100644 --- a/homeassistant/components/iotty/manifest.json +++ b/homeassistant/components/iotty/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/iotty", "integration_type": "device", "iot_class": "cloud_polling", - "requirements": ["iottycloud==0.1.3"] + "requirements": ["iottycloud==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 08e8fd3856e..6c01dd6d707 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1191,7 +1191,7 @@ insteon-frontend-home-assistant==0.5.0 intellifire4py==4.1.9 # homeassistant.components.iotty -iottycloud==0.1.3 +iottycloud==0.2.1 # homeassistant.components.iperf3 iperf3==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d38d09c8fe6..d6e9a03bc00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1002,7 +1002,7 @@ insteon-frontend-home-assistant==0.5.0 intellifire4py==4.1.9 # homeassistant.components.iotty -iottycloud==0.1.3 +iottycloud==0.2.1 # homeassistant.components.isal isal==1.6.1 From c4b870bfd3adb8016784d90377b844e99f532f1e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 11:30:35 +0200 Subject: [PATCH 0526/1309] Add EntityDescription classes to pylint plugin (#125596) * Add EntityDescription classes to pylint plugin * Ignore existing violations * Adjust --- .../components/dsmr_reader/definitions.py | 1 + .../sensor_types/sensor_entity_description.py | 1 + homeassistant/components/repetier/__init__.py | 1 + .../sensor_types/sensor_entity_description.py | 1 + pylint/plugins/hass_enforce_class_module.py | 77 ++++++++++++++++--- tests/pylint/conftest.py | 14 ++-- tests/pylint/test_enforce_class_module.py | 24 +++--- 7 files changed, 87 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 9003c4d4334..62d095aa993 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -40,6 +40,7 @@ def tariff_transform(value: str) -> str: @dataclass(frozen=True) +# pylint: disable-next=hass-enforce-class-module class DSMRReaderSensorEntityDescription(SensorEntityDescription): """Sensor entity description for DSMR Reader.""" diff --git a/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py b/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py index e1ee4c30326..10d00671ba5 100644 --- a/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py +++ b/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py @@ -15,6 +15,7 @@ class GrowattRequiredKeysMixin: @dataclass(frozen=True) +# pylint: disable-next=hass-enforce-class-module class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin): """Describes Growatt sensor entity.""" diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 2642e78e7ec..27ddc62a847 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -133,6 +133,7 @@ class RepetierRequiredKeysMixin: @dataclass(frozen=True) +# pylint: disable-next=hass-enforce-class-module class RepetierSensorEntityDescription( SensorEntityDescription, RepetierRequiredKeysMixin ): diff --git a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py index 8c792ab617f..1d06f04ab3d 100644 --- a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py +++ b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py @@ -15,6 +15,7 @@ class SunWEGRequiredKeysMixin: @dataclass(frozen=True) +# pylint: disable-next=hass-enforce-class-module class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): """Describes SunWEG sensor entity.""" diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index d9f844f907f..dcd42f9a1c1 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -1,38 +1,91 @@ -"""Plugin for checking if coordinator is in its own module.""" +"""Plugin for checking if class is in correct module.""" from __future__ import annotations +from ast import ClassDef +from dataclasses import dataclass + from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter +@dataclass +class ClassModuleMatch: + """Class for pattern matching.""" + + expected_module: str + base_class: str + + +_MODULES = [ + ClassModuleMatch("alarm_control_panel", "AlarmControlPanelEntityDescription"), + ClassModuleMatch("assist_satellite", "AssistSatelliteEntityDescription"), + ClassModuleMatch("binary_sensor", "BinarySensorEntityDescription"), + ClassModuleMatch("button", "ButtonEntityDescription"), + ClassModuleMatch("camera", "CameraEntityDescription"), + ClassModuleMatch("climate", "ClimateEntityDescription"), + ClassModuleMatch("coordinator", "DataUpdateCoordinator"), + ClassModuleMatch("cover", "CoverEntityDescription"), + ClassModuleMatch("date", "DateEntityDescription"), + ClassModuleMatch("datetime", "DateTimeEntityDescription"), + ClassModuleMatch("event", "EventEntityDescription"), + ClassModuleMatch("image", "ImageEntityDescription"), + ClassModuleMatch("image_processing", "ImageProcessingEntityDescription"), + ClassModuleMatch("lawn_mower", "LawnMowerEntityDescription"), + ClassModuleMatch("lock", "LockEntityDescription"), + ClassModuleMatch("media_player", "MediaPlayerEntityDescription"), + ClassModuleMatch("notify", "NotifyEntityDescription"), + ClassModuleMatch("number", "NumberEntityDescription"), + ClassModuleMatch("select", "SelectEntityDescription"), + ClassModuleMatch("sensor", "SensorEntityDescription"), + ClassModuleMatch("text", "TextEntityDescription"), + ClassModuleMatch("time", "TimeEntityDescription"), + ClassModuleMatch("update", "UpdateEntityDescription"), + ClassModuleMatch("vacuum", "VacuumEntityDescription"), + ClassModuleMatch("water_heater", "WaterHeaterEntityDescription"), + ClassModuleMatch("weather", "WeatherEntityDescription"), +] + + class HassEnforceClassModule(BaseChecker): - """Checker for coordinators own module.""" + """Checker for class in correct module.""" name = "hass_enforce_class_module" priority = -1 msgs = { "C7461": ( - "Derived data update coordinator is recommended to be placed in the 'coordinator' module", + "Derived %s is recommended to be placed in the '%s' module", "hass-enforce-class-module", - "Used when derived data update coordinator should be placed in its own module.", + "Used when derived class should be placed in its own module.", ), } def visit_classdef(self, node: nodes.ClassDef) -> None: - """Check if derived data update coordinator is placed in its own module.""" + """Check if derived class is placed in its own module.""" root_name = node.root().name - # we only want to check component update coordinators - if not root_name.startswith("homeassistant.components"): + # we only want to check components + if not root_name.startswith("homeassistant.components."): return - is_coordinator_module = root_name.endswith(".coordinator") - for ancestor in node.ancestors(): - if ancestor.name == "DataUpdateCoordinator" and not is_coordinator_module: - self.add_message("hass-enforce-class-module", node=node) - return + ancestors: list[ClassDef] | None = None + + for match in _MODULES: + if root_name.endswith(f".{match.expected_module}"): + continue + + if ancestors is None: + ancestors = list(node.ancestors()) # cache result for other modules + + for ancestor in ancestors: + if ancestor.name == match.base_class: + self.add_message( + "hass-enforce-class-module", + node=node, + args=(match.base_class, match.expected_module), + ) + return def register(linter: PyLinter) -> None: diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 38b4188230f..5e8ed28da6b 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -113,13 +113,11 @@ def hass_enforce_class_module_fixture() -> ModuleType: ) -@pytest.fixture(name="enforce_coordinator_module_checker") -def enforce_coordinator_module_fixture( - hass_enforce_class_module, linter -) -> BaseChecker: +@pytest.fixture(name="enforce_class_module_checker") +def enforce_class_module_fixture(hass_enforce_class_module, linter) -> BaseChecker: """Fixture to provide a hass_enforce_class_module checker.""" - enforce_coordinator_module_checker = ( - hass_enforce_class_module.HassEnforceClassModule(linter) + enforce_class_module_checker = hass_enforce_class_module.HassEnforceClassModule( + linter ) - enforce_coordinator_module_checker.module = "homeassistant.components.pylint_test" - return enforce_coordinator_module_checker + enforce_class_module_checker.module = "homeassistant.components.pylint_test" + return enforce_class_module_checker diff --git a/tests/pylint/test_enforce_class_module.py b/tests/pylint/test_enforce_class_module.py index 5fd6e0e88cc..b0f071fde52 100644 --- a/tests/pylint/test_enforce_class_module.py +++ b/tests/pylint/test_enforce_class_module.py @@ -41,21 +41,21 @@ from . import assert_adds_messages, assert_no_messages ), ], ) -def test_enforce_coordinator_module_good( - linter: UnittestLinter, enforce_coordinator_module_checker: BaseChecker, code: str +def test_enforce_class_module_good( + linter: UnittestLinter, enforce_class_module_checker: BaseChecker, code: str ) -> None: """Good test cases.""" root_node = astroid.parse(code, "homeassistant.components.pylint_test.coordinator") walker = ASTWalker(linter) - walker.add_checker(enforce_coordinator_module_checker) + walker.add_checker(enforce_class_module_checker) with assert_no_messages(linter): walker.walk(root_node) -def test_enforce_coordinator_module_bad_simple( +def test_enforce_class_module_bad_simple( linter: UnittestLinter, - enforce_coordinator_module_checker: BaseChecker, + enforce_class_module_checker: BaseChecker, ) -> None: """Bad test case with coordinator extending directly.""" root_node = astroid.parse( @@ -69,7 +69,7 @@ def test_enforce_coordinator_module_bad_simple( "homeassistant.components.pylint_test", ) walker = ASTWalker(linter) - walker.add_checker(enforce_coordinator_module_checker) + walker.add_checker(enforce_class_module_checker) with assert_adds_messages( linter, @@ -77,7 +77,7 @@ def test_enforce_coordinator_module_bad_simple( msg_id="hass-enforce-class-module", line=5, node=root_node.body[1], - args=None, + args=("DataUpdateCoordinator", "coordinator"), confidence=UNDEFINED, col_offset=0, end_line=5, @@ -87,9 +87,9 @@ def test_enforce_coordinator_module_bad_simple( walker.walk(root_node) -def test_enforce_coordinator_module_bad_nested( +def test_enforce_class_module_bad_nested( linter: UnittestLinter, - enforce_coordinator_module_checker: BaseChecker, + enforce_class_module_checker: BaseChecker, ) -> None: """Bad test case with nested coordinators.""" root_node = astroid.parse( @@ -106,7 +106,7 @@ def test_enforce_coordinator_module_bad_nested( "homeassistant.components.pylint_test", ) walker = ASTWalker(linter) - walker.add_checker(enforce_coordinator_module_checker) + walker.add_checker(enforce_class_module_checker) with assert_adds_messages( linter, @@ -114,7 +114,7 @@ def test_enforce_coordinator_module_bad_nested( msg_id="hass-enforce-class-module", line=5, node=root_node.body[1], - args=None, + args=("DataUpdateCoordinator", "coordinator"), confidence=UNDEFINED, col_offset=0, end_line=5, @@ -124,7 +124,7 @@ def test_enforce_coordinator_module_bad_nested( msg_id="hass-enforce-class-module", line=8, node=root_node.body[2], - args=None, + args=("DataUpdateCoordinator", "coordinator"), confidence=UNDEFINED, col_offset=0, end_line=8, From 8e026bf95d9eb67b5bdf0c9ffafe7caa9e481fb7 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Wed, 11 Sep 2024 18:52:19 +0900 Subject: [PATCH 0527/1309] Add common apis to base entity class of LG ThinQ integration (#125713) Co-authored-by: jangwon.lee --- homeassistant/components/lg_thinq/entity.py | 24 +++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py index 09ff8662efb..5cf3cd58837 100644 --- a/homeassistant/components/lg_thinq/entity.py +++ b/homeassistant/components/lg_thinq/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine import logging from typing import Any @@ -10,6 +10,7 @@ from thinqconnect import ThinQAPIException from thinqconnect.devices.const import Location from thinqconnect.integration import PropertyState +from homeassistant.const import UnitOfTemperature from homeassistant.core import callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr @@ -23,6 +24,11 @@ _LOGGER = logging.getLogger(__name__) EMPTY_STATE = PropertyState() +UNIT_CONVERSION_MAP: dict[str, str] = { + "F": UnitOfTemperature.FAHRENHEIT, + "C": UnitOfTemperature.CELSIUS, +} + class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """The base implementation of all lg thinq entities.""" @@ -64,6 +70,13 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Return the state data of entity.""" return self.coordinator.data.get(self.property_id, EMPTY_STATE) + def _get_unit_of_measurement(self, unit: str | None) -> str | None: + """Convert thinq unit string to HA unit string.""" + if unit is None: + return None + + return UNIT_CONVERSION_MAP.get(unit) + def _update_status(self) -> None: """Update status itself. @@ -81,11 +94,18 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): await super().async_added_to_hass() self._handle_coordinator_update() - async def async_call_api(self, target: Coroutine[Any, Any, Any]) -> None: + async def async_call_api( + self, + target: Coroutine[Any, Any, Any], + on_fail_method: Callable[[], None] | None = None, + ) -> None: """Call the given api and handle exception.""" try: await target except ThinQAPIException as exc: + if on_fail_method: + on_fail_method() + raise ServiceValidationError( exc.message, translation_domain=DOMAIN, From eb5390b94d69c775da3764363a53557637cf2c4c Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 11 Sep 2024 12:23:23 +0200 Subject: [PATCH 0528/1309] Update knx-frontend to 2024.9.10.221729 (#125734) --- homeassistant/components/knx/manifest.json | 2 +- .../components/knx/storage/entity_store_validation.py | 5 ++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 181dca6f4b8..76212496dec 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.1.1", "xknxproject==3.7.1", - "knx-frontend==2024.9.4.64538" + "knx-frontend==2024.9.10.221729" ], "single_config_entry": true } diff --git a/homeassistant/components/knx/storage/entity_store_validation.py b/homeassistant/components/knx/storage/entity_store_validation.py index e9997bd9f1a..9bad5297853 100644 --- a/homeassistant/components/knx/storage/entity_store_validation.py +++ b/homeassistant/components/knx/storage/entity_store_validation.py @@ -38,7 +38,10 @@ def parse_invalid(exc: vol.Invalid) -> _ErrorDescription: def validate_entity_data(entity_data: dict) -> dict: - """Validate entity data. Return validated data or raise EntityStoreValidationException.""" + """Validate entity data. + + Return validated data or raise EntityStoreValidationException. + """ try: # return so defaults are applied return ENTITY_STORE_DATA_SCHEMA(entity_data) # type: ignore[no-any-return] diff --git a/requirements_all.txt b/requirements_all.txt index 6c01dd6d707..df7399c01a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ kiwiki-client==0.1.1 knocki==0.3.1 # homeassistant.components.knx -knx-frontend==2024.9.4.64538 +knx-frontend==2024.9.10.221729 # homeassistant.components.konnected konnected==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6e9a03bc00..0d627e8e36d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1033,7 +1033,7 @@ kegtron-ble==0.4.0 knocki==0.3.1 # homeassistant.components.knx -knx-frontend==2024.9.4.64538 +knx-frontend==2024.9.10.221729 # homeassistant.components.konnected konnected==1.2.0 From 419e83f6d8652ba23217c46bd3cc82279aae8bb0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 12:51:39 +0200 Subject: [PATCH 0529/1309] Bump sfrbox-api to 0.0.11 (#125732) * Bump sfrbox-api to 0.0.11 * Re-enable tests --- homeassistant/components/sfr_box/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/sfr_box/snapshots/test_diagnostics.ambr | 4 ++-- tests/components/sfr_box/test_diagnostics.py | 3 +-- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sfr_box/manifest.json b/homeassistant/components/sfr_box/manifest.json index cd42997cec5..a2d65e9819d 100644 --- a/homeassistant/components/sfr_box/manifest.json +++ b/homeassistant/components/sfr_box/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sfr_box", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["sfrbox-api==0.0.10"] + "requirements": ["sfrbox-api==0.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index df7399c01a1..0513601e0d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2616,7 +2616,7 @@ sensoterra==2.0.1 sentry-sdk==1.40.3 # homeassistant.components.sfr_box -sfrbox-api==0.0.10 +sfrbox-api==0.0.11 # homeassistant.components.sharkiq sharkiq==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d627e8e36d..cb3fd11ac7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2077,7 +2077,7 @@ sensoterra==2.0.1 sentry-sdk==1.40.3 # homeassistant.components.sfr_box -sfrbox-api==0.0.10 +sfrbox-api==0.0.11 # homeassistant.components.sharkiq sharkiq==1.0.2 diff --git a/tests/components/sfr_box/snapshots/test_diagnostics.ambr b/tests/components/sfr_box/snapshots/test_diagnostics.ambr index 69139c2c374..22a914f8a79 100644 --- a/tests/components/sfr_box/snapshots/test_diagnostics.ambr +++ b/tests/components/sfr_box/snapshots/test_diagnostics.ambr @@ -31,7 +31,7 @@ 'product_id': 'NB6VAC-FXC-r0', 'refclient': '', 'serial_number': '**REDACTED**', - 'temperature': 27560.0, + 'temperature': 27560, 'uptime': 2353575, 'version_bootloader': 'NB6VAC-BOOTLOADER-R4.0.8', 'version_dsldriver': 'NB6VAC-XDSL-A2pv6F039p', @@ -90,7 +90,7 @@ 'product_id': 'NB6VAC-FXC-r0', 'refclient': '', 'serial_number': '**REDACTED**', - 'temperature': 27560.0, + 'temperature': 27560, 'uptime': 2353575, 'version_bootloader': 'NB6VAC-BOOTLOADER-R4.0.8', 'version_dsldriver': 'NB6VAC-XDSL-A2pv6F039p', diff --git a/tests/components/sfr_box/test_diagnostics.py b/tests/components/sfr_box/test_diagnostics.py index 26b7cf175e3..d31d97cbcf8 100644 --- a/tests/components/sfr_box/test_diagnostics.py +++ b/tests/components/sfr_box/test_diagnostics.py @@ -26,8 +26,7 @@ def override_platforms() -> Generator[None]: @pytest.mark.parametrize("net_infra", ["adsl", "ftth"]) -# Temporarily disable to unblock CI -async def _test_entry_diagnostics( +async def test_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry, hass_client: ClientSessionGenerator, From 2f68bbd27add8456f8ad3fc9ea5aa80695e4eca4 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Wed, 11 Sep 2024 06:51:56 -0400 Subject: [PATCH 0530/1309] Bump aiostreammagic to 2.2.3 (#125704) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/cambridge_audio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 8fc28a6e47e..3f2fe6c8e91 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], - "requirements": ["aiostreammagic==2.1.0"], + "requirements": ["aiostreammagic==2.2.3"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 0513601e0d5..88cce07ceef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -374,7 +374,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.1.0 +aiostreammagic==2.2.3 # homeassistant.components.switcher_kis aioswitcher==4.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb3fd11ac7f..b9ccc3dad4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -356,7 +356,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.1.0 +aiostreammagic==2.2.3 # homeassistant.components.switcher_kis aioswitcher==4.0.3 From b8ce687ec2bc62f76c68ad29755417646ff39e2f Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 11 Sep 2024 20:53:08 +1000 Subject: [PATCH 0531/1309] Add server side events to Smlight integration (#125553) * Register SSE client * Add switch events for settings changes * Mock sse settings events * Apply suggestions from code review Co-authored-by: Paarth Shah * access callbacks from mock call_args --------- Co-authored-by: Paarth Shah --- .../components/smlight/coordinator.py | 12 ++++++ homeassistant/components/smlight/switch.py | 27 +++++++++---- tests/components/smlight/conftest.py | 2 + tests/components/smlight/test_switch.py | 40 ++++++++++--------- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 094c6ec9cdb..396a89ef4b0 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from pysmlight import Api2, Info, Sensors +from pysmlight.const import Settings, SettingsProp from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError from homeassistant.config_entries import ConfigEntry @@ -44,6 +45,10 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): self.client = Api2(host=host, session=async_get_clientsession(hass)) self.legacy_api: int = 0 + self.config_entry.async_create_background_task( + hass, self.client.sse.client(), "smlight-sse-client" + ) + async def _async_setup(self) -> None: """Authenticate if needed during initial setup.""" if await self.client.check_auth_needed(): @@ -78,6 +83,13 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): translation_key="unsupported_firmware", ) + def update_setting(self, setting: Settings, value: bool | int) -> None: + """Update the sensor value from event.""" + prop = SettingsProp[setting.name].value + setattr(self.data.sensors, prop, value) + + self.async_set_updated_data(self.data) + async def _async_update_data(self) -> SmData: """Fetch data from the SMLIGHT device.""" try: diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 2e7b7e4df7e..38d94580d4d 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass import logging from typing import Any -from pysmlight import Sensors +from pysmlight import Sensors, SettingsEvent from pysmlight.const import Settings from homeassistant.components.switch import ( @@ -16,7 +16,7 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SmConfigEntry @@ -86,22 +86,33 @@ class SmSwitch(SmEntity, SwitchEntity): self._page, self._toggle = description.setting.value + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.client.sse.register_settings_cb( + self.entity_description.setting, self.event_callback + ) + ) + async def set_smlight(self, state: bool) -> None: """Set the state on SLZB device.""" await self.coordinator.client.set_toggle(self._page, self._toggle, state) + @callback + def event_callback(self, event: SettingsEvent) -> None: + """Handle switch events from the SLZB device.""" + if event.setting is not None: + self.coordinator.update_setting( + self.entity_description.setting, event.setting[self._toggle] + ) + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - self._attr_is_on = True - self.async_write_ha_state() - await self.set_smlight(True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - self._attr_is_on = False - self.async_write_ha_state() - await self.set_smlight(False) @property diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index b78ec7aa630..cb7ac938774 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch +from pysmlight.sse import sseClient from pysmlight.web import CmdWrapper, Info, Sensors import pytest @@ -89,6 +90,7 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: api.cmds = AsyncMock(spec_set=CmdWrapper) api.set_toggle = AsyncMock() + api.sse = MagicMock(spec_set=sseClient) yield api diff --git a/tests/components/smlight/test_switch.py b/tests/components/smlight/test_switch.py index 165024eaa83..a29dfbc35c2 100644 --- a/tests/components/smlight/test_switch.py +++ b/tests/components/smlight/test_switch.py @@ -1,14 +1,13 @@ """Tests for the SMLIGHT switch platform.""" +from collections.abc import Callable from unittest.mock import MagicMock -from freezegun.api import FrozenDateTimeFactory -from pysmlight import Sensors +from pysmlight import SettingsEvent from pysmlight.const import Settings import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.smlight.const import SCAN_INTERVAL from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -20,7 +19,7 @@ from homeassistant.helpers import entity_registry as er from .conftest import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform pytestmark = [ pytest.mark.usefixtures( @@ -48,18 +47,16 @@ async def test_switch_setup( @pytest.mark.parametrize( - ("entity", "setting", "field"), + ("entity", "setting"), [ - ("disable_leds", Settings.DISABLE_LEDS, "disable_leds"), - ("led_night_mode", Settings.NIGHT_MODE, "night_mode"), - ("auto_zigbee_update", Settings.ZB_AUTOUPDATE, "auto_zigbee"), + ("disable_leds", Settings.DISABLE_LEDS), + ("led_night_mode", Settings.NIGHT_MODE), + ("auto_zigbee_update", Settings.ZB_AUTOUPDATE), ], ) async def test_switches( hass: HomeAssistant, entity: str, - field: str, - freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, mock_smlight_client: MagicMock, setting: Settings, @@ -82,11 +79,21 @@ async def test_switches( assert len(mock_smlight_client.set_toggle.mock_calls) == 1 mock_smlight_client.set_toggle.assert_called_once_with(_page, _toggle, True) - mock_smlight_client.get_sensors.return_value = Sensors(**{field: True}) - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() + event_function: Callable[[SettingsEvent], None] = next( + ( + call_args[0][1] + for call_args in mock_smlight_client.sse.register_settings_cb.call_args_list + if setting == call_args[0][0] + ), + None, + ) + + async def _call_event_function(state: bool = True): + event_function(SettingsEvent(page=_page, origin="ha", setting={_toggle: state})) + await hass.async_block_till_done() + + await _call_event_function(state=True) state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -100,11 +107,8 @@ async def test_switches( assert len(mock_smlight_client.set_toggle.mock_calls) == 2 mock_smlight_client.set_toggle.assert_called_with(_page, _toggle, False) - mock_smlight_client.get_sensors.return_value = Sensors(**{field: False}) - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await _call_event_function(state=False) state = hass.states.get(entity_id) assert state.state == STATE_OFF From b1698bc0d5f8402a5c3d27980a1c12738fc458fc Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Wed, 11 Sep 2024 12:54:12 +0200 Subject: [PATCH 0532/1309] Allow to play a LinkPlay preset (#125204) * Allow to play a linkplay preset * Make it an entity service * Fixes * PR feedback * Rename more --- homeassistant/components/linkplay/icons.json | 7 +++++ .../components/linkplay/media_player.py | 28 ++++++++++++++++++- .../components/linkplay/services.yaml | 15 ++++++++++ .../components/linkplay/strings.json | 12 ++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/linkplay/icons.json create mode 100644 homeassistant/components/linkplay/services.yaml diff --git a/homeassistant/components/linkplay/icons.json b/homeassistant/components/linkplay/icons.json new file mode 100644 index 00000000000..ee76344dc39 --- /dev/null +++ b/homeassistant/components/linkplay/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "play_preset": { + "service": "mdi:play-box-outline" + } + } +} diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index e6ea5c5f11c..0e29a7f27d0 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -9,6 +9,7 @@ from typing import Any, Concatenate from linkplay.bridge import LinkPlayBridge from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus from linkplay.exceptions import LinkPlayException, LinkPlayRequestException +import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -25,7 +26,11 @@ from homeassistant.components.media_player.browse_media import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_platform, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow @@ -109,6 +114,15 @@ SEEKABLE_FEATURES: MediaPlayerEntityFeature = ( | MediaPlayerEntityFeature.SEEK ) +SERVICE_PLAY_PRESET = "play_preset" +ATTR_PRESET_NUMBER = "preset_number" + +SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema( + { + vol.Required(ATTR_PRESET_NUMBER): cv.positive_int, + } +) + async def async_setup_entry( hass: HomeAssistant, @@ -117,6 +131,13 @@ async def async_setup_entry( ) -> None: """Set up a media player from a config entry.""" + # register services + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_PLAY_PRESET, SERVICE_PLAY_PRESET_SCHEMA, "async_play_preset" + ) + + # add entities async_add_entities([LinkPlayMediaPlayerEntity(entry.runtime_data.bridge)]) @@ -262,6 +283,11 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): url = async_process_play_media_url(self.hass, media_id) await self._bridge.player.play(url) + @exception_wrap + async def async_play_preset(self, preset_number: int) -> None: + """Play preset number.""" + await self._bridge.player.play_preset(preset_number) + def _update_properties(self) -> None: """Update the properties of the media player.""" self._attr_available = True diff --git a/homeassistant/components/linkplay/services.yaml b/homeassistant/components/linkplay/services.yaml new file mode 100644 index 00000000000..20bc47be7a7 --- /dev/null +++ b/homeassistant/components/linkplay/services.yaml @@ -0,0 +1,15 @@ +play_preset: + target: + entity: + integration: linkplay + domain: media_player + fields: + preset_number: + example: 1 + required: true + default: 1 + selector: + number: + min: 1 + max: 10 + mode: box diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json index 46f5b29059f..12870816af7 100644 --- a/homeassistant/components/linkplay/strings.json +++ b/homeassistant/components/linkplay/strings.json @@ -22,5 +22,17 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "services": { + "play_preset": { + "name": "Play preset", + "description": "Play the preset number on the device.", + "fields": { + "preset_number": { + "name": "Preset number", + "description": "The preset number on the device to play." + } + } + } } } From 647017d18cc577c4553f76d5afa6d6f92f804a85 Mon Sep 17 00:00:00 2001 From: Adam Goode Date: Wed, 11 Sep 2024 07:00:57 -0400 Subject: [PATCH 0533/1309] Take a list of values for testing Threshold (#125705) When parameterizing these tests, I forgot that hysteresis tests are sensitive to all previous values rather than just the previous one. This change should restore behavior to the pre-parameterization version by replaying all value histories. Subsequent changes will add new test cases. --- .../threshold/test_binary_sensor.py | 194 +++++++++--------- 1 file changed, 95 insertions(+), 99 deletions(-) diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index 493d6b859c7..04016c0fc3f 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -42,21 +42,20 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( - ("from_val", "to_val", "expected_position", "expected_state"), + ("vals", "expected_position", "expected_state"), [ - (None, 15, POSITION_BELOW, STATE_OFF), # at threshold - (15, 16, POSITION_ABOVE, STATE_ON), - (16, 14, POSITION_BELOW, STATE_OFF), - (14, 15, POSITION_BELOW, STATE_OFF), - (15, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), - ("cat", 15, POSITION_BELOW, STATE_OFF), - (15, None, POSITION_UNKNOWN, STATE_UNKNOWN), + ([15], POSITION_BELOW, STATE_OFF), # at threshold + ([15, 16], POSITION_ABOVE, STATE_ON), + ([15, 16, 14], POSITION_BELOW, STATE_OFF), + ([15, 16, 14, 15], POSITION_BELOW, STATE_OFF), + ([15, 16, 14, 15, "cat"], POSITION_UNKNOWN, STATE_UNKNOWN), + ([15, 16, 14, 15, "cat", 15], POSITION_BELOW, STATE_OFF), + ([15, None], POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_upper( hass: HomeAssistant, - from_val: float | str | None, - to_val: float | str, + vals: list[float | str | None], expected_position: str, expected_state: str, ) -> None: @@ -72,8 +71,6 @@ async def test_sensor_upper( assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", from_val) - await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" assert state.attributes[ATTR_UPPER] == float( @@ -82,29 +79,29 @@ async def test_sensor_upper( assert state.attributes[ATTR_HYSTERESIS] == 0.0 assert state.attributes[ATTR_TYPE] == TYPE_UPPER - hass.states.async_set("sensor.test_monitored", to_val) - await hass.async_block_till_done() + for val in vals: + hass.states.async_set("sensor.test_monitored", val) + await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @pytest.mark.parametrize( - ("from_val", "to_val", "expected_position", "expected_state"), + ("vals", "expected_position", "expected_state"), [ - (None, 15, POSITION_ABOVE, STATE_OFF), # at threshold - (15, 16, POSITION_ABOVE, STATE_OFF), - (16, 14, POSITION_BELOW, STATE_ON), - (14, 15, POSITION_BELOW, STATE_ON), - (15, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), - ("cat", 15, POSITION_ABOVE, STATE_OFF), - (15, None, POSITION_UNKNOWN, STATE_UNKNOWN), + ([15], POSITION_ABOVE, STATE_OFF), # at threshold + ([15, 16], POSITION_ABOVE, STATE_OFF), + ([15, 16, 14], POSITION_BELOW, STATE_ON), + ([15, 16, 14, 15], POSITION_BELOW, STATE_ON), + ([15, 16, 14, 15, "cat"], POSITION_UNKNOWN, STATE_UNKNOWN), + ([15, 16, 14, 15, "cat", 15], POSITION_ABOVE, STATE_OFF), + ([15, None], POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_lower( hass: HomeAssistant, - from_val: float | str | None, - to_val: float | str, + vals: list[float | str | None], expected_position: str, expected_state: str, ) -> None: @@ -120,8 +117,6 @@ async def test_sensor_lower( assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", from_val) - await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" assert state.attributes[ATTR_LOWER] == float( @@ -130,32 +125,32 @@ async def test_sensor_lower( assert state.attributes[ATTR_HYSTERESIS] == 0.0 assert state.attributes[ATTR_TYPE] == TYPE_LOWER - hass.states.async_set("sensor.test_monitored", to_val) - await hass.async_block_till_done() + for val in vals: + hass.states.async_set("sensor.test_monitored", val) + await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @pytest.mark.parametrize( - ("from_val", "to_val", "expected_position", "expected_state"), + ("vals", "expected_position", "expected_state"), [ - (None, 17.5, POSITION_BELOW, STATE_OFF), # threshold + hysteresis - (17.5, 12.5, POSITION_BELOW, STATE_OFF), # threshold - hysteresis - (12.5, 20, POSITION_ABOVE, STATE_ON), - (20, 13, POSITION_ABOVE, STATE_ON), - (13, 12, POSITION_BELOW, STATE_OFF), - (12, 17, POSITION_BELOW, STATE_OFF), - (17, 18, POSITION_ABOVE, STATE_ON), - (18, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), - ("cat", 18, POSITION_ABOVE, STATE_ON), - (18, None, POSITION_UNKNOWN, STATE_UNKNOWN), + ([17.5], POSITION_BELOW, STATE_OFF), # threshold + hysteresis + ([17.5, 12.5], POSITION_BELOW, STATE_OFF), # threshold - hysteresis + ([17.5, 12.5, 20], POSITION_ABOVE, STATE_ON), + ([17.5, 12.5, 20, 13], POSITION_ABOVE, STATE_ON), + ([17.5, 12.5, 20, 13, 12], POSITION_BELOW, STATE_OFF), + ([17.5, 12.5, 20, 13, 12, 17], POSITION_BELOW, STATE_OFF), + ([17.5, 12.5, 20, 13, 12, 17, 18], POSITION_ABOVE, STATE_ON), + ([17.5, 12.5, 20, 13, 12, 17, 18, "cat"], POSITION_UNKNOWN, STATE_UNKNOWN), + ([17.5, 12.5, 20, 13, 12, 17, 18, "cat", 18], POSITION_ABOVE, STATE_ON), + ([18, None], POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_upper_hysteresis( hass: HomeAssistant, - from_val: float | str | None, - to_val: float | str, + vals: list[float | str | None], expected_position: str, expected_state: str, ) -> None: @@ -172,8 +167,6 @@ async def test_sensor_upper_hysteresis( assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", from_val) - await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" assert state.attributes[ATTR_UPPER] == float( @@ -182,32 +175,32 @@ async def test_sensor_upper_hysteresis( assert state.attributes[ATTR_HYSTERESIS] == 2.5 assert state.attributes[ATTR_TYPE] == TYPE_UPPER - hass.states.async_set("sensor.test_monitored", to_val) - await hass.async_block_till_done() + for val in vals: + hass.states.async_set("sensor.test_monitored", val) + await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @pytest.mark.parametrize( - ("from_val", "to_val", "expected_position", "expected_state"), + ("vals", "expected_position", "expected_state"), [ - (None, 17.5, POSITION_ABOVE, STATE_OFF), # threshold + hysteresis - (17.5, 12.5, POSITION_ABOVE, STATE_OFF), # threshold - hysteresis - (12.5, 20, POSITION_ABOVE, STATE_OFF), - (20, 13, POSITION_ABOVE, STATE_OFF), - (13, 12, POSITION_BELOW, STATE_ON), - (12, 17, POSITION_BELOW, STATE_ON), - (17, 18, POSITION_ABOVE, STATE_OFF), - (18, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), - ("cat", 18, POSITION_ABOVE, STATE_OFF), - (18, None, POSITION_UNKNOWN, STATE_UNKNOWN), + ([17.5], POSITION_ABOVE, STATE_OFF), # threshold + hysteresis + ([17.5, 12.5], POSITION_ABOVE, STATE_OFF), # threshold - hysteresis + ([17.5, 12.5, 20], POSITION_ABOVE, STATE_OFF), + ([17.5, 12.5, 20, 13], POSITION_ABOVE, STATE_OFF), + ([17.5, 12.5, 20, 13, 12], POSITION_BELOW, STATE_ON), + ([17.5, 12.5, 20, 13, 12, 17], POSITION_BELOW, STATE_ON), + ([17.5, 12.5, 20, 13, 12, 17, 18], POSITION_ABOVE, STATE_OFF), + ([17.5, 12.5, 20, 13, 12, 17, 18, "cat"], POSITION_UNKNOWN, STATE_UNKNOWN), + ([17.5, 12.5, 20, 13, 12, 17, 18, "cat", 18], POSITION_ABOVE, STATE_OFF), + ([18, None], POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_lower_hysteresis( hass: HomeAssistant, - from_val: float | str | None, - to_val: float | str, + vals: list[float | str | None], expected_position: str, expected_state: str, ) -> None: @@ -224,8 +217,6 @@ async def test_sensor_lower_hysteresis( assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", from_val) - await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" assert state.attributes[ATTR_LOWER] == float( @@ -234,30 +225,30 @@ async def test_sensor_lower_hysteresis( assert state.attributes[ATTR_HYSTERESIS] == 2.5 assert state.attributes[ATTR_TYPE] == TYPE_LOWER - hass.states.async_set("sensor.test_monitored", to_val) - await hass.async_block_till_done() + for val in vals: + hass.states.async_set("sensor.test_monitored", val) + await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @pytest.mark.parametrize( - ("from_val", "to_val", "expected_position", "expected_state"), + ("vals", "expected_position", "expected_state"), [ - (None, 10, POSITION_IN_RANGE, STATE_ON), # at lower threshold - (10, 20, POSITION_IN_RANGE, STATE_ON), # at upper threshold - (20, 16, POSITION_IN_RANGE, STATE_ON), - (16, 9, POSITION_BELOW, STATE_OFF), - (9, 21, POSITION_ABOVE, STATE_OFF), - (21, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), - ("cat", 21, POSITION_ABOVE, STATE_OFF), - (21, None, POSITION_UNKNOWN, STATE_UNKNOWN), + ([10], POSITION_IN_RANGE, STATE_ON), # at lower threshold + ([10, 20], POSITION_IN_RANGE, STATE_ON), # at upper threshold + ([10, 20, 16], POSITION_IN_RANGE, STATE_ON), + ([10, 20, 16, 9], POSITION_BELOW, STATE_OFF), + ([10, 20, 16, 9, 21], POSITION_ABOVE, STATE_OFF), + ([10, 20, 16, 9, 21, "cat"], POSITION_UNKNOWN, STATE_UNKNOWN), + ([10, 20, 16, 9, 21, "cat", 21], POSITION_ABOVE, STATE_OFF), + ([21, None], POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_in_range_no_hysteresis( hass: HomeAssistant, - from_val: float | str | None, - to_val: float | str, + vals: list[float | str | None], expected_position: str, expected_state: str, ) -> None: @@ -274,8 +265,6 @@ async def test_sensor_in_range_no_hysteresis( assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", from_val) - await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" assert state.attributes[ATTR_LOWER] == float( @@ -287,37 +276,45 @@ async def test_sensor_in_range_no_hysteresis( assert state.attributes[ATTR_HYSTERESIS] == 0.0 assert state.attributes[ATTR_TYPE] == TYPE_RANGE - hass.states.async_set("sensor.test_monitored", to_val) - await hass.async_block_till_done() + for val in vals: + hass.states.async_set("sensor.test_monitored", val) + await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state @pytest.mark.parametrize( - ("from_val", "to_val", "expected_position", "expected_state"), + ("vals", "expected_position", "expected_state"), [ - (None, 12, POSITION_IN_RANGE, STATE_ON), # lower threshold + hysteresis - (12, 22, POSITION_IN_RANGE, STATE_ON), # upper threshold + hysteresis - (22, 18, POSITION_IN_RANGE, STATE_ON), # upper threshold - hysteresis - (18, 16, POSITION_IN_RANGE, STATE_ON), - (16, 8, POSITION_IN_RANGE, STATE_ON), - (8, 7, POSITION_BELOW, STATE_OFF), - (7, 12, POSITION_BELOW, STATE_OFF), - (12, 13, POSITION_IN_RANGE, STATE_ON), - (13, 22, POSITION_IN_RANGE, STATE_ON), - (22, 23, POSITION_ABOVE, STATE_OFF), - (23, 18, POSITION_ABOVE, STATE_OFF), - (18, 17, POSITION_IN_RANGE, STATE_ON), - (17, "cat", POSITION_UNKNOWN, STATE_UNKNOWN), - ("cat", 17, POSITION_IN_RANGE, STATE_ON), - (17, None, POSITION_UNKNOWN, STATE_UNKNOWN), + ([12], POSITION_IN_RANGE, STATE_ON), # lower threshold + hysteresis + ([12, 22], POSITION_IN_RANGE, STATE_ON), # upper threshold + hysteresis + ([12, 22, 18], POSITION_IN_RANGE, STATE_ON), # upper threshold - hysteresis + ([12, 22, 18, 16], POSITION_IN_RANGE, STATE_ON), + ([12, 22, 18, 16, 8], POSITION_IN_RANGE, STATE_ON), + ([12, 22, 18, 16, 8, 7], POSITION_BELOW, STATE_OFF), + ([12, 22, 18, 16, 8, 7, 12], POSITION_BELOW, STATE_OFF), + ([12, 22, 18, 16, 8, 7, 12, 13], POSITION_IN_RANGE, STATE_ON), + ([12, 22, 18, 16, 8, 7, 12, 13, 22], POSITION_IN_RANGE, STATE_ON), + ([12, 22, 18, 16, 8, 7, 12, 13, 22, 23], POSITION_ABOVE, STATE_OFF), + ([12, 22, 18, 16, 8, 7, 12, 13, 22, 23, 18], POSITION_ABOVE, STATE_OFF), + ([12, 22, 18, 16, 8, 7, 12, 13, 22, 23, 18, 17], POSITION_IN_RANGE, STATE_ON), + ( + [12, 22, 18, 16, 8, 7, 12, 13, 22, 23, 18, 17, "cat"], + POSITION_UNKNOWN, + STATE_UNKNOWN, + ), + ( + [12, 22, 18, 16, 8, 7, 12, 13, 22, 23, 18, 17, "cat", 17], + POSITION_IN_RANGE, + STATE_ON, + ), + ([17, None], POSITION_UNKNOWN, STATE_UNKNOWN), ], ) async def test_sensor_in_range_with_hysteresis( hass: HomeAssistant, - from_val: float | str | None, - to_val: float | str, + vals: list[float | str | None], expected_position: str, expected_state: str, ) -> None: @@ -335,8 +332,6 @@ async def test_sensor_in_range_with_hysteresis( assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", from_val) - await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_ENTITY_ID] == "sensor.test_monitored" assert state.attributes[ATTR_LOWER] == float( @@ -348,8 +343,9 @@ async def test_sensor_in_range_with_hysteresis( assert state.attributes[ATTR_HYSTERESIS] == 2.0 assert state.attributes[ATTR_TYPE] == TYPE_RANGE - hass.states.async_set("sensor.test_monitored", to_val) - await hass.async_block_till_done() + for val in vals: + hass.states.async_set("sensor.test_monitored", val) + await hass.async_block_till_done() state = hass.states.get("binary_sensor.threshold") assert state.attributes[ATTR_POSITION] == expected_position assert state.state == expected_state From d722b7255c15925e38bb56a579ac2ddab10f4138 Mon Sep 17 00:00:00 2001 From: Adam Pasztor Date: Wed, 11 Sep 2024 13:02:06 +0200 Subject: [PATCH 0534/1309] Add ADS valve integration (#125619) * feat: Add ADS valve integration * fix: replace imports to adhere with #125665 * fix: address review feedback. * fix: address review feedback. --- homeassistant/components/ads/valve.py | 86 +++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 homeassistant/components/ads/valve.py diff --git a/homeassistant/components/ads/valve.py b/homeassistant/components/ads/valve.py new file mode 100644 index 00000000000..88e2836335f --- /dev/null +++ b/homeassistant/components/ads/valve.py @@ -0,0 +1,86 @@ +"""Support for ADS valves.""" + +from __future__ import annotations + +import pyads +import voluptuous as vol + +from homeassistant.components.valve import ( + DEVICE_CLASSES_SCHEMA as VALVE_DEVICE_CLASSES_SCHEMA, + PLATFORM_SCHEMA as VALVE_PLATFORM_SCHEMA, + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from . import CONF_ADS_VAR, DATA_ADS +from .entity import AdsEntity +from .hub import AdsHub + +DEFAULT_NAME = "ADS valve" + +PLATFORM_SCHEMA = VALVE_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ADS_VAR): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): VALVE_DEVICE_CLASSES_SCHEMA, + } +) + + +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up an ADS valve device.""" + ads_hub: AdsHub = hass.data[DATA_ADS] + ads_var = config[CONF_ADS_VAR] + name = config[CONF_NAME] + device_class = config.get(CONF_DEVICE_CLASS) + supported_features: ValveEntityFeature = ( + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + ) + + entity = AdsValve(ads_hub, ads_var, name, device_class, supported_features) + + add_entities([entity]) + + +class AdsValve(AdsEntity, ValveEntity): + """Representation of an ADS valve entity.""" + + def __init__( + self, + ads_hub: AdsHub, + ads_var: str, + name: str, + device_class: ValveDeviceClass | None, + supported_features: ValveEntityFeature, + ) -> None: + """Initialize AdsValve entity.""" + super().__init__(ads_hub, name, ads_var) + self._attr_device_class = device_class + self._attr_supported_features = supported_features + self._attr_reports_position = False + self._attr_is_closed = True + + async def async_added_to_hass(self) -> None: + """Register device notification.""" + await self.async_initialize_device(self._ads_var, pyads.PLCTYPE_BOOL) + + def open_valve(self, **kwargs) -> None: + """Open the valve.""" + self._ads_hub.write_by_name(self._ads_var, True, pyads.PLCTYPE_BOOL) + self._attr_is_closed = False + + def close_valve(self, **kwargs) -> None: + """Close the valve.""" + self._ads_hub.write_by_name(self._ads_var, False, pyads.PLCTYPE_BOOL) + self._attr_is_closed = True From 1a21266325deb8ce0a70992cac05c469c500296d Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 11 Sep 2024 13:03:17 +0200 Subject: [PATCH 0535/1309] Improve test code coverage for enphase_envoy (#125582) * Improve test code coverage for enphase_envoy * Update tests/components/enphase_envoy/test_init.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- tests/components/enphase_envoy/test_init.py | 122 +++++++++++++++++++- 1 file changed, 117 insertions(+), 5 deletions(-) diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 7b10e784d50..22d76750c39 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -22,10 +22,13 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) 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 . import setup_integration from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator async def test_with_pre_v7_firmware( @@ -189,8 +192,7 @@ async def test_coordinator_token_refresh_error( hass: HomeAssistant, mock_envoy: AsyncMock, ) -> None: - """Test coordinator with token provided from config.""" - # 63, 69-79 _async_try_refresh_token + """Test coordinator with expired token and failure to refresh.""" token = encode( # some time in 2021 payload={"name": "envoy", "exp": 1627314600}, @@ -210,12 +212,122 @@ async def test_coordinator_token_refresh_error( CONF_TOKEN: token, }, ) - # token refresh without username and password specified in - # EnvoyTokenAuthwill force token refresh error + # override fresh token in conftest mock_envoy.auth mock_envoy.auth = EnvoyTokenAuth("127.0.0.1", token=token, envoy_serial="1234") - await setup_integration(hass, entry) + # force token refresh to fail. + with patch( + "pyenphase.auth.EnvoyTokenAuth._obtain_token", + side_effect=EnvoyError, + ): + await setup_integration(hass, entry) + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == "1" + + +async def test_config_no_unique_id( + hass: HomeAssistant, + mock_envoy: AsyncMock, +) -> None: + """Test enphase_envoy init if config entry has no unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title="Envoy 1234", + unique_id=None, + data={ + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.LOADED + assert entry.unique_id == mock_envoy.serial_number + + +async def test_config_different_unique_id( + hass: HomeAssistant, + mock_envoy: AsyncMock, +) -> None: + """Test enphase_envoy init if config entry has different unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title="Envoy 1234", + unique_id=4321, + data={ + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + ], + indirect=["mock_envoy"], +) +async def test_remove_config_entry_device( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test removing enphase_envoy config entry device.""" + assert await async_setup_component(hass, "config", {}) + await setup_integration(hass, config_entry) + assert config_entry.state is ConfigEntryState.LOADED + + # use client to send remove_device command + hass_client = await hass_ws_client(hass) + + # add device that will pass remove test + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "delete_this_device")}, + ) + response = await hass_client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] + + # inverters are not allowed to be removed + entity = entity_registry.entities["sensor.inverter_1"] + device_entry = device_registry.async_get(entity.device_id) + response = await hass_client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] + + # envoy itself is not allowed to be removed + entity = entity_registry.entities["sensor.envoy_1234_current_power_production"] + device_entry = device_registry.async_get(entity.device_id) + response = await hass_client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] + + # encharge can not be removed + entity = entity_registry.entities["sensor.encharge_123456_power"] + device_entry = device_registry.async_get(entity.device_id) + response = await hass_client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] + + # enpower can not be removed + entity = entity_registry.entities["sensor.enpower_654321_temperature"] + device_entry = device_registry.async_get(entity.device_id) + response = await hass_client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] + + # relays can be removed + entity = entity_registry.entities["switch.nc1_fixture"] + device_entry = device_registry.async_get(entity.device_id) + response = await hass_client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] From 356bca119de71ea0d670cfd13787c81c652fa36f Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Wed, 11 Sep 2024 07:28:47 -0400 Subject: [PATCH 0536/1309] Duke Energy Integration (#125489) * Duke Energy Integration * add recorder mock fixture to all tests * address PR comments * update tests * add basic coordinator tests * PR comments round 2 * Fix --------- Co-authored-by: Joostlek --- CODEOWNERS | 2 + .../components/duke_energy/__init__.py | 22 ++ .../components/duke_energy/config_flow.py | 67 ++++++ homeassistant/components/duke_energy/const.py | 3 + .../components/duke_energy/coordinator.py | 222 ++++++++++++++++++ .../components/duke_energy/manifest.json | 10 + .../components/duke_energy/strings.json | 20 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/duke_energy/__init__.py | 1 + tests/components/duke_energy/conftest.py | 90 +++++++ .../duke_energy/test_config_flow.py | 118 ++++++++++ .../duke_energy/test_coordinator.py | 44 ++++ 15 files changed, 612 insertions(+) create mode 100644 homeassistant/components/duke_energy/__init__.py create mode 100644 homeassistant/components/duke_energy/config_flow.py create mode 100644 homeassistant/components/duke_energy/const.py create mode 100644 homeassistant/components/duke_energy/coordinator.py create mode 100644 homeassistant/components/duke_energy/manifest.json create mode 100644 homeassistant/components/duke_energy/strings.json create mode 100644 tests/components/duke_energy/__init__.py create mode 100644 tests/components/duke_energy/conftest.py create mode 100644 tests/components/duke_energy/test_config_flow.py create mode 100644 tests/components/duke_energy/test_coordinator.py diff --git a/CODEOWNERS b/CODEOWNERS index 42a0ab8e55d..1f03fc5ed96 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -359,6 +359,8 @@ build.json @home-assistant/supervisor /tests/components/dsmr/ @Robbie1221 /homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna +/homeassistant/components/duke_energy/ @hunterjm +/tests/components/duke_energy/ @hunterjm /homeassistant/components/duotecno/ @cereal2nd /tests/components/duotecno/ @cereal2nd /homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo diff --git a/homeassistant/components/duke_energy/__init__.py b/homeassistant/components/duke_energy/__init__.py new file mode 100644 index 00000000000..6eacc15880f --- /dev/null +++ b/homeassistant/components/duke_energy/__init__.py @@ -0,0 +1,22 @@ +"""The Duke Energy integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .coordinator import DukeEnergyConfigEntry, DukeEnergyCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool: + """Set up Duke Energy from a config entry.""" + + coordinator = DukeEnergyCoordinator(hass, entry.data) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DukeEnergyConfigEntry) -> bool: + """Unload a config entry.""" + return True diff --git a/homeassistant/components/duke_energy/config_flow.py b/homeassistant/components/duke_energy/config_flow.py new file mode 100644 index 00000000000..e06940b0fba --- /dev/null +++ b/homeassistant/components/duke_energy/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for Duke Energy integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiodukeenergy import DukeEnergy +from aiohttp import ClientError, ClientResponseError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class DukeEnergyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Duke Energy.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + session = async_get_clientsession(self.hass) + api = DukeEnergy( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session + ) + try: + auth = await api.authenticate() + except ClientResponseError as e: + errors["base"] = "invalid_auth" if e.status == 404 else "cannot_connect" + except (ClientError, TimeoutError): + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + username = auth["cdp_internal_user_id"].lower() + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured() + email = auth["email"].lower() + data = { + CONF_EMAIL: email, + CONF_USERNAME: username, + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + self._async_abort_entries_match(data) + return self.async_create_entry(title=email, data=data) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/duke_energy/const.py b/homeassistant/components/duke_energy/const.py new file mode 100644 index 00000000000..98c973fa2fc --- /dev/null +++ b/homeassistant/components/duke_energy/const.py @@ -0,0 +1,3 @@ +"""Constants for the Duke Energy integration.""" + +DOMAIN = "duke_energy" diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py new file mode 100644 index 00000000000..68b7db12d45 --- /dev/null +++ b/homeassistant/components/duke_energy/coordinator.py @@ -0,0 +1,222 @@ +"""Coordinator to handle Duke Energy connections.""" + +from datetime import datetime, timedelta +import logging +from types import MappingProxyType +from typing import Any, cast + +from aiodukeenergy import DukeEnergy +from aiohttp import ClientError + +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_SUPPORTED_METER_TYPES = ("ELECTRIC",) + +type DukeEnergyConfigEntry = ConfigEntry[DukeEnergyCoordinator] + + +class DukeEnergyCoordinator(DataUpdateCoordinator[None]): + """Handle inserting statistics.""" + + config_entry: DukeEnergyConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry_data: MappingProxyType[str, Any], + ) -> None: + """Initialize the data handler.""" + super().__init__( + hass, + _LOGGER, + name="Duke Energy", + # Data is updated daily on Duke Energy. + # Refresh every 12h to be at most 12h behind. + update_interval=timedelta(hours=12), + ) + self.api = DukeEnergy( + entry_data[CONF_USERNAME], + entry_data[CONF_PASSWORD], + async_get_clientsession(hass), + ) + self._statistic_ids: set = set() + + @callback + def _dummy_listener() -> None: + pass + + # Force the coordinator to periodically update by registering at least one listener. + # Duke Energy does not provide forecast data, so all information is historical. + # This makes _async_update_data get periodically called so we can insert statistics. + self.async_add_listener(_dummy_listener) + + self.config_entry.async_on_unload(self._clear_statistics) + + def _clear_statistics(self) -> None: + """Clear statistics.""" + get_instance(self.hass).async_clear_statistics(list(self._statistic_ids)) + + async def _async_update_data(self) -> None: + """Insert Duke Energy statistics.""" + meters: dict[str, dict[str, Any]] = await self.api.get_meters() + for serial_number, meter in meters.items(): + if ( + not isinstance(meter["serviceType"], str) + or meter["serviceType"] not in _SUPPORTED_METER_TYPES + ): + _LOGGER.debug( + "Skipping unsupported meter type %s", meter["serviceType"] + ) + continue + + id_prefix = f"{meter["serviceType"].lower()}_{serial_number}" + consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" + self._statistic_ids.add(consumption_statistic_id) + _LOGGER.debug( + "Updating Statistics for %s", + consumption_statistic_id, + ) + + last_stat = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 1, consumption_statistic_id, True, set() + ) + if not last_stat: + _LOGGER.debug("Updating statistic for the first time") + usage = await self._async_get_energy_usage(meter) + consumption_sum = 0.0 + last_stats_time = None + else: + usage = await self._async_get_energy_usage( + meter, + last_stat[consumption_statistic_id][0]["start"], + ) + if not usage: + _LOGGER.debug("No recent usage data. Skipping update") + continue + stats = await get_instance(self.hass).async_add_executor_job( + statistics_during_period, + self.hass, + min(usage.keys()), + None, + {consumption_statistic_id}, + "hour", + None, + {"sum"}, + ) + consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"]) + last_stats_time = stats[consumption_statistic_id][0]["start"] + + consumption_statistics = [] + + for start, data in usage.items(): + if last_stats_time is not None and start.timestamp() <= last_stats_time: + continue + consumption_sum += data["energy"] + + consumption_statistics.append( + StatisticData( + start=start, state=data["energy"], sum=consumption_sum + ) + ) + + name_prefix = ( + f"Duke Energy " f"{meter["serviceType"].capitalize()} {serial_number}" + ) + consumption_metadata = StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{name_prefix} Consumption", + source=DOMAIN, + statistic_id=consumption_statistic_id, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR + if meter["serviceType"] == "ELECTRIC" + else UnitOfVolume.CENTUM_CUBIC_FEET, + ) + + _LOGGER.debug( + "Adding %s statistics for %s", + len(consumption_statistics), + consumption_statistic_id, + ) + async_add_external_statistics( + self.hass, consumption_metadata, consumption_statistics + ) + + async def _async_get_energy_usage( + self, meter: dict[str, Any], start_time: float | None = None + ) -> dict[datetime, dict[str, float | int]]: + """Get energy usage. + + If start_time is None, get usage since account activation (or as far back as possible), + otherwise since start_time - 30 days to allow corrections in data. + + Duke Energy provides hourly data all the way back to ~3 years. + """ + + # All of Duke Energy Service Areas are currently in America/New_York timezone + # May need to re-think this if that ever changes and determine timezone based + # on the service address somehow. + tz = await dt_util.async_get_time_zone("America/New_York") + lookback = timedelta(days=30) + one = timedelta(days=1) + if start_time is None: + # Max 3 years of data + agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"]) + if agreement_date is None: + start = dt_util.now(tz) - timedelta(days=3 * 365) + else: + start = max( + agreement_date.replace(tzinfo=tz), + dt_util.now(tz) - timedelta(days=3 * 365), + ) + else: + start = datetime.fromtimestamp(start_time, tz=tz) - lookback + + start = start.replace(hour=0, minute=0, second=0, microsecond=0) + end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one + _LOGGER.debug("Data lookup range: %s - %s", start, end) + + start_step = end - lookback + end_step = end + usage: dict[datetime, dict[str, float | int]] = {} + while True: + _LOGGER.debug("Getting hourly usage: %s - %s", start_step, end_step) + try: + # Get data + results = await self.api.get_energy_usage( + meter["serialNum"], "HOURLY", "DAY", start_step, end_step + ) + usage = {**results["data"], **usage} + + for missing in results["missing"]: + _LOGGER.debug("Missing data: %s", missing) + + # Set next range + end_step = start_step - one + start_step = max(start_step - lookback, start) + + # Make sure we don't go back too far + if end_step < start: + break + except (TimeoutError, ClientError): + # ClientError is raised when there is no more data for the range + break + + _LOGGER.debug("Got %s meter usage reads", len(usage)) + return usage diff --git a/homeassistant/components/duke_energy/manifest.json b/homeassistant/components/duke_energy/manifest.json new file mode 100644 index 00000000000..ece18d7ad2a --- /dev/null +++ b/homeassistant/components/duke_energy/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "duke_energy", + "name": "Duke Energy", + "codeowners": ["@hunterjm"], + "config_flow": true, + "dependencies": ["recorder"], + "documentation": "https://www.home-assistant.io/integrations/duke_energy", + "iot_class": "cloud_polling", + "requirements": ["aiodukeenergy==0.2.2"] +} diff --git a/homeassistant/components/duke_energy/strings.json b/homeassistant/components/duke_energy/strings.json new file mode 100644 index 00000000000..96dc8b371d1 --- /dev/null +++ b/homeassistant/components/duke_energy/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2d9d8861155..351f9e8e2e5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -139,6 +139,7 @@ FLOWS = { "drop_connect", "dsmr", "dsmr_reader", + "duke_energy", "dunehd", "duotecno", "dwd_weather_warnings", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ae77dfdd04e..1e518cfe3aa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1375,6 +1375,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "duke_energy": { + "name": "Duke Energy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "dunehd": { "name": "Dune HD", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 88cce07ceef..20e0684a551 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -221,6 +221,9 @@ aiodiscover==2.1.0 # homeassistant.components.dnsip aiodns==3.2.0 +# homeassistant.components.duke_energy +aiodukeenergy==0.2.2 + # homeassistant.components.eafm aioeafm==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9ccc3dad4c..507362eb7df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -209,6 +209,9 @@ aiodiscover==2.1.0 # homeassistant.components.dnsip aiodns==3.2.0 +# homeassistant.components.duke_energy +aiodukeenergy==0.2.2 + # homeassistant.components.eafm aioeafm==0.1.2 diff --git a/tests/components/duke_energy/__init__.py b/tests/components/duke_energy/__init__.py new file mode 100644 index 00000000000..2750d9d806e --- /dev/null +++ b/tests/components/duke_energy/__init__.py @@ -0,0 +1 @@ +"""Tests for the Duke Energy integration.""" diff --git a/tests/components/duke_energy/conftest.py b/tests/components/duke_energy/conftest.py new file mode 100644 index 00000000000..ed4182f450f --- /dev/null +++ b/tests/components/duke_energy/conftest.py @@ -0,0 +1,90 @@ +"""Common fixtures for the Duke Energy tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.duke_energy.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry +from tests.typing import RecorderInstanceGenerator + + +@pytest.fixture +async def mock_recorder_before_hass( + async_test_recorder: RecorderInstanceGenerator, +) -> None: + """Set up recorder.""" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.duke_energy.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> Generator[AsyncMock]: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "test@example.com", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture +def mock_api() -> Generator[AsyncMock]: + """Mock a successful Duke Energy API.""" + with ( + patch( + "homeassistant.components.duke_energy.config_flow.DukeEnergy", + autospec=True, + ) as mock_api, + patch( + "homeassistant.components.duke_energy.coordinator.DukeEnergy", + new=mock_api, + ), + ): + api = mock_api.return_value + api.authenticate.return_value = { + "email": "TEST@EXAMPLE.COM", + "cdp_internal_user_id": "test-username", + } + api.get_meters.return_value = {} + yield api + + +@pytest.fixture +def mock_api_with_meters(mock_api: AsyncMock) -> AsyncMock: + """Mock a successful Duke Energy API with meters.""" + mock_api.get_meters.return_value = { + "123": { + "serialNum": "123", + "serviceType": "ELECTRIC", + "agreementActiveDate": "2000-01-01", + }, + } + mock_api.get_energy_usage.return_value = { + "data": { + dt_util.now(): { + "energy": 1.3, + "temperature": 70, + } + }, + "missing": [], + } + return mock_api diff --git a/tests/components/duke_energy/test_config_flow.py b/tests/components/duke_energy/test_config_flow.py new file mode 100644 index 00000000000..652267c9aac --- /dev/null +++ b/tests/components/duke_energy/test_config_flow.py @@ -0,0 +1,118 @@ +"""Test the Duke Energy config flow.""" + +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientError, ClientResponseError +import pytest + +from homeassistant import config_entries +from homeassistant.components.duke_energy.const import DOMAIN +from homeassistant.components.recorder import Recorder +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_user( + hass: HomeAssistant, + recorder_mock: Recorder, + mock_api: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user config.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + # test with all provided + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == "test@example.com" + + data = result.get("data") + assert data + assert data[CONF_USERNAME] == "test-username" + assert data[CONF_PASSWORD] == "test-password" + assert data[CONF_EMAIL] == "test@example.com" + + +async def test_abort_if_already_setup( + hass: HomeAssistant, + recorder_mock: Recorder, + mock_api: AsyncMock, + mock_config_entry: AsyncMock, +) -> None: + """Test we abort if the email is already setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + assert result + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_abort_if_already_setup_alternate_username( + hass: HomeAssistant, + recorder_mock: Recorder, + mock_api: AsyncMock, + mock_config_entry: AsyncMock, +) -> None: + """Test we abort if the email is already setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + assert result + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (ClientResponseError(None, None, status=404), "invalid_auth"), + (ClientResponseError(None, None, status=500), "cannot_connect"), + (TimeoutError(), "cannot_connect"), + (ClientError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_api_errors( + hass: HomeAssistant, + recorder_mock: Recorder, + mock_api: Mock, + side_effect, + expected_error, +) -> None: + """Test the failure scenarios.""" + mock_api.authenticate.side_effect = side_effect + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": expected_error} + + mock_api.authenticate.side_effect = None + + # test with all provided + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY diff --git a/tests/components/duke_energy/test_coordinator.py b/tests/components/duke_energy/test_coordinator.py new file mode 100644 index 00000000000..77ac9e8c2bf --- /dev/null +++ b/tests/components/duke_energy/test_coordinator.py @@ -0,0 +1,44 @@ +"""Tests for the SolarEdge coordinator services.""" + +from datetime import timedelta +from unittest.mock import Mock, patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.recorder import Recorder +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_with_meters: Mock, + freezer: FrozenDateTimeFactory, + recorder_mock: Recorder, +) -> None: + """Test Coordinator.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_api_with_meters.get_meters.call_count == 1 + # 3 years of data + assert mock_api_with_meters.get_energy_usage.call_count == 37 + + with patch( + "homeassistant.components.duke_energy.coordinator.get_last_statistics", + return_value={ + "duke_energy:electric_123_energy_consumption": [ + {"start": dt_util.now().timestamp()} + ] + }, + ): + freezer.tick(timedelta(hours=12)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_api_with_meters.get_meters.call_count == 2 + # Now have stats, so only one call + assert mock_api_with_meters.get_energy_usage.call_count == 38 From 3c1860cca226a45d6f4b336c538f4d393f60d8ea Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 11 Sep 2024 13:32:29 +0200 Subject: [PATCH 0537/1309] Add storage settings for enphase_envoy batteries without enpower device (#125527) * Add battery storage settings for enphase_envoy EU configuration * Add EU Battery test fixture to enphase_envoy * Add tests and snapshots for enphase_envoy EU battery * refactor eu battery fixture to align with other enphase_envoy fixtures * remove if from test and use test parameter for eu battery enphase_envoy tests --- .../components/enphase_envoy/number.py | 37 +- .../components/enphase_envoy/select.py | 36 +- .../components/enphase_envoy/switch.py | 38 +- .../enphase_envoy/fixtures/envoy_eu_batt.json | 262 + .../snapshots/test_binary_sensor.ambr | 93 + .../enphase_envoy/snapshots/test_number.ambr | 57 + .../enphase_envoy/snapshots/test_select.ambr | 57 + .../enphase_envoy/snapshots/test_sensor.ambr | 4502 +++++++++++++++++ .../enphase_envoy/snapshots/test_switch.ambr | 46 + .../enphase_envoy/test_binary_sensor.py | 4 +- tests/components/enphase_envoy/test_number.py | 15 +- tests/components/enphase_envoy/test_select.py | 15 +- tests/components/enphase_envoy/test_sensor.py | 6 + tests/components/enphase_envoy/test_switch.py | 25 +- 14 files changed, 5143 insertions(+), 50 deletions(-) create mode 100644 tests/components/enphase_envoy/fixtures/envoy_eu_batt.json diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 2c0708d9215..f27335b1f4c 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -88,7 +88,6 @@ async def async_setup_entry( envoy_data.tariff and envoy_data.tariff.storage_settings and coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE - and coordinator.envoy.supported_features & SupportedFeatures.ENPOWER ): entities.append( EnvoyStorageSettingsNumberEntity(coordinator, STORAGE_RESERVE_SOC_ENTITY) @@ -152,18 +151,30 @@ class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity): """Initialize the Enphase relay number entity.""" super().__init__(coordinator, description) self.envoy = coordinator.envoy - assert self.data.enpower is not None - enpower = self.data.enpower - self._serial_number = enpower.serial_number - self._attr_unique_id = f"{self._serial_number}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._serial_number)}, - manufacturer="Enphase", - model="Enpower", - name=f"Enpower {self._serial_number}", - sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), - ) + assert self.data is not None + if enpower := self.data.enpower: + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + else: + # If no enpower device assign numbers to Envoy itself + self._attr_unique_id = f"{self.envoy_serial_num}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.envoy_serial_num)}, + manufacturer="Enphase", + model=coordinator.envoy.envoy_model, + name=coordinator.name, + sw_version=str(coordinator.envoy.firmware), + hw_version=coordinator.envoy.part_number, + serial_number=self.envoy_serial_num, + ) @property def native_value(self) -> float: diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 78ebaa26d13..903c2c1edf6 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -143,7 +143,6 @@ async def async_setup_entry( envoy_data.tariff and envoy_data.tariff.storage_settings and coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE - and coordinator.envoy.supported_features & SupportedFeatures.ENPOWER ): entities.append( EnvoyStorageSettingsSelectEntity(coordinator, STORAGE_MODE_ENTITY) @@ -209,18 +208,29 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): super().__init__(coordinator, description) self.envoy = coordinator.envoy assert coordinator.envoy.data is not None - assert coordinator.envoy.data.enpower is not None - enpower = coordinator.envoy.data.enpower - self._serial_number = enpower.serial_number - self._attr_unique_id = f"{self._serial_number}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._serial_number)}, - manufacturer="Enphase", - model="Enpower", - name=f"Enpower {self._serial_number}", - sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), - ) + if enpower := coordinator.envoy.data.enpower: + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + else: + # If no enpower device assign selects to Envoy itself + self._attr_unique_id = f"{self.envoy_serial_num}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.envoy_serial_num)}, + manufacturer="Enphase", + model=coordinator.envoy.envoy_model, + name=coordinator.name, + sw_version=str(coordinator.envoy.firmware), + hw_version=coordinator.envoy.part_number, + serial_number=self.envoy_serial_num, + ) @property def current_option(self) -> str: diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 09711cd5908..14451aaf266 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -98,8 +98,7 @@ async def async_setup_entry( ) if ( - envoy_data.enpower - and envoy_data.tariff + envoy_data.tariff and envoy_data.tariff.storage_settings and (coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE) ): @@ -213,22 +212,35 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): self, coordinator: EnphaseUpdateCoordinator, description: EnvoyStorageSettingsSwitchEntityDescription, - enpower: EnvoyEnpower, + enpower: EnvoyEnpower | None, ) -> None: """Initialize the Enphase storage settings switch entity.""" super().__init__(coordinator, description) self.envoy = coordinator.envoy self.enpower = enpower - self._serial_number = enpower.serial_number - self._attr_unique_id = f"{self._serial_number}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._serial_number)}, - manufacturer="Enphase", - model="Enpower", - name=f"Enpower {self._serial_number}", - sw_version=str(enpower.firmware_version), - via_device=(DOMAIN, self.envoy_serial_num), - ) + if enpower: + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + else: + # If no enpower device assign switches to Envoy itself + self._attr_unique_id = f"{self.envoy_serial_num}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.envoy_serial_num)}, + manufacturer="Enphase", + model=coordinator.envoy.envoy_model, + name=coordinator.name, + sw_version=str(coordinator.envoy.firmware), + hw_version=coordinator.envoy.part_number, + serial_number=self.envoy_serial_num, + ) @property def is_on(self) -> bool: diff --git a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json new file mode 100644 index 00000000000..8118630200f --- /dev/null +++ b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json @@ -0,0 +1,262 @@ +{ + "serial_number": "1234", + "firmware": "7.6.358", + "part_number": "800-00654-r08", + "envoy_model": "Envoy, phases: 3, phase mode: three, net-consumption CT, production CT", + "supported_features": 1759, + "phase_mode": "three", + "phase_count": 3, + "active_phase_count": 0, + "ct_meter_count": 2, + "consumption_meter_type": "net-consumption", + "production_meter_type": "production", + "storage_meter_type": null, + "data": { + "encharge_inventory": { + "123456": { + "admin_state": 6, + "admin_state_str": "ENCHG_STATE_READY", + "bmu_firmware_version": "2.1.16", + "comm_level_2_4_ghz": 4, + "comm_level_sub_ghz": 4, + "communicating": true, + "dc_switch_off": false, + "encharge_capacity": 3500, + "encharge_revision": 2, + "firmware_loaded_date": 1714736645, + "firmware_version": "2.6.6618_rel/22.11", + "installed_date": 1714736645, + "last_report_date": 1714804173, + "led_status": 17, + "max_cell_temp": 16, + "operating": true, + "part_number": "830-01760-r46", + "percent_full": 4, + "serial_number": "122327081322", + "temperature": 16, + "temperature_unit": "C", + "zigbee_dongle_fw_version": "100F" + } + }, + "encharge_power": { + "123456": { + "apparent_power_mva": 0, + "real_power_mw": 0, + "soc": 4 + } + }, + "encharge_aggregate": { + "available_energy": 140, + "backup_reserve": 0, + "state_of_charge": 4, + "reserve_state_of_charge": 0, + "configured_reserve_state_of_charge": 0, + "max_available_capacity": 3500 + }, + "enpower": null, + "system_consumption": { + "watt_hours_lifetime": 1234, + "watt_hours_last_7_days": 1234, + "watt_hours_today": 1234, + "watts_now": 1234 + }, + "system_production": { + "watt_hours_lifetime": 1234, + "watt_hours_last_7_days": 1234, + "watt_hours_today": 1234, + "watts_now": 1234 + }, + "system_consumption_phases": null, + "system_production_phases": null, + "system_net_consumption": { + "watt_hours_lifetime": 4321, + "watt_hours_last_7_days": -1, + "watt_hours_today": -1, + "watts_now": 2341 + }, + "system_net_consumption_phases": null, + "ctmeter_production": { + "eid": "100000010", + "timestamp": 1708006110, + "energy_delivered": 11234, + "energy_received": 12345, + "active_power": 100, + "power_factor": 0.11, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance", "power-on-unused-phase"] + }, + "ctmeter_consumption": { + "eid": "100000020", + "timestamp": 1708006120, + "energy_delivered": 21234, + "energy_received": 22345, + "active_power": 101, + "power_factor": 0.21, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "ctmeter_storage": null, + "ctmeter_production_phases": { + "L1": { + "eid": "100000011", + "timestamp": 1708006111, + "energy_delivered": 112341, + "energy_received": 123451, + "active_power": 20, + "power_factor": 0.12, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["production-imbalance"] + }, + "L2": { + "eid": "100000012", + "timestamp": 1708006112, + "energy_delivered": 112342, + "energy_received": 123452, + "active_power": 30, + "power_factor": 0.13, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": ["power-on-unused-phase"] + }, + "L3": { + "eid": "100000013", + "timestamp": 1708006113, + "energy_delivered": 112343, + "energy_received": 123453, + "active_power": 50, + "power_factor": 0.14, + "voltage": 111, + "current": 0.2, + "frequency": 50.1, + "state": "enabled", + "measurement_type": "production", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeter_consumption_phases": { + "L1": { + "eid": "100000021", + "timestamp": 1708006121, + "energy_delivered": 212341, + "energy_received": 223451, + "active_power": 21, + "power_factor": 0.22, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L2": { + "eid": "100000022", + "timestamp": 1708006122, + "energy_delivered": 212342, + "energy_received": 223452, + "active_power": 31, + "power_factor": 0.23, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + }, + "L3": { + "eid": "100000023", + "timestamp": 1708006123, + "energy_delivered": 212343, + "energy_received": 223453, + "active_power": 51, + "power_factor": 0.24, + "voltage": 112, + "current": 0.3, + "frequency": 50.2, + "state": "enabled", + "measurement_type": "net-consumption", + "metering_status": "normal", + "status_flags": [] + } + }, + "ctmeter_storage_phases": null, + "dry_contact_status": {}, + "dry_contact_settings": {}, + "inverters": { + "1": { + "serial_number": "1", + "last_report_date": 1, + "last_report_watts": 1, + "max_report_watts": 1 + } + }, + "tariff": { + "currency": { + "code": "EUR" + }, + "logger": "mylogger", + "date": "1714749724", + "storage_settings": { + "mode": "self-consumption", + "operation_mode_sub_type": "", + "reserved_soc": 0.0, + "very_low_soc": 5, + "charge_from_grid": true, + "date": "1714749724" + }, + "single_rate": { + "rate": 0.0, + "sell": 0.0 + }, + "seasons": [ + { + "id": "all_year_long", + "start": "1/1", + "days": [ + { + "id": "all_days", + "days": "Mon,Tue,Wed,Thu,Fri,Sat,Sun", + "must_charge_start": 0, + "must_charge_duration": 0, + "must_charge_mode": "CP", + "enable_discharge_to_grid": false, + "periods": [ + { + "id": "period_1", + "start": 0, + "rate": 0.0 + } + ] + } + ], + "tiers": [] + } + ], + "seasons_sell": [] + }, + "raw": { + "varies_by": "firmware_version" + } + } +} diff --git a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr index 84401c7566b..f936a9db76e 100644 --- a/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_binary_sensor.ambr @@ -1,4 +1,97 @@ # serializer version: 1 +# name: test_binary_sensor[envoy_eu_batt][binary_sensor.encharge_123456_communicating-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.encharge_123456_communicating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Communicating', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'communicating', + 'unique_id': '123456_communicating', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[envoy_eu_batt][binary_sensor.encharge_123456_communicating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Encharge 123456 Communicating', + }), + 'context': , + 'entity_id': 'binary_sensor.encharge_123456_communicating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[envoy_eu_batt][binary_sensor.encharge_123456_dc_switch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.encharge_123456_dc_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'DC switch', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dc_switch', + 'unique_id': '123456_dc_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[envoy_eu_batt][binary_sensor.encharge_123456_dc_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Encharge 123456 DC switch', + }), + 'context': , + 'entity_id': 'binary_sensor.encharge_123456_dc_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensor[envoy_metered_batt_relay][binary_sensor.encharge_123456_communicating-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/enphase_envoy/snapshots/test_number.ambr b/tests/components/enphase_envoy/snapshots/test_number.ambr index 6310911c27e..b7e799c9ac8 100644 --- a/tests/components/enphase_envoy/snapshots/test_number.ambr +++ b/tests/components/enphase_envoy/snapshots/test_number.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_number[envoy_eu_batt][number.envoy_1234_reserve_battery_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.envoy_1234_reserve_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reserve battery level', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reserve_soc', + 'unique_id': '1234_reserve_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[envoy_eu_batt][number.envoy_1234_reserve_battery_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Envoy 1234 Reserve battery level', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.envoy_1234_reserve_battery_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_number[envoy_metered_batt_relay][number.enpower_654321_reserve_battery_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/enphase_envoy/snapshots/test_select.ambr b/tests/components/enphase_envoy/snapshots/test_select.ambr index 10f15820ac4..f091879d9fc 100644 --- a/tests/components/enphase_envoy/snapshots/test_select.ambr +++ b/tests/components/enphase_envoy/snapshots/test_select.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_select[envoy_eu_batt][select.envoy_1234_storage_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'backup', + 'self_consumption', + 'savings', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.envoy_1234_storage_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Storage mode', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storage_mode', + 'unique_id': '1234_storage_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[envoy_eu_batt][select.envoy_1234_storage_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Storage mode', + 'options': list([ + 'backup', + 'self_consumption', + 'savings', + ]), + }), + 'context': , + 'entity_id': 'select.envoy_1234_storage_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'self_consumption', + }) +# --- # name: test_select[envoy_metered_batt_relay][select.enpower_654321_storage_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index f0d4006f05c..c43325a639d 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -1838,6 +1838,4508 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_eu_batt][sensor.encharge_123456_apparent_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.encharge_123456_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456_apparent_power_mva', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.encharge_123456_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Encharge 123456 Apparent power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.encharge_123456_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.encharge_123456_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.encharge_123456_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.encharge_123456_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Encharge 123456 Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.encharge_123456_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.encharge_123456_last_reported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.encharge_123456_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '123456_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.encharge_123456_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Encharge 123456 Last reported', + }), + 'context': , + 'entity_id': 'sensor.encharge_123456_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-05-04T06:29:33+00:00', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.encharge_123456_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.encharge_123456_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456_real_power_mw', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.encharge_123456_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Encharge 123456 Power', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.encharge_123456_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.encharge_123456_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.encharge_123456_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.encharge_123456_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Encharge 123456 Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.encharge_123456_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_available_battery_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_available_battery_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Available battery energy', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'available_energy', + 'unique_id': '1234_available_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_available_battery_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Available battery energy', + 'icon': 'mdi:flash', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_available_battery_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '140', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_balanced_net_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'balanced net power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balanced_net_consumption', + 'unique_id': '1234_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_balanced_net_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 balanced net power consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_balanced_net_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.341', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Battery', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Envoy 1234 Battery', + 'icon': 'mdi:flash', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_battery_capacity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_battery_capacity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Battery capacity', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_capacity', + 'unique_id': '1234_max_capacity', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_battery_capacity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Battery capacity', + 'icon': 'mdi:flash', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_battery_capacity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3500', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_net_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Current net power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_consumption', + 'unique_id': '1234_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_net_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current net power consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.101', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_net_power_consumption_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Current net power consumption l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_consumption_phase', + 'unique_id': '1234_net_consumption_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_net_power_consumption_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current net power consumption l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.021', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_net_power_consumption_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Current net power consumption l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_consumption_phase', + 'unique_id': '1234_net_consumption_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_net_power_consumption_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current net power consumption l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.031', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_net_power_consumption_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Current net power consumption l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_consumption_phase', + 'unique_id': '1234_net_consumption_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_net_power_consumption_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current net power consumption l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_net_power_consumption_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.051', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_power_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_power_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Current power consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_power_consumption', + 'unique_id': '1234_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_power_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current power consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_power_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_power_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_current_power_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Current power production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_power_production', + 'unique_id': '1234_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_current_power_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Envoy 1234 Current power production', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_current_power_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_energy_consumption_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_consumption_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Energy consumption last seven days', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'seven_days_consumption', + 'unique_id': '1234_seven_days_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_energy_consumption_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Energy consumption last seven days', + 'icon': 'mdi:flash', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_consumption_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_energy_consumption_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_consumption_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Energy consumption today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_consumption', + 'unique_id': '1234_daily_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_energy_consumption_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Energy consumption today', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_consumption_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_energy_production_last_seven_days-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_production_last_seven_days', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Energy production last seven days', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'seven_days_production', + 'unique_id': '1234_seven_days_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_energy_production_last_seven_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Energy production last seven days', + 'icon': 'mdi:flash', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_production_last_seven_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_energy_production_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_energy_production_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Energy production today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'daily_production', + 'unique_id': '1234_daily_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_energy_production_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Energy production today', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_energy_production_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.234', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_frequency', + 'unique_id': '1234_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency net consumption CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.2', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_frequency_phase', + 'unique_id': '1234_frequency_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency net consumption CT l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.2', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_frequency_phase', + 'unique_id': '1234_frequency_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency net consumption CT l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.2', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_frequency_phase', + 'unique_id': '1234_frequency_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency net consumption CT l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.2', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency', + 'unique_id': '1234_production_ct_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Frequency production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_frequency_phase', + 'unique_id': '1234_production_ct_frequency_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_frequency_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Envoy 1234 Frequency production CT l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_frequency_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.1', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime balanced net energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_balanced_net_consumption', + 'unique_id': '1234_lifetime_balanced_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_balanced_net_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime balanced net energy consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_balanced_net_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.321', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_consumption', + 'unique_id': '1234_lifetime_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime energy consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001234', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_energy_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_energy_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_production', + 'unique_id': '1234_lifetime_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime energy production', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.001234', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_consumption-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime net energy consumption', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_consumption', + 'unique_id': '1234_lifetime_net_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy consumption', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.021234', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime net energy consumption l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_consumption_phase', + 'unique_id': '1234_lifetime_net_consumption_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.212341', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime net energy consumption l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_consumption_phase', + 'unique_id': '1234_lifetime_net_consumption_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.212342', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime net energy consumption l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_consumption_phase', + 'unique_id': '1234_lifetime_net_consumption_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_consumption_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy consumption l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_consumption_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.212343', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_production-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime net energy production', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_production', + 'unique_id': '1234_lifetime_net_production', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_production-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy production', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.022345', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_production_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime net energy production l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_production_phase', + 'unique_id': '1234_lifetime_net_production_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_production_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy production l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.223451', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_production_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime net energy production l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_production_phase', + 'unique_id': '1234_lifetime_net_production_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_production_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy production l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.223452', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_production_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Lifetime net energy production l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_net_production_phase', + 'unique_id': '1234_lifetime_net_production_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_lifetime_net_energy_production_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Lifetime net energy production l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_lifetime_net_energy_production_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.223453', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:flash', + 'original_name': 'Meter status flags active net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_status_flags', + 'unique_id': '1234_net_consumption_ct_status_flags', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT', + 'icon': 'mdi:flash', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:flash', + 'original_name': 'Meter status flags active net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_status_flags_phase', + 'unique_id': '1234_net_consumption_ct_status_flags_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l1', + 'icon': 'mdi:flash', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:flash', + 'original_name': 'Meter status flags active net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_status_flags_phase', + 'unique_id': '1234_net_consumption_ct_status_flags_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l2', + 'icon': 'mdi:flash', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:flash', + 'original_name': 'Meter status flags active net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_status_flags_phase', + 'unique_id': '1234_net_consumption_ct_status_flags_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active net consumption CT l3', + 'icon': 'mdi:flash', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:flash', + 'original_name': 'Meter status flags active production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_status_flags', + 'unique_id': '1234_production_ct_status_flags', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active production CT', + 'icon': 'mdi:flash', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:flash', + 'original_name': 'Meter status flags active production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_status_flags_phase', + 'unique_id': '1234_production_ct_status_flags_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active production CT l1', + 'icon': 'mdi:flash', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:flash', + 'original_name': 'Meter status flags active production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_status_flags_phase', + 'unique_id': '1234_production_ct_status_flags_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active production CT l2', + 'icon': 'mdi:flash', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:flash', + 'original_name': 'Meter status flags active production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_status_flags_phase', + 'unique_id': '1234_production_ct_status_flags_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_meter_status_flags_active_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Meter status flags active production CT l3', + 'icon': 'mdi:flash', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_meter_status_flags_active_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Metering status net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_metering_status', + 'unique_id': '1234_net_consumption_ct_metering_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status net consumption CT', + 'icon': 'mdi:flash', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Metering status net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_metering_status_phase', + 'unique_id': '1234_net_consumption_ct_metering_status_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status net consumption CT l1', + 'icon': 'mdi:flash', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Metering status net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_metering_status_phase', + 'unique_id': '1234_net_consumption_ct_metering_status_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status net consumption CT l2', + 'icon': 'mdi:flash', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Metering status net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_metering_status_phase', + 'unique_id': '1234_net_consumption_ct_metering_status_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status net consumption CT l3', + 'icon': 'mdi:flash', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Metering status production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_metering_status', + 'unique_id': '1234_production_ct_metering_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status production CT', + 'icon': 'mdi:flash', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Metering status production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_metering_status_phase', + 'unique_id': '1234_production_ct_metering_status_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status production CT l1', + 'icon': 'mdi:flash', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Metering status production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_metering_status_phase', + 'unique_id': '1234_production_ct_metering_status_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status production CT l2', + 'icon': 'mdi:flash', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Metering status production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_metering_status_phase', + 'unique_id': '1234_production_ct_metering_status_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_metering_status_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Envoy 1234 Metering status production CT l3', + 'icon': 'mdi:flash', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_metering_status_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_net_consumption_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current', + 'unique_id': '1234_net_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_net_consumption_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_net_consumption_ct_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_net_consumption_ct_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_net_consumption_ct_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_net_consumption_ct_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_net_consumption_ct_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Net consumption CT current l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_current_phase', + 'unique_id': '1234_net_ct_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_net_consumption_ct_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Net consumption CT current l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_net_consumption_ct_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.3', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor', + 'unique_id': '1234_net_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.21', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l1', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.22', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l2', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.23', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_powerfactor_phase', + 'unique_id': '1234_net_ct_powerfactor_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor net consumption CT l3', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.24', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'powerfactor production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor', + 'unique_id': '1234_production_ct_powerfactor', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 powerfactor production CT', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.11', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l1', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l2', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.13', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Powerfactor production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_powerfactor_phase', + 'unique_id': '1234_production_ct_powerfactor_l3', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_powerfactor_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Envoy 1234 Powerfactor production CT l3', + 'icon': 'mdi:flash', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_powerfactor_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.14', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_production_ct_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current', + 'unique_id': '1234_production_ct_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_production_ct_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_production_ct_current_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_production_ct_current_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_production_ct_current_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_production_ct_current_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_production_ct_current_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_production_ct_current_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Production CT current l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_current_phase', + 'unique_id': '1234_production_ct_current_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_production_ct_current_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Envoy 1234 Production CT current l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_production_ct_current_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_reserve_battery_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_reserve_battery_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Reserve battery energy', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reserve_energy', + 'unique_id': '1234_reserve_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_reserve_battery_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Envoy 1234 Reserve battery energy', + 'icon': 'mdi:flash', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_reserve_battery_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_reserve_battery_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_reserve_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Reserve battery level', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'reserve_soc', + 'unique_id': '1234_reserve_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_reserve_battery_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Envoy 1234 Reserve battery level', + 'icon': 'mdi:flash', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_reserve_battery_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_net_consumption_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage net consumption CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_voltage', + 'unique_id': '1234_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_net_consumption_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage net consumption CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '112', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_net_consumption_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage net consumption CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_voltage_phase', + 'unique_id': '1234_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_net_consumption_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage net consumption CT l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '112', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_net_consumption_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage net consumption CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_voltage_phase', + 'unique_id': '1234_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_net_consumption_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage net consumption CT l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '112', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_net_consumption_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage net consumption CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'net_ct_voltage_phase', + 'unique_id': '1234_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_net_consumption_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage net consumption CT l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_net_consumption_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '112', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_production_ct-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage', + 'unique_id': '1234_production_ct_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_production_ct-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_production_ct_l1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT l1', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_production_ct_l1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_production_ct_l2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT l2', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_production_ct_l2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l2', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_production_ct_l3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Voltage production CT l3', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'production_ct_voltage_phase', + 'unique_id': '1234_production_ct_voltage_l3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.envoy_1234_voltage_production_ct_l3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Envoy 1234 Voltage production CT l3', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.envoy_1234_voltage_production_ct_l3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '111', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': None, + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1', + 'icon': 'mdi:flash', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_last_reported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_last_reported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Last reported', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_reported', + 'unique_id': '1_last_reported', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_last_reported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Inverter 1 Last reported', + 'icon': 'mdi:flash', + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_reported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1970-01-01T00:00:01+00:00', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.encharge_123456_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/enphase_envoy/snapshots/test_switch.ambr b/tests/components/enphase_envoy/snapshots/test_switch.ambr index a5dafd735b5..46123c03cec 100644 --- a/tests/components/enphase_envoy/snapshots/test_switch.ambr +++ b/tests/components/enphase_envoy/snapshots/test_switch.ambr @@ -1,4 +1,50 @@ # serializer version: 1 +# name: test_switch[envoy_eu_batt][switch.envoy_1234_charge_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.envoy_1234_charge_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge from grid', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_from_grid', + 'unique_id': '1234_charge_from_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[envoy_eu_batt][switch.envoy_1234_charge_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Envoy 1234 Charge from grid', + }), + 'context': , + 'entity_id': 'switch.envoy_1234_charge_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch[envoy_metered_batt_relay][switch.enpower_654321_charge_from_grid-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/enphase_envoy/test_binary_sensor.py b/tests/components/enphase_envoy/test_binary_sensor.py index 883df4be6fc..bb4a5c5a191 100644 --- a/tests/components/enphase_envoy/test_binary_sensor.py +++ b/tests/components/enphase_envoy/test_binary_sensor.py @@ -16,7 +16,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("mock_envoy"), + ["envoy_eu_batt", "envoy_metered_batt_relay"], + indirect=["mock_envoy"], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensor( diff --git a/tests/components/enphase_envoy/test_number.py b/tests/components/enphase_envoy/test_number.py index dac51ed5e26..dbf711cacaa 100644 --- a/tests/components/enphase_envoy/test_number.py +++ b/tests/components/enphase_envoy/test_number.py @@ -21,7 +21,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("mock_envoy"), + ["envoy_metered_batt_relay", "envoy_eu_batt"], + indirect=["mock_envoy"], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number( @@ -60,19 +62,24 @@ async def test_no_number( @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], ) async def test_number_operation_storage( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + use_serial: bool, ) -> None: """Test enphase_envoy number storage entities operation.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.NUMBER]): await setup_integration(hass, config_entry) - sn = mock_envoy.data.enpower.serial_number - test_entity = f"{Platform.NUMBER}.enpower_{sn}_reserve_battery_level" + test_entity = f"{Platform.NUMBER}.{use_serial}_reserve_battery_level" assert (entity_state := hass.states.get(test_entity)) assert mock_envoy.data.tariff.storage_settings.reserved_soc == float( diff --git a/tests/components/enphase_envoy/test_select.py b/tests/components/enphase_envoy/test_select.py index 38640f53dea..071dbcb2fe2 100644 --- a/tests/components/enphase_envoy/test_select.py +++ b/tests/components/enphase_envoy/test_select.py @@ -28,7 +28,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("mock_envoy"), + ["envoy_metered_batt_relay", "envoy_eu_batt"], + indirect=["mock_envoy"], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_select( @@ -172,19 +174,24 @@ async def test_select_relay_modes( @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], ) async def test_select_storage_modes( hass: HomeAssistant, mock_envoy: AsyncMock, config_entry: MockConfigEntry, + use_serial: str, ) -> None: """Test select platform entities storage mode changes.""" with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): await setup_integration(hass, config_entry) - sn = mock_envoy.data.enpower.serial_number - test_entity = f"{Platform.SELECT}.enpower_{sn}_storage_mode" + test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" assert (entity_state := hass.states.get(test_entity)) assert STORAGE_MODE_MAP[mock_envoy.data.tariff.storage_settings.mode] == ( diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index 90b36e23555..3156f154729 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -26,6 +26,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_plat [ "envoy", "envoy_1p_metered", + "envoy_eu_batt", "envoy_metered_batt_relay", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", @@ -59,6 +60,7 @@ PRODUCTION_NAMES: tuple[str, ...] = ( [ "envoy", "envoy_1p_metered", + "envoy_eu_batt", "envoy_metered_batt_relay", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", @@ -148,6 +150,7 @@ CONSUMPTION_NAMES: tuple[str, ...] = ( ("mock_envoy"), [ "envoy_1p_metered", + "envoy_eu_batt", "envoy_metered_batt_relay", "envoy_nobatt_metered_3p", ], @@ -189,6 +192,7 @@ NET_CONSUMPTION_NAMES: tuple[str, ...] = ( ("mock_envoy"), [ "envoy_1p_metered", + "envoy_eu_batt", "envoy_metered_batt_relay", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", @@ -735,6 +739,7 @@ async def test_sensor_storage_phase_disabled_by_integration( [ "envoy", "envoy_1p_metered", + "envoy_eu_batt", "envoy_metered_batt_relay", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", @@ -767,6 +772,7 @@ async def test_sensor_inverter_data( [ "envoy", "envoy_1p_metered", + "envoy_eu_batt", "envoy_metered_batt_relay", "envoy_nobatt_metered_3p", "envoy_tot_cons_metered", diff --git a/tests/components/enphase_envoy/test_switch.py b/tests/components/enphase_envoy/test_switch.py index 15f59cc3ea6..f30cba4d201 100644 --- a/tests/components/enphase_envoy/test_switch.py +++ b/tests/components/enphase_envoy/test_switch.py @@ -24,7 +24,9 @@ from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - ("mock_envoy"), ["envoy_metered_batt_relay"], indirect=["mock_envoy"] + ("mock_envoy"), + ["envoy_metered_batt_relay", "envoy_eu_batt"], + indirect=["mock_envoy"], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switch( @@ -109,7 +111,26 @@ async def test_switch_grid_operation( mock_envoy.go_off_grid.assert_awaited_once_with() mock_envoy.go_off_grid.reset_mock() - test_entity = f"{Platform.SWITCH}.enpower_{sn}_charge_from_grid" + +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +async def test_switch_charge_from_grid_operation( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, +) -> None: + """Test switch platform operation for charge from grid switches.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SWITCH}.{use_serial}_charge_from_grid" # validate envoy value is reflected in entity assert (entity_state := hass.states.get(test_entity)) From eb66a2f32fd3e5431681478ea6b208dac14cfa01 Mon Sep 17 00:00:00 2001 From: Joseph Chiocchi Date: Wed, 11 Sep 2024 06:40:13 -0500 Subject: [PATCH 0538/1309] Update worldclock component config_flow labels to match pre-defined format output (#125707) update labels for pre-defined options update labels for pre-defined options to match strftime's formatted output --- homeassistant/components/worldclock/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/worldclock/config_flow.py b/homeassistant/components/worldclock/config_flow.py index a9598c049aa..eebf0d59dcb 100644 --- a/homeassistant/components/worldclock/config_flow.py +++ b/homeassistant/components/worldclock/config_flow.py @@ -28,11 +28,11 @@ TIME_STR_OPTIONS = [ SelectOptionDict( value=DEFAULT_TIME_STR_FORMAT, label=f"14:05 ({DEFAULT_TIME_STR_FORMAT})" ), - SelectOptionDict(value="%I:%M %p", label="11:05 am (%I:%M %p)"), + SelectOptionDict(value="%I:%M %p", label="11:05 AM (%I:%M %p)"), SelectOptionDict(value="%Y-%m-%d %H:%M", label="2024-01-01 14:05 (%Y-%m-%d %H:%M)"), SelectOptionDict( value="%a, %b %d, %Y %I:%M %p", - label="Monday, Jan 01, 2024 11:05 am (%a, %b %d, %Y %I:%M %p)", + label="Mon, Jan 01, 2024 11:05 AM (%a, %b %d, %Y %I:%M %p)", ), ] From 3a05855f716be4797207e47d66ba462bf53fcc80 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:19:49 +0200 Subject: [PATCH 0539/1309] Simplify imports in remote_rpi_gpio (#125745) --- .../components/remote_rpi_gpio/binary_sensor.py | 11 +++++------ homeassistant/components/remote_rpi_gpio/switch.py | 9 ++++----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index 98ae7328bc5..b3a8075c6ba 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -15,7 +15,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .. import remote_rpi_gpio from . import ( CONF_BOUNCETIME, CONF_INVERT_LOGIC, @@ -23,6 +22,8 @@ from . import ( DEFAULT_BOUNCETIME, DEFAULT_INVERT_LOGIC, DEFAULT_PULL_MODE, + read_input, + setup_input, ) CONF_PORTS = "ports" @@ -56,9 +57,7 @@ def setup_platform( devices = [] for port_num, port_name in ports.items(): try: - remote_sensor = remote_rpi_gpio.setup_input( - address, port_num, pull_mode, bouncetime - ) + remote_sensor = setup_input(address, port_num, pull_mode, bouncetime) except (ValueError, IndexError, KeyError, OSError): return new_sensor = RemoteRPiGPIOBinarySensor(port_name, remote_sensor, invert_logic) @@ -84,7 +83,7 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity): def read_gpio(): """Read state from GPIO.""" - self._state = remote_rpi_gpio.read_input(self._sensor) + self._state = read_input(self._sensor) self.schedule_update_ha_state() self._sensor.when_deactivated = read_gpio @@ -108,6 +107,6 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity): def update(self) -> None: """Update the GPIO state.""" try: - self._state = remote_rpi_gpio.read_input(self._sensor) + self._state = read_input(self._sensor) except requests.exceptions.ConnectionError: return diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index ff9ecbcd97b..bf31e4bb55a 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -16,8 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .. import remote_rpi_gpio -from . import CONF_INVERT_LOGIC, DEFAULT_INVERT_LOGIC +from . import CONF_INVERT_LOGIC, DEFAULT_INVERT_LOGIC, setup_output, write_output CONF_PORTS = "ports" @@ -46,7 +45,7 @@ def setup_platform( devices = [] for port, name in ports.items(): try: - led = remote_rpi_gpio.setup_output(address, port, invert_logic) + led = setup_output(address, port, invert_logic) except (ValueError, IndexError, KeyError, OSError): return new_switch = RemoteRPiGPIOSwitch(name, led) @@ -83,12 +82,12 @@ class RemoteRPiGPIOSwitch(SwitchEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - remote_rpi_gpio.write_output(self._switch, 1) + write_output(self._switch, 1) self._state = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - remote_rpi_gpio.write_output(self._switch, 0) + write_output(self._switch, 0) self._state = False self.schedule_update_ha_state() From 1d3f4316281f4784ec8f056cdcd29bc43d9cdb30 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:06:38 +0200 Subject: [PATCH 0540/1309] Use HassKey in trace (#125751) --- homeassistant/components/trace/__init__.py | 32 ++++++++++------------ homeassistant/components/trace/const.py | 18 ++++++++++-- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 79830e0b63f..011d8a3cf74 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -9,7 +9,7 @@ from typing import Any import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import ExtendedJSONEncoder @@ -43,11 +43,6 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) type TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] -@callback -def _get_data(hass: HomeAssistant) -> TraceData: - return hass.data[DATA_TRACE] # type: ignore[no-any-return] - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the trace integration.""" hass.data[DATA_TRACE] = {} @@ -62,7 +57,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.debug("Storing traces") try: await store.async_save( - {key: list(traces.values()) for key, traces in _get_data(hass).items()} + { + key: list(traces.values()) + for key, traces in hass.data[DATA_TRACE].items() + } ) except HomeAssistantError as exc: _LOGGER.error("Error storing traces", exc_info=exc) @@ -80,7 +78,7 @@ async def async_get_trace( # Restore saved traces if not done await async_restore_traces(hass) - return _get_data(hass)[key][run_id].as_extended_dict() + return hass.data[DATA_TRACE][key][run_id].as_extended_dict() async def async_list_contexts( @@ -90,11 +88,11 @@ async def async_list_contexts( # Restore saved traces if not done await async_restore_traces(hass) - values: Mapping[str, LimitedSizeDict[str, BaseTrace] | None] + values: Mapping[str, LimitedSizeDict[str, BaseTrace] | None] | TraceData if key is not None: - values = {key: _get_data(hass).get(key)} + values = {key: hass.data[DATA_TRACE].get(key)} else: - values = _get_data(hass) + values = hass.data[DATA_TRACE] def _trace_id(run_id: str, key: str) -> dict[str, str]: """Make trace_id for the response.""" @@ -111,7 +109,7 @@ async def async_list_contexts( def _get_debug_traces(hass: HomeAssistant, key: str) -> list[dict[str, Any]]: """Return a serializable list of debug traces for a script or automation.""" - if traces_for_key := _get_data(hass).get(key): + if traces_for_key := hass.data[DATA_TRACE].get(key): return [trace.as_short_dict() for trace in traces_for_key.values()] return [] @@ -125,7 +123,7 @@ async def async_list_traces( if not wanted_key: traces: list[dict[str, Any]] = [] - for key in _get_data(hass): + for key in hass.data[DATA_TRACE]: domain = key.split(".", 1)[0] if domain == wanted_domain: traces.extend(_get_debug_traces(hass, key)) @@ -140,7 +138,7 @@ def async_store_trace( ) -> None: """Store a trace if its key is valid.""" if key := trace.key: - traces = _get_data(hass) + traces = hass.data[DATA_TRACE] if key not in traces: traces[key] = LimitedSizeDict(size_limit=stored_traces) else: @@ -151,7 +149,7 @@ def async_store_trace( def _async_store_restored_trace(hass: HomeAssistant, trace: RestoredTrace) -> None: """Store a restored trace and move it to the end of the LimitedSizeDict.""" key = trace.key - traces = _get_data(hass) + traces = hass.data[DATA_TRACE] if key not in traces: traces[key] = LimitedSizeDict() traces[key][trace.run_id] = trace @@ -165,7 +163,7 @@ async def async_restore_traces(hass: HomeAssistant) -> None: hass.data[DATA_TRACES_RESTORED] = True - store: Store[dict[str, list]] = hass.data[DATA_TRACE_STORE] + store = hass.data[DATA_TRACE_STORE] try: restored_traces = await store.async_load() or {} except HomeAssistantError: @@ -176,7 +174,7 @@ async def async_restore_traces(hass: HomeAssistant) -> None: # Add stored traces in reversed order to prioritize the newest traces for json_trace in reversed(traces): if ( - (stored_traces := _get_data(hass).get(key)) + (stored_traces := hass.data[DATA_TRACE].get(key)) and stored_traces.size_limit is not None and len(stored_traces) >= stored_traces.size_limit ): diff --git a/homeassistant/components/trace/const.py b/homeassistant/components/trace/const.py index f17328325c6..71433d6bc93 100644 --- a/homeassistant/components/trace/const.py +++ b/homeassistant/components/trace/const.py @@ -1,7 +1,19 @@ """Shared constants for script and automation tracing and debugging.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.storage import Store + + from . import TraceData + + CONF_STORED_TRACES = "stored_traces" -DATA_TRACE = "trace" -DATA_TRACE_STORE = "trace_store" -DATA_TRACES_RESTORED = "trace_traces_restored" +DATA_TRACE: HassKey[TraceData] = HassKey("trace") +DATA_TRACE_STORE: HassKey[Store[dict[str, list]]] = HassKey("trace_store") +DATA_TRACES_RESTORED: HassKey[bool] = HassKey("trace_traces_restored") DEFAULT_STORED_TRACES = 5 # Stored traces per script or automation From c33ba541b0f4ac024d25ed4d833b32d6817a1f71 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:11:03 +0200 Subject: [PATCH 0541/1309] Add flexibility to HassEnforceClassModule (#125739) * Add flexibility to HassEnforceClassModule * Adjust --- pylint/plugins/hass_enforce_class_module.py | 5 ++- tests/pylint/test_enforce_class_module.py | 38 ++++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index dcd42f9a1c1..b8f83b1602f 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -68,11 +68,14 @@ class HassEnforceClassModule(BaseChecker): # we only want to check components if not root_name.startswith("homeassistant.components."): return + parts = root_name.split(".") + current_module = parts[3] if len(parts) > 3 else "" ancestors: list[ClassDef] | None = None for match in _MODULES: - if root_name.endswith(f".{match.expected_module}"): + # Allow module.py and module/sub_module.py + if current_module == match.expected_module: continue if ancestors is None: diff --git a/tests/pylint/test_enforce_class_module.py b/tests/pylint/test_enforce_class_module.py index b0f071fde52..13d3c2538a1 100644 --- a/tests/pylint/test_enforce_class_module.py +++ b/tests/pylint/test_enforce_class_module.py @@ -41,11 +41,21 @@ from . import assert_adds_messages, assert_no_messages ), ], ) +@pytest.mark.parametrize( + "path", + [ + "homeassistant.components.pylint_test.coordinator", + "homeassistant.components.pylint_test.coordinator.my_coordinator", + ], +) def test_enforce_class_module_good( - linter: UnittestLinter, enforce_class_module_checker: BaseChecker, code: str + linter: UnittestLinter, + enforce_class_module_checker: BaseChecker, + code: str, + path: str, ) -> None: """Good test cases.""" - root_node = astroid.parse(code, "homeassistant.components.pylint_test.coordinator") + root_node = astroid.parse(code, path) walker = ASTWalker(linter) walker.add_checker(enforce_class_module_checker) @@ -53,9 +63,19 @@ def test_enforce_class_module_good( walker.walk(root_node) +@pytest.mark.parametrize( + "path", + [ + "homeassistant.components.pylint_test", + "homeassistant.components.pylint_test.my_coordinator", + "homeassistant.components.pylint_test.coordinator_other", + "homeassistant.components.pylint_test.sensor", + ], +) def test_enforce_class_module_bad_simple( linter: UnittestLinter, enforce_class_module_checker: BaseChecker, + path: str, ) -> None: """Bad test case with coordinator extending directly.""" root_node = astroid.parse( @@ -66,7 +86,7 @@ def test_enforce_class_module_bad_simple( class TestCoordinator(DataUpdateCoordinator): pass """, - "homeassistant.components.pylint_test", + path, ) walker = ASTWalker(linter) walker.add_checker(enforce_class_module_checker) @@ -87,9 +107,19 @@ def test_enforce_class_module_bad_simple( walker.walk(root_node) +@pytest.mark.parametrize( + "path", + [ + "homeassistant.components.pylint_test", + "homeassistant.components.pylint_test.my_coordinator", + "homeassistant.components.pylint_test.coordinator_other", + "homeassistant.components.pylint_test.sensor", + ], +) def test_enforce_class_module_bad_nested( linter: UnittestLinter, enforce_class_module_checker: BaseChecker, + path: str, ) -> None: """Bad test case with nested coordinators.""" root_node = astroid.parse( @@ -103,7 +133,7 @@ def test_enforce_class_module_bad_nested( class NopeCoordinator(TestCoordinator): pass """, - "homeassistant.components.pylint_test", + path, ) walker = ASTWalker(linter) walker.add_checker(enforce_class_module_checker) From 09dd6477415d78804d47bf94d00fd86e9474ba83 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:21:51 +0200 Subject: [PATCH 0542/1309] Simplify imports in mysensors (#125746) --- homeassistant/components/mysensors/binary_sensor.py | 7 ++++--- homeassistant/components/mysensors/climate.py | 7 ++++--- homeassistant/components/mysensors/cover.py | 7 ++++--- homeassistant/components/mysensors/light.py | 6 +++--- homeassistant/components/mysensors/sensor.py | 9 +++++---- homeassistant/components/mysensors/text.py | 4 ++-- 6 files changed, 22 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index b8a3769308a..47805e86b1c 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -17,8 +17,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import mysensors +from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo +from .device import MySensorsChildEntity from .helpers import on_unload @@ -77,7 +78,7 @@ async def async_setup_entry( @callback def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors binary_sensor.""" - mysensors.setup_mysensors_platform( + setup_mysensors_platform( hass, Platform.BINARY_SENSOR, discovery_info, @@ -96,7 +97,7 @@ async def async_setup_entry( ) -class MySensorsBinarySensor(mysensors.device.MySensorsChildEntity, BinarySensorEntity): +class MySensorsBinarySensor(MySensorsChildEntity, BinarySensorEntity): """Representation of a MySensors binary sensor child node.""" entity_description: MySensorsBinarySensorDescription diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index 0008297f299..79bc7b4b98d 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -18,8 +18,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM -from .. import mysensors +from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo +from .device import MySensorsChildEntity from .helpers import on_unload DICT_HA_TO_MYS = { @@ -48,7 +49,7 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors climate.""" - mysensors.setup_mysensors_platform( + setup_mysensors_platform( hass, Platform.CLIMATE, discovery_info, @@ -67,7 +68,7 @@ async def async_setup_entry( ) -class MySensorsHVAC(mysensors.device.MySensorsChildEntity, ClimateEntity): +class MySensorsHVAC(MySensorsChildEntity, ClimateEntity): """Representation of a MySensors HVAC.""" _attr_hvac_modes = OPERATION_LIST diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index acd5643965f..a5f4e7b1022 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -12,8 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import mysensors +from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo +from .device import MySensorsChildEntity from .helpers import on_unload @@ -36,7 +37,7 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors cover.""" - mysensors.setup_mysensors_platform( + setup_mysensors_platform( hass, Platform.COVER, discovery_info, @@ -55,7 +56,7 @@ async def async_setup_entry( ) -class MySensorsCover(mysensors.device.MySensorsChildEntity, CoverEntity): +class MySensorsCover(MySensorsChildEntity, CoverEntity): """Representation of the value of a MySensors Cover child node.""" def get_cover_state(self) -> CoverState: diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index c3691a40140..e10aee6187f 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -18,7 +18,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import rgb_hex_to_rgb_list -from .. import mysensors +from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType from .device import MySensorsChildEntity from .helpers import on_unload @@ -38,7 +38,7 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors light.""" - mysensors.setup_mysensors_platform( + setup_mysensors_platform( hass, Platform.LIGHT, discovery_info, @@ -57,7 +57,7 @@ async def async_setup_entry( ) -class MySensorsLight(mysensors.device.MySensorsChildEntity, LightEntity): +class MySensorsLight(MySensorsChildEntity, LightEntity): """Representation of a MySensors Light child node.""" def __init__(self, *args: Any) -> None: diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 82e6833f664..695382c491b 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -38,7 +38,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM -from .. import mysensors +from . import setup_mysensors_platform from .const import ( ATTR_GATEWAY_ID, ATTR_NODE_ID, @@ -49,6 +49,7 @@ from .const import ( DiscoveryInfo, NodeDiscoveryInfo, ) +from .device import MySensorNodeEntity, MySensorsChildEntity from .helpers import on_unload SENSORS: dict[str, SensorEntityDescription] = { @@ -215,7 +216,7 @@ async def async_setup_entry( async def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors sensor.""" - mysensors.setup_mysensors_platform( + setup_mysensors_platform( hass, Platform.SENSOR, discovery_info, @@ -252,7 +253,7 @@ async def async_setup_entry( ) -class MyBatterySensor(mysensors.device.MySensorNodeEntity, SensorEntity): +class MyBatterySensor(MySensorNodeEntity, SensorEntity): """Battery sensor of MySensors node.""" _attr_device_class = SensorDeviceClass.BATTERY @@ -277,7 +278,7 @@ class MyBatterySensor(mysensors.device.MySensorNodeEntity, SensorEntity): self.async_write_ha_state() -class MySensorsSensor(mysensors.device.MySensorsChildEntity, SensorEntity): +class MySensorsSensor(MySensorsChildEntity, SensorEntity): """Representation of a MySensors Sensor child node.""" _attr_force_update = True diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index 021324d7a67..8aed9df2eef 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .. import mysensors +from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .device import MySensorsChildEntity from .helpers import on_unload @@ -25,7 +25,7 @@ async def async_setup_entry( @callback def async_discover(discovery_info: DiscoveryInfo) -> None: """Discover and add a MySensors text entity.""" - mysensors.setup_mysensors_platform( + setup_mysensors_platform( hass, Platform.TEXT, discovery_info, From f42bc3aaae388d4f55af8ec9ed1baa1c8208d81f Mon Sep 17 00:00:00 2001 From: Assaf Akrabi Date: Wed, 11 Sep 2024 16:48:20 +0300 Subject: [PATCH 0543/1309] Bump russound to 0.2.0 (#125743) * Update russound library to fix BrokenPipeError * Remove library from license expection list --- homeassistant/components/russound_rnet/manifest.json | 2 +- homeassistant/components/russound_rnet/media_player.py | 8 +++++++- requirements_all.txt | 2 +- script/licenses.py | 1 - 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json index a93e3fe5a87..90bf5d5a7f3 100644 --- a/homeassistant/components/russound_rnet/manifest.json +++ b/homeassistant/components/russound_rnet/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/russound_rnet", "iot_class": "local_polling", "loggers": ["russound"], - "requirements": ["russound==0.1.9"] + "requirements": ["russound==0.2.0"] } diff --git a/homeassistant/components/russound_rnet/media_player.py b/homeassistant/components/russound_rnet/media_player.py index a08cfbe7747..f8369ed64ca 100644 --- a/homeassistant/components/russound_rnet/media_player.py +++ b/homeassistant/components/russound_rnet/media_player.py @@ -96,7 +96,13 @@ class RussoundRNETDevice(MediaPlayerEntity): # Updated this function to make a single call to get_zone_info, so that # with a single call we can get On/Off, Volume and Source, reducing the # amount of traffic and speeding up the update process. - ret = self._russ.get_zone_info(self._controller_id, self._zone_id, 4) + try: + ret = self._russ.get_zone_info(self._controller_id, self._zone_id, 4) + except BrokenPipeError: + _LOGGER.error("Broken Pipe Error, trying to reconnect to Russound RNET") + self._russ.connect() + ret = self._russ.get_zone_info(self._controller_id, self._zone_id, 4) + _LOGGER.debug("ret= %s", ret) if ret is not None: _LOGGER.debug( diff --git a/requirements_all.txt b/requirements_all.txt index 20e0684a551..f88ed31e89a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2567,7 +2567,7 @@ rpi-bad-power==0.1.0 rtsp-to-webrtc==0.5.1 # homeassistant.components.russound_rnet -russound==0.1.9 +russound==0.2.0 # homeassistant.components.ruuvitag_ble ruuvitag-ble==0.1.2 diff --git a/script/licenses.py b/script/licenses.py index a6805b0a3ca..72906da2a89 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -160,7 +160,6 @@ EXCEPTIONS = { "pyvera", # https://github.com/maximvelichko/pyvera/pull/164 "pyxeoma", # https://github.com/jeradM/pyxeoma/pull/11 "repoze.lru", - "russound", # https://github.com/laf/russound/pull/14 # codespell:ignore laf "ruuvitag-ble", # https://github.com/Bluetooth-Devices/ruuvitag-ble/pull/10 "sensirion-ble", # https://github.com/akx/sensirion-ble/pull/9 "sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14 From 79f3e30fb6f81024329784b27a486389cf681a4d Mon Sep 17 00:00:00 2001 From: Russell VanderMey Date: Wed, 11 Sep 2024 09:49:37 -0400 Subject: [PATCH 0544/1309] Add TRIGGERcmd integration (#121268) * Initial commit with errors * Commitable * Use triggercmd user id as hub name * Validate the token * Use switch type, no trigger yet * Working integration * Use triggercmd module instead of httpx * Add tests for triggercmd integration * Add triggercmd to requirements_test_all.txt * Add untested triggercmd files to .coveragerc * Implement cgarwood's PR suggestions * Address PR feedback * Update homeassistant/components/triggercmd/config_flow.py Co-authored-by: Robert Resch * Update homeassistant/components/triggercmd/hub.py Co-authored-by: Robert Resch * Update homeassistant/components/triggercmd/strings.json Co-authored-by: Robert Resch * Update homeassistant/components/triggercmd/hub.py Co-authored-by: Robert Resch * Get user id via triggercmd module, and better check for status 200 code * PR feedback fixes * Update homeassistant/components/triggercmd/switch.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/triggercmd/switch.py Co-authored-by: Joost Lekkerkerker * More PR feedback fixes * Update homeassistant/components/triggercmd/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/triggercmd/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/triggercmd/switch.py Co-authored-by: Joost Lekkerkerker * More PR feedback fixes * Update tests/components/triggercmd/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Changes for PR feedback * Changes to address PR comments * Fix connection error when no internet * Update homeassistant/components/triggercmd/__init__.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/triggercmd/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/triggercmd/config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/triggercmd/config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/triggercmd/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Updates for PR feedback * Update tests/components/triggercmd/test_config_flow.py --------- Co-authored-by: Robert Resch Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../components/triggercmd/__init__.py | 36 ++++ .../components/triggercmd/config_flow.py | 75 ++++++++ homeassistant/components/triggercmd/const.py | 4 + .../components/triggercmd/manifest.json | 10 ++ .../components/triggercmd/strings.json | 22 +++ homeassistant/components/triggercmd/switch.py | 85 +++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/triggercmd/__init__.py | 1 + tests/components/triggercmd/conftest.py | 15 ++ .../components/triggercmd/test_config_flow.py | 161 ++++++++++++++++++ 14 files changed, 424 insertions(+) create mode 100644 homeassistant/components/triggercmd/__init__.py create mode 100644 homeassistant/components/triggercmd/config_flow.py create mode 100644 homeassistant/components/triggercmd/const.py create mode 100644 homeassistant/components/triggercmd/manifest.json create mode 100644 homeassistant/components/triggercmd/strings.json create mode 100644 homeassistant/components/triggercmd/switch.py create mode 100644 tests/components/triggercmd/__init__.py create mode 100644 tests/components/triggercmd/conftest.py create mode 100644 tests/components/triggercmd/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 1f03fc5ed96..fdb7069069d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1540,6 +1540,8 @@ build.json @home-assistant/supervisor /tests/components/transmission/ @engrbm87 @JPHutchins /homeassistant/components/trend/ @jpbede /tests/components/trend/ @jpbede +/homeassistant/components/triggercmd/ @rvmey +/tests/components/triggercmd/ @rvmey /homeassistant/components/tts/ @home-assistant/core /tests/components/tts/ @home-assistant/core /homeassistant/components/tuya/ @Tuya @zlinoliver @frenck diff --git a/homeassistant/components/triggercmd/__init__.py b/homeassistant/components/triggercmd/__init__.py new file mode 100644 index 00000000000..f58b2b481d4 --- /dev/null +++ b/homeassistant/components/triggercmd/__init__.py @@ -0,0 +1,36 @@ +"""The TRIGGERcmd component.""" + +from __future__ import annotations + +from triggercmd import client, ha + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_TOKEN + +PLATFORMS = [ + Platform.SWITCH, +] + +type TriggercmdConfigEntry = ConfigEntry[ha.Hub] + + +async def async_setup_entry(hass: HomeAssistant, entry: TriggercmdConfigEntry) -> bool: + """Set up TRIGGERcmd from a config entry.""" + hub = ha.Hub(entry.data[CONF_TOKEN]) + + status_code = await client.async_connection_test(entry.data[CONF_TOKEN]) + if status_code != 200: + raise ConfigEntryNotReady + + entry.runtime_data = hub + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: TriggercmdConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/triggercmd/config_flow.py b/homeassistant/components/triggercmd/config_flow.py new file mode 100644 index 00000000000..f39d3abc9d4 --- /dev/null +++ b/homeassistant/components/triggercmd/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for TRIGGERcmd integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import jwt +from triggercmd import TRIGGERcmdConnectionError, client +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .const import CONF_TOKEN, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({(CONF_TOKEN): str}) + + +async def validate_input(hass: HomeAssistant, data: dict) -> str: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + if len(data[CONF_TOKEN]) < 100: + raise InvalidToken + + token_data = jwt.decode(data[CONF_TOKEN], options={"verify_signature": False}) + if not token_data["id"]: + raise InvalidToken + + try: + await client.async_connection_test(data[CONF_TOKEN]) + except Exception as e: + raise TRIGGERcmdConnectionError from e + else: + return token_data["id"] + + +class TriggerCMDConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + identifier = await validate_input(self.hass, user_input) + except InvalidToken: + errors[CONF_TOKEN] = "invalid_token" + except TRIGGERcmdConnectionError: + errors["base"] = "connection_error" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(identifier) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="TRIGGERcmd Hub", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class InvalidToken(HomeAssistantError): + """Invalid token.""" diff --git a/homeassistant/components/triggercmd/const.py b/homeassistant/components/triggercmd/const.py new file mode 100644 index 00000000000..0fc15b2b806 --- /dev/null +++ b/homeassistant/components/triggercmd/const.py @@ -0,0 +1,4 @@ +"""Constants for the TRIGGERcmd integration.""" + +DOMAIN = "triggercmd" +CONF_TOKEN = "token" diff --git a/homeassistant/components/triggercmd/manifest.json b/homeassistant/components/triggercmd/manifest.json new file mode 100644 index 00000000000..b71a5b83a81 --- /dev/null +++ b/homeassistant/components/triggercmd/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "triggercmd", + "name": "TRIGGERcmd", + "codeowners": ["@rvmey"], + "config_flow": true, + "documentation": "https://docs.triggercmd.com", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["triggercmd==0.0.27"] +} diff --git a/homeassistant/components/triggercmd/strings.json b/homeassistant/components/triggercmd/strings.json new file mode 100644 index 00000000000..cbbbbc312be --- /dev/null +++ b/homeassistant/components/triggercmd/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "data": { + "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "token": "The token from the TRIGGERcmd instructions page" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/triggercmd/switch.py b/homeassistant/components/triggercmd/switch.py new file mode 100644 index 00000000000..94566fe301d --- /dev/null +++ b/homeassistant/components/triggercmd/switch.py @@ -0,0 +1,85 @@ +"""Platform for switch integration.""" + +from __future__ import annotations + +import logging + +from triggercmd import client, ha + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TriggercmdConfigEntry +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TriggercmdConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add switch for passed config_entry in HA.""" + hub = config_entry.runtime_data + async_add_entities(TRIGGERcmdSwitch(trigger) for trigger in hub.triggers) + + +class TRIGGERcmdSwitch(SwitchEntity): + """Representation of a Switch.""" + + _attr_has_entity_name = True + _attr_assumed_state = True + _attr_should_poll = False + + computer_id: str + trigger_id: str + firmware_version: str + model: str + hub: ha.Hub + + def __init__(self, trigger: TRIGGERcmdSwitch) -> None: + """Initialize the switch.""" + self._switch = trigger + self._attr_is_on = False + self._attr_unique_id = f"{trigger.computer_id}.{trigger.trigger_id}" + self._attr_name = trigger.trigger_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, trigger.computer_id)}, + name=trigger.computer_id.capitalize(), + sw_version=trigger.firmware_version, + model=trigger.model, + manufacturer=trigger.hub.manufacturer, + ) + + @property + def available(self) -> bool: + """Return True if hub is available.""" + return self._switch.hub.online + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self.trigger("on") + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self.trigger("off") + self._attr_is_on = False + self.async_write_ha_state() + + async def trigger(self, params: str): + """Trigger the command.""" + r = await client.async_trigger( + self._switch.hub.token, + { + "computer": self._switch.computer_id, + "trigger": self._switch.trigger_id, + "params": params, + "sender": "Home Assistant", + }, + ) + _LOGGER.debug("TRIGGERcmd trigger response: %s", r.json()) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 351f9e8e2e5..a0fb9a48a17 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -618,6 +618,7 @@ FLOWS = { "trafikverket_train", "trafikverket_weatherstation", "transmission", + "triggercmd", "tuya", "twentemilieu", "twilio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1e518cfe3aa..62e77d0edb1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6460,6 +6460,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "triggercmd": { + "name": "TRIGGERcmd", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tuya": { "name": "Tuya", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index f88ed31e89a..22fba3efe18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2835,6 +2835,9 @@ tplink-omada-client==1.4.2 # homeassistant.components.transmission transmission-rpc==7.0.3 +# homeassistant.components.triggercmd +triggercmd==0.0.27 + # homeassistant.components.twinkly ttls==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 507362eb7df..34b9892885e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2242,6 +2242,9 @@ tplink-omada-client==1.4.2 # homeassistant.components.transmission transmission-rpc==7.0.3 +# homeassistant.components.triggercmd +triggercmd==0.0.27 + # homeassistant.components.twinkly ttls==1.8.3 diff --git a/tests/components/triggercmd/__init__.py b/tests/components/triggercmd/__init__.py new file mode 100644 index 00000000000..90562a67386 --- /dev/null +++ b/tests/components/triggercmd/__init__.py @@ -0,0 +1 @@ +"""Tests for the triggercmd integration.""" diff --git a/tests/components/triggercmd/conftest.py b/tests/components/triggercmd/conftest.py new file mode 100644 index 00000000000..5e2ac250d61 --- /dev/null +++ b/tests/components/triggercmd/conftest.py @@ -0,0 +1,15 @@ +"""triggercmd conftest.""" + +from unittest.mock import patch + +import pytest + + +@pytest.fixture +def mock_async_setup_entry(): + """Mock async_setup_entry.""" + with patch( + "homeassistant.components.triggercmd.async_setup_entry", + return_value=True, + ) as mock_async_setup_entry: + yield mock_async_setup_entry diff --git a/tests/components/triggercmd/test_config_flow.py b/tests/components/triggercmd/test_config_flow.py new file mode 100644 index 00000000000..51f3730ab1a --- /dev/null +++ b/tests/components/triggercmd/test_config_flow.py @@ -0,0 +1,161 @@ +"""Define tests for the triggercmd config flow.""" + +from unittest.mock import patch + +import pytest +from triggercmd import TRIGGERcmdConnectionError + +from homeassistant.components.triggercmd.const import CONF_TOKEN, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +invalid_token_with_length_100_or_more = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEyMzQ1Njc4OTBxd2VydHl1aW9wYXNkZiIsImlhdCI6MTcxOTg4MTU4M30.E4T2S4RQfuI2ww74sUkkT-wyTGrV5_VDkgUdae5yo4E" +invalid_token_id = "1234567890qwertyuiopasdf" +invalid_token_with_length_100_or_more_and_no_id = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub2lkIjoiMTIzNDU2Nzg5MHF3ZXJ0eXVpb3Bhc2RmIiwiaWF0IjoxNzE5ODgxNTgzfQ.MaJLNWPGCE51Zibhbq-Yz7h3GkUxLurR2eoM2frnO6Y" + + +async def test_full_flow( + hass: HomeAssistant, +) -> None: + """Test config flow happy path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["errors"] == {} + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + with ( + patch( + "homeassistant.components.triggercmd.client.async_connection_test", + return_value=200, + ), + patch( + "homeassistant.components.triggercmd.ha.Hub", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: invalid_token_with_length_100_or_more}, + ) + + assert result["data"] == {CONF_TOKEN: invalid_token_with_length_100_or_more} + assert result["result"].unique_id == invalid_token_id + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("test_input", "expected"), + [ + (invalid_token_with_length_100_or_more_and_no_id, {"base": "unknown"}), + ("not-a-token", {CONF_TOKEN: "invalid_token"}), + ], +) +async def test_config_flow_user_invalid_token( + hass: HomeAssistant, + test_input: str, + expected: dict, +) -> None: + """Test the initial step of the config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with ( + patch( + "homeassistant.components.triggercmd.client.async_connection_test", + return_value=200, + ), + patch( + "homeassistant.components.triggercmd.ha.Hub", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: test_input}, + ) + + assert result["errors"] == expected + assert result["step_id"] == "user" + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: invalid_token_with_length_100_or_more}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_config_flow_entry_already_configured(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + MockConfigEntry( + domain=DOMAIN, + data={CONF_TOKEN: invalid_token_with_length_100_or_more}, + unique_id=invalid_token_id, + ).add_to_hass(hass) + + with ( + patch( + "homeassistant.components.triggercmd.client.async_connection_test", + return_value=200, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: invalid_token_with_length_100_or_more}, + ) + + assert result["reason"] == "already_configured" + assert result["type"] is FlowResultType.ABORT + + +async def test_config_flow_connection_error(hass: HomeAssistant) -> None: + """Test a connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + with ( + patch( + "homeassistant.components.triggercmd.client.async_connection_test", + side_effect=TRIGGERcmdConnectionError, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: invalid_token_with_length_100_or_more}, + ) + + assert result["errors"] == { + "base": "connection_error", + } + assert result["type"] is FlowResultType.FORM + + with ( + patch( + "homeassistant.components.triggercmd.client.async_connection_test", + return_value=200, + ), + patch( + "homeassistant.components.triggercmd.ha.Hub", + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: invalid_token_with_length_100_or_more}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY From 059fbe7958de91c87474476f79ded2e487cfcdb0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 15:52:44 +0200 Subject: [PATCH 0545/1309] Use HassKey in ads (#125735) --- homeassistant/components/ads/__init__.py | 10 +------ homeassistant/components/ads/binary_sensor.py | 10 +++---- homeassistant/components/ads/const.py | 18 ++++++++++++ homeassistant/components/ads/cover.py | 28 +++++++++---------- homeassistant/components/ads/entity.py | 2 +- homeassistant/components/ads/light.py | 19 ++++++------- homeassistant/components/ads/sensor.py | 20 +++++++------ homeassistant/components/ads/switch.py | 8 +++--- homeassistant/components/ads/valve.py | 11 ++++---- 9 files changed, 67 insertions(+), 59 deletions(-) create mode 100644 homeassistant/components/ads/const.py diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index c5c3b48499a..da855fb7228 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -15,11 +15,11 @@ from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType +from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN from .hub import AdsHub _LOGGER = logging.getLogger(__name__) -DATA_ADS = "data_ads" # Supported Types ADSTYPE_BOOL = "bool" @@ -63,15 +63,7 @@ ADS_TYPEMAP = { CONF_ADS_FACTOR = "factor" CONF_ADS_TYPE = "adstype" CONF_ADS_VALUE = "value" -CONF_ADS_VAR = "adsvar" -CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness" -CONF_ADS_VAR_POSITION = "adsvar_position" -STATE_KEY_STATE = "state" -STATE_KEY_BRIGHTNESS = "brightness" -STATE_KEY_POSITION = "position" - -DOMAIN = "ads" SERVICE_WRITE_DATA_BY_NAME = "write_data_by_name" diff --git a/homeassistant/components/ads/binary_sensor.py b/homeassistant/components/ads/binary_sensor.py index fde9ceaa143..4704026e454 100644 --- a/homeassistant/components/ads/binary_sensor.py +++ b/homeassistant/components/ads/binary_sensor.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE +from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE from .entity import AdsEntity DEFAULT_NAME = "ADS binary sensor" @@ -37,11 +37,11 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Binary Sensor platform for ADS.""" - ads_hub = hass.data.get(DATA_ADS) + ads_hub = hass.data[DATA_ADS] - ads_var = config[CONF_ADS_VAR] - name = config[CONF_NAME] - device_class = config.get(CONF_DEVICE_CLASS) + ads_var: str = config[CONF_ADS_VAR] + name: str = config[CONF_NAME] + device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) ads_sensor = AdsBinarySensor(ads_hub, name, ads_var, device_class) add_entities([ads_sensor]) diff --git a/homeassistant/components/ads/const.py b/homeassistant/components/ads/const.py new file mode 100644 index 00000000000..5683077e023 --- /dev/null +++ b/homeassistant/components/ads/const.py @@ -0,0 +1,18 @@ +"""Support for Automation Device Specification (ADS).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .hub import AdsHub + +DOMAIN = "ads" + +DATA_ADS: HassKey[AdsHub] = HassKey(DOMAIN) + +CONF_ADS_VAR = "adsvar" + +STATE_KEY_STATE = "state" diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index be1b0564069..31c1eac5d18 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -11,6 +11,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA as COVER_PLATFORM_SCHEMA, + CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -20,13 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( - CONF_ADS_VAR, - CONF_ADS_VAR_POSITION, - DATA_ADS, - STATE_KEY_POSITION, - STATE_KEY_STATE, -) +from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE from .entity import AdsEntity DEFAULT_NAME = "ADS Cover" @@ -35,6 +30,9 @@ CONF_ADS_VAR_SET_POS = "adsvar_set_position" CONF_ADS_VAR_OPEN = "adsvar_open" CONF_ADS_VAR_CLOSE = "adsvar_close" CONF_ADS_VAR_STOP = "adsvar_stop" +CONF_ADS_VAR_POSITION = "adsvar_position" + +STATE_KEY_POSITION = "position" PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( { @@ -59,14 +57,14 @@ def setup_platform( """Set up the cover platform for ADS.""" ads_hub = hass.data[DATA_ADS] - ads_var_is_closed = config.get(CONF_ADS_VAR) - ads_var_position = config.get(CONF_ADS_VAR_POSITION) - ads_var_pos_set = config.get(CONF_ADS_VAR_SET_POS) - ads_var_open = config.get(CONF_ADS_VAR_OPEN) - ads_var_close = config.get(CONF_ADS_VAR_CLOSE) - ads_var_stop = config.get(CONF_ADS_VAR_STOP) - name = config[CONF_NAME] - device_class = config.get(CONF_DEVICE_CLASS) + ads_var_is_closed: str | None = config.get(CONF_ADS_VAR) + ads_var_position: str | None = config.get(CONF_ADS_VAR_POSITION) + ads_var_pos_set: str | None = config.get(CONF_ADS_VAR_SET_POS) + ads_var_open: str | None = config.get(CONF_ADS_VAR_OPEN) + ads_var_close: str | None = config.get(CONF_ADS_VAR_CLOSE) + ads_var_stop: str | None = config.get(CONF_ADS_VAR_STOP) + name: str = config[CONF_NAME] + device_class: CoverDeviceClass | None = config.get(CONF_DEVICE_CLASS) add_entities( [ diff --git a/homeassistant/components/ads/entity.py b/homeassistant/components/ads/entity.py index 407be5c24e8..3973d279a22 100644 --- a/homeassistant/components/ads/entity.py +++ b/homeassistant/components/ads/entity.py @@ -6,7 +6,7 @@ import logging from homeassistant.helpers.entity import Entity -from . import STATE_KEY_STATE +from .const import STATE_KEY_STATE _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py index ac4f27a30dc..17e94923b01 100644 --- a/homeassistant/components/ads/light.py +++ b/homeassistant/components/ads/light.py @@ -19,15 +19,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( - CONF_ADS_VAR, - CONF_ADS_VAR_BRIGHTNESS, - DATA_ADS, - STATE_KEY_BRIGHTNESS, - STATE_KEY_STATE, -) +from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE from .entity import AdsEntity +CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness" +STATE_KEY_BRIGHTNESS = "brightness" + DEFAULT_NAME = "ADS Light" PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend( { @@ -45,11 +42,11 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the light platform for ADS.""" - ads_hub = hass.data.get(DATA_ADS) + ads_hub = hass.data[DATA_ADS] - ads_var_enable = config[CONF_ADS_VAR] - ads_var_brightness = config.get(CONF_ADS_VAR_BRIGHTNESS) - name = config[CONF_NAME] + ads_var_enable: str = config[CONF_ADS_VAR] + ads_var_brightness: str | None = config.get(CONF_ADS_VAR_BRIGHTNESS) + name: str = config[CONF_NAME] add_entities([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, name)]) diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 40a61da6657..9dea722e864 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -20,7 +20,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from .. import ads -from . import ADS_TYPEMAP, CONF_ADS_FACTOR, CONF_ADS_TYPE, CONF_ADS_VAR, STATE_KEY_STATE +from . import ADS_TYPEMAP, CONF_ADS_FACTOR, CONF_ADS_TYPE +from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE from .entity import AdsEntity DEFAULT_NAME = "ADS sensor" @@ -60,14 +61,15 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up an ADS sensor device.""" - ads_hub: ads.AdsHub = hass.data[ads.DATA_ADS] - ads_var = config[CONF_ADS_VAR] - ads_type = config[CONF_ADS_TYPE] - name = config[CONF_NAME] - factor = config.get(CONF_ADS_FACTOR) - device_class = config.get(CONF_DEVICE_CLASS) - state_class = config.get(CONF_STATE_CLASS) - unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + ads_hub = hass.data[DATA_ADS] + + ads_var: str = config[CONF_ADS_VAR] + ads_type: str = config[CONF_ADS_TYPE] + name: str = config[CONF_NAME] + factor: int | None = config.get(CONF_ADS_FACTOR) + device_class: SensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) + state_class: SensorStateClass | None = config.get(CONF_STATE_CLASS) + unit_of_measurement: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) entity = AdsSensor( ads_hub, diff --git a/homeassistant/components/ads/switch.py b/homeassistant/components/ads/switch.py index ba8564d6f1f..0412a127c95 100644 --- a/homeassistant/components/ads/switch.py +++ b/homeassistant/components/ads/switch.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE +from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE from .entity import AdsEntity DEFAULT_NAME = "ADS Switch" @@ -37,10 +37,10 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switch platform for ADS.""" - ads_hub = hass.data.get(DATA_ADS) + ads_hub = hass.data[DATA_ADS] - name = config[CONF_NAME] - ads_var = config[CONF_ADS_VAR] + name: str = config[CONF_NAME] + ads_var: str = config[CONF_ADS_VAR] add_entities([AdsSwitch(ads_hub, name, ads_var)]) diff --git a/homeassistant/components/ads/valve.py b/homeassistant/components/ads/valve.py index 88e2836335f..f20e21477db 100644 --- a/homeassistant/components/ads/valve.py +++ b/homeassistant/components/ads/valve.py @@ -18,7 +18,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_ADS_VAR, DATA_ADS +from .const import CONF_ADS_VAR, DATA_ADS from .entity import AdsEntity from .hub import AdsHub @@ -40,10 +40,11 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up an ADS valve device.""" - ads_hub: AdsHub = hass.data[DATA_ADS] - ads_var = config[CONF_ADS_VAR] - name = config[CONF_NAME] - device_class = config.get(CONF_DEVICE_CLASS) + ads_hub = hass.data[DATA_ADS] + + ads_var: str = config[CONF_ADS_VAR] + name: str = config[CONF_NAME] + device_class: ValveDeviceClass | None = config.get(CONF_DEVICE_CLASS) supported_features: ValveEntityFeature = ( ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE ) From 29311c7eb8f42d915325174fd86c8e52ae3ac257 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 11 Sep 2024 15:58:23 +0200 Subject: [PATCH 0546/1309] Fix favorite position missing for Motion Blinds TDBU devices (#125750) * Add favorite position for TDBU * fix styling --- homeassistant/components/motion_blinds/button.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/motion_blinds/button.py b/homeassistant/components/motion_blinds/button.py index 30f1cd53e6f..89841bf8fd4 100644 --- a/homeassistant/components/motion_blinds/button.py +++ b/homeassistant/components/motion_blinds/button.py @@ -26,7 +26,13 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] for blind in motion_gateway.device_list.values(): - if blind.limit_status == LimitStatus.Limit3Detected.name: + if blind.limit_status in ( + LimitStatus.Limit3Detected.name, + { + "T": LimitStatus.Limit3Detected.name, + "B": LimitStatus.Limit3Detected.name, + }, + ): entities.append(MotionGoFavoriteButton(coordinator, blind)) entities.append(MotionSetFavoriteButton(coordinator, blind)) From e140a2980ba04b82f20ae7f2c9d7a9237ef39782 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:07:42 +0200 Subject: [PATCH 0547/1309] Move shared constant in ios (#125748) --- homeassistant/components/ios/__init__.py | 31 ++++-------- homeassistant/components/ios/const.py | 22 ++++++++ homeassistant/components/ios/notify.py | 12 ++--- homeassistant/components/ios/sensor.py | 64 +++++++++++++++--------- 4 files changed, 77 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py index 2a821166d8a..ef141a28475 100644 --- a/homeassistant/components/ios/__init__.py +++ b/homeassistant/components/ios/__init__.py @@ -19,6 +19,16 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import load_json_object from .const import ( + ATTR_BATTERY, + ATTR_BATTERY_LEVEL, + ATTR_BATTERY_STATE, + ATTR_DEVICE, + ATTR_DEVICE_ID, + ATTR_DEVICE_NAME, + ATTR_DEVICE_PERMANENT_ID, + ATTR_DEVICE_SYSTEM_VERSION, + ATTR_DEVICE_TYPE, + BATTERY_STATES, CONF_ACTION_BACKGROUND_COLOR, CONF_ACTION_ICON, CONF_ACTION_ICON_COLOR, @@ -64,21 +74,14 @@ BEHAVIORS = [ATTR_DEFAULT_BEHAVIOR, ATTR_TEXT_INPUT_BEHAVIOR] ATTR_LAST_SEEN_AT = "lastSeenAt" -ATTR_DEVICE = "device" ATTR_PUSH_TOKEN = "pushToken" ATTR_APP = "app" ATTR_PERMISSIONS = "permissions" ATTR_PUSH_ID = "pushId" -ATTR_DEVICE_ID = "deviceId" ATTR_PUSH_SOUNDS = "pushSounds" -ATTR_BATTERY = "battery" -ATTR_DEVICE_NAME = "name" ATTR_DEVICE_LOCALIZED_MODEL = "localizedModel" ATTR_DEVICE_MODEL = "model" -ATTR_DEVICE_PERMANENT_ID = "permanentID" -ATTR_DEVICE_SYSTEM_VERSION = "systemVersion" -ATTR_DEVICE_TYPE = "type" ATTR_DEVICE_SYSTEM_NAME = "systemName" ATTR_APP_BUNDLE_IDENTIFIER = "bundleIdentifier" @@ -90,20 +93,6 @@ ATTR_NOTIFICATIONS_PERMISSION = "notifications" PERMISSIONS = [ATTR_LOCATION_PERMISSION, ATTR_NOTIFICATIONS_PERMISSION] -ATTR_BATTERY_STATE = "state" -ATTR_BATTERY_LEVEL = "level" - -ATTR_BATTERY_STATE_UNPLUGGED = "Not Charging" -ATTR_BATTERY_STATE_CHARGING = "Charging" -ATTR_BATTERY_STATE_FULL = "Full" -ATTR_BATTERY_STATE_UNKNOWN = "Unknown" - -BATTERY_STATES = [ - ATTR_BATTERY_STATE_UNPLUGGED, - ATTR_BATTERY_STATE_CHARGING, - ATTR_BATTERY_STATE_FULL, - ATTR_BATTERY_STATE_UNKNOWN, -] ATTR_DEVICES = "devices" diff --git a/homeassistant/components/ios/const.py b/homeassistant/components/ios/const.py index 181bbebd9a6..c9782aab1c7 100644 --- a/homeassistant/components/ios/const.py +++ b/homeassistant/components/ios/const.py @@ -2,6 +2,28 @@ DOMAIN = "ios" +ATTR_BATTERY = "battery" +ATTR_BATTERY_LEVEL = "level" +ATTR_BATTERY_STATE = "state" +ATTR_BATTERY_STATE_UNPLUGGED = "Not Charging" +ATTR_BATTERY_STATE_CHARGING = "Charging" +ATTR_BATTERY_STATE_FULL = "Full" +ATTR_BATTERY_STATE_UNKNOWN = "Unknown" + +BATTERY_STATES = [ + ATTR_BATTERY_STATE_UNPLUGGED, + ATTR_BATTERY_STATE_CHARGING, + ATTR_BATTERY_STATE_FULL, + ATTR_BATTERY_STATE_UNKNOWN, +] + +ATTR_DEVICE = "device" +ATTR_DEVICE_ID = "deviceId" +ATTR_DEVICE_NAME = "name" +ATTR_DEVICE_PERMANENT_ID = "permanentID" +ATTR_DEVICE_SYSTEM_VERSION = "systemVersion" +ATTR_DEVICE_TYPE = "type" + CONF_ACTION_NAME = "name" CONF_ACTION_BACKGROUND_COLOR = "background_color" CONF_ACTION_LABEL = "label" diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 92a706b3a38..b5bd0aea58f 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from .. import ios +from . import device_name_for_push_id, devices_with_push, enabled_push_ids _LOGGER = logging.getLogger(__name__) @@ -42,7 +42,7 @@ def log_rate_limits( _LOGGER.log( level, rate_limit_msg, - ios.device_name_for_push_id(hass, target), + device_name_for_push_id(hass, target), rate_limits["successful"], rate_limits["maximum"], rate_limits["errors"], @@ -60,7 +60,7 @@ def get_service( # Need this to enable requirements checking in the app. hass.config.components.add("ios.notify") - if not ios.devices_with_push(hass): + if not devices_with_push(hass): return None return iOSNotificationService() @@ -75,7 +75,7 @@ class iOSNotificationService(BaseNotificationService): @property def targets(self) -> dict[str, str]: """Return a dictionary of registered targets.""" - return ios.devices_with_push(self.hass) + return devices_with_push(self.hass) def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to the Lambda APNS gateway.""" @@ -89,13 +89,13 @@ class iOSNotificationService(BaseNotificationService): data[ATTR_TITLE] = kwargs.get(ATTR_TITLE) if not (targets := kwargs.get(ATTR_TARGET)): - targets = ios.enabled_push_ids(self.hass) + targets = enabled_push_ids(self.hass) if kwargs.get(ATTR_DATA) is not None: data[ATTR_DATA] = kwargs.get(ATTR_DATA) for target in targets: - if target not in ios.enabled_push_ids(self.hass): + if target not in enabled_push_ids(self.hass): _LOGGER.error("The target (%s) does not exist in .ios.conf", targets) return diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index 4171b8ecd46..a97c2145919 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -18,8 +18,22 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .. import ios -from .const import DOMAIN +from . import devices +from .const import ( + ATTR_BATTERY, + ATTR_BATTERY_LEVEL, + ATTR_BATTERY_STATE, + ATTR_BATTERY_STATE_FULL, + ATTR_BATTERY_STATE_UNKNOWN, + ATTR_BATTERY_STATE_UNPLUGGED, + ATTR_DEVICE, + ATTR_DEVICE_ID, + ATTR_DEVICE_NAME, + ATTR_DEVICE_PERMANENT_ID, + ATTR_DEVICE_SYSTEM_VERSION, + ATTR_DEVICE_TYPE, + DOMAIN, +) SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -55,7 +69,7 @@ async def async_setup_entry( """Set up iOS from a config entry.""" async_add_entities( IOSSensor(device_name, device, description) - for device_name, device in ios.devices(hass).items() + for device_name, device in devices(hass).items() for description in SENSOR_TYPES ) @@ -76,7 +90,7 @@ class IOSSensor(SensorEntity): self.entity_description = description self._device = device - device_id = device[ios.ATTR_DEVICE_ID] + device_id = device[ATTR_DEVICE_ID] self._attr_unique_id = f"{description.key}_{device_id}" @property @@ -85,44 +99,44 @@ class IOSSensor(SensorEntity): return DeviceInfo( identifiers={ ( - ios.DOMAIN, - self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_PERMANENT_ID], + DOMAIN, + self._device[ATTR_DEVICE][ATTR_DEVICE_PERMANENT_ID], ) }, manufacturer="Apple", - model=self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_TYPE], - name=self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME], - sw_version=self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_SYSTEM_VERSION], + model=self._device[ATTR_DEVICE][ATTR_DEVICE_TYPE], + name=self._device[ATTR_DEVICE][ATTR_DEVICE_NAME], + sw_version=self._device[ATTR_DEVICE][ATTR_DEVICE_SYSTEM_VERSION], ) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" - device = self._device[ios.ATTR_DEVICE] - device_battery = self._device[ios.ATTR_BATTERY] + device = self._device[ATTR_DEVICE] + device_battery = self._device[ATTR_BATTERY] return { - "Battery State": device_battery[ios.ATTR_BATTERY_STATE], - "Battery Level": device_battery[ios.ATTR_BATTERY_LEVEL], - "Device Type": device[ios.ATTR_DEVICE_TYPE], - "Device Name": device[ios.ATTR_DEVICE_NAME], - "Device Version": device[ios.ATTR_DEVICE_SYSTEM_VERSION], + "Battery State": device_battery[ATTR_BATTERY_STATE], + "Battery Level": device_battery[ATTR_BATTERY_LEVEL], + "Device Type": device[ATTR_DEVICE_TYPE], + "Device Name": device[ATTR_DEVICE_NAME], + "Device Version": device[ATTR_DEVICE_SYSTEM_VERSION], } @property def icon(self) -> str: """Return the icon to use in the frontend, if any.""" - device_battery = self._device[ios.ATTR_BATTERY] - battery_state = device_battery[ios.ATTR_BATTERY_STATE] - battery_level = device_battery[ios.ATTR_BATTERY_LEVEL] + device_battery = self._device[ATTR_BATTERY] + battery_state = device_battery[ATTR_BATTERY_STATE] + battery_level = device_battery[ATTR_BATTERY_LEVEL] charging = True icon_state = DEFAULT_ICON_STATE if battery_state in ( - ios.ATTR_BATTERY_STATE_FULL, - ios.ATTR_BATTERY_STATE_UNPLUGGED, + ATTR_BATTERY_STATE_FULL, + ATTR_BATTERY_STATE_UNPLUGGED, ): charging = False icon_state = f"{DEFAULT_ICON_STATE}-off" - elif battery_state == ios.ATTR_BATTERY_STATE_UNKNOWN: + elif battery_state == ATTR_BATTERY_STATE_UNKNOWN: battery_level = None charging = False icon_state = f"{DEFAULT_ICON_LEVEL}-unknown" @@ -135,17 +149,17 @@ class IOSSensor(SensorEntity): def _update(self, device: dict[str, Any]) -> None: """Get the latest state of the sensor.""" self._device = device - self._attr_native_value = self._device[ios.ATTR_BATTERY][ + self._attr_native_value = self._device[ATTR_BATTERY][ self.entity_description.key ] self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Handle addition to hass: register to dispatch.""" - self._attr_native_value = self._device[ios.ATTR_BATTERY][ + self._attr_native_value = self._device[ATTR_BATTERY][ self.entity_description.key ] - device_id = self._device[ios.ATTR_DEVICE_ID] + device_id = self._device[ATTR_DEVICE_ID] self.async_on_remove( async_dispatcher_connect(self.hass, f"{DOMAIN}.{device_id}", self._update) ) From a7b6652fba7d7d37d04b3e58345987b67fe88a06 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:07:57 +0200 Subject: [PATCH 0548/1309] Simplify imports in pilight (#125747) --- homeassistant/components/pilight/binary_sensor.py | 6 +++--- homeassistant/components/pilight/sensor.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/pilight/binary_sensor.py b/homeassistant/components/pilight/binary_sensor.py index 4d68748e0f7..0a94147af70 100644 --- a/homeassistant/components/pilight/binary_sensor.py +++ b/homeassistant/components/pilight/binary_sensor.py @@ -24,7 +24,7 @@ from homeassistant.helpers.event import track_point_in_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .. import pilight +from . import EVENT CONF_VARIABLE = "variable" CONF_RESET_DELAY_SEC = "reset_delay_sec" @@ -96,7 +96,7 @@ class PilightBinarySensor(BinarySensorEntity): self._on_value = on_value self._off_value = off_value - hass.bus.listen(pilight.EVENT, self._handle_code) + hass.bus.listen(EVENT, self._handle_code) @property def name(self): @@ -150,7 +150,7 @@ class PilightTriggerSensor(BinarySensorEntity): self._delay_after = None self._hass = hass - hass.bus.listen(pilight.EVENT, self._handle_code) + hass.bus.listen(EVENT, self._handle_code) @property def name(self): diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py index 8e5f3b7d78a..5ab80f57dc6 100644 --- a/homeassistant/components/pilight/sensor.py +++ b/homeassistant/components/pilight/sensor.py @@ -16,7 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .. import pilight +from . import EVENT _LOGGER = logging.getLogger(__name__) @@ -67,7 +67,7 @@ class PilightSensor(SensorEntity): self._payload = payload self._unit_of_measurement = unit_of_measurement - hass.bus.listen(pilight.EVENT, self._handle_code) + hass.bus.listen(EVENT, self._handle_code) @property def name(self): From cee14afc03d9b229b27c63b904415d5634c71b37 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:08:12 +0200 Subject: [PATCH 0549/1309] Move shared constant in zabbix (#125744) --- homeassistant/components/zabbix/__init__.py | 3 ++- homeassistant/components/zabbix/const.py | 3 +++ homeassistant/components/zabbix/sensor.py | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/zabbix/const.py diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 851af54da32..924903b241d 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -34,13 +34,14 @@ from homeassistant.helpers.entityfilter import ( ) from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) CONF_PUBLISH_STATES_HOST = "publish_states_host" DEFAULT_SSL = False DEFAULT_PATH = "zabbix" -DOMAIN = "zabbix" TIMEOUT = 5 RETRY_DELAY = 20 diff --git a/homeassistant/components/zabbix/const.py b/homeassistant/components/zabbix/const.py new file mode 100644 index 00000000000..5f710381f38 --- /dev/null +++ b/homeassistant/components/zabbix/const.py @@ -0,0 +1,3 @@ +"""Constants for Zabbix.""" + +DOMAIN = "zabbix" diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index 2187deb22e8..7cf1ed43cd9 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -19,7 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from .. import zabbix +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def setup_platform( """Set up the Zabbix sensor platform.""" sensors: list[ZabbixTriggerCountSensor] = [] - if not (zapi := hass.data[zabbix.DOMAIN]): + if not (zapi := hass.data[DOMAIN]): _LOGGER.error("Zabbix integration hasn't been loaded? zapi is None") return From 2db488b7a4b89ab0cd77f63ce733fbdb95df7887 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Wed, 11 Sep 2024 10:09:22 -0400 Subject: [PATCH 0550/1309] Add seek, shuffle, and repeat controls to Cambridge Audio (#125758) * Add advanced transport controls to Cambridge Audio * Use TransportControl model for play/pause --- .../cambridge_audio/media_player.py | 86 +++++++++++++++---- 1 file changed, 70 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index 27be2a60e52..c1f7cfcc4bc 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -4,13 +4,20 @@ from __future__ import annotations from datetime import datetime -from aiostreammagic import StreamMagicClient +from aiostreammagic import ( + RepeatMode as CambridgeRepeatMode, + ShuffleMode, + StreamMagicClient, + TransportControl, +) from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, + RepeatMode, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -30,6 +37,16 @@ PREAMP_FEATURES = ( | MediaPlayerEntityFeature.VOLUME_STEP ) +TRANSPORT_FEATURES: dict[TransportControl, MediaPlayerEntityFeature] = { + TransportControl.PLAY: MediaPlayerEntityFeature.PLAY, + TransportControl.PAUSE: MediaPlayerEntityFeature.PAUSE, + TransportControl.TRACK_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, + TransportControl.TRACK_PREVIOUS: MediaPlayerEntityFeature.PREVIOUS_TRACK, + TransportControl.TOGGLE_REPEAT: MediaPlayerEntityFeature.REPEAT_SET, + TransportControl.TOGGLE_SHUFFLE: MediaPlayerEntityFeature.SHUFFLE_SET, + TransportControl.SEEK: MediaPlayerEntityFeature.SEEK, +} + async def async_setup_entry( hass: HomeAssistant, @@ -46,6 +63,7 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): _attr_name = None _attr_media_content_type = MediaType.MUSIC + _attr_device_class = MediaPlayerDeviceClass.RECEIVER def __init__(self, client: StreamMagicClient) -> None: """Initialize an Cambridge Audio entity.""" @@ -71,16 +89,12 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): features = BASE_FEATURES if self.client.state.pre_amp_mode: features |= PREAMP_FEATURES - if "play_pause" in controls: + if TransportControl.PLAY_PAUSE in controls: features |= MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PAUSE - if "play" in controls: - features |= MediaPlayerEntityFeature.PLAY - if "pause" in controls: - features |= MediaPlayerEntityFeature.PAUSE - if "track_next" in controls: - features |= MediaPlayerEntityFeature.NEXT_TRACK - if "track_previous" in controls: - features |= MediaPlayerEntityFeature.PREVIOUS_TRACK + for control in controls: + feature = TRANSPORT_FEATURES.get(control) + if feature: + features |= feature return features @property @@ -164,6 +178,22 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): volume = self.client.state.volume_percent or 0 return volume / 100 + @property + def shuffle(self) -> bool | None: + """Current shuffle configuration.""" + mode_shuffle = self.client.play_state.mode_shuffle + if not mode_shuffle: + return False + return mode_shuffle != ShuffleMode.OFF + + @property + def repeat(self) -> RepeatMode | None: + """Current repeat configuration.""" + mode_repeat = RepeatMode.OFF + if self.client.play_state.mode_repeat == CambridgeRepeatMode.ALL: + mode_repeat = RepeatMode.ALL + return mode_repeat + async def async_media_play_pause(self) -> None: """Toggle play/pause the current media.""" await self.client.play_pause() @@ -171,7 +201,10 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): async def async_media_pause(self) -> None: """Pause the current media.""" controls = self.client.now_playing.controls - if "pause" not in controls and "play_pause" in controls: + if ( + TransportControl.PAUSE not in controls + and TransportControl.PLAY_PAUSE in controls + ): await self.client.play_pause() else: await self.client.pause() @@ -182,8 +215,14 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): async def async_media_play(self) -> None: """Play the current media.""" - if self.state == MediaPlayerState.PAUSED: + controls = self.client.now_playing.controls + if ( + TransportControl.PLAY not in controls + and TransportControl.PLAY_PAUSE in controls + ): await self.client.play_pause() + else: + await self.client.play() async def async_media_next_track(self) -> None: """Skip to the next track.""" @@ -222,7 +261,22 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): async def async_mute_volume(self, mute: bool) -> None: """Set the mute state.""" - if mute: - await self.client.mute() - else: - await self.client.unmute() + await self.client.set_mute(mute) + + async def async_media_seek(self, position: float) -> None: + """Seek to a position in the current media.""" + await self.client.media_seek(int(position)) + + async def async_set_shuffle(self, shuffle: bool) -> None: + """Set the shuffle mode for the current queue.""" + shuffle_mode = ShuffleMode.OFF + if shuffle: + shuffle_mode = ShuffleMode.ALL + await self.client.set_shuffle(shuffle_mode) + + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set the repeat mode for the current queue.""" + repeat_mode = CambridgeRepeatMode.OFF + if repeat: + repeat_mode = CambridgeRepeatMode.ALL + await self.client.set_repeat(repeat_mode) From f6cf23a8c2fa8fd46a6732550b1db913cefc6678 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 11 Sep 2024 16:17:20 +0200 Subject: [PATCH 0551/1309] Remove deprecated attributes from ping binary sensor (#125760) --- homeassistant/components/ping/binary_sensor.py | 17 ----------------- .../ping/snapshots/test_binary_sensor.ambr | 8 -------- 2 files changed, 25 deletions(-) diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 93f4e0f3896..5c50e4335f9 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Any - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -17,11 +15,6 @@ from .const import CONF_IMPORTED_BY from .coordinator import PingUpdateCoordinator from .entity import PingEntity -ATTR_ROUND_TRIP_TIME_AVG = "round_trip_time_avg" -ATTR_ROUND_TRIP_TIME_MAX = "round_trip_time_max" -ATTR_ROUND_TRIP_TIME_MDEV = "round_trip_time_mdev" -ATTR_ROUND_TRIP_TIME_MIN = "round_trip_time_min" - async def async_setup_entry( hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback @@ -53,13 +46,3 @@ class PingBinarySensor(PingEntity, BinarySensorEntity): def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.coordinator.data.is_alive - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the ICMP checo request.""" - return { - ATTR_ROUND_TRIP_TIME_AVG: self.coordinator.data.data.get("avg"), - ATTR_ROUND_TRIP_TIME_MAX: self.coordinator.data.data.get("max"), - ATTR_ROUND_TRIP_TIME_MDEV: self.coordinator.data.data.get("mdev"), - ATTR_ROUND_TRIP_TIME_MIN: self.coordinator.data.data.get("min"), - } diff --git a/tests/components/ping/snapshots/test_binary_sensor.ambr b/tests/components/ping/snapshots/test_binary_sensor.ambr index 24717938874..0196c2cbbfb 100644 --- a/tests/components/ping/snapshots/test_binary_sensor.ambr +++ b/tests/components/ping/snapshots/test_binary_sensor.ambr @@ -36,10 +36,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', 'friendly_name': '10.10.10.10', - 'round_trip_time_avg': 4.8, - 'round_trip_time_max': 10, - 'round_trip_time_mdev': None, - 'round_trip_time_min': 1, }), 'context': , 'entity_id': 'binary_sensor.10_10_10_10', @@ -54,10 +50,6 @@ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', 'friendly_name': '10.10.10.10', - 'round_trip_time_avg': None, - 'round_trip_time_max': None, - 'round_trip_time_mdev': None, - 'round_trip_time_min': None, }), 'context': , 'entity_id': 'binary_sensor.10_10_10_10', From 344e43a94a354b09a60a4b4e8d93ede09667dbd3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 11 Sep 2024 16:17:51 +0200 Subject: [PATCH 0552/1309] Remove commented out code from weatherflow cloud (#125759) --- .../components/weatherflow_cloud/conftest.py | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/tests/components/weatherflow_cloud/conftest.py b/tests/components/weatherflow_cloud/conftest.py index d83ee082b26..36b42bf24a8 100644 --- a/tests/components/weatherflow_cloud/conftest.py +++ b/tests/components/weatherflow_cloud/conftest.py @@ -113,39 +113,3 @@ def mock_api(): mock_api_class.return_value = mock_api yield mock_api - - -# -# @pytest.fixture -# def mock_api_with_lightning_error(): -# """Fixture for Mock WeatherFlowRestAPI.""" -# get_stations_response_data = StationsResponseREST.from_json( -# load_fixture("stations.json", DOMAIN) -# ) -# get_forecast_response_data = WeatherDataForecastREST.from_json( -# load_fixture("forecast.json", DOMAIN) -# ) -# get_observation_response_data = ObservationStationREST.from_json( -# load_fixture("station_observation_error.json", DOMAIN) -# ) -# -# data = { -# 24432: WeatherFlowDataREST( -# weather=get_forecast_response_data, -# observation=get_observation_response_data, -# station=get_stations_response_data.stations[0], -# device_observations=None, -# ) -# } -# -# with patch( -# "homeassistant.components.weatherflow_cloud.coordinator.WeatherFlowRestAPI", -# autospec=True, -# ) as mock_api_class: -# # Create an instance of AsyncMock for the API -# mock_api = AsyncMock() -# mock_api.get_all_data.return_value = data -# # Patch the class to return our mock_api instance -# mock_api_class.return_value = mock_api -# -# yield mock_api From bbdc036c3eb71d388376658d8f4a153ac9a88751 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 11 Sep 2024 16:27:07 +0200 Subject: [PATCH 0553/1309] Remove deprecated `ring.update` action (#125762) --- homeassistant/components/ring/__init__.py | 27 +------------------ homeassistant/components/ring/services.yaml | 1 - homeassistant/components/ring/strings.json | 17 ------------ tests/components/ring/test_camera.py | 19 -------------- tests/components/ring/test_init.py | 29 +-------------------- tests/components/ring/test_light.py | 19 -------------- tests/components/ring/test_switch.py | 28 +------------------- 7 files changed, 3 insertions(+), 137 deletions(-) delete mode 100644 homeassistant/components/ring/services.yaml diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 88c7467af91..2901a904dc4 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -11,14 +11,13 @@ from ring_doorbell import Auth, Ring, RingDevices from homeassistant.config_entries import ConfigEntry from homeassistant.const import APPLICATION_NAME, CONF_TOKEN -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( device_registry as dr, entity_registry as er, instance_id, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_LISTEN_CREDENTIALS, DOMAIN, PLATFORMS from .coordinator import RingDataCoordinator, RingListenCoordinator @@ -103,30 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool if hass.services.has_service(DOMAIN, "update"): return True - async def async_refresh_all(_: ServiceCall) -> None: - """Refresh all ring data.""" - _LOGGER.warning( - "Detected use of service 'ring.update'. " - "This is deprecated and will stop working in Home Assistant 2024.10. " - "Use 'homeassistant.update_entity' instead which updates all ring entities", - ) - async_create_issue( - hass, - DOMAIN, - "deprecated_service_ring_update", - breaks_in_ha_version="2024.10.0", - is_fixable=True, - is_persistent=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service_ring_update", - ) - for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN): - await loaded_entry.runtime_data.devices_coordinator.async_refresh() - - # register service - hass.services.async_register(DOMAIN, "update", async_refresh_all) - return True diff --git a/homeassistant/components/ring/services.yaml b/homeassistant/components/ring/services.yaml deleted file mode 100644 index 91b8669505b..00000000000 --- a/homeassistant/components/ring/services.yaml +++ /dev/null @@ -1 +0,0 @@ -update: diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 80598eab314..142b83ab51a 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -98,24 +98,7 @@ } } }, - "services": { - "update": { - "name": "Update", - "description": "Updates the data we have for all your ring devices." - } - }, "issues": { - "deprecated_service_ring_update": { - "title": "Detected use of deprecated action `ring.update`", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::ring::issues::deprecated_service_ring_update::title%]", - "description": "Use `homeassistant.update_entity` instead which will update all ring entities.\n\nPlease replace uses of this action and adjust your automations and scripts and select **submit** to close this issue." - } - } - } - }, "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/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index 619fb52846c..245c4ce6228 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -138,25 +138,6 @@ async def test_camera_motion_detection_not_supported( ) -async def test_updates_work( - hass: HomeAssistant, mock_ring_client, mock_ring_devices -) -> None: - """Tests the update service works correctly.""" - await setup_platform(hass, Platform.CAMERA) - state = hass.states.get("camera.internal") - assert state.attributes.get("motion_detection") is True - - internal_camera_mock = mock_ring_devices.get_device(345678) - internal_camera_mock.motion_detection = False - - await hass.services.async_call("ring", "update", {}, blocking=True) - - await hass.async_block_till_done() - - state = hass.states.get("camera.internal") - assert state.attributes.get("motion_detection") is not True - - @pytest.mark.parametrize( ("exception_type", "reauth_expected"), [ diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 10d183a22e9..5ac9e444cca 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -14,7 +14,7 @@ from homeassistant.components.ring.coordinator import RingEventListener from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from .device_mocks import FRONT_DOOR_DEVICE_ID @@ -233,33 +233,6 @@ async def test_error_on_device_update( assert hass.config_entries.async_get_entry(mock_config_entry.entry_id) -async def test_issue_deprecated_service_ring_update( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - caplog: pytest.LogCaptureFixture, - mock_ring_client, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the issue is raised on deprecated service ring.update.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - await hass.services.async_call(DOMAIN, "update", {}, blocking=True) - - issue = issue_registry.async_get_issue("ring", "deprecated_service_ring_update") - assert issue - assert issue.issue_domain == "ring" - assert issue.issue_id == "deprecated_service_ring_update" - assert issue.translation_key == "deprecated_service_ring_update" - - assert ( - "Detected use of service 'ring.update'. " - "This is deprecated and will stop working in Home Assistant 2024.10. " - "Use 'homeassistant.update_entity' instead which updates all ring entities" - ) in caplog.text - - @pytest.mark.parametrize( ("domain", "old_unique_id"), [ diff --git a/tests/components/ring/test_light.py b/tests/components/ring/test_light.py index 22ed4a31cf8..8ac47ac2f1d 100644 --- a/tests/components/ring/test_light.py +++ b/tests/components/ring/test_light.py @@ -65,25 +65,6 @@ async def test_light_can_be_turned_on(hass: HomeAssistant, mock_ring_client) -> assert state.state == "on" -async def test_updates_work( - hass: HomeAssistant, mock_ring_client, mock_ring_devices -) -> None: - """Tests the update service works correctly.""" - await setup_platform(hass, Platform.LIGHT) - state = hass.states.get("light.front_light") - assert state.state == "off" - - front_light_mock = mock_ring_devices.get_device(765432) - front_light_mock.lights = "on" - - await hass.services.async_call("ring", "update", {}, blocking=True) - - await hass.async_block_till_done() - - state = hass.states.get("light.front_light") - assert state.state == "on" - - @pytest.mark.parametrize( ("exception_type", "reauth_expected"), [ diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index f7aa885342a..300bc1d7b3f 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -4,11 +4,10 @@ import pytest import ring_doorbell from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component from .common import setup_platform @@ -66,31 +65,6 @@ async def test_siren_can_be_turned_on(hass: HomeAssistant, mock_ring_client) -> assert state.state == "on" -async def test_updates_work( - hass: HomeAssistant, mock_ring_client, mock_ring_devices -) -> None: - """Tests the update service works correctly.""" - await setup_platform(hass, Platform.SWITCH) - state = hass.states.get("switch.front_siren") - assert state.state == "off" - - front_siren_mock = mock_ring_devices.get_device(765432) - front_siren_mock.siren = 20 - - await async_setup_component(hass, "homeassistant", {}) - await hass.services.async_call( - "homeassistant", - "update_entity", - {ATTR_ENTITY_ID: ["switch.front_siren"]}, - blocking=True, - ) - - await hass.async_block_till_done() - - state = hass.states.get("switch.front_siren") - assert state.state == "on" - - @pytest.mark.parametrize( ("exception_type", "reauth_expected"), [ From 2ea8af83bd9c7af70f3e2d8229dfb94c55b428a8 Mon Sep 17 00:00:00 2001 From: jonnynch Date: Thu, 12 Sep 2024 00:33:26 +1000 Subject: [PATCH 0554/1309] Bump to python-nest-sdm to 5.0.1 (#125706) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 1b0697f7602..8453c51518d 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==5.0.0"] + "requirements": ["google-nest-sdm==5.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22fba3efe18..248275e6707 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1001,7 +1001,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.7.2 # homeassistant.components.nest -google-nest-sdm==5.0.0 +google-nest-sdm==5.0.1 # homeassistant.components.google_photos google-photos-library-api==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34b9892885e..186b2c50f23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -851,7 +851,7 @@ google-cloud-texttospeech==2.17.2 google-generativeai==0.7.2 # homeassistant.components.nest -google-nest-sdm==5.0.0 +google-nest-sdm==5.0.1 # homeassistant.components.google_photos google-photos-library-api==0.8.0 From e4347e552042faebac5635c2126df2b1a6b28984 Mon Sep 17 00:00:00 2001 From: Jeef Date: Wed, 11 Sep 2024 09:09:16 -0600 Subject: [PATCH 0555/1309] Add Monarch Money Integration (#124014) * Initial commit * Second commit - with some coverage but errors abount * Updated testing coverage * Should be just about ready for PR * Adding some error handling for wonky acocunts * Adding USD hardcoded as this is all that is currently supported i believe * updating snapshots * updating entity descrition a little * Addign cashflow in * adding aggregate sensors * tweak icons * refactor some type stuff as well as initialize the pr comment addressing process * remove empty fields from manifest * Update homeassistant/components/monarchmoney/sensor.py Co-authored-by: Joost Lekkerkerker * move stuff * get logging out of try block * get logging out of try block * using Subscription ID as stored in config entry for unique id soon * new unique id * giving cashflow a better unique id * Moving subscription id stuff into setup of coordinator * Update homeassistant/components/monarchmoney/config_flow.py Co-authored-by: Joost Lekkerkerker * ruff ruff * ruff ruff * split ot value and balance sensors... need to go tos leep * removed icons * Moved summary into a data class * efficenty increase * Update homeassistant/components/monarchmoney/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/monarchmoney/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/monarchmoney/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/monarchmoney/entity.py Co-authored-by: Joost Lekkerkerker * refactor continues * removed a comment * forgot to add a little bit of info * updated snapshot * Updates to monarch money using the new typed/wrapper setup * backing lib update * fixing manifest * fixing manifest * fixing manifest * Version 0.2.0 * fixing some types * more type fixes * cleanup and bump * no check * i think i got it all * the last thing * update domain name * i dont know what is in this commit * The Great Renaming * Moving to dict style accounting - as per request * updating backing deps * Update homeassistant/components/monarch_money/entity.py Co-authored-by: Joost Lekkerkerker * Update tests/components/monarch_money/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/monarch_money/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update tests/components/monarch_money/test_config_flow.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/monarch_money/sensor.py Co-authored-by: Joost Lekkerkerker * some changes * fixing capitalizaton * test test test * Adding dupe test * addressing pr stuff * forgot snapshot * Fix * Fix * Update homeassistant/components/monarch_money/sensor.py --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + .../components/monarch_money/__init__.py | 35 + .../components/monarch_money/config_flow.py | 157 +++ .../components/monarch_money/const.py | 10 + .../components/monarch_money/coordinator.py | 91 ++ .../components/monarch_money/entity.py | 83 ++ .../components/monarch_money/icons.json | 10 + .../components/monarch_money/manifest.json | 9 + .../components/monarch_money/sensor.py | 182 +++ .../components/monarch_money/strings.json | 46 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/monarch_money/__init__.py | 13 + tests/components/monarch_money/conftest.py | 79 ++ .../monarch_money/fixtures/get_accounts.json | 516 ++++++++ .../fixtures/get_cashflow_summary.json | 14 + .../fixtures/get_subscription_details.json | 10 + .../monarch_money/snapshots/test_sensor.ambr | 1112 +++++++++++++++++ .../monarch_money/test_config_flow.py | 166 +++ tests/components/monarch_money/test_sensor.py | 27 + 22 files changed, 2575 insertions(+) create mode 100644 homeassistant/components/monarch_money/__init__.py create mode 100644 homeassistant/components/monarch_money/config_flow.py create mode 100644 homeassistant/components/monarch_money/const.py create mode 100644 homeassistant/components/monarch_money/coordinator.py create mode 100644 homeassistant/components/monarch_money/entity.py create mode 100644 homeassistant/components/monarch_money/icons.json create mode 100644 homeassistant/components/monarch_money/manifest.json create mode 100644 homeassistant/components/monarch_money/sensor.py create mode 100644 homeassistant/components/monarch_money/strings.json create mode 100644 tests/components/monarch_money/__init__.py create mode 100644 tests/components/monarch_money/conftest.py create mode 100644 tests/components/monarch_money/fixtures/get_accounts.json create mode 100644 tests/components/monarch_money/fixtures/get_cashflow_summary.json create mode 100644 tests/components/monarch_money/fixtures/get_subscription_details.json create mode 100644 tests/components/monarch_money/snapshots/test_sensor.ambr create mode 100644 tests/components/monarch_money/test_config_flow.py create mode 100644 tests/components/monarch_money/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index fdb7069069d..2ce30a52e18 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -926,6 +926,8 @@ build.json @home-assistant/supervisor /tests/components/modern_forms/ @wonderslug /homeassistant/components/moehlenhoff_alpha2/ @j-a-n /tests/components/moehlenhoff_alpha2/ @j-a-n +/homeassistant/components/monarch_money/ @jeeftor +/tests/components/monarch_money/ @jeeftor /homeassistant/components/monoprice/ @etsinko @OnFreund /tests/components/monoprice/ @etsinko @OnFreund /homeassistant/components/monzo/ @jakemartin-icl diff --git a/homeassistant/components/monarch_money/__init__.py b/homeassistant/components/monarch_money/__init__.py new file mode 100644 index 00000000000..5f9aba7dd07 --- /dev/null +++ b/homeassistant/components/monarch_money/__init__.py @@ -0,0 +1,35 @@ +"""The Monarch Money integration.""" + +from __future__ import annotations + +from typedmonarchmoney import TypedMonarchMoney + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .coordinator import MonarchMoneyDataUpdateCoordinator + +type MonarchMoneyConfigEntry = ConfigEntry[MonarchMoneyDataUpdateCoordinator] + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: MonarchMoneyConfigEntry +) -> bool: + """Set up Monarch Money from a config entry.""" + monarch_client = TypedMonarchMoney(token=entry.data.get(CONF_TOKEN)) + + mm_coordinator = MonarchMoneyDataUpdateCoordinator(hass, monarch_client) + await mm_coordinator.async_config_entry_first_refresh() + entry.runtime_data = mm_coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: MonarchMoneyConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py new file mode 100644 index 00000000000..410630c7cd8 --- /dev/null +++ b/homeassistant/components/monarch_money/config_flow.py @@ -0,0 +1,157 @@ +"""Config flow for Monarch Money integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from monarchmoney import LoginFailedException, RequireMFAException +from monarchmoney.monarchmoney import SESSION_FILE +from typedmonarchmoney import TypedMonarchMoney +from typedmonarchmoney.models import MonarchSubscription +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_EMAIL, CONF_ID, CONF_PASSWORD, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_MFA_CODE, DOMAIN, LOGGER + +_LOGGER = logging.getLogger(__name__) + + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.EMAIL, + ), + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + ), + ), + } +) + +STEP_MFA_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_MFA_CODE): str, + } +) + + +async def validate_login( + hass: HomeAssistant, + data: dict[str, Any], + email: str | None = None, + password: str | None = None, +) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. Upon success a session will be saved + """ + + if not email: + email = data[CONF_EMAIL] + if not password: + password = data[CONF_PASSWORD] + monarch_client = TypedMonarchMoney() + if CONF_MFA_CODE in data: + mfa_code = data[CONF_MFA_CODE] + LOGGER.debug("Attempting to authenticate with MFA code") + try: + await monarch_client.multi_factor_authenticate(email, password, mfa_code) + except KeyError as err: + # A bug in the backing lib that I don't control throws a KeyError if the MFA code is wrong + LOGGER.debug("Bad MFA Code") + raise BadMFA from err + else: + LOGGER.debug("Attempting to authenticate") + try: + await monarch_client.login( + email=email, + password=password, + save_session=False, + use_saved_session=False, + ) + except RequireMFAException: + raise + except LoginFailedException as err: + raise InvalidAuth from err + + LOGGER.debug(f"Connection successful - saving session to file {SESSION_FILE}") + LOGGER.debug("Obtaining subscription id") + subs: MonarchSubscription = await monarch_client.get_subscription_details() + assert subs is not None + subscription_id = subs.id + return { + CONF_TOKEN: monarch_client.token, + CONF_ID: subscription_id, + } + + +class MonarchMoneyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Monarch Money.""" + + VERSION = 1 + + def __init__(self): + """Initialize config flow.""" + self.email: str | None = None + self.password: str | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + try: + info = await validate_login( + self.hass, user_input, email=self.email, password=self.password + ) + except RequireMFAException: + self.email = user_input[CONF_EMAIL] + self.password = user_input[CONF_PASSWORD] + + return self.async_show_form( + step_id="user", + data_schema=STEP_MFA_DATA_SCHEMA, + errors={"base": "mfa_required"}, + ) + except BadMFA: + return self.async_show_form( + step_id="user", + data_schema=STEP_MFA_DATA_SCHEMA, + errors={"base": "bad_mfa"}, + ) + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + await self.async_set_unique_id(info[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Monarch Money", + data={CONF_TOKEN: info[CONF_TOKEN]}, + ) + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class BadMFA(HomeAssistantError): + """Error to indicate the MFA code was bad.""" diff --git a/homeassistant/components/monarch_money/const.py b/homeassistant/components/monarch_money/const.py new file mode 100644 index 00000000000..f450f123179 --- /dev/null +++ b/homeassistant/components/monarch_money/const.py @@ -0,0 +1,10 @@ +"""Constants for the Monarch Money integration.""" + +import logging + +DOMAIN = "monarch_money" + +LOGGER = logging.getLogger(__package__) + +CONF_MFA_SECRET = "mfa_secret" +CONF_MFA_CODE = "mfa_code" diff --git a/homeassistant/components/monarch_money/coordinator.py b/homeassistant/components/monarch_money/coordinator.py new file mode 100644 index 00000000000..8eb15d448ec --- /dev/null +++ b/homeassistant/components/monarch_money/coordinator.py @@ -0,0 +1,91 @@ +"""Data coordinator for monarch money.""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta + +from aiohttp import ClientResponseError +from gql.transport.exceptions import TransportServerError +from monarchmoney import LoginFailedException +from typedmonarchmoney import TypedMonarchMoney +from typedmonarchmoney.models import ( + MonarchAccount, + MonarchCashflowSummary, + MonarchSubscription, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import LOGGER + + +@dataclass +class MonarchData: + """Data class to hold monarch data.""" + + account_data: dict[str, MonarchAccount] + cashflow_summary: MonarchCashflowSummary + + +class MonarchMoneyDataUpdateCoordinator(DataUpdateCoordinator[MonarchData]): + """Data update coordinator for Monarch Money.""" + + config_entry: ConfigEntry + subscription_id: str + + def __init__( + self, + hass: HomeAssistant, + client: TypedMonarchMoney, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name="monarchmoney", + update_interval=timedelta(hours=4), + ) + self.client = client + + async def _async_setup(self) -> None: + """Obtain subscription ID in setup phase.""" + try: + sub_details: MonarchSubscription = ( + await self.client.get_subscription_details() + ) + except (TransportServerError, LoginFailedException, ClientResponseError) as err: + raise ConfigEntryError("Authentication failed") from err + self.subscription_id = sub_details.id + + async def _async_update_data(self) -> MonarchData: + """Fetch data for all accounts.""" + + account_data, cashflow_summary = await asyncio.gather( + self.client.get_accounts_as_dict_with_id_key(), + self.client.get_cashflow_summary(), + ) + + return MonarchData(account_data=account_data, cashflow_summary=cashflow_summary) + + @property + def cashflow_summary(self) -> MonarchCashflowSummary: + """Return cashflow summary.""" + return self.data.cashflow_summary + + @property + def accounts(self) -> list[MonarchAccount]: + """Return accounts.""" + return list(self.data.account_data.values()) + + @property + def value_accounts(self) -> list[MonarchAccount]: + """Return value accounts.""" + return [x for x in self.accounts if x.is_value_account] + + @property + def balance_accounts(self) -> list[MonarchAccount]: + """Return accounts that aren't assets.""" + return [x for x in self.accounts if x.is_balance_account] diff --git a/homeassistant/components/monarch_money/entity.py b/homeassistant/components/monarch_money/entity.py new file mode 100644 index 00000000000..49a24385782 --- /dev/null +++ b/homeassistant/components/monarch_money/entity.py @@ -0,0 +1,83 @@ +"""Monarch money entity definition.""" + +from typedmonarchmoney.models import MonarchAccount, MonarchCashflowSummary + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MonarchMoneyDataUpdateCoordinator + + +class MonarchMoneyEntityBase(CoordinatorEntity[MonarchMoneyDataUpdateCoordinator]): + """Base entity for Monarch Money with entity name attribute.""" + + _attr_has_entity_name = True + + +class MonarchMoneyCashFlowEntity(MonarchMoneyEntityBase): + """Entity for Cashflow sensors.""" + + def __init__( + self, + coordinator: MonarchMoneyDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the Monarch Money Entity.""" + super().__init__(coordinator) + self._attr_unique_id = ( + f"{coordinator.subscription_id}_cashflow_{description.key}" + ) + self.entity_description = description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(coordinator.subscription_id))}, + name="Cashflow", + ) + + @property + def summary_data(self) -> MonarchCashflowSummary: + """Return cashflow summary data.""" + return self.coordinator.cashflow_summary + + +class MonarchMoneyAccountEntity(MonarchMoneyEntityBase): + """Entity for Account Sensors.""" + + def __init__( + self, + coordinator: MonarchMoneyDataUpdateCoordinator, + description: EntityDescription, + account: MonarchAccount, + ) -> None: + """Initialize the Monarch Money Entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._account_id = account.id + self._attr_attribution = ( + f"Data provided by Monarch Money API via {account.data_provider}" + ) + self._attr_unique_id = ( + f"{coordinator.subscription_id}_{account.id}_{description.translation_key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(account.id))}, + name=f"{account.institution_name} {account.name}", + entry_type=DeviceEntryType.SERVICE, + manufacturer=account.data_provider, + model=f"{account.institution_name} - {account.type_name} - {account.subtype_name}", + configuration_url=account.institution_url, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and ( + self._account_id in self.coordinator.data.account_data + ) + + @property + def account_data(self) -> MonarchAccount: + """Return the account data.""" + return self.coordinator.data.account_data[self._account_id] diff --git a/homeassistant/components/monarch_money/icons.json b/homeassistant/components/monarch_money/icons.json new file mode 100644 index 00000000000..95c5eb3cca4 --- /dev/null +++ b/homeassistant/components/monarch_money/icons.json @@ -0,0 +1,10 @@ +{ + "entity": { + "sensor": { + "sum_income": { "default": "mdi:cash-plus" }, + "sum_expense": { "default": "mdi:cash-minus" }, + "savings": { "default": "mdi:piggy-bank-outline" }, + "savings_rate": { "default": "mdi:cash-sync" } + } + } +} diff --git a/homeassistant/components/monarch_money/manifest.json b/homeassistant/components/monarch_money/manifest.json new file mode 100644 index 00000000000..ed28f825bcf --- /dev/null +++ b/homeassistant/components/monarch_money/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "monarch_money", + "name": "Monarch Money", + "codeowners": ["@jeeftor"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/monarchmoney", + "iot_class": "cloud_polling", + "requirements": ["typedmonarchmoney==0.3.1"] +} diff --git a/homeassistant/components/monarch_money/sensor.py b/homeassistant/components/monarch_money/sensor.py new file mode 100644 index 00000000000..fe7c728cf41 --- /dev/null +++ b/homeassistant/components/monarch_money/sensor.py @@ -0,0 +1,182 @@ +"""Sensor config - monarch money.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from typedmonarchmoney.models import MonarchAccount, MonarchCashflowSummary + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import MonarchMoneyConfigEntry +from .entity import MonarchMoneyAccountEntity, MonarchMoneyCashFlowEntity + + +@dataclass(frozen=True, kw_only=True) +class MonarchMoneyAccountSensorEntityDescription(SensorEntityDescription): + """Describe an account sensor entity.""" + + value_fn: Callable[[MonarchAccount], StateType | datetime] + picture_fn: Callable[[MonarchAccount], str | None] | None = None + + +@dataclass(frozen=True, kw_only=True) +class MonarchMoneyCashflowSensorEntityDescription(SensorEntityDescription): + """Describe a cashflow sensor entity.""" + + summary_fn: Callable[[MonarchCashflowSummary], StateType] + + +# These sensors include assets like a boat that might have value +MONARCH_MONEY_VALUE_SENSORS: tuple[MonarchMoneyAccountSensorEntityDescription, ...] = ( + MonarchMoneyAccountSensorEntityDescription( + key="value", + translation_key="value", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + value_fn=lambda account: account.balance, + picture_fn=lambda account: account.logo_url, + native_unit_of_measurement=CURRENCY_DOLLAR, + ), +) + +# Most accounts are balance sensors +MONARCH_MONEY_SENSORS: tuple[MonarchMoneyAccountSensorEntityDescription, ...] = ( + MonarchMoneyAccountSensorEntityDescription( + key="currentBalance", + translation_key="balance", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + value_fn=lambda account: account.balance, + picture_fn=lambda account: account.logo_url, + native_unit_of_measurement=CURRENCY_DOLLAR, + ), +) + +MONARCH_MONEY_AGE_SENSORS: tuple[MonarchMoneyAccountSensorEntityDescription, ...] = ( + MonarchMoneyAccountSensorEntityDescription( + key="age", + translation_key="age", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda account: account.last_update, + ), +) + +MONARCH_CASHFLOW_SENSORS: tuple[MonarchMoneyCashflowSensorEntityDescription, ...] = ( + MonarchMoneyCashflowSensorEntityDescription( + key="sum_income", + translation_key="sum_income", + summary_fn=lambda summary: summary.income, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=CURRENCY_DOLLAR, + ), + MonarchMoneyCashflowSensorEntityDescription( + key="sum_expense", + translation_key="sum_expense", + summary_fn=lambda summary: summary.expenses, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=CURRENCY_DOLLAR, + ), + MonarchMoneyCashflowSensorEntityDescription( + key="savings", + translation_key="savings", + summary_fn=lambda summary: summary.savings, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement=CURRENCY_DOLLAR, + ), + MonarchMoneyCashflowSensorEntityDescription( + key="savings_rate", + translation_key="savings_rate", + summary_fn=lambda summary: summary.savings_rate * 100, + suggested_display_precision=1, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MonarchMoneyConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Monarch Money sensors for config entries.""" + mm_coordinator = config_entry.runtime_data + + entity_list: list[MonarchMoneySensor | MonarchMoneyCashFlowSensor] = [ + MonarchMoneyCashFlowSensor( + mm_coordinator, + sensor_description, + ) + for sensor_description in MONARCH_CASHFLOW_SENSORS + ] + entity_list.extend( + MonarchMoneySensor( + mm_coordinator, + sensor_description, + account, + ) + for account in mm_coordinator.balance_accounts + for sensor_description in MONARCH_MONEY_SENSORS + ) + entity_list.extend( + MonarchMoneySensor( + mm_coordinator, + sensor_description, + account, + ) + for account in mm_coordinator.accounts + for sensor_description in MONARCH_MONEY_AGE_SENSORS + ) + entity_list.extend( + MonarchMoneySensor( + mm_coordinator, + sensor_description, + account, + ) + for account in mm_coordinator.value_accounts + for sensor_description in MONARCH_MONEY_VALUE_SENSORS + ) + + async_add_entities(entity_list) + + +class MonarchMoneyCashFlowSensor(MonarchMoneyCashFlowEntity, SensorEntity): + """Cashflow summary sensor.""" + + entity_description: MonarchMoneyCashflowSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state.""" + return self.entity_description.summary_fn(self.summary_data) + + +class MonarchMoneySensor(MonarchMoneyAccountEntity, SensorEntity): + """Define a monarch money sensor.""" + + entity_description: MonarchMoneyAccountSensorEntityDescription + + @property + def native_value(self) -> StateType | datetime: + """Return the state.""" + return self.entity_description.value_fn(self.account_data) + + @property + def entity_picture(self) -> str | None: + """Return the picture of the account as provided by monarch money if it exists.""" + if self.entity_description.picture_fn is not None: + return self.entity_description.picture_fn(self.account_data) + return None diff --git a/homeassistant/components/monarch_money/strings.json b/homeassistant/components/monarch_money/strings.json new file mode 100644 index 00000000000..d7a28940d7a --- /dev/null +++ b/homeassistant/components/monarch_money/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "step": { + "user": { + "description": "Enter your Monarch Money email and password, if required you will also be prompted for your MFA code.", + "data": { + "mfa_secret": "Add your MFA Secret. See docs for help.", + "mfa_code": "Enter your MFA code", + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "mfa_required": "Multi-factor authentication required.", + "bad_mfa": "Your code was invalid, please try again or use a recovery token." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "balance": { "name": "Balance" }, + "value": { "name": "Value" }, + + "age": { + "name": "Data age" + }, + + "sum_income": { + "name": "Income year to date" + }, + "sum_expense": { + "name": "Expense year to date" + }, + "savings": { + "name": "Savings year to date" + }, + "savings_rate": { + "name": "Savings rate" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a0fb9a48a17..b26519c6319 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -370,6 +370,7 @@ FLOWS = { "modem_callerid", "modern_forms", "moehlenhoff_alpha2", + "monarch_money", "monoprice", "monzo", "moon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 62e77d0edb1..8dde030a0d3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3802,6 +3802,12 @@ "config_flow": true, "iot_class": "local_push" }, + "monarch_money": { + "name": "Monarch Money", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "monessen": { "name": "Monessen", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index 248275e6707..36f35060907 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2856,6 +2856,9 @@ twilio==6.32.0 # homeassistant.components.twitch twitchAPI==4.2.1 +# homeassistant.components.monarch_money +typedmonarchmoney==0.3.1 + # homeassistant.components.ukraine_alarm uasiren==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 186b2c50f23..e7f356f88cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2263,6 +2263,9 @@ twilio==6.32.0 # homeassistant.components.twitch twitchAPI==4.2.1 +# homeassistant.components.monarch_money +typedmonarchmoney==0.3.1 + # homeassistant.components.ukraine_alarm uasiren==0.0.1 diff --git a/tests/components/monarch_money/__init__.py b/tests/components/monarch_money/__init__.py new file mode 100644 index 00000000000..f08addf2ec6 --- /dev/null +++ b/tests/components/monarch_money/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Monarch Money integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/monarch_money/conftest.py b/tests/components/monarch_money/conftest.py new file mode 100644 index 00000000000..7d6a965a009 --- /dev/null +++ b/tests/components/monarch_money/conftest.py @@ -0,0 +1,79 @@ +"""Common fixtures for the Monarch Money tests.""" + +from collections.abc import Generator +import json +from typing import Any +from unittest.mock import AsyncMock, PropertyMock, patch + +import pytest +from typedmonarchmoney.models import ( + MonarchAccount, + MonarchCashflowSummary, + MonarchSubscription, +) + +from homeassistant.components.monarch_money.const import DOMAIN +from homeassistant.const import CONF_TOKEN + +from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.monarch_money.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def mock_config_entry() -> MockConfigEntry: + """Fixture for mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_TOKEN: "fake_token_of_doom"}, + unique_id="222260252323873333", + version=1, + ) + + +@pytest.fixture +def mock_config_api() -> Generator[AsyncMock]: + """Mock the MonarchMoney class.""" + + account_json: dict[str, Any] = load_json_object_fixture("get_accounts.json", DOMAIN) + account_data = [MonarchAccount(data) for data in account_json["accounts"]] + account_data_dict: dict[str, MonarchAccount] = { + acc["id"]: MonarchAccount(acc) for acc in account_json["accounts"] + } + + cashflow_json: dict[str, Any] = json.loads( + load_fixture("get_cashflow_summary.json", DOMAIN) + ) + cashflow_summary = MonarchCashflowSummary(cashflow_json) + subscription_details = MonarchSubscription( + json.loads(load_fixture("get_subscription_details.json", DOMAIN)) + ) + + with ( + patch( + "homeassistant.components.monarch_money.config_flow.TypedMonarchMoney", + autospec=True, + ) as mock_class, + patch( + "homeassistant.components.monarch_money.TypedMonarchMoney", new=mock_class + ), + ): + instance = mock_class.return_value + type(instance).token = PropertyMock(return_value="mocked_token") + instance.login = AsyncMock(return_value=None) + instance.multi_factor_authenticate = AsyncMock(return_value=None) + instance.get_subscription_details = AsyncMock(return_value=subscription_details) + instance.get_accounts = AsyncMock(return_value=account_data) + instance.get_accounts_as_dict_with_id_key = AsyncMock( + return_value=account_data_dict + ) + instance.get_cashflow_summary = AsyncMock(return_value=cashflow_summary) + instance.get_subscription_details = AsyncMock(return_value=subscription_details) + yield mock_class diff --git a/tests/components/monarch_money/fixtures/get_accounts.json b/tests/components/monarch_money/fixtures/get_accounts.json new file mode 100644 index 00000000000..ddaecc1721b --- /dev/null +++ b/tests/components/monarch_money/fixtures/get_accounts.json @@ -0,0 +1,516 @@ +{ + "accounts": [ + { + "id": "900000000", + "displayName": "Brokerage", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": "0189", + "createdAt": "2021-10-15T01:32:33.809450+00:00", + "updatedAt": "2022-05-26T00:56:41.322045+00:00", + "displayLastUpdatedAt": "2022-05-26T00:56:41.321928+00:00", + "currentBalance": 1000.5, + "displayBalance": 1000.5, + "includeInNetWorth": true, + "hideFromList": true, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": false, + "includeInGoalBalance": false, + "dataProvider": "plaid", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 0, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 11, + "icon": "trending-up", + "logoUrl": "base64Nonce", + "type": { + "name": "brokerage", + "display": "Investments", + "__typename": "AccountType" + }, + "subtype": { + "name": "brokerage", + "display": "Brokerage", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "900000001", + "updateRequired": false, + "disconnectedFromDataProviderAt": null, + "dataProvider": "PLAID", + "institution": { + "id": "700000000", + "plaidInstitutionId": "ins_0", + "name": "Rando Brokerage", + "status": "DEGRADED", + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "700000000", + "name": "Rando Brokerage", + "logo": "base64Nonce", + "primaryColor": "#0075a3", + "url": "https://rando.brokerage/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "900000002", + "displayName": "Checking", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": "2602", + "createdAt": "2021-10-15T01:32:33.900521+00:00", + "updatedAt": "2024-02-17T11:21:05.228959+00:00", + "displayLastUpdatedAt": "2024-02-17T11:21:05.228721+00:00", + "currentBalance": 1000.02, + "displayBalance": 1000.02, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": true, + "dataProvider": "plaid", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 1403, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 0, + "icon": "dollar-sign", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "depository", + "display": "Cash", + "__typename": "AccountType" + }, + "subtype": { + "name": "checking", + "display": "Checking", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "900000003", + "updateRequired": false, + "disconnectedFromDataProviderAt": null, + "dataProvider": "PLAID", + "institution": { + "id": "7000000002", + "plaidInstitutionId": "ins_01", + "name": "Rando Bank", + "status": "DEGRADED", + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "7000000005", + "name": "Rando Bank", + "logo": "base64Nonce", + "primaryColor": "#0075a3", + "url": "https://rando.bank/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + + { + "id": "121212192626186051", + "displayName": "2050 Toyota RAV8", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": null, + "createdAt": "2024-08-16T17:37:21.885036+00:00", + "updatedAt": "2024-08-16T17:37:21.885057+00:00", + "displayLastUpdatedAt": "2024-08-16T17:37:21.885057+00:00", + "currentBalance": 11075.58, + "displayBalance": 11075.58, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": false, + "dataProvider": "vin_audit", + "dataProviderAccountId": "1111111v5cw252004", + "isManual": false, + "transactionsCount": 0, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 0, + "logoUrl": "https://api.monarchmoney.com/cdn-cgi/image/width=128/images/institution/159427559853802644", + "type": { + "name": "vehicle", + "display": "Vehicles", + "__typename": "AccountType" + }, + "subtype": { + "name": "car", + "display": "Car", + "__typename": "AccountSubtype" + }, + "credential": null, + "institution": { + "id": "123456789853802644", + "name": "VinAudit", + "primaryColor": "#74ab16", + "url": "https://www.vinaudit.com/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "9000000007", + "displayName": "Credit Card", + "syncDisabled": true, + "deactivatedAt": null, + "isHidden": true, + "isAsset": false, + "mask": "3542", + "createdAt": "2021-10-15T01:33:46.646459+00:00", + "updatedAt": "2022-12-10T18:17:06.129456+00:00", + "displayLastUpdatedAt": "2022-10-15T08:34:34.815239+00:00", + "currentBalance": -200.0, + "displayBalance": -200.0, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": false, + "includeInGoalBalance": true, + "dataProvider": "finicity", + "dataProviderAccountId": "50001", + "isManual": false, + "transactionsCount": 1138, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 1, + "icon": "credit-card", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "credit", + "display": "Credit Cards", + "__typename": "AccountType" + }, + "subtype": { + "name": "credit_card", + "display": "Credit Card", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "9000000009", + "updateRequired": true, + "disconnectedFromDataProviderAt": null, + "dataProvider": "FINICITY", + "institution": { + "id": "7000000002", + "plaidInstitutionId": "ins_9", + "name": "Rando Credit", + "status": null, + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "70000000010", + "name": "Rando Credit", + "logo": "base64Nonce", + "primaryColor": "#004966", + "url": "https://rando.credit/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "900000000012", + "displayName": "Roth IRA", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": "1052", + "createdAt": "2021-10-15T01:35:59.299450+00:00", + "updatedAt": "2024-02-17T13:32:21.072711+00:00", + "displayLastUpdatedAt": "2024-02-17T13:32:21.072453+00:00", + "currentBalance": 10000.43, + "displayBalance": 10000.43, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": false, + "dataProvider": "plaid", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 28, + "holdingsCount": 24, + "manualInvestmentsTrackingMethod": null, + "order": 4, + "icon": "trending-up", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "brokerage", + "display": "Investments", + "__typename": "AccountType" + }, + "subtype": { + "name": "roth", + "display": "Roth IRA", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "90000000014", + "updateRequired": false, + "disconnectedFromDataProviderAt": null, + "dataProvider": "PLAID", + "institution": { + "id": "70000000016", + "plaidInstitutionId": "ins_02", + "name": "Rando Investments", + "status": null, + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "70000000018", + "name": "Rando Investments", + "logo": "base64Nonce", + "primaryColor": "#40a829", + "url": "https://rando.investments/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "90000000020", + "displayName": "House", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": null, + "createdAt": "2021-10-15T01:39:29.370279+00:00", + "updatedAt": "2024-02-12T09:00:25.451425+00:00", + "displayLastUpdatedAt": "2024-02-12T09:00:25.451425+00:00", + "currentBalance": 123000.0, + "displayBalance": 123000.0, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": false, + "dataProvider": "zillow", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 0, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 2, + "icon": "home", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "real_estate", + "display": "Real Estate", + "__typename": "AccountType" + }, + "subtype": { + "name": "primary_home", + "display": "Primary Home", + "__typename": "AccountSubtype" + }, + "credential": null, + "institution": { + "id": "800000000", + "name": "Zillow", + "logo": "base64Nonce", + "primaryColor": "#006AFF", + "url": "https://www.zillow.com/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "90000000022", + "displayName": "401.k", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": null, + "createdAt": "2021-10-15T01:41:54.593239+00:00", + "updatedAt": "2024-02-17T08:13:10.554296+00:00", + "displayLastUpdatedAt": "2024-02-17T08:13:10.554029+00:00", + "currentBalance": 100000.35, + "displayBalance": 100000.35, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": false, + "dataProvider": "finicity", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 0, + "holdingsCount": 100, + "manualInvestmentsTrackingMethod": null, + "order": 3, + "icon": "trending-up", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "brokerage", + "display": "Investments", + "__typename": "AccountType" + }, + "subtype": { + "name": "st_401k", + "display": "401k", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "90000000024", + "updateRequired": false, + "disconnectedFromDataProviderAt": null, + "dataProvider": "FINICITY", + "institution": { + "id": "70000000026", + "plaidInstitutionId": "ins_03", + "name": "Rando Employer Investments", + "status": "HEALTHY", + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "70000000028", + "name": "Rando Employer Investments", + "logo": "base64Nonce", + "primaryColor": "#408800", + "url": "https://rando-employer.investments/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "90000000030", + "displayName": "Mortgage", + "syncDisabled": true, + "deactivatedAt": "2023-08-15", + "isHidden": true, + "isAsset": false, + "mask": "0973", + "createdAt": "2021-10-15T01:45:25.244570+00:00", + "updatedAt": "2023-08-16T01:41:36.115588+00:00", + "displayLastUpdatedAt": "2023-08-15T18:11:09.134874+00:00", + "currentBalance": 0.0, + "displayBalance": -0.0, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": false, + "includeInGoalBalance": false, + "dataProvider": "plaid", + "dataProviderAccountId": "testProviderAccountId", + "isManual": false, + "transactionsCount": 0, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 1, + "icon": "home", + "logoUrl": "data:image/png;base64,base64Nonce", + "type": { + "name": "loan", + "display": "Loans", + "__typename": "AccountType" + }, + "subtype": { + "name": "mortgage", + "display": "Mortgage", + "__typename": "AccountSubtype" + }, + "credential": { + "id": "90000000032", + "updateRequired": false, + "disconnectedFromDataProviderAt": null, + "dataProvider": "PLAID", + "institution": { + "id": "70000000034", + "plaidInstitutionId": "ins_04", + "name": "Rando Mortgage", + "status": "HEALTHY", + "logo": "base64Nonce", + "__typename": "Institution" + }, + "__typename": "Credential" + }, + "institution": { + "id": "70000000036", + "name": "Rando Mortgage", + "logo": "base64Nonce", + "primaryColor": "#095aa6", + "url": "https://rando.mortgage/", + "__typename": "Institution" + }, + "__typename": "Account" + }, + { + "id": "186321412999033223", + "displayName": "Wallet", + "syncDisabled": false, + "deactivatedAt": null, + "isHidden": false, + "isAsset": true, + "mask": null, + "createdAt": "2024-08-16T14:22:10.440514+00:00", + "updatedAt": "2024-08-16T14:22:10.512731+00:00", + "displayLastUpdatedAt": "2024-08-16T14:22:10.512731+00:00", + "currentBalance": 20.0, + "displayBalance": 20.0, + "includeInNetWorth": true, + "hideFromList": false, + "hideTransactionsFromReports": false, + "includeBalanceInNetWorth": true, + "includeInGoalBalance": true, + "dataProvider": "", + "dataProviderAccountId": null, + "isManual": true, + "transactionsCount": 0, + "holdingsCount": 0, + "manualInvestmentsTrackingMethod": null, + "order": 14, + "logoUrl": null, + "type": { + "name": "depository", + "display": "Cash", + "__typename": "AccountType" + }, + "subtype": { + "name": "prepaid", + "display": "Prepaid", + "__typename": "AccountSubtype" + }, + "credential": null, + "institution": null, + "__typename": "Account" + } + ], + "householdPreferences": { + "id": "900000000022", + "accountGroupOrder": [], + "__typename": "HouseholdPreferences" + } +} diff --git a/tests/components/monarch_money/fixtures/get_cashflow_summary.json b/tests/components/monarch_money/fixtures/get_cashflow_summary.json new file mode 100644 index 00000000000..a223782469a --- /dev/null +++ b/tests/components/monarch_money/fixtures/get_cashflow_summary.json @@ -0,0 +1,14 @@ +{ + "summary": [ + { + "summary": { + "sumIncome": 15000.0, + "sumExpense": -9000.0, + "savings": 6000.0, + "savingsRate": 0.4, + "__typename": "TransactionsSummary" + }, + "__typename": "AggregateData" + } + ] +} diff --git a/tests/components/monarch_money/fixtures/get_subscription_details.json b/tests/components/monarch_money/fixtures/get_subscription_details.json new file mode 100644 index 00000000000..16f90a2ca38 --- /dev/null +++ b/tests/components/monarch_money/fixtures/get_subscription_details.json @@ -0,0 +1,10 @@ +{ + "subscription": { + "id": "222260252323873333", + "paymentSource": "STRIPE", + "referralCode": "go3dpvrdmw", + "isOnFreeTrial": true, + "hasPremiumEntitlement": true, + "__typename": "HouseholdSubscription" + } +} diff --git a/tests/components/monarch_money/snapshots/test_sensor.ambr b/tests/components/monarch_money/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..cf7e0cb7b2f --- /dev/null +++ b/tests/components/monarch_money/snapshots/test_sensor.ambr @@ -0,0 +1,1112 @@ +# serializer version: 1 +# name: test_all_entities[sensor.cashflow_expense_year_to_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cashflow_expense_year_to_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Expense year to date', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sum_expense', + 'unique_id': '222260252323873333_cashflow_sum_expense', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.cashflow_expense_year_to_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Cashflow Expense year to date', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.cashflow_expense_year_to_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-9000.0', + }) +# --- +# name: test_all_entities[sensor.cashflow_income_year_to_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cashflow_income_year_to_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Income year to date', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sum_income', + 'unique_id': '222260252323873333_cashflow_sum_income', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.cashflow_income_year_to_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Cashflow Income year to date', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.cashflow_income_year_to_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15000.0', + }) +# --- +# name: test_all_entities[sensor.cashflow_savings_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cashflow_savings_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Savings rate', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'savings_rate', + 'unique_id': '222260252323873333_cashflow_savings_rate', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.cashflow_savings_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cashflow Savings rate', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.cashflow_savings_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities[sensor.cashflow_savings_year_to_date-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.cashflow_savings_year_to_date', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Savings year to date', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'savings', + 'unique_id': '222260252323873333_cashflow_savings', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.cashflow_savings_year_to_date-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'monetary', + 'friendly_name': 'Cashflow Savings year to date', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.cashflow_savings_year_to_date', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6000.0', + }) +# --- +# name: test_all_entities[sensor.manual_entry_wallet_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.manual_entry_wallet_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_186321412999033223_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.manual_entry_wallet_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'monetary', + 'friendly_name': 'Manual entry Wallet Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.manual_entry_wallet_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_all_entities[sensor.manual_entry_wallet_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.manual_entry_wallet_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_186321412999033223_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.manual_entry_wallet_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'timestamp', + 'friendly_name': 'Manual entry Wallet Data age', + }), + 'context': , + 'entity_id': 'sensor.manual_entry_wallet_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-08-16T14:22:10+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_bank_checking_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_bank_checking_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_900000002_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_bank_checking_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Rando Bank Checking Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_bank_checking_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000.02', + }) +# --- +# name: test_all_entities[sensor.rando_bank_checking_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_bank_checking_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_900000002_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_bank_checking_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Bank Checking Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_bank_checking_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-17T11:21:05+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_brokerage_brokerage_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_brokerage_brokerage_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_900000000_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_brokerage_brokerage_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'monetary', + 'entity_picture': 'base64Nonce', + 'friendly_name': 'Rando Brokerage Brokerage Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_brokerage_brokerage_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1000.5', + }) +# --- +# name: test_all_entities[sensor.rando_brokerage_brokerage_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_brokerage_brokerage_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_900000000_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_brokerage_brokerage_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Brokerage Brokerage Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_brokerage_brokerage_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-05-26T00:56:41+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_credit_credit_card_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_credit_credit_card_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_9000000007_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_credit_credit_card_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via FINICITY', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Rando Credit Credit Card Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_credit_credit_card_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-200.0', + }) +# --- +# name: test_all_entities[sensor.rando_credit_credit_card_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_credit_credit_card_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_9000000007_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_credit_credit_card_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via FINICITY', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Credit Credit Card Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_credit_credit_card_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-12-10T18:17:06+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_employer_investments_401_k_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_employer_investments_401_k_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_90000000022_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_employer_investments_401_k_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via FINICITY', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Rando Employer Investments 401.k Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_employer_investments_401_k_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100000.35', + }) +# --- +# name: test_all_entities[sensor.rando_employer_investments_401_k_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_employer_investments_401_k_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_90000000022_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_employer_investments_401_k_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via FINICITY', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Employer Investments 401.k Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_employer_investments_401_k_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-17T08:13:10+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_investments_roth_ira_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_investments_roth_ira_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_900000000012_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_investments_roth_ira_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Rando Investments Roth IRA Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_investments_roth_ira_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10000.43', + }) +# --- +# name: test_all_entities[sensor.rando_investments_roth_ira_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_investments_roth_ira_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_900000000012_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_investments_roth_ira_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Investments Roth IRA Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_investments_roth_ira_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-17T13:32:21+00:00', + }) +# --- +# name: test_all_entities[sensor.rando_mortgage_mortgage_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rando_mortgage_mortgage_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_90000000030_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.rando_mortgage_mortgage_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Rando Mortgage Mortgage Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.rando_mortgage_mortgage_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.rando_mortgage_mortgage_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.rando_mortgage_mortgage_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_90000000030_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.rando_mortgage_mortgage_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via PLAID', + 'device_class': 'timestamp', + 'friendly_name': 'Rando Mortgage Mortgage Data age', + }), + 'context': , + 'entity_id': 'sensor.rando_mortgage_mortgage_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-08-16T01:41:36+00:00', + }) +# --- +# name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.vinaudit_2050_toyota_rav8_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_121212192626186051_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'timestamp', + 'friendly_name': 'VinAudit 2050 Toyota RAV8 Data age', + }), + 'context': , + 'entity_id': 'sensor.vinaudit_2050_toyota_rav8_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-08-16T17:37:21+00:00', + }) +# --- +# name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_value-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vinaudit_2050_toyota_rav8_value', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Value', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'value', + 'unique_id': '222260252323873333_121212192626186051_value', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.vinaudit_2050_toyota_rav8_value-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'monetary', + 'entity_picture': 'https://api.monarchmoney.com/cdn-cgi/image/width=128/images/institution/159427559853802644', + 'friendly_name': 'VinAudit 2050 Toyota RAV8 Value', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.vinaudit_2050_toyota_rav8_value', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11075.58', + }) +# --- +# name: test_all_entities[sensor.zillow_house_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.zillow_house_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Balance', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '222260252323873333_90000000020_balance', + 'unit_of_measurement': '$', + }) +# --- +# name: test_all_entities[sensor.zillow_house_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'monetary', + 'entity_picture': 'data:image/png;base64,base64Nonce', + 'friendly_name': 'Zillow House Balance', + 'state_class': , + 'unit_of_measurement': '$', + }), + 'context': , + 'entity_id': 'sensor.zillow_house_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123000.0', + }) +# --- +# name: test_all_entities[sensor.zillow_house_data_age-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.zillow_house_data_age', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Data age', + 'platform': 'monarch_money', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'age', + 'unique_id': '222260252323873333_90000000020_age', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.zillow_house_data_age-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Monarch Money API via Manual entry', + 'device_class': 'timestamp', + 'friendly_name': 'Zillow House Data age', + }), + 'context': , + 'entity_id': 'sensor.zillow_house_data_age', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-12T09:00:25+00:00', + }) +# --- diff --git a/tests/components/monarch_money/test_config_flow.py b/tests/components/monarch_money/test_config_flow.py new file mode 100644 index 00000000000..03f0df0c526 --- /dev/null +++ b/tests/components/monarch_money/test_config_flow.py @@ -0,0 +1,166 @@ +"""Test the Monarch Money config flow.""" + +from unittest.mock import AsyncMock + +from monarchmoney import LoginFailedException, RequireMFAException + +from homeassistant.components.monarch_money.const import CONF_MFA_CODE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form_simple( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock +) -> None: + """Test simple case (no MFA / no errors).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Monarch Money" + assert result["data"] == { + CONF_TOKEN: "mocked_token", + } + assert result["result"].unique_id == "222260252323873333" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_add_duplicate_entry( + hass: HomeAssistant, + mock_config_entry, + mock_setup_entry: AsyncMock, + mock_config_api: AsyncMock, +) -> None: + """Test a duplicate error config flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock +) -> None: + """Test config flow with a login error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + # Change the login mock to raise an MFA required error + mock_config_api.return_value.login.side_effect = LoginFailedException( + "Invalid Auth" + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_config_api.return_value.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Monarch Money" + assert result["data"] == { + CONF_TOKEN: "mocked_token", + } + assert result["context"]["unique_id"] == "222260252323873333" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_mfa( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_config_api: AsyncMock +) -> None: + """Test MFA enabled on account configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + # Change the login mock to raise an MFA required error + mock_config_api.return_value.login.side_effect = RequireMFAException("mfa_required") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "mfa_required"} + assert result["step_id"] == "user" + + # Add a bad MFA Code response + mock_config_api.return_value.multi_factor_authenticate.side_effect = KeyError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_MFA_CODE: "123456", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "bad_mfa"} + assert result["step_id"] == "user" + + # Use a good MFA Code - Clear mock + mock_config_api.return_value.multi_factor_authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_MFA_CODE: "123456", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Monarch Money" + assert result["data"] == { + CONF_TOKEN: "mocked_token", + } + assert result["result"].unique_id == "222260252323873333" + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/monarch_money/test_sensor.py b/tests/components/monarch_money/test_sensor.py new file mode 100644 index 00000000000..aac1eaefb2d --- /dev/null +++ b/tests/components/monarch_money/test_sensor.py @@ -0,0 +1,27 @@ +"""Test sensors.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_config_api: AsyncMock, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.monarch_money.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From ba9dae10c39e92d5eb6a6fcaa2cb02d863052de2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:14:00 +0200 Subject: [PATCH 0556/1309] Simplify imports in mqtt (#125749) --- homeassistant/components/mqtt/discovery.py | 6 +++--- homeassistant/components/mqtt/trigger.py | 20 ++++++++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 8e379633674..7707b8e5f49 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -26,8 +26,8 @@ from homeassistant.loader import async_get_mqtt from homeassistant.util.json import json_loads_object from homeassistant.util.signal_type import SignalTypeFormat -from .. import mqtt from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS, ORIGIN_ABBREVIATIONS +from .client import async_subscribe_internal from .const import ( ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_PAYLOAD, @@ -341,7 +341,7 @@ async def async_start( # noqa: C901 ) mqtt_data.discovery_unsubscribe = [ - mqtt.async_subscribe_internal( + async_subscribe_internal( hass, topic, async_discovery_message_received, @@ -400,7 +400,7 @@ async def async_start( # noqa: C901 integration_unsubscribe.update( { - f"{integration}_{topic}": mqtt.async_subscribe_internal( + f"{integration}_{topic}": async_subscribe_internal( hass, topic, functools.partial(async_integration_message_received, integration), diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py index b901176cf88..da26f7f6839 100644 --- a/homeassistant/components/mqtt/trigger.py +++ b/homeassistant/components/mqtt/trigger.py @@ -24,8 +24,15 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerData, Trigge from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.util.json import json_loads -from .. import mqtt -from .const import CONF_ENCODING, CONF_QOS, CONF_TOPIC, DEFAULT_ENCODING, DEFAULT_QOS +from .client import async_subscribe_internal +from .const import ( + CONF_ENCODING, + CONF_QOS, + CONF_TOPIC, + DEFAULT_ENCODING, + DEFAULT_QOS, + DOMAIN, +) from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -33,11 +40,12 @@ from .models import ( PublishPayloadType, ReceiveMessage, ) +from .util import valid_subscribe_topic, valid_subscribe_topic_template TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_PLATFORM): mqtt.DOMAIN, - vol.Required(CONF_TOPIC): mqtt.util.valid_subscribe_topic_template, + vol.Required(CONF_PLATFORM): DOMAIN, + vol.Required(CONF_TOPIC): valid_subscribe_topic_template, vol.Optional(CONF_PAYLOAD): cv.template, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, @@ -76,7 +84,7 @@ async def async_attach_trigger( topic_template: Template = config[CONF_TOPIC] topic = topic_template.async_render(variables, limited=True, parse_result=False) - mqtt.util.valid_subscribe_topic(topic) + valid_subscribe_topic(topic) @callback def mqtt_automation_listener(mqttmsg: ReceiveMessage) -> None: @@ -104,7 +112,7 @@ async def async_attach_trigger( "Attaching MQTT trigger for topic: '%s', payload: '%s'", topic, wanted_payload ) - return mqtt.async_subscribe_internal( + return async_subscribe_internal( hass, topic, mqtt_automation_listener, From af5c63f80566d93275f36662e823d730bdf042a2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:14:31 +0200 Subject: [PATCH 0557/1309] Move overkiz cover definitions (#125757) --- .../components/overkiz/{cover.py => cover/__init__.py} | 10 +++++----- .../overkiz/{cover_entities => cover}/awning.py | 0 .../overkiz/{cover_entities => cover}/generic_cover.py | 0 .../{cover_entities => cover}/vertical_cover.py | 0 .../components/overkiz/cover_entities/__init__.py | 1 - 5 files changed, 5 insertions(+), 6 deletions(-) rename homeassistant/components/overkiz/{cover.py => cover/__init__.py} (83%) rename homeassistant/components/overkiz/{cover_entities => cover}/awning.py (100%) rename homeassistant/components/overkiz/{cover_entities => cover}/generic_cover.py (100%) rename homeassistant/components/overkiz/{cover_entities => cover}/vertical_cover.py (100%) delete mode 100644 homeassistant/components/overkiz/cover_entities/__init__.py diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover/__init__.py similarity index 83% rename from homeassistant/components/overkiz/cover.py rename to homeassistant/components/overkiz/cover/__init__.py index 51d2c9f2334..f9df3256253 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover/__init__.py @@ -7,11 +7,11 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeAssistantOverkizData -from .const import DOMAIN -from .cover_entities.awning import Awning -from .cover_entities.generic_cover import OverkizGenericCover -from .cover_entities.vertical_cover import LowSpeedCover, VerticalCover +from .. import HomeAssistantOverkizData +from ..const import DOMAIN +from .awning import Awning +from .generic_cover import OverkizGenericCover +from .vertical_cover import LowSpeedCover, VerticalCover async def async_setup_entry( diff --git a/homeassistant/components/overkiz/cover_entities/awning.py b/homeassistant/components/overkiz/cover/awning.py similarity index 100% rename from homeassistant/components/overkiz/cover_entities/awning.py rename to homeassistant/components/overkiz/cover/awning.py diff --git a/homeassistant/components/overkiz/cover_entities/generic_cover.py b/homeassistant/components/overkiz/cover/generic_cover.py similarity index 100% rename from homeassistant/components/overkiz/cover_entities/generic_cover.py rename to homeassistant/components/overkiz/cover/generic_cover.py diff --git a/homeassistant/components/overkiz/cover_entities/vertical_cover.py b/homeassistant/components/overkiz/cover/vertical_cover.py similarity index 100% rename from homeassistant/components/overkiz/cover_entities/vertical_cover.py rename to homeassistant/components/overkiz/cover/vertical_cover.py diff --git a/homeassistant/components/overkiz/cover_entities/__init__.py b/homeassistant/components/overkiz/cover_entities/__init__.py deleted file mode 100644 index 930202450d4..00000000000 --- a/homeassistant/components/overkiz/cover_entities/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Cover entities for the Overkiz (by Somfy) integration.""" From 315d59d615307d4b013a278ca1323f9241d85765 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:15:33 +0200 Subject: [PATCH 0558/1309] Move overkiz water heater definitions (#125756) --- .../overkiz/water_heater/__init__.py | 57 +++++++++++++++++++ ...stic_hot_water_production_mlb_component.py | 0 .../atlantic_pass_apc_dhw.py | 0 .../domestic_hot_water_production.py | 0 .../hitachi_dhw.py | 0 .../overkiz/water_heater_entities/__init__.py | 20 ------- 6 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/overkiz/water_heater/__init__.py rename homeassistant/components/overkiz/{water_heater_entities => water_heater}/atlantic_domestic_hot_water_production_mlb_component.py (100%) rename homeassistant/components/overkiz/{water_heater_entities => water_heater}/atlantic_pass_apc_dhw.py (100%) rename homeassistant/components/overkiz/{water_heater_entities => water_heater}/domestic_hot_water_production.py (100%) rename homeassistant/components/overkiz/{water_heater_entities => water_heater}/hitachi_dhw.py (100%) delete mode 100644 homeassistant/components/overkiz/water_heater_entities/__init__.py diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py new file mode 100644 index 00000000000..1fb5e5696bd --- /dev/null +++ b/homeassistant/components/overkiz/water_heater/__init__.py @@ -0,0 +1,57 @@ +"""Support for Overkiz water heater devices.""" + +from __future__ import annotations + +from pyoverkiz.enums.ui import UIWidget + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .. import HomeAssistantOverkizData +from ..const import DOMAIN +from ..entity import OverkizEntity +from .atlantic_domestic_hot_water_production_mlb_component import ( + AtlanticDomesticHotWaterProductionMBLComponent, +) +from .atlantic_pass_apc_dhw import AtlanticPassAPCDHW +from .domestic_hot_water_production import DomesticHotWaterProduction +from .hitachi_dhw import HitachiDHW + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Overkiz DHW from a config entry.""" + data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + entities: list[OverkizEntity] = [] + + for device in data.platforms[Platform.WATER_HEATER]: + if device.controllable_name in CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY: + entities.append( + CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY[device.controllable_name]( + device.device_url, data.coordinator + ) + ) + elif device.widget in WIDGET_TO_WATER_HEATER_ENTITY: + entities.append( + WIDGET_TO_WATER_HEATER_ENTITY[device.widget]( + device.device_url, data.coordinator + ) + ) + + async_add_entities(entities) + + +WIDGET_TO_WATER_HEATER_ENTITY = { + UIWidget.ATLANTIC_PASS_APC_DHW: AtlanticPassAPCDHW, + UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: DomesticHotWaterProduction, + UIWidget.HITACHI_DHW: HitachiDHW, +} + +CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY = { + "modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": AtlanticDomesticHotWaterProductionMBLComponent, +} diff --git a/homeassistant/components/overkiz/water_heater_entities/atlantic_domestic_hot_water_production_mlb_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py similarity index 100% rename from homeassistant/components/overkiz/water_heater_entities/atlantic_domestic_hot_water_production_mlb_component.py rename to homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_mlb_component.py diff --git a/homeassistant/components/overkiz/water_heater_entities/atlantic_pass_apc_dhw.py b/homeassistant/components/overkiz/water_heater/atlantic_pass_apc_dhw.py similarity index 100% rename from homeassistant/components/overkiz/water_heater_entities/atlantic_pass_apc_dhw.py rename to homeassistant/components/overkiz/water_heater/atlantic_pass_apc_dhw.py diff --git a/homeassistant/components/overkiz/water_heater_entities/domestic_hot_water_production.py b/homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py similarity index 100% rename from homeassistant/components/overkiz/water_heater_entities/domestic_hot_water_production.py rename to homeassistant/components/overkiz/water_heater/domestic_hot_water_production.py diff --git a/homeassistant/components/overkiz/water_heater_entities/hitachi_dhw.py b/homeassistant/components/overkiz/water_heater/hitachi_dhw.py similarity index 100% rename from homeassistant/components/overkiz/water_heater_entities/hitachi_dhw.py rename to homeassistant/components/overkiz/water_heater/hitachi_dhw.py diff --git a/homeassistant/components/overkiz/water_heater_entities/__init__.py b/homeassistant/components/overkiz/water_heater_entities/__init__.py deleted file mode 100644 index fdc41f213c6..00000000000 --- a/homeassistant/components/overkiz/water_heater_entities/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Water heater entities for the Overkiz (by Somfy) integration.""" - -from pyoverkiz.enums.ui import UIWidget - -from .atlantic_domestic_hot_water_production_mlb_component import ( - AtlanticDomesticHotWaterProductionMBLComponent, -) -from .atlantic_pass_apc_dhw import AtlanticPassAPCDHW -from .domestic_hot_water_production import DomesticHotWaterProduction -from .hitachi_dhw import HitachiDHW - -WIDGET_TO_WATER_HEATER_ENTITY = { - UIWidget.ATLANTIC_PASS_APC_DHW: AtlanticPassAPCDHW, - UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: DomesticHotWaterProduction, - UIWidget.HITACHI_DHW: HitachiDHW, -} - -CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY = { - "modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": AtlanticDomesticHotWaterProductionMBLComponent, -} From 393181df2034e9412319639becbeeb0dba5cdbb3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:15:58 +0200 Subject: [PATCH 0559/1309] Move overkiz climate definitions (#125741) --- homeassistant/components/overkiz/climate.py | 62 ------------------- .../{climate_entities => climate}/__init__.py | 55 ++++++++++++++++ .../atlantic_electrical_heater.py | 0 ...er_with_adjustable_temperature_setpoint.py | 0 .../atlantic_electrical_towel_dryer.py | 0 .../atlantic_heat_recovery_ventilation.py | 0 ...antic_pass_apc_heat_pump_main_component.py | 0 .../atlantic_pass_apc_heating_zone.py | 0 .../atlantic_pass_apc_zone_control.py | 0 .../atlantic_pass_apc_zone_control_zone.py | 0 .../hitachi_air_to_air_heat_pump_hlrrwifi.py | 0 .../hitachi_air_to_air_heat_pump_ovp.py | 0 .../somfy_heating_temperature_interface.py | 0 .../somfy_thermostat.py | 0 .../valve_heating_temperature_interface.py | 0 15 files changed, 55 insertions(+), 62 deletions(-) delete mode 100644 homeassistant/components/overkiz/climate.py rename homeassistant/components/overkiz/{climate_entities => climate}/__init__.py (59%) rename homeassistant/components/overkiz/{climate_entities => climate}/atlantic_electrical_heater.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/atlantic_electrical_towel_dryer.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/atlantic_heat_recovery_ventilation.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/atlantic_pass_apc_heat_pump_main_component.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/atlantic_pass_apc_heating_zone.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/atlantic_pass_apc_zone_control.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/atlantic_pass_apc_zone_control_zone.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/hitachi_air_to_air_heat_pump_hlrrwifi.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/hitachi_air_to_air_heat_pump_ovp.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/somfy_heating_temperature_interface.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/somfy_thermostat.py (100%) rename homeassistant/components/overkiz/{climate_entities => climate}/valve_heating_temperature_interface.py (100%) diff --git a/homeassistant/components/overkiz/climate.py b/homeassistant/components/overkiz/climate.py deleted file mode 100644 index 1663834abee..00000000000 --- a/homeassistant/components/overkiz/climate.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Support for Overkiz climate devices.""" - -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import HomeAssistantOverkizData -from .climate_entities import ( - WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY, - WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY, - WIDGET_TO_CLIMATE_ENTITY, -) -from .const import DOMAIN - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Overkiz climate from a config entry.""" - data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] - - # Match devices based on the widget. - entities_based_on_widget: list[Entity] = [ - WIDGET_TO_CLIMATE_ENTITY[device.widget](device.device_url, data.coordinator) - for device in data.platforms[Platform.CLIMATE] - if device.widget in WIDGET_TO_CLIMATE_ENTITY - ] - - # Match devices based on the widget and controllableName. - # ie Atlantic APC - entities_based_on_widget_and_controllable: list[Entity] = [ - WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget][ - device.controllable_name - ](device.device_url, data.coordinator) - for device in data.platforms[Platform.CLIMATE] - if device.widget in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY - and device.controllable_name - in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget] - ] - - # Match devices based on the widget and protocol. - # #ie Hitachi Air To Air Heat Pumps - entities_based_on_widget_and_protocol: list[Entity] = [ - WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol]( - device.device_url, data.coordinator - ) - for device in data.platforms[Platform.CLIMATE] - if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY - and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget] - ] - - async_add_entities( - entities_based_on_widget - + entities_based_on_widget_and_controllable - + entities_based_on_widget_and_protocol - ) diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate/__init__.py similarity index 59% rename from homeassistant/components/overkiz/climate_entities/__init__.py rename to homeassistant/components/overkiz/climate/__init__.py index ac864686432..f05a716031e 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate/__init__.py @@ -1,10 +1,20 @@ """Climate entities for the Overkiz (by Somfy) integration.""" +from __future__ import annotations + from enum import StrEnum, unique from pyoverkiz.enums import Protocol from pyoverkiz.enums.ui import UIWidget +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .. import HomeAssistantOverkizData +from ..const import DOMAIN from .atlantic_electrical_heater import AtlanticElectricalHeater from .atlantic_electrical_heater_with_adjustable_temperature_setpoint import ( AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint, @@ -65,3 +75,48 @@ WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = { Protocol.OVP: HitachiAirToAirHeatPumpOVP, }, } + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Overkiz climate from a config entry.""" + data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id] + + # Match devices based on the widget. + entities_based_on_widget: list[Entity] = [ + WIDGET_TO_CLIMATE_ENTITY[device.widget](device.device_url, data.coordinator) + for device in data.platforms[Platform.CLIMATE] + if device.widget in WIDGET_TO_CLIMATE_ENTITY + ] + + # Match devices based on the widget and controllableName. + # ie Atlantic APC + entities_based_on_widget_and_controllable: list[Entity] = [ + WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget][ + device.controllable_name + ](device.device_url, data.coordinator) + for device in data.platforms[Platform.CLIMATE] + if device.widget in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY + and device.controllable_name + in WIDGET_AND_CONTROLLABLE_TO_CLIMATE_ENTITY[device.widget] + ] + + # Match devices based on the widget and protocol. + # #ie Hitachi Air To Air Heat Pumps + entities_based_on_widget_and_protocol: list[Entity] = [ + WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget][device.protocol]( + device.device_url, data.coordinator + ) + for device in data.platforms[Platform.CLIMATE] + if device.widget in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY + and device.protocol in WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY[device.widget] + ] + + async_add_entities( + entities_based_on_widget + + entities_based_on_widget_and_controllable + + entities_based_on_widget_and_protocol + ) diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater.py rename to homeassistant/components/overkiz/climate/atlantic_electrical_heater.py diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py b/homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py rename to homeassistant/components/overkiz/climate/atlantic_electrical_heater_with_adjustable_temperature_setpoint.py diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py rename to homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py b/homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/atlantic_heat_recovery_ventilation.py rename to homeassistant/components/overkiz/climate/atlantic_heat_recovery_ventilation.py diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heat_pump_main_component.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heat_pump_main_component.py rename to homeassistant/components/overkiz/climate/atlantic_pass_apc_heat_pump_main_component.py diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_heating_zone.py rename to homeassistant/components/overkiz/climate/atlantic_pass_apc_heating_zone.py diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py rename to homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control.py diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py b/homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control_zone.py rename to homeassistant/components/overkiz/climate/atlantic_pass_apc_zone_control_zone.py diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_hlrrwifi.py rename to homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_hlrrwifi.py diff --git a/homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py b/homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/hitachi_air_to_air_heat_pump_ovp.py rename to homeassistant/components/overkiz/climate/hitachi_air_to_air_heat_pump_ovp.py diff --git a/homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/somfy_heating_temperature_interface.py rename to homeassistant/components/overkiz/climate/somfy_heating_temperature_interface.py diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate/somfy_thermostat.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/somfy_thermostat.py rename to homeassistant/components/overkiz/climate/somfy_thermostat.py diff --git a/homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py b/homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py similarity index 100% rename from homeassistant/components/overkiz/climate_entities/valve_heating_temperature_interface.py rename to homeassistant/components/overkiz/climate/valve_heating_temperature_interface.py From 0c1a605693f3330d38885edc9d9bc584c6cd78ed Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Wed, 11 Sep 2024 09:23:19 -0700 Subject: [PATCH 0560/1309] Add TotalConnect option to require alarm code (#122270) * add config option * use code_required option in alarm * test code_required options * only use code for disarm * change tests to disarm with code * remove unneeded code variable * Update homeassistant/components/totalconnect/alarm_control_panel.py Co-authored-by: Joost Lekkerkerker * use ServiceValidationError * translate ServiceValidationError * complete typing * Update tests/components/totalconnect/test_alarm_control_panel.py Co-authored-by: Joost Lekkerkerker * use ServiceValidationError in test * grab usercode from correct spot * use client code instead of unfilled location code * Revert "remove unneeded code variable" This reverts commit 220de0e698e5779fcd7c45bee999a60ad186ab7f. * remove unneeded code variable * improve usercode checking * use freezer * fix usercode test data * Update homeassistant/components/totalconnect/strings.json Co-authored-by: G Johansson * Update homeassistant/components/totalconnect/strings.json Co-authored-by: G Johansson * update test with new message --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: G Johansson --- .../totalconnect/alarm_control_panel.py | 46 +++++++++++++------ .../components/totalconnect/config_flow.py | 8 +++- .../components/totalconnect/const.py | 1 + .../components/totalconnect/strings.json | 9 +++- tests/components/totalconnect/common.py | 30 +++++++++--- .../totalconnect/test_alarm_control_panel.py | 45 +++++++++++++++++- .../totalconnect/test_config_flow.py | 5 +- 7 files changed, 115 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index edbbbb06e70..3c12e512dd6 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -9,6 +9,7 @@ from total_connect_client.location import TotalConnectLocation from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, + CodeFormat, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -22,11 +23,11 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import CODE_REQUIRED, DOMAIN from .coordinator import TotalConnectDataUpdateCoordinator from .entity import TotalConnectLocationEntity @@ -39,13 +40,10 @@ async def async_setup_entry( ) -> None: """Set up TotalConnect alarm panels based on a config entry.""" coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + code_required = entry.options.get(CODE_REQUIRED, False) async_add_entities( - TotalConnectAlarm( - coordinator, - location, - partition_id, - ) + TotalConnectAlarm(coordinator, location, partition_id, code_required) for location in coordinator.client.locations.values() for partition_id in location.partitions ) @@ -74,13 +72,13 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) - _attr_code_arm_required = False def __init__( self, coordinator: TotalConnectDataUpdateCoordinator, location: TotalConnectLocation, partition_id: int, + require_code: bool, ) -> None: """Initialize the TotalConnect status.""" super().__init__(coordinator, location) @@ -100,6 +98,10 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): self._attr_translation_placeholders = {"partition_id": str(partition_id)} self._attr_unique_id = f"{location.location_id}_{partition_id}" + self._attr_code_arm_required = require_code + if require_code: + self._attr_code_format = CodeFormat.NUMBER + @property def state(self) -> str | None: """Return the state of the device.""" @@ -150,6 +152,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" + self._check_usercode(code) try: await self.hass.async_add_executor_job(self._disarm) except UsercodeInvalid as error: @@ -163,12 +166,13 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) from error await self.coordinator.async_request_refresh() - def _disarm(self, code=None): + def _disarm(self) -> None: """Disarm synchronous.""" ArmingHelper(self._partition).disarm() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" + self._check_usercode(code) try: await self.hass.async_add_executor_job(self._arm_home) except UsercodeInvalid as error: @@ -182,12 +186,13 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) from error await self.coordinator.async_request_refresh() - def _arm_home(self): + def _arm_home(self) -> None: """Arm home synchronous.""" ArmingHelper(self._partition).arm_stay() async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" + self._check_usercode(code) try: await self.hass.async_add_executor_job(self._arm_away) except UsercodeInvalid as error: @@ -201,12 +206,13 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) from error await self.coordinator.async_request_refresh() - def _arm_away(self, code=None): + def _arm_away(self) -> None: """Arm away synchronous.""" ArmingHelper(self._partition).arm_away() async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" + self._check_usercode(code) try: await self.hass.async_add_executor_job(self._arm_night) except UsercodeInvalid as error: @@ -220,11 +226,11 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) from error await self.coordinator.async_request_refresh() - def _arm_night(self, code=None): + def _arm_night(self) -> None: """Arm night synchronous.""" ArmingHelper(self._partition).arm_stay_night() - async def async_alarm_arm_home_instant(self, code: str | None = None) -> None: + async def async_alarm_arm_home_instant(self) -> None: """Send arm home instant command.""" try: await self.hass.async_add_executor_job(self._arm_home_instant) @@ -243,7 +249,7 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): """Arm home instant synchronous.""" ArmingHelper(self._partition).arm_stay_instant() - async def async_alarm_arm_away_instant(self, code: str | None = None) -> None: + async def async_alarm_arm_away_instant(self) -> None: """Send arm away instant command.""" try: await self.hass.async_add_executor_job(self._arm_away_instant) @@ -258,6 +264,16 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): ) from error await self.coordinator.async_request_refresh() - def _arm_away_instant(self, code=None): + def _arm_away_instant(self): """Arm away instant synchronous.""" ArmingHelper(self._partition).arm_away_instant() + + def _check_usercode(self, code): + """Check if the run-time entered code matches configured code.""" + if ( + self._attr_code_arm_required + and self.coordinator.client.usercodes[self._location.location_id] != code + ): + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="invalid_pin" + ) diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index 2a4c4d421a1..c64dd5c6120 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_LOCATION, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers.typing import VolDictType -from .const import AUTO_BYPASS, CONF_USERCODES, DOMAIN +from .const import AUTO_BYPASS, CODE_REQUIRED, CONF_USERCODES, DOMAIN PASSWORD_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) @@ -217,7 +217,11 @@ class TotalConnectOptionsFlowHandler(OptionsFlow): vol.Required( AUTO_BYPASS, default=self.config_entry.options.get(AUTO_BYPASS, False), - ): bool + ): bool, + vol.Required( + CODE_REQUIRED, + default=self.config_entry.options.get(CODE_REQUIRED, False), + ): bool, } ), ) diff --git a/homeassistant/components/totalconnect/const.py b/homeassistant/components/totalconnect/const.py index 1e98adaaa70..005d21a9376 100644 --- a/homeassistant/components/totalconnect/const.py +++ b/homeassistant/components/totalconnect/const.py @@ -3,6 +3,7 @@ DOMAIN = "totalconnect" CONF_USERCODES = "usercodes" AUTO_BYPASS = "auto_bypass_low_battery" +CODE_REQUIRED = "code_required" # Most TotalConnect alarms will work passing '-1' as usercode DEFAULT_USERCODE = "-1" diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index faa136137db..c040ae9936e 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -33,9 +33,9 @@ "step": { "init": { "title": "TotalConnect Options", - "description": "Automatically bypass zones the moment they report a low battery.", "data": { - "auto_bypass_low_battery": "Auto bypass low battery" + "auto_bypass_low_battery": "Auto bypass low battery", + "code_required": "Require user to enter code for alarm actions" } } } @@ -76,5 +76,10 @@ "name": "Bypass" } } + }, + "exceptions": { + "invalid_pin": { + "message": "Incorrect code entered" + } } } diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 4cfbabb2d7d..828cad71e07 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -1,11 +1,17 @@ """Common methods used across tests for TotalConnect.""" +from typing import Any from unittest.mock import patch from total_connect_client import ArmingState, ResultCode, ZoneStatus, ZoneType -from homeassistant.components.totalconnect.const import CONF_USERCODES, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.components.totalconnect.const import ( + AUTO_BYPASS, + CODE_REQUIRED, + CONF_USERCODES, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -341,7 +347,7 @@ RESPONSE_ZONE_BYPASS_FAILURE = { USERNAME = "username@me.com" PASSWORD = "password" -USERCODES = {123456: "7890"} +USERCODES = {LOCATION_ID: "7890"} CONFIG_DATA = { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, @@ -349,6 +355,9 @@ CONFIG_DATA = { } CONFIG_DATA_NO_USERCODES = {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} +OPTIONS_DATA = {AUTO_BYPASS: False, CODE_REQUIRED: False} +OPTIONS_DATA_CODE_REQUIRED = {AUTO_BYPASS: False, CODE_REQUIRED: True} + PARTITION_DETAILS_1 = { "PartitionID": 1, "ArmingState": ArmingState.DISARMED.value, @@ -395,10 +404,19 @@ TOTALCONNECT_REQUEST = ( ) -async def setup_platform(hass: HomeAssistant, platform: Platform) -> MockConfigEntry: +async def setup_platform( + hass: HomeAssistant, platform: Any, code_required: bool = False +) -> MockConfigEntry: """Set up the TotalConnect platform.""" # first set up a config entry and add it to hass - mock_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_DATA) + if code_required: + mock_entry = MockConfigEntry( + domain=DOMAIN, data=CONFIG_DATA, options=OPTIONS_DATA_CODE_REQUIRED + ) + else: + mock_entry = MockConfigEntry( + domain=DOMAIN, data=CONFIG_DATA, options=OPTIONS_DATA + ) mock_entry.add_to_hass(hass) responses = [ @@ -426,7 +444,7 @@ async def setup_platform(hass: HomeAssistant, platform: Platform) -> MockConfigE async def init_integration(hass: HomeAssistant) -> MockConfigEntry: """Set up the TotalConnect integration.""" # first set up a config entry and add it to hass - mock_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_DATA) + mock_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_DATA, options=OPTIONS_DATA) mock_entry.add_to_hass(hass) responses = [ diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index a4f8333e8a8..ed89f0b00cd 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion from total_connect_client.exceptions import ( @@ -36,12 +37,13 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import dt as dt_util from .common import ( + LOCATION_ID, RESPONSE_ARM_FAILURE, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_AWAY, @@ -60,6 +62,7 @@ from .common import ( RESPONSE_UNKNOWN, RESPONSE_USER_CODE_INVALID, TOTALCONNECT_REQUEST, + USERCODES, setup_platform, ) @@ -132,7 +135,7 @@ async def test_arm_home_failure(hass: HomeAssistant) -> None: assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 - # usercode is invalid + # config entry usercode is invalid with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True @@ -369,6 +372,44 @@ async def test_disarm_failure(hass: HomeAssistant) -> None: assert mock_request.call_count == 3 +async def test_disarm_code_required( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test disarm with code.""" + responses = [RESPONSE_ARMED_AWAY, RESPONSE_DISARM_SUCCESS, RESPONSE_DISARMED] + await setup_platform(hass, ALARM_DOMAIN, code_required=True) + with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + await async_update_entity(hass, ENTITY_ID) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY + assert mock_request.call_count == 1 + + # runtime user entered code is bad + DATA_WITH_CODE = DATA.copy() + DATA_WITH_CODE["code"] = "666" + with pytest.raises(ServiceValidationError, match="Incorrect code entered"): + await hass.services.async_call( + ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA_WITH_CODE, blocking=True + ) + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY + # code check means the call to total_connect never happens + assert mock_request.call_count == 1 + + # runtime user entered code that is in config + DATA_WITH_CODE["code"] = USERCODES[LOCATION_ID] + await hass.services.async_call( + ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA_WITH_CODE, blocking=True + ) + await hass.async_block_till_done() + assert mock_request.call_count == 2 + + freezer.tick(DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert mock_request.call_count == 3 + assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED + + async def test_arm_night_success(hass: HomeAssistant) -> None: """Test arm night method success.""" responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_NIGHT] diff --git a/tests/components/totalconnect/test_config_flow.py b/tests/components/totalconnect/test_config_flow.py index a0be52afb3b..86419bff817 100644 --- a/tests/components/totalconnect/test_config_flow.py +++ b/tests/components/totalconnect/test_config_flow.py @@ -6,6 +6,7 @@ from total_connect_client.exceptions import AuthenticationError from homeassistant.components.totalconnect.const import ( AUTO_BYPASS, + CODE_REQUIRED, CONF_USERCODES, DOMAIN, ) @@ -238,11 +239,11 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={AUTO_BYPASS: True} + result["flow_id"], user_input={AUTO_BYPASS: True, CODE_REQUIRED: False} ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options == {AUTO_BYPASS: True} + assert config_entry.options == {AUTO_BYPASS: True, CODE_REQUIRED: False} await hass.async_block_till_done() assert await hass.config_entries.async_unload(config_entry.entry_id) From 420bdedcb5ef0cb9110690e2cb0cc2a41e88e685 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:38:06 +0200 Subject: [PATCH 0561/1309] Small improvements to linkplay from reviews (#125766) Small improvements --- homeassistant/components/linkplay/const.py | 2 +- homeassistant/components/linkplay/media_player.py | 5 ++--- homeassistant/components/linkplay/utils.py | 8 ++++---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/linkplay/const.py b/homeassistant/components/linkplay/const.py index 91a427d5eb8..f531e311f46 100644 --- a/homeassistant/components/linkplay/const.py +++ b/homeassistant/components/linkplay/const.py @@ -4,4 +4,4 @@ from homeassistant.const import Platform DOMAIN = "linkplay" PLATFORMS = [Platform.MEDIA_PLAYER] -CONF_SESSION = "session" +DATA_SESSION = "session" diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 0e29a7f27d0..02341f99970 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -177,9 +177,8 @@ class LinkPlayMediaPlayerEntity(MediaPlayerEntity): ] manufacturer, model = get_info_from_project(bridge.device.properties["project"]) - if model == MANUFACTURER_GENERIC: - model_id = None - else: + model_id = None + if model != MANUFACTURER_GENERIC: model_id = bridge.device.properties["project"] self._attr_device_info = dr.DeviceInfo( diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 7f15e297145..36a492f8464 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -8,7 +8,7 @@ from linkplay.utils import async_create_unverified_client_session from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.core import Event, HomeAssistant, callback -from .const import CONF_SESSION, DOMAIN +from .const import DATA_SESSION, DOMAIN MANUFACTURER_ARTSOUND: Final[str] = "ArtSound" MANUFACTURER_ARYLIC: Final[str] = "Arylic" @@ -57,7 +57,7 @@ def get_info_from_project(project: str) -> tuple[str, str]: async def async_get_client_session(hass: HomeAssistant) -> ClientSession: """Get a ClientSession that can be used with LinkPlay devices.""" hass.data.setdefault(DOMAIN, {}) - if CONF_SESSION not in hass.data[DOMAIN]: + if DATA_SESSION not in hass.data[DOMAIN]: clientsession: ClientSession = await async_create_unverified_client_session() @callback @@ -66,8 +66,8 @@ async def async_get_client_session(hass: HomeAssistant) -> ClientSession: clientsession.detach() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_websession) - hass.data[DOMAIN][CONF_SESSION] = clientsession + hass.data[DOMAIN][DATA_SESSION] = clientsession return clientsession - session: ClientSession = hass.data[DOMAIN][CONF_SESSION] + session: ClientSession = hass.data[DOMAIN][DATA_SESSION] return session From f52f60307b36ea3a4036072de7b52e558b1a0432 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Wed, 11 Sep 2024 20:33:00 +0300 Subject: [PATCH 0562/1309] Implement time triggers with offset for timestamp sensors (#120858) * Implement time triggers with offset for timestamp sensors * Fix bad change * Add testcase for multiple conf_at with offsets * Fix fixture rename * Fix testcase - if no offset provided, it should be just the string of the entity id * Get test to pass * Simplify code * Update the messaging and make the offset optional allowing specifying only the entity_id * Move state tracking one level up * Implement requesteed changes --- .../components/homeassistant/triggers/time.py | 72 +++++++-- .../homeassistant/triggers/test_time.py | 140 ++++++++++++++++-- 2 files changed, 184 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 5441683b86f..443d9c65d95 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -1,7 +1,9 @@ """Offer time listening automation rules.""" -from datetime import datetime +from collections.abc import Callable +from datetime import datetime, timedelta from functools import partial +from typing import NamedTuple import voluptuous as vol @@ -9,6 +11,8 @@ from homeassistant.components import sensor from homeassistant.const import ( ATTR_DEVICE_CLASS, CONF_AT, + CONF_ENTITY_ID, + CONF_OFFSET, CONF_PLATFORM, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -32,11 +36,22 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util +_TIME_TRIGGER_ENTITY = vol.All(str, cv.entity_domain(["input_datetime", "sensor"])) + +_TIME_TRIGGER_ENTITY_WITH_OFFSET = vol.Schema( + { + vol.Required(CONF_ENTITY_ID): cv.entity_domain(["sensor"]), + vol.Optional(CONF_OFFSET): cv.time_period, + } +) + _TIME_TRIGGER_SCHEMA = vol.Any( cv.time, - vol.All(str, cv.entity_domain(["input_datetime", "sensor"])), + _TIME_TRIGGER_ENTITY, + _TIME_TRIGGER_ENTITY_WITH_OFFSET, msg=( - "Expected HH:MM, HH:MM:SS or Entity ID with domain 'input_datetime' or 'sensor'" + "Expected HH:MM, HH:MM:SS, an Entity ID with domain 'input_datetime' or " + "'sensor', or a combination of a timestamp sensor entity and an offset." ), ) @@ -48,6 +63,13 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( ) +class TrackEntity(NamedTuple): + """Represents a tracking entity for a time trigger.""" + + entity_id: str + callback: Callable + + async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, @@ -56,7 +78,7 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" trigger_data = trigger_info["trigger_data"] - entities: dict[str, CALLBACK_TYPE] = {} + entities: dict[tuple[str, timedelta], CALLBACK_TYPE] = {} removes: list[CALLBACK_TYPE] = [] job = HassJob(action, f"time trigger {trigger_info}") @@ -79,15 +101,21 @@ async def async_attach_trigger( ) @callback - def update_entity_trigger_event(event: Event[EventStateChangedData]) -> None: + def update_entity_trigger_event( + event: Event[EventStateChangedData], offset: timedelta = timedelta(0) + ) -> None: """update_entity_trigger from the event.""" - return update_entity_trigger(event.data["entity_id"], event.data["new_state"]) + return update_entity_trigger( + event.data["entity_id"], event.data["new_state"], offset + ) @callback - def update_entity_trigger(entity_id: str, new_state: State | None = None) -> None: + def update_entity_trigger( + entity_id: str, new_state: State | None = None, offset: timedelta = timedelta(0) + ) -> None: """Update the entity trigger for the entity_id.""" # If a listener was already set up for entity, remove it. - if remove := entities.pop(entity_id, None): + if remove := entities.pop((entity_id, offset), None): remove() remove = None @@ -153,6 +181,9 @@ async def async_attach_trigger( ): trigger_dt = dt_util.parse_datetime(new_state.state) + if trigger_dt is not None: + trigger_dt += offset + if trigger_dt is not None and trigger_dt > dt_util.utcnow(): remove = async_track_point_in_time( hass, @@ -166,15 +197,27 @@ async def async_attach_trigger( # Was a listener set up? if remove: - entities[entity_id] = remove + entities[(entity_id, offset)] = remove - to_track: list[str] = [] + to_track: list[TrackEntity] = [] for at_time in config[CONF_AT]: if isinstance(at_time, str): # entity - to_track.append(at_time) update_entity_trigger(at_time, new_state=hass.states.get(at_time)) + to_track.append(TrackEntity(at_time, update_entity_trigger_event)) + elif isinstance(at_time, dict) and CONF_OFFSET in at_time: + # entity with offset + entity_id: str = at_time.get(CONF_ENTITY_ID, "") + offset: timedelta = at_time.get(CONF_OFFSET, timedelta(0)) + update_entity_trigger( + entity_id, new_state=hass.states.get(entity_id), offset=offset + ) + to_track.append( + TrackEntity( + entity_id, partial(update_entity_trigger_event, offset=offset) + ) + ) else: # datetime.time removes.append( @@ -187,9 +230,10 @@ async def async_attach_trigger( ) ) - # Track state changes of any entities. - removes.append( - async_track_state_change_event(hass, to_track, update_entity_trigger_event) + # Besides time, we also track state changes of requested entities. + removes.extend( + (async_track_state_change_event(hass, entry.entity_id, entry.callback)) + for entry in to_track ) @callback diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 76d80120fdd..5455b06d1c0 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -156,17 +156,40 @@ async def test_if_fires_using_at_input_datetime( ) +@pytest.mark.parametrize( + ("conf_at", "trigger_deltas"), + [ + (["5:00:00", "6:00:00"], [timedelta(0), timedelta(hours=1)]), + ( + [ + "5:00:05", + {"entity_id": "sensor.next_alarm", "offset": "00:00:10"}, + "sensor.next_alarm", + ], + [timedelta(seconds=5), timedelta(seconds=10), timedelta(0)], + ), + ], +) async def test_if_fires_using_multiple_at( hass: HomeAssistant, freezer: FrozenDateTimeFactory, service_calls: list[ServiceCall], + conf_at: list[str | dict[str, int | str]], + trigger_deltas: list[timedelta], ) -> None: - """Test for firing at.""" + """Test for firing at multiple trigger times.""" now = dt_util.now() - trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) - time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) + start_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) + + hass.states.async_set( + "sensor.next_alarm", + start_dt.isoformat(), + {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + ) + + time_that_will_not_match_right_away = start_dt - timedelta(minutes=1) freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) assert await async_setup_component( @@ -174,7 +197,7 @@ async def test_if_fires_using_multiple_at( automation.DOMAIN, { automation.DOMAIN: { - "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, + "trigger": {"platform": "time", "at": conf_at}, "action": { "service": "test.automation", "data_template": { @@ -186,17 +209,14 @@ async def test_if_fires_using_multiple_at( ) await hass.async_block_till_done() - async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) - await hass.async_block_till_done() + for count, delta in enumerate(sorted(trigger_deltas)): + async_fire_time_changed(hass, start_dt + delta + timedelta(seconds=1)) + await hass.async_block_till_done() - assert len(service_calls) == 1 - assert service_calls[0].data["some"] == "time - 5" - - async_fire_time_changed(hass, trigger_dt + timedelta(hours=1, seconds=1)) - await hass.async_block_till_done() - - assert len(service_calls) == 2 - assert service_calls[1].data["some"] == "time - 6" + assert len(service_calls) == count + 1 + assert ( + service_calls[count].data["some"] == f"time - {5 + (delta.seconds // 3600)}" + ) async def test_if_not_fires_using_wrong_at( @@ -518,12 +538,99 @@ async def test_if_fires_using_at_sensor( assert len(service_calls) == 2 +@pytest.mark.parametrize( + ("offset", "delta"), + [ + ("00:00:10", timedelta(seconds=10)), + ("-00:00:10", timedelta(seconds=-10)), + ({"minutes": 5}, timedelta(minutes=5)), + ], +) +async def test_if_fires_using_at_sensor_with_offset( + hass: HomeAssistant, + service_calls: list[ServiceCall], + freezer: FrozenDateTimeFactory, + offset: str | dict[str, int], + delta: timedelta, +) -> None: + """Test for firing at sensor time.""" + now = dt_util.now() + + start_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) + trigger_dt = start_dt + delta + + hass.states.async_set( + "sensor.next_alarm", + start_dt.isoformat(), + {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + ) + + time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) + + some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{ trigger.now.minute }}-{{ trigger.now.second }}-{{trigger.entity_id}}" + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": { + "entity_id": "sensor.next_alarm", + "offset": offset, + }, + }, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert ( + service_calls[0].data["some"] + == f"time-{trigger_dt.day}-{trigger_dt.hour}-{trigger_dt.minute}-{trigger_dt.second}-sensor.next_alarm" + ) + + start_dt += timedelta(days=1, hours=1) + trigger_dt += timedelta(days=1, hours=1) + + hass.states.async_set( + "sensor.next_alarm", + start_dt.isoformat(), + {ATTR_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP}, + ) + await hass.async_block_till_done() + + async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(service_calls) == 2 + assert ( + service_calls[1].data["some"] + == f"time-{trigger_dt.day}-{trigger_dt.hour}-{trigger_dt.minute}-{trigger_dt.second}-sensor.next_alarm" + ) + + @pytest.mark.parametrize( "conf", [ {"platform": "time", "at": "input_datetime.bla"}, {"platform": "time", "at": "sensor.bla"}, {"platform": "time", "at": "12:34"}, + {"platform": "time", "at": {"entity_id": "sensor.bla", "offset": "-00:01"}}, + { + "platform": "time", + "at": [{"entity_id": "sensor.bla", "offset": "-01:00:00"}], + }, ], ) def test_schema_valid(conf) -> None: @@ -537,6 +644,11 @@ def test_schema_valid(conf) -> None: {"platform": "time", "at": "binary_sensor.bla"}, {"platform": "time", "at": 745}, {"platform": "time", "at": "25:00"}, + { + "platform": "time", + "at": {"entity_id": "input_datetime.bla", "offset": "0:10"}, + }, + {"platform": "time", "at": {"entity_id": "13:00:00", "offset": "0:10"}}, ], ) def test_schema_invalid(conf) -> None: From 66f9e06c251df0e791752ab1f73b181fb11857cd Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 11 Sep 2024 19:39:54 +0200 Subject: [PATCH 0563/1309] Reload enphase_envoy integration upon envoy firmware change detection (#124650) * Reload enphase_envoy integration upon envoy firmware change detection. * remove persistant notification --- .../components/enphase_envoy/coordinator.py | 21 ++++++++++++ tests/components/enphase_envoy/test_sensor.py | 34 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index e91e245658c..00bc7666f78 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -24,6 +24,7 @@ SCAN_INTERVAL = timedelta(seconds=60) TOKEN_REFRESH_CHECK_INTERVAL = timedelta(days=1) STALE_TOKEN_THRESHOLD = timedelta(days=30).total_seconds() +NOTIFICATION_ID = "enphase_envoy_notification" _LOGGER = logging.getLogger(__name__) @@ -35,6 +36,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """DataUpdateCoordinator to gather data from any envoy.""" envoy_serial_number: str + envoy_firmware: str def __init__( self, hass: HomeAssistant, envoy: Envoy, entry: EnphaseConfigEntry @@ -46,6 +48,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.username = entry_data[CONF_USERNAME] self.password = entry_data[CONF_PASSWORD] self._setup_complete = False + self.envoy_firmware = "" self._cancel_token_refresh: CALLBACK_TYPE | None = None super().__init__( hass, @@ -158,6 +161,24 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise ConfigEntryAuthFailed from err except EnvoyError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + # if we have a firmware version from previous setup, compare to current one + # when envoy gets new firmware there will be an authentication failure + # which results in getting fw version again, if so reload the integration. + if (current_firmware := self.envoy_firmware) and current_firmware != ( + new_firmware := envoy.firmware + ): + _LOGGER.warning( + "Envoy firmware changed from: %s to: %s, reloading enphase envoy integration", + current_firmware, + new_firmware, + ) + # reload the integration to get all established again + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry.entry_id) + ) + # remember firmware version for next time + self.envoy_firmware = envoy.firmware _LOGGER.debug("Envoy data: %s", envoy_data) return envoy_data.raw diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index 3156f154729..784dfe54073 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -1,6 +1,7 @@ """Test Enphase Envoy sensors.""" from itertools import chain +import logging from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory @@ -1002,3 +1003,36 @@ async def test_sensor_missing_data( # test the original inverter is now unknown assert (entity_state := hass.states.get("sensor.inverter_1")) assert entity_state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy_metered_batt_relay", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_fw_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test enphase_envoy sensor update over fw update.""" + logging.getLogger("homeassistant.components.enphase_envoy").setLevel(logging.DEBUG) + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + # force HA to detect changed data by changing raw + mock_envoy.firmware = "0.0.0" + + # Move time to next update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "firmware changed from: " in caplog.text + assert "to: 0.0.0, reloading enphase envoy integration" in caplog.text From 75d3ea34fcd3365be1691f3449e11d9dc315f6d4 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Wed, 11 Sep 2024 18:46:26 +0100 Subject: [PATCH 0564/1309] Add test snapshots to ring switch and siren platforms (#125771) --- tests/components/ring/common.py | 7 +- .../components/ring/snapshots/test_siren.ambr | 58 +++++++++++ .../ring/snapshots/test_switch.ambr | 95 +++++++++++++++++++ tests/components/ring/test_siren.py | 21 +++- tests/components/ring/test_switch.py | 21 +++- 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 tests/components/ring/snapshots/test_siren.ambr create mode 100644 tests/components/ring/snapshots/test_switch.ambr diff --git a/tests/components/ring/common.py b/tests/components/ring/common.py index 71274fe1ee1..22fa1c2bf32 100644 --- a/tests/components/ring/common.py +++ b/tests/components/ring/common.py @@ -13,9 +13,10 @@ from tests.common import MockConfigEntry async def setup_platform(hass: HomeAssistant, platform: Platform) -> None: """Set up the ring platform and prerequisites.""" - MockConfigEntry(domain=DOMAIN, data={"username": "foo", "token": {}}).add_to_hass( - hass - ) + if not hass.config_entries.async_has_entries(DOMAIN): + MockConfigEntry( + domain=DOMAIN, data={"username": "foo", "token": {}} + ).add_to_hass(hass) with patch("homeassistant.components.ring.PLATFORMS", [platform]): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/ring/snapshots/test_siren.ambr b/tests/components/ring/snapshots/test_siren.ambr new file mode 100644 index 00000000000..14fdf63db7b --- /dev/null +++ b/tests/components/ring/snapshots/test_siren.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_states[siren.downstairs_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_tones': list([ + 'ding', + 'motion', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.downstairs_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'siren', + 'unique_id': '123456-siren', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[siren.downstairs_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'available_tones': list([ + 'ding', + 'motion', + ]), + 'friendly_name': 'Downstairs Siren', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.downstairs_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ring/snapshots/test_switch.ambr b/tests/components/ring/snapshots/test_switch.ambr new file mode 100644 index 00000000000..2d56cf3ad13 --- /dev/null +++ b/tests/components/ring/snapshots/test_switch.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_states[switch.front_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.front_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'siren', + 'unique_id': '765432-siren', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.front_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Siren', + }), + 'context': , + 'entity_id': 'switch.front_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_states[switch.internal_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.internal_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'siren', + 'unique_id': '345678-siren', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.internal_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Internal Siren', + }), + 'context': , + 'entity_id': 'switch.internal_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ring/test_siren.py b/tests/components/ring/test_siren.py index e71dd1e6e77..6ab1ef0bdf1 100644 --- a/tests/components/ring/test_siren.py +++ b/tests/components/ring/test_siren.py @@ -1,7 +1,10 @@ """The tests for the Ring button platform.""" +from unittest.mock import Mock + import pytest import ring_doorbell +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import Platform @@ -9,7 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from .common import MockConfigEntry, setup_platform + +from tests.common import snapshot_platform async def test_entity_registry( @@ -24,6 +29,20 @@ async def test_entity_registry( assert entry.unique_id == "123456-siren" +async def test_states( + hass: HomeAssistant, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test states.""" + + mock_config_entry.add_to_hass(hass) + await setup_platform(hass, Platform.SIREN) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + async def test_sirens_report_correctly(hass: HomeAssistant, mock_ring_client) -> None: """Tests that the initial state of a device that should be on is correct.""" await setup_platform(hass, Platform.SIREN) diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index 300bc1d7b3f..7b10ea0f23d 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -1,7 +1,10 @@ """The tests for the Ring switch platform.""" +from unittest.mock import Mock + import pytest import ring_doorbell +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import Platform @@ -9,7 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from .common import MockConfigEntry, setup_platform + +from tests.common import snapshot_platform async def test_entity_registry( @@ -27,6 +32,20 @@ async def test_entity_registry( assert entry.unique_id == "345678-siren" +async def test_states( + hass: HomeAssistant, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test states.""" + + mock_config_entry.add_to_hass(hass) + await setup_platform(hass, Platform.SWITCH) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + async def test_siren_off_reports_correctly( hass: HomeAssistant, mock_ring_client ) -> None: From d7cf05e693f5f2d6588a15460ba99350c67c59b2 Mon Sep 17 00:00:00 2001 From: Andy Castille Date: Wed, 11 Sep 2024 11:11:06 -0700 Subject: [PATCH 0565/1309] Allow attaching additional data to schedule helper blocks (#116585) * Add a new optional "data" key when defining time ranges for the schedule component that exposes the provided data in the state attributes of the schedule entity when that time range is active * Exclude all schedule entry custom data attributes from the recorder (with tests) * Fix setting schedule attributes to exclude from recorder, update test to verify the attributes exist but are not recorded * Fix test to ensure schedule data attributes are not recorded * Use vol.Any in place of vol.Or Co-authored-by: Erik Montnemery * Remove schedule block custom data shorthand as requested in https://github.com/home-assistant/core/pull/116585#pullrequestreview-2280260436 * Update homeassistant/components/schedule/__init__.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/schedule/__init__.py | 44 +++++++++++++++++-- homeassistant/components/schedule/const.py | 1 + tests/components/schedule/test_init.py | 33 +++++++++++--- tests/components/schedule/test_recorder.py | 35 +++++++++++++-- 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 08d0b083f7c..24ce4f3b3fa 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -39,6 +39,7 @@ from homeassistant.util import dt as dt_util from .const import ( ATTR_NEXT_EVENT, CONF_ALL_DAYS, + CONF_DATA, CONF_FROM, CONF_TO, DOMAIN, @@ -55,7 +56,7 @@ def valid_schedule(schedule: list[dict[str, str]]) -> list[dict[str, str]]: Ensure they have no overlap and the end time is greater than the start time. """ - # Emtpty schedule is valid + # Empty schedule is valid if not schedule: return schedule @@ -109,9 +110,13 @@ BASE_SCHEMA: VolDictType = { vol.Optional(CONF_ICON): cv.icon, } +# Extra data that the user can set on each time range +CUSTOM_DATA_SCHEMA = vol.Schema({str: vol.Any(bool, str, int, float)}) + TIME_RANGE_SCHEMA: VolDictType = { vol.Required(CONF_FROM): cv.time, vol.Required(CONF_TO): deserialize_to_time, + vol.Optional(CONF_DATA): CUSTOM_DATA_SCHEMA, } # Serialize time in validated config @@ -119,6 +124,7 @@ STORAGE_TIME_RANGE_SCHEMA = vol.Schema( { vol.Required(CONF_FROM): vol.Coerce(str), vol.Required(CONF_TO): serialize_to_time, + vol.Optional(CONF_DATA): CUSTOM_DATA_SCHEMA, } ) @@ -135,7 +141,6 @@ STORAGE_SCHEDULE_SCHEMA: VolDictType = { for day in CONF_ALL_DAYS } - # Validate YAML config CONFIG_SCHEMA = vol.Schema( {DOMAIN: cv.schema_with_slug_keys(vol.All(BASE_SCHEMA | SCHEDULE_SCHEMA))}, @@ -152,7 +157,7 @@ ENTITY_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up an input select.""" + """Set up a schedule.""" component = EntityComponent[Schedule](LOGGER, DOMAIN, hass) id_manager = IDManager() @@ -253,6 +258,12 @@ class Schedule(CollectionEntity): self._attr_name = self._config[CONF_NAME] self._attr_unique_id = self._config[CONF_ID] + # Exclude any custom attributes that may be present on time ranges from recording. + self._unrecorded_attributes = self.all_custom_data_keys() + self._Entity__combined_unrecorded_attributes = ( + self._entity_component_unrecorded_attributes | self._unrecorded_attributes + ) + @classmethod def from_storage(cls, config: ConfigType) -> Schedule: """Return entity instance initialized from storage.""" @@ -300,9 +311,11 @@ class Schedule(CollectionEntity): # Note that any time in the day is treated as smaller than time.max. if now.time() < time_range[CONF_TO] or time_range[CONF_TO] == time.max: self._attr_state = STATE_ON + current_data = time_range.get(CONF_DATA) break else: self._attr_state = STATE_OFF + current_data = None # Find next event in the schedule, loop over each day (starting with # the current day) until the next event has been found. @@ -344,6 +357,11 @@ class Schedule(CollectionEntity): self._attr_extra_state_attributes = { ATTR_NEXT_EVENT: next_event, } + + if current_data: + # Add each key/value pair in the data to the entity's state attributes + self._attr_extra_state_attributes.update(current_data) + self.async_write_ha_state() if next_event: @@ -352,3 +370,23 @@ class Schedule(CollectionEntity): self._update, next_event, ) + + def all_custom_data_keys(self) -> frozenset[str]: + """Return the set of all currently used custom data attribute keys.""" + data_keys = set() + + for weekday in WEEKDAY_TO_CONF.values(): + if not (weekday_config := self._config.get(weekday)): + continue # this weekday is not configured + + for time_range in weekday_config: + time_range_custom_data = time_range.get(CONF_DATA) + + if not time_range_custom_data or not isinstance( + time_range_custom_data, dict + ): + continue # this time range has no custom data, or it is not a dict + + data_keys.update(time_range_custom_data.keys()) + + return frozenset(data_keys) diff --git a/homeassistant/components/schedule/const.py b/homeassistant/components/schedule/const.py index 5ec57aae78d..6687dafefdb 100644 --- a/homeassistant/components/schedule/const.py +++ b/homeassistant/components/schedule/const.py @@ -6,6 +6,7 @@ from typing import Final DOMAIN: Final = "schedule" LOGGER = logging.getLogger(__package__) +CONF_DATA: Final = "data" CONF_FRIDAY: Final = "friday" CONF_FROM: Final = "from" CONF_MONDAY: Final = "monday" diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index 7cd59f19033..18346122bfd 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -12,6 +12,7 @@ import pytest from homeassistant.components.schedule import STORAGE_VERSION, STORAGE_VERSION_MINOR from homeassistant.components.schedule.const import ( ATTR_NEXT_EVENT, + CONF_DATA, CONF_FRIDAY, CONF_FROM, CONF_MONDAY, @@ -66,13 +67,21 @@ def schedule_setup( CONF_NAME: "from storage", CONF_ICON: "mdi:party-popper", CONF_FRIDAY: [ - {CONF_FROM: "17:00:00", CONF_TO: "23:59:59"}, + { + CONF_FROM: "17:00:00", + CONF_TO: "23:59:59", + CONF_DATA: {"party_level": "epic"}, + }, ], CONF_SATURDAY: [ {CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}, ], CONF_SUNDAY: [ - {CONF_FROM: "00:00:00", CONF_TO: "24:00:00"}, + { + CONF_FROM: "00:00:00", + CONF_TO: "24:00:00", + CONF_DATA: {"entry": "VIPs only"}, + }, ], } ] @@ -95,9 +104,21 @@ def schedule_setup( CONF_TUESDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}], CONF_WEDNESDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}], CONF_THURSDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}], - CONF_FRIDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}], + CONF_FRIDAY: [ + { + CONF_FROM: "00:00:00", + CONF_TO: "23:59:59", + CONF_DATA: {"party_level": "epic"}, + } + ], CONF_SATURDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}], - CONF_SUNDAY: [{CONF_FROM: "00:00:00", CONF_TO: "23:59:59"}], + CONF_SUNDAY: [ + { + CONF_FROM: "00:00:00", + CONF_TO: "23:59:59", + CONF_DATA: {"entry": "VIPs only"}, + } + ], } } } @@ -557,13 +578,13 @@ async def test_ws_list( assert len(result) == 1 assert result["from_storage"][ATTR_NAME] == "from storage" assert result["from_storage"][CONF_FRIDAY] == [ - {CONF_FROM: "17:00:00", CONF_TO: "23:59:59"} + {CONF_FROM: "17:00:00", CONF_TO: "23:59:59", CONF_DATA: {"party_level": "epic"}} ] assert result["from_storage"][CONF_SATURDAY] == [ {CONF_FROM: "00:00:00", CONF_TO: "23:59:59"} ] assert result["from_storage"][CONF_SUNDAY] == [ - {CONF_FROM: "00:00:00", CONF_TO: "24:00:00"} + {CONF_FROM: "00:00:00", CONF_TO: "24:00:00", CONF_DATA: {"entry": "VIPs only"}} ] assert "from_yaml" not in result diff --git a/tests/components/schedule/test_recorder.py b/tests/components/schedule/test_recorder.py index a7410472a44..85aef3e1990 100644 --- a/tests/components/schedule/test_recorder.py +++ b/tests/components/schedule/test_recorder.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.recorder.history import get_significant_states @@ -18,8 +19,11 @@ from tests.components.recorder.common import async_wait_recording_done @pytest.mark.usefixtures("recorder_mock", "enable_custom_integrations") -async def test_exclude_attributes(hass: HomeAssistant) -> None: +async def test_exclude_attributes( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test attributes to be excluded.""" + freezer.move_to("2024-08-02 06:30:00-07:00") # Before Friday event now = dt_util.utcnow() assert await async_setup_component( hass, @@ -33,9 +37,13 @@ async def test_exclude_attributes(hass: HomeAssistant) -> None: "tuesday": [{"from": "2:00", "to": "3:00"}], "wednesday": [{"from": "3:00", "to": "4:00"}], "thursday": [{"from": "5:00", "to": "6:00"}], - "friday": [{"from": "7:00", "to": "8:00"}], + "friday": [ + {"from": "7:00", "to": "8:00", "data": {"party_level": "epic"}} + ], "saturday": [{"from": "9:00", "to": "10:00"}], - "sunday": [{"from": "11:00", "to": "12:00"}], + "sunday": [ + {"from": "11:00", "to": "12:00", "data": {"entry": "VIPs only"}} + ], } } }, @@ -48,8 +56,25 @@ async def test_exclude_attributes(hass: HomeAssistant) -> None: assert state.attributes[ATTR_ICON] assert state.attributes[ATTR_NEXT_EVENT] + # Move to during Friday event + freezer.move_to("2024-08-02 07:30:00-07:00") + async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + state = hass.states.get("schedule.test") + assert "entry" not in state.attributes + assert state.attributes["party_level"] == "epic" + + # Move to during Sunday event + freezer.move_to("2024-08-04 11:30:00-07:00") + async_fire_time_changed(hass, fire_all=True) + await hass.async_block_till_done() + state = hass.states.get("schedule.test") + assert "party_level" not in state.attributes + assert state.attributes["entry"] == "VIPs only" + + await hass.async_block_till_done() + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() await async_wait_recording_done(hass) @@ -63,3 +88,5 @@ async def test_exclude_attributes(hass: HomeAssistant) -> None: assert ATTR_FRIENDLY_NAME in state.attributes assert ATTR_ICON in state.attributes assert ATTR_NEXT_EVENT not in state.attributes + assert "entry" not in state.attributes + assert "party_level" not in state.attributes From 98728d37a6a69260bfa0998b00c1c9e9a5a0d29d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 11 Sep 2024 20:50:48 +0200 Subject: [PATCH 0566/1309] Bump jaraco.abode to 6.2.0 (#125512) * Bump jaraco.abode to 6.2.0 * Bump jaraco.abode to 6.2.0 --- homeassistant/components/abode/binary_sensor.py | 2 +- homeassistant/components/abode/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index 0f1372dc8be..ca9679a5aaa 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import cast -from jaraco.abode.devices.sensor import BinarySensor +from jaraco.abode.devices.binary_sensor import BinarySensor from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index 225edea40ca..be705238932 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==5.2.1"] + "requirements": ["jaraco.abode==6.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 36f35060907..7a2c9269c26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1209,7 +1209,7 @@ ismartgate==5.0.1 israel-rail-api==0.1.2 # homeassistant.components.abode -jaraco.abode==5.2.1 +jaraco.abode==6.2.0 # homeassistant.components.jellyfin jellyfin-apiclient-python==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e7f356f88cb..2481c6865d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ ismartgate==5.0.1 israel-rail-api==0.1.2 # homeassistant.components.abode -jaraco.abode==5.2.1 +jaraco.abode==6.2.0 # homeassistant.components.jellyfin jellyfin-apiclient-python==1.9.2 From 610e9239a4397febf8bf192225a2d23629ff6c0a Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Wed, 11 Sep 2024 16:06:03 -0400 Subject: [PATCH 0567/1309] Add media player test to Cambridge Audio (#125780) * Add media player tests to Cambridge Audio * Add media player tests to Cambridge Audio * Remove unnecessary test case * Move state_update call out of mock * Update tests/components/cambridge_audio/test_media_player.py --------- Co-authored-by: Joost Lekkerkerker --- tests/components/cambridge_audio/conftest.py | 17 +- tests/components/cambridge_audio/const.py | 6 + .../fixtures/get_now_playing.json | 25 +++ .../fixtures/get_play_state.json | 22 ++ .../cambridge_audio/fixtures/get_sources.json | 113 ++++++++++ .../cambridge_audio/fixtures/get_state.json | 7 + .../cambridge_audio/test_media_player.py | 193 ++++++++++++++++++ 7 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 tests/components/cambridge_audio/const.py create mode 100644 tests/components/cambridge_audio/fixtures/get_now_playing.json create mode 100644 tests/components/cambridge_audio/fixtures/get_play_state.json create mode 100644 tests/components/cambridge_audio/fixtures/get_sources.json create mode 100644 tests/components/cambridge_audio/fixtures/get_state.json create mode 100644 tests/components/cambridge_audio/test_media_player.py diff --git a/tests/components/cambridge_audio/conftest.py b/tests/components/cambridge_audio/conftest.py index 931c0f30af1..f17ff0cca3f 100644 --- a/tests/components/cambridge_audio/conftest.py +++ b/tests/components/cambridge_audio/conftest.py @@ -3,13 +3,13 @@ from collections.abc import Generator from unittest.mock import Mock, patch -from aiostreammagic.models import Info +from aiostreammagic.models import Info, NowPlaying, PlayState, Source, State import pytest from homeassistant.components.cambridge_audio.const import DOMAIN from homeassistant.const import CONF_HOST -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_fixture, load_json_array_fixture from tests.components.smhi.common import AsyncMock @@ -39,7 +39,20 @@ def mock_stream_magic_client() -> Generator[AsyncMock]: client = mock_client.return_value client.host = "192.168.20.218" client.info = Info.from_json(load_fixture("get_info.json", DOMAIN)) + client.sources = [ + Source.from_dict(x) + for x in load_json_array_fixture("get_sources.json", DOMAIN) + ] + client.state = State.from_json(load_fixture("get_state.json", DOMAIN)) + client.play_state = PlayState.from_json( + load_fixture("get_play_state.json", DOMAIN) + ) + client.now_playing = NowPlaying.from_json( + load_fixture("get_now_playing.json", DOMAIN) + ) client.is_connected = Mock(return_value=True) + client.position_last_updated = client.play_state.position + client.unregister_state_update_callbacks = AsyncMock(return_value=True) yield client diff --git a/tests/components/cambridge_audio/const.py b/tests/components/cambridge_audio/const.py new file mode 100644 index 00000000000..36057c79bb3 --- /dev/null +++ b/tests/components/cambridge_audio/const.py @@ -0,0 +1,6 @@ +"""Constants for Cambridge Audio integration tests.""" + +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN + +DEVICE_NAME = "cambridge_audio_cxnv2" +ENTITY_ID = f"{MP_DOMAIN}.{DEVICE_NAME}" diff --git a/tests/components/cambridge_audio/fixtures/get_now_playing.json b/tests/components/cambridge_audio/fixtures/get_now_playing.json new file mode 100644 index 00000000000..8dcc781be9b --- /dev/null +++ b/tests/components/cambridge_audio/fixtures/get_now_playing.json @@ -0,0 +1,25 @@ +{ + "state": "PLAYING", + "source": { + "id": "AIRPLAY", + "name": "AirPlay" + }, + "allow_apd": false, + "listening_on": "Listening on Cambridge Audio CXNv2 - AirPlay", + "display": { + "line1": "Holiday", + "line2": "Green Day", + "line3": "Greatest Hits: God's Favorite Band", + "format": "44.1kHz/16bit ALAC", + "mqa": "none", + "playback_source": "iPhone", + "class": "stream.service.airplay", + "art_file": "/tmp/current/AlbumArtFile-811-363", + "art_url": "http://192.168.20.218:80/album-art-2d89?id=1:246", + "progress": { + "position": 216, + "duration": 232 + } + }, + "controls": ["play_pause", "track_next", "track_previous"] +} diff --git a/tests/components/cambridge_audio/fixtures/get_play_state.json b/tests/components/cambridge_audio/fixtures/get_play_state.json new file mode 100644 index 00000000000..cd727ee58a7 --- /dev/null +++ b/tests/components/cambridge_audio/fixtures/get_play_state.json @@ -0,0 +1,22 @@ +{ + "state": "play", + "position": 179, + "presettable": false, + "mode_repeat": "off", + "mode_shuffle": "off", + "metadata": { + "class": "md.track", + "source": "AIRPLAY", + "name": "AirPlay", + "duration": 232, + "album": "Greatest Hits: God's Favorite Band", + "artist": "Green Day", + "title": "Holiday", + "art_url": "http://192.168.20.218:80/album-art-2d89?id=1:246", + "mqa": "none", + "codec": "ALAC", + "lossless": true, + "sample_rate": 44100, + "bit_depth": 16 + } +} diff --git a/tests/components/cambridge_audio/fixtures/get_sources.json b/tests/components/cambridge_audio/fixtures/get_sources.json new file mode 100644 index 00000000000..185f65e5ff6 --- /dev/null +++ b/tests/components/cambridge_audio/fixtures/get_sources.json @@ -0,0 +1,113 @@ +[ + { + "id": "IR", + "name": "Internet Radio", + "default_name": "Internet Radio", + "class": "stream.radio", + "nameable": false, + "ui_selectable": false, + "description": "Internet Radio", + "description_locale": "Internet Radio", + "preferred_order": 9 + }, + { + "id": "USB_AUDIO", + "name": "USB Audio", + "default_name": "USB Audio", + "class": "digital.usb", + "nameable": true, + "ui_selectable": true, + "description": "USB Audio", + "description_locale": "USB Audio", + "preferred_order": 1 + }, + { + "id": "SPDIF_COAX", + "name": "D2", + "default_name": "D2", + "class": "digital.coax", + "nameable": true, + "ui_selectable": false, + "description": "Digital Co-axial", + "description_locale": "Digital Co-axial", + "preferred_order": 3 + }, + { + "id": "SPDIF_TOSLINK", + "name": "D1", + "default_name": "D1", + "class": "digital.toslink", + "nameable": true, + "ui_selectable": false, + "description": "Digital Optical", + "description_locale": "Digital Optical", + "preferred_order": 2 + }, + { + "id": "MEDIA_PLAYER", + "name": "Media Library", + "default_name": "Media Library", + "class": "stream.media", + "nameable": false, + "ui_selectable": true, + "description": "Media Player", + "description_locale": "Media Player", + "preferred_order": 10 + }, + { + "id": "AIRPLAY", + "name": "AirPlay", + "default_name": "AirPlay", + "class": "stream.service.airplay", + "nameable": false, + "ui_selectable": true, + "description": "AirPlay", + "description_locale": "AirPlay", + "preferred_order": 11 + }, + { + "id": "SPOTIFY", + "name": "Spotify", + "default_name": "Spotify", + "class": "stream.service.spotify", + "nameable": false, + "ui_selectable": true, + "description": "Spotify", + "description_locale": "Spotify", + "preferred_order": 6, + "normalisation": "off" + }, + { + "id": "CAST", + "name": "Chromecast built-in", + "default_name": "Chromecast built-in", + "class": "stream.service.cast", + "nameable": false, + "ui_selectable": true, + "description": "Chromecast built-in", + "description_locale": "Chromecast built-in", + "preferred_order": 8 + }, + { + "id": "ROON", + "name": "Roon Ready", + "default_name": "Roon Ready", + "class": "stream.service.roon", + "nameable": false, + "ui_selectable": false, + "description": "Roon Ready", + "description_locale": "Roon Ready", + "preferred_order": 5 + }, + { + "id": "TIDAL", + "name": "TIDAL Connect", + "default_name": "TIDAL Connect", + "class": "stream.service.tidal", + "nameable": false, + "ui_selectable": false, + "description": "TIDAL", + "description_locale": "TIDAL", + "preferred_order": 7 + } +] diff --git a/tests/components/cambridge_audio/fixtures/get_state.json b/tests/components/cambridge_audio/fixtures/get_state.json new file mode 100644 index 00000000000..1acf0df4f6a --- /dev/null +++ b/tests/components/cambridge_audio/fixtures/get_state.json @@ -0,0 +1,7 @@ +{ + "source": "AIRPLAY", + "power": true, + "pre_amp_mode": false, + "pre_amp_state": "disabled_user", + "cbus": "off" +} diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py new file mode 100644 index 00000000000..1f6564a6fab --- /dev/null +++ b/tests/components/cambridge_audio/test_media_player.py @@ -0,0 +1,193 @@ +"""Tests for the Cambridge Audio integration.""" + +from unittest.mock import AsyncMock + +from aiostreammagic import TransportControl +import pytest + +from homeassistant.components.media_player import ( + DOMAIN as MP_DOMAIN, + MediaPlayerEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + STATE_BUFFERING, + STATE_IDLE, + STATE_OFF, + STATE_PAUSED, + STATE_PLAYING, + STATE_STANDBY, +) +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import ENTITY_ID + +from tests.common import MockConfigEntry + + +async def mock_state_update(client: AsyncMock) -> None: + """Trigger a callback in the media player.""" + await client.register_state_update_callbacks.call_args[0][0](client) + + +async def test_entity_supported_features( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test entity attributes.""" + await setup_integration(hass, mock_config_entry) + await mock_state_update(mock_stream_magic_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + attrs = state.attributes + + # Ensure volume isn't available when pre-amp is disabled + assert not mock_stream_magic_client.state.pre_amp_mode + assert ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + not in attrs[ATTR_SUPPORTED_FEATURES] + ) + + # Check for basic media controls + assert { + TransportControl.PLAY_PAUSE, + TransportControl.TRACK_NEXT, + TransportControl.TRACK_PREVIOUS, + }.issubset(mock_stream_magic_client.now_playing.controls) + assert ( + MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PREVIOUS_TRACK + in attrs[ATTR_SUPPORTED_FEATURES] + ) + assert ( + MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.SEEK + not in attrs[ATTR_SUPPORTED_FEATURES] + ) + + mock_stream_magic_client.now_playing.controls = [ + TransportControl.TOGGLE_REPEAT, + TransportControl.TOGGLE_SHUFFLE, + TransportControl.SEEK, + ] + await mock_state_update(mock_stream_magic_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + attrs = state.attributes + + assert ( + MediaPlayerEntityFeature.SHUFFLE_SET + | MediaPlayerEntityFeature.REPEAT_SET + | MediaPlayerEntityFeature.SEEK + in attrs[ATTR_SUPPORTED_FEATURES] + ) + + mock_stream_magic_client.state.pre_amp_mode = True + await mock_state_update(mock_stream_magic_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + attrs = state.attributes + assert ( + MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + in attrs[ATTR_SUPPORTED_FEATURES] + ) + + +@pytest.mark.parametrize( + ("power_state", "play_state", "media_player_state"), + [ + (True, "NETWORK", STATE_STANDBY), + (False, "NETWORK", STATE_STANDBY), + (False, "play", STATE_OFF), + (True, "play", STATE_PLAYING), + (True, "pause", STATE_PAUSED), + (True, "connecting", STATE_BUFFERING), + (True, "stop", STATE_IDLE), + (True, "ready", STATE_IDLE), + ], +) +async def test_entity_state( + hass: HomeAssistant, + mock_stream_magic_client: AsyncMock, + mock_config_entry: MockConfigEntry, + power_state: bool, + play_state: str, + media_player_state: str, +) -> None: + """Test media player state.""" + await setup_integration(hass, mock_config_entry) + mock_stream_magic_client.state.power = power_state + mock_stream_magic_client.play_state.state = play_state + await mock_state_update(mock_stream_magic_client) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == media_player_state + + +async def test_media_play_pause_stop( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_stream_magic_client: AsyncMock, +) -> None: + """Test media next previous track service.""" + await setup_integration(hass, mock_config_entry) + + data = {ATTR_ENTITY_ID: ENTITY_ID} + + # Test for play/pause command when separate play and pause controls are unavailable + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PAUSE, data, True) + mock_stream_magic_client.play_pause.assert_called_once() + + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) + assert mock_stream_magic_client.play_pause.call_count == 2 + + # Test for separate play and pause controls + mock_stream_magic_client.now_playing.controls = [ + TransportControl.PLAY, + TransportControl.PAUSE, + ] + await mock_state_update(mock_stream_magic_client) + await hass.async_block_till_done() + + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PAUSE, data, True) + mock_stream_magic_client.pause.assert_called_once() + + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) + mock_stream_magic_client.play.assert_called_once() + + +async def test_media_next_previous_track( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_stream_magic_client: AsyncMock, +) -> None: + """Test media next previous track service.""" + await setup_integration(hass, mock_config_entry) + + data = {ATTR_ENTITY_ID: ENTITY_ID} + + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data, True) + + mock_stream_magic_client.next_track.assert_called_once() + + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data, True) + + mock_stream_magic_client.previous_track.assert_called_once() From f176233f0a4a3eb1cb072555d03fb8acf7e4ded9 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Wed, 11 Sep 2024 23:14:19 +0200 Subject: [PATCH 0568/1309] Bump pyblu to 1.0.2 (#125784) --- homeassistant/components/bluesound/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index 13514f52893..53f2d8a0240 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bluesound", "iot_class": "local_polling", - "requirements": ["pyblu==1.0.1"], + "requirements": ["pyblu==1.0.2"], "zeroconf": [ { "type": "_musc._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 7a2c9269c26..65afdba752e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1774,7 +1774,7 @@ pybbox==0.0.5-alpha pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==1.0.1 +pyblu==1.0.2 # homeassistant.components.neato pybotvac==0.0.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2481c6865d8..34d91e91651 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1442,7 +1442,7 @@ pybalboa==1.0.2 pyblackbird==0.6 # homeassistant.components.bluesound -pyblu==1.0.1 +pyblu==1.0.2 # homeassistant.components.neato pybotvac==0.0.25 From 0582c39d33209c2d9dc84f4546aff6fa5eb1b23c Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Wed, 11 Sep 2024 23:14:43 +0200 Subject: [PATCH 0569/1309] Remove call to removed function in bluesound integration (#125779) * Remove async_trigger_sync_on_all * Use cast instead of instanceof --- .../components/bluesound/media_player.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index e7506ea0611..1e2a537cd62 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -7,7 +7,7 @@ from asyncio import CancelledError, Task from contextlib import suppress from datetime import datetime, timedelta import logging -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple, cast from pyblu import Input, Player, Preset, Status, SyncStatus from pyblu.errors import PlayerUnreachableError @@ -369,11 +369,6 @@ class BluesoundPlayer(MediaPlayerEntity): # rebuild ordered list of entity_ids that are in the group, master is first self._group_list = self.rebuild_bluesound_group() - # the sleep is needed to make sure that the - # devices is synced - await asyncio.sleep(1) - await self.async_trigger_sync_on_all() - self.async_write_ha_state() except PlayerUnreachableError: self._attr_available = False @@ -419,13 +414,6 @@ class BluesoundPlayer(MediaPlayerEntity): self.async_write_ha_state() - async def async_trigger_sync_on_all(self) -> None: - """Trigger sync status update on all devices.""" - _LOGGER.debug("Trigger sync status on all devices") - - for player in self.hass.data[DATA_BLUESOUND]: - await player.force_update_sync_status() - async def async_update_captures(self) -> None: """Update Capture sources.""" inputs = await self._player.inputs() @@ -697,13 +685,13 @@ class BluesoundPlayer(MediaPlayerEntity): device_group = self._group_name.split("+") - sorted_entities = sorted( + sorted_entities: list[BluesoundPlayer] = sorted( self.hass.data[DATA_BLUESOUND], key=lambda entity: entity.is_master, reverse=True, ) return [ - entity.name + cast(str, entity.name) for entity in sorted_entities if entity.bluesound_device_name in device_group ] From ee7bee27663dc7d9d313259312b8021b2fcd0429 Mon Sep 17 00:00:00 2001 From: cnico Date: Wed, 11 Sep 2024 23:34:29 +0200 Subject: [PATCH 0570/1309] Refactoring flipr integration to prepare Hub device addition (#125262) * Addition of hub device * coordinator udata updated after a hub action * Unit tests update * Unit tests improvements * addition of tests on select and switch platforms * wording * Removal of select platform for PR containing only one platform * Remove hub to maintain only the refactoring that prepare the hub device addition * Review corrections * wording * Review corrections * Review corrections * Review corrections --- homeassistant/components/flipr/__init__.py | 99 +++++++- .../components/flipr/binary_sensor.py | 9 +- homeassistant/components/flipr/config_flow.py | 119 +++------- homeassistant/components/flipr/const.py | 4 +- homeassistant/components/flipr/coordinator.py | 26 +-- homeassistant/components/flipr/entity.py | 30 +-- homeassistant/components/flipr/sensor.py | 14 +- homeassistant/components/flipr/strings.json | 18 +- tests/components/flipr/__init__.py | 14 ++ tests/components/flipr/conftest.py | 97 ++++++++ tests/components/flipr/test_binary_sensor.py | 45 +--- tests/components/flipr/test_config_flow.py | 220 ++++++++---------- tests/components/flipr/test_init.py | 89 +++++-- tests/components/flipr/test_sensor.py | 80 ++----- 14 files changed, 479 insertions(+), 385 deletions(-) create mode 100644 tests/components/flipr/conftest.py diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 28515dd386f..7f43321d397 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -1,22 +1,59 @@ """The Flipr integration.""" +from collections import Counter +from dataclasses import dataclass +import logging + +from flipr_api import FliprAPIRestClient + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import issue_registry as ir from .const import DOMAIN from .coordinator import FliprDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Flipr from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - coordinator = FliprDataUpdateCoordinator(hass, entry) - await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator +@dataclass +class FliprData: + """The Flipr data class.""" + + flipr_coordinators: list[FliprDataUpdateCoordinator] + + +type FliprConfigEntry = ConfigEntry[FliprData] + + +async def async_setup_entry(hass: HomeAssistant, entry: FliprConfigEntry) -> bool: + """Set up flipr from a config entry.""" + + # Detect invalid old config entry and raise error if found + detect_invalid_old_configuration(hass, entry) + + config = entry.data + + username = config[CONF_EMAIL] + password = config[CONF_PASSWORD] + + _LOGGER.debug("Initializing Flipr client %s", username) + client = FliprAPIRestClient(username, password) + ids = await hass.async_add_executor_job(client.search_all_ids) + + _LOGGER.debug("List of devices ids : %s", ids) + + flipr_coordinators = [] + for flipr_id in ids["flipr"]: + flipr_coordinator = FliprDataUpdateCoordinator(hass, client, flipr_id) + await flipr_coordinator.async_config_entry_first_refresh() + flipr_coordinators.append(flipr_coordinator) + + entry.runtime_data = FliprData(flipr_coordinators) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -25,9 +62,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok + +def detect_invalid_old_configuration(hass: HomeAssistant, entry: ConfigEntry): + """Detect invalid old configuration and raise error if found.""" + + def find_duplicate_entries(entries): + values = [e.data["email"] for e in entries] + _LOGGER.debug("Detecting duplicates in values : %s", values) + return any(count > 1 for count in Counter(values).values()) + + entries = hass.config_entries.async_entries(DOMAIN) + + if find_duplicate_entries(entries): + ir.async_create_issue( + hass, + DOMAIN, + "duplicate_config", + breaks_in_ha_version="2025.4.0", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="duplicate_config", + ) + + raise ConfigEntryError( + "Duplicate entries found for flipr with the same user email. Please remove one of it manually. Multiple fliprs will be automatically detected after restart." + ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate config entry.""" + _LOGGER.debug("Migration of flipr config from version %s", entry.version) + + if entry.version == 1: + # In version 1, we have flipr device as config entry unique id + # and one device per config entry. + # We need to migrate to a new config entry that may contain multiple devices. + # So we change the entry data to match config_flow evolution. + login = entry.data[CONF_EMAIL] + + hass.config_entries.async_update_entry(entry, version=2, unique_id=login) + + _LOGGER.debug("Migration of flipr config to version 2 successful") + + return True diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py index a3c3e4dc8a1..cc6a9d36abc 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -7,11 +7,10 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import FliprConfigEntry from .entity import FliprEntity BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( @@ -30,15 +29,17 @@ BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FliprConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup of flipr binary sensors.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + + coordinators = config_entry.runtime_data.flipr_coordinators async_add_entities( FliprBinarySensor(coordinator, description) for description in BINARY_SENSORS_TYPES + for coordinator in coordinators ) diff --git a/homeassistant/components/flipr/config_flow.py b/homeassistant/components/flipr/config_flow.py index 3d616feb37f..287c7108b3f 100644 --- a/homeassistant/components/flipr/config_flow.py +++ b/homeassistant/components/flipr/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from typing import Any from flipr_api import FliprAPIRestClient from requests.exceptions import HTTPError, Timeout @@ -11,35 +12,37 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from .const import CONF_FLIPR_ID, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } +) + class FliprConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Flipr.""" - VERSION = 1 - - _username: str - _password: str - _flipr_id: str = "" - _possible_flipr_ids: list[str] + VERSION = 2 async def async_step_user( - self, user_input: dict[str, str] | None = None + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - if user_input is None: - return self._show_setup_form() - self._username = user_input[CONF_EMAIL] - self._password = user_input[CONF_PASSWORD] + errors: dict[str, str] = {} + + if user_input is not None: + client = FliprAPIRestClient( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) - errors = {} - if not self._flipr_id: try: - flipr_ids = await self._authenticate_and_search_flipr() + ids = await self.hass.async_add_executor_job(client.search_all_ids) except HTTPError: errors["base"] = "invalid_auth" except (Timeout, ConnectionError): @@ -48,79 +51,25 @@ class FliprConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" _LOGGER.exception("Unexpected exception") - if not errors and not flipr_ids: - # No flipr_id found. Tell the user with an error message. + else: + _LOGGER.debug("Found flipr or hub ids : %s", ids) + + if len(ids["flipr"]) > 0 or len(ids["hub"]) > 0: + # If there is a flipr or hub, we can create a config entry. + + await self.async_set_unique_id(user_input[CONF_EMAIL]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"Flipr {user_input[CONF_EMAIL]}", + data=user_input, + ) + + # if no flipr or hub found. Tell the user with an error message. errors["base"] = "no_flipr_id_found" - if errors: - return self._show_setup_form(errors) - - if len(flipr_ids) == 1: - self._flipr_id = flipr_ids[0] - else: - # If multiple flipr found (rare case), we ask the user to choose one in a select box. - # The user will have to run config_flow as many times as many fliprs he has. - self._possible_flipr_ids = flipr_ids - return await self.async_step_flipr_id() - - # Check if already configured - await self.async_set_unique_id(self._flipr_id) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=self._flipr_id, - data={ - CONF_EMAIL: self._username, - CONF_PASSWORD: self._password, - CONF_FLIPR_ID: self._flipr_id, - }, - ) - - def _show_setup_form(self, errors=None): - """Show the setup form to the user.""" return self.async_show_form( step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} - ), + data_schema=DATA_SCHEMA, errors=errors, ) - - async def _authenticate_and_search_flipr(self) -> list[str]: - """Validate the username and password provided and searches for a flipr id.""" - # Instantiates the flipr API that does not require async since it is has no network access. - client = FliprAPIRestClient(self._username, self._password) - - return await self.hass.async_add_executor_job(client.search_flipr_ids) - - async def async_step_flipr_id( - self, user_input: dict[str, str] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - if not user_input: - # Creation of a select with the proposal of flipr ids values found by API. - flipr_ids_for_form = {} - for flipr_id in self._possible_flipr_ids: - flipr_ids_for_form[flipr_id] = f"{flipr_id}" - - return self.async_show_form( - step_id="flipr_id", - data_schema=vol.Schema( - { - vol.Required(CONF_FLIPR_ID): vol.All( - vol.Coerce(str), vol.In(flipr_ids_for_form) - ) - } - ), - ) - - # Get chosen flipr_id. - self._flipr_id = user_input[CONF_FLIPR_ID] - - return await self.async_step_user( - { - CONF_EMAIL: self._username, - CONF_PASSWORD: self._password, - CONF_FLIPR_ID: self._flipr_id, - } - ) diff --git a/homeassistant/components/flipr/const.py b/homeassistant/components/flipr/const.py index d28353f4776..604c43212d1 100644 --- a/homeassistant/components/flipr/const.py +++ b/homeassistant/components/flipr/const.py @@ -2,9 +2,9 @@ DOMAIN = "flipr" -CONF_FLIPR_ID = "flipr_id" - ATTRIBUTION = "Flipr Data" MANUFACTURER = "CTAC-TECH" NAME = "Flipr" + +CONF_ENTRY_FLIPR_COORDINATORS = "flipr_coordinators" diff --git a/homeassistant/components/flipr/coordinator.py b/homeassistant/components/flipr/coordinator.py index afc7465498f..11dc3c9b071 100644 --- a/homeassistant/components/flipr/coordinator.py +++ b/homeassistant/components/flipr/coordinator.py @@ -6,39 +6,37 @@ import logging from flipr_api import FliprAPIRestClient from flipr_api.exceptions import FliprError -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_FLIPR_ID - _LOGGER = logging.getLogger(__name__) class FliprDataUpdateCoordinator(DataUpdateCoordinator): """Class to hold Flipr data retrieval.""" - def __init__(self, hass, entry): - """Initialize.""" - username = entry.data[CONF_EMAIL] - password = entry.data[CONF_PASSWORD] - self.flipr_id = entry.data[CONF_FLIPR_ID] + config_entry: ConfigEntry - # Establishes the connection. - self.client = FliprAPIRestClient(username, password) - self.entry = entry + def __init__( + self, hass: HomeAssistant, client: FliprAPIRestClient, flipr_or_hub_id: str + ) -> None: + """Initialize.""" + self.device_id = flipr_or_hub_id + self.client = client super().__init__( hass, _LOGGER, - name=f"Flipr data measure for {self.flipr_id}", - update_interval=timedelta(minutes=60), + name=f"Flipr or Hub data measure for {self.device_id}", + update_interval=timedelta(minutes=15), ) async def _async_update_data(self): """Fetch data from API endpoint.""" try: data = await self.hass.async_add_executor_job( - self.client.get_pool_measure_latest, self.flipr_id + self.client.get_pool_measure_latest, self.device_id ) except FliprError as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/flipr/entity.py b/homeassistant/components/flipr/entity.py index 859ffc9390b..d209a6a888e 100644 --- a/homeassistant/components/flipr/entity.py +++ b/homeassistant/components/flipr/entity.py @@ -2,12 +2,10 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER +from .const import ATTRIBUTION, DOMAIN, MANUFACTURER +from .coordinator import FliprDataUpdateCoordinator class FliprEntity(CoordinatorEntity): @@ -17,17 +15,21 @@ class FliprEntity(CoordinatorEntity): _attr_has_entity_name = True def __init__( - self, coordinator: DataUpdateCoordinator, description: EntityDescription + self, + coordinator: FliprDataUpdateCoordinator, + description: EntityDescription, + is_flipr_hub: bool = False, ) -> None: """Initialize Flipr sensor.""" super().__init__(coordinator) + self.device_id = coordinator.device_id self.entity_description = description - if coordinator.config_entry: - flipr_id = coordinator.config_entry.data[CONF_FLIPR_ID] - self._attr_unique_id = f"{flipr_id}-{description.key}" + self._attr_unique_id = f"{self.device_id}-{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, flipr_id)}, - manufacturer=MANUFACTURER, - name=f"Flipr {flipr_id}", - ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device_id)}, + manufacturer=MANUFACTURER, + name=f"Flipr hub {self.device_id}" + if is_flipr_hub + else f"Flipr {self.device_id}", + ) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 7a1c64dc766..ba863718182 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -8,12 +8,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import FliprConfigEntry from .entity import FliprEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( @@ -57,14 +56,17 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FliprConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinators = config_entry.runtime_data.flipr_coordinators - sensors = [FliprSensor(coordinator, description) for description in SENSOR_TYPES] - async_add_entities(sensors) + async_add_entities( + FliprSensor(coordinator, description) + for description in SENSOR_TYPES + for coordinator in coordinators + ) class FliprSensor(FliprEntity, SensorEntity): diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json index 235117afbd4..8eebb62cb5c 100644 --- a/homeassistant/components/flipr/strings.json +++ b/homeassistant/components/flipr/strings.json @@ -8,23 +8,13 @@ "email": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } - }, - "flipr_id": { - "title": "Choose your Flipr", - "description": "Choose your Flipr ID in the list", - "data": { - "flipr_id": "Flipr ID" - } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "no_flipr_id_found": "No flipr id associated to your account for now. You should verify it is working with the Flipr's mobile app first." - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "no_flipr_id_found": "No flipr or hub associated to your account for now. You should verify it is working with the Flipr's mobile app first." } }, "entity": { @@ -50,5 +40,11 @@ "name": "Red OX" } } + }, + "issues": { + "duplicate_config": { + "title": "Multiple flipr configurations with the same account", + "description": "The Flipr integration has been updated to work account based rather than device based. This means that if you have 2 devices, you only need one configuration. For every account you have, please delete all but one configuration and restart Home Assistant for it to set up the devices linked to your account." + } } } diff --git a/tests/components/flipr/__init__.py b/tests/components/flipr/__init__.py index 26767261866..3c5bfc2a6c2 100644 --- a/tests/components/flipr/__init__.py +++ b/tests/components/flipr/__init__.py @@ -1 +1,15 @@ """Tests for the Flipr integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Fixture for setting up the component.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/flipr/conftest.py b/tests/components/flipr/conftest.py new file mode 100644 index 00000000000..18457000636 --- /dev/null +++ b/tests/components/flipr/conftest.py @@ -0,0 +1,97 @@ +"""Common fixtures for the flipr tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.flipr.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + +# Data for the mocked object returned via flipr_api client. +MOCK_DATE_TIME = datetime(2021, 2, 15, 9, 10, 32, tzinfo=dt_util.UTC) +MOCK_FLIPR_MEASURE = { + "temperature": 10.5, + "ph": 7.03, + "chlorine": 0.23654886, + "red_ox": 657.58, + "date_time": MOCK_DATE_TIME, + "ph_status": "TooLow", + "chlorine_status": "Medium", + "battery": 95.0, +} + +MOCK_HUB_STATE_ON = { + "state": True, + "mode": "planning", + "planning": "dummyplanningid", +} + +MOCK_HUB_STATE_OFF = { + "state": False, + "mode": "manual", + "planning": "dummyplanningid", +} + +MOCK_HUB_MODE_MANUAL = { + "state": False, + "mode": "manual", + "planning": "dummyplanningid", +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.flipr.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock the config entry.""" + return MockConfigEntry( + version=2, + domain=DOMAIN, + unique_id="toto@toto.com", + data={ + CONF_EMAIL: "toto@toto.com", + CONF_PASSWORD: "myPassword", + }, + ) + + +@pytest.fixture +def mock_flipr_client() -> Generator[AsyncMock]: + """Mock a Flipr client.""" + + with ( + patch( + "homeassistant.components.flipr.FliprAPIRestClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.flipr.config_flow.FliprAPIRestClient", + new=mock_client, + ), + ): + client = mock_client.return_value + + # Default values for the tests using this mock : + client.search_all_ids.return_value = {"flipr": ["myfliprid"], "hub": []} + + client.get_pool_measure_latest.return_value = MOCK_FLIPR_MEASURE + + client.get_hub_state.return_value = MOCK_HUB_STATE_ON + + client.set_hub_state.return_value = MOCK_HUB_STATE_ON + + client.set_hub_mode.return_value = MOCK_HUB_MODE_MANUAL + + yield client diff --git a/tests/components/flipr/test_binary_sensor.py b/tests/components/flipr/test_binary_sensor.py index 971b5b046b3..ed43dbb8a77 100644 --- a/tests/components/flipr/test_binary_sensor.py +++ b/tests/components/flipr/test_binary_sensor.py @@ -1,49 +1,24 @@ """Test the Flipr binary sensor.""" -from datetime import datetime -from unittest.mock import patch +from unittest.mock import AsyncMock -from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util + +from . import setup_integration from tests.common import MockConfigEntry -# Data for the mocked object returned via flipr_api client. -MOCK_DATE_TIME = datetime(2021, 2, 15, 9, 10, 32, tzinfo=dt_util.UTC) -MOCK_FLIPR_MEASURE = { - "temperature": 10.5, - "ph": 7.03, - "chlorine": 0.23654886, - "red_ox": 657.58, - "date_time": MOCK_DATE_TIME, - "ph_status": "TooLow", - "chlorine_status": "Medium", -} - -async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_flipr_client: AsyncMock, +) -> None: """Test the creation and values of the Flipr binary sensors.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="test_entry_unique_id", - data={ - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - CONF_FLIPR_ID: "myfliprid", - }, - ) - entry.add_to_hass(hass) - - with patch( - "flipr_api.FliprAPIRestClient.get_pool_measure_latest", - return_value=MOCK_FLIPR_MEASURE, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) # Check entity unique_id value that is generated in FliprEntity base class. entity = entity_registry.async_get("binary_sensor.flipr_myfliprid_ph_status") diff --git a/tests/components/flipr/test_config_flow.py b/tests/components/flipr/test_config_flow.py index b99e6af7383..9df77dc0b2a 100644 --- a/tests/components/flipr/test_config_flow.py +++ b/tests/components/flipr/test_config_flow.py @@ -1,169 +1,131 @@ """Test the Flipr config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest from requests.exceptions import HTTPError, Timeout -from homeassistant import config_entries -from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.components.flipr.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -@pytest.fixture(name="mock_setup") -def mock_setups(): - """Prevent setup.""" - with patch( - "homeassistant.components.flipr.async_setup_entry", - return_value=True, - ): - yield - - -async def test_show_form(hass: HomeAssistant) -> None: - """Test we get the form.""" +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_flipr_client: AsyncMock +) -> None: + """Test the full flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == config_entries.SOURCE_USER + assert result["step_id"] == "user" + assert not result["errors"] - -async def test_invalid_credential(hass: HomeAssistant, mock_setup) -> None: - """Test invalid credential.""" - with patch( - "flipr_api.FliprAPIRestClient.search_flipr_ids", side_effect=HTTPError() - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ - CONF_EMAIL: "bad_login", - CONF_PASSWORD: "bad_pass", - CONF_FLIPR_ID: "", - }, - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_nominal_case(hass: HomeAssistant, mock_setup) -> None: - """Test valid login form.""" - with patch( - "flipr_api.FliprAPIRestClient.search_flipr_ids", - return_value=["flipid"], - ) as mock_flipr_client: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ - CONF_EMAIL: "dummylogin", - CONF_PASSWORD: "dummypass", - CONF_FLIPR_ID: "flipid", - }, - ) - await hass.async_block_till_done() - - assert len(mock_flipr_client.mock_calls) == 1 + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "flipid" + assert result["title"] == "Flipr dummylogin" + assert result["result"].unique_id == "dummylogin" assert result["data"] == { CONF_EMAIL: "dummylogin", CONF_PASSWORD: "dummypass", - CONF_FLIPR_ID: "flipid", } -async def test_multiple_flip_id(hass: HomeAssistant, mock_setup) -> None: - """Test multiple flipr id adding a config step.""" - with patch( - "flipr_api.FliprAPIRestClient.search_flipr_ids", - return_value=["FLIP1", "FLIP2"], - ) as mock_flipr_client: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ - CONF_EMAIL: "dummylogin", - CONF_PASSWORD: "dummypass", - }, - ) +@pytest.mark.parametrize( + ("exception", "expected"), + [ + (Exception("Bad request Boy :) --"), {"base": "unknown"}), + (HTTPError, {"base": "invalid_auth"}), + (Timeout, {"base": "cannot_connect"}), + (ConnectionError, {"base": "cannot_connect"}), + ], +) +async def test_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_flipr_client: AsyncMock, + exception: Exception, + expected: dict[str, str], +) -> None: + """Test we handle any error.""" + mock_flipr_client.search_all_ids.side_effect = exception - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "flipr_id" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_EMAIL: "nada", + CONF_PASSWORD: "nadap", + }, + ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_FLIPR_ID: "FLIP2"}, - ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == expected - assert len(mock_flipr_client.mock_calls) == 1 + # Test of recover in normal state after correction of the 1st error + mock_flipr_client.search_all_ids.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "FLIP2" + assert result["title"] == "Flipr dummylogin" assert result["data"] == { CONF_EMAIL: "dummylogin", CONF_PASSWORD: "dummypass", - CONF_FLIPR_ID: "FLIP2", } -async def test_no_flip_id(hass: HomeAssistant, mock_setup) -> None: - """Test no flipr id found.""" - with patch( - "flipr_api.FliprAPIRestClient.search_flipr_ids", - return_value=[], - ) as mock_flipr_client: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ - CONF_EMAIL: "dummylogin", - CONF_PASSWORD: "dummypass", - }, - ) +async def test_no_flipr_found( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_flipr_client: AsyncMock +) -> None: + """Test the case where there is no flipr found.""" - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "no_flipr_id_found"} - - assert len(mock_flipr_client.mock_calls) == 1 - - -async def test_http_errors(hass: HomeAssistant, mock_setup) -> None: - """Test HTTP Errors.""" - with patch("flipr_api.FliprAPIRestClient.search_flipr_ids", side_effect=Timeout()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ - CONF_EMAIL: "nada", - CONF_PASSWORD: "nada", - CONF_FLIPR_ID: "", - }, - ) + mock_flipr_client.search_all_ids.return_value = {"flipr": [], "hub": []} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_EMAIL: "nada", + CONF_PASSWORD: "nadap", + }, + ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} + assert result["step_id"] == "user" + assert result["errors"] == {"base": "no_flipr_id_found"} - with patch( - "flipr_api.FliprAPIRestClient.search_flipr_ids", - side_effect=Exception("Bad request Boy :) --"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data={ - CONF_EMAIL: "nada", - CONF_PASSWORD: "nada", - CONF_FLIPR_ID: "", - }, - ) + # Test of recover in normal state after correction of the 1st error + mock_flipr_client.search_all_ids.return_value = {"flipr": ["myfliprid"], "hub": []} - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Flipr dummylogin" + assert result["data"] == { + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + } diff --git a/tests/components/flipr/test_init.py b/tests/components/flipr/test_init.py index 6a49b5b7200..6e9341b1e06 100644 --- a/tests/components/flipr/test_init.py +++ b/tests/components/flipr/test_init.py @@ -1,29 +1,90 @@ """Tests for init methods.""" -from unittest.mock import patch +from unittest.mock import AsyncMock -from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.components.flipr.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant +from . import setup_integration + from tests.common import MockConfigEntry -async def test_unload_entry(hass: HomeAssistant) -> None: +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flipr_client: AsyncMock, +) -> None: """Test unload entry.""" - entry = MockConfigEntry( + + mock_flipr_client.search_all_ids.return_value = { + "flipr": ["myfliprid"], + "hub": ["hubid"], + } + + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_duplicate_config_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_flipr_client: AsyncMock, +) -> None: + """Test duplicate config entries.""" + + mock_config_entry_dup = MockConfigEntry( + version=2, domain=DOMAIN, + unique_id="toto@toto.com", data={ - CONF_EMAIL: "dummylogin", - CONF_PASSWORD: "dummypass", - CONF_FLIPR_ID: "FLIP1", + CONF_EMAIL: "toto@toto.com", + CONF_PASSWORD: "myPassword", + "flipr_id": "myflipr_id_dup", }, - unique_id="123456", ) - entry.add_to_hass(hass) - with patch("homeassistant.components.flipr.coordinator.FliprAPIRestClient"): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.config_entries.async_unload(entry.entry_id) - assert entry.state is ConfigEntryState.NOT_LOADED + + mock_config_entry.add_to_hass(hass) + # Initialize the first entry with default mock + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Initialize the second entry with another flipr id + mock_config_entry_dup.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry_dup.entry_id) + await hass.async_block_till_done() + assert mock_config_entry_dup.state is ConfigEntryState.SETUP_ERROR + + +async def test_migrate_entry( + hass: HomeAssistant, + mock_flipr_client: AsyncMock, +) -> None: + """Test migrate config entry from v1 to v2.""" + + mock_config_entry_v1 = MockConfigEntry( + version=1, + domain=DOMAIN, + title="myfliprid", + unique_id="test_entry_unique_id", + data={ + CONF_EMAIL: "toto@toto.com", + CONF_PASSWORD: "myPassword", + "flipr_id": "myfliprid", + }, + ) + + await setup_integration(hass, mock_config_entry_v1) + assert mock_config_entry_v1.state is ConfigEntryState.LOADED + assert mock_config_entry_v1.version == 2 + assert mock_config_entry_v1.unique_id == "toto@toto.com" + assert mock_config_entry_v1.data == { + CONF_EMAIL: "toto@toto.com", + CONF_PASSWORD: "myPassword", + "flipr_id": "myfliprid", + } diff --git a/tests/components/flipr/test_sensor.py b/tests/components/flipr/test_sensor.py index 31eb075469d..77937e3af54 100644 --- a/tests/components/flipr/test_sensor.py +++ b/tests/components/flipr/test_sensor.py @@ -1,59 +1,28 @@ """Test the Flipr sensor.""" -from datetime import datetime -from unittest.mock import patch +from unittest.mock import AsyncMock from flipr_api.exceptions import FliprError -from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - CONF_EMAIL, - CONF_PASSWORD, - PERCENTAGE, - UnitOfTemperature, -) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util + +from . import setup_integration from tests.common import MockConfigEntry -# Data for the mocked object returned via flipr_api client. -MOCK_DATE_TIME = datetime(2021, 2, 15, 9, 10, 32, tzinfo=dt_util.UTC) -MOCK_FLIPR_MEASURE = { - "temperature": 10.5, - "ph": 7.03, - "chlorine": 0.23654886, - "red_ox": 657.58, - "date_time": MOCK_DATE_TIME, - "ph_status": "TooLow", - "chlorine_status": "Medium", - "battery": 95.0, -} +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_flipr_client: AsyncMock, +) -> None: + """Test the creation and values of the Flipr binary sensors.""" -async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: - """Test the creation and values of the Flipr sensors.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="test_entry_unique_id", - data={ - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - CONF_FLIPR_ID: "myfliprid", - }, - ) - - entry.add_to_hass(hass) - - with patch( - "flipr_api.FliprAPIRestClient.get_pool_measure_latest", - return_value=MOCK_FLIPR_MEASURE, - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) # Check entity unique_id value that is generated in FliprEntity base class. entity = entity_registry.async_get("sensor.flipr_myfliprid_red_ox") @@ -97,27 +66,18 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) async def test_error_flipr_api_sensors( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_flipr_client: AsyncMock, ) -> None: """Test the Flipr sensors error.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id="test_entry_unique_id", - data={ - CONF_EMAIL: "toto@toto.com", - CONF_PASSWORD: "myPassword", - CONF_FLIPR_ID: "myfliprid", - }, + + mock_flipr_client.get_pool_measure_latest.side_effect = FliprError( + "Error during flipr data retrieval..." ) - entry.add_to_hass(hass) - - with patch( - "flipr_api.FliprAPIRestClient.get_pool_measure_latest", - side_effect=FliprError("Error during flipr data retrieval..."), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + await setup_integration(hass, mock_config_entry) # Check entity is not generated because of the FliprError raised. entity = entity_registry.async_get("sensor.flipr_myfliprid_red_ox") From 11fe48f2d23d8825408ce87ba7de094b797e8529 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Wed, 11 Sep 2024 19:57:54 -0400 Subject: [PATCH 0571/1309] Bump aiostreammagic to 2.2.5 (#125792) --- homeassistant/components/cambridge_audio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 3f2fe6c8e91..f8f61cc1890 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], - "requirements": ["aiostreammagic==2.2.3"], + "requirements": ["aiostreammagic==2.2.5"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 65afdba752e..776954fb983 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -377,7 +377,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.2.3 +aiostreammagic==2.2.5 # homeassistant.components.switcher_kis aioswitcher==4.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34d91e91651..03e18b64042 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -359,7 +359,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.2.3 +aiostreammagic==2.2.5 # homeassistant.components.switcher_kis aioswitcher==4.0.3 From 2475e8c0c44b8f60ee94ae22bab717337c27d0e2 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Thu, 12 Sep 2024 09:32:13 +0900 Subject: [PATCH 0572/1309] Add binary_sensor platform to LG ThinQ integration (#125664) * Add binary_sensor platform to LG ThinQ integration * Update homeassistant/components/lg_thinq/binary_sensor.py * Remove unused translation key * Add one_touch_filter property to binary_sensor * Add one_touch_filter to icons, strings * Update homeassistant/components/lg_thinq/strings.json --------- Co-authored-by: jangwon.lee Co-authored-by: Joost Lekkerkerker --- .../components/lg_thinq/binary_sensor.py | 83 ++++++++++++++++--- homeassistant/components/lg_thinq/icons.json | 15 ++++ .../components/lg_thinq/strings.json | 15 ++++ 3 files changed, 103 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/lg_thinq/binary_sensor.py b/homeassistant/components/lg_thinq/binary_sensor.py index c3179ea6948..c4f21861e54 100644 --- a/homeassistant/components/lg_thinq/binary_sensor.py +++ b/homeassistant/components/lg_thinq/binary_sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass import logging from thinqconnect import DeviceType @@ -9,6 +10,7 @@ from thinqconnect.devices.const import Property as ThinQProperty from thinqconnect.integration import ActiveMode from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -18,44 +20,95 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ThinqConfigEntry from .entity import ThinQEntity -BINARY_SENSOR_DESC: dict[ThinQProperty, BinarySensorEntityDescription] = { - ThinQProperty.RINSE_REFILL: BinarySensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class ThinQBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes ThinQ sensor entity.""" + + on_key: str | None = None + + +BINARY_SENSOR_DESC: dict[ThinQProperty, ThinQBinarySensorEntityDescription] = { + ThinQProperty.RINSE_REFILL: ThinQBinarySensorEntityDescription( key=ThinQProperty.RINSE_REFILL, translation_key=ThinQProperty.RINSE_REFILL, ), - ThinQProperty.ECO_FRIENDLY_MODE: BinarySensorEntityDescription( + ThinQProperty.ECO_FRIENDLY_MODE: ThinQBinarySensorEntityDescription( key=ThinQProperty.ECO_FRIENDLY_MODE, translation_key=ThinQProperty.ECO_FRIENDLY_MODE, ), - ThinQProperty.POWER_SAVE_ENABLED: BinarySensorEntityDescription( + ThinQProperty.POWER_SAVE_ENABLED: ThinQBinarySensorEntityDescription( key=ThinQProperty.POWER_SAVE_ENABLED, translation_key=ThinQProperty.POWER_SAVE_ENABLED, ), - ThinQProperty.REMOTE_CONTROL_ENABLED: BinarySensorEntityDescription( + ThinQProperty.REMOTE_CONTROL_ENABLED: ThinQBinarySensorEntityDescription( key=ThinQProperty.REMOTE_CONTROL_ENABLED, translation_key=ThinQProperty.REMOTE_CONTROL_ENABLED, ), - ThinQProperty.SABBATH_MODE: BinarySensorEntityDescription( + ThinQProperty.SABBATH_MODE: ThinQBinarySensorEntityDescription( key=ThinQProperty.SABBATH_MODE, translation_key=ThinQProperty.SABBATH_MODE, ), + ThinQProperty.DOOR_STATE: ThinQBinarySensorEntityDescription( + key=ThinQProperty.DOOR_STATE, + device_class=BinarySensorDeviceClass.DOOR, + on_key="open", + ), + ThinQProperty.MACHINE_CLEAN_REMINDER: ThinQBinarySensorEntityDescription( + key=ThinQProperty.MACHINE_CLEAN_REMINDER, + translation_key=ThinQProperty.MACHINE_CLEAN_REMINDER, + on_key="mcreminder_on", + ), + ThinQProperty.SIGNAL_LEVEL: ThinQBinarySensorEntityDescription( + key=ThinQProperty.SIGNAL_LEVEL, + translation_key=ThinQProperty.SIGNAL_LEVEL, + on_key="signallevel_on", + ), + ThinQProperty.CLEAN_LIGHT_REMINDER: ThinQBinarySensorEntityDescription( + key=ThinQProperty.CLEAN_LIGHT_REMINDER, + translation_key=ThinQProperty.CLEAN_LIGHT_REMINDER, + on_key="cleanlreminder_on", + ), + ThinQProperty.HOOD_OPERATION_MODE: ThinQBinarySensorEntityDescription( + key=ThinQProperty.HOOD_OPERATION_MODE, + translation_key="operation_mode", + on_key="power_on", + ), + ThinQProperty.WATER_HEATER_OPERATION_MODE: ThinQBinarySensorEntityDescription( + key=ThinQProperty.WATER_HEATER_OPERATION_MODE, + translation_key="operation_mode", + on_key="power_on", + ), + ThinQProperty.ONE_TOUCH_FILTER: ThinQBinarySensorEntityDescription( + key=ThinQProperty.ONE_TOUCH_FILTER, + translation_key=ThinQProperty.ONE_TOUCH_FILTER, + ), } DEVICE_TYPE_BINARY_SENSOR_MAP: dict[ - DeviceType, tuple[BinarySensorEntityDescription, ...] + DeviceType, tuple[ThinQBinarySensorEntityDescription, ...] ] = { DeviceType.COOKTOP: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), DeviceType.DISH_WASHER: ( + BINARY_SENSOR_DESC[ThinQProperty.DOOR_STATE], BINARY_SENSOR_DESC[ThinQProperty.RINSE_REFILL], BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], + BINARY_SENSOR_DESC[ThinQProperty.MACHINE_CLEAN_REMINDER], + BINARY_SENSOR_DESC[ThinQProperty.SIGNAL_LEVEL], + BINARY_SENSOR_DESC[ThinQProperty.CLEAN_LIGHT_REMINDER], ), DeviceType.DRYER: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), + DeviceType.HOOD: (BINARY_SENSOR_DESC[ThinQProperty.HOOD_OPERATION_MODE],), DeviceType.OVEN: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), DeviceType.REFRIGERATOR: ( + BINARY_SENSOR_DESC[ThinQProperty.DOOR_STATE], BINARY_SENSOR_DESC[ThinQProperty.ECO_FRIENDLY_MODE], BINARY_SENSOR_DESC[ThinQProperty.POWER_SAVE_ENABLED], BINARY_SENSOR_DESC[ThinQProperty.SABBATH_MODE], ), + DeviceType.KIMCHI_REFRIGERATOR: ( + BINARY_SENSOR_DESC[ThinQProperty.ONE_TOUCH_FILTER], + ), DeviceType.STYLER: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), DeviceType.WASHCOMBO_MAIN: ( BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], @@ -71,6 +124,9 @@ DEVICE_TYPE_BINARY_SENSOR_MAP: dict[ DeviceType.WASHTOWER_WASHER: ( BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], ), + DeviceType.WATER_HEATER: ( + BINARY_SENSOR_DESC[ThinQProperty.WATER_HEATER_OPERATION_MODE], + ), DeviceType.WINE_CELLAR: (BINARY_SENSOR_DESC[ThinQProperty.SABBATH_MODE],), } _LOGGER = logging.getLogger(__name__) @@ -104,14 +160,21 @@ async def async_setup_entry( class ThinQBinarySensorEntity(ThinQEntity, BinarySensorEntity): """Represent a thinq binary sensor platform.""" + entity_description: ThinQBinarySensorEntityDescription + def _update_status(self) -> None: """Update status itself.""" super()._update_status() + if (key := self.entity_description.on_key) is not None: + self._attr_is_on = self.data.value == key + else: + self._attr_is_on = self.data.is_on + _LOGGER.debug( - "[%s:%s] update status: %s", + "[%s:%s] update status: %s -> %s", self.coordinator.device_name, self.property_id, - self.data.is_on, + self.data.value, + self.is_on, ) - self._attr_is_on = self.data.is_on diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index 3cc4ab784c2..d96214725c8 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -23,6 +23,21 @@ }, "sabbath_mode": { "default": "mdi:food-off-outline" + }, + "machine_clean_reminder": { + "default": "mdi:tune-vertical-variant" + }, + "signal_level": { + "default": "mdi:tune-vertical-variant" + }, + "clean_light_reminder": { + "default": "mdi:tune-vertical-variant" + }, + "operation_mode": { + "default": "mdi:power" + }, + "one_touch_filter": { + "default": "mdi:air-filter" } } } diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 6649c6b0c13..9ec11952a9a 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -42,6 +42,21 @@ }, "sabbath_mode": { "name": "Sabbath" + }, + "machine_clean_reminder": { + "name": "Machine clean reminder" + }, + "signal_level": { + "name": "Chime sound" + }, + "clean_light_reminder": { + "name": "Clean indicator light" + }, + "operation_mode": { + "name": "[%key:component::binary_sensor::entity_component::power::name%]" + }, + "one_touch_filter": { + "name": "Fresh air filter" } } } From 96510721039c6ff846e5593ca000b6b0f0302d7f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 11 Sep 2024 19:57:47 -0500 Subject: [PATCH 0573/1309] Fix audio format for VoIP (#125785) Fix audio format --- .../components/voip/assist_satellite.py | 12 +++++++++++- tests/components/voip/test_voip.py | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 9f117fc9878..f75f65a08ea 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -8,7 +8,7 @@ from functools import partial import io import logging from pathlib import Path -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Any, Final import wave from voip_utils import RtpDatagramProtocol @@ -120,6 +120,16 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol """Return the entity ID of the VAD sensitivity to use for the next conversation.""" return self.voip_device.get_vad_sensitivity_entity_id(self.hass) + @property + def tts_options(self) -> dict[str, Any] | None: + """Options passed for text-to-speech.""" + return { + tts.ATTR_PREFERRED_FORMAT: "wav", + tts.ATTR_PREFERRED_SAMPLE_RATE: 16000, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 1, + tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + } + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index e6a635619a1..edd4d2972f4 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -3,6 +3,7 @@ import asyncio import io from pathlib import Path +from typing import Any from unittest.mock import AsyncMock, Mock, patch import wave @@ -10,7 +11,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from voip_utils import CallInfo -from homeassistant.components import assist_pipeline, assist_satellite, voip +from homeassistant.components import assist_pipeline, assist_satellite, tts, voip from homeassistant.components.assist_satellite.entity import ( AssistSatelliteEntity, AssistSatelliteState, @@ -205,11 +206,24 @@ async def test_pipeline( bad_chunk = bytes([1, 2, 3, 4]) async def async_pipeline_from_audio_stream( - hass: HomeAssistant, context: Context, *args, device_id: str | None, **kwargs + hass: HomeAssistant, + context: Context, + *args, + device_id: str | None, + tts_audio_output: str | dict[str, Any] | None, + **kwargs, ): assert context.user_id == voip_user_id assert device_id == voip_device.device_id + # voip can only stream WAV + assert tts_audio_output == { + tts.ATTR_PREFERRED_FORMAT: "wav", + tts.ATTR_PREFERRED_SAMPLE_RATE: 16000, + tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 1, + tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, + } + stt_stream = kwargs["stt_stream"] event_callback = kwargs["event_callback"] in_command = False From 21d3f150598e9aeb9103b302558a642055141adb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Sep 2024 07:58:05 +0200 Subject: [PATCH 0574/1309] Move growatt_server sensor definitions (#125755) --- .../{sensor.py => sensor/__init__.py} | 14 +++++++------- .../{sensor_types => sensor}/inverter.py | 0 .../growatt_server/{sensor_types => sensor}/mix.py | 0 .../sensor_entity_description.py | 1 - .../{sensor_types => sensor}/storage.py | 0 .../growatt_server/{sensor_types => sensor}/tlx.py | 0 .../{sensor_types => sensor}/total.py | 0 .../growatt_server/sensor_types/__init__.py | 1 - 8 files changed, 7 insertions(+), 9 deletions(-) rename homeassistant/components/growatt_server/{sensor.py => sensor/__init__.py} (97%) rename homeassistant/components/growatt_server/{sensor_types => sensor}/inverter.py (100%) rename homeassistant/components/growatt_server/{sensor_types => sensor}/mix.py (100%) rename homeassistant/components/growatt_server/{sensor_types => sensor}/sensor_entity_description.py (92%) rename homeassistant/components/growatt_server/{sensor_types => sensor}/storage.py (100%) rename homeassistant/components/growatt_server/{sensor_types => sensor}/tlx.py (100%) rename homeassistant/components/growatt_server/{sensor_types => sensor}/total.py (100%) delete mode 100644 homeassistant/components/growatt_server/sensor_types/__init__.py diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor/__init__.py similarity index 97% rename from homeassistant/components/growatt_server/sensor.py rename to homeassistant/components/growatt_server/sensor/__init__.py index 9c680b5d4f8..b0a93879bb3 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle, dt as dt_util -from .const import ( +from ..const import ( CONF_PLANT_ID, DEFAULT_PLANT_ID, DEFAULT_URL, @@ -25,12 +25,12 @@ from .const import ( DOMAIN, LOGIN_INVALID_AUTH_CODE, ) -from .sensor_types.inverter import INVERTER_SENSOR_TYPES -from .sensor_types.mix import MIX_SENSOR_TYPES -from .sensor_types.sensor_entity_description import GrowattSensorEntityDescription -from .sensor_types.storage import STORAGE_SENSOR_TYPES -from .sensor_types.tlx import TLX_SENSOR_TYPES -from .sensor_types.total import TOTAL_SENSOR_TYPES +from .inverter import INVERTER_SENSOR_TYPES +from .mix import MIX_SENSOR_TYPES +from .sensor_entity_description import GrowattSensorEntityDescription +from .storage import STORAGE_SENSOR_TYPES +from .tlx import TLX_SENSOR_TYPES +from .total import TOTAL_SENSOR_TYPES _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/growatt_server/sensor_types/inverter.py b/homeassistant/components/growatt_server/sensor/inverter.py similarity index 100% rename from homeassistant/components/growatt_server/sensor_types/inverter.py rename to homeassistant/components/growatt_server/sensor/inverter.py diff --git a/homeassistant/components/growatt_server/sensor_types/mix.py b/homeassistant/components/growatt_server/sensor/mix.py similarity index 100% rename from homeassistant/components/growatt_server/sensor_types/mix.py rename to homeassistant/components/growatt_server/sensor/mix.py diff --git a/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py b/homeassistant/components/growatt_server/sensor/sensor_entity_description.py similarity index 92% rename from homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py rename to homeassistant/components/growatt_server/sensor/sensor_entity_description.py index 10d00671ba5..e1ee4c30326 100644 --- a/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py +++ b/homeassistant/components/growatt_server/sensor/sensor_entity_description.py @@ -15,7 +15,6 @@ class GrowattRequiredKeysMixin: @dataclass(frozen=True) -# pylint: disable-next=hass-enforce-class-module class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin): """Describes Growatt sensor entity.""" diff --git a/homeassistant/components/growatt_server/sensor_types/storage.py b/homeassistant/components/growatt_server/sensor/storage.py similarity index 100% rename from homeassistant/components/growatt_server/sensor_types/storage.py rename to homeassistant/components/growatt_server/sensor/storage.py diff --git a/homeassistant/components/growatt_server/sensor_types/tlx.py b/homeassistant/components/growatt_server/sensor/tlx.py similarity index 100% rename from homeassistant/components/growatt_server/sensor_types/tlx.py rename to homeassistant/components/growatt_server/sensor/tlx.py diff --git a/homeassistant/components/growatt_server/sensor_types/total.py b/homeassistant/components/growatt_server/sensor/total.py similarity index 100% rename from homeassistant/components/growatt_server/sensor_types/total.py rename to homeassistant/components/growatt_server/sensor/total.py diff --git a/homeassistant/components/growatt_server/sensor_types/__init__.py b/homeassistant/components/growatt_server/sensor_types/__init__.py deleted file mode 100644 index 3f5be3be7f5..00000000000 --- a/homeassistant/components/growatt_server/sensor_types/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Sensor types for supported Growatt systems.""" From b1a777a95af405c74a7a5ba84118514bb7e2d5f2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Sep 2024 08:05:40 +0200 Subject: [PATCH 0575/1309] Move sunweg sensor definitions (#125754) --- .../sunweg/{sensor.py => sensor/__init__.py} | 14 +++++++------- .../sunweg/{sensor_types => sensor}/inverter.py | 0 .../sunweg/{sensor_types => sensor}/phase.py | 0 .../sensor_entity_description.py | 1 - .../sunweg/{sensor_types => sensor}/string.py | 0 .../sunweg/{sensor_types => sensor}/total.py | 0 .../components/sunweg/sensor_types/__init__.py | 1 - tests/components/sunweg/test_init.py | 2 +- 8 files changed, 8 insertions(+), 10 deletions(-) rename homeassistant/components/sunweg/{sensor.py => sensor/__init__.py} (93%) rename homeassistant/components/sunweg/{sensor_types => sensor}/inverter.py (100%) rename homeassistant/components/sunweg/{sensor_types => sensor}/phase.py (100%) rename homeassistant/components/sunweg/{sensor_types => sensor}/sensor_entity_description.py (92%) rename homeassistant/components/sunweg/{sensor_types => sensor}/string.py (100%) rename homeassistant/components/sunweg/{sensor_types => sensor}/total.py (100%) delete mode 100644 homeassistant/components/sunweg/sensor_types/__init__.py diff --git a/homeassistant/components/sunweg/sensor.py b/homeassistant/components/sunweg/sensor/__init__.py similarity index 93% rename from homeassistant/components/sunweg/sensor.py rename to homeassistant/components/sunweg/sensor/__init__.py index 004dd7276a7..e582b5135d3 100644 --- a/homeassistant/components/sunweg/sensor.py +++ b/homeassistant/components/sunweg/sensor/__init__.py @@ -17,13 +17,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SunWEGData -from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType -from .sensor_types.inverter import INVERTER_SENSOR_TYPES -from .sensor_types.phase import PHASE_SENSOR_TYPES -from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription -from .sensor_types.string import STRING_SENSOR_TYPES -from .sensor_types.total import TOTAL_SENSOR_TYPES +from .. import SunWEGData +from ..const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType +from .inverter import INVERTER_SENSOR_TYPES +from .phase import PHASE_SENSOR_TYPES +from .sensor_entity_description import SunWEGSensorEntityDescription +from .string import STRING_SENSOR_TYPES +from .total import TOTAL_SENSOR_TYPES _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sunweg/sensor_types/inverter.py b/homeassistant/components/sunweg/sensor/inverter.py similarity index 100% rename from homeassistant/components/sunweg/sensor_types/inverter.py rename to homeassistant/components/sunweg/sensor/inverter.py diff --git a/homeassistant/components/sunweg/sensor_types/phase.py b/homeassistant/components/sunweg/sensor/phase.py similarity index 100% rename from homeassistant/components/sunweg/sensor_types/phase.py rename to homeassistant/components/sunweg/sensor/phase.py diff --git a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py b/homeassistant/components/sunweg/sensor/sensor_entity_description.py similarity index 92% rename from homeassistant/components/sunweg/sensor_types/sensor_entity_description.py rename to homeassistant/components/sunweg/sensor/sensor_entity_description.py index 1d06f04ab3d..8c792ab617f 100644 --- a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py +++ b/homeassistant/components/sunweg/sensor/sensor_entity_description.py @@ -15,7 +15,6 @@ class SunWEGRequiredKeysMixin: @dataclass(frozen=True) -# pylint: disable-next=hass-enforce-class-module class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): """Describes SunWEG sensor entity.""" diff --git a/homeassistant/components/sunweg/sensor_types/string.py b/homeassistant/components/sunweg/sensor/string.py similarity index 100% rename from homeassistant/components/sunweg/sensor_types/string.py rename to homeassistant/components/sunweg/sensor/string.py diff --git a/homeassistant/components/sunweg/sensor_types/total.py b/homeassistant/components/sunweg/sensor/total.py similarity index 100% rename from homeassistant/components/sunweg/sensor_types/total.py rename to homeassistant/components/sunweg/sensor/total.py diff --git a/homeassistant/components/sunweg/sensor_types/__init__.py b/homeassistant/components/sunweg/sensor_types/__init__.py deleted file mode 100644 index f370fddd16b..00000000000 --- a/homeassistant/components/sunweg/sensor_types/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Sensor types for supported Sun WEG systems.""" diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py index 41edda38a5a..6cbe38a128b 100644 --- a/tests/components/sunweg/test_init.py +++ b/tests/components/sunweg/test_init.py @@ -7,7 +7,7 @@ from sunweg.api import APIHelper, SunWegApiError from homeassistant.components.sunweg import SunWEGData from homeassistant.components.sunweg.const import DOMAIN, DeviceType -from homeassistant.components.sunweg.sensor_types.sensor_entity_description import ( +from homeassistant.components.sunweg.sensor.sensor_entity_description import ( SunWEGSensorEntityDescription, ) from homeassistant.config_entries import ConfigEntryState From e89b2589708ae4e7ea1b08471548bea852c5d04c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 12 Sep 2024 09:07:01 +0200 Subject: [PATCH 0576/1309] Disable ESPHome assist_in_progress binary sensor (#125802) --- .../components/esphome/binary_sensor.py | 1 + tests/components/esphome/test_binary_sensor.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 32d96785601..0f404445486 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -74,6 +74,7 @@ class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntit """A binary sensor implementation for ESPHome for use with assist_pipeline.""" entity_description = BinarySensorEntityDescription( + entity_registry_enabled_default=False, key="assist_in_progress", translation_key="assist_in_progress", ) diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index 3da8a54ff34..a28e55de87f 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -15,12 +15,14 @@ import pytest from homeassistant.components.esphome import DomainData from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import MockESPHomeDevice from tests.common import MockConfigEntry +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_assist_in_progress( hass: HomeAssistant, mock_voice_assistant_v1_entry, @@ -44,6 +46,20 @@ async def test_assist_in_progress( assert state.state == "off" +async def test_assist_in_progress_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_voice_assistant_v1_entry, +) -> None: + """Test assist in progress binary sensor is added disabled.""" + + assert not hass.states.get("binary_sensor.test_assist_in_progress") + entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + @pytest.mark.parametrize( "binary_state", [(True, STATE_ON), (False, STATE_OFF), (None, STATE_UNKNOWN)] ) From da401cafdffc095e89f9cf6587d21fff659ff643 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 12 Sep 2024 09:28:36 +0200 Subject: [PATCH 0577/1309] Add support for cover tilt for Shelly 2PM Gen3 (#125717) * Add support for tilt * Fix config * Add test * Increase test coverage --- homeassistant/components/shelly/cover.py | 35 +++++++++++ tests/components/shelly/test_cover.py | 76 ++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 395df95735b..09e8279bf9b 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -9,6 +9,7 @@ from aioshelly.const import RPC_GENERATIONS from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityFeature, @@ -157,6 +158,13 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity): self._id = id_ if self.status["pos_control"]: self._attr_supported_features |= CoverEntityFeature.SET_POSITION + if coordinator.device.config[f"cover:{id_}"].get("slat", {}).get("enable"): + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) @property def is_closed(self) -> bool | None: @@ -171,6 +179,14 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity): return cast(int, self.status["current_pos"]) + @property + def current_cover_tilt_position(self) -> int | None: + """Return current position of cover tilt.""" + if "slat_pos" not in self.status: + return None + + return cast(int, self.status["slat_pos"]) + @property def is_closing(self) -> bool: """Return if the cover is closing.""" @@ -198,3 +214,22 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity): async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" await self.call_rpc("Cover.Stop", {"id": self._id}) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + await self.call_rpc("Cover.GoToPosition", {"id": self._id, "slat_pos": 100}) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + await self.call_rpc("Cover.GoToPosition", {"id": self._id, "slat_pos": 0}) + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + await self.call_rpc( + "Cover.GoToPosition", + {"id": self._id, "slat_pos": kwargs[ATTR_TILT_POSITION]}, + ) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self.call_rpc("Cover.Stop", {"id": self._id}) diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index cd5efb76cfe..f2b8567f540 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -1,17 +1,24 @@ """Tests for Shelly cover platform.""" +from copy import deepcopy from unittest.mock import Mock import pytest from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, @@ -187,3 +194,72 @@ async def test_rpc_device_no_position_control( ) await init_integration(hass, 2) assert hass.states.get("cover.test_cover_0").state == STATE_OPEN + + +async def test_rpc_cover_tilt( + hass: HomeAssistant, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + entity_registry: EntityRegistry, +) -> None: + """Test RPC cover that supports tilt.""" + entity_id = "cover.test_cover_0" + + config = deepcopy(mock_rpc_device.config) + config["cover:0"]["slat"] = {"enable": True} + monkeypatch.setattr(mock_rpc_device, "config", config) + + status = deepcopy(mock_rpc_device.status) + status["cover:0"]["slat_pos"] = 0 + monkeypatch.setattr(mock_rpc_device, "status", status) + + await init_integration(hass, 3) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-cover:0" + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_TILT_POSITION: 50}, + blocking=True, + ) + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 50) + mock_rpc_device.mock_update() + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 100) + mock_rpc_device.mock_update() + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mutate_rpc_device_status(monkeypatch, mock_rpc_device, "cover:0", "slat_pos", 10) + mock_rpc_device.mock_update() + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 From c21ea6b8da25b5ed0128a0737fcb612724e7b6dc Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 12 Sep 2024 10:13:19 +0200 Subject: [PATCH 0578/1309] Validate target temp features in Climate Entity (#125180) * Validate target temp features in Climate Entity * Soften * Break long string --- homeassistant/components/climate/__init__.py | 38 ++++++++++ tests/components/climate/test_init.py | 78 ++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 38d8e89269a..b0a9c5a4c5a 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -933,6 +933,44 @@ async def async_service_temperature_set( entity: ClimateEntity, service_call: ServiceCall ) -> None: """Handle set temperature service.""" + if ( + ATTR_TEMPERATURE in service_call.data + and not entity.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE + ): + 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. Please %s" + ), + entity.platform.platform_name, + entity.__class__.__name__, + report_issue, + ) + if ( + ATTR_TARGET_TEMP_LOW in service_call.data + and not entity.supported_features + & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ): + 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. Please %s" + ), + entity.platform.platform_name, + entity.__class__.__name__, + report_issue, + ) + hass = entity.hass kwargs = {} min_temp = entity.min_temp diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 6342313d1da..b3f26dc775f 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -110,6 +110,9 @@ class MockClimateEntity(MockEntity, ClimateEntity): _attr_swing_mode = "auto" _attr_swing_modes = ["auto", "off"] _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature = 20 + _attr_target_temperature_high = 25 + _attr_target_temperature_low = 15 @property def hvac_mode(self) -> HVACMode: @@ -143,6 +146,14 @@ class MockClimateEntity(MockEntity, ClimateEntity): """Set new target hvac mode.""" self._attr_hvac_mode = hvac_mode + def set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if ATTR_TEMPERATURE in kwargs: + self._attr_target_temperature = kwargs[ATTR_TEMPERATURE] + if ATTR_TARGET_TEMP_HIGH in kwargs: + self._attr_target_temperature_high = kwargs[ATTR_TARGET_TEMP_HIGH] + self._attr_target_temperature_low = kwargs[ATTR_TARGET_TEMP_LOW] + class MockClimateEntityTestMethods(MockClimateEntity): """Mock Climate device.""" @@ -242,6 +253,73 @@ def test_deprecated_current_constants( ) +async def test_temperature_features_is_valid( + hass: HomeAssistant, + register_test_integration: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test correct features for setting temperature.""" + + class MockClimateTempEntity(MockClimateEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + + class MockClimateTempRangeEntity(MockClimateEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return ClimateEntityFeature.TARGET_TEMPERATURE + + climate_temp_entity = MockClimateTempEntity( + name="test", entity_id="climate.test_temp" + ) + climate_temp_range_entity = MockClimateTempRangeEntity( + name="test", entity_id="climate.test_range" + ) + + setup_test_component_platform( + hass, + DOMAIN, + entities=[climate_temp_entity, climate_temp_range_entity], + from_config_entry=True, + ) + 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. Please" + ) in caplog.text + + 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. Please" + ) in caplog.text + + async def test_mode_validation( hass: HomeAssistant, register_test_integration: MockConfigEntry, From 70ebf2f5d8b1fa322a63068580547e014c9a7007 Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Thu, 12 Sep 2024 11:06:18 +0100 Subject: [PATCH 0579/1309] Accept more than 1 state for numeric entities in Bayesian (#119281) * test driven delevopment * test driven development - multi numeric state * better multi-state processing * when state==below return true * adds test for a bad state * improve codecov * value error already handled in async_numeric_state * remove whitespace * remove async_get * linting * test_driven dev for error handling * make tests fail correctly * ensure tests fail correctly * prevent bad numeric entries * ensure no overlapping ranges * fix tests, as error caught in validation * remove redundant er call * remove reddundant arg * improves code coverage * filter for numeric states before testing overlap * adress code review * skip non numeric configs but continue * wait to avoid race condition * Better tuples name and better guard clause * better test description * more accurate description * Add comments to calculations * using typing not collections as per ruff * Apply suggestions from code review Co-authored-by: Erik Montnemery * follow on from suggestions * Lazy evaluation Co-authored-by: Erik Montnemery * update error text in tests * fix broken tests * move validation function call * fixes return type of above_greater_than_below. * improves codecov * fixes validation --------- Co-authored-by: Erik Montnemery --- .../components/bayesian/binary_sensor.py | 149 +++++-- homeassistant/components/bayesian/const.py | 1 + homeassistant/components/bayesian/helpers.py | 1 + .../components/bayesian/test_binary_sensor.py | 400 +++++++++++++++--- 4 files changed, 464 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 192d7987311..6d203c344f2 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -5,7 +5,8 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Callable import logging -from typing import Any +import math +from typing import TYPE_CHECKING, Any, NamedTuple from uuid import UUID import voluptuous as vol @@ -50,6 +51,7 @@ from .const import ( ATTR_OCCURRED_OBSERVATION_ENTITIES, ATTR_PROBABILITY, ATTR_PROBABILITY_THRESHOLD, + CONF_NUMERIC_STATE, CONF_OBSERVATIONS, CONF_P_GIVEN_F, CONF_P_GIVEN_T, @@ -66,18 +68,74 @@ from .issues import raise_mirrored_entries, raise_no_prob_given_false _LOGGER = logging.getLogger(__name__) -NUMERIC_STATE_SCHEMA = vol.Schema( - { - CONF_PLATFORM: "numeric_state", - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_ABOVE): vol.Coerce(float), - vol.Optional(CONF_BELOW): vol.Coerce(float), - vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), - vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float), - }, - required=True, +def _above_greater_than_below(config: dict[str, Any]) -> dict[str, Any]: + if config[CONF_PLATFORM] == CONF_NUMERIC_STATE: + above = config.get(CONF_ABOVE) + below = config.get(CONF_BELOW) + if above is None and below is None: + _LOGGER.error( + "For bayesian numeric state for entity: %s at least one of 'above' or 'below' must be specified", + config[CONF_ENTITY_ID], + ) + raise vol.Invalid( + "For bayesian numeric state at least one of 'above' or 'below' must be specified." + ) + if above is not None and below is not None: + if above > below: + _LOGGER.error( + "For bayesian numeric state 'above' (%s) must be less than 'below' (%s)", + above, + below, + ) + raise vol.Invalid("'above' is greater than 'below'") + return config + + +NUMERIC_STATE_SCHEMA = vol.All( + vol.Schema( + { + CONF_PLATFORM: CONF_NUMERIC_STATE, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ABOVE): vol.Coerce(float), + vol.Optional(CONF_BELOW): vol.Coerce(float), + vol.Required(CONF_P_GIVEN_T): vol.Coerce(float), + vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float), + }, + required=True, + ), + _above_greater_than_below, ) + +def _no_overlapping(configs: list[dict]) -> list[dict]: + numeric_configs = [ + config for config in configs if config[CONF_PLATFORM] == CONF_NUMERIC_STATE + ] + if len(numeric_configs) < 2: + return configs + + class NumericConfig(NamedTuple): + above: float + below: float + + d: dict[str, list[NumericConfig]] = {} + for _, config in enumerate(numeric_configs): + above = config.get(CONF_ABOVE, -math.inf) + below = config.get(CONF_BELOW, math.inf) + entity_id: str = str(config[CONF_ENTITY_ID]) + d.setdefault(entity_id, []).append(NumericConfig(above, below)) + + for ent_id, intervals in d.items(): + intervals = sorted(intervals, key=lambda tup: tup.above) + + for i, tup in enumerate(intervals): + if len(intervals) > i + 1 and tup.below > intervals[i + 1].above: + raise vol.Invalid( + f"Ranges for bayesian numeric state entities must not overlap, but {ent_id} has overlapping ranges, above:{tup.above}, below:{tup.below} overlaps with above:{intervals[i+1].above}, below:{intervals[i+1].below}." + ) + return configs + + STATE_SCHEMA = vol.Schema( { CONF_PLATFORM: CONF_STATE, @@ -107,7 +165,8 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( vol.Required(CONF_OBSERVATIONS): vol.Schema( vol.All( cv.ensure_list, - [vol.Any(NUMERIC_STATE_SCHEMA, STATE_SCHEMA, TEMPLATE_SCHEMA)], + [vol.Any(TEMPLATE_SCHEMA, STATE_SCHEMA, NUMERIC_STATE_SCHEMA)], + _no_overlapping, ) ), vol.Required(CONF_PRIOR): vol.Coerce(float), @@ -211,10 +270,11 @@ class BayesianBinarySensor(BinarySensorEntity): self.observations_by_entity = self._build_observations_by_entity() self.observations_by_template = self._build_observations_by_template() - self.observation_handlers: dict[str, Callable[[Observation], bool | None]] = { + self.observation_handlers: dict[ + str, Callable[[Observation, bool], bool | None] + ] = { "numeric_state": self._process_numeric_state, "state": self._process_state, - "multi_state": self._process_multi_state, } async def async_added_to_hass(self) -> None: @@ -342,8 +402,9 @@ class BayesianBinarySensor(BinarySensorEntity): for observation in self.observations_by_entity[entity]: platform = observation.platform - observation.observed = self.observation_handlers[platform](observation) - + observation.observed = self.observation_handlers[platform]( + observation, observation.multi + ) local_observations[observation.id] = observation return local_observations @@ -408,9 +469,7 @@ class BayesianBinarySensor(BinarySensorEntity): if len(entity_observations) == 1: continue for observation in entity_observations: - if observation.platform != "state": - continue - observation.platform = "multi_state" + observation.multi = True return observations_by_entity @@ -437,14 +496,23 @@ class BayesianBinarySensor(BinarySensorEntity): return observations_by_template - def _process_numeric_state(self, entity_observation: Observation) -> bool | None: + def _process_numeric_state( + self, entity_observation: Observation, multi: bool = False + ) -> bool | None: """Return True if numeric condition is met, return False if not, return None otherwise.""" - entity = entity_observation.entity_id + entity_id = entity_observation.entity_id + # if we are dealing with numeric_state observations entity_id cannot be None + if TYPE_CHECKING: + assert entity_id is not None + + entity = self.hass.states.get(entity_id) + if entity is None: + return None try: if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]): return None - return condition.async_numeric_state( + result = condition.async_numeric_state( self.hass, entity, entity_observation.below, @@ -452,10 +520,24 @@ class BayesianBinarySensor(BinarySensorEntity): None, entity_observation.to_dict(), ) + if result: + return True + if multi: + state = float(entity.state) + if ( + entity_observation.below is not None + and state == entity_observation.below + ): + return True + return None except ConditionError: return None + else: + return False - def _process_state(self, entity_observation: Observation) -> bool | None: + def _process_state( + self, entity_observation: Observation, multi: bool = False + ) -> bool | None: """Return True if state conditions are met, return False if they are not. Returns None if the state is unavailable. @@ -467,24 +549,13 @@ class BayesianBinarySensor(BinarySensorEntity): if condition.state(self.hass, entity, [STATE_UNKNOWN, STATE_UNAVAILABLE]): return None - return condition.state(self.hass, entity, entity_observation.to_state) + result = condition.state(self.hass, entity, entity_observation.to_state) + if multi and not result: + return None except ConditionError: return None - - def _process_multi_state(self, entity_observation: Observation) -> bool | None: - """Return True if state conditions are met, otherwise return None. - - Never return False as all other states should have their own probabilities configured. - """ - - entity = entity_observation.entity_id - - try: - if condition.state(self.hass, entity, entity_observation.to_state): - return True - except ConditionError: - return None - return None + else: + return result @property def extra_state_attributes(self) -> dict[str, Any]: diff --git a/homeassistant/components/bayesian/const.py b/homeassistant/components/bayesian/const.py index 5d3f978cedc..cac4237b4ec 100644 --- a/homeassistant/components/bayesian/const.py +++ b/homeassistant/components/bayesian/const.py @@ -8,6 +8,7 @@ ATTR_PROBABILITY_THRESHOLD = "probability_threshold" CONF_OBSERVATIONS = "observations" CONF_PRIOR = "prior" CONF_TEMPLATE = "template" +CONF_NUMERIC_STATE = "numeric_state" CONF_PROBABILITY_THRESHOLD = "probability_threshold" CONF_P_GIVEN_F = "prob_given_false" CONF_P_GIVEN_T = "prob_given_true" diff --git a/homeassistant/components/bayesian/helpers.py b/homeassistant/components/bayesian/helpers.py index cc8966a90b6..2af3a331775 100644 --- a/homeassistant/components/bayesian/helpers.py +++ b/homeassistant/components/bayesian/helpers.py @@ -33,6 +33,7 @@ class Observation: below: float | None value_template: Template | None observed: bool | None = None + multi: bool = False id: uuid.UUID = field(default_factory=uuid.uuid4) def to_dict(self) -> dict[str, str | float | bool | None]: diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py index 818e9bed909..a8723ae5d30 100644 --- a/tests/components/bayesian/test_binary_sensor.py +++ b/tests/components/bayesian/test_binary_sensor.py @@ -1,6 +1,7 @@ """The test for the bayesian sensor platform.""" import json +from logging import WARNING from unittest.mock import patch import pytest @@ -20,16 +21,14 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from tests.common import get_fixture_path -async def test_load_values_when_added_to_hass( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: +async def test_load_values_when_added_to_hass(hass: HomeAssistant) -> None: """Test that sensor initializes with observations of relevant entities.""" config = { @@ -58,11 +57,6 @@ async def test_load_values_when_added_to_hass( assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() - assert ( - entity_registry.entities["binary_sensor.test_binary"].unique_id - == "bayesian-3b4c9563-5e84-4167-8fe7-8f507e796d72" - ) - state = hass.states.get("binary_sensor.test_binary") assert state.attributes.get("device_class") == "connectivity" assert state.attributes.get("observations")[0]["prob_given_true"] == 0.8 @@ -331,6 +325,75 @@ async def test_sensor_value_template(hass: HomeAssistant) -> None: assert state.state == "off" +async def test_mixed_states(hass: HomeAssistant) -> None: + """Test sensor on probability threshold limits.""" + config = { + "binary_sensor": { + "name": "should_HVAC", + "platform": "bayesian", + "observations": [ + { + "platform": "template", + "value_template": "{{states('sensor.guest_sensor') != 'off'}}", + "prob_given_true": 0.3, + "prob_given_false": 0.15, + }, + { + "platform": "state", + "entity_id": "sensor.anyone_home", + "to_state": "on", + "prob_given_true": 0.6, + "prob_given_false": 0.05, + }, + { + "platform": "numeric_state", + "entity_id": "sensor.temperature", + "below": 24, + "above": 19, + "prob_given_true": 0.1, + "prob_given_false": 0.6, + }, + ], + "prior": 0.3, + "probability_threshold": 0.5, + } + } + assert await async_setup_component(hass, "binary_sensor", config) + await hass.async_block_till_done() + + hass.states.async_set("sensor.guest_sensor", "UNKNOWN") + hass.states.async_set("sensor.anyone_home", "on") + hass.states.async_set("sensor.temperature", 15) + + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.should_HVAC") + + assert set(state.attributes.get("occurred_observation_entities")) == { + "sensor.anyone_home", + "sensor.temperature", + } + template_obs = { + "platform": "template", + "value_template": "{{states('sensor.guest_sensor') != 'off'}}", + "prob_given_true": 0.3, + "prob_given_false": 0.15, + "observed": True, + } + assert template_obs in state.attributes.get("observations") + + assert abs(0.95857988 - state.attributes.get("probability")) < 0.01 + # A = binary_sensor.should_HVAC being TRUE, P(A) being the prior + # B = value_template evaluating to TRUE + # Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ). + # Calculated where P(A) = 0.3, P(B|A) = 0.3 , P(B|notA) = 0.15 = 0.46153846 + # Step 2, prior is now 0.46153846, B now refers to sensor.anyone_home=='on' + # P(A) = 0.46153846, P(B|A) = 0.6 , P(B|notA) = 0.05, result = 0.91139240 + # Step 3, prior is now 0.91139240, B now refers to sensor.temperature in range [19,24] + # However since the temp is 15 we take the inverse probability for this negative observation + # P(A) = 0.91139240, P(B|A) = (1-0.1) , P(B|notA) = (1-0.6), result = 0.95857988 + + async def test_threshold(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None: """Test sensor on probability threshold limits.""" config = { @@ -367,7 +430,7 @@ async def test_threshold(hass: HomeAssistant, issue_registry: ir.IssueRegistry) async def test_multiple_observations(hass: HomeAssistant) -> None: """Test sensor with multiple observations of same entity. - these entries should be labelled as 'multi_state' and negative observations ignored - as the outcome is not known to be binary. + these entries should be labelled as 'state' and negative observations ignored - as the outcome is not known to be binary. Before the merge of #67631 this practice was a common work-around for bayesian's ignoring of negative observations, this also preserves that function """ @@ -436,83 +499,203 @@ async def test_multiple_observations(hass: HomeAssistant) -> None: # Calculated using bayes theorum where P(A) = 0.2, P(B|A) = 0.2, P(B|notA) = 0.6 assert state.state == "off" - assert state.attributes.get("observations")[0]["platform"] == "multi_state" - assert state.attributes.get("observations")[1]["platform"] == "multi_state" + assert state.attributes.get("observations")[0]["platform"] == "state" + assert state.attributes.get("observations")[1]["platform"] == "state" -async def test_multiple_numeric_observations(hass: HomeAssistant) -> None: - """Test sensor with multiple numeric observations of same entity.""" +async def test_multiple_numeric_observations( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test sensor on numeric state platform observations with more than one range. + + This tests an example where the probability of it being a 'nice day' varies over + a series of temperatures. Since this is a multi-state, all the non-observed ranges + should be ignored and only the range including the observed value should update + the prior. When a value lands on above or below (15 is tested) it is included if it + equals `below`, and ignored if it equals `above`. + """ config = { "binary_sensor": { "platform": "bayesian", - "name": "Test_Binary", + "name": "nice_day", "observations": [ { "platform": "numeric_state", - "entity_id": "sensor.test_monitored", - "below": 10, - "above": 0, - "prob_given_true": 0.4, - "prob_given_false": 0.0001, + "entity_id": "sensor.test_temp", + "below": 0, + "prob_given_true": 0.05, + "prob_given_false": 0.2, }, { "platform": "numeric_state", - "entity_id": "sensor.test_monitored", - "below": 100, - "above": 30, - "prob_given_true": 0.6, - "prob_given_false": 0.0001, + "entity_id": "sensor.test_temp", + "below": 10, + "above": 0, + "prob_given_true": 0.1, + "prob_given_false": 0.25, + }, + { + "platform": "numeric_state", + "entity_id": "sensor.test_temp", + "below": 15, + "above": 10, + "prob_given_true": 0.2, + "prob_given_false": 0.35, + }, + { + "platform": "numeric_state", + "entity_id": "sensor.test_temp", + "below": 25, + "above": 15, + "prob_given_true": 0.5, + "prob_given_false": 0.15, + }, + { + "platform": "numeric_state", + "entity_id": "sensor.test_temp", + "above": 25, + "prob_given_true": 0.15, + "prob_given_false": 0.05, }, ], - "prior": 0.1, + "prior": 0.3, } } - assert await async_setup_component(hass, "binary_sensor", config) await hass.async_block_till_done() - hass.states.async_set("sensor.test_monitored", STATE_UNKNOWN) + hass.states.async_set("sensor.test_temp", -5) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_binary") + state = hass.states.get("binary_sensor.nice_day") for attrs in state.attributes.values(): json.dumps(attrs) - assert state.attributes.get("occurred_observation_entities") == [] + assert state.attributes.get("occurred_observation_entities") == ["sensor.test_temp"] assert state.attributes.get("probability") == 0.1 + # No observations made so probability should be the prior + assert state.attributes.get("occurred_observation_entities") == ["sensor.test_temp"] + assert abs(state.attributes.get("probability") - 0.09677) < 0.01 + # A = binary_sensor.nice_day being TRUE + # B = sensor.test_temp in the range (, 0] + # Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ). + # Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false + # Calculated using P(A) = 0.3, P(B|A) = 0.05, P(B|~A) = 0.2 -> 0.09677 + # Because >1 range is defined for sensor.test_temp we should not infer anything from the + # ranges not observed + assert state.state == "off" + + hass.states.async_set("sensor.test_temp", 5) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.nice_day") + + assert state.attributes.get("occurred_observation_entities") == ["sensor.test_temp"] + assert abs(state.attributes.get("probability") - 0.14634146) < 0.01 + # A = binary_sensor.nice_day being TRUE + # B = sensor.test_temp in the range (0, 10] + # Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ). + # Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false + # Calculated using P(A) = 0.3, P(B|A) = 0.1, P(B|~A) = 0.25 -> 0.14634146 + # Because >1 range is defined for sensor.test_temp we should not infer anything from the + # ranges not observed assert state.state == "off" - hass.states.async_set("sensor.test_monitored", 20) + hass.states.async_set("sensor.test_temp", 12) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_binary") - - assert state.attributes.get("occurred_observation_entities") == [ - "sensor.test_monitored" - ] - assert round(abs(0.026 - state.attributes.get("probability")), 7) < 0.01 - # Step 1 Calculated where P(A) = 0.1, P(~B|A) = 0.6 (negative obs), P(~B|notA) = 0.9999 -> 0.0625 - # Step 2 P(A) = 0.0625, P(B|A) = 0.4 (negative obs), P(B|notA) = 0.9999 -> 0.26 + state = hass.states.get("binary_sensor.nice_day") + assert abs(state.attributes.get("probability") - 0.19672131) < 0.01 + # A = binary_sensor.nice_day being TRUE + # B = sensor.test_temp in the range (10, 15] + # Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ). + # Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false + # Calculated using P(A) = 0.3, P(B|A) = 0.2, P(B|~A) = 0.35 -> 0.19672131 + # Because >1 range is defined for sensor.test_temp we should not infer anything from the + # ranges not observed assert state.state == "off" - hass.states.async_set("sensor.test_monitored", 35) + hass.states.async_set("sensor.test_temp", 22) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_binary") - assert state.attributes.get("occurred_observation_entities") == [ - "sensor.test_monitored" - ] - assert abs(1 - state.attributes.get("probability")) < 0.01 - # Step 1 Calculated where P(A) = 0.1, P(~B|A) = 0.6 (negative obs), P(~B|notA) = 0.9999 -> 0.0625 - # Step 2 P(A) = 0.0625, P(B|A) = 0.6, P(B|notA) = 0.0001 -> 0.9975 + state = hass.states.get("binary_sensor.nice_day") + assert abs(state.attributes.get("probability") - 0.58823529) < 0.01 + # A = binary_sensor.nice_day being TRUE + # B = sensor.test_temp in the range (15, 25] + # Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ). + # Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false + # Calculated using P(A) = 0.3, P(B|A) = 0.5, P(B|~A) = 0.15 -> 0.58823529 + # Because >1 range is defined for sensor.test_temp we should not infer anything from the + # ranges not observed assert state.state == "on" + + hass.states.async_set("sensor.test_temp", 30) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.nice_day") + assert abs(state.attributes.get("probability") - 0.562500) < 0.01 + # A = binary_sensor.nice_day being TRUE + # B = sensor.test_temp in the range (25, ] + # Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ). + # Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false + # Calculated using P(A) = 0.3, P(B|A) = 0.15, P(B|~A) = 0.05 -> 0.562500 + # Because >1 range is defined for sensor.test_temp we should not infer anything from the + # ranges not observed + + assert state.state == "on" + + # Edge cases + # if on a threshold only one observation should be included and not both + hass.states.async_set("sensor.test_temp", 15) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.nice_day") + + assert state.attributes.get("occurred_observation_entities") == ["sensor.test_temp"] + + assert abs(state.attributes.get("probability") - 0.19672131) < 0.01 + # Where there are multi numeric ranges when on the threshold, use below + # A = binary_sensor.nice_day being TRUE + # B = sensor.test_temp in the range (10, 15] + # Bayes theorum is P(A|B) = P(B|A) * P(A) / ( P(B|A)*P(A) + P(B|~A)*P(~A) ). + # Where P(B|A) is prob_given_true and P(B|~A) is prob_given_false + # Calculated using P(A) = 0.3, P(B|A) = 0.2, P(B|~A) = 0.35 -> 0.19672131 + # Because >1 range is defined for sensor.test_temp we should not infer anything from the + # ranges not observed + + assert state.state == "off" + + assert len(issue_registry.issues) == 0 assert state.attributes.get("observations")[0]["platform"] == "numeric_state" - assert state.attributes.get("observations")[1]["platform"] == "numeric_state" + + hass.states.async_set("sensor.test_temp", "badstate") + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.nice_day") + + assert state.attributes.get("occurred_observation_entities") == [] + assert state.state == "off" + + hass.states.async_set("sensor.test_temp", STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.nice_day") + + assert state.attributes.get("occurred_observation_entities") == [] + assert state.state == "off" + + hass.states.async_set("sensor.test_temp", STATE_UNKNOWN) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.nice_day") + + assert state.attributes.get("occurred_observation_entities") == [] + assert state.state == "off" async def test_mirrored_observations( @@ -651,6 +834,127 @@ async def test_missing_prob_given_false( ) +async def test_bad_multi_numeric( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test whether missing prob_given_false are detected and appropriate issues are created.""" + + config = { + "binary_sensor": { + "platform": "bayesian", + "name": "bins_out", + "observations": [ + { + "platform": "numeric_state", + "entity_id": "sensor.signal_strength", + "above": 10, + "prob_given_true": 0.01, + "prob_given_false": 0.3, + }, + { + "platform": "numeric_state", + "entity_id": "sensor.signal_strength", + "above": 5, + "below": 10, + "prob_given_true": 0.02, + "prob_given_false": 0.5, + }, + { + "platform": "numeric_state", + "entity_id": "sensor.signal_strength", + "above": 0, + "below": 6, # overlaps + "prob_given_true": 0.07, + "prob_given_false": 0.1, + }, + { + "platform": "numeric_state", + "entity_id": "sensor.signal_strength", + "above": -10, + "below": 0, + "prob_given_true": 0.3, + "prob_given_false": 0.07, + }, + { + "platform": "numeric_state", + "entity_id": "sensor.signal_strength", + "below": -10, + "prob_given_true": 0.6, + "prob_given_false": 0.03, + }, + ], + "prior": 0.2, + } + } + caplog.clear() + caplog.set_level(WARNING) + + assert await async_setup_component(hass, "binary_sensor", config) + + assert "entities must not overlap" in caplog.text + + +async def test_inverted_numeric( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test whether missing prob_given_false are detected and appropriate logs are created.""" + + config = { + "binary_sensor": { + "platform": "bayesian", + "name": "goldilocks_zone", + "observations": [ + { + "platform": "numeric_state", + "entity_id": "sensor.temp", + "above": 23, + "below": 20, + "prob_given_true": 0.9, + "prob_given_false": 0.2, + }, + ], + "prior": 0.4, + } + } + + assert await async_setup_component(hass, "binary_sensor", config) + assert ( + "bayesian numeric state 'above' (23.0) must be less than 'below' (20.0)" + in caplog.text + ) + + +async def test_no_value_numeric( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test whether missing prob_given_false are detected and appropriate logs are created.""" + + config = { + "binary_sensor": { + "platform": "bayesian", + "name": "goldilocks_zone", + "observations": [ + { + "platform": "numeric_state", + "entity_id": "sensor.temp", + "prob_given_true": 0.9, + "prob_given_false": 0.2, + }, + ], + "prior": 0.4, + } + } + + assert await async_setup_component(hass, "binary_sensor", config) + assert "at least one of 'above' or 'below' must be specified" in caplog.text + + async def test_probability_updates(hass: HomeAssistant) -> None: """Test probability update function.""" prob_given_true = [0.3, 0.6, 0.8] From 02e392e215c9d24a3490fcbd7a8f7c6681b45887 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 12 Sep 2024 11:50:46 +0100 Subject: [PATCH 0580/1309] Finish cleanup of deprecated ring update service (#125810) --- homeassistant/components/ring/__init__.py | 11 +---------- homeassistant/components/ring/icons.json | 5 ----- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 2901a904dc4..992544b1e18 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -99,21 +99,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - if hass.services.has_service(DOMAIN, "update"): - return True - return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Ring entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if len(hass.config_entries.async_loaded_entries(DOMAIN)) == 1: - # This is the last loaded entry, clean up service - hass.services.async_remove(DOMAIN, "update") - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_remove_config_entry_device( diff --git a/homeassistant/components/ring/icons.json b/homeassistant/components/ring/icons.json index 5820fbf77c8..b765293ec04 100644 --- a/homeassistant/components/ring/icons.json +++ b/homeassistant/components/ring/icons.json @@ -37,10 +37,5 @@ "default": "mdi:alarm-bell" } } - }, - "services": { - "update": { - "service": "mdi:refresh" - } } } From 4e1b865775a7aada4b486caba6fc136c90d33d55 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Thu, 12 Sep 2024 14:13:23 +0200 Subject: [PATCH 0581/1309] Remove manufacturer name from Wake on LAN device_info (#123836) Remove made up manufacturer --- homeassistant/components/wake_on_lan/button.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/wake_on_lan/button.py b/homeassistant/components/wake_on_lan/button.py index 87135a61380..4d6b19bdd8e 100644 --- a/homeassistant/components/wake_on_lan/button.py +++ b/homeassistant/components/wake_on_lan/button.py @@ -60,7 +60,6 @@ class WolButton(ButtonEntity): self._attr_unique_id = dr.format_mac(mac_address) self._attr_device_info = dr.DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, self._attr_unique_id)}, - default_manufacturer="Wake on LAN", default_name=name, ) From 1a478bd78ab832fa7c7435bae91e5356536e3368 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:55:29 +0200 Subject: [PATCH 0582/1309] Use root import for media_player and media_source in tests (#125829) --- tests/components/dlna_dms/test_device_availability.py | 4 ++-- tests/components/dlna_dms/test_dms_device_source.py | 5 ++--- tests/components/jellyfin/test_media_source.py | 2 +- tests/components/nest/test_media_source.py | 2 +- tests/components/reolink/test_media_source.py | 2 +- tests/components/system_bridge/test_media_source.py | 2 +- tests/components/tts/test_media_source.py | 2 +- tests/components/universal/test_media_player.py | 7 +++++-- 8 files changed, 14 insertions(+), 12 deletions(-) diff --git a/tests/components/dlna_dms/test_device_availability.py b/tests/components/dlna_dms/test_device_availability.py index c1ad3c91a7b..1be68f91733 100644 --- a/tests/components/dlna_dms/test_device_availability.py +++ b/tests/components/dlna_dms/test_device_availability.py @@ -15,8 +15,8 @@ import pytest from homeassistant.components import media_source, ssdp from homeassistant.components.dlna_dms.const import DOMAIN from homeassistant.components.dlna_dms.dms import get_domain_data -from homeassistant.components.media_player.errors import BrowseError -from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_source import Unresolvable from homeassistant.core import HomeAssistant from .conftest import ( diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index 23d9e6927ae..7907d40c415 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -13,9 +13,8 @@ import pytest from homeassistant.components import media_source, ssdp from homeassistant.components.dlna_dms.const import DLNA_SORT_CRITERIA, DOMAIN from homeassistant.components.dlna_dms.dms import DidlPlayMedia -from homeassistant.components.media_player.errors import BrowseError -from homeassistant.components.media_source.error import Unresolvable -from homeassistant.components.media_source.models import BrowseMediaSource +from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_source import BrowseMediaSource, Unresolvable from homeassistant.core import HomeAssistant from .conftest import ( diff --git a/tests/components/jellyfin/test_media_source.py b/tests/components/jellyfin/test_media_source.py index a57d51de1f1..2aca59a4d26 100644 --- a/tests/components/jellyfin/test_media_source.py +++ b/tests/components/jellyfin/test_media_source.py @@ -6,7 +6,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.jellyfin.const import DOMAIN -from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import ( DOMAIN as MEDIA_SOURCE_DOMAIN, URI_SCHEME, diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 4bc3559e308..101bfae089d 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -17,7 +17,7 @@ from google_nest_sdm.event import EventMessage import numpy as np import pytest -from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import ( URI_SCHEME, Unresolvable, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 6351f683545..494432d0412 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -10,10 +10,10 @@ from reolink_aio.exceptions import ReolinkError from homeassistant.components.media_source import ( DOMAIN as MEDIA_SOURCE_DOMAIN, URI_SCHEME, + Unresolvable, async_browse_media, async_resolve_media, ) -from homeassistant.components.media_source.error import Unresolvable from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import CONF_USE_HTTPS, DOMAIN from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN diff --git a/tests/components/system_bridge/test_media_source.py b/tests/components/system_bridge/test_media_source.py index 161d69569b6..58ee4ebe05c 100644 --- a/tests/components/system_bridge/test_media_source.py +++ b/tests/components/system_bridge/test_media_source.py @@ -4,7 +4,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import paths -from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_player import BrowseError from homeassistant.components.media_source import ( DOMAIN as MEDIA_SOURCE_DOMAIN, URI_SCHEME, diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index ba856fd9622..81bbfcfed8a 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock import pytest from homeassistant.components import media_source -from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_player import BrowseError from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 7c992814cfe..5ebfd2c13ad 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -8,8 +8,11 @@ from voluptuous.error import MultipleInvalid from homeassistant import config as hass_config from homeassistant.components import input_number, input_select, media_player, switch -from homeassistant.components.media_player import MediaClass, MediaPlayerEntityFeature -from homeassistant.components.media_player.browse_media import BrowseMedia +from homeassistant.components.media_player import ( + BrowseMedia, + MediaClass, + MediaPlayerEntityFeature, +) import homeassistant.components.universal.media_player as universal from homeassistant.const import ( SERVICE_RELOAD, From e27cee53a8829d65be23775f84fda21562d7c794 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:33:27 +0200 Subject: [PATCH 0583/1309] Improve type hints in ads (#125825) * Improve type hints in ads * One more * Adjust --- homeassistant/components/ads/binary_sensor.py | 9 +++++++- homeassistant/components/ads/cover.py | 23 ++++++++++--------- homeassistant/components/ads/entity.py | 16 +++++++++---- homeassistant/components/ads/light.py | 9 +++++++- homeassistant/components/ads/valve.py | 9 +++----- 5 files changed, 42 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/ads/binary_sensor.py b/homeassistant/components/ads/binary_sensor.py index 4704026e454..72a12506dc1 100644 --- a/homeassistant/components/ads/binary_sensor.py +++ b/homeassistant/components/ads/binary_sensor.py @@ -19,6 +19,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE from .entity import AdsEntity +from .hub import AdsHub DEFAULT_NAME = "ADS binary sensor" PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( @@ -50,7 +51,13 @@ def setup_platform( class AdsBinarySensor(AdsEntity, BinarySensorEntity): """Representation of ADS binary sensors.""" - def __init__(self, ads_hub, name, ads_var, device_class): + def __init__( + self, + ads_hub: AdsHub, + name: str, + ads_var: str, + device_class: BinarySensorDeviceClass | None, + ) -> None: """Initialize ADS binary sensor.""" super().__init__(ads_hub, name, ads_var) self._attr_device_class = device_class or BinarySensorDeviceClass.MOVING diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index 31c1eac5d18..541f8bfc82c 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -23,6 +23,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE from .entity import AdsEntity +from .hub import AdsHub DEFAULT_NAME = "ADS Cover" @@ -57,7 +58,7 @@ def setup_platform( """Set up the cover platform for ADS.""" ads_hub = hass.data[DATA_ADS] - ads_var_is_closed: str | None = config.get(CONF_ADS_VAR) + ads_var_is_closed: str = config[CONF_ADS_VAR] ads_var_position: str | None = config.get(CONF_ADS_VAR_POSITION) ads_var_pos_set: str | None = config.get(CONF_ADS_VAR_SET_POS) ads_var_open: str | None = config.get(CONF_ADS_VAR_OPEN) @@ -88,16 +89,16 @@ class AdsCover(AdsEntity, CoverEntity): def __init__( self, - ads_hub, - ads_var_is_closed, - ads_var_position, - ads_var_pos_set, - ads_var_open, - ads_var_close, - ads_var_stop, - name, - device_class, - ): + ads_hub: AdsHub, + ads_var_is_closed: str, + ads_var_position: str | None, + ads_var_pos_set: str | None, + ads_var_open: str | None, + ads_var_close: str | None, + ads_var_stop: str | None, + name: str, + device_class: CoverDeviceClass | None, + ) -> None: """Initialize AdsCover entity.""" super().__init__(ads_hub, name, ads_var_is_closed) if self._attr_unique_id is None: diff --git a/homeassistant/components/ads/entity.py b/homeassistant/components/ads/entity.py index 3973d279a22..f51ede2bbc8 100644 --- a/homeassistant/components/ads/entity.py +++ b/homeassistant/components/ads/entity.py @@ -3,10 +3,12 @@ import asyncio from asyncio import timeout import logging +from typing import Any from homeassistant.helpers.entity import Entity from .const import STATE_KEY_STATE +from .hub import AdsHub _LOGGER = logging.getLogger(__name__) @@ -16,19 +18,23 @@ class AdsEntity(Entity): _attr_should_poll = False - def __init__(self, ads_hub, name, ads_var): + def __init__(self, ads_hub: AdsHub, name: str, ads_var: str) -> None: """Initialize ADS binary sensor.""" - self._state_dict = {} + self._state_dict: dict[str, Any] = {} self._state_dict[STATE_KEY_STATE] = None self._ads_hub = ads_hub self._ads_var = ads_var - self._event = None + self._event: asyncio.Event | None = None self._attr_unique_id = ads_var self._attr_name = name async def async_initialize_device( - self, ads_var, plctype, state_key=STATE_KEY_STATE, factor=None - ): + self, + ads_var: str, + plctype: type, + state_key: str = STATE_KEY_STATE, + factor: int | None = None, + ) -> None: """Register device notification.""" def update(name, value): diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py index 17e94923b01..5ea4868bf11 100644 --- a/homeassistant/components/ads/light.py +++ b/homeassistant/components/ads/light.py @@ -21,6 +21,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE from .entity import AdsEntity +from .hub import AdsHub CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness" STATE_KEY_BRIGHTNESS = "brightness" @@ -54,7 +55,13 @@ def setup_platform( class AdsLight(AdsEntity, LightEntity): """Representation of ADS light.""" - def __init__(self, ads_hub, ads_var_enable, ads_var_brightness, name): + def __init__( + self, + ads_hub: AdsHub, + ads_var_enable: str, + ads_var_brightness: str | None, + name: str, + ) -> None: """Initialize AdsLight entity.""" super().__init__(ads_hub, name, ads_var_enable) self._state_dict[STATE_KEY_BRIGHTNESS] = None diff --git a/homeassistant/components/ads/valve.py b/homeassistant/components/ads/valve.py index f20e21477db..b94215ec9ea 100644 --- a/homeassistant/components/ads/valve.py +++ b/homeassistant/components/ads/valve.py @@ -45,11 +45,8 @@ def setup_platform( ads_var: str = config[CONF_ADS_VAR] name: str = config[CONF_NAME] device_class: ValveDeviceClass | None = config.get(CONF_DEVICE_CLASS) - supported_features: ValveEntityFeature = ( - ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE - ) - entity = AdsValve(ads_hub, ads_var, name, device_class, supported_features) + entity = AdsValve(ads_hub, ads_var, name, device_class) add_entities([entity]) @@ -57,18 +54,18 @@ def setup_platform( class AdsValve(AdsEntity, ValveEntity): """Representation of an ADS valve entity.""" + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + def __init__( self, ads_hub: AdsHub, ads_var: str, name: str, device_class: ValveDeviceClass | None, - supported_features: ValveEntityFeature, ) -> None: """Initialize AdsValve entity.""" super().__init__(ads_hub, name, ads_var) self._attr_device_class = device_class - self._attr_supported_features = supported_features self._attr_reports_position = False self._attr_is_closed = True From 4afc472068630c829717efa03fd9989c2903bd69 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:38:53 +0200 Subject: [PATCH 0584/1309] Use root import for media_player and media_source (#125828) * Use root import for media_player and media_source * One more --- homeassistant/components/androidtv_remote/media_player.py | 2 +- homeassistant/components/arcam_fmj/media_player.py | 2 +- homeassistant/components/braviatv/media_player.py | 2 +- homeassistant/components/camera/media_source.py | 4 ++-- homeassistant/components/dlna_dms/dms.py | 7 +++++-- homeassistant/components/dlna_dms/media_source.py | 4 ++-- homeassistant/components/forked_daapd/browse_media.py | 8 ++++++-- homeassistant/components/image/media_source.py | 4 ++-- homeassistant/components/jellyfin/browse_media.py | 8 ++++++-- homeassistant/components/jellyfin/media_player.py | 2 +- homeassistant/components/jellyfin/media_source.py | 2 +- homeassistant/components/linkplay/media_player.py | 2 -- homeassistant/components/media_source/__init__.py | 2 -- homeassistant/components/motioneye/media_source.py | 5 +++-- homeassistant/components/nest/media_source.py | 4 ++-- homeassistant/components/netatmo/media_source.py | 5 +++-- homeassistant/components/radio_browser/media_source.py | 4 ++-- homeassistant/components/reolink/media_source.py | 4 ++-- homeassistant/components/roon/media_browser.py | 3 +-- homeassistant/components/sonos/exception.py | 2 +- homeassistant/components/system_bridge/media_source.py | 5 +++-- homeassistant/components/unifiprotect/media_source.py | 2 +- homeassistant/components/universal/media_player.py | 2 +- homeassistant/components/xbox/media_source.py | 2 +- 24 files changed, 48 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index 554aa2f2946..cdc307a0472 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -8,6 +8,7 @@ from typing import Any from androidtvremote2 import AndroidTVRemote, ConnectionClosed from homeassistant.components.media_player import ( + BrowseMedia, MediaClass, MediaPlayerDeviceClass, MediaPlayerEntity, @@ -15,7 +16,6 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 00b46a7024a..7a133777a0a 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -11,6 +11,7 @@ from arcam.fmj import ConnectionFailed, SourceCodes from arcam.fmj.state import State from homeassistant.components.media_player import ( + BrowseError, BrowseMedia, MediaClass, MediaPlayerEntity, @@ -18,7 +19,6 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.components.media_player.errors import BrowseError from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index 8d45cf4a439..4de167a6def 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -7,6 +7,7 @@ from typing import Any from homeassistant.components.media_player import ( BrowseError, + BrowseMedia, MediaClass, MediaPlayerDeviceClass, MediaPlayerEntity, @@ -14,7 +15,6 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py index 4bb6ed5f921..958235c684d 100644 --- a/homeassistant/components/camera/media_source.py +++ b/homeassistant/components/camera/media_source.py @@ -5,12 +5,12 @@ from __future__ import annotations import asyncio from homeassistant.components.media_player import BrowseError, MediaClass -from homeassistant.components.media_source.error import Unresolvable -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia, + Unresolvable, ) from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER from homeassistant.const import ATTR_FRIENDLY_NAME diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index afff1152cca..6a81fa46f74 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -20,8 +20,11 @@ from didl_lite import didl_lite from homeassistant.components import ssdp from homeassistant.components.media_player import BrowseError, MediaClass -from homeassistant.components.media_source.error import Unresolvable -from homeassistant.components.media_source.models import BrowseMediaSource, PlayMedia +from homeassistant.components.media_source import ( + BrowseMediaSource, + PlayMedia, + Unresolvable, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_URL from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/dlna_dms/media_source.py b/homeassistant/components/dlna_dms/media_source.py index 399398fa5b9..f5bb440f978 100644 --- a/homeassistant/components/dlna_dms/media_source.py +++ b/homeassistant/components/dlna_dms/media_source.py @@ -13,11 +13,11 @@ Media identifiers can look like: from __future__ import annotations from homeassistant.components.media_player import BrowseError, MediaClass, MediaType -from homeassistant.components.media_source.error import Unresolvable -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, MediaSourceItem, + Unresolvable, ) from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/forked_daapd/browse_media.py b/homeassistant/components/forked_daapd/browse_media.py index f2c62b80234..35ad0ed49b0 100644 --- a/homeassistant/components/forked_daapd/browse_media.py +++ b/homeassistant/components/forked_daapd/browse_media.py @@ -7,8 +7,12 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast from urllib.parse import quote, unquote -from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType -from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + MediaType, +) from homeassistant.helpers.network import is_internal_request from .const import CAN_PLAY_TYPE, URI_SCHEMA diff --git a/homeassistant/components/image/media_source.py b/homeassistant/components/image/media_source.py index e7f240aef5c..882249ef940 100644 --- a/homeassistant/components/image/media_source.py +++ b/homeassistant/components/image/media_source.py @@ -5,12 +5,12 @@ from __future__ import annotations from typing import cast from homeassistant.components.media_player import BrowseError, MediaClass -from homeassistant.components.media_source.error import Unresolvable -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia, + Unresolvable, ) from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant, State diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index 2af2bac4875..e5648b0a34f 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -7,8 +7,12 @@ from typing import Any from jellyfin_apiclient_python import JellyfinClient -from homeassistant.components.media_player import BrowseError, MediaClass, MediaType -from homeassistant.components.media_player.browse_media import BrowseMedia +from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, + MediaType, +) from homeassistant.core import HomeAssistant from .client_wrapper import get_artwork_url diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index d24d15f1dfa..96a058c726e 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -5,13 +5,13 @@ from __future__ import annotations from typing import Any from homeassistant.components.media_player import ( + BrowseMedia, MediaPlayerEntity, MediaPlayerEntityDescription, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) -from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index 4b3e8b0146a..0a462be5d61 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -11,7 +11,7 @@ from jellyfin_apiclient_python.api import jellyfin_url from jellyfin_apiclient_python.client import JellyfinClient from homeassistant.components.media_player import BrowseError, MediaClass -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, MediaSourceItem, diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 02341f99970..35b3a86f1c6 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -20,8 +20,6 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, RepeatMode, -) -from homeassistant.components.media_player.browse_media import ( async_process_play_media_url, ) from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 732a1d834f0..604f9b7cc88 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -13,8 +13,6 @@ from homeassistant.components.media_player import ( CONTENT_AUTH_EXPIRY_TIME, BrowseError, BrowseMedia, -) -from homeassistant.components.media_player.browse_media import ( async_process_play_media_url, ) from homeassistant.components.websocket_api import ActiveConnection diff --git a/homeassistant/components/motioneye/media_source.py b/homeassistant/components/motioneye/media_source.py index 7c12b84f255..7a5ed6646d5 100644 --- a/homeassistant/components/motioneye/media_source.py +++ b/homeassistant/components/motioneye/media_source.py @@ -9,12 +9,13 @@ from typing import cast from motioneye_client.const import KEY_MEDIA_LIST, KEY_MIME_TYPE, KEY_PATH from homeassistant.components.media_player import MediaClass, MediaType -from homeassistant.components.media_source.error import MediaSourceError, Unresolvable -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, + MediaSourceError, MediaSourceItem, PlayMedia, + Unresolvable, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index 71501e72552..146b6f2479e 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -37,12 +37,12 @@ from google_nest_sdm.transcoder import Transcoder from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.components.media_player import BrowseError, MediaClass, MediaType -from homeassistant.components.media_source.error import Unresolvable -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia, + Unresolvable, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index 7ad4acf5316..f92214c90f5 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -7,12 +7,13 @@ import logging import re from homeassistant.components.media_player import BrowseError, MediaClass, MediaType -from homeassistant.components.media_source.error import MediaSourceError, Unresolvable -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, + MediaSourceError, MediaSourceItem, PlayMedia, + Unresolvable, ) from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py index 2f95acf407d..8d2822ed50f 100644 --- a/homeassistant/components/radio_browser/media_source.py +++ b/homeassistant/components/radio_browser/media_source.py @@ -8,12 +8,12 @@ from radios import FilterBy, Order, RadioBrowser, Station from radios.radio_browser import pycountry from homeassistant.components.media_player import MediaClass, MediaType -from homeassistant.components.media_source.error import Unresolvable -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia, + Unresolvable, ) from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 3c5d60030a3..57c2a695c77 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -10,12 +10,12 @@ from reolink_aio.enums import VodRequestType from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings from homeassistant.components.media_player import MediaClass, MediaType -from homeassistant.components.media_source.error import Unresolvable -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia, + Unresolvable, ) from homeassistant.components.stream import create_stream from homeassistant.config_entries import ConfigEntryState diff --git a/homeassistant/components/roon/media_browser.py b/homeassistant/components/roon/media_browser.py index 806375bc902..13b2d9594e8 100644 --- a/homeassistant/components/roon/media_browser.py +++ b/homeassistant/components/roon/media_browser.py @@ -2,8 +2,7 @@ import logging -from homeassistant.components.media_player import BrowseMedia, MediaClass -from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_player import BrowseError, BrowseMedia, MediaClass class UnknownMediaType(BrowseError): diff --git a/homeassistant/components/sonos/exception.py b/homeassistant/components/sonos/exception.py index 6f7483f4188..4fd17d84392 100644 --- a/homeassistant/components/sonos/exception.py +++ b/homeassistant/components/sonos/exception.py @@ -1,6 +1,6 @@ """Sonos specific exceptions.""" -from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_player import BrowseError from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/system_bridge/media_source.py b/homeassistant/components/system_bridge/media_source.py index cd0ef8ee60f..53bc4f32506 100644 --- a/homeassistant/components/system_bridge/media_source.py +++ b/homeassistant/components/system_bridge/media_source.py @@ -7,8 +7,9 @@ from systembridgemodels.media_files import MediaFile, MediaFiles from systembridgemodels.media_get_files import MediaGetFiles from homeassistant.components.media_player import MediaClass -from homeassistant.components.media_source import MEDIA_CLASS_MAP, MEDIA_MIME_TYPES -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( + MEDIA_CLASS_MAP, + MEDIA_MIME_TYPES, BrowseMediaSource, MediaSource, MediaSourceItem, diff --git a/homeassistant/components/unifiprotect/media_source.py b/homeassistant/components/unifiprotect/media_source.py index a646c037d62..1e36b59d641 100644 --- a/homeassistant/components/unifiprotect/media_source.py +++ b/homeassistant/components/unifiprotect/media_source.py @@ -14,7 +14,7 @@ from yarl import URL from homeassistant.components.camera import CameraImageView from homeassistant.components.media_player import BrowseError, MediaClass -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, MediaSourceItem, diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index dda5230466a..25188eb3a5d 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -41,13 +41,13 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, + BrowseMedia, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, RepeatMode, ) -from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, diff --git a/homeassistant/components/xbox/media_source.py b/homeassistant/components/xbox/media_source.py index a63f3b2027b..4478502b4ca 100644 --- a/homeassistant/components/xbox/media_source.py +++ b/homeassistant/components/xbox/media_source.py @@ -13,7 +13,7 @@ from xbox.webapi.api.provider.screenshots.models import ScreenshotResponse from xbox.webapi.api.provider.smartglass.models import InstalledPackage from homeassistant.components.media_player import MediaClass -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSource, MediaSourceItem, From a4c88a8591b8c26def88aec8bb61c4c1405d657b Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Thu, 12 Sep 2024 10:09:53 -0400 Subject: [PATCH 0585/1309] Add entity available attribute to Cambridge Audio (#125831) * Bump aiostreammagic to 2.2.4 * Move callback handling to entity class * Wrap all module exceptions in HA errors for Cambridge Audio --- .../components/cambridge_audio/entity.py | 39 +++++++++++++++++++ .../cambridge_audio/media_player.py | 30 +++++++------- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/cambridge_audio/entity.py b/homeassistant/components/cambridge_audio/entity.py index 5ea9c7ab685..afdc88f53e0 100644 --- a/homeassistant/components/cambridge_audio/entity.py +++ b/homeassistant/components/cambridge_audio/entity.py @@ -1,13 +1,38 @@ """Base class for Cambridge Audio entities.""" +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate + from aiostreammagic import StreamMagicClient +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity +from . import STREAM_MAGIC_EXCEPTIONS from .const import DOMAIN +def command[_EntityT: CambridgeAudioEntity, **_P]( + func: Callable[Concatenate[_EntityT, _P], Awaitable[None]], +) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: + """Wrap async calls to raise on request error.""" + + @wraps(func) + async def decorator(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except STREAM_MAGIC_EXCEPTIONS as exc: + raise HomeAssistantError( + f"Error executing {func.__name__} on entity {self.entity_id}," + ) from exc + + return decorator + + class CambridgeAudioEntity(Entity): """Defines a base Cambridge Audio entity.""" @@ -24,3 +49,17 @@ class CambridgeAudioEntity(Entity): serial_number=client.info.unit_id, configuration_url=f"http://{client.host}", ) + + @callback + async def _state_update_callback(self, _client: StreamMagicClient) -> None: + """Call when the device is notified of changes.""" + self._attr_available = _client.is_connected() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """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.""" + await self.client.unregister_state_update_callbacks(self._state_update_callback) diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index c1f7cfcc4bc..aa6053d349f 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import CambridgeAudioEntity +from .entity import CambridgeAudioEntity, command BASE_FEATURES = ( MediaPlayerEntityFeature.SELECT_SOURCE @@ -70,18 +70,6 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): super().__init__(client) self._attr_unique_id = client.info.unit_id - async def _state_update_callback(self, _client: StreamMagicClient) -> None: - """Call when the device is notified of changes.""" - self.schedule_update_ha_state() - - async def async_added_to_hass(self) -> None: - """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.""" - await self.client.unregister_state_update_callbacks(self._state_update_callback) - @property def supported_features(self) -> MediaPlayerEntityFeature: """Supported features for the media player.""" @@ -194,10 +182,12 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): mode_repeat = RepeatMode.ALL return mode_repeat + @command async def async_media_play_pause(self) -> None: """Toggle play/pause the current media.""" await self.client.play_pause() + @command async def async_media_pause(self) -> None: """Pause the current media.""" controls = self.client.now_playing.controls @@ -209,10 +199,12 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): else: await self.client.pause() + @command async def async_media_stop(self) -> None: """Stop the current media.""" await self.client.stop() + @command async def async_media_play(self) -> None: """Play the current media.""" controls = self.client.now_playing.controls @@ -224,14 +216,17 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): else: await self.client.play() + @command async def async_media_next_track(self) -> None: """Skip to the next track.""" await self.client.next_track() + @command async def async_media_previous_track(self) -> None: """Skip to the previous track.""" await self.client.previous_track() + @command async def async_select_source(self, source: str) -> None: """Select the source.""" for src in self.client.sources: @@ -239,34 +234,42 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): await self.client.set_source_by_id(src.id) break + @command async def async_turn_on(self) -> None: """Power on the device.""" await self.client.power_on() + @command async def async_turn_off(self) -> None: """Power off the device.""" await self.client.power_off() + @command async def async_volume_up(self) -> None: """Step the volume up.""" await self.client.volume_up() + @command async def async_volume_down(self) -> None: """Step the volume down.""" await self.client.volume_down() + @command async def async_set_volume_level(self, volume: float) -> None: """Set the volume level.""" await self.client.set_volume(int(volume * 100)) + @command async def async_mute_volume(self, mute: bool) -> None: """Set the mute state.""" await self.client.set_mute(mute) + @command async def async_media_seek(self, position: float) -> None: """Seek to a position in the current media.""" await self.client.media_seek(int(position)) + @command async def async_set_shuffle(self, shuffle: bool) -> None: """Set the shuffle mode for the current queue.""" shuffle_mode = ShuffleMode.OFF @@ -274,6 +277,7 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): shuffle_mode = ShuffleMode.ALL await self.client.set_shuffle(shuffle_mode) + @command async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set the repeat mode for the current queue.""" repeat_mode = CambridgeRepeatMode.OFF From 6ef1dd56f582e1887af11ecf95adf812e5ef1a5b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Sep 2024 17:01:25 +0200 Subject: [PATCH 0586/1309] Use root import for device_automation (#125836) --- homeassistant/components/bthome/device_trigger.py | 4 ++-- homeassistant/components/deconz/device_trigger.py | 4 ++-- homeassistant/components/hue/device_trigger.py | 4 +--- homeassistant/components/hue/v1/device_trigger.py | 4 ++-- homeassistant/components/knx/device_trigger.py | 4 ++-- homeassistant/components/lg_netcast/device_trigger.py | 4 ++-- homeassistant/components/nest/device_trigger.py | 4 ++-- homeassistant/components/netatmo/device_trigger.py | 4 ++-- homeassistant/components/rfxtrx/device_action.py | 4 +--- homeassistant/components/rfxtrx/device_trigger.py | 4 ++-- homeassistant/components/samsungtv/device_trigger.py | 4 ++-- homeassistant/components/sensor/device_condition.py | 4 +--- homeassistant/components/sensor/device_trigger.py | 4 +--- homeassistant/components/shelly/device_trigger.py | 4 ++-- homeassistant/components/webostv/device_trigger.py | 4 ++-- homeassistant/components/zha/device_trigger.py | 4 ++-- homeassistant/components/zwave_js/device_condition.py | 4 +--- homeassistant/components/zwave_js/device_trigger.py | 4 ++-- 18 files changed, 31 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index 4eca110e581..d60089a9bf5 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.components.homeassistant.triggers import event as event_trigger diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index ec988feb3cf..e31fdc66db2 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -4,8 +4,8 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.components.homeassistant.triggers import event as event_trigger diff --git a/homeassistant/components/hue/device_trigger.py b/homeassistant/components/hue/device_trigger.py index 4104c667d74..dba5aba81da 100644 --- a/homeassistant/components/hue/device_trigger.py +++ b/homeassistant/components/hue/device_trigger.py @@ -4,9 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from homeassistant.components.device_automation.exceptions import ( - InvalidDeviceAutomationConfig, -) +from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import CALLBACK_TYPE from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/hue/v1/device_trigger.py b/homeassistant/components/hue/v1/device_trigger.py index 554926cdc70..493c668f549 100644 --- a/homeassistant/components/hue/v1/device_trigger.py +++ b/homeassistant/components/hue/v1/device_trigger.py @@ -6,8 +6,8 @@ from typing import TYPE_CHECKING import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.components.homeassistant.triggers import event as event_trigger diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index 96d8855f479..2eb1f86e7fc 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -6,8 +6,8 @@ from typing import Any, Final import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE diff --git a/homeassistant/components/lg_netcast/device_trigger.py b/homeassistant/components/lg_netcast/device_trigger.py index 51c5ec53004..d1808b3e536 100644 --- a/homeassistant/components/lg_netcast/device_trigger.py +++ b/homeassistant/components/lg_netcast/device_trigger.py @@ -6,8 +6,8 @@ from typing import Any import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE diff --git a/homeassistant/components/nest/device_trigger.py b/homeassistant/components/nest/device_trigger.py index 52c756d6a18..d2d36b6e529 100644 --- a/homeassistant/components/nest/device_trigger.py +++ b/homeassistant/components/nest/device_trigger.py @@ -4,8 +4,8 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.components.homeassistant.triggers import event as event_trigger diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index 686df2ef2cb..2673ebf8e05 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -4,8 +4,8 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.components.homeassistant.triggers import event as event_trigger diff --git a/homeassistant/components/rfxtrx/device_action.py b/homeassistant/components/rfxtrx/device_action.py index 65cf1a11911..405daa37ec5 100644 --- a/homeassistant/components/rfxtrx/device_action.py +++ b/homeassistant/components/rfxtrx/device_action.py @@ -6,9 +6,7 @@ from collections.abc import Callable import voluptuous as vol -from homeassistant.components.device_automation.exceptions import ( - InvalidDeviceAutomationConfig, -) +from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import Context, HomeAssistant import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/rfxtrx/device_trigger.py b/homeassistant/components/rfxtrx/device_trigger.py index 9e42cfa3919..35c1944948b 100644 --- a/homeassistant/components/rfxtrx/device_trigger.py +++ b/homeassistant/components/rfxtrx/device_trigger.py @@ -4,8 +4,8 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.components.homeassistant.triggers import event as event_trigger diff --git a/homeassistant/components/samsungtv/device_trigger.py b/homeassistant/components/samsungtv/device_trigger.py index 0e5c6608a17..2b3d9dbe666 100644 --- a/homeassistant/components/samsungtv/device_trigger.py +++ b/homeassistant/components/samsungtv/device_trigger.py @@ -4,8 +4,8 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 21258db2ac5..f2b51899312 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -5,10 +5,8 @@ from __future__ import annotations import voluptuous as vol from homeassistant.components.device_automation import ( - async_get_entity_registry_entry_or_raise, -) -from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, + async_get_entity_registry_entry_or_raise, ) from homeassistant.const import ( CONF_ABOVE, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 0ffc42127bc..b07b3fac11e 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -4,10 +4,8 @@ import voluptuous as vol from homeassistant.components.device_automation import ( DEVICE_TRIGGER_BASE_SCHEMA, - async_get_entity_registry_entry_or_raise, -) -from homeassistant.components.device_automation.exceptions import ( InvalidDeviceAutomationConfig, + async_get_entity_registry_entry_or_raise, ) from homeassistant.components.homeassistant.triggers import ( numeric_state as numeric_state_trigger, diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index 9aa57fa1d15..6e96eb5ed21 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -6,8 +6,8 @@ from typing import Final import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.components.homeassistant.triggers import event as event_trigger diff --git a/homeassistant/components/webostv/device_trigger.py b/homeassistant/components/webostv/device_trigger.py index 17d92b1abf3..f16b1cec4f5 100644 --- a/homeassistant/components/webostv/device_trigger.py +++ b/homeassistant/components/webostv/device_trigger.py @@ -4,8 +4,8 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index a134d2aa59b..8e8509e62a5 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -3,8 +3,8 @@ import voluptuous as vol from zha.application.const import ZHA_EVENT -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.components.homeassistant.triggers import event as event_trigger diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py index dcd42d4d85d..8a50c838eec 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -8,9 +8,7 @@ import voluptuous as vol from zwave_js_server.const import CommandClass from zwave_js_server.model.value import ConfigurationValue -from homeassistant.components.device_automation.exceptions import ( - InvalidDeviceAutomationConfig, -) +from homeassistant.components.device_automation import InvalidDeviceAutomationConfig from homeassistant.const import CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index 49027d4d43b..661d4557694 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -7,8 +7,8 @@ from typing import Any import voluptuous as vol from zwave_js_server.const import CommandClass -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import ( +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, InvalidDeviceAutomationConfig, ) from homeassistant.components.homeassistant.triggers import event, state From 2c210e4b580158385c63e1ec2aa7453f92e6ae24 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Sep 2024 19:31:57 +0200 Subject: [PATCH 0587/1309] Use root import for websocket_api (#125834) --- .../components/application_credentials/__init__.py | 2 +- homeassistant/components/calendar/__init__.py | 7 +++++-- homeassistant/components/config/category_registry.py | 2 +- homeassistant/components/config/device_registry.py | 2 +- homeassistant/components/config/entity_registry.py | 3 +-- homeassistant/components/config/floor_registry.py | 2 +- homeassistant/components/config/label_registry.py | 2 +- homeassistant/components/device_automation/__init__.py | 2 +- homeassistant/components/frontend/__init__.py | 2 +- homeassistant/components/frontend/storage.py | 2 +- homeassistant/components/hassio/websocket_api.py | 2 +- homeassistant/components/history/websocket_api.py | 3 +-- homeassistant/components/logbook/websocket_api.py | 3 +-- homeassistant/components/logger/websocket_api.py | 2 +- homeassistant/components/network/websocket.py | 2 +- homeassistant/components/usb/__init__.py | 2 +- homeassistant/components/zha/websocket_api.py | 2 +- 17 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 22deb124859..623706ce5bb 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -15,7 +15,7 @@ from typing import Any, Protocol import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index b94a6eb935f..3e33f077e93 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -16,8 +16,11 @@ from dateutil.rrule import rrulestr import voluptuous as vol from homeassistant.components import frontend, http, websocket_api -from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ( + ERR_NOT_FOUND, + ERR_NOT_SUPPORTED, + ActiveConnection, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import ( diff --git a/homeassistant/components/config/category_registry.py b/homeassistant/components/config/category_registry.py index ade35fddadc..27268928823 100644 --- a/homeassistant/components/config/category_registry.py +++ b/homeassistant/components/config/category_registry.py @@ -5,7 +5,7 @@ from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import category_registry as cr, config_validation as cv diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 8bc9133b0df..8b114041672 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant import loader from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.decorators import require_admin +from homeassistant.components.websocket_api import require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index bf7a9087d56..aed04943975 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -8,8 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import websocket_api -from homeassistant.components.websocket_api import ERR_NOT_FOUND -from homeassistant.components.websocket_api.decorators import require_admin +from homeassistant.components.websocket_api import ERR_NOT_FOUND, require_admin from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, diff --git a/homeassistant/components/config/floor_registry.py b/homeassistant/components/config/floor_registry.py index f3c9793d25e..afa74e7f9b8 100644 --- a/homeassistant/components/config/floor_registry.py +++ b/homeassistant/components/config/floor_registry.py @@ -5,7 +5,7 @@ from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import floor_registry as fr from homeassistant.helpers.floor_registry import FloorEntry diff --git a/homeassistant/components/config/label_registry.py b/homeassistant/components/config/label_registry.py index d02b9849d46..f60a3fca245 100644 --- a/homeassistant/components/config/label_registry.py +++ b/homeassistant/components/config/label_registry.py @@ -5,7 +5,7 @@ from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, label_registry as lr from homeassistant.helpers.label_registry import LabelEntry diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 5e196f40aa1..b54fe788a3d 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -15,7 +15,7 @@ import voluptuous as vol import voluptuous_serialize from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index c5df84cf549..e6e26a661ae 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -16,7 +16,7 @@ from yarl import URL from homeassistant.components import onboarding, websocket_api from homeassistant.components.http import KEY_HASS, HomeAssistantView, StaticPathConfig -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.config import async_hass_config_yaml from homeassistant.const import ( CONF_MODE, diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index d387e14b085..cbcc3024aa7 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -9,7 +9,7 @@ from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 03ca424035c..954d9ee8a02 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -8,7 +8,7 @@ from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 465416607a2..c85d975c3c9 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -13,8 +13,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance, history -from homeassistant.components.websocket_api import messages -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection, messages from homeassistant.const import ( COMPRESSED_STATE_ATTRIBUTES, COMPRESSED_STATE_LAST_CHANGED, diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index b776ad6303d..b295b845532 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -13,8 +13,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance -from homeassistant.components.websocket_api import messages -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection, messages from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import json_bytes diff --git a/homeassistant/components/logger/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 6d34b10bd34..2430f187a6f 100644 --- a/homeassistant/components/logger/websocket_api.py +++ b/homeassistant/components/logger/websocket_api.py @@ -5,7 +5,7 @@ from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.setup import async_get_loaded_integrations diff --git a/homeassistant/components/network/websocket.py b/homeassistant/components/network/websocket.py index 78626b893e4..b97bd2d58d1 100644 --- a/homeassistant/components/network/websocket.py +++ b/homeassistant/components/network/websocket.py @@ -7,7 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback from .const import ATTR_ADAPTERS, ATTR_CONFIGURED_ADAPTERS, NETWORK_CONFIG_SCHEMA diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index d4201d7f284..2da72d16ac6 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -16,7 +16,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import websocket_api -from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api import ActiveConnection from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import ( CALLBACK_TYPE, diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 0d4296e4b22..5ffd7117d93 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -94,7 +94,7 @@ from .helpers import ( ) if TYPE_CHECKING: - from homeassistant.components.websocket_api.connection import ActiveConnection + from homeassistant.components.websocket_api import ActiveConnection _LOGGER = logging.getLogger(__name__) From 57e1709782c87430bbb2893bf0aed00c49a75c23 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 12 Sep 2024 20:27:42 +0200 Subject: [PATCH 0588/1309] Remove deprecated YAML import from rova (#125849) --- homeassistant/components/rova/config_flow.py | 28 ----- homeassistant/components/rova/sensor.py | 70 +------------ homeassistant/components/rova/strings.json | 8 -- tests/components/rova/test_config_flow.py | 103 +------------------ 4 files changed, 4 insertions(+), 205 deletions(-) diff --git a/homeassistant/components/rova/config_flow.py b/homeassistant/components/rova/config_flow.py index a28e6202466..c25737160f4 100644 --- a/homeassistant/components/rova/config_flow.py +++ b/homeassistant/components/rova/config_flow.py @@ -59,31 +59,3 @@ class RovaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import the yaml config.""" - zip_code = import_data[CONF_ZIP_CODE] - number = import_data[CONF_HOUSE_NUMBER] - suffix = import_data[CONF_HOUSE_NUMBER_SUFFIX] - - await self.async_set_unique_id(f"{zip_code}{number}{suffix}".strip()) - self._abort_if_unique_id_configured() - - api = Rova(zip_code, number, suffix) - - try: - result = await self.hass.async_add_executor_job(api.is_rova_area) - - if result: - return self.async_create_entry( - title=f"{zip_code} {number} {suffix}".strip(), - data={ - CONF_ZIP_CODE: zip_code, - CONF_HOUSE_NUMBER: number, - CONF_HOUSE_NUMBER_SUFFIX: suffix, - }, - ) - return self.async_abort(reason="invalid_rova_area") - - except (ConnectTimeout, HTTPError): - return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index e44e84f52fa..589183eb7a8 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -4,26 +4,18 @@ from __future__ import annotations from datetime import datetime -import voluptuous as vol - from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import FlowResultType -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_HOUSE_NUMBER, CONF_HOUSE_NUMBER_SUFFIX, CONF_ZIP_CODE, DOMAIN +from .const import DOMAIN from .coordinator import RovaCoordinator ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=rova"} @@ -47,62 +39,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), ) -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_ZIP_CODE): cv.string, - vol.Required(CONF_HOUSE_NUMBER): cv.string, - vol.Optional(CONF_HOUSE_NUMBER_SUFFIX, default=""): cv.string, - vol.Optional(CONF_NAME, default="Rova"): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=["bio"]): vol.All( - cv.ensure_list, [vol.In(["bio", "paper", "plastic", "residual"])] - ), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the rova sensor platform through yaml configuration.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - if ( - result["type"] == FlowResultType.CREATE_ENTRY - or result["reason"] == "already_configured" - ): - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.10.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Rova", - }, - ) - else: - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2024.10.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders=ISSUE_PLACEHOLDER, - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/rova/strings.json b/homeassistant/components/rova/strings.json index 709e5450411..864989b90db 100644 --- a/homeassistant/components/rova/strings.json +++ b/homeassistant/components/rova/strings.json @@ -21,14 +21,6 @@ } }, "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "title": "The Rova YAML configuration import failed", - "description": "Configuring Rova using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to Rova works and restart Home Assistant to try again or remove the Rova YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, - "deprecated_yaml_import_issue_invalid_rova_area": { - "title": "The Rova YAML configuration import failed", - "description": "There was an error when trying to import your Rova YAML configuration.\n\nRova does not collect at this address.\n\nEnsure the imported configuration is correct and remove the Rova YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - }, "no_rova_area": { "title": "Rova does not collect at this address anymore", "description": "Rova does not collect at {zip_code} anymore.\n\nPlease remove the integration." diff --git a/tests/components/rova/test_config_flow.py b/tests/components/rova/test_config_flow.py index d9d1df3e188..608f4ec105b 100644 --- a/tests/components/rova/test_config_flow.py +++ b/tests/components/rova/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.components.rova.const import ( CONF_ZIP_CODE, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -167,104 +167,3 @@ async def test_abort_if_api_throws_exception( CONF_HOUSE_NUMBER: HOUSE_NUMBER, CONF_HOUSE_NUMBER_SUFFIX: HOUSE_NUMBER_SUFFIX, } - - -async def test_import(hass: HomeAssistant, mock_rova: MagicMock) -> None: - """Test import flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_ZIP_CODE: ZIP_CODE, - CONF_HOUSE_NUMBER: HOUSE_NUMBER, - CONF_HOUSE_NUMBER_SUFFIX: HOUSE_NUMBER_SUFFIX, - }, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == f"{ZIP_CODE} {HOUSE_NUMBER} {HOUSE_NUMBER_SUFFIX}" - assert result["data"] == { - CONF_ZIP_CODE: ZIP_CODE, - CONF_HOUSE_NUMBER: HOUSE_NUMBER, - CONF_HOUSE_NUMBER_SUFFIX: HOUSE_NUMBER_SUFFIX, - } - - -async def test_import_already_configured( - hass: HomeAssistant, mock_rova: MagicMock -) -> None: - """Test we abort import flow when entry is already configured.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=f"{ZIP_CODE}{HOUSE_NUMBER}{HOUSE_NUMBER_SUFFIX}", - data={ - CONF_ZIP_CODE: ZIP_CODE, - CONF_HOUSE_NUMBER: HOUSE_NUMBER, - CONF_HOUSE_NUMBER_SUFFIX: HOUSE_NUMBER_SUFFIX, - }, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_ZIP_CODE: ZIP_CODE, - CONF_HOUSE_NUMBER: HOUSE_NUMBER, - CONF_HOUSE_NUMBER_SUFFIX: HOUSE_NUMBER_SUFFIX, - }, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_import_if_not_rova_area( - hass: HomeAssistant, mock_rova: MagicMock -) -> None: - """Test we abort if rova does not collect at the given address.""" - - # test with area where rova does not collect - mock_rova.return_value.is_rova_area.return_value = False - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_ZIP_CODE: ZIP_CODE, - CONF_HOUSE_NUMBER: HOUSE_NUMBER, - CONF_HOUSE_NUMBER_SUFFIX: HOUSE_NUMBER_SUFFIX, - }, - ) - - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "invalid_rova_area" - - -@pytest.mark.parametrize( - ("exception", "error"), - [ - (ConnectTimeout(), "cannot_connect"), - (HTTPError(), "cannot_connect"), - ], -) -async def test_import_connection_errors( - hass: HomeAssistant, exception: Exception, error: str, mock_rova: MagicMock -) -> None: - """Test import connection errors flow.""" - - # test with HTTPError - mock_rova.return_value.is_rova_area.side_effect = exception - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_ZIP_CODE: ZIP_CODE, - CONF_HOUSE_NUMBER: HOUSE_NUMBER, - CONF_HOUSE_NUMBER_SUFFIX: HOUSE_NUMBER_SUFFIX, - }, - ) - - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == error From 56031b2e1a2dae749bb9447e610edddc262942da Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 12 Sep 2024 20:33:35 +0200 Subject: [PATCH 0589/1309] Disable Wyoming assist_in_progress binary sensor (#125806) --- .../components/wyoming/binary_sensor.py | 1 + .../components/wyoming/test_binary_sensor.py | 20 +++++++++++++++++++ tests/components/wyoming/test_devices.py | 7 ++++--- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py index 4f2c0bb170a..ac5db0cda99 100644 --- a/homeassistant/components/wyoming/binary_sensor.py +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -37,6 +37,7 @@ class WyomingSatelliteAssistInProgress(WyomingSatelliteEntity, BinarySensorEntit """Entity to represent Assist is in progress for satellite.""" entity_description = BinarySensorEntityDescription( + entity_registry_enabled_default=False, key="assist_in_progress", translation_key="assist_in_progress", ) diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py index 8d4e3c72c56..99ed5cda58e 100644 --- a/tests/components/wyoming/test_binary_sensor.py +++ b/tests/components/wyoming/test_binary_sensor.py @@ -1,13 +1,17 @@ """Test Wyoming binary sensor devices.""" +import pytest + from homeassistant.components.wyoming.devices import SatelliteDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import reload_satellite +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_assist_in_progress( hass: HomeAssistant, satellite_config_entry: ConfigEntry, @@ -36,3 +40,19 @@ async def test_assist_in_progress( assert state is not None assert state.state == STATE_OFF assert not satellite_device.is_active + + +async def test_assist_in_progress_disabled_by_default( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + satellite_device: SatelliteDevice, +) -> None: + """Test assist in progress binary sensor is added disabled.""" + assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) + assert assist_in_progress_id + + assert not hass.states.get(assist_in_progress_id) + entity_entry = entity_registry.async_get(assist_in_progress_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/wyoming/test_devices.py b/tests/components/wyoming/test_devices.py index 98efb76ab1d..24423264f93 100644 --- a/tests/components/wyoming/test_devices.py +++ b/tests/components/wyoming/test_devices.py @@ -32,8 +32,8 @@ async def test_device_registry_info( assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) assert assist_in_progress_id assist_in_progress_state = hass.states.get(assist_in_progress_id) - assert assist_in_progress_state is not None - assert assist_in_progress_state.state == STATE_OFF + # assist_in_progress binary sensor is disabled + assert assist_in_progress_state is None muted_id = satellite_device.get_muted_entity_id(hass) assert muted_id @@ -58,7 +58,8 @@ async def test_remove_device_registry_entry( # Check associated entities assist_in_progress_id = satellite_device.get_assist_in_progress_entity_id(hass) assert assist_in_progress_id - assert hass.states.get(assist_in_progress_id) is not None + # assist_in_progress binary sensor is disabled + assert hass.states.get(assist_in_progress_id) is None muted_id = satellite_device.get_muted_entity_id(hass) assert muted_id From 662a30ffaf82d343323e079d12ab2bdb321805af Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 12 Sep 2024 20:34:11 +0200 Subject: [PATCH 0590/1309] Disable voip call_in_progress binary sensor (#125812) --- .../components/voip/binary_sensor.py | 1 + tests/components/voip/test_binary_sensor.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py index 121de507d7b..a1ef36a7086 100644 --- a/homeassistant/components/voip/binary_sensor.py +++ b/homeassistant/components/voip/binary_sensor.py @@ -42,6 +42,7 @@ class VoIPCallInProgress(VoIPEntity, BinarySensorEntity): """Entity to represent voip call is in progress.""" entity_description = BinarySensorEntityDescription( + entity_registry_enabled_default=False, key="call_in_progress", translation_key="call_in_progress", ) diff --git a/tests/components/voip/test_binary_sensor.py b/tests/components/voip/test_binary_sensor.py index 58f1e0ea53b..50a8c5d4141 100644 --- a/tests/components/voip/test_binary_sensor.py +++ b/tests/components/voip/test_binary_sensor.py @@ -1,10 +1,14 @@ """Test VoIP binary sensor devices.""" +import pytest + from homeassistant.components.voip.devices import VoIPDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_call_in_progress( hass: HomeAssistant, config_entry: ConfigEntry, @@ -24,3 +28,20 @@ async def test_call_in_progress( state = hass.states.get("binary_sensor.192_168_1_210_call_in_progress") assert state.state == "off" + + +@pytest.mark.usefixtures("voip_device") +async def test_assist_in_progress_disabled_by_default( + hass: HomeAssistant, + config_entry: ConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test assist in progress binary sensor is added disabled.""" + + assert not hass.states.get("binary_sensor.192_168_1_210_call_in_progress") + entity_entry = entity_registry.async_get( + "binary_sensor.192_168_1_210_call_in_progress" + ) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION From d530fd31b063c9b3784ac2378e9eb5848dd03b87 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Sep 2024 20:37:00 +0200 Subject: [PATCH 0591/1309] Use root import for async_redact_data in diagnostics (#125821) --- homeassistant/components/aemet/diagnostics.py | 2 +- homeassistant/components/airzone/diagnostics.py | 2 +- homeassistant/components/airzone_cloud/diagnostics.py | 2 +- homeassistant/components/bmw_connected_drive/diagnostics.py | 2 +- homeassistant/components/fully_kiosk/diagnostics.py | 2 +- homeassistant/components/melcloud/diagnostics.py | 2 +- homeassistant/components/minecraft_server/diagnostics.py | 2 +- homeassistant/components/qnap_qsw/diagnostics.py | 2 +- homeassistant/components/roborock/diagnostics.py | 2 +- homeassistant/components/sensibo/diagnostics.py | 2 +- homeassistant/components/starlink/diagnostics.py | 2 +- homeassistant/components/subaru/diagnostics.py | 2 +- homeassistant/components/zha/diagnostics.py | 2 +- homeassistant/components/zwave_js/diagnostics.py | 3 +-- 14 files changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/aemet/diagnostics.py b/homeassistant/components/aemet/diagnostics.py index cc39d1adc32..2379bd34bc0 100644 --- a/homeassistant/components/aemet/diagnostics.py +++ b/homeassistant/components/aemet/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from aemet_opendata.const import AOD_COORDS -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, diff --git a/homeassistant/components/airzone/diagnostics.py b/homeassistant/components/airzone/diagnostics.py index 6c75b750eaf..2945df7b6fb 100644 --- a/homeassistant/components/airzone/diagnostics.py +++ b/homeassistant/components/airzone/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from aioairzone.const import API_MAC, AZD_MAC -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py index 516a8fcb165..b6744e36d8c 100644 --- a/homeassistant/components/airzone_cloud/diagnostics.py +++ b/homeassistant/components/airzone_cloud/diagnostics.py @@ -21,7 +21,7 @@ from aioairzone_cloud.const import ( RAW_WEBSERVERS, ) -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/bmw_connected_drive/diagnostics.py b/homeassistant/components/bmw_connected_drive/diagnostics.py index a3a8f5f942e..ff3c6f29559 100644 --- a/homeassistant/components/bmw_connected_drive/diagnostics.py +++ b/homeassistant/components/bmw_connected_drive/diagnostics.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any from bimmer_connected.utils import MyBMWJSONEncoder -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry diff --git a/homeassistant/components/fully_kiosk/diagnostics.py b/homeassistant/components/fully_kiosk/diagnostics.py index df03cb4a7bf..0ff567b0b46 100644 --- a/homeassistant/components/fully_kiosk/diagnostics.py +++ b/homeassistant/components/fully_kiosk/diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/homeassistant/components/melcloud/diagnostics.py b/homeassistant/components/melcloud/diagnostics.py index 8c2ad0818ff..31e52bf2bde 100644 --- a/homeassistant/components/melcloud/diagnostics.py +++ b/homeassistant/components/melcloud/diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py index 1cae535dc43..0bcffe1434a 100644 --- a/homeassistant/components/minecraft_server/diagnostics.py +++ b/homeassistant/components/minecraft_server/diagnostics.py @@ -4,7 +4,7 @@ from collections.abc import Iterable from dataclasses import asdict from typing import Any -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/qnap_qsw/diagnostics.py b/homeassistant/components/qnap_qsw/diagnostics.py index e732c551a40..6f42fb82cb7 100644 --- a/homeassistant/components/qnap_qsw/diagnostics.py +++ b/homeassistant/components/qnap_qsw/diagnostics.py @@ -6,7 +6,7 @@ from typing import Any from aioqsw.const import QSD_MAC, QSD_SERIAL -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py index 63de0da6a7f..e784e4ce837 100644 --- a/homeassistant/components/roborock/diagnostics.py +++ b/homeassistant/components/roborock/diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_UNIQUE_ID from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/sensibo/diagnostics.py b/homeassistant/components/sensibo/diagnostics.py index e08ad9f8b53..f781887ec0a 100644 --- a/homeassistant/components/sensibo/diagnostics.py +++ b/homeassistant/components/sensibo/diagnostics.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant from . import SensiboConfigEntry diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py index 88e6485cf77..c619458b1dd 100644 --- a/homeassistant/components/starlink/diagnostics.py +++ b/homeassistant/components/starlink/diagnostics.py @@ -3,7 +3,7 @@ from dataclasses import asdict from typing import Any -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/subaru/diagnostics.py b/homeassistant/components/subaru/diagnostics.py index 5d95cd0464b..eec5b01ab56 100644 --- a/homeassistant/components/subaru/diagnostics.py +++ b/homeassistant/components/subaru/diagnostics.py @@ -12,7 +12,7 @@ from subarulink.const import ( VEHICLE_NAME, ) -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index ad73978d24d..234f10d59ae 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -23,7 +23,7 @@ from zigpy.profiles import PROFILES from zigpy.types import Channels from zigpy.zcl import Cluster -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index dde455bd9b6..2bb656c97f5 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -12,8 +12,7 @@ from zwave_js_server.model.node import Node from zwave_js_server.model.value import ValueDataType from zwave_js_server.util.node import dump_node_state -from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant From d259e4512b5593c1d56aae7dd9b7e137004f6e45 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 12 Sep 2024 21:41:00 +0200 Subject: [PATCH 0592/1309] Improve logging message for validation in climate (#125837) --- homeassistant/components/climate/__init__.py | 14 +++++++++++--- tests/components/climate/test_init.py | 11 ++++++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index b0a9c5a4c5a..e6c1781a59f 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -713,7 +713,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ( "%s::%s sets the hvac_mode %s which is not " "valid for this entity with modes: %s. " - "This will stop working in 2025.3 and raise an error instead. " + "This will stop working in 2025.4 and raise an error instead. " "Please %s" ), self.platform.platform_name, @@ -937,6 +937,8 @@ 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, @@ -945,7 +947,9 @@ async def async_service_temperature_set( _LOGGER.warning( ( "%s::%s set_temperature action was used with temperature but the entity does not " - "implement the ClimateEntityFeature.TARGET_TEMPERATURE feature. Please %s" + "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__, @@ -956,6 +960,8 @@ async def async_service_temperature_set( 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, @@ -964,7 +970,9 @@ async def async_service_temperature_set( _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. Please %s" + "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__, diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index b3f26dc775f..b0322e9ddd8 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -300,7 +300,9 @@ async def test_temperature_features_is_valid( assert ( "MockClimateTempEntity set_temperature action was used " "with temperature but the entity does not " - "implement the ClimateEntityFeature.TARGET_TEMPERATURE feature. Please" + "implement the ClimateEntityFeature.TARGET_TEMPERATURE feature. " + "This will stop working in 2025.4 and raise an error instead. " + "Please" ) in caplog.text await hass.services.async_call( @@ -316,7 +318,9 @@ async def test_temperature_features_is_valid( assert ( "MockClimateTempRangeEntity set_temperature action was used with " "target_temp_low but the entity does not " - "implement the ClimateEntityFeature.TARGET_TEMPERATURE_RANGE feature. Please" + "implement the ClimateEntityFeature.TARGET_TEMPERATURE_RANGE feature. " + "This will stop working in 2025.4 and raise an error instead. " + "Please" ) in caplog.text @@ -385,7 +389,8 @@ async def test_mode_validation( assert ( "MockClimateEntity sets the hvac_mode auto which is not valid " "for this entity with modes: off, heat. This will stop working " - "in 2025.3 and raise an error instead. Please" in caplog.text + "in 2025.4 and raise an error instead. " + "Please" in caplog.text ) with pytest.raises( From 47a9dda3b8de118c436c9e09f7a2c3022265accb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Sep 2024 22:21:21 +0200 Subject: [PATCH 0593/1309] Use root import in components (#125858) --- homeassistant/components/assist_pipeline/pipeline.py | 2 +- homeassistant/components/assist_satellite/entity.py | 2 +- homeassistant/components/axis/hub/event_source.py | 3 +-- homeassistant/components/esphome/assist_satellite.py | 7 +++++-- homeassistant/components/generic/config_flow.py | 2 +- homeassistant/components/homekit_controller/connection.py | 2 +- homeassistant/components/mobile_app/timers.py | 2 +- homeassistant/components/mqtt/config_flow.py | 5 +++-- homeassistant/components/nanoleaf/device_trigger.py | 6 ++++-- homeassistant/components/notify/repairs.py | 3 +-- homeassistant/components/octoprint/camera.py | 2 +- 11 files changed, 20 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 8a5fec83565..a4255e37756 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -26,7 +26,7 @@ from homeassistant.components import ( wake_word, websocket_api, ) -from homeassistant.components.tts.media_source import ( +from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) from homeassistant.core import Context, HomeAssistant, callback diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 897f9ed244b..5da182ed9df 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -22,7 +22,7 @@ from homeassistant.components.assist_pipeline import ( vad, ) from homeassistant.components.media_player import async_process_play_media_url -from homeassistant.components.tts.media_source import ( +from homeassistant.components.tts import ( generate_media_source_id as tts_generate_media_source_id, ) from homeassistant.core import Context, callback diff --git a/homeassistant/components/axis/hub/event_source.py b/homeassistant/components/axis/hub/event_source.py index 7f2bfe7c982..d295639d1a6 100644 --- a/homeassistant/components/axis/hub/event_source.py +++ b/homeassistant/components/axis/hub/event_source.py @@ -9,8 +9,7 @@ from axis.models.mqtt import ClientState from axis.stream_manager import Signal, State from homeassistant.components import mqtt -from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN -from homeassistant.components.mqtt.models import ReceiveMessage +from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN, ReceiveMessage from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 9d48e96b52e..6370e91b9d1 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -27,8 +27,11 @@ from homeassistant.components.assist_pipeline import ( PipelineEventType, PipelineStage, ) -from homeassistant.components.intent import async_register_timer_handler -from homeassistant.components.intent.timers import TimerEventType, TimerInfo +from homeassistant.components.intent import ( + TimerEventType, + TimerInfo, + async_register_timer_handler, +) from homeassistant.components.media_player import async_process_play_media_url from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 401b49dad4a..d16124225c6 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.components.camera import ( DynamicStreamSettings, _async_get_image, ) -from homeassistant.components.http.view import HomeAssistantView +from homeassistant.components.http import HomeAssistantView from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 934e7e883ae..02bcd4265cb 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -22,7 +22,7 @@ from aiohomekit.model import Accessories, Accessory, Transport from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from homeassistant.components.thread.dataset_store import async_get_preferred_dataset +from homeassistant.components.thread import async_get_preferred_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VIA_DEVICE, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback diff --git a/homeassistant/components/mobile_app/timers.py b/homeassistant/components/mobile_app/timers.py index e092298c5d7..e9e44210534 100644 --- a/homeassistant/components/mobile_app/timers.py +++ b/homeassistant/components/mobile_app/timers.py @@ -3,7 +3,7 @@ from datetime import timedelta from homeassistant.components import notify -from homeassistant.components.intent.timers import TimerEventType, TimerInfo +from homeassistant.components.intent import TimerEventType, TimerInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index ca799ff3653..ad41c35e51a 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -16,11 +16,12 @@ from cryptography.x509 import load_pem_x509_certificate import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file -from homeassistant.components.hassio import HassioServiceInfo, is_hassio -from homeassistant.components.hassio.addon_manager import ( +from homeassistant.components.hassio import ( AddonError, AddonManager, AddonState, + HassioServiceInfo, + is_hassio, ) from homeassistant.config_entries import ( ConfigEntry, diff --git a/homeassistant/components/nanoleaf/device_trigger.py b/homeassistant/components/nanoleaf/device_trigger.py index b4049f2199d..28b39e03db7 100644 --- a/homeassistant/components/nanoleaf/device_trigger.py +++ b/homeassistant/components/nanoleaf/device_trigger.py @@ -4,8 +4,10 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA -from homeassistant.components.device_automation.exceptions import DeviceNotFound +from homeassistant.components.device_automation import ( + DEVICE_TRIGGER_BASE_SCHEMA, + DeviceNotFound, +) from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.const import ( CONF_DEVICE_ID, diff --git a/homeassistant/components/notify/repairs.py b/homeassistant/components/notify/repairs.py index d188f07c2ed..8969652d98e 100644 --- a/homeassistant/components/notify/repairs.py +++ b/homeassistant/components/notify/repairs.py @@ -2,8 +2,7 @@ from __future__ import annotations -from homeassistant.components.repairs import RepairsFlow -from homeassistant.components.repairs.issue_handler import ConfirmRepairFlow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index c5d6f9a62e1..e6430c55fa2 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -4,7 +4,7 @@ from __future__ import annotations from pyoctoprintapi import OctoprintClient, WebcamSettings -from homeassistant.components.mjpeg.camera import MjpegCamera +from homeassistant.components.mjpeg import MjpegCamera from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant From 11f42761aa41cf187c468b7d6c9b39e362877f6a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Sep 2024 23:30:51 +0200 Subject: [PATCH 0594/1309] Fix incorrect import in androidtv tests (#125860) --- tests/components/androidtv/test_diagnostics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/androidtv/test_diagnostics.py b/tests/components/androidtv/test_diagnostics.py index 7d1801514af..4ba53886739 100644 --- a/tests/components/androidtv/test_diagnostics.py +++ b/tests/components/androidtv/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the AndroidTV integration.""" -from homeassistant.components.asuswrt.diagnostics import TO_REDACT +from homeassistant.components.androidtv.diagnostics import TO_REDACT from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntryState From bd2b72235e54564a9c0ef45cbebcd2ab889bf458 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 13 Sep 2024 06:42:32 +0200 Subject: [PATCH 0595/1309] Use root import in tests (#125862) * Use root import in components * One more --- tests/common.py | 2 +- tests/components/cloud/test_tts.py | 2 +- tests/components/conftest.py | 2 +- tests/components/dlna_dms/test_media_source.py | 6 +++--- tests/components/google_assistant/test_helpers.py | 2 +- .../homeassistant_hardware/test_config_flow.py | 2 +- .../homeassistant_hardware/test_config_flow_failures.py | 6 +----- .../test_silabs_multiprotocol_addon.py | 9 +++++++-- .../homeassistant_sky_connect/test_config_flow.py | 2 +- .../components/homeassistant_yellow/test_config_flow.py | 7 +++++-- tests/components/mqtt/test_config_flow.py | 8 +++++--- tests/components/script/test_blueprint.py | 2 +- tests/components/zwave_js/test_config_flow.py | 3 +-- tests/components/zwave_js/test_init.py | 2 +- 14 files changed, 30 insertions(+), 25 deletions(-) diff --git a/tests/common.py b/tests/common.py index c2d561551ca..21b5ee1e720 100644 --- a/tests/common.py +++ b/tests/common.py @@ -419,7 +419,7 @@ def async_fire_mqtt_message( from paho.mqtt.client import MQTTMessage # pylint: disable-next=import-outside-toplevel - from homeassistant.components.mqtt.models import MqttData + from homeassistant.components.mqtt import MqttData if isinstance(payload, str): payload = payload.encode("utf-8") diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index 52a9bc19ea2..50ea5e87d82 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -23,8 +23,8 @@ from homeassistant.components.tts import ( ATTR_MEDIA_PLAYER_ENTITY_ID, ATTR_MESSAGE, DOMAIN as TTS_DOMAIN, + get_engine_instance, ) -from homeassistant.components.tts.helper import get_engine_instance from homeassistant.config import async_process_ha_core_config from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 39ff7071dc4..1e79248fbeb 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -14,7 +14,7 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant if TYPE_CHECKING: - from homeassistant.components.hassio.addon_manager import AddonManager + from homeassistant.components.hassio import AddonManager from .conversation import MockAgent from .device_tracker.common import MockScanner diff --git a/tests/components/dlna_dms/test_media_source.py b/tests/components/dlna_dms/test_media_source.py index 641232e356a..ad290826075 100644 --- a/tests/components/dlna_dms/test_media_source.py +++ b/tests/components/dlna_dms/test_media_source.py @@ -13,11 +13,11 @@ from homeassistant.components.dlna_dms.media_source import ( DmsMediaSource, async_get_media_source, ) -from homeassistant.components.media_player.errors import BrowseError -from homeassistant.components.media_source.error import Unresolvable -from homeassistant.components.media_source.models import ( +from homeassistant.components.media_player import BrowseError +from homeassistant.components.media_source import ( BrowseMediaSource, MediaSourceItem, + Unresolvable, ) from homeassistant.const import CONF_DEVICE_ID, CONF_URL from homeassistant.core import HomeAssistant diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 492f1be1829..8b46545d9c5 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -14,7 +14,7 @@ from homeassistant.components.google_assistant.const import ( SOURCE_LOCAL, STORE_GOOGLE_LOCAL_WEBHOOK_ID, ) -from homeassistant.components.matter.models import MatterDeviceInfo +from homeassistant.components.matter import MatterDeviceInfo from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index a1842f4c4e6..b94238c1225 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, Mock, call, patch import pytest from universal_silabs_flasher.const import ApplicationType -from homeassistant.components.hassio.addon_manager import AddonInfo, AddonState +from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 4c3ea7d28fa..a5c5f4d666a 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -5,11 +5,7 @@ from unittest.mock import AsyncMock import pytest from universal_silabs_flasher.const import ApplicationType -from homeassistant.components.hassio.addon_manager import ( - AddonError, - AddonInfo, - AddonState, -) +from homeassistant.components.hassio import AddonError, AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 5718133cd24..65fab707c0b 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -8,8 +8,13 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant.components.hassio import AddonError, AddonInfo, AddonState, HassIO -from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.hassio import ( + AddonError, + AddonInfo, + AddonState, + HassIO, + HassioAPIError, +) from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigFlow diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 0d4c517b07f..de9af6f204c 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components import usb -from homeassistant.components.hassio.addon_manager import AddonInfo, AddonState +from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_ZIGBEE, ) diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 949e58e61b6..c82c08314b0 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -5,8 +5,11 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components.hassio import DOMAIN as HASSIO_DOMAIN -from homeassistant.components.hassio.addon_manager import AddonInfo, AddonState +from homeassistant.components.hassio import ( + DOMAIN as HASSIO_DOMAIN, + AddonInfo, + AddonState, +) from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( STEP_PICK_FIRMWARE_ZIGBEE, ) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index d2f399899b1..70231cc6115 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -13,9 +13,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import mqtt -from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.components.hassio.addon_manager import AddonError -from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.hassio import ( + AddonError, + HassioAPIError, + HassioServiceInfo, +) from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED from homeassistant.const import ( CONF_CLIENT_ID, diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index 160b330c109..86567d2f16f 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -9,7 +9,7 @@ from unittest.mock import patch import pytest from homeassistant.components import script -from homeassistant.components.blueprint.models import Blueprint, DomainBlueprints +from homeassistant.components.blueprint import Blueprint, DomainBlueprints from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, template diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index a3affb6b977..d6081d24b18 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -14,8 +14,7 @@ from zwave_js_server.version import VersionInfo from homeassistant import config_entries from homeassistant.components import usb -from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.hassio import HassioAPIError, HassioServiceInfo from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.components.zwave_js.config_flow import SERVER_VERSION_TIMEOUT, TITLE from homeassistant.components.zwave_js.const import ADDON_SLUG, DOMAIN diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 51aeee72c1d..5ec72b8a46a 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -12,7 +12,7 @@ from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVers from zwave_js_server.model.node import Node from zwave_js_server.model.version import VersionInfo -from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.hassio import HassioAPIError from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN From f311198da078cdd3c762b8c04fc8fef72fadaf85 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 13 Sep 2024 08:34:55 +0200 Subject: [PATCH 0596/1309] Fix failing nextdns coordinator test (#125859) --- tests/components/nextdns/test_coordinator.py | 36 ++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/tests/components/nextdns/test_coordinator.py b/tests/components/nextdns/test_coordinator.py index 9613a6b423f..f2b353ea2c5 100644 --- a/tests/components/nextdns/test_coordinator.py +++ b/tests/components/nextdns/test_coordinator.py @@ -25,9 +25,39 @@ async def test_auth_error( assert entry.state is ConfigEntryState.LOADED freezer.tick(timedelta(minutes=10)) - with patch( - "homeassistant.components.nextdns.NextDns.connection_status", - side_effect=InvalidApiKeyError, + with ( + patch( + "homeassistant.components.nextdns.NextDns.get_profiles", + side_effect=InvalidApiKeyError, + ), + patch( + "homeassistant.components.nextdns.NextDns.get_analytics_status", + side_effect=InvalidApiKeyError, + ), + patch( + "homeassistant.components.nextdns.NextDns.get_analytics_encryption", + side_effect=InvalidApiKeyError, + ), + patch( + "homeassistant.components.nextdns.NextDns.get_analytics_dnssec", + side_effect=InvalidApiKeyError, + ), + patch( + "homeassistant.components.nextdns.NextDns.get_analytics_ip_versions", + side_effect=InvalidApiKeyError, + ), + patch( + "homeassistant.components.nextdns.NextDns.get_analytics_protocols", + side_effect=InvalidApiKeyError, + ), + patch( + "homeassistant.components.nextdns.NextDns.get_settings", + side_effect=InvalidApiKeyError, + ), + patch( + "homeassistant.components.nextdns.NextDns.connection_status", + side_effect=InvalidApiKeyError, + ), ): async_fire_time_changed(hass) await hass.async_block_till_done() From 6d17ad4da6fb863200c889cdb0b80fcccfa6a1e7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 13 Sep 2024 10:12:24 +0200 Subject: [PATCH 0597/1309] Move ADS supported types to a StrEnum (#125824) --- homeassistant/components/ads/__init__.py | 83 +++++++----------------- homeassistant/components/ads/const.py | 23 +++++++ homeassistant/components/ads/sensor.py | 43 ++++++------ 3 files changed, 68 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index da855fb7228..892390a91eb 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -15,49 +15,30 @@ from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN +from .const import CONF_ADS_VAR, DATA_ADS, DOMAIN, AdsType from .hub import AdsHub _LOGGER = logging.getLogger(__name__) -# Supported Types -ADSTYPE_BOOL = "bool" -ADSTYPE_BYTE = "byte" -ADSTYPE_INT = "int" -ADSTYPE_UINT = "uint" -ADSTYPE_SINT = "sint" -ADSTYPE_USINT = "usint" -ADSTYPE_DINT = "dint" -ADSTYPE_UDINT = "udint" -ADSTYPE_WORD = "word" -ADSTYPE_DWORD = "dword" -ADSTYPE_LREAL = "lreal" -ADSTYPE_REAL = "real" -ADSTYPE_STRING = "string" -ADSTYPE_TIME = "time" -ADSTYPE_DATE = "date" -ADSTYPE_DATE_AND_TIME = "dt" -ADSTYPE_TOD = "tod" - ADS_TYPEMAP = { - ADSTYPE_BOOL: pyads.PLCTYPE_BOOL, - ADSTYPE_BYTE: pyads.PLCTYPE_BYTE, - ADSTYPE_INT: pyads.PLCTYPE_INT, - ADSTYPE_UINT: pyads.PLCTYPE_UINT, - ADSTYPE_SINT: pyads.PLCTYPE_SINT, - ADSTYPE_USINT: pyads.PLCTYPE_USINT, - ADSTYPE_DINT: pyads.PLCTYPE_DINT, - ADSTYPE_UDINT: pyads.PLCTYPE_UDINT, - ADSTYPE_WORD: pyads.PLCTYPE_WORD, - ADSTYPE_DWORD: pyads.PLCTYPE_DWORD, - ADSTYPE_REAL: pyads.PLCTYPE_REAL, - ADSTYPE_LREAL: pyads.PLCTYPE_LREAL, - ADSTYPE_STRING: pyads.PLCTYPE_STRING, - ADSTYPE_TIME: pyads.PLCTYPE_TIME, - ADSTYPE_DATE: pyads.PLCTYPE_DATE, - ADSTYPE_DATE_AND_TIME: pyads.PLCTYPE_DT, - ADSTYPE_TOD: pyads.PLCTYPE_TOD, + AdsType.BOOL: pyads.PLCTYPE_BOOL, + AdsType.BYTE: pyads.PLCTYPE_BYTE, + AdsType.INT: pyads.PLCTYPE_INT, + AdsType.UINT: pyads.PLCTYPE_UINT, + AdsType.SINT: pyads.PLCTYPE_SINT, + AdsType.USINT: pyads.PLCTYPE_USINT, + AdsType.DINT: pyads.PLCTYPE_DINT, + AdsType.UDINT: pyads.PLCTYPE_UDINT, + AdsType.WORD: pyads.PLCTYPE_WORD, + AdsType.DWORD: pyads.PLCTYPE_DWORD, + AdsType.REAL: pyads.PLCTYPE_REAL, + AdsType.LREAL: pyads.PLCTYPE_LREAL, + AdsType.STRING: pyads.PLCTYPE_STRING, + AdsType.TIME: pyads.PLCTYPE_TIME, + AdsType.DATE: pyads.PLCTYPE_DATE, + AdsType.DATE_AND_TIME: pyads.PLCTYPE_DT, + AdsType.TOD: pyads.PLCTYPE_TOD, } CONF_ADS_FACTOR = "factor" @@ -82,27 +63,7 @@ CONFIG_SCHEMA = vol.Schema( SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema( { - vol.Required(CONF_ADS_TYPE): vol.In( - [ - ADSTYPE_BOOL, - ADSTYPE_BYTE, - ADSTYPE_INT, - ADSTYPE_UINT, - ADSTYPE_SINT, - ADSTYPE_USINT, - ADSTYPE_DINT, - ADSTYPE_UDINT, - ADSTYPE_WORD, - ADSTYPE_DWORD, - ADSTYPE_REAL, - ADSTYPE_LREAL, - ADSTYPE_STRING, - ADSTYPE_TIME, - ADSTYPE_DATE, - ADSTYPE_DATE_AND_TIME, - ADSTYPE_TOD, - ] - ), + vol.Required(CONF_ADS_TYPE): vol.Coerce(AdsType), vol.Required(CONF_ADS_VALUE): vol.Coerce(int), vol.Required(CONF_ADS_VAR): cv.string, } @@ -136,9 +97,9 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def handle_write_data_by_name(call: ServiceCall) -> None: """Write a value to the connected ADS device.""" - ads_var = call.data[CONF_ADS_VAR] - ads_type = call.data[CONF_ADS_TYPE] - value = call.data[CONF_ADS_VALUE] + ads_var: str = call.data[CONF_ADS_VAR] + ads_type: AdsType = call.data[CONF_ADS_TYPE] + value: int = call.data[CONF_ADS_VALUE] try: ads.write_by_name(ads_var, value, ADS_TYPEMAP[ads_type]) diff --git a/homeassistant/components/ads/const.py b/homeassistant/components/ads/const.py index 5683077e023..ea78fb41785 100644 --- a/homeassistant/components/ads/const.py +++ b/homeassistant/components/ads/const.py @@ -2,6 +2,7 @@ from __future__ import annotations +from enum import StrEnum from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey @@ -16,3 +17,25 @@ DATA_ADS: HassKey[AdsHub] = HassKey(DOMAIN) CONF_ADS_VAR = "adsvar" STATE_KEY_STATE = "state" + + +class AdsType(StrEnum): + """Supported Types.""" + + BOOL = "bool" + BYTE = "byte" + INT = "int" + UINT = "uint" + SINT = "sint" + USINT = "usint" + DINT = "dint" + UDINT = "udint" + WORD = "word" + DWORD = "dword" + LREAL = "lreal" + REAL = "real" + STRING = "string" + TIME = "time" + DATE = "date" + DATE_AND_TIME = "dt" + TOD = "tod" diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 9dea722e864..09579161a94 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -19,10 +19,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from .. import ads from . import ADS_TYPEMAP, CONF_ADS_FACTOR, CONF_ADS_TYPE -from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE +from .const import CONF_ADS_VAR, DATA_ADS, STATE_KEY_STATE, AdsType from .entity import AdsEntity +from .hub import AdsHub DEFAULT_NAME = "ADS sensor" @@ -30,21 +30,24 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ADS_VAR): cv.string, vol.Optional(CONF_ADS_FACTOR): cv.positive_int, - vol.Optional(CONF_ADS_TYPE, default=ads.ADSTYPE_INT): vol.In( - [ - ads.ADSTYPE_BOOL, - ads.ADSTYPE_BYTE, - ads.ADSTYPE_INT, - ads.ADSTYPE_UINT, - ads.ADSTYPE_SINT, - ads.ADSTYPE_USINT, - ads.ADSTYPE_DINT, - ads.ADSTYPE_UDINT, - ads.ADSTYPE_WORD, - ads.ADSTYPE_DWORD, - ads.ADSTYPE_LREAL, - ads.ADSTYPE_REAL, - ] + vol.Optional(CONF_ADS_TYPE, default=AdsType.INT): vol.All( + vol.Coerce(AdsType), + vol.In( + [ + AdsType.BOOL, + AdsType.BYTE, + AdsType.INT, + AdsType.UINT, + AdsType.SINT, + AdsType.USINT, + AdsType.DINT, + AdsType.UDINT, + AdsType.WORD, + AdsType.DWORD, + AdsType.LREAL, + AdsType.REAL, + ] + ), ), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, @@ -64,7 +67,7 @@ def setup_platform( ads_hub = hass.data[DATA_ADS] ads_var: str = config[CONF_ADS_VAR] - ads_type: str = config[CONF_ADS_TYPE] + ads_type: AdsType = config[CONF_ADS_TYPE] name: str = config[CONF_NAME] factor: int | None = config.get(CONF_ADS_FACTOR) device_class: SensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) @@ -90,9 +93,9 @@ class AdsSensor(AdsEntity, SensorEntity): def __init__( self, - ads_hub: ads.AdsHub, + ads_hub: AdsHub, ads_var: str, - ads_type: str, + ads_type: AdsType, name: str, factor: int | None, device_class: SensorDeviceClass | None, From 0c178d858fc5b0a1f027b36f250c594282de6681 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 13 Sep 2024 10:12:38 +0200 Subject: [PATCH 0598/1309] Fix incorrect import in lcn tests (#125877) --- tests/components/lcn/test_climate.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/components/lcn/test_climate.py b/tests/components/lcn/test_climate.py index c1a9d094c6b..b7fcc2fbe4b 100644 --- a/tests/components/lcn/test_climate.py +++ b/tests/components/lcn/test_climate.py @@ -7,13 +7,12 @@ from pypck.lcn_addr import LcnAddr from pypck.lcn_defs import Var, VarUnit, VarValue from syrupy.assertion import SnapshotAssertion -# pylint: disable=hass-component-root-import -from homeassistant.components.climate import DOMAIN as DOMAIN_CLIMATE -from homeassistant.components.climate.const import ( +from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DOMAIN as DOMAIN_CLIMATE, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, HVACMode, From 834a1ed608fd015cb68f43c7e0f58cf98f769366 Mon Sep 17 00:00:00 2001 From: Adam Pasztor Date: Fri, 13 Sep 2024 11:20:16 +0200 Subject: [PATCH 0599/1309] Add codeowner to ADS integration. (#125893) --- CODEOWNERS | 1 + homeassistant/components/ads/manifest.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 2ce30a52e18..1abdfd637ff 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -48,6 +48,7 @@ build.json @home-assistant/supervisor /tests/components/adax/ @danielhiversen /homeassistant/components/adguard/ @frenck /tests/components/adguard/ @frenck +/homeassistant/components/ads/ @mrpasztoradam /homeassistant/components/advantage_air/ @Bre77 /tests/components/advantage_air/ @Bre77 /homeassistant/components/aemet/ @Noltari diff --git a/homeassistant/components/ads/manifest.json b/homeassistant/components/ads/manifest.json index 0a2cd118a19..86fc54ea784 100644 --- a/homeassistant/components/ads/manifest.json +++ b/homeassistant/components/ads/manifest.json @@ -1,7 +1,7 @@ { "domain": "ads", "name": "ADS", - "codeowners": [], + "codeowners": ["@mrpasztoradam"], "documentation": "https://www.home-assistant.io/integrations/ads", "iot_class": "local_push", "loggers": ["pyads"], From 3eaa005c7e9a7bf5c0937c8b9be84dceb9beba4f Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Fri, 13 Sep 2024 12:41:13 +0200 Subject: [PATCH 0600/1309] Use start/stop level change to open/close Z-Wave JS Window Covering CC covers (#125827) * Z-Wave JS: Use start/stop level change to open/close Window Covering CC covers * fix: import * Update tests/components/zwave_js/test_cover.py Co-authored-by: Martin Hjelmare * assert that up_value and down_value exist * fix: forgot one --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/cover.py | 27 ++ tests/components/zwave_js/conftest.py | 16 + .../window_covering_outbound_bottom.json | 282 ++++++++++++++++++ tests/components/zwave_js/test_cover.py | 103 +++++++ 4 files changed, 428 insertions(+) create mode 100644 tests/components/zwave_js/fixtures/window_covering_outbound_bottom.json diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 363b32cedda..218c5cc82fe 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -19,6 +19,7 @@ from zwave_js_server.const.command_class.multilevel_switch import ( from zwave_js_server.const.command_class.window_covering import ( NO_POSITION_PROPERTY_KEYS, NO_POSITION_SUFFIX, + WINDOW_COVERING_LEVEL_CHANGE_DOWN_PROPERTY, WINDOW_COVERING_LEVEL_CHANGE_UP_PROPERTY, SlatStates, ) @@ -341,6 +342,20 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): super().__init__(config_entry, driver, info) pos_value: ZwaveValue | None = None tilt_value: ZwaveValue | None = None + self._up_value = cast( + ZwaveValue, + self.get_zwave_value( + WINDOW_COVERING_LEVEL_CHANGE_UP_PROPERTY, + value_property_key=info.primary_value.property_key, + ), + ) + self._down_value = cast( + ZwaveValue, + self.get_zwave_value( + WINDOW_COVERING_LEVEL_CHANGE_DOWN_PROPERTY, + value_property_key=info.primary_value.property_key, + ), + ) # If primary value is for position, we have to search for a tilt value if info.primary_value.property_key in COVER_POSITION_PROPERTY_KEYS: @@ -402,6 +417,18 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): """Return range of valid tilt positions.""" return abs(SlatStates.CLOSED_2 - SlatStates.CLOSED_1) + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._async_set_value(self._up_value, True) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self._async_set_value(self._down_value, True) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._async_set_value(self._up_value, False) + class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): """Representation of a Z-Wave motorized barrier device.""" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index a6bbe554f9a..489c2ee4b01 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -477,6 +477,12 @@ def basic_cc_sensor_state_fixture(): return json.loads(load_fixture("zwave_js/basic_cc_sensor_state.json")) +@pytest.fixture(name="window_covering_outbound_bottom_state", scope="package") +def window_covering_outbound_bottom_state_fixture(): + """Load node with Window Covering CC fixture data, with only the outbound bottom position supported.""" + return json.loads(load_fixture("zwave_js/window_covering_outbound_bottom.json")) + + # model fixtures @@ -1161,3 +1167,13 @@ def basic_cc_sensor_fixture(client, basic_cc_sensor_state): node = Node(client, copy.deepcopy(basic_cc_sensor_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="window_covering_outbound_bottom") +def window_covering_outbound_bottom_fixture( + client, window_covering_outbound_bottom_state +): + """Load node with Window Covering CC fixture data, with only the outbound bottom position supported.""" + node = Node(client, copy.deepcopy(window_covering_outbound_bottom_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/window_covering_outbound_bottom.json b/tests/components/zwave_js/fixtures/window_covering_outbound_bottom.json new file mode 100644 index 00000000000..4791e0d9486 --- /dev/null +++ b/tests/components/zwave_js/fixtures/window_covering_outbound_bottom.json @@ -0,0 +1,282 @@ +{ + "nodeId": 2, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 9600, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 6, + "label": "Appliance" + }, + "specific": { + "key": 1, + "label": "General Appliance" + } + }, + "interviewStage": "Complete", + "statistics": { + "commandsTX": 8, + "commandsRX": 5, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 2, + "rtt": 96.3, + "lastSeen": "2024-09-12T11:46:43.065Z" + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-09-12T11:46:43.065Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "levelChangeUp", + "propertyKey": 13, + "propertyName": "levelChangeUp", + "propertyKeyName": "Outbound Bottom", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Open - Outbound Bottom", + "ccSpecific": { + "parameter": 13 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "levelChangeDown", + "propertyKey": 13, + "propertyName": "levelChangeDown", + "propertyKeyName": "Outbound Bottom", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Close - Outbound Bottom", + "ccSpecific": { + "parameter": 13 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "targetValue", + "propertyKey": 13, + "propertyName": "targetValue", + "propertyKeyName": "Outbound Bottom", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value - Outbound Bottom", + "ccSpecific": { + "parameter": 13 + }, + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "states": { + "0": "Closed", + "99": "Open" + }, + "stateful": true, + "secret": false + }, + "value": 52 + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "currentValue", + "propertyKey": 13, + "propertyName": "currentValue", + "propertyKeyName": "Outbound Bottom", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value - Outbound Bottom", + "ccSpecific": { + "parameter": 13 + }, + "min": 0, + "max": 99, + "states": { + "0": "Closed", + "99": "Open" + }, + "stateful": true, + "secret": false + }, + "value": 52 + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "duration", + "propertyKey": 13, + "propertyName": "duration", + "propertyKeyName": "Outbound Bottom", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration - Outbound Bottom", + "ccSpecific": { + "parameter": 13 + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "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 + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + } + } + ], + "endpoints": [ + { + "nodeId": 2, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 6, + "label": "Appliance" + }, + "specific": { + "key": 1, + "label": "General Appliance" + } + }, + "commandClasses": [ + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 106, + "name": "Window Covering", + "version": 1, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index 07edb68f1da..ce394cb9067 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -994,3 +994,106 @@ async def test_nice_ibt4zwave_cover( assert args["value"] == 99 client.async_send_command.reset_mock() + + +async def test_window_covering_open_close( + hass: HomeAssistant, client, window_covering_outbound_bottom, integration +) -> None: + """Test Window Covering device open and close commands. + + A Window Covering device with position support + should be able to open/close with the start/stop level change properties. + """ + entity_id = "cover.node_2_outbound_bottom" + state = hass.states.get(entity_id) + + # The entity has position support, but not tilt + assert state + assert ATTR_CURRENT_POSITION in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes + + # Test opening + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 2 + assert args["valueId"] == { + "commandClass": 106, + "endpoint": 0, + "property": "levelChangeUp", + "propertyKey": 13, + } + assert args["value"] is True + + client.async_send_command.reset_mock() + + # Test stop after opening + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 2 + assert args["valueId"] == { + "commandClass": 106, + "endpoint": 0, + "property": "levelChangeUp", + "propertyKey": 13, + } + assert args["value"] is False + + client.async_send_command.reset_mock() + + # Test closing + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 2 + assert args["valueId"] == { + "commandClass": 106, + "endpoint": 0, + "property": "levelChangeDown", + "propertyKey": 13, + } + assert args["value"] is True + + client.async_send_command.reset_mock() + + # Test stop after closing + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 2 + assert args["valueId"] == { + "commandClass": 106, + "endpoint": 0, + "property": "levelChangeUp", + "propertyKey": 13, + } + assert args["value"] is False + + client.async_send_command.reset_mock() From 88cacbc89825c94b3979fb88824b1149178a5002 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 13 Sep 2024 12:43:37 +0200 Subject: [PATCH 0601/1309] Expose component constants for llm helper (#125891) * Expose climate INTENT_GET_TEMPERATURE * Expose conversation trace items * More fixes for llm helper --- homeassistant/components/climate/__init__.py | 1 + homeassistant/components/climate/const.py | 2 ++ homeassistant/components/climate/intent.py | 4 +--- homeassistant/components/conversation/__init__.py | 11 +++++++---- homeassistant/components/cover/__init__.py | 2 +- homeassistant/components/cover/const.py | 3 +++ homeassistant/components/cover/intent.py | 5 +---- homeassistant/components/homeassistant/__init__.py | 2 +- homeassistant/components/weather/__init__.py | 3 ++- homeassistant/components/weather/const.py | 2 ++ homeassistant/components/weather/intent.py | 4 +--- homeassistant/helpers/llm.py | 10 +++++----- 12 files changed, 27 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index e6c1781a59f..6cdb3339a7b 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -86,6 +86,7 @@ from .const import ( # noqa: F401 FAN_ON, FAN_TOP, HVAC_MODES, + INTENT_GET_TEMPERATURE, PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index b74169430d4..a84a2f3c628 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -145,6 +145,8 @@ DEFAULT_MAX_HUMIDITY = 99 DOMAIN = "climate" +INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" + SERVICE_SET_AUX_HEAT = "set_aux_heat" SERVICE_SET_FAN_MODE = "set_fan_mode" SERVICE_SET_PRESET_MODE = "set_preset_mode" diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 53d0891fcda..9a8dfdda4ec 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -7,9 +7,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from . import DOMAIN - -INTENT_GET_TEMPERATURE = "HassClimateGetTemperature" +from . import DOMAIN, INTENT_GET_TEMPERATURE async def async_setup_intents(hass: HomeAssistant) -> None: diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index a7b163d69bd..2e06387765b 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -46,20 +46,23 @@ from .default_agent import async_get_default_agent, async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult +from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT", + "ConversationEntity", + "ConversationEntityFeature", + "ConversationInput", + "ConversationResult", + "ConversationTraceEventType", + "async_conversation_trace_append", "async_converse", "async_get_agent_info", "async_set_agent", "async_setup", "async_unset_agent", - "ConversationEntity", - "ConversationInput", - "ConversationResult", - "ConversationEntityFeature", ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 90d2b644810..d2ec6bee8fa 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -42,7 +42,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from .const import DOMAIN +from .const import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER # noqa: F401 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cover/const.py b/homeassistant/components/cover/const.py index dd3e8b435c9..e9bbf81e5f5 100644 --- a/homeassistant/components/cover/const.py +++ b/homeassistant/components/cover/const.py @@ -1,3 +1,6 @@ """Constants for cover entity platform.""" DOMAIN = "cover" + +INTENT_OPEN_COVER = "HassOpenCover" +INTENT_CLOSE_COVER = "HassCloseCover" diff --git a/homeassistant/components/cover/intent.py b/homeassistant/components/cover/intent.py index 7580cff063a..dfc7d0f69a0 100644 --- a/homeassistant/components/cover/intent.py +++ b/homeassistant/components/cover/intent.py @@ -4,10 +4,7 @@ from homeassistant.const import SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from . import DOMAIN, CoverDeviceClass - -INTENT_OPEN_COVER = "HassOpenCover" -INTENT_CLOSE_COVER = "HassCloseCover" +from . import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER, CoverDeviceClass async def async_setup_intents(hass: HomeAssistant) -> None: diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index f771923ab2d..6cec47152e5 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -54,7 +54,7 @@ from .const import ( SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, ) -from .exposed_entities import ExposedEntities +from .exposed_entities import ExposedEntities, async_should_expose # noqa: F401 ATTR_ENTRY_ID = "entry_id" ATTR_SAFE_MODE = "safe_mode" diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index dab3394426e..28f3e6b5c53 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -44,7 +44,7 @@ from homeassistant.util.dt import utcnow from homeassistant.util.json import JsonValueType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import ( +from .const import ( # noqa: F401 ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_DEW_POINT, @@ -63,6 +63,7 @@ from .const import ( ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN, + INTENT_GET_WEATHER, UNIT_CONVERSIONS, VALID_UNITS, WeatherEntityFeature, diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index 0b5246ab31c..251bbd622fc 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -49,6 +49,8 @@ ATTR_WEATHER_UV_INDEX = "uv_index" DOMAIN: Final = "weather" +INTENT_GET_WEATHER = "HassGetWeather" + VALID_UNITS_PRESSURE: set[str] = { UnitOfPressure.HPA, UnitOfPressure.MBAR, diff --git a/homeassistant/components/weather/intent.py b/homeassistant/components/weather/intent.py index e00a386b619..078108d7afe 100644 --- a/homeassistant/components/weather/intent.py +++ b/homeassistant/components/weather/intent.py @@ -7,9 +7,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, State from homeassistant.helpers import intent -from . import DOMAIN - -INTENT_GET_WEATHER = "HassGetWeather" +from . import DOMAIN, INTENT_GET_WEATHER async def async_setup_intents(hass: HomeAssistant) -> None: diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 0c173df81ff..b8d8d66615d 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -14,16 +14,16 @@ import slugify as unicode_slug import voluptuous as vol from voluptuous_openapi import UNSUPPORTED, convert -from homeassistant.components.climate.intent import INTENT_GET_TEMPERATURE -from homeassistant.components.conversation.trace import ( +from homeassistant.components.climate import INTENT_GET_TEMPERATURE +from homeassistant.components.conversation import ( ConversationTraceEventType, async_conversation_trace_append, ) -from homeassistant.components.cover.intent import INTENT_CLOSE_COVER, INTENT_OPEN_COVER -from homeassistant.components.homeassistant.exposed_entities import async_should_expose +from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER +from homeassistant.components.homeassistant import async_should_expose from homeassistant.components.intent import async_device_supports_timers from homeassistant.components.script import ATTR_VARIABLES, DOMAIN as SCRIPT_DOMAIN -from homeassistant.components.weather.intent import INTENT_GET_WEATHER +from homeassistant.components.weather import INTENT_GET_WEATHER from homeassistant.const import ( ATTR_DOMAIN, ATTR_ENTITY_ID, From c67698b34e47cdda8f318a38f5b67ede7518746e Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Fri, 13 Sep 2024 12:50:04 +0200 Subject: [PATCH 0602/1309] Bump autarco lib to v3.0.0 (#125867) Bump autarco to v3.0.0 --- homeassistant/components/autarco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/autarco/manifest.json b/homeassistant/components/autarco/manifest.json index f0900472b1e..0058ab9af77 100644 --- a/homeassistant/components/autarco/manifest.json +++ b/homeassistant/components/autarco/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/autarco", "iot_class": "cloud_polling", - "requirements": ["autarco==2.0.0"] + "requirements": ["autarco==3.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 776954fb983..bbe2f28b75a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -517,7 +517,7 @@ auroranoaa==0.0.3 aurorapy==0.2.7 # homeassistant.components.autarco -autarco==2.0.0 +autarco==3.0.0 # homeassistant.components.avea # avea==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03e18b64042..55abd6119de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -472,7 +472,7 @@ auroranoaa==0.0.3 aurorapy==0.2.7 # homeassistant.components.autarco -autarco==2.0.0 +autarco==3.0.0 # homeassistant.components.axis axis==62 From ff31efdbf7dc6bb4aedc3a2db1b84698ccfd4c9e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 13 Sep 2024 12:58:23 +0200 Subject: [PATCH 0603/1309] Bump aiotankerkoenig to 0.4.2 (#125855) --- homeassistant/components/tankerkoenig/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json index c754094655d..eeb8646bea7 100644 --- a/homeassistant/components/tankerkoenig/manifest.json +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["aiotankerkoenig"], "quality_scale": "platinum", - "requirements": ["aiotankerkoenig==0.4.1"] + "requirements": ["aiotankerkoenig==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index bbe2f28b75a..558039fd2be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,7 +386,7 @@ aioswitcher==4.0.3 aiosyncthing==0.5.1 # homeassistant.components.tankerkoenig -aiotankerkoenig==0.4.1 +aiotankerkoenig==0.4.2 # homeassistant.components.tractive aiotractive==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 55abd6119de..290bbb95089 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -368,7 +368,7 @@ aioswitcher==4.0.3 aiosyncthing==0.5.1 # homeassistant.components.tankerkoenig -aiotankerkoenig==0.4.1 +aiotankerkoenig==0.4.2 # homeassistant.components.tractive aiotractive==0.6.0 From 590b3d0fd4667285a94b0cdfda6ba163981c39d3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 13 Sep 2024 12:58:51 +0200 Subject: [PATCH 0604/1309] Remove deprecated YAML import from seventeentrack (#125852) --- .../components/seventeentrack/config_flow.py | 32 ------- .../components/seventeentrack/sensor.py | 83 ++----------------- .../components/seventeentrack/strings.json | 8 -- .../seventeentrack/test_config_flow.py | 76 +---------------- .../components/seventeentrack/test_sensor.py | 13 --- 5 files changed, 7 insertions(+), 205 deletions(-) diff --git a/homeassistant/components/seventeentrack/config_flow.py b/homeassistant/components/seventeentrack/config_flow.py index 4433a73cd51..f4f3b3e82ae 100644 --- a/homeassistant/components/seventeentrack/config_flow.py +++ b/homeassistant/components/seventeentrack/config_flow.py @@ -97,38 +97,6 @@ class SeventeenTrackConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import 17Track config from configuration.yaml.""" - - client = self._get_client() - - try: - login_result = await client.profile.login( - import_data[CONF_USERNAME], import_data[CONF_PASSWORD] - ) - except SeventeenTrackError: - return self.async_abort(reason="cannot_connect") - - if not login_result: - return self.async_abort(reason="invalid_auth") - - account_id = client.profile.account_id - - await self.async_set_unique_id(account_id) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=import_data[CONF_USERNAME], - data=import_data, - options={ - CONF_SHOW_ARCHIVED: import_data.get( - CONF_SHOW_ARCHIVED, DEFAULT_SHOW_ARCHIVED - ), - CONF_SHOW_DELIVERED: import_data.get( - CONF_SHOW_DELIVERED, DEFAULT_SHOW_DELIVERED - ), - }, - ) - @callback def _get_client(self): session = aiohttp_client.async_get_clientsession(self.hass) diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py index 3122065adae..4e561a87961 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -4,31 +4,15 @@ from __future__ import annotations from typing import Any -import voluptuous as vol - from homeassistant.components import persistent_notification -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_LOCATION, - CONF_PASSWORD, - CONF_USERNAME, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import ( - config_validation as cv, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SeventeenTrackCoordinator @@ -43,8 +27,6 @@ from .const import ( ATTR_TRACKING_INFO_LANGUAGE, ATTR_TRACKING_NUMBER, ATTRIBUTION, - CONF_SHOW_ARCHIVED, - CONF_SHOW_DELIVERED, DEPRECATED_KEY, DOMAIN, LOGGER, @@ -54,59 +36,6 @@ from .const import ( VALUE_DELIVERED, ) -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_SHOW_ARCHIVED, default=False): cv.boolean, - vol.Optional(CONF_SHOW_DELIVERED, default=False): cv.boolean, - } -) - -ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=seventeentrack"} - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Initialize 17Track import from config.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - if ( - result["type"] == FlowResultType.CREATE_ENTRY - or result["reason"] == "already_configured" - ): - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - is_fixable=False, - breaks_in_ha_version="2024.10.0", - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "17Track", - }, - ) - else: - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_import_issue_{result['reason']}", - breaks_in_ha_version="2024.10.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key=f"deprecated_yaml_import_issue_{result['reason']}", - translation_placeholders=ISSUE_PLACEHOLDER, - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json index fda5575ff95..bbd01ed3055 100644 --- a/homeassistant/components/seventeentrack/strings.json +++ b/homeassistant/components/seventeentrack/strings.json @@ -38,14 +38,6 @@ } }, "issues": { - "deprecated_yaml_import_issue_cannot_connect": { - "title": "The 17Track YAML configuration import cannot connect to server", - "description": "Configuring 17Track using YAML is being removed but there was a connection error importing your YAML configuration.\n\nThings you can try:\nMake sure your home assistant can reach the web.\n\nThen restart Home Assistant to try importing this integration again.\n\nAlternatively, you may remove the 17Track configuration from your YAML configuration entirely, restart Home Assistant, and add the 17Track integration manually." - }, - "deprecated_yaml_import_issue_invalid_auth": { - "title": "The 17Track YAML configuration import request failed due to invalid authentication", - "description": "Configuring 17Track using YAML is being removed but there were invalid credentials provided while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your 17Track credentials are correct and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the 17Track configuration from your YAML configuration entirely, restart Home Assistant, and add the 17Track integration manually." - }, "deprecate_sensor": { "title": "17Track package sensors are being deprecated", "fix_flow": { diff --git a/tests/components/seventeentrack/test_config_flow.py b/tests/components/seventeentrack/test_config_flow.py index 0a7c4ca918c..9ad592419c3 100644 --- a/tests/components/seventeentrack/test_config_flow.py +++ b/tests/components/seventeentrack/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.components.seventeentrack.const import ( CONF_SHOW_ARCHIVED, CONF_SHOW_DELIVERED, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -105,55 +105,6 @@ async def test_flow_fails( } -async def test_import_flow(hass: HomeAssistant, mock_seventeentrack: AsyncMock) -> None: - """Test the import configuration flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_CONFIG_OLD, - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "someemail@gmail.com" - assert result["data"][CONF_USERNAME] == "someemail@gmail.com" - assert result["data"][CONF_PASSWORD] == "edc3eee7330e4fdda04489e3fbc283d0" - - -@pytest.mark.parametrize( - ("return_value", "side_effect", "error"), - [ - ( - False, - None, - "invalid_auth", - ), - ( - True, - SeventeenTrackError(), - "cannot_connect", - ), - ], -) -async def test_import_flow_cannot_connect_error( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - return_value, - side_effect, - error, -) -> None: - """Test the import configuration flow with error.""" - mock_seventeentrack.return_value.profile.login.return_value = return_value - mock_seventeentrack.return_value.profile.login.side_effect = side_effect - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_CONFIG_OLD, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == error - - async def test_option_flow(hass: HomeAssistant, mock_seventeentrack: AsyncMock) -> None: """Test option flow.""" entry = MockConfigEntry( @@ -181,28 +132,3 @@ async def test_option_flow(hass: HomeAssistant, mock_seventeentrack: AsyncMock) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_SHOW_ARCHIVED] assert not result["data"][CONF_SHOW_DELIVERED] - - -async def test_import_flow_already_configured( - hass: HomeAssistant, mock_seventeentrack: AsyncMock -) -> None: - """Test the import configuration flow with error.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=VALID_CONFIG, - unique_id=ACCOUNT_ID, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - result_aborted = await hass.config_entries.flow.async_configure( - result["flow_id"], - VALID_CONFIG, - ) - await hass.async_block_till_done() - - assert result_aborted["type"] is FlowResultType.ABORT - assert result_aborted["reason"] == "already_configured" diff --git a/tests/components/seventeentrack/test_sensor.py b/tests/components/seventeentrack/test_sensor.py index ca16fc64833..a631996b4eb 100644 --- a/tests/components/seventeentrack/test_sensor.py +++ b/tests/components/seventeentrack/test_sensor.py @@ -8,7 +8,6 @@ from freezegun.api import FrozenDateTimeFactory from pyseventeentrack.errors import SeventeenTrackError from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from . import goto_future, init_integration @@ -306,15 +305,3 @@ async def test_non_valid_platform_config( assert await async_setup_component(hass, "sensor", VALID_PLATFORM_CONFIG_FULL) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 0 - - -async def test_full_valid_platform_config( - hass: HomeAssistant, - mock_seventeentrack: AsyncMock, - issue_registry: ir.IssueRegistry, -) -> None: - """Ensure everything starts correctly.""" - assert await async_setup_component(hass, "sensor", VALID_PLATFORM_CONFIG_FULL) - await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == len(DEFAULT_SUMMARY.keys()) - assert len(issue_registry.issues) == 2 From 19a09b93ddfcbf971e2c67f0489b700679cb62d1 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 13 Sep 2024 12:59:33 +0200 Subject: [PATCH 0605/1309] Bump pydiscovergy to 3.0.2 (#125853) --- homeassistant/components/discovergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index 1061766a64c..b82f28a5d11 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pydiscovergy==3.0.1"] + "requirements": ["pydiscovergy==3.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 558039fd2be..d448648bb31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1831,7 +1831,7 @@ pydelijn==1.1.0 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==3.0.1 +pydiscovergy==3.0.2 # homeassistant.components.doods pydoods==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 290bbb95089..67029d52bf6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1475,7 +1475,7 @@ pydeconz==116 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==3.0.1 +pydiscovergy==3.0.2 # homeassistant.components.hydrawise pydrawise==2024.8.0 From 13d83d86f6c8da8693103f900e4c99715310e396 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Fri, 13 Sep 2024 07:15:53 -0400 Subject: [PATCH 0606/1309] Add reauth flow to Nice G.O. (#125516) * Add reauth flow to Nice G.O. * Remove unnecessary freezer use * Tweaks * Remove re-raise * Tiny typing tweak * Remove if in test * Remove overlaying old data * Don't touch title once done --- .../components/nice_go/config_flow.py | 61 +++++++++++++++- homeassistant/components/nice_go/strings.json | 7 ++ tests/components/nice_go/test_config_flow.py | 70 +++++++++++++++++++ tests/components/nice_go/test_init.py | 37 +++++----- 4 files changed, 157 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/nice_go/config_flow.py b/homeassistant/components/nice_go/config_flow.py index 9d2c1c05518..94594bbd11f 100644 --- a/homeassistant/components/nice_go/config_flow.py +++ b/homeassistant/components/nice_go/config_flow.py @@ -2,17 +2,19 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import datetime import logging -from typing import Any +from typing import TYPE_CHECKING, Any from nice_go import AuthFailedError, NiceGOApi import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.const import CONF_EMAIL, CONF_NAME, CONF_PASSWORD from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import NiceGOConfigEntry from .const import CONF_REFRESH_TOKEN, CONF_REFRESH_TOKEN_CREATION_TIME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -29,6 +31,7 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Nice G.O.""" VERSION = 1 + reauth_entry: NiceGOConfigEntry | None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -66,3 +69,57 @@ class NiceGOConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle re-authentication.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm re-authentication.""" + errors = {} + + if TYPE_CHECKING: + assert self.reauth_entry is not None + + if user_input is not None: + hub = NiceGOApi() + + try: + refresh_token = await hub.authenticate( + user_input[CONF_EMAIL], + user_input[CONF_PASSWORD], + async_get_clientsession(self.hass), + ) + except AuthFailedError: + errors["base"] = "invalid_auth" + except Exception: # noqa: BLE001 + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + self.reauth_entry, + data={ + **user_input, + CONF_REFRESH_TOKEN: refresh_token, + CONF_REFRESH_TOKEN_CREATION_TIME: datetime.now().timestamp(), + }, + unique_id=user_input[CONF_EMAIL], + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, + user_input or {CONF_EMAIL: self.reauth_entry.data[CONF_EMAIL]}, + ), + description_placeholders={CONF_NAME: self.reauth_entry.title}, + errors=errors, + ) diff --git a/homeassistant/components/nice_go/strings.json b/homeassistant/components/nice_go/strings.json index 30a2bbf58b6..f83207ad977 100644 --- a/homeassistant/components/nice_go/strings.json +++ b/homeassistant/components/nice_go/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "data": { "email": "[%key:common::config_flow::data::email%]", diff --git a/tests/components/nice_go/test_config_flow.py b/tests/components/nice_go/test_config_flow.py index 67930b9f752..9c25a640c75 100644 --- a/tests/components/nice_go/test_config_flow.py +++ b/tests/components/nice_go/test_config_flow.py @@ -16,6 +16,8 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import setup_integration + from tests.common import MockConfigEntry @@ -109,3 +111,71 @@ async def test_duplicate_device( ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nice_go: AsyncMock, +) -> None: + """Test reauth flow.""" + + await setup_integration(hass, mock_config_entry, []) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-email", + CONF_PASSWORD: "other-fake-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [(AuthFailedError, "invalid_auth"), (Exception, "unknown")], +) +async def test_reauth_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nice_go: AsyncMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test we handle invalid auth.""" + mock_nice_go.authenticate.side_effect = side_effect + await setup_integration(hass, mock_config_entry, []) + + result = await mock_config_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + mock_nice_go.authenticate.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test-email", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/nice_go/test_init.py b/tests/components/nice_go/test_init.py index 9c9bf28ca7a..23d496df238 100644 --- a/tests/components/nice_go/test_init.py +++ b/tests/components/nice_go/test_init.py @@ -10,7 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.nice_go.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import issue_registry as ir @@ -33,29 +33,32 @@ async def test_unload_entry( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -@pytest.mark.parametrize( - ("side_effect", "entry_state"), - [ - ( - AuthFailedError(), - ConfigEntryState.SETUP_ERROR, - ), - (ApiError(), ConfigEntryState.SETUP_RETRY), - ], -) -async def test_setup_failure( +async def test_setup_failure_api_error( hass: HomeAssistant, mock_nice_go: AsyncMock, mock_config_entry: MockConfigEntry, - side_effect: Exception, - entry_state: ConfigEntryState, ) -> None: """Test reauth trigger setup.""" - mock_nice_go.authenticate_refresh.side_effect = side_effect + mock_nice_go.authenticate_refresh.side_effect = ApiError() await setup_integration(hass, mock_config_entry, []) - assert mock_config_entry.state is entry_state + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_failure_auth_failed( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth trigger setup.""" + + mock_nice_go.authenticate_refresh.side_effect = AuthFailedError() + + await setup_integration(hass, mock_config_entry, []) + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) async def test_firmware_update_required( @@ -176,6 +179,8 @@ async def test_update_refresh_token_auth_failed( assert mock_nice_go.get_all_barriers.call_count == 1 assert mock_config_entry.data["refresh_token"] == "test-refresh-token" assert "Authentication failed" in caplog.text + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any(mock_config_entry.async_get_active_flows(hass, {SOURCE_REAUTH})) async def test_client_listen_api_error( From c7e9096dfd96d2b7474a25ed3382f838671e7937 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 13 Sep 2024 13:22:37 +0200 Subject: [PATCH 0607/1309] Bump zwave-js-server-python to 0.58.0 (#125666) * Bump zwave-js-server-python to 0.58.0 * Update lock test --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_lock.py | 10 ++++++++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index f394537803a..9533c82f2c1 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.57.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.58.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index d448648bb31..05538d60ae0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3053,7 +3053,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.57.0 +zwave-js-server-python==0.58.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67029d52bf6..6e83f5b8426 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2424,7 +2424,7 @@ zeversolar==0.3.1 zha==0.0.32 # homeassistant.components.zwave_js -zwave-js-server-python==0.57.0 +zwave-js-server-python==0.58.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index e8a8a2035d8..274444d813e 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -95,7 +95,9 @@ async def test_door_lock( ) node.receive_event(event) - assert hass.states.get(SCHLAGE_BE469_LOCK_ENTITY).state == STATE_LOCKED + state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) + assert state + assert state.state == STATE_LOCKED client.async_send_command.reset_mock() @@ -194,6 +196,7 @@ async def test_door_lock( "insideHandlesCanOpenDoorConfiguration": [True, True, True, True], "operationType": 2, "outsideHandlesCanOpenDoorConfiguration": [True, True, True, True], + "lockTimeoutConfiguration": 1, } ] assert args["commandClass"] == 98 @@ -239,6 +242,7 @@ async def test_door_lock( "insideHandlesCanOpenDoorConfiguration": [True, True, True, True], "operationType": 2, "outsideHandlesCanOpenDoorConfiguration": [True, True, True, True], + "lockTimeoutConfiguration": 1, } ] assert args["commandClass"] == 98 @@ -294,7 +298,9 @@ async def test_door_lock( node.receive_event(event) assert node.status == NodeStatus.DEAD - assert hass.states.get(SCHLAGE_BE469_LOCK_ENTITY).state == STATE_UNAVAILABLE + state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) + assert state + assert state.state == STATE_UNAVAILABLE async def test_only_one_lock( From 5d9c986f8749f3fc51c394233829dbcc89bde1fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 13 Sep 2024 13:33:57 +0200 Subject: [PATCH 0608/1309] Bump aiogithubapi from 23.11.0 to 24.6.0 (#125819) --- homeassistant/components/github/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json index cae2e7faca9..e202f805ec6 100644 --- a/homeassistant/components/github/manifest.json +++ b/homeassistant/components/github/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/github", "iot_class": "cloud_polling", "loggers": ["aiogithubapi"], - "requirements": ["aiogithubapi==23.11.0"] + "requirements": ["aiogithubapi==24.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 05538d60ae0..026686e972c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -249,7 +249,7 @@ aioflo==2021.11.0 aioftp==0.21.3 # homeassistant.components.github -aiogithubapi==23.11.0 +aiogithubapi==24.6.0 # homeassistant.components.guardian aioguardian==2022.07.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e83f5b8426..13596734eed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -234,7 +234,7 @@ aioesphomeapi==25.4.0 aioflo==2021.11.0 # homeassistant.components.github -aiogithubapi==23.11.0 +aiogithubapi==24.6.0 # homeassistant.components.guardian aioguardian==2022.07.0 From 6aa07243cd450164db7944dbe66bed1f52a590b6 Mon Sep 17 00:00:00 2001 From: TimL Date: Fri, 13 Sep 2024 21:36:54 +1000 Subject: [PATCH 0609/1309] Add info based sensors to Smlight integration (#125482) * Move entity category to class * improve type hints * Regenerate sensor snapshots to remove some invalid entries * Add info sensors that display various device settings/modes * Add strings for info sensors * Update sensor snapshot with new sensors * Use StateType Co-authored-by: Joost Lekkerkerker * Use icon translations * statetype * drop ip sensor * Lookup enum values before translating * entities use options * update options strings strings * lookup values from options * update sensor snapshot --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/smlight/icons.json | 15 + homeassistant/components/smlight/sensor.py | 73 +- homeassistant/components/smlight/strings.json | 23 + .../smlight/snapshots/test_sensor.ambr | 802 ++++-------------- 4 files changed, 278 insertions(+), 635 deletions(-) create mode 100644 homeassistant/components/smlight/icons.json diff --git a/homeassistant/components/smlight/icons.json b/homeassistant/components/smlight/icons.json new file mode 100644 index 00000000000..3d086466b4f --- /dev/null +++ b/homeassistant/components/smlight/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "device_mode": { + "default": "mdi:connection" + }, + "firmware_channel": { + "default": "mdi:update" + }, + "zigbee_type": { + "default": "mdi:zigbee" + } + } + } +} diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index f5193522c4c..8da6e354fd7 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from itertools import chain -from pysmlight import Sensors +from pysmlight import Info, Sensors from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,6 +18,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from . import SmConfigEntry @@ -30,11 +31,42 @@ from .entity import SmEntity class SmSensorEntityDescription(SensorEntityDescription): """Class describing SMLIGHT sensor entities.""" - entity_category = EntityCategory.DIAGNOSTIC value_fn: Callable[[Sensors], float | None] -SENSORS = [ +@dataclass(frozen=True, kw_only=True) +class SmInfoEntityDescription(SensorEntityDescription): + """Class describing SMLIGHT information entities.""" + + value_fn: Callable[[Info], StateType] + + +INFO: list[SmInfoEntityDescription] = [ + SmInfoEntityDescription( + key="device_mode", + translation_key="device_mode", + device_class=SensorDeviceClass.ENUM, + options=["eth", "wifi", "usb"], + value_fn=lambda x: x.coord_mode, + ), + SmInfoEntityDescription( + key="firmware_channel", + translation_key="firmware_channel", + device_class=SensorDeviceClass.ENUM, + options=["dev", "release"], + value_fn=lambda x: x.fw_channel, + ), + SmInfoEntityDescription( + key="zigbee_type", + translation_key="zigbee_type", + device_class=SensorDeviceClass.ENUM, + options=["coordinator", "router", "thread"], + value_fn=lambda x: x.zb_type, + ), +] + + +SENSORS: list[SmSensorEntityDescription] = [ SmSensorEntityDescription( key="core_temperature", translation_key="core_temperature", @@ -71,7 +103,7 @@ SENSORS = [ ), ] -UPTIME = [ +UPTIME: list[SmSensorEntityDescription] = [ SmSensorEntityDescription( key="core_uptime", translation_key="core_uptime", @@ -99,6 +131,7 @@ async def async_setup_entry( async_add_entities( chain( + (SmInfoSensorEntity(coordinator, description) for description in INFO), (SmSensorEntity(coordinator, description) for description in SENSORS), (SmUptimeSensorEntity(coordinator, description) for description in UPTIME), ) @@ -109,6 +142,7 @@ class SmSensorEntity(SmEntity, SensorEntity): """Representation of a slzb sensor.""" entity_description: SmSensorEntityDescription + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, @@ -122,11 +156,40 @@ class SmSensorEntity(SmEntity, SensorEntity): self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" @property - def native_value(self) -> datetime | float | None: + def native_value(self) -> datetime | str | float | None: """Return the sensor value.""" return self.entity_description.value_fn(self.coordinator.data.sensors) +class SmInfoSensorEntity(SmEntity, SensorEntity): + """Representation of a slzb info sensor.""" + + entity_description: SmInfoEntityDescription + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: SmDataUpdateCoordinator, + description: SmInfoEntityDescription, + ) -> None: + """Initiate slzb sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + value = self.entity_description.value_fn(self.coordinator.data.info) + options = self.entity_description.options + + if isinstance(value, int) and options is not None: + value = options[value] if 0 <= value < len(options) else None + + return value + + class SmUptimeSensorEntity(SmSensorEntity): """Representation of a slzb uptime sensor.""" diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 8628a49a13c..ad36711528b 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -68,6 +68,29 @@ }, "socket_uptime": { "name": "Zigbee uptime" + }, + "device_mode": { + "name": "Connection mode", + "state": { + "eth": "Ethernet", + "wifi": "Wi-Fi", + "usb": "USB" + } + }, + "firmware_channel": { + "name": "Firmware channel", + "state": { + "dev": "Development", + "release": "Stable" + } + }, + "zigbee_type": { + "name": "Zigbee type", + "state": { + "coordinator": "Coordinator", + "router": "Router", + "thread": "Thread" + } } }, "button": { diff --git a/tests/components/smlight/snapshots/test_sensor.ambr b/tests/components/smlight/snapshots/test_sensor.ambr index 6895a8473bd..7abc5ef4f64 100644 --- a/tests/components/smlight/snapshots/test_sensor.ambr +++ b/tests/components/smlight/snapshots/test_sensor.ambr @@ -1,4 +1,62 @@ # serializer version: 1 +# name: test_sensors[sensor.mock_title_connection_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'eth', + 'wifi', + 'usb', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_connection_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Connection mode', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'device_mode', + 'unique_id': 'aa:bb:cc:dd:ee:ff_device_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_connection_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Connection mode', + 'options': list([ + 'eth', + 'wifi', + 'usb', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_connection_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'eth', + }) +# --- # name: test_sensors[sensor.mock_title_core_chip_temp-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -148,6 +206,62 @@ 'state': '188', }) # --- +# name: test_sensors[sensor.mock_title_firmware_channel-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'dev', + 'release', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_firmware_channel', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware channel', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'firmware_channel', + 'unique_id': 'aa:bb:cc:dd:ee:ff_firmware_channel', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_firmware_channel-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Firmware channel', + 'options': list([ + 'dev', + 'release', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_firmware_channel', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'dev', + }) +# --- # name: test_sensors[sensor.mock_title_ram_usage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -196,100 +310,6 @@ 'state': '99', }) # --- -# name: test_sensors[sensor.mock_title_timestamp-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.mock_title_timestamp', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Timestamp', - 'platform': 'smlight', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'core_uptime', - 'unique_id': 'aa:bb:cc:dd:ee:ff_core_uptime', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.mock_title_timestamp-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Mock Title Timestamp', - }), - 'context': , - 'entity_id': 'sensor.mock_title_timestamp', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2024-06-25T02:51:15+00:00', - }) -# --- -# name: test_sensors[sensor.mock_title_timestamp_2-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.mock_title_timestamp_2', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Timestamp', - 'platform': 'smlight', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'socket_uptime', - 'unique_id': 'aa:bb:cc:dd:ee:ff_socket_uptime', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[sensor.mock_title_timestamp_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - 'friendly_name': 'Mock Title Timestamp', - }), - 'context': , - 'entity_id': 'sensor.mock_title_timestamp_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2024-06-30T23:57:53+00:00', - }) -# --- # name: test_sensors[sensor.mock_title_zigbee_chip_temp-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -344,6 +364,64 @@ 'state': '32.7', }) # --- +# name: test_sensors[sensor.mock_title_zigbee_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'coordinator', + 'router', + 'thread', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_zigbee_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zigbee type', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'zigbee_type', + 'unique_id': 'aa:bb:cc:dd:ee:ff_zigbee_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_zigbee_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Zigbee type', + 'options': list([ + 'coordinator', + 'router', + 'thread', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_zigbee_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensors[sensor.mock_title_zigbee_uptime-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -391,539 +469,3 @@ 'state': '2024-06-30T23:57:53+00:00', }) # --- -# name: test_sensors[sensor.slzb_06_core_chip_temp-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.slzb_06_core_chip_temp', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Core chip temp', - 'platform': 'smlight', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'core_temperature', - 'unique_id': 'aa:bb:cc:dd:ee:ff_core_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.slzb_06_core_chip_temp-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'slzb-06 Core chip temp', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.slzb_06_core_chip_temp', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '35.0', - }) -# --- -# name: test_sensors[sensor.slzb_06_core_chip_temp] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'slzb-06 Core chip temp', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.slzb_06_core_chip_temp', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '35.0', - }) -# --- -# name: test_sensors[sensor.slzb_06_core_chip_temp].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.slzb_06_core_chip_temp', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Core chip temp', - 'platform': 'smlight', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'core_temperature', - 'unique_id': 'aa:bb:cc:dd:ee:ff_core_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.slzb_06_core_chip_temp].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'http://slzb-06.local', - 'connections': set({ - tuple( - 'mac', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'SMLIGHT', - 'model': 'SLZB-06p7', - 'model_id': None, - 'name': 'slzb-06', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': 'core: v2.3.1.dev / zigbee: -1', - 'via_device_id': None, - }) -# --- -# name: test_sensors[sensor.slzb_06_filesystem_usage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.slzb_06_filesystem_usage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Filesystem usage', - 'platform': 'smlight', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fs_usage', - 'unique_id': 'aa:bb:cc:dd:ee:ff_fs_usage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.slzb_06_filesystem_usage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'data_size', - 'friendly_name': 'slzb-06 Filesystem usage', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.slzb_06_filesystem_usage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '188', - }) -# --- -# name: test_sensors[sensor.slzb_06_filesystem_usage] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'data_size', - 'friendly_name': 'slzb-06 Filesystem usage', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.slzb_06_filesystem_usage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '188', - }) -# --- -# name: test_sensors[sensor.slzb_06_filesystem_usage].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.slzb_06_filesystem_usage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Filesystem usage', - 'platform': 'smlight', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'fs_usage', - 'unique_id': 'aa:bb:cc:dd:ee:ff_fs_usage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.slzb_06_filesystem_usage].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'http://slzb-06.local', - 'connections': set({ - tuple( - 'mac', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'SMLIGHT', - 'model': 'SLZB-06p7', - 'model_id': None, - 'name': 'slzb-06', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': 'core: v2.3.1.dev / zigbee: -1', - 'via_device_id': None, - }) -# --- -# name: test_sensors[sensor.slzb_06_ram_usage-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.slzb_06_ram_usage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'RAM usage', - 'platform': 'smlight', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'ram_usage', - 'unique_id': 'aa:bb:cc:dd:ee:ff_ram_usage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.slzb_06_ram_usage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'data_size', - 'friendly_name': 'slzb-06 RAM usage', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.slzb_06_ram_usage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '99', - }) -# --- -# name: test_sensors[sensor.slzb_06_ram_usage] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'data_size', - 'friendly_name': 'slzb-06 RAM usage', - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.slzb_06_ram_usage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '99', - }) -# --- -# name: test_sensors[sensor.slzb_06_ram_usage].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.slzb_06_ram_usage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'RAM usage', - 'platform': 'smlight', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'ram_usage', - 'unique_id': 'aa:bb:cc:dd:ee:ff_ram_usage', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.slzb_06_ram_usage].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'http://slzb-06.local', - 'connections': set({ - tuple( - 'mac', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'SMLIGHT', - 'model': 'SLZB-06p7', - 'model_id': None, - 'name': 'slzb-06', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': 'core: v2.3.1.dev / zigbee: -1', - 'via_device_id': None, - }) -# --- -# name: test_sensors[sensor.slzb_06_zigbee_chip_temp-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.slzb_06_zigbee_chip_temp', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Zigbee chip temp', - 'platform': 'smlight', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'zigbee_temperature', - 'unique_id': 'aa:bb:cc:dd:ee:ff_zigbee_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.slzb_06_zigbee_chip_temp-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'slzb-06 Zigbee chip temp', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.slzb_06_zigbee_chip_temp', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '32.7', - }) -# --- -# name: test_sensors[sensor.slzb_06_zigbee_chip_temp] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'slzb-06 Zigbee chip temp', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.slzb_06_zigbee_chip_temp', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '32.7', - }) -# --- -# name: test_sensors[sensor.slzb_06_zigbee_chip_temp].1 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.slzb_06_zigbee_chip_temp', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 1, - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Zigbee chip temp', - 'platform': 'smlight', - 'previous_unique_id': None, - 'supported_features': 0, - 'translation_key': 'zigbee_temperature', - 'unique_id': 'aa:bb:cc:dd:ee:ff_zigbee_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[sensor.slzb_06_zigbee_chip_temp].2 - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'configuration_url': 'http://slzb-06.local', - 'connections': set({ - tuple( - 'mac', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'SMLIGHT', - 'model': 'SLZB-06p7', - 'model_id': None, - 'name': 'slzb-06', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': 'core: v2.3.1.dev / zigbee: -1', - 'via_device_id': None, - }) -# --- From 1ae1391cb927d80879ebe6d004817eaae06c5ebf Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Fri, 13 Sep 2024 14:04:00 +0200 Subject: [PATCH 0610/1309] Add platform sensor to BSBLAN integration (#125474) * add sensor platform * refactor: Add sensor data to async_get_config_entry_diagnostics * refactor: Add tests for sensor * chore: remove duplicate test * Update tests/components/bsblan/test_sensor.py Co-authored-by: Joost Lekkerkerker * refactor: let hass use translation_key fix raise * refactor: Add new sensor entity names to strings.json * refactor: Add tests for current temperature sensor * refactor: Update native_value method in BSBLanSensor * refactor: Update test --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/bsblan/__init__.py | 2 +- .../components/bsblan/coordinator.py | 6 +- .../components/bsblan/diagnostics.py | 1 + homeassistant/components/bsblan/sensor.py | 84 ++++++++++++++ homeassistant/components/bsblan/strings.json | 10 ++ tests/components/bsblan/conftest.py | 5 +- tests/components/bsblan/fixtures/sensor.json | 20 ++++ .../bsblan/snapshots/test_diagnostics.ambr | 16 +++ .../bsblan/snapshots/test_sensor.ambr | 103 ++++++++++++++++++ tests/components/bsblan/test_sensor.py | 66 +++++++++++ 10 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/bsblan/sensor.py create mode 100644 tests/components/bsblan/fixtures/sensor.json create mode 100644 tests/components/bsblan/snapshots/test_sensor.ambr create mode 100644 tests/components/bsblan/test_sensor.py diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 5ce90db5043..79447c6cff5 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_PASSKEY, DOMAIN from .coordinator import BSBLanUpdateCoordinator -PLATFORMS = [Platform.CLIMATE] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] @dataclasses.dataclass diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index 3320c0f7500..508f2c898c3 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import timedelta from random import randint -from bsblan import BSBLAN, BSBLANConnectionError, State +from bsblan import BSBLAN, BSBLANConnectionError, Sensor, State from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -19,6 +19,7 @@ class BSBLanCoordinatorData: """BSBLan data stored in the Home Assistant data object.""" state: State + sensor: Sensor class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]): @@ -54,6 +55,7 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]): """Get state and sensor data from BSB-Lan device.""" try: state = await self.client.state() + sensor = await self.client.sensor() except BSBLANConnectionError as err: host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown" raise UpdateFailed( @@ -61,4 +63,4 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]): ) from err self.update_interval = self._get_update_interval() - return BSBLanCoordinatorData(state=state) + return BSBLanCoordinatorData(state=state, sensor=sensor) diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py index b4ff67f4fbf..88418f306c8 100644 --- a/homeassistant/components/bsblan/diagnostics.py +++ b/homeassistant/components/bsblan/diagnostics.py @@ -22,6 +22,7 @@ async def async_get_config_entry_diagnostics( "device": data.device.to_dict(), "coordinator_data": { "state": data.coordinator.data.state.to_dict(), + "sensor": data.coordinator.data.sensor.to_dict(), }, "static": data.static.to_dict(), } diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py new file mode 100644 index 00000000000..346f972ea9a --- /dev/null +++ b/homeassistant/components/bsblan/sensor.py @@ -0,0 +1,84 @@ +"""Support for BSB-Lan sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import BSBLanData +from .const import DOMAIN +from .coordinator import BSBLanCoordinatorData +from .entity import BSBLanEntity + + +@dataclass(frozen=True, kw_only=True) +class BSBLanSensorEntityDescription(SensorEntityDescription): + """Describes BSB-Lan sensor entity.""" + + value_fn: Callable[[BSBLanCoordinatorData], StateType] + + +SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = ( + BSBLanSensorEntityDescription( + key="current_temperature", + translation_key="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.sensor.current_temperature.value, + ), + BSBLanSensorEntityDescription( + key="outside_temperature", + translation_key="outside_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.sensor.outside_temperature.value, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up BSB-Lan sensor based on a config entry.""" + data: BSBLanData = hass.data[DOMAIN][entry.entry_id] + async_add_entities(BSBLanSensor(data, description) for description in SENSOR_TYPES) + + +class BSBLanSensor(BSBLanEntity, SensorEntity): + """Defines a BSB-Lan sensor.""" + + entity_description: BSBLanSensorEntityDescription + + def __init__( + self, + data: BSBLanData, + description: BSBLanSensorEntityDescription, + ) -> None: + """Initialize BSB-Lan sensor.""" + super().__init__(data.coordinator, data) + self.entity_description = description + self._attr_unique_id = f"{data.device.MAC}-{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + value = self.entity_description.value_fn(self.coordinator.data) + if value == "---": + return None + return value diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 7a67d353803..4fb374fee75 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -32,5 +32,15 @@ "set_data_error": { "message": "An error occurred while sending the data to the BSBLAN device" } + }, + "entity": { + "sensor": { + "current_temperature": { + "name": "Current Temperature" + }, + "outside_temperature": { + "name": "Outside Temperature" + } + } } } diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index 96445a4bb23..68f716d836b 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch -from bsblan import Device, Info, State, StaticState +from bsblan import Device, Info, Sensor, State, StaticState import pytest from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN @@ -55,6 +55,9 @@ def mock_bsblan() -> Generator[MagicMock, None, None]: bsblan.static_values.return_value = StaticState.from_json( load_fixture("static.json", DOMAIN) ) + bsblan.sensor.return_value = Sensor.from_json( + load_fixture("sensor.json", DOMAIN) + ) yield bsblan diff --git a/tests/components/bsblan/fixtures/sensor.json b/tests/components/bsblan/fixtures/sensor.json new file mode 100644 index 00000000000..3448e7e98d8 --- /dev/null +++ b/tests/components/bsblan/fixtures/sensor.json @@ -0,0 +1,20 @@ +{ + "outside_temperature": { + "name": "Outside temp sensor local", + "error": 0, + "value": "6.1", + "desc": "", + "dataType": 0, + "readonly": 0, + "unit": "°C" + }, + "current_temperature": { + "name": "Room temp 1 actual value", + "error": 0, + "value": "18.6", + "desc": "", + "dataType": 0, + "readonly": 1, + "unit": "°C" + } +} diff --git a/tests/components/bsblan/snapshots/test_diagnostics.ambr b/tests/components/bsblan/snapshots/test_diagnostics.ambr index c9a82edf4e2..c1d152056ec 100644 --- a/tests/components/bsblan/snapshots/test_diagnostics.ambr +++ b/tests/components/bsblan/snapshots/test_diagnostics.ambr @@ -2,6 +2,22 @@ # name: test_diagnostics dict({ 'coordinator_data': dict({ + 'sensor': dict({ + 'current_temperature': dict({ + 'data_type': 0, + 'desc': '', + 'name': 'Room temp 1 actual value', + 'unit': '°C', + 'value': '18.6', + }), + 'outside_temperature': dict({ + 'data_type': 0, + 'desc': '', + 'name': 'Outside temp sensor local', + 'unit': '°C', + 'value': '6.1', + }), + }), 'state': dict({ 'current_temperature': dict({ 'data_type': 0, diff --git a/tests/components/bsblan/snapshots/test_sensor.ambr b/tests/components/bsblan/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..0146dd23b3d --- /dev/null +++ b/tests/components/bsblan/snapshots/test_sensor.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_sensor_entity_properties[sensor.bsb_lan_current_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bsb_lan_current_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current Temperature', + 'platform': 'bsblan', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'current_temperature', + 'unique_id': '00:80:41:19:69:90-current_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entity_properties[sensor.bsb_lan_current_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BSB-LAN Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bsb_lan_current_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.6', + }) +# --- +# name: test_sensor_entity_properties[sensor.bsb_lan_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.bsb_lan_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside Temperature', + 'platform': 'bsblan', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': '00:80:41:19:69:90-outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entity_properties[sensor.bsb_lan_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BSB-LAN Outside Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.bsb_lan_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.1', + }) +# --- diff --git a/tests/components/bsblan/test_sensor.py b/tests/components/bsblan/test_sensor.py new file mode 100644 index 00000000000..dc22574168d --- /dev/null +++ b/tests/components/bsblan/test_sensor.py @@ -0,0 +1,66 @@ +"""Tests for the BSB-Lan sensor platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_CURRENT_TEMP = "sensor.bsb_lan_current_temperature" +ENTITY_OUTSIDE_TEMP = "sensor.bsb_lan_outside_temperature" + + +async def test_sensor_entity_properties( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor entity properties.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("value", "expected_state"), + [ + (18.6, "18.6"), + (None, STATE_UNKNOWN), + ("---", STATE_UNKNOWN), + ], +) +async def test_current_temperature_scenarios( + hass: HomeAssistant, + mock_bsblan: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + value, + expected_state, +) -> None: + """Test various scenarios for current temperature sensor.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.SENSOR]) + + # Set up the mock value + mock_current_temp = MagicMock() + mock_current_temp.value = value + mock_bsblan.sensor.return_value.current_temperature = mock_current_temp + + # Trigger an update + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check the state + state = hass.states.get(ENTITY_CURRENT_TEMP) + assert state.state == expected_state From d2289fa5425c477ecff3c1ce9596240570cb9eb5 Mon Sep 17 00:00:00 2001 From: Adam Pasztor Date: Fri, 13 Sep 2024 14:05:37 +0200 Subject: [PATCH 0611/1309] Add select platform to ADS integration (#125892) * Add ADS Select integration * fix: review feedback. --- homeassistant/components/ads/select.py | 86 ++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 homeassistant/components/ads/select.py diff --git a/homeassistant/components/ads/select.py b/homeassistant/components/ads/select.py new file mode 100644 index 00000000000..39f813dec27 --- /dev/null +++ b/homeassistant/components/ads/select.py @@ -0,0 +1,86 @@ +"""Support for ADS select entities.""" + +from __future__ import annotations + +import pyads +import voluptuous as vol + +from homeassistant.components.select import ( + PLATFORM_SCHEMA as SELECT_PLATFORM_SCHEMA, + SelectEntity, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import CONF_ADS_VAR, DATA_ADS +from .entity import AdsEntity +from .hub import AdsHub + +DEFAULT_NAME = "ADS select" + +CONF_OPTIONS = "options" + +PLATFORM_SCHEMA = SELECT_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ADS_VAR): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_OPTIONS): vol.All(cv.ensure_list, [cv.string]), + } +) + + +def setup_platform( + hass: HomeAssistant, + config: ConfigType, + add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up an ADS select device.""" + ads_hub = hass.data[DATA_ADS] + + ads_var: str = config[CONF_ADS_VAR] + name: str = config[CONF_NAME] + options: list[str] = config[CONF_OPTIONS] + + entity = AdsSelect(ads_hub, ads_var, name, options) + + add_entities([entity]) + + +class AdsSelect(AdsEntity, SelectEntity): + """Representation of an ADS select entity.""" + + def __init__( + self, + ads_hub: AdsHub, + ads_var: str, + name: str, + options: list[str], + ) -> None: + """Initialize the AdsSelect entity.""" + super().__init__(ads_hub, name, ads_var) + self._attr_options = options + self._attr_current_option = None + + async def async_added_to_hass(self) -> None: + """Register device notification.""" + await self.async_initialize_device(self._ads_var, pyads.PLCTYPE_INT) + self._ads_hub.add_device_notification( + self._ads_var, pyads.PLCTYPE_INT, self._handle_ads_value + ) + + def select_option(self, option: str) -> None: + """Change the selected option.""" + if option in self._attr_options: + index = self._attr_options.index(option) + self._ads_hub.write_by_name(self._ads_var, index, pyads.PLCTYPE_INT) + self._attr_current_option = option + + def _handle_ads_value(self, name: str, value: int) -> None: + """Handle the value update from ADS.""" + if 0 <= value < len(self._attr_options): + self._attr_current_option = self._attr_options[value] + self.schedule_update_ha_state() From 8af6ffdb49734a7aaf19097f77254dea85e3f497 Mon Sep 17 00:00:00 2001 From: Josef Zweck <24647999+zweckj@users.noreply.github.com> Date: Fri, 13 Sep 2024 14:08:29 +0200 Subject: [PATCH 0612/1309] Bump lmcloud to 1.2.3 (#125801) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 181a2b9ab9b..a1da8982cd8 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["lmcloud"], - "requirements": ["lmcloud==1.2.2"] + "requirements": ["lmcloud==1.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 026686e972c..3ec9f3bccfb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1297,7 +1297,7 @@ linear-garage-door==0.2.9 linode-api==4.1.9b1 # homeassistant.components.lamarzocco -lmcloud==1.2.2 +lmcloud==1.2.3 # homeassistant.components.google_maps locationsharinglib==5.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13596734eed..b96e805de1b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1078,7 +1078,7 @@ libsoundtouch==0.8 linear-garage-door==0.2.9 # homeassistant.components.lamarzocco -lmcloud==1.2.2 +lmcloud==1.2.3 # homeassistant.components.london_underground london-tube-status==0.5 From e71709f0ec00b22abd2e482508f23dc23deb44d8 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 13 Sep 2024 22:15:25 +1000 Subject: [PATCH 0613/1309] Add switch platform to Tesla Fleet (#125798) * Add switch platform * Add tests --- .../components/tesla_fleet/__init__.py | 1 + .../components/tesla_fleet/entity.py | 19 + .../components/tesla_fleet/icons.json | 35 ++ .../components/tesla_fleet/strings.json | 41 ++ .../components/tesla_fleet/switch.py | 262 ++++++++++ .../tesla_fleet/snapshots/test_switch.ambr | 489 ++++++++++++++++++ tests/components/tesla_fleet/test_switch.py | 194 +++++++ 7 files changed, 1041 insertions(+) create mode 100644 homeassistant/components/tesla_fleet/switch.py create mode 100644 tests/components/tesla_fleet/snapshots/test_switch.ambr create mode 100644 tests/components/tesla_fleet/test_switch.py diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 3bcb0bf7ef9..61a1d02c355 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -44,6 +44,7 @@ PLATFORMS: Final = [ Platform.CLIMATE, Platform.DEVICE_TRACKER, Platform.SENSOR, + Platform.SWITCH, ] type TeslaFleetConfigEntry = ConfigEntry[TeslaFleetData] diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index 103fd216953..a7d649bce56 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -4,7 +4,9 @@ from abc import abstractmethod from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific +from tesla_fleet_api.const import Scope +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -29,6 +31,7 @@ class TeslaFleetEntity( _attr_has_entity_name = True read_only: bool + scoped: bool def __init__( self, @@ -78,6 +81,14 @@ class TeslaFleetEntity( def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" + def raise_for_read_only(self, scope: Scope) -> None: + """Raise an error if a scope is not available.""" + if not self.scoped: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key=f"missing_scope_{scope.name.lower()}", + ) + class TeslaFleetVehicleEntity(TeslaFleetEntity): """Parent class for TeslaFleet Vehicle entities.""" @@ -106,6 +117,14 @@ class TeslaFleetVehicleEntity(TeslaFleetEntity): """Wake up the vehicle if its asleep.""" await wake_up_vehicle(self.vehicle) + def raise_for_read_only(self, scope: Scope) -> None: + """Raise an error if no command signing or a scope is not available.""" + if self.vehicle.signing: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="command_signing" + ) + super().raise_for_read_only(scope) + class TeslaFleetEnergyLiveEntity(TeslaFleetEntity): """Parent class for TeslaFleet Energy Site Live entities.""" diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index dc40f282037..d25346fe2a7 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -121,6 +121,41 @@ "wall_connector_state": { "default": "mdi:ev-station" } + }, + "switch": { + "charge_state_user_charge_enable_request": { + "default": "mdi:ev-station" + }, + "climate_state_auto_seat_climate_left": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_auto_seat_climate_right": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_auto_steering_wheel_heat": { + "default": "mdi:steering" + }, + "climate_state_defrost_mode": { + "default": "mdi:snowflake-melt" + }, + "components_disallow_charge_from_grid_with_solar_installed": { + "state": { + "false": "mdi:transmission-tower", + "true": "mdi:solar-power" + } + }, + "vehicle_state_sentry_mode": { + "default": "mdi:shield-car" + }, + "vehicle_state_valet_mode": { + "default": "mdi:speedometer-slow" + } } } } diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 5b59d3efc5c..8a70fe0997a 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -286,6 +286,35 @@ "wall_connector_state": { "name": "State code" } + }, + "switch": { + "charge_state_user_charge_enable_request": { + "name": "Charge" + }, + "climate_state_auto_seat_climate_left": { + "name": "Auto seat climate left" + }, + "climate_state_auto_seat_climate_right": { + "name": "Auto seat climate right" + }, + "climate_state_auto_steering_wheel_heat": { + "name": "Auto steering wheel heater" + }, + "climate_state_defrost_mode": { + "name": "Defrost" + }, + "components_disallow_charge_from_grid_with_solar_installed": { + "name": "Allow charging from grid" + }, + "user_settings_storm_mode_enabled": { + "name": "Storm watch" + }, + "vehicle_state_sentry_mode": { + "name": "Sentry mode" + }, + "vehicle_state_valet_mode": { + "name": "Valet mode" + } } }, "exceptions": { @@ -304,6 +333,9 @@ "command_no_reason": { "message": "Command was unsuccessful but did not return a reason why." }, + "command_signing": { + "message": "Vehicle requires command signing. Please see documentation for more details." + }, "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature." }, @@ -312,6 +344,15 @@ }, "missing_temperature": { "message": "Temperature is required for this action." + }, + "missing_scope_vehicle_cmds": { + "message": "Missing vehicle commands scope." + }, + "missing_scope_vehicle_charging_cmds": { + "message": "Missing vehicle charging commands scope." + }, + "missing_scope_energy_cmds": { + "message": "Missing energy commands scope." } } } diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py new file mode 100644 index 00000000000..d602cff78c0 --- /dev/null +++ b/homeassistant/components/tesla_fleet/switch.py @@ -0,0 +1,262 @@ +"""Switch platform for Tesla Fleet integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from itertools import chain +from typing import Any + +from tesla_fleet_api.const import Scope, Seat + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslaFleetConfigEntry +from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity +from .helpers import handle_command, handle_vehicle_command +from .models import TeslaFleetEnergyData, TeslaFleetVehicleData + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TeslaFleetSwitchEntityDescription(SwitchEntityDescription): + """Describes TeslaFleet Switch entity.""" + + on_func: Callable + off_func: Callable + scopes: list[Scope] + + +VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = ( + TeslaFleetSwitchEntityDescription( + key="vehicle_state_sentry_mode", + on_func=lambda api: api.set_sentry_mode(on=True), + off_func=lambda api: api.set_sentry_mode(on=False), + scopes=[Scope.VEHICLE_CMDS], + ), + TeslaFleetSwitchEntityDescription( + key="climate_state_auto_seat_climate_left", + on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True), + off_func=lambda api: api.remote_auto_seat_climate_request( + Seat.FRONT_LEFT, False + ), + scopes=[Scope.VEHICLE_CMDS], + ), + TeslaFleetSwitchEntityDescription( + key="climate_state_auto_seat_climate_right", + on_func=lambda api: api.remote_auto_seat_climate_request( + Seat.FRONT_RIGHT, True + ), + off_func=lambda api: api.remote_auto_seat_climate_request( + Seat.FRONT_RIGHT, False + ), + scopes=[Scope.VEHICLE_CMDS], + ), + TeslaFleetSwitchEntityDescription( + key="climate_state_auto_steering_wheel_heat", + on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request( + on=True + ), + off_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request( + on=False + ), + scopes=[Scope.VEHICLE_CMDS], + ), + TeslaFleetSwitchEntityDescription( + key="climate_state_defrost_mode", + on_func=lambda api: api.set_preconditioning_max(on=True, manual_override=False), + off_func=lambda api: api.set_preconditioning_max( + on=False, manual_override=False + ), + scopes=[Scope.VEHICLE_CMDS], + ), +) + +VEHICLE_CHARGE_DESCRIPTION = TeslaFleetSwitchEntityDescription( + key="charge_state_user_charge_enable_request", + on_func=lambda api: api.charge_start(), + off_func=lambda api: api.charge_stop(), + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslaFleetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the TeslaFleet Switch platform from a config entry.""" + + async_add_entities( + chain( + ( + TeslaFleetVehicleSwitchEntity( + vehicle, description, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + for description in VEHICLE_DESCRIPTIONS + ), + ( + TeslaFleetChargeSwitchEntity( + vehicle, VEHICLE_CHARGE_DESCRIPTION, entry.runtime_data.scopes + ) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslaFleetChargeFromGridSwitchEntity( + energysite, + entry.runtime_data.scopes, + ) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_battery") + and energysite.info_coordinator.data.get("components_solar") + ), + ( + TeslaFleetStormModeSwitchEntity(energysite, entry.runtime_data.scopes) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_storm_mode_capable") + ), + ) + ) + + +class TeslaFleetSwitchEntity(SwitchEntity): + """Base class for all TeslaFleet switch entities.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + entity_description: TeslaFleetSwitchEntityDescription + + +class TeslaFleetVehicleSwitchEntity(TeslaFleetVehicleEntity, TeslaFleetSwitchEntity): + """Base class for TeslaFleet vehicle switch entities.""" + + def __init__( + self, + data: TeslaFleetVehicleData, + description: TeslaFleetSwitchEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the Switch.""" + super().__init__(data, description.key) + self.entity_description = description + self.scoped = any(scope in scopes for scope in description.scopes) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + if self._value is None: + self._attr_is_on = None + else: + self._attr_is_on = bool(self._value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + self.raise_for_read_only(self.entity_description.scopes[0]) + await self.wake_up_if_asleep() + await handle_vehicle_command(self.entity_description.on_func(self.api)) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + self.raise_for_read_only(self.entity_description.scopes[0]) + await self.wake_up_if_asleep() + await handle_vehicle_command(self.entity_description.off_func(self.api)) + self._attr_is_on = False + self.async_write_ha_state() + + +class TeslaFleetChargeSwitchEntity(TeslaFleetVehicleSwitchEntity): + """Entity class for TeslaFleet charge switch.""" + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + if self._value is None: + self._attr_is_on = self.get("charge_state_charge_enable_request") + else: + self._attr_is_on = self._value + + +class TeslaFleetChargeFromGridSwitchEntity( + TeslaFleetEnergyInfoEntity, TeslaFleetSwitchEntity +): + """Entity class for Charge From Grid switch.""" + + def __init__( + self, + data: TeslaFleetEnergyData, + scopes: list[Scope], + ) -> None: + """Initialize the Switch.""" + self.scoped = Scope.ENERGY_CMDS in scopes + super().__init__( + data, "components_disallow_charge_from_grid_with_solar_installed" + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + # When disallow_charge_from_grid_with_solar_installed is missing, its Off. + # But this sensor is flipped to match how the Tesla app works. + self._attr_is_on = not self.get(self.key, False) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + self.raise_for_read_only(Scope.ENERGY_CMDS) + await handle_command( + self.api.grid_import_export( + disallow_charge_from_grid_with_solar_installed=False + ) + ) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + self.raise_for_read_only(Scope.ENERGY_CMDS) + await handle_command( + self.api.grid_import_export( + disallow_charge_from_grid_with_solar_installed=True + ) + ) + self._attr_is_on = False + self.async_write_ha_state() + + +class TeslaFleetStormModeSwitchEntity( + TeslaFleetEnergyInfoEntity, TeslaFleetSwitchEntity +): + """Entity class for Storm Mode switch.""" + + def __init__( + self, + data: TeslaFleetEnergyData, + scopes: list[Scope], + ) -> None: + """Initialize the Switch.""" + super().__init__(data, "user_settings_storm_mode_enabled") + self.scoped = Scope.ENERGY_CMDS in scopes + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_available = self._value is not None + self._attr_is_on = bool(self._value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + self.raise_for_read_only(Scope.ENERGY_CMDS) + await handle_command(self.api.storm_mode(enabled=True)) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + self.raise_for_read_only(Scope.ENERGY_CMDS) + await handle_command(self.api.storm_mode(enabled=False)) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/tests/components/tesla_fleet/snapshots/test_switch.ambr b/tests/components/tesla_fleet/snapshots/test_switch.ambr new file mode 100644 index 00000000000..2d69a7d314a --- /dev/null +++ b/tests/components/tesla_fleet/snapshots/test_switch.ambr @@ -0,0 +1,489 @@ +# serializer version: 1 +# name: test_switch[switch.energy_site_allow_charging_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.energy_site_allow_charging_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Allow charging from grid', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'components_disallow_charge_from_grid_with_solar_installed', + 'unique_id': '123456-components_disallow_charge_from_grid_with_solar_installed', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.energy_site_allow_charging_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Energy Site Allow charging from grid', + }), + 'context': , + 'entity_id': 'switch.energy_site_allow_charging_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[switch.energy_site_storm_watch-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.energy_site_storm_watch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Storm watch', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'user_settings_storm_mode_enabled', + 'unique_id': '123456-user_settings_storm_mode_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.energy_site_storm_watch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Energy Site Storm watch', + }), + 'context': , + 'entity_id': 'switch.energy_site_storm_watch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.test_auto_seat_climate_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_auto_seat_climate_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate left', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_left', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_seat_climate_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_auto_seat_climate_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto seat climate left', + }), + 'context': , + 'entity_id': 'switch.test_auto_seat_climate_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.test_auto_seat_climate_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_auto_seat_climate_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto seat climate right', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_seat_climate_right', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_seat_climate_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_auto_seat_climate_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto seat climate right', + }), + 'context': , + 'entity_id': 'switch.test_auto_seat_climate_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.test_auto_steering_wheel_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_auto_steering_wheel_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Auto steering wheel heater', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_auto_steering_wheel_heat', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_auto_steering_wheel_heat', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_auto_steering_wheel_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto steering wheel heater', + }), + 'context': , + 'entity_id': 'switch.test_auto_steering_wheel_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[switch.test_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_user_charge_enable_request', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_user_charge_enable_request', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Charge', + }), + 'context': , + 'entity_id': 'switch.test_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[switch.test_defrost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_defrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrost', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_defrost_mode', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_defrost_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_defrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Defrost', + }), + 'context': , + 'entity_id': 'switch.test_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch[switch.test_sentry_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_sentry_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sentry mode', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_sentry_mode', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sentry_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[switch.test_sentry_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Sentry mode', + }), + 'context': , + 'entity_id': 'switch.test_sentry_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.energy_site_allow_charging_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Energy Site Allow charging from grid', + }), + 'context': , + 'entity_id': 'switch.energy_site_allow_charging_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.energy_site_storm_watch-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Energy Site Storm watch', + }), + 'context': , + 'entity_id': 'switch.energy_site_storm_watch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_alt[switch.test_auto_seat_climate_left-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto seat climate left', + }), + 'context': , + 'entity_id': 'switch.test_auto_seat_climate_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.test_auto_seat_climate_right-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto seat climate right', + }), + 'context': , + 'entity_id': 'switch.test_auto_seat_climate_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.test_auto_steering_wheel_heater-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Auto steering wheel heater', + }), + 'context': , + 'entity_id': 'switch.test_auto_steering_wheel_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.test_charge-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Charge', + }), + 'context': , + 'entity_id': 'switch.test_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_alt[switch.test_defrost-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Defrost', + }), + 'context': , + 'entity_id': 'switch.test_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch_alt[switch.test_sentry_mode-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Test Sentry mode', + }), + 'context': , + 'entity_id': 'switch.test_sentry_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tesla_fleet/test_switch.py b/tests/components/tesla_fleet/test_switch.py new file mode 100644 index 00000000000..5cf812439a5 --- /dev/null +++ b/tests/components/tesla_fleet/test_switch.py @@ -0,0 +1,194 @@ +"""Test the tesla_fleet switch platform.""" + +from copy import deepcopy +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, assert_entities_alt, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT + +from tests.common import MockConfigEntry + + +async def test_switch( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the switch entities are correct.""" + + await setup_platform(hass, normal_config_entry, [Platform.SWITCH]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +async def test_switch_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the switch entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, normal_config_entry, [Platform.SWITCH]) + assert_entities_alt(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +async def test_switch_offline( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the switch entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, normal_config_entry, [Platform.SWITCH]) + state = hass.states.get("switch.test_auto_seat_climate_left") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("name", "on", "off"), + [ + ("test_charge", "VehicleSpecific.charge_start", "VehicleSpecific.charge_stop"), + ( + "test_auto_seat_climate_left", + "VehicleSpecific.remote_auto_seat_climate_request", + "VehicleSpecific.remote_auto_seat_climate_request", + ), + ( + "test_auto_seat_climate_right", + "VehicleSpecific.remote_auto_seat_climate_request", + "VehicleSpecific.remote_auto_seat_climate_request", + ), + ( + "test_auto_steering_wheel_heater", + "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", + "VehicleSpecific.remote_auto_steering_wheel_heat_climate_request", + ), + ( + "test_defrost", + "VehicleSpecific.set_preconditioning_max", + "VehicleSpecific.set_preconditioning_max", + ), + ( + "energy_site_storm_watch", + "EnergySpecific.storm_mode", + "EnergySpecific.storm_mode", + ), + ( + "energy_site_allow_charging_from_grid", + "EnergySpecific.grid_import_export", + "EnergySpecific.grid_import_export", + ), + ( + "test_sentry_mode", + "VehicleSpecific.set_sentry_mode", + "VehicleSpecific.set_sentry_mode", + ), + ], +) +async def test_switch_services( + hass: HomeAssistant, + name: str, + on: str, + off: str, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the switch service calls work.""" + + await setup_platform(hass, normal_config_entry, [Platform.SWITCH]) + + entity_id = f"switch.{name}" + with patch( + f"homeassistant.components.tesla_fleet.{on}", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + call.assert_called_once() + + with patch( + f"homeassistant.components.tesla_fleet.{off}", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + call.assert_called_once() + + +async def test_switch_no_scope( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + readonly_config_entry: MockConfigEntry, +) -> None: + """Tests that the switch entities are correct.""" + + await setup_platform(hass, readonly_config_entry, [Platform.SWITCH]) + with pytest.raises(ServiceValidationError, match="Missing vehicle commands scope"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_auto_steering_wheel_heater"}, + blocking=True, + ) + + +async def test_switch_no_signing( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, + mock_products: AsyncMock, +) -> None: + """Tests that the switch entities are correct.""" + + # Make the vehicle require command signing + products = deepcopy(mock_products.return_value) + products["response"][0]["command_signing"] = "required" + mock_products.return_value = products + + await setup_platform(hass, normal_config_entry, [Platform.SWITCH]) + with pytest.raises( + ServiceValidationError, + match="Vehicle requires command signing. Please see documentation for more details", + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_auto_steering_wheel_heater"}, + blocking=True, + ) From e6d1daaceed854f2b3ea5fba6303273d521a25b5 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Fri, 13 Sep 2024 21:16:03 +0900 Subject: [PATCH 0614/1309] Add on_key to ONE_TOUCH_FILTER property in LG ThinQ integration (#125797) Co-authored-by: jangwon.lee --- homeassistant/components/lg_thinq/binary_sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lg_thinq/binary_sensor.py b/homeassistant/components/lg_thinq/binary_sensor.py index c4f21861e54..596f808ed89 100644 --- a/homeassistant/components/lg_thinq/binary_sensor.py +++ b/homeassistant/components/lg_thinq/binary_sensor.py @@ -82,6 +82,7 @@ BINARY_SENSOR_DESC: dict[ThinQProperty, ThinQBinarySensorEntityDescription] = { ThinQProperty.ONE_TOUCH_FILTER: ThinQBinarySensorEntityDescription( key=ThinQProperty.ONE_TOUCH_FILTER, translation_key=ThinQProperty.ONE_TOUCH_FILTER, + on_key="on", ), } From eae4618c529c13d099cc8917313a36fb5ee6d6ba Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 13 Sep 2024 13:27:33 +0100 Subject: [PATCH 0615/1309] Migrate ring siren and switch platforms to entity descriptions (#125775) --- homeassistant/components/ring/entity.py | 15 ++- homeassistant/components/ring/siren.py | 131 ++++++++++++++++++++---- homeassistant/components/ring/switch.py | 112 ++++++++++++-------- tests/components/ring/device_mocks.py | 3 + 4 files changed, 200 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 0d050e7697f..b93a7f35322 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -1,6 +1,6 @@ """Base class for Ring entity.""" -from collections.abc import Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from typing import Any, Concatenate, Generic, cast @@ -76,6 +76,19 @@ def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R]( return _wrap +def refresh_after[_RingEntityT: RingEntity[Any], **_P]( + func: Callable[Concatenate[_RingEntityT, _P], Awaitable[None]], +) -> Callable[Concatenate[_RingEntityT, _P], Coroutine[Any, Any, None]]: + """Define a wrapper to handle api call errors or refresh after success.""" + + @exception_wrap + async def _wrap(self: _RingEntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + await func(self, *args, **kwargs) + await self.coordinator.async_request_refresh() + + return _wrap + + def async_check_create_deprecated( hass: HomeAssistant, platform: Platform, diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index f5730d942b8..1a008695586 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -1,21 +1,69 @@ """Component providing HA Siren support for Ring Chimes.""" +from collections.abc import Callable, Coroutine +from dataclasses import dataclass import logging -from typing import Any +from typing import Any, Generic, cast -from ring_doorbell import RingChime, RingEventKind +from ring_doorbell import RingChime, RingEventKind, RingGeneric -from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature -from homeassistant.core import HomeAssistant +from homeassistant.components.siren import ( + ATTR_TONE, + SirenEntity, + SirenEntityDescription, + SirenEntityFeature, + SirenTurnOnServiceParameters, +) +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RingConfigEntry from .coordinator import RingDataCoordinator -from .entity import RingEntity, exception_wrap +from .entity import ( + RingDeviceT, + RingEntity, + RingEntityDescription, + async_check_create_deprecated, + refresh_after, +) _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class RingSirenEntityDescription( + SirenEntityDescription, RingEntityDescription, Generic[RingDeviceT] +): + """Describes a Ring siren entity.""" + + exists_fn: Callable[[RingGeneric], bool] + unique_id_fn: Callable[[RingDeviceT], str] = lambda device: str( + device.device_api_id + ) + is_on_fn: Callable[[RingDeviceT], bool] | None = None + turn_on_fn: ( + Callable[[RingDeviceT, SirenTurnOnServiceParameters], Coroutine[Any, Any, Any]] + | None + ) = None + turn_off_fn: Callable[[RingDeviceT], Coroutine[Any, Any, None]] | None = None + + +SIRENS: tuple[RingSirenEntityDescription[Any], ...] = ( + RingSirenEntityDescription[RingChime]( + key="siren", + translation_key="siren", + available_tones=[RingEventKind.DING.value, RingEventKind.MOTION.value], + # Historically the chime siren entity has appended `siren` to the unique id + unique_id_fn=lambda device: f"{device.device_api_id}-siren", + exists_fn=lambda device: isinstance(device, RingChime), + turn_on_fn=lambda device, kwargs: device.async_test_sound( + kind=str(kwargs.get(ATTR_TONE) or "") or RingEventKind.DING.value + ), + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: RingConfigEntry, @@ -26,27 +74,74 @@ async def async_setup_entry( devices_coordinator = ring_data.devices_coordinator async_add_entities( - RingChimeSiren(device, devices_coordinator) - for device in ring_data.devices.chimes + RingSiren(device, devices_coordinator, description) + for device in ring_data.devices.all_devices + for description in SIRENS + if description.exists_fn(device) + and async_check_create_deprecated( + hass, + Platform.SIREN, + description.unique_id_fn(device), + description, + ) ) -class RingChimeSiren(RingEntity[RingChime], SirenEntity): +class RingSiren(RingEntity[RingDeviceT], SirenEntity): """Creates a siren to play the test chimes of a Chime device.""" - _attr_available_tones = [RingEventKind.DING.value, RingEventKind.MOTION.value] - _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES - _attr_translation_key = "siren" + entity_description: RingSirenEntityDescription[RingDeviceT] - def __init__(self, device: RingChime, coordinator: RingDataCoordinator) -> None: + def __init__( + self, + device: RingDeviceT, + coordinator: RingDataCoordinator, + description: RingSirenEntityDescription[RingDeviceT], + ) -> None: """Initialize a Ring Chime siren.""" super().__init__(device, coordinator) - # Entity class attributes - self._attr_unique_id = f"{self._device.id}-siren" + self.entity_description = description + self._attr_unique_id = description.unique_id_fn(device) + if description.is_on_fn: + self._attr_is_on = description.is_on_fn(self._device) + features = SirenEntityFeature(0) + if description.turn_on_fn: + features = features | SirenEntityFeature.TURN_ON + if description.turn_off_fn: + features = features | SirenEntityFeature.TURN_OFF + if description.available_tones: + features = features | SirenEntityFeature.TONES + self._attr_supported_features = features - @exception_wrap + async def _async_set_siren(self, siren_on: bool, **kwargs: Any) -> None: + if siren_on and self.entity_description.turn_on_fn: + turn_on_params = cast(SirenTurnOnServiceParameters, kwargs) + await self.entity_description.turn_on_fn(self._device, turn_on_params) + elif not siren_on and self.entity_description.turn_off_fn: + await self.entity_description.turn_off_fn(self._device) + + if self.entity_description.is_on_fn: + self._attr_is_on = siren_on + self.async_write_ha_state() + + @refresh_after async def async_turn_on(self, **kwargs: Any) -> None: - """Play the test sound on a Ring Chime device.""" - tone = kwargs.get(ATTR_TONE) or RingEventKind.DING.value + """Turn on the siren.""" + await self._async_set_siren(True, **kwargs) - await self._device.async_test_sound(kind=tone) + @refresh_after + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the siren.""" + await self._async_set_siren(False) + + @callback + def _handle_coordinator_update(self) -> None: + """Call update method.""" + if not self.entity_description.is_on_fn: + return + self._device = cast( + RingDeviceT, + self._get_coordinator_data().get_device(self._device.device_api_id), + ) + self._attr_is_on = self.entity_description.is_on_fn(self._device) + super()._handle_coordinator_update() diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 01d321572ac..b81bf233ce8 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -1,29 +1,56 @@ """Component providing HA switch support for Ring Door Bell/Chimes.""" -from datetime import timedelta +from collections.abc import Callable, Coroutine, Sequence +from dataclasses import dataclass import logging -from typing import Any +from typing import Any, Generic, Self, cast -from ring_doorbell import RingStickUpCam +from ring_doorbell import RingCapability, RingStickUpCam -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator -from .entity import RingEntity, exception_wrap +from .entity import ( + RingDeviceT, + RingEntity, + RingEntityDescription, + async_check_create_deprecated, + refresh_after, +) _LOGGER = logging.getLogger(__name__) -# It takes a few seconds for the API to correctly return an update indicating -# that the changes have been made. Once we request a change (i.e. a light -# being turned on) we simply wait for this time delta before we allow -# updates to take place. +@dataclass(frozen=True, kw_only=True) +class RingSwitchEntityDescription( + SwitchEntityDescription, RingEntityDescription, Generic[RingDeviceT] +): + """Describes a Ring switch entity.""" -SKIP_UPDATES_DELAY = timedelta(seconds=5) + exists_fn: Callable[[RingDeviceT], bool] + unique_id_fn: Callable[[Self, RingDeviceT], str] = ( + lambda self, device: f"{device.device_api_id}-{self.key}" + ) + is_on_fn: Callable[[RingDeviceT], bool] + turn_on_fn: Callable[[RingDeviceT], Coroutine[Any, Any, None]] + turn_off_fn: Callable[[RingDeviceT], Coroutine[Any, Any, None]] + + +SWITCHES: Sequence[RingSwitchEntityDescription[Any]] = ( + RingSwitchEntityDescription[RingStickUpCam]( + key="siren", + translation_key="siren", + exists_fn=lambda device: device.has_capability(RingCapability.SIREN), + is_on_fn=lambda device: device.siren > 0, + turn_on_fn=lambda device: device.async_set_siren(1), + turn_off_fn=lambda device: device.async_set_siren(0), + ), +) async def async_setup_entry( @@ -36,61 +63,62 @@ async def async_setup_entry( devices_coordinator = ring_data.devices_coordinator async_add_entities( - SirenSwitch(device, devices_coordinator) - for device in ring_data.devices.stickup_cams - if device.has_capability("siren") + RingSwitch(device, devices_coordinator, description) + for description in SWITCHES + for device in ring_data.devices.all_devices + if description.exists_fn(device) + and async_check_create_deprecated( + hass, + Platform.SWITCH, + description.unique_id_fn(description, device), + description, + ) ) -class BaseRingSwitch(RingEntity[RingStickUpCam], SwitchEntity): +class RingSwitch(RingEntity[RingDeviceT], SwitchEntity): """Represents a switch for controlling an aspect of a ring device.""" + entity_description: RingSwitchEntityDescription[RingDeviceT] + def __init__( - self, device: RingStickUpCam, coordinator: RingDataCoordinator, device_type: str + self, + device: RingDeviceT, + coordinator: RingDataCoordinator, + description: RingSwitchEntityDescription[RingDeviceT], ) -> None: """Initialize the switch.""" super().__init__(device, coordinator) - self._device_type = device_type - self._attr_unique_id = f"{self._device.id}-{self._device_type}" - - -class SirenSwitch(BaseRingSwitch): - """Creates a switch to turn the ring cameras siren on and off.""" - - _attr_translation_key = "siren" - - def __init__( - self, device: RingStickUpCam, coordinator: RingDataCoordinator - ) -> None: - """Initialize the switch for a device with a siren.""" - super().__init__(device, coordinator, "siren") + self.entity_description = description self._no_updates_until = dt_util.utcnow() - self._attr_is_on = device.siren > 0 + self._attr_unique_id = description.unique_id_fn(description, device) + self._attr_is_on = description.is_on_fn(device) @callback def _handle_coordinator_update(self) -> None: """Call update method.""" - if self._no_updates_until > dt_util.utcnow(): - return - device = self._get_coordinator_data().get_stickup_cam( - self._device.device_api_id + self._device = cast( + RingDeviceT, + self._get_coordinator_data().get_device(self._device.device_api_id), ) - self._attr_is_on = device.siren > 0 + self._attr_is_on = self.entity_description.is_on_fn(self._device) super()._handle_coordinator_update() - @exception_wrap - async def _async_set_switch(self, new_state: int) -> None: + @refresh_after + async def _async_set_switch(self, switch_on: bool) -> None: """Update switch state, and causes Home Assistant to correctly update.""" - await self._device.async_set_siren(new_state) + if switch_on: + await self.entity_description.turn_on_fn(self._device) + else: + await self.entity_description.turn_off_fn(self._device) - self._attr_is_on = new_state > 0 - self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY + self._attr_is_on = switch_on self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the siren on for 30 seconds.""" - await self._async_set_switch(1) + await self._async_set_switch(True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the siren off.""" - await self._async_set_switch(0) + await self._async_set_switch(False) diff --git a/tests/components/ring/device_mocks.py b/tests/components/ring/device_mocks.py index 8ac5948d6a0..29fd5fb757a 100644 --- a/tests/components/ring/device_mocks.py +++ b/tests/components/ring/device_mocks.py @@ -158,6 +158,9 @@ def _mocked_ring_device(device_dict, device_family, device_class, capabilities): mock_device.configure_mock( siren=device_dict["siren_status"].get("seconds_remaining") ) + mock_device.async_set_siren.side_effect = lambda i: mock_device.configure_mock( + siren=i + ) if has_capability(RingCapability.BATTERY): mock_device.configure_mock( From 1cea791245c2d7344719a326e7179f119f666322 Mon Sep 17 00:00:00 2001 From: shapournemati-iotty <130070037+shapournemati-iotty@users.noreply.github.com> Date: Fri, 13 Sep 2024 14:55:53 +0200 Subject: [PATCH 0616/1309] Add Cover platform to Iotty (#125422) * fadd cover entity and device with mocked commands * add cover features and update its open percentage * execute command to the cloud instead of mocking change of shutter state * test iotty cover commands and insertion * fix post payload * refactor introducing common entity from which cover and switch inherit * move more properties to base class * use explicit values instead of snapshots * move iotty device initialization to base entity * move device info from property to attribute --- homeassistant/components/iotty/__init__.py | 2 +- homeassistant/components/iotty/coordinator.py | 7 +- homeassistant/components/iotty/cover.py | 193 ++++++++++++++ homeassistant/components/iotty/entity.py | 49 ++++ homeassistant/components/iotty/switch.py | 33 +-- tests/components/iotty/conftest.py | 72 +++++- tests/components/iotty/test_cover.py | 238 ++++++++++++++++++ 7 files changed, 561 insertions(+), 33 deletions(-) create mode 100644 homeassistant/components/iotty/cover.py create mode 100644 homeassistant/components/iotty/entity.py create mode 100644 tests/components/iotty/test_cover.py diff --git a/homeassistant/components/iotty/__init__.py b/homeassistant/components/iotty/__init__.py index b34b8d3840d..804f3f40196 100644 --- a/homeassistant/components/iotty/__init__.py +++ b/homeassistant/components/iotty/__init__.py @@ -19,7 +19,7 @@ from . import coordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.COVER, Platform.SWITCH] type IottyConfigEntry = ConfigEntry[IottyConfigEntryData] diff --git a/homeassistant/components/iotty/coordinator.py b/homeassistant/components/iotty/coordinator.py index f63c4b45112..12764ac1cf6 100644 --- a/homeassistant/components/iotty/coordinator.py +++ b/homeassistant/components/iotty/coordinator.py @@ -7,7 +7,8 @@ from datetime import timedelta import logging from iottycloud.device import Device -from iottycloud.verbs import RESULT, STATUS +from iottycloud.shutter import Shutter +from iottycloud.verbs import OPEN_PERCENTAGE, RESULT, STATUS from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -104,5 +105,9 @@ class IottyDataUpdateCoordinator(DataUpdateCoordinator[IottyData]): "Retrieved status: '%s' for device %s", status, device.device_id ) device.update_status(status) + if isinstance(device, Shutter) and isinstance( + percentage := json.get(OPEN_PERCENTAGE), int + ): + device.update_percentage(percentage) return IottyData(self._devices) diff --git a/homeassistant/components/iotty/cover.py b/homeassistant/components/iotty/cover.py new file mode 100644 index 00000000000..50a4a1deeba --- /dev/null +++ b/homeassistant/components/iotty/cover.py @@ -0,0 +1,193 @@ +"""Implement a iotty Shutter Device.""" + +from __future__ import annotations + +import logging +from typing import Any + +from iottycloud.device import Device +from iottycloud.shutter import Shutter, ShutterState +from iottycloud.verbs import SH_DEVICE_TYPE_UID + +from homeassistant.components.cover import ( + ATTR_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import IottyConfigEntry +from .api import IottyProxy +from .coordinator import IottyDataUpdateCoordinator +from .entity import IottyEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: IottyConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Activate the iotty Shutter component.""" + _LOGGER.debug("Setup COVER entry id is %s", config_entry.entry_id) + + coordinator = config_entry.runtime_data.coordinator + entities = [ + IottyShutter( + coordinator=coordinator, iotty_cloud=coordinator.iotty, iotty_device=d + ) + for d in coordinator.data.devices + if d.device_type == SH_DEVICE_TYPE_UID + if (isinstance(d, Shutter)) + ] + _LOGGER.debug("Found %d Shutters", len(entities)) + + async_add_entities(entities) + + known_devices: set = config_entry.runtime_data.known_devices + for known_device in coordinator.data.devices: + if known_device.device_type == SH_DEVICE_TYPE_UID: + known_devices.add(known_device) + + @callback + def async_update_data() -> None: + """Handle updated data from the API endpoint.""" + if not coordinator.last_update_success: + return + + devices = coordinator.data.devices + entities = [] + known_devices: set = config_entry.runtime_data.known_devices + + # Add entities for devices which we've not yet seen + for device in devices: + if ( + any(d.device_id == device.device_id for d in known_devices) + or device.device_type != SH_DEVICE_TYPE_UID + ): + continue + + iotty_entity = IottyShutter( + coordinator=coordinator, + iotty_cloud=coordinator.iotty, + iotty_device=Shutter( + device.device_id, + device.serial_number, + device.device_type, + device.device_name, + ), + ) + + entities.extend([iotty_entity]) + known_devices.add(device) + + async_add_entities(entities) + + # Add a subscriber to the coordinator to discover new devices + coordinator.async_add_listener(async_update_data) + + +class IottyShutter(IottyEntity, CoverEntity): + """Haas entity class for iotty Shutter.""" + + _attr_device_class = CoverDeviceClass.SHUTTER + _iotty_device: Shutter + _attr_supported_features: CoverEntityFeature = CoverEntityFeature(0) | ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + def __init__( + self, + coordinator: IottyDataUpdateCoordinator, + iotty_cloud: IottyProxy, + iotty_device: Shutter, + ) -> None: + """Initialize the Shutter device.""" + super().__init__(coordinator, iotty_cloud, iotty_device) + + @property + def current_cover_position(self) -> int | None: + """Return the current position of the shutter. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._iotty_device.percentage + + @property + def is_closed(self) -> bool: + """Return true if the Shutter is closed.""" + _LOGGER.debug( + "Retrieve device status for %s ? %s : %s", + self._iotty_device.device_id, + self._iotty_device.status, + self._iotty_device.percentage, + ) + return ( + self._iotty_device.status == ShutterState.STATIONARY + and self._iotty_device.percentage == 0 + ) + + @property + def is_opening(self) -> bool: + """Return true if the Shutter is opening.""" + return self._iotty_device.status == ShutterState.OPENING + + @property + def is_closing(self) -> bool: + """Return true if the Shutter is closing.""" + return self._iotty_device.status == ShutterState.CLOSING + + @property + def supported_features(self) -> CoverEntityFeature: + """Flag supported features.""" + return self._attr_supported_features + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._iotty_cloud.command( + self._iotty_device.device_id, self._iotty_device.cmd_open() + ) + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + await self._iotty_cloud.command( + self._iotty_device.device_id, self._iotty_device.cmd_close() + ) + await self.coordinator.async_request_refresh() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + percentage = kwargs[ATTR_POSITION] + await self._iotty_cloud.command( + self._iotty_device.device_id, + self._iotty_device.cmd_move_to(), + {"open_percentage": percentage}, + ) + await self.coordinator.async_request_refresh() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._iotty_cloud.command( + self._iotty_device.device_id, self._iotty_device.cmd_stop() + ) + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + device: Device = next( + device + for device in self.coordinator.data.devices + if device.device_id == self._iotty_device.device_id + ) + if isinstance(device, Shutter): + self._iotty_device = device + self.async_write_ha_state() diff --git a/homeassistant/components/iotty/entity.py b/homeassistant/components/iotty/entity.py new file mode 100644 index 00000000000..4eb7a421281 --- /dev/null +++ b/homeassistant/components/iotty/entity.py @@ -0,0 +1,49 @@ +"""Base class for iotty entities.""" + +import logging + +from iottycloud.lightswitch import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .api import IottyProxy +from .const import DOMAIN +from .coordinator import IottyDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class IottyEntity(CoordinatorEntity[IottyDataUpdateCoordinator]): + """Defines a base iotty entity.""" + + _attr_has_entity_name = True + _attr_name = None + _iotty_device_name: str + _iotty_cloud: IottyProxy + _iotty_device: Device + + def __init__( + self, + coordinator: IottyDataUpdateCoordinator, + iotty_cloud: IottyProxy, + iotty_device: Device, + ) -> None: + """Initialize iotty entity.""" + super().__init__(coordinator) + + _LOGGER.debug( + "Creating new COVER (%s) %s", + iotty_device.device_type, + iotty_device.device_id, + ) + + self._iotty_cloud = iotty_cloud + self._attr_unique_id = iotty_device.device_id + self._iotty_device_name = iotty_device.name + self._iotty_device = iotty_device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, iotty_device.device_id)}, + name=iotty_device.name, + manufacturer="iotty", + ) diff --git a/homeassistant/components/iotty/switch.py b/homeassistant/components/iotty/switch.py index ee489e88349..1e2bdffcf79 100644 --- a/homeassistant/components/iotty/switch.py +++ b/homeassistant/components/iotty/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any, cast +from typing import Any from iottycloud.device import Device from iottycloud.lightswitch import LightSwitch @@ -11,14 +11,12 @@ from iottycloud.verbs import LS_DEVICE_TYPE_UID from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import IottyConfigEntry from .api import IottyProxy -from .const import DOMAIN from .coordinator import IottyDataUpdateCoordinator +from .entity import IottyEntity _LOGGER = logging.getLogger(__name__) @@ -87,14 +85,10 @@ async def async_setup_entry( coordinator.async_add_listener(async_update_data) -class IottyLightSwitch(SwitchEntity, CoordinatorEntity[IottyDataUpdateCoordinator]): +class IottyLightSwitch(IottyEntity, SwitchEntity): """Haas entity class for iotty LightSwitch.""" - _attr_has_entity_name = True - _attr_name = None - _attr_entity_category = None _attr_device_class = SwitchDeviceClass.SWITCH - _iotty_cloud: IottyProxy _iotty_device: LightSwitch def __init__( @@ -104,26 +98,7 @@ class IottyLightSwitch(SwitchEntity, CoordinatorEntity[IottyDataUpdateCoordinato iotty_device: LightSwitch, ) -> None: """Initialize the LightSwitch device.""" - super().__init__(coordinator=coordinator) - - _LOGGER.debug( - "Creating new SWITCH (%s) %s", - iotty_device.device_type, - iotty_device.device_id, - ) - - self._iotty_cloud = iotty_cloud - self._iotty_device = iotty_device - self._attr_unique_id = iotty_device.device_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, cast(str, self._attr_unique_id))}, - name=self._iotty_device.name, - manufacturer="iotty", - ) + super().__init__(coordinator, iotty_cloud, iotty_device) @property def is_on(self) -> bool: diff --git a/tests/components/iotty/conftest.py b/tests/components/iotty/conftest.py index 9f858879cb9..1935a069cca 100644 --- a/tests/components/iotty/conftest.py +++ b/tests/components/iotty/conftest.py @@ -6,7 +6,18 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientSession from iottycloud.device import Device from iottycloud.lightswitch import LightSwitch -from iottycloud.verbs import LS_DEVICE_TYPE_UID, RESULT, STATUS, STATUS_OFF, STATUS_ON +from iottycloud.shutter import Shutter +from iottycloud.verbs import ( + LS_DEVICE_TYPE_UID, + OPEN_PERCENTAGE, + RESULT, + SH_DEVICE_TYPE_UID, + STATUS, + STATUS_OFF, + STATUS_ON, + STATUS_OPENING, + STATUS_STATIONATRY, +) import pytest from homeassistant import setup @@ -48,6 +59,20 @@ test_ls_one_added = [ ls_2, ] +sh_0 = Shutter("TestSH", "TEST_SERIAL_SH_0", SH_DEVICE_TYPE_UID, "[TEST] Shutter 0") +sh_1 = Shutter("TestSH1", "TEST_SERIAL_SH_1", SH_DEVICE_TYPE_UID, "[TEST] Shutter 1") +sh_2 = Shutter("TestSH2", "TEST_SERIAL_SH_2", SH_DEVICE_TYPE_UID, "[TEST] Shutter 2") + +test_sh = [sh_0, sh_1] + +test_sh_one_removed = [sh_0] + +test_sh_one_added = [ + sh_0, + sh_1, + sh_2, +] + @pytest.fixture async def local_oauth_impl(hass: HomeAssistant): @@ -142,7 +167,7 @@ def mock_get_devices_nodevices() -> Generator[AsyncMock]: @pytest.fixture def mock_get_devices_twolightswitches() -> Generator[AsyncMock]: - """Mock for get_devices, returning two objects.""" + """Mock for get_devices, returning two switches.""" with patch( "iottycloud.cloudapi.CloudApi.get_devices", return_value=test_ls @@ -150,6 +175,16 @@ def mock_get_devices_twolightswitches() -> Generator[AsyncMock]: yield mock_fn +@pytest.fixture +def mock_get_devices_twoshutters() -> Generator[AsyncMock]: + """Mock for get_devices, returning two shutters.""" + + with patch( + "iottycloud.cloudapi.CloudApi.get_devices", return_value=test_sh + ) as mock_fn: + yield mock_fn + + @pytest.fixture def mock_command_fn() -> Generator[AsyncMock]: """Mock for command.""" @@ -169,6 +204,39 @@ def mock_get_status_filled_off() -> Generator[AsyncMock]: yield mock_fn +@pytest.fixture +def mock_get_status_filled_stationary_100() -> Generator[AsyncMock]: + """Mock setting up a get_status.""" + + retval = {RESULT: {STATUS: STATUS_STATIONATRY, OPEN_PERCENTAGE: 100}} + with patch( + "iottycloud.cloudapi.CloudApi.get_status", return_value=retval + ) as mock_fn: + yield mock_fn + + +@pytest.fixture +def mock_get_status_filled_stationary_0() -> Generator[AsyncMock]: + """Mock setting up a get_status.""" + + retval = {RESULT: {STATUS: STATUS_STATIONATRY, OPEN_PERCENTAGE: 0}} + with patch( + "iottycloud.cloudapi.CloudApi.get_status", return_value=retval + ) as mock_fn: + yield mock_fn + + +@pytest.fixture +def mock_get_status_filled_opening_50() -> Generator[AsyncMock]: + """Mock setting up a get_status.""" + + retval = {RESULT: {STATUS: STATUS_OPENING, OPEN_PERCENTAGE: 50}} + with patch( + "iottycloud.cloudapi.CloudApi.get_status", return_value=retval + ) as mock_fn: + yield mock_fn + + @pytest.fixture def mock_get_status_filled() -> Generator[AsyncMock]: """Mock setting up a get_status.""" diff --git a/tests/components/iotty/test_cover.py b/tests/components/iotty/test_cover.py new file mode 100644 index 00000000000..fd30fe1b574 --- /dev/null +++ b/tests/components/iotty/test_cover.py @@ -0,0 +1,238 @@ +"""Unit tests the Hass COVER component.""" + +from aiohttp import ClientSession +from freezegun.api import FrozenDateTimeFactory +from iottycloud.verbs import ( + OPEN_PERCENTAGE, + RESULT, + STATUS, + STATUS_CLOSING, + STATUS_OPENING, + STATUS_STATIONATRY, +) + +from homeassistant.components.cover import ( + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.components.iotty.const import DOMAIN +from homeassistant.components.iotty.coordinator import UPDATE_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import test_sh_one_added + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_open_ok( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + local_oauth_impl: ClientSession, + mock_get_devices_twoshutters, + mock_get_status_filled_stationary_0, + mock_command_fn, +) -> None: + """Issue an open command.""" + + entity_id = "cover.test_shutter_0_test_serial_sh_0" + + mock_config_entry.add_to_hass(hass) + + config_entry_oauth2_flow.async_register_implementation( + hass, DOMAIN, local_oauth_impl + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_CLOSED + + mock_get_status_filled_stationary_0.return_value = { + RESULT: {STATUS: STATUS_OPENING, OPEN_PERCENTAGE: 10} + } + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + mock_command_fn.assert_called_once() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OPENING + + +async def test_close_ok( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + local_oauth_impl: ClientSession, + mock_get_devices_twoshutters, + mock_get_status_filled_stationary_100, + mock_command_fn, +) -> None: + """Issue a close command.""" + + entity_id = "cover.test_shutter_0_test_serial_sh_0" + + mock_config_entry.add_to_hass(hass) + + config_entry_oauth2_flow.async_register_implementation( + hass, DOMAIN, local_oauth_impl + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OPEN + + mock_get_status_filled_stationary_100.return_value = { + RESULT: {STATUS: STATUS_CLOSING, OPEN_PERCENTAGE: 90} + } + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + mock_command_fn.assert_called_once() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_CLOSING + + +async def test_stop_ok( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + local_oauth_impl: ClientSession, + mock_get_devices_twoshutters, + mock_get_status_filled_opening_50, + mock_command_fn, +) -> None: + """Issue a stop command.""" + + entity_id = "cover.test_shutter_0_test_serial_sh_0" + + mock_config_entry.add_to_hass(hass) + + config_entry_oauth2_flow.async_register_implementation( + hass, DOMAIN, local_oauth_impl + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OPENING + + mock_get_status_filled_opening_50.return_value = { + RESULT: {STATUS: STATUS_STATIONATRY, OPEN_PERCENTAGE: 60} + } + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + mock_command_fn.assert_called_once() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OPEN + + +async def test_set_position_ok( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + local_oauth_impl: ClientSession, + mock_get_devices_twoshutters, + mock_get_status_filled_stationary_0, + mock_command_fn, +) -> None: + """Issue a set position command.""" + + entity_id = "cover.test_shutter_0_test_serial_sh_0" + + mock_config_entry.add_to_hass(hass) + + config_entry_oauth2_flow.async_register_implementation( + hass, DOMAIN, local_oauth_impl + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_CLOSED + + mock_get_status_filled_stationary_0.return_value = { + RESULT: {STATUS: STATUS_OPENING, OPEN_PERCENTAGE: 50} + } + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 10}, + blocking=True, + ) + + await hass.async_block_till_done() + mock_command_fn.assert_called_once() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_OPENING + + +async def test_devices_insertion_ok( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + local_oauth_impl: ClientSession, + mock_get_devices_twoshutters, + mock_get_status_filled_stationary_0, + freezer: FrozenDateTimeFactory, +) -> None: + """Test iotty cover insertion.""" + + mock_config_entry.add_to_hass(hass) + + config_entry_oauth2_flow.async_register_implementation( + hass, DOMAIN, local_oauth_impl + ) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Should have two devices + assert hass.states.async_entity_ids_count() == 2 + assert hass.states.async_entity_ids() == [ + "cover.test_shutter_0_test_serial_sh_0", + "cover.test_shutter_1_test_serial_sh_1", + ] + + mock_get_devices_twoshutters.return_value = test_sh_one_added + + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Should have three devices + assert hass.states.async_entity_ids_count() == 3 + assert hass.states.async_entity_ids() == [ + "cover.test_shutter_0_test_serial_sh_0", + "cover.test_shutter_1_test_serial_sh_1", + "cover.test_shutter_2_test_serial_sh_2", + ] From 2e3aec3184d66dc7bb049adeb4d934b0b5d1a2fd Mon Sep 17 00:00:00 2001 From: "Lektri.co" <137074859+Lektrico@users.noreply.github.com> Date: Fri, 13 Sep 2024 16:13:49 +0300 Subject: [PATCH 0617/1309] Add button platform to the Lektrico integration (#125897) * Add lektrico buttons. * Add DeviceClass.RESTART, remove exception, update description. * Remove translation_key=reboot. * Add button in strings.json. * Fix button test with new snapshot. * Remove remove button from strings.json. * Delete all snapshots. * Add new snapshots. * Update tests/components/lektrico/snapshots/test_button.ambr * Update tests/components/lektrico/snapshots/test_button.ambr --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/lektrico/__init__.py | 4 +- homeassistant/components/lektrico/button.py | 102 +++++++++++++ .../components/lektrico/strings.json | 8 + .../lektrico/snapshots/test_button.ambr | 140 ++++++++++++++++++ tests/components/lektrico/test_button.py | 32 ++++ tests/components/lektrico/test_sensor.py | 6 +- 6 files changed, 288 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/lektrico/button.py create mode 100644 tests/components/lektrico/snapshots/test_button.ambr create mode 100644 tests/components/lektrico/test_button.py diff --git a/homeassistant/components/lektrico/__init__.py b/homeassistant/components/lektrico/__init__.py index 70dbecca77a..746d14f3605 100644 --- a/homeassistant/components/lektrico/__init__.py +++ b/homeassistant/components/lektrico/__init__.py @@ -11,10 +11,10 @@ from homeassistant.core import HomeAssistant from .coordinator import LektricoDeviceDataUpdateCoordinator # List the platforms that charger supports. -CHARGERS_PLATFORMS = [Platform.SENSOR] +CHARGERS_PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] # List the platforms that load balancer device supports. -LB_DEVICES_PLATFORMS = [Platform.SENSOR] +LB_DEVICES_PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] type LektricoConfigEntry = ConfigEntry[LektricoDeviceDataUpdateCoordinator] diff --git a/homeassistant/components/lektrico/button.py b/homeassistant/components/lektrico/button.py new file mode 100644 index 00000000000..62aef12ff53 --- /dev/null +++ b/homeassistant/components/lektrico/button.py @@ -0,0 +1,102 @@ +"""Support for Lektrico buttons.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from lektricowifi import Device + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator +from .entity import LektricoEntity + + +@dataclass(frozen=True, kw_only=True) +class LektricoButtonEntityDescription(ButtonEntityDescription): + """Describes Lektrico button entity.""" + + press_fn: Callable[[Device], Coroutine[Any, Any, dict[Any, Any]]] + + +BUTTONS_FOR_CHARGERS: tuple[LektricoButtonEntityDescription, ...] = ( + LektricoButtonEntityDescription( + key="charge_start", + translation_key="charge_start", + entity_category=EntityCategory.CONFIG, + press_fn=lambda device: device.send_charge_start(), + ), + LektricoButtonEntityDescription( + key="charge_stop", + translation_key="charge_stop", + entity_category=EntityCategory.CONFIG, + press_fn=lambda device: device.send_charge_stop(), + ), + LektricoButtonEntityDescription( + key="reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_fn=lambda device: device.send_reset(), + ), +) + +BUTTONS_FOR_LB_DEVICES: tuple[LektricoButtonEntityDescription, ...] = ( + LektricoButtonEntityDescription( + key="reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_fn=lambda device: device.send_reset(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LektricoConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Lektrico charger based on a config entry.""" + coordinator = entry.runtime_data + + buttons_to_be_used: tuple[LektricoButtonEntityDescription, ...] + if coordinator.device_type in (Device.TYPE_1P7K, Device.TYPE_3P22K): + buttons_to_be_used = BUTTONS_FOR_CHARGERS + else: + buttons_to_be_used = BUTTONS_FOR_LB_DEVICES + + async_add_entities( + LektricoButton( + description, + coordinator, + f"{entry.data[CONF_TYPE]}_{entry.data[ATTR_SERIAL_NUMBER]}", + ) + for description in buttons_to_be_used + ) + + +class LektricoButton(LektricoEntity, ButtonEntity): + """Defines an Lektrico button.""" + + entity_description: LektricoButtonEntityDescription + + def __init__( + self, + description: LektricoButtonEntityDescription, + coordinator: LektricoDeviceDataUpdateCoordinator, + device_name: str, + ) -> None: + """Initialize Lektrico button.""" + super().__init__(coordinator, device_name) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" + + async def async_press(self) -> None: + """Press the button.""" + await self.entity_description.press_fn(self.coordinator.device) diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index 767987e7e64..2470c0865d5 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -22,6 +22,14 @@ } }, "entity": { + "button": { + "charge_start": { + "name": "Charge start" + }, + "charge_stop": { + "name": "Charge stop" + } + }, "sensor": { "state": { "name": "State", diff --git a/tests/components/lektrico/snapshots/test_button.ambr b/tests/components/lektrico/snapshots/test_button.ambr new file mode 100644 index 00000000000..5070cd484c4 --- /dev/null +++ b/tests/components/lektrico/snapshots/test_button.ambr @@ -0,0 +1,140 @@ +# serializer version: 1 +# name: test_all_entities[button.1p7k_500006_charge_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.1p7k_500006_charge_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge start', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_start', + 'unique_id': '500006-charge_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.1p7k_500006_charge_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1p7k_500006 Charge start', + }), + 'context': , + 'entity_id': 'button.1p7k_500006_charge_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.1p7k_500006_charge_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.1p7k_500006_charge_stop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge stop', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_stop', + 'unique_id': '500006-charge_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.1p7k_500006_charge_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1p7k_500006 Charge stop', + }), + 'context': , + 'entity_id': 'button.1p7k_500006_charge_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[button.1p7k_500006_restart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.1p7k_500006_restart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '500006-reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[button.1p7k_500006_restart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': '1p7k_500006 Restart', + }), + 'context': , + 'entity_id': 'button.1p7k_500006_restart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/lektrico/test_button.py b/tests/components/lektrico/test_button.py new file mode 100644 index 00000000000..7bd77848d21 --- /dev/null +++ b/tests/components/lektrico/test_button.py @@ -0,0 +1,32 @@ +"""Tests for the Lektrico button platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + + with patch.multiple( + "homeassistant.components.lektrico", + CHARGERS_PLATFORMS=[Platform.BUTTON], + LB_DEVICES_PLATFORMS=[Platform.BUTTON], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/lektrico/test_sensor.py b/tests/components/lektrico/test_sensor.py index 756f149d3ad..27be7ff1c11 100644 --- a/tests/components/lektrico/test_sensor.py +++ b/tests/components/lektrico/test_sensor.py @@ -23,8 +23,10 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - with patch( - "homeassistant.components.lektrico.CHARGERS_PLATFORMS", [Platform.SENSOR] + with patch.multiple( + "homeassistant.components.lektrico", + CHARGERS_PLATFORMS=[Platform.SENSOR], + LB_DEVICES_PLATFORMS=[Platform.SENSOR], ): await setup_integration(hass, mock_config_entry) From 0af913cc9a9d8f200f30b05577bc6d9c03f5429e Mon Sep 17 00:00:00 2001 From: David Knowles Date: Fri, 13 Sep 2024 09:17:51 -0400 Subject: [PATCH 0618/1309] Automatically add and remove Schlage devices (#125520) * Allow manual deletion of stale Schlage devices * Automatically add and remove locks * Add tests and fix discovered bugs * Changes requested during review --- .../components/schlage/binary_sensor.py | 21 ++++-- .../components/schlage/coordinator.py | 39 +++++++++- homeassistant/components/schlage/lock.py | 15 ++-- homeassistant/components/schlage/sensor.py | 23 +++--- homeassistant/components/schlage/switch.py | 23 +++--- tests/components/schlage/conftest.py | 31 +++++--- .../schlage/snapshots/test_init.ambr | 33 +++++++++ .../components/schlage/test_binary_sensor.py | 11 +-- tests/components/schlage/test_init.py | 74 ++++++++++++++++++- tests/components/schlage/test_lock.py | 27 +------ tests/components/schlage/test_sensor.py | 14 ---- tests/components/schlage/test_switch.py | 14 ---- 12 files changed, 211 insertions(+), 114 deletions(-) create mode 100644 tests/components/schlage/snapshots/test_init.ambr diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py index a141403bdf4..bc1ee666f9e 100644 --- a/homeassistant/components/schlage/binary_sensor.py +++ b/homeassistant/components/schlage/binary_sensor.py @@ -45,15 +45,20 @@ async def async_setup_entry( ) -> None: """Set up binary_sensors based on a config entry.""" coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - SchlageBinarySensor( - coordinator=coordinator, - description=description, - device_id=device_id, + + def _add_new_locks(locks: dict[str, LockData]) -> None: + async_add_entities( + SchlageBinarySensor( + coordinator=coordinator, + description=description, + device_id=device_id, + ) + for device_id in locks + for description in _DESCRIPTIONS ) - for device_id in coordinator.data.locks - for description in _DESCRIPTIONS - ) + + _add_new_locks(coordinator.data.locks) + coordinator.new_locks_callbacks.append(_add_new_locks) class SchlageBinarySensor(SchlageEntity, BinarySensorEntity): diff --git a/homeassistant/components/schlage/coordinator.py b/homeassistant/components/schlage/coordinator.py index 959d1e215f8..365fabb8ac7 100644 --- a/homeassistant/components/schlage/coordinator.py +++ b/homeassistant/components/schlage/coordinator.py @@ -3,14 +3,17 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass from pyschlage import Lock, Schlage from pyschlage.exceptions import Error as SchlageError, NotAuthorizedError from pyschlage.log import LockLog -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, UPDATE_INTERVAL @@ -34,12 +37,16 @@ class SchlageData: class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): """The Schlage data update coordinator.""" + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, username: str, api: Schlage) -> None: """Initialize the class.""" super().__init__( hass, LOGGER, name=f"{DOMAIN} ({username})", update_interval=UPDATE_INTERVAL ) self.api = api + self.new_locks_callbacks: list[Callable[[dict[str, LockData]], None]] = [] + self.async_add_listener(self._add_remove_locks) async def _async_update_data(self) -> SchlageData: """Fetch the latest data from the Schlage API.""" @@ -55,9 +62,7 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): for lock in locks ) ) - return SchlageData( - locks={ld.lock.device_id: ld for ld in lock_data}, - ) + return SchlageData(locks={ld.lock.device_id: ld for ld in lock_data}) def _get_lock_data(self, lock: Lock) -> LockData: logs: list[LockLog] = [] @@ -74,3 +79,29 @@ class SchlageDataUpdateCoordinator(DataUpdateCoordinator[SchlageData]): LOGGER.debug('Failed to read logs for lock "%s": %s', lock.name, ex) return LockData(lock=lock, logs=logs) + + @callback + def _add_remove_locks(self) -> None: + """Add newly discovered locks and remove nonexistent locks.""" + if self.data is None: + return + + device_registry = dr.async_get(self.hass) + devices = dr.async_entries_for_config_entry( + device_registry, self.config_entry.entry_id + ) + previous_locks = {device.id for device in devices} + current_locks = set(self.data.locks.keys()) + if removed_locks := previous_locks - current_locks: + LOGGER.debug("Removed locks: %s", ", ".join(removed_locks)) + for device_id in removed_locks: + device_registry.async_update_device( + device_id=device_id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + if new_lock_ids := current_locks - previous_locks: + LOGGER.debug("New locks found: %s", ", ".join(new_lock_ids)) + new_locks = {lock_id: self.data.locks[lock_id] for lock_id in new_lock_ids} + for new_lock_callback in self.new_locks_callbacks: + new_lock_callback(new_locks) diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index 59ce00e809a..97dbfc78d41 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -21,10 +21,15 @@ async def async_setup_entry( ) -> None: """Set up Schlage WiFi locks based on a config entry.""" coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - SchlageLockEntity(coordinator=coordinator, device_id=device_id) - for device_id in coordinator.data.locks - ) + + def _add_new_locks(locks: dict[str, LockData]) -> None: + async_add_entities( + SchlageLockEntity(coordinator=coordinator, device_id=device_id) + for device_id in locks + ) + + _add_new_locks(coordinator.data.locks) + coordinator.new_locks_callbacks.append(_add_new_locks) class SchlageLockEntity(SchlageEntity, LockEntity): diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py index 8de09fa4cbb..115412882a2 100644 --- a/homeassistant/components/schlage/sensor.py +++ b/homeassistant/components/schlage/sensor.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageDataUpdateCoordinator from .entity import SchlageEntity _SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ @@ -35,15 +35,20 @@ async def async_setup_entry( ) -> None: """Set up sensors based on a config entry.""" coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - SchlageBatterySensor( - coordinator=coordinator, - description=description, - device_id=device_id, + + def _add_new_locks(locks: dict[str, LockData]) -> None: + async_add_entities( + SchlageBatterySensor( + coordinator=coordinator, + description=description, + device_id=device_id, + ) + for description in _SENSOR_DESCRIPTIONS + for device_id in locks ) - for description in _SENSOR_DESCRIPTIONS - for device_id in coordinator.data.locks - ) + + _add_new_locks(coordinator.data.locks) + coordinator.new_locks_callbacks.append(_add_new_locks) class SchlageBatterySensor(SchlageEntity, SensorEntity): diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py index 53771768ccd..aaed57fc741 100644 --- a/homeassistant/components/schlage/switch.py +++ b/homeassistant/components/schlage/switch.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import SchlageDataUpdateCoordinator +from .coordinator import LockData, SchlageDataUpdateCoordinator from .entity import SchlageEntity @@ -62,15 +62,20 @@ async def async_setup_entry( ) -> None: """Set up switches based on a config entry.""" coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( - SchlageSwitch( - coordinator=coordinator, - description=description, - device_id=device_id, + + def _add_new_locks(locks: dict[str, LockData]) -> None: + async_add_entities( + SchlageSwitch( + coordinator=coordinator, + description=description, + device_id=device_id, + ) + for device_id in locks + for description in SWITCHES ) - for device_id in coordinator.data.locks - for description in SWITCHES - ) + + _add_new_locks(coordinator.data.locks) + coordinator.new_locks_callbacks.append(_add_new_locks) class SchlageSwitch(SchlageEntity, SwitchEntity): diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index 9d61bb877d9..5ff8d045606 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Schlage tests.""" from collections.abc import Generator +from typing import Any from unittest.mock import AsyncMock, Mock, create_autospec, patch from pyschlage.lock import Lock @@ -70,21 +71,27 @@ def mock_pyschlage_auth() -> Mock: @pytest.fixture -def mock_lock() -> Mock: +def mock_lock(mock_lock_attrs: dict[str, Any]) -> Mock: """Mock Lock fixture.""" mock_lock = create_autospec(Lock) - mock_lock.configure_mock( - device_id="test", - name="Vault Door", - model_name="", - is_locked=False, - is_jammed=False, - battery_level=20, - firmware_version="1.0", - lock_and_leave_enabled=True, - beeper_enabled=True, - ) + mock_lock.configure_mock(**mock_lock_attrs) mock_lock.logs.return_value = [] mock_lock.last_changed_by.return_value = "thumbturn" mock_lock.keypad_disabled.return_value = False return mock_lock + + +@pytest.fixture +def mock_lock_attrs() -> dict[str, Any]: + """Attributes for a mock lock.""" + return { + "device_id": "test", + "name": "Vault Door", + "model_name": "", + "is_locked": False, + "is_jammed": False, + "battery_level": 20, + "firmware_version": "1.0", + "lock_and_leave_enabled": True, + "beeper_enabled": True, + } diff --git a/tests/components/schlage/snapshots/test_init.ambr b/tests/components/schlage/snapshots/test_init.ambr new file mode 100644 index 00000000000..c7049443ab7 --- /dev/null +++ b/tests/components/schlage/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_lock_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'schlage', + 'test', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Schlage', + 'model': '', + 'model_id': None, + 'name': 'Vault Door', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/schlage/test_binary_sensor.py b/tests/components/schlage/test_binary_sensor.py index dbbc5b07b87..91bd996ba5b 100644 --- a/tests/components/schlage/test_binary_sensor.py +++ b/tests/components/schlage/test_binary_sensor.py @@ -8,7 +8,7 @@ from pyschlage.exceptions import UnknownError from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from tests.common import async_fire_time_changed @@ -37,15 +37,6 @@ async def test_keypad_disabled_binary_sensor( mock_lock.keypad_disabled.assert_called_once_with([]) - mock_schlage.locks.return_value = [] - # Make the coordinator refresh data. - freezer.tick(timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") - assert keypad is not None - assert keypad.state == STATE_UNAVAILABLE - async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure( hass: HomeAssistant, diff --git a/tests/components/schlage/test_init.py b/tests/components/schlage/test_init.py index 0fe7af1982b..1f18bdde218 100644 --- a/tests/components/schlage/test_init.py +++ b/tests/components/schlage/test_init.py @@ -1,14 +1,20 @@ """Tests for the Schlage integration.""" -from unittest.mock import Mock, patch +from typing import Any +from unittest.mock import Mock, create_autospec, patch +from freezegun.api import FrozenDateTimeFactory from pycognito.exceptions import WarrantException from pyschlage.exceptions import Error, NotAuthorizedError +from pyschlage.lock import Lock +from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.schlage.const import DOMAIN, UPDATE_INTERVAL +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @patch( @@ -94,3 +100,65 @@ async def test_load_unload_config_entry( await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_lock_device_registry( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_added_config_entry: ConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test lock is added to device registry.""" + device = device_registry.async_get_device(identifiers={(DOMAIN, "test")}) + assert device == snapshot + + +async def test_auto_add_device( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_added_config_entry: ConfigEntry, + mock_schlage: Mock, + mock_lock: Mock, + mock_lock_attrs: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test new devices are auto-added to the device registry.""" + device = device_registry.async_get_device(identifiers={(DOMAIN, "test")}) + assert device is not None + + mock_lock_attrs["device_id"] = "test2" + new_mock_lock = create_autospec(Lock) + new_mock_lock.configure_mock(**mock_lock_attrs) + mock_schlage.locks.return_value = [mock_lock, new_mock_lock] + + # Make the coordinator refresh data. + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + new_device = device_registry.async_get_device(identifiers={(DOMAIN, "test2")}) + assert new_device is not None + + +async def test_auto_remove_device( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_added_config_entry: ConfigEntry, + mock_schlage: Mock, + mock_lock: Mock, + mock_lock_attrs: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test new devices are auto-added to the device registry.""" + device = device_registry.async_get_device(identifiers={(DOMAIN, "test")}) + assert device is not None + + mock_schlage.locks.return_value = [] + + # Make the coordinator refresh data. + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + new_device = device_registry.async_get_device(identifiers={(DOMAIN, "test")}) + assert new_device is None diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index ab0f4f5d863..74af80dce84 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -12,28 +12,13 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_UNLOCK, STATE_JAMMED, - STATE_UNAVAILABLE, STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr from tests.common import async_fire_time_changed -async def test_lock_device_registry( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_added_config_entry: ConfigEntry, -) -> None: - """Test lock is added to device registry.""" - device = device_registry.async_get_device(identifiers={("schlage", "test")}) - assert device.model == "" - assert device.sw_version == "1.0" - assert device.name == "Vault Door" - assert device.manufacturer == "Schlage" - - async def test_lock_attributes( hass: HomeAssistant, mock_added_config_entry: ConfigEntry, @@ -57,16 +42,6 @@ async def test_lock_attributes( assert lock is not None assert lock.state == STATE_JAMMED - mock_schlage.locks.return_value = [] - # Make the coordinator refresh data. - freezer.tick(timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done(wait_background_tasks=True) - lock = hass.states.get("lock.vault_door") - assert lock is not None - assert lock.state == STATE_UNAVAILABLE - assert "changed_by" not in lock.attributes - async def test_lock_services( hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry @@ -107,7 +82,7 @@ async def test_changed_by( freezer.tick(timedelta(seconds=30)) async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) - mock_lock.last_changed_by.assert_called_once_with() + mock_lock.last_changed_by.assert_called_with() lock_device = hass.states.get("lock.vault_door") assert lock_device is not None diff --git a/tests/components/schlage/test_sensor.py b/tests/components/schlage/test_sensor.py index 2c0cabbb1e8..9fa90edecbb 100644 --- a/tests/components/schlage/test_sensor.py +++ b/tests/components/schlage/test_sensor.py @@ -4,20 +4,6 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr - - -async def test_sensor_device_registry( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_added_config_entry: ConfigEntry, -) -> None: - """Test sensor is added to device registry.""" - device = device_registry.async_get_device(identifiers={("schlage", "test")}) - assert device.model == "" - assert device.sw_version == "1.0" - assert device.name == "Vault Door" - assert device.manufacturer == "Schlage" async def test_battery_sensor( diff --git a/tests/components/schlage/test_switch.py b/tests/components/schlage/test_switch.py index f1cded3ce22..52b8da81670 100644 --- a/tests/components/schlage/test_switch.py +++ b/tests/components/schlage/test_switch.py @@ -6,20 +6,6 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr - - -async def test_switch_device_registry( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_added_config_entry: ConfigEntry, -) -> None: - """Test switch is added to device registry.""" - device = device_registry.async_get_device(identifiers={("schlage", "test")}) - assert device.model == "" - assert device.sw_version == "1.0" - assert device.name == "Vault Door" - assert device.manufacturer == "Schlage" async def test_beeper_services( From a01036760e4b6b1ad193cb8b16018c899033d651 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Fri, 13 Sep 2024 09:20:31 -0400 Subject: [PATCH 0619/1309] Add tests to the media_player platform of the Squeezebox integration (#125378) * Squeezebox media_player platform tests * Fix play-pause test * Squeezebox remove stray reference to deprecated property * More tests for squeezebox * Update tests to fix merge conflict with binary_sensor * Refactor tests to use autospec * Use freeze and snapshot * Update media player entity before adding * Consolidate test fixtures for different platforms * Merge in sensor platform * Use deepcopy * Update tests with suggestions from code review --- .../components/squeezebox/media_player.py | 25 +- tests/components/squeezebox/__init__.py | 84 -- tests/components/squeezebox/conftest.py | 201 ++++- .../snapshots/test_media_player.ambr | 99 +++ .../squeezebox/test_binary_sensor.py | 21 +- .../squeezebox/test_media_player.py | 815 ++++++++++++++++++ tests/components/squeezebox/test_sensor.py | 15 +- 7 files changed, 1125 insertions(+), 135 deletions(-) create mode 100644 tests/components/squeezebox/snapshots/test_media_player.ambr create mode 100644 tests/components/squeezebox/test_media_player.py diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index f7f8df55e2c..610cb28d9ee 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -14,6 +14,7 @@ import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( ATTR_MEDIA_ENQUEUE, + BrowseError, BrowseMedia, MediaPlayerEnqueue, MediaPlayerEntity, @@ -26,6 +27,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, discovery_flow, @@ -138,7 +140,7 @@ async def async_setup_entry( _LOGGER.debug("Adding new entity: %s", player) entity = SqueezeBoxEntity(player, lms) known_players.append(entity) - async_add_entities([entity]) + async_add_entities([entity], True) if players := await lms.async_get_players(): for player in players: @@ -248,8 +250,11 @@ class SqueezeBoxEntity(MediaPlayerEntity): """Return the state of the device.""" if not self._player.power: return MediaPlayerState.OFF - if self._player.mode: - return SQUEEZEBOX_MODE.get(self._player.mode) + if self._player.mode and self._player.mode in SQUEEZEBOX_MODE: + return SQUEEZEBOX_MODE[self._player.mode] + _LOGGER.error( + "Received unknown mode %s from player %s", self._player.mode, self.name + ) return None async def async_update(self) -> None: @@ -278,6 +283,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): """Volume level of the media player (0..1).""" if self._player.volume: return int(float(self._player.volume)) / 100.0 + return None @property @@ -322,7 +328,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): @property def media_image_url(self) -> str | None: """Image url of current playing media.""" - return str(self._player.image_url) + return str(self._player.image_url) if self._player.image_url else None @property def media_title(self) -> str | None: @@ -371,11 +377,6 @@ class SqueezeBoxEntity(MediaPlayerEntity): if player in player_ids ] - @property - def sync_group(self) -> list[str]: - """List players we are synced with. Deprecated.""" - return self.group_members - @property def query_result(self) -> dict | bool: """Return the result from the call_query service.""" @@ -474,7 +475,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): "search_type": MediaType.PLAYLIST, } playlist = await generate_playlist(self._player, payload) - except ValueError: + except BrowseError: # a list of urls content = json.loads(media_id) playlist = content["urls"] @@ -553,8 +554,8 @@ class SqueezeBoxEntity(MediaPlayerEntity): if other_player_id := player_ids.get(other_player): await self._player.async_sync(other_player_id) else: - _LOGGER.debug( - "Could not find player_id for %s. Not syncing", other_player + raise ServiceValidationError( + f"Could not join unknown player {other_player}" ) async def async_unjoin_player(self) -> None: diff --git a/tests/components/squeezebox/__init__.py b/tests/components/squeezebox/__init__.py index 3b7a57db459..34c0363292d 100644 --- a/tests/components/squeezebox/__init__.py +++ b/tests/components/squeezebox/__init__.py @@ -1,85 +1 @@ """Tests for the Logitech Squeezebox integration.""" - -from homeassistant.components.squeezebox.const import ( - DOMAIN, - STATUS_QUERY_LIBRARYNAME, - STATUS_QUERY_MAC, - STATUS_QUERY_UUID, - STATUS_QUERY_VERSION, - STATUS_SENSOR_INFO_TOTAL_ALBUMS, - STATUS_SENSOR_INFO_TOTAL_ARTISTS, - STATUS_SENSOR_INFO_TOTAL_DURATION, - STATUS_SENSOR_INFO_TOTAL_GENRES, - STATUS_SENSOR_INFO_TOTAL_SONGS, - STATUS_SENSOR_LASTSCAN, - STATUS_SENSOR_OTHER_PLAYER_COUNT, - STATUS_SENSOR_PLAYER_COUNT, - STATUS_SENSOR_RESCAN, -) -from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant - -# from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry - -FAKE_IP = "42.42.42.42" -FAKE_MAC = "deadbeefdead" -FAKE_UUID = "deadbeefdeadbeefbeefdeafbeef42" -FAKE_PORT = 9000 -FAKE_VERSION = "42.0" - -FAKE_QUERY_RESPONSE = { - STATUS_QUERY_UUID: FAKE_UUID, - STATUS_QUERY_MAC: FAKE_MAC, - STATUS_QUERY_VERSION: FAKE_VERSION, - STATUS_SENSOR_RESCAN: 1, - STATUS_SENSOR_LASTSCAN: 0, - STATUS_QUERY_LIBRARYNAME: "FakeLib", - STATUS_SENSOR_INFO_TOTAL_ALBUMS: 4, - STATUS_SENSOR_INFO_TOTAL_ARTISTS: 2, - STATUS_SENSOR_INFO_TOTAL_DURATION: 500, - STATUS_SENSOR_INFO_TOTAL_GENRES: 1, - STATUS_SENSOR_INFO_TOTAL_SONGS: 42, - STATUS_SENSOR_PLAYER_COUNT: 10, - STATUS_SENSOR_OTHER_PLAYER_COUNT: 0, - "players_loop": [ - { - "isplaying": 0, - "name": "SqueezeLite-HA-Addon", - "seq_no": 0, - "modelname": "SqueezeLite-HA-Addon", - "playerindex": "status", - "model": "squeezelite", - "uuid": FAKE_UUID, - "canpoweroff": 1, - "ip": "192.168.78.86:57700", - "displaytype": "none", - "playerid": "f9:23:cd:37:c5:ff", - "power": 0, - "isplayer": 1, - "connected": 1, - "firmware": "v2.0.0-1488", - } - ], - "count": 1, -} - - -async def setup_mocked_integration(hass: HomeAssistant) -> MockConfigEntry: - """Mock ConfigEntry in Home Assistant.""" - - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=FAKE_UUID, - data={ - CONF_HOST: FAKE_IP, - CONF_PORT: FAKE_PORT, - }, - ) - - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 26cb0726aca..9c8201cfbca 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -11,17 +11,82 @@ from homeassistant.components.squeezebox.browse_media import ( MEDIA_TYPE_TO_SQUEEZEBOX, SQUEEZEBOX_ID_BY_TYPE, ) -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.components.squeezebox.const import ( + STATUS_QUERY_LIBRARYNAME, + STATUS_QUERY_MAC, + STATUS_QUERY_UUID, + STATUS_QUERY_VERSION, + STATUS_SENSOR_INFO_TOTAL_ALBUMS, + STATUS_SENSOR_INFO_TOTAL_ARTISTS, + STATUS_SENSOR_INFO_TOTAL_DURATION, + STATUS_SENSOR_INFO_TOTAL_GENRES, + STATUS_SENSOR_INFO_TOTAL_SONGS, + STATUS_SENSOR_LASTSCAN, + STATUS_SENSOR_OTHER_PLAYER_COUNT, + STATUS_SENSOR_PLAYER_COUNT, + STATUS_SENSOR_RESCAN, +) +from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +# from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry TEST_HOST = "1.2.3.4" TEST_PORT = "9000" TEST_USE_HTTPS = False -SERVER_UUID = "12345678-1234-1234-1234-123456789012" -TEST_MAC = "aa:bb:cc:dd:ee:ff" +SERVER_UUIDS = [ + "12345678-1234-1234-1234-123456789012", + "87654321-4321-4321-4321-210987654321", +] +TEST_MAC = ["aa:bb:cc:dd:ee:ff", "ff:ee:dd:cc:bb:aa"] +TEST_PLAYER_NAME = "Test Player" +TEST_SERVER_NAME = "Test Server" +FAKE_VALID_ITEM_ID = "1234" +FAKE_INVALID_ITEM_ID = "4321" + +FAKE_IP = "42.42.42.42" +FAKE_MAC = "deadbeefdead" +FAKE_UUID = "deadbeefdeadbeefbeefdeafbeef42" +FAKE_PORT = 9000 +FAKE_VERSION = "42.0" + +FAKE_QUERY_RESPONSE = { + STATUS_QUERY_UUID: FAKE_UUID, + STATUS_QUERY_MAC: FAKE_MAC, + STATUS_QUERY_VERSION: FAKE_VERSION, + STATUS_SENSOR_RESCAN: 1, + STATUS_SENSOR_LASTSCAN: 0, + STATUS_QUERY_LIBRARYNAME: "FakeLib", + STATUS_SENSOR_INFO_TOTAL_ALBUMS: 4, + STATUS_SENSOR_INFO_TOTAL_ARTISTS: 2, + STATUS_SENSOR_INFO_TOTAL_DURATION: 500, + STATUS_SENSOR_INFO_TOTAL_GENRES: 1, + STATUS_SENSOR_INFO_TOTAL_SONGS: 42, + STATUS_SENSOR_PLAYER_COUNT: 10, + STATUS_SENSOR_OTHER_PLAYER_COUNT: 0, + "players_loop": [ + { + "isplaying": 0, + "name": "SqueezeLite-HA-Addon", + "seq_no": 0, + "modelname": "SqueezeLite-HA-Addon", + "playerindex": "status", + "model": "squeezelite", + "uuid": FAKE_UUID, + "canpoweroff": 1, + "ip": "192.168.78.86:57700", + "displaytype": "none", + "playerid": "f9:23:cd:37:c5:ff", + "power": 0, + "isplayer": 1, + "connected": 1, + "firmware": "v2.0.0-1488", + } + ], + "count": 1, +} @pytest.fixture @@ -38,7 +103,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Add the squeezebox mock config entry to hass.""" config_entry = MockConfigEntry( domain=const.DOMAIN, - unique_id=SERVER_UUID, + unique_id=SERVER_UUIDS[0], data={ CONF_HOST: TEST_HOST, CONF_PORT: TEST_PORT, @@ -69,29 +134,41 @@ async def mock_async_browse( fake_items = [ { "title": "Fake Item 1", - "id": "1234", + "id": FAKE_VALID_ITEM_ID, "hasitems": False, "item_type": child_types[media_type], "artwork_track_id": "b35bb9e9", + "url": "file:///var/lib/squeezeboxserver/music/track_1.mp3", }, { "title": "Fake Item 2", - "id": "12345", + "id": FAKE_VALID_ITEM_ID + "_2", "hasitems": media_type == "favorites", "item_type": child_types[media_type], "image_url": "http://lms.internal:9000/html/images/favorites.png", + "url": "file:///var/lib/squeezeboxserver/music/track_2.mp3", }, { "title": "Fake Item 3", - "id": "123456", + "id": FAKE_VALID_ITEM_ID + "_3", "hasitems": media_type == "favorites", - "album_id": "123456" if media_type == "favorites" else None, + "album_id": FAKE_VALID_ITEM_ID if media_type == "favorites" else None, + "url": "file:///var/lib/squeezeboxserver/music/track_3.mp3", }, ] if browse_id: search_type, search_id = browse_id if search_id: + if search_type == "playlist_id": + return ( + { + "title": "Fake Item 1", + "items": fake_items, + } + if search_id == FAKE_VALID_ITEM_ID + else None + ) if search_type in SQUEEZEBOX_ID_BY_TYPE.values(): for item in fake_items: if item["id"] == search_id: @@ -115,20 +192,96 @@ async def mock_async_browse( @pytest.fixture -def lms() -> MagicMock: - """Mock a Lyrion Media Server with one mock player attached.""" - lms = MagicMock() - player = MagicMock() - player.player_id = TEST_MAC - player.name = "Test Player" - player.power = False - player.async_browse = AsyncMock(side_effect=mock_async_browse) - player.async_load_playlist = AsyncMock() - player.async_update = AsyncMock() - player.generate_image_url_from_track_id = MagicMock( - return_value="http://lms.internal:9000/html/images/favorites.png" +def player() -> MagicMock: + """Return a mock player.""" + return mock_pysqueezebox_player() + + +@pytest.fixture +def player_factory() -> MagicMock: + """Return a factory for creating mock players.""" + return mock_pysqueezebox_player + + +def mock_pysqueezebox_player(uuid: str) -> MagicMock: + """Mock a Lyrion Media Server player.""" + with patch( + "homeassistant.components.squeezebox.media_player.Player", autospec=True + ) as mock_player: + mock_player.async_browse = AsyncMock(side_effect=mock_async_browse) + mock_player.generate_image_url_from_track_id = MagicMock( + return_value="http://lms.internal:9000/html/images/favorites.png" + ) + mock_player.name = TEST_PLAYER_NAME + mock_player.player_id = uuid + mock_player.mode = "stop" + mock_player.playlist = None + mock_player.album = None + mock_player.artist = None + mock_player.remote_title = None + mock_player.title = None + mock_player.image_url = None + + return mock_player + + +@pytest.fixture +def lms_factory(player_factory: MagicMock) -> MagicMock: + """Return a factory for creating mock Lyrion Media Servers with arbitrary number of players.""" + return lambda player_count, uuid: mock_pysqueezebox_server( + player_factory, player_count, uuid ) - lms.async_get_players = AsyncMock(return_value=[player]) - lms.async_query = AsyncMock(return_value={"uuid": format_mac(TEST_MAC)}) - lms.async_status = AsyncMock(return_value={"uuid": format_mac(TEST_MAC)}) - return lms + + +@pytest.fixture +def lms(player_factory: MagicMock) -> MagicMock: + """Mock a Lyrion Media Server with one mock player attached.""" + return mock_pysqueezebox_server(player_factory, 1, uuid=TEST_MAC[0]) + + +def mock_pysqueezebox_server( + player_factory: MagicMock, player_count: int, uuid: str +) -> MagicMock: + """Create a mock Lyrion Media Server with the given number of mock players attached.""" + with patch("homeassistant.components.squeezebox.Server", autospec=True) as mock_lms: + players = [player_factory(TEST_MAC[index]) for index in range(player_count)] + mock_lms.async_get_players = AsyncMock(return_value=players) + + mock_lms.uuid = uuid + mock_lms.name = TEST_SERVER_NAME + mock_lms.async_query = AsyncMock(return_value={"uuid": format_mac(uuid)}) + mock_lms.async_status = AsyncMock(return_value={"uuid": format_mac(uuid)}) + return mock_lms + + +async def configure_squeezebox_media_player_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + lms: MagicMock, +) -> None: + """Configure a squeezebox config entry with appropriate mocks for media_player.""" + with ( + patch("homeassistant.components.squeezebox.PLATFORMS", [Platform.MEDIA_PLAYER]), + patch("homeassistant.components.squeezebox.Server", return_value=lms), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.fixture +async def configured_player( + hass: HomeAssistant, config_entry: MockConfigEntry, lms: MagicMock +) -> MagicMock: + """Fixture mocking calls to pysqueezebox Player from a configured squeezebox.""" + await configure_squeezebox_media_player_platform(hass, config_entry, lms) + return (await lms.async_get_players())[0] + + +@pytest.fixture +async def configured_players( + hass: HomeAssistant, config_entry: MockConfigEntry, lms_factory: MagicMock +) -> list[MagicMock]: + """Fixture mocking calls to two pysqueezebox Players from a configured squeezebox.""" + lms = lms_factory(2, uuid=SERVER_UUIDS[0]) + await configure_squeezebox_media_player_platform(hass, config_entry, lms) + return await lms.async_get_players() diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..cac53d9a5af --- /dev/null +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'squeezebox', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'https://lyrion.org/', + 'model': 'Lyrion Music Server', + 'model_id': None, + 'name': 'Test Player', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_entity_registry[media_player.test_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'squeezebox', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[media_player.test_player-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Player', + 'group_members': list([ + ]), + 'is_volume_muted': True, + 'media_album_name': 'None', + 'media_artist': 'None', + 'media_channel': 'None', + 'media_duration': 1, + 'media_position': 1, + 'media_title': 'None', + 'query_result': dict({ + }), + 'repeat': , + 'shuffle': False, + 'supported_features': , + 'volume_level': 0.01, + }), + 'context': , + 'entity_id': 'media_player.test_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/squeezebox/test_binary_sensor.py b/tests/components/squeezebox/test_binary_sensor.py index 450d16a709c..71cb5ceb105 100644 --- a/tests/components/squeezebox/test_binary_sensor.py +++ b/tests/components/squeezebox/test_binary_sensor.py @@ -1,22 +1,21 @@ """Test squeezebox binary sensors.""" -import copy +from copy import deepcopy from unittest.mock import patch from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from . import FAKE_QUERY_RESPONSE, setup_mocked_integration +from .conftest import FAKE_QUERY_RESPONSE + +from tests.common import MockConfigEntry async def test_binary_sensor( hass: HomeAssistant, - entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, ) -> None: """Test binary sensor states and attributes.""" - - # Setup component with ( patch( "homeassistant.components.squeezebox.PLATFORMS", @@ -24,11 +23,13 @@ async def test_binary_sensor( ), patch( "homeassistant.components.squeezebox.Server.async_query", - return_value=copy.deepcopy(FAKE_QUERY_RESPONSE), + return_value=deepcopy(FAKE_QUERY_RESPONSE), ), ): - await setup_mocked_integration(hass) - state = hass.states.get("binary_sensor.fakelib_library_rescan") + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("binary_sensor.fakelib_needs_restart") assert state is not None - assert state.state == "on" + assert state.state == "off" diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py new file mode 100644 index 00000000000..7721a2b86b4 --- /dev/null +++ b/tests/components/squeezebox/test_media_player.py @@ -0,0 +1,815 @@ +"""Tests for the squeezebox media player component.""" + +from datetime import timedelta +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_ENQUEUE, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEEK_POSITION, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, + SERVICE_JOIN, + SERVICE_PLAY_MEDIA, + SERVICE_UNJOIN, + MediaPlayerEnqueue, + MediaPlayerState, + MediaType, + RepeatMode, +) +from homeassistant.components.squeezebox.const import DOMAIN, SENSOR_UPDATE_INTERVAL +from homeassistant.components.squeezebox.media_player import ( + ATTR_PARAMETERS, + DISCOVERY_INTERVAL, + SERVICE_CALL_METHOD, + SERVICE_CALL_QUERY, +) +from homeassistant.const import ( + ATTR_COMMAND, + ATTR_ENTITY_ID, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PLAY_PAUSE, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, + SERVICE_MEDIA_STOP, + SERVICE_REPEAT_SET, + SERVICE_SHUFFLE_SET, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.util.dt import utcnow + +from .conftest import FAKE_VALID_ITEM_ID, TEST_MAC + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_device_registry( + hass: HomeAssistant, + device_registry: DeviceRegistry, + configured_player: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test squeezebox device registered in the device registry.""" + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[0])}) + assert reg_device is not None + assert reg_device == snapshot + + +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: EntityRegistry, + configured_player: MagicMock, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, +) -> None: + """Test squeezebox media_player entity registered in the entity registry.""" + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_squeezebox_player_rediscovery( + hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + """Test rediscovery of a squeezebox player.""" + + assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE + + # Make the player appear unavailable + configured_player.connected = False + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + assert hass.states.get("media_player.test_player").state == STATE_UNAVAILABLE + + # Make the player available again + configured_player.connected = True + freezer.tick(timedelta(seconds=DISCOVERY_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE + + +async def test_squeezebox_turn_on( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test turn on service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_set_power.assert_called_once_with(True) + + +async def test_squeezebox_turn_off( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test turn off service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_set_power.assert_called_once_with(False) + + +async def test_squeezebox_state( + hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + """Test determining the MediaPlayerState.""" + + configured_player.power = True + configured_player.mode = "stop" + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE + + configured_player.mode = "play" + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("media_player.test_player").state == MediaPlayerState.PLAYING + + configured_player.mode = "pause" + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("media_player.test_player").state == MediaPlayerState.PAUSED + + configured_player.power = False + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("media_player.test_player").state == MediaPlayerState.OFF + + +async def test_squeezebox_volume_up( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test volume up service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_UP, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_set_volume.assert_called_once_with("+5") + + +async def test_squeezebox_volume_down( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test volume down service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_DOWN, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_set_volume.assert_called_once_with("-5") + + +async def test_squeezebox_volume_set( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test volume set service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + configured_player.async_set_volume.assert_called_once_with("50") + + +async def test_squeezebox_volume_property( + hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + """Test volume property.""" + + configured_player.volume = 50 + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_VOLUME_LEVEL] + == 0.5 + ) + + configured_player.volume = None + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + ATTR_MEDIA_VOLUME_LEVEL + not in hass.states.get("media_player.test_player").attributes + ) + + +async def test_squeezebox_mute( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test mute service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: True}, + blocking=True, + ) + configured_player.async_set_muting.assert_called_once_with(True) + + +async def test_squeezebox_unmute( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test unmute service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_MUTE, + {ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_MUTED: False}, + blocking=True, + ) + configured_player.async_set_muting.assert_called_once_with(False) + + +async def test_squeezebox_mute_property( + hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + """Test the mute property.""" + + configured_player.muting = True + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_VOLUME_MUTED] + is True + ) + + configured_player.muting = False + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_VOLUME_MUTED] + is False + ) + + +async def test_squeezebox_repeat_mode( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test set repeat mode service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_REPEAT: RepeatMode.ALL, + }, + blocking=True, + ) + configured_player.async_set_repeat.assert_called_once_with("playlist") + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_REPEAT: RepeatMode.ONE, + }, + blocking=True, + ) + configured_player.async_set_repeat.assert_called_with("song") + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_REPEAT_SET, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_REPEAT: RepeatMode.OFF, + }, + blocking=True, + ) + configured_player.async_set_repeat.assert_called_with("none") + + +async def test_squeezebox_repeat_mode_property( + hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + """Test the repeat mode property.""" + configured_player.repeat = "playlist" + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_REPEAT] + == RepeatMode.ALL + ) + + configured_player.repeat = "song" + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_REPEAT] + == RepeatMode.ONE + ) + + configured_player.repeat = "none" + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_REPEAT] + == RepeatMode.OFF + ) + + +async def test_squeezebox_shuffle( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test set shuffle service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SHUFFLE_SET, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_SHUFFLE: True, + }, + blocking=True, + ) + configured_player.async_set_shuffle.assert_called_once_with("song") + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SHUFFLE_SET, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_SHUFFLE: False, + }, + blocking=True, + ) + configured_player.async_set_shuffle.assert_called_with("none") + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_SHUFFLE] + is False + ) + + +async def test_squeezebox_shuffle_property( + hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + """Test the shuffle property.""" + + configured_player.shuffle = "song" + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_SHUFFLE] + is True + ) + + configured_player.shuffle = "none" + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_SHUFFLE] + is False + ) + + +async def test_squeezebox_play( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test play service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_play.assert_called_once() + + +async def test_squeezebox_play_pause( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test play/pause service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY_PAUSE, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_toggle_pause.assert_called_once() + + +async def test_squeezebox_pause( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test pause service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_pause.assert_called_once() + + +async def test_squeezebox_seek( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test seek service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + }, + blocking=True, + ) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_SEEK, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_SEEK_POSITION: 100, + }, + blocking=True, + ) + configured_player.async_time.assert_called_once_with(100) + + +async def test_squeezebox_stop( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test stop service call.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_STOP, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_stop.assert_called_once() + + +async def test_squeezebox_load_playlist( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test load a playlist.""" + # load a playlist by number + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, + }, + blocking=True, + ) + assert configured_player.async_load_playlist.call_count == 1 + + # load a list of urls + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: json.dumps( + { + "urls": [ + {"url": FAKE_VALID_ITEM_ID}, + {"url": FAKE_VALID_ITEM_ID + "_2"}, + ], + "index": "0", + } + ), + ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, + }, + blocking=True, + ) + assert configured_player.async_load_playlist.call_count == 2 + + # clear the playlist + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_CLEAR_PLAYLIST, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_clear_playlist.assert_called_once() + + +async def test_squeezebox_enqueue( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test the various enqueue service calls.""" + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.ADD, + }, + blocking=True, + ) + configured_player.async_load_url.assert_called_once_with(FAKE_VALID_ITEM_ID, "add") + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.NEXT, + }, + blocking=True, + ) + configured_player.async_load_url.assert_called_with(FAKE_VALID_ITEM_ID, "insert") + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.PLAY, + }, + blocking=True, + ) + configured_player.async_load_url.assert_called_with(FAKE_VALID_ITEM_ID, "play_now") + + +async def test_squeezebox_skip_tracks( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test track skipping service calls.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.PLAYLIST, + }, + blocking=True, + ) + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_index.assert_called_once_with("+1") + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_index.assert_called_with("-1") + + +async def test_squeezebox_call_query( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test query service call.""" + await hass.services.async_call( + DOMAIN, + SERVICE_CALL_QUERY, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_COMMAND: "test_command", + ATTR_PARAMETERS: ["param1", "param2"], + }, + blocking=True, + ) + configured_player.async_query.assert_called_once_with( + "test_command", "param1", "param2" + ) + + +async def test_squeezebox_call_method( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test method call service call.""" + await hass.services.async_call( + DOMAIN, + SERVICE_CALL_METHOD, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_COMMAND: "test_command", + ATTR_PARAMETERS: ["param1", "param2"], + }, + blocking=True, + ) + configured_player.async_query.assert_called_once_with( + "test_command", "param1", "param2" + ) + + +async def test_squeezebox_invalid_state( + hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + """Test handling an unexpected state from pysqueezebox.""" + configured_player.mode = "invalid" + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("media_player.test_player").state == STATE_UNKNOWN + + +async def test_squeezebox_server_discovery( + hass: HomeAssistant, + lms: MagicMock, + lms_factory: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test discovery of a squeezebox server.""" + + async def mock_async_discover(callback): + """Mock the async_discover function of pysqueezebox.""" + return callback(lms_factory(2)) + + with ( + patch( + "homeassistant.components.squeezebox.Server", + return_value=lms, + ), + patch( + "homeassistant.components.squeezebox.media_player.async_discover", + mock_async_discover, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + # how do we check that a config flow started? + + +async def test_squeezebox_join(hass: HomeAssistant, configured_players: list) -> None: + """Test joining a squeezebox player.""" + + # join a valid player + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.test_player_2"], + }, + blocking=True, + ) + configured_players[0].async_sync.assert_called_once_with( + configured_players[1].player_id + ) + + # try to join an invalid player + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_JOIN, + { + ATTR_ENTITY_ID: "media_player.test_player", + ATTR_GROUP_MEMBERS: ["media_player.invalid"], + }, + blocking=True, + ) + + +async def test_squeezebox_unjoin( + hass: HomeAssistant, configured_player: MagicMock +) -> None: + """Test unjoining a squeezebox player.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_UNJOIN, + {ATTR_ENTITY_ID: "media_player.test_player"}, + blocking=True, + ) + configured_player.async_unsync.assert_called_once() + + +async def test_squeezebox_media_content_properties( + hass: HomeAssistant, + configured_player: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test media_content_id and media_content_type properties.""" + playlist_urls = [ + {"url": "test_title"}, + {"url": "test_title_2"}, + ] + configured_player.current_index = 0 + configured_player.playlist = playlist_urls + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("media_player.test_player").attributes[ + ATTR_MEDIA_CONTENT_ID + ] == json.dumps({"index": 0, "urls": playlist_urls}) + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_CONTENT_TYPE] + == MediaType.PLAYLIST + ) + + configured_player.url = "test_url" + configured_player.playlist = [{"url": "test_url"}] + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_CONTENT_ID] + == "test_url" + ) + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_CONTENT_TYPE] + == MediaType.MUSIC + ) + + configured_player.playlist = None + configured_player.url = None + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + ATTR_MEDIA_CONTENT_ID + not in hass.states.get("media_player.test_player").attributes + ) + assert ( + ATTR_MEDIA_CONTENT_TYPE + not in hass.states.get("media_player.test_player").attributes + ) + + +async def test_squeezebox_media_position_property( + hass: HomeAssistant, configured_player: MagicMock, freezer: FrozenDateTimeFactory +) -> None: + """Test media_position property.""" + configured_player.time = 100 + configured_player.async_update = AsyncMock( + side_effect=lambda: setattr(configured_player, "time", 105) + ) + last_update = utcnow() + freezer.tick(timedelta(seconds=SENSOR_UPDATE_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("media_player.test_player").attributes[ATTR_MEDIA_POSITION] + == 105 + ) + assert ( + ( + hass.states.get("media_player.test_player").attributes[ + ATTR_MEDIA_POSITION_UPDATED_AT + ] + ) + > last_update + ) diff --git a/tests/components/squeezebox/test_sensor.py b/tests/components/squeezebox/test_sensor.py index b9e9802568c..c262c2a0e7c 100644 --- a/tests/components/squeezebox/test_sensor.py +++ b/tests/components/squeezebox/test_sensor.py @@ -1,15 +1,18 @@ """Test squeezebox sensors.""" +from copy import deepcopy from unittest.mock import patch from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from . import FAKE_QUERY_RESPONSE, setup_mocked_integration +from .conftest import FAKE_QUERY_RESPONSE + +from tests.common import MockConfigEntry -async def test_sensor(hass: HomeAssistant) -> None: - """Test binary sensor states and attributes.""" +async def test_sensor(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test sensor states and attributes.""" # Setup component with ( @@ -19,10 +22,12 @@ async def test_sensor(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.squeezebox.Server.async_query", - return_value=FAKE_QUERY_RESPONSE, + return_value=deepcopy(FAKE_QUERY_RESPONSE), ), ): - await setup_mocked_integration(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get("sensor.fakelib_player_count") assert state is not None From ba856dac4e5d7183bc57be278537cb0ada4d2dfa Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 13 Sep 2024 14:39:22 +0100 Subject: [PATCH 0620/1309] Migrate ring cam siren from switch to siren platform (#125761) --- homeassistant/components/ring/siren.py | 16 +++- homeassistant/components/ring/switch.py | 4 + .../components/ring/snapshots/test_siren.ambr | 96 +++++++++++++++++++ tests/components/ring/test_siren.py | 51 +++++++++- tests/components/ring/test_switch.py | 68 +++++++++++-- 5 files changed, 225 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 1a008695586..b1452f7aeb5 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -5,7 +5,13 @@ from dataclasses import dataclass import logging from typing import Any, Generic, cast -from ring_doorbell import RingChime, RingEventKind, RingGeneric +from ring_doorbell import ( + RingCapability, + RingChime, + RingEventKind, + RingGeneric, + RingStickUpCam, +) from homeassistant.components.siren import ( ATTR_TONE, @@ -61,6 +67,14 @@ SIRENS: tuple[RingSirenEntityDescription[Any], ...] = ( kind=str(kwargs.get(ATTR_TONE) or "") or RingEventKind.DING.value ), ), + RingSirenEntityDescription[RingStickUpCam]( + key="siren", + translation_key="siren", + exists_fn=lambda device: device.has_capability(RingCapability.SIREN), + is_on_fn=lambda device: device.siren > 0, + turn_on_fn=lambda device, _: device.async_set_siren(1), + turn_off_fn=lambda device: device.async_set_siren(0), + ), ) diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index b81bf233ce8..f3a7d9a1252 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -16,6 +16,7 @@ import homeassistant.util.dt as dt_util from . import RingConfigEntry from .coordinator import RingDataCoordinator from .entity import ( + DeprecatedInfo, RingDeviceT, RingEntity, RingEntityDescription, @@ -49,6 +50,9 @@ SWITCHES: Sequence[RingSwitchEntityDescription[Any]] = ( is_on_fn=lambda device: device.siren > 0, turn_on_fn=lambda device: device.async_set_siren(1), turn_off_fn=lambda device: device.async_set_siren(0), + deprecated_info=DeprecatedInfo( + new_platform=Platform.SIREN, breaks_in_ha_version="2025.4.0" + ), ), ) diff --git a/tests/components/ring/snapshots/test_siren.ambr b/tests/components/ring/snapshots/test_siren.ambr index 14fdf63db7b..c49ab2cb30f 100644 --- a/tests/components/ring/snapshots/test_siren.ambr +++ b/tests/components/ring/snapshots/test_siren.ambr @@ -56,3 +56,99 @@ 'state': 'unknown', }) # --- +# name: test_states[siren.front_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.front_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'siren', + 'unique_id': '765432', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[siren.front_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Siren', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.front_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_states[siren.internal_siren-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.internal_siren', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Siren', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'siren', + 'unique_id': '345678', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[siren.internal_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Internal Siren', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.internal_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ring/test_siren.py b/tests/components/ring/test_siren.py index 6ab1ef0bdf1..6cfe8aecd57 100644 --- a/tests/components/ring/test_siren.py +++ b/tests/components/ring/test_siren.py @@ -6,8 +6,16 @@ import pytest import ring_doorbell from syrupy.assertion import SnapshotAssertion +from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -184,3 +192,44 @@ async def test_siren_errors_when_turned_on( ) == reauth_expected ) + + +async def test_camera_siren_on_off( + hass: HomeAssistant, mock_ring_client, mock_ring_devices +) -> None: + """Tests siren on a ring camera turns on and off.""" + await setup_platform(hass, Platform.SIREN) + + entity_id = "siren.front_siren" + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + downstairs_chime_mock = mock_ring_devices.get_device(765432) + downstairs_chime_mock.async_set_siren.assert_called_once_with(1) + + downstairs_chime_mock.async_set_siren.reset_mock() + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + downstairs_chime_mock.async_set_siren.assert_called_once_with(0) + + assert state.state == STATE_OFF diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index 7b10ea0f23d..c0d49ad2896 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -6,8 +6,17 @@ import pytest import ring_doorbell from syrupy.assertion import SnapshotAssertion -from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import Platform +from homeassistant.components.ring.const import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -17,10 +26,35 @@ from .common import MockConfigEntry, setup_platform from tests.common import snapshot_platform +@pytest.fixture +def create_deprecated_siren_entity( + hass: HomeAssistant, + mock_config_entry: ConfigEntry, + 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): + unique_id = f"{device_id}-siren" + + entity_registry.async_get_or_create( + domain=SWITCH_DOMAIN, + platform=DOMAIN, + unique_id=unique_id, + suggested_object_id=f"{device_name}_siren", + config_entry=mock_config_entry, + ) + + create_entry("front", 765432) + create_entry("internal", 345678) + + async def test_entity_registry( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_ring_client, + create_deprecated_siren_entity, ) -> None: """Tests that the devices are registered in the entity registry.""" await setup_platform(hass, Platform.SWITCH) @@ -38,6 +72,7 @@ async def test_states( mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + create_deprecated_siren_entity, ) -> None: """Test states.""" @@ -47,7 +82,7 @@ async def test_states( async def test_siren_off_reports_correctly( - hass: HomeAssistant, mock_ring_client + hass: HomeAssistant, mock_ring_client, create_deprecated_siren_entity ) -> None: """Tests that the initial state of a device that should be off is correct.""" await setup_platform(hass, Platform.SWITCH) @@ -58,7 +93,7 @@ async def test_siren_off_reports_correctly( async def test_siren_on_reports_correctly( - hass: HomeAssistant, mock_ring_client + hass: HomeAssistant, mock_ring_client, create_deprecated_siren_entity ) -> None: """Tests that the initial state of a device that should be on is correct.""" await setup_platform(hass, Platform.SWITCH) @@ -68,20 +103,36 @@ async def test_siren_on_reports_correctly( assert state.attributes.get("friendly_name") == "Internal Siren" -async def test_siren_can_be_turned_on(hass: HomeAssistant, mock_ring_client) -> None: +async def test_siren_can_be_turned_on_and_off( + hass: HomeAssistant, mock_ring_client, create_deprecated_siren_entity +) -> None: """Tests the siren turns on correctly.""" await setup_platform(hass, Platform.SWITCH) state = hass.states.get("switch.front_siren") - assert state.state == "off" + assert state.state == STATE_OFF await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.front_siren"}, blocking=True + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.front_siren"}, + blocking=True, ) await hass.async_block_till_done() state = hass.states.get("switch.front_siren") - assert state.state == "on" + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.front_siren"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("switch.front_siren") + assert state.state == STATE_OFF @pytest.mark.parametrize( @@ -99,6 +150,7 @@ async def test_switch_errors_when_turned_on( mock_ring_devices, exception_type, reauth_expected, + create_deprecated_siren_entity, ) -> None: """Tests the switch turns on correctly.""" await setup_platform(hass, Platform.SWITCH) From 58f66e54f979d26804af1f13208206171d9bcf1e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 13 Sep 2024 16:34:08 +0200 Subject: [PATCH 0621/1309] Improve config flow type hints in wolflink (#125313) --- .../components/wolflink/config_flow.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/wolflink/config_flow.py b/homeassistant/components/wolflink/config_flow.py index df5d7369a86..54c6db4cb07 100644 --- a/homeassistant/components/wolflink/config_flow.py +++ b/homeassistant/components/wolflink/config_flow.py @@ -1,10 +1,10 @@ """Config flow for Wolf SmartSet Service integration.""" import logging -from typing import Any from httpcore import ConnectError import voluptuous as vol +from wolf_comm.models import Device from wolf_comm.token_auth import InvalidAuth from wolf_comm.wolf_client import WolfClient @@ -26,14 +26,15 @@ class WolfLinkConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 2 + fetched_systems: list[Device] + def __init__(self) -> None: """Initialize with empty username and password.""" - self.username = None - self.password = None - self.fetched_systems = None + self.username: str | None = None + self.password: str | None = None async def async_step_user( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, str] | None = None ) -> ConfigFlowResult: """Handle the initial step to get connection parameters.""" errors = {} @@ -58,9 +59,11 @@ class WolfLinkConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=USER_SCHEMA, errors=errors ) - async def async_step_device(self, user_input=None): + async def async_step_device( + self, user_input: dict[str, str] | None = None + ) -> ConfigFlowResult: """Allow user to select device from devices connected to specified account.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: device_name = user_input[DEVICE_NAME] system = [ From a2a049c5cce255aa6a4cf0407ba818517effd2b5 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 13 Sep 2024 10:37:32 -0400 Subject: [PATCH 0622/1309] Bump aiostreammagic to 2.3.0 (#125903) --- homeassistant/components/cambridge_audio/entity.py | 5 ++++- homeassistant/components/cambridge_audio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cambridge_audio/test_media_player.py | 5 ++++- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/cambridge_audio/entity.py b/homeassistant/components/cambridge_audio/entity.py index afdc88f53e0..7292f99f928 100644 --- a/homeassistant/components/cambridge_audio/entity.py +++ b/homeassistant/components/cambridge_audio/entity.py @@ -5,6 +5,7 @@ from functools import wraps from typing import Any, Concatenate from aiostreammagic import StreamMagicClient +from aiostreammagic.models import CallbackType from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -51,7 +52,9 @@ class CambridgeAudioEntity(Entity): ) @callback - async def _state_update_callback(self, _client: StreamMagicClient) -> None: + async def _state_update_callback( + self, _client: StreamMagicClient, _callback_type: CallbackType + ) -> None: """Call when the device is notified of changes.""" self._attr_available = _client.is_connected() self.async_write_ha_state() diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index f8f61cc1890..5e4f58b2fc2 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], - "requirements": ["aiostreammagic==2.2.5"], + "requirements": ["aiostreammagic==2.3.0"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 3ec9f3bccfb..09e034fb98a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -377,7 +377,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.2.5 +aiostreammagic==2.3.0 # homeassistant.components.switcher_kis aioswitcher==4.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b96e805de1b..56ccf7b4f17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -359,7 +359,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.2.5 +aiostreammagic==2.3.0 # homeassistant.components.switcher_kis aioswitcher==4.0.3 diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py index 1f6564a6fab..a713b087d48 100644 --- a/tests/components/cambridge_audio/test_media_player.py +++ b/tests/components/cambridge_audio/test_media_player.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock from aiostreammagic import TransportControl +from aiostreammagic.models import CallbackType import pytest from homeassistant.components.media_player import ( @@ -33,7 +34,9 @@ from tests.common import MockConfigEntry async def mock_state_update(client: AsyncMock) -> None: """Trigger a callback in the media player.""" - await client.register_state_update_callbacks.call_args[0][0](client) + await client.register_state_update_callbacks.call_args[0][0]( + client, CallbackType.STATE + ) async def test_entity_supported_features( From d855f70e3bb4c62ded3b17858499cb5ad37d0097 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 13 Sep 2024 16:44:48 +0200 Subject: [PATCH 0623/1309] Add RestoreEntity to template alarm_control_panel (#125844) --- .../template/alarm_control_panel.py | 33 +++++++-- .../template/test_alarm_control_panel.py | 68 ++++++++++++++++++- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 7c23fdcebcc..e7fe3887ce9 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -4,6 +4,7 @@ from __future__ import annotations from enum import Enum import logging +from typing import Any import voluptuous as vol @@ -29,12 +30,14 @@ from homeassistant.const import ( STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -103,7 +106,9 @@ PLATFORM_SCHEMA = ALARM_CONTROL_PANEL_PLATFORM_SCHEMA.extend( ) -async def _async_create_entities(hass, config): +async def _async_create_entities( + hass: HomeAssistant, config: dict[str, Any] +) -> list[AlarmControlPanelTemplate]: """Create Template Alarm Control Panels.""" alarm_control_panels = [] @@ -133,18 +138,18 @@ async def async_setup_platform( async_add_entities(await _async_create_entities(hass, config)) -class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): +class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, RestoreEntity): """Representation of a templated Alarm Control Panel.""" _attr_should_poll = False def __init__( self, - hass, - object_id, - config, - unique_id, - ): + hass: HomeAssistant, + object_id: str, + config: dict, + unique_id: str | None, + ) -> None: """Initialize the panel.""" super().__init__( hass, config=config, fallback_name=object_id, unique_id=unique_id @@ -153,6 +158,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): ENTITY_ID_FORMAT, object_id, hass=hass ) name = self._attr_name + assert name is not None self._template = config.get(CONF_VALUE_TEMPLATE) self._disarm_script = None self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] @@ -216,6 +222,19 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): ) self._attr_supported_features = supported_features + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + (last_state := await self.async_get_last_state()) is not None + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + and last_state.state in _VALID_STATES + # The trigger might have fired already while we waited for stored data, + # then we should not restore state + and self._state is None + ): + self._state = last_state.state + @property def state(self) -> str | None: """Return the state of the device.""" diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index ea63d7b9926..ac9bb2dcb36 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -17,8 +17,13 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component, mock_restore_cache TEMPLATE_NAME = "alarm_control_panel.test_template_panel" PANEL_NAME = "alarm_control_panel.test" @@ -400,3 +405,64 @@ async def test_code_config( state = hass.states.get(TEMPLATE_NAME) assert state.attributes.get("code_format") == code_format assert state.attributes.get("code_arm_required") == code_arm_required + + +@pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) +@pytest.mark.parametrize( + "config", + [ + { + "alarm_control_panel": { + "platform": "template", + "panels": {"test_template_panel": TEMPLATE_ALARM_CONFIG}, + } + }, + ], +) +@pytest.mark.parametrize( + ("restored_state", "initial_state"), + [ + (STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_AWAY), + (STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_CUSTOM_BYPASS), + (STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_HOME), + (STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_NIGHT), + (STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMED_VACATION), + (STATE_ALARM_ARMING, STATE_ALARM_ARMING), + (STATE_ALARM_DISARMED, STATE_ALARM_DISARMED), + (STATE_ALARM_PENDING, STATE_ALARM_PENDING), + (STATE_ALARM_TRIGGERED, STATE_ALARM_TRIGGERED), + (STATE_UNAVAILABLE, STATE_UNKNOWN), + (STATE_UNKNOWN, STATE_UNKNOWN), + ("faulty_state", STATE_UNKNOWN), + ], +) +async def test_restore_state( + hass: HomeAssistant, + count, + domain, + config, + restored_state, + initial_state, +) -> None: + """Test restoring template alarm control panel.""" + + fake_state = State( + "alarm_control_panel.test_template_panel", + restored_state, + {}, + ) + mock_restore_cache(hass, (fake_state,)) + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + config, + ) + + await hass.async_block_till_done() + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("alarm_control_panel.test_template_panel") + assert state.state == initial_state From d507953c70f0e8065e74c45da2b614862732c604 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 13 Sep 2024 10:57:39 -0400 Subject: [PATCH 0624/1309] Add logs on disconnect/reconnect for Cambridge Audio (#125904) * Bump aiostreammagic to 2.3.0 * Add logging on disconnect/reconnect for Cambridge Audio --- .../components/cambridge_audio/__init__.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cambridge_audio/__init__.py b/homeassistant/components/cambridge_audio/__init__.py index 344045fe550..0b8d02aefad 100644 --- a/homeassistant/components/cambridge_audio/__init__.py +++ b/homeassistant/components/cambridge_audio/__init__.py @@ -3,18 +3,22 @@ from __future__ import annotations import asyncio +import logging from aiostreammagic import StreamMagicClient +from aiostreammagic.models import CallbackType from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from .const import CONNECT_TIMEOUT, STREAM_MAGIC_EXCEPTIONS PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] +_LOGGER = logging.getLogger(__name__) + type CambridgeAudioConfigEntry = ConfigEntry[StreamMagicClient] @@ -25,6 +29,19 @@ async def async_setup_entry( client = StreamMagicClient(entry.data[CONF_HOST]) + @callback + async def _connection_update_callback( + _client: StreamMagicClient, _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) + try: async with asyncio.timeout(CONNECT_TIMEOUT): await client.connect() From 2d9c9707e3268199f3c47058eca21039259d2358 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:09:33 -0400 Subject: [PATCH 0625/1309] Improve integration tests for Cambridge Audio (#125906) --- .../cambridge_audio/media_player.py | 2 +- .../cambridge_audio/test_media_player.py | 105 +++++++++++++++++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index aa6053d349f..c0287b9f8fa 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -281,6 +281,6 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): async def async_set_repeat(self, repeat: RepeatMode) -> None: """Set the repeat mode for the current queue.""" repeat_mode = CambridgeRepeatMode.OFF - if repeat: + if repeat in {RepeatMode.ALL, RepeatMode.ONE}: repeat_mode = CambridgeRepeatMode.ALL await self.client.set_repeat(repeat_mode) diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py index a713b087d48..b344c2faa2b 100644 --- a/tests/components/cambridge_audio/test_media_player.py +++ b/tests/components/cambridge_audio/test_media_player.py @@ -2,13 +2,21 @@ from unittest.mock import AsyncMock -from aiostreammagic import TransportControl +from aiostreammagic import ( + RepeatMode as CambridgeRepeatMode, + ShuffleMode, + TransportControl, +) from aiostreammagic.models import CallbackType import pytest from homeassistant.components.media_player import ( + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEEK_POSITION, + ATTR_MEDIA_SHUFFLE, DOMAIN as MP_DOMAIN, MediaPlayerEntityFeature, + RepeatMode, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -17,9 +25,15 @@ from homeassistant.const import ( SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_MEDIA_SEEK, + SERVICE_REPEAT_SET, + SERVICE_SHUFFLE_SET, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, STATE_BUFFERING, STATE_IDLE, STATE_OFF, + STATE_ON, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, @@ -124,6 +138,7 @@ async def test_entity_supported_features( (True, "connecting", STATE_BUFFERING), (True, "stop", STATE_IDLE), (True, "ready", STATE_IDLE), + (True, "other", STATE_ON), ], ) async def test_entity_state( @@ -194,3 +209,91 @@ async def test_media_next_previous_track( await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data, True) mock_stream_magic_client.previous_track.assert_called_once() + + +async def test_shuffle_repeat( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_stream_magic_client: AsyncMock, +) -> None: + """Test shuffle and repeat service.""" + await setup_integration(hass, mock_config_entry) + + mock_stream_magic_client.now_playing.controls = [ + TransportControl.TOGGLE_SHUFFLE, + TransportControl.TOGGLE_REPEAT, + ] + + # Test shuffle + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_SHUFFLE: False}, + ) + + mock_stream_magic_client.set_shuffle.assert_called_with(ShuffleMode.OFF) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SHUFFLE_SET, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_SHUFFLE: True}, + ) + + mock_stream_magic_client.set_shuffle.assert_called_with(ShuffleMode.ALL) + + # Test repeat + await hass.services.async_call( + MP_DOMAIN, + SERVICE_REPEAT_SET, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_REPEAT: RepeatMode.OFF}, + ) + + mock_stream_magic_client.set_repeat.assert_called_with(CambridgeRepeatMode.OFF) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_REPEAT_SET, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_REPEAT: RepeatMode.ALL}, + ) + + mock_stream_magic_client.set_repeat.assert_called_with(CambridgeRepeatMode.ALL) + + +async def test_power_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_stream_magic_client: AsyncMock, +) -> None: + """Test power service.""" + await setup_integration(hass, mock_config_entry) + + data = {ATTR_ENTITY_ID: ENTITY_ID} + + await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_ON, data, True) + + mock_stream_magic_client.power_on.assert_called_once() + + await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_OFF, data, True) + + mock_stream_magic_client.power_off.assert_called_once() + + +async def test_media_seek( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_stream_magic_client: AsyncMock, +) -> None: + """Test media seek service.""" + await setup_integration(hass, mock_config_entry) + + mock_stream_magic_client.now_playing.controls = [ + TransportControl.SEEK, + ] + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_SEEK, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_SEEK_POSITION: 100}, + ) + + mock_stream_magic_client.media_seek.assert_called_once_with(100) From ba7ca84899bee9e9fec1912a0243b1520cf3b703 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:34:06 -0400 Subject: [PATCH 0626/1309] Remove unused keys from the ZHA config schema (#125710) --- homeassistant/components/zha/helpers.py | 3 +- tests/components/zha/test_helpers.py | 39 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 56e7d481f2c..4ca2f5d172b 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -1163,7 +1163,8 @@ CONF_ZHA_OPTIONS_SCHEMA = vol.Schema( CONF_CONSIDER_UNAVAILABLE_BATTERY, default=CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, ): cv.positive_int, - } + }, + extra=vol.REMOVE_EXTRA, ) CONF_ZHA_ALARM_SCHEMA = vol.Schema( diff --git a/tests/components/zha/test_helpers.py b/tests/components/zha/test_helpers.py index d3392685437..f6dc8291d9f 100644 --- a/tests/components/zha/test_helpers.py +++ b/tests/components/zha/test_helpers.py @@ -5,16 +5,23 @@ from typing import Any import pytest import voluptuous_serialize +from zigpy.application import ControllerApplication from zigpy.types.basic import uint16_t from zigpy.zcl.clusters import lighting +import homeassistant.components.zha.const as zha_const from homeassistant.components.zha.helpers import ( cluster_command_schema_to_vol_schema, convert_to_zcl_values, + create_zha_config, exclude_none_values, + get_zha_data, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry _LOGGER = logging.getLogger(__name__) @@ -177,3 +184,35 @@ def test_exclude_none_values( for key in expected_output: assert expected_output[key] == obj[key] + + +async def test_create_zha_config_remove_unused( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, +) -> None: + """Test creating ZHA config data with unused keys.""" + config_entry.add_to_hass(hass) + + options = config_entry.options.copy() + options["custom_configuration"]["zha_options"]["some_random_key"] = "a value" + + hass.config_entries.async_update_entry(config_entry, options=options) + + assert ( + config_entry.options["custom_configuration"]["zha_options"]["some_random_key"] + == "a value" + ) + + status = await async_setup_component( + hass, + zha_const.DOMAIN, + {zha_const.DOMAIN: {zha_const.CONF_ENABLE_QUIRKS: False}}, + ) + assert status is True + await hass.async_block_till_done() + + ha_zha_data = get_zha_data(hass) + + # Does not error out + create_zha_config(hass, ha_zha_data) From 85aa32338e0fa31dc346d2d497dc48c20decc22b Mon Sep 17 00:00:00 2001 From: Robert Contreras Date: Fri, 13 Sep 2024 10:31:35 -0700 Subject: [PATCH 0627/1309] Add Home Connect sensors for fridge door states and alarms (#125490) * New sensors for Fridge door states and alarms * Move 2 option entities to binary_sensor, tests * Change state translations * Fix stale docstring --- .../components/home_connect/binary_sensor.py | 87 ++++++++++++- .../components/home_connect/const.py | 26 ++++ .../components/home_connect/icons.json | 44 +++++++ .../components/home_connect/sensor.py | 114 +++++++++++++++++- .../components/home_connect/strings.json | 44 +++++++ .../home_connect/fixtures/status.json | 4 + .../home_connect/test_binary_sensor.py | 64 +++++++++- tests/components/home_connect/test_sensor.py | 101 +++++++++++++++- 8 files changed, 478 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 84b02be1cc4..758759c135b 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -1,14 +1,21 @@ """Provides a binary sensor for Home Connect.""" +from dataclasses import dataclass, field import logging -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .api import HomeConnectDevice from .const import ( + ATTR_DEVICE, ATTR_VALUE, BSH_DOOR_STATE, BSH_DOOR_STATE_CLOSED, @@ -17,12 +24,47 @@ from .const import ( BSH_REMOTE_CONTROL_ACTIVATION_STATE, BSH_REMOTE_START_ALLOWANCE_STATE, DOMAIN, + REFRIGERATION_STATUS_DOOR_CHILLER, + REFRIGERATION_STATUS_DOOR_CLOSED, + REFRIGERATION_STATUS_DOOR_FREEZER, + REFRIGERATION_STATUS_DOOR_OPEN, + REFRIGERATION_STATUS_DOOR_REFRIGERATOR, ) from .entity import HomeConnectEntity _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription): + """Entity Description class for binary sensors.""" + + state_key: str | None + device_class: BinarySensorDeviceClass | None = BinarySensorDeviceClass.DOOR + boolean_map: dict[str, bool] = field( + default_factory=lambda: { + REFRIGERATION_STATUS_DOOR_CLOSED: False, + REFRIGERATION_STATUS_DOOR_OPEN: True, + } + ) + + +BINARY_SENSORS: tuple[HomeConnectBinarySensorEntityDescription, ...] = ( + HomeConnectBinarySensorEntityDescription( + key="Chiller Door", + state_key=REFRIGERATION_STATUS_DOOR_CHILLER, + ), + HomeConnectBinarySensorEntityDescription( + key="Freezer Door", + state_key=REFRIGERATION_STATUS_DOOR_FREEZER, + ), + HomeConnectBinarySensorEntityDescription( + key="Refrigerator Door", + state_key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -36,6 +78,15 @@ async def async_setup_entry( for device_dict in hc_api.devices: entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("binary_sensor", []) entities += [HomeConnectBinarySensor(**d) for d in entity_dicts] + device: HomeConnectDevice = device_dict[ATTR_DEVICE] + # Auto-discover entities + entities.extend( + HomeConnectFridgeDoorBinarySensor( + device=device, entity_description=description + ) + for description in BINARY_SENSORS + if description.state_key in device.appliance.status + ) return entities async_add_entities(await hass.async_add_executor_job(get_entities), True) @@ -93,3 +144,37 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): def device_class(self): """Return the device class.""" return self._device_class + + +class HomeConnectFridgeDoorBinarySensor(HomeConnectEntity, BinarySensorEntity): + """Binary sensor for Home Connect Fridge Doors.""" + + entity_description: HomeConnectBinarySensorEntityDescription + + def __init__( + self, + device: HomeConnectDevice, + entity_description: HomeConnectBinarySensorEntityDescription, + ) -> None: + """Initialize the entity.""" + self.entity_description = entity_description + super().__init__(device, entity_description.key) + + async def async_update(self) -> None: + """Update the binary sensor's status.""" + _LOGGER.debug( + "Updating: %s, cur state: %s", + self._attr_unique_id, + self.state, + ) + self._attr_is_on = self.entity_description.boolean_map.get( + self.device.appliance.status.get(self.entity_description.state_key, {}).get( + ATTR_VALUE + ) + ) + self._attr_available = self._attr_is_on is not None + _LOGGER.debug( + "Updated: %s, new state: %s", + self._attr_unique_id, + self.state, + ) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 4c21201c37a..68bad33ec50 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -14,6 +14,9 @@ BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive" BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed" BSH_CHILD_LOCK_STATE = "BSH.Common.Setting.ChildLock" +BSH_EVENT_PRESENT_STATE_PRESENT = "BSH.Common.EnumType.EventPresentState.Present" +BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed" +BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off" BSH_OPERATION_STATE = "BSH.Common.Status.OperationState" BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run" @@ -23,6 +26,11 @@ BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished" COOKING_LIGHTING = "Cooking.Common.Setting.Lighting" COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness" +COFFEE_EVENT_BEAN_CONTAINER_EMPTY = ( + "ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty" +) +COFFEE_EVENT_WATER_TANK_EMPTY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty" +COFFEE_EVENT_DRIP_TRAY_FULL = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull" REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer" REFRIGERATION_SUPERMODEREFRIGERATOR = ( @@ -30,6 +38,24 @@ REFRIGERATION_SUPERMODEREFRIGERATOR = ( ) REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled" +REFRIGERATION_STATUS_DOOR_CHILLER = "Refrigeration.Common.Status.Door.ChillerCommon" +REFRIGERATION_STATUS_DOOR_FREEZER = "Refrigeration.Common.Status.Door.Freezer" +REFRIGERATION_STATUS_DOOR_REFRIGERATOR = "Refrigeration.Common.Status.Door.Refrigerator" + +REFRIGERATION_STATUS_DOOR_CLOSED = "Refrigeration.Common.EnumType.Door.States.Closed" +REFRIGERATION_STATUS_DOOR_OPEN = "Refrigeration.Common.EnumType.Door.States.Open" + +REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR = ( + "Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator" +) +REFRIGERATION_EVENT_DOOR_ALARM_FREEZER = ( + "Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer" +) +REFRIGERATION_EVENT_TEMP_ALARM_FREEZER = ( + "Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer" +) + + BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled" BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness" BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor" diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 163c03b297c..949b30919b5 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -23,6 +23,50 @@ } }, "entity": { + "sensor": { + "alarm_sensor_fridge": { + "default": "mdi:fridge", + "state": { + "confirmed": "mdi:fridge-alert-outline", + "present": "mdi:fridge-alert" + } + }, + "alarm_sensor_freezer": { + "default": "mdi:snowflake", + "state": { + "confirmed": "mdi:snowflake-check", + "present": "mdi:snowflake-alert" + } + }, + "alarm_sensor_temp": { + "default": "mdi:thermometer", + "state": { + "confirmed": "mdi:thermometer-check", + "present": "mdi:thermometer-alert" + } + }, + "alarm_sensor_coffee_bean_container": { + "default": "mdi:coffee-maker", + "state": { + "confirmed": "mdi:coffee-maker-check", + "present": "mdi:coffee-maker-outline" + } + }, + "alarm_sensor_coffee_water_tank": { + "default": "mdi:water", + "state": { + "confirmed": "mdi:water-check", + "present": "mdi:water-alert" + } + }, + "alarm_sensor_coffee_drip_tray": { + "default": "mdi:tray", + "state": { + "confirmed": "mdi:tray-full", + "present": "mdi:tray-alert" + } + } + }, "switch": { "refrigeration_dispenser": { "default": "mdi:snowflake", diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 9bd48617fb3..c91864c2680 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,29 +1,95 @@ """Provides a sensor for Home Connect.""" +from dataclasses import dataclass, field from datetime import datetime, timedelta import logging from typing import cast -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util +from .api import ConfigEntryAuth, HomeConnectDevice from .const import ( + ATTR_DEVICE, ATTR_VALUE, + BSH_EVENT_PRESENT_STATE_OFF, BSH_OPERATION_STATE, BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_RUN, + COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + COFFEE_EVENT_DRIP_TRAY_FULL, + COFFEE_EVENT_WATER_TANK_EMPTY, DOMAIN, + REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, + REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, ) from .entity import HomeConnectEntity _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class HomeConnectSensorEntityDescription(SensorEntityDescription): + """Entity Description class for sensors.""" + + device_class: SensorDeviceClass | None = SensorDeviceClass.ENUM + options: list[str] | None = field( + default_factory=lambda: ["confirmed", "off", "present"] + ) + state_key: str + appliance_types: tuple[str, ...] + + +SENSORS: tuple[HomeConnectSensorEntityDescription, ...] = ( + HomeConnectSensorEntityDescription( + key="Door Alarm Freezer", + translation_key="alarm_sensor_freezer", + state_key=REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + appliance_types=("FridgeFreezer", "Freezer"), + ), + HomeConnectSensorEntityDescription( + key="Door Alarm Refrigerator", + translation_key="alarm_sensor_fridge", + state_key=REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR, + appliance_types=("FridgeFreezer", "Refrigerator"), + ), + HomeConnectSensorEntityDescription( + key="Temperature Alarm Freezer", + translation_key="alarm_sensor_temp", + state_key=REFRIGERATION_EVENT_TEMP_ALARM_FREEZER, + appliance_types=("FridgeFreezer", "Freezer"), + ), + HomeConnectSensorEntityDescription( + key="Bean Container Empty", + translation_key="alarm_sensor_coffee_bean_container", + state_key=COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key="Water Tank Empty", + translation_key="alarm_sensor_coffee_water_tank", + state_key=COFFEE_EVENT_WATER_TANK_EMPTY, + appliance_types=("CoffeeMaker",), + ), + HomeConnectSensorEntityDescription( + key="Drip Tray Full", + translation_key="alarm_sensor_coffee_drip_tray", + state_key=COFFEE_EVENT_DRIP_TRAY_FULL, + appliance_types=("CoffeeMaker",), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -34,10 +100,20 @@ async def async_setup_entry( def get_entities(): """Get a list of entities.""" entities = [] - hc_api = hass.data[DOMAIN][config_entry.entry_id] + hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("sensor", []) entities += [HomeConnectSensor(**d) for d in entity_dicts] + device: HomeConnectDevice = device_dict[ATTR_DEVICE] + # Auto-discover entities + entities.extend( + HomeConnectAlarmSensor( + device, + entity_description=description, + ) + for description in SENSORS + if device.appliance.type in description.appliance_types + ) return entities async_add_entities(await hass.async_add_executor_job(get_entities), True) @@ -101,3 +177,37 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): -1 ] _LOGGER.debug("Updated, new state: %s", self._attr_native_value) + + +class HomeConnectAlarmSensor(HomeConnectEntity, SensorEntity): + """Sensor entity setup using SensorEntityDescription.""" + + entity_description: HomeConnectSensorEntityDescription + + def __init__( + self, + device: HomeConnectDevice, + entity_description: HomeConnectSensorEntityDescription, + ) -> None: + """Initialize the entity.""" + self.entity_description = entity_description + super().__init__(device, self.entity_description.key) + + @property + def available(self) -> bool: + """Return true if the sensor is available.""" + return self._attr_native_value is not None + + async def async_update(self) -> None: + """Update the sensor's status.""" + self._attr_native_value = ( + self.device.appliance.status.get(self.entity_description.state_key, {}) + .get(ATTR_VALUE, BSH_EVENT_PRESENT_STATE_OFF) + .rsplit(".", maxsplit=1)[-1] + .lower() + ) + _LOGGER.debug( + "Updated: %s, new state: %s", + self._attr_unique_id, + self._attr_native_value, + ) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8afd3aaf8ce..1fcd95e9cb2 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1,4 +1,8 @@ { + "common": { + "confirmed": "Confirmed", + "present": "Present" + }, "config": { "step": { "pick_implementation": { @@ -129,5 +133,45 @@ "value": { "name": "Value", "description": "Value of the setting." } } } + }, + "entity": { + "sensor": { + "alarm_sensor_fridge": { + "state": { + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "alarm_sensor_freezer": { + "state": { + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "alarm_sensor_temp": { + "state": { + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "alarm_sensor_coffee_bean_container": { + "state": { + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "alarm_sensor_coffee_water_tank": { + "state": { + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + }, + "alarm_sensor_coffee_drip_tray": { + "state": { + "confirmed": "[%key:component::home_connect::common::confirmed%]", + "present": "[%key:component::home_connect::common::present%]" + } + } + } } } diff --git a/tests/components/home_connect/fixtures/status.json b/tests/components/home_connect/fixtures/status.json index 8eac586a308..efdbde6cd97 100644 --- a/tests/components/home_connect/fixtures/status.json +++ b/tests/components/home_connect/fixtures/status.json @@ -10,6 +10,10 @@ { "key": "BSH.Common.Status.DoorState", "value": "BSH.Common.EnumType.DoorState.Closed" + }, + { + "key": "Refrigeration.Common.Status.Door.Refrigerator", + "value": "BSH.Common.EnumType.DoorState.Open" } ] } diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 39502507439..de4263f6345 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, Mock +from homeconnect.api import HomeConnectAPI import pytest from homeassistant.components.home_connect.const import ( @@ -10,13 +11,16 @@ from homeassistant.components.home_connect.const import ( BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED, BSH_DOOR_STATE_OPEN, + REFRIGERATION_STATUS_DOOR_CLOSED, + REFRIGERATION_STATUS_DOOR_OPEN, + REFRIGERATION_STATUS_DOOR_REFRIGERATOR, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture @pytest.fixture @@ -70,3 +74,59 @@ async def test_binary_sensors_door_states( await async_update_entity(hass, entity_id) await hass.async_block_till_done() assert hass.states.is_state(entity_id, expected) + + +@pytest.mark.parametrize( + ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + [ + ( + "binary_sensor.fridgefreezer_refrigerator_door", + REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + REFRIGERATION_STATUS_DOOR_CLOSED, + STATE_OFF, + "FridgeFreezer", + ), + ( + "binary_sensor.fridgefreezer_refrigerator_door", + REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + REFRIGERATION_STATUS_DOOR_OPEN, + STATE_ON, + "FridgeFreezer", + ), + ( + "binary_sensor.fridgefreezer_refrigerator_door", + REFRIGERATION_STATUS_DOOR_REFRIGERATOR, + "", + STATE_UNAVAILABLE, + "FridgeFreezer", + ), + ], + indirect=["appliance"], +) +@pytest.mark.usefixtures("bypass_throttle") +async def test_bianry_sensors_fridge_door_states( + entity_id: str, + status_key: str, + event_value_update: str, + appliance: Mock, + expected: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, +) -> None: + """Tests for Home Connect Fridge appliance door states.""" + appliance.status.update( + HomeConnectAPI.json2dict( + load_json_object_fixture("home_connect/status.json")["data"]["status"] + ) + ) + get_appliances.return_value = [appliance] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + appliance.status.update({status_key: {"value": event_value_update}}) + await async_update_entity(hass, entity_id) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected) diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index 661ac62403f..f0565c178fe 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -4,14 +4,22 @@ from collections.abc import Awaitable, Callable from unittest.mock import MagicMock, Mock from freezegun.api import FrozenDateTimeFactory +from homeconnect.api import HomeConnectAPI import pytest +from homeassistant.components.home_connect.const import ( + BSH_EVENT_PRESENT_STATE_CONFIRMED, + BSH_EVENT_PRESENT_STATE_OFF, + BSH_EVENT_PRESENT_STATE_PRESENT, + COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture TEST_HC_APP = "Dishwasher" @@ -207,3 +215,94 @@ async def test_remaining_prog_time_edge_cases( await hass.async_block_till_done() freezer.tick() assert hass.states.is_state(entity_id, expected_state) + + +@pytest.mark.parametrize( + ("entity_id", "status_key", "event_value_update", "expected", "appliance"), + [ + ( + "sensor.fridgefreezer_door_alarm_freezer", + "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", + "", + "off", + "FridgeFreezer", + ), + ( + "sensor.fridgefreezer_door_alarm_freezer", + REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + BSH_EVENT_PRESENT_STATE_OFF, + "off", + "FridgeFreezer", + ), + ( + "sensor.fridgefreezer_door_alarm_freezer", + REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + BSH_EVENT_PRESENT_STATE_PRESENT, + "present", + "FridgeFreezer", + ), + ( + "sensor.fridgefreezer_door_alarm_freezer", + REFRIGERATION_EVENT_DOOR_ALARM_FREEZER, + BSH_EVENT_PRESENT_STATE_CONFIRMED, + "confirmed", + "FridgeFreezer", + ), + ( + "sensor.coffeemaker_bean_container_empty", + "EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF", + "", + "off", + "CoffeeMaker", + ), + ( + "sensor.coffeemaker_bean_container_empty", + COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + BSH_EVENT_PRESENT_STATE_OFF, + "off", + "CoffeeMaker", + ), + ( + "sensor.coffeemaker_bean_container_empty", + COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + BSH_EVENT_PRESENT_STATE_PRESENT, + "present", + "CoffeeMaker", + ), + ( + "sensor.coffeemaker_bean_container_empty", + COFFEE_EVENT_BEAN_CONTAINER_EMPTY, + BSH_EVENT_PRESENT_STATE_CONFIRMED, + "confirmed", + "CoffeeMaker", + ), + ], + indirect=["appliance"], +) +@pytest.mark.usefixtures("bypass_throttle") +async def test_sensors_states( + entity_id: str, + status_key: str, + event_value_update: str, + appliance: Mock, + expected: str, + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], + setup_credentials: None, + get_appliances: MagicMock, +) -> None: + """Tests for Appliance alarm sensors.""" + appliance.status.update( + HomeConnectAPI.json2dict( + load_json_object_fixture("home_connect/status.json")["data"]["status"] + ) + ) + get_appliances.return_value = [appliance] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + appliance.status.update({status_key: {"value": event_value_update}}) + await async_update_entity(hass, entity_id) + await hass.async_block_till_done() + assert hass.states.is_state(entity_id, expected) From 50a46933f62c2495cdc5e781ee13aa872c98036b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 13 Sep 2024 14:15:58 -0400 Subject: [PATCH 0628/1309] Bump ZHA to 0.0.33 (#125914) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index df60829a1e2..7046642160c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.32"], + "requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.33"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 09e034fb98a..3f726ac95c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3041,7 +3041,7 @@ zeroconf==0.134.0 zeversolar==0.3.1 # homeassistant.components.zha -zha==0.0.32 +zha==0.0.33 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 56ccf7b4f17..282a6db6417 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2421,7 +2421,7 @@ zeroconf==0.134.0 zeversolar==0.3.1 # homeassistant.components.zha -zha==0.0.32 +zha==0.0.33 # homeassistant.components.zwave_js zwave-js-server-python==0.58.0 From 94916ebbd184fe77e7efa6915a5ab28000047fb9 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 13 Sep 2024 15:45:05 -0400 Subject: [PATCH 0629/1309] Add diagnostics platform to Cambridge Audio (#125910) * Add diagnostics platform to Cambridge Audio * Remove exclusions from Cambridge diagnostics * Remove function call from snapshot Co-authored-by: Jan-Philipp Benecke --------- Co-authored-by: Jan-Philipp Benecke --- .../components/cambridge_audio/diagnostics.py | 21 ++++++++ .../snapshots/test_diagnostics.ambr | 51 +++++++++++++++++++ .../cambridge_audio/test_diagnostics.py | 29 +++++++++++ 3 files changed, 101 insertions(+) create mode 100644 homeassistant/components/cambridge_audio/diagnostics.py create mode 100644 tests/components/cambridge_audio/snapshots/test_diagnostics.ambr create mode 100644 tests/components/cambridge_audio/test_diagnostics.py diff --git a/homeassistant/components/cambridge_audio/diagnostics.py b/homeassistant/components/cambridge_audio/diagnostics.py new file mode 100644 index 00000000000..b4295e7c885 --- /dev/null +++ b/homeassistant/components/cambridge_audio/diagnostics.py @@ -0,0 +1,21 @@ +"""Diagnostics platform for Cambridge Audio.""" + +from typing import Any + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.redact import async_redact_data + +from . import CambridgeAudioConfigEntry + +TO_REDACT = {CONF_HOST} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: CambridgeAudioConfigEntry +) -> dict[str, Any]: + """Return diagnostics for the provided config entry.""" + client = entry.runtime_data + return async_redact_data( + {"info": client.info, "sources": client.sources}, TO_REDACT + ) diff --git a/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr b/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c554785006e --- /dev/null +++ b/tests/components/cambridge_audio/snapshots/test_diagnostics.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'info': dict({ + '__type': "", + 'repr': "Info(name='Cambridge Audio CXNv2', model='CXNv2', timezone='America/Chicago', locale='en_GB', udn='02680b5c-1320-4d54-9f7c-3cfe915ad4c3', unit_id='0020c2d8', api_version='1.8')", + }), + 'sources': list([ + dict({ + '__type': "", + 'repr': "Source(id='IR', name='Internet Radio', default_name='Internet Radio', nameable=False, ui_selectable=False, description='Internet Radio', description_locale='Internet Radio', preferred_order=9)", + }), + dict({ + '__type': "", + 'repr': "Source(id='USB_AUDIO', name='USB Audio', default_name='USB Audio', nameable=True, ui_selectable=True, description='USB Audio', description_locale='USB Audio', preferred_order=1)", + }), + dict({ + '__type': "", + 'repr': "Source(id='SPDIF_COAX', name='D2', default_name='D2', nameable=True, ui_selectable=False, description='Digital Co-axial', description_locale='Digital Co-axial', preferred_order=3)", + }), + dict({ + '__type': "", + 'repr': "Source(id='SPDIF_TOSLINK', name='D1', default_name='D1', nameable=True, ui_selectable=False, description='Digital Optical', description_locale='Digital Optical', preferred_order=2)", + }), + dict({ + '__type': "", + 'repr': "Source(id='MEDIA_PLAYER', name='Media Library', default_name='Media Library', nameable=False, ui_selectable=True, description='Media Player', description_locale='Media Player', preferred_order=10)", + }), + dict({ + '__type': "", + 'repr': "Source(id='AIRPLAY', name='AirPlay', default_name='AirPlay', nameable=False, ui_selectable=True, description='AirPlay', description_locale='AirPlay', preferred_order=11)", + }), + dict({ + '__type': "", + 'repr': "Source(id='SPOTIFY', name='Spotify', default_name='Spotify', nameable=False, ui_selectable=True, description='Spotify', description_locale='Spotify', preferred_order=6)", + }), + dict({ + '__type': "", + 'repr': "Source(id='CAST', name='Chromecast built-in', default_name='Chromecast built-in', nameable=False, ui_selectable=True, description='Chromecast built-in', description_locale='Chromecast built-in', preferred_order=8)", + }), + dict({ + '__type': "", + 'repr': "Source(id='ROON', name='Roon Ready', default_name='Roon Ready', nameable=False, ui_selectable=False, description='Roon Ready', description_locale='Roon Ready', preferred_order=5)", + }), + dict({ + '__type': "", + 'repr': "Source(id='TIDAL', name='TIDAL Connect', default_name='TIDAL Connect', nameable=False, ui_selectable=False, description='TIDAL', description_locale='TIDAL', preferred_order=7)", + }), + ]), + }) +# --- diff --git a/tests/components/cambridge_audio/test_diagnostics.py b/tests/components/cambridge_audio/test_diagnostics.py new file mode 100644 index 00000000000..9c1a09c6318 --- /dev/null +++ b/tests/components/cambridge_audio/test_diagnostics.py @@ -0,0 +1,29 @@ +"""Tests for the diagnostics data provided by the Cambridge Audio 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_entry_diagnostics( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_stream_magic_client: AsyncMock, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert result == snapshot From cabaf37437ea5d07bc751769762a267e1293b58a Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 13 Sep 2024 15:05:11 -0500 Subject: [PATCH 0630/1309] Bump aioesphomeapi and adjust handle_stop (#125907) * Bump aioesphomeapi and adjust handle_stop * Stop audio stream too * Update homeassistant/components/esphome/assist_satellite.py Co-authored-by: Paulus Schoutsen --------- Co-authored-by: Paulus Schoutsen --- .../components/esphome/assist_satellite.py | 16 +++- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 10 +-- .../esphome/test_assist_satellite.py | 81 ++++++++++++++++++- 6 files changed, 100 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 6370e91b9d1..370c3b9c8fd 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -350,9 +350,12 @@ class EsphomeAssistSatellite( """Handle incoming audio chunk from API.""" self._audio_queue.put_nowait(data) - async def handle_pipeline_stop(self) -> None: + async def handle_pipeline_stop(self, abort: bool) -> None: """Handle request for pipeline to stop.""" - self._stop_pipeline() + if abort: + self._abort_pipeline() + else: + self._stop_pipeline() def handle_pipeline_finished(self) -> None: """Handle when pipeline has finished running.""" @@ -466,10 +469,17 @@ class EsphomeAssistSatellite( yield chunk def _stop_pipeline(self) -> None: - """Request pipeline to be stopped.""" + """Request pipeline to be stopped by ending the audio stream and continue processing.""" self._audio_queue.put_nowait(None) _LOGGER.debug("Requested pipeline stop") + def _abort_pipeline(self) -> None: + """Request pipeline to be aborted (no further processing).""" + _LOGGER.debug("Requested pipeline abort") + self._audio_queue.put_nowait(None) + if self._pipeline_task is not None: + self._pipeline_task.cancel() + async def _start_udp_server(self) -> int: """Start a UDP server on a random free port.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index f18d6e7cc68..dbf51aafae4 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==25.4.0", + "aioesphomeapi==26.0.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.0.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 3f726ac95c6..22dff112deb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -240,7 +240,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==25.4.0 +aioesphomeapi==26.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 282a6db6417..f476d4d4817 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==25.4.0 +aioesphomeapi==26.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index af68df89360..a95d28359d2 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -205,7 +205,7 @@ class MockESPHomeDevice: Coroutine[Any, Any, int | None], ] self.voice_assistant_handle_stop_callback: Callable[ - [], Coroutine[Any, Any, None] + [bool], Coroutine[Any, Any, None] ] self.voice_assistant_handle_audio_callback: ( Callable[ @@ -287,7 +287,7 @@ class MockESPHomeDevice: [str, int, VoiceAssistantAudioSettings, str | None], Coroutine[Any, Any, int | None], ], - handle_stop: Callable[[], Coroutine[Any, Any, None]], + handle_stop: Callable[[bool], Coroutine[Any, Any, None]], handle_audio: ( Callable[ [bytes], @@ -313,9 +313,9 @@ class MockESPHomeDevice: conversation_id, flags, settings, wake_word_phrase ) - async def mock_voice_assistant_handle_stop(self) -> None: + async def mock_voice_assistant_handle_stop(self, abort: bool) -> None: """Mock voice assistant handle stop.""" - await self.voice_assistant_handle_stop_callback() + await self.voice_assistant_handle_stop_callback(abort) async def mock_voice_assistant_handle_audio(self, audio: bytes) -> None: """Mock voice assistant handle audio.""" @@ -394,7 +394,7 @@ async def _mock_generic_device_entry( [str, int, VoiceAssistantAudioSettings, str | None], Coroutine[Any, Any, int | None], ], - handle_stop: Callable[[], Coroutine[Any, Any, None]], + handle_stop: Callable[[bool], Coroutine[Any, Any, None]], handle_audio: ( Callable[ [bytes], diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 89840daf454..2e6727d88bb 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -368,7 +368,7 @@ async def test_pipeline_api_audio( ) mock_tts_streaming_task.cancel.assert_called_once() await satellite.handle_audio(b"test-mic") - await satellite.handle_pipeline_stop() + await satellite.handle_pipeline_stop(abort=False) await pipeline_finished.wait() await tts_finished.wait() @@ -563,7 +563,7 @@ async def test_pipeline_udp_audio( # Wait for audio chunk to be delivered await mic_audio_event.wait() - await satellite.handle_pipeline_stop() + await satellite.handle_pipeline_stop(abort=False) await pipeline_finished.wait() await tts_finished.wait() @@ -1073,3 +1073,80 @@ async def test_satellite_unloaded_on_disconnect( state = hass.states.get(satellite.entity_id) assert state is not None assert state.state == STATE_UNAVAILABLE + + +async def test_pipeline_abort( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test aborting a pipeline (no further processing).""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.API_AUDIO + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + chunks = [] + chunk_received = asyncio.Event() + pipeline_aborted = asyncio.Event() + + async def async_pipeline_from_audio_stream(*args, **kwargs): + stt_stream = kwargs["stt_stream"] + + try: + async for chunk in stt_stream: + chunks.append(chunk) + chunk_received.set() + except asyncio.CancelledError: + # Aborting cancels the pipeline task + pipeline_aborted.set() + raise + + pipeline_finished = asyncio.Event() + original_handle_pipeline_finished = satellite.handle_pipeline_finished + + def handle_pipeline_finished(): + original_handle_pipeline_finished() + pipeline_finished.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), + ): + async with asyncio.timeout(1): + await satellite.handle_pipeline_start( + conversation_id="", + flags=VoiceAssistantCommandFlag(0), # stt + audio_settings=VoiceAssistantAudioSettings(), + wake_word_phrase="", + ) + + await satellite.handle_audio(b"before-abort") + await chunk_received.wait() + + # Abort the pipeline, no further processing + await satellite.handle_pipeline_stop(abort=True) + await pipeline_aborted.wait() + + # This chunk should not make it into the STT stream + await satellite.handle_audio(b"after-abort") + await pipeline_finished.wait() + + # Only first chunk + assert chunks == [b"before-abort"] From 2080b9a87c5e963ecf6f60a637b1b8f56150b09b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 13 Sep 2024 22:12:16 +0200 Subject: [PATCH 0631/1309] Add config flow to template alarm_control_panel (#125861) * Add config flow to template alarm_control_panel * Remove commented code * Test import --- .../template/alarm_control_panel.py | 44 ++++++++++++++++++ .../components/template/config_flow.py | 45 ++++++++++++++++++ .../components/template/strings.json | 46 +++++++++++++++++++ .../snapshots/test_alarm_control_panel.ambr | 18 ++++++++ .../template/test_alarm_control_panel.py | 39 +++++++++++++++- tests/components/template/test_config_flow.py | 32 +++++++++++++ 6 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 tests/components/template/snapshots/test_alarm_control_panel.ambr diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index e7fe3887ce9..0d9e5ebc8ce 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -15,8 +15,10 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, CodeFormat, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, + CONF_DEVICE_ID, CONF_NAME, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -34,12 +36,14 @@ from homeassistant.const import ( ) 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.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import slugify from .const import DOMAIN from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf @@ -105,6 +109,25 @@ PLATFORM_SCHEMA = ALARM_CONTROL_PANEL_PLATFORM_SCHEMA.extend( } ) +ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( + TemplateCodeFormat + ), + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + } +) + async def _async_create_entities( hass: HomeAssistant, config: dict[str, Any] @@ -128,6 +151,27 @@ async def _async_create_entities( return alarm_control_panels +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + _options = dict(config_entry.options) + _options.pop("template_type") + validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options) + async_add_entities( + [ + AlarmControlPanelTemplate( + hass, + slugify(_options[CONF_NAME]), + validated_config, + config_entry.entry_id, + ) + ] + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index a8a7c1b9971..c1c023c0ea4 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -39,6 +39,18 @@ from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowMenuStep, ) +from .alarm_control_panel import ( + CONF_ARM_AWAY_ACTION, + CONF_ARM_CUSTOM_BYPASS_ACTION, + CONF_ARM_HOME_ACTION, + CONF_ARM_NIGHT_ACTION, + CONF_ARM_VACATION_ACTION, + CONF_CODE_ARM_REQUIRED, + CONF_CODE_FORMAT, + CONF_DISARM_ACTION, + CONF_TRIGGER_ACTION, + TemplateCodeFormat, +) from .binary_sensor import async_create_preview_binary_sensor from .const import CONF_PRESS, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .number import ( @@ -68,6 +80,30 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema: if flow_type == "config": schema = {vol.Required(CONF_NAME): selector.TextSelector()} + if domain == Platform.ALARM_CONTROL_PANEL: + schema |= { + vol.Optional(CONF_VALUE_TEMPLATE): selector.TemplateSelector(), + vol.Optional(CONF_DISARM_ACTION): selector.ActionSelector(), + vol.Optional(CONF_ARM_AWAY_ACTION): selector.ActionSelector(), + vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): selector.ActionSelector(), + vol.Optional(CONF_ARM_HOME_ACTION): selector.ActionSelector(), + vol.Optional(CONF_ARM_NIGHT_ACTION): selector.ActionSelector(), + vol.Optional(CONF_ARM_VACATION_ACTION): selector.ActionSelector(), + vol.Optional(CONF_TRIGGER_ACTION): selector.ActionSelector(), + vol.Optional( + CONF_CODE_ARM_REQUIRED, default=True + ): selector.BooleanSelector(), + vol.Optional( + CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[e.name for e in TemplateCodeFormat], + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="alarm_control_panel_code_format", + ) + ), + } + if domain == Platform.BINARY_SENSOR: schema |= _SCHEMA_STATE if flow_type == "config": @@ -265,6 +301,7 @@ def validate_user_input( TEMPLATE_TYPES = [ + "alarm_control_panel", "binary_sensor", "button", "image", @@ -276,6 +313,10 @@ TEMPLATE_TYPES = [ CONFIG_FLOW = { "user": SchemaFlowMenuStep(TEMPLATE_TYPES), + Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep( + config_schema(Platform.ALARM_CONTROL_PANEL), + validate_user_input=validate_user_input(Platform.ALARM_CONTROL_PANEL), + ), Platform.BINARY_SENSOR: SchemaFlowFormStep( config_schema(Platform.BINARY_SENSOR), preview="template", @@ -313,6 +354,10 @@ CONFIG_FLOW = { OPTIONS_FLOW = { "init": SchemaFlowFormStep(next_step=choose_options_step), + Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep( + options_schema(Platform.ALARM_CONTROL_PANEL), + validate_user_input=validate_user_input(Platform.ALARM_CONTROL_PANEL), + ), Platform.BINARY_SENSOR: SchemaFlowFormStep( options_schema(Platform.BINARY_SENSOR), preview="template", diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 4a79ee62d30..26a6ba61704 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -1,6 +1,26 @@ { "config": { "step": { + "alarm_control_panel": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "value_template": "[%key:component::template::config::step::switch::data::value_template%]", + "name": "[%key:common::config_flow::data::name%]", + "disarm": "Disarm action", + "arm_away": "Arm away action", + "arm_custom_bypass": "Arm custom bypass action", + "arm_home": "Arm home action", + "arm_night": "Arm night action", + "arm_vacation": "Arm vacation action", + "trigger": "Trigger action", + "code_arm_required": "Code arm required", + "code_format": "Code format" + }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, + "title": "Template alarm control panel" + }, "binary_sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -111,6 +131,25 @@ }, "options": { "step": { + "alarm_control_panel": { + "data": { + "device_id": "[%key:common::config_flow::data::device%]", + "value_template": "[%key:component::template::config::step::switch::data::value_template%]", + "disarm": "[%key:component::template::config::step::alarm_control_panel::data::disarm%]", + "arm_away": "[%key:component::template::config::step::alarm_control_panel::data::arm_away%]", + "arm_custom_bypass": "[%key:component::template::config::step::alarm_control_panel::data::arm_custom_bypass%]", + "arm_home": "[%key:component::template::config::step::alarm_control_panel::data::arm_home%]", + "arm_night": "[%key:component::template::config::step::alarm_control_panel::data::arm_night%]", + "arm_vacation": "[%key:component::template::config::step::alarm_control_panel::data::arm_vacation%]", + "trigger": "[%key:component::template::config::step::alarm_control_panel::data::trigger%]", + "code_arm_required": "[%key:component::template::config::step::alarm_control_panel::data::code_arm_required%]", + "code_format": "[%key:component::template::config::step::alarm_control_panel::data::code_format%]" + }, + "data_description": { + "device_id": "[%key:component::template::config::step::sensor::data_description::device_id%]" + }, + "title": "[%key:component::template::config::step::alarm_control_panel::title%]" + }, "binary_sensor": { "data": { "device_id": "[%key:common::config_flow::data::device%]", @@ -200,6 +239,13 @@ } }, "selector": { + "alarm_control_panel_code_format": { + "options": { + "no_code": "No code format", + "number": "Number", + "text": "Text" + } + }, "binary_sensor_device_class": { "options": { "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", diff --git a/tests/components/template/snapshots/test_alarm_control_panel.ambr b/tests/components/template/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..9772c31220e --- /dev/null +++ b/tests/components/template/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_setup_config_entry + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': True, + 'code_format': , + 'friendly_name': 'My template', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.my_template', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'armed_away', + }) +# --- diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index ac9bb2dcb36..1532197d738 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -1,7 +1,9 @@ """The tests for the Template alarm control panel platform.""" import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components import template from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN from homeassistant.const import ( ATTR_DOMAIN, @@ -23,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.setup import async_setup_component -from tests.common import assert_setup_component, mock_restore_cache +from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache TEMPLATE_NAME = "alarm_control_panel.test_template_panel" PANEL_NAME = "alarm_control_panel.test" @@ -130,6 +132,41 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: assert state.state == "unknown" +async def test_setup_config_entry( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test the config flow.""" + value_template = "{{ states('alarm_control_panel.one') }}" + + hass.states.async_set("alarm_control_panel.one", "armed_away", {}) + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "value_template": value_template, + "template_type": "alarm_control_panel", + "code_arm_required": True, + "code_format": "number", + }, + 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() + + state = hass.states.get("alarm_control_panel.my_template") + assert state is not None + assert state == snapshot + + hass.states.async_set("alarm_control_panel.one", "disarmed", {}) + await hass.async_block_till_done() + state = hass.states.get("alarm_control_panel.my_template") + assert state.state == STATE_ALARM_DISARMED + + @pytest.mark.parametrize(("count", "domain"), [(1, "alarm_control_panel")]) @pytest.mark.parametrize( "config", diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index 380a0a8f53e..713e27e653f 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -29,6 +29,16 @@ from tests.typing import WebSocketGenerator "extra_attrs", ), [ + ( + "alarm_control_panel", + {"value_template": "{{ states('alarm_control_panel.one') }}"}, + "armed_away", + {"one": "armed_away", "two": "disarmed"}, + {}, + {}, + {"code_arm_required": True, "code_format": "number"}, + {}, + ), ( "binary_sensor", { @@ -270,6 +280,12 @@ async def test_config_flow( "step": 0.1, }, ), + ( + "alarm_control_panel", + {"value_template": "{{ states('alarm_control_panel.one') }}"}, + {"code_arm_required": True, "code_format": "number"}, + {"code_arm_required": True, "code_format": "number"}, + ), ( "select", {"state": "{{ states('select.one') }}"}, @@ -476,6 +492,16 @@ def get_suggested(schema, key): }, "state", ), + ( + "alarm_control_panel", + {"value_template": "{{ states('alarm_control_panel.one') }}"}, + {"value_template": "{{ states('alarm_control_panel.two') }}"}, + ["armed_away", "disarmed"], + {"one": "armed_away", "two": "disarmed"}, + {"code_arm_required": True, "code_format": "number"}, + {"code_arm_required": True, "code_format": "number"}, + "value_template", + ), ( "select", {"state": "{{ states('select.one') }}"}, @@ -1244,6 +1270,12 @@ async def test_option_flow_sensor_preview_config_entry_removed( "step": 0.1, }, ), + ( + "alarm_control_panel", + {"value_template": "{{ states('alarm_control_panel.one') }}"}, + {"code_arm_required": True, "code_format": "number"}, + {"code_arm_required": True, "code_format": "number"}, + ), ( "select", {"state": "{{ states('select.one') }}"}, From 970d28bce98546d28ad2dd58f14327bc1d5bd639 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 13 Sep 2024 22:19:45 +0200 Subject: [PATCH 0632/1309] Remove own defined SOURCE_USER from sensoterra tests (#125919) --- tests/components/sensoterra/const.py | 1 - tests/components/sensoterra/test_config_flow.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/sensoterra/const.py b/tests/components/sensoterra/const.py index c85d675f9d7..cc80610645d 100644 --- a/tests/components/sensoterra/const.py +++ b/tests/components/sensoterra/const.py @@ -4,4 +4,3 @@ API_TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE4NTYzMDQwMDAsInN1Yi API_EMAIL = "test-email@example.com" API_PASSWORD = "test-password" HASS_UUID = "phony-unique-id" -SOURCE_USER = "user" diff --git a/tests/components/sensoterra/test_config_flow.py b/tests/components/sensoterra/test_config_flow.py index 23c57261741..20921406883 100644 --- a/tests/components/sensoterra/test_config_flow.py +++ b/tests/components/sensoterra/test_config_flow.py @@ -7,11 +7,12 @@ import pytest from sensoterra.customerapi import InvalidAuth as StInvalidAuth, Timeout as StTimeout from homeassistant.components.sensoterra.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import API_EMAIL, API_PASSWORD, API_TOKEN, HASS_UUID, SOURCE_USER +from .const import API_EMAIL, API_PASSWORD, API_TOKEN, HASS_UUID from tests.common import MockConfigEntry From 3eed5de36785abc2deb011e4300d0de70508f798 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 13 Sep 2024 15:31:38 -0500 Subject: [PATCH 0633/1309] Handle announcement finished for ESPHome TTS response (#125625) * Handle announcement finished for TTS response * Adjust test --- .../components/esphome/assist_satellite.py | 13 ++ tests/components/esphome/conftest.py | 34 +++- .../esphome/test_assist_satellite.py | 159 ++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 370c3b9c8fd..08dd2ac0774 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -14,6 +14,7 @@ import wave from aioesphomeapi import ( MediaPlayerFormatPurpose, + VoiceAssistantAnnounceFinished, VoiceAssistantAudioSettings, VoiceAssistantCommandFlag, VoiceAssistantEventType, @@ -166,6 +167,7 @@ class EsphomeAssistSatellite( handle_start=self.handle_pipeline_start, handle_stop=self.handle_pipeline_stop, handle_audio=self.handle_audio, + handle_announcement_finished=self.handle_announcement_finished, ) ) else: @@ -174,6 +176,7 @@ class EsphomeAssistSatellite( self.cli.subscribe_voice_assistant( handle_start=self.handle_pipeline_start, handle_stop=self.handle_pipeline_stop, + handle_announcement_finished=self.handle_announcement_finished, ) ) @@ -194,6 +197,10 @@ class EsphomeAssistSatellite( assist_satellite.AssistSatelliteEntityFeature.ANNOUNCE ) + if not (feature_flags & VoiceAssistantFeature.SPEAKER): + # Will use media player for TTS/announcements + self._update_tts_format() + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() @@ -382,6 +389,12 @@ class EsphomeAssistSatellite( timer_info.is_active, ) + async def handle_announcement_finished( + self, announce_finished: VoiceAssistantAnnounceFinished + ) -> None: + """Handle announcement finished message (also sent for TTS).""" + self.tts_response_finished() + def _update_tts_format(self) -> None: """Update the TTS format from the first media player.""" for supported_format in chain(*self.entry_data.media_player_formats.values()): diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index a95d28359d2..2b7c127efd3 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -19,6 +19,7 @@ from aioesphomeapi import ( HomeassistantServiceCall, ReconnectLogic, UserService, + VoiceAssistantAnnounceFinished, VoiceAssistantAudioSettings, VoiceAssistantFeature, ) @@ -214,6 +215,13 @@ class MockESPHomeDevice: ] | None ) + self.voice_assistant_handle_announcement_finished_callback: ( + Callable[ + [VoiceAssistantAnnounceFinished], + Coroutine[Any, Any, None], + ] + | None + ) self.device_info = device_info def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: @@ -295,11 +303,21 @@ class MockESPHomeDevice: ] | None ) = None, + handle_announcement_finished: ( + Callable[ + [VoiceAssistantAnnounceFinished], + Coroutine[Any, Any, None], + ] + | None + ) = None, ) -> None: """Set the voice assistant subscription callbacks.""" self.voice_assistant_handle_start_callback = handle_start self.voice_assistant_handle_stop_callback = handle_stop self.voice_assistant_handle_audio_callback = handle_audio + self.voice_assistant_handle_announcement_finished_callback = ( + handle_announcement_finished + ) async def mock_voice_assistant_handle_start( self, @@ -322,6 +340,13 @@ class MockESPHomeDevice: assert self.voice_assistant_handle_audio_callback is not None await self.voice_assistant_handle_audio_callback(audio) + async def mock_voice_assistant_handle_announcement_finished( + self, finished: VoiceAssistantAnnounceFinished + ) -> None: + """Mock voice assistant handle announcement finished.""" + assert self.voice_assistant_handle_announcement_finished_callback is not None + await self.voice_assistant_handle_announcement_finished_callback(finished) + async def _mock_generic_device_entry( hass: HomeAssistant, @@ -402,10 +427,17 @@ async def _mock_generic_device_entry( ] | None ) = None, + handle_announcement_finished: ( + Callable[ + [VoiceAssistantAnnounceFinished], + Coroutine[Any, Any, None], + ] + | None + ) = None, ) -> Callable[[], None]: """Subscribe to voice assistant.""" mock_device.set_subscribe_voice_assistant_callbacks( - handle_start, handle_stop, handle_audio + handle_start, handle_stop, handle_audio, handle_announcement_finished ) def unsub(): diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 2e6727d88bb..eb4f9802219 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -15,6 +15,7 @@ from aioesphomeapi import ( MediaPlayerInfo, MediaPlayerSupportedFormat, UserService, + VoiceAssistantAnnounceFinished, VoiceAssistantAudioSettings, VoiceAssistantCommandFlag, VoiceAssistantEventType, @@ -603,6 +604,160 @@ async def test_udp_errors() -> None: protocol.transport.sendto.assert_not_called() +async def test_pipeline_media_player( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + mock_wav: bytes, +) -> None: + """Test a complete pipeline run with the TTS response sent to a media player instead of a speaker. + + This test is not as comprehensive as test_pipeline_api_audio since we're + mainly focused on tts_response_finished getting automatically called. + """ + conversation_id = "test-conversation-id" + media_url = "http://test.url" + media_id = "test-media-id" + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + | VoiceAssistantFeature.API_AUDIO + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + async def async_pipeline_from_audio_stream(*args, device_id, **kwargs): + stt_stream = kwargs["stt_stream"] + + async for _chunk in stt_stream: + break + + event_callback = kwargs["event_callback"] + + # STT + event_callback( + PipelineEvent( + type=PipelineEventType.STT_START, + data={"engine": "test-stt-engine", "metadata": {}}, + ) + ) + + event_callback( + PipelineEvent( + type=PipelineEventType.STT_END, + data={"stt_output": {"text": "test-stt-text"}}, + ) + ) + + # Intent + event_callback( + PipelineEvent( + type=PipelineEventType.INTENT_START, + data={ + "engine": "test-intent-engine", + "language": hass.config.language, + "intent_input": "test-intent-text", + "conversation_id": conversation_id, + "device_id": device_id, + }, + ) + ) + + event_callback( + PipelineEvent( + type=PipelineEventType.INTENT_END, + data={"intent_output": {"conversation_id": conversation_id}}, + ) + ) + + # TTS + event_callback( + PipelineEvent( + type=PipelineEventType.TTS_START, + data={ + "engine": "test-stt-engine", + "language": hass.config.language, + "voice": "test-voice", + "tts_input": "test-tts-text", + }, + ) + ) + + # Should return mock_wav audio + event_callback( + PipelineEvent( + type=PipelineEventType.TTS_END, + data={"tts_output": {"url": media_url, "media_id": media_id}}, + ) + ) + + event_callback(PipelineEvent(type=PipelineEventType.RUN_END)) + + pipeline_finished = asyncio.Event() + original_handle_pipeline_finished = satellite.handle_pipeline_finished + + def handle_pipeline_finished(): + original_handle_pipeline_finished() + pipeline_finished.set() + + async def async_get_media_source_audio( + hass: HomeAssistant, + media_source_id: str, + ) -> tuple[str, bytes]: + return ("wav", mock_wav) + + tts_finished = asyncio.Event() + original_tts_response_finished = satellite.tts_response_finished + + def tts_response_finished(): + original_tts_response_finished() + tts_finished.set() + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), + patch( + "homeassistant.components.tts.async_get_media_source_audio", + new=async_get_media_source_audio, + ), + patch.object(satellite, "handle_pipeline_finished", handle_pipeline_finished), + patch.object(satellite, "tts_response_finished", tts_response_finished), + ): + async with asyncio.timeout(1): + await satellite.handle_pipeline_start( + conversation_id=conversation_id, + flags=VoiceAssistantCommandFlag(0), # stt + audio_settings=VoiceAssistantAudioSettings(), + wake_word_phrase="", + ) + + await satellite.handle_pipeline_stop(abort=False) + await pipeline_finished.wait() + + assert satellite.state == AssistSatelliteState.RESPONDING + + # Will trigger tts_response_finished + await mock_device.mock_voice_assistant_handle_announcement_finished( + VoiceAssistantAnnounceFinished(success=True) + ) + await tts_finished.wait() + + assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD + + async def test_timer_events( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -952,6 +1107,7 @@ async def test_announce_message( async def send_voice_assistant_announcement_await_response( media_id: str, timeout: float, text: str ): + assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "https://www.home-assistant.io/resolved.mp3" assert text == "test-text" @@ -983,6 +1139,7 @@ async def test_announce_message( blocking=True, ) await done.wait() + assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD async def test_announce_media_id( @@ -1016,6 +1173,7 @@ async def test_announce_media_id( async def send_voice_assistant_announcement_await_response( media_id: str, timeout: float, text: str ): + assert satellite.state == AssistSatelliteState.RESPONDING assert media_id == "https://www.home-assistant.io/resolved.mp3" done.set() @@ -1038,6 +1196,7 @@ async def test_announce_media_id( blocking=True, ) await done.wait() + assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD async def test_satellite_unloaded_on_disconnect( From 6d212ea24e1a4aa24a55355a993290d38843e2e3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 14 Sep 2024 03:31:44 +0200 Subject: [PATCH 0634/1309] Add helper functions for repair tests (#125886) * Expose repairs constants and function for other components * Reorder * Use helper methods * Adjust core_files * Improve * Update test_migrate.py --- .core_files.yaml | 2 + tests/components/doorbird/test_repairs.py | 24 ++-- tests/components/ecobee/test_repairs.py | 25 ++-- .../components/homeassistant/test_repairs.py | 57 +++------ tests/components/knx/test_repairs.py | 20 +-- tests/components/notify/test_repairs.py | 25 ++-- tests/components/repairs/__init__.py | 32 +++++ .../components/seventeentrack/test_repairs.py | 15 +-- tests/components/tibber/test_repairs.py | 18 +-- tests/components/unifiprotect/test_migrate.py | 4 +- tests/components/unifiprotect/test_repairs.py | 97 ++++----------- tests/components/workday/test_repairs.py | 114 ++++-------------- tests/components/zwave_js/test_repairs.py | 77 ++++-------- 13 files changed, 150 insertions(+), 360 deletions(-) diff --git a/.core_files.yaml b/.core_files.yaml index e852a567601..27bf77b84ae 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -126,9 +126,11 @@ tests: &tests - tests/*.py - tests/auth/** - tests/backports/** + - tests/components/diagnostics/** - tests/components/history/** - tests/components/logbook/** - tests/components/recorder/** + - tests/components/repairs/** - tests/components/sensor/** - tests/hassfest/** - tests/helpers/** diff --git a/tests/components/doorbird/test_repairs.py b/tests/components/doorbird/test_repairs.py index 7449250b718..34e6de7516e 100644 --- a/tests/components/doorbird/test_repairs.py +++ b/tests/components/doorbird/test_repairs.py @@ -2,16 +2,7 @@ from __future__ import annotations -from http import HTTPStatus - from homeassistant.components.doorbird.const import DOMAIN -from homeassistant.components.repairs.issue_handler import ( - async_process_repairs_platforms, -) -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -20,6 +11,11 @@ from homeassistant.setup import async_setup_component from . import mock_not_found_exception from .conftest import DoorbirdMockerType +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) from tests.typing import ClientSessionGenerator @@ -43,19 +39,13 @@ async def test_change_schedule_fails( await async_process_repairs_platforms(hass) client = await hass_client() - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) flow_id = data["flow_id"] placeholders = data["description_placeholders"] assert "404" in placeholders["error"] assert data["step_id"] == "confirm" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) assert data["type"] == "create_entry" diff --git a/tests/components/ecobee/test_repairs.py b/tests/components/ecobee/test_repairs.py index 43b3cc5b7d0..b00c49e7d91 100644 --- a/tests/components/ecobee/test_repairs.py +++ b/tests/components/ecobee/test_repairs.py @@ -1,22 +1,19 @@ """Test repairs for Ecobee integration.""" -from http import HTTPStatus from unittest.mock import MagicMock from homeassistant.components.ecobee import DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.components.repairs.issue_handler import ( - async_process_repairs_platforms, -) -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from .common import setup_platform +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) from tests.typing import ClientSessionGenerator THERMOSTAT_ID = 0 @@ -53,20 +50,14 @@ async def test_ecobee_notify_repair_flow( ) assert len(issue_registry.issues) == 1 - url = RepairsFlowIndexView.url - resp = await http_client.post( - url, json={"handler": "notify", "issue_id": f"migrate_notify_{DOMAIN}_{DOMAIN}"} + data = await start_repair_fix_flow( + http_client, "notify", f"migrate_notify_{DOMAIN}_{DOMAIN}" ) - assert resp.status == HTTPStatus.OK - data = await resp.json() flow_id = data["flow_id"] assert data["step_id"] == "confirm" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await http_client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(http_client, flow_id) assert data["type"] == "create_entry" # Test confirm step in repair flow await hass.async_block_till_done() diff --git a/tests/components/homeassistant/test_repairs.py b/tests/components/homeassistant/test_repairs.py index c7a1b3e762e..f81eaa694fa 100644 --- a/tests/components/homeassistant/test_repairs.py +++ b/tests/components/homeassistant/test_repairs.py @@ -1,19 +1,15 @@ """Test the Homeassistant repairs module.""" -from http import HTTPStatus - from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN -from homeassistant.components.repairs.issue_handler import ( - async_process_repairs_platforms, -) -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -48,32 +44,20 @@ async def test_integration_not_found_confirm_step( assert issue["issue_id"] == issue_id assert issue["translation_placeholders"] == {"domain": "test1"} - url = RepairsFlowIndexView.url - resp = await http_client.post( - url, json={"handler": HOMEASSISTANT_DOMAIN, "issue_id": issue_id} - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(http_client, HOMEASSISTANT_DOMAIN, issue_id) flow_id = data["flow_id"] assert data["step_id"] == "init" assert data["description_placeholders"] == {"domain": "test1"} - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - - # Show menu - resp = await http_client.post(url) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(http_client, flow_id) assert data["type"] == "menu" # Apply fix - resp = await http_client.post(url, json={"next_step_id": "confirm"}) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow( + http_client, flow_id, json={"next_step_id": "confirm"} + ) assert data["type"] == "create_entry" @@ -118,32 +102,21 @@ async def test_integration_not_found_ignore_step( assert issue["issue_id"] == issue_id assert issue["translation_placeholders"] == {"domain": "test1"} - url = RepairsFlowIndexView.url - resp = await http_client.post( - url, json={"handler": HOMEASSISTANT_DOMAIN, "issue_id": issue_id} - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(http_client, HOMEASSISTANT_DOMAIN, issue_id) flow_id = data["flow_id"] assert data["step_id"] == "init" assert data["description_placeholders"] == {"domain": "test1"} - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - # Show menu - resp = await http_client.post(url) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(http_client, flow_id) assert data["type"] == "menu" # Apply fix - resp = await http_client.post(url, json={"next_step_id": "ignore"}) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow( + http_client, flow_id, json={"next_step_id": "ignore"} + ) assert data["type"] == "abort" assert data["reason"] == "issue_ignored" diff --git a/tests/components/knx/test_repairs.py b/tests/components/knx/test_repairs.py index 690d6e450cb..b801f70324f 100644 --- a/tests/components/knx/test_repairs.py +++ b/tests/components/knx/test_repairs.py @@ -1,20 +1,15 @@ """Test repairs for KNX integration.""" -from http import HTTPStatus - from homeassistant.components.knx.const import DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import NotifySchema from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.issue_registry as ir from .conftest import KNXTestKit +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow from tests.typing import ClientSessionGenerator @@ -59,21 +54,14 @@ async def test_knx_notify_service_issue( ) # Test confirm step in repair flow - resp = await http_client.post( - RepairsFlowIndexView.url, - json={"handler": "notify", "issue_id": f"migrate_notify_{DOMAIN}_notify"}, + data = await start_repair_fix_flow( + http_client, "notify", f"migrate_notify_{DOMAIN}_notify" ) - assert resp.status == HTTPStatus.OK - data = await resp.json() flow_id = data["flow_id"] assert data["step_id"] == "confirm" - resp = await http_client.post( - RepairsFlowResourceView.url.format(flow_id=flow_id), - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(http_client, flow_id) assert data["type"] == "create_entry" # Assert the issue is no longer present diff --git a/tests/components/notify/test_repairs.py b/tests/components/notify/test_repairs.py index fef5818e1e6..e77da5cea6f 100644 --- a/tests/components/notify/test_repairs.py +++ b/tests/components/notify/test_repairs.py @@ -1,6 +1,5 @@ """Test repairs for notify entity component.""" -from http import HTTPStatus from unittest.mock import AsyncMock import pytest @@ -9,18 +8,16 @@ from homeassistant.components.notify import ( DOMAIN as NOTIFY_DOMAIN, migrate_notify_issue, ) -from homeassistant.components.repairs.issue_handler import ( - async_process_repairs_platforms, -) -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) from tests.typing import ClientSessionGenerator THERMOSTAT_ID = 0 @@ -66,20 +63,12 @@ async def test_notify_migration_repair_flow( ) assert len(issue_registry.issues) == 1 - url = RepairsFlowIndexView.url - resp = await http_client.post( - url, json={"handler": NOTIFY_DOMAIN, "issue_id": translation_key} - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(http_client, NOTIFY_DOMAIN, translation_key) flow_id = data["flow_id"] assert data["step_id"] == "confirm" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await http_client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(http_client, flow_id) assert data["type"] == "create_entry" # Test confirm step in repair flow await hass.async_block_till_done() diff --git a/tests/components/repairs/__init__.py b/tests/components/repairs/__init__.py index a6786db9685..e787d657e5c 100644 --- a/tests/components/repairs/__init__.py +++ b/tests/components/repairs/__init__.py @@ -1,5 +1,17 @@ """Tests for the repairs integration.""" +from http import HTTPStatus +from typing import Any + +from aiohttp.test_utils import TestClient + +from homeassistant.components.repairs.issue_handler import ( # noqa: F401 + async_process_repairs_platforms, +) +from homeassistant.components.repairs.websocket_api import ( + RepairsFlowIndexView, + RepairsFlowResourceView, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,3 +39,23 @@ async def get_repairs( assert msg["result"] return msg["result"]["issues"] + + +async def start_repair_fix_flow( + client: TestClient, handler: str, issue_id: int +) -> dict[str, Any]: + """Start a flow from an issue.""" + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": handler, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + return await resp.json() + + +async def process_repair_fix_flow( + client: TestClient, flow_id: int, json: dict[str, Any] | None = None +) -> dict[str, Any]: + """Return the repairs list of issues.""" + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url, json=json) + assert resp.status == HTTPStatus.OK + return await resp.json() diff --git a/tests/components/seventeentrack/test_repairs.py b/tests/components/seventeentrack/test_repairs.py index 0f697c1ad49..44d1f078432 100644 --- a/tests/components/seventeentrack/test_repairs.py +++ b/tests/components/seventeentrack/test_repairs.py @@ -1,12 +1,10 @@ """Tests for the seventeentrack repair flow.""" -from http import HTTPStatus from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN -from homeassistant.components.repairs.websocket_api import RepairsFlowIndexView from homeassistant.components.seventeentrack import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -16,6 +14,7 @@ from . import goto_future, init_integration from .conftest import DEFAULT_SUMMARY_LENGTH, get_package from tests.common import MockConfigEntry +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow from tests.typing import ClientSessionGenerator @@ -49,13 +48,7 @@ async def test_repair( client = await hass_client() - resp = await client.post( - RepairsFlowIndexView.url, - json={"handler": DOMAIN, "issue_id": repair_issue.issue_id}, - ) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, repair_issue.issue_id) flow_id = data["flow_id"] assert data == { @@ -70,9 +63,7 @@ async def test_repair( "preview": None, } - resp = await client.post(RepairsFlowIndexView.url + f"/{flow_id}") - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) flow_id = data["flow_id"] assert data == { diff --git a/tests/components/tibber/test_repairs.py b/tests/components/tibber/test_repairs.py index 89e85e5f8e1..5e5fde4569e 100644 --- a/tests/components/tibber/test_repairs.py +++ b/tests/components/tibber/test_repairs.py @@ -1,16 +1,12 @@ """Test loading of the Tibber config entry.""" -from http import HTTPStatus from unittest.mock import MagicMock from homeassistant.components.recorder import Recorder -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow from tests.typing import ClientSessionGenerator @@ -40,21 +36,15 @@ async def test_repair_flow( ) assert len(issue_registry.issues) == 1 - url = RepairsFlowIndexView.url - resp = await http_client.post( - url, json={"handler": "notify", "issue_id": f"migrate_notify_tibber_{service}"} + data = await start_repair_fix_flow( + http_client, "notify", f"migrate_notify_tibber_{service}" ) - assert resp.status == HTTPStatus.OK - data = await resp.json() flow_id = data["flow_id"] assert data["step_id"] == "confirm" # Simulate the users confirmed the repair flow - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await http_client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(http_client, flow_id) assert data["type"] == "create_entry" await hass.async_block_till_done() diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index 4e1bf8bd418..4bfc29a142b 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -7,9 +7,6 @@ from unittest.mock import patch from uiprotect.data import Camera from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN -from homeassistant.components.repairs.issue_handler import ( - async_process_repairs_platforms, -) from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, Platform @@ -19,6 +16,7 @@ from homeassistant.setup import async_setup_component from .utils import MockUFPFixture, init_entry +from tests.components.repairs import async_process_repairs_platforms from tests.typing import WebSocketGenerator diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index bdfcd6ff475..adb9555e6ea 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -3,24 +3,21 @@ from __future__ import annotations from copy import copy, deepcopy -from http import HTTPStatus from unittest.mock import AsyncMock, Mock from uiprotect.data import Camera, CloudAccount, ModelType, Version -from homeassistant.components.repairs.issue_handler import ( - async_process_repairs_platforms, -) -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import HomeAssistant from .utils import MockUFPFixture, init_entry +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -52,12 +49,7 @@ async def test_ea_warning_ignore( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post( - url, json={"handler": DOMAIN, "issue_id": "ea_channel_warning"} - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, "ea_channel_warning") flow_id = data["flow_id"] assert data["description_placeholders"] == { @@ -66,10 +58,7 @@ async def test_ea_warning_ignore( } assert data["step_id"] == "start" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) flow_id = data["flow_id"] assert data["description_placeholders"] == { @@ -78,10 +67,7 @@ async def test_ea_warning_ignore( } assert data["step_id"] == "confirm" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) assert data["type"] == "create_entry" @@ -114,12 +100,7 @@ async def test_ea_warning_fix( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post( - url, json={"handler": DOMAIN, "issue_id": "ea_channel_warning"} - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, "ea_channel_warning") flow_id = data["flow_id"] assert data["description_placeholders"] == { @@ -139,10 +120,7 @@ async def test_ea_warning_fix( ufp.ws_msg(mock_msg) await hass.async_block_till_done() - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) assert data["type"] == "create_entry" @@ -176,18 +154,12 @@ async def test_cloud_user_fix( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "cloud_user"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, "cloud_user") flow_id = data["flow_id"] assert data["step_id"] == "confirm" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) assert data["type"] == "create_entry" await hass.async_block_till_done() @@ -228,26 +200,17 @@ async def test_rtsp_read_only_ignore( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) flow_id = data["flow_id"] assert data["step_id"] == "start" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) flow_id = data["flow_id"] assert data["step_id"] == "confirm" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) assert data["type"] == "create_entry" @@ -287,18 +250,12 @@ async def test_rtsp_read_only_fix( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) flow_id = data["flow_id"] assert data["step_id"] == "start" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) assert data["type"] == "create_entry" @@ -337,18 +294,12 @@ async def test_rtsp_writable_fix( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) flow_id = data["flow_id"] assert data["step_id"] == "start" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) assert data["type"] == "create_entry" @@ -398,18 +349,12 @@ async def test_rtsp_writable_fix_when_not_setup( await hass.config_entries.async_unload(ufp.entry.entry_id) await hass.async_block_till_done() - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) flow_id = data["flow_id"] assert data["step_id"] == "start" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) assert data["type"] == "create_entry" diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index 60a55e1a347..e25d4e0ca45 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -2,12 +2,6 @@ from __future__ import annotations -from http import HTTPStatus - -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) from homeassistant.components.workday.const import CONF_REMOVE_HOLIDAYS, DOMAIN from homeassistant.const import CONF_COUNTRY from homeassistant.core import HomeAssistant @@ -23,6 +17,7 @@ from . import ( ) from tests.common import ANY +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -52,24 +47,15 @@ async def test_bad_country( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, "bad_country") flow_id = data["flow_id"] assert data["description_placeholders"] == {"title": entry.title} assert data["step_id"] == "country" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={"country": "DE"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id, json={"country": "DE"}) - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={"province": "HB"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id, json={"province": "HB"}) assert data["type"] == "create_entry" await hass.async_block_till_done() @@ -114,24 +100,15 @@ async def test_bad_country_none( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, "bad_country") flow_id = data["flow_id"] assert data["description_placeholders"] == {"title": entry.title} assert data["step_id"] == "country" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={"country": "DE"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id, json={"country": "DE"}) - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id, json={}) assert data["type"] == "create_entry" await hass.async_block_till_done() @@ -176,19 +153,13 @@ async def test_bad_country_no_province( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_country"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, "bad_country") flow_id = data["flow_id"] assert data["description_placeholders"] == {"title": entry.title} assert data["step_id"] == "country" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={"country": "SE"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id, json={"country": "SE"}) assert data["type"] == "create_entry" await hass.async_block_till_done() @@ -233,10 +204,7 @@ async def test_bad_province( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_province"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, "bad_province") flow_id = data["flow_id"] assert data["description_placeholders"] == { @@ -245,10 +213,7 @@ async def test_bad_province( } assert data["step_id"] == "province" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={"province": "BW"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id, json={"province": "BW"}) assert data["type"] == "create_entry" await hass.async_block_till_done() @@ -293,10 +258,7 @@ async def test_bad_province_none( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "bad_province"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, "bad_province") flow_id = data["flow_id"] assert data["description_placeholders"] == { @@ -305,10 +267,7 @@ async def test_bad_province_none( } assert data["step_id"] == "province" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id, json={}) assert data["type"] == "create_entry" await hass.async_block_till_done() @@ -359,13 +318,9 @@ async def test_bad_named_holiday( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post( - url, - json={"handler": DOMAIN, "issue_id": "bad_named_holiday-1-not_a_holiday"}, + data = await start_repair_fix_flow( + client, DOMAIN, "bad_named_holiday-1-not_a_holiday" ) - assert resp.status == HTTPStatus.OK - data = await resp.json() flow_id = data["flow_id"] assert data["description_placeholders"] == { @@ -375,23 +330,17 @@ async def test_bad_named_holiday( } assert data["step_id"] == "fix_remove_holiday" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post( - url, json={"remove_holidays": ["Christmas", "Not exist 2"]} + data = await process_repair_fix_flow( + client, flow_id, json={"remove_holidays": ["Christmas", "Not exist 2"]} ) - assert resp.status == HTTPStatus.OK - data = await resp.json() assert data["errors"] == { CONF_REMOVE_HOLIDAYS: "remove_holiday_error", } - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post( - url, json={"remove_holidays": ["Christmas", "Thanksgiving"]} + data = await process_repair_fix_flow( + client, flow_id, json={"remove_holidays": ["Christmas", "Thanksgiving"]} ) - assert resp.status == HTTPStatus.OK - data = await resp.json() assert data["type"] == "create_entry" await hass.async_block_till_done() @@ -442,13 +391,7 @@ async def test_bad_date_holiday( issue = i assert issue is not None - url = RepairsFlowIndexView.url - resp = await client.post( - url, - json={"handler": DOMAIN, "issue_id": "bad_date_holiday-1-2024_02_05"}, - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, "bad_date_holiday-1-2024_02_05") flow_id = data["flow_id"] assert data["description_placeholders"] == { @@ -458,10 +401,9 @@ async def test_bad_date_holiday( } assert data["step_id"] == "fix_remove_holiday" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={"remove_holidays": ["2024-02-06"]}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow( + client, flow_id, json={"remove_holidays": ["2024-02-06"]} + ) assert data["type"] == "create_entry" await hass.async_block_till_done() @@ -543,18 +485,12 @@ async def test_other_fixable_issues( "ignored": False, } in results - url = RepairsFlowIndexView.url - resp = await client.post(url, json={"handler": DOMAIN, "issue_id": "issue_1"}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(client, DOMAIN, "issue_1") flow_id = data["flow_id"] assert data["step_id"] == "confirm" - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(client, flow_id) assert data["type"] == "create_entry" await hass.async_block_till_done() diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index c103a06c5fa..2f10b70b48a 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -1,25 +1,22 @@ """Test the Z-Wave JS repairs module.""" from copy import deepcopy -from http import HTTPStatus from unittest.mock import patch from zwave_js_server.event import Event from zwave_js_server.model.node import Node -from homeassistant.components.repairs.issue_handler import ( - async_process_repairs_platforms, -) -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id from homeassistant.core import HomeAssistant import homeassistant.helpers.device_registry as dr import homeassistant.helpers.issue_registry as ir +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -84,30 +81,21 @@ async def test_device_config_file_changed_confirm_step( assert issue["issue_id"] == issue_id assert issue["translation_placeholders"] == {"device_name": device.name} - url = RepairsFlowIndexView.url - resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(http_client, DOMAIN, issue_id) flow_id = data["flow_id"] assert data["step_id"] == "init" assert data["description_placeholders"] == {"device_name": device.name} - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - # Show menu - resp = await http_client.post(url) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(http_client, flow_id) assert data["type"] == "menu" # Apply fix - resp = await http_client.post(url, json={"next_step_id": "confirm"}) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow( + http_client, flow_id, json={"next_step_id": "confirm"} + ) assert data["type"] == "create_entry" @@ -159,30 +147,21 @@ async def test_device_config_file_changed_ignore_step( assert issue["issue_id"] == issue_id assert issue["translation_placeholders"] == {"device_name": device.name} - url = RepairsFlowIndexView.url - resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(http_client, DOMAIN, issue_id) flow_id = data["flow_id"] assert data["step_id"] == "init" assert data["description_placeholders"] == {"device_name": device.name} - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - # Show menu - resp = await http_client.post(url) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(http_client, flow_id) assert data["type"] == "menu" # Ignore the issue - resp = await http_client.post(url, json={"next_step_id": "ignore"}) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow( + http_client, flow_id, json={"next_step_id": "ignore"} + ) assert data["type"] == "abort" assert data["reason"] == "issue_ignored" @@ -228,22 +207,13 @@ async def test_invalid_issue( issue = msg["result"]["issues"][0] assert issue["issue_id"] == "invalid_issue_id" - url = RepairsFlowIndexView.url - resp = await http_client.post( - url, json={"handler": DOMAIN, "issue_id": "invalid_issue_id"} - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(http_client, DOMAIN, "invalid_issue_id") flow_id = data["flow_id"] assert data["step_id"] == "confirm" # Apply fix - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await http_client.post(url) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow(http_client, flow_id) assert data["type"] == "create_entry" @@ -278,10 +248,7 @@ async def test_abort_confirm( await hass_ws_client(hass) http_client = await hass_client() - url = RepairsFlowIndexView.url - resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await start_repair_fix_flow(http_client, DOMAIN, issue_id) flow_id = data["flow_id"] assert data["step_id"] == "init" @@ -290,11 +257,9 @@ async def test_abort_confirm( await hass.config_entries.async_unload(integration.entry_id) # Apply fix - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await http_client.post(url, json={"next_step_id": "confirm"}) - - assert resp.status == HTTPStatus.OK - data = await resp.json() + data = await process_repair_fix_flow( + http_client, flow_id, json={"next_step_id": "confirm"} + ) assert data["type"] == "abort" assert data["reason"] == "cannot_connect" From 1b913b8088dbefce21e74c26ca880a4e2ac6c7a1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Sep 2024 23:21:31 -0400 Subject: [PATCH 0635/1309] Fix Assist Satellite making up conversation IDs (#125933) --- .../components/assist_satellite/entity.py | 15 ++++--- .../assist_satellite/test_entity.py | 42 ++++++++++++------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 5da182ed9df..c00cb26cb63 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -28,7 +28,6 @@ from homeassistant.components.tts import ( from homeassistant.core import Context, callback from homeassistant.helpers import entity from homeassistant.helpers.entity import EntityDescription -from homeassistant.util import ulid from .const import AssistSatelliteEntityFeature from .errors import AssistSatelliteError, SatelliteBusyError @@ -240,16 +239,11 @@ class AssistSatelliteEntity(entity.Entity): assert self._context is not None # Reset conversation id if necessary - if (self._conversation_id_time is None) or ( + if self._conversation_id_time and ( (time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC ): self._conversation_id = None - - if self._conversation_id is None: - self._conversation_id = ulid.ulid() - - # Update timeout - self._conversation_id_time = time.monotonic() + self._conversation_id_time = None # Set entity state based on pipeline events self._run_has_tts = False @@ -311,6 +305,11 @@ class AssistSatelliteEntity(entity.Entity): self._set_state(AssistSatelliteState.LISTENING_COMMAND) elif event.type is PipelineEventType.INTENT_START: self._set_state(AssistSatelliteState.PROCESSING) + elif event.type is PipelineEventType.INTENT_END: + assert event.data is not None + # Update timeout + self._conversation_id_time = time.monotonic() + self._conversation_id = event.data["intent_output"]["conversation_id"] elif event.type is PipelineEventType.TTS_START: # Wait until tts_response_finished is called to return to waiting state self._run_has_tts = True diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 3e58239f921..2af3af89681 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -69,22 +69,34 @@ async def test_entity_state( assert kwargs["start_stage"] == PipelineStage.STT assert kwargs["end_stage"] == PipelineStage.TTS - for event_type, expected_state in ( - (PipelineEventType.RUN_START, AssistSatelliteState.LISTENING_WAKE_WORD), - (PipelineEventType.RUN_END, AssistSatelliteState.LISTENING_WAKE_WORD), - (PipelineEventType.WAKE_WORD_START, AssistSatelliteState.LISTENING_WAKE_WORD), - (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.INTENT_START, AssistSatelliteState.PROCESSING), - (PipelineEventType.INTENT_END, AssistSatelliteState.PROCESSING), - (PipelineEventType.TTS_START, AssistSatelliteState.RESPONDING), - (PipelineEventType.TTS_END, AssistSatelliteState.RESPONDING), - (PipelineEventType.ERROR, AssistSatelliteState.RESPONDING), + for event_type, event_data, expected_state in ( + (PipelineEventType.RUN_START, {}, AssistSatelliteState.LISTENING_WAKE_WORD), + (PipelineEventType.RUN_END, {}, AssistSatelliteState.LISTENING_WAKE_WORD), + ( + PipelineEventType.WAKE_WORD_START, + {}, + AssistSatelliteState.LISTENING_WAKE_WORD, + ), + (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.INTENT_START, {}, AssistSatelliteState.PROCESSING), + ( + PipelineEventType.INTENT_END, + { + "intent_output": { + "conversation_id": "mock-conversation-id", + } + }, + AssistSatelliteState.PROCESSING, + ), + (PipelineEventType.TTS_START, {}, AssistSatelliteState.RESPONDING), + (PipelineEventType.TTS_END, {}, AssistSatelliteState.RESPONDING), + (PipelineEventType.ERROR, {}, AssistSatelliteState.RESPONDING), ): - kwargs["event_callback"](PipelineEvent(event_type, {})) + kwargs["event_callback"](PipelineEvent(event_type, event_data)) state = hass.states.get(ENTITY_ID) assert state.state == expected_state, event_type From 904c82be47ff6a216dbb15d91fba243128ba84c9 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Sat, 14 Sep 2024 08:05:47 +0200 Subject: [PATCH 0636/1309] Bump Weheat to 2024.09.10 (#125936) Weheat version bump to 2024.09.10 --- homeassistant/components/weheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 2dfceacb635..73f388fb01a 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2024.09.05"] + "requirements": ["weheat==2024.09.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 22dff112deb..3a6c82ce9b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2953,7 +2953,7 @@ weatherflow4py==0.3.4 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.09.05 +weheat==2024.09.10 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f476d4d4817..0a4a353b527 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2345,7 +2345,7 @@ weatherflow4py==0.3.4 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.09.05 +weheat==2024.09.10 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 From d121e4c9b5514ad2b6486d8c5782cbd1f6fc8e0e Mon Sep 17 00:00:00 2001 From: TimL Date: Sat, 14 Sep 2024 16:09:23 +1000 Subject: [PATCH 0637/1309] Bump pysmlight to 0.0.16 (#125935) Bump pysmlight to 0.0.16 for Smlight integration 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 6c0a2c39025..609899971aa 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_polling", - "requirements": ["pysmlight==0.0.15"], + "requirements": ["pysmlight==0.0.16"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 3a6c82ce9b3..e6efd168596 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2238,7 +2238,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.0.15 +pysmlight==0.0.16 # homeassistant.components.snmp pysnmp==6.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a4a353b527..9b8a379ecd0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1792,7 +1792,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.0.15 +pysmlight==0.0.16 # homeassistant.components.snmp pysnmp==6.2.5 From 5685ba7f5575108236729a5fd57caaeef38359d5 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sat, 14 Sep 2024 09:21:15 +0200 Subject: [PATCH 0638/1309] Make acknowledge requests from LCN modules optional (#125765) * Add acknowledge flag to config_entry * Add acknowledge option to lcn configuration * Fix tests * Bump pypck to 0.7.23 * Add entry fixture for config_entry version 1.1 to test migration * Add data_description to strings.json * Create versioned config_entry in tests --- homeassistant/components/lcn/__init__.py | 28 +++ homeassistant/components/lcn/config_flow.py | 6 +- homeassistant/components/lcn/const.py | 1 + homeassistant/components/lcn/helpers.py | 3 + homeassistant/components/lcn/manifest.json | 2 +- homeassistant/components/lcn/strings.json | 16 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lcn/conftest.py | 9 +- .../lcn/fixtures/config_entry_myhome.json | 1 + .../lcn/fixtures/config_entry_pchk.json | 1 + .../lcn/fixtures/config_entry_pchk_v1_1.json | 230 ++++++++++++++++++ tests/components/lcn/test_config_flow.py | 8 +- tests/components/lcn/test_init.py | 17 ++ 14 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 tests/components/lcn/fixtures/config_entry_pchk_v1_1.json diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 96ffaddfb93..a8d75fe5635 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -22,6 +22,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( ADD_ENTITIES_CALLBACKS, + CONF_ACKNOWLEDGE, CONF_DIM_MODE, CONF_SK_NUM_TRIES, CONNECTION, @@ -73,6 +74,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b settings = { "SK_NUM_TRIES": config_entry.data[CONF_SK_NUM_TRIES], "DIM_MODE": pypck.lcn_defs.OutputPortDimMode[config_entry.data[CONF_DIM_MODE]], + "ACKNOWLEDGE": config_entry.data[CONF_ACKNOWLEDGE], } # connect to PCHK @@ -137,6 +139,32 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version == 1: + new_data = {**config_entry.data} + + if config_entry.minor_version < 2: + new_data[CONF_ACKNOWLEDGE] = False + + hass.config_entries.async_update_entry( + config_entry, data=new_data, minor_version=2, version=1 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + return True + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Close connection to PCHK host represented by config_entry.""" # forward unloading to platforms diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index e3979effc07..a1a98a39db3 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -26,7 +26,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType from . import PchkConnectionManager -from .const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, DIM_MODES, DOMAIN +from .const import CONF_ACKNOWLEDGE, CONF_DIM_MODE, CONF_SK_NUM_TRIES, DIM_MODES, DOMAIN from .helpers import purge_device_registry, purge_entity_registry _LOGGER = logging.getLogger(__name__) @@ -38,6 +38,7 @@ CONFIG_DATA = { vol.Required(CONF_PASSWORD, default=""): str, vol.Required(CONF_SK_NUM_TRIES, default=0): cv.positive_int, vol.Required(CONF_DIM_MODE, default="STEPS200"): vol.In(DIM_MODES), + vol.Required(CONF_ACKNOWLEDGE, default=False): cv.boolean, } USER_DATA = {vol.Required(CONF_HOST, default="pchk"): str, **CONFIG_DATA} @@ -71,10 +72,12 @@ async def validate_connection(data: ConfigType) -> str | None: password = data[CONF_PASSWORD] sk_num_tries = data[CONF_SK_NUM_TRIES] dim_mode = data[CONF_DIM_MODE] + acknowledge = data[CONF_ACKNOWLEDGE] settings = { "SK_NUM_TRIES": sk_num_tries, "DIM_MODE": pypck.lcn_defs.OutputPortDimMode[dim_mode], + "ACKNOWLEDGE": acknowledge, } _LOGGER.debug("Validating connection parameters to PCHK host '%s'", host_name) @@ -108,6 +111,7 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a LCN config flow.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: """Import existing configuration from LCN.""" diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index 24d2e68495c..707d0f29ba3 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -25,6 +25,7 @@ CONF_SOFTWARE_SERIAL = "software_serial" CONF_HARDWARE_TYPE = "hardware_type" CONF_DOMAIN_DATA = "domain_data" +CONF_ACKNOWLEDGE = "acknowledge" CONF_CONNECTIONS = "connections" CONF_SK_NUM_TRIES = "sk_num_tries" CONF_OUTPUT = "output" diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 70034e9020a..7da047682ac 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -37,6 +37,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( BINSENSOR_PORTS, + CONF_ACKNOWLEDGE, CONF_CLIMATES, CONF_CONNECTIONS, CONF_DIM_MODE, @@ -158,6 +159,7 @@ def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]: "password": "lcn, "sk_num_tries: 0, "dim_mode: "STEPS200", + "acknowledge": False, "devices": [ { "address": (0, 7, False) @@ -192,6 +194,7 @@ def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]: CONF_PASSWORD: connection[CONF_PASSWORD], CONF_SK_NUM_TRIES: connection[CONF_SK_NUM_TRIES], CONF_DIM_MODE: connection[CONF_DIM_MODE], + CONF_ACKNOWLEDGE: False, CONF_DEVICES: [], CONF_ENTITIES: [], } diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 9023941277f..43a34291138 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.22", "lcn-frontend==0.1.6"] + "requirements": ["pypck==0.7.23", "lcn-frontend==0.1.6"] } diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index a5f303c6392..9b5ce8c9cc0 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -26,7 +26,12 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "sk_num_tries": "Segment coupler scan attempts", - "dim_mode": "Dimming mode" + "dim_mode": "Dimming mode", + "acknowledge": "Request acknowledgement from modules" + }, + "data_description": { + "dim_mode": "The number of steps used for dimming outputs.", + "acknowledge": "Retry sendig commands if no response is received (increases bus traffic)." } }, "reconfigure": { @@ -37,8 +42,13 @@ "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "sk_num_tries": "Segment coupler scan attempts", - "dim_mode": "Dimming mode" + "sk_num_tries": "[%key:component::lcn::config::step::user::data::sk_num_tries%]", + "dim_mode": "[%key:component::lcn::config::step::user::data::dim_mode%]", + "acknowledge": "[%key:component::lcn::config::step::user::data::acknowledge%]" + }, + "data_description": { + "dim_mode": "[%key:component::lcn::config::step::user::data_description::dim_mode%]", + "acknowledge": "[%key:component::lcn::config::step::user::data_description::acknowledge%]" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index e6efd168596..ca1c008aad7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2130,7 +2130,7 @@ pyownet==0.10.0.post1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.7.22 +pypck==0.7.23 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b8a379ecd0..b0e034ebf72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1711,7 +1711,7 @@ pyoverkiz==1.13.14 pyownet==0.10.0.post1 # homeassistant.components.lcn -pypck==0.7.22 +pypck==0.7.23 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index 16797f6065d..3c5979c3c36 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -10,6 +10,7 @@ from pypck.module import GroupConnection, ModuleConnection import pytest from homeassistant.components.lcn import PchkConnectionManager +from homeassistant.components.lcn.config_flow import LcnFlowHandler from homeassistant.components.lcn.const import DOMAIN from homeassistant.components.lcn.helpers import AddressType, generate_unique_id from homeassistant.const import CONF_ADDRESS, CONF_DEVICES, CONF_ENTITIES, CONF_HOST @@ -19,6 +20,8 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture +LATEST_CONFIG_ENTRY_VERSION = (LcnFlowHandler.VERSION, LcnFlowHandler.MINOR_VERSION) + class MockModuleConnection(ModuleConnection): """Fake a LCN module connection.""" @@ -71,7 +74,9 @@ class MockPchkConnectionManager(PchkConnectionManager): send_command = AsyncMock() -def create_config_entry(name: str) -> MockConfigEntry: +def create_config_entry( + name: str, version: tuple[int, int] = LATEST_CONFIG_ENTRY_VERSION +) -> MockConfigEntry: """Set up config entries with configuration data.""" fixture_filename = f"lcn/config_entry_{name}.json" entry_data = json.loads(load_fixture(fixture_filename)) @@ -89,6 +94,8 @@ def create_config_entry(name: str) -> MockConfigEntry: title=title, data=entry_data, options=options, + version=version[0], + minor_version=version[1], ) diff --git a/tests/components/lcn/fixtures/config_entry_myhome.json b/tests/components/lcn/fixtures/config_entry_myhome.json index a0f8e7d3e10..5abc9749b46 100644 --- a/tests/components/lcn/fixtures/config_entry_myhome.json +++ b/tests/components/lcn/fixtures/config_entry_myhome.json @@ -6,6 +6,7 @@ "password": "lcn", "sk_num_tries": 0, "dim_mode": "STEPS200", + "acknowledge": false, "devices": [], "entities": [ { diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index 9a8095ff16d..d8eef6d1eb3 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -6,6 +6,7 @@ "password": "lcn", "sk_num_tries": 0, "dim_mode": "STEPS200", + "acknowledge": false, "devices": [ { "address": [0, 7, false], diff --git a/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json b/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json new file mode 100644 index 00000000000..9a8095ff16d --- /dev/null +++ b/tests/components/lcn/fixtures/config_entry_pchk_v1_1.json @@ -0,0 +1,230 @@ +{ + "host": "pchk", + "ip_address": "192.168.2.41", + "port": 4114, + "username": "lcn", + "password": "lcn", + "sk_num_tries": 0, + "dim_mode": "STEPS200", + "devices": [ + { + "address": [0, 7, false], + "name": "TestModule", + "hardware_serial": -1, + "software_serial": -1, + "hardware_type": -1 + }, + { + "address": [0, 5, true], + "name": "TestGroup", + "hardware_serial": -1, + "software_serial": -1, + "hardware_type": -1 + } + ], + "entities": [ + { + "address": [0, 7, false], + "name": "Light_Output1", + "resource": "output1", + "domain": "light", + "domain_data": { + "output": "OUTPUT1", + "dimmable": true, + "transition": 5000.0 + } + }, + { + "address": [0, 7, false], + "name": "Light_Output2", + "resource": "output2", + "domain": "light", + "domain_data": { + "output": "OUTPUT2", + "dimmable": false, + "transition": 0 + } + }, + { + "address": [0, 7, false], + "name": "Light_Relay1", + "resource": "relay1", + "domain": "light", + "domain_data": { + "output": "RELAY1", + "dimmable": false, + "transition": 0.0 + } + }, + { + "address": [0, 7, false], + "name": "Switch_Output1", + "resource": "output1", + "domain": "switch", + "domain_data": { + "output": "OUTPUT1" + } + }, + { + "address": [0, 7, false], + "name": "Switch_Output2", + "resource": "output2", + "domain": "switch", + "domain_data": { + "output": "OUTPUT2" + } + }, + { + "address": [0, 7, false], + "name": "Switch_Relay1", + "resource": "relay1", + "domain": "switch", + "domain_data": { + "output": "RELAY1" + } + }, + { + "address": [0, 7, false], + "name": "Switch_Relay2", + "resource": "relay2", + "domain": "switch", + "domain_data": { + "output": "RELAY2" + } + }, + { + "address": [0, 5, true], + "name": "Switch_Group5", + "resource": "relay1", + "domain": "switch", + "domain_data": { + "output": "RELAY1" + } + }, + { + "address": [0, 7, false], + "name": "Cover_Outputs", + "resource": "outputs", + "domain": "cover", + "domain_data": { + "motor": "OUTPUTS", + "reverse_time": "RT1200" + } + }, + { + "address": [0, 7, false], + "name": "Cover_Relays", + "resource": "motor1", + "domain": "cover", + "domain_data": { + "motor": "MOTOR1", + "reverse_time": "RT1200" + } + }, + { + "address": [0, 7, false], + "name": "Climate1", + "resource": "var1.r1varsetpoint", + "domain": "climate", + "domain_data": { + "source": "VAR1", + "setpoint": "R1VARSETPOINT", + "lockable": true, + "min_temp": 0.0, + "max_temp": 40.0, + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Romantic", + "resource": "0.0", + "domain": "scene", + "domain_data": { + "register": 0, + "scene": 0, + "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], + "transition": null + } + }, + { + "address": [0, 7, false], + "name": "Romantic Transition", + "resource": "0.1", + "domain": "scene", + "domain_data": { + "register": 0, + "scene": 1, + "outputs": ["OUTPUT1", "OUTPUT2", "RELAY1"], + "transition": 10 + } + }, + { + "address": [0, 7, false], + "name": "Sensor_LockRegulator1", + "resource": "r1varsetpoint", + "domain": "binary_sensor", + "domain_data": { + "source": "R1VARSETPOINT" + } + }, + { + "address": [0, 7, false], + "name": "Binary_Sensor1", + "resource": "binsensor1", + "domain": "binary_sensor", + "domain_data": { + "source": "BINSENSOR1" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_KeyLock", + "resource": "a5", + "domain": "binary_sensor", + "domain_data": { + "source": "A5" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Var1", + "resource": "var1", + "domain": "sensor", + "domain_data": { + "source": "VAR1", + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Setpoint1", + "resource": "r1varsetpoint", + "domain": "sensor", + "domain_data": { + "source": "R1VARSETPOINT", + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Led6", + "resource": "led6", + "domain": "sensor", + "domain_data": { + "source": "LED6", + "unit_of_measurement": "NATIVE" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_LogicOp1", + "resource": "logicop1", + "domain": "sensor", + "domain_data": { + "source": "LOGICOP1", + "unit_of_measurement": "NATIVE" + } + } + ] +} diff --git a/tests/components/lcn/test_config_flow.py b/tests/components/lcn/test_config_flow.py index 9f46202ac8a..a34592a4f87 100644 --- a/tests/components/lcn/test_config_flow.py +++ b/tests/components/lcn/test_config_flow.py @@ -7,7 +7,12 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.lcn.config_flow import LcnFlowHandler, validate_connection -from homeassistant.components.lcn.const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, DOMAIN +from homeassistant.components.lcn.const import ( + CONF_ACKNOWLEDGE, + CONF_DIM_MODE, + CONF_SK_NUM_TRIES, + DOMAIN, +) from homeassistant.const import ( CONF_BASE, CONF_DEVICES, @@ -31,6 +36,7 @@ CONFIG_DATA = { CONF_PASSWORD: "lcn", CONF_SK_NUM_TRIES: 0, CONF_DIM_MODE: "STEPS200", + CONF_ACKNOWLEDGE: False, } CONNECTION_DATA = {CONF_HOST: "pchk", **CONFIG_DATA} diff --git a/tests/components/lcn/test_init.py b/tests/components/lcn/test_init.py index ece0e95e501..62fa79961cb 100644 --- a/tests/components/lcn/test_init.py +++ b/tests/components/lcn/test_init.py @@ -14,6 +14,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( MockConfigEntry, MockPchkConnectionManager, + create_config_entry, init_integration, setup_component, ) @@ -125,3 +126,19 @@ async def test_async_setup_from_configuration_yaml(hass: HomeAssistant) -> None: await setup_component(hass) assert async_setup_entry.await_count == 2 + + +@patch("homeassistant.components.lcn.PchkConnectionManager", MockPchkConnectionManager) +async def test_migrate_1_1(hass: HomeAssistant, entry) -> None: + """Test migration config entry.""" + entry_v1_1 = create_config_entry("pchk_v1_1", version=(1, 1)) + entry_v1_1.add_to_hass(hass) + + await hass.config_entries.async_setup(entry_v1_1.entry_id) + await hass.async_block_till_done() + + entry_migrated = hass.config_entries.async_get_entry(entry_v1_1.entry_id) + assert entry_migrated.state is ConfigEntryState.LOADED + assert entry_migrated.version == 1 + assert entry_migrated.minor_version == 2 + assert entry_migrated.data == entry.data From aece6cc327d0d05fd7d6fce3ad2fbdd8669613cf Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 14 Sep 2024 09:52:34 +0200 Subject: [PATCH 0639/1309] Use debug instead of info log level in linode (#125941) --- homeassistant/components/linode/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/linode/__init__.py b/homeassistant/components/linode/__init__.py index 2ed3cf244d0..80c082344e7 100644 --- a/homeassistant/components/linode/__init__.py +++ b/homeassistant/components/linode/__init__.py @@ -45,7 +45,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: _linode = Linode(access_token) try: - _LOGGER.info("Linode Profile %s", _linode.manager.get_profile().username) + _LOGGER.debug("Linode Profile %s", _linode.manager.get_profile().username) except linode.errors.ApiError as _ex: _LOGGER.error(_ex) return False From 932d66b0ee42cd862ed80a7f723e6f121cedc6f3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 14 Sep 2024 09:52:51 +0200 Subject: [PATCH 0640/1309] Use debug instead of info log level in google_maps (#125942) --- homeassistant/components/google_maps/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index d703078d198..31eca8fba01 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -100,7 +100,7 @@ class GoogleMapsScanner: self.max_gps_accuracy is not None and person.accuracy > self.max_gps_accuracy ): - _LOGGER.info( + _LOGGER.debug( ( "Ignoring %s update because expected GPS " "accuracy %s is not met: %s" From c0f11c27a358e5406a408a363fca6a3492972279 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 14 Sep 2024 09:53:04 +0200 Subject: [PATCH 0641/1309] Use warning instead of info log level in roborock (#125940) --- homeassistant/components/roborock/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 88a603eca2b..bb42c0bd080 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -151,7 +151,7 @@ async def setup_device( ) if device.pv == "A01": return await setup_device_a01(hass, user_data, device, product_info) - _LOGGER.info( + _LOGGER.warning( "Not adding device %s because its protocol version %s or category %s is not supported", device.duid, device.pv, From 9eb3d847158cdcc5402e380ec3c34f8ab2910651 Mon Sep 17 00:00:00 2001 From: TimL Date: Sat, 14 Sep 2024 19:54:00 +1000 Subject: [PATCH 0642/1309] Add Smlight integration to strict-typing (#125946) Add smlight to strict typing --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index 706a99cc0c3..5e9b13305c9 100644 --- a/.strict-typing +++ b/.strict-typing @@ -415,6 +415,7 @@ homeassistant.components.skybell.* homeassistant.components.slack.* homeassistant.components.sleepiq.* homeassistant.components.smhi.* +homeassistant.components.smlight.* homeassistant.components.snooz.* homeassistant.components.solarlog.* homeassistant.components.sonarr.* diff --git a/mypy.ini b/mypy.ini index 579658155c3..62da0ef73af 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3906,6 +3906,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.smlight.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.snooz.*] check_untyped_defs = true disallow_incomplete_defs = true From e92d9317aaf879198d7967a34bd7e0a98a7e2786 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Sat, 14 Sep 2024 12:01:04 +0200 Subject: [PATCH 0643/1309] Additional sensor for Weheat integration (#125524) * Added additional sensor to Weheat * Added tests for old and new sensors * Added energy sensor * Changed tests to use snapshot * Removed unused value and regenerated the ambr * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * changed DHW sensor creation * Wrapped lambda function --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/weheat/const.py | 1 + .../components/weheat/coordinator.py | 14 +- homeassistant/components/weheat/icons.json | 24 + homeassistant/components/weheat/sensor.py | 96 ++- homeassistant/components/weheat/strings.json | 34 + tests/components/weheat/__init__.py | 12 + tests/components/weheat/conftest.py | 84 ++- tests/components/weheat/const.py | 5 + .../weheat/snapshots/test_sensor.ambr | 660 ++++++++++++++++++ tests/components/weheat/test_sensor.py | 56 ++ 10 files changed, 974 insertions(+), 12 deletions(-) create mode 100644 tests/components/weheat/snapshots/test_sensor.ambr create mode 100644 tests/components/weheat/test_sensor.py diff --git a/homeassistant/components/weheat/const.py b/homeassistant/components/weheat/const.py index fa1b17f8c07..e33fd983572 100644 --- a/homeassistant/components/weheat/const.py +++ b/homeassistant/components/weheat/const.py @@ -23,3 +23,4 @@ LOGGER: Logger = getLogger(__package__) DISPLAY_PRECISION_WATTS = 0 DISPLAY_PRECISION_COP = 1 +DISPLAY_PRECISION_WATER_TEMP = 1 diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index 92c12990371..69d1319ed52 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -46,27 +46,27 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - self._heat_pump_info = heat_pump - self._heat_pump_data = HeatPump(API_URL, self._heat_pump_info.uuid) + self.heat_pump_info = heat_pump + self._heat_pump_data = HeatPump(API_URL, heat_pump.uuid) self.session = session @property def heatpump_id(self) -> str: """Return the heat pump id.""" - return self._heat_pump_info.uuid + return self.heat_pump_info.uuid @property def readable_name(self) -> str | None: """Return the readable name of the heat pump.""" - if self._heat_pump_info.name: - return self._heat_pump_info.name - return self._heat_pump_info.model + if self.heat_pump_info.name: + return self.heat_pump_info.name + return self.heat_pump_info.model @property def model(self) -> str: """Return the model of the heat pump.""" - return self._heat_pump_info.model + return self.heat_pump_info.model def fetch_data(self) -> HeatPump: """Get the data from the API.""" diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json index b1eaf481bfa..a7579c12ecd 100644 --- a/homeassistant/components/weheat/icons.json +++ b/homeassistant/components/weheat/icons.json @@ -9,6 +9,30 @@ }, "cop": { "default": "mdi:speedometer" + }, + "water_inlet_temperature": { + "default": "mdi:thermometer" + }, + "water_outlet_temperature": { + "default": "mdi:thermometer" + }, + "ch_inlet_temperature": { + "default": "mdi:radiator" + }, + "outside_temperature": { + "default": "mdi:home-thermometer-outline" + }, + "dhw_top_temperature": { + "default": "mdi:thermometer" + }, + "dhw_bottom_temperature": { + "default": "mdi:thermometer" + }, + "heat_pump_state": { + "default": "mdi:state-machine" + }, + "electricity_used": { + "default": "mdi:flash" } } } diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index a5bbc66001c..fc7d3628a33 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -11,13 +11,17 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfPower +from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import WeheatConfigEntry -from .const import DISPLAY_PRECISION_COP, DISPLAY_PRECISION_WATTS +from .const import ( + DISPLAY_PRECISION_COP, + DISPLAY_PRECISION_WATER_TEMP, + DISPLAY_PRECISION_WATTS, +) from .coordinator import WeheatDataUpdateCoordinator from .entity import WeheatEntity @@ -55,6 +59,84 @@ SENSORS = [ suggested_display_precision=DISPLAY_PRECISION_COP, value_fn=lambda status: status.cop, ), + WeHeatSensorEntityDescription( + translation_key="water_inlet_temperature", + key="water_inlet_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, + value_fn=lambda status: status.water_inlet_temperature, + ), + WeHeatSensorEntityDescription( + translation_key="water_outlet_temperature", + key="water_outlet_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, + value_fn=lambda status: status.water_outlet_temperature, + ), + WeHeatSensorEntityDescription( + translation_key="ch_inlet_temperature", + key="ch_inlet_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, + value_fn=lambda status: status.water_house_in_temperature, + ), + WeHeatSensorEntityDescription( + translation_key="outside_temperature", + key="outside_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, + value_fn=lambda status: status.air_inlet_temperature, + ), + WeHeatSensorEntityDescription( + translation_key="heat_pump_state", + key="heat_pump_state", + name=None, + device_class=SensorDeviceClass.ENUM, + options=[s.name.lower() for s in HeatPump.State], + value_fn=( + lambda status: status.heat_pump_state.name.lower() + if status.heat_pump_state + else None + ), + ), + WeHeatSensorEntityDescription( + translation_key="electricity_used", + key="electricity_used", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_total, + ), +] + + +DHW_SENSORS = [ + WeHeatSensorEntityDescription( + translation_key="dhw_top_temperature", + key="dhw_top_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, + value_fn=lambda status: status.dhw_top_temperature, + ), + WeHeatSensorEntityDescription( + translation_key="dhw_bottom_temperature", + key="dhw_bottom_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, + value_fn=lambda status: status.dhw_bottom_temperature, + ), ] @@ -64,12 +146,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensors for weheat heat pump.""" - async_add_entities( + entities = [ WeheatHeatPumpSensor(coordinator, entity_description) for entity_description in SENSORS for coordinator in entry.runtime_data + ] + entities.extend( + WeheatHeatPumpSensor(coordinator, entity_description) + for entity_description in DHW_SENSORS + for coordinator in entry.runtime_data + if coordinator.heat_pump_info.has_dhw ) + async_add_entities(entities) + class WeheatHeatPumpSensor(WeheatEntity, SensorEntity): """Defines a Weheat heat pump sensor.""" diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 63871b065b6..b77af4ed306 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -40,6 +40,40 @@ }, "cop": { "name": "COP" + }, + "water_inlet_temperature": { + "name": "Water inlet temperature" + }, + "water_outlet_temperature": { + "name": "Water outlet temperature" + }, + "ch_inlet_temperature": { + "name": "Central heating inlet temperature" + }, + "outside_temperature": { + "name": "Outside temperature" + }, + "dhw_top_temperature": { + "name": "DHW top temperature" + }, + "dhw_bottom_temperature": { + "name": "DHW bottom temperature" + }, + "heat_pump_state": { + "state": { + "standby": "[%key:common::state::standby%]", + "water_check": "Checking water temperature", + "heating": "Heating", + "cooling": "Cooling", + "dhw": "Heating DHW", + "legionella_prevention": "Legionella prevention", + "defrosting": "Defrosting", + "self_test": "Self test", + "manual_control": "Manual control" + } + }, + "electricity_used": { + "name": "Electricity used" } } } diff --git a/tests/components/weheat/__init__.py b/tests/components/weheat/__init__.py index c077280ccb5..65c4f84ba77 100644 --- a/tests/components/weheat/__init__.py +++ b/tests/components/weheat/__init__.py @@ -1 +1,13 @@ """Tests for the Weheat integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index 831d4d460ac..1b4bf26c35f 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -1,8 +1,12 @@ """Fixtures for Weheat tests.""" -from unittest.mock import patch +from collections.abc import Generator +from time import time +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from weheat.abstractions.discovery import HeatPumpDiscovery +from weheat.abstractions.heat_pump import HeatPump from homeassistant.components.application_credentials import ( DOMAIN as APPLICATION_CREDENTIALS, @@ -13,7 +17,9 @@ from homeassistant.components.weheat.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import CLIENT_ID, CLIENT_SECRET +from .const import CLIENT_ID, CLIENT_SECRET, TEST_HP_UUID, TEST_MODEL, TEST_SN + +from tests.common import MockConfigEntry @pytest.fixture(autouse=True) @@ -34,3 +40,77 @@ def mock_setup_entry(): "homeassistant.components.weheat.async_setup_entry", return_value=True ) as mock_setup: yield mock_setup + + +@pytest.fixture +def mock_heat_pump_info() -> HeatPumpDiscovery.HeatPumpInfo: + """Create a HeatPumpInfo with default settings.""" + return HeatPumpDiscovery.HeatPumpInfo(TEST_HP_UUID, None, TEST_MODEL, TEST_SN, True) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Weheat", + data={ + "id": "12345", + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, + }, + unique_id="123456789", + ) + + +@pytest.fixture +def mock_weheat_discover(mock_heat_pump_info) -> Generator[AsyncMock]: + """Mock an Weheat discovery.""" + with ( + patch( + "homeassistant.components.weheat.HeatPumpDiscovery.discover_active", + autospec=True, + ) as mock_discover, + ): + mock_discover.return_value = [mock_heat_pump_info] + + yield mock_discover + + +@pytest.fixture +def mock_weheat_heat_pump_instance() -> MagicMock: + """Mock an Weheat heat pump instance with a set of default values.""" + mock_heat_pump_instance = MagicMock(spec_set=HeatPump) + + mock_heat_pump_instance.water_inlet_temperature = 11 + mock_heat_pump_instance.water_outlet_temperature = 22 + mock_heat_pump_instance.water_house_in_temperature = 33 + mock_heat_pump_instance.air_inlet_temperature = 44 + mock_heat_pump_instance.power_input = 55 + mock_heat_pump_instance.power_output = 66 + mock_heat_pump_instance.dhw_top_temperature = 77 + mock_heat_pump_instance.dhw_bottom_temperature = 88 + mock_heat_pump_instance.cop = 4.5 + mock_heat_pump_instance.heat_pump_state = HeatPump.State.HEATING + mock_heat_pump_instance.energy_total = 12345 + + return mock_heat_pump_instance + + +@pytest.fixture +def mock_weheat_heat_pump(mock_weheat_heat_pump_instance) -> Generator[AsyncMock]: + """Mock the coordinator HeatPump data.""" + with ( + patch( + "homeassistant.components.weheat.coordinator.HeatPump", + ) as mock_heat_pump, + ): + mock_heat_pump.return_value = mock_weheat_heat_pump_instance + + yield mock_weheat_heat_pump_instance diff --git a/tests/components/weheat/const.py b/tests/components/weheat/const.py index 01733de1c91..bae74dc70a1 100644 --- a/tests/components/weheat/const.py +++ b/tests/components/weheat/const.py @@ -9,3 +9,8 @@ CONF_REFRESH_TOKEN = "refresh_token" CONF_AUTH_IMPLEMENTATION = "auth_implementation" MOCK_REFRESH_TOKEN = "mock_refresh_token" MOCK_ACCESS_TOKEN = "mock_access_token" + +TEST_HP_UUID = "0000-1111-2222-3333" +TEST_NAME = "Test Heat Pump" +TEST_MODEL = "Test Model" +TEST_SN = "SN-Test-This" diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fc2b6a845a8 --- /dev/null +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -0,0 +1,660 @@ +# serializer version: 1 +# name: test_all_entities[sensor.test_model-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'standby', + 'water_check', + 'heating', + 'cooling', + 'dhw', + 'legionella_prevention', + 'defrosting', + 'self_test', + 'manual_control', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'heat_pump_state', + 'unique_id': '0000-1111-2222-3333_heat_pump_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_model-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Model', + 'options': list([ + 'standby', + 'water_check', + 'heating', + 'cooling', + 'dhw', + 'legionella_prevention', + 'defrosting', + 'self_test', + 'manual_control', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_model', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating', + }) +# --- +# name: test_all_entities[sensor.test_model_central_heating_inlet_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_central_heating_inlet_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Central heating inlet temperature', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ch_inlet_temperature', + 'unique_id': '0000-1111-2222-3333_ch_inlet_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_central_heating_inlet_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Model Central heating inlet temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_central_heating_inlet_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33', + }) +# --- +# name: test_all_entities[sensor.test_model_cop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_cop', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'COP', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'cop', + 'unique_id': '0000-1111-2222-3333_cop', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_model_cop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Model COP', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_model_cop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.5', + }) +# --- +# name: test_all_entities[sensor.test_model_dhw_bottom_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_dhw_bottom_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW bottom temperature', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_bottom_temperature', + 'unique_id': '0000-1111-2222-3333_dhw_bottom_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_dhw_bottom_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Model DHW bottom temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_dhw_bottom_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_all_entities[sensor.test_model_dhw_top_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_dhw_top_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW top temperature', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_top_temperature', + 'unique_id': '0000-1111-2222-3333_dhw_top_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_dhw_top_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Model DHW top temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_dhw_top_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '77', + }) +# --- +# name: test_all_entities[sensor.test_model_electricity_used-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_electricity_used', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity used', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_used', + 'unique_id': '0000-1111-2222-3333_electricity_used', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_electricity_used-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test Model Electricity used', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_electricity_used', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12345', + }) +# --- +# name: test_all_entities[sensor.test_model_input_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_input_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input power', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_input', + 'unique_id': '0000-1111-2222-3333_power_input', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_input_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Model Input power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_input_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55', + }) +# --- +# name: test_all_entities[sensor.test_model_output_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_output_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Output power', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_output', + 'unique_id': '0000-1111-2222-3333_power_output', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_output_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Model Output power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_output_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '66', + }) +# --- +# name: test_all_entities[sensor.test_model_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': '0000-1111-2222-3333_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Model Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '44', + }) +# --- +# name: test_all_entities[sensor.test_model_power_output-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_power_output', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'power output', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'power_output', + 'unique_id': '0000-1111-2222-3333_power_output', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_power_output-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Model power output', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_power_output', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '77', + }) +# --- +# name: test_all_entities[sensor.test_model_water_inlet_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_water_inlet_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water inlet temperature', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_inlet_temperature', + 'unique_id': '0000-1111-2222-3333_water_inlet_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_water_inlet_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Model Water inlet temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_water_inlet_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11', + }) +# --- +# name: test_all_entities[sensor.test_model_water_outlet_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_water_outlet_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water outlet temperature', + 'platform': 'weheat', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_outlet_temperature', + 'unique_id': '0000-1111-2222-3333_water_outlet_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_water_outlet_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Model Water outlet temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_model_water_outlet_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py new file mode 100644 index 00000000000..5bd05b5cb2b --- /dev/null +++ b/tests/components/weheat/test_sensor.py @@ -0,0 +1,56 @@ +"""Tests for the weheat sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion +from weheat.abstractions.discovery import HeatPumpDiscovery + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 9), (True, 11)]) +async def test_create_entities( + hass: HomeAssistant, + mock_weheat_discover: AsyncMock, + mock_weheat_heat_pump: AsyncMock, + mock_heat_pump_info: HeatPumpDiscovery.HeatPumpInfo, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + has_dhw: bool, + nr_of_entities: int, +) -> None: + """Test creating entities.""" + mock_heat_pump_info.has_dhw = has_dhw + mock_weheat_discover.return_value = [mock_heat_pump_info] + + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == nr_of_entities From 2fa6370dc0ad7a46a2bc6f8a4f7baa1e2592ba26 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 14 Sep 2024 15:24:55 +0200 Subject: [PATCH 0644/1309] Use debug instead of info log level in components [a] (#125944) * Use debug instead of info log level in components [a] * Process code review comments --- homeassistant/components/actiontec/device_tracker.py | 5 ++--- homeassistant/components/adax/config_flow.py | 2 +- homeassistant/components/androidtv/config_flow.py | 2 +- homeassistant/components/androidtv/entity.py | 2 +- homeassistant/components/androidtv/media_player.py | 2 +- homeassistant/components/apple_tv/__init__.py | 4 ++-- homeassistant/components/apple_tv/remote.py | 2 +- homeassistant/components/aprs/device_tracker.py | 4 ++-- homeassistant/components/asuswrt/router.py | 2 +- homeassistant/components/aurora_abb_powerone/config_flow.py | 2 +- homeassistant/components/aurora_abb_powerone/coordinator.py | 4 ++-- homeassistant/components/axis/__init__.py | 2 +- 12 files changed, 16 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 801ddd00a5a..b1b9c81c674 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -51,7 +51,6 @@ class ActiontecDeviceScanner(DeviceScanner): self.last_results: list[Device] = [] data = self.get_actiontec_data() self.success_init = data is not None - _LOGGER.info("Scanner initialized") def scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" @@ -70,7 +69,7 @@ class ActiontecDeviceScanner(DeviceScanner): Return boolean if scanning successful. """ - _LOGGER.info("Scanning") + _LOGGER.debug("Scanning") if not self.success_init: return False @@ -79,7 +78,7 @@ class ActiontecDeviceScanner(DeviceScanner): self.last_results = [ device for device in actiontec_data if device.timevalid > -60 ] - _LOGGER.info("Scan successful") + _LOGGER.debug("Scan successful") return True def get_actiontec_data(self) -> list[Device] | None: diff --git a/homeassistant/components/adax/config_flow.py b/homeassistant/components/adax/config_flow.py index 3e8ca646cad..0a995fc6b85 100644 --- a/homeassistant/components/adax/config_flow.py +++ b/homeassistant/components/adax/config_flow.py @@ -130,7 +130,7 @@ class AdaxConfigFlow(ConfigFlow, domain=DOMAIN): async_get_clientsession(self.hass), account_id, password ) if token is None: - _LOGGER.info("Adax: Failed to login to retrieve token") + _LOGGER.debug("Adax: Failed to login to retrieve token") errors["base"] = "cannot_connect" return self.async_show_form( step_id="cloud", diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index 1ed4b0f6782..e8350acc9cb 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -131,7 +131,7 @@ class AndroidTVFlowHandler(ConfigFlow, domain=DOMAIN): return RESULT_CONN_ERROR, None dev_prop = aftv.device_properties - _LOGGER.info( + _LOGGER.debug( "Android device at %s: %s = %r, %s = %r", user_input[CONF_HOST], PROP_ETHMAC, diff --git a/homeassistant/components/androidtv/entity.py b/homeassistant/components/androidtv/entity.py index 470a4950ebc..626dd0f7794 100644 --- a/homeassistant/components/androidtv/entity.py +++ b/homeassistant/components/androidtv/entity.py @@ -67,7 +67,7 @@ def adb_decorator[_ADBDeviceT: AndroidTVEntity, **_P, _R]( return await func(self, *args, **kwargs) except LockNotAcquiredException: # If the ADB lock could not be acquired, skip this command - _LOGGER.info( + _LOGGER.debug( ( "ADB command %s not executed because the connection is" " currently in use" diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 75cf6ead6c3..6e338529ad4 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -306,7 +306,7 @@ class ADBDevice(AndroidTVEntity, MediaPlayerEntity): msg, title="Android Debug Bridge", ) - _LOGGER.info("%s", msg) + _LOGGER.debug("%s", msg) @adb_decorator() async def service_download(self, device_path: str, local_path: str) -> None: diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 08372aa79ae..d0e414c4e9e 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -375,7 +375,7 @@ class AppleTVManager(DeviceListener): f"Protocol(s) {missing_protocols_str} not yet found for {name}," " waiting for discovery." ) - _LOGGER.info( + _LOGGER.debug( "Protocol(s) %s not yet found for %s, trying later", missing_protocols_str, name, @@ -394,7 +394,7 @@ class AppleTVManager(DeviceListener): self._connection_attempts = 0 if self._connection_was_lost: - _LOGGER.info( + _LOGGER.warning( 'Connection was re-established to device "%s"', self.config_entry.data[CONF_NAME], ) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index 8950a46388d..a93a89cad3e 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -85,7 +85,7 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): if not attr_value: raise ValueError("Command not found. Exiting sequence") - _LOGGER.info("Sending command %s", single_command) + _LOGGER.debug("Sending command %s", single_command) if hold_secs >= 1: await attr_value(action=InputAction.Hold) diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index 67d0736e526..fc23fc5e436 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -159,7 +159,7 @@ class AprsListenerThread(threading.Thread): self.ais.set_filter(self.server_filter) try: - _LOGGER.info( + _LOGGER.debug( "Opening connection to %s with callsign %s", self.host, self.callsign ) self.ais.connect() @@ -170,7 +170,7 @@ class AprsListenerThread(threading.Thread): except (AprsConnectionError, LoginError) as err: self.start_complete(False, str(err)) except OSError: - _LOGGER.info( + _LOGGER.debug( "Closing connection to %s with callsign %s", self.host, self.callsign ) diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 1244db34ed5..330c4bcfb67 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -290,7 +290,7 @@ class AsusWrtRouter: if self._connect_error: self._connect_error = False - _LOGGER.info("Reconnected to ASUS router %s", self.host) + _LOGGER.warning("Reconnected to ASUS router %s", self.host) self._connected_devices = len(wrt_devices) consider_home: int = self._options.get( diff --git a/homeassistant/components/aurora_abb_powerone/config_flow.py b/homeassistant/components/aurora_abb_powerone/config_flow.py index 47c349ab48a..0b6e41257fc 100644 --- a/homeassistant/components/aurora_abb_powerone/config_flow.py +++ b/homeassistant/components/aurora_abb_powerone/config_flow.py @@ -45,7 +45,7 @@ def validate_and_connect( ret[ATTR_SERIAL_NUMBER] = client.serial_number() ret[ATTR_MODEL] = f"{client.version()} ({client.pn()})" ret[ATTR_FIRMWARE] = client.firmware(1) - _LOGGER.info("Returning device info=%s", ret) + _LOGGER.debug("Returning device info=%s", ret) except AuroraError: _LOGGER.warning("Could not connect to device=%s", comport) raise diff --git a/homeassistant/components/aurora_abb_powerone/coordinator.py b/homeassistant/components/aurora_abb_powerone/coordinator.py index 6a84869b2e5..0dd87e75766 100644 --- a/homeassistant/components/aurora_abb_powerone/coordinator.py +++ b/homeassistant/components/aurora_abb_powerone/coordinator.py @@ -78,9 +78,9 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): finally: if self.available != self.available_prev: if self.available: - _LOGGER.info("Communication with %s back online", self.name) + _LOGGER.warning("Communication with %s back online", self.name) else: - _LOGGER.info( + _LOGGER.warning( "Communication with %s lost", self.name, ) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index f1d8d1d4b63..e6c6fab47a1 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -52,6 +52,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Home Assistant 2023.2 hass.config_entries.async_update_entry(config_entry, version=3) - _LOGGER.info("Migration to version %s successful", config_entry.version) + _LOGGER.debug("Migration to version %s successful", config_entry.version) return True From b18b497a81a09743fbbbaecadd6f75408ddccb9b Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Sat, 14 Sep 2024 15:46:01 +0200 Subject: [PATCH 0645/1309] Bump solarlog_cli to 0.3.0 (#125951) --- homeassistant/components/solarlog/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index eb2268e08da..99ddc2ed162 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/solarlog", "iot_class": "local_polling", "loggers": ["solarlog_cli"], - "requirements": ["solarlog_cli==0.2.2"] + "requirements": ["solarlog_cli==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ca1c008aad7..1ed6db2bfc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2667,7 +2667,7 @@ soco==0.30.4 solaredge-local==0.2.3 # homeassistant.components.solarlog -solarlog_cli==0.2.2 +solarlog_cli==0.3.0 # homeassistant.components.solax solax==3.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0e034ebf72..9f8445fa6c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2113,7 +2113,7 @@ snapcast==2.3.6 soco==0.30.4 # homeassistant.components.solarlog -solarlog_cli==0.2.2 +solarlog_cli==0.3.0 # homeassistant.components.solax solax==3.1.1 From 2cbbf7d9a6342b4e514b37ba024867e77b8e900f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 14 Sep 2024 15:51:58 +0200 Subject: [PATCH 0646/1309] Use debug instead of info log level in components [c] (#125955) Use debug/warning instead of info log level in components [c] --- homeassistant/components/cast/helpers.py | 2 +- homeassistant/components/cast/media_player.py | 2 +- homeassistant/components/cisco_ios/device_tracker.py | 1 - homeassistant/components/comfoconnect/__init__.py | 2 +- homeassistant/components/concord232/binary_sensor.py | 2 +- homeassistant/components/control4/director_utils.py | 2 +- 6 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 865ea1ac3f6..228c69b65ec 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -80,7 +80,7 @@ class ChromecastInfo: "+label%3A%22integration%3A+cast%22" ) - _LOGGER.info( + _LOGGER.debug( ( "Fetched cast details for unknown model '%s' manufacturer:" " '%s', type: '%s'. Please %s" diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 028a01e6f22..28db97a857d 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -693,7 +693,7 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): # an arbitrary cast app, generally for UX. if "app_id" in app_data: app_id = app_data.pop("app_id") - _LOGGER.info("Starting Cast app by ID %s", app_id) + _LOGGER.debug("Starting Cast app by ID %s", app_id) await self.hass.async_add_executor_job(self._start_app, app_id) if app_data: _LOGGER.warning( diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 90c3e227615..1f78f95c259 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -52,7 +52,6 @@ class CiscoDeviceScanner(DeviceScanner): self.last_results = {} self.success_init = self._update_info() - _LOGGER.info("Initialized cisco_ios scanner") async def async_get_device_name(self, device: str) -> str | None: """Get the firmware doesn't save the name of the wireless device.""" diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py index 8a54c863083..4e0671fd134 100644 --- a/homeassistant/components/comfoconnect/__init__.py +++ b/homeassistant/components/comfoconnect/__init__.py @@ -66,7 +66,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.error("Could not connect to ComfoConnect bridge on %s", host) return False bridge = bridges[0] - _LOGGER.info("Bridge found: %s (%s)", bridge.uuid.hex(), bridge.host) + _LOGGER.debug("Bridge found: %s (%s)", bridge.uuid.hex(), bridge.host) # Setup ComfoConnect Bridge ccb = ComfoConnectBridge(hass, bridge, name, token, user_agent, pin) diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py index a1dcbc222f7..2b86e72e63c 100644 --- a/homeassistant/components/concord232/binary_sensor.py +++ b/homeassistant/components/concord232/binary_sensor.py @@ -80,7 +80,7 @@ def setup_platform( client.zones.sort(key=lambda zone: zone["number"]) for zone in client.zones: - _LOGGER.info("Loading Zone found: %s", zone["name"]) + _LOGGER.debug("Loading Zone found: %s", zone["name"]) if zone["number"] not in exclude: sensors.append( Concord232ZoneSensor( diff --git a/homeassistant/components/control4/director_utils.py b/homeassistant/components/control4/director_utils.py index 10e9486ee89..5e57237337c 100644 --- a/homeassistant/components/control4/director_utils.py +++ b/homeassistant/components/control4/director_utils.py @@ -37,7 +37,7 @@ async def update_variables_for_config_entry( try: return await _update_variables_for_config_entry(hass, entry, variable_names) except BadToken: - _LOGGER.info("Updating Control4 director token") + _LOGGER.debug("Updating Control4 director token") await refresh_tokens(hass, entry) return await _update_variables_for_config_entry(hass, entry, variable_names) From d28c32624cd59fc09d769db3f7f9359267e2b96e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 14 Sep 2024 15:52:23 +0200 Subject: [PATCH 0647/1309] Use debug/warning instead of info log level in components [b] (#125954) --- homeassistant/components/bbox/device_tracker.py | 5 ++--- homeassistant/components/blackbird/media_player.py | 2 +- .../components/bluetooth_le_tracker/device_tracker.py | 2 +- homeassistant/components/broadlink/remote.py | 2 +- homeassistant/components/bt_home_hub_5/device_tracker.py | 3 +-- homeassistant/components/bt_smarthub/device_tracker.py | 4 ++-- 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index 20ee0fa2820..12174d395f7 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -54,7 +54,6 @@ class BboxDeviceScanner(DeviceScanner): self.last_results: list[Device] = [] self.success_init = self._update_info() - _LOGGER.info("Scanner initialized") def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -78,7 +77,7 @@ class BboxDeviceScanner(DeviceScanner): Returns boolean if scanning successful. """ - _LOGGER.info("Scanning") + _LOGGER.debug("Scanning") box = pybbox.Bbox(ip=self.host) result = box.get_all_connected_devices() @@ -96,5 +95,5 @@ class BboxDeviceScanner(DeviceScanner): self.last_results = last_results - _LOGGER.info("Scan successful") + _LOGGER.debug("Scan successful") return True diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index 46cabaf4099..37672e98e0b 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -103,7 +103,7 @@ def setup_platform( devices = [] for zone_id, extra in config[CONF_ZONES].items(): - _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME]) + _LOGGER.debug("Adding zone %d - %s", zone_id, extra[CONF_NAME]) unique_id = f"{connection}-{zone_id}" device = BlackbirdZone(blackbird, sources, zone_id, extra[CONF_NAME]) hass.data[DATA_BLACKBIRD][unique_id] = device diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index 24b03b2f566..25e620ff15d 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -194,7 +194,7 @@ async def async_setup_scanner( # noqa: C901 if track_new: if mac not in devs_to_track and mac not in devs_no_track: - _LOGGER.info("Discovered Bluetooth LE device %s", mac) + _LOGGER.debug("Discovered Bluetooth LE device %s", mac) hass.async_create_task( async_see_device(mac, service_info.name, new_device=True) ) diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index 710b4a34a11..18a3a82017c 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -377,7 +377,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): device.api.check_frequency ) if is_found: - _LOGGER.info("Radiofrequency detected: %s MHz", frequency) + _LOGGER.debug("Radiofrequency detected: %s MHz", frequency) break else: await device.async_request(device.api.cancel_sweep_frequency) diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index 10c1b32c310..cbd06381578 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -41,7 +41,6 @@ class BTHomeHub5DeviceScanner(DeviceScanner): def __init__(self, config): """Initialise the scanner.""" - _LOGGER.info("Initialising BT Home Hub 5") self.host = config[CONF_HOST] self.last_results = {} @@ -69,7 +68,7 @@ class BTHomeHub5DeviceScanner(DeviceScanner): def update_info(self): """Ensure the information from the BT Home Hub 5 is up to date.""" - _LOGGER.info("Scanning") + _LOGGER.debug("Scanning") data = bthomehub5_devicelist.get_devicelist(self.host) diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 3e2565e0904..29f60bd317f 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -67,7 +67,7 @@ class BTSmartHubScanner(DeviceScanner): if self.get_bt_smarthub_data(): self.success_init = True else: - _LOGGER.info("Failed to connect to %s", self.smarthub.router_ip) + _LOGGER.warning("Failed to connect to %s", self.smarthub.router_ip) def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -88,7 +88,7 @@ class BTSmartHubScanner(DeviceScanner): if not self.success_init: return - _LOGGER.info("Scanning") + _LOGGER.debug("Scanning") if not (data := self.get_bt_smarthub_data()): _LOGGER.warning("Error scanning devices") return From 5fb9a24f228fda12cd44b67e41ae21aea4a25258 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 14 Sep 2024 16:36:32 +0200 Subject: [PATCH 0648/1309] Bump motionblinds to 0.6.25 (#125957) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index e1e12cf6729..b327c146300 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.24"] + "requirements": ["motionblinds==0.6.25"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1ed6db2bfc8..d1f999046c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1381,7 +1381,7 @@ monzopy==1.3.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.24 +motionblinds==0.6.25 # homeassistant.components.motionblinds_ble motionblindsble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f8445fa6c9..4c7b7b4ce73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1150,7 +1150,7 @@ monzopy==1.3.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.24 +motionblinds==0.6.25 # homeassistant.components.motionblinds_ble motionblindsble==0.1.1 From a24db20c649e0746605582d4ee79e90440b9c3c9 Mon Sep 17 00:00:00 2001 From: Gigatrappeur <5045347+Gigatrappeur@users.noreply.github.com> Date: Sat, 14 Sep 2024 17:01:41 +0200 Subject: [PATCH 0649/1309] Add k10+ vacuum in switchbot cloud integration (#125457) * Add k10+ vacuum in switchbot cloud integration * Change label fan speed, Mapping state HA, Add others vacuums * Update homeassistant/components/switchbot_cloud/vacuum.py Co-authored-by: Joost Lekkerkerker * Remove comments and add mapping for fan speed --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 4 +- .../components/switchbot_cloud/__init__.py | 18 ++- .../components/switchbot_cloud/climate.py | 2 +- .../components/switchbot_cloud/const.py | 5 + .../components/switchbot_cloud/entity.py | 2 +- .../components/switchbot_cloud/manifest.json | 2 +- .../components/switchbot_cloud/switch.py | 4 +- .../components/switchbot_cloud/vacuum.py | 127 ++++++++++++++++++ 8 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/switchbot_cloud/vacuum.py diff --git a/CODEOWNERS b/CODEOWNERS index 1abdfd637ff..04906e6bf88 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1436,8 +1436,8 @@ build.json @home-assistant/supervisor /tests/components/switchbee/ @jafar-atili /homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski -/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland -/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland +/homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur +/tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur /homeassistant/components/switcher_kis/ @thecode /tests/components/switcher_kis/ @thecode /homeassistant/components/switchmate/ @danielhiversen @qiz-li diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index c79ba41018f..39a179aaa21 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -15,7 +15,12 @@ from .const import DOMAIN from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.CLIMATE, + Platform.SENSOR, + Platform.SWITCH, + Platform.VACUUM, +] @dataclass @@ -25,6 +30,7 @@ class SwitchbotDevices: climates: list[Remote] = field(default_factory=list) switches: list[Device | Remote] = field(default_factory=list) sensors: list[Device] = field(default_factory=list) + vacuums: list[Device] = field(default_factory=list) @dataclass @@ -81,6 +87,16 @@ def make_device_data( devices_data.sensors.append( prepare_device(hass, api, device, coordinators_by_id) ) + if isinstance(device, Device) and device.device_type in [ + "K10+", + "K10+ Pro", + "Robot Vacuum Cleaner S1", + "Robot Vacuum Cleaner S1 Plus", + ]: + devices_data.vacuums.append( + prepare_device(hass, api, device, coordinators_by_id) + ) + return devices_data diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index e04145933ae..cd60313f37a 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -95,7 +95,7 @@ class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity): new_fan_speed = _SWITCHBOT_FAN_MODES.get( fan_mode or self._attr_fan_mode, _DEFAULT_SWITCHBOT_FAN_MODE ) - await self.send_command( + await self.send_api_command( AirConditionerCommands.SET_ALL, parameters=f"{new_temperature},{new_mode},{new_fan_speed},on", ) diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py index 66c84b63047..b849194537a 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -10,3 +10,8 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=600) SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_HUMIDITY = "humidity" SENSOR_KIND_BATTERY = "battery" + +VACUUM_FAN_SPEED_QUIET = "quiet" +VACUUM_FAN_SPEED_STANDARD = "standard" +VACUUM_FAN_SPEED_STRONG = "strong" +VACUUM_FAN_SPEED_MAX = "max" diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py index 7bb00cda945..f77adb7b192 100644 --- a/homeassistant/components/switchbot_cloud/entity.py +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -35,7 +35,7 @@ class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): model=device.device_type, ) - async def send_command( + async def send_api_command( self, command: Commands, command_type: str = "command", diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 0bafdec9f68..eb08d2183b1 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -1,7 +1,7 @@ { "domain": "switchbot_cloud", "name": "SwitchBot Cloud", - "codeowners": ["@SeraphicRav", "@laurence-presland"], + "codeowners": ["@SeraphicRav", "@laurence-presland", "@Gigatrappeur"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "integration_type": "hub", diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index fbcd4430f6e..c30e60086fa 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -36,13 +36,13 @@ class SwitchBotCloudSwitch(SwitchBotCloudEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await self.send_command(CommonCommands.ON) + await self.send_api_command(CommonCommands.ON) self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self.send_command(CommonCommands.OFF) + await self.send_api_command(CommonCommands.OFF) self._attr_is_on = False self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py new file mode 100644 index 00000000000..f9236507037 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -0,0 +1,127 @@ +"""Support for SwitchBot vacuum.""" + +from typing import Any + +from switchbot_api import Device, Remote, SwitchBotAPI, VacuumCommands + +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + STATE_IDLE, + STATE_PAUSED, + STATE_RETURNING, + StateVacuumEntity, + VacuumEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SwitchbotCloudData +from .const import ( + DOMAIN, + VACUUM_FAN_SPEED_MAX, + VACUUM_FAN_SPEED_QUIET, + VACUUM_FAN_SPEED_STANDARD, + VACUUM_FAN_SPEED_STRONG, +) +from .coordinator import SwitchBotCoordinator +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.vacuums + ) + + +VACUUM_SWITCHBOT_STATE_TO_HA_STATE: dict[str, str] = { + "StandBy": STATE_IDLE, + "Clearing": STATE_CLEANING, + "Paused": STATE_PAUSED, + "GotoChargeBase": STATE_RETURNING, + "Charging": STATE_DOCKED, + "ChargeDone": STATE_DOCKED, + "Dormant": STATE_IDLE, + "InTrouble": STATE_ERROR, + "InRemoteControl": STATE_CLEANING, + "InDustCollecting": STATE_DOCKED, +} + +VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED: dict[str, str] = { + VACUUM_FAN_SPEED_QUIET: "0", + VACUUM_FAN_SPEED_STANDARD: "1", + VACUUM_FAN_SPEED_STRONG: "2", + VACUUM_FAN_SPEED_MAX: "3", +} + + +# https://github.com/OpenWonderLabs/SwitchBotAPI?tab=readme-ov-file#robot-vacuum-cleaner-s1-plus-1 +class SwitchBotCloudVacuum(SwitchBotCloudEntity, StateVacuumEntity): + """Representation of a SwitchBot vacuum.""" + + _attr_supported_features: VacuumEntityFeature = ( + VacuumEntityFeature.BATTERY + | VacuumEntityFeature.FAN_SPEED + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.START + | VacuumEntityFeature.STATE + ) + + _attr_name = None + _attr_fan_speed_list: list[str] = list( + VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED.keys() + ) + + async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: + """Set fan speed.""" + self._attr_fan_speed = fan_speed + if fan_speed in VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED: + await self.send_api_command( + VacuumCommands.POW_LEVEL, + parameters=VACUUM_FAN_SPEED_TO_SWITCHBOT_FAN_SPEED[fan_speed], + ) + self.async_write_ha_state() + + async def async_pause(self) -> None: + """Pause the cleaning task.""" + await self.send_api_command(VacuumCommands.STOP) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Set the vacuum cleaner to return to the dock.""" + await self.send_api_command(VacuumCommands.DOCK) + + async def async_start(self) -> None: + """Start or resume the cleaning task.""" + await self.send_api_command(VacuumCommands.START) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if not self.coordinator.data: + return + + self._attr_battery_level = self.coordinator.data.get("battery") + self._attr_available = self.coordinator.data.get("onlineStatus") == "online" + + switchbot_state = str(self.coordinator.data.get("workingStatus")) + self._attr_state = VACUUM_SWITCHBOT_STATE_TO_HA_STATE.get(switchbot_state) + + self.async_write_ha_state() + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> SwitchBotCloudVacuum: + """Make a SwitchBotCloudVacuum.""" + return SwitchBotCloudVacuum(api, device, coordinator) From bcacc27456c45b0400b36bab63715bb4e0cb2950 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sat, 14 Sep 2024 17:00:59 -0400 Subject: [PATCH 0650/1309] Bump aiorussound to 3.0.5 (#125975) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 19273de92ee..0a18bdb3b8a 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.0.4"] + "requirements": ["aiorussound==3.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index d1f999046c8..fb153a45648 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -353,7 +353,7 @@ aioridwell==2024.01.0 aioruckus==0.41 # homeassistant.components.russound_rio -aiorussound==3.0.4 +aiorussound==3.0.5 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c7b7b4ce73..cc27c667dad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -335,7 +335,7 @@ aioridwell==2024.01.0 aioruckus==0.41 # homeassistant.components.russound_rio -aiorussound==3.0.4 +aiorussound==3.0.5 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 02211128798b5dbf07a3fbd4042ae273245e7e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sat, 14 Sep 2024 23:39:07 +0200 Subject: [PATCH 0651/1309] Update aioairzone to v0.9.3 (#125977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 872b6d4f394..c40f4138b0a 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.9.2"] + "requirements": ["aioairzone==0.9.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index fb153a45648..48ff43d2cb0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,7 +179,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.5 # homeassistant.components.airzone -aioairzone==0.9.2 +aioairzone==0.9.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc27c667dad..4de344a814e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aioairq==0.3.2 aioairzone-cloud==0.6.5 # homeassistant.components.airzone -aioairzone==0.9.2 +aioairzone==0.9.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From d070fd40a3766f76de3b0ace395b860bb3250760 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 14 Sep 2024 23:41:06 +0200 Subject: [PATCH 0652/1309] Use debug/warning instead of info log level in components [e] (#125970) --- homeassistant/components/ecobee/climate.py | 2 +- homeassistant/components/eddystone_temperature/sensor.py | 4 ++-- homeassistant/components/efergy/sensor.py | 2 +- homeassistant/components/electrasmart/climate.py | 2 +- homeassistant/components/envisalink/__init__.py | 6 +++--- homeassistant/components/ezviz/__init__.py | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index f9119f05394..e6801998e0d 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -673,7 +673,7 @@ class Thermostat(ClimateEntity): holdHours=self.hold_hours(), ) - _LOGGER.info("Setting fan mode to: %s", fan_mode) + _LOGGER.debug("Setting fan mode to: %s", fan_mode) def set_temp_hold(self, temp): """Set temperature hold in modes other than auto. diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 637beffcf94..5dc30a575d7 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -79,12 +79,12 @@ def setup_platform( def monitor_stop(event: Event) -> None: """Stop the monitor thread.""" - _LOGGER.info("Stopping scanner for Eddystone beacons") + _LOGGER.debug("Stopping scanner for Eddystone beacons") mon.stop() def monitor_start(event: Event) -> None: """Start the monitor thread.""" - _LOGGER.info("Starting scanner for Eddystone beacons") + _LOGGER.debug("Starting scanner for Eddystone beacons") mon.start() add_entities(devices) diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index a03f8f7d012..05c731370eb 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -182,4 +182,4 @@ class EfergySensor(EfergyEntity, SensorEntity): return if not self._attr_available: self._attr_available = True - LOGGER.info("Connection has resumed") + LOGGER.debug("Connection has resumed") diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py index 9f6e7cbddf5..81a07545a30 100644 --- a/homeassistant/components/electrasmart/climate.py +++ b/homeassistant/components/electrasmart/climate.py @@ -203,7 +203,7 @@ class ElectraClimateEntity(ClimateEntity): return if not self._was_available: - _LOGGER.info( + _LOGGER.debug( "%s (%s) is now available", self._electra_ac_device.mac, self.name, diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 65fdc1b5c63..8222c044503 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -160,7 +160,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @callback def async_connection_success_callback(data): """Handle a successful connection.""" - _LOGGER.info("Established a connection with the Envisalink") + _LOGGER.debug("Established a connection with the Envisalink") if not sync_connect.done(): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_envisalink) sync_connect.set_result(True) @@ -186,7 +186,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @callback def stop_envisalink(event): """Shutdown envisalink connection and thread on exit.""" - _LOGGER.info("Shutting down Envisalink") + _LOGGER.debug("Shutting down Envisalink") controller.stop() async def handle_custom_function(call: ServiceCall) -> None: @@ -203,7 +203,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: controller.callback_login_timeout = async_connection_fail_callback controller.callback_login_success = async_connection_success_callback - _LOGGER.info("Start envisalink") + _LOGGER.debug("Start envisalink") controller.start() if not await sync_connect: diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index c453060b472..6885304e0de 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -105,7 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]: for item in hass.config_entries.async_entries(domain=DOMAIN): if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD: - _LOGGER.info("Reload Ezviz main account with camera entry") + _LOGGER.debug("Reload Ezviz main account with camera entry") await hass.config_entries.async_reload(item.entry_id) return True From c1bcabbc9dba16bb3387056b567b26e2aca3634f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 14 Sep 2024 23:41:32 +0200 Subject: [PATCH 0653/1309] Use debug/warning instead of info log level in components [d] (#125969) --- homeassistant/components/denonavr/media_player.py | 2 +- homeassistant/components/devialet/config_flow.py | 2 +- homeassistant/components/doods/image_processing.py | 2 +- homeassistant/components/doorbird/device.py | 2 +- homeassistant/components/dynalite/bridge.py | 2 +- homeassistant/components/dynalite/dynalitebase.py | 2 +- homeassistant/components/dynalite/panel.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index a7d8565d6a4..091b70283b1 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -233,7 +233,7 @@ def async_log_errors[_DenonDeviceT: DenonDevice, **_P, _R]( ) finally: if available and not self.available: - _LOGGER.info( + _LOGGER.warning( "Denon AVR receiver at host %s is available again", self._receiver.host, ) diff --git a/homeassistant/components/devialet/config_flow.py b/homeassistant/components/devialet/config_flow.py index 4c097ae6f86..6c394faaa53 100644 --- a/homeassistant/components/devialet/config_flow.py +++ b/homeassistant/components/devialet/config_flow.py @@ -72,7 +72,7 @@ class DevialetFlowHandler(ConfigFlow, domain=DOMAIN): self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by zeroconf discovery.""" - LOGGER.info("Devialet device found via ZEROCONF: %s", discovery_info) + LOGGER.debug("Devialet device found via ZEROCONF: %s", discovery_info) self._host = discovery_info.host self._name = discovery_info.name.split(".", 1)[0] diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index acd9d7fe71b..51633d0e05d 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -278,7 +278,7 @@ class Doods(ImageProcessingEntity): ) for path in paths: - _LOGGER.info("Saving results image to %s", path) + _LOGGER.debug("Saving results image to %s", path) os.makedirs(os.path.dirname(path), exist_ok=True) img.save(path) diff --git a/homeassistant/components/doorbird/device.py b/homeassistant/components/doorbird/device.py index adcb441f458..1aaea257a4c 100644 --- a/homeassistant/components/doorbird/device.py +++ b/homeassistant/components/doorbird/device.py @@ -240,7 +240,7 @@ class ConfiguredDoorBird: ) return False - _LOGGER.info("Successfully registered URL for %s on %s", event, self.name) + _LOGGER.debug("Successfully registered URL for %s on %s", event, self.name) return True def get_event_data(self, event: str) -> dict[str, str | None]: diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 2245364b0b7..6f090371eee 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -68,7 +68,7 @@ class DynaliteBridge: log_string = ( "Connected" if self.dynalite_devices.connected else "Disconnected" ) - LOGGER.info("%s to dynalite host", log_string) + LOGGER.debug("%s to dynalite host", log_string) async_dispatcher_send(self.hass, self.update_signal()) else: async_dispatcher_send(self.hass, self.update_signal(device)) diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index bfc62609101..62667dc19c3 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -77,7 +77,7 @@ class DynaliteBase(RestoreEntity, ABC): if cur_state: self.initialize_state(cur_state) else: - LOGGER.info("Restore state not available for %s", self.entity_id) + LOGGER.warning("Restore state not available for %s", self.entity_id) self._unsub_dispatchers.append( async_dispatcher_connect( diff --git a/homeassistant/components/dynalite/panel.py b/homeassistant/components/dynalite/panel.py index b62944f63fe..623736cf02a 100644 --- a/homeassistant/components/dynalite/panel.py +++ b/homeassistant/components/dynalite/panel.py @@ -90,7 +90,7 @@ def save_dynalite_config( message_data = { conf: message_conf[conf] for conf in RELEVANT_CONFS if conf in message_conf } - LOGGER.info("Updating Dynalite config entry") + LOGGER.debug("Updating Dynalite config entry") hass.config_entries.async_update_entry(entry, data=message_data) connection.send_result(msg["id"], {}) From adfca851fe7ba6d463b96d8d7b3f1b0ae7b24cd1 Mon Sep 17 00:00:00 2001 From: Galorhallen <12990764+Galorhallen@users.noreply.github.com> Date: Sat, 14 Sep 2024 23:42:38 +0200 Subject: [PATCH 0654/1309] Bump govee light local to 1.5.2 (#125968) Update govee light local library --- homeassistant/components/govee_light_local/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json index 168a13e2477..b6b25f5aa09 100644 --- a/homeassistant/components/govee_light_local/manifest.json +++ b/homeassistant/components/govee_light_local/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/govee_light_local", "iot_class": "local_push", - "requirements": ["govee-local-api==1.5.1"] + "requirements": ["govee-local-api==1.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 48ff43d2cb0..71ac2786592 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1019,7 +1019,7 @@ gotailwind==0.2.3 govee-ble==0.40.0 # homeassistant.components.govee_light_local -govee-local-api==1.5.1 +govee-local-api==1.5.2 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4de344a814e..2cfb3796a2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -866,7 +866,7 @@ gotailwind==0.2.3 govee-ble==0.40.0 # homeassistant.components.govee_light_local -govee-local-api==1.5.1 +govee-local-api==1.5.2 # homeassistant.components.gpsd gps3==0.33.3 From ad467029c7182afa60f8b10912720208b470a62e Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Sat, 14 Sep 2024 14:46:21 -0700 Subject: [PATCH 0655/1309] Use Freezer for tests in TotalConnect (#125960) use Freezer for tests in TotalConnect --- .../totalconnect/test_alarm_control_panel.py | 72 +++++++++++++------ 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index ed89f0b00cd..eb2b849540c 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -40,7 +40,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.util import dt as dt_util from .common import ( LOCATION_ID, @@ -92,7 +91,9 @@ async def test_attributes( assert mock_request.call_count == 1 -async def test_arm_home_success(hass: HomeAssistant) -> None: +async def test_arm_home_success( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test arm home method success.""" responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_STAY] await setup_platform(hass, ALARM_DOMAIN) @@ -108,7 +109,8 @@ async def test_arm_home_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt_util.utcnow() + DELAY) + freezer.tick(DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_HOME @@ -148,7 +150,9 @@ async def test_arm_home_failure(hass: HomeAssistant) -> None: assert mock_request.call_count == 3 -async def test_arm_home_instant_success(hass: HomeAssistant) -> None: +async def test_arm_home_instant_success( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test arm home instant method success.""" responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_STAY] await setup_platform(hass, ALARM_DOMAIN) @@ -164,7 +168,8 @@ async def test_arm_home_instant_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt_util.utcnow() + DELAY) + freezer.tick(DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_HOME @@ -205,7 +210,9 @@ async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: assert mock_request.call_count == 3 -async def test_arm_away_instant_success(hass: HomeAssistant) -> None: +async def test_arm_away_instant_success( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test arm home instant method success.""" responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_AWAY] await setup_platform(hass, ALARM_DOMAIN) @@ -221,7 +228,8 @@ async def test_arm_away_instant_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt_util.utcnow() + DELAY) + freezer.tick(DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY @@ -262,7 +270,9 @@ async def test_arm_away_instant_failure(hass: HomeAssistant) -> None: assert mock_request.call_count == 3 -async def test_arm_away_success(hass: HomeAssistant) -> None: +async def test_arm_away_success( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test arm away method success.""" responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_AWAY] await setup_platform(hass, ALARM_DOMAIN) @@ -277,7 +287,8 @@ async def test_arm_away_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt_util.utcnow() + DELAY) + freezer.tick(DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY @@ -315,7 +326,9 @@ async def test_arm_away_failure(hass: HomeAssistant) -> None: assert mock_request.call_count == 3 -async def test_disarm_success(hass: HomeAssistant) -> None: +async def test_disarm_success( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test disarm method success.""" responses = [RESPONSE_ARMED_AWAY, RESPONSE_DISARM_SUCCESS, RESPONSE_DISARMED] await setup_platform(hass, ALARM_DOMAIN) @@ -330,7 +343,8 @@ async def test_disarm_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt_util.utcnow() + DELAY) + freezer.tick(DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED @@ -410,7 +424,9 @@ async def test_disarm_code_required( assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED -async def test_arm_night_success(hass: HomeAssistant) -> None: +async def test_arm_night_success( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test arm night method success.""" responses = [RESPONSE_DISARMED, RESPONSE_ARM_SUCCESS, RESPONSE_ARMED_NIGHT] await setup_platform(hass, ALARM_DOMAIN) @@ -425,7 +441,8 @@ async def test_arm_night_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt_util.utcnow() + DELAY) + freezer.tick(DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_NIGHT @@ -463,7 +480,7 @@ async def test_arm_night_failure(hass: HomeAssistant) -> None: assert mock_request.call_count == 3 -async def test_arming(hass: HomeAssistant) -> None: +async def test_arming(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test arming.""" responses = [RESPONSE_DISARMED, RESPONSE_SUCCESS, RESPONSE_ARMING] await setup_platform(hass, ALARM_DOMAIN) @@ -478,13 +495,14 @@ async def test_arming(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt_util.utcnow() + DELAY) + freezer.tick(DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMING -async def test_disarming(hass: HomeAssistant) -> None: +async def test_disarming(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test disarming.""" responses = [RESPONSE_ARMED_AWAY, RESPONSE_SUCCESS, RESPONSE_DISARMING] await setup_platform(hass, ALARM_DOMAIN) @@ -499,7 +517,8 @@ async def test_disarming(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt_util.utcnow() + DELAY) + freezer.tick(DELAY) + async_fire_time_changed(hass) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMING @@ -566,7 +585,9 @@ async def test_unknown(hass: HomeAssistant) -> None: assert mock_request.call_count == 1 -async def test_other_update_failures(hass: HomeAssistant) -> None: +async def test_other_update_failures( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test other failures seen during updates.""" responses = [ RESPONSE_DISARMED, @@ -585,31 +606,36 @@ async def test_other_update_failures(hass: HomeAssistant) -> None: assert mock_request.call_count == 1 # then an error: ServiceUnavailable --> UpdateFailed - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 2 # works again - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 2) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 3 # then an error: TotalConnectError --> UpdateFailed - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 3) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 4 # works again - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 4) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 5 # unknown TotalConnect status via ValueError - async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 5) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 6 From 5d14afad92b6448647ed999b8bb10e4db1413174 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 14 Sep 2024 23:47:27 +0200 Subject: [PATCH 0656/1309] Use debug/warning instead of info log level in components [f] (#125971) --- homeassistant/components/fints/sensor.py | 4 ++-- homeassistant/components/flic/binary_sensor.py | 6 +++--- homeassistant/components/flux_led/__init__.py | 2 +- homeassistant/components/foscam/__init__.py | 2 +- homeassistant/components/foscam/camera.py | 6 +++--- homeassistant/components/freebox/router.py | 2 +- homeassistant/components/fritz/coordinator.py | 4 ++-- homeassistant/components/fritzbox/__init__.py | 4 ++-- homeassistant/components/fritzbox_callmonitor/base.py | 2 +- homeassistant/components/frontier_silicon/media_player.py | 2 +- 10 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 8a92850ad47..e22b7072786 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -89,7 +89,7 @@ def setup_platform( for account in balance_accounts: if config[CONF_ACCOUNTS] and account.iban not in account_config: - _LOGGER.info("Skipping account %s for bank %s", account.iban, fints_name) + _LOGGER.debug("Skipping account %s for bank %s", account.iban, fints_name) continue if not (account_name := account_config.get(account.iban)): @@ -99,7 +99,7 @@ def setup_platform( for account in holdings_accounts: if config[CONF_HOLDINGS] and account.accountnumber not in holdings_config: - _LOGGER.info( + _LOGGER.debug( "Skipping holdings %s for bank %s", account.accountnumber, fints_name ) continue diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py index fcfe4b6604f..cd160480674 100644 --- a/homeassistant/components/flic/binary_sensor.py +++ b/homeassistant/components/flic/binary_sensor.py @@ -108,7 +108,7 @@ def start_scanning(config, add_entities, client): def scan_completed_callback(scan_wizard, result, address, name): """Restart scan wizard to constantly check for new buttons.""" if result == pyflic.ScanWizardResult.WizardSuccess: - _LOGGER.info("Found new button %s", address) + _LOGGER.debug("Found new button %s", address) elif result != pyflic.ScanWizardResult.WizardFailedTimeout: _LOGGER.warning( "Failed to connect to button %s. Reason: %s", address, result @@ -132,7 +132,7 @@ def setup_button( timeout: int = config[CONF_TIMEOUT] ignored_click_types: list[str] | None = config.get(CONF_IGNORED_CLICK_TYPES) button = FlicButton(hass, client, address, timeout, ignored_click_types) - _LOGGER.info("Connected to button %s", address) + _LOGGER.debug("Connected to button %s", address) add_entities([button]) @@ -203,7 +203,7 @@ class FlicButton(BinarySensorEntity): time_string, ) return True - _LOGGER.info( + _LOGGER.debug( "Queued %s allowed for %s. Time in queue was %s", click_type, self._address, diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index b3e17a65a5c..1472dfa4bf1 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -136,7 +136,7 @@ async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> new_unique_id = f"{unique_id}{entity_unique_id[len(unique_id):]}" else: return None - _LOGGER.info( + _LOGGER.debug( "Migrating unique_id from [%s] to [%s]", entity_unique_id, new_unique_id, diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index f8708a589ce..b4d64464972 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -89,6 +89,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unique_id=None, ) - LOGGER.info("Migration to version %s successful", entry.version) + LOGGER.debug("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 45704515422..075848f6ffb 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -129,7 +129,7 @@ class HassFoscamCamera(FoscamEntity, Camera): ) if ret == -3: - LOGGER.info( + LOGGER.warning( ( "Can't get motion detection status, camera %s configured with" " non-admin user" @@ -171,7 +171,7 @@ class HassFoscamCamera(FoscamEntity, Camera): if ret != 0: if ret == -3: - LOGGER.info( + LOGGER.warning( ( "Can't set motion detection status, camera %s configured" " with non-admin user" @@ -197,7 +197,7 @@ class HassFoscamCamera(FoscamEntity, Camera): if ret != 0: if ret == -3: - LOGGER.info( + LOGGER.warning( ( "Can't set motion detection status, camera %s configured" " with non-admin user" diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index ed2fbcf1e83..efa96eca5a7 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -225,7 +225,7 @@ class FreeboxRouter: fbx_raids: list[dict[str, Any]] = await self._api.storage.get_raids() or [] except HttpRequestError: self.supports_raid = False - _LOGGER.info( + _LOGGER.warning( "Router %s API does not support RAID", self.name, ) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 13c442a1ace..4134f0af026 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -79,7 +79,7 @@ def device_filter_out_from_trackers( def _ha_is_stopping(activity: str) -> None: """Inform that HA is stopping.""" - _LOGGER.info("Cannot execute %s: HomeAssistant is shutting down", activity) + _LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity) class ClassSetupMissing(Exception): @@ -658,7 +658,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): entity.domain == DEVICE_TRACKER_DOMAIN or "_internet_access" in entity.unique_id ) and entry_mac not in device_hosts: - _LOGGER.info("Removing orphan entity entry %s", entity.entity_id) + _LOGGER.debug("Removing orphan entity entry %s", entity.entity_id) entity_reg.async_remove(entity.entity_id) device_reg = dr.async_get(self.hass) diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 460e1edd851..ab6d88772d5 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -29,14 +29,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) -> and "_temperature" not in entry.unique_id ): new_unique_id = f"{entry.unique_id}_temperature" - LOGGER.info( + LOGGER.debug( "Migrating unique_id [%s] to [%s]", entry.unique_id, new_unique_id ) return {"new_unique_id": new_unique_id} if entry.domain == BINARY_SENSOR_DOMAIN and "_" not in entry.unique_id: new_unique_id = f"{entry.unique_id}_alarm" - LOGGER.info( + LOGGER.debug( "Migrating unique_id [%s] to [%s]", entry.unique_id, new_unique_id ) return {"new_unique_id": new_unique_id} diff --git a/homeassistant/components/fritzbox_callmonitor/base.py b/homeassistant/components/fritzbox_callmonitor/base.py index 72d17b57abc..2816880a1b2 100644 --- a/homeassistant/components/fritzbox_callmonitor/base.py +++ b/homeassistant/components/fritzbox_callmonitor/base.py @@ -62,7 +62,7 @@ class FritzBoxPhonebook: for name, nrs in self.phonebook_dict.items() for nr in nrs } - _LOGGER.info("Fritz!Box phone book successfully updated") + _LOGGER.debug("Fritz!Box phone book successfully updated") def get_phonebook_ids(self) -> list[int]: """Return list of phonebook ids.""" diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index cb02d430230..8407e0a869d 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -118,7 +118,7 @@ class AFSAPIDevice(MediaPlayerEntity): return if not self._attr_available: - _LOGGER.info( + _LOGGER.warning( "Reconnected to %s", self.name or afsapi.webfsapi_endpoint, ) From b1b7c3f7c1b1d51ba02dbcf47e22f2dffe538020 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 14 Sep 2024 23:33:16 -0700 Subject: [PATCH 0657/1309] Bump opower to 0.8.0 (#125981) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 02b98cfaf00..c347e52ef0e 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.7.0"] + "requirements": ["opower==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 71ac2786592..c95908b2bcf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1538,7 +1538,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.7.0 +opower==0.8.0 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2cfb3796a2e..200d793f5b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1268,7 +1268,7 @@ openhomedevice==2.2.0 openwebifpy==4.2.7 # homeassistant.components.opower -opower==0.7.0 +opower==0.8.0 # homeassistant.components.oralb oralb-ble==0.17.6 From 6dadd467ab6c844d1512fe1c03693bb6757ea851 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 15 Sep 2024 09:55:11 +0200 Subject: [PATCH 0658/1309] Remember Reolink config flow input (#125962) --- homeassistant/components/reolink/config_flow.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 067a7e24b8e..67db2e50b8a 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -205,6 +205,11 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): if CONF_HOST not in user_input: user_input[CONF_HOST] = self._host + # remember input in case of a error + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + self._host = user_input[CONF_HOST] + host = ReolinkHost(self.hass, user_input, DEFAULT_OPTIONS) try: await host.async_init() From d292f2b9b4eda5d91aad5352df8b5e6db10bfe7c Mon Sep 17 00:00:00 2001 From: Window-Hero <38403749+Window-Hero@users.noreply.github.com> Date: Sun, 15 Sep 2024 04:31:56 -0400 Subject: [PATCH 0659/1309] Update pil util font height (#123512) * Update pil.py The default font size is far too small and will frequently be rendered completely unreadable by JPEG compression. This is much more consistently readable, and properly specifies the font size in the draw.text function rather than relying on it being 8. * Update pil.py Converted to ruff format * Update pil.py Trying to get ruff formatting * Update pil.py fixed whitespace * Update pil.py removed trailing space --- homeassistant/util/pil.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/pil.py b/homeassistant/util/pil.py index 733f640ce48..6925cd03a4c 100644 --- a/homeassistant/util/pil.py +++ b/homeassistant/util/pil.py @@ -28,7 +28,7 @@ def draw_box( """ line_width = 3 - font_height = 8 + font_height = 20 y_min, x_min, y_max, x_max = box (left, right, top, bottom) = ( x_min * img_width, @@ -43,5 +43,8 @@ def draw_box( ) if text: draw.text( - (left + line_width, abs(top - line_width - font_height)), text, fill=color + (left + line_width, abs(top - line_width - font_height)), + text, + fill=color, + font_size=font_height, ) From 6906ee0e48730019d5a67c55b041aa4989174b43 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 15 Sep 2024 11:29:26 +0200 Subject: [PATCH 0660/1309] Improve Shelly RPC entity naming (#125415) * Fix default names for cover entities * Drop component index if only one component exists * Improve doc strings * Use more consistent naming * Typo * Revert removing index 0 from entity names * Improve names for RGB(W) lights --- homeassistant/components/shelly/sensor.py | 12 ++++---- homeassistant/components/shelly/utils.py | 14 ++++++--- tests/components/shelly/test_climate.py | 34 ++++++++++++--------- tests/components/shelly/test_sensor.py | 22 +++++++------- tests/components/shelly/test_utils.py | 37 ++++++++++++++++++++++- 5 files changed, 82 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 1ef174119e4..ea1a6801a89 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1066,7 +1066,7 @@ RPC_SENSORS: Final = { "analoginput": RpcSensorDescription( key="input", sub_key="percent", - name="Analog input", + name="analog", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, removal_condition=lambda config, _, key: ( @@ -1076,7 +1076,7 @@ RPC_SENSORS: Final = { "analoginput_xpercent": RpcSensorDescription( key="input", sub_key="xpercent", - name="Analog value", + name="analog value", removal_condition=lambda config, status, key: ( config[key]["type"] != "analog" or config[key]["enable"] is False @@ -1087,7 +1087,7 @@ RPC_SENSORS: Final = { "pulse_counter": RpcSensorDescription( key="input", sub_key="counts", - name="Pulse counter", + name="pulse counter", native_unit_of_measurement="pulse", state_class=SensorStateClass.TOTAL, value=lambda status, _: status["total"], @@ -1098,7 +1098,7 @@ RPC_SENSORS: Final = { "counter_value": RpcSensorDescription( key="input", sub_key="counts", - name="Counter value", + name="counter value", value=lambda status, _: status["xtotal"], removal_condition=lambda config, status, key: ( config[key]["type"] != "count" @@ -1110,7 +1110,7 @@ RPC_SENSORS: Final = { "counter_frequency": RpcSensorDescription( key="input", sub_key="freq", - name="Pulse counter frequency", + name="pulse counter frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, state_class=SensorStateClass.MEASUREMENT, removal_condition=lambda config, _, key: ( @@ -1120,7 +1120,7 @@ RPC_SENSORS: Final = { "counter_frequency_value": RpcSensorDescription( key="input", sub_key="xfreq", - name="Pulse counter frequency value", + name="pulse counter frequency value", removal_condition=lambda config, status, key: ( config[key]["type"] != "count" or config[key]["enable"] is False diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d0a8a1230c5..d05943df764 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -319,15 +319,19 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str: device_name = device.name entity_name: str | None = None if key in device.config: - entity_name = device.config[key].get("name", device_name) + entity_name = device.config[key].get("name") if entity_name is None: - if key.startswith(("input:", "light:", "switch:")): - return f"{device_name} {key.replace(':', '_')}" + channel = key.split(":")[0] + channel_id = key.split(":")[-1] + if key.startswith(("cover:", "input:", "light:", "switch:", "thermostat:")): + return f"{device_name} {channel.title()} {channel_id}" + if key.startswith(("rgb:", "rgbw:")): + return f"{device_name} {channel.upper()} light {channel_id}" if key.startswith("em1"): - return f"{device_name} EM{key.split(':')[-1]}" + return f"{device_name} EM{channel_id}" if key.startswith(("boolean:", "enum:", "number:", "text:")): - return key.replace(":", " ").title() + return f"{channel.title()} {channel_id}" return device_name return entity_name diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 1156d7e0ed5..997cf945626 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -609,23 +609,25 @@ async def test_rpc_climate_hvac_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test climate hvac mode service.""" + entity_id = "climate.test_name_thermostat_0" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 23 assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING assert state.attributes[ATTR_CURRENT_HUMIDITY] == 44.4 - entry = entity_registry.async_get(ENTITY_ID) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "123456789ABC-thermostat:0" monkeypatch.setitem(mock_rpc_device.status["thermostat:0"], "output", False) mock_rpc_device.mock_update() - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE assert state.attributes[ATTR_CURRENT_HUMIDITY] == 44.4 @@ -633,7 +635,7 @@ async def test_rpc_climate_hvac_mode( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) mock_rpc_device.mock_update() @@ -641,7 +643,7 @@ async def test_rpc_climate_hvac_mode( mock_rpc_device.call_rpc.assert_called_once_with( "Thermostat.SetConfig", {"config": {"id": 0, "enable": False}} ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.state == HVACMode.OFF @@ -652,20 +654,21 @@ async def test_rpc_climate_without_humidity( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test climate entity without the humidity value.""" + entity_id = "climate.test_name_thermostat_0" new_status = deepcopy(mock_rpc_device.status) new_status.pop("humidity:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.state == HVACMode.HEAT assert state.attributes[ATTR_TEMPERATURE] == 23 assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 12.3 assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING assert ATTR_CURRENT_HUMIDITY not in state.attributes - entry = entity_registry.async_get(ENTITY_ID) + entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "123456789ABC-thermostat:0" @@ -674,9 +677,11 @@ async def test_rpc_climate_set_temperature( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test climate set target temperature.""" + entity_id = "climate.test_name_thermostat_0" + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.attributes[ATTR_TEMPERATURE] == 23 # test set temperature without target temperature @@ -684,7 +689,7 @@ async def test_rpc_climate_set_temperature( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: ENTITY_ID, + ATTR_ENTITY_ID: entity_id, ATTR_TARGET_TEMP_LOW: 20, ATTR_TARGET_TEMP_HIGH: 30, }, @@ -696,7 +701,7 @@ async def test_rpc_climate_set_temperature( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 28}, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 28}, blocking=True, ) mock_rpc_device.mock_update() @@ -704,7 +709,7 @@ async def test_rpc_climate_set_temperature( mock_rpc_device.call_rpc.assert_called_once_with( "Thermostat.SetConfig", {"config": {"id": 0, "target_C": 28}} ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.attributes[ATTR_TEMPERATURE] == 28 @@ -712,13 +717,14 @@ async def test_rpc_climate_hvac_mode_cool( hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch ) -> None: """Test climate with hvac mode cooling.""" + entity_id = "climate.test_name_thermostat_0" new_config = deepcopy(mock_rpc_device.config) new_config["thermostat:0"]["type"] = "cooling" monkeypatch.setattr(mock_rpc_device, "config", new_config) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.state == HVACMode.COOL assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING @@ -730,7 +736,7 @@ async def test_wall_display_thermostat_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test Wall Display in thermostat mode.""" - climate_entity_id = "climate.test_name" + climate_entity_id = "climate.test_name_thermostat_0" switch_entity_id = "switch.test_switch_0" await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) @@ -757,7 +763,7 @@ async def test_wall_display_thermostat_mode_external_actuator( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test Wall Display in thermostat mode with an external actuator.""" - climate_entity_id = "climate.test_name" + climate_entity_id = "climate.test_name_thermostat_0" switch_entity_id = "switch.test_switch_0" new_status = deepcopy(mock_rpc_device.status) diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index ef8a609998a..18c3d874c55 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -729,14 +729,14 @@ async def test_rpc_analog_input_sensors( await init_integration(hass, 2) - entity_id = f"{SENSOR_DOMAIN}.test_name_analog_input" + entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog" assert hass.states.get(entity_id).state == "89" entry = entity_registry.async_get(entity_id) assert entry assert entry.unique_id == "123456789ABC-input:1-analoginput" - entity_id = f"{SENSOR_DOMAIN}.test_name_analog_value" + entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog_value" state = hass.states.get(entity_id) assert state assert state.state == "8.9" @@ -757,10 +757,10 @@ async def test_rpc_disabled_analog_input_sensors( await init_integration(hass, 2) - entity_id = f"{SENSOR_DOMAIN}.test_name_analog_input" + entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog" assert hass.states.get(entity_id) is None - entity_id = f"{SENSOR_DOMAIN}.test_name_analog_value" + entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog_value" assert hass.states.get(entity_id) is None @@ -777,10 +777,10 @@ async def test_rpc_disabled_xpercent( ) await init_integration(hass, 2) - entity_id = f"{SENSOR_DOMAIN}.test_name_analog_input" + entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog" assert hass.states.get(entity_id).state == "89" - entity_id = f"{SENSOR_DOMAIN}.test_name_analog_value" + entity_id = f"{SENSOR_DOMAIN}.test_name_input_1_analog_value" assert hass.states.get(entity_id) is None @@ -1293,7 +1293,7 @@ async def test_rpc_rgbw_sensors( await init_integration(hass, 2) - entity_id = "sensor.test_name_power" + entity_id = f"sensor.test_name_{light_type}_light_0_power" state = hass.states.get(entity_id) assert state @@ -1304,7 +1304,7 @@ async def test_rpc_rgbw_sensors( assert entry assert entry.unique_id == f"123456789ABC-{light_type}:0-power_{light_type}" - entity_id = "sensor.test_name_energy" + entity_id = f"sensor.test_name_{light_type}_light_0_energy" state = hass.states.get(entity_id) assert state @@ -1315,7 +1315,7 @@ async def test_rpc_rgbw_sensors( assert entry assert entry.unique_id == f"123456789ABC-{light_type}:0-energy_{light_type}" - entity_id = "sensor.test_name_current" + entity_id = f"sensor.test_name_{light_type}_light_0_current" state = hass.states.get(entity_id) assert state @@ -1328,7 +1328,7 @@ async def test_rpc_rgbw_sensors( assert entry assert entry.unique_id == f"123456789ABC-{light_type}:0-current_{light_type}" - entity_id = "sensor.test_name_voltage" + entity_id = f"sensor.test_name_{light_type}_light_0_voltage" state = hass.states.get(entity_id) assert state @@ -1341,7 +1341,7 @@ async def test_rpc_rgbw_sensors( assert entry assert entry.unique_id == f"123456789ABC-{light_type}:0-voltage_{light_type}" - entity_id = "sensor.test_name_device_temperature" + entity_id = f"sensor.test_name_{light_type}_light_0_device_temperature" state = hass.states.get(entity_id) assert state diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 5891f250fae..17bcd6e3d40 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -236,7 +236,42 @@ async def test_get_block_input_triggers( async def test_get_rpc_channel_name(mock_rpc_device: Mock) -> None: """Test get RPC channel name.""" assert get_rpc_channel_name(mock_rpc_device, "input:0") == "Test name input 0" - assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Test name input_3" + assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Test name Input 3" + + +@pytest.mark.parametrize( + ("component", "expected"), + [ + ("cover", "Cover"), + ("input", "Input"), + ("light", "Light"), + ("rgb", "RGB light"), + ("rgbw", "RGBW light"), + ("switch", "Switch"), + ("thermostat", "Thermostat"), + ], +) +async def test_get_rpc_channel_name_multiple_components( + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, + component: str, + expected: str, +) -> None: + """Test get RPC channel name when there is more components of the same type.""" + config = { + f"{component}:0": {"name": None}, + f"{component}:1": {"name": None}, + } + monkeypatch.setattr(mock_rpc_device, "config", config) + + assert ( + get_rpc_channel_name(mock_rpc_device, f"{component}:0") + == f"Test name {expected} 0" + ) + assert ( + get_rpc_channel_name(mock_rpc_device, f"{component}:1") + == f"Test name {expected} 1" + ) async def test_get_rpc_input_triggers( From f80cc1a247dcbfcecc4dfd3912d2065f3ad21cdb Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Sun, 15 Sep 2024 12:54:23 +0200 Subject: [PATCH 0661/1309] Bump ruff to 0.6.5 (#125923) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a494ee36c2..a63d60a7159 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.4 + rev: v0.6.5 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 1407fda02b5..6ddc0b75320 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.6.4 +ruff==0.6.5 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 4894e333840..d3638015199 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.9,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.1 tqdm==4.66.4 ruff==0.6.4 \ + stdlib-list==0.10.0 pipdeptree==2.23.1 tqdm==4.66.4 ruff==0.6.5 \ PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.9.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From d9812f0d48ac7c38a2404270bdbc7c81af534101 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sun, 15 Sep 2024 14:53:45 +0200 Subject: [PATCH 0662/1309] Fix uv installing in user site packages (#125808) --- homeassistant/util/package.py | 38 ++++++++-- tests/util/test_package.py | 132 ++++++++++++++++++++++++++++++---- 2 files changed, 153 insertions(+), 17 deletions(-) diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 4d87e51badc..3796bf35cd7 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -8,6 +8,7 @@ from importlib.metadata import PackageNotFoundError, version import logging import os from pathlib import Path +import site from subprocess import PIPE, Popen import sys from urllib.parse import urlparse @@ -83,6 +84,12 @@ def is_installed(requirement_str: str) -> bool: return False +_UV_ENV_PYTHON_VARS = ( + "UV_SYSTEM_PYTHON", + "UV_PYTHON", +) + + def install_package( package: str, upgrade: bool = True, @@ -96,7 +103,18 @@ def install_package( """ _LOGGER.info("Attempting install of %s", package) env = os.environ.copy() - args = ["uv", "pip", "install", "--quiet", package] + args = [ + "uv", + "pip", + "install", + "--quiet", + package, + # We need to use unsafe-first-match for custom components + # which can use a different version of a package than the one + # we have built the wheel for. + "--index-strategy", + "unsafe-first-match", + ] if timeout: env["HTTP_TIMEOUT"] = str(timeout) if upgrade: @@ -104,10 +122,20 @@ def install_package( if constraints is not None: args += ["--constraint", constraints] if target: - assert not is_virtual_env() - # This only works if not running in venv - args += ["--user"] - env["PYTHONUSERBASE"] = os.path.abspath(target) + abs_target = os.path.abspath(target) + args += ["--target", abs_target] + elif ( + not is_virtual_env() + and not (any(var in env for var in _UV_ENV_PYTHON_VARS)) + and (abs_target := site.getusersitepackages()) + ): + # Pip compatibility + # Uv has currently no support for --user + # See https://github.com/astral-sh/uv/issues/2077 + # Using workaround to install to site-packages + # https://github.com/astral-sh/uv/issues/2077#issuecomment-2150406001 + args += ["--python", sys.executable, "--target", abs_target] + _LOGGER.debug("Running uv pip command: args=%s", args) with Popen( args, diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 72600f94890..59a02bff838 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -93,7 +93,15 @@ def test_install(mock_popen: MagicMock, mock_env_copy: MagicMock) -> None: assert package.install_package(TEST_NEW_REQ, False) assert mock_popen.call_count == 2 assert mock_popen.mock_calls[0] == call( - ["uv", "pip", "install", "--quiet", TEST_NEW_REQ], + [ + "uv", + "pip", + "install", + "--quiet", + TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", + ], stdin=PIPE, stdout=PIPE, stderr=PIPE, @@ -111,7 +119,15 @@ def test_install_with_timeout(mock_popen: MagicMock, mock_env_copy: MagicMock) - assert mock_popen.call_count == 2 env["HTTP_TIMEOUT"] = "10" assert mock_popen.mock_calls[0] == call( - ["uv", "pip", "install", "--quiet", TEST_NEW_REQ], + [ + "uv", + "pip", + "install", + "--quiet", + TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", + ], stdin=PIPE, stdout=PIPE, stderr=PIPE, @@ -134,6 +150,8 @@ def test_install_upgrade(mock_popen, mock_env_copy) -> None: "install", "--quiet", TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", "--upgrade", ], stdin=PIPE, @@ -145,12 +163,26 @@ def test_install_upgrade(mock_popen, mock_env_copy) -> None: assert mock_popen.return_value.communicate.call_count == 1 -def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: +@pytest.mark.parametrize( + "is_venv", + [ + True, + False, + ], +) +def test_install_target( + mock_sys: MagicMock, + mock_popen: MagicMock, + mock_env_copy: MagicMock, + mock_venv: MagicMock, + is_venv: bool, +) -> None: """Test an install with a target.""" target = "target_folder" env = mock_env_copy() - env["PYTHONUSERBASE"] = os.path.abspath(target) - mock_venv.return_value = False + abs_target = os.path.abspath(target) + env["PYTHONUSERBASE"] = abs_target + mock_venv.return_value = is_venv mock_sys.platform = "linux" args = [ "uv", @@ -158,7 +190,10 @@ def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: "install", "--quiet", TEST_NEW_REQ, - "--user", + "--index-strategy", + "unsafe-first-match", + "--target", + abs_target, ] assert package.install_package(TEST_NEW_REQ, False, target=target) @@ -169,12 +204,83 @@ def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: assert mock_popen.return_value.communicate.call_count == 1 -@pytest.mark.usefixtures("mock_sys", "mock_popen", "mock_env_copy", "mock_venv") -def test_install_target_venv() -> None: - """Test an install with a target in a virtual environment.""" - target = "target_folder" - with pytest.raises(AssertionError): - package.install_package(TEST_NEW_REQ, False, target=target) +@pytest.mark.parametrize( + ("in_venv", "additional_env_vars"), + [ + (True, {}), + (False, {"UV_SYSTEM_PYTHON": "true"}), + (False, {"UV_PYTHON": "python3"}), + (False, {"UV_SYSTEM_PYTHON": "true", "UV_PYTHON": "python3"}), + ], + ids=["in_venv", "UV_SYSTEM_PYTHON", "UV_PYTHON", "UV_SYSTEM_PYTHON and UV_PYTHON"], +) +def test_install_pip_compatibility_no_workaround( + mock_sys: MagicMock, + mock_popen: MagicMock, + mock_env_copy: MagicMock, + mock_venv: MagicMock, + in_venv: bool, + additional_env_vars: dict[str, str], +) -> None: + """Test install will not use pip fallback.""" + env = mock_env_copy() + env.update(additional_env_vars) + mock_venv.return_value = in_venv + mock_sys.platform = "linux" + args = [ + "uv", + "pip", + "install", + "--quiet", + TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", + ] + + assert package.install_package(TEST_NEW_REQ, False) + assert mock_popen.call_count == 2 + assert mock_popen.mock_calls[0] == call( + args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env, close_fds=False + ) + assert mock_popen.return_value.communicate.call_count == 1 + + +def test_install_pip_compatibility_use_workaround( + mock_sys: MagicMock, + mock_popen: MagicMock, + mock_env_copy: MagicMock, + mock_venv: MagicMock, +) -> None: + """Test install will use pip compatibility fallback.""" + env = mock_env_copy() + mock_venv.return_value = False + mock_sys.platform = "linux" + python = "python3" + mock_sys.executable = python + site_dir = "/site_dir" + args = [ + "uv", + "pip", + "install", + "--quiet", + TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", + "--python", + python, + "--target", + site_dir, + ] + + with patch("homeassistant.util.package.site", autospec=True) as site_mock: + site_mock.getusersitepackages.return_value = site_dir + assert package.install_package(TEST_NEW_REQ, False) + + assert mock_popen.call_count == 2 + assert mock_popen.mock_calls[0] == call( + args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env, close_fds=False + ) + assert mock_popen.return_value.communicate.call_count == 1 @pytest.mark.usefixtures("mock_sys", "mock_venv") @@ -202,6 +308,8 @@ def test_install_constraint(mock_popen, mock_env_copy) -> None: "install", "--quiet", TEST_NEW_REQ, + "--index-strategy", + "unsafe-first-match", "--constraint", constraints, ], From e768bea2982a2a50bc2294237e01c530dea36584 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 15 Sep 2024 21:05:59 +0200 Subject: [PATCH 0663/1309] Switch Reolink from hass.data to runtime_data (#126002) Switch from hass.data to runtime_data --- homeassistant/components/reolink/__init__.py | 30 +++++++++---------- .../components/reolink/binary_sensor.py | 8 ++--- homeassistant/components/reolink/button.py | 8 ++--- homeassistant/components/reolink/camera.py | 8 ++--- .../components/reolink/config_flow.py | 13 +++----- .../components/reolink/diagnostics.py | 8 ++--- homeassistant/components/reolink/light.py | 8 ++--- .../components/reolink/media_source.py | 24 ++++++++------- homeassistant/components/reolink/number.py | 8 ++--- homeassistant/components/reolink/select.py | 8 ++--- homeassistant/components/reolink/sensor.py | 8 ++--- homeassistant/components/reolink/services.py | 2 +- homeassistant/components/reolink/siren.py | 8 ++--- homeassistant/components/reolink/switch.py | 7 ++--- homeassistant/components/reolink/update.py | 8 ++--- homeassistant/components/reolink/util.py | 9 +++--- .../components/reolink/test_binary_sensor.py | 5 ++-- tests/components/reolink/test_host.py | 9 ++---- 18 files changed, 75 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index f64c6bd9cf3..0ff69c00f8c 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -9,7 +9,6 @@ import logging from reolink_aio.api import RETRY_ATTEMPTS from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -26,7 +25,7 @@ from .const import DOMAIN from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services -from .util import ReolinkData, get_device_uid_and_ch +from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch _LOGGER = logging.getLogger(__name__) @@ -56,7 +55,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: ReolinkConfigEntry +) -> bool: """Set up Reolink from a config entry.""" host = ReolinkHost(hass, config_entry.data, config_entry.options) @@ -151,7 +152,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await host.stop() raise - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = ReolinkData( + config_entry.runtime_data = ReolinkData( host=host, device_coordinator=device_coordinator, firmware_coordinator=firmware_coordinator, @@ -168,30 +169,29 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def entry_update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def entry_update_listener( + hass: HomeAssistant, config_entry: ReolinkConfigEntry +) -> None: """Update the configuration of the host entity.""" await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: ReolinkConfigEntry +) -> bool: """Unload a config entry.""" - host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host + host: ReolinkHost = config_entry.runtime_data.host await host.stop() - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry ) -> bool: """Remove a device from a config entry.""" - host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host + host: ReolinkHost = config_entry.runtime_data.host (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) if is_chime: diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 70c21849bc2..c11161b11c7 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -20,15 +20,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ReolinkData -from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .util import ReolinkConfigEntry, ReolinkData @dataclass(frozen=True, kw_only=True) @@ -108,11 +106,11 @@ BINARY_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Reolink IP Camera.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data entities: list[ReolinkBinarySensorEntity] = [] for channel in reolink_data.host.api.channels: diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 3340cbad29a..986ac9d872c 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -16,7 +16,6 @@ from homeassistant.components.button import ( ButtonEntityDescription, ) from homeassistant.components.camera import CameraEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -26,14 +25,13 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) -from . import ReolinkData -from .const import DOMAIN from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) +from .util import ReolinkConfigEntry, ReolinkData ATTR_SPEED = "speed" SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM @@ -152,11 +150,11 @@ HOST_BUTTON_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Reolink button entities.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data entities: list[ReolinkButtonEntity | ReolinkHostButtonEntity] = [ ReolinkButtonEntity(reolink_data, channel, entity_description) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 4adac1a96d8..600286be9a2 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -13,14 +13,12 @@ from homeassistant.components.camera import ( CameraEntityDescription, CameraEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ReolinkData -from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .util import ReolinkConfigEntry, ReolinkData _LOGGER = logging.getLogger(__name__) @@ -91,11 +89,11 @@ CAMERA_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Reolink IP Camera.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data entities: list[ReolinkCamera] = [] for entity_description in CAMERA_ENTITIES: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 67db2e50b8a..5b316662a2c 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -11,12 +11,7 @@ from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkErr import voluptuous as vol from homeassistant.components import dhcp -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -37,7 +32,7 @@ from .exceptions import ( UserNotAdmin, ) from .host import ReolinkHost -from .util import is_connected +from .util import ReolinkConfigEntry, is_connected _LOGGER = logging.getLogger(__name__) @@ -48,7 +43,7 @@ DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL} class ReolinkOptionsFlowHandler(OptionsFlow): """Handle Reolink options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: ReolinkConfigEntry) -> None: """Initialize ReolinkOptionsFlowHandler.""" self.config_entry = config_entry @@ -104,7 +99,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, ) -> ReolinkOptionsFlowHandler: """Options callback for Reolink.""" return ReolinkOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index b06ddcd458f..693f2ba59a4 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -4,18 +4,16 @@ from __future__ import annotations from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import ReolinkData -from .const import DOMAIN +from .util import ReolinkConfigEntry, ReolinkData async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data host = reolink_data.host api = host.api diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index fe34cccc0c4..e7f3d3e5d1a 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -15,15 +15,13 @@ from homeassistant.components.light import ( LightEntity, LightEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ReolinkData -from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .util import ReolinkConfigEntry, ReolinkData @dataclass(frozen=True, kw_only=True) @@ -64,11 +62,11 @@ LIGHT_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Reolink light entities.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data async_add_entities( ReolinkLightEntity(reolink_data, channel, entity_description) diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 57c2a695c77..9280df0f5bd 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -22,8 +22,8 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ReolinkData from .const import DOMAIN +from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) @@ -46,6 +46,13 @@ def res_name(stream: str) -> str: return "Low res." +def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost: + """Return the Reolink host from the config entry id.""" + config_entry = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry is not None + return config_entry.runtime_data.host + + class ReolinkVODMediaSource(MediaSource): """Provide Reolink camera VODs as media sources.""" @@ -65,8 +72,7 @@ class ReolinkVODMediaSource(MediaSource): _, config_entry_id, channel_str, stream_res, filename = identifier channel = int(channel_str) - data: dict[str, ReolinkData] = self.hass.data[DOMAIN] - host = data[config_entry_id].host + host = get_host(self.hass, config_entry_id) def get_vod_type() -> VodRequestType: if filename.endswith(".mp4"): @@ -151,8 +157,7 @@ class ReolinkVODMediaSource(MediaSource): if config_entry.state != ConfigEntryState.LOADED: continue channels: list[str] = [] - data: dict[str, ReolinkData] = self.hass.data[DOMAIN] - host = data[config_entry.entry_id].host + host = config_entry.runtime_data.host entities = er.async_entries_for_config_entry( entity_reg, config_entry.entry_id ) @@ -213,8 +218,7 @@ class ReolinkVODMediaSource(MediaSource): self, config_entry_id: str, channel: int ) -> BrowseMediaSource: """Allow the user to select the high or low playback resolution, (low loads faster).""" - data: dict[str, ReolinkData] = self.hass.data[DOMAIN] - host = data[config_entry_id].host + host = get_host(self.hass, config_entry_id) main_enc = await host.api.get_encoding(channel, "main") if main_enc == "h265": @@ -297,8 +301,7 @@ class ReolinkVODMediaSource(MediaSource): self, config_entry_id: str, channel: int, stream: str ) -> BrowseMediaSource: """Return all days on which recordings are available for a reolink camera.""" - data: dict[str, ReolinkData] = self.hass.data[DOMAIN] - host = data[config_entry_id].host + host = get_host(self.hass, config_entry_id) # We want today of the camera, not necessarily today of the server now = host.api.time() or await host.api.async_get_time() @@ -354,8 +357,7 @@ class ReolinkVODMediaSource(MediaSource): day: int, ) -> BrowseMediaSource: """Return all recording files on a specific day of a Reolink camera.""" - data: dict[str, ReolinkData] = self.hass.data[DOMAIN] - host = data[config_entry_id].host + host = get_host(self.hass, config_entry_id) start = dt.datetime(year, month, day, hour=0, minute=0, second=0) end = dt.datetime(year, month, day, hour=23, minute=59, second=59) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 1dc99c886e1..a55f0d440a1 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -14,19 +14,17 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ReolinkData -from .const import DOMAIN from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ) +from .util import ReolinkConfigEntry, ReolinkData @dataclass(frozen=True, kw_only=True) @@ -492,11 +490,11 @@ CHIME_NUMBER_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Reolink number entities.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data entities: list[ReolinkNumberEntity | ReolinkChimeNumberEntity] = [ ReolinkNumberEntity(reolink_data, channel, entity_description) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 94cfdf6751b..8a2c977ede3 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -20,19 +20,17 @@ from reolink_aio.api import ( from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ReolinkData -from .const import DOMAIN from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ) +from .util import ReolinkConfigEntry, ReolinkData _LOGGER = logging.getLogger(__name__) @@ -183,11 +181,11 @@ CHIME_SELECT_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Reolink select entities.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data entities: list[ReolinkSelectEntity | ReolinkChimeSelectEntity] = [ ReolinkSelectEntity(reolink_data, channel, entity_description) diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 988b091735e..1e2d75ed849 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -16,20 +16,18 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import ReolinkData -from .const import DOMAIN from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) +from .util import ReolinkConfigEntry, ReolinkData @dataclass(frozen=True, kw_only=True) @@ -126,11 +124,11 @@ HDD_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Reolink IP Camera.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data entities: list[ ReolinkSensorEntity | ReolinkHostSensorEntity | ReolinkHddSensorEntity diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py index d5cb402c74b..326093e7a93 100644 --- a/homeassistant/components/reolink/services.py +++ b/homeassistant/components/reolink/services.py @@ -47,7 +47,7 @@ def async_setup_services(hass: HomeAssistant) -> None: translation_key="service_entry_ex", translation_placeholders={"service_name": "play_chime"}, ) - host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host + host: ReolinkHost = config_entry.runtime_data.host (device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host) chime: Chime | None = host.api.chime(chime_id) if not is_chime or chime is None: diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index 269c0690105..45f435c1f2c 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -14,14 +14,12 @@ from homeassistant.components.siren import ( SirenEntityDescription, SirenEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ReolinkData -from .const import DOMAIN from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .util import ReolinkConfigEntry, ReolinkData @dataclass(frozen=True) @@ -42,11 +40,11 @@ SIREN_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Reolink siren entities.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data async_add_entities( ReolinkSirenEntity(reolink_data, channel, entity_description) diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 2bf7689b32f..c3e945c7de8 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -10,14 +10,12 @@ from reolink_aio.api import Chime, Host from reolink_aio.exceptions import ReolinkError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ReolinkData from .const import DOMAIN from .entity import ( ReolinkChannelCoordinatorEntity, @@ -26,6 +24,7 @@ from .entity import ( ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) +from .util import ReolinkConfigEntry, ReolinkData @dataclass(frozen=True, kw_only=True) @@ -283,11 +282,11 @@ DEPRECATED_HDR = ReolinkSwitchEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Reolink switch entities.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data entities: list[ ReolinkSwitchEntity | ReolinkNVRSwitchEntity | ReolinkChimeSwitchEntity diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py index 3c1e70612a7..5738411fa72 100644 --- a/homeassistant/components/reolink/update.py +++ b/homeassistant/components/reolink/update.py @@ -15,20 +15,18 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from . import ReolinkData -from .const import DOMAIN from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) +from .util import ReolinkConfigEntry, ReolinkData POLL_AFTER_INSTALL = 120 @@ -68,11 +66,11 @@ HOST_UPDATE_ENTITIES = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ReolinkConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up update entities for Reolink component.""" - reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + reolink_data: ReolinkData = config_entry.runtime_data entities: list[ReolinkUpdateEntity | ReolinkHostUpdateEntity] = [ ReolinkUpdateEntity(reolink_data, channel, entity_description) diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index 305579e35cb..98c0e7b925b 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -12,6 +12,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN from .host import ReolinkHost +type ReolinkConfigEntry = config_entries.ConfigEntry[ReolinkData] + @dataclass class ReolinkData: @@ -24,13 +26,10 @@ class ReolinkData: def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) -> bool: """Check if an existing entry has a proper connection.""" - reolink_data: ReolinkData | None = hass.data.get(DOMAIN, {}).get( - config_entry.entry_id - ) return ( - reolink_data is not None + hasattr(config_entry, "runtime_data") and config_entry.state == config_entries.ConfigEntryState.LOADED - and reolink_data.device_coordinator.last_update_success + and config_entry.runtime_data.device_coordinator.last_update_success ) diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 893e58a9512..a2c5ba07aa8 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -5,13 +5,12 @@ from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL -from homeassistant.components.reolink.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import TEST_DUO_MODEL, TEST_NVR_NAME, TEST_UID +from .conftest import TEST_DUO_MODEL, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator @@ -46,7 +45,7 @@ async def test_motion_sensor( # test webhook callback reolink_connect.motion_detected.return_value = True reolink_connect.ONVIF_event_callback.return_value = [0] - webhook_id = f"{DOMAIN}_{TEST_UID.replace(':', '')}_ONVIF" + webhook_id = config_entry.runtime_data.host.webhook_id client = await hass_client_no_auth() await client.post(f"/api/webhook/{webhook_id}", data="test_data") diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index 64c3fe5c1b7..639d5bf046f 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -11,7 +11,6 @@ from reolink_aio.enums import SubType from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL -from homeassistant.components.reolink.const import DOMAIN from homeassistant.components.reolink.host import ( FIRST_ONVIF_LONG_POLL_TIMEOUT, FIRST_ONVIF_TIMEOUT, @@ -27,8 +26,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError from homeassistant.util.aiohttp import MockRequest -from .conftest import TEST_UID - from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -47,7 +44,7 @@ async def test_webhook_callback( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - webhook_id = f"{DOMAIN}_{TEST_UID.replace(':', '')}_ONVIF" + webhook_id = config_entry.runtime_data.host.webhook_id signal_all = MagicMock() signal_ch = MagicMock() @@ -276,7 +273,7 @@ async def test_long_poll_stop_when_push( # simulate ONVIF push callback client = await hass_client_no_auth() reolink_connect.ONVIF_event_callback.return_value = None - webhook_id = f"{DOMAIN}_{TEST_UID.replace(':', '')}_ONVIF" + webhook_id = config_entry.runtime_data.host.webhook_id await client.post(f"/api/webhook/{webhook_id}") freezer.tick(DEVICE_UPDATE_INTERVAL) @@ -379,7 +376,7 @@ async def test_diagnostics_event_connection( # simulate ONVIF push callback client = await hass_client_no_auth() reolink_connect.ONVIF_event_callback.return_value = None - webhook_id = f"{DOMAIN}_{TEST_UID.replace(':', '')}_ONVIF" + webhook_id = config_entry.runtime_data.host.webhook_id await client.post(f"/api/webhook/{webhook_id}") diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) From 089c942233679f40df514b4f5cb93429c35d9a45 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 15 Sep 2024 21:26:33 +0200 Subject: [PATCH 0664/1309] Bump plugwise to v1.4.0 (#125998) * Refresh plugwise test-fixtures * Update test-diagnostics file * Bump plugwise to v1.4.0 --- .../components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../all_data.json | 43 ++- .../anna_heatpump_heating/all_data.json | 3 +- .../fixtures/m_adam_cooling/all_data.json | 14 +- .../fixtures/m_adam_heating/all_data.json | 14 +- .../fixtures/m_adam_jip/all_data.json | 29 +- .../m_anna_heatpump_cooling/all_data.json | 3 +- .../m_anna_heatpump_idle/all_data.json | 3 +- .../fixtures/p1v4_442_single/all_data.json | 3 +- .../fixtures/p1v4_442_triple/all_data.json | 3 +- .../fixtures/stretch_v23/all_data.json | 340 ++++++++++++++++++ .../plugwise/snapshots/test_diagnostics.ambr | 43 ++- 14 files changed, 487 insertions(+), 17 deletions(-) create mode 100644 tests/components/plugwise/fixtures/stretch_v23/all_data.json diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 6ac5254b424..b1ce8961110 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["plugwise"], - "requirements": ["plugwise==1.0.0"], + "requirements": ["plugwise==1.4.0"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index c95908b2bcf..a1a204165d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1603,7 +1603,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.0.0 +plugwise==1.4.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 200d793f5b6..0f4685efe9b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1310,7 +1310,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.0.0 +plugwise==1.4.0 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 9c17df5072d..374c75ee338 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -6,6 +6,7 @@ "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", + "model_id": "160-01", "name": "NVR", "sensors": { "electricity_consumed": 34.0, @@ -26,6 +27,7 @@ "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", + "model_id": "160-01", "name": "Playstation Smart Plug", "sensors": { "electricity_consumed": 84.1, @@ -46,6 +48,7 @@ "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", + "model_id": "160-01", "name": "USG Smart Plug", "sensors": { "electricity_consumed": 8.5, @@ -66,6 +69,7 @@ "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", + "model_id": "160-01", "name": "Ziggo Modem", "sensors": { "electricity_consumed": 12.2, @@ -82,11 +86,15 @@ }, "680423ff840043738f42cc7f1ff97a36": { "available": true, + "binary_sensors": { + "low_battery": false + }, "dev_class": "thermo_sensor", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "08963fec7c53423ca5680aa4cb502c63", "model": "Tom/Floor", + "model_id": "106-03", "name": "Thermostatic Radiator Badkamer", "sensors": { "battery": 51, @@ -115,12 +123,16 @@ "CV Jessie", "off" ], + "binary_sensors": { + "low_battery": false + }, "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "82fa13f017d240daa0d0ea1775420f24", "mode": "auto", "model": "Lisa", + "model_id": "158-01", "name": "Zone Thermostat Jessie", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "select_schedule": "CV Jessie", @@ -150,6 +162,7 @@ "firmware": "2019-06-21T02:00:00+02:00", "location": "c50f167537524366a5af7aa3942feb1e", "model": "Plug", + "model_id": "160-01", "name": "CV Pomp", "sensors": { "electricity_consumed": 35.6, @@ -183,6 +196,7 @@ "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", + "model_id": "160-01", "name": "Fibaro HC2", "sensors": { "electricity_consumed": 12.5, @@ -199,11 +213,15 @@ }, "a2c3583e0a6349358998b760cea82d2a": { "available": true, + "binary_sensors": { + "low_battery": false + }, "dev_class": "thermo_sensor", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "12493538af164a409c6a1c79e38afe1c", "model": "Tom/Floor", + "model_id": "106-03", "name": "Bios Cv Thermostatic Radiator ", "sensors": { "battery": 62, @@ -228,6 +246,7 @@ "hardware": "1", "location": "c50f167537524366a5af7aa3942feb1e", "model": "Tom/Floor", + "model_id": "106-03", "name": "Floor kraan", "sensors": { "setpoint": 21.5, @@ -255,12 +274,16 @@ "CV Jessie", "off" ], + "binary_sensors": { + "low_battery": false + }, "dev_class": "zone_thermostat", "firmware": "2016-08-02T02:00:00+02:00", "hardware": "255", "location": "c50f167537524366a5af7aa3942feb1e", "mode": "auto", "model": "Lisa", + "model_id": "158-01", "name": "Zone Lisa WK", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "select_schedule": "GF7 Woonkamer", @@ -290,6 +313,7 @@ "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", + "model_id": "160-01", "name": "NAS", "sensors": { "electricity_consumed": 16.5, @@ -306,11 +330,15 @@ }, "d3da73bde12a47d5a6b8f9dad971f2ec": { "available": true, + "binary_sensors": { + "low_battery": false + }, "dev_class": "thermo_sensor", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "82fa13f017d240daa0d0ea1775420f24", "model": "Tom/Floor", + "model_id": "106-03", "name": "Thermostatic Radiator Jessie", "sensors": { "battery": 62, @@ -339,12 +367,16 @@ "CV Jessie", "off" ], + "binary_sensors": { + "low_battery": false + }, "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "12493538af164a409c6a1c79e38afe1c", "mode": "heat", "model": "Lisa", + "model_id": "158-01", "name": "Zone Lisa Bios", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "select_schedule": "off", @@ -379,12 +411,16 @@ "CV Jessie", "off" ], + "binary_sensors": { + "low_battery": false + }, "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", "location": "446ac08dd04d4eff8ac57489757b7314", "mode": "heat", "model": "Tom/Floor", + "model_id": "106-03", "name": "CV Kraan Garage", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "select_schedule": "off", @@ -421,12 +457,16 @@ "CV Jessie", "off" ], + "binary_sensors": { + "low_battery": false + }, "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", "location": "08963fec7c53423ca5680aa4cb502c63", "mode": "auto", "model": "Lisa", + "model_id": "158-01", "name": "Zone Thermostat Badkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "select_schedule": "Badkamer Schema", @@ -460,6 +500,7 @@ "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", "mac_address": "012345670001", "model": "Gateway", + "model_id": "smile_open_therm", "name": "Adam", "select_regulation_mode": "heating", "sensors": { @@ -473,7 +514,7 @@ "cooling_present": false, "gateway_id": "fe799307f1624099878210aa0b9f1475", "heater_id": "90986d591dcd426cae3ec3e8111ff730", - "item_count": 315, + "item_count": 340, "notifications": { "af82e4ccf9c548528166d38e560662a4": { "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index 5088281404a..b767f5531f2 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -10,6 +10,7 @@ "location": "a57efe5f145f498c9be62a9b63626fbf", "mac_address": "012345670001", "model": "Gateway", + "model_id": "smile_thermo", "name": "Smile Anna", "sensors": { "outdoor_temperature": 20.2 @@ -97,7 +98,7 @@ "cooling_present": true, "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "item_count": 66, + "item_count": 67, "notifications": {}, "reboot": true, "smile_name": "Smile Anna" diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index 759d0094dbb..166b13b84ff 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -28,11 +28,15 @@ }, "1772a4ea304041adb83f357b751341ff": { "available": true, + "binary_sensors": { + "low_battery": false + }, "dev_class": "thermo_sensor", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "f871b8c4d63549319221e294e4f88074", "model": "Tom/Floor", + "model_id": "106-03", "name": "Tom Badkamer", "sensors": { "battery": 99, @@ -64,6 +68,7 @@ "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "cool", "model": "ThermoTouch", + "model_id": "143.1", "name": "Anna", "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "off", @@ -90,6 +95,7 @@ "location": "bc93488efab249e5bc54fd7e175a6f91", "mac_address": "012345679891", "model": "Gateway", + "model_id": "smile_open_therm", "name": "Adam", "regulation_modes": [ "bleeding_hot", @@ -116,6 +122,9 @@ "Weekschema", "off" ], + "binary_sensors": { + "low_battery": true + }, "control_state": "preheating", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", @@ -123,11 +132,12 @@ "location": "f871b8c4d63549319221e294e4f88074", "mode": "auto", "model": "Lisa", + "model_id": "158-01", "name": "Lisa Badkamer", "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "Badkamer", "sensors": { - "battery": 38, + "battery": 14, "setpoint": 23.5, "temperature": 23.9 }, @@ -163,7 +173,7 @@ "cooling_present": true, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 147, + "item_count": 157, "notifications": {}, "reboot": true, "smile_name": "Adam" diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index e2c23df42d6..61935f1306a 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -33,11 +33,15 @@ }, "1772a4ea304041adb83f357b751341ff": { "available": true, + "binary_sensors": { + "low_battery": false + }, "dev_class": "thermo_sensor", "firmware": "2020-11-04T01:00:00+01:00", "hardware": "1", "location": "f871b8c4d63549319221e294e4f88074", "model": "Tom/Floor", + "model_id": "106-03", "name": "Tom Badkamer", "sensors": { "battery": 99, @@ -69,6 +73,7 @@ "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat", "model": "ThermoTouch", + "model_id": "143.1", "name": "Anna", "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "off", @@ -95,6 +100,7 @@ "location": "bc93488efab249e5bc54fd7e175a6f91", "mac_address": "012345679891", "model": "Gateway", + "model_id": "smile_open_therm", "name": "Adam", "regulation_modes": ["bleeding_hot", "bleeding_cold", "off", "heating"], "select_gateway_mode": "full", @@ -115,6 +121,9 @@ "Weekschema", "off" ], + "binary_sensors": { + "low_battery": true + }, "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", @@ -122,11 +131,12 @@ "location": "f871b8c4d63549319221e294e4f88074", "mode": "auto", "model": "Lisa", + "model_id": "158-01", "name": "Lisa Badkamer", "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], "select_schedule": "Badkamer", "sensors": { - "battery": 38, + "battery": 14, "setpoint": 15.0, "temperature": 17.9 }, @@ -162,7 +172,7 @@ "cooling_present": false, "gateway_id": "da224107914542988a88561b4452b0f6", "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 147, + "item_count": 157, "notifications": {}, "reboot": true, "smile_name": "Adam" diff --git a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json index 7888d777804..50c3fa5a7dc 100644 --- a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json @@ -3,6 +3,9 @@ "1346fbd8498d4dbcab7e18d51b771f3d": { "active_preset": "no_frost", "available": true, + "binary_sensors": { + "low_battery": false + }, "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", @@ -10,6 +13,7 @@ "location": "06aecb3d00354375924f50c47af36bd2", "mode": "off", "model": "Lisa", + "model_id": "158-01", "name": "Slaapkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { @@ -39,6 +43,7 @@ "hardware": "1", "location": "d58fec52899f4f1c92e4f8fad6d8c48c", "model": "Tom/Floor", + "model_id": "106-03", "name": "Tom Logeerkamer", "sensors": { "setpoint": 13.0, @@ -62,6 +67,7 @@ "hardware": "1", "location": "06aecb3d00354375924f50c47af36bd2", "model": "Tom/Floor", + "model_id": "106-03", "name": "Tom Slaapkamer", "sensors": { "setpoint": 13.0, @@ -82,7 +88,8 @@ "available": true, "dev_class": "zz_misc", "location": "9e4433a9d69f40b3aefd15e74395eaec", - "model": "lumi.plug.maeu01", + "model": "Aqara Smart Plug", + "model_id": "lumi.plug.maeu01", "name": "Plug", "sensors": { "electricity_consumed_interval": 0.0 @@ -97,6 +104,9 @@ "6f3e9d7084214c21b9dfa46f6eeb8700": { "active_preset": "home", "available": true, + "binary_sensors": { + "low_battery": false + }, "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", @@ -104,6 +114,7 @@ "location": "d27aede973b54be484f6842d1b2802ad", "mode": "heat", "model": "Lisa", + "model_id": "158-01", "name": "Kinderkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { @@ -133,6 +144,7 @@ "hardware": "1", "location": "13228dab8ce04617af318a2888b3c548", "model": "Tom/Floor", + "model_id": "106-03", "name": "Tom Woonkamer", "sensors": { "setpoint": 9.0, @@ -152,6 +164,9 @@ "a6abc6a129ee499c88a4d420cc413b47": { "active_preset": "home", "available": true, + "binary_sensors": { + "low_battery": false + }, "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", @@ -159,6 +174,7 @@ "location": "d58fec52899f4f1c92e4f8fad6d8c48c", "mode": "heat", "model": "Lisa", + "model_id": "158-01", "name": "Logeerkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { @@ -192,6 +208,7 @@ "location": "9e4433a9d69f40b3aefd15e74395eaec", "mac_address": "012345670001", "model": "Gateway", + "model_id": "smile_open_therm", "name": "Adam", "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], "select_gateway_mode": "full", @@ -209,6 +226,7 @@ "hardware": "1", "location": "d27aede973b54be484f6842d1b2802ad", "model": "Tom/Floor", + "model_id": "106-03", "name": "Tom Kinderkamer", "sensors": { "setpoint": 13.0, @@ -246,7 +264,8 @@ "setpoint": 90.0, "upper_bound": 90.0 }, - "model": "10.20", + "model": "Generic heater", + "model_id": "10.20", "name": "OpenTherm", "sensors": { "intended_boiler_temperature": 0.0, @@ -263,6 +282,9 @@ "f61f1a2535f54f52ad006a3d18e459ca": { "active_preset": "home", "available": true, + "binary_sensors": { + "low_battery": false + }, "control_state": "off", "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", @@ -270,6 +292,7 @@ "location": "13228dab8ce04617af318a2888b3c548", "mode": "heat", "model": "Jip", + "model_id": "168-01", "name": "Woonkamer", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], "sensors": { @@ -298,7 +321,7 @@ "cooling_present": false, "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", "heater_id": "e4684553153b44afbef2200885f379dc", - "item_count": 213, + "item_count": 228, "notifications": {}, "reboot": true, "smile_name": "Adam" diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index cb30b919797..05f5e0ffa46 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -10,6 +10,7 @@ "location": "a57efe5f145f498c9be62a9b63626fbf", "mac_address": "012345670001", "model": "Gateway", + "model_id": "smile_thermo", "name": "Smile Anna", "sensors": { "outdoor_temperature": 28.2 @@ -97,7 +98,7 @@ "cooling_present": true, "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "item_count": 66, + "item_count": 67, "notifications": {}, "reboot": true, "smile_name": "Smile Anna" diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 660f6b5a76b..327a87f9409 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -10,6 +10,7 @@ "location": "a57efe5f145f498c9be62a9b63626fbf", "mac_address": "012345670001", "model": "Gateway", + "model_id": "smile_thermo", "name": "Smile Anna", "sensors": { "outdoor_temperature": 28.2 @@ -97,7 +98,7 @@ "cooling_present": true, "gateway_id": "015ae9ea3f964e668e490fa39da3870b", "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "item_count": 66, + "item_count": 67, "notifications": {}, "reboot": true, "smile_name": "Smile Anna" diff --git a/tests/components/plugwise/fixtures/p1v4_442_single/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_single/all_data.json index 7f152779252..3ea4bb01be2 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_single/all_data.json +++ b/tests/components/plugwise/fixtures/p1v4_442_single/all_data.json @@ -10,6 +10,7 @@ "location": "a455b61e52394b2db5081ce025a430f3", "mac_address": "012345670001", "model": "Gateway", + "model_id": "smile", "name": "Smile P1", "vendor": "Plugwise" }, @@ -42,7 +43,7 @@ }, "gateway": { "gateway_id": "a455b61e52394b2db5081ce025a430f3", - "item_count": 31, + "item_count": 32, "notifications": {}, "reboot": true, "smile_name": "Smile P1" diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json index 582c883a3a7..b7476b24a1e 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json @@ -10,6 +10,7 @@ "location": "03e65b16e4b247a29ae0d75a78cb492e", "mac_address": "012345670001", "model": "Gateway", + "model_id": "smile", "name": "Smile P1", "vendor": "Plugwise" }, @@ -51,7 +52,7 @@ }, "gateway": { "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", - "item_count": 40, + "item_count": 41, "notifications": { "97a04c0c263049b29350a660b4cdd01e": { "warning": "The Smile P1 is not connected to a smart meter." diff --git a/tests/components/plugwise/fixtures/stretch_v23/all_data.json b/tests/components/plugwise/fixtures/stretch_v23/all_data.json new file mode 100644 index 00000000000..27142c7111f --- /dev/null +++ b/tests/components/plugwise/fixtures/stretch_v23/all_data.json @@ -0,0 +1,340 @@ +{ + "devices": { + "0000aaaa0000aaaa0000aaaa0000aa00": { + "dev_class": "gateway", + "firmware": "2.3.12", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "mac_address": "01:23:45:67:89:AB", + "model": "Gateway", + "name": "Stretch", + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101" + }, + "09c8ce93d7064fa6a233c0e4c2449bfe": { + "dev_class": "lamp", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "kerstboom buiten 043B016", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": false + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01" + }, + "199fd4b2caa44197aaf5b3128f6464ed": { + "dev_class": "airconditioner", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4026", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Airco 25F69E3", + "sensors": { + "electricity_consumed": 2.06, + "electricity_consumed_interval": 1.62, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A10" + }, + "24b2ed37c8964c73897db6340a39c129": { + "dev_class": "router", + "firmware": "2011-06-27T10:47:37+02:00", + "hardware": "6539-0700-7325", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle+ type F", + "name": "MK Netwerk 1A4455E", + "sensors": { + "electricity_consumed": 4.63, + "electricity_consumed_interval": 0.65, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "0123456789AB" + }, + "2587a7fcdd7e482dab03fda256076b4b": { + "dev_class": "zz_misc", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "00469CA1", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A16" + }, + "2cc9a0fe70ef4441a9e4f55dfd64b776": { + "dev_class": "lamp", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4026", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Lamp TV 025F698F", + "sensors": { + "electricity_consumed": 4.0, + "electricity_consumed_interval": 0.58, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A15" + }, + "305452ce97c243c0a7b4ab2a4ebfe6e3": { + "dev_class": "lamp", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4026", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Lamp piano 025F6819", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": false, + "relay": false + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05" + }, + "33a1c784a9ff4c2d8766a0212714be09": { + "dev_class": "lighting", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4026", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Barverlichting", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": false, + "relay": false + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A13" + }, + "407aa1c1099d463c9137a3a9eda787fd": { + "dev_class": "zz_misc", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "0043B013", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": false + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09" + }, + "6518f3f72a82486c97b91e26f2e9bd1d": { + "dev_class": "charger", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4026", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Bed 025F6768", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A14" + }, + "713427748874454ca1eb4488d7919cf2": { + "dev_class": "freezer", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Leeg 043220D", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": false + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A12" + }, + "71e3e65ffc5a41518b19460c6e8ee34f": { + "dev_class": "tv", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Leeg 043AEC6", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": false + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08" + }, + "828f6ce1e36744689baacdd6ddb1d12c": { + "dev_class": "washingmachine", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Wasmachine 043AEC7", + "sensors": { + "electricity_consumed": 3.5, + "electricity_consumed_interval": 0.5, + "electricity_produced": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02" + }, + "a28e6f5afc0e4fc68498c1f03e82a052": { + "dev_class": "lamp", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4026", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Lamp bank 25F67F8", + "sensors": { + "electricity_consumed": 4.19, + "electricity_consumed_interval": 0.62, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A03" + }, + "bc0adbebc50d428d9444a5d805c89da9": { + "dev_class": "watercooker", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Waterkoker 043AF7F", + "sensors": { + "electricity_consumed": 0.0, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07" + }, + "c71f1cb2100b42ca942f056dcb7eb01f": { + "dev_class": "tv", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "6539-0701-4026", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Tv hoek 25F6790", + "sensors": { + "electricity_consumed": 33.3, + "electricity_consumed_interval": 4.93, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0 + }, + "switches": { + "lock": false, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A11" + }, + "f7b145c8492f4dd7a4de760456fdef3e": { + "dev_class": "switching", + "members": ["407aa1c1099d463c9137a3a9eda787fd"], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": false + } + }, + "fd1b74f59e234a9dae4e23b2b5cf07ed": { + "dev_class": "dryer", + "firmware": "2011-06-27T10:52:18+02:00", + "hardware": "0000-0440-0107", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "model": "Circle type F", + "name": "Wasdroger 043AECA", + "sensors": { + "electricity_consumed": 1.31, + "electricity_consumed_interval": 0.21, + "electricity_produced": 0.0 + }, + "switches": { + "lock": true, + "relay": true + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A04" + } + }, + "gateway": { + "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", + "item_count": 229, + "smile_name": "Stretch" + } +} diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 44f4023d014..fda8c62b66d 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -8,6 +8,7 @@ 'firmware': '2019-06-21T02:00:00+02:00', 'location': 'cd143c07248f491493cea0533bc3d669', 'model': 'Plug', + 'model_id': '160-01', 'name': 'NVR', 'sensors': dict({ 'electricity_consumed': 34.0, @@ -28,6 +29,7 @@ 'firmware': '2019-06-21T02:00:00+02:00', 'location': 'cd143c07248f491493cea0533bc3d669', 'model': 'Plug', + 'model_id': '160-01', 'name': 'Playstation Smart Plug', 'sensors': dict({ 'electricity_consumed': 84.1, @@ -48,6 +50,7 @@ 'firmware': '2019-06-21T02:00:00+02:00', 'location': 'cd143c07248f491493cea0533bc3d669', 'model': 'Plug', + 'model_id': '160-01', 'name': 'USG Smart Plug', 'sensors': dict({ 'electricity_consumed': 8.5, @@ -68,6 +71,7 @@ 'firmware': '2019-06-21T02:00:00+02:00', 'location': 'cd143c07248f491493cea0533bc3d669', 'model': 'Plug', + 'model_id': '160-01', 'name': 'Ziggo Modem', 'sensors': dict({ 'electricity_consumed': 12.2, @@ -84,11 +88,15 @@ }), '680423ff840043738f42cc7f1ff97a36': dict({ 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), 'dev_class': 'thermo_sensor', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '08963fec7c53423ca5680aa4cb502c63', 'model': 'Tom/Floor', + 'model_id': '106-03', 'name': 'Thermostatic Radiator Badkamer', 'sensors': dict({ 'battery': 51, @@ -117,12 +125,16 @@ 'CV Jessie', 'off', ]), + 'binary_sensors': dict({ + 'low_battery': False, + }), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', 'location': '82fa13f017d240daa0d0ea1775420f24', 'mode': 'auto', 'model': 'Lisa', + 'model_id': '158-01', 'name': 'Zone Thermostat Jessie', 'preset_modes': list([ 'home', @@ -158,6 +170,7 @@ 'firmware': '2019-06-21T02:00:00+02:00', 'location': 'c50f167537524366a5af7aa3942feb1e', 'model': 'Plug', + 'model_id': '160-01', 'name': 'CV Pomp', 'sensors': dict({ 'electricity_consumed': 35.6, @@ -191,6 +204,7 @@ 'firmware': '2019-06-21T02:00:00+02:00', 'location': 'cd143c07248f491493cea0533bc3d669', 'model': 'Plug', + 'model_id': '160-01', 'name': 'Fibaro HC2', 'sensors': dict({ 'electricity_consumed': 12.5, @@ -207,11 +221,15 @@ }), 'a2c3583e0a6349358998b760cea82d2a': dict({ 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), 'dev_class': 'thermo_sensor', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '12493538af164a409c6a1c79e38afe1c', 'model': 'Tom/Floor', + 'model_id': '106-03', 'name': 'Bios Cv Thermostatic Radiator ', 'sensors': dict({ 'battery': 62, @@ -236,6 +254,7 @@ 'hardware': '1', 'location': 'c50f167537524366a5af7aa3942feb1e', 'model': 'Tom/Floor', + 'model_id': '106-03', 'name': 'Floor kraan', 'sensors': dict({ 'setpoint': 21.5, @@ -263,12 +282,16 @@ 'CV Jessie', 'off', ]), + 'binary_sensors': dict({ + 'low_battery': False, + }), 'dev_class': 'zone_thermostat', 'firmware': '2016-08-02T02:00:00+02:00', 'hardware': '255', 'location': 'c50f167537524366a5af7aa3942feb1e', 'mode': 'auto', 'model': 'Lisa', + 'model_id': '158-01', 'name': 'Zone Lisa WK', 'preset_modes': list([ 'home', @@ -304,6 +327,7 @@ 'firmware': '2019-06-21T02:00:00+02:00', 'location': 'cd143c07248f491493cea0533bc3d669', 'model': 'Plug', + 'model_id': '160-01', 'name': 'NAS', 'sensors': dict({ 'electricity_consumed': 16.5, @@ -320,11 +344,15 @@ }), 'd3da73bde12a47d5a6b8f9dad971f2ec': dict({ 'available': True, + 'binary_sensors': dict({ + 'low_battery': False, + }), 'dev_class': 'thermo_sensor', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '82fa13f017d240daa0d0ea1775420f24', 'model': 'Tom/Floor', + 'model_id': '106-03', 'name': 'Thermostatic Radiator Jessie', 'sensors': dict({ 'battery': 62, @@ -353,12 +381,16 @@ 'CV Jessie', 'off', ]), + 'binary_sensors': dict({ + 'low_battery': False, + }), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', 'location': '12493538af164a409c6a1c79e38afe1c', 'mode': 'heat', 'model': 'Lisa', + 'model_id': '158-01', 'name': 'Zone Lisa Bios', 'preset_modes': list([ 'home', @@ -399,12 +431,16 @@ 'CV Jessie', 'off', ]), + 'binary_sensors': dict({ + 'low_battery': False, + }), 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', 'location': '446ac08dd04d4eff8ac57489757b7314', 'mode': 'heat', 'model': 'Tom/Floor', + 'model_id': '106-03', 'name': 'CV Kraan Garage', 'preset_modes': list([ 'home', @@ -447,12 +483,16 @@ 'CV Jessie', 'off', ]), + 'binary_sensors': dict({ + 'low_battery': False, + }), 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', 'location': '08963fec7c53423ca5680aa4cb502c63', 'mode': 'auto', 'model': 'Lisa', + 'model_id': '158-01', 'name': 'Zone Thermostat Badkamer', 'preset_modes': list([ 'home', @@ -492,6 +532,7 @@ 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', 'mac_address': '012345670001', 'model': 'Gateway', + 'model_id': 'smile_open_therm', 'name': 'Adam', 'select_regulation_mode': 'heating', 'sensors': dict({ @@ -505,7 +546,7 @@ 'cooling_present': False, 'gateway_id': 'fe799307f1624099878210aa0b9f1475', 'heater_id': '90986d591dcd426cae3ec3e8111ff730', - 'item_count': 315, + 'item_count': 340, 'notifications': dict({ 'af82e4ccf9c548528166d38e560662a4': dict({ 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", From fccbaa0fbc92bbb3bbcedf40cf1af1dc512ead6a Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 16 Sep 2024 07:07:40 +0200 Subject: [PATCH 0665/1309] Add calendar to Husqvarna Automower (#120775) * Add Calendar * update * change timezone for tests * fix requirements * bump aioautomower to 2024.6.3b0 * bump aioautomower to 2024.6.4b0 * fix req * align dates * adjust * nnbw * better * improvements * req * update requirements * tests * tweaks * shift functions to library * tests * bump to aioautomower==2024.9.0b1 * tests * remove ZoneInfo wrapper * use timetzone from start_date object * Update requirements_all.txt * Fix names in ProgramEvent --- .../husqvarna_automower/__init__.py | 1 + .../husqvarna_automower/calendar.py | 86 ++++++++++ .../husqvarna_automower/fixtures/mower.json | 41 ++++- .../snapshots/test_calendar.ambr | 89 +++++++++++ .../snapshots/test_diagnostics.ambr | 47 +++++- .../husqvarna_automower/test_button.py | 4 +- .../husqvarna_automower/test_calendar.py | 149 ++++++++++++++++++ .../husqvarna_automower/test_diagnostics.py | 6 +- 8 files changed, 412 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/husqvarna_automower/calendar.py create mode 100644 tests/components/husqvarna_automower/snapshots/test_calendar.ambr create mode 100644 tests/components/husqvarna_automower/test_calendar.py diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 326a9a010ef..6e987b679ed 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -19,6 +19,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.CALENDAR, Platform.DEVICE_TRACKER, Platform.LAWN_MOWER, Platform.NUMBER, diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py new file mode 100644 index 00000000000..f0f5f9f4cd1 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -0,0 +1,86 @@ +"""Creates a calendar entity for the mower.""" + +from datetime import datetime +import logging + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from . import AutomowerConfigEntry +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AutomowerConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up lawn mower platform.""" + coordinator = entry.runtime_data + async_add_entities( + AutomowerCalendarEntity(mower_id, coordinator) for mower_id in coordinator.data + ) + + +class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): + """Representation of the Automower Calendar element.""" + + _attr_name: str | None = None + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Set up AutomowerCalendarEntity.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = mower_id + self._event: CalendarEvent | None = None + + @property + def event(self) -> CalendarEvent | None: + """Return the current or next upcoming event.""" + schedule = self.mower_attributes.calendar + if schedule.timeline is None: + return None + cursor = schedule.timeline.active_after(dt_util.now()) + program_event = next(cursor, None) + _LOGGER.debug("program_event %s", program_event) + if not program_event: + return None + return CalendarEvent( + summary=program_event.schedule_name, + start=program_event.start.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE), + end=program_event.end.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE), + rrule=program_event.rrule_str, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Return calendar events within a datetime range. + + This is only called when opening the calendar in the UI. + """ + schedule = self.mower_attributes.calendar + if schedule.timeline is None: + raise HomeAssistantError("Unable to get events: No schedule set") + cursor = schedule.timeline.overlapping( + start_date, + end_date, + ) + return [ + CalendarEvent( + summary=program_event.schedule_name, + start=program_event.start.replace(tzinfo=start_date.tzinfo), + end=program_event.end.replace(tzinfo=start_date.tzinfo), + rrule=program_event.rrule_str, + ) + for program_event in cursor + ] diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 6430dd4a89a..1927f4f281b 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -40,7 +40,8 @@ "thursday": false, "friday": true, "saturday": false, - "sunday": false + "sunday": false, + "workAreaId": 123456 }, { "start": 0, @@ -51,6 +52,42 @@ "thursday": true, "friday": false, "saturday": true, + "sunday": false, + "workAreaId": 123456 + }, + { + "start": 0, + "duration": 480, + "monday": false, + "tuesday": true, + "wednesday": false, + "thursday": true, + "friday": false, + "saturday": true, + "sunday": false, + "workAreaId": 654321 + }, + { + "start": 60, + "duration": 480, + "monday": true, + "tuesday": true, + "wednesday": false, + "thursday": true, + "friday": false, + "saturday": true, + "sunday": false, + "workAreaId": 654321 + }, + { + "start": 120, + "duration": 480, + "monday": true, + "tuesday": false, + "wednesday": false, + "thursday": true, + "friday": false, + "saturday": true, "sunday": false } ] @@ -64,7 +101,7 @@ }, "metadata": { "connected": true, - "statusTimestamp": 1697669932683 + "statusTimestamp": 1685923200000 }, "workAreas": [ { diff --git a/tests/components/husqvarna_automower/snapshots/test_calendar.ambr b/tests/components/husqvarna_automower/snapshots/test_calendar.ambr new file mode 100644 index 00000000000..55cf5e72cb9 --- /dev/null +++ b/tests/components/husqvarna_automower/snapshots/test_calendar.ambr @@ -0,0 +1,89 @@ +# serializer version: 1 +# name: test_calendar_snapshot[start_date0-end_date0] + dict({ + 'calendar.test_mower_1': dict({ + 'events': list([ + dict({ + 'end': '2023-06-05T09:00:00+02:00', + 'start': '2023-06-05T01:00:00+02:00', + 'summary': 'Back lawn schedule 2', + }), + dict({ + 'end': '2023-06-05T10:00:00+02:00', + 'start': '2023-06-05T02:00:00+02:00', + 'summary': 'Schedule 1', + }), + dict({ + 'end': '2023-06-06T00:00:00+02:00', + 'start': '2023-06-05T19:00:00+02:00', + 'summary': 'Front lawn schedule 1', + }), + dict({ + 'end': '2023-06-06T08:00:00+02:00', + 'start': '2023-06-06T00:00:00+02:00', + 'summary': 'Back lawn schedule 1', + }), + dict({ + 'end': '2023-06-06T08:00:00+02:00', + 'start': '2023-06-06T00:00:00+02:00', + 'summary': 'Front lawn schedule 2', + }), + dict({ + 'end': '2023-06-06T09:00:00+02:00', + 'start': '2023-06-06T01:00:00+02:00', + 'summary': 'Back lawn schedule 2', + }), + dict({ + 'end': '2023-06-08T00:00:00+02:00', + 'start': '2023-06-07T19:00:00+02:00', + 'summary': 'Front lawn schedule 1', + }), + dict({ + 'end': '2023-06-08T08:00:00+02:00', + 'start': '2023-06-08T00:00:00+02:00', + 'summary': 'Back lawn schedule 1', + }), + dict({ + 'end': '2023-06-08T08:00:00+02:00', + 'start': '2023-06-08T00:00:00+02:00', + 'summary': 'Front lawn schedule 2', + }), + dict({ + 'end': '2023-06-08T09:00:00+02:00', + 'start': '2023-06-08T01:00:00+02:00', + 'summary': 'Back lawn schedule 2', + }), + dict({ + 'end': '2023-06-08T10:00:00+02:00', + 'start': '2023-06-08T02:00:00+02:00', + 'summary': 'Schedule 1', + }), + dict({ + 'end': '2023-06-10T00:00:00+02:00', + 'start': '2023-06-09T19:00:00+02:00', + 'summary': 'Front lawn schedule 1', + }), + dict({ + 'end': '2023-06-10T08:00:00+02:00', + 'start': '2023-06-10T00:00:00+02:00', + 'summary': 'Front lawn schedule 2', + }), + dict({ + 'end': '2023-06-10T08:00:00+02:00', + 'start': '2023-06-10T00:00:00+02:00', + 'summary': 'Back lawn schedule 1', + }), + dict({ + 'end': '2023-06-10T09:00:00+02:00', + 'start': '2023-06-10T01:00:00+02:00', + 'summary': 'Back lawn schedule 2', + }), + dict({ + 'end': '2023-06-10T10:00:00+02:00', + 'start': '2023-06-10T02:00:00+02:00', + 'summary': 'Schedule 1', + }), + ]), + }), + }) +# --- diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 5052531efd2..76f6fc08039 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -16,8 +16,8 @@ 'thursday': False, 'tuesday': False, 'wednesday': True, - 'work_area_id': None, - 'work_area_name': None, + 'work_area_id': 123456, + 'work_area_name': 'Front lawn', }), dict({ 'duration': 480, @@ -29,6 +29,45 @@ 'thursday': True, 'tuesday': True, 'wednesday': False, + 'work_area_id': 123456, + 'work_area_name': 'Front lawn', + }), + dict({ + 'duration': 480, + 'friday': False, + 'monday': False, + 'saturday': True, + 'start': 0, + 'sunday': False, + 'thursday': True, + 'tuesday': True, + 'wednesday': False, + 'work_area_id': 654321, + 'work_area_name': 'Back lawn', + }), + dict({ + 'duration': 480, + 'friday': False, + 'monday': True, + 'saturday': True, + 'start': 60, + 'sunday': False, + 'thursday': True, + 'tuesday': True, + 'wednesday': False, + 'work_area_id': 654321, + 'work_area_name': 'Back lawn', + }), + dict({ + 'duration': 480, + 'friday': False, + 'monday': True, + 'saturday': True, + 'start': 120, + 'sunday': False, + 'thursday': True, + 'tuesday': False, + 'wednesday': False, 'work_area_id': None, 'work_area_name': None, }), @@ -43,7 +82,7 @@ }), 'metadata': dict({ 'connected': True, - 'status_dateteime': '2023-10-18T22:58:52.683000+00:00', + 'status_dateteime': '2023-06-05T00:00:00+00:00', }), 'mower': dict({ 'activity': 'PARKED_IN_CS', @@ -143,7 +182,7 @@ 'auth_implementation': 'husqvarna_automower', 'token': dict({ 'access_token': '**REDACTED**', - 'expires_at': 1709208000.0, + 'expires_at': 1685926800.0, 'expires_in': 86399, 'provider': 'husqvarna', 'refresh_token': '**REDACTED**', diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index 5cbb9b893a8..aee37864a3b 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -33,7 +33,7 @@ from tests.common import ( ) -@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC)) async def test_button_states_and_commands( hass: HomeAssistant, mock_automower_client: AsyncMock, @@ -76,7 +76,7 @@ async def test_button_states_and_commands( mocked_method.assert_called_once_with(TEST_MOWER_ID) await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == "2024-02-29T11:16:00+00:00" + assert state.state == "2023-06-05T00:16:00+00:00" getattr(mock_automower_client.commands, "error_confirm").side_effect = ApiException( "Test error" ) diff --git a/tests/components/husqvarna_automower/test_calendar.py b/tests/components/husqvarna_automower/test_calendar.py new file mode 100644 index 00000000000..39c273145ee --- /dev/null +++ b/tests/components/husqvarna_automower/test_calendar.py @@ -0,0 +1,149 @@ +"""Tests for calendar platform.""" + +from collections.abc import Awaitable, Callable +import datetime +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock +import urllib + +from aioautomower.utils import mower_list_to_dictionary_dataclass +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + EVENT_END_DATETIME, + EVENT_START_DATETIME, + SERVICE_GET_EVENTS, +) +from homeassistant.components.husqvarna_automower.const import DOMAIN +from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL +from homeassistant.const import ATTR_ENTITY_ID +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, + load_json_value_fixture, +) +from tests.typing import ClientSessionGenerator + +TEST_ENTITY = "calendar.test_mower_1" +type GetEventsFn = Callable[[str, str], Awaitable[dict[str, Any]]] + + +@pytest.fixture(name="get_events") +def get_events_fixture( + hass_client: ClientSessionGenerator, +) -> GetEventsFn: + """Fetch calendar events from the HTTP API.""" + + async def _fetch(start: str, end: str) -> list[dict[str, Any]]: + client = await hass_client() + response = await client.get( + f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + ) + assert response.status == HTTPStatus.OK + results = await response.json() + return [{k: event[k] for k in ("summary", "start", "end")} for event in results] + + return _fetch + + +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, 12)) +async def test_calendar_state_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """State test of the calendar.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("calendar.test_mower_1") + assert state is not None + assert state.state == "off" + + +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, 19)) +async def test_calendar_state_on( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """State test of the calendar.""" + await setup_integration(hass, mock_config_entry) + state = hass.states.get("calendar.test_mower_1") + assert state is not None + assert state.state == "on" + + +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5)) +async def test_empty_calendar( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + get_events: GetEventsFn, +) -> None: + """State if there is no schedule set.""" + await setup_integration(hass, mock_config_entry) + json_values = load_json_value_fixture("mower.json", DOMAIN) + json_values["data"][0]["attributes"]["calendar"]["tasks"] = [] + values = mower_list_to_dictionary_dataclass(json_values) + mock_automower_client.get_status.return_value = values + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("calendar.test_mower_1") + assert state is not None + assert state.state == "off" + events = await get_events("2023-06-05T00:00:00", "2023-06-12T00:00:00") + assert events == [] + + +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5)) +@pytest.mark.parametrize( + ( + "start_date", + "end_date", + ), + [ + ( + datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC), + datetime.datetime(2023, 6, 12, tzinfo=datetime.UTC), + ), + ], +) +async def test_calendar_snapshot( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + start_date: datetime, + end_date: datetime, +) -> None: + """Snapshot test of the calendar entity.""" + await setup_integration(hass, mock_config_entry) + events = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + ATTR_ENTITY_ID: "calendar.test_mower_1", + EVENT_START_DATETIME: start_date, + EVENT_END_DATETIME: end_date, + }, + blocking=True, + return_response=True, + ) + + assert events == snapshot diff --git a/tests/components/husqvarna_automower/test_diagnostics.py b/tests/components/husqvarna_automower/test_diagnostics.py index 3166b09f1ee..f8dc89af6f0 100644 --- a/tests/components/husqvarna_automower/test_diagnostics.py +++ b/tests/components/husqvarna_automower/test_diagnostics.py @@ -21,7 +21,7 @@ from tests.components.diagnostics import ( from tests.typing import ClientSessionGenerator -@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC)) async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -40,7 +40,7 @@ async def test_entry_diagnostics( assert result == snapshot(exclude=props("created_at", "modified_at")) -@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC)) +@pytest.mark.freeze_time(datetime.datetime(2023, 6, 5, tzinfo=datetime.UTC)) async def test_device_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -49,7 +49,7 @@ async def test_device_diagnostics( mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, ) -> None: - """Test select platform.""" + """Test device diagnostics platform.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) From 8dbca0fa0b157fec7a5b1118d45db574484b767b Mon Sep 17 00:00:00 2001 From: Seferino Fernandez Date: Sun, 15 Sep 2024 23:59:47 -0700 Subject: [PATCH 0666/1309] Added virtual integration for Arizona Public Service supported by opower (#126014) Co-authored-by: tronikos --- homeassistant/components/aps/__init__.py | 1 + homeassistant/components/aps/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/aps/__init__.py create mode 100644 homeassistant/components/aps/manifest.json diff --git a/homeassistant/components/aps/__init__.py b/homeassistant/components/aps/__init__.py new file mode 100644 index 00000000000..7af88840958 --- /dev/null +++ b/homeassistant/components/aps/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Arizona Public Service (APS).""" diff --git a/homeassistant/components/aps/manifest.json b/homeassistant/components/aps/manifest.json new file mode 100644 index 00000000000..347fd74a7bf --- /dev/null +++ b/homeassistant/components/aps/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "aps", + "name": "Arizona Public Service (APS)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8dde030a0d3..f3392a3338a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -407,6 +407,11 @@ "config_flow": false, "iot_class": "cloud_push" }, + "aps": { + "name": "Arizona Public Service (APS)", + "integration_type": "virtual", + "supported_by": "opower" + }, "apsystems": { "name": "APsystems", "integration_type": "device", From 2174ee18dcab1528eba0eb92ee41e68dd8def539 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 16 Sep 2024 09:21:23 +0200 Subject: [PATCH 0667/1309] Implement Reolink reconfiguration flow (#126004) Co-authored-by: Robert Resch --- .../components/reolink/config_flow.py | 42 +++++++++++----- homeassistant/components/reolink/host.py | 4 +- homeassistant/components/reolink/strings.json | 3 +- tests/components/reolink/test_config_flow.py | 50 +++++++++++++++++++ 4 files changed, 85 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 5b316662a2c..489597e7764 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -11,7 +11,13 @@ from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkErr import voluptuous as vol from homeassistant.components import dhcp -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -94,7 +100,6 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): self._host: str | None = None self._username: str = "admin" self._password: str | None = None - self._reauth: bool = False @staticmethod @callback @@ -111,7 +116,6 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): self._host = entry_data[CONF_HOST] self._username = entry_data[CONF_USERNAME] self._password = entry_data[CONF_PASSWORD] - self._reauth = True self.context["title_placeholders"]["ip_address"] = entry_data[CONF_HOST] self.context["title_placeholders"]["hostname"] = self.context[ "title_placeholders" @@ -129,6 +133,19 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", description_placeholders=placeholders ) + async def async_step_reconfigure( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform a reconfiguration.""" + config_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + assert config_entry is not None + self._host = config_entry.data[CONF_HOST] + self._username = config_entry.data[CONF_USERNAME] + self._password = config_entry.data[CONF_PASSWORD] + return await self.async_step_user() + async def async_step_dhcp( self, discovery_info: dhcp.DhcpServiceInfo ) -> ConfigFlowResult: @@ -244,14 +261,15 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): existing_entry = await self.async_set_unique_id( mac_address, raise_on_progress=False ) - if existing_entry and self._reauth: - if self.hass.config_entries.async_update_entry( - existing_entry, data=user_input - ): - await self.hass.config_entries.async_reload( - existing_entry.entry_id - ) - return self.async_abort(reason="reauth_successful") + if existing_entry and self.init_step in ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + ): + return self.async_update_reload_and_abort( + entry=existing_entry, + data=user_input, + reason=f"{self.init_step}_successful", + ) self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( @@ -266,7 +284,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): vol.Required(CONF_PASSWORD, default=self._password): str, } ) - if self._host is None or errors: + if self._host is None or self.init_step == SOURCE_RECONFIGURE or errors: data_schema = data_schema.extend( { vol.Required(CONF_HOST, default=self._host): str, diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 0df4918be76..58ae191eb9f 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -561,7 +561,9 @@ class ReolinkHost: def register_webhook(self) -> None: """Register the webhook for motion events.""" - self.webhook_id = f"{DOMAIN}_{self.unique_id.replace(':', '')}_ONVIF" + self.webhook_id = ( + f"{DOMAIN}_{self.unique_id.replace(':', '')}_{webhook.async_generate_id()}" + ) event_id = self.webhook_id webhook.async_register( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 3710c3743fa..9f18f4afe15 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -35,7 +35,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "options": { diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 40695861aaf..4c362e150ca 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -503,3 +503,53 @@ async def test_dhcp_ip_update( await hass.async_block_till_done() assert config_entry.data[CONF_HOST] == expected + + +async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: + """Test a reconfiguration flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=format_mac(TEST_MAC), + data={ + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_RECONFIGURE, + "entry_id": config_entry.entry_id, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: TEST_HOST2, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == TEST_HOST2 + assert config_entry.data[CONF_USERNAME] == TEST_USERNAME + assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD From e89c007a38f1d4798703b3e4cf9ef3e24d40066d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:22:37 +0200 Subject: [PATCH 0668/1309] Bump github/codeql-action from 3.26.6 to 3.26.7 (#126021) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 33c7d6a2711..dbc2dbf5963 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.1.7 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.26.6 + uses: github/codeql-action/init@v3.26.7 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.26.6 + uses: github/codeql-action/analyze@v3.26.7 with: category: "/language:python" From 7df224f3824731d4c8700eb1c84b38e5e182cb8d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:05:34 +0200 Subject: [PATCH 0669/1309] Use root import in assist_satellite imports (#126025) --- tests/components/esphome/test_assist_satellite.py | 4 ++-- tests/components/voip/test_voip.py | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index eb4f9802219..928ef38d250 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -26,11 +26,11 @@ import pytest from homeassistant.components import assist_satellite, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType -from homeassistant.components.assist_satellite.entity import ( +from homeassistant.components.assist_satellite import ( AssistSatelliteEntity, AssistSatelliteEntityFeature, - AssistSatelliteState, ) +from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.esphome import DOMAIN from homeassistant.components.esphome.assist_satellite import ( EsphomeAssistSatellite, diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index edd4d2972f4..f856da8b1e9 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -12,10 +12,8 @@ from syrupy.assertion import SnapshotAssertion from voip_utils import CallInfo from homeassistant.components import assist_pipeline, assist_satellite, tts, voip -from homeassistant.components.assist_satellite.entity import ( - AssistSatelliteEntity, - AssistSatelliteState, -) +from homeassistant.components.assist_satellite import AssistSatelliteEntity +from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.voip import HassVoipDatagramProtocol from homeassistant.components.voip.assist_satellite import Tones, VoipAssistSatellite from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices From 1caed79895fbd8ff1cb8a094a4b6f05249aedc2a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 16 Sep 2024 10:09:44 +0200 Subject: [PATCH 0670/1309] Validate set_humidity in ClimateEntity (#125242) * Implementation validation for set_humidity in ClimateEntity * Fixes --- homeassistant/components/climate/__init__.py | 29 +++++++- homeassistant/components/climate/strings.json | 3 + tests/components/climate/test_init.py | 67 +++++++++++++++++++ 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 6cdb3339a7b..7b016d9c90b 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -202,7 +202,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_HUMIDITY, {vol.Required(ATTR_HUMIDITY): vol.Coerce(int)}, - "async_set_humidity", + async_service_humidity_set, [ClimateEntityFeature.TARGET_HUMIDITY], ) component.async_register_entity_service( @@ -930,6 +930,33 @@ async def async_service_aux_heat( await entity.async_turn_aux_heat_off() +async def async_service_humidity_set( + entity: ClimateEntity, service_call: ServiceCall +) -> None: + """Handle set humidity service.""" + humidity = service_call.data[ATTR_HUMIDITY] + min_humidity = entity.min_humidity + max_humidity = entity.max_humidity + _LOGGER.debug( + "Check valid humidity %d in range %d - %d", + humidity, + min_humidity, + max_humidity, + ) + if humidity < min_humidity or humidity > max_humidity: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="humidity_out_of_range", + translation_placeholders={ + "humidity": str(humidity), + "min_humidity": str(min_humidity), + "max_humidity": str(max_humidity), + }, + ) + + await entity.async_set_humidity(humidity) + + async def async_service_temperature_set( entity: ClimateEntity, service_call: ServiceCall ) -> None: diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 1af21815b9f..3ff8d325da5 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -269,6 +269,9 @@ }, "temp_out_of_range": { "message": "Provided temperature {check_temp} is not valid. Accepted range is {min_temp} to {max_temp}." + }, + "humidity_out_of_range": { + "message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}." } } } diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index b0322e9ddd8..1c9144b40f7 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -20,6 +20,7 @@ from homeassistant.components.climate import ( from homeassistant.components.climate.const import ( ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, + ATTR_HUMIDITY, ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_PRESET_MODE, @@ -27,6 +28,7 @@ from homeassistant.components.climate.const import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, SERVICE_SET_FAN_MODE, + SERVICE_SET_HUMIDITY, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, @@ -1060,6 +1062,71 @@ async def test_no_issue_no_aux_property( ) not in caplog.text +async def test_humidity_validation( + hass: HomeAssistant, + register_test_integration: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validation for humidity.""" + + class MockClimateEntityHumidity(MockClimateEntity): + """Mock climate class with mocked aux heater.""" + + _attr_supported_features = ClimateEntityFeature.TARGET_HUMIDITY + _attr_target_humidity = 50 + _attr_min_humidity = 50 + _attr_max_humidity = 60 + + def set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + self._attr_target_humidity = humidity + + test_climate = MockClimateEntityHumidity( + name="Test", + unique_id="unique_climate_test", + ) + + setup_test_component_platform( + hass, DOMAIN, entities=[test_climate], from_config_entry=True + ) + await hass.config_entries.async_setup(register_test_integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state.attributes.get(ATTR_HUMIDITY) == 50 + + with pytest.raises( + ServiceValidationError, + match="Provided humidity 1 is not valid. Accepted range is 50 to 60", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": "climate.test", + ATTR_HUMIDITY: "1", + }, + blocking=True, + ) + + assert exc.value.translation_key == "humidity_out_of_range" + assert "Check valid humidity 1 in range 50 - 60" in caplog.text + + with pytest.raises( + ServiceValidationError, + match="Provided humidity 70 is not valid. Accepted range is 50 to 60", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": "climate.test", + ATTR_HUMIDITY: "70", + }, + blocking=True, + ) + + async def test_temperature_validation( hass: HomeAssistant, register_test_integration: MockConfigEntry ) -> None: From 3dd641816035d8176197b9d5df03563db158d87c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 16 Sep 2024 03:10:07 -0500 Subject: [PATCH 0671/1309] Use sample bytes in ESPHome media format (#126016) --- .../components/esphome/assist_satellite.py | 19 ++++- .../components/esphome/ffmpeg_proxy.py | 13 +++- .../components/esphome/media_player.py | 19 ++++- .../esphome/test_assist_satellite.py | 69 +++++++++++++++++++ tests/components/esphome/test_media_player.py | 22 ++++-- 5 files changed, 131 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 08dd2ac0774..7ce46fab64b 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -402,10 +402,23 @@ class EsphomeAssistSatellite( if supported_format.purpose == MediaPlayerFormatPurpose.ANNOUNCEMENT: self._attr_tts_options = { tts.ATTR_PREFERRED_FORMAT: supported_format.format, - tts.ATTR_PREFERRED_SAMPLE_RATE: supported_format.sample_rate, - tts.ATTR_PREFERRED_SAMPLE_CHANNELS: supported_format.num_channels, - tts.ATTR_PREFERRED_SAMPLE_BYTES: 2, } + + if supported_format.sample_rate > 0: + self._attr_tts_options[tts.ATTR_PREFERRED_SAMPLE_RATE] = ( + supported_format.sample_rate + ) + + if supported_format.sample_rate > 0: + self._attr_tts_options[tts.ATTR_PREFERRED_SAMPLE_CHANNELS] = ( + supported_format.num_channels + ) + + if supported_format.sample_rate > 0: + self._attr_tts_options[tts.ATTR_PREFERRED_SAMPLE_BYTES] = ( + supported_format.sample_bytes + ) + break async def _stream_tts_audio( diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py index d2f538bfbd5..1649c628be9 100644 --- a/homeassistant/components/esphome/ffmpeg_proxy.py +++ b/homeassistant/components/esphome/ffmpeg_proxy.py @@ -26,11 +26,12 @@ def async_create_proxy_url( media_format: str, rate: int | None = None, channels: int | None = None, + width: int | None = None, ) -> str: """Create a one-time use proxy URL that automatically converts the media.""" data: FFmpegProxyData = hass.data[DATA_FFMPEG_PROXY] return data.async_create_proxy_url( - device_id, media_url, media_format, rate, channels + device_id, media_url, media_format, rate, channels, width ) @@ -50,6 +51,9 @@ class FFmpegConversionInfo: channels: int | None """Target number of channels (None to keep source channels).""" + width: int | None + """Target sample width in bytes (None to keep source width).""" + @dataclass class FFmpegProxyData: @@ -70,11 +74,12 @@ class FFmpegProxyData: media_format: str, rate: int | None, channels: int | None, + width: int | None, ) -> str: """Create a one-time use proxy URL that automatically converts the media.""" convert_id = secrets.token_urlsafe(16) self.conversions[device_id][convert_id] = FFmpegConversionInfo( - media_url, media_format, rate, channels + media_url, media_format, rate, channels, width ) _LOGGER.debug("Media URL allowed by proxy: %s", media_url) @@ -136,6 +141,10 @@ class FFmpegConvertResponse(web.StreamResponse): # Number of channels command_args.extend(["-ac", str(self.convert_info.channels)]) + if self.convert_info.width == 2: + # 16-bit samples + command_args.extend(["-sample_fmt", "s16"]) + # Output to stdout command_args.append("pipe:") diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index d742029bcef..3930b71d106 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -170,13 +170,28 @@ class EsphomeMediaPlayer( _LOGGER.debug("Proxying media url %s with format %s", url, format_to_use) device_id = self.device_entry.id media_format = format_to_use.format + + # 0 = None + rate: int | None = None + channels: int | None = None + width: int | None = None + if format_to_use.sample_rate > 0: + rate = format_to_use.sample_rate + + if format_to_use.num_channels > 0: + channels = format_to_use.num_channels + + if format_to_use.sample_bytes > 0: + width = format_to_use.sample_bytes + proxy_url = async_create_proxy_url( self.hass, device_id, url, media_format=media_format, - rate=format_to_use.sample_rate, - channels=format_to_use.num_channels, + rate=rate, + channels=channels, + width=width, ) # Resolve URL diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 928ef38d250..f9a431e19d8 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1006,6 +1006,7 @@ async def test_tts_format_from_media_player( sample_rate=48000, num_channels=2, purpose=MediaPlayerFormatPurpose.DEFAULT, + sample_bytes=2, ), # This is the format that should be used for tts MediaPlayerSupportedFormat( @@ -1013,6 +1014,7 @@ async def test_tts_format_from_media_player( sample_rate=22050, num_channels=1, purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + sample_bytes=2, ), ], ) @@ -1050,6 +1052,73 @@ async def test_tts_format_from_media_player( } +async def test_tts_minimal_format_from_media_player( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test text-to-speech format when media player only specifies the codec.""" + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[ + MediaPlayerInfo( + object_id="mymedia_player", + key=1, + name="my media_player", + unique_id="my_media_player", + supports_pause=True, + supported_formats=[ + MediaPlayerSupportedFormat( + format="flac", + sample_rate=48000, + num_channels=2, + purpose=MediaPlayerFormatPurpose.DEFAULT, + sample_bytes=2, + ), + # This is the format that should be used for tts + MediaPlayerSupportedFormat( + format="mp3", + sample_rate=0, # source rate + num_channels=0, # source channels + purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + sample_bytes=0, # source width + ), + ], + ) + ], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + with patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + ) as mock_pipeline_from_audio_stream: + await satellite.handle_pipeline_start( + conversation_id="", + flags=0, + audio_settings=VoiceAssistantAudioSettings(), + wake_word_phrase=None, + ) + + mock_pipeline_from_audio_stream.assert_called_once() + kwargs = mock_pipeline_from_audio_stream.call_args_list[0].kwargs + + # Should be ANNOUNCEMENT format from media player + assert kwargs.get("tts_audio_output") == { + tts.ATTR_PREFERRED_FORMAT: "mp3", + } + + async def test_announce_supported_features( hass: HomeAssistant, mock_client: APIClient, diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index e859324b394..799666fc66e 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -310,15 +310,17 @@ async def test_media_player_proxy( supported_formats=[ MediaPlayerSupportedFormat( format="flac", - sample_rate=48000, - num_channels=2, + sample_rate=0, # source rate + num_channels=0, # source channels purpose=MediaPlayerFormatPurpose.DEFAULT, + sample_bytes=0, # source width ), MediaPlayerSupportedFormat( format="wav", sample_rate=16000, num_channels=1, purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + sample_bytes=2, ), MediaPlayerSupportedFormat( format="mp3", @@ -369,7 +371,13 @@ async def test_media_player_proxy( mock_async_create_proxy_url.assert_called_once() device_id = mock_async_create_proxy_url.call_args[0][1] mock_async_create_proxy_url.assert_called_once_with( - hass, device_id, media_url, media_format="flac", rate=48000, channels=2 + hass, + device_id, + media_url, + media_format="flac", + rate=None, + channels=None, + width=None, ) media_args = mock_client.media_player_command.call_args.kwargs @@ -395,7 +403,13 @@ async def test_media_player_proxy( mock_async_create_proxy_url.assert_called_once() device_id = mock_async_create_proxy_url.call_args[0][1] mock_async_create_proxy_url.assert_called_once_with( - hass, device_id, media_url, media_format="wav", rate=16000, channels=1 + hass, + device_id, + media_url, + media_format="wav", + rate=16000, + channels=1, + width=2, ) media_args = mock_client.media_player_command.call_args.kwargs From 457f63cce0cd2be824b1fa012aaee85ec8525207 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:10:53 +0200 Subject: [PATCH 0672/1309] Add platform Entity classes to pylint plugin (#125737) * Add platform Entity classes to pylint plugin * Fix violations * Fix violations * More * Allow component package with same name as a platform * One more --- .../components/hue/v1/binary_sensor.py | 1 + homeassistant/components/hue/v1/light.py | 1 + homeassistant/components/hue/v1/sensor.py | 4 + .../components/hue/v2/binary_sensor.py | 4 + homeassistant/components/hue/v2/group.py | 1 + homeassistant/components/hue/v2/light.py | 1 + homeassistant/components/hue/v2/sensor.py | 5 + .../components/input_button/__init__.py | 1 + .../components/input_select/__init__.py | 1 + pylint/plugins/hass_enforce_class_module.py | 103 ++++++++++-------- tests/pylint/test_enforce_class_module.py | 30 +++++ 11 files changed, 109 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/hue/v1/binary_sensor.py b/homeassistant/components/hue/v1/binary_sensor.py index 01524b48b79..325c4d022fa 100644 --- a/homeassistant/components/hue/v1/binary_sensor.py +++ b/homeassistant/components/hue/v1/binary_sensor.py @@ -25,6 +25,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) +# pylint: disable-next=hass-enforce-class-module class HuePresence(GenericZLLSensor, BinarySensorEntity): """The presence sensor entity for a Hue motion sensor device.""" diff --git a/homeassistant/components/hue/v1/light.py b/homeassistant/components/hue/v1/light.py index 68e05932e7a..76dd0fce12b 100644 --- a/homeassistant/components/hue/v1/light.py +++ b/homeassistant/components/hue/v1/light.py @@ -305,6 +305,7 @@ def hass_to_hue_brightness(value): return max(1, round((value / 255) * 254)) +# pylint: disable-next=hass-enforce-class-module class HueLight(CoordinatorEntity, LightEntity): """Representation of a Hue light.""" diff --git a/homeassistant/components/hue/v1/sensor.py b/homeassistant/components/hue/v1/sensor.py index 9a85f83f3e8..88d494ed44b 100644 --- a/homeassistant/components/hue/v1/sensor.py +++ b/homeassistant/components/hue/v1/sensor.py @@ -32,10 +32,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): await bridge.sensor_manager.async_register_component("sensor", async_add_entities) +# pylint: disable-next=hass-enforce-class-module class GenericHueGaugeSensorEntity(GenericZLLSensor, SensorEntity): """Parent class for all 'gauge' Hue device sensors.""" +# pylint: disable-next=hass-enforce-class-module class HueLightLevel(GenericHueGaugeSensorEntity): """The light level sensor entity for a Hue motion sensor device.""" @@ -71,6 +73,7 @@ class HueLightLevel(GenericHueGaugeSensorEntity): return attributes +# pylint: disable-next=hass-enforce-class-module class HueTemperature(GenericHueGaugeSensorEntity): """The temperature sensor entity for a Hue motion sensor device.""" @@ -87,6 +90,7 @@ class HueTemperature(GenericHueGaugeSensorEntity): return self.sensor.temperature / 100 +# pylint: disable-next=hass-enforce-class-module class HueBattery(GenericHueSensor, SensorEntity): """Battery class for when a batt-powered device is only represented as an event.""" diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 650a9384e35..5054ab6e817 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -82,6 +82,7 @@ async def async_setup_entry( register_items(api.sensors.tamper, HueTamperSensor) +# pylint: disable-next=hass-enforce-class-module class HueMotionSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Motion sensor.""" @@ -103,6 +104,7 @@ class HueMotionSensor(HueBaseEntity, BinarySensorEntity): return self.resource.motion.value +# pylint: disable-next=hass-enforce-class-module class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Entertainment Configuration as binary sensor.""" @@ -126,6 +128,7 @@ class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity): return self.resource.metadata.name +# pylint: disable-next=hass-enforce-class-module class HueContactSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Contact sensor.""" @@ -147,6 +150,7 @@ class HueContactSensor(HueBaseEntity, BinarySensorEntity): return self.resource.contact_report.state != ContactState.CONTACT +# pylint: disable-next=hass-enforce-class-module class HueTamperSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Tamper sensor.""" diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 34797b0e42c..97ff6feffa5 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -76,6 +76,7 @@ async def async_setup_entry( ) +# pylint: disable-next=hass-enforce-class-module class GroupedHueLight(HueBaseEntity, LightEntity): """Representation of a Grouped Hue light.""" diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index 6fd0eea7a0b..053b3c19c2d 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -68,6 +68,7 @@ async def async_setup_entry( ) +# pylint: disable-next=hass-enforce-class-module class HueLight(HueBaseEntity, LightEntity): """Representation of a Hue light.""" diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index 6e90d3ca775..bdf1db6df2e 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -79,6 +79,7 @@ async def async_setup_entry( register_items(ctrl_base.zigbee_connectivity, HueZigbeeConnectivitySensor) +# pylint: disable-next=hass-enforce-class-module class HueSensorBase(HueBaseEntity, SensorEntity): """Representation of a Hue sensor.""" @@ -94,6 +95,7 @@ class HueSensorBase(HueBaseEntity, SensorEntity): self.controller = controller +# pylint: disable-next=hass-enforce-class-module class HueTemperatureSensor(HueSensorBase): """Representation of a Hue Temperature sensor.""" @@ -111,6 +113,7 @@ class HueTemperatureSensor(HueSensorBase): return round(self.resource.temperature.value, 1) +# pylint: disable-next=hass-enforce-class-module class HueLightLevelSensor(HueSensorBase): """Representation of a Hue LightLevel (illuminance) sensor.""" @@ -139,6 +142,7 @@ class HueLightLevelSensor(HueSensorBase): } +# pylint: disable-next=hass-enforce-class-module class HueBatterySensor(HueSensorBase): """Representation of a Hue Battery sensor.""" @@ -164,6 +168,7 @@ class HueBatterySensor(HueSensorBase): return {"battery_state": self.resource.power_state.battery_state.value} +# pylint: disable-next=hass-enforce-class-module class HueZigbeeConnectivitySensor(HueSensorBase): """Representation of a Hue ZigbeeConnectivity sensor.""" diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index 6584b40fb55..69ff235948d 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -128,6 +128,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +# pylint: disable-next=hass-enforce-class-module class InputButton(collection.CollectionEntity, ButtonEntity, RestoreEntity): """Representation of a button.""" diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 6efe16240cb..a117cf0a867 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -246,6 +246,7 @@ class InputSelectStorageCollection(collection.DictStorageCollection): return {CONF_ID: item[CONF_ID]} | update_data +# pylint: disable-next=hass-enforce-class-module class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): """Representation of a select input.""" diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index b8f83b1602f..fe233d4afe7 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -3,49 +3,66 @@ from __future__ import annotations from ast import ClassDef -from dataclasses import dataclass from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter - -@dataclass -class ClassModuleMatch: - """Class for pattern matching.""" - - expected_module: str - base_class: str - - -_MODULES = [ - ClassModuleMatch("alarm_control_panel", "AlarmControlPanelEntityDescription"), - ClassModuleMatch("assist_satellite", "AssistSatelliteEntityDescription"), - ClassModuleMatch("binary_sensor", "BinarySensorEntityDescription"), - ClassModuleMatch("button", "ButtonEntityDescription"), - ClassModuleMatch("camera", "CameraEntityDescription"), - ClassModuleMatch("climate", "ClimateEntityDescription"), - ClassModuleMatch("coordinator", "DataUpdateCoordinator"), - ClassModuleMatch("cover", "CoverEntityDescription"), - ClassModuleMatch("date", "DateEntityDescription"), - ClassModuleMatch("datetime", "DateTimeEntityDescription"), - ClassModuleMatch("event", "EventEntityDescription"), - ClassModuleMatch("image", "ImageEntityDescription"), - ClassModuleMatch("image_processing", "ImageProcessingEntityDescription"), - ClassModuleMatch("lawn_mower", "LawnMowerEntityDescription"), - ClassModuleMatch("lock", "LockEntityDescription"), - ClassModuleMatch("media_player", "MediaPlayerEntityDescription"), - ClassModuleMatch("notify", "NotifyEntityDescription"), - ClassModuleMatch("number", "NumberEntityDescription"), - ClassModuleMatch("select", "SelectEntityDescription"), - ClassModuleMatch("sensor", "SensorEntityDescription"), - ClassModuleMatch("text", "TextEntityDescription"), - ClassModuleMatch("time", "TimeEntityDescription"), - ClassModuleMatch("update", "UpdateEntityDescription"), - ClassModuleMatch("vacuum", "VacuumEntityDescription"), - ClassModuleMatch("water_heater", "WaterHeaterEntityDescription"), - ClassModuleMatch("weather", "WeatherEntityDescription"), -] +_MODULES: dict[str, set[str]] = { + "air_quality": {"AirQualityEntity"}, + "alarm_control_panel": { + "AlarmControlPanelEntity", + "AlarmControlPanelEntityDescription", + }, + "assist_satellite": {"AssistSatelliteEntity", "AssistSatelliteEntityDescription"}, + "binary_sensor": {"BinarySensorEntity", "BinarySensorEntityDescription"}, + "button": {"ButtonEntity", "ButtonEntityDescription"}, + "calendar": {"CalendarEntity"}, + "camera": {"CameraEntity", "CameraEntityDescription"}, + "climate": {"ClimateEntity", "ClimateEntityDescription"}, + "coordinator": {"DataUpdateCoordinator"}, + "conversation": {"ConversationEntity"}, + "cover": {"CoverEntity", "CoverEntityDescription"}, + "date": {"DateEntity", "DateEntityDescription"}, + "datetime": {"DateTimeEntity", "DateTimeEntityDescription"}, + "device_tracker": {"DeviceTrackerEntity"}, + "event": {"EventEntity", "EventEntityDescription"}, + "fan": {"FanEntity", "FanEntityDescription"}, + "geo_location": {"GeolocationEvent"}, + "humidifier": {"HumidifierEntity", "HumidifierEntityDescription"}, + "image": {"ImageEntity", "ImageEntityDescription"}, + "image_processing": { + "ImageProcessingEntity", + "ImageProcessingFaceEntity", + "ImageProcessingEntityDescription", + }, + "lawn_mower": {"LawnMowerEntity", "LawnMowerEntityDescription"}, + "light": {"LightEntity", "LightEntityDescription"}, + "lock": {"LockEntity", "LockEntityDescription"}, + "media_player": {"MediaPlayerEntity", "MediaPlayerEntityDescription"}, + "notify": {"NotifyEntity", "NotifyEntityDescription"}, + "number": {"NumberEntity", "NumberEntityDescription", "RestoreNumber"}, + "remote": {"RemoteEntity", "RemoteEntityDescription"}, + "select": {"SelectEntity", "SelectEntityDescription"}, + "sensor": {"RestoreSensor", "SensorEntity", "SensorEntityDescription"}, + "siren": {"SirenEntity", "SirenEntityDescription"}, + "stt": {"SpeechToTextEntity"}, + "switch": {"SwitchEntity", "SwitchEntityDescription"}, + "text": {"TextEntity", "TextEntityDescription"}, + "time": {"TimeEntity", "TimeEntityDescription"}, + "todo": {"TodoListEntity"}, + "tts": {"TextToSpeechEntity"}, + "update": {"UpdateEntityDescription"}, + "vacuum": {"VacuumEntity", "VacuumEntityDescription"}, + "wake_word": {"WakeWordDetectionEntity"}, + "water_heater": {"WaterHeaterEntity"}, + "weather": { + "CoordinatorWeatherEntity", + "SingleCoordinatorWeatherEntity", + "WeatherEntity", + "WeatherEntityDescription", + }, +} class HassEnforceClassModule(BaseChecker): @@ -69,24 +86,24 @@ class HassEnforceClassModule(BaseChecker): if not root_name.startswith("homeassistant.components."): return parts = root_name.split(".") + current_integration = parts[2] current_module = parts[3] if len(parts) > 3 else "" ancestors: list[ClassDef] | None = None - for match in _MODULES: - # Allow module.py and module/sub_module.py - if current_module == match.expected_module: + for expected_module, classes in _MODULES.items(): + if expected_module in (current_module, current_integration): continue if ancestors is None: ancestors = list(node.ancestors()) # cache result for other modules for ancestor in ancestors: - if ancestor.name == match.base_class: + if ancestor.name in classes: self.add_message( "hass-enforce-class-module", node=node, - args=(match.base_class, match.expected_module), + args=(ancestor.name, expected_module), ) return diff --git a/tests/pylint/test_enforce_class_module.py b/tests/pylint/test_enforce_class_module.py index 13d3c2538a1..db7daf0a258 100644 --- a/tests/pylint/test_enforce_class_module.py +++ b/tests/pylint/test_enforce_class_module.py @@ -63,6 +63,36 @@ def test_enforce_class_module_good( walker.walk(root_node) +@pytest.mark.parametrize( + "path", + [ + "homeassistant.components.sensor", + "homeassistant.components.sensor.entity", + "homeassistant.components.pylint_test.sensor", + "homeassistant.components.pylint_test.sensor.entity", + ], +) +def test_enforce_class_platform_good( + linter: UnittestLinter, + enforce_class_module_checker: BaseChecker, + path: str, +) -> None: + """Good test cases.""" + code = """ + class SensorEntity: + pass + + class CustomSensorEntity(SensorEntity): + pass + """ + root_node = astroid.parse(code, path) + walker = ASTWalker(linter) + walker.add_checker(enforce_class_module_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + @pytest.mark.parametrize( "path", [ From 56d00fd0c86f40a568f102132792be13a4d3e9cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:19:40 +0200 Subject: [PATCH 0673/1309] Improve type hints in numato (#126022) --- homeassistant/components/numato/__init__.py | 31 ++++++++++--------- .../components/numato/binary_sensor.py | 2 +- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/numato/__init__.py b/homeassistant/components/numato/__init__.py index 978264d867e..3b99079f949 100644 --- a/homeassistant/components/numato/__init__.py +++ b/homeassistant/components/numato/__init__.py @@ -1,5 +1,6 @@ """Support for controlling GPIO pins of a Numato Labs USB GPIO expander.""" +from collections.abc import Callable import logging import numato_gpio as gpio @@ -16,7 +17,7 @@ from homeassistant.const import ( PERCENTAGE, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType @@ -149,14 +150,14 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN][DATA_API] = NumatoAPI() - def cleanup_gpio(event): + def cleanup_gpio(event: Event) -> None: """Stuff to do before stopping.""" _LOGGER.debug("Clean up Numato GPIO") gpio.cleanup() if DATA_API in hass.data[DOMAIN]: hass.data[DOMAIN][DATA_API].ports_registered.clear() - def prepare_gpio(event): + def prepare_gpio(event: Event) -> None: """Stuff to do when home assistant starts.""" _LOGGER.debug("Setup cleanup at stop for Numato GPIO") hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio) @@ -172,11 +173,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class NumatoAPI: """Home-Assistant specific API for numato device access.""" - def __init__(self): + def __init__(self) -> None: """Initialize API state.""" - self.ports_registered = {} + self.ports_registered: dict[tuple[int, int], int] = {} - def check_port_free(self, device_id, port, direction): + def check_port_free(self, device_id: int, port: int, direction: int) -> None: """Check whether a port is still free set up. Fail with exception if it has already been registered. @@ -194,7 +195,7 @@ class NumatoAPI: ) ) - def check_device_id(self, device_id): + def check_device_id(self, device_id: int) -> None: """Check whether a device has been discovered. Fail with exception. @@ -202,7 +203,7 @@ class NumatoAPI: if device_id not in gpio.devices: raise gpio.NumatoGpioError(f"Device {device_id} not available.") - def check_port(self, device_id, port, direction): + def check_port(self, device_id: int, port: int, direction: int) -> None: """Raise an error if the port setup doesn't match the direction.""" self.check_device_id(device_id) if (device_id, port) not in self.ports_registered: @@ -220,35 +221,37 @@ class NumatoAPI: if self.ports_registered[(device_id, port)] != direction: raise gpio.NumatoGpioError(msg[direction]) - def setup_output(self, device_id, port): + def setup_output(self, device_id: int, port: int) -> None: """Set up a GPIO as output.""" self.check_device_id(device_id) self.check_port_free(device_id, port, gpio.OUT) gpio.devices[device_id].setup(port, gpio.OUT) - def setup_input(self, device_id, port): + def setup_input(self, device_id: int, port: int) -> None: """Set up a GPIO as input.""" self.check_device_id(device_id) gpio.devices[device_id].setup(port, gpio.IN) self.check_port_free(device_id, port, gpio.IN) - def write_output(self, device_id, port, value): + def write_output(self, device_id: int, port: int, value: int) -> None: """Write a value to a GPIO.""" self.check_port(device_id, port, gpio.OUT) gpio.devices[device_id].write(port, value) - def read_input(self, device_id, port): + def read_input(self, device_id: int, port: int) -> int: """Read a value from a GPIO.""" self.check_port(device_id, port, gpio.IN) return gpio.devices[device_id].read(port) - def read_adc_input(self, device_id, port): + def read_adc_input(self, device_id: int, port: int) -> int: """Read an ADC value from a GPIO ADC port.""" self.check_port(device_id, port, gpio.IN) self.check_device_id(device_id) return gpio.devices[device_id].adc_read(port) - def edge_detect(self, device_id, port, event_callback): + def edge_detect( + self, device_id: int, port: int, event_callback: Callable[[int, bool], None] + ) -> None: """Add detection for RISING and FALLING events.""" self.check_port(device_id, port, gpio.IN) gpio.devices[device_id].add_event_detect(port, event_callback, gpio.BOTH) diff --git a/homeassistant/components/numato/binary_sensor.py b/homeassistant/components/numato/binary_sensor.py index 1f664a372ba..47ab248d383 100644 --- a/homeassistant/components/numato/binary_sensor.py +++ b/homeassistant/components/numato/binary_sensor.py @@ -39,7 +39,7 @@ def setup_platform( if discovery_info is None: return - def read_gpio(device_id, port, level): + def read_gpio(device_id: int, port: int, level: bool) -> None: """Send signal to entity to have it update state.""" dispatcher_send(hass, NUMATO_SIGNAL.format(device_id, port), level) From e0d18c621b262cbc81285c20f83cfc5d7612b2bd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:20:08 +0200 Subject: [PATCH 0674/1309] Add missing type hint in monarch_money (#126019) --- homeassistant/components/monarch_money/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/monarch_money/config_flow.py b/homeassistant/components/monarch_money/config_flow.py index 410630c7cd8..5bfdc02c61e 100644 --- a/homeassistant/components/monarch_money/config_flow.py +++ b/homeassistant/components/monarch_money/config_flow.py @@ -103,7 +103,7 @@ class MonarchMoneyConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize config flow.""" self.email: str | None = None self.password: str | None = None From db1349b95c87fc7c84a371372274d36d50c59677 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 16 Sep 2024 10:20:32 +0200 Subject: [PATCH 0675/1309] Remove yaml import from downloader (#125921) --- .../components/downloader/__init__.py | 72 +--------------- .../components/downloader/config_flow.py | 8 -- .../components/downloader/strings.json | 6 -- .../components/downloader/test_config_flow.py | 42 +--------- tests/components/downloader/test_init.py | 84 +------------------ 5 files changed, 5 insertions(+), 207 deletions(-) diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py index 3fded1215c4..75e1103a712 100644 --- a/homeassistant/components/downloader/__init__.py +++ b/homeassistant/components/downloader/__init__.py @@ -10,17 +10,10 @@ import threading import requests import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, - ServiceCall, -) -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service import async_register_admin_service -from homeassistant.helpers.typing import ConfigType from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path from .const import ( @@ -36,67 +29,6 @@ from .const import ( SERVICE_DOWNLOAD_FILE, ) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Required(CONF_DOWNLOAD_DIR): cv.string})}, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Downloader component, via the YAML file.""" - if DOMAIN not in config: - return True - - hass.async_create_task(_async_import_config(hass, config)) - return True - - -async def _async_import_config(hass: HomeAssistant, config: ConfigType) -> None: - """Import the Downloader component from the YAML file.""" - - import_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_DOWNLOAD_DIR: config[DOMAIN][CONF_DOWNLOAD_DIR], - }, - ) - - if ( - import_result["type"] == FlowResultType.ABORT - and import_result["reason"] != "single_instance_allowed" - ): - async_create_issue( - hass, - DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.10.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="directory_does_not_exist", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Downloader", - "url": "/config/integrations/dashboard/add?domain=downloader", - }, - ) - else: - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.10.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Downloader", - }, - ) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Listen for download events to download files.""" diff --git a/homeassistant/components/downloader/config_flow.py b/homeassistant/components/downloader/config_flow.py index 61a7ba8fe52..3c3d6189f8a 100644 --- a/homeassistant/components/downloader/config_flow.py +++ b/homeassistant/components/downloader/config_flow.py @@ -43,14 +43,6 @@ class DownloaderConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle a flow initiated by configuration file.""" - try: - await self._validate_input(import_data) - except DirectoryDoesNotExist: - return self.async_abort(reason="directory_does_not_exist") - return self.async_create_entry(title=DEFAULT_NAME, data=import_data) - async def _validate_input(self, user_input: dict[str, Any]) -> None: """Validate the user input if the directory exists.""" download_path = user_input[CONF_DOWNLOAD_DIR] diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json index cf962bd9713..11a2bda8fce 100644 --- a/homeassistant/components/downloader/strings.json +++ b/homeassistant/components/downloader/strings.json @@ -35,11 +35,5 @@ } } } - }, - "issues": { - "directory_does_not_exist": { - "title": "The {integration_title} failed to import", - "description": "The {integration_title} integration failed to import because the configured directory does not exist.\n\nEnsure the directory exists and restart Home Assistant to try again or remove the {integration_title} configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." - } } } diff --git a/tests/components/downloader/test_config_flow.py b/tests/components/downloader/test_config_flow.py index 132b83dffdf..6bd740afab8 100644 --- a/tests/components/downloader/test_config_flow.py +++ b/tests/components/downloader/test_config_flow.py @@ -4,9 +4,8 @@ from unittest.mock import patch import pytest -from homeassistant import config_entries from homeassistant.components.downloader.const import CONF_DOWNLOAD_DIR, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -54,7 +53,7 @@ async def test_user_form(hass: HomeAssistant) -> None: assert result["data"] == {"download_dir": "download_dir"} -@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +@pytest.mark.parametrize("source", [SOURCE_USER]) async def test_single_instance_allowed( hass: HomeAssistant, source: str, @@ -69,40 +68,3 @@ async def test_single_instance_allowed( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" - - -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test import flow.""" - with ( - patch( - "homeassistant.components.downloader.async_setup_entry", return_value=True - ), - patch( - "os.path.isdir", - return_value=True, - ), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=CONFIG, - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Downloader" - assert result["data"] == CONFIG - - -async def test_import_flow_directory_not_found(hass: HomeAssistant) -> None: - """Test import flow.""" - with patch("os.path.isdir", return_value=False): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_DOWNLOAD_DIR: "download_dir", - }, - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "directory_does_not_exist" diff --git a/tests/components/downloader/test_init.py b/tests/components/downloader/test_init.py index 5832c0402b4..70dfd227019 100644 --- a/tests/components/downloader/test_init.py +++ b/tests/components/downloader/test_init.py @@ -8,9 +8,7 @@ from homeassistant.components.downloader import ( SERVICE_DOWNLOAD_FILE, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -29,83 +27,3 @@ async def test_initialization(hass: HomeAssistant) -> None: assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) assert config_entry.state is ConfigEntryState.LOADED - - -async def test_import(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None: - """Test the import of the downloader component.""" - with patch("os.path.isdir", return_value=True): - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_DOWNLOAD_DIR: "/test_dir", - }, - }, - ) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - assert config_entry.data == {CONF_DOWNLOAD_DIR: "/test_dir"} - assert config_entry.state is ConfigEntryState.LOADED - assert hass.services.has_service(DOMAIN, SERVICE_DOWNLOAD_FILE) - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue( - issue_id="deprecated_yaml_downloader", domain=HOMEASSISTANT_DOMAIN - ) - assert issue - - -async def test_import_directory_missing( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test the import of the downloader component.""" - with patch("os.path.isdir", return_value=False): - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_DOWNLOAD_DIR: "/test_dir", - }, - }, - ) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 0 - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue( - issue_id="deprecated_yaml_downloader", domain=DOMAIN - ) - assert issue - - -async def test_import_already_exists( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test the import of the downloader component.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_DOWNLOAD_DIR: "/test_dir", - }, - ) - config_entry.add_to_hass(hass) - with patch("os.path.isdir", return_value=True): - assert await async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_DOWNLOAD_DIR: "/test_dir", - }, - }, - ) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue( - issue_id="deprecated_yaml_downloader", domain=HOMEASSISTANT_DOMAIN - ) - assert issue From c77a3674b0b04b0b6e9ae106bbf12435cebe59eb Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Mon, 16 Sep 2024 10:22:04 +0200 Subject: [PATCH 0676/1309] Cleanup zwave_js fixture definitions (#125896) * refactor: cleanup zwave_js fixture definitions * fix: that one fixture that's not an object * fix: some more forgotten ones --- tests/components/zwave_js/conftest.py | 471 +++++++++++++------------- 1 file changed, 243 insertions(+), 228 deletions(-) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 489c2ee4b01..e90c1533b5f 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -3,7 +3,7 @@ import asyncio import copy import io -import json +from typing import Any from unittest.mock import DEFAULT, AsyncMock, patch import pytest @@ -12,27 +12,33 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo +from homeassistant.components.zwave_js.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.util.json import JsonArrayType -from tests.common import MockConfigEntry, load_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) # State fixtures @pytest.fixture(name="controller_state", scope="package") -def controller_state_fixture(): +def controller_state_fixture() -> dict[str, Any]: """Load the controller state fixture data.""" - return json.loads(load_fixture("zwave_js/controller_state.json")) + return load_json_object_fixture("controller_state.json", DOMAIN) @pytest.fixture(name="controller_node_state", scope="package") -def controller_node_state_fixture(): +def controller_node_state_fixture() -> dict[str, Any]: """Load the controller node state fixture data.""" - return json.loads(load_fixture("zwave_js/controller_node_state.json")) + return load_json_object_fixture("controller_node_state.json", DOMAIN) @pytest.fixture(name="version_state", scope="package") -def version_state_fixture(): +def version_state_fixture() -> dict[str, Any]: """Load the version state fixture data.""" return { "type": "version", @@ -43,7 +49,7 @@ def version_state_fixture(): @pytest.fixture(name="log_config_state") -def log_config_state_fixture(): +def log_config_state_fixture() -> dict[str, Any]: """Return log config state fixture data.""" return { "enabled": True, @@ -55,70 +61,70 @@ def log_config_state_fixture(): @pytest.fixture(name="config_entry_diagnostics", scope="package") -def config_entry_diagnostics_fixture(): +def config_entry_diagnostics_fixture() -> JsonArrayType: """Load the config entry diagnostics fixture data.""" - return json.loads(load_fixture("zwave_js/config_entry_diagnostics.json")) + return load_json_array_fixture("config_entry_diagnostics.json", DOMAIN) @pytest.fixture(name="config_entry_diagnostics_redacted", scope="package") -def config_entry_diagnostics_redacted_fixture(): +def config_entry_diagnostics_redacted_fixture() -> dict[str, Any]: """Load the redacted config entry diagnostics fixture data.""" - return json.loads(load_fixture("zwave_js/config_entry_diagnostics_redacted.json")) + return load_json_object_fixture("config_entry_diagnostics_redacted.json", DOMAIN) @pytest.fixture(name="multisensor_6_state", scope="package") -def multisensor_6_state_fixture(): +def multisensor_6_state_fixture() -> dict[str, Any]: """Load the multisensor 6 node state fixture data.""" - return json.loads(load_fixture("zwave_js/multisensor_6_state.json")) + return load_json_object_fixture("multisensor_6_state.json", DOMAIN) @pytest.fixture(name="ecolink_door_sensor_state", scope="package") -def ecolink_door_sensor_state_fixture(): +def ecolink_door_sensor_state_fixture() -> dict[str, Any]: """Load the Ecolink Door/Window Sensor node state fixture data.""" - return json.loads(load_fixture("zwave_js/ecolink_door_sensor_state.json")) + return load_json_object_fixture("ecolink_door_sensor_state.json", DOMAIN) @pytest.fixture(name="hank_binary_switch_state", scope="package") -def binary_switch_state_fixture(): +def binary_switch_state_fixture() -> dict[str, Any]: """Load the hank binary switch node state fixture data.""" - return json.loads(load_fixture("zwave_js/hank_binary_switch_state.json")) + return load_json_object_fixture("hank_binary_switch_state.json", DOMAIN) @pytest.fixture(name="bulb_6_multi_color_state", scope="package") -def bulb_6_multi_color_state_fixture(): +def bulb_6_multi_color_state_fixture() -> dict[str, Any]: """Load the bulb 6 multi-color node state fixture data.""" - return json.loads(load_fixture("zwave_js/bulb_6_multi_color_state.json")) + return load_json_object_fixture("bulb_6_multi_color_state.json", DOMAIN) @pytest.fixture(name="light_color_null_values_state", scope="package") -def light_color_null_values_state_fixture(): +def light_color_null_values_state_fixture() -> dict[str, Any]: """Load the light color null values node state fixture data.""" - return json.loads(load_fixture("zwave_js/light_color_null_values_state.json")) + return load_json_object_fixture("light_color_null_values_state.json", DOMAIN) @pytest.fixture(name="eaton_rf9640_dimmer_state", scope="package") -def eaton_rf9640_dimmer_state_fixture(): +def eaton_rf9640_dimmer_state_fixture() -> dict[str, Any]: """Load the eaton rf9640 dimmer node state fixture data.""" - return json.loads(load_fixture("zwave_js/eaton_rf9640_dimmer_state.json")) + return load_json_object_fixture("eaton_rf9640_dimmer_state.json", DOMAIN) @pytest.fixture(name="lock_schlage_be469_state", scope="package") -def lock_schlage_be469_state_fixture(): +def lock_schlage_be469_state_fixture() -> dict[str, Any]: """Load the schlage lock node state fixture data.""" - return json.loads(load_fixture("zwave_js/lock_schlage_be469_state.json")) + return load_json_object_fixture("lock_schlage_be469_state.json", DOMAIN) @pytest.fixture(name="lock_august_asl03_state", scope="package") -def lock_august_asl03_state_fixture(): +def lock_august_asl03_state_fixture() -> dict[str, Any]: """Load the August Pro lock node state fixture data.""" - return json.loads(load_fixture("zwave_js/lock_august_asl03_state.json")) + return load_json_object_fixture("lock_august_asl03_state.json", DOMAIN) @pytest.fixture(name="climate_radio_thermostat_ct100_plus_state", scope="package") -def climate_radio_thermostat_ct100_plus_state_fixture(): +def climate_radio_thermostat_ct100_plus_state_fixture() -> dict[str, Any]: """Load the climate radio thermostat ct100 plus node state fixture data.""" - return json.loads( - load_fixture("zwave_js/climate_radio_thermostat_ct100_plus_state.json") + return load_json_object_fixture( + "climate_radio_thermostat_ct100_plus_state.json", DOMAIN ) @@ -126,217 +132,215 @@ def climate_radio_thermostat_ct100_plus_state_fixture(): name="climate_radio_thermostat_ct100_plus_different_endpoints_state", scope="package", ) -def climate_radio_thermostat_ct100_plus_different_endpoints_state_fixture(): +def climate_radio_thermostat_ct100_plus_different_endpoints_state_fixture() -> ( + dict[str, Any] +): """Load the thermostat fixture state with values on different endpoints. This device is a radio thermostat ct100. """ - return json.loads( - load_fixture( - "zwave_js/climate_radio_thermostat_ct100_plus_different_endpoints_state.json" - ) + return load_json_object_fixture( + "climate_radio_thermostat_ct100_plus_different_endpoints_state.json", DOMAIN ) @pytest.fixture(name="climate_adc_t3000_state", scope="package") -def climate_adc_t3000_state_fixture(): +def climate_adc_t3000_state_fixture() -> dict[str, Any]: """Load the climate ADC-T3000 node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_adc_t3000_state.json")) + return load_json_object_fixture("climate_adc_t3000_state.json", DOMAIN) @pytest.fixture(name="climate_airzone_aidoo_control_hvac_unit_state", scope="package") -def climate_airzone_aidoo_control_hvac_unit_state_fixture(): +def climate_airzone_aidoo_control_hvac_unit_state_fixture() -> dict[str, Any]: """Load the climate Airzone Aidoo Control HVAC Unit state fixture data.""" - return json.loads( - load_fixture("zwave_js/climate_airzone_aidoo_control_hvac_unit_state.json") + return load_json_object_fixture( + "climate_airzone_aidoo_control_hvac_unit_state.json", DOMAIN ) @pytest.fixture(name="climate_danfoss_lc_13_state", scope="package") -def climate_danfoss_lc_13_state_fixture(): +def climate_danfoss_lc_13_state_fixture() -> dict[str, Any]: """Load Danfoss (LC-13) electronic radiator thermostat node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_danfoss_lc_13_state.json")) + return load_json_object_fixture("climate_danfoss_lc_13_state.json", DOMAIN) @pytest.fixture(name="climate_eurotronic_spirit_z_state", scope="package") -def climate_eurotronic_spirit_z_state_fixture(): +def climate_eurotronic_spirit_z_state_fixture() -> dict[str, Any]: """Load the climate Eurotronic Spirit Z thermostat node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_eurotronic_spirit_z_state.json")) + return load_json_object_fixture("climate_eurotronic_spirit_z_state.json", DOMAIN) @pytest.fixture(name="climate_heatit_z_trm6_state", scope="package") -def climate_heatit_z_trm6_state_fixture(): +def climate_heatit_z_trm6_state_fixture() -> dict[str, Any]: """Load the climate HEATIT Z-TRM6 thermostat node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_heatit_z_trm6_state.json")) + return load_json_object_fixture("climate_heatit_z_trm6_state.json", DOMAIN) @pytest.fixture(name="climate_heatit_z_trm3_state", scope="package") -def climate_heatit_z_trm3_state_fixture(): +def climate_heatit_z_trm3_state_fixture() -> dict[str, Any]: """Load the climate HEATIT Z-TRM3 thermostat node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_heatit_z_trm3_state.json")) + return load_json_object_fixture("climate_heatit_z_trm3_state.json", DOMAIN) @pytest.fixture(name="climate_heatit_z_trm2fx_state", scope="package") -def climate_heatit_z_trm2fx_state_fixture(): +def climate_heatit_z_trm2fx_state_fixture() -> dict[str, Any]: """Load the climate HEATIT Z-TRM2fx thermostat node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_heatit_z_trm2fx_state.json")) + return load_json_object_fixture("climate_heatit_z_trm2fx_state.json", DOMAIN) @pytest.fixture(name="climate_heatit_z_trm3_no_value_state", scope="package") -def climate_heatit_z_trm3_no_value_state_fixture(): +def climate_heatit_z_trm3_no_value_state_fixture() -> dict[str, Any]: """Load the climate HEATIT Z-TRM3 thermostat node w/no value state fixture data.""" - return json.loads( - load_fixture("zwave_js/climate_heatit_z_trm3_no_value_state.json") - ) + return load_json_object_fixture("climate_heatit_z_trm3_no_value_state.json", DOMAIN) @pytest.fixture(name="nortek_thermostat_state", scope="package") -def nortek_thermostat_state_fixture(): +def nortek_thermostat_state_fixture() -> dict[str, Any]: """Load the nortek thermostat node state fixture data.""" - return json.loads(load_fixture("zwave_js/nortek_thermostat_state.json")) + return load_json_object_fixture("nortek_thermostat_state.json", DOMAIN) @pytest.fixture(name="srt321_hrt4_zw_state", scope="package") -def srt321_hrt4_zw_state_fixture(): +def srt321_hrt4_zw_state_fixture() -> dict[str, Any]: """Load the climate HRT4-ZW / SRT321 / SRT322 thermostat node state fixture data.""" - return json.loads(load_fixture("zwave_js/srt321_hrt4_zw_state.json")) + return load_json_object_fixture("srt321_hrt4_zw_state.json", DOMAIN) @pytest.fixture(name="chain_actuator_zws12_state", scope="package") -def window_cover_state_fixture(): +def window_cover_state_fixture() -> dict[str, Any]: """Load the window cover node state fixture data.""" - return json.loads(load_fixture("zwave_js/chain_actuator_zws12_state.json")) + return load_json_object_fixture("chain_actuator_zws12_state.json", DOMAIN) @pytest.fixture(name="fan_generic_state", scope="package") -def fan_generic_state_fixture(): +def fan_generic_state_fixture() -> dict[str, Any]: """Load the fan node state fixture data.""" - return json.loads(load_fixture("zwave_js/fan_generic_state.json")) + return load_json_object_fixture("fan_generic_state.json", DOMAIN) @pytest.fixture(name="hs_fc200_state", scope="package") -def hs_fc200_state_fixture(): +def hs_fc200_state_fixture() -> dict[str, Any]: """Load the HS FC200+ node state fixture data.""" - return json.loads(load_fixture("zwave_js/fan_hs_fc200_state.json")) + return load_json_object_fixture("fan_hs_fc200_state.json", DOMAIN) @pytest.fixture(name="leviton_zw4sf_state", scope="package") -def leviton_zw4sf_state_fixture(): +def leviton_zw4sf_state_fixture() -> dict[str, Any]: """Load the Leviton ZW4SF node state fixture data.""" - return json.loads(load_fixture("zwave_js/leviton_zw4sf_state.json")) + return load_json_object_fixture("leviton_zw4sf_state.json", DOMAIN) @pytest.fixture(name="fan_honeywell_39358_state", scope="package") -def fan_honeywell_39358_state_fixture(): +def fan_honeywell_39358_state_fixture() -> dict[str, Any]: """Load the fan node state fixture data.""" - return json.loads(load_fixture("zwave_js/fan_honeywell_39358_state.json")) + return load_json_object_fixture("fan_honeywell_39358_state.json", DOMAIN) @pytest.fixture(name="gdc_zw062_state", scope="package") -def motorized_barrier_cover_state_fixture(): +def motorized_barrier_cover_state_fixture() -> dict[str, Any]: """Load the motorized barrier cover node state fixture data.""" - return json.loads(load_fixture("zwave_js/cover_zw062_state.json")) + return load_json_object_fixture("cover_zw062_state.json", DOMAIN) @pytest.fixture(name="iblinds_v2_state", scope="package") -def iblinds_v2_state_fixture(): +def iblinds_v2_state_fixture() -> dict[str, Any]: """Load the iBlinds v2 node state fixture data.""" - return json.loads(load_fixture("zwave_js/cover_iblinds_v2_state.json")) + return load_json_object_fixture("cover_iblinds_v2_state.json", DOMAIN) @pytest.fixture(name="iblinds_v3_state", scope="package") -def iblinds_v3_state_fixture(): +def iblinds_v3_state_fixture() -> dict[str, Any]: """Load the iBlinds v3 node state fixture data.""" - return json.loads(load_fixture("zwave_js/cover_iblinds_v3_state.json")) + return load_json_object_fixture("cover_iblinds_v3_state.json", DOMAIN) @pytest.fixture(name="zvidar_state", scope="package") -def zvidar_state_fixture(): +def zvidar_state_fixture() -> dict[str, Any]: """Load the ZVIDAR node state fixture data.""" - return json.loads(load_fixture("zwave_js/cover_zvidar_state.json")) + return load_json_object_fixture("cover_zvidar_state.json", DOMAIN) @pytest.fixture(name="qubino_shutter_state", scope="package") -def qubino_shutter_state_fixture(): +def qubino_shutter_state_fixture() -> dict[str, Any]: """Load the Qubino Shutter node state fixture data.""" - return json.loads(load_fixture("zwave_js/cover_qubino_shutter_state.json")) + return load_json_object_fixture("cover_qubino_shutter_state.json", DOMAIN) @pytest.fixture(name="aeotec_nano_shutter_state", scope="package") -def aeotec_nano_shutter_state_fixture(): +def aeotec_nano_shutter_state_fixture() -> dict[str, Any]: """Load the Aeotec Nano Shutter node state fixture data.""" - return json.loads(load_fixture("zwave_js/cover_aeotec_nano_shutter_state.json")) + return load_json_object_fixture("cover_aeotec_nano_shutter_state.json", DOMAIN) @pytest.fixture(name="fibaro_fgr222_shutter_state", scope="package") -def fibaro_fgr222_shutter_state_fixture(): +def fibaro_fgr222_shutter_state_fixture() -> dict[str, Any]: """Load the Fibaro FGR222 node state fixture data.""" - return json.loads(load_fixture("zwave_js/cover_fibaro_fgr222_state.json")) + return load_json_object_fixture("cover_fibaro_fgr222_state.json", DOMAIN) @pytest.fixture(name="fibaro_fgr223_shutter_state", scope="package") -def fibaro_fgr223_shutter_state_fixture(): +def fibaro_fgr223_shutter_state_fixture() -> dict[str, Any]: """Load the Fibaro FGR223 node state fixture data.""" - return json.loads(load_fixture("zwave_js/cover_fibaro_fgr223_state.json")) + return load_json_object_fixture("cover_fibaro_fgr223_state.json", DOMAIN) @pytest.fixture(name="shelly_europe_ltd_qnsh_001p10_state", scope="package") -def shelly_europe_ltd_qnsh_001p10_state_fixture(): +def shelly_europe_ltd_qnsh_001p10_state_fixture() -> dict[str, Any]: """Load the Shelly QNSH 001P10 node state fixture data.""" - return json.loads(load_fixture("zwave_js/shelly_europe_ltd_qnsh_001p10_state.json")) + return load_json_object_fixture("shelly_europe_ltd_qnsh_001p10_state.json", DOMAIN) @pytest.fixture(name="merten_507801_state", scope="package") -def merten_507801_state_fixture(): +def merten_507801_state_fixture() -> dict[str, Any]: """Load the Merten 507801 Shutter node state fixture data.""" - return json.loads(load_fixture("zwave_js/cover_merten_507801_state.json")) + return load_json_object_fixture("cover_merten_507801_state.json", DOMAIN) @pytest.fixture(name="aeon_smart_switch_6_state", scope="package") -def aeon_smart_switch_6_state_fixture(): +def aeon_smart_switch_6_state_fixture() -> dict[str, Any]: """Load the AEON Labs (ZW096) Smart Switch 6 node state fixture data.""" - return json.loads(load_fixture("zwave_js/aeon_smart_switch_6_state.json")) + return load_json_object_fixture("aeon_smart_switch_6_state.json", DOMAIN) @pytest.fixture(name="ge_12730_state", scope="package") -def ge_12730_state_fixture(): +def ge_12730_state_fixture() -> dict[str, Any]: """Load the GE 12730 node state fixture data.""" - return json.loads(load_fixture("zwave_js/fan_ge_12730_state.json")) + return load_json_object_fixture("fan_ge_12730_state.json", DOMAIN) @pytest.fixture(name="aeotec_radiator_thermostat_state", scope="package") -def aeotec_radiator_thermostat_state_fixture(): +def aeotec_radiator_thermostat_state_fixture() -> dict[str, Any]: """Load the Aeotec Radiator Thermostat node state fixture data.""" - return json.loads(load_fixture("zwave_js/aeotec_radiator_thermostat_state.json")) + return load_json_object_fixture("aeotec_radiator_thermostat_state.json", DOMAIN) @pytest.fixture(name="inovelli_lzw36_state", scope="package") -def inovelli_lzw36_state_fixture(): +def inovelli_lzw36_state_fixture() -> dict[str, Any]: """Load the Inovelli LZW36 node state fixture data.""" - return json.loads(load_fixture("zwave_js/inovelli_lzw36_state.json")) + return load_json_object_fixture("inovelli_lzw36_state.json", DOMAIN) @pytest.fixture(name="null_name_check_state", scope="package") -def null_name_check_state_fixture(): +def null_name_check_state_fixture() -> dict[str, Any]: """Load the null name check node state fixture data.""" - return json.loads(load_fixture("zwave_js/null_name_check_state.json")) + return load_json_object_fixture("null_name_check_state.json", DOMAIN) @pytest.fixture(name="lock_id_lock_as_id150_state", scope="package") -def lock_id_lock_as_id150_state_fixture(): +def lock_id_lock_as_id150_state_fixture() -> dict[str, Any]: """Load the id lock id-150 lock node state fixture data.""" - return json.loads(load_fixture("zwave_js/lock_id_lock_as_id150_state.json")) + return load_json_object_fixture("lock_id_lock_as_id150_state.json", DOMAIN) @pytest.fixture( name="climate_radio_thermostat_ct101_multiple_temp_units_state", scope="package" ) -def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture(): +def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture() -> ( + dict[str, Any] +): """Load the climate multiple temp units node state fixture data.""" - return json.loads( - load_fixture( - "zwave_js/climate_radio_thermostat_ct101_multiple_temp_units_state.json" - ) + return load_json_object_fixture( + "climate_radio_thermostat_ct101_multiple_temp_units_state.json", DOMAIN ) @@ -346,141 +350,142 @@ def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture(): ), scope="package", ) -def climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state_fixture(): +def climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state_fixture() -> ( + dict[str, Any] +): """Load climate device w/ mode+setpoint on diff endpoints node state fixture data.""" - return json.loads( - load_fixture( - "zwave_js/climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state.json" - ) + return load_json_object_fixture( + "climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state.json", + DOMAIN, ) @pytest.fixture(name="vision_security_zl7432_state", scope="package") -def vision_security_zl7432_state_fixture(): +def vision_security_zl7432_state_fixture() -> dict[str, Any]: """Load the vision security zl7432 switch node state fixture data.""" - return json.loads(load_fixture("zwave_js/vision_security_zl7432_state.json")) + return load_json_object_fixture("vision_security_zl7432_state.json", DOMAIN) @pytest.fixture(name="zen_31_state", scope="package") -def zem_31_state_fixture(): +def zem_31_state_fixture() -> dict[str, Any]: """Load the zen_31 node state fixture data.""" - return json.loads(load_fixture("zwave_js/zen_31_state.json")) + return load_json_object_fixture("zen_31_state.json", DOMAIN) @pytest.fixture(name="wallmote_central_scene_state", scope="package") -def wallmote_central_scene_state_fixture(): +def wallmote_central_scene_state_fixture() -> dict[str, Any]: """Load the wallmote central scene node state fixture data.""" - return json.loads(load_fixture("zwave_js/wallmote_central_scene_state.json")) + return load_json_object_fixture("wallmote_central_scene_state.json", DOMAIN) @pytest.fixture(name="ge_in_wall_dimmer_switch_state", scope="package") -def ge_in_wall_dimmer_switch_state_fixture(): +def ge_in_wall_dimmer_switch_state_fixture() -> dict[str, Any]: """Load the ge in-wall dimmer switch node state fixture data.""" - return json.loads(load_fixture("zwave_js/ge_in_wall_dimmer_switch_state.json")) + return load_json_object_fixture("ge_in_wall_dimmer_switch_state.json", DOMAIN) @pytest.fixture(name="aeotec_zw164_siren_state", scope="package") -def aeotec_zw164_siren_state_fixture(): +def aeotec_zw164_siren_state_fixture() -> dict[str, Any]: """Load the aeotec zw164 siren node state fixture data.""" - return json.loads(load_fixture("zwave_js/aeotec_zw164_siren_state.json")) + return load_json_object_fixture("aeotec_zw164_siren_state.json", DOMAIN) @pytest.fixture(name="lock_popp_electric_strike_lock_control_state", scope="package") -def lock_popp_electric_strike_lock_control_state_fixture(): +def lock_popp_electric_strike_lock_control_state_fixture() -> dict[str, Any]: """Load the popp electric strike lock control node state fixture data.""" - return json.loads( - load_fixture("zwave_js/lock_popp_electric_strike_lock_control_state.json") + return load_json_object_fixture( + "lock_popp_electric_strike_lock_control_state.json", DOMAIN ) @pytest.fixture(name="fortrezz_ssa1_siren_state", scope="package") -def fortrezz_ssa1_siren_state_fixture(): +def fortrezz_ssa1_siren_state_fixture() -> dict[str, Any]: """Load the fortrezz ssa1 siren node state fixture data.""" - return json.loads(load_fixture("zwave_js/fortrezz_ssa1_siren_state.json")) + return load_json_object_fixture("fortrezz_ssa1_siren_state.json", DOMAIN) @pytest.fixture(name="fortrezz_ssa3_siren_state", scope="package") -def fortrezz_ssa3_siren_state_fixture(): +def fortrezz_ssa3_siren_state_fixture() -> dict[str, Any]: """Load the fortrezz ssa3 siren node state fixture data.""" - return json.loads(load_fixture("zwave_js/fortrezz_ssa3_siren_state.json")) + return load_json_object_fixture("fortrezz_ssa3_siren_state.json", DOMAIN) @pytest.fixture(name="zp3111_not_ready_state", scope="package") -def zp3111_not_ready_state_fixture(): +def zp3111_not_ready_state_fixture() -> dict[str, Any]: """Load the zp3111 4-in-1 sensor not-ready node state fixture data.""" - return json.loads(load_fixture("zwave_js/zp3111-5_not_ready_state.json")) + return load_json_object_fixture("zp3111-5_not_ready_state.json", DOMAIN) @pytest.fixture(name="zp3111_state", scope="package") -def zp3111_state_fixture(): +def zp3111_state_fixture() -> dict[str, Any]: """Load the zp3111 4-in-1 sensor node state fixture data.""" - return json.loads(load_fixture("zwave_js/zp3111-5_state.json")) + return load_json_object_fixture("zp3111-5_state.json", DOMAIN) @pytest.fixture(name="express_controls_ezmultipli_state", scope="package") -def light_express_controls_ezmultipli_state_fixture(): +def light_express_controls_ezmultipli_state_fixture() -> dict[str, Any]: """Load the Express Controls EZMultiPli node state fixture data.""" - return json.loads(load_fixture("zwave_js/express_controls_ezmultipli_state.json")) + return load_json_object_fixture("express_controls_ezmultipli_state.json", DOMAIN) @pytest.fixture(name="lock_home_connect_620_state", scope="package") -def lock_home_connect_620_state_fixture(): +def lock_home_connect_620_state_fixture() -> dict[str, Any]: """Load the Home Connect 620 lock node state fixture data.""" - return json.loads(load_fixture("zwave_js/lock_home_connect_620_state.json")) + return load_json_object_fixture("lock_home_connect_620_state.json", DOMAIN) @pytest.fixture(name="switch_zooz_zen72_state", scope="package") -def switch_zooz_zen72_state_fixture(): +def switch_zooz_zen72_state_fixture() -> dict[str, Any]: """Load the Zooz Zen72 switch node state fixture data.""" - return json.loads(load_fixture("zwave_js/switch_zooz_zen72_state.json")) + return load_json_object_fixture("switch_zooz_zen72_state.json", DOMAIN) @pytest.fixture(name="indicator_test_state", scope="package") -def indicator_test_state_fixture(): +def indicator_test_state_fixture() -> dict[str, Any]: """Load the indicator CC test node state fixture data.""" - return json.loads(load_fixture("zwave_js/indicator_test_state.json")) + return load_json_object_fixture("indicator_test_state.json", DOMAIN) @pytest.fixture(name="energy_production_state", scope="package") -def energy_production_state_fixture(): +def energy_production_state_fixture() -> dict[str, Any]: """Load a mock node with energy production CC state fixture data.""" - return json.loads(load_fixture("zwave_js/energy_production_state.json")) + return load_json_object_fixture("energy_production_state.json", DOMAIN) @pytest.fixture(name="nice_ibt4zwave_state", scope="package") -def nice_ibt4zwave_state_fixture(): +def nice_ibt4zwave_state_fixture() -> dict[str, Any]: """Load a Nice IBT4ZWAVE cover node state fixture data.""" - return json.loads(load_fixture("zwave_js/cover_nice_ibt4zwave_state.json")) + return load_json_object_fixture("cover_nice_ibt4zwave_state.json", DOMAIN) @pytest.fixture(name="logic_group_zdb5100_state", scope="package") -def logic_group_zdb5100_state_fixture(): +def logic_group_zdb5100_state_fixture() -> dict[str, Any]: """Load the Logic Group ZDB5100 node state fixture data.""" - return json.loads(load_fixture("zwave_js/logic_group_zdb5100_state.json")) + return load_json_object_fixture("logic_group_zdb5100_state.json", DOMAIN) @pytest.fixture(name="central_scene_node_state", scope="package") -def central_scene_node_state_fixture(): +def central_scene_node_state_fixture() -> dict[str, Any]: """Load node with Central Scene CC node state fixture data.""" - return json.loads(load_fixture("zwave_js/central_scene_node_state.json")) + return load_json_object_fixture("central_scene_node_state.json", DOMAIN) @pytest.fixture(name="light_device_class_is_null_state", scope="package") -def light_device_class_is_null_state_fixture(): +def light_device_class_is_null_state_fixture() -> dict[str, Any]: """Load node with device class is None state fixture data.""" - return json.loads(load_fixture("zwave_js/light_device_class_is_null_state.json")) + return load_json_object_fixture("light_device_class_is_null_state.json", DOMAIN) @pytest.fixture(name="basic_cc_sensor_state", scope="package") -def basic_cc_sensor_state_fixture(): +def basic_cc_sensor_state_fixture() -> dict[str, Any]: """Load node with Basic CC sensor fixture data.""" - return json.loads(load_fixture("zwave_js/basic_cc_sensor_state.json")) + return load_json_object_fixture("basic_cc_sensor_state.json", DOMAIN) @pytest.fixture(name="window_covering_outbound_bottom_state", scope="package") -def window_covering_outbound_bottom_state_fixture(): +def window_covering_outbound_bottom_state_fixture() -> dict[str, Any]: """Load node with Window Covering CC fixture data, with only the outbound bottom position supported.""" - return json.loads(load_fixture("zwave_js/window_covering_outbound_bottom.json")) + return load_json_object_fixture("window_covering_outbound_bottom.json", DOMAIN) # model fixtures @@ -544,7 +549,7 @@ def mock_client_fixture( @pytest.fixture(name="multisensor_6") -def multisensor_6_fixture(client, multisensor_6_state): +def multisensor_6_fixture(client, multisensor_6_state) -> Node: """Mock a multisensor 6 node.""" node = Node(client, copy.deepcopy(multisensor_6_state)) client.driver.controller.nodes[node.node_id] = node @@ -552,7 +557,7 @@ def multisensor_6_fixture(client, multisensor_6_state): @pytest.fixture(name="ecolink_door_sensor") -def legacy_binary_sensor_fixture(client, ecolink_door_sensor_state): +def legacy_binary_sensor_fixture(client, ecolink_door_sensor_state) -> Node: """Mock a legacy_binary_sensor node.""" node = Node(client, copy.deepcopy(ecolink_door_sensor_state)) client.driver.controller.nodes[node.node_id] = node @@ -560,7 +565,7 @@ def legacy_binary_sensor_fixture(client, ecolink_door_sensor_state): @pytest.fixture(name="hank_binary_switch") -def hank_binary_switch_fixture(client, hank_binary_switch_state): +def hank_binary_switch_fixture(client, hank_binary_switch_state) -> Node: """Mock a binary switch node.""" node = Node(client, copy.deepcopy(hank_binary_switch_state)) client.driver.controller.nodes[node.node_id] = node @@ -568,7 +573,7 @@ def hank_binary_switch_fixture(client, hank_binary_switch_state): @pytest.fixture(name="bulb_6_multi_color") -def bulb_6_multi_color_fixture(client, bulb_6_multi_color_state): +def bulb_6_multi_color_fixture(client, bulb_6_multi_color_state) -> Node: """Mock a bulb 6 multi-color node.""" node = Node(client, copy.deepcopy(bulb_6_multi_color_state)) client.driver.controller.nodes[node.node_id] = node @@ -576,7 +581,7 @@ def bulb_6_multi_color_fixture(client, bulb_6_multi_color_state): @pytest.fixture(name="light_color_null_values") -def light_color_null_values_fixture(client, light_color_null_values_state): +def light_color_null_values_fixture(client, light_color_null_values_state) -> Node: """Mock a node with current color value item being null.""" node = Node(client, copy.deepcopy(light_color_null_values_state)) client.driver.controller.nodes[node.node_id] = node @@ -584,7 +589,7 @@ def light_color_null_values_fixture(client, light_color_null_values_state): @pytest.fixture(name="eaton_rf9640_dimmer") -def eaton_rf9640_dimmer_fixture(client, eaton_rf9640_dimmer_state): +def eaton_rf9640_dimmer_fixture(client, eaton_rf9640_dimmer_state) -> Node: """Mock a Eaton RF9640 (V4 compatible) dimmer node.""" node = Node(client, copy.deepcopy(eaton_rf9640_dimmer_state)) client.driver.controller.nodes[node.node_id] = node @@ -592,7 +597,7 @@ def eaton_rf9640_dimmer_fixture(client, eaton_rf9640_dimmer_state): @pytest.fixture(name="lock_schlage_be469") -def lock_schlage_be469_fixture(client, lock_schlage_be469_state): +def lock_schlage_be469_fixture(client, lock_schlage_be469_state) -> Node: """Mock a schlage lock node.""" node = Node(client, copy.deepcopy(lock_schlage_be469_state)) client.driver.controller.nodes[node.node_id] = node @@ -600,7 +605,7 @@ def lock_schlage_be469_fixture(client, lock_schlage_be469_state): @pytest.fixture(name="lock_august_pro") -def lock_august_asl03_fixture(client, lock_august_asl03_state): +def lock_august_asl03_fixture(client, lock_august_asl03_state) -> Node: """Mock a August Pro lock node.""" node = Node(client, copy.deepcopy(lock_august_asl03_state)) client.driver.controller.nodes[node.node_id] = node @@ -610,7 +615,7 @@ def lock_august_asl03_fixture(client, lock_august_asl03_state): @pytest.fixture(name="climate_radio_thermostat_ct100_plus") def climate_radio_thermostat_ct100_plus_fixture( client, climate_radio_thermostat_ct100_plus_state -): +) -> Node: """Mock a climate radio thermostat ct100 plus node.""" node = Node(client, copy.deepcopy(climate_radio_thermostat_ct100_plus_state)) client.driver.controller.nodes[node.node_id] = node @@ -620,7 +625,7 @@ def climate_radio_thermostat_ct100_plus_fixture( @pytest.fixture(name="climate_radio_thermostat_ct100_plus_different_endpoints") def climate_radio_thermostat_ct100_plus_different_endpoints_fixture( client, climate_radio_thermostat_ct100_plus_different_endpoints_state -): +) -> Node: """Mock climate radio thermostat ct100 plus node w/ values on diff endpoints.""" node = Node( client, @@ -631,7 +636,7 @@ def climate_radio_thermostat_ct100_plus_different_endpoints_fixture( @pytest.fixture(name="climate_adc_t3000") -def climate_adc_t3000_fixture(client, climate_adc_t3000_state): +def climate_adc_t3000_fixture(client, climate_adc_t3000_state) -> Node: """Mock a climate ADC-T3000 node.""" node = Node(client, copy.deepcopy(climate_adc_t3000_state)) client.driver.controller.nodes[node.node_id] = node @@ -639,7 +644,7 @@ def climate_adc_t3000_fixture(client, climate_adc_t3000_state): @pytest.fixture(name="climate_adc_t3000_missing_setpoint") -def climate_adc_t3000_missing_setpoint_fixture(client, climate_adc_t3000_state): +def climate_adc_t3000_missing_setpoint_fixture(client, climate_adc_t3000_state) -> Node: """Mock a climate ADC-T3000 node with missing de-humidify setpoint.""" data = copy.deepcopy(climate_adc_t3000_state) data["name"] = f"{data['name']} missing setpoint" @@ -655,7 +660,7 @@ def climate_adc_t3000_missing_setpoint_fixture(client, climate_adc_t3000_state): @pytest.fixture(name="climate_adc_t3000_missing_mode") -def climate_adc_t3000_missing_mode_fixture(client, climate_adc_t3000_state): +def climate_adc_t3000_missing_mode_fixture(client, climate_adc_t3000_state) -> Node: """Mock a climate ADC-T3000 node with missing mode setpoint.""" data = copy.deepcopy(climate_adc_t3000_state) data["name"] = f"{data['name']} missing mode" @@ -671,7 +676,9 @@ def climate_adc_t3000_missing_mode_fixture(client, climate_adc_t3000_state): @pytest.fixture(name="climate_adc_t3000_missing_fan_mode_states") -def climate_adc_t3000_missing_fan_mode_states_fixture(client, climate_adc_t3000_state): +def climate_adc_t3000_missing_fan_mode_states_fixture( + client, climate_adc_t3000_state +) -> Node: """Mock ADC-T3000 node w/ missing 'states' metadata on Thermostat Fan Mode.""" data = copy.deepcopy(climate_adc_t3000_state) data["name"] = f"{data['name']} missing fan mode states" @@ -697,7 +704,7 @@ def climate_airzone_aidoo_control_hvac_unit_fixture( @pytest.fixture(name="climate_danfoss_lc_13") -def climate_danfoss_lc_13_fixture(client, climate_danfoss_lc_13_state): +def climate_danfoss_lc_13_fixture(client, climate_danfoss_lc_13_state) -> Node: """Mock a climate radio danfoss LC-13 node.""" node = Node(client, copy.deepcopy(climate_danfoss_lc_13_state)) client.driver.controller.nodes[node.node_id] = node @@ -705,7 +712,9 @@ def climate_danfoss_lc_13_fixture(client, climate_danfoss_lc_13_state): @pytest.fixture(name="climate_eurotronic_spirit_z") -def climate_eurotronic_spirit_z_fixture(client, climate_eurotronic_spirit_z_state): +def climate_eurotronic_spirit_z_fixture( + client, climate_eurotronic_spirit_z_state +) -> Node: """Mock a climate radio danfoss LC-13 node.""" node = Node(client, climate_eurotronic_spirit_z_state) client.driver.controller.nodes[node.node_id] = node @@ -713,7 +722,7 @@ def climate_eurotronic_spirit_z_fixture(client, climate_eurotronic_spirit_z_stat @pytest.fixture(name="climate_heatit_z_trm6") -def climate_heatit_z_trm6_fixture(client, climate_heatit_z_trm6_state): +def climate_heatit_z_trm6_fixture(client, climate_heatit_z_trm6_state) -> Node: """Mock a climate radio HEATIT Z-TRM6 node.""" node = Node(client, copy.deepcopy(climate_heatit_z_trm6_state)) client.driver.controller.nodes[node.node_id] = node @@ -723,7 +732,7 @@ def climate_heatit_z_trm6_fixture(client, climate_heatit_z_trm6_state): @pytest.fixture(name="climate_heatit_z_trm3_no_value") def climate_heatit_z_trm3_no_value_fixture( client, climate_heatit_z_trm3_no_value_state -): +) -> Node: """Mock a climate radio HEATIT Z-TRM3 node.""" node = Node(client, copy.deepcopy(climate_heatit_z_trm3_no_value_state)) client.driver.controller.nodes[node.node_id] = node @@ -731,7 +740,7 @@ def climate_heatit_z_trm3_no_value_fixture( @pytest.fixture(name="climate_heatit_z_trm3") -def climate_heatit_z_trm3_fixture(client, climate_heatit_z_trm3_state): +def climate_heatit_z_trm3_fixture(client, climate_heatit_z_trm3_state) -> Node: """Mock a climate radio HEATIT Z-TRM3 node.""" node = Node(client, copy.deepcopy(climate_heatit_z_trm3_state)) client.driver.controller.nodes[node.node_id] = node @@ -739,7 +748,7 @@ def climate_heatit_z_trm3_fixture(client, climate_heatit_z_trm3_state): @pytest.fixture(name="climate_heatit_z_trm2fx") -def climate_heatit_z_trm2fx_fixture(client, climate_heatit_z_trm2fx_state): +def climate_heatit_z_trm2fx_fixture(client, climate_heatit_z_trm2fx_state) -> Node: """Mock a climate radio HEATIT Z-TRM2fx node.""" node = Node(client, copy.deepcopy(climate_heatit_z_trm2fx_state)) client.driver.controller.nodes[node.node_id] = node @@ -747,7 +756,7 @@ def climate_heatit_z_trm2fx_fixture(client, climate_heatit_z_trm2fx_state): @pytest.fixture(name="nortek_thermostat") -def nortek_thermostat_fixture(client, nortek_thermostat_state): +def nortek_thermostat_fixture(client, nortek_thermostat_state) -> Node: """Mock a nortek thermostat node.""" node = Node(client, copy.deepcopy(nortek_thermostat_state)) client.driver.controller.nodes[node.node_id] = node @@ -755,7 +764,7 @@ def nortek_thermostat_fixture(client, nortek_thermostat_state): @pytest.fixture(name="srt321_hrt4_zw") -def srt321_hrt4_zw_fixture(client, srt321_hrt4_zw_state): +def srt321_hrt4_zw_fixture(client, srt321_hrt4_zw_state) -> Node: """Mock a HRT4-ZW / SRT321 / SRT322 thermostat node.""" node = Node(client, copy.deepcopy(srt321_hrt4_zw_state)) client.driver.controller.nodes[node.node_id] = node @@ -763,7 +772,9 @@ def srt321_hrt4_zw_fixture(client, srt321_hrt4_zw_state): @pytest.fixture(name="aeotec_radiator_thermostat") -def aeotec_radiator_thermostat_fixture(client, aeotec_radiator_thermostat_state): +def aeotec_radiator_thermostat_fixture( + client, aeotec_radiator_thermostat_state +) -> Node: """Mock a Aeotec thermostat node.""" node = Node(client, aeotec_radiator_thermostat_state) client.driver.controller.nodes[node.node_id] = node @@ -771,23 +782,23 @@ def aeotec_radiator_thermostat_fixture(client, aeotec_radiator_thermostat_state) @pytest.fixture(name="nortek_thermostat_added_event") -def nortek_thermostat_added_event_fixture(client): +def nortek_thermostat_added_event_fixture(client) -> Node: """Mock a Nortek thermostat node added event.""" - event_data = json.loads(load_fixture("zwave_js/nortek_thermostat_added_event.json")) + event_data = load_json_object_fixture("nortek_thermostat_added_event.json", DOMAIN) return Event("node added", event_data) @pytest.fixture(name="nortek_thermostat_removed_event") -def nortek_thermostat_removed_event_fixture(client): +def nortek_thermostat_removed_event_fixture(client) -> Node: """Mock a Nortek thermostat node removed event.""" - event_data = json.loads( - load_fixture("zwave_js/nortek_thermostat_removed_event.json") + event_data = load_json_object_fixture( + "nortek_thermostat_removed_event.json", DOMAIN ) return Event("node removed", event_data) @pytest.fixture(name="integration") -async def integration_fixture(hass: HomeAssistant, client): +async def integration_fixture(hass: HomeAssistant, client) -> Node: """Set up the zwave_js integration.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -800,7 +811,7 @@ async def integration_fixture(hass: HomeAssistant, client): @pytest.fixture(name="chain_actuator_zws12") -def window_cover_fixture(client, chain_actuator_zws12_state): +def window_cover_fixture(client, chain_actuator_zws12_state) -> Node: """Mock a window cover node.""" node = Node(client, copy.deepcopy(chain_actuator_zws12_state)) client.driver.controller.nodes[node.node_id] = node @@ -808,7 +819,7 @@ def window_cover_fixture(client, chain_actuator_zws12_state): @pytest.fixture(name="fan_generic") -def fan_generic_fixture(client, fan_generic_state): +def fan_generic_fixture(client, fan_generic_state) -> Node: """Mock a fan node.""" node = Node(client, copy.deepcopy(fan_generic_state)) client.driver.controller.nodes[node.node_id] = node @@ -816,7 +827,7 @@ def fan_generic_fixture(client, fan_generic_state): @pytest.fixture(name="hs_fc200") -def hs_fc200_fixture(client, hs_fc200_state): +def hs_fc200_fixture(client, hs_fc200_state) -> Node: """Mock a fan node.""" node = Node(client, copy.deepcopy(hs_fc200_state)) client.driver.controller.nodes[node.node_id] = node @@ -824,7 +835,7 @@ def hs_fc200_fixture(client, hs_fc200_state): @pytest.fixture(name="leviton_zw4sf") -def leviton_zw4sf_fixture(client, leviton_zw4sf_state): +def leviton_zw4sf_fixture(client, leviton_zw4sf_state) -> Node: """Mock a fan node.""" node = Node(client, copy.deepcopy(leviton_zw4sf_state)) client.driver.controller.nodes[node.node_id] = node @@ -832,7 +843,7 @@ def leviton_zw4sf_fixture(client, leviton_zw4sf_state): @pytest.fixture(name="fan_honeywell_39358") -def fan_honeywell_39358_fixture(client, fan_honeywell_39358_state): +def fan_honeywell_39358_fixture(client, fan_honeywell_39358_state) -> Node: """Mock a fan node.""" node = Node(client, copy.deepcopy(fan_honeywell_39358_state)) client.driver.controller.nodes[node.node_id] = node @@ -840,7 +851,7 @@ def fan_honeywell_39358_fixture(client, fan_honeywell_39358_state): @pytest.fixture(name="null_name_check") -def null_name_check_fixture(client, null_name_check_state): +def null_name_check_fixture(client, null_name_check_state) -> Node: """Mock a node with no name.""" node = Node(client, copy.deepcopy(null_name_check_state)) client.driver.controller.nodes[node.node_id] = node @@ -848,7 +859,7 @@ def null_name_check_fixture(client, null_name_check_state): @pytest.fixture(name="gdc_zw062") -def motorized_barrier_cover_fixture(client, gdc_zw062_state): +def motorized_barrier_cover_fixture(client, gdc_zw062_state) -> Node: """Mock a motorized barrier node.""" node = Node(client, copy.deepcopy(gdc_zw062_state)) client.driver.controller.nodes[node.node_id] = node @@ -856,7 +867,7 @@ def motorized_barrier_cover_fixture(client, gdc_zw062_state): @pytest.fixture(name="iblinds_v2") -def iblinds_v2_cover_fixture(client, iblinds_v2_state): +def iblinds_v2_cover_fixture(client, iblinds_v2_state) -> Node: """Mock an iBlinds v2.0 window cover node.""" node = Node(client, copy.deepcopy(iblinds_v2_state)) client.driver.controller.nodes[node.node_id] = node @@ -864,7 +875,7 @@ def iblinds_v2_cover_fixture(client, iblinds_v2_state): @pytest.fixture(name="iblinds_v3") -def iblinds_v3_cover_fixture(client, iblinds_v3_state): +def iblinds_v3_cover_fixture(client, iblinds_v3_state) -> Node: """Mock an iBlinds v3 window cover node.""" node = Node(client, copy.deepcopy(iblinds_v3_state)) client.driver.controller.nodes[node.node_id] = node @@ -872,7 +883,7 @@ def iblinds_v3_cover_fixture(client, iblinds_v3_state): @pytest.fixture(name="zvidar") -def zvidar_cover_fixture(client, zvidar_state): +def zvidar_cover_fixture(client, zvidar_state) -> Node: """Mock a ZVIDAR window cover node.""" node = Node(client, copy.deepcopy(zvidar_state)) client.driver.controller.nodes[node.node_id] = node @@ -880,7 +891,7 @@ def zvidar_cover_fixture(client, zvidar_state): @pytest.fixture(name="qubino_shutter") -def qubino_shutter_cover_fixture(client, qubino_shutter_state): +def qubino_shutter_cover_fixture(client, qubino_shutter_state) -> Node: """Mock a Qubino flush shutter node.""" node = Node(client, copy.deepcopy(qubino_shutter_state)) client.driver.controller.nodes[node.node_id] = node @@ -888,7 +899,7 @@ def qubino_shutter_cover_fixture(client, qubino_shutter_state): @pytest.fixture(name="aeotec_nano_shutter") -def aeotec_nano_shutter_cover_fixture(client, aeotec_nano_shutter_state): +def aeotec_nano_shutter_cover_fixture(client, aeotec_nano_shutter_state) -> Node: """Mock a Aeotec Nano Shutter node.""" node = Node(client, copy.deepcopy(aeotec_nano_shutter_state)) client.driver.controller.nodes[node.node_id] = node @@ -896,7 +907,7 @@ def aeotec_nano_shutter_cover_fixture(client, aeotec_nano_shutter_state): @pytest.fixture(name="fibaro_fgr222_shutter") -def fibaro_fgr222_shutter_cover_fixture(client, fibaro_fgr222_shutter_state): +def fibaro_fgr222_shutter_cover_fixture(client, fibaro_fgr222_shutter_state) -> Node: """Mock a Fibaro FGR222 Shutter node.""" node = Node(client, copy.deepcopy(fibaro_fgr222_shutter_state)) client.driver.controller.nodes[node.node_id] = node @@ -904,7 +915,7 @@ def fibaro_fgr222_shutter_cover_fixture(client, fibaro_fgr222_shutter_state): @pytest.fixture(name="fibaro_fgr223_shutter") -def fibaro_fgr223_shutter_cover_fixture(client, fibaro_fgr223_shutter_state): +def fibaro_fgr223_shutter_cover_fixture(client, fibaro_fgr223_shutter_state) -> Node: """Mock a Fibaro FGR223 Shutter node.""" node = Node(client, copy.deepcopy(fibaro_fgr223_shutter_state)) client.driver.controller.nodes[node.node_id] = node @@ -914,7 +925,7 @@ def fibaro_fgr223_shutter_cover_fixture(client, fibaro_fgr223_shutter_state): @pytest.fixture(name="shelly_qnsh_001P10_shutter") def shelly_qnsh_001P10_cover_shutter_fixture( client, shelly_europe_ltd_qnsh_001p10_state -): +) -> Node: """Mock a Shelly QNSH 001P10 Shutter node.""" node = Node(client, copy.deepcopy(shelly_europe_ltd_qnsh_001p10_state)) client.driver.controller.nodes[node.node_id] = node @@ -922,7 +933,7 @@ def shelly_qnsh_001P10_cover_shutter_fixture( @pytest.fixture(name="merten_507801") -def merten_507801_cover_fixture(client, merten_507801_state): +def merten_507801_cover_fixture(client, merten_507801_state) -> Node: """Mock a Merten 507801 Shutter node.""" node = Node(client, copy.deepcopy(merten_507801_state)) client.driver.controller.nodes[node.node_id] = node @@ -930,7 +941,7 @@ def merten_507801_cover_fixture(client, merten_507801_state): @pytest.fixture(name="aeon_smart_switch_6") -def aeon_smart_switch_6_fixture(client, aeon_smart_switch_6_state): +def aeon_smart_switch_6_fixture(client, aeon_smart_switch_6_state) -> Node: """Mock an AEON Labs (ZW096) Smart Switch 6 node.""" node = Node(client, aeon_smart_switch_6_state) client.driver.controller.nodes[node.node_id] = node @@ -938,7 +949,7 @@ def aeon_smart_switch_6_fixture(client, aeon_smart_switch_6_state): @pytest.fixture(name="ge_12730") -def ge_12730_fixture(client, ge_12730_state): +def ge_12730_fixture(client, ge_12730_state) -> Node: """Mock a GE 12730 fan controller node.""" node = Node(client, copy.deepcopy(ge_12730_state)) client.driver.controller.nodes[node.node_id] = node @@ -946,7 +957,7 @@ def ge_12730_fixture(client, ge_12730_state): @pytest.fixture(name="inovelli_lzw36") -def inovelli_lzw36_fixture(client, inovelli_lzw36_state): +def inovelli_lzw36_fixture(client, inovelli_lzw36_state) -> Node: """Mock a Inovelli LZW36 fan controller node.""" node = Node(client, copy.deepcopy(inovelli_lzw36_state)) client.driver.controller.nodes[node.node_id] = node @@ -954,7 +965,7 @@ def inovelli_lzw36_fixture(client, inovelli_lzw36_state): @pytest.fixture(name="lock_id_lock_as_id150") -def lock_id_lock_as_id150(client, lock_id_lock_as_id150_state): +def lock_id_lock_as_id150_fixture(client, lock_id_lock_as_id150_state) -> Node: """Mock an id lock id-150 lock node.""" node = Node(client, copy.deepcopy(lock_id_lock_as_id150_state)) client.driver.controller.nodes[node.node_id] = node @@ -962,7 +973,7 @@ def lock_id_lock_as_id150(client, lock_id_lock_as_id150_state): @pytest.fixture(name="lock_id_lock_as_id150_not_ready") -def node_not_ready(client, lock_id_lock_as_id150_state): +def node_not_ready_fixture(client, lock_id_lock_as_id150_state) -> Node: """Mock an id lock id-150 lock node that's not ready.""" state = copy.deepcopy(lock_id_lock_as_id150_state) state["ready"] = False @@ -974,7 +985,7 @@ def node_not_ready(client, lock_id_lock_as_id150_state): @pytest.fixture(name="climate_radio_thermostat_ct101_multiple_temp_units") def climate_radio_thermostat_ct101_multiple_temp_units_fixture( client, climate_radio_thermostat_ct101_multiple_temp_units_state -): +) -> Node: """Mock a climate device with multiple temp units node.""" node = Node( client, copy.deepcopy(climate_radio_thermostat_ct101_multiple_temp_units_state) @@ -989,7 +1000,7 @@ def climate_radio_thermostat_ct101_multiple_temp_units_fixture( def climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_fixture( client, climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state, -): +) -> Node: """Mock a climate device with mode and setpoint on differenet endpoints node.""" node = Node( client, @@ -1002,7 +1013,7 @@ def climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_fixt @pytest.fixture(name="vision_security_zl7432") -def vision_security_zl7432_fixture(client, vision_security_zl7432_state): +def vision_security_zl7432_fixture(client, vision_security_zl7432_state) -> Node: """Mock a vision security zl7432 node.""" node = Node(client, copy.deepcopy(vision_security_zl7432_state)) client.driver.controller.nodes[node.node_id] = node @@ -1010,7 +1021,7 @@ def vision_security_zl7432_fixture(client, vision_security_zl7432_state): @pytest.fixture(name="zen_31") -def zen_31_fixture(client, zen_31_state): +def zen_31_fixture(client, zen_31_state) -> Node: """Mock a bulb 6 multi-color node.""" node = Node(client, copy.deepcopy(zen_31_state)) client.driver.controller.nodes[node.node_id] = node @@ -1018,7 +1029,7 @@ def zen_31_fixture(client, zen_31_state): @pytest.fixture(name="wallmote_central_scene") -def wallmote_central_scene_fixture(client, wallmote_central_scene_state): +def wallmote_central_scene_fixture(client, wallmote_central_scene_state) -> Node: """Mock a wallmote central scene node.""" node = Node(client, copy.deepcopy(wallmote_central_scene_state)) client.driver.controller.nodes[node.node_id] = node @@ -1026,7 +1037,7 @@ def wallmote_central_scene_fixture(client, wallmote_central_scene_state): @pytest.fixture(name="ge_in_wall_dimmer_switch") -def ge_in_wall_dimmer_switch_fixture(client, ge_in_wall_dimmer_switch_state): +def ge_in_wall_dimmer_switch_fixture(client, ge_in_wall_dimmer_switch_state) -> Node: """Mock a ge in-wall dimmer switch scene node.""" node = Node(client, copy.deepcopy(ge_in_wall_dimmer_switch_state)) client.driver.controller.nodes[node.node_id] = node @@ -1034,7 +1045,7 @@ def ge_in_wall_dimmer_switch_fixture(client, ge_in_wall_dimmer_switch_state): @pytest.fixture(name="aeotec_zw164_siren") -def aeotec_zw164_siren_fixture(client, aeotec_zw164_siren_state): +def aeotec_zw164_siren_fixture(client, aeotec_zw164_siren_state) -> Node: """Mock a aeotec zw164 siren node.""" node = Node(client, copy.deepcopy(aeotec_zw164_siren_state)) client.driver.controller.nodes[node.node_id] = node @@ -1044,7 +1055,7 @@ def aeotec_zw164_siren_fixture(client, aeotec_zw164_siren_state): @pytest.fixture(name="lock_popp_electric_strike_lock_control") def lock_popp_electric_strike_lock_control_fixture( client, lock_popp_electric_strike_lock_control_state -): +) -> Node: """Mock a popp electric strike lock control node.""" node = Node(client, copy.deepcopy(lock_popp_electric_strike_lock_control_state)) client.driver.controller.nodes[node.node_id] = node @@ -1052,7 +1063,7 @@ def lock_popp_electric_strike_lock_control_fixture( @pytest.fixture(name="fortrezz_ssa1_siren") -def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state): +def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state) -> Node: """Mock a fortrezz ssa1 siren node.""" node = Node(client, copy.deepcopy(fortrezz_ssa1_siren_state)) client.driver.controller.nodes[node.node_id] = node @@ -1060,7 +1071,7 @@ def fortrezz_ssa1_siren_fixture(client, fortrezz_ssa1_siren_state): @pytest.fixture(name="fortrezz_ssa3_siren") -def fortrezz_ssa3_siren_fixture(client, fortrezz_ssa3_siren_state): +def fortrezz_ssa3_siren_fixture(client, fortrezz_ssa3_siren_state) -> Node: """Mock a fortrezz ssa3 siren node.""" node = Node(client, copy.deepcopy(fortrezz_ssa3_siren_state)) client.driver.controller.nodes[node.node_id] = node @@ -1068,13 +1079,13 @@ def fortrezz_ssa3_siren_fixture(client, fortrezz_ssa3_siren_state): @pytest.fixture(name="firmware_file") -def firmware_file_fixture(): +def firmware_file_fixture() -> io.BytesIO: """Return mock firmware file stream.""" return io.BytesIO(bytes(10)) @pytest.fixture(name="zp3111_not_ready") -def zp3111_not_ready_fixture(client, zp3111_not_ready_state): +def zp3111_not_ready_fixture(client, zp3111_not_ready_state) -> Node: """Mock a zp3111 4-in-1 sensor node in a not-ready state.""" node = Node(client, copy.deepcopy(zp3111_not_ready_state)) client.driver.controller.nodes[node.node_id] = node @@ -1082,7 +1093,7 @@ def zp3111_not_ready_fixture(client, zp3111_not_ready_state): @pytest.fixture(name="zp3111") -def zp3111_fixture(client, zp3111_state): +def zp3111_fixture(client, zp3111_state) -> Node: """Mock a zp3111 4-in-1 sensor node.""" node = Node(client, copy.deepcopy(zp3111_state)) client.driver.controller.nodes[node.node_id] = node @@ -1090,7 +1101,9 @@ def zp3111_fixture(client, zp3111_state): @pytest.fixture(name="express_controls_ezmultipli") -def express_controls_ezmultipli_fixture(client, express_controls_ezmultipli_state): +def express_controls_ezmultipli_fixture( + client, express_controls_ezmultipli_state +) -> Node: """Mock a Express Controls EZMultiPli node.""" node = Node(client, copy.deepcopy(express_controls_ezmultipli_state)) client.driver.controller.nodes[node.node_id] = node @@ -1098,7 +1111,7 @@ def express_controls_ezmultipli_fixture(client, express_controls_ezmultipli_stat @pytest.fixture(name="lock_home_connect_620") -def lock_home_connect_620_fixture(client, lock_home_connect_620_state): +def lock_home_connect_620_fixture(client, lock_home_connect_620_state) -> Node: """Mock a Home Connect 620 lock node.""" node = Node(client, copy.deepcopy(lock_home_connect_620_state)) client.driver.controller.nodes[node.node_id] = node @@ -1106,7 +1119,7 @@ def lock_home_connect_620_fixture(client, lock_home_connect_620_state): @pytest.fixture(name="switch_zooz_zen72") -def switch_zooz_zen72_fixture(client, switch_zooz_zen72_state): +def switch_zooz_zen72_fixture(client, switch_zooz_zen72_state) -> Node: """Mock a Zooz Zen72 switch node.""" node = Node(client, copy.deepcopy(switch_zooz_zen72_state)) client.driver.controller.nodes[node.node_id] = node @@ -1114,7 +1127,7 @@ def switch_zooz_zen72_fixture(client, switch_zooz_zen72_state): @pytest.fixture(name="indicator_test") -def indicator_test_fixture(client, indicator_test_state): +def indicator_test_fixture(client, indicator_test_state) -> Node: """Mock a indicator CC test node.""" node = Node(client, copy.deepcopy(indicator_test_state)) client.driver.controller.nodes[node.node_id] = node @@ -1122,7 +1135,7 @@ def indicator_test_fixture(client, indicator_test_state): @pytest.fixture(name="energy_production") -def energy_production_fixture(client, energy_production_state): +def energy_production_fixture(client, energy_production_state) -> Node: """Mock a mock node with Energy Production CC.""" node = Node(client, copy.deepcopy(energy_production_state)) client.driver.controller.nodes[node.node_id] = node @@ -1130,7 +1143,7 @@ def energy_production_fixture(client, energy_production_state): @pytest.fixture(name="nice_ibt4zwave") -def nice_ibt4zwave_fixture(client, nice_ibt4zwave_state): +def nice_ibt4zwave_fixture(client, nice_ibt4zwave_state) -> Node: """Mock a Nice IBT4ZWAVE cover node.""" node = Node(client, copy.deepcopy(nice_ibt4zwave_state)) client.driver.controller.nodes[node.node_id] = node @@ -1138,7 +1151,7 @@ def nice_ibt4zwave_fixture(client, nice_ibt4zwave_state): @pytest.fixture(name="logic_group_zdb5100") -def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): +def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state) -> Node: """Mock a ZDB5100 light node.""" node = Node(client, copy.deepcopy(logic_group_zdb5100_state)) client.driver.controller.nodes[node.node_id] = node @@ -1146,7 +1159,7 @@ def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): @pytest.fixture(name="central_scene_node") -def central_scene_node_fixture(client, central_scene_node_state): +def central_scene_node_fixture(client, central_scene_node_state) -> Node: """Mock a node with the Central Scene CC.""" node = Node(client, copy.deepcopy(central_scene_node_state)) client.driver.controller.nodes[node.node_id] = node @@ -1154,7 +1167,9 @@ def central_scene_node_fixture(client, central_scene_node_state): @pytest.fixture(name="light_device_class_is_null") -def light_device_class_is_null_fixture(client, light_device_class_is_null_state): +def light_device_class_is_null_fixture( + client, light_device_class_is_null_state +) -> Node: """Mock a node when device class is null.""" node = Node(client, copy.deepcopy(light_device_class_is_null_state)) client.driver.controller.nodes[node.node_id] = node @@ -1162,7 +1177,7 @@ def light_device_class_is_null_fixture(client, light_device_class_is_null_state) @pytest.fixture(name="basic_cc_sensor") -def basic_cc_sensor_fixture(client, basic_cc_sensor_state): +def basic_cc_sensor_fixture(client, basic_cc_sensor_state) -> Node: """Mock a node with a Basic CC.""" node = Node(client, copy.deepcopy(basic_cc_sensor_state)) client.driver.controller.nodes[node.node_id] = node @@ -1172,7 +1187,7 @@ def basic_cc_sensor_fixture(client, basic_cc_sensor_state): @pytest.fixture(name="window_covering_outbound_bottom") def window_covering_outbound_bottom_fixture( client, window_covering_outbound_bottom_state -): +) -> Node: """Load node with Window Covering CC fixture data, with only the outbound bottom position supported.""" node = Node(client, copy.deepcopy(window_covering_outbound_bottom_state)) client.driver.controller.nodes[node.node_id] = node From 156a88a3a305f9d4d1187ddb7cbfc45ff8fc5ddf Mon Sep 17 00:00:00 2001 From: Antony Kurniawan Date: Mon, 16 Sep 2024 15:49:15 +0700 Subject: [PATCH 0677/1309] Ignore negative derivative when the input is total_increasing (#119141) * if the derivative is negative, ignore it * add option to ignore the negatives or not * add tests for a new ignore negative derivative * add missing description when editing * rename to ignore_negative_derivative to increase clarity of which negative I mean in case in the future we want a ignore_negative_value... * use state_class=total_increasing to ignore the negative derivative * remove ignore negative from the config * add test for total_increasing_reset case * add comments * update test_total_increasing_reset with history tests Also remove the last comment because the test is already clear My existing comment there isn't unique to this unit test but applies to the entire component. The existing web documentation pointing to Wikipedia should suffice. --------- Co-authored-by: Erik Montnemery --- homeassistant/components/derivative/sensor.py | 12 +++++++ tests/components/derivative/test_sensor.py | 36 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 36719b43ccb..be27201bda9 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -10,9 +10,11 @@ from typing import TYPE_CHECKING import voluptuous as vol from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, RestoreSensor, SensorEntity, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -238,6 +240,16 @@ class DerivativeSensor(RestoreSensor, SensorEntity): except AssertionError as err: _LOGGER.error("Could not calculate derivative: %s", err) + # For total inreasing sensors, the value is expected to continuously increase. + # A negative derivative for a total increasing sensor likely indicates the + # sensor has been reset. To prevent inaccurate data, discard this sample. + if ( + new_state.attributes.get(ATTR_STATE_CLASS) + == SensorStateClass.TOTAL_INCREASING + and new_derivative < 0 + ): + return + # add latest derivative to the window list self._state_list.append( (old_state.last_updated, new_state.last_updated, new_derivative) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 3646340cac3..4a4d8519b25 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -8,6 +8,7 @@ from typing import Any from freezegun import freeze_time from homeassistant.components.derivative.const import DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -354,6 +355,41 @@ async def test_suffix(hass: HomeAssistant) -> None: assert round(float(state.state), config["sensor"]["round"]) == 0.0 +async def test_total_increasing_reset(hass: HomeAssistant) -> None: + """Test derivative sensor state with total_increasing sensor input where it should ignore the reset value.""" + times = [0, 20, 30, 35, 40, 50, 60] + values = [0, 10, 30, 40, 0, 10, 40] + expected_times = [0, 20, 30, 35, 50, 60] + expected_values = ["0.00", "0.50", "2.00", "2.00", "1.00", "3.00"] + + config, entity_id = await _setup_sensor(hass, {"unit_time": UnitOfTime.SECONDS}) + + base_time = dt_util.utcnow() + actual_times = [] + actual_values = [] + with freeze_time(base_time) as freezer: + for time, value in zip(times, values, strict=False): + current_time = base_time + timedelta(seconds=time) + freezer.move_to(current_time) + hass.states.async_set( + entity_id, + value, + {ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + + if state.last_reported == current_time: + actual_times.append(time) + actual_values.append(state.state) + + assert actual_times == expected_times + assert actual_values == expected_values + + async def test_device_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 29fb83e98bfe23f7c3e3ffcc208fa21b28f39db1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:06:25 +0200 Subject: [PATCH 0678/1309] Implement battery state binary sensor in Plugwise (#126020) --- homeassistant/components/plugwise/binary_sensor.py | 7 +++++++ homeassistant/components/plugwise/strings.json | 3 +++ tests/components/plugwise/test_init.py | 9 ++++++--- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 4b251d20a02..fb271ea7264 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -9,6 +9,7 @@ from typing import Any from plugwise.constants import BinarySensorType from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -31,6 +32,12 @@ class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = ( + PlugwiseBinarySensorEntityDescription( + key="low_battery", + translation_key="low_battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), PlugwiseBinarySensorEntityDescription( key="compressor_state", translation_key="compressor_state", diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index f74fc036e2a..c09323f458b 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -30,6 +30,9 @@ }, "entity": { "binary_sensor": { + "low_battery": { + "name": "Battery state" + }, "compressor_state": { "name": "Compressor state" }, diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 26aedf864dc..46ef7b89d09 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -40,6 +40,9 @@ TOM = { "location": "f871b8c4d63549319221e294e4f88074", "model": "Tom/Floor", "name": "Tom Zolder", + "binary_sensors": { + "low_battery": False, + }, "sensors": { "battery": 99, "temperature": 18.6, @@ -221,7 +224,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 29 + == 31 ) assert ( len( @@ -244,7 +247,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 34 + == 37 ) assert ( len( @@ -271,7 +274,7 @@ async def test_update_device( entity_registry, mock_config_entry.entry_id ) ) - == 29 + == 31 ) assert ( len( From 2e76b1f834ea26ef3e1726930812cb4c2ea82518 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:08:42 +0200 Subject: [PATCH 0679/1309] Use shorthand attributes in numato (#126023) --- .../components/numato/binary_sensor.py | 7 +----- homeassistant/components/numato/sensor.py | 24 ++++--------------- homeassistant/components/numato/switch.py | 18 ++++---------- 3 files changed, 9 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/numato/binary_sensor.py b/homeassistant/components/numato/binary_sensor.py index 47ab248d383..a369be43b43 100644 --- a/homeassistant/components/numato/binary_sensor.py +++ b/homeassistant/components/numato/binary_sensor.py @@ -97,7 +97,7 @@ class NumatoGpioBinarySensor(BinarySensorEntity): def __init__(self, name, device_id, port, invert_logic, api): """Initialize the Numato GPIO based binary sensor object.""" - self._name = name or DEVICE_DEFAULT_NAME + self._attr_name = name or DEVICE_DEFAULT_NAME self._device_id = device_id self._port = port self._invert_logic = invert_logic @@ -120,11 +120,6 @@ class NumatoGpioBinarySensor(BinarySensorEntity): self._state = level self.async_write_ha_state() - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def is_on(self): """Return the state of the entity.""" diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index ef71e00bc73..99ef69baa7b 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -74,38 +74,22 @@ class NumatoGpioAdc(SensorEntity): def __init__(self, name, device_id, port, src_range, dst_range, dst_unit, api): """Initialize the sensor.""" - self._name = name + self._attr_name = name self._device_id = device_id self._port = port self._src_range = src_range self._dst_range = dst_range - self._state = None - self._unit_of_measurement = dst_unit + self._attr_native_unit_of_measurement = dst_unit self._api = api - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - def update(self) -> None: """Get the latest data and updates the state.""" try: adc_val = self._api.read_adc_input(self._device_id, self._port) adc_val = self._clamp_to_source_range(adc_val) - self._state = self._linear_scale_to_dest_range(adc_val) + self._attr_native_value = self._linear_scale_to_dest_range(adc_val) except NumatoGpioError as err: - self._state = None + self._attr_native_value = None _LOGGER.error( "Failed to update Numato device %s ADC-port %s: %s", self._device_id, diff --git a/homeassistant/components/numato/switch.py b/homeassistant/components/numato/switch.py index 37d1229e0b2..0a7522c8b11 100644 --- a/homeassistant/components/numato/switch.py +++ b/homeassistant/components/numato/switch.py @@ -73,30 +73,20 @@ class NumatoGpioSwitch(SwitchEntity): def __init__(self, name, device_id, port, invert_logic, api): """Initialize the port.""" - self._name = name or DEVICE_DEFAULT_NAME + self._attr_name = name or DEVICE_DEFAULT_NAME self._device_id = device_id self._port = port self._invert_logic = invert_logic - self._state = False + self._attr_is_on = False self._api = api - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return true if port is turned on.""" - return self._state - def turn_on(self, **kwargs: Any) -> None: """Turn the port on.""" try: self._api.write_output( self._device_id, self._port, 0 if self._invert_logic else 1 ) - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() except NumatoGpioError as err: _LOGGER.error( @@ -112,7 +102,7 @@ class NumatoGpioSwitch(SwitchEntity): self._api.write_output( self._device_id, self._port, 1 if self._invert_logic else 0 ) - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() except NumatoGpioError as err: _LOGGER.error( From f395688c2df539bc18e76903b58d38a7c5234120 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:17:06 +0200 Subject: [PATCH 0680/1309] Move apple_tv base entity to separate module (#126029) --- homeassistant/components/apple_tv/__init__.py | 77 +++---------------- homeassistant/components/apple_tv/const.py | 3 + homeassistant/components/apple_tv/entity.py | 71 +++++++++++++++++ .../components/apple_tv/media_player.py | 3 +- homeassistant/components/apple_tv/remote.py | 3 +- 5 files changed, 87 insertions(+), 70 deletions(-) create mode 100644 homeassistant/components/apple_tv/entity.py diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index d0e414c4e9e..f4417134b37 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -32,14 +32,16 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN +from .const import ( + CONF_CREDENTIALS, + CONF_IDENTIFIERS, + CONF_START_OFF, + DOMAIN, + SIGNAL_CONNECTED, + SIGNAL_DISCONNECTED, +) _LOGGER = logging.getLogger(__name__) @@ -49,9 +51,6 @@ DEFAULT_NAME_HP = "HomePod" BACKOFF_TIME_LOWER_LIMIT = 15 # seconds BACKOFF_TIME_UPPER_LIMIT = 300 # Five minutes -SIGNAL_CONNECTED = "apple_tv_connected" -SIGNAL_DISCONNECTED = "apple_tv_disconnected" - PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] AUTH_EXCEPTIONS = ( @@ -120,64 +119,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -class AppleTVEntity(Entity): - """Device that sends commands to an Apple TV.""" - - _attr_should_poll = False - _attr_has_entity_name = True - _attr_name = None - atv: AppleTVInterface | None = None - - def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None: - """Initialize device.""" - self.manager = manager - self._attr_unique_id = identifier - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, identifier)}, - name=name, - ) - - async def async_added_to_hass(self) -> None: - """Handle when an entity is about to be added to Home Assistant.""" - - @callback - def _async_connected(atv: AppleTVInterface) -> None: - """Handle that a connection was made to a device.""" - self.atv = atv - self.async_device_connected(atv) - self.async_write_ha_state() - - @callback - def _async_disconnected() -> None: - """Handle that a connection to a device was lost.""" - self.async_device_disconnected() - self.atv = None - self.async_write_ha_state() - - if self.manager.atv: - # ATV is already connected - _async_connected(self.manager.atv) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, f"{SIGNAL_CONNECTED}_{self.unique_id}", _async_connected - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{SIGNAL_DISCONNECTED}_{self.unique_id}", - _async_disconnected, - ) - ) - - def async_device_connected(self, atv: AppleTVInterface) -> None: - """Handle when connection is made to device.""" - - def async_device_disconnected(self) -> None: - """Handle when connection was lost to device.""" - - class AppleTVManager(DeviceListener): """Connection and power manager for an Apple TV. diff --git a/homeassistant/components/apple_tv/const.py b/homeassistant/components/apple_tv/const.py index 5fb169ec259..dd215337f1c 100644 --- a/homeassistant/components/apple_tv/const.py +++ b/homeassistant/components/apple_tv/const.py @@ -6,3 +6,6 @@ CONF_CREDENTIALS = "credentials" CONF_IDENTIFIERS = "identifiers" CONF_START_OFF = "start_off" + +SIGNAL_CONNECTED = "apple_tv_connected" +SIGNAL_DISCONNECTED = "apple_tv_disconnected" diff --git a/homeassistant/components/apple_tv/entity.py b/homeassistant/components/apple_tv/entity.py new file mode 100644 index 00000000000..ad8364e2927 --- /dev/null +++ b/homeassistant/components/apple_tv/entity.py @@ -0,0 +1,71 @@ +"""The Apple TV integration.""" + +from __future__ import annotations + +from pyatv.interface import AppleTV as AppleTVInterface + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import AppleTVManager +from .const import DOMAIN, SIGNAL_CONNECTED, SIGNAL_DISCONNECTED + + +class AppleTVEntity(Entity): + """Device that sends commands to an Apple TV.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None + atv: AppleTVInterface | None = None + + def __init__(self, name: str, identifier: str, manager: AppleTVManager) -> None: + """Initialize device.""" + self.manager = manager + self._attr_unique_id = identifier + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + name=name, + ) + + async def async_added_to_hass(self) -> None: + """Handle when an entity is about to be added to Home Assistant.""" + + @callback + def _async_connected(atv: AppleTVInterface) -> None: + """Handle that a connection was made to a device.""" + self.atv = atv + self.async_device_connected(atv) + self.async_write_ha_state() + + @callback + def _async_disconnected() -> None: + """Handle that a connection to a device was lost.""" + self.async_device_disconnected() + self.atv = None + self.async_write_ha_state() + + if self.manager.atv: + # ATV is already connected + _async_connected(self.manager.atv) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"{SIGNAL_CONNECTED}_{self.unique_id}", _async_connected + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SIGNAL_DISCONNECTED}_{self.unique_id}", + _async_disconnected, + ) + ) + + def async_device_connected(self, atv: AppleTVInterface) -> None: + """Handle when connection is made to device.""" + + def async_device_disconnected(self) -> None: + """Handle when connection was lost to device.""" diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 9fb9dee46e1..c6b71c64b4f 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -42,8 +42,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import AppleTvConfigEntry, AppleTVEntity, AppleTVManager +from . import AppleTvConfigEntry, AppleTVManager from .browse_media import build_app_list +from .entity import AppleTVEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index a93a89cad3e..7f2c9f1b591 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -19,7 +19,8 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AppleTvConfigEntry, AppleTVEntity +from . import AppleTvConfigEntry +from .entity import AppleTVEntity _LOGGER = logging.getLogger(__name__) From 9f1cc638c9edb57b7272571e03a12a5ab2c816ae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:26:12 +0200 Subject: [PATCH 0681/1309] Move blebox base entity to separate module (#126027) --- homeassistant/components/blebox/__init__.py | 29 -------------- .../components/blebox/binary_sensor.py | 3 +- homeassistant/components/blebox/button.py | 2 +- homeassistant/components/blebox/climate.py | 2 +- homeassistant/components/blebox/cover.py | 2 +- homeassistant/components/blebox/entity.py | 39 +++++++++++++++++++ homeassistant/components/blebox/light.py | 2 +- homeassistant/components/blebox/sensor.py | 2 +- homeassistant/components/blebox/switch.py | 2 +- 9 files changed, 47 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/blebox/entity.py diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index 77b9618a5e3..89d0d5fb146 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -4,7 +4,6 @@ import logging from blebox_uniapi.box import Box from blebox_uniapi.error import Error -from blebox_uniapi.feature import Feature from blebox_uniapi.session import ApiHost from homeassistant.config_entries import ConfigEntry @@ -17,8 +16,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity from .const import DEFAULT_SETUP_TIMEOUT, DOMAIN, PRODUCT from .helpers import get_maybe_authenticated_session @@ -75,29 +72,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class BleBoxEntity[_FeatureT: Feature](Entity): - """Implements a common class for entities representing a BleBox feature.""" - - def __init__(self, feature: _FeatureT) -> None: - """Initialize a BleBox entity.""" - self._feature = feature - self._attr_name = feature.full_name - self._attr_unique_id = feature.unique_id - product = feature.product - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, product.unique_id)}, - manufacturer=product.brand, - model=product.model, - name=product.name, - sw_version=product.firmware_version, - configuration_url=f"http://{product.address}", - ) - - async def async_update(self) -> None: - """Update the entity state.""" - try: - await self._feature.async_update() - except Error as ex: - _LOGGER.error("Updating '%s' failed: %s", self.name, ex) diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py index 7eb6fd1e5a2..7f909fd9a7b 100644 --- a/homeassistant/components/blebox/binary_sensor.py +++ b/homeassistant/components/blebox/binary_sensor.py @@ -12,7 +12,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, PRODUCT, BleBoxEntity +from .const import DOMAIN, PRODUCT +from .entity import BleBoxEntity BINARY_SENSOR_TYPES = ( BinarySensorEntityDescription( diff --git a/homeassistant/components/blebox/button.py b/homeassistant/components/blebox/button.py index 940fe7f8f6f..24b09306de7 100644 --- a/homeassistant/components/blebox/button.py +++ b/homeassistant/components/blebox/button.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity from .const import DOMAIN, PRODUCT +from .entity import BleBoxEntity async def async_setup_entry( diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index 24f036dcd49..d4834ebbc28 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -17,8 +17,8 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity from .const import DOMAIN, PRODUCT +from .entity import BleBoxEntity SCAN_INTERVAL = timedelta(seconds=5) diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index bb75c88ca2a..c86d7aef056 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -20,8 +20,8 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_O from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity from .const import DOMAIN, PRODUCT +from .entity import BleBoxEntity BLEBOX_TO_COVER_DEVICE_CLASSES = { "gate": CoverDeviceClass.GATE, diff --git a/homeassistant/components/blebox/entity.py b/homeassistant/components/blebox/entity.py new file mode 100644 index 00000000000..14e87349a62 --- /dev/null +++ b/homeassistant/components/blebox/entity.py @@ -0,0 +1,39 @@ +"""Base entity for the BleBox devices integration.""" + +import logging + +from blebox_uniapi.error import Error +from blebox_uniapi.feature import Feature + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class BleBoxEntity[_FeatureT: Feature](Entity): + """Implements a common class for entities representing a BleBox feature.""" + + def __init__(self, feature: _FeatureT) -> None: + """Initialize a BleBox entity.""" + self._feature = feature + self._attr_name = feature.full_name + self._attr_unique_id = feature.unique_id + product = feature.product + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, product.unique_id)}, + manufacturer=product.brand, + model=product.model, + name=product.name, + sw_version=product.firmware_version, + configuration_url=f"http://{product.address}", + ) + + async def async_update(self) -> None: + """Update the entity state.""" + try: + await self._feature.async_update() + except Error as ex: + _LOGGER.error("Updating '%s' failed: %s", self.name, ex) diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 34f9b24b17b..650b8c057de 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -25,8 +25,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity from .const import DOMAIN, PRODUCT +from .entity import BleBoxEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index fa11f6d6680..c60387c97b1 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -27,8 +27,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity from .const import DOMAIN, PRODUCT +from .entity import BleBoxEntity SENSOR_TYPES = ( SensorEntityDescription( diff --git a/homeassistant/components/blebox/switch.py b/homeassistant/components/blebox/switch.py index a68b9f01cf2..93c8df0030c 100644 --- a/homeassistant/components/blebox/switch.py +++ b/homeassistant/components/blebox/switch.py @@ -11,8 +11,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import BleBoxEntity from .const import DOMAIN, PRODUCT +from .entity import BleBoxEntity SCAN_INTERVAL = timedelta(seconds=5) From 02cb6a6af7cd790a78f967e7569c74a90cf0af34 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:28:42 +0200 Subject: [PATCH 0682/1309] Force root import of references from other components (#125816) * Force root import of references from other components * Improve * Adjust * Tweak exceptions * Another * Another * Another * Another * Another * Another * Another * Another * Another * Another * Another * Another * Adjust * More * Ignore violations in test * Improve --- pylint/plugins/hass_imports.py | 44 ++++++++++++++++--- tests/components/cloud/test_http_api.py | 2 + .../components/deconz/test_device_trigger.py | 2 + .../esphome/test_assist_satellite.py | 2 + .../google_assistant/test_smart_home.py | 10 +++++ tests/components/logbook/test_init.py | 2 + .../traccar_server/test_config_flow.py | 2 + tests/components/voip/test_voip.py | 2 + tests/conftest.py | 4 ++ tests/pylint/test_imports.py | 8 ++++ 10 files changed, 72 insertions(+), 6 deletions(-) diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index afe307dce42..f7713daabe8 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -394,6 +394,31 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { ], } +_IGNORE_ROOT_IMPORT = ( + "assist_pipeline", + "automation", + "bluetooth", + "camera", + "cast", + "device_automation", + "device_tracker", + "ffmpeg", + "ffmpeg_motion", + "google_assistant", + "hardware", + "homeassistant", + "homeassistant_hardware", + "http", + "manual", + "plex", + "recorder", + "rest", + "script", + "sensor", + "stream", + "zha", +) + # Blacklist of imports that should be using the namespace @dataclass @@ -489,8 +514,9 @@ class HassImportsFormatChecker(BaseChecker): if module.startswith(f"{self.current_package}."): self.add_message("hass-relative-import", node=node) continue - if module.startswith("homeassistant.components.") and module.endswith( - "const" + if ( + module.startswith("homeassistant.components.") + and len(module.split(".")) > 3 ): if ( self.current_package.startswith("tests.components.") @@ -546,11 +572,17 @@ class HassImportsFormatChecker(BaseChecker): self.add_message("hass-relative-import", node=node) return - if node.modname.startswith("homeassistant.components.") and not ( - self.current_package.startswith("tests.components.") - and self.current_package.split(".")[2] == node.modname.split(".")[2] + if ( + node.modname.startswith("homeassistant.components.") + and (module_parts := node.modname.split(".")) + and (module_integration := module_parts[2]) + and module_integration not in _IGNORE_ROOT_IMPORT + and not ( + self.current_package.startswith("tests.components.") + and self.current_package.split(".")[2] == module_integration + ) ): - if node.modname.endswith(".const"): + if len(module_parts) > 3: self.add_message("hass-component-root-import", node=node) return for name, alias in node.names: diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 5ee9af88681..15339f43dae 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -14,6 +14,8 @@ from hass_nabucasa.voice import TTS_VOICES import pytest from homeassistant.components.alexa import errors as alexa_errors + +# pylint: disable-next=hass-component-root-import from homeassistant.components.alexa.entities import LightCapabilities from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index 6f74db0b82c..1502cc4081d 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -7,6 +7,8 @@ from pytest_unordered import unordered from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN + +# pylint: disable-next=hass-component-root-import from homeassistant.components.binary_sensor.device_trigger import ( CONF_BAT_LOW, CONF_NOT_BAT_LOW, diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index f9a431e19d8..5136e160e89 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -30,6 +30,8 @@ from homeassistant.components.assist_satellite import ( AssistSatelliteEntity, AssistSatelliteEntityFeature, ) + +# pylint: disable-next=hass-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.esphome import DOMAIN from homeassistant.components.esphome.assist_satellite import ( diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index ea8f6957e38..214fc4a38de 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -9,10 +9,20 @@ from pytest_unordered import unordered from homeassistant.components.camera import CameraEntityFeature from homeassistant.components.climate import ATTR_MAX_TEMP, ATTR_MIN_TEMP, HVACMode + +# pylint: disable-next=hass-component-root-import from homeassistant.components.demo.binary_sensor import DemoBinarySensor + +# pylint: disable-next=hass-component-root-import from homeassistant.components.demo.cover import DemoCover + +# pylint: disable-next=hass-component-root-import from homeassistant.components.demo.light import LIGHT_EFFECT_LIST, DemoLight + +# pylint: disable-next=hass-component-root-import from homeassistant.components.demo.media_player import AbstractDemoPlayer + +# pylint: disable-next=hass-component-root-import from homeassistant.components.demo.switch import DemoSwitch from homeassistant.components.google_assistant import ( EVENT_COMMAND_RECEIVED, diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 606c398c31f..8ac7dde67ab 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -11,6 +11,8 @@ import pytest import voluptuous as vol from homeassistant.components import logbook, recorder + +# pylint: disable-next=hass-component-root-import from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED from homeassistant.components.logbook.models import EventAsRow, LazyEventPartialState diff --git a/tests/components/traccar_server/test_config_flow.py b/tests/components/traccar_server/test_config_flow.py index 62f39f00dc1..d9500441519 100644 --- a/tests/components/traccar_server/test_config_flow.py +++ b/tests/components/traccar_server/test_config_flow.py @@ -8,6 +8,8 @@ import pytest from pytraccar import TraccarException from homeassistant import config_entries + +# pylint: disable-next=hass-component-root-import from homeassistant.components.traccar.device_tracker import PLATFORM_SCHEMA from homeassistant.components.traccar_server.const import ( CONF_CUSTOM_ATTRIBUTES, diff --git a/tests/components/voip/test_voip.py b/tests/components/voip/test_voip.py index f856da8b1e9..cf5148e8ba0 100644 --- a/tests/components/voip/test_voip.py +++ b/tests/components/voip/test_voip.py @@ -13,6 +13,8 @@ from voip_utils import CallInfo from homeassistant.components import assist_pipeline, assist_satellite, tts, voip from homeassistant.components.assist_satellite import AssistSatelliteEntity + +# pylint: disable-next=hass-component-root-import from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.voip import HassVoipDatagramProtocol from homeassistant.components.voip.assist_satellite import Tones, VoipAssistSatellite diff --git a/tests/conftest.py b/tests/conftest.py index 178fdd74a69..cfcfaf8526c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,11 +51,15 @@ from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant from homeassistant.components.device_tracker.legacy import Device + +# pylint: disable-next=hass-component-root-import from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED, ) + +# pylint: disable-next=hass-component-root-import from homeassistant.components.websocket_api.http import URL from homeassistant.config import YAML_CONFIG_FILE from homeassistant.config_entries import ConfigEntries, ConfigEntry, ConfigEntryState diff --git a/tests/pylint/test_imports.py b/tests/pylint/test_imports.py index 980b9ead74c..5044e73d253 100644 --- a/tests/pylint/test_imports.py +++ b/tests/pylint/test_imports.py @@ -208,6 +208,10 @@ def test_good_root_import( "from homeassistant.components.climate.const import ClimateEntityFeature", "homeassistant.components.pylint_test.climate", ), + ( + "from homeassistant.components.climate.entity import ClimateEntityFeature", + "homeassistant.components.pylint_test.climate", + ), ( "from homeassistant.components.climate import const", "tests.components.pylint_test.climate", @@ -220,6 +224,10 @@ def test_good_root_import( "import homeassistant.components.climate.const as climate", "tests.components.pylint_test.climate", ), + ( + "import homeassistant.components.climate.entity as climate", + "tests.components.pylint_test.climate", + ), ], ) def test_bad_root_import( From c6d04d874f3084e16ac65bdb5524efc04b0c9b4c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:34:24 +0200 Subject: [PATCH 0683/1309] Move and rename acmeda base entity to separate module (#126028) Move acmeda base entity to separate module --- homeassistant/components/acmeda/cover.py | 4 ++-- homeassistant/components/acmeda/{base.py => entity.py} | 2 +- homeassistant/components/acmeda/sensor.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename homeassistant/components/acmeda/{base.py => entity.py} (98%) diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index d96675de10c..77099e86adc 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -14,8 +14,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AcmedaConfigEntry -from .base import AcmedaBase from .const import ACMEDA_HUB_UPDATE +from .entity import AcmedaEntity from .helpers import async_add_acmeda_entities @@ -44,7 +44,7 @@ async def async_setup_entry( ) -class AcmedaCover(AcmedaBase, CoverEntity): +class AcmedaCover(AcmedaEntity, CoverEntity): """Representation of an Acmeda cover device.""" _attr_name = None diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/entity.py similarity index 98% rename from homeassistant/components/acmeda/base.py rename to homeassistant/components/acmeda/entity.py index 149fceaa2df..63432886b4d 100644 --- a/homeassistant/components/acmeda/base.py +++ b/homeassistant/components/acmeda/entity.py @@ -11,7 +11,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ACMEDA_ENTITY_REMOVE, DOMAIN, LOGGER -class AcmedaBase(entity.Entity): +class AcmedaEntity(entity.Entity): """Base representation of an Acmeda roller.""" _attr_should_poll = False diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index be9f37b03dc..f5df1bf013d 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -9,8 +9,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AcmedaConfigEntry -from .base import AcmedaBase from .const import ACMEDA_HUB_UPDATE +from .entity import AcmedaEntity from .helpers import async_add_acmeda_entities @@ -39,7 +39,7 @@ async def async_setup_entry( ) -class AcmedaBattery(AcmedaBase, SensorEntity): +class AcmedaBattery(AcmedaEntity, SensorEntity): """Representation of an Acmeda cover sensor.""" _attr_device_class = SensorDeviceClass.BATTERY From 53c23dfb6fb81f13a200069e91589afac7e77ca4 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 16 Sep 2024 11:41:26 +0200 Subject: [PATCH 0684/1309] Use debug/warning instead of info log level in components [g] (#126032) --- .../components/generic_hygrostat/humidifier.py | 6 +++--- homeassistant/components/generic_thermostat/climate.py | 10 +++++----- homeassistant/components/geniushub/__init__.py | 2 +- homeassistant/components/gree/coordinator.py | 2 +- .../components/growatt_server/sensor/__init__.py | 2 +- homeassistant/components/guardian/util.py | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 0aa4ba2e515..69c4fb3cdf4 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -480,7 +480,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): ): self._active = True force = True - _LOGGER.info( + _LOGGER.debug( ( "Obtained current and target humidity. " "Generic hygrostat active. %s, %s" @@ -530,7 +530,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): ) or ( self._device_class == HumidifierDeviceClass.DEHUMIDIFIER and too_dry ): - _LOGGER.info("Turning off humidifier %s", self._switch_entity_id) + _LOGGER.debug("Turning off humidifier %s", self._switch_entity_id) await self._async_device_turn_off() elif time is not None: # The time argument is passed only in keep-alive case @@ -538,7 +538,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): elif ( self._device_class == HumidifierDeviceClass.HUMIDIFIER and too_dry ) or (self._device_class == HumidifierDeviceClass.DEHUMIDIFIER and too_wet): - _LOGGER.info("Turning on humidifier %s", self._switch_entity_id) + _LOGGER.debug("Turning on humidifier %s", self._switch_entity_id) await self._async_device_turn_on() elif time is not None: # The time argument is passed only in keep-alive case diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 2a118b70879..d68eaccbb0c 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -500,7 +500,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._target_temp, ): self._active = True - _LOGGER.info( + _LOGGER.debug( ( "Obtained current and target temperature. " "Generic thermostat active. %s, %s" @@ -539,21 +539,21 @@ class GenericThermostat(ClimateEntity, RestoreEntity): too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance if self._is_device_active: if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot): - _LOGGER.info("Turning off heater %s", self.heater_entity_id) + _LOGGER.debug("Turning off heater %s", self.heater_entity_id) await self._async_heater_turn_off() elif time is not None: # The time argument is passed only in keep-alive case - _LOGGER.info( + _LOGGER.debug( "Keep-alive - Turning on heater heater %s", self.heater_entity_id, ) await self._async_heater_turn_on() elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold): - _LOGGER.info("Turning on heater %s", self.heater_entity_id) + _LOGGER.debug("Turning on heater %s", self.heater_entity_id) await self._async_heater_turn_on() elif time is not None: # The time argument is passed only in keep-alive case - _LOGGER.info( + _LOGGER.debug( "Keep-alive - Turning off heater %s", self.heater_entity_id ) await self._async_heater_turn_off() diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 836add310b6..0609b675504 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -239,7 +239,7 @@ class GeniusBroker: await self.client.update() if self._connect_error: self._connect_error = False - _LOGGER.info("Connection to geniushub re-established") + _LOGGER.warning("Connection to geniushub re-established") except ( aiohttp.ClientResponseError, aiohttp.client_exceptions.ClientConnectorError, diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py index ae8b22706ef..42d6734a6b2 100644 --- a/homeassistant/components/gree/coordinator.py +++ b/homeassistant/components/gree/coordinator.py @@ -138,7 +138,7 @@ class DiscoveryService(Listener): except DeviceTimeoutError: _LOGGER.error("Timeout trying to bind to gree device: %s", device_info) - _LOGGER.info( + _LOGGER.debug( "Adding Gree device %s at %s:%i", device.device_info.name, device.device_info.ip, diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index b0a93879bb3..e77660e6a3a 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -72,7 +72,7 @@ async def async_setup_entry( # If the URL has been deprecated then change to the default instead if url in DEPRECATED_URLS: - _LOGGER.info( + _LOGGER.warning( "URL: %s has been deprecated, migrating to the latest default: %s", url, DEFAULT_URL, diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 4b9a2835474..48e0a51c70a 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -55,7 +55,7 @@ def async_finish_entity_domain_replacements( continue old_entity_id = registry_entry.entity_id - LOGGER.info('Removing old entity: "%s"', old_entity_id) + LOGGER.debug('Removing old entity: "%s"', old_entity_id) ent_reg.async_remove(old_entity_id) From b32f40c0fe89f74274a3ac3863b182ce5e73f973 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 16 Sep 2024 11:44:14 +0200 Subject: [PATCH 0685/1309] Use debug/warning instead of info log level in components [h] (#126033) --- homeassistant/components/harmony/__init__.py | 2 +- homeassistant/components/hdmi_cec/__init__.py | 6 +++--- homeassistant/components/hdmi_cec/switch.py | 2 +- homeassistant/components/hitron_coda/device_tracker.py | 7 +++---- homeassistant/components/homekit/__init__.py | 2 +- homeassistant/components/homekit/type_cameras.py | 6 +++--- homeassistant/components/homekit/type_covers.py | 2 +- homeassistant/components/homekit_controller/connection.py | 6 +++--- .../components/homematicip_cloud/alarm_control_panel.py | 1 - homeassistant/components/homematicip_cloud/config_flow.py | 8 ++++---- .../components/homematicip_cloud/generic_entity.py | 1 - homeassistant/components/homematicip_cloud/hap.py | 4 ++-- homeassistant/components/horizon/media_player.py | 2 +- homeassistant/components/huawei_lte/__init__.py | 8 ++++---- 14 files changed, 27 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index 12f7d903f0d..9a643815385 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -59,7 +59,7 @@ async def _migrate_old_unique_ids( activity_id = names_to_ids.get(activity_name) if activity_id is not None: - _LOGGER.info( + _LOGGER.debug( "Migrating unique_id from [%s] to [%s]", entity_entry.unique_id, activity_id, diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 43a649ba01a..9d208b3a228 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -210,7 +210,7 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 _LOGGER.debug("Reached _adapter_watchdog") event.call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog_job) if not adapter.initialized: - _LOGGER.info("Adapter not initialized; Trying to restart") + _LOGGER.warning("Adapter not initialized; Trying to restart") hass.bus.fire(EVENT_HDMI_CEC_UNAVAILABLE) adapter.init() @@ -240,7 +240,7 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 KeyPressCommand(mute_key_mapping[att], dst=ADDR_AUDIOSYSTEM) ) hdmi_network.send_command(KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM)) - _LOGGER.info("Audio muted") + _LOGGER.debug("Audio muted") else: _LOGGER.warning("Unknown command %s", cmd) @@ -307,7 +307,7 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 if not isinstance(addr, (PhysicalAddress,)): addr = PhysicalAddress(addr) hdmi_network.active_source(addr) - _LOGGER.info("Selected %s (%s)", call.data[ATTR_DEVICE], addr) + _LOGGER.debug("Selected %s (%s)", call.data[ATTR_DEVICE], addr) def _update(call: ServiceCall) -> None: """Update if device update is needed. diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py index 280ea20413b..95998f44a9a 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -27,7 +27,7 @@ def setup_platform( ) -> None: """Find and return HDMI devices as switches.""" if discovery_info and ATTR_NEW in discovery_info: - _LOGGER.info("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) + _LOGGER.debug("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) entities = [] for device in discovery_info[ATTR_NEW]: hdmi_device = hass.data[DOMAIN][device] diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py index af1c17689c7..2126f5834ce 100644 --- a/homeassistant/components/hitron_coda/device_tracker.py +++ b/homeassistant/components/hitron_coda/device_tracker.py @@ -66,7 +66,6 @@ class HitronCODADeviceScanner(DeviceScanner): self._userid = None self.success_init = self._update_info() - _LOGGER.info("Scanner initialized") def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -82,7 +81,7 @@ class HitronCODADeviceScanner(DeviceScanner): def _login(self): """Log in to the router. This is required for subsequent api calls.""" - _LOGGER.info("Logging in to CODA") + _LOGGER.debug("Logging in to CODA") try: data = [("user", self._username), (self._type, self._password)] @@ -102,7 +101,7 @@ class HitronCODADeviceScanner(DeviceScanner): def _update_info(self): """Get ARP from router.""" - _LOGGER.info("Fetching") + _LOGGER.debug("Fetching") if self._userid is None and not self._login(): _LOGGER.error("Could not obtain a user ID from the router") @@ -137,5 +136,5 @@ class HitronCODADeviceScanner(DeviceScanner): self.last_results = last_results - _LOGGER.info("Request successful") + _LOGGER.debug("Request successful") return True diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 3f633c2ec59..2fec1382766 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -409,7 +409,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: HomeKitConfigEntry) -> break if not logged_shutdown_wait: - _LOGGER.info("Waiting for the HomeKit server to shutdown") + _LOGGER.debug("Waiting for the HomeKit server to shutdown") logged_shutdown_wait = True await asyncio.sleep(PORT_CLEANUP_CHECK_INTERVAL_SECS) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 3851bb43541..13169c877a9 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -453,7 +453,7 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] _LOGGER.error("Failed to open ffmpeg stream") return False - _LOGGER.info( + _LOGGER.debug( "[%s] Started stream process - PID %d", session_info["id"], stream.process.pid, @@ -528,11 +528,11 @@ class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] self._async_stop_ffmpeg_watch(session_id) if not pid_is_alive(stream.process.pid): - _LOGGER.info("[%s] Stream already stopped", session_id) + _LOGGER.warning("[%s] Stream already stopped", session_id) return for shutdown_method in ("close", "kill"): - _LOGGER.info("[%s] %s stream", session_id, shutdown_method) + _LOGGER.debug("[%s] %s stream", session_id, shutdown_method) try: await getattr(stream, shutdown_method)() except Exception: diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index b2f8bc1f01a..855c3b71cc4 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -253,7 +253,7 @@ class OpeningDeviceBase(HomeAccessory): def set_tilt(self, value: float) -> None: """Set tilt to value if call came from HomeKit.""" - _LOGGER.info("%s: Set tilt to %d", self.entity_id, value) + _LOGGER.debug("%s: Set tilt to %d", self.entity_id, value) # HomeKit sends values between -90 and 90. # We'll have to normalize to [0,100] diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 02bcd4265cb..52f22bcc9f4 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -433,7 +433,7 @@ class HKDevice: continue if self.config_entry.entry_id not in device.config_entries: - _LOGGER.info( + _LOGGER.warning( ( "Found candidate device for %s:aid:%s, but owned by a different" " config entry, skipping" @@ -443,7 +443,7 @@ class HKDevice: ) continue - _LOGGER.info( + _LOGGER.debug( "Migrating device identifiers for %s:aid:%s", self.unique_id, accessory.aid, @@ -904,7 +904,7 @@ class HKDevice: return if self._polling_lock_warned: - _LOGGER.info( + _LOGGER.warning( ( "HomeKit device no longer detecting back pressure - not" " skipping poll: %s" diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 1f294a8cade..e1684c34e4e 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -52,7 +52,6 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): def __init__(self, hap: HomematicipHAP) -> None: """Initialize the alarm control panel.""" self._home: AsyncHome = hap.home - _LOGGER.info("Setting up %s", self.name) @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index a8b17a80aff..9a9e1cb6778 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -43,10 +43,10 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN): self.auth = HomematicipAuth(self.hass, user_input) connected = await self.auth.async_setup() if connected: - _LOGGER.info("Connection to HomematicIP Cloud established") + _LOGGER.debug("Connection to HomematicIP Cloud established") return await self.async_step_link() - _LOGGER.info("Connection to HomematicIP Cloud failed") + _LOGGER.debug("Connection to HomematicIP Cloud failed") errors["base"] = "invalid_sgtin_or_pin" return self.async_show_form( @@ -69,7 +69,7 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN): if pressed: authtoken = await self.auth.async_register() if authtoken: - _LOGGER.info("Write config entry for HomematicIP Cloud") + _LOGGER.debug("Write config entry for HomematicIP Cloud") return self.async_create_entry( title=self.auth.config[HMIPC_HAPID], data={ @@ -92,7 +92,7 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(hapid) self._abort_if_unique_id_configured() - _LOGGER.info("Imported authentication for %s", hapid) + _LOGGER.debug("Imported authentication for %s", hapid) return self.async_create_entry( title=hapid, data={HMIPC_AUTHTOKEN: authtoken, HMIPC_HAPID: hapid, HMIPC_NAME: name}, diff --git a/homeassistant/components/homematicip_cloud/generic_entity.py b/homeassistant/components/homematicip_cloud/generic_entity.py index 163f3eec75e..276177420ed 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/generic_entity.py @@ -95,7 +95,6 @@ class HomematicipGenericEntity(Entity): self.functional_channel = self.get_current_channel() # Marker showing that the HmIP device hase been removed. self.hmip_device_removed = False - _LOGGER.info("Setting up %s (%s)", self.name, self._device.modelType) @property def device_info(self) -> DeviceInfo | None: diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 2384426dc82..db7fcb348c8 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -104,7 +104,7 @@ class HomematicipHAP: _LOGGER.error("Error connecting with HomematicIP Cloud: %s", err) return False - _LOGGER.info( + _LOGGER.debug( "Connected to HomematicIP with HAP %s", self.config_entry.unique_id ) @@ -220,7 +220,7 @@ class HomematicipHAP: if self._retry_task is not None: self._retry_task.cancel() await self.home.disable_events() - _LOGGER.info("Closed connection to HomematicIP cloud server") + _LOGGER.debug("Closed connection to HomematicIP cloud server") await self.hass.config_entries.async_unload_platforms( self.config_entry, PLATFORMS ) diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index 9531f9c0ed7..ba3ca5e2e35 100644 --- a/homeassistant/components/horizon/media_player.py +++ b/homeassistant/components/horizon/media_player.py @@ -65,7 +65,7 @@ def setup_platform( _LOGGER.error("Connection to %s at %s failed: %s", name, host, msg) raise PlatformNotReady from msg - _LOGGER.info("Connection to %s at %s established", name, host) + _LOGGER.debug("Connection to %s at %s established", name, host) add_entities([HorizonDevice(client, name, keys)], True) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index b0c40c71658..ad72e839534 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -209,7 +209,7 @@ class Router: else: _LOGGER.debug("failed") return - _LOGGER.info( + _LOGGER.warning( "%s requires authorization, excluding from future updates", key ) self.subscriptions.pop(key) @@ -221,7 +221,7 @@ class Router: exc, (ResponseErrorNotSupportedException, ExpatError) ) and exc.code not in (-1, 100006): raise - _LOGGER.info( + _LOGGER.warning( "%s apparently not supported by device, excluding from future updates", key, ) @@ -559,12 +559,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if isinstance(recipient, str): options[CONF_RECIPIENT] = [x.strip() for x in recipient.split(",")] hass.config_entries.async_update_entry(config_entry, options=options, version=2) - _LOGGER.info("Migrated config entry to version %d", config_entry.version) + _LOGGER.debug("Migrated config entry to version %d", config_entry.version) if config_entry.version == 2: data = dict(config_entry.data) data[CONF_MAC] = [] hass.config_entries.async_update_entry(config_entry, data=data, version=3) - _LOGGER.info("Migrated config entry to version %d", config_entry.version) + _LOGGER.debug("Migrated config entry to version %d", config_entry.version) # There can be no longer needed *_from_yaml data and options things left behind # from pre-2022.4ish; they can be removed while at it when/if we eventually bump and # migrate to version > 3 for some other reason. From 15bf6222f5a5503a36c23dbb49f09abe81ec9a74 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 16 Sep 2024 11:53:13 +0200 Subject: [PATCH 0686/1309] Use Home Assistant aiohttp session for Reolink (#125948) --- homeassistant/components/reolink/host.py | 8 ++++++-- tests/components/reolink/test_config_flow.py | 6 +++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 58ae191eb9f..527f40469b4 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -25,6 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -64,10 +65,12 @@ class ReolinkHost: ) -> None: """Initialize Reolink Host. Could be either NVR, or Camera.""" self._hass: HomeAssistant = hass - - self._clientsession: aiohttp.ClientSession | None = None self._unique_id: str = "" + def get_aiohttp_session() -> aiohttp.ClientSession: + """Return the HA aiohttp session.""" + return async_get_clientsession(hass, verify_ssl=False) + self._api = Host( config[CONF_HOST], config[CONF_USERNAME], @@ -76,6 +79,7 @@ class ReolinkHost: use_https=config.get(CONF_USE_HTTPS), protocol=options[CONF_PROTOCOL], timeout=DEFAULT_TIMEOUT, + aiohttp_get_session_callback=get_aiohttp_session, ) self.last_wake: float = 0 diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4c362e150ca..4d89906a768 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -2,8 +2,9 @@ import json from typing import Any -from unittest.mock import AsyncMock, MagicMock, call +from unittest.mock import ANY, AsyncMock, MagicMock, call +from aiohttp import ClientSession from freezegun.api import FrozenDateTimeFactory import pytest from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkError @@ -492,11 +493,14 @@ async def test_dhcp_ip_update( use_https=TEST_USE_HTTPS, protocol=DEFAULT_PROTOCOL, timeout=DEFAULT_TIMEOUT, + aiohttp_get_session_callback=ANY, ) assert expected_call in reolink_connect_class.call_args_list for exc_call in reolink_connect_class.call_args_list: assert exc_call[0][0] in host_call_list + get_session = exc_call[1]["aiohttp_get_session_callback"] + assert isinstance(get_session(), ClientSession) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" From 18e2c2f6dd41aea684a1e18a63484fbd1fc6e207 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:53:29 +0200 Subject: [PATCH 0687/1309] Disable pylint ignore_missing_annotations in config flow (#125322) * Disable pylint ignore_missing_annotations in config flow * Add tests * Ignore point --- homeassistant/components/point/config_flow.py | 4 ++++ pylint/plugins/hass_enforce_type_hints.py | 24 ++++++++++++------- tests/pylint/test_enforce_type_hints.py | 19 ++++++++++++++- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index b2455438208..390a2691c80 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -25,6 +25,7 @@ _LOGGER = logging.getLogger(__name__) @callback +# pylint: disable-next=hass-argument-type # see PR 118243 def register_flow_implementation(hass, domain, client_id, client_secret): """Register a flow implementation. @@ -51,6 +52,7 @@ class PointFlowHandler(ConfigFlow, domain=DOMAIN): """Initialize flow.""" self.flow_impl = None + # pylint: disable-next=hass-return-type # see PR 118243 async def async_step_import(self, user_input=None): """Handle external yaml configuration.""" if self._async_current_entries(): @@ -86,6 +88,7 @@ class PointFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required("flow_impl"): vol.In(list(flows))}), ) + # pylint: disable-next=hass-return-type # see PR 118243 async def async_step_auth(self, user_input=None): """Create an entry for auth.""" if self._async_current_entries(): @@ -125,6 +128,7 @@ class PointFlowHandler(ConfigFlow, domain=DOMAIN): return point_session.get_authorization_url + # pylint: disable-next=hass-return-type # see PR 118243 async def async_step_code(self, code=None): """Received code for authentication.""" if self._async_current_entries(): diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 13499134668..7f4a7fbd485 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -28,6 +28,8 @@ _KNOWN_GENERIC_TYPES: set[str] = { } _KNOWN_GENERIC_TYPES_TUPLE = tuple(_KNOWN_GENERIC_TYPES) +_FORCE_ANNOTATION_PLATFORMS = ["config_flow"] + class _Special(Enum): """Sentinel values.""" @@ -3108,6 +3110,7 @@ class HassTypeHintChecker(BaseChecker): _class_matchers: list[ClassTypeHintMatch] _function_matchers: list[TypeHintMatch] _module_node: nodes.Module + _module_platform: str | None _in_test_module: bool def visit_module(self, node: nodes.Module) -> None: @@ -3115,24 +3118,22 @@ class HassTypeHintChecker(BaseChecker): self._class_matchers = [] self._function_matchers = [] self._module_node = node + self._module_platform = _get_module_platform(node.name) self._in_test_module = node.name.startswith("tests.") - if ( - self._in_test_module - or (module_platform := _get_module_platform(node.name)) is None - ): + if self._in_test_module or self._module_platform is None: return - if module_platform in _PLATFORMS: + if self._module_platform in _PLATFORMS: self._function_matchers.extend(_FUNCTION_MATCH["__any_platform__"]) - if function_matches := _FUNCTION_MATCH.get(module_platform): + if function_matches := _FUNCTION_MATCH.get(self._module_platform): self._function_matchers.extend(function_matches) - if class_matches := _CLASS_MATCH.get(module_platform): + if class_matches := _CLASS_MATCH.get(self._module_platform): self._class_matchers.extend(class_matches) - if property_matches := _INHERITANCE_MATCH.get(module_platform): + if property_matches := _INHERITANCE_MATCH.get(self._module_platform): self._class_matchers.extend(property_matches) self._class_matchers.reverse() @@ -3142,7 +3143,12 @@ class HassTypeHintChecker(BaseChecker): ) -> bool: """Check if we can skip the function validation.""" return ( - self.linter.config.ignore_missing_annotations + # test modules are excluded from ignore_missing_annotations + not self._in_test_module + # some modules have checks forced + and self._module_platform not in _FORCE_ANNOTATION_PLATFORMS + # other modules are only checked ignore_missing_annotations + and self.linter.config.ignore_missing_annotations and node.returns is None and not _has_valid_annotations(annotations) ) diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index b1692d1d60d..6c53e9832d9 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -313,7 +313,9 @@ def test_invalid_config_flow_step( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: """Ensure invalid hints are rejected for ConfigFlow step.""" - class_node, func_node, arg_node = astroid.extract_node( + type_hint_checker.linter.config.ignore_missing_annotations = True + + class_node, func_node, arg_node, func_node2 = astroid.extract_node( """ class FlowHandler(): pass @@ -329,6 +331,12 @@ def test_invalid_config_flow_step( device_config: dict #@ ): pass + + async def async_step_custom( #@ + self, + user_input + ): + pass """, "homeassistant.components.pylint_test.config_flow", ) @@ -354,6 +362,15 @@ def test_invalid_config_flow_step( end_line=11, end_col_offset=33, ), + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node2, + args=("ConfigFlowResult", "async_step_custom"), + line=17, + col_offset=4, + end_line=17, + end_col_offset=31, + ), ): type_hint_checker.visit_classdef(class_node) From e6b86b662ad8d3c7cc7a4ccd213e490060e5b82b Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:56:13 +0200 Subject: [PATCH 0688/1309] Add reconnect logic and proper reporting to MotionMount integration (#125670) * Add reconnect logic and proper reporting * Use snake_case * Log on warning, not on info * Reduce line length * Refactor non-raising code out of try blocks * Remove `_ensure_connected()` from action functions --- .../components/motionmount/entity.py | 24 ++++++++++++++ .../components/motionmount/number.py | 13 ++++++-- .../components/motionmount/select.py | 31 ++++++++++++++----- 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py index d2da2481f1a..ba81c9d10bd 100644 --- a/homeassistant/components/motionmount/entity.py +++ b/homeassistant/components/motionmount/entity.py @@ -1,5 +1,7 @@ """Support for MotionMount sensors.""" +import logging +import socket from typing import TYPE_CHECKING import motionmount @@ -12,6 +14,8 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN, EMPTY_MAC +_LOGGER = logging.getLogger(__name__) + class MotionMountEntity(Entity): """Representation of a MotionMount entity.""" @@ -70,3 +74,23 @@ class MotionMountEntity(Entity): self.mm.remove_listener(self.async_write_ha_state) self.mm.remove_listener(self.update_name) await super().async_will_remove_from_hass() + + async def _ensure_connected(self) -> bool: + """Make sure there is a connection with the MotionMount. + + Returns false if the connection failed to be ensured. + """ + + if self.mm.is_connected: + return True + try: + await self.mm.connect() + except (ConnectionError, TimeoutError, socket.gaierror): + # We're not interested in exceptions here. In case of a failed connection + # the try/except from the caller will report it. + # The purpose of `_ensure_connected()` is only to make sure we try to + # reconnect, where failures should not be logged each time + return False + else: + _LOGGER.warning("Successfully reconnected to MotionMount") + return True diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py index 3217a4558e1..25370ec51d8 100644 --- a/homeassistant/components/motionmount/number.py +++ b/homeassistant/components/motionmount/number.py @@ -1,11 +1,14 @@ """Support for MotionMount numeric control.""" +import socket + import motionmount from homeassistant.components.number import NumberEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -46,7 +49,10 @@ class MotionMountExtension(MotionMountEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set the new value for extension.""" - await self.mm.set_extension(int(value)) + try: + await self.mm.set_extension(int(value)) + except (TimeoutError, socket.gaierror) as ex: + raise HomeAssistantError("Failed to communicate with MotionMount") from ex class MotionMountTurn(MotionMountEntity, NumberEntity): @@ -69,4 +75,7 @@ class MotionMountTurn(MotionMountEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set the new value for turn.""" - await self.mm.set_turn(int(value * -1)) + try: + await self.mm.set_turn(int(value * -1)) + except (TimeoutError, socket.gaierror) as ex: + raise HomeAssistantError("Failed to communicate with MotionMount") from ex diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index d15bbb7326b..9bca6578bcc 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -1,15 +1,23 @@ """Support for MotionMount numeric control.""" +from datetime import timedelta +import logging +import socket + import motionmount from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, WALL_PRESET_NAME from .entity import MotionMountEntity +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=60) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -23,6 +31,7 @@ async def async_setup_entry( class MotionMountPresets(MotionMountEntity, SelectEntity): """The presets of a MotionMount.""" + _attr_should_poll = True _attr_translation_key = "motionmount_preset" def __init__( @@ -44,8 +53,15 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): async def async_update(self) -> None: """Get latest state from MotionMount.""" - self._presets = await self.mm.get_presets() - self._update_options(self._presets) + if not await self._ensure_connected(): + return + + try: + self._presets = await self.mm.get_presets() + except (TimeoutError, socket.gaierror) as ex: + _LOGGER.warning("Failed to communicate with MotionMount: %s", ex) + else: + self._update_options(self._presets) @property def current_option(self) -> str | None: @@ -72,8 +88,9 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Set the new option.""" index = int(option[:1]) - await self.mm.go_to_preset(index) - self._attr_current_option = option - - # Perform an update so we detect changes to the presets (changes are not pushed) - self.async_schedule_update_ha_state(True) + try: + await self.mm.go_to_preset(index) + except (TimeoutError, socket.gaierror) as ex: + raise HomeAssistantError("Failed to communicate with MotionMount") from ex + else: + self._attr_current_option = option From f0df8264fa3af96b8901e2b076473d30e989bbb4 Mon Sep 17 00:00:00 2001 From: Jeef Date: Mon, 16 Sep 2024 03:57:40 -0600 Subject: [PATCH 0689/1309] Bump weatherflow cloud to 1.0.6 (#125966) bumping backing lib --- homeassistant/components/weatherflow_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 8e3394e1e37..98c98cfbac7 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==0.3.4"] + "requirements": ["weatherflow4py==1.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index a1a204165d3..fb2faaedbd1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2947,7 +2947,7 @@ watchdog==2.3.1 waterfurnace==1.1.0 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.3.4 +weatherflow4py==1.0.6 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f4685efe9b..64211551dcf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2339,7 +2339,7 @@ wallbox==0.7.0 watchdog==2.3.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==0.3.4 +weatherflow4py==1.0.6 # homeassistant.components.webmin webmin-xmlrpc==0.0.2 From bcbf810cbe4db997a9bde5ebee8c99fcf7aed66d Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 16 Sep 2024 05:57:59 -0400 Subject: [PATCH 0690/1309] Bump aiostreammagic to 2.3.1 (#126017) --- homeassistant/components/cambridge_audio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json index 5e4f58b2fc2..f2f067a4a9d 100644 --- a/homeassistant/components/cambridge_audio/manifest.json +++ b/homeassistant/components/cambridge_audio/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["aiostreammagic"], - "requirements": ["aiostreammagic==2.3.0"], + "requirements": ["aiostreammagic==2.3.1"], "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index fb2faaedbd1..81e0a5c5497 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -377,7 +377,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.3.0 +aiostreammagic==2.3.1 # homeassistant.components.switcher_kis aioswitcher==4.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64211551dcf..b1a9caa0ffc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -359,7 +359,7 @@ aiosolaredge==0.2.0 aiosteamist==1.0.0 # homeassistant.components.cambridge_audio -aiostreammagic==2.3.0 +aiostreammagic==2.3.1 # homeassistant.components.switcher_kis aioswitcher==4.0.3 From e8bacd84ce9f1464ac0a7d192934304c62af7637 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 16 Sep 2024 12:12:49 +0200 Subject: [PATCH 0691/1309] Add Reolink chime package ringtone (#125786) * add chime package ringtone * fix mypy * fix mypy * fix mypy * fixes --- homeassistant/components/reolink/entity.py | 18 +++++++++++-- homeassistant/components/reolink/icons.json | 26 ++++++++++++++++--- homeassistant/components/reolink/number.py | 3 ++- homeassistant/components/reolink/select.py | 17 +++++++++++- homeassistant/components/reolink/strings.json | 16 ++++++++++++ homeassistant/components/reolink/switch.py | 3 ++- 6 files changed, 74 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index c47822e125c..234aa79f303 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -34,6 +34,14 @@ class ReolinkHostEntityDescription(EntityDescription): supported: Callable[[Host], bool] = lambda api: True +@dataclass(frozen=True, kw_only=True) +class ReolinkChimeEntityDescription(EntityDescription): + """A class that describes entities for a chime.""" + + cmd_key: str | None = None + supported: Callable[[Chime], bool] = lambda chime: True + + class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): """Parent class for entities that control the Reolink NVR itself, without a channel. @@ -42,7 +50,11 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] """ _attr_has_entity_name = True - entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription + entity_description: ( + ReolinkHostEntityDescription + | ReolinkChannelEntityDescription + | ReolinkChimeEntityDescription + ) def __init__( self, @@ -102,7 +114,7 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" - entity_description: ReolinkChannelEntityDescription + entity_description: ReolinkChannelEntityDescription | ReolinkChimeEntityDescription def __init__( self, @@ -164,6 +176,8 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity): """Parent class for Reolink chime entities connected.""" + entity_description: ReolinkChimeEntityDescription + def __init__( self, reolink_data: ReolinkData, diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index f1c6f88a0f0..e3a0c867f18 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -101,7 +101,10 @@ "default": "mdi:spotlight-beam" }, "volume": { - "default": "mdi:volume-high" + "default": "mdi:volume-high", + "state": { + "0": "mdi:volume-off" + } }, "guard_return_time": { "default": "mdi:crosshairs-gps" @@ -208,13 +211,28 @@ "default": "mdi:hdr" }, "motion_tone": { - "default": "mdi:music-note" + "default": "mdi:music-note", + "state": { + "off": "mdi:music-note-off" + } }, "people_tone": { - "default": "mdi:music-note" + "default": "mdi:music-note", + "state": { + "off": "mdi:music-note-off" + } }, "visitor_tone": { - "default": "mdi:music-note" + "default": "mdi:music-note", + "state": { + "off": "mdi:music-note-off" + } + }, + "package_tone": { + "default": "mdi:music-note", + "state": { + "off": "mdi:music-note-off" + } } }, "sensor": { diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index a55f0d440a1..ff523b559d6 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -23,6 +23,7 @@ from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, + ReolinkChimeEntityDescription, ) from .util import ReolinkConfigEntry, ReolinkData @@ -44,7 +45,7 @@ class ReolinkNumberEntityDescription( @dataclass(frozen=True, kw_only=True) class ReolinkChimeNumberEntityDescription( NumberEntityDescription, - ReolinkChannelEntityDescription, + ReolinkChimeEntityDescription, ): """A class that describes number entities for a chime.""" diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 8a2c977ede3..bc6368df8de 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -29,6 +29,7 @@ from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, + ReolinkChimeEntityDescription, ) from .util import ReolinkConfigEntry, ReolinkData @@ -50,7 +51,7 @@ class ReolinkSelectEntityDescription( @dataclass(frozen=True, kw_only=True) class ReolinkChimeSelectEntityDescription( SelectEntityDescription, - ReolinkChannelEntityDescription, + ReolinkChimeEntityDescription, ): """A class that describes select entities for a chime.""" @@ -154,6 +155,7 @@ CHIME_SELECT_ENTITIES = ( cmd_key="GetDingDongCfg", translation_key="motion_tone", entity_category=EntityCategory.CONFIG, + supported=lambda chime: "md" in chime.chime_event_types, get_options=[method.name for method in ChimeToneEnum], value=lambda chime: ChimeToneEnum(chime.tone("md")).name, method=lambda chime, name: chime.set_tone("md", ChimeToneEnum[name].value), @@ -164,6 +166,7 @@ CHIME_SELECT_ENTITIES = ( translation_key="people_tone", entity_category=EntityCategory.CONFIG, get_options=[method.name for method in ChimeToneEnum], + supported=lambda chime: "people" in chime.chime_event_types, value=lambda chime: ChimeToneEnum(chime.tone("people")).name, method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value), ), @@ -173,9 +176,20 @@ CHIME_SELECT_ENTITIES = ( translation_key="visitor_tone", entity_category=EntityCategory.CONFIG, get_options=[method.name for method in ChimeToneEnum], + supported=lambda chime: "visitor" in chime.chime_event_types, value=lambda chime: ChimeToneEnum(chime.tone("visitor")).name, method=lambda chime, name: chime.set_tone("visitor", ChimeToneEnum[name].value), ), + ReolinkChimeSelectEntityDescription( + key="package_tone", + cmd_key="GetDingDongCfg", + translation_key="package_tone", + entity_category=EntityCategory.CONFIG, + get_options=[method.name for method in ChimeToneEnum], + supported=lambda chime: "package" in chime.chime_event_types, + value=lambda chime: ChimeToneEnum(chime.tone("package")).name, + method=lambda chime, name: chime.set_tone("package", ChimeToneEnum[name].value), + ), ) @@ -197,6 +211,7 @@ async def async_setup_entry( ReolinkChimeSelectEntity(reolink_data, chime, entity_description) for entity_description in CHIME_SELECT_ENTITIES for chime in reolink_data.host.api.chime_list + if entity_description.supported(chime) ) async_add_entities(entities) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 9f18f4afe15..bd674b6574f 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -578,6 +578,22 @@ "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]", "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" } + }, + "package_tone": { + "name": "Package ringtone", + "state": { + "off": "[%key:common::state::off%]", + "citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]", + "originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]", + "pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]", + "loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]", + "attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]", + "hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]", + "goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]", + "operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]", + "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]", + "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" + } } }, "sensor": { diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index c3e945c7de8..e43cb0fdaaa 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -21,6 +21,7 @@ from .entity import ( ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, + ReolinkChimeEntityDescription, ReolinkHostCoordinatorEntity, ReolinkHostEntityDescription, ) @@ -52,7 +53,7 @@ class ReolinkNVRSwitchEntityDescription( @dataclass(frozen=True, kw_only=True) class ReolinkChimeSwitchEntityDescription( SwitchEntityDescription, - ReolinkChannelEntityDescription, + ReolinkChimeEntityDescription, ): """A class that describes switch entities for a chime.""" From a8648b7cdce6b4001dae8f36a001871279493780 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Mon, 16 Sep 2024 12:16:15 +0200 Subject: [PATCH 0692/1309] Add Bang & Olufsen media_player grouping (#123020) * Add Beolink custom services Add support for media player grouping via beolink Give media player entity name * Fix progress not being set to None as Beolink listener Revert naming changes * Update API simplify Beolink attributes * Improve beolink custom services * Fix Beolink expandable source check Add unexpand return value Set entity name on initialization * Handle entity naming as intended * Fix "null" Beolink self friendly name * Add regex service input validation Add all_discovered to beolink_expand service Improve beolink_expand response * Add service icons * Fix merge Remove unnecessary assignment * Remove invalid typing Update response typing for updated API * Revert to old typed response dict method Remove mypy ignore line Fix jid possibly used before assignment * Re add debugging logging * Fix coroutine Fix formatting * Remove unnecessary update control * Make tests pass Fix remote leader media position bug Improve remote leader BangOlufsenSource comparison * Fix naming and add callback decorators * Move regex service check to variable Suppress KeyError Update tests * Re-add hass running check * Improve comments, naming and type hinting * Remove old temporary fix * Convert logged warning to raised exception for invalid media_player Simplify code using walrus operator * Fix test for invalid media_player grouping * Improve method naming * Improve _beolink_sources explanation * Improve _beolink_sources explanation * Add initial media_player grouping * Convert custom service methods to media_player methods Fix testing * Remove beolink JID extra state attribute * Modify custom services to only work as expected for media_player grouping Fix tests * Remove unused dispatch * Remove wrong comment * Remove commented out code * Add config entry mock typing * Fix beolink listener playback progress Fix formatting Add and use get_serial_number_from_jid function * Fix testing * Clarify beolink WebSocket notifications * Further clarify beolink WebSocket notifications * Convert notification value to enum value * Improve comments for touch to join * Fix None being cast to str if leader is not in HA * Add error messages to devices in Beolink session and not Home Assistant Rework _get_beolink_jid * Replace redundant function call * Show friendly name for unavailable remote leader instead of JID * Update homeassistant/components/bang_olufsen/media_player.py Co-authored-by: Erik Montnemery * Remove unneeded typing * Rework _get_beolink_jid entity check Clarify invalid entity error message * Remove redundant "entity" from string * Fix invalid typing fix state assertions * Fix raised error type --------- Co-authored-by: Erik Montnemery --- .../components/bang_olufsen/config_flow.py | 3 +- .../components/bang_olufsen/const.py | 5 + .../components/bang_olufsen/media_player.py | 179 +++++++++++++++- .../components/bang_olufsen/strings.json | 3 + homeassistant/components/bang_olufsen/util.py | 5 + .../components/bang_olufsen/websocket.py | 11 +- tests/components/bang_olufsen/conftest.py | 43 +++- tests/components/bang_olufsen/const.py | 22 +- .../bang_olufsen/test_media_player.py | 200 ++++++++++++++++++ 9 files changed, 457 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py index 76e4656129e..85b7a22cd56 100644 --- a/homeassistant/components/bang_olufsen/config_flow.py +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -25,6 +25,7 @@ from .const import ( DEFAULT_MODEL, DOMAIN, ) +from .util import get_serial_number_from_jid class EntryData(TypedDict, total=False): @@ -107,7 +108,7 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) self._beolink_jid = beolink_self.jid - self._serial_number = beolink_self.jid.split(".")[2].split("@")[0] + self._serial_number = get_serial_number_from_jid(beolink_self.jid) await self.async_set_unique_id(self._serial_number) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 748b4baf621..6803a141cee 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -78,6 +78,11 @@ class WebsocketNotification(StrEnum): VOLUME = "volume" # Sub-notifications + BEOLINK = "beolink" + BEOLINK_PEERS = "beolinkPeers" + BEOLINK_LISTENERS = "beolinkListeners" + BEOLINK_AVAILABLE_LISTENERS = "beolinkAvailableListeners" + CONFIGURATION = "configuration" NOTIFICATION = "notification" REMOTE_MENU_CHANGED = "remoteMenuChanged" diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 8bc97858d0d..ea84eef9c84 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -5,13 +5,14 @@ from __future__ import annotations from collections.abc import Callable import json import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from mozart_api import __version__ as MOZART_API_VERSION from mozart_api.exceptions import ApiException from mozart_api.models import ( Action, Art, + BeolinkLeader, OverlayPlayRequest, OverlayPlayRequestTextToSpeechTextToSpeech, PlaybackContentMetadata, @@ -44,9 +45,10 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL +from homeassistant.const import CONF_MODEL, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -66,12 +68,14 @@ from .const import ( WebsocketNotification, ) from .entity import BangOlufsenEntity +from .util import get_serial_number_from_jid _LOGGER = logging.getLogger(__name__) BANG_OLUFSEN_FEATURES = ( MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.CLEAR_PLAYLIST + | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.MEDIA_ANNOUNCE | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.PAUSE @@ -134,14 +138,19 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self._state: str = MediaPlayerState.IDLE self._video_sources: dict[str, str] = {} + # Beolink compatible sources + self._beolink_sources: dict[str, bool] = {} + self._remote_leader: BeolinkLeader | None = None + async def async_added_to_hass(self) -> None: """Turn on the dispatchers.""" await self._initialize() signal_handlers: dict[str, Callable] = { CONNECTION_STATUS: self._async_update_connection_state, + WebsocketNotification.BEOLINK: self._async_update_beolink, WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, - WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata, + WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, WebsocketNotification.PLAYBACK_PROGRESS: self._async_update_playback_progress, WebsocketNotification.PLAYBACK_STATE: self._async_update_playback_state, WebsocketNotification.REMOTE_MENU_CHANGED: self._async_update_sources, @@ -183,6 +192,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): if product_state.playback: if product_state.playback.metadata: self._playback_metadata = product_state.playback.metadata + self._remote_leader = product_state.playback.metadata.remote_leader if product_state.playback.progress: self._playback_progress = product_state.playback.progress if product_state.playback.source: @@ -201,9 +211,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # If the device has been updated with new sources, then the API will fail here. await self._async_update_sources() - # Set the static entity attributes that needed more information. - self._attr_source_list = list(self._sources.values()) - async def _async_update_sources(self) -> None: """Get sources for the specific product.""" @@ -237,6 +244,21 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): and source.id not in HIDDEN_SOURCE_IDS } + # Some sources are not Beolink expandable, meaning that they can't be joined by + # or expand to other Bang & Olufsen devices for a multi-room experience. + # _source_change, which is used throughout the entity for current source + # information, lacks this information, so source ID's and their expandability is + # stored in the self._beolink_sources variable. + self._beolink_sources = { + source.id: ( + source.is_multiroom_available + if source.is_multiroom_available is not None + else False + ) + for source in cast(list[Source], sources.items) + if source.id + } + # Video sources from remote menu menu_items = await self._client.get_remote_menu() @@ -260,19 +282,22 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # Combine the source dicts self._sources = self._audio_sources | self._video_sources + self._attr_source_list = list(self._sources.values()) + # HASS won't necessarily be running the first time this method is run if self.hass.is_running: self.async_write_ha_state() @callback - def _async_update_playback_metadata(self, data: PlaybackContentMetadata) -> None: + async def _async_update_playback_metadata_and_beolink( + self, data: PlaybackContentMetadata + ) -> None: """Update _playback_metadata and related.""" self._playback_metadata = data - # Update current artwork. + # Update current artwork and remote_leader. self._media_image = get_highest_resolution_artwork(self._playback_metadata) - - self.async_write_ha_state() + await self._async_update_beolink() @callback def _async_update_playback_error(self, data: PlaybackError) -> None: @@ -319,6 +344,96 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self.async_write_ha_state() + @callback + async def _async_update_beolink(self) -> None: + """Update the current Beolink leader, listeners, peers and self.""" + + # Add Beolink listeners / leader + self._remote_leader = self._playback_metadata.remote_leader + + # Create group members list + group_members = [] + + # If the device is a listener. + if self._remote_leader is not None: + # Add leader if available in Home Assistant + leader = self._get_entity_id_from_jid(self._remote_leader.jid) + group_members.append( + leader + if leader is not None + else f"leader_not_in_hass-{self._remote_leader.friendly_name}" + ) + + # Add self + group_members.append(self.entity_id) + + # If not listener, check if leader. + else: + beolink_listeners = await self._client.get_beolink_listeners() + + # Check if the device is a leader. + if len(beolink_listeners) > 0: + # Add self + group_members.append(self.entity_id) + + # Get the entity_ids of the listeners if available in Home Assistant + group_members.extend( + [ + listener + if ( + listener := self._get_entity_id_from_jid( + beolink_listener.jid + ) + ) + is not None + else f"listener_not_in_hass-{beolink_listener.jid}" + for beolink_listener in beolink_listeners + ] + ) + + self._attr_group_members = group_members + + self.async_write_ha_state() + + def _get_entity_id_from_jid(self, jid: str) -> str | None: + """Get entity_id from Beolink JID (if available).""" + + unique_id = get_serial_number_from_jid(jid) + + entity_registry = er.async_get(self.hass) + return entity_registry.async_get_entity_id( + Platform.MEDIA_PLAYER, DOMAIN, unique_id + ) + + def _get_beolink_jid(self, entity_id: str) -> str: + """Get beolink JID from entity_id.""" + + entity_registry = er.async_get(self.hass) + + # Check for valid bang_olufsen media_player entity + entity_entry = entity_registry.async_get(entity_id) + + if ( + entity_entry is None + or entity_entry.domain != Platform.MEDIA_PLAYER + or entity_entry.platform != DOMAIN + or entity_entry.config_entry_id is None + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_grouping_entity", + translation_placeholders={"entity_id": entity_id}, + ) + + config_entry = self.hass.config_entries.async_get_entry( + entity_entry.config_entry_id + ) + if TYPE_CHECKING: + assert config_entry + + # Return JID + return cast(str, config_entry.data[CONF_BEOLINK_JID]) + @property def state(self) -> MediaPlayerState: """Return the current state of the media player.""" @@ -664,3 +779,47 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): media_content_id, content_filter=lambda item: item.media_content_type.startswith("audio/"), ) + + async def async_join_players(self, group_members: list[str]) -> None: + """Create a Beolink session with defined group members.""" + + # Use the touch to join if no entities have been defined + # Touch to join will make the device connect to any other currently-playing + # Beolink compatible B&O device. + # Repeated presses / calls will cycle between compatible playing devices. + if len(group_members) == 0: + await self._async_beolink_join() + return + + # Get JID for each group member + jids = [self._get_beolink_jid(group_member) for group_member in group_members] + await self._async_beolink_expand(jids) + + async def async_unjoin_player(self) -> None: + """Unjoin Beolink session. End session if leader.""" + await self._async_beolink_leave() + + async def _async_beolink_join(self) -> None: + """Join a Beolink multi-room experience.""" + await self._client.join_latest_beolink_experience() + + async def _async_beolink_expand(self, beolink_jids: list[str]) -> None: + """Expand a Beolink multi-room experience with a device or devices.""" + # Ensure that the current source is expandable + if not self._beolink_sources[cast(str, self._source_change.id)]: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_source", + translation_placeholders={ + "invalid_source": cast(str, self._source_change.id), + "valid_sources": ", ".join(list(self._beolink_sources.keys())), + }, + ) + + # Try to expand to all defined devices + for beolink_jid in beolink_jids: + await self._client.post_beolink_expand(jid=beolink_jid) + + async def _async_beolink_leave(self) -> None: + """Leave the current Beolink experience.""" + await self._client.post_beolink_leave() diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index cf5b212d424..6c4b7f1370c 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -40,6 +40,9 @@ }, "play_media_error": { "message": "An error occurred while attempting to play {media_type}: {error_message}." + }, + "invalid_grouping_entity": { + "message": "Entity with id: {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?" } } } diff --git a/homeassistant/components/bang_olufsen/util.py b/homeassistant/components/bang_olufsen/util.py index c54b3059ee4..e375b58e8ac 100644 --- a/homeassistant/components/bang_olufsen/util.py +++ b/homeassistant/components/bang_olufsen/util.py @@ -16,3 +16,8 @@ def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry: assert device return device + + +def get_serial_number_from_jid(jid: str) -> str: + """Get serial number from Beolink JID.""" + return jid.split(".")[2].split("@")[0] diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 0c0a5096d91..6e5c1d4c76c 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -96,7 +96,16 @@ class BangOlufsenWebsocket(BangOlufsenBase): # Try to match the notification type with available WebsocketNotification members notification_type = try_parse_enum(WebsocketNotification, notification.value) - if notification_type is WebsocketNotification.REMOTE_MENU_CHANGED: + if notification_type in ( + WebsocketNotification.BEOLINK_PEERS, + WebsocketNotification.BEOLINK_LISTENERS, + WebsocketNotification.BEOLINK_AVAILABLE_LISTENERS, + ): + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebsocketNotification.BEOLINK}", + ) + elif notification_type is WebsocketNotification.REMOTE_MENU_CHANGED: async_dispatcher_send( self.hass, f"{self._unique_id}_{WebsocketNotification.REMOTE_MENU_CHANGED}", diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 291f3cad8d9..0ad9d34a170 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -27,10 +27,17 @@ from homeassistant.core import HomeAssistant from .const import ( TEST_DATA_CREATE_ENTRY, + TEST_DATA_CREATE_ENTRY_2, TEST_FRIENDLY_NAME, + TEST_FRIENDLY_NAME_2, + TEST_FRIENDLY_NAME_3, TEST_JID_1, + TEST_JID_2, + TEST_JID_3, TEST_NAME, + TEST_NAME_2, TEST_SERIAL_NUMBER, + TEST_SERIAL_NUMBER_2, ) from tests.common import MockConfigEntry @@ -47,6 +54,17 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_2() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_SERIAL_NUMBER_2, + data=TEST_DATA_CREATE_ENTRY_2, + title=TEST_NAME_2, + ) + + @pytest.fixture async def mock_media_player( hass: HomeAssistant, @@ -102,13 +120,19 @@ def mock_mozart_client() -> Generator[AsyncMock]: is_enabled=True, is_multiroom_available=False, ), - # The only available source + # The only available beolink source Source( name="Tidal", id="tidal", is_enabled=True, is_multiroom_available=True, ), + Source( + name="Line-In", + id="lineIn", + is_enabled=True, + is_multiroom_available=False, + ), # Is disabled, so should not be user selectable Source( name="Powerlink", @@ -228,6 +252,17 @@ def mock_mozart_client() -> Generator[AsyncMock]: id="64c9da45-3682-44a4-8030-09ed3ef44160", ), } + client.get_beolink_peers = AsyncMock() + client.get_beolink_peers.return_value = [ + BeolinkPeer(friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2), + BeolinkPeer(friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3), + ] + client.get_beolink_listeners = AsyncMock() + client.get_beolink_listeners.return_value = [ + BeolinkPeer(friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2), + BeolinkPeer(friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3), + ] + client.post_standby = AsyncMock() client.set_current_volume_level = AsyncMock() client.set_volume_mute = AsyncMock() @@ -242,6 +277,12 @@ def mock_mozart_client() -> Generator[AsyncMock]: client.add_to_queue = AsyncMock() client.post_remote_trigger = AsyncMock() client.set_active_source = AsyncMock() + client.post_beolink_expand = AsyncMock() + client.join_beolink_peer = AsyncMock() + client.post_beolink_unexpand = AsyncMock() + client.post_beolink_leave = AsyncMock() + client.post_beolink_allstandby = AsyncMock() + client.join_latest_beolink_experience = AsyncMock() # Non-REST API client methods client.check_device_connection = AsyncMock() diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index d5e2221675a..e8d8653c5b7 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -39,13 +39,27 @@ TEST_MODEL_BALANCE = "Beosound Balance" TEST_MODEL_THEATRE = "Beosound Theatre" TEST_MODEL_LEVEL = "Beosound Level" TEST_SERIAL_NUMBER = "11111111" +TEST_SERIAL_NUMBER_2 = "22222222" TEST_NAME = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER}" +TEST_NAME_2 = f"{TEST_MODEL_BALANCE}-{TEST_SERIAL_NUMBER_2}" TEST_FRIENDLY_NAME = "Living room Balance" TEST_TYPE_NUMBER = "1111" TEST_ITEM_NUMBER = "1111111" TEST_JID_1 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.{TEST_SERIAL_NUMBER}@products.bang-olufsen.com" TEST_MEDIA_PLAYER_ENTITY_ID = "media_player.beosound_balance_11111111" +TEST_FRIENDLY_NAME_2 = "Laundry room Balance" +TEST_JID_2 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.22222222@products.bang-olufsen.com" +TEST_MEDIA_PLAYER_ENTITY_ID_2 = "media_player.beosound_balance_22222222" + +TEST_FRIENDLY_NAME_3 = "Lego room Balance" +TEST_JID_3 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.33333333@products.bang-olufsen.com" +TEST_MEDIA_PLAYER_ENTITY_ID_3 = "media_player.beosound_balance_33333333" + +TEST_FRIENDLY_NAME_4 = "Lounge room Balance" +TEST_JID_4 = f"{TEST_TYPE_NUMBER}.{TEST_ITEM_NUMBER}.44444444@products.bang-olufsen.com" +TEST_MEDIA_PLAYER_ENTITY_ID_4 = "media_player.beosound_balance_44444444" + TEST_HOSTNAME_ZEROCONF = TEST_NAME.replace(" ", "-") + ".local." TEST_TYPE_ZEROCONF = "_bangolufsen._tcp.local." TEST_NAME_ZEROCONF = TEST_NAME.replace(" ", "-") + "." + TEST_TYPE_ZEROCONF @@ -60,6 +74,12 @@ TEST_DATA_CREATE_ENTRY = { CONF_BEOLINK_JID: TEST_JID_1, CONF_NAME: TEST_NAME, } +TEST_DATA_CREATE_ENTRY_2 = { + CONF_HOST: TEST_HOST, + CONF_MODEL: TEST_MODEL_BALANCE, + CONF_BEOLINK_JID: TEST_JID_2, + CONF_NAME: TEST_NAME_2, +} TEST_DATA_ZEROCONF = ZeroconfServiceInfo( ip_address=IPv4Address(TEST_HOST), @@ -101,7 +121,7 @@ TEST_DATA_ZEROCONF_IPV6 = ZeroconfServiceInfo( }, ) -TEST_AUDIO_SOURCES = [BangOlufsenSource.TIDAL.name] +TEST_AUDIO_SOURCES = [BangOlufsenSource.TIDAL.name, BangOlufsenSource.LINE_IN.name] TEST_VIDEO_SOURCES = ["HDMI A"] TEST_SOURCES = TEST_AUDIO_SOURCES + TEST_VIDEO_SOURCES TEST_FALLBACK_SOURCES = [ diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 76f0d842648..12dee794709 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -5,6 +5,7 @@ import logging from unittest.mock import AsyncMock, patch from mozart_api.models import ( + BeolinkLeader, PlaybackContentMetadata, RenderingState, Source, @@ -18,6 +19,7 @@ from homeassistant.components.bang_olufsen.const import ( BangOlufsenSource, ) from homeassistant.components.media_player import ( + ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_ALBUM_ARTIST, @@ -62,7 +64,11 @@ from .const import ( TEST_DEEZER_PLAYLIST, TEST_DEEZER_TRACK, TEST_FALLBACK_SOURCES, + TEST_FRIENDLY_NAME_2, + TEST_JID_2, TEST_MEDIA_PLAYER_ENTITY_ID, + TEST_MEDIA_PLAYER_ENTITY_ID_2, + TEST_MEDIA_PLAYER_ENTITY_ID_3, TEST_OVERLAY_INVALID_OFFSET_VOLUME_TTS, TEST_OVERLAY_OFFSET_VOLUME_TTS, TEST_PLAYBACK_ERROR, @@ -452,6 +458,70 @@ async def test_async_set_volume_level( ) +async def test_async_update_beolink_line_in( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test _async_update_beolink with line-in and no active Beolink session.""" + # Ensure no listeners + mock_mozart_client.get_beolink_listeners.return_value = [] + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + source_change_callback = ( + mock_mozart_client.get_source_change_notifications.call_args[0][0] + ) + beolink_callback = mock_mozart_client.get_notification_notifications.call_args[0][0] + + # Set source + source_change_callback(BangOlufsenSource.LINE_IN) + beolink_callback(WebsocketNotificationTag(value="beolinkListeners")) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states.attributes["group_members"] == [] + + assert mock_mozart_client.get_beolink_listeners.call_count == 1 + + +async def test_async_update_beolink_listener( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_config_entry_2: MockConfigEntry, +) -> None: + """Test _async_update_beolink as a listener.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + playback_metadata_callback = ( + mock_mozart_client.get_playback_metadata_notifications.call_args[0][0] + ) + + # Add another entity + mock_config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_2.entry_id) + + # Runs _async_update_beolink + playback_metadata_callback( + PlaybackContentMetadata( + remote_leader=BeolinkLeader( + friendly_name=TEST_FRIENDLY_NAME_2, jid=TEST_JID_2 + ) + ) + ) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states.attributes["group_members"] == [ + TEST_MEDIA_PLAYER_ENTITY_ID_2, + TEST_MEDIA_PLAYER_ENTITY_ID, + ] + + assert mock_mozart_client.get_beolink_listeners.call_count == 0 + + async def test_async_mute_volume( hass: HomeAssistant, mock_mozart_client: AsyncMock, @@ -1147,3 +1217,133 @@ async def test_async_browse_media( assert response["success"] assert (child in response["result"]["children"]) is present + + +@pytest.mark.parametrize( + ("group_members", "expand_count", "join_count"), + [ + # Valid member + ([TEST_MEDIA_PLAYER_ENTITY_ID_2], 1, 0), + # Touch to join + ([], 0, 1), + ], +) +async def test_async_join_players( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_config_entry_2: MockConfigEntry, + group_members: list[str], + expand_count: int, + join_count: int, +) -> None: + """Test async_join_players.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + source_change_callback = ( + mock_mozart_client.get_source_change_notifications.call_args[0][0] + ) + + # Add another entity + mock_config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_2.entry_id) + + # Set the source to a beolink expandable source + source_change_callback(BangOlufsenSource.TIDAL) + + await hass.services.async_call( + "media_player", + "join", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: group_members, + }, + blocking=True, + ) + + assert mock_mozart_client.post_beolink_expand.call_count == expand_count + assert mock_mozart_client.join_latest_beolink_experience.call_count == join_count + + +@pytest.mark.parametrize( + ("source", "group_members", "expected_result", "error_type"), + [ + # Invalid source + ( + BangOlufsenSource.LINE_IN, + [TEST_MEDIA_PLAYER_ENTITY_ID_2], + pytest.raises(ServiceValidationError), + "invalid_source", + ), + # Invalid media_player entity + ( + BangOlufsenSource.TIDAL, + [TEST_MEDIA_PLAYER_ENTITY_ID_3], + pytest.raises(ServiceValidationError), + "invalid_grouping_entity", + ), + ], +) +async def test_async_join_players_invalid( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_config_entry_2: MockConfigEntry, + source: Source, + group_members: list[str], + expected_result: AbstractContextManager, + error_type: str, +) -> None: + """Test async_join_players with an invalid media_player entity.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + source_change_callback = ( + mock_mozart_client.get_source_change_notifications.call_args[0][0] + ) + + mock_config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry_2.entry_id) + + source_change_callback(source) + + with expected_result as exc_info: + await hass.services.async_call( + "media_player", + "join", + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + ATTR_GROUP_MEMBERS: group_members, + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == error_type + assert exc_info.errisinstance(HomeAssistantError) + + assert mock_mozart_client.post_beolink_expand.call_count == 0 + assert mock_mozart_client.join_latest_beolink_experience.call_count == 0 + + +async def test_async_unjoin_player( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_unjoin_player.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.services.async_call( + "media_player", + "unjoin", + {ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID}, + blocking=True, + ) + + mock_mozart_client.post_beolink_leave.assert_called_once() From af030033054f0c730b0efb0b685eaa6790042ab6 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Mon, 16 Sep 2024 03:17:17 -0700 Subject: [PATCH 0693/1309] Improve TotalConnect translations (#125978) * improve translations * remove periods from tests * simplify message strings * use a comma --- .../totalconnect/alarm_control_panel.py | 42 +++++++++++++------ .../components/totalconnect/strings.json | 36 ++++++++++++++++ .../totalconnect/test_alarm_control_panel.py | 30 ++++++------- 3 files changed, 78 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 3c12e512dd6..fb13c630e3e 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -158,11 +158,14 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): except UsercodeInvalid as error: self.coordinator.config_entry.async_start_reauth(self.hass) raise HomeAssistantError( - "TotalConnect usercode is invalid. Did not disarm" + translation_domain=DOMAIN, + translation_key="disarm_invalid_code", ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to disarm {self.device.name}." + translation_domain=DOMAIN, + translation_key="disarm_failed", + translation_placeholders={"device": self.device.name}, ) from error await self.coordinator.async_request_refresh() @@ -178,11 +181,14 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): except UsercodeInvalid as error: self.coordinator.config_entry.async_start_reauth(self.hass) raise HomeAssistantError( - "TotalConnect usercode is invalid. Did not arm home" + translation_domain=DOMAIN, + translation_key="arm_home_invalid_code", ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm home {self.device.name}." + translation_domain=DOMAIN, + translation_key="arm_home_failed", + translation_placeholders={"device": self.device.name}, ) from error await self.coordinator.async_request_refresh() @@ -198,11 +204,14 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): except UsercodeInvalid as error: self.coordinator.config_entry.async_start_reauth(self.hass) raise HomeAssistantError( - "TotalConnect usercode is invalid. Did not arm away" + translation_domain=DOMAIN, + translation_key="arm_away_invalid_code", ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm away {self.device.name}." + translation_domain=DOMAIN, + translation_key="arm_away_failed", + translation_placeholders={"device": self.device.name}, ) from error await self.coordinator.async_request_refresh() @@ -218,11 +227,14 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): except UsercodeInvalid as error: self.coordinator.config_entry.async_start_reauth(self.hass) raise HomeAssistantError( - "TotalConnect usercode is invalid. Did not arm night" + translation_domain=DOMAIN, + translation_key="arm_night_invalid_code", ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm night {self.device.name}." + translation_domain=DOMAIN, + translation_key="arm_night_failed", + translation_placeholders={"device": self.device.name}, ) from error await self.coordinator.async_request_refresh() @@ -237,11 +249,14 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): except UsercodeInvalid as error: self.coordinator.config_entry.async_start_reauth(self.hass) raise HomeAssistantError( - "TotalConnect usercode is invalid. Did not arm home instant" + translation_domain=DOMAIN, + translation_key="arm_home_instant_invalid_code", ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm home instant {self.device.name}." + translation_domain=DOMAIN, + translation_key="arm_home_instant_failed", + translation_placeholders={"device": self.device.name}, ) from error await self.coordinator.async_request_refresh() @@ -256,11 +271,14 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity): except UsercodeInvalid as error: self.coordinator.config_entry.async_start_reauth(self.hass) raise HomeAssistantError( - "TotalConnect usercode is invalid. Did not arm away instant" + translation_domain=DOMAIN, + translation_key="arm_away_instant_invalid_code", ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm away instant {self.device.name}." + translation_domain=DOMAIN, + translation_key="arm_away_instant_failed", + translation_placeholders={"device": self.device.name}, ) from error await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index c040ae9936e..004056ef9ac 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -80,6 +80,42 @@ "exceptions": { "invalid_pin": { "message": "Incorrect code entered" + }, + "disarm_failed": { + "message": "Failed to disarm {device}" + }, + "disarm_invalid_code": { + "message": "Usercode is invalid, did not disarm" + }, + "arm_home_failed": { + "message": "Failed to arm home {device}" + }, + "arm_home_invalid_code": { + "message": "Usercode is invalid, did not arm home" + }, + "arm_away_failed": { + "message": "Failed to arm away {device}" + }, + "arm_away_invalid_code": { + "message": "Usercode is invalid, did not arm away" + }, + "arm_night_failed": { + "message": "Failed to arm night {device}" + }, + "arm_night_invalid_code": { + "message": "Usercode is invalid, did not arm night" + }, + "arm_home_instant_failed": { + "message": "Failed to arm home instant {device}" + }, + "arm_home_instant_invalid_code": { + "message": "Usercode is invalid, did not arm home instant" + }, + "arm_away_instant_failed": { + "message": "Failed to arm away instant {device}" + }, + "arm_away_instant_invalid_code": { + "message": "Usercode is invalid, did not arm away instant" } } } diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index eb2b849540c..453c9be485a 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -133,7 +133,7 @@ async def test_arm_home_failure(hass: HomeAssistant) -> None: ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect failed to arm home test." + assert f"{err.value}" == "Failed to arm home test" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 @@ -143,7 +143,7 @@ async def test_arm_home_failure(hass: HomeAssistant) -> None: ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect usercode is invalid. Did not arm home" + assert f"{err.value}" == "Usercode is invalid, did not arm home" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED # should have started a re-auth flow assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 @@ -190,7 +190,7 @@ async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect failed to arm home instant test." + assert f"{err.value}" == "Failed to arm home instant test" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 @@ -200,10 +200,7 @@ async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True ) await hass.async_block_till_done() - assert ( - f"{err.value}" - == "TotalConnect usercode is invalid. Did not arm home instant" - ) + assert f"{err.value}" == "Usercode is invalid, did not arm home instant" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED # should have started a re-auth flow assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 @@ -250,7 +247,7 @@ async def test_arm_away_instant_failure(hass: HomeAssistant) -> None: DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect failed to arm away instant test." + assert f"{err.value}" == "Failed to arm away instant test" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 @@ -260,10 +257,7 @@ async def test_arm_away_instant_failure(hass: HomeAssistant) -> None: DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True ) await hass.async_block_till_done() - assert ( - f"{err.value}" - == "TotalConnect usercode is invalid. Did not arm away instant" - ) + assert f"{err.value}" == "Usercode is invalid, did not arm away instant" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED # should have started a re-auth flow assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 @@ -309,7 +303,7 @@ async def test_arm_away_failure(hass: HomeAssistant) -> None: ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect failed to arm away test." + assert f"{err.value}" == "Failed to arm away test" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 @@ -319,7 +313,7 @@ async def test_arm_away_failure(hass: HomeAssistant) -> None: ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect usercode is invalid. Did not arm away" + assert f"{err.value}" == "Usercode is invalid, did not arm away" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED # should have started a re-auth flow assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 @@ -369,7 +363,7 @@ async def test_disarm_failure(hass: HomeAssistant) -> None: ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect failed to disarm test." + assert f"{err.value}" == "Failed to disarm test" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY assert mock_request.call_count == 2 @@ -379,7 +373,7 @@ async def test_disarm_failure(hass: HomeAssistant) -> None: ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect usercode is invalid. Did not disarm" + assert f"{err.value}" == "Usercode is invalid, did not disarm" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY # should have started a re-auth flow assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 @@ -463,7 +457,7 @@ async def test_arm_night_failure(hass: HomeAssistant) -> None: ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect failed to arm night test." + assert f"{err.value}" == "Failed to arm night test" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 @@ -473,7 +467,7 @@ async def test_arm_night_failure(hass: HomeAssistant) -> None: ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True ) await hass.async_block_till_done() - assert f"{err.value}" == "TotalConnect usercode is invalid. Did not arm night" + assert f"{err.value}" == "Usercode is invalid, did not arm night" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED # should have started a re-auth flow assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 From e3c2f81506aaa566445a8191d0679e56580eaaa2 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 16 Sep 2024 20:26:11 +1000 Subject: [PATCH 0694/1309] Add select platform to Tesla Fleet (#125931) * Add Select Platform * Add Select strings and icons * Add tests * Clean up fixture --- .../components/tesla_fleet/__init__.py | 1 + .../components/tesla_fleet/icons.json | 60 ++ .../components/tesla_fleet/select.py | 264 ++++++++ .../components/tesla_fleet/strings.json | 89 +++ .../tesla_fleet/snapshots/test_select.ambr | 585 ++++++++++++++++++ tests/components/tesla_fleet/test_select.py | 136 ++++ 6 files changed, 1135 insertions(+) create mode 100644 homeassistant/components/tesla_fleet/select.py create mode 100644 tests/components/tesla_fleet/snapshots/test_select.ambr create mode 100644 tests/components/tesla_fleet/test_select.py diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 61a1d02c355..bfd1c8907ed 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -43,6 +43,7 @@ PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.DEVICE_TRACKER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index d25346fe2a7..5927acaa1d9 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -60,6 +60,66 @@ "default": "mdi:routes" } }, + "select": { + "climate_state_seat_heater_left": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_rear_center": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_rear_left": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_rear_right": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_right": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_third_row_left": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "climate_state_seat_heater_third_row_right": { + "default": "mdi:car-seat-heater", + "state": { + "off": "mdi:car-seat" + } + }, + "components_customer_preferred_export_rule": { + "default": "mdi:transmission-tower", + "state": { + "battery_ok": "mdi:battery-negative", + "never": "mdi:transmission-tower-off", + "pv_only": "mdi:solar-panel" + } + }, + "default_real_mode": { + "default": "mdi:home-battery", + "state": { + "autonomous": "mdi:auto-fix", + "backup": "mdi:battery-charging-100", + "self_consumption": "mdi:home-battery" + } + } + }, "sensor": { "battery_power": { "default": "mdi:home-battery" diff --git a/homeassistant/components/tesla_fleet/select.py b/homeassistant/components/tesla_fleet/select.py new file mode 100644 index 00000000000..515a0e7c2e7 --- /dev/null +++ b/homeassistant/components/tesla_fleet/select.py @@ -0,0 +1,264 @@ +"""Select platform for Tesla Fleet integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from itertools import chain + +from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslaFleetConfigEntry +from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity +from .helpers import handle_command, handle_vehicle_command +from .models import TeslaFleetEnergyData, TeslaFleetVehicleData + +OFF = "off" +LOW = "low" +MEDIUM = "medium" +HIGH = "high" + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class SeatHeaterDescription(SelectEntityDescription): + """Seat Heater entity description.""" + + position: Seat + available_fn: Callable[[TeslaFleetSeatHeaterSelectEntity], bool] = lambda _: True + + +SEAT_HEATER_DESCRIPTIONS: tuple[SeatHeaterDescription, ...] = ( + SeatHeaterDescription( + key="climate_state_seat_heater_left", + position=Seat.FRONT_LEFT, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_right", + position=Seat.FRONT_RIGHT, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_rear_left", + position=Seat.REAR_LEFT, + available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + entity_registry_enabled_default=False, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_rear_center", + position=Seat.REAR_CENTER, + available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + entity_registry_enabled_default=False, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_rear_right", + position=Seat.REAR_RIGHT, + available_fn=lambda self: self.get("vehicle_config_rear_seat_heaters") != 0, + entity_registry_enabled_default=False, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_third_row_left", + position=Seat.THIRD_LEFT, + available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + entity_registry_enabled_default=False, + ), + SeatHeaterDescription( + key="climate_state_seat_heater_third_row_right", + position=Seat.THIRD_RIGHT, + available_fn=lambda self: self.get("vehicle_config_third_row_seats") != "None", + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslaFleetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the TeslaFleet select platform from a config entry.""" + + async_add_entities( + chain( + ( + TeslaFleetSeatHeaterSelectEntity( + vehicle, description, entry.runtime_data.scopes + ) + for description in SEAT_HEATER_DESCRIPTIONS + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslaFleetWheelHeaterSelectEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ), + ( + TeslaFleetOperationSelectEntity(energysite, entry.runtime_data.scopes) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_battery") + ), + ( + TeslaFleetExportRuleSelectEntity(energysite, entry.runtime_data.scopes) + for energysite in entry.runtime_data.energysites + if energysite.info_coordinator.data.get("components_battery") + and energysite.info_coordinator.data.get("components_solar") + ), + ) + ) + + +class TeslaFleetSeatHeaterSelectEntity(TeslaFleetVehicleEntity, SelectEntity): + """Select entity for vehicle seat heater.""" + + entity_description: SeatHeaterDescription + + _attr_options = [ + OFF, + LOW, + MEDIUM, + HIGH, + ] + + def __init__( + self, + data: TeslaFleetVehicleData, + description: SeatHeaterDescription, + scopes: list[Scope], + ) -> None: + """Initialize the vehicle seat select entity.""" + self.entity_description = description + self.scoped = Scope.VEHICLE_CMDS in scopes + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_available = self.entity_description.available_fn(self) + value = self._value + if value is None: + self._attr_current_option = None + else: + self._attr_current_option = self._attr_options[value] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_read_only(Scope.VEHICLE_CMDS) + await self.wake_up_if_asleep() + level = self._attr_options.index(option) + # AC must be on to turn on seat heater + if level and not self.get("climate_state_is_climate_on"): + await handle_vehicle_command(self.api.auto_conditioning_start()) + await handle_vehicle_command( + self.api.remote_seat_heater_request(self.entity_description.position, level) + ) + self._attr_current_option = option + self.async_write_ha_state() + + +class TeslaFleetWheelHeaterSelectEntity(TeslaFleetVehicleEntity, SelectEntity): + """Select entity for vehicle steering wheel heater.""" + + _attr_options = [ + OFF, + LOW, + HIGH, + ] + + def __init__( + self, + data: TeslaFleetVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the vehicle steering wheel select entity.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + super().__init__( + data, + "climate_state_steering_wheel_heat_level", + ) + + def _async_update_attrs(self) -> None: + """Handle updated data from the coordinator.""" + + value = self._value + if value is None: + self._attr_current_option = None + else: + self._attr_current_option = self._attr_options[value] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_read_only(Scope.VEHICLE_CMDS) + await self.wake_up_if_asleep() + level = self._attr_options.index(option) + # AC must be on to turn on steering wheel heater + if level and not self.get("climate_state_is_climate_on"): + await handle_vehicle_command(self.api.auto_conditioning_start()) + await handle_vehicle_command( + self.api.remote_steering_wheel_heat_level_request(level) + ) + self._attr_current_option = option + self.async_write_ha_state() + + +class TeslaFleetOperationSelectEntity(TeslaFleetEnergyInfoEntity, SelectEntity): + """Select entity for operation mode select entities.""" + + _attr_options: list[str] = [ + EnergyOperationMode.AUTONOMOUS, + EnergyOperationMode.BACKUP, + EnergyOperationMode.SELF_CONSUMPTION, + ] + + def __init__( + self, + data: TeslaFleetEnergyData, + scopes: list[Scope], + ) -> None: + """Initialize the operation mode select entity.""" + self.scoped = Scope.ENERGY_CMDS in scopes + super().__init__(data, "default_real_mode") + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_current_option = self._value + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_read_only(Scope.ENERGY_CMDS) + await handle_command(self.api.operation(option)) + self._attr_current_option = option + self.async_write_ha_state() + + +class TeslaFleetExportRuleSelectEntity(TeslaFleetEnergyInfoEntity, SelectEntity): + """Select entity for export rules select entities.""" + + _attr_options: list[str] = [ + EnergyExportMode.NEVER, + EnergyExportMode.BATTERY_OK, + EnergyExportMode.PV_ONLY, + ] + + def __init__( + self, + data: TeslaFleetEnergyData, + scopes: list[Scope], + ) -> None: + """Initialize the export rules select entity.""" + self.scoped = Scope.ENERGY_CMDS in scopes + super().__init__(data, "components_customer_preferred_export_rule") + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_current_option = self.get(self.key, EnergyExportMode.NEVER.value) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + self.raise_for_read_only(Scope.ENERGY_CMDS) + await handle_command( + self.api.grid_import_export(customer_preferred_export_rule=option) + ) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 8a70fe0997a..25011cd6d45 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -133,6 +133,95 @@ "name": "Route" } }, + "select": { + "climate_state_seat_heater_left": { + "name": "Seat heater front left", + "state": { + "high": "High", + "low": "Low", + "medium": "Medium", + "off": "Off" + } + }, + "climate_state_seat_heater_rear_center": { + "name": "Seat heater rear center", + "state": { + "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_seat_heater_rear_left": { + "name": "Seat heater rear left", + "state": { + "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_seat_heater_rear_right": { + "name": "Seat heater rear right", + "state": { + "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_seat_heater_right": { + "name": "Seat heater front right", + "state": { + "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_seat_heater_third_row_left": { + "name": "Seat heater third row left", + "state": { + "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_seat_heater_third_row_right": { + "name": "Seat heater third row right", + "state": { + "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]", + "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "climate_state_steering_wheel_heat_level": { + "name": "Steering wheel heater", + "state": { + "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]", + "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]", + "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]" + } + }, + "components_customer_preferred_export_rule": { + "name": "Allow export", + "state": { + "battery_ok": "Battery", + "never": "Never", + "pv_only": "Solar only" + } + }, + "default_real_mode": { + "name": "Operation mode", + "state": { + "autonomous": "Autonomous", + "backup": "Backup", + "self_consumption": "Self consumption" + } + } + }, "sensor": { "battery_power": { "name": "Battery power" diff --git a/tests/components/tesla_fleet/snapshots/test_select.ambr b/tests/components/tesla_fleet/snapshots/test_select.ambr new file mode 100644 index 00000000000..f29ce841113 --- /dev/null +++ b/tests/components/tesla_fleet/snapshots/test_select.ambr @@ -0,0 +1,585 @@ +# serializer version: 1 +# name: test_select[select.energy_site_allow_export-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.energy_site_allow_export', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Allow export', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'components_customer_preferred_export_rule', + 'unique_id': '123456-components_customer_preferred_export_rule', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.energy_site_allow_export-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Allow export', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.energy_site_allow_export', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'pv_only', + }) +# --- +# name: test_select[select.energy_site_operation_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.energy_site_operation_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Operation mode', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'default_real_mode', + 'unique_id': '123456-default_real_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.energy_site_operation_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Operation mode', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.energy_site_operation_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'self_consumption', + }) +# --- +# name: test_select[select.test_seat_heater_front_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_front_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater front left', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_left', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_front_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater front left', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_front_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_front_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_front_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater front right', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_right', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_front_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater front right', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_front_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_center-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_center', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear center', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_center', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_center', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_center-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear center', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_center', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear left', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_left', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear left', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_rear_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_rear_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater rear right', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_rear_right', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_rear_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_rear_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater rear right', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_rear_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_select[select.test_seat_heater_third_row_left-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_third_row_left', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater third row left', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_third_row_left', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_third_row_left', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_third_row_left-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater third row left', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_third_row_left', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_select[select.test_seat_heater_third_row_right-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_seat_heater_third_row_right', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Seat heater third row right', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_seat_heater_third_row_right', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_seat_heater_third_row_right', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_seat_heater_third_row_right-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Seat heater third row right', + 'options': list([ + 'off', + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_seat_heater_third_row_right', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_select[select.test_steering_wheel_heater-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.test_steering_wheel_heater', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Steering wheel heater', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'climate_state_steering_wheel_heat_level', + 'unique_id': 'LRWXF7EK4KC700000-climate_state_steering_wheel_heat_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_select[select.test_steering_wheel_heater-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Steering wheel heater', + 'options': list([ + 'off', + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.test_steering_wheel_heater', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tesla_fleet/test_select.py b/tests/components/tesla_fleet/test_select.py new file mode 100644 index 00000000000..902b28ddb7a --- /dev/null +++ b/tests/components/tesla_fleet/test_select.py @@ -0,0 +1,136 @@ +"""Test the Tesla Fleet select platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.tesla_fleet.select import LOW +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the select entities are correct.""" + + await setup_platform(hass, normal_config_entry, [Platform.SELECT]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +async def test_select_offline( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the select entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, normal_config_entry, [Platform.SELECT]) + state = hass.states.get("select.test_seat_heater_front_left") + assert state.state == STATE_UNKNOWN + + +async def test_select_services( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the select services work.""" + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, normal_config_entry, [Platform.SELECT]) + + entity_id = "select.test_seat_heater_front_left" + with ( + patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.remote_seat_heater_request", + return_value=COMMAND_OK, + ) as remote_seat_heater_request, + patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + return_value=COMMAND_OK, + ) as auto_conditioning_start, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LOW}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == LOW + auto_conditioning_start.assert_called_once() + remote_seat_heater_request.assert_called_once() + + entity_id = "select.test_steering_wheel_heater" + with ( + patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.remote_steering_wheel_heat_level_request", + return_value=COMMAND_OK, + ) as remote_steering_wheel_heat_level_request, + patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.auto_conditioning_start", + return_value=COMMAND_OK, + ) as auto_conditioning_start, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: LOW}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == LOW + auto_conditioning_start.assert_called_once() + remote_steering_wheel_heat_level_request.assert_called_once() + + entity_id = "select.energy_site_operation_mode" + with patch( + "homeassistant.components.tesla_fleet.EnergySpecific.operation", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: EnergyOperationMode.AUTONOMOUS.value, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == EnergyOperationMode.AUTONOMOUS.value + call.assert_called_once() + + entity_id = "select.energy_site_allow_export" + with patch( + "homeassistant.components.tesla_fleet.EnergySpecific.grid_import_export", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: EnergyExportMode.BATTERY_OK.value}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == EnergyExportMode.BATTERY_OK.value + call.assert_called_once() From 8bfcdb9266b819e92f980352b4379a2313760055 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 16 Sep 2024 12:38:28 +0200 Subject: [PATCH 0695/1309] Use debug instead of info log level in components [L] (#126039) Use debug instead of info log level in components [l] --- homeassistant/components/landisgyr_heat_meter/__init__.py | 2 +- homeassistant/components/lifx/__init__.py | 2 +- homeassistant/components/linksys_smart/device_tracker.py | 2 +- homeassistant/components/lirc/__init__.py | 2 +- homeassistant/components/litejet/__init__.py | 2 +- homeassistant/components/lutron/__init__.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index a2fc1320c2b..5cbdc593100 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -73,6 +73,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass, config_entry.entry_id, update_entity_unique_id ) - _LOGGER.info("Migration to version %s successful", config_entry.version) + _LOGGER.debug("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 47f00959bcd..974292c6e80 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -88,7 +88,7 @@ async def async_legacy_migration( hass, hosts_by_serial, existing_serials, legacy_entry ) if missing_discovery_count: - _LOGGER.info( + _LOGGER.debug( "Migration in progress, waiting to discover %s device(s)", missing_discovery_count, ) diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py index 3bd47e59d48..596b7012140 100644 --- a/homeassistant/components/linksys_smart/device_tracker.py +++ b/homeassistant/components/linksys_smart/device_tracker.py @@ -62,7 +62,7 @@ class LinksysSmartWifiDeviceScanner(DeviceScanner): def _update_info(self): """Check for connected devices.""" - _LOGGER.info("Checking Linksys Smart Wifi") + _LOGGER.debug("Checking Linksys Smart Wifi") self.last_results = {} response = self._make_request() diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index b847a160f51..f5b26743a03 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -71,7 +71,7 @@ class LircInterface(threading.Thread): # interpret result from python-lirc if code: code = code[0] - _LOGGER.info("Got new LIRC code %s", code) + _LOGGER.debug("Got new LIRC code %s", code) self.hass.bus.fire(EVENT_IR_COMMAND_RECEIVED, {BUTTON_NAME: code}) else: time.sleep(0.2) diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index e9d1cca74cb..84667d6c94d 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def handle_connected_changed(connected: bool, reason: str) -> None: if connected: - _LOGGER.info("Connected") + _LOGGER.debug("Connected") else: _LOGGER.warning("Disconnected %s", reason) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 45a51eb6df8..a494a37cb52 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -54,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b lutron_client = Lutron(host, uid, pwd) await hass.async_add_executor_job(lutron_client.load_xml_db) lutron_client.connect() - _LOGGER.info("Connected to main repeater at %s", host) + _LOGGER.debug("Connected to main repeater at %s", host) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) From 136242e38c2fdfe03dabf3bd4874135882311fd3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 16 Sep 2024 12:38:50 +0200 Subject: [PATCH 0696/1309] Use debug/warning instead of info log level in components [k] (#126038) --- homeassistant/components/kankun/switch.py | 4 ++-- homeassistant/components/kira/__init__.py | 2 +- homeassistant/components/kira/remote.py | 2 +- homeassistant/components/kiwi/lock.py | 2 +- homeassistant/components/konnected/panel.py | 6 +++--- homeassistant/components/kraken/__init__.py | 2 +- homeassistant/components/kulersky/light.py | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/kankun/switch.py b/homeassistant/components/kankun/switch.py index a86bed5eb9a..cd91b7660c8 100644 --- a/homeassistant/components/kankun/switch.py +++ b/homeassistant/components/kankun/switch.py @@ -89,7 +89,7 @@ class KankunSwitch(SwitchEntity): def _switch(self, newstate): """Switch on or off.""" - _LOGGER.info("Switching to state: %s", newstate) + _LOGGER.debug("Switching to state: %s", newstate) try: req = requests.get( @@ -101,7 +101,7 @@ class KankunSwitch(SwitchEntity): def _query_state(self): """Query switch state.""" - _LOGGER.info("Querying state from: %s", self._url) + _LOGGER.debug("Querying state from: %s", self._url) try: req = requests.get(f"{self._url}?get=state", auth=self._auth, timeout=5) diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py index b0305bc0643..b41961f64ee 100644 --- a/homeassistant/components/kira/__init__.py +++ b/homeassistant/components/kira/__init__.py @@ -141,7 +141,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Stop the KIRA receiver.""" for receiver in hass.data[DOMAIN][CONF_SENSOR].values(): receiver.stop() - _LOGGER.info("Terminated receivers") + _LOGGER.debug("Terminated receivers") hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_kira) diff --git a/homeassistant/components/kira/remote.py b/homeassistant/components/kira/remote.py index f6ee4af75ef..c1d28f8b077 100644 --- a/homeassistant/components/kira/remote.py +++ b/homeassistant/components/kira/remote.py @@ -45,5 +45,5 @@ class KiraRemote(remote.RemoteEntity): """Send a command to one device.""" for single_command in command: code_tuple = (single_command, kwargs.get(remote.ATTR_DEVICE)) - _LOGGER.info("Sending Command: %s to %s", *code_tuple) + _LOGGER.debug("Sending Command: %s to %s", *code_tuple) self._kira.sendCode(code_tuple) diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index bde9a77f748..fb4272dfa63 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -55,7 +55,7 @@ def setup_platform( return if not (available_locks := kiwi.get_locks()): # No locks found; abort setup routine. - _LOGGER.info("No KIWI locks found in your account") + _LOGGER.debug("No KIWI locks found in your account") return add_entities([KiwiLock(lock, kiwi) for lock in available_locks], True) diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py index 605b27f7547..e2dfc6be06a 100644 --- a/homeassistant/components/konnected/panel.py +++ b/homeassistant/components/konnected/panel.py @@ -123,7 +123,7 @@ class AlarmPanel: self.api_version = KONN_API_VERSIONS.get( self.status.get("model", KONN_MODEL), KONN_API_VERSIONS[KONN_MODEL] ) - _LOGGER.info( + _LOGGER.debug( "Connected to new %s device", self.status.get("model", "Konnected") ) _LOGGER.debug(self.status) @@ -145,7 +145,7 @@ class AlarmPanel: self.connect_attempts = 0 self.connected = True - _LOGGER.info( + _LOGGER.debug( ( "Set up Konnected device %s. Open http://%s:%s in a " "web browser to view device status" @@ -380,7 +380,7 @@ class AlarmPanel: self.async_desired_settings_payload() != self.async_current_settings_payload() ): - _LOGGER.info("Pushing settings to device %s", self.device_id) + _LOGGER.debug("Pushing settings to device %s", self.device_id) await self.client.put_settings(**self.async_desired_settings_payload()) diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index 692f602460b..9a90e77f2b6 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -77,7 +77,7 @@ class KrakenData: return await self._hass.async_add_executor_job(self._get_kraken_data) except pykrakenapi.pykrakenapi.KrakenAPIError as error: if "Unknown asset pair" in str(error): - _LOGGER.info( + _LOGGER.warning( "Kraken.com reported an unknown asset pair. Refreshing list of" " tradable asset pairs" ) diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index cb98e52250f..552507ef50b 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -137,7 +137,7 @@ class KulerskyLight(LightEntity): self._attr_available = False return if self._attr_available is False: - _LOGGER.info("Reconnected to %s", self._light.address) + _LOGGER.warning("Reconnected to %s", self._light.address) self._attr_available = True brightness = max(rgbw) From 6a2d31a48105d8a80532d94924d9daf29c7dbd0d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 16 Sep 2024 12:39:02 +0200 Subject: [PATCH 0697/1309] Use debug instead of info log level in components [j] (#126037) Use debug/warning instead of info log level in components [j] --- homeassistant/components/juicenet/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index 5c32caab36f..445d04e67ec 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -72,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not juicenet.devices: _LOGGER.error("No JuiceNet devices found for this account") return False - _LOGGER.info("%d JuiceNet device(s) found", len(juicenet.devices)) + _LOGGER.debug("%d JuiceNet device(s) found", len(juicenet.devices)) async def async_update_data(): """Update all device states from the JuiceNet API.""" From dadd397bf0f30970c3710e3df392d4e09c849bd6 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 16 Sep 2024 12:39:20 +0200 Subject: [PATCH 0698/1309] Use debug/warning instead of info log level in components [i] (#126036) --- homeassistant/components/insteon/config_flow.py | 2 +- homeassistant/components/isy994/__init__.py | 2 +- homeassistant/components/isy994/services.py | 2 +- homeassistant/components/izone/climate.py | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 6b048004ba1..7c79b8d3888 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -44,7 +44,7 @@ async def _async_connect(**kwargs): _LOGGER.error("Could not connect to Insteon modem") return False - _LOGGER.info("Connected to Insteon modem") + _LOGGER.debug("Connected to Insteon modem") return True diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 0c238182849..d2862054971 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -144,7 +144,7 @@ async def async_setup_entry( isy_data.net_resources.append(resource) # Dump ISY Clock Information. Future: Add ISY as sensor to Hass with attrs - _LOGGER.info(repr(isy.clock)) + _LOGGER.debug(repr(isy.clock)) isy_data.root = isy _async_get_or_create_isy_device_in_registry(hass, entry, isy) diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index ffcea5cc8f8..1cd46446ed6 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -242,7 +242,7 @@ def async_unload_services(hass: HomeAssistant) -> None: if not existing_services or SERVICE_SEND_PROGRAM_COMMAND not in existing_services: return - _LOGGER.info("Unloading ISY994 Services") + _LOGGER.debug("Unloading ISY994 Services") hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_PROGRAM_COMMAND) hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_RAW_NODE_COMMAND) hass.services.async_remove(domain=DOMAIN, service=SERVICE_SEND_NODE_COMMAND) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 617cdc730cc..2a602939250 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -85,9 +85,9 @@ async def async_setup_entry( # Filter out any entities excluded in the config file if conf and ctrl.device_uid in conf[CONF_EXCLUDE]: - _LOGGER.info("Controller UID=%s ignored as excluded", ctrl.device_uid) + _LOGGER.debug("Controller UID=%s ignored as excluded", ctrl.device_uid) return - _LOGGER.info("Controller UID=%s discovered", ctrl.device_uid) + _LOGGER.debug("Controller UID=%s discovered", ctrl.device_uid) device = ControllerDevice(ctrl) async_add_entities([device]) @@ -245,9 +245,9 @@ class ControllerDevice(ClimateEntity): return if available: - _LOGGER.info("Reconnected controller %s ", self._controller.device_uid) + _LOGGER.warning("Reconnected controller %s ", self._controller.device_uid) else: - _LOGGER.info( + _LOGGER.warning( "Controller %s disconnected due to exception: %s", self._controller.device_uid, ex, From 1e4864d8c5b89cfe4bd2b45ebfafe9f4cd927457 Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 16 Sep 2024 20:41:29 +1000 Subject: [PATCH 0699/1309] Set Smlight integration to local_push class (#125983) --- homeassistant/components/smlight/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 609899971aa..cd1002e35d9 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", - "iot_class": "local_polling", + "iot_class": "local_push", "requirements": ["pysmlight==0.0.16"], "zeroconf": [ { diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f3392a3338a..9963409f62e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5664,7 +5664,7 @@ "name": "SMLIGHT SLZB", "integration_type": "device", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_push" }, "sms": { "name": "SMS notifications via GSM-modem", From 765448fdf47833ee501101ce2184fffc211079e5 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 16 Sep 2024 12:56:08 +0200 Subject: [PATCH 0700/1309] Exclude uv from wheels building (#126035) --- .github/workflows/wheels.yml | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 2ba72411330..5a53d91cbe2 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -131,6 +131,12 @@ jobs: with: name: requirements_diff + - name: Adjust build env + run: | + # Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine + sed -i "/uv/d" requirements.txt + sed -i "/uv/d" requirements_diff.txt + - name: Build wheels uses: home-assistant/wheels@2024.07.1 with: @@ -174,6 +180,18 @@ jobs: with: name: requirements_all_wheels + - name: Adjust build env + run: | + if [ "${{ matrix.arch }}" = "i386" ]; then + echo "NPY_DISABLE_SVML=1" >> .env_file + fi + + # Do not pin numpy in wheels building + sed -i "/numpy/d" homeassistant/package_constraints.txt + # Don't build wheels for uv as uv requires a greater version of rust as currently available on alpine + sed -i "/uv/d" requirements.txt + sed -i "/uv/d" requirements_diff.txt + - name: Split requirements all run: | # We split requirements all into multiple files. @@ -194,15 +212,6 @@ jobs: cat homeassistant/package_constraints.txt | grep 'grpcio==' >> requirements_old-cython.txt cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt - - name: Adjust build env - run: | - if [ "${{ matrix.arch }}" = "i386" ]; then - echo "NPY_DISABLE_SVML=1" >> .env_file - fi - - # Do not pin numpy in wheels building - sed -i "/numpy/d" homeassistant/package_constraints.txt - - name: Build wheels (old cython) uses: home-assistant/wheels@2024.07.1 with: From fdc58f952e842a3e3d28b47a64e61459ead34265 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 16 Sep 2024 21:38:45 +1000 Subject: [PATCH 0701/1309] Add number platform to Tesla Fleet (#125985) * Add number platform * Actually add the umber platform files --- .../components/tesla_fleet/entity.py | 6 + .../components/tesla_fleet/number.py | 206 ++++++++++++++++ .../components/tesla_fleet/strings.json | 14 ++ .../tesla_fleet/snapshots/test_number.ambr | 231 ++++++++++++++++++ tests/components/tesla_fleet/test_number.py | 119 +++++++++ 5 files changed, 576 insertions(+) create mode 100644 homeassistant/components/tesla_fleet/number.py create mode 100644 tests/components/tesla_fleet/snapshots/test_number.ambr create mode 100644 tests/components/tesla_fleet/test_number.py diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py index a7d649bce56..60230cd881d 100644 --- a/homeassistant/components/tesla_fleet/entity.py +++ b/homeassistant/components/tesla_fleet/entity.py @@ -62,6 +62,12 @@ class TeslaFleetEntity( """Return a specific value from coordinator data.""" return self.coordinator.data.get(key, default) + def get_number(self, key: str, default: float) -> float: + """Return a specific number from coordinator data.""" + if isinstance(value := self.coordinator.data.get(key), (int, float)): + return value + return default + @property def is_none(self) -> bool: """Return if the value is a literal None.""" diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py new file mode 100644 index 00000000000..b806b4dbc77 --- /dev/null +++ b/homeassistant/components/tesla_fleet/number.py @@ -0,0 +1,206 @@ +"""Number platform for Tesla Fleet integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from itertools import chain +from typing import Any + +from tesla_fleet_api import EnergySpecific, VehicleSpecific +from tesla_fleet_api.const import Scope + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level + +from . import TeslaFleetConfigEntry +from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity +from .helpers import handle_command, handle_vehicle_command +from .models import TeslaFleetEnergyData, TeslaFleetVehicleData + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class TeslaFleetNumberVehicleEntityDescription(NumberEntityDescription): + """Describes TeslaFleet Number entity.""" + + func: Callable[[VehicleSpecific, float], Awaitable[Any]] + native_min_value: float + native_max_value: float + min_key: str | None = None + max_key: str + scopes: list[Scope] + + +VEHICLE_DESCRIPTIONS: tuple[TeslaFleetNumberVehicleEntityDescription, ...] = ( + TeslaFleetNumberVehicleEntityDescription( + key="charge_state_charge_current_request", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=32, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=NumberDeviceClass.CURRENT, + mode=NumberMode.AUTO, + max_key="charge_state_charge_current_request_max", + func=lambda api, value: api.set_charging_amps(value), + scopes=[Scope.VEHICLE_CHARGING_CMDS], + ), + TeslaFleetNumberVehicleEntityDescription( + key="charge_state_charge_limit_soc", + native_step=PRECISION_WHOLE, + native_min_value=50, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + mode=NumberMode.AUTO, + min_key="charge_state_charge_limit_soc_min", + max_key="charge_state_charge_limit_soc_max", + func=lambda api, value: api.set_charge_limit(value), + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + ), +) + + +@dataclass(frozen=True, kw_only=True) +class TeslaFleetNumberBatteryEntityDescription(NumberEntityDescription): + """Describes TeslaFleet Number entity.""" + + func: Callable[[EnergySpecific, float], Awaitable[Any]] + requires: str | None = None + + +ENERGY_INFO_DESCRIPTIONS: tuple[TeslaFleetNumberBatteryEntityDescription, ...] = ( + TeslaFleetNumberBatteryEntityDescription( + key="backup_reserve_percent", + func=lambda api, value: api.backup(int(value)), + requires="components_battery", + ), + TeslaFleetNumberBatteryEntityDescription( + key="off_grid_vehicle_charging_reserve_percent", + func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)), + requires="components_off_grid_vehicle_charging_reserve_supported", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslaFleetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the TeslaFleet number platform from a config entry.""" + + async_add_entities( + chain( + ( # Add vehicle entities + TeslaFleetVehicleNumberEntity( + vehicle, + description, + entry.runtime_data.scopes, + ) + for vehicle in entry.runtime_data.vehicles + for description in VEHICLE_DESCRIPTIONS + ), + ( # Add energy site entities + TeslaFleetEnergyInfoNumberSensorEntity( + energysite, + description, + entry.runtime_data.scopes, + ) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.requires is None + or energysite.info_coordinator.data.get(description.requires) + ), + ) + ) + + +class TeslaFleetVehicleNumberEntity(TeslaFleetVehicleEntity, NumberEntity): + """Vehicle number entity base class.""" + + entity_description: TeslaFleetNumberVehicleEntityDescription + + def __init__( + self, + data: TeslaFleetVehicleData, + description: TeslaFleetNumberVehicleEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the number entity.""" + self.scoped = any(scope in scopes for scope in description.scopes) + self.entity_description = description + super().__init__( + data, + description.key, + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_native_value = self._value + + if (min_key := self.entity_description.min_key) is not None: + self._attr_native_min_value = self.get_number( + min_key, + self.entity_description.native_min_value, + ) + else: + self._attr_native_min_value = self.entity_description.native_min_value + + self._attr_native_max_value = self.get_number( + self.entity_description.max_key, + self.entity_description.native_max_value, + ) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_read_only(self.entity_description.scopes[0]) + await self.wake_up_if_asleep() + await handle_vehicle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() + + +class TeslaFleetEnergyInfoNumberSensorEntity(TeslaFleetEnergyInfoEntity, NumberEntity): + """Energy info number entity base class.""" + + entity_description: TeslaFleetNumberBatteryEntityDescription + _attr_native_step = PRECISION_WHOLE + _attr_native_min_value = 0 + _attr_native_max_value = 100 + _attr_device_class = NumberDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__( + self, + data: TeslaFleetEnergyData, + description: TeslaFleetNumberBatteryEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the number entity.""" + self.scoped = Scope.ENERGY_CMDS in scopes + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_native_value = self._value + self._attr_icon = icon_for_battery_level(self.native_value) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_read_only(Scope.ENERGY_CMDS) + await handle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 25011cd6d45..ed8f45d2f8f 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -133,6 +133,20 @@ "name": "Route" } }, + "number": { + "backup_reserve_percent": { + "name": "Backup reserve" + }, + "charge_state_charge_current_request": { + "name": "Charge current" + }, + "charge_state_charge_limit_soc": { + "name": "Charge limit" + }, + "off_grid_vehicle_charging_reserve_percent": { + "name": "Off grid reserve" + } + }, "select": { "climate_state_seat_heater_left": { "name": "Seat heater front left", diff --git a/tests/components/tesla_fleet/snapshots/test_number.ambr b/tests/components/tesla_fleet/snapshots/test_number.ambr new file mode 100644 index 00000000000..00dd67015fe --- /dev/null +++ b/tests/components/tesla_fleet/snapshots/test_number.ambr @@ -0,0 +1,231 @@ +# serializer version: 1 +# name: test_number[number.energy_site_backup_reserve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.energy_site_backup_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-alert', + 'original_name': 'Backup reserve', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backup_reserve_percent', + 'unique_id': '123456-backup_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.energy_site_backup_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Backup reserve', + 'icon': 'mdi:battery-alert', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.energy_site_backup_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number[number.energy_site_off_grid_reserve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.energy_site_off_grid_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Off grid reserve', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'off_grid_vehicle_charging_reserve_percent', + 'unique_id': '123456-off_grid_vehicle_charging_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.energy_site_off_grid_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Off grid reserve', + 'icon': 'mdi:battery-unknown', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.energy_site_off_grid_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_number[number.test_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 16, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge current', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_current_request', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_current_request', + 'unit_of_measurement': , + }) +# --- +# name: test_number[number.test_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test Charge current', + 'max': 16, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_number[number.test_charge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge limit', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_limit_soc', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_limit_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.test_charge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Charge limit', + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_charge_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- diff --git a/tests/components/tesla_fleet/test_number.py b/tests/components/tesla_fleet/test_number.py new file mode 100644 index 00000000000..8551a99ee29 --- /dev/null +++ b/tests/components/tesla_fleet/test_number.py @@ -0,0 +1,119 @@ +"""Test the Tesla Fleet number platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the number entities are correct.""" + + await setup_platform(hass, normal_config_entry, [Platform.NUMBER]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +async def test_number_offline( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the number entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, normal_config_entry, [Platform.NUMBER]) + state = hass.states.get("number.test_charge_current") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_services( + hass: HomeAssistant, mock_vehicle_data, normal_config_entry: MockConfigEntry +) -> None: + """Tests that the number services work.""" + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, normal_config_entry, [Platform.NUMBER]) + + entity_id = "number.test_charge_current" + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.set_charging_amps", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 16}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "16" + call.assert_called_once() + + entity_id = "number.test_charge_limit" + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.set_charge_limit", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 60}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "60" + call.assert_called_once() + + entity_id = "number.energy_site_backup_reserve" + with patch( + "homeassistant.components.tesla_fleet.EnergySpecific.backup", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 80, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "80" + call.assert_called_once() + + entity_id = "number.energy_site_off_grid_reserve" + with patch( + "homeassistant.components.tesla_fleet.EnergySpecific.off_grid_vehicle_charging_reserve", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 88}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "88" + call.assert_called_once() From ac17020cd05f9985b61f0fd5ef707dc15a21af10 Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 16 Sep 2024 21:45:39 +1000 Subject: [PATCH 0702/1309] Abort zeroconf flow on connect error during discovery (#125980) Abort zereconf flow on connect error during discovery --- homeassistant/components/smlight/config_flow.py | 7 ++++++- tests/components/smlight/test_config_flow.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index e8984300ff1..0e5b0f49d7b 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -97,8 +97,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): mac = discovery_info.properties.get("mac") # fallback for legacy firmware if mac is None: - info = await self.client.get_info() + try: + info = await self.client.get_info() + except SmlightConnectionError: + # User is likely running unsupported ESPHome firmware + return self.async_abort(reason="cannot_connect") mac = info.MAC + await self.async_set_unique_id(format_mac(mac)) self._abort_if_unique_id_configured() diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index dae727c7a29..2fd39f75704 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -336,6 +336,22 @@ async def test_zeroconf_cannot_connect( assert result2["reason"] == "cannot_connect" +async def test_zeroconf_legacy_cannot_connect( + hass: HomeAssistant, mock_smlight_client: MagicMock +) -> None: + """Test we abort flow on zeroconf discovery unsupported firmware.""" + mock_smlight_client.get_info.side_effect = SmlightConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=DISCOVERY_INFO_LEGACY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + @pytest.mark.usefixtures("mock_smlight_client") async def test_zeroconf_legacy_mac( hass: HomeAssistant, mock_smlight_client: MagicMock, mock_setup_entry: AsyncMock From 5660d1e48ea658e23078ff43cc2ee0360b3b6fea Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 16 Sep 2024 21:56:44 +1000 Subject: [PATCH 0703/1309] Add internet binary sensor to Smlight integration (#125982) * Add internet sensor updated by events * Strings for internet sensor * Update binary_sensor snapshot with internet sensor * Add test for internet sensor * Address review comments --------- Co-authored-by: Tim Lunn --- .../components/smlight/binary_sensor.py | 59 +++++++++++++++++- homeassistant/components/smlight/const.py | 1 + homeassistant/components/smlight/strings.json | 3 + .../smlight/snapshots/test_binary_sensor.ambr | 47 ++++++++++++++ .../components/smlight/test_binary_sensor.py | 61 ++++++++++++++++++- 5 files changed, 167 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index b010c3f7cbd..b5c695617eb 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -6,6 +6,8 @@ from _collections_abc import Callable from dataclasses import dataclass from pysmlight import Sensors +from pysmlight.const import Events as SmEvents +from pysmlight.sse import MessageEvent from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -14,12 +16,15 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import SCAN_INTERNET_INTERVAL from .coordinator import SmDataUpdateCoordinator from .entity import SmEntity +SCAN_INTERVAL = SCAN_INTERNET_INTERVAL + @dataclass(frozen=True, kw_only=True) class SmBinarySensorEntityDescription(BinarySensorEntityDescription): @@ -52,7 +57,13 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - SmBinarySensorEntity(coordinator, description) for description in SENSORS + [ + *( + SmBinarySensorEntity(coordinator, description) + for description in SENSORS + ), + SmInternetSensorEntity(coordinator), + ] ) @@ -78,3 +89,47 @@ class SmBinarySensorEntity(SmEntity, BinarySensorEntity): def is_on(self) -> bool: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data.sensors) + + +class SmInternetSensorEntity(SmEntity, BinarySensorEntity): + """Representation of the SLZB internet sensor.""" + + _attr_translation_key = "internet" + _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: SmDataUpdateCoordinator, + ) -> None: + """Initialize slzb binary sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.unique_id}_{self._attr_translation_key}" + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.client.sse.register_callback( + SmEvents.EVENT_INET_STATE, self.internet_callback + ) + ) + await self.async_update() + + @callback + def internet_callback(self, event: MessageEvent) -> None: + """Update internet state from event.""" + self._attr_is_on = event.data == "ok" + self.async_write_ha_state() + + @property + def should_poll(self) -> bool: + """Poll entity for internet connected updates.""" + return True + + async def async_update(self) -> None: + """Update the sensor. + + This is an async api, device will respond with EVENT_INET_STATE event. + """ + await self.coordinator.client.get_param("inetState") diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index 791b00c3e93..a49ac009a50 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -9,4 +9,5 @@ ATTR_MANUFACTURER = "SMLIGHT" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) +SCAN_INTERNET_INTERVAL = timedelta(minutes=15) UPTIME_DEVIATION = timedelta(seconds=5) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index ad36711528b..425815f68f0 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -46,6 +46,9 @@ "ethernet": { "name": "Ethernet" }, + "internet": { + "name": "Internet" + }, "wifi": { "name": "Wi-Fi" } diff --git a/tests/components/smlight/snapshots/test_binary_sensor.ambr b/tests/components/smlight/snapshots/test_binary_sensor.ambr index 5ea936f9647..17dca1c9784 100644 --- a/tests/components/smlight/snapshots/test_binary_sensor.ambr +++ b/tests/components/smlight/snapshots/test_binary_sensor.ambr @@ -46,6 +46,53 @@ 'state': 'on', }) # --- +# name: test_all_binary_sensors[binary_sensor.mock_title_internet-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mock_title_internet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Internet', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'internet', + 'unique_id': 'aa:bb:cc:dd:ee:ff_internet', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensors[binary_sensor.mock_title_internet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Mock Title Internet', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_title_internet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_binary_sensors[binary_sensor.mock_title_wi_fi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smlight/test_binary_sensor.py b/tests/components/smlight/test_binary_sensor.py index ddf9b01bf16..ce7d4e3ff6d 100644 --- a/tests/components/smlight/test_binary_sensor.py +++ b/tests/components/smlight/test_binary_sensor.py @@ -1,15 +1,22 @@ """Tests for the SMLIGHT binary sensor platform.""" +from collections.abc import Callable +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pysmlight.const import Events +from pysmlight.sse import MessageEvent import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.smlight.const import SCAN_INTERNET_INTERVAL +from homeassistant.const import STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .conftest import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform pytestmark = [ pytest.mark.usefixtures( @@ -17,6 +24,14 @@ pytestmark = [ ) ] +MOCK_INET_STATE = MessageEvent( + type="EVENT_INET_STATE", + message="EVENT_INET_STATE", + data="ok", + origin="http://slzb-06.local", + last_event_id="", +) + @pytest.fixture def platforms() -> list[Platform]: @@ -36,6 +51,8 @@ async def test_all_binary_sensors( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + await hass.config_entries.async_unload(entry.entry_id) + async def test_disabled_by_default_sensors( hass: HomeAssistant, @@ -50,3 +67,43 @@ async def test_disabled_by_default_sensors( assert (entry := entity_registry.async_get("binary_sensor.mock_title_wi_fi")) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_internet_sensor_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test internet sensor event.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("binary_sensor.mock_title_internet") + assert state is not None + assert state.state == STATE_UNKNOWN + + assert len(mock_smlight_client.get_param.mock_calls) == 1 + mock_smlight_client.get_param.assert_called_with("inetState") + + freezer.tick(SCAN_INTERNET_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(mock_smlight_client.get_param.mock_calls) == 2 + mock_smlight_client.get_param.assert_called_with("inetState") + + event_function: Callable[[MessageEvent], None] = next( + ( + call_args[0][1] + for call_args in mock_smlight_client.sse.register_callback.call_args_list + if call_args[0][0] == Events.EVENT_INET_STATE + ), + None, + ) + + event_function(MOCK_INET_STATE) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.mock_title_internet") + assert state is not None + assert state.state == STATE_ON From e9364f4c3aa6c935015ac24327a3fc4b34f78f05 Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 16 Sep 2024 22:14:15 +1000 Subject: [PATCH 0704/1309] Add update platform for Smlight integration (#125943) * Create update coordinator for update entities * fix type errors * update info fixture with zigbee version * Add fixtures for Firmware objects * mock get_firmware_version function * Add update platform for Smlight integration * Add strings for update platform * Add tests for update platform * add snapshot for update tests * Split out base coordinator * Update homeassistant/components/smlight/strings.json Co-authored-by: Joost Lekkerkerker * overwrite coordinator types --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/smlight/__init__.py | 38 ++- .../components/smlight/binary_sensor.py | 2 +- homeassistant/components/smlight/button.py | 3 +- homeassistant/components/smlight/const.py | 3 + .../components/smlight/coordinator.py | 105 +++++--- homeassistant/components/smlight/entity.py | 6 +- homeassistant/components/smlight/sensor.py | 4 +- homeassistant/components/smlight/strings.json | 8 + homeassistant/components/smlight/switch.py | 3 +- homeassistant/components/smlight/update.py | 189 ++++++++++++++ tests/components/smlight/conftest.py | 24 +- .../smlight/fixtures/esp_firmware.json | 35 +++ tests/components/smlight/fixtures/info.json | 4 +- .../smlight/fixtures/zb_firmware.json | 46 ++++ .../smlight/snapshots/test_init.ambr | 2 +- .../smlight/snapshots/test_sensor.ambr | 2 +- .../smlight/snapshots/test_update.ambr | 115 +++++++++ tests/components/smlight/test_update.py | 234 ++++++++++++++++++ 18 files changed, 772 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/smlight/update.py create mode 100644 tests/components/smlight/fixtures/esp_firmware.json create mode 100644 tests/components/smlight/fixtures/zb_firmware.json create mode 100644 tests/components/smlight/snapshots/test_update.ambr create mode 100644 tests/components/smlight/test_update.py diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 58d5b7d343f..52db6c8770b 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -2,29 +2,55 @@ from __future__ import annotations +from dataclasses import dataclass + +from pysmlight import Api2 + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmDataUpdateCoordinator, SmFirmwareUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] -type SmConfigEntry = ConfigEntry[SmDataUpdateCoordinator] + + +@dataclass(kw_only=True) +class SmlightData: + """Coordinator data class.""" + + data: SmDataUpdateCoordinator + firmware: SmFirmwareUpdateCoordinator + + +type SmConfigEntry = ConfigEntry[SmlightData] async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Set up SMLIGHT Zigbee from a config entry.""" - coordinator = SmDataUpdateCoordinator(hass, entry.data[CONF_HOST]) - await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + client = Api2(host=entry.data[CONF_HOST], session=async_get_clientsession(hass)) + entry.async_create_background_task(hass, client.sse.client(), "smlight-sse-client") + + data_coordinator = SmDataUpdateCoordinator(hass, entry.data[CONF_HOST], client) + firmware_coordinator = SmFirmwareUpdateCoordinator( + hass, entry.data[CONF_HOST], client + ) + + await data_coordinator.async_config_entry_first_refresh() + await firmware_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = SmlightData( + data=data_coordinator, firmware=firmware_coordinator + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index b5c695617eb..d273460e206 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -54,7 +54,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up SMLIGHT sensor based on a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.data async_add_entities( [ diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index b6a0c24c2ed..de19c57d1b1 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -60,7 +60,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up SMLIGHT buttons based on a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.data async_add_entities(SmButton(coordinator, button) for button in BUTTONS) @@ -68,6 +68,7 @@ async def async_setup_entry( class SmButton(SmEntity, ButtonEntity): """Defines a SLZB-06 button.""" + coordinator: SmDataUpdateCoordinator entity_description: SmButtonDescription _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/smlight/const.py b/homeassistant/components/smlight/const.py index a49ac009a50..669094b2441 100644 --- a/homeassistant/components/smlight/const.py +++ b/homeassistant/components/smlight/const.py @@ -6,7 +6,10 @@ import logging DOMAIN = "smlight" ATTR_MANUFACTURER = "SMLIGHT" +DATA_COORDINATOR = "data" +FIRMWARE_COORDINATOR = "firmware" +SCAN_FIRMWARE_INTERVAL = timedelta(hours=6) LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=300) SCAN_INTERNET_INTERVAL = timedelta(minutes=15) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index 396a89ef4b0..e5ef21bd531 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -1,22 +1,28 @@ """DataUpdateCoordinator for Smlight.""" +from __future__ import annotations + +from abc import abstractmethod from dataclasses import dataclass +from typing import TYPE_CHECKING from pysmlight import Api2, Info, Sensors from pysmlight.const import Settings, SettingsProp from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError +from pysmlight.web import Firmware -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN, LOGGER, SCAN_FIRMWARE_INTERVAL, SCAN_INTERVAL + +if TYPE_CHECKING: + from . import SmConfigEntry @dataclass @@ -27,12 +33,21 @@ class SmData: info: Info -class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): - """Class to manage fetching SMLIGHT data.""" +@dataclass +class SmFwData: + """SMLIGHT firmware data stored in the FirmwareUpdateCoordinator.""" - config_entry: ConfigEntry + info: Info + esp_firmware: list[Firmware] | None + zb_firmware: list[Firmware] | None - def __init__(self, hass: HomeAssistant, host: str) -> None: + +class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base Coordinator for SMLIGHT.""" + + config_entry: SmConfigEntry + + def __init__(self, hass: HomeAssistant, host: str, client: Api2) -> None: """Initialize the coordinator.""" super().__init__( hass, @@ -41,14 +56,10 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): update_interval=SCAN_INTERVAL, ) + self.client = client self.unique_id: str | None = None - self.client = Api2(host=host, session=async_get_clientsession(hass)) self.legacy_api: int = 0 - self.config_entry.async_create_background_task( - hass, self.client.sse.client(), "smlight-sse-client" - ) - async def _async_setup(self) -> None: """Authenticate if needed during initial setup.""" if await self.client.check_auth_needed(): @@ -83,26 +94,62 @@ class SmDataUpdateCoordinator(DataUpdateCoordinator[SmData]): translation_key="unsupported_firmware", ) - def update_setting(self, setting: Settings, value: bool | int) -> None: - """Update the sensor value from event.""" - prop = SettingsProp[setting.name].value - setattr(self.data.sensors, prop, value) - - self.async_set_updated_data(self.data) - - async def _async_update_data(self) -> SmData: - """Fetch data from the SMLIGHT device.""" + async def _async_update_data(self) -> _DataT: try: - sensors = Sensors() - if not self.legacy_api: - sensors = await self.client.get_sensors() - - return SmData( - sensors=sensors, - info=await self.client.get_info(), - ) + return await self._internal_update_data() except SmlightAuthError as err: raise ConfigEntryAuthFailed from err except SmlightConnectionError as err: raise UpdateFailed(err) from err + + @abstractmethod + async def _internal_update_data(self) -> _DataT: + """Update coordinator data.""" + + +class SmDataUpdateCoordinator(SmBaseDataUpdateCoordinator[SmData]): + """Class to manage fetching SMLIGHT sensor data.""" + + def update_setting(self, setting: Settings, value: bool | int) -> None: + """Update the sensor value from event.""" + + prop = SettingsProp[setting.name].value + setattr(self.data.sensors, prop, value) + + self.async_set_updated_data(self.data) + + async def _internal_update_data(self) -> SmData: + """Fetch sensor data from the SMLIGHT device.""" + sensors = Sensors() + if not self.legacy_api: + sensors = await self.client.get_sensors() + + return SmData( + sensors=sensors, + info=await self.client.get_info(), + ) + + +class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]): + """Class to manage fetching SMLIGHT firmware update data from cloud.""" + + def __init__(self, hass: HomeAssistant, host: str, client: Api2) -> None: + """Initialize the coordinator.""" + super().__init__(hass, host, client) + + self.update_interval = SCAN_FIRMWARE_INTERVAL + # only one update can run at a time (core or zibgee) + self.in_progress = False + + async def _internal_update_data(self) -> SmFwData: + """Fetch data from the SMLIGHT device.""" + info = await self.client.get_info() + + return SmFwData( + info=info, + esp_firmware=await self.client.get_firmware_version(info.fw_channel), + zb_firmware=await self.client.get_firmware_version( + info.fw_channel, device=info.model, mode="zigbee" + ), + ) diff --git a/homeassistant/components/smlight/entity.py b/homeassistant/components/smlight/entity.py index 50767d3bf74..7e6213cbdf1 100644 --- a/homeassistant/components/smlight/entity.py +++ b/homeassistant/components/smlight/entity.py @@ -10,15 +10,15 @@ from homeassistant.helpers.device_registry import ( from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_MANUFACTURER -from .coordinator import SmDataUpdateCoordinator +from .coordinator import SmBaseDataUpdateCoordinator -class SmEntity(CoordinatorEntity[SmDataUpdateCoordinator]): +class SmEntity(CoordinatorEntity[SmBaseDataUpdateCoordinator]): """Base class for all SMLight entities.""" _attr_has_entity_name = True - def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: + def __init__(self, coordinator: SmBaseDataUpdateCoordinator) -> None: """Initialize entity with device.""" super().__init__(coordinator) mac = format_mac(coordinator.data.info.MAC) diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py index 8da6e354fd7..1116b99f8c1 100644 --- a/homeassistant/components/smlight/sensor.py +++ b/homeassistant/components/smlight/sensor.py @@ -127,7 +127,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up SMLIGHT sensor based on a config entry.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.data async_add_entities( chain( @@ -141,6 +141,7 @@ async def async_setup_entry( class SmSensorEntity(SmEntity, SensorEntity): """Representation of a slzb sensor.""" + coordinator: SmDataUpdateCoordinator entity_description: SmSensorEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -164,6 +165,7 @@ class SmSensorEntity(SmEntity, SensorEntity): class SmInfoSensorEntity(SmEntity, SensorEntity): """Representation of a slzb info sensor.""" + coordinator: SmDataUpdateCoordinator entity_description: SmInfoEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 425815f68f0..812218287a9 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -117,6 +117,14 @@ "night_mode": { "name": "LED night mode" } + }, + "update": { + "core_update": { + "name": "Core firmware" + }, + "zigbee_update": { + "name": "Zigbee firmware" + } } }, "issues": { diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 38d94580d4d..930875335d1 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -63,7 +63,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Initialize switches for SLZB-06 device.""" - coordinator = entry.runtime_data + coordinator = entry.runtime_data.data async_add_entities(SmSwitch(coordinator, switch) for switch in SWITCHES) @@ -71,6 +71,7 @@ async def async_setup_entry( class SmSwitch(SmEntity, SwitchEntity): """Representation of a SLZB-06 switch.""" + coordinator: SmDataUpdateCoordinator entity_description: SmSwitchEntityDescription _attr_device_class = SwitchDeviceClass.SWITCH diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py new file mode 100644 index 00000000000..e00499760b1 --- /dev/null +++ b/homeassistant/components/smlight/update.py @@ -0,0 +1,189 @@ +"""Support updates for SLZB-06 ESP32 and Zigbee firmwares.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final + +from pysmlight.const import Events as SmEvents +from pysmlight.models import Firmware, Info +from pysmlight.sse import MessageEvent + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SmConfigEntry +from .coordinator import SmFirmwareUpdateCoordinator, SmFwData +from .entity import SmEntity + + +@dataclass(frozen=True, kw_only=True) +class SmUpdateEntityDescription(UpdateEntityDescription): + """Describes SMLIGHT SLZB-06 update entity.""" + + installed_version: Callable[[Info], str | None] + fw_list: Callable[[SmFwData], list[Firmware] | None] + + +UPDATE_ENTITIES: Final = [ + SmUpdateEntityDescription( + key="core_update", + translation_key="core_update", + installed_version=lambda x: x.sw_version, + fw_list=lambda x: x.esp_firmware, + ), + SmUpdateEntityDescription( + key="zigbee_update", + translation_key="zigbee_update", + installed_version=lambda x: x.zb_version, + fw_list=lambda x: x.zb_firmware, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: SmConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the SMLIGHT update entities.""" + coordinator = entry.runtime_data.firmware + + async_add_entities( + SmUpdateEntity(coordinator, description) for description in UPDATE_ENTITIES + ) + + +class SmUpdateEntity(SmEntity, UpdateEntity): + """Representation for SLZB-06 update entities.""" + + coordinator: SmFirmwareUpdateCoordinator + entity_description: SmUpdateEntityDescription + _attr_entity_category = EntityCategory.CONFIG + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) + + def __init__( + self, + coordinator: SmFirmwareUpdateCoordinator, + description: SmUpdateEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + + self._finished_event = asyncio.Event() + self._firmware: Firmware | None = None + self._unload: list[Callable] = [] + + @property + def installed_version(self) -> str | None: + """Version installed..""" + data = self.coordinator.data + + version = self.entity_description.installed_version(data.info) + return version if version != "-1" else None + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + data = self.coordinator.data + + fw = self.entity_description.fw_list(data) + + if fw and self.entity_description.key == "zigbee_update": + fw = [f for f in fw if f.type == data.info.zb_type] + + if fw: + self._firmware = fw[0] + return self._firmware.ver + + return None + + def register_callbacks(self) -> None: + """Register callbacks for SSE update events.""" + self._unload.append( + self.coordinator.client.sse.register_callback( + SmEvents.ZB_FW_prgs, self._update_progress + ) + ) + self._unload.append( + self.coordinator.client.sse.register_callback( + SmEvents.FW_UPD_done, self._update_finished + ) + ) + self._unload.append( + self.coordinator.client.sse.register_callback( + SmEvents.ZB_FW_err, self._update_failed + ) + ) + + def release_notes(self) -> str | None: + """Return release notes for firmware.""" + + if self._firmware and self._firmware.notes: + return self._firmware.notes + + return None + + @callback + def _update_progress(self, progress: MessageEvent) -> None: + """Update install progress on event.""" + + progress = int(progress.data) + if progress > 1: + self._attr_in_progress = progress + self.async_write_ha_state() + + def _update_done(self) -> None: + """Handle cleanup for update done.""" + self._finished_event.set() + self.coordinator.in_progress = False + + for remove_cb in self._unload: + remove_cb() + self._unload.clear() + + @callback + def _update_finished(self, event: MessageEvent) -> None: + """Handle event for update finished.""" + + self._update_done() + + @callback + def _update_failed(self, event: MessageEvent) -> None: + self._update_done() + + raise HomeAssistantError(f"Update failed for {self.name}") + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install firmware update.""" + + if not self.coordinator.in_progress and self._firmware: + self.coordinator.in_progress = True + self._attr_in_progress = True + self.register_callbacks() + + await self.coordinator.client.fw_update(self._firmware) + + # block until update finished event received + await self._finished_event.wait() + + await self.coordinator.async_refresh() + self._finished_event.clear() diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index cb7ac938774..665a55ba880 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch from pysmlight.sse import sseClient -from pysmlight.web import CmdWrapper, Info, Sensors +from pysmlight.web import CmdWrapper, Firmware, Info, Sensors import pytest from homeassistant.components.smlight import PLATFORMS @@ -12,7 +12,11 @@ from homeassistant.components.smlight.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) MOCK_HOST = "slzb-06.local" MOCK_USERNAME = "test-user" @@ -71,9 +75,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: """Mock the SMLIGHT API client.""" with ( - patch( - "homeassistant.components.smlight.coordinator.Api2", autospec=True - ) as smlight_mock, + patch("homeassistant.components.smlight.Api2", autospec=True) as smlight_mock, patch("homeassistant.components.smlight.config_flow.Api2", new=smlight_mock), ): api = smlight_mock.return_value @@ -85,6 +87,18 @@ def mock_smlight_client(request: pytest.FixtureRequest) -> Generator[MagicMock]: load_json_object_fixture("sensors.json", DOMAIN) ) + def get_firmware_side_effect(*args, **kwargs) -> list[Firmware]: + """Return the firmware version.""" + fw_list = [] + if kwargs.get("mode") == "zigbee": + fw_list = load_json_array_fixture("zb_firmware.json", DOMAIN) + else: + fw_list = load_json_array_fixture("esp_firmware.json", DOMAIN) + + return [Firmware.from_dict(fw) for fw in fw_list] + + api.get_firmware_version.side_effect = get_firmware_side_effect + api.check_auth_needed.return_value = False api.authenticate.return_value = True diff --git a/tests/components/smlight/fixtures/esp_firmware.json b/tests/components/smlight/fixtures/esp_firmware.json new file mode 100644 index 00000000000..6ea0e1a8b44 --- /dev/null +++ b/tests/components/smlight/fixtures/esp_firmware.json @@ -0,0 +1,35 @@ +[ + { + "mode": "ESP", + "type": null, + "notes": "CHANGELOG (Current 2.5.2 vs. Previous 2.3.6):\\r\\nFixed incorrect device type detection for some devices\\r\\nFixed web interface not working on some devices\\r\\nFixed disabled SSID/pass fields\\r\\n", + "rev": "20240830", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/core/slzb-06-v2.5.2-ota.bin", + "ver": "v2.5.2", + "dev": false, + "prod": true, + "baud": null + }, + { + "mode": "ESP", + "type": null, + "notes": "Read/write IEEE for CC chips\\r\\nDefault black theme\\r\\nAdd device mac to MDNS ZeroConf\\r\\nBreaking change! socket_uptime in /ha_sensors and /metrics now in seconds\\r\\nNew 5 languages\\r\\nAdd manual ZB OTA for 06M\\r\\nAdd warning modal for ZB manual OTA\\r\\nWireGuard can now use hostname instead of IP\\r\\nWiFi AP fixes and improvements\\r\\nImproved management of socket clients\\r\\nFix \"Disable web server when socket is connected\"\\r\\nFix events tag for log\\r\\nFix ZB maual OTA header text\\r\\nFix feedback page stack overflow\\r\\nFix sta drop in AP mode after scan start", + "rev": "20240815", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/core/slzb-06-v2.3.6-ota.bin", + "ver": "v2.3.6", + "dev": false, + "prod": true, + "baud": null + }, + { + "mode": "ESP", + "type": null, + "notes": "release of previous version", + "rev": "10112023", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/core/slzb-06-0.9.9-ota.bin", + "ver": "0.9.9", + "dev": false, + "prod": true, + "baud": null + } +] diff --git a/tests/components/smlight/fixtures/info.json b/tests/components/smlight/fixtures/info.json index 070232512f3..8f1e718ca74 100644 --- a/tests/components/smlight/fixtures/info.json +++ b/tests/components/smlight/fixtures/info.json @@ -13,6 +13,6 @@ "zb_flash_size": 704, "zb_hw": "CC2652P7", "zb_ram_size": 152, - "zb_version": -1, - "zb_type": -1 + "zb_version": "20240314", + "zb_type": 0 } diff --git a/tests/components/smlight/fixtures/zb_firmware.json b/tests/components/smlight/fixtures/zb_firmware.json new file mode 100644 index 00000000000..ca9d10f87ac --- /dev/null +++ b/tests/components/smlight/fixtures/zb_firmware.json @@ -0,0 +1,46 @@ +[ + { + "mode": "ZB", + "type": 0, + "notes": "SMLIGHT latest Coordinator release for CC2674P10 chips [16-Jul-2024]:
- +20dB TRANSMIT POWER SUPPORT;
- SDK 7.41 based (latest);
", + "rev": "20240716", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp-SLZB-06P10-20240716.bin", + "ver": "20240716", + "dev": false, + "prod": true, + "baud": 115200 + }, + { + "mode": "ZB", + "type": 1, + "notes": "SMLIGHT latest ROUTER release for CC2674P10 chips [16-Jul-2024]:
- SDK 7.41 based (latest);
Terms of use", + "rev": "20240716", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/zr-ZR_SLZB-06P10-20240716.bin", + "ver": "20240716", + "dev": false, + "prod": true, + "baud": 0 + }, + { + "mode": "ZB", + "type": 0, + "notes": "SMLIGHT Coordinator release for CC2674P10 chips [15-Mar-2024]:
- Engineering (dev) version, not recommended (INT);
- SDK 7.40 based (latest);
- Baudrate: 115200;
Terms of use", + "rev": "20240315", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp_LP_EM_CC2674P10_SM_tirtos7_ticlangNR.bin", + "ver": "20240315", + "dev": false, + "prod": false, + "baud": 115200 + }, + { + "mode": "ZB", + "type": 0, + "notes": "SMLIGHT Coordinator release for CC2674P10 chips [14-Mar-2024]:
- Factory flashed firmware (EXT);
- SDK 7.40 based (latest);
- Baudrate: 115200;
Terms of use", + "rev": "20240314", + "link": "https://smlight.tech/flasher/firmware/bin/slzb06x/zigbee/slzb06p10/znp_LP_EM_CC2674P10_SM_tirtos7_ticlangNP.bin", + "ver": "20240314", + "dev": false, + "prod": false, + "baud": 115200 + } +] diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index bb6a6c50f9b..598166e537b 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -27,7 +27,7 @@ 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': 'core: v2.3.6 / zigbee: -1', + 'sw_version': 'core: v2.3.6 / zigbee: 20240314', 'via_device_id': None, }) # --- diff --git a/tests/components/smlight/snapshots/test_sensor.ambr b/tests/components/smlight/snapshots/test_sensor.ambr index 7abc5ef4f64..262ecfe1544 100644 --- a/tests/components/smlight/snapshots/test_sensor.ambr +++ b/tests/components/smlight/snapshots/test_sensor.ambr @@ -419,7 +419,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'coordinator', }) # --- # name: test_sensors[sensor.mock_title_zigbee_uptime-entry] diff --git a/tests/components/smlight/snapshots/test_update.ambr b/tests/components/smlight/snapshots/test_update.ambr new file mode 100644 index 00000000000..755c9bc7312 --- /dev/null +++ b/tests/components/smlight/snapshots/test_update.ambr @@ -0,0 +1,115 @@ +# serializer version: 1 +# name: test_update_setup[update.mock_title_core_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.mock_title_core_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Core firmware', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'core_update', + 'unique_id': 'aa:bb:cc:dd:ee:ff-core_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_setup[update.mock_title_core_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/smlight/icon.png', + 'friendly_name': 'Mock Title Core firmware', + 'in_progress': False, + 'installed_version': 'v2.3.6', + 'latest_version': 'v2.5.2', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.mock_title_core_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_setup[update.mock_title_zigbee_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.mock_title_zigbee_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zigbee firmware', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'zigbee_update', + 'unique_id': 'aa:bb:cc:dd:ee:ff-zigbee_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_setup[update.mock_title_zigbee_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/smlight/icon.png', + 'friendly_name': 'Mock Title Zigbee firmware', + 'in_progress': False, + 'installed_version': '20240314', + 'latest_version': '20240716', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.mock_title_zigbee_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py new file mode 100644 index 00000000000..b8b8de8a09b --- /dev/null +++ b/tests/components/smlight/test_update.py @@ -0,0 +1,234 @@ +"""Tests for the SMLIGHT update platform.""" + +from collections.abc import Callable +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory +from pysmlight import Firmware, Info +from pysmlight.const import Events as SmEvents +from pysmlight.sse import MessageEvent +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smlight.const import SCAN_FIRMWARE_INTERVAL +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DOMAIN as PLATFORM, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.typing import WebSocketGenerator + +pytestmark = [ + pytest.mark.usefixtures( + "mock_smlight_client", + ) +] + +MOCK_FIRMWARE_DONE = MessageEvent( + type="FW_UPD_done", + message="FW_UPD_done", + data="", + origin="http://slzb-06p10.local", + last_event_id="", +) + +MOCK_FIRMWARE_PROGRESS = MessageEvent( + type="ZB_FW_prgs", + message="ZB_FW_prgs", + data="50", + origin="http://slzb-06p10.local", + last_event_id="", +) + +MOCK_FIRMWARE_FAIL = MessageEvent( + type="ZB_FW_err", + message="ZB_FW_err", + data="", + origin="http://slzb-06p10.local", + last_event_id="", +) + +MOCK_FIRMWARE_NOTES = [ + Firmware( + ver="v2.3.6", + mode="ESP", + notes=None, + ) +] + + +def get_callback_function(mock: MagicMock, trigger: SmEvents): + """Extract the callback function for a given trigger.""" + return next( + ( + call_args[0][1] + for call_args in mock.sse.register_callback.call_args_list + if trigger == call_args[0][0] + ), + None, + ) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms, which should be loaded during the test.""" + return [Platform.UPDATE] + + +async def test_update_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test setup of SMLIGHT switches.""" + entry = await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_update_firmware( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test firmware updates.""" + await setup_integration(hass, mock_config_entry) + entity_id = "update.mock_title_core_firmware" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=False, + ) + + assert len(mock_smlight_client.fw_update.mock_calls) == 1 + + event_function: Callable[[MessageEvent], None] = get_callback_function( + mock_smlight_client, SmEvents.ZB_FW_prgs + ) + + async def _call_event_function(event: MessageEvent): + event_function(event) + + await _call_event_function(MOCK_FIRMWARE_PROGRESS) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_IN_PROGRESS] == 50 + + event_function: Callable[[MessageEvent], None] = get_callback_function( + mock_smlight_client, SmEvents.FW_UPD_done + ) + + await _call_event_function(MOCK_FIRMWARE_DONE) + + mock_smlight_client.get_info.return_value = Info( + sw_version="v2.5.2", + ) + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + + +async def test_update_firmware_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test firmware updates.""" + await setup_integration(hass, mock_config_entry) + entity_id = "update.mock_title_core_firmware" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.3.6" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=False, + ) + + assert len(mock_smlight_client.fw_update.mock_calls) == 1 + + event_function: Callable[[MessageEvent], None] = get_callback_function( + mock_smlight_client, SmEvents.ZB_FW_err + ) + + async def _call_event_function(event: MessageEvent): + event_function(event) + + with pytest.raises(HomeAssistantError): + await _call_event_function(MOCK_FIRMWARE_FAIL) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_IN_PROGRESS] is False + + +async def test_update_release_notes( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test firmware release notes.""" + await setup_integration(hass, mock_config_entry) + ws_client = await hass_ws_client(hass) + await hass.async_block_till_done() + entity_id = "update.mock_title_core_firmware" + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": entity_id, + } + ) + result = await ws_client.receive_json() + assert result["result"] is not None + + mock_smlight_client.get_firmware_version.side_effect = None + mock_smlight_client.get_firmware_version.return_value = MOCK_FIRMWARE_NOTES + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await ws_client.send_json( + { + "id": 2, + "type": "update/release_notes", + "entity_id": entity_id, + } + ) + result = await ws_client.receive_json() + await hass.async_block_till_done() + assert result["result"] is None From e08a94fe1c938d0845733194d910f8d2cde42426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 16 Sep 2024 14:16:03 +0200 Subject: [PATCH 0705/1309] Add Matter tests for BatVoltage attribute from PowerSource cluster (#125645) * Add BatVoltage Attribute from PowerSource Cluster * Update sensor.py Remove comment * Update homeassistant/components/matter/sensor.py Co-authored-by: Martin Hjelmare * Fixture for a Eve Door & Window node Fixture for a Eve Door & Window node to check BatVoltage attribute from PowerSource cluster * Test battery voltage sensor * Update test_sensor.py * ruff-format * Update test_sensor.py * Update test_sensor.py battery_voltage attribute test * Update test_sensor.py * Update test_sensor.py * Update tests/components/matter/test_sensor.py Co-authored-by: Martin Hjelmare * Update test_sensor.py * Adjust values --------- Co-authored-by: Martin Hjelmare --- tests/components/matter/test_sensor.py | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 17cff38787c..20ecef8609b 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -251,6 +251,33 @@ async def test_battery_sensor( assert entry.entity_category == EntityCategory.DIAGNOSTIC +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_battery_sensor_voltage( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + eve_contact_sensor_node: MatterNode, +) -> None: + """Test battery voltage sensor.""" + entity_id = "sensor.eve_door_voltage" + state = hass.states.get(entity_id) + assert state + assert state.state == "3.558" + + set_node_attribute(eve_contact_sensor_node, 1, 47, 11, 4234) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == "4.234" + + entry = entity_registry.async_get(entity_id) + + assert entry + assert entry.entity_category == EntityCategory.DIAGNOSTIC + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_energy_sensors_custom_cluster( From 8370a552633be1c46ac8d30d65a0594d7cbec41b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 14:24:48 +0200 Subject: [PATCH 0706/1309] Move devolo home control base entity to separate module (#126042) --- homeassistant/components/devolo_home_control/binary_sensor.py | 2 +- .../components/devolo_home_control/devolo_multi_level_switch.py | 2 +- .../devolo_home_control/{devolo_device.py => entity.py} | 0 homeassistant/components/devolo_home_control/sensor.py | 2 +- homeassistant/components/devolo_home_control/switch.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename homeassistant/components/devolo_home_control/{devolo_device.py => entity.py} (100%) diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py index 349780304c6..449b1c7659f 100644 --- a/homeassistant/components/devolo_home_control/binary_sensor.py +++ b/homeassistant/components/devolo_home_control/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_device import DevoloDeviceEntity +from .entity import DevoloDeviceEntity DEVICE_CLASS_MAPPING = { "Water alarm": BinarySensorDeviceClass.MOISTURE, diff --git a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py index 3072cb01f2e..3e2d551d1f8 100644 --- a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py +++ b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py @@ -3,7 +3,7 @@ from devolo_home_control_api.devices.zwave import Zwave from devolo_home_control_api.homecontrol import HomeControl -from .devolo_device import DevoloDeviceEntity +from .entity import DevoloDeviceEntity class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): diff --git a/homeassistant/components/devolo_home_control/devolo_device.py b/homeassistant/components/devolo_home_control/entity.py similarity index 100% rename from homeassistant/components/devolo_home_control/devolo_device.py rename to homeassistant/components/devolo_home_control/entity.py diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index 134e45a137e..61a63419732 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_device import DevoloDeviceEntity +from .entity import DevoloDeviceEntity DEVICE_CLASS_MAPPING = { "battery": SensorDeviceClass.BATTERY, diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index dd3248be315..a6f16229046 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_device import DevoloDeviceEntity +from .entity import DevoloDeviceEntity async def async_setup_entry( From e85ab067bd62cc3fc4a81eca7c05c36c2ce98e8f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 14:26:20 +0200 Subject: [PATCH 0707/1309] Move and rename crownstone base entity to separate module (#126034) --- .../components/crownstone/{devices.py => entity.py} | 2 +- homeassistant/components/crownstone/light.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename homeassistant/components/crownstone/{devices.py => entity.py} (96%) diff --git a/homeassistant/components/crownstone/devices.py b/homeassistant/components/crownstone/entity.py similarity index 96% rename from homeassistant/components/crownstone/devices.py rename to homeassistant/components/crownstone/entity.py index 4995702701d..cb06a5fb00d 100644 --- a/homeassistant/components/crownstone/devices.py +++ b/homeassistant/components/crownstone/entity.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity import Entity from .const import CROWNSTONE_INCLUDE_TYPES, DOMAIN -class CrownstoneBaseEntity(Entity): +class CrownstoneEntity(Entity): """Base entity class for Crownstone devices.""" _attr_should_poll = False diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py index 37904408606..16faa3a36d2 100644 --- a/homeassistant/components/crownstone/light.py +++ b/homeassistant/components/crownstone/light.py @@ -24,7 +24,7 @@ from .const import ( SIG_CROWNSTONE_STATE_UPDATE, SIG_UART_STATE_CHANGE, ) -from .devices import CrownstoneBaseEntity +from .entity import CrownstoneEntity from .helpers import map_from_to if TYPE_CHECKING: @@ -39,7 +39,7 @@ async def async_setup_entry( """Set up crownstones from a config entry.""" manager: CrownstoneEntryManager = hass.data[DOMAIN][config_entry.entry_id] - entities: list[CrownstoneEntity] = [] + entities: list[CrownstoneLightEntity] = [] # Add Crownstone entities that support switching/dimming for sphere in manager.cloud.cloud_data: @@ -47,10 +47,10 @@ async def async_setup_entry( if crownstone.type in CROWNSTONE_INCLUDE_TYPES: # Crownstone can communicate with Crownstone USB if manager.uart and sphere.cloud_id == manager.usb_sphere_id: - entities.append(CrownstoneEntity(crownstone, manager.uart)) + entities.append(CrownstoneLightEntity(crownstone, manager.uart)) # Crownstone can't communicate with Crownstone USB else: - entities.append(CrownstoneEntity(crownstone)) + entities.append(CrownstoneLightEntity(crownstone)) async_add_entities(entities) @@ -65,7 +65,7 @@ def hass_to_crownstone_state(value: int) -> int: return map_from_to(value, 0, 255, 0, 100) -class CrownstoneEntity(CrownstoneBaseEntity, LightEntity): +class CrownstoneLightEntity(CrownstoneEntity, LightEntity): """Representation of a crownstone. Light platform is used to support dimming. From 3ba39d515883c1e3120c7d5ab7eb4a5f1f0a56ad Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Mon, 16 Sep 2024 14:43:37 +0200 Subject: [PATCH 0708/1309] Add translation to communication exceptions in MotionMount (#126043) Add translation to communication exceptions --- homeassistant/components/motionmount/number.py | 10 ++++++++-- homeassistant/components/motionmount/select.py | 5 ++++- homeassistant/components/motionmount/strings.json | 5 +++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py index 25370ec51d8..b42c04a6588 100644 --- a/homeassistant/components/motionmount/number.py +++ b/homeassistant/components/motionmount/number.py @@ -52,7 +52,10 @@ class MotionMountExtension(MotionMountEntity, NumberEntity): try: await self.mm.set_extension(int(value)) except (TimeoutError, socket.gaierror) as ex: - raise HomeAssistantError("Failed to communicate with MotionMount") from ex + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_communication", + ) from ex class MotionMountTurn(MotionMountEntity, NumberEntity): @@ -78,4 +81,7 @@ class MotionMountTurn(MotionMountEntity, NumberEntity): try: await self.mm.set_turn(int(value * -1)) except (TimeoutError, socket.gaierror) as ex: - raise HomeAssistantError("Failed to communicate with MotionMount") from ex + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_communication", + ) from ex diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py index 9bca6578bcc..9b43d901a21 100644 --- a/homeassistant/components/motionmount/select.py +++ b/homeassistant/components/motionmount/select.py @@ -91,6 +91,9 @@ class MotionMountPresets(MotionMountEntity, SelectEntity): try: await self.mm.go_to_preset(index) except (TimeoutError, socket.gaierror) as ex: - raise HomeAssistantError("Failed to communicate with MotionMount") from ex + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="failed_communication", + ) from ex else: self._attr_current_option = option diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json index 39f7c53db35..bd28156607c 100644 --- a/homeassistant/components/motionmount/strings.json +++ b/homeassistant/components/motionmount/strings.json @@ -56,5 +56,10 @@ } } } + }, + "exceptions": { + "failed_communication": { + "message": "Failed to communicate with MotionMount" + } } } From c63cab336c17ae8179026601c196603568d32be2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 16 Sep 2024 07:50:43 -0500 Subject: [PATCH 0709/1309] Change wake word interception to a subscription (#125629) * Allow stopping intercepting wake words * Make wake word interception a subscription * Keep future * Add test for unsub --- .../assist_satellite/websocket_api.py | 19 ++- .../assist_satellite/test_websocket_api.py | 141 ++++++++++++++---- 2 files changed, 129 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 10687f4210e..8de10c8a9de 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent @@ -42,5 +43,19 @@ async def websocket_intercept_wake_word( ) return - wake_word_phrase = await satellite.async_intercept_wake_word() - connection.send_result(msg["id"], {"wake_word_phrase": wake_word_phrase}) + async def intercept_wake_word() -> None: + """Push an intercepted wake word to websocket.""" + try: + wake_word_phrase = await satellite.async_intercept_wake_word() + connection.send_message( + websocket_api.event_message( + msg["id"], + {"wake_word_phrase": wake_word_phrase}, + ) + ) + except HomeAssistantError as err: + connection.send_error(msg["id"], "home_assistant_error", str(err)) + + task = hass.async_create_task(intercept_wake_word(), "intercept_wake_word") + connection.subscriptions[msg["id"]] = task.cancel + connection.send_message(websocket_api.result_message(msg["id"])) diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py index af49334e629..7895ea2555a 100644 --- a/tests/components/assist_satellite/test_websocket_api.py +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -1,6 +1,9 @@ """Test WebSocket API.""" import asyncio +from unittest.mock import patch + +import pytest from homeassistant.components.assist_pipeline import PipelineStage from homeassistant.config_entries import ConfigEntry @@ -28,20 +31,23 @@ async def test_intercept_wake_word( "entity_id": ENTITY_ID, } ) - - for _ in range(3): - await asyncio.sleep(0) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] await entity.async_accept_pipeline_from_satellite( - object(), + object(), # type: ignore[arg-type] start_stage=PipelineStage.STT, wake_word_phrase="ok, nabu", ) - response = await ws_client.receive_json() + async with asyncio.timeout(1): + msg = await ws_client.receive_json() - assert response["success"] - assert response["result"] == {"wake_word_phrase": "ok, nabu"} + assert msg["id"] == subscription_id + assert msg["type"] == "event" + assert msg["event"] == {"wake_word_phrase": "ok, nabu"} async def test_intercept_wake_word_requires_on_device_wake_word( @@ -60,18 +66,23 @@ async def test_intercept_wake_word_requires_on_device_wake_word( } ) - for _ in range(3): - await asyncio.sleep(0) + async with asyncio.timeout(1): + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["result"] is None await entity.async_accept_pipeline_from_satellite( - object(), + object(), # type: ignore[arg-type] # Emulate wake word processing in Home Assistant start_stage=PipelineStage.WAKE_WORD, ) - response = await ws_client.receive_json() - assert not response["success"] - assert response["error"] == { + async with asyncio.timeout(1): + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"] == { "code": "home_assistant_error", "message": "Only on-device wake words currently supported", } @@ -93,18 +104,23 @@ async def test_intercept_wake_word_requires_wake_word_phrase( } ) - for _ in range(3): - await asyncio.sleep(0) + async with asyncio.timeout(1): + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["result"] is None await entity.async_accept_pipeline_from_satellite( - object(), + object(), # type: ignore[arg-type] start_stage=PipelineStage.STT, # We are not passing wake word phrase ) - response = await ws_client.receive_json() - assert not response["success"] - assert response["error"] == { + async with asyncio.timeout(1): + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"] == { "code": "home_assistant_error", "message": "No wake word phrase provided", } @@ -128,10 +144,12 @@ async def test_intercept_wake_word_require_admin( "entity_id": ENTITY_ID, } ) - response = await ws_client.receive_json() - assert not response["success"] - assert response["error"] == { + async with asyncio.timeout(1): + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"] == { "code": "unauthorized", "message": "Unauthorized", } @@ -152,10 +170,11 @@ async def test_intercept_wake_word_invalid_satellite( "entity_id": "assist_satellite.invalid", } ) - response = await ws_client.receive_json() + async with asyncio.timeout(1): + msg = await ws_client.receive_json() - assert not response["success"] - assert response["error"] == { + assert not msg["success"] + assert msg["error"] == { "code": "not_found", "message": "Entity not found", } @@ -167,7 +186,7 @@ async def test_intercept_wake_word_twice( entity: MockAssistSatellite, hass_ws_client: WebSocketGenerator, ) -> None: - """Test intercepting a wake word requires admin access.""" + """Test intercepting a wake word twice cancels the previous request.""" ws_client = await hass_ws_client(hass) await ws_client.send_json_auto_id( @@ -177,16 +196,80 @@ async def test_intercept_wake_word_twice( } ) + async with asyncio.timeout(1): + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["result"] is None + + task = hass.async_create_task(ws_client.receive_json()) + await ws_client.send_json_auto_id( { "type": "assist_satellite/intercept_wake_word", "entity_id": ENTITY_ID, } ) - response = await ws_client.receive_json() - assert not response["success"] - assert response["error"] == { + # Should get an error from previous subscription + async with asyncio.timeout(1): + msg = await task + + assert not msg["success"] + assert msg["error"] == { "code": "home_assistant_error", "message": "Wake word interception already in progress", } + + # Response to second subscription + async with asyncio.timeout(1): + msg = await ws_client.receive_json() + + assert msg["success"] + assert msg["result"] is None + + +async def test_intercept_wake_word_unsubscribe( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that closing the websocket connection stops interception.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/intercept_wake_word", + "entity_id": ENTITY_ID, + } + ) + + # Wait for interception to start + for _ in range(3): + await asyncio.sleep(0) + + async def receive_json(): + with pytest.raises(TypeError): + # Raises TypeError when connection is closed + await ws_client.receive_json() + + task = hass.async_create_task(receive_json()) + + # Close connection + await ws_client.close() + await task + + with ( + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + ) as mock_pipeline_from_audio_stream, + ): + # Start a pipeline with a wake word + await entity.async_accept_pipeline_from_satellite( + object(), + wake_word_phrase="ok, nabu", # type: ignore[arg-type] + ) + + # Wake word should not be intercepted + mock_pipeline_from_audio_stream.assert_called_once() From 02f6d4bd112ac7fbff2e47946c1799b87fb7403b Mon Sep 17 00:00:00 2001 From: Iskra kranj <162285659+iskrakranj@users.noreply.github.com> Date: Mon, 16 Sep 2024 14:51:53 +0200 Subject: [PATCH 0710/1309] Bump pyiskra to 0.1.11 (#126048) bumped pyiskra to 0.1.11 --- homeassistant/components/iskra/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json index 7bda12ab615..ff7ff700e30 100644 --- a/homeassistant/components/iskra/manifest.json +++ b/homeassistant/components/iskra/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyiskra"], - "requirements": ["pyiskra==0.1.8"] + "requirements": ["pyiskra==0.1.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 81e0a5c5497..1aaccce6e06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1963,7 +1963,7 @@ pyiqvia==2022.04.0 pyirishrail==0.0.2 # homeassistant.components.iskra -pyiskra==0.1.8 +pyiskra==0.1.11 # homeassistant.components.iss pyiss==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1a9caa0ffc..15f26159299 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1574,7 +1574,7 @@ pyipp==0.16.0 pyiqvia==2022.04.0 # homeassistant.components.iskra -pyiskra==0.1.8 +pyiskra==0.1.11 # homeassistant.components.iss pyiss==1.0.1 From a17dc3cb5272c82a249b5add8e035f778a74b5c9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Sep 2024 15:11:02 +0200 Subject: [PATCH 0711/1309] Introduce Reolink base entity description (#126050) --- homeassistant/components/reolink/entity.py | 26 +++++++++------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 234aa79f303..d73c3a9b6e6 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -19,26 +19,30 @@ from .const import DOMAIN @dataclass(frozen=True, kw_only=True) -class ReolinkChannelEntityDescription(EntityDescription): - """A class that describes entities for a camera channel.""" +class ReolinkEntityDescription(EntityDescription): + """A class that describes entities for Reolink.""" cmd_key: str | None = None + + +@dataclass(frozen=True, kw_only=True) +class ReolinkChannelEntityDescription(ReolinkEntityDescription): + """A class that describes entities for a camera channel.""" + supported: Callable[[Host, int], bool] = lambda api, ch: True @dataclass(frozen=True, kw_only=True) -class ReolinkHostEntityDescription(EntityDescription): +class ReolinkHostEntityDescription(ReolinkEntityDescription): """A class that describes host entities.""" - cmd_key: str | None = None supported: Callable[[Host], bool] = lambda api: True @dataclass(frozen=True, kw_only=True) -class ReolinkChimeEntityDescription(EntityDescription): +class ReolinkChimeEntityDescription(ReolinkEntityDescription): """A class that describes entities for a chime.""" - cmd_key: str | None = None supported: Callable[[Chime], bool] = lambda chime: True @@ -50,11 +54,7 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] """ _attr_has_entity_name = True - entity_description: ( - ReolinkHostEntityDescription - | ReolinkChannelEntityDescription - | ReolinkChimeEntityDescription - ) + entity_description: ReolinkEntityDescription def __init__( self, @@ -114,8 +114,6 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" - entity_description: ReolinkChannelEntityDescription | ReolinkChimeEntityDescription - def __init__( self, reolink_data: ReolinkData, @@ -176,8 +174,6 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity): """Parent class for Reolink chime entities connected.""" - entity_description: ReolinkChimeEntityDescription - def __init__( self, reolink_data: ReolinkData, From e3e93df187346c009404b7748480a12b99b541a9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:19:09 +0200 Subject: [PATCH 0712/1309] Move elkm1 base entity to separate module (#126052) --- homeassistant/components/elkm1/__init__.py | 128 ---------------- .../components/elkm1/alarm_control_panel.py | 3 +- .../components/elkm1/binary_sensor.py | 3 +- homeassistant/components/elkm1/climate.py | 4 +- homeassistant/components/elkm1/entity.py | 144 ++++++++++++++++++ homeassistant/components/elkm1/light.py | 3 +- homeassistant/components/elkm1/scene.py | 3 +- homeassistant/components/elkm1/sensor.py | 3 +- homeassistant/components/elkm1/switch.py | 3 +- 9 files changed, 159 insertions(+), 135 deletions(-) create mode 100644 homeassistant/components/elkm1/entity.py diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index b66a4ce2ed8..34a35fbeb09 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -3,8 +3,6 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable -from enum import Enum import logging import re from types import MappingProxyType @@ -17,7 +15,6 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - ATTR_CONNECTIONS, CONF_ENABLED, CONF_EXCLUDE, CONF_HOST, @@ -33,8 +30,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -430,126 +425,3 @@ def _create_elk_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, "set_time", _set_time_service, SET_TIME_SERVICE_SCHEMA ) - - -def create_elk_entities( - elk_data: ELKM1Data, - elk_elements: Iterable[Element], - element_type: str, - class_: Any, - entities: list[ElkEntity], -) -> list[ElkEntity] | None: - """Create the ElkM1 devices of a particular class.""" - auto_configure = elk_data.auto_configure - - if not auto_configure and not elk_data.config[element_type]["enabled"]: - return None - - elk = elk_data.elk - _LOGGER.debug("Creating elk entities for %s", elk) - - for element in elk_elements: - if auto_configure: - if not element.configured: - continue - # Only check the included list if auto configure is not - elif not elk_data.config[element_type]["included"][element.index]: - continue - - entities.append(class_(element, elk, elk_data)) - return entities - - -class ElkEntity(Entity): - """Base class for all Elk entities.""" - - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: - """Initialize the base of all Elk devices.""" - self._elk = elk - self._element = element - self._mac = elk_data.mac - self._prefix = elk_data.prefix - self._temperature_unit: str = elk_data.config["temperature_unit"] - # unique_id starts with elkm1_ iff there is no prefix - # it starts with elkm1m_{prefix} iff there is a prefix - # this is to avoid a conflict between - # prefix=foo, name=bar (which would be elkm1_foo_bar) - # - and - - # prefix="", name="foo bar" (which would be elkm1_foo_bar also) - # we could have used elkm1__foo_bar for the latter, but that - # would have been a breaking change - if self._prefix != "": - uid_start = f"elkm1m_{self._prefix}" - else: - uid_start = "elkm1" - self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower() - self._attr_name = element.name - - @property - def unique_id(self) -> str: - """Return unique id of the element.""" - return self._unique_id - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the default attributes of the element.""" - dict_as_str = {} - for key, val in self._element.as_dict().items(): - dict_as_str[key] = val.value if isinstance(val, Enum) else val - return {**dict_as_str, **self.initial_attrs()} - - @property - def available(self) -> bool: - """Is the entity available to be updated.""" - return self._elk.is_connected() - - def initial_attrs(self) -> dict[str, Any]: - """Return the underlying element's attributes as a dict.""" - return {"index": self._element.index + 1} - - def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: - pass - - @callback - def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None: - """Handle callback from an Elk element that has changed.""" - self._element_changed(element, changeset) - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register callback for ElkM1 changes and update entity state.""" - self._element.add_callback(self._element_callback) - self._element_callback(self._element, {}) - - @property - def device_info(self) -> DeviceInfo: - """Device info connecting via the ElkM1 system.""" - return DeviceInfo( - name=self._element.name, - identifiers={(DOMAIN, self._unique_id)}, - via_device=(DOMAIN, f"{self._prefix}_system"), - ) - - -class ElkAttachedEntity(ElkEntity): - """An elk entity that is attached to the elk system.""" - - @property - def device_info(self) -> DeviceInfo: - """Device info for the underlying ElkM1 system.""" - device_name = "ElkM1" - if self._prefix: - device_name += f" {self._prefix}" - device_info = DeviceInfo( - identifiers={(DOMAIN, f"{self._prefix}_system")}, - manufacturer="ELK Products, Inc.", - model="M1", - name=device_name, - sw_version=self._elk.panel.elkm1_version, - ) - if self._mac: - device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, self._mac)} - return device_info diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index b24d0f869c6..f5437b6ed94 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -33,13 +33,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import VolDictType -from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry, create_elk_entities +from . import ElkM1ConfigEntry from .const import ( ATTR_CHANGED_BY_ID, ATTR_CHANGED_BY_KEYPAD, ATTR_CHANGED_BY_TIME, ELK_USER_CODE_SERVICE_SCHEMA, ) +from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities from .models import ELKM1Data DISPLAY_MESSAGE_SERVICE_SCHEMA: VolDictType = { diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py index 171e9968ce6..854f8c56fb8 100644 --- a/homeassistant/components/elkm1/binary_sensor.py +++ b/homeassistant/components/elkm1/binary_sensor.py @@ -12,7 +12,8 @@ from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry +from . import ElkM1ConfigEntry +from .entity import ElkAttachedEntity, ElkEntity async def async_setup_entry( diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 177f17d6e7e..bf5650f237b 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -22,7 +22,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from . import DOMAIN, ElkEntity, ElkM1ConfigEntry, create_elk_entities +from . import ElkM1ConfigEntry +from .const import DOMAIN +from .entity import ElkEntity, create_elk_entities SUPPORT_HVAC = [ HVACMode.OFF, diff --git a/homeassistant/components/elkm1/entity.py b/homeassistant/components/elkm1/entity.py new file mode 100644 index 00000000000..d9967d93967 --- /dev/null +++ b/homeassistant/components/elkm1/entity.py @@ -0,0 +1,144 @@ +"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels.""" + +from __future__ import annotations + +from collections.abc import Iterable +from enum import Enum +import logging +from typing import Any + +from elkm1_lib.elements import Element +from elkm1_lib.elk import Elk + +from homeassistant.const import ATTR_CONNECTIONS +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .models import ELKM1Data + +_LOGGER = logging.getLogger(__name__) + + +def create_elk_entities( + elk_data: ELKM1Data, + elk_elements: Iterable[Element], + element_type: str, + class_: Any, + entities: list[ElkEntity], +) -> list[ElkEntity] | None: + """Create the ElkM1 devices of a particular class.""" + auto_configure = elk_data.auto_configure + + if not auto_configure and not elk_data.config[element_type]["enabled"]: + return None + + elk = elk_data.elk + _LOGGER.debug("Creating elk entities for %s", elk) + + for element in elk_elements: + if auto_configure: + if not element.configured: + continue + # Only check the included list if auto configure is not + elif not elk_data.config[element_type]["included"][element.index]: + continue + + entities.append(class_(element, elk, elk_data)) + return entities + + +class ElkEntity(Entity): + """Base class for all Elk entities.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: + """Initialize the base of all Elk devices.""" + self._elk = elk + self._element = element + self._mac = elk_data.mac + self._prefix = elk_data.prefix + self._temperature_unit: str = elk_data.config["temperature_unit"] + # unique_id starts with elkm1_ iff there is no prefix + # it starts with elkm1m_{prefix} iff there is a prefix + # this is to avoid a conflict between + # prefix=foo, name=bar (which would be elkm1_foo_bar) + # - and - + # prefix="", name="foo bar" (which would be elkm1_foo_bar also) + # we could have used elkm1__foo_bar for the latter, but that + # would have been a breaking change + if self._prefix != "": + uid_start = f"elkm1m_{self._prefix}" + else: + uid_start = "elkm1" + self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower() + self._attr_name = element.name + + @property + def unique_id(self) -> str: + """Return unique id of the element.""" + return self._unique_id + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the default attributes of the element.""" + dict_as_str = {} + for key, val in self._element.as_dict().items(): + dict_as_str[key] = val.value if isinstance(val, Enum) else val + return {**dict_as_str, **self.initial_attrs()} + + @property + def available(self) -> bool: + """Is the entity available to be updated.""" + return self._elk.is_connected() + + def initial_attrs(self) -> dict[str, Any]: + """Return the underlying element's attributes as a dict.""" + return {"index": self._element.index + 1} + + def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: + pass + + @callback + def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None: + """Handle callback from an Elk element that has changed.""" + self._element_changed(element, changeset) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callback for ElkM1 changes and update entity state.""" + self._element.add_callback(self._element_callback) + self._element_callback(self._element, {}) + + @property + def device_info(self) -> DeviceInfo: + """Device info connecting via the ElkM1 system.""" + return DeviceInfo( + name=self._element.name, + identifiers={(DOMAIN, self._unique_id)}, + via_device=(DOMAIN, f"{self._prefix}_system"), + ) + + +class ElkAttachedEntity(ElkEntity): + """An elk entity that is attached to the elk system.""" + + @property + def device_info(self) -> DeviceInfo: + """Device info for the underlying ElkM1 system.""" + device_name = "ElkM1" + if self._prefix: + device_name += f" {self._prefix}" + device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self._prefix}_system")}, + manufacturer="ELK Products, Inc.", + model="M1", + name=device_name, + sw_version=self._elk.panel.elkm1_version, + ) + if self._mac: + device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, self._mac)} + return device_info diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py index 17d525f6ddc..c041c9c9d65 100644 --- a/homeassistant/components/elkm1/light.py +++ b/homeassistant/components/elkm1/light.py @@ -12,7 +12,8 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElkEntity, ElkM1ConfigEntry, create_elk_entities +from . import ElkM1ConfigEntry +from .entity import ElkEntity, create_elk_entities from .models import ELKM1Data diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py index e4b738c9dbd..d8a1d83f326 100644 --- a/homeassistant/components/elkm1/scene.py +++ b/homeassistant/components/elkm1/scene.py @@ -10,7 +10,8 @@ from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry, create_elk_entities +from . import ElkM1ConfigEntry +from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities async def async_setup_entry( diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 16f877719a7..e0231c86699 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -22,8 +22,9 @@ from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType -from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry, create_elk_entities +from . import ElkM1ConfigEntry from .const import ATTR_VALUE, ELK_USER_CODE_SERVICE_SCHEMA +from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh" SERVICE_SENSOR_COUNTER_SET = "sensor_counter_set" diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py index 70b38802a42..3e0f4849518 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -14,7 +14,8 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ElkAttachedEntity, ElkEntity, ElkM1ConfigEntry, create_elk_entities +from . import ElkM1ConfigEntry +from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities from .models import ELKM1Data From 5a769fb51b16f3f782fa66dd82dd75a61b1fb527 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:19:25 +0200 Subject: [PATCH 0713/1309] Move enocean base entity to separate module (#126053) --- homeassistant/components/enocean/binary_sensor.py | 2 +- homeassistant/components/enocean/{device.py => entity.py} | 0 homeassistant/components/enocean/light.py | 2 +- homeassistant/components/enocean/sensor.py | 2 +- homeassistant/components/enocean/switch.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename homeassistant/components/enocean/{device.py => entity.py} (100%) diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index 3ecf1ba4ba2..01e39f96510 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .device import EnOceanEntity +from .entity import EnOceanEntity DEFAULT_NAME = "EnOcean binary sensor" DEPENDENCIES = ["enocean"] diff --git a/homeassistant/components/enocean/device.py b/homeassistant/components/enocean/entity.py similarity index 100% rename from homeassistant/components/enocean/device.py rename to homeassistant/components/enocean/entity.py diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index 1e81e3cd089..aae84e73848 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .device import EnOceanEntity +from .entity import EnOceanEntity CONF_SENDER_ID = "sender_id" diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 177c95c2832..98e32ce1a4f 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -30,7 +30,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .device import EnOceanEntity +from .entity import EnOceanEntity CONF_MAX_TEMP = "max_temp" CONF_MIN_TEMP = "min_temp" diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py index 9bf8b8e775c..0259a60982f 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN, LOGGER -from .device import EnOceanEntity +from .entity import EnOceanEntity CONF_CHANNEL = "channel" DEFAULT_NAME = "EnOcean Switch" From 21b92455afc05226d081650bd7427d2e7b1efbb1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:20:53 +0200 Subject: [PATCH 0714/1309] Move and rename envisalink base entity to separate module (#126054) --- .../components/envisalink/__init__.py | 18 ----------------- .../envisalink/alarm_control_panel.py | 4 ++-- .../components/envisalink/binary_sensor.py | 12 +++-------- homeassistant/components/envisalink/entity.py | 20 +++++++++++++++++++ homeassistant/components/envisalink/sensor.py | 4 ++-- homeassistant/components/envisalink/switch.py | 11 +++------- 6 files changed, 30 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/envisalink/entity.py diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 8222c044503..0146b650c22 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -244,20 +243,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) return True - - -class EnvisalinkDevice(Entity): - """Representation of an Envisalink device.""" - - _attr_should_poll = False - - def __init__(self, name, info, controller): - """Initialize the device.""" - self._controller = controller - self._info = info - self._name = name - - @property - def name(self): - """Return the name of the device.""" - return self._name diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index ea8b6390178..4ad9a927d9c 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -37,8 +37,8 @@ from . import ( PARTITION_SCHEMA, SIGNAL_KEYPAD_UPDATE, SIGNAL_PARTITION_UPDATE, - EnvisalinkDevice, ) +from .entity import EnvisalinkEntity _LOGGER = logging.getLogger(__name__) @@ -102,7 +102,7 @@ async def async_setup_platform( ) -class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): +class EnvisalinkAlarm(EnvisalinkEntity, AlarmControlPanelEntity): """Representation of an Envisalink-based alarm panel.""" _attr_supported_features = ( diff --git a/homeassistant/components/envisalink/binary_sensor.py b/homeassistant/components/envisalink/binary_sensor.py index 9c0909539bb..6c4e2b528e9 100644 --- a/homeassistant/components/envisalink/binary_sensor.py +++ b/homeassistant/components/envisalink/binary_sensor.py @@ -13,14 +13,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from . import ( - CONF_ZONENAME, - CONF_ZONETYPE, - DATA_EVL, - SIGNAL_ZONE_UPDATE, - ZONE_SCHEMA, - EnvisalinkDevice, -) +from . import CONF_ZONENAME, CONF_ZONETYPE, DATA_EVL, SIGNAL_ZONE_UPDATE, ZONE_SCHEMA +from .entity import EnvisalinkEntity _LOGGER = logging.getLogger(__name__) @@ -52,7 +46,7 @@ async def async_setup_platform( async_add_entities(entities) -class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorEntity): +class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity): """Representation of an Envisalink binary sensor.""" def __init__(self, hass, zone_number, zone_name, zone_type, info, controller): diff --git a/homeassistant/components/envisalink/entity.py b/homeassistant/components/envisalink/entity.py new file mode 100644 index 00000000000..a686ed2e3cb --- /dev/null +++ b/homeassistant/components/envisalink/entity.py @@ -0,0 +1,20 @@ +"""Support for Envisalink devices.""" + +from homeassistant.helpers.entity import Entity + + +class EnvisalinkEntity(Entity): + """Representation of an Envisalink device.""" + + _attr_should_poll = False + + def __init__(self, name, info, controller): + """Initialize the device.""" + self._controller = controller + self._info = info + self._name = name + + @property + def name(self): + """Return the name of the device.""" + return self._name diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py index fcafc23dd37..70d471a685c 100644 --- a/homeassistant/components/envisalink/sensor.py +++ b/homeassistant/components/envisalink/sensor.py @@ -16,8 +16,8 @@ from . import ( PARTITION_SCHEMA, SIGNAL_KEYPAD_UPDATE, SIGNAL_PARTITION_UPDATE, - EnvisalinkDevice, ) +from .entity import EnvisalinkEntity _LOGGER = logging.getLogger(__name__) @@ -49,7 +49,7 @@ async def async_setup_platform( async_add_entities(entities) -class EnvisalinkSensor(EnvisalinkDevice, SensorEntity): +class EnvisalinkSensor(EnvisalinkEntity, SensorEntity): """Representation of an Envisalink keypad.""" def __init__(self, hass, partition_name, partition_number, info, controller): diff --git a/homeassistant/components/envisalink/switch.py b/homeassistant/components/envisalink/switch.py index 36ad3d5bf81..e4f37bf328d 100644 --- a/homeassistant/components/envisalink/switch.py +++ b/homeassistant/components/envisalink/switch.py @@ -11,13 +11,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( - CONF_ZONENAME, - DATA_EVL, - SIGNAL_ZONE_BYPASS_UPDATE, - ZONE_SCHEMA, - EnvisalinkDevice, -) +from . import CONF_ZONENAME, DATA_EVL, SIGNAL_ZONE_BYPASS_UPDATE, ZONE_SCHEMA +from .entity import EnvisalinkEntity _LOGGER = logging.getLogger(__name__) @@ -51,7 +46,7 @@ async def async_setup_platform( async_add_entities(entities) -class EnvisalinkSwitch(EnvisalinkDevice, SwitchEntity): +class EnvisalinkSwitch(EnvisalinkEntity, SwitchEntity): """Representation of an Envisalink switch.""" def __init__(self, hass, zone_number, zone_name, info, controller): From 9dd16d3df5ea1e97f1c082d1c6de0e3239576246 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:21:30 +0200 Subject: [PATCH 0715/1309] Move efergy base entity to separate module (#126051) --- homeassistant/components/efergy/__init__.py | 24 ----------------- homeassistant/components/efergy/entity.py | 30 +++++++++++++++++++++ homeassistant/components/efergy/sensor.py | 3 ++- tests/components/efergy/__init__.py | 2 +- 4 files changed, 33 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/efergy/entity.py diff --git a/homeassistant/components/efergy/__init__.py b/homeassistant/components/efergy/__init__.py index 52979e50552..fd5aa930027 100644 --- a/homeassistant/components/efergy/__init__.py +++ b/homeassistant/components/efergy/__init__.py @@ -8,12 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity - -from .const import DEFAULT_NAME, DOMAIN PLATFORMS = [Platform.SENSOR] type EfergyConfigEntry = ConfigEntry[Efergy] @@ -47,22 +42,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: EfergyConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: EfergyConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class EfergyEntity(Entity): - """Representation of a Efergy entity.""" - - _attr_attribution = "Data provided by Efergy" - - def __init__(self, api: Efergy, server_unique_id: str) -> None: - """Initialize an Efergy entity.""" - self.api = api - self._attr_device_info = DeviceInfo( - configuration_url="https://engage.efergy.com/user/login", - connections={(dr.CONNECTION_NETWORK_MAC, api.info["mac"])}, - identifiers={(DOMAIN, server_unique_id)}, - manufacturer=DEFAULT_NAME, - name=DEFAULT_NAME, - model=api.info["type"], - sw_version=api.info["version"], - ) diff --git a/homeassistant/components/efergy/entity.py b/homeassistant/components/efergy/entity.py new file mode 100644 index 00000000000..4cbe44d1c10 --- /dev/null +++ b/homeassistant/components/efergy/entity.py @@ -0,0 +1,30 @@ +"""The Efergy integration.""" + +from __future__ import annotations + +from pyefergy import Efergy + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DEFAULT_NAME, DOMAIN + + +class EfergyEntity(Entity): + """Representation of a Efergy entity.""" + + _attr_attribution = "Data provided by Efergy" + + def __init__(self, api: Efergy, server_unique_id: str) -> None: + """Initialize an Efergy entity.""" + self.api = api + self._attr_device_info = DeviceInfo( + configuration_url="https://engage.efergy.com/user/login", + connections={(dr.CONNECTION_NETWORK_MAC, api.info["mac"])}, + identifiers={(DOMAIN, server_unique_id)}, + manufacturer=DEFAULT_NAME, + name=DEFAULT_NAME, + model=api.info["type"], + sw_version=api.info["version"], + ) diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 05c731370eb..419c4da591d 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -20,8 +20,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import EfergyConfigEntry, EfergyEntity +from . import EfergyConfigEntry from .const import CONF_CURRENT_VALUES, LOGGER +from .entity import EfergyEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( diff --git a/tests/components/efergy/__init__.py b/tests/components/efergy/__init__.py index d763aaa2fb6..36efa77cf45 100644 --- a/tests/components/efergy/__init__.py +++ b/tests/components/efergy/__init__.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch from pyefergy import exceptions -from homeassistant.components.efergy import DOMAIN +from homeassistant.components.efergy.const import DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component From 4c5535d1cc97457bcab8d611306ffc56f60070bd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:25:30 +0200 Subject: [PATCH 0716/1309] Move econet base entity to separate module (#126049) --- homeassistant/components/econet/__init__.py | 47 ++----------------- .../components/econet/binary_sensor.py | 2 +- homeassistant/components/econet/climate.py | 2 +- homeassistant/components/econet/const.py | 2 + homeassistant/components/econet/entity.py | 46 ++++++++++++++++++ homeassistant/components/econet/sensor.py | 2 +- homeassistant/components/econet/switch.py | 2 +- .../components/econet/water_heater.py | 2 +- 8 files changed, 56 insertions(+), 49 deletions(-) create mode 100644 homeassistant/components/econet/entity.py diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 4aba79f779f..4fd920a5ecc 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -16,14 +16,12 @@ from pyeconet.errors import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .const import API_CLIENT, DOMAIN, EQUIPMENT +from .const import API_CLIENT, DOMAIN, EQUIPMENT, PUSH_UPDATE _LOGGER = logging.getLogger(__name__) @@ -34,7 +32,6 @@ PLATFORMS = [ Platform.SWITCH, Platform.WATER_HEATER, ] -PUSH_UPDATE = "econet.push_update" INTERVAL = timedelta(minutes=60) @@ -99,41 +96,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][API_CLIENT].pop(entry.entry_id) hass.data[DOMAIN][EQUIPMENT].pop(entry.entry_id) return unload_ok - - -class EcoNetEntity(Entity): - """Define a base EcoNet entity.""" - - _attr_should_poll = False - - def __init__(self, econet): - """Initialize.""" - self._econet = econet - self._attr_name = econet.device_name - self._attr_unique_id = f"{econet.device_id}_{econet.device_name}" - - async def async_added_to_hass(self): - """Subscribe to device events.""" - await super().async_added_to_hass() - self.async_on_remove( - async_dispatcher_connect(self.hass, PUSH_UPDATE, self.on_update_received) - ) - - @callback - def on_update_received(self): - """Update was pushed from the ecoent API.""" - self.async_write_ha_state() - - @property - def available(self): - """Return if the device is online or not.""" - return self._econet.connected - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return DeviceInfo( - identifiers={(DOMAIN, self._econet.device_id)}, - manufacturer="Rheem", - name=self._econet.device_name, - ) diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py index 3f8e17a5fbe..0f5cb6f92af 100644 --- a/homeassistant/components/econet/binary_sensor.py +++ b/homeassistant/components/econet/binary_sensor.py @@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT +from .entity import EcoNetEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index 1d6cefc9645..bac123bf206 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -22,8 +22,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT +from .entity import EcoNetEntity ECONET_STATE_TO_HA = { ThermostatOperationMode.HEATING: HVACMode.HEAT, diff --git a/homeassistant/components/econet/const.py b/homeassistant/components/econet/const.py index 46c70021048..ee8d4fc8a46 100644 --- a/homeassistant/components/econet/const.py +++ b/homeassistant/components/econet/const.py @@ -3,3 +3,5 @@ DOMAIN = "econet" API_CLIENT = "api_client" EQUIPMENT = "equipment" + +PUSH_UPDATE = "econet.push_update" diff --git a/homeassistant/components/econet/entity.py b/homeassistant/components/econet/entity.py new file mode 100644 index 00000000000..44488f0b133 --- /dev/null +++ b/homeassistant/components/econet/entity.py @@ -0,0 +1,46 @@ +"""Support for EcoNet products.""" + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, PUSH_UPDATE + + +class EcoNetEntity(Entity): + """Define a base EcoNet entity.""" + + _attr_should_poll = False + + def __init__(self, econet): + """Initialize.""" + self._econet = econet + self._attr_name = econet.device_name + self._attr_unique_id = f"{econet.device_id}_{econet.device_name}" + + async def async_added_to_hass(self): + """Subscribe to device events.""" + await super().async_added_to_hass() + self.async_on_remove( + async_dispatcher_connect(self.hass, PUSH_UPDATE, self.on_update_received) + ) + + @callback + def on_update_received(self): + """Update was pushed from the ecoent API.""" + self.async_write_ha_state() + + @property + def available(self): + """Return if the device is online or not.""" + return self._econet.connected + + @property + def device_info(self) -> DeviceInfo: + """Return device registry information for this entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._econet.device_id)}, + manufacturer="Rheem", + name=self._econet.device_name, + ) diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py index f2d4ab304a5..19bac8c9e1f 100644 --- a/homeassistant/components/econet/sensor.py +++ b/homeassistant/components/econet/sensor.py @@ -21,8 +21,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT +from .entity import EcoNetEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py index 107cd7dc586..e36f6c834b1 100644 --- a/homeassistant/components/econet/switch.py +++ b/homeassistant/components/econet/switch.py @@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT +from .entity import EcoNetEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index 5db339b4411..efe4196993c 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -22,8 +22,8 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EcoNetEntity from .const import DOMAIN, EQUIPMENT +from .entity import EcoNetEntity SCAN_INTERVAL = timedelta(hours=1) From 45f2198972f55ff02a00a56eb3bae5edc586f202 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:29:29 +0200 Subject: [PATCH 0717/1309] Move and rename fibaro base entity to separate module (#126055) --- homeassistant/components/fibaro/__init__.py | 122 +---------------- .../components/fibaro/binary_sensor.py | 5 +- homeassistant/components/fibaro/climate.py | 21 +-- homeassistant/components/fibaro/cover.py | 5 +- homeassistant/components/fibaro/entity.py | 126 ++++++++++++++++++ homeassistant/components/fibaro/event.py | 5 +- homeassistant/components/fibaro/light.py | 5 +- homeassistant/components/fibaro/lock.py | 5 +- homeassistant/components/fibaro/sensor.py | 7 +- homeassistant/components/fibaro/switch.py | 5 +- 10 files changed, 160 insertions(+), 146 deletions(-) create mode 100644 homeassistant/components/fibaro/entity.py diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index d6118aa3655..d9e7e022aee 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -15,14 +15,7 @@ from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver from requests.exceptions import HTTPError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ARMED, - ATTR_BATTERY_LEVEL, - CONF_PASSWORD, - CONF_URL, - CONF_USERNAME, - Platform, -) +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -31,7 +24,6 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo -from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from .const import CONF_IMPORT_PLUGINS, DOMAIN @@ -450,118 +442,6 @@ async def async_remove_config_entry_device( return True -class FibaroDevice(Entity): - """Representation of a Fibaro device entity.""" - - _attr_should_poll = False - - def __init__(self, fibaro_device: DeviceModel) -> None: - """Initialize the device.""" - self.fibaro_device = fibaro_device - self.controller = fibaro_device.fibaro_controller - self.ha_id = fibaro_device.ha_id - self._attr_name = fibaro_device.friendly_name - self._attr_unique_id = fibaro_device.unique_id_str - - self._attr_device_info = self.controller.get_device_info(fibaro_device) - # propagate hidden attribute set in fibaro home center to HA - if not fibaro_device.visible: - self._attr_entity_registry_visible_default = False - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - self.controller.register(self.fibaro_device.fibaro_id, self._update_callback) - - def _update_callback(self) -> None: - """Update the state.""" - self.schedule_update_ha_state(True) - - @property - def level(self) -> int | None: - """Get the level of Fibaro device.""" - if self.fibaro_device.value.has_value: - return self.fibaro_device.value.int_value() - return None - - @property - def level2(self) -> int | None: - """Get the tilt level of Fibaro device.""" - if self.fibaro_device.value_2.has_value: - return self.fibaro_device.value_2.int_value() - return None - - def dont_know_message(self, cmd: str) -> None: - """Make a warning in case we don't know how to perform an action.""" - _LOGGER.warning( - "Not sure how to %s: %s (available actions: %s)", - cmd, - str(self.ha_id), - str(self.fibaro_device.actions), - ) - - def set_level(self, level: int) -> None: - """Set the level of Fibaro device.""" - self.action("setValue", level) - if self.fibaro_device.value.has_value: - self.fibaro_device.properties["value"] = level - if self.fibaro_device.has_brightness: - self.fibaro_device.properties["brightness"] = level - - def set_level2(self, level: int) -> None: - """Set the level2 of Fibaro device.""" - self.action("setValue2", level) - if self.fibaro_device.value_2.has_value: - self.fibaro_device.properties["value2"] = level - - def call_turn_on(self) -> None: - """Turn on the Fibaro device.""" - self.action("turnOn") - - def call_turn_off(self) -> None: - """Turn off the Fibaro device.""" - self.action("turnOff") - - def call_set_color(self, red: int, green: int, blue: int, white: int) -> None: - """Set the color of Fibaro device.""" - red = int(max(0, min(255, red))) - green = int(max(0, min(255, green))) - blue = int(max(0, min(255, blue))) - white = int(max(0, min(255, white))) - color_str = f"{red},{green},{blue},{white}" - self.fibaro_device.properties["color"] = color_str - self.action("setColor", str(red), str(green), str(blue), str(white)) - - def action(self, cmd: str, *args: Any) -> None: - """Perform an action on the Fibaro HC.""" - if cmd in self.fibaro_device.actions: - self.fibaro_device.execute_action(cmd, args) - _LOGGER.debug("-> %s.%s%s called", str(self.ha_id), str(cmd), str(args)) - else: - self.dont_know_message(cmd) - - @property - def current_binary_state(self) -> bool: - """Return the current binary state.""" - return self.fibaro_device.value.bool_value(False) - - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return the state attributes of the device.""" - attr = {"fibaro_id": self.fibaro_device.fibaro_id} - - if self.fibaro_device.has_battery_level: - attr[ATTR_BATTERY_LEVEL] = self.fibaro_device.battery_level - if self.fibaro_device.has_armed: - attr[ATTR_ARMED] = self.fibaro_device.armed - - return attr - - def update(self) -> None: - """Update the available state of the entity.""" - if self.fibaro_device.has_dead: - self._attr_available = not self.fibaro_device.dead - - class FibaroConnectFailed(HomeAssistantError): """Error to indicate we cannot connect to fibaro home center.""" diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 3c965c11b34..9f3efbfb514 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -17,8 +17,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController, FibaroDevice +from . import FibaroController from .const import DOMAIN +from .entity import FibaroEntity SENSOR_TYPES = { "com.fibaro.floodSensor": ["Flood", "mdi:water", BinarySensorDeviceClass.MOISTURE], @@ -56,7 +57,7 @@ async def async_setup_entry( ) -class FibaroBinarySensor(FibaroDevice, BinarySensorEntity): +class FibaroBinarySensor(FibaroEntity, BinarySensorEntity): """Representation of a Fibaro Binary Sensor.""" def __init__(self, fibaro_device: DeviceModel) -> None: diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index cf08d52d36e..0bfc2223317 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -22,8 +22,9 @@ from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController, FibaroDevice +from . import FibaroController from .const import DOMAIN +from .entity import FibaroEntity PRESET_RESUME = "resume" PRESET_MOIST = "moist" @@ -124,7 +125,7 @@ async def async_setup_entry( ) -class FibaroThermostat(FibaroDevice, ClimateEntity): +class FibaroThermostat(FibaroEntity, ClimateEntity): """Representation of a Fibaro Thermostat.""" _enable_turn_on_off_backwards_compatibility = False @@ -132,10 +133,10 @@ class FibaroThermostat(FibaroDevice, ClimateEntity): def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the Fibaro device.""" super().__init__(fibaro_device) - self._temp_sensor_device: FibaroDevice | None = None - self._target_temp_device: FibaroDevice | None = None - self._op_mode_device: FibaroDevice | None = None - self._fan_mode_device: FibaroDevice | None = None + self._temp_sensor_device: FibaroEntity | None = None + self._target_temp_device: FibaroEntity | None = None + self._op_mode_device: FibaroEntity | None = None + self._fan_mode_device: FibaroEntity | None = None self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) siblings = fibaro_device.fibaro_controller.get_siblings(fibaro_device) @@ -150,23 +151,23 @@ class FibaroThermostat(FibaroDevice, ClimateEntity): and (device.value.has_value or device.has_heating_thermostat_setpoint) and device.unit in ("C", "F") ): - self._temp_sensor_device = FibaroDevice(device) + self._temp_sensor_device = FibaroEntity(device) tempunit = device.unit if any( action for action in TARGET_TEMP_ACTIONS if action in device.actions ): - self._target_temp_device = FibaroDevice(device) + self._target_temp_device = FibaroEntity(device) self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE if device.has_unit: tempunit = device.unit if any(action for action in OP_MODE_ACTIONS if action in device.actions): - self._op_mode_device = FibaroDevice(device) + self._op_mode_device = FibaroEntity(device) self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE if "setFanMode" in device.actions: - self._fan_mode_device = FibaroDevice(device) + self._fan_mode_device = FibaroEntity(device) self._attr_supported_features |= ClimateEntityFeature.FAN_MODE if tempunit == "F": diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index e71ae8982e7..fc28e57af70 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -18,8 +18,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController, FibaroDevice +from . import FibaroController from .const import DOMAIN +from .entity import FibaroEntity async def async_setup_entry( @@ -35,7 +36,7 @@ async def async_setup_entry( ) -class FibaroCover(FibaroDevice, CoverEntity): +class FibaroCover(FibaroEntity, CoverEntity): """Representation a Fibaro Cover.""" def __init__(self, fibaro_device: DeviceModel) -> None: diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py new file mode 100644 index 00000000000..6a8e12136c8 --- /dev/null +++ b/homeassistant/components/fibaro/entity.py @@ -0,0 +1,126 @@ +"""Support for the Fibaro devices.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from pyfibaro.fibaro_device import DeviceModel + +from homeassistant.const import ATTR_ARMED, ATTR_BATTERY_LEVEL +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + + +class FibaroEntity(Entity): + """Representation of a Fibaro device entity.""" + + _attr_should_poll = False + + def __init__(self, fibaro_device: DeviceModel) -> None: + """Initialize the device.""" + self.fibaro_device = fibaro_device + self.controller = fibaro_device.fibaro_controller + self.ha_id = fibaro_device.ha_id + self._attr_name = fibaro_device.friendly_name + self._attr_unique_id = fibaro_device.unique_id_str + + self._attr_device_info = self.controller.get_device_info(fibaro_device) + # propagate hidden attribute set in fibaro home center to HA + if not fibaro_device.visible: + self._attr_entity_registry_visible_default = False + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + self.controller.register(self.fibaro_device.fibaro_id, self._update_callback) + + def _update_callback(self) -> None: + """Update the state.""" + self.schedule_update_ha_state(True) + + @property + def level(self) -> int | None: + """Get the level of Fibaro device.""" + if self.fibaro_device.value.has_value: + return self.fibaro_device.value.int_value() + return None + + @property + def level2(self) -> int | None: + """Get the tilt level of Fibaro device.""" + if self.fibaro_device.value_2.has_value: + return self.fibaro_device.value_2.int_value() + return None + + def dont_know_message(self, cmd: str) -> None: + """Make a warning in case we don't know how to perform an action.""" + _LOGGER.warning( + "Not sure how to %s: %s (available actions: %s)", + cmd, + str(self.ha_id), + str(self.fibaro_device.actions), + ) + + def set_level(self, level: int) -> None: + """Set the level of Fibaro device.""" + self.action("setValue", level) + if self.fibaro_device.value.has_value: + self.fibaro_device.properties["value"] = level + if self.fibaro_device.has_brightness: + self.fibaro_device.properties["brightness"] = level + + def set_level2(self, level: int) -> None: + """Set the level2 of Fibaro device.""" + self.action("setValue2", level) + if self.fibaro_device.value_2.has_value: + self.fibaro_device.properties["value2"] = level + + def call_turn_on(self) -> None: + """Turn on the Fibaro device.""" + self.action("turnOn") + + def call_turn_off(self) -> None: + """Turn off the Fibaro device.""" + self.action("turnOff") + + def call_set_color(self, red: int, green: int, blue: int, white: int) -> None: + """Set the color of Fibaro device.""" + red = int(max(0, min(255, red))) + green = int(max(0, min(255, green))) + blue = int(max(0, min(255, blue))) + white = int(max(0, min(255, white))) + color_str = f"{red},{green},{blue},{white}" + self.fibaro_device.properties["color"] = color_str + self.action("setColor", str(red), str(green), str(blue), str(white)) + + def action(self, cmd: str, *args: Any) -> None: + """Perform an action on the Fibaro HC.""" + if cmd in self.fibaro_device.actions: + self.fibaro_device.execute_action(cmd, args) + _LOGGER.debug("-> %s.%s%s called", str(self.ha_id), str(cmd), str(args)) + else: + self.dont_know_message(cmd) + + @property + def current_binary_state(self) -> bool: + """Return the current binary state.""" + return self.fibaro_device.value.bool_value(False) + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return the state attributes of the device.""" + attr = {"fibaro_id": self.fibaro_device.fibaro_id} + + if self.fibaro_device.has_battery_level: + attr[ATTR_BATTERY_LEVEL] = self.fibaro_device.battery_level + if self.fibaro_device.has_armed: + attr[ATTR_ARMED] = self.fibaro_device.armed + + return attr + + def update(self) -> None: + """Update the available state of the entity.""" + if self.fibaro_device.has_dead: + self._attr_available = not self.fibaro_device.dead diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index c65e8f143c6..c964ab283c1 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -15,8 +15,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController, FibaroDevice +from . import FibaroController from .const import DOMAIN +from .entity import FibaroEntity async def async_setup_entry( @@ -38,7 +39,7 @@ async def async_setup_entry( ) -class FibaroEventEntity(FibaroDevice, EventEntity): +class FibaroEventEntity(FibaroEntity, EventEntity): """Representation of a Fibaro Event Entity.""" def __init__(self, fibaro_device: DeviceModel, scene_event: SceneEvent) -> None: diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 2f2182c53cd..17831a36a4a 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -22,8 +22,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController, FibaroDevice +from . import FibaroController from .const import DOMAIN +from .entity import FibaroEntity PARALLEL_UPDATES = 2 @@ -62,7 +63,7 @@ async def async_setup_entry( ) -class FibaroLight(FibaroDevice, LightEntity): +class FibaroLight(FibaroEntity, LightEntity): """Representation of a Fibaro Light, including dimmable.""" def __init__(self, fibaro_device: DeviceModel) -> None: diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index faa82815b8d..55583d2a967 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -12,8 +12,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController, FibaroDevice +from . import FibaroController from .const import DOMAIN +from .entity import FibaroEntity async def async_setup_entry( @@ -29,7 +30,7 @@ async def async_setup_entry( ) -class FibaroLock(FibaroDevice, LockEntity): +class FibaroLock(FibaroEntity, LockEntity): """Representation of a Fibaro Lock.""" def __init__(self, fibaro_device: DeviceModel) -> None: diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index fd6ec74050d..008395b020f 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -27,8 +27,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import convert -from . import FibaroController, FibaroDevice +from . import FibaroController from .const import DOMAIN +from .entity import FibaroEntity # List of known sensors which represents a fibaro device MAIN_SENSOR_TYPES: dict[str, SensorEntityDescription] = { @@ -132,7 +133,7 @@ async def async_setup_entry( async_add_entities(entities, True) -class FibaroSensor(FibaroDevice, SensorEntity): +class FibaroSensor(FibaroEntity, SensorEntity): """Representation of a Fibaro Sensor.""" def __init__( @@ -161,7 +162,7 @@ class FibaroSensor(FibaroDevice, SensorEntity): self._attr_native_value = self.fibaro_device.value.float_value() -class FibaroAdditionalSensor(FibaroDevice, SensorEntity): +class FibaroAdditionalSensor(FibaroEntity, SensorEntity): """Representation of a Fibaro Additional Sensor.""" def __init__( diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index f6ceed972f7..1ad933f5d20 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -12,8 +12,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FibaroController, FibaroDevice +from . import FibaroController from .const import DOMAIN +from .entity import FibaroEntity async def async_setup_entry( @@ -29,7 +30,7 @@ async def async_setup_entry( ) -class FibaroSwitch(FibaroDevice, SwitchEntity): +class FibaroSwitch(FibaroEntity, SwitchEntity): """Representation of a Fibaro Switch.""" def __init__(self, fibaro_device: DeviceModel) -> None: From 34cf044a7ce590fe8f1075014cb24824c7ebef75 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:29:44 +0200 Subject: [PATCH 0718/1309] Move freebox base entity to separate module (#126056) --- homeassistant/components/freebox/alarm_control_panel.py | 2 +- homeassistant/components/freebox/binary_sensor.py | 2 +- homeassistant/components/freebox/camera.py | 2 +- homeassistant/components/freebox/{home_base.py => entity.py} | 0 homeassistant/components/freebox/sensor.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename homeassistant/components/freebox/{home_base.py => entity.py} (100%) diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py index da5983f9374..891180785b0 100644 --- a/homeassistant/components/freebox/alarm_control_panel.py +++ b/homeassistant/components/freebox/alarm_control_panel.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, FreeboxHomeCategory -from .home_base import FreeboxHomeEntity +from .entity import FreeboxHomeEntity from .router import FreeboxRouter FREEBOX_TO_STATUS = { diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index a54930753a0..20c124efea6 100644 --- a/homeassistant/components/freebox/binary_sensor.py +++ b/homeassistant/components/freebox/binary_sensor.py @@ -17,7 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, FreeboxHomeCategory -from .home_base import FreeboxHomeEntity +from .entity import FreeboxHomeEntity from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index 879941af040..33919df74f6 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_DETECTION, DOMAIN, FreeboxHomeCategory -from .home_base import FreeboxHomeEntity +from .entity import FreeboxHomeEntity from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/freebox/home_base.py b/homeassistant/components/freebox/entity.py similarity index 100% rename from homeassistant/components/freebox/home_base.py rename to homeassistant/components/freebox/entity.py diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index e5a0b8223a9..097c8c138ee 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util from .const import DOMAIN -from .home_base import FreeboxHomeEntity +from .entity import FreeboxHomeEntity from .router import FreeboxRouter _LOGGER = logging.getLogger(__name__) From 95db4df13ac841fefc8440590a3a054b5b4540ad Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 16 Sep 2024 09:37:46 -0400 Subject: [PATCH 0719/1309] Add missing Zigbee/Thread firmware config flow translations (#125782) --- .../components/homeassistant_hardware/strings.json | 3 ++- .../components/homeassistant_sky_connect/strings.json | 8 ++++++-- .../components/homeassistant_yellow/strings.json | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index dbbb2057323..b483df75d75 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -51,7 +51,8 @@ "not_hassio_thread": "The OpenThread Border Router addon can only be installed with Home Assistant OS. If you would like to use the {model} as an Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.", "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", - "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again." + "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." }, "progress": { "install_zigbee_flasher_addon": "The Silicon Labs Flasher addon is installed, this may take a few minutes.", diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 20f587c2dbb..a596b9846ce 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -113,7 +113,8 @@ "not_hassio_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::not_hassio_thread%]", "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", - "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]" + "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", @@ -181,7 +182,10 @@ "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]", "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", "not_hassio_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::not_hassio_thread%]", - "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]" + "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", + "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", + "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index fd3be3586b1..b089e483899 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -138,7 +138,8 @@ "not_hassio_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::not_hassio_thread%]", "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", - "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]" + "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device." }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", From 4fbc5a9558251eb43481f51a66689a3ded73224b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 15:47:26 +0200 Subject: [PATCH 0720/1309] Move hdmi_cec base entity to separate module (#126057) --- homeassistant/components/hdmi_cec/__init__.py | 105 +---------------- homeassistant/components/hdmi_cec/const.py | 7 ++ homeassistant/components/hdmi_cec/entity.py | 109 ++++++++++++++++++ .../components/hdmi_cec/media_player.py | 3 +- homeassistant/components/hdmi_cec/switch.py | 3 +- 5 files changed, 121 insertions(+), 106 deletions(-) create mode 100644 homeassistant/components/hdmi_cec/const.py create mode 100644 homeassistant/components/hdmi_cec/entity.py diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 9d208b3a228..6b4a949c0fc 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -35,30 +35,15 @@ from homeassistant.const import ( from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.helpers import discovery, event import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -DOMAIN = "hdmi_cec" +from .const import DOMAIN, EVENT_HDMI_CEC_UNAVAILABLE _LOGGER = logging.getLogger(__name__) DEFAULT_DISPLAY_NAME = "HA" CONF_TYPES = "types" -ICON_UNKNOWN = "mdi:help" -ICON_AUDIO = "mdi:speaker" -ICON_PLAYER = "mdi:play" -ICON_TUNER = "mdi:radio" -ICON_RECORDER = "mdi:microphone" -ICON_TV = "mdi:television" -ICONS_BY_TYPE = { - 0: ICON_TV, - 1: ICON_RECORDER, - 3: ICON_TUNER, - 4: ICON_PLAYER, - 5: ICON_AUDIO, -} - CMD_UP = "up" CMD_DOWN = "down" CMD_MUTE = "mute" @@ -70,12 +55,7 @@ CMD_RELEASE = "release" EVENT_CEC_COMMAND_RECEIVED = "cec_command_received" EVENT_CEC_KEYPRESS_RECEIVED = "cec_keypress_received" -ATTR_PHYSICAL_ADDRESS = "physical_address" -ATTR_TYPE_ID = "type_id" -ATTR_VENDOR_NAME = "vendor_name" -ATTR_VENDOR_ID = "vendor_id" ATTR_DEVICE = "device" -ATTR_TYPE = "type" ATTR_KEY = "key" ATTR_DUR = "dur" ATTR_SRC = "src" @@ -156,7 +136,6 @@ CONFIG_SCHEMA = vol.Schema( ) WATCHDOG_INTERVAL = 120 -EVENT_HDMI_CEC_UNAVAILABLE = "hdmi_cec_unavailable" def pad_physical_address(addr): @@ -356,85 +335,3 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_cec) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) return True - - -class CecEntity(Entity): - """Representation of a HDMI CEC device entity.""" - - _attr_should_poll = False - - def __init__(self, device, logical) -> None: - """Initialize the device.""" - self._device = device - self._logical_address = logical - self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) - self._set_attr_name() - self._attr_icon = ICONS_BY_TYPE.get(self._device.type, ICON_UNKNOWN) - - def _set_attr_name(self): - """Set name.""" - if ( - self._device.osd_name is not None - and self.vendor_name is not None - and self.vendor_name != "Unknown" - ): - self._attr_name = f"{self.vendor_name} {self._device.osd_name}" - elif self._device.osd_name is None: - self._attr_name = f"{self._device.type_name} {self._logical_address}" - else: - self._attr_name = f"{self._device.type_name} {self._logical_address} ({self._device.osd_name})" - - def _hdmi_cec_unavailable(self, callback_event): - self._attr_available = False - self.schedule_update_ha_state(False) - - async def async_added_to_hass(self): - """Register HDMI callbacks after initialization.""" - self._device.set_update_callback(self._update) - self.hass.bus.async_listen( - EVENT_HDMI_CEC_UNAVAILABLE, self._hdmi_cec_unavailable - ) - - def _update(self, device=None): - """Device status changed, schedule an update.""" - self._attr_available = True - self.schedule_update_ha_state(True) - - @property - def vendor_id(self): - """Return the ID of the device's vendor.""" - return self._device.vendor_id - - @property - def vendor_name(self): - """Return the name of the device's vendor.""" - return self._device.vendor - - @property - def physical_address(self): - """Return the physical address of device in HDMI network.""" - return str(self._device.physical_address) - - @property - def type(self): - """Return a string representation of the device's type.""" - return self._device.type_name - - @property - def type_id(self): - """Return the type ID of device.""" - return self._device.type - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - state_attr = {} - if self.vendor_id is not None: - state_attr[ATTR_VENDOR_ID] = self.vendor_id - state_attr[ATTR_VENDOR_NAME] = self.vendor_name - if self.type_id is not None: - state_attr[ATTR_TYPE_ID] = self.type_id - state_attr[ATTR_TYPE] = self.type - if self.physical_address is not None: - state_attr[ATTR_PHYSICAL_ADDRESS] = self.physical_address - return state_attr diff --git a/homeassistant/components/hdmi_cec/const.py b/homeassistant/components/hdmi_cec/const.py new file mode 100644 index 00000000000..beb95e95676 --- /dev/null +++ b/homeassistant/components/hdmi_cec/const.py @@ -0,0 +1,7 @@ +"""Support for HDMI CEC.""" + +DOMAIN = "hdmi_cec" + +ATTR_NEW = "new" + +EVENT_HDMI_CEC_UNAVAILABLE = "hdmi_cec_unavailable" diff --git a/homeassistant/components/hdmi_cec/entity.py b/homeassistant/components/hdmi_cec/entity.py new file mode 100644 index 00000000000..b1bcb2720d4 --- /dev/null +++ b/homeassistant/components/hdmi_cec/entity.py @@ -0,0 +1,109 @@ +"""Support for HDMI CEC.""" + +from __future__ import annotations + +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, EVENT_HDMI_CEC_UNAVAILABLE + +ATTR_PHYSICAL_ADDRESS = "physical_address" +ATTR_TYPE = "type" +ATTR_TYPE_ID = "type_id" +ATTR_VENDOR_NAME = "vendor_name" +ATTR_VENDOR_ID = "vendor_id" + +ICON_UNKNOWN = "mdi:help" +ICON_AUDIO = "mdi:speaker" +ICON_PLAYER = "mdi:play" +ICON_TUNER = "mdi:radio" +ICON_RECORDER = "mdi:microphone" +ICON_TV = "mdi:television" +ICONS_BY_TYPE = { + 0: ICON_TV, + 1: ICON_RECORDER, + 3: ICON_TUNER, + 4: ICON_PLAYER, + 5: ICON_AUDIO, +} + + +class CecEntity(Entity): + """Representation of a HDMI CEC device entity.""" + + _attr_should_poll = False + + def __init__(self, device, logical) -> None: + """Initialize the device.""" + self._device = device + self._logical_address = logical + self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) + self._set_attr_name() + self._attr_icon = ICONS_BY_TYPE.get(self._device.type, ICON_UNKNOWN) + + def _set_attr_name(self): + """Set name.""" + if ( + self._device.osd_name is not None + and self.vendor_name is not None + and self.vendor_name != "Unknown" + ): + self._attr_name = f"{self.vendor_name} {self._device.osd_name}" + elif self._device.osd_name is None: + self._attr_name = f"{self._device.type_name} {self._logical_address}" + else: + self._attr_name = f"{self._device.type_name} {self._logical_address} ({self._device.osd_name})" + + def _hdmi_cec_unavailable(self, callback_event): + self._attr_available = False + self.schedule_update_ha_state(False) + + async def async_added_to_hass(self): + """Register HDMI callbacks after initialization.""" + self._device.set_update_callback(self._update) + self.hass.bus.async_listen( + EVENT_HDMI_CEC_UNAVAILABLE, self._hdmi_cec_unavailable + ) + + def _update(self, device=None): + """Device status changed, schedule an update.""" + self._attr_available = True + self.schedule_update_ha_state(True) + + @property + def vendor_id(self): + """Return the ID of the device's vendor.""" + return self._device.vendor_id + + @property + def vendor_name(self): + """Return the name of the device's vendor.""" + return self._device.vendor + + @property + def physical_address(self): + """Return the physical address of device in HDMI network.""" + return str(self._device.physical_address) + + @property + def type(self): + """Return a string representation of the device's type.""" + return self._device.type_name + + @property + def type_id(self): + """Return the type ID of device.""" + return self._device.type + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + state_attr = {} + if self.vendor_id is not None: + state_attr[ATTR_VENDOR_ID] = self.vendor_id + state_attr[ATTR_VENDOR_NAME] = self.vendor_name + if self.type_id is not None: + state_attr[ATTR_TYPE_ID] = self.type_id + state_attr[ATTR_TYPE] = self.type + if self.physical_address is not None: + state_attr[ATTR_PHYSICAL_ADDRESS] = self.physical_address + return state_attr diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py index e86a1f5be70..7ad06f0c45a 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -37,7 +37,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ATTR_NEW, DOMAIN, CecEntity +from .const import ATTR_NEW, DOMAIN +from .entity import CecEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py index 95998f44a9a..d1bb603a938 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ATTR_NEW, DOMAIN, CecEntity +from .const import ATTR_NEW, DOMAIN +from .entity import CecEntity _LOGGER = logging.getLogger(__name__) From 587ebd5d47e31a9d25f6dc07fe97fa787df07b9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Mon, 16 Sep 2024 16:07:43 +0200 Subject: [PATCH 0721/1309] Add new integration for WMS WebControl pro using local API (#124176) * Add new integration for WMS WebControl pro using local API Warema recently released a new local API for their WMS hub called "WebControl pro". This integration makes use of the new local API via a new dedicated Python library pywmspro. For now this integration only supports awnings as covers. But pywmspro is device-agnostic to ease future extensions. * Incorporated review feedback from joostlek Thanks a lot! * Incorporated more review feedback from joostlek Thanks a lot! * Incorporated more review feedback from joostlek Thanks a lot! * Fix * Follow-up fix * Improve handling of DHCP discovery * Further test improvements suggested by joostlek, thanks! --------- Co-authored-by: Joostlek --- CODEOWNERS | 2 + homeassistant/components/wmspro/__init__.py | 66 +++++ .../components/wmspro/config_flow.py | 89 +++++++ homeassistant/components/wmspro/const.py | 7 + homeassistant/components/wmspro/cover.py | 77 ++++++ homeassistant/components/wmspro/entity.py | 43 ++++ homeassistant/components/wmspro/manifest.json | 19 ++ homeassistant/components/wmspro/strings.json | 25 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/dhcp.py | 8 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/wmspro/__init__.py | 16 ++ tests/components/wmspro/conftest.py | 106 ++++++++ .../wmspro/fixtures/example_config_prod.json | 77 ++++++ .../wmspro/fixtures/example_config_test.json | 75 ++++++ .../fixtures/example_status_prod_awning.json | 22 ++ .../wmspro/snapshots/test_cover.ambr | 50 ++++ tests/components/wmspro/test_config_flow.py | 235 ++++++++++++++++++ tests/components/wmspro/test_cover.py | 226 +++++++++++++++++ tests/components/wmspro/test_init.py | 38 +++ 22 files changed, 1194 insertions(+) create mode 100644 homeassistant/components/wmspro/__init__.py create mode 100644 homeassistant/components/wmspro/config_flow.py create mode 100644 homeassistant/components/wmspro/const.py create mode 100644 homeassistant/components/wmspro/cover.py create mode 100644 homeassistant/components/wmspro/entity.py create mode 100644 homeassistant/components/wmspro/manifest.json create mode 100644 homeassistant/components/wmspro/strings.json create mode 100644 tests/components/wmspro/__init__.py create mode 100644 tests/components/wmspro/conftest.py create mode 100644 tests/components/wmspro/fixtures/example_config_prod.json create mode 100644 tests/components/wmspro/fixtures/example_config_test.json create mode 100644 tests/components/wmspro/fixtures/example_status_prod_awning.json create mode 100644 tests/components/wmspro/snapshots/test_cover.ambr create mode 100644 tests/components/wmspro/test_config_flow.py create mode 100644 tests/components/wmspro/test_cover.py create mode 100644 tests/components/wmspro/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 04906e6bf88..13981b3f6f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1668,6 +1668,8 @@ build.json @home-assistant/supervisor /tests/components/wiz/ @sbidy /homeassistant/components/wled/ @frenck /tests/components/wled/ @frenck +/homeassistant/components/wmspro/ @mback2k +/tests/components/wmspro/ @mback2k /homeassistant/components/wolflink/ @adamkrol93 @mtielen /tests/components/wolflink/ @adamkrol93 @mtielen /homeassistant/components/workday/ @fabaff @gjohansson-ST diff --git a/homeassistant/components/wmspro/__init__.py b/homeassistant/components/wmspro/__init__.py new file mode 100644 index 00000000000..c0c4a9e3950 --- /dev/null +++ b/homeassistant/components/wmspro/__init__.py @@ -0,0 +1,66 @@ +"""The WMS WebControl pro API integration.""" + +from __future__ import annotations + +import aiohttp +from wmspro.webcontrol import WebControlPro + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import UNDEFINED + +from .const import DOMAIN, MANUFACTURER + +PLATFORMS: list[Platform] = [Platform.COVER] + +type WebControlProConfigEntry = ConfigEntry[WebControlPro] + + +async def async_setup_entry( + hass: HomeAssistant, entry: WebControlProConfigEntry +) -> bool: + """Set up wmspro from a config entry.""" + host = entry.data[CONF_HOST] + session = async_get_clientsession(hass) + hub = WebControlPro(host, session) + + try: + await hub.ping() + except aiohttp.ClientError as err: + raise ConfigEntryNotReady(f"Error while connecting to {host}") from err + + entry.runtime_data = hub + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + if entry.unique_id + else UNDEFINED, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer=MANUFACTURER, + model="WMS WebControl pro", + configuration_url=f"http://{hub.host}/system", + ) + + try: + await hub.refresh() + for dest in hub.dests.values(): + await dest.refresh() + except aiohttp.ClientError as err: + raise ConfigEntryNotReady(f"Error while refreshing from {host}") from err + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: WebControlProConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/wmspro/config_flow.py b/homeassistant/components/wmspro/config_flow.py new file mode 100644 index 00000000000..ba3b5ef367d --- /dev/null +++ b/homeassistant/components/wmspro/config_flow.py @@ -0,0 +1,89 @@ +"""Config flow for WMS WebControl pro API integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +import voluptuous as vol +from wmspro.webcontrol import WebControlPro + +from homeassistant.components import dhcp +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, SUGGESTED_HOST + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + } +) + + +class WebControlProConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for wmspro.""" + + VERSION = 1 + + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle the DHCP discovery step.""" + unique_id = format_mac(discovery_info.macaddress) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + for entry in self.hass.config_entries.async_entries(DOMAIN): + if not entry.unique_id and entry.data[CONF_HOST] in ( + discovery_info.hostname, + discovery_info.ip, + ): + self.hass.config_entries.async_update_entry(entry, unique_id=unique_id) + return self.async_abort(reason="already_configured") + + return await self.async_step_user() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user-based step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + host = user_input[CONF_HOST] + session = async_get_clientsession(self.hass) + hub = WebControlPro(host, session) + try: + pong = await hub.ping() + except aiohttp.ClientError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if not pong: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry(title=host, data=user_input) + + if self.source == dhcp.DOMAIN: + discovery_info: DhcpServiceInfo = self.init_data + data_values = {CONF_HOST: discovery_info.hostname or discovery_info.ip} + else: + data_values = {CONF_HOST: SUGGESTED_HOST} + + self.context["title_placeholders"] = data_values + data_schema = self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, data_values + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/wmspro/const.py b/homeassistant/components/wmspro/const.py new file mode 100644 index 00000000000..0a1036cf632 --- /dev/null +++ b/homeassistant/components/wmspro/const.py @@ -0,0 +1,7 @@ +"""Constants for the WMS WebControl pro API integration.""" + +DOMAIN = "wmspro" +SUGGESTED_HOST = "webcontrol" + +ATTRIBUTION = "Data provided by WMS WebControl pro API" +MANUFACTURER = "WAREMA Renkhoff SE" diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py new file mode 100644 index 00000000000..b8540a5bf08 --- /dev/null +++ b/homeassistant/components/wmspro/cover.py @@ -0,0 +1,77 @@ +"""Support for covers connected with WMS WebControl pro.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from wmspro.const import ( + WMS_WebControl_pro_API_actionDescription, + WMS_WebControl_pro_API_actionType, +) + +from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import WebControlProConfigEntry +from .entity import WebControlProGenericEntity + +SCAN_INTERVAL = timedelta(seconds=5) +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: WebControlProConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the WMS based covers from a config entry.""" + hub = config_entry.runtime_data + + entities: list[WebControlProGenericEntity] = [] + for dest in hub.dests.values(): + if dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive): + entities.append(WebControlProAwning(config_entry.entry_id, dest)) # noqa: PERF401 + + async_add_entities(entities) + + +class WebControlProAwning(WebControlProGenericEntity, CoverEntity): + """Representation of a WMS based awning.""" + + _attr_device_class = CoverDeviceClass.AWNING + + @property + def current_cover_position(self) -> int | None: + """Return current position of cover.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + return action["percentage"] + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + await action(percentage=kwargs[ATTR_POSITION]) + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + return self.current_cover_position == 0 + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + await action(percentage=100) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + action = self._dest.action(WMS_WebControl_pro_API_actionDescription.AwningDrive) + await action(percentage=0) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the device if in motion.""" + action = self._dest.action( + WMS_WebControl_pro_API_actionDescription.ManualCommand, + WMS_WebControl_pro_API_actionType.Stop, + ) + await action() diff --git a/homeassistant/components/wmspro/entity.py b/homeassistant/components/wmspro/entity.py new file mode 100644 index 00000000000..0bbbc69a294 --- /dev/null +++ b/homeassistant/components/wmspro/entity.py @@ -0,0 +1,43 @@ +"""Generic entity for the WMS WebControl pro API integration.""" + +from __future__ import annotations + +from wmspro.destination import Destination + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import ATTRIBUTION, DOMAIN, MANUFACTURER + + +class WebControlProGenericEntity(Entity): + """Foundation of all WMS based entities.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, config_entry_id: str, dest: Destination) -> None: + """Initialize the entity with destination channel.""" + dest_id_str = str(dest.id) + self._dest = dest + self._attr_unique_id = dest_id_str + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, dest_id_str)}, + manufacturer=MANUFACTURER, + model=dest.animationType.name, + name=dest.name, + serial_number=dest_id_str, + suggested_area=dest.room.name, + via_device=(DOMAIN, config_entry_id), + configuration_url=f"http://{dest.host}/control", + ) + + async def async_update(self) -> None: + """Update the entity.""" + await self._dest.refresh() + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._dest.available diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json new file mode 100644 index 00000000000..ec97f444a54 --- /dev/null +++ b/homeassistant/components/wmspro/manifest.json @@ -0,0 +1,19 @@ +{ + "domain": "wmspro", + "name": "WMS WebControl pro", + "codeowners": ["@mback2k"], + "config_flow": true, + "dependencies": [], + "dhcp": [ + { + "macaddress": "0023D5*" + }, + { + "registered_devices": true + } + ], + "documentation": "https://www.home-assistant.io/integrations/wmspro", + "integration_type": "hub", + "iot_class": "local_polling", + "requirements": ["pywmspro==0.1.0"] +} diff --git a/homeassistant/components/wmspro/strings.json b/homeassistant/components/wmspro/strings.json new file mode 100644 index 00000000000..9b6d129905b --- /dev/null +++ b/homeassistant/components/wmspro/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "flow_title": "{host}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your WMS WebControl pro." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b26519c6319..55fa5f116e6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -670,6 +670,7 @@ FLOWS = { "withings", "wiz", "wled", + "wmspro", "wolflink", "workday", "worldclock", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 8f5964f1618..757c43c96a7 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -1089,6 +1089,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "wiz", "hostname": "wiz_*", }, + { + "domain": "wmspro", + "macaddress": "0023D5*", + }, + { + "domain": "wmspro", + "registered_devices": True, + }, { "domain": "yale", "hostname": "yale-connect-plus", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9963409f62e..cb550f38bc3 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6942,6 +6942,12 @@ "config_flow": true, "iot_class": "local_push" }, + "wmspro": { + "name": "WMS WebControl pro", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "wolflink": { "name": "Wolf SmartSet Service", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 1aaccce6e06..a314b6c51cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2470,6 +2470,9 @@ pywilight==0.0.74 # homeassistant.components.wiz pywizlight==0.5.14 +# homeassistant.components.wmspro +pywmspro==0.1.0 + # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15f26159299..d0341c2502b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1970,6 +1970,9 @@ pywilight==0.0.74 # homeassistant.components.wiz pywizlight==0.5.14 +# homeassistant.components.wmspro +pywmspro==0.1.0 + # homeassistant.components.ws66i pyws66i==1.1 diff --git a/tests/components/wmspro/__init__.py b/tests/components/wmspro/__init__.py new file mode 100644 index 00000000000..fee2fc64849 --- /dev/null +++ b/tests/components/wmspro/__init__.py @@ -0,0 +1,16 @@ +"""Tests for the wmspro integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> bool: + """Set up a config entry.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result diff --git a/tests/components/wmspro/conftest.py b/tests/components/wmspro/conftest.py new file mode 100644 index 00000000000..76c11e71316 --- /dev/null +++ b/tests/components/wmspro/conftest.py @@ -0,0 +1,106 @@ +"""Common fixtures for the wmspro tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.wmspro.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a dummy config entry.""" + return MockConfigEntry( + title="WebControl", + domain=DOMAIN, + data={CONF_HOST: "webcontrol"}, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.wmspro.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_hub_ping() -> Generator[AsyncMock]: + """Override WebControlPro.ping.""" + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ) as mock_hub_ping: + yield mock_hub_ping + + +@pytest.fixture +def mock_hub_refresh() -> Generator[AsyncMock]: + """Override WebControlPro.refresh.""" + with patch( + "wmspro.webcontrol.WebControlPro.refresh", + return_value=True, + ) as mock_hub_refresh: + yield mock_hub_refresh + + +@pytest.fixture +def mock_hub_configuration_test() -> Generator[AsyncMock]: + """Override WebControlPro.configuration.""" + with patch( + "wmspro.webcontrol.WebControlPro._getConfiguration", + return_value=load_json_object_fixture("example_config_test.json", DOMAIN), + ) as mock_hub_configuration: + yield mock_hub_configuration + + +@pytest.fixture +def mock_hub_configuration_prod() -> Generator[AsyncMock]: + """Override WebControlPro._getConfiguration.""" + with patch( + "wmspro.webcontrol.WebControlPro._getConfiguration", + return_value=load_json_object_fixture("example_config_prod.json", DOMAIN), + ) as mock_hub_configuration: + yield mock_hub_configuration + + +@pytest.fixture +def mock_hub_status_prod_awning() -> Generator[AsyncMock]: + """Override WebControlPro._getStatus.""" + with patch( + "wmspro.webcontrol.WebControlPro._getStatus", + return_value=load_json_object_fixture( + "example_status_prod_awning.json", DOMAIN + ), + ) as mock_dest_refresh: + yield mock_dest_refresh + + +@pytest.fixture +def mock_dest_refresh() -> Generator[AsyncMock]: + """Override Destination.refresh.""" + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ) as mock_dest_refresh: + yield mock_dest_refresh + + +@pytest.fixture +def mock_action_call() -> Generator[AsyncMock]: + """Override Action.__call__.""" + + async def fake_call(self, **kwargs): + self._update_params(kwargs) + + with patch( + "wmspro.action.Action.__call__", + fake_call, + ) as mock_action_call: + yield mock_action_call diff --git a/tests/components/wmspro/fixtures/example_config_prod.json b/tests/components/wmspro/fixtures/example_config_prod.json new file mode 100644 index 00000000000..6e313b566f7 --- /dev/null +++ b/tests/components/wmspro/fixtures/example_config_prod.json @@ -0,0 +1,77 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 58717, + "animationType": 1, + "names": ["Markise", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 0, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + }, + { + "id": 97358, + "animationType": 6, + "names": ["Licht", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 8, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 17, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 20, + "actionType": 4, + "actionDescription": 6 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + } + ] + } + ], + "rooms": [ + { + "id": 19239, + "name": "Terrasse", + "destinations": [58717, 97358], + "scenes": [687471, 765095] + } + ], + "scenes": [ + { + "id": 687471, + "names": ["Licht an", "", "", ""] + }, + { + "id": 765095, + "names": ["Licht aus", "", "", ""] + } + ] +} diff --git a/tests/components/wmspro/fixtures/example_config_test.json b/tests/components/wmspro/fixtures/example_config_test.json new file mode 100644 index 00000000000..1bb63e089ad --- /dev/null +++ b/tests/components/wmspro/fixtures/example_config_test.json @@ -0,0 +1,75 @@ +{ + "command": "getConfiguration", + "protocolVersion": "1.0.0", + "destinations": [ + { + "id": 17776, + "animationType": 0, + "names": ["Küche", "", "", ""], + "actions": [ + { + "id": 0, + "actionType": 0, + "actionDescription": 2, + "minValue": 0, + "maxValue": 100 + }, + { + "id": 6, + "actionType": 2, + "actionDescription": 3, + "minValue": -127, + "maxValue": 127 + }, + { + "id": 16, + "actionType": 6, + "actionDescription": 12 + }, + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + }, + { + "id": 23, + "actionType": 7, + "actionDescription": 12 + } + ] + }, + { + "id": 200951, + "animationType": 999, + "names": ["Aktor Potentialfrei", "", "", ""], + "actions": [ + { + "id": 22, + "actionType": 8, + "actionDescription": 13 + }, + { + "id": 26, + "actionType": 9, + "actionDescription": 999, + "minValue": 0, + "maxValue": 16 + } + ] + } + ], + "rooms": [ + { + "id": 42581, + "name": "Raum 0", + "destinations": [17776, 116682, 194367, 200951], + "scenes": [688966] + } + ], + "scenes": [ + { + "id": 688966, + "names": ["Gute Nacht", "", "", ""] + } + ] +} diff --git a/tests/components/wmspro/fixtures/example_status_prod_awning.json b/tests/components/wmspro/fixtures/example_status_prod_awning.json new file mode 100644 index 00000000000..6ca697a4532 --- /dev/null +++ b/tests/components/wmspro/fixtures/example_status_prod_awning.json @@ -0,0 +1,22 @@ +{ + "command": "getStatus", + "protocolVersion": "1.0.0", + "details": [ + { + "destinationId": 58717, + "data": { + "drivingCause": 0, + "heartbeatError": false, + "blocking": false, + "productData": [ + { + "actionId": 0, + "value": { + "percentage": 100 + } + } + ] + } + } + ] +} diff --git a/tests/components/wmspro/snapshots/test_cover.ambr b/tests/components/wmspro/snapshots/test_cover.ambr new file mode 100644 index 00000000000..21042789c16 --- /dev/null +++ b/tests/components/wmspro/snapshots/test_cover.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_cover_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'terrasse', + 'config_entries': , + 'configuration_url': 'http://webcontrol/control', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'wmspro', + '58717', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'WAREMA Renkhoff SE', + 'model': 'Awning', + 'model_id': None, + 'name': 'Markise', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': '58717', + 'suggested_area': 'Terrasse', + 'sw_version': None, + 'via_device_id': , + }) +# --- +# name: test_cover_update + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by WMS WebControl pro API', + 'current_position': 100, + 'device_class': 'awning', + 'friendly_name': 'Markise', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.markise', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py new file mode 100644 index 00000000000..6a254a93836 --- /dev/null +++ b/tests/components/wmspro/test_config_flow.py @@ -0,0 +1,235 @@ +"""Test the wmspro config flow.""" + +from unittest.mock import AsyncMock, patch + +import aiohttp + +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.components.wmspro.const import DOMAIN +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_config_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we can handle user-input to create a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_from_dhcp( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we can handle DHCP discovery to create a config entry.""" + info = DhcpServiceInfo( + ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=info + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_from_dhcp_add_mac( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can use DHCP discovery to add MAC address to a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id is None + + info = DhcpServiceInfo( + ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=info + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == "00:11:22:33:44:55" + + +async def test_config_flow_ping_failed( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle ping failed error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + side_effect=aiohttp.ClientError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_unknown_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle an unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + side_effect=RuntimeError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + with patch( + "wmspro.webcontrol.WebControlPro.ping", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.2.3.4", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "1.2.3.4" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/wmspro/test_cover.py b/tests/components/wmspro/test_cover.py new file mode 100644 index 00000000000..1e8653335a7 --- /dev/null +++ b/tests/components/wmspro/test_cover.py @@ -0,0 +1,226 @@ +"""Test the wmspro diagnostics.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.wmspro.const import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import setup_config_entry + +from tests.common import MockConfigEntry + + +async def test_cover_device( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that a cover device is created correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) == 2 + + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "58717")}) + assert device_entry is not None + assert device_entry == snapshot + + +async def test_cover_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that a cover entity is created and updated correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) == 2 + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity == snapshot + + await async_setup_component(hass, "homeassistant", {}) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + assert len(mock_hub_status_prod_awning.mock_calls) == 3 + + +async def test_cover_close_and_open( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + mock_action_call: AsyncMock, +) -> None: + """Test that a cover entity is opened and closed correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 100 + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + + await hass.services.async_call( + Platform.COVER, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "closed" + assert entity.attributes["current_position"] == 0 + assert len(mock_hub_status_prod_awning.mock_calls) == before + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + + await hass.services.async_call( + Platform.COVER, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 100 + assert len(mock_hub_status_prod_awning.mock_calls) == before + + +async def test_cover_move( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + mock_action_call: AsyncMock, +) -> None: + """Test that a cover entity is moved and closed correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 100 + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + + await hass.services.async_call( + Platform.COVER, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity.entity_id, "position": 50}, + blocking=True, + ) + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 50 + assert len(mock_hub_status_prod_awning.mock_calls) == before + + +async def test_cover_move_and_stop( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_hub_status_prod_awning: AsyncMock, + mock_action_call: AsyncMock, +) -> None: + """Test that a cover entity is moved and closed correctly.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_hub_status_prod_awning.mock_calls) >= 1 + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 100 + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + + await hass.services.async_call( + Platform.COVER, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: entity.entity_id, "position": 80}, + blocking=True, + ) + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 80 + assert len(mock_hub_status_prod_awning.mock_calls) == before + + with patch( + "wmspro.destination.Destination.refresh", + return_value=True, + ): + before = len(mock_hub_status_prod_awning.mock_calls) + + await hass.services.async_call( + Platform.COVER, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity.entity_id}, + blocking=True, + ) + + entity = hass.states.get("cover.markise") + assert entity is not None + assert entity.state == "open" + assert entity.attributes["current_position"] == 80 + assert len(mock_hub_status_prod_awning.mock_calls) == before diff --git a/tests/components/wmspro/test_init.py b/tests/components/wmspro/test_init.py new file mode 100644 index 00000000000..aeb5f3db152 --- /dev/null +++ b/tests/components/wmspro/test_init.py @@ -0,0 +1,38 @@ +"""Test the wmspro initialization.""" + +from unittest.mock import AsyncMock + +import aiohttp + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_config_entry + +from tests.common import MockConfigEntry + + +async def test_config_entry_device_config_ping_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, +) -> None: + """Test that a config entry will be retried due to ConfigEntryNotReady.""" + mock_hub_ping.side_effect = aiohttp.ClientError + await setup_config_entry(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert len(mock_hub_ping.mock_calls) == 1 + + +async def test_config_entry_device_config_refresh_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_refresh: AsyncMock, +) -> None: + """Test that a config entry will be retried due to ConfigEntryNotReady.""" + mock_hub_refresh.side_effect = aiohttp.ClientError + await setup_config_entry(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_refresh.mock_calls) == 1 From 7ada2f864cb7fc68d2576d979af5c21d33859c37 Mon Sep 17 00:00:00 2001 From: xLarry Date: Mon, 16 Sep 2024 16:21:16 +0200 Subject: [PATCH 0722/1309] Add sensor platform to laundrify integration (#121378) * feat: initial implementation of sensor platform * refactor(tests): await setup of config_entry in parent function * feat(tests): add tests for laundrify sensor platform * refactor: set name property for laundrify binary_sensor * refactor(tests): add missing type hints * refactor(tests): remove global change of the logging level * refactor: address minor changes from code review * refactor(tests): transform setup_config_entry into fixture * refactor: leverage entity descriptions to define common entity properties * refactor: change native unit to Wh * fix(tests): use fixture to create the config entry * fix: remove redundant raise of LaundrifyDeviceException * fix(tests): raise a LaundrifyDeviceException to test the update failure behavior * refactor(tests): merge several library fixtures into a single one * refactor(tests): create a separate UpdateCoordinator instead of using the internal * refactor(tests): avoid using LaundrifyPowerSensor * refactor: simplify value retrieval by directly accessing the coordinator * refactor: remove non-raising code from try-block * refactor(sensor): revert usage of entity descriptions * refactor(sensor): consolidate common attributes and init func to LaundrifyBaseSensor * refactor(sensor): instantiate DeviceInfo obj instead of using dict * refactor(tests): use freezer to trigger coordinator update * refactor(tests): assert on entity state instead of coordinator * refactor(tests): make use of freezer * chore(tests): typo in comment --- .../components/laundrify/__init__.py | 2 +- .../components/laundrify/binary_sensor.py | 1 - homeassistant/components/laundrify/sensor.py | 99 +++++++++++++++++++ tests/components/laundrify/__init__.py | 21 ---- tests/components/laundrify/conftest.py | 76 ++++++++------ .../laundrify/fixtures/machines.json | 3 +- .../components/laundrify/test_config_flow.py | 42 ++++---- .../components/laundrify/test_coordinator.py | 76 ++++++++------ tests/components/laundrify/test_init.py | 48 +++++---- tests/components/laundrify/test_sensor.py | 94 ++++++++++++++++++ 10 files changed, 331 insertions(+), 131 deletions(-) create mode 100644 homeassistant/components/laundrify/sensor.py create mode 100644 tests/components/laundrify/test_sensor.py diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index 9eb15625319..33d66c7748e 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_POLL_INTERVAL, DOMAIN from .coordinator import LaundrifyUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index c94c943a17d..cee6aa6c754 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -44,7 +44,6 @@ class LaundrifyPowerPlug( _attr_device_class = BinarySensorDeviceClass.RUNNING _attr_unique_id: str _attr_has_entity_name = True - _attr_name = None _attr_translation_key = "wash_cycle" def __init__( diff --git a/homeassistant/components/laundrify/sensor.py b/homeassistant/components/laundrify/sensor.py new file mode 100644 index 00000000000..98169f95fce --- /dev/null +++ b/homeassistant/components/laundrify/sensor.py @@ -0,0 +1,99 @@ +"""Platform for sensor integration.""" + +import logging + +from laundrify_aio import LaundrifyDevice +from laundrify_aio.exceptions import LaundrifyDeviceException + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LaundrifyUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add power sensor for passed config_entry in HA.""" + + coordinator: LaundrifyUpdateCoordinator = hass.data[DOMAIN][config.entry_id][ + "coordinator" + ] + + sensor_entities: list[LaundrifyPowerSensor | LaundrifyEnergySensor] = [] + for device in coordinator.data.values(): + sensor_entities.append(LaundrifyPowerSensor(device)) + sensor_entities.append(LaundrifyEnergySensor(coordinator, device)) + + async_add_entities(sensor_entities) + + +class LaundrifyBaseSensor(SensorEntity): + """Base class for Laundrify sensors.""" + + _attr_has_entity_name = True + + def __init__(self, device: LaundrifyDevice) -> None: + """Initialize the sensor.""" + self._device = device + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device.id)}) + self._attr_unique_id = f"{device.id}_{self._attr_device_class}" + + +class LaundrifyPowerSensor(LaundrifyBaseSensor): + """Representation of a Power sensor.""" + + _attr_device_class = SensorDeviceClass.POWER + _attr_native_unit_of_measurement = UnitOfPower.WATT + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_suggested_display_precision = 0 + + async def async_update(self) -> None: + """Fetch latest power measurement from the device.""" + try: + power = await self._device.get_power() + except LaundrifyDeviceException as err: + _LOGGER.debug("Couldn't load power for %s: %s", self._attr_unique_id, err) + self._attr_available = False + else: + _LOGGER.debug("Retrieved power for %s: %s", self._attr_unique_id, power) + if power is not None: + self._attr_available = True + self._attr_native_value = power + + +class LaundrifyEnergySensor( + CoordinatorEntity[LaundrifyUpdateCoordinator], LaundrifyBaseSensor +): + """Representation of an Energy sensor.""" + + _attr_device_class = SensorDeviceClass.ENERGY + _attr_native_unit_of_measurement = UnitOfEnergy.WATT_HOUR + _attr_state_class = SensorStateClass.TOTAL + _attr_suggested_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_suggested_display_precision = 2 + + def __init__( + self, coordinator: LaundrifyUpdateCoordinator, device: LaundrifyDevice + ) -> None: + """Initialize the sensor.""" + CoordinatorEntity.__init__(self, coordinator) + LaundrifyBaseSensor.__init__(self, device) + + @property + def native_value(self) -> float: + """Return the total energy of the device.""" + device = self.coordinator.data[self._device.id] + return float(device.totalEnergy) diff --git a/tests/components/laundrify/__init__.py b/tests/components/laundrify/__init__.py index c09c6290adf..cb4ab1ad010 100644 --- a/tests/components/laundrify/__init__.py +++ b/tests/components/laundrify/__init__.py @@ -1,22 +1 @@ """Tests for the laundrify integration.""" - -from homeassistant.components.laundrify import DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.core import HomeAssistant - -from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID - -from tests.common import MockConfigEntry - - -def create_entry( - hass: HomeAssistant, access_token: str = VALID_ACCESS_TOKEN -) -> MockConfigEntry: - """Create laundrify entry in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=VALID_ACCOUNT_ID, - data={CONF_ACCESS_TOKEN: access_token}, - ) - entry.add_to_hass(hass) - return entry diff --git a/tests/components/laundrify/conftest.py b/tests/components/laundrify/conftest.py index 2f6496c06a5..d60fe3f090b 100644 --- a/tests/components/laundrify/conftest.py +++ b/tests/components/laundrify/conftest.py @@ -1,59 +1,75 @@ """Configure py.test.""" import json -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from laundrify_aio import LaundrifyAPI, LaundrifyDevice import pytest +from homeassistant.components.laundrify import DOMAIN +from homeassistant.components.laundrify.const import MANUFACTURER +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID -from tests.common import load_fixture +from tests.common import MockConfigEntry, load_fixture +from tests.typing import ClientSessionGenerator -@pytest.fixture(name="laundrify_setup_entry") -def laundrify_setup_entry_fixture(): - """Mock laundrify setup entry function.""" - with patch( - "homeassistant.components.laundrify.async_setup_entry", return_value=True - ) as mock_setup_entry: - yield mock_setup_entry +@pytest.fixture(name="mock_device") +def laundrify_sensor_fixture() -> LaundrifyDevice: + """Return a default Laundrify power sensor mock.""" + # Load test data from machines.json + machine_data = json.loads(load_fixture("laundrify/machines.json"))[0] + + mock_device = AsyncMock(spec=LaundrifyDevice) + mock_device.id = machine_data["id"] + mock_device.manufacturer = MANUFACTURER + mock_device.model = machine_data["model"] + mock_device.name = machine_data["name"] + mock_device.firmwareVersion = machine_data["firmwareVersion"] + return mock_device -@pytest.fixture(name="laundrify_exchange_code") -def laundrify_exchange_code_fixture(): - """Mock laundrify exchange_auth_code function.""" - with patch( - "laundrify_aio.LaundrifyAPI.exchange_auth_code", - return_value=VALID_ACCESS_TOKEN, - ) as exchange_code_mock: - yield exchange_code_mock - - -@pytest.fixture(name="laundrify_validate_token") -def laundrify_validate_token_fixture(): - """Mock laundrify validate_token function.""" - with patch( - "laundrify_aio.LaundrifyAPI.validate_token", - return_value=True, - ) as validate_token_mock: - yield validate_token_mock +@pytest.fixture(name="laundrify_config_entry") +async def laundrify_setup_config_entry( + hass: HomeAssistant, access_token: str = VALID_ACCESS_TOKEN +) -> MockConfigEntry: + """Create laundrify entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=VALID_ACCOUNT_ID, + data={CONF_ACCESS_TOKEN: access_token}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry @pytest.fixture(name="laundrify_api_mock", autouse=True) -def laundrify_api_fixture(laundrify_exchange_code, laundrify_validate_token): +def laundrify_api_fixture(hass_client: ClientSessionGenerator): """Mock valid laundrify API responses.""" with ( patch( "laundrify_aio.LaundrifyAPI.get_account_id", return_value=VALID_ACCOUNT_ID, ), + patch( + "laundrify_aio.LaundrifyAPI.validate_token", + return_value=True, + ), + patch( + "laundrify_aio.LaundrifyAPI.exchange_auth_code", + return_value=VALID_ACCESS_TOKEN, + ), patch( "laundrify_aio.LaundrifyAPI.get_machines", return_value=[ LaundrifyDevice(machine, LaundrifyAPI) for machine in json.loads(load_fixture("laundrify/machines.json")) ], - ) as get_machines_mock, + ), ): - yield get_machines_mock + yield LaundrifyAPI(VALID_ACCESS_TOKEN, hass_client) diff --git a/tests/components/laundrify/fixtures/machines.json b/tests/components/laundrify/fixtures/machines.json index 3397212659f..4319e76880e 100644 --- a/tests/components/laundrify/fixtures/machines.json +++ b/tests/components/laundrify/fixtures/machines.json @@ -5,6 +5,7 @@ "status": "OFF", "internalIP": "192.168.0.123", "model": "SU02", - "firmwareVersion": "2.1.0" + "firmwareVersion": "2.1.0", + "totalEnergy": 1337.0 } ] diff --git a/tests/components/laundrify/test_config_flow.py b/tests/components/laundrify/test_config_flow.py index 8bb8211195c..656fadf087f 100644 --- a/tests/components/laundrify/test_config_flow.py +++ b/tests/components/laundrify/test_config_flow.py @@ -8,11 +8,12 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import create_entry from .const import VALID_ACCESS_TOKEN, VALID_AUTH_CODE, VALID_USER_INPUT +from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant, laundrify_setup_entry) -> None: + +async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -31,14 +32,11 @@ async def test_form(hass: HomeAssistant, laundrify_setup_entry) -> None: assert result["data"] == { CONF_ACCESS_TOKEN: VALID_ACCESS_TOKEN, } - assert len(laundrify_setup_entry.mock_calls) == 1 -async def test_form_invalid_format( - hass: HomeAssistant, laundrify_exchange_code -) -> None: +async def test_form_invalid_format(hass: HomeAssistant, laundrify_api_mock) -> None: """Test we handle invalid format.""" - laundrify_exchange_code.side_effect = exceptions.InvalidFormat + laundrify_api_mock.exchange_auth_code.side_effect = exceptions.InvalidFormat result = await hass.config_entries.flow.async_init( DOMAIN, @@ -50,9 +48,9 @@ async def test_form_invalid_format( assert result["errors"] == {CONF_CODE: "invalid_format"} -async def test_form_invalid_auth(hass: HomeAssistant, laundrify_exchange_code) -> None: +async def test_form_invalid_auth(hass: HomeAssistant, laundrify_api_mock) -> None: """Test we handle invalid auth.""" - laundrify_exchange_code.side_effect = exceptions.UnknownAuthCode + laundrify_api_mock.exchange_auth_code.side_effect = exceptions.UnknownAuthCode result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, @@ -63,11 +61,11 @@ async def test_form_invalid_auth(hass: HomeAssistant, laundrify_exchange_code) - assert result["errors"] == {CONF_CODE: "invalid_auth"} -async def test_form_cannot_connect( - hass: HomeAssistant, laundrify_exchange_code -) -> None: +async def test_form_cannot_connect(hass: HomeAssistant, laundrify_api_mock) -> None: """Test we handle cannot connect error.""" - laundrify_exchange_code.side_effect = exceptions.ApiConnectionException + laundrify_api_mock.exchange_auth_code.side_effect = ( + exceptions.ApiConnectionException + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, @@ -78,11 +76,9 @@ async def test_form_cannot_connect( assert result["errors"] == {"base": "cannot_connect"} -async def test_form_unkown_exception( - hass: HomeAssistant, laundrify_exchange_code -) -> None: +async def test_form_unkown_exception(hass: HomeAssistant, laundrify_api_mock) -> None: """Test we handle all other errors.""" - laundrify_exchange_code.side_effect = Exception + laundrify_api_mock.exchange_auth_code.side_effect = Exception result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, @@ -93,10 +89,11 @@ async def test_form_unkown_exception( assert result["errors"] == {"base": "unknown"} -async def test_step_reauth(hass: HomeAssistant) -> None: +async def test_step_reauth( + hass: HomeAssistant, laundrify_config_entry: MockConfigEntry +) -> None: """Test the reauth form is shown.""" - config_entry = create_entry(hass) - result = await config_entry.start_reauth_flow(hass) + result = await laundrify_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -110,9 +107,10 @@ async def test_step_reauth(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM -async def test_integration_already_exists(hass: HomeAssistant) -> None: +async def test_integration_already_exists( + hass: HomeAssistant, laundrify_config_entry: MockConfigEntry +) -> None: """Test we only allow a single config flow.""" - create_entry(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) diff --git a/tests/components/laundrify/test_coordinator.py b/tests/components/laundrify/test_coordinator.py index 0a395c736de..64b486d1285 100644 --- a/tests/components/laundrify/test_coordinator.py +++ b/tests/components/laundrify/test_coordinator.py @@ -1,52 +1,70 @@ """Test the laundrify coordinator.""" -from laundrify_aio import exceptions +from datetime import timedelta -from homeassistant.components.laundrify.const import DOMAIN -from homeassistant.core import HomeAssistant +from freezegun.api import FrozenDateTimeFactory +from laundrify_aio import LaundrifyDevice, exceptions -from . import create_entry +from homeassistant.components.laundrify.const import DEFAULT_POLL_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant, State +from homeassistant.util import slugify + +from tests.common import async_fire_time_changed -async def test_coordinator_update_success(hass: HomeAssistant) -> None: +def get_coord_entity(hass: HomeAssistant, mock_device: LaundrifyDevice) -> State: + """Get the coordinated energy sensor entity.""" + device_slug = slugify(mock_device.name, separator="_") + return hass.states.get(f"sensor.{device_slug}_energy") + + +async def test_coordinator_update_success( + hass: HomeAssistant, + laundrify_config_entry, + mock_device: LaundrifyDevice, + freezer: FrozenDateTimeFactory, +) -> None: """Test the coordinator update is performed successfully.""" - config_entry = create_entry(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] - await coordinator.async_refresh() + freezer.tick(timedelta(seconds=DEFAULT_POLL_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() - assert coordinator.last_update_success + coord_entity = get_coord_entity(hass, mock_device) + assert coord_entity.state != STATE_UNAVAILABLE async def test_coordinator_update_unauthorized( - hass: HomeAssistant, laundrify_api_mock + hass: HomeAssistant, + laundrify_config_entry, + laundrify_api_mock, + mock_device: LaundrifyDevice, + freezer: FrozenDateTimeFactory, ) -> None: """Test the coordinator update fails if an UnauthorizedException is thrown.""" - config_entry = create_entry(hass) - await hass.config_entries.async_setup(config_entry.entry_id) + laundrify_api_mock.get_machines.side_effect = exceptions.UnauthorizedException + + freezer.tick(timedelta(seconds=DEFAULT_POLL_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] - laundrify_api_mock.side_effect = exceptions.UnauthorizedException - await coordinator.async_refresh() - await hass.async_block_till_done() - - assert not coordinator.last_update_success + coord_entity = get_coord_entity(hass, mock_device) + assert coord_entity.state == STATE_UNAVAILABLE async def test_coordinator_update_connection_failed( - hass: HomeAssistant, laundrify_api_mock + hass: HomeAssistant, + laundrify_config_entry, + laundrify_api_mock, + mock_device: LaundrifyDevice, + freezer: FrozenDateTimeFactory, ) -> None: """Test the coordinator update fails if an ApiConnectionException is thrown.""" - config_entry = create_entry(hass) - await hass.config_entries.async_setup(config_entry.entry_id) + laundrify_api_mock.get_machines.side_effect = exceptions.ApiConnectionException + + freezer.tick(timedelta(seconds=DEFAULT_POLL_INTERVAL)) + async_fire_time_changed(hass) await hass.async_block_till_done() - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] - laundrify_api_mock.side_effect = exceptions.ApiConnectionException - await coordinator.async_refresh() - await hass.async_block_till_done() - - assert not coordinator.last_update_success + coord_entity = get_coord_entity(hass, mock_device) + assert coord_entity.state == STATE_UNAVAILABLE diff --git a/tests/components/laundrify/test_init.py b/tests/components/laundrify/test_init.py index e3ec54a3225..a23f1a3bc82 100644 --- a/tests/components/laundrify/test_init.py +++ b/tests/components/laundrify/test_init.py @@ -6,54 +6,50 @@ from homeassistant.components.laundrify.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import create_entry +from tests.common import MockConfigEntry async def test_setup_entry_api_unauthorized( - hass: HomeAssistant, laundrify_validate_token + hass: HomeAssistant, + laundrify_api_mock, + laundrify_config_entry: MockConfigEntry, ) -> None: """Test that ConfigEntryAuthFailed is thrown when authentication fails.""" - laundrify_validate_token.side_effect = exceptions.UnauthorizedException - config_entry = create_entry(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + laundrify_api_mock.validate_token.side_effect = exceptions.UnauthorizedException + await hass.config_entries.async_reload(laundrify_config_entry.entry_id) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.SETUP_ERROR + assert laundrify_config_entry.state is ConfigEntryState.SETUP_ERROR assert not hass.data.get(DOMAIN) async def test_setup_entry_api_cannot_connect( - hass: HomeAssistant, laundrify_validate_token + hass: HomeAssistant, + laundrify_api_mock, + laundrify_config_entry: MockConfigEntry, ) -> None: """Test that ApiConnectionException is thrown when connection fails.""" - laundrify_validate_token.side_effect = exceptions.ApiConnectionException - config_entry = create_entry(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + laundrify_api_mock.validate_token.side_effect = exceptions.ApiConnectionException + await hass.config_entries.async_reload(laundrify_config_entry.entry_id) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert laundrify_config_entry.state is ConfigEntryState.SETUP_RETRY assert not hass.data.get(DOMAIN) -async def test_setup_entry_successful(hass: HomeAssistant) -> None: +async def test_setup_entry_successful( + hass: HomeAssistant, laundrify_config_entry: MockConfigEntry +) -> None: """Test entry can be setup successfully.""" - config_entry = create_entry(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.LOADED + assert laundrify_config_entry.state is ConfigEntryState.LOADED -async def test_setup_entry_unload(hass: HomeAssistant) -> None: +async def test_setup_entry_unload( + hass: HomeAssistant, laundrify_config_entry: MockConfigEntry +) -> None: """Test unloading the laundrify entry.""" - config_entry = create_entry(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.config_entries.async_unload(config_entry.entry_id) + await hass.config_entries.async_unload(laundrify_config_entry.entry_id) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.NOT_LOADED + assert laundrify_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/laundrify/test_sensor.py b/tests/components/laundrify/test_sensor.py new file mode 100644 index 00000000000..49b60200c1d --- /dev/null +++ b/tests/components/laundrify/test_sensor.py @@ -0,0 +1,94 @@ +"""Test the laundrify sensor platform.""" + +from datetime import timedelta +import logging +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from laundrify_aio import LaundrifyDevice +from laundrify_aio.exceptions import LaundrifyDeviceException +import pytest + +from homeassistant.components.laundrify.const import ( + DEFAULT_POLL_INTERVAL, + DOMAIN, + MODELS, +) +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.util import slugify + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_laundrify_sensor_init( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_device: LaundrifyDevice, + laundrify_config_entry: MockConfigEntry, +) -> None: + """Test Laundrify sensor default state.""" + device_slug = slugify(mock_device.name, separator="_") + + state = hass.states.get(f"sensor.{device_slug}_power") + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER + assert state.state == STATE_UNKNOWN + + device = device_registry.async_get_device({(DOMAIN, mock_device.id)}) + assert device is not None + assert device.name == mock_device.name + assert device.identifiers == {(DOMAIN, mock_device.id)} + assert device.manufacturer == mock_device.manufacturer + assert device.model == MODELS[mock_device.model] + assert device.sw_version == mock_device.firmwareVersion + + +async def test_laundrify_sensor_update( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_device: LaundrifyDevice, + laundrify_config_entry: MockConfigEntry, +) -> None: + """Test Laundrify sensor update.""" + device_slug = slugify(mock_device.name, separator="_") + + state = hass.states.get(f"sensor.{device_slug}_power") + assert state.state == STATE_UNKNOWN + + with patch("laundrify_aio.LaundrifyDevice.get_power", return_value=95): + freezer.tick(timedelta(seconds=DEFAULT_POLL_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(f"sensor.{device_slug}_power") + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfPower.WATT + assert state.state == "95" + + +async def test_laundrify_sensor_update_failure( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, + mock_device: LaundrifyDevice, + laundrify_config_entry: MockConfigEntry, +) -> None: + """Test that update failures are logged.""" + caplog.set_level(logging.DEBUG) + + # test get_power() to raise a LaundrifyDeviceException + with patch( + "laundrify_aio.LaundrifyDevice.get_power", + side_effect=LaundrifyDeviceException("Raising error to test update failure."), + ): + freezer.tick(timedelta(seconds=DEFAULT_POLL_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert f"Couldn't load power for {mock_device.id}_power" in caplog.text From b73be2df6e4e12fc5116adc5c7edffbab3e64259 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 16 Sep 2024 20:01:12 +0200 Subject: [PATCH 0723/1309] Implement model_id's in Plugwise (#126069) --- homeassistant/components/plugwise/__init__.py | 3 ++- homeassistant/components/plugwise/entity.py | 1 + tests/components/plugwise/conftest.py | 11 ++++++++++ tests/components/plugwise/test_init.py | 22 +++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index de2250ac72e..f7677e39f7a 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -31,9 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) -> identifiers={(DOMAIN, str(coordinator.api.gateway_id))}, manufacturer="Plugwise", model=coordinator.api.smile_model, + model_id=coordinator.api.smile_model_id, name=coordinator.api.smile_name, sw_version=coordinator.api.smile_version[0], - ) + ) # required for adding the entity-less P1 Gateway await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index b2562ef8f39..e24f3d1e1bb 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -47,6 +47,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): connections=connections, manufacturer=data.get("vendor"), model=data.get("model"), + model_id=data.get("model_id"), name=coordinator.data.gateway["smile_name"], sw_version=data.get("firmware"), hw_version=data.get("hardware"), diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index ec857a965e5..825a82e7595 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -65,6 +65,7 @@ def mock_smile_config_flow() -> Generator[MagicMock]: smile = smile_mock.return_value smile.smile_hostname = "smile12345" smile.smile_model = "Test Model" + smile.smile_model_id = "Test Model ID" smile.smile_name = "Test Smile Name" smile.connect.return_value = True yield smile @@ -86,6 +87,7 @@ def mock_smile_adam() -> Generator[MagicMock]: smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" + smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" smile.connect.return_value = True all_data = _read_json(chosen_env, "all_data") @@ -112,6 +114,7 @@ def mock_smile_adam_2() -> Generator[MagicMock]: smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" + smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" smile.connect.return_value = True all_data = _read_json(chosen_env, "all_data") @@ -138,6 +141,7 @@ def mock_smile_adam_3() -> Generator[MagicMock]: smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" + smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" smile.connect.return_value = True all_data = _read_json(chosen_env, "all_data") @@ -164,6 +168,7 @@ def mock_smile_adam_4() -> Generator[MagicMock]: smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" + smile.smile_model_id = "smile_open_therm" smile.smile_name = "Adam" smile.connect.return_value = True all_data = _read_json(chosen_env, "all_data") @@ -189,6 +194,7 @@ def mock_smile_anna() -> Generator[MagicMock]: smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" + smile.smile_model_id = "smile_thermo" smile.smile_name = "Smile Anna" smile.connect.return_value = True all_data = _read_json(chosen_env, "all_data") @@ -214,6 +220,7 @@ def mock_smile_anna_2() -> Generator[MagicMock]: smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" + smile.smile_model_id = "smile_thermo" smile.smile_name = "Smile Anna" smile.connect.return_value = True all_data = _read_json(chosen_env, "all_data") @@ -239,6 +246,7 @@ def mock_smile_anna_3() -> Generator[MagicMock]: smile.smile_type = "thermostat" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" + smile.smile_model_id = "smile_thermo" smile.smile_name = "Smile Anna" smile.connect.return_value = True all_data = _read_json(chosen_env, "all_data") @@ -264,6 +272,7 @@ def mock_smile_p1() -> Generator[MagicMock]: smile.smile_type = "power" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" + smile.smile_model_id = "smile" smile.smile_name = "Smile P1" smile.connect.return_value = True all_data = _read_json(chosen_env, "all_data") @@ -289,6 +298,7 @@ def mock_smile_p1_2() -> Generator[MagicMock]: smile.smile_type = "power" smile.smile_hostname = "smile98765" smile.smile_model = "Gateway" + smile.smile_model_id = "smile" smile.smile_name = "Smile P1" smile.connect.return_value = True all_data = _read_json(chosen_env, "all_data") @@ -314,6 +324,7 @@ def mock_stretch() -> Generator[MagicMock]: smile.smile_type = "stretch" smile.smile_hostname = "stretch98765" smile.smile_model = "Gateway" + smile.smile_model_id = None smile.smile_name = "Stretch" smile.connect.return_value = True all_data = _read_json(chosen_env, "all_data") diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 46ef7b89d09..65c9fb6c5a5 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -110,6 +110,28 @@ async def test_gateway_config_entry_not_ready( assert mock_config_entry.state is entry_state +async def test_device_in_dr( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smile_p1: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test Gateway device registry data.""" + mock_config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "a455b61e52394b2db5081ce025a430f3")} + ) + assert device_entry.hw_version == "AME Smile 2.0 board" + assert device_entry.manufacturer == "Plugwise" + assert device_entry.model == "Gateway" + assert device_entry.model_id == "smile" + assert device_entry.name == "Smile P1" + assert device_entry.sw_version == "4.4.2" + + @pytest.mark.parametrize( ("entitydata", "old_unique_id", "new_unique_id"), [ From 351de1ca72a27263e1ca5252594db5e5aad63512 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Sep 2024 20:21:04 +0200 Subject: [PATCH 0724/1309] Move and rename alert base entity to separate module (#126030) Move alert base entity to separate module --- homeassistant/components/alert/__init__.py | 209 +-------------------- homeassistant/components/alert/entity.py | 206 ++++++++++++++++++++ tests/components/alert/test_init.py | 2 +- 3 files changed, 212 insertions(+), 205 deletions(-) create mode 100644 homeassistant/components/alert/entity.py diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index c49e14f2c6f..12341c158c0 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -2,18 +2,8 @@ from __future__ import annotations -from collections.abc import Callable -from datetime import timedelta -from typing import Any - import voluptuous as vol -from homeassistant.components.notify import ( - ATTR_DATA, - ATTR_MESSAGE, - ATTR_TITLE, - DOMAIN as DOMAIN_NOTIFY, -) from homeassistant.const import ( CONF_ENTITY_ID, CONF_NAME, @@ -22,22 +12,12 @@ from homeassistant.const import ( SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_IDLE, - STATE_OFF, STATE_ON, ) -from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.event import ( - async_track_point_in_time, - async_track_state_change_event, -) -from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType -from homeassistant.util.dt import now from .const import ( CONF_ALERT_MESSAGE, @@ -52,6 +32,7 @@ from .const import ( DOMAIN, LOGGER, ) +from .entity import AlertEntity ALERT_SCHEMA = vol.Schema( { @@ -83,9 +64,9 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Alert component.""" - component = EntityComponent[Alert](LOGGER, DOMAIN, hass) + component = EntityComponent[AlertEntity](LOGGER, DOMAIN, hass) - entities: list[Alert] = [] + entities: list[AlertEntity] = [] for object_id, cfg in config[DOMAIN].items(): if not cfg: @@ -104,7 +85,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: data = cfg.get(CONF_DATA) entities.append( - Alert( + AlertEntity( hass, object_id, name, @@ -131,183 +112,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_add_entities(entities) return True - - -class Alert(Entity): - """Representation of an alert.""" - - _attr_should_poll = False - - def __init__( - self, - hass: HomeAssistant, - entity_id: str, - name: str, - watched_entity_id: str, - state: str, - repeat: list[float], - skip_first: bool, - message_template: Template | None, - done_message_template: Template | None, - notifiers: list[str], - can_ack: bool, - title_template: Template | None, - data: dict[Any, Any], - ) -> None: - """Initialize the alert.""" - self.hass = hass - self._attr_name = name - self._alert_state = state - self._skip_first = skip_first - self._data = data - - self._message_template = message_template - self._done_message_template = done_message_template - self._title_template = title_template - - self._notifiers = notifiers - self._can_ack = can_ack - - self._delay = [timedelta(minutes=val) for val in repeat] - self._next_delay = 0 - - self._firing = False - self._ack = False - self._cancel: Callable[[], None] | None = None - self._send_done_message = False - self.entity_id = f"{DOMAIN}.{entity_id}" - - async_track_state_change_event( - hass, [watched_entity_id], self.watched_entity_change - ) - - @property - def state(self) -> str: - """Return the alert status.""" - if self._firing: - if self._ack: - return STATE_OFF - return STATE_ON - return STATE_IDLE - - async def watched_entity_change(self, event: Event[EventStateChangedData]) -> None: - """Determine if the alert should start or stop.""" - if (to_state := event.data["new_state"]) is None: - return - LOGGER.debug("Watched entity (%s) has changed", event.data["entity_id"]) - if to_state.state == self._alert_state and not self._firing: - await self.begin_alerting() - if to_state.state != self._alert_state and self._firing: - await self.end_alerting() - - async def begin_alerting(self) -> None: - """Begin the alert procedures.""" - LOGGER.debug("Beginning Alert: %s", self._attr_name) - self._ack = False - self._firing = True - self._next_delay = 0 - - if not self._skip_first: - await self._notify() - else: - await self._schedule_notify() - - self.async_write_ha_state() - - async def end_alerting(self) -> None: - """End the alert procedures.""" - LOGGER.debug("Ending Alert: %s", self._attr_name) - if self._cancel is not None: - self._cancel() - self._cancel = None - - self._ack = False - self._firing = False - if self._send_done_message: - await self._notify_done_message() - self.async_write_ha_state() - - async def _schedule_notify(self) -> None: - """Schedule a notification.""" - delay = self._delay[self._next_delay] - next_msg = now() + delay - self._cancel = async_track_point_in_time( - self.hass, - HassJob( - self._notify, name="Schedule notify alert", cancel_on_shutdown=True - ), - next_msg, - ) - self._next_delay = min(self._next_delay + 1, len(self._delay) - 1) - - async def _notify(self, *args: Any) -> None: - """Send the alert notification.""" - if not self._firing: - return - - if not self._ack: - LOGGER.info("Alerting: %s", self._attr_name) - self._send_done_message = True - - if self._message_template is not None: - message = self._message_template.async_render(parse_result=False) - else: - message = self._attr_name - - await self._send_notification_message(message) - await self._schedule_notify() - - async def _notify_done_message(self) -> None: - """Send notification of complete alert.""" - LOGGER.info("Alerting: %s", self._done_message_template) - self._send_done_message = False - - if self._done_message_template is None: - return - - message = self._done_message_template.async_render(parse_result=False) - - await self._send_notification_message(message) - - async def _send_notification_message(self, message: Any) -> None: - if not self._notifiers: - return - - msg_payload = {ATTR_MESSAGE: message} - - if self._title_template is not None: - title = self._title_template.async_render(parse_result=False) - msg_payload[ATTR_TITLE] = title - if self._data: - msg_payload[ATTR_DATA] = self._data - - LOGGER.debug(msg_payload) - - for target in self._notifiers: - try: - await self.hass.services.async_call( - DOMAIN_NOTIFY, target, msg_payload, context=self._context - ) - except ServiceNotFound: - LOGGER.error( - "Failed to call notify.%s, retrying at next notification interval", - target, - ) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Async Unacknowledge alert.""" - LOGGER.debug("Reset Alert: %s", self._attr_name) - self._ack = False - self.async_write_ha_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Async Acknowledge alert.""" - LOGGER.debug("Acknowledged Alert: %s", self._attr_name) - self._ack = True - self.async_write_ha_state() - - async def async_toggle(self, **kwargs: Any) -> None: - """Async toggle alert.""" - if self._ack: - return await self.async_turn_on() - return await self.async_turn_off() diff --git a/homeassistant/components/alert/entity.py b/homeassistant/components/alert/entity.py new file mode 100644 index 00000000000..629047b15ba --- /dev/null +++ b/homeassistant/components/alert/entity.py @@ -0,0 +1,206 @@ +"""Support for repeating alerts when conditions are met.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta +from typing import Any + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as DOMAIN_NOTIFY, +) +from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON +from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant +from homeassistant.exceptions import ServiceNotFound +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import ( + async_track_point_in_time, + async_track_state_change_event, +) +from homeassistant.helpers.template import Template +from homeassistant.util.dt import now + +from .const import DOMAIN, LOGGER + + +class AlertEntity(Entity): + """Representation of an alert.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + entity_id: str, + name: str, + watched_entity_id: str, + state: str, + repeat: list[float], + skip_first: bool, + message_template: Template | None, + done_message_template: Template | None, + notifiers: list[str], + can_ack: bool, + title_template: Template | None, + data: dict[Any, Any], + ) -> None: + """Initialize the alert.""" + self.hass = hass + self._attr_name = name + self._alert_state = state + self._skip_first = skip_first + self._data = data + + self._message_template = message_template + self._done_message_template = done_message_template + self._title_template = title_template + + self._notifiers = notifiers + self._can_ack = can_ack + + self._delay = [timedelta(minutes=val) for val in repeat] + self._next_delay = 0 + + self._firing = False + self._ack = False + self._cancel: Callable[[], None] | None = None + self._send_done_message = False + self.entity_id = f"{DOMAIN}.{entity_id}" + + async_track_state_change_event( + hass, [watched_entity_id], self.watched_entity_change + ) + + @property + def state(self) -> str: + """Return the alert status.""" + if self._firing: + if self._ack: + return STATE_OFF + return STATE_ON + return STATE_IDLE + + async def watched_entity_change(self, event: Event[EventStateChangedData]) -> None: + """Determine if the alert should start or stop.""" + if (to_state := event.data["new_state"]) is None: + return + LOGGER.debug("Watched entity (%s) has changed", event.data["entity_id"]) + if to_state.state == self._alert_state and not self._firing: + await self.begin_alerting() + if to_state.state != self._alert_state and self._firing: + await self.end_alerting() + + async def begin_alerting(self) -> None: + """Begin the alert procedures.""" + LOGGER.debug("Beginning Alert: %s", self._attr_name) + self._ack = False + self._firing = True + self._next_delay = 0 + + if not self._skip_first: + await self._notify() + else: + await self._schedule_notify() + + self.async_write_ha_state() + + async def end_alerting(self) -> None: + """End the alert procedures.""" + LOGGER.debug("Ending Alert: %s", self._attr_name) + if self._cancel is not None: + self._cancel() + self._cancel = None + + self._ack = False + self._firing = False + if self._send_done_message: + await self._notify_done_message() + self.async_write_ha_state() + + async def _schedule_notify(self) -> None: + """Schedule a notification.""" + delay = self._delay[self._next_delay] + next_msg = now() + delay + self._cancel = async_track_point_in_time( + self.hass, + HassJob( + self._notify, name="Schedule notify alert", cancel_on_shutdown=True + ), + next_msg, + ) + self._next_delay = min(self._next_delay + 1, len(self._delay) - 1) + + async def _notify(self, *args: Any) -> None: + """Send the alert notification.""" + if not self._firing: + return + + if not self._ack: + LOGGER.info("Alerting: %s", self._attr_name) + self._send_done_message = True + + if self._message_template is not None: + message = self._message_template.async_render(parse_result=False) + else: + message = self._attr_name + + await self._send_notification_message(message) + await self._schedule_notify() + + async def _notify_done_message(self) -> None: + """Send notification of complete alert.""" + LOGGER.info("Alerting: %s", self._done_message_template) + self._send_done_message = False + + if self._done_message_template is None: + return + + message = self._done_message_template.async_render(parse_result=False) + + await self._send_notification_message(message) + + async def _send_notification_message(self, message: Any) -> None: + if not self._notifiers: + return + + msg_payload = {ATTR_MESSAGE: message} + + if self._title_template is not None: + title = self._title_template.async_render(parse_result=False) + msg_payload[ATTR_TITLE] = title + if self._data: + msg_payload[ATTR_DATA] = self._data + + LOGGER.debug(msg_payload) + + for target in self._notifiers: + try: + await self.hass.services.async_call( + DOMAIN_NOTIFY, target, msg_payload, context=self._context + ) + except ServiceNotFound: + LOGGER.error( + "Failed to call notify.%s, retrying at next notification interval", + target, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Async Unacknowledge alert.""" + LOGGER.debug("Reset Alert: %s", self._attr_name) + self._ack = False + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Async Acknowledge alert.""" + LOGGER.debug("Acknowledged Alert: %s", self._attr_name) + self._ack = True + self.async_write_ha_state() + + async def async_toggle(self, **kwargs: Any) -> None: + """Async toggle alert.""" + if self._ack: + return await self.async_turn_on() + return await self.async_turn_off() diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 31236c84f34..263fb69c883 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -337,7 +337,7 @@ async def test_skipfirst(hass: HomeAssistant, mock_notifier: list[ServiceCall]) async def test_done_message_state_tracker_reset_on_cancel(hass: HomeAssistant) -> None: """Test that the done message is reset when canceled.""" - entity = alert.Alert(hass, *TEST_NOACK) + entity = alert.AlertEntity(hass, *TEST_NOACK) entity._cancel = lambda *args: None assert entity._send_done_message is False entity._send_done_message = True From 529e1203135df5185ac0426e104e9c98e196c3f2 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:28:06 -0400 Subject: [PATCH 0725/1309] Remove callback decorators in Cambridge Audio (#126082) Remove callback decorator from async methods in Cambridge Audio --- homeassistant/components/cambridge_audio/__init__.py | 3 +-- homeassistant/components/cambridge_audio/entity.py | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cambridge_audio/__init__.py b/homeassistant/components/cambridge_audio/__init__.py index 0b8d02aefad..5060d12cfe1 100644 --- a/homeassistant/components/cambridge_audio/__init__.py +++ b/homeassistant/components/cambridge_audio/__init__.py @@ -10,7 +10,7 @@ from aiostreammagic.models import CallbackType from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import CONNECT_TIMEOUT, STREAM_MAGIC_EXCEPTIONS @@ -29,7 +29,6 @@ async def async_setup_entry( client = StreamMagicClient(entry.data[CONF_HOST]) - @callback async def _connection_update_callback( _client: StreamMagicClient, _callback_type: CallbackType ) -> None: diff --git a/homeassistant/components/cambridge_audio/entity.py b/homeassistant/components/cambridge_audio/entity.py index 7292f99f928..ac43a673725 100644 --- a/homeassistant/components/cambridge_audio/entity.py +++ b/homeassistant/components/cambridge_audio/entity.py @@ -7,13 +7,11 @@ from typing import Any, Concatenate from aiostreammagic import StreamMagicClient from aiostreammagic.models import CallbackType -from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from . import STREAM_MAGIC_EXCEPTIONS -from .const import DOMAIN +from .const import DOMAIN, STREAM_MAGIC_EXCEPTIONS def command[_EntityT: CambridgeAudioEntity, **_P]( @@ -51,7 +49,6 @@ class CambridgeAudioEntity(Entity): configuration_url=f"http://{client.host}", ) - @callback async def _state_update_callback( self, _client: StreamMagicClient, _callback_type: CallbackType ) -> None: From 738818aa7af7a26513b51bcf3b6c362c39afd8b1 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:42:27 -0400 Subject: [PATCH 0726/1309] Add media player stop support to Cambridge Audio (#126066) --- homeassistant/components/cambridge_audio/media_player.py | 1 + tests/components/cambridge_audio/test_media_player.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index c0287b9f8fa..1c490cd6ac9 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -45,6 +45,7 @@ TRANSPORT_FEATURES: dict[TransportControl, MediaPlayerEntityFeature] = { TransportControl.TOGGLE_REPEAT: MediaPlayerEntityFeature.REPEAT_SET, TransportControl.TOGGLE_SHUFFLE: MediaPlayerEntityFeature.SHUFFLE_SET, TransportControl.SEEK: MediaPlayerEntityFeature.SEEK, + TransportControl.STOP: MediaPlayerEntityFeature.STOP, } diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py index b344c2faa2b..391cdd868ec 100644 --- a/tests/components/cambridge_audio/test_media_player.py +++ b/tests/components/cambridge_audio/test_media_player.py @@ -26,6 +26,7 @@ from homeassistant.const import ( SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, + SERVICE_MEDIA_STOP, SERVICE_REPEAT_SET, SERVICE_SHUFFLE_SET, SERVICE_TURN_OFF, @@ -181,6 +182,7 @@ async def test_media_play_pause_stop( mock_stream_magic_client.now_playing.controls = [ TransportControl.PLAY, TransportControl.PAUSE, + TransportControl.STOP, ] await mock_state_update(mock_stream_magic_client) await hass.async_block_till_done() @@ -191,6 +193,9 @@ async def test_media_play_pause_stop( await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data, True) mock_stream_magic_client.play.assert_called_once() + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_STOP, data, True) + mock_stream_magic_client.stop.assert_called_once() + async def test_media_next_previous_track( hass: HomeAssistant, From dde989685c38a52c4bf220c3cc9c728c3e761c24 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 16 Sep 2024 21:34:07 -0500 Subject: [PATCH 0727/1309] Add Assist satellite configuration (#126063) * Basic implementation * Add websocket commands * Clean up * Add callback to other signatures * Remove unused constant * Re-add callback * Add callback to test --- .../components/assist_satellite/__init__.py | 9 +- .../components/assist_satellite/entity.py | 40 +++++++ .../assist_satellite/websocket_api.py | 84 +++++++++++++ .../components/esphome/assist_satellite.py | 15 ++- .../components/voip/assist_satellite.py | 14 +++ tests/components/assist_satellite/conftest.py | 29 ++++- .../assist_satellite/test_websocket_api.py | 112 ++++++++++++++++++ 7 files changed, 300 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 3d6e04bcc75..2d4459ffd8c 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -11,15 +11,22 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, AssistSatelliteEntityFeature -from .entity import AssistSatelliteEntity, AssistSatelliteEntityDescription +from .entity import ( + AssistSatelliteConfiguration, + AssistSatelliteEntity, + AssistSatelliteEntityDescription, + AssistSatelliteWakeWord, +) from .errors import SatelliteBusyError from .websocket_api import async_register_websocket_api __all__ = [ "DOMAIN", "AssistSatelliteEntity", + "AssistSatelliteConfiguration", "AssistSatelliteEntityDescription", "AssistSatelliteEntityFeature", + "AssistSatelliteWakeWord", "SatelliteBusyError", ] diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index c00cb26cb63..079d3ae2948 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -4,6 +4,7 @@ from abc import abstractmethod import asyncio from collections.abc import AsyncIterable import contextlib +from dataclasses import dataclass from enum import StrEnum import logging import time @@ -57,6 +58,34 @@ class AssistSatelliteEntityDescription(EntityDescription, frozen_or_thawed=True) """A class that describes Assist satellite entities.""" +@dataclass(frozen=True) +class AssistSatelliteWakeWord: + """Available wake word model.""" + + id: str + """Unique id for wake word model.""" + + wake_word: str + """Wake word phrase.""" + + trained_languages: list[str] + """List of languages that the wake word was trained on.""" + + +@dataclass +class AssistSatelliteConfiguration: + """Satellite configuration.""" + + available_wake_words: list[AssistSatelliteWakeWord] + """List of available available wake word models.""" + + active_wake_words: list[str] + """List of active wake word ids.""" + + max_active_wake_words: int + """Maximum number of simultaneous wake words allowed (0 for no limit).""" + + class AssistSatelliteEntity(entity.Entity): """Entity encapsulating the state and functionality of an Assist satellite.""" @@ -98,6 +127,17 @@ class AssistSatelliteEntity(entity.Entity): """Options passed for text-to-speech.""" return self._attr_tts_options + @callback + @abstractmethod + def async_get_configuration(self) -> AssistSatelliteConfiguration: + """Get the current satellite configuration.""" + + @abstractmethod + async def async_set_configuration( + self, config: AssistSatelliteConfiguration + ) -> None: + """Set the current satellite configuration.""" + async def async_intercept_wake_word(self) -> str | None: """Intercept the next wake word from the satellite. diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 8de10c8a9de..0d7a434dba5 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -1,5 +1,6 @@ """Assist satellite Websocket API.""" +from dataclasses import asdict, replace from typing import Any import voluptuous as vol @@ -18,6 +19,8 @@ from .entity import AssistSatelliteEntity def async_register_websocket_api(hass: HomeAssistant) -> None: """Register the websocket API.""" websocket_api.async_register_command(hass, websocket_intercept_wake_word) + websocket_api.async_register_command(hass, websocket_get_configuration) + websocket_api.async_register_command(hass, websocket_set_wake_words) @callback @@ -59,3 +62,84 @@ async def websocket_intercept_wake_word( task = hass.async_create_task(intercept_wake_word(), "intercept_wake_word") connection.subscriptions[msg["id"]] = task.cancel connection.send_message(websocket_api.result_message(msg["id"])) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_satellite/get_configuration", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + } +) +def websocket_get_configuration( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get the current satellite configuration.""" + component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] + satellite = component.get_entity(msg["entity_id"]) + if satellite is None: + connection.send_error( + msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" + ) + return + + config_dict = asdict(satellite.async_get_configuration()) + config_dict["pipeline_entity_id"] = satellite.pipeline_entity_id + config_dict["vad_entity_id"] = satellite.vad_sensitivity_entity_id + + connection.send_result(msg["id"], config_dict) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_satellite/set_wake_words", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + vol.Required("wake_word_ids"): [str], + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_set_wake_words( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Set the active wake words for the satellite.""" + component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] + satellite = component.get_entity(msg["entity_id"]) + if satellite is None: + connection.send_error( + msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" + ) + return + + config = satellite.async_get_configuration() + + # Don't set too many active wake words + actual_ids = msg["wake_word_ids"] + if len(actual_ids) > config.max_active_wake_words: + connection.send_error( + msg["id"], + websocket_api.ERR_NOT_SUPPORTED, + f"Maximum number of active wake words is {config.max_active_wake_words}", + ) + return + + # Verify all ids are available + available_ids = {ww.id for ww in config.available_wake_words} + for ww_id in actual_ids: + if ww_id not in available_ids: + connection.send_error( + msg["id"], + websocket_api.ERR_NOT_SUPPORTED, + f"Wake word id is not supported: {ww_id}", + ) + return + + await satellite.async_set_configuration( + replace(config, active_wake_words=actual_ids) + ) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 7ce46fab64b..3c66c82a734 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -36,7 +36,7 @@ from homeassistant.components.intent import ( from homeassistant.components.media_player import async_process_play_media_url from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -150,6 +150,19 @@ class EsphomeAssistSatellite( f"{self.entry_data.device_info.mac_address}-vad_sensitivity", ) + @callback + def async_get_configuration( + self, + ) -> assist_satellite.AssistSatelliteConfiguration: + """Get the current satellite configuration.""" + raise NotImplementedError + + async def async_set_configuration( + self, config: assist_satellite.AssistSatelliteConfiguration + ) -> None: + """Set the current satellite configuration.""" + raise NotImplementedError + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index f75f65a08ea..2f37a8a63e1 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -20,6 +20,7 @@ from homeassistant.components.assist_pipeline import ( PipelineNotFound, ) from homeassistant.components.assist_satellite import ( + AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityDescription, ) @@ -141,6 +142,19 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol assert self.voip_device.protocol == self self.voip_device.protocol = None + @callback + def async_get_configuration( + self, + ) -> AssistSatelliteConfiguration: + """Get the current satellite configuration.""" + raise NotImplementedError + + async def async_set_configuration( + self, config: AssistSatelliteConfiguration + ) -> None: + """Set the current satellite configuration.""" + raise NotImplementedError + # ------------------------------------------------------------------------- # VoIP # ------------------------------------------------------------------------- diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index a14e9e9452b..3a374b312cc 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -8,11 +8,13 @@ import pytest from homeassistant.components.assist_pipeline import PipelineEvent from homeassistant.components.assist_satellite import ( DOMAIN as AS_DOMAIN, + AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityFeature, + AssistSatelliteWakeWord, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from tests.common import ( @@ -42,6 +44,20 @@ class MockAssistSatellite(AssistSatelliteEntity): """Initialize the mock entity.""" self.events = [] self.announcements = [] + self.config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord( + id="1234", wake_word="okay nabu", trained_languages=["en"] + ), + AssistSatelliteWakeWord( + id="5678", + wake_word="hey jarvis", + trained_languages=["en"], + ), + ], + active_wake_words=["1234"], + max_active_wake_words=1, + ) def on_pipeline_event(self, event: PipelineEvent) -> None: """Handle pipeline events.""" @@ -51,6 +67,17 @@ class MockAssistSatellite(AssistSatelliteEntity): """Announce media on a device.""" self.announcements.append((message, media_id)) + @callback + def async_get_configuration(self) -> AssistSatelliteConfiguration: + """Get the current satellite configuration.""" + return self.config + + async def async_set_configuration( + self, config: AssistSatelliteConfiguration + ) -> None: + """Set the current satellite configuration.""" + self.config = config + @pytest.fixture def entity() -> MockAssistSatellite: diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py index 7895ea2555a..709005e38cf 100644 --- a/tests/components/assist_satellite/test_websocket_api.py +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -273,3 +273,115 @@ async def test_intercept_wake_word_unsubscribe( # Wake word should not be intercepted mock_pipeline_from_audio_stream.assert_called_once() + + +async def test_get_configuration( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test getting satellite configuration.""" + ws_client = await hass_ws_client(hass) + + with ( + patch.object(entity, "_attr_pipeline_entity_id", "select.test_pipeline"), + patch.object(entity, "_attr_vad_sensitivity_entity_id", "select.test_vad"), + ): + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/get_configuration", + "entity_id": ENTITY_ID, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == { + "active_wake_words": ["1234"], + "available_wake_words": [ + {"id": "1234", "trained_languages": ["en"], "wake_word": "okay nabu"}, + {"id": "5678", "trained_languages": ["en"], "wake_word": "hey jarvis"}, + ], + "max_active_wake_words": 1, + "pipeline_entity_id": "select.test_pipeline", + "vad_entity_id": "select.test_vad", + } + + +async def test_set_wake_words( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test setting active wake words.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/set_wake_words", + "entity_id": ENTITY_ID, + "wake_word_ids": ["5678"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + # Verify change + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/get_configuration", + "entity_id": ENTITY_ID, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"].get("active_wake_words") == ["5678"] + + +async def test_set_wake_words_exceed_maximum( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test setting too many active wake words.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/set_wake_words", + "entity_id": ENTITY_ID, + "wake_word_ids": ["1234", "5678"], # max of 1 + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_supported", + "message": "Maximum number of active wake words is 1", + } + + +async def test_set_wake_words_bad_id( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test setting active wake words with a bad id.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/set_wake_words", + "entity_id": ENTITY_ID, + "wake_word_ids": ["abcd"], # not an available id + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "not_supported", + "message": "Wake word id is not supported: abcd", + } From 6eab5e3e14cb29c78f42e4d015b5f6be13b109d0 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 16 Sep 2024 22:08:39 -0500 Subject: [PATCH 0728/1309] Add ESPHome Assist satellite configuration (#126085) * Basic implementation * Add websocket commands * Clean up * Add callback to other signatures * Remove unused constant * Re-add callback * Add callback to test * Implement get/set configuration * Add tests * Re-add constant * Bump aioesphomeapi --------- Co-authored-by: Paulus Schoutsen --- .../components/esphome/assist_satellite.py | 35 ++++++++++++- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../esphome/test_assist_satellite.py | 49 +++++++++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 3c66c82a734..f8ed4c48651 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -79,6 +79,7 @@ _TIMER_EVENT_TYPES: EsphomeEnumMapper[VoiceAssistantTimerEventType, TimerEventTy ) _ANNOUNCEMENT_TIMEOUT_SEC = 5 * 60 # 5 minutes +_CONFIG_TIMEOUT_SEC = 5 async def async_setup_entry( @@ -128,6 +129,11 @@ class EsphomeAssistSatellite( self._tts_streaming_task: asyncio.Task | None = None self._udp_server: VoiceAssistantUDPServer | None = None + # Empty config. Updated when added to HA. + self._satellite_config = assist_satellite.AssistSatelliteConfiguration( + available_wake_words=[], active_wake_words=[], max_active_wake_words=0 + ) + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -155,13 +161,33 @@ class EsphomeAssistSatellite( self, ) -> assist_satellite.AssistSatelliteConfiguration: """Get the current satellite configuration.""" - raise NotImplementedError + return self._satellite_config async def async_set_configuration( self, config: assist_satellite.AssistSatelliteConfiguration ) -> None: """Set the current satellite configuration.""" - raise NotImplementedError + await self.cli.set_voice_assistant_configuration( + active_wake_words=config.active_wake_words + ) + _LOGGER.debug("Set active wake words: %s", config.active_wake_words) + + 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) + + # Update available/active wake words + self._satellite_config.available_wake_words = [ + assist_satellite.AssistSatelliteWakeWord( + id=model.id, + wake_word=model.wake_word, + trained_languages=list(model.trained_languages), + ) + for model in config.available_wake_words + ] + self._satellite_config.active_wake_words = list(config.active_wake_words) + self._satellite_config.max_active_wake_words = config.max_active_wake_words + _LOGGER.debug("Received satellite configuration: %s", self._satellite_config) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -214,6 +240,11 @@ class EsphomeAssistSatellite( # Will use media player for TTS/announcements self._update_tts_format() + # Fetch latest config in the background + self.config_entry.async_create_background_task( + self.hass, self._update_satellite_config(), "esphome_voice_assistant_config" + ) + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index dbf51aafae4..aca92f976cc 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==26.0.0", + "aioesphomeapi==27.0.0", "esphome-dashboard-api==1.2.3", "bleak-esphome==1.0.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index a314b6c51cb..a40b660b548 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -240,7 +240,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==26.0.0 +aioesphomeapi==27.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0341c2502b..3fc8d2bd20e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==26.0.0 +aioesphomeapi==27.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 5136e160e89..03111c0d8d8 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -27,8 +27,10 @@ import pytest from homeassistant.components import assist_satellite, tts from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( + AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityFeature, + AssistSatelliteWakeWord, ) # pylint: disable-next=hass-component-root-import @@ -1380,3 +1382,50 @@ async def test_pipeline_abort( # Only first chunk assert chunks == [b"before-abort"] + + +async def test_get_set_configuration( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test getting and setting the satellite configuration.""" + expected_config = AssistSatelliteConfiguration( + available_wake_words=[ + AssistSatelliteWakeWord("1234", "okay nabu", ["en"]), + AssistSatelliteWakeWord("5678", "hey jarvis", ["en"]), + ], + active_wake_words=["1234"], + max_active_wake_words=1, + ) + mock_client.get_voice_assistant_configuration.return_value = expected_config + + mock_device: MockESPHomeDevice = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # HA should have been updated + actual_config = satellite.async_get_configuration() + assert actual_config == expected_config + + # Change active wake words + actual_config.active_wake_words = ["5678"] + await satellite.async_set_configuration(actual_config) + + # Device should have been updated + mock_client.set_voice_assistant_configuration.assert_called_once_with( + active_wake_words=["5678"] + ) From a3155b2ad765ce5b21baae48a06d051c1561eb9b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 10:15:26 +0200 Subject: [PATCH 0729/1309] Move knx base entity to separate module (#126102) * Move knx base entity to separate module * one more --- homeassistant/components/knx/binary_sensor.py | 2 +- homeassistant/components/knx/button.py | 2 +- homeassistant/components/knx/climate.py | 2 +- homeassistant/components/knx/cover.py | 2 +- homeassistant/components/knx/date.py | 2 +- homeassistant/components/knx/datetime.py | 2 +- homeassistant/components/knx/{knx_entity.py => entity.py} | 0 homeassistant/components/knx/fan.py | 2 +- homeassistant/components/knx/light.py | 2 +- homeassistant/components/knx/notify.py | 2 +- homeassistant/components/knx/number.py | 2 +- homeassistant/components/knx/scene.py | 2 +- homeassistant/components/knx/select.py | 2 +- homeassistant/components/knx/sensor.py | 2 +- homeassistant/components/knx/switch.py | 2 +- homeassistant/components/knx/text.py | 2 +- homeassistant/components/knx/time.py | 2 +- homeassistant/components/knx/weather.py | 2 +- 18 files changed, 17 insertions(+), 17 deletions(-) rename homeassistant/components/knx/{knx_entity.py => entity.py} (100%) diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index ad978dde30e..96438df96d7 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -24,7 +24,7 @@ from homeassistant.helpers.typing import ConfigType from . import KNXModule from .const import ATTR_COUNTER, ATTR_SOURCE, KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity from .schema import BinarySensorSchema diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 9a5700917f9..5a2add5dcd7 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -13,7 +13,7 @@ from homeassistant.helpers.typing import ConfigType from . import KNXModule from .const import CONF_PAYLOAD_LENGTH, KNX_ADDRESS, KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity async def async_setup_entry( diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 05f6a80d2d4..2eb3b913195 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -32,7 +32,7 @@ from homeassistant.helpers.typing import ConfigType from . import KNXModule from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity from .schema import ClimateSchema ATTR_COMMAND_VALUE = "command_value" diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index c4b445ff87f..2d38426a687 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType from . import KNXModule from .const import KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity from .schema import CoverSchema diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index d551d4e5b27..8f65ac8a952 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -30,7 +30,7 @@ from .const import ( KNX_ADDRESS, KNX_MODULE_KEY, ) -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity async def async_setup_entry( diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 0f98a7be217..caeaed6da93 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -31,7 +31,7 @@ from .const import ( KNX_ADDRESS, KNX_MODULE_KEY, ) -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity async def async_setup_entry( diff --git a/homeassistant/components/knx/knx_entity.py b/homeassistant/components/knx/entity.py similarity index 100% rename from homeassistant/components/knx/knx_entity.py rename to homeassistant/components/knx/entity.py diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 6a026be2edf..ce17517b970 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -21,7 +21,7 @@ from homeassistant.util.scaling import int_states_in_range from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity from .schema import FanSchema DEFAULT_PERCENTAGE: Final = 50 diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index a9116f5c282..a73f568b2a9 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -30,7 +30,7 @@ import homeassistant.util.color as color_util from . import KNXModule from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes -from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .schema import LightSchema from .storage.const import ( CONF_COLOR_TEMP_MAX, diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index ec17cf941f5..46abbaa1454 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import KNXModule from .const import DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity async def async_get_service( diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 1a6c33239c9..27e4ff743ab 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -24,7 +24,7 @@ from homeassistant.helpers.typing import ConfigType from . import KNXModule from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity from .schema import NumberSchema diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index 0a0e68239ef..dfd226d72b1 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -15,7 +15,7 @@ from homeassistant.helpers.typing import ConfigType from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity from .schema import SceneSchema diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 272db48f14e..b499e3c601d 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -29,7 +29,7 @@ from .const import ( KNX_ADDRESS, KNX_MODULE_KEY, ) -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity from .schema import SelectSchema diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 03b3f3f70c3..ed265db4ac7 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -35,7 +35,7 @@ from homeassistant.util.enum import try_parse_enum from . import KNXModule from .const import ATTR_SOURCE, KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity from .schema import SensorSchema SCAN_INTERVAL = timedelta(seconds=10) diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 9146a98dda4..9390cbfea43 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -35,7 +35,7 @@ from .const import ( KNX_ADDRESS, KNX_MODULE_KEY, ) -from .knx_entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .schema import SwitchSchema from .storage.const import ( CONF_DEVICE_INFO, diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index 1fdfc21bf2b..2256afadbd9 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -24,7 +24,7 @@ from homeassistant.helpers.typing import ConfigType from . import KNXModule from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity async def async_setup_entry( diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 8e57b4a4fb5..1e82c324502 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -30,7 +30,7 @@ from .const import ( KNX_ADDRESS, KNX_MODULE_KEY, ) -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity async def async_setup_entry( diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 3cf8f163330..a1e5c0efe48 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -21,7 +21,7 @@ from homeassistant.helpers.typing import ConfigType from . import KNXModule from .const import KNX_MODULE_KEY -from .knx_entity import KnxYamlEntity +from .entity import KnxYamlEntity from .schema import WeatherSchema From 3601c531f400255d10b82529549e564fbe483a54 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Tue, 17 Sep 2024 10:23:27 +0200 Subject: [PATCH 0730/1309] Adding reauth support to Weheat (#126108) * Added reauth in config flow and raise approriate errors * Added reauth tests * Some cleanup after looking at other PRs --- homeassistant/components/weheat/__init__.py | 9 +++- .../components/weheat/config_flow.py | 39 +++++++++++++-- .../components/weheat/coordinator.py | 4 +- homeassistant/components/weheat/strings.json | 3 +- tests/components/weheat/conftest.py | 21 +++++++- tests/components/weheat/const.py | 1 + tests/components/weheat/test_config_flow.py | 48 ++++++++++++++++++- 7 files changed, 116 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py index 4800046926d..d924d6ceaab 100644 --- a/homeassistant/components/weheat/__init__.py +++ b/homeassistant/components/weheat/__init__.py @@ -3,10 +3,12 @@ from __future__ import annotations from weheat.abstractions.discovery import HeatPumpDiscovery +from weheat.exceptions import UnauthorizedException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -30,7 +32,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo entry.runtime_data = [] # fetch a list of the heat pumps the entry can access - for pump_info in await HeatPumpDiscovery.discover_active(API_URL, token): + try: + discovered_heat_pumps = await HeatPumpDiscovery.discover_active(API_URL, token) + except UnauthorizedException as error: + raise ConfigEntryAuthFailed from error + + for pump_info in discovered_heat_pumps: LOGGER.debug("Adding %s", pump_info) # for each pump, add a coordinator new_coordinator = WeheatDataUpdateCoordinator(hass, session, pump_info) diff --git a/homeassistant/components/weheat/config_flow.py b/homeassistant/components/weheat/config_flow.py index 707c2f6bc97..c1eccaf6ba7 100644 --- a/homeassistant/components/weheat/config_flow.py +++ b/homeassistant/components/weheat/config_flow.py @@ -1,10 +1,12 @@ """Config flow for Weheat.""" +from collections.abc import Mapping import logging +from typing import Any from weheat.abstractions.user import get_user_id_from_token -from homeassistant.config_entries import ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler @@ -16,6 +18,8 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): DOMAIN = DOMAIN + reauth_entry: ConfigEntry | None = None + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -34,7 +38,34 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): user_id = await get_user_id_from_token( API_URL, data[CONF_TOKEN][CONF_ACCESS_TOKEN] ) - await self.async_set_unique_id(user_id) - self._abort_if_unique_id_configured() + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() - return self.async_create_entry(title=ENTRY_TITLE, data=data) + return self.async_create_entry(title=ENTRY_TITLE, data=data) + + if self.reauth_entry.unique_id == user_id: + return self.async_update_reload_and_abort( + self.reauth_entry, + unique_id=user_id, + data={**self.reauth_entry.data, **data}, + ) + + return self.async_abort(reason="wrong_account") + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py index 69d1319ed52..a50e9daec18 100644 --- a/homeassistant/components/weheat/coordinator.py +++ b/homeassistant/components/weheat/coordinator.py @@ -15,6 +15,7 @@ from weheat.exceptions import ( from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -24,7 +25,6 @@ EXCEPTIONS = ( ServiceException, NotFoundException, ForbiddenException, - UnauthorizedException, BadRequestException, ApiException, ) @@ -72,6 +72,8 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]): """Get the data from the API.""" try: self._heat_pump_data.get_status(self.session.token[CONF_ACCESS_TOKEN]) + except UnauthorizedException as error: + raise ConfigEntryAuthFailed from error except EXCEPTIONS as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index b77af4ed306..3982bfd23b3 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -24,7 +24,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "no_devices_found": "Could not find any heat pumps on this account" + "no_devices_found": "Could not find any heat pumps on this account", + "wrong_account": "You can only reauthenticate this account with the same user." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index 1b4bf26c35f..622882d6e8d 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -17,7 +17,14 @@ from homeassistant.components.weheat.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import CLIENT_ID, CLIENT_SECRET, TEST_HP_UUID, TEST_MODEL, TEST_SN +from .const import ( + CLIENT_ID, + CLIENT_SECRET, + TEST_HP_UUID, + TEST_MODEL, + TEST_SN, + USER_UUID_1, +) from tests.common import MockConfigEntry @@ -69,6 +76,18 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_user_id() -> Generator[AsyncMock]: + """Mock the user API call.""" + with ( + patch( + "homeassistant.components.weheat.config_flow.get_user_id_from_token", + return_value=USER_UUID_1, + ) as user_mock, + ): + yield user_mock + + @pytest.fixture def mock_weheat_discover(mock_heat_pump_info) -> Generator[AsyncMock]: """Mock an Weheat discovery.""" diff --git a/tests/components/weheat/const.py b/tests/components/weheat/const.py index bae74dc70a1..61203259c58 100644 --- a/tests/components/weheat/const.py +++ b/tests/components/weheat/const.py @@ -4,6 +4,7 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" USER_UUID_1 = "0000-1111-2222-3333" +USER_UUID_2 = "0000-1111-2222-4444" CONF_REFRESH_TOKEN = "refresh_token" CONF_AUTH_IMPLEMENTATION = "auth_implementation" diff --git a/tests/components/weheat/test_config_flow.py b/tests/components/weheat/test_config_flow.py index c065d011e42..b33dd0a8db8 100644 --- a/tests/components/weheat/test_config_flow.py +++ b/tests/components/weheat/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Weheat config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -23,6 +23,7 @@ from .const import ( MOCK_ACCESS_TOKEN, MOCK_REFRESH_TOKEN, USER_UUID_1, + USER_UUID_2, ) from tests.common import MockConfigEntry @@ -99,6 +100,51 @@ async def test_duplicate_unique_id( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + ("logged_in_user", "expected_reason"), + [(USER_UUID_1, "reauth_successful"), (USER_UUID_2, "wrong_account")], +) +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_user_id: AsyncMock, + mock_weheat_discover: AsyncMock, + setup_credentials, + logged_in_user: str, + expected_reason: str, +) -> None: + """Check reauth flow both with and without the correct logged in user.""" + mock_user_id.return_value = logged_in_user + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + unique_id=USER_UUID_1, + ) + + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + + await handle_oauth(hass, hass_client_no_auth, aioclient_mock, result) + + assert result["type"] is FlowResultType.EXTERNAL_STEP + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == expected_reason + assert entry.unique_id == USER_UUID_1 + + async def handle_oauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, From 92099866e2d7401c692aa0dd98ea3ee91eec20c7 Mon Sep 17 00:00:00 2001 From: TimL Date: Tue, 17 Sep 2024 23:24:20 +1000 Subject: [PATCH 0731/1309] Bump pysmlight to 0.1.0 (#126111) Bump pysmlight 0.1.0 for Smlight integration --- 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 cd1002e35d9..66d68b80ace 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.0.16"], + "requirements": ["pysmlight==0.1.0"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index a40b660b548..ee7704f5f46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2238,7 +2238,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.0.16 +pysmlight==0.1.0 # homeassistant.components.snmp pysnmp==6.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fc8d2bd20e..b7e3e897817 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1792,7 +1792,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.0.16 +pysmlight==0.1.0 # homeassistant.components.snmp pysnmp==6.2.5 From 84c20745a847fe92fb66bcb9959a5e968886442f Mon Sep 17 00:00:00 2001 From: "Lektri.co" <137074859+Lektrico@users.noreply.github.com> Date: Tue, 17 Sep 2024 16:30:24 +0300 Subject: [PATCH 0732/1309] Add number platform to the Lektrico integration (#126119) * Add platform number. * Remove number user_limit. * Change LED to led in number snapshot. * Update homeassistant/components/lektrico/number.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/lektrico/__init__.py | 6 +- homeassistant/components/lektrico/number.py | 100 ++++++++++++++++ .../components/lektrico/strings.json | 8 ++ .../lektrico/fixtures/get_info.json | 5 +- .../lektrico/snapshots/test_number.ambr | 113 ++++++++++++++++++ tests/components/lektrico/test_number.py | 31 +++++ 6 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/lektrico/number.py create mode 100644 tests/components/lektrico/snapshots/test_number.ambr create mode 100644 tests/components/lektrico/test_number.py diff --git a/homeassistant/components/lektrico/__init__.py b/homeassistant/components/lektrico/__init__.py index 746d14f3605..bd2ca8de214 100644 --- a/homeassistant/components/lektrico/__init__.py +++ b/homeassistant/components/lektrico/__init__.py @@ -11,7 +11,11 @@ from homeassistant.core import HomeAssistant from .coordinator import LektricoDeviceDataUpdateCoordinator # List the platforms that charger supports. -CHARGERS_PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] +CHARGERS_PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.NUMBER, + Platform.SENSOR, +] # List the platforms that load balancer device supports. LB_DEVICES_PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] diff --git a/homeassistant/components/lektrico/number.py b/homeassistant/components/lektrico/number.py new file mode 100644 index 00000000000..8054ba8afe5 --- /dev/null +++ b/homeassistant/components/lektrico/number.py @@ -0,0 +1,100 @@ +"""Support for Lektrico number entities.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from lektricowifi import Device + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import ( + ATTR_SERIAL_NUMBER, + CONF_TYPE, + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator +from .entity import LektricoEntity + + +@dataclass(frozen=True, kw_only=True) +class LektricoNumberEntityDescription(NumberEntityDescription): + """Describes Lektrico number entity.""" + + value_fn: Callable[[dict[str, Any]], int] + set_value_fn: Callable[[Device, int], Coroutine[Any, Any, dict[Any, Any]]] + + +NUMBERS: tuple[LektricoNumberEntityDescription, ...] = ( + LektricoNumberEntityDescription( + key="led_max_brightness", + translation_key="led_max_brightness", + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=100, + native_step=5, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: int(data["led_max_brightness"]), + set_value_fn=lambda data, value: data.set_led_max_brightness(value), + ), + LektricoNumberEntityDescription( + key="dynamic_limit", + translation_key="dynamic_limit", + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_max_value=32, + native_step=1, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_fn=lambda data: int(data["dynamic_current"]), + set_value_fn=lambda data, value: data.set_dynamic_current(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LektricoConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Lektrico number entities based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + LektricoNumber( + description, + coordinator, + f"{entry.data[CONF_TYPE]}_{entry.data[ATTR_SERIAL_NUMBER]}", + ) + for description in NUMBERS + ) + + +class LektricoNumber(LektricoEntity, NumberEntity): + """Defines a Lektrico number entity.""" + + entity_description: LektricoNumberEntityDescription + + def __init__( + self, + description: LektricoNumberEntityDescription, + coordinator: LektricoDeviceDataUpdateCoordinator, + device_name: str, + ) -> None: + """Initialize Lektrico number.""" + super().__init__(coordinator, device_name) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + + @property + def native_value(self) -> int | None: + """Return the state of the number.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Set the selected value.""" + await self.entity_description.set_value_fn(self.coordinator.device, int(value)) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index 2470c0865d5..a636ee543e6 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -30,6 +30,14 @@ "name": "Charge stop" } }, + "number": { + "led_max_brightness": { + "name": "Led brightness" + }, + "dynamic_limit": { + "name": "Dynamic limit" + } + }, "sensor": { "state": { "name": "State", diff --git a/tests/components/lektrico/fixtures/get_info.json b/tests/components/lektrico/fixtures/get_info.json index a8f2a56b8d8..7c2fc30b0b0 100644 --- a/tests/components/lektrico/fixtures/get_info.json +++ b/tests/components/lektrico/fixtures/get_info.json @@ -9,5 +9,8 @@ "current_limit_reason": "installation_current", "voltage_l1": 220.0, "current_l1": 0.0, - "fw_version": "1.44" + "fw_version": "1.44", + "led_max_brightness": 20, + "dynamic_current": 32, + "user_current": 32 } diff --git a/tests/components/lektrico/snapshots/test_number.ambr b/tests/components/lektrico/snapshots/test_number.ambr new file mode 100644 index 00000000000..30a37a25a09 --- /dev/null +++ b/tests/components/lektrico/snapshots/test_number.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_all_entities[number.1p7k_500006_dynamic_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 32, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.1p7k_500006_dynamic_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dynamic limit', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'dynamic_limit', + 'unique_id': '500006_dynamic_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.1p7k_500006_dynamic_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1p7k_500006 Dynamic limit', + 'max': 32, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.1p7k_500006_dynamic_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32', + }) +# --- +# name: test_all_entities[number.1p7k_500006_led_brightness-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.1p7k_500006_led_brightness', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Led brightness', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'led_max_brightness', + 'unique_id': '500006_led_max_brightness', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[number.1p7k_500006_led_brightness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1p7k_500006 Led brightness', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.1p7k_500006_led_brightness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- diff --git a/tests/components/lektrico/test_number.py b/tests/components/lektrico/test_number.py new file mode 100644 index 00000000000..ade6515ca72 --- /dev/null +++ b/tests/components/lektrico/test_number.py @@ -0,0 +1,31 @@ +"""Tests for the Lektrico number platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch.multiple( + "homeassistant.components.lektrico", + CHARGERS_PLATFORMS=[Platform.NUMBER], + LB_DEVICES_PLATFORMS=[Platform.NUMBER], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 7fee61db84319c69a18ba66a507ec1a71e4c8667 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:37:39 +0200 Subject: [PATCH 0733/1309] Move nissan_leaf base entity to separate module (#126106) --- .../components/nissan_leaf/__init__.py | 51 +---------------- .../components/nissan_leaf/binary_sensor.py | 3 +- .../components/nissan_leaf/button.py | 3 +- homeassistant/components/nissan_leaf/const.py | 2 + .../components/nissan_leaf/entity.py | 56 +++++++++++++++++++ .../components/nissan_leaf/sensor.py | 3 +- .../components/nissan_leaf/switch.py | 3 +- 7 files changed, 69 insertions(+), 52 deletions(-) create mode 100644 homeassistant/components/nissan_leaf/entity.py diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 2cbec236261..865ae33b38c 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -17,14 +17,10 @@ from pycarwings2.responses import ( import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME, Platform -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import utcnow @@ -52,6 +48,7 @@ from .const import ( PYCARWINGS2_SLEEP, RESTRICTED_BATTERY, RESTRICTED_INTERVAL, + SIGNAL_UPDATE_LEAF, ) _LOGGER = logging.getLogger(__name__) @@ -90,7 +87,6 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] -SIGNAL_UPDATE_LEAF = "nissan_leaf_update" SERVICE_UPDATE_LEAF = "update" SERVICE_START_CHARGE_LEAF = "start_charge" @@ -496,44 +492,3 @@ class LeafDataStore: self._remove_listener = async_track_point_in_utc_time( self.hass, self.async_update_data, update_at ) - - -class LeafEntity(Entity): - """Base class for Nissan Leaf entity.""" - - def __init__(self, car: LeafDataStore) -> None: - """Store LeafDataStore upon init.""" - self.car = car - - def log_registration(self) -> None: - """Log registration.""" - _LOGGER.debug( - "Registered %s integration for VIN %s", - self.__class__.__name__, - self.car.leaf.vin, - ) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return default attributes for Nissan leaf entities.""" - return { - "next_update": self.car.next_update, - "last_attempt": self.car.last_check, - "updated_on": self.car.last_battery_response, - "update_in_progress": self.car.request_in_progress, - "vin": self.car.leaf.vin, - } - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - self.log_registration() - self.async_on_remove( - async_dispatcher_connect( - self.car.hass, SIGNAL_UPDATE_LEAF, self._update_callback - ) - ) - - @callback - def _update_callback(self) -> None: - """Update the state.""" - self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/nissan_leaf/binary_sensor.py b/homeassistant/components/nissan_leaf/binary_sensor.py index 3b15fabe382..7938b314deb 100644 --- a/homeassistant/components/nissan_leaf/binary_sensor.py +++ b/homeassistant/components/nissan_leaf/binary_sensor.py @@ -12,8 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import LeafDataStore, LeafEntity +from . import LeafDataStore from .const import DATA_CHARGING, DATA_LEAF, DATA_PLUGGED_IN +from .entity import LeafEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nissan_leaf/button.py b/homeassistant/components/nissan_leaf/button.py index aa2bbbbca9b..6a5d051751b 100644 --- a/homeassistant/components/nissan_leaf/button.py +++ b/homeassistant/components/nissan_leaf/button.py @@ -9,7 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DATA_CHARGING, DATA_LEAF, LeafEntity +from . import DATA_CHARGING, DATA_LEAF +from .entity import LeafEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nissan_leaf/const.py b/homeassistant/components/nissan_leaf/const.py index 299576b86a7..22842fbbc72 100644 --- a/homeassistant/components/nissan_leaf/const.py +++ b/homeassistant/components/nissan_leaf/const.py @@ -34,3 +34,5 @@ RESTRICTED_BATTERY: Final = 2 MAX_RESPONSE_ATTEMPTS: Final = 3 PYCARWINGS2_SLEEP: Final = 40 + +SIGNAL_UPDATE_LEAF = "nissan_leaf_update" diff --git a/homeassistant/components/nissan_leaf/entity.py b/homeassistant/components/nissan_leaf/entity.py new file mode 100644 index 00000000000..73813c8931e --- /dev/null +++ b/homeassistant/components/nissan_leaf/entity.py @@ -0,0 +1,56 @@ +"""Support for the Nissan Leaf Carwings/Nissan Connect API.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import LeafDataStore +from .const import SIGNAL_UPDATE_LEAF + +_LOGGER = logging.getLogger(__name__) + + +class LeafEntity(Entity): + """Base class for Nissan Leaf entity.""" + + def __init__(self, car: LeafDataStore) -> None: + """Store LeafDataStore upon init.""" + self.car = car + + def log_registration(self) -> None: + """Log registration.""" + _LOGGER.debug( + "Registered %s integration for VIN %s", + self.__class__.__name__, + self.car.leaf.vin, + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return default attributes for Nissan leaf entities.""" + return { + "next_update": self.car.next_update, + "last_attempt": self.car.last_check, + "updated_on": self.car.last_battery_response, + "update_in_progress": self.car.request_in_progress, + "vin": self.car.leaf.vin, + } + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.log_registration() + self.async_on_remove( + async_dispatcher_connect( + self.car.hass, SIGNAL_UPDATE_LEAF, self._update_callback + ) + ) + + @callback + def _update_callback(self) -> None: + """Update the state.""" + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index bde1719e9b1..71dda39db1a 100644 --- a/homeassistant/components/nissan_leaf/sensor.py +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -13,7 +13,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateTyp from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import LeafDataStore, LeafEntity +from . import LeafDataStore from .const import ( DATA_BATTERY, DATA_CHARGING, @@ -21,6 +21,7 @@ from .const import ( DATA_RANGE_AC, DATA_RANGE_AC_OFF, ) +from .entity import LeafEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nissan_leaf/switch.py b/homeassistant/components/nissan_leaf/switch.py index 39f875ff95f..82a84567fec 100644 --- a/homeassistant/components/nissan_leaf/switch.py +++ b/homeassistant/components/nissan_leaf/switch.py @@ -10,8 +10,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import LeafDataStore, LeafEntity +from . import LeafDataStore from .const import DATA_CLIMATE, DATA_LEAF +from .entity import LeafEntity _LOGGER = logging.getLogger(__name__) From 4d140d81f9f7451b9380260bf08939c3068dddfb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:37:56 +0200 Subject: [PATCH 0734/1309] Move mysensors base entity to separate module (#126105) --- homeassistant/components/mysensors/__init__.py | 2 +- homeassistant/components/mysensors/binary_sensor.py | 2 +- homeassistant/components/mysensors/climate.py | 2 +- homeassistant/components/mysensors/cover.py | 2 +- homeassistant/components/mysensors/device_tracker.py | 2 +- homeassistant/components/mysensors/{device.py => entity.py} | 0 homeassistant/components/mysensors/handler.py | 2 +- homeassistant/components/mysensors/light.py | 2 +- homeassistant/components/mysensors/remote.py | 2 +- homeassistant/components/mysensors/sensor.py | 2 +- homeassistant/components/mysensors/switch.py | 2 +- homeassistant/components/mysensors/text.py | 2 +- tests/components/mysensors/conftest.py | 2 +- 13 files changed, 12 insertions(+), 12 deletions(-) rename homeassistant/components/mysensors/{device.py => entity.py} (100%) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 8ebcbe0e2fe..ce01f139dab 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -23,7 +23,7 @@ from .const import ( DiscoveryInfo, SensorType, ) -from .device import MySensorsChildEntity, get_mysensors_devices +from .entity import MySensorsChildEntity, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index 47805e86b1c..54f7036b79c 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsChildEntity +from .entity import MySensorsChildEntity from .helpers import on_unload diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index 79bc7b4b98d..ce15faa589c 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -20,7 +20,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsChildEntity +from .entity import MySensorsChildEntity from .helpers import on_unload DICT_HA_TO_MYS = { diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index a5f4e7b1022..808589b9022 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsChildEntity +from .entity import MySensorsChildEntity from .helpers import on_unload diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 968ee94b60e..af684ea195d 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsChildEntity +from .entity import MySensorsChildEntity from .helpers import on_unload diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/entity.py similarity index 100% rename from homeassistant/components/mysensors/device.py rename to homeassistant/components/mysensors/entity.py diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index 20e0ddd0e5a..96ea5347102 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -13,7 +13,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import decorator from .const import CHILD_CALLBACK, NODE_CALLBACK, DevId, GatewayId -from .device import get_mysensors_devices +from .entity import get_mysensors_devices from .helpers import ( discover_mysensors_node, discover_mysensors_platform, diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index e10aee6187f..a76b42359c1 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -20,7 +20,7 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsChildEntity +from .entity import MySensorsChildEntity from .helpers import on_unload diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index e9404bb3197..1a4f6fdaa90 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsChildEntity +from .entity import MySensorsChildEntity from .helpers import on_unload diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 695382c491b..3cf4be21757 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -49,7 +49,7 @@ from .const import ( DiscoveryInfo, NodeDiscoveryInfo, ) -from .device import MySensorNodeEntity, MySensorsChildEntity +from .entity import MySensorNodeEntity, MySensorsChildEntity from .helpers import on_unload SENSORS: dict[str, SensorEntityDescription] = { diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 400ef2c5896..4eabf6374f1 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsChildEntity +from .entity import MySensorsChildEntity from .helpers import on_unload diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index 8aed9df2eef..4edb5ccdbd8 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsChildEntity +from .entity import MySensorsChildEntity from .helpers import on_unload diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index b6fce35a4c7..1d407815db0 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -141,7 +141,7 @@ async def integration_fixture( config: dict[str, Any] = {} config_entry.add_to_hass(hass) with patch( - "homeassistant.components.mysensors.device.Debouncer", autospec=True + "homeassistant.components.mysensors.entity.Debouncer", autospec=True ) as debouncer_class: def debouncer( From 93f2b7c8a38650b0d85d0c6ae93c4b97040cf52a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:38:10 +0200 Subject: [PATCH 0735/1309] Move modbus base entity to separate module (#126104) --- homeassistant/components/modbus/binary_sensor.py | 2 +- homeassistant/components/modbus/climate.py | 2 +- homeassistant/components/modbus/cover.py | 2 +- homeassistant/components/modbus/{base_platform.py => entity.py} | 0 homeassistant/components/modbus/fan.py | 2 +- homeassistant/components/modbus/light.py | 2 +- homeassistant/components/modbus/sensor.py | 2 +- homeassistant/components/modbus/switch.py | 2 +- 8 files changed, 7 insertions(+), 7 deletions(-) rename homeassistant/components/modbus/{base_platform.py => entity.py} (100%) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 314877b7927..54ee49ed6a2 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -24,13 +24,13 @@ from homeassistant.helpers.update_coordinator import ( ) from . import get_hub -from .base_platform import BasePlatform from .const import ( CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT, ) +from .entity import BasePlatform from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 0a4eae341b4..bcbaa0f32af 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -43,7 +43,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub -from .base_platform import BaseStructPlatform from .const import ( CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_WRITE_REGISTER, @@ -86,6 +85,7 @@ from .const import ( CONF_WRITE_REGISTERS, DataType, ) +from .entity import BaseStructPlatform from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 1221a05a5ac..ce44c2935f6 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -22,7 +22,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub -from .base_platform import BasePlatform from .const import ( CALL_TYPE_COIL, CALL_TYPE_WRITE_COIL, @@ -34,6 +33,7 @@ from .const import ( CONF_STATUS_REGISTER, CONF_STATUS_REGISTER_TYPE, ) +from .entity import BasePlatform from .modbus import ModbusHub PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/entity.py similarity index 100% rename from homeassistant/components/modbus/base_platform.py rename to homeassistant/components/modbus/entity.py diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index e8b9d3bdaa7..5d12fe37fd1 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -11,8 +11,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub -from .base_platform import BaseSwitch from .const import CONF_FANS +from .entity import BaseSwitch from .modbus import ModbusHub PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 16714219bc2..42745c2bb78 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub -from .base_platform import BaseSwitch +from .entity import BaseSwitch from .modbus import ModbusHub PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index dbc464e98a9..4b4fd5bd51a 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -27,8 +27,8 @@ from homeassistant.helpers.update_coordinator import ( ) from . import get_hub -from .base_platform import BaseStructPlatform from .const import CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT +from .entity import BaseStructPlatform from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index ff02e4a7a7e..71413391a5f 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -11,7 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import get_hub -from .base_platform import BaseSwitch +from .entity import BaseSwitch from .modbus import ModbusHub PARALLEL_UPDATES = 1 From 3a55cbc8180efea60406beb541b74c7afa3022d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:39:11 +0200 Subject: [PATCH 0736/1309] Move and rename lutron caseta base entity to separate module (#126103) --- .../components/lutron_caseta/__init__.py | 123 +----------------- .../components/lutron_caseta/binary_sensor.py | 8 +- .../components/lutron_caseta/button.py | 4 +- .../components/lutron_caseta/cover.py | 6 +- .../components/lutron_caseta/entity.py | 108 +++++++++++++++ homeassistant/components/lutron_caseta/fan.py | 4 +- .../components/lutron_caseta/light.py | 4 +- .../components/lutron_caseta/switch.py | 4 +- .../components/lutron_caseta/util.py | 23 ++++ 9 files changed, 151 insertions(+), 133 deletions(-) create mode 100644 homeassistant/components/lutron_caseta/entity.py diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 178acea83f0..26fc5ba153e 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -14,13 +14,12 @@ from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ATTR_DEVICE_ID, ATTR_SUGGESTED_AREA, CONF_HOST, Platform +from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from .const import ( @@ -40,7 +39,6 @@ from .const import ( CONF_CERTFILE, CONF_KEYFILE, CONF_SUBTYPE, - CONFIG_URL, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, MANUFACTURER, @@ -68,7 +66,7 @@ from .models import ( LutronKeypad, LutronKeypadData, ) -from .util import serial_to_unique_id +from .util import area_name_from_id, serial_to_unique_id _LOGGER = logging.getLogger(__name__) @@ -224,7 +222,7 @@ def _async_register_bridge_device( configuration_url="https://device-login.lutron.com", ) - area = _area_name_from_id(bridge.areas, bridge_device["area"]) + area = area_name_from_id(bridge.areas, bridge_device["area"]) if area != UNASSIGNED_AREA: device_args["suggested_area"] = area @@ -342,7 +340,7 @@ def _async_build_lutron_keypad( keypad_device_id: int, ) -> LutronKeypad: # First time seeing this keypad, build keypad data and store in keypads - area_name = _area_name_from_id(bridge.areas, bridge_keypad["area"]) + area_name = area_name_from_id(bridge.areas, bridge_keypad["area"]) keypad_name = bridge_keypad["name"].split("_")[-1] keypad_serial = _handle_none_keypad_serial(bridge_keypad, bridge_device["serial"]) device_info = DeviceInfo( @@ -404,27 +402,6 @@ def _handle_none_keypad_serial(keypad_device: dict, bridge_serial: int) -> str: return keypad_device["serial"] or f"{bridge_serial}_{keypad_device['device_id']}" -def _area_name_from_id(areas: dict[str, dict], area_id: str | None) -> str: - """Return the full area name including parent(s).""" - if area_id is None: - return UNASSIGNED_AREA - return _construct_area_name_from_id(areas, area_id, []) - - -def _construct_area_name_from_id( - areas: dict[str, dict], area_id: str, labels: list[str] -) -> str: - """Recursively construct the full area name including parent(s).""" - area = areas[area_id] - parent_area_id = area["parent_id"] - if parent_area_id is None: - # This is the root area, return last area - return " ".join(labels) - - labels.insert(0, area["name"]) - return _construct_area_name_from_id(areas, parent_area_id, labels) - - @callback def async_get_lip_button(device_type: str, leap_button: int) -> int | None: """Get the LIP button for a given LEAP button.""" @@ -500,98 +477,6 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -class LutronCasetaDevice(Entity): - """Common base class for all Lutron Caseta devices.""" - - _attr_should_poll = False - - def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None: - """Set up the base class. - - [:param]device the device metadata - [:param]bridge the smartbridge object - [:param]bridge_device a dict with the details of the bridge - """ - self._device = device - self._smartbridge = data.bridge - self._bridge_device = data.bridge_device - self._bridge_unique_id = serial_to_unique_id(data.bridge_device["serial"]) - if "serial" not in self._device: - return - - if "parent_device" in device: - # This is a child entity, handle the naming in button.py and switch.py - return - area = _area_name_from_id(self._smartbridge.areas, device["area"]) - name = device["name"].split("_")[-1] - self._attr_name = full_name = f"{area} {name}" - info = DeviceInfo( - # Historically we used the device serial number for the identifier - # but the serial is usually an integer and a string is expected - # here. Since it would be a breaking change to change the identifier - # we are ignoring the type error here until it can be migrated to - # a string in a future release. - identifiers={ - ( - DOMAIN, - self._handle_none_serial(self.serial), # type: ignore[arg-type] - ) - }, - manufacturer=MANUFACTURER, - model=f"{device['model']} ({device['type']})", - name=full_name, - via_device=(DOMAIN, self._bridge_device["serial"]), - configuration_url=CONFIG_URL, - ) - if area != UNASSIGNED_AREA: - info[ATTR_SUGGESTED_AREA] = area - self._attr_device_info = info - - async def async_added_to_hass(self): - """Register callbacks.""" - self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state) - - def _handle_none_serial(self, serial: str | int | None) -> str | int: - """Handle None serial returned by RA3 and QSX processors.""" - if serial is None: - return f"{self._bridge_unique_id}_{self.device_id}" - return serial - - @property - def device_id(self): - """Return the device ID used for calling pylutron_caseta.""" - return self._device["device_id"] - - @property - def serial(self) -> int | None: - """Return the serial number of the device.""" - return self._device["serial"] - - @property - def unique_id(self) -> str: - """Return the unique ID of the device (serial).""" - return str(self._handle_none_serial(self.serial)) - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = { - "device_id": self.device_id, - } - if zone := self._device.get("zone"): - attributes["zone_id"] = zone - return attributes - - -class LutronCasetaDeviceUpdatableEntity(LutronCasetaDevice): - """A lutron_caseta entity that can update by syncing data from the bridge.""" - - async def async_update(self) -> None: - """Update when forcing a refresh of the device.""" - self._device = self._smartbridge.get_device_by_id(self.device_id) - _LOGGER.debug(self._device) - - def _id_to_identifier(lutron_id: str) -> tuple[str, str]: """Convert a lutron caseta identifier to a device identifier.""" return (DOMAIN, lutron_id) diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index bfed8c785ae..b51756692c1 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -11,9 +11,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice, _area_name_from_id +from . import DOMAIN as CASETA_DOMAIN from .const import CONFIG_URL, MANUFACTURER, UNASSIGNED_AREA +from .entity import LutronCasetaEntity from .models import LutronCasetaConfigEntry +from .util import area_name_from_id async def async_setup_entry( @@ -35,7 +37,7 @@ async def async_setup_entry( ) -class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): +class LutronOccupancySensor(LutronCasetaEntity, BinarySensorEntity): """Representation of a Lutron occupancy group.""" _attr_device_class = BinarySensorDeviceClass.OCCUPANCY @@ -43,7 +45,7 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): def __init__(self, device, data): """Init an occupancy sensor.""" super().__init__(device, data) - area = _area_name_from_id(self._smartbridge.areas, device["area"]) + area = area_name_from_id(self._smartbridge.areas, device["area"]) name = f"{area} {device['device_name']}" self._attr_name = name self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py index d2651673c4c..a74de46346b 100644 --- a/homeassistant/components/lutron_caseta/button.py +++ b/homeassistant/components/lutron_caseta/button.py @@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LutronCasetaDevice from .device_trigger import LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP +from .entity import LutronCasetaEntity from .models import LutronCasetaConfigEntry, LutronCasetaData @@ -65,7 +65,7 @@ async def async_setup_entry( async_add_entities(entities) -class LutronCasetaButton(LutronCasetaDevice, ButtonEntity): +class LutronCasetaButton(LutronCasetaEntity, ButtonEntity): """Representation of a Lutron pico and keypad button.""" def __init__( diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 47711abb80e..11da2220be9 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -13,11 +13,11 @@ from homeassistant.components.cover import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LutronCasetaDeviceUpdatableEntity +from .entity import LutronCasetaUpdatableEntity from .models import LutronCasetaConfigEntry -class LutronCasetaShade(LutronCasetaDeviceUpdatableEntity, CoverEntity): +class LutronCasetaShade(LutronCasetaUpdatableEntity, CoverEntity): """Representation of a Lutron shade with open/close functionality.""" _attr_supported_features = ( @@ -59,7 +59,7 @@ class LutronCasetaShade(LutronCasetaDeviceUpdatableEntity, CoverEntity): await self._smartbridge.set_value(self.device_id, kwargs[ATTR_POSITION]) -class LutronCasetaTiltOnlyBlind(LutronCasetaDeviceUpdatableEntity, CoverEntity): +class LutronCasetaTiltOnlyBlind(LutronCasetaUpdatableEntity, CoverEntity): """Representation of a Lutron tilt only blind.""" _attr_supported_features = ( diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py new file mode 100644 index 00000000000..f954be74f1d --- /dev/null +++ b/homeassistant/components/lutron_caseta/entity.py @@ -0,0 +1,108 @@ +"""Component for interacting with a Lutron Caseta system.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.const import ATTR_SUGGESTED_AREA +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import CONFIG_URL, DOMAIN, MANUFACTURER, UNASSIGNED_AREA +from .models import LutronCasetaData +from .util import area_name_from_id, serial_to_unique_id + +_LOGGER = logging.getLogger(__name__) + + +class LutronCasetaEntity(Entity): + """Common base class for all Lutron Caseta devices.""" + + _attr_should_poll = False + + def __init__(self, device: dict[str, Any], data: LutronCasetaData) -> None: + """Set up the base class. + + [:param]device the device metadata + [:param]bridge the smartbridge object + [:param]bridge_device a dict with the details of the bridge + """ + self._device = device + self._smartbridge = data.bridge + self._bridge_device = data.bridge_device + self._bridge_unique_id = serial_to_unique_id(data.bridge_device["serial"]) + if "serial" not in self._device: + return + + if "parent_device" in device: + # This is a child entity, handle the naming in button.py and switch.py + return + area = area_name_from_id(self._smartbridge.areas, device["area"]) + name = device["name"].split("_")[-1] + self._attr_name = full_name = f"{area} {name}" + info = DeviceInfo( + # Historically we used the device serial number for the identifier + # but the serial is usually an integer and a string is expected + # here. Since it would be a breaking change to change the identifier + # we are ignoring the type error here until it can be migrated to + # a string in a future release. + identifiers={ + ( + DOMAIN, + self._handle_none_serial(self.serial), # type: ignore[arg-type] + ) + }, + manufacturer=MANUFACTURER, + model=f"{device['model']} ({device['type']})", + name=full_name, + via_device=(DOMAIN, self._bridge_device["serial"]), + configuration_url=CONFIG_URL, + ) + if area != UNASSIGNED_AREA: + info[ATTR_SUGGESTED_AREA] = area + self._attr_device_info = info + + async def async_added_to_hass(self): + """Register callbacks.""" + self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state) + + def _handle_none_serial(self, serial: str | int | None) -> str | int: + """Handle None serial returned by RA3 and QSX processors.""" + if serial is None: + return f"{self._bridge_unique_id}_{self.device_id}" + return serial + + @property + def device_id(self): + """Return the device ID used for calling pylutron_caseta.""" + return self._device["device_id"] + + @property + def serial(self) -> int | None: + """Return the serial number of the device.""" + return self._device["serial"] + + @property + def unique_id(self) -> str: + """Return the unique ID of the device (serial).""" + return str(self._handle_none_serial(self.serial)) + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + attributes = { + "device_id": self.device_id, + } + if zone := self._device.get("zone"): + attributes["zone_id"] = zone + return attributes + + +class LutronCasetaUpdatableEntity(LutronCasetaEntity): + """A lutron_caseta entity that can update by syncing data from the bridge.""" + + async def async_update(self) -> None: + """Update when forcing a refresh of the device.""" + self._device = self._smartbridge.get_device_by_id(self.device_id) + _LOGGER.debug(self._device) diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index f15f6d53e15..e2bf7f15098 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -18,7 +18,7 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from . import LutronCasetaDeviceUpdatableEntity +from .entity import LutronCasetaUpdatableEntity from .models import LutronCasetaConfigEntry DEFAULT_ON_PERCENTAGE = 50 @@ -41,7 +41,7 @@ async def async_setup_entry( async_add_entities(LutronCasetaFan(fan_device, data) for fan_device in fan_devices) -class LutronCasetaFan(LutronCasetaDeviceUpdatableEntity, FanEntity): +class LutronCasetaFan(LutronCasetaUpdatableEntity, FanEntity): """Representation of a Lutron Caseta fan. Including Fan Speed.""" _attr_supported_features = ( diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index 7eed03a1e06..146ed826c14 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -24,8 +24,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LutronCasetaDeviceUpdatableEntity from .const import DEVICE_TYPE_SPECTRUM_TUNE, DEVICE_TYPE_WHITE_TUNE +from .entity import LutronCasetaUpdatableEntity from .models import LutronCasetaData SUPPORTED_COLOR_MODE_DICT = { @@ -68,7 +68,7 @@ async def async_setup_entry( ) -class LutronCasetaLight(LutronCasetaDeviceUpdatableEntity, LightEntity): +class LutronCasetaLight(LutronCasetaUpdatableEntity, LightEntity): """Representation of a Lutron Light, including dimmable, white tune, and spectrum tune.""" _attr_supported_features = LightEntityFeature.TRANSITION diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index b8543309fbf..5037d077a02 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LutronCasetaDeviceUpdatableEntity +from .entity import LutronCasetaUpdatableEntity async def async_setup_entry( @@ -28,7 +28,7 @@ async def async_setup_entry( ) -class LutronCasetaLight(LutronCasetaDeviceUpdatableEntity, SwitchEntity): +class LutronCasetaLight(LutronCasetaUpdatableEntity, SwitchEntity): """Representation of a Lutron Caseta switch.""" def __init__(self, device, data): diff --git a/homeassistant/components/lutron_caseta/util.py b/homeassistant/components/lutron_caseta/util.py index 07b5b502fd0..d4f0a9083fe 100644 --- a/homeassistant/components/lutron_caseta/util.py +++ b/homeassistant/components/lutron_caseta/util.py @@ -2,7 +2,30 @@ from __future__ import annotations +from .const import UNASSIGNED_AREA + def serial_to_unique_id(serial: int) -> str: """Convert a lutron serial number to a unique id.""" return hex(serial)[2:].zfill(8) + + +def area_name_from_id(areas: dict[str, dict], area_id: str | None) -> str: + """Return the full area name including parent(s).""" + if area_id is None: + return UNASSIGNED_AREA + return _construct_area_name_from_id(areas, area_id, []) + + +def _construct_area_name_from_id( + areas: dict[str, dict], area_id: str, labels: list[str] +) -> str: + """Recursively construct the full area name including parent(s).""" + area = areas[area_id] + parent_area_id = area["parent_id"] + if parent_area_id is None: + # This is the root area, return last area + return " ".join(labels) + + labels.insert(0, area["name"]) + return _construct_area_name_from_id(areas, parent_area_id, labels) From ecea251efae15f04862f8ed89723df8b98429f00 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:39:49 +0200 Subject: [PATCH 0737/1309] Move and rename ihc base entity to separate module (#126101) --- homeassistant/components/ihc/binary_sensor.py | 4 ++-- homeassistant/components/ihc/{ihcdevice.py => entity.py} | 4 ++-- homeassistant/components/ihc/light.py | 4 ++-- homeassistant/components/ihc/sensor.py | 4 ++-- homeassistant/components/ihc/switch.py | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) rename homeassistant/components/ihc/{ihcdevice.py => entity.py} (97%) diff --git a/homeassistant/components/ihc/binary_sensor.py b/homeassistant/components/ihc/binary_sensor.py index ed273878cb4..413d89ca027 100644 --- a/homeassistant/components/ihc/binary_sensor.py +++ b/homeassistant/components/ihc/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.enum import try_parse_enum from .const import CONF_INVERTING, DOMAIN, IHC_CONTROLLER -from .ihcdevice import IHCDevice +from .entity import IHCEntity def setup_platform( @@ -48,7 +48,7 @@ def setup_platform( add_entities(devices) -class IHCBinarySensor(IHCDevice, BinarySensorEntity): +class IHCBinarySensor(IHCEntity, BinarySensorEntity): """IHC Binary Sensor. The associated IHC resource can be any in or output from a IHC product diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/entity.py similarity index 97% rename from homeassistant/components/ihc/ihcdevice.py rename to homeassistant/components/ihc/entity.py index 07ff71b812a..f73c3079867 100644 --- a/homeassistant/components/ihc/ihcdevice.py +++ b/homeassistant/components/ihc/entity.py @@ -11,10 +11,10 @@ from .const import CONF_INFO, DOMAIN _LOGGER = logging.getLogger(__name__) -class IHCDevice(Entity): +class IHCEntity(Entity): """Base class for all IHC devices. - All IHC devices have an associated IHC resource. IHCDevice handled the + All IHC devices have an associated IHC resource. IHCEntity handled the registration of the IHC controller callback when the IHC resource changes. Derived classes must implement the on_ihc_change method """ diff --git a/homeassistant/components/ihc/light.py b/homeassistant/components/ihc/light.py index 98e373daff4..47f343304dc 100644 --- a/homeassistant/components/ihc/light.py +++ b/homeassistant/components/ihc/light.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_DIMMABLE, CONF_OFF_ID, CONF_ON_ID, DOMAIN, IHC_CONTROLLER -from .ihcdevice import IHCDevice +from .entity import IHCEntity from .util import async_pulse, async_set_bool, async_set_int @@ -50,7 +50,7 @@ def setup_platform( add_entities(devices) -class IhcLight(IHCDevice, LightEntity): +class IhcLight(IHCEntity, LightEntity): """Representation of a IHC light. For dimmable lights, the associated IHC resource should be a light diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index 1ca41ed2666..f3b722b2cdd 100644 --- a/homeassistant/components/ihc/sensor.py +++ b/homeassistant/components/ihc/sensor.py @@ -12,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_system import TEMPERATURE_UNITS from .const import DOMAIN, IHC_CONTROLLER -from .ihcdevice import IHCDevice +from .entity import IHCEntity def setup_platform( @@ -38,7 +38,7 @@ def setup_platform( add_entities(devices) -class IHCSensor(IHCDevice, SensorEntity): +class IHCSensor(IHCEntity, SensorEntity): """Implementation of the IHC sensor.""" def __init__( diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py index f41f17bc998..b509c2dd10f 100644 --- a/homeassistant/components/ihc/switch.py +++ b/homeassistant/components/ihc/switch.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OFF_ID, CONF_ON_ID, DOMAIN, IHC_CONTROLLER -from .ihcdevice import IHCDevice +from .entity import IHCEntity from .util import async_pulse, async_set_bool @@ -43,7 +43,7 @@ def setup_platform( add_entities(devices) -class IHCSwitch(IHCDevice, SwitchEntity): +class IHCSwitch(IHCEntity, SwitchEntity): """Representation of an IHC switch.""" def __init__( From 6dfa6b000104f971c7f0433420e4d99b63393dae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:40:25 +0200 Subject: [PATCH 0738/1309] Move iaqualink base entity to separate module (#126100) --- .../components/iaqualink/__init__.py | 50 +----------------- .../components/iaqualink/binary_sensor.py | 2 +- homeassistant/components/iaqualink/climate.py | 3 +- homeassistant/components/iaqualink/entity.py | 52 +++++++++++++++++++ homeassistant/components/iaqualink/light.py | 3 +- homeassistant/components/iaqualink/sensor.py | 2 +- homeassistant/components/iaqualink/switch.py | 3 +- 7 files changed, 62 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/iaqualink/entity.py diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 36235d52ed7..26bffc4e982 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -12,7 +12,6 @@ import httpx from iaqualink.client import AqualinkClient from iaqualink.device import ( AqualinkBinarySensor, - AqualinkDevice, AqualinkLight, AqualinkSensor, AqualinkSwitch, @@ -29,16 +28,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.httpx_client import get_async_client from .const import DOMAIN, UPDATE_INTERVAL +from .entity import AqualinkEntity _LOGGER = logging.getLogger(__name__) @@ -194,44 +189,3 @@ def refresh_system[_AqualinkEntityT: AqualinkEntity, **_P]( async_dispatcher_send(self.hass, DOMAIN) return wrapper - - -class AqualinkEntity(Entity): - """Abstract class for all Aqualink platforms. - - Entity state is updated via the interval timer within the integration. - Any entity state change via the iaqualink library triggers an internal - state refresh which is then propagated to all the entities in the system - via the refresh_system decorator above to the _update_callback in this - class. - """ - - _attr_should_poll = False - - def __init__(self, dev: AqualinkDevice) -> None: - """Initialize the entity.""" - self.dev = dev - self._attr_unique_id = f"{dev.system.serial}_{dev.name}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer=dev.manufacturer, - model=dev.model, - name=dev.label, - via_device=(DOMAIN, dev.system.serial), - ) - - async def async_added_to_hass(self) -> None: - """Set up a listener when this entity is added to HA.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) - ) - - @property - def assumed_state(self) -> bool: - """Return whether the state is based on actual reading from the device.""" - return self.dev.system.online in [False, None] - - @property - def available(self) -> bool: - """Return whether the device is available or not.""" - return self.dev.system.online is True diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py index 92e152701a4..9e173dc36e0 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AqualinkEntity from .const import DOMAIN as AQUALINK_DOMAIN +from .entity import AqualinkEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 8ed3026e72e..78da1eff071 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -20,8 +20,9 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AqualinkEntity, refresh_system +from . import refresh_system from .const import DOMAIN as AQUALINK_DOMAIN +from .entity import AqualinkEntity from .utils import await_or_reraise _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/iaqualink/entity.py b/homeassistant/components/iaqualink/entity.py new file mode 100644 index 00000000000..437611e5a5f --- /dev/null +++ b/homeassistant/components/iaqualink/entity.py @@ -0,0 +1,52 @@ +"""Component to embed Aqualink devices.""" + +from __future__ import annotations + +from iaqualink.device import AqualinkDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class AqualinkEntity(Entity): + """Abstract class for all Aqualink platforms. + + Entity state is updated via the interval timer within the integration. + Any entity state change via the iaqualink library triggers an internal + state refresh which is then propagated to all the entities in the system + via the refresh_system decorator above to the _update_callback in this + class. + """ + + _attr_should_poll = False + + def __init__(self, dev: AqualinkDevice) -> None: + """Initialize the entity.""" + self.dev = dev + self._attr_unique_id = f"{dev.system.serial}_{dev.name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=dev.manufacturer, + model=dev.model, + name=dev.label, + via_device=(DOMAIN, dev.system.serial), + ) + + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) + ) + + @property + def assumed_state(self) -> bool: + """Return whether the state is based on actual reading from the device.""" + return self.dev.system.online in [False, None] + + @property + def available(self) -> bool: + """Return whether the device is available or not.""" + return self.dev.system.online is True diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 74ffe489a51..59172c13576 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -18,8 +18,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AqualinkEntity, refresh_system +from . import refresh_system from .const import DOMAIN as AQUALINK_DOMAIN +from .entity import AqualinkEntity from .utils import await_or_reraise PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index 35dc01928ec..881adb420bf 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -14,8 +14,8 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AqualinkEntity from .const import DOMAIN as AQUALINK_DOMAIN +from .entity import AqualinkEntity PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index 43b35b456a3..601c5701a4a 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -11,8 +11,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AqualinkEntity, refresh_system +from . import refresh_system from .const import DOMAIN as AQUALINK_DOMAIN +from .entity import AqualinkEntity from .utils import await_or_reraise PARALLEL_UPDATES = 0 From 1afcbd02a9e79b2ffe99799ec4ea77022becb526 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:40:33 +0200 Subject: [PATCH 0739/1309] Move insteon base entity to separate module (#126099) --- homeassistant/components/insteon/binary_sensor.py | 2 +- homeassistant/components/insteon/climate.py | 2 +- homeassistant/components/insteon/cover.py | 2 +- .../components/insteon/{insteon_entity.py => entity.py} | 0 homeassistant/components/insteon/fan.py | 2 +- homeassistant/components/insteon/light.py | 2 +- homeassistant/components/insteon/lock.py | 2 +- homeassistant/components/insteon/switch.py | 2 +- homeassistant/components/insteon/utils.py | 2 +- tests/components/insteon/test_lock.py | 8 ++------ 10 files changed, 10 insertions(+), 14 deletions(-) rename homeassistant/components/insteon/{insteon_entity.py => entity.py} (100%) diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index fb19d2287cc..abb26b7f8e8 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -25,7 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES -from .insteon_entity import InsteonEntity +from .entity import InsteonEntity from .utils import async_add_insteon_devices, async_add_insteon_entities SENSOR_TYPES = { diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index ffdd17f3ac0..3db8edbf1c9 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -23,7 +23,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES -from .insteon_entity import InsteonEntity +from .entity import InsteonEntity from .utils import async_add_insteon_devices, async_add_insteon_entities FAN_ONLY = "fan_only" diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index 60c4593f3c5..fe4f484798d 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -15,7 +15,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES -from .insteon_entity import InsteonEntity +from .entity import InsteonEntity from .utils import async_add_insteon_devices, async_add_insteon_entities diff --git a/homeassistant/components/insteon/insteon_entity.py b/homeassistant/components/insteon/entity.py similarity index 100% rename from homeassistant/components/insteon/insteon_entity.py rename to homeassistant/components/insteon/entity.py diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index 0a31e5915f6..c13e22bf8c5 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -17,7 +17,7 @@ from homeassistant.util.percentage import ( ) from .const import SIGNAL_ADD_ENTITIES -from .insteon_entity import InsteonEntity +from .entity import InsteonEntity from .utils import async_add_insteon_devices, async_add_insteon_entities SPEED_RANGE = (1, 255) # off is not included diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index f6752db3cf1..d19f3cca34a 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -13,7 +13,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES -from .insteon_entity import InsteonEntity +from .entity import InsteonEntity from .utils import async_add_insteon_devices, async_add_insteon_entities MAX_BRIGHTNESS = 255 diff --git a/homeassistant/components/insteon/lock.py b/homeassistant/components/insteon/lock.py index 27fb0fd42d8..d5f30eacbac 100644 --- a/homeassistant/components/insteon/lock.py +++ b/homeassistant/components/insteon/lock.py @@ -10,7 +10,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES -from .insteon_entity import InsteonEntity +from .entity import InsteonEntity from .utils import async_add_insteon_devices, async_add_insteon_entities diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py index b60729232f2..67ce5fa8c0d 100644 --- a/homeassistant/components/insteon/switch.py +++ b/homeassistant/components/insteon/switch.py @@ -10,7 +10,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import SIGNAL_ADD_ENTITIES -from .insteon_entity import InsteonEntity +from .entity import InsteonEntity from .utils import async_add_insteon_devices, async_add_insteon_entities diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 26d1aab4928..7c598b476a4 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -98,7 +98,7 @@ from .schemas import ( ) if TYPE_CHECKING: - from .insteon_entity import InsteonEntity + from .entity import InsteonEntity _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/insteon/test_lock.py b/tests/components/insteon/test_lock.py index a782e006a62..f0ed0bbe66f 100644 --- a/tests/components/insteon/test_lock.py +++ b/tests/components/insteon/test_lock.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components import insteon from homeassistant.components.insteon import ( DOMAIN, - insteon_entity, + entity as insteon_entity, utils as insteon_utils, ) from homeassistant.components.lock import ( # SERVICE_LOCK,; SERVICE_UNLOCK, @@ -48,11 +48,7 @@ def patch_setup_and_devices(): patch.object(insteon, "async_close"), patch.object(insteon, "devices", devices), patch.object(insteon_utils, "devices", devices), - patch.object( - insteon_entity, - "devices", - devices, - ), + patch.object(insteon_entity, "devices", devices), ): yield From 9557386b6e0052ebd76f4bd434f8454580a0612e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:41:03 +0200 Subject: [PATCH 0740/1309] Move huawei_lte base entity to separate module (#126098) --- .../components/huawei_lte/__init__.py | 64 +--------------- .../components/huawei_lte/binary_sensor.py | 2 +- homeassistant/components/huawei_lte/button.py | 2 +- .../components/huawei_lte/device_tracker.py | 3 +- homeassistant/components/huawei_lte/entity.py | 76 +++++++++++++++++++ homeassistant/components/huawei_lte/select.py | 3 +- homeassistant/components/huawei_lte/sensor.py | 3 +- homeassistant/components/huawei_lte/switch.py | 2 +- 8 files changed, 86 insertions(+), 69 deletions(-) create mode 100644 homeassistant/components/huawei_lte/entity.py diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index ad72e839534..a5a60d8406d 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -48,8 +48,7 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -569,64 +568,3 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # from pre-2022.4ish; they can be removed while at it when/if we eventually bump and # migrate to version > 3 for some other reason. return True - - -class HuaweiLteBaseEntity(Entity): - """Huawei LTE entity base class.""" - - _available = True - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__(self, router: Router) -> None: - """Initialize.""" - self.router = router - self._unsub_handlers: list[Callable] = [] - - @property - def _device_unique_id(self) -> str: - """Return unique ID for entity within a router.""" - raise NotImplementedError - - @property - def unique_id(self) -> str: - """Return unique ID for entity.""" - return f"{self.router.config_entry.unique_id}-{self._device_unique_id}" - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - return self._available - - async def async_update(self) -> None: - """Update state.""" - raise NotImplementedError - - async def async_added_to_hass(self) -> None: - """Connect to update signals.""" - self._unsub_handlers.append( - async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self._async_maybe_update) - ) - - async def _async_maybe_update(self, config_entry_unique_id: str) -> None: - """Update state if the update signal comes from our router.""" - if config_entry_unique_id == self.router.config_entry.unique_id: - self.async_schedule_update_ha_state(True) - - async def async_will_remove_from_hass(self) -> None: - """Invoke unsubscription handlers.""" - for unsub in self._unsub_handlers: - unsub() - self._unsub_handlers.clear() - - -class HuaweiLteBaseEntityWithDevice(HuaweiLteBaseEntity): - """Base entity with device info.""" - - @property - def device_info(self) -> DeviceInfo: - """Get info for matching with parent router.""" - return DeviceInfo( - connections=self.router.device_connections, - identifiers=self.router.device_identifiers, - ) diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index c90a7854a91..06b859cea84 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -16,13 +16,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HuaweiLteBaseEntityWithDevice from .const import ( DOMAIN, KEY_MONITORING_CHECK_NOTIFICATIONS, KEY_MONITORING_STATUS, KEY_WLAN_WIFI_FEATURE_SWITCH, ) +from .entity import HuaweiLteBaseEntityWithDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huawei_lte/button.py b/homeassistant/components/huawei_lte/button.py index f494836e80d..55b009d25bf 100644 --- a/homeassistant/components/huawei_lte/button.py +++ b/homeassistant/components/huawei_lte/button.py @@ -16,8 +16,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform -from . import HuaweiLteBaseEntityWithDevice from .const import DOMAIN +from .entity import HuaweiLteBaseEntityWithDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 0e35208dcce..6a05b237160 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -20,7 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HuaweiLteBaseEntity, Router +from . import Router from .const import ( CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS, @@ -29,6 +29,7 @@ from .const import ( KEY_WLAN_HOST_LIST, UPDATE_SIGNAL, ) +from .entity import HuaweiLteBaseEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huawei_lte/entity.py b/homeassistant/components/huawei_lte/entity.py new file mode 100644 index 00000000000..99d7ca112c4 --- /dev/null +++ b/homeassistant/components/huawei_lte/entity.py @@ -0,0 +1,76 @@ +"""Support for Huawei LTE routers.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import timedelta + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import Router +from .const import UPDATE_SIGNAL + +SCAN_INTERVAL = timedelta(seconds=10) + + +class HuaweiLteBaseEntity(Entity): + """Huawei LTE entity base class.""" + + _available = True + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, router: Router) -> None: + """Initialize.""" + self.router = router + self._unsub_handlers: list[Callable] = [] + + @property + def _device_unique_id(self) -> str: + """Return unique ID for entity within a router.""" + raise NotImplementedError + + @property + def unique_id(self) -> str: + """Return unique ID for entity.""" + return f"{self.router.config_entry.unique_id}-{self._device_unique_id}" + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + return self._available + + async def async_update(self) -> None: + """Update state.""" + raise NotImplementedError + + async def async_added_to_hass(self) -> None: + """Connect to update signals.""" + self._unsub_handlers.append( + async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self._async_maybe_update) + ) + + async def _async_maybe_update(self, config_entry_unique_id: str) -> None: + """Update state if the update signal comes from our router.""" + if config_entry_unique_id == self.router.config_entry.unique_id: + self.async_schedule_update_ha_state(True) + + async def async_will_remove_from_hass(self) -> None: + """Invoke unsubscription handlers.""" + for unsub in self._unsub_handlers: + unsub() + self._unsub_handlers.clear() + + +class HuaweiLteBaseEntityWithDevice(HuaweiLteBaseEntity): + """Base entity with device info.""" + + @property + def device_info(self) -> DeviceInfo: + """Get info for matching with parent router.""" + return DeviceInfo( + connections=self.router.device_connections, + identifiers=self.router.device_identifiers, + ) diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py index bf8f65a8ba5..d8a16ae2f79 100644 --- a/homeassistant/components/huawei_lte/select.py +++ b/homeassistant/components/huawei_lte/select.py @@ -21,8 +21,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED -from . import HuaweiLteBaseEntityWithDevice, Router +from . import Router from .const import DOMAIN, KEY_NET_NET_MODE +from .entity import HuaweiLteBaseEntityWithDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 2a7fe5c29b2..86965e89dd0 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -30,7 +30,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import HuaweiLteBaseEntityWithDevice, Router +from . import Router from .const import ( DOMAIN, KEY_DEVICE_INFORMATION, @@ -44,6 +44,7 @@ from .const import ( KEY_SMS_SMS_COUNT, SENSOR_KEYS, ) +from .entity import HuaweiLteBaseEntityWithDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index 3a499851f9a..07fd89d0b6c 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -15,12 +15,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HuaweiLteBaseEntityWithDevice from .const import ( DOMAIN, KEY_DIALUP_MOBILE_DATASWITCH, KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH, ) +from .entity import HuaweiLteBaseEntityWithDevice _LOGGER = logging.getLogger(__name__) From c8e2408f829d93c406d83148db1ab68c5a74eeb1 Mon Sep 17 00:00:00 2001 From: Daniel Krebs Date: Tue, 17 Sep 2024 15:41:51 +0200 Subject: [PATCH 0741/1309] Allow setting volume on Ring devices (#125773) * Turn Ring Doorbell and Chime volumes into number entities. * turn RingOther volumes into numbers as well * fix linter issues * move other volume strings into `number` section * add back old volume sensors but deprecate them * add tests for `ring.number` * add back strings for sensors that have just become deprecated * remove deprecated volume sensors from test * Revert "remove deprecated volume sensors from test" This reverts commit fc95af66e7136202dca9560325d88b811ec22c45. * create entities for deprecated sensors so that tests still run * remove print * add entities immediately * move `RingNumberEntityDescription` above `RingNumber` and remove unused import * remove irrelevant comment about history * fix not using `setter_fn` * add missing icons for other volume entities * rename `entity` -> `entity_id` in number tests * fix typing in number test * use constants for `hass.services.async_call()` * use `@refresh_after` decorator instead of delaying updates manually * move descriptors above entity class * Use snapshot to test states. * add missing snapshot file for number platform * Update homeassistant/components/ring/number.py Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --------- Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- homeassistant/components/ring/const.py | 1 + homeassistant/components/ring/icons.json | 14 + homeassistant/components/ring/number.py | 150 ++ homeassistant/components/ring/sensor.py | 12 + homeassistant/components/ring/strings.json | 14 + tests/components/ring/device_mocks.py | 18 +- .../ring/snapshots/test_number.ambr | 2353 +++++++++++++++++ tests/components/ring/test_number.py | 95 + tests/components/ring/test_sensor.py | 36 +- 9 files changed, 2687 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/ring/number.py create mode 100644 tests/components/ring/snapshots/test_number.ambr create mode 100644 tests/components/ring/test_number.py diff --git a/homeassistant/components/ring/const.py b/homeassistant/components/ring/const.py index 5fac77d63bb..24801045b17 100644 --- a/homeassistant/components/ring/const.py +++ b/homeassistant/components/ring/const.py @@ -20,6 +20,7 @@ PLATFORMS = [ Platform.CAMERA, Platform.EVENT, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, diff --git a/homeassistant/components/ring/icons.json b/homeassistant/components/ring/icons.json index b765293ec04..0798d910b7b 100644 --- a/homeassistant/components/ring/icons.json +++ b/homeassistant/components/ring/icons.json @@ -1,5 +1,19 @@ { "entity": { + "number": { + "volume": { + "default": "mdi:bell-ring" + }, + "doorbell_volume": { + "default": "mdi:bell-ring" + }, + "mic_volume": { + "default": "mdi:microphone" + }, + "voice_volume": { + "default": "mdi:account-voice" + } + }, "sensor": { "last_activity": { "default": "mdi:history" diff --git a/homeassistant/components/ring/number.py b/homeassistant/components/ring/number.py new file mode 100644 index 00000000000..91aabb6c800 --- /dev/null +++ b/homeassistant/components/ring/number.py @@ -0,0 +1,150 @@ +"""Component providing HA number support for Ring Door Bell/Chimes.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Generic, cast + +from ring_doorbell import RingChime, RingDoorBell, RingGeneric, RingOther +import ring_doorbell.const + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import RingConfigEntry +from .coordinator import RingDataCoordinator +from .entity import RingDeviceT, RingEntity, refresh_after + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RingConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a numbers for a Ring device.""" + ring_data = entry.runtime_data + devices_coordinator = ring_data.devices_coordinator + + async_add_entities( + RingNumber(device, devices_coordinator, description) + for description in NUMBER_TYPES + for device in ring_data.devices.all_devices + if description.exists_fn(device) + ) + + +@dataclass(frozen=True, kw_only=True) +class RingNumberEntityDescription(NumberEntityDescription, Generic[RingDeviceT]): + """Describes Ring number entity.""" + + value_fn: Callable[[RingDeviceT], StateType] + setter_fn: Callable[[RingDeviceT, float], Awaitable[None]] + exists_fn: Callable[[RingGeneric], bool] + + +NUMBER_TYPES: tuple[RingNumberEntityDescription[Any], ...] = ( + RingNumberEntityDescription[RingChime]( + key="volume", + translation_key="volume", + mode=NumberMode.SLIDER, + native_min_value=ring_doorbell.const.CHIME_VOL_MIN, + native_max_value=ring_doorbell.const.CHIME_VOL_MAX, + native_step=1, + value_fn=lambda device: device.volume, + setter_fn=lambda device, value: device.async_set_volume(int(value)), + exists_fn=lambda device: isinstance(device, RingChime), + ), + RingNumberEntityDescription[RingDoorBell]( + key="volume", + translation_key="volume", + mode=NumberMode.SLIDER, + native_min_value=ring_doorbell.const.DOORBELL_VOL_MIN, + native_max_value=ring_doorbell.const.DOORBELL_VOL_MAX, + native_step=1, + value_fn=lambda device: device.volume, + setter_fn=lambda device, value: device.async_set_volume(int(value)), + exists_fn=lambda device: isinstance(device, RingDoorBell), + ), + RingNumberEntityDescription[RingOther]( + key="doorbell_volume", + translation_key="doorbell_volume", + mode=NumberMode.SLIDER, + native_min_value=ring_doorbell.const.OTHER_DOORBELL_VOL_MIN, + native_max_value=ring_doorbell.const.OTHER_DOORBELL_VOL_MAX, + native_step=1, + value_fn=lambda device: device.doorbell_volume, + setter_fn=lambda device, value: device.async_set_doorbell_volume(int(value)), + exists_fn=lambda device: isinstance(device, RingOther), + ), + RingNumberEntityDescription[RingOther]( + key="mic_volume", + translation_key="mic_volume", + mode=NumberMode.SLIDER, + native_min_value=ring_doorbell.const.MIC_VOL_MIN, + native_max_value=ring_doorbell.const.MIC_VOL_MAX, + native_step=1, + value_fn=lambda device: device.mic_volume, + setter_fn=lambda device, value: device.async_set_mic_volume(int(value)), + exists_fn=lambda device: isinstance(device, RingOther), + ), + RingNumberEntityDescription[RingOther]( + key="voice_volume", + translation_key="voice_volume", + mode=NumberMode.SLIDER, + native_min_value=ring_doorbell.const.VOICE_VOL_MIN, + native_max_value=ring_doorbell.const.VOICE_VOL_MAX, + native_step=1, + value_fn=lambda device: device.voice_volume, + setter_fn=lambda device, value: device.async_set_voice_volume(int(value)), + exists_fn=lambda device: isinstance(device, RingOther), + ), +) + + +class RingNumber(RingEntity[RingDeviceT], NumberEntity): + """A number implementation for Ring device.""" + + entity_description: RingNumberEntityDescription[RingDeviceT] + + def __init__( + self, + device: RingDeviceT, + coordinator: RingDataCoordinator, + description: RingNumberEntityDescription[RingDeviceT], + ) -> None: + """Initialize a number for Ring device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{device.id}-{description.key}" + self._update_native_value() + + def _update_native_value(self) -> None: + native_value = self.entity_description.value_fn(self._device) + if native_value is not None: + self._attr_native_value = float(native_value) + + @callback + def _handle_coordinator_update(self) -> None: + """Call update method.""" + + self._device = cast( + RingDeviceT, + self._get_coordinator_data().get_device(self._device.device_api_id), + ) + + self._update_native_value() + + super()._handle_coordinator_update() + + @refresh_after + async def async_set_native_value(self, value: float) -> None: + """Call setter on Ring device.""" + await self.entity_description.setter_fn(self._device, value) + + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 219f1b0224c..dee67882857 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -215,24 +215,36 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = ( translation_key="volume", value_fn=lambda device: device.volume, exists_fn=lambda device: isinstance(device, (RingDoorBell, RingChime)), + deprecated_info=DeprecatedInfo( + new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0" + ), ), RingSensorEntityDescription[RingOther]( key="doorbell_volume", translation_key="doorbell_volume", value_fn=lambda device: device.doorbell_volume, exists_fn=lambda device: isinstance(device, RingOther), + deprecated_info=DeprecatedInfo( + new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0" + ), ), RingSensorEntityDescription[RingOther]( key="mic_volume", translation_key="mic_volume", value_fn=lambda device: device.mic_volume, exists_fn=lambda device: isinstance(device, RingOther), + deprecated_info=DeprecatedInfo( + new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0" + ), ), RingSensorEntityDescription[RingOther]( key="voice_volume", translation_key="voice_volume", value_fn=lambda device: device.voice_volume, exists_fn=lambda device: isinstance(device, RingOther), + deprecated_info=DeprecatedInfo( + new_platform=Platform.NUMBER, breaks_in_ha_version="2025.4.0" + ), ), RingSensorEntityDescription[RingGeneric]( key="wifi_signal_category", diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 142b83ab51a..201832b9465 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -58,6 +58,20 @@ "name": "[%key:component::light::title%]" } }, + "number": { + "volume": { + "name": "Volume" + }, + "doorbell_volume": { + "name": "Doorbell volume" + }, + "mic_volume": { + "name": "Mic volume" + }, + "voice_volume": { + "name": "Voice volume" + } + }, "siren": { "siren": { "name": "[%key:component::siren::title%]" diff --git a/tests/components/ring/device_mocks.py b/tests/components/ring/device_mocks.py index 29fd5fb757a..cdb93d9911d 100644 --- a/tests/components/ring/device_mocks.py +++ b/tests/components/ring/device_mocks.py @@ -8,6 +8,7 @@ Mocks the api calls on the devices such as history() and health(). """ from datetime import datetime +from functools import partial from unittest.mock import AsyncMock, MagicMock from ring_doorbell import ( @@ -153,6 +154,9 @@ def _mocked_ring_device(device_dict, device_family, device_class, capabilities): "doorbell_volume", device_dict["settings"].get("volume") ) ) + mock_device.async_set_volume.side_effect = lambda i: mock_device.configure_mock( + volume=i + ) if has_capability(RingCapability.SIREN): mock_device.configure_mock( @@ -170,10 +174,14 @@ def _mocked_ring_device(device_dict, device_family, device_class, capabilities): ) if device_family == "other": - mock_device.configure_mock( - doorbell_volume=device_dict["settings"].get("doorbell_volume"), - mic_volume=device_dict["settings"].get("mic_volume"), - voice_volume=device_dict["settings"].get("voice_volume"), - ) + for prop in ("doorbell_volume", "mic_volume", "voice_volume"): + mock_device.configure_mock( + **{ + prop: device_dict["settings"].get(prop), + f"async_set_{prop}.side_effect": partial( + setattr, mock_device, prop + ), + } + ) return mock_device diff --git a/tests/components/ring/snapshots/test_number.ambr b/tests/components/ring/snapshots/test_number.ambr new file mode 100644 index 00000000000..97059527ade --- /dev/null +++ b/tests/components/ring/snapshots/test_number.ambr @@ -0,0 +1,2353 @@ +# serializer version: 1 +# name: test_states[number.downstairs_volume-2.0][number.downstairs_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.downstairs_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '123456-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.downstairs_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Downstairs Volume', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.downstairs_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.front_door_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_door_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '987654-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.front_door_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Door Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_door_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.front_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '765432-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.front_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.ingress_doorbell_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_doorbell_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_volume', + 'unique_id': '185036587-doorbell_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.ingress_doorbell_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Doorbell volume', + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_doorbell_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.ingress_mic_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_mic_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mic volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mic_volume', + 'unique_id': '185036587-mic_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.ingress_mic_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Mic volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_mic_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.ingress_voice_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_voice_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voice volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voice_volume', + 'unique_id': '185036587-voice_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.ingress_voice_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Voice volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_voice_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.internal_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.internal_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '345678-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.downstairs_volume-2.0][number.internal_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Internal Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.internal_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.downstairs_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.downstairs_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '123456-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.downstairs_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Downstairs Volume', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.downstairs_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.downstairs_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.downstairs_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '123456-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.downstairs_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Downstairs Volume', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.downstairs_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.front_door_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_door_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '987654-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.front_door_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Door Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_door_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.front_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '765432-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.front_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.ingress_doorbell_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_doorbell_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_volume', + 'unique_id': '185036587-doorbell_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.ingress_doorbell_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Doorbell volume', + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_doorbell_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.ingress_mic_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_mic_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mic volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mic_volume', + 'unique_id': '185036587-mic_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.ingress_mic_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Mic volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_mic_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.ingress_voice_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_voice_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voice volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voice_volume', + 'unique_id': '185036587-voice_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.ingress_voice_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Voice volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_voice_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.internal_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.internal_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '345678-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.front_door_volume-1.0][number.internal_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Internal Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.internal_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.front_door_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_door_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '987654-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.front_door_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Door Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_door_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_states[number.front_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '765432-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.front_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.downstairs_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.downstairs_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '123456-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.downstairs_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Downstairs Volume', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.downstairs_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.front_door_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_door_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '987654-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.front_door_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Door Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_door_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.front_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '765432-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.front_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.ingress_doorbell_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_doorbell_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_volume', + 'unique_id': '185036587-doorbell_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.ingress_doorbell_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Doorbell volume', + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_doorbell_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.ingress_mic_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_mic_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mic volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mic_volume', + 'unique_id': '185036587-mic_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.ingress_mic_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Mic volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_mic_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.ingress_voice_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_voice_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voice volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voice_volume', + 'unique_id': '185036587-voice_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.ingress_voice_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Voice volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_voice_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.internal_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.internal_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '345678-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_doorbell_volume-8.0][number.internal_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Internal Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.internal_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_doorbell_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_doorbell_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_volume', + 'unique_id': '185036587-doorbell_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_doorbell_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Doorbell volume', + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_doorbell_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.downstairs_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.downstairs_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '123456-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.downstairs_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Downstairs Volume', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.downstairs_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.front_door_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_door_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '987654-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.front_door_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Door Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_door_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.front_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '765432-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.front_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.ingress_doorbell_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_doorbell_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_volume', + 'unique_id': '185036587-doorbell_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.ingress_doorbell_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Doorbell volume', + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_doorbell_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.ingress_mic_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_mic_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mic volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mic_volume', + 'unique_id': '185036587-mic_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.ingress_mic_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Mic volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_mic_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.ingress_voice_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_voice_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voice volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voice_volume', + 'unique_id': '185036587-voice_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.ingress_voice_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Voice volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_voice_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.internal_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.internal_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '345678-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_mic_volume-11.0][number.internal_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Internal Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.internal_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_mic_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_mic_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mic volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mic_volume', + 'unique_id': '185036587-mic_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_mic_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Mic volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_mic_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.downstairs_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.downstairs_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '123456-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.downstairs_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Downstairs Volume', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.downstairs_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.front_door_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_door_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '987654-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.front_door_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Door Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_door_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.front_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.front_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '765432-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.front_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.front_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.ingress_doorbell_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_doorbell_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_volume', + 'unique_id': '185036587-doorbell_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.ingress_doorbell_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Doorbell volume', + 'max': 8, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_doorbell_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.0', + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.ingress_mic_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_mic_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mic volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mic_volume', + 'unique_id': '185036587-mic_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.ingress_mic_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Mic volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_mic_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.ingress_voice_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_voice_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voice volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voice_volume', + 'unique_id': '185036587-voice_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.ingress_voice_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Voice volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_voice_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.internal_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.internal_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '345678-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_voice_volume-11.0][number.internal_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Internal Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.internal_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.ingress_voice_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.ingress_voice_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voice volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voice_volume', + 'unique_id': '185036587-voice_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.ingress_voice_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Voice volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.ingress_voice_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- +# name: test_states[number.internal_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.internal_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '345678-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[number.internal_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Internal Volume', + 'max': 11, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.internal_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.0', + }) +# --- diff --git a/tests/components/ring/test_number.py b/tests/components/ring/test_number.py new file mode 100644 index 00000000000..aa484c6a7b2 --- /dev/null +++ b/tests/components/ring/test_number.py @@ -0,0 +1,95 @@ +"""The tests for the Ring number platform.""" + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import MockConfigEntry, setup_platform + +from tests.common import snapshot_platform + + +@pytest.mark.parametrize( + ("entity_id", "unique_id"), + [ + ("number.downstairs_volume", "123456-volume"), + ("number.front_door_volume", "987654-volume"), + ("number.ingress_doorbell_volume", "185036587-doorbell_volume"), + ("number.ingress_mic_volume", "185036587-mic_volume"), + ("number.ingress_voice_volume", "185036587-voice_volume"), + ], +) +async def test_entity_registry( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_ring_client: Mock, + entity_id: str, + unique_id: str, +) -> None: + """Tests that the devices are registered in the entity registry.""" + await setup_platform(hass, Platform.NUMBER) + + entry = entity_registry.async_get(entity_id) + assert entry is not None and entry.unique_id == unique_id + + +async def test_states( + hass: HomeAssistant, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test states.""" + + mock_config_entry.add_to_hass(hass) + await setup_platform(hass, Platform.NUMBER) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "new_value"), + [ + ("number.downstairs_volume", "4.0"), + ("number.front_door_volume", "3.0"), + ("number.ingress_doorbell_volume", "7.0"), + ("number.ingress_mic_volume", "2.0"), + ("number.ingress_voice_volume", "5.0"), + ], +) +async def test_volume_can_be_changed( + hass: HomeAssistant, + mock_ring_client: Mock, + entity_id: str, + new_value: str, +) -> None: + """Tests the volume can be changed correctly.""" + await setup_platform(hass, Platform.NUMBER) + + state = hass.states.get(entity_id) + assert state is not None + old_value = state.state + + # otherwise this test would be pointless + assert old_value != new_value + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: new_value}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state is not None and state.state == new_value diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index dead52a5acc..07f35a3ff79 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -25,7 +25,41 @@ from .device_mocks import FRONT_DEVICE_ID, FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ from tests.common import async_fire_time_changed -async def test_sensor(hass: HomeAssistant, mock_ring_client) -> None: +@pytest.fixture +def create_deprecated_sensor_entities( + hass: HomeAssistant, + mock_config_entry: ConfigEntry, + 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, + description, + device_id, + ): + unique_id = f"{device_id}-{description}" + entity_registry.async_get_or_create( + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=unique_id, + suggested_object_id=f"{device_name}_{description}", + config_entry=mock_config_entry, + ) + + create_entry("downstairs", "volume", 123456) + create_entry("front_door", "volume", 987654) + create_entry("ingress", "doorbell_volume", 185036587) + create_entry("ingress", "mic_volume", 185036587) + create_entry("ingress", "voice_volume", 185036587) + + +async def test_sensor( + hass: HomeAssistant, + mock_ring_client, + create_deprecated_sensor_entities, +) -> None: """Test the Ring sensors.""" await setup_platform(hass, "sensor") From c20d07c14a8ce93b76519c6a928f8936d35c812c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:44:11 +0200 Subject: [PATCH 0742/1309] Move and rename hlk_sw16 base entity to separate module (#126096) --- homeassistant/components/hlk_sw16/__init__.py | 56 +----------------- homeassistant/components/hlk_sw16/entity.py | 59 +++++++++++++++++++ homeassistant/components/hlk_sw16/switch.py | 5 +- 3 files changed, 63 insertions(+), 57 deletions(-) create mode 100644 homeassistant/components/hlk_sw16/entity.py diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index 3e6a9f6b0d6..ce37be96dcd 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -9,11 +9,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES, Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from .const import ( @@ -131,53 +127,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) return unload_ok - - -class SW16Device(Entity): - """Representation of a HLK-SW16 device. - - Contains the common logic for HLK-SW16 entities. - """ - - _attr_should_poll = False - - def __init__(self, device_port, entry_id, client): - """Initialize the device.""" - # HLK-SW16 specific attributes for every component type - self._entry_id = entry_id - self._device_port = device_port - self._is_on = None - self._client = client - self._attr_name = device_port - self._attr_unique_id = f"{self._entry_id}_{self._device_port}" - - @callback - def handle_event_callback(self, event): - """Propagate changes through ha.""" - _LOGGER.debug("Relay %s new state callback: %r", self.unique_id, event) - self._is_on = event - self.async_write_ha_state() - - @property - def available(self): - """Return True if entity is available.""" - return bool(self._client.is_connected) - - @callback - def _availability_callback(self, availability): - """Update availability state.""" - self.async_write_ha_state() - - async def async_added_to_hass(self): - """Register update callback.""" - self._client.register_status_callback( - self.handle_event_callback, self._device_port - ) - self._is_on = await self._client.status(self._device_port) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"hlk_sw16_device_available_{self._entry_id}", - self._availability_callback, - ) - ) diff --git a/homeassistant/components/hlk_sw16/entity.py b/homeassistant/components/hlk_sw16/entity.py new file mode 100644 index 00000000000..fdef5f6764b --- /dev/null +++ b/homeassistant/components/hlk_sw16/entity.py @@ -0,0 +1,59 @@ +"""Support for HLK-SW16 relay switches.""" + +import logging + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + + +class SW16Entity(Entity): + """Representation of a HLK-SW16 device. + + Contains the common logic for HLK-SW16 entities. + """ + + _attr_should_poll = False + + def __init__(self, device_port, entry_id, client): + """Initialize the device.""" + # HLK-SW16 specific attributes for every component type + self._entry_id = entry_id + self._device_port = device_port + self._is_on = None + self._client = client + self._attr_name = device_port + self._attr_unique_id = f"{self._entry_id}_{self._device_port}" + + @callback + def handle_event_callback(self, event): + """Propagate changes through ha.""" + _LOGGER.debug("Relay %s new state callback: %r", self.unique_id, event) + self._is_on = event + self.async_write_ha_state() + + @property + def available(self): + """Return True if entity is available.""" + return bool(self._client.is_connected) + + @callback + def _availability_callback(self, availability): + """Update availability state.""" + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Register update callback.""" + self._client.register_status_callback( + self.handle_event_callback, self._device_port + ) + self._is_on = await self._client.status(self._device_port) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"hlk_sw16_device_available_{self._entry_id}", + self._availability_callback, + ) + ) diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py index 590ab9c4497..3911dd6eab9 100644 --- a/homeassistant/components/hlk_sw16/switch.py +++ b/homeassistant/components/hlk_sw16/switch.py @@ -7,8 +7,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DATA_DEVICE_REGISTER, SW16Device +from . import DATA_DEVICE_REGISTER from .const import DOMAIN +from .entity import SW16Entity PARALLEL_UPDATES = 0 @@ -31,7 +32,7 @@ async def async_setup_entry( async_add_entities(devices_from_entities(hass, entry)) -class SW16Switch(SW16Device, SwitchEntity): +class SW16Switch(SW16Entity, SwitchEntity): """Representation of a HLK-SW16 switch.""" @property From a9c479a78b3b597c3602b2aa98abfa70488d427d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:44:52 +0200 Subject: [PATCH 0743/1309] Move hive base entity to separate module (#126095) --- homeassistant/components/hive/__init__.py | 35 ++--------------- .../components/hive/alarm_control_panel.py | 2 +- .../components/hive/binary_sensor.py | 2 +- homeassistant/components/hive/climate.py | 3 +- homeassistant/components/hive/entity.py | 39 +++++++++++++++++++ homeassistant/components/hive/light.py | 3 +- homeassistant/components/hive/sensor.py | 2 +- homeassistant/components/hive/switch.py | 3 +- homeassistant/components/hive/water_heater.py | 3 +- 9 files changed, 53 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/hive/entity.py diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 4001215d90e..1c11ccad595 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -18,15 +18,12 @@ from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORM_LOOKUP, PLATFORMS +from .entity import HiveEntity _LOGGER = logging.getLogger(__name__) @@ -139,29 +136,3 @@ def refresh_system[_HiveEntityT: HiveEntity, **_P]( async_dispatcher_send(self.hass, DOMAIN) return wrapper - - -class HiveEntity(Entity): - """Initiate Hive Base Class.""" - - def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: - """Initialize the instance.""" - self.hive = hive - self.device = hive_device - self._attr_name = self.device["haName"] - self._attr_unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}' - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - model=self.device["deviceData"]["model"], - manufacturer=self.device["deviceData"]["manufacturer"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - self.attributes: dict[str, Any] = {} - - async def async_added_to_hass(self) -> None: - """When entity is added to Home Assistant.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) - ) diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py index 06383784a3f..34d5d3d10c6 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -18,8 +18,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HiveEntity from .const import DOMAIN +from .entity import HiveEntity PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index 512b06ece6d..d14d98bcf50 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -14,8 +14,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HiveEntity from .const import DOMAIN +from .entity import HiveEntity PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index 87d93eea95f..4e5ea95f2fa 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -21,13 +21,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HiveEntity, refresh_system +from . import refresh_system from .const import ( ATTR_TIME_PERIOD, DOMAIN, SERVICE_BOOST_HEATING_OFF, SERVICE_BOOST_HEATING_ON, ) +from .entity import HiveEntity HIVE_TO_HASS_STATE = { "SCHEDULE": HVACMode.AUTO, diff --git a/homeassistant/components/hive/entity.py b/homeassistant/components/hive/entity.py new file mode 100644 index 00000000000..1209e8c8f05 --- /dev/null +++ b/homeassistant/components/hive/entity.py @@ -0,0 +1,39 @@ +"""Support for the Hive devices and services.""" + +from __future__ import annotations + +from typing import Any + +from apyhiveapi import Hive + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class HiveEntity(Entity): + """Initiate Hive Base Class.""" + + def __init__(self, hive: Hive, hive_device: dict[str, Any]) -> None: + """Initialize the instance.""" + self.hive = hive + self.device = hive_device + self._attr_name = self.device["haName"] + self._attr_unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}' + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + model=self.device["deviceData"]["model"], + manufacturer=self.device["deviceData"]["manufacturer"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) + self.attributes: dict[str, Any] = {} + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) + ) diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index 1ce49599262..10de781bf1d 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -17,8 +17,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util -from . import HiveEntity, refresh_system +from . import refresh_system from .const import ATTR_MODE, DOMAIN +from .entity import HiveEntity if TYPE_CHECKING: from apyhiveapi import Hive diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index d51acecc9f6..97f7a07237d 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -24,8 +24,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import HiveEntity from .const import DOMAIN +from .entity import HiveEntity PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index 136f03de195..1421616db57 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -13,8 +13,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HiveEntity, refresh_system +from . import refresh_system from .const import ATTR_MODE, DOMAIN +from .entity import HiveEntity PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py index 2e582e19567..b038739d2ad 100644 --- a/homeassistant/components/hive/water_heater.py +++ b/homeassistant/components/hive/water_heater.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HiveEntity, refresh_system +from . import refresh_system from .const import ( ATTR_ONOFF, ATTR_TIME_PERIOD, @@ -24,6 +24,7 @@ from .const import ( SERVICE_BOOST_HOT_WATER, WATER_HEATER_MODES, ) +from .entity import HiveEntity HOTWATER_NAME = "Hot Water" PARALLEL_UPDATES = 0 From f3facac0168666c24ae3e85d0bcdd516de15acce Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 15:46:04 +0200 Subject: [PATCH 0744/1309] Move homematicip_cloud base entity to separate module (#126094) * Move homematicip_cloud base entity to separate module * One more --- .../components/homematicip_cloud/__init__.py | 3 +- .../homematicip_cloud/alarm_control_panel.py | 8 +-- .../homematicip_cloud/binary_sensor.py | 7 +-- .../components/homematicip_cloud/button.py | 5 +- .../components/homematicip_cloud/climate.py | 9 ++-- .../components/homematicip_cloud/cover.py | 5 +- .../{generic_entity.py => entity.py} | 6 +-- .../components/homematicip_cloud/helpers.py | 2 +- .../components/homematicip_cloud/light.py | 5 +- .../components/homematicip_cloud/lock.py | 5 +- .../components/homematicip_cloud/sensor.py | 5 +- .../components/homematicip_cloud/services.py | 50 +++++++++---------- .../components/homematicip_cloud/switch.py | 6 +-- .../components/homematicip_cloud/weather.py | 5 +- tests/components/homematicip_cloud/helper.py | 2 +- .../homematicip_cloud/test_binary_sensor.py | 2 +- .../homematicip_cloud/test_sensor.py | 2 +- .../homematicip_cloud/test_switch.py | 2 +- 18 files changed, 68 insertions(+), 61 deletions(-) rename homeassistant/components/homematicip_cloud/{generic_entity.py => entity.py} (98%) diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 08002bc551a..c59a9d788b3 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -21,8 +21,7 @@ from .const import ( HMIPC_HAPID, HMIPC_NAME, ) -from .generic_entity import HomematicipGenericEntity # noqa: F401 -from .hap import HomematicipAuth, HomematicipHAP # noqa: F401 +from .hap import HomematicipHAP from .services import async_setup_services, async_unload_services CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index e1684c34e4e..35aa321f2a8 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as HMIPC_DOMAIN +from .const import DOMAIN from .hap import AsyncHome, HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -35,7 +35,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the HomematicIP alrm control panel from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + hap = hass.data[DOMAIN][config_entry.unique_id] async_add_entities([HomematicipAlarmControlPanelEntity(hap)]) @@ -57,11 +57,11 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): def device_info(self) -> DeviceInfo: """Return device specific attributes.""" return DeviceInfo( - identifiers={(HMIPC_DOMAIN, f"ACP {self._home.id}")}, + identifiers={(DOMAIN, f"ACP {self._home.id}")}, manufacturer="eQ-3", model=CONST_ALARM_CONTROL_PANEL_NAME, name=self.name, - via_device=(HMIPC_DOMAIN, self._home.id), + via_device=(DOMAIN, self._home.id), ) @property diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 29d8576f060..38590e4505b 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -39,7 +39,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .const import DOMAIN +from .entity import HomematicipGenericEntity from .hap import HomematicipHAP ATTR_ACCELERATION_SENSOR_MODE = "acceleration_sensor_mode" @@ -78,7 +79,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud binary sensor from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)] for device in hap.home.devices: if isinstance(device, AsyncAccelerationSensor): @@ -168,7 +169,7 @@ class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEnt return DeviceInfo( identifiers={ # Serial numbers of Homematic IP device - (HMIPC_DOMAIN, self._home.id) + (DOMAIN, self._home.id) } ) diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py index c2707f68a89..244be47d7f6 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .const import DOMAIN +from .entity import HomematicipGenericEntity from .hap import HomematicipHAP @@ -19,7 +20,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the HomematicIP button from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + hap = hass.data[DOMAIN][config_entry.unique_id] async_add_entities( HomematicipGarageDoorControllerButton(hap, device) diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index dd89efed1c9..f6a69f50770 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -31,7 +31,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .const import DOMAIN +from .entity import HomematicipGenericEntity from .hap import HomematicipHAP HEATING_PROFILES = {"PROFILE_1": 0, "PROFILE_2": 1, "PROFILE_3": 2} @@ -59,7 +60,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the HomematicIP climate from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + hap = hass.data[DOMAIN][config_entry.unique_id] async_add_entities( HomematicipHeatingGroup(hap, device) @@ -94,11 +95,11 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): def device_info(self) -> DeviceInfo: """Return device specific attributes.""" return DeviceInfo( - identifiers={(HMIPC_DOMAIN, self._device.id)}, + identifiers={(DOMAIN, self._device.id)}, manufacturer="eQ-3", model=self._device.modelType, name=self._device.label, - via_device=(HMIPC_DOMAIN, self._device.homeId), + via_device=(DOMAIN, self._device.homeId), ) @property diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index b0cff8b6a10..1db536afd4f 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -25,7 +25,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .const import DOMAIN +from .entity import HomematicipGenericEntity from .hap import HomematicipHAP HMIP_COVER_OPEN = 0 @@ -40,7 +41,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the HomematicIP cover from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [ HomematicipCoverShutterGroup(hap, group) for group in hap.home.groups diff --git a/homeassistant/components/homematicip_cloud/generic_entity.py b/homeassistant/components/homematicip_cloud/entity.py similarity index 98% rename from homeassistant/components/homematicip_cloud/generic_entity.py rename to homeassistant/components/homematicip_cloud/entity.py index 276177420ed..82d682b9910 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -15,7 +15,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity -from .const import DOMAIN as HMIPC_DOMAIN +from .const import DOMAIN from .hap import AsyncHome, HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -104,14 +104,14 @@ class HomematicipGenericEntity(Entity): return DeviceInfo( identifiers={ # Serial numbers of Homematic IP device - (HMIPC_DOMAIN, self._device.id) + (DOMAIN, self._device.id) }, manufacturer=self._device.oem, model=self._device.modelType, name=self._device.label, sw_version=self._device.firmwareVersion, # Link to the homematic ip access point. - via_device=(HMIPC_DOMAIN, self._device.homeId), + via_device=(DOMAIN, self._device.homeId), ) return None diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py index 5b7f98ad884..9959b993a6c 100644 --- a/homeassistant/components/homematicip_cloud/helpers.py +++ b/homeassistant/components/homematicip_cloud/helpers.py @@ -13,7 +13,7 @@ from homematicip.device import Device from homeassistant.exceptions import HomeAssistantError -from . import HomematicipGenericEntity +from .entity import HomematicipGenericEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 17daafc5896..5a56ae69377 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -30,7 +30,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .const import DOMAIN +from .entity import HomematicipGenericEntity from .hap import HomematicipHAP @@ -40,7 +41,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud lights from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncBrandSwitchMeasuring): diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py index cf98828598f..b00f42fc844 100644 --- a/homeassistant/components/homematicip_cloud/lock.py +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -13,7 +13,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .const import DOMAIN +from .entity import HomematicipGenericEntity from .helpers import handle_errors _LOGGER = logging.getLogger(__name__) @@ -39,7 +40,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the HomematicIP locks from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + hap = hass.data[DOMAIN][config_entry.unique_id] async_add_entities( HomematicipDoorLockDrive(hap, device) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 1f76c6cce1f..a9c046e25bf 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -53,7 +53,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .const import DOMAIN +from .entity import HomematicipGenericEntity from .hap import HomematicipHAP from .helpers import get_channels_from_device @@ -91,7 +92,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the HomematicIP Cloud sensors from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncHomeControlAccessPoint): diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 4c04e4a858b..69765ccc601 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -21,7 +21,7 @@ from homeassistant.helpers.service import ( verify_domain_control, ) -from .const import DOMAIN as HMIPC_DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -122,10 +122,10 @@ SCHEMA_SET_HOME_COOLING_MODE = vol.Schema( async def async_setup_services(hass: HomeAssistant) -> None: """Set up the HomematicIP Cloud services.""" - if hass.services.async_services_for_domain(HMIPC_DOMAIN): + if hass.services.async_services_for_domain(DOMAIN): return - @verify_domain_control(hass, HMIPC_DOMAIN) + @verify_domain_control(hass, DOMAIN) async def async_call_hmipc_service(service: ServiceCall) -> None: """Call correct HomematicIP Cloud service.""" service_name = service.service @@ -150,42 +150,42 @@ async def async_setup_services(hass: HomeAssistant) -> None: await _async_set_home_cooling_mode(hass, service) hass.services.async_register( - domain=HMIPC_DOMAIN, + domain=DOMAIN, service=SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION, service_func=async_call_hmipc_service, schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION, ) hass.services.async_register( - domain=HMIPC_DOMAIN, + domain=DOMAIN, service=SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD, service_func=async_call_hmipc_service, schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD, ) hass.services.async_register( - domain=HMIPC_DOMAIN, + domain=DOMAIN, service=SERVICE_ACTIVATE_VACATION, service_func=async_call_hmipc_service, schema=SCHEMA_ACTIVATE_VACATION, ) hass.services.async_register( - domain=HMIPC_DOMAIN, + domain=DOMAIN, service=SERVICE_DEACTIVATE_ECO_MODE, service_func=async_call_hmipc_service, schema=SCHEMA_DEACTIVATE_ECO_MODE, ) hass.services.async_register( - domain=HMIPC_DOMAIN, + domain=DOMAIN, service=SERVICE_DEACTIVATE_VACATION, service_func=async_call_hmipc_service, schema=SCHEMA_DEACTIVATE_VACATION, ) hass.services.async_register( - domain=HMIPC_DOMAIN, + domain=DOMAIN, service=SERVICE_SET_ACTIVE_CLIMATE_PROFILE, service_func=async_call_hmipc_service, schema=SCHEMA_SET_ACTIVE_CLIMATE_PROFILE, @@ -193,7 +193,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: async_register_admin_service( hass=hass, - domain=HMIPC_DOMAIN, + domain=DOMAIN, service=SERVICE_DUMP_HAP_CONFIG, service_func=async_call_hmipc_service, schema=SCHEMA_DUMP_HAP_CONFIG, @@ -201,7 +201,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: async_register_admin_service( hass=hass, - domain=HMIPC_DOMAIN, + domain=DOMAIN, service=SERVICE_RESET_ENERGY_COUNTER, service_func=async_call_hmipc_service, schema=SCHEMA_RESET_ENERGY_COUNTER, @@ -209,7 +209,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: async_register_admin_service( hass=hass, - domain=HMIPC_DOMAIN, + domain=DOMAIN, service=SERVICE_SET_HOME_COOLING_MODE, service_func=async_call_hmipc_service, schema=SCHEMA_SET_HOME_COOLING_MODE, @@ -218,11 +218,11 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def async_unload_services(hass: HomeAssistant): """Unload HomematicIP Cloud services.""" - if hass.data[HMIPC_DOMAIN]: + if hass.data[DOMAIN]: return for hmipc_service in HMIPC_SERVICES: - hass.services.async_remove(domain=HMIPC_DOMAIN, service=hmipc_service) + hass.services.async_remove(domain=DOMAIN, service=hmipc_service) async def _async_activate_eco_mode_with_duration( @@ -235,7 +235,7 @@ async def _async_activate_eco_mode_with_duration( if home := _get_home(hass, hapid): await home.activate_absence_with_duration(duration) else: - for hap in hass.data[HMIPC_DOMAIN].values(): + for hap in hass.data[DOMAIN].values(): await hap.home.activate_absence_with_duration(duration) @@ -249,7 +249,7 @@ async def _async_activate_eco_mode_with_period( if home := _get_home(hass, hapid): await home.activate_absence_with_period(endtime) else: - for hap in hass.data[HMIPC_DOMAIN].values(): + for hap in hass.data[DOMAIN].values(): await hap.home.activate_absence_with_period(endtime) @@ -262,7 +262,7 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> if home := _get_home(hass, hapid): await home.activate_vacation(endtime, temperature) else: - for hap in hass.data[HMIPC_DOMAIN].values(): + for hap in hass.data[DOMAIN].values(): await hap.home.activate_vacation(endtime, temperature) @@ -272,7 +272,7 @@ async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) if home := _get_home(hass, hapid): await home.deactivate_absence() else: - for hap in hass.data[HMIPC_DOMAIN].values(): + for hap in hass.data[DOMAIN].values(): await hap.home.deactivate_absence() @@ -282,7 +282,7 @@ async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) if home := _get_home(hass, hapid): await home.deactivate_vacation() else: - for hap in hass.data[HMIPC_DOMAIN].values(): + for hap in hass.data[DOMAIN].values(): await hap.home.deactivate_vacation() @@ -293,7 +293,7 @@ async def _set_active_climate_profile( entity_id_list = service.data[ATTR_ENTITY_ID] climate_profile_index = service.data[ATTR_CLIMATE_PROFILE_INDEX] - 1 - for hap in hass.data[HMIPC_DOMAIN].values(): + for hap in hass.data[DOMAIN].values(): if entity_id_list != "all": for entity_id in entity_id_list: group = hap.hmip_device_by_entity_id.get(entity_id) @@ -313,7 +313,7 @@ async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> N config_file_prefix = service.data[ATTR_CONFIG_OUTPUT_FILE_PREFIX] anonymize = service.data[ATTR_ANONYMIZE] - for hap in hass.data[HMIPC_DOMAIN].values(): + for hap in hass.data[DOMAIN].values(): hap_sgtin = hap.config_entry.unique_id if anonymize: @@ -333,7 +333,7 @@ async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall) """Service to reset the energy counter.""" entity_id_list = service.data[ATTR_ENTITY_ID] - for hap in hass.data[HMIPC_DOMAIN].values(): + for hap in hass.data[DOMAIN].values(): if entity_id_list != "all": for entity_id in entity_id_list: device = hap.hmip_device_by_entity_id.get(entity_id) @@ -353,17 +353,17 @@ async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall if home := _get_home(hass, hapid): await home.set_cooling(cooling) else: - for hap in hass.data[HMIPC_DOMAIN].values(): + for hap in hass.data[DOMAIN].values(): await hap.home.set_cooling(cooling) def _get_home(hass: HomeAssistant, hapid: str) -> AsyncHome | None: """Return a HmIP home.""" - if hap := hass.data[HMIPC_DOMAIN].get(hapid): + if hap := hass.data[DOMAIN].get(hapid): return hap.home raise ServiceValidationError( - translation_domain=HMIPC_DOMAIN, + translation_domain=DOMAIN, translation_key="access_point_not_found", translation_placeholders={"id": hapid}, ) diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 9aa60d45d93..70bf14631cb 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -27,8 +27,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity -from .generic_entity import ATTR_GROUP_MEMBER_UNREACHABLE +from .const import DOMAIN +from .entity import ATTR_GROUP_MEMBER_UNREACHABLE, HomematicipGenericEntity from .hap import HomematicipHAP @@ -38,7 +38,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the HomematicIP switch from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [ HomematicipGroupSwitch(hap, group) for group in hap.home.groups diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 34e3f58d6ef..cbe7c2845b8 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -27,7 +27,8 @@ from homeassistant.const import UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericEntity +from .const import DOMAIN +from .entity import HomematicipGenericEntity from .hap import HomematicipHAP HOME_WEATHER_CONDITION = { @@ -55,7 +56,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the HomematicIP weather sensor from a config entry.""" - hap = hass.data[HMIPC_DOMAIN][config_entry.unique_id] + hap = hass.data[DOMAIN][config_entry.unique_id] entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: if isinstance(device, AsyncWeatherSensorPro): diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py index 229b3c20251..d42b9602d38 100644 --- a/tests/components/homematicip_cloud/helper.py +++ b/tests/components/homematicip_cloud/helper.py @@ -16,7 +16,7 @@ from homematicip.base.homematicip_object import HomeMaticIPObject from homematicip.home import Home from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN -from homeassistant.components.homematicip_cloud.generic_entity import ( +from homeassistant.components.homematicip_cloud.entity import ( ATTR_IS_GROUP, ATTR_MODEL_TYPE, ) diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index d6ea33ed5fb..02e96b10fe8 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.components.homematicip_cloud.binary_sensor import ( ATTR_WATER_LEVEL_DETECTED, ATTR_WINDOW_STATE, ) -from homeassistant.components.homematicip_cloud.generic_entity import ( +from homeassistant.components.homematicip_cloud.entity import ( ATTR_EVENT_DELAY, ATTR_GROUP_MEMBER_UNREACHABLE, ATTR_LOW_BATTERY, diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 4028f6d189e..07cf5ea0ae5 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -3,7 +3,7 @@ from homematicip.base.enums import ValveState from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN -from homeassistant.components.homematicip_cloud.generic_entity import ( +from homeassistant.components.homematicip_cloud.entity import ( ATTR_CONFIG_PENDING, ATTR_DEVICE_OVERHEATED, ATTR_DEVICE_OVERLOADED, diff --git a/tests/components/homematicip_cloud/test_switch.py b/tests/components/homematicip_cloud/test_switch.py index e4b51688ba7..54cdd632d03 100644 --- a/tests/components/homematicip_cloud/test_switch.py +++ b/tests/components/homematicip_cloud/test_switch.py @@ -1,7 +1,7 @@ """Tests for HomematicIP Cloud switch.""" from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN -from homeassistant.components.homematicip_cloud.generic_entity import ( +from homeassistant.components.homematicip_cloud.entity import ( ATTR_GROUP_MEMBER_UNREACHABLE, ) from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN From 2ae4989031233b44199b34af4411624ed7a01a1f Mon Sep 17 00:00:00 2001 From: cnico Date: Tue, 17 Sep 2024 15:56:07 +0200 Subject: [PATCH 0745/1309] Addition of Flipr hub with switch platform (#125866) * Addition of Flipr hub with switch platform * Remove of loggers in tests * Review corrections * Review corrections * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/flipr/__init__.py | 13 ++- homeassistant/components/flipr/const.py | 2 - homeassistant/components/flipr/coordinator.py | 26 ++++- homeassistant/components/flipr/entity.py | 6 +- homeassistant/components/flipr/switch.py | 67 +++++++++++ tests/components/flipr/test_switch.py | 110 ++++++++++++++++++ 6 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/flipr/switch.py create mode 100644 tests/components/flipr/test_switch.py diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 7f43321d397..e775171bf06 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -13,9 +13,9 @@ from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -from .coordinator import FliprDataUpdateCoordinator +from .coordinator import FliprDataUpdateCoordinator, FliprHubDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) @@ -25,6 +25,7 @@ class FliprData: """The Flipr data class.""" flipr_coordinators: list[FliprDataUpdateCoordinator] + hub_coordinators: list[FliprHubDataUpdateCoordinator] type FliprConfigEntry = ConfigEntry[FliprData] @@ -53,7 +54,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: FliprConfigEntry) -> boo await flipr_coordinator.async_config_entry_first_refresh() flipr_coordinators.append(flipr_coordinator) - entry.runtime_data = FliprData(flipr_coordinators) + hub_coordinators = [] + for hub_id in ids["hub"]: + hub_coordinator = FliprHubDataUpdateCoordinator(hass, client, hub_id) + await hub_coordinator.async_config_entry_first_refresh() + hub_coordinators.append(hub_coordinator) + + entry.runtime_data = FliprData(flipr_coordinators, hub_coordinators) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/flipr/const.py b/homeassistant/components/flipr/const.py index 604c43212d1..256426ae97a 100644 --- a/homeassistant/components/flipr/const.py +++ b/homeassistant/components/flipr/const.py @@ -6,5 +6,3 @@ ATTRIBUTION = "Flipr Data" MANUFACTURER = "CTAC-TECH" NAME = "Flipr" - -CONF_ENTRY_FLIPR_COORDINATORS = "flipr_coordinators" diff --git a/homeassistant/components/flipr/coordinator.py b/homeassistant/components/flipr/coordinator.py index 11dc3c9b071..12fd174fe7d 100644 --- a/homeassistant/components/flipr/coordinator.py +++ b/homeassistant/components/flipr/coordinator.py @@ -2,6 +2,7 @@ from datetime import timedelta import logging +from typing import Any from flipr_api import FliprAPIRestClient from flipr_api.exceptions import FliprError @@ -13,8 +14,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) -class FliprDataUpdateCoordinator(DataUpdateCoordinator): - """Class to hold Flipr data retrieval.""" +class BaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Parent class to hold Flipr and Hub data retrieval.""" config_entry: ConfigEntry @@ -32,7 +33,11 @@ class FliprDataUpdateCoordinator(DataUpdateCoordinator): update_interval=timedelta(minutes=15), ) - async def _async_update_data(self): + +class FliprDataUpdateCoordinator(BaseDataUpdateCoordinator[dict[str, Any]]): + """Class to hold Flipr data retrieval.""" + + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from API endpoint.""" try: data = await self.hass.async_add_executor_job( @@ -42,3 +47,18 @@ class FliprDataUpdateCoordinator(DataUpdateCoordinator): raise UpdateFailed(error) from error return data + + +class FliprHubDataUpdateCoordinator(BaseDataUpdateCoordinator[dict[str, Any]]): + """Class to hold Flipr hub data retrieval.""" + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from API endpoint.""" + try: + data = await self.hass.async_add_executor_job( + self.client.get_hub_state, self.device_id + ) + except FliprError as error: + raise UpdateFailed(error) from error + + return data diff --git a/homeassistant/components/flipr/entity.py b/homeassistant/components/flipr/entity.py index d209a6a888e..7db60ebc890 100644 --- a/homeassistant/components/flipr/entity.py +++ b/homeassistant/components/flipr/entity.py @@ -5,10 +5,10 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN, MANUFACTURER -from .coordinator import FliprDataUpdateCoordinator +from .coordinator import BaseDataUpdateCoordinator -class FliprEntity(CoordinatorEntity): +class FliprEntity(CoordinatorEntity[BaseDataUpdateCoordinator]): """Implements a common class elements representing the Flipr component.""" _attr_attribution = ATTRIBUTION @@ -16,7 +16,7 @@ class FliprEntity(CoordinatorEntity): def __init__( self, - coordinator: FliprDataUpdateCoordinator, + coordinator: BaseDataUpdateCoordinator, description: EntityDescription, is_flipr_hub: bool = False, ) -> None: diff --git a/homeassistant/components/flipr/switch.py b/homeassistant/components/flipr/switch.py new file mode 100644 index 00000000000..65e729ec280 --- /dev/null +++ b/homeassistant/components/flipr/switch.py @@ -0,0 +1,67 @@ +"""Switch platform for the Flipr's Hub.""" + +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FliprConfigEntry +from .entity import FliprEntity + +_LOGGER = logging.getLogger(__name__) + +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="hubState", + name=None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FliprConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switch for Flipr hub.""" + coordinators = config_entry.runtime_data.hub_coordinators + + async_add_entities( + FliprHubSwitch(coordinator, description, True) + for description in SWITCH_TYPES + for coordinator in coordinators + ) + + +class FliprHubSwitch(FliprEntity, SwitchEntity): + """Switch representing Hub state.""" + + @property + def is_on(self) -> bool: + """Return state of the switch.""" + _LOGGER.debug("coordinator data = %s", self.coordinator.data) + return self.coordinator.data["state"] + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + _LOGGER.debug("Switching off %s", self.device_id) + data = await self.hass.async_add_executor_job( + self.coordinator.client.set_hub_state, + self.device_id, + False, + ) + _LOGGER.debug("New hub infos are %s", data) + self.coordinator.async_set_updated_data(data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + _LOGGER.debug("Switching on %s", self.device_id) + data = await self.hass.async_add_executor_job( + self.coordinator.client.set_hub_state, + self.device_id, + True, + ) + _LOGGER.debug("New hub infos are %s", data) + self.coordinator.async_set_updated_data(data) diff --git a/tests/components/flipr/test_switch.py b/tests/components/flipr/test_switch.py new file mode 100644 index 00000000000..f994ac1bdd3 --- /dev/null +++ b/tests/components/flipr/test_switch.py @@ -0,0 +1,110 @@ +"""Test the Flipr switch for Hub.""" + +from unittest.mock import AsyncMock + +from flipr_api.exceptions import FliprError + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .conftest import MOCK_HUB_STATE_OFF + +from tests.common import MockConfigEntry + +SWITCH_ENTITY_ID = "switch.flipr_hub_myhubid" + + +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_flipr_client: AsyncMock, +) -> None: + """Test the creation and values of the Flipr switch.""" + + mock_flipr_client.search_all_ids.return_value = {"flipr": [], "hub": ["myhubid"]} + + await setup_integration(hass, mock_config_entry) + + # Check entity unique_id value that is generated in FliprEntity base class. + entity = entity_registry.async_get(SWITCH_ENTITY_ID) + assert entity.unique_id == "myhubid-hubState" + + state = hass.states.get(SWITCH_ENTITY_ID) + assert state + assert state.state == STATE_ON + + +async def test_switch_actions( + hass: HomeAssistant, + mock_flipr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the actions on the Flipr Hub switch.""" + + mock_flipr_client.search_all_ids.return_value = {"flipr": [], "hub": ["myhubid"]} + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + state = hass.states.get(SWITCH_ENTITY_ID) + assert state.state == STATE_ON + + mock_flipr_client.set_hub_state.return_value = MOCK_HUB_STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: SWITCH_ENTITY_ID}, + blocking=True, + ) + state = hass.states.get(SWITCH_ENTITY_ID) + assert state.state == STATE_OFF + + +async def test_no_switch_found( + hass: HomeAssistant, + mock_flipr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the switch absence.""" + + mock_flipr_client.search_all_ids.return_value = {"flipr": [], "hub": []} + + await setup_integration(hass, mock_config_entry) + + assert not hass.states.async_entity_ids(SWITCH_DOMAIN) + + +async def test_error_flipr_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_flipr_client: AsyncMock, +) -> None: + """Test the Flipr sensors error.""" + + mock_flipr_client.search_all_ids.return_value = {"flipr": [], "hub": ["myhubid"]} + + mock_flipr_client.get_hub_state.side_effect = FliprError( + "Error during flipr data retrieval..." + ) + + await setup_integration(hass, mock_config_entry) + + # Check entity is not generated because of the FliprError raised. + entity = entity_registry.async_get(SWITCH_ENTITY_ID) + assert entity is None From 4d04402ad4473a95b300b9c9503e0daabb261ae1 Mon Sep 17 00:00:00 2001 From: Robert Contreras Date: Tue, 17 Sep 2024 06:56:20 -0700 Subject: [PATCH 0746/1309] Add Home Connect light entity for cooling appliances (#126090) * Add Home Connect light entities for fridge * Update homeassistant/components/home_connect/light.py --------- Co-authored-by: Joost Lekkerkerker --- .../components/home_connect/const.py | 9 ++ .../components/home_connect/light.py | 96 +++++++++++++++++-- .../home_connect/fixtures/settings.json | 16 ++++ tests/components/home_connect/test_light.py | 26 ++++- 4 files changed, 135 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 68bad33ec50..f86b43511ec 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -32,6 +32,15 @@ COFFEE_EVENT_BEAN_CONTAINER_EMPTY = ( COFFEE_EVENT_WATER_TANK_EMPTY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty" COFFEE_EVENT_DRIP_TRAY_FULL = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull" +REFRIGERATION_INTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.Internal.Power" +REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS = ( + "Refrigeration.Common.Setting.Light.Internal.Brightness" +) +REFRIGERATION_EXTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.External.Power" +REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS = ( + "Refrigeration.Common.Setting.Light.External.Brightness" +) + REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer" REFRIGERATION_SUPERMODEREFRIGERATOR = ( "Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator" diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 3b062fac66c..a1556d5caab 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -1,5 +1,6 @@ """Provides a light for Home Connect.""" +from dataclasses import dataclass import logging from math import ceil from typing import Any @@ -11,13 +12,15 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, ColorMode, LightEntity, + LightEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ENTITIES +from homeassistant.const import CONF_DEVICE, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util +from .api import HomeConnectDevice from .const import ( ATTR_VALUE, BSH_AMBIENT_LIGHT_BRIGHTNESS, @@ -28,12 +31,38 @@ from .const import ( COOKING_LIGHTING, COOKING_LIGHTING_BRIGHTNESS, DOMAIN, + REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, + REFRIGERATION_EXTERNAL_LIGHT_POWER, + REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, + REFRIGERATION_INTERNAL_LIGHT_POWER, ) from .entity import HomeConnectEntity _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) +class HomeConnectLightEntityDescription(LightEntityDescription): + """Light entity description.""" + + on_key: str + brightness_key: str | None + + +LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = ( + HomeConnectLightEntityDescription( + key="Internal Light", + on_key=REFRIGERATION_INTERNAL_LIGHT_POWER, + brightness_key=REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS, + ), + HomeConnectLightEntityDescription( + key="External Light", + on_key=REFRIGERATION_EXTERNAL_LIGHT_POWER, + brightness_key=REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -48,7 +77,18 @@ async def async_setup_entry( for device_dict in hc_api.devices: entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("light", []) entity_list = [HomeConnectLight(**d) for d in entity_dicts] - entities += entity_list + device: HomeConnectDevice = device_dict[CONF_DEVICE] + # Auto-discover entities + entities.extend( + HomeConnectCoolingLight( + device=device, + ambient=False, + entity_description=description, + ) + for description in LIGHTS + if description.on_key in device.appliance.status + ) + entities.extend(entity_list) return entities async_add_entities(await hass.async_add_executor_job(get_entities), True) @@ -57,10 +97,14 @@ async def async_setup_entry( class HomeConnectLight(HomeConnectEntity, LightEntity): """Light for Home Connect.""" - def __init__(self, device, desc, ambient): + def __init__(self, device, desc, ambient) -> None: """Initialize the entity.""" super().__init__(device, desc) self._ambient = ambient + self._percentage_scale = (10, 100) + self._brightness_key: str | None + self._custom_color_key: str | None + self._color_key: str | None if ambient: self._brightness_key = BSH_AMBIENT_LIGHT_BRIGHTNESS self._key = BSH_AMBIENT_LIGHT_ENABLED @@ -97,10 +141,15 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): except HomeConnectError as err: _LOGGER.error("Error while trying selecting customcolor: %s", err) if self._attr_brightness is not None: - brightness = 10 + ceil(self._attr_brightness / 255 * 90) + brightness_arg = self._attr_brightness if ATTR_BRIGHTNESS in kwargs: - brightness = 10 + ceil(kwargs[ATTR_BRIGHTNESS] / 255 * 90) + brightness_arg = kwargs[ATTR_BRIGHTNESS] + brightness = ceil( + color_util.brightness_to_value( + self._percentage_scale, brightness_arg + ) + ) hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) if hs_color is not None: @@ -120,8 +169,16 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): ) elif ATTR_BRIGHTNESS in kwargs: - _LOGGER.debug("Changing brightness for: %s", self.name) - brightness = 10 + ceil(kwargs[ATTR_BRIGHTNESS] / 255 * 90) + _LOGGER.debug( + "Changing brightness for: %s, to: %s", + self.name, + kwargs[ATTR_BRIGHTNESS], + ) + brightness = ceil( + color_util.brightness_to_value( + self._percentage_scale, kwargs[ATTR_BRIGHTNESS] + ) + ) try: await self.hass.async_add_executor_job( self.device.appliance.set_setting, self._brightness_key, brightness @@ -172,7 +229,9 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): rgb = color_util.rgb_hex_to_rgb_list(colorvalue) hsv = color_util.color_RGB_to_hsv(rgb[0], rgb[1], rgb[2]) self._attr_hs_color = (hsv[0], hsv[1]) - self._attr_brightness = ceil((hsv[2] - 10) * 255 / 90) + self._attr_brightness = color_util.value_to_brightness( + self._percentage_scale, hsv[2] + ) _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) else: @@ -180,7 +239,24 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): if brightness is None: self._attr_brightness = None else: - self._attr_brightness = ceil( - (brightness.get(ATTR_VALUE) - 10) * 255 / 90 + self._attr_brightness = color_util.value_to_brightness( + self._percentage_scale, brightness[ATTR_VALUE] ) _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) + + +class HomeConnectCoolingLight(HomeConnectLight): + """Light entity for Cooling Appliances.""" + + def __init__( + self, + device: HomeConnectDevice, + ambient: bool, + entity_description: HomeConnectLightEntityDescription, + ) -> None: + """Initialize Cooling Light Entity.""" + super().__init__(device, entity_description.key, ambient) + self.entity_description = entity_description + self._key = entity_description.on_key + self._brightness_key = entity_description.brightness_key + self._percentage_scale = (1, 100) diff --git a/tests/components/home_connect/fixtures/settings.json b/tests/components/home_connect/fixtures/settings.json index 29d431419c6..1b9bec57276 100644 --- a/tests/components/home_connect/fixtures/settings.json +++ b/tests/components/home_connect/fixtures/settings.json @@ -138,6 +138,22 @@ "constraints": { "access": "readWrite" } + }, + { + "key": "Refrigeration.Common.Setting.Light.External.Power", + "value": true, + "type": "Boolean" + }, + { + "key": "Refrigeration.Common.Setting.Light.External.Brightness", + "value": 70, + "unit": "%", + "type": "Double", + "constraints": { + "min": 0, + "max": 100, + "access": "readWrite" + } } ] } diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index f37eb71b8aa..7d375ce0b62 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable, Generator from unittest.mock import MagicMock, Mock -from homeconnect.api import HomeConnectError +from homeconnect.api import HomeConnectAppliance, HomeConnectError import pytest from homeassistant.components.home_connect.const import ( @@ -12,6 +12,8 @@ from homeassistant.components.home_connect.const import ( BSH_AMBIENT_LIGHT_ENABLED, COOKING_LIGHTING, COOKING_LIGHTING_BRIGHTNESS, + REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS, + REFRIGERATION_EXTERNAL_LIGHT_POWER, ) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -148,6 +150,19 @@ async def test_light( STATE_ON, "Hood", ), + ( + "light.fridgefreezer_external_light", + { + REFRIGERATION_EXTERNAL_LIGHT_POWER: { + "value": True, + }, + REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS: {"value": 75}, + }, + SERVICE_TURN_ON, + {}, + STATE_ON, + "FridgeFreezer", + ), ], indirect=["appliance"], ) @@ -166,7 +181,14 @@ async def test_light_functionality( get_appliances: MagicMock, ) -> None: """Test light functionality.""" - appliance.status.update(SETTINGS_STATUS) + appliance.status.update( + HomeConnectAppliance.json2dict( + load_json_object_fixture("home_connect/settings.json") + .get(appliance.name) + .get("data") + .get("settings") + ) + ) get_appliances.return_value = [appliance] assert config_entry.state == ConfigEntryState.NOT_LOADED From 2190054abfd231728a08674e7c2486e65bb61222 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Sep 2024 16:11:03 +0200 Subject: [PATCH 0747/1309] Improve negative TTS test (#126126) --- tests/components/tts/test_media_source.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index 81bbfcfed8a..367b24dd4d0 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -1,6 +1,7 @@ """Tests for TTS media source.""" from http import HTTPStatus +import re from unittest.mock import MagicMock import pytest @@ -169,29 +170,34 @@ async def test_resolving( [(MSProvider(DEFAULT_LANG), MSEntity(DEFAULT_LANG))], ) @pytest.mark.parametrize( - "setup", + ("setup", "engine"), [ - "mock_setup", - "mock_config_entry_setup", + ("mock_setup", "test"), + ("mock_config_entry_setup", "tts.test"), ], indirect=["setup"], ) -async def test_resolving_errors(hass: HomeAssistant, setup: str) -> None: +async def test_resolving_errors(hass: HomeAssistant, setup: str, engine: str) -> None: """Test resolving.""" # No message added with pytest.raises(media_source.Unresolvable): await media_source.async_resolve_media(hass, "media-source://tts/test", None) # Non-existing provider - with pytest.raises(media_source.Unresolvable): + with pytest.raises( + media_source.Unresolvable, match="Provider non-existing not found" + ): await media_source.async_resolve_media( hass, "media-source://tts/non-existing?message=bla", None ) # Non-existing option - with pytest.raises(media_source.Unresolvable): + with pytest.raises( + media_source.Unresolvable, + match=re.escape("Invalid options found: ['non_existing_option']"), + ): await media_source.async_resolve_media( hass, - "media-source://tts/non-existing?message=bla&non_existing_option=bla", + f"media-source://tts/{engine}?message=bla&non_existing_option=bla", None, ) From ca5980590769ff50888b4f9e8b9897d67bf4fb25 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 17 Sep 2024 16:12:09 +0200 Subject: [PATCH 0748/1309] Add sync clock button for Husqvarna Automower (#125689) * Sync Clock * optimize add entitites * fix? * test * simplify command * 1 generic entity * docstrings * tweaks * tests * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * suggestions from review --------- Co-authored-by: Joost Lekkerkerker --- .../components/husqvarna_automower/button.py | 87 ++++++++++++++----- .../components/husqvarna_automower/entity.py | 15 +++- .../components/husqvarna_automower/icons.json | 5 ++ .../husqvarna_automower/strings.json | 3 + .../snapshots/test_button.ambr | 46 ++++++++++ .../husqvarna_automower/test_button.py | 52 ++++++++++- 6 files changed, 180 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 810dd4df92d..696c5ae85ea 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -1,22 +1,68 @@ -"""Creates a button entity for Husqvarna Automower integration.""" +"""Creates button entities for the Husqvarna Automower integration.""" +from collections.abc import Awaitable, Callable +from dataclasses import dataclass import logging +from typing import Any -from aioautomower.exceptions import ApiException +from aioautomower.model import MowerAttributes +from aioautomower.session import AutomowerSession -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util from . import AutomowerConfigEntry -from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator -from .entity import AutomowerAvailableEntity +from .entity import ( + AutomowerAvailableEntity, + _check_error_free, + handle_sending_exception, +) _LOGGER = logging.getLogger(__name__) +async def _async_set_time( + session: AutomowerSession, + mower_id: str, +) -> None: + """Set datetime for the mower.""" + # dt_util returns the current (aware) local datetime, set in the frontend. + # We assume it's the timezone in which the mower is. + await session.commands.set_datetime( + mower_id, + dt_util.now(), + ) + + +@dataclass(frozen=True, kw_only=True) +class AutomowerButtonEntityDescription(ButtonEntityDescription): + """Describes Automower button entities.""" + + available_fn: Callable[[MowerAttributes], bool] = lambda _: True + exists_fn: Callable[[MowerAttributes], bool] = lambda _: True + press_fn: Callable[[AutomowerSession, str], Awaitable[Any]] + + +BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = ( + AutomowerButtonEntityDescription( + key="confirm_error", + translation_key="confirm_error", + available_fn=lambda data: data.mower.is_error_confirmable, + exists_fn=lambda data: data.capabilities.can_confirm_error, + press_fn=lambda session, mower_id: session.commands.error_confirm(mower_id), + ), + AutomowerButtonEntityDescription( + key="sync_clock", + translation_key="sync_clock", + available_fn=_check_error_free, + press_fn=_async_set_time, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: AutomowerConfigEntry, @@ -25,38 +71,35 @@ async def async_setup_entry( """Set up button platform.""" coordinator = entry.runtime_data async_add_entities( - AutomowerButtonEntity(mower_id, coordinator) + AutomowerButtonEntity(mower_id, coordinator, description) for mower_id in coordinator.data - if coordinator.data[mower_id].capabilities.can_confirm_error + for description in BUTTON_TYPES + if description.exists_fn(coordinator.data[mower_id]) ) class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): """Defining the AutomowerButtonEntity.""" - _attr_translation_key = "confirm_error" + entity_description: AutomowerButtonEntityDescription def __init__( self, mower_id: str, coordinator: AutomowerDataUpdateCoordinator, + description: AutomowerButtonEntityDescription, ) -> None: - """Set up button platform.""" + """Set up AutomowerButtonEntity.""" super().__init__(mower_id, coordinator) - self._attr_unique_id = f"{mower_id}_confirm_error" + self.entity_description = description + self._attr_unique_id = f"{mower_id}_{description.key}" @property def available(self) -> bool: - """Return True if the device and entity is available.""" - return super().available and self.mower_attributes.mower.is_error_confirmable + """Return the available attribute of the entity.""" + return self.entity_description.available_fn(self.mower_attributes) + @handle_sending_exception() async def async_press(self) -> None: - """Handle the button press.""" - try: - await self.coordinator.api.commands.error_confirm(self.mower_id) - except ApiException as exception: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="command_send_failed", - translation_placeholders={"exception": str(exception)}, - ) from exception + """Send a command to the mower.""" + await self.entity_description.press_fn(self.coordinator.api, self.mower_id) diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 1da49322989..d6af85aaad7 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -9,6 +9,7 @@ from typing import Any from aioautomower.exceptions import ApiException from aioautomower.model import MowerActivities, MowerAttributes, MowerStates +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -34,6 +35,15 @@ ERROR_STATES = [ ] +@callback +def _check_error_free(mower_attributes: MowerAttributes) -> bool: + """Check if the mower has any errors.""" + return ( + mower_attributes.mower.state not in ERROR_STATES + or mower_attributes.mower.activity not in ERROR_ACTIVITIES + ) + + def handle_sending_exception( poll_after_sending: bool = False, ) -> Callable[ @@ -109,7 +119,4 @@ class AutomowerControlEntity(AutomowerAvailableEntity): @property def available(self) -> bool: """Return True if the device is available.""" - return super().available and ( - self.mower_attributes.mower.state not in ERROR_STATES - or self.mower_attributes.mower.activity not in ERROR_ACTIVITIES - ) + return super().available and _check_error_free(self.mower_attributes) diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index bcaf1826260..8511a63fbec 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -8,6 +8,11 @@ "default": "mdi:debug-step-into" } }, + "button": { + "sync_clock": { + "default": "mdi:clock-check-outline" + } + }, "number": { "cutting_height": { "default": "mdi:grass" diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index c34a5dd3340..2c93c7492cf 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -45,6 +45,9 @@ "button": { "confirm_error": { "name": "Confirm error" + }, + "sync_clock": { + "name": "Sync clock" } }, "number": { diff --git a/tests/components/husqvarna_automower/snapshots/test_button.ambr b/tests/components/husqvarna_automower/snapshots/test_button.ambr index ab2cb427f1a..fb73d14013f 100644 --- a/tests/components/husqvarna_automower/snapshots/test_button.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_button.ambr @@ -45,3 +45,49 @@ 'state': 'unavailable', }) # --- +# name: test_button_snapshot[button.test_mower_1_sync_clock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_mower_1_sync_clock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync clock', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sync_clock', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_sync_clock', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_mower_1_sync_clock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Sync clock', + }), + 'context': , + 'entity_id': 'button.test_mower_1_sync_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/husqvarna_automower/test_button.py b/tests/components/husqvarna_automower/test_button.py index aee37864a3b..bf76fcbb598 100644 --- a/tests/components/husqvarna_automower/test_button.py +++ b/tests/components/husqvarna_automower/test_button.py @@ -2,6 +2,7 @@ import datetime from unittest.mock import AsyncMock, patch +import zoneinfo from aioautomower.exceptions import ApiException from aioautomower.utils import mower_list_to_dictionary_dataclass @@ -9,7 +10,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.button import SERVICE_PRESS +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.husqvarna_automower.const import DOMAIN from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL from homeassistant.const import ( @@ -40,7 +41,7 @@ async def test_button_states_and_commands( mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: - """Test button commands.""" + """Test error confirm button command.""" entity_id = "button.test_mower_1_confirm_error" await setup_integration(hass, mock_config_entry) state = hass.states.get(entity_id) @@ -92,6 +93,53 @@ async def test_button_states_and_commands( ) +@pytest.mark.freeze_time(datetime.datetime(2024, 2, 29, 11, tzinfo=datetime.UTC)) +async def test_sync_clock( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sync clock button command.""" + entity_id = "button.test_mower_1_sync_clock" + await setup_integration(hass, mock_config_entry) + state = hass.states.get(entity_id) + assert state.name == "Test Mower 1 Sync clock" + + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + mock_automower_client.get_status.return_value = values + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + mocked_method = mock_automower_client.commands.set_datetime + # datetime(2024, 2, 29, 11, tzinfo=datetime.UTC) is in local time of the tests + # datetime(2024, 2, 29, 12, tzinfo=zoneinfo.ZoneInfo(key='Europe/Berlin')) + mocked_method.assert_called_once_with( + TEST_MOWER_ID, + datetime.datetime(2024, 2, 29, 12, tzinfo=zoneinfo.ZoneInfo("Europe/Berlin")), + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == "2024-02-29T11:00:00+00:00" + mock_automower_client.commands.set_datetime.side_effect = ApiException("Test error") + with pytest.raises( + HomeAssistantError, + match="Failed to send command: Test error", + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_button_snapshot( hass: HomeAssistant, From 219417cfb549dc748c95454f6d8b3e24528d7b27 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 16:13:40 +0200 Subject: [PATCH 0749/1309] Move homeworks base entity to separate module (#126097) * Move homeworks base entity to separate module * Move calculate_unique_id to util.py --- .../components/homeworks/__init__.py | 34 ------------------ .../components/homeworks/binary_sensor.py | 3 +- homeassistant/components/homeworks/button.py | 3 +- .../components/homeworks/config_flow.py | 4 ++- homeassistant/components/homeworks/entity.py | 35 +++++++++++++++++++ homeassistant/components/homeworks/light.py | 3 +- homeassistant/components/homeworks/util.py | 6 ++++ 7 files changed, 50 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/homeworks/entity.py create mode 100644 homeassistant/components/homeworks/util.py diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index 448487cb8b0..e9e8c969b61 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -33,7 +33,6 @@ from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -48,8 +47,6 @@ CONF_COMMAND = "command" EVENT_BUTTON_PRESS = "homeworks_button_press" EVENT_BUTTON_RELEASE = "homeworks_button_release" -DEFAULT_FADE_RATE = 1.0 - KEYPAD_LEDSTATE_POLL_COOLDOWN = 1.0 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -204,37 +201,6 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -def calculate_unique_id(controller_id: str, addr: str, idx: int) -> str: - """Calculate entity unique id.""" - return f"homeworks.{controller_id}.{addr}.{idx}" - - -class HomeworksEntity(Entity): - """Base class of a Homeworks device.""" - - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__( - self, - controller: Homeworks, - controller_id: str, - addr: str, - idx: int, - name: str | None, - ) -> None: - """Initialize Homeworks device.""" - self._addr = addr - self._idx = idx - self._controller_id = controller_id - self._attr_name = name - self._attr_unique_id = calculate_unique_id( - self._controller_id, self._addr, self._idx - ) - self._controller = controller - self._attr_extra_state_attributes = {"homeworks_address": self._addr} - - class HomeworksKeypad: """When you want signals instead of entities. diff --git a/homeassistant/components/homeworks/binary_sensor.py b/homeassistant/components/homeworks/binary_sensor.py index 9a9f7086ba5..f1ba3c02835 100644 --- a/homeassistant/components/homeworks/binary_sensor.py +++ b/homeassistant/components/homeworks/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeworksData, HomeworksEntity, HomeworksKeypad +from . import HomeworksData, HomeworksKeypad from .const import ( CONF_ADDR, CONF_BUTTONS, @@ -25,6 +25,7 @@ from .const import ( CONF_NUMBER, DOMAIN, ) +from .entity import HomeworksEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/homeworks/button.py b/homeassistant/components/homeworks/button.py index f071b05b492..6a13573ac88 100644 --- a/homeassistant/components/homeworks/button.py +++ b/homeassistant/components/homeworks/button.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeworksData, HomeworksEntity +from . import HomeworksData from .const import ( CONF_ADDR, CONF_BUTTONS, @@ -23,6 +23,7 @@ from .const import ( CONF_RELEASE_DELAY, DOMAIN, ) +from .entity import HomeworksEntity async def async_setup_entry( diff --git a/homeassistant/components/homeworks/config_flow.py b/homeassistant/components/homeworks/config_flow.py index 8e9c8e3b29a..3d947e3d599 100644 --- a/homeassistant/components/homeworks/config_flow.py +++ b/homeassistant/components/homeworks/config_flow.py @@ -39,7 +39,6 @@ from homeassistant.helpers.selector import TextSelector from homeassistant.helpers.typing import VolDictType from homeassistant.util import slugify -from . import DEFAULT_FADE_RATE, calculate_unique_id from .const import ( CONF_ADDR, CONF_BUTTONS, @@ -56,9 +55,12 @@ from .const import ( DEFAULT_LIGHT_NAME, DOMAIN, ) +from .util import calculate_unique_id _LOGGER = logging.getLogger(__name__) +DEFAULT_FADE_RATE = 1.0 + CONTROLLER_EDIT = { vol.Required(CONF_HOST): selector.TextSelector(), vol.Required(CONF_PORT): selector.NumberSelector( diff --git a/homeassistant/components/homeworks/entity.py b/homeassistant/components/homeworks/entity.py new file mode 100644 index 00000000000..49abfb9241e --- /dev/null +++ b/homeassistant/components/homeworks/entity.py @@ -0,0 +1,35 @@ +"""Support for Lutron Homeworks Series 4 and 8 systems.""" + +from __future__ import annotations + +from pyhomeworks.pyhomeworks import Homeworks + +from homeassistant.helpers.entity import Entity + +from .util import calculate_unique_id + + +class HomeworksEntity(Entity): + """Base class of a Homeworks device.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + controller: Homeworks, + controller_id: str, + addr: str, + idx: int, + name: str | None, + ) -> None: + """Initialize Homeworks device.""" + self._addr = addr + self._idx = idx + self._controller_id = controller_id + self._attr_name = name + self._attr_unique_id = calculate_unique_id( + self._controller_id, self._addr, self._idx + ) + self._controller = controller + self._attr_extra_state_attributes = {"homeworks_address": self._addr} diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py index 20ae08017d3..ac52c1f4974 100644 --- a/homeassistant/components/homeworks/light.py +++ b/homeassistant/components/homeworks/light.py @@ -15,8 +15,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import HomeworksData, HomeworksEntity +from . import HomeworksData from .const import CONF_ADDR, CONF_CONTROLLER_ID, CONF_DIMMERS, CONF_RATE, DOMAIN +from .entity import HomeworksEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/homeworks/util.py b/homeassistant/components/homeworks/util.py new file mode 100644 index 00000000000..0ed295f7bae --- /dev/null +++ b/homeassistant/components/homeworks/util.py @@ -0,0 +1,6 @@ +"""Support for Lutron Homeworks Series 4 and 8 systems.""" + + +def calculate_unique_id(controller_id: str, addr: str, idx: int) -> str: + """Calculate entity unique id.""" + return f"homeworks.{controller_id}.{addr}.{idx}" From 2ec0d8e8efed89c7b9b84b6750e805c8b13ad8bb Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 17 Sep 2024 16:14:59 +0200 Subject: [PATCH 0750/1309] Use debug/warning instead of info log level in components [m] (#126074) * Use debug instead of info log level in components [m] * Fix modbus test --- homeassistant/components/mediaroom/media_player.py | 2 +- homeassistant/components/minio/__init__.py | 6 +++--- homeassistant/components/minio/minio_helper.py | 4 ++-- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/modbus.py | 2 +- homeassistant/components/monoprice/media_player.py | 2 +- homeassistant/components/mysensors/__init__.py | 2 +- homeassistant/components/mysensors/gateway.py | 4 ++-- homeassistant/components/mystrom/binary_sensor.py | 2 +- tests/components/modbus/test_init.py | 6 +++--- 10 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index 8e60609fbac..97b61da437a 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -149,7 +149,7 @@ class MediaroomDevice(MediaPlayerEntity): self.host = host self.stb = Remote(host) - _LOGGER.info( + _LOGGER.debug( "Found STB at %s%s", host, " - I'm optimistic" if optimistic else "" ) self._channel = None diff --git a/homeassistant/components/minio/__init__.py b/homeassistant/components/minio/__init__.py index e5470cc3313..8a301ea4225 100644 --- a/homeassistant/components/minio/__init__.py +++ b/homeassistant/components/minio/__init__.py @@ -181,7 +181,7 @@ class QueueListener(threading.Thread): def run(self): """Listen to queue events, and forward them to Home Assistant event bus.""" - _LOGGER.info("Running QueueListener") + _LOGGER.debug("Running QueueListener") while True: if (event := self._queue.get()) is None: break @@ -203,10 +203,10 @@ class QueueListener(threading.Thread): def stop(self): """Stop run by putting None into queue and join the thread.""" - _LOGGER.info("Stopping QueueListener") + _LOGGER.debug("Stopping QueueListener") self._queue.put(None) self.join() - _LOGGER.info("Stopped QueueListener") + _LOGGER.debug("Stopped QueueListener") def start_handler(self, _): """Start handler helper method.""" diff --git a/homeassistant/components/minio/minio_helper.py b/homeassistant/components/minio/minio_helper.py index bd814bdf349..6b0021406f7 100644 --- a/homeassistant/components/minio/minio_helper.py +++ b/homeassistant/components/minio/minio_helper.py @@ -116,7 +116,7 @@ class MinioEventThread(threading.Thread): def run(self): """Create MinioClient and run the loop.""" - _LOGGER.info("Running MinioEventThread") + _LOGGER.debug("Running MinioEventThread") self._should_stop = False @@ -125,7 +125,7 @@ class MinioEventThread(threading.Thread): ) while not self._should_stop: - _LOGGER.info("Connecting to minio event stream") + _LOGGER.debug("Connecting to minio event stream") response = None try: response = get_minio_notification_response( diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index f5efe03dad4..64a9e71b3fc 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -464,7 +464,7 @@ async def async_reset_platform(hass: HomeAssistant, integration_name: str) -> No if DOMAIN not in hass.data: _LOGGER.error("Modbus cannot reload, because it was never loaded") return - _LOGGER.info("Modbus reloading") + _LOGGER.debug("Modbus reloading") hubs = hass.data[DOMAIN] for name in hubs: await hubs[name].async_close() diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index e70b9de50f0..cc70a783234 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -341,7 +341,7 @@ class ModbusHub: self._log_error(err, error_state=False) return message = f"modbus {self.name} communication open" - _LOGGER.info(message) + _LOGGER.warning(message) async def async_setup(self) -> bool: """Set up pymodbus client.""" diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index daf13b4d7b8..2dde0832440 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -71,7 +71,7 @@ async def async_setup_entry( for i in range(1, 4): for j in range(1, 7): zone_id = (i * 10) + j - _LOGGER.info("Adding zone %d for port %s", zone_id, port) + _LOGGER.debug("Adding zone %d for port %s", zone_id, port) entities.append( MonopriceZone(monoprice, sources, config_entry.entry_id, zone_id) ) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index ce01f139dab..19dcce78446 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -148,7 +148,7 @@ def setup_mysensors_platform( devices[dev_id] = device_class_copy(*args_copy) new_devices.append(devices[dev_id]) if new_devices: - _LOGGER.info("Adding new devices: %s", new_devices) + _LOGGER.debug("Adding new devices: %s", new_devices) if async_add_entities is not None: async_add_entities(new_devices) return new_devices diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 00c8d5eecfb..fa3464c0088 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -114,14 +114,14 @@ async def try_connect( await gateway_ready.wait() return True except TimeoutError: - _LOGGER.info("Try gateway connect failed with timeout") + _LOGGER.warning("Try gateway connect failed with timeout") return False finally: if connect_task is not None and not connect_task.done(): connect_task.cancel() await gateway.stop() except OSError as err: - _LOGGER.info("Try gateway connect failed with exception", exc_info=err) + _LOGGER.warning("Try gateway connect failed with exception", exc_info=err) return False diff --git a/homeassistant/components/mystrom/binary_sensor.py b/homeassistant/components/mystrom/binary_sensor.py index c63ab4e5f3b..16772fc7073 100644 --- a/homeassistant/components/mystrom/binary_sensor.py +++ b/homeassistant/components/mystrom/binary_sensor.py @@ -60,7 +60,7 @@ class MyStromView(HomeAssistantView): button_id = data[button_action] entity_id = f"{BINARY_SENSOR_DOMAIN}.{button_id}_{button_action}" if entity_id not in self.buttons: - _LOGGER.info( + _LOGGER.debug( "New myStrom button/action detected: %s/%s", button_id, button_action ) self.buttons[entity_id] = MyStromBinarySensor( diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index d4dc5b05fac..70230e7d326 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -1168,7 +1168,7 @@ async def test_stop_restart( ) -> None: """Run test for service stop.""" - caplog.set_level(logging.INFO) + caplog.set_level(logging.WARNING) entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") assert hass.states.get(entity_id).state in (STATE_UNKNOWN, STATE_UNAVAILABLE) hass.states.async_set(entity_id, 17) @@ -1234,7 +1234,7 @@ async def test_integration_reload( ) -> None: """Run test for integration reload.""" - caplog.set_level(logging.INFO) + caplog.set_level(logging.DEBUG) caplog.clear() yaml_path = get_fixture_path("configuration.yaml", "modbus") @@ -1253,7 +1253,7 @@ async def test_integration_reload_failed( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus ) -> None: """Run test for integration connect failure on reload.""" - caplog.set_level(logging.INFO) + caplog.set_level(logging.DEBUG) caplog.clear() yaml_path = get_fixture_path("configuration.yaml", "modbus") From 01688946b3abfcde2c64cde958560ec78b63998f Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Tue, 17 Sep 2024 16:34:26 +0200 Subject: [PATCH 0751/1309] Fix set brightness for Netatmo lights (#126075) * fix set brightness for Netatmo lights * round returns int by default * Update homeassistant/components/netatmo/light.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/netatmo/light.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index b1871e9dabb..fe30dc0eaa4 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -173,7 +173,9 @@ class NetatmoLight(NetatmoModuleEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn light on.""" if ATTR_BRIGHTNESS in kwargs: - await self.device.async_set_brightness(kwargs[ATTR_BRIGHTNESS]) + await self.device.async_set_brightness( + round(kwargs[ATTR_BRIGHTNESS] / 2.55) + ) else: await self.device.async_on() @@ -194,6 +196,6 @@ class NetatmoLight(NetatmoModuleEntity, LightEntity): if (brightness := self.device.brightness) is not None: # Netatmo uses a range of [0, 100] to control brightness - self._attr_brightness = round((brightness / 100) * 255) + self._attr_brightness = round(brightness * 2.55) else: self._attr_brightness = None From c5839604d585ea86d1836d9e07eac521a58c69ee Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 17:13:23 +0200 Subject: [PATCH 0752/1309] Move qwikswitch base entity to separate module (#126130) --- .../components/qwikswitch/__init__.py | 71 +----------------- .../components/qwikswitch/binary_sensor.py | 3 +- homeassistant/components/qwikswitch/entity.py | 74 +++++++++++++++++++ homeassistant/components/qwikswitch/light.py | 3 +- homeassistant/components/qwikswitch/sensor.py | 3 +- homeassistant/components/qwikswitch/switch.py | 3 +- 6 files changed, 83 insertions(+), 74 deletions(-) create mode 100644 homeassistant/components/qwikswitch/entity.py diff --git a/homeassistant/components/qwikswitch/__init__.py b/homeassistant/components/qwikswitch/__init__.py index eea110a02d7..776e32dded1 100644 --- a/homeassistant/components/qwikswitch/__init__.py +++ b/homeassistant/components/qwikswitch/__init__.py @@ -9,7 +9,6 @@ from pyqwikswitch.qwikswitch import CMD_BUTTONS, QS_CMD, QS_ID, SENSORS, QSType import voluptuous as vol from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA -from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import ( CONF_SENSORS, CONF_SWITCHES, @@ -22,11 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -70,70 +65,6 @@ CONFIG_SCHEMA = vol.Schema( ) -class QSEntity(Entity): - """Qwikswitch Entity base.""" - - _attr_should_poll = False - - def __init__(self, qsid, name): - """Initialize the QSEntity.""" - self._name = name - self.qsid = qsid - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return a unique identifier for this sensor.""" - return f"qs{self.qsid}" - - @callback - def update_packet(self, packet): - """Receive update packet from QSUSB. Match dispather_send signature.""" - self.async_write_ha_state() - - async def async_added_to_hass(self): - """Listen for updates from QSUSb via dispatcher.""" - self.async_on_remove( - async_dispatcher_connect(self.hass, self.qsid, self.update_packet) - ) - - -class QSToggleEntity(QSEntity): - """Representation of a Qwikswitch Toggle Entity. - - Implemented: - - QSLight extends QSToggleEntity and Light[2] (ToggleEntity[1]) - - QSSwitch extends QSToggleEntity and SwitchEntity[3] (ToggleEntity[1]) - - [1] /helpers/entity.py - [2] /components/light/__init__.py - [3] /components/switch/__init__.py - """ - - def __init__(self, qsid, qsusb): - """Initialize the ToggleEntity.""" - self.device = qsusb.devices[qsid] - super().__init__(qsid, self.device.name) - - @property - def is_on(self): - """Check if device is on (non-zero).""" - return self.device.value > 0 - - async def async_turn_on(self, **kwargs): - """Turn the device on.""" - new = kwargs.get(ATTR_BRIGHTNESS, 255) - self.hass.data[DOMAIN].devices.set_value(self.qsid, new) - - async def async_turn_off(self, **_): - """Turn the device off.""" - self.hass.data[DOMAIN].devices.set_value(self.qsid, 0) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Qwiskswitch component setup.""" diff --git a/homeassistant/components/qwikswitch/binary_sensor.py b/homeassistant/components/qwikswitch/binary_sensor.py index b35908da12c..195433ebc17 100644 --- a/homeassistant/components/qwikswitch/binary_sensor.py +++ b/homeassistant/components/qwikswitch/binary_sensor.py @@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH, QSEntity +from . import DOMAIN as QWIKSWITCH +from .entity import QSEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/qwikswitch/entity.py b/homeassistant/components/qwikswitch/entity.py new file mode 100644 index 00000000000..3a2ec5a9206 --- /dev/null +++ b/homeassistant/components/qwikswitch/entity.py @@ -0,0 +1,74 @@ +"""Support for Qwikswitch devices.""" + +from __future__ import annotations + +from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import DOMAIN + + +class QSEntity(Entity): + """Qwikswitch Entity base.""" + + _attr_should_poll = False + + def __init__(self, qsid, name): + """Initialize the QSEntity.""" + self._name = name + self.qsid = qsid + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + return f"qs{self.qsid}" + + @callback + def update_packet(self, packet): + """Receive update packet from QSUSB. Match dispather_send signature.""" + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Listen for updates from QSUSb via dispatcher.""" + self.async_on_remove( + async_dispatcher_connect(self.hass, self.qsid, self.update_packet) + ) + + +class QSToggleEntity(QSEntity): + """Representation of a Qwikswitch Toggle Entity. + + Implemented: + - QSLight extends QSToggleEntity and Light[2] (ToggleEntity[1]) + - QSSwitch extends QSToggleEntity and SwitchEntity[3] (ToggleEntity[1]) + + [1] /helpers/entity.py + [2] /components/light/__init__.py + [3] /components/switch/__init__.py + """ + + def __init__(self, qsid, qsusb): + """Initialize the ToggleEntity.""" + self.device = qsusb.devices[qsid] + super().__init__(qsid, self.device.name) + + @property + def is_on(self): + """Check if device is on (non-zero).""" + return self.device.value > 0 + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + new = kwargs.get(ATTR_BRIGHTNESS, 255) + self.hass.data[DOMAIN].devices.set_value(self.qsid, new) + + async def async_turn_off(self, **_): + """Turn the device off.""" + self.hass.data[DOMAIN].devices.set_value(self.qsid, 0) diff --git a/homeassistant/components/qwikswitch/light.py b/homeassistant/components/qwikswitch/light.py index 12c2763d3a4..073f7bb873a 100644 --- a/homeassistant/components/qwikswitch/light.py +++ b/homeassistant/components/qwikswitch/light.py @@ -7,7 +7,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH, QSToggleEntity +from . import DOMAIN as QWIKSWITCH +from .entity import QSToggleEntity async def async_setup_platform( diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py index 856949d8926..64e560b4f08 100644 --- a/homeassistant/components/qwikswitch/sensor.py +++ b/homeassistant/components/qwikswitch/sensor.py @@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH, QSEntity +from . import DOMAIN as QWIKSWITCH +from .entity import QSEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/qwikswitch/switch.py b/homeassistant/components/qwikswitch/switch.py index 1623bfb3361..ec47b4d99f2 100644 --- a/homeassistant/components/qwikswitch/switch.py +++ b/homeassistant/components/qwikswitch/switch.py @@ -7,7 +7,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as QWIKSWITCH, QSToggleEntity +from . import DOMAIN as QWIKSWITCH +from .entity import QSToggleEntity async def async_setup_platform( From b262e1518fbea19679957dd5123977dc0ef864ac Mon Sep 17 00:00:00 2001 From: Elisha Eshed Date: Tue, 17 Sep 2024 18:18:35 +0300 Subject: [PATCH 0753/1309] Order train station names in Israel rail API (#126121) --- homeassistant/components/israel_rail/config_flow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/israel_rail/config_flow.py b/homeassistant/components/israel_rail/config_flow.py index 3adecaf428c..0f78c227d0a 100644 --- a/homeassistant/components/israel_rail/config_flow.py +++ b/homeassistant/components/israel_rail/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from .const import CONF_DESTINATION, CONF_START, DOMAIN STATIONS_NAMES = [station["Heb"] for station in STATIONS.values()] +STATIONS_NAMES.sort() DATA_SCHEMA = vol.Schema( { From 2588435c5cbac89f983b34191dca5ad53df70293 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 17 Sep 2024 18:20:57 +0200 Subject: [PATCH 0754/1309] Move roborock base entity to separate module (#126135) --- homeassistant/components/roborock/binary_sensor.py | 2 +- homeassistant/components/roborock/button.py | 2 +- homeassistant/components/roborock/{device.py => entity.py} | 0 homeassistant/components/roborock/image.py | 2 +- homeassistant/components/roborock/number.py | 2 +- homeassistant/components/roborock/select.py | 2 +- homeassistant/components/roborock/sensor.py | 2 +- homeassistant/components/roborock/switch.py | 2 +- homeassistant/components/roborock/time.py | 2 +- homeassistant/components/roborock/vacuum.py | 2 +- 10 files changed, 9 insertions(+), 9 deletions(-) rename homeassistant/components/roborock/{device.py => entity.py} (100%) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index fb35a50c210..b88556ea857 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RoborockConfigEntry from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockCoordinatedEntityV1 +from .entity import RoborockCoordinatedEntityV1 @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index 31421320c41..2f214c7c51c 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RoborockConfigEntry from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockEntityV1 +from .entity import RoborockEntityV1 @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/entity.py similarity index 100% rename from homeassistant/components/roborock/device.py rename to homeassistant/components/roborock/entity.py diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 4ead7e9635d..ee48656290f 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -23,7 +23,7 @@ import homeassistant.util.dt as dt_util from . import RoborockConfigEntry from .const import DEFAULT_DRAWABLES, DOMAIN, DRAWABLES, IMAGE_CACHE_INTERVAL, MAP_SLEEP from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockCoordinatedEntityV1 +from .entity import RoborockCoordinatedEntityV1 async def async_setup_entry( diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 92552ca85d8..9f0d578cae4 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RoborockConfigEntry from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockEntityV1 +from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index d9e87fbcd08..2b24ac76104 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RoborockConfigEntry from .const import MAP_SLEEP from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockCoordinatedEntityV1 +from .entity import RoborockCoordinatedEntityV1 @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index b247dc6936d..33ce6be5a68 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -37,7 +37,7 @@ from homeassistant.helpers.typing import StateType from . import RoborockConfigEntry from .coordinator import RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01 -from .device import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 +from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1 @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index ef46fe61415..407ec51103c 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RoborockConfigEntry from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockEntityV1 +from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index 1136170192d..a705eb69ea1 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RoborockConfigEntry from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockEntityV1 +from .entity import RoborockEntityV1 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 81a10e26415..3b873f259e4 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RoborockConfigEntry from .const import DOMAIN, GET_MAPS_SERVICE_NAME from .coordinator import RoborockDataUpdateCoordinator -from .device import RoborockCoordinatedEntityV1 +from .entity import RoborockCoordinatedEntityV1 STATE_CODE_TO_STATE = { RoborockStateCode.starting: STATE_IDLE, # "Starting" From 622e9aa3dc94b11c7c0f72bcd10fd26bf0aaa0cd Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 17 Sep 2024 18:39:11 +0200 Subject: [PATCH 0755/1309] Use debug/warning/error instead of info log level in components [n] (#126137) --- homeassistant/components/nanoleaf/config_flow.py | 2 +- homeassistant/components/neato/vacuum.py | 4 +++- homeassistant/components/netatmo/__init__.py | 4 ++-- homeassistant/components/netatmo/data_handler.py | 4 ++-- homeassistant/components/netgear/__init__.py | 2 +- homeassistant/components/nmap_tracker/__init__.py | 2 +- homeassistant/components/numato/__init__.py | 4 ++-- homeassistant/components/numato/binary_sensor.py | 2 +- 8 files changed, 13 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index 080b8131b1d..cc34e30eb59 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -215,7 +215,7 @@ class NanoleafConfigFlow(ConfigFlow, domain=DOMAIN): self.discovery_conf.pop(self.nanoleaf.host) if self.device_id in self.discovery_conf: self.discovery_conf.pop(self.device_id) - _LOGGER.info( + _LOGGER.debug( "Successfully imported Nanoleaf %s from the discovery integration", name, ) diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index b750b121f58..77ca5346b10 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -376,7 +376,9 @@ class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity): "Zone '%s' was not found for the robot '%s'", zone, self.entity_id ) return - _LOGGER.info("Start cleaning zone '%s' with robot %s", zone, self.entity_id) + _LOGGER.debug( + "Start cleaning zone '%s' with robot %s", zone, self.entity_id + ) self._attr_state = STATE_CLEANING try: diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index f402009e13b..6f14c9c76bb 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -164,7 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await hass.data[DOMAIN][entry.entry_id][AUTH].async_addwebhook(webhook_url) - _LOGGER.info("Register Netatmo webhook: %s", webhook_url) + _LOGGER.debug("Register Netatmo webhook: %s", webhook_url) except pyatmo.ApiError as err: _LOGGER.error("Error during webhook registration - %s", err) else: @@ -224,7 +224,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await data[entry.entry_id][AUTH].async_dropwebhook() except pyatmo.ApiError: _LOGGER.debug("No webhook to be dropped") - _LOGGER.info("Unregister Netatmo webhook") + _LOGGER.debug("Unregister Netatmo webhook") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index a4c4dbfa21d..3a28c3b8336 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -215,11 +215,11 @@ class NetatmoDataHandler: async def handle_event(self, event: dict) -> None: """Handle webhook events.""" if event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_ACTIVATION: - _LOGGER.info("%s webhook successfully registered", MANUFACTURER) + _LOGGER.debug("%s webhook successfully registered", MANUFACTURER) self._webhook = True elif event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_DEACTIVATION: - _LOGGER.info("%s webhook unregistered", MANUFACTURER) + _LOGGER.debug("%s webhook unregistered", MANUFACTURER) self._webhook = False elif event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_NACAMERA_CONNECTION: diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 445453ad2aa..58f63e5212a 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -48,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if port != router.port or ssl != router.ssl: data = {**entry.data, CONF_PORT: router.port, CONF_SSL: router.ssl} hass.config_entries.async_update_entry(entry, data=data) - _LOGGER.info( + _LOGGER.warning( ( "Netgear port-SSL combination updated from (%i, %r) to (%i, %r), " "this should only occur after a firmware update" diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index ffc4b975308..dcb4e1361fd 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -380,7 +380,7 @@ class NmapDeviceScanner: ) if mac is None: self._async_device_offline(ipv4, "No MAC address found", now) - _LOGGER.info("No MAC address found for %s", ipv4) + _LOGGER.warning("No MAC address found for %s", ipv4) continue formatted_mac = format_mac(mac) diff --git a/homeassistant/components/numato/__init__.py b/homeassistant/components/numato/__init__.py index 3b99079f949..28aa8623a7e 100644 --- a/homeassistant/components/numato/__init__.py +++ b/homeassistant/components/numato/__init__.py @@ -139,11 +139,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: try: gpio.discover(config[DOMAIN][CONF_DISCOVER]) except gpio.NumatoGpioError as err: - _LOGGER.info("Error discovering Numato devices: %s", err) + _LOGGER.error("Error discovering Numato devices: %s", err) gpio.cleanup() return False - _LOGGER.info( + _LOGGER.debug( "Initializing Numato 32 port USB GPIO expanders with IDs: %s", ", ".join(str(d) for d in gpio.devices), ) diff --git a/homeassistant/components/numato/binary_sensor.py b/homeassistant/components/numato/binary_sensor.py index a369be43b43..0f4ea23e722 100644 --- a/homeassistant/components/numato/binary_sensor.py +++ b/homeassistant/components/numato/binary_sensor.py @@ -71,7 +71,7 @@ def setup_platform( api.edge_detect(device_id, port, partial(read_gpio, device_id)) except NumatoGpioError as err: - _LOGGER.info( + _LOGGER.error( "Notification setup failed on device %s, " "updates on binary sensor %s only in polling mode: %s", device_id, From bc8929d37f60e5d8d9cda2e1cd274ba213bd5e20 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 17 Sep 2024 19:44:12 +0200 Subject: [PATCH 0756/1309] Use debug/warning instead of info log level in components [o] (#126138) --- homeassistant/components/obihai/__init__.py | 2 +- homeassistant/components/obihai/sensor.py | 2 +- homeassistant/components/onewire/onewire_entities.py | 2 +- homeassistant/components/onvif/event.py | 4 ++-- homeassistant/components/openuv/binary_sensor.py | 2 +- homeassistant/components/openweathermap/__init__.py | 2 +- homeassistant/components/orvibo/switch.py | 4 ++-- homeassistant/components/owntracks/__init__.py | 2 +- homeassistant/components/owntracks/messages.py | 12 ++++++------ 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 0ba0b3dfc5e..43fd3e3426b 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -40,7 +40,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, unique_id=format_mac(device_mac), version=2 ) - LOGGER.info("Migration to version %s successful", entry.version) + LOGGER.debug("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 344767c8cd1..c162bd6c559 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -106,7 +106,7 @@ class ObihaiServiceSensors(SensorEntity): if not self.requester.available: self.requester.available = True - LOGGER.info("Connection restored") + LOGGER.warning("Connection restored") self._attr_available = True except RequestException as exc: diff --git a/homeassistant/components/onewire/onewire_entities.py b/homeassistant/components/onewire/onewire_entities.py index 03ed2dd679a..bbf36deaaa0 100644 --- a/homeassistant/components/onewire/onewire_entities.py +++ b/homeassistant/components/onewire/onewire_entities.py @@ -78,7 +78,7 @@ class OneWireEntity(Entity): else: if not self._last_update_success: self._last_update_success = True - _LOGGER.info("Fetching %s data recovered", self.name) + _LOGGER.debug("Fetching %s data recovered", self.name) if self.entity_description.read_mode == READ_MODE_INT: self._state = int(self._value_raw) elif self.entity_description.read_mode == READ_MODE_BOOL: diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 95aa0728a19..4b5335f1eb6 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -165,7 +165,7 @@ class EventManager: if not (parser := PARSERS.get(topic)): if topic not in UNHANDLED_TOPICS: - LOGGER.info( + LOGGER.warning( "%s: No registered handler for event from %s: %s", self.name, unique_id, @@ -177,7 +177,7 @@ class EventManager: event = await parser(unique_id, msg) if not event: - LOGGER.info( + LOGGER.warning( "%s: Unable to parse event from %s: %s", self.name, unique_id, msg ) return diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index da4dfc3f742..61751e2a0b6 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -51,7 +51,7 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): for key in ("from_time", "to_time", "from_uv", "to_uv"): if not data.get(key): - LOGGER.info("Skipping update due to missing data: %s", key) + LOGGER.warning("Skipping update due to missing data: %s", key) return if self.entity_description.key == TYPE_PROTECTION_WINDOW: diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index 747b93179bc..33cd23c4f6c 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -88,7 +88,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: version=CONFIG_FLOW_VERSION, ) - _LOGGER.info("Migration to version %s successful", CONFIG_FLOW_VERSION) + _LOGGER.debug("Migration to version %s successful", CONFIG_FLOW_VERSION) return True diff --git a/homeassistant/components/orvibo/switch.py b/homeassistant/components/orvibo/switch.py index 34bf63aaaab..2f990333cf6 100644 --- a/homeassistant/components/orvibo/switch.py +++ b/homeassistant/components/orvibo/switch.py @@ -59,7 +59,7 @@ def setup_platform( switch_conf = config.get(CONF_SWITCHES, [config]) if config.get(CONF_DISCOVERY): - _LOGGER.info("Discovering S20 switches") + _LOGGER.debug("Discovering S20 switches") switch_data.update(discover()) for switch in switch_conf: @@ -70,7 +70,7 @@ def setup_platform( switches.append( S20Switch(data.get(CONF_NAME), S20(host, mac=data.get(CONF_MAC))) ) - _LOGGER.info("Initialized S20 at %s", host) + _LOGGER.debug("Initialized S20 at %s", host) except S20Exception: _LOGGER.error("S20 at %s couldn't be initialized", host) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index f57d305d355..720c3718a4f 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -261,7 +261,7 @@ class OwnTracksContext: return False if self.max_gps_accuracy is not None and acc > self.max_gps_accuracy: - _LOGGER.info( + _LOGGER.warning( "Ignoring %s update because expected GPS accuracy %s is not met: %s", message["_type"], self.max_gps_accuracy, diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py index 011b4f75489..93d079b783d 100644 --- a/homeassistant/components/owntracks/messages.py +++ b/homeassistant/components/owntracks/messages.py @@ -214,14 +214,14 @@ async def _async_transition_message_enter(hass, context, message, location): beacons = context.mobile_beacons_active[dev_id] if location not in beacons: beacons.add(location) - _LOGGER.info("Added beacon %s", location) + _LOGGER.debug("Added beacon %s", location) context.async_see_beacons(hass, dev_id, kwargs) else: # Normal region regions = context.regions_entered[dev_id] if location not in regions: regions.append(location) - _LOGGER.info("Enter region %s", location) + _LOGGER.debug("Enter region %s", location) _set_gps_from_zone(kwargs, location, zone) context.async_see(**kwargs) context.async_see_beacons(hass, dev_id, kwargs) @@ -238,7 +238,7 @@ async def _async_transition_message_leave(hass, context, message, location): beacons = context.mobile_beacons_active[dev_id] if location in beacons: beacons.remove(location) - _LOGGER.info("Remove beacon %s", location) + _LOGGER.debug("Remove beacon %s", location) context.async_see_beacons(hass, dev_id, kwargs) else: new_region = regions[-1] if regions else None @@ -246,12 +246,12 @@ async def _async_transition_message_leave(hass, context, message, location): # Exit to previous region zone = hass.states.get(f"zone.{slugify(new_region)}") _set_gps_from_zone(kwargs, new_region, zone) - _LOGGER.info("Exit to %s", new_region) + _LOGGER.debug("Exit to %s", new_region) context.async_see(**kwargs) context.async_see_beacons(hass, dev_id, kwargs) return - _LOGGER.info("Exit to GPS") + _LOGGER.debug("Exit to GPS") # Check for GPS accuracy if context.async_valid_accuracy(message): @@ -335,7 +335,7 @@ async def async_handle_waypoints_message(hass, context, message): wayps = message.get("waypoints", [message]) - _LOGGER.info("Got %d waypoints from %s", len(wayps), message["topic"]) + _LOGGER.debug("Got %d waypoints from %s", len(wayps), message["topic"]) name_base = " ".join(_parse_topic(message["topic"], context.mqtt_topic)) From 4efa147a2b79e5464d38e6ee5861c70fc65078c9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 17 Sep 2024 19:44:38 +0200 Subject: [PATCH 0757/1309] Use debug/warning instead of info log level in components [p] (#126139) --- homeassistant/components/pandora/media_player.py | 8 ++++---- homeassistant/components/point/__init__.py | 2 +- homeassistant/components/point/config_flow.py | 2 +- homeassistant/components/ps4/__init__.py | 4 ++-- homeassistant/components/ps4/media_player.py | 4 ++-- homeassistant/components/pushbullet/notify.py | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index eb6815959c2..f781f366173 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -98,7 +98,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): if self.state != MediaPlayerState.OFF: return self._pianobar = pexpect.spawn("pianobar") - _LOGGER.info("Started pianobar subprocess") + _LOGGER.debug("Started pianobar subprocess") mode = self._pianobar.expect( ["Receiving new playlist", "Select station:", "Email:"] ) @@ -126,7 +126,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): def turn_off(self) -> None: """Turn the media player off.""" if self._pianobar is None: - _LOGGER.info("Pianobar subprocess already stopped") + _LOGGER.warning("Pianobar subprocess already stopped") return self._pianobar.send("q") try: @@ -212,7 +212,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): ] ) except pexpect.exceptions.EOF: - _LOGGER.info("Pianobar process already exited") + _LOGGER.warning("Pianobar process already exited") return None self._log_match() @@ -289,7 +289,7 @@ class PandoraMediaPlayer(MediaPlayerEntity): command = CMD_MAP.get(service_cmd) _LOGGER.debug("Sending pinaobar command %s for %s", command, service_cmd) if command is None: - _LOGGER.info("Command %s not supported yet", service_cmd) + _LOGGER.warning("Command %s not supported yet", service_cmd) self._clear_buffer() self._pianobar.sendline(command) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index d5babef5b2a..acfa53ae215 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -125,7 +125,7 @@ async def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry, session): if CONF_WEBHOOK_ID not in entry.data: webhook_id = webhook.async_generate_id() webhook_url = webhook.async_generate_url(hass, webhook_id) - _LOGGER.info("Registering new webhook at: %s", webhook_url) + _LOGGER.debug("Registering new webhook at: %s", webhook_url) hass.config_entries.async_update_entry( entry, diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 390a2691c80..6dbe8d5bb37 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -162,7 +162,7 @@ class PointFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.error("Authentication Error") return self.async_abort(reason="auth_error") - _LOGGER.info("Successfully authenticated Point") + _LOGGER.debug("Successfully authenticated Point") user_email = (await point_session.user()).get("email") or "" return self.async_create_entry( diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 3e92861b963..0ada2885fa7 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -111,7 +111,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device[CONF_REGION] = country version = 2 config_entries.async_update_entry(entry, data=data, version=2) - _LOGGER.info( + _LOGGER.debug( "PlayStation 4 Config Updated: Region changed to: %s", country, ) @@ -143,7 +143,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config_entry=entry, device_id=e_entry.device_id, ) - _LOGGER.info( + _LOGGER.debug( "PlayStation 4 identifier for entity: %s has changed", entity_id, ) diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 77477ba7901..ecd20e2d71d 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -118,7 +118,7 @@ class PS4Device(MediaPlayerEntity): """Display logger msg if region is deprecated.""" # Non-Breaking although data returned may be inaccurate. if self._region in deprecated_regions: - _LOGGER.info( + _LOGGER.warning( """Region: %s has been deprecated. Please remove PS4 integration and Re-configure again to utilize @@ -340,7 +340,7 @@ class PS4Device(MediaPlayerEntity): """Set device info for registry.""" # If cannot get status on startup, assume info from registry. if status is None: - _LOGGER.info("Assuming status from registry") + _LOGGER.debug("Assuming status from registry") e_registry = er.async_get(self.hass) d_registry = dr.async_get(self.hass) diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index 96f78c4a35d..f2e70695b27 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -92,7 +92,7 @@ class PushBulletNotificationService(BaseNotificationService): # This also seems to work to send to all devices in own account. if ttype == "email": self._push_data(message, title, data, self.pushbullet, email=tname) - _LOGGER.info("Sent notification to email %s", tname) + _LOGGER.debug("Sent notification to email %s", tname) continue # Target is sms, send directly, don't use a target object. @@ -100,7 +100,7 @@ class PushBulletNotificationService(BaseNotificationService): self._push_data( message, title, data, self.pushbullet, phonenumber=tname ) - _LOGGER.info("Sent sms notification to %s", tname) + _LOGGER.debug("Sent sms notification to %s", tname) continue if ttype not in self.pbtargets: From adcb541b4b14d64596c57865660034a953a25b2a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 17 Sep 2024 19:45:05 +0200 Subject: [PATCH 0758/1309] Use debug/warning instead of info log level in components [r] (#126140) --- homeassistant/components/rachio/__init__.py | 2 +- homeassistant/components/rachio/device.py | 6 +++--- homeassistant/components/rainmachine/__init__.py | 4 ++-- homeassistant/components/rainmachine/util.py | 2 +- homeassistant/components/recollect_waste/__init__.py | 2 +- homeassistant/components/remember_the_milk/__init__.py | 2 +- homeassistant/components/rflink/__init__.py | 4 ++-- homeassistant/components/rfxtrx/__init__.py | 4 ++-- homeassistant/components/ridwell/__init__.py | 2 +- homeassistant/components/ring/__init__.py | 2 +- homeassistant/components/rmvtransport/sensor.py | 2 +- homeassistant/components/rocketchat/notify.py | 6 ++++-- homeassistant/components/rpi_power/binary_sensor.py | 2 +- tests/components/rpi_power/test_binary_sensor.py | 2 +- 14 files changed, 22 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index 6976d3f5ba6..3014b541f7d 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -83,7 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not person.controllers and not person.base_stations: _LOGGER.error("No Rachio devices found in account %s", person.username) return False - _LOGGER.info( + _LOGGER.warning( ( "%d Rachio device(s) found; The url %s must be accessible from the internet" " in order to receive updates" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 0bbb862753e..f06910cd505 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -164,7 +164,7 @@ class RachioPerson: # rachio hands us back a dict if isinstance(webhooks, dict): if webhooks.get("code") == PERMISSION_ERROR: - _LOGGER.info( + _LOGGER.warning( ( "Not adding controller '%s', only controllers owned by '%s'" " may be added" @@ -195,7 +195,7 @@ class RachioPerson: for base in base_stations ) - _LOGGER.info('Using Rachio API as user "%s"', self.username) + _LOGGER.debug('Using Rachio API as user "%s"', self.username) @property def user_id(self) -> str | None: @@ -334,7 +334,7 @@ class RachioIro: def stop_watering(self) -> None: """Stop watering all zones connected to this controller.""" self.rachio.device.stop_water(self.controller_id) - _LOGGER.info("Stopped watering of all zones on %s", self) + _LOGGER.debug("Stopped watering of all zones on %s", self) def pause_watering(self, duration) -> None: """Pause watering on this controller.""" diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index b10d562ac67..f2e97aa7c24 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -291,7 +291,7 @@ async def async_setup_entry( # noqa: C901 else: data = await controller.zones.all(details=True, include_inactive=True) except UnknownAPICallError: - LOGGER.info( + LOGGER.warning( "Skipping unsupported API call for controller %s: %s", controller.name, api_category, @@ -518,7 +518,7 @@ async def async_migrate_entry( await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) - LOGGER.info("Migration to version %s successful", version) + LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index f3823d21164..c784c3c471f 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -63,7 +63,7 @@ def async_finish_entity_domain_replacements( old_entity_id = registry_entry.entity_id if strategy.remove_old_entity: - LOGGER.info('Removing old entity: "%s"', old_entity_id) + LOGGER.debug('Removing old entity: "%s"', old_entity_id) ent_reg.async_remove(old_entity_id) diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index bd01aed5473..6606f31a42d 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -109,6 +109,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) - LOGGER.info("Migration to version %s successful", version) + LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 425a12d5c4d..7f91c6e2f13 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -58,7 +58,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: stored_rtm_config = RememberTheMilkConfiguration(hass) for rtm_config in config[DOMAIN]: account_name = rtm_config[CONF_NAME] - _LOGGER.info("Adding Remember the milk account %s", account_name) + _LOGGER.debug("Adding Remember the milk account %s", account_name) api_key = rtm_config[CONF_API_KEY] shared_secret = rtm_config[CONF_SHARED_SECRET] token = stored_rtm_config.get_token(account_name) diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index e5d5e97fa84..a7525b7caf5 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -264,7 +264,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def connect(): """Set up connection and hook it into HA for reconnect/shutdown.""" - _LOGGER.info("Initiating Rflink connection") + _LOGGER.debug("Initiating Rflink connection") # Rflink create_rflink_connection decides based on the value of host # (string or None) if serial or tcp mode should be used @@ -311,7 +311,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: EVENT_HOMEASSISTANT_STOP, lambda x: transport.close() ) - _LOGGER.info("Connected to Rflink") + _LOGGER.debug("Connected to Rflink") hass.async_create_task(connect(), eager_start=False) async_dispatcher_connect(hass, SIGNAL_EVENT, event_callback) diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index f3466aa704d..24a7f5ada51 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -231,7 +231,7 @@ async def async_setup_internal(hass: HomeAssistant, entry: ConfigEntry) -> None: config = {} config[CONF_DEVICE_ID] = device_id - _LOGGER.info( + _LOGGER.debug( "Added device (Device ID: %s Class: %s Sub: %s, Event: %s)", event.device.id_string.lower(), event.device.__class__.__name__, @@ -416,7 +416,7 @@ def find_possible_pt2262_device(device_ids: set[str], device_id: str) -> str | N size = i if size is not None: size = len(dev_id) - size - 1 - _LOGGER.info( + _LOGGER.debug( ( "Found possible device %s for %s " "with the following configuration:\n" diff --git a/homeassistant/components/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index cf584207091..71e80086833 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -55,6 +55,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await er.async_migrate_entries(hass, entry.entry_id, migrate_unique_id) - LOGGER.info("Migration to version %s successful", version) + LOGGER.debug("Migration to version %s successful", version) return True diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 992544b1e18..c1042a9546d 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -133,7 +133,7 @@ async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None: existing_entity_id, ) return None - _LOGGER.info("Fixing non string unique id %s", entity_entry.unique_id) + _LOGGER.debug("Fixing non string unique id %s", entity_entry.unique_id) return {"new_unique_id": new_unique_id} return None diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index e8b976129c5..f9ad4e24631 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -289,6 +289,6 @@ class RMVDepartureData: if not self._error_notification and _deps_not_found: self._error_notification = True - _LOGGER.info("Destination(s) %s not found", ", ".join(_deps_not_found)) + _LOGGER.warning("Destination(s) %s not found", ", ".join(_deps_not_found)) self.departures = _deps diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py index e39fb2dc0a1..a06226d22ee 100644 --- a/homeassistant/components/rocketchat/notify.py +++ b/homeassistant/components/rocketchat/notify.py @@ -52,8 +52,10 @@ def get_service( except RocketConnectionException: _LOGGER.warning("Unable to connect to Rocket.Chat server at %s", url) except RocketAuthenticationException: - _LOGGER.warning("Rocket.Chat authentication failed for user %s", username) - _LOGGER.info("Please check your username/password") + _LOGGER.warning( + "Rocket.Chat authentication failed for user %s. Please check your username/password", + username, + ) return None diff --git a/homeassistant/components/rpi_power/binary_sensor.py b/homeassistant/components/rpi_power/binary_sensor.py index a7306899bde..00d7ec0e3f4 100644 --- a/homeassistant/components/rpi_power/binary_sensor.py +++ b/homeassistant/components/rpi_power/binary_sensor.py @@ -55,5 +55,5 @@ class RaspberryChargerBinarySensor(BinarySensorEntity): if value: _LOGGER.warning(DESCRIPTION_UNDER_VOLTAGE) else: - _LOGGER.info(DESCRIPTION_NORMALIZED) + _LOGGER.debug(DESCRIPTION_NORMALIZED) self._attr_is_on = value diff --git a/tests/components/rpi_power/test_binary_sensor.py b/tests/components/rpi_power/test_binary_sensor.py index 865d7c035b8..a5776a22fb0 100644 --- a/tests/components/rpi_power/test_binary_sensor.py +++ b/tests/components/rpi_power/test_binary_sensor.py @@ -68,6 +68,6 @@ async def test_new_detected( assert state.state == STATE_OFF assert ( binary_sensor.__name__, - logging.INFO, + logging.DEBUG, DESCRIPTION_NORMALIZED, ) in caplog.record_tuples From 37cdc6d500b1bf4eb77f5d4d7eb69c9dac593d48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Tue, 17 Sep 2024 23:17:04 +0200 Subject: [PATCH 0759/1309] Add diagnostics support for WMS WebControl pro (#126077) --- .../components/wmspro/diagnostics.py | 16 ++ homeassistant/components/wmspro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../wmspro/snapshots/test_diagnostics.ambr | 240 ++++++++++++++++++ tests/components/wmspro/test_diagnostics.py | 34 +++ 6 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/wmspro/diagnostics.py create mode 100644 tests/components/wmspro/snapshots/test_diagnostics.ambr create mode 100644 tests/components/wmspro/test_diagnostics.py diff --git a/homeassistant/components/wmspro/diagnostics.py b/homeassistant/components/wmspro/diagnostics.py new file mode 100644 index 00000000000..c35cecc5ab5 --- /dev/null +++ b/homeassistant/components/wmspro/diagnostics.py @@ -0,0 +1,16 @@ +"""Diagnostics support for WMS WebControl pro API integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import WebControlProConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: WebControlProConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return entry.runtime_data.diag() diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index ec97f444a54..3e0c4e21e6c 100644 --- a/homeassistant/components/wmspro/manifest.json +++ b/homeassistant/components/wmspro/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/wmspro", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["pywmspro==0.1.0"] + "requirements": ["pywmspro==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee7704f5f46..96edcc6cb0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2471,7 +2471,7 @@ pywilight==0.0.74 pywizlight==0.5.14 # homeassistant.components.wmspro -pywmspro==0.1.0 +pywmspro==0.2.0 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b7e3e897817..3f693181f36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1971,7 +1971,7 @@ pywilight==0.0.74 pywizlight==0.5.14 # homeassistant.components.wmspro -pywmspro==0.1.0 +pywmspro==0.2.0 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/tests/components/wmspro/snapshots/test_diagnostics.ambr b/tests/components/wmspro/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..6a87c0416ab --- /dev/null +++ b/tests/components/wmspro/snapshots/test_diagnostics.ambr @@ -0,0 +1,240 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config': dict({ + 'command': 'getConfiguration', + 'destinations': list([ + dict({ + 'actions': list([ + dict({ + 'actionDescription': 0, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 16, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 1, + 'id': 58717, + 'names': list([ + 'Markise', + '', + '', + '', + ]), + }), + dict({ + 'actions': list([ + dict({ + 'actionDescription': 8, + 'actionType': 0, + 'id': 0, + 'maxValue': 100, + 'minValue': 0, + }), + dict({ + 'actionDescription': 12, + 'actionType': 6, + 'id': 17, + }), + dict({ + 'actionDescription': 6, + 'actionType': 4, + 'id': 20, + }), + dict({ + 'actionDescription': 13, + 'actionType': 8, + 'id': 22, + }), + ]), + 'animationType': 6, + 'id': 97358, + 'names': list([ + 'Licht', + '', + '', + '', + ]), + }), + ]), + 'protocolVersion': '1.0.0', + 'rooms': list([ + dict({ + 'destinations': list([ + 58717, + 97358, + ]), + 'id': 19239, + 'name': 'Terrasse', + 'scenes': list([ + 687471, + 765095, + ]), + }), + ]), + 'scenes': list([ + dict({ + 'id': 687471, + 'names': list([ + 'Licht an', + '', + '', + '', + ]), + }), + dict({ + 'id': 765095, + 'names': list([ + 'Licht aus', + '', + '', + '', + ]), + }), + ]), + }), + 'dests': dict({ + '58717': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'AwningDrive', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '16': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 16, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'Awning', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 58717, + 'name': 'Markise', + 'room': dict({ + '19239': 'Terrasse', + }), + 'status': dict({ + }), + }), + '97358': dict({ + 'actions': dict({ + '0': dict({ + 'actionDescription': 'LightDimming', + 'actionType': 'Percentage', + 'attrs': dict({ + 'maxValue': 100, + 'minValue': 0, + }), + 'id': 0, + 'params': dict({ + }), + }), + '17': dict({ + 'actionDescription': 'ManualCommand', + 'actionType': 'Stop', + 'attrs': dict({ + }), + 'id': 17, + 'params': dict({ + }), + }), + '20': dict({ + 'actionDescription': 'LightSwitch', + 'actionType': 'Switch', + 'attrs': dict({ + }), + 'id': 20, + 'params': dict({ + }), + }), + '22': dict({ + 'actionDescription': 'Identify', + 'actionType': 'Identify', + 'attrs': dict({ + }), + 'id': 22, + 'params': dict({ + }), + }), + }), + 'animationType': 'Dimmer', + 'available': True, + 'blocking': None, + 'drivingCause': 'Unknown', + 'heartbeatError': None, + 'id': 97358, + 'name': 'Licht', + 'room': dict({ + '19239': 'Terrasse', + }), + 'status': dict({ + }), + }), + }), + 'host': 'webcontrol', + 'rooms': dict({ + '19239': dict({ + 'destinations': dict({ + '58717': 'Markise', + '97358': 'Licht', + }), + 'id': 19239, + 'name': 'Terrasse', + 'scenes': dict({ + '687471': 'Licht an', + '765095': 'Licht aus', + }), + }), + }), + 'scenes': dict({ + '687471': dict({ + 'id': 687471, + 'name': 'Licht an', + 'room': dict({ + '19239': 'Terrasse', + }), + }), + '765095': dict({ + 'id': 765095, + 'name': 'Licht aus', + 'room': dict({ + '19239': 'Terrasse', + }), + }), + }), + }) +# --- diff --git a/tests/components/wmspro/test_diagnostics.py b/tests/components/wmspro/test_diagnostics.py new file mode 100644 index 00000000000..930c3f2898e --- /dev/null +++ b/tests/components/wmspro/test_diagnostics.py @@ -0,0 +1,34 @@ +"""Test the wmspro diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_config_entry + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_hub_ping: AsyncMock, + mock_hub_configuration_prod: AsyncMock, + mock_dest_refresh: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test that a config entry can be loaded with DeviceConfig.""" + assert await setup_config_entry(hass, mock_config_entry) + assert len(mock_hub_ping.mock_calls) == 1 + assert len(mock_hub_configuration_prod.mock_calls) == 1 + assert len(mock_dest_refresh.mock_calls) == 2 + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert result == snapshot From 97d0d91d2c464a7a8474e6035969116b70631e65 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 17 Sep 2024 17:22:35 -0400 Subject: [PATCH 0760/1309] Use aiohasupervisor for addon info calls (#125926) * Use aiohasupervisor for addon info calls * Fix issue/repair tests in supervisor * Fixes from feedback --- .../components/analytics/analytics.py | 11 +- homeassistant/components/hassio/__init__.py | 2 +- .../components/hassio/addon_manager.py | 27 +-- .../components/hassio/coordinator.py | 12 +- homeassistant/components/hassio/discovery.py | 11 +- homeassistant/components/hassio/handler.py | 37 ++-- homeassistant/components/hassio/manifest.json | 3 +- homeassistant/components/hassio/update.py | 2 +- homeassistant/components/otbr/config_flow.py | 21 ++- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/analytics/test_analytics.py | 95 ++++++----- tests/components/analytics/test_init.py | 3 + tests/components/conftest.py | 34 +++- tests/components/hassio/common.py | 80 +++++---- tests/components/hassio/test_addon_manager.py | 20 +-- tests/components/hassio/test_binary_sensor.py | 17 +- tests/components/hassio/test_diagnostics.py | 2 +- tests/components/hassio/test_discovery.py | 24 +-- tests/components/hassio/test_handler.py | 14 -- tests/components/hassio/test_init.py | 20 ++- tests/components/hassio/test_issues.py | 2 +- tests/components/hassio/test_repairs.py | 4 +- tests/components/hassio/test_sensor.py | 2 +- tests/components/hassio/test_update.py | 30 ++-- .../test_silabs_multiprotocol_addon.py | 34 ++-- .../homeassistant_yellow/test_hardware.py | 2 + tests/components/matter/test_init.py | 4 +- tests/components/otbr/test_config_flow.py | 161 +++++++----------- tests/components/zwave_js/test_init.py | 4 +- 30 files changed, 367 insertions(+), 317 deletions(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 01c8bf22787..e5f203f346d 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -177,6 +177,7 @@ class Analytics: hass = self.hass supervisor_info = None operating_system_info: dict[str, Any] = {} + supervisor_client = hassio.get_supervisor_client(hass) if not self.onboarded or not self.preferences.get(ATTR_BASE, False): LOGGER.debug("Nothing to submit") @@ -263,16 +264,16 @@ class Analytics: if supervisor_info is not None: installed_addons = await asyncio.gather( *( - hassio.async_get_addon_info(hass, addon[ATTR_SLUG]) + supervisor_client.addons.addon_info(addon[ATTR_SLUG]) for addon in supervisor_info[ATTR_ADDONS] ) ) addons.extend( { - ATTR_SLUG: addon[ATTR_SLUG], - ATTR_PROTECTED: addon[ATTR_PROTECTED], - ATTR_VERSION: addon[ATTR_VERSION], - ATTR_AUTO_UPDATE: addon[ATTR_AUTO_UPDATE], + ATTR_SLUG: addon.slug, + ATTR_PROTECTED: addon.protected, + ATTR_VERSION: addon.version, + ATTR_AUTO_UPDATE: addon.auto_update, } for addon in installed_addons ) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 647c2248d56..73e3ae5d7ff 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -102,7 +102,6 @@ from .handler import ( # noqa: F401 HassioAPIError, async_create_backup, async_get_addon_discovery_info, - async_get_addon_info, async_get_addon_store_info, async_get_green_settings, async_get_yellow_settings, @@ -120,6 +119,7 @@ from .handler import ( # noqa: F401 async_update_diagnostics, async_update_os, async_update_supervisor, + get_supervisor_client, ) from .http import HassIOView from .ingress import async_setup_ingress_view diff --git a/homeassistant/components/hassio/addon_manager.py b/homeassistant/components/hassio/addon_manager.py index b3c43f16be1..01babdc3a33 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -10,6 +10,12 @@ from functools import partial, wraps import logging from typing import Any, Concatenate +from aiohasupervisor import SupervisorError +from aiohasupervisor.models import ( + AddonState as SupervisorAddonState, + InstalledAddonComplete, +) + from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -17,7 +23,6 @@ from .handler import ( HassioAPIError, async_create_backup, async_get_addon_discovery_info, - async_get_addon_info, async_get_addon_store_info, async_install_addon, async_restart_addon, @@ -26,6 +31,7 @@ from .handler import ( async_stop_addon, async_uninstall_addon, async_update_addon, + get_supervisor_client, ) type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]] @@ -53,7 +59,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R]( """Wrap an add-on manager method.""" try: return_value = await func(self, *args, **kwargs) - except HassioAPIError as err: + except (HassioAPIError, SupervisorError) as err: raise AddonError( f"{error_message.format(addon_name=self.addon_name)}: {err}" ) from err @@ -140,6 +146,7 @@ class AddonManager: @api_error("Failed to get the {addon_name} add-on info") async def async_get_addon_info(self) -> AddonInfo: """Return and cache manager add-on info.""" + supervisor_client = get_supervisor_client(self._hass) addon_store_info = await async_get_addon_store_info(self._hass, self.addon_slug) self._logger.debug("Add-on store info: %s", addon_store_info) if not addon_store_info["installed"]: @@ -152,23 +159,23 @@ class AddonManager: version=None, ) - addon_info = await async_get_addon_info(self._hass, self.addon_slug) + addon_info = await supervisor_client.addons.addon_info(self.addon_slug) addon_state = self.async_get_addon_state(addon_info) return AddonInfo( - available=addon_info["available"], - hostname=addon_info["hostname"], - options=addon_info["options"], + available=addon_info.available, + hostname=addon_info.hostname, + options=addon_info.options, state=addon_state, - update_available=addon_info["update_available"], - version=addon_info["version"], + update_available=addon_info.update_available, + version=addon_info.version, ) @callback - def async_get_addon_state(self, addon_info: dict[str, Any]) -> AddonState: + def async_get_addon_state(self, addon_info: InstalledAddonComplete) -> AddonState: """Return the current state of the managed add-on.""" addon_state = AddonState.NOT_RUNNING - if addon_info["state"] == "started": + if addon_info.state == SupervisorAddonState.STARTED: addon_state = AddonState.RUNNING if self._install_task and not self._install_task.done(): addon_state = AddonState.INSTALLING diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 024128f4ef8..dc62f41abb5 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -7,6 +7,8 @@ from collections import defaultdict import logging from typing import TYPE_CHECKING, Any +from aiohasupervisor import SupervisorError + from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -514,11 +516,15 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]: """Return the info for an add-on.""" try: - info = await self.hassio.get_addon_info(slug) - except HassioAPIError as err: + info = await self.hassio.client.addons.addon_info(slug) + except SupervisorError as err: _LOGGER.warning("Could not fetch info for %s: %s", slug, err) return (slug, None) - return (slug, info) + # Translate to legacy hassio names for compatibility + info_dict = info.to_dict() + info_dict["hassio_api"] = info_dict.pop("supervisor_api") + info_dict["hassio_role"] = info_dict.pop("supervisor_role") + return (slug, info_dict) @callback def async_enable_container_updates( diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 66be8267d53..009f9dfde7e 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -12,7 +12,7 @@ from aiohttp.web_exceptions import HTTPServiceUnavailable from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ATTR_NAME, ATTR_SERVICE, EVENT_HOMEASSISTANT_START +from homeassistant.const import ATTR_SERVICE, EVENT_HOMEASSISTANT_START from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow @@ -99,20 +99,21 @@ class HassIODiscovery(HomeAssistantView): # Read additional Add-on info try: - addon_info = await self.hassio.get_addon_info(slug) + addon_info = await self.hassio.client.addons.addon_info(slug) except HassioAPIError as err: _LOGGER.error("Can't read add-on info: %s", err) return - name: str = addon_info[ATTR_NAME] - config_data[ATTR_ADDON] = name + config_data[ATTR_ADDON] = addon_info.name # Use config flow discovery_flow.async_create_flow( self.hass, service, context={"source": config_entries.SOURCE_HASSIO}, - data=HassioServiceInfo(config=config_data, name=name, slug=slug, uuid=uuid), + data=HassioServiceInfo( + config=config_data, name=addon_info.name, slug=slug, uuid=uuid + ), ) async def async_process_del(self, data: dict[str, Any]) -> None: diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 7c8d5c61a22..8db1c616512 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -9,6 +9,7 @@ import logging import os from typing import Any +from aiohasupervisor import SupervisorClient import aiohttp from yarl import URL @@ -62,17 +63,6 @@ def api_data[**_P]( return _wrapper -@bind_hass -async def async_get_addon_info(hass: HomeAssistant, slug: str) -> dict: - """Return add-on info. - - The add-on must be installed. - The caller of the function should handle HassioAPIError. - """ - hassio: HassIO = hass.data[DOMAIN] - return await hassio.get_addon_info(slug) - - @api_data async def async_get_addon_store_info(hass: HomeAssistant, slug: str) -> dict: """Return add-on store info. @@ -332,7 +322,16 @@ class HassIO: self.loop = loop self.websession = websession self._ip = ip - self._base_url = URL(f"http://{ip}") + base_url = f"http://{ip}" + self._base_url = URL(base_url) + self._client = SupervisorClient( + base_url, os.environ.get("SUPERVISOR_TOKEN", ""), session=websession + ) + + @property + def client(self) -> SupervisorClient: + """Return aiohasupervisor client.""" + return self._client @_api_bool def is_connected(self) -> Coroutine: @@ -390,14 +389,6 @@ class HassIO: """ return self.send_command("/network/info", method="get") - @api_data - def get_addon_info(self, addon: str) -> Coroutine: - """Return data for a Add-on. - - This method returns a coroutine. - """ - return self.send_command(f"/addons/{addon}/info", method="get") - @api_data def get_core_stats(self) -> Coroutine: """Return stats for the core. @@ -617,3 +608,9 @@ class HassIO: _LOGGER.error("Client error on %s request %s", command, err) raise HassioAPIError + + +def get_supervisor_client(hass: HomeAssistant) -> SupervisorClient: + """Return supervisor client.""" + hassio: HassIO = hass.data[DOMAIN] + return hassio.client diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index b32e5ebcd53..9d95ea66312 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -5,5 +5,6 @@ "dependencies": ["http", "repairs"], "documentation": "https://www.home-assistant.io/integrations/hassio", "iot_class": "local_polling", - "quality_scale": "internal" + "quality_scale": "internal", + "requirements": ["aiohasupervisor==0.1.0b0"] } diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 8e7650a9225..a7974850e19 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -304,5 +304,5 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): await async_update_core(self.hass, version=version, backup=backup) except HassioAPIError as err: raise HomeAssistantError( - f"Error updating Home Assistant Core {err}" + f"Error updating Home Assistant Core: {err}" ) from err diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index c1747981b07..f24d141247d 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -13,16 +13,12 @@ from python_otbr_api.tlv_parser import MeshcopTLVType import voluptuous as vol import yarl -from homeassistant.components.hassio import ( - HassioAPIError, - HassioServiceInfo, - async_get_addon_info, -) +from homeassistant.components.hassio import AddonError, AddonManager, HassioServiceInfo from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware from homeassistant.components.thread import async_get_preferred_dataset from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -43,6 +39,12 @@ class AlreadyConfigured(HomeAssistantError): """Raised when the router is already configured.""" +@callback +def get_addon_manager(hass: HomeAssistant, slug: str) -> AddonManager: + """Get the add-on manager.""" + return AddonManager(hass, _LOGGER, "OpenThread Border Router", slug) + + def _is_yellow(hass: HomeAssistant) -> bool: """Return True if Home Assistant is running on a Home Assistant Yellow.""" try: @@ -55,10 +57,11 @@ def _is_yellow(hass: HomeAssistant) -> bool: async def _title(hass: HomeAssistant, discovery_info: HassioServiceInfo) -> str: """Return config entry title.""" device: str | None = None + addon_manager = get_addon_manager(hass, discovery_info.slug) - with suppress(HassioAPIError): - addon_info = await async_get_addon_info(hass, discovery_info.slug) - device = addon_info.get("options", {}).get("device") + with suppress(AddonError): + addon_info = await addon_manager.async_get_addon_info() + device = addon_info.options.get("device") if _is_yellow(hass) and device == "/dev/ttyAMA1": return f"Home Assistant Yellow ({discovery_info.name})" diff --git a/requirements_all.txt b/requirements_all.txt index 96edcc6cb0e..cfea05041c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -257,6 +257,9 @@ aioguardian==2022.07.0 # homeassistant.components.harmony aioharmony==0.2.10 +# homeassistant.components.hassio +aiohasupervisor==0.1.0b0 + # homeassistant.components.homekit_controller aiohomekit==3.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f693181f36..ea78e9dbdba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -242,6 +242,9 @@ aioguardian==2022.07.0 # homeassistant.components.harmony aioharmony==0.2.10 +# homeassistant.components.hassio +aiohasupervisor==0.1.0b0 + # homeassistant.components.homekit_controller aiohomekit==3.2.3 diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 4b4fdc159de..5542aab4b30 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -67,6 +67,7 @@ def _last_call_payload(aioclient: AiohttpClientMocker) -> dict[str, Any]: return aioclient.mock_calls[-1][2] +@pytest.mark.usefixtures("supervisor_client") async def test_no_send( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -126,6 +127,7 @@ async def test_load_with_supervisor_without_diagnostics(hass: HomeAssistant) -> assert not analytics.preferences[ATTR_DIAGNOSTICS] +@pytest.mark.usefixtures("supervisor_client") async def test_failed_to_send( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -144,6 +146,7 @@ async def test_failed_to_send( ) +@pytest.mark.usefixtures("supervisor_client") async def test_failed_to_send_raises( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -159,7 +162,7 @@ async def test_failed_to_send_raises( assert "Error sending analytics" in caplog.text -@pytest.mark.usefixtures("installation_type_mock") +@pytest.mark.usefixtures("installation_type_mock", "supervisor_client") async def test_send_base( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -182,6 +185,7 @@ async def test_send_base( assert snapshot == submitted_data +@pytest.mark.usefixtures("supervisor_client") async def test_send_base_with_supervisor( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -230,7 +234,7 @@ async def test_send_base_with_supervisor( assert snapshot == submitted_data -@pytest.mark.usefixtures("installation_type_mock") +@pytest.mark.usefixtures("installation_type_mock", "supervisor_client") async def test_send_usage( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -271,6 +275,7 @@ async def test_send_usage_with_supervisor( caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, snapshot: SnapshotAssertion, + supervisor_client: AsyncMock, ) -> None: """Test send usage with supervisor preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -281,6 +286,9 @@ async def test_send_usage_with_supervisor( assert analytics.preferences[ATTR_USAGE] hass.config.components.add("default_config") + supervisor_client.addons.addon_info.return_value = Mock( + slug="test_addon", protected=True, version="1", auto_update=False + ) with ( patch( "homeassistant.components.hassio.get_supervisor_info", @@ -305,17 +313,6 @@ async def test_send_usage_with_supervisor( "homeassistant.components.hassio.get_host_info", side_effect=Mock(return_value={}), ), - patch( - "homeassistant.components.hassio.async_get_addon_info", - side_effect=AsyncMock( - return_value={ - "slug": "test_addon", - "protected": True, - "version": "1", - "auto_update": False, - } - ), - ), patch( "homeassistant.components.hassio.is_hassio", side_effect=Mock(return_value=True), @@ -330,7 +327,7 @@ async def test_send_usage_with_supervisor( assert snapshot == submitted_data -@pytest.mark.usefixtures("installation_type_mock") +@pytest.mark.usefixtures("installation_type_mock", "supervisor_client") async def test_send_statistics( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -358,9 +355,10 @@ async def test_send_statistics( assert snapshot == submitted_data -@pytest.mark.usefixtures("mock_hass_config") +@pytest.mark.usefixtures("mock_hass_config", "supervisor_client") async def test_send_statistics_one_integration_fails( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -381,7 +379,9 @@ async def test_send_statistics_one_integration_fails( assert post_call[2]["integration_count"] == 0 -@pytest.mark.usefixtures("installation_type_mock", "mock_hass_config") +@pytest.mark.usefixtures( + "installation_type_mock", "mock_hass_config", "supervisor_client" +) async def test_send_statistics_disabled_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -418,7 +418,9 @@ async def test_send_statistics_disabled_integration( assert snapshot == submitted_data -@pytest.mark.usefixtures("installation_type_mock", "mock_hass_config") +@pytest.mark.usefixtures( + "installation_type_mock", "mock_hass_config", "supervisor_client" +) async def test_send_statistics_ignored_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -461,9 +463,10 @@ async def test_send_statistics_ignored_integration( assert snapshot == submitted_data -@pytest.mark.usefixtures("mock_hass_config") +@pytest.mark.usefixtures("mock_hass_config", "supervisor_client") async def test_send_statistics_async_get_integration_unknown_exception( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -489,6 +492,7 @@ async def test_send_statistics_with_supervisor( caplog: pytest.LogCaptureFixture, aioclient_mock: AiohttpClientMocker, snapshot: SnapshotAssertion, + supervisor_client: AsyncMock, ) -> None: """Test send statistics preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -497,6 +501,9 @@ async def test_send_statistics_with_supervisor( assert analytics.preferences[ATTR_BASE] assert analytics.preferences[ATTR_STATISTICS] + supervisor_client.addons.addon_info.return_value = Mock( + slug="test_addon", protected=True, version="1", auto_update=False + ) with ( patch( "homeassistant.components.hassio.get_supervisor_info", @@ -521,17 +528,6 @@ async def test_send_statistics_with_supervisor( "homeassistant.components.hassio.get_host_info", side_effect=Mock(return_value={}), ), - patch( - "homeassistant.components.hassio.async_get_addon_info", - side_effect=AsyncMock( - return_value={ - "slug": "test_addon", - "protected": True, - "version": "1", - "auto_update": False, - } - ), - ), patch( "homeassistant.components.hassio.is_hassio", side_effect=Mock(return_value=True), @@ -546,6 +542,7 @@ async def test_send_statistics_with_supervisor( assert snapshot == submitted_data +@pytest.mark.usefixtures("supervisor_client") async def test_reusing_uuid( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -563,7 +560,9 @@ async def test_reusing_uuid( assert analytics.uuid == "NOT_MOCK_UUID" -@pytest.mark.usefixtures("enable_custom_integrations", "installation_type_mock") +@pytest.mark.usefixtures( + "enable_custom_integrations", "installation_type_mock", "supervisor_client" +) async def test_custom_integrations( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -590,8 +589,10 @@ async def test_custom_integrations( assert snapshot == submitted_data +@pytest.mark.usefixtures("supervisor_client") async def test_dev_url( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test sending payload to dev url.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL_DEV, status=200) @@ -607,6 +608,7 @@ async def test_dev_url( assert str(payload[1]) == ANALYTICS_ENDPOINT_URL_DEV +@pytest.mark.usefixtures("supervisor_client") async def test_dev_url_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -630,8 +632,10 @@ async def test_dev_url_error( ) in caplog.text +@pytest.mark.usefixtures("supervisor_client") async def test_nightly_endpoint( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test sending payload to production url when running nightly.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) @@ -647,7 +651,9 @@ async def test_nightly_endpoint( assert str(payload[1]) == ANALYTICS_ENDPOINT_URL -@pytest.mark.usefixtures("installation_type_mock", "mock_hass_config") +@pytest.mark.usefixtures( + "installation_type_mock", "mock_hass_config", "supervisor_client" +) async def test_send_with_no_energy( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -683,7 +689,9 @@ async def test_send_with_no_energy( assert snapshot == submitted_data -@pytest.mark.usefixtures("recorder_mock", "installation_type_mock", "mock_hass_config") +@pytest.mark.usefixtures( + "recorder_mock", "installation_type_mock", "mock_hass_config", "supervisor_client" +) async def test_send_with_no_energy_config( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -714,7 +722,9 @@ async def test_send_with_no_energy_config( ) -@pytest.mark.usefixtures("recorder_mock", "installation_type_mock", "mock_hass_config") +@pytest.mark.usefixtures( + "recorder_mock", "installation_type_mock", "mock_hass_config", "supervisor_client" +) async def test_send_with_energy_config( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -745,7 +755,9 @@ async def test_send_with_energy_config( ) -@pytest.mark.usefixtures("installation_type_mock", "mock_hass_config") +@pytest.mark.usefixtures( + "installation_type_mock", "mock_hass_config", "supervisor_client" +) async def test_send_usage_with_certificate( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -771,7 +783,7 @@ async def test_send_usage_with_certificate( assert snapshot == submitted_data -@pytest.mark.usefixtures("recorder_mock", "installation_type_mock") +@pytest.mark.usefixtures("recorder_mock", "installation_type_mock", "supervisor_client") async def test_send_with_recorder( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -802,6 +814,7 @@ async def test_send_with_recorder( ) +@pytest.mark.usefixtures("supervisor_client") async def test_send_with_problems_loading_yaml( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -821,7 +834,7 @@ async def test_send_with_problems_loading_yaml( assert len(aioclient_mock.mock_calls) == 0 -@pytest.mark.usefixtures("mock_hass_config") +@pytest.mark.usefixtures("mock_hass_config", "supervisor_client") async def test_timeout_while_sending( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, @@ -840,7 +853,7 @@ async def test_timeout_while_sending( assert "Timeout sending analytics" in caplog.text -@pytest.mark.usefixtures("installation_type_mock") +@pytest.mark.usefixtures("installation_type_mock", "supervisor_client") async def test_not_check_config_entries_if_yaml( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, diff --git a/tests/components/analytics/test_init.py b/tests/components/analytics/test_init.py index cf8d4838415..66000fc5936 100644 --- a/tests/components/analytics/test_init.py +++ b/tests/components/analytics/test_init.py @@ -2,6 +2,8 @@ from unittest.mock import patch +import pytest + from homeassistant.components.analytics.const import ANALYTICS_ENDPOINT_URL, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -20,6 +22,7 @@ async def test_setup(hass: HomeAssistant) -> None: assert DOMAIN in hass.data +@pytest.mark.usefixtures("supervisor_client") async def test_websocket( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 1e79248fbeb..e6c685a1342 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Generator from importlib.util import find_spec from pathlib import Path from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch import pytest @@ -243,12 +243,14 @@ def addon_info_side_effect_fixture() -> Any | None: @pytest.fixture(name="addon_info") -def addon_info_fixture(addon_info_side_effect: Any | None) -> Generator[AsyncMock]: +def addon_info_fixture( + supervisor_client: AsyncMock, addon_info_side_effect: Any | None +) -> Generator[AsyncMock]: """Mock Supervisor add-on info.""" # pylint: disable-next=import-outside-toplevel from .hassio.common import mock_addon_info - yield from mock_addon_info(addon_info_side_effect) + yield from mock_addon_info(supervisor_client, addon_info_side_effect) @pytest.fixture(name="addon_not_installed") @@ -409,3 +411,29 @@ def update_addon_fixture() -> Generator[AsyncMock]: from .hassio.common import mock_update_addon yield from mock_update_addon() + + +@pytest.fixture(name="supervisor_client") +def supervisor_client() -> Generator[AsyncMock]: + """Mock the supervisor client.""" + supervisor_client = AsyncMock() + supervisor_client.addons = AsyncMock() + with ( + patch( + "homeassistant.components.hassio.get_supervisor_client", + return_value=supervisor_client, + ), + patch( + "homeassistant.components.hassio.handler.get_supervisor_client", + return_value=supervisor_client, + ), + patch( + "homeassistant.components.hassio.addon_manager.get_supervisor_client", + return_value=supervisor_client, + ), + patch( + "homeassistant.components.hassio.handler.HassIO.client", + new=PropertyMock(return_value=supervisor_client), + ), + ): + yield supervisor_client diff --git a/tests/components/hassio/common.py b/tests/components/hassio/common.py index 630368a0a7a..8aee2b35a5f 100644 --- a/tests/components/hassio/common.py +++ b/tests/components/hassio/common.py @@ -3,14 +3,28 @@ from __future__ import annotations from collections.abc import Generator +from dataclasses import fields import logging +from types import MethodType from typing import Any -from unittest.mock import DEFAULT, AsyncMock, patch +from unittest.mock import DEFAULT, AsyncMock, Mock, patch + +from aiohasupervisor.models import InstalledAddonComplete from homeassistant.components.hassio.addon_manager import AddonManager from homeassistant.core import HomeAssistant LOGGER = logging.getLogger(__name__) +INSTALLED_ADDON_FIELDS = [field.name for field in fields(InstalledAddonComplete)] + + +def mock_to_dict(obj: Mock, fields: list[str]) -> dict[str, Any]: + """Aiohasupervisor mocks to dictionary representation.""" + return { + field: getattr(obj, field) + for field in fields + if not isinstance(getattr(obj, field), Mock) + } def mock_addon_manager(hass: HomeAssistant) -> AddonManager: @@ -52,21 +66,31 @@ def mock_addon_store_info( yield addon_store_info -def mock_addon_info(addon_info_side_effect: Any | None) -> Generator[AsyncMock]: +def mock_addon_info( + supervisor_client: AsyncMock, addon_info_side_effect: Any | None +) -> Generator[AsyncMock]: """Mock Supervisor add-on info.""" - with patch( - "homeassistant.components.hassio.addon_manager.async_get_addon_info", - side_effect=addon_info_side_effect, - ) as addon_info: - addon_info.return_value = { - "available": False, - "hostname": None, - "options": {}, - "state": None, - "update_available": False, - "version": None, - } - yield addon_info + supervisor_client.addons.addon_info.side_effect = addon_info_side_effect + + supervisor_client.addons.addon_info.return_value = addon_info = Mock( + spec=InstalledAddonComplete, + slug="test", + repository="core", + available=False, + hostname="", + options={}, + state="unknown", + update_available=False, + version=None, + supervisor_api=False, + supervisor_role="default", + ) + addon_info.name = "test" + addon_info.to_dict = MethodType( + lambda self: mock_to_dict(self, INSTALLED_ADDON_FIELDS), + addon_info, + ) + yield supervisor_client.addons.addon_info def mock_addon_not_installed( @@ -87,10 +111,10 @@ def mock_addon_installed( "state": "stopped", "version": "1.0.0", } - addon_info.return_value["available"] = True - addon_info.return_value["hostname"] = "core-test-addon" - addon_info.return_value["state"] = "stopped" - addon_info.return_value["version"] = "1.0.0" + addon_info.return_value.available = True + addon_info.return_value.hostname = "core-test-addon" + addon_info.return_value.state = "stopped" + addon_info.return_value.version = "1.0.0" return addon_info @@ -102,10 +126,7 @@ def mock_addon_running(addon_store_info: AsyncMock, addon_info: AsyncMock) -> As "state": "started", "version": "1.0.0", } - addon_info.return_value["available"] = True - addon_info.return_value["hostname"] = "core-test-addon" - addon_info.return_value["state"] = "started" - addon_info.return_value["version"] = "1.0.0" + addon_info.return_value.state = "started" return addon_info @@ -122,9 +143,10 @@ def mock_install_addon_side_effect( "state": "stopped", "version": "1.0.0", } - addon_info.return_value["available"] = True - addon_info.return_value["state"] = "stopped" - addon_info.return_value["version"] = "1.0.0" + + addon_info.return_value.available = True + addon_info.return_value.state = "stopped" + addon_info.return_value.version = "1.0.0" return install_addon @@ -152,8 +174,8 @@ def mock_start_addon_side_effect( "state": "started", "version": "1.0.0", } - addon_info.return_value["available"] = True - addon_info.return_value["state"] = "started" + addon_info.return_value.available = True + addon_info.return_value.state = "started" return start_addon @@ -194,7 +216,7 @@ def mock_uninstall_addon() -> Generator[AsyncMock]: def mock_addon_options(addon_info: AsyncMock) -> dict[str, Any]: """Mock add-on options.""" - return addon_info.return_value["options"] + return addon_info.return_value.options def mock_set_addon_options_side_effect(addon_options: dict[str, Any]) -> Any | None: diff --git a/tests/components/hassio/test_addon_manager.py b/tests/components/hassio/test_addon_manager.py index 4cb57e5b8d8..c1b47f67d3c 100644 --- a/tests/components/hassio/test_addon_manager.py +++ b/tests/components/hassio/test_addon_manager.py @@ -43,7 +43,7 @@ async def test_not_available_raises_exception( ) -> None: """Test addon not available raises exception.""" addon_store_info.return_value["available"] = False - addon_info.return_value["available"] = False + addon_info.return_value.available = False with pytest.raises(AddonError) as err: await addon_manager.async_install_addon() @@ -118,7 +118,7 @@ async def test_get_addon_info( addon_state: AddonState, ) -> None: """Test get addon info when addon is installed.""" - addon_installed.return_value["state"] = addon_info_state + addon_installed.return_value.state = addon_info_state assert await addon_manager.async_get_addon_info() == AddonInfo( available=True, hostname="core-test-addon", @@ -198,7 +198,7 @@ async def test_install_addon( ) -> None: """Test install addon.""" addon_store_info.return_value["available"] = True - addon_info.return_value["available"] = True + addon_info.return_value.available = True await addon_manager.async_install_addon() @@ -213,7 +213,7 @@ async def test_install_addon_error( ) -> None: """Test install addon raises error.""" addon_store_info.return_value["available"] = True - addon_info.return_value["available"] = True + addon_info.return_value.available = True install_addon.side_effect = HassioAPIError("Boom") with pytest.raises(AddonError) as err: @@ -501,7 +501,7 @@ async def test_update_addon( update_addon: AsyncMock, ) -> None: """Test update addon.""" - addon_info.return_value["update_available"] = True + addon_info.return_value.update_available = True await addon_manager.async_update_addon() @@ -521,7 +521,7 @@ async def test_update_addon_no_update( update_addon: AsyncMock, ) -> None: """Test update addon without update available.""" - addon_info.return_value["update_available"] = False + addon_info.return_value.update_available = False await addon_manager.async_update_addon() @@ -539,7 +539,7 @@ async def test_update_addon_error( update_addon: AsyncMock, ) -> None: """Test update addon raises error.""" - addon_info.return_value["update_available"] = True + addon_info.return_value.update_available = True update_addon.side_effect = HassioAPIError("Boom") with pytest.raises(AddonError) as err: @@ -564,7 +564,7 @@ async def test_schedule_update_addon( update_addon: AsyncMock, ) -> None: """Test schedule update addon.""" - addon_info.return_value["update_available"] = True + addon_info.return_value.update_available = True update_task = addon_manager.async_schedule_update_addon() @@ -637,7 +637,7 @@ async def test_schedule_update_addon_error( error_message: str, ) -> None: """Test schedule update addon raises error.""" - addon_installed.return_value["update_available"] = True + addon_installed.return_value.update_available = True create_backup.side_effect = create_backup_error update_addon.side_effect = update_addon_error @@ -688,7 +688,7 @@ async def test_schedule_update_addon_logs_error( caplog: pytest.LogCaptureFixture, ) -> None: """Test schedule update addon logs error.""" - addon_installed.return_value["update_available"] = True + addon_installed.return_value.update_available = True create_backup.side_effect = create_backup_error update_addon.side_effect = update_addon_error diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index af72ea9d702..33cfd448b44 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -1,7 +1,7 @@ """The tests for the hassio binary sensors.""" import os -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest @@ -17,7 +17,7 @@ MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) -def mock_all(aioclient_mock: AiohttpClientMocker) -> None: +def mock_all(aioclient_mock: AiohttpClientMocker, addon_installed) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) @@ -193,20 +193,23 @@ def mock_all(aioclient_mock: AiohttpClientMocker) -> None: @pytest.mark.parametrize( - ("entity_id", "expected"), + ("entity_id", "expected", "addon_state"), [ - ("binary_sensor.test_running", "on"), - ("binary_sensor.test2_running", "off"), + ("binary_sensor.test_running", "on", "started"), + ("binary_sensor.test2_running", "off", "stopped"), ], ) async def test_binary_sensor( hass: HomeAssistant, - entity_id, - expected, + entity_id: str, + expected: str, + addon_state: str, aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + addon_installed: AsyncMock, ) -> None: """Test hassio OS and addons binary sensor.""" + addon_installed.return_value.state = addon_state config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 0d648ba9bdb..0fcf7933ac0 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -18,7 +18,7 @@ MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) -def mock_all(aioclient_mock: AiohttpClientMocker) -> None: +def mock_all(aioclient_mock: AiohttpClientMocker, addon_installed) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py index 305b863b3af..a0851ccd9f6 100644 --- a/tests/components/hassio/test_discovery.py +++ b/tests/components/hassio/test_discovery.py @@ -43,6 +43,7 @@ async def test_hassio_discovery_startup( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_mqtt: type[config_entries.ConfigFlow], + addon_installed: AsyncMock, ) -> None: """Test startup and discovery after event.""" aioclient_mock.get( @@ -67,10 +68,7 @@ async def test_hassio_discovery_startup( }, }, ) - aioclient_mock.get( - "http://127.0.0.1/addons/mosquitto/info", - json={"result": "ok", "data": {"name": "Mosquitto Test"}}, - ) + addon_installed.return_value.name = "Mosquitto Test" assert aioclient_mock.call_count == 0 @@ -78,7 +76,7 @@ async def test_hassio_discovery_startup( await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 1 assert mock_mqtt.async_step_hassio.called mock_mqtt.async_step_hassio.assert_called_with( HassioServiceInfo( @@ -102,6 +100,7 @@ async def test_hassio_discovery_startup_done( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_mqtt: type[config_entries.ConfigFlow], + addon_installed: AsyncMock, ) -> None: """Test startup and discovery with hass discovery.""" aioclient_mock.post( @@ -130,10 +129,7 @@ async def test_hassio_discovery_startup_done( }, }, ) - aioclient_mock.get( - "http://127.0.0.1/addons/mosquitto/info", - json={"result": "ok", "data": {"name": "Mosquitto Test"}}, - ) + addon_installed.return_value.name = "Mosquitto Test" with ( patch( @@ -149,7 +145,7 @@ async def test_hassio_discovery_startup_done( await async_setup_component(hass, "hassio", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 1 assert mock_mqtt.async_step_hassio.called mock_mqtt.async_step_hassio.assert_called_with( HassioServiceInfo( @@ -173,6 +169,7 @@ async def test_hassio_discovery_webhook( aioclient_mock: AiohttpClientMocker, hassio_client: TestClient, mock_mqtt: type[config_entries.ConfigFlow], + addon_installed: AsyncMock, ) -> None: """Test discovery webhook.""" aioclient_mock.get( @@ -193,10 +190,7 @@ async def test_hassio_discovery_webhook( }, }, ) - aioclient_mock.get( - "http://127.0.0.1/addons/mosquitto/info", - json={"result": "ok", "data": {"name": "Mosquitto Test"}}, - ) + addon_installed.return_value.name = "Mosquitto Test" resp = await hassio_client.post( "/api/hassio_push/discovery/testuuid", @@ -207,7 +201,7 @@ async def test_hassio_discovery_webhook( await hass.async_block_till_done() assert resp.status == HTTPStatus.OK - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 1 assert mock_mqtt.async_step_hassio.called mock_mqtt.async_step_hassio.assert_called_with( HassioServiceInfo( diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index 949f96ece38..1fb1e44c46d 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -201,20 +201,6 @@ async def test_api_homeassistant_restart( assert aioclient_mock.call_count == 1 -async def test_api_addon_info( - hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker -) -> None: - """Test setup with API Add-on info.""" - aioclient_mock.get( - "http://127.0.0.1/addons/test/info", - json={"result": "ok", "data": {"name": "bla"}}, - ) - - data = await hassio_handler.get_addon_info("test") - assert data["name"] == "bla" - assert aioclient_mock.call_count == 1 - - async def test_api_addon_stats( hassio_handler: HassIO, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index d71e8acfbe0..13626ef19d0 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -509,6 +509,7 @@ async def test_service_calls( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, + addon_installed, ) -> None: """Call service and check the API calls behind that.""" with ( @@ -546,14 +547,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 24 + assert aioclient_mock.call_count == 22 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 26 + assert aioclient_mock.call_count == 24 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -568,7 +569,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 28 + assert aioclient_mock.call_count == 26 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -593,7 +594,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 30 + assert aioclient_mock.call_count == 28 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -612,7 +613,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 31 + assert aioclient_mock.call_count == 29 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -628,7 +629,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 32 + assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -647,7 +648,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 34 + assert aioclient_mock.call_count == 32 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -749,6 +750,7 @@ async def test_service_calls_core( assert aioclient_mock.call_count == 6 +@pytest.mark.usefixtures("addon_installed") async def test_entry_load_and_unload(hass: HomeAssistant) -> None: """Test loading and unloading config entry.""" with patch.dict(os.environ, MOCK_ENVIRON): @@ -775,6 +777,7 @@ async def test_migration_off_hassio(hass: HomeAssistant) -> None: assert hass.config_entries.async_entries(DOMAIN) == [] +@pytest.mark.usefixtures("addon_installed") async def test_device_registry_calls( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -927,6 +930,7 @@ async def test_device_registry_calls( assert len(device_registry.devices) == 5 +@pytest.mark.usefixtures("addon_installed") async def test_coordinator_updates( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1002,7 +1006,7 @@ async def test_coordinator_updates( assert "Error on Supervisor API: Unknown" in caplog.text -@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "addon_installed") async def test_coordinator_updates_stats_entities_enabled( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 1a3d3d83f95..578279dbf79 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -835,7 +835,7 @@ async def test_system_is_not_ready( @pytest.mark.parametrize( "all_setup_requests", [{"include_addons": True}], indirect=True ) -@pytest.mark.usefixtures("all_setup_requests") +@pytest.mark.usefixtures("all_setup_requests", "addon_installed") async def test_supervisor_issues_detached_addon_missing( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 907529ec9c4..7655f657eda 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -563,7 +563,7 @@ async def test_mount_failed_repair_flow( @pytest.mark.parametrize( "all_setup_requests", [{"include_addons": True}], indirect=True ) -@pytest.mark.usefixtures("all_setup_requests") +@pytest.mark.usefixtures("all_setup_requests", "addon_installed") async def test_supervisor_issue_docker_config_repair_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -786,7 +786,7 @@ async def test_supervisor_issue_repair_flow_multiple_data_disks( @pytest.mark.parametrize( "all_setup_requests", [{"include_addons": True}], indirect=True ) -@pytest.mark.usefixtures("all_setup_requests") +@pytest.mark.usefixtures("all_setup_requests", "addon_installed") async def test_supervisor_issue_detached_addon_removed( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 71b867d849d..bd3de73baf5 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -28,7 +28,7 @@ MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) -def mock_all(aioclient_mock: AiohttpClientMocker) -> None: +def mock_all(aioclient_mock: AiohttpClientMocker, addon_installed) -> None: """Mock all setup requests.""" _install_default_mocks(aioclient_mock) _install_test_addon_stats_mock(aioclient_mock) diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 9a047010cc3..6195e62aaac 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -2,8 +2,9 @@ from datetime import timedelta import os -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from aiohasupervisor import SupervisorBadRequestError import pytest from homeassistant.components.hassio import DOMAIN, HassioAPIError @@ -21,7 +22,7 @@ MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) -def mock_all(aioclient_mock: AiohttpClientMocker) -> None: +def mock_all(aioclient_mock: AiohttpClientMocker, addon_installed) -> None: """Mock all setup requests.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) @@ -217,8 +218,10 @@ async def test_update_entities( expected_state, auto_update, aioclient_mock: AiohttpClientMocker, + addon_installed: AsyncMock, ) -> None: """Test update entities.""" + addon_installed.return_value.auto_update = auto_update config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) config_entry.add_to_hass(hass) @@ -375,7 +378,7 @@ async def test_update_addon_with_error( exc=HassioAPIError, ) - with pytest.raises(HomeAssistantError): + with pytest.raises(HomeAssistantError, match=r"^Error updating test:"): assert not await hass.services.async_call( "update", "install", @@ -404,7 +407,9 @@ async def test_update_os_with_error( exc=HassioAPIError, ) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match=r"^Error updating Home Assistant Operating System:" + ): assert not await hass.services.async_call( "update", "install", @@ -433,7 +438,9 @@ async def test_update_supervisor_with_error( exc=HassioAPIError, ) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match=r"^Error updating Home Assistant Supervisor:" + ): assert not await hass.services.async_call( "update", "install", @@ -462,7 +469,9 @@ async def test_update_core_with_error( exc=HassioAPIError, ) - with pytest.raises(HomeAssistantError): + with pytest.raises( + HomeAssistantError, match=r"^Error updating Home Assistant Core:" + ): assert not await hass.services.async_call( "update", "install", @@ -613,9 +622,12 @@ async def test_no_os_entity(hass: HomeAssistant) -> None: async def test_setting_up_core_update_when_addon_fails( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + addon_installed: AsyncMock, ) -> None: """Test setting up core update when single addon fails.""" + addon_installed.side_effect = SupervisorBadRequestError("Addon Test does not exist") with ( patch.dict(os.environ, MOCK_ENVIRON), patch( @@ -626,10 +638,6 @@ async def test_setting_up_core_update_when_addon_fails( "homeassistant.components.hassio.HassIO.get_addon_changelog", side_effect=HassioAPIError("add-on is not running"), ), - patch( - "homeassistant.components.hassio.HassIO.get_addon_info", - side_effect=HassioAPIError("add-on is not running"), - ), ): result = await async_setup_component( hass, diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 65fab707c0b..7d4b1dc9df0 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -418,7 +418,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( await hass.async_block_till_done() install_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") - addon_info.return_value["hostname"] = "core-silabs-multiprotocol" + addon_info.return_value.hostname = "core-silabs-multiprotocol" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" @@ -513,7 +513,7 @@ async def test_option_flow_addon_installed_same_device_reconfigure_unexpected_us ) -> None: """Test reconfiguring the multi pan addon.""" - addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + addon_info.return_value.options["device"] = "/dev/ttyTEST123" multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( hass @@ -572,7 +572,7 @@ async def test_option_flow_addon_installed_same_device_reconfigure_expected_user ) -> None: """Test reconfiguring the multi pan addon.""" - addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + addon_info.return_value.options["device"] = "/dev/ttyTEST123" multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( hass @@ -643,7 +643,7 @@ async def test_option_flow_addon_installed_same_device_uninstall( ) -> None: """Test uninstalling the multi pan addon.""" - addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + addon_info.return_value.options["device"] = "/dev/ttyTEST123" # Setup the config entry config_entry = MockConfigEntry( @@ -738,7 +738,7 @@ async def test_option_flow_addon_installed_same_device_do_not_uninstall_multi_pa ) -> None: """Test uninstalling the multi pan addon.""" - addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + addon_info.return_value.options["device"] = "/dev/ttyTEST123" # Setup the config entry config_entry = MockConfigEntry( @@ -781,7 +781,7 @@ async def test_option_flow_flasher_already_running_failure( ) -> None: """Test uninstalling the multi pan addon but with the flasher addon running.""" - addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + addon_info.return_value.options["device"] = "/dev/ttyTEST123" # Setup the config entry config_entry = MockConfigEntry( @@ -805,7 +805,7 @@ async def test_option_flow_flasher_already_running_failure( # The flasher addon is already installed and running, this is bad addon_store_info.return_value["installed"] = True - addon_info.return_value["state"] = "started" + addon_info.return_value.state = "started" result = await hass.config_entries.options.async_configure( result["flow_id"], {silabs_multiprotocol_addon.CONF_DISABLE_MULTI_PAN: True} @@ -828,7 +828,7 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed ) -> None: """Test uninstalling the multi pan addon.""" - addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + addon_info.return_value.options["device"] = "/dev/ttyTEST123" # Setup the config entry config_entry = MockConfigEntry( @@ -898,7 +898,7 @@ async def test_option_flow_flasher_install_failure( ) -> None: """Test uninstalling the multi pan addon, case where flasher addon fails.""" - addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + addon_info.return_value.options["device"] = "/dev/ttyTEST123" # Setup the config entry config_entry = MockConfigEntry( @@ -967,7 +967,7 @@ async def test_option_flow_flasher_addon_flash_failure( ) -> None: """Test where flasher addon fails to flash Zigbee firmware.""" - addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + addon_info.return_value.options["device"] = "/dev/ttyTEST123" # Setup the config entry config_entry = MockConfigEntry( @@ -1034,7 +1034,7 @@ async def test_option_flow_uninstall_migration_initiate_failure( ) -> None: """Test uninstalling the multi pan addon, case where ZHA migration init fails.""" - addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + addon_info.return_value.options["device"] = "/dev/ttyTEST123" # Setup the config entry config_entry = MockConfigEntry( @@ -1095,7 +1095,7 @@ async def test_option_flow_uninstall_migration_finish_failure( ) -> None: """Test uninstalling the multi pan addon, case where ZHA migration init fails.""" - addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + addon_info.return_value.options["device"] = "/dev/ttyTEST123" # Setup the config entry config_entry = MockConfigEntry( @@ -1667,7 +1667,7 @@ async def test_check_multi_pan_addon_auto_start( ) -> None: """Test `check_multi_pan_addon` auto starting the addon.""" - addon_info.return_value["state"] = "not_running" + addon_info.return_value.state = "not_running" addon_store_info.return_value = { "installed": True, "available": True, @@ -1686,7 +1686,7 @@ async def test_check_multi_pan_addon( ) -> None: """Test `check_multi_pan_addon`.""" - addon_info.return_value["state"] = "started" + addon_info.return_value.state = "started" addon_store_info.return_value = { "installed": True, "available": True, @@ -1717,7 +1717,7 @@ async def test_multi_pan_addon_using_device_not_running( ) -> None: """Test `multi_pan_addon_using_device` when the addon isn't running.""" - addon_info.return_value["state"] = "not_running" + addon_info.return_value.state = "not_running" addon_store_info.return_value = { "installed": True, "available": True, @@ -1745,8 +1745,8 @@ async def test_multi_pan_addon_using_device( ) -> None: """Test `multi_pan_addon_using_device` when the addon isn't running.""" - addon_info.return_value["state"] = "started" - addon_info.return_value["options"] = { + addon_info.return_value.state = "started" + addon_info.return_value.options = { "autoflash_firmware": True, "device": options_device, "baudrate": "115200", diff --git a/tests/components/homeassistant_yellow/test_hardware.py b/tests/components/homeassistant_yellow/test_hardware.py index 9d43b341abf..4fd2eddb704 100644 --- a/tests/components/homeassistant_yellow/test_hardware.py +++ b/tests/components/homeassistant_yellow/test_hardware.py @@ -13,6 +13,7 @@ from tests.common import MockConfigEntry, MockModule, mock_integration from tests.typing import WebSocketGenerator +@pytest.mark.usefixtures("supervisor_client") async def test_hardware_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info ) -> None: @@ -65,6 +66,7 @@ async def test_hardware_info( @pytest.mark.parametrize("os_info", [None, {"board": None}, {"board": "other"}]) +@pytest.mark.usefixtures("supervisor_client") async def test_hardware_info_fail( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, os_info, addon_store_info ) -> None: diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index cd5ef307cd3..1296604f390 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -411,8 +411,8 @@ async def test_update_addon( connect_side_effect: Exception, ) -> None: """Test update the Matter add-on during entry setup.""" - addon_info.return_value["version"] = addon_version - addon_info.return_value["update_available"] = update_available + addon_info.return_value.version = addon_version + addon_info.return_value.update_available = update_available create_backup.side_effect = create_backup_side_effect update_addon.side_effect = update_addon_side_effect matter_client.connect.side_effect = connect_side_effect diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index edd92591b1b..966f80d0bd8 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -3,7 +3,7 @@ import asyncio from http import HTTPStatus from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch import aiohttp import pytest @@ -32,21 +32,16 @@ HASSIO_DATA_2 = hassio.HassioServiceInfo( ) -@pytest.fixture(name="addon_info") -def addon_info_fixture(): - """Mock Supervisor add-on info.""" - with patch( - "homeassistant.components.otbr.config_flow.async_get_addon_info", - ) as addon_info: - addon_info.return_value = { - "available": True, - "hostname": None, - "options": {}, - "state": None, - "update_available": False, - "version": None, - } - yield addon_info +@pytest.fixture(name="otbr_addon_info") +def otbr_addon_info_fixture(addon_info: AsyncMock, addon_installed) -> AsyncMock: + """Mock Supervisor otbr add-on info.""" + addon_info.return_value.available = True + addon_info.return_value.hostname = "" + addon_info.return_value.options = {} + addon_info.return_value.state = "unknown" + addon_info.return_value.update_available = False + addon_info.return_value.version = None + return addon_info @pytest.mark.parametrize( @@ -360,7 +355,7 @@ async def _test_user_flow_connect_error(hass: HomeAssistant, func, error) -> Non @pytest.mark.usefixtures("get_border_agent_id") async def test_hassio_discovery_flow( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: """Test the hassio discovery flow.""" url = "http://core-silabs-multiprotocol:8081" @@ -393,20 +388,14 @@ async def test_hassio_discovery_flow( @pytest.mark.usefixtures("get_border_agent_id") async def test_hassio_discovery_flow_yellow( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: """Test the hassio discovery flow.""" url = "http://core-silabs-multiprotocol:8081" aioclient_mock.get(f"{url}/node/dataset/active", text="aa") - addon_info.return_value = { - "available": True, - "hostname": None, - "options": {"device": "/dev/ttyAMA1"}, - "state": None, - "update_available": False, - "version": None, - } + otbr_addon_info.return_value.available = True + otbr_addon_info.return_value.options = {"device": "/dev/ttyAMA1"} with ( patch( @@ -455,20 +444,14 @@ async def test_hassio_discovery_flow_sky_connect( title: str, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - addon_info, + otbr_addon_info, ) -> None: """Test the hassio discovery flow.""" url = "http://core-silabs-multiprotocol:8081" aioclient_mock.get(f"{url}/node/dataset/active", text="aa") - addon_info.return_value = { - "available": True, - "hostname": None, - "options": {"device": device}, - "state": None, - "update_available": False, - "version": None, - } + otbr_addon_info.return_value.available = True + otbr_addon_info.return_value.options = {"device": device} with patch( "homeassistant.components.otbr.async_setup_entry", @@ -497,7 +480,7 @@ async def test_hassio_discovery_flow_sky_connect( @pytest.mark.usefixtures("get_active_dataset_tlvs", "get_extended_address") async def test_hassio_discovery_flow_2x_addons( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: """Test the hassio discovery flow when the user has 2 addons with otbr support.""" url1 = "http://core-silabs-multiprotocol:8081" @@ -507,37 +490,28 @@ async def test_hassio_discovery_flow_2x_addons( aioclient_mock.get(f"{url1}/node/ba-id", json=TEST_BORDER_AGENT_ID.hex()) aioclient_mock.get(f"{url2}/node/ba-id", json=TEST_BORDER_AGENT_ID_2.hex()) - async def _addon_info(hass: HomeAssistant, slug: str) -> dict[str, Any]: + async def _addon_info(slug: str) -> Mock: await asyncio.sleep(0) if slug == "otbr": - return { - "available": True, - "hostname": None, - "options": { - "device": ( - "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_" - "9e2adbd75b8beb119fe564a0f320645d-if00-port0" - ) - }, - "state": None, - "update_available": False, - "version": None, - } - return { - "available": True, - "hostname": None, - "options": { - "device": ( - "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_" - "9e2adbd75b8beb119fe564a0f320645d-if00-port1" - ) - }, - "state": None, - "update_available": False, - "version": None, - } + device = ( + "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_" + "9e2adbd75b8beb119fe564a0f320645d-if00-port0" + ) + else: + device = ( + "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_" + "9e2adbd75b8beb119fe564a0f320645d-if00-port1" + ) + return Mock( + available=True, + hostname=otbr_addon_info.return_value.hostname, + options={"device": device}, + state=otbr_addon_info.return_value.state, + update_available=otbr_addon_info.return_value.update_available, + version=otbr_addon_info.return_value.version, + ) - addon_info.side_effect = _addon_info + otbr_addon_info.side_effect = _addon_info result1 = await hass.config_entries.flow.async_init( otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA @@ -590,7 +564,7 @@ async def test_hassio_discovery_flow_2x_addons( @pytest.mark.usefixtures("get_active_dataset_tlvs", "get_extended_address") async def test_hassio_discovery_flow_2x_addons_same_ext_address( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: """Test the hassio discovery flow when the user has 2 addons with otbr support.""" url1 = "http://core-silabs-multiprotocol:8081" @@ -600,37 +574,28 @@ async def test_hassio_discovery_flow_2x_addons_same_ext_address( aioclient_mock.get(f"{url1}/node/ba-id", json=TEST_BORDER_AGENT_ID.hex()) aioclient_mock.get(f"{url2}/node/ba-id", json=TEST_BORDER_AGENT_ID.hex()) - async def _addon_info(hass: HomeAssistant, slug: str) -> dict[str, Any]: + async def _addon_info(slug: str) -> Mock: await asyncio.sleep(0) if slug == "otbr": - return { - "available": True, - "hostname": None, - "options": { - "device": ( - "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_" - "9e2adbd75b8beb119fe564a0f320645d-if00-port0" - ) - }, - "state": None, - "update_available": False, - "version": None, - } - return { - "available": True, - "hostname": None, - "options": { - "device": ( - "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_" - "9e2adbd75b8beb119fe564a0f320645d-if00-port1" - ) - }, - "state": None, - "update_available": False, - "version": None, - } + device = ( + "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_" + "9e2adbd75b8beb119fe564a0f320645d-if00-port0" + ) + else: + device = ( + "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_" + "9e2adbd75b8beb119fe564a0f320645d-if00-port1" + ) + return Mock( + available=True, + hostname=otbr_addon_info.return_value.hostname, + options={"device": device}, + state=otbr_addon_info.return_value.state, + update_available=otbr_addon_info.return_value.update_available, + version=otbr_addon_info.return_value.version, + ) - addon_info.side_effect = _addon_info + otbr_addon_info.side_effect = _addon_info result1 = await hass.config_entries.flow.async_init( otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA @@ -666,7 +631,7 @@ async def test_hassio_discovery_flow_2x_addons_same_ext_address( @pytest.mark.usefixtures("get_border_agent_id") async def test_hassio_discovery_flow_router_not_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -724,7 +689,7 @@ async def test_hassio_discovery_flow_router_not_setup( @pytest.mark.usefixtures("get_border_agent_id") async def test_hassio_discovery_flow_router_not_setup_has_preferred( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_addon_info ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -780,7 +745,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, multiprotocol_addon_manager_mock, - addon_info, + otbr_addon_info, ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -920,7 +885,7 @@ async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None: @pytest.mark.usefixtures( - "addon_info", + "otbr_addon_info", "get_active_dataset_tlvs", "get_border_agent_id", "get_extended_address", @@ -962,7 +927,7 @@ async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) - ], ) @pytest.mark.usefixtures( - "addon_info", + "otbr_addon_info", "get_active_dataset_tlvs", "get_border_agent_id", "get_extended_address", diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 5ec72b8a46a..a83ed2603dc 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -772,8 +772,8 @@ async def test_update_addon( network_key = "abc123" addon_options["device"] = device addon_options["network_key"] = network_key - addon_info.return_value["version"] = addon_version - addon_info.return_value["update_available"] = update_available + addon_info.return_value.version = addon_version + addon_info.return_value.update_available = update_available create_backup.side_effect = create_backup_side_effect update_addon.side_effect = update_addon_side_effect client.connect.side_effect = InvalidServerVersion( From 4aaba171ca1307da410e651e282c3df88539747f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 09:46:13 +0200 Subject: [PATCH 0761/1309] Cleanup unnecessary F401 ignores (#126188) * Cleanup unnecessary F401 ignores * Adjust tests --- homeassistant/components/airnow/__init__.py | 1 - homeassistant/components/co2signal/__init__.py | 1 - homeassistant/components/harmony/__init__.py | 2 +- tests/components/airnow/conftest.py | 2 +- tests/components/co2signal/conftest.py | 2 +- tests/components/co2signal/test_config_flow.py | 3 ++- 6 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index cff6b8c2795..2047a9d41bc 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -15,7 +15,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN # noqa: F401 from .coordinator import AirNowDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 1b69a06d12d..e84ba387194 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -9,7 +9,6 @@ from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN # noqa: F401 from .coordinator import CO2SignalCoordinator PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index 9a643815385..e4b6f1c7c2c 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DOMAIN, HARMONY_OPTIONS_UPDATE, PLATFORMS # noqa: F401 +from .const import HARMONY_OPTIONS_UPDATE, PLATFORMS from .data import HarmonyConfigEntry, HarmonyData _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py index c5d23fa7289..84adf12806d 100644 --- a/tests/components/airnow/conftest.py +++ b/tests/components/airnow/conftest.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.airnow import DOMAIN +from homeassistant.components.airnow.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonArrayType diff --git a/tests/components/co2signal/conftest.py b/tests/components/co2signal/conftest.py index d5cca448569..680465c2537 100644 --- a/tests/components/co2signal/conftest.py +++ b/tests/components/co2signal/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components.co2signal import DOMAIN +from homeassistant.components.co2signal.const import DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py index ad61ae4f897..92d9450b670 100644 --- a/tests/components/co2signal/test_config_flow.py +++ b/tests/components/co2signal/test_config_flow.py @@ -11,7 +11,8 @@ from aioelectricitymaps import ( import pytest from homeassistant import config_entries -from homeassistant.components.co2signal import DOMAIN, config_flow +from homeassistant.components.co2signal import config_flow +from homeassistant.components.co2signal.const import DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType From e7bb9a440a190c4b4dc236237c7fe594da30ec2e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 09:49:10 +0200 Subject: [PATCH 0762/1309] Move vesync base entity to separate module (#126187) --- homeassistant/components/vesync/common.py | 67 +----------------- .../components/vesync/diagnostics.py | 2 +- homeassistant/components/vesync/entity.py | 69 +++++++++++++++++++ homeassistant/components/vesync/fan.py | 2 +- homeassistant/components/vesync/light.py | 2 +- homeassistant/components/vesync/sensor.py | 2 +- homeassistant/components/vesync/switch.py | 2 +- 7 files changed, 75 insertions(+), 71 deletions(-) create mode 100644 homeassistant/components/vesync/entity.py diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 33fc88f32d6..b57b49f9994 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -1,14 +1,8 @@ """Common utilities for VeSync Component.""" import logging -from typing import Any -from pyvesync.vesyncbasedevice import VeSyncBaseDevice - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, ToggleEntity - -from .const import DOMAIN, VS_FANS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES +from .const import VS_FANS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES _LOGGER = logging.getLogger(__name__) @@ -48,62 +42,3 @@ async def async_process_devices(hass, manager): _LOGGER.info("%d VeSync switches found", len(manager.switches)) return devices - - -class VeSyncBaseEntity(Entity): - """Base class for VeSync Entity Representations.""" - - _attr_has_entity_name = True - - def __init__(self, device: VeSyncBaseDevice) -> None: - """Initialize the VeSync device.""" - self.device = device - self._attr_unique_id = self.base_unique_id - - @property - def base_unique_id(self): - """Return the ID of this device.""" - # The unique_id property may be overridden in subclasses, such as in - # sensors. Maintaining base_unique_id allows us to group related - # entities under a single device. - if isinstance(self.device.sub_device_no, int): - return f"{self.device.cid}{self.device.sub_device_no!s}" - return self.device.cid - - @property - def available(self) -> bool: - """Return True if device is available.""" - return self.device.connection_status == "online" - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.base_unique_id)}, - name=self.device.device_name, - model=self.device.device_type, - manufacturer="VeSync", - sw_version=self.device.current_firm_version, - ) - - def update(self) -> None: - """Update vesync device.""" - self.device.update() - - -class VeSyncDevice(VeSyncBaseEntity, ToggleEntity): - """Base class for VeSync Device Representations.""" - - @property - def details(self): - """Provide access to the device details dictionary.""" - return self.device.details - - @property - def is_on(self) -> bool: - """Return True if device is on.""" - return self.device.device_status == "on" - - def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - self.device.turn_off() diff --git a/homeassistant/components/vesync/diagnostics.py b/homeassistant/components/vesync/diagnostics.py index 9af8a7fed67..e1c092b1e32 100644 --- a/homeassistant/components/vesync/diagnostics.py +++ b/homeassistant/components/vesync/diagnostics.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from .common import VeSyncBaseDevice from .const import DOMAIN, VS_MANAGER +from .entity import VeSyncBaseDevice KEYS_TO_REDACT = {"manager", "uuid", "mac_id"} diff --git a/homeassistant/components/vesync/entity.py b/homeassistant/components/vesync/entity.py new file mode 100644 index 00000000000..fd636561e9e --- /dev/null +++ b/homeassistant/components/vesync/entity.py @@ -0,0 +1,69 @@ +"""Common entity for VeSync Component.""" + +from typing import Any + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, ToggleEntity + +from .const import DOMAIN + + +class VeSyncBaseEntity(Entity): + """Base class for VeSync Entity Representations.""" + + _attr_has_entity_name = True + + def __init__(self, device: VeSyncBaseDevice) -> None: + """Initialize the VeSync device.""" + self.device = device + self._attr_unique_id = self.base_unique_id + + @property + def base_unique_id(self): + """Return the ID of this device.""" + # The unique_id property may be overridden in subclasses, such as in + # sensors. Maintaining base_unique_id allows us to group related + # entities under a single device. + if isinstance(self.device.sub_device_no, int): + return f"{self.device.cid}{self.device.sub_device_no!s}" + return self.device.cid + + @property + def available(self) -> bool: + """Return True if device is available.""" + return self.device.connection_status == "online" + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.base_unique_id)}, + name=self.device.device_name, + model=self.device.device_type, + manufacturer="VeSync", + sw_version=self.device.current_firm_version, + ) + + def update(self) -> None: + """Update vesync device.""" + self.device.update() + + +class VeSyncDevice(VeSyncBaseEntity, ToggleEntity): + """Base class for VeSync Device Representations.""" + + @property + def details(self): + """Provide access to the device details dictionary.""" + return self.device.details + + @property + def is_on(self) -> bool: + """Return True if device is on.""" + return self.device.device_status == "on" + + def turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self.device.turn_off() diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 6ef9e41eb43..58a262e769f 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -17,8 +17,8 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from .common import VeSyncDevice from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS +from .entity import VeSyncDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 9b15e635903..6e449f63394 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -14,8 +14,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import VeSyncDevice from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS +from .entity import VeSyncDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 8939295a2db..79061ec0c4c 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -30,8 +30,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .common import VeSyncBaseEntity from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_SENSORS +from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 1d0c3472d53..a162a648ad7 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import VeSyncDevice from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_SWITCHES +from .entity import VeSyncDevice _LOGGER = logging.getLogger(__name__) From da4f401d179a285e395b94fdd1cdd9ad02f20c9a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 09:50:07 +0200 Subject: [PATCH 0763/1309] Move vera base entity to separate module (#126186) --- homeassistant/components/vera/__init__.py | 90 +-------------- .../components/vera/binary_sensor.py | 6 +- homeassistant/components/vera/climate.py | 6 +- homeassistant/components/vera/cover.py | 6 +- homeassistant/components/vera/entity.py | 103 ++++++++++++++++++ homeassistant/components/vera/light.py | 6 +- homeassistant/components/vera/lock.py | 6 +- homeassistant/components/vera/sensor.py | 6 +- homeassistant/components/vera/switch.py | 6 +- tests/components/vera/test_config_flow.py | 6 +- 10 files changed, 130 insertions(+), 111 deletions(-) create mode 100644 homeassistant/components/vera/entity.py diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index 722a6b86d4b..b8f0b702ebe 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio from collections import defaultdict import logging -from typing import Any import pyvera as veraApi from requests.exceptions import RequestException @@ -14,10 +13,6 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ARMED, - ATTR_BATTERY_LEVEL, - ATTR_LAST_TRIP_TIME, - ATTR_TRIPPED, CONF_EXCLUDE, CONF_LIGHTS, EVENT_HOMEASSISTANT_STOP, @@ -26,10 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -from homeassistant.util import slugify -from homeassistant.util.dt import utc_from_timestamp from .common import ( ControllerData, @@ -39,7 +31,7 @@ from .common import ( set_controller_data, ) from .config_flow import fix_device_id_list, new_options -from .const import CONF_CONTROLLER, CONF_LEGACY_UNIQUE_ID, DOMAIN, VERA_ID_FORMAT +from .const import CONF_CONTROLLER, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -204,83 +196,3 @@ def map_vera_device( ), None, ) - - -class VeraDevice[_DeviceTypeT: veraApi.VeraDevice](Entity): - """Representation of a Vera device entity.""" - - def __init__( - self, vera_device: _DeviceTypeT, controller_data: ControllerData - ) -> None: - """Initialize the device.""" - self.vera_device = vera_device - self.controller = controller_data.controller - - self._name = self.vera_device.name - # Append device id to prevent name clashes in HA. - self.vera_id = VERA_ID_FORMAT.format( - slugify(vera_device.name), vera_device.vera_device_id - ) - - if controller_data.config_entry.data.get(CONF_LEGACY_UNIQUE_ID): - self._unique_id = str(self.vera_device.vera_device_id) - else: - self._unique_id = f"vera_{controller_data.config_entry.unique_id}_{self.vera_device.vera_device_id}" - - async def async_added_to_hass(self) -> None: - """Subscribe to updates.""" - self.controller.register(self.vera_device, self._update_callback) - - def _update_callback(self, _device: _DeviceTypeT) -> None: - """Update the state.""" - self.schedule_update_ha_state(True) - - def update(self): - """Force a refresh from the device if the device is unavailable.""" - refresh_needed = self.vera_device.should_poll or not self.available - _LOGGER.debug("%s: update called (refresh=%s)", self._name, refresh_needed) - if refresh_needed: - self.vera_device.refresh() - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes of the device.""" - attr = {} - - if self.vera_device.has_battery: - attr[ATTR_BATTERY_LEVEL] = self.vera_device.battery_level - - if self.vera_device.is_armable: - armed = self.vera_device.is_armed - attr[ATTR_ARMED] = "True" if armed else "False" - - if self.vera_device.is_trippable: - if (last_tripped := self.vera_device.last_trip) is not None: - utc_time = utc_from_timestamp(int(last_tripped)) - attr[ATTR_LAST_TRIP_TIME] = utc_time.isoformat() - else: - attr[ATTR_LAST_TRIP_TIME] = None - tripped = self.vera_device.is_tripped - attr[ATTR_TRIPPED] = "True" if tripped else "False" - - attr["Vera Device Id"] = self.vera_device.vera_device_id - - return attr - - @property - def available(self): - """If device communications have failed return false.""" - return not self.vera_device.comm_failure - - @property - def unique_id(self) -> str: - """Return a unique ID. - - The Vera assigns a unique and immutable ID number to each device. - """ - return self._unique_id diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py index d90f6a78858..3438ee81d4a 100644 --- a/homeassistant/components/vera/binary_sensor.py +++ b/homeassistant/components/vera/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VeraDevice from .common import ControllerData, get_controller_data +from .entity import VeraEntity async def async_setup_entry( @@ -30,7 +30,7 @@ async def async_setup_entry( ) -class VeraBinarySensor(VeraDevice[veraApi.VeraBinarySensor], BinarySensorEntity): +class VeraBinarySensor(VeraEntity[veraApi.VeraBinarySensor], BinarySensorEntity): """Representation of a Vera Binary Sensor.""" _attr_is_on = False @@ -39,7 +39,7 @@ class VeraBinarySensor(VeraDevice[veraApi.VeraBinarySensor], BinarySensorEntity) self, vera_device: veraApi.VeraBinarySensor, controller_data: ControllerData ) -> None: """Initialize the binary_sensor.""" - VeraDevice.__init__(self, vera_device, controller_data) + VeraEntity.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) def update(self) -> None: diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 79a6c2566e0..01fe26be6bc 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -19,8 +19,8 @@ from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VeraDevice from .common import ControllerData, get_controller_data +from .entity import VeraEntity FAN_OPERATION_LIST = [FAN_ON, FAN_AUTO] @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): +class VeraThermostat(VeraEntity[veraApi.VeraThermostat], ClimateEntity): """Representation of a Vera Thermostat.""" _attr_hvac_modes = SUPPORT_HVAC @@ -60,7 +60,7 @@ class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): self, vera_device: veraApi.VeraThermostat, controller_data: ControllerData ) -> None: """Initialize the Vera device.""" - VeraDevice.__init__(self, vera_device, controller_data) + VeraEntity.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) @property diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py index 25ffe987d5e..b5b57f43c0c 100644 --- a/homeassistant/components/vera/cover.py +++ b/homeassistant/components/vera/cover.py @@ -12,8 +12,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VeraDevice from .common import ControllerData, get_controller_data +from .entity import VeraEntity async def async_setup_entry( @@ -32,14 +32,14 @@ async def async_setup_entry( ) -class VeraCover(VeraDevice[veraApi.VeraCurtain], CoverEntity): +class VeraCover(VeraEntity[veraApi.VeraCurtain], CoverEntity): """Representation a Vera Cover.""" def __init__( self, vera_device: veraApi.VeraCurtain, controller_data: ControllerData ) -> None: """Initialize the Vera device.""" - VeraDevice.__init__(self, vera_device, controller_data) + VeraEntity.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) @property diff --git a/homeassistant/components/vera/entity.py b/homeassistant/components/vera/entity.py new file mode 100644 index 00000000000..84e21e54983 --- /dev/null +++ b/homeassistant/components/vera/entity.py @@ -0,0 +1,103 @@ +"""Support for Vera devices.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pyvera as veraApi + +from homeassistant.const import ( + ATTR_ARMED, + ATTR_BATTERY_LEVEL, + ATTR_LAST_TRIP_TIME, + ATTR_TRIPPED, +) +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify +from homeassistant.util.dt import utc_from_timestamp + +from .common import ControllerData +from .const import CONF_LEGACY_UNIQUE_ID, VERA_ID_FORMAT + +_LOGGER = logging.getLogger(__name__) + + +class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity): + """Representation of a Vera device entity.""" + + def __init__( + self, vera_device: _DeviceTypeT, controller_data: ControllerData + ) -> None: + """Initialize the device.""" + self.vera_device = vera_device + self.controller = controller_data.controller + + self._name = self.vera_device.name + # Append device id to prevent name clashes in HA. + self.vera_id = VERA_ID_FORMAT.format( + slugify(vera_device.name), vera_device.vera_device_id + ) + + if controller_data.config_entry.data.get(CONF_LEGACY_UNIQUE_ID): + self._unique_id = str(self.vera_device.vera_device_id) + else: + self._unique_id = f"vera_{controller_data.config_entry.unique_id}_{self.vera_device.vera_device_id}" + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + self.controller.register(self.vera_device, self._update_callback) + + def _update_callback(self, _device: _DeviceTypeT) -> None: + """Update the state.""" + self.schedule_update_ha_state(True) + + def update(self): + """Force a refresh from the device if the device is unavailable.""" + refresh_needed = self.vera_device.should_poll or not self.available + _LOGGER.debug("%s: update called (refresh=%s)", self._name, refresh_needed) + if refresh_needed: + self.vera_device.refresh() + + @property + def name(self) -> str: + """Return the name of the device.""" + return self._name + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes of the device.""" + attr = {} + + if self.vera_device.has_battery: + attr[ATTR_BATTERY_LEVEL] = self.vera_device.battery_level + + if self.vera_device.is_armable: + armed = self.vera_device.is_armed + attr[ATTR_ARMED] = "True" if armed else "False" + + if self.vera_device.is_trippable: + if (last_tripped := self.vera_device.last_trip) is not None: + utc_time = utc_from_timestamp(int(last_tripped)) + attr[ATTR_LAST_TRIP_TIME] = utc_time.isoformat() + else: + attr[ATTR_LAST_TRIP_TIME] = None + tripped = self.vera_device.is_tripped + attr[ATTR_TRIPPED] = "True" if tripped else "False" + + attr["Vera Device Id"] = self.vera_device.vera_device_id + + return attr + + @property + def available(self): + """If device communications have failed return false.""" + return not self.vera_device.comm_failure + + @property + def unique_id(self) -> str: + """Return a unique ID. + + The Vera assigns a unique and immutable ID number to each device. + """ + return self._unique_id diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index 86e5dfa6a91..e512676de9a 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -19,8 +19,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util -from . import VeraDevice from .common import ControllerData, get_controller_data +from .entity import VeraEntity async def async_setup_entry( @@ -39,7 +39,7 @@ async def async_setup_entry( ) -class VeraLight(VeraDevice[veraApi.VeraDimmer], LightEntity): +class VeraLight(VeraEntity[veraApi.VeraDimmer], LightEntity): """Representation of a Vera Light, including dimmable.""" _attr_is_on = False @@ -50,7 +50,7 @@ class VeraLight(VeraDevice[veraApi.VeraDimmer], LightEntity): self, vera_device: veraApi.VeraDimmer, controller_data: ControllerData ) -> None: """Initialize the light.""" - VeraDevice.__init__(self, vera_device, controller_data) + VeraEntity.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) @property diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py index 01509aa8388..18f0b9de3e2 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -12,8 +12,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VeraDevice from .common import ControllerData, get_controller_data +from .entity import VeraEntity ATTR_LAST_USER_NAME = "changed_by_name" ATTR_LOW_BATTERY = "low_battery" @@ -35,14 +35,14 @@ async def async_setup_entry( ) -class VeraLock(VeraDevice[veraApi.VeraLock], LockEntity): +class VeraLock(VeraEntity[veraApi.VeraLock], LockEntity): """Representation of a Vera lock.""" def __init__( self, vera_device: veraApi.VeraLock, controller_data: ControllerData ) -> None: """Initialize the Vera device.""" - VeraDevice.__init__(self, vera_device, controller_data) + VeraEntity.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) def lock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index 97e6d6d6314..95f1fa0bd89 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -23,8 +23,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VeraDevice from .common import ControllerData, get_controller_data +from .entity import VeraEntity SCAN_INTERVAL = timedelta(seconds=5) @@ -45,7 +45,7 @@ async def async_setup_entry( ) -class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): +class VeraSensor(VeraEntity[veraApi.VeraSensor], SensorEntity): """Representation of a Vera Sensor.""" def __init__( @@ -54,7 +54,7 @@ class VeraSensor(VeraDevice[veraApi.VeraSensor], SensorEntity): """Initialize the sensor.""" self._temperature_units: str | None = None self.last_changed_time = None - VeraDevice.__init__(self, vera_device, controller_data) + VeraEntity.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: self._attr_device_class = SensorDeviceClass.TEMPERATURE diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py index 3e594685d6b..ad7fbe68458 100644 --- a/homeassistant/components/vera/switch.py +++ b/homeassistant/components/vera/switch.py @@ -12,8 +12,8 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VeraDevice from .common import ControllerData, get_controller_data +from .entity import VeraEntity async def async_setup_entry( @@ -32,7 +32,7 @@ async def async_setup_entry( ) -class VeraSwitch(VeraDevice[veraApi.VeraSwitch], SwitchEntity): +class VeraSwitch(VeraEntity[veraApi.VeraSwitch], SwitchEntity): """Representation of a Vera Switch.""" _attr_is_on = False @@ -41,7 +41,7 @@ class VeraSwitch(VeraDevice[veraApi.VeraSwitch], SwitchEntity): self, vera_device: veraApi.VeraSwitch, controller_data: ControllerData ) -> None: """Initialize the Vera device.""" - VeraDevice.__init__(self, vera_device, controller_data) + VeraEntity.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) def turn_on(self, **kwargs: Any) -> None: diff --git a/tests/components/vera/test_config_flow.py b/tests/components/vera/test_config_flow.py index 057945450e3..9572645f6d2 100644 --- a/tests/components/vera/test_config_flow.py +++ b/tests/components/vera/test_config_flow.py @@ -5,7 +5,11 @@ from unittest.mock import MagicMock, patch from requests.exceptions import RequestException from homeassistant import config_entries -from homeassistant.components.vera import CONF_CONTROLLER, CONF_LEGACY_UNIQUE_ID, DOMAIN +from homeassistant.components.vera.const import ( + CONF_CONTROLLER, + CONF_LEGACY_UNIQUE_ID, + DOMAIN, +) from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType From 93de46b50e2948c097c3a6c25c16bf35d4375f32 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 09:51:05 +0200 Subject: [PATCH 0764/1309] Move velux base entity to separate module (#126185) --- homeassistant/components/velux/__init__.py | 35 ++------------------- homeassistant/components/velux/cover.py | 3 +- homeassistant/components/velux/entity.py | 36 ++++++++++++++++++++++ homeassistant/components/velux/light.py | 3 +- homeassistant/components/velux/scene.py | 2 +- 5 files changed, 43 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/velux/entity.py diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 614ed810429..2f1cab67c16 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,11 +1,10 @@ """Support for VELUX KLF 200 devices.""" -from pyvlx import Node, PyVLX, PyVLXException +from pyvlx import PyVLX, PyVLXException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers.entity import Entity +from homeassistant.core import HomeAssistant, ServiceCall from .const import DOMAIN, LOGGER, PLATFORMS @@ -67,33 +66,3 @@ class VeluxModule: LOGGER.debug("Velux interface started") await self.pyvlx.load_scenes() await self.pyvlx.load_nodes() - - -class VeluxEntity(Entity): - """Abstraction for al Velux entities.""" - - _attr_should_poll = False - - def __init__(self, node: Node, config_entry_id: str) -> None: - """Initialize the Velux device.""" - self.node = node - self._attr_unique_id = ( - node.serial_number - if node.serial_number - else f"{config_entry_id}_{node.node_id}" - ) - self._attr_name = node.name if node.name else f"#{node.node_id}" - - @callback - def async_register_callbacks(self): - """Register callbacks to update hass after device was changed.""" - - async def after_update_callback(device): - """Call after device was updated.""" - self.async_write_ha_state() - - self.node.register_device_updated_cb(after_update_callback) - - async def async_added_to_hass(self): - """Store register state change callback.""" - self.async_register_callbacks() diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index cd7564eee81..2e74441c873 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -18,7 +18,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, VeluxEntity +from .const import DOMAIN +from .entity import VeluxEntity PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py new file mode 100644 index 00000000000..674ba5dde45 --- /dev/null +++ b/homeassistant/components/velux/entity.py @@ -0,0 +1,36 @@ +"""Support for VELUX KLF 200 devices.""" + +from pyvlx import Node + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity + + +class VeluxEntity(Entity): + """Abstraction for al Velux entities.""" + + _attr_should_poll = False + + def __init__(self, node: Node, config_entry_id: str) -> None: + """Initialize the Velux device.""" + self.node = node + self._attr_unique_id = ( + node.serial_number + if node.serial_number + else f"{config_entry_id}_{node.node_id}" + ) + self._attr_name = node.name if node.name else f"#{node.node_id}" + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + + async def after_update_callback(device): + """Call after device was updated.""" + self.async_write_ha_state() + + self.node.register_device_updated_cb(after_update_callback) + + async def async_added_to_hass(self): + """Store register state change callback.""" + self.async_register_callbacks() diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index e98632701f3..14f12a01060 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -11,7 +11,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, VeluxEntity +from .const import DOMAIN +from .entity import VeluxEntity PARALLEL_UPDATES = 1 diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index 30858b25002..54888413613 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN +from .const import DOMAIN PARALLEL_UPDATES = 1 From 3d9aa60e4ed0234684c988c5dc659f59750b9b42 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:55:55 +0200 Subject: [PATCH 0765/1309] Move wirelesstag shared constants to separate module (#126192) --- .../components/wirelesstag/__init__.py | 29 ++----------------- .../components/wirelesstag/binary_sensor.py | 11 +++---- homeassistant/components/wirelesstag/const.py | 11 +++++++ .../components/wirelesstag/sensor.py | 15 ++++------ .../components/wirelesstag/switch.py | 10 +++---- homeassistant/components/wirelesstag/util.py | 28 ++++++++++++++++++ 6 files changed, 54 insertions(+), 50 deletions(-) create mode 100644 homeassistant/components/wirelesstag/const.py create mode 100644 homeassistant/components/wirelesstag/util.py diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 710255153c2..2bd2fbebac9 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -6,7 +6,6 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from wirelesstagpy import WirelessTags from wirelesstagpy.exceptions import WirelessTagsException -from wirelesstagpy.sensortag import SensorTag from homeassistant.components import persistent_notification from homeassistant.const import ( @@ -19,12 +18,13 @@ from homeassistant.const import ( UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType +from .const import DOMAIN, SIGNAL_BINARY_EVENT_UPDATE, SIGNAL_TAG_UPDATE + _LOGGER = logging.getLogger(__name__) @@ -39,17 +39,8 @@ ATTR_TAG_POWER_CONSUMPTION = "power_consumption" NOTIFICATION_ID = "wirelesstag_notification" NOTIFICATION_TITLE = "Wireless Sensor Tag Setup" -DOMAIN = "wirelesstag" DEFAULT_ENTITY_NAMESPACE = "wirelesstag" -# Template for signal - first parameter is tag_id, -# second, tag manager mac address -SIGNAL_TAG_UPDATE = "wirelesstag.tag_info_updated_{}_{}" - -# Template for signal - tag_id, sensor type and -# tag manager mac address -SIGNAL_BINARY_EVENT_UPDATE = "wirelesstag.binary_event_updated_{}_{}_{}" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -129,22 +120,6 @@ class WirelessTagPlatform: self.api.start_monitoring(push_callback) -def async_migrate_unique_id( - hass: HomeAssistant, tag: SensorTag, domain: str, key: str -) -> None: - """Migrate old unique id to new one with use of tag's uuid.""" - registry = er.async_get(hass) - new_unique_id = f"{tag.uuid}_{key}" - - if registry.async_get_entity_id(domain, DOMAIN, new_unique_id): - return - - old_unique_id = f"{tag.tag_id}_{key}" - if entity_id := registry.async_get_entity_id(domain, DOMAIN, old_unique_id): - _LOGGER.debug("Updating unique id for %s %s", key, entity_id) - registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - - def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Wireless Sensor Tag component.""" conf = config[DOMAIN] diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 052f6547dd2..cd8f058cce4 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -15,12 +15,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( - DOMAIN as WIRELESSTAG_DOMAIN, - SIGNAL_BINARY_EVENT_UPDATE, - WirelessTagBaseSensor, - async_migrate_unique_id, -) +from . import WirelessTagBaseSensor +from .const import DOMAIN, SIGNAL_BINARY_EVENT_UPDATE +from .util import async_migrate_unique_id # On means in range, Off means out of range SENSOR_PRESENCE = "presence" @@ -84,7 +81,7 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the platform for a WirelessTags.""" - platform = hass.data[WIRELESSTAG_DOMAIN] + platform = hass.data[DOMAIN] sensors = [] tags = platform.tags diff --git a/homeassistant/components/wirelesstag/const.py b/homeassistant/components/wirelesstag/const.py new file mode 100644 index 00000000000..c1384606bf1 --- /dev/null +++ b/homeassistant/components/wirelesstag/const.py @@ -0,0 +1,11 @@ +"""Support for Wireless Sensor Tags.""" + +DOMAIN = "wirelesstag" + +# Template for signal - first parameter is tag_id, +# second, tag manager mac address +SIGNAL_TAG_UPDATE = "wirelesstag.tag_info_updated_{}_{}" + +# Template for signal - tag_id, sensor type and +# tag manager mac address +SIGNAL_BINARY_EVENT_UPDATE = "wirelesstag.binary_event_updated_{}_{}_{}" diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 87906bdc2ae..9f7ed3cc4b0 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -20,12 +20,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( - DOMAIN as WIRELESSTAG_DOMAIN, - SIGNAL_TAG_UPDATE, - WirelessTagBaseSensor, - async_migrate_unique_id, -) +from . import WirelessTagBaseSensor +from .const import DOMAIN, SIGNAL_TAG_UPDATE +from .util import async_migrate_unique_id _LOGGER = logging.getLogger(__name__) @@ -81,7 +78,7 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" - platform = hass.data[WIRELESSTAG_DOMAIN] + platform = hass.data[DOMAIN] sensors = [] tags = platform.tags for tag in tags.values(): @@ -113,9 +110,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): # sensor.wirelesstag_bedroom_temperature # and not as sensor.bedroom for temperature and # sensor.bedroom_2 for humidity - self.entity_id = ( - f"sensor.{WIRELESSTAG_DOMAIN}_{self.underscored_name}_{self._sensor_type}" - ) + self.entity_id = f"sensor.{DOMAIN}_{self.underscored_name}_{self._sensor_type}" async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index 239461df4ea..a5323ab3f1d 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -17,11 +17,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( - DOMAIN as WIRELESSTAG_DOMAIN, - WirelessTagBaseSensor, - async_migrate_unique_id, -) +from . import WirelessTagBaseSensor +from .const import DOMAIN +from .util import async_migrate_unique_id SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -64,7 +62,7 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up switches for a Wireless Sensor Tags.""" - platform = hass.data[WIRELESSTAG_DOMAIN] + platform = hass.data[DOMAIN] tags = platform.load_tags() monitored_conditions = config[CONF_MONITORED_CONDITIONS] diff --git a/homeassistant/components/wirelesstag/util.py b/homeassistant/components/wirelesstag/util.py new file mode 100644 index 00000000000..1b5d6551fc4 --- /dev/null +++ b/homeassistant/components/wirelesstag/util.py @@ -0,0 +1,28 @@ +"""Support for Wireless Sensor Tags.""" + +import logging + +from wirelesstagpy.sensortag import SensorTag + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def async_migrate_unique_id( + hass: HomeAssistant, tag: SensorTag, domain: str, key: str +) -> None: + """Migrate old unique id to new one with use of tag's uuid.""" + registry = er.async_get(hass) + new_unique_id = f"{tag.uuid}_{key}" + + if registry.async_get_entity_id(domain, DOMAIN, new_unique_id): + return + + old_unique_id = f"{tag.tag_id}_{key}" + if entity_id := registry.async_get_entity_id(domain, DOMAIN, old_unique_id): + _LOGGER.debug("Updating unique id for %s %s", key, entity_id) + registry.async_update_entity(entity_id, new_unique_id=new_unique_id) From 989a90bb93ed84b19ccf403f866ff57395106b3f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:56:39 +0200 Subject: [PATCH 0766/1309] Move wilight base entity to separate module (#126193) --- homeassistant/components/wilight/__init__.py | 59 +------------------ .../components/wilight/config_flow.py | 2 +- homeassistant/components/wilight/const.py | 3 + homeassistant/components/wilight/cover.py | 3 +- homeassistant/components/wilight/entity.py | 59 +++++++++++++++++++ homeassistant/components/wilight/fan.py | 3 +- homeassistant/components/wilight/light.py | 3 +- homeassistant/components/wilight/switch.py | 3 +- 8 files changed, 73 insertions(+), 62 deletions(-) create mode 100644 homeassistant/components/wilight/const.py create mode 100644 homeassistant/components/wilight/entity.py diff --git a/homeassistant/components/wilight/__init__.py b/homeassistant/components/wilight/__init__.py index 067197c8a14..5242f84ab93 100644 --- a/homeassistant/components/wilight/__init__.py +++ b/homeassistant/components/wilight/__init__.py @@ -1,20 +1,13 @@ """The WiLight integration.""" -from typing import Any - -from pywilight.wilight_device import PyWiLightDevice - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from .const import DOMAIN from .parent_device import WiLightParent -DOMAIN = "wilight" - # List the platforms that you want to support. PLATFORMS = [Platform.COVER, Platform.FAN, Platform.LIGHT, Platform.SWITCH] @@ -48,51 +41,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: del hass.data[DOMAIN][entry.entry_id] return unload_ok - - -class WiLightDevice(Entity): - """Representation of a WiLight device. - - Contains the common logic for WiLight entities. - """ - - _attr_should_poll = False - _attr_has_entity_name = True - - def __init__(self, api_device: PyWiLightDevice, index: str, item_name: str) -> None: - """Initialize the device.""" - # WiLight specific attributes for every component type - self._device_id = api_device.device_id - self._client = api_device.client - self._index = index - self._status: dict[str, Any] = {} - - self._attr_unique_id = f"{self._device_id}_{index}" - self._attr_device_info = DeviceInfo( - name=item_name, - identifiers={(DOMAIN, self._attr_unique_id)}, - model=api_device.model, - manufacturer="WiLight", - sw_version=api_device.swversion, - via_device=(DOMAIN, self._device_id), - ) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return bool(self._client.is_connected) - - @callback - def handle_event_callback(self, states: dict[str, Any]) -> None: - """Propagate changes through ha.""" - self._status = states - self.async_write_ha_state() - - async def async_update(self) -> None: - """Synchronize state with api_device.""" - await self._client.status(self._index) - - async def async_added_to_hass(self) -> None: - """Register update callback.""" - self._client.register_status_callback(self.handle_event_callback, self._index) - await self._client.status(self._index) diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py index 8795da19091..b7f9b9485ed 100644 --- a/homeassistant/components/wilight/config_flow.py +++ b/homeassistant/components/wilight/config_flow.py @@ -9,7 +9,7 @@ from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST -from . import DOMAIN +from .const import DOMAIN CONF_SERIAL_NUMBER = "serial_number" CONF_MODEL_NAME = "model_name" diff --git a/homeassistant/components/wilight/const.py b/homeassistant/components/wilight/const.py new file mode 100644 index 00000000000..29de5093b70 --- /dev/null +++ b/homeassistant/components/wilight/const.py @@ -0,0 +1,3 @@ +"""The WiLight integration.""" + +DOMAIN = "wilight" diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py index 4ae4692db40..8a5cb45d909 100644 --- a/homeassistant/components/wilight/cover.py +++ b/homeassistant/components/wilight/cover.py @@ -20,7 +20,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, WiLightDevice +from .const import DOMAIN +from .entity import WiLightDevice from .parent_device import WiLightParent diff --git a/homeassistant/components/wilight/entity.py b/homeassistant/components/wilight/entity.py new file mode 100644 index 00000000000..b8edf44b495 --- /dev/null +++ b/homeassistant/components/wilight/entity.py @@ -0,0 +1,59 @@ +"""The WiLight integration.""" + +from typing import Any + +from pywilight.wilight_device import PyWiLightDevice + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class WiLightDevice(Entity): + """Representation of a WiLight device. + + Contains the common logic for WiLight entities. + """ + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, api_device: PyWiLightDevice, index: str, item_name: str) -> None: + """Initialize the device.""" + # WiLight specific attributes for every component type + self._device_id = api_device.device_id + self._client = api_device.client + self._index = index + self._status: dict[str, Any] = {} + + self._attr_unique_id = f"{self._device_id}_{index}" + self._attr_device_info = DeviceInfo( + name=item_name, + identifiers={(DOMAIN, self._attr_unique_id)}, + model=api_device.model, + manufacturer="WiLight", + sw_version=api_device.swversion, + via_device=(DOMAIN, self._device_id), + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return bool(self._client.is_connected) + + @callback + def handle_event_callback(self, states: dict[str, Any]) -> None: + """Propagate changes through ha.""" + self._status = states + self.async_write_ha_state() + + async def async_update(self) -> None: + """Synchronize state with api_device.""" + await self._client.status(self._index) + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + self._client.register_status_callback(self.handle_event_callback, self._index) + await self._client.status(self._index) diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index 71559658c35..71f1098603b 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -25,7 +25,8 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from . import DOMAIN, WiLightDevice +from .const import DOMAIN +from .entity import WiLightDevice from .parent_device import WiLightParent ORDERED_NAMED_FAN_SPEEDS = [WL_SPEED_LOW, WL_SPEED_MEDIUM, WL_SPEED_HIGH] diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py index 1a51ecd884e..fbe2499798d 100644 --- a/homeassistant/components/wilight/light.py +++ b/homeassistant/components/wilight/light.py @@ -17,7 +17,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, WiLightDevice +from .const import DOMAIN +from .entity import WiLightDevice from .parent_device import WiLightParent diff --git a/homeassistant/components/wilight/switch.py b/homeassistant/components/wilight/switch.py index 94e39492626..f2a1ce8b0c5 100644 --- a/homeassistant/components/wilight/switch.py +++ b/homeassistant/components/wilight/switch.py @@ -14,7 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, WiLightDevice +from .const import DOMAIN +from .entity import WiLightDevice from .parent_device import WiLightParent from .support import wilight_to_hass_trigger, wilight_trigger as wl_trigger From db8c379b93d7af3d8c47b81fbafe278a0e5b63ab Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:57:03 +0200 Subject: [PATCH 0767/1309] Move wiffi base entity to separate module (#126194) --- homeassistant/components/wiffi/__init__.py | 93 +------------------ .../components/wiffi/binary_sensor.py | 2 +- homeassistant/components/wiffi/entity.py | 93 +++++++++++++++++++ homeassistant/components/wiffi/sensor.py | 2 +- 4 files changed, 98 insertions(+), 92 deletions(-) create mode 100644 homeassistant/components/wiffi/entity.py diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index c465bc0d2ca..6cf216011f2 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -7,26 +7,19 @@ import logging from wiffi import WiffiTcpServer from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PORT, CONF_TIMEOUT, Platform +from homeassistant.const import CONF_PORT, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.dt import utcnow from .const import ( CHECK_ENTITIES_SIGNAL, CREATE_ENTITY_SIGNAL, - DEFAULT_TIMEOUT, DOMAIN, UPDATE_ENTITY_SIGNAL, ) +from .entity import generate_unique_id _LOGGER = logging.getLogger(__name__) @@ -78,11 +71,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def generate_unique_id(device, metric): - """Generate a unique string for the entity.""" - return f"{device.mac_address.replace(':', '')}-{metric.name}" - - class WiffiIntegrationApi: """API object for wiffi handling. Stored in hass.data.""" @@ -135,78 +123,3 @@ class WiffiIntegrationApi: def _periodic_tick(self, now=None): """Check if any entity has timed out because it has not been updated.""" async_dispatcher_send(self._hass, CHECK_ENTITIES_SIGNAL) - - -class WiffiEntity(Entity): - """Common functionality for all wiffi entities.""" - - _attr_should_poll = False - - def __init__(self, device, metric, options): - """Initialize the base elements of a wiffi entity.""" - self._id = generate_unique_id(device, metric) - self._attr_unique_id = self._id - self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}, - identifiers={(DOMAIN, device.mac_address)}, - manufacturer="stall.biz", - model=device.moduletype, - name=f"{device.moduletype} {device.mac_address}", - sw_version=device.sw_version, - configuration_url=device.configuration_url, - ) - self._attr_name = metric.description - self._expiration_date = None - self._value = None - self._timeout = options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) - - async def async_added_to_hass(self): - """Entity has been added to hass.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"{UPDATE_ENTITY_SIGNAL}-{self._id}", - self._update_value_callback, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, CHECK_ENTITIES_SIGNAL, self._check_expiration_date - ) - ) - - def reset_expiration_date(self): - """Reset value expiration date. - - Will be called by derived classes after a value update has been received. - """ - self._expiration_date = utcnow() + timedelta(minutes=self._timeout) - - @callback - def _update_value_callback(self, device, metric): - """Update the value of the entity.""" - - @callback - def _check_expiration_date(self): - """Periodically check if entity value has been updated. - - If there are no more updates from the wiffi device, the value will be - set to unavailable. - """ - if ( - self._value is not None - and self._expiration_date is not None - and utcnow() > self._expiration_date - ): - self._value = None - self.async_write_ha_state() - - def _is_measurement_entity(self): - """Measurement entities have a value in present time.""" - return ( - not self._attr_name.endswith("_gestern") and not self._is_metered_entity() - ) - - def _is_metered_entity(self): - """Metered entities have a value that keeps increasing until reset.""" - return self._attr_name.endswith("_pro_h") or self._attr_name.endswith("_heute") diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index 80088f373b4..b7431b2555c 100644 --- a/homeassistant/components/wiffi/binary_sensor.py +++ b/homeassistant/components/wiffi/binary_sensor.py @@ -6,8 +6,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import WiffiEntity from .const import CREATE_ENTITY_SIGNAL +from .entity import WiffiEntity async def async_setup_entry( diff --git a/homeassistant/components/wiffi/entity.py b/homeassistant/components/wiffi/entity.py new file mode 100644 index 00000000000..fd774c930c8 --- /dev/null +++ b/homeassistant/components/wiffi/entity.py @@ -0,0 +1,93 @@ +"""Component for wiffi support.""" + +from datetime import timedelta + +from homeassistant.const import CONF_TIMEOUT +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.util.dt import utcnow + +from .const import CHECK_ENTITIES_SIGNAL, DEFAULT_TIMEOUT, DOMAIN, UPDATE_ENTITY_SIGNAL + + +def generate_unique_id(device, metric): + """Generate a unique string for the entity.""" + return f"{device.mac_address.replace(':', '')}-{metric.name}" + + +class WiffiEntity(Entity): + """Common functionality for all wiffi entities.""" + + _attr_should_poll = False + + def __init__(self, device, metric, options): + """Initialize the base elements of a wiffi entity.""" + self._id = generate_unique_id(device, metric) + self._attr_unique_id = self._id + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}, + identifiers={(DOMAIN, device.mac_address)}, + manufacturer="stall.biz", + model=device.moduletype, + name=f"{device.moduletype} {device.mac_address}", + sw_version=device.sw_version, + configuration_url=device.configuration_url, + ) + self._attr_name = metric.description + self._expiration_date = None + self._value = None + self._timeout = options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) + + async def async_added_to_hass(self): + """Entity has been added to hass.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{UPDATE_ENTITY_SIGNAL}-{self._id}", + self._update_value_callback, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, CHECK_ENTITIES_SIGNAL, self._check_expiration_date + ) + ) + + def reset_expiration_date(self): + """Reset value expiration date. + + Will be called by derived classes after a value update has been received. + """ + self._expiration_date = utcnow() + timedelta(minutes=self._timeout) + + @callback + def _update_value_callback(self, device, metric): + """Update the value of the entity.""" + + @callback + def _check_expiration_date(self): + """Periodically check if entity value has been updated. + + If there are no more updates from the wiffi device, the value will be + set to unavailable. + """ + if ( + self._value is not None + and self._expiration_date is not None + and utcnow() > self._expiration_date + ): + self._value = None + self.async_write_ha_state() + + def _is_measurement_entity(self): + """Measurement entities have a value in present time.""" + return ( + not self._attr_name.endswith("_gestern") and not self._is_metered_entity() + ) + + def _is_metered_entity(self): + """Metered entities have a value that keeps increasing until reset.""" + return self._attr_name.endswith("_pro_h") or self._attr_name.endswith("_heute") diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index cf8cf8719c3..699a760685a 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import WiffiEntity from .const import CREATE_ENTITY_SIGNAL +from .entity import WiffiEntity from .wiffi_strings import ( WIFFI_UOM_DEGREE, WIFFI_UOM_LUX, From 799bc50c9883bea0421affd4ed81ff9d7f6f3b21 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:57:24 +0200 Subject: [PATCH 0768/1309] Avoid constant re-export in fujitsu_fglair (#126190) Avoid re-export in fujitsu_fglair --- homeassistant/components/fujitsu_fglair/__init__.py | 3 ++- homeassistant/components/fujitsu_fglair/config_flow.py | 3 ++- homeassistant/components/fujitsu_fglair/const.py | 5 ----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py index bd891f05b8d..633f0a62e55 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -5,13 +5,14 @@ from __future__ import annotations from contextlib import suppress from ayla_iot_unofficial import new_ayla_api +from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_ID, FGLAIR_APP_SECRET from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import API_TIMEOUT, CONF_EUROPE, FGLAIR_APP_ID, FGLAIR_APP_SECRET +from .const import API_TIMEOUT, CONF_EUROPE from .coordinator import FGLairCoordinator PLATFORMS: list[Platform] = [Platform.CLIMATE] diff --git a/homeassistant/components/fujitsu_fglair/config_flow.py b/homeassistant/components/fujitsu_fglair/config_flow.py index 5021e495656..6db22db451d 100644 --- a/homeassistant/components/fujitsu_fglair/config_flow.py +++ b/homeassistant/components/fujitsu_fglair/config_flow.py @@ -5,13 +5,14 @@ import logging from typing import Any from ayla_iot_unofficial import AylaAuthError, new_ayla_api +from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_ID, FGLAIR_APP_SECRET import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers import aiohttp_client -from .const import API_TIMEOUT, CONF_EUROPE, DOMAIN, FGLAIR_APP_ID, FGLAIR_APP_SECRET +from .const import API_TIMEOUT, CONF_EUROPE, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fujitsu_fglair/const.py b/homeassistant/components/fujitsu_fglair/const.py index a9d485281a3..3c79c800041 100644 --- a/homeassistant/components/fujitsu_fglair/const.py +++ b/homeassistant/components/fujitsu_fglair/const.py @@ -2,11 +2,6 @@ from datetime import timedelta -from ayla_iot_unofficial.fujitsu_consts import ( # noqa: F401 - FGLAIR_APP_ID, - FGLAIR_APP_SECRET, -) - API_TIMEOUT = 10 API_REFRESH = timedelta(minutes=5) From e9ac6b74822e1ce370e3adb8e91e3d4bc50fd1bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:59:04 +0200 Subject: [PATCH 0769/1309] Move xiaomi_aqara base entity to separate module (#126197) --- .../components/xiaomi_aqara/__init__.py | 157 ----------------- .../components/xiaomi_aqara/binary_sensor.py | 2 +- .../components/xiaomi_aqara/cover.py | 2 +- .../components/xiaomi_aqara/entity.py | 165 ++++++++++++++++++ .../components/xiaomi_aqara/light.py | 2 +- homeassistant/components/xiaomi_aqara/lock.py | 2 +- .../components/xiaomi_aqara/sensor.py | 2 +- .../components/xiaomi_aqara/switch.py | 2 +- 8 files changed, 171 insertions(+), 163 deletions(-) create mode 100644 homeassistant/components/xiaomi_aqara/entity.py diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index ee7948a237e..b7f4aa1942e 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -1,9 +1,7 @@ """Support for Xiaomi Gateways.""" import asyncio -from datetime import timedelta import logging -from typing import Any import voluptuous as vol from xiaomi_gateway import AsyncXiaomiGatewayMulticast, XiaomiGateway @@ -11,11 +9,8 @@ from xiaomi_gateway import AsyncXiaomiGatewayMulticast, XiaomiGateway from homeassistant.components import persistent_notification from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_DEVICE_ID, - ATTR_VOLTAGE, CONF_HOST, - CONF_MAC, CONF_PORT, CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP, @@ -24,11 +19,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo, format_mac -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType -from homeassistant.util.dt import utcnow from .const import ( CONF_INTERFACE, @@ -58,8 +49,6 @@ ATTR_GW_MAC = "gw_mac" ATTR_RINGTONE_ID = "ringtone_id" ATTR_RINGTONE_VOL = "ringtone_vol" -TIME_TILL_UNAVAILABLE = timedelta(minutes=150) - SERVICE_PLAY_RINGTONE = "play_ringtone" SERVICE_STOP_RINGTONE = "stop_ringtone" SERVICE_ADD_DEVICE = "add_device" @@ -245,152 +234,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -class XiaomiDevice(Entity): - """Representation a base Xiaomi device.""" - - _attr_should_poll = False - - def __init__(self, device, device_type, xiaomi_hub, config_entry): - """Initialize the Xiaomi device.""" - self._state = None - self._is_available = True - self._sid = device["sid"] - self._model = device["model"] - self._protocol = device["proto"] - self._name = f"{device_type}_{self._sid}" - self._device_name = f"{self._model}_{self._sid}" - self._type = device_type - self._write_to_hub = xiaomi_hub.write_to_hub - self._get_from_hub = xiaomi_hub.get_from_hub - self._extra_state_attributes = {} - self._remove_unavailability_tracker = None - self._xiaomi_hub = xiaomi_hub - self.parse_data(device["data"], device["raw_data"]) - self.parse_voltage(device["data"]) - - if hasattr(self, "_data_key") and self._data_key: - self._unique_id = f"{self._data_key}{self._sid}" - else: - self._unique_id = f"{self._type}{self._sid}" - - self._gateway_id = config_entry.unique_id - if config_entry.data[CONF_MAC] == format_mac(self._sid): - # this entity belongs to the gateway itself - self._is_gateway = True - self._device_id = config_entry.unique_id - else: - # this entity is connected through zigbee - self._is_gateway = False - self._device_id = self._sid - - async def async_added_to_hass(self): - """Start unavailability tracking.""" - self._xiaomi_hub.callbacks[self._sid].append(self.push_data) - self._async_track_unavailable() - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def device_id(self): - """Return the device id of the Xiaomi Aqara device.""" - return self._device_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device info of the Xiaomi Aqara device.""" - if self._is_gateway: - device_info = DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - model=self._model, - ) - else: - device_info = DeviceInfo( - connections={(dr.CONNECTION_ZIGBEE, self._device_id)}, - identifiers={(DOMAIN, self._device_id)}, - manufacturer="Xiaomi Aqara", - model=self._model, - name=self._device_name, - sw_version=self._protocol, - via_device=(DOMAIN, self._gateway_id), - ) - - return device_info - - @property - def available(self): - """Return True if entity is available.""" - return self._is_available - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._extra_state_attributes - - @callback - def _async_set_unavailable(self, now): - """Set state to UNAVAILABLE.""" - self._remove_unavailability_tracker = None - self._is_available = False - self.async_write_ha_state() - - @callback - def _async_track_unavailable(self): - if self._remove_unavailability_tracker: - self._remove_unavailability_tracker() - self._remove_unavailability_tracker = async_track_point_in_utc_time( - self.hass, self._async_set_unavailable, utcnow() + TIME_TILL_UNAVAILABLE - ) - if not self._is_available: - self._is_available = True - return True - return False - - def push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None: - """Push from Hub running in another thread.""" - self.hass.loop.call_soon_threadsafe(self.async_push_data, data, raw_data) - - @callback - def async_push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None: - """Push from Hub handled in the event loop.""" - _LOGGER.debug("PUSH >> %s: %s", self, data) - was_unavailable = self._async_track_unavailable() - is_data = self.parse_data(data, raw_data) - is_voltage = self.parse_voltage(data) - if is_data or is_voltage or was_unavailable: - self.async_write_ha_state() - - def parse_voltage(self, data): - """Parse battery level data sent by gateway.""" - if "voltage" in data: - voltage_key = "voltage" - elif "battery_voltage" in data: - voltage_key = "battery_voltage" - else: - return False - - max_volt = 3300 - min_volt = 2800 - voltage = data[voltage_key] - self._extra_state_attributes[ATTR_VOLTAGE] = round(voltage / 1000.0, 2) - voltage = min(voltage, max_volt) - voltage = max(voltage, min_volt) - percent = ((voltage - min_volt) / (max_volt - min_volt)) * 100 - self._extra_state_attributes[ATTR_BATTERY_LEVEL] = round(percent, 1) - return True - - def parse_data(self, data, raw_data): - """Parse data sent by gateway.""" - raise NotImplementedError - - def _add_gateway_to_schema(hass, schema): """Extend a voluptuous schema with a gateway validator.""" diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index 75208b142dd..ad91dda2173 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -12,8 +12,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from . import XiaomiDevice from .const import DOMAIN, GATEWAYS_KEY +from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index 64c9f6f208a..e073ef6b683 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -7,8 +7,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import XiaomiDevice from .const import DOMAIN, GATEWAYS_KEY +from .entity import XiaomiDevice ATTR_CURTAIN_LEVEL = "curtain_level" diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py new file mode 100644 index 00000000000..2b43b7e9315 --- /dev/null +++ b/homeassistant/components/xiaomi_aqara/entity.py @@ -0,0 +1,165 @@ +"""Support for Xiaomi Gateways.""" + +from datetime import timedelta +import logging +from typing import Any + +from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, CONF_MAC +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +TIME_TILL_UNAVAILABLE = timedelta(minutes=150) + + +class XiaomiDevice(Entity): + """Representation a base Xiaomi device.""" + + _attr_should_poll = False + + def __init__(self, device, device_type, xiaomi_hub, config_entry): + """Initialize the Xiaomi device.""" + self._state = None + self._is_available = True + self._sid = device["sid"] + self._model = device["model"] + self._protocol = device["proto"] + self._name = f"{device_type}_{self._sid}" + self._device_name = f"{self._model}_{self._sid}" + self._type = device_type + self._write_to_hub = xiaomi_hub.write_to_hub + self._get_from_hub = xiaomi_hub.get_from_hub + self._extra_state_attributes = {} + self._remove_unavailability_tracker = None + self._xiaomi_hub = xiaomi_hub + self.parse_data(device["data"], device["raw_data"]) + self.parse_voltage(device["data"]) + + if hasattr(self, "_data_key") and self._data_key: + self._unique_id = f"{self._data_key}{self._sid}" + else: + self._unique_id = f"{self._type}{self._sid}" + + self._gateway_id = config_entry.unique_id + if config_entry.data[CONF_MAC] == format_mac(self._sid): + # this entity belongs to the gateway itself + self._is_gateway = True + self._device_id = config_entry.unique_id + else: + # this entity is connected through zigbee + self._is_gateway = False + self._device_id = self._sid + + async def async_added_to_hass(self): + """Start unavailability tracking.""" + self._xiaomi_hub.callbacks[self._sid].append(self.push_data) + self._async_track_unavailable() + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def device_id(self): + """Return the device id of the Xiaomi Aqara device.""" + return self._device_id + + @property + def device_info(self) -> DeviceInfo: + """Return the device info of the Xiaomi Aqara device.""" + if self._is_gateway: + device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + model=self._model, + ) + else: + device_info = DeviceInfo( + connections={(dr.CONNECTION_ZIGBEE, self._device_id)}, + identifiers={(DOMAIN, self._device_id)}, + manufacturer="Xiaomi Aqara", + model=self._model, + name=self._device_name, + sw_version=self._protocol, + via_device=(DOMAIN, self._gateway_id), + ) + + return device_info + + @property + def available(self): + """Return True if entity is available.""" + return self._is_available + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return self._extra_state_attributes + + @callback + def _async_set_unavailable(self, now): + """Set state to UNAVAILABLE.""" + self._remove_unavailability_tracker = None + self._is_available = False + self.async_write_ha_state() + + @callback + def _async_track_unavailable(self): + if self._remove_unavailability_tracker: + self._remove_unavailability_tracker() + self._remove_unavailability_tracker = async_track_point_in_utc_time( + self.hass, self._async_set_unavailable, utcnow() + TIME_TILL_UNAVAILABLE + ) + if not self._is_available: + self._is_available = True + return True + return False + + def push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None: + """Push from Hub running in another thread.""" + self.hass.loop.call_soon_threadsafe(self.async_push_data, data, raw_data) + + @callback + def async_push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None: + """Push from Hub handled in the event loop.""" + _LOGGER.debug("PUSH >> %s: %s", self, data) + was_unavailable = self._async_track_unavailable() + is_data = self.parse_data(data, raw_data) + is_voltage = self.parse_voltage(data) + if is_data or is_voltage or was_unavailable: + self.async_write_ha_state() + + def parse_voltage(self, data): + """Parse battery level data sent by gateway.""" + if "voltage" in data: + voltage_key = "voltage" + elif "battery_voltage" in data: + voltage_key = "battery_voltage" + else: + return False + + max_volt = 3300 + min_volt = 2800 + voltage = data[voltage_key] + self._extra_state_attributes[ATTR_VOLTAGE] = round(voltage / 1000.0, 2) + voltage = min(voltage, max_volt) + voltage = max(voltage, min_volt) + percent = ((voltage - min_volt) / (max_volt - min_volt)) * 100 + self._extra_state_attributes[ATTR_BATTERY_LEVEL] = round(percent, 1) + return True + + def parse_data(self, data, raw_data): + """Parse data sent by gateway.""" + raise NotImplementedError diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index fc19a22eb5f..c8057f1df4a 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -16,8 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util -from . import XiaomiDevice from .const import DOMAIN, GATEWAYS_KEY +from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index 8499864576a..f64f6ae527a 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from . import XiaomiDevice from .const import DOMAIN, GATEWAYS_KEY +from .entity import XiaomiDevice FINGER_KEY = "fing_verified" PASSWORD_KEY = "psw_verified" diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index 4b354a6e730..49358276a48 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -22,8 +22,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import XiaomiDevice from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY, POWER_MODELS +from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index b6bd2ca1e6a..f66cf8c7603 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -8,8 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import XiaomiDevice from .const import DOMAIN, GATEWAYS_KEY +from .entity import XiaomiDevice _LOGGER = logging.getLogger(__name__) From 18935457054c6f8a24edf1e20049f8e4ecb0ab52 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:59:27 +0200 Subject: [PATCH 0770/1309] Move xiaomi_miio base entity to separate module (#126198) --- .../components/xiaomi_miio/air_quality.py | 2 +- .../components/xiaomi_miio/binary_sensor.py | 2 +- .../components/xiaomi_miio/button.py | 2 +- .../components/xiaomi_miio/device.py | 143 +------------ .../components/xiaomi_miio/entity.py | 193 ++++++++++++++++++ homeassistant/components/xiaomi_miio/fan.py | 2 +- .../components/xiaomi_miio/gateway.py | 49 ----- .../components/xiaomi_miio/humidifier.py | 2 +- homeassistant/components/xiaomi_miio/light.py | 3 +- .../components/xiaomi_miio/number.py | 2 +- .../components/xiaomi_miio/select.py | 2 +- .../components/xiaomi_miio/sensor.py | 3 +- .../components/xiaomi_miio/switch.py | 3 +- .../components/xiaomi_miio/vacuum.py | 2 +- 14 files changed, 205 insertions(+), 205 deletions(-) create mode 100644 homeassistant/components/xiaomi_miio/entity.py diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 80dd751a98c..199d9161353 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -18,7 +18,7 @@ from .const import ( MODEL_AIRQUALITYMONITOR_S1, MODEL_AIRQUALITYMONITOR_V1, ) -from .device import XiaomiMiioEntity +from .entity import XiaomiMiioEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 5d4b2042429..a5ab7e56e6b 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -32,7 +32,7 @@ from .const import ( MODELS_VACUUM_WITH_MOP, MODELS_VACUUM_WITH_SEPARATE_MOP, ) -from .device import XiaomiCoordinatedMiioEntity +from .entity import XiaomiCoordinatedMiioEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index 7496f765fe3..9a64941f398 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -24,7 +24,7 @@ from .const import ( MODEL_AIRFRESH_T2017, MODELS_VACUUM, ) -from .device import XiaomiCoordinatedMiioEntity +from .entity import XiaomiCoordinatedMiioEntity # Fans ATTR_RESET_DUST_FILTER = "reset_dust_filter" diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index e90a86ab7e9..beeb7e95e54 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -1,24 +1,11 @@ """Code to handle a Xiaomi Device.""" -import datetime -from enum import Enum -from functools import partial import logging -from typing import Any from construct.core import ChecksumError from miio import Device, DeviceException -from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC, CONF_MODEL -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) - -from .const import DOMAIN, AuthException, SetupException +from .const import AuthException, SetupException _LOGGER = logging.getLogger(__name__) @@ -66,131 +53,3 @@ class ConnectXiaomiDevice: self._device_info.firmware_version, self._device_info.hardware_version, ) - - -class XiaomiMiioEntity(Entity): - """Representation of a base Xiaomi Miio Entity.""" - - def __init__(self, name, device, entry, unique_id): - """Initialize the Xiaomi Miio Device.""" - self._device = device - self._model = entry.data[CONF_MODEL] - self._mac = entry.data[CONF_MAC] - self._device_id = entry.unique_id - self._unique_id = unique_id - self._name = name - self._available = None - - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of this entity, if any.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - device_info = DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer="Xiaomi", - model=self._model, - name=self._name, - ) - - if self._mac is not None: - device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} - - return device_info - - -class XiaomiCoordinatedMiioEntity[_T: DataUpdateCoordinator[Any]]( - CoordinatorEntity[_T] -): - """Representation of a base a coordinated Xiaomi Miio Entity.""" - - _attr_has_entity_name = True - - def __init__(self, device, entry, unique_id, coordinator): - """Initialize the coordinated Xiaomi Miio Device.""" - super().__init__(coordinator) - self._device = device - self._model = entry.data[CONF_MODEL] - self._mac = entry.data[CONF_MAC] - self._device_id = entry.unique_id - self._device_name = entry.title - self._unique_id = unique_id - - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - device_info = DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer="Xiaomi", - model=self._model, - name=self._device_name, - ) - - if self._mac is not None: - device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} - - return device_info - - async def _try_command(self, mask_error, func, *args, **kwargs): - """Call a miio device command handling error messages.""" - try: - result = await self.hass.async_add_executor_job( - partial(func, *args, **kwargs) - ) - except DeviceException as exc: - if self.available: - _LOGGER.error(mask_error, exc) - - return False - - _LOGGER.debug("Response received from miio device: %s", result) - return True - - @classmethod - def _extract_value_from_attribute(cls, state, attribute): - value = getattr(state, attribute) - if isinstance(value, Enum): - return value.value - if isinstance(value, datetime.timedelta): - return cls._parse_time_delta(value) - if isinstance(value, datetime.time): - return cls._parse_datetime_time(value) - if isinstance(value, datetime.datetime): - return cls._parse_datetime_datetime(value) - - if value is None: - _LOGGER.debug("Attribute %s is None, this is unexpected", attribute) - - return value - - @staticmethod - def _parse_time_delta(timedelta: datetime.timedelta) -> int: - return int(timedelta.total_seconds()) - - @staticmethod - def _parse_datetime_time(initial_time: datetime.time) -> str: - time = datetime.datetime.now().replace( - hour=initial_time.hour, minute=initial_time.minute, second=0, microsecond=0 - ) - - if time < datetime.datetime.now(): - time += datetime.timedelta(days=1) - - return time.isoformat() - - @staticmethod - def _parse_datetime_datetime(time: datetime.datetime) -> str: - return time.isoformat() diff --git a/homeassistant/components/xiaomi_miio/entity.py b/homeassistant/components/xiaomi_miio/entity.py new file mode 100644 index 00000000000..0343a7526d7 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/entity.py @@ -0,0 +1,193 @@ +"""Code to handle a Xiaomi Device.""" + +import datetime +from enum import Enum +from functools import partial +import logging +from typing import Any + +from miio import DeviceException + +from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC, CONF_MODEL +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ATTR_AVAILABLE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class XiaomiMiioEntity(Entity): + """Representation of a base Xiaomi Miio Entity.""" + + def __init__(self, name, device, entry, unique_id): + """Initialize the Xiaomi Miio Device.""" + self._device = device + self._model = entry.data[CONF_MODEL] + self._mac = entry.data[CONF_MAC] + self._device_id = entry.unique_id + self._unique_id = unique_id + self._name = name + self._available = None + + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of this entity, if any.""" + return self._name + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer="Xiaomi", + model=self._model, + name=self._name, + ) + + if self._mac is not None: + device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} + + return device_info + + +class XiaomiCoordinatedMiioEntity[_T: DataUpdateCoordinator[Any]]( + CoordinatorEntity[_T] +): + """Representation of a base a coordinated Xiaomi Miio Entity.""" + + _attr_has_entity_name = True + + def __init__(self, device, entry, unique_id, coordinator): + """Initialize the coordinated Xiaomi Miio Device.""" + super().__init__(coordinator) + self._device = device + self._model = entry.data[CONF_MODEL] + self._mac = entry.data[CONF_MAC] + self._device_id = entry.unique_id + self._device_name = entry.title + self._unique_id = unique_id + + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer="Xiaomi", + model=self._model, + name=self._device_name, + ) + + if self._mac is not None: + device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} + + return device_info + + async def _try_command(self, mask_error, func, *args, **kwargs): + """Call a miio device command handling error messages.""" + try: + result = await self.hass.async_add_executor_job( + partial(func, *args, **kwargs) + ) + except DeviceException as exc: + if self.available: + _LOGGER.error(mask_error, exc) + + return False + + _LOGGER.debug("Response received from miio device: %s", result) + return True + + @classmethod + def _extract_value_from_attribute(cls, state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + if isinstance(value, datetime.timedelta): + return cls._parse_time_delta(value) + if isinstance(value, datetime.time): + return cls._parse_datetime_time(value) + if isinstance(value, datetime.datetime): + return cls._parse_datetime_datetime(value) + + if value is None: + _LOGGER.debug("Attribute %s is None, this is unexpected", attribute) + + return value + + @staticmethod + def _parse_time_delta(timedelta: datetime.timedelta) -> int: + return int(timedelta.total_seconds()) + + @staticmethod + def _parse_datetime_time(initial_time: datetime.time) -> str: + time = datetime.datetime.now().replace( + hour=initial_time.hour, minute=initial_time.minute, second=0, microsecond=0 + ) + + if time < datetime.datetime.now(): + time += datetime.timedelta(days=1) + + return time.isoformat() + + @staticmethod + def _parse_datetime_datetime(time: datetime.datetime) -> str: + return time.isoformat() + + +class XiaomiGatewayDevice(CoordinatorEntity, Entity): + """Representation of a base Xiaomi Gateway Device.""" + + def __init__(self, coordinator, sub_device, entry): + """Initialize the Xiaomi Gateway Device.""" + super().__init__(coordinator) + self._sub_device = sub_device + self._entry = entry + self._unique_id = sub_device.sid + self._name = f"{sub_device.name} ({sub_device.sid})" + + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of this entity, if any.""" + return self._name + + @property + def device_info(self) -> DeviceInfo: + """Return the device info of the gateway.""" + return DeviceInfo( + identifiers={(DOMAIN, self._sub_device.sid)}, + via_device=(DOMAIN, self._entry.unique_id), + manufacturer="Xiaomi", + name=self._sub_device.name, + model=self._sub_device.model, + sw_version=self._sub_device.firmware_version, + hw_version=self._sub_device.zigbee_model, + ) + + @property + def available(self): + """Return if entity is available.""" + if self.coordinator.data is None: + return False + + return self.coordinator.data[ATTR_AVAILABLE] diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index f075ff8816f..88752c35698 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -91,7 +91,7 @@ from .const import ( SERVICE_RESET_FILTER, SERVICE_SET_EXTRA_FEATURES, ) -from .device import XiaomiCoordinatedMiioEntity +from .entity import XiaomiCoordinatedMiioEntity from .typing import ServiceMethodDetails _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/gateway.py b/homeassistant/components/xiaomi_miio/gateway.py index 39e8ce503a4..ffd6279f639 100644 --- a/homeassistant/components/xiaomi_miio/gateway.py +++ b/homeassistant/components/xiaomi_miio/gateway.py @@ -8,17 +8,11 @@ from micloud.micloudexception import MiCloudAccessDenied from miio import DeviceException, gateway from miio.gateway.gateway import GATEWAY_MODEL_EU -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import CoordinatorEntity - from .const import ( - ATTR_AVAILABLE, CONF_CLOUD_COUNTRY, CONF_CLOUD_PASSWORD, CONF_CLOUD_SUBDEVICES, CONF_CLOUD_USERNAME, - DOMAIN, AuthException, SetupException, ) @@ -134,46 +128,3 @@ class ConnectXiaomiGateway: "DeviceException during setup of xiaomi gateway with host" f" {self._host}" ) from error - - -class XiaomiGatewayDevice(CoordinatorEntity, Entity): - """Representation of a base Xiaomi Gateway Device.""" - - def __init__(self, coordinator, sub_device, entry): - """Initialize the Xiaomi Gateway Device.""" - super().__init__(coordinator) - self._sub_device = sub_device - self._entry = entry - self._unique_id = sub_device.sid - self._name = f"{sub_device.name} ({sub_device.sid})" - - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of this entity, if any.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device info of the gateway.""" - return DeviceInfo( - identifiers={(DOMAIN, self._sub_device.sid)}, - via_device=(DOMAIN, self._entry.unique_id), - manufacturer="Xiaomi", - name=self._sub_device.name, - model=self._sub_device.model, - sw_version=self._sub_device.firmware_version, - hw_version=self._sub_device.zigbee_model, - ) - - @property - def available(self): - """Return if entity is available.""" - if self.coordinator.data is None: - return False - - return self.coordinator.data[ATTR_AVAILABLE] diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 8367b063102..4701345756a 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -37,7 +37,7 @@ from .const import ( MODELS_HUMIDIFIER_MIOT, MODELS_HUMIDIFIER_MJJSQ, ) -from .device import XiaomiCoordinatedMiioEntity +from .entity import XiaomiCoordinatedMiioEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 35537e82b2e..8ccc798a2e1 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -66,8 +66,7 @@ from .const import ( SERVICE_SET_DELAYED_TURN_OFF, SERVICE_SET_SCENE, ) -from .device import XiaomiMiioEntity -from .gateway import XiaomiGatewayDevice +from .entity import XiaomiGatewayDevice, XiaomiMiioEntity from .typing import ServiceMethodDetails _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 107debb7a60..e284027d4c1 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -96,7 +96,7 @@ from .const import ( MODELS_PURIFIER_MIIO, MODELS_PURIFIER_MIOT, ) -from .device import XiaomiCoordinatedMiioEntity +from .entity import XiaomiCoordinatedMiioEntity ATTR_DELAY_OFF_COUNTDOWN = "delay_off_countdown" ATTR_FAN_LEVEL = "fan_level" diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index a8e936aaf8f..55c9105b177 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -64,7 +64,7 @@ from .const import ( MODEL_FAN_ZA3, MODEL_FAN_ZA4, ) -from .device import XiaomiCoordinatedMiioEntity +from .entity import XiaomiCoordinatedMiioEntity ATTR_DISPLAY_ORIENTATION = "display_orientation" ATTR_LED_BRIGHTNESS = "led_brightness" diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 9b23e89903f..d34972b3793 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -89,8 +89,7 @@ from .const import ( ROBOROCK_GENERIC, ROCKROBO_GENERIC, ) -from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity -from .gateway import XiaomiGatewayDevice +from .entity import XiaomiCoordinatedMiioEntity, XiaomiGatewayDevice, XiaomiMiioEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 42eb6cc0838..57a1a155c38 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -113,8 +113,7 @@ from .const import ( SERVICE_SET_WIFI_LED_ON, SUCCESS, ) -from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity -from .gateway import XiaomiGatewayDevice +from .entity import XiaomiCoordinatedMiioEntity, XiaomiGatewayDevice, XiaomiMiioEntity from .typing import ServiceMethodDetails _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index ac833f7646c..b720cc90d2c 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -41,7 +41,7 @@ from .const import ( SERVICE_START_REMOTE_CONTROL, SERVICE_STOP_REMOTE_CONTROL, ) -from .device import XiaomiCoordinatedMiioEntity +from .entity import XiaomiCoordinatedMiioEntity _LOGGER = logging.getLogger(__name__) From 8827b5510f146b7d11b902f144be22d5aa953617 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:59:53 +0200 Subject: [PATCH 0771/1309] Move zwave_me base entity to separate module (#126200) --- homeassistant/components/zwave_me/__init__.py | 72 +----------------- .../components/zwave_me/binary_sensor.py | 3 +- homeassistant/components/zwave_me/button.py | 2 +- homeassistant/components/zwave_me/climate.py | 2 +- homeassistant/components/zwave_me/cover.py | 2 +- homeassistant/components/zwave_me/entity.py | 73 +++++++++++++++++++ homeassistant/components/zwave_me/fan.py | 2 +- homeassistant/components/zwave_me/light.py | 3 +- homeassistant/components/zwave_me/lock.py | 2 +- homeassistant/components/zwave_me/number.py | 2 +- homeassistant/components/zwave_me/sensor.py | 3 +- homeassistant/components/zwave_me/siren.py | 2 +- homeassistant/components/zwave_me/switch.py | 2 +- 13 files changed, 89 insertions(+), 81 deletions(-) create mode 100644 homeassistant/components/zwave_me/entity.py diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 7e00924c221..36ee62eec53 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -1,21 +1,16 @@ """The Z-Wave-Me WS integration.""" -import logging - from zwave_me_ws import ZWaveMe, ZWaveMeData from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_URL -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import dispatcher_send from .const import DOMAIN, PLATFORMS, ZWaveMePlatform -_LOGGER = logging.getLogger(__name__) ZWAVE_ME_PLATFORMS = [platform.value for platform in ZWaveMePlatform] @@ -111,66 +106,3 @@ async def async_setup_platforms( controller.platforms_inited = True await hass.async_add_executor_job(controller.zwave_api.get_devices) - - -class ZWaveMeEntity(Entity): - """Representation of a ZWaveMe device.""" - - def __init__(self, controller, device): - """Initialize the device.""" - self.controller = controller - self.device = device - self._attr_name = device.title - self._attr_unique_id: str = ( - f"{self.controller.config.unique_id}-{self.device.id}" - ) - self._attr_should_poll = False - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device.deviceIdentifier)}, - name=self._attr_name, - manufacturer=self.device.manufacturer, - sw_version=self.device.firmware, - suggested_area=self.device.locationName, - ) - - async def async_added_to_hass(self) -> None: - """Connect to an updater.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, f"ZWAVE_ME_INFO_{self.device.id}", self.get_new_data - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - f"ZWAVE_ME_UNAVAILABLE_{self.device.id}", - self.set_unavailable_status, - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, f"ZWAVE_ME_DESTROY_{self.device.id}", self.delete_entity - ) - ) - - @callback - def get_new_data(self, new_data: ZWaveMeData) -> None: - """Update info in the HAss.""" - self.device = new_data - self._attr_available = not new_data.isFailed - self.async_write_ha_state() - - @callback - def set_unavailable_status(self): - """Update status in the HAss.""" - self._attr_available = False - self.async_write_ha_state() - - @callback - def delete_entity(self) -> None: - """Remove this entity.""" - self.hass.async_create_task(self.async_remove(force_remove=True)) diff --git a/homeassistant/components/zwave_me/binary_sensor.py b/homeassistant/components/zwave_me/binary_sensor.py index 3be8f912b6d..d121c17770b 100644 --- a/homeassistant/components/zwave_me/binary_sensor.py +++ b/homeassistant/components/zwave_me/binary_sensor.py @@ -14,8 +14,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeController, ZWaveMeEntity +from . import ZWaveMeController from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity BINARY_SENSORS_MAP: dict[str, BinarySensorEntityDescription] = { "generic": BinarySensorEntityDescription( diff --git a/homeassistant/components/zwave_me/button.py b/homeassistant/components/zwave_me/button.py index f7f1d5d7945..50ddf01aeab 100644 --- a/homeassistant/components/zwave_me/button.py +++ b/homeassistant/components/zwave_me/button.py @@ -6,8 +6,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeEntity from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.BUTTON diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py index 02112e51617..de6f606745f 100644 --- a/homeassistant/components/zwave_me/climate.py +++ b/homeassistant/components/zwave_me/climate.py @@ -17,8 +17,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeEntity from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity TEMPERATURE_DEFAULT_STEP = 0.5 diff --git a/homeassistant/components/zwave_me/cover.py b/homeassistant/components/zwave_me/cover.py index c2eec09496d..c9359402c01 100644 --- a/homeassistant/components/zwave_me/cover.py +++ b/homeassistant/components/zwave_me/cover.py @@ -14,8 +14,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeEntity from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.COVER diff --git a/homeassistant/components/zwave_me/entity.py b/homeassistant/components/zwave_me/entity.py new file mode 100644 index 00000000000..a02c893d54a --- /dev/null +++ b/homeassistant/components/zwave_me/entity.py @@ -0,0 +1,73 @@ +"""The Z-Wave-Me WS integration.""" + +from zwave_me_ws import ZWaveMeData + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class ZWaveMeEntity(Entity): + """Representation of a ZWaveMe device.""" + + def __init__(self, controller, device): + """Initialize the device.""" + self.controller = controller + self.device = device + self._attr_name = device.title + self._attr_unique_id: str = ( + f"{self.controller.config.unique_id}-{self.device.id}" + ) + self._attr_should_poll = False + + @property + def device_info(self) -> DeviceInfo: + """Return device specific attributes.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.deviceIdentifier)}, + name=self._attr_name, + manufacturer=self.device.manufacturer, + sw_version=self.device.firmware, + suggested_area=self.device.locationName, + ) + + async def async_added_to_hass(self) -> None: + """Connect to an updater.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"ZWAVE_ME_INFO_{self.device.id}", self.get_new_data + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"ZWAVE_ME_UNAVAILABLE_{self.device.id}", + self.set_unavailable_status, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, f"ZWAVE_ME_DESTROY_{self.device.id}", self.delete_entity + ) + ) + + @callback + def get_new_data(self, new_data: ZWaveMeData) -> None: + """Update info in the HAss.""" + self.device = new_data + self._attr_available = not new_data.isFailed + self.async_write_ha_state() + + @callback + def set_unavailable_status(self): + """Update status in the HAss.""" + self._attr_available = False + self.async_write_ha_state() + + @callback + def delete_entity(self) -> None: + """Remove this entity.""" + self.hass.async_create_task(self.async_remove(force_remove=True)) diff --git a/homeassistant/components/zwave_me/fan.py b/homeassistant/components/zwave_me/fan.py index b8a4b5e4ad2..1016586ab55 100644 --- a/homeassistant/components/zwave_me/fan.py +++ b/homeassistant/components/zwave_me/fan.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeEntity from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.FAN diff --git a/homeassistant/components/zwave_me/light.py b/homeassistant/components/zwave_me/light.py index 2289fe7b115..f111c04e928 100644 --- a/homeassistant/components/zwave_me/light.py +++ b/homeassistant/components/zwave_me/light.py @@ -17,8 +17,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeController, ZWaveMeEntity +from . import ZWaveMeController from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity async def async_setup_entry( diff --git a/homeassistant/components/zwave_me/lock.py b/homeassistant/components/zwave_me/lock.py index 6218dac1627..0bcc8f092ae 100644 --- a/homeassistant/components/zwave_me/lock.py +++ b/homeassistant/components/zwave_me/lock.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeEntity from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.LOCK diff --git a/homeassistant/components/zwave_me/number.py b/homeassistant/components/zwave_me/number.py index 272e833d678..9a98a4f8d00 100644 --- a/homeassistant/components/zwave_me/number.py +++ b/homeassistant/components/zwave_me/number.py @@ -6,8 +6,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeEntity from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.NUMBER diff --git a/homeassistant/components/zwave_me/sensor.py b/homeassistant/components/zwave_me/sensor.py index 20470e6e62b..be0b0bae284 100644 --- a/homeassistant/components/zwave_me/sensor.py +++ b/homeassistant/components/zwave_me/sensor.py @@ -28,8 +28,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeController, ZWaveMeEntity +from . import ZWaveMeController from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity @dataclass(frozen=True) diff --git a/homeassistant/components/zwave_me/siren.py b/homeassistant/components/zwave_me/siren.py index a1bf8081616..443b2cc7b37 100644 --- a/homeassistant/components/zwave_me/siren.py +++ b/homeassistant/components/zwave_me/siren.py @@ -8,8 +8,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeEntity from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity DEVICE_NAME = ZWaveMePlatform.SIREN diff --git a/homeassistant/components/zwave_me/switch.py b/homeassistant/components/zwave_me/switch.py index 4c11f079b12..05cf06484e9 100644 --- a/homeassistant/components/zwave_me/switch.py +++ b/homeassistant/components/zwave_me/switch.py @@ -13,8 +13,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ZWaveMeEntity from .const import DOMAIN, ZWaveMePlatform +from .entity import ZWaveMeEntity _LOGGER = logging.getLogger(__name__) DEVICE_NAME = ZWaveMePlatform.SWITCH From dd77c6b59f6325f3dd84aa9e1d5ce1f7ff71e589 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:00:06 +0200 Subject: [PATCH 0772/1309] Move xs1 base entity to separate module (#126199) --- homeassistant/components/xs1/__init__.py | 20 -------------------- homeassistant/components/xs1/climate.py | 3 ++- homeassistant/components/xs1/entity.py | 23 +++++++++++++++++++++++ homeassistant/components/xs1/sensor.py | 3 ++- homeassistant/components/xs1/switch.py | 3 ++- 5 files changed, 29 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/xs1/entity.py diff --git a/homeassistant/components/xs1/__init__.py b/homeassistant/components/xs1/__init__.py index e24fbc0181e..6f7197817d7 100644 --- a/homeassistant/components/xs1/__init__.py +++ b/homeassistant/components/xs1/__init__.py @@ -1,6 +1,5 @@ """Support for the EZcontrol XS1 gateway.""" -import asyncio import logging import voluptuous as vol @@ -17,7 +16,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -44,11 +42,6 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH] -# Lock used to limit the amount of concurrent update requests -# as the XS1 Gateway can only handle a very -# small amount of concurrent requests -UPDATE_LOCK = asyncio.Lock() - def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up XS1 integration.""" @@ -88,16 +81,3 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: discovery.load_platform(hass, platform, DOMAIN, {}, config) return True - - -class XS1DeviceEntity(Entity): - """Representation of a base XS1 device.""" - - def __init__(self, device): - """Initialize the XS1 device.""" - self.device = device - - async def async_update(self): - """Retrieve latest device state.""" - async with UPDATE_LOCK: - await self.hass.async_add_executor_job(self.device.update) diff --git a/homeassistant/components/xs1/climate.py b/homeassistant/components/xs1/climate.py index e594f32adff..c7d580631d3 100644 --- a/homeassistant/components/xs1/climate.py +++ b/homeassistant/components/xs1/climate.py @@ -16,7 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS, XS1DeviceEntity +from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS +from .entity import XS1DeviceEntity MIN_TEMP = 8 MAX_TEMP = 25 diff --git a/homeassistant/components/xs1/entity.py b/homeassistant/components/xs1/entity.py new file mode 100644 index 00000000000..7239a6fd446 --- /dev/null +++ b/homeassistant/components/xs1/entity.py @@ -0,0 +1,23 @@ +"""Support for the EZcontrol XS1 gateway.""" + +import asyncio + +from homeassistant.helpers.entity import Entity + +# Lock used to limit the amount of concurrent update requests +# as the XS1 Gateway can only handle a very +# small amount of concurrent requests +UPDATE_LOCK = asyncio.Lock() + + +class XS1DeviceEntity(Entity): + """Representation of a base XS1 device.""" + + def __init__(self, device): + """Initialize the XS1 device.""" + self.device = device + + async def async_update(self): + """Retrieve latest device state.""" + async with UPDATE_LOCK: + await self.hass.async_add_executor_job(self.device.update) diff --git a/homeassistant/components/xs1/sensor.py b/homeassistant/components/xs1/sensor.py index e98fd33743b..b3895d67d82 100644 --- a/homeassistant/components/xs1/sensor.py +++ b/homeassistant/components/xs1/sensor.py @@ -9,7 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS, XS1DeviceEntity +from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS +from .entity import XS1DeviceEntity def setup_platform( diff --git a/homeassistant/components/xs1/switch.py b/homeassistant/components/xs1/switch.py index c2af652d6ad..a8f66390a6d 100644 --- a/homeassistant/components/xs1/switch.py +++ b/homeassistant/components/xs1/switch.py @@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, XS1DeviceEntity +from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN +from .entity import XS1DeviceEntity def setup_platform( From 06e7e377d441e5fb58ea472afb605f4415ec166c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:00:51 +0200 Subject: [PATCH 0773/1309] Rename tasmota base entity module (#126182) --- homeassistant/components/tasmota/binary_sensor.py | 2 +- homeassistant/components/tasmota/cover.py | 2 +- homeassistant/components/tasmota/{mixins.py => entity.py} | 0 homeassistant/components/tasmota/fan.py | 2 +- homeassistant/components/tasmota/light.py | 2 +- homeassistant/components/tasmota/sensor.py | 2 +- homeassistant/components/tasmota/switch.py | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename homeassistant/components/tasmota/{mixins.py => entity.py} (100%) diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index 071cce81880..8a4b501af05 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -20,7 +20,7 @@ import homeassistant.helpers.event as evt from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW -from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate +from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate async def async_setup_entry( diff --git a/homeassistant/components/tasmota/cover.py b/homeassistant/components/tasmota/cover.py index 4ab9464e9f9..2cb3cfeea25 100644 --- a/homeassistant/components/tasmota/cover.py +++ b/homeassistant/components/tasmota/cover.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW -from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate +from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate async def async_setup_entry( diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/entity.py similarity index 100% rename from homeassistant/components/tasmota/mixins.py rename to homeassistant/components/tasmota/entity.py diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py index 340edff3b35..15664201d99 100644 --- a/homeassistant/components/tasmota/fan.py +++ b/homeassistant/components/tasmota/fan.py @@ -24,7 +24,7 @@ from homeassistant.util.percentage import ( from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW -from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate +from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate ORDERED_NAMED_FAN_SPEEDS = [ tasmota_const.FAN_SPEED_LOW, diff --git a/homeassistant/components/tasmota/light.py b/homeassistant/components/tasmota/light.py index 5effc9c4997..9b69ee60524 100644 --- a/homeassistant/components/tasmota/light.py +++ b/homeassistant/components/tasmota/light.py @@ -35,7 +35,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW -from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate, TasmotaOnOffEntity +from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate, TasmotaOnOffEntity DEFAULT_BRIGHTNESS_MAX = 255 TASMOTA_BRIGHTNESS_MAX = 100 diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 30649fa38bd..8cc538e706a 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -44,7 +44,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW -from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate +from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate DEVICE_CLASS = "device_class" STATE_CLASS = "state_class" diff --git a/homeassistant/components/tasmota/switch.py b/homeassistant/components/tasmota/switch.py index 44c45621e09..b5c19fc2431 100644 --- a/homeassistant/components/tasmota/switch.py +++ b/homeassistant/components/tasmota/switch.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW -from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate, TasmotaOnOffEntity +from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate, TasmotaOnOffEntity async def async_setup_entry( From 16ac303994234580fb63582a3fe91e1de0d2a86c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:03:32 +0200 Subject: [PATCH 0774/1309] Move tcp base entity to separate module (#126181) --- homeassistant/components/tcp/binary_sensor.py | 3 +- homeassistant/components/tcp/common.py | 112 --------------- homeassistant/components/tcp/entity.py | 130 ++++++++++++++++++ homeassistant/components/tcp/sensor.py | 3 +- tests/components/tcp/test_binary_sensor.py | 4 +- tests/components/tcp/test_sensor.py | 6 +- 6 files changed, 139 insertions(+), 119 deletions(-) create mode 100644 homeassistant/components/tcp/entity.py diff --git a/homeassistant/components/tcp/binary_sensor.py b/homeassistant/components/tcp/binary_sensor.py index 638dfd53de5..13fd0787b5d 100644 --- a/homeassistant/components/tcp/binary_sensor.py +++ b/homeassistant/components/tcp/binary_sensor.py @@ -12,8 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .common import TCP_PLATFORM_SCHEMA, TcpEntity +from .common import TCP_PLATFORM_SCHEMA from .const import CONF_VALUE_ON +from .entity import TcpEntity PLATFORM_SCHEMA: Final = BINARY_SENSOR_PLATFORM_SCHEMA.extend(TCP_PLATFORM_SCHEMA) diff --git a/homeassistant/components/tcp/common.py b/homeassistant/components/tcp/common.py index 263fc416026..a89cd999ddd 100644 --- a/homeassistant/components/tcp/common.py +++ b/homeassistant/components/tcp/common.py @@ -2,10 +2,6 @@ from __future__ import annotations -import logging -import select -import socket -import ssl from typing import Any, Final import voluptuous as vol @@ -21,11 +17,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import ConfigType from .const import ( CONF_BUFFER_SIZE, @@ -36,10 +28,6 @@ from .const import ( DEFAULT_TIMEOUT, DEFAULT_VERIFY_SSL, ) -from .model import TcpSensorConfig - -_LOGGER: Final = logging.getLogger(__name__) - TCP_PLATFORM_SCHEMA: Final[dict[vol.Marker, Any]] = { vol.Required(CONF_HOST): cv.string, @@ -54,103 +42,3 @@ TCP_PLATFORM_SCHEMA: Final[dict[vol.Marker, Any]] = { vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, } - - -class TcpEntity(Entity): - """Base entity class for TCP platform.""" - - def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: - """Set all the config values if they exist and get initial state.""" - - self._hass = hass - self._config: TcpSensorConfig = { - CONF_NAME: config[CONF_NAME], - CONF_HOST: config[CONF_HOST], - CONF_PORT: config[CONF_PORT], - CONF_TIMEOUT: config[CONF_TIMEOUT], - CONF_PAYLOAD: config[CONF_PAYLOAD], - CONF_UNIT_OF_MEASUREMENT: config.get(CONF_UNIT_OF_MEASUREMENT), - CONF_VALUE_TEMPLATE: config.get(CONF_VALUE_TEMPLATE), - CONF_VALUE_ON: config.get(CONF_VALUE_ON), - CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], - CONF_SSL: config[CONF_SSL], - CONF_VERIFY_SSL: config[CONF_VERIFY_SSL], - } - - self._ssl_context: ssl.SSLContext | None = None - if self._config[CONF_SSL]: - self._ssl_context = ssl.create_default_context() - if not self._config[CONF_VERIFY_SSL]: - self._ssl_context.check_hostname = False - self._ssl_context.verify_mode = ssl.CERT_NONE - - self._state: str | None = None - self.update() - - @property - def name(self) -> str: - """Return the name of this sensor.""" - return self._config[CONF_NAME] - - def update(self) -> None: - """Get the latest value for this sensor.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.settimeout(self._config[CONF_TIMEOUT]) - try: - sock.connect((self._config[CONF_HOST], self._config[CONF_PORT])) - except OSError as err: - _LOGGER.error( - "Unable to connect to %s on port %s: %s", - self._config[CONF_HOST], - self._config[CONF_PORT], - err, - ) - return - - if self._ssl_context is not None: - sock = self._ssl_context.wrap_socket( - sock, server_hostname=self._config[CONF_HOST] - ) - - try: - sock.send(self._config[CONF_PAYLOAD].encode()) - except OSError as err: - _LOGGER.error( - "Unable to send payload %r to %s on port %s: %s", - self._config[CONF_PAYLOAD], - self._config[CONF_HOST], - self._config[CONF_PORT], - err, - ) - return - - readable, _, _ = select.select([sock], [], [], self._config[CONF_TIMEOUT]) - if not readable: - _LOGGER.warning( - ( - "Timeout (%s second(s)) waiting for a response after " - "sending %r to %s on port %s" - ), - self._config[CONF_TIMEOUT], - self._config[CONF_PAYLOAD], - self._config[CONF_HOST], - self._config[CONF_PORT], - ) - return - - value = sock.recv(self._config[CONF_BUFFER_SIZE]).decode() - - value_template = self._config[CONF_VALUE_TEMPLATE] - if value_template is not None: - try: - self._state = value_template.render(parse_result=False, value=value) - except TemplateError: - _LOGGER.error( - "Unable to render template of %r with value: %r", - self._config[CONF_VALUE_TEMPLATE], - value, - ) - return - return - - self._state = value diff --git a/homeassistant/components/tcp/entity.py b/homeassistant/components/tcp/entity.py new file mode 100644 index 00000000000..eaf5cb6963e --- /dev/null +++ b/homeassistant/components/tcp/entity.py @@ -0,0 +1,130 @@ +"""Common code for TCP component.""" + +from __future__ import annotations + +import logging +import select +import socket +import ssl +from typing import Final + +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PAYLOAD, + CONF_PORT, + CONF_SSL, + CONF_TIMEOUT, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_BUFFER_SIZE, CONF_VALUE_ON +from .model import TcpSensorConfig + +_LOGGER: Final = logging.getLogger(__name__) + + +class TcpEntity(Entity): + """Base entity class for TCP platform.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Set all the config values if they exist and get initial state.""" + + self._hass = hass + self._config: TcpSensorConfig = { + CONF_NAME: config[CONF_NAME], + CONF_HOST: config[CONF_HOST], + CONF_PORT: config[CONF_PORT], + CONF_TIMEOUT: config[CONF_TIMEOUT], + CONF_PAYLOAD: config[CONF_PAYLOAD], + CONF_UNIT_OF_MEASUREMENT: config.get(CONF_UNIT_OF_MEASUREMENT), + CONF_VALUE_TEMPLATE: config.get(CONF_VALUE_TEMPLATE), + CONF_VALUE_ON: config.get(CONF_VALUE_ON), + CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], + CONF_SSL: config[CONF_SSL], + CONF_VERIFY_SSL: config[CONF_VERIFY_SSL], + } + + self._ssl_context: ssl.SSLContext | None = None + if self._config[CONF_SSL]: + self._ssl_context = ssl.create_default_context() + if not self._config[CONF_VERIFY_SSL]: + self._ssl_context.check_hostname = False + self._ssl_context.verify_mode = ssl.CERT_NONE + + self._state: str | None = None + self.update() + + @property + def name(self) -> str: + """Return the name of this sensor.""" + return self._config[CONF_NAME] + + def update(self) -> None: + """Get the latest value for this sensor.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(self._config[CONF_TIMEOUT]) + try: + sock.connect((self._config[CONF_HOST], self._config[CONF_PORT])) + except OSError as err: + _LOGGER.error( + "Unable to connect to %s on port %s: %s", + self._config[CONF_HOST], + self._config[CONF_PORT], + err, + ) + return + + if self._ssl_context is not None: + sock = self._ssl_context.wrap_socket( + sock, server_hostname=self._config[CONF_HOST] + ) + + try: + sock.send(self._config[CONF_PAYLOAD].encode()) + except OSError as err: + _LOGGER.error( + "Unable to send payload %r to %s on port %s: %s", + self._config[CONF_PAYLOAD], + self._config[CONF_HOST], + self._config[CONF_PORT], + err, + ) + return + + readable, _, _ = select.select([sock], [], [], self._config[CONF_TIMEOUT]) + if not readable: + _LOGGER.warning( + ( + "Timeout (%s second(s)) waiting for a response after " + "sending %r to %s on port %s" + ), + self._config[CONF_TIMEOUT], + self._config[CONF_PAYLOAD], + self._config[CONF_HOST], + self._config[CONF_PORT], + ) + return + + value = sock.recv(self._config[CONF_BUFFER_SIZE]).decode() + + value_template = self._config[CONF_VALUE_TEMPLATE] + if value_template is not None: + try: + self._state = value_template.render(parse_result=False, value=value) + except TemplateError: + _LOGGER.error( + "Unable to render template of %r with value: %r", + self._config[CONF_VALUE_TEMPLATE], + value, + ) + return + return + + self._state = value diff --git a/homeassistant/components/tcp/sensor.py b/homeassistant/components/tcp/sensor.py index a3bd4b2c619..1d53b21bc2e 100644 --- a/homeassistant/components/tcp/sensor.py +++ b/homeassistant/components/tcp/sensor.py @@ -13,7 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from .common import TCP_PLATFORM_SCHEMA, TcpEntity +from .common import TCP_PLATFORM_SCHEMA +from .entity import TcpEntity PLATFORM_SCHEMA: Final = SENSOR_PLATFORM_SCHEMA.extend(TCP_PLATFORM_SCHEMA) diff --git a/tests/components/tcp/test_binary_sensor.py b/tests/components/tcp/test_binary_sensor.py index 05aa2a471db..c84a36016ad 100644 --- a/tests/components/tcp/test_binary_sensor.py +++ b/tests/components/tcp/test_binary_sensor.py @@ -23,9 +23,9 @@ TEST_ENTITY = "binary_sensor.test_name" def mock_socket_fixture(): """Mock the socket.""" with ( - patch("homeassistant.components.tcp.common.socket.socket") as mock_socket, + patch("homeassistant.components.tcp.entity.socket.socket") as mock_socket, patch( - "homeassistant.components.tcp.common.select.select", + "homeassistant.components.tcp.entity.select.select", return_value=(True, False, False), ), ): diff --git a/tests/components/tcp/test_sensor.py b/tests/components/tcp/test_sensor.py index 04fbb2c667e..27003df46cd 100644 --- a/tests/components/tcp/test_sensor.py +++ b/tests/components/tcp/test_sensor.py @@ -43,7 +43,7 @@ socket_test_value = "123" @pytest.fixture(name="mock_socket") def mock_socket_fixture(mock_select): """Mock socket.""" - with patch("homeassistant.components.tcp.common.socket.socket") as mock_socket: + with patch("homeassistant.components.tcp.entity.socket.socket") as mock_socket: socket_instance = mock_socket.return_value.__enter__.return_value socket_instance.recv.return_value = socket_test_value.encode() yield socket_instance @@ -53,7 +53,7 @@ def mock_socket_fixture(mock_select): def mock_select_fixture(): """Mock select.""" with patch( - "homeassistant.components.tcp.common.select.select", + "homeassistant.components.tcp.entity.select.select", return_value=(True, False, False), ) as mock_select: yield mock_select @@ -63,7 +63,7 @@ def mock_select_fixture(): def mock_ssl_context_fixture(): """Mock select.""" with patch( - "homeassistant.components.tcp.common.ssl.create_default_context", + "homeassistant.components.tcp.entity.ssl.create_default_context", ) as mock_ssl_context: mock_ssl_context.return_value.wrap_socket.return_value.recv.return_value = ( socket_test_value + "567" From 8785a9869e37a6b5727bdae025a1c4a509ec7685 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:04:01 +0200 Subject: [PATCH 0775/1309] Rename tuya base entity module (#126180) --- homeassistant/components/tuya/alarm_control_panel.py | 2 +- homeassistant/components/tuya/binary_sensor.py | 2 +- homeassistant/components/tuya/button.py | 2 +- homeassistant/components/tuya/camera.py | 2 +- homeassistant/components/tuya/climate.py | 2 +- homeassistant/components/tuya/cover.py | 2 +- homeassistant/components/tuya/{base.py => entity.py} | 0 homeassistant/components/tuya/fan.py | 2 +- homeassistant/components/tuya/humidifier.py | 2 +- homeassistant/components/tuya/light.py | 2 +- homeassistant/components/tuya/number.py | 2 +- homeassistant/components/tuya/select.py | 2 +- homeassistant/components/tuya/sensor.py | 2 +- homeassistant/components/tuya/siren.py | 2 +- homeassistant/components/tuya/switch.py | 2 +- homeassistant/components/tuya/vacuum.py | 2 +- 16 files changed, 15 insertions(+), 15 deletions(-) rename homeassistant/components/tuya/{base.py => entity.py} (100%) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 29da625a990..fbea8d352a0 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -22,8 +22,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .entity import TuyaEntity class Mode(StrEnum): diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 2d6d9b478c8..4759a24905a 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -17,8 +17,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode +from .entity import TuyaEntity @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index f62bba928b4..f77fed776b0 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -11,8 +11,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode +from .entity import TuyaEntity # All descriptions can be found here. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index f3913611b07..9e66531dd51 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -11,8 +11,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode +from .entity import TuyaEntity # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index d47c71532a4..93aaaa40c26 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -24,8 +24,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import IntegerTypeData, TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .entity import IntegerTypeData, TuyaEntity TUYA_HVAC_TO_HA = { "auto": HVACMode.HEAT_COOL, diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index e92c6f5c5f2..9c3269c27f2 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -20,8 +20,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import IntegerTypeData, TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .entity import IntegerTypeData, TuyaEntity @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/entity.py similarity index 100% rename from homeassistant/components/tuya/base.py rename to homeassistant/components/tuya/entity.py diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 01a7ccf5083..4a6de1cae09 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -21,8 +21,8 @@ from homeassistant.util.percentage import ( ) from . import TuyaConfigEntry -from .base import EnumTypeData, IntegerTypeData, TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .entity import EnumTypeData, IntegerTypeData, TuyaEntity TUYA_SUPPORT_TYPE = { "fs", # Fan diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 3d16b0dfbbb..cb872d67719 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -17,8 +17,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import IntegerTypeData, TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .entity import IntegerTypeData, TuyaEntity @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 0c07eb05aac..060b1f4b7ef 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -23,8 +23,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import IntegerTypeData, TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode +from .entity import IntegerTypeData, TuyaEntity from .util import remap_value diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index d7614fb837a..d989cad07bb 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -15,8 +15,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import IntegerTypeData, TuyaEntity from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .entity import IntegerTypeData, TuyaEntity # All descriptions can be found here. Mostly the Integer data types in the # default instructions set of each category end up being a number. diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 111b9e40918..abc5e4c496b 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -11,8 +11,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .entity import TuyaEntity # All descriptions can be found here. Mostly the Enum data types in the # default instructions set of each category end up being a select. diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 4f3c6099377..fd8efcac95d 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -27,7 +27,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from . import TuyaConfigEntry -from .base import ElectricityTypeData, EnumTypeData, IntegerTypeData, TuyaEntity from .const import ( DEVICE_CLASS_UNITS, DOMAIN, @@ -36,6 +35,7 @@ from .const import ( DPType, UnitOfMeasurement, ) +from .entity import ElectricityTypeData, EnumTypeData, IntegerTypeData, TuyaEntity @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 683705c6546..334dced134d 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -16,8 +16,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode +from .entity import TuyaEntity # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 8af9a00ab45..77432c5b9a5 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -17,8 +17,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode +from .entity import TuyaEntity # All descriptions can be found here. Mostly the Boolean data types in the # default instruction set of each category end up being a Switch. diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 360d6d4f5c3..2e0a154e670 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -19,8 +19,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import TuyaConfigEntry -from .base import EnumTypeData, IntegerTypeData, TuyaEntity from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .entity import EnumTypeData, IntegerTypeData, TuyaEntity TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { From 0deb152bb26bfa9e418aa6a34ac19809b937c7e2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:04:28 +0200 Subject: [PATCH 0776/1309] Move tellstick shared constants to separate module (#126179) --- homeassistant/components/tellstick/__init__.py | 11 +++++++---- homeassistant/components/tellstick/const.py | 8 ++++++++ homeassistant/components/tellstick/cover.py | 4 ++-- homeassistant/components/tellstick/light.py | 4 ++-- homeassistant/components/tellstick/switch.py | 4 ++-- 5 files changed, 21 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/tellstick/const.py diff --git a/homeassistant/components/tellstick/__init__.py b/homeassistant/components/tellstick/__init__.py index 1a60927e25f..9b55e73841f 100644 --- a/homeassistant/components/tellstick/__init__.py +++ b/homeassistant/components/tellstick/__init__.py @@ -25,16 +25,19 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType +from .const import ( + ATTR_DISCOVER_CONFIG, + ATTR_DISCOVER_DEVICES, + DATA_TELLSTICK, + DEFAULT_SIGNAL_REPETITIONS, +) + _LOGGER = logging.getLogger(__name__) -ATTR_DISCOVER_CONFIG = "config" -ATTR_DISCOVER_DEVICES = "devices" CONF_SIGNAL_REPETITIONS = "signal_repetitions" -DEFAULT_SIGNAL_REPETITIONS = 1 DOMAIN = "tellstick" -DATA_TELLSTICK = "tellstick_device" SIGNAL_TELLCORE_CALLBACK = "tellstick_callback" # Use a global tellstick domain lock to avoid getting Tellcore errors when diff --git a/homeassistant/components/tellstick/const.py b/homeassistant/components/tellstick/const.py new file mode 100644 index 00000000000..625621e4615 --- /dev/null +++ b/homeassistant/components/tellstick/const.py @@ -0,0 +1,8 @@ +"""Support for Tellstick.""" + +ATTR_DISCOVER_CONFIG = "config" +ATTR_DISCOVER_DEVICES = "devices" + +DATA_TELLSTICK = "tellstick_device" + +DEFAULT_SIGNAL_REPETITIONS = 1 diff --git a/homeassistant/components/tellstick/cover.py b/homeassistant/components/tellstick/cover.py index cb49d876e71..ee6d2bb2808 100644 --- a/homeassistant/components/tellstick/cover.py +++ b/homeassistant/components/tellstick/cover.py @@ -9,12 +9,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from . import TellstickDevice +from .const import ( ATTR_DISCOVER_CONFIG, ATTR_DISCOVER_DEVICES, DATA_TELLSTICK, DEFAULT_SIGNAL_REPETITIONS, - TellstickDevice, ) diff --git a/homeassistant/components/tellstick/light.py b/homeassistant/components/tellstick/light.py index acbcf2d6cb5..eba80049cd6 100644 --- a/homeassistant/components/tellstick/light.py +++ b/homeassistant/components/tellstick/light.py @@ -7,12 +7,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from . import TellstickDevice +from .const import ( ATTR_DISCOVER_CONFIG, ATTR_DISCOVER_DEVICES, DATA_TELLSTICK, DEFAULT_SIGNAL_REPETITIONS, - TellstickDevice, ) diff --git a/homeassistant/components/tellstick/switch.py b/homeassistant/components/tellstick/switch.py index e3eb4825d91..8ea4c82b5e9 100644 --- a/homeassistant/components/tellstick/switch.py +++ b/homeassistant/components/tellstick/switch.py @@ -7,12 +7,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from . import TellstickDevice +from .const import ( ATTR_DISCOVER_CONFIG, ATTR_DISCOVER_DEVICES, DATA_TELLSTICK, DEFAULT_SIGNAL_REPETITIONS, - TellstickDevice, ) From 6325a332bd47a778f695eab0cf8f1a21e192a631 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:05:21 +0200 Subject: [PATCH 0777/1309] Move soma base entity to separate module (#126177) --- homeassistant/components/soma/__init__.py | 109 +-------------------- homeassistant/components/soma/const.py | 2 + homeassistant/components/soma/cover.py | 3 +- homeassistant/components/soma/entity.py | 112 ++++++++++++++++++++++ homeassistant/components/soma/sensor.py | 4 +- 5 files changed, 119 insertions(+), 111 deletions(-) create mode 100644 homeassistant/components/soma/entity.py diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index 7b14aaa3c81..9ffe5539ff3 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -2,12 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Coroutine -import logging -from typing import Any - from api.soma_api import SomaApi -from requests import RequestException import voluptuous as vol from homeassistant import config_entries @@ -15,16 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -from .const import API, DOMAIN, HOST, PORT -from .utils import is_api_response_success - -_LOGGER = logging.getLogger(__name__) - -DEVICES = "devices" +from .const import API, DEVICES, DOMAIN, HOST, PORT CONFIG_SCHEMA = vol.Schema( vol.All( @@ -72,98 +60,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -def soma_api_call[_SomaEntityT: SomaEntity]( - api_call: Callable[[_SomaEntityT], Coroutine[Any, Any, dict]], -) -> Callable[[_SomaEntityT], Coroutine[Any, Any, dict]]: - """Soma api call decorator.""" - - async def inner(self: _SomaEntityT) -> dict: - response = {} - try: - response_from_api = await api_call(self) - except RequestException: - if self.api_is_available: - _LOGGER.warning("Connection to SOMA Connect failed") - self.api_is_available = False - else: - if not self.api_is_available: - self.api_is_available = True - _LOGGER.info("Connection to SOMA Connect succeeded") - - if not is_api_response_success(response_from_api): - if self.is_available: - self.is_available = False - _LOGGER.warning( - ( - "Device is unreachable (%s). Error while fetching the" - " state: %s" - ), - self.name, - response_from_api["msg"], - ) - else: - if not self.is_available: - self.is_available = True - _LOGGER.info("Device %s is now reachable", self.name) - response = response_from_api - return response - - return inner - - -class SomaEntity(Entity): - """Representation of a generic Soma device.""" - - _attr_has_entity_name = True - - def __init__(self, device, api): - """Initialize the Soma device.""" - self.device = device - self.api = api - self.current_position = 50 - self.battery_state = 0 - self.is_available = True - self.api_is_available = True - - @property - def available(self): - """Return true if the last API commands returned successfully.""" - return self.is_available - - @property - def unique_id(self): - """Return the unique id base on the id returned by pysoma API.""" - return self.device["mac"] - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes. - - Implemented by platform classes. - """ - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Wazombi Labs", - name=self.device["name"], - ) - - def set_position(self, position: int) -> None: - """Set the current device position.""" - self.current_position = position - self.schedule_update_ha_state() - - @soma_api_call - async def get_shade_state_from_api(self) -> dict: - """Return the shade state from the api.""" - return await self.hass.async_add_executor_job( - self.api.get_shade_state, self.device["mac"] - ) - - @soma_api_call - async def get_battery_level_from_api(self) -> dict: - """Return the battery level from the api.""" - return await self.hass.async_add_executor_job( - self.api.get_battery_level, self.device["mac"] - ) diff --git a/homeassistant/components/soma/const.py b/homeassistant/components/soma/const.py index 815a0176e7e..b34596abe93 100644 --- a/homeassistant/components/soma/const.py +++ b/homeassistant/components/soma/const.py @@ -4,3 +4,5 @@ DOMAIN = "soma" HOST = "host" PORT = "port" API = "api" + +DEVICES = "devices" diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index a5d9507af4a..50f7d34e406 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -16,7 +16,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import API, DEVICES, DOMAIN, SomaEntity +from .const import API, DEVICES, DOMAIN +from .entity import SomaEntity from .utils import is_api_response_success diff --git a/homeassistant/components/soma/entity.py b/homeassistant/components/soma/entity.py new file mode 100644 index 00000000000..f9824d107b1 --- /dev/null +++ b/homeassistant/components/soma/entity.py @@ -0,0 +1,112 @@ +"""Support for Soma Smartshades.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +import logging +from typing import Any + +from requests import RequestException + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .utils import is_api_response_success + +_LOGGER = logging.getLogger(__name__) + + +def soma_api_call[_SomaEntityT: SomaEntity]( + api_call: Callable[[_SomaEntityT], Coroutine[Any, Any, dict]], +) -> Callable[[_SomaEntityT], Coroutine[Any, Any, dict]]: + """Soma api call decorator.""" + + async def inner(self: _SomaEntityT) -> dict: + response = {} + try: + response_from_api = await api_call(self) + except RequestException: + if self.api_is_available: + _LOGGER.warning("Connection to SOMA Connect failed") + self.api_is_available = False + else: + if not self.api_is_available: + self.api_is_available = True + _LOGGER.info("Connection to SOMA Connect succeeded") + + if not is_api_response_success(response_from_api): + if self.is_available: + self.is_available = False + _LOGGER.warning( + ( + "Device is unreachable (%s). Error while fetching the" + " state: %s" + ), + self.name, + response_from_api["msg"], + ) + else: + if not self.is_available: + self.is_available = True + _LOGGER.info("Device %s is now reachable", self.name) + response = response_from_api + return response + + return inner + + +class SomaEntity(Entity): + """Representation of a generic Soma device.""" + + _attr_has_entity_name = True + + def __init__(self, device, api): + """Initialize the Soma device.""" + self.device = device + self.api = api + self.current_position = 50 + self.battery_state = 0 + self.is_available = True + self.api_is_available = True + + @property + def available(self): + """Return true if the last API commands returned successfully.""" + return self.is_available + + @property + def unique_id(self): + """Return the unique id base on the id returned by pysoma API.""" + return self.device["mac"] + + @property + def device_info(self) -> DeviceInfo: + """Return device specific attributes. + + Implemented by platform classes. + """ + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + manufacturer="Wazombi Labs", + name=self.device["name"], + ) + + def set_position(self, position: int) -> None: + """Set the current device position.""" + self.current_position = position + self.schedule_update_ha_state() + + @soma_api_call + async def get_shade_state_from_api(self) -> dict: + """Return the shade state from the api.""" + return await self.hass.async_add_executor_job( + self.api.get_shade_state, self.device["mac"] + ) + + @soma_api_call + async def get_battery_level_from_api(self) -> dict: + """Return the battery level from the api.""" + return await self.hass.async_add_executor_job( + self.api.get_battery_level, self.device["mac"] + ) diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 4992ec5cde4..806886009f3 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -9,8 +9,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle -from . import DEVICES, SomaEntity -from .const import API, DOMAIN +from .const import API, DEVICES, DOMAIN +from .entity import SomaEntity MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) From fdf460b82b91a8df6fe0a3d472b38ffcff362dcf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:05:50 +0200 Subject: [PATCH 0778/1309] Move smartthings base entity to separate module (#126176) --- .../components/smartthings/__init__.py | 47 +---------------- .../components/smartthings/binary_sensor.py | 2 +- .../components/smartthings/climate.py | 2 +- homeassistant/components/smartthings/cover.py | 2 +- .../components/smartthings/entity.py | 50 +++++++++++++++++++ homeassistant/components/smartthings/fan.py | 2 +- homeassistant/components/smartthings/light.py | 2 +- homeassistant/components/smartthings/lock.py | 2 +- .../components/smartthings/sensor.py | 2 +- .../components/smartthings/switch.py | 2 +- 10 files changed, 59 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/smartthings/entity.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 9bfa11d3293..bcc752ff173 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -11,7 +11,6 @@ import logging from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from pysmartapp.event import EVENT_TYPE_DEVICE from pysmartthings import Attribute, Capability, SmartThings -from pysmartthings.device import DeviceEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET @@ -19,12 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_loaded_integration @@ -433,42 +427,3 @@ class DeviceBroker: updated_devices.add(device.device_id) async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, updated_devices) - - -class SmartThingsEntity(Entity): - """Defines a SmartThings entity.""" - - _attr_should_poll = False - - def __init__(self, device: DeviceEntity) -> None: - """Initialize the instance.""" - self._device = device - self._dispatcher_remove = None - self._attr_name = device.label - self._attr_unique_id = device.device_id - self._attr_device_info = DeviceInfo( - configuration_url="https://account.smartthings.com", - identifiers={(DOMAIN, device.device_id)}, - manufacturer=device.status.ocf_manufacturer_name, - model=device.status.ocf_model_number, - name=device.label, - hw_version=device.status.ocf_hardware_version, - sw_version=device.status.ocf_firmware_version, - ) - - async def async_added_to_hass(self): - """Device added to hass.""" - - async def async_update_state(devices): - """Update device state.""" - if self._device.device_id in devices: - await self.async_update_ha_state(True) - - self._dispatcher_remove = async_dispatcher_connect( - self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect the device when removed.""" - if self._dispatcher_remove: - self._dispatcher_remove() diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 4bb60217eee..611473b011d 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -15,8 +15,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN +from .entity import SmartThingsEntity CAPABILITY_TO_ATTRIB = { Capability.acceleration_sensor: Attribute.acceleration, diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 0598e549f24..073a1470c21 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -28,8 +28,8 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN +from .entity import SmartThingsEntity ATTR_OPERATION_STATE = "operation_state" MODE_TO_STATE = { diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 276a68176b4..d0e2fc3f039 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -23,8 +23,8 @@ from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN +from .entity import SmartThingsEntity VALUE_TO_STATE = { "closed": STATE_CLOSED, diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py new file mode 100644 index 00000000000..cc63213d122 --- /dev/null +++ b/homeassistant/components/smartthings/entity.py @@ -0,0 +1,50 @@ +"""Support for SmartThings Cloud.""" + +from __future__ import annotations + +from pysmartthings.device import DeviceEntity + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE + + +class SmartThingsEntity(Entity): + """Defines a SmartThings entity.""" + + _attr_should_poll = False + + def __init__(self, device: DeviceEntity) -> None: + """Initialize the instance.""" + self._device = device + self._dispatcher_remove = None + self._attr_name = device.label + self._attr_unique_id = device.device_id + self._attr_device_info = DeviceInfo( + configuration_url="https://account.smartthings.com", + identifiers={(DOMAIN, device.device_id)}, + manufacturer=device.status.ocf_manufacturer_name, + model=device.status.ocf_model_number, + name=device.label, + hw_version=device.status.ocf_hardware_version, + sw_version=device.status.ocf_firmware_version, + ) + + async def async_added_to_hass(self): + """Device added to hass.""" + + async def async_update_state(devices): + """Update device state.""" + if self._device.device_id in devices: + await self.async_update_ha_state(True) + + self._dispatcher_remove = async_dispatcher_connect( + self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect the device when removed.""" + if self._dispatcher_remove: + self._dispatcher_remove() diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 840c04c2a10..131cccdd869 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -18,8 +18,8 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN +from .entity import SmartThingsEntity SPEED_RANGE = (1, 3) # off is not included diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 24a44a99d94..fd4b87f0ee7 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -23,8 +23,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util -from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN +from .entity import SmartThingsEntity async def async_setup_entry( diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 0cd954e7542..a0ae9e50443 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -12,8 +12,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN +from .entity import SmartThingsEntity ST_STATE_LOCKED = "locked" ST_LOCK_ATTR_MAP = { diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 2a61be3dc75..b73d3b43764 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -31,8 +31,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN +from .entity import SmartThingsEntity class Map(NamedTuple): diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index bd5f7bc0b68..5cfe4576d6a 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -12,8 +12,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN +from .entity import SmartThingsEntity async def async_setup_entry( From bbe64e99e17e36bcf1de601a9403f2b828b8c761 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:06:19 +0200 Subject: [PATCH 0779/1309] Move slack base entity to separate module (#126175) --- homeassistant/components/slack/__init__.py | 28 ----------------- homeassistant/components/slack/entity.py | 36 ++++++++++++++++++++++ homeassistant/components/slack/sensor.py | 2 +- 3 files changed, 37 insertions(+), 29 deletions(-) create mode 100644 homeassistant/components/slack/entity.py diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py index e5f6a50122e..6fce38e4774 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -13,8 +13,6 @@ from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv, discovery -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import ConfigType from .const import ( @@ -22,7 +20,6 @@ from .const import ( ATTR_USER_ID, DATA_CLIENT, DATA_HASS_CONFIG, - DEFAULT_NAME, DOMAIN, SLACK_DATA, ) @@ -74,28 +71,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return True - - -class SlackEntity(Entity): - """Representation of a Slack entity.""" - - _attr_attribution = "Data provided by Slack" - _attr_has_entity_name = True - - def __init__( - self, - data: dict[str, str | WebClient], - description: EntityDescription, - entry: ConfigEntry, - ) -> None: - """Initialize a Slack entity.""" - self._client = data[DATA_CLIENT] - self.entity_description = description - self._attr_unique_id = f"{data[ATTR_USER_ID]}_{description.key}" - self._attr_device_info = DeviceInfo( - configuration_url=data[ATTR_URL], - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry.entry_id)}, - manufacturer=DEFAULT_NAME, - name=entry.title, - ) diff --git a/homeassistant/components/slack/entity.py b/homeassistant/components/slack/entity.py new file mode 100644 index 00000000000..7147186ee9b --- /dev/null +++ b/homeassistant/components/slack/entity.py @@ -0,0 +1,36 @@ +"""The slack integration.""" + +from __future__ import annotations + +from slack import WebClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from .const import ATTR_URL, ATTR_USER_ID, DATA_CLIENT, DEFAULT_NAME, DOMAIN + + +class SlackEntity(Entity): + """Representation of a Slack entity.""" + + _attr_attribution = "Data provided by Slack" + _attr_has_entity_name = True + + def __init__( + self, + data: dict[str, str | WebClient], + description: EntityDescription, + entry: ConfigEntry, + ) -> None: + """Initialize a Slack entity.""" + self._client = data[DATA_CLIENT] + self.entity_description = description + self._attr_unique_id = f"{data[ATTR_USER_ID]}_{description.key}" + self._attr_device_info = DeviceInfo( + configuration_url=data[ATTR_URL], + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer=DEFAULT_NAME, + name=entry.title, + ) diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index b4d7fd28bd7..9e3beaadd8b 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -14,8 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from . import SlackEntity from .const import ATTR_SNOOZE, DOMAIN, SLACK_DATA +from .entity import SlackEntity async def async_setup_entry( From 47657af173a81036e79ad7967ad9db3f773fefb3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:06:40 +0200 Subject: [PATCH 0780/1309] Move raincloud shared constants to separate module (#126174) Move shared raincloud constants to separate module --- .../components/raincloud/__init__.py | 34 ++----------------- .../components/raincloud/binary_sensor.py | 5 ++- homeassistant/components/raincloud/const.py | 17 ++++++++++ homeassistant/components/raincloud/sensor.py | 24 ++++++++----- homeassistant/components/raincloud/switch.py | 16 ++++----- 5 files changed, 48 insertions(+), 48 deletions(-) create mode 100644 homeassistant/components/raincloud/const.py diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index a805024357c..56f1cff2e99 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -8,13 +8,7 @@ from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant.components import persistent_notification -from homeassistant.const import ( - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_USERNAME, - PERCENTAGE, - UnitOfTime, -) +from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send @@ -22,18 +16,14 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType +from .const import DATA_RAINCLOUD, SIGNAL_UPDATE_RAINCLOUD + _LOGGER = logging.getLogger(__name__) -ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] - -CONF_WATERING_TIME = "watering_minutes" - NOTIFICATION_ID = "raincloud_notification" NOTIFICATION_TITLE = "Rain Cloud Setup" -DATA_RAINCLOUD = "raincloud" DOMAIN = "raincloud" -DEFAULT_WATERING_TIME = 15 KEY_MAP = { "auto_watering": "Automatic Watering", @@ -57,27 +47,9 @@ ICON_MAP = { "watering_time": "mdi:water-pump", } -UNIT_OF_MEASUREMENT_MAP = { - "auto_watering": "", - "battery": PERCENTAGE, - "is_watering": "", - "manual_watering": "", - "next_cycle": "", - "rain_delay": UnitOfTime.DAYS, - "status": "", - "watering_time": UnitOfTime.MINUTES, -} - -BINARY_SENSORS = ["is_watering", "status"] - -SENSORS = ["battery", "next_cycle", "rain_delay", "watering_time"] - -SWITCHES = ["auto_watering", "manual_watering"] SCAN_INTERVAL = timedelta(seconds=20) -SIGNAL_UPDATE_RAINCLOUD = "raincloud_update" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( diff --git a/homeassistant/components/raincloud/binary_sensor.py b/homeassistant/components/raincloud/binary_sensor.py index 90ad36985ef..90e8cc99240 100644 --- a/homeassistant/components/raincloud/binary_sensor.py +++ b/homeassistant/components/raincloud/binary_sensor.py @@ -16,10 +16,13 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import BINARY_SENSORS, DATA_RAINCLOUD, ICON_MAP, RainCloudEntity +from . import RainCloudEntity +from .const import DATA_RAINCLOUD, ICON_MAP _LOGGER = logging.getLogger(__name__) +BINARY_SENSORS = ["is_watering", "status"] + PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): vol.All( diff --git a/homeassistant/components/raincloud/const.py b/homeassistant/components/raincloud/const.py new file mode 100644 index 00000000000..957830ffcc5 --- /dev/null +++ b/homeassistant/components/raincloud/const.py @@ -0,0 +1,17 @@ +"""Support for Melnor RainCloud sprinkler water timer.""" + +DATA_RAINCLOUD = "raincloud" + +ICON_MAP = { + "auto_watering": "mdi:autorenew", + "battery": "", + "is_watering": "", + "manual_watering": "mdi:water-pump", + "next_cycle": "mdi:calendar-clock", + "rain_delay": "mdi:weather-rainy", + "status": "", + "watering_time": "mdi:water-pump", +} + + +SIGNAL_UPDATE_RAINCLOUD = "raincloud_update" diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py index 34a7cf73490..6a7a45dbf37 100644 --- a/homeassistant/components/raincloud/sensor.py +++ b/homeassistant/components/raincloud/sensor.py @@ -10,23 +10,20 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, SensorEntity, ) -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_MONITORED_CONDITIONS, PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( - DATA_RAINCLOUD, - ICON_MAP, - SENSORS, - UNIT_OF_MEASUREMENT_MAP, - RainCloudEntity, -) +from . import RainCloudEntity +from .const import DATA_RAINCLOUD, ICON_MAP _LOGGER = logging.getLogger(__name__) +SENSORS = ["battery", "next_cycle", "rain_delay", "watering_time"] + PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( @@ -35,6 +32,17 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( } ) +UNIT_OF_MEASUREMENT_MAP = { + "auto_watering": "", + "battery": PERCENTAGE, + "is_watering": "", + "manual_watering": "", + "next_cycle": "", + "rain_delay": UnitOfTime.DAYS, + "status": "", + "watering_time": UnitOfTime.MINUTES, +} + def setup_platform( hass: HomeAssistant, diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py index 45d0b4f0fc5..47a2de8afaa 100644 --- a/homeassistant/components/raincloud/switch.py +++ b/homeassistant/components/raincloud/switch.py @@ -17,17 +17,17 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( - ALLOWED_WATERING_TIME, - CONF_WATERING_TIME, - DATA_RAINCLOUD, - DEFAULT_WATERING_TIME, - SWITCHES, - RainCloudEntity, -) +from . import RainCloudEntity +from .const import DATA_RAINCLOUD _LOGGER = logging.getLogger(__name__) +ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] +CONF_WATERING_TIME = "watering_minutes" +DEFAULT_WATERING_TIME = 15 + +SWITCHES = ["auto_watering", "manual_watering"] + PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SWITCHES)): vol.All( From df434fc5e22c3c32ea7504241168025931ac04f9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:07:13 +0200 Subject: [PATCH 0781/1309] Move shared rflink constants to separate module (#126173) --- homeassistant/components/rflink/__init__.py | 54 +++++-------------- .../components/rflink/binary_sensor.py | 3 +- homeassistant/components/rflink/const.py | 39 ++++++++++++++ homeassistant/components/rflink/cover.py | 4 +- homeassistant/components/rflink/light.py | 4 +- homeassistant/components/rflink/sensor.py | 4 +- homeassistant/components/rflink/switch.py | 4 +- homeassistant/components/rflink/utils.py | 14 +++++ 8 files changed, 75 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/rflink/const.py diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index a7525b7caf5..5f334e33fc1 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -32,38 +32,32 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from .utils import brightness_to_rflink +from .const import ( + DATA_DEVICE_REGISTER, + DATA_ENTITY_LOOKUP, + DEFAULT_SIGNAL_REPETITIONS, + EVENT_KEY_COMMAND, + EVENT_KEY_ID, + EVENT_KEY_SENSOR, + SIGNAL_AVAILABILITY, + SIGNAL_HANDLE_EVENT, + TMP_ENTITY, +) +from .utils import brightness_to_rflink, identify_event_type _LOGGER = logging.getLogger(__name__) -ATTR_EVENT = "event" - -CONF_ALIASES = "aliases" -CONF_GROUP_ALIASES = "group_aliases" -CONF_GROUP = "group" -CONF_NOGROUP_ALIASES = "nogroup_aliases" -CONF_DEVICE_DEFAULTS = "device_defaults" -CONF_AUTOMATIC_ADD = "automatic_add" -CONF_FIRE_EVENT = "fire_event" CONF_IGNORE_DEVICES = "ignore_devices" CONF_RECONNECT_INTERVAL = "reconnect_interval" -CONF_SIGNAL_REPETITIONS = "signal_repetitions" CONF_WAIT_FOR_ACK = "wait_for_ack" CONF_KEEPALIVE_IDLE = "tcp_keepalive_idle_timer" -DATA_DEVICE_REGISTER = "rflink_device_register" -DATA_ENTITY_LOOKUP = "rflink_entity_lookup" DATA_ENTITY_GROUP_LOOKUP = "rflink_entity_group_only_lookup" DEFAULT_RECONNECT_INTERVAL = 10 -DEFAULT_SIGNAL_REPETITIONS = 1 DEFAULT_TCP_KEEPALIVE_IDLE_TIMER = 3600 CONNECTION_TIMEOUT = 10 EVENT_BUTTON_PRESSED = "button_pressed" -EVENT_KEY_COMMAND = "command" -EVENT_KEY_ID = "id" -EVENT_KEY_SENSOR = "sensor" -EVENT_KEY_UNIT = "unit" RFLINK_GROUP_COMMANDS = ["allon", "alloff"] @@ -71,20 +65,8 @@ DOMAIN = "rflink" SERVICE_SEND_COMMAND = "send_command" -SIGNAL_AVAILABILITY = "rflink_device_available" -SIGNAL_HANDLE_EVENT = "rflink_handle_event_{}" SIGNAL_EVENT = "rflink_event" -TMP_ENTITY = "tmp.{}" - -DEVICE_DEFAULTS_SCHEMA = vol.Schema( - { - vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, - vol.Optional( - CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS - ): vol.Coerce(int), - } -) CONFIG_SCHEMA = vol.Schema( { @@ -113,18 +95,6 @@ SEND_COMMAND_SCHEMA = vol.Schema( ) -def identify_event_type(event): - """Look at event to determine type of device. - - Async friendly. - """ - if EVENT_KEY_COMMAND in event: - return EVENT_KEY_COMMAND - if EVENT_KEY_SENSOR in event: - return EVENT_KEY_SENSOR - return "unknown" - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Rflink component.""" # Allow entities to register themselves by device_id to be looked up when diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py index b731037fbfc..949130b54e6 100644 --- a/homeassistant/components/rflink/binary_sensor.py +++ b/homeassistant/components/rflink/binary_sensor.py @@ -26,7 +26,8 @@ import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_ALIASES, RflinkDevice +from . import RflinkDevice +from .const import CONF_ALIASES CONF_OFF_DELAY = "off_delay" DEFAULT_FORCE_UPDATE = False diff --git a/homeassistant/components/rflink/const.py b/homeassistant/components/rflink/const.py new file mode 100644 index 00000000000..80168a86f94 --- /dev/null +++ b/homeassistant/components/rflink/const.py @@ -0,0 +1,39 @@ +"""Support for Rflink devices.""" + +from __future__ import annotations + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +CONF_ALIASES = "aliases" +CONF_GROUP_ALIASES = "group_aliases" +CONF_GROUP = "group" +CONF_NOGROUP_ALIASES = "nogroup_aliases" +CONF_DEVICE_DEFAULTS = "device_defaults" +CONF_AUTOMATIC_ADD = "automatic_add" +CONF_FIRE_EVENT = "fire_event" +CONF_SIGNAL_REPETITIONS = "signal_repetitions" + +DATA_DEVICE_REGISTER = "rflink_device_register" +DATA_ENTITY_LOOKUP = "rflink_entity_lookup" +DEFAULT_SIGNAL_REPETITIONS = 1 + +EVENT_KEY_COMMAND = "command" +EVENT_KEY_ID = "id" +EVENT_KEY_SENSOR = "sensor" +EVENT_KEY_UNIT = "unit" + +SIGNAL_AVAILABILITY = "rflink_device_available" +SIGNAL_HANDLE_EVENT = "rflink_handle_event_{}" + +TMP_ENTITY = "tmp.{}" + +DEVICE_DEFAULTS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, + vol.Optional( + CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS + ): vol.Coerce(int), + } +) diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index 54a84a68a2e..f1298367a4f 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -18,7 +18,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from . import RflinkCommand +from .const import ( CONF_ALIASES, CONF_DEVICE_DEFAULTS, CONF_FIRE_EVENT, @@ -27,7 +28,6 @@ from . import ( CONF_NOGROUP_ALIASES, CONF_SIGNAL_REPETITIONS, DEVICE_DEFAULTS_SCHEMA, - RflinkCommand, ) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index b29bb4f1d48..68aa17778da 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -20,7 +20,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from . import SwitchableRflinkDevice +from .const import ( CONF_ALIASES, CONF_AUTOMATIC_ADD, CONF_DEVICE_DEFAULTS, @@ -33,7 +34,6 @@ from . import ( DEVICE_DEFAULTS_SCHEMA, EVENT_KEY_COMMAND, EVENT_KEY_ID, - SwitchableRflinkDevice, ) from .utils import brightness_to_rflink, rflink_to_brightness diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index f3c3df7f46b..d89670f8a1b 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -40,7 +40,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from . import RflinkDevice +from .const import ( CONF_ALIASES, CONF_AUTOMATIC_ADD, DATA_DEVICE_REGISTER, @@ -51,7 +52,6 @@ from . import ( SIGNAL_AVAILABILITY, SIGNAL_HANDLE_EVENT, TMP_ENTITY, - RflinkDevice, ) SENSOR_TYPES = ( diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index af4bbc43700..9f85a391662 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -14,7 +14,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( +from . import SwitchableRflinkDevice +from .const import ( CONF_ALIASES, CONF_DEVICE_DEFAULTS, CONF_FIRE_EVENT, @@ -23,7 +24,6 @@ from . import ( CONF_NOGROUP_ALIASES, CONF_SIGNAL_REPETITIONS, DEVICE_DEFAULTS_SCHEMA, - SwitchableRflinkDevice, ) PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/rflink/utils.py b/homeassistant/components/rflink/utils.py index 9738d9f74fa..7a05c596773 100644 --- a/homeassistant/components/rflink/utils.py +++ b/homeassistant/components/rflink/utils.py @@ -1,5 +1,7 @@ """RFLink integration utils.""" +from .const import EVENT_KEY_COMMAND, EVENT_KEY_SENSOR + def brightness_to_rflink(brightness: int) -> int: """Convert 0-255 brightness to RFLink dim level (0-15).""" @@ -9,3 +11,15 @@ def brightness_to_rflink(brightness: int) -> int: def rflink_to_brightness(dim_level: int) -> int: """Convert RFLink dim level (0-15) to 0-255 brightness.""" return int(dim_level * 17) + + +def identify_event_type(event): + """Look at event to determine type of device. + + Async friendly. + """ + if EVENT_KEY_COMMAND in event: + return EVENT_KEY_COMMAND + if EVENT_KEY_SENSOR in event: + return EVENT_KEY_SENSOR + return "unknown" From dbb6eaa9eb3f0c5d2926450827a32c0b1159a57f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:07:38 +0200 Subject: [PATCH 0782/1309] Move and rename remember_the_milk base entity to separate module (#126171) Move remember_the_milk base entity to separate module --- .../components/remember_the_milk/__init__.py | 144 +----------------- .../components/remember_the_milk/entity.py | 142 +++++++++++++++++ 2 files changed, 149 insertions(+), 137 deletions(-) create mode 100644 homeassistant/components/remember_the_milk/entity.py diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 7f91c6e2f13..d544c42efe1 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -4,17 +4,18 @@ import json import logging import os -from rtmapi import Rtm, RtmRequestFailedException +from rtmapi import Rtm import voluptuous as vol from homeassistant.components import configurator -from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN, STATE_OK -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from .entity import RememberTheMilkEntity + # httplib2 is a transitive dependency from RtmAPI. If this dependency is not # set explicitly, the library does not work. _LOGGER = logging.getLogger(__name__) @@ -53,7 +54,7 @@ SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string}) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Remember the milk component.""" - component = EntityComponent[RememberTheMilk](_LOGGER, DOMAIN, hass) + component = EntityComponent[RememberTheMilkEntity](_LOGGER, DOMAIN, hass) stored_rtm_config = RememberTheMilkConfiguration(hass) for rtm_config in config[DOMAIN]: @@ -85,7 +86,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def _create_instance( hass, account_name, api_key, shared_secret, token, stored_rtm_config, component ): - entity = RememberTheMilk( + entity = RememberTheMilkEntity( account_name, api_key, shared_secret, token, stored_rtm_config ) component.add_entities([entity]) @@ -237,134 +238,3 @@ class RememberTheMilkConfiguration: if hass_id in self._config[profile_name][CONF_ID_MAP]: del self._config[profile_name][CONF_ID_MAP][hass_id] self.save_config() - - -class RememberTheMilk(Entity): - """Representation of an interface to Remember The Milk.""" - - def __init__(self, name, api_key, shared_secret, token, rtm_config): - """Create new instance of Remember The Milk component.""" - self._name = name - self._api_key = api_key - self._shared_secret = shared_secret - self._token = token - self._rtm_config = rtm_config - self._rtm_api = Rtm(api_key, shared_secret, "delete", token) - self._token_valid = None - self._check_token() - _LOGGER.debug("Instance created for account %s", self._name) - - def _check_token(self): - """Check if the API token is still valid. - - If it is not valid any more, delete it from the configuration. This - will trigger a new authentication process. - """ - valid = self._rtm_api.token_valid() - if not valid: - _LOGGER.error( - "Token for account %s is invalid. You need to register again!", - self.name, - ) - self._rtm_config.delete_token(self._name) - self._token_valid = False - else: - self._token_valid = True - return self._token_valid - - def create_task(self, call: ServiceCall) -> None: - """Create a new task on Remember The Milk. - - You can use the smart syntax to define the attributes of a new task, - e.g. "my task #some_tag ^today" will add tag "some_tag" and set the - due date to today. - """ - try: - task_name = call.data[CONF_NAME] - hass_id = call.data.get(CONF_ID) - rtm_id = None - if hass_id is not None: - rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) - result = self._rtm_api.rtm.timelines.create() - timeline = result.timeline.value - - if hass_id is None or rtm_id is None: - result = self._rtm_api.rtm.tasks.add( - timeline=timeline, name=task_name, parse="1" - ) - _LOGGER.debug( - "Created new task '%s' in account %s", task_name, self.name - ) - self._rtm_config.set_rtm_id( - self._name, - hass_id, - result.list.id, - result.list.taskseries.id, - result.list.taskseries.task.id, - ) - else: - self._rtm_api.rtm.tasks.setName( - name=task_name, - list_id=rtm_id[0], - taskseries_id=rtm_id[1], - task_id=rtm_id[2], - timeline=timeline, - ) - _LOGGER.debug( - "Updated task with id '%s' in account %s to name %s", - hass_id, - self.name, - task_name, - ) - except RtmRequestFailedException as rtm_exception: - _LOGGER.error( - "Error creating new Remember The Milk task for account %s: %s", - self._name, - rtm_exception, - ) - - def complete_task(self, call: ServiceCall) -> None: - """Complete a task that was previously created by this component.""" - hass_id = call.data[CONF_ID] - rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) - if rtm_id is None: - _LOGGER.error( - ( - "Could not find task with ID %s in account %s. " - "So task could not be closed" - ), - hass_id, - self._name, - ) - return - try: - result = self._rtm_api.rtm.timelines.create() - timeline = result.timeline.value - self._rtm_api.rtm.tasks.complete( - list_id=rtm_id[0], - taskseries_id=rtm_id[1], - task_id=rtm_id[2], - timeline=timeline, - ) - self._rtm_config.delete_rtm_id(self._name, hass_id) - _LOGGER.debug( - "Completed task with id %s in account %s", hass_id, self._name - ) - except RtmRequestFailedException as rtm_exception: - _LOGGER.error( - "Error creating new Remember The Milk task for account %s: %s", - self._name, - rtm_exception, - ) - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - if not self._token_valid: - return "API token invalid" - return STATE_OK diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py new file mode 100644 index 00000000000..8fa52b6c06c --- /dev/null +++ b/homeassistant/components/remember_the_milk/entity.py @@ -0,0 +1,142 @@ +"""Support to interact with Remember The Milk.""" + +import logging + +from rtmapi import Rtm, RtmRequestFailedException + +from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK +from homeassistant.core import ServiceCall +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + + +class RememberTheMilkEntity(Entity): + """Representation of an interface to Remember The Milk.""" + + def __init__(self, name, api_key, shared_secret, token, rtm_config): + """Create new instance of Remember The Milk component.""" + self._name = name + self._api_key = api_key + self._shared_secret = shared_secret + self._token = token + self._rtm_config = rtm_config + self._rtm_api = Rtm(api_key, shared_secret, "delete", token) + self._token_valid = None + self._check_token() + _LOGGER.debug("Instance created for account %s", self._name) + + def _check_token(self): + """Check if the API token is still valid. + + If it is not valid any more, delete it from the configuration. This + will trigger a new authentication process. + """ + valid = self._rtm_api.token_valid() + if not valid: + _LOGGER.error( + "Token for account %s is invalid. You need to register again!", + self.name, + ) + self._rtm_config.delete_token(self._name) + self._token_valid = False + else: + self._token_valid = True + return self._token_valid + + def create_task(self, call: ServiceCall) -> None: + """Create a new task on Remember The Milk. + + You can use the smart syntax to define the attributes of a new task, + e.g. "my task #some_tag ^today" will add tag "some_tag" and set the + due date to today. + """ + try: + task_name = call.data[CONF_NAME] + hass_id = call.data.get(CONF_ID) + rtm_id = None + if hass_id is not None: + rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) + result = self._rtm_api.rtm.timelines.create() + timeline = result.timeline.value + + if hass_id is None or rtm_id is None: + result = self._rtm_api.rtm.tasks.add( + timeline=timeline, name=task_name, parse="1" + ) + _LOGGER.debug( + "Created new task '%s' in account %s", task_name, self.name + ) + self._rtm_config.set_rtm_id( + self._name, + hass_id, + result.list.id, + result.list.taskseries.id, + result.list.taskseries.task.id, + ) + else: + self._rtm_api.rtm.tasks.setName( + name=task_name, + list_id=rtm_id[0], + taskseries_id=rtm_id[1], + task_id=rtm_id[2], + timeline=timeline, + ) + _LOGGER.debug( + "Updated task with id '%s' in account %s to name %s", + hass_id, + self.name, + task_name, + ) + except RtmRequestFailedException as rtm_exception: + _LOGGER.error( + "Error creating new Remember The Milk task for account %s: %s", + self._name, + rtm_exception, + ) + + def complete_task(self, call: ServiceCall) -> None: + """Complete a task that was previously created by this component.""" + hass_id = call.data[CONF_ID] + rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) + if rtm_id is None: + _LOGGER.error( + ( + "Could not find task with ID %s in account %s. " + "So task could not be closed" + ), + hass_id, + self._name, + ) + return + try: + result = self._rtm_api.rtm.timelines.create() + timeline = result.timeline.value + self._rtm_api.rtm.tasks.complete( + list_id=rtm_id[0], + taskseries_id=rtm_id[1], + task_id=rtm_id[2], + timeline=timeline, + ) + self._rtm_config.delete_rtm_id(self._name, hass_id) + _LOGGER.debug( + "Completed task with id %s in account %s", hass_id, self._name + ) + except RtmRequestFailedException as rtm_exception: + _LOGGER.error( + "Error creating new Remember The Milk task for account %s: %s", + self._name, + rtm_exception, + ) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if not self._token_valid: + return "API token invalid" + return STATE_OK From 987b8af1b1ed6c5dba0008a2b8ac7b0d731bee3b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 18 Sep 2024 11:08:12 +0200 Subject: [PATCH 0783/1309] Use debug/warning instead of info log level in components [u] (#126148) --- homeassistant/components/ubus/device_tracker.py | 2 +- homeassistant/components/unifiprotect/data.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py index 84a813f1d37..285a176af0a 100644 --- a/homeassistant/components/ubus/device_tracker.py +++ b/homeassistant/components/ubus/device_tracker.py @@ -123,7 +123,7 @@ class UbusDeviceScanner(DeviceScanner): if not self.success_init: return False - _LOGGER.info("Checking hostapd") + _LOGGER.debug("Checking hostapd") if not self.hostapd: hostapd = self.ubus.get_hostapd() diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index b8e47e0e0f1..4ad8892ca01 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -164,7 +164,7 @@ class ProtectData: self._auth_failures = 0 if not was_success: - _LOGGER.info("%s: Connection restored", self._entry.title) + _LOGGER.warning("%s: Connection restored", self._entry.title) self._async_process_updates() elif force_update: self._async_process_updates() From b1ef91bcfea7564e6f6b1af6663403f83b620527 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:22:36 +0200 Subject: [PATCH 0784/1309] Move wirelesstag base entity to separate module (#126203) --- .../components/wirelesstag/__init__.py | 93 +----------------- .../components/wirelesstag/binary_sensor.py | 2 +- .../components/wirelesstag/entity.py | 95 +++++++++++++++++++ .../components/wirelesstag/sensor.py | 2 +- .../components/wirelesstag/switch.py | 2 +- 5 files changed, 99 insertions(+), 95 deletions(-) create mode 100644 homeassistant/components/wirelesstag/entity.py diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 2bd2fbebac9..a32e940073b 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -8,34 +8,16 @@ from wirelesstagpy import WirelessTags from wirelesstagpy.exceptions import WirelessTagsException from homeassistant.components import persistent_notification -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_VOLTAGE, - CONF_PASSWORD, - CONF_USERNAME, - PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - UnitOfElectricPotential, -) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, SIGNAL_BINARY_EVENT_UPDATE, SIGNAL_TAG_UPDATE _LOGGER = logging.getLogger(__name__) - -# Strength of signal in dBm -ATTR_TAG_SIGNAL_STRENGTH = "signal_strength" -# Indicates if tag is out of range or not -ATTR_TAG_OUT_OF_RANGE = "out_of_range" -# Number in percents from max power of tag receiver -ATTR_TAG_POWER_CONSUMPTION = "power_consumption" - - NOTIFICATION_ID = "wirelesstag_notification" NOTIFICATION_TITLE = "Wireless Sensor Tag Setup" @@ -144,76 +126,3 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: return False return True - - -class WirelessTagBaseSensor(Entity): - """Base class for HA implementation for Wireless Sensor Tag.""" - - def __init__(self, api, tag): - """Initialize a base sensor for Wireless Sensor Tag platform.""" - self._api = api - self._tag = tag - self._uuid = self._tag.uuid - self.tag_id = self._tag.tag_id - self.tag_manager_mac = self._tag.tag_manager_mac - self._name = self._tag.name - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def principal_value(self): - """Return base value. - - Subclasses need override based on type of sensor. - """ - return 0 - - def updated_state_value(self): - """Return formatted value. - - The default implementation formats principal value. - """ - return self.decorate_value(self.principal_value) - - def decorate_value(self, value): - """Decorate input value to be well presented for end user.""" - return f"{value:.1f}" - - @property - def available(self): - """Return True if entity is available.""" - return self._tag.is_alive - - def update(self): - """Update state.""" - if not self.should_poll: - return - - updated_tags = self._api.load_tags() - if (updated_tag := updated_tags[self._uuid]) is None: - _LOGGER.error('Unable to update tag: "%s"', self.name) - return - - self._tag = updated_tag - self._state = self.updated_state_value() - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_BATTERY_LEVEL: int(self._tag.battery_remaining * 100), - ATTR_VOLTAGE: ( - f"{self._tag.battery_volts:.2f}{UnitOfElectricPotential.VOLT}" - ), - ATTR_TAG_SIGNAL_STRENGTH: ( - f"{self._tag.signal_strength}{SIGNAL_STRENGTH_DECIBELS_MILLIWATT}" - ), - ATTR_TAG_OUT_OF_RANGE: not self._tag.is_in_range, - ATTR_TAG_POWER_CONSUMPTION: ( - f"{self._tag.power_consumption:.2f}{PERCENTAGE}" - ), - } diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index cd8f058cce4..9e8075dd874 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -15,8 +15,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import WirelessTagBaseSensor from .const import DOMAIN, SIGNAL_BINARY_EVENT_UPDATE +from .entity import WirelessTagBaseSensor from .util import async_migrate_unique_id # On means in range, Off means out of range diff --git a/homeassistant/components/wirelesstag/entity.py b/homeassistant/components/wirelesstag/entity.py new file mode 100644 index 00000000000..31f8ee99d0d --- /dev/null +++ b/homeassistant/components/wirelesstag/entity.py @@ -0,0 +1,95 @@ +"""Support for Wireless Sensor Tags.""" + +import logging + +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_VOLTAGE, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfElectricPotential, +) +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + + +# Strength of signal in dBm +ATTR_TAG_SIGNAL_STRENGTH = "signal_strength" +# Indicates if tag is out of range or not +ATTR_TAG_OUT_OF_RANGE = "out_of_range" +# Number in percents from max power of tag receiver +ATTR_TAG_POWER_CONSUMPTION = "power_consumption" + + +class WirelessTagBaseSensor(Entity): + """Base class for HA implementation for Wireless Sensor Tag.""" + + def __init__(self, api, tag): + """Initialize a base sensor for Wireless Sensor Tag platform.""" + self._api = api + self._tag = tag + self._uuid = self._tag.uuid + self.tag_id = self._tag.tag_id + self.tag_manager_mac = self._tag.tag_manager_mac + self._name = self._tag.name + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def principal_value(self): + """Return base value. + + Subclasses need override based on type of sensor. + """ + return 0 + + def updated_state_value(self): + """Return formatted value. + + The default implementation formats principal value. + """ + return self.decorate_value(self.principal_value) + + def decorate_value(self, value): + """Decorate input value to be well presented for end user.""" + return f"{value:.1f}" + + @property + def available(self): + """Return True if entity is available.""" + return self._tag.is_alive + + def update(self): + """Update state.""" + if not self.should_poll: + return + + updated_tags = self._api.load_tags() + if (updated_tag := updated_tags[self._uuid]) is None: + _LOGGER.error('Unable to update tag: "%s"', self.name) + return + + self._tag = updated_tag + self._state = self.updated_state_value() + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_BATTERY_LEVEL: int(self._tag.battery_remaining * 100), + ATTR_VOLTAGE: ( + f"{self._tag.battery_volts:.2f}{UnitOfElectricPotential.VOLT}" + ), + ATTR_TAG_SIGNAL_STRENGTH: ( + f"{self._tag.signal_strength}{SIGNAL_STRENGTH_DECIBELS_MILLIWATT}" + ), + ATTR_TAG_OUT_OF_RANGE: not self._tag.is_in_range, + ATTR_TAG_POWER_CONSUMPTION: ( + f"{self._tag.power_consumption:.2f}{PERCENTAGE}" + ), + } diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index 9f7ed3cc4b0..7a3cbe5efe2 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -20,8 +20,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import WirelessTagBaseSensor from .const import DOMAIN, SIGNAL_TAG_UPDATE +from .entity import WirelessTagBaseSensor from .util import async_migrate_unique_id _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index a5323ab3f1d..cae5d63988c 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -17,8 +17,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import WirelessTagBaseSensor from .const import DOMAIN +from .entity import WirelessTagBaseSensor from .util import async_migrate_unique_id SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( From 3fb92bc2458201710a6cbdb03a2e56a2add0d787 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:23:10 +0200 Subject: [PATCH 0785/1309] Move raincloud base entity to separate module (#126170) --- .../components/raincloud/__init__.py | 66 +----------------- .../components/raincloud/binary_sensor.py | 2 +- homeassistant/components/raincloud/entity.py | 68 +++++++++++++++++++ homeassistant/components/raincloud/sensor.py | 2 +- homeassistant/components/raincloud/switch.py | 2 +- 5 files changed, 72 insertions(+), 68 deletions(-) create mode 100644 homeassistant/components/raincloud/entity.py diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index 56f1cff2e99..f1eef40f307 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -11,8 +11,7 @@ from homeassistant.components import persistent_notification from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType @@ -25,29 +24,6 @@ NOTIFICATION_TITLE = "Rain Cloud Setup" DOMAIN = "raincloud" -KEY_MAP = { - "auto_watering": "Automatic Watering", - "battery": "Battery", - "is_watering": "Watering", - "manual_watering": "Manual Watering", - "next_cycle": "Next Cycle", - "rain_delay": "Rain Delay", - "status": "Status", - "watering_time": "Remaining Watering Time", -} - -ICON_MAP = { - "auto_watering": "mdi:autorenew", - "battery": "", - "is_watering": "", - "manual_watering": "mdi:water-pump", - "next_cycle": "mdi:calendar-clock", - "rain_delay": "mdi:weather-rainy", - "status": "", - "watering_time": "mdi:water-pump", -} - - SCAN_INTERVAL = timedelta(seconds=20) CONFIG_SCHEMA = vol.Schema( @@ -104,43 +80,3 @@ class RainCloudHub: def __init__(self, data): """Initialize the entity.""" self.data = data - - -class RainCloudEntity(Entity): - """Entity class for RainCloud devices.""" - - _attr_attribution = "Data provided by Melnor Aquatimer.com" - - def __init__(self, data, sensor_type): - """Initialize the RainCloud entity.""" - self.data = data - self._sensor_type = sensor_type - self._name = f"{self.data.name} {KEY_MAP.get(self._sensor_type)}" - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - async def async_added_to_hass(self): - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_RAINCLOUD, self._update_callback - ) - ) - - def _update_callback(self): - """Call update method.""" - self.schedule_update_ha_state(True) - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {"identifier": self.data.serial} - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/raincloud/binary_sensor.py b/homeassistant/components/raincloud/binary_sensor.py index 90e8cc99240..2696c192ed6 100644 --- a/homeassistant/components/raincloud/binary_sensor.py +++ b/homeassistant/components/raincloud/binary_sensor.py @@ -16,8 +16,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import RainCloudEntity from .const import DATA_RAINCLOUD, ICON_MAP +from .entity import RainCloudEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/raincloud/entity.py b/homeassistant/components/raincloud/entity.py new file mode 100644 index 00000000000..337324d96eb --- /dev/null +++ b/homeassistant/components/raincloud/entity.py @@ -0,0 +1,68 @@ +"""Support for Melnor RainCloud sprinkler water timer.""" + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import SIGNAL_UPDATE_RAINCLOUD + +KEY_MAP = { + "auto_watering": "Automatic Watering", + "battery": "Battery", + "is_watering": "Watering", + "manual_watering": "Manual Watering", + "next_cycle": "Next Cycle", + "rain_delay": "Rain Delay", + "status": "Status", + "watering_time": "Remaining Watering Time", +} + +ICON_MAP = { + "auto_watering": "mdi:autorenew", + "battery": "", + "is_watering": "", + "manual_watering": "mdi:water-pump", + "next_cycle": "mdi:calendar-clock", + "rain_delay": "mdi:weather-rainy", + "status": "", + "watering_time": "mdi:water-pump", +} + + +class RainCloudEntity(Entity): + """Entity class for RainCloud devices.""" + + _attr_attribution = "Data provided by Melnor Aquatimer.com" + + def __init__(self, data, sensor_type): + """Initialize the RainCloud entity.""" + self.data = data + self._sensor_type = sensor_type + self._name = f"{self.data.name} {KEY_MAP.get(self._sensor_type)}" + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + async def async_added_to_hass(self): + """Register callbacks.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_RAINCLOUD, self._update_callback + ) + ) + + def _update_callback(self): + """Call update method.""" + self.schedule_update_ha_state(True) + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return {"identifier": self.data.serial} + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py index 6a7a45dbf37..1f9d8d7b2c5 100644 --- a/homeassistant/components/raincloud/sensor.py +++ b/homeassistant/components/raincloud/sensor.py @@ -17,8 +17,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import RainCloudEntity from .const import DATA_RAINCLOUD, ICON_MAP +from .entity import RainCloudEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py index 47a2de8afaa..59a11a6b167 100644 --- a/homeassistant/components/raincloud/switch.py +++ b/homeassistant/components/raincloud/switch.py @@ -17,8 +17,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import RainCloudEntity from .const import DATA_RAINCLOUD +from .entity import RainCloudEntity _LOGGER = logging.getLogger(__name__) From cf389681f643e03e72bcdd8ed47eb819b0a690b6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:25:43 +0200 Subject: [PATCH 0786/1309] Move upb base entity to separate module (#126184) --- homeassistant/components/upb/__init__.py | 61 +--------------------- homeassistant/components/upb/entity.py | 64 ++++++++++++++++++++++++ homeassistant/components/upb/light.py | 2 +- homeassistant/components/upb/scene.py | 2 +- 4 files changed, 67 insertions(+), 62 deletions(-) create mode 100644 homeassistant/components/upb/entity.py diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py index 2e5a69393d4..ca4375d1232 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -4,9 +4,7 @@ import upb_lib from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, CONF_FILE_PATH, CONF_HOST, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity +from homeassistant.core import HomeAssistant from .const import ( ATTR_ADDRESS, @@ -65,60 +63,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> upb.disconnect() hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok - - -class UpbEntity(Entity): - """Base class for all UPB entities.""" - - _attr_should_poll = False - - def __init__(self, element, unique_id, upb): - """Initialize the base of all UPB devices.""" - self._upb = upb - self._element = element - element_type = "link" if element.addr.is_link else "device" - self._unique_id = f"{unique_id}_{element_type}_{element.addr}" - - @property - def unique_id(self): - """Return unique id of the element.""" - return self._unique_id - - @property - def extra_state_attributes(self): - """Return the default attributes of the element.""" - return self._element.as_dict() - - @property - def available(self): - """Is the entity available to be updated.""" - return self._upb.is_connected() - - def _element_changed(self, element, changeset): - pass - - @callback - def _element_callback(self, element, changeset): - """Handle callback from an UPB element that has changed.""" - self._element_changed(element, changeset) - self.async_write_ha_state() - - async def async_added_to_hass(self): - """Register callback for UPB changes and update entity state.""" - self._element.add_callback(self._element_callback) - self._element_callback(self._element, {}) - - -class UpbAttachedEntity(UpbEntity): - """Base class for UPB attached entities.""" - - @property - def device_info(self) -> DeviceInfo: - """Device info for the entity.""" - return DeviceInfo( - identifiers={(DOMAIN, self._element.index)}, - manufacturer=self._element.manufacturer, - model=self._element.product, - name=self._element.name, - sw_version=self._element.version, - ) diff --git a/homeassistant/components/upb/entity.py b/homeassistant/components/upb/entity.py new file mode 100644 index 00000000000..13037adf680 --- /dev/null +++ b/homeassistant/components/upb/entity.py @@ -0,0 +1,64 @@ +"""Support the UPB PIM.""" + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class UpbEntity(Entity): + """Base class for all UPB entities.""" + + _attr_should_poll = False + + def __init__(self, element, unique_id, upb): + """Initialize the base of all UPB devices.""" + self._upb = upb + self._element = element + element_type = "link" if element.addr.is_link else "device" + self._unique_id = f"{unique_id}_{element_type}_{element.addr}" + + @property + def unique_id(self): + """Return unique id of the element.""" + return self._unique_id + + @property + def extra_state_attributes(self): + """Return the default attributes of the element.""" + return self._element.as_dict() + + @property + def available(self): + """Is the entity available to be updated.""" + return self._upb.is_connected() + + def _element_changed(self, element, changeset): + pass + + @callback + def _element_callback(self, element, changeset): + """Handle callback from an UPB element that has changed.""" + self._element_changed(element, changeset) + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Register callback for UPB changes and update entity state.""" + self._element.add_callback(self._element_callback) + self._element_callback(self._element, {}) + + +class UpbAttachedEntity(UpbEntity): + """Base class for UPB attached entities.""" + + @property + def device_info(self) -> DeviceInfo: + """Device info for the entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._element.index)}, + manufacturer=self._element.manufacturer, + model=self._element.product, + name=self._element.name, + sw_version=self._element.version, + ) diff --git a/homeassistant/components/upb/light.py b/homeassistant/components/upb/light.py index 881eda3525f..07bd50b7d9f 100644 --- a/homeassistant/components/upb/light.py +++ b/homeassistant/components/upb/light.py @@ -15,8 +15,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpbAttachedEntity from .const import DOMAIN, UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA +from .entity import UpbAttachedEntity SERVICE_LIGHT_FADE_START = "light_fade_start" SERVICE_LIGHT_FADE_STOP = "light_fade_stop" diff --git a/homeassistant/components/upb/scene.py b/homeassistant/components/upb/scene.py index 276b620d5b5..5a5e17b3e4c 100644 --- a/homeassistant/components/upb/scene.py +++ b/homeassistant/components/upb/scene.py @@ -8,8 +8,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UpbEntity from .const import DOMAIN, UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA +from .entity import UpbEntity SERVICE_LINK_DEACTIVATE = "link_deactivate" SERVICE_LINK_FADE_STOP = "link_fade_stop" From 63929a1177ba96dc9497581af7cae52be28e310e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:26:52 +0200 Subject: [PATCH 0787/1309] Move onvif base entity to separate module (#126128) --- homeassistant/components/onvif/binary_sensor.py | 2 +- homeassistant/components/onvif/button.py | 2 +- homeassistant/components/onvif/camera.py | 2 +- homeassistant/components/onvif/{base.py => entity.py} | 0 homeassistant/components/onvif/sensor.py | 2 +- homeassistant/components/onvif/switch.py | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename homeassistant/components/onvif/{base.py => entity.py} (100%) diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py index 4aa4d81e055..92c5ab45129 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -14,9 +14,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.enum import try_parse_enum -from .base import ONVIFBaseEntity from .const import DOMAIN from .device import ONVIFDevice +from .entity import ONVIFBaseEntity async def async_setup_entry( diff --git a/homeassistant/components/onvif/button.py b/homeassistant/components/onvif/button.py index 1e86b73fc66..644a7c942f7 100644 --- a/homeassistant/components/onvif/button.py +++ b/homeassistant/components/onvif/button.py @@ -6,9 +6,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .base import ONVIFBaseEntity from .const import DOMAIN from .device import ONVIFDevice +from .entity import ONVIFBaseEntity async def async_setup_entry( diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 4b6dfa1a625..8c0fd027b95 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -24,7 +24,6 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .base import ONVIFBaseEntity from .const import ( ABSOLUTE_MOVE, ATTR_CONTINUOUS_DURATION, @@ -51,6 +50,7 @@ from .const import ( ZOOM_OUT, ) from .device import ONVIFDevice +from .entity import ONVIFBaseEntity from .models import Profile diff --git a/homeassistant/components/onvif/base.py b/homeassistant/components/onvif/entity.py similarity index 100% rename from homeassistant/components/onvif/base.py rename to homeassistant/components/onvif/entity.py diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index 5b0c72e88dd..46db26361bc 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -13,9 +13,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.enum import try_parse_enum -from .base import ONVIFBaseEntity from .const import DOMAIN from .device import ONVIFDevice +from .entity import ONVIFBaseEntity async def async_setup_entry( diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py index 02b48d20bef..ff62e469af0 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -11,9 +11,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .base import ONVIFBaseEntity from .const import DOMAIN from .device import ONVIFDevice +from .entity import ONVIFBaseEntity from .models import Profile From e2c6d2765af22271104d21aaac93f09100fc5930 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Wed, 18 Sep 2024 10:28:33 +0100 Subject: [PATCH 0788/1309] Remove default mastodon instance in config flow (#126204) Remove default mastodon instance --- homeassistant/components/mastodon/config_flow.py | 1 - homeassistant/components/mastodon/strings.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py index 5c9419cd12d..5e1af5fae92 100644 --- a/homeassistant/components/mastodon/config_flow.py +++ b/homeassistant/components/mastodon/config_flow.py @@ -28,7 +28,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required( CONF_BASE_URL, - default=DEFAULT_URL, ): TextSelector(TextSelectorConfig(type=TextSelectorType.URL)), vol.Required( CONF_CLIENT_ID, diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 906b67dd481..fd4dd890b37 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -9,7 +9,7 @@ "access_token": "[%key:common::config_flow::data::access_token%]" }, "data_description": { - "base_url": "The URL of your Mastodon instance." + "base_url": "The URL of your Mastodon instance e.g. https://mastodon.social." } } }, From de104b35db8f2c9b478a88c3f86a547189a55e8e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:28:56 +0200 Subject: [PATCH 0789/1309] Move tellstick base entity to separate module (#126205) --- .../components/tellstick/__init__.py | 155 +----------------- homeassistant/components/tellstick/const.py | 2 + homeassistant/components/tellstick/cover.py | 2 +- homeassistant/components/tellstick/entity.py | 151 +++++++++++++++++ homeassistant/components/tellstick/light.py | 2 +- homeassistant/components/tellstick/switch.py | 2 +- 6 files changed, 159 insertions(+), 155 deletions(-) create mode 100644 homeassistant/components/tellstick/entity.py diff --git a/homeassistant/components/tellstick/__init__.py b/homeassistant/components/tellstick/__init__.py index 9b55e73841f..8fae04dd9ce 100644 --- a/homeassistant/components/tellstick/__init__.py +++ b/homeassistant/components/tellstick/__init__.py @@ -1,15 +1,8 @@ """Support for Tellstick.""" import logging -import threading -from tellcore.constants import ( - TELLSTICK_DIM, - TELLSTICK_TURNOFF, - TELLSTICK_TURNON, - TELLSTICK_UP, -) -from tellcore.library import TelldusError +from tellcore.constants import TELLSTICK_DIM, TELLSTICK_UP from tellcore.telldus import AsyncioCallbackDispatcher, TelldusCore from tellcorenet import TellCoreClient import voluptuous as vol @@ -18,11 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from .const import ( @@ -30,6 +19,7 @@ from .const import ( ATTR_DISCOVER_DEVICES, DATA_TELLSTICK, DEFAULT_SIGNAL_REPETITIONS, + SIGNAL_TELLCORE_CALLBACK, ) _LOGGER = logging.getLogger(__name__) @@ -38,12 +28,6 @@ CONF_SIGNAL_REPETITIONS = "signal_repetitions" DOMAIN = "tellstick" -SIGNAL_TELLCORE_CALLBACK = "tellstick_callback" - -# Use a global tellstick domain lock to avoid getting Tellcore errors when -# calling concurrently. -TELLSTICK_LOCK = threading.RLock() - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -165,136 +149,3 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, clean_up_callback) return True - - -class TellstickDevice(Entity): - """Representation of a Tellstick device. - - Contains the common logic for all Tellstick devices. - """ - - _attr_assumed_state = True - _attr_should_poll = False - - def __init__(self, tellcore_device, signal_repetitions): - """Init the Tellstick device.""" - self._signal_repetitions = signal_repetitions - self._state = None - self._requested_state = None - self._requested_data = None - self._repeats_left = 0 - - # Look up our corresponding tellcore device - self._tellcore_device = tellcore_device - self._attr_name = tellcore_device.name - self._attr_unique_id = tellcore_device.id - - async def async_added_to_hass(self): - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_TELLCORE_CALLBACK, self.update_from_callback - ) - ) - - @property - def is_on(self): - """Return true if the device is on.""" - return self._state - - def _parse_ha_data(self, kwargs): - """Turn the value from HA into something useful.""" - raise NotImplementedError - - def _parse_tellcore_data(self, tellcore_data): - """Turn the value received from tellcore into something useful.""" - raise NotImplementedError - - def _update_model(self, new_state, data): - """Update the device entity state to match the arguments.""" - raise NotImplementedError - - def _send_device_command(self, requested_state, requested_data): - """Let tellcore update the actual device to the requested state.""" - raise NotImplementedError - - def _send_repeated_command(self): - """Send a tellstick command once and decrease the repeat count.""" - - with TELLSTICK_LOCK: - if self._repeats_left > 0: - self._repeats_left -= 1 - try: - self._send_device_command( - self._requested_state, self._requested_data - ) - except TelldusError as err: - _LOGGER.error(err) - - def _change_device_state(self, new_state, data): - """Turn on or off the device.""" - with TELLSTICK_LOCK: - # Set the requested state and number of repeats before calling - # _send_repeated_command the first time. Subsequent calls will be - # made from the callback. (We don't want to queue a lot of commands - # in case the user toggles the switch the other way before the - # queue is fully processed.) - self._requested_state = new_state - self._requested_data = data - self._repeats_left = self._signal_repetitions - self._send_repeated_command() - - # Sooner or later this will propagate to the model from the - # callback, but for a fluid UI experience update it directly. - self._update_model(new_state, data) - self.schedule_update_ha_state() - - def turn_on(self, **kwargs): - """Turn the switch on.""" - self._change_device_state(True, self._parse_ha_data(kwargs)) - - def turn_off(self, **kwargs): - """Turn the switch off.""" - self._change_device_state(False, None) - - def _update_model_from_command(self, tellcore_command, tellcore_data): - """Update the model, from a sent tellcore command and data.""" - - if tellcore_command not in [TELLSTICK_TURNON, TELLSTICK_TURNOFF, TELLSTICK_DIM]: - _LOGGER.debug("Unhandled tellstick command: %d", tellcore_command) - return - - self._update_model( - tellcore_command != TELLSTICK_TURNOFF, - self._parse_tellcore_data(tellcore_data), - ) - - def update_from_callback(self, tellcore_id, tellcore_command, tellcore_data): - """Handle updates from the tellcore callback.""" - if tellcore_id != self._tellcore_device.id: - return - - self._update_model_from_command(tellcore_command, tellcore_data) - self.schedule_update_ha_state() - - # This is a benign race on _repeats_left -- it's checked with the lock - # in _send_repeated_command. - if self._repeats_left > 0: - self._send_repeated_command() - - def _update_from_tellcore(self): - """Read the current state of the device from the tellcore library.""" - - with TELLSTICK_LOCK: - try: - last_command = self._tellcore_device.last_sent_command( - TELLSTICK_TURNON | TELLSTICK_TURNOFF | TELLSTICK_DIM - ) - last_data = self._tellcore_device.last_sent_value() - self._update_model_from_command(last_command, last_data) - except TelldusError as err: - _LOGGER.error(err) - - def update(self): - """Poll the current state of the device.""" - self._update_from_tellcore() diff --git a/homeassistant/components/tellstick/const.py b/homeassistant/components/tellstick/const.py index 625621e4615..64730a1161d 100644 --- a/homeassistant/components/tellstick/const.py +++ b/homeassistant/components/tellstick/const.py @@ -6,3 +6,5 @@ ATTR_DISCOVER_DEVICES = "devices" DATA_TELLSTICK = "tellstick_device" DEFAULT_SIGNAL_REPETITIONS = 1 + +SIGNAL_TELLCORE_CALLBACK = "tellstick_callback" diff --git a/homeassistant/components/tellstick/cover.py b/homeassistant/components/tellstick/cover.py index ee6d2bb2808..255892c1f6c 100644 --- a/homeassistant/components/tellstick/cover.py +++ b/homeassistant/components/tellstick/cover.py @@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import TellstickDevice from .const import ( ATTR_DISCOVER_CONFIG, ATTR_DISCOVER_DEVICES, DATA_TELLSTICK, DEFAULT_SIGNAL_REPETITIONS, ) +from .entity import TellstickDevice def setup_platform( diff --git a/homeassistant/components/tellstick/entity.py b/homeassistant/components/tellstick/entity.py new file mode 100644 index 00000000000..746c7f4dd4d --- /dev/null +++ b/homeassistant/components/tellstick/entity.py @@ -0,0 +1,151 @@ +"""Support for Tellstick.""" + +import logging +import threading + +from tellcore.constants import TELLSTICK_DIM, TELLSTICK_TURNOFF, TELLSTICK_TURNON +from tellcore.library import TelldusError + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import SIGNAL_TELLCORE_CALLBACK + +_LOGGER = logging.getLogger(__name__) + +# Use a global tellstick domain lock to avoid getting Tellcore errors when +# calling concurrently. +TELLSTICK_LOCK = threading.RLock() + + +class TellstickDevice(Entity): + """Representation of a Tellstick device. + + Contains the common logic for all Tellstick devices. + """ + + _attr_assumed_state = True + _attr_should_poll = False + + def __init__(self, tellcore_device, signal_repetitions): + """Init the Tellstick device.""" + self._signal_repetitions = signal_repetitions + self._state = None + self._requested_state = None + self._requested_data = None + self._repeats_left = 0 + + # Look up our corresponding tellcore device + self._tellcore_device = tellcore_device + self._attr_name = tellcore_device.name + self._attr_unique_id = tellcore_device.id + + async def async_added_to_hass(self): + """Register callbacks.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_TELLCORE_CALLBACK, self.update_from_callback + ) + ) + + @property + def is_on(self): + """Return true if the device is on.""" + return self._state + + def _parse_ha_data(self, kwargs): + """Turn the value from HA into something useful.""" + raise NotImplementedError + + def _parse_tellcore_data(self, tellcore_data): + """Turn the value received from tellcore into something useful.""" + raise NotImplementedError + + def _update_model(self, new_state, data): + """Update the device entity state to match the arguments.""" + raise NotImplementedError + + def _send_device_command(self, requested_state, requested_data): + """Let tellcore update the actual device to the requested state.""" + raise NotImplementedError + + def _send_repeated_command(self): + """Send a tellstick command once and decrease the repeat count.""" + + with TELLSTICK_LOCK: + if self._repeats_left > 0: + self._repeats_left -= 1 + try: + self._send_device_command( + self._requested_state, self._requested_data + ) + except TelldusError as err: + _LOGGER.error(err) + + def _change_device_state(self, new_state, data): + """Turn on or off the device.""" + with TELLSTICK_LOCK: + # Set the requested state and number of repeats before calling + # _send_repeated_command the first time. Subsequent calls will be + # made from the callback. (We don't want to queue a lot of commands + # in case the user toggles the switch the other way before the + # queue is fully processed.) + self._requested_state = new_state + self._requested_data = data + self._repeats_left = self._signal_repetitions + self._send_repeated_command() + + # Sooner or later this will propagate to the model from the + # callback, but for a fluid UI experience update it directly. + self._update_model(new_state, data) + self.schedule_update_ha_state() + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._change_device_state(True, self._parse_ha_data(kwargs)) + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._change_device_state(False, None) + + def _update_model_from_command(self, tellcore_command, tellcore_data): + """Update the model, from a sent tellcore command and data.""" + + if tellcore_command not in [TELLSTICK_TURNON, TELLSTICK_TURNOFF, TELLSTICK_DIM]: + _LOGGER.debug("Unhandled tellstick command: %d", tellcore_command) + return + + self._update_model( + tellcore_command != TELLSTICK_TURNOFF, + self._parse_tellcore_data(tellcore_data), + ) + + def update_from_callback(self, tellcore_id, tellcore_command, tellcore_data): + """Handle updates from the tellcore callback.""" + if tellcore_id != self._tellcore_device.id: + return + + self._update_model_from_command(tellcore_command, tellcore_data) + self.schedule_update_ha_state() + + # This is a benign race on _repeats_left -- it's checked with the lock + # in _send_repeated_command. + if self._repeats_left > 0: + self._send_repeated_command() + + def _update_from_tellcore(self): + """Read the current state of the device from the tellcore library.""" + + with TELLSTICK_LOCK: + try: + last_command = self._tellcore_device.last_sent_command( + TELLSTICK_TURNON | TELLSTICK_TURNOFF | TELLSTICK_DIM + ) + last_data = self._tellcore_device.last_sent_value() + self._update_model_from_command(last_command, last_data) + except TelldusError as err: + _LOGGER.error(err) + + def update(self): + """Poll the current state of the device.""" + self._update_from_tellcore() diff --git a/homeassistant/components/tellstick/light.py b/homeassistant/components/tellstick/light.py index eba80049cd6..0b7878cd10e 100644 --- a/homeassistant/components/tellstick/light.py +++ b/homeassistant/components/tellstick/light.py @@ -7,13 +7,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import TellstickDevice from .const import ( ATTR_DISCOVER_CONFIG, ATTR_DISCOVER_DEVICES, DATA_TELLSTICK, DEFAULT_SIGNAL_REPETITIONS, ) +from .entity import TellstickDevice def setup_platform( diff --git a/homeassistant/components/tellstick/switch.py b/homeassistant/components/tellstick/switch.py index 8ea4c82b5e9..fc9a44ef66c 100644 --- a/homeassistant/components/tellstick/switch.py +++ b/homeassistant/components/tellstick/switch.py @@ -7,13 +7,13 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import TellstickDevice from .const import ( ATTR_DISCOVER_CONFIG, ATTR_DISCOVER_DEVICES, DATA_TELLSTICK, DEFAULT_SIGNAL_REPETITIONS, ) +from .entity import TellstickDevice def setup_platform( From 0281e95f2e9643152c068f154a252604d2766caf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:29:23 +0200 Subject: [PATCH 0790/1309] Prefer __all__ over F401 ignore (#126189) --- homeassistant/components/axis/hub/__init__.py | 6 ++++-- homeassistant/components/deconz/hub/__init__.py | 6 ++++-- homeassistant/components/unifi/hub/__init__.py | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/axis/hub/__init__.py b/homeassistant/components/axis/hub/__init__.py index e68f902b628..8fd80989ca2 100644 --- a/homeassistant/components/axis/hub/__init__.py +++ b/homeassistant/components/axis/hub/__init__.py @@ -1,4 +1,6 @@ """Internal functionality not part of HA infrastructure.""" -from .api import get_axis_api # noqa: F401 -from .hub import AxisHub # noqa: F401 +from .api import get_axis_api +from .hub import AxisHub + +__all__ = ["AxisHub", "get_axis_api"] diff --git a/homeassistant/components/deconz/hub/__init__.py b/homeassistant/components/deconz/hub/__init__.py index e484bd5bb59..b816ceafad7 100644 --- a/homeassistant/components/deconz/hub/__init__.py +++ b/homeassistant/components/deconz/hub/__init__.py @@ -1,4 +1,6 @@ """Internal functionality not part of HA infrastructure.""" -from .api import get_deconz_api # noqa: F401 -from .hub import DeconzHub # noqa: F401 +from .api import get_deconz_api +from .hub import DeconzHub + +__all__ = ["DeconzHub", "get_deconz_api"] diff --git a/homeassistant/components/unifi/hub/__init__.py b/homeassistant/components/unifi/hub/__init__.py index b8ed15d46f4..dc307206d79 100644 --- a/homeassistant/components/unifi/hub/__init__.py +++ b/homeassistant/components/unifi/hub/__init__.py @@ -1,4 +1,6 @@ """Internal functionality not part of HA infrastructure.""" -from .api import get_unifi_api # noqa: F401 -from .hub import UnifiHub # noqa: F401 +from .api import get_unifi_api +from .hub import UnifiHub + +__all__ = ["UnifiHub", "get_unifi_api"] From 4f53ffcd9c43d9bfbc2bbf2439f808ff25549b21 Mon Sep 17 00:00:00 2001 From: TimL Date: Wed, 18 Sep 2024 19:40:27 +1000 Subject: [PATCH 0791/1309] Add VPN sensor and switch for Smlight integration (#126201) * Add vpn_status sensor * update test fixures with new attributes * Add vpn enabled switch vpn strings * Add vpn switch to test * update snapshots * Add vpn status to disabled by default test --- .../components/smlight/binary_sensor.py | 6 +++ homeassistant/components/smlight/strings.json | 6 +++ homeassistant/components/smlight/switch.py | 7 +++ tests/components/smlight/fixtures/info.json | 1 + .../components/smlight/fixtures/sensors.json | 4 +- .../smlight/snapshots/test_binary_sensor.ambr | 47 +++++++++++++++++++ .../smlight/snapshots/test_switch.ambr | 47 +++++++++++++++++++ .../components/smlight/test_binary_sensor.py | 11 +++-- tests/components/smlight/test_switch.py | 18 +++++++ 9 files changed, 142 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py index d273460e206..b1aba3a52fe 100644 --- a/homeassistant/components/smlight/binary_sensor.py +++ b/homeassistant/components/smlight/binary_sensor.py @@ -39,6 +39,12 @@ SENSORS = [ translation_key="ethernet", value_fn=lambda x: x.ethernet, ), + SmBinarySensorEntityDescription( + key="vpn", + translation_key="vpn", + entity_registry_enabled_default=False, + value_fn=lambda x: x.vpn_status, + ), SmBinarySensorEntityDescription( key="wifi", translation_key="wifi", diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 812218287a9..97797feae2a 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -49,6 +49,9 @@ "internet": { "name": "Internet" }, + "vpn": { + "name": "VPN" + }, "wifi": { "name": "Wi-Fi" } @@ -116,6 +119,9 @@ }, "night_mode": { "name": "LED night mode" + }, + "vpn_enabled": { + "name": "VPN enabled" } }, "update": { diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py index 930875335d1..c1173f22338 100644 --- a/homeassistant/components/smlight/switch.py +++ b/homeassistant/components/smlight/switch.py @@ -54,6 +54,13 @@ SWITCHES: list[SmSwitchEntityDescription] = [ setting=Settings.ZB_AUTOUPDATE, state_fn=lambda x: x.auto_zigbee, ), + SmSwitchEntityDescription( + key="vpn_enabled", + translation_key="vpn_enabled", + setting=Settings.ENABLE_VPN, + entity_registry_enabled_default=False, + state_fn=lambda x: x.vpn_enabled, + ), ] diff --git a/tests/components/smlight/fixtures/info.json b/tests/components/smlight/fixtures/info.json index 8f1e718ca74..e3defb4410e 100644 --- a/tests/components/smlight/fixtures/info.json +++ b/tests/components/smlight/fixtures/info.json @@ -11,6 +11,7 @@ "sw_version": "v2.3.6", "wifi_mode": 0, "zb_flash_size": 704, + "zb_channel": 0, "zb_hw": "CC2652P7", "zb_ram_size": 152, "zb_version": "20240314", diff --git a/tests/components/smlight/fixtures/sensors.json b/tests/components/smlight/fixtures/sensors.json index 89ec5615f34..ea1fb9c1899 100644 --- a/tests/components/smlight/fixtures/sensors.json +++ b/tests/components/smlight/fixtures/sensors.json @@ -10,5 +10,7 @@ "wifi_status": 255, "disable_leds": false, "night_mode": true, - "auto_zigbee": false + "auto_zigbee": false, + "vpn_enabled": false, + "vpn_status": true } diff --git a/tests/components/smlight/snapshots/test_binary_sensor.ambr b/tests/components/smlight/snapshots/test_binary_sensor.ambr index 17dca1c9784..8becf5b2567 100644 --- a/tests/components/smlight/snapshots/test_binary_sensor.ambr +++ b/tests/components/smlight/snapshots/test_binary_sensor.ambr @@ -93,6 +93,53 @@ 'state': 'unknown', }) # --- +# name: test_all_binary_sensors[binary_sensor.mock_title_vpn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mock_title_vpn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VPN', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vpn', + 'unique_id': 'aa:bb:cc:dd:ee:ff_vpn', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_binary_sensors[binary_sensor.mock_title_vpn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Mock Title VPN', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_title_vpn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_binary_sensors[binary_sensor.mock_title_wi_fi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smlight/snapshots/test_switch.ambr b/tests/components/smlight/snapshots/test_switch.ambr index b8e1c8357ac..733d002be0f 100644 --- a/tests/components/smlight/snapshots/test_switch.ambr +++ b/tests/components/smlight/snapshots/test_switch.ambr @@ -140,3 +140,50 @@ 'state': 'on', }) # --- +# name: test_switch_setup[switch.mock_title_vpn_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.mock_title_vpn_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VPN enabled', + 'platform': 'smlight', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vpn_enabled', + 'unique_id': 'aa:bb:cc:dd:ee:ff-vpn_enabled', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_setup[switch.mock_title_vpn_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Mock Title VPN enabled', + }), + 'context': , + 'entity_id': 'switch.mock_title_vpn_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smlight/test_binary_sensor.py b/tests/components/smlight/test_binary_sensor.py index ce7d4e3ff6d..1b1c0358c37 100644 --- a/tests/components/smlight/test_binary_sensor.py +++ b/tests/components/smlight/test_binary_sensor.py @@ -62,11 +62,14 @@ async def test_disabled_by_default_sensors( """Test wifi sensor is disabled by default .""" await setup_integration(hass, mock_config_entry) - assert not hass.states.get("binary_sensor.mock_title_wi_fi") + for sensor in ("wi_fi", "vpn"): + assert not hass.states.get(f"binary_sensor.mock_title_{sensor}") - assert (entry := entity_registry.async_get("binary_sensor.mock_title_wi_fi")) - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert ( + entry := entity_registry.async_get(f"binary_sensor.mock_title_{sensor}") + ) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_internet_sensor_event( diff --git a/tests/components/smlight/test_switch.py b/tests/components/smlight/test_switch.py index a29dfbc35c2..a917a10da08 100644 --- a/tests/components/smlight/test_switch.py +++ b/tests/components/smlight/test_switch.py @@ -34,6 +34,7 @@ def platforms() -> list[Platform]: return [Platform.SWITCH] +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_switch_setup( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -46,12 +47,29 @@ async def test_switch_setup( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) +async def test_disabled_by_default_switch( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test vpn enabled switch is disabled by default .""" + await setup_integration(hass, mock_config_entry) + + assert not hass.states.get("switch.mock_title_vpn_enabled") + + assert (entry := entity_registry.async_get("switch.mock_title_vpn_enabled")) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("entity", "setting"), [ ("disable_leds", Settings.DISABLE_LEDS), ("led_night_mode", Settings.NIGHT_MODE), ("auto_zigbee_update", Settings.ZB_AUTOUPDATE), + ("vpn_enabled", Settings.ENABLE_VPN), ], ) async def test_switches( From 1ff69825e4bef6453d283739cf705b358af0d9df Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:41:49 +0200 Subject: [PATCH 0792/1309] Move rflink base entity to separate module (#126206) --- homeassistant/components/rflink/__init__.py | 311 +---------------- .../components/rflink/binary_sensor.py | 2 +- homeassistant/components/rflink/const.py | 1 + homeassistant/components/rflink/cover.py | 2 +- homeassistant/components/rflink/entity.py | 325 ++++++++++++++++++ homeassistant/components/rflink/light.py | 2 +- homeassistant/components/rflink/sensor.py | 2 +- homeassistant/components/rflink/switch.py | 2 +- tests/components/rflink/test_cover.py | 2 +- tests/components/rflink/test_light.py | 2 +- tests/components/rflink/test_switch.py | 2 +- 11 files changed, 338 insertions(+), 315 deletions(-) create mode 100644 homeassistant/components/rflink/entity.py diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 5f334e33fc1..7e86854dbce 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -6,36 +6,30 @@ import asyncio from collections import defaultdict import logging -from rflink.protocol import ProtocolBase, create_rflink_connection +from rflink.protocol import create_rflink_connection from serial import SerialException import voluptuous as vol from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_STATE, CONF_COMMAND, CONF_DEVICE_ID, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, - STATE_ON, ) from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from .const import ( DATA_DEVICE_REGISTER, + DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP, - DEFAULT_SIGNAL_REPETITIONS, EVENT_KEY_COMMAND, EVENT_KEY_ID, EVENT_KEY_SENSOR, @@ -43,7 +37,8 @@ from .const import ( SIGNAL_HANDLE_EVENT, TMP_ENTITY, ) -from .utils import brightness_to_rflink, identify_event_type +from .entity import RflinkCommand +from .utils import identify_event_type _LOGGER = logging.getLogger(__name__) @@ -52,13 +47,10 @@ CONF_RECONNECT_INTERVAL = "reconnect_interval" CONF_WAIT_FOR_ACK = "wait_for_ack" CONF_KEEPALIVE_IDLE = "tcp_keepalive_idle_timer" -DATA_ENTITY_GROUP_LOOKUP = "rflink_entity_group_only_lookup" DEFAULT_RECONNECT_INTERVAL = 10 DEFAULT_TCP_KEEPALIVE_IDLE_TIMER = 3600 CONNECTION_TIMEOUT = 10 -EVENT_BUTTON_PRESSED = "button_pressed" - RFLINK_GROUP_COMMANDS = ["allon", "alloff"] DOMAIN = "rflink" @@ -286,298 +278,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.async_create_task(connect(), eager_start=False) async_dispatcher_connect(hass, SIGNAL_EVENT, event_callback) return True - - -class RflinkDevice(Entity): - """Representation of a Rflink device. - - Contains the common logic for Rflink entities. - """ - - _state: bool | None = None - _available = True - _attr_should_poll = False - - def __init__( - self, - device_id, - initial_event=None, - name=None, - aliases=None, - group=True, - group_aliases=None, - nogroup_aliases=None, - fire_event=False, - signal_repetitions=DEFAULT_SIGNAL_REPETITIONS, - ): - """Initialize the device.""" - # Rflink specific attributes for every component type - self._initial_event = initial_event - self._device_id = device_id - self._attr_unique_id = device_id - if name: - self._name = name - else: - self._name = device_id - - self._aliases = aliases - self._group = group - self._group_aliases = group_aliases - self._nogroup_aliases = nogroup_aliases - self._should_fire_event = fire_event - self._signal_repetitions = signal_repetitions - - @callback - def handle_event_callback(self, event): - """Handle incoming event for device type.""" - # Call platform specific event handler - self._handle_event(event) - - # Propagate changes through ha - self.async_write_ha_state() - - # Put command onto bus for user to subscribe to - if self._should_fire_event and identify_event_type(event) == EVENT_KEY_COMMAND: - self.hass.bus.async_fire( - EVENT_BUTTON_PRESSED, - {ATTR_ENTITY_ID: self.entity_id, ATTR_STATE: event[EVENT_KEY_COMMAND]}, - ) - _LOGGER.debug( - "Fired bus event for %s: %s", self.entity_id, event[EVENT_KEY_COMMAND] - ) - - def _handle_event(self, event): - """Platform specific event handler.""" - raise NotImplementedError - - @property - def name(self): - """Return a name for the device.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - if self.assumed_state: - return False - return self._state - - @property - def assumed_state(self): - """Assume device state until first device event sets state.""" - return self._state is None - - @property - def available(self): - """Return True if entity is available.""" - return self._available - - @callback - def _availability_callback(self, availability): - """Update availability state.""" - self._available = availability - self.async_write_ha_state() - - async def async_added_to_hass(self): - """Register update callback.""" - await super().async_added_to_hass() - # Remove temporary bogus entity_id if added - tmp_entity = TMP_ENTITY.format(self._device_id) - if ( - tmp_entity - in self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][self._device_id] - ): - self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][ - self._device_id - ].remove(tmp_entity) - - # Register id and aliases - self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][self._device_id].append( - self.entity_id - ) - if self._group: - self.hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND][ - self._device_id - ].append(self.entity_id) - # aliases respond to both normal and group commands (allon/alloff) - if self._aliases: - for _id in self._aliases: - self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][_id].append( - self.entity_id - ) - self.hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND][_id].append( - self.entity_id - ) - # group_aliases only respond to group commands (allon/alloff) - if self._group_aliases: - for _id in self._group_aliases: - self.hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND][_id].append( - self.entity_id - ) - # nogroup_aliases only respond to normal commands - if self._nogroup_aliases: - for _id in self._nogroup_aliases: - self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][_id].append( - self.entity_id - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_AVAILABILITY, self._availability_callback - ) - ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - SIGNAL_HANDLE_EVENT.format(self.entity_id), - self.handle_event_callback, - ) - ) - - # Process the initial event now that the entity is created - if self._initial_event: - self.handle_event_callback(self._initial_event) - - -class RflinkCommand(RflinkDevice): - """Singleton class to make Rflink command interface available to entities. - - This class is to be inherited by every Entity class that is actionable - (switches/lights). It exposes the Rflink command interface for these - entities. - - The Rflink interface is managed as a class level and set during setup (and - reset on reconnect). - """ - - # Keep repetition tasks to cancel if state is changed before repetitions - # are sent - _repetition_task: asyncio.Task[None] | None = None - - _protocol: ProtocolBase | None = None - - _wait_ack: bool | None = None - - @classmethod - def set_rflink_protocol( - cls, protocol: ProtocolBase | None, wait_ack: bool | None = None - ) -> None: - """Set the Rflink asyncio protocol as a class variable.""" - cls._protocol = protocol - if wait_ack is not None: - cls._wait_ack = wait_ack - - @classmethod - def is_connected(cls): - """Return connection status.""" - return bool(cls._protocol) - - @classmethod - async def send_command(cls, device_id, action): - """Send device command to Rflink and wait for acknowledgement.""" - return await cls._protocol.send_command_ack(device_id, action) - - async def _async_handle_command(self, command, *args): - """Do bookkeeping for command, send it to rflink and update state.""" - self.cancel_queued_send_commands() - - if command == "turn_on": - cmd = "on" - self._state = True - - elif command == "turn_off": - cmd = "off" - self._state = False - - elif command == "dim": - # convert brightness to rflink dim level - cmd = str(brightness_to_rflink(args[0])) - self._state = True - - elif command == "toggle": - cmd = "on" - # if the state is unknown or false, it gets set as true - # if the state is true, it gets set as false - self._state = self._state in [None, False] - - # Cover options for RFlink - elif command == "close_cover": - cmd = "DOWN" - self._state = False - - elif command == "open_cover": - cmd = "UP" - self._state = True - - elif command == "stop_cover": - cmd = "STOP" - self._state = True - - # Send initial command and queue repetitions. - # This allows the entity state to be updated quickly and not having to - # wait for all repetitions to be sent - await self._async_send_command(cmd, self._signal_repetitions) - - # Update state of entity - self.async_write_ha_state() - - def cancel_queued_send_commands(self): - """Cancel queued signal repetition commands. - - For example when user changed state while repetitions are still - queued for broadcast. Or when an incoming Rflink command (remote - switch) changes the state. - """ - # cancel any outstanding tasks from the previous state change - if self._repetition_task: - self._repetition_task.cancel() - - async def _async_send_command(self, cmd, repetitions): - """Send a command for device to Rflink gateway.""" - _LOGGER.debug("Sending command: %s to Rflink device: %s", cmd, self._device_id) - - if not self.is_connected(): - raise HomeAssistantError("Cannot send command, not connected!") - - if self._wait_ack: - # Puts command on outgoing buffer then waits for Rflink to confirm - # the command has been sent out. - await self._protocol.send_command_ack(self._device_id, cmd) - else: - # Puts command on outgoing buffer and returns straight away. - # Rflink protocol/transport handles asynchronous writing of buffer - # to serial/tcp device. Does not wait for command send - # confirmation. - self._protocol.send_command(self._device_id, cmd) - - if repetitions > 1: - self._repetition_task = self.hass.async_create_task( - self._async_send_command(cmd, repetitions - 1), eager_start=False - ) - - -class SwitchableRflinkDevice(RflinkCommand, RestoreEntity): - """Rflink entity which can switch on/off (eg: light, switch).""" - - async def async_added_to_hass(self): - """Restore RFLink device state (ON/OFF).""" - await super().async_added_to_hass() - if (old_state := await self.async_get_last_state()) is not None: - self._state = old_state.state == STATE_ON - - def _handle_event(self, event): - """Adjust state if Rflink picks up a remote command for this device.""" - self.cancel_queued_send_commands() - - command = event["command"] - if command in ["on", "allon"]: - self._state = True - elif command in ["off", "alloff"]: - self._state = False - - async def async_turn_on(self, **kwargs): - """Turn the device on.""" - await self._async_handle_command("turn_on") - - async def async_turn_off(self, **kwargs): - """Turn the device off.""" - await self._async_handle_command("turn_off") diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py index 949130b54e6..29046ba7616 100644 --- a/homeassistant/components/rflink/binary_sensor.py +++ b/homeassistant/components/rflink/binary_sensor.py @@ -26,8 +26,8 @@ import homeassistant.helpers.event as evt from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import RflinkDevice from .const import CONF_ALIASES +from .entity import RflinkDevice CONF_OFF_DELAY = "off_delay" DEFAULT_FORCE_UPDATE = False diff --git a/homeassistant/components/rflink/const.py b/homeassistant/components/rflink/const.py index 80168a86f94..cc52ea978bd 100644 --- a/homeassistant/components/rflink/const.py +++ b/homeassistant/components/rflink/const.py @@ -16,6 +16,7 @@ CONF_FIRE_EVENT = "fire_event" CONF_SIGNAL_REPETITIONS = "signal_repetitions" DATA_DEVICE_REGISTER = "rflink_device_register" +DATA_ENTITY_GROUP_LOOKUP = "rflink_entity_group_only_lookup" DATA_ENTITY_LOOKUP = "rflink_entity_lookup" DEFAULT_SIGNAL_REPETITIONS = 1 diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index f1298367a4f..a6148ed7760 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -18,7 +18,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import RflinkCommand from .const import ( CONF_ALIASES, CONF_DEVICE_DEFAULTS, @@ -29,6 +28,7 @@ from .const import ( CONF_SIGNAL_REPETITIONS, DEVICE_DEFAULTS_SCHEMA, ) +from .entity import RflinkCommand _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rflink/entity.py b/homeassistant/components/rflink/entity.py new file mode 100644 index 00000000000..26153acf7ba --- /dev/null +++ b/homeassistant/components/rflink/entity.py @@ -0,0 +1,325 @@ +"""Support for Rflink devices.""" + +from __future__ import annotations + +import asyncio +import logging + +from rflink.protocol import ProtocolBase + +from homeassistant.const import ATTR_ENTITY_ID, ATTR_STATE, STATE_ON +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import ( + DATA_ENTITY_GROUP_LOOKUP, + DATA_ENTITY_LOOKUP, + DEFAULT_SIGNAL_REPETITIONS, + EVENT_KEY_COMMAND, + SIGNAL_AVAILABILITY, + SIGNAL_HANDLE_EVENT, + TMP_ENTITY, +) +from .utils import brightness_to_rflink, identify_event_type + +_LOGGER = logging.getLogger(__name__) + +EVENT_BUTTON_PRESSED = "button_pressed" + + +class RflinkDevice(Entity): + """Representation of a Rflink device. + + Contains the common logic for Rflink entities. + """ + + _state: bool | None = None + _available = True + _attr_should_poll = False + + def __init__( + self, + device_id, + initial_event=None, + name=None, + aliases=None, + group=True, + group_aliases=None, + nogroup_aliases=None, + fire_event=False, + signal_repetitions=DEFAULT_SIGNAL_REPETITIONS, + ): + """Initialize the device.""" + # Rflink specific attributes for every component type + self._initial_event = initial_event + self._device_id = device_id + self._attr_unique_id = device_id + if name: + self._name = name + else: + self._name = device_id + + self._aliases = aliases + self._group = group + self._group_aliases = group_aliases + self._nogroup_aliases = nogroup_aliases + self._should_fire_event = fire_event + self._signal_repetitions = signal_repetitions + + @callback + def handle_event_callback(self, event): + """Handle incoming event for device type.""" + # Call platform specific event handler + self._handle_event(event) + + # Propagate changes through ha + self.async_write_ha_state() + + # Put command onto bus for user to subscribe to + if self._should_fire_event and identify_event_type(event) == EVENT_KEY_COMMAND: + self.hass.bus.async_fire( + EVENT_BUTTON_PRESSED, + {ATTR_ENTITY_ID: self.entity_id, ATTR_STATE: event[EVENT_KEY_COMMAND]}, + ) + _LOGGER.debug( + "Fired bus event for %s: %s", self.entity_id, event[EVENT_KEY_COMMAND] + ) + + def _handle_event(self, event): + """Platform specific event handler.""" + raise NotImplementedError + + @property + def name(self): + """Return a name for the device.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + if self.assumed_state: + return False + return self._state + + @property + def assumed_state(self): + """Assume device state until first device event sets state.""" + return self._state is None + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @callback + def _availability_callback(self, availability): + """Update availability state.""" + self._available = availability + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Register update callback.""" + await super().async_added_to_hass() + # Remove temporary bogus entity_id if added + tmp_entity = TMP_ENTITY.format(self._device_id) + if ( + tmp_entity + in self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][self._device_id] + ): + self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][ + self._device_id + ].remove(tmp_entity) + + # Register id and aliases + self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][self._device_id].append( + self.entity_id + ) + if self._group: + self.hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND][ + self._device_id + ].append(self.entity_id) + # aliases respond to both normal and group commands (allon/alloff) + if self._aliases: + for _id in self._aliases: + self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][_id].append( + self.entity_id + ) + self.hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND][_id].append( + self.entity_id + ) + # group_aliases only respond to group commands (allon/alloff) + if self._group_aliases: + for _id in self._group_aliases: + self.hass.data[DATA_ENTITY_GROUP_LOOKUP][EVENT_KEY_COMMAND][_id].append( + self.entity_id + ) + # nogroup_aliases only respond to normal commands + if self._nogroup_aliases: + for _id in self._nogroup_aliases: + self.hass.data[DATA_ENTITY_LOOKUP][EVENT_KEY_COMMAND][_id].append( + self.entity_id + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, SIGNAL_AVAILABILITY, self._availability_callback + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_HANDLE_EVENT.format(self.entity_id), + self.handle_event_callback, + ) + ) + + # Process the initial event now that the entity is created + if self._initial_event: + self.handle_event_callback(self._initial_event) + + +class RflinkCommand(RflinkDevice): + """Singleton class to make Rflink command interface available to entities. + + This class is to be inherited by every Entity class that is actionable + (switches/lights). It exposes the Rflink command interface for these + entities. + + The Rflink interface is managed as a class level and set during setup (and + reset on reconnect). + """ + + # Keep repetition tasks to cancel if state is changed before repetitions + # are sent + _repetition_task: asyncio.Task[None] | None = None + + _protocol: ProtocolBase | None = None + + _wait_ack: bool | None = None + + @classmethod + def set_rflink_protocol( + cls, protocol: ProtocolBase | None, wait_ack: bool | None = None + ) -> None: + """Set the Rflink asyncio protocol as a class variable.""" + cls._protocol = protocol + if wait_ack is not None: + cls._wait_ack = wait_ack + + @classmethod + def is_connected(cls): + """Return connection status.""" + return bool(cls._protocol) + + @classmethod + async def send_command(cls, device_id, action): + """Send device command to Rflink and wait for acknowledgement.""" + return await cls._protocol.send_command_ack(device_id, action) + + async def _async_handle_command(self, command, *args): + """Do bookkeeping for command, send it to rflink and update state.""" + self.cancel_queued_send_commands() + + if command == "turn_on": + cmd = "on" + self._state = True + + elif command == "turn_off": + cmd = "off" + self._state = False + + elif command == "dim": + # convert brightness to rflink dim level + cmd = str(brightness_to_rflink(args[0])) + self._state = True + + elif command == "toggle": + cmd = "on" + # if the state is unknown or false, it gets set as true + # if the state is true, it gets set as false + self._state = self._state in [None, False] + + # Cover options for RFlink + elif command == "close_cover": + cmd = "DOWN" + self._state = False + + elif command == "open_cover": + cmd = "UP" + self._state = True + + elif command == "stop_cover": + cmd = "STOP" + self._state = True + + # Send initial command and queue repetitions. + # This allows the entity state to be updated quickly and not having to + # wait for all repetitions to be sent + await self._async_send_command(cmd, self._signal_repetitions) + + # Update state of entity + self.async_write_ha_state() + + def cancel_queued_send_commands(self): + """Cancel queued signal repetition commands. + + For example when user changed state while repetitions are still + queued for broadcast. Or when an incoming Rflink command (remote + switch) changes the state. + """ + # cancel any outstanding tasks from the previous state change + if self._repetition_task: + self._repetition_task.cancel() + + async def _async_send_command(self, cmd, repetitions): + """Send a command for device to Rflink gateway.""" + _LOGGER.debug("Sending command: %s to Rflink device: %s", cmd, self._device_id) + + if not self.is_connected(): + raise HomeAssistantError("Cannot send command, not connected!") + + if self._wait_ack: + # Puts command on outgoing buffer then waits for Rflink to confirm + # the command has been sent out. + await self._protocol.send_command_ack(self._device_id, cmd) + else: + # Puts command on outgoing buffer and returns straight away. + # Rflink protocol/transport handles asynchronous writing of buffer + # to serial/tcp device. Does not wait for command send + # confirmation. + self._protocol.send_command(self._device_id, cmd) + + if repetitions > 1: + self._repetition_task = self.hass.async_create_task( + self._async_send_command(cmd, repetitions - 1), eager_start=False + ) + + +class SwitchableRflinkDevice(RflinkCommand, RestoreEntity): + """Rflink entity which can switch on/off (eg: light, switch).""" + + async def async_added_to_hass(self): + """Restore RFLink device state (ON/OFF).""" + await super().async_added_to_hass() + if (old_state := await self.async_get_last_state()) is not None: + self._state = old_state.state == STATE_ON + + def _handle_event(self, event): + """Adjust state if Rflink picks up a remote command for this device.""" + self.cancel_queued_send_commands() + + command = event["command"] + if command in ["on", "allon"]: + self._state = True + elif command in ["off", "alloff"]: + self._state = False + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._async_handle_command("turn_on") + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._async_handle_command("turn_off") diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 68aa17778da..00117140abb 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -20,7 +20,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import SwitchableRflinkDevice from .const import ( CONF_ALIASES, CONF_AUTOMATIC_ADD, @@ -35,6 +34,7 @@ from .const import ( EVENT_KEY_COMMAND, EVENT_KEY_ID, ) +from .entity import SwitchableRflinkDevice from .utils import brightness_to_rflink, rflink_to_brightness _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index d89670f8a1b..68b7847423c 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -40,7 +40,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import RflinkDevice from .const import ( CONF_ALIASES, CONF_AUTOMATIC_ADD, @@ -53,6 +52,7 @@ from .const import ( SIGNAL_HANDLE_EVENT, TMP_ENTITY, ) +from .entity import RflinkDevice SENSOR_TYPES = ( # check new descriptors against PACKET_FIELDS & UNITS from rflink.parser diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index 9f85a391662..23b93896878 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -14,7 +14,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import SwitchableRflinkDevice from .const import ( CONF_ALIASES, CONF_DEVICE_DEFAULTS, @@ -25,6 +24,7 @@ from .const import ( CONF_SIGNAL_REPETITIONS, DEVICE_DEFAULTS_SCHEMA, ) +from .entity import SwitchableRflinkDevice PARALLEL_UPDATES = 0 diff --git a/tests/components/rflink/test_cover.py b/tests/components/rflink/test_cover.py index 0f14e76620f..af61cc698e0 100644 --- a/tests/components/rflink/test_cover.py +++ b/tests/components/rflink/test_cover.py @@ -7,7 +7,7 @@ control of RFLink cover devices. import pytest -from homeassistant.components.rflink import EVENT_BUTTON_PRESSED +from homeassistant.components.rflink.entity import EVENT_BUTTON_PRESSED from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, diff --git a/tests/components/rflink/test_light.py b/tests/components/rflink/test_light.py index ceb2b19e192..e76d5b4f783 100644 --- a/tests/components/rflink/test_light.py +++ b/tests/components/rflink/test_light.py @@ -8,7 +8,7 @@ control of RFLink switch devices. import pytest from homeassistant.components.light import ATTR_BRIGHTNESS -from homeassistant.components.rflink import EVENT_BUTTON_PRESSED +from homeassistant.components.rflink.entity import EVENT_BUTTON_PRESSED from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, diff --git a/tests/components/rflink/test_switch.py b/tests/components/rflink/test_switch.py index 2aab145f847..f81c41f03d5 100644 --- a/tests/components/rflink/test_switch.py +++ b/tests/components/rflink/test_switch.py @@ -7,7 +7,7 @@ control of Rflink switch devices. import pytest -from homeassistant.components.rflink import EVENT_BUTTON_PRESSED +from homeassistant.components.rflink.entity import EVENT_BUTTON_PRESSED from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, From 116733e1a5eff859c9f0eb3052280ece683ee45b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 11:42:16 +0200 Subject: [PATCH 0793/1309] Rename onewire base entity module (#126129) Move onewire base entity to separate module --- homeassistant/components/onewire/binary_sensor.py | 2 +- .../components/onewire/{onewire_entities.py => entity.py} | 0 homeassistant/components/onewire/sensor.py | 2 +- homeassistant/components/onewire/switch.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename homeassistant/components/onewire/{onewire_entities.py => entity.py} (100%) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 82cdb1936f7..5607fd7ed1d 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import OneWireConfigEntry from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL -from .onewire_entities import OneWireEntity, OneWireEntityDescription +from .entity import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub diff --git a/homeassistant/components/onewire/onewire_entities.py b/homeassistant/components/onewire/entity.py similarity index 100% rename from homeassistant/components/onewire/onewire_entities.py rename to homeassistant/components/onewire/entity.py diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index b7d7e3ddbe9..c9030cab8ea 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -38,7 +38,7 @@ from .const import ( READ_MODE_FLOAT, READ_MODE_INT, ) -from .onewire_entities import OneWireEntity, OneWireEntityDescription +from .entity import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 11bcbff5970..ec0bc44e03f 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import OneWireConfigEntry from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL -from .onewire_entities import OneWireEntity, OneWireEntityDescription +from .entity import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub From b74a6a64bc6a0233130698821066deabf82ccf2c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:14:20 +0200 Subject: [PATCH 0794/1309] Rename roomba base entity module (#126134) * Move roomba base entity to separate module * Simplify --- homeassistant/components/roomba/binary_sensor.py | 2 +- homeassistant/components/roomba/braava.py | 2 +- homeassistant/components/roomba/{irobot_base.py => entity.py} | 0 homeassistant/components/roomba/roomba.py | 2 +- homeassistant/components/roomba/sensor.py | 2 +- homeassistant/components/roomba/vacuum.py | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename homeassistant/components/roomba/{irobot_base.py => entity.py} (100%) diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index 40a5535d5af..baf66375036 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import roomba_reported_state from .const import DOMAIN -from .irobot_base import IRobotEntity +from .entity import IRobotEntity from .models import RoombaData diff --git a/homeassistant/components/roomba/braava.py b/homeassistant/components/roomba/braava.py index 37411680d0b..6a62a715a8a 100644 --- a/homeassistant/components/roomba/braava.py +++ b/homeassistant/components/roomba/braava.py @@ -4,7 +4,7 @@ import logging from homeassistant.components.vacuum import VacuumEntityFeature -from .irobot_base import SUPPORT_IROBOT, IRobotVacuum +from .entity import SUPPORT_IROBOT, IRobotVacuum _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/entity.py similarity index 100% rename from homeassistant/components/roomba/irobot_base.py rename to homeassistant/components/roomba/entity.py diff --git a/homeassistant/components/roomba/roomba.py b/homeassistant/components/roomba/roomba.py index 5d774120634..a26f1912831 100644 --- a/homeassistant/components/roomba/roomba.py +++ b/homeassistant/components/roomba/roomba.py @@ -4,7 +4,7 @@ import logging from homeassistant.components.vacuum import VacuumEntityFeature -from .irobot_base import SUPPORT_IROBOT, IRobotVacuum +from .entity import SUPPORT_IROBOT, IRobotVacuum _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index e0aaf5d8c6e..87e97fdb760 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .irobot_base import IRobotEntity +from .entity import IRobotEntity from .models import RoombaData diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index e4a83375ccc..a45b8eea632 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -9,7 +9,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import roomba_reported_state from .braava import BraavaJet from .const import DOMAIN -from .irobot_base import IRobotVacuum +from .entity import IRobotVacuum from .models import RoombaData from .roomba import RoombaVacuum, RoombaVacuumCarpetBoost From adf25b427b972caf6079403c5e1532d854c1c97b Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Wed, 18 Sep 2024 11:29:50 +0100 Subject: [PATCH 0795/1309] Broaden scope of ConfigEntryNotReady in Mealie (#126208) Broaden scope of ConfigEntryNotReady --- homeassistant/components/mealie/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index bf0fbcac406..443c8fdd991 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from aiomealie import MealieAuthenticationError, MealieClient, MealieConnectionError +from aiomealie import MealieAuthenticationError, MealieClient, MealieError from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant @@ -53,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MealieConfigEntry) -> bo version = create_version(about.version) except MealieAuthenticationError as error: raise ConfigEntryAuthFailed from error - except MealieConnectionError as error: + except MealieError as error: raise ConfigEntryNotReady(error) from error if not version.valid: From 39e720caed2bbf9c37472b360cf8aeedb11241b9 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 18 Sep 2024 12:39:50 +0200 Subject: [PATCH 0796/1309] Use debug/warning instead of info log level in components [t] (#126147) --- homeassistant/components/tank_utility/sensor.py | 2 +- homeassistant/components/telegram_bot/__init__.py | 2 +- homeassistant/components/telegram_bot/webhooks.py | 2 +- homeassistant/components/tellduslive/config_flow.py | 4 ++-- homeassistant/components/tellduslive/light.py | 2 +- homeassistant/components/tellstick/__init__.py | 2 +- homeassistant/components/tensorflow/image_processing.py | 2 +- homeassistant/components/tesla_fleet/__init__.py | 2 +- homeassistant/components/thomson/device_tracker.py | 2 +- homeassistant/components/tile/__init__.py | 2 +- homeassistant/components/tile/device_tracker.py | 2 +- homeassistant/components/tomato/device_tracker.py | 2 +- homeassistant/components/toon/coordinator.py | 2 +- homeassistant/components/tplink/entity.py | 2 +- homeassistant/components/tractive/__init__.py | 2 +- homeassistant/components/twilio_call/notify.py | 2 +- homeassistant/components/twilio_sms/notify.py | 2 +- homeassistant/components/twinkly/light.py | 4 ++-- 18 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 9bdcc1b6f4f..6d4327a1d06 100644 --- a/homeassistant/components/tank_utility/sensor.py +++ b/homeassistant/components/tank_utility/sensor.py @@ -125,7 +125,7 @@ class TankUtilitySensor(SensorEntity): requests.codes.unauthorized, requests.codes.bad_request, ): - _LOGGER.info("Getting new token") + _LOGGER.debug("Getting new token") self._token = auth.get_token(self._email, self._password, force=True) data = tank_monitor.get_device_data(self._token, self.device) else: diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 2d53c744c22..64e2517a40b 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -384,7 +384,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: platform = platforms[p_type] - _LOGGER.info("Setting up %s.%s", DOMAIN, p_type) + _LOGGER.debug("Setting up %s.%s", DOMAIN, p_type) try: receiver_service = await platform.async_setup_platform(hass, bot, p_config) if receiver_service is False: diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 41835f955ed..3eb3c71a0bb 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -112,7 +112,7 @@ class PushBot(BaseTelegramBotEntity): if current_status and current_status["url"] != self.webhook_url: result = await self._try_to_set_webhook() if result: - _LOGGER.info("Set new telegram webhook %s", self.webhook_url) + _LOGGER.debug("Set new telegram webhook %s", self.webhook_url) else: _LOGGER.error("Set telegram webhook failed %s", self.webhook_url) return False diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py index 3bbb34912f9..365a363ca28 100644 --- a/homeassistant/components/tellduslive/config_flow.py +++ b/homeassistant/components/tellduslive/config_flow.py @@ -124,9 +124,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): """Run when a Tellstick is discovered.""" await self._async_handle_discovery_without_unique_id() - _LOGGER.info("Discovered tellstick device: %s", discovery_info) + _LOGGER.debug("Discovered tellstick device: %s", discovery_info) if supports_local_api(discovery_info[1]): - _LOGGER.info("%s support local API", discovery_info[1]) + _LOGGER.debug("%s support local API", discovery_info[1]) self._hosts.append(discovery_info[0]) return await self.async_step_user() diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 753e9cf9476..005bf97d8c0 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -67,7 +67,7 @@ class TelldusLiveLight(TelldusLiveEntity, LightEntity): brightness = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness) if brightness == 0: fallback_brightness = 100 - _LOGGER.info( + _LOGGER.debug( "Setting brightness to %d%%, because it was 0", fallback_brightness ) brightness = int(fallback_brightness * 255 / 100) diff --git a/homeassistant/components/tellstick/__init__.py b/homeassistant/components/tellstick/__init__.py index 8fae04dd9ce..9d120b7aaa8 100644 --- a/homeassistant/components/tellstick/__init__.py +++ b/homeassistant/components/tellstick/__init__.py @@ -51,7 +51,7 @@ def _discover(hass, config, component_name, found_tellcore_devices): if not found_tellcore_devices: return - _LOGGER.info( + _LOGGER.debug( "Discovered %d new %s devices", len(found_tellcore_devices), component_name ) diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index f13c0b24d0b..cf8e293161a 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -330,7 +330,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): ) for path in paths: - _LOGGER.info("Saving results image to %s", path) + _LOGGER.debug("Saving results image to %s", path) os.makedirs(os.path.dirname(path), exist_ok=True) img.save(path) diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index bfd1c8907ed..117756c8977 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -100,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - raise ConfigEntryAuthFailed from e except InvalidRegion: try: - LOGGER.info("Region is invalid, trying to find the correct region") + LOGGER.warning("Region is invalid, trying to find the correct region") await tesla.find_server() try: products = (await tesla.products())["response"] diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index f1da5f19f91..abf3e604472 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -82,7 +82,7 @@ class ThomsonDeviceScanner(DeviceScanner): if not self.success_init: return False - _LOGGER.info("Checking ARP") + _LOGGER.debug("Checking ARP") if not (data := self.get_thomson_data()): return False diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py index 7dbeea1a4f3..7fd5afcea7d 100644 --- a/homeassistant/components/tile/__init__.py +++ b/homeassistant/components/tile/__init__.py @@ -89,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except InvalidAuthError as err: raise ConfigEntryAuthFailed("Invalid credentials") from err except SessionExpiredError: - LOGGER.info("Tile session expired; creating a new one") + LOGGER.debug("Tile session expired; creating a new one") await client.async_init() except TileError as err: raise UpdateFailed(f"Error while retrieving data: {err}") from err diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index b33c2c592b8..270922b91d5 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -71,7 +71,7 @@ async def async_setup_scanner( ) ) - _LOGGER.info( + _LOGGER.debug( "Your Tile configuration has been imported into the UI; " "please remove it from configuration.yaml" ) diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index f1527f52c64..b705363944f 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -96,7 +96,7 @@ class TomatoDeviceScanner(DeviceScanner): Return boolean if scanning successful. """ - _LOGGER.info("Scanning") + _LOGGER.debug("Scanning") try: if self.ssl: diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index 85ea53de705..586eca34959 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -90,7 +90,7 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): await self.toon.subscribe_webhook( application_id=self.entry.entry_id, url=webhook_url ) - _LOGGER.info("Registered Toon webhook: %s", webhook_url) + _LOGGER.debug("Registered Toon webhook: %s", webhook_url) except ToonError as err: _LOGGER.error("Error during webhook registration - %s", err) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index beb71d4e5ce..4155878b8fe 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -319,7 +319,7 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC): and desc.entity_registry_enabled_default, ) - _LOGGER.info( + _LOGGER.debug( "Device feature: %s (%s) needs an entity description defined in HA", feature.name, feature.id, diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 4f0de7b14cd..8bc2d11d047 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -136,7 +136,7 @@ async def _generate_trackables( return None if "details" not in trackable: - _LOGGER.info( + _LOGGER.warning( "Tracker %s has no details and will be skipped. This happens for shared trackers", trackable["device_id"], ) diff --git a/homeassistant/components/twilio_call/notify.py b/homeassistant/components/twilio_call/notify.py index 5338bb59a79..ab79ea9692d 100644 --- a/homeassistant/components/twilio_call/notify.py +++ b/homeassistant/components/twilio_call/notify.py @@ -53,7 +53,7 @@ class TwilioCallNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Call to specified target users.""" if not (targets := kwargs.get(ATTR_TARGET)): - _LOGGER.info("At least 1 target is required") + _LOGGER.warning("At least 1 target is required") return if message.startswith(("http://", "https://")): diff --git a/homeassistant/components/twilio_sms/notify.py b/homeassistant/components/twilio_sms/notify.py index d1e2ca2888f..531fadcf259 100644 --- a/homeassistant/components/twilio_sms/notify.py +++ b/homeassistant/components/twilio_sms/notify.py @@ -66,7 +66,7 @@ class TwilioSMSNotificationService(BaseNotificationService): twilio_args[ATTR_MEDIAURL] = data[ATTR_MEDIAURL] if not targets: - _LOGGER.info("At least 1 target is required") + _LOGGER.warning("At least 1 target is required") return for target in targets: diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 2749c9a7764..6f6dffe63d2 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -280,7 +280,7 @@ class TwinklyLight(LightEntity): await self.async_update_current_movie() if not self._attr_available: - _LOGGER.info("Twinkly '%s' is now available", self._client.host) + _LOGGER.warning("Twinkly '%s' is now available", self._client.host) # We don't use the echo API to track the availability since # we already have to pull the device to get its state. @@ -289,7 +289,7 @@ class TwinklyLight(LightEntity): # We log this as "info" as it's pretty common that the Christmas # light are not reachable in July if self._attr_available: - _LOGGER.info( + _LOGGER.warning( "Twinkly '%s' is not reachable (client error)", self._client.host ) self._attr_available = False From ec2db3851681107ea1654bb766ac56b4b12fd39c Mon Sep 17 00:00:00 2001 From: tdfountain <174762217+tdfountain@users.noreply.github.com> Date: Wed, 18 Sep 2024 04:16:35 -0700 Subject: [PATCH 0797/1309] Move input current from diagnostic to regular sensor in NUT (#124183) Move input current from Diagnostic Co-authored-by: Shay Levy --- homeassistant/components/nut/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index d2398a560b7..7f211d5452b 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -658,7 +658,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), "input.L1.current": SensorEntityDescription( From a10d68e63e851311ca96fa1e681fe9d43d744929 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Sep 2024 13:50:36 +0200 Subject: [PATCH 0798/1309] Fix device cleanup in plugwise (#126212) --- .../components/plugwise/coordinator.py | 24 ++++++++++++------- tests/components/plugwise/test_init.py | 10 ++++---- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 8958ecae930..9a47bef8d9a 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -107,16 +107,22 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): # via_device cannot be None, this will result in the deletion # of other Plugwise Gateways when present! via_device: str = "" + + # First find the Plugwise via_device for device_entry in device_list: - if device_entry.identifiers: - item = list(list(device_entry.identifiers)[0]) - if item[0] == DOMAIN: - # First find the Plugwise via_device, this is always the first device - if item[1] == data.gateway[GATEWAY_ID]: - via_device = device_entry.id - elif ( # then remove the connected orphaned device(s) + for identifier in device_entry.identifiers: + if identifier[0] != DOMAIN or identifier[1] != data.gateway[GATEWAY_ID]: + continue + via_device = device_entry.id + break + + # Then remove the connected orphaned device(s) + for device_entry in device_list: + for identifier in device_entry.identifiers: + if identifier[0] == DOMAIN: + if ( device_entry.via_device_id == via_device - and item[1] not in data.devices + and identifier[1] not in data.devices ): device_reg.async_update_device( device_entry.id, remove_config_entry_id=entry.entry_id @@ -125,5 +131,5 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): "Removed %s device %s %s from device_registry", DOMAIN, device_entry.model, - item[1], + identifier[1], ) diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py index 65c9fb6c5a5..5b276d5018d 100644 --- a/tests/components/plugwise/test_init.py +++ b/tests/components/plugwise/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from plugwise.exceptions import ( ConnectionFailedError, InvalidAuthentication, @@ -19,7 +20,6 @@ from homeassistant.const import Platform 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 homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -231,9 +231,9 @@ async def test_update_device( mock_smile_adam_2: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, ) -> None: """Test a clean-up of the device_registry.""" - utcnow = dt_util.utcnow() data = mock_smile_adam_2.async_update.return_value mock_config_entry.add_to_hass(hass) @@ -260,7 +260,8 @@ async def test_update_device( # Add a 2nd Tom/Floor data.devices.update(TOM) with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - async_fire_time_changed(hass, utcnow + timedelta(minutes=1)) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( @@ -287,7 +288,8 @@ async def test_update_device( # Remove the existing Tom/Floor data.devices.pop("1772a4ea304041adb83f357b751341ff") with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - async_fire_time_changed(hass, utcnow + timedelta(minutes=1)) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert ( From 6bff6b562af536225fce5a03bca276070cacb018 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 18 Sep 2024 14:51:05 +0200 Subject: [PATCH 0799/1309] Add ThirdReality Matter NightLight to transition exception list (#126216) --- homeassistant/components/matter/light.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index bcac945562a..d334979b7c8 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -67,6 +67,7 @@ TRANSITION_BLOCKLIST = ( (5009, 514, "1.0", "1.0.0"), (5010, 769, "3.0", "1.0.0"), (5130, 544, "v0.4", "6.7.196e9d4e08-14"), + (5127, 4232, "ver_0.1", "v1.00.51"), ) From 139765995ecebf9b70be3559423fbe80d546be49 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 18 Sep 2024 23:19:44 +1000 Subject: [PATCH 0800/1309] Bump tesla-fleet-api to 0.7.8 (#126164) bump --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 29966b3b49c..f83f4f93e3c 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], "quality_scale": "gold", - "requirements": ["tesla-fleet-api==0.7.3"] + "requirements": ["tesla-fleet-api==0.7.8"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 1780d9f0a10..715c6cd2159 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], "quality_scale": "platinum", - "requirements": ["tesla-fleet-api==0.7.3"] + "requirements": ["tesla-fleet-api==0.7.8"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index c921921a0ca..d9f2cea9618 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], "quality_scale": "platinum", - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.7.3"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.7.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index cfea05041c2..86dbe806bb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2785,7 +2785,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.7.3 +tesla-fleet-api==0.7.8 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea78e9dbdba..1b97bee9614 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2207,7 +2207,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.7.3 +tesla-fleet-api==0.7.8 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From ac93570476af1ce442aeab7ac947b06bb1c1e072 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 18 Sep 2024 16:11:29 +0200 Subject: [PATCH 0801/1309] Remove LG Thinq (#125900) --- CODEOWNERS | 2 - homeassistant/brands/lg.json | 2 +- homeassistant/components/lg_thinq/__init__.py | 101 ---------- .../components/lg_thinq/binary_sensor.py | 181 ------------------ .../components/lg_thinq/config_flow.py | 103 ---------- homeassistant/components/lg_thinq/const.py | 12 -- .../components/lg_thinq/coordinator.py | 69 ------- homeassistant/components/lg_thinq/entity.py | 115 ----------- homeassistant/components/lg_thinq/icons.json | 44 ----- .../components/lg_thinq/manifest.json | 11 -- .../components/lg_thinq/strings.json | 63 ------ homeassistant/components/lg_thinq/switch.py | 107 ----------- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/lg_thinq/__init__.py | 1 - tests/components/lg_thinq/conftest.py | 86 --------- tests/components/lg_thinq/const.py | 8 - tests/components/lg_thinq/test_config_flow.py | 66 ------- 20 files changed, 1 insertion(+), 983 deletions(-) delete mode 100644 homeassistant/components/lg_thinq/__init__.py delete mode 100644 homeassistant/components/lg_thinq/binary_sensor.py delete mode 100644 homeassistant/components/lg_thinq/config_flow.py delete mode 100644 homeassistant/components/lg_thinq/const.py delete mode 100644 homeassistant/components/lg_thinq/coordinator.py delete mode 100644 homeassistant/components/lg_thinq/entity.py delete mode 100644 homeassistant/components/lg_thinq/icons.json delete mode 100644 homeassistant/components/lg_thinq/manifest.json delete mode 100644 homeassistant/components/lg_thinq/strings.json delete mode 100644 homeassistant/components/lg_thinq/switch.py delete mode 100644 tests/components/lg_thinq/__init__.py delete mode 100644 tests/components/lg_thinq/conftest.py delete mode 100644 tests/components/lg_thinq/const.py delete mode 100644 tests/components/lg_thinq/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 13981b3f6f8..10feb81b2ea 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -817,8 +817,6 @@ build.json @home-assistant/supervisor /tests/components/lektrico/ @lektrico /homeassistant/components/lg_netcast/ @Drafteed @splinter98 /tests/components/lg_netcast/ @Drafteed @splinter98 -/homeassistant/components/lg_thinq/ @LG-ThinQ-Integration -/tests/components/lg_thinq/ @LG-ThinQ-Integration /homeassistant/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob /homeassistant/components/lifx/ @Djelibeybi diff --git a/homeassistant/brands/lg.json b/homeassistant/brands/lg.json index 6b706685f1f..350db80b5f3 100644 --- a/homeassistant/brands/lg.json +++ b/homeassistant/brands/lg.json @@ -1,5 +1,5 @@ { "domain": "lg", "name": "LG", - "integrations": ["lg_netcast", "lg_thinq", "lg_soundbar", "webostv"] + "integrations": ["lg_netcast", "lg_soundbar", "webostv"] } diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py deleted file mode 100644 index 625938564a8..00000000000 --- a/homeassistant/components/lg_thinq/__init__.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Support for LG ThinQ Connect device.""" - -from __future__ import annotations - -import asyncio -import logging - -from thinqconnect import ThinQApi, ThinQAPIException -from thinqconnect.integration import async_get_ha_bridge_list - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY, Platform -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession - -from .const import CONF_CONNECT_CLIENT_ID -from .coordinator import DeviceDataUpdateCoordinator, async_setup_device_coordinator - -type ThinqConfigEntry = ConfigEntry[dict[str, DeviceDataUpdateCoordinator]] - -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: ThinqConfigEntry) -> bool: - """Set up an entry.""" - entry.runtime_data = {} - - access_token = entry.data[CONF_ACCESS_TOKEN] - client_id = entry.data[CONF_CONNECT_CLIENT_ID] - country_code = entry.data[CONF_COUNTRY] - - thinq_api = ThinQApi( - session=async_get_clientsession(hass), - access_token=access_token, - country_code=country_code, - client_id=client_id, - ) - - # Setup coordinators and register devices. - await async_setup_coordinators(hass, entry, thinq_api) - - # Set up all platforms for this device/entry. - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - # Clean up devices they are no longer in use. - async_cleanup_device_registry(hass, entry) - - return True - - -async def async_setup_coordinators( - hass: HomeAssistant, - entry: ThinqConfigEntry, - thinq_api: ThinQApi, -) -> None: - """Set up coordinators and register devices.""" - # Get a list of ha bridge. - try: - bridge_list = await async_get_ha_bridge_list(thinq_api) - except ThinQAPIException as exc: - raise ConfigEntryNotReady(exc.message) from exc - - if not bridge_list: - return - - # Setup coordinator per device. - task_list = [ - hass.async_create_task(async_setup_device_coordinator(hass, bridge)) - for bridge in bridge_list - ] - task_result = await asyncio.gather(*task_list) - for coordinator in task_result: - entry.runtime_data[coordinator.unique_id] = coordinator - - -@callback -def async_cleanup_device_registry(hass: HomeAssistant, entry: ThinqConfigEntry) -> None: - """Clean up device registry.""" - new_device_unique_ids = [ - coordinator.unique_id for coordinator in entry.runtime_data.values() - ] - device_registry = dr.async_get(hass) - existing_entries = dr.async_entries_for_config_entry( - device_registry, entry.entry_id - ) - - # Remove devices that are no longer exist. - for old_entry in existing_entries: - old_unique_id = next(iter(old_entry.identifiers))[1] - if old_unique_id not in new_device_unique_ids: - device_registry.async_remove_device(old_entry.id) - _LOGGER.debug("Remove device_registry: device_id=%s", old_entry.id) - - -async def async_unload_entry(hass: HomeAssistant, entry: ThinqConfigEntry) -> bool: - """Unload the entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lg_thinq/binary_sensor.py b/homeassistant/components/lg_thinq/binary_sensor.py deleted file mode 100644 index 596f808ed89..00000000000 --- a/homeassistant/components/lg_thinq/binary_sensor.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Support for binary sensor entities.""" - -from __future__ import annotations - -from dataclasses import dataclass -import logging - -from thinqconnect import DeviceType -from thinqconnect.devices.const import Property as ThinQProperty -from thinqconnect.integration import ActiveMode - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import ThinqConfigEntry -from .entity import ThinQEntity - - -@dataclass(frozen=True, kw_only=True) -class ThinQBinarySensorEntityDescription(BinarySensorEntityDescription): - """Describes ThinQ sensor entity.""" - - on_key: str | None = None - - -BINARY_SENSOR_DESC: dict[ThinQProperty, ThinQBinarySensorEntityDescription] = { - ThinQProperty.RINSE_REFILL: ThinQBinarySensorEntityDescription( - key=ThinQProperty.RINSE_REFILL, - translation_key=ThinQProperty.RINSE_REFILL, - ), - ThinQProperty.ECO_FRIENDLY_MODE: ThinQBinarySensorEntityDescription( - key=ThinQProperty.ECO_FRIENDLY_MODE, - translation_key=ThinQProperty.ECO_FRIENDLY_MODE, - ), - ThinQProperty.POWER_SAVE_ENABLED: ThinQBinarySensorEntityDescription( - key=ThinQProperty.POWER_SAVE_ENABLED, - translation_key=ThinQProperty.POWER_SAVE_ENABLED, - ), - ThinQProperty.REMOTE_CONTROL_ENABLED: ThinQBinarySensorEntityDescription( - key=ThinQProperty.REMOTE_CONTROL_ENABLED, - translation_key=ThinQProperty.REMOTE_CONTROL_ENABLED, - ), - ThinQProperty.SABBATH_MODE: ThinQBinarySensorEntityDescription( - key=ThinQProperty.SABBATH_MODE, - translation_key=ThinQProperty.SABBATH_MODE, - ), - ThinQProperty.DOOR_STATE: ThinQBinarySensorEntityDescription( - key=ThinQProperty.DOOR_STATE, - device_class=BinarySensorDeviceClass.DOOR, - on_key="open", - ), - ThinQProperty.MACHINE_CLEAN_REMINDER: ThinQBinarySensorEntityDescription( - key=ThinQProperty.MACHINE_CLEAN_REMINDER, - translation_key=ThinQProperty.MACHINE_CLEAN_REMINDER, - on_key="mcreminder_on", - ), - ThinQProperty.SIGNAL_LEVEL: ThinQBinarySensorEntityDescription( - key=ThinQProperty.SIGNAL_LEVEL, - translation_key=ThinQProperty.SIGNAL_LEVEL, - on_key="signallevel_on", - ), - ThinQProperty.CLEAN_LIGHT_REMINDER: ThinQBinarySensorEntityDescription( - key=ThinQProperty.CLEAN_LIGHT_REMINDER, - translation_key=ThinQProperty.CLEAN_LIGHT_REMINDER, - on_key="cleanlreminder_on", - ), - ThinQProperty.HOOD_OPERATION_MODE: ThinQBinarySensorEntityDescription( - key=ThinQProperty.HOOD_OPERATION_MODE, - translation_key="operation_mode", - on_key="power_on", - ), - ThinQProperty.WATER_HEATER_OPERATION_MODE: ThinQBinarySensorEntityDescription( - key=ThinQProperty.WATER_HEATER_OPERATION_MODE, - translation_key="operation_mode", - on_key="power_on", - ), - ThinQProperty.ONE_TOUCH_FILTER: ThinQBinarySensorEntityDescription( - key=ThinQProperty.ONE_TOUCH_FILTER, - translation_key=ThinQProperty.ONE_TOUCH_FILTER, - on_key="on", - ), -} - -DEVICE_TYPE_BINARY_SENSOR_MAP: dict[ - DeviceType, tuple[ThinQBinarySensorEntityDescription, ...] -] = { - DeviceType.COOKTOP: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), - DeviceType.DISH_WASHER: ( - BINARY_SENSOR_DESC[ThinQProperty.DOOR_STATE], - BINARY_SENSOR_DESC[ThinQProperty.RINSE_REFILL], - BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], - BINARY_SENSOR_DESC[ThinQProperty.MACHINE_CLEAN_REMINDER], - BINARY_SENSOR_DESC[ThinQProperty.SIGNAL_LEVEL], - BINARY_SENSOR_DESC[ThinQProperty.CLEAN_LIGHT_REMINDER], - ), - DeviceType.DRYER: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), - DeviceType.HOOD: (BINARY_SENSOR_DESC[ThinQProperty.HOOD_OPERATION_MODE],), - DeviceType.OVEN: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), - DeviceType.REFRIGERATOR: ( - BINARY_SENSOR_DESC[ThinQProperty.DOOR_STATE], - BINARY_SENSOR_DESC[ThinQProperty.ECO_FRIENDLY_MODE], - BINARY_SENSOR_DESC[ThinQProperty.POWER_SAVE_ENABLED], - BINARY_SENSOR_DESC[ThinQProperty.SABBATH_MODE], - ), - DeviceType.KIMCHI_REFRIGERATOR: ( - BINARY_SENSOR_DESC[ThinQProperty.ONE_TOUCH_FILTER], - ), - DeviceType.STYLER: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), - DeviceType.WASHCOMBO_MAIN: ( - BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], - ), - DeviceType.WASHCOMBO_MINI: ( - BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], - ), - DeviceType.WASHER: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), - DeviceType.WASHTOWER_DRYER: ( - BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], - ), - DeviceType.WASHTOWER: (BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED],), - DeviceType.WASHTOWER_WASHER: ( - BINARY_SENSOR_DESC[ThinQProperty.REMOTE_CONTROL_ENABLED], - ), - DeviceType.WATER_HEATER: ( - BINARY_SENSOR_DESC[ThinQProperty.WATER_HEATER_OPERATION_MODE], - ), - DeviceType.WINE_CELLAR: (BINARY_SENSOR_DESC[ThinQProperty.SABBATH_MODE],), -} -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up an entry for binary sensor platform.""" - entities: list[ThinQBinarySensorEntity] = [] - for coordinator in entry.runtime_data.values(): - if ( - descriptions := DEVICE_TYPE_BINARY_SENSOR_MAP.get( - coordinator.api.device.device_type - ) - ) is not None: - for description in descriptions: - entities.extend( - ThinQBinarySensorEntity(coordinator, description, property_id) - for property_id in coordinator.api.get_active_idx( - description.key, ActiveMode.READ_ONLY - ) - ) - - if entities: - async_add_entities(entities) - - -class ThinQBinarySensorEntity(ThinQEntity, BinarySensorEntity): - """Represent a thinq binary sensor platform.""" - - entity_description: ThinQBinarySensorEntityDescription - - def _update_status(self) -> None: - """Update status itself.""" - super()._update_status() - - if (key := self.entity_description.on_key) is not None: - self._attr_is_on = self.data.value == key - else: - self._attr_is_on = self.data.is_on - - _LOGGER.debug( - "[%s:%s] update status: %s -> %s", - self.coordinator.device_name, - self.property_id, - self.data.value, - self.is_on, - ) diff --git a/homeassistant/components/lg_thinq/config_flow.py b/homeassistant/components/lg_thinq/config_flow.py deleted file mode 100644 index cdb41916688..00000000000 --- a/homeassistant/components/lg_thinq/config_flow.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Config flow for LG ThinQ.""" - -from __future__ import annotations - -import logging -from typing import Any -import uuid - -from thinqconnect import ThinQApi, ThinQAPIException -from thinqconnect.country import Country -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import CountrySelector, CountrySelectorConfig - -from .const import ( - CLIENT_PREFIX, - CONF_CONNECT_CLIENT_ID, - DEFAULT_COUNTRY, - DOMAIN, - THINQ_DEFAULT_NAME, - THINQ_PAT_URL, -) - -SUPPORTED_COUNTRIES = [country.value for country in Country] - -_LOGGER = logging.getLogger(__name__) - - -class ThinQFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a config flow.""" - - VERSION = 1 - - def _get_default_country_code(self) -> str: - """Get the default country code based on config.""" - country = self.hass.config.country - if country is not None and country in SUPPORTED_COUNTRIES: - return country - - return DEFAULT_COUNTRY - - async def _validate_and_create_entry( - self, access_token: str, country_code: str - ) -> ConfigFlowResult: - """Create an entry for the flow.""" - connect_client_id = f"{CLIENT_PREFIX}-{uuid.uuid4()!s}" - - # To verify PAT, create an api to retrieve the device list. - await ThinQApi( - session=async_get_clientsession(self.hass), - access_token=access_token, - country_code=country_code, - client_id=connect_client_id, - ).async_get_device_list() - - # If verification is success, create entry. - return self.async_create_entry( - title=THINQ_DEFAULT_NAME, - data={ - CONF_ACCESS_TOKEN: access_token, - CONF_CONNECT_CLIENT_ID: connect_client_id, - CONF_COUNTRY: country_code, - }, - ) - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a flow initiated by the user.""" - errors: dict[str, str] = {} - - if user_input is not None: - access_token = user_input[CONF_ACCESS_TOKEN] - country_code = user_input[CONF_COUNTRY] - - # Check if PAT is already configured. - await self.async_set_unique_id(access_token) - self._abort_if_unique_id_configured() - - try: - return await self._validate_and_create_entry(access_token, country_code) - except ThinQAPIException: - errors["base"] = "token_unauthorized" - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Required( - CONF_COUNTRY, default=self._get_default_country_code() - ): CountrySelector( - CountrySelectorConfig(countries=SUPPORTED_COUNTRIES) - ), - } - ), - description_placeholders={"pat_url": THINQ_PAT_URL}, - errors=errors, - ) diff --git a/homeassistant/components/lg_thinq/const.py b/homeassistant/components/lg_thinq/const.py deleted file mode 100644 index 09f8c0833df..00000000000 --- a/homeassistant/components/lg_thinq/const.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Constants for LG ThinQ.""" - -from typing import Final - -# Config flow -DOMAIN = "lg_thinq" -COMPANY = "LGE" -DEFAULT_COUNTRY: Final = "US" -THINQ_DEFAULT_NAME: Final = "LG ThinQ" -THINQ_PAT_URL: Final = "https://connect-pat.lgthinq.com" -CLIENT_PREFIX: Final = "home-assistant" -CONF_CONNECT_CLIENT_ID: Final = "connect_client_id" diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py deleted file mode 100644 index 5ba77c648a8..00000000000 --- a/homeassistant/components/lg_thinq/coordinator.py +++ /dev/null @@ -1,69 +0,0 @@ -"""DataUpdateCoordinator for the LG ThinQ device.""" - -from __future__ import annotations - -import logging -from typing import Any - -from thinqconnect import ThinQAPIException -from thinqconnect.integration import HABridge - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """LG Device's Data Update Coordinator.""" - - def __init__(self, hass: HomeAssistant, ha_bridge: HABridge) -> None: - """Initialize data coordinator.""" - super().__init__( - hass, - _LOGGER, - name=f"{DOMAIN}_{ha_bridge.device.device_id}", - ) - - self.data = {} - self.api = ha_bridge - self.device_id = ha_bridge.device.device_id - self.sub_id = ha_bridge.sub_id - - alias = ha_bridge.device.alias - - # The device name is usually set to 'alias'. - # But, if the sub_id exists, it will be set to 'alias {sub_id}'. - # e.g. alias='MyWashTower', sub_id='dryer' then 'MyWashTower dryer'. - self.device_name = f"{alias} {self.sub_id}" if self.sub_id else alias - - # The unique id is usually set to 'device_id'. - # But, if the sub_id exists, it will be set to 'device_id_{sub_id}'. - # e.g. device_id='TQSXXXX', sub_id='dryer' then 'TQSXXXX_dryer'. - self.unique_id = ( - f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id - ) - - async def _async_update_data(self) -> dict[str, Any]: - """Request to the server to update the status from full response data.""" - try: - return await self.api.fetch_data() - except ThinQAPIException as e: - raise UpdateFailed(e) from e - - def refresh_status(self) -> None: - """Refresh current status.""" - self.async_set_updated_data(self.data) - - -async def async_setup_device_coordinator( - hass: HomeAssistant, ha_bridge: HABridge -) -> DeviceDataUpdateCoordinator: - """Create DeviceDataUpdateCoordinator and device_api per device.""" - coordinator = DeviceDataUpdateCoordinator(hass, ha_bridge) - await coordinator.async_refresh() - - _LOGGER.debug("Setup device's coordinator: %s", coordinator.device_name) - return coordinator diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py deleted file mode 100644 index 5cf3cd58837..00000000000 --- a/homeassistant/components/lg_thinq/entity.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Base class for ThinQ entities.""" - -from __future__ import annotations - -from collections.abc import Callable, Coroutine -import logging -from typing import Any - -from thinqconnect import ThinQAPIException -from thinqconnect.devices.const import Location -from thinqconnect.integration import PropertyState - -from homeassistant.const import UnitOfTemperature -from homeassistant.core import callback -from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import COMPANY, DOMAIN -from .coordinator import DeviceDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) - -EMPTY_STATE = PropertyState() - -UNIT_CONVERSION_MAP: dict[str, str] = { - "F": UnitOfTemperature.FAHRENHEIT, - "C": UnitOfTemperature.CELSIUS, -} - - -class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): - """The base implementation of all lg thinq entities.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DeviceDataUpdateCoordinator, - entity_description: EntityDescription, - property_id: str, - ) -> None: - """Initialize an entity.""" - super().__init__(coordinator) - - self.entity_description = entity_description - self.property_id = property_id - self.location = self.coordinator.api.get_location_for_idx(self.property_id) - - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, coordinator.unique_id)}, - manufacturer=COMPANY, - model=coordinator.api.device.model_name, - name=coordinator.device_name, - ) - self._attr_unique_id = f"{coordinator.unique_id}_{self.property_id}" - if self.location is not None and self.location not in ( - Location.MAIN, - Location.OVEN, - coordinator.sub_id, - ): - self._attr_translation_placeholders = {"location": self.location} - self._attr_translation_key = ( - f"{entity_description.translation_key}_for_location" - ) - - @property - def data(self) -> PropertyState: - """Return the state data of entity.""" - return self.coordinator.data.get(self.property_id, EMPTY_STATE) - - def _get_unit_of_measurement(self, unit: str | None) -> str | None: - """Convert thinq unit string to HA unit string.""" - if unit is None: - return None - - return UNIT_CONVERSION_MAP.get(unit) - - def _update_status(self) -> None: - """Update status itself. - - All inherited classes can update their own status in here. - """ - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._update_status() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - self._handle_coordinator_update() - - async def async_call_api( - self, - target: Coroutine[Any, Any, Any], - on_fail_method: Callable[[], None] | None = None, - ) -> None: - """Call the given api and handle exception.""" - try: - await target - except ThinQAPIException as exc: - if on_fail_method: - on_fail_method() - - raise ServiceValidationError( - exc.message, - translation_domain=DOMAIN, - translation_key=exc.code, - ) from exc - finally: - await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json deleted file mode 100644 index d96214725c8..00000000000 --- a/homeassistant/components/lg_thinq/icons.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "entity": { - "switch": { - "operation_power": { - "default": "mdi:power" - } - }, - "binary_sensor": { - "eco_friendly_mode": { - "default": "mdi:sprout" - }, - "power_save_enabled": { - "default": "mdi:meter-electric" - }, - "remote_control_enabled": { - "default": "mdi:remote" - }, - "remote_control_enabled_for_location": { - "default": "mdi:remote" - }, - "rinse_refill": { - "default": "mdi:tune-vertical-variant" - }, - "sabbath_mode": { - "default": "mdi:food-off-outline" - }, - "machine_clean_reminder": { - "default": "mdi:tune-vertical-variant" - }, - "signal_level": { - "default": "mdi:tune-vertical-variant" - }, - "clean_light_reminder": { - "default": "mdi:tune-vertical-variant" - }, - "operation_mode": { - "default": "mdi:power" - }, - "one_touch_filter": { - "default": "mdi:air-filter" - } - } - } -} diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json deleted file mode 100644 index 4b880d2544d..00000000000 --- a/homeassistant/components/lg_thinq/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "domain": "lg_thinq", - "name": "LG ThinQ", - "codeowners": ["@LG-ThinQ-Integration"], - "config_flow": true, - "dependencies": [], - "documentation": "https://www.home-assistant.io/integrations/lg_thinq/", - "iot_class": "cloud_push", - "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==0.9.7"] -} diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json deleted file mode 100644 index 9ec11952a9a..00000000000 --- a/homeassistant/components/lg_thinq/strings.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" - }, - "error": { - "token_unauthorized": "The token is invalid or unauthorized." - }, - "step": { - "user": { - "title": "Connect to ThinQ", - "description": "Please enter a ThinQ [PAT(Personal Access Token)]({pat_url}) created with your LG ThinQ account.", - "data": { - "access_token": "Personal Access Token", - "country": "Country" - } - } - } - }, - "entity": { - "switch": { - "operation_power": { - "name": "Power" - } - }, - "binary_sensor": { - "eco_friendly_mode": { - "name": "Eco friendly" - }, - "power_save_enabled": { - "name": "Power saving mode" - }, - "remote_control_enabled": { - "name": "Remote start" - }, - "remote_control_enabled_for_location": { - "name": "{location} remote start" - }, - "rinse_refill": { - "name": "Rinse refill needed" - }, - "sabbath_mode": { - "name": "Sabbath" - }, - "machine_clean_reminder": { - "name": "Machine clean reminder" - }, - "signal_level": { - "name": "Chime sound" - }, - "clean_light_reminder": { - "name": "Clean indicator light" - }, - "operation_mode": { - "name": "[%key:component::binary_sensor::entity_component::power::name%]" - }, - "one_touch_filter": { - "name": "Fresh air filter" - } - } - } -} diff --git a/homeassistant/components/lg_thinq/switch.py b/homeassistant/components/lg_thinq/switch.py deleted file mode 100644 index fe78b7813fa..00000000000 --- a/homeassistant/components/lg_thinq/switch.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Support for switch entities.""" - -from __future__ import annotations - -import logging -from typing import Any - -from thinqconnect import DeviceType -from thinqconnect.devices.const import Property as ThinQProperty -from thinqconnect.integration import ActiveMode - -from homeassistant.components.switch import ( - SwitchDeviceClass, - SwitchEntity, - SwitchEntityDescription, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import ThinqConfigEntry -from .entity import ThinQEntity - -DEVICE_TYPE_SWITCH_MAP: dict[DeviceType, tuple[SwitchEntityDescription, ...]] = { - DeviceType.AIR_PURIFIER_FAN: ( - SwitchEntityDescription( - key=ThinQProperty.AIR_FAN_OPERATION_MODE, translation_key="operation_power" - ), - ), - DeviceType.AIR_PURIFIER: ( - SwitchEntityDescription( - key=ThinQProperty.AIR_PURIFIER_OPERATION_MODE, - translation_key="operation_power", - ), - ), - DeviceType.DEHUMIDIFIER: ( - SwitchEntityDescription( - key=ThinQProperty.DEHUMIDIFIER_OPERATION_MODE, - translation_key="operation_power", - ), - ), - DeviceType.HUMIDIFIER: ( - SwitchEntityDescription( - key=ThinQProperty.HUMIDIFIER_OPERATION_MODE, - translation_key="operation_power", - ), - ), - DeviceType.SYSTEM_BOILER: ( - SwitchEntityDescription( - key=ThinQProperty.BOILER_OPERATION_MODE, translation_key="operation_power" - ), - ), -} - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ThinqConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up an entry for switch platform.""" - entities: list[ThinQSwitchEntity] = [] - for coordinator in entry.runtime_data.values(): - if ( - descriptions := DEVICE_TYPE_SWITCH_MAP.get( - coordinator.api.device.device_type - ) - ) is not None: - for description in descriptions: - entities.extend( - ThinQSwitchEntity(coordinator, description, property_id) - for property_id in coordinator.api.get_active_idx( - description.key, ActiveMode.READ_WRITE - ) - ) - - if entities: - async_add_entities(entities) - - -class ThinQSwitchEntity(ThinQEntity, SwitchEntity): - """Represent a thinq switch platform.""" - - _attr_device_class = SwitchDeviceClass.SWITCH - - def _update_status(self) -> None: - """Update status itself.""" - super()._update_status() - - _LOGGER.debug( - "[%s:%s] update status: %s", - self.coordinator.device_name, - self.property_id, - self.data.is_on, - ) - self._attr_is_on = self.data.is_on - - async def async_turn_on(self, **kwargs: Any) -> None: - """Turn on the switch.""" - _LOGGER.debug("[%s] async_turn_on", self.name) - await self.async_call_api(self.coordinator.api.async_turn_on(self.property_id)) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn off the switch.""" - _LOGGER.debug("[%s] async_turn_off", self.name) - await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id)) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 55fa5f116e6..e126558cc0d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -326,7 +326,6 @@ FLOWS = { "lektrico", "lg_netcast", "lg_soundbar", - "lg_thinq", "lidarr", "lifx", "linear_garage_door", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cb550f38bc3..528d10aaab8 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3262,12 +3262,6 @@ "iot_class": "local_polling", "name": "LG Netcast" }, - "lg_thinq": { - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_push", - "name": "LG ThinQ" - }, "lg_soundbar": { "integration_type": "hub", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 86dbe806bb1..056a5fbe6d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2808,9 +2808,6 @@ thermopro-ble==0.10.0 # homeassistant.components.thingspeak thingspeak==1.0.0 -# homeassistant.components.lg_thinq -thinqconnect==0.9.7 - # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b97bee9614..9159e6044dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2224,9 +2224,6 @@ thermobeacon-ble==0.7.0 # homeassistant.components.thermopro thermopro-ble==0.10.0 -# homeassistant.components.lg_thinq -thinqconnect==0.9.7 - # homeassistant.components.tilt_ble tilt-ble==0.2.3 diff --git a/tests/components/lg_thinq/__init__.py b/tests/components/lg_thinq/__init__.py deleted file mode 100644 index 68ffb960f71..00000000000 --- a/tests/components/lg_thinq/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the lgthinq integration.""" diff --git a/tests/components/lg_thinq/conftest.py b/tests/components/lg_thinq/conftest.py deleted file mode 100644 index cae2de61fa4..00000000000 --- a/tests/components/lg_thinq/conftest.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Configure tests for the LGThinQ integration.""" - -from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from thinqconnect import ThinQAPIException - -from homeassistant.components.lg_thinq.const import CONF_CONNECT_CLIENT_ID, DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY - -from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT, MOCK_UUID - -from tests.common import MockConfigEntry - - -def mock_thinq_api_response( - *, - status: int = 200, - body: dict | None = None, - error_code: str | None = None, - error_message: str | None = None, -) -> MagicMock: - """Create a mock thinq api response.""" - response = MagicMock() - response.status = status - response.body = body - response.error_code = error_code - response.error_message = error_message - return response - - -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Create a mock config entry.""" - return MockConfigEntry( - domain=DOMAIN, - title=f"Test {DOMAIN}", - unique_id=MOCK_PAT, - data={ - CONF_ACCESS_TOKEN: MOCK_PAT, - CONF_CONNECT_CLIENT_ID: MOCK_CONNECT_CLIENT_ID, - CONF_COUNTRY: MOCK_COUNTRY, - }, - ) - - -@pytest.fixture -def mock_uuid() -> Generator[AsyncMock]: - """Mock a uuid.""" - with ( - patch("uuid.uuid4", autospec=True, return_value=MOCK_UUID) as mock_uuid, - patch( - "homeassistant.components.lg_thinq.config_flow.uuid.uuid4", - new=mock_uuid, - ), - ): - yield mock_uuid.return_value - - -@pytest.fixture -def mock_thinq_api() -> Generator[AsyncMock]: - """Mock a thinq api.""" - with ( - patch("thinqconnect.ThinQApi", autospec=True) as mock_api, - patch( - "homeassistant.components.lg_thinq.config_flow.ThinQApi", - new=mock_api, - ), - ): - thinq_api = mock_api.return_value - thinq_api.async_get_device_list = AsyncMock( - return_value=mock_thinq_api_response(status=200, body={}) - ) - yield thinq_api - - -@pytest.fixture -def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock: - """Mock an invalid thinq api.""" - mock_thinq_api.async_get_device_list = AsyncMock( - side_effect=ThinQAPIException( - code="1309", message="Not allowed api call", headers=None - ) - ) - return mock_thinq_api diff --git a/tests/components/lg_thinq/const.py b/tests/components/lg_thinq/const.py deleted file mode 100644 index f46baa61c38..00000000000 --- a/tests/components/lg_thinq/const.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Constants for lgthinq test.""" - -from typing import Final - -MOCK_PAT: Final[str] = "123abc4567de8f90g123h4ij56klmn789012p345rst6uvw789xy" -MOCK_UUID: Final[str] = "1b3deabc-123d-456d-987d-2a1c7b3bdb67" -MOCK_CONNECT_CLIENT_ID: Final[str] = f"home-assistant-{MOCK_UUID}" -MOCK_COUNTRY: Final[str] = "KR" diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py deleted file mode 100644 index db0e2d29450..00000000000 --- a/tests/components/lg_thinq/test_config_flow.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Test the lgthinq config flow.""" - -from unittest.mock import AsyncMock - -from homeassistant.components.lg_thinq.const import CONF_CONNECT_CLIENT_ID, DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - -from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT - -from tests.common import MockConfigEntry - - -async def test_config_flow( - hass: HomeAssistant, mock_thinq_api: AsyncMock, mock_uuid: AsyncMock -) -> None: - """Test that an thinq entry is normally created.""" - 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"], - user_input={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_ACCESS_TOKEN: MOCK_PAT, - CONF_COUNTRY: MOCK_COUNTRY, - CONF_CONNECT_CLIENT_ID: MOCK_CONNECT_CLIENT_ID, - } - - mock_thinq_api.async_get_device_list.assert_called_once() - - -async def test_config_flow_invalid_pat( - hass: HomeAssistant, mock_invalid_thinq_api: AsyncMock -) -> None: - """Test that an thinq flow should be aborted with an invalid PAT.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "token_unauthorized"} - mock_invalid_thinq_api.async_get_device_list.assert_called_once() - - -async def test_config_flow_already_configured( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_thinq_api: AsyncMock -) -> None: - """Test that thinq flow should be aborted when already configured.""" - mock_config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_ACCESS_TOKEN: MOCK_PAT, CONF_COUNTRY: MOCK_COUNTRY}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" From e2f1c60981eaaee98e00b3f0c850f7d1b57841f3 Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Wed, 18 Sep 2024 10:23:35 -0400 Subject: [PATCH 0802/1309] Fix Fujitsu fglair authentication error and other issues (#125439) * Use correct app credentials when europe is checked * Rework to add china as well * Use our own package since the maintainer of the original package is not responding * Revert to using rewardone's package * Import app credentials where needed instead of __init__ * Rework region selector * Bump config entry minor and add migration * Address comments --- .../components/fujitsu_fglair/__init__.py | 32 ++++++-- .../components/fujitsu_fglair/config_flow.py | 20 +++-- .../components/fujitsu_fglair/const.py | 3 + .../components/fujitsu_fglair/manifest.json | 2 +- .../components/fujitsu_fglair/strings.json | 14 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/fujitsu_fglair/conftest.py | 14 +++- .../fujitsu_fglair/test_config_flow.py | 14 ++-- tests/components/fujitsu_fglair/test_init.py | 81 ++++++++++++++++++- 10 files changed, 154 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py index 633f0a62e55..f25e01bcd11 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -5,14 +5,14 @@ from __future__ import annotations from contextlib import suppress from ayla_iot_unofficial import new_ayla_api -from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_ID, FGLAIR_APP_SECRET +from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import API_TIMEOUT, CONF_EUROPE +from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU from .coordinator import FGLairCoordinator PLATFORMS: list[Platform] = [Platform.CLIMATE] @@ -22,12 +22,13 @@ type FGLairConfigEntry = ConfigEntry[FGLairCoordinator] async def async_setup_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> bool: """Set up Fujitsu HVAC (based on Ayla IOT) from a config entry.""" + app_id, app_secret = FGLAIR_APP_CREDENTIALS[entry.data[CONF_REGION]] api = new_ayla_api( entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], - FGLAIR_APP_ID, - FGLAIR_APP_SECRET, - europe=entry.data[CONF_EUROPE], + app_id, + app_secret, + europe=entry.data[CONF_REGION] == REGION_EU, websession=aiohttp_client.async_get_clientsession(hass), timeout=API_TIMEOUT, ) @@ -48,3 +49,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> b await entry.runtime_data.api.async_sign_out() return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: FGLairConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version > 1: + return False + + if entry.version == 1: + new_data = {**entry.data} + if entry.minor_version < 2: + is_europe = new_data.get(CONF_EUROPE, False) + if is_europe: + new_data[CONF_REGION] = REGION_EU + else: + new_data[CONF_REGION] = REGION_DEFAULT + + hass.config_entries.async_update_entry( + entry, data=new_data, minor_version=2, version=1 + ) + + return True diff --git a/homeassistant/components/fujitsu_fglair/config_flow.py b/homeassistant/components/fujitsu_fglair/config_flow.py index 6db22db451d..aef856631f6 100644 --- a/homeassistant/components/fujitsu_fglair/config_flow.py +++ b/homeassistant/components/fujitsu_fglair/config_flow.py @@ -5,14 +5,15 @@ import logging from typing import Any from ayla_iot_unofficial import AylaAuthError, new_ayla_api -from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_ID, FGLAIR_APP_SECRET +from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig -from .const import API_TIMEOUT, CONF_EUROPE, DOMAIN +from .const import API_TIMEOUT, CONF_REGION, DOMAIN, REGION_DEFAULT, REGION_EU _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_EUROPE): bool, + vol.Required(CONF_REGION, default=REGION_DEFAULT): SelectSelector( + SelectSelectorConfig( + options=[region.lower() for region in FGLAIR_APP_CREDENTIALS], + translation_key=CONF_REGION, + ) + ), } ) STEP_REAUTH_DATA_SCHEMA = vol.Schema( @@ -34,18 +40,20 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema( class FGLairConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Fujitsu HVAC (based on Ayla IOT).""" + MINOR_VERSION = 2 _reauth_entry: ConfigEntry | None = None async def _async_validate_credentials( self, user_input: dict[str, Any] ) -> dict[str, str]: errors: dict[str, str] = {} + app_id, app_secret = FGLAIR_APP_CREDENTIALS[user_input[CONF_REGION]] api = new_ayla_api( user_input[CONF_USERNAME], user_input[CONF_PASSWORD], - FGLAIR_APP_ID, - FGLAIR_APP_SECRET, - europe=user_input[CONF_EUROPE], + app_id, + app_secret, + europe=user_input[CONF_REGION] == REGION_EU, websession=aiohttp_client.async_get_clientsession(self.hass), timeout=API_TIMEOUT, ) diff --git a/homeassistant/components/fujitsu_fglair/const.py b/homeassistant/components/fujitsu_fglair/const.py index 3c79c800041..8aa911a8b30 100644 --- a/homeassistant/components/fujitsu_fglair/const.py +++ b/homeassistant/components/fujitsu_fglair/const.py @@ -7,4 +7,7 @@ API_REFRESH = timedelta(minutes=5) DOMAIN = "fujitsu_fglair" +CONF_REGION = "region" CONF_EUROPE = "is_europe" +REGION_EU = "EU" +REGION_DEFAULT = "default" diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json index 9286f7c24d9..76cf3966fbe 100644 --- a/homeassistant/components/fujitsu_fglair/manifest.json +++ b/homeassistant/components/fujitsu_fglair/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair", "iot_class": "cloud_polling", - "requirements": ["ayla-iot-unofficial==1.3.1"] + "requirements": ["ayla-iot-unofficial==1.4.1"] } diff --git a/homeassistant/components/fujitsu_fglair/strings.json b/homeassistant/components/fujitsu_fglair/strings.json index 8f7d775d7e4..3ad4e59ec1c 100644 --- a/homeassistant/components/fujitsu_fglair/strings.json +++ b/homeassistant/components/fujitsu_fglair/strings.json @@ -4,12 +4,9 @@ "user": { "title": "Enter your FGLair credentials", "data": { - "is_europe": "Use european servers", + "region": "Region", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" - }, - "data_description": { - "is_europe": "Allows the user to choose whether to use european servers or not since the API uses different endoint URLs for european vs non-european users" } }, "reauth_confirm": { @@ -29,5 +26,14 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "selector": { + "region": { + "options": { + "default": "Other", + "eu": "Europe", + "cn": "China" + } + } } } diff --git a/requirements_all.txt b/requirements_all.txt index 056a5fbe6d2..9c3c8d5574f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -532,7 +532,7 @@ autarco==3.0.0 axis==62 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.3.1 +ayla-iot-unofficial==1.4.1 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9159e6044dc..c4d1936f59a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -481,7 +481,7 @@ autarco==3.0.0 axis==62 # homeassistant.components.fujitsu_fglair -ayla-iot-unofficial==1.3.1 +ayla-iot-unofficial==1.4.1 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 diff --git a/tests/components/fujitsu_fglair/conftest.py b/tests/components/fujitsu_fglair/conftest.py index 04042fb0b09..5974adbeb0d 100644 --- a/tests/components/fujitsu_fglair/conftest.py +++ b/tests/components/fujitsu_fglair/conftest.py @@ -7,7 +7,11 @@ from ayla_iot_unofficial import AylaApi from ayla_iot_unofficial.fujitsu_hvac import FanSpeed, FujitsuHVAC, OpMode, SwingMode import pytest -from homeassistant.components.fujitsu_fglair.const import CONF_EUROPE, DOMAIN +from homeassistant.components.fujitsu_fglair.const import ( + CONF_REGION, + DOMAIN, + REGION_DEFAULT, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry @@ -57,15 +61,19 @@ def mock_ayla_api(mock_devices: list[AsyncMock]) -> Generator[AsyncMock]: @pytest.fixture -def mock_config_entry() -> MockConfigEntry: +def mock_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: """Return a regular config entry.""" + region = REGION_DEFAULT + if hasattr(request, "param"): + region = request.param + return MockConfigEntry( domain=DOMAIN, unique_id=TEST_USERNAME, data={ CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_EUROPE: False, + CONF_REGION: region, }, ) diff --git a/tests/components/fujitsu_fglair/test_config_flow.py b/tests/components/fujitsu_fglair/test_config_flow.py index 2828cf95339..6c9ebd66e47 100644 --- a/tests/components/fujitsu_fglair/test_config_flow.py +++ b/tests/components/fujitsu_fglair/test_config_flow.py @@ -5,7 +5,11 @@ from unittest.mock import AsyncMock from ayla_iot_unofficial import AylaAuthError import pytest -from homeassistant.components.fujitsu_fglair.const import CONF_EUROPE, DOMAIN +from homeassistant.components.fujitsu_fglair.const import ( + CONF_REGION, + DOMAIN, + REGION_DEFAULT, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -28,7 +32,7 @@ async def _initial_step(hass: HomeAssistant) -> FlowResult: { CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_EUROPE: False, + CONF_REGION: REGION_DEFAULT, }, ) @@ -45,7 +49,7 @@ async def test_full_flow( assert result["data"] == { CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_EUROPE: False, + CONF_REGION: REGION_DEFAULT, } @@ -94,7 +98,7 @@ async def test_form_exceptions( { CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_EUROPE: False, + CONF_REGION: REGION_DEFAULT, }, ) @@ -103,7 +107,7 @@ async def test_form_exceptions( assert result["data"] == { CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, - CONF_EUROPE: False, + CONF_REGION: REGION_DEFAULT, } diff --git a/tests/components/fujitsu_fglair/test_init.py b/tests/components/fujitsu_fglair/test_init.py index fa67ea08661..af51b222c19 100644 --- a/tests/components/fujitsu_fglair/test_init.py +++ b/tests/components/fujitsu_fglair/test_init.py @@ -1,17 +1,33 @@ """Test the initialization of fujitsu_fglair entities.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from ayla_iot_unofficial import AylaAuthError +from ayla_iot_unofficial.fujitsu_consts import FGLAIR_APP_CREDENTIALS from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.fujitsu_fglair.const import API_REFRESH, DOMAIN -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.components.fujitsu_fglair.const import ( + API_REFRESH, + API_TIMEOUT, + CONF_EUROPE, + CONF_REGION, + DOMAIN, + REGION_DEFAULT, + REGION_EU, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import aiohttp_client, entity_registry as er from . import entity_id, setup_integration +from .conftest import TEST_PASSWORD, TEST_USERNAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -35,6 +51,63 @@ async def test_auth_failure( assert hass.states.get(entity_id(mock_devices[1])).state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "mock_config_entry", FGLAIR_APP_CREDENTIALS.keys(), indirect=True +) +async def test_auth_regions( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ayla_api: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_devices: list[AsyncMock], +) -> None: + """Test that we use the correct credentials if europe is selected.""" + with patch( + "homeassistant.components.fujitsu_fglair.new_ayla_api", return_value=AsyncMock() + ) as new_ayla_api_patch: + await setup_integration(hass, mock_config_entry) + new_ayla_api_patch.assert_called_once_with( + TEST_USERNAME, + TEST_PASSWORD, + FGLAIR_APP_CREDENTIALS[mock_config_entry.data[CONF_REGION]][0], + FGLAIR_APP_CREDENTIALS[mock_config_entry.data[CONF_REGION]][1], + europe=mock_config_entry.data[CONF_REGION] == "EU", + websession=aiohttp_client.async_get_clientsession(hass), + timeout=API_TIMEOUT, + ) + + +@pytest.mark.parametrize("is_europe", [True, False]) +async def test_migrate_entry_v11_v12( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_ayla_api: AsyncMock, + is_europe: bool, + mock_devices: list[AsyncMock], +) -> None: + """Test migration from schema 1.1 to 1.2.""" + v11_config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USERNAME, + data={ + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_EUROPE: is_europe, + }, + ) + + await setup_integration(hass, v11_config_entry) + updated_entry = hass.config_entries.async_get_entry(v11_config_entry.entry_id) + + assert updated_entry.state is ConfigEntryState.LOADED + assert updated_entry.version == 1 + assert updated_entry.minor_version == 2 + if is_europe: + assert updated_entry.data[CONF_REGION] is REGION_EU + else: + assert updated_entry.data[CONF_REGION] is REGION_DEFAULT + + async def test_device_auth_failure( hass: HomeAssistant, freezer: FrozenDateTimeFactory, From 12dbabb849692577beebdb5024fa0f8f5572e4bb Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Wed, 18 Sep 2024 16:26:09 +0200 Subject: [PATCH 0803/1309] Update Aseko to support new API (#126133) * Update Aseko to support new API * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Use self.unit instead of self._unit * Refactor sensor setup entry * Keep same unique id and identifier * Revert rename free_chlorine translation key * Remove new heating entity to keep PR small * Fix keep same unique id --------- Co-authored-by: Joost Lekkerkerker --- .../components/aseko_pool_live/__init__.py | 29 ++--- .../aseko_pool_live/binary_sensor.py | 52 +++----- .../components/aseko_pool_live/config_flow.py | 20 ++- .../components/aseko_pool_live/coordinator.py | 23 ++-- .../components/aseko_pool_live/entity.py | 49 ++++++-- .../components/aseko_pool_live/icons.json | 15 ++- .../components/aseko_pool_live/manifest.json | 2 +- .../components/aseko_pool_live/sensor.py | 117 +++++++++++------- .../components/aseko_pool_live/strings.json | 13 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aseko_pool_live/conftest.py | 20 +++ .../aseko_pool_live/test_config_flow.py | 54 ++++---- 13 files changed, 222 insertions(+), 176 deletions(-) create mode 100644 tests/components/aseko_pool_live/conftest.py diff --git a/homeassistant/components/aseko_pool_live/__init__.py b/homeassistant/components/aseko_pool_live/__init__.py index 5773b3eb5b9..5985af4d023 100644 --- a/homeassistant/components/aseko_pool_live/__init__.py +++ b/homeassistant/components/aseko_pool_live/__init__.py @@ -4,13 +4,12 @@ from __future__ import annotations import logging -from aioaseko import APIUnavailable, InvalidAuthCredentials, MobileAccount +from aioaseko import Aseko, AsekoNotLoggedIn from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.exceptions import ConfigEntryAuthFailed from .const import DOMAIN from .coordinator import AsekoDataUpdateCoordinator @@ -22,28 +21,17 @@ PLATFORMS: list[str] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Aseko Pool Live from a config entry.""" - account = MobileAccount( - async_get_clientsession(hass), - username=entry.data[CONF_EMAIL], - password=entry.data[CONF_PASSWORD], - ) + aseko = Aseko(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]) try: - units = await account.get_units() - except InvalidAuthCredentials as err: + await aseko.login() + except AsekoNotLoggedIn as err: raise ConfigEntryAuthFailed from err - except APIUnavailable as err: - raise ConfigEntryNotReady from err - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = [] - - for unit in units: - coordinator = AsekoDataUpdateCoordinator(hass, unit) - await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id].append((unit, coordinator)) + coordinator = AsekoDataUpdateCoordinator(hass, aseko) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True @@ -51,7 +39,6 @@ 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): hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index 79953565769..90be61b230d 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from aioaseko import Unit from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -25,26 +24,14 @@ from .entity import AsekoEntity class AsekoBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes an Aseko binary sensor entity.""" - value_fn: Callable[[Unit], bool] + value_fn: Callable[[Unit], bool | None] -UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = ( +BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = ( AsekoBinarySensorEntityDescription( key="water_flow", - translation_key="water_flow", - value_fn=lambda unit: unit.water_flow, - ), - AsekoBinarySensorEntityDescription( - key="has_alarm", - translation_key="alarm", - value_fn=lambda unit: unit.has_alarm, - device_class=BinarySensorDeviceClass.SAFETY, - ), - AsekoBinarySensorEntityDescription( - key="has_error", - translation_key="error", - value_fn=lambda unit: unit.has_error, - device_class=BinarySensorDeviceClass.PROBLEM, + translation_key="water_flow_to_probes", + value_fn=lambda unit: unit.water_flow_to_probes, ), ) @@ -55,33 +42,22 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Aseko Pool Live binary sensors.""" - data: list[tuple[Unit, AsekoDataUpdateCoordinator]] = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator: AsekoDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + units = coordinator.data.values() async_add_entities( - AsekoUnitBinarySensorEntity(unit, coordinator, description) - for unit, coordinator in data - for description in UNIT_BINARY_SENSORS + AsekoBinarySensorEntity(unit, coordinator, description) + for description in BINARY_SENSORS + for unit in units + if description.value_fn(unit) is not None ) -class AsekoUnitBinarySensorEntity(AsekoEntity, BinarySensorEntity): - """Representation of a unit water flow binary sensor entity.""" +class AsekoBinarySensorEntity(AsekoEntity, BinarySensorEntity): + """Representation of an Aseko binary sensor entity.""" entity_description: AsekoBinarySensorEntityDescription - def __init__( - self, - unit: Unit, - coordinator: AsekoDataUpdateCoordinator, - entity_description: AsekoBinarySensorEntityDescription, - ) -> None: - """Initialize the unit binary sensor.""" - super().__init__(unit, coordinator) - self.entity_description = entity_description - self._attr_unique_id = f"{self._unit.serial_number}_{entity_description.key}" - @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the state of the sensor.""" - return self.entity_description.value_fn(self._unit) + return self.entity_description.value_fn(self.unit) diff --git a/homeassistant/components/aseko_pool_live/config_flow.py b/homeassistant/components/aseko_pool_live/config_flow.py index ce6de3683d5..c0edee694be 100644 --- a/homeassistant/components/aseko_pool_live/config_flow.py +++ b/homeassistant/components/aseko_pool_live/config_flow.py @@ -6,12 +6,11 @@ from collections.abc import Mapping import logging from typing import Any -from aioaseko import APIUnavailable, InvalidAuthCredentials, WebAccount +from aioaseko import Aseko, AsekoAPIError, AsekoInvalidCredentials import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_UNIQUE_ID -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -34,15 +33,12 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN): async def get_account_info(self, email: str, password: str) -> dict: """Get account info from the mobile API and the web API.""" - session = async_get_clientsession(self.hass) - - web_account = WebAccount(session, email, password) - web_account_info = await web_account.login() - + aseko = Aseko(email, password) + user = await aseko.login() return { CONF_EMAIL: email, CONF_PASSWORD: password, - CONF_UNIQUE_ID: web_account_info.user_id, + CONF_UNIQUE_ID: user.user_id, } async def async_step_user( @@ -58,9 +54,9 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN): info = await self.get_account_info( user_input[CONF_EMAIL], user_input[CONF_PASSWORD] ) - except APIUnavailable: + except AsekoAPIError: errors["base"] = "cannot_connect" - except InvalidAuthCredentials: + except AsekoInvalidCredentials: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") @@ -122,9 +118,9 @@ class AsekoConfigFlow(ConfigFlow, domain=DOMAIN): info = await self.get_account_info( user_input[CONF_EMAIL], user_input[CONF_PASSWORD] ) - except APIUnavailable: + except AsekoAPIError: errors["base"] = "cannot_connect" - except InvalidAuthCredentials: + except AsekoInvalidCredentials: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/aseko_pool_live/coordinator.py b/homeassistant/components/aseko_pool_live/coordinator.py index a7f2d5ad5ac..eb7ccf9ec42 100644 --- a/homeassistant/components/aseko_pool_live/coordinator.py +++ b/homeassistant/components/aseko_pool_live/coordinator.py @@ -5,34 +5,31 @@ from __future__ import annotations from datetime import timedelta import logging -from aioaseko import Unit, Variable +from aioaseko import Aseko, Unit from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) -class AsekoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Variable]]): +class AsekoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Unit]]): """Class to manage fetching Aseko unit data from single endpoint.""" - def __init__(self, hass: HomeAssistant, unit: Unit) -> None: + def __init__(self, hass: HomeAssistant, aseko: Aseko) -> None: """Initialize global Aseko unit data updater.""" - self._unit = unit - - if self._unit.name: - name = self._unit.name - else: - name = f"{self._unit.type}-{self._unit.serial_number}" + self._aseko = aseko super().__init__( hass, _LOGGER, - name=name, + name=DOMAIN, update_interval=timedelta(minutes=2), ) - async def _async_update_data(self) -> dict[str, Variable]: + async def _async_update_data(self) -> dict[str, Unit]: """Fetch unit data.""" - await self._unit.get_state() - return {variable.type: variable for variable in self._unit.variables} + units = await self._aseko.get_units() + return {unit.serial_number: unit for unit in units} diff --git a/homeassistant/components/aseko_pool_live/entity.py b/homeassistant/components/aseko_pool_live/entity.py index 6f0979da2e7..038e0a175d3 100644 --- a/homeassistant/components/aseko_pool_live/entity.py +++ b/homeassistant/components/aseko_pool_live/entity.py @@ -3,6 +3,7 @@ from aioaseko import Unit from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -14,20 +15,44 @@ class AsekoEntity(CoordinatorEntity[AsekoDataUpdateCoordinator]): _attr_has_entity_name = True - def __init__(self, unit: Unit, coordinator: AsekoDataUpdateCoordinator) -> None: + def __init__( + self, + unit: Unit, + coordinator: AsekoDataUpdateCoordinator, + description: EntityDescription, + ) -> None: """Initialize the aseko entity.""" super().__init__(coordinator) + self.entity_description = description self._unit = unit - - if self._unit.type == "Remote": - self._device_model = "ASIN Pool" - else: - self._device_model = f"ASIN AQUA {self._unit.type}" - self._device_name = self._unit.name if self._unit.name else self._device_model - + self._attr_unique_id = f"{self.unit.serial_number}{self.entity_description.key}" self._attr_device_info = DeviceInfo( - name=self._device_name, - identifiers={(DOMAIN, str(self._unit.serial_number))}, - manufacturer="Aseko", - model=self._device_model, + identifiers={(DOMAIN, self.unit.serial_number)}, + serial_number=self.unit.serial_number, + name=unit.name or unit.serial_number, + manufacturer=( + self.unit.brand_name.primary + if self.unit.brand_name is not None + else None + ), + model=( + self.unit.brand_name.secondary + if self.unit.brand_name is not None + else None + ), + configuration_url=f"https://aseko.cloud/unit/{self.unit.serial_number}", + ) + + @property + def unit(self) -> Unit: + """Return the aseko unit.""" + return self.coordinator.data[self._unit.serial_number] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available + and self.unit.serial_number in self.coordinator.data + and self.unit.online ) diff --git a/homeassistant/components/aseko_pool_live/icons.json b/homeassistant/components/aseko_pool_live/icons.json index 2f8a77fc417..23a8459d857 100644 --- a/homeassistant/components/aseko_pool_live/icons.json +++ b/homeassistant/components/aseko_pool_live/icons.json @@ -1,16 +1,25 @@ { "entity": { "binary_sensor": { - "water_flow": { + "water_flow_to_probes": { "default": "mdi:waves-arrow-right" } }, "sensor": { + "air_temperature": { + "default": "mdi:thermometer-lines" + }, "free_chlorine": { - "default": "mdi:flask" + "default": "mdi:pool" + }, + "redox": { + "default": "mdi:pool" + }, + "salinity": { + "default": "mdi:pool" }, "water_temperature": { - "default": "mdi:coolant-temperature" + "default": "mdi:pool-thermometer" } } } diff --git a/homeassistant/components/aseko_pool_live/manifest.json b/homeassistant/components/aseko_pool_live/manifest.json index a340408ad71..628a9732188 100644 --- a/homeassistant/components/aseko_pool_live/manifest.json +++ b/homeassistant/components/aseko_pool_live/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aseko_pool_live", "iot_class": "cloud_polling", "loggers": ["aioaseko"], - "requirements": ["aioaseko==0.2.0"] + "requirements": ["aioaseko==1.0.0"] } diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index a4ddea9ad89..d140d2a474f 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -2,77 +2,104 @@ from __future__ import annotations -from aioaseko import Unit, Variable +from collections.abc import Callable +from dataclasses import dataclass + +from aioaseko import Unit from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DOMAIN from .coordinator import AsekoDataUpdateCoordinator from .entity import AsekoEntity +@dataclass(frozen=True, kw_only=True) +class AsekoSensorEntityDescription(SensorEntityDescription): + """Describes an Aseko sensor entity.""" + + value_fn: Callable[[Unit], StateType] + + +SENSORS: list[AsekoSensorEntityDescription] = [ + AsekoSensorEntityDescription( + key="airTemp", + translation_key="air_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda unit: unit.air_temperature, + ), + AsekoSensorEntityDescription( + key="free_chlorine", + translation_key="free_chlorine", + native_unit_of_measurement="mg/l", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda unit: unit.cl_free, + ), + AsekoSensorEntityDescription( + key="ph", + device_class=SensorDeviceClass.PH, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda unit: unit.ph, + ), + AsekoSensorEntityDescription( + key="rx", + translation_key="redox", + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda unit: unit.redox, + ), + AsekoSensorEntityDescription( + key="salinity", + translation_key="salinity", + native_unit_of_measurement="kg/m³", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda unit: unit.salinity, + ), + AsekoSensorEntityDescription( + key="waterTemp", + translation_key="water_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda unit: unit.water_temperature, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Aseko Pool Live sensors.""" - data: list[tuple[Unit, AsekoDataUpdateCoordinator]] = hass.data[DOMAIN][ - config_entry.entry_id - ] - + coordinator: AsekoDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + units = coordinator.data.values() async_add_entities( - VariableSensorEntity(unit, variable, coordinator) - for unit, coordinator in data - for variable in unit.variables + AsekoSensorEntity(unit, coordinator, description) + for description in SENSORS + for unit in units + if description.value_fn(unit) is not None ) -class VariableSensorEntity(AsekoEntity, SensorEntity): - """Representation of a unit variable sensor entity.""" +class AsekoSensorEntity(AsekoEntity, SensorEntity): + """Representation of an Aseko unit sensor entity.""" - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__( - self, unit: Unit, variable: Variable, coordinator: AsekoDataUpdateCoordinator - ) -> None: - """Initialize the variable sensor.""" - super().__init__(unit, coordinator) - self._variable = variable - - translation_key = { - "Air temp.": "air_temperature", - "Cl free": "free_chlorine", - "Water temp.": "water_temperature", - }.get(self._variable.name) - if translation_key is not None: - self._attr_translation_key = translation_key - else: - self._attr_name = self._variable.name - - self._attr_unique_id = f"{self._unit.serial_number}{self._variable.type}" - self._attr_native_unit_of_measurement = self._variable.unit - - self._attr_icon = { - "rx": "mdi:test-tube", - "waterLevel": "mdi:waves", - }.get(self._variable.type) - - self._attr_device_class = { - "airTemp": SensorDeviceClass.TEMPERATURE, - "waterTemp": SensorDeviceClass.TEMPERATURE, - "ph": SensorDeviceClass.PH, - }.get(self._variable.type) + entity_description: AsekoSensorEntityDescription @property - def native_value(self) -> int | None: + def native_value(self) -> StateType: """Return the state of the sensor.""" - variable = self.coordinator.data[self._variable.type] - return variable.current_value + return self.entity_description.value_fn(self.unit) diff --git a/homeassistant/components/aseko_pool_live/strings.json b/homeassistant/components/aseko_pool_live/strings.json index 7f77b9ec69b..9ac341a7989 100644 --- a/homeassistant/components/aseko_pool_live/strings.json +++ b/homeassistant/components/aseko_pool_live/strings.json @@ -26,11 +26,8 @@ }, "entity": { "binary_sensor": { - "water_flow": { - "name": "Water flow" - }, - "alarm": { - "name": "Alarm" + "water_flow_to_probes": { + "name": "Water flow to probes" } }, "sensor": { @@ -40,6 +37,12 @@ "free_chlorine": { "name": "Free chlorine" }, + "redox": { + "name": "Redox potential" + }, + "salinity": { + "name": "Salinity" + }, "water_temperature": { "name": "Water temperature" } diff --git a/requirements_all.txt b/requirements_all.txt index 9c3c8d5574f..897f23fc747 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aioapcaccess==0.4.2 aioaquacell==0.2.0 # homeassistant.components.aseko_pool_live -aioaseko==0.2.0 +aioaseko==1.0.0 # homeassistant.components.asuswrt aioasuswrt==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4d1936f59a..3bc91ad64f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -180,7 +180,7 @@ aioapcaccess==0.4.2 aioaquacell==0.2.0 # homeassistant.components.aseko_pool_live -aioaseko==0.2.0 +aioaseko==1.0.0 # homeassistant.components.asuswrt aioasuswrt==1.4.0 diff --git a/tests/components/aseko_pool_live/conftest.py b/tests/components/aseko_pool_live/conftest.py new file mode 100644 index 00000000000..f3bbddb2cab --- /dev/null +++ b/tests/components/aseko_pool_live/conftest.py @@ -0,0 +1,20 @@ +"""Aseko Pool Live conftest.""" + +from datetime import datetime + +from aioaseko import User +import pytest + + +@pytest.fixture +def user() -> User: + """Aseko User fixture.""" + return User( + user_id="a_user_id", + created_at=datetime.now(), + updated_at=datetime.now(), + name="John", + surname="Doe", + language="any_language", + is_active=True, + ) diff --git a/tests/components/aseko_pool_live/test_config_flow.py b/tests/components/aseko_pool_live/test_config_flow.py index e4dedf36da4..de1bf0912f8 100644 --- a/tests/components/aseko_pool_live/test_config_flow.py +++ b/tests/components/aseko_pool_live/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from aioaseko import AccountInfo, APIUnavailable, InvalidAuthCredentials +from aioaseko import AsekoAPIError, AsekoInvalidCredentials, User import pytest from homeassistant import config_entries @@ -23,7 +23,7 @@ async def test_async_step_user_form(hass: HomeAssistant) -> None: assert result["errors"] == {} -async def test_async_step_user_success(hass: HomeAssistant) -> None: +async def test_async_step_user_success(hass: HomeAssistant, user: User) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -31,8 +31,8 @@ async def test_async_step_user_success(hass: HomeAssistant) -> None: with ( patch( - "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", - return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), + "homeassistant.components.aseko_pool_live.config_flow.Aseko.login", + return_value=user, ), patch( "homeassistant.components.aseko_pool_live.async_setup_entry", @@ -60,13 +60,13 @@ async def test_async_step_user_success(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("error_web", "reason"), [ - (APIUnavailable, "cannot_connect"), - (InvalidAuthCredentials, "invalid_auth"), + (AsekoAPIError, "cannot_connect"), + (AsekoInvalidCredentials, "invalid_auth"), (Exception, "unknown"), ], ) async def test_async_step_user_exception( - hass: HomeAssistant, error_web: Exception, reason: str + hass: HomeAssistant, user: User, error_web: Exception, reason: str ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -74,8 +74,8 @@ async def test_async_step_user_exception( ) with patch( - "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", - return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), + "homeassistant.components.aseko_pool_live.config_flow.Aseko.login", + return_value=user, side_effect=error_web, ): result2 = await hass.config_entries.flow.async_configure( @@ -93,13 +93,13 @@ async def test_async_step_user_exception( @pytest.mark.parametrize( ("error_web", "reason"), [ - (APIUnavailable, "cannot_connect"), - (InvalidAuthCredentials, "invalid_auth"), + (AsekoAPIError, "cannot_connect"), + (AsekoInvalidCredentials, "invalid_auth"), (Exception, "unknown"), ], ) async def test_get_account_info_exceptions( - hass: HomeAssistant, error_web: Exception, reason: str + hass: HomeAssistant, user: User, error_web: Exception, reason: str ) -> None: """Test we handle config flow exceptions.""" result = await hass.config_entries.flow.async_init( @@ -107,8 +107,8 @@ async def test_get_account_info_exceptions( ) with patch( - "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", - return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), + "homeassistant.components.aseko_pool_live.config_flow.Aseko.login", + return_value=user, side_effect=error_web, ): result2 = await hass.config_entries.flow.async_configure( @@ -123,7 +123,7 @@ async def test_get_account_info_exceptions( assert result2["errors"] == {"base": reason} -async def test_async_step_reauth_success(hass: HomeAssistant) -> None: +async def test_async_step_reauth_success(hass: HomeAssistant, user: User) -> None: """Test successful reauthentication.""" mock_entry = MockConfigEntry( @@ -139,10 +139,16 @@ async def test_async_step_reauth_success(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" assert result["errors"] == {} - with patch( - "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", - return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), - ) as mock_setup_entry: + with ( + patch( + "homeassistant.components.aseko_pool_live.config_flow.Aseko.login", + return_value=user, + ), + patch( + "homeassistant.components.aseko_pool_live.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_EMAIL: "aseko@example.com", CONF_PASSWORD: "passw0rd"}, @@ -156,13 +162,13 @@ async def test_async_step_reauth_success(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("error_web", "reason"), [ - (APIUnavailable, "cannot_connect"), - (InvalidAuthCredentials, "invalid_auth"), + (AsekoAPIError, "cannot_connect"), + (AsekoInvalidCredentials, "invalid_auth"), (Exception, "unknown"), ], ) async def test_async_step_reauth_exception( - hass: HomeAssistant, error_web: Exception, reason: str + hass: HomeAssistant, user: User, error_web: Exception, reason: str ) -> None: """Test we get the form.""" @@ -176,8 +182,8 @@ async def test_async_step_reauth_exception( result = await mock_entry.start_reauth_flow(hass) with patch( - "homeassistant.components.aseko_pool_live.config_flow.WebAccount.login", - return_value=AccountInfo("aseko@example.com", "a_user_id", "any_language"), + "homeassistant.components.aseko_pool_live.config_flow.Aseko.login", + return_value=user, side_effect=error_web, ): result2 = await hass.config_entries.flow.async_configure( From 252ce2c95b68c09d7abe1fa6b9a27cef9d7eeade Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 18 Sep 2024 18:19:13 +0200 Subject: [PATCH 0804/1309] Improve FlowManager.async_finish_flow docstring (#126178) * Improve FlowManager.async_finish_flow docstring * Fix typos --- homeassistant/auth/__init__.py | 6 +++++- homeassistant/components/auth/mfa_setup_flow.py | 6 +++++- homeassistant/components/repairs/issue_handler.py | 6 +++++- homeassistant/config_entries.py | 9 ++++++++- homeassistant/data_entry_flow.py | 6 +++++- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index b74fd587fab..19045406a15 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -127,7 +127,11 @@ class AuthManagerFlowManager( flow: data_entry_flow.FlowHandler[AuthFlowResult, tuple[str, str]], result: AuthFlowResult, ) -> AuthFlowResult: - """Return a user as result of login flow.""" + """Return a user as result of login flow. + + This method is called when a flow step returns FlowResultType.ABORT or + FlowResultType.CREATE_ENTRY. + """ flow = cast(LoginFlow, flow) if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index 8ae55396fa9..84f66440a75 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -57,7 +57,11 @@ class MfaFlowManager(data_entry_flow.FlowManager): async def async_finish_flow( self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult ) -> data_entry_flow.FlowResult: - """Complete an mfs setup flow.""" + """Complete an mfa setup flow. + + This method is called when a flow step returns FlowResultType.ABORT or + FlowResultType.CREATE_ENTRY. + """ _LOGGER.debug("flow_result: %s", result) return result diff --git a/homeassistant/components/repairs/issue_handler.py b/homeassistant/components/repairs/issue_handler.py index 38dcea1668d..b0b3f82a5d6 100644 --- a/homeassistant/components/repairs/issue_handler.py +++ b/homeassistant/components/repairs/issue_handler.py @@ -82,7 +82,11 @@ class RepairsFlowManager(data_entry_flow.FlowManager): async def async_finish_flow( self, flow: data_entry_flow.FlowHandler, result: data_entry_flow.FlowResult ) -> data_entry_flow.FlowResult: - """Complete a fix flow.""" + """Complete a fix flow. + + This method is called when a flow step returns FlowResultType.ABORT or + FlowResultType.CREATE_ENTRY. + """ if result.get("type") != data_entry_flow.FlowResultType.ABORT: ir.async_delete_issue(self.hass, flow.handler, flow.init_data["issue_id"]) if "result" not in result: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 797fcc5f345..395dcaf79a3 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1338,7 +1338,11 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): flow: data_entry_flow.FlowHandler[ConfigFlowResult], result: ConfigFlowResult, ) -> ConfigFlowResult: - """Finish a config flow and add an entry.""" + """Finish a config flow and add an entry. + + This method is called when a flow step returns FlowResultType.ABORT or + FlowResultType.CREATE_ENTRY. + """ flow = cast(ConfigFlow, flow) # Mark the step as done. @@ -2660,6 +2664,9 @@ class OptionsFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): ) -> ConfigFlowResult: """Finish an options flow and update options for configuration entry. + This method is called when a flow step returns FlowResultType.ABORT or + FlowResultType.CREATE_ENTRY. + Flow.handler and entry_id is the same thing to map flow with entry. """ flow = cast(OptionsFlow, flow) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index b8e8f269b82..7ecbe5508c6 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -226,7 +226,11 @@ class FlowManager(abc.ABC, Generic[_FlowResultT, _HandlerT]): async def async_finish_flow( self, flow: FlowHandler[_FlowResultT, _HandlerT], result: _FlowResultT ) -> _FlowResultT: - """Finish a data entry flow.""" + """Finish a data entry flow. + + This method is called when a flow step returns FlowResultType.ABORT or + FlowResultType.CREATE_ENTRY. + """ async def async_post_init( self, flow: FlowHandler[_FlowResultT, _HandlerT], result: _FlowResultT From 5fcdcbf9b90d6d876f178022c4821653377b051e Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 18 Sep 2024 20:37:01 +0200 Subject: [PATCH 0805/1309] Bump pydaikin to 2.13.7 (#126219) --- homeassistant/components/daikin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 88c29a20435..f6e9cb78efb 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/daikin", "iot_class": "local_polling", "loggers": ["pydaikin"], - "requirements": ["pydaikin==2.13.6"], + "requirements": ["pydaikin==2.13.7"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 897f23fc747..f3da2cc2e25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1816,7 +1816,7 @@ pycsspeechtts==1.0.8 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.13.6 +pydaikin==2.13.7 # homeassistant.components.danfoss_air pydanfossair==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bc91ad64f9..34fb79ed2b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1466,7 +1466,7 @@ pycoolmasternet-async==0.2.2 pycsspeechtts==1.0.8 # homeassistant.components.daikin -pydaikin==2.13.6 +pydaikin==2.13.7 # homeassistant.components.deako pydeako==0.4.0 From 6bc2d11c5e988d000b8408327e6ab2679dd0577e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 18 Sep 2024 20:38:45 +0200 Subject: [PATCH 0806/1309] Add base Entity class to enforce-class-module pylint plugin (#126026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add base Entity class to enforcé-class-module pylint plugin * Ignore bluetooth * Ignore hue * Ignore dominos * Ignore ffmpeg * Ignore mqtt * Ignore microsoft_face * Ignore plant * Ignore point * Ignore rfxtrx * Ignore template * Ignore tag * Ignore deconz --- .../bluetooth/passive_update_processor.py | 1 + .../components/deconz/deconz_device.py | 1 + homeassistant/components/dominos/__init__.py | 2 +- homeassistant/components/ffmpeg/__init__.py | 2 +- .../components/hue/v1/sensor_base.py | 2 +- .../components/hue/v1/sensor_device.py | 2 +- homeassistant/components/hue/v2/entity.py | 2 +- .../components/microsoft_face/__init__.py | 2 +- homeassistant/components/mqtt/mixins.py | 8 +-- homeassistant/components/plant/__init__.py | 2 +- homeassistant/components/point/__init__.py | 2 +- homeassistant/components/rfxtrx/siren.py | 2 +- homeassistant/components/tag/__init__.py | 2 +- .../components/template/template_entity.py | 2 +- pylint/plugins/hass_enforce_class_module.py | 15 ++++ tests/pylint/test_enforce_class_module.py | 70 +++++++++++++++++++ 16 files changed, 102 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 3e7e4e96659..8f66a3582ea 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -597,6 +597,7 @@ class PassiveBluetoothDataProcessor[_T, _DataT]: self.async_update_listeners(new_data, was_available, changed_entity_keys) +# pylint: disable-next=hass-enforce-class-module class PassiveBluetoothProcessorEntity[ _PassiveBluetoothDataProcessorT: PassiveBluetoothDataProcessor[Any, Any] ](Entity): diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 8551ad33cf5..48cf94ea5aa 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -68,6 +68,7 @@ class DeconzBase[_DeviceT: _DeviceType]: ) +# pylint: disable-next=hass-enforce-class-module class DeconzDevice[_DeviceT: _DeviceType](DeconzBase[_DeviceT], Entity): """Representation of a deCONZ device.""" diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py index 9b11b667e84..609cb93ba0d 100644 --- a/homeassistant/components/dominos/__init__.py +++ b/homeassistant/components/dominos/__init__.py @@ -182,7 +182,7 @@ class DominosProductListView(http.HomeAssistantView): return self.json(self.dominos.get_menu()) -class DominosOrder(Entity): +class DominosOrder(Entity): # pylint: disable=hass-enforce-class-module """Represents a Dominos order entity.""" def __init__(self, order_info, dominos): diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 5e1be36f398..94503108deb 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -176,7 +176,7 @@ class FFmpegManager: return CONTENT_TYPE_MULTIPART.format("ffserver") -class FFmpegBase[_HAFFmpegT: HAFFmpeg](Entity): +class FFmpegBase[_HAFFmpegT: HAFFmpeg](Entity): # pylint: disable=hass-enforce-class-module """Interface object for FFmpeg.""" _attr_should_poll = False diff --git a/homeassistant/components/hue/v1/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py index bac02c45209..393069b0c7c 100644 --- a/homeassistant/components/hue/v1/sensor_base.py +++ b/homeassistant/components/hue/v1/sensor_base.py @@ -165,7 +165,7 @@ class SensorManager: self._component_add_entities[platform](value) -class GenericHueSensor(GenericHueDevice, entity.Entity): +class GenericHueSensor(GenericHueDevice, entity.Entity): # pylint: disable=hass-enforce-class-module """Representation of a Hue sensor.""" should_poll = False diff --git a/homeassistant/components/hue/v1/sensor_device.py b/homeassistant/components/hue/v1/sensor_device.py index 1ff97af2e62..cb0a2721334 100644 --- a/homeassistant/components/hue/v1/sensor_device.py +++ b/homeassistant/components/hue/v1/sensor_device.py @@ -10,7 +10,7 @@ from ..const import ( ) -class GenericHueDevice(entity.Entity): +class GenericHueDevice(entity.Entity): # pylint: disable=hass-enforce-class-module """Representation of a Hue device.""" def __init__(self, sensor, name, bridge, primary_sensor=None): diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 6575d7f4702..e472009286d 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -34,7 +34,7 @@ RESOURCE_TYPE_NAMES = { } -class HueBaseEntity(Entity): +class HueBaseEntity(Entity): # pylint: disable=hass-enforce-class-module """Generic Entity Class for a Hue resource.""" _attr_should_poll = False diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index fa4de7f9c99..6a7e2d42fd9 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -214,7 +214,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class MicrosoftFaceGroupEntity(Entity): +class MicrosoftFaceGroupEntity(Entity): # pylint: disable=hass-enforce-class-module """Person-Group state/data Entity.""" _attr_should_poll = False diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index ce811e13a24..b1c7c6edadb 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -369,7 +369,7 @@ def init_entity_id_from_config( ) -class MqttAttributesMixin(Entity): +class MqttAttributesMixin(Entity): # pylint: disable=hass-enforce-class-module """Mixin used for platforms that support JSON attributes.""" _attributes_extra_blocked: frozenset[str] = frozenset() @@ -454,7 +454,7 @@ class MqttAttributesMixin(Entity): _LOGGER.warning("JSON result was not a dictionary") -class MqttAvailabilityMixin(Entity): +class MqttAvailabilityMixin(Entity): # pylint: disable=hass-enforce-class-module """Mixin used for platforms that report availability.""" def __init__(self, config: ConfigType) -> None: @@ -799,7 +799,7 @@ class MqttDiscoveryDeviceUpdateMixin(ABC): """Handle the cleanup of platform specific parts, extend to the platform.""" -class MqttDiscoveryUpdateMixin(Entity): +class MqttDiscoveryUpdateMixin(Entity): # pylint: disable=hass-enforce-class-module """Mixin used to handle updated discovery message for entity based platforms.""" def __init__( @@ -1021,7 +1021,7 @@ def device_info_from_specifications( return info -class MqttEntityDeviceInfo(Entity): +class MqttEntityDeviceInfo(Entity): # pylint: disable=hass-enforce-class-module """Mixin used for mqtt platforms that support the device registry.""" def __init__( diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index c6e527290df..b3e1084f501 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -127,7 +127,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class Plant(Entity): +class Plant(Entity): # pylint: disable=hass-enforce-class-module """Plant monitors the well-being of a plant. It also checks the measurements against diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index acfa53ae215..dc461f7200e 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -257,7 +257,7 @@ class MinutPointClient: return await self._client.alarm_arm(home_id) -class MinutPointEntity(Entity): +class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # see PR 118243 """Base Entity used by the sensors.""" _attr_should_poll = False diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py index 67a0c6b7dce..17112619acb 100644 --- a/homeassistant/components/rfxtrx/siren.py +++ b/homeassistant/components/rfxtrx/siren.py @@ -93,7 +93,7 @@ async def async_setup_entry( ) -class RfxtrxOffDelayMixin(Entity): +class RfxtrxOffDelayMixin(Entity): # pylint: disable=hass-enforce-class-module """Mixin to support timeouts on data. Many 433 devices only send data when active. They will diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 0462c5bec34..160408732c9 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -360,7 +360,7 @@ async def async_scan_tag( _LOGGER.debug("Tag: %s scanned by device: %s", tag_id, device_id) -class TagEntity(Entity): +class TagEntity(Entity): # pylint: disable=hass-enforce-class-module """Representation of a Tag entity.""" _unrecorded_attributes = frozenset({TAG_ID}) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index a074f828284..8930edc03e6 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -244,7 +244,7 @@ class _TemplateAttribute: return -class TemplateEntity(Entity): +class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module """Entity that uses templates to calculate attributes.""" _attr_available = True diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index fe233d4afe7..0fce0e13f63 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -8,6 +8,8 @@ from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter +from homeassistant.const import Platform + _MODULES: dict[str, set[str]] = { "air_quality": {"AirQualityEntity"}, "alarm_control_panel": { @@ -63,6 +65,7 @@ _MODULES: dict[str, set[str]] = { "WeatherEntityDescription", }, } +_PLATFORMS: set[str] = {platform.value for platform in Platform} class HassEnforceClassModule(BaseChecker): @@ -89,6 +92,18 @@ class HassEnforceClassModule(BaseChecker): current_integration = parts[2] current_module = parts[3] if len(parts) > 3 else "" + if current_module != "entity" and current_integration not in _PLATFORMS: + top_level_ancestors = list(node.ancestors(recurs=False)) + + for ancestor in top_level_ancestors: + if ancestor.name == "Entity": + self.add_message( + "hass-enforce-class-module", + node=node, + args=(ancestor.name, "entity"), + ) + return + ancestors: list[ClassDef] | None = None for expected_module, classes in _MODULES.items(): diff --git a/tests/pylint/test_enforce_class_module.py b/tests/pylint/test_enforce_class_module.py index db7daf0a258..8927147e89a 100644 --- a/tests/pylint/test_enforce_class_module.py +++ b/tests/pylint/test_enforce_class_module.py @@ -192,3 +192,73 @@ def test_enforce_class_module_bad_nested( ), ): walker.walk(root_node) + + +@pytest.mark.parametrize( + "path", + [ + "homeassistant.components.sensor", + "homeassistant.components.sensor.entity", + "homeassistant.components.pylint_test.entity", + ], +) +def test_enforce_entity_good( + linter: UnittestLinter, + enforce_class_module_checker: BaseChecker, + path: str, +) -> None: + """Good test cases.""" + code = """ + class Entity: + pass + + class CustomEntity(Entity): + pass + """ + root_node = astroid.parse(code, path) + walker = ASTWalker(linter) + walker.add_checker(enforce_class_module_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +@pytest.mark.parametrize( + "path", + [ + "homeassistant.components.pylint_test", + "homeassistant.components.pylint_test.select", + "homeassistant.components.pylint_test.select.entity", + ], +) +def test_enforce_entity_bad( + linter: UnittestLinter, + enforce_class_module_checker: BaseChecker, + path: str, +) -> None: + """Good test cases.""" + code = """ + class Entity: + pass + + class CustomEntity(Entity): + pass + """ + root_node = astroid.parse(code, path) + walker = ASTWalker(linter) + walker.add_checker(enforce_class_module_checker) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-enforce-class-module", + line=5, + node=root_node.body[1], + args=("Entity", "entity"), + confidence=UNDEFINED, + col_offset=0, + end_line=5, + end_col_offset=18, + ), + ): + walker.walk(root_node) From 5075b8736e58de861205e2e5d0a0cd5816a86bdf Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 18 Sep 2024 21:14:55 +0200 Subject: [PATCH 0807/1309] Use debug/warning instead of info log level in components [w] (#126231) --- homeassistant/components/wake_on_lan/switch.py | 2 +- homeassistant/components/webostv/media_player.py | 4 ++-- homeassistant/components/wilight/parent_device.py | 2 +- homeassistant/components/ws66i/__init__.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index f4949ec6901..fcf8936d498 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -113,7 +113,7 @@ class WolSwitch(SwitchEntity): if self._broadcast_port is not None: service_kwargs["port"] = self._broadcast_port - _LOGGER.info( + _LOGGER.debug( "Send magic packet to mac %s (broadcast: %s, port: %s)", self._mac_address, self._broadcast_address, diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 099b5a73784..239780e3f01 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -422,13 +422,13 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): partial_match_channel_id = channel["channelId"] if perfect_match_channel_id is not None: - _LOGGER.info( + _LOGGER.debug( "Switching to channel <%s> with perfect match", perfect_match_channel_id, ) await self._client.set_channel(perfect_match_channel_id) elif partial_match_channel_id is not None: - _LOGGER.info( + _LOGGER.debug( "Switching to channel <%s> with partial match", partial_match_channel_id, ) diff --git a/homeassistant/components/wilight/parent_device.py b/homeassistant/components/wilight/parent_device.py index 6e96274f0a4..6e71649d8fc 100644 --- a/homeassistant/components/wilight/parent_device.py +++ b/homeassistant/components/wilight/parent_device.py @@ -78,7 +78,7 @@ class WiLightParent: EVENT_HOMEASSISTANT_STOP, lambda x: client.stop() ) - _LOGGER.info("Connected to WiLight device: %s", api_device.device_id) + _LOGGER.debug("Connected to WiLight device: %s", api_device.device_id) await connect(api_device) diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 1993f38e0ab..83ad7bbf070 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -52,7 +52,7 @@ def _find_zones(hass: HomeAssistant, ws66i: WS66i) -> list[int]: zone_id = (amp_num * 10) + zone_num zone_list.append(zone_id) - _LOGGER.info("Detected %d amp(s)", amp_num - 1) + _LOGGER.debug("Detected %d amp(s)", amp_num - 1) return zone_list From 3f531c02a2a373742b6ad23d5bf8a64b42ac4b67 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 18 Sep 2024 21:15:50 +0200 Subject: [PATCH 0808/1309] Use debug/warning instead of info log level in components [v] (#126228) --- homeassistant/components/verisure/__init__.py | 2 +- homeassistant/components/versasense/__init__.py | 2 +- homeassistant/components/vesync/common.py | 8 ++++---- homeassistant/components/vilfo/__init__.py | 2 +- homeassistant/components/vizio/media_player.py | 2 +- homeassistant/components/vlc_telnet/media_player.py | 2 +- homeassistant/components/vulcan/calendar.py | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py index 0f8c8d936ef..e635ab712be 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -108,6 +108,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, version=2) - LOGGER.info("Migration to version %s successful", entry.version) + LOGGER.debug("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/versasense/__init__.py b/homeassistant/components/versasense/__init__.py index f209234f8c2..ed4a8edf32c 100644 --- a/homeassistant/components/versasense/__init__.py +++ b/homeassistant/components/versasense/__init__.py @@ -55,7 +55,7 @@ async def _configure_entities(hass, config, consumer): switch_info = {} for mac, device in devices.items(): - _LOGGER.info("Device connected: %s %s", device.name, mac) + _LOGGER.debug("Device connected: %s %s", device.name, mac) hass.data[DOMAIN][mac] = {} for peripheral_id, peripheral in device.peripherals.items(): diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index b57b49f9994..5f7b2a3a29e 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -21,17 +21,17 @@ async def async_process_devices(hass, manager): devices[VS_FANS].extend(manager.fans) # Expose fan sensors separately devices[VS_SENSORS].extend(manager.fans) - _LOGGER.info("%d VeSync fans found", len(manager.fans)) + _LOGGER.debug("%d VeSync fans found", len(manager.fans)) if manager.bulbs: devices[VS_LIGHTS].extend(manager.bulbs) - _LOGGER.info("%d VeSync lights found", len(manager.bulbs)) + _LOGGER.debug("%d VeSync lights found", len(manager.bulbs)) if manager.outlets: devices[VS_SWITCHES].extend(manager.outlets) # Expose outlets' voltage, power & energy usage as separate sensors devices[VS_SENSORS].extend(manager.outlets) - _LOGGER.info("%d VeSync outlets found", len(manager.outlets)) + _LOGGER.debug("%d VeSync outlets found", len(manager.outlets)) if manager.switches: for switch in manager.switches: @@ -39,6 +39,6 @@ async def async_process_devices(hass, manager): devices[VS_SWITCHES].append(switch) else: devices[VS_LIGHTS].append(switch) - _LOGGER.info("%d VeSync switches found", len(manager.switches)) + _LOGGER.debug("%d VeSync switches found", len(manager.switches)) return devices diff --git a/homeassistant/components/vilfo/__init__.py b/homeassistant/components/vilfo/__init__.py index fe00fa494b5..ca74e74f37a 100644 --- a/homeassistant/components/vilfo/__init__.py +++ b/homeassistant/components/vilfo/__init__.py @@ -105,5 +105,5 @@ class VilfoRouterData: return if self.available and self._unavailable_logged: - _LOGGER.info("Vilfo Router %s is available again", self.host) + _LOGGER.warning("Vilfo Router %s is available again", self.host) self._unavailable_logged = False diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index ba9c92f94f1..5711d8fbac9 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -200,7 +200,7 @@ class VizioDevice(MediaPlayerEntity): return if not self._attr_available: - _LOGGER.info( + _LOGGER.warning( "Restored connection to %s", self._config_entry.data[CONF_HOST] ) self._attr_available = True diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index bd58b2ad23a..bede6efbf57 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -131,7 +131,7 @@ class VlcDevice(MediaPlayerEntity): self._attr_state = MediaPlayerState.IDLE self._attr_available = True - LOGGER.info("Connected to vlc host: %s", self._vlc.host) + LOGGER.debug("Connected to vlc host: %s", self._vlc.host) status = await self._vlc.status() LOGGER.debug("Status: %s", status) diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py index e068a772345..a89b6b4a116 100644 --- a/homeassistant/components/vulcan/calendar.py +++ b/homeassistant/components/vulcan/calendar.py @@ -133,7 +133,7 @@ class VulcanCalendarEntity(CalendarEntity): events = await get_lessons(self.client) if not self.available: - _LOGGER.info("Restored connection with API") + _LOGGER.warning("Restored connection with API") self._attr_available = True if events == []: From d90caf3e86e9e0f61707393d2c62174703c40cac Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 18 Sep 2024 21:23:05 +0200 Subject: [PATCH 0809/1309] Remove default transition in Matter light platform (#126220) * Remove default transition in Matter light platform * adjust test --- homeassistant/components/matter/light.py | 3 +-- tests/components/matter/test_light.py | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index d334979b7c8..471e776d6be 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -41,7 +41,6 @@ COLOR_MODE_MAP = { clusters.ColorControl.Enums.ColorMode.kCurrentXAndCurrentY: ColorMode.XY, clusters.ColorControl.Enums.ColorMode.kColorTemperature: ColorMode.COLOR_TEMP, } -DEFAULT_TRANSITION = 0.2 # there's a bug in (at least) Espressif's implementation of light transitions # on devices based on Matter 1.0. Mark potential devices with this issue. @@ -287,7 +286,7 @@ class MatterLight(MatterEntity, LightEntity): xy_color = kwargs.get(ATTR_XY_COLOR) color_temp = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) - transition = kwargs.get(ATTR_TRANSITION, DEFAULT_TRANSITION) + transition = kwargs.get(ATTR_TRANSITION, 0) if self._transitions_disabled: transition = 0 diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 4fd73b6457b..14a3a6ca97e 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -159,7 +159,7 @@ async def test_dimmable_light( endpoint_id=1, command=clusters.LevelControl.Commands.MoveToLevelWithOnOff( level=128, - transitionTime=2, + transitionTime=0, ), ) matter_client.send_device_command.reset_mock() @@ -237,7 +237,7 @@ async def test_color_temperature_light( endpoint_id=1, command=clusters.ColorControl.Commands.MoveToColorTemperature( colorTemperatureMireds=300, - transitionTime=2, + transitionTime=0, optionsMask=1, optionsOverride=1, ), @@ -348,7 +348,7 @@ async def test_extended_color_light( command=clusters.ColorControl.Commands.MoveToColor( colorX=0.5 * 65536, colorY=0.5 * 65536, - transitionTime=2, + transitionTime=0, optionsMask=1, optionsOverride=1, ), @@ -413,7 +413,7 @@ async def test_extended_color_light( command=clusters.ColorControl.Commands.MoveToHueAndSaturation( hue=167, saturation=254, - transitionTime=2, + transitionTime=0, optionsMask=1, optionsOverride=1, ), From 1d425f3913d220b475c3111d3b9e1a55acfa7377 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 18 Sep 2024 21:33:52 +0200 Subject: [PATCH 0810/1309] Use debug/warning instead of info log level in components [s] (#126141) * Use debug/warning instead of info log level in components [s] * Fix merge error --- homeassistant/components/samsungtv/__init__.py | 10 +++++----- homeassistant/components/samsungtv/bridge.py | 8 ++++---- homeassistant/components/samsungtv/entity.py | 2 +- homeassistant/components/samsungtv/media_player.py | 6 +++--- homeassistant/components/samsungtv/remote.py | 2 +- homeassistant/components/scsgate/__init__.py | 2 +- homeassistant/components/serial/sensor.py | 2 +- .../components/sighthound/image_processing.py | 2 +- homeassistant/components/simplisafe/__init__.py | 6 +++--- homeassistant/components/simplisafe/binary_sensor.py | 2 +- homeassistant/components/simplisafe/lock.py | 2 +- homeassistant/components/simplisafe/sensor.py | 2 +- homeassistant/components/skybeacon/sensor.py | 4 ++-- homeassistant/components/sms/gateway.py | 2 +- homeassistant/components/snapcast/server.py | 2 +- homeassistant/components/solaredge/coordinator.py | 2 +- homeassistant/components/soma/config_flow.py | 2 +- homeassistant/components/somfy_mylink/cover.py | 2 +- homeassistant/components/sonarr/__init__.py | 2 +- homeassistant/components/songpal/media_player.py | 2 +- homeassistant/components/sonos/__init__.py | 2 +- homeassistant/components/soundtouch/media_player.py | 6 +++--- homeassistant/components/swisscom/device_tracker.py | 6 +++--- homeassistant/components/switchbee/__init__.py | 6 +++--- homeassistant/components/switchbee/entity.py | 2 +- homeassistant/components/syncthing/__init__.py | 4 ++-- homeassistant/components/syncthru/__init__.py | 2 +- homeassistant/components/synology_dsm/common.py | 2 +- homeassistant/components/synology_dsm/config_flow.py | 2 +- .../components/synology_srm/device_tracker.py | 2 -- tests/components/sonos/test_init.py | 2 +- 31 files changed, 49 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index f3b967a485e..1dfd3f00b93 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -208,7 +208,7 @@ async def _async_create_bridge_with_updated_data( "Failed to determine connection method, make sure the device is on." ) - LOGGER.info("Updated port to %s and method to %s for %s", port, method, host) + LOGGER.debug("Updated port to %s and method to %s for %s", port, method, host) updated_data[CONF_PORT] = port updated_data[CONF_METHOD] = method @@ -235,21 +235,21 @@ async def _async_create_bridge_with_updated_data( if mac and mac != "none": # Samsung sometimes returns a value of "none" for the mac address # this should be ignored - LOGGER.info("Updated mac to %s for %s", mac, host) + LOGGER.debug("Updated mac to %s for %s", mac, host) updated_data[CONF_MAC] = dr.format_mac(mac) else: - LOGGER.info("Failed to get mac for %s", host) + LOGGER.warning("Failed to get mac for %s", host) if not model: LOGGER.debug("Attempting to get model for %s", host) if info: model = info.get("device", {}).get("modelName") if model: - LOGGER.info("Updated model to %s for %s", model, host) + LOGGER.debug("Updated model to %s for %s", model, host) updated_data[CONF_MODEL] = model if model_requires_encryption(model) and method != METHOD_ENCRYPTED_WEBSOCKET: - LOGGER.info( + LOGGER.warning( ( "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" diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index f9f5b0d6e73..b4d060372e6 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -536,7 +536,7 @@ class SamsungTVWSBridge( LOGGER.debug("Working config: %s", config) return RESULT_SUCCESS except ConnectionClosedError as err: - LOGGER.info( + LOGGER.warning( ( "Working but unsupported config: %s, error: '%s'; this may be" " an indication that access to the TV has been denied. Please" @@ -609,7 +609,7 @@ class SamsungTVWSBridge( try: await self._remote.start_listening(self._remote_event) except UnauthorizedError as err: - LOGGER.info( + LOGGER.warning( "Failed to get remote for %s, re-authentication required: %s", self.host, repr(err), @@ -618,7 +618,7 @@ class SamsungTVWSBridge( self._notify_reauth_callback() self._remote = None except ConnectionClosedError as err: - LOGGER.info( + LOGGER.warning( "Failed to get remote for %s: %s", self.host, repr(err), @@ -643,7 +643,7 @@ class SamsungTVWSBridge( # Initialise device info on first connect await self.async_device_info() if self.token != self._remote.token: - LOGGER.info( + LOGGER.warning( "SamsungTVWSBridge has provided a new token %s", self._remote.token, ) diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 1af7495d78e..61aa8abce53 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -93,7 +93,7 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) LOGGER.debug("Attempting to turn on %s via automation", self.entity_id) await self._turn_on_action.async_run(self.hass, self._context) elif self._mac: - LOGGER.info( + LOGGER.warning( "Attempting to turn on %s via Wake-On-Lan; if this does not work, " "please ensure that Wake-On-Lan is available for your device or use " "a turn_on automation", diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 960b69f71e3..7180e8a0c1a 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -284,7 +284,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): async def _async_launch_app(self, app_id: str) -> None: """Send launch_app to the tv.""" if self._bridge.power_off_in_progress: - LOGGER.info("TV is powering off, not sending launch_app command") + LOGGER.debug("TV is powering off, not sending launch_app command") return assert isinstance(self._bridge, SamsungTVWSBridge) await self._bridge.async_launch_app(app_id) @@ -293,7 +293,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): """Send a key to the tv and handles exceptions.""" assert keys if self._bridge.power_off_in_progress and keys[0] != "KEY_POWEROFF": - LOGGER.info("TV is powering off, not sending keys: %s", keys) + LOGGER.debug("TV is powering off, not sending keys: %s", keys) return await self._bridge.async_send_keys(keys) @@ -304,7 +304,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): async def async_set_volume_level(self, volume: float) -> None: """Set volume level on the media player.""" if (dmr_device := self._dmr_device) is None: - LOGGER.info("Upnp services are not available on %s", self._host) + LOGGER.warning("Upnp services are not available on %s", self._host) return try: await dmr_device.async_set_volume_level(volume) diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index afbac341226..401a5d383f0 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -46,7 +46,7 @@ class SamsungTVRemote(SamsungTVEntity, RemoteEntity): See https://github.com/jaruba/ha-samsungtv-tizen/blob/master/Key_codes.md """ if self._bridge.power_off_in_progress: - LOGGER.info("TV is powering off, not sending keys: %s", command) + LOGGER.debug("TV is powering off, not sending keys: %s", command) return num_repeats = kwargs[ATTR_NUM_REPEATS] diff --git a/homeassistant/components/scsgate/__init__.py b/homeassistant/components/scsgate/__init__.py index db96ccb688a..9aabb315942 100644 --- a/homeassistant/components/scsgate/__init__.py +++ b/homeassistant/components/scsgate/__init__.py @@ -43,7 +43,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def stop_monitor(event): """Stop the SCSGate.""" - _LOGGER.info("Stopping SCSGate monitor thread") + _LOGGER.debug("Stopping SCSGate monitor thread") scsgate.stop() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_monitor) diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index e7c39d97f6a..a09401473b2 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -196,7 +196,7 @@ class SerialSensor(SensorEntity): logged_error = True await self._handle_error() else: - _LOGGER.info("Serial device %s connected", device) + _LOGGER.debug("Serial device %s connected", device) while True: try: line = await reader.readline() diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py index 706a8dd037a..acc8309af26 100644 --- a/homeassistant/components/sighthound/image_processing.py +++ b/homeassistant/components/sighthound/image_processing.py @@ -157,7 +157,7 @@ class SighthoundEntity(ImageProcessingEntity): if self._save_timestamped_file: timestamp_save_path = directory / f"{self._name}_{self._last_detection}.jpg" img.save(timestamp_save_path) - _LOGGER.info("Sighthound saved file %s", timestamp_save_path) + _LOGGER.debug("Sighthound saved file %s", timestamp_save_path) @property def camera_entity(self): diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index b23358c985f..58a3af83b5e 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -504,7 +504,7 @@ class SimpliSafe: except Exception as err: # noqa: BLE001 LOGGER.error("Unknown exception while connecting to websocket: %s", err) - LOGGER.info("Reconnecting to websocket") + LOGGER.warning("Reconnecting to websocket") await self._async_cancel_websocket_loop() self._websocket_reconnect_task = self._hass.async_create_task( self._async_start_websocket_loop() @@ -604,7 +604,7 @@ class SimpliSafe: @callback def async_save_refresh_token(token: str) -> None: """Save a refresh token to the config entry.""" - LOGGER.info("Saving new refresh token to HASS storage") + LOGGER.debug("Saving new refresh token to HASS storage") self._hass.config_entries.async_update_entry( self.entry, data={**self.entry.data, CONF_TOKEN: token}, @@ -647,7 +647,7 @@ class SimpliSafe: # In case the user attempts an action not allowed in their current plan, # we merely log that message at INFO level (so the user is aware, # but not spammed with ERROR messages that they cannot change): - LOGGER.info(result) + LOGGER.debug(result) if isinstance(result, SimplipyError): raise UpdateFailed(f"SimpliSafe error while updating: {result}") diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 3f56149a9f8..a91b03b519a 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -63,7 +63,7 @@ async def async_setup_entry( for system in simplisafe.systems.values(): if system.version == 2: - LOGGER.info("Skipping sensor setup for V2 system: %s", system.system_id) + LOGGER.warning("Skipping sensor setup for V2 system: %s", system.system_id) continue for sensor in system.sensors.values(): diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index 680fc0f4c0f..a287947615b 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -38,7 +38,7 @@ async def async_setup_entry( for system in simplisafe.systems.values(): if system.version == 2: - LOGGER.info("Skipping lock setup for V2 system: %s", system.system_id) + LOGGER.warning("Skipping lock setup for V2 system: %s", system.system_id) continue locks.extend( diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index fbccfc4b2f9..c360ad5228c 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( for system in simplisafe.systems.values(): if system.version == 2: - LOGGER.info("Skipping sensor setup for V2 system: %s", system.system_id) + LOGGER.warning("Skipping sensor setup for V2 system: %s", system.system_id) continue sensors.extend( diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index a3a5eb48098..5fa62d06fc2 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -69,7 +69,7 @@ def setup_platform( def monitor_stop(_service_or_event): """Stop the monitor thread.""" - _LOGGER.info("Stopping monitor for %s", name) + _LOGGER.debug("Stopping monitor for %s", name) mon.terminate() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, monitor_stop) @@ -163,7 +163,7 @@ class Monitor(threading.Thread, SensorEntity): # Magic: writing this makes device happy device.char_write_handle(0x1B, bytearray([255]), False) device.subscribe(BLE_TEMP_UUID, self._update) - _LOGGER.info("Subscribed to %s", self.name) + _LOGGER.debug("Subscribed to %s", self.name) while self.keep_going: # protect against stale connections, just read temperature device.char_read(BLE_TEMP_UUID, timeout=CONNECT_TIMEOUT) diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 60962f198b2..a11996e3dfc 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -128,7 +128,7 @@ class Gateway: except gammu.ERR_EMPTY: # error is raised if memory is empty (this induces wrong reported # memory status) - _LOGGER.info("Failed to read messages!") + _LOGGER.warning("Failed to read messages!") # Link all SMS when there are concatenated messages return gammu.LinkSMS(entries) diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py index 4714156c4c2..ab4091e30af 100644 --- a/homeassistant/components/snapcast/server.py +++ b/homeassistant/components/snapcast/server.py @@ -115,7 +115,7 @@ class HomeAssistantSnapcast: client.set_availability(True) for group in self.groups: group.set_availability(True) - _LOGGER.info("Server connected: %s", self.hpid) + _LOGGER.debug("Server connected: %s", self.hpid) self.on_update() def on_disconnect(self, ex: Exception | None) -> None: diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index 0c264c1c514..d37cf355fce 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -93,7 +93,7 @@ class SolarEdgeOverviewDataService(SolarEdgeDataService): for index, key in enumerate(energy_keys, start=1): # All coming values in list should be larger than the current value. if any(self.data[k] > self.data[key] for k in energy_keys[index:]): - LOGGER.info( + LOGGER.warning( "Ignoring invalid energy value %s for %s", self.data[key], key ) self.data.pop(key) diff --git a/homeassistant/components/soma/config_flow.py b/homeassistant/components/soma/config_flow.py index caf361d5c3c..346f499c6fa 100644 --- a/homeassistant/components/soma/config_flow.py +++ b/homeassistant/components/soma/config_flow.py @@ -50,7 +50,7 @@ class SomaFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="connection_error") try: result = await self.hass.async_add_executor_job(api.list_devices) - _LOGGER.info("Successfully set up Soma Connect") + _LOGGER.debug("Successfully set up Soma Connect") if result["result"] == "success": return self.async_create_entry( title="Soma Connect", diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py index 577795d172b..791c46cd07a 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -52,7 +52,7 @@ async def async_setup_entry( cover_list.append(SomfyShade(somfy_mylink, **cover_config)) - _LOGGER.info( + _LOGGER.debug( "Adding Somfy Cover: %s with targetID %s", cover_config["name"], cover_config["target_id"], diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 89c247ebbfb..7718ff799f5 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -107,7 +107,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } hass.config_entries.async_update_entry(entry, data=data, version=2) - LOGGER.info("Migration to version %s successful", entry.version) + LOGGER.debug("Migration to version %s successful", entry.version) return True diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 9f828591a08..b4063b09691 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -167,7 +167,7 @@ class SongpalEntity(MediaPlayerEntity): async def async_activate_websocket(self): """Activate websocket for listening if wanted.""" - _LOGGER.info("Activating websocket connection") + _LOGGER.debug("Activating websocket connection") async def _volume_changed(volume: VolumeChange): _LOGGER.debug("Volume changed: %s", volume) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 912a8d04f4e..82e4a5ebfba 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -413,7 +413,7 @@ class SonosDiscoveryManager: continue if self.hosts_in_error.pop(ip_addr, None): - _LOGGER.info("Connection reestablished to Sonos device %s", ip_addr) + _LOGGER.warning("Connection reestablished to Sonos device %s", ip_addr) # Each speaker has the topology for other online speakers, so add them in here if they were not # configured. The metadata is already in Soco for these. if new_hosts := { diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index c09c4ed72c4..5edd42b931a 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -289,7 +289,7 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): if not slaves: _LOGGER.warning("Unable to create zone without slaves") else: - _LOGGER.info("Creating zone with master %s", self._device.config.name) + _LOGGER.debug("Creating zone with master %s", self._device.config.name) self._device.create_zone([slave.device for slave in slaves]) def remove_zone_slave(self, slaves): @@ -305,7 +305,7 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): if not slaves: _LOGGER.warning("Unable to find slaves to remove") else: - _LOGGER.info( + _LOGGER.debug( "Removing slaves from zone with master %s", self._device.config.name ) # SoundTouch API seems to have a bug and won't remove slaves if there are @@ -327,7 +327,7 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): if not slaves: _LOGGER.warning("Unable to find slaves to add") else: - _LOGGER.info( + _LOGGER.debug( "Adding slaves to zone with master %s", self._device.config.name ) self._device.add_zone_slave([slave.device for slave in slaves]) diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 94b6ddd4efd..66537a4311e 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -70,7 +70,7 @@ class SwisscomDeviceScanner(DeviceScanner): if not self.success_init: return False - _LOGGER.info("Loading data from Swisscom Internet Box") + _LOGGER.debug("Loading data from Swisscom Internet Box") if not (data := self.get_swisscom_data()): return False @@ -95,11 +95,11 @@ class SwisscomDeviceScanner(DeviceScanner): requests.exceptions.Timeout, requests.exceptions.ConnectTimeout, ): - _LOGGER.info("No response from Swisscom Internet Box") + _LOGGER.debug("No response from Swisscom Internet Box") return devices if "status" not in request.json(): - _LOGGER.info("No status in response from Swisscom Internet Box") + _LOGGER.debug("No status in response from Swisscom Internet Box") return devices for device in request.json()["status"]: diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index d5e182a31dc..758698a7d67 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -115,7 +115,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> rf"(?:{old_unique_id})-(?P\d+)", entity_entry.unique_id ): entity_new_unique_id = f'{new_unique_id}-{match.group("id")}' - _LOGGER.info( + _LOGGER.debug( "Migrating entity %s from %s to new id %s", entity_entry.entity_id, entity_entry.unique_id, @@ -141,7 +141,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> f"{match.group('id')}-{new_unique_id}", ) } - _LOGGER.info( + _LOGGER.debug( "Migrating device %s identifiers from %s to %s", device_entry.name, device_entry.identifiers, @@ -158,6 +158,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.config_entries.async_update_entry(config_entry, version=2) - _LOGGER.info("Migration to version %s successful", config_entry.version) + _LOGGER.debug("Migration to version %s successful", config_entry.version) return True diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py index 893f052c8a0..d2d58a3ace3 100644 --- a/homeassistant/components/switchbee/entity.py +++ b/homeassistant/components/switchbee/entity.py @@ -88,7 +88,7 @@ class SwitchBeeDeviceEntity[_DeviceTypeT: SwitchBeeBaseDevice]( def _check_if_became_online(self) -> None: """Check if the device was offline (now online) and bring it back.""" if not self._is_online: - _LOGGER.info( + _LOGGER.warning( "%s device is now responding", self.name, ) diff --git a/homeassistant/components/syncthing/__init__.py b/homeassistant/components/syncthing/__init__.py index 28ec14a1935..8ef63e76825 100644 --- a/homeassistant/components/syncthing/__init__.py +++ b/homeassistant/components/syncthing/__init__.py @@ -124,7 +124,7 @@ class SyncthingClient: while True: if await self._server_available(): if server_was_unavailable: - _LOGGER.info( + _LOGGER.warning( "The syncthing server '%s' is back online", self._client.url ) async_dispatcher_send( @@ -153,7 +153,7 @@ class SyncthingClient: event, ) except aiosyncthing.exceptions.SyncthingError: - _LOGGER.info( + _LOGGER.warning( ( "The syncthing server '%s' is not available. Sleeping %i" " seconds and retrying" diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py index c6764de51a7..b3d1230fdfe 100644 --- a/homeassistant/components/syncthru/__init__.py +++ b/homeassistant/components/syncthru/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await printer.update() except SyncThruAPINotSupported as api_error: # if an exception is thrown, printer does not support syncthru - _LOGGER.info( + _LOGGER.debug( "Configured printer at %s does not provide SyncThru JSON API", printer.url, exc_info=api_error, diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index e2023aa91a1..9a6284eff2b 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -138,7 +138,7 @@ class SynoApi: except SYNOLOGY_CONNECTION_EXCEPTIONS: self._with_surveillance_station = False self.dsm.reset(SynoSurveillanceStation.API_KEY) - LOGGER.info( + LOGGER.warning( "Surveillance Station found, but disabled due to missing user" " permissions" ) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index d019361edad..29521ee537c 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -289,7 +289,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): and existing_entry.data[CONF_HOST] != host and ip(existing_entry.data[CONF_HOST]).version == ip(host).version ): - _LOGGER.info( + _LOGGER.debug( "Update host from '%s' to '%s' for NAS '%s' via discovery", existing_entry.data[CONF_HOST], host, diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 962849df360..3e0e7add185 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -100,8 +100,6 @@ class SynologySrmDeviceScanner(DeviceScanner): self.devices = [] self.success_init = self._update_info() - _LOGGER.info("Synology SRM scanner initialized") - def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 85ab8f4dd5a..36a6571f3b0 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -138,7 +138,7 @@ async def test_async_poll_manual_hosts_warnings( await manager.async_poll_manual_hosts() assert len(caplog.messages) == 1 record = caplog.records[0] - assert record.levelname == "INFO" + assert record.levelname == "WARNING" assert "Connection reestablished to Sonos device" in record.message assert mock_async_call_later.call_count == 3 From 8338075d03858422edfe13953e337e15c916b2ee Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 18 Sep 2024 21:34:11 +0200 Subject: [PATCH 0811/1309] Use debug/warning/error instead of info log level in components [x] (#126232) --- homeassistant/components/x10/light.py | 2 +- homeassistant/components/xiaomi/camera.py | 2 +- homeassistant/components/xiaomi/device_tracker.py | 2 +- homeassistant/components/xiaomi_miio/__init__.py | 8 ++++++-- .../components/xiaomi_miio/device_tracker.py | 4 ++-- homeassistant/components/xiaomi_miio/gateway.py | 2 +- homeassistant/components/xiaomi_miio/remote.py | 4 ++-- homeassistant/components/xmpp/notify.py | 14 +++++++------- 8 files changed, 21 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py index 29c15f66993..23343cb0f8d 100644 --- a/homeassistant/components/x10/light.py +++ b/homeassistant/components/x10/light.py @@ -54,7 +54,7 @@ def setup_platform( try: x10_command("info") except CalledProcessError as err: - _LOGGER.info("Assuming that the device is CM17A: %s", err.output) + _LOGGER.warning("Assuming that the device is CM17A: %s", err.output) is_cm11a = False add_entities(X10Light(light, is_cm11a) for light in config[CONF_DEVICES]) diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index 8ab15f85147..cb8d5f39dec 100644 --- a/homeassistant/components/xiaomi/camera.py +++ b/homeassistant/components/xiaomi/camera.py @@ -140,7 +140,7 @@ class XiaomiCamera(Camera): videos = [v for v in ftp.nlst() if ".tmp" not in v] if not videos: - _LOGGER.info('Video folder "%s" is empty; delaying', latest_dir) + _LOGGER.debug('Video folder "%s" is empty; delaying', latest_dir) return False if self._model == MODEL_XIAOFANG: diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py index 04f3ea6667a..9d4a29d2c78 100644 --- a/homeassistant/components/xiaomi/device_tracker.py +++ b/homeassistant/components/xiaomi/device_tracker.py @@ -139,7 +139,7 @@ def _retrieve_list(host, token, **kwargs): _LOGGER.exception("No list in response from mi router. %s", result) return None else: - _LOGGER.info( + _LOGGER.warning( "Receive wrong Xiaomi code %s, expected 0 in response %s", xiaomi_code, result, diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index bea8d9b402f..9e14a3c58ba 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -186,7 +186,9 @@ def _async_update_data_default(hass, device): except DeviceException as ex: if getattr(ex, "code", None) != -9999: raise UpdateFailed(ex) from ex - _LOGGER.info("Got exception while fetching the state, trying again: %s", ex) + _LOGGER.error( + "Got exception while fetching the state, trying again: %s", ex + ) # Try to fetch the data a second time after error code -9999 try: return await _async_fetch_data() @@ -273,7 +275,9 @@ def _async_update_data_vacuum( except DeviceException as ex: if getattr(ex, "code", None) != -9999: raise UpdateFailed(ex) from ex - _LOGGER.info("Got exception while fetching the state, trying again: %s", ex) + _LOGGER.error( + "Got exception while fetching the state, trying again: %s", ex + ) # Try to fetch the data a second time after error code -9999 try: diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py index 30cbf699646..1dfc5e53410 100644 --- a/homeassistant/components/xiaomi_miio/device_tracker.py +++ b/homeassistant/components/xiaomi_miio/device_tracker.py @@ -37,12 +37,12 @@ def get_scanner( host = config[CONF_HOST] token = config[CONF_TOKEN] - _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) try: device = WifiRepeater(host, token) device_info = device.info() - _LOGGER.info( + _LOGGER.debug( "%s %s %s detected", device_info.model, device_info.firmware_version, diff --git a/homeassistant/components/xiaomi_miio/gateway.py b/homeassistant/components/xiaomi_miio/gateway.py index ffd6279f639..dd5deec2296 100644 --- a/homeassistant/components/xiaomi_miio/gateway.py +++ b/homeassistant/components/xiaomi_miio/gateway.py @@ -87,7 +87,7 @@ class ConnectXiaomiGateway: try: self._gateway_device.discover_devices() except DeviceException as error: - _LOGGER.info( + _LOGGER.error( ( "DeviceException during getting subdevices of xiaomi gateway" " with host %s, trying cloud to obtain subdevices: %s" diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py index 72707109ad6..9c83f3f4674 100644 --- a/homeassistant/components/xiaomi_miio/remote.py +++ b/homeassistant/components/xiaomi_miio/remote.py @@ -77,7 +77,7 @@ async def async_setup_platform( token = config[CONF_TOKEN] # Create handler - _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) # The Chuang Mi IR Remote Controller wants to be re-discovered every # 5 minutes. As long as polling is disabled the device should be @@ -89,7 +89,7 @@ async def async_setup_platform( device_info = await hass.async_add_executor_job(device.info) model = device_info.model unique_id = f"{model}-{device_info.mac_address}" - _LOGGER.info( + _LOGGER.debug( "%s %s %s detected", model, device_info.firmware_version, diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index c73248f2524..3fb5dd166a1 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -190,13 +190,13 @@ async def async_send_message( # noqa: C901 _LOGGER.debug("Timeout set to %ss", timeout) url = await self.upload_file(timeout=timeout) - _LOGGER.info("Upload success") + _LOGGER.debug("Upload success") for recipient in recipients: if room: - _LOGGER.info("Sending file to %s", room) + _LOGGER.debug("Sending file to %s", room) message = self.Message(sto=room, stype="groupchat") else: - _LOGGER.info("Sending file to %s", recipient) + _LOGGER.debug("Sending file to %s", recipient) message = self.Message(sto=recipient, stype="chat") message["body"] = url message["oob"]["url"] = url @@ -264,7 +264,7 @@ async def async_send_message( # noqa: C901 uploaded via XEP_0363 and HTTP and returns the resulting URL """ - _LOGGER.info("Getting file from %s", url) + _LOGGER.debug("Getting file from %s", url) def get_url(url): """Return result for GET request to url.""" @@ -295,7 +295,7 @@ async def async_send_message( # noqa: C901 _LOGGER.debug("Got %s extension", extension) filename = self.get_random_filename(None, extension=extension) - _LOGGER.info("Uploading file from URL, %s", filename) + _LOGGER.debug("Uploading file from URL, %s", filename) return await self["xep_0363"].upload_file( filename, @@ -313,7 +313,7 @@ async def async_send_message( # noqa: C901 async def upload_file_from_path(self, path: str, timeout=None): """Upload a file from a local file path via XEP_0363.""" - _LOGGER.info("Uploading file from path, %s", path) + _LOGGER.debug("Uploading file from path, %s", path) if not hass.config.is_allowed_path(path): raise PermissionError("Could not access file. Path not allowed") @@ -374,6 +374,6 @@ async def async_send_message( # noqa: C901 @staticmethod def discard_ssl_invalid_cert(event): """Do nothing if ssl certificate is invalid.""" - _LOGGER.info("Ignoring invalid SSL certificate as requested") + _LOGGER.debug("Ignoring invalid SSL certificate as requested") SendNotificationBot() From 31b9c2fb60764a51319a01f0bae1dc3da41bdbd1 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 18 Sep 2024 21:34:31 +0200 Subject: [PATCH 0812/1309] Use debug instead of info log level in components [y] (#126233) --- homeassistant/components/yale_smart_alarm/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 3c853afb6fd..c543de89b84 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -59,6 +59,6 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, version=2) - LOGGER.info("Migration to version %s successful", entry.version) + LOGGER.debug("Migration to version %s successful", entry.version) return True From 9b60a6c0958a4b59b4496856f034ef93b8a3a4cd Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 18 Sep 2024 21:34:55 +0200 Subject: [PATCH 0813/1309] Use debug/warning/error instead of info log level in components [z] (#126234) --- homeassistant/components/zabbix/__init__.py | 2 +- homeassistant/components/zabbix/sensor.py | 2 +- homeassistant/components/zerproc/light.py | 2 +- homeassistant/components/ziggo_mediabox_xl/media_player.py | 4 ++-- homeassistant/components/zoneminder/camera.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 924903b241d..d9bab3e6fe4 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -85,7 +85,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: try: zapi = ZabbixAPI(url=url, user=username, password=password) - _LOGGER.info("Connected to Zabbix API Version %s", zapi.api_version()) + _LOGGER.debug("Connected to Zabbix API Version %s", zapi.api_version()) except ZabbixAPIException as login_exception: _LOGGER.error("Unable to login to the Zabbix API: %s", login_exception) return False diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py index 7cf1ed43cd9..f5d96f106cb 100644 --- a/homeassistant/components/zabbix/sensor.py +++ b/homeassistant/components/zabbix/sensor.py @@ -56,7 +56,7 @@ def setup_platform( _LOGGER.error("Zabbix integration hasn't been loaded? zapi is None") return - _LOGGER.info("Connected to Zabbix API Version %s", zapi.api_version()) + _LOGGER.debug("Connected to Zabbix API Version %s", zapi.api_version()) # The following code seems overly complex. Need to think about this... if trigger_conf := config.get(_CONF_TRIGGERS): diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index 71bb38dd80f..ed6ed03ad27 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -147,7 +147,7 @@ class ZerprocLight(LightEntity): self._attr_available = False return if not self.available: - _LOGGER.info("Reconnected to %s", self._light.address) + _LOGGER.warning("Reconnected to %s", self._light.address) self._attr_available = True self._attr_is_on = state.is_on hsv = color_util.color_RGB_to_hsv(*state.color) diff --git a/homeassistant/components/ziggo_mediabox_xl/media_player.py b/homeassistant/components/ziggo_mediabox_xl/media_player.py index a81a206b5b2..6e858b454e9 100644 --- a/homeassistant/components/ziggo_mediabox_xl/media_player.py +++ b/homeassistant/components/ziggo_mediabox_xl/media_player.py @@ -64,7 +64,7 @@ def setup_platform( if mediabox.test_connection(): connection_successful = True elif manual_config: - _LOGGER.info("Can't connect to %s", host) + _LOGGER.error("Can't connect to %s", host) else: _LOGGER.error("Can't connect to %s", host) # When the device is in eco mode it's not connected to the network @@ -77,7 +77,7 @@ def setup_platform( except OSError as error: _LOGGER.error("Can't connect to %s: %s", host, error) else: - _LOGGER.info("Ignoring duplicate Ziggo Mediabox XL %s", host) + _LOGGER.warning("Ignoring duplicate Ziggo Mediabox XL %s", host) add_entities(hosts, True) diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index ab938472ed7..21513b4bed4 100644 --- a/homeassistant/components/zoneminder/camera.py +++ b/homeassistant/components/zoneminder/camera.py @@ -35,7 +35,7 @@ def setup_platform( ) for monitor in monitors: - _LOGGER.info("Initializing camera %s", monitor.id) + _LOGGER.debug("Initializing camera %s", monitor.id) cameras.append(ZoneMinderCamera(monitor, zm_client.verify_ssl)) add_entities(cameras) From 6e6dae45d1ed99922212f50295907ca2fbdc16ab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 18 Sep 2024 21:59:19 +0200 Subject: [PATCH 0814/1309] Set model id on Govee lights (#126211) --- homeassistant/components/govee_light_local/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py index 60bf07e8e19..fb52c233436 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -93,7 +93,7 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity): }, name=device.sku, manufacturer=MANUFACTURER, - model=device.sku, + model_id=device.sku, serial_number=device.fingerprint, ) From 931c8f9e66193348fdcf92f93e7803d79b077f2f Mon Sep 17 00:00:00 2001 From: Ian Date: Wed, 18 Sep 2024 13:26:30 -0700 Subject: [PATCH 0815/1309] Bump nextbus to 2.0.5 (#126230) --- homeassistant/components/nextbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index d22ba66d860..6300dc1cdc9 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==2.0.4"] + "requirements": ["py-nextbusnext==2.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index f3da2cc2e25..5e2871a45cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1680,7 +1680,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.0.4 +py-nextbusnext==2.0.5 # homeassistant.components.nightscout py-nightscout==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34fb79ed2b7..1a2dac2f694 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1375,7 +1375,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.0.4 +py-nextbusnext==2.0.5 # homeassistant.components.nightscout py-nightscout==1.2.2 From f8274cd5c2f59f43fd7828c96568a309043182ba Mon Sep 17 00:00:00 2001 From: cnico Date: Wed, 18 Sep 2024 23:04:22 +0200 Subject: [PATCH 0816/1309] Addition of select platform for flipr hub (#126237) * Addition of select platform for flipr hub * Review corrections --- homeassistant/components/flipr/__init__.py | 2 +- homeassistant/components/flipr/select.py | 56 ++++++++++ homeassistant/components/flipr/strings.json | 10 ++ tests/components/flipr/test_select.py | 109 ++++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/flipr/select.py create mode 100644 tests/components/flipr/test_select.py diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index e775171bf06..99bddb5a0d0 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -15,7 +15,7 @@ from homeassistant.helpers import issue_registry as ir from .const import DOMAIN from .coordinator import FliprDataUpdateCoordinator, FliprHubDataUpdateCoordinator -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/flipr/select.py b/homeassistant/components/flipr/select.py new file mode 100644 index 00000000000..b8a8f0db60a --- /dev/null +++ b/homeassistant/components/flipr/select.py @@ -0,0 +1,56 @@ +"""Select platform for the Flipr's Hub.""" + +import logging + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FliprConfigEntry +from .entity import FliprEntity + +_LOGGER = logging.getLogger(__name__) + +SELECT_TYPES: tuple[SelectEntityDescription, ...] = ( + SelectEntityDescription( + key="hubMode", + translation_key="hub_mode", + options=["auto", "manual", "planning"], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: FliprConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up select for Flipr hub mode.""" + coordinators = config_entry.runtime_data.hub_coordinators + + async_add_entities( + FliprHubSelect(coordinator, description, True) + for description in SELECT_TYPES + for coordinator in coordinators + ) + + +class FliprHubSelect(FliprEntity, SelectEntity): + """Select representing Hub mode.""" + + @property + def current_option(self) -> str | None: + """Return current select option.""" + _LOGGER.debug("coordinator data = %s", self.coordinator.data) + return self.coordinator.data["mode"] + + async def async_select_option(self, option: str) -> None: + """Select new mode for Hub.""" + _LOGGER.debug("Changing mode of %s to %s", self.device_id, option) + data = await self.hass.async_add_executor_job( + self.coordinator.client.set_hub_mode, + self.device_id, + option, + ) + _LOGGER.debug("New hub infos are %s", data) + self.coordinator.async_set_updated_data(data) diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json index 8eebb62cb5c..631b0ce5488 100644 --- a/homeassistant/components/flipr/strings.json +++ b/homeassistant/components/flipr/strings.json @@ -39,6 +39,16 @@ "red_ox": { "name": "Red OX" } + }, + "select": { + "hub_mode": { + "name": "Mode", + "state": { + "auto": "Automatic", + "manual": "Manual", + "planning": "Planning" + } + } } }, "issues": { diff --git a/tests/components/flipr/test_select.py b/tests/components/flipr/test_select.py new file mode 100644 index 00000000000..d71297f4f1a --- /dev/null +++ b/tests/components/flipr/test_select.py @@ -0,0 +1,109 @@ +"""Test the Flipr select for Hub.""" + +import logging +from unittest.mock import AsyncMock + +from flipr_api.exceptions import FliprError + +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + +SELECT_ENTITY_ID = "select.flipr_hub_myhubid_mode" + + +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_flipr_client: AsyncMock, +) -> None: + """Test the creation and values of the Flipr select.""" + + mock_flipr_client.search_all_ids.return_value = {"flipr": [], "hub": ["myhubid"]} + + await setup_integration(hass, mock_config_entry) + + # Check entity unique_id value that is generated in FliprEntity base class. + entity = entity_registry.async_get(SELECT_ENTITY_ID) + _LOGGER.debug("Found entity = %s", entity) + assert entity.unique_id == "myhubid-hubMode" + + mode = hass.states.get(SELECT_ENTITY_ID) + _LOGGER.debug("Found mode = %s", mode) + assert mode + assert mode.state == "planning" + assert mode.attributes.get(ATTR_OPTIONS) == ["auto", "manual", "planning"] + + +async def test_select_actions( + hass: HomeAssistant, + mock_flipr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the actions on the Flipr Hub select.""" + + mock_flipr_client.search_all_ids.return_value = {"flipr": [], "hub": ["myhubid"]} + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(SELECT_ENTITY_ID) + assert state.state == "planning" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: SELECT_ENTITY_ID, ATTR_OPTION: "manual"}, + blocking=True, + ) + state = hass.states.get(SELECT_ENTITY_ID) + assert state.state == "manual" + + +async def test_no_select_found( + hass: HomeAssistant, + mock_flipr_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test the select absence.""" + + mock_flipr_client.search_all_ids.return_value = {"flipr": [], "hub": []} + + await setup_integration(hass, mock_config_entry) + + assert not hass.states.async_entity_ids(SELECT_ENTITY_ID) + + +async def test_error_flipr_api( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + mock_flipr_client: AsyncMock, +) -> None: + """Test the Flipr sensors error.""" + + mock_flipr_client.search_all_ids.return_value = {"flipr": [], "hub": ["myhubid"]} + + mock_flipr_client.get_hub_state.side_effect = FliprError( + "Error during flipr data retrieval..." + ) + + await setup_integration(hass, mock_config_entry) + + # Check entity is not generated because of the FliprError raised. + entity = entity_registry.async_get(SELECT_ENTITY_ID) + assert entity is None From d1a483880295901c701e84f14c9f0886cbccf266 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 18 Sep 2024 23:05:09 -0500 Subject: [PATCH 0817/1309] Allow one reusable proxy URL per ESPHome device (#125845) * Allow one reusable URL per device * Move process to convert info * Stop previous process * Change to 404 * Better error handling --- .../components/esphome/ffmpeg_proxy.py | 102 ++++++++------ tests/components/esphome/test_ffmpeg_proxy.py | 129 +++++++++++++++++- 2 files changed, 183 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py index 1649c628be9..c2bf72c40e5 100644 --- a/homeassistant/components/esphome/ffmpeg_proxy.py +++ b/homeassistant/components/esphome/ffmpeg_proxy.py @@ -1,7 +1,6 @@ """HTTP view that converts audio from a URL to a preferred format.""" import asyncio -from collections import defaultdict from dataclasses import dataclass, field from http import HTTPStatus import logging @@ -28,7 +27,7 @@ def async_create_proxy_url( channels: int | None = None, width: int | None = None, ) -> str: - """Create a one-time use proxy URL that automatically converts the media.""" + """Create a use proxy URL that automatically converts the media.""" data: FFmpegProxyData = hass.data[DATA_FFMPEG_PROXY] return data.async_create_proxy_url( device_id, media_url, media_format, rate, channels, width @@ -39,7 +38,10 @@ def async_create_proxy_url( class FFmpegConversionInfo: """Information for ffmpeg conversion.""" - url: str + convert_id: str + """Unique id for media conversion.""" + + media_url: str """Source URL of media to convert.""" media_format: str @@ -54,18 +56,16 @@ class FFmpegConversionInfo: width: int | None """Target sample width in bytes (None to keep source width).""" + proc: asyncio.subprocess.Process | None = None + """Subprocess doing ffmpeg conversion.""" + @dataclass class FFmpegProxyData: """Data for ffmpeg proxy conversion.""" - # device_id -> convert_id -> info - conversions: dict[str, dict[str, FFmpegConversionInfo]] = field( - default_factory=lambda: defaultdict(dict) - ) - - # device_id -> process - processes: dict[str, asyncio.subprocess.Process] = field(default_factory=dict) + # device_id -> info + conversions: dict[str, FFmpegConversionInfo] = field(default_factory=dict) def async_create_proxy_url( self, @@ -77,9 +77,19 @@ class FFmpegProxyData: width: int | None, ) -> str: """Create a one-time use proxy URL that automatically converts the media.""" + if (convert_info := self.conversions.pop(device_id, None)) is not None: + # Stop existing conversion before overwriting info + if (convert_info.proc is not None) and ( + convert_info.proc.returncode is None + ): + _LOGGER.debug( + "Stopping existing ffmpeg process for device: %s", device_id + ) + convert_info.proc.kill() + convert_id = secrets.token_urlsafe(16) - self.conversions[device_id][convert_id] = FFmpegConversionInfo( - media_url, media_format, rate, channels, width + self.conversions[device_id] = FFmpegConversionInfo( + convert_id, media_url, media_format, rate, channels, width ) _LOGGER.debug("Media URL allowed by proxy: %s", media_url) @@ -128,7 +138,7 @@ class FFmpegConvertResponse(web.StreamResponse): command_args = [ "-i", - self.convert_info.url, + self.convert_info.media_url, "-f", self.convert_info.media_format, ] @@ -156,12 +166,12 @@ class FFmpegConvertResponse(web.StreamResponse): stderr=asyncio.subprocess.PIPE, ) + # Only one conversion process per device is allowed + self.convert_info.proc = proc + assert proc.stdout is not None assert proc.stderr is not None - # Only one conversion process per device is allowed - self.proxy_data.processes[self.device_id] = proc - try: # Pull audio chunks from ffmpeg and pass them to the HTTP client while ( @@ -173,22 +183,26 @@ class FFmpegConvertResponse(web.StreamResponse): ): await writer.write(chunk) await writer.drain() + except asyncio.CancelledError: + raise # don't log error + except: + _LOGGER.exception("Unexpected error during ffmpeg conversion") + + # Process did not exit successfully + stderr_text = "" + while line := await proc.stderr.readline(): + stderr_text += line.decode() + _LOGGER.error("FFmpeg output: %s", stderr_text) + + raise finally: + # Terminate hangs, so kill is used + if proc.returncode is None: + proc.kill() + # Close connection await writer.write_eof() - # Terminate hangs, so kill is used - proc.kill() - - if proc.returncode != 0: - # Process did not exit successfully - stderr_text = "" - while line := await proc.stderr.readline(): - stderr_text += line.decode() - _LOGGER.error("Error shutting down ffmpeg: %s", stderr_text) - else: - _LOGGER.debug("Conversion completed: %s", self.convert_info) - return writer @@ -208,27 +222,25 @@ class FFmpegProxyView(HomeAssistantView): self, request: web.Request, device_id: str, filename: str ) -> web.StreamResponse: """Start a get request.""" - - # {id}.mp3 -> id - convert_id = filename.rsplit(".")[0] - - try: - convert_info = self.proxy_data.conversions[device_id].pop(convert_id) - except KeyError: - _LOGGER.error( - "Unrecognized convert id %s for device: %s", convert_id, device_id - ) + if (convert_info := self.proxy_data.conversions.get(device_id)) is None: return web.Response( - body="Convert id not recognized", status=HTTPStatus.BAD_REQUEST + body="No proxy URL for device", status=HTTPStatus.NOT_FOUND ) - # Stop any existing process - proc = self.proxy_data.processes.pop(device_id, None) - if (proc is not None) and (proc.returncode is None): - _LOGGER.debug("Stopping existing ffmpeg process for device: %s", device_id) + # {id}.mp3 -> id, mp3 + convert_id, media_format = filename.rsplit(".") - # Terminate hangs, so kill is used - proc.kill() + if (convert_info.convert_id != convert_id) or ( + convert_info.media_format != media_format + ): + return web.Response(body="Invalid proxy URL", status=HTTPStatus.BAD_REQUEST) + + # Stop previous process if the URL is being reused. + # We could continue from where the previous connection left off, but + # there would be no media header. + if (convert_info.proc is not None) and (convert_info.proc.returncode is None): + convert_info.proc.kill() + convert_info.proc = None # Stream converted audio back to client return FFmpegConvertResponse( diff --git a/tests/components/esphome/test_ffmpeg_proxy.py b/tests/components/esphome/test_ffmpeg_proxy.py index 577126201df..ef657ed8c7b 100644 --- a/tests/components/esphome/test_ffmpeg_proxy.py +++ b/tests/components/esphome/test_ffmpeg_proxy.py @@ -61,7 +61,7 @@ async def test_proxy_view( # Should fail because we haven't allowed the URL yet req = await client.get(url) - assert req.status == HTTPStatus.BAD_REQUEST + assert req.status == HTTPStatus.NOT_FOUND # Allow the URL with patch( @@ -75,6 +75,12 @@ async def test_proxy_view( == url ) + # Requesting the wrong media format should fail + wrong_url = f"/api/esphome/ffmpeg_proxy/{device_id}/{convert_id}.flac" + req = await client.get(wrong_url) + assert req.status == HTTPStatus.BAD_REQUEST + + # Correct URL req = await client.get(url) assert req.status == HTTPStatus.OK @@ -90,11 +96,11 @@ async def test_proxy_view( assert round(mp3_file.info.length, 0) == 1 -async def test_ffmpeg_error( +async def test_ffmpeg_file_doesnt_exist( hass: HomeAssistant, hass_client: ClientSessionGenerator, ) -> None: - """Test proxy HTTP view with an ffmpeg error.""" + """Test ffmpeg conversion with a file that doesn't exist.""" device_id = "1234" await async_setup_component(hass, esphome.DOMAIN, {esphome.DOMAIN: {}}) @@ -109,3 +115,120 @@ async def test_ffmpeg_error( assert req.status == HTTPStatus.OK mp3_data = await req.content.read() assert not mp3_data + + +async def test_lingering_process( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test that a new request stops the old ffmpeg process.""" + device_id = "1234" + + await async_setup_component(hass, esphome.DOMAIN, {esphome.DOMAIN: {}}) + client = await hass_client() + + with tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as temp_file: + with wave.open(temp_file.name, "wb") as wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(16000 * 2)) # 1s + + temp_file.seek(0) + wav_url = pathname2url(temp_file.name) + url1 = async_create_proxy_url( + hass, + device_id, + wav_url, + media_format="wav", + rate=22050, + channels=2, + width=2, + ) + + # First request will start ffmpeg + req1 = await client.get(url1) + assert req1.status == HTTPStatus.OK + + # Only read part of the data + await req1.content.readexactly(100) + + # Allow another URL + url2 = async_create_proxy_url( + hass, + device_id, + wav_url, + media_format="wav", + rate=22050, + channels=2, + width=2, + ) + + req2 = await client.get(url2) + assert req2.status == HTTPStatus.OK + + wav_data = await req2.content.read() + + # All of the data should be there because this is a new ffmpeg process + with io.BytesIO(wav_data) as wav_io, wave.open(wav_io, "rb") as wav_file: + # We can't use getnframes() here because the WAV header will be incorrect. + # WAV encoders usually go back and update the WAV header after all of + # the frames are written, but ffmpeg can't do that because we're + # streaming the data. + # So instead, we just read and count frames until we run out. + num_frames = 0 + while chunk := wav_file.readframes(1024): + num_frames += len(chunk) // (2 * 2) # 2 channels, 16-bit samples + + assert num_frames == 22050 # 1s + + +async def test_request_same_url_multiple_times( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test that the ffmpeg process is restarted if the same URL is requested multiple times.""" + device_id = "1234" + + await async_setup_component(hass, esphome.DOMAIN, {esphome.DOMAIN: {}}) + client = await hass_client() + + with tempfile.NamedTemporaryFile(mode="wb+", suffix=".wav") as temp_file: + with wave.open(temp_file.name, "wb") as wav_file: + wav_file.setframerate(16000) + wav_file.setsampwidth(2) + wav_file.setnchannels(1) + wav_file.writeframes(bytes(16000 * 2 * 10)) # 10s + + temp_file.seek(0) + wav_url = pathname2url(temp_file.name) + url = async_create_proxy_url( + hass, + device_id, + wav_url, + media_format="wav", + rate=22050, + channels=2, + width=2, + ) + + # First request will start ffmpeg + req1 = await client.get(url) + assert req1.status == HTTPStatus.OK + + # Only read part of the data + await req1.content.readexactly(100) + + # Second request should restart ffmpeg + req2 = await client.get(url) + assert req2.status == HTTPStatus.OK + + wav_data = await req2.content.read() + + # All of the data should be there because this is a new ffmpeg process + with io.BytesIO(wav_data) as wav_io, wave.open(wav_io, "rb") as wav_file: + num_frames = 0 + while chunk := wav_file.readframes(1024): + num_frames += len(chunk) // (2 * 2) # 2 channels, 16-bit samples + + assert num_frames == 22050 * 10 # 10s From dd10a833dbe50ecc571f5c65bf93b67944615fdb Mon Sep 17 00:00:00 2001 From: Sebastian Nohn Date: Thu, 19 Sep 2024 09:11:57 +0200 Subject: [PATCH 0818/1309] Fix tibber fails if power production is enabled but no power is produced (#126209) * fix #125312 - tibber integration fails if power production is enabled but no power is produced * fix requirements_all.txt --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 527364b6866..eb59d2456fb 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.30.1"] + "requirements": ["pyTibber==0.30.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5e2871a45cf..727cfaf8a00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1725,7 +1725,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.30.1 +pyTibber==0.30.2 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a2dac2f694..3df4a5d6492 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1402,7 +1402,7 @@ pyElectra==1.2.4 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.30.1 +pyTibber==0.30.2 # homeassistant.components.dlink pyW215==0.7.0 From 4d63bf473d689fd575365937de9ac63eb0826833 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 19 Sep 2024 09:50:47 +0200 Subject: [PATCH 0819/1309] Add validation to set_humidity action in humidifier (#125863) --- .../components/humidifier/__init__.py | 38 ++++++++- .../components/humidifier/strings.json | 5 ++ tests/components/humidifier/conftest.py | 69 +++++++++++++++ tests/components/humidifier/test_init.py | 83 ++++++++++++++++++- 4 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 tests/components/humidifier/conftest.py diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 37e2bd3e3ba..605bd4284f8 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -18,7 +18,8 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.deprecation import ( all_with_deprecated_constants, @@ -45,7 +46,13 @@ from .const import ( # noqa: F401 DOMAIN, MODE_AUTO, MODE_AWAY, + MODE_BABY, + MODE_BOOST, + MODE_COMFORT, + MODE_ECO, + MODE_HOME, MODE_NORMAL, + MODE_SLEEP, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, HumidifierAction, @@ -108,7 +115,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Coerce(int), vol.Range(min=0, max=100) ) }, - "async_set_humidity", + async_service_humidity_set, ) return True @@ -281,6 +288,33 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT return features +async def async_service_humidity_set( + entity: HumidifierEntity, service_call: ServiceCall +) -> None: + """Handle set humidity service.""" + humidity = service_call.data[ATTR_HUMIDITY] + min_humidity = entity.min_humidity + max_humidity = entity.max_humidity + _LOGGER.debug( + "Check valid humidity %d in range %d - %d", + humidity, + min_humidity, + max_humidity, + ) + if humidity < min_humidity or humidity > max_humidity: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="humidity_out_of_range", + translation_placeholders={ + "humidity": str(humidity), + "min_humidity": str(min_humidity), + "max_humidity": str(max_humidity), + }, + ) + + await entity.async_set_humidity(humidity) + + # As we import deprecated constants from the const module, we need to add these two functions # otherwise this module will be logged for using deprecated constants and not the custom component # These can be removed if no deprecated constant are in this module anymore diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 0416f4a68a6..753368dc572 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -115,5 +115,10 @@ "name": "[%key:common::action::toggle%]", "description": "Toggles the humidifier on/off." } + }, + "exceptions": { + "humidity_out_of_range": { + "message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}." + } } } diff --git a/tests/components/humidifier/conftest.py b/tests/components/humidifier/conftest.py new file mode 100644 index 00000000000..9fe1720ffc0 --- /dev/null +++ b/tests/components/humidifier/conftest.py @@ -0,0 +1,69 @@ +"""Fixtures for Humidifier platform tests.""" + +from collections.abc import Generator + +import pytest + +from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield + + +@pytest.fixture +def register_test_integration( + hass: HomeAssistant, config_flow_fixture: None +) -> Generator: + """Provide a mocked integration for tests.""" + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + async def help_async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [HUMIDIFIER_DOMAIN] + ) + return True + + async def help_async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config emntry.""" + return await hass.config_entries.async_unload_platforms( + config_entry, [Platform.HUMIDIFIER] + ) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + + return config_entry diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index b31750a3a3b..2725f942576 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -8,16 +8,28 @@ import pytest from homeassistant.components import humidifier from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, ATTR_MODE, + DOMAIN as HUMIDIFIER_DOMAIN, + MODE_ECO, + MODE_NORMAL, + SERVICE_SET_HUMIDITY, HumidifierEntity, HumidifierEntityFeature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError -from tests.common import help_test_all, import_and_test_deprecated_constant_enum +from tests.common import ( + MockConfigEntry, + MockEntity, + help_test_all, + import_and_test_deprecated_constant_enum, + setup_test_component_platform, +) -class MockHumidifierEntity(HumidifierEntity): +class MockHumidifierEntity(MockEntity, HumidifierEntity): """Mock Humidifier device to use in tests.""" @property @@ -101,3 +113,70 @@ def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> assert "is using deprecated supported features values" not in caplog.text assert entity.state_attributes[ATTR_MODE] == "mode1" + + +async def test_humidity_validation( + hass: HomeAssistant, + register_test_integration: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test validation for humidity.""" + + class MockHumidifierEntityHumidity(MockEntity, HumidifierEntity): + """Mock climate class with mocked aux heater.""" + + _attr_supported_features = HumidifierEntityFeature.MODES + _attr_available_modes = [MODE_NORMAL, MODE_ECO] + _attr_mode = MODE_NORMAL + _attr_target_humidity = 50 + _attr_min_humidity = 50 + _attr_max_humidity = 60 + + def set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + self._attr_target_humidity = humidity + + test_humidifier = MockHumidifierEntityHumidity( + name="Test", + unique_id="unique_humidifier_test", + ) + + setup_test_component_platform( + hass, HUMIDIFIER_DOMAIN, entities=[test_humidifier], from_config_entry=True + ) + await hass.config_entries.async_setup(register_test_integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test") + assert state.attributes.get(ATTR_HUMIDITY) == 50 + + with pytest.raises( + ServiceValidationError, + match="Provided humidity 1 is not valid. Accepted range is 50 to 60", + ) as exc: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": "humidifier.test", + ATTR_HUMIDITY: "1", + }, + blocking=True, + ) + + assert exc.value.translation_key == "humidity_out_of_range" + assert "Check valid humidity 1 in range 50 - 60" in caplog.text + + with pytest.raises( + ServiceValidationError, + match="Provided humidity 70 is not valid. Accepted range is 50 to 60", + ) as exc: + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_SET_HUMIDITY, + { + "entity_id": "humidifier.test", + ATTR_HUMIDITY: "70", + }, + blocking=True, + ) From 1dd1de2636e54df75976eeaecf93cd004145ce5e Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 19 Sep 2024 10:07:28 +0200 Subject: [PATCH 0820/1309] Pass default value in Z-Wave websocket handler for configuration values (#125343) * Pass default value in zwave websocket handler for configuration values * Update test --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/api.py | 1 + tests/components/zwave_js/test_api.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 8f81790708f..b43528fe358 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1713,6 +1713,7 @@ async def websocket_get_config_parameters( "unit": metadata.unit, "writeable": metadata.writeable, "readable": metadata.readable, + "default": metadata.default, }, "value": zwave_value.value, } diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 0437f9d9085..bb236ea9acb 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3048,9 +3048,21 @@ async def test_get_config_parameters( assert result[key]["property"] == 2 assert result[key]["property_key"] is None assert result[key]["endpoint"] == 0 - assert result[key]["metadata"]["type"] == "number" assert result[key]["configuration_value_type"] == "enumerated" assert result[key]["metadata"]["states"] + assert ( + result[key]["metadata"]["description"] + == "Stay awake for 10 minutes at power on" + ) + assert result[key]["metadata"]["label"] == "Stay Awake in Battery Mode" + assert result[key]["metadata"]["type"] == "number" + assert result[key]["metadata"]["min"] == 0 + assert result[key]["metadata"]["max"] == 1 + assert result[key]["metadata"]["unit"] is None + assert result[key]["metadata"]["writeable"] is True + assert result[key]["metadata"]["readable"] is True + assert result[key]["metadata"]["default"] == 0 + assert result[key]["value"] == 0 key = "52-112-0-201-255" assert result[key]["property_key"] == 255 From 31f9687ba1cfa9e5f6b9382fc7ffc70922c5bdaf Mon Sep 17 00:00:00 2001 From: TimL Date: Thu, 19 Sep 2024 18:29:02 +1000 Subject: [PATCH 0821/1309] Update repairs for Smlight integration to allow firmware updates where possible (#126113) * Dont launch SSE client for core firmware 0.9.9 * Dont offer updates on core firmware 0.9.9 * Add correct firmware done event for legacy v2 firmware * test update legacy v2 firmware * Dont raise issue for firmware v2 --- homeassistant/components/smlight/__init__.py | 6 +- .../components/smlight/coordinator.py | 5 +- homeassistant/components/smlight/update.py | 8 +++ tests/components/smlight/test_init.py | 4 +- tests/components/smlight/test_update.py | 55 +++++++++++++++++-- 5 files changed, 67 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index 52db6c8770b..cbfb8162d63 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -36,7 +36,6 @@ type SmConfigEntry = ConfigEntry[SmlightData] async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: """Set up SMLIGHT Zigbee from a config entry.""" client = Api2(host=entry.data[CONF_HOST], session=async_get_clientsession(hass)) - entry.async_create_background_task(hass, client.sse.client(), "smlight-sse-client") data_coordinator = SmDataUpdateCoordinator(hass, entry.data[CONF_HOST], client) firmware_coordinator = SmFirmwareUpdateCoordinator( @@ -46,6 +45,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool: await data_coordinator.async_config_entry_first_refresh() await firmware_coordinator.async_config_entry_first_refresh() + if data_coordinator.data.info.legacy_api < 2: + entry.async_create_background_task( + hass, client.sse.client(), "smlight-sse-client" + ) + entry.runtime_data = SmlightData( data=data_coordinator, firmware=firmware_coordinator ) diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py index e5ef21bd531..5b38ec4a89e 100644 --- a/homeassistant/components/smlight/coordinator.py +++ b/homeassistant/components/smlight/coordinator.py @@ -80,9 +80,8 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): info = await self.client.get_info() self.unique_id = format_mac(info.MAC) - - if info.legacy_api: - self.legacy_api = info.legacy_api + self.legacy_api = info.legacy_api + if info.legacy_api == 2: ir.async_create_issue( self.hass, DOMAIN, diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py index e00499760b1..cb28a197860 100644 --- a/homeassistant/components/smlight/update.py +++ b/homeassistant/components/smlight/update.py @@ -102,6 +102,8 @@ class SmUpdateEntity(SmEntity, UpdateEntity): def latest_version(self) -> str | None: """Latest version available for install.""" data = self.coordinator.data + if self.coordinator.legacy_api == 2: + return None fw = self.entity_description.fw_list(data) @@ -126,6 +128,12 @@ class SmUpdateEntity(SmEntity, UpdateEntity): SmEvents.FW_UPD_done, self._update_finished ) ) + if self.coordinator.legacy_api == 1: + self._unload.append( + self.coordinator.client.sse.register_callback( + SmEvents.ESP_UPD_done, self._update_finished + ) + ) self._unload.append( self.coordinator.client.sse.register_callback( SmEvents.ZB_FW_err, self._update_failed diff --git a/tests/components/smlight/test_init.py b/tests/components/smlight/test_init.py index eb7b6396d26..afc53932fb0 100644 --- a/tests/components/smlight/test_init.py +++ b/tests/components/smlight/test_init.py @@ -122,10 +122,10 @@ async def test_device_legacy_firmware( issue_registry: IssueRegistry, ) -> None: """Test device setup for old firmware version that dont support required API.""" - LEGACY_VERSION = "v2.3.1" + LEGACY_VERSION = "v0.9.9" mock_smlight_client.get_sensors.side_effect = SmlightError mock_smlight_client.get_info.return_value = Info( - legacy_api=1, sw_version=LEGACY_VERSION, MAC="AA:BB:CC:DD:EE:FF" + legacy_api=2, sw_version=LEGACY_VERSION, MAC="AA:BB:CC:DD:EE:FF" ) entry = await setup_integration(hass, mock_config_entry) diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index b8b8de8a09b..b0b8910ef9b 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -126,10 +126,7 @@ async def test_update_firmware( mock_smlight_client, SmEvents.ZB_FW_prgs ) - async def _call_event_function(event: MessageEvent): - event_function(event) - - await _call_event_function(MOCK_FIRMWARE_PROGRESS) + event_function(MOCK_FIRMWARE_PROGRESS) state = hass.states.get(entity_id) assert state.attributes[ATTR_IN_PROGRESS] == 50 @@ -137,7 +134,55 @@ async def test_update_firmware( mock_smlight_client, SmEvents.FW_UPD_done ) - await _call_event_function(MOCK_FIRMWARE_DONE) + event_function(MOCK_FIRMWARE_DONE) + + mock_smlight_client.get_info.return_value = Info( + sw_version="v2.5.2", + ) + + freezer.tick(SCAN_FIRMWARE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.5.2" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + + +async def test_update_legacy_firmware_v2( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test firmware update for legacy v2 firmware.""" + mock_smlight_client.get_info.return_value = Info( + sw_version="v2.0.18", + legacy_api=1, + MAC="AA:BB:CC:DD:EE:FF", + ) + await setup_integration(hass, mock_config_entry) + entity_id = "update.mock_title_core_firmware" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == "v2.0.18" + assert state.attributes[ATTR_LATEST_VERSION] == "v2.5.2" + + await hass.services.async_call( + PLATFORM, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=False, + ) + + assert len(mock_smlight_client.fw_update.mock_calls) == 1 + + event_function: Callable[[MessageEvent], None] = get_callback_function( + mock_smlight_client, SmEvents.ESP_UPD_done + ) + + event_function(MOCK_FIRMWARE_DONE) mock_smlight_client.get_info.return_value = Info( sw_version="v2.5.2", From 5d2f8319b159f9e235336f232d7510164e0c37c1 Mon Sep 17 00:00:00 2001 From: Alberto Montes Date: Thu, 19 Sep 2024 10:32:38 +0200 Subject: [PATCH 0822/1309] Update string formatting to use f-string on tests (#125986) * Update string formatting to use f-string on tests * Update test_package.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Update statement given feedback --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .../components/config/test_config_entries.py | 8 +-- tests/components/datadog/test_init.py | 2 +- tests/components/directv/test_media_player.py | 6 +-- .../dte_energy_bridge/test_sensor.py | 6 +-- tests/components/emulated_hue/test_hue_api.py | 8 +-- tests/components/html5/test_notify.py | 2 +- tests/components/http/test_auth.py | 2 +- tests/components/intent/test_init.py | 4 +- tests/components/locative/test_init.py | 42 +++++---------- .../components/lovelace/test_system_health.py | 2 +- .../components/meraki/test_device_tracker.py | 8 +-- .../mobile_app/test_device_tracker.py | 6 +-- tests/components/mobile_app/test_webhook.py | 52 +++++++++---------- .../owntracks/test_device_tracker.py | 2 +- tests/components/ps4/test_init.py | 4 +- tests/components/ps4/test_media_player.py | 33 ++++-------- tests/components/traccar/test_init.py | 26 ++++------ .../trafikverket_ferry/test_config_flow.py | 4 +- .../trafikverket_train/test_config_flow.py | 4 +- tests/helpers/test_dispatcher.py | 2 +- tests/helpers/test_icon.py | 8 +-- tests/util/test_package.py | 4 +- 22 files changed, 87 insertions(+), 148 deletions(-) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index a4dc91d5355..4c61ab506e3 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -791,9 +791,7 @@ async def test_get_progress_flow(hass: HomeAssistant, client: TestClient) -> Non assert resp.status == HTTPStatus.OK data = await resp.json() - resp2 = await client.get( - "/api/config/config_entries/flow/{}".format(data["flow_id"]) - ) + resp2 = await client.get(f"/api/config/config_entries/flow/{data['flow_id']}") assert resp2.status == HTTPStatus.OK data2 = await resp2.json() @@ -829,9 +827,7 @@ async def test_get_progress_flow_unauth( hass_admin_user.groups = [] - resp2 = await client.get( - "/api/config/config_entries/flow/{}".format(data["flow_id"]) - ) + resp2 = await client.get(f"/api/config/config_entries/flow/{data['flow_id']}") assert resp2.status == HTTPStatus.UNAUTHORIZED diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py index 36c1d951078..3b7bea3c926 100644 --- a/tests/components/datadog/test_init.py +++ b/tests/components/datadog/test_init.py @@ -79,7 +79,7 @@ async def test_logbook_entry(hass: HomeAssistant) -> None: assert mock_statsd.event.call_count == 1 assert mock_statsd.event.call_args == mock.call( title="Home Assistant", - text="%%% \n **{}** {} \n %%%".format(event["name"], event["message"]), + text=f"%%% \n **{event['name']}** {event['message']} \n %%%", tags=["entity:sensor.foo.bar", "domain:automation"], ) diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 33eb35ed268..37762a22fe2 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -215,7 +215,7 @@ async def test_check_attributes( assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) assert state.attributes.get(ATTR_MEDIA_TITLE) == "Snow Bride" assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) is None - assert state.attributes.get(ATTR_MEDIA_CHANNEL) == "{} ({})".format("HALLHD", "312") + assert state.attributes.get(ATTR_MEDIA_CHANNEL) == "HALLHD (312)" assert state.attributes.get(ATTR_INPUT_SOURCE) == "312" assert not state.attributes.get(ATTR_MEDIA_CURRENTLY_RECORDING) assert state.attributes.get(ATTR_MEDIA_RATING) == "TV-G" @@ -234,7 +234,7 @@ async def test_check_attributes( assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) assert state.attributes.get(ATTR_MEDIA_TITLE) == "Tyler's Ultimate" assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) == "Spaghetti and Clam Sauce" - assert state.attributes.get(ATTR_MEDIA_CHANNEL) == "{} ({})".format("FOODHD", "231") + assert state.attributes.get(ATTR_MEDIA_CHANNEL) == "FOODHD (231)" assert state.attributes.get(ATTR_INPUT_SOURCE) == "231" assert not state.attributes.get(ATTR_MEDIA_CURRENTLY_RECORDING) assert state.attributes.get(ATTR_MEDIA_RATING) == "No Rating" @@ -255,7 +255,7 @@ async def test_check_attributes( assert state.attributes.get(ATTR_MEDIA_ARTIST) == "Gerald Albright" assert state.attributes.get(ATTR_MEDIA_ALBUM_NAME) == "Slam Dunk (2014)" assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) is None - assert state.attributes.get(ATTR_MEDIA_CHANNEL) == "{} ({})".format("MCSJ", "851") + assert state.attributes.get(ATTR_MEDIA_CHANNEL) == "MCSJ (851)" assert state.attributes.get(ATTR_INPUT_SOURCE) == "851" assert not state.attributes.get(ATTR_MEDIA_CURRENTLY_RECORDING) assert state.attributes.get(ATTR_MEDIA_RATING) == "TV-PG" diff --git a/tests/components/dte_energy_bridge/test_sensor.py b/tests/components/dte_energy_bridge/test_sensor.py index 244bec4e270..41d340fae48 100644 --- a/tests/components/dte_energy_bridge/test_sensor.py +++ b/tests/components/dte_energy_bridge/test_sensor.py @@ -20,7 +20,7 @@ async def test_setup_correct_reading(hass: HomeAssistant) -> None: """Test DTE Energy bridge returns a correct value.""" with requests_mock.Mocker() as mock_req: mock_req.get( - "http://{}/instantaneousdemand".format(DTE_ENERGY_BRIDGE_CONFIG["ip"]), + f"http://{DTE_ENERGY_BRIDGE_CONFIG['ip']}/instantaneousdemand", text=".411 kW", ) assert await async_setup_component( @@ -34,7 +34,7 @@ async def test_setup_incorrect_units_reading(hass: HomeAssistant) -> None: """Test DTE Energy bridge handles a value with incorrect units.""" with requests_mock.Mocker() as mock_req: mock_req.get( - "http://{}/instantaneousdemand".format(DTE_ENERGY_BRIDGE_CONFIG["ip"]), + f"http://{DTE_ENERGY_BRIDGE_CONFIG['ip']}/instantaneousdemand", text="411 kW", ) assert await async_setup_component( @@ -48,7 +48,7 @@ async def test_setup_bad_format_reading(hass: HomeAssistant) -> None: """Test DTE Energy bridge handles an invalid value.""" with requests_mock.Mocker() as mock_req: mock_req.get( - "http://{}/instantaneousdemand".format(DTE_ENERGY_BRIDGE_CONFIG["ip"]), + f"http://{DTE_ENERGY_BRIDGE_CONFIG['ip']}/instantaneousdemand", text="411", ) assert await async_setup_component( diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 28e269fdaeb..a445f8bae0d 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -1248,9 +1248,7 @@ async def test_proper_put_state_request(hue_client: TestClient) -> None: """Test the request to set the state.""" # Test proper on value parsing result = await hue_client.put( - "/api/username/lights/{}/state".format( - ENTITY_NUMBERS_BY_ID["light.ceiling_lights"] - ), + f"/api/username/lights/{ENTITY_NUMBERS_BY_ID['light.ceiling_lights']}/state", data=json.dumps({HUE_API_STATE_ON: 1234}), ) @@ -1258,9 +1256,7 @@ async def test_proper_put_state_request(hue_client: TestClient) -> None: # Test proper brightness value parsing result = await hue_client.put( - "/api/username/lights/{}/state".format( - ENTITY_NUMBERS_BY_ID["light.ceiling_lights"] - ), + f"/api/username/lights/{ENTITY_NUMBERS_BY_ID['light.ceiling_lights']}/state", data=json.dumps({HUE_API_STATE_ON: True, HUE_API_STATE_BRI: "Hello world!"}), ) diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py index 85a790c0610..0d9388907a9 100644 --- a/tests/components/html5/test_notify.py +++ b/tests/components/html5/test_notify.py @@ -495,7 +495,7 @@ async def test_callback_view_with_jwt( assert push_payload["body"] == "Hello" assert push_payload["icon"] == "beer.png" - bearer_token = "Bearer {}".format(push_payload["data"]["jwt"]) + bearer_token = f"Bearer {push_payload['data']['jwt']}" resp = await client.post( PUBLISH_URL, json={"type": "push"}, headers={AUTHORIZATION: bearer_token} diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 76c512c9686..052c0031469 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -312,7 +312,7 @@ async def test_auth_access_signed_path_with_refresh_token( assert data["user_id"] == refresh_token.user.id # Use signature on other path - req = await client.get("/another_path?{}".format(signed_path.split("?")[1])) + req = await client.get(f"/another_path?{signed_path.split('?')[1]}") assert req.status == HTTPStatus.UNAUTHORIZED # We only allow GET diff --git a/tests/components/intent/test_init.py b/tests/components/intent/test_init.py index 7288c4855af..659ca16c0bb 100644 --- a/tests/components/intent/test_init.py +++ b/tests/components/intent/test_init.py @@ -34,11 +34,11 @@ async def test_http_handle_intent( assert intent_obj.context.user_id == hass_admin_user.id response = intent_obj.create_response() response.async_set_speech( - "I've ordered a {}!".format(intent_obj.slots["type"]["value"]) + f"I've ordered a {intent_obj.slots['type']['value']}!" ) response.async_set_card( "Beer ordered", - "You chose a {}.".format(intent_obj.slots["type"]["value"]), + f"You chose a {intent_obj.slots['type']['value']}.", ) return response diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py index 8fd239ee398..89d26ea6c7a 100644 --- a/tests/components/locative/test_init.py +++ b/tests/components/locative/test_init.py @@ -134,9 +134,7 @@ async def test_enter_and_exit( req = await locative_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert state_name == "home" data["id"] = "HOME" @@ -146,9 +144,7 @@ async def test_enter_and_exit( req = await locative_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert state_name == "not_home" data["id"] = "hOmE" @@ -158,9 +154,7 @@ async def test_enter_and_exit( req = await locative_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert state_name == "home" data["trigger"] = "exit" @@ -169,9 +163,7 @@ async def test_enter_and_exit( req = await locative_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert state_name == "not_home" data["id"] = "work" @@ -181,9 +173,7 @@ async def test_enter_and_exit( req = await locative_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert state_name == "work" @@ -206,7 +196,7 @@ async def test_exit_after_enter( await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}") assert state.state == "home" data["id"] = "Work" @@ -216,7 +206,7 @@ async def test_exit_after_enter( await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}") assert state.state == "work" data["id"] = "Home" @@ -227,7 +217,7 @@ async def test_exit_after_enter( await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}") assert state.state == "work" @@ -250,7 +240,7 @@ async def test_exit_first( await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}") assert state.state == "not_home" @@ -273,9 +263,7 @@ async def test_two_devices( await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data_device_1["device"]) - ) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data_device_1['device']}") assert state.state == "not_home" # Enter Home @@ -286,13 +274,9 @@ async def test_two_devices( await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data_device_2["device"]) - ) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data_device_2['device']}") assert state.state == "home" - state = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data_device_1["device"]) - ) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data_device_1['device']}") assert state.state == "not_home" @@ -318,7 +302,7 @@ async def test_load_unload_entry( await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}") assert state.state == "not_home" assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1 diff --git a/tests/components/lovelace/test_system_health.py b/tests/components/lovelace/test_system_health.py index 4fe248fa950..251153fe419 100644 --- a/tests/components/lovelace/test_system_health.py +++ b/tests/components/lovelace/test_system_health.py @@ -72,6 +72,6 @@ async def test_system_health_info_yaml_not_found(hass: HomeAssistant) -> None: assert info == { "dashboards": 1, "mode": "yaml", - "error": "{} not found".format(hass.config.path("ui-lovelace.yaml")), + "error": f"{hass.config.path('ui-lovelace.yaml')} not found", "resources": 0, } diff --git a/tests/components/meraki/test_device_tracker.py b/tests/components/meraki/test_device_tracker.py index c3126f7b76a..139396a0689 100644 --- a/tests/components/meraki/test_device_tracker.py +++ b/tests/components/meraki/test_device_tracker.py @@ -142,12 +142,8 @@ async def test_data_will_be_saved( req = await meraki_client.post(URL, data=json.dumps(data)) assert req.status == HTTPStatus.OK await hass.async_block_till_done() - state_name = hass.states.get( - "{}.{}".format("device_tracker", "00_26_ab_b8_a9_a4") - ).state + state_name = hass.states.get("device_tracker.00_26_ab_b8_a9_a4").state assert state_name == "home" - state_name = hass.states.get( - "{}.{}".format("device_tracker", "00_26_ab_b8_a9_a5") - ).state + state_name = hass.states.get("device_tracker.00_26_ab_b8_a9_a5").state assert state_name == "home" diff --git a/tests/components/mobile_app/test_device_tracker.py b/tests/components/mobile_app/test_device_tracker.py index d1cbc21c36b..92a956ab629 100644 --- a/tests/components/mobile_app/test_device_tracker.py +++ b/tests/components/mobile_app/test_device_tracker.py @@ -15,7 +15,7 @@ async def test_sending_location( ) -> None: """Test sending a location via a webhook.""" resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "update_location", "data": { @@ -48,7 +48,7 @@ async def test_sending_location( assert state.attributes["vertical_accuracy"] == 80 resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "update_location", "data": { @@ -87,7 +87,7 @@ async def test_restoring_location( ) -> None: """Test sending a location via a webhook.""" resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "update_location", "data": { diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 61e342a45ce..dda5f369ad5 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -101,7 +101,7 @@ async def test_webhook_handle_render_template( ) -> None: """Test that we render templates properly.""" resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "render_template", "data": { @@ -133,7 +133,7 @@ async def test_webhook_handle_call_services( calls = async_mock_service(hass, "test", "mobile_app") resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json=CALL_SERVICE, ) @@ -158,7 +158,7 @@ async def test_webhook_handle_fire_event( hass.bus.async_listen("test_event", store_event) resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), json=FIRE_EVENT + f"/api/webhook/{create_registrations[1]['webhook_id']}", json=FIRE_EVENT ) assert resp.status == HTTPStatus.OK @@ -224,7 +224,7 @@ async def test_webhook_handle_get_zones( await hass.services.async_call(ZONE_DOMAIN, "reload", blocking=True) resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={"type": "get_zones"}, ) @@ -317,7 +317,7 @@ async def test_webhook_returns_error_incorrect_json( ) -> None: """Test that an error is returned when JSON is invalid.""" resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), data="not json" + f"/api/webhook/{create_registrations[1]['webhook_id']}", data="not json" ) assert resp.status == HTTPStatus.BAD_REQUEST @@ -350,7 +350,7 @@ async def test_webhook_handle_decryption( container = {"type": msg["type"], "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -374,7 +374,7 @@ async def test_webhook_handle_decryption_legacy( container = {"type": "render_template", "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -399,7 +399,7 @@ async def test_webhook_handle_decryption_fail( data = encrypt_payload(key, RENDER_TEMPLATE["data"]) container = {"type": "render_template", "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -412,7 +412,7 @@ async def test_webhook_handle_decryption_fail( data = encrypt_payload(key, "{not_valid", encode_json=False) container = {"type": "render_template", "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -424,7 +424,7 @@ async def test_webhook_handle_decryption_fail( data = encrypt_payload(key[::-1], RENDER_TEMPLATE["data"]) container = {"type": "render_template", "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -444,7 +444,7 @@ async def test_webhook_handle_decryption_legacy_fail( data = encrypt_payload_legacy(key, RENDER_TEMPLATE["data"]) container = {"type": "render_template", "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -457,7 +457,7 @@ async def test_webhook_handle_decryption_legacy_fail( data = encrypt_payload_legacy(key, "{not_valid", encode_json=False) container = {"type": "render_template", "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -469,7 +469,7 @@ async def test_webhook_handle_decryption_legacy_fail( data = encrypt_payload_legacy(key[::-1], RENDER_TEMPLATE["data"]) container = {"type": "render_template", "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -490,7 +490,7 @@ async def test_webhook_handle_decryption_legacy_upgrade( container = {"type": "render_template", "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -508,7 +508,7 @@ async def test_webhook_handle_decryption_legacy_upgrade( container = {"type": "render_template", "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -526,7 +526,7 @@ async def test_webhook_handle_decryption_legacy_upgrade( container = {"type": "render_template", "encrypted": True, "encrypted_data": data} resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), json=container + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=container ) assert resp.status == HTTPStatus.OK @@ -539,7 +539,7 @@ async def test_webhook_requires_encryption( ) -> None: """Test that encrypted registrations only accept encrypted data.""" resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[0]["webhook_id"]), + f"/api/webhook/{create_registrations[0]['webhook_id']}", json=RENDER_TEMPLATE, ) @@ -560,7 +560,7 @@ async def test_webhook_update_location_without_locations( # start off with a location set by name resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "update_location", "data": {"location_name": STATE_HOME}, @@ -575,7 +575,7 @@ async def test_webhook_update_location_without_locations( # set location to an 'unknown' state resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "update_location", "data": {"altitude": 123}, @@ -597,7 +597,7 @@ async def test_webhook_update_location_with_gps( ) -> None: """Test that location can be updated.""" resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "update_location", "data": {"gps": [1, 2], "gps_accuracy": 10, "altitude": -10}, @@ -621,7 +621,7 @@ async def test_webhook_update_location_with_gps_without_accuracy( ) -> None: """Test that location can be updated.""" resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "update_location", "data": {"gps": [1, 2]}, @@ -659,7 +659,7 @@ async def test_webhook_update_location_with_location_name( await hass.services.async_call(ZONE_DOMAIN, "reload", blocking=True) resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "update_location", "data": {"location_name": "zone_name"}, @@ -672,7 +672,7 @@ async def test_webhook_update_location_with_location_name( assert state.state == "zone_name" resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "update_location", "data": {"location_name": STATE_HOME}, @@ -685,7 +685,7 @@ async def test_webhook_update_location_with_location_name( assert state.state == STATE_HOME resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "update_location", "data": {"location_name": STATE_NOT_HOME}, @@ -876,7 +876,7 @@ async def test_webhook_handle_scan_tag( events = async_capture_events(hass, EVENT_TAG_SCANNED) resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={"type": "scan_tag", "data": {"tag_id": "mock-tag-id"}}, ) @@ -1052,7 +1052,7 @@ async def test_webhook_handle_conversation_process( return_value=mock_conversation_agent, ): resp = await webhook_client.post( - "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + f"/api/webhook/{create_registrations[1]['webhook_id']}", json={ "type": "conversation_process", "data": { diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 2f35139c021..93f40d0ae3d 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -1540,7 +1540,7 @@ async def test_encrypted_payload_wrong_topic_key( async def test_encrypted_payload_no_topic_key(hass: HomeAssistant, setup_comp) -> None: """Test encrypted payload with no topic key.""" await setup_owntracks( - hass, {CONF_SECRET: {"owntracks/{}/{}".format(USER, "otherdevice"): "foobar"}} + hass, {CONF_SECRET: {f"owntracks/{USER}/otherdevice": "foobar"}} ) await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) assert hass.states.get(DEVICE_TRACKER_STATE) is None diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index 3a9aac38646..d14f367b2bd 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -269,9 +269,7 @@ async def test_send_command(hass: HomeAssistant) -> None: """Test send_command service.""" await setup_mock_component(hass) - mock_func = "{}{}".format( - "homeassistant.components.ps4", ".media_player.PS4Device.async_send_command" - ) + mock_func = "homeassistant.components.ps4.media_player.PS4Device.async_send_command" mock_devices = hass.data[PS4_DATA].devices assert len(mock_devices) == 1 diff --git a/tests/components/ps4/test_media_player.py b/tests/components/ps4/test_media_player.py index 5268306c87a..737cc3c9f1b 100644 --- a/tests/components/ps4/test_media_player.py +++ b/tests/components/ps4/test_media_player.py @@ -194,10 +194,7 @@ async def test_state_standby_is_set(hass: HomeAssistant) -> None: async def test_state_playing_is_set(hass: HomeAssistant) -> None: """Test that state is set to playing.""" mock_entity_id = await setup_mock_component(hass) - mock_func = "{}{}".format( - "homeassistant.components.ps4.media_player.", - "pyps4.Ps4Async.async_get_ps_store_data", - ) + mock_func = "homeassistant.components.ps4.media_player.pyps4.Ps4Async.async_get_ps_store_data" with patch(mock_func, return_value=None): await mock_ddp_response(hass, MOCK_STATUS_PLAYING) @@ -224,10 +221,7 @@ async def test_state_none_is_set(hass: HomeAssistant) -> None: async def test_media_attributes_are_fetched(hass: HomeAssistant) -> None: """Test that media attributes are fetched.""" mock_entity_id = await setup_mock_component(hass) - mock_func = "{}{}".format( - "homeassistant.components.ps4.media_player.", - "pyps4.Ps4Async.async_get_ps_store_data", - ) + mock_func = "homeassistant.components.ps4.media_player.pyps4.Ps4Async.async_get_ps_store_data" # Mock result from fetching data. mock_result = MagicMock() @@ -276,8 +270,7 @@ async def test_media_attributes_are_loaded( patch_load_json_object.return_value = {MOCK_TITLE_ID: MOCK_GAMES_DATA_LOCKED} with patch( - "homeassistant.components.ps4.media_player." - "pyps4.Ps4Async.async_get_ps_store_data", + "homeassistant.components.ps4.media_player.pyps4.Ps4Async.async_get_ps_store_data", return_value=None, ) as mock_fetch: await mock_ddp_response(hass, MOCK_STATUS_PLAYING) @@ -381,9 +374,7 @@ async def test_device_info_assummed_works( async def test_turn_on(hass: HomeAssistant) -> None: """Test that turn on service calls function.""" mock_entity_id = await setup_mock_component(hass) - mock_func = "{}{}".format( - "homeassistant.components.ps4.media_player.", "pyps4.Ps4Async.wakeup" - ) + mock_func = "homeassistant.components.ps4.media_player.pyps4.Ps4Async.wakeup" with patch(mock_func) as mock_call: await hass.services.async_call( @@ -397,9 +388,7 @@ async def test_turn_on(hass: HomeAssistant) -> None: async def test_turn_off(hass: HomeAssistant) -> None: """Test that turn off service calls function.""" mock_entity_id = await setup_mock_component(hass) - mock_func = "{}{}".format( - "homeassistant.components.ps4.media_player.", "pyps4.Ps4Async.standby" - ) + mock_func = "homeassistant.components.ps4.media_player.pyps4.Ps4Async.standby" with patch(mock_func) as mock_call: await hass.services.async_call( @@ -413,9 +402,7 @@ async def test_turn_off(hass: HomeAssistant) -> None: async def test_toggle(hass: HomeAssistant) -> None: """Test that toggle service calls function.""" mock_entity_id = await setup_mock_component(hass) - mock_func = "{}{}".format( - "homeassistant.components.ps4.media_player.", "pyps4.Ps4Async.toggle" - ) + mock_func = "homeassistant.components.ps4.media_player.pyps4.Ps4Async.toggle" with patch(mock_func) as mock_call: await hass.services.async_call( @@ -429,8 +416,8 @@ async def test_toggle(hass: HomeAssistant) -> None: async def test_media_pause(hass: HomeAssistant) -> None: """Test that media pause service calls function.""" mock_entity_id = await setup_mock_component(hass) - mock_func = "{}{}".format( - "homeassistant.components.ps4.media_player.", "pyps4.Ps4Async.remote_control" + mock_func = ( + "homeassistant.components.ps4.media_player.pyps4.Ps4Async.remote_control" ) with patch(mock_func) as mock_call: @@ -445,8 +432,8 @@ async def test_media_pause(hass: HomeAssistant) -> None: async def test_media_stop(hass: HomeAssistant) -> None: """Test that media stop service calls function.""" mock_entity_id = await setup_mock_component(hass) - mock_func = "{}{}".format( - "homeassistant.components.ps4.media_player.", "pyps4.Ps4Async.remote_control" + mock_func = ( + "homeassistant.components.ps4.media_player.pyps4.Ps4Async.remote_control" ) with patch(mock_func) as mock_call: diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index 49127aec347..610e741f5f5 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -121,18 +121,14 @@ async def test_enter_and_exit( req = await client.post(url, params=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['id']}").state assert state_name == STATE_HOME # Enter Home again req = await client.post(url, params=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['id']}").state assert state_name == STATE_HOME data["lon"] = 0 @@ -142,9 +138,7 @@ async def test_enter_and_exit( req = await client.post(url, params=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['id']}").state assert state_name == STATE_NOT_HOME assert len(device_registry.devices) == 1 @@ -171,7 +165,7 @@ async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None req = await client.post(url, params=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['id']}") assert state.state == STATE_NOT_HOME assert state.attributes["gps_accuracy"] == 10.5 assert state.attributes["battery_level"] == 10.0 @@ -194,7 +188,7 @@ async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None req = await client.post(url, params=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['id']}") assert state.state == STATE_HOME assert state.attributes["gps_accuracy"] == 123 assert state.attributes["battery_level"] == 23 @@ -214,7 +208,7 @@ async def test_two_devices(hass: HomeAssistant, client, webhook_id) -> None: await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data_device_1["id"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data_device_1['id']}") assert state.state == "not_home" # Enter Home @@ -226,9 +220,9 @@ async def test_two_devices(hass: HomeAssistant, client, webhook_id) -> None: await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data_device_2["id"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data_device_2['id']}") assert state.state == "home" - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data_device_1["id"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data_device_1['id']}") assert state.state == "not_home" @@ -244,9 +238,7 @@ async def test_load_unload_entry(hass: HomeAssistant, client, webhook_id) -> Non req = await client.post(url, params=data) await hass.async_block_till_done() assert req.status == HTTPStatus.OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["id"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['id']}").state assert state_name == STATE_HOME assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1 diff --git a/tests/components/trafikverket_ferry/test_config_flow.py b/tests/components/trafikverket_ferry/test_config_flow.py index 916f9c9f2ec..5671d9d3fb7 100644 --- a/tests/components/trafikverket_ferry/test_config_flow.py +++ b/tests/components/trafikverket_ferry/test_config_flow.py @@ -62,9 +62,7 @@ async def test_form(hass: HomeAssistant) -> None: "weekday": ["mon", "fri"], } assert len(mock_setup_entry.mock_calls) == 1 - assert result2["result"].unique_id == "{}-{}-{}-{}".format( - "eker\u00f6", "slagsta", "10:00", "['mon', 'fri']" - ) + assert result2["result"].unique_id == "eker\u00f6-slagsta-10:00-['mon', 'fri']" @pytest.mark.parametrize( diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 3090a9fe337..9fe02994f05 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -73,9 +73,7 @@ async def test_form(hass: HomeAssistant) -> None: } assert result["options"] == {"filter_product": None} assert len(mock_setup_entry.mock_calls) == 1 - assert result["result"].unique_id == "{}-{}-{}-{}".format( - "stockholmc", "uppsalac", "10:00", "['mon', 'fri']" - ) + assert result["result"].unique_id == "stockholmc-uppsalac-10:00-['mon', 'fri']" async def test_form_entry_already_exist(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index 0350b2e6e3a..edd18d54db4 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -73,7 +73,7 @@ async def test_signal_type_format(hass: HomeAssistant) -> None: assert calls == [("Hello", 2)] # Test compatibility with string keys - async_dispatcher_send(hass, "test-{}".format("unique-id"), "x", 4) + async_dispatcher_send(hass, "test-unique-id", "x", 4) await hass.async_block_till_done() assert calls == [("Hello", 2), ("x", 4)] diff --git a/tests/helpers/test_icon.py b/tests/helpers/test_icon.py index e0dc89f5322..ad5c852ded9 100644 --- a/tests/helpers/test_icon.py +++ b/tests/helpers/test_icon.py @@ -25,12 +25,8 @@ def test_battery_icon() -> None: iconbase = "mdi:battery" for level in range(0, 100, 5): print( # noqa: T201 - "Level: %d. icon: %s, charging: %s" - % ( - level, - icon.icon_for_battery_level(level, False), - icon.icon_for_battery_level(level, True), - ) + f"Level: {level}. icon: {icon.icon_for_battery_level(level, False)}, " + f"charging: {icon.icon_for_battery_level(level, True)}" ) if level <= 10: postfix_charging = "-outline" diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 59a02bff838..10152254914 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -19,9 +19,7 @@ RESOURCE_DIR = os.path.abspath( TEST_NEW_REQ = "pyhelloworld3==1.0.0" -TEST_ZIP_REQ = "file://{}#{}".format( - os.path.join(RESOURCE_DIR, "pyhelloworld3.zip"), TEST_NEW_REQ -) +TEST_ZIP_REQ = f"file://{RESOURCE_DIR}/pyhelloworld3.zip#{TEST_NEW_REQ}" @pytest.fixture From 8ca33104018581c9419e38c304998a7b9df2f856 Mon Sep 17 00:00:00 2001 From: Arun Philip Date: Thu, 19 Sep 2024 04:34:27 -0400 Subject: [PATCH 0823/1309] Fix qbittorrent error when torrent count is 0 (#126146) Fix handling of `NoneType` for torrents in `count_torrents_in_states` function Added a check to handle cases where the 'torrents' data is None, avoiding a `TypeError` when attempting to get the length of a `NoneType` object. The function now returns 0 if 'torrents' is None, ensuring robust behavior when no torrent data is available. --- homeassistant/components/qbittorrent/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index cd65fb766e4..68de7e1d5e5 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -177,8 +177,12 @@ def count_torrents_in_states( # When torrents are not in the returned data, there are none, return 0. try: torrents = cast(Mapping[str, Mapping], coordinator.data.get("torrents")) + if torrents is None: + return 0 + if not states: return len(torrents) + return len( [torrent for torrent in torrents.values() if torrent.get("state") in states] ) From 3981c878602bb2b8392a85aa23e3db4ceb209855 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 19 Sep 2024 10:45:26 +0200 Subject: [PATCH 0824/1309] Prevent blocking event loop in ps4 (#126151) * Prevent blocking event loop in ps4 * Process code review comment --- homeassistant/components/ps4/media_player.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index ecd20e2d71d..8db24beae20 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -96,11 +96,10 @@ class PS4Device(MediaPlayerEntity): self._retry = 0 self._disconnected = False - @callback def status_callback(self) -> None: """Handle status callback. Parse status.""" self._parse_status() - self.async_write_ha_state() + self.schedule_update_ha_state() @callback def subscribe_to_protocol(self) -> None: @@ -157,7 +156,7 @@ class PS4Device(MediaPlayerEntity): self._ps4.ddp_protocol = self.hass.data[PS4_DATA].protocol self.subscribe_to_protocol() - self._parse_status() + await self.hass.async_add_executor_job(self._parse_status) def _parse_status(self) -> None: """Parse status.""" From 3c99fad6b90e3556033d5a1825e11d893cdfb687 Mon Sep 17 00:00:00 2001 From: Iskra kranj <162285659+iskrakranj@users.noreply.github.com> Date: Thu, 19 Sep 2024 10:48:42 +0200 Subject: [PATCH 0825/1309] Add counters to iskra integration (#126046) * Added counters to iskra integration * reverted pyiskra bump as reviewed * Fixed iskra integration according to review * fixed iskra integration according to review --- homeassistant/components/iskra/const.py | 4 ++ homeassistant/components/iskra/sensor.py | 57 ++++++++++++++++++++- homeassistant/components/iskra/strings.json | 36 +++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/iskra/const.py b/homeassistant/components/iskra/const.py index 5fc3b501962..a4ed36b50b2 100644 --- a/homeassistant/components/iskra/const.py +++ b/homeassistant/components/iskra/const.py @@ -21,5 +21,9 @@ ATTR_PHASE1_CURRENT = "phase1_current" ATTR_PHASE2_CURRENT = "phase2_current" ATTR_PHASE3_CURRENT = "phase3_current" +# Counters +ATTR_NON_RESETTABLE_COUNTER = "non_resettable_counter_{}" +ATTR_RESETTABLE_COUNTER = "resettable_counter_{}" + # Frequency ATTR_FREQUENCY = "frequency" diff --git a/homeassistant/components/iskra/sensor.py b/homeassistant/components/iskra/sensor.py index 9e9976749a1..df9e3ec53f9 100644 --- a/homeassistant/components/iskra/sensor.py +++ b/homeassistant/components/iskra/sensor.py @@ -3,9 +3,10 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from pyiskra.devices import Device +from pyiskra.helper import Counter, CounterType from homeassistant.components.sensor import ( SensorDeviceClass, @@ -17,6 +18,7 @@ from homeassistant.const import ( UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, + UnitOfEnergy, UnitOfFrequency, UnitOfPower, UnitOfReactivePower, @@ -27,6 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import IskraConfigEntry from .const import ( ATTR_FREQUENCY, + ATTR_NON_RESETTABLE_COUNTER, ATTR_PHASE1_CURRENT, ATTR_PHASE1_POWER, ATTR_PHASE1_VOLTAGE, @@ -36,6 +39,7 @@ from .const import ( ATTR_PHASE3_CURRENT, ATTR_PHASE3_POWER, ATTR_PHASE3_VOLTAGE, + ATTR_RESETTABLE_COUNTER, ATTR_TOTAL_ACTIVE_POWER, ATTR_TOTAL_APPARENT_POWER, ATTR_TOTAL_REACTIVE_POWER, @@ -163,6 +167,44 @@ SENSOR_TYPES: tuple[IskraSensorEntityDescription, ...] = ( ) +def get_counter_entity_description( + counter: Counter, + index: int, + entity_name: str, +) -> IskraSensorEntityDescription: + """Dynamically create IskraSensor object as energy meter's counters are customizable.""" + + key = entity_name.format(index + 1) + + if entity_name == ATTR_NON_RESETTABLE_COUNTER: + entity_description = IskraSensorEntityDescription( + key=key, + translation_key=key, + state_class=SensorStateClass.TOTAL_INCREASING, + value_func=lambda device: device.counters.non_resettable[index].value, + native_unit_of_measurement=counter.units, + ) + else: + entity_description = IskraSensorEntityDescription( + key=key, + translation_key=key, + state_class=SensorStateClass.TOTAL_INCREASING, + value_func=lambda device: device.counters.resettable[index].value, + native_unit_of_measurement=counter.units, + ) + + # Set unit of measurement and device class based on counter type + # HA's Energy device class supports only active energy + if counter.counter_type in [CounterType.ACTIVE_IMPORT, CounterType.ACTIVE_EXPORT]: + entity_description = replace( + entity_description, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ) + + return entity_description + + async def async_setup_entry( hass: HomeAssistant, entry: IskraConfigEntry, @@ -205,6 +247,19 @@ async def async_setup_entry( if description.key in sensors ) + if device.supports_counters: + for index, counter in enumerate(device.counters.non_resettable[:4]): + description = get_counter_entity_description( + counter, index, ATTR_NON_RESETTABLE_COUNTER + ) + entities.append(IskraSensor(coordinator, description)) + + for index, counter in enumerate(device.counters.resettable[:8]): + description = get_counter_entity_description( + counter, index, ATTR_RESETTABLE_COUNTER + ) + entities.append(IskraSensor(coordinator, description)) + async_add_entities(entities) diff --git a/homeassistant/components/iskra/strings.json b/homeassistant/components/iskra/strings.json index bd70336f637..5818cdfa1db 100644 --- a/homeassistant/components/iskra/strings.json +++ b/homeassistant/components/iskra/strings.json @@ -86,6 +86,42 @@ }, "phase3_current": { "name": "Phase 3 current" + }, + "non_resettable_counter_1": { + "name": "Non Resettable counter 1" + }, + "non_resettable_counter_2": { + "name": "Non Resettable counter 2" + }, + "non_resettable_counter_3": { + "name": "Non Resettable counter 3" + }, + "non_resettable_counter_4": { + "name": "Non Resettable counter 4" + }, + "resettable_counter_1": { + "name": "Resettable counter 1" + }, + "resettable_counter_2": { + "name": "Resettable counter 2" + }, + "resettable_counter_3": { + "name": "Resettable counter 3" + }, + "resettable_counter_4": { + "name": "Resettable counter 4" + }, + "resettable_counter_5": { + "name": "Resettable counter 5" + }, + "resettable_counter_6": { + "name": "Resettable counter 6" + }, + "resettable_counter_7": { + "name": "Resettable counter 7" + }, + "resettable_counter_8": { + "name": "Resettable counter 8" } } } From b787c2617b97e607e5ae6f107e0ae5ec2c463082 Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Thu, 19 Sep 2024 10:59:54 +0200 Subject: [PATCH 0826/1309] Revert "Fix missing id in Habitica completed todos API response" (#126142) Revert "Fix missing id in Habitica completed todos API response (#124565)" This reverts commit c9e7c76ee55c628e59c659bd331ab6bf0352bed6. --- .../components/habitica/coordinator.py | 9 +----- tests/components/habitica/test_init.py | 28 +++++++++---------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index 357643593e4..4e949b703fb 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -56,14 +56,7 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]): try: user_response = await self.api.user.get() tasks_response = await self.api.tasks.user.get() - tasks_response.extend( - [ - {"id": task["_id"], **task} - for task in await self.api.tasks.user.get(type="completedTodos") - if task.get("_id") - ] - ) - + tasks_response.extend(await self.api.tasks.user.get(type="completedTodos")) except ClientResponseError as error: if error.status == HTTPStatus.TOO_MANY_REQUESTS: _LOGGER.debug("Currently rate limited, skipping update") diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 56f17bc9889..683472a720f 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -74,20 +74,7 @@ def common_requests(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: } }, ) - aioclient_mock.get( - "https://habitica.com/api/v3/tasks/user?type=completedTodos", - json={ - "data": [ - { - "text": "this is a mock todo #5", - "id": 5, - "_id": 5, - "type": "todo", - "completed": True, - } - ] - }, - ) + aioclient_mock.get( "https://habitica.com/api/v3/tasks/user", json={ @@ -102,6 +89,19 @@ def common_requests(aioclient_mock: AiohttpClientMocker) -> AiohttpClientMocker: ] }, ) + aioclient_mock.get( + "https://habitica.com/api/v3/tasks/user?type=completedTodos", + json={ + "data": [ + { + "text": "this is a mock todo #5", + "id": 5, + "type": "todo", + "completed": True, + } + ] + }, + ) aioclient_mock.post( "https://habitica.com/api/v3/tasks/user", From c94bb6c1db9daa2aa9028e495c8e2cb5d9576ef2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 19 Sep 2024 11:00:22 +0200 Subject: [PATCH 0827/1309] Add new method version_is_newer to Update platform (#124797) * Allow string comparing in update platform * new approach after architecture discussion * cleanup * Update homeassistant/components/update/__init__.py Co-authored-by: Erik Montnemery * Update homeassistant/components/update/__init__.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * add tests * Update tests/components/update/test_init.py Co-authored-by: Erik Montnemery * Update tests/components/update/test_init.py Co-authored-by: Erik Montnemery * Update tests/components/update/test_init.py Co-authored-by: Erik Montnemery * update docstrings * one more docstring --------- Co-authored-by: Erik Montnemery Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/update/__init__.py | 9 ++++- tests/components/update/test_init.py | 41 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index cd52de6550f..90495871cb2 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -181,7 +181,7 @@ class UpdateEntityDescription(EntityDescription, frozen_or_thawed=True): @lru_cache(maxsize=256) def _version_is_newer(latest_version: str, installed_version: str) -> bool: - """Return True if version is newer.""" + """Return True if latest_version is newer than installed_version.""" return AwesomeVersion(latest_version) > installed_version @@ -384,6 +384,11 @@ class UpdateEntity( """ raise NotImplementedError + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return True if latest_version is newer than installed_version.""" + # We don't inline the `_version_is_newer` function because of caching + return _version_is_newer(latest_version, installed_version) + @property @final def state(self) -> str | None: @@ -399,7 +404,7 @@ class UpdateEntity( return STATE_OFF try: - newer = _version_is_newer(latest_version, installed_version) + newer = self.version_is_newer(latest_version, installed_version) except AwesomeVersionCompareException: # Can't compare versions, already tried exact match return STATE_ON diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 7860c679f37..6082e0ecfe7 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch +from awesomeversion import AwesomeVersion, AwesomeVersionStrategy import pytest from homeassistant.components.update import ( @@ -956,3 +957,43 @@ async def test_deprecated_supported_features_ints_with_service_call( }, blocking=True, ) + + +async def test_custom_version_is_newer(hass: HomeAssistant) -> None: + """Test UpdateEntity with overridden version_is_newer method.""" + + class MockUpdateEntity(UpdateEntity): + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return True if latest_version is newer than installed_version.""" + return AwesomeVersion( + latest_version, + find_first_match=True, + ensure_strategy=[AwesomeVersionStrategy.SEMVER], + ) > AwesomeVersion( + installed_version, + find_first_match=True, + ensure_strategy=[AwesomeVersionStrategy.SEMVER], + ) + + update = MockUpdateEntity() + update.hass = hass + update.platform = MockEntityPlatform(hass) + + STABLE = "20230913-111730/v1.14.0-gcb84623" + BETA = "20231107-162609/v1.14.1-rc1-g0617c15" + + # Set current installed version to STABLE + update._attr_installed_version = STABLE + update._attr_latest_version = BETA + + assert update.installed_version == STABLE + assert update.latest_version == BETA + assert update.state == STATE_ON + + # Set current installed version to BETA + update._attr_installed_version = BETA + update._attr_latest_version = STABLE + + assert update.installed_version == BETA + assert update.latest_version == STABLE + assert update.state == STATE_OFF From e40a853fdb7a4db6cf131dddfe6ed607cfd6b45a Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 19 Sep 2024 11:03:20 +0200 Subject: [PATCH 0828/1309] Fix set temperature action in AVM FRITZ!SmartHome (#126072) * fix set_temperature logic * improvements --- homeassistant/components/fritzbox/climate.py | 12 +- tests/components/fritzbox/test_climate.py | 141 ++++++++----------- 2 files changed, 67 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 5288682c388..61e75bec000 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -135,14 +135,16 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if kwargs.get(ATTR_HVAC_MODE) is not None: - hvac_mode = kwargs[ATTR_HVAC_MODE] + target_temp = kwargs.get(ATTR_TEMPERATURE) + hvac_mode = kwargs.get(ATTR_HVAC_MODE) + if hvac_mode == HVACMode.OFF: await self.async_set_hvac_mode(hvac_mode) - elif kwargs.get(ATTR_TEMPERATURE) is not None: - temperature = kwargs[ATTR_TEMPERATURE] + elif target_temp is not None: await self.hass.async_add_executor_job( - self.data.set_target_temperature, temperature + self.data.set_target_temperature, target_temp ) + else: + return await self.coordinator.async_refresh() @property diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 062ba4f865f..6bd405aa5ab 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -1,7 +1,7 @@ """Tests for AVM Fritz!Box climate component.""" from datetime import timedelta -from unittest.mock import Mock, call +from unittest.mock import Mock, _Call, call from freezegun.api import FrozenDateTimeFactory import pytest @@ -15,6 +15,8 @@ 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, @@ -270,8 +272,40 @@ async def test_update_error(hass: HomeAssistant, fritz: Mock) -> None: assert fritz().login.call_count == 4 -async def test_set_temperature_temperature(hass: HomeAssistant, fritz: Mock) -> None: - """Test setting temperature by temperature.""" +@pytest.mark.parametrize( + ("service_data", "expected_call_args"), + [ + ({ATTR_TEMPERATURE: 23}, [call(23)]), + ( + { + ATTR_HVAC_MODE: HVACMode.OFF, + ATTR_TEMPERATURE: 23, + }, + [call(0)], + ), + ( + { + ATTR_HVAC_MODE: HVACMode.HEAT, + ATTR_TEMPERATURE: 23, + }, + [call(23)], + ), + ( + { + ATTR_TARGET_TEMP_HIGH: 16, + ATTR_TARGET_TEMP_LOW: 10, + }, + [], + ), + ], +) +async def test_set_temperature( + hass: HomeAssistant, + fritz: Mock, + service_data: dict, + expected_call_args: list[_Call], +) -> None: + """Test setting temperature.""" device = FritzDeviceClimateMock() assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz @@ -280,56 +314,32 @@ async def test_set_temperature_temperature(hass: HomeAssistant, fritz: Mock) -> await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 23}, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, True, ) - assert device.set_target_temperature.call_args_list == [call(23)] + assert device.set_target_temperature.call_count == len(expected_call_args) + assert device.set_target_temperature.call_args_list == expected_call_args -async def test_set_temperature_mode_off(hass: HomeAssistant, fritz: Mock) -> None: - """Test setting temperature by mode.""" - device = FritzDeviceClimateMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: ENTITY_ID, - ATTR_HVAC_MODE: HVACMode.OFF, - ATTR_TEMPERATURE: 23, - }, - True, - ) - assert device.set_target_temperature.call_args_list == [call(0)] - - -async def test_set_temperature_mode_heat(hass: HomeAssistant, fritz: Mock) -> None: - """Test setting temperature by mode.""" - device = FritzDeviceClimateMock() - device.target_temperature = 0.0 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: ENTITY_ID, - ATTR_HVAC_MODE: HVACMode.HEAT, - ATTR_TEMPERATURE: 23, - }, - True, - ) - assert device.set_target_temperature.call_args_list == [call(22)] - - -async def test_set_hvac_mode_off(hass: HomeAssistant, fritz: Mock) -> None: +@pytest.mark.parametrize( + ("service_data", "target_temperature", "expected_call_args"), + [ + ({ATTR_HVAC_MODE: HVACMode.OFF}, 22, [call(0)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 0.0, [call(22)]), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 18, []), + ({ATTR_HVAC_MODE: HVACMode.HEAT}, 22, []), + ], +) +async def test_set_hvac_mode( + hass: HomeAssistant, + fritz: Mock, + service_data: dict, + target_temperature: float, + expected_call_args: list[_Call], +) -> None: """Test setting hvac mode.""" device = FritzDeviceClimateMock() + device.target_temperature = target_temperature assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) @@ -337,43 +347,12 @@ async def test_set_hvac_mode_off(hass: HomeAssistant, fritz: Mock) -> None: await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.OFF}, + {ATTR_ENTITY_ID: ENTITY_ID, **service_data}, True, ) - assert device.set_target_temperature.call_args_list == [call(0)] - -async def test_no_reset_hvac_mode_heat(hass: HomeAssistant, fritz: Mock) -> None: - """Test setting hvac mode.""" - device = FritzDeviceClimateMock() - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, - True, - ) - assert device.set_target_temperature.call_count == 0 - - -async def test_set_hvac_mode_heat(hass: HomeAssistant, fritz: Mock) -> None: - """Test setting hvac mode.""" - device = FritzDeviceClimateMock() - device.target_temperature = 0.0 - assert await setup_config_entry( - hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz - ) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, - True, - ) - assert device.set_target_temperature.call_args_list == [call(22)] + assert device.set_target_temperature.call_count == len(expected_call_args) + assert device.set_target_temperature.call_args_list == expected_call_args async def test_set_preset_mode_comfort(hass: HomeAssistant, fritz: Mock) -> None: From bc3a42c65876548c585290f820901832766cbb37 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 19 Sep 2024 11:03:54 +0200 Subject: [PATCH 0829/1309] Fix serial handling in ViCare integration (#125495) * hand down device serial into common entity * fix platforms * Revert "fix platforms" This reverts commit 067af2b567538989f97c5a764be64f8744663daf. * handle event loop issue * hand in serial * Revert "Revert "fix platforms"" This reverts commit 9bbb55ee6da96ea31b98896e82c4b45ab001707b. * fix get serial call * handle other exceptions * also check device model for migration * merge entity and device migration * add test fixture without serial * adjust test cases * add dummy fixture * remove commented code * modify migration * use continue * break comment --- homeassistant/components/vicare/__init__.py | 108 +++++++++--------- .../components/vicare/binary_sensor.py | 15 ++- homeassistant/components/vicare/button.py | 6 +- homeassistant/components/vicare/climate.py | 8 +- homeassistant/components/vicare/entity.py | 6 +- homeassistant/components/vicare/fan.py | 8 +- homeassistant/components/vicare/number.py | 9 +- homeassistant/components/vicare/sensor.py | 15 ++- homeassistant/components/vicare/utils.py | 24 +++- .../components/vicare/water_heater.py | 6 +- .../fixtures/dummy-device-no-serial.json | 3 + tests/components/vicare/test_init.py | 84 ++++++++------ 12 files changed, 183 insertions(+), 109 deletions(-) create mode 100644 tests/components/vicare/fixtures/dummy-device-no-serial.json diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index ead210e2816..d6b9e4b923a 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -18,7 +18,7 @@ from PyViCare.PyViCareUtils import ( from homeassistant.components.climate import DOMAIN as DOMAIN_CLIMATE from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.storage import STORAGE_DIR @@ -31,7 +31,7 @@ from .const import ( UNSUPPORTED_DEVICES, ) from .types import ViCareDevice -from .utils import get_device +from .utils import get_device, get_device_serial _LOGGER = logging.getLogger(__name__) _TOKEN_FILENAME = "vicare_token.save" @@ -51,9 +51,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for device in hass.data[DOMAIN][entry.entry_id][DEVICE_LIST]: # Migration can be removed in 2025.4.0 - await async_migrate_devices(hass, entry, device) - # Migration can be removed in 2025.4.0 - await async_migrate_entities(hass, entry, device) + await async_migrate_devices_and_entities(hass, entry, device) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -117,70 +115,72 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_migrate_devices( +async def async_migrate_devices_and_entities( hass: HomeAssistant, entry: ConfigEntry, device: ViCareDevice ) -> None: """Migrate old entry.""" - registry = dr.async_get(hass) + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) gateway_serial: str = device.config.getConfig().serial - device_serial: str = device.api.getSerial() + device_id = device.config.getId() + device_serial: str | None = await hass.async_add_executor_job( + get_device_serial, device.api + ) + device_model = device.config.getModel() old_identifier = gateway_serial - new_identifier = f"{gateway_serial}_{device_serial}" + new_identifier = ( + f"{gateway_serial}_{device_serial if device_serial is not None else device_id}" + ) # Migrate devices - for device_entry in dr.async_entries_for_config_entry(registry, entry.entry_id): - if device_entry.identifiers == {(DOMAIN, old_identifier)}: - _LOGGER.debug("Migrating device %s", device_entry.name) - registry.async_update_device( + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + if ( + device_entry.identifiers == {(DOMAIN, old_identifier)} + and device_entry.model == device_model + ): + _LOGGER.debug( + "Migrating device %s to new identifier %s", + device_entry.name, + new_identifier, + ) + device_registry.async_update_device( device_entry.id, serial_number=device_serial, new_identifiers={(DOMAIN, new_identifier)}, ) + # Migrate entities + for entity_entry in er.async_entries_for_device( + entity_registry, device_entry.id, True + ): + if entity_entry.unique_id.startswith(new_identifier): + # already correct, nothing to do + continue + unique_id_parts = entity_entry.unique_id.split("-") + # replace old prefix `` + # with `_` + unique_id_parts[0] = new_identifier + # convert climate entity unique id + # from `-` + # to `-heating-` + if entity_entry.domain == DOMAIN_CLIMATE: + unique_id_parts[len(unique_id_parts) - 1] = ( + f"{entity_entry.translation_key}-{unique_id_parts[len(unique_id_parts)-1]}" + ) + entity_new_unique_id = "-".join(unique_id_parts) -async def async_migrate_entities( - hass: HomeAssistant, entry: ConfigEntry, device: ViCareDevice -) -> None: - """Migrate old entry.""" - gateway_serial: str = device.config.getConfig().serial - device_serial: str = device.api.getSerial() - new_identifier = f"{gateway_serial}_{device_serial}" - - @callback - def _update_unique_id( - entity_entry: er.RegistryEntry, - ) -> dict[str, str] | None: - """Update unique ID of entity entry.""" - if not entity_entry.unique_id.startswith(gateway_serial): - # belongs to other device/gateway - return None - if entity_entry.unique_id.startswith(f"{gateway_serial}_"): - # Already correct, nothing to do - return None - - unique_id_parts = entity_entry.unique_id.split("-") - unique_id_parts[0] = new_identifier - - # convert climate entity unique id from `-` to `-heating-` - if entity_entry.domain == DOMAIN_CLIMATE: - unique_id_parts[len(unique_id_parts) - 1] = ( - f"{entity_entry.translation_key}-{unique_id_parts[len(unique_id_parts)-1]}" - ) - - entity_new_unique_id = "-".join(unique_id_parts) - - _LOGGER.debug( - "Migrating entity %s from %s to new id %s", - entity_entry.entity_id, - entity_entry.unique_id, - entity_new_unique_id, - ) - return {"new_unique_id": entity_new_unique_id} - - # Migrate entities - await er.async_migrate_entries(hass, entry.entry_id, _update_unique_id) + _LOGGER.debug( + "Migrating entity %s to new unique id %s", + entity_entry.name, + entity_new_unique_id, + ) + entity_registry.async_update_entity( + entity_id=entity_entry.entity_id, new_unique_id=entity_new_unique_id + ) def get_supported_devices( diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 7fe248fa266..55f0ab96ed0 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -31,7 +31,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity from .types import ViCareDevice, ViCareRequiredKeysMixin -from .utils import get_burners, get_circuits, get_compressors, is_supported +from .utils import ( + get_burners, + get_circuits, + get_compressors, + get_device_serial, + is_supported, +) _LOGGER = logging.getLogger(__name__) @@ -116,6 +122,7 @@ def _build_entities( entities.extend( ViCareBinarySensor( description, + get_device_serial(device.api), device.config, device.api, ) @@ -131,6 +138,7 @@ def _build_entities( entities.extend( ViCareBinarySensor( description, + get_device_serial(device.api), device.config, device.api, component, @@ -166,12 +174,15 @@ class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): def __init__( self, description: ViCareBinarySensorEntityDescription, + device_serial: str | None, device_config: PyViCareDeviceConfig, device: PyViCareDevice, component: PyViCareHeatingDeviceComponent | None = None, ) -> None: """Initialize the sensor.""" - super().__init__(description.key, device_config, device, component) + super().__init__( + description.key, device_serial, device_config, device, component + ) self.entity_description = description @property diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 51a763c1fcc..49d142c1edb 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity from .types import ViCareDevice, ViCareRequiredKeysMixinWithSet -from .utils import is_supported +from .utils import get_device_serial, is_supported _LOGGER = logging.getLogger(__name__) @@ -55,6 +55,7 @@ def _build_entities( return [ ViCareButton( description, + get_device_serial(device.api), device.config, device.api, ) @@ -88,11 +89,12 @@ class ViCareButton(ViCareEntity, ButtonEntity): def __init__( self, description: ViCareButtonEntityDescription, + device_serial: str | None, device_config: PyViCareDeviceConfig, device: PyViCareDevice, ) -> None: """Initialize the button.""" - super().__init__(description.key, device_config, device) + super().__init__(description.key, device_serial, device_config, device) self.entity_description = description def press(self) -> None: diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 410395760ea..b742ad257fa 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -40,7 +40,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity from .types import HeatingProgram, ViCareDevice -from .utils import get_burners, get_circuits, get_compressors +from .utils import get_burners, get_circuits, get_compressors, get_device_serial _LOGGER = logging.getLogger(__name__) @@ -87,6 +87,7 @@ def _build_entities( """Create ViCare climate entities for a device.""" return [ ViCareClimate( + get_device_serial(device.api), device.config, device.api, circuit, @@ -143,12 +144,15 @@ class ViCareClimate(ViCareEntity, ClimateEntity): def __init__( self, + device_serial: str | None, device_config: PyViCareDeviceConfig, device: PyViCareDevice, circuit: PyViCareHeatingCircuit, ) -> None: """Initialize the climate device.""" - super().__init__(self._attr_translation_key, device_config, device, circuit) + super().__init__( + self._attr_translation_key, device_serial, device_config, device, circuit + ) self._device = device self._attributes: dict[str, Any] = {} self._attributes["vicare_programs"] = self._api.getPrograms() diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index f48243e83e1..dfb8c48dfc3 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -20,14 +20,16 @@ class ViCareEntity(Entity): def __init__( self, unique_id_suffix: str, + device_serial: str | None, device_config: PyViCareDeviceConfig, device: PyViCareDevice, component: PyViCareHeatingDeviceComponent | None = None, ) -> None: """Initialize the entity.""" gateway_serial = device_config.getConfig().serial - device_serial = device.getSerial() - identifier = f"{gateway_serial}_{device_serial}" + device_id = device_config.getId() + + identifier = f"{gateway_serial}_{device_serial if device_serial is not None else device_id}" self._api: PyViCareDevice | PyViCareHeatingDeviceComponent = ( component if component else device diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py index d7dbd037b56..b787de20773 100644 --- a/homeassistant/components/vicare/fan.py +++ b/homeassistant/components/vicare/fan.py @@ -29,6 +29,7 @@ from homeassistant.util.percentage import ( from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity +from .utils import get_device_serial _LOGGER = logging.getLogger(__name__) @@ -100,7 +101,7 @@ async def async_setup_entry( async_add_entities( [ - ViCareFan(device.config, device.api) + ViCareFan(get_device_serial(device.api), device.config, device.api) for device in device_list if isinstance(device.api, PyViCareVentilationDevice) ] @@ -125,11 +126,14 @@ class ViCareFan(ViCareEntity, FanEntity): def __init__( self, + device_serial: str | None, device_config: PyViCareDeviceConfig, device: PyViCareDevice, ) -> None: """Initialize the fan entity.""" - super().__init__(self._attr_translation_key, device_config, device) + super().__init__( + self._attr_translation_key, device_serial, device_config, device + ) def update(self) -> None: """Update state of fan.""" diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index a7f679f7224..529caca6a87 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -33,7 +33,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity from .types import HeatingProgram, ViCareDevice, ViCareRequiredKeysMixin -from .utils import get_circuits, is_supported +from .utils import get_circuits, get_device_serial, is_supported _LOGGER = logging.getLogger(__name__) @@ -279,6 +279,7 @@ def _build_entities( entities.extend( ViCareNumber( description, + get_device_serial(device.api), device.config, device.api, ) @@ -289,6 +290,7 @@ def _build_entities( entities.extend( ViCareNumber( description, + get_device_serial(device.api), device.config, device.api, circuit, @@ -324,12 +326,15 @@ class ViCareNumber(ViCareEntity, NumberEntity): def __init__( self, description: ViCareNumberEntityDescription, + device_serial: str | None, device_config: PyViCareDeviceConfig, device: PyViCareDevice, component: PyViCareHeatingDeviceComponent | None = None, ) -> None: """Initialize the number.""" - super().__init__(description.key, device_config, device, component) + super().__init__( + description.key, device_serial, device_config, device, component + ) self.entity_description = description @property diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index bdcb6dfa3aa..79a93ffa345 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -51,7 +51,13 @@ from .const import ( ) from .entity import ViCareEntity from .types import ViCareDevice, ViCareRequiredKeysMixin -from .utils import get_burners, get_circuits, get_compressors, is_supported +from .utils import ( + get_burners, + get_circuits, + get_compressors, + get_device_serial, + is_supported, +) _LOGGER = logging.getLogger(__name__) @@ -868,6 +874,7 @@ def _build_entities( entities.extend( ViCareSensor( description, + get_device_serial(device.api), device.config, device.api, ) @@ -883,6 +890,7 @@ def _build_entities( entities.extend( ViCareSensor( description, + get_device_serial(device.api), device.config, device.api, component, @@ -920,12 +928,15 @@ class ViCareSensor(ViCareEntity, SensorEntity): def __init__( self, description: ViCareSensorEntityDescription, + device_serial: str | None, device_config: PyViCareDeviceConfig, device: PyViCareDevice, component: PyViCareHeatingDeviceComponent | None = None, ) -> None: """Initialize the sensor.""" - super().__init__(description.key, device_config, device, component) + super().__init__( + description.key, device_serial, device_config, device, component + ) self.entity_description = description @property diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 2ba5ddbfb0a..5156ea4a41e 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -7,7 +7,12 @@ from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareHeatingDevice import ( HeatingDeviceWithComponent as PyViCareHeatingDeviceComponent, ) -from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError +from PyViCare.PyViCareUtils import ( + PyViCareInvalidDataError, + PyViCareNotSupportedFeatureError, + PyViCareRateLimitError, +) +import requests from homeassistant.config_entries import ConfigEntry @@ -27,6 +32,23 @@ def get_device( )() +def get_device_serial(device: PyViCareDevice) -> str | None: + """Get device serial for device if supported.""" + try: + return device.getSerial() + except PyViCareNotSupportedFeatureError: + _LOGGER.debug("Device does not offer a 'device.serial' data point") + except PyViCareRateLimitError as limit_exception: + _LOGGER.debug("Vicare API rate limit exceeded: %s", limit_exception) + except PyViCareInvalidDataError as invalid_data_exception: + _LOGGER.debug("Invalid data from Vicare server: %s", invalid_data_exception) + except requests.exceptions.ConnectionError: + _LOGGER.debug("Unable to retrieve data from ViCare server") + except ValueError: + _LOGGER.debug("Unable to decode data from ViCare server") + return None + + def is_supported( name: str, entity_description: ViCareRequiredKeysMixin, diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 621d2f2a09b..5e241c9a3be 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -28,7 +28,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEVICE_LIST, DOMAIN from .entity import ViCareEntity from .types import ViCareDevice -from .utils import get_circuits +from .utils import get_circuits, get_device_serial _LOGGER = logging.getLogger(__name__) @@ -69,6 +69,7 @@ def _build_entities( return [ ViCareWater( + get_device_serial(device.api), device.config, device.api, circuit, @@ -108,12 +109,13 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity): def __init__( self, + device_serial: str | None, device_config: PyViCareDeviceConfig, device: PyViCareDevice, circuit: PyViCareHeatingCircuit, ) -> None: """Initialize the DHW water_heater device.""" - super().__init__(circuit.id, device_config, device) + super().__init__(circuit.id, device_serial, device_config, device) self._circuit = circuit self._attributes: dict[str, Any] = {} diff --git a/tests/components/vicare/fixtures/dummy-device-no-serial.json b/tests/components/vicare/fixtures/dummy-device-no-serial.json new file mode 100644 index 00000000000..268c73f0e37 --- /dev/null +++ b/tests/components/vicare/fixtures/dummy-device-no-serial.json @@ -0,0 +1,3 @@ +{ + "data": [] +} diff --git a/tests/components/vicare/test_init.py b/tests/components/vicare/test_init.py index fea7b5985f1..62bec7f50c5 100644 --- a/tests/components/vicare/test_init.py +++ b/tests/components/vicare/test_init.py @@ -14,74 +14,78 @@ from tests.common import MockConfigEntry # Device migration test can be removed in 2025.4.0 -async def test_device_migration( +async def test_device_and_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, ) -> None: """Test that the device registry is updated correctly.""" - fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] + fixtures: list[Fixture] = [ + Fixture({"type:boiler"}, "vicare/Vitodens300W.json"), + Fixture({"type:boiler"}, "vicare/dummy-device-no-serial.json"), + ] with ( patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), ): mock_config_entry.add_to_hass(hass) - device_registry.async_get_or_create( + # device with serial data point + device0 = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={ (DOMAIN, "gateway0"), }, + model="model0", ) - - await hass.config_entries.async_setup(mock_config_entry.entry_id) - - await hass.async_block_till_done() - - assert device_registry.async_get_device(identifiers={(DOMAIN, "gateway0")}) is None - - assert ( - device_registry.async_get_device( - identifiers={(DOMAIN, "gateway0_deviceSerialVitodens300W")} - ) - is not None - ) - - -# Entity migration test can be removed in 2025.4.0 -async def test_climate_entity_migration( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that the climate entity unique_id gets migrated correctly.""" - fixtures: list[Fixture] = [Fixture({"type:boiler"}, "vicare/Vitodens300W.json")] - with ( - patch(f"{MODULE}.vicare_login", return_value=MockPyViCare(fixtures)), - patch(f"{MODULE}.PLATFORMS", [Platform.CLIMATE]), - ): - mock_config_entry.add_to_hass(hass) - - entry1 = entity_registry.async_get_or_create( + entry0 = entity_registry.async_get_or_create( domain=Platform.CLIMATE, platform=DOMAIN, config_entry=mock_config_entry, unique_id="gateway0-0", translation_key="heating", + device_id=device0.id, ) - entry2 = entity_registry.async_get_or_create( + entry1 = entity_registry.async_get_or_create( domain=Platform.CLIMATE, platform=DOMAIN, config_entry=mock_config_entry, unique_id="gateway0_deviceSerialVitodens300W-heating-1", translation_key="heating", + device_id=device0.id, ) - entry3 = entity_registry.async_get_or_create( + # device without serial data point + device1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={ + (DOMAIN, "gateway1"), + }, + model="model1", + ) + entry2 = entity_registry.async_get_or_create( domain=Platform.CLIMATE, platform=DOMAIN, config_entry=mock_config_entry, unique_id="gateway1-0", translation_key="heating", + device_id=device1.id, + ) + # device is not provided by api + device2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={ + (DOMAIN, "gateway2"), + }, + model="model2", + ) + entry3 = entity_registry.async_get_or_create( + domain=Platform.CLIMATE, + platform=DOMAIN, + config_entry=mock_config_entry, + unique_id="gateway2-0", + translation_key="heating", + device_id=device2.id, ) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -89,11 +93,15 @@ async def test_climate_entity_migration( await hass.async_block_till_done() assert ( - entity_registry.async_get(entry1.entity_id).unique_id + entity_registry.async_get(entry0.entity_id).unique_id == "gateway0_deviceSerialVitodens300W-heating-0" ) assert ( - entity_registry.async_get(entry2.entity_id).unique_id + entity_registry.async_get(entry1.entity_id).unique_id == "gateway0_deviceSerialVitodens300W-heating-1" ) - assert entity_registry.async_get(entry3.entity_id).unique_id == "gateway1-0" + assert ( + entity_registry.async_get(entry2.entity_id).unique_id + == "gateway1_deviceId1-heating-0" + ) + assert entity_registry.async_get(entry3.entity_id).unique_id == "gateway2-0" From d90cdf24f595c88ccc90ceea5b0f64686b2b325c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 19 Sep 2024 19:04:27 +1000 Subject: [PATCH 0830/1309] Fix wall connector state in Teslemetry (#124149) * Fix wall connector state * review feedback * Rename None to Disconnected * Translate disconnected --- homeassistant/components/teslemetry/entity.py | 7 +++++++ homeassistant/components/teslemetry/sensor.py | 19 ++++++++++--------- .../components/teslemetry/strings.json | 5 ++++- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 74c1fdd52b1..bba678f754b 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -192,3 +192,10 @@ class TeslemetryWallConnectorEntity( .get(self.din, {}) .get(self.key) ) + + @property + def exists(self) -> bool: + """Return True if it exists in the wall connector coordinator data.""" + return self.key in self.coordinator.data.get("wall_connectors", {}).get( + self.din, {} + ) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index 90b37cc1dac..b63f6b905b4 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -379,18 +379,18 @@ ENERGY_LIVE_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription(key="island_status", device_class=SensorDeviceClass.ENUM), ) -WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( +WALL_CONNECTOR_DESCRIPTIONS: tuple[TeslemetrySensorEntityDescription, ...] = ( + TeslemetrySensorEntityDescription( key="wall_connector_state", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslemetrySensorEntityDescription( key="wall_connector_fault_state", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), - SensorEntityDescription( + TeslemetrySensorEntityDescription( key="wall_connector_power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -398,8 +398,9 @@ WALL_CONNECTOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( suggested_display_precision=2, device_class=SensorDeviceClass.POWER, ), - SensorEntityDescription( + TeslemetrySensorEntityDescription( key="vin", + value_fn=lambda vin: vin or "disconnected", ), ) @@ -525,13 +526,13 @@ class TeslemetryEnergyLiveSensorEntity(TeslemetryEnergyLiveEntity, SensorEntity) class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorEntity): """Base class for Teslemetry energy site metric sensors.""" - entity_description: SensorEntityDescription + entity_description: TeslemetrySensorEntityDescription def __init__( self, data: TeslemetryEnergyData, din: str, - description: SensorEntityDescription, + description: TeslemetrySensorEntityDescription, ) -> None: """Initialize the sensor.""" self.entity_description = description @@ -543,8 +544,8 @@ class TeslemetryWallConnectorSensorEntity(TeslemetryWallConnectorEntity, SensorE def _async_update_attrs(self) -> None: """Update the attributes of the sensor.""" - self._attr_available = not self.is_none - self._attr_native_value = self._value + if self.exists: + self._attr_native_value = self.entity_description.value_fn(self._value) class TeslemetryEnergyInfoSensorEntity(TeslemetryEnergyInfoEntity, SensorEntity): diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 48eb4aae8bc..29c9ef3bbb7 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -420,7 +420,10 @@ "name": "version" }, "vin": { - "name": "Vehicle" + "name": "Vehicle", + "state": { + "disconnected": "Disconnected" + } }, "vpp_backup_reserve_percent": { "name": "VPP backup reserve" From b471a6e519e7660e6fb9a6432cc516dbbd70f87f Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 19 Sep 2024 11:35:44 +0200 Subject: [PATCH 0831/1309] Add has_entity_name to entity display dict and fix name (#125832) * Add has_entity_name to entity display dict and fix name * Fix tests --- homeassistant/helpers/entity_registry.py | 7 +++++-- tests/components/config/test_entity_registry.py | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 5d17c0c46b1..6f4647030dd 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -235,8 +235,11 @@ class RegistryEntry: display_dict["ec"] = ENTITY_CATEGORY_VALUE_TO_INDEX[category] if self.hidden_by is not None: display_dict["hb"] = True - if not self.name and self.has_entity_name: - display_dict["en"] = self.original_name + if self.has_entity_name: + display_dict["hn"] = True + name = self.name or self.original_name + if name is not None: + display_dict["en"] = name if self.domain == "sensor" and (sensor_options := self.options.get("sensor")): if (precision := sensor_options.get("display_precision")) is not None or ( precision := sensor_options.get("suggested_display_precision") diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 60657d4a77b..bfbd69ec9bd 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -245,6 +245,7 @@ async def test_list_entities_for_display( "ec": 1, "ei": "test_domain.test", "en": "Hello World", + "hn": True, "ic": "mdi:icon", "lb": [], "pl": "test_platform", @@ -254,7 +255,7 @@ async def test_list_entities_for_display( "ai": "area52", "di": "device123", "ei": "test_domain.nameless", - "en": None, + "hn": True, "lb": [], "pl": "test_platform", }, @@ -262,6 +263,8 @@ async def test_list_entities_for_display( "ai": "area52", "di": "device123", "ei": "test_domain.renamed", + "en": "User name", + "hn": True, "lb": [], "pl": "test_platform", }, @@ -326,6 +329,7 @@ async def test_list_entities_for_display( "ai": "area52", "di": "device123", "ei": "test_domain.test", + "hn": True, "lb": [], "en": "Hello World", "pl": "test_platform", From b2401bf2e307eaf325c2c1a92e2cee74bcf17efe Mon Sep 17 00:00:00 2001 From: Alberto Montes Date: Thu, 19 Sep 2024 11:38:25 +0200 Subject: [PATCH 0832/1309] Update string formatting to use f-string on components (#125987) * Update string formatting to use f-string on components * Update code given review feedback * Use f-string --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/buienradar/sensor.py | 2 +- homeassistant/components/buienradar/util.py | 2 +- .../components/emoncms_history/__init__.py | 6 ++--- homeassistant/components/graphite/__init__.py | 3 +-- homeassistant/components/home_connect/api.py | 2 +- homeassistant/components/kira/__init__.py | 2 +- .../components/limitlessled/light.py | 4 +-- homeassistant/components/mysensors/light.py | 6 +++-- homeassistant/components/netio/switch.py | 5 ++-- homeassistant/components/numato/__init__.py | 13 +++++----- homeassistant/components/recorder/executor.py | 2 +- .../components/recorder/migration.py | 26 +++++++------------ .../components/sense/binary_sensor.py | 2 +- homeassistant/components/sense/sensor.py | 2 +- .../seven_segments/image_processing.py | 2 +- .../components/shopping_list/intent.py | 6 ++--- .../components/signal_messenger/notify.py | 9 +++---- homeassistant/components/skybeacon/sensor.py | 2 +- homeassistant/components/snips/__init__.py | 2 +- .../components/starlingbank/sensor.py | 5 ++-- homeassistant/components/statsd/__init__.py | 2 +- homeassistant/components/stream/worker.py | 12 +++++---- homeassistant/components/supla/entity.py | 7 +++-- .../swiss_hydrological_data/sensor.py | 2 +- .../components/system_log/__init__.py | 4 +-- homeassistant/components/ted5000/sensor.py | 4 +-- .../components/tellduslive/sensor.py | 2 +- .../components/tensorflow/image_processing.py | 2 +- .../components/tomato/device_tracker.py | 3 ++- homeassistant/components/venstar/__init__.py | 3 ++- homeassistant/components/verisure/camera.py | 8 ++---- .../components/viaggiatreno/sensor.py | 2 +- .../components/yeelight/config_flow.py | 4 +-- homeassistant/components/zha/helpers.py | 18 ++++++------- homeassistant/components/zwave_me/light.py | 4 +-- 35 files changed, 81 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 69c762c1bc1..c61d8e10b85 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -888,7 +888,7 @@ class BrSensor(SensorEntity): if sensor_type.startswith(PRECIPITATION_FORECAST): result = {ATTR_ATTRIBUTION: data.get(ATTRIBUTION)} if self._timeframe is not None: - result[TIMEFRAME_LABEL] = "%d min" % (self._timeframe) + result[TIMEFRAME_LABEL] = f"{self._timeframe} min" self._attr_extra_state_attributes = result diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index f089fce89b7..a7267320de3 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -101,7 +101,7 @@ class BrData: if resp.status == HTTPStatus.OK: result[SUCCESS] = True else: - result[MESSAGE] = "Got http statuscode: %d" % (resp.status) + result[MESSAGE] = f"Got http statuscode: {resp.status}" return result except (TimeoutError, aiohttp.ClientError) as err: diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py index 7de3a4f2ef8..00af1fec6c6 100644 --- a/homeassistant/components/emoncms_history/__init__.py +++ b/homeassistant/components/emoncms_history/__init__.py @@ -86,15 +86,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: continue if payload_dict: - payload = "{{{}}}".format( - ",".join(f"{key}:{val}" for key, val in payload_dict.items()) - ) + payload = ",".join(f"{key}:{val}" for key, val in payload_dict.items()) send_data( conf.get(CONF_URL), conf.get(CONF_API_KEY), str(conf.get(CONF_INPUTNODE)), - payload, + f"{{{payload}}}", ) track_point_in_time( diff --git a/homeassistant/components/graphite/__init__.py b/homeassistant/components/graphite/__init__.py index b0672e1f853..336ca6ba2cb 100644 --- a/homeassistant/components/graphite/__init__.py +++ b/homeassistant/components/graphite/__init__.py @@ -138,8 +138,7 @@ class GraphiteFeeder(threading.Thread): with suppress(ValueError): things["state"] = state.state_as_number(new_state) lines = [ - "%s.%s.%s %f %i" - % (self._prefix, entity_id, key.replace(" ", "_"), value, now) + f"{self._prefix}.{entity_id}.{key.replace(' ', '_')} {value:f} {now}" for key, value in things.items() if isinstance(value, (float, int)) ] diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index 10dc2d360fa..33b1a462e43 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -180,7 +180,7 @@ class DeviceWithPrograms(HomeConnectDevice): ATTR_DEVICE: self, ATTR_DESC: k, ATTR_UNIT: unit, - ATTR_KEY: "BSH.Common.Option.{}".format(k.replace(" ", "")), + ATTR_KEY: f"BSH.Common.Option.{k.replace(' ', '')}", ATTR_ICON: icon, ATTR_DEVICE_CLASS: device_class, ATTR_SIGN: sign, diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py index b41961f64ee..52618a125b6 100644 --- a/homeassistant/components/kira/__init__.py +++ b/homeassistant/components/kira/__init__.py @@ -111,7 +111,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the KIRA module and load platform.""" # note: module_name is not the HA device name. it's just a unique name # to ensure the component and platform can share information - module_name = ("%s_%d" % (DOMAIN, idx)) if idx else DOMAIN + module_name = f"{DOMAIN}_{idx}" if idx else DOMAIN device_name = module_conf.get(CONF_NAME, DOMAIN) port = module_conf.get(CONF_PORT, DEFAULT_PORT) host = module_conf.get(CONF_HOST, DEFAULT_HOST) diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 4456d112d0f..c6b3301081d 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -119,13 +119,13 @@ def rewrite_legacy(config: ConfigType) -> ConfigType: else: _LOGGER.warning("Legacy configuration format detected") for i in range(1, 5): - name_key = "group_%d_name" % i + name_key = f"group_{i}_name" if name_key in bridge_conf: groups.append( { "number": i, "type": bridge_conf.get( - "group_%d_type" % i, DEFAULT_LED_TYPE + f"group_{i}_type", DEFAULT_LED_TYPE ), "name": bridge_conf.get(name_key), } diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index a76b42359c1..87f60174cab 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -173,7 +173,8 @@ class MySensorsLightRGB(MySensorsLight): new_rgb: tuple[int, int, int] | None = kwargs.get(ATTR_RGB_COLOR) if new_rgb is None: return - hex_color = "{:02x}{:02x}{:02x}".format(*new_rgb) + red, green, blue = new_rgb + hex_color = f"{red:02x}{green:02x}{blue:02x}" self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) @@ -220,7 +221,8 @@ class MySensorsLightRGBW(MySensorsLightRGB): new_rgbw: tuple[int, int, int, int] | None = kwargs.get(ATTR_RGBW_COLOR) if new_rgbw is None: return - hex_color = "{:02x}{:02x}{:02x}{:02x}".format(*new_rgbw) + red, green, blue, white = new_rgbw + hex_color = f"{red:02x}{green:02x}{blue:02x}{white:02x}" self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, hex_color, ack=1 ) diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index 54bfef5e1da..5c2b93bcae7 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -109,7 +109,7 @@ class NetioApiView(HomeAssistantView): states, consumptions, cumulated_consumptions, start_dates = [], [], [], [] for i in range(1, 5): - out = "output%d" % i + out = f"output{i}" states.append(data.get(f"{out}_state") == STATE_ON) consumptions.append(float(data.get(f"{out}_consumption", 0))) cumulated_consumptions.append( @@ -168,7 +168,8 @@ class NetioSwitch(SwitchEntity): def _set(self, value): val = list("uuuu") val[int(self.outlet) - 1] = "1" if value else "0" - self.netio.get("port list {}".format("".join(val))) + val = "".join(val) + self.netio.get(f"port list {val}") self.netio.states[int(self.outlet) - 1] = value self.schedule_update_ha_state() diff --git a/homeassistant/components/numato/__init__.py b/homeassistant/components/numato/__init__.py index 28aa8623a7e..00122132d44 100644 --- a/homeassistant/components/numato/__init__.py +++ b/homeassistant/components/numato/__init__.py @@ -185,14 +185,13 @@ class NumatoAPI: if (device_id, port) not in self.ports_registered: self.ports_registered[(device_id, port)] = direction else: + io = ( + "input" + if self.ports_registered[(device_id, port)] == gpio.IN + else "output" + ) raise gpio.NumatoGpioError( - "Device {} port {} already in use as {}.".format( - device_id, - port, - "input" - if self.ports_registered[(device_id, port)] == gpio.IN - else "output", - ) + f"Device {device_id} port {port} already in use as {io}." ) def check_device_id(self, device_id: int) -> None: diff --git a/homeassistant/components/recorder/executor.py b/homeassistant/components/recorder/executor.py index 8102c769ac1..6b8192d1e14 100644 --- a/homeassistant/components/recorder/executor.py +++ b/homeassistant/components/recorder/executor.py @@ -55,7 +55,7 @@ class DBInterruptibleThreadPoolExecutor(InterruptibleThreadPoolExecutor): num_threads = len(self._threads) if num_threads < self._max_workers: - thread_name = "%s_%d" % (self._thread_name_prefix or self, num_threads) + thread_name = f"{self._thread_name_prefix or self}_{num_threads}" executor_thread = threading.Thread( name=thread_name, target=_worker_with_shutdown_hook, diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index df7ff5c4fed..9a27a44d706 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -288,9 +288,11 @@ def _migrate_schema( "The database is about to upgrade from schema version %s to %s%s", current_version, end_version, - f". {MIGRATION_NOTE_OFFLINE}" - if current_version < LIVE_MIGRATION_MIN_SCHEMA_VERSION - else "", + ( + f". {MIGRATION_NOTE_OFFLINE}" + if current_version < LIVE_MIGRATION_MIN_SCHEMA_VERSION + else "" + ), ) schema_status = dataclass_replace(schema_status, current_version=end_version) @@ -475,11 +477,7 @@ def _add_columns( try: connection = session.connection() connection.execute( - text( - "ALTER TABLE {table} {columns_def}".format( - table=table_name, columns_def=", ".join(columns_def) - ) - ) + text(f"ALTER TABLE {table_name} {', '.join(columns_def)}") ) except (InternalError, OperationalError, ProgrammingError): # Some engines support adding all columns at once, @@ -530,10 +528,8 @@ def _modify_columns( if engine.dialect.name == SupportedDialect.POSTGRESQL: columns_def = [ - "ALTER {column} TYPE {type}".format( - **dict(zip(["column", "type"], col_def.split(" ", 1), strict=False)) - ) - for col_def in columns_def + f"ALTER {column} TYPE {type_}" + for column, type_ in (col_def.split(" ", 1) for col_def in columns_def) ] elif engine.dialect.name == "mssql": columns_def = [f"ALTER COLUMN {col_def}" for col_def in columns_def] @@ -544,11 +540,7 @@ def _modify_columns( try: connection = session.connection() connection.execute( - text( - "ALTER TABLE {table} {columns_def}".format( - table=table_name, columns_def=", ".join(columns_def) - ) - ) + text(f"ALTER TABLE {table_name} {', '.join(columns_def)}") ) except (InternalError, OperationalError): _LOGGER.info("Unable to use quick column modify. Modifying 1 by 1") diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 5640dd19961..8317f8458b3 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -56,7 +56,7 @@ async def _migrate_old_unique_ids(hass, devices): def sense_to_mdi(sense_icon): """Convert sense icon to mdi icon.""" - return "mdi:{}".format(MDI_ICONS.get(sense_icon, "power-plug")) + return f"mdi:{MDI_ICONS.get(sense_icon, "power-plug")}" class SenseDevice(BinarySensorEntity): diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 129b1262fd0..bc9dd470f5e 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -78,7 +78,7 @@ TREND_SENSOR_VARIANTS = [ def sense_to_mdi(sense_icon): """Convert sense icon to mdi icon.""" - return "mdi:{}".format(MDI_ICONS.get(sense_icon, "power-plug")) + return f"mdi:{MDI_ICONS.get(sense_icon, 'power-plug')}" async def async_setup_entry( diff --git a/homeassistant/components/seven_segments/image_processing.py b/homeassistant/components/seven_segments/image_processing.py index 7b41a1702c0..63fd27e0dd0 100644 --- a/homeassistant/components/seven_segments/image_processing.py +++ b/homeassistant/components/seven_segments/image_processing.py @@ -82,7 +82,7 @@ class ImageProcessingSsocr(ImageProcessingEntity): self.filepath = os.path.join( self.hass.config.config_dir, - "ssocr-{}.png".format(self._name.replace(" ", "_")), + f"ssocr-{self._name.replace(' ', '_')}.png", ) crop = [ "crop", diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index d45085be5fa..84ea3971293 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -53,10 +53,8 @@ class ListTopItemsIntent(intent.IntentHandler): if not items: response.async_set_speech("There are no items on your shopping list") else: + items_list = ", ".join(itm["name"] for itm in reversed(items)) response.async_set_speech( - "These are the top {} items on your shopping list: {}".format( - min(len(items), 5), - ", ".join(itm["name"] for itm in reversed(items)), - ) + f"These are the top {min(len(items), 5)} items on your shopping list: {items_list}" ) return response diff --git a/homeassistant/components/signal_messenger/notify.py b/homeassistant/components/signal_messenger/notify.py index 9321bc3232f..53a255da5ff 100644 --- a/homeassistant/components/signal_messenger/notify.py +++ b/homeassistant/components/signal_messenger/notify.py @@ -166,12 +166,11 @@ class SignalNotificationService(BaseNotificationService): and int(str(resp.headers.get("Content-Length"))) > attachment_size_limit ): + content_length = int(str(resp.headers.get("Content-Length"))) raise ValueError( # noqa: TRY301 - "Attachment too large (Content-Length reports {}). Max size: {}" - " bytes".format( - int(str(resp.headers.get("Content-Length"))), - CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES, - ) + "Attachment too large (Content-Length reports " + f"{content_length}). Max size: " + f"{CONF_MAX_ALLOWED_DOWNLOAD_SIZE_BYTES} bytes" ) size = 0 diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 5fa62d06fc2..6cb5064b40e 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -184,7 +184,7 @@ class Monitor(threading.Thread, SensorEntity): value[2], value[1], ) - self.data["temp"] = float("%d.%d" % (value[0], value[2])) + self.data["temp"] = float(f"{value[0]}.{value[2]}") self.data["humid"] = value[1] def terminate(self): diff --git a/homeassistant/components/snips/__init__.py b/homeassistant/components/snips/__init__.py index 4731a0f324a..70837b95ec5 100644 --- a/homeassistant/components/snips/__init__.py +++ b/homeassistant/components/snips/__init__.py @@ -140,7 +140,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: slots = {} for slot in request.get("slots", []): slots[slot["slotName"]] = {"value": resolve_slot_values(slot)} - slots["{}_raw".format(slot["slotName"])] = {"value": slot["rawValue"]} + slots[f"{slot['slotName']}_raw"] = {"value": slot["rawValue"]} slots["site_id"] = {"value": request.get("siteId")} slots["session_id"] = {"value": request.get("sessionId")} slots["confidenceScore"] = {"value": request["intent"]["confidenceScore"]} diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index fd351416c28..282323d8b7b 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -92,9 +92,8 @@ class StarlingBalanceSensor(SensorEntity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format( - self._account_name, self._balance_data_type.replace("_", " ").capitalize() - ) + balance_data_type = self._balance_data_type.replace("_", " ").capitalize() + return f"{self._account_name} {balance_data_type}" @property def native_value(self): diff --git a/homeassistant/components/statsd/__init__.py b/homeassistant/components/statsd/__init__.py index efe1c818025..50b74b20028 100644 --- a/homeassistant/components/statsd/__init__.py +++ b/homeassistant/components/statsd/__init__.py @@ -80,7 +80,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: # Send attribute values for key, value in states.items(): if isinstance(value, (float, int)): - stat = "{}.{}".format(state.entity_id, key.replace(" ", "_")) + stat = f"{state.entity_id}.{key.replace(' ', '_')}" statsd_client.gauge(stat, value, sample_rate) elif isinstance(_state, (float, int)): diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 354cc476186..0d72a9b0818 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -367,12 +367,14 @@ class StreamMuxer: data=self._memory_file.read(), ), ( - segment_duration := float( - (adjusted_dts - self._segment_start_dts) * packet.time_base + ( + segment_duration := float( + (adjusted_dts - self._segment_start_dts) * packet.time_base + ) ) - ) - if last_part - else 0, + if last_part + else 0 + ), ) if last_part: # If we've written the last part, we can close the memory_file. diff --git a/homeassistant/components/supla/entity.py b/homeassistant/components/supla/entity.py index fa257e39a06..446d67d19d6 100644 --- a/homeassistant/components/supla/entity.py +++ b/homeassistant/components/supla/entity.py @@ -27,10 +27,9 @@ class SuplaEntity(CoordinatorEntity): @property def unique_id(self) -> str: """Return a unique ID.""" - return "supla-{}-{}".format( - self.channel_data["iodevice"]["gUIDString"].lower(), - self.channel_data["channelNumber"], - ) + uid = self.channel_data["iodevice"]["gUIDString"].lower() + channel_number = self.channel_data["channelNumber"] + return f"supla-{uid}-{channel_number}" @property def name(self) -> str | None: diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py index c67045521b5..3d88182eaa4 100644 --- a/homeassistant/components/swiss_hydrological_data/sensor.py +++ b/homeassistant/components/swiss_hydrological_data/sensor.py @@ -103,7 +103,7 @@ class SwissHydrologicalDataSensor(SensorEntity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._data["water-body-name"], self._condition) + return f"{self._data['water-body-name']} {self._condition}" @property def unique_id(self) -> str: diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 0749f87a67f..22950aa9f1e 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -299,9 +299,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass_path: str = HOMEASSISTANT_PATH[0] config_dir = hass.config.config_dir - paths_re = re.compile( - r"(?:{})/(.*)".format("|".join([re.escape(x) for x in (hass_path, config_dir)])) - ) + paths_re = re.compile(rf"(?:{re.escape(hass_path)}|{re.escape(config_dir)})/(.*)") handler = LogErrorHandler( hass, conf[CONF_MAX_ENTRIES], conf[CONF_FIRE_EVENT], paths_re ) diff --git a/homeassistant/components/ted5000/sensor.py b/homeassistant/components/ted5000/sensor.py index 68f4520a7e3..26f469349b4 100644 --- a/homeassistant/components/ted5000/sensor.py +++ b/homeassistant/components/ted5000/sensor.py @@ -136,8 +136,8 @@ class Ted5000Gateway: mtus = int(doc["LiveData"]["System"]["NumberMTU"]) for mtu in range(1, mtus + 1): - power = int(doc["LiveData"]["Power"]["MTU%d" % mtu]["PowerNow"]) - voltage = int(doc["LiveData"]["Voltage"]["MTU%d" % mtu]["VoltageNow"]) + power = int(doc["LiveData"]["Power"][f"MTU{mtu}"]["PowerNow"]) + voltage = int(doc["LiveData"]["Voltage"][f"MTU{mtu}"]["VoltageNow"]) self.data[mtu] = { UnitOfPower.WATT: power, diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 70c83bb0038..e588ea6318f 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 "{}-{}-{}".format(*self._id) + return "-".join(self._id) diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index cf8e293161a..f4a3a7bfe07 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -324,7 +324,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): # Draw detected objects for instance in values: - label = "{} {:.1f}%".format(category, instance["score"]) + label = f"{category} {instance['score']:.1f}%" draw_box( draw, instance["box"], img_width, img_height, label, (255, 255, 0) ) diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py index b705363944f..dfa8d2bd4e1 100644 --- a/homeassistant/components/tomato/device_tracker.py +++ b/homeassistant/components/tomato/device_tracker.py @@ -61,9 +61,10 @@ class TomatoDeviceScanner(DeviceScanner): if port is None: port = 443 if self.ssl else 80 + protocol = "https" if self.ssl else "http" self.req = requests.Request( "POST", - "http{}://{}:{}/update.cgi".format("s" if self.ssl else "", host, port), + f"{protocol}://{host}:{port}/update.cgi", data={"_http_id": http_id, "exec": "devlist"}, auth=requests.auth.HTTPBasicAuth(username, password), ).prepare() diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index cbcfd3dff90..563a974fad6 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -84,10 +84,11 @@ class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): @property def device_info(self) -> DeviceInfo: """Return the device information for this entity.""" + fw_ver_major, fw_ver_minor = self._client.get_firmware_ver() return DeviceInfo( identifiers={(DOMAIN, self._config.entry_id)}, name=self._client.name, manufacturer="Venstar", model=f"{self._client.model}-{self._client.get_type()}", - sw_version="{}.{}".format(*(self._client.get_firmware_ver())), + sw_version=f"{fw_ver_major}.{fw_ver_minor}", ) diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 50606a49eab..70cd436d24c 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -110,9 +110,7 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) return LOGGER.debug("Download new image %s", new_image_id) - new_image_path = os.path.join( - self._directory_path, "{}{}".format(new_image_id, ".jpg") - ) + new_image_path = os.path.join(self._directory_path, f"{new_image_id}.jpg") new_image_url = new_image["contentUrl"] self.coordinator.verisure.download_image(new_image_url, new_image_path) LOGGER.debug("Old image_id=%s", self._image_id) @@ -123,9 +121,7 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) def delete_image(self, _=None) -> None: """Delete an old image.""" - remove_image = os.path.join( - self._directory_path, "{}{}".format(self._image_id, ".jpg") - ) + remove_image = os.path.join(self._directory_path, f"{self._image_id}.jpg") try: os.remove(remove_image) LOGGER.debug("Deleting old image %s", remove_image) diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 1ea12ed6a41..cb652270c69 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -174,7 +174,7 @@ class ViaggiaTrenoSensor(SensorEntity): self._state = NO_INFORMATION_STRING self._unit = "" else: - self._state = "Error: {}".format(res["error"]) + self._state = f"Error: {res['error']}" self._unit = "" else: for i in MONITORED_INFO: diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index b22774c68c3..cafed622300 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -85,9 +85,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle discovery from zeroconf.""" self._discovered_ip = discovery_info.host - await self.async_set_unique_id( - "{0:#0{1}x}".format(int(discovery_info.name[-26:-18]), 18) - ) + await self.async_set_unique_id(f"{int(discovery_info.name[-26:-18]):#018x}") return await self._async_handle_discovery_with_unique_id() async def async_step_ssdp( diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 4ca2f5d172b..dc999f13693 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -617,9 +617,11 @@ class ZHAGatewayProxy(EventBase): ATTR_NWK: str(event.device_info.nwk), ATTR_IEEE: str(event.device_info.ieee), DEVICE_PAIRING_STATUS: event.device_info.pairing_status.name, - ATTR_MODEL: event.device_info.model - if event.device_info.model - else UNKNOWN_MODEL, + ATTR_MODEL: ( + event.device_info.model + if event.device_info.model + else UNKNOWN_MODEL + ), ATTR_MANUFACTURER: manuf if manuf else UNKNOWN_MANUFACTURER, ATTR_SIGNATURE: event.device_info.signature, }, @@ -922,9 +924,7 @@ class LogRelayHandler(logging.Handler): hass_path: str = HOMEASSISTANT_PATH[0] config_dir = self.hass.config.config_dir self.paths_re = re.compile( - r"(?:{})/(.*)".format( - "|".join([re.escape(x) for x in (hass_path, config_dir)]) - ) + rf"(?:{re.escape(hass_path)}|{re.escape(config_dir)})/(.*)" ) def emit(self, record: LogRecord) -> None: @@ -1025,9 +1025,9 @@ def cluster_command_schema_to_vol_schema(schema: CommandSchema) -> vol.Schema: """Convert a cluster command schema to a voluptuous schema.""" return vol.Schema( { - vol.Optional(field.name) - if field.optional - else vol.Required(field.name): schema_type_to_vol(field.type) + ( + vol.Optional(field.name) if field.optional else vol.Required(field.name) + ): schema_type_to_vol(field.type) for field in schema.fields } ) diff --git a/homeassistant/components/zwave_me/light.py b/homeassistant/components/zwave_me/light.py index f111c04e928..ef3eca5d389 100644 --- a/homeassistant/components/zwave_me/light.py +++ b/homeassistant/components/zwave_me/light.py @@ -85,8 +85,8 @@ class ZWaveMeRGB(ZWaveMeEntity, LightEntity): self.device.id, f"exact?level={round(brightness / 2.55)}" ) return - cmd = "exact?red={}&green={}&blue={}" - cmd = cmd.format(*color) if any(color) else cmd.format(*(255, 255, 255)) + red, green, blue = color if any(color) else (255, 255, 255) + cmd = f"exact?red={red}&green={green}&blue={blue}" self.controller.zwave_api.send_command(self.device.id, cmd) @property From c81d10482200113251e332e2ada4eb277d36f733 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 19 Sep 2024 13:12:37 +0200 Subject: [PATCH 0833/1309] Sort values in Platform enum (#126259) --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index acbef5c58cc..aaffcc9aa84 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -75,9 +75,9 @@ class Platform(StrEnum): TIME = "time" TODO = "todo" TTS = "tts" + UPDATE = "update" VACUUM = "vacuum" VALVE = "valve" - UPDATE = "update" WAKE_WORD = "wake_word" WATER_HEATER = "water_heater" WEATHER = "weather" From 5864591150434cf8ace221cf3141a1797a031625 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 19 Sep 2024 13:28:09 +0200 Subject: [PATCH 0834/1309] Mark tag as entity component in pylint plugin (#126183) * Move tag base entity to separate module * Add tag to _ENTITY_COMPONENTS * Move Entity back in * Add tag to base platforms * Adjust core_files * Revert "Adjust core_files" This reverts commit 180c5034de5c4e80afeeb8149c6fa22395b215a4. * Revert "Add tag to base platforms" This reverts commit 381bcf12f0b52a5df665086862e715bbc7e90b79. --- homeassistant/components/tag/__init__.py | 2 +- pylint/plugins/hass_enforce_class_module.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 160408732c9..0462c5bec34 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -360,7 +360,7 @@ async def async_scan_tag( _LOGGER.debug("Tag: %s scanned by device: %s", tag_id, device_id) -class TagEntity(Entity): # pylint: disable=hass-enforce-class-module +class TagEntity(Entity): """Representation of a Tag entity.""" _unrecorded_attributes = frozenset({TAG_ID}) diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index 0fce0e13f63..c0b363bbddf 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -65,7 +65,8 @@ _MODULES: dict[str, set[str]] = { "WeatherEntityDescription", }, } -_PLATFORMS: set[str] = {platform.value for platform in Platform} +_ENTITY_COMPONENTS: set[str] = {platform.value for platform in Platform} +_ENTITY_COMPONENTS.add("tag") class HassEnforceClassModule(BaseChecker): @@ -92,7 +93,7 @@ class HassEnforceClassModule(BaseChecker): current_integration = parts[2] current_module = parts[3] if len(parts) > 3 else "" - if current_module != "entity" and current_integration not in _PLATFORMS: + if current_module != "entity" and current_integration not in _ENTITY_COMPONENTS: top_level_ancestors = list(node.ancestors(recurs=False)) for ancestor in top_level_ancestors: From 31adb048f1dc89517b7a1b2a1775abc455b17174 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 19 Sep 2024 13:42:53 +0200 Subject: [PATCH 0835/1309] Bump uv to 0.4.12 (#126257) --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 416a7ee91b8..469bd3910b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.4.9 +RUN pip3 install uv==0.4.12 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b6132523bf8..d04afe27656 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -58,7 +58,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.4.9 +uv==0.4.12 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index c3dc607afc5..6fb54eb5ec2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.4.9", + "uv==0.4.12", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", diff --git a/requirements.txt b/requirements.txt index bdba105011f..eacd78fe863 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,7 +37,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.4.9 +uv==0.4.12 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index d3638015199..5e42d0268dc 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.4.9,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.4.12,source=/uv,target=/bin/uv \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ From 7ba9d1fe65704122e8d1c8e74da5d6c98a0e0f2f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 19 Sep 2024 13:57:27 +0200 Subject: [PATCH 0836/1309] Use mock_config_flow helper in config_entries tests (#126251) --- tests/test_config_entries.py | 136 +++++++++++++++++------------------ 1 file changed, 66 insertions(+), 70 deletions(-) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index faa1c4c5bcc..422fa516a2a 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -675,7 +675,7 @@ async def test_add_entry_calls_setup_entry( """Test user step.""" return self.async_create_entry(title="title", data={"token": "supersecret"}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow, "beer": 5}): + with mock_config_flow("comp", TestFlow), mock_config_flow("invalid_flow", 5): await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} ) @@ -866,7 +866,7 @@ async def test_saving_and_loading( await self.async_set_unique_id("unique") return self.async_create_entry(title="Test Title", data={"token": "abcd"}) - with patch.dict(config_entries.HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_USER} ) @@ -1059,23 +1059,20 @@ async def test_discovery_notification( mock_integration(hass, MockModule("test")) mock_platform(hass, "test.config_flow", None) - with patch.dict(config_entries.HANDLERS): + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" - class TestFlow(config_entries.ConfigFlow, domain="test"): - """Test flow.""" + VERSION = 5 - VERSION = 5 + async def async_step_discovery(self, discovery_info): + """Test discovery step.""" + return self.async_show_form(step_id="discovery_confirm") - async def async_step_discovery(self, discovery_info): - """Test discovery step.""" - return self.async_show_form(step_id="discovery_confirm") - - async def async_step_discovery_confirm(self, discovery_info): - """Test discovery confirm step.""" - return self.async_create_entry( - title="Test Title", data={"token": "abcd"} - ) + async def async_step_discovery_confirm(self, discovery_info): + """Test discovery confirm step.""" + return self.async_create_entry(title="Test Title", data={"token": "abcd"}) + with mock_config_flow("test", TestFlow): notifications = async_get_persistent_notifications(hass) assert "config_entry_discovery" not in notifications @@ -1113,29 +1110,28 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: mock_integration(hass, MockModule("test")) mock_platform(hass, "test.config_flow", None) - with patch.dict(config_entries.HANDLERS): + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" - class TestFlow(config_entries.ConfigFlow, domain="test"): - """Test flow.""" + VERSION = 5 - VERSION = 5 + async def async_step_user(self, user_input): + """Test user step.""" + return self.async_show_form(step_id="user_confirm") - async def async_step_user(self, user_input): - """Test user step.""" - return self.async_show_form(step_id="user_confirm") + async def async_step_user_confirm(self, user_input): + """Test user confirm step.""" + return self.async_show_form(step_id="user_confirm") - async def async_step_user_confirm(self, user_input): - """Test user confirm step.""" - return self.async_show_form(step_id="user_confirm") + async def async_step_reauth(self, user_input): + """Test reauth step.""" + return self.async_show_form(step_id="reauth_confirm") - async def async_step_reauth(self, user_input): - """Test reauth step.""" - return self.async_show_form(step_id="reauth_confirm") - - async def async_step_reauth_confirm(self, user_input): - """Test reauth confirm step.""" - return self.async_abort(reason="test") + async def async_step_reauth_confirm(self, user_input): + """Test reauth confirm step.""" + return self.async_abort(reason="test") + with mock_config_flow("test", TestFlow): # Start user flow to assert that reconfigure notification doesn't fire await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_USER} @@ -1235,7 +1231,7 @@ async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: """Test discovery step.""" return self.async_abort(reason="test") - with patch.dict(config_entries.HANDLERS, {"test": TestFlow}): + with mock_config_flow("test", TestFlow): await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_DISCOVERY} ) @@ -1570,7 +1566,7 @@ async def test_create_entry_options( options={"example": user_input["option"]}, ) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): assert await async_setup_component(hass, "comp", {}) await hass.async_block_till_done() @@ -2317,7 +2313,7 @@ async def test_unique_id_persisted( await self.async_set_unique_id("mock-unique-id") return self.async_create_entry(title="mock-title", data={}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} ) @@ -2368,7 +2364,7 @@ async def test_unique_id_existing_entry( return self.async_create_entry(title="mock-title", data={"via": "flow"}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} ) @@ -2414,7 +2410,7 @@ async def test_entry_id_existing_entry( with ( pytest.raises(HomeAssistantError), - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), patch( "homeassistant.config_entries.ulid_util.ulid_now", return_value=collide_entry_id, @@ -2457,7 +2453,7 @@ async def test_unique_id_update_existing_entry_without_reload( ) with ( - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload, @@ -2507,7 +2503,7 @@ async def test_unique_id_update_existing_entry_with_reload( ) with ( - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload, @@ -2527,7 +2523,7 @@ async def test_unique_id_update_existing_entry_with_reload( updates["host"] = "2.2.2.2" entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None) with ( - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload, @@ -2584,7 +2580,7 @@ async def test_unique_id_from_discovery_in_setup_retry( # Verify we do not reload from a user source with ( - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload, @@ -2600,7 +2596,7 @@ async def test_unique_id_from_discovery_in_setup_retry( # Verify do reload from a discovery source with ( - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload, @@ -2652,7 +2648,7 @@ async def test_unique_id_not_update_existing_entry( ) with ( - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload, @@ -2686,7 +2682,7 @@ async def test_unique_id_in_progress( await self.async_set_unique_id("mock-unique-id") return self.async_show_form(step_id="discovery") - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): # Create one to be in progress result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} @@ -2726,7 +2722,7 @@ async def test_finish_flow_aborts_progress( return self.async_create_entry(title="yo", data={}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): # Create one to be in progress result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} @@ -2761,7 +2757,7 @@ async def test_unique_id_ignore( await self.async_set_unique_id("mock-unique-id") return self.async_show_form(step_id="discovery") - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): # Create one to be in progress result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} @@ -2825,7 +2821,7 @@ async def test_manual_add_overrides_ignored_entry( raise NotImplementedError with ( - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload, @@ -2869,7 +2865,7 @@ async def test_manual_add_overrides_ignored_entry_singleton( return self.async_abort(reason="single_instance_allowed") return self.async_create_entry(title="title", data={"token": "supersecret"}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow, "beer": 5}): + with mock_config_flow("comp", TestFlow), mock_config_flow("invalid_flow", 5): await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} ) @@ -2910,7 +2906,7 @@ async def test_async_current_entries_does_not_skip_ignore_non_user( return self.async_abort(reason="single_instance_allowed") return self.async_create_entry(title="title", data={"token": "supersecret"}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow, "beer": 5}): + with mock_config_flow("comp", TestFlow), mock_config_flow("invalid_flow", 5): await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_IMPORT} ) @@ -2947,7 +2943,7 @@ async def test_async_current_entries_explicit_skip_ignore( return self.async_abort(reason="single_instance_allowed") return self.async_create_entry(title="title", data={"token": "supersecret"}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow, "beer": 5}): + with mock_config_flow("comp", TestFlow), mock_config_flow("invalid_flow", 5): await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_IMPORT} ) @@ -2988,7 +2984,7 @@ async def test_async_current_entries_explicit_include_ignore( return self.async_abort(reason="single_instance_allowed") return self.async_create_entry(title="title", data={"token": "supersecret"}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow, "beer": 5}): + with mock_config_flow("comp", TestFlow), mock_config_flow("invalid_flow", 5): await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_IMPORT} ) @@ -3016,7 +3012,7 @@ async def test_unignore_step_form( await self.async_set_unique_id(unique_id) return self.async_show_form(step_id="discovery") - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_IGNORE}, @@ -3059,7 +3055,7 @@ async def test_unignore_create_entry( await self.async_set_unique_id(unique_id) return self.async_create_entry(title="yo", data={}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_IGNORE}, @@ -3099,7 +3095,7 @@ async def test_unignore_default_impl( VERSION = 1 - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_IGNORE}, @@ -3151,7 +3147,7 @@ async def test_partial_flows_hidden( async def async_step_someform(self, user_input=None): raise NotImplementedError - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): # Start a config entry flow and wait for it to be blocked init_task = asyncio.ensure_future( manager.flow.async_init( @@ -3217,7 +3213,7 @@ async def test_async_setup_init_entry( """Test import step creating entry.""" return self.async_create_entry(title="title", data={}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): assert await async_setup_component(hass, "comp", {}) await hass.async_block_till_done() @@ -3278,7 +3274,7 @@ async def test_async_setup_init_entry_completes_before_loaded_event_fires( # This test must not use hass.async_block_till_done() # as its explicitly testing what happens without it - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): assert await async_setup_component(hass, "comp", {}) assert len(async_setup_entry.mock_calls) == 1 assert load_events[0].event_type == EVENT_COMPONENT_LOADED @@ -3334,7 +3330,7 @@ async def test_async_setup_update_entry(hass: HomeAssistant) -> None: ) return self.async_abort(reason="yo") - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): assert await async_setup_component(hass, "comp", {}) entries = hass.config_entries.async_entries("comp") @@ -3383,7 +3379,7 @@ async def test_flow_with_default_discovery( return self.async_create_entry(title="yo", data={}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): # Create one to be in progress result = await manager.flow.async_init( "comp", context={"source": discovery_source[0]}, data=discovery_source[1] @@ -3433,7 +3429,7 @@ async def test_flow_with_default_discovery_with_unique_id( async def async_step_mock(self, user_input=None): raise NotImplementedError - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DISCOVERY} ) @@ -3460,7 +3456,7 @@ async def test_default_discovery_abort_existing_entries( VERSION = 1 - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DISCOVERY} ) @@ -3489,7 +3485,7 @@ async def test_default_discovery_in_progress( async def async_step_mock(self, user_input=None): raise NotImplementedError - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DISCOVERY}, @@ -3529,7 +3525,7 @@ async def test_default_discovery_abort_on_new_unique_flow( async def async_step_mock(self, user_input=None): raise NotImplementedError - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): # First discovery with default, no unique ID result2 = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DISCOVERY}, data={} @@ -3576,7 +3572,7 @@ async def test_default_discovery_abort_on_user_flow_complete( async def async_step_mock(self, user_input=None): raise NotImplementedError - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): # First discovery with default, no unique ID flow1 = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DISCOVERY}, data={} @@ -3640,7 +3636,7 @@ async def test_flow_same_device_multiple_sources( return self.async_show_form(step_id="link") return self.async_create_entry(title="title", data={"token": "supersecret"}) - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + with mock_config_flow("comp", TestFlow): # Create one to be in progress flow1 = manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_ZEROCONF} @@ -4159,7 +4155,7 @@ async def test_async_abort_entries_match( self._async_abort_entries_match(matchers) return self.async_abort(reason="no_match") - with patch.dict(config_entries.HANDLERS, {"comp": TestFlow, "beer": 5}): + with mock_config_flow("comp", TestFlow), mock_config_flow("invalid_flow", 5): result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_USER} ) @@ -4455,7 +4451,7 @@ async def test_unique_id_update_while_setup_in_progress( ) with ( - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload, @@ -5023,7 +5019,7 @@ async def test_update_entry_and_reload( **kwargs, ) - with patch.dict(config_entries.HANDLERS, {"comp": MockFlowHandler}): + with mock_config_flow("comp", MockFlowHandler): task = await manager.flow.async_init("comp", context={"source": "reauth"}) await hass.async_block_till_done() @@ -5305,7 +5301,7 @@ async def test_avoid_adding_second_config_entry_on_single_config_entry( "homeassistant.loader.async_get_integration", return_value=integration, ), - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), ): # Start a flow result = await manager.flow.async_init( @@ -5364,7 +5360,7 @@ async def test_in_progress_get_canceled_when_entry_is_created( return self.async_show_form(step_id="user") with ( - patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), + mock_config_flow("comp", TestFlow), patch( "homeassistant.loader.async_get_integration", return_value=integration, From 28ece89272c48695d550110b661c03ab4755bbc4 Mon Sep 17 00:00:00 2001 From: Alberto Montes Date: Thu, 19 Sep 2024 14:31:13 +0200 Subject: [PATCH 0837/1309] Update string formatting to use f-string on core codebase (#125988) * Update string formatting to use f-string on core codebase * Small change given review feedback --- homeassistant/helpers/dispatcher.py | 8 ++++---- homeassistant/util/logging.py | 6 +++--- script/inspect_schemas.py | 4 +++- .../device_trigger/tests/test_device_trigger.py | 14 ++++++++------ 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 173e441781c..a5a790b7ce5 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -151,11 +151,11 @@ def _format_err[*_Ts]( *args: Any, ) -> str: """Format error message.""" - return "Exception in {} when dispatching '{}': {}".format( + + return ( # Functions wrapped in partial do not have a __name__ - getattr(target, "__name__", None) or str(target), - signal, - args, + f"Exception in {getattr(target, "__name__", None) or target} " + f"when dispatching '{signal}': {args}" ) diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index d2554ef543c..2c4eb744614 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -196,8 +196,8 @@ def async_create_catching_coro[_T]( trace = traceback.extract_stack() return catch_log_coro_exception( target, - lambda: "Exception in {} called from\n {}".format( - target.__name__, - "".join(traceback.format_list(trace[:-1])), + lambda: ( + f"Exception in {target.__name__} called from\n" + + "".join(traceback.format_list(trace[:-1])) ), ) diff --git a/script/inspect_schemas.py b/script/inspect_schemas.py index fa6707e93b2..0f888d14af2 100755 --- a/script/inspect_schemas.py +++ b/script/inspect_schemas.py @@ -57,7 +57,9 @@ def main(): ) for key in sorted(msg): - print("\n{}\n - {}".format(key, "\n - ".join(msg[key]))) + print(f"\n{key}") + for val in msg[key]: + print(f" - {val}") if __name__ == "__main__": diff --git a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py index 7e4f88261bc..1693049ae4c 100644 --- a/script/scaffold/templates/device_trigger/tests/test_device_trigger.py +++ b/script/scaffold/templates/device_trigger/tests/test_device_trigger.py @@ -109,14 +109,16 @@ async def test_if_fires_on_state_change( hass.states.async_set("NEW_DOMAIN.entity", STATE_ON) await hass.async_block_till_done() assert len(service_calls) == 1 - assert service_calls[0].data[ - "some" - ] == "turn_on - device - {} - off - on - None - 0".format("NEW_DOMAIN.entity") + assert ( + service_calls[0].data["some"] + == "turn_on - device - NEW_DOMAIN.entity - off - on - None - 0" + ) # Fake that the entity is turning off. hass.states.async_set("NEW_DOMAIN.entity", STATE_OFF) await hass.async_block_till_done() assert len(service_calls) == 2 - assert service_calls[1].data[ - "some" - ] == "turn_off - device - {} - on - off - None - 0".format("NEW_DOMAIN.entity") + assert ( + service_calls[1].data["some"] + == "turn_off - device - NEW_DOMAIN.entity - on - off - None - 0" + ) From b2d669ac3ce4173a7d6cbdb86e42eccc47bfd42b Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Thu, 19 Sep 2024 09:13:21 -0400 Subject: [PATCH 0838/1309] Add aiohasupervisor to core requirements (#126225) --- homeassistant/package_constraints.txt | 1 + pyproject.toml | 3 +++ requirements.txt | 1 + 3 files changed, 5 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d04afe27656..4ec00f00ab0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,6 +3,7 @@ aiodhcpwatcher==1.0.2 aiodiscover==2.1.0 aiodns==3.2.0 +aiohasupervisor==0.1.0b0 aiohttp-fast-zlib==0.1.1 aiohttp==3.10.5 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index 6fb54eb5ec2..5fdbb91e434 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ classifiers = [ requires-python = ">=3.12.0" 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.0b0", "aiohttp==3.10.5", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", diff --git a/requirements.txt b/requirements.txt index eacd78fe863..bfe13e72a5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ # Home Assistant Core aiodns==3.2.0 +aiohasupervisor==0.1.0b0 aiohttp==3.10.5 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 From baa79303a7147ec1a67b9112ea696bc27d8d44df Mon Sep 17 00:00:00 2001 From: Claudia Pellegrino Date: Thu, 19 Sep 2024 16:11:13 +0200 Subject: [PATCH 0839/1309] Make combined rmvtransport filters work (#126255) rmvtransport: make filters always effective In the `rmvtransport` integration, the three config attributes `destination`, `lines`, and `time_offset` all act as filters. The expectation is that if multiple filters are given, all of them take effect. However, as a consequence of using `elif` in the loop body, if a `destination` filter has been configured, then both the `lines` and the `time_offset` filters are ignored and have no effect. Replace the `elif` with an `if` clause to allow all filter settings to work as intended. CC: @cgtobi --- .../components/rmvtransport/sensor.py | 2 +- tests/components/rmvtransport/test_sensor.py | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index f9ad4e24631..8fd437e7e1d 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -271,7 +271,7 @@ class RMVDepartureData: if not dest_found: continue - elif ( + if ( self._lines and journey["number"] not in self._lines or journey["minutes"] < self._time_offset diff --git a/tests/components/rmvtransport/test_sensor.py b/tests/components/rmvtransport/test_sensor.py index c17eaac2105..47728be438c 100644 --- a/tests/components/rmvtransport/test_sensor.py +++ b/tests/components/rmvtransport/test_sensor.py @@ -32,6 +32,23 @@ VALID_CONFIG_MISC = { } VALID_CONFIG_DEST = { + "sensor": { + "platform": "rmvtransport", + "next_departure": [ + { + "station": "3000010", + "destinations": [ + "Frankfurt (Main) Flughafen Regionalbahnhof", + "Frankfurt (Main) Stadion", + ], + "lines": [12, "S8"], + "time_offset": 15, + } + ], + } +} + +VALID_CONFIG_DEST_ONLY = { "sensor": { "platform": "rmvtransport", "next_departure": [ @@ -144,6 +161,19 @@ def get_departures_mock(): "info_long": None, "icon": "https://products/32_pic.png", }, + { + "product": "Bus", + "number": 12, + "trainId": "1234568", + "direction": "Frankfurt (Main) Hugo-Junkers-Straße/Schleife", + "departure_time": datetime.datetime(2018, 8, 6, 14, 30), + "minutes": 16, + "delay": 0, + "stops": ["Frankfurt (Main) Stadion"], + "info": None, + "info_long": None, + "icon": "https://products/32_pic.png", + }, ], } @@ -215,6 +245,26 @@ async def test_rmvtransport_dest_config(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "sensor", VALID_CONFIG_DEST) await hass.async_block_till_done() + state = hass.states.get("sensor.frankfurt_main_hauptbahnhof") + assert state is not None + assert state.state == "16" + assert ( + state.attributes["direction"] == "Frankfurt (Main) Hugo-Junkers-Straße/Schleife" + ) + assert state.attributes["line"] == 12 + assert state.attributes["minutes"] == 16 + assert state.attributes["departure_time"] == datetime.datetime(2018, 8, 6, 14, 30) + + +async def test_rmvtransport_dest_only_config(hass: HomeAssistant) -> None: + """Test destination configuration.""" + with patch( + "RMVtransport.RMVtransport.get_departures", + return_value=get_departures_mock(), + ): + assert await async_setup_component(hass, "sensor", VALID_CONFIG_DEST_ONLY) + await hass.async_block_till_done() + state = hass.states.get("sensor.frankfurt_main_hauptbahnhof") assert state.state == "11" assert ( From 9988c66d6769bb405aa27a1383e0b6b7bb2892f1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 19 Sep 2024 17:30:54 +0200 Subject: [PATCH 0840/1309] Bump reolink_aio to 0.9.9 (#126267) --- 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 b90f7f4a045..20c90c427d2 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.8"] + "requirements": ["reolink-aio==0.9.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 727cfaf8a00..86153e47ff8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2531,7 +2531,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.9.8 +reolink-aio==0.9.9 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3df4a5d6492..ef48da0f8ae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2016,7 +2016,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.9.8 +reolink-aio==0.9.9 # homeassistant.components.rflink rflink==0.0.66 From b18b532b4091c904d2be578e4e21ed40aacb46e3 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Thu, 19 Sep 2024 18:40:39 +0100 Subject: [PATCH 0841/1309] Bump ring-doorbell to 0.9.5 (#126264) * Bump ring_doorbell to 0.9.5 * Update number snapshot --- homeassistant/components/ring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ring/snapshots/test_number.ambr | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json index 3aced8fd1ea..78195cccfe6 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.3"] + "requirements": ["ring-doorbell[listen]==0.9.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 86153e47ff8..a1e8c6aabca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2540,7 +2540,7 @@ rfk101py==0.0.1 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell[listen]==0.9.3 +ring-doorbell[listen]==0.9.5 # homeassistant.components.fleetgo ritassist==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef48da0f8ae..fb7533ca4a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2022,7 +2022,7 @@ reolink-aio==0.9.9 rflink==0.0.66 # homeassistant.components.ring -ring-doorbell[listen]==0.9.3 +ring-doorbell[listen]==0.9.5 # homeassistant.components.roku rokuecp==0.19.3 diff --git a/tests/components/ring/snapshots/test_number.ambr b/tests/components/ring/snapshots/test_number.ambr index 97059527ade..9228589dc81 100644 --- a/tests/components/ring/snapshots/test_number.ambr +++ b/tests/components/ring/snapshots/test_number.ambr @@ -397,7 +397,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 10, + 'max': 11, 'min': 0, 'mode': , 'step': 1, @@ -434,7 +434,7 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Ring.com', 'friendly_name': 'Downstairs Volume', - 'max': 10, + 'max': 11, 'min': 0, 'mode': , 'step': 1, From 21affac5711c6508fa72928f56767a50a12e1385 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Sep 2024 20:50:33 +0200 Subject: [PATCH 0842/1309] Rename mqtt mixins module to `entity.py` (#126279) --- homeassistant/components/mqtt/alarm_control_panel.py | 2 +- homeassistant/components/mqtt/binary_sensor.py | 2 +- homeassistant/components/mqtt/button.py | 2 +- homeassistant/components/mqtt/camera.py | 2 +- homeassistant/components/mqtt/climate.py | 2 +- homeassistant/components/mqtt/cover.py | 2 +- homeassistant/components/mqtt/device_automation.py | 2 +- homeassistant/components/mqtt/device_tracker.py | 2 +- homeassistant/components/mqtt/device_trigger.py | 2 +- homeassistant/components/mqtt/{mixins.py => entity.py} | 10 +++++----- homeassistant/components/mqtt/event.py | 2 +- homeassistant/components/mqtt/fan.py | 2 +- homeassistant/components/mqtt/humidifier.py | 2 +- homeassistant/components/mqtt/image.py | 2 +- homeassistant/components/mqtt/lawn_mower.py | 2 +- homeassistant/components/mqtt/light/__init__.py | 2 +- homeassistant/components/mqtt/light/schema_basic.py | 2 +- homeassistant/components/mqtt/light/schema_json.py | 2 +- homeassistant/components/mqtt/light/schema_template.py | 2 +- homeassistant/components/mqtt/lock.py | 2 +- homeassistant/components/mqtt/notify.py | 2 +- homeassistant/components/mqtt/number.py | 2 +- homeassistant/components/mqtt/scene.py | 2 +- homeassistant/components/mqtt/select.py | 2 +- homeassistant/components/mqtt/sensor.py | 2 +- homeassistant/components/mqtt/siren.py | 2 +- homeassistant/components/mqtt/switch.py | 2 +- homeassistant/components/mqtt/tag.py | 2 +- homeassistant/components/mqtt/text.py | 2 +- homeassistant/components/mqtt/update.py | 2 +- homeassistant/components/mqtt/vacuum.py | 2 +- homeassistant/components/mqtt/valve.py | 2 +- homeassistant/components/mqtt/water_heater.py | 2 +- tests/components/mqtt/test_common.py | 4 ++-- tests/components/mqtt/test_event.py | 6 +++--- tests/components/mqtt/test_init.py | 2 +- 36 files changed, 43 insertions(+), 43 deletions(-) rename homeassistant/components/mqtt/{mixins.py => entity.py} (99%) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 3cdb3efea7f..7f14c65ffb0 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -39,7 +39,7 @@ from .const import ( CONF_SUPPORTED_FEATURES, PAYLOAD_NONE, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 293b6e5f1f4..7f89a78991a 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -37,7 +37,7 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA from .const import CONF_STATE_TOPIC, PAYLOAD_NONE -from .mixins import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper +from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 6ad11859f44..2aac51890c1 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -15,7 +15,7 @@ from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_RETAIN -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index fa550b9fd0c..ca622defb25 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_TOPIC -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_subscribe_topic diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index ac276c37d71..dd3efa4054b 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -79,7 +79,7 @@ from .const import ( DEFAULT_OPTIMISTIC, PAYLOAD_NONE, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 2d1b64d002a..f53d895ec4f 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -61,7 +61,7 @@ from .const import ( DEFAULT_RETAIN, PAYLOAD_NONE, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index 8d23d32326b..366f2f13ad4 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -12,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import device_trigger from .config import MQTT_BASE_SCHEMA -from .mixins import async_setup_non_entity_entry_helper +from .entity import async_setup_non_entity_entry_helper AUTOMATION_TYPE_TRIGGER = "trigger" AUTOMATION_TYPES = [AUTOMATION_TYPE_TRIGGER] diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 57614106d4e..13b89256e21 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -33,7 +33,7 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_PAYLOAD_RESET, CONF_STATE_TOPIC -from .mixins import CONF_JSON_ATTRS_TOPIC, MqttEntity, async_setup_entity_entry_helper +from .entity import CONF_JSON_ATTRS_TOPIC, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_subscribe_topic diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 911dce163f9..80faf879587 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -36,7 +36,7 @@ from .const import ( DOMAIN, ) from .discovery import MQTTDiscoveryPayload, clear_discovery_hash -from .mixins import MqttDiscoveryDeviceUpdateMixin, send_discovery_done, update_device +from .entity import MqttDiscoveryDeviceUpdateMixin, send_discovery_done, update_device from .models import DATA_MQTT from .schemas import MQTT_ENTITY_DEVICE_INFO_SCHEMA diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/entity.py similarity index 99% rename from homeassistant/components/mqtt/mixins.py rename to homeassistant/components/mqtt/entity.py index b1c7c6edadb..633b22035dd 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/entity.py @@ -1,4 +1,4 @@ -"""MQTT component mixins and helpers.""" +"""MQTT (entity) component mixins and helpers.""" from __future__ import annotations @@ -369,7 +369,7 @@ def init_entity_id_from_config( ) -class MqttAttributesMixin(Entity): # pylint: disable=hass-enforce-class-module +class MqttAttributesMixin(Entity): """Mixin used for platforms that support JSON attributes.""" _attributes_extra_blocked: frozenset[str] = frozenset() @@ -454,7 +454,7 @@ class MqttAttributesMixin(Entity): # pylint: disable=hass-enforce-class-module _LOGGER.warning("JSON result was not a dictionary") -class MqttAvailabilityMixin(Entity): # pylint: disable=hass-enforce-class-module +class MqttAvailabilityMixin(Entity): """Mixin used for platforms that report availability.""" def __init__(self, config: ConfigType) -> None: @@ -799,7 +799,7 @@ class MqttDiscoveryDeviceUpdateMixin(ABC): """Handle the cleanup of platform specific parts, extend to the platform.""" -class MqttDiscoveryUpdateMixin(Entity): # pylint: disable=hass-enforce-class-module +class MqttDiscoveryUpdateMixin(Entity): """Mixin used to handle updated discovery message for entity based platforms.""" def __init__( @@ -1021,7 +1021,7 @@ def device_info_from_specifications( return info -class MqttEntityDeviceInfo(Entity): # pylint: disable=hass-enforce-class-module +class MqttEntityDeviceInfo(Entity): """Mixin used for mqtt platforms that support the device registry.""" def __init__( diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 0dc267f80f9..3f67891ca5e 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -26,7 +26,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object from . import subscription from .config import MQTT_RO_SCHEMA from .const import CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON, PAYLOAD_NONE -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( DATA_MQTT, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index a22dba4ae93..70187ee9eb1 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -47,7 +47,7 @@ from .const import ( CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index d55c1d3cebf..304d293de79 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -49,7 +49,7 @@ from .const import ( CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 30fd102764d..6ecdee06489 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -25,7 +25,7 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_BASE_SCHEMA -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( DATA_MQTT, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index f4aa248929e..11afe4220c4 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -26,7 +26,7 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_RETAIN, DEFAULT_OPTIMISTIC, DEFAULT_RETAIN -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 04619b08e11..a1ba955181d 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, VolSchemaType -from ..mixins import async_setup_entity_entry_helper +from ..entity import async_setup_entity_entry_helper from .schema import CONF_SCHEMA, MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import ( DISCOVERY_SCHEMA_BASIC, diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 1a64b1eecb4..de6a9d4c126 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -51,7 +51,7 @@ from ..const import ( CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) -from ..mixins import MqttEntity +from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 58fde4a3800..89f338f6bab 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -65,7 +65,7 @@ from ..const import ( CONF_STATE_TOPIC, DOMAIN as MQTT_DOMAIN, ) -from ..mixins import MqttEntity +from ..entity import MqttEntity from ..models import ReceiveMessage from ..schemas import MQTT_ENTITY_COMMON_SCHEMA from ..util import valid_subscribe_topic diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index a1f4ea2e81a..c4f9cad44c5 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -38,7 +38,7 @@ import homeassistant.util.color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, PAYLOAD_NONE -from ..mixins import MqttEntity +from ..entity import MqttEntity from ..models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index c72dcd8dc21..e58d15b659d 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -34,7 +34,7 @@ from .const import ( CONF_STATE_OPENING, CONF_STATE_TOPIC, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 581660b6ecf..4a5ccc02774 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -15,7 +15,7 @@ from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_RETAIN -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index ce441a2de6e..895334f2e1e 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -39,7 +39,7 @@ from .const import ( CONF_PAYLOAD_RESET, CONF_STATE_TOPIC, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 994a77d3abb..dad596d9c4f 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -17,7 +17,7 @@ from homeassistant.helpers.typing import ConfigType from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 5f9c4a11c23..37d3287988f 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -26,7 +26,7 @@ from .const import ( CONF_OPTIONS, CONF_STATE_TOPIC, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index fc95807b8a5..5b7fbe34b76 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -40,7 +40,7 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA from .const import CONF_OPTIONS, CONF_STATE_TOPIC, PAYLOAD_NONE -from .mixins import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper +from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import check_state_too_long diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index e7cf9e270bd..1937b60fde0 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -46,7 +46,7 @@ from .const import ( PAYLOAD_EMPTY_JSON, PAYLOAD_NONE, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 510de7b40dc..a73c4fe53f8 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -34,7 +34,7 @@ from .const import ( CONF_STATE_TOPIC, PAYLOAD_NONE, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 031c620af4a..680f252fb20 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -20,7 +20,7 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import ATTR_DISCOVERY_HASH, CONF_QOS, CONF_TOPIC from .discovery import MQTTDiscoveryPayload -from .mixins import ( +from .entity import ( MqttDiscoveryDeviceUpdateMixin, async_handle_schema_error, async_setup_non_entity_entry_helper, diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index 0db711cc456..edfecfbc038 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -28,7 +28,7 @@ from homeassistant.helpers.typing import ConfigType, VolSchemaType from . import subscription from .config import MQTT_RW_SCHEMA from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_STATE_TOPIC -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 4b87e0ef7da..f7bb9f75dd1 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -25,7 +25,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import subscription from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 87d6c9dd744..86b32aa281b 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -34,7 +34,7 @@ from homeassistant.util.json import json_loads_object from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 02127dfc19c..05c8ad833a0 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -59,7 +59,7 @@ from .const import ( DEFAULT_RETAIN, PAYLOAD_NONE, ) -from .mixins import MqttEntity, async_setup_entity_entry_helper +from .entity import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 13b0478210f..b98d73e0bfe 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -65,7 +65,7 @@ from .const import ( DEFAULT_OPTIMISTIC, PAYLOAD_NONE, ) -from .mixins import async_setup_entity_entry_helper +from .entity import async_setup_entity_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index c135c29ebc5..b89baf06254 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -20,7 +20,7 @@ from homeassistant.components.mqtt.const import ( MQTT_CONNECTION_STATE, SUPPORTED_COMPONENTS, ) -from homeassistant.components.mqtt.mixins import MQTT_ATTRIBUTES_BLOCKED +from homeassistant.components.mqtt.entity import MQTT_ATTRIBUTES_BLOCKED from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -1938,7 +1938,7 @@ async def help_test_skipped_async_ha_write_state( ) -> None: """Test entity.async_ha_write_state is only called on changes.""" with patch( - "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + "homeassistant.components.mqtt.entity.MqttEntity.async_write_ha_state" ) as mock_async_ha_write_state: assert len(mock_async_ha_write_state.mock_calls) == 0 async_fire_mqtt_message(hass, topic, payload1) diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index 3d4847a406a..ea46f514d3d 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -90,7 +90,7 @@ async def test_multiple_events_are_all_updating_the_state( """Test all events are respected and trigger a state write.""" await mqtt_mock_entry() with patch( - "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + "homeassistant.components.mqtt.entity.MqttEntity.async_write_ha_state" ) as mock_async_ha_write_state: async_fire_mqtt_message( hass, "test-topic", '{"event_type": "press", "duration": "short" }' @@ -109,7 +109,7 @@ async def test_handling_retained_event_payloads( """Test if event messages with a retained flag are ignored.""" await mqtt_mock_entry() with patch( - "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + "homeassistant.components.mqtt.entity.MqttEntity.async_write_ha_state" ) as mock_async_ha_write_state: async_fire_mqtt_message( hass, @@ -752,7 +752,7 @@ async def test_skipped_async_ha_write_state2( payload1 = '{"event_type": "press"}' payload2 = '{"event_type": "unknown"}' with patch( - "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + "homeassistant.components.mqtt.entity.MqttEntity.async_write_ha_state" ) as mock_async_ha_write_state: assert len(mock_async_ha_write_state.mock_calls) == 0 async_fire_mqtt_message(hass, topic, payload1) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 8f7f7ed6289..562e74bfd1d 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1894,7 +1894,7 @@ async def test_disabling_and_enabling_entry( config_light = '{"name": "test_new", "command_topic": "test-topic_new"}' with patch( - "homeassistant.components.mqtt.mixins.mqtt_config_entry_enabled", + "homeassistant.components.mqtt.entity.mqtt_config_entry_enabled", return_value=False, ): # Discovery of mqtt tag From bafc42c8f1e26d4584d061440897b47b6f6549e3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Sep 2024 21:29:14 +0200 Subject: [PATCH 0843/1309] Cleanup unused protocol class for mqtt entity setup (#126276) --- homeassistant/components/mqtt/entity.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 633b22035dd..5845dae12e2 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -143,20 +143,6 @@ MQTT_ATTRIBUTES_BLOCKED = { } -class SetupEntity(Protocol): - """Protocol type for async_setup_entities.""" - - async def __call__( - self, - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, - ) -> None: - """Define setup_entities type.""" - - @callback def async_handle_schema_error( discovery_payload: MQTTDiscoveryPayload, err: vol.Invalid From 3d43c224856823a7bd426b95c402a4e408165114 Mon Sep 17 00:00:00 2001 From: Alberto Montes Date: Thu, 19 Sep 2024 22:16:40 +0200 Subject: [PATCH 0844/1309] Update tooling configuration to enforce f-string formatting (#125989) * Update tooling configuration to enforce f-string formatting * Disable the rule on Pylint as it is handled by ruff --- pyproject.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5fdbb91e434..bddb709ca03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,7 +151,6 @@ class-const-naming-style = "any" # inconsistent-return-statements - doesn't handle raise # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this -# consider-using-f-string - str.format sometimes more readable # possibly-used-before-assignment - too many errors / not necessarily issues # --- # Pylint CodeStyle plugin @@ -174,7 +173,6 @@ disable = [ "too-many-public-methods", "too-many-boolean-expressions", "wrong-import-order", - "consider-using-f-string", "consider-using-namedtuple-or-dataclass", "consider-using-assignment-expr", "possibly-used-before-assignment", @@ -316,6 +314,7 @@ disable = [ "broad-except", # BLE001 "protected-access", # SLF001 "broad-exception-raised", # TRY002 + "consider-using-f-string", # PLC0209 # "no-self-use", # PLR6301 # Optional plugin, not enabled # Handled by mypy @@ -721,6 +720,7 @@ select = [ "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts) "E", # pycodestyle "F", # pyflakes/autoflake + "F541", # f-string without any placeholders "FLY", # flynt "FURB", # refurb "G", # flake8-logging-format @@ -776,6 +776,8 @@ select = [ "TID251", # Banned imports "TRY", # tryceratops "UP", # pyupgrade + "UP031", # Use format specifiers instead of percent format + "UP032", # Use f-string instead of `format` call "W", # pycodestyle ] From 72065768f334243e9e72bdabe1eb0c0ff6fd606e Mon Sep 17 00:00:00 2001 From: Marc-Philip Date: Fri, 20 Sep 2024 00:36:31 +0200 Subject: [PATCH 0845/1309] Allow github requirements specs in hassfest for non-core integrations (#124925) * allow all requirements specs * remove unnecessary tests * Revert "remove unnecessary tests" This reverts commit 0a2af0318d59f2a7edbd9496ab12bd5a56f5afaa. * Revert "allow all requirements specs" This reverts commit d15cd27f7b7c95b176a3eccb747b6ebff8acffda. * be lenient only for custom integrations * don't allow blanks as requested --------- Co-authored-by: Martin Hjelmare --- script/hassfest/requirements.py | 25 +++++++++++++------------ tests/hassfest/test_requirements.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index d35d96121c5..3df25f3284a 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -84,18 +84,19 @@ def validate_requirements_format(integration: Integration) -> bool: if not version: continue - for part in version.split(";", 1)[0].split(","): - version_part = PIP_VERSION_RANGE_SEPARATOR.match(part) - if ( - version_part - and AwesomeVersion(version_part.group(2)).strategy - == AwesomeVersionStrategy.UNKNOWN - ): - integration.add_error( - "requirements", - f"Unable to parse package version ({version}) for {pkg}.", - ) - continue + if integration.core: + for part in version.split(";", 1)[0].split(","): + version_part = PIP_VERSION_RANGE_SEPARATOR.match(part) + if ( + version_part + and AwesomeVersion(version_part.group(2)).strategy + == AwesomeVersionStrategy.UNKNOWN + ): + integration.add_error( + "requirements", + f"Unable to parse package version ({version}) for {pkg}.", + ) + continue return len(integration.errors) == start_errors diff --git a/tests/hassfest/test_requirements.py b/tests/hassfest/test_requirements.py index 433e63d904c..e70bee104c9 100644 --- a/tests/hassfest/test_requirements.py +++ b/tests/hassfest/test_requirements.py @@ -87,3 +87,22 @@ def test_validate_requirements_format_successful(integration: Integration) -> No ] assert validate_requirements_format(integration) assert len(integration.errors) == 0 + + +def test_validate_requirements_format_github_core(integration: Integration) -> None: + """Test requirement that points to github fails with core component.""" + integration.manifest["requirements"] = [ + "git+https://github.com/user/project.git@1.2.3", + ] + assert not validate_requirements_format(integration) + assert len(integration.errors) == 1 + + +def test_validate_requirements_format_github_custom(integration: Integration) -> None: + """Test requirement that points to github succeeds with custom component.""" + integration.manifest["requirements"] = [ + "git+https://github.com/user/project.git@1.2.3", + ] + integration.path = Path("") + assert validate_requirements_format(integration) + assert len(integration.errors) == 0 From bb5640b41b414064907046033321e9ae1b5a6bbd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Sep 2024 03:43:21 +0200 Subject: [PATCH 0846/1309] Simplify imports in recorder (#126248) --- .../components/recorder/history/__init__.py | 12 ++++++------ .../components/recorder/history/legacy.py | 4 ++-- .../components/recorder/history/modern.py | 14 +++++++------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py index de7002eb6a4..a28027adb1a 100644 --- a/homeassistant/components/recorder/history/__init__.py +++ b/homeassistant/components/recorder/history/__init__.py @@ -8,8 +8,8 @@ from typing import Any from sqlalchemy.orm.session import Session from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.recorder import get_instance -from ... import recorder from ..filters import Filters from .const import NEED_ATTRIBUTE_DOMAINS, SIGNIFICANT_DOMAINS from .modern import ( @@ -44,7 +44,7 @@ def get_full_significant_states_with_session( no_attributes: bool = False, ) -> dict[str, list[State]]: """Return a dict of significant states during a time period.""" - if not recorder.get_instance(hass).states_meta_manager.active: + if not get_instance(hass).states_meta_manager.active: from .legacy import ( # pylint: disable=import-outside-toplevel get_full_significant_states_with_session as _legacy_get_full_significant_states_with_session, ) @@ -69,7 +69,7 @@ def get_last_state_changes( hass: HomeAssistant, number_of_states: int, entity_id: str ) -> dict[str, list[State]]: """Return the last number_of_states.""" - if not recorder.get_instance(hass).states_meta_manager.active: + if not get_instance(hass).states_meta_manager.active: from .legacy import ( # pylint: disable=import-outside-toplevel get_last_state_changes as _legacy_get_last_state_changes, ) @@ -93,7 +93,7 @@ def get_significant_states( compressed_state_format: bool = False, ) -> dict[str, list[State | dict[str, Any]]]: """Return a dict of significant states during a time period.""" - if not recorder.get_instance(hass).states_meta_manager.active: + if not get_instance(hass).states_meta_manager.active: from .legacy import ( # pylint: disable=import-outside-toplevel get_significant_states as _legacy_get_significant_states, ) @@ -129,7 +129,7 @@ def get_significant_states_with_session( compressed_state_format: bool = False, ) -> dict[str, list[State | dict[str, Any]]]: """Return a dict of significant states during a time period.""" - if not recorder.get_instance(hass).states_meta_manager.active: + if not get_instance(hass).states_meta_manager.active: from .legacy import ( # pylint: disable=import-outside-toplevel get_significant_states_with_session as _legacy_get_significant_states_with_session, ) @@ -163,7 +163,7 @@ def state_changes_during_period( include_start_time_state: bool = True, ) -> dict[str, list[State]]: """Return a list of states that changed during a time period.""" - if not recorder.get_instance(hass).states_meta_manager.active: + if not get_instance(hass).states_meta_manager.active: from .legacy import ( # pylint: disable=import-outside-toplevel state_changes_during_period as _legacy_state_changes_during_period, ) diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 2b84309f0b9..b59fc43c3d0 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -19,9 +19,9 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id +from homeassistant.helpers.recorder import get_instance import homeassistant.util.dt as dt_util -from ... import recorder from ..db_schema import RecorderRuns, StateAttributes, States from ..filters import Filters from ..models import process_timestamp, process_timestamp_to_utc_isoformat @@ -496,7 +496,7 @@ def _get_rows_with_session( ) if run is None: - run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) + run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time) if run is None or process_timestamp(run.start) > utc_point_in_time: # History did not run before utc_point_in_time diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 3cbec60e83f..b44bec0d0ee 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -24,9 +24,9 @@ from sqlalchemy.orm.session import Session from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE from homeassistant.core import HomeAssistant, State, split_entity_id +from homeassistant.helpers.recorder import get_instance import homeassistant.util.dt as dt_util -from ... import recorder from ..const import LAST_REPORTED_SCHEMA_VERSION from ..db_schema import SHARED_ATTR_OR_LEGACY_ATTRIBUTES, StateAttributes, States from ..filters import Filters @@ -231,7 +231,7 @@ def get_significant_states_with_session( raise ValueError("entity_ids must be provided") entity_id_to_metadata_id: dict[str, int | None] | None = None metadata_ids_in_significant_domains: list[int] = [] - instance = recorder.get_instance(hass) + instance = get_instance(hass) if not ( entity_id_to_metadata_id := instance.states_meta_manager.get_many( entity_ids, session, False @@ -393,14 +393,14 @@ def state_changes_during_period( ) -> dict[str, list[State]]: """Return states changes during UTC period start_time - end_time.""" has_last_reported = ( - recorder.get_instance(hass).schema_version >= LAST_REPORTED_SCHEMA_VERSION + get_instance(hass).schema_version >= LAST_REPORTED_SCHEMA_VERSION ) if not entity_id: raise ValueError("entity_id must be provided") entity_ids = [entity_id.lower()] with session_scope(hass=hass, read_only=True) as session: - instance = recorder.get_instance(hass) + instance = get_instance(hass) if not ( possible_metadata_id := instance.states_meta_manager.get( entity_id, session, False @@ -507,7 +507,7 @@ def get_last_state_changes( ) -> dict[str, list[State]]: """Return the last number_of_states.""" has_last_reported = ( - recorder.get_instance(hass).schema_version >= LAST_REPORTED_SCHEMA_VERSION + get_instance(hass).schema_version >= LAST_REPORTED_SCHEMA_VERSION ) entity_id_lower = entity_id.lower() entity_ids = [entity_id_lower] @@ -517,7 +517,7 @@ def get_last_state_changes( # because the metadata_id_last_updated_ts index is in ascending order. with session_scope(hass=hass, read_only=True) as session: - instance = recorder.get_instance(hass) + instance = get_instance(hass) if not ( possible_metadata_id := instance.states_meta_manager.get( entity_id, session, False @@ -604,7 +604,7 @@ def _get_run_start_ts_for_utc_point_in_time( hass: HomeAssistant, utc_point_in_time: datetime ) -> float | None: """Return the start time of a run.""" - run = recorder.get_instance(hass).recorder_runs_manager.get(utc_point_in_time) + run = get_instance(hass).recorder_runs_manager.get(utc_point_in_time) if ( run is not None and (run_start := process_timestamp(run.start)) < utc_point_in_time From df0195bfe8879a457a477364737f02ed94e94033 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Sep 2024 09:40:37 +0200 Subject: [PATCH 0847/1309] Bump github/codeql-action from 3.26.7 to 3.26.8 (#126302) --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index dbc2dbf5963..3568ad8bc7a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.1.7 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.26.7 + uses: github/codeql-action/init@v3.26.8 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.26.7 + uses: github/codeql-action/analyze@v3.26.8 with: category: "/language:python" From dccdb71b2d07c515321f29ec5fbc395586ec131e Mon Sep 17 00:00:00 2001 From: Ian Date: Fri, 20 Sep 2024 01:18:13 -0700 Subject: [PATCH 0848/1309] Make NextBus coordinator more resilient and efficient (#126161) * Make NextBus coordinator more resilient and efficient Resolves issues where one request failing will prevent all agency predictions to fail. This also removes redundant requests for predictions that share the same stop. * Add unload entry test * Prevent shutdown if the coordinator is still needed --- homeassistant/components/nextbus/__init__.py | 25 +++-- .../components/nextbus/coordinator.py | 54 +++++++-- homeassistant/components/nextbus/sensor.py | 4 +- tests/components/nextbus/conftest.py | 86 +++++++++------ tests/components/nextbus/test_sensor.py | 103 +++++++++++++++++- 5 files changed, 212 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index e8c0bc224fe..817990620fe 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -13,15 +13,19 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up platforms for NextBus.""" entry_agency = entry.data[CONF_AGENCY] + entry_stop = entry.data[CONF_STOP] + coordinator_key = f"{entry_agency}-{entry_stop}" - coordinator: NextBusDataUpdateCoordinator = hass.data.setdefault(DOMAIN, {}).get( - entry_agency + coordinator: NextBusDataUpdateCoordinator | None = hass.data.setdefault( + DOMAIN, {} + ).get( + coordinator_key, ) if coordinator is None: coordinator = NextBusDataUpdateCoordinator(hass, entry_agency) - hass.data[DOMAIN][entry_agency] = coordinator + hass.data[DOMAIN][coordinator_key] = coordinator - coordinator.add_stop_route(entry.data[CONF_STOP], entry.data[CONF_ROUTE]) + coordinator.add_stop_route(entry_stop, entry.data[CONF_ROUTE]) await coordinator.async_config_entry_first_refresh() @@ -33,11 +37,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - entry_agency = entry.data.get(CONF_AGENCY) - coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][entry_agency] - coordinator.remove_stop_route(entry.data[CONF_STOP], entry.data[CONF_ROUTE]) + entry_agency = entry.data[CONF_AGENCY] + entry_stop = entry.data[CONF_STOP] + coordinator_key = f"{entry_agency}-{entry_stop}" + + coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][coordinator_key] + coordinator.remove_stop_route(entry_stop, entry.data[CONF_ROUTE]) + if not coordinator.has_routes(): - hass.data[DOMAIN].pop(entry_agency) + await coordinator.async_shutdown() + hass.data[DOMAIN].pop(coordinator_key) return True diff --git a/homeassistant/components/nextbus/coordinator.py b/homeassistant/components/nextbus/coordinator.py index 781742e4c08..dcaafa9573b 100644 --- a/homeassistant/components/nextbus/coordinator.py +++ b/homeassistant/components/nextbus/coordinator.py @@ -48,27 +48,63 @@ class NextBusDataUpdateCoordinator(DataUpdateCoordinator): """Check if this coordinator is tracking any routes.""" return len(self._route_stops) > 0 + async def async_shutdown(self) -> None: + """If there are no more routes, cancel any scheduled call, and ignore new runs.""" + if self.has_routes(): + return + + await super().async_shutdown() + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from NextBus.""" - _route_stops = set(self._route_stops) - self.logger.debug("Updating data from API. Routes: %s", str(_route_stops)) + _stops_to_route_stops: dict[str, set[RouteStop]] = {} + for route_stop in self._route_stops: + _stops_to_route_stops.setdefault(route_stop.stop_id, set()).add(route_stop) + + self.logger.debug( + "Updating data from API. Routes: %s", str(_stops_to_route_stops) + ) def _update_data() -> dict: """Fetch data from NextBus.""" self.logger.debug("Updating data from API (executor)") predictions: dict[RouteStop, dict[str, Any]] = {} - for route_stop in _route_stops: - prediction_results: list[dict[str, Any]] = [] + + for stop_id, route_stops in _stops_to_route_stops.items(): + self.logger.debug("Updating data from API (executor) %s", stop_id) try: - prediction_results = self.client.predictions_for_stop( - route_stop.stop_id, route_stop.route_id + prediction_results = self.client.predictions_for_stop(stop_id) + except NextBusHTTPError as ex: + self.logger.error( + "Error updating %s (executor): %s %s", + str(stop_id), + ex, + getattr(ex, "response", None), ) - except (NextBusHTTPError, NextBusFormatError) as ex: + raise UpdateFailed("Failed updating nextbus data", ex) from ex + except NextBusFormatError as ex: raise UpdateFailed("Failed updating nextbus data", ex) from ex - if prediction_results: - predictions[route_stop] = prediction_results[0] + self.logger.debug( + "Prediction results for %s (executor): %s", + str(stop_id), + str(prediction_results), + ) + + for route_stop in route_stops: + for prediction_result in prediction_results: + if ( + prediction_result["stop"]["id"] == route_stop.stop_id + and prediction_result["route"]["id"] == route_stop.route_id + ): + predictions[route_stop] = prediction_result + break + else: + self.logger.warning( + "Prediction not found for %s (executor)", str(route_stop) + ) + self._predictions = predictions return predictions diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 8ef5323858f..554814fe2db 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -28,8 +28,10 @@ async def async_setup_entry( """Load values from configuration and initialize the platform.""" _LOGGER.debug(config.data) entry_agency = config.data[CONF_AGENCY] + entry_stop = config.data[CONF_STOP] + coordinator_key = f"{entry_agency}-{entry_stop}" - coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN].get(entry_agency) + coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN].get(coordinator_key) async_add_entities( ( diff --git a/tests/components/nextbus/conftest.py b/tests/components/nextbus/conftest.py index 231faccf907..03e62a811f4 100644 --- a/tests/components/nextbus/conftest.py +++ b/tests/components/nextbus/conftest.py @@ -41,7 +41,7 @@ import pytest def route_config_direction(request: pytest.FixtureRequest) -> Any: """Generate alternative directions values. - When only on edirection is returned, it is not returned as a list, but instead an object. + When only one direction is returned, it is not returned as a list, but instead an object. """ return request.param @@ -75,42 +75,56 @@ def mock_nextbus_lists( "hidden": False, "timestamp": "2024-06-23T03:06:58Z", }, + { + "id": "G", + "rev": 1057, + "title": "F Market & Wharves", + "description": "7am-10pm daily", + "color": "", + "textColor": "", + "hidden": False, + "timestamp": "2024-06-23T03:06:58Z", + }, ] - instance.route_details.return_value = { - "id": "F", - "rev": 1057, - "title": "F Market & Wharves", - "description": "7am-10pm daily", - "color": "", - "textColor": "", - "hidden": False, - "boundingBox": {}, - "stops": [ - { - "id": "5184", - "lat": 37.8071299, - "lon": -122.41732, - "name": "Jones St & Beach St", - "code": "15184", - "hidden": False, - "showDestinationSelector": True, - "directions": ["F_0_var1", "F_0_var0"], - }, - { - "id": "5651", - "lat": 37.8071299, - "lon": -122.41732, - "name": "Jones St & Beach St", - "code": "15651", - "hidden": False, - "showDestinationSelector": True, - "directions": ["F_0_var1", "F_0_var0"], - }, - ], - "directions": route_config_direction, - "paths": [], - "timestamp": "2024-06-23T03:06:58Z", - } + def route_details_side_effect(agency: str, route: str) -> dict: + route = route.upper() + return { + "id": route, + "rev": 1057, + "title": f"{route} Market & Wharves", + "description": "7am-10pm daily", + "color": "", + "textColor": "", + "hidden": False, + "boundingBox": {}, + "stops": [ + { + "id": "5184", + "lat": 37.8071299, + "lon": -122.41732, + "name": "Jones St & Beach St", + "code": "15184", + "hidden": False, + "showDestinationSelector": True, + "directions": ["F_0_var1", "F_0_var0"], + }, + { + "id": "5651", + "lat": 37.8071299, + "lon": -122.41732, + "name": "Jones St & Beach St", + "code": "15651", + "hidden": False, + "showDestinationSelector": True, + "directions": ["F_0_var1", "F_0_var0"], + }, + ], + "directions": route_config_direction, + "paths": [], + "timestamp": "2024-06-23T03:06:58Z", + } + + instance.route_details.side_effect = route_details_side_effect return instance diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index dd0346c3e7a..8b62ed453b2 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -5,6 +5,7 @@ from copy import deepcopy from unittest.mock import MagicMock, patch from urllib.error import HTTPError +from freezegun.api import FrozenDateTimeFactory from py_nextbus.client import NextBusFormatError, NextBusHTTPError import pytest @@ -16,16 +17,21 @@ from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed VALID_AGENCY = "sfmta-cis" VALID_ROUTE = "F" VALID_STOP = "5184" +VALID_COORDINATOR_KEY = f"{VALID_AGENCY}-{VALID_STOP}" VALID_AGENCY_TITLE = "San Francisco Muni" VALID_ROUTE_TITLE = "F-Market & Wharves" VALID_STOP_TITLE = "Market St & 7th St" SENSOR_ID = "sensor.san_francisco_muni_f_market_wharves_market_st_7th_st" +ROUTE_2 = "G" +ROUTE_TITLE_2 = "G-Market & Wharves" +SENSOR_ID_2 = "sensor.san_francisco_muni_g_market_wharves_market_st_7th_st" + PLATFORM_CONFIG = { sensor.DOMAIN: { "platform": DOMAIN, @@ -44,6 +50,14 @@ CONFIG_BASIC = { } } +CONFIG_BASIC_2 = { + DOMAIN: { + CONF_AGENCY: VALID_AGENCY, + CONF_ROUTE: ROUTE_2, + CONF_STOP: VALID_STOP, + } +} + BASIC_RESULTS = [ { "route": { @@ -60,7 +74,20 @@ BASIC_RESULTS = [ {"minutes": 3, "timestamp": 1553807373000}, {"minutes": 10, "timestamp": 1553807380000}, ], - } + }, + { + "route": { + "title": ROUTE_TITLE_2, + "id": ROUTE_2, + }, + "stop": { + "name": VALID_STOP_TITLE, + "id": VALID_STOP, + }, + "values": [ + {"minutes": 90, "timestamp": 1553807379000}, + ], + }, ] NO_UPCOMING = [ @@ -74,7 +101,18 @@ NO_UPCOMING = [ "id": VALID_STOP, }, "values": [], - } + }, + { + "route": { + "title": ROUTE_TITLE_2, + "id": ROUTE_2, + }, + "stop": { + "name": VALID_STOP_TITLE, + "id": VALID_STOP, + }, + "values": [], + }, ] @@ -100,13 +138,15 @@ async def assert_setup_sensor( hass: HomeAssistant, config: dict[str, dict[str, str]], expected_state=ConfigEntryState.LOADED, + route_title: str = VALID_ROUTE_TITLE, ) -> MockConfigEntry: """Set up the sensor and assert it's been created.""" + unique_id = f"{config[DOMAIN][CONF_AGENCY]}_{config[DOMAIN][CONF_ROUTE]}_{config[DOMAIN][CONF_STOP]}" config_entry = MockConfigEntry( domain=DOMAIN, data=config[DOMAIN], - title=f"{VALID_AGENCY_TITLE} {VALID_ROUTE_TITLE} {VALID_STOP_TITLE}", - unique_id=f"{VALID_AGENCY}_{VALID_ROUTE}_{VALID_STOP}", + title=f"{VALID_AGENCY_TITLE} {route_title} {VALID_STOP_TITLE}", + unique_id=unique_id, ) config_entry.add_to_hass(hass) @@ -153,7 +193,7 @@ async def test_prediction_exceptions( ) -> None: """Test that some coodinator exceptions raise UpdateFailed exceptions.""" await assert_setup_sensor(hass, CONFIG_BASIC) - coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][VALID_AGENCY] + coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][VALID_COORDINATOR_KEY] mock_nextbus_predictions.side_effect = client_exception with pytest.raises(UpdateFailed): await coordinator._async_update_data() @@ -205,3 +245,54 @@ async def test_verify_no_upcoming( assert state is not None assert state.attributes["upcoming"] == "No upcoming predictions" assert state.state == "unknown" + + +async def test_unload_entry( + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that the sensor can be unloaded.""" + config_entry1 = await assert_setup_sensor(hass, CONFIG_BASIC) + await assert_setup_sensor(hass, CONFIG_BASIC_2, route_title=ROUTE_TITLE_2) + + # Verify the first sensor + state = hass.states.get(SENSOR_ID) + assert state is not None + assert state.state == "2019-03-28T21:09:31+00:00" + assert state.attributes["agency"] == VALID_AGENCY + assert state.attributes["route"] == VALID_ROUTE_TITLE + assert state.attributes["stop"] == VALID_STOP_TITLE + assert state.attributes["upcoming"] == "1, 2, 3, 10" + + # Verify the second sensor + state = hass.states.get(SENSOR_ID_2) + assert state is not None + assert state.state == "2019-03-28T21:09:39+00:00" + assert state.attributes["agency"] == VALID_AGENCY + assert state.attributes["route"] == ROUTE_TITLE_2 + assert state.attributes["stop"] == VALID_STOP_TITLE + assert state.attributes["upcoming"] == "90" + + # Update mock to return new predictions + new_predictions = deepcopy(BASIC_RESULTS) + new_predictions[1]["values"] = [{"minutes": 5, "timestamp": 1553807375000}] + mock_nextbus_predictions.return_value = new_predictions + + # Unload config entry 1 + await hass.config_entries.async_unload(config_entry1.entry_id) + await hass.async_block_till_done() + assert config_entry1.state is ConfigEntryState.NOT_LOADED + + # Skip ahead in time + freezer.tick(120) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Check update for new predictions + state = hass.states.get(SENSOR_ID_2) + assert state is not None + assert state.attributes["upcoming"] == "5" + assert state.state == "2019-03-28T21:09:35+00:00" From 1f1ce672094e50a47abe130012953cd6b2245b1d Mon Sep 17 00:00:00 2001 From: vhkristof Date: Fri, 20 Sep 2024 10:18:47 +0200 Subject: [PATCH 0849/1309] Add service to set the AC schedule of renault vehicles (#125006) * Add service to set the AC schedule of renault vehicles * Remove executable permission * Applied review comments (use snapshot) * Rewrote examples to not use JSON --- homeassistant/components/renault/icons.json | 3 + .../components/renault/renault_vehicle.py | 12 + homeassistant/components/renault/services.py | 60 +++- .../components/renault/services.yaml | 99 ++++-- homeassistant/components/renault/strings.json | 16 +- .../fixtures/action.set_ac_schedules.json | 20 ++ .../renault/fixtures/hvac_settings.json | 41 +++ .../renault/snapshots/test_services.ambr | 297 ++++++++++++++++++ tests/components/renault/test_services.py | 98 +++++- 9 files changed, 618 insertions(+), 28 deletions(-) create mode 100644 tests/components/renault/fixtures/action.set_ac_schedules.json create mode 100644 tests/components/renault/fixtures/hvac_settings.json diff --git a/homeassistant/components/renault/icons.json b/homeassistant/components/renault/icons.json index 883725eb601..8b9c4885eaa 100644 --- a/homeassistant/components/renault/icons.json +++ b/homeassistant/components/renault/icons.json @@ -72,6 +72,9 @@ }, "charge_set_schedules": { "service": "mdi:calendar-clock" + }, + "ac_set_schedules": { + "service": "mdi:calendar-clock" } } } diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index b77442c8331..d8266d75319 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -167,6 +167,18 @@ class RenaultVehicleProxy: """Start vehicle ac.""" return await self._vehicle.set_ac_start(temperature, when) + @with_error_wrapping + async def get_hvac_settings(self) -> models.KamereonVehicleHvacSettingsData: + """Get vehicle hvac settings.""" + return await self._vehicle.get_hvac_settings() + + @with_error_wrapping + async def set_hvac_schedules( + self, schedules: list[models.HvacSchedule] + ) -> models.KamereonVehicleHvacScheduleActionData: + """Set vehicle hvac schedules.""" + return await self._vehicle.set_hvac_schedules(schedules) + @with_error_wrapping async def get_charging_settings(self) -> models.KamereonVehicleChargingSettingsData: """Get vehicle charging settings.""" diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index e02a0febdf2..4409d9f284b 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -66,10 +66,43 @@ SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( } ) +SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA = vol.Schema( + { + vol.Required("readyAtTime"): cv.string, + } +) + +SERVICE_AC_SET_SCHEDULE_SCHEMA = vol.Schema( + { + vol.Required("id"): cv.positive_int, + vol.Optional("activated"): cv.boolean, + vol.Optional("monday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("tuesday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("wednesday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("thursday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("friday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("saturday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + vol.Optional("sunday"): vol.Any(None, SERVICE_AC_SET_SCHEDULE_DAY_SCHEMA), + } +) +SERVICE_AC_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( + { + vol.Required(ATTR_SCHEDULES): vol.All( + cv.ensure_list, [SERVICE_AC_SET_SCHEDULE_SCHEMA] + ), + } +) + SERVICE_AC_CANCEL = "ac_cancel" SERVICE_AC_START = "ac_start" SERVICE_CHARGE_SET_SCHEDULES = "charge_set_schedules" -SERVICES = [SERVICE_AC_CANCEL, SERVICE_AC_START, SERVICE_CHARGE_SET_SCHEDULES] +SERVICE_AC_SET_SCHEDULES = "ac_set_schedules" +SERVICES = [ + SERVICE_AC_CANCEL, + SERVICE_AC_START, + SERVICE_CHARGE_SET_SCHEDULES, + SERVICE_AC_SET_SCHEDULES, +] def setup_services(hass: HomeAssistant) -> None: @@ -111,6 +144,25 @@ def setup_services(hass: HomeAssistant) -> None: "It may take some time before these changes are reflected in your vehicle" ) + async def ac_set_schedules(service_call: ServiceCall) -> None: + """Set A/C schedules.""" + schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] + proxy = get_vehicle_proxy(service_call.data) + hvac_schedules = await proxy.get_hvac_settings() + + for schedule in schedules: + hvac_schedules.update(schedule) + + if TYPE_CHECKING: + assert hvac_schedules.schedules is not None + LOGGER.debug("HVAC set schedules attempt: %s", schedules) + result = await proxy.set_hvac_schedules(hvac_schedules.schedules) + + LOGGER.debug("HVAC set schedules result: %s", result) + LOGGER.debug( + "It may take some time before these changes are reflected in your vehicle" + ) + def get_vehicle_proxy(service_call_data: Mapping) -> RenaultVehicleProxy: """Get vehicle from service_call data.""" device_registry = dr.async_get(hass) @@ -148,3 +200,9 @@ def setup_services(hass: HomeAssistant) -> None: charge_set_schedules, schema=SERVICE_CHARGE_SET_SCHEDULES_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_AC_SET_SCHEDULES, + ac_set_schedules, + schema=SERVICE_AC_SET_SCHEDULES_SCHEMA, + ) diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml index 2dc99833d5f..835a57bd9c1 100644 --- a/homeassistant/components/renault/services.yaml +++ b/homeassistant/components/renault/services.yaml @@ -27,6 +27,33 @@ ac_cancel: device: integration: renault +ac_set_schedules: + fields: + vehicle: + required: true + selector: + device: + integration: renault + schedules: + example: + - id: 1 + activated: false + - id: 2 + activated: true + monday: + readyAtTime: "T20:45Z" + sunday: + readyAtTime: "T20:45Z" + - id: 3 + activated: false + - id: 4 + activated: false + - id: 5 + activated: false + required: true + selector: + object: + charge_set_schedules: fields: vehicle: @@ -35,31 +62,53 @@ charge_set_schedules: device: integration: renault schedules: - example: >- - [ - { - 'id':1, - 'activated':true, - 'monday':{'startTime':'T12:00Z','duration':15}, - 'tuesday':{'startTime':'T12:00Z','duration':15}, - 'wednesday':{'startTime':'T12:00Z','duration':15}, - 'thursday':{'startTime':'T12:00Z','duration':15}, - 'friday':{'startTime':'T12:00Z','duration':15}, - 'saturday':{'startTime':'T12:00Z','duration':15}, - 'sunday':{'startTime':'T12:00Z','duration':15} - }, - { - 'id':2, - 'activated':false, - 'monday':{'startTime':'T12:00Z','duration':240}, - 'tuesday':{'startTime':'T12:00Z','duration':240}, - 'wednesday':{'startTime':'T12:00Z','duration':240}, - 'thursday':{'startTime':'T12:00Z','duration':240}, - 'friday':{'startTime':'T12:00Z','duration':240}, - 'saturday':{'startTime':'T12:00Z','duration':240}, - 'sunday':{'startTime':'T12:00Z','duration':240} - }, - ] + example: + - id: 1 + activated: true + monday: + startTime: "T12:00Z" + duration: 15 + tuesday: + startTime: "T12:00Z" + duration: 15 + wednesday: + startTime: "T12:00Z" + duration: 15 + thursday: + startTime: "T12:00Z" + duration: 15 + friday: + startTime: "T12:00Z" + duration: 15 + saturday: + startTime: "T12:00Z" + duration: 15 + sunday: + startTime: "T12:00Z" + duration: 15 + - id: 2 + activated: true + monday: + startTime: "T12:00Z" + duration: 240 + tuesday: + startTime: "T12:00Z" + duration: 240 + wednesday: + startTime: "T12:00Z" + duration: 240 + thursday: + startTime: "T12:00Z" + duration: 240 + friday: + startTime: "T12:00Z" + duration: 240 + saturday: + startTime: "T12:00Z" + duration: 240 + sunday: + startTime: "T12:00Z" + duration: 240 required: true selector: object: diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 54864387869..9cc34edb82f 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -175,7 +175,7 @@ }, "ac_cancel": { "name": "Cancel A/C", - "description": "Canceles A/C on vehicle.", + "description": "Cancels A/C on vehicle.", "fields": { "vehicle": { "name": "Vehicle", @@ -196,6 +196,20 @@ "description": "Schedule details." } } + }, + "ac_set_schedules": { + "name": "Update A/C schedule", + "description": "Updates A/C schedule on vehicle.", + "fields": { + "vehicle": { + "name": "Vehicle", + "description": "[%key:component::renault::services::ac_start::fields::vehicle::description%]" + }, + "schedules": { + "name": "Schedules", + "description": "[%key:component::renault::services::charge_set_schedules::fields::schedules::description%]" + } + } } } } diff --git a/tests/components/renault/fixtures/action.set_ac_schedules.json b/tests/components/renault/fixtures/action.set_ac_schedules.json new file mode 100644 index 00000000000..601c1f6cf2d --- /dev/null +++ b/tests/components/renault/fixtures/action.set_ac_schedules.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "HvacSchedule", + "id": "guid", + "attributes": { + "schedules": [ + { + "id": 1, + "activated": true, + "tuesday": { "readyAtTime": "T04:30Z" }, + "wednesday": { "readyAtTime": "T22:30Z" }, + "thursday": { "readyAtTime": "T22:00Z" }, + "friday": { "readyAtTime": "T23:30Z" }, + "saturday": { "readyAtTime": "T18:30Z" }, + "sunday": { "readyAtTime": "T12:45Z" } + } + ] + } + } +} diff --git a/tests/components/renault/fixtures/hvac_settings.json b/tests/components/renault/fixtures/hvac_settings.json new file mode 100644 index 00000000000..8dd37e56af4 --- /dev/null +++ b/tests/components/renault/fixtures/hvac_settings.json @@ -0,0 +1,41 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "dateTime": "2020-12-24T20:00:00.000Z", + "mode": "scheduled", + "schedules": [ + { + "id": 1, + "activated": false + }, + { + "id": 2, + "activated": true, + "wednesday": { "readyAtTime": "T15:15Z" }, + "friday": { "readyAtTime": "T15:15Z" } + }, + { + "id": 3, + "activated": false, + "monday": { "readyAtTime": "T23:30Z" }, + "tuesday": { "readyAtTime": "T23:30Z" }, + "wednesday": { "readyAtTime": "T23:30Z" }, + "thursday": { "readyAtTime": "T23:30Z" }, + "friday": { "readyAtTime": "T23:30Z" }, + "saturday": { "readyAtTime": "T23:30Z" }, + "sunday": { "readyAtTime": "T23:30Z" } + }, + { + "id": 4, + "activated": false + }, + { + "id": 5, + "activated": false + } + ] + } + } +} diff --git a/tests/components/renault/snapshots/test_services.ambr b/tests/components/renault/snapshots/test_services.ambr index df4269c7430..882b2ffbe34 100644 --- a/tests/components/renault/snapshots/test_services.ambr +++ b/tests/components/renault/snapshots/test_services.ambr @@ -1,4 +1,301 @@ # serializer version: 1 +# name: test_service_set_ac_schedule[zoe_40] + list([ + dict({ + 'activated': False, + 'friday': None, + 'id': 1, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 1, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': True, + 'friday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'readyAtTime': 'T15:15Z', + }), + 'id': 2, + 'monday': None, + 'raw_data': dict({ + 'activated': True, + 'friday': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'id': 2, + 'wednesday': dict({ + 'readyAtTime': 'T15:15Z', + }), + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'readyAtTime': 'T15:15Z', + }), + }), + dict({ + 'activated': False, + 'friday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'id': 3, + 'monday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'raw_data': dict({ + 'activated': False, + 'friday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'id': 3, + 'monday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'saturday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'sunday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'thursday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'wednesday': dict({ + 'readyAtTime': 'T23:30Z', + }), + }), + 'saturday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'sunday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'thursday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'wednesday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 4, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 4, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 5, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 5, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + ]) +# --- +# name: test_service_set_ac_schedule_multi[zoe_40] + list([ + dict({ + 'activated': False, + 'friday': None, + 'id': 1, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 1, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': True, + 'friday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'readyAtTime': 'T15:15Z', + }), + 'id': 2, + 'monday': None, + 'raw_data': dict({ + 'activated': True, + 'friday': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'id': 2, + 'wednesday': dict({ + 'readyAtTime': 'T15:15Z', + }), + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T15:15Z', + }), + 'readyAtTime': 'T15:15Z', + }), + }), + dict({ + 'activated': True, + 'friday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T12:00Z', + }), + 'readyAtTime': 'T12:00Z', + }), + 'id': 3, + 'monday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T12:00Z', + }), + 'readyAtTime': 'T12:00Z', + }), + 'raw_data': dict({ + 'activated': False, + 'friday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'id': 3, + 'monday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'saturday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'sunday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'thursday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'wednesday': dict({ + 'readyAtTime': 'T23:30Z', + }), + }), + 'saturday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T12:00Z', + }), + 'readyAtTime': 'T12:00Z', + }), + 'sunday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T12:00Z', + }), + 'readyAtTime': 'T12:00Z', + }), + 'thursday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T23:30Z', + }), + 'readyAtTime': 'T23:30Z', + }), + 'tuesday': dict({ + 'raw_data': dict({ + 'readyAtTime': 'T12:00Z', + }), + 'readyAtTime': 'T12:00Z', + }), + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 4, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 4, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + dict({ + 'activated': False, + 'friday': None, + 'id': 5, + 'monday': None, + 'raw_data': dict({ + 'activated': False, + 'id': 5, + }), + 'saturday': None, + 'sunday': None, + 'thursday': None, + 'tuesday': None, + 'wednesday': None, + }), + ]) +# --- # name: test_service_set_charge_schedule[zoe_40] list([ dict({ diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index aadeec60ebf..bdb233f4d97 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest from renault_api.exceptions import RenaultException from renault_api.kamereon import schemas -from renault_api.kamereon.models import ChargeSchedule +from renault_api.kamereon.models import ChargeSchedule, HvacSchedule from syrupy import SnapshotAssertion from homeassistant.components.renault.const import DOMAIN @@ -17,6 +17,7 @@ from homeassistant.components.renault.services import ( ATTR_VEHICLE, ATTR_WHEN, SERVICE_AC_CANCEL, + SERVICE_AC_SET_SCHEDULES, SERVICE_AC_START, SERVICE_CHARGE_SET_SCHEDULES, ) @@ -238,6 +239,101 @@ async def test_service_set_charge_schedule_multi( assert mock_call_data[1].thursday.duration == 15 +async def test_service_set_ac_schedule( + hass: HomeAssistant, config_entry: ConfigEntry, snapshot: SnapshotAssertion +) -> None: + """Test that service invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + schedules = {"id": 2} + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_SCHEDULES: schedules, + } + + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_settings", + return_value=schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture("renault/hvac_settings.json") + ).get_attributes(schemas.KamereonVehicleHvacSettingsDataSchema), + ), + patch( + "renault_api.renault_vehicle.RenaultVehicle.set_hvac_schedules", + return_value=( + schemas.KamereonVehicleHvacScheduleActionDataSchema.loads( + load_fixture("renault/action.set_ac_schedules.json") + ) + ), + ) as mock_action, + ): + await hass.services.async_call( + DOMAIN, SERVICE_AC_SET_SCHEDULES, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0] + assert mock_call_data == snapshot + + +async def test_service_set_ac_schedule_multi( + hass: HomeAssistant, config_entry: ConfigEntry, snapshot: SnapshotAssertion +) -> None: + """Test that service invokes renault_api with correct data.""" + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + schedules = [ + { + "id": 3, + "activated": True, + "monday": {"readyAtTime": "T12:00Z"}, + "tuesday": {"readyAtTime": "T12:00Z"}, + "wednesday": None, + "friday": {"readyAtTime": "T12:00Z"}, + "saturday": {"readyAtTime": "T12:00Z"}, + "sunday": {"readyAtTime": "T12:00Z"}, + }, + {"id": 4}, + ] + data = { + ATTR_VEHICLE: get_device_id(hass), + ATTR_SCHEDULES: schedules, + } + + with ( + patch( + "renault_api.renault_vehicle.RenaultVehicle.get_hvac_settings", + return_value=schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture("renault/hvac_settings.json") + ).get_attributes(schemas.KamereonVehicleHvacSettingsDataSchema), + ), + patch( + "renault_api.renault_vehicle.RenaultVehicle.set_hvac_schedules", + return_value=( + schemas.KamereonVehicleHvacScheduleActionDataSchema.loads( + load_fixture("renault/action.set_ac_schedules.json") + ) + ), + ) as mock_action, + ): + await hass.services.async_call( + DOMAIN, SERVICE_AC_SET_SCHEDULES, service_data=data, blocking=True + ) + assert len(mock_action.mock_calls) == 1 + mock_call_data: list[HvacSchedule] = mock_action.mock_calls[0][1][0] + assert mock_call_data == snapshot + + # Schedule is activated now + assert mock_call_data[2].activated is True + # Monday updated with new values + assert mock_call_data[2].monday.readyAtTime == "T12:00Z" + # Wednesday has original values cleared + assert mock_call_data[2].wednesday is None + # Thursday keeps original values + assert mock_call_data[2].thursday.readyAtTime == "T23:30Z" + + async def test_service_invalid_device_id( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: From 778729101a1fe2bb1cd99855f948251fba0ce4d2 Mon Sep 17 00:00:00 2001 From: TimL Date: Fri, 20 Sep 2024 18:21:10 +1000 Subject: [PATCH 0850/1309] Bump pysmlight to 0.1.1 (#126301) Bump pysmlight 0.1.1 --- 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 66d68b80ace..3f4a0c69b24 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.0"], + "requirements": ["pysmlight==0.1.1"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index a1e8c6aabca..953679030ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2241,7 +2241,7 @@ pysmarty2==0.10.1 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.0 +pysmlight==0.1.1 # homeassistant.components.snmp pysnmp==6.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb7533ca4a7..ca8b96c5af2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1795,7 +1795,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.1.0 +pysmlight==0.1.1 # homeassistant.components.snmp pysnmp==6.2.5 From efdb1073a149e0fb8a506d96be032b5404a34215 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 20 Sep 2024 09:45:22 +0100 Subject: [PATCH 0851/1309] Add in-home chime switch to ring (#126305) * Add in-home chime switch to ring * Fix accidental conftest change --- homeassistant/components/ring/icons.json | 6 +++ homeassistant/components/ring/strings.json | 3 ++ homeassistant/components/ring/switch.py | 16 ++++++- tests/components/ring/device_mocks.py | 16 +++++++ .../ring/snapshots/test_switch.ambr | 47 +++++++++++++++++++ tests/components/ring/test_switch.py | 27 +++++++---- 6 files changed, 105 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ring/icons.json b/homeassistant/components/ring/icons.json index 0798d910b7b..a5411e3e54f 100644 --- a/homeassistant/components/ring/icons.json +++ b/homeassistant/components/ring/icons.json @@ -49,6 +49,12 @@ "switch": { "siren": { "default": "mdi:alarm-bell" + }, + "in_home_chime": { + "default": "mdi:bell-ring-outline", + "state": { + "on": "mdi:bell-ring" + } } } } diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 201832b9465..1094b3abd42 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -109,6 +109,9 @@ "switch": { "siren": { "name": "[%key:component::siren::title%]" + }, + "in_home_chime": { + "name": "In-home chime" } } }, diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index f3a7d9a1252..79c049792db 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -5,7 +5,8 @@ from dataclasses import dataclass import logging from typing import Any, Generic, Self, cast -from ring_doorbell import RingCapability, RingStickUpCam +from ring_doorbell import RingCapability, RingDoorBell, RingStickUpCam +from ring_doorbell.const import DOORBELL_EXISTING_TYPE from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import Platform @@ -26,6 +27,8 @@ from .entity import ( _LOGGER = logging.getLogger(__name__) +IN_HOME_CHIME_IS_PRESENT = {v for k, v in DOORBELL_EXISTING_TYPE.items() if k != 2} + @dataclass(frozen=True, kw_only=True) class RingSwitchEntityDescription( @@ -54,6 +57,17 @@ SWITCHES: Sequence[RingSwitchEntityDescription[Any]] = ( new_platform=Platform.SIREN, breaks_in_ha_version="2025.4.0" ), ), + RingSwitchEntityDescription[RingDoorBell]( + key="in_home_chime", + translation_key="in_home_chime", + exists_fn=lambda device: device.family == "doorbots" + and device.existing_doorbell_type in IN_HOME_CHIME_IS_PRESENT, + is_on_fn=lambda device: device.existing_doorbell_type_enabled or False, + turn_on_fn=lambda device: device.async_set_existing_doorbell_type_enabled(True), + turn_off_fn=lambda device: device.async_set_existing_doorbell_type_enabled( + False + ), + ), ) diff --git a/tests/components/ring/device_mocks.py b/tests/components/ring/device_mocks.py index cdb93d9911d..99ee6cd11be 100644 --- a/tests/components/ring/device_mocks.py +++ b/tests/components/ring/device_mocks.py @@ -18,6 +18,7 @@ from ring_doorbell import ( RingOther, RingStickUpCam, ) +from ring_doorbell.const import DOORBELL_EXISTING_TYPE from homeassistant.components.ring.const import DOMAIN from homeassistant.util import dt as dt_util @@ -173,6 +174,21 @@ def _mocked_ring_device(device_dict, device_family, device_class, capabilities): ) ) + if device_family == "doorbots": + mock_device.configure_mock( + existing_doorbell_type=DOORBELL_EXISTING_TYPE[ + device_dict["settings"]["chime_settings"].get("type", 2) + ] + ) + mock_device.configure_mock( + existing_doorbell_type_enabled=device_dict["settings"][ + "chime_settings" + ].get("enable", False) + ) + mock_device.async_set_existing_doorbell_type_enabled.side_effect = ( + lambda i: mock_device.configure_mock(existing_doorbell_type_enabled=i) + ) + if device_family == "other": for prop in ("doorbell_volume", "mic_volume", "voice_volume"): mock_device.configure_mock( diff --git a/tests/components/ring/snapshots/test_switch.ambr b/tests/components/ring/snapshots/test_switch.ambr index 2d56cf3ad13..c45b36c430b 100644 --- a/tests/components/ring/snapshots/test_switch.ambr +++ b/tests/components/ring/snapshots/test_switch.ambr @@ -1,4 +1,51 @@ # serializer version: 1 +# name: test_states[switch.front_door_in_home_chime-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.front_door_in_home_chime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'In-home chime', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'in_home_chime', + 'unique_id': '987654-in_home_chime', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.front_door_in_home_chime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Door In-home chime', + }), + 'context': , + 'entity_id': 'switch.front_door_in_home_chime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.front_siren-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index c0d49ad2896..a29dbf72cde 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -103,35 +103,44 @@ async def test_siren_on_reports_correctly( assert state.attributes.get("friendly_name") == "Internal Siren" -async def test_siren_can_be_turned_on_and_off( - hass: HomeAssistant, mock_ring_client, create_deprecated_siren_entity +@pytest.mark.parametrize( + ("entity_id"), + [ + ("switch.front_siren"), + ("switch.front_door_in_home_chime"), + ], +) +async def test_switch_can_be_turned_on_and_off( + hass: HomeAssistant, + mock_ring_client, + create_deprecated_siren_entity, + entity_id, ) -> None: - """Tests the siren turns on correctly.""" + """Tests the switch turns on and off correctly.""" await setup_platform(hass, Platform.SWITCH) - state = hass.states.get("switch.front_siren") - assert state.state == STATE_OFF + assert hass.states.get(entity_id) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.front_siren"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.front_siren") + state = hass.states.get(entity_id) assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.front_siren"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) await hass.async_block_till_done() - state = hass.states.get("switch.front_siren") + state = hass.states.get(entity_id) assert state.state == STATE_OFF From 2062e49ae16d6aed555763a9fd2031e8bcc7984e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Sep 2024 11:10:27 +0200 Subject: [PATCH 0852/1309] Improve readability in hass_imports pylint plugin (#126252) * Improve readability in hass_imports pylint plugin * One more * docstring * docstring --- pylint/plugins/hass_imports.py | 145 ++++++++++++++++++++++++--------- 1 file changed, 105 insertions(+), 40 deletions(-) diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index f7713daabe8..eacabc5b700 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -548,6 +548,85 @@ class HassImportsFormatChecker(BaseChecker): if len(split_package) < node.level + 2: self.add_message("hass-absolute-import", node=node) + def _check_for_constant_alias( + self, + node: nodes.ImportFrom, + current_component: str | None, + imported_component: str, + ) -> bool: + """Check for hass-import-constant-alias.""" + if current_component == imported_component: + return True + + # Check for `from homeassistant.components.other import DOMAIN` + for name, alias in node.names: + if name == "DOMAIN" and (alias is None or alias == "DOMAIN"): + self.add_message( + "hass-import-constant-alias", + node=node, + args=( + "DOMAIN", + "DOMAIN", + f"{imported_component.upper()}_DOMAIN", + ), + ) + return False + + return True + + def _check_for_component_root_import( + self, + node: nodes.ImportFrom, + current_component: str | None, + imported_parts: list[str], + imported_component: str, + ) -> bool: + """Check for hass-component-root-import.""" + if ( + current_component == imported_component + or imported_component in _IGNORE_ROOT_IMPORT + ): + return True + + # Check for `from homeassistant.components.other.module import something` + if len(imported_parts) > 3: + self.add_message("hass-component-root-import", node=node) + return False + + # Check for `from homeassistant.components.other import const` + for name, _ in node.names: + if name == "const": + self.add_message("hass-component-root-import", node=node) + return False + + return True + + def _check_for_relative_import( + self, + current_package: str, + node: nodes.ImportFrom, + current_component: str | None, + ) -> bool: + """Check for hass-relative-import.""" + if node.modname == current_package or node.modname.startswith( + f"{current_package}." + ): + self.add_message("hass-relative-import", node=node) + return False + + for root in ("homeassistant", "tests"): + if current_package.startswith(f"{root}.components."): + if node.modname == f"{root}.components": + for name in node.names: + if name[0] == current_component: + self.add_message("hass-relative-import", node=node) + return False + elif node.modname.startswith(f"{root}.components.{current_component}."): + self.add_message("hass-relative-import", node=node) + return False + + return True + def visit_importfrom(self, node: nodes.ImportFrom) -> None: """Check for improper 'from _ import _' invocations.""" if not self.current_package: @@ -555,52 +634,36 @@ class HassImportsFormatChecker(BaseChecker): if node.level is not None: self._visit_importfrom_relative(self.current_package, node) return - if node.modname == self.current_package or node.modname.startswith( - f"{self.current_package}." - ): - self.add_message("hass-relative-import", node=node) - return + + # Cache current component + current_component: str | None = None for root in ("homeassistant", "tests"): if self.current_package.startswith(f"{root}.components."): current_component = self.current_package.split(".")[2] - if node.modname == f"{root}.components": - for name in node.names: - if name[0] == current_component: - self.add_message("hass-relative-import", node=node) - return - if node.modname.startswith(f"{root}.components.{current_component}."): - self.add_message("hass-relative-import", node=node) - return - if ( - node.modname.startswith("homeassistant.components.") - and (module_parts := node.modname.split(".")) - and (module_integration := module_parts[2]) - and module_integration not in _IGNORE_ROOT_IMPORT - and not ( - self.current_package.startswith("tests.components.") - and self.current_package.split(".")[2] == module_integration - ) + # Checks for hass-relative-import + if not self._check_for_relative_import( + self.current_package, node, current_component ): - if len(module_parts) > 3: - self.add_message("hass-component-root-import", node=node) - return - for name, alias in node.names: - if name == "const": - self.add_message("hass-component-root-import", node=node) - return - if name == "DOMAIN" and (alias is None or alias == "DOMAIN"): - self.add_message( - "hass-import-constant-alias", - node=node, - args=( - "DOMAIN", - "DOMAIN", - f"{node.modname.split(".")[2].upper()}_DOMAIN", - ), - ) - return + return + if node.modname.startswith("homeassistant.components."): + imported_parts = node.modname.split(".") + imported_component = imported_parts[2] + + # Checks for hass-component-root-import + if not self._check_for_component_root_import( + node, current_component, imported_parts, imported_component + ): + return + + # Checks for hass-import-constant-alias + if not self._check_for_constant_alias( + node, current_component, imported_component + ): + return + + # Checks for hass-deprecated-import if obsolete_imports := _OBSOLETE_IMPORT.get(node.modname): for name_tuple in node.names: for obsolete_import in obsolete_imports: @@ -610,6 +673,8 @@ class HassImportsFormatChecker(BaseChecker): node=node, args=(import_match.string, obsolete_import.reason), ) + + # Checks for hass-helper-namespace-import if namespace_alias := _FORCE_NAMESPACE_IMPORT.get(node.modname): for name in node.names: if name[0] in namespace_alias.names: From 87240bb96fc7b8356454f358f33dfd3a6b75f428 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 20 Sep 2024 11:16:58 +0200 Subject: [PATCH 0853/1309] Fix loading KNX UI entities with entity category set (#126290) * Fix loading KNX UI entities with entity category set * add test * docstring fixes * telegram order * Optionally ignore telegram sending order in tests because we can't know which platform initialises first --- homeassistant/components/knx/entity.py | 21 +++-- homeassistant/components/knx/light.py | 14 ++- homeassistant/components/knx/switch.py | 13 ++- tests/components/knx/README.md | 16 ++-- tests/components/knx/conftest.py | 85 ++++++++++++------- .../components/knx/fixtures/config_store.json | 21 ++++- tests/components/knx/test_device.py | 3 +- tests/components/knx/test_light.py | 27 +++++- 8 files changed, 134 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index c81a6ee06db..6574e5d5860 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -2,20 +2,23 @@ from __future__ import annotations -from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Any from xknx.devices import Device as XknxDevice +from homeassistant.const import CONF_ENTITY_CATEGORY, EntityCategory +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_registry import RegistryEntry +from .const import DOMAIN +from .storage.config_store import PlatformControllerBase +from .storage.const import CONF_DEVICE_INFO + if TYPE_CHECKING: from . import KNXModule -from .storage.config_store import PlatformControllerBase - class KnxUiEntityPlatformController(PlatformControllerBase): """Class to manage dynamic adding and reloading of UI entities.""" @@ -93,13 +96,19 @@ class KnxYamlEntity(_KnxEntityBase): self._device = device -class KnxUiEntity(_KnxEntityBase, ABC): +class KnxUiEntity(_KnxEntityBase): """Representation of a KNX UI entity.""" _attr_unique_id: str + _attr_has_entity_name = True - @abstractmethod def __init__( - self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] + self, knx_module: KNXModule, unique_id: str, entity_config: dict[str, Any] ) -> None: """Initialize the UI entity.""" + self._knx_module = knx_module + self._attr_unique_id = unique_id + if entity_category := entity_config.get(CONF_ENTITY_CATEGORY): + self._attr_entity_category = EntityCategory(entity_category) + if device_info := entity_config.get(CONF_DEVICE_INFO): + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index a73f568b2a9..ba1194220c2 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -20,7 +20,6 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -35,7 +34,6 @@ from .schema import LightSchema from .storage.const import ( CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, - CONF_DEVICE_INFO, CONF_DPT, CONF_ENTITY, CONF_GA_BLUE_BRIGHTNESS, @@ -554,21 +552,19 @@ class KnxYamlLight(_KnxLight, KnxYamlEntity): class KnxUiLight(_KnxLight, KnxUiEntity): """Representation of a KNX light.""" - _attr_has_entity_name = True _device: XknxLight def __init__( self, knx_module: KNXModule, unique_id: str, config: ConfigType ) -> None: """Initialize of KNX light.""" - self._knx_module = knx_module + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) self._device = _create_ui_light( knx_module.xknx, config[DOMAIN], config[CONF_ENTITY][CONF_NAME] ) self._attr_max_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MAX] self._attr_min_color_temp_kelvin: int = config[DOMAIN][CONF_COLOR_TEMP_MIN] - - self._attr_entity_category = config[CONF_ENTITY][CONF_ENTITY_CATEGORY] - self._attr_unique_id = unique_id - if device_info := config[CONF_ENTITY].get(CONF_DEVICE_INFO): - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 9390cbfea43..725468cd6a9 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -18,7 +18,6 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -38,7 +37,6 @@ from .const import ( from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .schema import SwitchSchema from .storage.const import ( - CONF_DEVICE_INFO, CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_STATE, @@ -133,14 +131,17 @@ class KnxYamlSwitch(_KnxSwitch, KnxYamlEntity): class KnxUiSwitch(_KnxSwitch, KnxUiEntity): """Representation of a KNX switch configured from UI.""" - _attr_has_entity_name = True _device: XknxSwitch def __init__( self, knx_module: KNXModule, unique_id: str, config: dict[str, Any] ) -> None: """Initialize KNX switch.""" - self._knx_module = knx_module + super().__init__( + knx_module=knx_module, + unique_id=unique_id, + entity_config=config[CONF_ENTITY], + ) self._device = XknxSwitch( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], @@ -153,7 +154,3 @@ class KnxUiSwitch(_KnxSwitch, KnxUiEntity): sync_state=config[DOMAIN][CONF_SYNC_STATE], invert=config[DOMAIN][CONF_INVERT], ) - self._attr_entity_category = config[CONF_ENTITY][CONF_ENTITY_CATEGORY] - self._attr_unique_id = unique_id - if device_info := config[CONF_ENTITY].get(CONF_DEVICE_INFO): - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_info)}) diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md index 8778feb2251..ef8398b3d17 100644 --- a/tests/components/knx/README.md +++ b/tests/components/knx/README.md @@ -18,22 +18,22 @@ async def test_something(hass, knx): ## Asserting outgoing telegrams -All outgoing telegrams are pushed to an assertion queue. Assert them in order they were sent. +All outgoing telegrams are appended to an assertion list. Assert them in order they were sent or pass `ignore_order=True` to the assertion method. - `knx.assert_no_telegram` - Asserts that no telegram was sent (assertion queue is empty). + Asserts that no telegram was sent (assertion list is empty). - `knx.assert_telegram_count(count: int)` Asserts that `count` telegrams were sent. -- `knx.assert_read(group_address: str, response: int | tuple[int, ...] | None = None)` +- `knx.assert_read(group_address: str, response: int | tuple[int, ...] | None = None, ignore_order: bool = False)` Asserts that a GroupValueRead telegram was sent to `group_address`. - The telegram will be removed from the assertion queue. + The telegram will be removed from the assertion list. Optionally inject incoming GroupValueResponse telegram after reception to clear the value reader waiting task. This can also be done manually with `knx.receive_response`. -- `knx.assert_response(group_address: str, payload: int | tuple[int, ...])` +- `knx.assert_response(group_address: str, payload: int | tuple[int, ...], ignore_order: bool = False)` Asserts that a GroupValueResponse telegram with `payload` was sent to `group_address`. - The telegram will be removed from the assertion queue. -- `knx.assert_write(group_address: str, payload: int | tuple[int, ...])` + The telegram will be removed from the assertion list. +- `knx.assert_write(group_address: str, payload: int | tuple[int, ...], ignore_order: bool = False)` Asserts that a GroupValueWrite telegram with `payload` was sent to `group_address`. - The telegram will be removed from the assertion queue. + The telegram will be removed from the assertion list. Change some states or call some services and assert outgoing telegrams. diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 19f2bc4d845..c0ec1dd9b9a 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -57,9 +57,9 @@ class KNXTestKit: self.hass: HomeAssistant = hass self.mock_config_entry: MockConfigEntry = mock_config_entry self.xknx: XKNX - # outgoing telegrams will be put in the Queue instead of sent to the interface + # outgoing telegrams will be put in the List instead of sent to the interface # telegrams to an InternalGroupAddress won't be queued here - self._outgoing_telegrams: asyncio.Queue = asyncio.Queue() + self._outgoing_telegrams: list[Telegram] = [] def assert_state(self, entity_id: str, state: str, **attributes) -> None: """Assert the state of an entity.""" @@ -76,7 +76,7 @@ class KNXTestKit: async def patch_xknx_start(): """Patch `xknx.start` for unittests.""" self.xknx.cemi_handler.send_telegram = AsyncMock( - side_effect=self._outgoing_telegrams.put + side_effect=self._outgoing_telegrams.append ) # after XKNX.__init__() to not overwrite it by the config entry again # before StateUpdater starts to avoid slow down of tests @@ -117,24 +117,22 @@ class KNXTestKit: ######################## def _list_remaining_telegrams(self) -> str: - """Return a string containing remaining outgoing telegrams in test Queue. One per line.""" - remaining_telegrams = [] - while not self._outgoing_telegrams.empty(): - remaining_telegrams.append(self._outgoing_telegrams.get_nowait()) - return "\n".join(map(str, remaining_telegrams)) + """Return a string containing remaining outgoing telegrams in test List.""" + return "\n".join(map(str, self._outgoing_telegrams)) async def assert_no_telegram(self) -> None: - """Assert if every telegram in test Queue was checked.""" + """Assert if every telegram in test List was checked.""" await self.hass.async_block_till_done() - assert self._outgoing_telegrams.empty(), ( - f"Found remaining unasserted Telegrams: {self._outgoing_telegrams.qsize()}\n" + remaining_telegram_count = len(self._outgoing_telegrams) + assert not remaining_telegram_count, ( + f"Found remaining unasserted Telegrams: {remaining_telegram_count}\n" f"{self._list_remaining_telegrams()}" ) async def assert_telegram_count(self, count: int) -> None: - """Assert outgoing telegram count in test Queue.""" + """Assert outgoing telegram count in test List.""" await self.hass.async_block_till_done() - actual_count = self._outgoing_telegrams.qsize() + actual_count = len(self._outgoing_telegrams) assert actual_count == count, ( f"Outgoing telegrams: {actual_count} - Expected: {count}\n" f"{self._list_remaining_telegrams()}" @@ -149,52 +147,79 @@ class KNXTestKit: group_address: str, payload: int | tuple[int, ...] | None, apci_type: type[APCI], + ignore_order: bool = False, ) -> None: - """Assert outgoing telegram. One by one in timely order.""" + """Assert outgoing telegram. Optionally in timely order.""" await self.xknx.telegrams.join() - try: - telegram = self._outgoing_telegrams.get_nowait() - except asyncio.QueueEmpty as err: + if not self._outgoing_telegrams: raise AssertionError( f"No Telegram found. Expected: {apci_type.__name__} -" f" {group_address} - {payload}" - ) from err + ) + _expected_ga = GroupAddress(group_address) + if ignore_order: + for telegram in self._outgoing_telegrams: + if ( + telegram.destination_address == _expected_ga + and isinstance(telegram.payload, apci_type) + and (payload is None or telegram.payload.value.value == payload) + ): + self._outgoing_telegrams.remove(telegram) + return + raise AssertionError( + f"Telegram not found. Expected: {apci_type.__name__} -" + f" {group_address} - {payload}" + f"\nUnasserted telegrams:\n{self._list_remaining_telegrams()}" + ) + + telegram = self._outgoing_telegrams.pop(0) assert isinstance( telegram.payload, apci_type ), f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" - assert ( - str(telegram.destination_address) == group_address + telegram.destination_address == _expected_ga ), f"Group address mismatch in {telegram} - Expected: {group_address}" - if payload is not None: assert ( telegram.payload.value.value == payload # type: ignore[attr-defined] ), f"Payload mismatch in {telegram} - Expected: {payload}" async def assert_read( - self, group_address: str, response: int | tuple[int, ...] | None = None + self, + group_address: str, + response: int | tuple[int, ...] | None = None, + ignore_order: bool = False, ) -> None: - """Assert outgoing GroupValueRead telegram. One by one in timely order. + """Assert outgoing GroupValueRead telegram. Optionally in timely order. Optionally inject incoming GroupValueResponse telegram after reception. """ - await self.assert_telegram(group_address, None, GroupValueRead) + await self.assert_telegram(group_address, None, GroupValueRead, ignore_order) if response is not None: await self.receive_response(group_address, response) async def assert_response( - self, group_address: str, payload: int | tuple[int, ...] + self, + group_address: str, + payload: int | tuple[int, ...], + ignore_order: bool = False, ) -> None: - """Assert outgoing GroupValueResponse telegram. One by one in timely order.""" - await self.assert_telegram(group_address, payload, GroupValueResponse) + """Assert outgoing GroupValueResponse telegram. Optionally in timely order.""" + await self.assert_telegram( + group_address, payload, GroupValueResponse, ignore_order + ) async def assert_write( - self, group_address: str, payload: int | tuple[int, ...] + self, + group_address: str, + payload: int | tuple[int, ...], + ignore_order: bool = False, ) -> None: - """Assert outgoing GroupValueWrite telegram. One by one in timely order.""" - await self.assert_telegram(group_address, payload, GroupValueWrite) + """Assert outgoing GroupValueWrite telegram. Optionally in timely order.""" + await self.assert_telegram( + group_address, payload, GroupValueWrite, ignore_order + ) #################### # Incoming telegrams diff --git a/tests/components/knx/fixtures/config_store.json b/tests/components/knx/fixtures/config_store.json index 971b692ade1..5eabcfa87f9 100644 --- a/tests/components/knx/fixtures/config_store.json +++ b/tests/components/knx/fixtures/config_store.json @@ -23,7 +23,26 @@ } } }, - "light": {} + "light": { + "knx_es_01J85ZKTFHSZNG4X9DYBE592TF": { + "entity": { + "name": "test", + "device_info": null, + "entity_category": "config" + }, + "knx": { + "color_temp_min": 2700, + "color_temp_max": 6000, + "_light_color_mode_schema": "default", + "ga_switch": { + "write": "1/1/21", + "state": "1/0/21", + "passive": [] + }, + "sync_state": true + } + } + } } } } diff --git a/tests/components/knx/test_device.py b/tests/components/knx/test_device.py index 330fd854a50..04ff02f0611 100644 --- a/tests/components/knx/test_device.py +++ b/tests/components/knx/test_device.py @@ -58,7 +58,8 @@ async def test_remove_device( await knx.setup_integration({}) client = await hass_ws_client(hass) - await knx.assert_read("1/0/45", response=True) + await knx.assert_read("1/0/21", response=True, ignore_order=True) # test light + await knx.assert_read("1/0/45", response=True, ignore_order=True) # test switch assert hass_storage[KNX_CONFIG_STORAGE_KEY]["data"]["entities"].get("switch") test_device = device_registry.async_get_device( diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index e2e4a673a0d..88f76a163d5 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -19,8 +19,9 @@ from homeassistant.components.light import ( ATTR_RGBW_COLOR, ColorMode, ) -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import KnxEntityGenerator from .conftest import KNXTestKit @@ -1159,7 +1160,7 @@ async def test_light_ui_create( knx: KNXTestKit, create_ui_entity: KnxEntityGenerator, ) -> None: - """Test creating a switch.""" + """Test creating a light.""" await knx.setup_integration({}) await create_ui_entity( platform=Platform.LIGHT, @@ -1192,7 +1193,7 @@ async def test_light_ui_color_temp( color_temp_mode: str, raw_ct: tuple[int, ...], ) -> None: - """Test creating a switch.""" + """Test creating a color-temp light.""" await knx.setup_integration({}) await create_ui_entity( platform=Platform.LIGHT, @@ -1218,3 +1219,23 @@ async def test_light_ui_color_temp( state = hass.states.get("light.test") assert state.state is STATE_ON assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == pytest.approx(4200, abs=1) + + +async def test_light_ui_load( + hass: HomeAssistant, + knx: KNXTestKit, + load_config_store: None, + entity_registry: er.EntityRegistry, +) -> None: + """Test loading a light from storage.""" + await knx.setup_integration({}) + + await knx.assert_read("1/0/21", response=True, ignore_order=True) + # unrelated switch in config store + await knx.assert_read("1/0/45", response=True, ignore_order=True) + + state = hass.states.get("light.test") + assert state.state is STATE_ON + + entity = entity_registry.async_get("light.test") + assert entity.entity_category is EntityCategory.CONFIG From d56a7217d96ea147a7b6648da9db59fe0202a8db Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Fri, 20 Sep 2024 05:19:41 -0400 Subject: [PATCH 0854/1309] Bump aiohasupervisor to 0.1.0b1 (#126282) --- 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 9d95ea66312..fe38fa78003 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.0b0"] + "requirements": ["aiohasupervisor==0.1.0b1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4ec00f00ab0..68820c9b318 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.0b0 +aiohasupervisor==0.1.0b1 aiohttp-fast-zlib==0.1.1 aiohttp==3.10.5 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index bddb709ca03..a7d772ea601 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.0b0", + "aiohasupervisor==0.1.0b1", "aiohttp==3.10.5", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", diff --git a/requirements.txt b/requirements.txt index bfe13e72a5c..b7f5c8c6ec2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # Home Assistant Core aiodns==3.2.0 -aiohasupervisor==0.1.0b0 +aiohasupervisor==0.1.0b1 aiohttp==3.10.5 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 diff --git a/requirements_all.txt b/requirements_all.txt index 953679030ee..dbffd37b40c 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.0b0 +aiohasupervisor==0.1.0b1 # homeassistant.components.homekit_controller aiohomekit==3.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca8b96c5af2..ae66a82dfbb 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.0b0 +aiohasupervisor==0.1.0b1 # homeassistant.components.homekit_controller aiohomekit==3.2.3 From 42f8d9d10f1a975af2b3ce0715d0afcd1a3da242 Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Fri, 20 Sep 2024 10:27:05 +0100 Subject: [PATCH 0855/1309] Add motion detection switch entity to ring (#126278) Add motion detection switch to ring --- homeassistant/components/ring/icons.json | 6 + homeassistant/components/ring/strings.json | 3 + homeassistant/components/ring/switch.py | 8 + tests/components/ring/device_mocks.py | 3 + .../ring/snapshots/test_switch.ambr | 141 ++++++++++++++++++ tests/components/ring/test_switch.py | 1 + 6 files changed, 162 insertions(+) diff --git a/homeassistant/components/ring/icons.json b/homeassistant/components/ring/icons.json index a5411e3e54f..de999a5ef37 100644 --- a/homeassistant/components/ring/icons.json +++ b/homeassistant/components/ring/icons.json @@ -55,6 +55,12 @@ "state": { "on": "mdi:bell-ring" } + }, + "motion_detection": { + "default": "mdi:motion-sensor-off", + "state": { + "on": "mdi:motion-sensor" + } } } } diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 1094b3abd42..da0a8af5324 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -112,6 +112,9 @@ }, "in_home_chime": { "name": "In-home chime" + }, + "motion_detection": { + "name": "Motion detection" } } }, diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 79c049792db..0ac31fec209 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -68,6 +68,14 @@ SWITCHES: Sequence[RingSwitchEntityDescription[Any]] = ( False ), ), + RingSwitchEntityDescription[RingDoorBell]( + key="motion_detection", + translation_key="motion_detection", + exists_fn=lambda device: device.has_capability(RingCapability.MOTION_DETECTION), + is_on_fn=lambda device: device.motion_detection, + turn_on_fn=lambda device: device.async_set_motion_detection(True), + turn_off_fn=lambda device: device.async_set_motion_detection(False), + ), ) diff --git a/tests/components/ring/device_mocks.py b/tests/components/ring/device_mocks.py index 99ee6cd11be..4c475c0be87 100644 --- a/tests/components/ring/device_mocks.py +++ b/tests/components/ring/device_mocks.py @@ -145,6 +145,9 @@ def _mocked_ring_device(device_dict, device_family, device_class, capabilities): mock_device.configure_mock( motion_detection=device_dict["settings"].get("motion_detection_enabled"), ) + mock_device.async_set_motion_detection.side_effect = ( + lambda i: mock_device.configure_mock(motion_detection=i) + ) if has_capability(RingCapability.LIGHT): mock_device.configure_mock(lights=device_dict.get("led_status")) diff --git a/tests/components/ring/snapshots/test_switch.ambr b/tests/components/ring/snapshots/test_switch.ambr index c45b36c430b..57c27cfedfa 100644 --- a/tests/components/ring/snapshots/test_switch.ambr +++ b/tests/components/ring/snapshots/test_switch.ambr @@ -46,6 +46,100 @@ 'state': 'on', }) # --- +# name: test_states[switch.front_door_motion_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.front_door_motion_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion detection', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion_detection', + 'unique_id': '987654-motion_detection', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.front_door_motion_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Door Motion detection', + }), + 'context': , + 'entity_id': 'switch.front_door_motion_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_states[switch.front_motion_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.front_motion_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion detection', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion_detection', + 'unique_id': '765432-motion_detection', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.front_motion_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Motion detection', + }), + 'context': , + 'entity_id': 'switch.front_motion_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_states[switch.front_siren-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -93,6 +187,53 @@ 'state': 'off', }) # --- +# name: test_states[switch.internal_motion_detection-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.internal_motion_detection', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion detection', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion_detection', + 'unique_id': '345678-motion_detection', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[switch.internal_motion_detection-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Internal Motion detection', + }), + 'context': , + 'entity_id': 'switch.internal_motion_detection', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_states[switch.internal_siren-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index a29dbf72cde..d18add827ec 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -108,6 +108,7 @@ async def test_siren_on_reports_correctly( [ ("switch.front_siren"), ("switch.front_door_in_home_chime"), + ("switch.front_motion_detection"), ], ) async def test_switch_can_be_turned_on_and_off( From 7a9da6dde1b4ef4a52534d2269f916d113209d16 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 20 Sep 2024 12:01:07 +0200 Subject: [PATCH 0856/1309] Add additional mower to Husqvarna Autmower tests (#126313) --- .../husqvarna_automower/fixtures/mower.json | 76 ++- .../snapshots/test_binary_sensor.ambr | 139 +++++ .../snapshots/test_button.ambr | 46 ++ .../snapshots/test_calendar.ambr | 22 +- .../snapshots/test_diagnostics.ambr | 15 +- .../snapshots/test_init.ambr | 2 +- .../snapshots/test_sensor.ambr | 570 ++++++++++++++++++ .../snapshots/test_switch.ambr | 46 ++ .../husqvarna_automower/test_calendar.py | 2 +- 9 files changed, 876 insertions(+), 42 deletions(-) diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 1927f4f281b..a2bab4b2f43 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -6,7 +6,7 @@ "attributes": { "system": { "name": "Test Mower 1", - "model": "450XH-TEST", + "model": "HUSQVARNA AUTOMOWER® 450XH", "serialNumber": 123 }, "battery": { @@ -78,17 +78,6 @@ "saturday": true, "sunday": false, "workAreaId": 654321 - }, - { - "start": 120, - "duration": 480, - "monday": true, - "tuesday": false, - "wednesday": false, - "thursday": true, - "friday": false, - "saturday": true, - "sunday": false } ] }, @@ -219,6 +208,69 @@ } } } + }, + { + "type": "mower", + "id": "1234", + "attributes": { + "system": { + "name": "Test Mower 2", + "model": "HUSQVARNA AUTOMOWER® Aspire R4", + "serialNumber": 123 + }, + "battery": { + "batteryPercent": 50 + }, + "capabilities": { + "canConfirmError": false, + "headlights": false, + "position": false, + "stayOutZones": false, + "workAreas": false + }, + "mower": { + "mode": "MAIN_AREA", + "activity": "PARKED_IN_CS", + "inactiveReason": "NONE", + "state": "RESTRICTED", + "errorCode": 0, + "errorCodeTimestamp": 0 + }, + "calendar": { + "tasks": [ + { + "start": 120, + "duration": 49, + "monday": true, + "tuesday": false, + "wednesday": false, + "thursday": false, + "friday": false, + "saturday": false, + "sunday": false + } + ] + }, + "planner": { + "nextStartTimestamp": 1685991600000, + "override": { + "action": "NOT_ACTIVE" + }, + "restrictedReason": "WEEK_SCHEDULE" + }, + "metadata": { + "connected": true, + "statusTimestamp": 1697669932683 + }, + "positions": [], + "settings": { + "cuttingHeight": null, + "headlight": { + "mode": null + } + }, + "statistics": {} + } } ] } diff --git a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr index aaa9c59679f..16d9452e847 100644 --- a/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_binary_sensor.ambr @@ -138,3 +138,142 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_mower_2_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_battery_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Test Mower 2 Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.test_mower_2_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_leaving_dock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_mower_2_leaving_dock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Leaving dock', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'leaving_dock', + 'unique_id': '1234_leaving_dock', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_leaving_dock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 2 Leaving dock', + }), + 'context': , + 'entity_id': 'binary_sensor.test_mower_2_leaving_dock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_returning_to_dock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_mower_2_returning_to_dock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Returning to dock', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'returning_to_dock', + 'unique_id': '1234_returning_to_dock', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_snapshot[binary_sensor.test_mower_2_returning_to_dock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 2 Returning to dock', + }), + 'context': , + 'entity_id': 'binary_sensor.test_mower_2_returning_to_dock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/husqvarna_automower/snapshots/test_button.ambr b/tests/components/husqvarna_automower/snapshots/test_button.ambr index fb73d14013f..2ce3aae3065 100644 --- a/tests/components/husqvarna_automower/snapshots/test_button.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_button.ambr @@ -91,3 +91,49 @@ 'state': 'unknown', }) # --- +# name: test_button_snapshot[button.test_mower_2_sync_clock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_mower_2_sync_clock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sync clock', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'sync_clock', + 'unique_id': '1234_sync_clock', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_snapshot[button.test_mower_2_sync_clock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 2 Sync clock', + }), + 'context': , + 'entity_id': 'button.test_mower_2_sync_clock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/husqvarna_automower/snapshots/test_calendar.ambr b/tests/components/husqvarna_automower/snapshots/test_calendar.ambr index 55cf5e72cb9..1924b9ad42e 100644 --- a/tests/components/husqvarna_automower/snapshots/test_calendar.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_calendar.ambr @@ -8,11 +8,6 @@ 'start': '2023-06-05T01:00:00+02:00', 'summary': 'Back lawn schedule 2', }), - dict({ - 'end': '2023-06-05T10:00:00+02:00', - 'start': '2023-06-05T02:00:00+02:00', - 'summary': 'Schedule 1', - }), dict({ 'end': '2023-06-06T00:00:00+02:00', 'start': '2023-06-05T19:00:00+02:00', @@ -53,11 +48,6 @@ 'start': '2023-06-08T01:00:00+02:00', 'summary': 'Back lawn schedule 2', }), - dict({ - 'end': '2023-06-08T10:00:00+02:00', - 'start': '2023-06-08T02:00:00+02:00', - 'summary': 'Schedule 1', - }), dict({ 'end': '2023-06-10T00:00:00+02:00', 'start': '2023-06-09T19:00:00+02:00', @@ -66,21 +56,25 @@ dict({ 'end': '2023-06-10T08:00:00+02:00', 'start': '2023-06-10T00:00:00+02:00', - 'summary': 'Front lawn schedule 2', + 'summary': 'Back lawn schedule 1', }), dict({ 'end': '2023-06-10T08:00:00+02:00', 'start': '2023-06-10T00:00:00+02:00', - 'summary': 'Back lawn schedule 1', + 'summary': 'Front lawn schedule 2', }), dict({ 'end': '2023-06-10T09:00:00+02:00', 'start': '2023-06-10T01:00:00+02:00', 'summary': 'Back lawn schedule 2', }), + ]), + }), + 'calendar.test_mower_2': dict({ + 'events': list([ dict({ - 'end': '2023-06-10T10:00:00+02:00', - 'start': '2023-06-10T02:00:00+02:00', + 'end': '2023-06-05T02:49:00+02:00', + 'start': '2023-06-05T02:00:00+02:00', 'summary': 'Schedule 1', }), ]), diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 76f6fc08039..5793fc3d50c 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -58,19 +58,6 @@ 'work_area_id': 654321, 'work_area_name': 'Back lawn', }), - dict({ - 'duration': 480, - 'friday': False, - 'monday': True, - 'saturday': True, - 'start': 120, - 'sunday': False, - 'thursday': True, - 'tuesday': False, - 'wednesday': False, - 'work_area_id': None, - 'work_area_name': None, - }), ]), }), 'capabilities': dict({ @@ -136,7 +123,7 @@ }), }), 'system': dict({ - 'model': '450XH-TEST', + 'model': 'HUSQVARNA AUTOMOWER® 450XH', 'name': 'Test Mower 1', 'serial_number': 123, }), diff --git a/tests/components/husqvarna_automower/snapshots/test_init.ambr b/tests/components/husqvarna_automower/snapshots/test_init.ambr index ccfb1bf3df4..adf70fb0aab 100644 --- a/tests/components/husqvarna_automower/snapshots/test_init.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_init.ambr @@ -20,7 +20,7 @@ 'labels': set({ }), 'manufacturer': 'Husqvarna', - 'model': '450XH-TEST', + 'model': 'HUSQVARNA AUTOMOWER® 450XH', 'model_id': None, 'name': 'Test Mower 1', 'name_by_user': None, diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index c260e6beba6..13f602b902c 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -1056,3 +1056,573 @@ 'state': 'Front lawn', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_2_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_2_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1234_battery_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_2_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Mower 2 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_mower_2_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_2_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_error', + 'alarm_mower_in_motion', + 'alarm_mower_lifted', + 'alarm_mower_stopped', + 'alarm_mower_switched_off', + 'alarm_mower_tilted', + 'alarm_outside_geofence', + 'angular_sensor_problem', + 'battery_problem', + 'battery_problem', + 'battery_restriction_due_to_ambient_temperature', + 'can_error', + 'charging_current_too_high', + 'charging_station_blocked', + 'charging_system_problem', + 'charging_system_problem', + 'collision_sensor_defect', + 'collision_sensor_error', + 'collision_sensor_problem_front', + 'collision_sensor_problem_rear', + 'com_board_not_available', + 'communication_circuit_board_sw_must_be_updated', + 'complex_working_area', + 'connection_changed', + 'connection_not_changed', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_settings_restored', + 'cutting_drive_motor_1_defect', + 'cutting_drive_motor_2_defect', + 'cutting_drive_motor_3_defect', + 'cutting_height_blocked', + 'cutting_height_problem', + 'cutting_height_problem_curr', + 'cutting_height_problem_dir', + 'cutting_height_problem_drive', + 'cutting_motor_problem', + 'cutting_stopped_slope_too_steep', + 'cutting_system_blocked', + 'cutting_system_blocked', + 'cutting_system_imbalance_warning', + 'cutting_system_major_imbalance', + 'destination_not_reachable', + 'difficult_finding_home', + 'docking_sensor_defect', + 'electronic_problem', + 'empty_battery', + 'folding_cutting_deck_sensor_defect', + 'folding_sensor_activated', + 'geofence_problem', + 'geofence_problem', + 'gps_navigation_problem', + 'guide_1_not_found', + 'guide_2_not_found', + 'guide_3_not_found', + 'guide_calibration_accomplished', + 'guide_calibration_failed', + 'high_charging_power_loss', + 'high_internal_power_loss', + 'high_internal_temperature', + 'internal_voltage_error', + 'invalid_battery_combination_invalid_combination_of_different_battery_types', + 'invalid_sub_device_combination', + 'invalid_system_configuration', + 'left_brush_motor_overloaded', + 'lift_sensor_defect', + 'lifted', + 'limited_cutting_height_range', + 'limited_cutting_height_range', + 'loop_sensor_defect', + 'loop_sensor_problem_front', + 'loop_sensor_problem_left', + 'loop_sensor_problem_rear', + 'loop_sensor_problem_right', + 'low_battery', + 'memory_circuit_problem', + 'mower_lifted', + 'mower_tilted', + 'no_accurate_position_from_satellites', + 'no_confirmed_position', + 'no_drive', + 'no_loop_signal', + 'no_power_in_charging_station', + 'no_response_from_charger', + 'outside_working_area', + 'poor_signal_quality', + 'reference_station_communication_problem', + 'right_brush_motor_overloaded', + 'safety_function_faulty', + 'settings_restored', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_not_found', + 'sim_card_requires_pin', + 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', + 'slope_too_steep', + 'sms_could_not_be_sent', + 'stop_button_problem', + 'stuck_in_charging_station', + 'switch_cord_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'tilt_sensor_problem', + 'too_high_discharge_current', + 'too_high_internal_current', + 'trapped', + 'ultrasonic_problem', + 'ultrasonic_sensor_1_defect', + 'ultrasonic_sensor_2_defect', + 'ultrasonic_sensor_3_defect', + 'ultrasonic_sensor_4_defect', + 'unexpected_cutting_height_adj', + 'unexpected_error', + 'upside_down', + 'weak_gps_signal', + 'wheel_drive_problem_left', + 'wheel_drive_problem_rear_left', + 'wheel_drive_problem_rear_right', + 'wheel_drive_problem_right', + 'wheel_motor_blocked_left', + 'wheel_motor_blocked_rear_left', + 'wheel_motor_blocked_rear_right', + 'wheel_motor_blocked_right', + 'wheel_motor_overloaded_left', + 'wheel_motor_overloaded_rear_left', + 'wheel_motor_overloaded_rear_right', + 'wheel_motor_overloaded_right', + 'work_area_not_valid', + 'wrong_loop_signal', + 'wrong_pin_code', + 'zone_generator_problem', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_2_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Error', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'error', + 'unique_id': '1234_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_2_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 2 Error', + 'options': list([ + 'no_error', + 'alarm_mower_in_motion', + 'alarm_mower_lifted', + 'alarm_mower_stopped', + 'alarm_mower_switched_off', + 'alarm_mower_tilted', + 'alarm_outside_geofence', + 'angular_sensor_problem', + 'battery_problem', + 'battery_problem', + 'battery_restriction_due_to_ambient_temperature', + 'can_error', + 'charging_current_too_high', + 'charging_station_blocked', + 'charging_system_problem', + 'charging_system_problem', + 'collision_sensor_defect', + 'collision_sensor_error', + 'collision_sensor_problem_front', + 'collision_sensor_problem_rear', + 'com_board_not_available', + 'communication_circuit_board_sw_must_be_updated', + 'complex_working_area', + 'connection_changed', + 'connection_not_changed', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_problem', + 'connectivity_settings_restored', + 'cutting_drive_motor_1_defect', + 'cutting_drive_motor_2_defect', + 'cutting_drive_motor_3_defect', + 'cutting_height_blocked', + 'cutting_height_problem', + 'cutting_height_problem_curr', + 'cutting_height_problem_dir', + 'cutting_height_problem_drive', + 'cutting_motor_problem', + 'cutting_stopped_slope_too_steep', + 'cutting_system_blocked', + 'cutting_system_blocked', + 'cutting_system_imbalance_warning', + 'cutting_system_major_imbalance', + 'destination_not_reachable', + 'difficult_finding_home', + 'docking_sensor_defect', + 'electronic_problem', + 'empty_battery', + 'folding_cutting_deck_sensor_defect', + 'folding_sensor_activated', + 'geofence_problem', + 'geofence_problem', + 'gps_navigation_problem', + 'guide_1_not_found', + 'guide_2_not_found', + 'guide_3_not_found', + 'guide_calibration_accomplished', + 'guide_calibration_failed', + 'high_charging_power_loss', + 'high_internal_power_loss', + 'high_internal_temperature', + 'internal_voltage_error', + 'invalid_battery_combination_invalid_combination_of_different_battery_types', + 'invalid_sub_device_combination', + 'invalid_system_configuration', + 'left_brush_motor_overloaded', + 'lift_sensor_defect', + 'lifted', + 'limited_cutting_height_range', + 'limited_cutting_height_range', + 'loop_sensor_defect', + 'loop_sensor_problem_front', + 'loop_sensor_problem_left', + 'loop_sensor_problem_rear', + 'loop_sensor_problem_right', + 'low_battery', + 'memory_circuit_problem', + 'mower_lifted', + 'mower_tilted', + 'no_accurate_position_from_satellites', + 'no_confirmed_position', + 'no_drive', + 'no_loop_signal', + 'no_power_in_charging_station', + 'no_response_from_charger', + 'outside_working_area', + 'poor_signal_quality', + 'reference_station_communication_problem', + 'right_brush_motor_overloaded', + 'safety_function_faulty', + 'settings_restored', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_locked', + 'sim_card_not_found', + 'sim_card_requires_pin', + 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', + 'slope_too_steep', + 'sms_could_not_be_sent', + 'stop_button_problem', + 'stuck_in_charging_station', + 'switch_cord_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'temporary_battery_problem', + 'tilt_sensor_problem', + 'too_high_discharge_current', + 'too_high_internal_current', + 'trapped', + 'ultrasonic_problem', + 'ultrasonic_sensor_1_defect', + 'ultrasonic_sensor_2_defect', + 'ultrasonic_sensor_3_defect', + 'ultrasonic_sensor_4_defect', + 'unexpected_cutting_height_adj', + 'unexpected_error', + 'upside_down', + 'weak_gps_signal', + 'wheel_drive_problem_left', + 'wheel_drive_problem_rear_left', + 'wheel_drive_problem_rear_right', + 'wheel_drive_problem_right', + 'wheel_motor_blocked_left', + 'wheel_motor_blocked_rear_left', + 'wheel_motor_blocked_rear_right', + 'wheel_motor_blocked_right', + 'wheel_motor_overloaded_left', + 'wheel_motor_overloaded_rear_left', + 'wheel_motor_overloaded_rear_right', + 'wheel_motor_overloaded_right', + 'work_area_not_valid', + 'wrong_loop_signal', + 'wrong_pin_code', + 'zone_generator_problem', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_2_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_error', + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_2_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'main_area', + 'demo', + 'secondary_area', + 'home', + 'unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_2_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Mode', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mode', + 'unique_id': '1234_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_2_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 2 Mode', + 'options': list([ + 'main_area', + 'demo', + 'secondary_area', + 'home', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_2_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'main_area', + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_2_next_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_2_next_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next start', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'next_start_timestamp', + 'unique_id': '1234_next_start_timestamp', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_2_next_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Mower 2 Next start', + }), + 'context': , + 'entity_id': 'sensor.test_mower_2_next_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-06-05T17:00:00+00:00', + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_2_restricted_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'all_work_areas_completed', + 'daily_limit', + 'external', + 'fota', + 'frost', + 'none', + 'not_applicable', + 'park_override', + 'sensor', + 'week_schedule', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_2_restricted_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restricted reason', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'restricted_reason', + 'unique_id': '1234_restricted_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_2_restricted_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 2 Restricted reason', + 'options': list([ + 'all_work_areas_completed', + 'daily_limit', + 'external', + 'fota', + 'frost', + 'none', + 'not_applicable', + 'park_override', + 'sensor', + 'week_schedule', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_2_restricted_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'week_schedule', + }) +# --- diff --git a/tests/components/husqvarna_automower/snapshots/test_switch.ambr b/tests/components/husqvarna_automower/snapshots/test_switch.ambr index f52462496ff..4bc851fa73d 100644 --- a/tests/components/husqvarna_automower/snapshots/test_switch.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_switch.ambr @@ -137,3 +137,49 @@ 'state': 'on', }) # --- +# name: test_switch_snapshot[switch.test_mower_2_enable_schedule-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_mower_2_enable_schedule', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Enable schedule', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'enable_schedule', + 'unique_id': '1234_enable_schedule', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_mower_2_enable_schedule-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 2 Enable schedule', + }), + 'context': , + 'entity_id': 'switch.test_mower_2_enable_schedule', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/husqvarna_automower/test_calendar.py b/tests/components/husqvarna_automower/test_calendar.py index 39c273145ee..0e914e272fb 100644 --- a/tests/components/husqvarna_automower/test_calendar.py +++ b/tests/components/husqvarna_automower/test_calendar.py @@ -138,7 +138,7 @@ async def test_calendar_snapshot( CALENDAR_DOMAIN, SERVICE_GET_EVENTS, { - ATTR_ENTITY_ID: "calendar.test_mower_1", + ATTR_ENTITY_ID: ["calendar.test_mower_1", "calendar.test_mower_2"], EVENT_START_DATETIME: start_date, EVENT_END_DATETIME: end_date, }, From 1768daf98cbfddbab17a9c4ed8d49491c8bdeb5f Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Fri, 20 Sep 2024 12:02:07 +0200 Subject: [PATCH 0857/1309] Add support for native oauth2 in Point (#118243) * initial oauth2 implementation * fix unload_entry * read old yaml/entry config * update tests * fix: pylint on tests * Apply suggestions from code review Co-authored-by: Robert Resch * fix constants, formatting * use runtime_data * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * fix missing import * adopt to PointData dataclass * fix typing * add more strings (copied from weheat) * move the PointData dataclass to avoid circular imports * use configflow inspired by withings * raise ConfigEntryAuthFailed * it is called entry_lock * fix webhook issue * fix oauth_create_entry * stop using async_forward_entry_setup * Fixup * fix strings * fix issue that old config might be without unique_id * parametrize tests * Update homeassistant/components/point/config_flow.py * Update tests/components/point/test_config_flow.py * Fix --------- Co-authored-by: Robert Resch Co-authored-by: Joost Lekkerkerker --- homeassistant/components/point/__init__.py | 167 ++++++---- .../components/point/alarm_control_panel.py | 2 +- homeassistant/components/point/api.py | 26 ++ .../point/application_credentials.py | 14 + .../components/point/binary_sensor.py | 2 +- homeassistant/components/point/config_flow.py | 228 +++----------- homeassistant/components/point/const.py | 4 + homeassistant/components/point/manifest.json | 4 +- homeassistant/components/point/sensor.py | 2 +- homeassistant/components/point/strings.json | 42 +-- .../generated/application_credentials.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/point/__init__.py | 11 + tests/components/point/test_config_flow.py | 293 ++++++++++-------- 15 files changed, 393 insertions(+), 407 deletions(-) create mode 100644 homeassistant/components/point/api.py create mode 100644 homeassistant/components/point/application_credentials.py diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index dc461f7200e..ca764a3844e 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -1,27 +1,34 @@ """Support for Minut Point.""" import asyncio +from dataclasses import dataclass +from http import HTTPStatus import logging -from aiohttp import web -from httpx import ConnectTimeout +from aiohttp import ClientError, ClientResponseError, web from pypoint import PointSession import voluptuous as vol -from homeassistant import config_entries from homeassistant.components import webhook -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, - CONF_TOKEN, CONF_WEBHOOK_ID, Platform, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + config_validation as cv, + device_registry as dr, +) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -29,10 +36,11 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp -from . import config_flow +from . import api from .const import ( CONF_WEBHOOK_URL, DOMAIN, @@ -45,11 +53,10 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -DATA_CONFIG_ENTRY_LOCK = "point_config_entry_lock" -CONFIG_ENTRY_IS_SETUP = "point_config_entry_is_setup" - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +type PointConfigEntry = ConfigEntry[PointData] + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -70,57 +77,80 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = config[DOMAIN] - config_flow.register_flow_implementation( - hass, DOMAIN, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET] + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Point", + }, ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + if not hass.config_entries.async_entries(DOMAIN): + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + ), + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) ) - ) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Point from a config entry.""" +async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: + """Set up Minut Point from a config entry.""" - async def token_saver(token, **kwargs): - _LOGGER.debug("Saving updated token %s", token) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_TOKEN: token} + if "auth_implementation" not in entry.data: + raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.") + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry ) - - session = PointSession( - async_get_clientsession(hass), - entry.data["refresh_args"][CONF_CLIENT_ID], - entry.data["refresh_args"][CONF_CLIENT_SECRET], - token=entry.data[CONF_TOKEN], - token_saver=token_saver, ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = api.AsyncConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), session + ) + try: - # the call to user() implicitly calls ensure_active_token() in authlib - await session.user() - except ConnectTimeout as err: - _LOGGER.debug("Connection Timeout") + await auth.async_get_access_token() + except ClientResponseError as err: + if err.status in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err + except ClientError as err: raise ConfigEntryNotReady from err - except Exception: # noqa: BLE001 - _LOGGER.error("Authentication Error") - return False - hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() - hass.data[CONFIG_ENTRY_IS_SETUP] = set() + point_session = PointSession(auth) - await async_setup_webhook(hass, entry, session) - client = MinutPointClient(hass, entry, session) - hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client}) + client = MinutPointClient(hass, entry, point_session) hass.async_create_task(client.update()) + entry.runtime_data = PointData(client) + + await async_setup_webhook(hass, entry, point_session) + # Entries are added in the client.update() function. return True -async def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry, session): +async def async_setup_webhook( + hass: HomeAssistant, entry: PointConfigEntry, session: PointSession +) -> None: """Set up a webhook to handle binary sensor events.""" if CONF_WEBHOOK_ID not in entry.data: webhook_id = webhook.async_generate_id() @@ -135,27 +165,26 @@ async def async_setup_webhook(hass: HomeAssistant, entry: ConfigEntry, session): CONF_WEBHOOK_URL: webhook_url, }, ) + await session.update_webhook( - entry.data[CONF_WEBHOOK_URL], + webhook.async_generate_url(hass, entry.data[CONF_WEBHOOK_ID]), entry.data[CONF_WEBHOOK_ID], ["*"], ) - webhook.async_register( hass, DOMAIN, "Point", entry.data[CONF_WEBHOOK_ID], handle_webhook ) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool: """Unload a config entry.""" - webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - session = hass.data[DOMAIN].pop(entry.entry_id) - await session.remove_webhook() - - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - + if unload_ok := await hass.config_entries.async_unload_platforms( + entry, [*PLATFORMS, Platform.ALARM_CONTROL_PANEL] + ): + session: PointSession = entry.runtime_data.client + if CONF_WEBHOOK_ID in entry.data: + webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + await session.remove_webhook() return unload_ok @@ -205,14 +234,6 @@ class MinutPointClient: async def new_device(device_id, platform): """Load new device.""" - config_entries_key = f"{platform}.{DOMAIN}" - async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]: - if config_entries_key not in self._hass.data[CONFIG_ENTRY_IS_SETUP]: - await self._hass.config_entries.async_forward_entry_setups( - self._config_entry, [platform] - ) - self._hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) - async_dispatcher_send( self._hass, POINT_DISCOVERY_NEW.format(platform, DOMAIN), device_id ) @@ -220,10 +241,16 @@ class MinutPointClient: self._is_available = True for home_id in self._client.homes: if home_id not in self._known_homes: + await self._hass.config_entries.async_forward_entry_setups( + self._config_entry, [Platform.ALARM_CONTROL_PANEL] + ) await new_device(home_id, "alarm_control_panel") self._known_homes.add(home_id) for device in self._client.devices: if device.device_id not in self._known_devices: + await self._hass.config_entries.async_forward_entry_setups( + self._config_entry, PLATFORMS + ) for platform in PLATFORMS: await new_device(device.device_id, platform) self._known_devices.add(device.device_id) @@ -262,7 +289,7 @@ class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # s _attr_should_poll = False - def __init__(self, point_client, device_id, device_class): + def __init__(self, point_client, device_id, device_class) -> None: """Initialize the entity.""" self._async_unsub_dispatcher_connect = None self._client = point_client @@ -284,7 +311,7 @@ class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # s if device_class: self._attr_name = f"{self._name} {device_class.capitalize()}" - def __str__(self): + def __str__(self) -> str: """Return string representation of device.""" return f"MinutPoint {self.name}" @@ -337,3 +364,11 @@ class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # s def last_update(self): """Return the last_update time for the device.""" return parse_datetime(self.device.last_update) + + +@dataclass +class PointData: + """Point Data.""" + + client: MinutPointClient + entry_lock: asyncio.Lock = asyncio.Lock() diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 70c19056397..3657bad28ae 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -43,7 +43,7 @@ async def async_setup_entry( async def async_discover_home(home_id): """Discover and add a discovered home.""" - client = hass.data[POINT_DOMAIN][config_entry.entry_id] + client = config_entry.runtime_data.client async_add_entities([MinutPointAlarmControl(client, home_id)], True) async_dispatcher_connect( diff --git a/homeassistant/components/point/api.py b/homeassistant/components/point/api.py new file mode 100644 index 00000000000..b55a7704cbf --- /dev/null +++ b/homeassistant/components/point/api.py @@ -0,0 +1,26 @@ +"""API for Minut Point bound to Home Assistant OAuth.""" + +from aiohttp import ClientSession +import pypoint + +from homeassistant.helpers import config_entry_oauth2_flow + + +class AsyncConfigEntryAuth(pypoint.AbstractAuth): + """Provide Minut Point authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Minut Point auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return self._oauth_session.token["access_token"] diff --git a/homeassistant/components/point/application_credentials.py b/homeassistant/components/point/application_credentials.py new file mode 100644 index 00000000000..03cd02761f9 --- /dev/null +++ b/homeassistant/components/point/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the Minut Point integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index db3a7328e00..1443f6132ad 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -49,7 +49,7 @@ async def async_setup_entry( async def async_discover_sensor(device_id): """Discover and add a discovered sensor.""" - client = hass.data[POINT_DOMAIN][config_entry.entry_id] + client = config_entry.runtime_data.client async_add_entities( ( MinutPointBinarySensor(client, device_id, device_name) diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py index 6dbe8d5bb37..0e4f88ab578 100644 --- a/homeassistant/components/point/config_flow.py +++ b/homeassistant/components/point/config_flow.py @@ -1,197 +1,71 @@ """Config flow for Minut Point.""" -import asyncio -from collections import OrderedDict +from collections.abc import Mapping import logging from typing import Any -from pypoint import PointSession -import voluptuous as vol - -from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +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.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler from .const import DOMAIN -AUTH_CALLBACK_PATH = "/api/minut" -AUTH_CALLBACK_NAME = "api:minut" -DATA_FLOW_IMPL = "point_flow_implementation" +class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle Minut Point OAuth2 authentication.""" -_LOGGER = logging.getLogger(__name__) + DOMAIN = DOMAIN + reauth_entry: ConfigEntry | None = None -@callback -# pylint: disable-next=hass-argument-type # see PR 118243 -def register_flow_implementation(hass, domain, client_id, client_secret): - """Register a flow implementation. + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) - domain: Domain of the component responsible for the implementation. - name: Name of the component. - client_id: Client id. - client_secret: Client secret. - """ - if DATA_FLOW_IMPL not in hass.data: - hass.data[DATA_FLOW_IMPL] = OrderedDict() + async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult: + """Handle import from YAML.""" + return await self.async_step_user() - hass.data[DATA_FLOW_IMPL][domain] = { - CONF_CLIENT_ID: client_id, - CONF_CLIENT_SECRET: client_secret, - } + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() - -class PointFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle a config flow.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize flow.""" - self.flow_impl = None - - # pylint: disable-next=hass-return-type # see PR 118243 - async def async_step_import(self, user_input=None): - """Handle external yaml configuration.""" - if self._async_current_entries(): - return self.async_abort(reason="already_setup") - - self.flow_impl = DOMAIN - - return await self.async_step_auth() - - async def async_step_user( + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle a flow start.""" - flows = self.hass.data.get(DATA_FLOW_IMPL, {}) + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() - if self._async_current_entries(): - return self.async_abort(reason="already_setup") + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an oauth config entry or update existing entry for reauth.""" + user_id = str(data[CONF_TOKEN]["user_id"]) + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() - if not flows: - _LOGGER.debug("no flows") - return self.async_abort(reason="no_flows") - - if len(flows) == 1: - self.flow_impl = list(flows)[0] - return await self.async_step_auth() - - if user_input is not None: - self.flow_impl = user_input["flow_impl"] - return await self.async_step_auth() - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({vol.Required("flow_impl"): vol.In(list(flows))}), - ) - - # pylint: disable-next=hass-return-type # see PR 118243 - async def async_step_auth(self, user_input=None): - """Create an entry for auth.""" - if self._async_current_entries(): - return self.async_abort(reason="external_setup") - - errors = {} - - if user_input is not None: - errors["base"] = "follow_link" - - try: - async with asyncio.timeout(10): - url = await self._get_authorization_url() - except TimeoutError: - return self.async_abort(reason="authorize_url_timeout") - except Exception: - _LOGGER.exception("Unexpected error generating auth url") - return self.async_abort(reason="unknown_authorize_url_generation") - return self.async_show_form( - step_id="auth", - description_placeholders={"authorization_url": url}, - errors=errors, - ) - - async def _get_authorization_url(self): - """Create Minut Point session and get authorization url.""" - flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl] - client_id = flow[CONF_CLIENT_ID] - client_secret = flow[CONF_CLIENT_SECRET] - point_session = PointSession( - async_get_clientsession(self.hass), - client_id, - client_secret, - ) - - self.hass.http.register_view(MinutAuthCallbackView()) - - return point_session.get_authorization_url - - # pylint: disable-next=hass-return-type # see PR 118243 - async def async_step_code(self, code=None): - """Received code for authentication.""" - if self._async_current_entries(): - return self.async_abort(reason="already_setup") - - if code is None: - return self.async_abort(reason="no_code") - - _LOGGER.debug( - "Should close all flows below %s", - self._async_in_progress(), - ) - # Remove notification if no other discovery config entries in progress - - return await self._async_create_session(code) - - async def _async_create_session(self, code): - """Create point session and entries.""" - - flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] - client_id = flow[CONF_CLIENT_ID] - client_secret = flow[CONF_CLIENT_SECRET] - point_session = PointSession( - async_get_clientsession(self.hass), - client_id, - client_secret, - ) - token = await point_session.get_access_token(code) - _LOGGER.debug("Got new token") - if not point_session.is_authorized: - _LOGGER.error("Authentication Error") - return self.async_abort(reason="auth_error") - - _LOGGER.debug("Successfully authenticated Point") - user_email = (await point_session.user()).get("email") or "" - - return self.async_create_entry( - title=user_email, - data={ - "token": token, - "refresh_args": { - CONF_CLIENT_ID: client_id, - CONF_CLIENT_SECRET: client_secret, - }, - }, - ) - - -class MinutAuthCallbackView(HomeAssistantView): - """Minut Authorization Callback View.""" - - requires_auth = False - url = AUTH_CALLBACK_PATH - name = AUTH_CALLBACK_NAME - - @staticmethod - async def get(request): - """Receive authorization code.""" - hass = request.app[KEY_HASS] - if "code" in request.query: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": "code"}, data=request.query["code"] - ) + return self.async_create_entry( + title="Minut Point", + data={**data, CONF_WEBHOOK_ID: async_generate_id()}, ) - return "OK!" + + if ( + self.reauth_entry.unique_id is None + or self.reauth_entry.unique_id == user_id + ): + logging.debug("user_id: %s", user_id) + return self.async_update_reload_and_abort( + self.reauth_entry, + data={**self.reauth_entry.data, **data}, + unique_id=user_id, + ) + + return self.async_abort(reason="wrong_account") diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py index c8c8f14d019..1c2720749e6 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -7,8 +7,12 @@ DOMAIN = "point" SCAN_INTERVAL = timedelta(minutes=1) CONF_WEBHOOK_URL = "webhook_url" +CONF_REFRESH_TOKEN = "refresh_token" EVENT_RECEIVED = "point_webhook_received" SIGNAL_UPDATE_ENTITY = "point_update" SIGNAL_WEBHOOK = "point_webhook" POINT_DISCOVERY_NEW = "point_new_{}_{}" + +OAUTH2_AUTHORIZE = "https://api.minut.com/v8/oauth/authorize" +OAUTH2_TOKEN = "https://api.minut.com/v8/oauth/token" diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json index 0e8d7068a4f..7b0a2f0e01e 100644 --- a/homeassistant/components/point/manifest.json +++ b/homeassistant/components/point/manifest.json @@ -3,10 +3,10 @@ "name": "Minut Point", "codeowners": ["@fredrike"], "config_flow": true, - "dependencies": ["webhook", "http"], + "dependencies": ["application_credentials", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/point", "iot_class": "cloud_polling", "loggers": ["pypoint"], "quality_scale": "silver", - "requirements": ["pypoint==2.3.2"] + "requirements": ["pypoint==3.0.0"] } diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 446a67273fc..f97000bae82 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -54,7 +54,7 @@ async def async_setup_entry( async def async_discover_sensor(device_id): """Discover and add a discovered sensor.""" - client = hass.data[POINT_DOMAIN][config_entry.entry_id] + client = config_entry.runtime_data.client async_add_entities( [ MinutPointSensor(client, device_id, description) diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json index 8a28e314b69..b2e8d9309d9 100644 --- a/homeassistant/components/point/strings.json +++ b/homeassistant/components/point/strings.json @@ -1,29 +1,31 @@ { "config": { - "step": { - "user": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", - "description": "[%key:common::config_flow::description::confirm_setup%]", - "data": { "flow_impl": "Provider" } - }, - "auth": { - "title": "Authenticate Point", - "description": "Please follow the link below and **Accept** access to your Minut account, then come back and press **Submit** below.\n\n[Link]({authorization_url})" - } + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "You can only reauthenticate this account with the same user." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" }, - "error": { - "no_token": "[%key:common::config_flow::error::invalid_access_token%]", - "follow_link": "Please follow the link and authenticate before pressing Submit" - }, - "abort": { - "already_setup": "[%key:common::config_flow::abort::single_instance_allowed%]", - "external_setup": "Point successfully configured from another flow.", - "no_flows": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]" + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Point integration needs to re-authenticate your account" + } } } } diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 359ef656290..6b3028826dc 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -24,6 +24,7 @@ APPLICATION_CREDENTIALS = [ "neato", "nest", "netatmo", + "point", "senz", "spotify", "tesla_fleet", diff --git a/requirements_all.txt b/requirements_all.txt index dbffd37b40c..b3f4101602f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2142,7 +2142,7 @@ pypjlink2==1.2.1 pyplaato==0.0.18 # homeassistant.components.point -pypoint==2.3.2 +pypoint==3.0.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae66a82dfbb..46d2cb1b210 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1723,7 +1723,7 @@ pypjlink2==1.2.1 pyplaato==0.0.18 # homeassistant.components.point -pypoint==2.3.2 +pypoint==3.0.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 diff --git a/tests/components/point/__init__.py b/tests/components/point/__init__.py index 9fb6eea9ac7..254eef2e936 100644 --- a/tests/components/point/__init__.py +++ b/tests/components/point/__init__.py @@ -1 +1,12 @@ """Tests for the Point component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py index 71f3f31ce8d..bd1e3cfac29 100644 --- a/tests/components/point/test_config_flow.py +++ b/tests/components/point/test_config_flow.py @@ -1,153 +1,172 @@ -"""Tests for the Point config flow.""" +"""Test the Minut Point config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest -from homeassistant.components.point import DOMAIN, config_flow -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.point.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + +REDIRECT_URL = "https://example.com/auth/external/callback" -def init_config_flow( - hass: HomeAssistant, side_effect: type[Exception] | None = None -) -> config_flow.PointFlowHandler: - """Init a configuration flow.""" - config_flow.register_flow_implementation(hass, DOMAIN, "id", "secret") - flow = config_flow.PointFlowHandler() - flow._get_authorization_url = AsyncMock( - return_value="https://example.com", side_effect=side_effect +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), ) - flow.hass = hass - return flow -@pytest.fixture -def is_authorized() -> bool: - """Set PointSession authorized.""" - return True - - -@pytest.fixture -def mock_pypoint(is_authorized): - """Mock pypoint.""" - with patch( - "homeassistant.components.point.config_flow.PointSession" - ) as PointSession: - PointSession.return_value.get_access_token = AsyncMock( - return_value={"access_token": "boo"} - ) - PointSession.return_value.is_authorized = is_authorized - PointSession.return_value.user = AsyncMock( - return_value={"email": "john.doe@example.com"} - ) - yield PointSession - - -async def test_abort_if_no_implementation_registered(hass: HomeAssistant) -> None: - """Test we abort if no implementation is registered.""" - flow = config_flow.PointFlowHandler() - flow.hass = hass - - result = await flow.async_step_user() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_flows" - - -async def test_abort_if_already_setup(hass: HomeAssistant) -> None: - """Test we abort if Point is already setup.""" - flow = init_config_flow(hass) - - with patch.object(hass.config_entries, "async_entries", return_value=[{}]): - result = await flow.async_step_user() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_setup" - - with patch.object(hass.config_entries, "async_entries", return_value=[{}]): - result = await flow.async_step_import() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_setup" - - -async def test_full_flow_implementation(hass: HomeAssistant, mock_pypoint) -> None: - """Test registering an implementation and finishing flow works.""" - config_flow.register_flow_implementation(hass, "test-other", None, None) - flow = init_config_flow(hass) - - result = await flow.async_step_user() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - result = await flow.async_step_user({"flow_impl": "test"}) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - assert result["description_placeholders"] == { - "authorization_url": "https://example.com" - } - - result = await flow.async_step_code("123ABC") - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["data"]["refresh_args"] == { - CONF_CLIENT_ID: "id", - CONF_CLIENT_SECRET: "secret", - } - assert result["title"] == "john.doe@example.com" - assert result["data"]["token"] == {"access_token": "boo"} - - -async def test_step_import(hass: HomeAssistant, mock_pypoint) -> None: - """Test that we trigger import when configuring with client.""" - flow = init_config_flow(hass) - - result = await flow.async_step_import() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - - -@pytest.mark.parametrize("is_authorized", [False]) -async def test_wrong_code_flow_implementation( - hass: HomeAssistant, mock_pypoint +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, ) -> None: - """Test wrong code.""" - flow = init_config_flow(hass) + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( # noqa: SLF001 + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": "abcd", + }, + ) + + with patch( + "homeassistant.components.point.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "abcd" + assert result["result"].data["token"]["user_id"] == "abcd" + assert result["result"].data["token"]["type"] == "Bearer" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["result"].data["token"]["expires_in"] == 60 + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert "webhook_id" in result["result"].data + + +@pytest.mark.parametrize( + ("unique_id", "expected", "expected_unique_id"), + [ + ("abcd", "reauth_successful", "abcd"), + (None, "reauth_successful", "abcd"), + ("abcde", "wrong_account", "abcde"), + ], + ids=("correct-unique_id", "missing-unique_id", "wrong-unique_id-abort"), +) +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + unique_id: str | None, + expected: str, + expected_unique_id: str, +) -> None: + """Test reauthentication flow.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=unique_id, + version=1, + data={"id": "timmo", "auth_implementation": DOMAIN}, + ) + old_entry.add_to_hass(hass) + + result = await old_entry.start_reauth_flow(hass) + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "user_id": "abcd", + }, + ) + + with ( + patch("homeassistant.components.point.api.AsyncConfigEntryAuth"), + patch( + f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - result = await flow.async_step_code("123ABC") assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "auth_error" + assert result["reason"] == expected + assert old_entry.unique_id == expected_unique_id -async def test_not_pick_implementation_if_only_one(hass: HomeAssistant) -> None: - """Test we allow picking implementation if we have one flow_imp.""" - flow = init_config_flow(hass) - - result = await flow.async_step_user() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "auth" - - -async def test_abort_if_timeout_generating_auth_url(hass: HomeAssistant) -> None: - """Test we abort if generating authorize url fails.""" - flow = init_config_flow(hass, side_effect=TimeoutError) - - result = await flow.async_step_user() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "authorize_url_timeout" - - -async def test_abort_if_exception_generating_auth_url(hass: HomeAssistant) -> None: - """Test we abort if generating authorize url blows up.""" - flow = init_config_flow(hass, side_effect=ValueError) - - result = await flow.async_step_user() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unknown_authorize_url_generation" - - -async def test_abort_no_code(hass: HomeAssistant) -> None: - """Test if no code is given to step_code.""" - flow = init_config_flow(hass) - - result = await flow.async_step_code() - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_code" +async def test_import_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "pick_implementation" From 7ff0d54291046f29565e4acd995625ed9929298f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 20 Sep 2024 12:03:16 +0200 Subject: [PATCH 0858/1309] Clean ondilo ico logging (#126310) * Clean too verbose logging * Add tests --- .../components/ondilo_ico/coordinator.py | 19 ++-- tests/components/ondilo_ico/test_init.py | 99 +++++++++++++++++++ 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index 9a98ce0037e..bc092ad0b9a 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -42,9 +42,7 @@ class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]): """Fetch data from API endpoint.""" try: return await self.hass.async_add_executor_job(self._update_data) - except OndiloError as err: - _LOGGER.exception("Error getting pools") raise UpdateFailed(f"Error communicating with API: {err}") from err def _update_data(self) -> dict[str, OndiloIcoData]: @@ -52,23 +50,28 @@ class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]): res = {} pools = self.api.get_pools() _LOGGER.debug("Pools: %s", pools) + error: OndiloError | None = None for pool in pools: + pool_id = pool["id"] try: - ico = self.api.get_ICO_details(pool["id"]) + ico = self.api.get_ICO_details(pool_id) if not ico: _LOGGER.debug( - "The pool id %s does not have any ICO attached", pool["id"] + "The pool id %s does not have any ICO attached", pool_id ) continue - sensors = self.api.get_last_pool_measures(pool["id"]) - except OndiloError: - _LOGGER.exception("Error communicating with API for %s", pool["id"]) + sensors = self.api.get_last_pool_measures(pool_id) + except OndiloError as err: + error = err + _LOGGER.debug("Error communicating with API for %s: %s", pool_id, err) continue - res[pool["id"]] = OndiloIcoData( + res[pool_id] = OndiloIcoData( ico=ico, pool=pool, sensors={sensor["data_type"]: sensor["value"] for sensor in sensors}, ) if not res: + if error: + raise UpdateFailed(f"Error communicating with API: {error}") from error raise UpdateFailed("No data available") return res diff --git a/tests/components/ondilo_ico/test_init.py b/tests/components/ondilo_ico/test_init.py index 707022e9145..67f68f27b3e 100644 --- a/tests/components/ondilo_ico/test_init.py +++ b/tests/components/ondilo_ico/test_init.py @@ -3,6 +3,8 @@ from typing import Any from unittest.mock import MagicMock +from ondilo import OndiloError +import pytest from syrupy import SnapshotAssertion from homeassistant.config_entries import ConfigEntryState @@ -35,6 +37,29 @@ async def test_devices( assert device_entry == snapshot(name=f"{identifier[0]}-{identifier[1]}") +async def test_get_pools_error( + hass: HomeAssistant, + mock_ondilo_client: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test get pools errors.""" + mock_ondilo_client.get_pools.side_effect = OndiloError( + 502, + ( + " 502 Bad Gateway " + "

502 Bad Gateway

" + ), + ) + await setup_integration(hass, config_entry, mock_ondilo_client) + + # No sensor should be created + assert not hass.states.async_all() + # We should not have tried to retrieve pool measures + assert mock_ondilo_client.get_ICO_details.call_count == 0 + assert mock_ondilo_client.get_last_pool_measures.call_count == 0 + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_init_with_no_ico_attached( hass: HomeAssistant, mock_ondilo_client: MagicMock, @@ -53,3 +78,77 @@ async def test_init_with_no_ico_attached( # We should not have tried to retrieve pool measures mock_ondilo_client.get_last_pool_measures.assert_not_called() assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("api", ["get_ICO_details", "get_last_pool_measures"]) +async def test_details_error_all_pools( + hass: HomeAssistant, + mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + pool1: dict[str, Any], + api: str, +) -> None: + """Test details and measures error for all pools.""" + mock_ondilo_client.get_pools.return_value = pool1 + client_api = getattr(mock_ondilo_client, api) + client_api.side_effect = OndiloError(400, "error") + + await setup_integration(hass, config_entry, mock_ondilo_client) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + assert not device_entries + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_details_error_one_pool( + hass: HomeAssistant, + mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + ico_details2: dict[str, Any], +) -> None: + """Test details error for one pool and success for the other.""" + mock_ondilo_client.get_ICO_details.side_effect = [ + OndiloError( + 404, + "Not Found", + ), + ico_details2, + ] + + await setup_integration(hass, config_entry, mock_ondilo_client) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + assert len(device_entries) == 1 + + +async def test_measures_error_one_pool( + hass: HomeAssistant, + mock_ondilo_client: MagicMock, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + last_measures: list[dict[str, Any]], +) -> None: + """Test measures error for one pool and success for the other.""" + mock_ondilo_client.get_last_pool_measures.side_effect = [ + OndiloError( + 404, + "Not Found", + ), + last_measures, + ] + + await setup_integration(hass, config_entry, mock_ondilo_client) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + assert len(device_entries) == 1 From fb56c5875ac223ca55a52a08f0e7f6f96be3c7c8 Mon Sep 17 00:00:00 2001 From: Tatham Oddie Date: Fri, 20 Sep 2024 20:04:24 +1000 Subject: [PATCH 0859/1309] Add device class for UPNP uptime sensor (#126306) Allows for easier conversion of time periods within HA natively --- homeassistant/components/upnp/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index d6da50c877d..aae2f8308c1 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -89,6 +89,7 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( UpnpSensorEntityDescription( key=ROUTER_UPTIME, translation_key="uptime", + device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, From 3ad6589f25f19a3f0e53fb6b4724c78ffb94a02a Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 20 Sep 2024 12:04:38 +0200 Subject: [PATCH 0860/1309] Bump python-MotionMount to 2.2.0 (#126309) --- homeassistant/components/motionmount/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json index 2f7d24142db..1fa3d31cfab 100644 --- a/homeassistant/components/motionmount/manifest.json +++ b/homeassistant/components/motionmount/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/motionmount", "integration_type": "device", "iot_class": "local_push", - "requirements": ["python-MotionMount==2.1.0"], + "requirements": ["python-MotionMount==2.2.0"], "zeroconf": ["_tvm._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b3f4101602f..4e2438f4e10 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2280,7 +2280,7 @@ pytedee-async==0.2.20 pythinkingcleaner==0.0.3 # homeassistant.components.motionmount -python-MotionMount==2.1.0 +python-MotionMount==2.2.0 # homeassistant.components.awair python-awair==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 46d2cb1b210..4ebf08cce72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1828,7 +1828,7 @@ pytautulli==23.1.1 pytedee-async==0.2.20 # homeassistant.components.motionmount -python-MotionMount==2.1.0 +python-MotionMount==2.2.0 # homeassistant.components.awair python-awair==0.2.4 From ef94fcf87361ab13aa6daeab047a6bfc41105d49 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 20 Sep 2024 12:05:19 +0200 Subject: [PATCH 0861/1309] Fix duplicate power sensors for Matter 1.3 powerplugs (#126269) * Prevent duplicate power sensors in Matter sensor platform * adjust test as well --- homeassistant/components/matter/discovery.py | 9 ++- homeassistant/components/matter/models.py | 12 ++-- homeassistant/components/matter/sensor.py | 30 ++++------ .../nodes/eve-energy-plug-patched.json | 56 ++++++++++++------- 4 files changed, 61 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 33c8bb47e6a..c3e347e9808 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -100,13 +100,20 @@ def async_discover_entities( ): continue - # check for values that may not be present + # check for endpoint-attributes that may not be present if schema.absent_attributes is not None and any( endpoint.has_attribute(None, val_schema) for val_schema in schema.absent_attributes ): continue + # check for clusters that may not be present + if schema.absent_clusters is not None and any( + endpoint.node.has_cluster(val_schema) + for val_schema in schema.absent_clusters + ): + continue + # all checks passed, this value belongs to an entity attributes_to_watch = list(schema.required_attributes) diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index bb79d3571cf..c9488437a06 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import TypedDict from chip.clusters import Objects as clusters -from chip.clusters.Objects import ClusterAttributeDescriptor +from chip.clusters.Objects import Cluster, ClusterAttributeDescriptor from matter_server.client.models.device_types import DeviceType from matter_server.client.models.node import MatterEndpoint @@ -95,11 +95,15 @@ class MatterDiscoverySchema: # [optional] the attribute's endpoint_id must match ANY of these values endpoint_id: tuple[int, ...] | None = None - # [optional] additional attributes that MAY NOT be present - # on the node for this scheme to pass + # [optional] attributes that MAY NOT be present + # (on the same endpoint) for this scheme to pass absent_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None - # [optional] additional attributes that may be present + # [optional] cluster(s) that MAY NOT be present + # (on ANY endpoint) for this scheme to pass + absent_clusters: tuple[type[Cluster], ...] | None = None + + # [optional] additional attributes that may be present (on the same endpoint) # these attributes are copied over to attributes_to_watch and # are not discovered by other entities optional_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index da627734be6..94102151e17 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -188,7 +188,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.Watt,), - absent_attributes=(clusters.ElectricalPowerMeasurement.Attributes.ActivePower,), + absent_clusters=(clusters.ElectricalPowerMeasurement,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -202,7 +202,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.Voltage,), - absent_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,), + absent_clusters=(clusters.ElectricalPowerMeasurement,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -216,9 +216,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.WattAccumulated,), - absent_attributes=( - clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, - ), + absent_clusters=(clusters.ElectricalEnergyMeasurement,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -232,9 +230,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.Current,), - absent_attributes=( - clusters.ElectricalPowerMeasurement.Attributes.ActiveCurrent, - ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -398,7 +394,7 @@ DISCOVERY_SCHEMAS = [ required_attributes=( ThirdRealityMeteringCluster.Attributes.InstantaneousDemand, ), - absent_attributes=(clusters.ElectricalPowerMeasurement.Attributes.ActivePower,), + absent_clusters=(clusters.ElectricalPowerMeasurement,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -415,9 +411,7 @@ DISCOVERY_SCHEMAS = [ required_attributes=( ThirdRealityMeteringCluster.Attributes.CurrentSummationDelivered, ), - absent_attributes=( - clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, - ), + absent_clusters=(clusters.ElectricalEnergyMeasurement,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -432,7 +426,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.Watt,), - absent_attributes=(clusters.ElectricalPowerMeasurement.Attributes.ActivePower,), + absent_clusters=(clusters.ElectricalPowerMeasurement,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -446,9 +440,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.WattAccumulated,), - absent_attributes=( - clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, - ), + absent_clusters=(clusters.ElectricalEnergyMeasurement,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -463,7 +455,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.Voltage,), - absent_attributes=(clusters.ElectricalPowerMeasurement.Attributes.Voltage,), + absent_clusters=(clusters.ElectricalPowerMeasurement,), ), MatterDiscoverySchema( platform=Platform.SENSOR, @@ -477,9 +469,7 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.Current,), - absent_attributes=( - clusters.ElectricalPowerMeasurement.Attributes.ActiveCurrent, - ), + absent_clusters=(clusters.ElectricalPowerMeasurement,), ), MatterDiscoverySchema( platform=Platform.SENSOR, diff --git a/tests/components/matter/fixtures/nodes/eve-energy-plug-patched.json b/tests/components/matter/fixtures/nodes/eve-energy-plug-patched.json index 6b449643e8e..18c4a8c68ef 100644 --- a/tests/components/matter/fixtures/nodes/eve-energy-plug-patched.json +++ b/tests/components/matter/fixtures/nodes/eve-energy-plug-patched.json @@ -305,9 +305,23 @@ 319422468, 319422469, 319422471, 319422472, 319422473, 319422474, 319422475, 319422476, 319422478, 319422481, 319422482, 65533 ], - "1/144/0": 2, - "1/144/1": 3, - "1/144/2": [ + "2/29/0": [ + { + "0": 1296, + "1": 1 + } + ], + "2/29/1": [3, 29, 144, 145, 156], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/144/0": 2, + "2/144/1": 3, + "2/144/2": [ { "0": 1, "1": true, @@ -345,16 +359,16 @@ ] } ], - "1/144/4": 220000, - "1/144/5": 2000, - "1/144/8": 550000, - "1/144/65533": 1, - "1/144/65532": 2, - "1/144/65531": [0, 1, 2, 4, 5, 8, 65528, 65529, 65530, 65531, 65532, 65533], - "1/144/65530": [], - "1/144/65529": [], - "1/144/65528": [], - "1/145/0": { + "2/144/4": 220000, + "2/144/5": 2000, + "2/144/8": 550000, + "2/144/65533": 1, + "2/144/65532": 2, + "2/144/65531": [0, 1, 2, 4, 5, 8, 65528, 65529, 65530, 65531, 65532, 65533], + "2/144/65530": [], + "2/144/65529": [], + "2/144/65528": [], + "2/145/0": { "0": 14, "1": true, "2": 0, @@ -366,16 +380,16 @@ } ] }, - "1/145/65533": 1, - "1/145/65532": 7, - "1/145/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], - "1/145/65530": [0], - "1/145/65529": [], - "1/145/65528": [], - "1/145/1": { + "2/145/65533": 1, + "2/145/65532": 7, + "2/145/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "2/145/65530": [0], + "2/145/65529": [], + "2/145/65528": [], + "2/145/1": { "0": 2500 }, - "1/145/2": null + "2/145/2": null }, "attribute_subscriptions": [], "last_subscription_attempt": 0 From 8b44c16b577968cad567b6cf6a686f2d2c09e92b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Sep 2024 12:07:15 +0200 Subject: [PATCH 0862/1309] Use HassKey in core components (a-c) (#126258) * Use HassKey in conversation * Use HassKey in assist_satellite * automation * More * Unrelated * Improve --- .../components/air_quality/__init__.py | 10 +++---- .../alarm_control_panel/__init__.py | 10 +++---- .../components/assist_satellite/__init__.py | 10 +++---- .../components/assist_satellite/const.py | 12 ++++++++ .../assist_satellite/websocket_api.py | 13 +++----- .../components/automation/__init__.py | 30 +++++++------------ .../components/binary_sensor/__init__.py | 10 +++---- homeassistant/components/button/__init__.py | 10 +++---- homeassistant/components/calendar/__init__.py | 20 +++++-------- homeassistant/components/calendar/const.py | 13 ++++++++ homeassistant/components/calendar/trigger.py | 5 ++-- homeassistant/components/camera/__init__.py | 11 ++++--- homeassistant/components/camera/const.py | 11 ++++++- .../components/camera/media_source.py | 7 ++--- homeassistant/components/climate/__init__.py | 10 +++---- .../components/conversation/__init__.py | 16 ++++------ .../components/conversation/agent_manager.py | 6 ++-- .../components/conversation/const.py | 12 ++++++++ homeassistant/components/conversation/http.py | 7 ++--- homeassistant/components/cover/__init__.py | 10 +++---- 20 files changed, 125 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/air_quality/__init__.py b/homeassistant/components/air_quality/__init__.py index 9a80ee39e86..605a34a69e0 100644 --- a/homeassistant/components/air_quality/__init__.py +++ b/homeassistant/components/air_quality/__init__.py @@ -13,11 +13,13 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN _LOGGER: Final = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[AirQualityEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -54,7 +56,7 @@ PROP_TO_ATTR: Final[dict[str, str]] = { async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the air quality component.""" - component = hass.data[DOMAIN] = EntityComponent[AirQualityEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[AirQualityEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -63,14 +65,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[AirQualityEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[AirQualityEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class AirQualityEntity(Entity): diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index b09d5867d26..91d3a83df8e 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -33,6 +33,7 @@ from homeassistant.helpers.deprecation import ( from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 _DEPRECATED_FORMAT_NUMBER, @@ -52,6 +53,7 @@ from .const import ( # noqa: F401 _LOGGER: Final = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[AlarmControlPanelEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA: Final = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE: Final = cv.PLATFORM_SCHEMA_BASE @@ -69,7 +71,7 @@ ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for sensors.""" - component = hass.data[DOMAIN] = EntityComponent[AlarmControlPanelEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[AlarmControlPanelEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -122,14 +124,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[AlarmControlPanelEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[AlarmControlPanelEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class AlarmControlPanelEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 2d4459ffd8c..77c9d8e678a 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -10,7 +10,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, AssistSatelliteEntityFeature +from .const import DOMAIN, DOMAIN_DATA, AssistSatelliteEntityFeature from .entity import ( AssistSatelliteConfiguration, AssistSatelliteEntity, @@ -36,7 +36,7 @@ PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - component = hass.data[DOMAIN] = EntityComponent[AssistSatelliteEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[AssistSatelliteEntity]( _LOGGER, DOMAIN, hass ) await component.async_setup(config) @@ -62,11 +62,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index 3a9ce896fb2..bd5453e06de 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -1,9 +1,21 @@ """Constants for assist satellite.""" +from __future__ import annotations + from enum import IntFlag +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from .entity import AssistSatelliteEntity DOMAIN = "assist_satellite" +DOMAIN_DATA: HassKey[EntityComponent[AssistSatelliteEntity]] = HassKey(DOMAIN) + class AssistSatelliteEntityFeature(IntFlag): """Supported features of Assist satellite entity.""" diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 0d7a434dba5..ee7bef7e4e8 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -9,10 +9,8 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_component import EntityComponent -from .const import DOMAIN -from .entity import AssistSatelliteEntity +from .const import DOMAIN, DOMAIN_DATA @callback @@ -38,8 +36,7 @@ async def websocket_intercept_wake_word( msg: dict[str, Any], ) -> None: """Intercept the next wake word from a satellite.""" - component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] - satellite = component.get_entity(msg["entity_id"]) + satellite = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) if satellite is None: connection.send_error( msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" @@ -77,8 +74,7 @@ def websocket_get_configuration( msg: dict[str, Any], ) -> None: """Get the current satellite configuration.""" - component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] - satellite = component.get_entity(msg["entity_id"]) + satellite = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) if satellite is None: connection.send_error( msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" @@ -108,8 +104,7 @@ async def websocket_set_wake_words( msg: dict[str, Any], ) -> None: """Set the active wake words for the satellite.""" - component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] - satellite = component.get_entity(msg["entity_id"]) + satellite = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) if satellite is None: connection.send_error( msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index dacbe074e95..1db5125a8a6 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -94,6 +94,7 @@ from homeassistant.helpers.trigger import ( from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.dt import parse_datetime +from homeassistant.util.hass_dict import HassKey from .config import AutomationConfig, ValidationStatus from .const import ( @@ -109,6 +110,7 @@ from .const import ( from .helpers import async_get_blueprints from .trace import trace_automation +DOMAIN_DATA: HassKey[EntityComponent[BaseAutomationEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -161,14 +163,12 @@ def _automations_with_x( hass: HomeAssistant, referenced_id: str, property_name: str ) -> list[str]: """Return all automations that reference the x.""" - if DOMAIN not in hass.data: + if DOMAIN_DATA not in hass.data: return [] - component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] - return [ automation_entity.entity_id - for automation_entity in component.entities + for automation_entity in hass.data[DOMAIN_DATA].entities if referenced_id in getattr(automation_entity, property_name) ] @@ -177,12 +177,10 @@ def _x_in_automation( hass: HomeAssistant, entity_id: str, property_name: str ) -> list[str]: """Return all x in an automation.""" - if DOMAIN not in hass.data: + if DOMAIN_DATA not in hass.data: return [] - component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] - - if (automation_entity := component.get_entity(entity_id)) is None: + if (automation_entity := hass.data[DOMAIN_DATA].get_entity(entity_id)) is None: return [] return list(getattr(automation_entity, property_name)) @@ -254,11 +252,9 @@ def automations_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list if DOMAIN not in hass.data: return [] - component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] - return [ automation_entity.entity_id - for automation_entity in component.entities + for automation_entity in hass.data[DOMAIN_DATA].entities if automation_entity.referenced_blueprint == blueprint_path ] @@ -266,12 +262,10 @@ def automations_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list @callback def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None: """Return the blueprint the automation is based on or None.""" - if DOMAIN not in hass.data: + if DOMAIN_DATA not in hass.data: return None - component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] - - if (automation_entity := component.get_entity(entity_id)) is None: + if (automation_entity := hass.data[DOMAIN_DATA].get_entity(entity_id)) is None: return None return automation_entity.referenced_blueprint @@ -279,7 +273,7 @@ def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up all automations.""" - hass.data[DOMAIN] = component = EntityComponent[BaseAutomationEntity]( + hass.data[DOMAIN_DATA] = component = EntityComponent[BaseAutomationEntity]( LOGGER, DOMAIN, hass ) @@ -1210,9 +1204,7 @@ def websocket_config( msg: dict[str, Any], ) -> None: """Get automation config.""" - component: EntityComponent[BaseAutomationEntity] = hass.data[DOMAIN] - - automation = component.get_entity(msg["entity_id"]) + automation = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) if automation is None: connection.send_error( diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 0b3e423e339..5ed6014030f 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -24,10 +24,12 @@ from homeassistant.helpers.deprecation import ( from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) DOMAIN = "binary_sensor" +DOMAIN_DATA: HassKey[EntityComponent[BinarySensorEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -217,7 +219,7 @@ _DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for binary sensors.""" - component = hass.data[DOMAIN] = EntityComponent[BinarySensorEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[BinarySensorEntity]( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL ) @@ -227,14 +229,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[BinarySensorEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[BinarySensorEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class BinarySensorEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 3955fabdf00..614a6e6dba3 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -19,11 +19,13 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN, SERVICE_PRESS _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[ButtonEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -47,7 +49,7 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(ButtonDeviceClass)) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Button entities.""" - component = hass.data[DOMAIN] = EntityComponent[ButtonEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[ButtonEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -63,14 +65,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[ButtonEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[ButtonEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class ButtonEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 3e33f077e93..e1f206ca661 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -43,6 +43,8 @@ from homeassistant.util.json import JsonValueType from .const import ( CONF_EVENT, + DOMAIN, + DOMAIN_DATA, EVENT_DESCRIPTION, EVENT_DURATION, EVENT_END, @@ -70,7 +72,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -DOMAIN = "calendar" ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -285,7 +286,7 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for calendars.""" - component = hass.data[DOMAIN] = EntityComponent[CalendarEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[CalendarEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -318,14 +319,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) def get_date(date: dict[str, Any]) -> datetime.datetime: @@ -702,8 +701,7 @@ async def handle_calendar_event_create( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle creation of a calendar event.""" - component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] - if not (entity := component.get_entity(msg["entity_id"])): + if not (entity := hass.data[DOMAIN_DATA].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return @@ -743,8 +741,7 @@ async def handle_calendar_event_delete( ) -> None: """Handle delete of a calendar event.""" - component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] - if not (entity := component.get_entity(msg["entity_id"])): + if not (entity := hass.data[DOMAIN_DATA].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return @@ -789,8 +786,7 @@ async def handle_calendar_event_update( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle creation of a calendar event.""" - component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] - if not (entity := component.get_entity(msg["entity_id"])): + if not (entity := hass.data[DOMAIN_DATA].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index e667510325b..6266a604c81 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -1,6 +1,19 @@ """Constants for calendar components.""" +from __future__ import annotations + from enum import IntFlag +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import CalendarEntity + +DOMAIN = "calendar" +DOMAIN_DATA: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN) CONF_EVENT = "event" diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index 523a634704c..4daa32f7fc7 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -23,7 +23,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from . import DOMAIN, CalendarEntity, CalendarEvent +from . import CalendarEntity, CalendarEvent +from .const import DOMAIN, DOMAIN_DATA _LOGGER = logging.getLogger(__name__) @@ -94,7 +95,7 @@ type QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEven def get_entity(hass: HomeAssistant, entity_id: str) -> CalendarEntity: """Get the calendar entity for the provided entity_id.""" - component: EntityComponent[CalendarEntity] = hass.data[DOMAIN] + component: EntityComponent[CalendarEntity] = hass.data[DOMAIN_DATA] if not (entity := component.get_entity(entity_id)) or not isinstance( entity, CalendarEntity ): diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 859ced1ba86..14f884c1750 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -73,6 +73,7 @@ from .const import ( # noqa: F401 DATA_CAMERA_PREFS, DATA_RTSP_TO_WEB_RTC, DOMAIN, + DOMAIN_DATA, PREF_ORIENTATION, PREF_PRELOAD_STREAM, SERVICE_RECORD, @@ -362,7 +363,7 @@ def async_register_rtsp_to_web_rtc_provider( async def _async_refresh_providers(hass: HomeAssistant) -> None: """Check all cameras for any state changes for registered providers.""" - component: EntityComponent[Camera] = hass.data[DOMAIN] + component = hass.data[DOMAIN_DATA] await asyncio.gather( *(camera.async_refresh_providers() for camera in component.entities) ) @@ -380,7 +381,7 @@ def _async_get_rtsp_to_web_rtc_providers( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the camera component.""" - component = hass.data[DOMAIN] = EntityComponent[Camera]( + component = hass.data[DOMAIN_DATA] = EntityComponent[Camera]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -455,14 +456,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[Camera] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[Camera] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) CACHED_PROPERTIES_WITH_ATTR_ = { diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index ad863f374d1..d6a2372ffc1 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -1,8 +1,10 @@ """Constants for Camera component.""" +from __future__ import annotations + from enum import StrEnum from functools import partial -from typing import Final +from typing import TYPE_CHECKING, Final from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, @@ -10,8 +12,15 @@ from homeassistant.helpers.deprecation import ( check_if_deprecated_constant, dir_with_deprecated_constants, ) +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import Camera DOMAIN: Final = "camera" +DOMAIN_DATA: HassKey[EntityComponent[Camera]] = HassKey(DOMAIN) DATA_CAMERA_PREFS: Final = "camera_prefs" DATA_RTSP_TO_WEB_RTC: Final = "rtsp_to_web_rtc" diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py index 958235c684d..00c0e83b46f 100644 --- a/homeassistant/components/camera/media_source.py +++ b/homeassistant/components/camera/media_source.py @@ -16,10 +16,9 @@ from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_component import EntityComponent from . import Camera, _async_stream_endpoint_url -from .const import DOMAIN, StreamType +from .const import DOMAIN, DOMAIN_DATA, StreamType async def async_get_media_source(hass: HomeAssistant) -> CameraMediaSource: @@ -59,7 +58,7 @@ class CameraMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" - component: EntityComponent[Camera] = self.hass.data[DOMAIN] + component = self.hass.data[DOMAIN_DATA] camera = component.get_entity(item.identifier) if not camera: @@ -108,7 +107,7 @@ class CameraMediaSource(MediaSource): return _media_source_for_camera(self.hass, camera, content_type) - component: EntityComponent[Camera] = self.hass.data[DOMAIN] + component = self.hass.data[DOMAIN_DATA] results = await asyncio.gather( *(_filter_browsable_camera(camera) for camera in component.entities), return_exceptions=True, diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 7b016d9c90b..7213a2ebca0 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -37,6 +37,7 @@ from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_issue_tracker, async_suggest_report_issue +from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( # noqa: F401 @@ -114,6 +115,7 @@ from .const import ( # noqa: F401 _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[ClimateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -150,7 +152,7 @@ SET_TEMPERATURE_SCHEMA = vol.All( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up climate entities.""" - component = hass.data[DOMAIN] = EntityComponent[ClimateEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[ClimateEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -223,14 +225,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[ClimateEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[ClimateEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class ClimateEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 2e06387765b..983d2074ab5 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -36,6 +36,7 @@ from .const import ( ATTR_LANGUAGE, ATTR_TEXT, DOMAIN, + DOMAIN_DATA, HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT, SERVICE_PROCESS, @@ -132,7 +133,6 @@ def async_get_conversation_languages( all conversation agents. """ agent_manager = get_agent_manager(hass) - entity_component: EntityComponent[ConversationEntity] = hass.data[DOMAIN] agents: list[ConversationEntity | AbstractConversationAgent] if agent_id: @@ -148,7 +148,7 @@ def async_get_conversation_languages( agents = [agent] else: - agents = list(entity_component.entities) + agents = list(hass.data[DOMAIN_DATA].entities) for info in agent_manager.async_get_agent_info(): agent = agent_manager.async_get_agent(info.id) assert agent is not None @@ -208,10 +208,8 @@ async def async_prepare_agent( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" - entity_component: EntityComponent[ConversationEntity] = EntityComponent( - _LOGGER, DOMAIN, hass - ) - hass.data[DOMAIN] = entity_component + entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass) + hass.data[DOMAIN_DATA] = entity_component await async_setup_default_agent( hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) @@ -269,11 +267,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[ConversationEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[ConversationEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 8202b9a0ed4..ae7d9551140 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -10,9 +10,8 @@ import voluptuous as vol from homeassistant.core import Context, HomeAssistant, async_get_hass, callback from homeassistant.helpers import config_validation as cv, singleton -from homeassistant.helpers.entity_component import EntityComponent -from .const import DOMAIN, HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT +from .const import DOMAIN_DATA, HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT from .default_agent import async_get_default_agent from .entity import ConversationEntity from .models import ( @@ -54,8 +53,7 @@ def async_get_agent( return async_get_default_agent(hass) if "." in agent_id: - entity_component: EntityComponent[ConversationEntity] = hass.data[DOMAIN] - return entity_component.get_entity(agent_id) + return hass.data[DOMAIN_DATA].get_entity(agent_id) manager = get_agent_manager(hass) diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index 14b2d1d4955..b7e45142f8f 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -1,6 +1,16 @@ """Const for conversation integration.""" +from __future__ import annotations + from enum import IntFlag +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from .entity import ConversationEntity DOMAIN = "conversation" DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} @@ -15,6 +25,8 @@ ATTR_CONVERSATION_ID = "conversation_id" SERVICE_PROCESS = "process" SERVICE_RELOAD = "reload" +DOMAIN_DATA: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN) + class ConversationEntityFeature(IntFlag): """Supported features of the conversation entity.""" diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 591298cbac1..982575b9957 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -19,7 +19,6 @@ from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import MATCH_ALL from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, intent -from homeassistant.helpers.entity_component import EntityComponent from homeassistant.util import language as language_util from .agent_manager import ( @@ -28,7 +27,7 @@ from .agent_manager import ( async_get_agent, get_agent_manager, ) -from .const import DOMAIN +from .const import DOMAIN_DATA from .default_agent import ( METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE, @@ -113,13 +112,11 @@ async def websocket_list_agents( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """List conversation agents and, optionally, if they support a given language.""" - entity_component: EntityComponent[ConversationEntity] = hass.data[DOMAIN] - country = msg.get("country") language = msg.get("language") agents = [] - for entity in entity_component.entities: + for entity in hass.data[DOMAIN_DATA].entities: supported_languages = entity.supported_languages if language and supported_languages != MATCH_ALL: supported_languages = language_util.matches( diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index d2ec6bee8fa..d64358896ba 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -41,11 +41,13 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER # noqa: F401 _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[CoverEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -151,7 +153,7 @@ def is_closed(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for covers.""" - component = hass.data[DOMAIN] = EntityComponent[CoverEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[CoverEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -231,14 +233,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[CoverEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[CoverEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class CoverEntityDescription(EntityDescription, frozen_or_thawed=True): From 90f691fa2c48517eea3a6181a09e0cc8d4ebf14c Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Fri, 20 Sep 2024 12:07:38 +0200 Subject: [PATCH 0863/1309] Mark current position sensor for Matter switch as default disabled (#126254) --- homeassistant/components/matter/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 94102151e17..ee780993a55 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -480,6 +480,7 @@ DISCOVERY_SCHEMAS = [ state_class=SensorStateClass.MEASUREMENT, translation_key="switch_current_position", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), entity_class=MatterSensor, required_attributes=(clusters.Switch.Attributes.CurrentPosition,), From 7433d2eca998a20a1d79b84ece81fdda76a4da48 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Fri, 20 Sep 2024 06:11:51 -0400 Subject: [PATCH 0864/1309] Add broken link and missing device lists to insteon configuration panel (#119715) * Add broken link and missing device lists * Fix incorrect import * Add tests * Bump pyinsteon * Typing --- .../components/insteon/api/__init__.py | 6 ++ homeassistant/components/insteon/api/aldb.py | 52 +++++++++++++- .../components/insteon/api/config.py | 71 ++++++++++++++++++- .../components/insteon/api/device.py | 18 +---- homeassistant/components/insteon/utils.py | 15 ++++ tests/components/insteon/mock_devices.py | 8 +++ tests/components/insteon/test_api_aldb.py | 36 ++++++++++ tests/components/insteon/test_api_config.py | 56 +++++++++++++++ tests/components/insteon/test_api_device.py | 6 +- 9 files changed, 245 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py index b19b1912340..d277a4b3caf 100644 --- a/homeassistant/components/insteon/api/__init__.py +++ b/homeassistant/components/insteon/api/__init__.py @@ -14,13 +14,16 @@ from .aldb import ( websocket_get_aldb, websocket_load_aldb, websocket_notify_on_aldb_status, + websocket_notify_on_aldb_status_all, websocket_reset_aldb, websocket_write_aldb, ) from .config import ( websocket_add_device_override, + websocket_get_broken_links, websocket_get_config, websocket_get_modem_schema, + websocket_get_unknown_devices, websocket_remove_device_override, websocket_update_modem_config, ) @@ -70,6 +73,7 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_notify_on_aldb_status) websocket_api.async_register_command(hass, websocket_add_x10_device) websocket_api.async_register_command(hass, websocket_remove_device) + websocket_api.async_register_command(hass, websocket_notify_on_aldb_status_all) websocket_api.async_register_command(hass, websocket_get_properties) websocket_api.async_register_command(hass, websocket_change_properties_record) @@ -82,6 +86,8 @@ def async_load_api(hass): websocket_api.async_register_command(hass, websocket_update_modem_config) websocket_api.async_register_command(hass, websocket_add_device_override) websocket_api.async_register_command(hass, websocket_remove_device_override) + websocket_api.async_register_command(hass, websocket_get_broken_links) + websocket_api.async_register_command(hass, websocket_get_unknown_devices) async def async_register_insteon_frontend(hass: HomeAssistant): diff --git a/homeassistant/components/insteon/api/aldb.py b/homeassistant/components/insteon/api/aldb.py index 663dcf4dffd..ffc846fe6c3 100644 --- a/homeassistant/components/insteon/api/aldb.py +++ b/homeassistant/components/insteon/api/aldb.py @@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from ..const import DEVICE_ADDRESS, ID, INSTEON_DEVICE_NOT_FOUND, TYPE -from .device import async_device_name, notify_device_not_found +from ..utils import async_device_name +from .device import notify_device_not_found ALDB_RECORD = "record" ALDB_RECORD_SCHEMA = vol.Schema( @@ -59,6 +60,13 @@ async def async_reload_and_save_aldb(hass, device): await devices.async_save(workdir=hass.config.config_dir) +def any_aldb_loading() -> bool: + """Identify if any All-Link Databases are loading.""" + return any( + device.aldb.status == ALDBStatus.LOADING for _, device in devices.items() + ) + + @websocket_api.websocket_command( {vol.Required(TYPE): "insteon/aldb/get", vol.Required(DEVICE_ADDRESS): str} ) @@ -293,3 +301,45 @@ async def websocket_notify_on_aldb_status( device.aldb.subscribe_status_changed(aldb_loaded) connection.send_result(msg[ID]) + + +@websocket_api.websocket_command({vol.Required(TYPE): "insteon/aldb/notify_all"}) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_notify_on_aldb_status_all( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Tell Insteon all ALDBs are loaded.""" + + @callback + def aldb_status_changed(status: ALDBStatus) -> None: + """Forward ALDB loaded event to websocket.""" + + forward_data = { + "type": "status", + "is_loading": any_aldb_loading(), + } + connection.send_message(websocket_api.event_message(msg["id"], forward_data)) + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for device in devices.values(): + device.aldb.unsubscribe_status_changed(aldb_status_changed) + + forward_data = {"type": "unsubscribed"} + connection.send_message(websocket_api.event_message(msg["id"], forward_data)) + + connection.subscriptions[msg["id"]] = async_cleanup + for device in devices.values(): + device.aldb.subscribe_status_changed(aldb_status_changed) + + connection.send_result(msg[ID]) + + forward_data = { + "type": "status", + "is_loading": any_aldb_loading(), + } + connection.send_message(websocket_api.event_message(msg["id"], forward_data)) diff --git a/homeassistant/components/insteon/api/config.py b/homeassistant/components/insteon/api/config.py index 88c062c3271..70baa4b8ee9 100644 --- a/homeassistant/components/insteon/api/config.py +++ b/homeassistant/components/insteon/api/config.py @@ -6,6 +6,9 @@ from typing import Any, TypedDict from pyinsteon import async_close, async_connect, devices from pyinsteon.address import Address +from pyinsteon.aldb.aldb_record import ALDBRecord +from pyinsteon.constants import LinkStatus +from pyinsteon.managers.link_manager import get_broken_links import voluptuous as vol import voluptuous_serialize @@ -13,6 +16,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_DEVICE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from ..const import ( @@ -34,7 +38,7 @@ from ..schemas import ( build_plm_manual_schema, build_plm_schema, ) -from ..utils import async_get_usb_ports +from ..utils import async_device_name, async_get_usb_ports HUB_V1_SCHEMA = build_hub_schema(hub_version=1) HUB_V2_SCHEMA = build_hub_schema(hub_version=2) @@ -134,6 +138,30 @@ def remove_device_override(hass: HomeAssistant, address: Address): hass.config_entries.async_update_entry(entry=config_entry, options=new_options) +async def async_link_to_dict( + address: Address, record: ALDBRecord, dev_registry: dr.DeviceRegistry, status=None +) -> dict[str, str | int]: + """Convert a link to a dictionary.""" + link_dict: dict[str, str | int] = {} + device_name = await async_device_name(dev_registry, address) + target_name = await async_device_name(dev_registry, record.target) + link_dict["address"] = str(address) + link_dict["device_name"] = device_name if device_name else str(address) + link_dict["mem_addr"] = record.mem_addr + link_dict["in_use"] = record.is_in_use + link_dict["group"] = record.group + link_dict["is_controller"] = record.is_controller + link_dict["highwater"] = record.is_high_water_mark + link_dict["target"] = str(record.target) + link_dict["target_name"] = target_name if target_name else str(record.target) + link_dict["data1"] = record.data1 + link_dict["data2"] = record.data2 + link_dict["data3"] = record.data3 + if status: + link_dict["status"] = status.name.lower() + return link_dict + + async def _async_connect(**kwargs): """Connect to the Insteon modem.""" if devices.modem: @@ -270,3 +298,44 @@ async def websocket_remove_device_override( remove_device_override(hass, address) async_dispatcher_send(hass, SIGNAL_REMOVE_DEVICE_OVERRIDE, address) connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + {vol.Required(TYPE): "insteon/config/get_broken_links"} +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_broken_links( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get any broken links between devices.""" + broken_links = get_broken_links(devices=devices) + dev_registry = dr.async_get(hass) + broken_links_list = [ + await async_link_to_dict(address, record, dev_registry, status) + for address, record, status in broken_links + if status != LinkStatus.MISSING_TARGET + ] + connection.send_result(msg[ID], broken_links_list) + + +@websocket_api.websocket_command( + {vol.Required(TYPE): "insteon/config/get_unknown_devices"} +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_unknown_devices( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get any broken links between devices.""" + broken_links = get_broken_links(devices=devices) + unknown_devices = { + str(record.target) + for _, record, status in broken_links + if status == LinkStatus.MISSING_TARGET + } + connection.send_result(msg[ID], unknown_devices) diff --git a/homeassistant/components/insteon/api/device.py b/homeassistant/components/insteon/api/device.py index ff688eef40c..cd2b992c706 100644 --- a/homeassistant/components/insteon/api/device.py +++ b/homeassistant/components/insteon/api/device.py @@ -26,6 +26,7 @@ from ..const import ( TYPE, ) from ..schemas import build_x10_schema +from ..utils import compute_device_name from .config import add_x10_device, remove_device_override, remove_x10_device X10_DEVICE = "x10_device" @@ -33,11 +34,6 @@ X10_DEVICE_SCHEMA = build_x10_schema() REMOVE_ALL_REFS = "remove_all_refs" -def compute_device_name(ha_device): - """Return the HA device name.""" - return ha_device.name_by_user if ha_device.name_by_user else ha_device.name - - async def async_add_devices(address, multiple): """Add one or more Insteon devices.""" async for _ in devices.async_add_device(address=address, multiple=multiple): @@ -52,20 +48,10 @@ def get_insteon_device_from_ha_device(ha_device): return None -async def async_device_name(dev_registry, address): - """Get the Insteon device name from a device registry id.""" - ha_device = dev_registry.async_get_device(identifiers={(DOMAIN, str(address))}) - if not ha_device: - if device := devices[address]: - return f"{device.description} ({device.model})" - return "" - return compute_device_name(ha_device) - - def notify_device_not_found(connection, msg, text): """Notify the caller that the device was not found.""" connection.send_message( - websocket_api.error_message(msg[ID], websocket_api.ERR_NOT_FOUND, text) + websocket_api.error_message(msg[ID], websocket_api.const.ERR_NOT_FOUND, text) ) diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 7c598b476a4..5b1d6379328 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -471,3 +471,18 @@ def get_usb_ports() -> dict[str, str]: async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: """Return a dict of USB ports and their friendly names.""" return await hass.async_add_executor_job(get_usb_ports) + + +def compute_device_name(ha_device) -> str: + """Return the HA device name.""" + return ha_device.name_by_user if ha_device.name_by_user else ha_device.name + + +async def async_device_name(dev_registry: dr.DeviceRegistry, address: Address) -> str: + """Get the Insteon device name from a device registry id.""" + ha_device = dev_registry.async_get_device(identifiers={(DOMAIN, str(address))}) + if not ha_device: + if device := devices[address]: + return f"{device.description} ({device.model})" + return "" + return compute_device_name(ha_device) diff --git a/tests/components/insteon/mock_devices.py b/tests/components/insteon/mock_devices.py index 2c385c337fd..05db45d00ac 100644 --- a/tests/components/insteon/mock_devices.py +++ b/tests/components/insteon/mock_devices.py @@ -168,6 +168,14 @@ class MockDevices: yield address await asyncio.sleep(0.01) + def values(self): + """Return the devices.""" + return self._devices.values() + + def items(self): + """Return the address, device pair.""" + return self._devices.items() + def subscribe(self, listener, force_strong_ref=False): """Mock the subscribe function.""" subscribe_topic(listener, DEVICE_LIST_CHANGED) diff --git a/tests/components/insteon/test_api_aldb.py b/tests/components/insteon/test_api_aldb.py index 9f3c78b4b39..bdb749836e2 100644 --- a/tests/components/insteon/test_api_aldb.py +++ b/tests/components/insteon/test_api_aldb.py @@ -1,5 +1,6 @@ """Test the Insteon All-Link Database APIs.""" +import asyncio import json from typing import Any from unittest.mock import patch @@ -332,3 +333,38 @@ async def test_bad_address( msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND + + +async def test_notify_on_aldb_loading( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aldb_data +) -> None: + """Test tracking changes to ALDB status across all devices.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json_auto_id({TYPE: "insteon/aldb/notify_all"}) + msg = await ws_client.receive_json() + assert msg["success"] + + await asyncio.sleep(0.1) + msg = await ws_client.receive_json() + assert msg["event"]["type"] == "status" + assert not msg["event"]["is_loading"] + + device = devices["333333"] + device.aldb._update_status(ALDBStatus.LOADING) + await asyncio.sleep(0.1) + msg = await ws_client.receive_json() + assert msg["event"]["type"] == "status" + assert msg["event"]["is_loading"] + + device.aldb._update_status(ALDBStatus.LOADED) + await asyncio.sleep(0.1) + msg = await ws_client.receive_json() + assert msg["event"]["type"] == "status" + assert not msg["event"]["is_loading"] + + await ws_client.client.session.close() + + # Allow lingering tasks to complete + await asyncio.sleep(0.1) diff --git a/tests/components/insteon/test_api_config.py b/tests/components/insteon/test_api_config.py index 7c922338638..212b05b74b0 100644 --- a/tests/components/insteon/test_api_config.py +++ b/tests/components/insteon/test_api_config.py @@ -1,7 +1,10 @@ """Test the Insteon APIs for configuring the integration.""" +import asyncio +import json from unittest.mock import patch +from homeassistant.components import insteon from homeassistant.components.insteon.api.device import ID, TYPE from homeassistant.components.insteon.const import ( CONF_HUB_VERSION, @@ -18,8 +21,10 @@ from .const import ( MOCK_USER_INPUT_PLM, ) from .mock_connection import mock_failed_connection, mock_successful_connection +from .mock_devices import MockDevices from .mock_setup import async_mock_setup +from tests.common import load_fixture from tests.typing import WebSocketGenerator @@ -389,3 +394,54 @@ async def test_remove_device_override_no_overrides( config_entry = hass.config_entries.async_get_entry("abcde12345") assert not config_entry.options.get(CONF_OVERRIDE) + + +async def test_get_broken_links( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting broken ALDB links.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + devices = MockDevices() + await devices.async_load() + aldb_data = json.loads(load_fixture("insteon/aldb_data.json")) + devices.fill_aldb("33.33.33", aldb_data) + with patch.object(insteon.api.config, "devices", devices): + await ws_client.send_json({ID: 2, TYPE: "insteon/config/get_broken_links"}) + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(msg["result"]) == 5 + + +async def test_get_unknown_devices( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test getting unknown Insteon devices.""" + + ws_client, _, _, _ = await async_mock_setup(hass, hass_ws_client) + devices = MockDevices() + await devices.async_load() + aldb_data = { + "4095": { + "memory": 4095, + "in_use": True, + "controller": False, + "high_water_mark": False, + "bit5": True, + "bit4": False, + "group": 0, + "target": "FFFFFF", + "data1": 0, + "data2": 0, + "data3": 0, + }, + } + devices.fill_aldb("33.33.33", aldb_data) + with patch.object(insteon.api.config, "devices", devices): + await ws_client.send_json({ID: 2, TYPE: "insteon/config/get_unknown_devices"}) + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(msg["result"]) == 1 + await asyncio.sleep(0.1) diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py index 29d601eb3ef..6f1a174f024 100644 --- a/tests/components/insteon/test_api_device.py +++ b/tests/components/insteon/test_api_device.py @@ -16,7 +16,6 @@ from homeassistant.components.insteon.api.device import ( ID, INSTEON_DEVICE_NOT_FOUND, TYPE, - async_device_name, ) from homeassistant.components.insteon.const import ( CONF_OVERRIDE, @@ -24,6 +23,7 @@ from homeassistant.components.insteon.const import ( DOMAIN, MULTIPLE, ) +from homeassistant.components.insteon.utils import async_device_name from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -129,10 +129,6 @@ async def test_get_ha_device_name( name = await async_device_name(device_reg, "11.11.11") assert name == "Device 11.11.11" - # Test no HA device but a real Insteon device - name = await async_device_name(device_reg, "22.22.22") - assert name == "Device 22.22.22 (2)" - # Test no HA or Insteon device name = await async_device_name(device_reg, "BB.BB.BB") assert name == "" From cd95c133af8e820e0869331d355cd1aed39ac2bc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Sep 2024 12:25:51 +0200 Subject: [PATCH 0865/1309] Enable all TID ruff rules (#126312) * Enable ruff rule TID252 * One more * comment --- homeassistant/components/trace/websocket_api.py | 2 +- homeassistant/helpers/config_validation.py | 3 ++- homeassistant/helpers/discovery.py | 2 +- pyproject.toml | 7 ++++++- tests/helpers/test_debounce.py | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/trace/websocket_api.py b/homeassistant/components/trace/websocket_api.py index f1ea6133d43..f5572e5e4ac 100644 --- a/homeassistant/components/trace/websocket_api.py +++ b/homeassistant/components/trace/websocket_api.py @@ -26,7 +26,7 @@ from homeassistant.helpers.script import ( debug_stop, ) -from .. import trace +from .. import trace # noqa: TID252 (see PR 125822) TRACE_DOMAINS = ("automation", "script") diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 6a92599921b..fd8d54fc6e0 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1119,7 +1119,8 @@ def custom_serializer(schema: Any) -> Any: def _custom_serializer(schema: Any, *, allow_section: bool) -> Any: """Serialize additional types for voluptuous_serialize.""" - from .. import data_entry_flow # pylint: disable=import-outside-toplevel + from homeassistant import data_entry_flow # pylint: disable=import-outside-toplevel + from . import selector # pylint: disable=import-outside-toplevel if schema is positive_time_period_dict: diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 9f656dad56c..7c1b5ac4a64 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -14,8 +14,8 @@ from typing import Any, TypedDict from homeassistant import core, setup from homeassistant.const import Platform from homeassistant.loader import bind_hass +from homeassistant.util.signal_type import SignalTypeFormat -from ..util.signal_type import SignalTypeFormat from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal from .typing import ConfigType, DiscoveryInfoType diff --git a/pyproject.toml b/pyproject.toml index a7d772ea601..913ddfb23e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -773,7 +773,7 @@ select = [ "T100", # Trace found: {name} used "T20", # flake8-print "TCH", # flake8-type-checking - "TID251", # Banned imports + "TID", # Tidy imports "TRY", # tryceratops "UP", # pyupgrade "UP031", # Use format specifiers instead of percent format @@ -911,6 +911,11 @@ split-on-trailing-comma = false "homeassistant/scripts/*" = ["T201"] "script/*" = ["T20"] +# Allow relative imports within auth and within components +"homeassistant/auth/*/*" = ["TID252"] +"homeassistant/components/*/*/*" = ["TID252"] +"tests/components/*/*/*" = ["TID252"] + # Temporary "homeassistant/**" = ["PTH"] "tests/**" = ["PTH"] diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index 84b3d19b6d7..6fa758aec6e 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import debounce from homeassistant.util.dt import utcnow -from ..common import async_fire_time_changed +from tests.common import async_fire_time_changed _LOGGER = logging.getLogger(__name__) From f93bcbaa84e29c08d8db20f9ba25ee0aa3cdf9bc Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 20 Sep 2024 12:40:16 +0200 Subject: [PATCH 0866/1309] Bump aioautomower to 2024.9.1 (#126315) --- .../husqvarna_automower/calendar.py | 32 +++++++++++++------ .../husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 12 +++---- 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index f0f5f9f4cd1..2e1d9433fb7 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -3,6 +3,8 @@ from datetime import datetime import logging +from aioautomower.model import make_name_string + from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -54,8 +56,13 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): _LOGGER.debug("program_event %s", program_event) if not program_event: return None + work_area_name = None + if self.mower_attributes.work_area_dict and program_event.work_area_id: + work_area_name = self.mower_attributes.work_area_dict[ + program_event.work_area_id + ] return CalendarEvent( - summary=program_event.schedule_name, + summary=make_name_string(work_area_name, program_event.schedule_no), start=program_event.start.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE), end=program_event.end.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE), rrule=program_event.rrule_str, @@ -75,12 +82,19 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): start_date, end_date, ) - return [ - CalendarEvent( - summary=program_event.schedule_name, - start=program_event.start.replace(tzinfo=start_date.tzinfo), - end=program_event.end.replace(tzinfo=start_date.tzinfo), - rrule=program_event.rrule_str, + calendar_events = [] + for program_event in cursor: + work_area_name = None + if self.mower_attributes.work_area_dict and program_event.work_area_id: + work_area_name = self.mower_attributes.work_area_dict[ + program_event.work_area_id + ] + calendar_events.append( + CalendarEvent( + summary=make_name_string(work_area_name, program_event.schedule_no), + start=program_event.start.replace(tzinfo=start_date.tzinfo), + end=program_event.end.replace(tzinfo=start_date.tzinfo), + rrule=program_event.rrule_str, + ) ) - for program_event in cursor - ] + return calendar_events diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 0721d65524e..84d206c3363 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", "iot_class": "cloud_push", "loggers": ["aioautomower"], - "requirements": ["aioautomower==2024.9.0"] + "requirements": ["aioautomower==2024.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4e2438f4e10..dd332313ccd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -198,7 +198,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.9.0 +aioautomower==2024.9.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ebf08cce72..67ca05e6132 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -186,7 +186,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.9.0 +aioautomower==2024.9.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 5793fc3d50c..5ffb826bb4a 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -16,8 +16,7 @@ 'thursday': False, 'tuesday': False, 'wednesday': True, - 'work_area_id': 123456, - 'work_area_name': 'Front lawn', + 'workAreaId': 123456, }), dict({ 'duration': 480, @@ -29,8 +28,7 @@ 'thursday': True, 'tuesday': True, 'wednesday': False, - 'work_area_id': 123456, - 'work_area_name': 'Front lawn', + 'workAreaId': 123456, }), dict({ 'duration': 480, @@ -42,8 +40,7 @@ 'thursday': True, 'tuesday': True, 'wednesday': False, - 'work_area_id': 654321, - 'work_area_name': 'Back lawn', + 'workAreaId': 654321, }), dict({ 'duration': 480, @@ -55,8 +52,7 @@ 'thursday': True, 'tuesday': True, 'wednesday': False, - 'work_area_id': 654321, - 'work_area_name': 'Back lawn', + 'workAreaId': 654321, }), ]), }), From 76967e848db22d8134b41be65e51bb856b0d87c5 Mon Sep 17 00:00:00 2001 From: TimL Date: Fri, 20 Sep 2024 20:40:50 +1000 Subject: [PATCH 0867/1309] Refactor smlight event_function to common function (#126260) refactor event_function --- tests/components/smlight/__init__.py | 20 +++++++++++++ .../components/smlight/test_binary_sensor.py | 11 ++----- tests/components/smlight/test_update.py | 30 ++++--------------- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/tests/components/smlight/__init__.py b/tests/components/smlight/__init__.py index 37184226507..e518e0573ba 100644 --- a/tests/components/smlight/__init__.py +++ b/tests/components/smlight/__init__.py @@ -1 +1,21 @@ """Tests for the SMLIGHT Zigbee adapter integration.""" + +from collections.abc import Callable +from unittest.mock import MagicMock + +from pysmlight.const import Events as SmEvents +from pysmlight.sse import MessageEvent + + +def get_mock_event_function( + mock: MagicMock, event: SmEvents +) -> Callable[[MessageEvent], None]: + """Extract event function from mock call_args.""" + return next( + ( + call_args[0][1] + for call_args in mock.sse.register_callback.call_args_list + if call_args[0][0] == event + ), + None, + ) diff --git a/tests/components/smlight/test_binary_sensor.py b/tests/components/smlight/test_binary_sensor.py index 1b1c0358c37..b1d72b66dcf 100644 --- a/tests/components/smlight/test_binary_sensor.py +++ b/tests/components/smlight/test_binary_sensor.py @@ -1,6 +1,5 @@ """Tests for the SMLIGHT binary sensor platform.""" -from collections.abc import Callable from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory @@ -14,6 +13,7 @@ from homeassistant.const import STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import get_mock_event_function from .conftest import setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -95,13 +95,8 @@ async def test_internet_sensor_event( assert len(mock_smlight_client.get_param.mock_calls) == 2 mock_smlight_client.get_param.assert_called_with("inetState") - event_function: Callable[[MessageEvent], None] = next( - ( - call_args[0][1] - for call_args in mock_smlight_client.sse.register_callback.call_args_list - if call_args[0][0] == Events.EVENT_INET_STATE - ), - None, + event_function = get_mock_event_function( + mock_smlight_client, Events.EVENT_INET_STATE ) event_function(MOCK_INET_STATE) diff --git a/tests/components/smlight/test_update.py b/tests/components/smlight/test_update.py index b0b8910ef9b..7bff12bb027 100644 --- a/tests/components/smlight/test_update.py +++ b/tests/components/smlight/test_update.py @@ -1,6 +1,5 @@ """Tests for the SMLIGHT update platform.""" -from collections.abc import Callable from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory @@ -23,6 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from . import get_mock_event_function from .conftest import setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -67,18 +67,6 @@ MOCK_FIRMWARE_NOTES = [ ] -def get_callback_function(mock: MagicMock, trigger: SmEvents): - """Extract the callback function for a given trigger.""" - return next( - ( - call_args[0][1] - for call_args in mock.sse.register_callback.call_args_list - if trigger == call_args[0][0] - ), - None, - ) - - @pytest.fixture def platforms() -> list[Platform]: """Platforms, which should be loaded during the test.""" @@ -122,17 +110,13 @@ async def test_update_firmware( assert len(mock_smlight_client.fw_update.mock_calls) == 1 - event_function: Callable[[MessageEvent], None] = get_callback_function( - mock_smlight_client, SmEvents.ZB_FW_prgs - ) + event_function = get_mock_event_function(mock_smlight_client, SmEvents.ZB_FW_prgs) event_function(MOCK_FIRMWARE_PROGRESS) state = hass.states.get(entity_id) assert state.attributes[ATTR_IN_PROGRESS] == 50 - event_function: Callable[[MessageEvent], None] = get_callback_function( - mock_smlight_client, SmEvents.FW_UPD_done - ) + event_function = get_mock_event_function(mock_smlight_client, SmEvents.FW_UPD_done) event_function(MOCK_FIRMWARE_DONE) @@ -178,9 +162,7 @@ async def test_update_legacy_firmware_v2( assert len(mock_smlight_client.fw_update.mock_calls) == 1 - event_function: Callable[[MessageEvent], None] = get_callback_function( - mock_smlight_client, SmEvents.ESP_UPD_done - ) + event_function = get_mock_event_function(mock_smlight_client, SmEvents.ESP_UPD_done) event_function(MOCK_FIRMWARE_DONE) @@ -220,9 +202,7 @@ async def test_update_firmware_failed( assert len(mock_smlight_client.fw_update.mock_calls) == 1 - event_function: Callable[[MessageEvent], None] = get_callback_function( - mock_smlight_client, SmEvents.ZB_FW_err - ) + event_function = get_mock_event_function(mock_smlight_client, SmEvents.ZB_FW_err) async def _call_event_function(event: MessageEvent): event_function(event) From 184580257dce6b6204006487794d79c6509804d2 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Fri, 20 Sep 2024 12:53:15 +0200 Subject: [PATCH 0868/1309] Add battery data to Autarco integration (#125924) * Rename site to account_site * Add battery service with entities * Test UpdateFailed exception in coordinator * Add battery data to diagnostics report * Add TOTAL state_class where needed * Fix --------- Co-authored-by: Joostlek --- .../components/autarco/coordinator.py | 33 +- .../components/autarco/diagnostics.py | 23 +- homeassistant/components/autarco/sensor.py | 130 +++++- homeassistant/components/autarco/strings.json | 24 + tests/components/autarco/conftest.py | 13 +- .../autarco/snapshots/test_diagnostics.ambr | 11 + .../autarco/snapshots/test_sensor.ambr | 418 +++++++++++++++++- tests/components/autarco/test_sensor.py | 36 +- 8 files changed, 669 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/autarco/coordinator.py b/homeassistant/components/autarco/coordinator.py index 82eb4439a86..5dd19478ae8 100644 --- a/homeassistant/components/autarco/coordinator.py +++ b/homeassistant/components/autarco/coordinator.py @@ -4,11 +4,19 @@ from __future__ import annotations from typing import NamedTuple -from autarco import AccountSite, Autarco, Inverter, Solar +from autarco import ( + AccountSite, + Autarco, + AutarcoConnectionError, + Battery, + Inverter, + Site, + Solar, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER, SCAN_INTERVAL @@ -18,6 +26,8 @@ class AutarcoData(NamedTuple): solar: Solar inverters: dict[str, Inverter] + site: Site + battery: Battery | None class AutarcoDataUpdateCoordinator(DataUpdateCoordinator[AutarcoData]): @@ -29,7 +39,7 @@ class AutarcoDataUpdateCoordinator(DataUpdateCoordinator[AutarcoData]): self, hass: HomeAssistant, client: Autarco, - site: AccountSite, + account_site: AccountSite, ) -> None: """Initialize global Autarco data updater.""" super().__init__( @@ -39,11 +49,22 @@ class AutarcoDataUpdateCoordinator(DataUpdateCoordinator[AutarcoData]): update_interval=SCAN_INTERVAL, ) self.client = client - self.site = site + self.account_site = account_site async def _async_update_data(self) -> AutarcoData: """Fetch data from Autarco API.""" + battery = None + try: + site = await self.client.get_site(self.account_site.public_key) + solar = await self.client.get_solar(self.account_site.public_key) + inverters = await self.client.get_inverters(self.account_site.public_key) + if site.has_battery: + battery = await self.client.get_battery(self.account_site.public_key) + except AutarcoConnectionError as error: + raise UpdateFailed(error) from error return AutarcoData( - solar=await self.client.get_solar(self.site.public_key), - inverters=await self.client.get_inverters(self.site.public_key), + solar=solar, + inverters=inverters, + site=site, + battery=battery, ) diff --git a/homeassistant/components/autarco/diagnostics.py b/homeassistant/components/autarco/diagnostics.py index d1b082fd307..c865a38ffd8 100644 --- a/homeassistant/components/autarco/diagnostics.py +++ b/homeassistant/components/autarco/diagnostics.py @@ -18,9 +18,9 @@ async def async_get_config_entry_diagnostics( return { "sites_data": [ { - "id": coordinator.site.site_id, - "name": coordinator.site.system_name, - "health": coordinator.site.health, + "id": coordinator.account_site.site_id, + "name": coordinator.account_site.system_name, + "health": coordinator.account_site.health, "solar": { "power_production": coordinator.data.solar.power_production, "energy_production_today": coordinator.data.solar.energy_production_today, @@ -37,6 +37,23 @@ async def async_get_config_entry_diagnostics( } for inverter in coordinator.data.inverters.values() ], + **( + { + "battery": { + "flow_now": coordinator.data.battery.flow_now, + "net_charged_now": coordinator.data.battery.net_charged_now, + "state_of_charge": coordinator.data.battery.state_of_charge, + "discharged_today": coordinator.data.battery.discharged_today, + "discharged_month": coordinator.data.battery.discharged_month, + "discharged_total": coordinator.data.battery.discharged_total, + "charged_today": coordinator.data.battery.charged_today, + "charged_month": coordinator.data.battery.charged_month, + "charged_total": coordinator.data.battery.charged_total, + } + } + if coordinator.data.battery is not None + else {} + ), } for coordinator in autarco_data ], diff --git a/homeassistant/components/autarco/sensor.py b/homeassistant/components/autarco/sensor.py index 2352cdee060..c870197a504 100644 --- a/homeassistant/components/autarco/sensor.py +++ b/homeassistant/components/autarco/sensor.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from autarco import Inverter, Solar +from autarco import Battery, Inverter, Solar from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,6 +25,81 @@ from .const import DOMAIN from .coordinator import AutarcoDataUpdateCoordinator +@dataclass(frozen=True, kw_only=True) +class AutarcoBatterySensorEntityDescription(SensorEntityDescription): + """Describes an Autarco sensor entity.""" + + value_fn: Callable[[Battery], StateType] + + +SENSORS_BATTERY: tuple[AutarcoBatterySensorEntityDescription, ...] = ( + AutarcoBatterySensorEntityDescription( + key="flow_now", + translation_key="flow_now", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda battery: battery.flow_now, + ), + AutarcoBatterySensorEntityDescription( + key="state_of_charge", + translation_key="state_of_charge", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda battery: battery.state_of_charge, + ), + AutarcoBatterySensorEntityDescription( + key="discharged_today", + translation_key="discharged_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda battery: battery.discharged_today, + ), + AutarcoBatterySensorEntityDescription( + key="discharged_month", + translation_key="discharged_month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda battery: battery.discharged_month, + ), + AutarcoBatterySensorEntityDescription( + key="discharged_total", + translation_key="discharged_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda battery: battery.discharged_total, + ), + AutarcoBatterySensorEntityDescription( + key="charged_today", + translation_key="charged_today", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda battery: battery.charged_today, + ), + AutarcoBatterySensorEntityDescription( + key="charged_month", + translation_key="charged_month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value_fn=lambda battery: battery.charged_month, + ), + AutarcoBatterySensorEntityDescription( + key="charged_total", + translation_key="charged_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda battery: battery.charged_total, + ), +) + + @dataclass(frozen=True, kw_only=True) class AutarcoSolarSensorEntityDescription(SensorEntityDescription): """Describes an Autarco sensor entity.""" @@ -46,6 +121,7 @@ SENSORS_SOLAR: tuple[AutarcoSolarSensorEntityDescription, ...] = ( translation_key="energy_production_today", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, value_fn=lambda solar: solar.energy_production_today, ), AutarcoSolarSensorEntityDescription( @@ -53,6 +129,7 @@ SENSORS_SOLAR: tuple[AutarcoSolarSensorEntityDescription, ...] = ( translation_key="energy_production_month", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, value_fn=lambda solar: solar.energy_production_month, ), AutarcoSolarSensorEntityDescription( @@ -117,9 +194,52 @@ async def async_setup_entry( for description in SENSORS_INVERTER for inverter in coordinator.data.inverters ) + if coordinator.data.battery: + entities.extend( + AutarcoBatterySensorEntity( + coordinator=coordinator, + description=description, + ) + for description in SENSORS_BATTERY + ) async_add_entities(entities) +class AutarcoBatterySensorEntity( + CoordinatorEntity[AutarcoDataUpdateCoordinator], SensorEntity +): + """Defines an Autarco battery sensor.""" + + entity_description: AutarcoBatterySensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + *, + coordinator: AutarcoDataUpdateCoordinator, + description: AutarcoBatterySensorEntityDescription, + ) -> None: + """Initialize Autarco sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.account_site.site_id}_battery_{description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.account_site.site_id}_battery")}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Autarco", + name="Battery", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + assert self.coordinator.data.battery is not None + return self.entity_description.value_fn(self.coordinator.data.battery) + + class AutarcoSolarSensorEntity( CoordinatorEntity[AutarcoDataUpdateCoordinator], SensorEntity ): @@ -138,9 +258,11 @@ class AutarcoSolarSensorEntity( super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.site.site_id}_solar_{description.key}" + self._attr_unique_id = ( + f"{coordinator.account_site.site_id}_solar_{description.key}" + ) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{coordinator.site.site_id}_solar")}, + identifiers={(DOMAIN, f"{coordinator.account_site.site_id}_solar")}, entry_type=DeviceEntryType.SERVICE, manufacturer="Autarco", name="Solar", diff --git a/homeassistant/components/autarco/strings.json b/homeassistant/components/autarco/strings.json index 2eff962a13a..8eda5fe0411 100644 --- a/homeassistant/components/autarco/strings.json +++ b/homeassistant/components/autarco/strings.json @@ -23,6 +23,30 @@ }, "entity": { "sensor": { + "flow_now": { + "name": "Flow now" + }, + "state_of_charge": { + "name": "State of charge" + }, + "discharged_today": { + "name": "Discharged today" + }, + "discharged_month": { + "name": "Discharged month" + }, + "discharged_total": { + "name": "Discharged total" + }, + "charged_today": { + "name": "Charged today" + }, + "charged_month": { + "name": "Charged month" + }, + "charged_total": { + "name": "Charged total" + }, "power_production": { "name": "Power production" }, diff --git a/tests/components/autarco/conftest.py b/tests/components/autarco/conftest.py index c7a95d7aa23..b35ea993600 100644 --- a/tests/components/autarco/conftest.py +++ b/tests/components/autarco/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from autarco import AccountSite, Inverter, Solar +from autarco import AccountSite, Battery, Inverter, Solar import pytest from homeassistant.components.autarco.const import DOMAIN @@ -66,6 +66,17 @@ def mock_autarco_client() -> Generator[AsyncMock]: health="OK", ), } + client.get_battery.return_value = Battery( + flow_now=777, + net_charged_now=777, + state_of_charge=56, + discharged_today=2, + discharged_month=25, + discharged_total=696, + charged_today=1, + charged_month=26, + charged_total=748, + ) yield client diff --git a/tests/components/autarco/snapshots/test_diagnostics.ambr b/tests/components/autarco/snapshots/test_diagnostics.ambr index 53d9f96fb86..876e6d6b727 100644 --- a/tests/components/autarco/snapshots/test_diagnostics.ambr +++ b/tests/components/autarco/snapshots/test_diagnostics.ambr @@ -3,6 +3,17 @@ dict({ 'sites_data': list([ dict({ + 'battery': dict({ + 'charged_month': 26, + 'charged_today': 1, + 'charged_total': 748, + 'discharged_month': 25, + 'discharged_today': 2, + 'discharged_total': 696, + 'flow_now': 777, + 'net_charged_now': 777, + 'state_of_charge': 56, + }), 'health': 'OK', 'id': 1, 'inverters': list([ diff --git a/tests/components/autarco/snapshots/test_sensor.ambr b/tests/components/autarco/snapshots/test_sensor.ambr index 0aa093d6a6d..dbbd8e9b47d 100644 --- a/tests/components/autarco/snapshots/test_sensor.ambr +++ b/tests/components/autarco/snapshots/test_sensor.ambr @@ -1,4 +1,412 @@ # serializer version: 1 +# name: test_all_sensors[sensor.battery_charged_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_charged_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charged month', + 'platform': 'autarco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charged_month', + 'unique_id': '1_battery_charged_month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.battery_charged_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Battery Charged month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_charged_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- +# name: test_all_sensors[sensor.battery_charged_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_charged_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charged today', + 'platform': 'autarco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charged_today', + 'unique_id': '1_battery_charged_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.battery_charged_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Battery Charged today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_charged_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_sensors[sensor.battery_charged_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_charged_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charged total', + 'platform': 'autarco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charged_total', + 'unique_id': '1_battery_charged_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.battery_charged_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Battery Charged total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_charged_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '748', + }) +# --- +# name: test_all_sensors[sensor.battery_discharged_month-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_discharged_month', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharged month', + 'platform': 'autarco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'discharged_month', + 'unique_id': '1_battery_discharged_month', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.battery_discharged_month-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Battery Discharged month', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_discharged_month', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_all_sensors[sensor.battery_discharged_today-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_discharged_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharged today', + 'platform': 'autarco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'discharged_today', + 'unique_id': '1_battery_discharged_today', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.battery_discharged_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Battery Discharged today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_discharged_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_all_sensors[sensor.battery_discharged_total-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_discharged_total', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Discharged total', + 'platform': 'autarco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'discharged_total', + 'unique_id': '1_battery_discharged_total', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.battery_discharged_total-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Battery Discharged total', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_discharged_total', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '696', + }) +# --- +# name: test_all_sensors[sensor.battery_flow_now-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_flow_now', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flow now', + 'platform': 'autarco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flow_now', + 'unique_id': '1_battery_flow_now', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.battery_flow_now-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Battery Flow now', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.battery_flow_now', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '777', + }) +# --- +# name: test_all_sensors[sensor.battery_state_of_charge-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.battery_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'autarco', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'state_of_charge', + 'unique_id': '1_battery_state_of_charge', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_sensors[sensor.battery_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Battery State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.battery_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '56', + }) +# --- # name: test_all_sensors[sensor.inverter_test_serial_1_energy_ac_output_total-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -208,7 +616,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -241,6 +651,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Solar Energy production month', + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -256,7 +667,9 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'device_class': None, 'device_id': , @@ -289,6 +702,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'energy', 'friendly_name': 'Solar Energy production today', + 'state_class': , 'unit_of_measurement': , }), 'context': , diff --git a/tests/components/autarco/test_sensor.py b/tests/components/autarco/test_sensor.py index e5e823501b9..c7e65baba70 100644 --- a/tests/components/autarco/test_sensor.py +++ b/tests/components/autarco/test_sensor.py @@ -1,16 +1,20 @@ """Test the sensor provided by the Autarco integration.""" -from unittest.mock import MagicMock, patch +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch +from autarco import AutarcoConnectionError +from freezegun.api import FrozenDateTimeFactory from syrupy import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_all_sensors( @@ -25,3 +29,29 @@ async def test_all_sensors( await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_update_failed( + hass: HomeAssistant, + mock_autarco_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entities become unavailable after failed update.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert ( + hass.states.get("sensor.inverter_test_serial_1_energy_ac_output_total").state + is not None + ) + + mock_autarco_client.get_solar.side_effect = AutarcoConnectionError + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.inverter_test_serial_1_energy_ac_output_total").state + == STATE_UNAVAILABLE + ) From 41ffa8d6db5c45a5d14e01ef3d0c503c067c2df6 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Fri, 20 Sep 2024 14:19:45 +0200 Subject: [PATCH 0869/1309] Add login and rewrite access to extended data for solarlog (#126024) * Initial commit * Add/update tests * Minor adjustment * Update data_schema * Adjust get password * Set const for has_password, remove deletion of extended_data * Update diagnostics snapshot * Correct typo * Add test for migration from mv 2 to 3 * Adjust migration test --- homeassistant/components/solarlog/__init__.py | 6 +- .../components/solarlog/config_flow.py | 128 +++++++++++-- homeassistant/components/solarlog/const.py | 2 + .../components/solarlog/coordinator.py | 42 ++++- .../components/solarlog/strings.json | 25 ++- tests/components/solarlog/conftest.py | 17 +- .../solarlog/snapshots/test_diagnostics.ambr | 5 +- tests/components/solarlog/test_config_flow.py | 175 ++++++++++++++---- tests/components/solarlog/test_init.py | 109 +++++++++-- 9 files changed, 433 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index f23305ca8f2..5937c8a496d 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -7,6 +7,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from .const import CONF_HAS_PWD from .coordinator import SolarLogCoordinator _LOGGER = logging.getLogger(__name__) @@ -57,12 +58,13 @@ async def async_migrate_entry( entity.entity_id, new_unique_id=new_uid ) + if config_entry.minor_version < 3: # migrate config_entry new = {**config_entry.data} - new["extended_data"] = False + new[CONF_HAS_PWD] = False hass.config_entries.async_update_entry( - config_entry, data=new, minor_version=2, version=1 + config_entry, data=new, minor_version=3, version=1 ) _LOGGER.debug( diff --git a/homeassistant/components/solarlog/config_flow.py b/homeassistant/components/solarlog/config_flow.py index 5f047a9c844..f161fca0297 100644 --- a/homeassistant/components/solarlog/config_flow.py +++ b/homeassistant/components/solarlog/config_flow.py @@ -1,18 +1,24 @@ """Config flow for solarlog integration.""" +from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any from urllib.parse import ParseResult, urlparse from solarlog_cli.solarlog_connector import SolarLogConnector -from solarlog_cli.solarlog_exceptions import SolarLogConnectionError, SolarLogError +from solarlog_cli.solarlog_exceptions import ( + SolarLogAuthenticationError, + SolarLogConnectionError, + SolarLogError, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.util import slugify -from .const import DEFAULT_HOST, DEFAULT_NAME, DOMAIN +from . import SolarlogConfigEntry +from .const import CONF_HAS_PWD, DEFAULT_HOST, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -20,12 +26,14 @@ _LOGGER = logging.getLogger(__name__) class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for solarlog.""" + _entry: SolarlogConfigEntry | None = None VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def __init__(self) -> None: """Initialize the config flow.""" self._errors: dict = {} + self._user_input: dict = {} def _parse_url(self, host: str) -> str: """Return parsed host url.""" @@ -51,6 +59,23 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): return True + async def _test_extended_data(self, host: str, pwd: str = "") -> bool: + """Check if we get extended data from Solar-Log device.""" + response: bool = False + solarlog = SolarLogConnector(host, password=pwd) + try: + response = await solarlog.test_extended_data_available() + except SolarLogAuthenticationError: + self._errors = {CONF_HOST: "password_error"} + response = False + except SolarLogError: + self._errors = {CONF_HOST: "unknown"} + response = False + finally: + await solarlog.client.close() + + return response + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -64,6 +89,10 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_NAME] = slugify(user_input[CONF_NAME]) if await self._test_connection(user_input[CONF_HOST]): + if user_input[CONF_HAS_PWD]: + self._user_input = user_input + return await self.async_step_password() + return self.async_create_entry( title=user_input[CONF_NAME], data=user_input ) @@ -76,7 +105,33 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, - vol.Required("extended_data", default=False): bool, + vol.Required(CONF_HAS_PWD, default=False): bool, + } + ), + errors=self._errors, + ) + + async def async_step_password( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Step when user sets password .""" + self._errors = {} + if user_input is not None: + if await self._test_extended_data( + self._user_input[CONF_HOST], user_input[CONF_PASSWORD] + ): + self._user_input |= user_input + return self.async_create_entry( + title=self._user_input[CONF_NAME], data=self._user_input + ) + else: + user_input = {CONF_PASSWORD: ""} + + return self.async_show_form( + step_id="password", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, } ), errors=self._errors, @@ -93,19 +148,66 @@ class SolarLogConfigFlow(ConfigFlow, domain=DOMAIN): assert entry is not None if user_input is not None: - return self.async_update_reload_and_abort( - entry, - reason="reconfigure_successful", - data={**entry.data, **user_input}, - ) + 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, + reason="reconfigure_successful", + data={**entry.data, **user_input}, + ) + + if await self._test_extended_data( + 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, + reason="reconfigure_successful", + data={**entry.data, **user_input}, + ) return self.async_show_form( step_id="reconfigure", data_schema=vol.Schema( { - vol.Required( - "extended_data", default=entry.data["extended_data"] - ): bool, + vol.Optional(CONF_HAS_PWD, default=entry.data[CONF_HAS_PWD]): bool, + vol.Optional(CONF_PASSWORD): str, } ), ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle flow upon an API authentication error.""" + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauthorization flow.""" + + assert self._entry is not None + + if user_input and await self._test_extended_data( + self._entry.data[CONF_HOST], user_input.get(CONF_PASSWORD, "") + ): + return self.async_update_reload_and_abort( + self._entry, data={**self._entry.data, **user_input} + ) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_HAS_PWD, default=self._entry.data[CONF_HAS_PWD] + ): bool, + vol.Optional(CONF_PASSWORD): str, + } + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=data_schema, + errors=self._errors, + ) diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index 31f17af83b5..f86d103f830 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -7,3 +7,5 @@ DOMAIN = "solarlog" # Default config for solarlog. DEFAULT_HOST = "http://solar-log" DEFAULT_NAME = "solarlog" + +CONF_HAS_PWD = "has_password" diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index 5c9aa540261..51199ab7051 100644 --- a/homeassistant/components/solarlog/coordinator.py +++ b/homeassistant/components/solarlog/coordinator.py @@ -9,6 +9,7 @@ from urllib.parse import ParseResult, urlparse from solarlog_cli.solarlog_connector import SolarLogConnector from solarlog_cli.solarlog_exceptions import ( + SolarLogAuthenticationError, SolarLogConnectionError, SolarLogUpdateError, ) @@ -16,7 +17,7 @@ from solarlog_cli.solarlog_models import SolarlogData from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) @@ -35,6 +36,7 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): ) host_entry = entry.data[CONF_HOST] + password = entry.data.get("password", "") url = urlparse(host_entry, "http") netloc = url.netloc or url.path @@ -45,12 +47,18 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): self.host = url.geturl() self.solarlog = SolarLogConnector( - self.host, entry.data["extended_data"], hass.config.time_zone + self.host, + tz=hass.config.time_zone, + password=password, ) async def _async_setup(self) -> None: """Do initialization logic.""" - if self.solarlog.extended_data: + _LOGGER.debug("Start async_setup") + logged_in = False + if self.solarlog.password != "": + logged_in = await self.renew_authentication() + if logged_in or await self.solarlog.test_extended_data_available(): device_list = await self.solarlog.update_device_list() self.solarlog.set_enabled_devices({key: True for key in device_list}) @@ -63,11 +71,31 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]): if self.solarlog.extended_data: await self.solarlog.update_device_list() data.inverter_data = await self.solarlog.update_inverter_data() - except SolarLogConnectionError as err: - raise ConfigEntryNotReady(err) from err - except SolarLogUpdateError as err: - raise UpdateFailed(err) from err + except SolarLogConnectionError as ex: + raise ConfigEntryNotReady(ex) from ex + except SolarLogAuthenticationError as ex: + if await self.renew_authentication(): + # login was successful, update availability of extended data, retry data update + await self.solarlog.test_extended_data_available() + raise ConfigEntryNotReady from ex + raise ConfigEntryAuthFailed from ex + except SolarLogUpdateError as ex: + raise UpdateFailed(ex) from ex _LOGGER.debug("Data successfully updated") return data + + async def renew_authentication(self) -> bool: + """Renew access token for SolarLog API.""" + logged_in = False + try: + logged_in = await self.solarlog.login() + except SolarLogAuthenticationError as ex: + raise ConfigEntryAuthFailed from ex + except (SolarLogConnectionError, SolarLogUpdateError) as ex: + raise ConfigEntryNotReady from ex + + _LOGGER.debug("Credentials successfully updated? %s", logged_in) + + return logged_in diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index f5f5e064294..7dc7dbb84bb 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -6,26 +6,45 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "name": "The prefix to be used for your Solar-Log sensors", - "extended_data": "Get additional data from Solar-Log. Extended data is only accessible, if no password is set for the Solar-Log. Use at your own risk!" + "has_password": "I have the password for the Solar-Log user account." }, "data_description": { - "host": "The hostname or IP address of your Solar-Log device." + "host": "The hostname or IP address of your Solar-Log device.", + "has_password": "The password is required, if the open JSON-API is deactivated or if you would like to access additional data provided by your Solar-Log device." + } + }, + "password": { + "title": "Define your Solar-Log connection", + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The password for the general user of your Solar-Log device." + } + }, + "reauth_confirm": { + "description": "Update your credentials for Solar-Log device", + "data": { + "has_password": "[%key:component::solarlog::config::step::user::data::has_password%]", + "password": "[%key:common::config_flow::data::password%]" } }, "reconfigure": { "title": "Configure SolarLog", "data": { - "extended_data": "[%key:component::solarlog::config::step::user::data::extended_data%]" + "has_password": "[%key:component::solarlog::config::step::user::data::has_password%]" } } }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "password_error": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, diff --git a/tests/components/solarlog/conftest.py b/tests/components/solarlog/conftest.py index 1b315fa3e8c..22b85a590ff 100644 --- a/tests/components/solarlog/conftest.py +++ b/tests/components/solarlog/conftest.py @@ -6,8 +6,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from solarlog_cli.solarlog_models import InverterData, SolarlogData -from homeassistant.components.solarlog.const import DOMAIN as SOLARLOG_DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.components.solarlog.const import ( + CONF_HAS_PWD, + DOMAIN as SOLARLOG_DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD from .const import HOST, NAME @@ -36,9 +39,10 @@ def mock_config_entry() -> MockConfigEntry: data={ CONF_HOST: HOST, CONF_NAME: NAME, - "extended_data": True, + CONF_HAS_PWD: True, + CONF_PASSWORD: "pwd", }, - minor_version=2, + minor_version=3, entry_id="ce5f5431554d101905d31797e1232da8", ) @@ -55,11 +59,14 @@ def mock_solarlog_connector(): mock_solarlog_api = AsyncMock() mock_solarlog_api.set_enabled_devices = MagicMock() mock_solarlog_api.test_connection.return_value = True + mock_solarlog_api.test_extended_data_available.return_value = True + mock_solarlog_api.extended_data.return_value = True mock_solarlog_api.update_data.return_value = data - mock_solarlog_api.update_device_list.return_value = INVERTER_DATA + mock_solarlog_api.update_device_list.return_value = DEVICE_LIST mock_solarlog_api.update_inverter_data.return_value = INVERTER_DATA mock_solarlog_api.device_name = {0: "Inverter 1", 1: "Inverter 2"}.get mock_solarlog_api.device_enabled = {0: True, 1: False}.get + mock_solarlog_api.password.return_value = "pwd" with ( patch( diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index 09ff3a333ee..ef237b545bb 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -3,14 +3,15 @@ dict({ 'config_entry': dict({ 'data': dict({ - 'extended_data': True, + 'has_password': True, 'host': '**REDACTED**', 'name': 'Solarlog test 1 2 3', + 'password': 'pwd', }), 'disabled_by': None, 'domain': 'solarlog', 'entry_id': 'ce5f5431554d101905d31797e1232da8', - 'minor_version': 2, + 'minor_version': 3, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py index b7ae6119893..17c32d8b38d 100644 --- a/tests/components/solarlog/test_config_flow.py +++ b/tests/components/solarlog/test_config_flow.py @@ -1,14 +1,18 @@ """Test the solarlog config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest -from solarlog_cli.solarlog_exceptions import SolarLogConnectionError, SolarLogError +from solarlog_cli.solarlog_exceptions import ( + SolarLogAuthenticationError, + SolarLogConnectionError, + SolarLogError, +) from homeassistant.components.solarlog import config_flow -from homeassistant.components.solarlog.const import DOMAIN +from homeassistant.components.solarlog.const import CONF_HAS_PWD, DOMAIN from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -17,6 +21,7 @@ from .const import HOST, NAME from tests.common import MockConfigEntry +@pytest.mark.usefixtures("test_connect") async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" @@ -26,22 +31,16 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.solarlog.config_flow.SolarLogConfigFlow._test_connection", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: HOST, CONF_NAME: NAME, "extended_data": False}, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: HOST, CONF_NAME: NAME, CONF_HAS_PWD: False}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "solarlog_test_1_2_3" assert result2["data"][CONF_HOST] == "http://1.1.1.1" - assert result2["data"]["extended_data"] is False + assert result2["data"][CONF_HAS_PWD] is False assert len(mock_setup_entry.mock_calls) == 1 @@ -67,7 +66,7 @@ async def test_user( # tests with all provided result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_HOST: HOST, CONF_NAME: NAME, "extended_data": True} + result["flow_id"], {CONF_HOST: HOST, CONF_NAME: NAME, CONF_HAS_PWD: False} ) await hass.async_block_till_done() @@ -78,16 +77,23 @@ async def test_user( @pytest.mark.parametrize( - ("exception", "error"), + ("exception1", "error1", "exception2", "error2"), [ - (SolarLogConnectionError, {CONF_HOST: "cannot_connect"}), - (SolarLogError, {CONF_HOST: "unknown"}), + ( + SolarLogConnectionError, + {CONF_HOST: "cannot_connect"}, + SolarLogAuthenticationError, + {CONF_HOST: "password_error"}, + ), + (SolarLogError, {CONF_HOST: "unknown"}, SolarLogError, {CONF_HOST: "unknown"}), ], ) async def test_form_exceptions( hass: HomeAssistant, - exception: Exception, - error: dict[str, str], + exception1: Exception, + error1: dict[str, str], + exception2: Exception, + error2: dict[str, str], mock_solarlog_connector: AsyncMock, ) -> None: """Test we can handle Form exceptions.""" @@ -97,30 +103,57 @@ async def test_form_exceptions( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - mock_solarlog_connector.test_connection.side_effect = exception + mock_solarlog_connector.test_connection.side_effect = exception1 # tests with connection error result = await flow.async_step_user( - {CONF_NAME: NAME, CONF_HOST: HOST, "extended_data": False} + {CONF_NAME: NAME, CONF_HOST: HOST, CONF_HAS_PWD: False} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == error + assert result["errors"] == error1 + # tests with password error mock_solarlog_connector.test_connection.side_effect = None + mock_solarlog_connector.test_extended_data_available.side_effect = exception2 - # tests with all provided result = await flow.async_step_user( - {CONF_NAME: NAME, CONF_HOST: HOST, "extended_data": False} + {CONF_NAME: NAME, CONF_HOST: HOST, CONF_HAS_PWD: True} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "password" + + result = await flow.async_step_password({CONF_PASSWORD: "pwd"}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "password" + assert result["errors"] == error2 + + mock_solarlog_connector.test_extended_data_available.side_effect = None + + # tests with all provided (no password) + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_HOST: HOST, CONF_HAS_PWD: False} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "solarlog_test_1_2_3" assert result["data"][CONF_HOST] == HOST - assert result["data"]["extended_data"] is False + assert result["data"][CONF_HAS_PWD] is False + + # tests with all provided (password) + result = await flow.async_step_password({CONF_PASSWORD: "pwd"}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "solarlog_test_1_2_3" + assert result["data"][CONF_PASSWORD] == "pwd" async def test_abort_if_already_setup(hass: HomeAssistant, test_connect: None) -> None: @@ -140,14 +173,25 @@ async def test_abort_if_already_setup(hass: HomeAssistant, test_connect: None) - result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: HOST, CONF_NAME: "solarlog_test_7_8_9", "extended_data": False}, + {CONF_HOST: HOST, CONF_NAME: "solarlog_test_7_8_9", CONF_HAS_PWD: False}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" +@pytest.mark.parametrize( + ("has_password", "password"), + [ + (True, "pwd"), + (False, ""), + ], +) async def test_reconfigure_flow( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_solarlog_connector: AsyncMock, + has_password: bool, + password: str, ) -> None: """Test config flow options.""" entry = MockConfigEntry( @@ -155,8 +199,9 @@ async def test_reconfigure_flow( title="solarlog_test_1_2_3", data={ CONF_HOST: HOST, - "extended_data": False, + CONF_HAS_PWD: False, }, + minor_version=3, ) entry.add_to_hass(hass) @@ -170,11 +215,77 @@ async def test_reconfigure_flow( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" + # test with all data provided result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"extended_data": True} + result["flow_id"], {CONF_HAS_PWD: True, CONF_PASSWORD: password} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert len(mock_setup_entry.mock_calls) == 1 + + entry = hass.config_entries.async_get_entry(entry.entry_id) + assert entry + assert entry.title == "solarlog_test_1_2_3" + assert entry.data[CONF_HAS_PWD] == has_password + assert entry.data[CONF_PASSWORD] == password + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SolarLogAuthenticationError, {CONF_HOST: "password_error"}), + (SolarLogError, {CONF_HOST: "unknown"}), + ], +) +async def test_reauth( + hass: HomeAssistant, + exception: Exception, + error: dict[str, str], + mock_solarlog_connector: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test reauth-flow works.""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="solarlog_test_1_2_3", + data={ + CONF_HOST: HOST, + CONF_HAS_PWD: True, + CONF_PASSWORD: "pwd", + }, + minor_version=3, + ) + entry.add_to_hass(hass) + + result = await entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_solarlog_connector.test_extended_data_available.side_effect = exception + + # tests with connection error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "other_pwd"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == error + + mock_solarlog_connector.test_extended_data_available.side_effect = None + + # tests with all information provided + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "other_pwd"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert entry.data[CONF_PASSWORD] == "other_pwd" diff --git a/tests/components/solarlog/test_init.py b/tests/components/solarlog/test_init.py index 0044d09f20e..b4ef270e78b 100644 --- a/tests/components/solarlog/test_init.py +++ b/tests/components/solarlog/test_init.py @@ -2,12 +2,19 @@ from unittest.mock import AsyncMock -from solarlog_cli.solarlog_exceptions import SolarLogConnectionError +import pytest +from solarlog_cli.solarlog_exceptions import ( + SolarLogAuthenticationError, + SolarLogConnectionError, + SolarLogError, + SolarLogUpdateError, +) -from homeassistant.components.solarlog.const import DOMAIN +from homeassistant.components.solarlog.const import CONF_HAS_PWD, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -32,27 +39,103 @@ async def test_load_unload( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -async def test_raise_config_entry_not_ready_when_offline( +@pytest.mark.parametrize( + ("exception", "error"), + [ + (SolarLogAuthenticationError, ConfigEntryState.SETUP_ERROR), + (SolarLogUpdateError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_error( hass: HomeAssistant, + exception: SolarLogError, + error: str, mock_config_entry: MockConfigEntry, mock_solarlog_connector: AsyncMock, ) -> None: - """Config entry state is SETUP_RETRY when Solarlog is offline.""" + """Test errors in setting up coordinator (i.e. login error).""" - mock_solarlog_connector.update_data.side_effect = SolarLogConnectionError + mock_solarlog_connector.login.side_effect = exception await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state == error + + if error == ConfigEntryState.SETUP_RETRY: + assert len(hass.config_entries.flow.async_progress()) == 0 + + +@pytest.mark.parametrize( + ("login_side_effect", "login_return_value", "entry_state"), + [ + (SolarLogAuthenticationError, False, ConfigEntryState.SETUP_ERROR), + (ConfigEntryNotReady, False, ConfigEntryState.SETUP_RETRY), + (None, False, ConfigEntryState.SETUP_ERROR), + (None, True, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_auth_error_during_first_refresh( + hass: HomeAssistant, + login_side_effect: Exception | None, + login_return_value: bool, + entry_state: str, + mock_config_entry: MockConfigEntry, + mock_solarlog_connector: AsyncMock, +) -> None: + """Test the correct exceptions are thrown for auth error during first refresh.""" + + mock_solarlog_connector.password.return_value = "" + mock_solarlog_connector.update_data.side_effect = SolarLogAuthenticationError + + mock_solarlog_connector.login.return_value = login_return_value + mock_solarlog_connector.login.side_effect = login_side_effect + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await hass.async_block_till_done() + + assert mock_config_entry.state == entry_state + + +@pytest.mark.parametrize( + ("exception"), + [ + (SolarLogConnectionError), + (SolarLogUpdateError), + ], +) +async def test_other_exceptions_during_first_refresh( + hass: HomeAssistant, + exception: SolarLogError, + mock_config_entry: MockConfigEntry, + mock_solarlog_connector: AsyncMock, +) -> None: + """Test the correct exceptions are thrown during first refresh.""" + + mock_solarlog_connector.update_data.side_effect = exception + + await setup_platform(hass, mock_config_entry, [Platform.SENSOR]) + await hass.async_block_till_done() + + assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY assert len(hass.config_entries.flow.async_progress()) == 0 +@pytest.mark.parametrize( + ("minor_version", "suffix"), + [ + (1, "time"), + (2, "last_updated"), + ], +) async def test_migrate_config_entry( hass: HomeAssistant, + minor_version: int, + suffix: str, device_registry: DeviceRegistry, entity_registry: EntityRegistry, + mock_solarlog_connector: AsyncMock, ) -> None: """Test successful migration of entry data.""" entry = MockConfigEntry( @@ -62,7 +145,7 @@ async def test_migrate_config_entry( CONF_HOST: HOST, }, version=1, - minor_version=1, + minor_version=minor_version, ) entry.add_to_hass(hass) @@ -72,17 +155,19 @@ async def test_migrate_config_entry( manufacturer="Solar-Log", name="solarlog", ) + uid = f"{entry.entry_id}_{suffix}" + sensor_entity = entity_registry.async_get_or_create( config_entry=entry, platform=DOMAIN, domain=Platform.SENSOR, - unique_id=f"{entry.entry_id}_time", + unique_id=uid, device_id=device.id, ) assert entry.version == 1 - assert entry.minor_version == 1 - assert sensor_entity.unique_id == f"{entry.entry_id}_time" + assert entry.minor_version == minor_version + assert sensor_entity.unique_id == f"{entry.entry_id}_{suffix}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -92,6 +177,6 @@ async def test_migrate_config_entry( assert entity_migrated.unique_id == f"{entry.entry_id}_last_updated" assert entry.version == 1 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert entry.data[CONF_HOST] == HOST - assert entry.data["extended_data"] is False + assert entry.data[CONF_HAS_PWD] is False From 604c848dec7d2ac272ddbb9c841a4d8aac4e073b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 Sep 2024 09:09:37 -0400 Subject: [PATCH 0870/1309] Change assist satellite announce method signature (#126299) --- .../components/assist_satellite/__init__.py | 2 ++ .../components/assist_satellite/entity.py | 29 +++++++++++++++++-- .../components/esphome/assist_satellite.py | 10 ++++--- tests/components/assist_satellite/conftest.py | 5 ++-- .../assist_satellite/test_entity.py | 23 ++++++++++----- 5 files changed, 52 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 77c9d8e678a..3f322beef29 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, DOMAIN_DATA, AssistSatelliteEntityFeature from .entity import ( + AssistSatelliteAnnouncement, AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityDescription, @@ -22,6 +23,7 @@ from .websocket_api import async_register_websocket_api __all__ = [ "DOMAIN", + "AssistSatelliteAnnouncement", "AssistSatelliteEntity", "AssistSatelliteConfiguration", "AssistSatelliteEntityDescription", diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index 079d3ae2948..23b588b569e 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from enum import StrEnum import logging import time -from typing import Any, Final, final +from typing import Any, Final, Literal, final from homeassistant.components import media_source, stt, tts from homeassistant.components.assist_pipeline import ( @@ -86,6 +86,19 @@ class AssistSatelliteConfiguration: """Maximum number of simultaneous wake words allowed (0 for no limit).""" +@dataclass +class AssistSatelliteAnnouncement: + """Announcement to be made.""" + + message: str + """Message to be spoken.""" + + media_id: str + """Media ID to be played.""" + + media_id_source: Literal["url", "media_id", "tts"] + + class AssistSatelliteEntity(entity.Entity): """Entity encapsulating the state and functionality of an Assist satellite.""" @@ -174,10 +187,13 @@ class AssistSatelliteEntity(entity.Entity): """ await self._cancel_running_pipeline() + media_id_source: Literal["url", "media_id", "tts"] | None = None + if message is None: message = "" if not media_id: + media_id_source = "tts" # Synthesize audio and get URL pipeline_id = self._resolve_pipeline() pipeline = async_get_pipeline(self.hass, pipeline_id) @@ -198,6 +214,8 @@ class AssistSatelliteEntity(entity.Entity): ) if media_source.is_media_source_id(media_id): + if not media_id_source: + media_id_source = "media_id" media = await media_source.async_resolve_media( self.hass, media_id, @@ -205,6 +223,9 @@ class AssistSatelliteEntity(entity.Entity): ) media_id = media.url + if not media_id_source: + media_id_source = "url" + # Resolve to full URL media_id = async_process_play_media_url(self.hass, media_id) @@ -216,12 +237,14 @@ class AssistSatelliteEntity(entity.Entity): try: # Block until announcement is finished - await self.async_announce(message, media_id) + await self.async_announce( + AssistSatelliteAnnouncement(message, media_id, media_id_source) + ) finally: self._is_announcing = False self._set_state(AssistSatelliteState.LISTENING_WAKE_WORD) - async def async_announce(self, message: str, media_id: str) -> None: + async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: """Announce media on the satellite. Should block until the announcement is done playing. diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index f8ed4c48651..a0e05a6c565 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -313,18 +313,20 @@ class EsphomeAssistSatellite( self.cli.send_voice_assistant_event(event_type, data_to_send) - async def async_announce(self, message: str, media_id: str) -> None: + async def async_announce( + self, announcement: assist_satellite.AssistSatelliteAnnouncement + ) -> None: """Announce media on the satellite. Should block until the announcement is done playing. """ _LOGGER.debug( "Waiting for announcement to finished (message=%s, media_id=%s)", - message, - media_id, + announcement.message, + announcement.media_id, ) await self.cli.send_voice_assistant_announcement_await_response( - media_id, _ANNOUNCEMENT_TIMEOUT_SEC, message + announcement.media_id, _ANNOUNCEMENT_TIMEOUT_SEC, announcement.message ) async def handle_pipeline_start( diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index 3a374b312cc..489460f8e2c 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.assist_pipeline import PipelineEvent from homeassistant.components.assist_satellite import ( DOMAIN as AS_DOMAIN, + AssistSatelliteAnnouncement, AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityFeature, @@ -63,9 +64,9 @@ class MockAssistSatellite(AssistSatelliteEntity): """Handle pipeline events.""" self.events.append(event) - async def async_announce(self, message: str, media_id: str) -> None: + async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None: """Announce media on a device.""" - self.announcements.append((message, media_id)) + self.announcements.append(announcement) @callback def async_get_configuration(self) -> AssistSatelliteConfiguration: diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 2af3af89681..b2347184bec 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -17,7 +17,10 @@ from homeassistant.components.assist_pipeline import ( async_update_pipeline, vad, ) -from homeassistant.components.assist_satellite import SatelliteBusyError +from homeassistant.components.assist_satellite import ( + AssistSatelliteAnnouncement, + SatelliteBusyError, +) from homeassistant.components.assist_satellite.entity import AssistSatelliteState from homeassistant.components.media_source import PlayMedia from homeassistant.config_entries import ConfigEntry @@ -159,18 +162,22 @@ async def test_new_pipeline_cancels_pipeline( [ ( {"message": "Hello"}, - ("Hello", "https://www.home-assistant.io/resolved.mp3"), + AssistSatelliteAnnouncement( + "Hello", "https://www.home-assistant.io/resolved.mp3", "tts" + ), ), ( { "message": "Hello", - "media_id": "http://example.com/bla.mp3", + "media_id": "media-source://bla", }, - ("Hello", "http://example.com/bla.mp3"), + AssistSatelliteAnnouncement( + "Hello", "https://www.home-assistant.io/resolved.mp3", "media_id" + ), ), ( {"media_id": "http://example.com/bla.mp3"}, - ("", "http://example.com/bla.mp3"), + AssistSatelliteAnnouncement("", "http://example.com/bla.mp3", "url"), ), ], ) @@ -195,10 +202,10 @@ async def test_announce( original_announce = entity.async_announce announce_started = asyncio.Event() - async def async_announce(message, media_id): + async def async_announce(announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING - await original_announce(message, media_id) + await original_announce(announcement) announce_started.set() def tts_generate_media_source_id( @@ -249,7 +256,7 @@ async def test_announce_busy( announce_started = asyncio.Event() got_error = asyncio.Event() - async def async_announce(message, media_id): + async def async_announce(announcement): announce_started.set() # Block so we can do another announcement From 1fcfe9e135c1e4806b5da7e6a749a15df2d2bfe2 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 20 Sep 2024 15:41:41 +0200 Subject: [PATCH 0871/1309] Bump pyduotecno to 2024.9.0 (#126328) --- homeassistant/components/duotecno/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 1adb9e874e5..8f8740ddfdf 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "quality_scale": "silver", - "requirements": ["pyDuotecno==2024.5.1"] + "requirements": ["pyDuotecno==2024.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd332313ccd..5004282a2c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1707,7 +1707,7 @@ pyCEC==0.5.2 pyControl4==1.2.0 # homeassistant.components.duotecno -pyDuotecno==2024.5.1 +pyDuotecno==2024.9.0 # homeassistant.components.electrasmart pyElectra==1.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67ca05e6132..4dac1ae855c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1393,7 +1393,7 @@ pyCEC==0.5.2 pyControl4==1.2.0 # homeassistant.components.duotecno -pyDuotecno==2024.5.1 +pyDuotecno==2024.9.0 # homeassistant.components.electrasmart pyElectra==1.2.4 From 99a65d3098462513a97072dd3730080156c6c7c8 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 20 Sep 2024 15:57:32 +0200 Subject: [PATCH 0872/1309] Fix update platform for Shelly gen1 devices (#124798) --- homeassistant/components/shelly/update.py | 17 ++++ tests/components/shelly/conftest.py | 6 +- tests/components/shelly/test_update.py | 97 +++++++++++++++++------ 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 0678da44472..61ebc144e3d 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -9,6 +9,7 @@ from typing import Any, Final, cast from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.update import ( ATTR_INSTALLED_VERSION, @@ -203,6 +204,22 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity): else: LOGGER.debug("Result of OTA update call: %s", result) + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return True if available version is newer then installed version. + + Default strategy generate an exception with Shelly firmware format + thus making the entity state always true. + """ + return AwesomeVersion( + latest_version, + find_first_match=True, + ensure_strategy=[AwesomeVersionStrategy.SEMVER], + ) > AwesomeVersion( + installed_version, + find_first_match=True, + ensure_strategy=[AwesomeVersionStrategy.SEMVER], + ) + class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity): """Represent a RPC update entity.""" diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index a983cbbcda9..d453d25698c 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -226,9 +226,9 @@ MOCK_STATUS_COAP = { "update": { "status": "pending", "has_update": True, - "beta_version": "some_beta_version", - "new_version": "some_new_version", - "old_version": "some_old_version", + "beta_version": "20231107-162609/v1.14.1-rc1-g0617c15", + "new_version": "20230913-111730/v1.14.0-gcb84623", + "old_version": "20230913-111730/v1.14.0-gcb84623", }, "uptime": 5 * REST_SENSORS_UPDATE_INTERVAL, "wifi_sta": {"rssi": -64}, diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index c6434c0b988..b4145b2441a 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -54,15 +54,15 @@ async def test_block_update( ) -> None: """Test block device update entity.""" entity_id = "update.test_name_firmware_update" - monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") - monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1.0.0") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2.0.0") monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes[ATTR_INSTALLED_VERSION] == "1" - assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" assert state.attributes[ATTR_IN_PROGRESS] is False supported_feat = state.attributes[ATTR_SUPPORTED_FEATURES] assert supported_feat == UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS @@ -77,18 +77,18 @@ async def test_block_update( state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes[ATTR_INSTALLED_VERSION] == "1" - assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_RELEASE_URL] == GEN1_RELEASE_URL - monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2") + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2.0.0") await mock_rest_update(hass, freezer) state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "2" - assert state.attributes[ATTR_LATEST_VERSION] == "2" + assert state.attributes[ATTR_INSTALLED_VERSION] == "2.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0" assert state.attributes[ATTR_IN_PROGRESS] is False entry = entity_registry.async_get(entity_id) @@ -106,25 +106,27 @@ async def test_block_beta_update( ) -> None: """Test block device beta update entity.""" entity_id = "update.test_name_beta_firmware_update" - monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") - monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1.0.0") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2.0.0") monkeypatch.setitem(mock_block_device.status["update"], "beta_version", "") monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) await init_integration(hass, 1) state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "1" - assert state.attributes[ATTR_LATEST_VERSION] == "1" + assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "1.0.0" assert state.attributes[ATTR_IN_PROGRESS] is False - monkeypatch.setitem(mock_block_device.status["update"], "beta_version", "2b") + monkeypatch.setitem( + mock_block_device.status["update"], "beta_version", "2.0.0-beta" + ) await mock_rest_update(hass, freezer) state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes[ATTR_INSTALLED_VERSION] == "1" - assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" assert state.attributes[ATTR_IN_PROGRESS] is False assert state.attributes[ATTR_RELEASE_URL] is None @@ -138,17 +140,17 @@ async def test_block_beta_update( state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes[ATTR_INSTALLED_VERSION] == "1" - assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_INSTALLED_VERSION] == "1.0.0" + assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" assert state.attributes[ATTR_IN_PROGRESS] is True - monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2b") + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "2.0.0-beta") await mock_rest_update(hass, freezer) state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" - assert state.attributes[ATTR_LATEST_VERSION] == "2b" + assert state.attributes[ATTR_INSTALLED_VERSION] == "2.0.0-beta" + assert state.attributes[ATTR_LATEST_VERSION] == "2.0.0-beta" assert state.attributes[ATTR_IN_PROGRESS] is False entry = entity_registry.async_get(entity_id) @@ -164,8 +166,8 @@ async def test_block_update_connection_error( caplog: pytest.LogCaptureFixture, ) -> None: """Test block device update connection error.""" - monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") - monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1.0.0") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2.0.0") monkeypatch.setattr( mock_block_device, "trigger_ota_update", @@ -190,8 +192,8 @@ async def test_block_update_auth_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block device update authentication error.""" - monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1") - monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2") + monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1.0.0") + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2.0.0") monkeypatch.setattr( mock_block_device, "trigger_ota_update", @@ -222,6 +224,51 @@ async def test_block_update_auth_error( assert flow["context"].get("entry_id") == entry.entry_id +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_block_version_compare( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_block_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test block device custom firmware version comparison.""" + + STABLE = "20230913-111730/v1.14.0-gcb84623" + BETA = "20231107-162609/v1.14.1-rc1-g0617c15" + + entity_id_beta = "update.test_name_beta_firmware_update" + entity_id_latest = "update.test_name_firmware_update" + monkeypatch.setitem(mock_block_device.status["update"], "old_version", STABLE) + monkeypatch.setitem(mock_block_device.status["update"], "new_version", "") + monkeypatch.setitem(mock_block_device.status["update"], "beta_version", BETA) + monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) + await init_integration(hass, 1) + + state = hass.states.get(entity_id_latest) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == STABLE + assert state.attributes[ATTR_LATEST_VERSION] == STABLE + state = hass.states.get(entity_id_beta) + assert state.state == STATE_ON + assert state.attributes[ATTR_INSTALLED_VERSION] == STABLE + assert state.attributes[ATTR_LATEST_VERSION] == BETA + + monkeypatch.setitem(mock_block_device.status["update"], "old_version", BETA) + monkeypatch.setitem(mock_block_device.status["update"], "new_version", STABLE) + monkeypatch.setitem(mock_block_device.status["update"], "beta_version", BETA) + await mock_rest_update(hass, freezer) + + state = hass.states.get(entity_id_latest) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == BETA + assert state.attributes[ATTR_LATEST_VERSION] == STABLE + state = hass.states.get(entity_id_beta) + assert state.state == STATE_OFF + assert state.attributes[ATTR_INSTALLED_VERSION] == BETA + assert state.attributes[ATTR_LATEST_VERSION] == BETA + + async def test_rpc_update( hass: HomeAssistant, mock_rpc_device: Mock, From 992b810fa947e90f7d30ee73ce6072bbe4806a8f Mon Sep 17 00:00:00 2001 From: "Teemu R." Date: Fri, 20 Sep 2024 16:11:02 +0200 Subject: [PATCH 0873/1309] Add siren platform for tplink (#124934) * Add siren platform for tplink * Add tests * Add alarm to features.json * Update based on reviews * Use alarm module instead of individual features --------- Co-authored-by: J. Nick Koston --- homeassistant/components/tplink/const.py | 1 + homeassistant/components/tplink/entity.py | 2 + homeassistant/components/tplink/siren.py | 61 ++++++++++++++ tests/components/tplink/__init__.py | 11 +++ .../components/tplink/fixtures/features.json | 5 ++ .../tplink/snapshots/test_siren.ambr | 84 +++++++++++++++++++ tests/components/tplink/test_siren.py | 76 +++++++++++++++++ 7 files changed, 240 insertions(+) create mode 100644 homeassistant/components/tplink/siren.py create mode 100644 tests/components/tplink/snapshots/test_siren.ambr create mode 100644 tests/components/tplink/test_siren.py diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 91085edb5a2..28e4b04bcf9 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -36,6 +36,7 @@ PLATFORMS: Final = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, ] diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 4155878b8fe..9d357d8a22c 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -70,6 +70,8 @@ EXCLUDED_FEATURES = { "available_firmware_version", "update_available", "check_latest_firmware", + # siren + "alarm", } diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py new file mode 100644 index 00000000000..c4ece56f0f6 --- /dev/null +++ b/homeassistant/components/tplink/siren.py @@ -0,0 +1,61 @@ +"""Support for TPLink hub alarm.""" + +from __future__ import annotations + +from typing import Any + +from kasa import Device, Module +from kasa.smart.modules.alarm import Alarm + +from homeassistant.components.siren import SirenEntity, SirenEntityFeature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TPLinkConfigEntry +from .coordinator import TPLinkDataUpdateCoordinator +from .entity import CoordinatedTPLinkEntity, async_refresh_after + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TPLinkConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up siren entities.""" + data = config_entry.runtime_data + parent_coordinator = data.parent_coordinator + device = parent_coordinator.device + + if Module.Alarm in device.modules: + async_add_entities([TPLinkSirenEntity(device, parent_coordinator)]) + + +class TPLinkSirenEntity(CoordinatedTPLinkEntity, SirenEntity): + """Representation of a tplink hub alarm.""" + + _attr_name = None + _attr_supported_features = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON + + def __init__( + self, + device: Device, + coordinator: TPLinkDataUpdateCoordinator, + ) -> None: + """Initialize the siren entity.""" + self._alarm_module: Alarm = device.modules[Module.Alarm] + super().__init__(device, coordinator) + + @async_refresh_after + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the siren on.""" + await self._alarm_module.play() + + @async_refresh_after + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the siren off.""" + await self._alarm_module.stop() + + @callback + def _async_update_attrs(self) -> None: + """Update the entity's attributes.""" + self._attr_is_on = self._alarm_module.active diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index 93c3a35a2e9..35ca3f2267c 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -18,6 +18,7 @@ from kasa import ( ) from kasa.interfaces import Fan, Light, LightEffect, LightState from kasa.protocol import BaseProtocol +from kasa.smart.modules.alarm import Alarm from syrupy import SnapshotAssertion from homeassistant.components.tplink import ( @@ -387,6 +388,15 @@ def _mocked_fan_module(effect) -> Fan: return fan +def _mocked_alarm_module(device): + alarm = MagicMock(auto_spec=Alarm, name="Mocked alarm") + alarm.active = False + alarm.play = AsyncMock() + alarm.stop = AsyncMock() + + return alarm + + def _mocked_strip_children(features=None, alias=None) -> list[Device]: plug0 = _mocked_device( alias="Plug0" if alias is None else alias, @@ -453,6 +463,7 @@ MODULE_TO_MOCK_GEN = { Module.Light: _mocked_light_module, Module.LightEffect: _mocked_light_effect_module, Module.Fan: _mocked_fan_module, + Module.Alarm: _mocked_alarm_module, } diff --git a/tests/components/tplink/fixtures/features.json b/tests/components/tplink/fixtures/features.json index 6d4afd98d15..9f9d61b6e11 100644 --- a/tests/components/tplink/fixtures/features.json +++ b/tests/components/tplink/fixtures/features.json @@ -200,6 +200,11 @@ "type": "BinarySensor", "category": "Primary" }, + "alarm": { + "value": false, + "type": "BinarySensor", + "category": "Info" + }, "test_alarm": { "value": "", "type": "Action", diff --git a/tests/components/tplink/snapshots/test_siren.ambr b/tests/components/tplink/snapshots/test_siren.ambr new file mode 100644 index 00000000000..b144288bd1c --- /dev/null +++ b/tests/components/tplink/snapshots/test_siren.ambr @@ -0,0 +1,84 @@ +# serializer version: 1 +# name: test_states[hub-entry] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '1.0.0', + 'id': , + 'identifiers': set({ + tuple( + 'tplink', + '123456789ABCDEFGH', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'TP-Link', + 'model': 'HS100', + 'model_id': None, + 'name': 'hub', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + 'via_device_id': None, + }) +# --- +# name: test_states[siren.hub-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.hub', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'tplink', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABCDEFGH', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[siren.hub-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'hub', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.hub', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tplink/test_siren.py b/tests/components/tplink/test_siren.py new file mode 100644 index 00000000000..8c3328558b0 --- /dev/null +++ b/tests/components/tplink/test_siren.py @@ -0,0 +1,76 @@ +"""Tests for siren platform.""" + +from __future__ import annotations + +from kasa import Device, Module +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.siren import ( + DOMAIN as SIREN_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import _mocked_device, setup_platform_for_device, snapshot_platform + +from tests.common import MockConfigEntry + +ENTITY_ID = "siren.hub" + + +@pytest.fixture +async def mocked_hub(hass: HomeAssistant) -> Device: + """Return mocked tplink hub with an alarm module.""" + + return _mocked_device( + alias="hub", + modules=[Module.Alarm], + device_type=Device.Type.Hub, + ) + + +async def test_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mocked_hub: Device, +) -> None: + """Snapshot test.""" + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + await snapshot_platform( + hass, entity_registry, device_registry, snapshot, mock_config_entry.entry_id + ) + + +async def test_turn_on_and_off( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mocked_hub: Device +) -> None: + """Test that turn_on and turn_off services work as expected.""" + await setup_platform_for_device(hass, mock_config_entry, Platform.SIREN, mocked_hub) + + alarm_module = mocked_hub.modules[Module.Alarm] + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [ENTITY_ID]}, + blocking=True, + ) + + alarm_module.stop.assert_called() + + await hass.services.async_call( + SIREN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [ENTITY_ID]}, + blocking=True, + ) + + alarm_module.play.assert_called() From 8254a643d24ea4fa0372649c4124e3ee4cce0695 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 20 Sep 2024 16:26:41 +0200 Subject: [PATCH 0874/1309] Make geniushub platforms a list (#126320) --- homeassistant/components/geniushub/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index 0609b675504..d750282b4f1 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -91,13 +91,13 @@ SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( } ) -PLATFORMS = ( - Platform.CLIMATE, - Platform.WATER_HEATER, - Platform.SENSOR, +PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.CLIMATE, + Platform.SENSOR, Platform.SWITCH, -) + Platform.WATER_HEATER, +] async def _async_import(hass: HomeAssistant, base_config: ConfigType) -> None: From 803de403216028d3eb15750455b47291c9abda24 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Sep 2024 16:40:57 +0200 Subject: [PATCH 0875/1309] Add trace to core files (#126314) --- .core_files.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.core_files.yaml b/.core_files.yaml index 27bf77b84ae..e49ca624393 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -111,6 +111,7 @@ components: &components - homeassistant/components/tag/** - homeassistant/components/template/** - homeassistant/components/timer/** + - homeassistant/components/trace/** - homeassistant/components/usb/** - homeassistant/components/webhook/** - homeassistant/components/websocket_api/** From c408fd0e6223d508a0a0516f87fd304dc166fd70 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 20 Sep 2024 17:47:12 +0200 Subject: [PATCH 0876/1309] Update pylint to 3.3.0 (#126330) --- pyproject.toml | 1 + requirements_test.txt | 4 ++-- tests/components/bsblan/conftest.py | 2 +- tests/components/ibeacon/test_device_tracker.py | 4 +--- tests/components/ibeacon/test_sensor.py | 4 +--- tests/components/sensoterra/conftest.py | 4 ++-- 6 files changed, 8 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 913ddfb23e0..2fa6ffe2c31 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -172,6 +172,7 @@ disable = [ "too-many-locals", "too-many-public-methods", "too-many-boolean-expressions", + "too-many-positional-arguments", "wrong-import-order", "consider-using-namedtuple-or-dataclass", "consider-using-assignment-expr", diff --git a/requirements_test.txt b/requirements_test.txt index 7579a654d40..382bd3c2d85 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,14 +7,14 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.2.4 +astroid==3.3.3 coverage==7.6.0 freezegun==1.5.1 mock-open==1.4.0 mypy-dev==1.12.0a3 pre-commit==3.7.1 pydantic==1.10.17 -pylint==3.2.6 +pylint==3.3.0 pylint-per-file-ignores==1.3.2 pipdeptree==2.23.1 pip-licenses==4.5.1 diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py index 68f716d836b..e46cdd75f2d 100644 --- a/tests/components/bsblan/conftest.py +++ b/tests/components/bsblan/conftest.py @@ -40,7 +40,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_bsblan() -> Generator[MagicMock, None, None]: +def mock_bsblan() -> Generator[MagicMock]: """Return a mocked BSBLAN client.""" with ( patch("homeassistant.components.bsblan.BSBLAN", autospec=True) as bsblan_mock, diff --git a/tests/components/ibeacon/test_device_tracker.py b/tests/components/ibeacon/test_device_tracker.py index dcc21b5bfc9..e34cc480cb0 100644 --- a/tests/components/ibeacon/test_device_tracker.py +++ b/tests/components/ibeacon/test_device_tracker.py @@ -11,9 +11,7 @@ from homeassistant.components.bluetooth import ( async_ble_device_from_address, async_last_service_info, ) -from homeassistant.components.bluetooth.const import ( # pylint: disable=hass-component-root-import - UNAVAILABLE_TRACK_SECONDS, -) +from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.components.ibeacon.const import ( DOMAIN, UNAVAILABLE_TIMEOUT, diff --git a/tests/components/ibeacon/test_sensor.py b/tests/components/ibeacon/test_sensor.py index e2ddf1dd7bc..f4dba57bced 100644 --- a/tests/components/ibeacon/test_sensor.py +++ b/tests/components/ibeacon/test_sensor.py @@ -4,9 +4,7 @@ from datetime import timedelta import pytest -from homeassistant.components.bluetooth.const import ( # pylint: disable=hass-component-root-import - UNAVAILABLE_TRACK_SECONDS, -) +from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.components.ibeacon.const import DOMAIN, UPDATE_INTERVAL from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ( diff --git a/tests/components/sensoterra/conftest.py b/tests/components/sensoterra/conftest.py index 2e19a96543a..0f6b7a3014b 100644 --- a/tests/components/sensoterra/conftest.py +++ b/tests/components/sensoterra/conftest.py @@ -9,7 +9,7 @@ from .const import API_TOKEN @pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock, None, None]: +def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.sensoterra.async_setup_entry", @@ -19,7 +19,7 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: @pytest.fixture -def mock_customer_api_client() -> Generator[AsyncMock, None, None]: +def mock_customer_api_client() -> Generator[AsyncMock]: """Override async_setup_entry.""" with ( patch( From e8d5ebef7e6547db72541c67927f029701f7d329 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 20 Sep 2024 17:48:03 +0200 Subject: [PATCH 0877/1309] Bump ruff to 0.6.6 (#126343) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a63d60a7159..303106087f2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.5 + rev: v0.6.6 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 6ddc0b75320..a506cb37c88 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.3.0 -ruff==0.6.5 +ruff==0.6.6 yamllint==1.35.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 5e42d0268dc..e996bcc081a 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.12,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.1 tqdm==4.66.4 ruff==0.6.5 \ + stdlib-list==0.10.0 pipdeptree==2.23.1 tqdm==4.66.4 ruff==0.6.6 \ PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.9.4 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" From 123b6b687e8107bc816d99f6782823bffe199bdd Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 20 Sep 2024 12:57:55 -0500 Subject: [PATCH 0878/1309] Route non-TTS media through ESPHome ffmpeg proxy (#126287) * Route non-TTS media through proxy * Use media_id_source --- .../components/esphome/assist_satellite.py | 30 +++++++++++++- .../esphome/test_assist_satellite.py | 40 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index a0e05a6c565..1485d88a7d2 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -14,6 +14,7 @@ import wave from aioesphomeapi import ( MediaPlayerFormatPurpose, + MediaPlayerSupportedFormat, VoiceAssistantAnnounceFinished, VoiceAssistantAudioSettings, VoiceAssistantCommandFlag, @@ -44,6 +45,7 @@ from .const import DOMAIN from .entity import EsphomeAssistEntity from .entry_data import ESPHomeConfigEntry, RuntimeEntryData from .enum_mapper import EsphomeEnumMapper +from .ffmpeg_proxy import async_create_proxy_url _LOGGER = logging.getLogger(__name__) @@ -325,8 +327,34 @@ class EsphomeAssistSatellite( announcement.message, announcement.media_id, ) + media_id = announcement.media_id + if announcement.media_id_source != "tts": + # Route non-TTS media through the proxy + format_to_use: MediaPlayerSupportedFormat | None = None + for supported_format in chain( + *self.entry_data.media_player_formats.values() + ): + if supported_format.purpose == MediaPlayerFormatPurpose.ANNOUNCEMENT: + format_to_use = supported_format + break + + if format_to_use is not None: + assert (self.registry_entry is not None) and ( + self.registry_entry.device_id is not None + ) + proxy_url = async_create_proxy_url( + self.hass, + self.registry_entry.device_id, + media_id, + media_format=format_to_use.format, + rate=format_to_use.sample_rate or None, + channels=format_to_use.num_channels or None, + width=format_to_use.sample_bytes or None, + ) + media_id = async_process_play_media_url(self.hass, proxy_url) + await self.cli.send_voice_assistant_announcement_await_response( - announcement.media_id, _ANNOUNCEMENT_TIMEOUT_SEC, announcement.message + media_id, _ANNOUNCEMENT_TIMEOUT_SEC, announcement.message ) async def handle_pipeline_start( diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 03111c0d8d8..71bae989daf 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1222,11 +1222,29 @@ async def test_announce_media_id( [APIClient, list[EntityInfo], list[UserService], list[EntityState]], Awaitable[MockESPHomeDevice], ], + device_registry: dr.DeviceRegistry, ) -> None: """Test announcement with media id.""" mock_device: MockESPHomeDevice = await mock_esphome_device( mock_client=mock_client, - entity_info=[], + entity_info=[ + MediaPlayerInfo( + object_id="mymedia_player", + key=1, + name="my media_player", + unique_id="my_media_player", + supports_pause=True, + supported_formats=[ + MediaPlayerSupportedFormat( + format="flac", + sample_rate=48000, + num_channels=2, + purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + sample_bytes=2, + ), + ], + ) + ], user_service=[], states=[], device_info={ @@ -1238,6 +1256,10 @@ async def test_announce_media_id( ) await hass.async_block_till_done() + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} + ) + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) assert satellite is not None @@ -1247,7 +1269,7 @@ async def test_announce_media_id( media_id: str, timeout: float, text: str ): assert satellite.state == AssistSatelliteState.RESPONDING - assert media_id == "https://www.home-assistant.io/resolved.mp3" + assert media_id == "https://www.home-assistant.io/proxied.flac" done.set() @@ -1257,6 +1279,10 @@ async def test_announce_media_id( "send_voice_assistant_announcement_await_response", new=send_voice_assistant_announcement_await_response, ), + patch( + "homeassistant.components.esphome.assist_satellite.async_create_proxy_url", + return_value="https://www.home-assistant.io/proxied.flac", + ) as mock_async_create_proxy_url, ): async with asyncio.timeout(1): await hass.services.async_call( @@ -1271,6 +1297,16 @@ async def test_announce_media_id( await done.wait() assert satellite.state == AssistSatelliteState.LISTENING_WAKE_WORD + mock_async_create_proxy_url.assert_called_once_with( + hass, + dev.id, + "https://www.home-assistant.io/resolved.mp3", + media_format="flac", + rate=48000, + channels=2, + width=2, + ) + async def test_satellite_unloaded_on_disconnect( hass: HomeAssistant, From 65fb688164197ad5d0d9d0820982e0bf44c2d168 Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Fri, 20 Sep 2024 23:19:27 +0300 Subject: [PATCH 0879/1309] Add YogevBokobza to switcher_kis codeowners (#126359) * Add YogevBokobza to switchre_kis CODEOWNERS * Update manifest.json * Update homeassistant/components/switcher_kis/manifest.json --------- Co-authored-by: Shay Levy --- CODEOWNERS | 4 ++-- homeassistant/components/switcher_kis/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 10feb81b2ea..e3c2b47c497 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1436,8 +1436,8 @@ build.json @home-assistant/supervisor /tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /homeassistant/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur /tests/components/switchbot_cloud/ @SeraphicRav @laurence-presland @Gigatrappeur -/homeassistant/components/switcher_kis/ @thecode -/tests/components/switcher_kis/ @thecode +/homeassistant/components/switcher_kis/ @thecode @YogevBokobza +/tests/components/switcher_kis/ @thecode @YogevBokobza /homeassistant/components/switchmate/ @danielhiversen @qiz-li /homeassistant/components/syncthing/ @zhulik /tests/components/syncthing/ @zhulik diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index f9956621ca6..902316f374e 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -1,7 +1,7 @@ { "domain": "switcher_kis", "name": "Switcher", - "codeowners": ["@thecode"], + "codeowners": ["@thecode", "@YogevBokobza"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switcher_kis", "iot_class": "local_push", From 3e1da876c6bf6aa08002d187ad8dd6eb3009a83c Mon Sep 17 00:00:00 2001 From: YogevBokobza Date: Fri, 20 Sep 2024 23:19:57 +0300 Subject: [PATCH 0880/1309] Add Switcher Runner S11 support (#123578) * switcher start s11 integration * switcher linting * switcher starting reauth logic * switcher fix linting * switcher fix linting * switcher remove get_circuit_number * switcher adding support for validate token * switcher fix initial auth for new devices and fix strings * switcher fix linting * switcher fix utils * Revert "switcher fix utils" This reverts commit b162a943b94fb0a581140feb21fe871df578c16a. * switcher revert and test * switcher fix validate logic and strings * switcher add tests to improve coverage * switcher adding tests * switcher adding test * switcher revert back things * switcher fix based on requested changes * switcher tests fixes * switcher fix based on requested changes * switcher remove single_instance_allowed code and added tests * Update config_flow.py * switcher fix comment * switcher fix tests * switcher lint * switcehr fix based on requested changes * switche fix lint * switcher small rename fix * switcher fix based on requested changes * switcher fix based on requested changes * switcher fix based on requested changes * Update tests/components/switcher_kis/test_config_flow.py Co-authored-by: Shay Levy * Update tests/components/switcher_kis/test_config_flow.py Co-authored-by: Shay Levy * Update tests/components/switcher_kis/test_config_flow.py Co-authored-by: Shay Levy * Update tests/components/switcher_kis/test_config_flow.py --------- Co-authored-by: Shay Levy --- .../components/switcher_kis/__init__.py | 11 +- .../components/switcher_kis/config_flow.py | 114 +++++++++++- .../components/switcher_kis/coordinator.py | 7 +- .../components/switcher_kis/cover.py | 25 ++- .../components/switcher_kis/strings.json | 20 ++- .../components/switcher_kis/utils.py | 4 +- tests/components/switcher_kis/__init__.py | 13 +- tests/components/switcher_kis/consts.py | 24 +++ .../switcher_kis/test_config_flow.py | 169 +++++++++++++++++- tests/components/switcher_kis/test_cover.py | 122 ++++++++----- 10 files changed, 449 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index 555ba951041..88baa9aed91 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -8,7 +8,7 @@ from aioswitcher.bridge import SwitcherBridge from aioswitcher.device import SwitcherBase from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -32,6 +32,8 @@ type SwitcherConfigEntry = ConfigEntry[dict[str, SwitcherDataUpdateCoordinator]] async def async_setup_entry(hass: HomeAssistant, entry: SwitcherConfigEntry) -> bool: """Set up Switcher from a config entry.""" + token = entry.data.get(CONF_TOKEN) + @callback def on_device_data_callback(device: SwitcherBase) -> None: """Use as a callback for device data.""" @@ -45,14 +47,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitcherConfigEntry) -> # New device - create device _LOGGER.info( - "Discovered Switcher device - id: %s, key: %s, name: %s, type: %s (%s)", + "Discovered Switcher device - id: %s, key: %s, name: %s, type: %s (%s), is_token_needed: %s", device.device_id, device.device_key, device.name, device.device_type.value, device.device_type.hex_rep, + device.token_needed, ) + if device.token_needed and not token: + entry.async_start_reauth(hass) + return + coordinator = SwitcherDataUpdateCoordinator(hass, entry, device) coordinator.async_setup() coordinators[device.device_id] = coordinator diff --git a/homeassistant/components/switcher_kis/config_flow.py b/homeassistant/components/switcher_kis/config_flow.py index 31764ecf390..e34961ebf6c 100644 --- a/homeassistant/components/switcher_kis/config_flow.py +++ b/homeassistant/components/switcher_kis/config_flow.py @@ -2,9 +2,117 @@ from __future__ import annotations -from homeassistant.helpers import config_entry_flow +from collections.abc import Mapping +import logging +from typing import Any, Final + +from aioswitcher.bridge import SwitcherBase +from aioswitcher.device.tools import validate_token +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_TOKEN, CONF_USERNAME from .const import DOMAIN -from .utils import async_has_devices +from .utils import async_discover_devices -config_entry_flow.register_discovery_flow(DOMAIN, "Switcher", async_has_devices) +_LOGGER = logging.getLogger(__name__) + + +CONFIG_SCHEMA: Final = vol.Schema( + { + vol.Required(CONF_USERNAME, default=""): str, + vol.Required(CONF_TOKEN, default=""): str, + } +) + + +class SwitcherFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle Switcher config flow.""" + + VERSION = 1 + + entry: ConfigEntry | None = None + username: str | None = None + token: str | None = None + discovered_devices: dict[str, SwitcherBase] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the start of the config flow.""" + self.discovered_devices = await async_discover_devices() + + return self.async_show_form(step_id="confirm") + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user-confirmation of the config flow.""" + if len(self.discovered_devices) == 0: + return self.async_abort(reason="no_devices_found") + + for device_id, device in self.discovered_devices.items(): + if device.token_needed: + _LOGGER.debug("Device with ID %s requires a token", device_id) + return await self.async_step_credentials() + return await self._create_entry() + + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the credentials step.""" + errors: dict[str, str] = {} + if user_input is not None: + self.username = user_input.get(CONF_USERNAME) + self.token = user_input.get(CONF_TOKEN) + + token_is_valid = await validate_token( + user_input[CONF_USERNAME], user_input[CONF_TOKEN] + ) + if token_is_valid: + return await self._create_entry() + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="credentials", data_schema=CONFIG_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + assert self.entry is not None + + if user_input is not None: + token_is_valid = await validate_token( + user_input[CONF_USERNAME], user_input[CONF_TOKEN] + ) + if token_is_valid: + return self.async_update_reload_and_abort( + self.entry, data={**self.entry.data, **user_input} + ) + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=CONFIG_SCHEMA, + errors=errors, + ) + + async def _create_entry(self) -> ConfigFlowResult: + return self.async_create_entry( + title="Switcher", + data={ + CONF_USERNAME: self.username, + CONF_TOKEN: self.token, + }, + ) diff --git a/homeassistant/components/switcher_kis/coordinator.py b/homeassistant/components/switcher_kis/coordinator.py index 1fdefda23a2..d292e9f8f39 100644 --- a/homeassistant/components/switcher_kis/coordinator.py +++ b/homeassistant/components/switcher_kis/coordinator.py @@ -8,6 +8,7 @@ import logging from aioswitcher.device import SwitcherBase from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, update_coordinator from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -23,7 +24,10 @@ class SwitcherDataUpdateCoordinator( """Switcher device data update coordinator.""" def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, device: SwitcherBase + self, + hass: HomeAssistant, + entry: ConfigEntry, + device: SwitcherBase, ) -> None: """Initialize the Switcher device coordinator.""" super().__init__( @@ -34,6 +38,7 @@ class SwitcherDataUpdateCoordinator( ) self.entry = entry self.data = device + self.token = entry.data.get(CONF_TOKEN) async def _async_update_data(self) -> SwitcherBase: """Mark device offline if no data.""" diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 19c40d05e63..5d8a777afa2 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -42,8 +42,11 @@ async def async_setup_entry( @callback def async_add_cover(coordinator: SwitcherDataUpdateCoordinator) -> None: """Add cover from Switcher device.""" - if coordinator.data.device_type.category == DeviceCategory.SHUTTER: - async_add_entities([SwitcherCoverEntity(coordinator)]) + if coordinator.data.device_type.category in ( + DeviceCategory.SHUTTER, + DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, + ): + async_add_entities([SwitcherCoverEntity(coordinator, 0)]) config_entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_cover) @@ -65,9 +68,14 @@ class SwitcherCoverEntity( | CoverEntityFeature.STOP ) - def __init__(self, coordinator: SwitcherDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: SwitcherDataUpdateCoordinator, + cover_id: int | None = None, + ) -> None: """Initialize the entity.""" super().__init__(coordinator) + self._cover_id = cover_id self._attr_unique_id = f"{coordinator.device_id}-{coordinator.mac_address}" self._attr_device_info = DeviceInfo( @@ -102,6 +110,7 @@ class SwitcherCoverEntity( self.coordinator.data.ip_address, self.coordinator.data.device_id, self.coordinator.data.device_key, + self.coordinator.token, ) as swapi: response = await getattr(swapi, api)(*args) except (TimeoutError, OSError, RuntimeError) as err: @@ -117,16 +126,18 @@ class SwitcherCoverEntity( async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self._async_call_api(API_SET_POSITON, 0) + await self._async_call_api(API_SET_POSITON, 0, self._cover_id) async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" - await self._async_call_api(API_SET_POSITON, 100) + await self._async_call_api(API_SET_POSITON, 100, self._cover_id) async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - await self._async_call_api(API_SET_POSITON, kwargs[ATTR_POSITION]) + await self._async_call_api( + API_SET_POSITON, kwargs[ATTR_POSITION], self._cover_id + ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - await self._async_call_api(API_STOP) + await self._async_call_api(API_STOP, self._cover_id) diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index e21bdbcdf7a..a3b3739eb2e 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -3,11 +3,29 @@ "step": { "confirm": { "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "credentials": { + "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see https://www.home-assistant.io/integrations/switcher_kis/#prerequisites", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "token": "[%key:common::config_flow::data::access_token%]" + } + }, + "reauth_confirm": { + "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see https://www.home-assistant.io/integrations/switcher_kis/#prerequisites", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "token": "[%key:common::config_flow::data::access_token%]" + } } }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py index ad23d51e44d..50bfb883e6c 100644 --- a/homeassistant/components/switcher_kis/utils.py +++ b/homeassistant/components/switcher_kis/utils.py @@ -16,7 +16,7 @@ from .const import DISCOVERY_TIME_SEC _LOGGER = logging.getLogger(__name__) -async def async_has_devices(hass: HomeAssistant) -> bool: +async def async_discover_devices() -> dict[str, SwitcherBase]: """Discover Switcher devices.""" _LOGGER.debug("Starting discovery") discovered_devices = {} @@ -35,7 +35,7 @@ async def async_has_devices(hass: HomeAssistant) -> bool: await bridge.stop() _LOGGER.debug("Finished discovery, discovered devices: %s", len(discovered_devices)) - return len(discovered_devices) > 0 + return discovered_devices @singleton.singleton("switcher_breeze_remote_manager") diff --git a/tests/components/switcher_kis/__init__.py b/tests/components/switcher_kis/__init__.py index 3f08afcbc9f..b9b44eb6d72 100644 --- a/tests/components/switcher_kis/__init__.py +++ b/tests/components/switcher_kis/__init__.py @@ -1,14 +1,23 @@ """Test cases and object for the Switcher integration tests.""" from homeassistant.components.switcher_kis.const import DOMAIN +from homeassistant.const import CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, username: str | None = None, token: str | None = None +) -> MockConfigEntry: """Set up the Switcher integration in Home Assistant.""" - entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + data = {} + if username is not None: + data[CONF_USERNAME] = username + if token is not None: + data[CONF_TOKEN] = token + + entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=DOMAIN) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index ffeef64b5d7..7b0b5c28f3f 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -6,6 +6,7 @@ from aioswitcher.device import ( ShutterDirection, SwitcherPowerPlug, SwitcherShutter, + SwitcherSingleShutterDualLight, SwitcherThermostat, SwitcherWaterHeater, ThermostatFanLevel, @@ -19,14 +20,17 @@ DUMMY_DEVICE_ID1 = "a123bc" DUMMY_DEVICE_ID2 = "cafe12" DUMMY_DEVICE_ID3 = "bada77" DUMMY_DEVICE_ID4 = "bbd164" +DUMMY_DEVICE_ID5 = "bcdb64" DUMMY_DEVICE_KEY1 = "18" DUMMY_DEVICE_KEY2 = "01" DUMMY_DEVICE_KEY3 = "12" DUMMY_DEVICE_KEY4 = "07" +DUMMY_DEVICE_KEY5 = "15" DUMMY_DEVICE_NAME1 = "Plug 23BC" DUMMY_DEVICE_NAME2 = "Heater FE12" DUMMY_DEVICE_NAME3 = "Breeze AB39" DUMMY_DEVICE_NAME4 = "Runner DD77" +DUMMY_DEVICE_NAME5 = "RunnerS11 6CF5" DUMMY_DEVICE_PASSWORD = "12345678" DUMMY_ELECTRIC_CURRENT1 = 0.5 DUMMY_ELECTRIC_CURRENT2 = 12.8 @@ -34,14 +38,17 @@ DUMMY_IP_ADDRESS1 = "192.168.100.157" DUMMY_IP_ADDRESS2 = "192.168.100.158" DUMMY_IP_ADDRESS3 = "192.168.100.159" DUMMY_IP_ADDRESS4 = "192.168.100.160" +DUMMY_IP_ADDRESS5 = "192.168.100.161" DUMMY_MAC_ADDRESS1 = "A1:B2:C3:45:67:D8" DUMMY_MAC_ADDRESS2 = "A1:B2:C3:45:67:D9" DUMMY_MAC_ADDRESS3 = "A1:B2:C3:45:67:DA" DUMMY_MAC_ADDRESS4 = "A1:B2:C3:45:67:DB" +DUMMY_MAC_ADDRESS5 = "A1:B2:C3:45:67:DC" DUMMY_TOKEN_NEEDED1 = False DUMMY_TOKEN_NEEDED2 = False DUMMY_TOKEN_NEEDED3 = False DUMMY_TOKEN_NEEDED4 = False +DUMMY_TOKEN_NEEDED5 = True DUMMY_PHONE_ID = "1234" DUMMY_POWER_CONSUMPTION1 = 100 DUMMY_POWER_CONSUMPTION2 = 2780 @@ -55,6 +62,9 @@ DUMMY_SWING = ThermostatSwing.OFF DUMMY_REMOTE_ID = "ELEC7001" DUMMY_POSITION = 54 DUMMY_DIRECTION = ShutterDirection.SHUTTER_STOP +DUMMY_USERNAME = "email" +DUMMY_TOKEN = "zvVvd7JxtN7CgvkD1Psujw==" +DUMMY_LIGHTS = [DeviceState.ON, DeviceState.ON] DUMMY_PLUG_DEVICE = SwitcherPowerPlug( DeviceType.POWER_PLUG, @@ -97,6 +107,20 @@ DUMMY_SHUTTER_DEVICE = SwitcherShutter( DUMMY_DIRECTION, ) +DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE = SwitcherSingleShutterDualLight( + DeviceType.RUNNER_S11, + DeviceState.ON, + DUMMY_DEVICE_ID5, + DUMMY_DEVICE_KEY5, + DUMMY_IP_ADDRESS5, + DUMMY_MAC_ADDRESS5, + DUMMY_DEVICE_NAME5, + DUMMY_TOKEN_NEEDED5, + DUMMY_POSITION, + DUMMY_DIRECTION, + DUMMY_LIGHTS, +) + DUMMY_THERMOSTAT_DEVICE = SwitcherThermostat( DeviceType.BREEZE, DeviceState.ON, diff --git a/tests/components/switcher_kis/test_config_flow.py b/tests/components/switcher_kis/test_config_flow.py index e42b8ac484d..7845c5a43b5 100644 --- a/tests/components/switcher_kis/test_config_flow.py +++ b/tests/components/switcher_kis/test_config_flow.py @@ -6,10 +6,17 @@ import pytest from homeassistant import config_entries from homeassistant.components.switcher_kis.const import DOMAIN +from homeassistant.const import CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE +from .consts import ( + DUMMY_PLUG_DEVICE, + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE, + DUMMY_TOKEN, + DUMMY_USERNAME, + DUMMY_WATER_HEATER_DEVICE, +) from tests.common import MockConfigEntry @@ -43,13 +50,96 @@ async def test_user_setup( assert mock_bridge.is_running is False assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Switcher" - assert result2["result"].data == {} + assert result2["result"].data == {CONF_USERNAME: None, CONF_TOKEN: None} await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 +@pytest.mark.parametrize( + "mock_bridge", + [ + [ + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE, + ] + ], + indirect=True, +) +async def test_user_setup_found_token_device_valid_token( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_bridge +) -> None: + """Test we can finish a config flow with token device found.""" + with patch("homeassistant.components.switcher_kis.utils.DISCOVERY_TIME_SEC", 0): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert mock_bridge.is_running is False + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "credentials" + + with patch( + "homeassistant.components.switcher_kis.config_flow.validate_token", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_USERNAME: DUMMY_USERNAME, CONF_TOKEN: DUMMY_TOKEN}, + ) + + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Switcher" + assert result3["result"].data == { + CONF_USERNAME: DUMMY_USERNAME, + CONF_TOKEN: DUMMY_TOKEN, + } + + +@pytest.mark.parametrize( + "mock_bridge", + [ + [ + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE, + ] + ], + indirect=True, +) +async def test_user_setup_found_token_device_invalid_token( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_bridge +) -> None: + """Test we can finish a config flow with token device found.""" + with patch("homeassistant.components.switcher_kis.utils.DISCOVERY_TIME_SEC", 0): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "credentials" + + with patch( + "homeassistant.components.switcher_kis.config_flow.validate_token", + return_value=False, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_USERNAME: DUMMY_USERNAME, CONF_TOKEN: DUMMY_TOKEN}, + ) + + assert result3["type"] is FlowResultType.FORM + assert result3["errors"] == {"base": "invalid_auth"} + + async def test_user_setup_abort_no_devices_found( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_bridge ) -> None: @@ -84,3 +174,78 @@ async def test_single_instance(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" + + +@pytest.mark.parametrize( + ("user_input"), + [ + ({CONF_USERNAME: DUMMY_USERNAME, CONF_TOKEN: DUMMY_TOKEN}), + ], +) +async def test_reauth_successful( + hass: HomeAssistant, + user_input: dict[str, str], +) -> None: + """Test starting a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: DUMMY_USERNAME, CONF_TOKEN: DUMMY_TOKEN}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.switcher_kis.config_flow.validate_token", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_invalid_auth(hass: HomeAssistant) -> None: + """Test reauthentication flow with invalid credentials.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_USERNAME: DUMMY_USERNAME, CONF_TOKEN: DUMMY_TOKEN}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.switcher_kis.config_flow.validate_token", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USERNAME: "invalid_user", CONF_TOKEN: "invalid_token"}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/switcher_kis/test_cover.py b/tests/components/switcher_kis/test_cover.py index c228da6b556..88e92b927e2 100644 --- a/tests/components/switcher_kis/test_cover.py +++ b/tests/components/switcher_kis/test_cover.py @@ -25,21 +25,39 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import slugify from . import init_integration -from .consts import DUMMY_SHUTTER_DEVICE as DEVICE +from .consts import ( + DUMMY_SHUTTER_DEVICE as DEVICE, + DUMMY_SINGLE_SHUTTER_DUAL_LIGHT_DEVICE as DEVICE2, + DUMMY_TOKEN as TOKEN, + DUMMY_USERNAME as USERNAME, +) ENTITY_ID = f"{COVER_DOMAIN}.{slugify(DEVICE.name)}" +ENTITY_ID2 = f"{COVER_DOMAIN}.{slugify(DEVICE2.name)}" -@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) +@pytest.mark.parametrize( + ("device", "entity_id"), + [ + (DEVICE, ENTITY_ID), + (DEVICE2, ENTITY_ID2), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2]], indirect=True) async def test_cover( - hass: HomeAssistant, mock_bridge, mock_api, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + mock_bridge, + mock_api, + monkeypatch: pytest.MonkeyPatch, + device, + entity_id, ) -> None: """Test cover services.""" - await init_integration(hass) + await init_integration(hass, USERNAME, TOKEN) assert mock_bridge # Test initial state - open - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.state == STATE_OPEN # Test set position @@ -49,17 +67,17 @@ async def test_cover( await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 77}, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 77}, blocking=True, ) - monkeypatch.setattr(DEVICE, "position", 77) - mock_bridge.mock_callbacks([DEVICE]) + monkeypatch.setattr(device, "position", 77) + mock_bridge.mock_callbacks([device]) await hass.async_block_till_done() assert mock_api.call_count == 2 - mock_control_device.assert_called_once_with(77) - state = hass.states.get(ENTITY_ID) + mock_control_device.assert_called_once_with(77, 0) + state = hass.states.get(entity_id) assert state.state == STATE_OPEN assert state.attributes[ATTR_CURRENT_POSITION] == 77 @@ -70,17 +88,17 @@ async def test_cover( await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: ENTITY_ID}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - monkeypatch.setattr(DEVICE, "direction", ShutterDirection.SHUTTER_UP) - mock_bridge.mock_callbacks([DEVICE]) + monkeypatch.setattr(device, "direction", ShutterDirection.SHUTTER_UP) + mock_bridge.mock_callbacks([device]) await hass.async_block_till_done() assert mock_api.call_count == 4 - mock_control_device.assert_called_once_with(100) - state = hass.states.get(ENTITY_ID) + mock_control_device.assert_called_once_with(100, 0) + state = hass.states.get(entity_id) assert state.state == STATE_OPENING # Test close @@ -90,17 +108,17 @@ async def test_cover( await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: ENTITY_ID}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - monkeypatch.setattr(DEVICE, "direction", ShutterDirection.SHUTTER_DOWN) - mock_bridge.mock_callbacks([DEVICE]) + monkeypatch.setattr(device, "direction", ShutterDirection.SHUTTER_DOWN) + mock_bridge.mock_callbacks([device]) await hass.async_block_till_done() assert mock_api.call_count == 6 - mock_control_device.assert_called_once_with(0) - state = hass.states.get(ENTITY_ID) + mock_control_device.assert_called_once_with(0, 0) + state = hass.states.get(entity_id) assert state.state == STATE_CLOSING # Test stop @@ -110,37 +128,50 @@ async def test_cover( await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: ENTITY_ID}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - monkeypatch.setattr(DEVICE, "direction", ShutterDirection.SHUTTER_STOP) - mock_bridge.mock_callbacks([DEVICE]) + monkeypatch.setattr(device, "direction", ShutterDirection.SHUTTER_STOP) + mock_bridge.mock_callbacks([device]) await hass.async_block_till_done() assert mock_api.call_count == 8 - mock_control_device.assert_called_once() - state = hass.states.get(ENTITY_ID) + mock_control_device.assert_called_once_with(0) + state = hass.states.get(entity_id) assert state.state == STATE_OPEN # Test closed on position == 0 - monkeypatch.setattr(DEVICE, "position", 0) - mock_bridge.mock_callbacks([DEVICE]) + monkeypatch.setattr(device, "position", 0) + mock_bridge.mock_callbacks([device]) await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.state == STATE_CLOSED assert state.attributes[ATTR_CURRENT_POSITION] == 0 -@pytest.mark.parametrize("mock_bridge", [[DEVICE]], indirect=True) -async def test_cover_control_fail(hass: HomeAssistant, mock_bridge, mock_api) -> None: +@pytest.mark.parametrize( + ("device", "entity_id"), + [ + (DEVICE, ENTITY_ID), + (DEVICE2, ENTITY_ID2), + ], +) +@pytest.mark.parametrize("mock_bridge", [[DEVICE, DEVICE2]], indirect=True) +async def test_cover_control_fail( + hass: HomeAssistant, + mock_bridge, + mock_api, + device, + entity_id, +) -> None: """Test cover control fail.""" - await init_integration(hass) + await init_integration(hass, USERNAME, TOKEN) assert mock_bridge # Test initial state - open - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.state == STATE_OPEN # Test exception during set position @@ -152,20 +183,20 @@ async def test_cover_control_fail(hass: HomeAssistant, mock_bridge, mock_api) -> await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 44}, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 44}, blocking=True, ) assert mock_api.call_count == 2 - mock_control_device.assert_called_once_with(44) - state = hass.states.get(ENTITY_ID) + mock_control_device.assert_called_once_with(44, 0) + state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE # Make device available again - mock_bridge.mock_callbacks([DEVICE]) + mock_bridge.mock_callbacks([device]) await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity_id) assert state.state == STATE_OPEN # Test error response during set position @@ -177,11 +208,22 @@ async def test_cover_control_fail(hass: HomeAssistant, mock_bridge, mock_api) -> await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_POSITION: 27}, + {ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 27}, blocking=True, ) assert mock_api.call_count == 4 - mock_control_device.assert_called_once_with(27) - state = hass.states.get(ENTITY_ID) + mock_control_device.assert_called_once_with(27, 0) + state = hass.states.get(entity_id) assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("mock_bridge", [[DEVICE2]], indirect=True) +async def test_cover2_no_token( + hass: HomeAssistant, mock_bridge, mock_api, monkeypatch: pytest.MonkeyPatch +) -> None: + """Test single cover dual light without token services.""" + await init_integration(hass) + assert mock_bridge + + assert mock_api.call_count == 0 From 41c1cfcef05cab3ec5c478eaf36856ec9826f764 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 20 Sep 2024 23:07:52 +0200 Subject: [PATCH 0881/1309] Improve lock handling in Yale Smart Living (#124245) * Improve handling of locks in yalesmartalarm * requirements * fix coordinator setup * Fix lock iteration * Fix tests * Fix review comments --- .../yale_smart_alarm/coordinator.py | 69 +++---------------- .../components/yale_smart_alarm/entity.py | 24 ++++++- .../components/yale_smart_alarm/lock.py | 64 ++++++++++------- .../components/yale_smart_alarm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/yale_smart_alarm/conftest.py | 20 ++++-- .../snapshots/test_diagnostics.ambr | 11 --- .../yale_smart_alarm/snapshots/test_lock.ambr | 2 +- .../components/yale_smart_alarm/test_lock.py | 8 --- 10 files changed, 91 insertions(+), 113 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py index 3bfd13b2152..911b4523fc4 100644 --- a/homeassistant/components/yale_smart_alarm/coordinator.py +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -3,8 +3,9 @@ from __future__ import annotations from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any +from yalesmartalarmclient import YaleLock from yalesmartalarmclient.client import YaleSmartAlarmClient from yalesmartalarmclient.exceptions import AuthenticationError @@ -32,6 +33,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), always_update=False, ) + self.locks: list[YaleLock] = [] async def _async_setup(self) -> None: """Set up connection to Yale.""" @@ -41,6 +43,7 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.entry.data[CONF_USERNAME], self.entry.data[CONF_PASSWORD], ) + self.locks = await self.hass.async_add_executor_job(self.yale.get_locks) except AuthenticationError as error: raise ConfigEntryAuthFailed from error except YALE_BASE_ERRORS as error: @@ -51,65 +54,11 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): updates = await self.hass.async_add_executor_job(self.get_updates) - locks = [] door_windows = [] temp_sensors = [] for device in updates["cycle"]["device_status"]: state = device["status1"] - if device["type"] == "device_type.door_lock": - lock_status_str = device["minigw_lock_status"] - lock_status = int(str(lock_status_str or 0), 16) - closed = (lock_status & 16) == 16 - locked = (lock_status & 1) == 1 - if not lock_status and "device_status.lock" in state: - device["_state"] = "locked" - device["_state2"] = "unknown" - locks.append(device) - continue - if not lock_status and "device_status.unlock" in state: - device["_state"] = "unlocked" - device["_state2"] = "unknown" - locks.append(device) - continue - if ( - lock_status - and ( - "device_status.lock" in state or "device_status.unlock" in state - ) - and closed - and locked - ): - device["_state"] = "locked" - device["_state2"] = "closed" - locks.append(device) - continue - if ( - lock_status - and ( - "device_status.lock" in state or "device_status.unlock" in state - ) - and closed - and not locked - ): - device["_state"] = "unlocked" - device["_state2"] = "closed" - locks.append(device) - continue - if ( - lock_status - and ( - "device_status.lock" in state or "device_status.unlock" in state - ) - and not closed - ): - device["_state"] = "unlocked" - device["_state2"] = "open" - locks.append(device) - continue - device["_state"] = "unavailable" - locks.append(device) - continue if device["type"] == "device_type.door_contact": if "device_status.dc_close" in state: device["_state"] = "closed" @@ -128,19 +77,16 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): _sensor_map = { contact["address"]: contact["_state"] for contact in door_windows } - _lock_map = {lock["address"]: lock["_state"] for lock in locks} _temp_map = {temp["address"]: temp["status_temp"] for temp in temp_sensors} return { "alarm": updates["arm_status"], - "locks": locks, "door_windows": door_windows, "temp_sensors": temp_sensors, "status": updates["status"], "online": updates["online"], "sensor_map": _sensor_map, "temp_map": _temp_map, - "lock_map": _lock_map, "panel_info": updates["panel_info"], } @@ -149,6 +95,13 @@ class YaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: arm_status = self.yale.get_armed_status() data = self.yale.get_information() + if TYPE_CHECKING: + assert data.cycle + for device in data.cycle["data"]["device_status"]: + if device["type"] == YaleLock.DEVICE_TYPE: + for lock in self.locks: + if lock.name == device["name"]: + lock.update(device) except AuthenticationError as error: raise ConfigEntryAuthFailed from error except YALE_BASE_ERRORS as error: diff --git a/homeassistant/components/yale_smart_alarm/entity.py b/homeassistant/components/yale_smart_alarm/entity.py index 179e20d509d..a0d08d19ba5 100644 --- a/homeassistant/components/yale_smart_alarm/entity.py +++ b/homeassistant/components/yale_smart_alarm/entity.py @@ -1,5 +1,7 @@ """Base class for yale_smart_alarm entity.""" +from yalesmartalarmclient import YaleLock + from homeassistant.const import CONF_NAME, CONF_USERNAME from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.entity import Entity @@ -9,7 +11,7 @@ from .const import DOMAIN, MANUFACTURER, MODEL from .coordinator import YaleDataUpdateCoordinator -class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity): +class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): """Base implementation for Yale device.""" _attr_has_entity_name = True @@ -23,7 +25,25 @@ class YaleEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity): manufacturer=MANUFACTURER, model=MODEL, identifiers={(DOMAIN, data["address"])}, - via_device=(DOMAIN, self.coordinator.entry.data[CONF_USERNAME]), + via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]), + ) + + +class YaleLockEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): + """Base implementation for Yale lock device.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: YaleDataUpdateCoordinator, lock: YaleLock) -> None: + """Initialize an Yale device.""" + super().__init__(coordinator) + self._attr_unique_id: str = lock.sid() + self._attr_device_info = DeviceInfo( + name=lock.name, + manufacturer=MANUFACTURER, + model=MODEL, + identifiers={(DOMAIN, lock.sid())}, + via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]), ) diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 386e546afbf..7374a7c06de 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -2,9 +2,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from homeassistant.components.lock import LockEntity +from yalesmartalarmclient import YaleLock, YaleLockState + +from homeassistant.components.lock import ( + STATE_LOCKED, + STATE_OPEN, + STATE_UNLOCKED, + LockEntity, +) from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -18,7 +25,13 @@ from .const import ( YALE_ALL_ERRORS, ) from .coordinator import YaleDataUpdateCoordinator -from .entity import YaleEntity +from .entity import YaleLockEntity + +LOCK_STATE_MAP = { + YaleLockState.LOCKED: STATE_LOCKED, + YaleLockState.UNLOCKED: STATE_UNLOCKED, + YaleLockState.DOOR_OPEN: STATE_OPEN, +} async def async_setup_entry( @@ -30,68 +43,62 @@ async def async_setup_entry( code_format = entry.options.get(CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS) async_add_entities( - YaleDoorlock(coordinator, data, code_format) - for data in coordinator.data["locks"] + YaleDoorlock(coordinator, lock, code_format) for lock in coordinator.locks ) -class YaleDoorlock(YaleEntity, LockEntity): +class YaleDoorlock(YaleLockEntity, LockEntity): """Representation of a Yale doorlock.""" _attr_name = None def __init__( - self, coordinator: YaleDataUpdateCoordinator, data: dict, code_format: int + self, coordinator: YaleDataUpdateCoordinator, lock: YaleLock, code_format: int ) -> None: """Initialize the Yale Lock Device.""" - super().__init__(coordinator, data) + super().__init__(coordinator, lock) self._attr_code_format = rf"^\d{{{code_format}}}$" - self.lock_name: str = data["name"] + self.lock_data = lock async def async_unlock(self, **kwargs: Any) -> None: """Send unlock command.""" code: str | None = kwargs.get(ATTR_CODE) - return await self.async_set_lock("unlocked", code) + return await self.async_set_lock(YaleLockState.UNLOCKED, code) async def async_lock(self, **kwargs: Any) -> None: """Send lock command.""" - return await self.async_set_lock("locked", None) + return await self.async_set_lock(YaleLockState.LOCKED, None) - async def async_set_lock(self, command: str, code: str | None) -> None: + async def async_set_lock(self, state: YaleLockState, code: str | None) -> None: """Set lock.""" - if TYPE_CHECKING: - assert self.coordinator.yale, "Connection to API is missing" - if command == "unlocked" and not code: + if state is YaleLockState.UNLOCKED and not code: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="no_code", ) + lock_state = False try: - get_lock = await self.hass.async_add_executor_job( - self.coordinator.yale.lock_api.get, self.lock_name - ) - if get_lock and command == "locked": + if state is YaleLockState.LOCKED: lock_state = await self.hass.async_add_executor_job( - self.coordinator.yale.lock_api.close_lock, - get_lock, + self.lock_data.close ) - if code and get_lock and command == "unlocked": + if code and state is YaleLockState.UNLOCKED: lock_state = await self.hass.async_add_executor_job( - self.coordinator.yale.lock_api.open_lock, get_lock, code + self.lock_data.open, code ) except YALE_ALL_ERRORS as error: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="set_lock", translation_placeholders={ - "name": self.lock_name, + "name": self.lock_data.name, "error": str(error), }, ) from error if lock_state: - self.coordinator.data["lock_map"][self._attr_unique_id] = command + self.lock_data.set_state(state) self.async_write_ha_state() return raise HomeAssistantError( @@ -102,4 +109,9 @@ class YaleDoorlock(YaleEntity, LockEntity): @property def is_locked(self) -> bool | None: """Return true if the lock is locked.""" - return bool(self.coordinator.data["lock_map"][self._attr_unique_id] == "locked") + return LOCK_STATE_MAP.get(self.lock_data.state()) == STATE_LOCKED + + @property + def is_open(self) -> bool | None: + """Return true if the lock is open.""" + return LOCK_STATE_MAP.get(self.lock_data.state()) == STATE_OPEN diff --git a/homeassistant/components/yale_smart_alarm/manifest.json b/homeassistant/components/yale_smart_alarm/manifest.json index 92dd774d1d9..d9e75195db2 100644 --- a/homeassistant/components/yale_smart_alarm/manifest.json +++ b/homeassistant/components/yale_smart_alarm/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale_smart_alarm", "iot_class": "cloud_polling", "loggers": ["yalesmartalarmclient"], - "requirements": ["yalesmartalarmclient==0.4.0"] + "requirements": ["yalesmartalarmclient==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5004282a2c9..73e31290d37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3002,7 +3002,7 @@ xmltodict==0.13.0 xs1-api-client==3.0.0 # homeassistant.components.yale_smart_alarm -yalesmartalarmclient==0.4.0 +yalesmartalarmclient==0.4.2 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4dac1ae855c..3763e866e62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2388,7 +2388,7 @@ xknxproject==3.7.1 xmltodict==0.13.0 # homeassistant.components.yale_smart_alarm -yalesmartalarmclient==0.4.0 +yalesmartalarmclient==0.4.2 # homeassistant.components.august # homeassistant.components.yale diff --git a/tests/components/yale_smart_alarm/conftest.py b/tests/components/yale_smart_alarm/conftest.py index 0499b6212d6..2a43eb8c6e7 100644 --- a/tests/components/yale_smart_alarm/conftest.py +++ b/tests/components/yale_smart_alarm/conftest.py @@ -7,7 +7,7 @@ from typing import Any from unittest.mock import Mock, patch import pytest -from yalesmartalarmclient import YaleSmartAlarmData +from yalesmartalarmclient import YaleDoorManAPI, YaleLock, YaleSmartAlarmData from yalesmartalarmclient.const import YALE_STATE_ARM_FULL from homeassistant.components.yale_smart_alarm.const import DOMAIN, PLATFORMS @@ -53,16 +53,28 @@ async def load_config_entry( config_entry.add_to_hass(hass) + cycle = get_data.cycle["data"] + data = {"data": cycle["device_status"]} + with patch( "homeassistant.components.yale_smart_alarm.coordinator.YaleSmartAlarmClient", autospec=True, ) as mock_client_class: client = mock_client_class.return_value client.auth = Mock() - client.lock_api = Mock() + client.auth.get_authenticated = Mock(return_value=data) + client.auth.post_authenticated = Mock(return_value={"code": "000"}) + client.lock_api = YaleDoorManAPI(client.auth) + locks = [ + YaleLock(device, lock_api=client.lock_api) + for device in cycle["device_status"] + if device["type"] == YaleLock.DEVICE_TYPE + ] + client.get_locks.return_value = locks client.get_all.return_value = get_all_data client.get_information.return_value = get_data client.get_armed_status.return_value = YALE_STATE_ARM_FULL + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -78,7 +90,7 @@ def get_fixture_data() -> dict[str, Any]: return json_data -@pytest.fixture(name="get_data", scope="package") +@pytest.fixture(name="get_data") def get_update_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData: """Load update data and return.""" @@ -94,7 +106,7 @@ def get_update_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData: ) -@pytest.fixture(name="get_all_data", scope="package") +@pytest.fixture(name="get_all_data") def get_diag_data(loaded_fixture: dict[str, Any]) -> YaleSmartAlarmData: """Load all data and return.""" diff --git a/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr index 750430b529a..e78c9520429 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_diagnostics.ambr @@ -24,8 +24,6 @@ 'capture_latest': None, 'device_status': list([ dict({ - '_state': 'locked', - '_state2': 'closed', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -86,8 +84,6 @@ 'type_no': '72', }), dict({ - '_state': 'unlocked', - '_state2': 'unknown', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -147,8 +143,6 @@ 'type_no': '72', }), dict({ - '_state': 'locked', - '_state2': 'unknown', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -391,8 +385,6 @@ 'type_no': '4', }), dict({ - '_state': 'unlocked', - '_state2': 'closed', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -453,8 +445,6 @@ 'type_no': '72', }), dict({ - '_state': 'unlocked', - '_state2': 'open', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', @@ -515,7 +505,6 @@ 'type_no': '72', }), dict({ - '_state': 'unavailable', 'address': '**REDACTED**', 'area': '1', 'bypass': '0', diff --git a/tests/components/yale_smart_alarm/snapshots/test_lock.ambr b/tests/components/yale_smart_alarm/snapshots/test_lock.ambr index da9c11e01d2..34da7db087a 100644 --- a/tests/components/yale_smart_alarm/snapshots/test_lock.ambr +++ b/tests/components/yale_smart_alarm/snapshots/test_lock.ambr @@ -236,7 +236,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unlocked', + 'state': 'open', }) # --- # name: test_lock[load_platforms0][lock.device9-entry] diff --git a/tests/components/yale_smart_alarm/test_lock.py b/tests/components/yale_smart_alarm/test_lock.py index b1bbbaabc57..bb8c9d55053 100644 --- a/tests/components/yale_smart_alarm/test_lock.py +++ b/tests/components/yale_smart_alarm/test_lock.py @@ -47,8 +47,6 @@ async def test_lock_service_calls( hass: HomeAssistant, get_data: YaleSmartAlarmData, load_config_entry: tuple[MockConfigEntry, Mock], - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, ) -> None: """Test the Yale Smart Alarm lock.""" @@ -101,8 +99,6 @@ async def test_lock_service_call_fails( hass: HomeAssistant, get_data: YaleSmartAlarmData, load_config_entry: tuple[MockConfigEntry, Mock], - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, ) -> None: """Test the Yale Smart Alarm lock service call fails.""" @@ -153,8 +149,6 @@ async def test_lock_service_call_fails_with_incorrect_status( hass: HomeAssistant, get_data: YaleSmartAlarmData, load_config_entry: tuple[MockConfigEntry, Mock], - entity_registry: er.EntityRegistry, - snapshot: SnapshotAssertion, ) -> None: """Test the Yale Smart Alarm lock service call fails with incorrect return state.""" @@ -163,9 +157,7 @@ async def test_lock_service_call_fails_with_incorrect_status( data = deepcopy(get_data.cycle) data["data"] = data["data"].pop("device_status") - client.auth.get_authenticated = Mock(return_value=data) client.auth.post_authenticated = Mock(return_value={"code": "FFF"}) - client.lock_api = YaleDoorManAPI(client.auth) state = hass.states.get("lock.device1") assert state.state == "locked" From 4fcfbd81349f458efdbea3ddb4bba0bcfa45014b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Sep 2024 23:40:08 +0200 Subject: [PATCH 0882/1309] Rename deconz base entity module (#126041) * Move and rename deconz base entity to separate module * Cancel rename --- homeassistant/components/deconz/alarm_control_panel.py | 2 +- homeassistant/components/deconz/binary_sensor.py | 2 +- homeassistant/components/deconz/button.py | 2 +- homeassistant/components/deconz/climate.py | 2 +- homeassistant/components/deconz/cover.py | 2 +- homeassistant/components/deconz/deconz_event.py | 2 +- homeassistant/components/deconz/{deconz_device.py => entity.py} | 1 - homeassistant/components/deconz/fan.py | 2 +- homeassistant/components/deconz/light.py | 2 +- homeassistant/components/deconz/lock.py | 2 +- homeassistant/components/deconz/number.py | 2 +- homeassistant/components/deconz/scene.py | 2 +- homeassistant/components/deconz/select.py | 2 +- homeassistant/components/deconz/sensor.py | 2 +- homeassistant/components/deconz/siren.py | 2 +- homeassistant/components/deconz/switch.py | 2 +- 16 files changed, 15 insertions(+), 16 deletions(-) rename homeassistant/components/deconz/{deconz_device.py => entity.py} (99%) diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index a82081dedd2..2f9bda6d5ed 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub DECONZ_TO_ALARM_STATE = { diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index d1bf955bb2f..a5496d3bc10 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -29,7 +29,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_DARK, ATTR_ON -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub ATTR_ORIENTATION = "orientation" diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index 6089e77de32..ecf28b5e22c 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -19,7 +19,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .deconz_device import DeconzDevice, DeconzSceneMixin +from .entity import DeconzDevice, DeconzSceneMixin from .hub import DeconzHub diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 0d9ff5db97e..1e228dc6c48 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -34,7 +34,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub DECONZ_FAN_SMART = "smart" diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index 1018b27a6a5..030c4b12709 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub DECONZ_TYPE_TO_DEVICE_CLASS = { diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index 56cbf47b4e3..d6d2ddf1373 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -25,7 +25,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.util import slugify from .const import ATTR_DURATION, ATTR_ROTATION, CONF_ANGLE, CONF_GESTURE, LOGGER -from .deconz_device import DeconzBase +from .entity import DeconzBase from .hub import DeconzHub CONF_DECONZ_EVENT = "deconz_event" diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/entity.py similarity index 99% rename from homeassistant/components/deconz/deconz_device.py rename to homeassistant/components/deconz/entity.py index 48cf94ea5aa..8551ad33cf5 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/entity.py @@ -68,7 +68,6 @@ class DeconzBase[_DeviceT: _DeviceType]: ) -# pylint: disable-next=hass-enforce-class-module class DeconzDevice[_DeviceT: _DeviceType](DeconzBase[_DeviceT], Entity): """Representation of a deCONZ device.""" diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py index 77733769d9d..48f29cf9b72 100644 --- a/homeassistant/components/deconz/fan.py +++ b/homeassistant/components/deconz/fan.py @@ -20,7 +20,7 @@ from homeassistant.util.percentage import ( percentage_to_ordered_list_item, ) -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub ORDERED_NAMED_FAN_SPEEDS: list[LightFanSpeed] = [ diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index b3e5b4f8157..a15aeb5a059 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -33,7 +33,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import color_hs_to_xy from .const import DOMAIN as DECONZ_DOMAIN, POWER_PLUGS -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub DECONZ_GROUP = "is_deconz_group" diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 505c894374a..50375e99778 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index c18ef68b2a6..53461960573 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -22,7 +22,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index a131add9c28..70b9f3f21b5 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .deconz_device import DeconzSceneMixin +from .entity import DeconzSceneMixin from .hub import DeconzHub diff --git a/homeassistant/components/deconz/select.py b/homeassistant/components/deconz/select.py index 39c266b4a35..cbd96a4faf9 100644 --- a/homeassistant/components/deconz/select.py +++ b/homeassistant/components/deconz/select.py @@ -17,7 +17,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub SENSITIVITY_TO_DECONZ = { diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 9f116b5ab0b..241ba015c67 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -56,7 +56,7 @@ from homeassistant.helpers.typing import StateType import homeassistant.util.dt as dt_util from .const import ATTR_DARK, ATTR_ON -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub PROVIDES_EXTRA_ATTRIBUTES = ( diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py index aa9a943095d..982a0bd1b9e 100644 --- a/homeassistant/components/deconz/siren.py +++ b/homeassistant/components/deconz/siren.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index 2533b5cbfea..c79cd7b28db 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import POWER_PLUGS -from .deconz_device import DeconzDevice +from .entity import DeconzDevice from .hub import DeconzHub From c07db352f3a81f7f6758d4f89298ebe2516cb9cb Mon Sep 17 00:00:00 2001 From: Niklas Wagner Date: Sat, 21 Sep 2024 01:00:23 +0200 Subject: [PATCH 0883/1309] Offboard myself as prusalink codeowner (#126361) --- CODEOWNERS | 4 ++-- homeassistant/components/prusalink/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e3c2b47c497..a144f1b339b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1133,8 +1133,8 @@ build.json @home-assistant/supervisor /homeassistant/components/proximity/ @mib1185 /tests/components/proximity/ @mib1185 /homeassistant/components/proxmoxve/ @jhollowe @Corbeno -/homeassistant/components/prusalink/ @balloob @Skaronator -/tests/components/prusalink/ @balloob @Skaronator +/homeassistant/components/prusalink/ @balloob +/tests/components/prusalink/ @balloob /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 /homeassistant/components/pure_energie/ @klaasnicolaas diff --git a/homeassistant/components/prusalink/manifest.json b/homeassistant/components/prusalink/manifest.json index 6c64419debb..c41b55bd5ab 100644 --- a/homeassistant/components/prusalink/manifest.json +++ b/homeassistant/components/prusalink/manifest.json @@ -1,7 +1,7 @@ { "domain": "prusalink", "name": "PrusaLink", - "codeowners": ["@balloob", "@Skaronator"], + "codeowners": ["@balloob"], "config_flow": true, "dhcp": [ { From 91c1e75c00517fba0ddc1a66cbde0a3691f9a7fb Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 21 Sep 2024 11:29:28 +0200 Subject: [PATCH 0884/1309] Get supervisor client in analytics only on systems with supervisor (#126375) fix supervisor dependency --- homeassistant/components/analytics/analytics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index e5f203f346d..c1141b40e4d 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -177,7 +177,6 @@ class Analytics: hass = self.hass supervisor_info = None operating_system_info: dict[str, Any] = {} - supervisor_client = hassio.get_supervisor_client(hass) if not self.onboarded or not self.preferences.get(ATTR_BASE, False): LOGGER.debug("Nothing to submit") @@ -262,6 +261,7 @@ class Analytics: integrations.append(integration.domain) if supervisor_info is not None: + supervisor_client = hassio.get_supervisor_client(hass) installed_addons = await asyncio.gather( *( supervisor_client.addons.addon_info(addon[ATTR_SLUG]) From 0299fa1b687f99ea82a0423dcccbf739d1c44ee2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 11:34:28 +0200 Subject: [PATCH 0885/1309] Use HassKey in stt (#126335) --- homeassistant/components/stt/__init__.py | 30 ++++++++---------------- homeassistant/components/stt/const.py | 14 ++++++++++- homeassistant/components/stt/legacy.py | 5 ++-- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index f82d6c2ab93..2fb3b652c5c 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -32,6 +32,7 @@ from homeassistant.util import dt as dt_util, language as language_util from .const import ( DATA_PROVIDERS, DOMAIN, + DOMAIN_DATA, AudioBitRates, AudioChannels, AudioCodecs, @@ -72,11 +73,9 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @callback def async_default_engine(hass: HomeAssistant) -> str | None: """Return the domain or entity id of the default engine.""" - component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] - default_entity_id: str | None = None - for entity in component.entities: + for entity in hass.data[DOMAIN_DATA].entities: if entity.platform and entity.platform.platform_name == "cloud": return entity.entity_id @@ -91,9 +90,7 @@ def async_get_speech_to_text_entity( hass: HomeAssistant, entity_id: str ) -> SpeechToTextEntity | None: """Return stt entity.""" - component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] - - return component.get_entity(entity_id) + return hass.data[DOMAIN_DATA].get_entity(entity_id) @callback @@ -111,13 +108,11 @@ def async_get_speech_to_text_languages(hass: HomeAssistant) -> set[str]: """Return a set with the union of languages supported by stt engines.""" languages = set() - component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] - legacy_providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] - for entity in component.entities: + for entity in hass.data[DOMAIN_DATA].entities: for language_tag in entity.supported_languages: languages.add(language_tag) - for engine in legacy_providers.values(): + for engine in hass.data[DATA_PROVIDERS].values(): for language_tag in engine.supported_languages: languages.add(language_tag) @@ -128,7 +123,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up STT.""" websocket_api.async_register_command(hass, websocket_list_engines) - component = hass.data[DOMAIN] = EntityComponent[SpeechToTextEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[SpeechToTextEntity]( _LOGGER, DOMAIN, hass ) @@ -150,14 +145,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class SpeechToTextEntity(RestoreEntity): @@ -426,15 +419,12 @@ def websocket_list_engines( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """List speech-to-text engines and, optionally, if they support a given language.""" - component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] - legacy_providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] - country = msg.get("country") language = msg.get("language") providers = [] provider_info: dict[str, Any] - for entity in component.entities: + for entity in hass.data[DOMAIN_DATA].entities: provider_info = { "engine_id": entity.entity_id, "supported_languages": entity.supported_languages, @@ -445,7 +435,7 @@ def websocket_list_engines( ) providers.append(provider_info) - for engine_id, provider in legacy_providers.items(): + for engine_id, provider in hass.data[DATA_PROVIDERS].items(): provider_info = { "engine_id": engine_id, "name": provider.name, diff --git a/homeassistant/components/stt/const.py b/homeassistant/components/stt/const.py index 2df5bea0316..5c805494cef 100644 --- a/homeassistant/components/stt/const.py +++ b/homeassistant/components/stt/const.py @@ -1,9 +1,21 @@ """STT constante.""" +from __future__ import annotations + from enum import Enum +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import SpeechToTextEntity + from .legacy import Provider DOMAIN = "stt" -DATA_PROVIDERS = f"{DOMAIN}_providers" +DOMAIN_DATA: HassKey[EntityComponent[SpeechToTextEntity]] = HassKey(DOMAIN) +DATA_PROVIDERS: HassKey[dict[str, Provider]] = HassKey(f"{DOMAIN}_providers") class AudioCodecs(str, Enum): diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 7bb0d84c289..13144eae5b4 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -34,7 +34,8 @@ _LOGGER = logging.getLogger(__name__) @callback def async_default_provider(hass: HomeAssistant) -> str | None: """Return the domain of the default provider.""" - return next(iter(hass.data[DATA_PROVIDERS]), None) + providers = hass.data[DATA_PROVIDERS] + return next(iter(providers), None) @callback @@ -42,7 +43,7 @@ def async_get_provider( hass: HomeAssistant, domain: str | None = None ) -> Provider | None: """Return provider.""" - providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] + providers = hass.data[DATA_PROVIDERS] if domain: return providers.get(domain) From a58b1ca6e4c9b7c71533d1b3ec2ae92a146df34a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 11:36:03 +0200 Subject: [PATCH 0886/1309] Use HassKey in sensor (#126336) --- homeassistant/components/sensor/__init__.py | 10 +++++----- homeassistant/components/sensor/recorder.py | 12 +++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index e7f4b00fd77..29d31d10ffc 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -63,6 +63,7 @@ from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import UNDEFINED, ConfigType, StateType, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum +from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 _DEPRECATED_STATE_CLASS_MEASUREMENT, @@ -88,6 +89,7 @@ from .websocket_api import async_setup as async_setup_ws_api _LOGGER: Final = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[SensorEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -115,7 +117,7 @@ __all__ = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for sensors.""" - component = hass.data[DOMAIN] = EntityComponent[SensorEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[SensorEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -126,14 +128,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[SensorEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[SensorEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class SensorEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index fce41a13ca6..462b25dd552 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -37,6 +37,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.loader import async_suggest_report_issue from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_LAST_RESET, @@ -63,14 +64,15 @@ EQUIVALENT_UNITS = { "ft³/m": UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, } + # Keep track of entities for which a warning about decreasing value has been logged -SEEN_DIP = "sensor_seen_total_increasing_dip" -WARN_DIP = "sensor_warn_total_increasing_dip" +SEEN_DIP: HassKey[set[str]] = HassKey(f"{DOMAIN}_seen_total_increasing_dip") +WARN_DIP: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_total_increasing_dip") # Keep track of entities for which a warning about negative value has been logged -WARN_NEGATIVE = "sensor_warn_total_increasing_negative" +WARN_NEGATIVE: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_total_increasing_negative") # Keep track of entities for which a warning about unsupported unit has been logged -WARN_UNSUPPORTED_UNIT = "sensor_warn_unsupported_unit" -WARN_UNSTABLE_UNIT = "sensor_warn_unstable_unit" +WARN_UNSUPPORTED_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unsupported_unit") +WARN_UNSTABLE_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unstable_unit") # Link to dev statistics where issues around LTS can be fixed LINK_DEV_STATISTICS = "https://my.home-assistant.io/redirect/developer_statistics" From 83672ee28b55050106f6b09d438b7f880cd0c3c3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 11:38:27 +0200 Subject: [PATCH 0887/1309] Use HassKey in device_tracker (#126339) --- .../components/device_tracker/config_entry.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 14b2d02b5f4..0e8a9d940da 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -28,6 +28,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.typing import StateType +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_HOST_NAME, @@ -40,6 +41,9 @@ from .const import ( SourceType, ) +DOMAIN_DATA: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN) +DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac") + # mypy: disallow-any-generics @@ -50,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if component is not None: return await component.async_setup_entry(entry) - component = hass.data[DOMAIN] = EntityComponent[BaseTrackerEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[BaseTrackerEntity]( LOGGER, DOMAIN, hass ) component.register_shutdown() @@ -60,8 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an entry.""" - component: EntityComponent[BaseTrackerEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) @callback @@ -93,16 +96,15 @@ def _async_register_mac( unique_id: str, ) -> None: """Register a mac address with a unique ID.""" - data_key = "device_tracker_mac" mac = dr.format_mac(mac) - if data_key in hass.data: - hass.data[data_key][mac] = (domain, unique_id) + if DATA_KEY in hass.data: + hass.data[DATA_KEY][mac] = (domain, unique_id) return # Setup listening. # dict mapping mac -> partial unique ID - data = hass.data[data_key] = {mac: (domain, unique_id)} + data = hass.data[DATA_KEY] = {mac: (domain, unique_id)} @callback def handle_device_event(ev: Event[EventDeviceRegistryUpdatedData]) -> None: From 52d349d776754c7abf2622e4efbd4327a9459424 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 21 Sep 2024 12:01:43 +0200 Subject: [PATCH 0888/1309] Bump aiovlc to 0.5.1 (#126365) * bump aiovlc to 0.5.0 * bump aiovlc to 0.5.1 --- homeassistant/components/vlc_telnet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vlc_telnet/manifest.json b/homeassistant/components/vlc_telnet/manifest.json index 7a5e00cff21..5041619e84f 100644 --- a/homeassistant/components/vlc_telnet/manifest.json +++ b/homeassistant/components/vlc_telnet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vlc_telnet", "iot_class": "local_polling", "loggers": ["aiovlc"], - "requirements": ["aiovlc==0.3.2"] + "requirements": ["aiovlc==0.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 73e31290d37..164f5cb9345 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -398,7 +398,7 @@ aiotractive==0.6.0 aiounifi==80 # homeassistant.components.vlc_telnet -aiovlc==0.3.2 +aiovlc==0.5.1 # homeassistant.components.vodafone_station aiovodafone==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3763e866e62..fdebfbb2454 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -380,7 +380,7 @@ aiotractive==0.6.0 aiounifi==80 # homeassistant.components.vlc_telnet -aiovlc==0.3.2 +aiovlc==0.5.1 # homeassistant.components.vodafone_station aiovodafone==0.6.0 From 94df0bd5ab300abfbbc77012194494632de2e954 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 13:10:14 +0200 Subject: [PATCH 0889/1309] Use HassKey in core components (d-z) (#126324) * Use HassKey in core components (d-s) * Add * Undo light * Undo device_tracker * Undo notify * Undo sensor * Undo stt * Improve --- homeassistant/components/date/__init__.py | 10 +++++----- homeassistant/components/datetime/__init__.py | 10 +++++----- homeassistant/components/event/__init__.py | 10 +++++----- homeassistant/components/fan/__init__.py | 10 +++++----- .../components/geo_location/__init__.py | 10 +++++----- .../components/humidifier/__init__.py | 10 +++++----- .../components/lawn_mower/__init__.py | 10 +++++----- homeassistant/components/lock/__init__.py | 10 +++++----- .../components/media_player/__init__.py | 13 ++++++------- homeassistant/components/number/__init__.py | 10 +++++----- homeassistant/components/remote/__init__.py | 10 +++++----- homeassistant/components/scene/__init__.py | 10 +++++----- homeassistant/components/select/__init__.py | 10 +++++----- homeassistant/components/siren/__init__.py | 10 +++++----- homeassistant/components/switch/__init__.py | 10 +++++----- homeassistant/components/text/__init__.py | 10 +++++----- homeassistant/components/time/__init__.py | 10 +++++----- homeassistant/components/update/__init__.py | 13 ++++++------- homeassistant/components/vacuum/__init__.py | 10 +++++----- homeassistant/components/valve/__init__.py | 10 +++++----- .../components/wake_word/__init__.py | 19 +++++++++---------- .../components/water_heater/__init__.py | 10 +++++----- 22 files changed, 116 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 7914c6d2984..701db594c67 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -16,11 +16,13 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN, SERVICE_SET_VALUE _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[DateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -37,7 +39,7 @@ async def _async_set_value(entity: DateEntity, service_call: ServiceCall) -> Non async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Date entities.""" - component = hass.data[DOMAIN] = EntityComponent[DateEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[DateEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -51,14 +53,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[DateEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[DateEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class DateEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index f418f81da03..e3e742e107c 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -16,11 +16,13 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[DateTimeEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -40,7 +42,7 @@ async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Date/Time entities.""" - component = hass.data[DOMAIN] = EntityComponent[DateTimeEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[DateTimeEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -58,14 +60,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[DateTimeEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[DateTimeEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class DateTimeEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index 4ca000f6a40..b73babd5edc 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -17,10 +17,12 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[EventEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -51,7 +53,7 @@ __all__ = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Event entities.""" - component = hass.data[DOMAIN] = EntityComponent[EventEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[EventEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -60,14 +62,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[EventEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[EventEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class EventEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 5a15ece665a..3256168d3c5 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -34,6 +34,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -42,6 +43,7 @@ from homeassistant.util.percentage import ( _LOGGER = logging.getLogger(__name__) DOMAIN = "fan" +DOMAIN_DATA: HassKey[EntityComponent[FanEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -119,7 +121,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Expose fan control via statemachine and services.""" - component = hass.data[DOMAIN] = EntityComponent[FanEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[FanEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -201,14 +203,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[FanEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[FanEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class FanEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index e0c8d806fe6..ca32c479549 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -14,10 +14,12 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) DOMAIN = "geo_location" +DOMAIN_DATA: HassKey[EntityComponent[GeolocationEvent]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -32,7 +34,7 @@ ATTR_SOURCE = "source" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Geolocation component.""" - component = hass.data[DOMAIN] = EntityComponent[GeolocationEvent]( + component = hass.data[DOMAIN_DATA] = EntityComponent[GeolocationEvent]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -41,14 +43,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[GeolocationEvent] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[GeolocationEvent] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) CACHED_PROPERTIES_WITH_ATTR_ = { diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 605bd4284f8..12b5b38696a 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -30,6 +30,7 @@ from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 _DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER, @@ -61,6 +62,7 @@ from .const import ( # noqa: F401 _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[HumidifierEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -94,7 +96,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up humidifier devices.""" - component = hass.data[DOMAIN] = EntityComponent[HumidifierEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[HumidifierEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -123,14 +125,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[HumidifierEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[HumidifierEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class HumidifierEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index 9eef6ad8343..b4d174f6676 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -13,6 +13,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import ( DOMAIN, @@ -25,6 +26,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=60) @@ -32,7 +34,7 @@ SCAN_INTERVAL = timedelta(seconds=60) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the lawn_mower component.""" - component = hass.data[DOMAIN] = EntityComponent[LawnMowerEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[LawnMowerEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -55,14 +57,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up lawn mower devices.""" - component: EntityComponent[LawnMowerEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[LawnMowerEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class LawnMowerEntityEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index fd3f60d3502..d9123497696 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -39,11 +39,13 @@ from homeassistant.helpers.deprecation import ( from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[LockEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -76,7 +78,7 @@ PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT} async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for locks.""" - component = hass.data[DOMAIN] = EntityComponent[LockEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[LockEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -100,14 +102,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[LockEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[LockEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class LockEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index beb672a1e58..b160305e6d6 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -59,6 +59,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from .browse_media import BrowseMedia, async_process_play_media_url # noqa: F401 from .const import ( # noqa: F401 @@ -132,6 +133,7 @@ from .errors import BrowseError _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[MediaPlayerEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -264,7 +266,7 @@ def _rename_keys(**keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for media_players.""" - component = hass.data[DOMAIN] = EntityComponent[MediaPlayerEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[MediaPlayerEntity]( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL ) @@ -438,14 +440,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class MediaPlayerEntityDescription(EntityDescription, frozen_or_thawed=True): @@ -1282,8 +1282,7 @@ async def websocket_browse_media( To use, media_player integrations can implement MediaPlayerEntity.async_browse_media() """ - component: EntityComponent[MediaPlayerEntity] = hass.data[DOMAIN] - player = component.get_entity(msg["entity_id"]) + player = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) if player is None: connection.send_error(msg["id"], "entity_not_found", "Entity not found") diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 2c750bd834e..7ff86dca7a8 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_suggest_report_issue +from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 ATTR_MAX, @@ -49,6 +50,7 @@ from .websocket_api import async_setup as async_setup_ws_api _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[NumberEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -81,7 +83,7 @@ __all__ = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Number entities.""" - component = hass.data[DOMAIN] = EntityComponent[NumberEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[NumberEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) async_setup_ws_api(hass) @@ -124,14 +126,12 @@ async def async_set_value(entity: NumberEntity, service_call: ServiceCall) -> No async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[NumberEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[NumberEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class NumberEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index cb67a7568e2..28019727ffb 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -32,10 +32,12 @@ from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) DOMAIN = "remote" +DOMAIN_DATA: HassKey[EntityComponent[RemoteEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -98,7 +100,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for remotes.""" - component = hass.data[DOMAIN] = EntityComponent[RemoteEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[RemoteEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -155,14 +157,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[RemoteEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[RemoteEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class RemoteEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 596d256ffb7..6fcebbdfb67 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -17,8 +17,10 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey DOMAIN: Final = "scene" +DOMAIN_DATA: HassKey[EntityComponent[Scene]] = HassKey(DOMAIN) STATES: Final = "states" @@ -60,7 +62,7 @@ PLATFORM_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the scenes.""" - component = hass.data[DOMAIN] = EntityComponent[Scene]( + component = hass.data[DOMAIN_DATA] = EntityComponent[Scene]( logging.getLogger(__name__), DOMAIN, hass ) @@ -83,14 +85,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[Scene] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[Scene] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class Scene(RestoreEntity): diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 24f7d8bffea..62592428da0 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -16,6 +16,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_CYCLE, @@ -31,6 +32,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[SelectEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -59,7 +61,7 @@ __all__ = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Select entities.""" - component = hass.data[DOMAIN] = EntityComponent[SelectEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[SelectEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -99,14 +101,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[SelectEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[SelectEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class SelectEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 801ca4f2bee..34c3e22f094 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -21,6 +21,7 @@ from homeassistant.helpers.deprecation import ( from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, VolDictType +from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 _DEPRECATED_SUPPORT_DURATION, @@ -38,6 +39,7 @@ from .const import ( # noqa: F401 _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[SirenEntity]] = HassKey(DOMAIN) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=60) @@ -104,7 +106,7 @@ def process_turn_on_params( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up siren devices.""" - component = hass.data[DOMAIN] = EntityComponent[SirenEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[SirenEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -143,14 +145,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[SirenEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[SirenEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class SirenEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 43971741e51..e1320fe4469 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -28,11 +28,13 @@ from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[SwitchEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -74,7 +76,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for switches.""" - component = hass.data[DOMAIN] = EntityComponent[SwitchEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[SwitchEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -88,14 +90,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[SwitchEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[SwitchEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class SwitchEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index 33589be8f41..5c4fbf2c15c 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_MAX, @@ -33,6 +34,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[TextEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -46,7 +48,7 @@ __all__ = ["DOMAIN", "TextEntity", "TextEntityDescription", "TextMode"] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Text entities.""" - component = hass.data[DOMAIN] = EntityComponent[TextEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[TextEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -81,14 +83,12 @@ async def _async_set_value(entity: TextEntity, service_call: ServiceCall) -> Non async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[TextEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[TextEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class TextMode(StrEnum): diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 23c9796ec2e..7230ce490bd 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -16,11 +16,13 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN, SERVICE_SET_VALUE _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[TimeEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -37,7 +39,7 @@ async def _async_set_value(entity: TimeEntity, service_call: ServiceCall) -> Non async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Time entities.""" - component = hass.data[DOMAIN] = EntityComponent[TimeEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[TimeEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -51,14 +53,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[TimeEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[TimeEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class TimeEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 90495871cb2..699f8bad51f 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -21,6 +21,7 @@ from homeassistant.helpers.entity import ABCCachedProperties, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_AUTO_UPDATE, @@ -41,6 +42,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[UpdateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -78,7 +80,7 @@ __all__ = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Select entities.""" - component = hass.data[DOMAIN] = EntityComponent[UpdateEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[UpdateEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -111,14 +113,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[UpdateEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[UpdateEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None: @@ -492,8 +492,7 @@ async def websocket_release_notes( msg: dict[str, Any], ) -> None: """Get the full release notes for a entity.""" - component: EntityComponent[UpdateEntity] = hass.data[DOMAIN] - entity = component.get_entity(msg["entity_id"]) + entity = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) if entity is None: connection.send_error( diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 867e25d4b2a..069371c9b17 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -28,11 +28,13 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, STATE_RETURNING _LOGGER = logging.getLogger(__name__) +DOMAIN_DATA: HassKey[EntityComponent[StateVacuumEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -108,7 +110,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the vacuum component.""" - component = hass.data[DOMAIN] = EntityComponent[StateVacuumEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[StateVacuumEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -171,14 +173,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[StateVacuumEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[StateVacuumEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py index 04ce12e8a8f..18aa30e05b5 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -27,10 +27,12 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) DOMAIN = "valve" +DOMAIN_DATA: HassKey[EntityComponent[ValveEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -64,7 +66,7 @@ ATTR_POSITION = "position" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for valves.""" - component = hass.data[DOMAIN] = EntityComponent[ValveEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[ValveEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -108,14 +110,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[ValveEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[ValveEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 5ce592aacd8..84e59ab66d6 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -19,6 +19,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import DOMAIN from .models import DetectionResult, WakeWord @@ -35,6 +36,7 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +DOMAIN_DATA: HassKey[EntityComponent[WakeWordDetectionEntity]] = HassKey(DOMAIN) TIMEOUT_FETCH_WAKE_WORDS = 10 @@ -50,16 +52,16 @@ def async_get_wake_word_detection_entity( hass: HomeAssistant, entity_id: str ) -> WakeWordDetectionEntity | None: """Return wake word entity.""" - component: EntityComponent[WakeWordDetectionEntity] = hass.data[DOMAIN] - - return component.get_entity(entity_id) + return hass.data[DOMAIN_DATA].get_entity(entity_id) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up wake word.""" websocket_api.async_register_command(hass, websocket_entity_info) - component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) + component = hass.data[DOMAIN_DATA] = EntityComponent[WakeWordDetectionEntity]( + _LOGGER, DOMAIN, hass + ) component.register_shutdown() return True @@ -67,14 +69,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class WakeWordDetectionEntity(RestoreEntity): @@ -142,8 +142,7 @@ async def websocket_entity_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Get info about wake word entity.""" - component: EntityComponent[WakeWordDetectionEntity] = hass.data[DOMAIN] - entity = component.get_entity(msg["entity_id"]) + entity = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) if entity is None: connection.send_error( diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index e6e424329fb..da8b49bd171 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -35,10 +35,12 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType, VolDictType +from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import TemperatureConverter from .const import DOMAIN +DOMAIN_DATA: HassKey[EntityComponent[WaterHeaterEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -109,7 +111,7 @@ SET_OPERATION_MODE_SCHEMA: VolDictType = { async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up water_heater devices.""" - component = hass.data[DOMAIN] = EntityComponent[WaterHeaterEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[WaterHeaterEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -137,14 +139,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[WaterHeaterEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[WaterHeaterEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class WaterHeaterEntityEntityDescription(EntityDescription, frozen_or_thawed=True): From 9422cde275b37507c0058855688f90349b860575 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 21 Sep 2024 13:11:27 +0200 Subject: [PATCH 0890/1309] Bump airgradient to 0.9.0 (#126319) * Bump airgradient to 0.9.0 * Bump airgradient to 0.9.0 --- homeassistant/components/airgradient/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airgradient/snapshots/test_sensor.ambr | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index fed4fafdc74..c0472131357 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/airgradient", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["airgradient==0.8.0"], + "requirements": ["airgradient==0.9.0"], "zeroconf": ["_airgradient._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 164f5cb9345..90263c03ee9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -419,7 +419,7 @@ aiowithings==3.0.3 aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.8.0 +airgradient==0.9.0 # homeassistant.components.airly airly==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdebfbb2454..5756ee4a4c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ aiowithings==3.0.3 aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.8.0 +airgradient==0.9.0 # homeassistant.components.airly airly==1.1.0 diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr index ff83fdcc111..941369ff266 100644 --- a/tests/components/airgradient/snapshots/test_sensor.ambr +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -305,7 +305,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '48.0', + 'state': '47.0', }) # --- # name: test_all_entities[indoor][sensor.airgradient_led_bar_brightness-entry] @@ -912,7 +912,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '27.96', + 'state': '22.17', }) # --- # name: test_all_entities[indoor][sensor.airgradient_voc_index-entry] From f7004188d2a5ac70898f09bfd83b7ea3afcb200e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 13:11:57 +0200 Subject: [PATCH 0891/1309] Use HassKey in group (#126321) * Use HassKey in group * Adjust * Improve --- homeassistant/components/group/__init__.py | 16 ++++++---------- homeassistant/components/group/const.py | 20 +++++++++++++++----- homeassistant/components/group/entity.py | 6 +++--- homeassistant/components/group/registry.py | 3 +-- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index f89bf67861d..e863eb41211 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -22,7 +22,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.group import ( expand_entity_ids as _expand_entity_ids, get_entity_ids as _get_entity_ids, @@ -50,11 +49,12 @@ from .const import ( # noqa: F401 ATTR_REMOVE_ENTITIES, CONF_HIDE_MEMBERS, DOMAIN, + DOMAIN_DATA, GROUP_ORDER, REG_KEY, ) from .entity import Group, async_get_component -from .registry import GroupIntegrationRegistry, async_setup as async_setup_registry +from .registry import async_setup as async_setup_registry CONF_ALL = "all" @@ -110,8 +110,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: return False if (state := hass.states.get(entity_id)) is not None: - registry: GroupIntegrationRegistry = hass.data[REG_KEY] - return state.state in registry.on_off_mapping + return state.state in hass.data[REG_KEY].on_off_mapping return False @@ -132,7 +131,7 @@ def groups_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]: return [ group.entity_id - for group in hass.data[DOMAIN].entities + for group in hass.data[DOMAIN_DATA].entities if entity_id in group.tracking ] @@ -179,10 +178,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up all groups found defined in the configuration.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = EntityComponent[Group](_LOGGER, DOMAIN, hass) - - component: EntityComponent[Group] = hass.data[DOMAIN] + component = async_get_component(hass) await async_setup_registry(hass) @@ -338,7 +334,7 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> None entity_ids: Collection[str] = conf.get(CONF_ENTITIES) or [] icon: str | None = conf.get(CONF_ICON) mode = bool(conf.get(CONF_ALL)) - order: int = hass.data[GROUP_ORDER] + order = hass.data[GROUP_ORDER] # We keep track of the order when we are creating the tasks # in the same way that async_create_group does to make diff --git a/homeassistant/components/group/const.py b/homeassistant/components/group/const.py index 0fdd429269f..790e643eb14 100644 --- a/homeassistant/components/group/const.py +++ b/homeassistant/components/group/const.py @@ -1,14 +1,24 @@ """Constants for the Group integration.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from .entity import Group + from .registry import GroupIntegrationRegistry + CONF_HIDE_MEMBERS = "hide_members" CONF_IGNORE_NON_NUMERIC = "ignore_non_numeric" DOMAIN = "group" - -REG_KEY = f"{DOMAIN}_registry" - -GROUP_ORDER = "group_order" - +DOMAIN_DATA: HassKey[EntityComponent[Group]] = HassKey(DOMAIN) +REG_KEY: HassKey[GroupIntegrationRegistry] = HassKey(f"{DOMAIN}_registry") +GROUP_ORDER: HassKey[int] = HassKey("group_order") ATTR_ADD_ENTITIES = "add_entities" ATTR_REMOVE_ENTITIES = "remove_entities" diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 1b2db35531f..02926cfc97b 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event -from .const import ATTR_AUTO, ATTR_ORDER, DOMAIN, GROUP_ORDER, REG_KEY +from .const import ATTR_AUTO, ATTR_ORDER, DOMAIN, DOMAIN_DATA, GROUP_ORDER, REG_KEY from .registry import GroupIntegrationRegistry, SingleStateType ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -478,8 +478,8 @@ class Group(Entity): def async_get_component(hass: HomeAssistant) -> EntityComponent[Group]: """Get the group entity component.""" - if (component := hass.data.get(DOMAIN)) is None: - component = hass.data[DOMAIN] = EntityComponent[Group]( + if (component := hass.data.get(DOMAIN_DATA)) is None: + component = hass.data[DOMAIN_DATA] = EntityComponent[Group]( _PACKAGE_LOGGER, DOMAIN, hass ) return component diff --git a/homeassistant/components/group/registry.py b/homeassistant/components/group/registry.py index aba1b299ced..96fa8721271 100644 --- a/homeassistant/components/group/registry.py +++ b/homeassistant/components/group/registry.py @@ -160,8 +160,7 @@ def _process_group_platform( hass: HomeAssistant, domain: str, platform: GroupProtocol ) -> None: """Process a group platform.""" - registry: GroupIntegrationRegistry = hass.data[REG_KEY] - platform.async_describe_on_off_states(hass, registry) + platform.async_describe_on_off_states(hass, hass.data[REG_KEY]) @dataclass(frozen=True, slots=True) From 32f02aa3c6ac5e56666d5f8941a67398aafb92f0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 13:13:41 +0200 Subject: [PATCH 0892/1309] Use HassKey in image (#126322) --- homeassistant/components/image/__init__.py | 10 ++++------ homeassistant/components/image/const.py | 13 ++++++++++++- homeassistant/components/image/media_source.py | 10 +++------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 2307a66d5a1..692a398c577 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -30,7 +30,7 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType -from .const import DOMAIN, IMAGE_TIMEOUT +from .const import DOMAIN, DOMAIN_DATA, IMAGE_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -88,7 +88,7 @@ async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the image component.""" - component = hass.data[DOMAIN] = EntityComponent[ImageEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[ImageEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -120,14 +120,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[ImageEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[ImageEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) CACHED_PROPERTIES_WITH_ATTR_ = { diff --git a/homeassistant/components/image/const.py b/homeassistant/components/image/const.py index d96f13b4951..7746e40afbb 100644 --- a/homeassistant/components/image/const.py +++ b/homeassistant/components/image/const.py @@ -1,7 +1,18 @@ """Constants for the image integration.""" -from typing import Final +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import ImageEntity + DOMAIN: Final = "image" +DOMAIN_DATA: HassKey[EntityComponent[ImageEntity]] = HassKey(DOMAIN) IMAGE_TIMEOUT: Final = 10 diff --git a/homeassistant/components/image/media_source.py b/homeassistant/components/image/media_source.py index 882249ef940..4ed24498453 100644 --- a/homeassistant/components/image/media_source.py +++ b/homeassistant/components/image/media_source.py @@ -14,10 +14,8 @@ from homeassistant.components.media_source import ( ) from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant, State -from homeassistant.helpers.entity_component import EntityComponent -from . import ImageEntity -from .const import DOMAIN +from .const import DOMAIN, DOMAIN_DATA async def async_get_media_source(hass: HomeAssistant) -> ImageMediaSource: @@ -37,8 +35,7 @@ class ImageMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" - component: EntityComponent[ImageEntity] = self.hass.data[DOMAIN] - image = component.get_entity(item.identifier) + image = self.hass.data[DOMAIN_DATA].get_entity(item.identifier) if not image: raise Unresolvable(f"Could not resolve media item: {item.identifier}") @@ -55,7 +52,6 @@ class ImageMediaSource(MediaSource): if item.identifier: raise BrowseError("Unknown item") - component: EntityComponent[ImageEntity] = self.hass.data[DOMAIN] children = [ BrowseMediaSource( domain=DOMAIN, @@ -69,7 +65,7 @@ class ImageMediaSource(MediaSource): can_play=True, can_expand=False, ) - for image in component.entities + for image in self.hass.data[DOMAIN_DATA].entities ] return BrowseMediaSource( From d40464e5d305f214aa6dbec912bcec8b8f8ac4bf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 13:14:27 +0200 Subject: [PATCH 0893/1309] Use HassKey in tts (#126327) * Use HassKey in tts * Also migrate DATA_TTS_MANAGER --- homeassistant/components/tts/__init__.py | 57 ++++++++------------ homeassistant/components/tts/const.py | 14 ++++- homeassistant/components/tts/helper.py | 12 ++--- homeassistant/components/tts/legacy.py | 9 ++-- homeassistant/components/tts/media_source.py | 26 ++++----- 5 files changed, 53 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 9e3d9f65a76..5ecbe15601d 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -62,6 +62,7 @@ from .const import ( DEFAULT_CACHE_DIR, DEFAULT_TIME_MEMORY, DOMAIN, + DOMAIN_DATA, TtsAudioType, ) from .helper import get_engine_instance @@ -137,19 +138,16 @@ def async_default_engine(hass: HomeAssistant) -> str | None: Returns None if no engines found. """ - component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] - manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - default_entity_id: str | None = None - for entity in component.entities: + for entity in hass.data[DOMAIN_DATA].entities: if entity.platform and entity.platform.platform_name == "cloud": return entity.entity_id if default_entity_id is None: default_entity_id = entity.entity_id - return default_entity_id or next(iter(manager.providers), None) + return default_entity_id or next(iter(hass.data[DATA_TTS_MANAGER].providers), None) @callback @@ -158,11 +156,11 @@ def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: Returns None if no engines found or invalid engine passed in. """ - component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] - manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - if engine is not None: - if not component.get_entity(engine) and engine not in manager.providers: + if ( + not hass.data[DOMAIN_DATA].get_entity(engine) + and engine not in hass.data[DATA_TTS_MANAGER].providers + ): return None return engine @@ -179,10 +177,8 @@ async def async_support_options( if (engine_instance := get_engine_instance(hass, engine)) is None: raise HomeAssistantError(f"Provider {engine} not found") - manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - try: - manager.process_options(engine_instance, language, options) + hass.data[DATA_TTS_MANAGER].process_options(engine_instance, language, options) except HomeAssistantError: return False @@ -194,8 +190,7 @@ async def async_get_media_source_audio( media_source_id: str, ) -> tuple[str, bytes]: """Get TTS audio as extension, data.""" - manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - return await manager.async_get_tts_audio( + return await hass.data[DATA_TTS_MANAGER].async_get_tts_audio( **media_source_id_to_kwargs(media_source_id), ) @@ -205,14 +200,11 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: """Return a set with the union of languages supported by tts engines.""" languages = set() - component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] - manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - - for entity in component.entities: + for entity in hass.data[DOMAIN_DATA].entities: for language_tag in entity.supported_languages: languages.add(language_tag) - for tts_engine in manager.providers.values(): + for tts_engine in hass.data[DATA_TTS_MANAGER].providers.values(): for language_tag in tts_engine.supported_languages: languages.add(language_tag) @@ -325,7 +317,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return False hass.data[DATA_TTS_MANAGER] = tts - component = hass.data[DOMAIN] = EntityComponent[TextToSpeechEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[TextToSpeechEntity]( _LOGGER, DOMAIN, hass ) @@ -373,14 +365,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) CACHED_PROPERTIES_WITH_ATTR_ = { @@ -1105,16 +1095,13 @@ def websocket_list_engines( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """List text to speech engines and, optionally, if they support a given language.""" - component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] - manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - country = msg.get("country") language = msg.get("language") providers = [] provider_info: dict[str, Any] entity_domains: set[str] = set() - for entity in component.entities: + for entity in hass.data[DOMAIN_DATA].entities: provider_info = { "engine_id": entity.entity_id, "supported_languages": entity.supported_languages, @@ -1126,7 +1113,7 @@ def websocket_list_engines( providers.append(provider_info) if entity.platform: entity_domains.add(entity.platform.platform_name) - for engine_id, provider in manager.providers.items(): + for engine_id, provider in hass.data[DATA_TTS_MANAGER].providers.items(): provider_info = { "engine_id": engine_id, "name": provider.name, @@ -1156,17 +1143,19 @@ def websocket_get_engine( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Get text to speech engine info.""" - component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] - manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - engine_id = msg["engine_id"] provider_info: dict[str, Any] provider: TextToSpeechEntity | Provider | None = next( - (entity for entity in component.entities if entity.entity_id == engine_id), None + ( + entity + for entity in hass.data[DOMAIN_DATA].entities + if entity.entity_id == engine_id + ), + None, ) if not provider: - provider = manager.providers.get(engine_id) + provider = hass.data[DATA_TTS_MANAGER].providers.get(engine_id) if not provider: connection.send_error( diff --git a/homeassistant/components/tts/const.py b/homeassistant/components/tts/const.py index ab22a44cab6..b465dfb15dd 100644 --- a/homeassistant/components/tts/const.py +++ b/homeassistant/components/tts/const.py @@ -1,5 +1,16 @@ """Text-to-speech constants.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import SpeechManager, TextToSpeechEntity + ATTR_CACHE = "cache" ATTR_LANGUAGE = "language" ATTR_MESSAGE = "message" @@ -15,7 +26,8 @@ DEFAULT_CACHE_DIR = "tts" DEFAULT_TIME_MEMORY = 300 DOMAIN = "tts" +DOMAIN_DATA: HassKey[EntityComponent[TextToSpeechEntity]] = HassKey(DOMAIN) -DATA_TTS_MANAGER = "tts_manager" +DATA_TTS_MANAGER: HassKey[SpeechManager] = HassKey("tts_manager") type TtsAudioType = tuple[str | None, bytes | None] diff --git a/homeassistant/components/tts/helper.py b/homeassistant/components/tts/helper.py index 4b5ef168550..41b938f7e0b 100644 --- a/homeassistant/components/tts/helper.py +++ b/homeassistant/components/tts/helper.py @@ -5,12 +5,11 @@ from __future__ import annotations from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_component import EntityComponent -from .const import DATA_TTS_MANAGER, DOMAIN +from .const import DATA_TTS_MANAGER, DOMAIN_DATA if TYPE_CHECKING: - from . import SpeechManager, TextToSpeechEntity + from . import TextToSpeechEntity from .legacy import Provider @@ -18,10 +17,7 @@ def get_engine_instance( hass: HomeAssistant, engine: str ) -> TextToSpeechEntity | Provider | None: """Get engine instance.""" - component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] - - if entity := component.get_entity(engine): + if entity := hass.data[DOMAIN_DATA].get_entity(engine): return entity - manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - return manager.providers.get(engine) + return hass.data[DATA_TTS_MANAGER].providers.get(engine) diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index e36a1227603..54ea89cb674 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -57,9 +57,6 @@ from .const import ( from .media_source import generate_media_source_id from .models import Voice -if TYPE_CHECKING: - from . import SpeechManager - _LOGGER = logging.getLogger(__name__) CONF_SERVICE_NAME = "service_name" @@ -105,8 +102,6 @@ async def async_setup_legacy( hass: HomeAssistant, config: ConfigType ) -> list[Coroutine[Any, Any, None]]: """Set up legacy text-to-speech providers.""" - tts: SpeechManager = hass.data[DATA_TTS_MANAGER] - # Load service descriptions from tts/services.yaml services_yaml = Path(__file__).parent / "services.yaml" services_dict = await hass.async_add_executor_job( @@ -147,7 +142,9 @@ async def async_setup_legacy( _LOGGER.error("Error setting up platform: %s", p_type) return - tts.async_register_legacy_engine(p_type, provider, p_config) + hass.data[DATA_TTS_MANAGER].async_register_legacy_engine( + p_type, provider, p_config + ) except Exception: _LOGGER.exception("Error setting up platform: %s", p_type) return diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index a907fc485c9..13c37681259 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -3,7 +3,7 @@ from __future__ import annotations import mimetypes -from typing import TYPE_CHECKING, TypedDict +from typing import TypedDict from yarl import URL @@ -18,14 +18,10 @@ from homeassistant.components.media_source import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_component import EntityComponent -from .const import DATA_TTS_MANAGER, DOMAIN +from .const import DATA_TTS_MANAGER, DOMAIN, DOMAIN_DATA from .helper import get_engine_instance -if TYPE_CHECKING: - from . import SpeechManager, TextToSpeechEntity - async def async_get_media_source(hass: HomeAssistant) -> TTSMediaSource: """Set up tts media source.""" @@ -44,8 +40,6 @@ def generate_media_source_id( """Generate a media source ID for text-to-speech.""" from . import async_resolve_engine # pylint: disable=import-outside-toplevel - manager: SpeechManager = hass.data[DATA_TTS_MANAGER] - if (engine := async_resolve_engine(hass, engine)) is None: raise HomeAssistantError("Invalid TTS provider selected") @@ -53,7 +47,7 @@ def generate_media_source_id( # We raise above if the engine is not resolved, so engine_instance can't be None assert engine_instance is not None - manager.process_options(engine_instance, language, options) + hass.data[DATA_TTS_MANAGER].process_options(engine_instance, language, options) params = { "message": message, } @@ -113,10 +107,8 @@ class TTSMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" - manager: SpeechManager = self.hass.data[DATA_TTS_MANAGER] - try: - url = await manager.async_get_url_path( + url = await self.hass.data[DATA_TTS_MANAGER].async_get_url_path( **media_source_id_to_kwargs(item.identifier) ) except HomeAssistantError as err: @@ -136,10 +128,12 @@ class TTSMediaSource(MediaSource): return self._engine_item(engine, params) # Root. List providers. - manager: SpeechManager = self.hass.data[DATA_TTS_MANAGER] - component: EntityComponent[TextToSpeechEntity] = self.hass.data[DOMAIN] - children = [self._engine_item(engine) for engine in manager.providers] + [ - self._engine_item(entity.entity_id) for entity in component.entities + children = [ + self._engine_item(engine) + for engine in self.hass.data[DATA_TTS_MANAGER].providers + ] + [ + self._engine_item(entity.entity_id) + for entity in self.hass.data[DOMAIN_DATA].entities ] return BrowseMediaSource( domain=DOMAIN, From 1b4ba68e184aad0de7e1334b4dfc82eaf2c14016 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 13:15:42 +0200 Subject: [PATCH 0894/1309] Use HassKey in weather (#126329) --- homeassistant/components/weather/__init__.py | 9 ++++----- homeassistant/components/weather/const.py | 9 ++++++++- homeassistant/components/weather/websocket_api.py | 8 ++------ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 28f3e6b5c53..03b8addc1c9 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -63,6 +63,7 @@ from .const import ( # noqa: F401 ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN, + DOMAIN_DATA, INTENT_GET_WEATHER, UNIT_CONVERSIONS, VALID_UNITS, @@ -196,7 +197,7 @@ class Forecast(TypedDict, total=False): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the weather component.""" - component = hass.data[DOMAIN] = EntityComponent[WeatherEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[WeatherEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) component.async_register_entity_service( @@ -217,14 +218,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class WeatherEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index 251bbd622fc..ef8eada2b3f 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from enum import IntFlag -from typing import Final +from typing import TYPE_CHECKING, Final from homeassistant.const import ( UnitOfLength, @@ -13,6 +13,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) +from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import ( DistanceConverter, PressureConverter, @@ -20,6 +21,11 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, ) +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import WeatherEntity + class WeatherEntityFeature(IntFlag): """Supported features of the update entity.""" @@ -48,6 +54,7 @@ ATTR_WEATHER_CLOUD_COVERAGE = "cloud_coverage" ATTR_WEATHER_UV_INDEX = "uv_index" DOMAIN: Final = "weather" +DOMAIN_DATA: HassKey[EntityComponent[WeatherEntity]] = HassKey(DOMAIN) INTENT_GET_WEATHER = "HassGetWeather" diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py index 98adbd1bd02..fb9759c9bdf 100644 --- a/homeassistant/components/weather/websocket_api.py +++ b/homeassistant/components/weather/websocket_api.py @@ -9,10 +9,9 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_component import EntityComponent from homeassistant.util.json import JsonValueType -from .const import DOMAIN, VALID_UNITS, WeatherEntityFeature +from .const import DOMAIN, DOMAIN_DATA, VALID_UNITS, WeatherEntityFeature FORECAST_TYPE_TO_FLAG = { "daily": WeatherEntityFeature.FORECAST_DAILY, @@ -56,13 +55,10 @@ async def ws_subscribe_forecast( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Subscribe to weather forecasts.""" - from . import WeatherEntity # pylint: disable=import-outside-toplevel - - component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] entity_id: str = msg["entity_id"] forecast_type: Literal["daily", "hourly", "twice_daily"] = msg["forecast_type"] - if not (entity := component.get_entity(msg["entity_id"])): + if not (entity := hass.data[DOMAIN_DATA].get_entity(msg["entity_id"])): connection.send_error( msg["id"], "invalid_entity_id", From 37d527bd08d56bc5edb201f2a967735c6223615f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 13:16:22 +0200 Subject: [PATCH 0895/1309] Use HassKey in camera (#126331) --- homeassistant/components/camera/__init__.py | 15 +++++++-------- homeassistant/components/camera/const.py | 9 ++++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 14f884c1750..ae081b96cd8 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -373,9 +373,7 @@ def _async_get_rtsp_to_web_rtc_providers( hass: HomeAssistant, ) -> Iterable[RtspToWebRtcProviderType]: """Return registered RTSP to WebRTC providers.""" - providers: dict[str, RtspToWebRtcProviderType] = hass.data.get( - DATA_RTSP_TO_WEB_RTC, {} - ) + providers = hass.data.get(DATA_RTSP_TO_WEB_RTC, {}) return providers.values() @@ -952,8 +950,9 @@ async def websocket_get_prefs( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle request for account info.""" - prefs: CameraPreferences = hass.data[DATA_CAMERA_PREFS] - stream_prefs = await prefs.get_dynamic_stream_settings(msg["entity_id"]) + stream_prefs = await hass.data[DATA_CAMERA_PREFS].get_dynamic_stream_settings( + msg["entity_id"] + ) connection.send_result(msg["id"], asdict(stream_prefs)) @@ -970,14 +969,14 @@ async def websocket_update_prefs( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle request for account info.""" - prefs: CameraPreferences = hass.data[DATA_CAMERA_PREFS] - changes = dict(msg) changes.pop("id") changes.pop("type") entity_id = changes.pop("entity_id") try: - entity_prefs = await prefs.async_update(entity_id, **changes) + entity_prefs = await hass.data[DATA_CAMERA_PREFS].async_update( + entity_id, **changes + ) except HomeAssistantError as ex: _LOGGER.error("Error setting camera preferences: %s", ex) connection.send_error(msg["id"], "update_failed", str(ex)) diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index d6a2372ffc1..453506e7a90 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -17,13 +17,16 @@ from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: from homeassistant.helpers.entity_component import EntityComponent - from . import Camera + from . import Camera, RtspToWebRtcProviderType + from .prefs import CameraPreferences DOMAIN: Final = "camera" DOMAIN_DATA: HassKey[EntityComponent[Camera]] = HassKey(DOMAIN) -DATA_CAMERA_PREFS: Final = "camera_prefs" -DATA_RTSP_TO_WEB_RTC: Final = "rtsp_to_web_rtc" +DATA_CAMERA_PREFS: HassKey[CameraPreferences] = HassKey("camera_prefs") +DATA_RTSP_TO_WEB_RTC: HassKey[dict[str, RtspToWebRtcProviderType]] = HassKey( + "rtsp_to_web_rtc" +) PREF_PRELOAD_STREAM: Final = "preload_stream" PREF_ORIENTATION: Final = "orientation" From aa736b2de6917847b5d83c8f30994daa504fde76 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 13:17:01 +0200 Subject: [PATCH 0896/1309] Use HassKey in notify (#126338) --- homeassistant/components/notify/__init__.py | 12 +++++---- homeassistant/components/notify/legacy.py | 29 ++++++++++----------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index f9b0a64db3d..75b4b65ac5b 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +from homeassistant.util.hass_dict import HassKey from .const import ( # noqa: F401 ATTR_DATA, @@ -46,6 +47,7 @@ from .repairs import migrate_notify_issue # noqa: F401 # Platform specific data ATTR_TITLE_DEFAULT = "Home Assistant" +DOMAIN_DATA: HassKey[EntityComponent[NotifyEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) @@ -76,7 +78,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # legacy platforms to finish setting up. hass.async_create_task(setup, eager_start=True) - component = hass.data[DOMAIN] = EntityComponent[NotifyEntity](_LOGGER, DOMAIN, hass) + component = hass.data[DOMAIN_DATA] = EntityComponent[NotifyEntity]( + _LOGGER, DOMAIN, hass + ) component.async_register_entity_service( SERVICE_SEND_MESSAGE, { @@ -113,14 +117,12 @@ class NotifyEntityDescription(EntityDescription, frozen_or_thawed=True): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[NotifyEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[NotifyEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) class NotifyEntity(RestoreEntity): diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index a210e80242e..46538aad921 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -3,13 +3,13 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine, Mapping +from collections.abc import Coroutine, Mapping from functools import partial from typing import Any, Protocol, cast from homeassistant.config import config_per_platform from homeassistant.const import CONF_DESCRIPTION, CONF_NAME -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery from homeassistant.helpers.service import async_set_service_schema @@ -21,6 +21,7 @@ from homeassistant.setup import ( async_start_setup, ) from homeassistant.util import slugify +from homeassistant.util.hass_dict import HassKey from homeassistant.util.yaml import load_yaml_dict from .const import ( @@ -35,8 +36,12 @@ from .const import ( ) CONF_FIELDS = "fields" -NOTIFY_SERVICES = "notify_services" -NOTIFY_DISCOVERY_DISPATCHER = "notify_discovery_dispatcher" +NOTIFY_SERVICES: HassKey[dict[str, list[BaseNotificationService]]] = HassKey( + f"{DOMAIN}_services" +) +NOTIFY_DISCOVERY_DISPATCHER: HassKey[CALLBACK_TYPE | None] = HassKey( + f"{DOMAIN}_discovery_dispatcher" +) class LegacyNotifyPlatform(Protocol): @@ -160,11 +165,9 @@ async def async_reload(hass: HomeAssistant, integration_name: str) -> None: if not _async_integration_has_notify_services(hass, integration_name): return - notify_services: list[BaseNotificationService] = hass.data[NOTIFY_SERVICES][ - integration_name - ] tasks = [ - notify_service.async_register_services() for notify_service in notify_services + notify_service.async_register_services() + for notify_service in hass.data[NOTIFY_SERVICES][integration_name] ] await asyncio.gather(*tasks) @@ -173,20 +176,16 @@ async def async_reload(hass: HomeAssistant, integration_name: str) -> None: @bind_hass async def async_reset_platform(hass: HomeAssistant, integration_name: str) -> None: """Unregister notify services for an integration.""" - notify_discovery_dispatcher: Callable[[], None] | None = hass.data.get( - NOTIFY_DISCOVERY_DISPATCHER - ) + notify_discovery_dispatcher = hass.data.get(NOTIFY_DISCOVERY_DISPATCHER) if notify_discovery_dispatcher: notify_discovery_dispatcher() hass.data[NOTIFY_DISCOVERY_DISPATCHER] = None if not _async_integration_has_notify_services(hass, integration_name): return - notify_services: list[BaseNotificationService] = hass.data[NOTIFY_SERVICES][ - integration_name - ] tasks = [ - notify_service.async_unregister_services() for notify_service in notify_services + notify_service.async_unregister_services() + for notify_service in hass.data[NOTIFY_SERVICES][integration_name] ] await asyncio.gather(*tasks) From 5b22cfa9b3e903a200438df153e693337a0ca67c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 21 Sep 2024 16:30:40 +0200 Subject: [PATCH 0897/1309] Use HassKey in todo (#126325) * Use HassKey in todo * One more --- homeassistant/components/todo/__init__.py | 20 +++++++++----------- homeassistant/components/todo/const.py | 11 +++++++++++ homeassistant/components/todo/intent.py | 9 +++++---- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index d35d9d6bbea..533ae354dd2 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -1,5 +1,7 @@ """The todo integration.""" +from __future__ import annotations + from collections.abc import Callable, Iterable import dataclasses import datetime @@ -37,6 +39,7 @@ from .const import ( ATTR_RENAME, ATTR_STATUS, DOMAIN, + DOMAIN_DATA, TodoItemStatus, TodoListEntityFeature, TodoServices, @@ -111,7 +114,7 @@ def _validate_supported_features( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Todo entities.""" - component = hass.data[DOMAIN] = EntityComponent[TodoListEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[TodoListEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -194,14 +197,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) @dataclasses.dataclass @@ -331,10 +332,9 @@ async def websocket_handle_subscribe_todo_items( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Subscribe to To-do list item updates.""" - component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] entity_id: str = msg["entity_id"] - if not (entity := component.get_entity(entity_id)): + if not (entity := hass.data[DOMAIN_DATA].get_entity(entity_id)): connection.send_error( msg["id"], "invalid_entity_id", @@ -387,10 +387,9 @@ async def websocket_handle_todo_item_list( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle the list of To-do items in a To-do- list.""" - component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] if ( not (entity_id := msg[CONF_ENTITY_ID]) - or not (entity := component.get_entity(entity_id)) + or not (entity := hass.data[DOMAIN_DATA].get_entity(entity_id)) or not isinstance(entity, TodoListEntity) ): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") @@ -423,8 +422,7 @@ async def websocket_handle_todo_item_move( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle move of a To-do item within a To-do list.""" - component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] - if not (entity := component.get_entity(msg["entity_id"])): + if not (entity := hass.data[DOMAIN_DATA].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py index ee7ef53715d..634075d7f32 100644 --- a/homeassistant/components/todo/const.py +++ b/homeassistant/components/todo/const.py @@ -1,8 +1,19 @@ """Constants for the To-do integration.""" +from __future__ import annotations + from enum import IntFlag, StrEnum +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import TodoListEntity DOMAIN = "todo" +DOMAIN_DATA: HassKey[EntityComponent[TodoListEntity]] = HassKey(DOMAIN) ATTR_DUE = "due" ATTR_DUE_DATE = "due_date" diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index cd8ad7f02ab..6520e6c12b7 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -6,9 +6,9 @@ import voluptuous as vol from homeassistant.core import HomeAssistant from homeassistant.helpers import intent -from homeassistant.helpers.entity_component import EntityComponent -from . import DOMAIN, TodoItem, TodoItemStatus, TodoListEntity +from . import TodoItem, TodoItemStatus, TodoListEntity +from .const import DOMAIN, DOMAIN_DATA INTENT_LIST_ADD_ITEM = "HassListAddItem" @@ -37,7 +37,6 @@ class ListAddItemIntent(intent.IntentHandler): item = slots["item"]["value"] list_name = slots["name"]["value"] - component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] target_list: TodoListEntity | None = None # Find matching list @@ -50,7 +49,9 @@ class ListAddItemIntent(intent.IntentHandler): result=match_result, constraints=match_constraints ) - target_list = component.get_entity(match_result.states[0].entity_id) + target_list = hass.data[DOMAIN_DATA].get_entity( + match_result.states[0].entity_id + ) if target_list is None: raise intent.IntentHandleError(f"No to-do list: {list_name}") From 24106114b4de37e14d2214023da99ffd7e25afa3 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 21 Sep 2024 15:44:35 +0100 Subject: [PATCH 0898/1309] Correct / tidy up entity doc strings for evohome (#126380) * correct / tidy up entity doc strings * tweak --- homeassistant/components/evohome/climate.py | 10 +++++----- homeassistant/components/evohome/entity.py | 12 ++++++------ homeassistant/components/evohome/water_heater.py | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 07601474062..5aa99bca60e 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -148,7 +148,7 @@ async def async_setup_platform( class EvoClimateEntity(EvoDevice, ClimateEntity): - """Base for an evohome Climate device.""" + """Base for any evohome-compatible climate entity (controller, zone).""" _attr_temperature_unit = UnitOfTemperature.CELSIUS _enable_turn_on_off_backwards_compatibility = False @@ -160,14 +160,14 @@ class EvoClimateEntity(EvoDevice, ClimateEntity): class EvoZone(EvoChild, EvoClimateEntity): - """Base for a Honeywell TCC Zone.""" + """Base for any evohome-compatible heating zone.""" _attr_preset_modes = list(HA_PRESET_TO_EVO) _evo_device: evo.Zone # mypy hint def __init__(self, evo_broker: EvoBroker, evo_device: evo.Zone) -> None: - """Initialize a Honeywell TCC Zone.""" + """Initialize an evohome-compatible heating zone.""" super().__init__(evo_broker, evo_device) self._evo_id = evo_device.zoneId @@ -342,7 +342,7 @@ class EvoZone(EvoChild, EvoClimateEntity): class EvoController(EvoClimateEntity): - """Base for a Honeywell TCC Controller/Location. + """Base for any evohome-compatible controller. The Controller (aka TCS, temperature control system) is the parent of all the child (CH/DHW) devices. It is implemented as a Climate entity to expose the controller's @@ -357,7 +357,7 @@ class EvoController(EvoClimateEntity): _evo_device: evo.ControlSystem # mypy hint def __init__(self, evo_broker: EvoBroker, evo_device: evo.ControlSystem) -> None: - """Initialize a Honeywell TCC Controller/Location.""" + """Initialize an evohome-compatible controller.""" super().__init__(evo_broker, evo_device) self._evo_id = evo_device.systemId diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index 4f85791572c..5da9df247cd 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -26,9 +26,9 @@ _LOGGER = logging.getLogger(__name__) class EvoDevice(Entity): - """Base for any evohome device. + """Base for any evohome-compatible entity (controller, DHW, zone). - This includes the Controller, (up to 12) Heating Zones and (optionally) a + This includes the controller, (1 to 12) heating zones and (optionally) a DHW controller. """ @@ -39,7 +39,7 @@ class EvoDevice(Entity): evo_broker: EvoBroker, evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, ) -> None: - """Initialize the evohome entity.""" + """Initialize an evohome-compatible entity (TCS, DHW, zone).""" self._evo_device = evo_device self._evo_broker = evo_broker self._evo_tcs = evo_broker.tcs @@ -88,9 +88,9 @@ class EvoDevice(Entity): class EvoChild(EvoDevice): - """Base for any evohome child. + """Base for any evohome-compatible child entity (DHW, zone). - This includes (up to 12) Heating Zones and (optionally) a DHW controller. + This includes (1 to 12) heating zones and (optionally) a DHW controller. """ _evo_id: str # mypy hint @@ -98,7 +98,7 @@ class EvoChild(EvoDevice): def __init__( self, evo_broker: EvoBroker, evo_device: evo.HotWater | evo.Zone ) -> None: - """Initialize a evohome Controller (hub).""" + """Initialize an evohome-compatible child entity (DHW, zone).""" super().__init__(evo_broker, evo_device) self._schedule: dict[str, Any] = {} diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index abf3e2f3926..a50e16b5dda 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -74,7 +74,7 @@ async def async_setup_platform( class EvoDHW(EvoChild, WaterHeaterEntity): - """Base for a Honeywell TCC DHW controller (aka boiler).""" + """Base for any evohome-compatible DHW controller.""" _attr_name = "DHW controller" _attr_icon = "mdi:thermometer-lines" @@ -84,7 +84,7 @@ class EvoDHW(EvoChild, WaterHeaterEntity): _evo_device: evo.HotWater # mypy hint def __init__(self, evo_broker: EvoBroker, evo_device: evo.HotWater) -> None: - """Initialize an evohome DHW controller.""" + """Initialize an evohome-compatible DHW controller.""" super().__init__(evo_broker, evo_device) self._evo_id = evo_device.dhwId From 556deb4f777d47df46f66fd8c3bb4a44204c942c Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Sat, 21 Sep 2024 18:03:51 +0100 Subject: [PATCH 0899/1309] Fix tplink number platform to use intended BOX mode (#126397) The NumberMode should be BOX as per the entity description but due to the missing dataclass decorator was resolving to NumberMode.AUTO. --- homeassistant/components/tplink/number.py | 2 ++ .../components/tplink/snapshots/test_number.ambr | 16 ++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py index 4b273800e6a..999d01b2814 100644 --- a/homeassistant/components/tplink/number.py +++ b/homeassistant/components/tplink/number.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import Final @@ -26,6 +27,7 @@ from .entity import ( _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True, kw_only=True) class TPLinkNumberEntityDescription( NumberEntityDescription, TPLinkFeatureEntityDescription ): diff --git a/tests/components/tplink/snapshots/test_number.ambr b/tests/components/tplink/snapshots/test_number.ambr index ee06314ffe3..977d2098fb9 100644 --- a/tests/components/tplink/snapshots/test_number.ambr +++ b/tests/components/tplink/snapshots/test_number.ambr @@ -43,7 +43,7 @@ 'capabilities': dict({ 'max': 65536, 'min': 0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -79,7 +79,7 @@ 'friendly_name': 'my_device Smooth off', 'max': 65536, 'min': 0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -98,7 +98,7 @@ 'capabilities': dict({ 'max': 65536, 'min': 0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -134,7 +134,7 @@ 'friendly_name': 'my_device Smooth on', 'max': 65536, 'min': 0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -153,7 +153,7 @@ 'capabilities': dict({ 'max': 65536, 'min': -10, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -189,7 +189,7 @@ 'friendly_name': 'my_device Temperature offset', 'max': 65536, 'min': -10, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -208,7 +208,7 @@ 'capabilities': dict({ 'max': 65536, 'min': 0, - 'mode': , + 'mode': , 'step': 1.0, }), 'config_entry_id': , @@ -244,7 +244,7 @@ 'friendly_name': 'my_device Turn off in', 'max': 65536, 'min': 0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , From 505fb3738f51476db512b01f51c3644d3628b39e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 21 Sep 2024 10:56:13 -0700 Subject: [PATCH 0900/1309] Update the Google Photos integration to limit scope to Home Assistant created content (#126398) --- .../components/google_photos/const.py | 7 ++----- .../components/google_photos/media_source.py | 17 +++-------------- .../google_photos/test_config_flow.py | 18 ++++++------------ .../google_photos/test_media_source.py | 6 ++---- .../components/google_photos/test_services.py | 4 ++-- 5 files changed, 15 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/google_photos/const.py b/homeassistant/components/google_photos/const.py index c629e6feb27..9c623ed7819 100644 --- a/homeassistant/components/google_photos/const.py +++ b/homeassistant/components/google_photos/const.py @@ -6,12 +6,9 @@ OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" UPLOAD_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly" -READ_SCOPES = [ - "https://www.googleapis.com/auth/photoslibrary.readonly", - "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", -] +READ_SCOPE = "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" OAUTH2_SCOPES = [ - *READ_SCOPES, + READ_SCOPE, UPLOAD_SCOPE, "https://www.googleapis.com/auth/userinfo.profile", ] diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index 63d66d5a82b..2388869d75b 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -19,11 +19,10 @@ from homeassistant.components.media_source import ( from homeassistant.core import HomeAssistant from . import GooglePhotosConfigEntry -from .const import DOMAIN, READ_SCOPES +from .const import DOMAIN, READ_SCOPE _LOGGER = logging.getLogger(__name__) -MAX_RECENT_PHOTOS = 100 MEDIA_ITEMS_PAGE_SIZE = 100 ALBUM_PAGE_SIZE = 50 @@ -38,16 +37,12 @@ class SpecialAlbumDetails: path: str title: str list_args: dict[str, Any] - max_photos: int | None class SpecialAlbum(Enum): """Special Album types.""" - RECENT = SpecialAlbumDetails("recent", "Recent Photos", {}, MAX_RECENT_PHOTOS) - FAVORITE = SpecialAlbumDetails( - "favorites", "Favorite Photos", {"favorites": True}, None - ) + UPLOADED = SpecialAlbumDetails("uploaded", "Uploaded", {}) @classmethod def of(cls, path: str) -> Self | None: @@ -247,12 +242,6 @@ class GooglePhotosMediaSource(MediaSource): **list_args, page_size=MEDIA_ITEMS_PAGE_SIZE ): media_items.extend(media_item_result.media_items) - if ( - special_album - and (max_photos := special_album.value.max_photos) - and len(media_items) > max_photos - ): - break except GooglePhotosApiError as err: raise BrowseError(f"Error listing media items: {err}") from err @@ -270,7 +259,7 @@ class GooglePhotosMediaSource(MediaSource): entries = [] for entry in self.hass.config_entries.async_loaded_entries(DOMAIN): scopes = entry.data["token"]["scope"].split(" ") - if any(scope in scopes for scope in READ_SCOPES): + if READ_SCOPE in scopes: entries.append(entry) return entries diff --git a/tests/components/google_photos/test_config_flow.py b/tests/components/google_photos/test_config_flow.py index 48c8723df3c..4896f82effb 100644 --- a/tests/components/google_photos/test_config_flow.py +++ b/tests/components/google_photos/test_config_flow.py @@ -92,8 +92,7 @@ async def test_full_flow( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" - "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" - "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" @@ -121,8 +120,7 @@ async def test_full_flow( "refresh_token": FAKE_REFRESH_TOKEN, "type": "Bearer", "scope": ( - "https://www.googleapis.com/auth/photoslibrary.readonly" - " https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" " https://www.googleapis.com/auth/photoslibrary.appendonly" " https://www.googleapis.com/auth/userinfo.profile" ), @@ -163,8 +161,7 @@ async def test_api_not_enabled( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" - "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" - "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" @@ -203,8 +200,7 @@ async def test_general_exception( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" - "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" - "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" @@ -288,8 +284,7 @@ async def test_reauth( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" "&redirect_uri=https://example.com/auth/external/callback" f"&state={state}" - "&scope=https://www.googleapis.com/auth/photoslibrary.readonly" - "+https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "&scope=https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" "+https://www.googleapis.com/auth/photoslibrary.appendonly" "+https://www.googleapis.com/auth/userinfo.profile" "&access_type=offline&prompt=consent" @@ -321,8 +316,7 @@ async def test_reauth( "refresh_token": FAKE_REFRESH_TOKEN, "type": "Bearer", "scope": ( - "https://www.googleapis.com/auth/photoslibrary.readonly" - " https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata" " https://www.googleapis.com/auth/photoslibrary.appendonly" " https://www.googleapis.com/auth/userinfo.profile" ), diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index 762a4d5ebd1..9d287998fa8 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -66,8 +66,7 @@ async def test_no_read_scopes( @pytest.mark.parametrize( ("album_path", "expected_album_title"), [ - (f"{CONFIG_ENTRY_ID}/a/recent", "Recent Photos"), - (f"{CONFIG_ENTRY_ID}/a/favorites", "Favorite Photos"), + (f"{CONFIG_ENTRY_ID}/a/uploaded", "Uploaded Photos"), (f"{CONFIG_ENTRY_ID}/a/album-media-id-1", "Album title"), ], ) @@ -109,8 +108,7 @@ async def test_browse_albums( assert browse.identifier == CONFIG_ENTRY_ID assert browse.title == "Account Name" assert [(child.identifier, child.title) for child in browse.children] == [ - (f"{CONFIG_ENTRY_ID}/a/recent", "Recent Photos"), - (f"{CONFIG_ENTRY_ID}/a/favorites", "Favorite Photos"), + (f"{CONFIG_ENTRY_ID}/a/uploaded", "Uploaded"), (f"{CONFIG_ENTRY_ID}/a/album-media-id-1", "Album title"), ] diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py index 10d57e1d178..eaf7163f62b 100644 --- a/tests/components/google_photos/test_services.py +++ b/tests/components/google_photos/test_services.py @@ -11,7 +11,7 @@ from google_photos_library_api.model import ( ) import pytest -from homeassistant.components.google_photos.const import DOMAIN, READ_SCOPES +from homeassistant.components.google_photos.const import DOMAIN, READ_SCOPE from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -225,7 +225,7 @@ async def test_upload_service_fails_create( @pytest.mark.parametrize( ("scopes"), [ - READ_SCOPES, + [READ_SCOPE], ], ) async def test_upload_service_no_scope( From 9bfc2eaeb9a221eeec8fc91e50857b06952d6ebd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 21 Sep 2024 21:11:17 +0200 Subject: [PATCH 0901/1309] Set connection and command timeout in VLC Telnet (#126401) use 1s lower than scan interval --- homeassistant/components/vlc_telnet/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vlc_telnet/__init__.py b/homeassistant/components/vlc_telnet/__init__.py index a61fcafd2cb..c327b58a644 100644 --- a/homeassistant/components/vlc_telnet/__init__.py +++ b/homeassistant/components/vlc_telnet/__init__.py @@ -5,6 +5,9 @@ from dataclasses import dataclass from aiovlc.client import Client from aiovlc.exceptions import AuthError, ConnectError +from homeassistant.components.media_player import ( + SCAN_INTERVAL as MEDIAPLAYER_SCAN_INTERVAL, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform from homeassistant.core import HomeAssistant @@ -33,7 +36,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: VlcConfigEntry) -> bool: port = config[CONF_PORT] password = config[CONF_PASSWORD] - vlc = Client(password=password, host=host, port=port) + vlc = Client( + password=password, + host=host, + port=port, + timeout=int(MEDIAPLAYER_SCAN_INTERVAL.total_seconds() - 1), + ) available = True From 6cd99e4ed42a63396c9f85640957d7464126eeb3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 21 Sep 2024 23:14:12 +0200 Subject: [PATCH 0902/1309] Add issue asking users to disable ESPHome assist_in_progress sensor (#125805) * Add issue asking users to disable ESPHome assist_in_progress binary sensor * Include integration name in title and description * Add repair flow * Improve test coverage --- .../components/assist_pipeline/manifest.json | 1 + .../assist_pipeline/repair_flows.py | 55 ++++++++ .../components/assist_pipeline/strings.json | 12 ++ .../components/esphome/binary_sensor.py | 38 ++++++ homeassistant/components/esphome/repairs.py | 22 ++++ homeassistant/components/esphome/strings.json | 10 ++ .../assist_pipeline/test_repair_flows.py | 17 +++ .../components/esphome/test_binary_sensor.py | 119 +++++++++++++++++- tests/components/esphome/test_repairs.py | 13 ++ 9 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/assist_pipeline/repair_flows.py create mode 100644 homeassistant/components/esphome/repairs.py create mode 100644 tests/components/assist_pipeline/test_repair_flows.py create mode 100644 tests/components/esphome/test_repairs.py diff --git a/homeassistant/components/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 1b93ecd9eef..3a59d8f87f1 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -1,6 +1,7 @@ { "domain": "assist_pipeline", "name": "Assist pipeline", + "after_dependencies": ["repairs"], "codeowners": ["@balloob", "@synesthesiam"], "dependencies": ["conversation", "stt", "tts", "wake_word"], "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", diff --git a/homeassistant/components/assist_pipeline/repair_flows.py b/homeassistant/components/assist_pipeline/repair_flows.py new file mode 100644 index 00000000000..d3d9633bd06 --- /dev/null +++ b/homeassistant/components/assist_pipeline/repair_flows.py @@ -0,0 +1,55 @@ +"""Repairs implementation for the cloud integration.""" + +from __future__ import annotations + +from typing import cast + +import voluptuous as vol + +from homeassistant.components.assist_satellite import DOMAIN as ASSIST_SATELLITE_DOMAIN +from homeassistant.components.repairs import RepairsFlow +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import entity_registry as er + +REQUIRED_KEYS = ("entity_id", "entity_uuid", "integration_name") + + +class AssistInProgressDeprecatedRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict[str, str | int | float | None] | None) -> None: + """Initialize.""" + if not data or any(key not in data for key in REQUIRED_KEYS): + raise ValueError("Missing data") + self._data = data + + async def async_step_init(self, _: None = None) -> FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm_disable_entity() + + async def async_step_confirm_disable_entity( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + entity_registry = er.async_get(self.hass) + entity_entry = entity_registry.async_get( + cast(str, self._data["entity_uuid"]) + ) + if entity_entry: + entity_registry.async_update_entity( + entity_entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER + ) + return self.async_create_entry(data={}) + + description_placeholders: dict[str, str] = { + "assist_satellite_domain": ASSIST_SATELLITE_DOMAIN, + "entity_id": cast(str, self._data["entity_id"]), + "integration_name": cast(str, self._data["integration_name"]), + } + return self.async_show_form( + step_id="confirm_disable_entity", + data_schema=vol.Schema({}), + description_placeholders=description_placeholders, + ) diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json index 8fa67879fc3..d81bcf83a1a 100644 --- a/homeassistant/components/assist_pipeline/strings.json +++ b/homeassistant/components/assist_pipeline/strings.json @@ -21,5 +21,17 @@ } } } + }, + "issues": { + "assist_in_progress_deprecated": { + "title": "{integration_name} assist in progress binary sensors are deprecated", + "fix_flow": { + "step": { + "confirm_disable_entity": { + "description": "The {integration_name} assist in progress binary sensor `{entity_id}` is deprecated.\n\nMigrate your configuration to use the corresponding `{assist_satellite_domain}` entity and then click SUBMIT to disable the assist in progress binary sensor and fix this issue." + } + } + } + } } } diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 0f404445486..8c2353519fe 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityInfo from homeassistant.components.binary_sensor import ( @@ -10,9 +12,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum +from .const import DOMAIN from .entity import EsphomeAssistEntity, EsphomeEntity, platform_async_setup_entry from .entry_data import ESPHomeConfigEntry @@ -79,6 +83,40 @@ class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntit translation_key="assist_in_progress", ) + async def async_added_to_hass(self) -> None: + """Create issue.""" + await super().async_added_to_hass() + if TYPE_CHECKING: + assert self.registry_entry is not None + ir.async_create_issue( + self.hass, + DOMAIN, + f"assist_in_progress_deprecated_{self.registry_entry.id}", + breaks_in_ha_version="2025.3", + data={ + "entity_id": self.entity_id, + "entity_uuid": self.registry_entry.id, + "integration_name": "ESPHome", + }, + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="assist_in_progress_deprecated", + translation_placeholders={ + "integration_name": "ESPHome", + }, + ) + + async def async_will_remove_from_hass(self) -> None: + """Remove issue.""" + await super().async_will_remove_from_hass() + if TYPE_CHECKING: + assert self.registry_entry is not None + ir.async_delete_issue( + self.hass, + DOMAIN, + f"assist_in_progress_deprecated_{self.registry_entry.id}", + ) + @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/esphome/repairs.py b/homeassistant/components/esphome/repairs.py new file mode 100644 index 00000000000..24c8aa16a12 --- /dev/null +++ b/homeassistant/components/esphome/repairs.py @@ -0,0 +1,22 @@ +"""Repairs implementation for the cloud integration.""" + +from __future__ import annotations + +from homeassistant.components.assist_pipeline.repair_flows import ( + AssistInProgressDeprecatedRepairFlow, +) +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("assist_in_progress_deprecated"): + return AssistInProgressDeprecatedRepairFlow(data) + # If ESPHome adds confirm-only repairs in the future, this should be changed + # to return a ConfirmRepairFlow instead of raising a ValueError + raise ValueError(f"unknown repair {issue_id}") diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index eb2e8f65b78..026b2bd0690 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -93,6 +93,16 @@ } }, "issues": { + "assist_in_progress_deprecated": { + "title": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::title%]", + "fix_flow": { + "step": { + "confirm_disable_entity": { + "description": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::fix_flow::step::confirm_disable_entity::description%]" + } + } + } + }, "ble_firmware_outdated": { "title": "Update {name} with ESPHome {version} or later", "description": "To improve Bluetooth reliability and performance, we highly recommend updating {name} with ESPHome {version} or later. When updating the device from ESPHome earlier than 2022.12.0, it is recommended to use a serial cable instead of an over-the-air update to take advantage of the new partition scheme." diff --git a/tests/components/assist_pipeline/test_repair_flows.py b/tests/components/assist_pipeline/test_repair_flows.py new file mode 100644 index 00000000000..4c8a242b20c --- /dev/null +++ b/tests/components/assist_pipeline/test_repair_flows.py @@ -0,0 +1,17 @@ +"""Test repair flows.""" + +import pytest + +from homeassistant.components.assist_pipeline.repair_flows import ( + AssistInProgressDeprecatedRepairFlow, +) + + +@pytest.mark.parametrize( + "data", [None, {}, {"entity_id": "blah", "entity_uuid": "12345"}] +) +def test_assist_in_progress_deprecated_flow_requires_data(data: dict | None) -> None: + """Test AssistInProgressDeprecatedRepairFlow requires data.""" + + with pytest.raises(ValueError): + AssistInProgressDeprecatedRepairFlow(data) diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index a28e55de87f..25d8b60f574 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -1,6 +1,7 @@ """Test ESPHome binary sensors.""" from collections.abc import Awaitable, Callable +from http import HTTPStatus from aioesphomeapi import ( APIClient, @@ -12,14 +13,17 @@ from aioesphomeapi import ( ) import pytest -from homeassistant.components.esphome import DomainData +from homeassistant.components.esphome import DOMAIN, DomainData +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from .conftest import MockESPHomeDevice from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -49,6 +53,7 @@ async def test_assist_in_progress( async def test_assist_in_progress_disabled_by_default( hass: HomeAssistant, entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, mock_voice_assistant_v1_entry, ) -> None: """Test assist in progress binary sensor is added disabled.""" @@ -59,6 +64,116 @@ async def test_assist_in_progress_disabled_by_default( assert entity_entry.disabled assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + # Test no issue for disabled entity + assert len(issue_registry.issues) == 0 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_assist_in_progress_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_voice_assistant_v1_entry, +) -> None: + """Test assist in progress binary sensor.""" + + state = hass.states.get("binary_sensor.test_assist_in_progress") + assert state is not None + + entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") + issue = issue_registry.async_get_issue( + DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" + ) + assert issue is not None + + # Test issue goes away after disabling the entity + entity_registry.async_update_entity( + "binary_sensor.test_assist_in_progress", + disabled_by=er.RegistryEntryDisabler.USER, + ) + await hass.async_block_till_done() + issue = issue_registry.async_get_issue( + DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" + ) + assert issue is None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_assist_in_progress_repair_flow( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + mock_voice_assistant_v1_entry, +) -> None: + """Test assist in progress binary sensor deprecation issue flow.""" + + state = hass.states.get("binary_sensor.test_assist_in_progress") + assert state is not None + + entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") + assert entity_entry.disabled_by is None + issue = issue_registry.async_get_issue( + DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" + ) + assert issue is not None + assert issue.data == { + "entity_id": "binary_sensor.test_assist_in_progress", + "entity_uuid": entity_entry.id, + "integration_name": "ESPHome", + } + assert issue.translation_key == "assist_in_progress_deprecated" + assert issue.translation_placeholders == {"integration_name": "ESPHome"} + + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_start() + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "data_schema": [], + "description_placeholders": { + "assist_satellite_domain": "assist_satellite", + "entity_id": "binary_sensor.test_assist_in_progress", + "integration_name": "ESPHome", + }, + "errors": None, + "flow_id": flow_id, + "handler": DOMAIN, + "last_step": None, + "preview": None, + "step_id": "confirm_disable_entity", + "type": "form", + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "description": None, + "description_placeholders": None, + "flow_id": flow_id, + "handler": DOMAIN, + "type": "create_entry", + } + + # Test the entity is disabled + entity_entry = entity_registry.async_get("binary_sensor.test_assist_in_progress") + assert entity_entry.disabled_by is er.RegistryEntryDisabler.USER + @pytest.mark.parametrize( "binary_state", [(True, STATE_ON), (False, STATE_OFF), (None, STATE_UNKNOWN)] diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py new file mode 100644 index 00000000000..76a10cae8e3 --- /dev/null +++ b/tests/components/esphome/test_repairs.py @@ -0,0 +1,13 @@ +"""Test ESPHome binary sensors.""" + +import pytest + +from homeassistant.components.esphome import repairs +from homeassistant.core import HomeAssistant + + +async def test_create_fix_flow_raises_on_unknown_issue_id(hass: HomeAssistant) -> None: + """Test reate_fix_flow raises on unknown issue_id.""" + + with pytest.raises(ValueError): + await repairs.async_create_fix_flow(hass, "no_such_issue", None) From a923f15d171fe6af5cbb6b1d11758800080a5908 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sat, 21 Sep 2024 23:29:41 +0100 Subject: [PATCH 0903/1309] Rename some evohome constants for clarity / readability (#126394) initial commit --- tests/components/evohome/test_storage.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 32cd49a1539..e44f98651fd 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -55,7 +55,7 @@ ACCESS_TOKEN_EXP_DTM, ACCESS_TOKEN_EXP_STR = dt_pair(dt_util.now() + timedelta(h USERNAME_DIFF: Final = f"not_{USERNAME}" USERNAME_SAME: Final = USERNAME -TEST_DATA: Final[dict[str, _TokenStoreT]] = { +TEST_STORAGE_DATA: Final[dict[str, _TokenStoreT]] = { "sans_session_id": { SZ_USERNAME: USERNAME_SAME, SZ_REFRESH_TOKEN: REFRESH_TOKEN, @@ -71,7 +71,7 @@ TEST_DATA: Final[dict[str, _TokenStoreT]] = { }, } -TEST_DATA_NULL: Final[dict[str, _EmptyStoreT | None]] = { +TEST_STORAGE_NULL: Final[dict[str, _EmptyStoreT | None]] = { "store_is_absent": None, "store_was_reset": {}, } @@ -83,7 +83,7 @@ DOMAIN_STORAGE_BASE: Final = { } -@pytest.mark.parametrize("idx", TEST_DATA_NULL) +@pytest.mark.parametrize("idx", TEST_STORAGE_NULL) async def test_auth_tokens_null( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -92,7 +92,7 @@ async def test_auth_tokens_null( ) -> None: """Test loading/saving authentication tokens when no cached tokens in the store.""" - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA_NULL[idx]} + hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_NULL[idx]} mock_client = await setup_evohome(hass, evo_config, install="minimal") @@ -113,7 +113,7 @@ async def test_auth_tokens_null( ) -@pytest.mark.parametrize("idx", TEST_DATA) +@pytest.mark.parametrize("idx", TEST_STORAGE_DATA) async def test_auth_tokens_same( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -122,7 +122,7 @@ async def test_auth_tokens_same( ) -> None: """Test loading/saving authentication tokens when matching username.""" - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA[idx]} + hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} mock_client = await setup_evohome(hass, evo_config, install="minimal") @@ -142,7 +142,7 @@ async def test_auth_tokens_same( assert dt_util.parse_datetime(data[SZ_ACCESS_TOKEN_EXPIRES]) == ACCESS_TOKEN_EXP_DTM -@pytest.mark.parametrize("idx", TEST_DATA) +@pytest.mark.parametrize("idx", TEST_STORAGE_DATA) async def test_auth_tokens_past( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -154,7 +154,7 @@ async def test_auth_tokens_past( dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) # make this access token have expired in the past... - test_data = TEST_DATA[idx].copy() # shallow copy is OK here + test_data = TEST_STORAGE_DATA[idx].copy() # shallow copy is OK here test_data[SZ_ACCESS_TOKEN_EXPIRES] = dt_str hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data} @@ -180,7 +180,7 @@ async def test_auth_tokens_past( ) -@pytest.mark.parametrize("idx", TEST_DATA) +@pytest.mark.parametrize("idx", TEST_STORAGE_DATA) async def test_auth_tokens_diff( hass: HomeAssistant, hass_storage: dict[str, Any], @@ -189,7 +189,7 @@ async def test_auth_tokens_diff( ) -> None: """Test loading/saving authentication tokens when unmatched username.""" - hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_DATA[idx]} + hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} mock_client = await setup_evohome( hass, evo_config | {CONF_USERNAME: USERNAME_DIFF}, install="minimal" From d8e9d1c16efdd7c05e74c7690dd0060cfd84d23e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Sep 2024 19:40:16 -0400 Subject: [PATCH 0904/1309] Bump uiprotect to 6.1.0 (#126345) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 4483a5990eb..1e9f7d11807 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -40,7 +40,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["uiprotect", "unifi_discovery"], - "requirements": ["uiprotect==6.0.2", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==6.1.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 90263c03ee9..ed9c83a6cfb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2866,7 +2866,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.0.2 +uiprotect==6.1.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5756ee4a4c5..dbafd13a88c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2273,7 +2273,7 @@ typedmonarchmoney==0.3.1 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==6.0.2 +uiprotect==6.1.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From af2798f0632d521edee7ba455e41fa75cb6c3c51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Sep 2024 20:18:53 -0400 Subject: [PATCH 0905/1309] Switch genexp to listcomp in async_progress_by_init_data_type (#126405) Since listcomps are inlined in python 3.12+, the listcomp will be a bit faster. Additionally we always iterate everything here so there is no reason to use a genexpr --- homeassistant/data_entry_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 7ecbe5508c6..dff7ebee03c 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -296,11 +296,11 @@ class FlowManager(abc.ABC, Generic[_FlowResultT, _HandlerT]): ) -> list[_FlowResultT]: """Return flows in progress init matching by data type as a partial FlowResult.""" return self._async_flow_handler_to_flow_result( - ( + [ progress for progress in self._init_data_process_index.get(init_data_type, ()) if matcher(progress.init_data) - ), + ], include_uninitialized, ) From 5db3c6e47bcbf49057b7f4af6b7d9f3a801a6fb4 Mon Sep 17 00:00:00 2001 From: Oliver <10700296+ol-iver@users.noreply.github.com> Date: Sun, 22 Sep 2024 03:00:35 +0200 Subject: [PATCH 0906/1309] Disconnect telnet when `denonavr` media player entity is unloaded (#126406) Disconnect telnet when unloading `denonavr` media player entity --- homeassistant/components/denonavr/media_player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 091b70283b1..a6a94404fd3 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -301,6 +301,8 @@ class DenonDevice(MediaPlayerEntity): async def async_will_remove_from_hass(self) -> None: """Clean up the entity.""" + if self._receiver.telnet_connected: + await self._receiver.async_telnet_disconnect() self._receiver.unregister_callback(ALL_TELNET_EVENTS, self._telnet_callback) @async_log_errors From f102d9900423131769f1cff7f1173fd80c9a93fa Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 22 Sep 2024 03:04:29 +0200 Subject: [PATCH 0907/1309] Fix insteon test (#126404) * Fix insteon test * Increase time * More sleep --- tests/components/insteon/test_api_config.py | 1 + tests/components/insteon/test_api_properties.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tests/components/insteon/test_api_config.py b/tests/components/insteon/test_api_config.py index 212b05b74b0..9c85ca6a706 100644 --- a/tests/components/insteon/test_api_config.py +++ b/tests/components/insteon/test_api_config.py @@ -406,6 +406,7 @@ async def test_get_broken_links( await devices.async_load() aldb_data = json.loads(load_fixture("insteon/aldb_data.json")) devices.fill_aldb("33.33.33", aldb_data) + await asyncio.sleep(1) with patch.object(insteon.api.config, "devices", devices): await ws_client.send_json({ID: 2, TYPE: "insteon/config/get_broken_links"}) msg = await ws_client.receive_json() diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index 35ff95a5cc8..aeeeeab3d7b 100644 --- a/tests/components/insteon/test_api_properties.py +++ b/tests/components/insteon/test_api_properties.py @@ -1,5 +1,6 @@ """Test the Insteon properties APIs.""" +import asyncio import json from typing import Any from unittest.mock import AsyncMock, patch @@ -156,6 +157,7 @@ async def test_get_read_only_properties( msg = await ws_client.receive_json() assert msg["success"] assert len(msg["result"]["properties"]) == 15 + await asyncio.sleep(1) async def test_get_unknown_properties( From cf8955c71a7521a4d199cacb9571994b403e39f5 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 22 Sep 2024 03:04:43 +0200 Subject: [PATCH 0908/1309] Bump reolink-aio to 0.9.10 (#126387) --- 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 20c90c427d2..d4ccaaef134 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.9"] + "requirements": ["reolink-aio==0.9.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index ed9c83a6cfb..67df495a192 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2531,7 +2531,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.9.9 +reolink-aio==0.9.10 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbafd13a88c..e55cdda9e21 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2016,7 +2016,7 @@ renault-api==0.2.7 renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.9.9 +reolink-aio==0.9.10 # homeassistant.components.rflink rflink==0.0.66 From 1164326d1020e5efa04d9d5fc8785f00dc086fec Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 22 Sep 2024 02:05:10 +0100 Subject: [PATCH 0909/1309] Remove superfluous type hints from evohome (#126383) inital commit --- homeassistant/components/evohome/const.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index 15949bc3c37..3ebe6954fea 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -53,8 +53,8 @@ ATTR_DURATION_UNTIL: Final = "duration" class EvoService(StrEnum): """The Evohome services.""" - REFRESH_SYSTEM: Final = "refresh_system" - SET_SYSTEM_MODE: Final = "set_system_mode" - RESET_SYSTEM: Final = "reset_system" - SET_ZONE_OVERRIDE: Final = "set_zone_override" - RESET_ZONE_OVERRIDE: Final = "clear_zone_override" + REFRESH_SYSTEM = "refresh_system" + SET_SYSTEM_MODE = "set_system_mode" + RESET_SYSTEM = "reset_system" + SET_ZONE_OVERRIDE = "set_zone_override" + RESET_ZONE_OVERRIDE = "clear_zone_override" From 06cd86419f0ec45fedc80d7f343e7194fae20c3d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 22 Sep 2024 03:05:52 +0200 Subject: [PATCH 0910/1309] Bump python-holidays to 0.57 (#126367) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 0a2d98e71c5..30cfd34e0fb 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.56", "babel==2.15.0"] + "requirements": ["holidays==0.57", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 297b20b8c0e..1201354bab2 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.56"] + "requirements": ["holidays==0.57"] } diff --git a/requirements_all.txt b/requirements_all.txt index 67df495a192..74bda661d34 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1114,7 +1114,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.56 +holidays==0.57 # homeassistant.components.frontend home-assistant-frontend==20240909.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e55cdda9e21..b64c400b5c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -940,7 +940,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.56 +holidays==0.57 # homeassistant.components.frontend home-assistant-frontend==20240909.1 From 79872b3e1d215e93c5138c40c3e4ddd084178c51 Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Sun, 22 Sep 2024 12:08:50 +0200 Subject: [PATCH 0911/1309] Fix due date calculation for future dailies in Habitica integration (#126403) Calculate next due date for dailies with startdate in the future --- homeassistant/components/habitica/util.py | 41 +++++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index b3241aa5787..0ac3ea2a4e2 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -3,7 +3,7 @@ from __future__ import annotations import datetime -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity @@ -14,25 +14,44 @@ from homeassistant.util import dt as dt_util def next_due_date(task: dict[str, Any], last_cron: str) -> datetime.date | None: """Calculate due date for dailies and yesterdailies.""" + today = to_date(last_cron) + startdate = to_date(task["startDate"]) + if TYPE_CHECKING: + assert today + assert startdate + if task["isDue"] and not task["completed"]: - return dt_util.as_local(datetime.datetime.fromisoformat(last_cron)).date() + return to_date(last_cron) + + if startdate > today: + if task["frequency"] == "daily" or ( + task["frequency"] in ("monthly", "yearly") and task["daysOfMonth"] + ): + return startdate + + if ( + task["frequency"] in ("weekly", "monthly") + and (nextdue := to_date(task["nextDue"][0])) + and startdate > nextdue + ): + return to_date(task["nextDue"][1]) + + return to_date(task["nextDue"][0]) + + +def to_date(date: str) -> datetime.date | None: + """Convert an iso date to a datetime.date object.""" try: - return dt_util.as_local( - datetime.datetime.fromisoformat(task["nextDue"][0]) - ).date() + return dt_util.as_local(datetime.datetime.fromisoformat(date)).date() except ValueError: - # sometimes nextDue dates are in this format instead of iso: + # sometimes nextDue dates are JavaScript datetime strings instead of iso: # "Mon May 06 2024 00:00:00 GMT+0200" try: return dt_util.as_local( - datetime.datetime.strptime( - task["nextDue"][0], "%a %b %d %Y %H:%M:%S %Z%z" - ) + datetime.datetime.strptime(date, "%a %b %d %Y %H:%M:%S %Z%z") ).date() except ValueError: return None - except IndexError: - return None def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: From f073e455757105eef283b2858a012389d009e826 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 22 Sep 2024 22:17:07 +1000 Subject: [PATCH 0912/1309] Add media player to Tesla Fleet (#126416) * Add media player platform * Use MediaPlayerState * Revert change --- .../components/tesla_fleet/__init__.py | 1 + .../components/tesla_fleet/media_player.py | 149 +++++++++++++++++ .../components/tesla_fleet/strings.json | 5 + .../snapshots/test_media_player.ambr | 136 +++++++++++++++ .../tesla_fleet/test_media_player.py | 157 ++++++++++++++++++ 5 files changed, 448 insertions(+) create mode 100644 homeassistant/components/tesla_fleet/media_player.py create mode 100644 tests/components/tesla_fleet/snapshots/test_media_player.ambr create mode 100644 tests/components/tesla_fleet/test_media_player.py diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 117756c8977..ff2d7373626 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -43,6 +43,7 @@ PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.DEVICE_TRACKER, + Platform.MEDIA_PLAYER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/tesla_fleet/media_player.py b/homeassistant/components/tesla_fleet/media_player.py new file mode 100644 index 00000000000..0a1d18c3407 --- /dev/null +++ b/homeassistant/components/tesla_fleet/media_player.py @@ -0,0 +1,149 @@ +"""Media player platform for Tesla Fleet integration.""" + +from __future__ import annotations + +from tesla_fleet_api.const import Scope + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslaFleetConfigEntry +from .entity import TeslaFleetVehicleEntity +from .helpers import handle_vehicle_command +from .models import TeslaFleetVehicleData + +STATES = { + "Playing": MediaPlayerState.PLAYING, + "Paused": MediaPlayerState.PAUSED, + "Stopped": MediaPlayerState.IDLE, + "Off": MediaPlayerState.OFF, +} +VOLUME_MAX = 11.0 +VOLUME_STEP = 1.0 / 3 + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslaFleetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tesla Fleet Media platform from a config entry.""" + + async_add_entities( + TeslaFleetMediaEntity(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslaFleetMediaEntity(TeslaFleetVehicleEntity, MediaPlayerEntity): + """Vehicle media player class.""" + + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_supported_features = ( + MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + ) + _volume_max: float = VOLUME_MAX + + def __init__( + self, + data: TeslaFleetVehicleData, + scoped: bool, + ) -> None: + """Initialize the media player entity.""" + super().__init__(data, "media") + self.scoped = scoped + if not scoped and data.signing: + self._attr_supported_features = MediaPlayerEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + self._volume_max = ( + self.get("vehicle_state_media_info_audio_volume_max") or VOLUME_MAX + ) + self._attr_state = STATES.get( + self.get("vehicle_state_media_info_media_playback_status") or "Off", + ) + self._attr_volume_step = ( + 1.0 + / self._volume_max + / ( + self.get("vehicle_state_media_info_audio_volume_increment") + or VOLUME_STEP + ) + ) + + if volume := self.get("vehicle_state_media_info_audio_volume"): + self._attr_volume_level = volume / self._volume_max + else: + self._attr_volume_level = None + + if duration := self.get("vehicle_state_media_info_now_playing_duration"): + self._attr_media_duration = duration / 1000 + else: + self._attr_media_duration = None + + if duration and ( + position := self.get("vehicle_state_media_info_now_playing_elapsed") + ): + self._attr_media_position = position / 1000 + else: + self._attr_media_position = None + + self._attr_media_title = self.get("vehicle_state_media_info_now_playing_title") + self._attr_media_artist = self.get( + "vehicle_state_media_info_now_playing_artist" + ) + self._attr_media_album_name = self.get( + "vehicle_state_media_info_now_playing_album" + ) + self._attr_media_playlist = self.get( + "vehicle_state_media_info_now_playing_station" + ) + self._attr_source = self.get("vehicle_state_media_info_now_playing_source") + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + await self.wake_up_if_asleep() + await handle_vehicle_command( + self.api.adjust_volume(int(volume * self._volume_max)) + ) + self._attr_volume_level = volume + self.async_write_ha_state() + + async def async_media_play(self) -> None: + """Send play command.""" + if self.state != MediaPlayerState.PLAYING: + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PLAYING + self.async_write_ha_state() + + async def async_media_pause(self) -> None: + """Send pause command.""" + if self.state == MediaPlayerState.PLAYING: + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PAUSED + self.async_write_ha_state() + + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.media_next_track()) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.media_prev_track()) diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index ed8f45d2f8f..308e630ced5 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -133,6 +133,11 @@ "name": "Route" } }, + "media_player": { + "media": { + "name": "[%key:component::media_player::title%]" + } + }, "number": { "backup_reserve_percent": { "name": "Backup reserve" diff --git a/tests/components/tesla_fleet/snapshots/test_media_player.ambr b/tests/components/tesla_fleet/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..d6f3f3e4825 --- /dev/null +++ b/tests/components/tesla_fleet/snapshots/test_media_player.ambr @@ -0,0 +1,136 @@ +# serializer version: 1 +# name: test_media_player[media_player.test_media_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_media_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media player', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'media', + 'unique_id': 'LRWXF7EK4KC700000-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.test_media_player-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Elon Musk', + 'media_artist': 'Walter Isaacson', + 'media_duration': 651.0, + 'media_playlist': 'Elon Musk', + 'media_position': 1.0, + 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', + 'source': 'Audible', + 'supported_features': , + 'volume_level': 0.16129355359011466, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_media_player_alt[media_player.test_media_player-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': '', + 'media_artist': '', + 'media_playlist': '', + 'media_title': '', + 'source': 'Spotify', + 'supported_features': , + 'volume_level': 0.25806775026025003, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player_noscope[media_player.test_media_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_media_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media player', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'media', + 'unique_id': 'LRWXF7EK4KC700000-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_noscope[media_player.test_media_player-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Elon Musk', + 'media_artist': 'Walter Isaacson', + 'media_duration': 651.0, + 'media_playlist': 'Elon Musk', + 'media_position': 1.0, + 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', + 'source': 'Audible', + 'supported_features': , + 'volume_level': 0.16129355359011466, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/tesla_fleet/test_media_player.py b/tests/components/tesla_fleet/test_media_player.py new file mode 100644 index 00000000000..4c833e7499f --- /dev/null +++ b/tests/components/tesla_fleet/test_media_player.py @@ -0,0 +1,157 @@ +"""Test the Tesla Fleet media player platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_VOLUME_SET, + MediaPlayerState, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, assert_entities_alt, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT + +from tests.common import MockConfigEntry + + +async def test_media_player( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the media player entities are correct.""" + + await setup_platform(hass, normal_config_entry, [Platform.MEDIA_PLAYER]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the media player entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, normal_config_entry, [Platform.MEDIA_PLAYER]) + assert_entities_alt(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_offline( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the media player entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, normal_config_entry, [Platform.MEDIA_PLAYER]) + state = hass.states.get("media_player.test_media_player") + assert state.state == MediaPlayerState.OFF + + +async def test_media_player_noscope( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + readonly_config_entry: MockConfigEntry, +) -> None: + """Tests that the media player entities are correct without required scope.""" + + await setup_platform(hass, readonly_config_entry, [Platform.MEDIA_PLAYER]) + assert_entities(hass, readonly_config_entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_services( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the media player services work.""" + + await setup_platform(hass, normal_config_entry, [Platform.MEDIA_PLAYER]) + + entity_id = "media_player.test_media_player" + + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.adjust_volume", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.5 + call.assert_called_once() + + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.media_toggle_playback", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == MediaPlayerState.PAUSED + call.assert_called_once() + + # This test will fail without the previous call to pause playback + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.media_toggle_playback", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == MediaPlayerState.PLAYING + call.assert_called_once() + + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.media_next_track", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + call.assert_called_once() + + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.media_prev_track", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + call.assert_called_once() From 0abde86cf97e7f5d1460f03192edd3edee2e0e01 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 22 Sep 2024 14:18:57 +0200 Subject: [PATCH 0913/1309] Use HassKey in light (#126333) --- homeassistant/components/light/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 445096ae643..94b27664b99 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -29,14 +29,16 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, VolDictType from homeassistant.loader import bind_hass import homeassistant.util.color as color_util +from homeassistant.util.hass_dict import HassKey DOMAIN = "light" +DOMAIN_DATA: HassKey[EntityComponent[LightEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=30) -DATA_PROFILES = "light_profiles" +DATA_PROFILES: HassKey[Profiles] = HassKey(f"{DOMAIN}_profiles") class LightEntityFeature(IntFlag): @@ -299,7 +301,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: def preprocess_turn_on_alternatives( - hass: HomeAssistant, params: dict[str, Any] | VolDictType + hass: HomeAssistant, params: dict[str, Any] ) -> None: """Process extra data for turn light on request. @@ -393,7 +395,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Expose light control via state machine and services.""" - component = hass.data[DOMAIN] = EntityComponent[LightEntity]( + component = hass.data[DOMAIN_DATA] = EntityComponent[LightEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -403,7 +405,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # of the light base platform. hass.async_create_task(profiles.async_initialize(), eager_start=True) - def preprocess_data(data: VolDictType) -> VolDictType: + def preprocess_data(data: dict[str, Any]) -> VolDictType: """Preprocess the service data.""" base: VolDictType = { entity_field: data.pop(entity_field) @@ -670,14 +672,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - component: EntityComponent[LightEntity] = hass.data[DOMAIN] - return await component.async_setup_entry(entry) + return await hass.data[DOMAIN_DATA].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - component: EntityComponent[LightEntity] = hass.data[DOMAIN] - return await component.async_unload_entry(entry) + return await hass.data[DOMAIN_DATA].async_unload_entry(entry) def _coerce_none(value: str) -> None: From 20f7490fd94db48bef1c6e16e603afb81168d0e5 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sun, 22 Sep 2024 14:19:14 +0200 Subject: [PATCH 0914/1309] Remove invalid callback decorator from Bang & Olfusen coroutine functions (#126420) Remove callback decorator form coroutine functions --- homeassistant/components/bang_olufsen/media_player.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index ea84eef9c84..bd74f15ddf9 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -288,7 +288,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): if self.hass.is_running: self.async_write_ha_state() - @callback async def _async_update_playback_metadata_and_beolink( self, data: PlaybackContentMetadata ) -> None: @@ -344,7 +343,6 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self.async_write_ha_state() - @callback async def _async_update_beolink(self) -> None: """Update the current Beolink leader, listeners, peers and self.""" From 66d310977d6bd2085036c3e79e85743029bd27f5 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 22 Sep 2024 22:27:09 +1000 Subject: [PATCH 0915/1309] Add cover platform to Tesla Fleet (#126411) Add cover platform --- .../components/tesla_fleet/__init__.py | 1 + homeassistant/components/tesla_fleet/cover.py | 253 ++++ .../components/tesla_fleet/icons.json | 5 + .../components/tesla_fleet/strings.json | 17 + .../tesla_fleet/snapshots/test_cover.ambr | 1201 +++++++++++++++++ tests/components/tesla_fleet/test_cover.py | 240 ++++ 6 files changed, 1717 insertions(+) create mode 100644 homeassistant/components/tesla_fleet/cover.py create mode 100644 tests/components/tesla_fleet/snapshots/test_cover.ambr create mode 100644 tests/components/tesla_fleet/test_cover.py diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index ff2d7373626..c1f9c0ce8f9 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -42,6 +42,7 @@ from .oauth import TeslaSystemImplementation PLATFORMS: Final = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.COVER, Platform.DEVICE_TRACKER, Platform.MEDIA_PLAYER, Platform.SELECT, diff --git a/homeassistant/components/tesla_fleet/cover.py b/homeassistant/components/tesla_fleet/cover.py new file mode 100644 index 00000000000..4e49e24b689 --- /dev/null +++ b/homeassistant/components/tesla_fleet/cover.py @@ -0,0 +1,253 @@ +"""Cover platform for Tesla Fleet integration.""" + +from __future__ import annotations + +from typing import Any + +from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslaFleetConfigEntry +from .entity import TeslaFleetVehicleEntity +from .helpers import handle_vehicle_command +from .models import TeslaFleetVehicleData + +OPEN = 1 +CLOSED = 0 + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslaFleetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the TeslaFleet cover platform from a config entry.""" + + async_add_entities( + klass(vehicle, entry.runtime_data.scopes) + for (klass) in ( + TeslaFleetWindowEntity, + TeslaFleetChargePortEntity, + TeslaFleetFrontTrunkEntity, + TeslaFleetRearTrunkEntity, + TeslaFleetSunroofEntity, + ) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslaFleetWindowEntity(TeslaFleetVehicleEntity, CoverEntity): + """Cover entity for the windows.""" + + _attr_device_class = CoverDeviceClass.WINDOW + + def __init__(self, data: TeslaFleetVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(data, "windows") + self.scoped = Scope.VEHICLE_CMDS in scopes + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + if not self.scoped or self.vehicle.signing: + self._attr_supported_features = CoverEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + fd = self.get("vehicle_state_fd_window") + fp = self.get("vehicle_state_fp_window") + rd = self.get("vehicle_state_rd_window") + rp = self.get("vehicle_state_rp_window") + + # Any open set to open + if OPEN in (fd, fp, rd, rp): + self._attr_is_closed = False + # All closed set to closed + elif CLOSED == fd == fp == rd == rp: + self._attr_is_closed = True + # Otherwise, set to unknown + else: + self._attr_is_closed = None + + async def async_open_cover(self, **kwargs: Any) -> None: + """Vent windows.""" + await self.wake_up_if_asleep() + await handle_vehicle_command( + self.api.window_control(command=WindowCommand.VENT) + ) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + await self.wake_up_if_asleep() + await handle_vehicle_command( + self.api.window_control(command=WindowCommand.CLOSE) + ) + self._attr_is_closed = True + self.async_write_ha_state() + + +class TeslaFleetChargePortEntity(TeslaFleetVehicleEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + + def __init__(self, vehicle: TeslaFleetVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "charge_state_charge_port_door_open") + self.scoped = any( + scope in scopes + for scope in (Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS) + ) + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + if not self.scoped or self.vehicle.signing: + self._attr_supported_features = CoverEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_is_closed = not self._value + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open charge port.""" + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_open()) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close charge port.""" + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_close()) + self._attr_is_closed = True + self.async_write_ha_state() + + +class TeslaFleetFrontTrunkEntity(TeslaFleetVehicleEntity, CoverEntity): + """Cover entity for the front trunk.""" + + _attr_device_class = CoverDeviceClass.DOOR + + def __init__(self, vehicle: TeslaFleetVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "vehicle_state_ft") + + self.scoped = Scope.VEHICLE_CMDS in scopes + self._attr_supported_features = CoverEntityFeature.OPEN + if not self.scoped or self.vehicle.signing: + self._attr_supported_features = CoverEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + self._attr_is_closed = self._value == CLOSED + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open front trunk.""" + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.FRONT)) + self._attr_is_closed = False + self.async_write_ha_state() + + +class TeslaFleetRearTrunkEntity(TeslaFleetVehicleEntity, CoverEntity): + """Cover entity for the rear trunk.""" + + _attr_device_class = CoverDeviceClass.DOOR + + def __init__(self, vehicle: TeslaFleetVehicleData, scopes: list[Scope]) -> None: + """Initialize the cover.""" + super().__init__(vehicle, "vehicle_state_rt") + + self.scoped = Scope.VEHICLE_CMDS in scopes + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + if not self.scoped or self.vehicle.signing: + self._attr_supported_features = CoverEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + value = self._value + if value == CLOSED: + self._attr_is_closed = True + elif value == OPEN: + self._attr_is_closed = False + else: + self._attr_is_closed = None + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open rear trunk.""" + if self.is_closed is not False: + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close rear trunk.""" + if self.is_closed is not True: + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) + self._attr_is_closed = True + self.async_write_ha_state() + + +class TeslaFleetSunroofEntity(TeslaFleetVehicleEntity, CoverEntity): + """Cover entity for the sunroof.""" + + _attr_device_class = CoverDeviceClass.WINDOW + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + _attr_entity_registry_enabled_default = False + + def __init__(self, vehicle: TeslaFleetVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + super().__init__(vehicle, "vehicle_state_sun_roof_state") + + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped or self.vehicle.signing: + self._attr_supported_features = CoverEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + value = self._value + if value in (None, "unknown"): + self._attr_is_closed = None + else: + self._attr_is_closed = value == "closed" + + self._attr_current_cover_position = self.get( + "vehicle_state_sun_roof_percent_open" + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open sunroof.""" + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.VENT)) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close sunroof.""" + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.CLOSE)) + self._attr_is_closed = True + self.async_write_ha_state() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Close sunroof.""" + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.STOP)) + self._attr_is_closed = False + self.async_write_ha_state() diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index 5927acaa1d9..21e6cc46f60 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -52,6 +52,11 @@ } } }, + "cover": { + "charge_state_charge_port_door_open": { + "default": "mdi:ev-plug-ccs2" + } + }, "device_tracker": { "location": { "default": "mdi:map-marker" diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 308e630ced5..0b297173363 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -125,6 +125,23 @@ } } }, + "cover": { + "charge_state_charge_port_door_open": { + "name": "Charge port door" + }, + "vehicle_state_ft": { + "name": "Frunk" + }, + "vehicle_state_rt": { + "name": "Trunk" + }, + "vehicle_state_sun_roof_state": { + "name": "Sunroof" + }, + "windows": { + "name": "Windows" + } + }, "device_tracker": { "location": { "name": "Location" diff --git a/tests/components/tesla_fleet/snapshots/test_cover.ambr b/tests/components/tesla_fleet/snapshots/test_cover.ambr new file mode 100644 index 00000000000..c8eb9fb257e --- /dev/null +++ b/tests/components/tesla_fleet/snapshots/test_cover.ambr @@ -0,0 +1,1201 @@ +# serializer version: 1 +# name: test_cover[cover.test_charge_port_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_charge_port_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge port door', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_charge_port_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Charge port door', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_charge_port_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover[cover.test_frunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_frunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frunk', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_frunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Frunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover[cover.test_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'windows', + 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test None', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover[cover.test_none_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_none_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_none_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test None', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_none_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover[cover.test_none_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_none_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_none_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test None', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_none_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover[cover.test_none_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_none_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_none_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test None', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_none_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover[cover.test_none_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_none_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_sun_roof_state', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_none_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test None', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_none_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover[cover.test_sunroof-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_sun_roof_state', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Sunroof', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover[cover.test_trunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_trunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trunk', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_trunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Trunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover[cover.test_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'windows', + 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Windows', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover_alt[cover.test_charge_port_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_charge_port_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge port door', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_charge_port_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Charge port door', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_charge_port_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_alt[cover.test_frunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_frunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frunk', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_frunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Frunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_alt[cover.test_none-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_none', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'windows', + 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_none-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test None', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_none', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_alt[cover.test_none_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_none_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_none_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test None', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_none_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_alt[cover.test_none_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_none_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_none_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test None', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_none_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_alt[cover.test_none_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_none_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_none_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test None', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_none_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_alt[cover.test_none_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_none_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_sun_roof_state', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_none_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test None', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_none_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_cover_alt[cover.test_sunroof-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_sun_roof_state', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Sunroof', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_cover_alt[cover.test_trunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_trunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trunk', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_trunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Trunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_alt[cover.test_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'windows', + 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Windows', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_readonly[cover.test_charge_port_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_charge_port_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge port door', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_port_door_open', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_door_open', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_readonly[cover.test_charge_port_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Charge port door', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_charge_port_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_readonly[cover.test_frunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_frunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frunk', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_ft', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_ft', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_readonly[cover.test_frunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Frunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover_readonly[cover.test_sunroof-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_sun_roof_state', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_readonly[cover.test_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Sunroof', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_readonly[cover.test_trunk-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_trunk', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Trunk', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_rt', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_rt', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_readonly[cover.test_trunk-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Trunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_cover_readonly[cover.test_windows-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_windows', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Windows', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'windows', + 'unique_id': 'LRWXF7EK4KC700000-windows', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_readonly[cover.test_windows-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Windows', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_windows', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/tesla_fleet/test_cover.py b/tests/components/tesla_fleet/test_cover.py new file mode 100644 index 00000000000..97636ec3ae5 --- /dev/null +++ b/tests/components/tesla_fleet/test_cover.py @@ -0,0 +1,240 @@ +"""Test the Teslemetry cover platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_CLOSED, + STATE_OPEN, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_cover( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the cover entities are correct.""" + + await setup_platform(hass, normal_config_entry, [Platform.COVER]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_cover_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the cover entities are correct with alternate values.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, normal_config_entry, [Platform.COVER]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_cover_readonly( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + readonly_config_entry: MockConfigEntry, +) -> None: + """Tests that the cover entities are correct without scopes.""" + + await setup_platform(hass, readonly_config_entry, [Platform.COVER]) + assert_entities(hass, readonly_config_entry.entry_id, entity_registry, snapshot) + + +async def test_cover_offline( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the cover entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, normal_config_entry, [Platform.COVER]) + state = hass.states.get("cover.test_windows") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_cover_services( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the cover entities are correct.""" + + await setup_platform(hass, normal_config_entry, [Platform.COVER]) + + # Vent Windows + entity_id = "cover.test_windows" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.window_control", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + call.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ["cover.test_windows"]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_CLOSED + + # Charge Port Door + entity_id = "cover.test_charge_port_door" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_open", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.charge_port_door_close", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_CLOSED + + # Frunk + entity_id = "cover.test_frunk" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + # Trunk + entity_id = "cover.test_trunk" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.actuate_trunk", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + call.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_CLOSED + + # Sunroof + entity_id = "cover.test_sunroof" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.sun_roof_control", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + call.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + call.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_CLOSED From 118ceedda1640c39f72f1ff287b8a6d0d84185a5 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 22 Sep 2024 14:41:47 +0200 Subject: [PATCH 0916/1309] Add Reolink Home Hub ringtone control (#126390) * Add Hub alarm/visitor ringtones * fix styling * fix translations * fix tests * Rename buzzer to hub ringtone --- homeassistant/components/reolink/icons.json | 16 +++++++-- homeassistant/components/reolink/select.py | 27 ++++++++++++++ homeassistant/components/reolink/strings.json | 36 +++++++++++++++++-- homeassistant/components/reolink/switch.py | 4 +-- .../reolink/snapshots/test_diagnostics.ambr | 4 +++ 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index e3a0c867f18..a254669a119 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -210,6 +210,18 @@ "hdr": { "default": "mdi:hdr" }, + "hub_alarm_ringtone": { + "default": "mdi:music-note", + "state": { + "alarm": "mdi:bullhorn" + } + }, + "hub_visitor_ringtone": { + "default": "mdi:music-note", + "state": { + "alarm": "mdi:bullhorn" + } + }, "motion_tone": { "default": "mdi:music-note", "state": { @@ -297,8 +309,8 @@ "manual_record": { "default": "mdi:record-rec" }, - "buzzer": { - "default": "mdi:room-service" + "hub_ringtone_on_event": { + "default": "mdi:music-note" }, "doorbell_button_sound": { "default": "mdi:volume-high" diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index bc6368df8de..b4175d41069 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -13,6 +13,7 @@ from reolink_aio.api import ( DayNightEnum, HDREnum, Host, + HubToneEnum, SpotlightModeEnum, StatusLedEnum, TrackMethodEnum, @@ -114,6 +115,32 @@ SELECT_ENTITIES = ( api.set_quick_reply(ch, file_id=_get_quick_reply_id(api, ch, mess)) ), ), + ReolinkSelectEntityDescription( + key="hub_alarm_ringtone", + cmd_key="GetDeviceAudioCfg", + translation_key="hub_alarm_ringtone", + entity_category=EntityCategory.CONFIG, + get_options=[mode.name for mode in HubToneEnum], + supported=lambda api, ch: api.supported(ch, "hub_audio"), + value=lambda api, ch: HubToneEnum(api.hub_alarm_tone_id(ch)).name, + method=lambda api, ch, name: ( + api.set_hub_audio(ch, alarm_tone_id=HubToneEnum[name].value) + ), + ), + ReolinkSelectEntityDescription( + key="hub_visitor_ringtone", + cmd_key="GetDeviceAudioCfg", + translation_key="hub_visitor_ringtone", + entity_category=EntityCategory.CONFIG, + get_options=[mode.name for mode in HubToneEnum], + supported=lambda api, ch: ( + api.supported(ch, "hub_audio") and api.is_doorbell(ch) + ), + value=lambda api, ch: HubToneEnum(api.hub_visitor_tone_id(ch)).name, + method=lambda api, ch, name: ( + api.set_hub_audio(ch, visitor_tone_id=HubToneEnum[name].value) + ), + ), ReolinkSelectEntityDescription( key="auto_track_method", cmd_key="GetAiCfg", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index bd674b6574f..212300332c4 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -531,6 +531,38 @@ "auto": "Auto" } }, + "hub_alarm_ringtone": { + "name": "Hub alarm ringtone", + "state": { + "alarm": "Alarm", + "citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]", + "originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]", + "pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]", + "loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]", + "attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]", + "hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]", + "goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]", + "operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]", + "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]", + "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" + } + }, + "hub_visitor_ringtone": { + "name": "Hub visitor ringtone", + "state": { + "alarm": "[%key:component::reolink::entity::select::hub_alarm_ringtone::state::alarm%]", + "citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]", + "originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]", + "pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]", + "loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]", + "attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]", + "hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]", + "goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]", + "operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]", + "moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]", + "waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]" + } + }, "motion_tone": { "name": "Motion ringtone", "state": { @@ -663,8 +695,8 @@ "manual_record": { "name": "Manual record" }, - "buzzer": { - "name": "Buzzer on event" + "hub_ringtone_on_event": { + "name": "Hub ringtone on event" }, "doorbell_button_sound": { "name": "Doorbell button sound" diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index e43cb0fdaaa..07f75ca5fa3 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -171,7 +171,7 @@ SWITCH_ENTITIES = ( ReolinkSwitchEntityDescription( key="buzzer", cmd_key="GetBuzzerAlarmV20", - translation_key="buzzer", + translation_key="hub_ringtone_on_event", entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "buzzer") and api.is_nvr, value=lambda api, ch: api.buzzer_enabled(ch), @@ -248,7 +248,7 @@ NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="buzzer", cmd_key="GetBuzzerAlarmV20", - translation_key="buzzer", + translation_key="hub_ringtone_on_event", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "buzzer"), diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 00363023d14..b8646eb0bee 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -77,6 +77,10 @@ '0': 1, 'null': 1, }), + 'GetDeviceAudioCfg': dict({ + '0': 2, + 'null': 2, + }), 'GetEmail': dict({ '0': 1, 'null': 2, From bd3efe57f7135858e0ce731ba4cd5e3b5bcbd1eb Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 22 Sep 2024 14:44:26 +0200 Subject: [PATCH 0917/1309] Add Reolink hub status light (#126388) * Add Home Hub status led * fix styling * Add tests --- homeassistant/components/reolink/light.py | 77 ++++++++++++++- .../reolink/snapshots/test_diagnostics.ambr | 3 + tests/components/reolink/test_light.py | 97 +++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index e7f3d3e5d1a..d545a878068 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -20,7 +20,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) from .util import ReolinkConfigEntry, ReolinkData @@ -37,6 +42,17 @@ class ReolinkLightEntityDescription( turn_on_off_fn: Callable[[Host, int, bool], Any] +@dataclass(frozen=True, kw_only=True) +class ReolinkHostLightEntityDescription( + LightEntityDescription, + ReolinkHostEntityDescription, +): + """A class that describes host light entities.""" + + is_on_fn: Callable[[Host], bool] + turn_on_off_fn: Callable[[Host, bool], Any] + + LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", @@ -59,6 +75,18 @@ LIGHT_ENTITIES = ( ), ) +HOST_LIGHT_ENTITIES = ( + ReolinkHostLightEntityDescription( + key="hub_status_led", + cmd_key="GetStateLight", + translation_key="status_led", + entity_category=EntityCategory.CONFIG, + supported=lambda api: api.supported(None, "state_light"), + is_on_fn=lambda api: api.state_light, + turn_on_off_fn=lambda api, value: api.set_state_light(value), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -68,13 +96,20 @@ async def async_setup_entry( """Set up a Reolink light entities.""" reolink_data: ReolinkData = config_entry.runtime_data - async_add_entities( + entities: list[ReolinkLightEntity | ReolinkHostLightEntity] = [ ReolinkLightEntity(reolink_data, channel, entity_description) for entity_description in LIGHT_ENTITIES for channel in reolink_data.host.api.channels if entity_description.supported(reolink_data.host.api, channel) + ] + entities.extend( + ReolinkHostLightEntity(reolink_data, entity_description) + for entity_description in HOST_LIGHT_ENTITIES + if entity_description.supported(reolink_data.host.api) ) + async_add_entities(entities) + class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): """Base light entity class for Reolink IP cameras.""" @@ -148,3 +183,41 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): except ReolinkError as err: raise HomeAssistantError(err) from err self.async_write_ha_state() + + +class ReolinkHostLightEntity(ReolinkHostCoordinatorEntity, LightEntity): + """Base host light entity class for Reolink IP cameras.""" + + entity_description: ReolinkHostLightEntityDescription + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_color_mode = ColorMode.ONOFF + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkHostLightEntityDescription, + ) -> None: + """Initialize Reolink host light entity.""" + self.entity_description = entity_description + super().__init__(reolink_data) + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.entity_description.is_on_fn(self._host.api) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn light off.""" + try: + await self.entity_description.turn_on_off_fn(self._host.api, False) + except ReolinkError as err: + raise HomeAssistantError(err) from err + self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn light on.""" + try: + await self.entity_description.turn_on_off_fn(self._host.api, True) + except ReolinkError as err: + raise HomeAssistantError(err) from err + self.async_write_ha_state() diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index b8646eb0bee..542df064f5d 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -137,6 +137,9 @@ '0': 1, 'null': 2, }), + 'GetStateLight': dict({ + 'null': 1, + }), 'GetWhiteLed': dict({ '0': 3, 'null': 3, diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index c495a0ff25e..7c0c11c3f63 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -144,3 +144,100 @@ async def test_light_turn_on( {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, blocking=True, ) + + +async def test_host_light_state( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test host light entity state with status led.""" + reolink_connect.state_light = True + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_status_led" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + +async def test_host_light_turn_off( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test host light turn off service.""" + + def mock_supported(ch, capability): + if capability == "power_led": + return False + return True + + reolink_connect.supported = mock_supported + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_status_led" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_state_light.assert_called_with(False) + + reolink_connect.set_state_light.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +async def test_host_light_turn_on( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test host light turn on service.""" + + def mock_supported(ch, capability): + if capability == "power_led": + return False + return True + + reolink_connect.supported = mock_supported + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_status_led" + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + reolink_connect.set_state_light.assert_called_with(True) + + reolink_connect.set_state_light.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) From 705af35dd67565aaee39c89fab8c322d22a4974b Mon Sep 17 00:00:00 2001 From: Sean Chen Date: Sun, 22 Sep 2024 07:44:53 -0500 Subject: [PATCH 0918/1309] Parse AirNow observation timezone correctly (#122006) Parse observation timezone correctly Co-authored-by: Joost Lekkerkerker --- homeassistant/components/airnow/const.py | 24 ++++++++++++++- .../components/airnow/coordinator.py | 6 +--- homeassistant/components/airnow/sensor.py | 29 +++++++++++-------- .../airnow/snapshots/test_diagnostics.ambr | 2 +- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/airnow/const.py b/homeassistant/components/airnow/const.py index 054a5cbfea7..1198f68128d 100644 --- a/homeassistant/components/airnow/const.py +++ b/homeassistant/components/airnow/const.py @@ -14,10 +14,32 @@ ATTR_API_POLLUTANT = "Pollutant" ATTR_API_REPORT_DATE = "DateObserved" ATTR_API_REPORT_HOUR = "HourObserved" ATTR_API_REPORT_TZ = "LocalTimeZone" -ATTR_API_REPORT_TZINFO = "LocalTimeZoneInfo" ATTR_API_STATE = "StateCode" ATTR_API_STATION = "ReportingArea" ATTR_API_STATION_LATITUDE = "Latitude" ATTR_API_STATION_LONGITUDE = "Longitude" DEFAULT_NAME = "AirNow" DOMAIN = "airnow" + +SECONDS_PER_HOUR = 3600 + +# AirNow seems to only use standard time zones, +# but we include daylight savings for completeness/futureproofing. +US_TZ_OFFSETS = { + "HST": -10 * SECONDS_PER_HOUR, + "HDT": -9 * SECONDS_PER_HOUR, + # AirNow returns AKT instead of AKST or AKDT, use standard + "AKT": -9 * SECONDS_PER_HOUR, + "AKST": -9 * SECONDS_PER_HOUR, + "AKDT": -8 * SECONDS_PER_HOUR, + "PST": -8 * SECONDS_PER_HOUR, + "PDT": -7 * SECONDS_PER_HOUR, + "MST": -7 * SECONDS_PER_HOUR, + "MDT": -6 * SECONDS_PER_HOUR, + "CST": -6 * SECONDS_PER_HOUR, + "CDT": -5 * SECONDS_PER_HOUR, + "EST": -5 * SECONDS_PER_HOUR, + "EDT": -4 * SECONDS_PER_HOUR, + "AST": -4 * SECONDS_PER_HOUR, + "ADT": -3 * SECONDS_PER_HOUR, +} diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index 35f8a0e0abf..32185080d25 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -12,7 +12,6 @@ from pyairnow.errors import AirNowError from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util from .const import ( ATTR_API_AQI, @@ -27,7 +26,6 @@ from .const import ( ATTR_API_REPORT_DATE, ATTR_API_REPORT_HOUR, ATTR_API_REPORT_TZ, - ATTR_API_REPORT_TZINFO, ATTR_API_STATE, ATTR_API_STATION, ATTR_API_STATION_LATITUDE, @@ -98,9 +96,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Copy Report Details data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] - data[ATTR_API_REPORT_TZINFO] = await dt_util.async_get_time_zone( - obv[ATTR_API_REPORT_TZ] - ) + data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ] # Copy Station Details data[ATTR_API_STATE] = obv[ATTR_API_STATE] diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 722c0d6f4a9..1abf93514a5 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -4,9 +4,10 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime from typing import Any +from dateutil import parser + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -34,12 +35,13 @@ from .const import ( ATTR_API_PM25, ATTR_API_REPORT_DATE, ATTR_API_REPORT_HOUR, - ATTR_API_REPORT_TZINFO, + ATTR_API_REPORT_TZ, ATTR_API_STATION, ATTR_API_STATION_LATITUDE, ATTR_API_STATION_LONGITUDE, DEFAULT_NAME, DOMAIN, + US_TZ_OFFSETS, ) ATTRIBUTION = "Data provided by AirNow" @@ -69,6 +71,18 @@ def station_extra_attrs(data: dict[str, Any]) -> dict[str, Any]: return {} +def aqi_extra_attrs(data: dict[str, Any]) -> dict[str, Any]: + """Process extra attributes for main AQI sensor.""" + return { + ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION], + ATTR_LEVEL: data[ATTR_API_AQI_LEVEL], + ATTR_TIME: parser.parse( + f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}:00 {data[ATTR_API_REPORT_TZ]}", + tzinfos=US_TZ_OFFSETS, + ).isoformat(), + } + + SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_AQI, @@ -76,16 +90,7 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.AQI, value_fn=lambda data: data.get(ATTR_API_AQI), - extra_state_attributes_fn=lambda data: { - ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION], - ATTR_LEVEL: data[ATTR_API_AQI_LEVEL], - ATTR_TIME: datetime.strptime( - f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}", - "%Y-%m-%d %H", - ) - .replace(tzinfo=data[ATTR_API_REPORT_TZINFO]) - .isoformat(), - }, + extra_state_attributes_fn=aqi_extra_attrs, ), AirNowEntityDescription( key=ATTR_API_PM10, diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index c2004d759a9..71fda040c1d 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -8,7 +8,7 @@ 'DateObserved': '2020-12-20', 'HourObserved': 15, 'Latitude': '**REDACTED**', - 'LocalTimeZoneInfo': 'PST', + 'LocalTimeZone': 'PST', 'Longitude': '**REDACTED**', 'O3': 0.048, 'PM10': 12, From 46c26e794257396eb2f289dfb51ca2334ec0c19e Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sun, 22 Sep 2024 09:05:50 -0400 Subject: [PATCH 0919/1309] Bump nice-go to 0.3.9 (#126399) --- homeassistant/components/nice_go/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nice_go/manifest.json b/homeassistant/components/nice_go/manifest.json index 315f23d949d..d3f54e5e668 100644 --- a/homeassistant/components/nice_go/manifest.json +++ b/homeassistant/components/nice_go/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["nice_go"], - "requirements": ["nice-go==0.3.8"] + "requirements": ["nice-go==0.3.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 74bda661d34..331b478ae52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1447,7 +1447,7 @@ nextdns==3.3.0 nibe==2.11.0 # homeassistant.components.nice_go -nice-go==0.3.8 +nice-go==0.3.9 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b64c400b5c9..1d8f4c040a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1207,7 +1207,7 @@ nextdns==3.3.0 nibe==2.11.0 # homeassistant.components.nice_go -nice-go==0.3.8 +nice-go==0.3.9 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From 53d76355ec2c21b159b0556785b29fe2392d05e8 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 22 Sep 2024 14:37:01 +0100 Subject: [PATCH 0920/1309] Correct a docstring typo for evohome (#126426) initial commit --- homeassistant/components/evohome/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 5a5d9d09521..58e0e16e059 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -79,7 +79,8 @@ CONFIG_SCHEMA: Final = vol.Schema( extra=vol.ALLOW_EXTRA, ) -# system mode schemas are built dynamically when the services are regiatered +# system mode schemas are built dynamically when the services are registered +# because supported modes can vary for edge-case systems RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( {vol.Required(ATTR_ENTITY_ID): cv.entity_id} From 286c22c0edb662664a3c3d3dc8627de52925b223 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 22 Sep 2024 15:58:11 +0200 Subject: [PATCH 0921/1309] Add Reolink CPU usage sensor (#126386) --- homeassistant/components/reolink/icons.json | 3 +++ homeassistant/components/reolink/sensor.py | 11 +++++++++++ homeassistant/components/reolink/strings.json | 3 +++ 3 files changed, 17 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index a254669a119..c8cc6f60f09 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -260,6 +260,9 @@ "wifi_signal": { "default": "mdi:wifi" }, + "cpu_usage": { + "default": "mdi:cpu-64-bit" + }, "hdd_storage": { "default": "mdi:harddisk" }, diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 1e2d75ed849..c2fc815235e 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -106,6 +106,17 @@ HOST_SENSORS = ( value=lambda api: api.wifi_signal, supported=lambda api: api.supported(None, "wifi") and api.wifi_connection, ), + ReolinkHostSensorEntityDescription( + key="cpu_usage", + cmd_key="GetPerformance", + translation_key="cpu_usage", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda api: api.cpu_usage, + supported=lambda api: api.supported(None, "performance"), + ), ) HDD_SENSORS = ( diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 212300332c4..4326c6ace9d 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -632,6 +632,9 @@ "wifi_signal": { "name": "Wi-Fi signal" }, + "cpu_usage": { + "name": "CPU usage" + }, "ptz_pan_position": { "name": "PTZ pan position" }, From 90957dfedb5b3431bb3c8c81998443dc490c13c6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 22 Sep 2024 15:59:23 +0200 Subject: [PATCH 0922/1309] Add Reolink hub volume number entities (#126389) * Add Home Hub alarm and message volume * fix styling * Add tests * Update homeassistant/components/reolink/number.py * Update test_diagnostics.ambr --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/reolink/icons.json | 12 +++ homeassistant/components/reolink/number.py | 80 ++++++++++++++++++- homeassistant/components/reolink/strings.json | 6 ++ .../reolink/snapshots/test_diagnostics.ambr | 2 +- tests/components/reolink/test_number.py | 44 ++++++++++ 5 files changed, 142 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index c8cc6f60f09..5815e165607 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -106,6 +106,18 @@ "0": "mdi:volume-off" } }, + "alarm_volume": { + "default": "mdi:volume-high", + "state": { + "0": "mdi:volume-off" + } + }, + "message_volume": { + "default": "mdi:volume-high", + "state": { + "0": "mdi:volume-off" + } + }, "guard_return_time": { "default": "mdi:crosshairs-gps" }, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index ff523b559d6..8ce568d4bd0 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -24,6 +24,8 @@ from .entity import ( ReolinkChannelEntityDescription, ReolinkChimeCoordinatorEntity, ReolinkChimeEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, ) from .util import ReolinkConfigEntry, ReolinkData @@ -42,6 +44,18 @@ class ReolinkNumberEntityDescription( value: Callable[[Host, int], float | None] +@dataclass(frozen=True, kw_only=True) +class ReolinkHostNumberEntityDescription( + NumberEntityDescription, + ReolinkHostEntityDescription, +): + """A class that describes number entities for the host.""" + + method: Callable[[Host, float], Any] + mode: NumberMode = NumberMode.AUTO + value: Callable[[Host], float | None] + + @dataclass(frozen=True, kw_only=True) class ReolinkChimeNumberEntityDescription( NumberEntityDescription, @@ -474,6 +488,33 @@ NUMBER_ENTITIES = ( ), ) +HOST_NUMBER_ENTITIES = ( + ReolinkHostNumberEntityDescription( + key="alarm_volume", + cmd_key="GetDeviceAudioCfg", + translation_key="alarm_volume", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api: api.supported(None, "hub_audio"), + value=lambda api: api.alarm_volume, + method=lambda api, value: api.set_hub_audio(alarm_volume=int(value)), + ), + ReolinkHostNumberEntityDescription( + key="message_volume", + cmd_key="GetDeviceAudioCfg", + translation_key="message_volume", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api: api.supported(None, "hub_audio"), + value=lambda api: api.message_volume, + method=lambda api, value: api.set_hub_audio(message_volume=int(value)), + ), +) + CHIME_NUMBER_ENTITIES = ( ReolinkChimeNumberEntityDescription( key="volume", @@ -497,12 +538,17 @@ async def async_setup_entry( """Set up a Reolink number entities.""" reolink_data: ReolinkData = config_entry.runtime_data - entities: list[ReolinkNumberEntity | ReolinkChimeNumberEntity] = [ + entities: list[NumberEntity] = [ ReolinkNumberEntity(reolink_data, channel, entity_description) for entity_description in NUMBER_ENTITIES for channel in reolink_data.host.api.channels if entity_description.supported(reolink_data.host.api, channel) ] + entities.extend( + ReolinkHostNumberEntity(reolink_data, entity_description) + for entity_description in HOST_NUMBER_ENTITIES + if entity_description.supported(reolink_data.host.api) + ) entities.extend( ReolinkChimeNumberEntity(reolink_data, chime, entity_description) for entity_description in CHIME_NUMBER_ENTITIES @@ -552,6 +598,38 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): self.async_write_ha_state() +class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity): + """Base number entity class for Reolink Host.""" + + entity_description: ReolinkHostNumberEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkHostNumberEntityDescription, + ) -> None: + """Initialize Reolink number entity.""" + self.entity_description = entity_description + super().__init__(reolink_data) + + self._attr_mode = entity_description.mode + + @property + def native_value(self) -> float | None: + """State of the number entity.""" + return self.entity_description.value(self._host.api) + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + try: + await self.entity_description.method(self._host.api, value) + except InvalidParameterError as err: + raise ServiceValidationError(err) from err + except ReolinkError as err: + raise HomeAssistantError(err) from err + self.async_write_ha_state() + + class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity): """Base number entity class for Reolink IP cameras.""" diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 4326c6ace9d..6dde5efa2ec 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -395,6 +395,12 @@ "volume": { "name": "Volume" }, + "alarm_volume": { + "name": "Alarm volume" + }, + "message_volume": { + "name": "Message volume" + }, "guard_return_time": { "name": "Guard return time" }, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 542df064f5d..33e9c78c550 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -79,7 +79,7 @@ }), 'GetDeviceAudioCfg': dict({ '0': 2, - 'null': 2, + 'null': 4, }), 'GetEmail': dict({ '0': 1, diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index e9abcec946c..89b6935de5b 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -65,6 +65,50 @@ async def test_number( ) +async def test_host_number( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test number entity with volume.""" + reolink_connect.alarm_volume = 85 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_alarm_volume" + + assert hass.states.get(entity_id).state == "85" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 45}, + blocking=True, + ) + reolink_connect.set_hub_audio.assert_called_with(alarm_volume=45) + + reolink_connect.set_hub_audio.side_effect = ReolinkError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 45}, + blocking=True, + ) + + reolink_connect.set_hub_audio.side_effect = InvalidParameterError("Test error") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 45}, + blocking=True, + ) + + async def test_chime_number( hass: HomeAssistant, config_entry: MockConfigEntry, From 7c5dc299819bf1964626bc231ffa2a6ea540c04f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:01:08 +0200 Subject: [PATCH 0923/1309] Prevent leading and trailing spaces in translation values (#126427) * Prevent leading and trailing spaces in translation values * Adjust components * Tests --- homeassistant/components/fronius/strings.json | 2 +- homeassistant/components/hive/strings.json | 2 +- .../components/husqvarna_automower/strings.json | 2 +- homeassistant/components/madvr/strings.json | 4 ++-- homeassistant/components/waze_travel_time/strings.json | 2 +- script/hassfest/translations.py | 10 ++++++---- .../husqvarna_automower/snapshots/test_number.ambr | 4 ++-- 7 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json index ccfb88852a8..1eaa612a6e7 100644 --- a/homeassistant/components/fronius/strings.json +++ b/homeassistant/components/fronius/strings.json @@ -275,7 +275,7 @@ "name": "Relative self consumption" }, "capacity_maximum": { - "name": "Maximum capacity " + "name": "Maximum capacity" }, "capacity_designed": { "name": "Designed capacity" diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index bd4e95618e4..c8062a64ade 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -21,7 +21,7 @@ "data": { "device_name": "Device Name" }, - "description": "Enter your Hive configuration ", + "description": "Enter your Hive configuration", "title": "Hive Configuration." }, "reauth": { diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 2c93c7492cf..f251a8bf5e0 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -55,7 +55,7 @@ "name": "Cutting height" }, "my_lawn_cutting_height": { - "name": "My lawn cutting height " + "name": "My lawn cutting height" }, "work_area_cutting_height": { "name": "{work_area} cutting height" diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json index b8d30be23aa..06851efa2c8 100644 --- a/homeassistant/components/madvr/strings.json +++ b/homeassistant/components/madvr/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Setup madVR Envy", - "description": "Your device needs to be on in order to add the integation. ", + "description": "Your device needs to be on in order to add the integation.", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" @@ -15,7 +15,7 @@ }, "reconfigure": { "title": "Reconfigure madVR Envy", - "description": "Your device needs to be on in order to reconfigure the integation. ", + "description": "Your device needs to be on in order to reconfigure the integation.", "data": { "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" diff --git a/homeassistant/components/waze_travel_time/strings.json b/homeassistant/components/waze_travel_time/strings.json index 6b0b4184af7..507731fc973 100644 --- a/homeassistant/components/waze_travel_time/strings.json +++ b/homeassistant/components/waze_travel_time/strings.json @@ -100,7 +100,7 @@ }, "avoid_subscription_roads": { "name": "[%key:component::waze_travel_time::options::step::init::data::avoid_subscription_roads%]", - "description": "Whether to avoid subscription roads. " + "description": "Whether to avoid subscription roads." } } } diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index fa12ce626ad..50cfc62b5cf 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -131,11 +131,13 @@ def translation_value_validator(value: Any) -> str: - prevents strings with single quoted placeholders - prevents combined translations """ - value = cv.string_with_no_html(value) - value = string_no_single_quoted_placeholders(value) - if RE_COMBINED_REFERENCE.search(value): + string_value = cv.string_with_no_html(value) + string_value = string_no_single_quoted_placeholders(string_value) + if RE_COMBINED_REFERENCE.search(string_value): raise vol.Invalid("the string should not contain combined translations") - return str(value) + if string_value != string_value.strip(" "): + raise vol.Invalid("the string should not contain leading or trailing spaces") + return string_value def string_no_single_quoted_placeholders(value: str) -> str: diff --git a/tests/components/husqvarna_automower/snapshots/test_number.ambr b/tests/components/husqvarna_automower/snapshots/test_number.ambr index de8b397f01c..63e42ee5d5c 100644 --- a/tests/components/husqvarna_automower/snapshots/test_number.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_number.ambr @@ -195,7 +195,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'My lawn cutting height ', + 'original_name': 'My lawn cutting height', 'platform': 'husqvarna_automower', 'previous_unique_id': None, 'supported_features': 0, @@ -207,7 +207,7 @@ # name: test_number_snapshot[number.test_mower_1_my_lawn_cutting_height-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Mower 1 My lawn cutting height ', + 'friendly_name': 'Test Mower 1 My lawn cutting height', 'max': 100.0, 'min': 0.0, 'mode': , From 96b7fc9a754cbfa1b8146b37f848f9031337db0d Mon Sep 17 00:00:00 2001 From: Trevor Schirmer <24777085+TrevorSchirmer@users.noreply.github.com> Date: Sun, 22 Sep 2024 10:01:46 -0400 Subject: [PATCH 0924/1309] Add mm/s and in/s As Unit Of Speed (#125044) Co-authored-by: J. Nick Koston --- homeassistant/components/sensor/const.py | 4 ++-- homeassistant/const.py | 2 ++ homeassistant/util/unit_conversion.py | 4 ++++ homeassistant/util/unit_system.py | 2 ++ tests/components/sensor/test_websocket_api.py | 2 ++ tests/util/test_unit_conversion.py | 14 ++++++++++++++ tests/util/test_unit_system.py | 12 ++++++++++++ 7 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index de30678d9fa..da0b48a23a0 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -350,8 +350,8 @@ class SensorDeviceClass(StrEnum): """Generic speed. Unit of measurement: `SPEED_*` units or `UnitOfVolumetricFlux` - - SI /metric: `mm/d`, `mm/h`, `m/s`, `km/h` - - USCS / imperial: `in/d`, `in/h`, `ft/s`, `mph` + - SI /metric: `mm/d`, `mm/h`, `m/s`, `km/h`, `mm/s` + - USCS / imperial: `in/d`, `in/h`, `in/s`, `ft/s`, `mph` - Nautical: `kn` - Beaufort: `Beaufort` """ diff --git a/homeassistant/const.py b/homeassistant/const.py index aaffcc9aa84..257fcd2bfd2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1273,10 +1273,12 @@ class UnitOfSpeed(StrEnum): BEAUFORT = "Beaufort" FEET_PER_SECOND = "ft/s" + INCHES_PER_SECOND = "in/s" METERS_PER_SECOND = "m/s" KILOMETERS_PER_HOUR = "km/h" KNOTS = "kn" MILES_PER_HOUR = "mph" + MILLIMETERS_PER_SECOND = "mm/s" _DEPRECATED_SPEED_FEET_PER_SECOND: Final = DeprecatedConstantEnum( diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index dd6d300a2c1..0f2f6464ed8 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -337,9 +337,11 @@ class SpeedConverter(BaseUnitConverter): UnitOfVolumetricFlux.MILLIMETERS_PER_DAY: _DAYS_TO_SECS / _MM_TO_M, UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR: _HRS_TO_SECS / _MM_TO_M, UnitOfSpeed.FEET_PER_SECOND: 1 / _FOOT_TO_M, + UnitOfSpeed.INCHES_PER_SECOND: 1 / _IN_TO_M, UnitOfSpeed.KILOMETERS_PER_HOUR: _HRS_TO_SECS / _KM_TO_M, UnitOfSpeed.KNOTS: _HRS_TO_SECS / _NAUTICAL_MILE_TO_M, UnitOfSpeed.METERS_PER_SECOND: 1, + UnitOfSpeed.MILLIMETERS_PER_SECOND: 1 / _MM_TO_M, UnitOfSpeed.MILES_PER_HOUR: _HRS_TO_SECS / _MILE_TO_M, UnitOfSpeed.BEAUFORT: 1, } @@ -348,11 +350,13 @@ class SpeedConverter(BaseUnitConverter): UnitOfVolumetricFlux.INCHES_PER_HOUR, UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + UnitOfSpeed.INCHES_PER_SECOND, UnitOfSpeed.FEET_PER_SECOND, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS, UnitOfSpeed.METERS_PER_SECOND, UnitOfSpeed.MILES_PER_HOUR, + UnitOfSpeed.MILLIMETERS_PER_SECOND, UnitOfSpeed.BEAUFORT, } diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 98cfb2f1368..02a115e10c1 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -258,6 +258,7 @@ METRIC_SYSTEM = UnitSystem( ("pressure", UnitOfPressure.INHG): UnitOfPressure.HPA, # Convert non-metric speeds except knots to km/h ("speed", UnitOfSpeed.FEET_PER_SECOND): UnitOfSpeed.KILOMETERS_PER_HOUR, + ("speed", UnitOfSpeed.INCHES_PER_SECOND): UnitOfSpeed.MILLIMETERS_PER_SECOND, ("speed", UnitOfSpeed.MILES_PER_HOUR): UnitOfSpeed.KILOMETERS_PER_HOUR, ( "speed", @@ -330,6 +331,7 @@ US_CUSTOMARY_SYSTEM = UnitSystem( ("pressure", UnitOfPressure.MMHG): UnitOfPressure.INHG, # Convert non-USCS speeds, except knots, to mph ("speed", UnitOfSpeed.METERS_PER_SECOND): UnitOfSpeed.MILES_PER_HOUR, + ("speed", UnitOfSpeed.MILLIMETERS_PER_SECOND): UnitOfSpeed.INCHES_PER_SECOND, ("speed", UnitOfSpeed.KILOMETERS_PER_HOUR): UnitOfSpeed.MILES_PER_HOUR, ( "speed", diff --git a/tests/components/sensor/test_websocket_api.py b/tests/components/sensor/test_websocket_api.py index 6f4eeb252e2..b1dafa04c94 100644 --- a/tests/components/sensor/test_websocket_api.py +++ b/tests/components/sensor/test_websocket_api.py @@ -36,11 +36,13 @@ async def test_device_class_units( "ft/s", "in/d", "in/h", + "in/s", "km/h", "kn", "m/s", "mm/d", "mm/h", + "mm/s", "mph", ] } diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 8342aa732f8..2408914f256 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -431,6 +431,20 @@ _CONVERTED_VALUE: dict[ 708661.42, UnitOfVolumetricFlux.INCHES_PER_HOUR, ), + # 5 m/s * 1000 = 5000 mm/s + ( + 5, + UnitOfSpeed.METERS_PER_SECOND, + 5000, + UnitOfSpeed.MILLIMETERS_PER_SECOND, + ), + # 5 m/s ÷ 0.0254 = 196.8503937 in/s + ( + 5, + UnitOfSpeed.METERS_PER_SECOND, + 5 / 0.0254, + UnitOfSpeed.INCHES_PER_SECOND, + ), # 5000 in/h / 39.3701 in/m / 3600 s/h = 0.03528 m/s ( 5000, diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 15500777212..316a9ead17a 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -413,6 +413,11 @@ def test_get_unit_system_invalid(key: str) -> None: UnitOfSpeed.FEET_PER_SECOND, UnitOfSpeed.KILOMETERS_PER_HOUR, ), + ( + SensorDeviceClass.SPEED, + UnitOfSpeed.INCHES_PER_SECOND, + UnitOfSpeed.MILLIMETERS_PER_SECOND, + ), ( SensorDeviceClass.SPEED, UnitOfSpeed.MILES_PER_HOUR, @@ -520,6 +525,7 @@ UNCONVERTED_UNITS_METRIC_SYSTEM = { UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS, UnitOfSpeed.METERS_PER_SECOND, + UnitOfSpeed.MILLIMETERS_PER_SECOND, UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, ), @@ -661,6 +667,11 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: ), (SensorDeviceClass.SPEED, UnitOfVolumetricFlux.INCHES_PER_DAY, None), (SensorDeviceClass.SPEED, UnitOfVolumetricFlux.INCHES_PER_HOUR, None), + ( + SensorDeviceClass.SPEED, + UnitOfSpeed.MILLIMETERS_PER_SECOND, + UnitOfSpeed.INCHES_PER_SECOND, + ), (SensorDeviceClass.SPEED, "very_fast", None), # Test volume conversion (SensorDeviceClass.VOLUME, UnitOfVolume.CUBIC_METERS, UnitOfVolume.CUBIC_FEET), @@ -729,6 +740,7 @@ UNCONVERTED_UNITS_US_SYSTEM = { UnitOfSpeed.FEET_PER_SECOND, UnitOfSpeed.KNOTS, UnitOfSpeed.MILES_PER_HOUR, + UnitOfSpeed.INCHES_PER_SECOND, UnitOfVolumetricFlux.INCHES_PER_DAY, UnitOfVolumetricFlux.INCHES_PER_HOUR, ), From 90aa9aa98fbda49df73993d69c2096cfa9a4fa9c Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:02:30 +0200 Subject: [PATCH 0925/1309] Improve plugwise device cleanup (#126419) * Improve code * Ruff-suggestion * Change as suggested --- .../components/plugwise/coordinator.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/plugwise/coordinator.py b/homeassistant/components/plugwise/coordinator.py index 9a47bef8d9a..c3fe33c64d2 100644 --- a/homeassistant/components/plugwise/coordinator.py +++ b/homeassistant/components/plugwise/coordinator.py @@ -104,24 +104,19 @@ class PlugwiseDataUpdateCoordinator(DataUpdateCoordinator[PlugwiseData]): device_list = dr.async_entries_for_config_entry( device_reg, self.config_entry.entry_id ) - # via_device cannot be None, this will result in the deletion - # of other Plugwise Gateways when present! - via_device: str = "" - # First find the Plugwise via_device - for device_entry in device_list: - for identifier in device_entry.identifiers: - if identifier[0] != DOMAIN or identifier[1] != data.gateway[GATEWAY_ID]: - continue - via_device = device_entry.id - break + gateway_device = device_reg.async_get_device( + {(DOMAIN, data.gateway[GATEWAY_ID])} + ) + assert gateway_device is not None + via_device_id = gateway_device.id # Then remove the connected orphaned device(s) for device_entry in device_list: for identifier in device_entry.identifiers: if identifier[0] == DOMAIN: if ( - device_entry.via_device_id == via_device + device_entry.via_device_id == via_device_id and identifier[1] not in data.devices ): device_reg.async_update_device( From f98b1d248a959781d63504f07bbfcf7001e5518a Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 23 Sep 2024 00:04:36 +1000 Subject: [PATCH 0926/1309] Add diagnostics platform to Smlight (#126423) * Add diagnostics for Smlight * test diagnostics * Add log fixture and snapshot --------- Co-authored-by: Joost Lekkerkerker --- .../components/smlight/diagnostics.py | 25 ++++++++++++++++ tests/components/smlight/fixtures/logs.txt | 1 + .../smlight/snapshots/test_diagnostics.ambr | 27 +++++++++++++++++ tests/components/smlight/test_diagnostics.py | 30 +++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 homeassistant/components/smlight/diagnostics.py create mode 100644 tests/components/smlight/fixtures/logs.txt create mode 100644 tests/components/smlight/snapshots/test_diagnostics.ambr create mode 100644 tests/components/smlight/test_diagnostics.py diff --git a/homeassistant/components/smlight/diagnostics.py b/homeassistant/components/smlight/diagnostics.py new file mode 100644 index 00000000000..d303e5803bb --- /dev/null +++ b/homeassistant/components/smlight/diagnostics.py @@ -0,0 +1,25 @@ +"""Collect diagnostics for SMLIGHT devices.""" + +from __future__ import annotations + +from typing import Any + +from pysmlight.const import Actions + +from homeassistant.core import HomeAssistant + +from . import SmConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: SmConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordintator = config_entry.runtime_data.data + info = await coordintator.client.get_info() + log = await coordintator.client.get({"action": Actions.API_GET_LOG.value}) or "none" + + return { + "info": info.to_dict(), + "log": log.split("\n"), + } diff --git a/tests/components/smlight/fixtures/logs.txt b/tests/components/smlight/fixtures/logs.txt new file mode 100644 index 00000000000..f04dc881514 --- /dev/null +++ b/tests/components/smlight/fixtures/logs.txt @@ -0,0 +1 @@ +[04:28:51] setup | Starting firmware: v2.3.6\n[04:28:52] ConfigHelper | LittleFS mounted\n[04:28:52] ConfigHelper | load config\n[04:28:52] ConfigHelper | config open: Ok\n[04:28:52] setup | Config loaded\n[04:28:52] setup | Reboot reason: 3\n[04:28:52] setup | Coordinator mode: LAN\n[04:28:52] setup | Device type: SLZB-06P10\n[04:28:52] setup | Radio mode: \"ZB COORD\" Radio FW version: 20240716 Radio FW CH: PROD\n[04:28:52] Network | init\n[04:28:52] L_Y,L_B | status: 1\n[04:28:54] Network | EVENT_ETH_START\n[04:28:54] Network | EVENT_ETH_CONNECTED\n[04:28:54] Network | [MDNS] Started\n[04:28:54] Network | EVENT_ETH_GOT_IP\n[04:28:54] Network | ETH MAC: AA:BB:CC:DD:EE:FF IPv4: 192.168.0.11 GW: 192.168.0.1 Speed: 100Mbps DNS1: 192.168.0.1 DNS2: 0.0.0.0\n[04:28:54] Network | fireNetworkUp\n[04:28:54] taskZB | Waiting for zbChk\n[04:28:54] Web | Webserver started \ No newline at end of file diff --git a/tests/components/smlight/snapshots/test_diagnostics.ambr b/tests/components/smlight/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..97177de1704 --- /dev/null +++ b/tests/components/smlight/snapshots/test_diagnostics.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'info': dict({ + 'MAC': 'AA:BB:CC:DD:EE:FF', + 'coord_mode': 0, + 'device_ip': '192.168.1.161', + 'fs_total': 3456, + 'fw_channel': 'dev', + 'hostname': 'SLZB-06p7', + 'legacy_api': 0, + 'model': 'SLZB-06p7', + 'ram_total': 296, + 'sw_version': 'v2.3.6', + 'wifi_mode': 0, + 'zb_channel': 0, + 'zb_flash_size': 704, + 'zb_hw': 'CC2652P7', + 'zb_ram_size': 152, + 'zb_type': 0, + 'zb_version': '20240314', + }), + 'log': list([ + '[04:28:51] setup | Starting firmware: v2.3.6\\n[04:28:52] ConfigHelper | LittleFS mounted\\n[04:28:52] ConfigHelper | load config\\n[04:28:52] ConfigHelper | config open: Ok\\n[04:28:52] setup | Config loaded\\n[04:28:52] setup | Reboot reason: 3\\n[04:28:52] setup | Coordinator mode: LAN\\n[04:28:52] setup | Device type: SLZB-06P10\\n[04:28:52] setup | Radio mode: \\"ZB COORD\\" Radio FW version: 20240716 Radio FW CH: PROD\\n[04:28:52] Network | init\\n[04:28:52] L_Y,L_B | status: 1\\n[04:28:54] Network | EVENT_ETH_START\\n[04:28:54] Network | EVENT_ETH_CONNECTED\\n[04:28:54] Network | [MDNS] Started\\n[04:28:54] Network | EVENT_ETH_GOT_IP\\n[04:28:54] Network | ETH MAC: AA:BB:CC:DD:EE:FF IPv4: 192.168.0.11 GW: 192.168.0.1 Speed: 100Mbps DNS1: 192.168.0.1 DNS2: 0.0.0.0\\n[04:28:54] Network | fireNetworkUp\\n[04:28:54] taskZB | Waiting for zbChk\\n[04:28:54] Web | Webserver started', + ]), + }) +# --- diff --git a/tests/components/smlight/test_diagnostics.py b/tests/components/smlight/test_diagnostics.py new file mode 100644 index 00000000000..d0c756bfd87 --- /dev/null +++ b/tests/components/smlight/test_diagnostics.py @@ -0,0 +1,30 @@ +"""Test SMLIGHT diagnostics.""" + +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.smlight.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import setup_integration + +from tests.common import MockConfigEntry, load_fixture +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + mock_smlight_client.get.return_value = load_fixture("logs.txt", DOMAIN) + entry = await setup_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == snapshot From 02b3da8f80aa4e2ad58e9fc8c814fc283d84ced8 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:06:01 +0200 Subject: [PATCH 0927/1309] Automatic device cleanup for Husqvarna Automower (#126384) * Automatic device cleanup for Husqvarna Automower * fix copy&paste mistake * typing * overwrite type in coordinator --- .../husqvarna_automower/__init__.py | 30 +++++++++++++++- .../husqvarna_automower/coordinator.py | 2 ++ .../husqvarna_automower/test_init.py | 36 +++++++++++++++++-- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 6e987b679ed..117ded0dcf9 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -9,9 +9,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow +from homeassistant.helpers import ( + aiohttp_client, + config_entry_oauth2_flow, + device_registry as dr, + entity_registry as er, +) from . import api +from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -53,6 +59,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> coordinator = AutomowerDataUpdateCoordinator(hass, automower_api, entry) await coordinator.async_config_entry_first_refresh() + available_devices = list(coordinator.data) + cleanup_removed_devices(hass, coordinator.config_entry, available_devices) entry.runtime_data = coordinator entry.async_create_background_task( @@ -73,3 +81,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: AutomowerConfigEntry) -> bool: """Handle unload of an entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def cleanup_removed_devices( + hass: HomeAssistant, config_entry: ConfigEntry, available_devices: list[str] +) -> None: + """Cleanup entity and device registry from removed devices.""" + entity_reg = er.async_get(hass) + for entity in er.async_entries_for_config_entry(entity_reg, config_entry.entry_id): + if entity.unique_id.split("_")[0] not in available_devices: + _LOGGER.debug("Removing obsolete entity entry %s", entity.entity_id) + entity_reg.async_remove(entity.entity_id) + + device_reg = dr.async_get(hass) + identifiers = {(DOMAIN, mower_id) for mower_id in available_devices} + for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id): + if not set(device.identifiers) & identifiers: + _LOGGER.debug("Removing obsolete device entry %s", device.name) + device_reg.async_update_device( + device.id, remove_config_entry_id=config_entry.entry_id + ) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 817789727ca..458ff50dac9 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -27,6 +27,8 @@ SCAN_INTERVAL = timedelta(minutes=8) class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]): """Class to manage fetching Husqvarna data.""" + config_entry: ConfigEntry + def __init__( self, hass: HomeAssistant, api: AutomowerSession, entry: ConfigEntry ) -> None: diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 84fe1b9e891..ab80aea5a3f 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -10,6 +10,7 @@ from aioautomower.exceptions import ( AuthException, HusqvarnaWSServerHandshakeError, ) +from aioautomower.utils import mower_list_to_dictionary_dataclass from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -17,12 +18,16 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.const import DOMAIN, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration from .const import TEST_MOWER_ID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_value_fixture, +) from tests.test_util.aiohttp import AiohttpClientMocker @@ -160,3 +165,30 @@ async def test_device_info( identifiers={(DOMAIN, TEST_MOWER_ID)}, ) assert reg_device == snapshot + + +async def test_coordinator_automatic_registry_cleanup( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test automatic registry cleanup.""" + await setup_integration(hass, mock_config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + await hass.async_block_till_done() + + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 42 + assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 2 + + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + values.pop(TEST_MOWER_ID) + mock_automower_client.get_status.return_value = values + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 + assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 1 From d66c28dd6a47e581cb971532ceaf023f0825a7d7 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Sun, 22 Sep 2024 10:14:08 -0400 Subject: [PATCH 0928/1309] Bump pysqueezebox version to 0.9.2 (#126347) * Bump pysqueezebox version to 0.9.1 * Bump pysqueezebox version to 0.9.2 --- 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 c43225f94cd..88a5ce02bc0 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.8.1"] + "requirements": ["pysqueezebox==0.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 331b478ae52..8ccd9ab9238 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2259,7 +2259,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.8.1 +pysqueezebox==0.9.2 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d8f4c040a7..62149115e81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1813,7 +1813,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.8.1 +pysqueezebox==0.9.2 # homeassistant.components.suez_water pysuez==0.2.0 From 3137f75221e4d0452ae6ddbd83c60eb78b4d8301 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 22 Sep 2024 16:15:24 +0200 Subject: [PATCH 0929/1309] Add switch to Yale Smart Living (#126366) --- .../components/yale_smart_alarm/const.py | 1 + .../components/yale_smart_alarm/entity.py | 1 + .../components/yale_smart_alarm/lock.py | 1 - .../components/yale_smart_alarm/manifest.json | 2 +- .../components/yale_smart_alarm/strings.json | 5 + .../components/yale_smart_alarm/switch.py | 59 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/yale_smart_alarm/conftest.py | 1 + .../snapshots/test_switch.ambr | 277 ++++++++++++++++++ .../yale_smart_alarm/test_switch.py | 46 +++ 11 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/yale_smart_alarm/switch.py create mode 100644 tests/components/yale_smart_alarm/snapshots/test_switch.ambr create mode 100644 tests/components/yale_smart_alarm/test_switch.py diff --git a/homeassistant/components/yale_smart_alarm/const.py b/homeassistant/components/yale_smart_alarm/const.py index e7b732c6cf9..4166d0085d5 100644 --- a/homeassistant/components/yale_smart_alarm/const.py +++ b/homeassistant/components/yale_smart_alarm/const.py @@ -40,6 +40,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.LOCK, Platform.SENSOR, + Platform.SWITCH, ] STATE_MAP = { diff --git a/homeassistant/components/yale_smart_alarm/entity.py b/homeassistant/components/yale_smart_alarm/entity.py index a0d08d19ba5..e37dc3562f5 100644 --- a/homeassistant/components/yale_smart_alarm/entity.py +++ b/homeassistant/components/yale_smart_alarm/entity.py @@ -45,6 +45,7 @@ class YaleLockEntity(CoordinatorEntity[YaleDataUpdateCoordinator]): identifiers={(DOMAIN, lock.sid())}, via_device=(DOMAIN, coordinator.entry.data[CONF_USERNAME]), ) + self.lock_data = lock class YaleAlarmEntity(CoordinatorEntity[YaleDataUpdateCoordinator], Entity): diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 7374a7c06de..65913dbb3bd 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -58,7 +58,6 @@ class YaleDoorlock(YaleLockEntity, LockEntity): """Initialize the Yale Lock Device.""" super().__init__(coordinator, lock) self._attr_code_format = rf"^\d{{{code_format}}}$" - self.lock_data = lock async def async_unlock(self, **kwargs: Any) -> None: """Send unlock command.""" diff --git a/homeassistant/components/yale_smart_alarm/manifest.json b/homeassistant/components/yale_smart_alarm/manifest.json index d9e75195db2..9a13cf72db9 100644 --- a/homeassistant/components/yale_smart_alarm/manifest.json +++ b/homeassistant/components/yale_smart_alarm/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/yale_smart_alarm", "iot_class": "cloud_polling", "loggers": ["yalesmartalarmclient"], - "requirements": ["yalesmartalarmclient==0.4.2"] + "requirements": ["yalesmartalarmclient==0.4.3"] } diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index 63260c03e7f..abaa6996bbe 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -55,6 +55,11 @@ "panic": { "name": "Panic button" } + }, + "switch": { + "autolock": { + "name": "Autolock" + } } }, "exceptions": { diff --git a/homeassistant/components/yale_smart_alarm/switch.py b/homeassistant/components/yale_smart_alarm/switch.py new file mode 100644 index 00000000000..e8c0817c2de --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/switch.py @@ -0,0 +1,59 @@ +"""Switches for Yale Alarm.""" + +from __future__ import annotations + +from typing import Any + +from yalesmartalarmclient import YaleLock + +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import YaleConfigEntry +from .coordinator import YaleDataUpdateCoordinator +from .entity import YaleLockEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Yale switch entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + YaleAutolockSwitch(coordinator, lock) + for lock in coordinator.locks + if lock.supports_lock_config() + ) + + +class YaleAutolockSwitch(YaleLockEntity, SwitchEntity): + """Representation of a Yale autolock switch.""" + + _attr_translation_key = "autolock" + + def __init__(self, coordinator: YaleDataUpdateCoordinator, lock: YaleLock) -> None: + """Initialize the Yale Autolock Switch.""" + super().__init__(coordinator, lock) + self._attr_unique_id = f"{lock.sid()}-autolock" + self._attr_is_on = self.lock_data.autolock() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + if await self.hass.async_add_executor_job(self.lock_data.set_autolock, True): + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + if await self.hass.async_add_executor_job(self.lock_data.set_autolock, False): + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = self.lock_data.autolock() + super()._handle_coordinator_update() diff --git a/requirements_all.txt b/requirements_all.txt index 8ccd9ab9238..844be54fa0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3002,7 +3002,7 @@ xmltodict==0.13.0 xs1-api-client==3.0.0 # homeassistant.components.yale_smart_alarm -yalesmartalarmclient==0.4.2 +yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62149115e81..44c463bc6c5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2388,7 +2388,7 @@ xknxproject==3.7.1 xmltodict==0.13.0 # homeassistant.components.yale_smart_alarm -yalesmartalarmclient==0.4.2 +yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale diff --git a/tests/components/yale_smart_alarm/conftest.py b/tests/components/yale_smart_alarm/conftest.py index 2a43eb8c6e7..7a7abcac67c 100644 --- a/tests/components/yale_smart_alarm/conftest.py +++ b/tests/components/yale_smart_alarm/conftest.py @@ -64,6 +64,7 @@ async def load_config_entry( client.auth = Mock() client.auth.get_authenticated = Mock(return_value=data) client.auth.post_authenticated = Mock(return_value={"code": "000"}) + client.auth.put_authenticated = Mock(return_value={"code": "000"}) client.lock_api = YaleDoorManAPI(client.auth) locks = [ YaleLock(device, lock_api=client.lock_api) diff --git a/tests/components/yale_smart_alarm/snapshots/test_switch.ambr b/tests/components/yale_smart_alarm/snapshots/test_switch.ambr new file mode 100644 index 00000000000..f631a6fcbfe --- /dev/null +++ b/tests/components/yale_smart_alarm/snapshots/test_switch.ambr @@ -0,0 +1,277 @@ +# serializer version: 1 +# name: test_switch[load_platforms0][switch.device1_autolock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device1_autolock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Autolock', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'autolock', + 'unique_id': '1111-autolock', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[load_platforms0][switch.device1_autolock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device1 Autolock', + }), + 'context': , + 'entity_id': 'switch.device1_autolock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[load_platforms0][switch.device2_autolock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device2_autolock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Autolock', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'autolock', + 'unique_id': '2222-autolock', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[load_platforms0][switch.device2_autolock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device2 Autolock', + }), + 'context': , + 'entity_id': 'switch.device2_autolock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[load_platforms0][switch.device3_autolock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device3_autolock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Autolock', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'autolock', + 'unique_id': '3333-autolock', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[load_platforms0][switch.device3_autolock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device3 Autolock', + }), + 'context': , + 'entity_id': 'switch.device3_autolock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[load_platforms0][switch.device7_autolock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device7_autolock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Autolock', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'autolock', + 'unique_id': '7777-autolock', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[load_platforms0][switch.device7_autolock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device7 Autolock', + }), + 'context': , + 'entity_id': 'switch.device7_autolock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[load_platforms0][switch.device8_autolock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device8_autolock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Autolock', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'autolock', + 'unique_id': '8888-autolock', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[load_platforms0][switch.device8_autolock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device8 Autolock', + }), + 'context': , + 'entity_id': 'switch.device8_autolock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch[load_platforms0][switch.device9_autolock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.device9_autolock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Autolock', + 'platform': 'yale_smart_alarm', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'autolock', + 'unique_id': '9999-autolock', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch[load_platforms0][switch.device9_autolock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device9 Autolock', + }), + 'context': , + 'entity_id': 'switch.device9_autolock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/yale_smart_alarm/test_switch.py b/tests/components/yale_smart_alarm/test_switch.py new file mode 100644 index 00000000000..b189a3fd003 --- /dev/null +++ b/tests/components/yale_smart_alarm/test_switch.py @@ -0,0 +1,46 @@ +"""The test for the Yale smart living switch.""" + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion +from yalesmartalarmclient import YaleSmartAlarmData + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SWITCH]], +) +async def test_switch( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + load_config_entry: tuple[MockConfigEntry, Mock], + get_data: YaleSmartAlarmData, + snapshot: SnapshotAssertion, +) -> None: + """Test the Yale Smart Living autolock switch.""" + + await snapshot_platform( + hass, entity_registry, snapshot, load_config_entry[0].entry_id + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "switch.device1_autolock", + }, + blocking=True, + ) + + state = hass.states.get("switch.device1_autolock") + assert state.state == STATE_OFF From 78459991bfcc15b114a13a0cebb65f9947a9e77d Mon Sep 17 00:00:00 2001 From: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:17:36 +0200 Subject: [PATCH 0930/1309] Bump wolf-comm to 0.0.10 (#126342) * Updated wolf-comm lib to 0.0.10 * run command to update requirements_all.txt and requirements_test_all.txt --- 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 6a98dcd6ca4..daa7d187bfb 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.9"] + "requirements": ["wolf-comm==0.0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 844be54fa0f..49a263f4490 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2974,7 +2974,7 @@ wirelesstagpy==0.8.1 wled==0.20.2 # homeassistant.components.wolflink -wolf-comm==0.0.9 +wolf-comm==0.0.10 # homeassistant.components.wyoming wyoming==1.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44c463bc6c5..70fd537a685 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2363,7 +2363,7 @@ wiffi==1.1.2 wled==0.20.2 # homeassistant.components.wolflink -wolf-comm==0.0.9 +wolf-comm==0.0.10 # homeassistant.components.wyoming wyoming==1.5.4 From 3f13f6ed120056f42f10d70192b86b2650e0f311 Mon Sep 17 00:00:00 2001 From: Raj Laud <50647620+rajlaud@users.noreply.github.com> Date: Sun, 22 Sep 2024 10:31:37 -0400 Subject: [PATCH 0931/1309] Fix error in squeezebox media browser album art (#126346) Fix error in squeezebox media browser album art part 2 --- homeassistant/components/squeezebox/browse_media.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 61ae7b7a403..6c69aa532ec 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -131,12 +131,12 @@ async def build_item_response( can_expand = False can_play = True - if artwork_track_id := item.get("artwork_track_id") and item_type: + if artwork_track_id := item.get("artwork_track_id"): if internal_request: item_thumbnail = player.generate_image_url_from_track_id( artwork_track_id ) - else: + elif item_type is not None: item_thumbnail = entity.get_browse_image_url( item_type, item_id, artwork_track_id ) From f4b324bbad2e24a73dfaa31e6ca85b9f24ccf6d0 Mon Sep 17 00:00:00 2001 From: "Lektri.co" <137074859+Lektrico@users.noreply.github.com> Date: Sun, 22 Sep 2024 17:36:22 +0300 Subject: [PATCH 0932/1309] Add new values for sensor for Lektrico integration (#126210) * Add new values for sensor limit_reason. * Remove unknown from limit reason sensor. --- .../components/lektrico/manifest.json | 2 +- homeassistant/components/lektrico/sensor.py | 32 ++++++++++++------- .../components/lektrico/strings.json | 5 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../lektrico/snapshots/test_sensor.ambr | 6 ++++ 6 files changed, 34 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/lektrico/manifest.json b/homeassistant/components/lektrico/manifest.json index 5aef09f3845..d96b8cc4b69 100644 --- a/homeassistant/components/lektrico/manifest.json +++ b/homeassistant/components/lektrico/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/lektrico", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["lektricowifi==0.0.41"], + "requirements": ["lektricowifi==0.0.42"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/lektrico/sensor.py b/homeassistant/components/lektrico/sensor.py index a8a929d974f..a26a3676d8b 100644 --- a/homeassistant/components/lektrico/sensor.py +++ b/homeassistant/components/lektrico/sensor.py @@ -41,6 +41,21 @@ class LektricoSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[dict[str, Any]], StateType] +LIMIT_REASON_OPTIONS = [ + "no_limit", + "installation_current", + "user_limit", + "dynamic_limit", + "schedule", + "em_offline", + "em", + "ocpp", + "overtemperature", + "switching_phases", + "1p_charging_disabled", +] + + SENSORS_FOR_CHARGERS: tuple[LektricoSensorEntityDescription, ...] = ( LektricoSensorEntityDescription( key="state", @@ -104,17 +119,12 @@ SENSORS_FOR_CHARGERS: tuple[LektricoSensorEntityDescription, ...] = ( key="limit_reason", translation_key="limit_reason", device_class=SensorDeviceClass.ENUM, - options=[ - "no_limit", - "installation_current", - "user_limit", - "dynamic_limit", - "schedule", - "em_offline", - "em", - "ocpp", - ], - value_fn=lambda data: str(data["current_limit_reason"]), + options=LIMIT_REASON_OPTIONS, + value_fn=lambda data: ( + str(data["current_limit_reason"]) + if str(data["current_limit_reason"]) in LIMIT_REASON_OPTIONS + else None + ), ), ) diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index a636ee543e6..3f4a732a4a0 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -70,7 +70,10 @@ "schedule": "Schedule", "em_offline": "EM offline", "em": "EM", - "ocpp": "OCPP" + "ocpp": "OCPP", + "overtemperature": "Overtemperature", + "switching_phases": "Switching phases", + "1p_charging_disabled": "1p charging disabled" } }, "breaker_current": { diff --git a/requirements_all.txt b/requirements_all.txt index 49a263f4490..e26b3f69c9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1270,7 +1270,7 @@ leaone-ble==0.1.0 led-ble==1.0.2 # homeassistant.components.lektrico -lektricowifi==0.0.41 +lektricowifi==0.0.42 # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70fd537a685..09cd1fe57b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1066,7 +1066,7 @@ leaone-ble==0.1.0 led-ble==1.0.2 # homeassistant.components.lektrico -lektricowifi==0.0.41 +lektricowifi==0.0.42 # homeassistant.components.foscam libpyfoscam==1.2.2 diff --git a/tests/components/lektrico/snapshots/test_sensor.ambr b/tests/components/lektrico/snapshots/test_sensor.ambr index 7df5df70218..002e0b00ca8 100644 --- a/tests/components/lektrico/snapshots/test_sensor.ambr +++ b/tests/components/lektrico/snapshots/test_sensor.ambr @@ -260,6 +260,9 @@ 'em_offline', 'em', 'ocpp', + 'overtemperature', + 'switching_phases', + '1p_charging_disabled', ]), }), 'config_entry_id': , @@ -303,6 +306,9 @@ 'em_offline', 'em', 'ocpp', + 'overtemperature', + 'switching_phases', + '1p_charging_disabled', ]), }), 'context': , From ba3ba7b890bd951395910b0669607e19638934c3 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sun, 22 Sep 2024 16:36:36 +0200 Subject: [PATCH 0933/1309] Bump mozart_api to 3.4.1.8.8 (#126334) Update API --- homeassistant/components/bang_olufsen/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bang_olufsen/manifest.json b/homeassistant/components/bang_olufsen/manifest.json index 3cc9fdb5cd1..a93a6e7a624 100644 --- a/homeassistant/components/bang_olufsen/manifest.json +++ b/homeassistant/components/bang_olufsen/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/bang_olufsen", "integration_type": "device", "iot_class": "local_push", - "requirements": ["mozart-api==3.4.1.8.6"], + "requirements": ["mozart-api==3.4.1.8.8"], "zeroconf": ["_bangolufsen._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e26b3f69c9b..21204047ac5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1393,7 +1393,7 @@ motionblindsble==0.1.1 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==3.4.1.8.6 +mozart-api==3.4.1.8.8 # homeassistant.components.mullvad mullvad-api==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09cd1fe57b1..3d25fb0b63e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1162,7 +1162,7 @@ motionblindsble==0.1.1 motioneye-client==0.3.14 # homeassistant.components.bang_olufsen -mozart-api==3.4.1.8.6 +mozart-api==3.4.1.8.8 # homeassistant.components.mullvad mullvad-api==1.0.0 From bd4bbb30ec4aeb21a53d677559b3ac0ca420605a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 22 Sep 2024 07:42:50 -0700 Subject: [PATCH 0934/1309] Bump google-photos-library-api to 0.11.1 (#126430) --- homeassistant/components/google_photos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/google_photos/conftest.py | 1 - 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_photos/manifest.json b/homeassistant/components/google_photos/manifest.json index 5ff37135f9a..b71eec4bdd9 100644 --- a/homeassistant/components/google_photos/manifest.json +++ b/homeassistant/components/google_photos/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_photos", "iot_class": "cloud_polling", "loggers": ["google_photos_library_api"], - "requirements": ["google-photos-library-api==0.8.0"] + "requirements": ["google-photos-library-api==0.11.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 21204047ac5..9b79a4d2b4a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ google-generativeai==0.7.2 google-nest-sdm==5.0.1 # homeassistant.components.google_photos -google-photos-library-api==0.8.0 +google-photos-library-api==0.11.1 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3d25fb0b63e..c556c5e1ea0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -857,7 +857,7 @@ google-generativeai==0.7.2 google-nest-sdm==5.0.1 # homeassistant.components.google_photos -google-photos-library-api==0.8.0 +google-photos-library-api==0.11.1 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index 3ca64471fa1..c657cd14a53 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -131,7 +131,6 @@ def mock_client_api( mock_api.get_user_info.return_value = UserInfoResult( id=user_identifier, name="Test Name", - email="test.name@gmail.com", ) responses = load_json_array_fixture(fixture_name, DOMAIN) if fixture_name else [] From bb2c2d161a178313ade63efc13dc97aa74aa2c37 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 22 Sep 2024 15:50:08 +0100 Subject: [PATCH 0935/1309] Rename an evohome test fixture (#126425) rename a fixture --- tests/components/evohome/conftest.py | 2 +- tests/components/evohome/test_init.py | 4 ++-- tests/components/evohome/test_storage.py | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/components/evohome/conftest.py b/tests/components/evohome/conftest.py index 82c5cd76024..6d956e99454 100644 --- a/tests/components/evohome/conftest.py +++ b/tests/components/evohome/conftest.py @@ -109,7 +109,7 @@ async def block_request( @pytest.fixture -def evo_config() -> dict[str, str]: +def config() -> dict[str, str]: "Return a default/minimal configuration." return { CONF_USERNAME: USERNAME, diff --git a/tests/components/evohome/test_init.py b/tests/components/evohome/test_init.py index ad688d04882..cf610d2e664 100644 --- a/tests/components/evohome/test_init.py +++ b/tests/components/evohome/test_init.py @@ -15,7 +15,7 @@ from .const import TEST_INSTALLS @pytest.mark.parametrize("install", TEST_INSTALLS) async def test_entities( hass: HomeAssistant, - evo_config: dict[str, str], + config: dict[str, str], install: str, snapshot: SnapshotAssertion, freezer: FrozenDateTimeFactory, @@ -25,6 +25,6 @@ async def test_entities( # some extended state attrs are relative the current time freezer.move_to("2024-07-10 12:00:00+00:00") - await setup_evohome(hass, evo_config, install=install) + await setup_evohome(hass, config, install=install) assert hass.states.async_all() == snapshot diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index e44f98651fd..3d0c158a30f 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -87,14 +87,14 @@ DOMAIN_STORAGE_BASE: Final = { async def test_auth_tokens_null( hass: HomeAssistant, hass_storage: dict[str, Any], - evo_config: dict[str, str], + config: dict[str, str], idx: str, ) -> None: """Test loading/saving authentication tokens when no cached tokens in the store.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_NULL[idx]} - mock_client = await setup_evohome(hass, evo_config, install="minimal") + mock_client = await setup_evohome(hass, config, install="minimal") # Confirm client was instantiated without tokens, as cache was empty... assert SZ_REFRESH_TOKEN not in mock_client.call_args.kwargs @@ -117,14 +117,14 @@ async def test_auth_tokens_null( async def test_auth_tokens_same( hass: HomeAssistant, hass_storage: dict[str, Any], - evo_config: dict[str, str], + config: dict[str, str], idx: str, ) -> None: """Test loading/saving authentication tokens when matching username.""" hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} - mock_client = await setup_evohome(hass, evo_config, install="minimal") + mock_client = await setup_evohome(hass, config, install="minimal") # Confirm client was instantiated with the cached tokens... assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN @@ -146,7 +146,7 @@ async def test_auth_tokens_same( async def test_auth_tokens_past( hass: HomeAssistant, hass_storage: dict[str, Any], - evo_config: dict[str, str], + config: dict[str, str], idx: str, ) -> None: """Test loading/saving authentication tokens with matching username, but expired.""" @@ -159,7 +159,7 @@ async def test_auth_tokens_past( hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": test_data} - mock_client = await setup_evohome(hass, evo_config, install="minimal") + mock_client = await setup_evohome(hass, config, install="minimal") # Confirm client was instantiated with the cached tokens... assert mock_client.call_args.kwargs[SZ_REFRESH_TOKEN] == REFRESH_TOKEN @@ -184,7 +184,7 @@ async def test_auth_tokens_past( async def test_auth_tokens_diff( hass: HomeAssistant, hass_storage: dict[str, Any], - evo_config: dict[str, str], + config: dict[str, str], idx: str, ) -> None: """Test loading/saving authentication tokens when unmatched username.""" @@ -192,7 +192,7 @@ async def test_auth_tokens_diff( hass_storage[DOMAIN] = DOMAIN_STORAGE_BASE | {"data": TEST_STORAGE_DATA[idx]} mock_client = await setup_evohome( - hass, evo_config | {CONF_USERNAME: USERNAME_DIFF}, install="minimal" + hass, config | {CONF_USERNAME: USERNAME_DIFF}, install="minimal" ) # Confirm client was instantiated without tokens, as username was different... From 8158ca7c69240014275417b92a05e12804df1184 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 22 Sep 2024 16:55:31 +0200 Subject: [PATCH 0936/1309] Add connection test feature to assist_satellite (#126256) * Add connection test feature to assist_satellite * Add http to assist_satellite dependencies * Remove extra logging * Incorporate feedback * Fix tests * ruff * Apply suggestions from code review Co-authored-by: Bram Kragten * Use asyncio.Event instead of dispatcher * Respond asap * Update homeassistant/components/assist_satellite/websocket_api.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Michael Hansen Co-authored-by: Paulus Schoutsen Co-authored-by: Bram Kragten Co-authored-by: Martin Hjelmare --- .../components/assist_satellite/__init__.py | 10 +- .../assist_satellite/connection_test.mp3 | Bin 0 -> 36780 bytes .../assist_satellite/connection_test.py | 43 ++++++ .../components/assist_satellite/const.py | 4 + .../components/assist_satellite/manifest.json | 2 +- .../assist_satellite/websocket_api.py | 69 ++++++++- tests/components/assist_satellite/conftest.py | 2 +- .../assist_satellite/test_websocket_api.py | 133 +++++++++++++++++- 8 files changed, 258 insertions(+), 5 deletions(-) create mode 100755 homeassistant/components/assist_satellite/connection_test.mp3 create mode 100644 homeassistant/components/assist_satellite/connection_test.py diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 3f322beef29..6932fa3180c 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -10,7 +10,13 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, DOMAIN_DATA, AssistSatelliteEntityFeature +from .connection_test import ConnectionTestView +from .const import ( + CONNECTION_TEST_DATA, + DOMAIN, + DOMAIN_DATA, + AssistSatelliteEntityFeature, +) from .entity import ( AssistSatelliteAnnouncement, AssistSatelliteConfiguration, @@ -57,7 +63,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + hass.data[CONNECTION_TEST_DATA] = {} async_register_websocket_api(hass) + hass.http.register_view(ConnectionTestView()) return True diff --git a/homeassistant/components/assist_satellite/connection_test.mp3 b/homeassistant/components/assist_satellite/connection_test.mp3 new file mode 100755 index 0000000000000000000000000000000000000000..5fd79ce86095ad7800c1684cb8392c0ff89d6175 GIT binary patch literal 36780 zcmb@tJq*1d zIuHng8Nu`YC27OQP!%U5(VvOlm3_bBREWWK%r&*}DNy(0Hqt4=vOjN)zHG9gXUQ_3 zz#{QR_q{8}nQt5-S9xSCWh?S*iJ>2~Gt+B&_@SyT*Ug`gMG2L4bK3XIDUEy=87q}7 zH6)(mtb5VD{(}Sj&+cW2Bj_i*^M{rvUIRliWWT&~e*6f&@AU2hPJsI*+i& z*VHbDyIvnX_AJ<9!t1hG@v6*r%kUazVk;RA3wjOoCooU`zq>C701HEla@I=);4mFH z1n%;BA4$m zkzwD_fSY7nLdbdAv+1YabeR0L(H@&%kk;HN%>Ct$=$__6DNu0%BSi={dYK>^i-#Vj z1Wv-lOvNTGS$w6cc_eGaM}lhRa1foy%+D$xDmEjSF=m^YOeyn@%Kq4A8h;aNuL5|o z42iEtEr)-uiTT1P58&B>I>X%of!#*C!Rx2@!6#2n+D4z84th;q#S6WuzjLwhb4lfi zb5s0(Ck1YFEzVBX(|N1T+B--B%I~`XpvWR;u!x~BJuG$`#z(_%DgbRb9@K)POQEfT zs6g8oXiBhhknU4-I4l+|obY!xtmf71BiMJo;_ucN_B=2iT~$)U5PHT(LQ>8rvS^{< zO~mj}El>|T7HAcUL>4KJmsja_$%jpH2f>lc>me=OklZ&;$1@8hP&N`XBdbru%d z0L1#M|NJoxG`zJD9bf>eZuc6VNDf^Hy4)*?j^}s108GL_I znehIFi*z|f&Xnks>FJd86PQml~x;u_^B zLYcok%Ix&@^^KQ{PJj!2{fED;w44c~D+I9%_X9Y%{?C*u9;~XGAFehw^YE`_Rfl6^ z8>iK#eD$|gP5iR__@vHNRmmLW_cFLxD8ob9|33WRa|Hm$c`GKP)rVK^?uJ(fsLN&1 zznXO@57o`z!4oJc$mH?xAEEW*Ak@s9Q9m6B7ZPsCMh*(GM8~khk?8}Cs9=T0;;%D9 z{v#BEX)LLC>}-iCo*12o1cONE#?7y5K%ns+29$q*Fx6_x5ifMq2Gb}xOEB^%mxM#5 zBhfM4_~>H>jM~UaVz_vuO~PG!86M4jPgHkImd)ewVlg-2YWVwAhvP9-CRu1K7#M&2 ziZcaM`@;^=KZkL$S6+c!ramNSo|)9az}6_;ah6jS4X_zPusag`zcbz^+yR>W4KBqu znG~~yFEUmx@3=MZ<>p5<$A%O!>CuB084*ZKOA0j9*L^AT0bnyCgboCjl1qd_LFnex zqy<_mjmCK1Xwv-M7=&O(TUW5a6D7w!!aO*KlBE`V$#KCmdCIKYI&?;VJaW4g&QPW; zmqul_evk2nVWzQC5%zRr7gv>k#V$d!7dd?6pTp7JJtvqKg2jRQ*bU%<6ciSqwN}~I zlZ<^KAP#5}T^>EXr!paU0WdPC4gT*8s8}JH56E5rd)^*ge?RtZ_D+&N<#J+IJOA9E z^^3=5crXCOScjs%oY;>JBBlg^v8_UJz@3j|1VqvGrV0@AY$C)DgdmV}PMMi}w-8J| z?h21hau5l!1Lk()2x<{53;`t+4$)42GwPAb!gHRf7?UXMJ_Q>Qu+dvlX}M|TzTf%v zPOBtj=pD$K&JO1thnzMaOOz20E~7&ZG5L1!9JkauiRx3H43pE}g<&@&ww-lNq_%V3 z=r_bRFUhr7RFs1@Hh`aqTmgBel%fGlPuc%F8vq#GT>Lz^w|G6NGyShT0DCNVB4a4Rv>iS8S(-f_(7mJi3w|9O?= z)R56XK60^S|0Fwi;$p0(gSpLkX2obXzOnmTepCHjkOXl5itl%Zv4g7DAn;|Z_j4Cv z@DWp8Xa2<`V0x{Ytu#+o54H__&Jv6Ya6sDG$WijtNPJW)DYQ2yY!Sn%H=K+nU=bur z2SQ3g)VN|wx+AmHWWZ2l1euzcR6+M@*%-HsK));710j;wb3vYw$E$q5dAU(hj%{DZ zr-Q-W6Ca6a{*DACEI~A9zih7&s;S6*8Bm-Vhk_WKJ;;ZKiLtuzF(Ju;@O}F4ScF+g zZ!9X_Qmdg02>nQea&U8cMGxj%q_&H-j3Dh-Tv@;K zsujDg^>DZzb?IK?mBYzf*g#2Rq+Sl2EfzoI*mV z)Re-fV3BIl-Io3PoMT#~W1Gt$$n((DJ^{>k4}_qx=P$X2v%i@!aK1zlV%gXJ(#+!S z>4K~mq7MOOFH7kmX)r^jZ?oh;udIB~3liDFX%##|n~hDWMLs~++kbe&V zqZ5A7FDCQvyB7C<0KjfW@V)S$-^9AZfb~dMEj(+mw%~ohi!ZZKkf;%;n~rphc|>60 zw|8tr=aYx2>pJPKL@P6+iR>x$~61ObFh{LMmg!S}z%=imR#bV=G zm6-z3FqSa%%*T6ltbVf}m$FVnqDV19oPHn7>X``n|1W6kNQ(!sQn$|JK}! zC-$Q?Hf~iuf5rdzR(Cf??qYk>e{Np9Yq5Lhzfq`@5zKBoa$t7_0QsI>!TATlE$_z4 zyqa8?2K;EW8>3RdEG7XU(GZX$ajX^ybN*{ABoFD=ZOR)<3zF-0m5qghT>JW{AVTUP zOOReQ7!o<`0VM@N$w8s-DnB}=gC7Vzft>x|R(ZT8GsF8pi20JpuZhLoV-(rY@*g2k zceir*cb`Y>mQxb5VtJ7jiXK9k%?3yM*BR>uVYNmmD})& z??RV^S$(eHHN7|QeW0Llt+x(+QKvm7)=x@Xewf4V32<%f+qqNZT}XwwYE2K>#uE#j zu3#7>#BM}V%7I|`*s|C&<^n^iy7b>cFcG}X9u-iiXe_ZUR0Q-rGR7!}G~Mdml37-) zhY?x?4vl;y2hngcG6bIb?st!BS4|qy1FF)-^*zOXfQ^W*J$icqag=$J%sqGFd6VwO zt?}cNET))TGb7ouFoTl!;uAP9`Sh&L3afS+G{ z;?wcJAM1=3FzG5TYnQO3B7L;Co2VFNJDgNA-x)GPHrGAn>i?dSv^S%dDT1HAsOhk< z_X%#wC1{9+f1hT36!)@KBNv#;&FF!k(w5wmqPi_NtQKV z1HscSU=;X1`!tjDOap--&!mOelz_(A?OFgw&+jdc?t2xvOzGy&4h~8+iqWQel9`%h z!TT7mK|w&5m?QIVH@~p3>^rg76~kQ`KEIWkR2;njVBm0- z>7$v2fbhUn#I01zWgEGArlm$3(oiompj1scDL(DoJ{@4GzZ_?otLqSKfC!9%iVEo{{q6Z*cijj;DPoH zgr&s=YbTcFBv*w$5YmO5NiwKDZbSLd5dszEE(tu+*xVgQmAYE~BlLlqP6-+sZQ0`} zu4dF7-5r9dMk#YR0f~(zlQo@(ve{FDSj3jzYg1RkJ9KW8raXtuh-Zql!=J<^h5tT# zp2npg-kCl)*L0Q$&izx-)O?U(ZgFe7NbR!wI_7R&EZq=bIAgpFdzolg{?|mHvH1BF zaR1!ZedVBB`*HqBvvqdLsH;Rufw$34PZBQZSqKOT>g!)fy~NR4+BWU8GBQ?&f43Hh z(1t_#Vc<97(Q0s`QhowVsimYEECo(jc7PQ^IuReLt3>-Evj#8P8ohwA3bt4>fAS{d zuWrj|Z6vwbi7ac~Rr{h4X9K_ax6>Ha*VVxrz(Nb)ekIu7tgW+n>`&=DDIRncw9?XY zaq*_(@(-{!_YMAoFhSld9=*{K{`>1kT^+)}2qlb75FHPk27^pi3y%*2qZczV0D_)G z!VP^Af=skY#_{}6P!`!;awA3q8i^AXoKuee;6;UzGpv=kEuq<_r)tQ62I0`Ld@0EZ z?KzfH(oK3G8vQ(IdcR=@|w z0aYQB-{g+TuEB;kye;y9L%R;R-0Y32AmTpz-5PTLU{sBHBku4k=H=nvG8piF3W+@T zApHs0l@oSn%0J|XgR@7Giv1@7o94Z}5_)L2 z3Ro=(j5L`_^*Iz|G4{oU3pX5C61~xZNMIvM%Gq6h?Noz)H}9+b#N6@wWc0&g{T-?^ z7*Ggn;*M(WUp_h1 z-X^;5+G{wQ?3{EIaC_5~zbSG5__k5g0szi#gJ#FiQkKmvewFuRQWw6Xxx%FpxMPDG zgV-MkWkSvrnNWltOTosYwNSO3IwXn3ZGRm6xdl~iXD>;CkP+Bwf-#r_XxeIq;4e}( z;W@?N12O@eCbZBd%)~P?MMe^gCb4kx18-yx6wVs@!xj`DK4ZztCfzSNO9D}%7RMDA zp$Sz?{U$8>_4Ue0YiK?{*cH9sXq{tj z`Df$Ba|-Y_7B!S5IgLn#faDpEnl^9qv1-?UWdSffndg6bMn zI2047mk#%_D?&~HQ7@A3IZO+!LTm~=2ID|$W~nt;5n8x$_%szmjr6^Y&_wiKup)=Q zzDfOfAgtjRsc)G~4)JnuNn51R_nJOZX_fuizhjaqvt)iW@zt8Hyn2siDSc1p-}MzG zN%3*JYRWL@PD?Owa067e0mp6$pP4Hre*S8ssial4Y1Z_W*R!EApL1R-RTn;Lo@qYL zY&fepo@aWmYfo6^MwTV)T#;UFMPnG|%qT|J)O#?GHZP0$n~6^d*XB)fhrk+EG2@F~ zlc*+$U+>d+Mzo$;+|lhEd>e8c3`5+Iu|C$=M1>IKJc*IEb!JILK?N=$s12*h=;L<~ z82p(3X~YC*8jKd!{W|~OXHF}|yn&3zE44K720b`fQ$A9*k~l(+)O5nd(+`BUW6uwng`3C_*5~@Z z29}L3QvyQDF~@4_C~dGgt7}Am>4619TxEQEQfnbr;T6bF_z((Ak)aStd354MRah^l zJaHa$e*`%evODEi_{EYr0KRpbj`ng2K`4WcfiLrFQ1tzQ@ z)EP9CMTZPIoD=4{c@8}=F&%99vPm2}to>T}-2SV4e=Q7j-DQlY_ zr3j*>K+2ce5lv7{PZw#35Q-3E!NgY|ou{&+eaPCE_PKZMJa`l{M~_izE%4{Xah}li zeY}FXfqz^edtpa>U>Y?C|&tAN4= z56$>-2p3*v^mWd&#Y_qwD34**Ixf_@CKG{)!=~93bn~QIlu4z=daO5DXfT{L(fWmiDySx2~GRMBF>qZi*{?u6i)M~fCd zExYilqzH4AnmkdQ)t4ATC2KjHITrWE@5^Mvav%0Tn@L=d zE#6;t=m`x-tZO=BcC>?V7NeeOP_a^hB?vuAWNXYS`q$p;G7(D_K9*;pU@I^U3%8Yl zlQcF&!TmY2VK`$v-`0SSbLn`X2Mqz~AL!60r)?IzkhN@BIeDYPbqL;s!6TE`Vgqz` z{<%5fAvHInbBi=wK~hWPwP(=cn)X9GNRvG6n29-t>_$EC_JS+SAmYla zE{qmGfuVqmA5&Hg7BeY1D*5^3KIr+{&DHa?ZCXSgZMEEpOr$j%&0EStzg@Nn4p=Jr z88q{$2-~GigB&~j5NFmWqyTQoDNCPt28X=no7a}?h=tpMAkne6=pgCBLYXBI5*ix$ zY$S}0Nh=O5!4Cw5ae-3h7wDofV18_ZLQp;!8;l&+K3*d0fe;XT0dtYVUn9Mg<#vRj zYB?>)J8E~=a^!l86{;U9P@@mGIm9I>eM<2W7prwKuXxY7>^SJ4r9*h@$=XfU-|HEd z=B;~oKNY3cWNm@ZRi883Ypq3ISbdu}9P4^^plzWusXur4ta98?+iNFjI3I7^gT9l4 ze(AkUhK(YSTD{}UuS3$+D&hZ_tNSWH7_d0XxLOGwbh&zUgjmMv?D23Gh?yChw2o5ZQVT zTm`$CmC9clT<{SD4vy9Xhg}Om6R^Vk;iRohXmW?HRn&N?+C6$6GZzLS3x^H0U=Sk7 zkz4Fi5Oop+(&xn(_fSFaCRhyz$p^9D}Nw#6n8Q0+KaI!jk@rl`dsQ# z)DvrZs1DV&SUuEo7JNe`QUv^-qC61r2P|h&iH}eX;hJ8{S3V4AWfU#@A&dr?ikbUV zI0FnGcjOn(hU_m-Y-)rfI@vqE^9-IIUMG7jBn7E?159rBAI>KkAQw}Q>I|e;YTbc++S-yVT z$%_49l+UHE^aKDl)~>##d%k|{{XT}g%5%(#xb}h2eB6bJi|mJ0>Qkm8ga?T6lJckt z;_gw6EN`(wf-;sod!b^M(^Vo699f_7>FSAqe7XKQy;$A>(+yLSSf;7e?g|qKW493< zd2GJiU*s4iVL6?Eh!PJLJca=q#>tfJCx?TEG1nS`2*Iqz3$e>9V{-+gAtJ4z#@?lG z>j^{x8D?wLD_a~jOx4Wh`wh=c6nqX3y#|l&*AFw=7Jb_X%Y`n?j^0$obvd>r9J~Fx z{7_j}fBuUy{crv8KmQN8Z6&SFpSewJKCtiE&Z<#Tp$+|!{_JFVq4@hl24K`%OX2bvwq0%po{43fmMG}Ez&^ClnnUZRW*N8$4 z{(K0FAx=M)DYq|Cy6J^C=4E~qwSfOzeso-N^jY}$&4pR>jpEH-@OW_7UH)2=uP09i z@8^7O((Ha0?xV&giQv3eVEt`uPVnh-py_t_!vmrAxQirLS^QO*84dyzA(l%@LG`%V+~-WFRL`KJ^Ygn>!N#Xm|@><(cR$$w#kqi4Yfg@zMuNy0a%ycwfo z`Re#qJ|%kjBpR26Yk8(hR~}p1h|D#TY7zP7rxI!P6yO~;xAAXdr)NeY)=X3kpb$I8 zAG(MTE1{8e@0$HsxA!+~EGzI7ebsD(UG}s@pgQ>{~>97$IJ+*D%+~W4*X;+Q5CQESL2ca00Q0*T4x{>|8)DFJ3 zE!2ii6-KYy3PIe6~JEVo`b3pNqrsRq8_8{ z-q}5wrN>qbV=;ZiiA!%AkXYRx4x2*KVM>}f9&SnElJ6k6bZBsoZ*;UH>aM2mpJnra z6YGqFCo6k~t$KN9zhz}U_sgWnt*SP7?EOHffZzhpRW@an{gnM>ve<-ACxHe*B7llu(vi?V5=2g{$!RavY7)3Sm-5Q_pu4wpEOU-HSe4%G|c zMXn~CKVgDHIA>t561WmcFE~Vw1MIZ(4>}riIFp^S?*10c0Ae94dMkTIS-}wArfQ3s-M~j>F%xF-nFlb8*g2Lq-1O=@t+7kAfZp+GA2OIOOR3r$D;PfiUOyg$~;o-!s&W13>vtvB95e z<1u+~Y1yq*BL@}Cp5$cTt0x{uqRcJjMw-K1K@mIBGVnL|G?~|2acgXo0TvL^kcRFA2u9DSzsvkHAR3 zn3kRTIndHp=Ox1&0N3bfWYg#r8*@dIt`?D;SVki zIR>`J{tdENs)T{{3cI{ISy68d-24iv`_#>Z_^-AqOQQcsD^gH;q6ODk1eu1ZmN>y8 zynoIHwX32epqpTe_O}-+6Zgx1-XLt*4!G8HaAjE{@xLNrPqE2Z8LfmAaGU5~<-pP& zh3j~PLV0FM8}OhQ#poY}_=rHW=vu*|2(%rAuXZnI;JS3LOIl|`sf`lbi?KRAE5s=f zci%UD=xr4+Z_#Fsk9ms-Bzw{33cfl}m#BJq*j}IMdLKZO`pQtE!^fX2=#DnOUgvF1 z^QZmevB*-jk$$PVfCx6kXBw&pLWtP2J_?1$?Z%s2>V6NE0;zEmF|VttR>4CaN?1~Z z^cLt7YhM4m=S*YWG2LtJq(qebP13Dx&`S2DlZ9QrnBlZo#M zUK848t+?MA2Z;eA1&fYsS=eqxYFy<`8#vkE!?gKX>8H{11&jLQS6Ae{Y0sZ=H{34+ zlRP*6Z!>?~eGg2$pW$%xB0XqW3cmXFd(=xK$LVxqbUl9(IG#-z?xY&Pd=+j@e(}-I zOAWX8xObp9g7;?kc<;aYP(C`$6H4DqX-`kBcTmDUn(gh z1|vBpq#$B!@Ml8Q>hStFcWm^sPdLa-dOGBUB36m(j+bGjX|FOU&;INbGVGR_4oJ~P4=0k0K`~mvZRm~UrYb-Bv z_EM{!F)R1KBaSY@Q=l*g?t*`r%XROp(yA(Y1GlXxdGeW0?c^%}+iY#=fX;1%OY6(_ zh;@V!1(*PA2chW^8BxlMmE4qDom(2ClDD0eB8jC|*zAY0tK|s5zV!X7wjszJ6Qh12 z$d5+H`1@;YA6hU*h!@xvMstJI;ZRv$ErgohSx-k(H4&VH*e zT;|O9S()Oxe&+Mnw$|+O^}}yMHBF_owQ8~N?AV7OLvol_5MpsCRd<91;GQ`>6uTb> z!i#VqA3C>v@Sz%l^8lAV6rnAy@rNn+rM71+s@hgWb_o7AeHe+!ta=-aN}yeL>l-(& zBR2`ynyOTuYn4hxL<$J$Iusf@0eHWR4d(z%z(WaxpwNVW3Y>xkcYr4okjaYU@#Zqn zhq7hjlZHw&%4?-@QX5xfrq1GKo(mmm9%DqE9zXxB;UQFL96xA$cuJbU;pDQf;2iu? z_us33+*P^1=9cRc6c+?kp2^)v&~N zR1gabz~t7&0DXgFZbSLRx5+Rq1m-|dikpL>FB2o)u^Drd)2uWhoVA}(#a_O821L~n z7kwwfR$H_!J2!ay%0#lwaKye!dohz}W+;n#&0(yrw#xLqL6I0U*9)=Ws*43p7Y>%l z)Urr+ERr2;WRn=)LSM7HeE$u+WZ4;&-`!6P{_V(&Pt)Mv3A zSquTxLSud0ImD?iWrgIFoBKaPBySstHC+{vA0K=uVOc8-D;@!r5_AS(plJ#65z=^z zVQBH=YTIDx=jtlN$%w0{#>yMs(fL>o29~ORy;xtoQJyv7Hi)YdH{?^v zJg7_ATYemr%fDW0Yq2YHHwBCtDpnpkI7p3XRS-shA7cg^odBGFfY>MZ{y~fR_YPh= z!3(G;bOBt8X9$ko{-`{@PO8v&X@0qyQHj#~xss`w^JWs@J>s-2gsC^_-K^}j z|7>ZbhzcX%Er_1?BoXX_P%CVbq!<=36WLcakSd%WJg|k$KnSYU>8AMddHmdfc-aNK!| zOD{eu4RLUz(h$p~4nYGUuSa=wolqeX?eQjLTLCedgb&|e>i>ZeU!+FL3TMDITBuSP z9Kdluiim5=AEV^tr)#3TDUe|i-Le;tNke4)iZFp|O)RR|+Dc~4=V_GuEc}$Otor5! zH%npaNQvy?tHv(Zw#y@XYgPw$&gQxSvGfvqV&w!`LUAno7+jj;;>W z8x)c|$$)Y_{nWEe6Q*+LOd}ZT$TpKH$4Y%cAL@ZaMVQqPQdC7X#1R^$6`Vx~N}{Jg zWP%!E73g(_`=tAIx%_Wv>_|CbMhgylLg9M9p8O zQN~-$M5CrhE0-_Ksw!Xp{Na1`GsVPw&i7eI_UC6mJ4Sb#CZgvmM}mo-{I$mlDy;?bRP>`B z5UkME-XkM5+Qq|O|3bAV(SN59qH2uqx%%W7VmFQ8q2vbxjARfsCPDbr1ECRub9>ib zR6FFsQj2O>na_#mQ9;f_ap;rKf1(e)SW*VMboA8s@vaYWJFO$!IeOT?4j%?Hsx_0t zsXt_;JY(y;L%!F zdcIDaLhYp$v$(8(J#I9E9w~D&$8QL(OhFHpK`TKm0K7w{Zdh6 zxU?ndR=<@KaO}Bpn=!jT%CK@mRWc%S0sFsw7;*#b(#F$y)*qA29th3HoohPxepqFn zVdrMEM72X&!w)`GI#k|*N<-PpB=rWPD%r>Ba;%bfEpL65H{SeXWO%M3G0L=4RhZuC zpqHgS6xYRjSJpG2^t9&wT;I~0LS;HRr zlTW^_pDkW}8J1C6+EQ#M3tT}YZ*4Bwaj@^#ybOfKkG&aI1SFUO(Kx=+= zjp*CS@ybj_Ra+hjck2r`_pT^IEldF1TfwM1ky5SP?8}kPFhNgce#{hwx zE5Lyz?&RxyPUnaICiich>tBX{*#~IAXZ!l}Ec)llK3%=`T-y)YCP)z{R1T2oZz>T@ z^Ovw(BK~dxwAPPquD%XSP3QXe+yUGBQi*$2ehtj zCVz7;mz&9Eemr)^RS{V^bD{gqo#WZ@MZmSM^RN1~vxWP&<~b8T_^BxS>vN3y8N)55 zsWMlUK*?Bm#Qt6Xn0*fO*Ek^2CQXiBrN^yli?#nP4%WACbcjK2$5wv_DIa-}M7L&H zULsd&g1#U{9;-c`)f%tx>rvm;3VTr+u*=uX{^WTgvk|3 zSez~j^-jt9ZACIaq&AW?`ic%6j%*DT_=}p;UM8!~IQUNGs$7b-6_@M-41CMPV+%Ga zF-PLeCn%!-=0@7!`LfNlkHs7L68H;=-!R00@er0DV-&H3fs1>K`&M%nhfJA9HUo#u zCtph+iZ#F$=&katckP-D=R;PQss#^Hk0DnOQQAZ#V{A?x(0s8YuvOOs~< z&eAf@vOgDe_i#uMV2B(2bp)Ed4h8|mZWvwe1EHz7b3T+Tw?=#lJ3^pVeJ+Wws|k4> z%axG-MY+Nskug6AH5PoVwf}5cPZtd|%_sJpE>jNb|0-|z$w!i;VZ(Ru{4l-tMB`gi zieagEt=WFp+O(rHBm2I*{XAtFko76(M9-^X5bD(=vYkUbI4JUEx|GH$oP1;VV%I-U zRC4pI;Bn!jo7al~ka2z1EDU&VU!RD+(~MDyA=QXu%|$2=w5g%V4E1*?#fItTZ0#?N ziN_CV$*D2L{Oz|DjfL)OZ_YU>OT`Uu(&&wi2}s{)%7wYF`wNP~Xwn%TF>x)CS5=ZKP z)%LPjGV3Fbdd%wNz=|#wo<@l1>aYFS9YxXAJ!ujlhR1qi#u~~dzZMVmr-MFuO~?O$ zbEqA7R~;>i61J!;${(HobPt$6SspozVinzwwik!@7RgL#)BO%C!(s1-kfvB1sq?|9+Hu?wrp2Z8SS%@@ z$yKaa9=VEsKSl+DWSeJsp;(|1#fqU&rR|r)1{a~)T3k!xShTDDiPtw6oYw&!jf@ip z`9ndv$yb*@Gk@T&?PZ*>``<}YzpBYvnvR*dim=P{7O@M^8u|RJE%=&z?XAWfw;JtG zG$}u4w8rD6kA`?$MuA;`*j+tgsQ;By)O%zuM+gE~~Lz3H9^$L3K=E~#)4?eU+aPHtDn}U)QILA>7jf|HhX!R%`a)>S#qDRSc z%lu+vV`)xvDbr>;U2?xVkB<{vSoQ(gCz+2%S&M{&Qtj%^UEK3eEA_KV+LxjVd6J`x ziJ%M~)3VXo0DJncD|X{@pUSr6z&X@rA~R|L7kmS>Z%DdyF`Dr|DP?$Z+iyQ3U(*zi z7r_QoLqCHj{^RGQvcX*}qkHZt&4Qje9HzyKj!?t;eYvWJ1)X`+-G4$7nK~GQzWzA{ z!r-hTZVc75Hu`D(l8{z&tjWxY-Q8Q4$YrI^-QevRtZ9RGnx6{y5le|$JR*Y~&o@t@ zyVR`Tg6E=WjZ4=szWvJ^pVsnY`$3OC{Z6YypKiXK<{7-ByArB{s_BcOqgfH3^-e=r zJ)w@imK91O8^Be-G$X^%*mv>3tabXa?VG$6ATzk@)^FH8p}jWCdkeqR;sblqC(|Xh zOg?mUWSXxB1TFNiN1AV}91<}}B{#-_vokjIlz{oCa5kg|ovxx})mq9?t0zR7-ls%{ zE|xHC_43rK-(XfB;q-ApvvB6WN`c@U+qI7jRVnb){^vt_^37mgln)KI6h2f6@e@0; zI_q*oQO9okQ3I;_}tP&{#Q5PP3$ zLCn5^*i@#<;*{; z$UmI62^i~_*l1S?beniFqJ{p2LjRD_C;voJf6b&1T_Q>qlkOpWx&&t%4yDf3vIK!X z`ef;ZfO0;j3U>Ni-3S&)+A#@_PL z&#LF^FT2E4V2Q<`DpEM>F9v9nn9S{8d^mYTn<1w)t=%l8rD$U z+)vxnY2!zz=4|)JYIK%c1VqdCl>D3z)0H0^6I~BWuEMWM3W5;^9u}mHwM@_1ftjnR zzb}~dfR8cb!uoxLVk?QeDTrhQkeIQUQ%c@Zq|7+6X(cJAd7sXlRn}vB@U5OWyP1?` z6gG9eezN9q@znQK+vo$K4TAG`sE!pCZj+hXE$Hk+ue$DgGlT*4qz2B873ta&94|4 z9Brb*`XOGjf297hP&@kR5ZwYJCTA7{ru#nx(U~2eMAr=bQoSGDa6xIZ$7WZztqZ*~ zOoCzyp!QCR4*Qw6a=+t5kcqTodYTeLp@eaH$~!%##j)7E50o;5tUXP49AaP9QbF53bf;QsC{|;wag$b;qILSaa@q?3)%6Kq0zkZ zs=NBJ$#Q#|ALv;A!K6VcEL-gSi^^vv za-Z-M(Xy3P5PFfJAEdzV7_i4Be96GG&vXz|Q=+Fujr1=<8@AuPp$^$Ct8P0ux$JZ} zuKH0C&wZ~_-av=0?R%LZ3BeY!!Ei}@L@c(biB3Y5;I?)JZhF7M1A7|)QkAH=ksqj{`eZI{JWhvdGtt0G>R(zXCzaPxoI@5 zd}ZNxIowPo!oE~m?19CrA>i%Jg2(bb$=-DaEQ>b_b;hf6M@8Pg$UJ&!Y%LkA53myS z!my78Bph0nFmBvpG^TZa9-dx~`FU6p7G3ls0kyAdg9Y7RKtj%;sF{+jRK;%>(o16+ z@+6F+O{G(*gRbK?le`)UUUvasvmLE>Yd-geLT9fnWsXonO zDW9x`&BbDRCtX_IMBQ4IWXkn&zF$E3D}y{2bIsRp72%k9uWGczCi|0nQXQk7;BGbm zCmYqBCBya`Nk?JWdgFB{z2myhUo+C-%YIz;PN9b|az30Bi&6#+j4zvW0pr2&X7ZHh z^L=v0P0#!jJA@#uCOhb-@T?5RHt8RKk(jk%~a*j$_(||1ht#{eEBDov`f7%yXDt*fq~MG*r&08zOB>uQ)AjYobWeVmUy?R6Y9rA@n^5vh3uJhHy*p04Gs+wD8>Q@D%dm32FC1DR9T}Kq_OXvd5yiOFn~e*XbRztr@GQFwL+QaaJ{L*I^6j-IG^j_-HBxWljkR|kG=-y=3m#g zXT&}b+Kf35bVU(DX$s^2ML81o2FRp`om8vf_lH-(*EfQl^Ri}xj#da?JFX$#Pb`aT zty|qpSg9?iHNgdIp?4=2^De=SlVmHxquKfXZ!2nrMd!jvMY;QN^frrO@o-9QLlZEj z0Id+fe9QdmbQ~VBSEhUWf!%Hy71c+(fahz7y1%;5-&xnWkd&Fy4JxB6rar=xl3?Kx zco@iP&9>03#?;(f6Ay!o7?1efK?WXRSX?h~a3BRBB*!W!5v^4{5y5;``)u9WJ|6UW+7-sMg5`pLH6$l^D;q$xP<|IiFo1 z+4DcxxVws3Px>3OCr1&kA3+iB1ORW7w#wD$E@>qLkMaM|Y68IJ%1PIi{d3g2>dS3s zr8eHy-8=io!(m_^7#)5JVRHg((!BV!KqM@f#ReAA1*4~vg@ky*=rLel_@JDaBKwRW zX#pj)9d2wBDbUHJr3;y-3zhkT+3`h(U8G$A5#yxtD!WdOk$` zFTIq(UxjP&jQ=-c)xxbL(>Sz?u5W(O<(5g`QS;H@%44%0*^=X0IX<%*s3QAdO8n7_ zDH1myyN*ZQiI*w9{&QVy?`)~(u;gRcYC}g_p+y$0brxm{K`?xZfk`C>@$v7B!lLed zP{6qsm%shW;)p_X5}C-Q#Deiv@LfQ4&{0}`*FuNSO7pbG%VCC}(L-3#-v*-yIlHB?hFZxCmQXXCvay*}*%vXluFvpJV97Vm_c{I8a+R}Jn;!wuqNDev(hKygIpm}Uh%KyjHRRzSc1=+!Ea0wpV-GT>qcXtm2 zhX6r?ySux)I|O%kLU4!RL1%}&-R+P0?Cv|~R@J#Br-goAl}@0JuuL}6P7n=6YMIDM zQS^S8C>IhVG&T1R)NV$AVij3iZis6vsIM{@t`fi7f}t6hBTSf!TRiTNO!&~A8uO0O zDZ(AGZQqZd^4IL=?`_*hO2!Q+Q)lLW)lGT5fQZ=-I>GzX*}SaH;pIsOiq;$sO<%}K zeM^e1COXnTMaeNsA56*9VaKQE%dD2&1qR~H(P?#0aSbV(O-APrU$wijO5y0IrucQr`0ZXrJJ7Y zmV2+{);;5fE%>0^X2tzsT7Se0EcgR*T9U7e=<^rKk*k5w+8s)lcbA_Z4Dl)qhrXQO zcLsxcDlKa^@7X^Z$3EWQ^=Mme0}>}1oRLmBR&8abQ%R&2dR~1*ZrPaWX~;H5I$!2C z!4woU=c9D|Ay!GIRhp4)?PoX^^midsAcL#55Cl9~Y5{gy!b}LhBvx4xon;O$?#Q*( zOrFpN6Ac^9WSHoHkfKdy>x@M%Ehf62b%+T(?+6`4 z-9CQli&z)#XOaKcSg$NxhlmQj7+UsU9GXbSYqo7N(-aj9OqO)EY^Uds-fM1Xp&B>A zNNH16Nq;DsY{=J_r)v)jE6;5pg1?F(32%de=N3=p2TN*9gL1Zyr<3jd9CgCY#C%Uv z3_+knZ_sDZ!b9YnUb+NZoGjL#{S*lCBx^Qtnjs)5zo}I6P*I_uz0OD24;1|-E;vIR z!35v;aFr1QC&@_Edm->c1^tbXDX<0&Hg9vk_UoAJ;2fB&CFBsAG@|J+RysAZ5$={- z-m%WXGG5vbQo4LwZ}c2lOYk8s`fBpq=$de>*fr*$9!_>a1sKBdVC5AoJvGGXAh>D+ zmodP|UuH9=LMY`EF@!NBx%K28M+ zQ6ynJHaZwlh&)^tILIak7RUoQ05IB52vyaKRzDN>URmpHq$5VQE0ghmstAJqmKhEs3u^05h2I0^iT!YJ?j~ap>t2jNlx9fF=DI#X$0$B5uv=ss7IH0pvbmRPRb#Yc1 zLM>KHktii@QUxf$;36Q24qzP0Z|mQQbu6EJ773I@CdQG=R9&fMA~7>{rbEL;hEZgF zdB&~LbkNCU7dw7e!nrpV>qiub1An3W3|!u}W!r6_F;3H_xTJUm`5*2TElL)+y%?YPtt^SqNJ z%`)4uv-r@^_@jr+FXhQ}0msVWjF{-yh;SbbU$3>4tvB+S)Fg5~)^`o{+!%r$&form z-mVtwkV}oFbMTEp1=3&VDEi`*#0n7u9V4D%aJw0m(@d#SG12S7rSwy&6if@W0|2&u zM3R&^xD?=mnNo=)F)sokYo5PPdV7rW;@Y%8lFi(c{-|3b|2}WC)K16BsBU<%{zdzb z3xf$ao(9R(p!rpl9@+)e)lF;@+hW`ytP=V#%`UQw9v|)sX6eW;jg9k_#9X%w#_&H! z$p+Aaavf-hGlfk8z?vEkUp-uf7O>!);PaV5Z;#I3w~zT6%~M-GXoEO?LB62#%gKW* zV%>G~YF^F~3_(i3!k1zxDLP780OK6I5s85nKb?72x5vhBQ{3NQthmUpO{E(}g!A)b zNlb;Bn@I#EfA73dE)+|E5ceFKY)li_Bz-8XBsuI0W0il`zaz8{e=Bb*%HB?^!esYf zP653RTz!C%YWlB&r)wF1rb1?p+Qe2vPMUD--zjaqbxM{u|xK8_GKUQCJR{9V+NpSO}wLGb8f@19-x(O43xQ!olXjKv*d7*jhN^h^!E4&>!e<0IB~G zPPM0o_vzQylEYKE2V|YM#kz@;8*i*FeL`V^p~iP()@d({TPuG-Mx8|(?k9?AH~TJ5gXSzb$428)F@*Rw+oTALm;?cC zDGR&-KT-qUU{LEHbtP(xillFk%d5M~w_)uSJKgUHZNuN}TmNim#}i?S`VXPDCTM2l zPrbAMjXt#KS6r^QmQ_Cy@ehJxQfr=MN%cLN1$d5ivrR5yzx{2A*eQGUN?O+U(ycP! z{@a$F=_Ke(6&GHHELpe;r57x8WEO(~?<5pVPE6Ql31@i-dWp%r;gpv)_Gn<=4jyPgK`ATH$wku1f?6NC;7sP0f)%3Khr2151%yFB!`lesrIueQ-*bu?E*SLm%Ns#7wWLt)JilWM5ST?D*rWKb*eDa^V zs-DZFQX)HUr6<}`wWJcmpHCjceWT4y-x1nGy*0M&huRRDVMPA#Jha#ZkLm~|)bd|_ zs3$Or+IIA^JoLcgVu!kEtR;Msix!+jp;EbQ_I!NyG*m2$wEd{8e}~GphTN7q96#YI z8=h(t9yZYL9B4&0jUtMMRLGASCJx$Sw9FYBuh+ye)8Di7?feM3ezCL%d2V=~fd+k} ze>|mG$3qR*$?&HVM)?QelHuN?ZAZ_}rfL+CXmfi7SO@6-EcPD z&#Et&%f`7&p9g(wwtfnqPdu?K3CjpSP!*S+3u$y|RA(40i2iV-UHnx3L(Qf_R4GKM ze%iFdBd*G(%~fR0I^ihAD(h;qsd)ovRsC3w|4BES!ZZaIr5rVa;R^X@DA@vcYb#5E$a*$~35nVkrIyRZ<$e>S8d|IQSkq+0&fhgLai<4cmO zhDtZxpC?jR-0Gjzy@#f5uR?E#9}EdzYkJsPSP#byIxa!lbs+cA6?z%#s7=euliG}E zVl3~(&n_D%-!S9rwjMvSG4v$MDVjc%b;;v&5}t#1ALzAbqx!IWv)|qW>qOfCz2VnH zk6$v-2?rMrGAwj7A=RG*uds~{mm{bz)Cwof@-UNa1`h{HjSgB3=u34vNaX5+L69u~ zcfMuVEf^Kv+@_cs_V&3rTD9U8)7tXU_lplz@_GF)x{{zaB29IdPZ}=i<)v+-ba8(# zxRY92-#oUhcZ={aX>P-8k8WqpBT?oFNXJ5c_FelI5EG$bp$$NYiDA}#E_n5p8bN8N z2xzH4Y=83<{QL$2skW`^PHZyz-i3%D83_+Zr*g35ql>e!LfuepU$Q`}i@NxEumnOt zk;ej9FAGx8Am9TK@qnS~`TBWr=2WL@JKDq;2h6N<%Uy@kAO(%zMKh6n8efitXSIyCs zeQv;&o(+HDV{NbClDJiuF|GP(p`d8ek!h`ntiEG5PV@<%r37W=&x@Wz?KV7q9W+w8 zFGzQzMrU~)R18w+XaT>sZ@a;`Jo=&VZ0HFIB8jvb>xhP=Rw@Gm%LwPE&gIV-kph^W ztlA$4aU%%`fuR6+zduqWCR#W#eSVl}Q*IUyj$;SkUp#t1OT3rQA2@fD*rsb9p<04Y zWs-dxELPu5=rHp3&Q9pZy08#?l#~*f$|(=l!7JK90p80)5Cq#=k(S^lmp|+5-{o>> zw#mv#V~t(H1L%T60 zmk;_+XjOn7_gv1N`G-tFC~O#ti4j}i#erU~9=zIK4=*gm)sj)nbuf;j#_vY7c}h!Vxk=nX36wV*(5Y>Ic6@Feq2=Ttv*4PQ0=T_%{a zBPcBoyj@o_vk3#%^MRycX)WnRw#gK_FY#vQ&RC(z&Efh@{^q0$lm-TScz9Kf)KjZa z#y?Cmv8Mal?w;_jVPhoG)#vNYODWCE;d}g0z@&wqob2}9&m8kHD4O%O#%0-Gic1C` zu2UYi)_wASeZ9~m{(3Us8$EJ=f6f0{96AmG{*wv3@wZT$eL}~2L5I(3+%Wh9P(i4w z00`GW03;PV6ape7^ue`{4|nBd2GEDwGcdF^7_PT4mDcOn$zTVj?zSFUiH8cE+O~63 zU{HB(f4vUwdP1QKh z-?(d-F-6v>B@~SnnZkfpTi`UKrqAfvgG;|H)jyQ#pz?;h>psQ-w8DJI4! z{chx$_m>kjCeGtTw|FAD;f9Pq7;Sed;>(c~9qJ*NDp+=a4m*ZDzbxW4Fbo#I@=-(+ zhZ4lQq_OO`0o1;1e77g0@JSl7TNyTrw@^KJ`v46e3T)k!5&N;$Lt@trX_I|imCP6F zWnOKO|J60(QRX<`{eXJ6{V6)bJPZAv*3;^=LrU5NSKTILZft%xn>f$pHl7au0E3E5 zwW4h8KD9_YTToyLGet#1VXq9AD2JIGD@#oW37*L8Bj(C14F?4ewnJ`S*hpJ`kk7`m z7sGUH8Q#cC)fezKdj`FBcc?aU6|b$cnU8KI#4)5Fcnn$y zHcxl45q#qM=$!mLuV;LD70iNK&3YbzZe@;*$EzA?A=wg0X5q0;X3D?PdRasv z1EGq@qd?hjzZ|>U(3#9eePj*Q!OQzFzW@86!L>9XPT*%c30PA}C{DPHhSgY00v?7P z&yRN+RebO;j2KC+6ry~E-go41ixV=NqRXKB>T>(X3Z1ND zv*V_0##7zoleg@=lW#gYv($^l?mFH0YXU0gZq%s;@#Q^Q}OT;L&T)FQdgjD~3KZ;WmQ-F%%_g;_Esuv0{>iC1>(PA?V&up>{Ha)&JSwA*gq z5dx>Szk$f8|7-L@1}7as)zb?@&?z72fqvZlG5wG3sIfquO59?h z2Qfw*GPNm?B~_6tD~%h;liMqnZ8{;sfNe&ZCBP`RwJs(iA3wZ_I0N83z|1*NzUjVR zRi)_el%Q-lsPZtc_HZK#%TFgU2`na$#(grx;&jPgvJRezOrejmKPWVe8?TO$T0iW1 zSP;yVBfxk5?A5R0F1{fw&FCuPXexdrq)h18_o@;OlldnD82Wn^QYbL@3=k}wEeqP( z5%iRDBdYYso(JwLU-xnFyu+m888 zlLtHIrK#MIiNCZ29agAuY0y|0T6gr5nXZ>6H$*?*dYWNsWs2fCXd7QLvIk0TMROc- zTqyD#y1NA*)FH}G#LkyUDYgc%;ipNCs8LXdCMJeP60`S!&OO{)www_hM0q^*@c9ox z(_oD#`)BFts}t=y*;poR)=^D~S^3gMb#9Y#LX*+49B4!CkOxEv79s&P z)*e7EXytu))|Ut6n->uBrM9@~JyinLvdVsn;qqB=-O{E3VNqN^!>Fk)%DWA1BH!xT z3L|w0%`yJhrl33tMkwl2Z^M6+;8KCkcNkLD2fK`#bdfFJ$1ThFkaM~O(z`9kEWWJP zbW}gS@Le33?HkK2sv!m7@l&9f1gK0h3ePOv09*G)OQOzb z3xPp++~f2rTz`tTm`v<9a@$@+y=LbK<)YrHs>6lew$uedoHE zrq`3rkhkl)C<~i!pkAPqmi@YsMDCTJWQh@_H^yurxXS3g={78S@)1*S?S^xLV$4Iv znt=bx8Ce_lpopTsabw_UWh6p`DQ`kzKjb#w^@GNT|oktxod)GTiRu5}x}xSwBLW%O{8`Nu0OBjH|A zLbHkNxfVGRj&r}q`)|bu^{b`=zr5<>EVKZjN0w=6+4C?k8`%f%2yKVms@n=f6>wbB zOV$R5znl4+;fmNM%UccoJk%ZqM zaPGwSwd1?nXosEH7UT2DZ?kR5x{Dl6X(Bp)Lwe51bU;m z1Ci!>TQSbb3MH^rV#=*leezR9qdb&2x-r4*_Caiarv(_Zb81Vyd(u(}BQIH7r=&Nu zRg1@6@md(Hfzo$YX-z|I+Ar?PKJOeCy5AcI4Y&)L1Cw0AWk_Dzkd(=<3DU0hzf zZN792Sa$ovWFUtG0olHg0)l8uk(XJxWjQF%+1w)8Jl`M_4{U-ZK7{T`8nY0Og#1{l zpbK)M&D_WVetwQ_ngls+c~oa>xTPe%G-o{ze3DHouD>mzq5fw>fSaW+qVwVS`Ap~k zxEy9%6J&K1Yj5{Ib-BEUnO_7+G9s&W)24*!(4QvyXD-x+Xe4E!jT*Ep9^BdE=%0V# z=uQ-YerKjB_TwQmRn6AndFFd?6wt4*aVZEzjzeOIIry^M5)^O~YVJ$|+=f+*W>fp*!^oDz zVx{AS0)l0k+cg$M_M9uX9cnc)O&)0(f6t5QzG|sz40R#)9cMw(?l`zO21SHGI75v6 zMq-eJaIhiLVutIh?TGmN_<6i{ToppQzl|(TAM^kPo?E&t&&boE7f&2)zMvD%Un#{k zMug=y5~E_eV=-f6)yZY+SL2z&Va3H<0I-JN$f9dWl;d(lRKw(u{h(HxblkMpkr~E} zd`iu^Xz%9U+2&2|nVUPooXx}ZseZ~L8Y+9K!;p#n?(&Z*#Jsa@r~>&vOvqq_XztOi z8=z5w&_kQvwaZdgcHP^nNNh4=aEAD8qF;S`5NSj#iToLC9>(Eyp#gH5D``2S$IL@EMirp{qL|HcG##sU;yK?d^+B zn-bI5zV?28ReVD|{>-2(nHZoGpg~jhbb2{v_;?eOaq9TeuqZQz##UoJ2ZzLxOUI!e zf~)$Y07*cd1dxvqF_+dg=6G0$m(oe(U>&TAw^G1jEZ(NhsXaB`A|j|;YRftG28m~BhkV>s49 zDnzA>%)B?&7v#l+?VMdJD9=KMAC_*se_%>sNTODfSJjJF z>C(AN)k_W}G9ci~+%LPh3`zWgE7LG_UB90+qX3+&cZRqE&)bvMV==~}aCtkF+TIF@ zoWx6#!J<@#r!99C+@S&54*qoE=$##U9nkg2X zz@{aUU~UCT&hRD?71e?u*{;S_b1Yv_x9Zdl9&u#8s4E-XkYNZo%`<)TS@165zN9T4 z5C=eEK<}m{q;bicp~)8lLSjzzq)kqnL<)8TG0`WfVSyhhCafUPx*`Jssrk%&xHUPK zS_ZLnhsobrvIC87^rl3(WqPW8yU2Mwk2dMqQ0U!@Zi!=<-Ea8P7N7O^e63yY&k5Ky z37sFRdmfKG;n{MyGJWn(-KT%~Sbn;Pws6iLYT0y+d)p5EK5yTi$nC4ZMS zfIly*W03O~vdmP&Ci_URooxebJNbNo!jkp^KCCi7ZMfl9)_JZkY(3mfu=VkdCQC>x z-B}_~>-|FTMT!E<;AUY$sG69Z-_GB>BeWfQD`VUD6YS*}CQIL$0w#I`fGH|&WQlVd z06wu|dCVT-IyRtYh4Zd*{YW`q2wq9^nM`m5*GXG4_A|GfIk8(jcD*gJ7|i6fgN7t7 zI6Ga^k!ce~2F*o{=N)f78SrAAXrD6MK0tTw)$-F8NZ6Qtxh5aKyZ-$2T)k!8Gx2rZ zwXFkIZ;aKKSTs4FmZlg=L>QM02kobXK=&|h87xMu1wNz%1pEPxUzNw4kidvsPU2Rl z`>!-;;Z>t-<1CVA@=e{&Xti?<^g@h13XuW+%i*yHjLt6e2ZRMm3SI8IFQnbyKq)+m zchrYz-R@w0uBYi6Z~e1t0xwOD)TnrJd$Romiz$;5+1SyPVs)2D7h%%;!9gkam;KY0 zv8)o7)CT9Jb-NGLD2BdZ2LM`HJKy^3!OOhH>$;z>k(n(YH0zM)Pj7<;ZIP9Az^P)H zi*UdUq;iHF!hjv3_eWZSz=zsXNX$8Qm=`b9;A@tCb-FG&-6>w3)?Krd)o@SyX4BK; z^k;41#K73`+0Hu0eaf@zbG2kkc;Y)kzanl)ZTq17^_*DjzP)FClt!DNtAkj3+quAT zC}U-WY3yu#_An!QUcuAw*ATDUL)vS=l>A}QV_MBwAN1>&$k!^&%4c*68P{rI7{sYY zvr%mi5&{8qZ5Tl6Tw-A&8h}0T+4s$mze2T?i3NsmB0hWsDvEB_)Ixhtw=xGj?sZvn zvh>4p^h_dhbWoxs2tSXKbPuZ~_b}IU2oP`}$4{jt49A~&fEWZ3ZH49u0V^lm9sx*X z{PLpZV*fDFZ}EL>T;lj%(^uJXvWAnh<`c)AeZ$6_)1i-DKnM>uA9=-&Vr9By$O*w#}-mPFsX zyW38KD7P>ZWV|fA{4CT(mI{G3Fjsg6y?8trWEJ6V+N(NB27!+)Pi}KNC2O=00Py$y= zppr}7S4Cw*SK7q`O>E>Xeg2K+cZ7~&ZpUqVp$bg@e0taa1GoA}H$b8UA@|Pw*QQXO z_>*mCT0NbN_Q2$_@PczK?zxJs+Oe*2quXtk-3}WHd%z+-P_@+Qhq2LbNk@M)2Y(|y zfRTd`T9AKfljUdMljmnPEy?{)J2uAiTxR0ShoJpbP|y8a-bKVyH0O4iEWQP{iaZ_A zK^`SQEs%i@681;QFd{>6@Q7)UaJcce8Gb?udgn7Gq;-FLAH!chLw#6v{WrhVtz(^ID2_ab1e7HaGID;w~JA6>*ng* zHqIz)p3UGEG5Z>FTyw1^^3hdNfMo<0NpG6V4$-zUJ`3C)SU7ok8DXC`?X9Xnj*Lqz ziBo{}wT_G%i+BlMCD(7ewz+Ww33EkP4jvoMFbLtdlCDs4B7lO)l70GyOP^eYp8v$~ z7;7K+G)fLhYbmAKkg6E`uVB*FEy9s z(u;^x3$c!d^eGMT&K3(3JIjKM*H@n$5Zsh|cJK;DRd zGGUq)B09OVa3^T=^Mkp&hX-(r$mG0~ec4)cO<}Yth$eKFb3uf%pjMW{d=8|hU3#zX zCU4am}#JK48n@{$7VXJ+fsAOc1SjR=GyOqe<7>7(YkWxYbv7a}<`j%sHm zRlHHw^f9!F!#!9@wK$MjQ-(cg?T*LeDT2KzKePf(nr?A)veW<(Haf51ASNL$wme*w zE)W0(<1hE}z@eXed6obbEJ6+q8T`=%$t|~B{}kj=X~{^6^uePdy|?piS>LqbTys)> zv0TqRYd87!>vA43-8(|tVK?Qr{U&+xe|XQq8k%60(=0HT3t}9_Y9;~e(B{1nBL%u! zD|gBiaSXhWfmoM8`rknxm05LUoMGTfg?30(z;4JW!~_tV2h2k=+J_S&$rV!4BiQEO zJuOMfYQN{B$(Op#uyRI8 zo3tuSC4`B?YsFU?LH~pci2U}%#^1Q6*h@mfnl6Hf13hq{VTNws)AjY}ONur>sQg3C zBdO20dbfqwX+&dF#K(=^%ZLjcx=+3?r3atXL*=|SW$)P4BEanNYD@j+1AYEta{2~v z8b?r!IP)2OP-V;$G8dO;aL6Amk%Yu|3$*%pWBHr>xQ&H?!>=lJa=`8DF;Cr<4p^sR z?QOs*tGR|OR-jCBcen7mu21CNUmDlnOp!+i0H6|*+to@(-WOtF?Ccfa#fDzfqCs0B z4xs`T_q-nTJCO>H2L7-*6`lzHe67)|aGGS3xTe2;oUe9C^pTuO9a@2kF?l4OGqfON zc_`|G(_#@HMTXS;J3?CsH*0pnnEs-FKHHVDfDuA5Zh}LBLG9gYCIxf3JpINiv=|s)r-PJ79kvdN{r*LB!NP8gte`aN>td`FnPT$B>2d^M1(mx{GFB4ZF`<~<9e|X z6gnu(Az=ns)%YfPRd)s+P}OPn)07Cv zFyqUCZw_Ua_dGB+N!Arp=B%ORqq+Me=}gymLuCPjt%Zl>7aiZXuJ0<2>hY2C$n}M{ zZko8iJEh1%oW4mJwLzR9hDV_U9EA5X*I3+|S&%uYFIEyBi%HNLU~;*09r57doE&M2 zm$%Ifi@O(i3nq3y{n?sZ-Kc{uNP-?^b={0a16Tk0&q_@E0c}}!1*OA_;O}KA-{%5= zkbYyLz3e1b$OADD06%3xqAStTBuX?c8_zpRb(=g-18Z|JIWG63*-H@HN2d%+seCnO zHkAocVHvUB{qWIugiaA|8NT%X1S6!39Et|8K2m_FgRO=^A0=)k@dMkCmQr7Fm4_%O zXU*;6+|bwAOKiJ&=C`zdH-z%wNI) z{ImII8z2>6ko(x1bNwJnav7!%pL8Bl zgXZdv?DC#kulAAC^P$>4U~>O)Q4^%}`PFix)6_2GCaW;LIBm&F5`HnqsV!F+Uu?N( zrq0)(a{9Vl<2~`#{Hcoy+_cN9+^Ar}+Lq8p6AMAPu`)48qfLfBwa)khVX`0V(Fe6h zt#4dd5xqz~VQe8;wI{fTX}sCV3HA5dX>}t5XfEz|)$XJ3N2i|^K4r5Zl4wz!*f9ib zoU}T2g_iE>A6C!F#?{pqLD=6JUayvyMX_eXY`M3CTV05;6O)Zx5Qs_I4xuVCF=0dy zOhn_bs36e(y1)8twFum5oOz7(!<&inM2X^w2dRxtl zto%dzeHoC)Ww!Yn!CJU^W)0)YY;3BjBverNI3u^c?PUlFN9!9 zRltD=%}Qm<3PYEu{iELH-eB>mgA%k1YAJcvc}q*WsJx!KlvoM2W}j6mE~9c$Hxm=j z&%Oxl0rfmQQJvBK8%_boAwt{!u{`l>@u+t%cZ90m1X&e?+NZz;{$5gv^*|^p9$AuD zFx-z2;Q(RdeTZlhG+ZH~Vd}1?Im^wtsmmsVL3?s8)BRtZ3 z03-lKCiIVT2um=cTB>S=`Eqra0cfG>>}2^3wCdi_W!P==AfWb9caH&&2uJbmp<Ml*COSpp#3T&ApHK%rKQ7K#;@6kH1`ZD=p-hRC*G)|Qq;CaUz^hET2=AnBsAR~4Icg&-Pop9$hsuCL6o zbkv=98m%_mtivL(>d910=ozLT9-YU#?zRO;Z7=d#d9kER#fB$8QgCi8;9DtFie=`L z#|3xrt7;QQyt-XKPh6ZS{>qXS>`>3HF@d>_B2fEDzD6xtH>T?RBV}Zpk2I(mP}`T! zj#n>y&hIDsBWXDn7XksMMAUU@cOM7|5JG{tA_M`U{Q+WBAG`bdNTe`;gij1CEG$}k zwY9(hT$xjy;xK3(+AUXV)eUUu=_w67@VCfhi%$!j6h1H>rnokYH&WDZJaoaec6OeK zwplJNHT%4(3xlA@19L(P6-;;oqiLlSjq{VJc3e{k&jN&pv1~KHaKc|u_yc=sbW!*C z1i=Y~)Ay_b=p|XKo=0YIaN7;v%rC+9{A^%@Z?~LxzzsiNYC`u2IB^{J`6s#WoI@7n z)L~hLiu#!CwFYpA%&6p=mgh zUh$+SW2yBR;2oiZup4>XzOj7yKT7ANXaL(IEU+XAavgG?1sEapk`)q2NXcpISsEFP z$e8RosX%1^+Qg2N#(@zTHobAhCd{uUn(yMiMWF`skWLgbsC@{7mVhc=>eF zZFg|k79t>78}or)&h8@$7CsStKerJ-{7*j$;(*$(#mSI6Xy94qSeT!F-|=CFz#_H# z&;J0*;nnD~nttn18;-#`=ba8|dUI@E=@T>(4=UAceA0d28=YUtSV zb+|>Fo6KJ^4l4}RR7JX#G1?solc*ttk)t4-kYjX?BRspthWr_V3+wyT^Y01Zl@y-W zW4|Lb7ITYgD<%uXxE4AOWdTSX$ssfV5Fk*)Lb-AQnCvU|@qY?Gvz<9O3Pa*08JoZc z4`-IL#Yg&&k|9^t6L*Hc9e<6v0}dr08}2VU7U$~hJ4Y*2`rI(H*|hyk;2`}%GE4dk zRWuAMm{(0!W&YJi(cM()NNS%BZCHC2j!>_1Y|w%`pPeDLD}itg+;;$MQ{Hs1Vm+Yq#O+-;Yt-`EL3idxocMzx{=DvyAiO)#a2%YAlSxk4BXE0_$|;ntbi=b@=9WsPmTa#bz$ zY|gaWH**d5t6wa1nsEgI5fj>=$X|mYppoyE_6Lm_lF(>O z-=DO8s8!12U1Jn-aO|#~F`ZT@lNFeWz&k-tBTa$LXEdT-;u$5b=|po(9mAAh)(+V} zxUhV0;7#+Z{}Xx{&-qEIq3`EYi-lq}Q~5jU&p;vlVosg-60ecxXge7$;I=0*>4azAJ;=md%i=keBsI+l_NVHx}R!8^X2&;;rYDHtFL z#agYXQZ#7WdtepVgz&OQ`QF1&NiIGV{0s#&C3479TjM%4S4lrZJo;5n1Fz*uPn&kv zQf(QjqwY^N7EGX$k(HYOO}i2oO7sq|jFwTfly(n@C%GxCgvFz3BA^@*24BWaWihc$ zZY5EP`kS{Im+k%NbkmaHf%%iKiIs+H*O;OTRoSK3uo>#6f9d%tDQTDJoz=YdoUteC zkzQw}^k=)@mT%QH%ZC50?A2bCaVO)*foBgLp@m$WG=XoXZ>qG!y;_Dwh*e}%2&b*x zCNf=X*5mJ%laInTdf)Dc<#X3_GWT`+acT{X(&{ccgjs3pJlplK7#7dY-oLGEA3p?J zzIHK>f8KY;IE&<+GEN;zHATjy1NDP?lM z^09D7@N5ZPDr{4^?aWzkVdunFELT_I&uFMrKl<*yBEBOu7jr}Kp9v{5{tH7u^*R6o za`vck^KSr{D1aOv$hqPKvP3GM!kI-9EQ}Q8vT4MSFw>9&1|SNW`MJJELlsepg@8)# z>j)%VEZw}yHmTae4~5*)`?oDDcypEG5E4&ZM2Jk#_^>8rJNGRv3f)&RAXr0+*16ERgcT(XL!&a$$tLq{Rd3sl^~*aDm_*=H2seRk{@__7>5%TfACP>eEqw zMrg99v^lZODahNczOW`cZB|+9mEYNS8&xiiFMDpiF1cTBA2!J68hb88G@ng;kn_f8 zd&0Iy=)Lk=Dpg<*lB>fo73UQsilSa((|!5x5uoRrv;%jLA^#_Pnu{-)M~QFRoENw$ zbP4_Q4gTf$n28$2_U>LX8xc0XZFp^7cNxtOwssL9S4#r(LwiIF@l%wuW4%td$K)Iw zL=j#BD8*hA*WuiiqsrN9LRVPDw;pheEWz12r4=`&>E4;EaCn>bm^rnnZ_6~FXT(?+x#yKiGn9e z$(jdY;1bD0NAy^}eS<~~RB~X`GW&*z!6FBP1ZAW|%naIk-ZPwfGpEct&1HAx*g&K3 zWRSPi8NiE9ZNd}dZAOdOUgK5CK@|bvQRuu-x%`CWKzK7Hq2uj2@`1&qZfHw* zcgM1MmZgzJUkf7PA1vUXr=C})wnJkAe&dq;Y=T%ub$_Q~H@u}ipIY8gKN0X3@2t&0 zOCBHV>JDBhb4ES0%rCERoBRvoSUl?jR?Q9~_6{OaKh>il6RR*&6TaURJO~56#@x~8 zaI~eL8>gwTm=PgQctvr}jPru1IOfplLm~W=16lukQ7R&5Zo7Az8a~|O+aBjzwu|EF zh`^qiq|VwQ`mpg$gfGZ?&y^lEd`2{A@F1?g2v*$}ES2?schGD5!a|zif!)$I&)np} z4$N9TUwZ{QxI;nAw%==nDUZc7AL~Le+lYB@aMKs7X6(_!`||VFU2zXyc*^I%t59uk z>xBKFAsw&`ZM@l9%=M<7P=JuAgHqK$8$vv9f&|-8p9}angx;`9tfka_h8r)IH>wX% zPOE2w{Lwp7O^(~s16scP$2=$MOZ;cId<5lwkvEs}KB<3UqYXE=y<}Njdfpt5capO0 zhqft0S(jRD$OH*qMom;dC}LL6>IJRX>vBEEl8)YE$6hHvH7E00UCp9#^93fRdduxz z5=EK*t!>niRkHlIWbn^j^I}aEvmrB^b}@eKN~VTs?t}$fsA_q7t}9*{g9#p^h7(J1 zYsl*iD@yRBeF(_4C;P7D9u-u9sJgT?Fl(5VT3wpzC}vNb%B>s4rsPsQCSpADDVhxM z2?O}?(%vpR(p%kBqnP2RVk*r5&4L)NYHD48hH4#`BWYZIrE1*p$Q|Wv5_QLcxo$$% zLLQz+6&#PqBxy4~aZP5tG%v(eZ)~Doh7^ng6VU&69T}Vrtnb~w`3~^Aw-gtb?UDWE z*>5-{#zQ3o)~E2P1?G zka?6{qsI+O`11F>#E4X!IXXv63(f~#vZ=YU7ne|wv*i zPMwQ2ptnV*7+u4{h;8&MMsH)+KZ)GUu3HHeikJ{$bYv)u=D~96JeN^cFP?r~E35D5 zxwllQ0xbpkjGta#PJ<4ok{+lNH?U;lu{UbiYZIr`yGk$PGGE>vdIYDPDn|A&ldFC>s8NbH?9uPLB={A1s*Qz&eJE` z4;3(%?wk%0?CWoNi|W4Tpo*N*3o?3hl}b&UQrz-#=m3ioDs*(J1avL881a4acN^M< zz(luwr*ic&=imdY^$`%g39b}burIZl#06|al!XgnHIzS&WA=FMdko(<^urf@O6#P2 z_z6!ucv0=J{PO-W31vss`sy-*A*?9-K<{5;?3u={+1GjXqq06;o52U;0*iZDFkGX* zVhAK;x)XLp^C3kFAmSZrN0e5q(rL3WDUgC`5TN@YCHlMmZ`(cp>Y8=4O{&CBAmGy^ zN5u7)v}OAC-K8x6dixh{2oQc%cdG2Twn8H+q@`)Z!7?a<@I%6B^{+6Y9qo4ibP z8rP%7Ptvw8sx&q2+Yb z0Lrp~)=lqBq3;{`IE3D?k}1(yoA^z37)>qJKq1fSuY(3fd?bt>S;8>pHt2f#!pCwG zg`BfE$UG=p#bzfUlHqq=bRps@H2jqbZ$NClMLg|(X4m!gUhT_u;zzphr8c&mNKP&> z+33kkW@w!{QX~nnZSkpsJ~WwNB(~rG@6)y}hYjWS>BJdBq=D%1FvXPvl^E%_fUqB( zxV%Uc@MwXlhJKuW;d|O?H5&TXuCyt0@|8(bb&~7Tn8&~Q>)F9oyVEq@0^+Q&6)Y;%*^RL zHL*%@-~Q_z)3b3W~N;cwm(SxZJbm zBED%ojA(5Sk`3qWb0iA})y!X%?rsFW*LOw-c%C2gXBHFp9Mth@L)iY6dKNOoSkrk? zRrLZB9T>c_qr!5j;8pz`W4a<+LNgm~w@Na8}J5s+Vyd0AG3lg3@c>K;-^ngHshTyn#FbQPmtD_pJWRf|%9 zC>d2wZPm?vr=nvSFf&5guEa=f$3CJ+%N&qB4guF7oo2zlU)2Dl;V!$wENk4%b%}vlmih)L zg-i)T(;2n;0GD$N<_4ISYRQByLM$W*?QOehHTV?pA?%=-C0@tzqeSq%A-$E~3%y+S zsidd|4ESq}KR@jsmSZb=pkW`=*(v7Nj(Wzq{K$zjzr|?BXE~zn+@_CLbvTw8oKof{fm4H++m9p zT27Yhmrh@NP!`Odn&G^5HTX4sO6@qmi+|@Ya=aeStE1y;Dam)|#jV-q3eK%W-ld7n zPl1o%cKYY6ZzaQz?>9u>Tv@U!=v-eA4^kT6Uh(Pu)qNx)AWS3Bf@V)*>!-b!d&I%! zqORP^xX?8DF(~l8R`RHrh4UZ6_onWS=M~#Ja<7>DmQ&o!Bz6d*-QJ1$NmwrPEVB4pGu-qs!G9I6Swu0S0-O#63Z*Z1!H#%;=d?vHmX~;KD zb}#|T77sLL?{x;xy}CDH^{MihHbURwWmssCw9vNJ@TI$N$2AUBcIC*nWs@y^l|MtO z|1+v}=b}dHPx;4&>q_#L*Hb4CnSx`SKBYNcAF$BOR_K1kbdES)!AW=sZrkX&>NDyW z-=DE)TTjJ=otzhHk1pJ6|NXv9kAbHaES-MGZ;@PN-p#b=hQ7lh3}u69EBKx0ejxwb zv_#p`T{)NEs^({LIOZ>5pAq zsvRZ`FaJ?x>Nw#W;q!XIW=w7Sw#ST`KC4Xiy=IQShIp%^rK#>VDFZY6EJ{>vr#iz` zKp;q+;LRQ1in61bOB;jlf%n{vK7)ebs7{fpm>PA)L#ctJ#M!0zZ6t8}Fw+e$8( z9T8Ts##TpJ9>#)U?p2#EW4H>2lI(ECzEvcf=Omw&BSC0Jd2am6iPhe{ssRKF?^8Sm zAaw0!Q~uZU5XmK$d6xWLZfjzv`C7(#vrD9pu~W4Nq6L$N*zIWa2e5$|7#iWX7O#xF z>w0rGe13&hz+ZO>wBnt|JZxISNBUj%nDvWZR5wSB*W@inn(J!qJ7~Q}(H+ZBcVyGr zTXApQg!uwWzxA7ii3!H^bieiTo5!LbI=9_QgEato9(X2g$k`QC98$(V=YELVt%Sx;rFkvCi`fsJ`-kZa%_V42)U z5Ag)2miB2nAVK?bkES>Dk$KXPmw5~Zn?6>Z&FyBEUr)!Q(B;DFB_Jt>O5y2D?%kz4 z|Fyhl=3c`@l#raeE~2g`<~}y5;|^te3V;s4Dipod+ZD-A37VOdC#o`q7!tvih%>Xm zV#~i#ksvfBwFN^ZP*8`sWFYDTgtnE*ytU2gJDYs!ba>D!aokE%#91bPb!d%{nuhU^?w> z;Yam7;AA_%t5QSYTk#ES~>zq!T# yVTOOHkRX(;JYPrQULGR)n}NVtAY=q^!G0S^%%TGV;gf&|MgJn=|IPnX3;Yerp@#SX literal 0 HcmV?d00001 diff --git a/homeassistant/components/assist_satellite/connection_test.py b/homeassistant/components/assist_satellite/connection_test.py new file mode 100644 index 00000000000..956542dacf3 --- /dev/null +++ b/homeassistant/components/assist_satellite/connection_test.py @@ -0,0 +1,43 @@ +"""Assist satellite connection test.""" + +import logging +from pathlib import Path + +from aiohttp import web + +from homeassistant.components.http import KEY_HASS, HomeAssistantView + +from .const import CONNECTION_TEST_DATA + +_LOGGER = logging.getLogger(__name__) + +CONNECTION_TEST_CONTENT_TYPE = "audio/mpeg" +CONNECTION_TEST_FILENAME = "connection_test.mp3" +CONNECTION_TEST_URL_BASE = "/api/assist_satellite/connection_test" + + +class ConnectionTestView(HomeAssistantView): + """View to serve an audio sample for connection test.""" + + requires_auth = False + url = f"{CONNECTION_TEST_URL_BASE}/{{connection_id}}" + name = "api:assist_satellite_connection_test" + + async def get(self, request: web.Request, connection_id: str) -> web.Response: + """Start a get request.""" + _LOGGER.debug("Request for connection test with id %s", connection_id) + + hass = request.app[KEY_HASS] + connection_test_data = hass.data[CONNECTION_TEST_DATA] + + connection_test_event = connection_test_data.pop(connection_id, None) + + if connection_test_event is None: + return web.Response(status=404) + + connection_test_event.set() + + audio_path = Path(__file__).parent / CONNECTION_TEST_FILENAME + audio_data = await hass.async_add_executor_job(audio_path.read_bytes) + + return web.Response(body=audio_data, content_type=CONNECTION_TEST_CONTENT_TYPE) diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index bd5453e06de..73bc126f7ba 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from enum import IntFlag from typing import TYPE_CHECKING @@ -15,6 +16,9 @@ if TYPE_CHECKING: DOMAIN = "assist_satellite" DOMAIN_DATA: HassKey[EntityComponent[AssistSatelliteEntity]] = HassKey(DOMAIN) +CONNECTION_TEST_DATA: HassKey[dict[str, asyncio.Event]] = HassKey( + f"{DOMAIN}_connection_tests" +) class AssistSatelliteEntityFeature(IntFlag): diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json index b4f89456351..68a3ceafd4f 100644 --- a/homeassistant/components/assist_satellite/manifest.json +++ b/homeassistant/components/assist_satellite/manifest.json @@ -2,7 +2,7 @@ "domain": "assist_satellite", "name": "Assist Satellite", "codeowners": ["@home-assistant/core", "@synesthesiam"], - "dependencies": ["assist_pipeline", "stt", "tts"], + "dependencies": ["assist_pipeline", "http", "stt", "tts"], "documentation": "https://www.home-assistant.io/integrations/assist_satellite", "integration_type": "entity", "quality_scale": "internal" diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index ee7bef7e4e8..741f4364e7f 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -1,5 +1,6 @@ """Assist satellite Websocket API.""" +import asyncio from dataclasses import asdict, replace from typing import Any @@ -9,8 +10,19 @@ from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util import uuid as uuid_util -from .const import DOMAIN, DOMAIN_DATA +from .connection_test import CONNECTION_TEST_URL_BASE +from .const import ( + CONNECTION_TEST_DATA, + DOMAIN, + DOMAIN_DATA, + AssistSatelliteEntityFeature, +) +from .entity import AssistSatelliteEntity + +CONNECTION_TEST_TIMEOUT = 30 @callback @@ -19,6 +31,7 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_intercept_wake_word) websocket_api.async_register_command(hass, websocket_get_configuration) websocket_api.async_register_command(hass, websocket_set_wake_words) + websocket_api.async_register_command(hass, websocket_test_connection) @callback @@ -138,3 +151,57 @@ async def websocket_set_wake_words( replace(config, active_wake_words=actual_ids) ) connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "assist_satellite/test_connection", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + } +) +@websocket_api.async_response +async def websocket_test_connection( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Test the connection between the device and Home Assistant. + + Send an announcement to the device with a special media id. + """ + component: EntityComponent[AssistSatelliteEntity] = hass.data[DOMAIN] + satellite = component.get_entity(msg["entity_id"]) + if satellite is None: + connection.send_error( + msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" + ) + return + if not (satellite.supported_features or 0) & AssistSatelliteEntityFeature.ANNOUNCE: + connection.send_error( + msg["id"], + websocket_api.ERR_NOT_SUPPORTED, + "Entity does not support announce", + ) + return + + # Announce and wait for event + connection_test_data = hass.data[CONNECTION_TEST_DATA] + connection_id = uuid_util.random_uuid_hex() + connection_test_event = asyncio.Event() + connection_test_data[connection_id] = connection_test_event + + hass.async_create_background_task( + satellite.async_internal_announce( + media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}" + ), + f"assist_satellite_connection_test_{msg['entity_id']}", + ) + + try: + async with asyncio.timeout(CONNECTION_TEST_TIMEOUT): + await connection_test_event.wait() + connection.send_result(msg["id"], {"status": "success"}) + except TimeoutError: + connection.send_result(msg["id"], {"status": "timeout"}) + finally: + connection_test_data.pop(connection_id, None) diff --git a/tests/components/assist_satellite/conftest.py b/tests/components/assist_satellite/conftest.py index 489460f8e2c..9e9bfd959e6 100644 --- a/tests/components/assist_satellite/conftest.py +++ b/tests/components/assist_satellite/conftest.py @@ -44,7 +44,7 @@ class MockAssistSatellite(AssistSatelliteEntity): def __init__(self) -> None: """Initialize the mock entity.""" self.events = [] - self.announcements = [] + self.announcements: list[AssistSatelliteAnnouncement] = [] self.config = AssistSatelliteConfiguration( available_wake_words=[ AssistSatelliteWakeWord( diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py index 709005e38cf..257961a5b32 100644 --- a/tests/components/assist_satellite/test_websocket_api.py +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -1,11 +1,16 @@ """Test WebSocket API.""" import asyncio +from http import HTTPStatus from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.assist_pipeline import PipelineStage +from homeassistant.components.assist_satellite.websocket_api import ( + CONNECTION_TEST_TIMEOUT, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -13,7 +18,7 @@ from . import ENTITY_ID from .conftest import MockAssistSatellite from tests.common import MockUser -from tests.typing import WebSocketGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator async def test_intercept_wake_word( @@ -385,3 +390,129 @@ async def test_set_wake_words_bad_id( "code": "not_supported", "message": "Wake word id is not supported: abcd", } + + +async def test_connection_test( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, + hass_client: ClientSessionGenerator, +) -> None: + """Test connection test.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/test_connection", + "entity_id": ENTITY_ID, + } + ) + + for _ in range(3): + await asyncio.sleep(0) + + assert len(entity.announcements) == 1 + assert entity.announcements[0].message == "" + announcement_media_id = entity.announcements[0].media_id + hass_url = "http://10.10.10.10:8123" + assert announcement_media_id.startswith( + f"{hass_url}/api/assist_satellite/connection_test/" + ) + + # Fake satellite fetches the URL + client = await hass_client() + resp = await client.get(announcement_media_id[len(hass_url) :]) + assert resp.status == HTTPStatus.OK + + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"status": "success"} + + +async def test_connection_test_timeout( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection test timeout.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/test_connection", + "entity_id": ENTITY_ID, + } + ) + + for _ in range(3): + await asyncio.sleep(0) + + assert len(entity.announcements) == 1 + assert entity.announcements[0].message == "" + announcement_media_id = entity.announcements[0].media_id + hass_url = "http://10.10.10.10:8123" + assert announcement_media_id.startswith( + f"{hass_url}/api/assist_satellite/connection_test/" + ) + + freezer.tick(CONNECTION_TEST_TIMEOUT + 1) + + # Timeout + response = await ws_client.receive_json() + assert response["success"] + assert response["result"] == {"status": "timeout"} + + +async def test_connection_test_invalid_satellite( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test connection test with unknown entity id.""" + ws_client = await hass_ws_client(hass) + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/test_connection", + "entity_id": "assist_satellite.invalid", + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Entity not found", + } + + +async def test_connection_test_timeout_announcement_unsupported( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test connection test entity which does not support announce.""" + ws_client = await hass_ws_client(hass) + + # Disable announce support + entity.supported_features = 0 + + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/test_connection", + "entity_id": ENTITY_ID, + } + ) + response = await ws_client.receive_json() + + assert not response["success"] + assert response["error"] == { + "code": "not_supported", + "message": "Entity does not support announce", + } From 2a36ec3e21a34b63d8b2e0b66a78ce5e13bf41fc Mon Sep 17 00:00:00 2001 From: MarkGodwin <10632972+MarkGodwin@users.noreply.github.com> Date: Sun, 22 Sep 2024 16:05:29 +0100 Subject: [PATCH 0937/1309] Automatically remove unregistered TP-Link Omada devices at start up (#124153) * Adding coordinator for omada device list * Remove dead omada devices at startup * Tidy up tests * Address PR feedback * Returned to use of read-only properties for coordinators. Tidied up parameters some more * Update homeassistant/components/tplink_omada/controller.py * Update homeassistant/components/tplink_omada/controller.py * Update homeassistant/components/tplink_omada/controller.py --------- Co-authored-by: Joost Lekkerkerker --- .../components/tplink_omada/__init__.py | 26 +++++++-- .../components/tplink_omada/binary_sensor.py | 2 +- .../components/tplink_omada/controller.py | 54 ++++++++++-------- .../components/tplink_omada/coordinator.py | 23 +++++++- .../components/tplink_omada/device_tracker.py | 3 +- .../components/tplink_omada/entity.py | 3 +- .../components/tplink_omada/switch.py | 2 +- .../components/tplink_omada/update.py | 55 +++++++++++++------ tests/components/tplink_omada/test_init.py | 47 ++++++++++++++++ 9 files changed, 164 insertions(+), 51 deletions(-) create mode 100644 tests/components/tplink_omada/test_init.py diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 19b3d58dbd4..9945df2bbae 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from tplink_omada_client import OmadaSite +from tplink_omada_client.devices import OmadaListDevice from tplink_omada_client.exceptions import ( ConnectionFailed, LoginFailed, @@ -14,6 +15,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .config_flow import CONF_SITE, create_omada_client from .const import DOMAIN @@ -52,13 +54,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: site_client = await client.get_site_client(OmadaSite("", entry.data[CONF_SITE])) controller = OmadaSiteController(hass, site_client) - gateway_coordinator = await controller.get_gateway_coordinator() - if gateway_coordinator: - await gateway_coordinator.async_config_entry_first_refresh() - await controller.get_clients_coordinator().async_config_entry_first_refresh() + await controller.initialize_first_refresh() hass.data[DOMAIN][entry.entry_id] = controller + _remove_old_devices(hass, entry, controller.devices_coordinator.data) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -70,3 +71,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +def _remove_old_devices( + hass: HomeAssistant, entry: ConfigEntry, omada_devices: dict[str, OmadaListDevice] +) -> None: + device_registry = dr.async_get(hass) + + for registered_device in device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id + ): + mac = next( + (i[1] for i in registered_device.identifiers if i[0] == DOMAIN), None + ) + if mac and mac not in omada_devices: + device_registry.async_update_device( + registered_device.id, remove_config_entry_id=entry.entry_id + ) diff --git a/homeassistant/components/tplink_omada/binary_sensor.py b/homeassistant/components/tplink_omada/binary_sensor.py index c0304c4d1b2..c3941ff7595 100644 --- a/homeassistant/components/tplink_omada/binary_sensor.py +++ b/homeassistant/components/tplink_omada/binary_sensor.py @@ -34,7 +34,7 @@ async def async_setup_entry( """Set up binary sensors.""" controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] - gateway_coordinator = await controller.get_gateway_coordinator() + gateway_coordinator = controller.gateway_coordinator if not gateway_coordinator: return diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index d92a6f37e24..658286981f9 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -7,6 +7,7 @@ from homeassistant.core import HomeAssistant from .coordinator import ( OmadaClientsCoordinator, + OmadaDevicesCoordinator, OmadaGatewayCoordinator, OmadaSwitchPortCoordinator, ) @@ -16,15 +17,33 @@ class OmadaSiteController: """Controller for the Omada SDN site.""" _gateway_coordinator: OmadaGatewayCoordinator | None = None - _initialized_gateway_coordinator = False - _clients_coordinator: OmadaClientsCoordinator | None = None - def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + def __init__( + self, + hass: HomeAssistant, + omada_client: OmadaSiteClient, + ) -> None: """Create the controller.""" self._hass = hass self._omada_client = omada_client self._switch_port_coordinators: dict[str, OmadaSwitchPortCoordinator] = {} + self._devices_coordinator = OmadaDevicesCoordinator(hass, omada_client) + self._clients_coordinator = OmadaClientsCoordinator(hass, omada_client) + + async def initialize_first_refresh(self) -> None: + """Initialize the all coordinators, and perform first refresh.""" + await self._devices_coordinator.async_config_entry_first_refresh() + + devices = self._devices_coordinator.data.values() + gateway = next((d for d in devices if d.type == "gateway"), None) + if gateway: + self._gateway_coordinator = OmadaGatewayCoordinator( + self._hass, self._omada_client, gateway.mac + ) + await self._gateway_coordinator.async_config_entry_first_refresh() + + await self.clients_coordinator.async_config_entry_first_refresh() @property def omada_client(self) -> OmadaSiteClient: @@ -42,26 +61,17 @@ class OmadaSiteController: return self._switch_port_coordinators[switch.mac] - async def get_gateway_coordinator(self) -> OmadaGatewayCoordinator | None: - """Get coordinator for site's gateway, or None if there is no gateway.""" - if not self._initialized_gateway_coordinator: - self._initialized_gateway_coordinator = True - devices = await self._omada_client.get_devices() - gateway = next((d for d in devices if d.type == "gateway"), None) - if not gateway: - return None - - self._gateway_coordinator = OmadaGatewayCoordinator( - self._hass, self._omada_client, gateway.mac - ) - + @property + def gateway_coordinator(self) -> OmadaGatewayCoordinator | None: + """Gets the coordinator for site's gateway, or None if there is no gateway.""" return self._gateway_coordinator - def get_clients_coordinator(self) -> OmadaClientsCoordinator: - """Get coordinator for site's clients.""" - if not self._clients_coordinator: - self._clients_coordinator = OmadaClientsCoordinator( - self._hass, self._omada_client - ) + @property + def devices_coordinator(self) -> OmadaDevicesCoordinator: + """Gets the coordinator for site's devices.""" + return self._devices_coordinator + @property + def clients_coordinator(self) -> OmadaClientsCoordinator: + """Gets the coordinator for site's clients.""" return self._clients_coordinator diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index da0a79ef991..e4f15e6567c 100644 --- a/homeassistant/components/tplink_omada/coordinator.py +++ b/homeassistant/components/tplink_omada/coordinator.py @@ -6,7 +6,7 @@ import logging from tplink_omada_client import OmadaSiteClient, OmadaSwitchPortDetails from tplink_omada_client.clients import OmadaWirelessClient -from tplink_omada_client.devices import OmadaGateway, OmadaSwitch +from tplink_omada_client.devices import OmadaGateway, OmadaListDevice, OmadaSwitch from tplink_omada_client.exceptions import OmadaClientException from homeassistant.core import HomeAssistant @@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__) POLL_SWITCH_PORT = 300 POLL_GATEWAY = 300 POLL_CLIENTS = 300 +POLL_DEVICES = 900 class OmadaCoordinator[_T](DataUpdateCoordinator[dict[str, _T]]): @@ -27,14 +28,14 @@ class OmadaCoordinator[_T](DataUpdateCoordinator[dict[str, _T]]): hass: HomeAssistant, omada_client: OmadaSiteClient, name: str, - poll_delay: int = 300, + poll_delay: int | None = 300, ) -> None: """Initialize my coordinator.""" super().__init__( hass, _LOGGER, name=f"Omada API Data - {name}", - update_interval=timedelta(seconds=poll_delay), + update_interval=timedelta(seconds=poll_delay) if poll_delay else None, ) self.omada_client = omada_client @@ -91,6 +92,22 @@ class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): return {self.mac: gateway} +class OmadaDevicesCoordinator(OmadaCoordinator[OmadaListDevice]): + """Coordinator for generic device lists from the controller.""" + + def __init__( + self, + hass: HomeAssistant, + omada_client: OmadaSiteClient, + ) -> None: + """Initialize my coordinator.""" + super().__init__(hass, omada_client, "DeviceList", POLL_CLIENTS) + + async def poll_update(self) -> dict[str, OmadaListDevice]: + """Poll the site's current registered Omada devices.""" + return {d.mac: d for d in await self.omada_client.get_devices()} + + class OmadaClientsCoordinator(OmadaCoordinator[OmadaWirelessClient]): """Coordinator for getting details about the site's connected clients.""" diff --git a/homeassistant/components/tplink_omada/device_tracker.py b/homeassistant/components/tplink_omada/device_tracker.py index be734592d11..12c519b883f 100644 --- a/homeassistant/components/tplink_omada/device_tracker.py +++ b/homeassistant/components/tplink_omada/device_tracker.py @@ -26,7 +26,6 @@ async def async_setup_entry( controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] - clients_coordinator = controller.get_clients_coordinator() site_id = config_entry.data[CONF_SITE] # Add all known WiFi devices as potentially tracked devices. They will only be @@ -34,7 +33,7 @@ async def async_setup_entry( async_add_entities( [ OmadaClientScannerEntity( - site_id, client.mac, client.name, clients_coordinator + site_id, client.mac, client.name, controller.clients_coordinator ) async for client in controller.omada_client.get_known_clients() if isinstance(client, OmadaWirelessClient) diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index 13ec7b3c6cb..213764aaa12 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -5,7 +5,6 @@ from typing import Any from tplink_omada_client.devices import OmadaDevice from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -19,7 +18,7 @@ class OmadaDeviceEntity[_T: OmadaCoordinator[Any]](CoordinatorEntity[_T]): """Initialize the device.""" super().__init__(coordinator) self.device = device - self._attr_device_info = DeviceInfo( + self._attr_device_info = dr.DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, identifiers={(DOMAIN, device.mac)}, manufacturer="TP-Link", diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index 9f9eeceb866..12d4d4039ee 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -74,7 +74,7 @@ async def async_setup_entry( if desc.exists_func(switch, port) ) - gateway_coordinator = await controller.get_gateway_coordinator() + gateway_coordinator = controller.gateway_coordinator if gateway_coordinator: for gateway in gateway_coordinator.data.values(): entities.extend( diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index a7552263ff1..82c694a5ae4 100644 --- a/homeassistant/components/tplink_omada/update.py +++ b/homeassistant/components/tplink_omada/update.py @@ -21,10 +21,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .controller import OmadaSiteController -from .coordinator import OmadaCoordinator +from .coordinator import POLL_DEVICES, OmadaCoordinator, OmadaDevicesCoordinator from .entity import OmadaDeviceEntity -POLL_DELAY_IDLE = 6 * 60 * 60 POLL_DELAY_UPGRADE = 60 @@ -35,15 +34,28 @@ class FirmwareUpdateStatus(NamedTuple): firmware: OmadaFirmwareUpdate | None -class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): # pylint: disable=hass-enforce-class-module - """Coordinator for getting details about ports on a switch.""" +class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): # pylint: disable=hass-enforce-class-module + """Coordinator for getting details about available firmware updates for Omada devices.""" - def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + omada_client: OmadaSiteClient, + devices_coordinator: OmadaDevicesCoordinator, + ) -> None: """Initialize my coordinator.""" - super().__init__(hass, omada_client, "Firmware Updates", POLL_DELAY_IDLE) + super().__init__(hass, omada_client, "Firmware Updates", poll_delay=None) + + self._devices_coordinator = devices_coordinator + self._config_entry = config_entry + + config_entry.async_on_unload( + devices_coordinator.async_add_listener(self._handle_devices_update) + ) async def _get_firmware_updates(self) -> list[FirmwareUpdateStatus]: - devices = await self.omada_client.get_devices() + devices = self._devices_coordinator.data.values() updates = [ FirmwareUpdateStatus( @@ -55,12 +67,12 @@ class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): # for d in devices ] - # During a firmware upgrade, poll more frequently - self.update_interval = timedelta( + # During a firmware upgrade, poll device list more frequently + self._devices_coordinator.update_interval = timedelta( seconds=( POLL_DELAY_UPGRADE if any(u.device.fw_download for u in updates) - else POLL_DELAY_IDLE + else POLL_DEVICES ) ) return updates @@ -69,6 +81,14 @@ class OmadaFirmwareUpdateCoodinator(OmadaCoordinator[FirmwareUpdateStatus]): # """Poll the state of Omada Devices firmware update availability.""" return {d.device.mac: d for d in await self._get_firmware_updates()} + @callback + def _handle_devices_update(self) -> None: + """Handle updated data from the devices coordinator.""" + # Trigger a refresh of our data, based on the updated device list + self._config_entry.async_create_background_task( + self.hass, self.async_request_refresh(), "Omada Firmware Update Refresh" + ) + async def async_setup_entry( hass: HomeAssistant, @@ -77,18 +97,21 @@ async def async_setup_entry( ) -> None: """Set up switches.""" controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] - omada_client = controller.omada_client - devices = await omada_client.get_devices() + devices = controller.devices_coordinator.data - coordinator = OmadaFirmwareUpdateCoodinator(hass, omada_client) + coordinator = OmadaFirmwareUpdateCoordinator( + hass, config_entry, controller.omada_client, controller.devices_coordinator + ) - async_add_entities(OmadaDeviceUpdate(coordinator, device) for device in devices) + async_add_entities( + OmadaDeviceUpdate(coordinator, device) for device in devices.values() + ) await coordinator.async_request_refresh() class OmadaDeviceUpdate( - OmadaDeviceEntity[OmadaFirmwareUpdateCoodinator], + OmadaDeviceEntity[OmadaFirmwareUpdateCoordinator], UpdateEntity, ): """Firmware update status for Omada SDN devices.""" @@ -103,7 +126,7 @@ class OmadaDeviceUpdate( def __init__( self, - coordinator: OmadaFirmwareUpdateCoodinator, + coordinator: OmadaFirmwareUpdateCoordinator, device: OmadaListDevice, ) -> None: """Initialize the update entity.""" diff --git a/tests/components/tplink_omada/test_init.py b/tests/components/tplink_omada/test_init.py new file mode 100644 index 00000000000..762168df9d6 --- /dev/null +++ b/tests/components/tplink_omada/test_init.py @@ -0,0 +1,47 @@ +"""Tests for TP-Link Omada integration init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.tplink_omada.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + +MOCK_ENTRY_DATA = { + "host": "https://fake.omada.host", + "verify_ssl": True, + "site": "SiteId", + "username": "test-username", + "password": "test-password", +} + + +async def test_missing_devices_removed_at_startup( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_omada_client: MagicMock, +) -> None: + """Test missing devices are removed at startup.""" + mock_config_entry = MockConfigEntry( + title="Test Omada Controller", + domain=DOMAIN, + data=dict(MOCK_ENTRY_DATA), + unique_id="12345", + ) + mock_config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "AA:BB:CC:DD:EE:FF")}, + manufacturer="TPLink", + name="Old Device", + model="Some old model", + ) + + assert device_registry.async_get(device_entry.id) == device_entry + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert device_registry.async_get(device_entry.id) is None From f9e7721653bbd1c8977c6ac9f6b9c344c14abf77 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sun, 22 Sep 2024 11:30:59 -0400 Subject: [PATCH 0938/1309] Fix error if light status is missing in Nice G.O. (#126432) --- .../components/nice_go/coordinator.py | 8 +++-- homeassistant/components/nice_go/light.py | 5 +++- .../nice_go/fixtures/get_all_barriers.json | 30 +++++++++++++++++++ .../nice_go/snapshots/test_cover.ambr | 6 ++-- .../nice_go/snapshots/test_diagnostics.ambr | 9 ++++++ tests/components/nice_go/test_light.py | 2 ++ 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index d6693db2d8a..dd2d7ccb45e 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -47,7 +47,7 @@ class NiceGODevice: id: str name: str barrier_status: str - light_status: bool + light_status: bool | None fw_version: str connected: bool vacation_mode: bool @@ -113,7 +113,11 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): else: barrier_status = BARRIER_STATUS[int(barrier_status_raw[2])].lower() - light_status = barrier_state.reported["lightStatus"].split(",")[0] == "1" + light_status = ( + barrier_state.reported["lightStatus"].split(",")[0] == "1" + if barrier_state.reported.get("lightStatus") + else None + ) fw_version = barrier_state.reported["deviceFwVersion"] if barrier_state.connectionState: connected = barrier_state.connectionState.connected diff --git a/homeassistant/components/nice_go/light.py b/homeassistant/components/nice_go/light.py index 4a08364688e..aa606dbcb8f 100644 --- a/homeassistant/components/nice_go/light.py +++ b/homeassistant/components/nice_go/light.py @@ -1,6 +1,6 @@ """Nice G.O. light.""" -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.light import ColorMode, LightEntity from homeassistant.core import HomeAssistant @@ -22,6 +22,7 @@ async def async_setup_entry( async_add_entities( NiceGOLightEntity(coordinator, device_id, device_data.name) for device_id, device_data in coordinator.data.items() + if device_data.light_status is not None ) @@ -35,6 +36,8 @@ class NiceGOLightEntity(NiceGOEntity, LightEntity): @property def is_on(self) -> bool: """Return if the light is on or not.""" + if TYPE_CHECKING: + assert self.data.light_status is not None return self.data.light_status async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/tests/components/nice_go/fixtures/get_all_barriers.json b/tests/components/nice_go/fixtures/get_all_barriers.json index adb0fb4bacd..0597f0038dc 100644 --- a/tests/components/nice_go/fixtures/get_all_barriers.json +++ b/tests/components/nice_go/fixtures/get_all_barriers.json @@ -60,5 +60,35 @@ "connected": true, "updatedTimestamp": "123" } + }, + { + "id": "3", + "type": "WallStation", + "controlLevel": "Owner", + "attr": [ + { + "key": "organization", + "value": "test_organization" + } + ], + "state": { + "deviceId": "3", + "desired": { "key": "value" }, + "reported": { + "displayName": "Test Garage 3", + "autoDisabled": false, + "migrationStatus": "DONE", + "deviceId": "3", + "vcnMode": false, + "deviceFwVersion": "1.2.3.4.5.6", + "barrierStatus": "2,100,0,0,-1,0,3,0" + }, + "timestamp": null, + "version": null + }, + "connectionState": { + "connected": true, + "updatedTimestamp": "123" + } } ] diff --git a/tests/components/nice_go/snapshots/test_cover.ambr b/tests/components/nice_go/snapshots/test_cover.ambr index 391d91584bf..fa65b3b9b4c 100644 --- a/tests/components/nice_go/snapshots/test_cover.ambr +++ b/tests/components/nice_go/snapshots/test_cover.ambr @@ -120,11 +120,11 @@ 'original_device_class': , 'original_icon': None, 'original_name': None, - 'platform': 'linear_garage_door', + 'platform': 'nice_go', 'previous_unique_id': None, 'supported_features': , 'translation_key': None, - 'unique_id': 'test3-GDO', + 'unique_id': '3', 'unit_of_measurement': None, }) # --- @@ -140,7 +140,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'opening', + 'state': 'closed', }) # --- # name: test_covers[cover.test_garage_4-entry] diff --git a/tests/components/nice_go/snapshots/test_diagnostics.ambr b/tests/components/nice_go/snapshots/test_diagnostics.ambr index 6f9428ed246..380a867ac60 100644 --- a/tests/components/nice_go/snapshots/test_diagnostics.ambr +++ b/tests/components/nice_go/snapshots/test_diagnostics.ambr @@ -20,6 +20,15 @@ 'name': 'Test Garage 2', 'vacation_mode': True, }), + '3': dict({ + 'barrier_status': 'closed', + 'connected': True, + 'fw_version': '1.2.3.4.5.6', + 'id': '3', + 'light_status': None, + 'name': 'Test Garage 3', + 'vacation_mode': False, + }), }), 'entry': dict({ 'data': dict({ diff --git a/tests/components/nice_go/test_light.py b/tests/components/nice_go/test_light.py index e1852581fe6..9c860c0225f 100644 --- a/tests/components/nice_go/test_light.py +++ b/tests/components/nice_go/test_light.py @@ -78,6 +78,7 @@ async def test_update_light_state( assert hass.states.get("light.test_garage_1_light").state == STATE_ON assert hass.states.get("light.test_garage_2_light").state == STATE_OFF + assert hass.states.get("light.test_garage_3_light") is None device_update = load_json_object_fixture("device_state_update.json", DOMAIN) await mock_config_entry.runtime_data.on_data(device_update) @@ -86,3 +87,4 @@ async def test_update_light_state( assert hass.states.get("light.test_garage_1_light").state == STATE_OFF assert hass.states.get("light.test_garage_2_light").state == STATE_ON + assert hass.states.get("light.test_garage_3_light") is None From f8a53aea09f0c97c6040bb439176e06b3ef0f473 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 22 Sep 2024 17:54:14 +0200 Subject: [PATCH 0939/1309] Use HassKey in conversation (#126332) * Use HassKey in conversation * Adjust tests --- homeassistant/components/conversation/__init__.py | 8 +++++--- .../components/conversation/agent_manager.py | 10 +++++++--- homeassistant/components/conversation/const.py | 2 ++ .../components/conversation/default_agent.py | 14 ++++++-------- homeassistant/components/conversation/http.py | 8 ++------ homeassistant/components/conversation/trigger.py | 8 ++------ .../components/conversation/test_default_agent.py | 7 ++++--- tests/components/conversation/test_http.py | 3 ++- tests/components/conversation/test_init.py | 5 +++-- tests/components/conversation/test_trigger.py | 3 ++- 10 files changed, 35 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 983d2074ab5..a1325171af2 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -35,6 +35,7 @@ from .const import ( ATTR_CONVERSATION_ID, ATTR_LANGUAGE, ATTR_TEXT, + DATA_DEFAULT_ENTITY, DOMAIN, DOMAIN_DATA, HOME_ASSISTANT_AGENT, @@ -43,7 +44,7 @@ from .const import ( SERVICE_RELOAD, ConversationEntityFeature, ) -from .default_agent import async_get_default_agent, async_setup_default_agent +from .default_agent import async_setup_default_agent from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult @@ -247,8 +248,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def handle_reload(service: ServiceCall) -> None: """Reload intents.""" - agent = async_get_default_agent(hass) - await agent.async_reload(language=service.data.get(ATTR_LANGUAGE)) + await hass.data[DATA_DEFAULT_ENTITY].async_reload( + language=service.data.get(ATTR_LANGUAGE) + ) hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index ae7d9551140..25b2a5a4220 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -11,8 +11,12 @@ import voluptuous as vol from homeassistant.core import Context, HomeAssistant, async_get_hass, callback from homeassistant.helpers import config_validation as cv, singleton -from .const import DOMAIN_DATA, HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT -from .default_agent import async_get_default_agent +from .const import ( + DATA_DEFAULT_ENTITY, + DOMAIN_DATA, + HOME_ASSISTANT_AGENT, + OLD_HOME_ASSISTANT_AGENT, +) from .entity import ConversationEntity from .models import ( AbstractConversationAgent, @@ -50,7 +54,7 @@ def async_get_agent( ) -> AbstractConversationAgent | ConversationEntity | None: """Get specified agent.""" if agent_id is None or agent_id in (HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT): - return async_get_default_agent(hass) + return hass.data[DATA_DEFAULT_ENTITY] if "." in agent_id: return hass.data[DOMAIN_DATA].get_entity(agent_id) diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index b7e45142f8f..f4599ef8991 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -10,6 +10,7 @@ from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: from homeassistant.helpers.entity_component import EntityComponent + from .default_agent import DefaultAgent from .entity import ConversationEntity DOMAIN = "conversation" @@ -26,6 +27,7 @@ SERVICE_PROCESS = "process" SERVICE_RELOAD = "reload" DOMAIN_DATA: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN) +DATA_DEFAULT_ENTITY: HassKey[DefaultAgent] = HassKey(f"{DOMAIN}_default_entity") class ConversationEntityFeature(IntFlag): diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 05b4d194d33..155909d5fe3 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -44,7 +44,12 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_added_domain from homeassistant.util.json import JsonObjectType, json_loads_object -from .const import DEFAULT_EXPOSED_ATTRIBUTES, DOMAIN, ConversationEntityFeature +from .const import ( + DATA_DEFAULT_ENTITY, + DEFAULT_EXPOSED_ATTRIBUTES, + DOMAIN, + ConversationEntityFeature, +) from .entity import ConversationEntity from .models import ConversationInput, ConversationResult from .trace import ConversationTraceEventType, async_conversation_trace_append @@ -60,16 +65,9 @@ TRIGGER_CALLBACK_TYPE = Callable[ METADATA_CUSTOM_SENTENCE = "hass_custom_sentence" METADATA_CUSTOM_FILE = "hass_custom_file" -DATA_DEFAULT_ENTITY = "conversation_default_entity" ERROR_SENTINEL = object() -@core.callback -def async_get_default_agent(hass: core.HomeAssistant) -> DefaultAgent: - """Get the default agent.""" - return hass.data[DATA_DEFAULT_ENTITY] - - def json_load(fp: IO[str]) -> JsonObjectType: """Wrap json_loads for get_intents.""" return json_loads_object(fp.read()) diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 982575b9957..181afeb8525 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -27,13 +27,11 @@ from .agent_manager import ( async_get_agent, get_agent_manager, ) -from .const import DOMAIN_DATA +from .const import DATA_DEFAULT_ENTITY, DOMAIN_DATA from .default_agent import ( METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE, - DefaultAgent, SentenceTriggerResult, - async_get_default_agent, ) from .entity import ConversationEntity from .models import ConversationInput @@ -173,10 +171,8 @@ async def websocket_hass_agent_debug( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Return intents that would be matched by the default agent for a list of sentences.""" - agent = async_get_default_agent(hass) - assert isinstance(agent, DefaultAgent) results = [ - await agent.async_recognize( + await hass.data[DATA_DEFAULT_ENTITY].async_recognize( ConversationInput( text=sentence, context=connection.context(msg), diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index 0a4cbfcb7e5..ec7ecc76da0 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -14,8 +14,7 @@ from homeassistant.helpers.script import ScriptRunResult from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import UNDEFINED, ConfigType -from .const import DOMAIN -from .default_agent import DefaultAgent, async_get_default_agent +from .const import DATA_DEFAULT_ENTITY, DOMAIN def has_no_punctuation(value: list[str]) -> list[str]: @@ -110,7 +109,4 @@ async def async_attach_trigger( # two trigger copies for who will provide a response. return None - default_agent = async_get_default_agent(hass) - assert isinstance(default_agent, DefaultAgent) - - return default_agent.register_trigger(sentences, call_action) + return hass.data[DATA_DEFAULT_ENTITY].register_trigger(sentences, call_action) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 935ef205d4f..cf9d575ebe0 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -13,6 +13,7 @@ import yaml from homeassistant.components import conversation, cover, media_player from homeassistant.components.conversation import default_agent +from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.conversation.models import ConversationInput from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.components.homeassistant.exposed_entities import ( @@ -203,7 +204,7 @@ async def test_exposed_areas( @pytest.mark.usefixtures("init_components") async def test_conversation_agent(hass: HomeAssistant) -> None: """Test DefaultAgent.""" - agent = default_agent.async_get_default_agent(hass) + agent = hass.data[DATA_DEFAULT_ENTITY] with patch( "homeassistant.components.conversation.default_agent.get_languages", return_value=["dwarvish", "elvish", "entish"], @@ -380,7 +381,7 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None: trigger_sentences = ["It's party time", "It is time to party"] trigger_response = "Cowabunga!" - agent = default_agent.async_get_default_agent(hass) + agent = hass.data[DATA_DEFAULT_ENTITY] assert isinstance(agent, default_agent.DefaultAgent) callback = AsyncMock(return_value=trigger_response) @@ -1905,7 +1906,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non hass.states.async_set("cover.front_door", "closed") calls = async_mock_service(hass, "cover", SERVICE_OPEN_COVER) - agent = default_agent.async_get_default_agent(hass) + agent = hass.data[DATA_DEFAULT_ENTITY] assert isinstance(agent, default_agent.DefaultAgent) result = await agent.async_process( diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 1431fd6c17b..5b6f7072a2d 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -8,6 +8,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.conversation import default_agent +from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant @@ -214,7 +215,7 @@ async def test_ws_prepare( hass: HomeAssistant, init_components, hass_ws_client: WebSocketGenerator, agent_id ) -> None: """Test the Websocket prepare conversation API.""" - agent = default_agent.async_get_default_agent(hass) + agent = hass.data[DATA_DEFAULT_ENTITY] assert isinstance(agent, default_agent.DefaultAgent) # No intents should be loaded yet diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 34a8fce636d..e92b1ab538f 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components import conversation from homeassistant.components.conversation import default_agent +from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -143,7 +144,7 @@ async def test_prepare_reload(hass: HomeAssistant, init_components) -> None: language = hass.config.language # Load intents - agent = default_agent.async_get_default_agent(hass) + agent = hass.data[DATA_DEFAULT_ENTITY] assert isinstance(agent, default_agent.DefaultAgent) await agent.async_prepare(language) @@ -171,7 +172,7 @@ async def test_prepare_fail(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "conversation", {}) # Load intents - agent = default_agent.async_get_default_agent(hass) + agent = hass.data[DATA_DEFAULT_ENTITY] assert isinstance(agent, default_agent.DefaultAgent) await agent.async_prepare("not-a-language") diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 3c3e58e7136..903bc405cf0 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -6,6 +6,7 @@ import pytest import voluptuous as vol from homeassistant.components.conversation import default_agent +from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY from homeassistant.components.conversation.models import ConversationInput from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import trigger @@ -550,7 +551,7 @@ async def test_trigger_with_device_id(hass: HomeAssistant) -> None: }, ) - agent = default_agent.async_get_default_agent(hass) + agent = hass.data[DATA_DEFAULT_ENTITY] assert isinstance(agent, default_agent.DefaultAgent) result = await agent.async_process( From 5f74dbcfc26edbe4d1f8ffc775e0d53973720e72 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 22 Sep 2024 09:03:21 -0700 Subject: [PATCH 0940/1309] Bump google-photos-library-api to 0.12.0 (#126433) Bump google-photos-library-api==0.12.0 --- homeassistant/components/google_photos/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_photos/manifest.json b/homeassistant/components/google_photos/manifest.json index b71eec4bdd9..28cd2512432 100644 --- a/homeassistant/components/google_photos/manifest.json +++ b/homeassistant/components/google_photos/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_photos", "iot_class": "cloud_polling", "loggers": ["google_photos_library_api"], - "requirements": ["google-photos-library-api==0.11.1"] + "requirements": ["google-photos-library-api==0.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9b79a4d2b4a..2221bef4133 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ google-generativeai==0.7.2 google-nest-sdm==5.0.1 # homeassistant.components.google_photos -google-photos-library-api==0.11.1 +google-photos-library-api==0.12.0 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c556c5e1ea0..64a82994038 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -857,7 +857,7 @@ google-generativeai==0.7.2 google-nest-sdm==5.0.1 # homeassistant.components.google_photos -google-photos-library-api==0.11.1 +google-photos-library-api==0.12.0 # homeassistant.components.google_travel_time googlemaps==2.5.1 From 9e37c14179f70a20401d26db080c96e9be222f51 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sun, 22 Sep 2024 12:04:19 -0400 Subject: [PATCH 0941/1309] Bump pydrawise to 2024.9.0 (#126431) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 9b733cb73d0..9678dc83e5f 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2024.8.0"] + "requirements": ["pydrawise==2024.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2221bef4133..824b6f10367 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1840,7 +1840,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2024.8.0 +pydrawise==2024.9.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64a82994038..7aa9093bf2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1481,7 +1481,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2024.8.0 +pydrawise==2024.9.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From b107b2c7bf6dd255dec4e20e054ecaa659b5a544 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 22 Sep 2024 09:30:37 -0700 Subject: [PATCH 0942/1309] Enforce a Google Photos upload action file size limit (#126437) * Set a Google Photos upload file size limit * Update homeassistant/components/google_photos/services.py Co-authored-by: Joost Lekkerkerker * Replace strings with constants --------- Co-authored-by: Joost Lekkerkerker --- .../components/google_photos/services.py | 11 ++ .../components/google_photos/strings.json | 3 + .../components/google_photos/test_services.py | 185 +++++++++++------- 3 files changed, 126 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index 66aa61e23a4..1687e812b1d 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -32,6 +32,7 @@ UPLOAD_SERVICE_SCHEMA = vol.Schema( vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]), } ) +CONTENT_SIZE_LIMIT = 20 * 1024 * 1024 def _read_file_contents( @@ -53,6 +54,16 @@ def _read_file_contents( translation_key="filename_does_not_exist", translation_placeholders={"filename": filename}, ) + if filename_path.stat().st_size > CONTENT_SIZE_LIMIT: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_too_large", + translation_placeholders={ + "filename": filename, + "size": str(filename_path.stat().st_size), + "limit": str(CONTENT_SIZE_LIMIT), + }, + ) mime_type, _ = mimetypes.guess_type(filename) if mime_type is None or not (mime_type.startswith(("image", "video"))): raise HomeAssistantError( diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index bf2809f896f..faf91f71979 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -40,6 +40,9 @@ "filename_does_not_exist": { "message": "`{filename}` does not exist" }, + "file_too_large": { + "message": "`{filename}` is too large ({size} > {limit})" + }, "filename_is_not_image": { "message": "`{filename}` is not an image" }, diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py index eaf7163f62b..10f4543bcc2 100644 --- a/tests/components/google_photos/test_services.py +++ b/tests/components/google_photos/test_services.py @@ -1,5 +1,8 @@ """Tests for Google Photos.""" +from collections.abc import Generator +from dataclasses import dataclass +import re from unittest.mock import Mock, patch from google_photos_library_api.exceptions import GooglePhotosApiError @@ -12,12 +15,61 @@ from google_photos_library_api.model import ( import pytest from homeassistant.components.google_photos.const import DOMAIN, READ_SCOPE +from homeassistant.components.google_photos.services import ( + CONF_CONFIG_ENTRY_ID, + UPLOAD_SERVICE, +) from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_FILENAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry +TEST_FILENAME = "doorbell_snapshot.jpg" + + +@dataclass +class MockUploadFile: + """Dataclass used to configure the test with a fake file behavior.""" + + content: bytes = b"image bytes" + exists: bool = True + is_allowed_path: bool = True + size: int | None = None + + +@pytest.fixture(name="upload_file") +def upload_file_fixture() -> None: + """Fixture to set up test configuration with a fake file.""" + return MockUploadFile() + + +@pytest.fixture(autouse=True) +def mock_upload_file( + hass: HomeAssistant, upload_file: MockUploadFile +) -> Generator[None]: + """Fixture that mocks out the file calls using the FakeFile fixture.""" + with ( + patch( + "homeassistant.components.google_photos.services.Path.read_bytes", + return_value=upload_file.content, + ), + patch( + "homeassistant.components.google_photos.services.Path.exists", + return_value=upload_file.exists, + ), + patch.object( + hass.config, "is_allowed_path", return_value=upload_file.is_allowed_path + ), + patch("pathlib.Path.stat") as mock_stat, + ): + mock_stat.return_value = Mock() + mock_stat.return_value.st_size = ( + upload_file.size if upload_file.size else len(upload_file.content) + ) + yield + @pytest.mark.usefixtures("setup_integration") async def test_upload_service( @@ -38,27 +90,16 @@ async def test_upload_service( ] ) - with ( - patch( - "homeassistant.components.google_photos.services.Path.read_bytes", - return_value=b"image bytes", - ), - patch( - "homeassistant.components.google_photos.services.Path.exists", - return_value=True, - ), - patch.object(hass.config, "is_allowed_path", return_value=True), - ): - response = await hass.services.async_call( - DOMAIN, - "upload", - { - "config_entry_id": config_entry.entry_id, - "filename": "doorbell_snapshot.jpg", - }, - blocking=True, - return_response=True, - ) + response = await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + }, + blocking=True, + return_response=True, + ) assert response == {"media_items": [{"media_item_id": "new-media-item-id-1"}]} @@ -72,10 +113,10 @@ async def test_upload_service_config_entry_not_found( with pytest.raises(HomeAssistantError, match="not found in registry"): await hass.services.async_call( DOMAIN, - "upload", + UPLOAD_SERVICE, { - "config_entry_id": "invalid-config-entry-id", - "filename": "doorbell_snapshot.jpg", + CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id", + CONF_FILENAME: TEST_FILENAME, }, blocking=True, return_response=True, @@ -96,10 +137,10 @@ async def test_config_entry_not_loaded( with pytest.raises(HomeAssistantError, match="not found in registry"): await hass.services.async_call( DOMAIN, - "upload", + UPLOAD_SERVICE, { - "config_entry_id": config_entry.unique_id, - "filename": "doorbell_snapshot.jpg", + CONF_CONFIG_ENTRY_ID: config_entry.unique_id, + CONF_FILENAME: TEST_FILENAME, }, blocking=True, return_response=True, @@ -107,21 +148,21 @@ async def test_config_entry_not_loaded( @pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize("upload_file", [MockUploadFile(is_allowed_path=False)]) async def test_path_is_not_allowed( hass: HomeAssistant, config_entry: MockConfigEntry, ) -> None: """Test upload service call with a filename path that is not allowed.""" with ( - patch.object(hass.config, "is_allowed_path", return_value=False), pytest.raises(HomeAssistantError, match="no access to path"), ): await hass.services.async_call( DOMAIN, - "upload", + UPLOAD_SERVICE, { - "config_entry_id": config_entry.entry_id, - "filename": "doorbell_snapshot.jpg", + CONF_CONFIG_ENTRY_ID: config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, }, blocking=True, return_response=True, @@ -129,22 +170,19 @@ async def test_path_is_not_allowed( @pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize("upload_file", [MockUploadFile(exists=False)]) async def test_filename_does_not_exist( hass: HomeAssistant, config_entry: MockConfigEntry, ) -> None: """Test upload service call with a filename path that does not exist.""" - with ( - patch.object(hass.config, "is_allowed_path", return_value=True), - patch("pathlib.Path.exists", return_value=False), - pytest.raises(HomeAssistantError, match="does not exist"), - ): + with pytest.raises(HomeAssistantError, match="does not exist"): await hass.services.async_call( DOMAIN, - "upload", + UPLOAD_SERVICE, { - "config_entry_id": config_entry.entry_id, - "filename": "doorbell_snapshot.jpg", + CONF_CONFIG_ENTRY_ID: config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, }, blocking=True, return_response=True, @@ -161,24 +199,13 @@ async def test_upload_service_upload_content_failure( mock_api.upload_content.side_effect = GooglePhotosApiError() - with ( - patch( - "homeassistant.components.google_photos.services.Path.read_bytes", - return_value=b"image bytes", - ), - patch( - "homeassistant.components.google_photos.services.Path.exists", - return_value=True, - ), - patch.object(hass.config, "is_allowed_path", return_value=True), - pytest.raises(HomeAssistantError, match="Failed to upload content"), - ): + with pytest.raises(HomeAssistantError, match="Failed to upload content"): await hass.services.async_call( DOMAIN, - "upload", + UPLOAD_SERVICE, { - "config_entry_id": config_entry.entry_id, - "filename": "doorbell_snapshot.jpg", + CONF_CONFIG_ENTRY_ID: config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, }, blocking=True, return_response=True, @@ -195,26 +222,15 @@ async def test_upload_service_fails_create( mock_api.create_media_items.side_effect = GooglePhotosApiError() - with ( - patch( - "homeassistant.components.google_photos.services.Path.read_bytes", - return_value=b"image bytes", - ), - patch( - "homeassistant.components.google_photos.services.Path.exists", - return_value=True, - ), - patch.object(hass.config, "is_allowed_path", return_value=True), - pytest.raises( - HomeAssistantError, match="Google Photos API responded with error" - ), + with pytest.raises( + HomeAssistantError, match="Google Photos API responded with error" ): await hass.services.async_call( DOMAIN, - "upload", + UPLOAD_SERVICE, { - "config_entry_id": config_entry.entry_id, - "filename": "doorbell_snapshot.jpg", + CONF_CONFIG_ENTRY_ID: config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, }, blocking=True, return_response=True, @@ -237,10 +253,33 @@ async def test_upload_service_no_scope( with pytest.raises(HomeAssistantError, match="not granted permission"): await hass.services.async_call( DOMAIN, - "upload", + UPLOAD_SERVICE, { - "config_entry_id": config_entry.entry_id, - "filename": "doorbell_snapshot.jpg", + CONF_CONFIG_ENTRY_ID: config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize("upload_file", [MockUploadFile(size=26 * 1024 * 1024)]) +async def test_upload_size_limit( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test upload service call with a filename path that does not exist.""" + with pytest.raises( + HomeAssistantError, + match=re.escape(f"`{TEST_FILENAME}` is too large (27262976 > 20971520)"), + ): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, }, blocking=True, return_response=True, From 113a7927347c9ec5a69b5ca1ce47a1e2a1b30854 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sun, 22 Sep 2024 23:08:27 +0200 Subject: [PATCH 0943/1309] Fix blocking call in Bang & Olufsen API client initialization (#126456) * Update API * Add fix for blocking call to load_default_certs --- homeassistant/components/bang_olufsen/__init__.py | 3 ++- homeassistant/components/bang_olufsen/config_flow.py | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index 07b9d0befe1..e11df6ad5ed 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -17,6 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_MODEL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.device_registry as dr +from homeassistant.util.ssl import get_default_context from .const import DOMAIN from .websocket import BangOlufsenWebsocket @@ -48,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=entry.data[CONF_MODEL], ) - client = MozartClient(host=entry.data[CONF_HOST]) + client = MozartClient(host=entry.data[CONF_HOST], ssl_context=get_default_context()) # Check API and WebSocket connection try: diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py index 85b7a22cd56..e1c1c7ab538 100644 --- a/homeassistant/components/bang_olufsen/config_flow.py +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from homeassistant.util.ssl import get_default_context from .const import ( ATTR_FRIENDLY_NAME, @@ -88,7 +89,9 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN): errors={"base": _exception_map[type(error)]}, ) - self._client = MozartClient(self._host) + self._client = MozartClient( + host=self._host, ssl_context=get_default_context() + ) # Try to get information from Beolink self method. async with self._client: @@ -137,7 +140,7 @@ class BangOlufsenConfigFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="ipv6_address") # Check connection to ensure valid address is received - self._client = MozartClient(self._host) + self._client = MozartClient(self._host, ssl_context=get_default_context()) async with self._client: try: From c759512c70d7b260ba40b92944a82f012c76652b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 02:55:55 +0200 Subject: [PATCH 0944/1309] Prevent callback decorator on coroutine functions (#126429) * Prevent callback decorator on async functions * Adjust * Adjust * Adjust components * Adjust tests * Rename * One more * Adjust * Adjust again * Apply suggestions from code review Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/assist_satellite/websocket_api.py | 2 -- homeassistant/components/cloud/google_config.py | 2 +- homeassistant/components/dsmr/sensor.py | 2 +- homeassistant/components/fritz/switch.py | 3 +-- homeassistant/components/lcn/websocket.py | 3 +-- homeassistant/components/lifx/sensor.py | 1 - homeassistant/components/madvr/__init__.py | 3 +-- homeassistant/components/onkyo/media_player.py | 2 -- homeassistant/components/plaato/sensor.py | 2 +- homeassistant/components/wake_word/__init__.py | 1 - homeassistant/components/zha/helpers.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 15 +++++++++++++-- tests/test_core.py | 4 ++-- 13 files changed, 22 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 741f4364e7f..4c95d9555aa 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -34,7 +34,6 @@ def async_register_websocket_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_test_connection) -@callback @websocket_api.websocket_command( { vol.Required("type"): "assist_satellite/intercept_wake_word", @@ -101,7 +100,6 @@ def websocket_get_configuration( connection.send_result(msg["id"], config_dict) -@callback @websocket_api.websocket_command( { vol.Required("type"): "assist_satellite/set_wake_words", diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 3586823ca11..43dd5279d35 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -478,7 +478,7 @@ class CloudGoogleConfig(AbstractConfig): self.async_schedule_google_sync_all() @callback - async def _handle_device_registry_updated( + def _handle_device_registry_updated( self, event: Event[dr.EventDeviceRegistryUpdatedData] ) -> None: """Handle when device registry updated.""" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index b76736a1101..a069c32be04 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -713,7 +713,7 @@ async def async_setup_entry( task = asyncio.create_task(connect_and_reconnect()) @callback - async def _async_stop(_: Event) -> None: + def _async_stop(_: Event) -> None: if add_entities_handler is not None: add_entities_handler() task.cancel() diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index ce89cfc736d..dfcb1162c3e 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -9,7 +9,7 @@ from homeassistant.components.network import async_get_source_ip from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -242,7 +242,6 @@ async def async_setup_entry( async_add_entities(entities_list) - @callback async def async_update_avm_device() -> None: """Update the values of the AVM device.""" async_add_entities(await _async_profile_entities_list(avm_wrapper, data_fritz)) diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 65896cc78d1..d3268dfbf91 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_NAME, CONF_RESOURCE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv @@ -102,7 +102,6 @@ def get_config_entry( ) -> AsyncWebSocketCommandHandler: """Websocket decorator to ensure the config_entry exists and return it.""" - @callback @wraps(func) async def get_entry( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index 2f54317f9bd..68f354024e4 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -65,7 +65,6 @@ class LIFXRssiSensor(LIFXEntity, SensorEntity): """Handle coordinator updates.""" self._attr_native_value = self.coordinator.rssi - @callback async def async_added_to_hass(self) -> None: """Enable RSSI updates.""" self.async_on_remove(self.coordinator.async_enable_rssi_updates()) diff --git a/homeassistant/components/madvr/__init__.py b/homeassistant/components/madvr/__init__.py index a6ad3b2d1fd..bb42adb21fc 100644 --- a/homeassistant/components/madvr/__init__.py +++ b/homeassistant/components/madvr/__init__.py @@ -8,7 +8,7 @@ from madvr.madvr import Madvr from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant from .coordinator import MadVRCoordinator @@ -47,7 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> boo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - @callback async def handle_unload(event: Event) -> None: """Handle unload.""" await async_handle_unload(coordinator=coordinator) diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 1718ecb36be..af4285e2abd 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -268,7 +268,6 @@ async def async_setup_platform( _LOGGER.debug("Manually creating receiver: %s (%s)", name, host) - @callback async def async_onkyo_interview_callback(conn: pyeiscp.Connection) -> None: """Receiver interviewed, connection not yet active.""" info = ReceiverInfo(conn.host, conn.port, conn.name, conn.identifier) @@ -284,7 +283,6 @@ async def async_setup_platform( else: _LOGGER.debug("Discovering receivers") - @callback async def async_onkyo_discovery_callback(conn: pyeiscp.Connection) -> None: """Receiver discovered, connection not yet active.""" info = ReceiverInfo(conn.host, conn.port, conn.name, conn.identifier) diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index 7aa30dd2fe0..b11bac40144 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -44,7 +44,7 @@ async def async_setup_entry( entry_data = hass.data[DOMAIN][entry.entry_id] @callback - async def _async_update_from_webhook(device_id, sensor_data: PlaatoDevice): + def _async_update_from_webhook(device_id, sensor_data: PlaatoDevice): """Update/Create the sensors.""" entry_data[SENSOR_DATA] = sensor_data diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 84e59ab66d6..00db5a7355b 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -137,7 +137,6 @@ class WakeWordDetectionEntity(RestoreEntity): } ) @websocket_api.async_response -@callback async def websocket_entity_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index dc999f13693..8e22e412e60 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -1107,7 +1107,7 @@ def async_cluster_exists(hass: HomeAssistant, cluster_id, skip_coordinator=True) @callback -async def async_add_entities( +def async_add_entities( _async_add_entities: AddEntitiesCallback, entity_class: type[ZHAEntity], entities: list[EntityData], diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 7f4a7fbd485..f696bc55177 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -3093,6 +3093,11 @@ class HassTypeHintChecker(BaseChecker): "hass-consider-usefixtures-decorator", "Used when an argument type is None and could be a fixture", ), + "W7434": ( + "A coroutine function should not be decorated with @callback", + "hass-async-callback-decorator", + "Used when a coroutine function has an invalid @callback decorator", + ), } options = ( ( @@ -3195,6 +3200,14 @@ class HassTypeHintChecker(BaseChecker): self._check_function(function_node, match, annotations) checked_class_methods.add(function_node.name) + def visit_asyncfunctiondef(self, node: nodes.AsyncFunctionDef) -> None: + """Apply checks on an AsyncFunctionDef node.""" + if ( + decoratornames := node.decoratornames() + ) and "homeassistant.core.callback" in decoratornames: + self.add_message("hass-async-callback-decorator", node=node) + self.visit_functiondef(node) + def visit_functiondef(self, node: nodes.FunctionDef) -> None: """Apply relevant type hint checks on a FunctionDef node.""" annotations = _get_all_annotations(node) @@ -3234,8 +3247,6 @@ class HassTypeHintChecker(BaseChecker): continue self._check_function(node, match, annotations) - visit_asyncfunctiondef = visit_functiondef - def _check_function( self, node: nodes.FunctionDef, diff --git a/tests/test_core.py b/tests/test_core.py index 9ca57d1563f..9f19a372634 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2194,7 +2194,7 @@ async def test_async_functions_with_callback(hass: HomeAssistant) -> None: runs = [] @ha.callback - async def test(): + async def test(): # pylint: disable=hass-async-callback-decorator runs.append(True) await hass.async_add_job(test) @@ -2205,7 +2205,7 @@ async def test_async_functions_with_callback(hass: HomeAssistant) -> None: assert len(runs) == 2 @ha.callback - async def service_handler(call): + async def service_handler(call): # pylint: disable=hass-async-callback-decorator runs.append(True) hass.services.async_register("test_domain", "test_service", service_handler) From ba48a86156c404877c062a098dbd93ecc40344f3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 22 Sep 2024 21:26:33 -0400 Subject: [PATCH 0945/1309] OpenAI to not speak out whole errors (#126409) * OpenAI to not speak out whole errors * Update snapshot --- .../components/openai_conversation/conversation.py | 7 ++++--- .../openai_conversation/snapshots/test_conversation.ambr | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index a7109a6d6ec..9c73766c8d4 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -148,7 +148,7 @@ class OpenAIConversationEntity( LOGGER.error("Error getting LLM API: %s", err) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, - f"Error preparing LLM API: {err}", + "Error preparing LLM API", ) return conversation.ConversationResult( response=intent_response, conversation_id=user_input.conversation_id @@ -208,7 +208,7 @@ class OpenAIConversationEntity( intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem with my template: {err}", + "Sorry, I had a problem with my template", ) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id @@ -248,10 +248,11 @@ class OpenAIConversationEntity( user=conversation_id, ) except openai.OpenAIError as err: + LOGGER.error("Error talking to OpenAI: %s", err) intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem talking to OpenAI: {err}", + "Sorry, I had a problem talking to OpenAI", ) return conversation.ConversationResult( response=intent_response, conversation_id=conversation_id diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index e4dd7cd00bb..eaa3a9de64c 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -20,7 +20,7 @@ speech=dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Error preparing LLM API: API non-existing not found', + 'speech': 'Error preparing LLM API', }), }), speech_slots=dict({ From abceed8112a41a4c2189dbeaab9d669c85495780 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Sep 2024 21:41:10 -0500 Subject: [PATCH 0946/1309] Use identity check for zeroconf enum compare (#126444) --- homeassistant/components/zeroconf/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index bbc89e77a76..bdffdcf63a7 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -408,7 +408,7 @@ class ZeroconfDiscovery: state_change, ) - if state_change == ServiceStateChange.Removed: + if state_change is ServiceStateChange.Removed: self._async_dismiss_discoveries(name) return From 04e232096fb14b5140cd3d22720d0dca57d97c68 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 08:37:35 +0200 Subject: [PATCH 0947/1309] Move atag base entity to separate module (#126475) --- homeassistant/components/atag/__init__.py | 32 +---------------- homeassistant/components/atag/climate.py | 3 +- homeassistant/components/atag/entity.py | 36 +++++++++++++++++++ homeassistant/components/atag/sensor.py | 3 +- homeassistant/components/atag/water_heater.py | 3 +- 5 files changed, 43 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/atag/entity.py diff --git a/homeassistant/components/atag/__init__.py b/homeassistant/components/atag/__init__.py index 85732485165..fe6a27c116d 100644 --- a/homeassistant/components/atag/__init__.py +++ b/homeassistant/components/atag/__init__.py @@ -10,12 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed _LOGGER = logging.getLogger(__name__) @@ -64,28 +59,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class AtagEntity(CoordinatorEntity[DataUpdateCoordinator[AtagOne]]): - """Defines a base Atag entity.""" - - def __init__( - self, coordinator: DataUpdateCoordinator[AtagOne], atag_id: str - ) -> None: - """Initialize the Atag entity.""" - super().__init__(coordinator) - - self._id = atag_id - self._attr_name = DOMAIN.title() - self._attr_unique_id = f"{coordinator.data.id}-{atag_id}" - - @property - def device_info(self) -> DeviceInfo: - """Return info for device registry.""" - return DeviceInfo( - identifiers={(DOMAIN, self.coordinator.data.id)}, - manufacturer="Atag", - model="Atag One", - name="Atag Thermostat", - sw_version=self.coordinator.data.apiversion, - ) diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index ff66839926f..c40db7cdd3e 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -18,7 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum -from . import DOMAIN, AtagEntity +from . import DOMAIN +from .entity import AtagEntity PRESET_MAP = { "Manual": "manual", diff --git a/homeassistant/components/atag/entity.py b/homeassistant/components/atag/entity.py new file mode 100644 index 00000000000..2847c5d17f6 --- /dev/null +++ b/homeassistant/components/atag/entity.py @@ -0,0 +1,36 @@ +"""The ATAG Integration.""" + +from pyatag import AtagOne + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import DOMAIN + + +class AtagEntity(CoordinatorEntity[DataUpdateCoordinator[AtagOne]]): + """Defines a base Atag entity.""" + + def __init__( + self, coordinator: DataUpdateCoordinator[AtagOne], atag_id: str + ) -> None: + """Initialize the Atag entity.""" + super().__init__(coordinator) + + self._id = atag_id + self._attr_name = DOMAIN.title() + self._attr_unique_id = f"{coordinator.data.id}-{atag_id}" + + @property + def device_info(self) -> DeviceInfo: + """Return info for device registry.""" + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.data.id)}, + manufacturer="Atag", + model="Atag One", + name="Atag Thermostat", + sw_version=self.coordinator.data.apiversion, + ) diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py index 25a3de34556..4fcbfeaa308 100644 --- a/homeassistant/components/atag/sensor.py +++ b/homeassistant/components/atag/sensor.py @@ -11,7 +11,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, AtagEntity +from . import DOMAIN +from .entity import AtagEntity SENSORS = { "Outside Temperature": "outside_temp", diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py index 8bae3df7436..91ccd623c55 100644 --- a/homeassistant/components/atag/water_heater.py +++ b/homeassistant/components/atag/water_heater.py @@ -12,7 +12,8 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, Platform, UnitOfTem from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, AtagEntity +from . import DOMAIN +from .entity import AtagEntity OPERATION_LIST = [STATE_OFF, STATE_ECO, STATE_PERFORMANCE] From 52ef358e1c6917b123849430a0f1ab7e6ac89bce Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 08:38:24 +0200 Subject: [PATCH 0948/1309] Move airvisual base entity to separate module (#126474) --- .../components/airvisual/__init__.py | 43 +---------------- homeassistant/components/airvisual/entity.py | 47 +++++++++++++++++++ homeassistant/components/airvisual/sensor.py | 3 +- .../components/airvisual_pro/__init__.py | 35 +------------- .../components/airvisual_pro/entity.py | 37 +++++++++++++++ .../components/airvisual_pro/sensor.py | 3 +- tests/components/airvisual/test_init.py | 4 +- 7 files changed, 94 insertions(+), 78 deletions(-) create mode 100644 homeassistant/components/airvisual/entity.py create mode 100644 homeassistant/components/airvisual_pro/entity.py diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index f8f045859b3..dac34b170c9 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -34,13 +34,8 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_CITY, @@ -403,39 +398,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) - async def async_reload_entry(hass: HomeAssistant, entry: AirVisualConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -class AirVisualEntity(CoordinatorEntity): - """Define a generic AirVisual entity.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator, - entry: ConfigEntry, - description: EntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - - self._attr_extra_state_attributes = {} - self._entry = entry - self.entity_description = description - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - @callback - def update() -> None: - """Update the state.""" - self.update_from_latest_data() - self.async_write_ha_state() - - self.async_on_remove(self.coordinator.async_add_listener(update)) - - self.update_from_latest_data() - - @callback - def update_from_latest_data(self) -> None: - """Update the entity from the latest data.""" - raise NotImplementedError diff --git a/homeassistant/components/airvisual/entity.py b/homeassistant/components/airvisual/entity.py new file mode 100644 index 00000000000..db480e560c7 --- /dev/null +++ b/homeassistant/components/airvisual/entity.py @@ -0,0 +1,47 @@ +"""The AirVisual component.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + + +class AirVisualEntity(CoordinatorEntity): + """Define a generic AirVisual entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + entry: ConfigEntry, + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_extra_state_attributes = {} + self._entry = entry + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + @callback + def update() -> None: + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove(self.coordinator.async_add_listener(update)) + + self.update_from_latest_data() + + @callback + def update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + raise NotImplementedError diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index c9df2f72233..88a670edb82 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -26,8 +26,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import AirVisualConfigEntry, AirVisualEntity +from . import AirVisualConfigEntry from .const import CONF_CITY +from .entity import AirVisualEntity ATTR_CITY = "city" ATTR_COUNTRY = "country" diff --git a/homeassistant/components/airvisual_pro/__init__.py b/homeassistant/components/airvisual_pro/__init__.py index 7397f279021..b95d0597bab 100644 --- a/homeassistant/components/airvisual_pro/__init__.py +++ b/homeassistant/components/airvisual_pro/__init__.py @@ -24,15 +24,9 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER +from .const import LOGGER PLATFORMS = [Platform.SENSOR] @@ -120,28 +114,3 @@ async def async_unload_entry( await entry.runtime_data.node.async_disconnect() return unload_ok - - -class AirVisualProEntity(CoordinatorEntity): - """Define a generic AirVisual Pro entity.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, description: EntityDescription - ) -> None: - """Initialize.""" - super().__init__(coordinator) - - self._attr_unique_id = f"{coordinator.data['serial_number']}_{description.key}" - self.entity_description = description - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return DeviceInfo( - identifiers={(DOMAIN, self.coordinator.data["serial_number"])}, - manufacturer="AirVisual", - model=self.coordinator.data["status"]["model"], - name=self.coordinator.data["settings"]["node_name"], - hw_version=self.coordinator.data["status"]["system_version"], - sw_version=self.coordinator.data["status"]["app_version"], - ) diff --git a/homeassistant/components/airvisual_pro/entity.py b/homeassistant/components/airvisual_pro/entity.py new file mode 100644 index 00000000000..bc28fa36e52 --- /dev/null +++ b/homeassistant/components/airvisual_pro/entity.py @@ -0,0 +1,37 @@ +"""The AirVisual Pro integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +class AirVisualProEntity(CoordinatorEntity): + """Define a generic AirVisual Pro entity.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.data['serial_number']}_{description.key}" + self.entity_description = description + + @property + def device_info(self) -> DeviceInfo: + """Return device registry information for this entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.data["serial_number"])}, + manufacturer="AirVisual", + model=self.coordinator.data["status"]["model"], + name=self.coordinator.data["settings"]["node_name"], + hw_version=self.coordinator.data["status"]["system_version"], + sw_version=self.coordinator.data["status"]["app_version"], + ) diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 895ba7d3244..66726832843 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -22,7 +22,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AirVisualProConfigEntry, AirVisualProEntity +from . import AirVisualProConfigEntry +from .entity import AirVisualProEntity @dataclass(frozen=True, kw_only=True) diff --git a/tests/components/airvisual/test_init.py b/tests/components/airvisual/test_init.py index 7fa9f4ca779..19dab3de210 100644 --- a/tests/components/airvisual/test_init.py +++ b/tests/components/airvisual/test_init.py @@ -11,7 +11,9 @@ from homeassistant.components.airvisual import ( INTEGRATION_TYPE_GEOGRAPHY_NAME, INTEGRATION_TYPE_NODE_PRO, ) -from homeassistant.components.airvisual_pro import DOMAIN as AIRVISUAL_PRO_DOMAIN + +# pylint: disable-next=hass-component-root-import +from homeassistant.components.airvisual_pro.const import DOMAIN as AIRVISUAL_PRO_DOMAIN from homeassistant.const import ( CONF_API_KEY, CONF_COUNTRY, From 49c9f843f8289032a9a0eaf0fd939941f4164ba6 Mon Sep 17 00:00:00 2001 From: jesperraemaekers <146726232+jesperraemaekers@users.noreply.github.com> Date: Mon, 23 Sep 2024 08:39:40 +0200 Subject: [PATCH 0949/1309] Bump Weheat to 2024.09.23 (#126471) Weheat version bump for support new model --- homeassistant/components/weheat/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 73f388fb01a..d32e0ce4047 100644 --- a/homeassistant/components/weheat/manifest.json +++ b/homeassistant/components/weheat/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/weheat", "iot_class": "cloud_polling", - "requirements": ["weheat==2024.09.10"] + "requirements": ["weheat==2024.09.23"] } diff --git a/requirements_all.txt b/requirements_all.txt index 824b6f10367..8f3daf0b7f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2956,7 +2956,7 @@ weatherflow4py==1.0.6 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.09.10 +weheat==2024.09.23 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7aa9093bf2f..9b713455776 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2348,7 +2348,7 @@ weatherflow4py==1.0.6 webmin-xmlrpc==0.0.2 # homeassistant.components.weheat -weheat==2024.09.10 +weheat==2024.09.23 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.8 From bed3fcfd434bd276857413c8f5b35a6ebb6501c6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 08:47:23 +0200 Subject: [PATCH 0950/1309] Move cert_expiry base entity to separate module (#126478) --- .../components/cert_expiry/entity.py | 23 +++++++++++++++++++ .../components/cert_expiry/sensor.py | 20 +++------------- 2 files changed, 26 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/cert_expiry/entity.py diff --git a/homeassistant/components/cert_expiry/entity.py b/homeassistant/components/cert_expiry/entity.py new file mode 100644 index 00000000000..f412f16fba8 --- /dev/null +++ b/homeassistant/components/cert_expiry/entity.py @@ -0,0 +1,23 @@ +"""Counter for the days until an HTTPS (TLS) certificate will expire.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import CertExpiryDataUpdateCoordinator + + +class CertExpiryEntity(CoordinatorEntity[CertExpiryDataUpdateCoordinator]): + """Defines a base Cert Expiry entity.""" + + _attr_has_entity_name = True + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return additional sensor state attributes.""" + return { + "is_valid": self.coordinator.is_cert_valid, + "error": str(self.coordinator.cert_error), + } diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index f52ff8a40d8..a6f163b51be 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime, timedelta -from typing import Any import voluptuous as vol @@ -20,10 +19,11 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import CertExpiryConfigEntry, CertExpiryDataUpdateCoordinator +from . import CertExpiryConfigEntry from .const import DEFAULT_PORT, DOMAIN +from .coordinator import CertExpiryDataUpdateCoordinator +from .entity import CertExpiryEntity SCAN_INTERVAL = timedelta(hours=12) @@ -73,20 +73,6 @@ async def async_setup_entry( async_add_entities(sensors, True) -class CertExpiryEntity(CoordinatorEntity[CertExpiryDataUpdateCoordinator]): - """Defines a base Cert Expiry entity.""" - - _attr_has_entity_name = True - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return additional sensor state attributes.""" - return { - "is_valid": self.coordinator.is_cert_valid, - "error": str(self.coordinator.cert_error), - } - - class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): """Implementation of the Cert Expiry timestamp sensor.""" From 7b9f2950718b5859546811e72f3447e9291e5575 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 08:47:43 +0200 Subject: [PATCH 0951/1309] Move control4 base entity to separate module (#126477) * Move control4 base entity to separate module * Adjust --- homeassistant/components/control4/__init__.py | 44 ---------------- homeassistant/components/control4/entity.py | 51 +++++++++++++++++++ homeassistant/components/control4/light.py | 3 +- .../components/control4/media_player.py | 2 +- 4 files changed, 54 insertions(+), 46 deletions(-) create mode 100644 homeassistant/components/control4/entity.py diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index a3d0cebd1fc..8d0eb72a73b 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import json import logging -from typing import Any from aiohttp import client_exceptions from pyControl4.account import C4Account @@ -23,11 +22,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from .const import ( API_RETRY_TIMES, @@ -166,41 +160,3 @@ async def get_items_of_category(hass: HomeAssistant, entry: ConfigEntry, categor for item in director_all_items if "categories" in item and category in item["categories"] ] - - -class Control4Entity(CoordinatorEntity[Any]): - """Base entity for Control4.""" - - def __init__( - self, - entry_data: dict, - coordinator: DataUpdateCoordinator[Any], - name: str | None, - idx: int, - device_name: str | None, - device_manufacturer: str | None, - device_model: str | None, - device_id: int, - ) -> None: - """Initialize a Control4 entity.""" - super().__init__(coordinator) - self.entry_data = entry_data - self._attr_name = name - self._attr_unique_id = str(idx) - self._idx = idx - self._controller_unique_id = entry_data[CONF_CONTROLLER_UNIQUE_ID] - self._device_name = device_name - self._device_manufacturer = device_manufacturer - self._device_model = device_model - self._device_id = device_id - - @property - def device_info(self) -> DeviceInfo: - """Return info of parent Control4 device of entity.""" - return DeviceInfo( - identifiers={(DOMAIN, str(self._device_id))}, - manufacturer=self._device_manufacturer, - model=self._device_model, - name=self._device_name, - via_device=(DOMAIN, self._controller_unique_id), - ) diff --git a/homeassistant/components/control4/entity.py b/homeassistant/components/control4/entity.py new file mode 100644 index 00000000000..fdb22e6578d --- /dev/null +++ b/homeassistant/components/control4/entity.py @@ -0,0 +1,51 @@ +"""The Control4 integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import CONF_CONTROLLER_UNIQUE_ID, DOMAIN + + +class Control4Entity(CoordinatorEntity[Any]): + """Base entity for Control4.""" + + def __init__( + self, + entry_data: dict, + coordinator: DataUpdateCoordinator[Any], + name: str | None, + idx: int, + device_name: str | None, + device_manufacturer: str | None, + device_model: str | None, + device_id: int, + ) -> None: + """Initialize a Control4 entity.""" + super().__init__(coordinator) + self.entry_data = entry_data + self._attr_name = name + self._attr_unique_id = str(idx) + self._idx = idx + self._controller_unique_id = entry_data[CONF_CONTROLLER_UNIQUE_ID] + self._device_name = device_name + self._device_manufacturer = device_manufacturer + self._device_model = device_model + self._device_id = device_id + + @property + def device_info(self) -> DeviceInfo: + """Return info of parent Control4 device of entity.""" + return DeviceInfo( + identifiers={(DOMAIN, str(self._device_id))}, + manufacturer=self._device_manufacturer, + model=self._device_model, + name=self._device_name, + via_device=(DOMAIN, self._controller_unique_id), + ) diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index d7cfd44dc43..927f4643619 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -23,9 +23,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from . import Control4Entity, get_items_of_category +from . import get_items_of_category from .const import CONF_DIRECTOR, CONTROL4_ENTITY_TYPE, DOMAIN from .director_utils import update_variables_for_config_entry +from .entity import Control4Entity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index 72aa44faaed..9e3421817a3 100644 --- a/homeassistant/components/control4/media_player.py +++ b/homeassistant/components/control4/media_player.py @@ -24,9 +24,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from . import Control4Entity from .const import CONF_DIRECTOR, CONF_DIRECTOR_ALL_ITEMS, CONF_UI_CONFIGURATION, DOMAIN from .director_utils import update_variables_for_config_entry +from .entity import Control4Entity _LOGGER = logging.getLogger(__name__) From 432d44c20d12146a8e61f70310a6f1bff216d851 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 08:54:28 +0200 Subject: [PATCH 0952/1309] Move deluge base entity to separate module (#126479) --- homeassistant/components/deluge/__init__.py | 25 +---------------- homeassistant/components/deluge/entity.py | 30 +++++++++++++++++++++ homeassistant/components/deluge/sensor.py | 3 ++- homeassistant/components/deluge/switch.py | 3 ++- 4 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/deluge/entity.py diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py index 62367e81af4..f4608b37006 100644 --- a/homeassistant/components/deluge/__init__.py +++ b/homeassistant/components/deluge/__init__.py @@ -17,10 +17,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_WEB_PORT, DEFAULT_NAME, DOMAIN +from .const import CONF_WEB_PORT from .coordinator import DelugeDataUpdateCoordinator PLATFORMS = [Platform.SENSOR, Platform.SWITCH] @@ -61,24 +59,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: DelugeConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: DelugeConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class DelugeEntity(CoordinatorEntity[DelugeDataUpdateCoordinator]): - """Representation of a Deluge entity.""" - - _attr_has_entity_name = True - - def __init__(self, coordinator: DelugeDataUpdateCoordinator) -> None: - """Initialize a Deluge entity.""" - super().__init__(coordinator) - self._server_unique_id = coordinator.config_entry.entry_id - self._attr_device_info = DeviceInfo( - configuration_url=( - f"http://{coordinator.api.host}:{coordinator.api.web_port}" - ), - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - manufacturer=DEFAULT_NAME, - name=DEFAULT_NAME, - sw_version=coordinator.api.deluge_version, - ) diff --git a/homeassistant/components/deluge/entity.py b/homeassistant/components/deluge/entity.py new file mode 100644 index 00000000000..5873abb3199 --- /dev/null +++ b/homeassistant/components/deluge/entity.py @@ -0,0 +1,30 @@ +"""The Deluge integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import DelugeDataUpdateCoordinator + + +class DelugeEntity(CoordinatorEntity[DelugeDataUpdateCoordinator]): + """Representation of a Deluge entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: DelugeDataUpdateCoordinator) -> None: + """Initialize a Deluge entity.""" + super().__init__(coordinator) + self._server_unique_id = coordinator.config_entry.entry_id + self._attr_device_info = DeviceInfo( + configuration_url=( + f"http://{coordinator.api.host}:{coordinator.api.web_port}" + ), + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + manufacturer=DEFAULT_NAME, + name=DEFAULT_NAME, + sw_version=coordinator.api.deluge_version, + ) diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index fd4bf36889c..05f78ddf501 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -17,9 +17,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import DelugeConfigEntry, DelugeEntity +from . import DelugeConfigEntry from .const import CURRENT_STATUS, DATA_KEYS, DOWNLOAD_SPEED, UPLOAD_SPEED from .coordinator import DelugeDataUpdateCoordinator +from .entity import DelugeEntity def get_state(data: dict[str, float], key: str) -> str | float: diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py index cfae0244ebd..d81f02eee29 100644 --- a/homeassistant/components/deluge/switch.py +++ b/homeassistant/components/deluge/switch.py @@ -9,8 +9,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DelugeConfigEntry, DelugeEntity +from . import DelugeConfigEntry from .coordinator import DelugeDataUpdateCoordinator +from .entity import DelugeEntity async def async_setup_entry( From 5a52e4c71d801b34198c4fa972ffc4a747664412 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 08:59:23 +0200 Subject: [PATCH 0953/1309] Move evil_genius_labs base entity to separate module (#126480) --- .../components/evil_genius_labs/__init__.py | 24 +-------------- .../components/evil_genius_labs/entity.py | 30 +++++++++++++++++++ .../components/evil_genius_labs/light.py | 2 +- .../components/evil_genius_labs/util.py | 2 +- 4 files changed, 33 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/evil_genius_labs/entity.py diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index afc6fecd9a4..d5bc3a564a2 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -7,9 +7,7 @@ import pyevilgenius from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers import aiohttp_client from .const import DOMAIN from .coordinator import EvilGeniusUpdateCoordinator @@ -41,23 +39,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class EvilGeniusEntity(CoordinatorEntity[EvilGeniusUpdateCoordinator]): - """Base entity for Evil Genius.""" - - _attr_has_entity_name = True - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - info = self.coordinator.info - return DeviceInfo( - identifiers={(DOMAIN, info["wiFiChipId"])}, - connections={(dr.CONNECTION_NETWORK_MAC, info["macAddress"])}, - name=self.coordinator.device_name, - model=self.coordinator.product_name, - manufacturer="Evil Genius Labs", - sw_version=info["coreVersion"].replace("_", "."), - configuration_url=self.coordinator.client.url, - ) diff --git a/homeassistant/components/evil_genius_labs/entity.py b/homeassistant/components/evil_genius_labs/entity.py new file mode 100644 index 00000000000..a690b385c56 --- /dev/null +++ b/homeassistant/components/evil_genius_labs/entity.py @@ -0,0 +1,30 @@ +"""The Evil Genius Labs integration.""" + +from __future__ import annotations + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EvilGeniusUpdateCoordinator + + +class EvilGeniusEntity(CoordinatorEntity[EvilGeniusUpdateCoordinator]): + """Base entity for Evil Genius.""" + + _attr_has_entity_name = True + + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + info = self.coordinator.info + return DeviceInfo( + identifiers={(DOMAIN, info["wiFiChipId"])}, + connections={(dr.CONNECTION_NETWORK_MAC, info["macAddress"])}, + name=self.coordinator.device_name, + model=self.coordinator.product_name, + manufacturer="Evil Genius Labs", + sw_version=info["coreVersion"].replace("_", "."), + configuration_url=self.coordinator.client.url, + ) diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py index 89bdcae9ef7..3556672dcce 100644 --- a/homeassistant/components/evil_genius_labs/light.py +++ b/homeassistant/components/evil_genius_labs/light.py @@ -11,9 +11,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EvilGeniusEntity from .const import DOMAIN from .coordinator import EvilGeniusUpdateCoordinator +from .entity import EvilGeniusEntity from .util import update_when_done HA_NO_EFFECT = "None" diff --git a/homeassistant/components/evil_genius_labs/util.py b/homeassistant/components/evil_genius_labs/util.py index f3c86f2666f..1182cab3e8b 100644 --- a/homeassistant/components/evil_genius_labs/util.py +++ b/homeassistant/components/evil_genius_labs/util.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate -from . import EvilGeniusEntity +from .entity import EvilGeniusEntity def update_when_done[_EvilGeniusEntityT: EvilGeniusEntity, **_P, _R]( From a9b215357fd399ca02c9cfba4d81c29b008b0005 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 08:59:38 +0200 Subject: [PATCH 0954/1309] Move elmax base entity to separate module (#126481) --- .../components/elmax/alarm_control_panel.py | 2 +- .../components/elmax/binary_sensor.py | 2 +- homeassistant/components/elmax/common.py | 37 +---------------- homeassistant/components/elmax/cover.py | 2 +- homeassistant/components/elmax/entity.py | 41 +++++++++++++++++++ homeassistant/components/elmax/switch.py | 2 +- 6 files changed, 46 insertions(+), 40 deletions(-) create mode 100644 homeassistant/components/elmax/entity.py diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py index 61d13704641..4162b177975 100644 --- a/homeassistant/components/elmax/alarm_control_panel.py +++ b/homeassistant/components/elmax/alarm_control_panel.py @@ -25,9 +25,9 @@ from homeassistant.exceptions import HomeAssistantError, InvalidStateError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .common import ElmaxEntity from .const import DOMAIN from .coordinator import ElmaxCoordinator +from .entity import ElmaxEntity async def async_setup_entry( diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index e477ab6c2a4..ec51f861819 100644 --- a/homeassistant/components/elmax/binary_sensor.py +++ b/homeassistant/components/elmax/binary_sensor.py @@ -12,9 +12,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ElmaxEntity from .const import DOMAIN from .coordinator import ElmaxCoordinator +from .entity import ElmaxEntity async def async_setup_entry( diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index 965e30235ff..88e61e36a68 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -4,15 +4,10 @@ from __future__ import annotations import ssl -from elmax_api.model.endpoint import DeviceEndpoint from elmax_api.model.panel import PanelEntry from packaging import version -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN, ELMAX_LOCAL_API_PATH, MIN_APIV2_SUPPORTED_VERSION -from .coordinator import ElmaxCoordinator +from .const import ELMAX_LOCAL_API_PATH, MIN_APIV2_SUPPORTED_VERSION def get_direct_api_url(host: str, port: int, use_ssl: bool) -> str: @@ -47,33 +42,3 @@ class DirectPanel(PanelEntry): def get_name_by_user(self, username: str) -> str: """Return the panel name.""" return f"Direct Panel {self.hash}" - - -class ElmaxEntity(CoordinatorEntity[ElmaxCoordinator]): - """Wrapper for Elmax entities.""" - - def __init__( - self, - elmax_device: DeviceEndpoint, - panel_version: str, - coordinator: ElmaxCoordinator, - ) -> None: - """Construct the object.""" - super().__init__(coordinator=coordinator) - self._device = elmax_device - self._attr_unique_id = elmax_device.endpoint_id - self._attr_name = elmax_device.name - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.panel_entry.hash)}, - name=coordinator.panel_entry.get_name_by_user( - coordinator.http_client.get_authenticated_username() - ), - manufacturer="Elmax", - model=panel_version, - sw_version=panel_version, - ) - - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and self.coordinator.panel_entry.online diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index 528b2e6dead..a53c28c5f33 100644 --- a/homeassistant/components/elmax/cover.py +++ b/homeassistant/components/elmax/cover.py @@ -13,9 +13,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ElmaxEntity from .const import DOMAIN from .coordinator import ElmaxCoordinator +from .entity import ElmaxEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/elmax/entity.py b/homeassistant/components/elmax/entity.py new file mode 100644 index 00000000000..a49fdc14c3e --- /dev/null +++ b/homeassistant/components/elmax/entity.py @@ -0,0 +1,41 @@ +"""Elmax integration common classes and utilities.""" + +from __future__ import annotations + +from elmax_api.model.endpoint import DeviceEndpoint + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ElmaxCoordinator + + +class ElmaxEntity(CoordinatorEntity[ElmaxCoordinator]): + """Wrapper for Elmax entities.""" + + def __init__( + self, + elmax_device: DeviceEndpoint, + panel_version: str, + coordinator: ElmaxCoordinator, + ) -> None: + """Construct the object.""" + super().__init__(coordinator=coordinator) + self._device = elmax_device + self._attr_unique_id = elmax_device.endpoint_id + self._attr_name = elmax_device.name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.panel_entry.hash)}, + name=coordinator.panel_entry.get_name_by_user( + coordinator.http_client.get_authenticated_username() + ), + manufacturer="Elmax", + model=panel_version, + sw_version=panel_version, + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.panel_entry.online diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py index 6ecbc70a8c5..d0e52c556f6 100644 --- a/homeassistant/components/elmax/switch.py +++ b/homeassistant/components/elmax/switch.py @@ -12,9 +12,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ElmaxEntity from .const import DOMAIN from .coordinator import ElmaxCoordinator +from .entity import ElmaxEntity _LOGGER = logging.getLogger(__name__) From 16221cfbbd6c4f0c6aa13c1b6d55f006217e3ed7 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 23 Sep 2024 09:27:11 +0200 Subject: [PATCH 0955/1309] Fix Matter climate platform attributes when dedicated OnOff attribute is off (#126286) --- homeassistant/components/matter/climate.py | 90 ++++++++++++---------- 1 file changed, 49 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index ff00e4ee495..4eec539c0db 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -190,48 +190,56 @@ class MatterClimate(MatterEntity, ClimateEntity): # if the mains power is off - treat it as if the HVAC mode is off self._attr_hvac_mode = HVACMode.OFF self._attr_hvac_action = None - return - - # update hvac_mode from SystemMode - system_mode_value = int( - self.get_matter_attribute_value(clusters.Thermostat.Attributes.SystemMode) - ) - match system_mode_value: - case SystemModeEnum.kAuto: - self._attr_hvac_mode = HVACMode.HEAT_COOL - case SystemModeEnum.kDry: - self._attr_hvac_mode = HVACMode.DRY - case SystemModeEnum.kFanOnly: - self._attr_hvac_mode = HVACMode.FAN_ONLY - case SystemModeEnum.kCool | SystemModeEnum.kPrecooling: - self._attr_hvac_mode = HVACMode.COOL - case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat: - self._attr_hvac_mode = HVACMode.HEAT - case SystemModeEnum.kFanOnly: - self._attr_hvac_mode = HVACMode.FAN_ONLY - case SystemModeEnum.kDry: - self._attr_hvac_mode = HVACMode.DRY - case _: - self._attr_hvac_mode = HVACMode.OFF - # running state is an optional attribute - # which we map to hvac_action if it exists (its value is not None) - self._attr_hvac_action = None - if running_state_value := self.get_matter_attribute_value( - clusters.Thermostat.Attributes.ThermostatRunningState - ): - match running_state_value: - case ThermostatRunningState.Heat | ThermostatRunningState.HeatStage2: - self._attr_hvac_action = HVACAction.HEATING - case ThermostatRunningState.Cool | ThermostatRunningState.CoolStage2: - self._attr_hvac_action = HVACAction.COOLING - case ( - ThermostatRunningState.Fan - | ThermostatRunningState.FanStage2 - | ThermostatRunningState.FanStage3 - ): - self._attr_hvac_action = HVACAction.FAN + else: + # update hvac_mode from SystemMode + system_mode_value = int( + self.get_matter_attribute_value( + clusters.Thermostat.Attributes.SystemMode + ) + ) + match system_mode_value: + case SystemModeEnum.kAuto: + self._attr_hvac_mode = HVACMode.HEAT_COOL + case SystemModeEnum.kDry: + self._attr_hvac_mode = HVACMode.DRY + case SystemModeEnum.kFanOnly: + self._attr_hvac_mode = HVACMode.FAN_ONLY + case SystemModeEnum.kCool | SystemModeEnum.kPrecooling: + self._attr_hvac_mode = HVACMode.COOL + case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat: + self._attr_hvac_mode = HVACMode.HEAT + case SystemModeEnum.kFanOnly: + self._attr_hvac_mode = HVACMode.FAN_ONLY + case SystemModeEnum.kDry: + self._attr_hvac_mode = HVACMode.DRY case _: - self._attr_hvac_action = HVACAction.OFF + self._attr_hvac_mode = HVACMode.OFF + # running state is an optional attribute + # which we map to hvac_action if it exists (its value is not None) + self._attr_hvac_action = None + if running_state_value := self.get_matter_attribute_value( + clusters.Thermostat.Attributes.ThermostatRunningState + ): + match running_state_value: + case ( + ThermostatRunningState.Heat + | ThermostatRunningState.HeatStage2 + ): + self._attr_hvac_action = HVACAction.HEATING + case ( + ThermostatRunningState.Cool + | ThermostatRunningState.CoolStage2 + ): + self._attr_hvac_action = HVACAction.COOLING + case ( + ThermostatRunningState.Fan + | ThermostatRunningState.FanStage2 + | ThermostatRunningState.FanStage3 + ): + self._attr_hvac_action = HVACAction.FAN + case _: + self._attr_hvac_action = HVACAction.OFF + # update target temperature high/low supports_range = ( self._attr_supported_features From ffa7e5a50486703874ce05ded7f2ff921531f900 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 09:28:32 +0200 Subject: [PATCH 0956/1309] Move gogogate2 base entity to separate module (#126485) --- homeassistant/components/gogogate2/common.py | 62 +----------------- homeassistant/components/gogogate2/cover.py | 3 +- homeassistant/components/gogogate2/entity.py | 68 ++++++++++++++++++++ homeassistant/components/gogogate2/sensor.py | 3 +- 4 files changed, 75 insertions(+), 61 deletions(-) create mode 100644 homeassistant/components/gogogate2/entity.py diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 3052e9041ac..52b1788c23e 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -14,7 +14,7 @@ from ismartgate import ( ISmartGateApi, ISmartGateInfoResponse, ) -from ismartgate.common import AbstractDoor, get_door_by_id +from ismartgate.common import AbstractDoor from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -24,11 +24,10 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.update_coordinator import CoordinatorEntity, UpdateFailed +from homeassistant.helpers.update_coordinator import UpdateFailed -from .const import DATA_UPDATE_COORDINATOR, DEVICE_TYPE_ISMARTGATE, DOMAIN, MANUFACTURER +from .const import DATA_UPDATE_COORDINATOR, DEVICE_TYPE_ISMARTGATE, DOMAIN from .coordinator import DeviceDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -42,61 +41,6 @@ class StateData(NamedTuple): door: AbstractDoor | None -class GoGoGate2Entity(CoordinatorEntity[DeviceDataUpdateCoordinator]): - """Base class for gogogate2 entities.""" - - def __init__( - self, - config_entry: ConfigEntry, - data_update_coordinator: DeviceDataUpdateCoordinator, - door: AbstractDoor, - unique_id: str, - ) -> None: - """Initialize gogogate2 base entity.""" - super().__init__(data_update_coordinator) - self._config_entry = config_entry - self._door = door - self._door_id = door.door_id - self._api = data_update_coordinator.api - self._attr_unique_id = unique_id - - @property - def door(self) -> AbstractDoor: - """Return the door object.""" - door = get_door_by_id(self._door.door_id, self.coordinator.data) - self._door = door or self._door - return self._door - - @property - def door_status(self) -> AbstractDoor: - """Return the door with status.""" - data = self.coordinator.data - door_with_statuses = self._api.async_get_door_statuses_from_info(data) - return door_with_statuses[self._door_id] - - @property - def device_info(self) -> DeviceInfo: - """Device info for the controller.""" - data = self.coordinator.data - if data.remoteaccessenabled: - configuration_url = f"https://{data.remoteaccess}" - else: - configuration_url = f"http://{self._config_entry.data[CONF_IP_ADDRESS]}" - return DeviceInfo( - configuration_url=configuration_url, - identifiers={(DOMAIN, str(self._config_entry.unique_id))}, - name=self._config_entry.title, - manufacturer=MANUFACTURER, - model=data.model, - sw_version=data.firmwareversion, - ) - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {"door_id": self._door_id} - - def get_data_update_coordinator( hass: HomeAssistant, config_entry: ConfigEntry ) -> DeviceDataUpdateCoordinator: diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index e807f1acd3f..6bd38a0bc01 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -20,8 +20,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import GoGoGate2Entity, cover_unique_id, get_data_update_coordinator +from .common import cover_unique_id, get_data_update_coordinator from .coordinator import DeviceDataUpdateCoordinator +from .entity import GoGoGate2Entity async def async_setup_entry( diff --git a/homeassistant/components/gogogate2/entity.py b/homeassistant/components/gogogate2/entity.py new file mode 100644 index 00000000000..8a699f6101b --- /dev/null +++ b/homeassistant/components/gogogate2/entity.py @@ -0,0 +1,68 @@ +"""Common code for GogoGate2 component.""" + +from __future__ import annotations + +from ismartgate.common import AbstractDoor, get_door_by_id + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import DeviceDataUpdateCoordinator + + +class GoGoGate2Entity(CoordinatorEntity[DeviceDataUpdateCoordinator]): + """Base class for gogogate2 entities.""" + + def __init__( + self, + config_entry: ConfigEntry, + data_update_coordinator: DeviceDataUpdateCoordinator, + door: AbstractDoor, + unique_id: str, + ) -> None: + """Initialize gogogate2 base entity.""" + super().__init__(data_update_coordinator) + self._config_entry = config_entry + self._door = door + self._door_id = door.door_id + self._api = data_update_coordinator.api + self._attr_unique_id = unique_id + + @property + def door(self) -> AbstractDoor: + """Return the door object.""" + door = get_door_by_id(self._door.door_id, self.coordinator.data) + self._door = door or self._door + return self._door + + @property + def door_status(self) -> AbstractDoor: + """Return the door with status.""" + data = self.coordinator.data + door_with_statuses = self._api.async_get_door_statuses_from_info(data) + return door_with_statuses[self._door_id] + + @property + def device_info(self) -> DeviceInfo: + """Device info for the controller.""" + data = self.coordinator.data + if data.remoteaccessenabled: + configuration_url = f"https://{data.remoteaccess}" + else: + configuration_url = f"http://{self._config_entry.data[CONF_IP_ADDRESS]}" + return DeviceInfo( + configuration_url=configuration_url, + identifiers={(DOMAIN, str(self._config_entry.unique_id))}, + name=self._config_entry.title, + manufacturer=MANUFACTURER, + model=data.model, + sw_version=data.firmwareversion, + ) + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return {"door_id": self._door_id} diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py index 1dd0a57f7ed..c7740e24825 100644 --- a/homeassistant/components/gogogate2/sensor.py +++ b/homeassistant/components/gogogate2/sensor.py @@ -16,8 +16,9 @@ from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import GoGoGate2Entity, get_data_update_coordinator, sensor_unique_id +from .common import get_data_update_coordinator, sensor_unique_id from .coordinator import DeviceDataUpdateCoordinator +from .entity import GoGoGate2Entity SENSOR_ID_WIRED = "WIRE" From 3f4f2f4e2b71ce392c125e1eab7ce6b191b4624f Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 23 Sep 2024 17:36:56 +1000 Subject: [PATCH 0957/1309] Add router reconnect button for Smlight integration (#126408) * Add button for router reconnect * strings for router reconnect * remove stale router reconnect if zigbee is not running router firmware * Add tests for router reconnect button * Update homeassistant/components/smlight/strings.json And fix associated tests Co-authored-by: Joost Lekkerkerker * Make router button entity dynamic * adjust test for dynamic runtime removal * drop if statements from tests --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/smlight/button.py | 33 ++++++++++-- homeassistant/components/smlight/strings.json | 3 ++ tests/components/smlight/test_button.py | 52 ++++++++++++++++--- 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py index de19c57d1b1..d82034b87fb 100644 --- a/homeassistant/components/smlight/button.py +++ b/homeassistant/components/smlight/button.py @@ -5,20 +5,22 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging -from typing import Final from pysmlight.web import CmdWrapper from homeassistant.components.button import ( + DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass, ButtonEntity, ButtonEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import DOMAIN from .coordinator import SmDataUpdateCoordinator from .entity import SmEntity @@ -32,7 +34,7 @@ class SmButtonDescription(ButtonEntityDescription): press_fn: Callable[[CmdWrapper], Awaitable[None]] -BUTTONS: Final = [ +BUTTONS: list[SmButtonDescription] = [ SmButtonDescription( key="core_restart", translation_key="core_restart", @@ -53,6 +55,13 @@ BUTTONS: Final = [ ), ] +ROUTER = SmButtonDescription( + key="reconnect_zigbee_router", + translation_key="reconnect_zigbee_router", + entity_registry_enabled_default=False, + press_fn=lambda cmd: cmd.zb_router(), +) + async def async_setup_entry( hass: HomeAssistant, @@ -63,6 +72,24 @@ async def async_setup_entry( coordinator = entry.runtime_data.data async_add_entities(SmButton(coordinator, button) for button in BUTTONS) + entity_created = False + + @callback + def _check_router(startup: bool = False) -> None: + nonlocal entity_created + + if coordinator.data.info.zb_type == 1 and not entity_created: + async_add_entities([SmButton(coordinator, ROUTER)]) + entity_created = True + elif coordinator.data.info.zb_type != 1 and (startup or entity_created): + entity_registry = er.async_get(hass) + if entity_id := entity_registry.async_get_entity_id( + BUTTON_DOMAIN, DOMAIN, f"{coordinator.unique_id}-{ROUTER.key}" + ): + entity_registry.async_remove(entity_id) + + coordinator.async_add_listener(_check_router) + _check_router(startup=True) class SmButton(SmEntity, ButtonEntity): diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 97797feae2a..1e6a533beef 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -108,6 +108,9 @@ }, "zigbee_flash_mode": { "name": "Zigbee flash mode" + }, + "reconnect_zigbee_router": { + "name": "Reconnect zigbee router" } }, "switch": { diff --git a/tests/components/smlight/test_button.py b/tests/components/smlight/test_button.py index 487351acdea..3721ee815e6 100644 --- a/tests/components/smlight/test_button.py +++ b/tests/components/smlight/test_button.py @@ -2,16 +2,19 @@ from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pysmlight import Info import pytest from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.smlight.const import SCAN_INTERVAL from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .conftest import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -20,12 +23,16 @@ def platforms() -> Platform | list[Platform]: return [Platform.BUTTON] +MOCK_ROUTER = Info(MAC="AA:BB:CC:DD:EE:FF", zb_type=1) + + @pytest.mark.parametrize( ("entity_id", "method"), [ ("core_restart", "reboot"), ("zigbee_flash_mode", "zb_bootloader"), ("zigbee_restart", "zb_restart"), + ("reconnect_zigbee_router", "zb_router"), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -38,6 +45,7 @@ async def test_buttons( mock_smlight_client: MagicMock, ) -> None: """Test creation of button entities.""" + mock_smlight_client.get_info.return_value = MOCK_ROUTER await setup_integration(hass, mock_config_entry) state = hass.states.get(f"button.mock_title_{entity_id}") @@ -61,17 +69,49 @@ async def test_buttons( mock_method.assert_called_with() -@pytest.mark.usefixtures("mock_smlight_client") -async def test_disabled_by_default_button( +@pytest.mark.parametrize("entity_id", ["zigbee_flash_mode", "reconnect_zigbee_router"]) +async def test_disabled_by_default_buttons( hass: HomeAssistant, + entity_id: str, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, ) -> None: - """Test the disabled by default flash mode button.""" + """Test the disabled by default buttons.""" + mock_smlight_client.get_info.return_value = MOCK_ROUTER await setup_integration(hass, mock_config_entry) - assert not hass.states.get("button.mock_title_zigbee_flash_mode") + assert not hass.states.get(f"button.mock_{entity_id}") - assert (entry := entity_registry.async_get("button.mock_title_zigbee_flash_mode")) + assert (entry := entity_registry.async_get(f"button.mock_title_{entity_id}")) assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_remove_router_reconnect( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test removal of orphaned router reconnect button.""" + save_mock = mock_smlight_client.get_info.return_value + mock_smlight_client.get_info.return_value = MOCK_ROUTER + mock_config_entry = await setup_integration(hass, mock_config_entry) + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 4 + assert entities[3].unique_id == "aa:bb:cc:dd:ee:ff-reconnect_zigbee_router" + + mock_smlight_client.get_info.return_value = save_mock + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + + await hass.async_block_till_done() + + entity = entity_registry.async_get("button.mock_title_reconnect_zigbee_router") + assert entity is None From 6d069bec19fa3105abcd99b2bf88ca840aec9a17 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 09:57:54 +0200 Subject: [PATCH 0958/1309] Move iqvia base entity to separate module (#126489) --- homeassistant/components/iqvia/__init__.py | 56 +------------------ homeassistant/components/iqvia/entity.py | 62 ++++++++++++++++++++++ homeassistant/components/iqvia/sensor.py | 2 +- 3 files changed, 65 insertions(+), 55 deletions(-) create mode 100644 homeassistant/components/iqvia/entity.py diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index ab05ae19d86..8b72d6f8784 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -13,15 +13,10 @@ from pyiqvia.errors import IQVIAError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_ZIP_CODE, @@ -112,50 +107,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class IQVIAEntity(CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]]): - """Define a base IQVIA entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[dict[str, Any]], - entry: ConfigEntry, - description: EntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - - self._attr_extra_state_attributes = {} - self._attr_unique_id = f"{entry.data[CONF_ZIP_CODE]}_{description.key}" - self._entry = entry - self.entity_description = description - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - if not self.coordinator.last_update_success: - return - - self.update_from_latest_data() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - if self.entity_description.key == TYPE_ALLERGY_FORECAST: - self.async_on_remove( - self.hass.data[DOMAIN][self._entry.entry_id][ - TYPE_ALLERGY_OUTLOOK - ].async_add_listener(self._handle_coordinator_update) - ) - - self.update_from_latest_data() - - @callback - def update_from_latest_data(self) -> None: - """Update the entity from the latest data.""" - raise NotImplementedError diff --git a/homeassistant/components/iqvia/entity.py b/homeassistant/components/iqvia/entity.py new file mode 100644 index 00000000000..e77c0f7e32a --- /dev/null +++ b/homeassistant/components/iqvia/entity.py @@ -0,0 +1,62 @@ +"""Support for IQVIA.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import CONF_ZIP_CODE, DOMAIN, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_OUTLOOK + + +class IQVIAEntity(CoordinatorEntity[DataUpdateCoordinator[dict[str, Any]]]): + """Define a base IQVIA entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, Any]], + entry: ConfigEntry, + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_extra_state_attributes = {} + self._attr_unique_id = f"{entry.data[CONF_ZIP_CODE]}_{description.key}" + self._entry = entry + self.entity_description = description + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if not self.coordinator.last_update_success: + return + + self.update_from_latest_data() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + if self.entity_description.key == TYPE_ALLERGY_FORECAST: + self.async_on_remove( + self.hass.data[DOMAIN][self._entry.entry_id][ + TYPE_ALLERGY_OUTLOOK + ].async_add_listener(self._handle_coordinator_update) + ) + + self.update_from_latest_data() + + @callback + def update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + raise NotImplementedError diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index af351e0d543..d04e0885454 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ATTR_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import IQVIAEntity from .const import ( DOMAIN, TYPE_ALLERGY_FORECAST, @@ -33,6 +32,7 @@ from .const import ( TYPE_DISEASE_INDEX, TYPE_DISEASE_TODAY, ) +from .entity import IQVIAEntity ATTR_ALLERGEN_AMOUNT = "allergen_amount" ATTR_ALLERGEN_GENUS = "allergen_genus" From 5ad426d62eb29c332f6031c00209991f1a1d9ccb Mon Sep 17 00:00:00 2001 From: Manuel Frei Date: Mon, 23 Sep 2024 10:09:58 +0200 Subject: [PATCH 0959/1309] Fix surepetcare token update (#126385) Co-authored-by: Joostlek --- .../components/surepetcare/config_flow.py | 98 +++++++++---------- .../surepetcare/test_config_flow.py | 35 ++++--- 2 files changed, 71 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index 6626b1d6dee..a993e9a47f1 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -10,9 +10,8 @@ import surepy from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, SURE_API_TIMEOUT @@ -27,57 +26,43 @@ USER_DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: - """Validate the user input allows us to connect.""" - surepy_client = surepy.Surepy( - data[CONF_USERNAME], - data[CONF_PASSWORD], - auth_token=None, - api_timeout=SURE_API_TIMEOUT, - session=async_get_clientsession(hass), - ) - - token = await surepy_client.sac.get_token() - - return {CONF_TOKEN: token} - - class SurePetCareConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Sure Petcare.""" VERSION = 1 - def __init__(self) -> None: - """Initialize.""" - self._username: str | None = None + reauth_entry: ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - if user_input is None: - return self.async_show_form(step_id="user", data_schema=USER_DATA_SCHEMA) - errors = {} - - try: - info = await validate_input(self.hass, user_input) - except SurePetcareAuthenticationError: - errors["base"] = "invalid_auth" - except SurePetcareError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) - self._abort_if_unique_id_configured() - - user_input[CONF_TOKEN] = info[CONF_TOKEN] - return self.async_create_entry( - title="Sure Petcare", - data=user_input, + if user_input is not None: + client = surepy.Surepy( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + auth_token=None, + api_timeout=SURE_API_TIMEOUT, + session=async_get_clientsession(self.hass), ) + try: + token = await client.sac.get_token() + except SurePetcareAuthenticationError: + errors["base"] = "invalid_auth" + except SurePetcareError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Sure Petcare", + data={**user_input, CONF_TOKEN: token}, + ) return self.async_show_form( step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors @@ -87,18 +72,27 @@ class SurePetCareConfigFlow(ConfigFlow, domain=DOMAIN): self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - self._username = entry_data[CONF_USERNAME] + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" + assert self.reauth_entry errors = {} if user_input is not None: - user_input[CONF_USERNAME] = self._username + client = surepy.Surepy( + self.reauth_entry.data[CONF_USERNAME], + user_input[CONF_PASSWORD], + auth_token=None, + api_timeout=SURE_API_TIMEOUT, + session=async_get_clientsession(self.hass), + ) try: - await validate_input(self.hass, user_input) + token = await client.sac.get_token() except SurePetcareAuthenticationError: errors["base"] = "invalid_auth" except SurePetcareError: @@ -107,16 +101,20 @@ class SurePetCareConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - existing_entry = await self.async_set_unique_id( - user_input[CONF_USERNAME].lower() + return self.async_update_reload_and_abort( + self.reauth_entry, + data={ + **self.reauth_entry.data, + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_TOKEN: token, + }, ) - if existing_entry: - await self.hass.config_entries.async_reload(existing_entry.entry_id) - return self.async_abort(reason="reauth_successful") return self.async_show_form( step_id="reauth_confirm", - description_placeholders={"username": self._username}, + description_placeholders={ + "username": self.reauth_entry.data[CONF_USERNAME] + }, data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), errors=errors, ) diff --git a/tests/components/surepetcare/test_config_flow.py b/tests/components/surepetcare/test_config_flow.py index c4055ebe658..1140a2c54ef 100644 --- a/tests/components/surepetcare/test_config_flow.py +++ b/tests/components/surepetcare/test_config_flow.py @@ -6,6 +6,7 @@ from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError from homeassistant import config_entries from homeassistant.components.surepetcare.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -24,7 +25,7 @@ async def test_form(hass: HomeAssistant, surepetcare: NonCallableMagicMock) -> N DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert not result["errors"] with patch( "homeassistant.components.surepetcare.async_setup_entry", @@ -146,11 +147,17 @@ async def test_flow_entry_already_exists( assert result["reason"] == "already_configured" -async def test_reauthentication(hass: HomeAssistant) -> None: +async def test_reauthentication( + hass: HomeAssistant, surepetcare: NonCallableMagicMock +) -> None: """Test surepetcare reauthentication.""" old_entry = MockConfigEntry( domain="surepetcare", - data=INPUT_DATA, + data={ + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_TOKEN: "token", + }, unique_id="test-username", ) old_entry.add_to_hass(hass) @@ -161,19 +168,23 @@ async def test_reauthentication(hass: HomeAssistant) -> None: assert result["errors"] == {} assert result["step_id"] == "reauth_confirm" - with patch( - "homeassistant.components.surepetcare.config_flow.surepy.client.SureAPIClient.get_token", - return_value={"token": "token"}, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"password": "test-password"}, - ) - await hass.async_block_till_done() + surepetcare.get_token.return_value = "token2" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"password": "test-password2"}, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "reauth_successful" + assert old_entry.data == { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password2", + CONF_TOKEN: "token2", + } + async def test_reauthentication_failure(hass: HomeAssistant) -> None: """Test surepetcare reauthentication failure.""" From 683a5b7120097ff879dd01e64ad7724d22ef71aa Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 23 Sep 2024 10:11:27 +0200 Subject: [PATCH 0960/1309] Fix next change (scheduler) sensors in AVM FRITZ!SmartHome (#126363) --- homeassistant/components/fritzbox/sensor.py | 26 +++++++-- tests/components/fritzbox/__init__.py | 5 +- tests/components/fritzbox/test_climate.py | 2 +- tests/components/fritzbox/test_sensor.py | 62 ++++++++++++++++++++- 4 files changed, 85 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index d28727c01f5..dbfdc2f9c95 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -83,20 +83,38 @@ def entity_category_temperature(device: FritzhomeDevice) -> EntityCategory | Non return None -def value_nextchange_preset(device: FritzhomeDevice) -> str: +def value_nextchange_preset(device: FritzhomeDevice) -> str | None: """Return native value for next scheduled preset sensor.""" + if not device.nextchange_endperiod: + return None if device.nextchange_temperature == device.eco_temperature: return PRESET_ECO return PRESET_COMFORT -def value_scheduled_preset(device: FritzhomeDevice) -> str: +def value_scheduled_preset(device: FritzhomeDevice) -> str | None: """Return native value for current scheduled preset sensor.""" + if not device.nextchange_endperiod: + return None if device.nextchange_temperature == device.eco_temperature: return PRESET_COMFORT return PRESET_ECO +def value_nextchange_temperature(device: FritzhomeDevice) -> float | None: + """Return native value for next scheduled temperature time sensor.""" + if device.nextchange_endperiod and isinstance(device.nextchange_temperature, float): + return device.nextchange_temperature + return None + + +def value_nextchange_time(device: FritzhomeDevice) -> datetime | None: + """Return native value for next scheduled changed time sensor.""" + if device.nextchange_endperiod: + return utc_from_timestamp(device.nextchange_endperiod) + return None + + SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( FritzSensorEntityDescription( key="temperature", @@ -181,7 +199,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_temperature, - native_value=lambda device: device.nextchange_temperature, + native_value=value_nextchange_temperature, ), FritzSensorEntityDescription( key="nextchange_time", @@ -189,7 +207,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, suitable=suitable_nextchange_time, - native_value=lambda device: utc_from_timestamp(device.nextchange_endperiod), + native_value=value_nextchange_time, ), FritzSensorEntityDescription( key="nextchange_preset", diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index bd68615212d..034b86497db 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from unittest.mock import Mock -from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO from homeassistant.components.fritzbox.const import DOMAIN from homeassistant.core import HomeAssistant @@ -110,9 +109,7 @@ class FritzDeviceClimateMock(FritzEntityBaseMock): target_temperature = 19.5 window_open = "fake_window" nextchange_temperature = 22.0 - nextchange_endperiod = 0 - nextchange_preset = PRESET_COMFORT - scheduled_preset = PRESET_ECO + nextchange_endperiod = 1726855200 class FritzDeviceClimateWithoutTempSensorMock(FritzDeviceClimateMock): diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 6bd405aa5ab..f43e77e9861 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -125,7 +125,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock) -> None: f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_next_scheduled_change_time" ) assert state - assert state.state == "1970-01-01T00:00:00+00:00" + assert state.state == "2024-09-20T18:00:00+00:00" assert ( state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Next scheduled change time" diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index 633049a8a9b..0da040bbb5b 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -3,8 +3,10 @@ from datetime import timedelta from unittest.mock import Mock +import pytest from requests.exceptions import HTTPError +from homeassistant.components.climate import PRESET_COMFORT, PRESET_ECO from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN from homeassistant.components.sensor import ( ATTR_STATE_CLASS, @@ -16,6 +18,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, PERCENTAGE, + STATE_UNKNOWN, EntityCategory, UnitOfTemperature, ) @@ -23,7 +26,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util -from . import FritzDeviceSensorMock, set_devices, setup_config_entry +from . import ( + FritzDeviceClimateMock, + FritzDeviceSensorMock, + set_devices, + setup_config_entry, +) from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed @@ -136,3 +144,55 @@ async def test_discover_new_device(hass: HomeAssistant, fritz: Mock) -> None: state = hass.states.get(f"{SENSOR_DOMAIN}.new_device_temperature") assert state + + +@pytest.mark.parametrize( + ("next_changes", "expected_states"), + [ + ( + [0, 16], + [STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN], + ), + ( + [0, 22], + [STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN], + ), + ( + [1726855200, 16.0], + ["2024-09-20T18:00:00+00:00", "16.0", PRESET_ECO, PRESET_COMFORT], + ), + ( + [1726855200, 22.0], + ["2024-09-20T18:00:00+00:00", "22.0", PRESET_COMFORT, PRESET_ECO], + ), + ], +) +async def test_next_change_sensors( + hass: HomeAssistant, fritz: Mock, next_changes: list, expected_states: list +) -> None: + """Test next change sensors.""" + device = FritzDeviceClimateMock() + device.nextchange_endperiod = next_changes[0] + device.nextchange_temperature = next_changes[1] + + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + base_name = f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}" + + state = hass.states.get(f"{base_name}_next_scheduled_change_time") + assert state + assert state.state == expected_states[0] + + state = hass.states.get(f"{base_name}_next_scheduled_temperature") + assert state + assert state.state == expected_states[1] + + state = hass.states.get(f"{base_name}_next_scheduled_preset") + assert state + assert state.state == expected_states[2] + + state = hass.states.get(f"{base_name}_current_scheduled_preset") + assert state + assert state.state == expected_states[3] From d12367a68009833ef98e3f5eae34e22e41867079 Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Mon, 23 Sep 2024 04:14:01 -0400 Subject: [PATCH 0961/1309] Add support for new JVC Projector auth method (#126453) --- homeassistant/components/jvc_projector/coordinator.py | 3 ++- homeassistant/components/jvc_projector/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py index 874253b3324..a2ecfa8eb52 100644 --- a/homeassistant/components/jvc_projector/coordinator.py +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any from jvcprojector import ( JvcProjector, @@ -40,7 +41,7 @@ class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): self.device = device self.unique_id = format_mac(device.mac) - async def _async_update_data(self) -> dict[str, str]: + async def _async_update_data(self) -> dict[str, Any]: """Get the latest state data.""" try: state = await self.device.get_state() diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index 5d83e937494..f24ec4df51c 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==1.0.12"] + "requirements": ["pyjvcprojector==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8f3daf0b7f1..80401297119 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1978,7 +1978,7 @@ pyisy==3.1.14 pyitachip2ir==0.0.7 # homeassistant.components.jvc_projector -pyjvcprojector==1.0.12 +pyjvcprojector==1.1.0 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b713455776..df8fa46e1ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1586,7 +1586,7 @@ pyiss==1.0.1 pyisy==3.1.14 # homeassistant.components.jvc_projector -pyjvcprojector==1.0.12 +pyjvcprojector==1.1.0 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 From 5b1e4e069198b0f364ed639f091ad9a8ccd80a56 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 23 Sep 2024 10:15:07 +0200 Subject: [PATCH 0962/1309] Fix Matter Model ID for bridged devices (#126059) Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/adapter.py | 23 ++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index b56c82f8b9a..410f86ef473 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast +from chip.clusters import Objects as clusters from matter_server.client.models.device_types import BridgedDevice from matter_server.common.models import EventType, ServerInfoMessage @@ -194,11 +195,25 @@ class MatterAdapter: identifiers.add((DOMAIN, f"{ID_TYPE_SERIAL}_{basic_info_serial_number}")) serial_number = basic_info_serial_number - model = ( - get_clean_name(basic_info.productName) or device_type.__name__ + # Model name is the human readable name of the model/product name + model_name = ( + # productLabel is optional but preferred (e.g. Hue Bloom) + get_clean_name(basic_info.productLabel) + # alternative is the productName (e.g. LCT001) + or get_clean_name(basic_info.productName) + # if no product name, use the device type name + or device_type.__name__ if device_type else None ) + # Model ID is the non-human readable product ID + # we prefer the matter product ID so we can look it up in Matter DCL + if isinstance(basic_info, clusters.BridgedDeviceBasicInformation): + # On bridged devices, the productID is not available + model_id = None + else: + model_id = str(product_id) if (product_id := basic_info.productID) else None + dr.async_get(self.hass).async_get_or_create( name=name, config_entry_id=self.config_entry.entry_id, @@ -206,8 +221,8 @@ class MatterAdapter: hw_version=basic_info.hardwareVersionString, sw_version=basic_info.softwareVersionString, manufacturer=basic_info.vendorName or endpoint.node.device_info.vendorName, - model=model, - model_id=str(basic_info.productID) if basic_info.productID else None, + model=model_name, + model_id=model_id, serial_number=serial_number, via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None, ) From 9b9edecaac9bbbdb64c6568eccab94c145dfb579 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 11:55:03 +0200 Subject: [PATCH 0963/1309] Move nuki base entity to separate module (#126500) --- homeassistant/components/nuki/__init__.py | 34 +-------------- .../components/nuki/binary_sensor.py | 3 +- homeassistant/components/nuki/entity.py | 42 +++++++++++++++++++ homeassistant/components/nuki/lock.py | 3 +- homeassistant/components/nuki/sensor.py | 3 +- 5 files changed, 49 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/nuki/entity.py diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 2b9035e730f..4f3f56f7f03 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -10,7 +10,6 @@ import logging from aiohttp import web from pynuki import NukiBridge, NukiLock, NukiOpener from pynuki.bridge import InvalidCredentialsException -from pynuki.device import NukiDevice from requests.exceptions import RequestException from homeassistant import exceptions @@ -25,9 +24,8 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, issue_registry as ir -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.helpers.update_coordinator import CoordinatorEntity, UpdateFailed +from homeassistant.helpers.update_coordinator import UpdateFailed from .const import CONF_ENCRYPT_TOKEN, DEFAULT_TIMEOUT, DOMAIN from .coordinator import NukiCoordinator @@ -266,33 +264,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class NukiEntity[_NukiDeviceT: NukiDevice](CoordinatorEntity[NukiCoordinator]): - """An entity using CoordinatorEntity. - - The CoordinatorEntity class provides: - should_poll - async_update - async_added_to_hass - available - - """ - - def __init__(self, coordinator: NukiCoordinator, nuki_device: _NukiDeviceT) -> None: - """Pass coordinator to CoordinatorEntity.""" - super().__init__(coordinator) - self._nuki_device = nuki_device - - @property - def device_info(self) -> DeviceInfo: - """Device info for Nuki entities.""" - return DeviceInfo( - identifiers={(DOMAIN, parse_id(self._nuki_device.nuki_id))}, - name=self._nuki_device.name, - manufacturer="Nuki Home Solutions GmbH", - model=self._nuki_device.device_model_str.capitalize(), - sw_version=self._nuki_device.firmware_version, - via_device=(DOMAIN, self.coordinator.bridge_id), - serial_number=parse_id(self._nuki_device.nuki_id), - ) diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 731b94e6551..8269c43813e 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -14,8 +14,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NukiEntity, NukiEntryData +from . import NukiEntryData from .const import DOMAIN as NUKI_DOMAIN +from .entity import NukiEntity async def async_setup_entry( diff --git a/homeassistant/components/nuki/entity.py b/homeassistant/components/nuki/entity.py new file mode 100644 index 00000000000..2de1827c416 --- /dev/null +++ b/homeassistant/components/nuki/entity.py @@ -0,0 +1,42 @@ +"""The nuki component.""" + +from __future__ import annotations + +from pynuki.device import NukiDevice + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NukiCoordinator +from .helpers import parse_id + + +class NukiEntity[_NukiDeviceT: NukiDevice](CoordinatorEntity[NukiCoordinator]): + """An entity using CoordinatorEntity. + + The CoordinatorEntity class provides: + should_poll + async_update + async_added_to_hass + available + + """ + + def __init__(self, coordinator: NukiCoordinator, nuki_device: _NukiDeviceT) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self._nuki_device = nuki_device + + @property + def device_info(self) -> DeviceInfo: + """Device info for Nuki entities.""" + return DeviceInfo( + identifiers={(DOMAIN, parse_id(self._nuki_device.nuki_id))}, + name=self._nuki_device.name, + manufacturer="Nuki Home Solutions GmbH", + model=self._nuki_device.device_model_str.capitalize(), + sw_version=self._nuki_device.firmware_version, + via_device=(DOMAIN, self.coordinator.bridge_id), + serial_number=parse_id(self._nuki_device.nuki_id), + ) diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 6e1c98bc69c..a2bf7559fc4 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -17,8 +17,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NukiEntity, NukiEntryData +from . import NukiEntryData from .const import ATTR_ENABLE, ATTR_UNLATCH, DOMAIN as NUKI_DOMAIN, ERROR_STATES +from .entity import NukiEntity from .helpers import CannotConnect diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 628783062d3..d89202ac7d7 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -10,8 +10,9 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NukiEntity, NukiEntryData +from . import NukiEntryData from .const import DOMAIN as NUKI_DOMAIN +from .entity import NukiEntity async def async_setup_entry( From 52aec885ea3dc2651c4c095c5432747253e842de Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:32:18 +0200 Subject: [PATCH 0964/1309] Move nibe_heatpump base entity to separate module (#126498) --- .../components/nibe_heatpump/__init__.py | 4 +- .../components/nibe_heatpump/binary_sensor.py | 7 +-- .../components/nibe_heatpump/button.py | 8 +-- .../components/nibe_heatpump/climate.py | 8 +-- .../components/nibe_heatpump/coordinator.py | 49 +----------------- .../components/nibe_heatpump/entity.py | 50 +++++++++++++++++++ .../components/nibe_heatpump/number.py | 7 +-- .../components/nibe_heatpump/select.py | 7 +-- .../components/nibe_heatpump/sensor.py | 7 +-- .../components/nibe_heatpump/switch.py | 7 +-- .../components/nibe_heatpump/water_heater.py | 8 +-- 11 files changed, 86 insertions(+), 76 deletions(-) create mode 100644 homeassistant/components/nibe_heatpump/entity.py diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index fbb49351e0e..b3ceb00a834 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -30,7 +30,7 @@ from .const import ( CONF_WORD_SWAP, DOMAIN, ) -from .coordinator import Coordinator +from .coordinator import CoilCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -81,7 +81,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) ) - coordinator = Coordinator(hass, heatpump, connection) + coordinator = CoilCoordinator(hass, heatpump, connection) data = hass.data.setdefault(DOMAIN, {}) data[entry.entry_id] = coordinator diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 035a4a23a08..0cb16bf4485 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import CoilEntity, Coordinator +from .coordinator import CoilCoordinator +from .entity import CoilEntity async def async_setup_entry( @@ -21,7 +22,7 @@ async def async_setup_entry( ) -> None: """Set up platform.""" - coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( BinarySensor(coordinator, coil) @@ -35,7 +36,7 @@ class BinarySensor(CoilEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, coordinator: Coordinator, coil: Coil) -> None: + def __init__(self, coordinator: CoilCoordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py index 0c3122805e1..df8ceef6479 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER -from .coordinator import Coordinator +from .coordinator import CoilCoordinator async def async_setup_entry( @@ -23,7 +23,7 @@ async def async_setup_entry( ) -> None: """Set up platform.""" - coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] def reset_buttons(): if unit := UNIT_COILGROUPS.get(coordinator.series, {}).get("main"): @@ -35,13 +35,13 @@ async def async_setup_entry( async_add_entities(reset_buttons()) -class NibeAlarmResetButton(CoordinatorEntity[Coordinator], ButtonEntity): +class NibeAlarmResetButton(CoordinatorEntity[CoilCoordinator], ButtonEntity): """Sensor entity.""" _attr_has_entity_name = True _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, coordinator: Coordinator, unit: UnitCoilGroup) -> None: + def __init__(self, coordinator: CoilCoordinator, unit: UnitCoilGroup) -> None: """Initialize entity.""" self._reset_coil = coordinator.heatpump.get_coil_by_address(unit.alarm_reset) self._alarm_coil = coordinator.heatpump.get_coil_by_address(unit.alarm) diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index d933d5a5ab0..f89d6ec29a9 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -38,7 +38,7 @@ from .const import ( VALUES_PRIORITY_COOLING, VALUES_PRIORITY_HEATING, ) -from .coordinator import Coordinator +from .coordinator import CoilCoordinator async def async_setup_entry( @@ -48,7 +48,7 @@ async def async_setup_entry( ) -> None: """Set up platform.""" - coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] main_unit = UNIT_COILGROUPS[coordinator.series]["main"] @@ -62,7 +62,7 @@ async def async_setup_entry( async_add_entities(climate_systems()) -class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): +class NibeClimateEntity(CoordinatorEntity[CoilCoordinator], ClimateEntity): """Climate entity.""" _attr_entity_category = None @@ -78,7 +78,7 @@ class NibeClimateEntity(CoordinatorEntity[Coordinator], ClimateEntity): def __init__( self, - coordinator: Coordinator, + coordinator: CoilCoordinator, key: str, unit: UnitCoilGroup, climate: ClimateCoilGroup, diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index 0f1fabe4249..2c19703549a 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -17,12 +17,7 @@ from nibe.heatpump import HeatPump, Series from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER @@ -68,7 +63,7 @@ class ContextCoordinator[_DataTypeT, _ContextTypeT](DataUpdateCoordinator[_DataT return release_update -class Coordinator(ContextCoordinator[dict[int, CoilData], int]): +class CoilCoordinator(ContextCoordinator[dict[int, CoilData], int]): """Update coordinator for nibe heat pumps.""" config_entry: ConfigEntry @@ -188,43 +183,3 @@ class Coordinator(ContextCoordinator[dict[int, CoilData], int]): self.task.cancel() await asyncio.wait((self.task,)) await self.connection.stop() - - -class CoilEntity(CoordinatorEntity[Coordinator]): - """Base for coil based entities.""" - - _attr_has_entity_name = True - _attr_entity_registry_enabled_default = False - - def __init__( - self, coordinator: Coordinator, coil: Coil, entity_format: str - ) -> None: - """Initialize base entity.""" - super().__init__(coordinator, {coil.address}) - self.entity_id = async_generate_entity_id( - entity_format, coil.name, hass=coordinator.hass - ) - self._attr_name = coil.title - self._attr_unique_id = f"{coordinator.unique_id}-{coil.address}" - self._attr_device_info = coordinator.device_info - self._coil = coil - - @property - def available(self) -> bool: - """Return if entity is available.""" - return self.coordinator.last_update_success and self._coil.address in ( - self.coordinator.data or {} - ) - - def _async_read_coil(self, data: CoilData): - """Update state of entity based on coil data.""" - - async def _async_write_coil(self, value: float | str): - """Write coil and update state.""" - await self.coordinator.async_write_coil(self._coil, value) - - def _handle_coordinator_update(self) -> None: - data = self.coordinator.data.get(self._coil.address) - if data is not None: - self._async_read_coil(data) - self.async_write_ha_state() diff --git a/homeassistant/components/nibe_heatpump/entity.py b/homeassistant/components/nibe_heatpump/entity.py new file mode 100644 index 00000000000..3cbc8af32a3 --- /dev/null +++ b/homeassistant/components/nibe_heatpump/entity.py @@ -0,0 +1,50 @@ +"""The Nibe Heat Pump coordinator.""" + +from __future__ import annotations + +from nibe.coil import Coil, CoilData + +from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import CoilCoordinator + + +class CoilEntity(CoordinatorEntity[CoilCoordinator]): + """Base for coil based entities.""" + + _attr_has_entity_name = True + _attr_entity_registry_enabled_default = False + + def __init__( + self, coordinator: CoilCoordinator, coil: Coil, entity_format: str + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator, {coil.address}) + self.entity_id = async_generate_entity_id( + entity_format, coil.name, hass=coordinator.hass + ) + self._attr_name = coil.title + self._attr_unique_id = f"{coordinator.unique_id}-{coil.address}" + self._attr_device_info = coordinator.device_info + self._coil = coil + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and self._coil.address in ( + self.coordinator.data or {} + ) + + def _async_read_coil(self, data: CoilData): + """Update state of entity based on coil data.""" + + async def _async_write_coil(self, value: float | str): + """Write coil and update state.""" + await self.coordinator.async_write_coil(self._coil, value) + + def _handle_coordinator_update(self) -> None: + data = self.coordinator.data.get(self._coil.address) + if data is not None: + self._async_read_coil(data) + self.async_write_ha_state() diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 509f3364fee..cb379139eed 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import CoilEntity, Coordinator +from .coordinator import CoilCoordinator +from .entity import CoilEntity async def async_setup_entry( @@ -21,7 +22,7 @@ async def async_setup_entry( ) -> None: """Set up platform.""" - coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( Number(coordinator, coil) @@ -44,7 +45,7 @@ class Number(CoilEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG - def __init__(self, coordinator: Coordinator, coil: Coil) -> None: + def __init__(self, coordinator: CoilCoordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) if coil.min is None or coil.max is None: diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py index 07c958885b8..3aecff94649 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -11,7 +11,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import CoilEntity, Coordinator +from .coordinator import CoilCoordinator +from .entity import CoilEntity async def async_setup_entry( @@ -21,7 +22,7 @@ async def async_setup_entry( ) -> None: """Set up platform.""" - coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( Select(coordinator, coil) @@ -35,7 +36,7 @@ class Select(CoilEntity, SelectEntity): _attr_entity_category = EntityCategory.CONFIG - def __init__(self, coordinator: Coordinator, coil: Coil) -> None: + def __init__(self, coordinator: CoilCoordinator, coil: Coil) -> None: """Initialize entity.""" assert coil.mappings super().__init__(coordinator, coil, ENTITY_ID_FORMAT) diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index c6bac0323b9..d34fed50977 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -26,7 +26,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import CoilEntity, Coordinator +from .coordinator import CoilCoordinator +from .entity import CoilEntity UNIT_DESCRIPTIONS = { "°C": SensorEntityDescription( @@ -130,7 +131,7 @@ async def async_setup_entry( ) -> None: """Set up platform.""" - coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( Sensor(coordinator, coil, UNIT_DESCRIPTIONS.get(coil.unit)) @@ -144,7 +145,7 @@ class Sensor(CoilEntity, SensorEntity): def __init__( self, - coordinator: Coordinator, + coordinator: CoilCoordinator, coil: Coil, entity_description: SensorEntityDescription | None, ) -> None: diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 594a8078b76..72b7c20c7b3 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -13,7 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import CoilEntity, Coordinator +from .coordinator import CoilCoordinator +from .entity import CoilEntity async def async_setup_entry( @@ -23,7 +24,7 @@ async def async_setup_entry( ) -> None: """Set up platform.""" - coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( Switch(coordinator, coil) @@ -37,7 +38,7 @@ class Switch(CoilEntity, SwitchEntity): _attr_entity_category = EntityCategory.CONFIG - def __init__(self, coordinator: Coordinator, coil: Coil) -> None: + def __init__(self, coordinator: CoilCoordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py index c60f5b6e3b2..f53df596d27 100644 --- a/homeassistant/components/nibe_heatpump/water_heater.py +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -26,7 +26,7 @@ from .const import ( VALUES_TEMPORARY_LUX_INACTIVE, VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE, ) -from .coordinator import Coordinator +from .coordinator import CoilCoordinator async def async_setup_entry( @@ -36,7 +36,7 @@ async def async_setup_entry( ) -> None: """Set up platform.""" - coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] def water_heaters(): for key, group in WATER_HEATER_COILGROUPS.get(coordinator.series, ()).items(): @@ -48,7 +48,7 @@ async def async_setup_entry( async_add_entities(water_heaters()) -class WaterHeater(CoordinatorEntity[Coordinator], WaterHeaterEntity): +class WaterHeater(CoordinatorEntity[CoilCoordinator], WaterHeaterEntity): """Sensor entity.""" _attr_entity_category = None @@ -59,7 +59,7 @@ class WaterHeater(CoordinatorEntity[Coordinator], WaterHeaterEntity): def __init__( self, - coordinator: Coordinator, + coordinator: CoilCoordinator, key: str, desc: WaterHeaterCoilGroup, ) -> None: From a75a513531d668c10984d44183ac1623578a30e6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:42:00 +0200 Subject: [PATCH 0965/1309] Move radarr base entity to separate module (#126514) --- homeassistant/components/radarr/__init__.py | 47 +------------------ .../components/radarr/binary_sensor.py | 3 +- homeassistant/components/radarr/calendar.py | 3 +- homeassistant/components/radarr/entity.py | 46 ++++++++++++++++++ homeassistant/components/radarr/sensor.py | 3 +- 5 files changed, 53 insertions(+), 49 deletions(-) create mode 100644 homeassistant/components/radarr/entity.py diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 1023bf10659..5c225697f98 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -3,26 +3,15 @@ from __future__ import annotations from dataclasses import dataclass, fields -from typing import cast from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_SW_VERSION, - CONF_API_KEY, - CONF_URL, - CONF_VERIFY_SSL, - Platform, -) +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( CalendarUpdateCoordinator, DiskSpaceDataUpdateCoordinator, @@ -31,7 +20,6 @@ from .coordinator import ( QueueDataUpdateCoordinator, RadarrDataUpdateCoordinator, StatusDataUpdateCoordinator, - T, ) PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] @@ -89,36 +77,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: RadarrConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: RadarrConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class RadarrEntity(CoordinatorEntity[RadarrDataUpdateCoordinator[T]]): - """Defines a base Radarr entity.""" - - _attr_has_entity_name = True - coordinator: RadarrDataUpdateCoordinator[T] - - def __init__( - self, - coordinator: RadarrDataUpdateCoordinator[T], - description: EntityDescription, - ) -> None: - """Create Radarr entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return device information about the Radarr instance.""" - device_info = DeviceInfo( - configuration_url=self.coordinator.host_configuration.url, - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, - manufacturer=DEFAULT_NAME, - name=self.coordinator.config_entry.title, - ) - if isinstance(self.coordinator, StatusDataUpdateCoordinator): - device_info[ATTR_SW_VERSION] = cast( - StatusDataUpdateCoordinator, self.coordinator - ).data.version - return device_info diff --git a/homeassistant/components/radarr/binary_sensor.py b/homeassistant/components/radarr/binary_sensor.py index 6c0468cff58..953c7dead18 100644 --- a/homeassistant/components/radarr/binary_sensor.py +++ b/homeassistant/components/radarr/binary_sensor.py @@ -13,8 +13,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RadarrConfigEntry, RadarrEntity +from . import RadarrConfigEntry from .const import HEALTH_ISSUES +from .entity import RadarrEntity BINARY_SENSOR_TYPE = BinarySensorEntityDescription( key="health", diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py index 4f866123a1a..c741c178862 100644 --- a/homeassistant/components/radarr/calendar.py +++ b/homeassistant/components/radarr/calendar.py @@ -9,8 +9,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RadarrConfigEntry, RadarrEntity +from . import RadarrConfigEntry from .coordinator import CalendarUpdateCoordinator, RadarrEvent +from .entity import RadarrEntity CALENDAR_TYPE = EntityDescription( key="calendar", diff --git a/homeassistant/components/radarr/entity.py b/homeassistant/components/radarr/entity.py new file mode 100644 index 00000000000..bc2c17821cc --- /dev/null +++ b/homeassistant/components/radarr/entity.py @@ -0,0 +1,46 @@ +"""The Radarr component.""" + +from __future__ import annotations + +from typing import cast + +from homeassistant.const import ATTR_SW_VERSION +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import RadarrDataUpdateCoordinator, StatusDataUpdateCoordinator, T + + +class RadarrEntity(CoordinatorEntity[RadarrDataUpdateCoordinator[T]]): + """Defines a base Radarr entity.""" + + _attr_has_entity_name = True + coordinator: RadarrDataUpdateCoordinator[T] + + def __init__( + self, + coordinator: RadarrDataUpdateCoordinator[T], + description: EntityDescription, + ) -> None: + """Create Radarr entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about the Radarr instance.""" + device_info = DeviceInfo( + configuration_url=self.coordinator.host_configuration.url, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + manufacturer=DEFAULT_NAME, + name=self.coordinator.config_entry.title, + ) + if isinstance(self.coordinator, StatusDataUpdateCoordinator): + device_info[ATTR_SW_VERSION] = cast( + StatusDataUpdateCoordinator, self.coordinator + ).data.version + return device_info diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 441c44de781..df1a0686e00 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -19,8 +19,9 @@ from homeassistant.const import EntityCategory, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RadarrConfigEntry, RadarrEntity +from . import RadarrConfigEntry from .coordinator import RadarrDataUpdateCoordinator, T +from .entity import RadarrEntity def get_space(data: list[Diskspace], name: str) -> str: From c8e3e2ce1b908a1c0d362750197d03389f3111c3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:42:22 +0200 Subject: [PATCH 0966/1309] Move rainmachine base entity to separate module (#126513) --- .../components/rainmachine/__init__.py | 65 +------------- .../components/rainmachine/binary_sensor.py | 4 +- .../components/rainmachine/button.py | 4 +- .../components/rainmachine/entity.py | 84 +++++++++++++++++++ homeassistant/components/rainmachine/model.py | 12 --- .../components/rainmachine/select.py | 4 +- .../components/rainmachine/sensor.py | 4 +- .../components/rainmachine/switch.py | 9 +- .../components/rainmachine/update.py | 4 +- 9 files changed, 97 insertions(+), 93 deletions(-) create mode 100644 homeassistant/components/rainmachine/entity.py delete mode 100644 homeassistant/components/rainmachine/model.py diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index f2e97aa7c24..4d486c9c6aa 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -31,8 +31,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity, UpdateFailed +from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util.dt import as_timestamp, utcnow from homeassistant.util.network import is_ip_address @@ -54,7 +53,6 @@ from .const import ( LOGGER, ) from .coordinator import RainMachineDataUpdateCoordinator -from .model import RainMachineEntityDescription DEFAULT_SSL = True @@ -528,64 +526,3 @@ async def async_reload_entry( ) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -class RainMachineEntity(CoordinatorEntity[RainMachineDataUpdateCoordinator]): - """Define a generic RainMachine entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - entry: RainMachineConfigEntry, - data: RainMachineData, - description: RainMachineEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(data.coordinators[description.api_category]) - - self._attr_extra_state_attributes = {} - self._attr_unique_id = f"{data.controller.mac}_{description.key}" - self._entry = entry - self._data = data - self._version_coordinator = data.coordinators[DATA_API_VERSIONS] - self.entity_description = description - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this controller.""" - return DeviceInfo( - identifiers={(DOMAIN, self._data.controller.mac)}, - configuration_url=( - f"https://{self._entry.data[CONF_IP_ADDRESS]}:" - f"{self._entry.data[CONF_PORT]}" - ), - connections={(dr.CONNECTION_NETWORK_MAC, self._data.controller.mac)}, - name=self._data.controller.name.capitalize(), - manufacturer="RainMachine", - model=( - f"Version {self._version_coordinator.data['hwVer']} " - f"(API: {self._version_coordinator.data['apiVer']})" - ), - sw_version=self._version_coordinator.data["swVer"], - ) - - @callback - def _handle_coordinator_update(self) -> None: - """Respond to a DataUpdateCoordinator update.""" - self.update_from_latest_data() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - self.async_on_remove( - self._version_coordinator.async_add_listener( - self._handle_coordinator_update, self.coordinator_context - ) - ) - self.update_from_latest_data() - - @callback - def update_from_latest_data(self) -> None: - """Update the state.""" diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 574f458ec47..4ba9b58d596 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -11,9 +11,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RainMachineConfigEntry, RainMachineEntity +from . import RainMachineConfigEntry from .const import DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_CURRENT -from .model import RainMachineEntityDescription +from .entity import RainMachineEntity, RainMachineEntityDescription from .util import ( EntityDomainReplacementStrategy, async_finish_entity_domain_replacements, diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index 7087e5e5b8e..2f68c6a8a9c 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -19,9 +19,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RainMachineConfigEntry, RainMachineEntity +from . import RainMachineConfigEntry from .const import DATA_PROVISION_SETTINGS -from .model import RainMachineEntityDescription +from .entity import RainMachineEntity, RainMachineEntityDescription @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/rainmachine/entity.py b/homeassistant/components/rainmachine/entity.py new file mode 100644 index 00000000000..1289d3e808e --- /dev/null +++ b/homeassistant/components/rainmachine/entity.py @@ -0,0 +1,84 @@ +"""Support for RainMachine devices.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import RainMachineConfigEntry, RainMachineData +from .const import DATA_API_VERSIONS, DOMAIN +from .coordinator import RainMachineDataUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class RainMachineEntityDescription(EntityDescription): + """Describe a RainMachine entity.""" + + api_category: str + + +class RainMachineEntity(CoordinatorEntity[RainMachineDataUpdateCoordinator]): + """Define a generic RainMachine entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + entry: RainMachineConfigEntry, + data: RainMachineData, + description: RainMachineEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(data.coordinators[description.api_category]) + + self._attr_extra_state_attributes = {} + self._attr_unique_id = f"{data.controller.mac}_{description.key}" + self._entry = entry + self._data = data + self._version_coordinator = data.coordinators[DATA_API_VERSIONS] + self.entity_description = description + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this controller.""" + return DeviceInfo( + identifiers={(DOMAIN, self._data.controller.mac)}, + configuration_url=( + f"https://{self._entry.data[CONF_IP_ADDRESS]}:" + f"{self._entry.data[CONF_PORT]}" + ), + connections={(dr.CONNECTION_NETWORK_MAC, self._data.controller.mac)}, + name=self._data.controller.name.capitalize(), + manufacturer="RainMachine", + model=( + f"Version {self._version_coordinator.data['hwVer']} " + f"(API: {self._version_coordinator.data['apiVer']})" + ), + sw_version=self._version_coordinator.data["swVer"], + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Respond to a DataUpdateCoordinator update.""" + self.update_from_latest_data() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self._version_coordinator.async_add_listener( + self._handle_coordinator_update, self.coordinator_context + ) + ) + self.update_from_latest_data() + + @callback + def update_from_latest_data(self) -> None: + """Update the state.""" diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py deleted file mode 100644 index ee5567112cf..00000000000 --- a/homeassistant/components/rainmachine/model.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Define RainMachine data models.""" - -from dataclasses import dataclass - -from homeassistant.helpers.entity import EntityDescription - - -@dataclass(frozen=True, kw_only=True) -class RainMachineEntityDescription(EntityDescription): - """Describe a RainMachine entity.""" - - api_category: str diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index 73de33cc8ed..1d9225a5bb2 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -14,9 +14,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem -from . import RainMachineConfigEntry, RainMachineData, RainMachineEntity +from . import RainMachineConfigEntry, RainMachineData from .const import DATA_RESTRICTIONS_UNIVERSAL -from .model import RainMachineEntityDescription +from .entity import RainMachineEntity, RainMachineEntityDescription from .util import key_exists diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 5363000a8ac..64f9ecf3990 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -20,9 +20,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utc_from_timestamp, utcnow -from . import RainMachineConfigEntry, RainMachineData, RainMachineEntity +from . import RainMachineConfigEntry, RainMachineData from .const import DATA_PROGRAMS, DATA_PROVISION_SETTINGS, DATA_ZONES -from .model import RainMachineEntityDescription +from .entity import RainMachineEntity, RainMachineEntityDescription from .util import ( RUN_STATE_MAP, EntityDomainReplacementStrategy, diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 8368db47d61..2a065f18976 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -20,12 +20,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType -from . import ( - RainMachineConfigEntry, - RainMachineData, - RainMachineEntity, - async_update_programs_and_zones, -) +from . import RainMachineConfigEntry, RainMachineData, async_update_programs_and_zones from .const import ( CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, @@ -37,7 +32,7 @@ from .const import ( DATA_ZONES, DEFAULT_ZONE_RUN, ) -from .model import RainMachineEntityDescription +from .entity import RainMachineEntity, RainMachineEntityDescription from .util import RUN_STATE_MAP, key_exists ATTR_ACTIVITY_TYPE = "activity_type" diff --git a/homeassistant/components/rainmachine/update.py b/homeassistant/components/rainmachine/update.py index a7c11061718..dbb91b70c85 100644 --- a/homeassistant/components/rainmachine/update.py +++ b/homeassistant/components/rainmachine/update.py @@ -16,9 +16,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RainMachineConfigEntry, RainMachineEntity +from . import RainMachineConfigEntry from .const import DATA_MACHINE_FIRMWARE_UPDATE_STATUS -from .model import RainMachineEntityDescription +from .entity import RainMachineEntity, RainMachineEntityDescription class UpdateStates(Enum): From 78d80fefc5d40b006a764e7116e4d79ae2e2928f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:43:00 +0200 Subject: [PATCH 0967/1309] Move purpleair base entity to separate module (#126511) --- .../components/purpleair/__init__.py | 64 +----------------- homeassistant/components/purpleair/entity.py | 66 +++++++++++++++++++ homeassistant/components/purpleair/sensor.py | 2 +- 3 files changed, 68 insertions(+), 64 deletions(-) create mode 100644 homeassistant/components/purpleair/entity.py diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index fb86612597a..2d4022946b2 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -2,21 +2,9 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Any - -from aiopurpleair.models.sensors import SensorModel - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_SHOW_ON_MAP, - Platform, -) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import PurpleAirDataUpdateCoordinator @@ -48,53 +36,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): - """Define a base PurpleAir entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: PurpleAirDataUpdateCoordinator, - entry: ConfigEntry, - sensor_index: int, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - - self._sensor_index = sensor_index - - self._attr_device_info = DeviceInfo( - configuration_url=self.coordinator.async_get_map_url(sensor_index), - hw_version=self.sensor_data.hardware, - identifiers={(DOMAIN, str(sensor_index))}, - manufacturer="PurpleAir, Inc.", - model=self.sensor_data.model, - name=self.sensor_data.name, - sw_version=self.sensor_data.firmware_version, - ) - self._entry = entry - - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return entity specific state attributes.""" - attrs = {} - - # Displaying the geography on the map relies upon putting the latitude/longitude - # in the entity attributes with "latitude" and "longitude" as the keys. - # Conversely, we can hide the location on the map by using other keys, like - # "lati" and "long": - if self._entry.options.get(CONF_SHOW_ON_MAP): - attrs[ATTR_LATITUDE] = self.sensor_data.latitude - attrs[ATTR_LONGITUDE] = self.sensor_data.longitude - else: - attrs["lati"] = self.sensor_data.latitude - attrs["long"] = self.sensor_data.longitude - return attrs - - @property - def sensor_data(self) -> SensorModel: - """Define a property to get this entity's SensorModel object.""" - return self.coordinator.data.data[self._sensor_index] diff --git a/homeassistant/components/purpleair/entity.py b/homeassistant/components/purpleair/entity.py new file mode 100644 index 00000000000..4f7be1874ed --- /dev/null +++ b/homeassistant/components/purpleair/entity.py @@ -0,0 +1,66 @@ +"""The PurpleAir integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aiopurpleair.models.sensors import SensorModel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PurpleAirDataUpdateCoordinator + + +class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): + """Define a base PurpleAir entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PurpleAirDataUpdateCoordinator, + entry: ConfigEntry, + sensor_index: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._sensor_index = sensor_index + + self._attr_device_info = DeviceInfo( + configuration_url=self.coordinator.async_get_map_url(sensor_index), + hw_version=self.sensor_data.hardware, + identifiers={(DOMAIN, str(sensor_index))}, + manufacturer="PurpleAir, Inc.", + model=self.sensor_data.model, + name=self.sensor_data.name, + sw_version=self.sensor_data.firmware_version, + ) + self._entry = entry + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return entity specific state attributes.""" + attrs = {} + + # Displaying the geography on the map relies upon putting the latitude/longitude + # in the entity attributes with "latitude" and "longitude" as the keys. + # Conversely, we can hide the location on the map by using other keys, like + # "lati" and "long": + if self._entry.options.get(CONF_SHOW_ON_MAP): + attrs[ATTR_LATITUDE] = self.sensor_data.latitude + attrs[ATTR_LONGITUDE] = self.sensor_data.longitude + else: + attrs["lati"] = self.sensor_data.latitude + attrs["long"] = self.sensor_data.longitude + return attrs + + @property + def sensor_data(self) -> SensorModel: + """Define a property to get this entity's SensorModel object.""" + return self.coordinator.data.data[self._sensor_index] diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index d1db77c2c31..9fb0249a360 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -27,9 +27,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PurpleAirEntity from .const import CONF_SENSOR_INDICES, DOMAIN from .coordinator import PurpleAirDataUpdateCoordinator +from .entity import PurpleAirEntity CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}" From f7543cd0ba6da0a782575b4e6c6398dc42658752 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:43:48 +0200 Subject: [PATCH 0968/1309] Move pi_hole base entity to separate module (#126509) --- homeassistant/components/pi_hole/__init__.py | 39 +--------------- .../components/pi_hole/binary_sensor.py | 3 +- homeassistant/components/pi_hole/entity.py | 45 +++++++++++++++++++ homeassistant/components/pi_hole/sensor.py | 3 +- homeassistant/components/pi_hole/switch.py | 3 +- homeassistant/components/pi_hole/update.py | 3 +- 6 files changed, 54 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/pi_hole/entity.py diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index bf314e96dec..64e73a20c59 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -22,12 +22,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_STATISTICS_ONLY, DOMAIN, MIN_TIME_BETWEEN_UPDATES @@ -140,35 +135,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Pi-hole entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class PiHoleEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): - """Representation of a Pi-hole entity.""" - - def __init__( - self, - api: Hole, - coordinator: DataUpdateCoordinator[None], - name: str, - server_unique_id: str, - ) -> None: - """Initialize a Pi-hole entity.""" - super().__init__(coordinator) - self.api = api - self._name = name - self._server_unique_id = server_unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device information of the entity.""" - if self.api.tls: - config_url = f"https://{self.api.host}/{self.api.location}" - else: - config_url = f"http://{self.api.host}/{self.api.location}" - - return DeviceInfo( - identifiers={(DOMAIN, self._server_unique_id)}, - name=self._name, - manufacturer="Pi-hole", - configuration_url=config_url, - ) diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 001a2ebcee8..5e3ce560ab4 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -17,7 +17,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import PiHoleConfigEntry, PiHoleEntity +from . import PiHoleConfigEntry +from .entity import PiHoleEntity @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/pi_hole/entity.py b/homeassistant/components/pi_hole/entity.py new file mode 100644 index 00000000000..0f5c6039232 --- /dev/null +++ b/homeassistant/components/pi_hole/entity.py @@ -0,0 +1,45 @@ +"""The pi_hole component.""" + +from __future__ import annotations + +from hole import Hole + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +class PiHoleEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): + """Representation of a Pi-hole entity.""" + + def __init__( + self, + api: Hole, + coordinator: DataUpdateCoordinator[None], + name: str, + server_unique_id: str, + ) -> None: + """Initialize a Pi-hole entity.""" + super().__init__(coordinator) + self.api = api + self._name = name + self._server_unique_id = server_unique_id + + @property + def device_info(self) -> DeviceInfo: + """Return the device information of the entity.""" + if self.api.tls: + config_url = f"https://{self.api.host}/{self.api.location}" + else: + config_url = f"http://{self.api.host}/{self.api.location}" + + return DeviceInfo( + identifiers={(DOMAIN, self._server_unique_id)}, + name=self._name, + manufacturer="Pi-hole", + configuration_url=config_url, + ) diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 14ad3ac82dd..503883e9326 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -11,7 +11,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import PiHoleConfigEntry, PiHoleEntity +from . import PiHoleConfigEntry +from .entity import PiHoleEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py index 83ed3e6d787..805ba479a9e 100644 --- a/homeassistant/components/pi_hole/switch.py +++ b/homeassistant/components/pi_hole/switch.py @@ -14,8 +14,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PiHoleConfigEntry, PiHoleEntity +from . import PiHoleConfigEntry from .const import SERVICE_DISABLE, SERVICE_DISABLE_ATTR_DURATION +from .entity import PiHoleEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index c1a435f628c..510f5d1dc19 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -13,7 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import PiHoleConfigEntry, PiHoleEntity +from . import PiHoleConfigEntry +from .entity import PiHoleEntity @dataclass(frozen=True) From d67a1993d0c29c84114eee19cfd1e79efc03cefe Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:44:24 +0200 Subject: [PATCH 0969/1309] Move ovo_energy base entity to separate module (#126507) --- .../components/ovo_energy/__init__.py | 36 +--------------- homeassistant/components/ovo_energy/entity.py | 43 +++++++++++++++++++ homeassistant/components/ovo_energy/sensor.py | 2 +- 3 files changed, 45 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/ovo_energy/entity.py diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index d207f3161f4..7cce25d08d5 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -15,12 +15,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import CONF_ACCOUNT, DATA_CLIENT, DATA_COORDINATOR, DOMAIN @@ -102,32 +97,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: del hass.data[DOMAIN][entry.entry_id] return unload_ok - - -class OVOEnergyEntity(CoordinatorEntity[DataUpdateCoordinator[OVODailyUsage]]): - """Defines a base OVO Energy entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[OVODailyUsage], - client: OVOEnergy, - ) -> None: - """Initialize the OVO Energy entity.""" - super().__init__(coordinator) - self._client = client - - -class OVOEnergyDeviceEntity(OVOEnergyEntity): - """Defines a OVO Energy device entity.""" - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this OVO Energy instance.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._client.account_id)}, - manufacturer="OVO Energy", - name=self._client.username, - ) diff --git a/homeassistant/components/ovo_energy/entity.py b/homeassistant/components/ovo_energy/entity.py new file mode 100644 index 00000000000..ed8a24b0542 --- /dev/null +++ b/homeassistant/components/ovo_energy/entity.py @@ -0,0 +1,43 @@ +"""Support for OVO Energy.""" + +from __future__ import annotations + +from ovoenergy import OVOEnergy +from ovoenergy.models import OVODailyUsage + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +class OVOEnergyEntity(CoordinatorEntity[DataUpdateCoordinator[OVODailyUsage]]): + """Defines a base OVO Energy entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator[OVODailyUsage], + client: OVOEnergy, + ) -> None: + """Initialize the OVO Energy entity.""" + super().__init__(coordinator) + self._client = client + + +class OVOEnergyDeviceEntity(OVOEnergyEntity): + """Defines a OVO Energy device entity.""" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this OVO Energy instance.""" + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._client.account_id)}, + manufacturer="OVO Energy", + name=self._client.username, + ) diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 3012a130a1a..8cada86da34 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -24,8 +24,8 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from . import OVOEnergyDeviceEntity from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN +from .entity import OVOEnergyDeviceEntity SCAN_INTERVAL = timedelta(seconds=300) PARALLEL_UPDATES = 4 From 61de70c1dff867275a14126f1fa842b8214e0efa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:44:42 +0200 Subject: [PATCH 0970/1309] Move openuv base entity to separate module (#126506) --- homeassistant/components/openuv/__init__.py | 26 --------------- .../components/openuv/binary_sensor.py | 2 +- homeassistant/components/openuv/entity.py | 33 +++++++++++++++++++ homeassistant/components/openuv/sensor.py | 2 +- 4 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/openuv/entity.py diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index b7c13ad49f1..19e63747e4b 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -19,9 +19,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( CONF_FROM_WINDOW, @@ -110,26 +107,3 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Migration to version %s successful", version) return True - - -class OpenUvEntity(CoordinatorEntity): - """Define a generic OpenUV entity.""" - - _attr_has_entity_name = True - - def __init__( - self, coordinator: OpenUvCoordinator, description: EntityDescription - ) -> None: - """Initialize.""" - super().__init__(coordinator) - - self._attr_extra_state_attributes = {} - self._attr_unique_id = ( - f"{coordinator.latitude}_{coordinator.longitude}_{description.key}" - ) - self.entity_description = description - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{coordinator.latitude}_{coordinator.longitude}")}, - name="OpenUV", - entry_type=DeviceEntryType.SERVICE, - ) diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 61751e2a0b6..018d91710df 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime, utcnow -from . import OpenUvEntity from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW from .coordinator import OpenUvCoordinator +from .entity import OpenUvEntity ATTR_PROTECTION_WINDOW_ENDING_TIME = "end_time" ATTR_PROTECTION_WINDOW_ENDING_UV = "end_uv" diff --git a/homeassistant/components/openuv/entity.py b/homeassistant/components/openuv/entity.py new file mode 100644 index 00000000000..f3015815bf1 --- /dev/null +++ b/homeassistant/components/openuv/entity.py @@ -0,0 +1,33 @@ +"""Support for UV data from openuv.io.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OpenUvCoordinator + + +class OpenUvEntity(CoordinatorEntity): + """Define a generic OpenUV entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: OpenUvCoordinator, description: EntityDescription + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_extra_state_attributes = {} + self._attr_unique_id = ( + f"{coordinator.latitude}_{coordinator.longitude}_{description.key}" + ) + self.entity_description = description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.latitude}_{coordinator.longitude}")}, + name="OpenUV", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index a79bc410715..742017be639 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -18,7 +18,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime -from . import OpenUvEntity from .const import ( DATA_UV, DOMAIN, @@ -34,6 +33,7 @@ from .const import ( TYPE_SAFE_EXPOSURE_TIME_6, ) from .coordinator import OpenUvCoordinator +from .entity import OpenUvEntity ATTR_MAX_UV_TIME = "time" From 0163f3d57eece21535019c88e29d9cbe8119978d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:45:00 +0200 Subject: [PATCH 0971/1309] Move omnilogic base entity to separate module (#126505) --- homeassistant/components/omnilogic/common.py | 92 ------------------- homeassistant/components/omnilogic/entity.py | 93 ++++++++++++++++++++ homeassistant/components/omnilogic/sensor.py | 3 +- homeassistant/components/omnilogic/switch.py | 3 +- 4 files changed, 97 insertions(+), 94 deletions(-) create mode 100644 homeassistant/components/omnilogic/entity.py diff --git a/homeassistant/components/omnilogic/common.py b/homeassistant/components/omnilogic/common.py index 13b9803409c..4e3e2962d03 100644 --- a/homeassistant/components/omnilogic/common.py +++ b/homeassistant/components/omnilogic/common.py @@ -1,97 +1,5 @@ """Common classes and elements for Omnilogic Integration.""" -from typing import Any - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import OmniLogicUpdateCoordinator - - -class OmniLogicEntity(CoordinatorEntity[OmniLogicUpdateCoordinator]): - """Defines the base OmniLogic entity.""" - - def __init__( - self, - coordinator: OmniLogicUpdateCoordinator, - kind: str, - name: str, - item_id: tuple, - icon: str, - ) -> None: - """Initialize the OmniLogic Entity.""" - super().__init__(coordinator) - - bow_id = None - entity_data = coordinator.data[item_id] - - backyard_id = item_id[:2] - if len(item_id) == 6: - bow_id = item_id[:4] - - msp_system_id = coordinator.data[backyard_id]["systemId"] - entity_friendly_name = f"{coordinator.data[backyard_id]['BackyardName']} " - unique_id = f"{msp_system_id}" - - if bow_id is not None: - unique_id = f"{unique_id}_{coordinator.data[bow_id]['systemId']}" - - if kind != "Heaters": - entity_friendly_name = ( - f"{entity_friendly_name}{coordinator.data[bow_id]['Name']} " - ) - else: - entity_friendly_name = f"{entity_friendly_name}{coordinator.data[bow_id]['Operation']['VirtualHeater']['Name']} " - - unique_id = f"{unique_id}_{coordinator.data[item_id]['systemId']}_{kind}" - - if entity_data.get("Name") is not None: - entity_friendly_name = f"{entity_friendly_name} {entity_data['Name']}" - - entity_friendly_name = f"{entity_friendly_name} {name}" - - unique_id = unique_id.replace(" ", "_") - - self._kind = kind - self._name = entity_friendly_name - self._unique_id = unique_id - self._item_id = item_id - self._icon = icon - self._attrs: dict[str, Any] = {} - self._msp_system_id = msp_system_id - self._backyard_name = coordinator.data[backyard_id]["BackyardName"] - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self): - """Return the icon for the entity.""" - return self._icon - - @property - def extra_state_attributes(self): - """Return the attributes.""" - return self._attrs - - @property - def device_info(self) -> DeviceInfo: - """Define the device as back yard/MSP System.""" - return DeviceInfo( - identifiers={(DOMAIN, self._msp_system_id)}, - manufacturer="Hayward", - model="OmniLogic", - name=self._backyard_name, - ) - def check_guard(state_key, item, entity_setting): """Validate that this entity passes the defined guard conditions defined at setup.""" diff --git a/homeassistant/components/omnilogic/entity.py b/homeassistant/components/omnilogic/entity.py new file mode 100644 index 00000000000..6f7b769fc8f --- /dev/null +++ b/homeassistant/components/omnilogic/entity.py @@ -0,0 +1,93 @@ +"""Common classes and elements for Omnilogic Integration.""" + +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OmniLogicUpdateCoordinator + + +class OmniLogicEntity(CoordinatorEntity[OmniLogicUpdateCoordinator]): + """Defines the base OmniLogic entity.""" + + def __init__( + self, + coordinator: OmniLogicUpdateCoordinator, + kind: str, + name: str, + item_id: tuple, + icon: str, + ) -> None: + """Initialize the OmniLogic Entity.""" + super().__init__(coordinator) + + bow_id = None + entity_data = coordinator.data[item_id] + + backyard_id = item_id[:2] + if len(item_id) == 6: + bow_id = item_id[:4] + + msp_system_id = coordinator.data[backyard_id]["systemId"] + entity_friendly_name = f"{coordinator.data[backyard_id]['BackyardName']} " + unique_id = f"{msp_system_id}" + + if bow_id is not None: + unique_id = f"{unique_id}_{coordinator.data[bow_id]['systemId']}" + + if kind != "Heaters": + entity_friendly_name = ( + f"{entity_friendly_name}{coordinator.data[bow_id]['Name']} " + ) + else: + entity_friendly_name = f"{entity_friendly_name}{coordinator.data[bow_id]['Operation']['VirtualHeater']['Name']} " + + unique_id = f"{unique_id}_{coordinator.data[item_id]['systemId']}_{kind}" + + if entity_data.get("Name") is not None: + entity_friendly_name = f"{entity_friendly_name} {entity_data['Name']}" + + entity_friendly_name = f"{entity_friendly_name} {name}" + + unique_id = unique_id.replace(" ", "_") + + self._kind = kind + self._name = entity_friendly_name + self._unique_id = unique_id + self._item_id = item_id + self._icon = icon + self._attrs: dict[str, Any] = {} + self._msp_system_id = msp_system_id + self._backyard_name = coordinator.data[backyard_id]["BackyardName"] + + @property + def unique_id(self) -> str: + """Return a unique, Home Assistant friendly identifier for this entity.""" + return self._unique_id + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self): + """Return the icon for the entity.""" + return self._icon + + @property + def extra_state_attributes(self): + """Return the attributes.""" + return self._attrs + + @property + def device_info(self) -> DeviceInfo: + """Define the device as back yard/MSP System.""" + return DeviceInfo( + identifiers={(DOMAIN, self._msp_system_id)}, + manufacturer="Hayward", + model="OmniLogic", + name=self._backyard_name, + ) diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index 9def0d9825e..c87b589e1f6 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -15,9 +15,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import OmniLogicEntity, check_guard +from .common import check_guard from .const import COORDINATOR, DEFAULT_PH_OFFSET, DOMAIN, PUMP_TYPES from .coordinator import OmniLogicUpdateCoordinator +from .entity import OmniLogicEntity async def async_setup_entry( diff --git a/homeassistant/components/omnilogic/switch.py b/homeassistant/components/omnilogic/switch.py index 388099f92e9..eb57d03bc34 100644 --- a/homeassistant/components/omnilogic/switch.py +++ b/homeassistant/components/omnilogic/switch.py @@ -12,9 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import OmniLogicEntity, check_guard +from .common import check_guard from .const import COORDINATOR, DOMAIN, PUMP_TYPES from .coordinator import OmniLogicUpdateCoordinator +from .entity import OmniLogicEntity SERVICE_SET_SPEED = "set_pump_speed" OMNILOGIC_SWITCH_OFF = 7 From 438cbc99b16f4a083c84c7ce1d71ce0b863b8332 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:45:13 +0200 Subject: [PATCH 0972/1309] Move nzbget base entity to separate module (#126502) --- homeassistant/components/nzbget/__init__.py | 24 ----------------- homeassistant/components/nzbget/entity.py | 29 +++++++++++++++++++++ homeassistant/components/nzbget/sensor.py | 2 +- homeassistant/components/nzbget/switch.py | 2 +- 4 files changed, 31 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/nzbget/entity.py diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index d47ac78c9d0..84456c4c006 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -6,8 +6,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_SPEED, @@ -93,25 +91,3 @@ def _async_register_services( async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -class NZBGetEntity(CoordinatorEntity[NZBGetDataUpdateCoordinator]): - """Defines a base NZBGet entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - *, - entry_id: str, - entry_name: str, - coordinator: NZBGetDataUpdateCoordinator, - ) -> None: - """Initialize the NZBGet entity.""" - super().__init__(coordinator) - self._entry_id = entry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entry_id)}, - name=entry_name, - entry_type=DeviceEntryType.SERVICE, - ) diff --git a/homeassistant/components/nzbget/entity.py b/homeassistant/components/nzbget/entity.py new file mode 100644 index 00000000000..7644cb28232 --- /dev/null +++ b/homeassistant/components/nzbget/entity.py @@ -0,0 +1,29 @@ +"""The NZBGet integration.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NZBGetDataUpdateCoordinator + + +class NZBGetEntity(CoordinatorEntity[NZBGetDataUpdateCoordinator]): + """Defines a base NZBGet entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + *, + entry_id: str, + entry_name: str, + coordinator: NZBGetDataUpdateCoordinator, + ) -> None: + """Initialize the NZBGet entity.""" + super().__init__(coordinator) + self._entry_id = entry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + name=entry_name, + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 394e1175c2f..f6a4e4cc973 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -17,9 +17,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from . import NZBGetEntity from .const import DATA_COORDINATOR, DOMAIN from .coordinator import NZBGetDataUpdateCoordinator +from .entity import NZBGetEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index c6505fd522d..552a1854902 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -10,9 +10,9 @@ from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NZBGetEntity from .const import DATA_COORDINATOR, DOMAIN from .coordinator import NZBGetDataUpdateCoordinator +from .entity import NZBGetEntity async def async_setup_entry( From 43322bc3d99f051c8bf64701b0562c5d9e171ae8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:45:30 +0200 Subject: [PATCH 0973/1309] Move notion base entity to separate module (#126499) --- homeassistant/components/notion/__init__.py | 107 +-------------- .../components/notion/binary_sensor.py | 3 +- homeassistant/components/notion/entity.py | 123 ++++++++++++++++++ homeassistant/components/notion/model.py | 12 -- homeassistant/components/notion/sensor.py | 3 +- 5 files changed, 127 insertions(+), 121 deletions(-) create mode 100644 homeassistant/components/notion/entity.py delete mode 100644 homeassistant/components/notion/model.py diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 00bded5c3a0..79f5d951e7e 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -6,18 +6,14 @@ from datetime import timedelta from typing import Any from uuid import UUID -from aionotion.bridge.models import Bridge from aionotion.errors import InvalidCredentialsError, NotionError -from aionotion.listener.models import Listener, ListenerKind +from aionotion.listener.models import ListenerKind from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers import entity_registry as er from .const import ( CONF_REFRESH_TOKEN, @@ -168,102 +164,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class NotionEntity(CoordinatorEntity[NotionDataUpdateCoordinator]): - """Define a base Notion entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: NotionDataUpdateCoordinator, - listener_id: str, - sensor_id: str, - bridge_id: int, - description: EntityDescription, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator) - - sensor = self.coordinator.data.sensors[sensor_id] - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, sensor.hardware_id)}, - manufacturer="Silicon Labs", - model=str(sensor.hardware_revision), - name=str(sensor.name).capitalize(), - sw_version=sensor.firmware_version, - ) - - if bridge := self._async_get_bridge(bridge_id): - self._attr_device_info["via_device"] = (DOMAIN, bridge.hardware_id) - - self._attr_extra_state_attributes = {} - self._attr_unique_id = listener_id - self._bridge_id = bridge_id - self._listener_id = listener_id - self._sensor_id = sensor_id - self.entity_description = description - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - self.coordinator.last_update_success - and self._listener_id in self.coordinator.data.listeners - ) - - @property - def listener(self) -> Listener: - """Return the listener related to this entity.""" - return self.coordinator.data.listeners[self._listener_id] - - @callback - def _async_get_bridge(self, bridge_id: int) -> Bridge | None: - """Get a bridge by ID (if it exists).""" - if (bridge := self.coordinator.data.bridges.get(bridge_id)) is None: - LOGGER.debug("Entity references a non-existent bridge ID: %s", bridge_id) - return None - return bridge - - @callback - def _async_update_bridge_id(self) -> None: - """Update the entity's bridge ID if it has changed. - - Sensors can move to other bridges based on signal strength, etc. - """ - sensor = self.coordinator.data.sensors[self._sensor_id] - - # If the bridge ID hasn't changed, return: - if self._bridge_id == sensor.bridge.id: - return - - # If the bridge doesn't exist, return: - if (bridge := self._async_get_bridge(sensor.bridge.id)) is None: - return - - self._bridge_id = sensor.bridge.id - - device_registry = dr.async_get(self.hass) - this_device = device_registry.async_get_device( - identifiers={(DOMAIN, sensor.hardware_id)} - ) - bridge = self.coordinator.data.bridges[self._bridge_id] - bridge_device = device_registry.async_get_device( - identifiers={(DOMAIN, bridge.hardware_id)} - ) - - if not bridge_device or not this_device: - return - - device_registry.async_update_device( - this_device.id, via_device_id=bridge_device.id - ) - - @callback - def _handle_coordinator_update(self) -> None: - """Respond to a DataUpdateCoordinator update.""" - if self._listener_id in self.coordinator.data.listeners: - self._async_update_bridge_id() - super()._handle_coordinator_update() diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index da50a809689..8c57310752a 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NotionEntity from .const import ( DOMAIN, LOGGER, @@ -32,7 +31,7 @@ from .const import ( SENSOR_WINDOW_HINGED, ) from .coordinator import NotionDataUpdateCoordinator -from .model import NotionEntityDescription +from .entity import NotionEntity, NotionEntityDescription @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/notion/entity.py b/homeassistant/components/notion/entity.py new file mode 100644 index 00000000000..11e470f1d26 --- /dev/null +++ b/homeassistant/components/notion/entity.py @@ -0,0 +1,123 @@ +"""Support for Notion.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from aionotion.bridge.models import Bridge +from aionotion.listener.models import Listener, ListenerKind + +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, LOGGER +from .coordinator import NotionDataUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class NotionEntityDescription: + """Define an description for Notion entities.""" + + listener_kind: ListenerKind + + +class NotionEntity(CoordinatorEntity[NotionDataUpdateCoordinator]): + """Define a base Notion entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: NotionDataUpdateCoordinator, + listener_id: str, + sensor_id: str, + bridge_id: int, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + sensor = self.coordinator.data.sensors[sensor_id] + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, sensor.hardware_id)}, + manufacturer="Silicon Labs", + model=str(sensor.hardware_revision), + name=str(sensor.name).capitalize(), + sw_version=sensor.firmware_version, + ) + + if bridge := self._async_get_bridge(bridge_id): + self._attr_device_info["via_device"] = (DOMAIN, bridge.hardware_id) + + self._attr_extra_state_attributes = {} + self._attr_unique_id = listener_id + self._bridge_id = bridge_id + self._listener_id = listener_id + self._sensor_id = sensor_id + self.entity_description = description + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + self.coordinator.last_update_success + and self._listener_id in self.coordinator.data.listeners + ) + + @property + def listener(self) -> Listener: + """Return the listener related to this entity.""" + return self.coordinator.data.listeners[self._listener_id] + + @callback + def _async_get_bridge(self, bridge_id: int) -> Bridge | None: + """Get a bridge by ID (if it exists).""" + if (bridge := self.coordinator.data.bridges.get(bridge_id)) is None: + LOGGER.debug("Entity references a non-existent bridge ID: %s", bridge_id) + return None + return bridge + + @callback + def _async_update_bridge_id(self) -> None: + """Update the entity's bridge ID if it has changed. + + Sensors can move to other bridges based on signal strength, etc. + """ + sensor = self.coordinator.data.sensors[self._sensor_id] + + # If the bridge ID hasn't changed, return: + if self._bridge_id == sensor.bridge.id: + return + + # If the bridge doesn't exist, return: + if (bridge := self._async_get_bridge(sensor.bridge.id)) is None: + return + + self._bridge_id = sensor.bridge.id + + device_registry = dr.async_get(self.hass) + this_device = device_registry.async_get_device( + identifiers={(DOMAIN, sensor.hardware_id)} + ) + bridge = self.coordinator.data.bridges[self._bridge_id] + bridge_device = device_registry.async_get_device( + identifiers={(DOMAIN, bridge.hardware_id)} + ) + + if not bridge_device or not this_device: + return + + device_registry.async_update_device( + this_device.id, via_device_id=bridge_device.id + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Respond to a DataUpdateCoordinator update.""" + if self._listener_id in self.coordinator.data.listeners: + self._async_update_bridge_id() + super()._handle_coordinator_update() diff --git a/homeassistant/components/notion/model.py b/homeassistant/components/notion/model.py deleted file mode 100644 index 541ca245329..00000000000 --- a/homeassistant/components/notion/model.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Define Notion model mixins.""" - -from dataclasses import dataclass - -from aionotion.listener.models import ListenerKind - - -@dataclass(frozen=True, kw_only=True) -class NotionEntityDescription: - """Define an description for Notion entities.""" - - listener_kind: ListenerKind diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index d12dabbbc33..fb853e65d7d 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -15,10 +15,9 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NotionEntity from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE from .coordinator import NotionDataUpdateCoordinator -from .model import NotionEntityDescription +from .entity import NotionEntity, NotionEntityDescription @dataclass(frozen=True, kw_only=True) From d4efdcb78ccfb9a522a44e33fdf685b6f2dbcd05 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 23 Sep 2024 12:46:46 +0200 Subject: [PATCH 0974/1309] Bump `pysnmp` and `brother` (#126488) * Bump pysnmp * Bump brother * Unpin pyasn1 --- homeassistant/components/brother/manifest.json | 2 +- homeassistant/components/snmp/manifest.json | 2 +- homeassistant/package_constraints.txt | 6 ------ requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- script/gen_requirements_all.py | 6 ------ 6 files changed, 6 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index d9c8e36aa1d..4e773a6cff2 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==4.3.0"], + "requirements": ["brother==4.3.1"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index c3970e1e00a..0b8863c8e58 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/snmp", "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], - "requirements": ["pysnmp==6.2.5"] + "requirements": ["pysnmp==6.2.6"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 68820c9b318..c1f6586988b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -186,9 +186,3 @@ tuf>=4.0.0 # https://github.com/jd/tenacity/issues/471 tenacity!=8.4.0 - -# pyasn1.compat.octets was removed in pyasn1 0.6.1 and breaks some integrations -# and tests that import it directly -# https://github.com/pyasn1/pyasn1/pull/60 -# https://github.com/lextudio/pysnmp/issues/114 -pyasn1==0.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 80401297119..9a75fc3e9bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ bring-api==0.8.1 broadlink==0.19.0 # homeassistant.components.brother -brother==4.3.0 +brother==4.3.1 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -2244,7 +2244,7 @@ pysml==0.0.12 pysmlight==0.1.1 # homeassistant.components.snmp -pysnmp==6.2.5 +pysnmp==6.2.6 # homeassistant.components.snooz pysnooz==0.8.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df8fa46e1ad..e3447b3c029 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -554,7 +554,7 @@ bring-api==0.8.1 broadlink==0.19.0 # homeassistant.components.brother -brother==4.3.0 +brother==4.3.1 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -1798,7 +1798,7 @@ pysml==0.0.12 pysmlight==0.1.1 # homeassistant.components.snmp -pysnmp==6.2.5 +pysnmp==6.2.6 # homeassistant.components.snooz pysnooz==0.8.6 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 20d6dd3c014..47a6412bcfd 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -205,12 +205,6 @@ tuf>=4.0.0 # https://github.com/jd/tenacity/issues/471 tenacity!=8.4.0 - -# pyasn1.compat.octets was removed in pyasn1 0.6.1 and breaks some integrations -# and tests that import it directly -# https://github.com/pyasn1/pyasn1/pull/60 -# https://github.com/lextudio/pysnmp/issues/114 -pyasn1==0.6.0 """ GENERATED_MESSAGE = ( From 26651c18a6b3e8c80a9a388e7bebc9464393fbf4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:47:17 +0200 Subject: [PATCH 0975/1309] Move modern_forms base entity to separate module (#126497) --- .../components/modern_forms/__init__.py | 35 +--------------- .../components/modern_forms/binary_sensor.py | 2 +- .../components/modern_forms/entity.py | 41 +++++++++++++++++++ homeassistant/components/modern_forms/fan.py | 3 +- .../components/modern_forms/light.py | 3 +- .../components/modern_forms/sensor.py | 2 +- .../components/modern_forms/switch.py | 3 +- 7 files changed, 50 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/modern_forms/entity.py diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index dea7d4fadea..ef2bbad70ce 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -11,11 +11,10 @@ from aiomodernforms import ModernFormsConnectionError, ModernFormsError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ModernFormsDataUpdateCoordinator +from .entity import ModernFormsDeviceEntity PLATFORMS = [ Platform.BINARY_SENSOR, @@ -84,35 +83,3 @@ def modernforms_exception_handler[ _LOGGER.error("Invalid response from API: %s", error) return handler - - -class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator]): - """Defines a Modern Forms device entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - *, - entry_id: str, - coordinator: ModernFormsDataUpdateCoordinator, - enabled_default: bool = True, - ) -> None: - """Initialize the Modern Forms entity.""" - super().__init__(coordinator) - self._attr_enabled_default = enabled_default - self._entry_id = entry_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this Modern Forms device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.coordinator.data.info.mac_address)}, - name=self.coordinator.data.info.device_name, - manufacturer="Modern Forms", - model=self.coordinator.data.info.fan_type, - sw_version=( - f"{self.coordinator.data.info.firmware_version} /" - f" {self.coordinator.data.info.main_mcu_firmware_version}" - ), - ) diff --git a/homeassistant/components/modern_forms/binary_sensor.py b/homeassistant/components/modern_forms/binary_sensor.py index 5fb0096b477..ea903c580a4 100644 --- a/homeassistant/components/modern_forms/binary_sensor.py +++ b/homeassistant/components/modern_forms/binary_sensor.py @@ -8,9 +8,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from . import ModernFormsDeviceEntity from .const import CLEAR_TIMER, DOMAIN from .coordinator import ModernFormsDataUpdateCoordinator +from .entity import ModernFormsDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/modern_forms/entity.py b/homeassistant/components/modern_forms/entity.py new file mode 100644 index 00000000000..c8419295c1f --- /dev/null +++ b/homeassistant/components/modern_forms/entity.py @@ -0,0 +1,41 @@ +"""The Modern Forms integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ModernFormsDataUpdateCoordinator + + +class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator]): + """Defines a Modern Forms device entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + *, + entry_id: str, + coordinator: ModernFormsDataUpdateCoordinator, + enabled_default: bool = True, + ) -> None: + """Initialize the Modern Forms entity.""" + super().__init__(coordinator) + self._attr_enabled_default = enabled_default + self._entry_id = entry_id + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Modern Forms device.""" + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.data.info.mac_address)}, + name=self.coordinator.data.info.device_name, + manufacturer="Modern Forms", + model=self.coordinator.data.info.fan_type, + sw_version=( + f"{self.coordinator.data.info.firmware_version} /" + f" {self.coordinator.data.info.main_mcu_firmware_version}" + ), + ) diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index e34038c7be7..a599c5b6dd6 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -18,7 +18,7 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from . import ModernFormsDeviceEntity, modernforms_exception_handler +from . import modernforms_exception_handler from .const import ( ATTR_SLEEP_TIME, CLEAR_TIMER, @@ -29,6 +29,7 @@ from .const import ( SERVICE_SET_FAN_SLEEP_TIMER, ) from .coordinator import ModernFormsDataUpdateCoordinator +from .entity import ModernFormsDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 4c210038694..2b53a414cea 100644 --- a/homeassistant/components/modern_forms/light.py +++ b/homeassistant/components/modern_forms/light.py @@ -17,7 +17,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from . import ModernFormsDeviceEntity, modernforms_exception_handler +from . import modernforms_exception_handler from .const import ( ATTR_SLEEP_TIME, CLEAR_TIMER, @@ -28,6 +28,7 @@ from .const import ( SERVICE_SET_LIGHT_SLEEP_TIMER, ) from .coordinator import ModernFormsDataUpdateCoordinator +from .entity import ModernFormsDeviceEntity BRIGHTNESS_RANGE = (1, 255) diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index 851e3092ce5..0f1e90cbe52 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -11,9 +11,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from . import ModernFormsDeviceEntity from .const import CLEAR_TIMER, DOMAIN from .coordinator import ModernFormsDataUpdateCoordinator +from .entity import ModernFormsDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/modern_forms/switch.py b/homeassistant/components/modern_forms/switch.py index a80115c0f93..f2e8b1b705c 100644 --- a/homeassistant/components/modern_forms/switch.py +++ b/homeassistant/components/modern_forms/switch.py @@ -9,9 +9,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ModernFormsDeviceEntity, modernforms_exception_handler +from . import modernforms_exception_handler from .const import DOMAIN from .coordinator import ModernFormsDataUpdateCoordinator +from .entity import ModernFormsDeviceEntity async def async_setup_entry( From acd3b2d732e79a5cfb81701a8d845505210018a6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:47:39 +0200 Subject: [PATCH 0976/1309] Move lyric base entity to separate module (#126493) --- homeassistant/components/lyric/__init__.py | 110 +------------------- homeassistant/components/lyric/climate.py | 2 +- homeassistant/components/lyric/entity.py | 114 +++++++++++++++++++++ homeassistant/components/lyric/sensor.py | 2 +- 4 files changed, 117 insertions(+), 111 deletions(-) create mode 100644 homeassistant/components/lyric/entity.py diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 6c35e084424..b338605a6ea 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -10,9 +10,6 @@ import logging from aiohttp.client_exceptions import ClientResponseError from aiolyric import Lyric from aiolyric.exceptions import LyricAuthenticationException, LyricException -from aiolyric.objects.device import LyricDevice -from aiolyric.objects.location import LyricLocation -from aiolyric.objects.priority import LyricAccessory, LyricRoom from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -22,14 +19,8 @@ from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, config_validation as cv, - device_registry as dr, -) -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, ) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( ConfigEntryLyricClient, @@ -127,102 +118,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class LyricEntity(CoordinatorEntity[DataUpdateCoordinator[Lyric]]): - """Defines a base Honeywell Lyric entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: DataUpdateCoordinator[Lyric], - location: LyricLocation, - device: LyricDevice, - key: str, - ) -> None: - """Initialize the Honeywell Lyric entity.""" - super().__init__(coordinator) - self._key = key - self._location = location - self._mac_id = device.mac_id - self._update_thermostat = coordinator.data.update_thermostat - self._update_fan = coordinator.data.update_fan - - @property - def unique_id(self) -> str: - """Return the unique ID for this entity.""" - return self._key - - @property - def location(self) -> LyricLocation: - """Get the Lyric Location.""" - return self.coordinator.data.locations_dict[self._location.location_id] - - @property - def device(self) -> LyricDevice: - """Get the Lyric Device.""" - return self.location.devices_dict[self._mac_id] - - -class LyricDeviceEntity(LyricEntity): - """Defines a Honeywell Lyric device entity.""" - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this Honeywell Lyric instance.""" - return DeviceInfo( - identifiers={(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, - connections={(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, - manufacturer="Honeywell", - model=self.device.device_model, - name=f"{self.device.name} Thermostat", - ) - - -class LyricAccessoryEntity(LyricDeviceEntity): - """Defines a Honeywell Lyric accessory entity, a sub-device of a thermostat.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator[Lyric], - location: LyricLocation, - device: LyricDevice, - room: LyricRoom, - accessory: LyricAccessory, - key: str, - ) -> None: - """Initialize the Honeywell Lyric accessory entity.""" - super().__init__(coordinator, location, device, key) - self._room_id = room.id - self._accessory_id = accessory.id - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this Honeywell Lyric instance.""" - return DeviceInfo( - identifiers={ - ( - f"{dr.CONNECTION_NETWORK_MAC}_room_accessory", - f"{self._mac_id}_room{self._room_id}_accessory{self._accessory_id}", - ) - }, - manufacturer="Honeywell", - model="RCHTSENSOR", - name=f"{self.room.room_name} Sensor", - via_device=(dr.CONNECTION_NETWORK_MAC, self._mac_id), - ) - - @property - def room(self) -> LyricRoom: - """Get the Lyric Device.""" - return self.coordinator.data.rooms_dict[self._mac_id][self._room_id] - - @property - def accessory(self) -> LyricAccessory: - """Get the Lyric Device.""" - return next( - accessory - for accessory in self.room.accessories - if accessory.id == self._accessory_id - ) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 22ab8ba57d4..37810f33256 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -40,7 +40,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import VolDictType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import LyricDeviceEntity from .const import ( DOMAIN, LYRIC_EXCEPTIONS, @@ -50,6 +49,7 @@ from .const import ( PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ) +from .entity import LyricDeviceEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lyric/entity.py b/homeassistant/components/lyric/entity.py new file mode 100644 index 00000000000..5a5a76f1442 --- /dev/null +++ b/homeassistant/components/lyric/entity.py @@ -0,0 +1,114 @@ +"""The Honeywell Lyric integration.""" + +from __future__ import annotations + +from aiolyric import Lyric +from aiolyric.objects.device import LyricDevice +from aiolyric.objects.location import LyricLocation +from aiolyric.objects.priority import LyricAccessory, LyricRoom + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + + +class LyricEntity(CoordinatorEntity[DataUpdateCoordinator[Lyric]]): + """Defines a base Honeywell Lyric entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator[Lyric], + location: LyricLocation, + device: LyricDevice, + key: str, + ) -> None: + """Initialize the Honeywell Lyric entity.""" + super().__init__(coordinator) + self._key = key + self._location = location + self._mac_id = device.mac_id + self._update_thermostat = coordinator.data.update_thermostat + self._update_fan = coordinator.data.update_fan + + @property + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + return self._key + + @property + def location(self) -> LyricLocation: + """Get the Lyric Location.""" + return self.coordinator.data.locations_dict[self._location.location_id] + + @property + def device(self) -> LyricDevice: + """Get the Lyric Device.""" + return self.location.devices_dict[self._mac_id] + + +class LyricDeviceEntity(LyricEntity): + """Defines a Honeywell Lyric device entity.""" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Honeywell Lyric instance.""" + return DeviceInfo( + identifiers={(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, self._mac_id)}, + manufacturer="Honeywell", + model=self.device.device_model, + name=f"{self.device.name} Thermostat", + ) + + +class LyricAccessoryEntity(LyricDeviceEntity): + """Defines a Honeywell Lyric accessory entity, a sub-device of a thermostat.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[Lyric], + location: LyricLocation, + device: LyricDevice, + room: LyricRoom, + accessory: LyricAccessory, + key: str, + ) -> None: + """Initialize the Honeywell Lyric accessory entity.""" + super().__init__(coordinator, location, device, key) + self._room_id = room.id + self._accessory_id = accessory.id + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Honeywell Lyric instance.""" + return DeviceInfo( + identifiers={ + ( + f"{dr.CONNECTION_NETWORK_MAC}_room_accessory", + f"{self._mac_id}_room{self._room_id}_accessory{self._accessory_id}", + ) + }, + manufacturer="Honeywell", + model="RCHTSENSOR", + name=f"{self.room.room_name} Sensor", + via_device=(dr.CONNECTION_NETWORK_MAC, self._mac_id), + ) + + @property + def room(self) -> LyricRoom: + """Get the Lyric Device.""" + return self.coordinator.data.rooms_dict[self._mac_id][self._room_id] + + @property + def accessory(self) -> LyricAccessory: + """Get the Lyric Device.""" + return next( + accessory + for accessory in self.room.accessories + if accessory.id == self._accessory_id + ) diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 7e006bc7bfe..38cb895a110 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -25,7 +25,6 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from . import LyricAccessoryEntity, LyricDeviceEntity from .const import ( DOMAIN, PRESET_HOLD_UNTIL, @@ -34,6 +33,7 @@ from .const import ( PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ) +from .entity import LyricAccessoryEntity, LyricDeviceEntity LYRIC_SETPOINT_STATUS_NAMES = { PRESET_NO_HOLD: "Following Schedule", From da3f18839a60d7317c3db9966f3bbf33fb7bccbb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:47:53 +0200 Subject: [PATCH 0977/1309] Move lidarr base entity to separate module (#126492) --- homeassistant/components/lidarr/__init__.py | 25 +----------------- homeassistant/components/lidarr/entity.py | 29 +++++++++++++++++++++ homeassistant/components/lidarr/sensor.py | 3 ++- 3 files changed, 32 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/lidarr/entity.py diff --git a/homeassistant/components/lidarr/__init__.py b/homeassistant/components/lidarr/__init__.py index e7935501650..907c89eb737 100644 --- a/homeassistant/components/lidarr/__init__.py +++ b/homeassistant/components/lidarr/__init__.py @@ -12,17 +12,13 @@ from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platfor from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.device_registry import DeviceEntryType from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( DiskSpaceDataUpdateCoordinator, - LidarrDataUpdateCoordinator, QueueDataUpdateCoordinator, StatusDataUpdateCoordinator, - T, WantedDataUpdateCoordinator, ) @@ -80,22 +76,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: LidarrConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: LidarrConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class LidarrEntity(CoordinatorEntity[LidarrDataUpdateCoordinator[T]]): - """Defines a base Lidarr entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: LidarrDataUpdateCoordinator[T], - description: EntityDescription, - ) -> None: - """Initialize the Lidarr entity.""" - super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.config_entry.entry_id)} - ) diff --git a/homeassistant/components/lidarr/entity.py b/homeassistant/components/lidarr/entity.py new file mode 100644 index 00000000000..a707f7850fb --- /dev/null +++ b/homeassistant/components/lidarr/entity.py @@ -0,0 +1,29 @@ +"""The Lidarr component.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LidarrDataUpdateCoordinator, T + + +class LidarrEntity(CoordinatorEntity[LidarrDataUpdateCoordinator[T]]): + """Defines a base Lidarr entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LidarrDataUpdateCoordinator[T], + description: EntityDescription, + ) -> None: + """Initialize the Lidarr entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)} + ) diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index b50a826a1c7..e7ea1027ff0 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -18,9 +18,10 @@ from homeassistant.const import UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import LidarrConfigEntry, LidarrEntity +from . import LidarrConfigEntry from .const import BYTE_SIZES from .coordinator import LidarrDataUpdateCoordinator, T +from .entity import LidarrEntity def get_space(data: list[LidarrRootFolder], name: str) -> str: From 1858c64e5f99f6ede40e2eaf7268705c7520e8d7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:48:07 +0200 Subject: [PATCH 0978/1309] Move motioneye base entity to separate module (#126495) --- .../components/motioneye/__init__.py | 64 +--------------- homeassistant/components/motioneye/camera.py | 8 +- homeassistant/components/motioneye/entity.py | 73 +++++++++++++++++++ homeassistant/components/motioneye/sensor.py | 3 +- homeassistant/components/motioneye/switch.py | 3 +- tests/components/motioneye/__init__.py | 2 +- 6 files changed, 81 insertions(+), 72 deletions(-) create mode 100644 homeassistant/components/motioneye/entity.py diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 6ec3092ab35..e24b844c4a2 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -8,7 +8,6 @@ from http import HTTPStatus import json import logging import os -from types import MappingProxyType from typing import Any from urllib.parse import urlencode, urljoin @@ -52,18 +51,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_EVENT_TYPE, @@ -125,13 +118,6 @@ def split_motioneye_device_identifier( return (DOMAIN, config_id, camera_id) -def get_motioneye_entity_unique_id( - config_entry_id: str, camera_id: int, entity_type: str -) -> str: - """Get the unique_id for a motionEye entity.""" - return f"{config_entry_id}_{camera_id}_{entity_type}" - - def get_camera_from_cameras( camera_id: int, data: dict[str, Any] | None ) -> dict[str, Any] | None: @@ -530,51 +516,3 @@ def get_media_url( return client.get_image_url(camera_id, path) return client.get_movie_url(camera_id, path) return None - - -class MotionEyeEntity(CoordinatorEntity): - """Base class for motionEye entities.""" - - _attr_has_entity_name = True - - def __init__( - self, - config_entry_id: str, - type_name: str, - camera: dict[str, Any], - client: MotionEyeClient, - coordinator: DataUpdateCoordinator, - options: MappingProxyType[str, Any], - entity_description: EntityDescription | None = None, - ) -> None: - """Initialize a motionEye entity.""" - self._camera_id = camera[KEY_ID] - self._device_identifier = get_motioneye_device_identifier( - config_entry_id, self._camera_id - ) - self._unique_id = get_motioneye_entity_unique_id( - config_entry_id, - self._camera_id, - type_name, - ) - self._client = client - self._camera: dict[str, Any] | None = camera - self._options = options - if entity_description is not None: - self.entity_description = entity_description - super().__init__(coordinator) - - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo(identifiers={self._device_identifier}) - - @property - def available(self) -> bool: - """Return if entity is available.""" - return self._camera is not None and super().available diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index d84f7b43c04..df4c321037e 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -45,12 +45,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import ( - MotionEyeEntity, - get_camera_from_cameras, - is_acceptable_camera, - listen_for_new_cameras, -) +from . import get_camera_from_cameras, is_acceptable_camera, listen_for_new_cameras from .const import ( CONF_ACTION, CONF_CLIENT, @@ -65,6 +60,7 @@ from .const import ( SERVICE_SNAPSHOT, TYPE_MOTIONEYE_MJPEG_CAMERA, ) +from .entity import MotionEyeEntity PLATFORMS = [Platform.CAMERA] diff --git a/homeassistant/components/motioneye/entity.py b/homeassistant/components/motioneye/entity.py new file mode 100644 index 00000000000..49739f2fca3 --- /dev/null +++ b/homeassistant/components/motioneye/entity.py @@ -0,0 +1,73 @@ +"""The motionEye integration.""" + +from __future__ import annotations + +from types import MappingProxyType +from typing import Any + +from motioneye_client.client import MotionEyeClient +from motioneye_client.const import KEY_ID + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import get_motioneye_device_identifier + + +def get_motioneye_entity_unique_id( + config_entry_id: str, camera_id: int, entity_type: str +) -> str: + """Get the unique_id for a motionEye entity.""" + return f"{config_entry_id}_{camera_id}_{entity_type}" + + +class MotionEyeEntity(CoordinatorEntity): + """Base class for motionEye entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + config_entry_id: str, + type_name: str, + camera: dict[str, Any], + client: MotionEyeClient, + coordinator: DataUpdateCoordinator, + options: MappingProxyType[str, Any], + entity_description: EntityDescription | None = None, + ) -> None: + """Initialize a motionEye entity.""" + self._camera_id = camera[KEY_ID] + self._device_identifier = get_motioneye_device_identifier( + config_entry_id, self._camera_id + ) + self._unique_id = get_motioneye_entity_unique_id( + config_entry_id, + self._camera_id, + type_name, + ) + self._client = client + self._camera: dict[str, Any] | None = camera + self._options = options + if entity_description is not None: + self.entity_description = entity_description + super().__init__(coordinator) + + @property + def unique_id(self) -> str: + """Return a unique id for this instance.""" + return self._unique_id + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return DeviceInfo(identifiers={self._device_identifier}) + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._camera is not None and super().available diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index dac4d77cdb4..e0113544848 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -16,8 +16,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import MotionEyeEntity, get_camera_from_cameras, listen_for_new_cameras +from . import get_camera_from_cameras, listen_for_new_cameras from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR +from .entity import MotionEyeEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 81a01587aa0..9d704f17740 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -22,8 +22,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import MotionEyeEntity, get_camera_from_cameras, listen_for_new_cameras +from . import get_camera_from_cameras, listen_for_new_cameras from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE +from .entity import MotionEyeEntity MOTIONEYE_SWITCHES = [ SwitchEntityDescription( diff --git a/tests/components/motioneye/__init__.py b/tests/components/motioneye/__init__.py index 183d1b3e6bf..3a80e6dc63d 100644 --- a/tests/components/motioneye/__init__.py +++ b/tests/components/motioneye/__init__.py @@ -7,8 +7,8 @@ from unittest.mock import AsyncMock, Mock, patch from motioneye_client.const import DEFAULT_PORT -from homeassistant.components.motioneye import get_motioneye_entity_unique_id from homeassistant.components.motioneye.const import DOMAIN +from homeassistant.components.motioneye.entity import get_motioneye_entity_unique_id from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL From ef8b6e2805d8abc962090ae4663659a927b1a0d2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:48:23 +0200 Subject: [PATCH 0979/1309] Rename melnor base entity module (#126496) --- homeassistant/components/melnor/{models.py => entity.py} | 0 homeassistant/components/melnor/number.py | 2 +- homeassistant/components/melnor/sensor.py | 2 +- homeassistant/components/melnor/switch.py | 2 +- homeassistant/components/melnor/time.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename homeassistant/components/melnor/{models.py => entity.py} (100%) diff --git a/homeassistant/components/melnor/models.py b/homeassistant/components/melnor/entity.py similarity index 100% rename from homeassistant/components/melnor/models.py rename to homeassistant/components/melnor/entity.py diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index beaa0fd913b..15c47008346 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import MelnorDataUpdateCoordinator -from .models import MelnorZoneEntity, get_entities_for_valves +from .entity import MelnorZoneEntity, get_entities_for_valves @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index 233dada8ab2..bbb3416dcc9 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -28,7 +28,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import MelnorDataUpdateCoordinator -from .models import MelnorBluetoothEntity, MelnorZoneEntity, get_entities_for_valves +from .entity import MelnorBluetoothEntity, MelnorZoneEntity, get_entities_for_valves def watering_seconds_left(valve: Valve) -> datetime | None: diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index efa779f04b0..d7fb96739b3 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import MelnorDataUpdateCoordinator -from .models import MelnorZoneEntity, get_entities_for_valves +from .entity import MelnorZoneEntity, get_entities_for_valves @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py index 373a22c8ff4..08de7e054de 100644 --- a/homeassistant/components/melnor/time.py +++ b/homeassistant/components/melnor/time.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import MelnorDataUpdateCoordinator -from .models import MelnorZoneEntity, get_entities_for_valves +from .entity import MelnorZoneEntity, get_entities_for_valves @dataclass(frozen=True, kw_only=True) From a9d12608bdf6fe49b7db7ff7189a8cc0b54cbd2d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:49:24 +0200 Subject: [PATCH 0980/1309] Move guardian base entity to separate module (#126486) --- homeassistant/components/guardian/__init__.py | 70 ---------------- .../components/guardian/binary_sensor.py | 12 +-- homeassistant/components/guardian/button.py | 3 +- homeassistant/components/guardian/entity.py | 80 +++++++++++++++++++ homeassistant/components/guardian/sensor.py | 12 +-- homeassistant/components/guardian/switch.py | 3 +- homeassistant/components/guardian/util.py | 2 +- homeassistant/components/guardian/valve.py | 3 +- 8 files changed, 99 insertions(+), 86 deletions(-) create mode 100644 homeassistant/components/guardian/entity.py diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 812c54d76a6..c1cbb4c0e5a 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -24,10 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( API_SENSOR_PAIR_DUMP, @@ -357,70 +354,3 @@ class PairedSensorManager: config_entry_id=self._entry.entry_id, identifiers={(DOMAIN, uid)} ) dev_reg.async_remove_device(device.id) - - -class GuardianEntity(CoordinatorEntity[GuardianDataUpdateCoordinator]): - """Define a base Guardian entity.""" - - _attr_has_entity_name = True - - def __init__( - self, coordinator: GuardianDataUpdateCoordinator, description: EntityDescription - ) -> None: - """Initialize.""" - super().__init__(coordinator) - - self.entity_description = description - - -class PairedSensorEntity(GuardianEntity): - """Define a Guardian paired sensor entity.""" - - def __init__( - self, - entry: ConfigEntry, - coordinator: GuardianDataUpdateCoordinator, - description: EntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator, description) - - paired_sensor_uid = coordinator.data["uid"] - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, paired_sensor_uid)}, - manufacturer="Elexa", - model=coordinator.data["codename"], - name=f"Guardian paired sensor {paired_sensor_uid}", - via_device=(DOMAIN, entry.data[CONF_UID]), - ) - self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" - - -@dataclass(frozen=True, kw_only=True) -class ValveControllerEntityDescription(EntityDescription): - """Describe a Guardian valve controller entity.""" - - api_category: str - - -class ValveControllerEntity(GuardianEntity): - """Define a Guardian valve controller entity.""" - - def __init__( - self, - entry: ConfigEntry, - coordinators: dict[str, GuardianDataUpdateCoordinator], - description: ValveControllerEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinators[description.api_category], description) - - self._diagnostics_coordinator = coordinators[API_SYSTEM_DIAGNOSTICS] - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, entry.data[CONF_UID])}, - manufacturer="Elexa", - model=self._diagnostics_coordinator.data["firmware"], - name=f"Guardian valve controller {entry.data[CONF_UID]}", - ) - self._attr_unique_id = f"{entry.data[CONF_UID]}_{description.key}" diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index c3621ea2d79..84bb61da0e5 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -18,12 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - GuardianData, - PairedSensorEntity, - ValveControllerEntity, - ValveControllerEntityDescription, -) +from . import GuardianData from .const import ( API_SYSTEM_ONBOARD_SENSOR_STATUS, CONF_UID, @@ -31,6 +26,11 @@ from .const import ( SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .coordinator import GuardianDataUpdateCoordinator +from .entity import ( + PairedSensorEntity, + ValveControllerEntity, + ValveControllerEntityDescription, +) from .util import ( EntityDomainReplacementStrategy, async_finish_entity_domain_replacements, diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index 8313ad23007..f4881a9d94b 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -18,8 +18,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription +from . import GuardianData from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN +from .entity import ValveControllerEntity, ValveControllerEntityDescription from .util import convert_exceptions_to_homeassistant_error diff --git a/homeassistant/components/guardian/entity.py b/homeassistant/components/guardian/entity.py new file mode 100644 index 00000000000..fca0afeda0e --- /dev/null +++ b/homeassistant/components/guardian/entity.py @@ -0,0 +1,80 @@ +"""The Elexa Guardian integration.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import API_SYSTEM_DIAGNOSTICS, CONF_UID, DOMAIN +from .coordinator import GuardianDataUpdateCoordinator + + +class GuardianEntity(CoordinatorEntity[GuardianDataUpdateCoordinator]): + """Define a base Guardian entity.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: GuardianDataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.entity_description = description + + +class PairedSensorEntity(GuardianEntity): + """Define a Guardian paired sensor entity.""" + + def __init__( + self, + entry: ConfigEntry, + coordinator: GuardianDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator, description) + + paired_sensor_uid = coordinator.data["uid"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, paired_sensor_uid)}, + manufacturer="Elexa", + model=coordinator.data["codename"], + name=f"Guardian paired sensor {paired_sensor_uid}", + via_device=(DOMAIN, entry.data[CONF_UID]), + ) + self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" + + +@dataclass(frozen=True, kw_only=True) +class ValveControllerEntityDescription(EntityDescription): + """Describe a Guardian valve controller entity.""" + + api_category: str + + +class ValveControllerEntity(GuardianEntity): + """Define a Guardian valve controller entity.""" + + def __init__( + self, + entry: ConfigEntry, + coordinators: dict[str, GuardianDataUpdateCoordinator], + description: ValveControllerEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinators[description.api_category], description) + + self._diagnostics_coordinator = coordinators[API_SYSTEM_DIAGNOSTICS] + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.data[CONF_UID])}, + manufacturer="Elexa", + model=self._diagnostics_coordinator.data["firmware"], + name=f"Guardian valve controller {entry.data[CONF_UID]}", + ) + self._attr_unique_id = f"{entry.data[CONF_UID]}_{description.key}" diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 448a7231df1..3f9547e652a 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -25,12 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import ( - GuardianData, - PairedSensorEntity, - ValveControllerEntity, - ValveControllerEntityDescription, -) +from . import GuardianData from .const import ( API_SYSTEM_DIAGNOSTICS, API_SYSTEM_ONBOARD_SENSOR_STATUS, @@ -39,6 +34,11 @@ from .const import ( DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) +from .entity import ( + PairedSensorEntity, + ValveControllerEntity, + ValveControllerEntityDescription, +) SENSOR_KIND_AVG_CURRENT = "average_current" SENSOR_KIND_BATTERY = "battery" diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 25bc8115208..fccf4f55a1f 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -14,8 +14,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription +from . import GuardianData from .const import API_VALVE_STATUS, API_WIFI_STATUS, DOMAIN +from .entity import ValveControllerEntity, ValveControllerEntityDescription from .util import convert_exceptions_to_homeassistant_error from .valve import GuardianValveState diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index 48e0a51c70a..69e79f6627e 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -18,7 +18,7 @@ from homeassistant.helpers import entity_registry as er from .const import LOGGER if TYPE_CHECKING: - from . import GuardianEntity + from .entity import GuardianEntity DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/guardian/valve.py b/homeassistant/components/guardian/valve.py index fcedc71f188..8c9749958bf 100644 --- a/homeassistant/components/guardian/valve.py +++ b/homeassistant/components/guardian/valve.py @@ -19,8 +19,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription +from . import GuardianData from .const import API_VALVE_STATUS, DOMAIN +from .entity import ValveControllerEntity, ValveControllerEntityDescription from .util import convert_exceptions_to_homeassistant_error VALVE_KIND_VALVE = "valve" From 8ef7cae36d898f6943f62c09ad08985689d6f2a7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 23 Sep 2024 12:50:40 +0200 Subject: [PATCH 0981/1309] Speedup Reolink tests by using scope="module" (#125215) * use scope="module" * Instead of side_effect = None, use reset_mock(side_efffect=True) * fix tests --- tests/components/reolink/conftest.py | 2 +- tests/components/reolink/test_button.py | 6 ++++ tests/components/reolink/test_camera.py | 2 ++ tests/components/reolink/test_config_flow.py | 11 ++++++- tests/components/reolink/test_host.py | 33 +++++++++++++++---- tests/components/reolink/test_init.py | 16 +++++++++ tests/components/reolink/test_light.py | 6 ++++ tests/components/reolink/test_media_source.py | 5 +++ tests/components/reolink/test_number.py | 4 +++ tests/components/reolink/test_select.py | 6 ++++ tests/components/reolink/test_siren.py | 6 +++- tests/components/reolink/test_switch.py | 13 ++++++-- tests/components/reolink/test_update.py | 3 ++ 13 files changed, 101 insertions(+), 12 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index c14a5ee0c32..720ee362c3c 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -52,7 +52,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry -@pytest.fixture +@pytest.fixture(scope="module") def reolink_connect_class() -> Generator[MagicMock]: """Mock reolink connection and return both the host_mock and host_mock_class.""" with ( diff --git a/tests/components/reolink/test_button.py b/tests/components/reolink/test_button.py index 7c91051c66e..126fbb6b29a 100644 --- a/tests/components/reolink/test_button.py +++ b/tests/components/reolink/test_button.py @@ -48,6 +48,8 @@ async def test_button( blocking=True, ) + reolink_connect.set_ptz_command.reset_mock(side_effect=True) + async def test_ptz_move_service( hass: HomeAssistant, @@ -79,6 +81,8 @@ async def test_ptz_move_service( blocking=True, ) + reolink_connect.set_ptz_command.reset_mock(side_effect=True) + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_host_button( @@ -110,3 +114,5 @@ async def test_host_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + reolink_connect.reboot.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_camera.py b/tests/components/reolink/test_camera.py index 96bb5a099c9..21ebb242882 100644 --- a/tests/components/reolink/test_camera.py +++ b/tests/components/reolink/test_camera.py @@ -43,6 +43,8 @@ async def test_camera( # check getting the stream source assert await async_get_stream_source(hass, entity_id) is not None + reolink_connect.get_snapshot.reset_mock(side_effect=True) + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_no_stream_source( diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 4d89906a768..4ade0771ffb 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -200,7 +200,7 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "api_error"} - reolink_connect.get_host_data.side_effect = None + reolink_connect.get_host_data.reset_mock(side_effect=True) result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -225,6 +225,9 @@ async def test_config_flow_errors( CONF_PROTOCOL: DEFAULT_PROTOCOL, } + reolink_connect.unsubscribe.reset_mock(side_effect=True) + reolink_connect.logout.reset_mock(side_effect=True) + async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test specifying non default settings using options flow.""" @@ -478,6 +481,7 @@ async def test_dhcp_ip_update( ) if attr is not None: + original = getattr(reolink_connect, attr) setattr(reolink_connect, attr, value) result = await hass.config_entries.flow.async_init( @@ -508,6 +512,11 @@ async def test_dhcp_ip_update( await hass.async_block_till_done() assert config_entry.data[CONF_HOST] == expected + reolink_connect.get_states.side_effect = None + reolink_connect_class.reset_mock() + if attr is not None: + setattr(reolink_connect, attr, original) + async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reconfiguration flow.""" diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index 639d5bf046f..77d156c9486 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -75,9 +75,7 @@ async def test_webhook_callback( # test webhook callback single channel with error in event callback signal_ch.reset_mock() - reolink_connect.ONVIF_event_callback = AsyncMock( - side_effect=Exception("Test error") - ) + reolink_connect.ONVIF_event_callback.side_effect = Exception("Test error") await client.post(f"/api/webhook/{webhook_id}", data="test_data") signal_ch.assert_not_called() @@ -87,19 +85,22 @@ async def test_webhook_callback( content=bytes("test", "utf-8"), mock_source="test", ) - request.read = AsyncMock(side_effect=ConnectionResetError("Test error")) + request.read = AsyncMock() + request.read.side_effect = ConnectionResetError("Test error") await async_handle_webhook(hass, webhook_id, request) signal_all.assert_not_called() - request.read = AsyncMock(side_effect=ClientResponseError("Test error", "Test")) + request.read.side_effect = ClientResponseError("Test error", "Test") await async_handle_webhook(hass, webhook_id, request) signal_all.assert_not_called() - request.read = AsyncMock(side_effect=CancelledError("Test error")) + request.read.side_effect = CancelledError("Test error") with pytest.raises(CancelledError): await async_handle_webhook(hass, webhook_id, request) signal_all.assert_not_called() + reolink_connect.ONVIF_event_callback.reset_mock(side_effect=True) + async def test_no_mac( hass: HomeAssistant, @@ -107,11 +108,14 @@ async def test_no_mac( reolink_connect: MagicMock, ) -> None: """Test setup of host with no mac.""" + original = reolink_connect.mac_address reolink_connect.mac_address = None assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY + reolink_connect.mac_address = original + async def test_subscribe_error( hass: HomeAssistant, @@ -124,6 +128,7 @@ async def test_subscribe_error( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED + reolink_connect.subscribe.reset_mock(side_effect=True) async def test_subscribe_unsuccesfull( @@ -179,6 +184,9 @@ async def test_ONVIF_not_supported( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED + reolink_connect.subscribe.reset_mock(side_effect=True) + reolink_connect.subscribed.return_value = True + async def test_renew( hass: HomeAssistant, @@ -216,6 +224,9 @@ async def test_renew( reolink_connect.subscribe.assert_called() + reolink_connect.renew.reset_mock(side_effect=True) + reolink_connect.subscribe.reset_mock(side_effect=True) + async def test_long_poll_renew_fail( hass: HomeAssistant, @@ -237,6 +248,8 @@ async def test_long_poll_renew_fail( # ensure long polling continues reolink_connect.pull_point_request.assert_called() + reolink_connect.subscribe.reset_mock(side_effect=True) + async def test_register_webhook_errors( hass: HomeAssistant, @@ -290,6 +303,8 @@ async def test_long_poll_errors( reolink_connect: MagicMock, ) -> None: """Test errors during ONVIF long polling.""" + reolink_connect.pull_point_request.reset_mock() + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED @@ -314,6 +329,8 @@ async def test_long_poll_errors( reolink_connect.unsubscribe.assert_called_with(sub_type=SubType.long_poll) + reolink_connect.pull_point_request.reset_mock(side_effect=True) + async def test_fast_polling_errors( hass: HomeAssistant, @@ -322,6 +339,7 @@ async def test_fast_polling_errors( reolink_connect: MagicMock, ) -> None: """Test errors during ONVIF fast polling.""" + reolink_connect.get_motion_state_all_ch.reset_mock() reolink_connect.get_motion_state_all_ch.side_effect = ReolinkError("Test error") reolink_connect.pull_point_request.side_effect = ReolinkError("Test error") @@ -348,6 +366,9 @@ async def test_fast_polling_errors( # fast polling continues despite errors assert reolink_connect.get_motion_state_all_ch.call_count == 2 + reolink_connect.get_motion_state_all_ch.reset_mock(side_effect=True) + reolink_connect.pull_point_request.reset_mock(side_effect=True) + async def test_diagnostics_event_connection( hass: HomeAssistant, diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 765b3426249..ffb2dfca6bc 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -92,6 +92,7 @@ async def test_failures_parametrized( expected: ConfigEntryState, ) -> None: """Test outcomes when changing errors.""" + original = getattr(reolink_connect, attr) setattr(reolink_connect, attr, value) assert await hass.config_entries.async_setup(config_entry.entry_id) is ( expected is ConfigEntryState.LOADED @@ -100,6 +101,8 @@ async def test_failures_parametrized( assert config_entry.state == expected + setattr(reolink_connect, attr, original) + async def test_firmware_error_twice( hass: HomeAssistant, @@ -124,6 +127,8 @@ async def test_firmware_error_twice( assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + reolink_connect.check_new_firmware.reset_mock(side_effect=True) + async def test_credential_error_three( hass: HomeAssistant, @@ -149,6 +154,8 @@ async def test_credential_error_three( assert (HOMEASSISTANT_DOMAIN, issue_id) in issue_registry.issues + reolink_connect.get_states.reset_mock(side_effect=True) + async def test_entry_reloading( hass: HomeAssistant, @@ -157,6 +164,7 @@ async def test_entry_reloading( ) -> None: """Test the entry is reloaded correctly when settings change.""" reolink_connect.is_nvr = False + reolink_connect.logout.reset_mock() assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -169,6 +177,8 @@ async def test_entry_reloading( assert reolink_connect.logout.call_count == 1 assert config_entry.title == "New Name" + reolink_connect.is_nvr = True + @pytest.mark.parametrize( ("attr", "value", "expected_models"), @@ -224,6 +234,7 @@ async def test_removing_disconnected_cams( # Try to remove the device after 'disconnecting' a camera. if attr is not None: + original = getattr(reolink_connect, attr) setattr(reolink_connect, attr, value) expected_success = TEST_CAM_MODEL not in expected_models for device in device_entries: @@ -237,6 +248,9 @@ async def test_removing_disconnected_cams( device_models = [device.model for device in device_entries] assert sorted(device_models) == sorted(expected_models) + if attr is not None: + setattr(reolink_connect, attr, original) + @pytest.mark.parametrize( ("attr", "value", "expected_models"), @@ -548,6 +562,8 @@ async def test_port_repair_issue( assert (DOMAIN, "enable_port") in issue_registry.issues + reolink_connect.set_net_port.reset_mock(side_effect=True) + async def test_webhook_repair_issue( hass: HomeAssistant, diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index 7c0c11c3f63..948a7fce0fe 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -94,6 +94,8 @@ async def test_light_turn_off( blocking=True, ) + reolink_connect.set_whiteled.reset_mock(side_effect=True) + async def test_light_turn_on( hass: HomeAssistant, @@ -145,6 +147,8 @@ async def test_light_turn_on( blocking=True, ) + reolink_connect.set_whiteled.reset_mock(side_effect=True) + async def test_host_light_state( hass: HomeAssistant, @@ -203,6 +207,8 @@ async def test_host_light_turn_off( blocking=True, ) + reolink_connect.set_state_light.reset_mock(side_effect=True) + async def test_host_light_turn_on( hass: HomeAssistant, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 494432d0412..32afd1f73ca 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -32,6 +32,7 @@ from homeassistant.setup import async_setup_component from .conftest import ( TEST_HOST2, + TEST_HOST_MODEL, TEST_MAC2, TEST_NVR_NAME, TEST_NVR_NAME2, @@ -225,6 +226,8 @@ async def test_browsing( assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id + reolink_connect.model = TEST_HOST_MODEL + async def test_browsing_unsupported_encoding( hass: HomeAssistant, @@ -345,3 +348,5 @@ async def test_browsing_not_loaded( assert browse.title == "Reolink" assert browse.identifier is None assert len(browse.children) == 1 + + reolink_connect.get_host_data.side_effect = None diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index 89b6935de5b..c6507fa36c1 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -64,6 +64,8 @@ async def test_number( blocking=True, ) + reolink_connect.set_volume.reset_mock(side_effect=True) + async def test_host_number( hass: HomeAssistant, @@ -153,3 +155,5 @@ async def test_chime_number( {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 1}, blocking=True, ) + + test_chime.set_option.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 0534f36f4c5..7910174380a 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -74,6 +74,8 @@ async def test_floodlight_mode_select( assert hass.states.get(entity_id).state == STATE_UNKNOWN + reolink_connect.set_whiteled.reset_mock(side_effect=True) + async def test_play_quick_reply_message( hass: HomeAssistant, @@ -99,6 +101,8 @@ async def test_play_quick_reply_message( ) reolink_connect.play_quick_reply.assert_called_once() + reolink_connect.quick_reply_dict = MagicMock() + async def test_chime_select( hass: HomeAssistant, @@ -153,3 +157,5 @@ async def test_chime_select( await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNKNOWN + + test_chime.set_tone.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_siren.py b/tests/components/reolink/test_siren.py index 0d9d3e0b800..f6ba8e0ea77 100644 --- a/tests/components/reolink/test_siren.py +++ b/tests/components/reolink/test_siren.py @@ -61,7 +61,6 @@ async def test_siren( reolink_connect.set_siren.assert_called_with(0, True, 2) # test siren turn off - reolink_connect.set_siren.side_effect = None await hass.services.async_call( SIREN_DOMAIN, SERVICE_TURN_OFF, @@ -101,6 +100,7 @@ async def test_siren_turn_on_errors( entity_id = f"{Platform.SIREN}.{TEST_NVR_NAME}_siren" + original = getattr(reolink_connect, attr) setattr(reolink_connect, attr, value) with pytest.raises(expected): await hass.services.async_call( @@ -110,6 +110,8 @@ async def test_siren_turn_on_errors( blocking=True, ) + setattr(reolink_connect, attr, original) + async def test_siren_turn_off_errors( hass: HomeAssistant, @@ -132,3 +134,5 @@ async def test_siren_turn_off_errors( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + reolink_connect.set_siren.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index 7f8d606555d..f9fb18a458f 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -138,7 +138,7 @@ async def test_switch( ) # test switch turn off - reolink_connect.set_recording.side_effect = None + reolink_connect.set_recording.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -156,6 +156,8 @@ async def test_switch( blocking=True, ) + reolink_connect.set_recording.reset_mock(side_effect=True) + async def test_host_switch( hass: HomeAssistant, @@ -165,6 +167,7 @@ async def test_host_switch( ) -> None: """Test host switch entity.""" reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_connect.recording_enabled.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -200,7 +203,7 @@ async def test_host_switch( ) # test switch turn off - reolink_connect.set_recording.side_effect = None + reolink_connect.set_recording.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -218,6 +221,8 @@ async def test_host_switch( blocking=True, ) + reolink_connect.set_recording.reset_mock(side_effect=True) + async def test_chime_switch( hass: HomeAssistant, @@ -262,7 +267,7 @@ async def test_chime_switch( ) # test switch turn off - test_chime.set_option.side_effect = None + test_chime.set_option.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -279,3 +284,5 @@ async def test_chime_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + test_chime.set_option.reset_mock(side_effect=True) diff --git a/tests/components/reolink/test_update.py b/tests/components/reolink/test_update.py index 3ad10a11499..a13009204d7 100644 --- a/tests/components/reolink/test_update.py +++ b/tests/components/reolink/test_update.py @@ -73,6 +73,7 @@ async def test_update_firm( ) -> None: """Test update state when update available with firmware info from reolink.com.""" reolink_connect.camera_name.return_value = TEST_CAM_NAME + reolink_connect.camera_sw_version.return_value = "v1.1.0.0.0.0000" new_firmware = NewSoftwareVersion( version_string="v3.3.0.226_23031644", download_url=TEST_DOWNLOAD_URL, @@ -129,3 +130,5 @@ async def test_update_firm( await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF + + reolink_connect.update_firmware.side_effect = None From c8d20a8c23506b16a96b800b54ca7f8db0458947 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 12:50:51 +0200 Subject: [PATCH 0982/1309] Move fritzbox base entity to separate module (#126482) --- homeassistant/components/fritzbox/__init__.py | 62 +---------------- .../components/fritzbox/binary_sensor.py | 2 +- homeassistant/components/fritzbox/button.py | 2 +- homeassistant/components/fritzbox/climate.py | 2 +- homeassistant/components/fritzbox/cover.py | 2 +- homeassistant/components/fritzbox/entity.py | 68 +++++++++++++++++++ homeassistant/components/fritzbox/light.py | 4 +- homeassistant/components/fritzbox/sensor.py | 2 +- homeassistant/components/fritzbox/switch.py | 2 +- 9 files changed, 77 insertions(+), 69 deletions(-) create mode 100644 homeassistant/components/fritzbox/entity.py diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index ab6d88772d5..07bc8fb15f2 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -2,18 +2,11 @@ from __future__ import annotations -from abc import ABC, abstractmethod - -from pyfritzhome import FritzhomeDevice -from pyfritzhome.devicetypes.fritzhomeentitybase import FritzhomeEntityBase - from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.const import EVENT_HOMEASSISTANT_STOP, UnitOfTemperature from homeassistant.core import Event, HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo -from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator @@ -83,56 +76,3 @@ async def async_remove_config_entry_device( return False return True - - -class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC): - """Basis FritzBox entity.""" - - def __init__( - self, - coordinator: FritzboxDataUpdateCoordinator, - ain: str, - entity_description: EntityDescription | None = None, - ) -> None: - """Initialize the FritzBox entity.""" - super().__init__(coordinator) - - self.ain = ain - if entity_description is not None: - self._attr_has_entity_name = True - self.entity_description = entity_description - self._attr_unique_id = f"{ain}_{entity_description.key}" - else: - self._attr_name = self.data.name - self._attr_unique_id = ain - - @property - @abstractmethod - def data(self) -> FritzhomeEntityBase: - """Return data object from coordinator.""" - - -class FritzBoxDeviceEntity(FritzBoxEntity): - """Reflects FritzhomeDevice and uses its attributes to construct FritzBoxDeviceEntity.""" - - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and self.data.present - - @property - def data(self) -> FritzhomeDevice: - """Return device data object from coordinator.""" - return self.coordinator.data.devices[self.ain] - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return DeviceInfo( - name=self.data.name, - identifiers={(DOMAIN, self.ain)}, - manufacturer=self.data.manufacturer, - model=self.data.productname, - sw_version=self.data.fw_version, - configuration_url=self.coordinator.configuration_url, - ) diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 89394d35fe5..3c9cb6ada5c 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -17,8 +17,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzBoxDeviceEntity from .coordinator import FritzboxConfigEntry +from .entity import FritzBoxDeviceEntity from .model import FritzEntityDescriptionMixinBase diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py index 7ef91a74252..44a6697e1c0 100644 --- a/homeassistant/components/fritzbox/button.py +++ b/homeassistant/components/fritzbox/button.py @@ -7,9 +7,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzBoxEntity from .const import DOMAIN from .coordinator import FritzboxConfigEntry +from .entity import FritzBoxEntity async def async_setup_entry( diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 61e75bec000..7b0bec6fc09 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -22,7 +22,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzBoxDeviceEntity from .const import ( ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, @@ -32,6 +31,7 @@ from .const import ( LOGGER, ) from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator +from .entity import FritzBoxDeviceEntity from .model import ClimateExtraAttributes HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF] diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index 7a74d0b8184..de87d6f8852 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -13,8 +13,8 @@ from homeassistant.components.cover import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzBoxDeviceEntity from .coordinator import FritzboxConfigEntry +from .entity import FritzBoxDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/fritzbox/entity.py b/homeassistant/components/fritzbox/entity.py new file mode 100644 index 00000000000..cd619588bc1 --- /dev/null +++ b/homeassistant/components/fritzbox/entity.py @@ -0,0 +1,68 @@ +"""Support for AVM FRITZ!SmartHome devices.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from pyfritzhome import FritzhomeDevice +from pyfritzhome.devicetypes.fritzhomeentitybase import FritzhomeEntityBase + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FritzboxDataUpdateCoordinator + + +class FritzBoxEntity(CoordinatorEntity[FritzboxDataUpdateCoordinator], ABC): + """Basis FritzBox entity.""" + + def __init__( + self, + coordinator: FritzboxDataUpdateCoordinator, + ain: str, + entity_description: EntityDescription | None = None, + ) -> None: + """Initialize the FritzBox entity.""" + super().__init__(coordinator) + + self.ain = ain + if entity_description is not None: + self._attr_has_entity_name = True + self.entity_description = entity_description + self._attr_unique_id = f"{ain}_{entity_description.key}" + else: + self._attr_name = self.data.name + self._attr_unique_id = ain + + @property + @abstractmethod + def data(self) -> FritzhomeEntityBase: + """Return data object from coordinator.""" + + +class FritzBoxDeviceEntity(FritzBoxEntity): + """Reflects FritzhomeDevice and uses its attributes to construct FritzBoxDeviceEntity.""" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.data.present + + @property + def data(self) -> FritzhomeDevice: + """Return device data object from coordinator.""" + return self.coordinator.data.devices[self.ain] + + @property + def device_info(self) -> DeviceInfo: + """Return device specific attributes.""" + return DeviceInfo( + name=self.data.name, + identifiers={(DOMAIN, self.ain)}, + manufacturer=self.data.manufacturer, + model=self.data.productname, + sw_version=self.data.fw_version, + configuration_url=self.coordinator.configuration_url, + ) diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index c19d7a8600d..d347f6898c0 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -16,9 +16,9 @@ from homeassistant.components.light import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity from .const import COLOR_MODE, LOGGER -from .coordinator import FritzboxConfigEntry +from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator +from .entity import FritzBoxDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index dbfdc2f9c95..e610fd80f3e 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -30,8 +30,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp -from . import FritzBoxDeviceEntity from .coordinator import FritzboxConfigEntry +from .entity import FritzBoxDeviceEntity from .model import FritzEntityDescriptionMixinBase diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index d13f21e1c14..18b676d449e 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FritzBoxDeviceEntity from .const import DOMAIN from .coordinator import FritzboxConfigEntry +from .entity import FritzBoxDeviceEntity async def async_setup_entry( From 71f65378466711e9d771c7f3a0e51bd05ef64cb5 Mon Sep 17 00:00:00 2001 From: Adam Goode Date: Mon, 23 Sep 2024 06:51:29 -0400 Subject: [PATCH 0983/1309] Add additional test cases to Threshold (#126469) There are still some bugs to be fixed, but for now this adds some additional test cases for things that are already correct. --- .../threshold/test_binary_sensor.py | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index 04016c0fc3f..e0973c7a580 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -47,7 +47,7 @@ from tests.common import MockConfigEntry ([15], POSITION_BELOW, STATE_OFF), # at threshold ([15, 16], POSITION_ABOVE, STATE_ON), ([15, 16, 14], POSITION_BELOW, STATE_OFF), - ([15, 16, 14, 15], POSITION_BELOW, STATE_OFF), + ([15, 16, 14, 15], POSITION_BELOW, STATE_OFF), # below -> threshold ([15, 16, 14, 15, "cat"], POSITION_UNKNOWN, STATE_UNKNOWN), ([15, 16, 14, 15, "cat", 15], POSITION_BELOW, STATE_OFF), ([15, None], POSITION_UNKNOWN, STATE_UNKNOWN), @@ -146,6 +146,18 @@ async def test_sensor_lower( ([17.5, 12.5, 20, 13, 12, 17, 18, "cat"], POSITION_UNKNOWN, STATE_UNKNOWN), ([17.5, 12.5, 20, 13, 12, 17, 18, "cat", 18], POSITION_ABOVE, STATE_ON), ([18, None], POSITION_UNKNOWN, STATE_UNKNOWN), + # below within -> above + ([14, 17.6], POSITION_ABOVE, STATE_ON), + # above within -> below + ([16, 12.4], POSITION_BELOW, STATE_OFF), + # below within -> above within + ([14, 16], POSITION_BELOW, STATE_OFF), + # above within -> below within + ([16, 14], POSITION_BELOW, STATE_OFF), + # above -> above within -> below within + ([20, 16, 14], POSITION_ABOVE, STATE_ON), + # below -> below within -> above within + ([10, 14, 16], POSITION_BELOW, STATE_OFF), ], ) async def test_sensor_upper_hysteresis( @@ -196,6 +208,18 @@ async def test_sensor_upper_hysteresis( ([17.5, 12.5, 20, 13, 12, 17, 18, "cat"], POSITION_UNKNOWN, STATE_UNKNOWN), ([17.5, 12.5, 20, 13, 12, 17, 18, "cat", 18], POSITION_ABOVE, STATE_OFF), ([18, None], POSITION_UNKNOWN, STATE_UNKNOWN), + # below within -> above + ([14, 17.6], POSITION_ABOVE, STATE_OFF), + # above within -> below + ([16, 12.4], POSITION_BELOW, STATE_ON), + # below within -> above within + ([14, 16], POSITION_ABOVE, STATE_OFF), + # above within -> below within + ([16, 14], POSITION_ABOVE, STATE_OFF), + # above -> above within -> below within + ([20, 16, 14], POSITION_ABOVE, STATE_OFF), + # below -> below within -> above within + ([10, 14, 16], POSITION_BELOW, STATE_ON), ], ) async def test_sensor_lower_hysteresis( @@ -237,13 +261,27 @@ async def test_sensor_lower_hysteresis( ("vals", "expected_position", "expected_state"), [ ([10], POSITION_IN_RANGE, STATE_ON), # at lower threshold - ([10, 20], POSITION_IN_RANGE, STATE_ON), # at upper threshold + ([10, 20], POSITION_IN_RANGE, STATE_ON), # lower threshold -> upper threshold ([10, 20, 16], POSITION_IN_RANGE, STATE_ON), ([10, 20, 16, 9], POSITION_BELOW, STATE_OFF), ([10, 20, 16, 9, 21], POSITION_ABOVE, STATE_OFF), ([10, 20, 16, 9, 21, "cat"], POSITION_UNKNOWN, STATE_UNKNOWN), ([10, 20, 16, 9, 21, "cat", 21], POSITION_ABOVE, STATE_OFF), ([21, None], POSITION_UNKNOWN, STATE_UNKNOWN), + # upper threshold -> lower threshold + ([20, 10], POSITION_IN_RANGE, STATE_ON), + # in-range -> upper threshold + ([15, 20], POSITION_IN_RANGE, STATE_ON), + # in-range -> lower threshold + ([15, 10], POSITION_IN_RANGE, STATE_ON), + # below -> above + ([5, 25], POSITION_ABOVE, STATE_OFF), + # above -> below + ([25, 5], POSITION_BELOW, STATE_OFF), + # in-range -> above + ([15, 25], POSITION_ABOVE, STATE_OFF), + # in-range -> below + ([15, 5], POSITION_BELOW, STATE_OFF), ], ) async def test_sensor_in_range_no_hysteresis( @@ -310,6 +348,32 @@ async def test_sensor_in_range_no_hysteresis( STATE_ON, ), ([17, None], POSITION_UNKNOWN, STATE_UNKNOWN), + # upper threshold -> lower threshold + ([20, 10], POSITION_IN_RANGE, STATE_ON), + # in-range -> upper threshold + ([15, 20], POSITION_IN_RANGE, STATE_ON), + # in-range -> lower threshold + ([15, 10], POSITION_IN_RANGE, STATE_ON), + # below -> above + ([5, 25], POSITION_ABOVE, STATE_OFF), + # above -> below + ([25, 5], POSITION_BELOW, STATE_OFF), + # in-range -> above + ([15, 25], POSITION_ABOVE, STATE_OFF), + # in-range -> below + ([15, 5], POSITION_BELOW, STATE_OFF), + # below -> lower threshold + ([5, 10], POSITION_BELOW, STATE_OFF), + # below -> in-range -> lower threshold + ([5, 15, 10], POSITION_IN_RANGE, STATE_ON), + # above -> upper threshold + ([25, 20], POSITION_ABOVE, STATE_OFF), + # above -> in-range -> upper threshold + ([25, 15, 20], POSITION_IN_RANGE, STATE_ON), + ([15, 22.1], POSITION_ABOVE, STATE_OFF), # in-range -> above hysteresis edge + ([15, 7.9], POSITION_BELOW, STATE_OFF), # in-range -> below hysteresis edge + ([7, 11.9], POSITION_BELOW, STATE_OFF), + ([23, 18.1], POSITION_ABOVE, STATE_OFF), ], ) async def test_sensor_in_range_with_hysteresis( From e3351db3d88b1260f03209f2305809fd1ff1dae0 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 23 Sep 2024 20:52:13 +1000 Subject: [PATCH 0984/1309] Add lock platform to Tesla Fleet (#126412) * Add lock platform * Add lock platform tests * Fix json --- .../components/tesla_fleet/__init__.py | 1 + .../components/tesla_fleet/icons.json | 11 ++ homeassistant/components/tesla_fleet/lock.py | 103 ++++++++++++++++ .../components/tesla_fleet/strings.json | 11 ++ .../tesla_fleet/snapshots/test_lock.ambr | 95 ++++++++++++++ tests/components/tesla_fleet/test_lock.py | 116 ++++++++++++++++++ 6 files changed, 337 insertions(+) create mode 100644 homeassistant/components/tesla_fleet/lock.py create mode 100644 tests/components/tesla_fleet/snapshots/test_lock.ambr create mode 100644 tests/components/tesla_fleet/test_lock.py diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index c1f9c0ce8f9..9825325a948 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -44,6 +44,7 @@ PLATFORMS: Final = [ Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, + Platform.LOCK, Platform.MEDIA_PLAYER, Platform.SELECT, Platform.SENSOR, diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index 21e6cc46f60..d8708163a53 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -65,6 +65,17 @@ "default": "mdi:routes" } }, + "lock": { + "charge_state_charge_port_latch": { + "default": "mdi:ev-plug-tesla" + }, + "vehicle_state_locked": { + "state": { + "locked": "mdi:car-door-lock", + "unlocked": "mdi:car-door-lock-open" + } + } + }, "select": { "climate_state_seat_heater_left": { "default": "mdi:car-seat-heater", diff --git a/homeassistant/components/tesla_fleet/lock.py b/homeassistant/components/tesla_fleet/lock.py new file mode 100644 index 00000000000..32998d409be --- /dev/null +++ b/homeassistant/components/tesla_fleet/lock.py @@ -0,0 +1,103 @@ +"""Lock platform for Tesla Fleet integration.""" + +from __future__ import annotations + +from typing import Any + +from tesla_fleet_api.const import Scope + +from homeassistant.components.lock import LockEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslaFleetConfigEntry +from .const import DOMAIN +from .entity import TeslaFleetVehicleEntity +from .helpers import handle_vehicle_command +from .models import TeslaFleetVehicleData + +ENGAGED = "Engaged" + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslaFleetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the TeslaFleet lock platform from a config entry.""" + + async_add_entities( + klass(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) + for klass in ( + TeslaFleetVehicleLockEntity, + TeslaFleetCableLockEntity, + ) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslaFleetVehicleLockEntity(TeslaFleetVehicleEntity, LockEntity): + """Lock entity for TeslaFleet.""" + + def __init__(self, data: TeslaFleetVehicleData, scoped: bool) -> None: + """Initialize the lock.""" + super().__init__(data, "vehicle_state_locked") + self.scoped = scoped + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + self._attr_is_locked = self._value + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the doors.""" + self.raise_for_read_only(Scope.VEHICLE_CMDS) + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_lock()) + self._attr_is_locked = True + self.async_write_ha_state() + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the doors.""" + self.raise_for_read_only(Scope.VEHICLE_CMDS) + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.door_unlock()) + self._attr_is_locked = False + self.async_write_ha_state() + + +class TeslaFleetCableLockEntity(TeslaFleetVehicleEntity, LockEntity): + """Cable Lock entity for TeslaFleet.""" + + def __init__( + self, + data: TeslaFleetVehicleData, + scoped: bool, + ) -> None: + """Initialize the lock.""" + super().__init__(data, "charge_state_charge_port_latch") + self.scoped = scoped + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + if self._value is None: + self._attr_is_locked = None + self._attr_is_locked = self._value == ENGAGED + + async def async_lock(self, **kwargs: Any) -> None: + """Charge cable Lock cannot be manually locked.""" + raise ServiceValidationError( + "Insert cable to lock", + translation_domain=DOMAIN, + translation_key="no_cable", + ) + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock charge cable lock.""" + self.raise_for_read_only(Scope.VEHICLE_CMDS) + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.charge_port_door_open()) + self._attr_is_locked = False + self.async_write_ha_state() diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 0b297173363..9b8de58665c 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -150,6 +150,14 @@ "name": "Route" } }, + "lock": { + "charge_state_charge_port_latch": { + "name": "Charge cable lock" + }, + "vehicle_state_locked": { + "name": "[%key:component::lock::title%]" + } + }, "media_player": { "media": { "name": "[%key:component::media_player::title%]" @@ -443,6 +451,9 @@ } }, "exceptions": { + "no_cable": { + "message": "Charge cable will lock automatically when connected" + }, "update_failed": { "message": "{endpoint} data request failed: {message}" }, diff --git a/tests/components/tesla_fleet/snapshots/test_lock.ambr b/tests/components/tesla_fleet/snapshots/test_lock.ambr new file mode 100644 index 00000000000..3384bb0eb97 --- /dev/null +++ b/tests/components/tesla_fleet/snapshots/test_lock.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_lock[lock.test_charge_cable_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_charge_cable_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Charge cable lock', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_port_latch', + 'unique_id': 'LRWXF7EK4KC700000-charge_state_charge_port_latch', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[lock.test_charge_cable_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Charge cable lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_charge_cable_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'locked', + }) +# --- +# name: test_lock[lock.test_lock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'lock', + 'entity_category': None, + 'entity_id': 'lock.test_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_locked', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_locked', + 'unit_of_measurement': None, + }) +# --- +# name: test_lock[lock.test_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Lock', + 'supported_features': , + }), + 'context': , + 'entity_id': 'lock.test_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unlocked', + }) +# --- diff --git a/tests/components/tesla_fleet/test_lock.py b/tests/components/tesla_fleet/test_lock.py new file mode 100644 index 00000000000..c576496284f --- /dev/null +++ b/tests/components/tesla_fleet/test_lock.py @@ -0,0 +1,116 @@ +"""Test the Tesla Fleet lock platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_LOCKED, + STATE_UNKNOWN, + STATE_UNLOCKED, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK + +from tests.common import MockConfigEntry + + +async def test_lock( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the lock entities are correct.""" + + await setup_platform(hass, normal_config_entry, [Platform.LOCK]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +async def test_lock_offline( + hass: HomeAssistant, + mock_vehicle_data: AsyncMock, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the lock entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, normal_config_entry, [Platform.LOCK]) + state = hass.states.get("lock.test_lock") + assert state.state == STATE_UNKNOWN + + +async def test_lock_services( + hass: HomeAssistant, + normal_config_entry: MockConfigEntry, +) -> None: + """Tests that the lock services work.""" + + await setup_platform(hass, normal_config_entry, [Platform.LOCK]) + + entity_id = "lock.test_lock" + + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.door_lock", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == STATE_LOCKED + call.assert_called_once() + + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.door_unlock", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == STATE_UNLOCKED + call.assert_called_once() + + entity_id = "lock.test_charge_cable_lock" + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + with patch( + "homeassistant.components.tesla_fleet.VehicleSpecific.charge_port_door_open", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == STATE_UNLOCKED + call.assert_called_once() From fb400af7d2ca0a432c197547794927e67d908172 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 13:02:39 +0200 Subject: [PATCH 0985/1309] Prevent trailing line feeds in translation values (#126446) * Prevent trailing line feeds in translation values * Fixup strings --- homeassistant/components/dormakaba_dkey/strings.json | 2 +- homeassistant/components/github/strings.json | 2 +- homeassistant/components/google/strings.json | 2 +- homeassistant/components/google_assistant_sdk/strings.json | 2 +- homeassistant/components/google_mail/strings.json | 2 +- homeassistant/components/google_photos/strings.json | 2 +- homeassistant/components/google_sheets/strings.json | 2 +- homeassistant/components/google_tasks/strings.json | 2 +- homeassistant/components/homeassistant/strings.json | 2 +- homeassistant/components/myuplink/strings.json | 2 +- homeassistant/components/nest/strings.json | 2 +- homeassistant/components/owntracks/strings.json | 2 +- homeassistant/components/plaato/strings.json | 2 +- script/hassfest/translations.py | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index 1fdc7cb359f..eb8cbc1d676 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -12,7 +12,7 @@ "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, "reauth_confirm": { - "description": "The activation code is no longer valid, a new unused activation code is needed.\n\n" + "description": "The activation code is no longer valid, a new unused activation code is needed." }, "associate": { "description": "Provide an unused activation code.\n\nTo create an activation code, create a new key in the dKey admin app, then choose to share the key and share an activation code.\n\nMake sure to close the dKey admin app before proceeding.", diff --git a/homeassistant/components/github/strings.json b/homeassistant/components/github/strings.json index 130b404015c..38b796e2fd2 100644 --- a/homeassistant/components/github/strings.json +++ b/homeassistant/components/github/strings.json @@ -9,7 +9,7 @@ } }, "progress": { - "wait_for_device": "Open {url}, and paste the following code to authorize the integration: \n```\n{code}\n```\n" + "wait_for_device": "Open {url}, and paste the following code to authorize the integration: \n```\n{code}\n```" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 4e62b134b0e..c2b35d63c63 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -44,7 +44,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type.\n\n" + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type." }, "services": { "add_event": { diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index d5d1d885427..7690790e0a9 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -40,7 +40,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "services": { "send_text_command": { diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index 142e8f039d2..4b0b515a346 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -32,7 +32,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Mail. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Mail. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "entity": { "sensor": { diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index faf91f71979..aaed29b124d 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Photos. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Photos. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "config": { "step": { diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index 0723456224f..bc48f8821ad 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -31,7 +31,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Sheets. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Sheets. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "services": { "append_sheet": { diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index 4479b34935e..c7635ebd6e4 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Tasks. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Tasks. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "config": { "step": { diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 69a3e26ad79..aef751b71a6 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -40,7 +40,7 @@ }, "no_platform_setup": { "title": "Unused YAML configuration for the {platform} integration", - "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}\n" + "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}`", diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 30cfefe5e18..4e344e55c43 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or click **Create New Application**.\n1. Set appropriate Application name and Description\n2. Enter `{callback_url}` as Callback Url\n\n" + "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or click **Create New Application**.\n1. Set appropriate Application name and Description\n2. Enter `{callback_url}` as Callback Url" }, "config": { "step": { diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index cd915acfbe5..b80c86c357c 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -17,7 +17,7 @@ }, "device_project": { "title": "Nest: Create a Device Access Project", - "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).\n", + "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", "data": { "project_id": "Device Access Project ID" } diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json index 499b598d7ae..8fdd771b95e 100644 --- a/homeassistant/components/owntracks/strings.json +++ b/homeassistant/components/owntracks/strings.json @@ -11,7 +11,7 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "create_entry": { - "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to Preferences > Connection. Change the following settings:\n - Mode: HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `'(Your name)'`\n - Device ID: `'(Your device name)'`\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left > Settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `'(Your name)'`\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + "default": "On Android, open [the OwnTracks app]({android_url}), go to Preferences > Connection. Change the following settings:\n - Mode: HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `'(Your name)'`\n - Device ID: `'(Your device name)'`\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left > Settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `'(Your name)'`\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." } } } diff --git a/homeassistant/components/plaato/strings.json b/homeassistant/components/plaato/strings.json index 934628e82c2..23568258118 100644 --- a/homeassistant/components/plaato/strings.json +++ b/homeassistant/components/plaato/strings.json @@ -41,7 +41,7 @@ "step": { "webhook": { "title": "Options for Plaato Airlock", - "description": "Webhook info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n" + "description": "Webhook info:\n\n- URL: `{webhook_url}`\n- Method: POST" }, "user": { "title": "Options for Plaato", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 50cfc62b5cf..2c3b9b4d99b 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -135,7 +135,7 @@ def translation_value_validator(value: Any) -> str: string_value = string_no_single_quoted_placeholders(string_value) if RE_COMBINED_REFERENCE.search(string_value): raise vol.Invalid("the string should not contain combined translations") - if string_value != string_value.strip(" "): + if string_value != string_value.strip(): raise vol.Invalid("the string should not contain leading or trailing spaces") return string_value From 14bc65e8e7edc8d3922288e5a7b8e593371dfda5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 13:06:10 +0200 Subject: [PATCH 0986/1309] Move gardena_bluetooth base entity to separate module (#126484) --- .../components/gardena_bluetooth/__init__.py | 8 ++-- .../gardena_bluetooth/binary_sensor.py | 5 ++- .../components/gardena_bluetooth/button.py | 5 ++- .../gardena_bluetooth/coordinator.py | 41 +----------------- .../components/gardena_bluetooth/entity.py | 43 +++++++++++++++++++ .../components/gardena_bluetooth/number.py | 11 ++--- .../components/gardena_bluetooth/sensor.py | 11 ++--- .../components/gardena_bluetooth/switch.py | 7 +-- .../components/gardena_bluetooth/valve.py | 7 +-- .../components/gardena_bluetooth/conftest.py | 7 +-- 10 files changed, 73 insertions(+), 72 deletions(-) create mode 100644 homeassistant/components/gardena_bluetooth/entity.py diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index ed5b1c14ba3..b6a26456168 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers.device_registry import DeviceInfo import homeassistant.util.dt as dt_util from .const import DOMAIN -from .coordinator import Coordinator, DeviceUnavailable +from .coordinator import DeviceUnavailable, GardenaBluetoothCoordinator PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -75,7 +75,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=model, ) - coordinator = Coordinator(hass, LOGGER, client, uuids, device, address) + coordinator = GardenaBluetoothCoordinator( + hass, LOGGER, client, uuids, device, address + ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -87,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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): - coordinator: Coordinator = hass.data[DOMAIN].pop(entry.entry_id) + coordinator: GardenaBluetoothCoordinator = hass.data[DOMAIN].pop(entry.entry_id) await coordinator.async_shutdown() return unload_ok diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index c552beaf878..be6d8bbeede 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -18,7 +18,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import Coordinator, GardenaBluetoothDescriptorEntity +from .coordinator import GardenaBluetoothCoordinator +from .entity import GardenaBluetoothDescriptorEntity @dataclass(frozen=True) @@ -55,7 +56,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up binary sensor based on a config entry.""" - coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: GardenaBluetoothCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [ GardenaBluetoothBinarySensor(coordinator, description, description.context) for description in DESCRIPTIONS diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index bdcf9094f5c..67377dc684e 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -14,7 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import Coordinator, GardenaBluetoothDescriptorEntity +from .coordinator import GardenaBluetoothCoordinator +from .entity import GardenaBluetoothDescriptorEntity @dataclass(frozen=True) @@ -44,7 +45,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up button based on a config entry.""" - coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: GardenaBluetoothCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [ GardenaBluetoothButton(coordinator, description, description.context) for description in DESCRIPTIONS diff --git a/homeassistant/components/gardena_bluetooth/coordinator.py b/homeassistant/components/gardena_bluetooth/coordinator.py index 296eff2686e..5caafe0e794 100644 --- a/homeassistant/components/gardena_bluetooth/coordinator.py +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -4,7 +4,6 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any from gardena_bluetooth.client import Client from gardena_bluetooth.exceptions import ( @@ -16,12 +15,7 @@ from gardena_bluetooth.parse import Characteristic, CharacteristicType from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed SCAN_INTERVAL = timedelta(seconds=60) LOGGER = logging.getLogger(__name__) @@ -31,7 +25,7 @@ class DeviceUnavailable(HomeAssistantError): """Raised if device can't be found.""" -class Coordinator(DataUpdateCoordinator[dict[str, bytes]]): +class GardenaBluetoothCoordinator(DataUpdateCoordinator[dict[str, bytes]]): """Class to manage fetching data.""" def __init__( @@ -102,34 +96,3 @@ class Coordinator(DataUpdateCoordinator[dict[str, bytes]]): self.data[char.uuid] = char.encode(value) await self.async_refresh() - - -class GardenaBluetoothEntity(CoordinatorEntity[Coordinator]): - """Coordinator entity for Gardena Bluetooth.""" - - _attr_has_entity_name = True - - def __init__(self, coordinator: Coordinator, context: Any = None) -> None: - """Initialize coordinator entity.""" - super().__init__(coordinator, context) - self._attr_device_info = coordinator.device_info - - @property - def available(self) -> bool: - """Return if entity is available.""" - return self.coordinator.last_update_success and self._attr_available - - -class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity): - """Coordinator entity for entities with entity description.""" - - def __init__( - self, - coordinator: Coordinator, - description: EntityDescription, - context: set[str], - ) -> None: - """Initialize description entity.""" - super().__init__(coordinator, context) - self._attr_unique_id = f"{coordinator.address}-{description.key}" - self.entity_description = description diff --git a/homeassistant/components/gardena_bluetooth/entity.py b/homeassistant/components/gardena_bluetooth/entity.py new file mode 100644 index 00000000000..a0344fc4ca0 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/entity.py @@ -0,0 +1,43 @@ +"""Provides the DataUpdateCoordinator.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import GardenaBluetoothCoordinator + + +class GardenaBluetoothEntity(CoordinatorEntity[GardenaBluetoothCoordinator]): + """Coordinator entity for Gardena Bluetooth.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: GardenaBluetoothCoordinator, context: Any = None + ) -> None: + """Initialize coordinator entity.""" + super().__init__(coordinator, context) + self._attr_device_info = coordinator.device_info + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success and self._attr_available + + +class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity): + """Coordinator entity for entities with entity description.""" + + def __init__( + self, + coordinator: GardenaBluetoothCoordinator, + description: EntityDescription, + context: set[str], + ) -> None: + """Initialize description entity.""" + super().__init__(coordinator, context) + self._attr_unique_id = f"{coordinator.address}-{description.key}" + self.entity_description = description diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index cbc4866b0ff..d3c178ee637 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -23,11 +23,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import ( - Coordinator, - GardenaBluetoothDescriptorEntity, - GardenaBluetoothEntity, -) +from .coordinator import GardenaBluetoothCoordinator +from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity @dataclass(frozen=True) @@ -111,7 +108,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up entity based on a config entry.""" - coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: GardenaBluetoothCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[NumberEntity] = [ GardenaBluetoothNumber(coordinator, description, description.context) for description in DESCRIPTIONS @@ -159,7 +156,7 @@ class GardenaBluetoothRemainingOpenSetNumber(GardenaBluetoothEntity, NumberEntit def __init__( self, - coordinator: Coordinator, + coordinator: GardenaBluetoothCoordinator, ) -> None: """Initialize the remaining time entity.""" super().__init__(coordinator, {Valve.remaining_open_time.uuid}) diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 3e6ddf9a2df..19fefefa9aa 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -21,11 +21,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util from .const import DOMAIN -from .coordinator import ( - Coordinator, - GardenaBluetoothDescriptorEntity, - GardenaBluetoothEntity, -) +from .coordinator import GardenaBluetoothCoordinator +from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity @dataclass(frozen=True) @@ -101,7 +98,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Gardena Bluetooth sensor based on a config entry.""" - coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: GardenaBluetoothCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[GardenaBluetoothEntity] = [ GardenaBluetoothSensor(coordinator, description, description.context) for description in DESCRIPTIONS @@ -140,7 +137,7 @@ class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity): def __init__( self, - coordinator: Coordinator, + coordinator: GardenaBluetoothCoordinator, ) -> None: """Initialize the sensor.""" super().__init__(coordinator, {Valve.remaining_open_time.uuid}) diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py index d010665e427..58b4b2e4e51 100644 --- a/homeassistant/components/gardena_bluetooth/switch.py +++ b/homeassistant/components/gardena_bluetooth/switch.py @@ -13,14 +13,15 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import Coordinator, GardenaBluetoothEntity +from .coordinator import GardenaBluetoothCoordinator +from .entity import GardenaBluetoothEntity async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up switch based on a config entry.""" - coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: GardenaBluetoothCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [] if GardenaBluetoothValveSwitch.characteristics.issubset( coordinator.characteristics @@ -41,7 +42,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity): def __init__( self, - coordinator: Coordinator, + coordinator: GardenaBluetoothCoordinator, ) -> None: """Initialize the switch.""" super().__init__( diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index 3faf758f7e9..877cc5b505e 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .coordinator import Coordinator, GardenaBluetoothEntity +from .coordinator import GardenaBluetoothCoordinator +from .entity import GardenaBluetoothEntity FALLBACK_WATERING_TIME_IN_SECONDS = 60 * 60 @@ -21,7 +22,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up switch based on a config entry.""" - coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: GardenaBluetoothCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [] if GardenaBluetoothValve.characteristics.issubset(coordinator.characteristics): entities.append(GardenaBluetoothValve(coordinator)) @@ -45,7 +46,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity): def __init__( self, - coordinator: Coordinator, + coordinator: GardenaBluetoothCoordinator, ) -> None: """Initialize the switch.""" super().__init__( diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index 882c9b1b090..d363e0e69f3 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -112,10 +112,5 @@ def mock_client( @pytest.fixture(autouse=True) -def enable_all_entities(): +def enable_all_entities(entity_registry_enabled_by_default: None) -> None: """Make sure all entities are enabled.""" - with patch( - "homeassistant.components.gardena_bluetooth.coordinator.GardenaBluetoothEntity.entity_registry_enabled_default", - new=Mock(return_value=True), - ): - yield From ec311ecd2b8be85a5a18c97548b5834089a054a8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 13:09:38 +0200 Subject: [PATCH 0987/1309] Move prusalink base entity to separate module (#126510) * Move prusalink base entity to separate module * Fix tests --- .../components/prusalink/__init__.py | 19 -------------- .../components/prusalink/binary_sensor.py | 2 +- homeassistant/components/prusalink/button.py | 2 +- homeassistant/components/prusalink/camera.py | 2 +- homeassistant/components/prusalink/entity.py | 25 +++++++++++++++++++ homeassistant/components/prusalink/sensor.py | 2 +- tests/components/prusalink/test_button.py | 2 +- 7 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/prusalink/entity.py diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 62eeb91d3e1..1415e3dd0a6 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -16,9 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .config_flow import ConfigFlow from .const import DOMAIN @@ -26,7 +24,6 @@ from .coordinator import ( InfoUpdateCoordinator, JobUpdateCoordinator, LegacyStatusCoordinator, - PrusaLinkUpdateCoordinator, StatusCoordinator, ) @@ -128,19 +125,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): - """Defines a base PrusaLink entity.""" - - _attr_has_entity_name = True - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this PrusaLink device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, - name=self.coordinator.config_entry.title, - manufacturer="Prusa", - configuration_url=self.coordinator.api.client.host, - ) diff --git a/homeassistant/components/prusalink/binary_sensor.py b/homeassistant/components/prusalink/binary_sensor.py index abeb79c2876..d40ac8a4cfa 100644 --- a/homeassistant/components/prusalink/binary_sensor.py +++ b/homeassistant/components/prusalink/binary_sensor.py @@ -17,9 +17,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PrusaLinkEntity from .const import DOMAIN from .coordinator import PrusaLinkUpdateCoordinator +from .entity import PrusaLinkEntity T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo) diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index 0ad7e531d46..06d356b2ca6 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -15,9 +15,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PrusaLinkEntity from .const import DOMAIN from .coordinator import PrusaLinkUpdateCoordinator +from .entity import PrusaLinkEntity T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index 2185c5f3cf6..eee655447cc 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -9,9 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import PrusaLinkEntity from .const import DOMAIN from .coordinator import JobUpdateCoordinator +from .entity import PrusaLinkEntity async def async_setup_entry( diff --git a/homeassistant/components/prusalink/entity.py b/homeassistant/components/prusalink/entity.py new file mode 100644 index 00000000000..e0bc62ba3c0 --- /dev/null +++ b/homeassistant/components/prusalink/entity.py @@ -0,0 +1,25 @@ +"""The PrusaLink integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PrusaLinkUpdateCoordinator + + +class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): + """Defines a base PrusaLink entity.""" + + _attr_has_entity_name = True + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this PrusaLink device.""" + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + name=self.coordinator.config_entry.title, + manufacturer="Prusa", + configuration_url=self.coordinator.api.client.host, + ) diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index 96cd4979b11..0c746adbe2e 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -29,9 +29,9 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from homeassistant.util.variance import ignore_variance -from . import PrusaLinkEntity from .const import DOMAIN from .coordinator import PrusaLinkUpdateCoordinator +from .entity import PrusaLinkEntity T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo) diff --git a/tests/components/prusalink/test_button.py b/tests/components/prusalink/test_button.py index 54f3854161c..f85e0232c74 100644 --- a/tests/components/prusalink/test_button.py +++ b/tests/components/prusalink/test_button.py @@ -93,7 +93,7 @@ async def test_button_resume_cancel( with ( patch(f"pyprusalink.PrusaLink.{method}") as mock_meth, patch( - "homeassistant.components.prusalink.PrusaLinkUpdateCoordinator._fetch_data" + "homeassistant.components.prusalink.coordinator.PrusaLinkUpdateCoordinator._fetch_data" ), ): await hass.services.async_call( From b7ba7893703c72763794e19f1d529b3c1d94c6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 23 Sep 2024 13:33:19 +0200 Subject: [PATCH 0988/1309] Code quality improvements at Home Connect (#126323) Added types to all arguments and return values to all functions Defined class members and its types outside the constructor Improved logic at binary sensor --- .../components/home_connect/__init__.py | 2 +- homeassistant/components/home_connect/api.py | 66 +++++++++++-------- .../components/home_connect/binary_sensor.py | 60 +++++++++-------- .../components/home_connect/entity.py | 6 +- .../components/home_connect/light.py | 4 +- .../components/home_connect/sensor.py | 18 ++++- .../components/home_connect/switch.py | 19 +++--- 7 files changed, 100 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index ebfd6f91c76..5f07b8075ce 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -244,7 +244,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @Throttle(SCAN_INTERVAL) -async def update_all_devices(hass, entry): +async def update_all_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update all the devices.""" data = hass.data[DOMAIN] hc_api = data[entry.entry_id] diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index 33b1a462e43..f03093b46b9 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -1,14 +1,15 @@ """API for Home Connect bound to HASS OAuth.""" +from abc import abstractmethod from asyncio import run_coroutine_threadsafe import logging from typing import Any import homeconnect -from homeconnect.api import HomeConnectError +from homeconnect.api import HomeConnectAppliance, HomeConnectError -from homeassistant import config_entries, core from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, @@ -17,6 +18,7 @@ from homeassistant.const import ( PERCENTAGE, UnitOfTime, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.dispatcher import dispatcher_send @@ -44,8 +46,8 @@ class ConfigEntryAuth(homeconnect.HomeConnectAPI): def __init__( self, - hass: core.HomeAssistant, - config_entry: config_entries.ConfigEntry, + hass: HomeAssistant, + config_entry: ConfigEntry, implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, ) -> None: """Initialize Home Connect Auth.""" @@ -65,11 +67,12 @@ class ConfigEntryAuth(homeconnect.HomeConnectAPI): return self.session.token - def get_devices(self): + def get_devices(self) -> list[dict[str, Any]]: """Get a dictionary of devices.""" appl = self.get_appliances() devices = [] for app in appl: + device: HomeConnectDevice if app.type == "Dryer": device = Dryer(self.hass, app) elif app.type == "Washer": @@ -110,13 +113,15 @@ class HomeConnectDevice: # for some devices, this is instead BSH_POWER_STANDBY # see https://developer.home-connect.com/docs/settings/power_state power_off_state = BSH_POWER_OFF + hass: HomeAssistant + appliance: HomeConnectAppliance - def __init__(self, hass, appliance): + def __init__(self, hass: HomeAssistant, appliance: HomeConnectAppliance) -> None: """Initialize the device class.""" self.hass = hass self.appliance = appliance - def initialize(self): + def initialize(self) -> None: """Fetch the info needed to initialize the device.""" try: self.appliance.get_status() @@ -137,17 +142,22 @@ class HomeConnectDevice: } self.appliance.listen_events(callback=self.event_callback) - def event_callback(self, appliance): + def event_callback(self, appliance: HomeConnectAppliance) -> None: """Handle event.""" _LOGGER.debug("Update triggered on %s", appliance.name) _LOGGER.debug(self.appliance.status) dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId) + @abstractmethod + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: + """Get a dictionary with info about the associated entities.""" + raise NotImplementedError + class DeviceWithPrograms(HomeConnectDevice): """Device with programs.""" - def get_programs_available(self): + def get_programs_available(self) -> list: """Get the available programs.""" try: programs_available = self.appliance.get_programs_available() @@ -156,7 +166,7 @@ class DeviceWithPrograms(HomeConnectDevice): programs_available = [] return programs_available - def get_program_switches(self): + def get_program_switches(self) -> list[dict[str, Any]]: """Get a dictionary with info about program switches. There will be one switch for each program. @@ -164,7 +174,7 @@ class DeviceWithPrograms(HomeConnectDevice): programs = self.get_programs_available() return [{ATTR_DEVICE: self, "program_name": p} for p in programs] - def get_program_sensors(self): + def get_program_sensors(self) -> list[dict[str, Any]]: """Get a dictionary with info about program sensors. There will be one of the four types of sensors for each @@ -192,7 +202,7 @@ class DeviceWithPrograms(HomeConnectDevice): class DeviceWithOpState(HomeConnectDevice): """Device that has an operation state sensor.""" - def get_opstate_sensor(self): + def get_opstate_sensor(self) -> list[dict[str, Any]]: """Get a list with info about operation state sensors.""" return [ @@ -211,7 +221,7 @@ class DeviceWithOpState(HomeConnectDevice): class DeviceWithDoor(HomeConnectDevice): """Device that has a door sensor.""" - def get_door_entity(self): + def get_door_entity(self) -> dict[str, Any]: """Get a dictionary with info about the door binary sensor.""" return { ATTR_DEVICE: self, @@ -224,7 +234,7 @@ class DeviceWithDoor(HomeConnectDevice): class DeviceWithLight(HomeConnectDevice): """Device that has lighting.""" - def get_light_entity(self): + def get_light_entity(self) -> dict[str, Any]: """Get a dictionary with info about the lighting.""" return {ATTR_DEVICE: self, ATTR_DESC: "Light", ATTR_AMBIENT: None} @@ -232,7 +242,7 @@ class DeviceWithLight(HomeConnectDevice): class DeviceWithAmbientLight(HomeConnectDevice): """Device that has ambient lighting.""" - def get_ambientlight_entity(self): + def get_ambientlight_entity(self) -> dict[str, Any]: """Get a dictionary with info about the ambient lighting.""" return {ATTR_DEVICE: self, ATTR_DESC: "AmbientLight", ATTR_AMBIENT: True} @@ -240,7 +250,7 @@ class DeviceWithAmbientLight(HomeConnectDevice): class DeviceWithRemoteControl(HomeConnectDevice): """Device that has Remote Control binary sensor.""" - def get_remote_control(self): + def get_remote_control(self) -> dict[str, Any]: """Get a dictionary with info about the remote control sensor.""" return { ATTR_DEVICE: self, @@ -252,7 +262,7 @@ class DeviceWithRemoteControl(HomeConnectDevice): class DeviceWithRemoteStart(HomeConnectDevice): """Device that has a Remote Start binary sensor.""" - def get_remote_start(self): + def get_remote_start(self) -> dict[str, Any]: """Get a dictionary with info about the remote start sensor.""" return { ATTR_DEVICE: self, @@ -270,7 +280,7 @@ class Dryer( ): """Dryer class.""" - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() remote_control = self.get_remote_control() @@ -295,7 +305,7 @@ class Dishwasher( ): """Dishwasher class.""" - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() remote_control = self.get_remote_control() @@ -321,7 +331,7 @@ class Oven( power_off_state = BSH_POWER_STANDBY - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() remote_control = self.get_remote_control() @@ -345,7 +355,7 @@ class Washer( ): """Washer class.""" - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() remote_control = self.get_remote_control() @@ -369,7 +379,7 @@ class WasherDryer( ): """WasherDryer class.""" - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() remote_control = self.get_remote_control() @@ -412,7 +422,7 @@ class Hood( ): """Hood class.""" - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() remote_start = self.get_remote_start() @@ -432,7 +442,7 @@ class Hood( class FridgeFreezer(DeviceWithDoor): """Fridge/Freezer class.""" - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() return {"binary_sensor": [door_entity]} @@ -441,7 +451,7 @@ class FridgeFreezer(DeviceWithDoor): class Refrigerator(DeviceWithDoor): """Refrigerator class.""" - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() return {"binary_sensor": [door_entity]} @@ -450,7 +460,7 @@ class Refrigerator(DeviceWithDoor): class Freezer(DeviceWithDoor): """Freezer class.""" - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" door_entity = self.get_door_entity() return {"binary_sensor": [door_entity]} @@ -459,7 +469,7 @@ class Freezer(DeviceWithDoor): class Hob(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteControl): """Hob class.""" - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" remote_control = self.get_remote_control() op_state_sensor = self.get_opstate_sensor() @@ -477,7 +487,7 @@ class CookProcessor(DeviceWithOpState): power_off_state = BSH_POWER_STANDBY - def get_entity_info(self): + def get_entity_info(self) -> dict[str, list[dict[str, Any]]]: """Get a dictionary with infos about the associated entities.""" op_state_sensor = self.get_opstate_sensor() return {"sensor": op_state_sensor} diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py index 758759c135b..c6c43a3119c 100644 --- a/homeassistant/components/home_connect/binary_sensor.py +++ b/homeassistant/components/home_connect/binary_sensor.py @@ -72,8 +72,8 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect binary sensor.""" - def get_entities(): - entities = [] + def get_entities() -> list[BinarySensorEntity]: + entities: list[BinarySensorEntity] = [] hc_api = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("binary_sensor", []) @@ -95,55 +95,59 @@ async def async_setup_entry( class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): """Binary sensor for Home Connect.""" - def __init__(self, device, desc, sensor_type, device_class=None): + def __init__( + self, + device: HomeConnectDevice, + desc: str, + sensor_type: str, + device_class: BinarySensorDeviceClass | None = None, + ) -> None: """Initialize the entity.""" super().__init__(device, desc) - self._state = None - self._device_class = device_class + self._attr_device_class = device_class self._type = sensor_type + self._false_value_list = None + self._true_value_list = None if self._type == "door": self._update_key = BSH_DOOR_STATE - self._false_value_list = (BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED) + self._false_value_list = [BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_LOCKED] self._true_value_list = [BSH_DOOR_STATE_OPEN] elif self._type == "remote_control": self._update_key = BSH_REMOTE_CONTROL_ACTIVATION_STATE - self._false_value_list = [False] - self._true_value_list = [True] elif self._type == "remote_start": self._update_key = BSH_REMOTE_START_ALLOWANCE_STATE - self._false_value_list = [False] - self._true_value_list = [True] - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return bool(self._state) @property def available(self) -> bool: """Return true if the binary sensor is available.""" - return self._state is not None + return self._attr_is_on is not None async def async_update(self) -> None: """Update the binary sensor's status.""" state = self.device.appliance.status.get(self._update_key, {}) if not state: - self._state = None - elif state.get(ATTR_VALUE) in self._false_value_list: - self._state = False - elif state.get(ATTR_VALUE) in self._true_value_list: - self._state = True + self._attr_is_on = None + return + + value = state.get(ATTR_VALUE) + if self._false_value_list and self._true_value_list: + if value in self._false_value_list: + self._attr_is_on = False + elif value in self._true_value_list: + self._attr_is_on = True + else: + _LOGGER.warning( + "Unexpected value for HomeConnect %s state: %s", self._type, state + ) + self._attr_is_on = None + elif isinstance(value, bool): + self._attr_is_on = value else: _LOGGER.warning( "Unexpected value for HomeConnect %s state: %s", self._type, state ) - self._state = None - _LOGGER.debug("Updated, new state: %s", self._state) - - @property - def device_class(self): - """Return the device class.""" - return self._device_class + self._attr_is_on = None + _LOGGER.debug("Updated, new state: %s", self._attr_is_on) class HomeConnectFridgeDoorBinarySensor(HomeConnectEntity, BinarySensorEntity): diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index d60f8a96e09..4ed14cd99af 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -30,7 +30,7 @@ class HomeConnectEntity(Entity): name=device.appliance.name, ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -39,13 +39,13 @@ class HomeConnectEntity(Entity): ) @callback - def _update_callback(self, ha_id): + def _update_callback(self, ha_id: str) -> None: """Update data.""" if ha_id == self.device.appliance.haId: self.async_entity_update() @callback - def async_entity_update(self): + def async_entity_update(self) -> None: """Update the entity.""" _LOGGER.debug("Entity update triggered on %s", self) self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index a1556d5caab..b7696493baa 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -70,9 +70,9 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect light.""" - def get_entities(): + def get_entities() -> list[LightEntity]: """Get a list of entities.""" - entities = [] + entities: list[LightEntity] = [] hc_api = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("light", []) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index c91864c2680..d1635a6bdfa 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -97,9 +97,9 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect sensor.""" - def get_entities(): + def get_entities() -> list[SensorEntity]: """Get a list of entities.""" - entities = [] + entities: list[SensorEntity] = [] hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("sensor", []) @@ -122,7 +122,19 @@ async def async_setup_entry( class HomeConnectSensor(HomeConnectEntity, SensorEntity): """Sensor class for Home Connect.""" - def __init__(self, device, desc, key, unit, icon, device_class, sign=1): + _key: str + _sign: int + + def __init__( + self, + device: HomeConnectDevice, + desc: str, + key: str, + unit: str, + icon: str, + device_class: SensorDeviceClass, + sign: int = 1, + ) -> None: """Initialize the entity.""" super().__init__(device, desc) self._key = key diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 80e8e4b2d39..63eabc2e31e 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -61,15 +61,15 @@ async def async_setup_entry( ) -> None: """Set up the Home Connect switch.""" - def get_entities(): + def get_entities() -> list[SwitchEntity]: """Get a list of entities.""" - entities = [] + entities: list[SwitchEntity] = [] hc_api: ConfigEntryAuth = hass.data[DOMAIN][config_entry.entry_id] for device_dict in hc_api.devices: entity_dicts = device_dict.get(CONF_ENTITIES, {}).get("switch", []) - entity_list = [HomeConnectProgramSwitch(**d) for d in entity_dicts] - entity_list += [HomeConnectPowerSwitch(device_dict[CONF_DEVICE])] - entity_list += [HomeConnectChildLockSwitch(device_dict[CONF_DEVICE])] + entities.extend(HomeConnectProgramSwitch(**d) for d in entity_dicts) + entities.append(HomeConnectPowerSwitch(device_dict[CONF_DEVICE])) + entities.append(HomeConnectChildLockSwitch(device_dict[CONF_DEVICE])) # Auto-discover entities hc_device: HomeConnectDevice = device_dict[CONF_DEVICE] entities.extend( @@ -77,7 +77,6 @@ async def async_setup_entry( for description in SWITCHES if description.on_key in hc_device.appliance.status ) - entities.extend(entity_list) return entities @@ -88,7 +87,6 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): """Generic switch class for Home Connect Binary Settings.""" entity_description: HomeConnectSwitchEntityDescription - _attr_available: bool = False def __init__( self, @@ -97,6 +95,7 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): ) -> None: """Initialize the entity.""" self.entity_description = entity_description + self._attr_available = False super().__init__(device=device, desc=entity_description.key) async def async_turn_on(self, **kwargs: Any) -> None: @@ -148,7 +147,7 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): """Switch class for Home Connect.""" - def __init__(self, device, program_name): + def __init__(self, device: HomeConnectDevice, program_name: str) -> None: """Initialize the entity.""" desc = " ".join(["Program", program_name.split(".")[-1]]) if device.appliance.type == "WasherDryer": @@ -191,7 +190,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): """Power switch class for Home Connect.""" - def __init__(self, device): + def __init__(self, device: HomeConnectDevice) -> None: """Initialize the entity.""" super().__init__(device, "Power") @@ -258,7 +257,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): class HomeConnectChildLockSwitch(HomeConnectEntity, SwitchEntity): """Child lock switch class for Home Connect.""" - def __init__(self, device) -> None: + def __init__(self, device: HomeConnectDevice) -> None: """Initialize the entity.""" super().__init__(device, "ChildLock") From f11cdb4ab4e1206bdeff8954091344aecd441ca2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 13:37:52 +0200 Subject: [PATCH 0989/1309] Move rfxtrx base entity to separate module (#126521) --- homeassistant/components/rfxtrx/__init__.py | 116 +---------------- .../components/rfxtrx/binary_sensor.py | 3 +- homeassistant/components/rfxtrx/const.py | 2 + homeassistant/components/rfxtrx/cover.py | 3 +- homeassistant/components/rfxtrx/entity.py | 123 ++++++++++++++++++ homeassistant/components/rfxtrx/event.py | 3 +- homeassistant/components/rfxtrx/light.py | 3 +- homeassistant/components/rfxtrx/sensor.py | 3 +- homeassistant/components/rfxtrx/siren.py | 8 +- homeassistant/components/rfxtrx/switch.py | 10 +- 10 files changed, 142 insertions(+), 132 deletions(-) create mode 100644 homeassistant/components/rfxtrx/entity.py diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 24a7f5ada51..d100999527f 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -25,21 +25,16 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.device_registry import ( - DeviceInfo, - EventDeviceRegistryUpdatedData, -) +from homeassistant.helpers.device_registry import EventDeviceRegistryUpdatedData from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from .const import ( ATTR_EVENT, - COMMAND_GROUP_LIST, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_PROTOCOLS, @@ -48,11 +43,11 @@ from .const import ( DOMAIN, EVENT_RFXTRX_EVENT, SERVICE_SEND, + SIGNAL_EVENT, ) DEFAULT_OFF_DELAY = 2.0 -SIGNAL_EVENT = f"{DOMAIN}_event" CONNECT_TIMEOUT = 30.0 _LOGGER = logging.getLogger(__name__) @@ -461,14 +456,6 @@ def get_device_tuple_from_identifiers( return DeviceTuple(identifier2[1], identifier2[2], identifier2[3]) -def get_identifiers_from_device_tuple( - device_tuple: DeviceTuple, -) -> set[tuple[str, str]]: - """Calculate the device identifier from a device tuple.""" - # work around legacy identifier, being a multi tuple value - return {(DOMAIN, *device_tuple)} # type: ignore[arg-type] - - async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: @@ -477,102 +464,3 @@ async def async_remove_config_entry_device( The actual cleanup is done in the device registry event """ return True - - -class RfxtrxEntity(RestoreEntity): - """Represents a Rfxtrx device. - - Contains the common logic for Rfxtrx lights and switches. - """ - - _attr_assumed_state = True - _attr_has_entity_name = True - _attr_should_poll = False - _device: rfxtrxmod.RFXtrxDevice - _event: rfxtrxmod.RFXtrxEvent | None - - def __init__( - self, - device: rfxtrxmod.RFXtrxDevice, - device_id: DeviceTuple, - event: rfxtrxmod.RFXtrxEvent | None = None, - ) -> None: - """Initialize the device.""" - self._attr_device_info = DeviceInfo( - identifiers=get_identifiers_from_device_tuple(device_id), - model=device.type_string, - name=f"{device.type_string} {device.id_string}", - ) - self._attr_unique_id = "_".join(x for x in device_id) - self._device = device - self._event = event - self._device_id = device_id - # If id_string is 213c7f2:1, the group_id is 213c7f2, and the device will respond to - # group events regardless of their group indices. - (self._group_id, _, _) = cast(str, device.id_string).partition(":") - - async def async_added_to_hass(self) -> None: - """Restore RFXtrx device state (ON/OFF).""" - if self._event: - self._apply_event(self._event) - - self.async_on_remove( - async_dispatcher_connect(self.hass, SIGNAL_EVENT, self._handle_event) - ) - - @property - def extra_state_attributes(self) -> dict[str, str] | None: - """Return the device state attributes.""" - if not self._event: - return None - return {ATTR_EVENT: "".join(f"{x:02x}" for x in self._event.data)} - - def _event_applies( - self, event: rfxtrxmod.RFXtrxEvent, device_id: DeviceTuple - ) -> bool: - """Check if event applies to me.""" - if isinstance(event, rfxtrxmod.ControlEvent): - if ( - "Command" in event.values - and event.values["Command"] in COMMAND_GROUP_LIST - ): - device: rfxtrxmod.RFXtrxDevice = event.device - (group_id, _, _) = cast(str, device.id_string).partition(":") - return group_id == self._group_id - - # Otherwise, the event only applies to the matching device. - return device_id == self._device_id - - def _apply_event(self, event: rfxtrxmod.RFXtrxEvent) -> None: - """Apply a received event.""" - self._event = event - - @callback - def _handle_event( - self, event: rfxtrxmod.RFXtrxEvent, device_id: DeviceTuple - ) -> None: - """Handle a reception of data, overridden by other classes.""" - - -class RfxtrxCommandEntity(RfxtrxEntity): - """Represents a Rfxtrx device. - - Contains the common logic for Rfxtrx lights and switches. - """ - - _attr_name = None - - def __init__( - self, - device: rfxtrxmod.RFXtrxDevice, - device_id: DeviceTuple, - event: rfxtrxmod.RFXtrxEvent | None = None, - ) -> None: - """Initialzie a switch or light device.""" - super().__init__(device, device_id, event=event) - - async def _async_send[*_Ts]( - self, fun: Callable[[rfxtrxmod.PySerialTransport, *_Ts], None], *args: *_Ts - ) -> None: - rfx_object: rfxtrxmod.Connect = self.hass.data[DOMAIN][DATA_RFXOBJECT] - await self.hass.async_add_executor_job(fun, rfx_object.transport, *args) diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 03c22167358..316cf44ef0d 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -19,7 +19,7 @@ from homeassistant.helpers import event as evt from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DeviceTuple, RfxtrxEntity, async_setup_platform_entry, get_pt2262_cmd +from . import DeviceTuple, async_setup_platform_entry, get_pt2262_cmd from .const import ( COMMAND_OFF_LIST, COMMAND_ON_LIST, @@ -27,6 +27,7 @@ from .const import ( CONF_OFF_DELAY, DEVICE_PACKET_TYPE_LIGHTING4, ) +from .entity import RfxtrxEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rfxtrx/const.py b/homeassistant/components/rfxtrx/const.py index 7a6e333d3db..f932c825f75 100644 --- a/homeassistant/components/rfxtrx/const.py +++ b/homeassistant/components/rfxtrx/const.py @@ -46,3 +46,5 @@ EVENT_RFXTRX_EVENT = "rfxtrx_event" DATA_RFXOBJECT = "rfxobject" DOMAIN = "rfxtrx" + +SIGNAL_EVENT = f"{DOMAIN}_event" diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index 9e9e5a090e4..1d3bdf26910 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DeviceTuple, RfxtrxCommandEntity, async_setup_platform_entry +from . import DeviceTuple, async_setup_platform_entry from .const import ( COMMAND_OFF_LIST, COMMAND_ON_LIST, @@ -22,6 +22,7 @@ from .const import ( CONST_VENETIAN_BLIND_MODE_EU, CONST_VENETIAN_BLIND_MODE_US, ) +from .entity import RfxtrxCommandEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rfxtrx/entity.py b/homeassistant/components/rfxtrx/entity.py new file mode 100644 index 00000000000..b5752e366bc --- /dev/null +++ b/homeassistant/components/rfxtrx/entity.py @@ -0,0 +1,123 @@ +"""Support for RFXtrx devices.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import cast + +import RFXtrx as rfxtrxmod + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity + +from . import DeviceTuple +from .const import ATTR_EVENT, COMMAND_GROUP_LIST, DATA_RFXOBJECT, DOMAIN, SIGNAL_EVENT + + +def _get_identifiers_from_device_tuple( + device_tuple: DeviceTuple, +) -> set[tuple[str, str]]: + """Calculate the device identifier from a device tuple.""" + # work around legacy identifier, being a multi tuple value + return {(DOMAIN, *device_tuple)} # type: ignore[arg-type] + + +class RfxtrxEntity(RestoreEntity): + """Represents a Rfxtrx device. + + Contains the common logic for Rfxtrx lights and switches. + """ + + _attr_assumed_state = True + _attr_has_entity_name = True + _attr_should_poll = False + _device: rfxtrxmod.RFXtrxDevice + _event: rfxtrxmod.RFXtrxEvent | None + + def __init__( + self, + device: rfxtrxmod.RFXtrxDevice, + device_id: DeviceTuple, + event: rfxtrxmod.RFXtrxEvent | None = None, + ) -> None: + """Initialize the device.""" + self._attr_device_info = DeviceInfo( + identifiers=_get_identifiers_from_device_tuple(device_id), + model=device.type_string, + name=f"{device.type_string} {device.id_string}", + ) + self._attr_unique_id = "_".join(x for x in device_id) + self._device = device + self._event = event + self._device_id = device_id + # If id_string is 213c7f2:1, the group_id is 213c7f2, and the device will respond to + # group events regardless of their group indices. + (self._group_id, _, _) = cast(str, device.id_string).partition(":") + + async def async_added_to_hass(self) -> None: + """Restore RFXtrx device state (ON/OFF).""" + if self._event: + self._apply_event(self._event) + + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_EVENT, self._handle_event) + ) + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return the device state attributes.""" + if not self._event: + return None + return {ATTR_EVENT: "".join(f"{x:02x}" for x in self._event.data)} + + def _event_applies( + self, event: rfxtrxmod.RFXtrxEvent, device_id: DeviceTuple + ) -> bool: + """Check if event applies to me.""" + if isinstance(event, rfxtrxmod.ControlEvent): + if ( + "Command" in event.values + and event.values["Command"] in COMMAND_GROUP_LIST + ): + device: rfxtrxmod.RFXtrxDevice = event.device + (group_id, _, _) = cast(str, device.id_string).partition(":") + return group_id == self._group_id + + # Otherwise, the event only applies to the matching device. + return device_id == self._device_id + + def _apply_event(self, event: rfxtrxmod.RFXtrxEvent) -> None: + """Apply a received event.""" + self._event = event + + @callback + def _handle_event( + self, event: rfxtrxmod.RFXtrxEvent, device_id: DeviceTuple + ) -> None: + """Handle a reception of data, overridden by other classes.""" + + +class RfxtrxCommandEntity(RfxtrxEntity): + """Represents a Rfxtrx device. + + Contains the common logic for Rfxtrx lights and switches. + """ + + _attr_name = None + + def __init__( + self, + device: rfxtrxmod.RFXtrxDevice, + device_id: DeviceTuple, + event: rfxtrxmod.RFXtrxEvent | None = None, + ) -> None: + """Initialzie a switch or light device.""" + super().__init__(device, device_id, event=event) + + async def _async_send[*_Ts]( + self, fun: Callable[[rfxtrxmod.PySerialTransport, *_Ts], None], *args: *_Ts + ) -> None: + rfx_object: rfxtrxmod.Connect = self.hass.data[DOMAIN][DATA_RFXOBJECT] + await self.hass.async_add_executor_job(fun, rfx_object.transport, *args) diff --git a/homeassistant/components/rfxtrx/event.py b/homeassistant/components/rfxtrx/event.py index 5c3944dc74b..212d93b5019 100644 --- a/homeassistant/components/rfxtrx/event.py +++ b/homeassistant/components/rfxtrx/event.py @@ -14,8 +14,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify -from . import DeviceTuple, RfxtrxEntity, async_setup_platform_entry +from . import DeviceTuple, async_setup_platform_entry from .const import DEVICE_PACKET_TYPE_LIGHTING4 +from .entity import RfxtrxEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py index f9bbbc28a8d..0e2f7bef65a 100644 --- a/homeassistant/components/rfxtrx/light.py +++ b/homeassistant/components/rfxtrx/light.py @@ -14,8 +14,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DeviceTuple, RfxtrxCommandEntity, async_setup_platform_entry +from . import DeviceTuple, async_setup_platform_entry from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST +from .entity import RfxtrxCommandEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 46a3f021122..cc195c9944e 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -39,8 +39,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import DeviceTuple, RfxtrxEntity, async_setup_platform_entry, get_rfx_object +from . import DeviceTuple, async_setup_platform_entry, get_rfx_object from .const import ATTR_EVENT +from .entity import RfxtrxEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py index 17112619acb..1635f1f55a9 100644 --- a/homeassistant/components/rfxtrx/siren.py +++ b/homeassistant/components/rfxtrx/siren.py @@ -14,13 +14,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from . import ( - DEFAULT_OFF_DELAY, - DeviceTuple, - RfxtrxCommandEntity, - async_setup_platform_entry, -) +from . import DEFAULT_OFF_DELAY, DeviceTuple, async_setup_platform_entry from .const import CONF_OFF_DELAY +from .entity import RfxtrxCommandEntity SECURITY_PANIC_ON = "Panic" SECURITY_PANIC_OFF = "End Panic" diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index fad395f41c2..1464cccb5c4 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -14,19 +14,15 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - DOMAIN, - DeviceTuple, - RfxtrxCommandEntity, - async_setup_platform_entry, - get_pt2262_cmd, -) +from . import DeviceTuple, async_setup_platform_entry, get_pt2262_cmd from .const import ( COMMAND_OFF_LIST, COMMAND_ON_LIST, CONF_DATA_BITS, DEVICE_PACKET_TYPE_LIGHTING4, + DOMAIN, ) +from .entity import RfxtrxCommandEntity DATA_SWITCH = f"{DOMAIN}_switch" From a1abea4e0f86dd1d2b6a5696fac91c6d113339a0 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 23 Sep 2024 21:48:00 +1000 Subject: [PATCH 0990/1309] Add button platform to Tesla Fleet (#126410) * Add button platform * Fix tests * Fix button setup * Make func required * do_nothing --- .../components/tesla_fleet/__init__.py | 1 + .../components/tesla_fleet/button.py | 97 ++++++ .../components/tesla_fleet/icons.json | 20 ++ .../components/tesla_fleet/strings.json | 20 ++ .../tesla_fleet/snapshots/test_button.ambr | 277 ++++++++++++++++++ tests/components/tesla_fleet/test_button.py | 58 ++++ 6 files changed, 473 insertions(+) create mode 100644 homeassistant/components/tesla_fleet/button.py create mode 100644 tests/components/tesla_fleet/snapshots/test_button.ambr create mode 100644 tests/components/tesla_fleet/test_button.py diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index 9825325a948..61f9dc66ffc 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -41,6 +41,7 @@ from .oauth import TeslaSystemImplementation PLATFORMS: Final = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, diff --git a/homeassistant/components/tesla_fleet/button.py b/homeassistant/components/tesla_fleet/button.py new file mode 100644 index 00000000000..548bf065397 --- /dev/null +++ b/homeassistant/components/tesla_fleet/button.py @@ -0,0 +1,97 @@ +"""Button platform for Tesla Fleet integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from tesla_fleet_api.const import Scope + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TeslaFleetConfigEntry +from .entity import TeslaFleetVehicleEntity +from .helpers import handle_vehicle_command +from .models import TeslaFleetVehicleData + +PARALLEL_UPDATES = 0 + + +async def do_nothing() -> None: + """Do nothing.""" + + +@dataclass(frozen=True, kw_only=True) +class TeslaFleetButtonEntityDescription(ButtonEntityDescription): + """Describes a TeslaFleet Button entity.""" + + func: Callable[[TeslaFleetButtonEntity], Awaitable[Any]] + + +DESCRIPTIONS: tuple[TeslaFleetButtonEntityDescription, ...] = ( + TeslaFleetButtonEntityDescription( + key="wake", func=lambda self: do_nothing() + ), # Every button runs wakeup, so func does nothing + TeslaFleetButtonEntityDescription( + key="flash_lights", func=lambda self: self.api.flash_lights() + ), + TeslaFleetButtonEntityDescription( + key="honk", func=lambda self: self.api.honk_horn() + ), + TeslaFleetButtonEntityDescription( + key="enable_keyless_driving", func=lambda self: self.api.remote_start_drive() + ), + TeslaFleetButtonEntityDescription( + key="boombox", func=lambda self: self.api.remote_boombox(0) + ), + TeslaFleetButtonEntityDescription( + key="homelink", + func=lambda self: self.api.trigger_homelink( + lat=self.coordinator.data["drive_state_latitude"], + lon=self.coordinator.data["drive_state_longitude"], + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TeslaFleetConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the TeslaFleet Button platform from a config entry.""" + + async_add_entities( + TeslaFleetButtonEntity(vehicle, description) + for vehicle in entry.runtime_data.vehicles + for description in DESCRIPTIONS + if Scope.VEHICLE_CMDS in entry.runtime_data.scopes + and (not vehicle.signing or description.key == "wake") + # Wake doesn't need signing + ) + + +class TeslaFleetButtonEntity(TeslaFleetVehicleEntity, ButtonEntity): + """Base class for TeslaFleet buttons.""" + + entity_description: TeslaFleetButtonEntityDescription + + def __init__( + self, + data: TeslaFleetVehicleData, + description: TeslaFleetButtonEntityDescription, + ) -> None: + """Initialize the button.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + async def async_press(self) -> None: + """Press the button.""" + await self.wake_up_if_asleep() + await handle_vehicle_command(self.entity_description.func(self)) diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index d8708163a53..aa5c1c920d4 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -38,6 +38,26 @@ } } }, + "button": { + "boombox": { + "default": "mdi:volume-high" + }, + "enable_keyless_driving": { + "default": "mdi:car-key" + }, + "flash_lights": { + "default": "mdi:flashlight" + }, + "homelink": { + "default": "mdi:garage" + }, + "honk": { + "default": "mdi:bullhorn" + }, + "wake": { + "default": "mdi:sleep-off" + } + }, "climate": { "driver_temp": { "state_attributes": { diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 9b8de58665c..8f7f91b4960 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -107,6 +107,26 @@ "name": "Tire pressure warning rear right" } }, + "button": { + "boombox": { + "name": "Play fart" + }, + "enable_keyless_driving": { + "name": "Keyless driving" + }, + "flash_lights": { + "name": "Flash lights" + }, + "homelink": { + "name": "Homelink" + }, + "honk": { + "name": "Honk horn" + }, + "wake": { + "name": "Wake" + } + }, "climate": { "climate_state_cabin_overheat_protection": { "name": "Cabin overheat protection" diff --git a/tests/components/tesla_fleet/snapshots/test_button.ambr b/tests/components/tesla_fleet/snapshots/test_button.ambr new file mode 100644 index 00000000000..8b5270d4852 --- /dev/null +++ b/tests/components/tesla_fleet/snapshots/test_button.ambr @@ -0,0 +1,277 @@ +# serializer version: 1 +# name: test_button[button.test_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flash_lights', + 'unique_id': 'LRWXF7EK4KC700000-flash_lights', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Flash lights', + }), + 'context': , + 'entity_id': 'button.test_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_homelink-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_homelink', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Homelink', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'homelink', + 'unique_id': 'LRWXF7EK4KC700000-homelink', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_homelink-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink', + }), + 'context': , + 'entity_id': 'button.test_homelink', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_honk_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_honk_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Honk horn', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'honk', + 'unique_id': 'LRWXF7EK4KC700000-honk', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_honk_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Honk horn', + }), + 'context': , + 'entity_id': 'button.test_honk_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_keyless_driving-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_keyless_driving', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Keyless driving', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'enable_keyless_driving', + 'unique_id': 'LRWXF7EK4KC700000-enable_keyless_driving', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_keyless_driving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Keyless driving', + }), + 'context': , + 'entity_id': 'button.test_keyless_driving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_play_fart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_play_fart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Play fart', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boombox', + 'unique_id': 'LRWXF7EK4KC700000-boombox', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_play_fart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Play fart', + }), + 'context': , + 'entity_id': 'button.test_play_fart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_wake-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_wake', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wake', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wake', + 'unique_id': 'LRWXF7EK4KC700000-wake', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_wake-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wake', + }), + 'context': , + 'entity_id': 'button.test_wake', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tesla_fleet/test_button.py b/tests/components/tesla_fleet/test_button.py new file mode 100644 index 00000000000..8b83011e6f4 --- /dev/null +++ b/tests/components/tesla_fleet/test_button.py @@ -0,0 +1,58 @@ +"""Test the Tesla Fleet button platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + normal_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the button entities are correct.""" + + await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) + assert_entities(hass, normal_config_entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.parametrize( + ("name", "func"), + [ + ("flash_lights", "flash_lights"), + ("honk_horn", "honk_horn"), + ("keyless_driving", "remote_start_drive"), + ("play_fart", "remote_boombox"), + ("homelink", "trigger_homelink"), + ], +) +async def test_press( + hass: HomeAssistant, normal_config_entry: MockConfigEntry, name: str, func: str +) -> None: + """Test pressing the API buttons.""" + await setup_platform(hass, normal_config_entry, [Platform.BUTTON]) + + with patch( + f"homeassistant.components.tesla_fleet.VehicleSpecific.{func}", + return_value=COMMAND_OK, + ) as command: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: [f"button.test_{name}"]}, + blocking=True, + ) + command.assert_called_once() From 0bcaa734279d1bb7e08ab3d400b3164f37f858bb Mon Sep 17 00:00:00 2001 From: Iskra kranj <162285659+iskrakranj@users.noreply.github.com> Date: Mon, 23 Sep 2024 13:57:02 +0200 Subject: [PATCH 0991/1309] Bump pyiskra to 0.1.14 (#126518) --- homeassistant/components/iskra/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json index ff7ff700e30..94f20b4d93c 100644 --- a/homeassistant/components/iskra/manifest.json +++ b/homeassistant/components/iskra/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyiskra"], - "requirements": ["pyiskra==0.1.11"] + "requirements": ["pyiskra==0.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9a75fc3e9bd..b1fdee04607 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1966,7 +1966,7 @@ pyiqvia==2022.04.0 pyirishrail==0.0.2 # homeassistant.components.iskra -pyiskra==0.1.11 +pyiskra==0.1.14 # homeassistant.components.iss pyiss==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3447b3c029..b07412bb2e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1577,7 +1577,7 @@ pyipp==0.16.0 pyiqvia==2022.04.0 # homeassistant.components.iskra -pyiskra==0.1.11 +pyiskra==0.1.14 # homeassistant.components.iss pyiss==1.0.1 From 4cb162a06899339ca36cf32db946db0f2fc34260 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 13:58:16 +0200 Subject: [PATCH 0992/1309] Move sia base entity to separate module (#126524) --- homeassistant/components/sia/alarm_control_panel.py | 2 +- homeassistant/components/sia/binary_sensor.py | 2 +- homeassistant/components/sia/{sia_entity_base.py => entity.py} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename homeassistant/components/sia/{sia_entity_base.py => entity.py} (100%) diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 42ce81cbfc1..2b2a32ca67d 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -25,7 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from .const import CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ZONES, KEY_ALARM, PREVIOUS_STATE -from .sia_entity_base import SIABaseEntity, SIAEntityDescription +from .entity import SIABaseEntity, SIAEntityDescription _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index 307b5073e90..4c8e4ca6130 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -28,7 +28,7 @@ from .const import ( KEY_SMOKE, SIA_HUB_ZONE, ) -from .sia_entity_base import SIABaseEntity, SIAEntityDescription +from .entity import SIABaseEntity, SIAEntityDescription _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sia/sia_entity_base.py b/homeassistant/components/sia/entity.py similarity index 100% rename from homeassistant/components/sia/sia_entity_base.py rename to homeassistant/components/sia/entity.py From 60eba6d7834f6c4ca37257aee5e802a29c8a05ac Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:02:03 +0200 Subject: [PATCH 0993/1309] Rename toon base entity module (#126525) --- homeassistant/components/toon/binary_sensor.py | 2 +- homeassistant/components/toon/climate.py | 2 +- homeassistant/components/toon/{models.py => entity.py} | 0 homeassistant/components/toon/helpers.py | 2 +- homeassistant/components/toon/sensor.py | 2 +- homeassistant/components/toon/switch.py | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename homeassistant/components/toon/{models.py => entity.py} (100%) diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index b184e5aacb7..11b13a32ee5 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import ToonDataUpdateCoordinator -from .models import ( +from .entity import ( ToonBoilerDeviceEntity, ToonBoilerModuleDeviceEntity, ToonDisplayDeviceEntity, diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index 1570a637f95..365706ba4fd 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -28,8 +28,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ToonDataUpdateCoordinator from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN +from .entity import ToonDisplayDeviceEntity from .helpers import toon_exception_handler -from .models import ToonDisplayDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/entity.py similarity index 100% rename from homeassistant/components/toon/models.py rename to homeassistant/components/toon/entity.py diff --git a/homeassistant/components/toon/helpers.py b/homeassistant/components/toon/helpers.py index 0dd740544df..d65a6d76676 100644 --- a/homeassistant/components/toon/helpers.py +++ b/homeassistant/components/toon/helpers.py @@ -8,7 +8,7 @@ from typing import Any, Concatenate from toonapi import ToonConnectionError, ToonError -from .models import ToonEntity +from .entity import ToonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 09fdcb4e4ab..09f36c88079 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CURRENCY_EUR, DOMAIN, VOLUME_CM3, VOLUME_LMIN from .coordinator import ToonDataUpdateCoordinator -from .models import ( +from .entity import ( ToonBoilerDeviceEntity, ToonDisplayDeviceEntity, ToonElectricityMeterDeviceEntity, diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py index b491505a8a5..deb2a12f2d0 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -19,8 +19,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import ToonDataUpdateCoordinator +from .entity import ToonDisplayDeviceEntity, ToonEntity, ToonRequiredKeysMixin from .helpers import toon_exception_handler -from .models import ToonDisplayDeviceEntity, ToonEntity, ToonRequiredKeysMixin async def async_setup_entry( From 46f9e86f6aa10b87d269443c980a344319f526b9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:14:22 +0200 Subject: [PATCH 0994/1309] Move tailscale base entity to separate module (#126527) --- .../components/tailscale/__init__.py | 46 ---------------- .../components/tailscale/binary_sensor.py | 2 +- homeassistant/components/tailscale/entity.py | 52 +++++++++++++++++++ homeassistant/components/tailscale/sensor.py | 2 +- 4 files changed, 54 insertions(+), 48 deletions(-) create mode 100644 homeassistant/components/tailscale/entity.py diff --git a/homeassistant/components/tailscale/__init__.py b/homeassistant/components/tailscale/__init__.py index 5498687332f..549bf07e181 100644 --- a/homeassistant/components/tailscale/__init__.py +++ b/homeassistant/components/tailscale/__init__.py @@ -2,17 +2,9 @@ from __future__ import annotations -from tailscale import Device as TailscaleDevice - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from .const import DOMAIN from .coordinator import TailscaleDataUpdateCoordinator @@ -37,41 +29,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: del hass.data[DOMAIN][entry.entry_id] return unload_ok - - -class TailscaleEntity(CoordinatorEntity): - """Defines a Tailscale base entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - *, - coordinator: DataUpdateCoordinator, - device: TailscaleDevice, - description: EntityDescription, - ) -> None: - """Initialize a Tailscale sensor.""" - super().__init__(coordinator=coordinator) - self.entity_description = description - self.device_id = device.device_id - self._attr_unique_id = f"{device.device_id}_{description.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - device: TailscaleDevice = self.coordinator.data[self.device_id] - - configuration_url = "https://login.tailscale.com/admin/machines/" - if device.addresses: - configuration_url += device.addresses[0] - - return DeviceInfo( - configuration_url=configuration_url, - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, device.device_id)}, - manufacturer="Tailscale Inc.", - model=device.os, - name=device.name.split(".")[0], - sw_version=device.client_version, - ) diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index 7803a7eb472..981f871de09 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -17,8 +17,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TailscaleEntity from .const import DOMAIN +from .entity import TailscaleEntity @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/tailscale/entity.py b/homeassistant/components/tailscale/entity.py new file mode 100644 index 00000000000..a14b873a00f --- /dev/null +++ b/homeassistant/components/tailscale/entity.py @@ -0,0 +1,52 @@ +"""The Tailscale integration.""" + +from __future__ import annotations + +from tailscale import Device as TailscaleDevice + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + + +class TailscaleEntity(CoordinatorEntity): + """Defines a Tailscale base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + *, + coordinator: DataUpdateCoordinator, + device: TailscaleDevice, + description: EntityDescription, + ) -> None: + """Initialize a Tailscale sensor.""" + super().__init__(coordinator=coordinator) + self.entity_description = description + self.device_id = device.device_id + self._attr_unique_id = f"{device.device_id}_{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device info.""" + device: TailscaleDevice = self.coordinator.data[self.device_id] + + configuration_url = "https://login.tailscale.com/admin/machines/" + if device.addresses: + configuration_url += device.addresses[0] + + return DeviceInfo( + configuration_url=configuration_url, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, device.device_id)}, + manufacturer="Tailscale Inc.", + model=device.os, + name=device.name.split(".")[0], + sw_version=device.client_version, + ) diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py index 99b91d17442..fa4c966a7d7 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -18,8 +18,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import TailscaleEntity from .const import DOMAIN +from .entity import TailscaleEntity @dataclass(frozen=True, kw_only=True) From a579eef66c777479814a8ee4eab2e02979e9ec56 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:16:13 +0200 Subject: [PATCH 0995/1309] Move tesla_wall_connector base entity to separate module (#126529) --- .../tesla_wall_connector/__init__.py | 47 +---------------- .../tesla_wall_connector/binary_sensor.py | 7 +-- .../components/tesla_wall_connector/entity.py | 50 +++++++++++++++++++ .../components/tesla_wall_connector/sensor.py | 7 +-- 4 files changed, 55 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/tesla_wall_connector/entity.py diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 28ddc15ade7..f4d04ca8cc6 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -2,11 +2,9 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any from tesla_wall_connector import WallConnector from tesla_wall_connector.exceptions import ( @@ -20,19 +18,13 @@ from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS, - WALLCONNECTOR_DEVICE_NAME, ) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -123,43 +115,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def get_unique_id(serial_number: str, key: str) -> str: - """Get a unique entity name.""" - return f"{serial_number}-{key}" - - -class WallConnectorEntity(CoordinatorEntity): - """Base class for Wall Connector entities.""" - - _attr_has_entity_name = True - - def __init__(self, wall_connector_data: WallConnectorData) -> None: - """Initialize WallConnector Entity.""" - self.wall_connector_data = wall_connector_data - self._attr_unique_id = get_unique_id( - wall_connector_data.serial_number, self.entity_description.key - ) - super().__init__(wall_connector_data.update_coordinator) - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.wall_connector_data.serial_number)}, - name=WALLCONNECTOR_DEVICE_NAME, - model=self.wall_connector_data.part_number, - sw_version=self.wall_connector_data.firmware_version, - manufacturer="Tesla", - ) - - -@dataclass(frozen=True) -class WallConnectorLambdaValueGetterMixin: - """Mixin with a function pointer for getting sensor value.""" - - value_fn: Callable[[dict], Any] - - @dataclass class WallConnectorData: """Data for the Tesla Wall Connector integration.""" diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index cf8fbf53b52..f7ef385b8ed 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -13,12 +13,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - WallConnectorData, - WallConnectorEntity, - WallConnectorLambdaValueGetterMixin, -) +from . import WallConnectorData from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS +from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tesla_wall_connector/entity.py b/homeassistant/components/tesla_wall_connector/entity.py new file mode 100644 index 00000000000..ea08a00e791 --- /dev/null +++ b/homeassistant/components/tesla_wall_connector/entity.py @@ -0,0 +1,50 @@ +"""The Tesla Wall Connector integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import WallConnectorData +from .const import DOMAIN, WALLCONNECTOR_DEVICE_NAME + + +@dataclass(frozen=True) +class WallConnectorLambdaValueGetterMixin: + """Mixin with a function pointer for getting sensor value.""" + + value_fn: Callable[[dict], Any] + + +def _get_unique_id(serial_number: str, key: str) -> str: + """Get a unique entity name.""" + return f"{serial_number}-{key}" + + +class WallConnectorEntity(CoordinatorEntity): + """Base class for Wall Connector entities.""" + + _attr_has_entity_name = True + + def __init__(self, wall_connector_data: WallConnectorData) -> None: + """Initialize WallConnector Entity.""" + self.wall_connector_data = wall_connector_data + self._attr_unique_id = _get_unique_id( + wall_connector_data.serial_number, self.entity_description.key + ) + super().__init__(wall_connector_data.update_coordinator) + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return DeviceInfo( + identifiers={(DOMAIN, self.wall_connector_data.serial_number)}, + name=WALLCONNECTOR_DEVICE_NAME, + model=self.wall_connector_data.part_number, + sw_version=self.wall_connector_data.firmware_version, + manufacturer="Tesla", + ) diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 077f70c5370..a50c81c912e 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -21,12 +21,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ( - WallConnectorData, - WallConnectorEntity, - WallConnectorLambdaValueGetterMixin, -) +from . import WallConnectorData from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS +from .entity import WallConnectorEntity, WallConnectorLambdaValueGetterMixin _LOGGER = logging.getLogger(__name__) From 9fcefca0f5960ae79890567825f583d86ef2f73c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:16:24 +0200 Subject: [PATCH 0996/1309] Rename tradfri base entity module (#126526) * Rename tradfri base entity module * Missed a file --- homeassistant/components/tradfri/cover.py | 2 +- homeassistant/components/tradfri/{base_class.py => entity.py} | 0 homeassistant/components/tradfri/fan.py | 2 +- homeassistant/components/tradfri/light.py | 2 +- homeassistant/components/tradfri/sensor.py | 2 +- homeassistant/components/tradfri/switch.py | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename homeassistant/components/tradfri/{base_class.py => entity.py} (100%) diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index 873b5f3cd07..92d10320327 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -12,9 +12,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .base_class import TradfriBaseEntity from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator +from .entity import TradfriBaseEntity async def async_setup_entry( diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/entity.py similarity index 100% rename from homeassistant/components/tradfri/base_class.py rename to homeassistant/components/tradfri/entity.py diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index 6561fc166dc..75616607ee8 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -12,9 +12,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .base_class import TradfriBaseEntity from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator +from .entity import TradfriBaseEntity ATTR_AUTO = "Auto" ATTR_MAX_FAN_STEPS = 49 diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index ef65c6bf957..b0bf6d24019 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -22,9 +22,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util -from .base_class import TradfriBaseEntity from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator +from .entity import TradfriBaseEntity async def async_setup_entry( diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 5d3e63d3a5d..4e560f0e7b5 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -26,7 +26,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .base_class import TradfriBaseEntity from .const import ( CONF_GATEWAY_ID, COORDINATOR, @@ -36,6 +35,7 @@ from .const import ( LOGGER, ) from .coordinator import TradfriDeviceDataUpdateCoordinator +from .entity import TradfriBaseEntity @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index 20695f26500..088b775b9fd 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -12,9 +12,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .base_class import TradfriBaseEntity from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API from .coordinator import TradfriDeviceDataUpdateCoordinator +from .entity import TradfriBaseEntity async def async_setup_entry( From df0c8064b27bc3d36e51f119fd1bec0da4cedd11 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:17:28 +0200 Subject: [PATCH 0997/1309] Move tolo base entity to separate module (#126530) --- homeassistant/components/tolo/__init__.py | 25 +---------------- .../components/tolo/binary_sensor.py | 3 +- homeassistant/components/tolo/button.py | 3 +- homeassistant/components/tolo/climate.py | 3 +- homeassistant/components/tolo/entity.py | 28 +++++++++++++++++++ homeassistant/components/tolo/fan.py | 3 +- homeassistant/components/tolo/light.py | 3 +- homeassistant/components/tolo/number.py | 3 +- homeassistant/components/tolo/select.py | 3 +- homeassistant/components/tolo/sensor.py | 3 +- homeassistant/components/tolo/switch.py | 3 +- 11 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 homeassistant/components/tolo/entity.py diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py index a90d23b0e22..58ba9f550a9 100644 --- a/homeassistant/components/tolo/__init__.py +++ b/homeassistant/components/tolo/__init__.py @@ -11,12 +11,7 @@ from tololib import ToloClient, ToloSettings, ToloStatus from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DEFAULT_RETRY_COUNT, DEFAULT_RETRY_TIMEOUT, DOMAIN @@ -89,21 +84,3 @@ class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): # pylin except TimeoutError as error: raise UpdateFailed("communication timeout") from error return ToloSaunaData(status, settings) - - -class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): - """CoordinatorEntity for TOLO Sauna.""" - - _attr_has_entity_name = True - - def __init__( - self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry - ) -> None: - """Initialize ToloSaunaCoordinatorEntity.""" - super().__init__(coordinator) - self._attr_device_info = DeviceInfo( - name="TOLO Sauna", - identifiers={(DOMAIN, entry.entry_id)}, - manufacturer="SteamTec", - model=self.coordinator.data.status.model.name.capitalize(), - ) diff --git a/homeassistant/components/tolo/binary_sensor.py b/homeassistant/components/tolo/binary_sensor.py index f8cb442c92f..835bc913a86 100644 --- a/homeassistant/components/tolo/binary_sensor.py +++ b/homeassistant/components/tolo/binary_sensor.py @@ -9,8 +9,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( diff --git a/homeassistant/components/tolo/button.py b/homeassistant/components/tolo/button.py index 9a8ac67b9fe..7c32d7d7a29 100644 --- a/homeassistant/components/tolo/button.py +++ b/homeassistant/components/tolo/button.py @@ -8,8 +8,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index 2994d97d54a..f6360e1d99b 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -25,8 +25,9 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTempera from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( diff --git a/homeassistant/components/tolo/entity.py b/homeassistant/components/tolo/entity.py new file mode 100644 index 00000000000..68ddc382e7f --- /dev/null +++ b/homeassistant/components/tolo/entity.py @@ -0,0 +1,28 @@ +"""Component to control TOLO Sauna/Steam Bath.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ToloSaunaUpdateCoordinator +from .const import DOMAIN + + +class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): + """CoordinatorEntity for TOLO Sauna.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry + ) -> None: + """Initialize ToloSaunaCoordinatorEntity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + name="TOLO Sauna", + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="SteamTec", + model=self.coordinator.data.status.model.name.capitalize(), + ) diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py index 034bdb0b6a6..396dc0b0da4 100644 --- a/homeassistant/components/tolo/fan.py +++ b/homeassistant/components/tolo/fan.py @@ -9,8 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( diff --git a/homeassistant/components/tolo/light.py b/homeassistant/components/tolo/light.py index 809bb367072..5491aa90ea4 100644 --- a/homeassistant/components/tolo/light.py +++ b/homeassistant/components/tolo/light.py @@ -9,8 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .entity import ToloSaunaCoordinatorEntity async def async_setup_entry( diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index 2d2c20715fa..acdd26fe9c0 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -20,8 +20,9 @@ from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .entity import ToloSaunaCoordinatorEntity @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/tolo/select.py b/homeassistant/components/tolo/select.py index 96335cecc68..b41595d3a34 100644 --- a/homeassistant/components/tolo/select.py +++ b/homeassistant/components/tolo/select.py @@ -13,8 +13,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from . import ToloSaunaUpdateCoordinator from .const import DOMAIN, AromaTherapySlot, LampMode +from .entity import ToloSaunaCoordinatorEntity @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py index bee01cc283f..8ea6b68ae95 100644 --- a/homeassistant/components/tolo/sensor.py +++ b/homeassistant/components/tolo/sensor.py @@ -23,8 +23,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .entity import ToloSaunaCoordinatorEntity @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/tolo/switch.py b/homeassistant/components/tolo/switch.py index b90f548ee76..9799d106658 100644 --- a/homeassistant/components/tolo/switch.py +++ b/homeassistant/components/tolo/switch.py @@ -13,8 +13,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator +from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .entity import ToloSaunaCoordinatorEntity @dataclass(frozen=True, kw_only=True) From 52de26e67b44d9ccf2c948e352579cda17f68b1e Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 23 Sep 2024 14:17:37 +0200 Subject: [PATCH 0998/1309] Remove unused i386 code in Dockerfile (#126520) --- Dockerfile | 12 +++--------- script/hassfest/docker.py | 12 +++--------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index 469bd3910b5..51929f481c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,15 +29,9 @@ RUN \ if ls homeassistant/home_assistant_*.whl 1> /dev/null 2>&1; then \ uv pip install homeassistant/home_assistant_*.whl; \ fi \ - && if [ "${BUILD_ARCH}" = "i386" ]; then \ - linux32 uv pip install \ - --no-build \ - -r homeassistant/requirements_all.txt; \ - else \ - uv pip install \ - --no-build \ - -r homeassistant/requirements_all.txt; \ - fi + && uv pip install \ + --no-build \ + -r homeassistant/requirements_all.txt ## Setup Home Assistant Core COPY . homeassistant/ diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index bcafbdb53c0..d12a7e5f78e 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -42,15 +42,9 @@ RUN \ if ls homeassistant/home_assistant_*.whl 1> /dev/null 2>&1; then \ uv pip install homeassistant/home_assistant_*.whl; \ fi \ - && if [ "${{BUILD_ARCH}}" = "i386" ]; then \ - linux32 uv pip install \ - --no-build \ - -r homeassistant/requirements_all.txt; \ - else \ - uv pip install \ - --no-build \ - -r homeassistant/requirements_all.txt; \ - fi + && uv pip install \ + --no-build \ + -r homeassistant/requirements_all.txt ## Setup Home Assistant Core COPY . homeassistant/ From ef39ee1d5d711b7ba90933a9268809b3ca84b0dc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:17:54 +0200 Subject: [PATCH 0999/1309] Move tautulli base entity to separate module (#126528) --- homeassistant/components/tautulli/__init__.py | 32 +--------------- homeassistant/components/tautulli/entity.py | 38 +++++++++++++++++++ homeassistant/components/tautulli/sensor.py | 3 +- 3 files changed, 41 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/tautulli/entity.py diff --git a/homeassistant/components/tautulli/__init__.py b/homeassistant/components/tautulli/__init__.py index 7d3efa4f283..a031354ae7d 100644 --- a/homeassistant/components/tautulli/__init__.py +++ b/homeassistant/components/tautulli/__init__.py @@ -2,17 +2,13 @@ from __future__ import annotations -from pytautulli import PyTautulli, PyTautulliApiUser, PyTautulliHostConfiguration +from pytautulli import PyTautulli, PyTautulliHostConfiguration from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN from .coordinator import TautulliDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -42,29 +38,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: TautulliConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: TautulliConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class TautulliEntity(CoordinatorEntity[TautulliDataUpdateCoordinator]): - """Defines a base Tautulli entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: TautulliDataUpdateCoordinator, - description: EntityDescription, - user: PyTautulliApiUser | None = None, - ) -> None: - """Initialize the Tautulli entity.""" - super().__init__(coordinator) - entry_id = coordinator.config_entry.entry_id - self._attr_unique_id = f"{entry_id}_{description.key}" - self.entity_description = description - self.user = user - self._attr_device_info = DeviceInfo( - configuration_url=coordinator.host_configuration.base_url, - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, user.user_id if user else entry_id)}, - manufacturer=DEFAULT_NAME, - name=user.username if user else DEFAULT_NAME, - ) diff --git a/homeassistant/components/tautulli/entity.py b/homeassistant/components/tautulli/entity.py new file mode 100644 index 00000000000..692c2141954 --- /dev/null +++ b/homeassistant/components/tautulli/entity.py @@ -0,0 +1,38 @@ +"""The Tautulli integration.""" + +from __future__ import annotations + +from pytautulli import PyTautulliApiUser + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import TautulliDataUpdateCoordinator + + +class TautulliEntity(CoordinatorEntity[TautulliDataUpdateCoordinator]): + """Defines a base Tautulli entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TautulliDataUpdateCoordinator, + description: EntityDescription, + user: PyTautulliApiUser | None = None, + ) -> None: + """Initialize the Tautulli entity.""" + super().__init__(coordinator) + entry_id = coordinator.config_entry.entry_id + self._attr_unique_id = f"{entry_id}_{description.key}" + self.entity_description = description + self.user = user + self._attr_device_info = DeviceInfo( + configuration_url=coordinator.host_configuration.base_url, + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, user.user_id if user else entry_id)}, + manufacturer=DEFAULT_NAME, + name=user.username if user else DEFAULT_NAME, + ) diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index 26b7c602de8..cd21630031a 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -26,9 +26,10 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType -from . import TautulliConfigEntry, TautulliEntity +from . import TautulliConfigEntry from .const import ATTR_TOP_USER, DOMAIN from .coordinator import TautulliDataUpdateCoordinator +from .entity import TautulliEntity def get_top_stats( From 11bb8e402e0fc6fb3b75b16d64e6c899b1b28211 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 23 Sep 2024 14:18:09 +0200 Subject: [PATCH 1000/1309] Use Bravia TV MAC address in `DeviceInfo.connections` (#126519) --- homeassistant/components/braviatv/entity.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/braviatv/entity.py b/homeassistant/components/braviatv/entity.py index ac08543b875..75540b316a7 100644 --- a/homeassistant/components/braviatv/entity.py +++ b/homeassistant/components/braviatv/entity.py @@ -1,6 +1,6 @@ """A entity class for Bravia TV integration.""" -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BraviaTVCoordinator @@ -28,3 +28,7 @@ class BraviaTVEntity(CoordinatorEntity[BraviaTVCoordinator]): model=model, name=f"{ATTR_MANUFACTURER} {model}", ) + if coordinator.client.mac is not None: + self._attr_device_info["connections"] = { + (CONNECTION_NETWORK_MAC, coordinator.client.mac) + } From efc1ff6eff5dcbdd839552262f18f0a6ecf6fd24 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 23 Sep 2024 14:18:24 +0200 Subject: [PATCH 1001/1309] Fix Shelly update entity names (#126512) --- homeassistant/components/shelly/update.py | 8 +++---- tests/components/shelly/test_update.py | 26 +++++++++++------------ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 61ebc144e3d..fb586ae8b85 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -59,7 +59,7 @@ class RestUpdateDescription(RestEntityDescription, UpdateEntityDescription): REST_UPDATES: Final = { "fwupdate": RestUpdateDescription( - name="Firmware update", + name="Firmware", key="fwupdate", latest_version=lambda status: status["update"]["new_version"], beta=False, @@ -68,7 +68,7 @@ REST_UPDATES: Final = { entity_registry_enabled_default=False, ), "fwupdate_beta": RestUpdateDescription( - name="Beta firmware update", + name="Beta firmware", key="fwupdate", latest_version=lambda status: status["update"].get("beta_version"), beta=True, @@ -80,7 +80,7 @@ REST_UPDATES: Final = { RPC_UPDATES: Final = { "fwupdate": RpcUpdateDescription( - name="Firmware update", + name="Firmware", key="sys", sub_key="available_updates", latest_version=lambda status: status.get("stable", {"version": ""})["version"], @@ -89,7 +89,7 @@ RPC_UPDATES: Final = { entity_category=EntityCategory.CONFIG, ), "fwupdate_beta": RpcUpdateDescription( - name="Beta firmware update", + name="Beta firmware", key="sys", sub_key="available_updates", latest_version=lambda status: status.get("beta", {"version": ""})["version"], diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index b4145b2441a..a89dfcd1e71 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -53,7 +53,7 @@ async def test_block_update( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block device update entity.""" - entity_id = "update.test_name_firmware_update" + entity_id = "update.test_name_firmware" monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1.0.0") monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2.0.0") monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) @@ -105,7 +105,7 @@ async def test_block_beta_update( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block device beta update entity.""" - entity_id = "update.test_name_beta_firmware_update" + entity_id = "update.test_name_beta_firmware" monkeypatch.setitem(mock_block_device.status["update"], "old_version", "1.0.0") monkeypatch.setitem(mock_block_device.status["update"], "new_version", "2.0.0") monkeypatch.setitem(mock_block_device.status["update"], "beta_version", "") @@ -179,7 +179,7 @@ async def test_block_update_connection_error( await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + {ATTR_ENTITY_ID: "update.test_name_firmware"}, blocking=True, ) assert "Error starting OTA update" in str(excinfo.value) @@ -206,7 +206,7 @@ async def test_block_update_auth_error( await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + {ATTR_ENTITY_ID: "update.test_name_firmware"}, blocking=True, ) @@ -237,8 +237,8 @@ async def test_block_version_compare( STABLE = "20230913-111730/v1.14.0-gcb84623" BETA = "20231107-162609/v1.14.1-rc1-g0617c15" - entity_id_beta = "update.test_name_beta_firmware_update" - entity_id_latest = "update.test_name_firmware_update" + entity_id_beta = "update.test_name_beta_firmware" + entity_id_latest = "update.test_name_firmware" monkeypatch.setitem(mock_block_device.status["update"], "old_version", STABLE) monkeypatch.setitem(mock_block_device.status["update"], "new_version", "") monkeypatch.setitem(mock_block_device.status["update"], "beta_version", BETA) @@ -276,7 +276,7 @@ async def test_rpc_update( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device update entity.""" - entity_id = "update.test_name_firmware_update" + entity_id = "update.test_name_firmware" monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -391,7 +391,7 @@ async def test_rpc_sleeping_update( "stable": {"version": "2"}, }, ) - entity_id = f"{UPDATE_DOMAIN}.test_name_firmware_update" + entity_id = f"{UPDATE_DOMAIN}.test_name_firmware" await init_integration(hass, 2, sleep_period=1000) # Entity should be created when device is online @@ -436,7 +436,7 @@ async def test_rpc_restored_sleeping_update( entity_id = register_entity( hass, UPDATE_DOMAIN, - "test_name_firmware_update", + "test_name_firmware", "sys-fwupdate", entry, device_id=device.id, @@ -495,7 +495,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( entity_id = register_entity( hass, UPDATE_DOMAIN, - "test_name_firmware_update", + "test_name_firmware", "sys-fwupdate", entry, device_id=device.id, @@ -534,7 +534,7 @@ async def test_rpc_beta_update( monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device beta update entity.""" - entity_id = "update.test_name_beta_firmware_update" + entity_id = "update.test_name_beta_firmware" monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -679,7 +679,7 @@ async def test_rpc_update_errors( await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + {ATTR_ENTITY_ID: "update.test_name_firmware"}, blocking=True, ) assert error in str(excinfo.value) @@ -714,7 +714,7 @@ async def test_rpc_update_auth_error( await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + {ATTR_ENTITY_ID: "update.test_name_firmware"}, blocking=True, ) From 0fc7bc2762d92a88dfac628df30f1534028885af Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Sep 2024 14:19:17 +0200 Subject: [PATCH 1002/1309] Fix a couple of stale ESPHome docstrings (#126508) --- homeassistant/components/esphome/repairs.py | 2 +- tests/components/esphome/test_repairs.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/repairs.py b/homeassistant/components/esphome/repairs.py index 24c8aa16a12..31e4b88c689 100644 --- a/homeassistant/components/esphome/repairs.py +++ b/homeassistant/components/esphome/repairs.py @@ -1,4 +1,4 @@ -"""Repairs implementation for the cloud integration.""" +"""Repairs implementation for the esphome integration.""" from __future__ import annotations diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index 76a10cae8e3..c365e65cbe1 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -1,4 +1,4 @@ -"""Test ESPHome binary sensors.""" +"""Test ESPHome repairs.""" import pytest From 9c6f9031781ac06f920b1eda6016b1e50baef93d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:20:16 +0200 Subject: [PATCH 1003/1309] Move tomorrowio base entity to separate module (#126531) --- .../components/tomorrowio/__init__.py | 37 +-------------- homeassistant/components/tomorrowio/entity.py | 45 +++++++++++++++++++ homeassistant/components/tomorrowio/sensor.py | 2 +- .../components/tomorrowio/weather.py | 2 +- 4 files changed, 48 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/tomorrowio/entity.py diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 5fd99e86cb4..73f62735e06 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from pytomorrowio import TomorrowioV4 -from pytomorrowio.const import CURRENT from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN @@ -11,10 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ATTRIBUTION, DOMAIN, INTEGRATION_NAME +from .const import DOMAIN from .coordinator import TomorrowioDataUpdateCoordinator PLATFORMS = [SENSOR_DOMAIN, WEATHER_DOMAIN] @@ -57,35 +54,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data.pop(DOMAIN) return unload_ok - - -class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): - """Base Tomorrow.io Entity.""" - - _attr_attribution = ATTRIBUTION - _attr_has_entity_name = True - - def __init__( - self, - config_entry: ConfigEntry, - coordinator: TomorrowioDataUpdateCoordinator, - api_version: int, - ) -> None: - """Initialize Tomorrow.io Entity.""" - super().__init__(coordinator) - self.api_version = api_version - self._config_entry = config_entry - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._config_entry.data[CONF_API_KEY])}, - manufacturer=INTEGRATION_NAME, - sw_version=f"v{self.api_version}", - entry_type=DeviceEntryType.SERVICE, - ) - - def _get_current_property(self, property_name: str) -> int | str | float | None: - """Get property from current conditions. - - Used for V4 API. - """ - entry_id = self._config_entry.entry_id - return self.coordinator.data[entry_id].get(CURRENT, {}).get(property_name) diff --git a/homeassistant/components/tomorrowio/entity.py b/homeassistant/components/tomorrowio/entity.py new file mode 100644 index 00000000000..6560ac58724 --- /dev/null +++ b/homeassistant/components/tomorrowio/entity.py @@ -0,0 +1,45 @@ +"""The Tomorrow.io integration.""" + +from __future__ import annotations + +from pytomorrowio.const import CURRENT + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN, INTEGRATION_NAME +from .coordinator import TomorrowioDataUpdateCoordinator + + +class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): + """Base Tomorrow.io Entity.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: TomorrowioDataUpdateCoordinator, + api_version: int, + ) -> None: + """Initialize Tomorrow.io Entity.""" + super().__init__(coordinator) + self.api_version = api_version + self._config_entry = config_entry + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._config_entry.data[CONF_API_KEY])}, + manufacturer=INTEGRATION_NAME, + sw_version=f"v{self.api_version}", + entry_type=DeviceEntryType.SERVICE, + ) + + def _get_current_property(self, property_name: str) -> int | str | float | None: + """Get property from current conditions. + + Used for V4 API. + """ + entry_id = self._config_entry.entry_id + return self.coordinator.data[entry_id].get(CURRENT, {}).get(property_name) diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index cfe2d870ccb..7ff17961b58 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -38,7 +38,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import TomorrowioEntity from .const import ( DOMAIN, TMRW_ATTR_CARBON_MONOXIDE, @@ -70,6 +69,7 @@ from .const import ( TMRW_ATTR_WIND_GUST, ) from .coordinator import TomorrowioDataUpdateCoordinator +from .entity import TomorrowioEntity @dataclass(frozen=True) diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index e77a798f1e4..92b09500e7b 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -37,7 +37,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import dt as dt_util -from . import TomorrowioEntity from .const import ( CLEAR_CONDITIONS, CONDITIONS, @@ -61,6 +60,7 @@ from .const import ( TMRW_ATTR_WIND_SPEED, ) from .coordinator import TomorrowioDataUpdateCoordinator +from .entity import TomorrowioEntity async def async_setup_entry( From 939f2e41e9e9314556458919f32d419e60313f80 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 23 Sep 2024 14:20:18 +0200 Subject: [PATCH 1004/1309] Change valve state to an enum (#126428) --- homeassistant/components/mqtt/valve.py | 39 +++-- homeassistant/components/valve/__init__.py | 13 +- homeassistant/components/valve/const.py | 14 ++ tests/components/esphome/test_valve.py | 33 ++--- .../components/google_assistant/test_trait.py | 24 +-- tests/components/mqtt/test_valve.py | 137 +++++++++--------- tests/components/shelly/test_valve.py | 22 +-- tests/components/switch_as_x/test_valve.py | 32 ++-- tests/components/valve/test_init.py | 13 +- tests/components/valve/test_intent.py | 8 +- 10 files changed, 165 insertions(+), 170 deletions(-) create mode 100644 homeassistant/components/valve/const.py diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 05c8ad833a0..00d3d7d79bd 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -13,6 +13,7 @@ from homeassistant.components.valve import ( DEVICE_CLASSES_SCHEMA, ValveEntity, ValveEntityFeature, + ValveState, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -20,10 +21,6 @@ from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, - STATE_CLOSED, - STATE_CLOSING, - STATE_OPEN, - STATE_OPENING, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -86,8 +83,8 @@ NO_POSITION_KEYS = ( DEFAULTS = { CONF_PAYLOAD_CLOSE: DEFAULT_PAYLOAD_CLOSE, CONF_PAYLOAD_OPEN: DEFAULT_PAYLOAD_OPEN, - CONF_STATE_OPEN: STATE_OPEN, - CONF_STATE_CLOSED: STATE_CLOSED, + CONF_STATE_OPEN: ValveState.OPEN, + CONF_STATE_CLOSED: ValveState.CLOSED, } RESET_CLOSING_OPENING = "reset_opening_closing" @@ -118,9 +115,9 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_REPORTS_POSITION, default=False): cv.boolean, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_STATE_CLOSED): cv.string, - vol.Optional(CONF_STATE_CLOSING, default=STATE_CLOSING): cv.string, + vol.Optional(CONF_STATE_CLOSING, default=ValveState.CLOSING): cv.string, vol.Optional(CONF_STATE_OPEN): cv.string, - vol.Optional(CONF_STATE_OPENING, default=STATE_OPENING): cv.string, + vol.Optional(CONF_STATE_OPENING, default=ValveState.OPENING): cv.string, vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } @@ -216,14 +213,14 @@ class MqttValve(MqttEntity, ValveEntity): @callback def _update_state(self, state: str | None) -> None: """Update the valve state properties.""" - self._attr_is_opening = state == STATE_OPENING - self._attr_is_closing = state == STATE_CLOSING + self._attr_is_opening = state == ValveState.OPENING + self._attr_is_closing = state == ValveState.CLOSING if self.reports_position: return if state is None: self._attr_is_closed = None else: - self._attr_is_closed = state == STATE_CLOSED + self._attr_is_closed = state == ValveState.CLOSED @callback def _process_binary_valve_update( @@ -232,13 +229,13 @@ class MqttValve(MqttEntity, ValveEntity): """Process an update for a valve that does not report the position.""" state: str | None = None if state_payload == self._config[CONF_STATE_OPENING]: - state = STATE_OPENING + state = ValveState.OPENING elif state_payload == self._config[CONF_STATE_CLOSING]: - state = STATE_CLOSING + state = ValveState.CLOSING elif state_payload == self._config[CONF_STATE_OPEN]: - state = STATE_OPEN + state = ValveState.OPEN elif state_payload == self._config[CONF_STATE_CLOSED]: - state = STATE_CLOSED + state = ValveState.CLOSED elif state_payload == PAYLOAD_NONE: state = None else: @@ -259,9 +256,9 @@ class MqttValve(MqttEntity, ValveEntity): state: str | None = None position_set: bool = False if state_payload == self._config[CONF_STATE_OPENING]: - state = STATE_OPENING + state = ValveState.OPENING elif state_payload == self._config[CONF_STATE_CLOSING]: - state = STATE_CLOSING + state = ValveState.CLOSING elif state_payload == PAYLOAD_NONE: self._attr_current_valve_position = None return @@ -363,7 +360,7 @@ class MqttValve(MqttEntity, ValveEntity): await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that valve has changed state. - self._update_state(STATE_OPEN) + self._update_state(ValveState.OPEN) self.async_write_ha_state() async def async_close_valve(self) -> None: @@ -377,7 +374,7 @@ class MqttValve(MqttEntity, ValveEntity): await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that valve has changed state. - self._update_state(STATE_CLOSED) + self._update_state(ValveState.CLOSED) self.async_write_ha_state() async def async_stop_valve(self) -> None: @@ -405,9 +402,9 @@ class MqttValve(MqttEntity, ValveEntity): ) if self._optimistic: self._update_state( - STATE_CLOSED + ValveState.CLOSED if percentage_position == self._config[CONF_POSITION_CLOSED] - else STATE_OPEN + else ValveState.OPEN ) self._attr_current_valve_position = percentage_position self.async_write_ha_state() diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py index 18aa30e05b5..c6b49a9a7c2 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -11,7 +11,7 @@ from typing import Any, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE, SERVICE_SET_VALVE_POSITION, @@ -29,9 +29,10 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey +from .const import DOMAIN, ValveState + _LOGGER = logging.getLogger(__name__) -DOMAIN = "valve" DOMAIN_DATA: HassKey[EntityComponent[ValveEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA @@ -173,18 +174,18 @@ class ValveEntity(Entity): reports_position = self.reports_position if self.is_opening: self.__is_last_toggle_direction_open = True - return STATE_OPENING + return ValveState.OPENING if self.is_closing: self.__is_last_toggle_direction_open = False - return STATE_CLOSING + return ValveState.CLOSING if reports_position is True: if (current_valve_position := self.current_valve_position) is None: return None position_zero = current_valve_position == 0 - return STATE_CLOSED if position_zero else STATE_OPEN + return ValveState.CLOSED if position_zero else ValveState.OPEN if (closed := self.is_closed) is None: return None - return STATE_CLOSED if closed else STATE_OPEN + return ValveState.CLOSED if closed else ValveState.OPEN @final @property diff --git a/homeassistant/components/valve/const.py b/homeassistant/components/valve/const.py new file mode 100644 index 00000000000..5f590b5015a --- /dev/null +++ b/homeassistant/components/valve/const.py @@ -0,0 +1,14 @@ +"""Constants for the Valve entity platform.""" + +from enum import StrEnum + +DOMAIN = "valve" + + +class ValveState(StrEnum): + """State of Valve entities.""" + + OPENING = "opening" + CLOSING = "closing" + CLOSED = "closed" + OPEN = "open" diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py index 5ba7bcbe187..7a7e22b1713 100644 --- a/tests/components/esphome/test_valve.py +++ b/tests/components/esphome/test_valve.py @@ -10,7 +10,7 @@ from aioesphomeapi import ( UserService, ValveInfo, ValveOperation, - ValveState, + ValveState as ESPHomeValveState, ) from homeassistant.components.valve import ( @@ -21,10 +21,7 @@ from homeassistant.components.valve import ( SERVICE_OPEN_VALVE, SERVICE_SET_VALVE_POSITION, SERVICE_STOP_VALVE, - STATE_CLOSED, - STATE_CLOSING, - STATE_OPEN, - STATE_OPENING, + ValveState, ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -52,7 +49,7 @@ async def test_valve_entity( ) ] states = [ - ValveState( + ESPHomeValveState( key=1, position=0.5, current_operation=ValveOperation.IS_OPENING, @@ -67,7 +64,7 @@ async def test_valve_entity( ) state = hass.states.get("valve.test_myvalve") assert state is not None - assert state.state == STATE_OPENING + assert state.state == ValveState.OPENING assert state.attributes[ATTR_CURRENT_POSITION] == 50 await hass.services.async_call( @@ -107,28 +104,30 @@ async def test_valve_entity( mock_client.valve_command.reset_mock() mock_device.set_state( - ValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) + ESPHomeValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) ) await hass.async_block_till_done() state = hass.states.get("valve.test_myvalve") assert state is not None - assert state.state == STATE_CLOSED + assert state.state == ValveState.CLOSED mock_device.set_state( - ValveState(key=1, position=0.5, current_operation=ValveOperation.IS_CLOSING) + ESPHomeValveState( + key=1, position=0.5, current_operation=ValveOperation.IS_CLOSING + ) ) await hass.async_block_till_done() state = hass.states.get("valve.test_myvalve") assert state is not None - assert state.state == STATE_CLOSING + assert state.state == ValveState.CLOSING mock_device.set_state( - ValveState(key=1, position=1.0, current_operation=ValveOperation.IDLE) + ESPHomeValveState(key=1, position=1.0, current_operation=ValveOperation.IDLE) ) await hass.async_block_till_done() state = hass.states.get("valve.test_myvalve") assert state is not None - assert state.state == STATE_OPEN + assert state.state == ValveState.OPEN async def test_valve_entity_without_position( @@ -151,7 +150,7 @@ async def test_valve_entity_without_position( ) ] states = [ - ValveState( + ESPHomeValveState( key=1, position=0.5, current_operation=ValveOperation.IS_OPENING, @@ -166,7 +165,7 @@ async def test_valve_entity_without_position( ) state = hass.states.get("valve.test_myvalve") assert state is not None - assert state.state == STATE_OPENING + assert state.state == ValveState.OPENING assert ATTR_CURRENT_POSITION not in state.attributes await hass.services.async_call( @@ -188,9 +187,9 @@ async def test_valve_entity_without_position( mock_client.valve_command.reset_mock() mock_device.set_state( - ValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) + ESPHomeValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) ) await hass.async_block_till_done() state = hass.states.get("valve.test_myvalve") assert state is not None - assert state.state == STATE_CLOSED + assert state.state == ValveState.CLOSED diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 54aa4035670..06e898a62fa 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -612,10 +612,10 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: ), ( valve.DOMAIN, - valve.STATE_OPEN, - valve.STATE_CLOSED, - valve.STATE_OPENING, - valve.STATE_CLOSING, + valve.ValveState.OPEN, + valve.ValveState.CLOSED, + valve.ValveState.OPENING, + valve.ValveState.CLOSING, ValveEntityFeature.STOP | ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, @@ -736,10 +736,10 @@ async def test_startstop_cover_valve( ), ( valve.DOMAIN, - valve.STATE_OPEN, - valve.STATE_CLOSED, - valve.STATE_OPENING, - valve.STATE_CLOSING, + valve.ValveState.OPEN, + valve.ValveState.CLOSED, + valve.ValveState.OPENING, + valve.ValveState.CLOSING, ValveEntityFeature.STOP | ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, @@ -3144,7 +3144,7 @@ async def test_openclose_cover_valve_unknown_state( valve.DOMAIN, valve.SERVICE_SET_VALVE_POSITION, ValveEntityFeature.SET_POSITION, - valve.STATE_OPEN, + valve.ValveState.OPEN, ), ], ) @@ -3191,7 +3191,7 @@ async def test_openclose_cover_valve_assumed_state( ), ( valve.DOMAIN, - valve.STATE_OPEN, + valve.ValveState.OPEN, ), ], ) @@ -3242,8 +3242,8 @@ async def test_openclose_cover_valve_query_only( ), ( valve.DOMAIN, - valve.STATE_OPEN, - valve.STATE_CLOSED, + valve.ValveState.OPEN, + valve.ValveState.CLOSED, ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, valve.SERVICE_OPEN_VALVE, valve.SERVICE_CLOSE_VALVE, diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py index 53a7190eaf3..6dd0102b8a3 100644 --- a/tests/components/mqtt/test_valve.py +++ b/tests/components/mqtt/test_valve.py @@ -14,6 +14,7 @@ from homeassistant.components.valve import ( ATTR_CURRENT_POSITION, ATTR_POSITION, SERVICE_SET_VALVE_POSITION, + ValveState, ) from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -22,10 +23,6 @@ from homeassistant.const import ( SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE, SERVICE_STOP_VALVE, - STATE_CLOSED, - STATE_CLOSING, - STATE_OPEN, - STATE_OPENING, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -103,14 +100,14 @@ DEFAULT_CONFIG_REPORTS_POSITION = { @pytest.mark.parametrize( ("message", "asserted_state"), [ - ("open", STATE_OPEN), - ("closed", STATE_CLOSED), - ("closing", STATE_CLOSING), - ("opening", STATE_OPENING), - ('{"state" : "open"}', STATE_OPEN), - ('{"state" : "closed"}', STATE_CLOSED), - ('{"state" : "closing"}', STATE_CLOSING), - ('{"state" : "opening"}', STATE_OPENING), + ("open", ValveState.OPEN), + ("closed", ValveState.CLOSED), + ("closing", ValveState.CLOSING), + ("opening", ValveState.OPENING), + ('{"state" : "open"}', ValveState.OPEN), + ('{"state" : "closed"}', ValveState.CLOSED), + ('{"state" : "closing"}', ValveState.CLOSING), + ('{"state" : "opening"}', ValveState.OPENING), ], ) async def test_state_via_state_topic_no_position( @@ -155,10 +152,10 @@ async def test_state_via_state_topic_no_position( @pytest.mark.parametrize( ("message", "asserted_state"), [ - ('{"state":"open"}', STATE_OPEN), - ('{"state":"closed"}', STATE_CLOSED), - ('{"state":"closing"}', STATE_CLOSING), - ('{"state":"opening"}', STATE_OPENING), + ('{"state":"open"}', ValveState.OPEN), + ('{"state":"closed"}', ValveState.CLOSED), + ('{"state":"closing"}', ValveState.CLOSING), + ('{"state":"opening"}', ValveState.OPENING), ], ) async def test_state_via_state_topic_with_template( @@ -199,9 +196,9 @@ async def test_state_via_state_topic_with_template( @pytest.mark.parametrize( ("message", "asserted_state"), [ - ('{"position":100}', STATE_OPEN), - ('{"position":50.0}', STATE_OPEN), - ('{"position":0}', STATE_CLOSED), + ('{"position":100}', ValveState.OPEN), + ('{"position":50.0}', ValveState.OPEN), + ('{"position":0}', ValveState.CLOSED), ('{"position":null}', STATE_UNKNOWN), ('{"position":"non_numeric"}', STATE_UNKNOWN), ('{"ignored":12}', STATE_UNKNOWN), @@ -245,23 +242,23 @@ async def test_state_via_state_topic_with_position_template( ("message", "asserted_state", "valve_position"), [ ("invalid", STATE_UNKNOWN, None), - ("0", STATE_CLOSED, 0), - ("opening", STATE_OPENING, None), - ("50", STATE_OPEN, 50), - ("closing", STATE_CLOSING, None), - ("100", STATE_OPEN, 100), + ("0", ValveState.CLOSED, 0), + ("opening", ValveState.OPENING, None), + ("50", ValveState.OPEN, 50), + ("closing", ValveState.CLOSING, None), + ("100", ValveState.OPEN, 100), ("open", STATE_UNKNOWN, None), ("closed", STATE_UNKNOWN, None), - ("-10", STATE_CLOSED, 0), - ("110", STATE_OPEN, 100), - ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), - ('{"position": 10, "state": "opening"}', STATE_OPENING, 10), - ('{"position": 50, "state": "open"}', STATE_OPEN, 50), - ('{"position": 100, "state": "closing"}', STATE_CLOSING, 100), - ('{"position": 90, "state": "closing"}', STATE_CLOSING, 90), - ('{"position": 0, "state": "closed"}', STATE_CLOSED, 0), - ('{"position": -10, "state": "closed"}', STATE_CLOSED, 0), - ('{"position": 110, "state": "open"}', STATE_OPEN, 100), + ("-10", ValveState.CLOSED, 0), + ("110", ValveState.OPEN, 100), + ('{"position": 0, "state": "opening"}', ValveState.OPENING, 0), + ('{"position": 10, "state": "opening"}', ValveState.OPENING, 10), + ('{"position": 50, "state": "open"}', ValveState.OPEN, 50), + ('{"position": 100, "state": "closing"}', ValveState.CLOSING, 100), + ('{"position": 90, "state": "closing"}', ValveState.CLOSING, 90), + ('{"position": 0, "state": "closed"}', ValveState.CLOSED, 0), + ('{"position": -10, "state": "closed"}', ValveState.CLOSED, 0), + ('{"position": 110, "state": "open"}', ValveState.OPEN, 100), ], ) async def test_state_via_state_topic_through_position( @@ -319,18 +316,18 @@ async def test_opening_closing_state_is_reset( assert not state.attributes.get(ATTR_ASSUMED_STATE) messages = [ - ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), - ('{"position": 50, "state": "opening"}', STATE_OPENING, 50), - ('{"position": 60}', STATE_OPENING, 60), - ('{"position": 100, "state": "opening"}', STATE_OPENING, 100), - ('{"position": 100, "state": null}', STATE_OPEN, 100), - ('{"position": 90, "state": "closing"}', STATE_CLOSING, 90), - ('{"position": 40}', STATE_CLOSING, 40), - ('{"position": 0}', STATE_CLOSED, 0), - ('{"position": 10}', STATE_OPEN, 10), - ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), - ('{"position": 0, "state": "closing"}', STATE_CLOSING, 0), - ('{"position": 0}', STATE_CLOSED, 0), + ('{"position": 0, "state": "opening"}', ValveState.OPENING, 0), + ('{"position": 50, "state": "opening"}', ValveState.OPENING, 50), + ('{"position": 60}', ValveState.OPENING, 60), + ('{"position": 100, "state": "opening"}', ValveState.OPENING, 100), + ('{"position": 100, "state": null}', ValveState.OPEN, 100), + ('{"position": 90, "state": "closing"}', ValveState.CLOSING, 90), + ('{"position": 40}', ValveState.CLOSING, 40), + ('{"position": 0}', ValveState.CLOSED, 0), + ('{"position": 10}', ValveState.OPEN, 10), + ('{"position": 0, "state": "opening"}', ValveState.OPENING, 0), + ('{"position": 0, "state": "closing"}', ValveState.CLOSING, 0), + ('{"position": 0}', ValveState.CLOSED, 0), ] for message, asserted_state, valve_position in messages: @@ -416,19 +413,19 @@ async def test_invalid_state_updates( @pytest.mark.parametrize( ("message", "asserted_state", "valve_position"), [ - ("-128", STATE_CLOSED, 0), - ("0", STATE_OPEN, 50), - ("127", STATE_OPEN, 100), - ("-130", STATE_CLOSED, 0), - ("130", STATE_OPEN, 100), - ('{"position": -128, "state": "opening"}', STATE_OPENING, 0), - ('{"position": -30, "state": "opening"}', STATE_OPENING, 38), - ('{"position": 30, "state": "open"}', STATE_OPEN, 61), - ('{"position": 127, "state": "closing"}', STATE_CLOSING, 100), - ('{"position": 100, "state": "closing"}', STATE_CLOSING, 89), - ('{"position": -128, "state": "closed"}', STATE_CLOSED, 0), - ('{"position": -130, "state": "closed"}', STATE_CLOSED, 0), - ('{"position": 130, "state": "open"}', STATE_OPEN, 100), + ("-128", ValveState.CLOSED, 0), + ("0", ValveState.OPEN, 50), + ("127", ValveState.OPEN, 100), + ("-130", ValveState.CLOSED, 0), + ("130", ValveState.OPEN, 100), + ('{"position": -128, "state": "opening"}', ValveState.OPENING, 0), + ('{"position": -30, "state": "opening"}', ValveState.OPENING, 38), + ('{"position": 30, "state": "open"}', ValveState.OPEN, 61), + ('{"position": 127, "state": "closing"}', ValveState.CLOSING, 100), + ('{"position": 100, "state": "closing"}', ValveState.CLOSING, 89), + ('{"position": -128, "state": "closed"}', ValveState.CLOSED, 0), + ('{"position": -130, "state": "closed"}', ValveState.CLOSED, 0), + ('{"position": 130, "state": "open"}', ValveState.OPEN, 100), ], ) async def test_state_via_state_trough_position_with_alt_range( @@ -632,8 +629,8 @@ async def test_open_close_payload_config_not_allowed( @pytest.mark.parametrize( ("service", "asserted_message", "asserted_state"), [ - (SERVICE_CLOSE_VALVE, "CLOSE", STATE_CLOSED), - (SERVICE_OPEN_VALVE, "OPEN", STATE_OPEN), + (SERVICE_CLOSE_VALVE, "CLOSE", ValveState.CLOSED), + (SERVICE_OPEN_VALVE, "OPEN", ValveState.OPEN), ], ) async def test_controlling_valve_by_state_optimistic( @@ -782,9 +779,9 @@ async def test_controlling_valve_by_set_valve_position( @pytest.mark.parametrize( ("position", "asserted_message", "asserted_position", "asserted_state"), [ - (0, "0", 0, STATE_CLOSED), - (30, "30", 30, STATE_OPEN), - (100, "100", 100, STATE_OPEN), + (0, "0", 0, ValveState.CLOSED), + (30, "30", 30, ValveState.OPEN), + (100, "100", 100, ValveState.OPEN), ], ) async def test_controlling_valve_optimistic_by_set_valve_position( @@ -947,8 +944,8 @@ async def test_controlling_valve_with_alt_range_by_position( @pytest.mark.parametrize( ("service", "asserted_message", "asserted_state", "asserted_position"), [ - (SERVICE_CLOSE_VALVE, "0", STATE_CLOSED, 0), - (SERVICE_OPEN_VALVE, "100", STATE_OPEN, 100), + (SERVICE_CLOSE_VALVE, "0", ValveState.CLOSED, 0), + (SERVICE_OPEN_VALVE, "100", ValveState.OPEN, 100), ], ) async def test_controlling_valve_by_position_optimistic( @@ -1004,10 +1001,10 @@ async def test_controlling_valve_by_position_optimistic( @pytest.mark.parametrize( ("position", "asserted_message", "asserted_position", "asserted_state"), [ - (0, "-128", 0, STATE_CLOSED), - (30, "-52", 30, STATE_OPEN), - (50, "0", 50, STATE_OPEN), - (100, "127", 100, STATE_OPEN), + (0, "-128", 0, ValveState.CLOSED), + (30, "-52", 30, ValveState.OPEN), + (50, "0", 50, ValveState.OPEN), + (100, "127", 100, ValveState.OPEN), ], ) async def test_controlling_valve_optimistic_alt_range_by_set_valve_position( diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py index 58b55e4f2dd..b35ce98b664 100644 --- a/tests/components/shelly/test_valve.py +++ b/tests/components/shelly/test_valve.py @@ -5,16 +5,8 @@ from unittest.mock import Mock from aioshelly.const import MODEL_GAS import pytest -from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN -from homeassistant.const import ( - ATTR_ENTITY_ID, - SERVICE_CLOSE_VALVE, - SERVICE_OPEN_VALVE, - STATE_CLOSED, - STATE_CLOSING, - STATE_OPEN, - STATE_OPENING, -) +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -37,7 +29,7 @@ async def test_block_device_gas_valve( assert entry assert entry.unique_id == "123456789ABC-valve_0-valve" - assert hass.states.get(entity_id).state == STATE_CLOSED + assert hass.states.get(entity_id).state == ValveState.CLOSED await hass.services.async_call( VALVE_DOMAIN, @@ -48,7 +40,7 @@ async def test_block_device_gas_valve( state = hass.states.get(entity_id) assert state - assert state.state == STATE_OPENING + assert state.state == ValveState.OPENING monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened") mock_block_device.mock_update() @@ -56,7 +48,7 @@ async def test_block_device_gas_valve( state = hass.states.get(entity_id) assert state - assert state.state == STATE_OPEN + assert state.state == ValveState.OPEN await hass.services.async_call( VALVE_DOMAIN, @@ -67,7 +59,7 @@ async def test_block_device_gas_valve( state = hass.states.get(entity_id) assert state - assert state.state == STATE_CLOSING + assert state.state == ValveState.CLOSING monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "closed") mock_block_device.mock_update() @@ -75,4 +67,4 @@ async def test_block_device_gas_valve( state = hass.states.get(entity_id) assert state - assert state.state == STATE_CLOSED + assert state.state == ValveState.CLOSED diff --git a/tests/components/switch_as_x/test_valve.py b/tests/components/switch_as_x/test_valve.py index 854f693404f..6f6ef719ae1 100644 --- a/tests/components/switch_as_x/test_valve.py +++ b/tests/components/switch_as_x/test_valve.py @@ -7,7 +7,7 @@ from homeassistant.components.switch_as_x.const import ( CONF_TARGET_DOMAIN, DOMAIN, ) -from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState from homeassistant.const import ( CONF_ENTITY_ID, SERVICE_CLOSE_VALVE, @@ -15,10 +15,8 @@ from homeassistant.const import ( SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_CLOSED, STATE_OFF, STATE_ON, - STATE_OPEN, Platform, ) from homeassistant.core import HomeAssistant @@ -71,7 +69,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN await hass.services.async_call( VALVE_DOMAIN, @@ -81,7 +79,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED await hass.services.async_call( VALVE_DOMAIN, @@ -91,7 +89,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN await hass.services.async_call( VALVE_DOMAIN, @@ -101,7 +99,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED await hass.services.async_call( SWITCH_DOMAIN, @@ -111,7 +109,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN await hass.services.async_call( SWITCH_DOMAIN, @@ -121,7 +119,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED await hass.services.async_call( SWITCH_DOMAIN, @@ -131,7 +129,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN async def test_service_calls_inverted(hass: HomeAssistant) -> None: @@ -154,7 +152,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED await hass.services.async_call( VALVE_DOMAIN, @@ -164,7 +162,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN await hass.services.async_call( VALVE_DOMAIN, @@ -174,7 +172,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN await hass.services.async_call( VALVE_DOMAIN, @@ -184,7 +182,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED await hass.services.async_call( SWITCH_DOMAIN, @@ -194,7 +192,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED await hass.services.async_call( SWITCH_DOMAIN, @@ -204,7 +202,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + assert hass.states.get("valve.decorative_lights").state == ValveState.OPEN await hass.services.async_call( SWITCH_DOMAIN, @@ -214,4 +212,4 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + assert hass.states.get("valve.decorative_lights").state == ValveState.CLOSED diff --git a/tests/components/valve/test_init.py b/tests/components/valve/test_init.py index 378ddb2a94b..d8eb38a3b9b 100644 --- a/tests/components/valve/test_init.py +++ b/tests/components/valve/test_init.py @@ -11,16 +11,13 @@ from homeassistant.components.valve import ( ValveEntity, ValveEntityDescription, ValveEntityFeature, + ValveState, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_SET_VALVE_POSITION, SERVICE_TOGGLE, - STATE_CLOSED, - STATE_CLOSING, - STATE_OPEN, - STATE_OPENING, STATE_UNAVAILABLE, Platform, ) @@ -349,19 +346,19 @@ def set_valve_position(ent, position) -> None: def is_open(hass: HomeAssistant, ent: ValveEntity) -> bool: """Return if the valve is closed based on the statemachine.""" - return hass.states.is_state(ent.entity_id, STATE_OPEN) + return hass.states.is_state(ent.entity_id, ValveState.OPEN) def is_opening(hass: HomeAssistant, ent: ValveEntity) -> bool: """Return if the valve is closed based on the statemachine.""" - return hass.states.is_state(ent.entity_id, STATE_OPENING) + return hass.states.is_state(ent.entity_id, ValveState.OPENING) def is_closed(hass: HomeAssistant, ent: ValveEntity) -> bool: """Return if the valve is closed based on the statemachine.""" - return hass.states.is_state(ent.entity_id, STATE_CLOSED) + return hass.states.is_state(ent.entity_id, ValveState.CLOSED) def is_closing(hass: HomeAssistant, ent: ValveEntity) -> bool: """Return if the valve is closed based on the statemachine.""" - return hass.states.is_state(ent.entity_id, STATE_CLOSING) + return hass.states.is_state(ent.entity_id, ValveState.CLOSING) diff --git a/tests/components/valve/test_intent.py b/tests/components/valve/test_intent.py index a8f4054602b..4f29017b4c1 100644 --- a/tests/components/valve/test_intent.py +++ b/tests/components/valve/test_intent.py @@ -6,8 +6,8 @@ from homeassistant.components.valve import ( SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE, SERVICE_SET_VALVE_POSITION, + ValveState, ) -from homeassistant.const import STATE_CLOSED, STATE_OPEN from homeassistant.core import HomeAssistant from homeassistant.helpers import intent from homeassistant.setup import async_setup_component @@ -20,7 +20,7 @@ async def test_open_valve_intent(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "intent", {}) entity_id = f"{DOMAIN}.test_valve" - hass.states.async_set(entity_id, STATE_CLOSED) + hass.states.async_set(entity_id, ValveState.CLOSED) calls = async_mock_service(hass, DOMAIN, SERVICE_OPEN_VALVE) response = await intent.async_handle( @@ -41,7 +41,7 @@ async def test_close_valve_intent(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "intent", {}) entity_id = f"{DOMAIN}.test_valve" - hass.states.async_set(entity_id, STATE_OPEN) + hass.states.async_set(entity_id, ValveState.OPEN) calls = async_mock_service(hass, DOMAIN, SERVICE_CLOSE_VALVE) response = await intent.async_handle( @@ -63,7 +63,7 @@ async def test_set_valve_position(hass: HomeAssistant) -> None: entity_id = f"{DOMAIN}.test_valve" hass.states.async_set( - entity_id, STATE_CLOSED, attributes={ATTR_CURRENT_POSITION: 0} + entity_id, ValveState.CLOSED, attributes={ATTR_CURRENT_POSITION: 0} ) calls = async_mock_service(hass, DOMAIN, SERVICE_SET_VALVE_POSITION) From 2859c9fe19df9df8495e3ddb844c1de15584fac1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:23:01 +0200 Subject: [PATCH 1005/1309] Move simplisafe base entity to separate module (#126523) --- .../components/simplisafe/__init__.py | 228 +---------------- .../simplisafe/alarm_control_panel.py | 3 +- .../components/simplisafe/binary_sensor.py | 3 +- homeassistant/components/simplisafe/button.py | 3 +- homeassistant/components/simplisafe/const.py | 7 + homeassistant/components/simplisafe/entity.py | 235 ++++++++++++++++++ homeassistant/components/simplisafe/lock.py | 3 +- homeassistant/components/simplisafe/sensor.py | 3 +- 8 files changed, 261 insertions(+), 224 deletions(-) create mode 100644 homeassistant/components/simplisafe/entity.py diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 58a3af83b5e..b72519f9734 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -3,12 +3,11 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine, Iterable +from collections.abc import Callable, Coroutine from datetime import timedelta from typing import Any, cast from simplipy import API -from simplipy.device import Device, DeviceTypes from simplipy.errors import ( EndpointUnavailableError, InvalidCredentialsError, @@ -31,14 +30,8 @@ from simplipy.system.v3 import ( from simplipy.websocket import ( EVENT_AUTOMATIC_TEST, EVENT_CAMERA_MOTION_DETECTED, - EVENT_CONNECTION_LOST, - EVENT_CONNECTION_RESTORED, EVENT_DEVICE_TEST, EVENT_DOORBELL_DETECTED, - EVENT_LOCK_LOCKED, - EVENT_LOCK_UNLOCKED, - EVENT_POWER_OUTAGE, - EVENT_POWER_RESTORED, EVENT_SECRET_ALERT_TRIGGERED, EVENT_SENSOR_PAIRED_AND_NAMED, EVENT_USER_INITIATED_TEST, @@ -67,20 +60,12 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, ) -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import ( async_register_admin_service, verify_domain_control, ) -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_ALARM_DURATION, @@ -90,8 +75,14 @@ from .const import ( ATTR_ENTRY_DELAY_HOME, ATTR_EXIT_DELAY_AWAY, ATTR_EXIT_DELAY_HOME, + ATTR_LAST_EVENT_INFO, + ATTR_LAST_EVENT_SENSOR_NAME, + ATTR_LAST_EVENT_SENSOR_TYPE, + ATTR_LAST_EVENT_TIMESTAMP, ATTR_LIGHT, + ATTR_SYSTEM_ID, ATTR_VOICE_PROMPT_VOLUME, + DISPATCHER_TOPIC_WEBSOCKET_EVENT, DOMAIN, LOGGER, ) @@ -99,27 +90,18 @@ from .typing import SystemType ATTR_CATEGORY = "category" ATTR_LAST_EVENT_CHANGED_BY = "last_event_changed_by" -ATTR_LAST_EVENT_INFO = "last_event_info" -ATTR_LAST_EVENT_SENSOR_NAME = "last_event_sensor_name" ATTR_LAST_EVENT_SENSOR_SERIAL = "last_event_sensor_serial" -ATTR_LAST_EVENT_SENSOR_TYPE = "last_event_sensor_type" -ATTR_LAST_EVENT_TIMESTAMP = "last_event_timestamp" ATTR_LAST_EVENT_TYPE = "last_event_type" ATTR_LAST_EVENT_TYPE = "last_event_type" ATTR_MESSAGE = "message" ATTR_PIN_LABEL = "label" ATTR_PIN_LABEL_OR_VALUE = "label_or_pin" ATTR_PIN_VALUE = "pin" -ATTR_SYSTEM_ID = "system_id" ATTR_TIMESTAMP = "timestamp" -DEFAULT_CONFIG_URL = "https://webapp.simplisafe.com/new/#/dashboard" -DEFAULT_ENTITY_MODEL = "Alarm control panel" -DEFAULT_ERROR_THRESHOLD = 2 DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) DEFAULT_SOCKET_MIN_RETRY = 15 -DISPATCHER_TOPIC_WEBSOCKET_EVENT = "simplisafe_websocket_event_{0}" EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT" EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION" @@ -201,7 +183,6 @@ SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = vol.Schema( } ) -WEBSOCKET_EVENTS_REQUIRING_SERIAL = [EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED] WEBSOCKET_EVENTS_TO_FIRE_HASS_EVENT = [ EVENT_AUTOMATIC_TEST, EVENT_CAMERA_MOTION_DETECTED, @@ -651,194 +632,3 @@ class SimpliSafe: if isinstance(result, SimplipyError): raise UpdateFailed(f"SimpliSafe error while updating: {result}") - - -class SimpliSafeEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): - """Define a base SimpliSafe entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - simplisafe: SimpliSafe, - system: SystemType, - *, - device: Device | None = None, - additional_websocket_events: Iterable[str] | None = None, - ) -> None: - """Initialize.""" - assert simplisafe.coordinator - super().__init__(simplisafe.coordinator) - - # SimpliSafe can incorrectly return an error state when there isn't any - # error. This can lead to entities having an unknown state frequently. - # To protect against that, we measure an error count for each entity and only - # mark the state as unavailable if we detect a few in a row: - self._error_count = 0 - - if device: - model = device.type.name.capitalize().replace("_", " ") - device_name = f"{device.name.capitalize()} {model}" - serial = device.serial - else: - model = device_name = DEFAULT_ENTITY_MODEL - serial = system.serial - - event = simplisafe.initial_event_to_use[system.system_id] - - if raw_type := event.get("sensorType"): - try: - device_type = DeviceTypes(raw_type) - except ValueError: - device_type = DeviceTypes.UNKNOWN - else: - device_type = DeviceTypes.UNKNOWN - - self._attr_extra_state_attributes = { - ATTR_LAST_EVENT_INFO: event.get("info"), - ATTR_LAST_EVENT_SENSOR_NAME: event.get("sensorName"), - ATTR_LAST_EVENT_SENSOR_TYPE: device_type.name.lower(), - ATTR_LAST_EVENT_TIMESTAMP: event.get("eventTimestamp"), - ATTR_SYSTEM_ID: system.system_id, - } - - self._attr_device_info = DeviceInfo( - configuration_url=DEFAULT_CONFIG_URL, - identifiers={(DOMAIN, serial)}, - manufacturer="SimpliSafe", - model=model, - name=device_name, - via_device=(DOMAIN, str(system.system_id)), - ) - - self._attr_unique_id = serial - self._device = device - self._online = True - self._simplisafe = simplisafe - self._system = system - self._websocket_events_to_listen_for = [ - EVENT_CONNECTION_LOST, - EVENT_CONNECTION_RESTORED, - EVENT_POWER_OUTAGE, - EVENT_POWER_RESTORED, - ] - if additional_websocket_events: - self._websocket_events_to_listen_for += additional_websocket_events - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - # We can easily detect if the V3 system is offline, but no simple check exists - # for the V2 system. Therefore, assuming the coordinator hasn't failed, we mark - # the entity as available if: - # 1. We can verify that the system is online (assuming True if we can't) - # 2. We can verify that the entity is online - if isinstance(self._system, SystemV3): - system_offline = self._system.offline - else: - system_offline = False - - return ( - self._error_count < DEFAULT_ERROR_THRESHOLD - and self._online - and not system_offline - ) - - @callback - def _handle_coordinator_update(self) -> None: - """Update the entity with new REST API data.""" - if self.coordinator.last_update_success: - self.async_reset_error_count() - else: - self.async_increment_error_count() - - self.async_update_from_rest_api() - self.async_write_ha_state() - - @callback - def _handle_websocket_update(self, event: WebsocketEvent) -> None: - """Update the entity with new websocket data.""" - # Ignore this event if it belongs to a system other than this one: - if event.system_id != self._system.system_id: - return - - # Ignore this event if this entity hasn't expressed interest in its type: - if event.event_type not in self._websocket_events_to_listen_for: - return - - # Ignore this event if it belongs to a entity with a different serial - # number from this one's: - if ( - self._device - and event.event_type in WEBSOCKET_EVENTS_REQUIRING_SERIAL - and event.sensor_serial != self._device.serial - ): - return - - sensor_type: str | None - if event.sensor_type: - sensor_type = event.sensor_type.name - else: - sensor_type = None - - self._attr_extra_state_attributes.update( - { - ATTR_LAST_EVENT_INFO: event.info, - ATTR_LAST_EVENT_SENSOR_NAME: event.sensor_name, - ATTR_LAST_EVENT_SENSOR_TYPE: sensor_type, - ATTR_LAST_EVENT_TIMESTAMP: event.timestamp, - } - ) - - # It's unknown whether these events reach the base station (since the connection - # is lost); we include this for completeness and coverage: - if event.event_type in (EVENT_CONNECTION_LOST, EVENT_POWER_OUTAGE): - self._online = False - return - - # If the base station comes back online, set entities to available, but don't - # instruct the entities to update their state (since there won't be anything new - # until the next websocket event or REST API update: - if event.event_type in (EVENT_CONNECTION_RESTORED, EVENT_POWER_RESTORED): - self._online = True - return - - self.async_update_from_websocket_event(event) - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - DISPATCHER_TOPIC_WEBSOCKET_EVENT.format(self._system.system_id), - self._handle_websocket_update, - ) - ) - - self.async_update_from_rest_api() - - @callback - def async_increment_error_count(self) -> None: - """Increment this entity's error count.""" - LOGGER.debug('Error for entity "%s" (total: %s)', self.name, self._error_count) - self._error_count += 1 - - @callback - def async_reset_error_count(self) -> None: - """Reset this entity's error count.""" - if self._error_count == 0: - return - - LOGGER.debug('Resetting error count for "%s"', self.name) - self._error_count = 0 - - @callback - def async_update_from_rest_api(self) -> None: - """Update the entity when new data comes from the REST API.""" - - @callback - def async_update_from_websocket_event(self, event: WebsocketEvent) -> None: - """Update the entity when new data comes from the websocket.""" diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 28ebd246623..478e5784e19 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -40,7 +40,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SimpliSafe, SimpliSafeEntity +from . import SimpliSafe from .const import ( ATTR_ALARM_DURATION, ATTR_ALARM_VOLUME, @@ -54,6 +54,7 @@ from .const import ( DOMAIN, LOGGER, ) +from .entity import SimpliSafeEntity from .typing import SystemType ATTR_BATTERY_BACKUP_POWER_LEVEL = "battery_backup_power_level" diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index a91b03b519a..0310e958e6e 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -15,8 +15,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SimpliSafe, SimpliSafeEntity +from . import SimpliSafe from .const import DOMAIN, LOGGER +from .entity import SimpliSafeEntity SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.CARBON_MONOXIDE, diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py index 40bf857da2a..f0272d09f61 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -15,8 +15,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SimpliSafe, SimpliSafeEntity +from . import SimpliSafe from .const import DOMAIN +from .entity import SimpliSafeEntity from .typing import SystemType diff --git a/homeassistant/components/simplisafe/const.py b/homeassistant/components/simplisafe/const.py index 1ed77bcd685..95bb72913d0 100644 --- a/homeassistant/components/simplisafe/const.py +++ b/homeassistant/components/simplisafe/const.py @@ -13,5 +13,12 @@ ATTR_ENTRY_DELAY_AWAY = "entry_delay_away" ATTR_ENTRY_DELAY_HOME = "entry_delay_home" ATTR_EXIT_DELAY_AWAY = "exit_delay_away" ATTR_EXIT_DELAY_HOME = "exit_delay_home" +ATTR_LAST_EVENT_INFO = "last_event_info" +ATTR_LAST_EVENT_SENSOR_NAME = "last_event_sensor_name" +ATTR_LAST_EVENT_SENSOR_TYPE = "last_event_sensor_type" +ATTR_LAST_EVENT_TIMESTAMP = "last_event_timestamp" ATTR_LIGHT = "light" +ATTR_SYSTEM_ID = "system_id" ATTR_VOICE_PROMPT_VOLUME = "voice_prompt_volume" + +DISPATCHER_TOPIC_WEBSOCKET_EVENT = "simplisafe_websocket_event_{0}" diff --git a/homeassistant/components/simplisafe/entity.py b/homeassistant/components/simplisafe/entity.py new file mode 100644 index 00000000000..ff1dd49e9fc --- /dev/null +++ b/homeassistant/components/simplisafe/entity.py @@ -0,0 +1,235 @@ +"""Support for SimpliSafe alarm systems.""" + +from __future__ import annotations + +from collections.abc import Iterable + +from simplipy.device import Device, DeviceTypes +from simplipy.system.v3 import SystemV3 +from simplipy.websocket import ( + EVENT_CONNECTION_LOST, + EVENT_CONNECTION_RESTORED, + EVENT_LOCK_LOCKED, + EVENT_LOCK_UNLOCKED, + EVENT_POWER_OUTAGE, + EVENT_POWER_RESTORED, + WebsocketEvent, +) + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from . import SimpliSafe +from .const import ( + ATTR_LAST_EVENT_INFO, + ATTR_LAST_EVENT_SENSOR_NAME, + ATTR_LAST_EVENT_SENSOR_TYPE, + ATTR_LAST_EVENT_TIMESTAMP, + ATTR_SYSTEM_ID, + DISPATCHER_TOPIC_WEBSOCKET_EVENT, + DOMAIN, + LOGGER, +) +from .typing import SystemType + +DEFAULT_CONFIG_URL = "https://webapp.simplisafe.com/new/#/dashboard" +DEFAULT_ENTITY_MODEL = "Alarm control panel" +DEFAULT_ERROR_THRESHOLD = 2 + +WEBSOCKET_EVENTS_REQUIRING_SERIAL = [EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED] + + +class SimpliSafeEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): + """Define a base SimpliSafe entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + simplisafe: SimpliSafe, + system: SystemType, + *, + device: Device | None = None, + additional_websocket_events: Iterable[str] | None = None, + ) -> None: + """Initialize.""" + assert simplisafe.coordinator + super().__init__(simplisafe.coordinator) + + # SimpliSafe can incorrectly return an error state when there isn't any + # error. This can lead to entities having an unknown state frequently. + # To protect against that, we measure an error count for each entity and only + # mark the state as unavailable if we detect a few in a row: + self._error_count = 0 + + if device: + model = device.type.name.capitalize().replace("_", " ") + device_name = f"{device.name.capitalize()} {model}" + serial = device.serial + else: + model = device_name = DEFAULT_ENTITY_MODEL + serial = system.serial + + event = simplisafe.initial_event_to_use[system.system_id] + + if raw_type := event.get("sensorType"): + try: + device_type = DeviceTypes(raw_type) + except ValueError: + device_type = DeviceTypes.UNKNOWN + else: + device_type = DeviceTypes.UNKNOWN + + self._attr_extra_state_attributes = { + ATTR_LAST_EVENT_INFO: event.get("info"), + ATTR_LAST_EVENT_SENSOR_NAME: event.get("sensorName"), + ATTR_LAST_EVENT_SENSOR_TYPE: device_type.name.lower(), + ATTR_LAST_EVENT_TIMESTAMP: event.get("eventTimestamp"), + ATTR_SYSTEM_ID: system.system_id, + } + + self._attr_device_info = DeviceInfo( + configuration_url=DEFAULT_CONFIG_URL, + identifiers={(DOMAIN, serial)}, + manufacturer="SimpliSafe", + model=model, + name=device_name, + via_device=(DOMAIN, str(system.system_id)), + ) + + self._attr_unique_id = serial + self._device = device + self._online = True + self._simplisafe = simplisafe + self._system = system + self._websocket_events_to_listen_for = [ + EVENT_CONNECTION_LOST, + EVENT_CONNECTION_RESTORED, + EVENT_POWER_OUTAGE, + EVENT_POWER_RESTORED, + ] + if additional_websocket_events: + self._websocket_events_to_listen_for += additional_websocket_events + + @property + def available(self) -> bool: + """Return whether the entity is available.""" + # We can easily detect if the V3 system is offline, but no simple check exists + # for the V2 system. Therefore, assuming the coordinator hasn't failed, we mark + # the entity as available if: + # 1. We can verify that the system is online (assuming True if we can't) + # 2. We can verify that the entity is online + if isinstance(self._system, SystemV3): + system_offline = self._system.offline + else: + system_offline = False + + return ( + self._error_count < DEFAULT_ERROR_THRESHOLD + and self._online + and not system_offline + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Update the entity with new REST API data.""" + if self.coordinator.last_update_success: + self.async_reset_error_count() + else: + self.async_increment_error_count() + + self.async_update_from_rest_api() + self.async_write_ha_state() + + @callback + def _handle_websocket_update(self, event: WebsocketEvent) -> None: + """Update the entity with new websocket data.""" + # Ignore this event if it belongs to a system other than this one: + if event.system_id != self._system.system_id: + return + + # Ignore this event if this entity hasn't expressed interest in its type: + if event.event_type not in self._websocket_events_to_listen_for: + return + + # Ignore this event if it belongs to a entity with a different serial + # number from this one's: + if ( + self._device + and event.event_type in WEBSOCKET_EVENTS_REQUIRING_SERIAL + and event.sensor_serial != self._device.serial + ): + return + + sensor_type: str | None + if event.sensor_type: + sensor_type = event.sensor_type.name + else: + sensor_type = None + + self._attr_extra_state_attributes.update( + { + ATTR_LAST_EVENT_INFO: event.info, + ATTR_LAST_EVENT_SENSOR_NAME: event.sensor_name, + ATTR_LAST_EVENT_SENSOR_TYPE: sensor_type, + ATTR_LAST_EVENT_TIMESTAMP: event.timestamp, + } + ) + + # It's unknown whether these events reach the base station (since the connection + # is lost); we include this for completeness and coverage: + if event.event_type in (EVENT_CONNECTION_LOST, EVENT_POWER_OUTAGE): + self._online = False + return + + # If the base station comes back online, set entities to available, but don't + # instruct the entities to update their state (since there won't be anything new + # until the next websocket event or REST API update: + if event.event_type in (EVENT_CONNECTION_RESTORED, EVENT_POWER_RESTORED): + self._online = True + return + + self.async_update_from_websocket_event(event) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + DISPATCHER_TOPIC_WEBSOCKET_EVENT.format(self._system.system_id), + self._handle_websocket_update, + ) + ) + + self.async_update_from_rest_api() + + @callback + def async_increment_error_count(self) -> None: + """Increment this entity's error count.""" + LOGGER.debug('Error for entity "%s" (total: %s)', self.name, self._error_count) + self._error_count += 1 + + @callback + def async_reset_error_count(self) -> None: + """Reset this entity's error count.""" + if self._error_count == 0: + return + + LOGGER.debug('Resetting error count for "%s"', self.name) + self._error_count = 0 + + @callback + def async_update_from_rest_api(self) -> None: + """Update the entity when new data comes from the REST API.""" + + @callback + def async_update_from_websocket_event(self, event: WebsocketEvent) -> None: + """Update the entity when new data comes from the websocket.""" diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index a287947615b..c610223bff1 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -15,8 +15,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SimpliSafe, SimpliSafeEntity +from . import SimpliSafe from .const import DOMAIN, LOGGER +from .entity import SimpliSafeEntity ATTR_LOCK_LOW_BATTERY = "lock_low_battery" ATTR_PIN_PAD_LOW_BATTERY = "pin_pad_low_battery" diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index c360ad5228c..a5f46e87a7c 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -16,8 +16,9 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SimpliSafe, SimpliSafeEntity +from . import SimpliSafe from .const import DOMAIN, LOGGER +from .entity import SimpliSafeEntity async def async_setup_entry( From 0e0ac3efe587ee6464f990b4cb66d3b8499ee17a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Sep 2024 07:23:43 -0500 Subject: [PATCH 1006/1309] Remove uneeded isoformat calls in registry as_storage_fragment properties (#126440) --- homeassistant/helpers/device_registry.py | 8 ++++---- homeassistant/helpers/entity_registry.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 30001a64474..af0baa75a01 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -367,7 +367,7 @@ class DeviceEntry: "config_entries": list(self.config_entries), "configuration_url": self.configuration_url, "connections": list(self.connections), - "created_at": self.created_at.isoformat(), + "created_at": self.created_at, "disabled_by": self.disabled_by, "entry_type": self.entry_type, "hw_version": self.hw_version, @@ -377,7 +377,7 @@ class DeviceEntry: "manufacturer": self.manufacturer, "model": self.model, "model_id": self.model_id, - "modified_at": self.modified_at.isoformat(), + "modified_at": self.modified_at, "name_by_user": self.name_by_user, "name": self.name, "primary_config_entry": self.primary_config_entry, @@ -426,11 +426,11 @@ class DeletedDeviceEntry: { "config_entries": list(self.config_entries), "connections": list(self.connections), - "created_at": self.created_at.isoformat(), + "created_at": self.created_at, "identifiers": list(self.identifiers), "id": self.id, "orphaned_timestamp": self.orphaned_timestamp, - "modified_at": self.modified_at.isoformat(), + "modified_at": self.modified_at, } ) ) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 6f4647030dd..df06a49e97f 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -338,7 +338,7 @@ class RegistryEntry: "categories": self.categories, "capabilities": self.capabilities, "config_entry_id": self.config_entry_id, - "created_at": self.created_at.isoformat(), + "created_at": self.created_at, "device_class": self.device_class, "device_id": self.device_id, "disabled_by": self.disabled_by, @@ -349,7 +349,7 @@ class RegistryEntry: "id": self.id, "has_entity_name": self.has_entity_name, "labels": list(self.labels), - "modified_at": self.modified_at.isoformat(), + "modified_at": self.modified_at, "name": self.name, "options": self.options, "original_device_class": self.original_device_class, @@ -420,10 +420,10 @@ class DeletedRegistryEntry: json_bytes( { "config_entry_id": self.config_entry_id, - "created_at": self.created_at.isoformat(), + "created_at": self.created_at, "entity_id": self.entity_id, "id": self.id, - "modified_at": self.modified_at.isoformat(), + "modified_at": self.modified_at, "orphaned_timestamp": self.orphaned_timestamp, "platform": self.platform, "unique_id": self.unique_id, From de88068c66955e92daf0ec398c2151dfa4f6e190 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:30:26 +0200 Subject: [PATCH 1007/1309] Merge unifiprotect entity and models modules (#126532) --- .../components/unifiprotect/binary_sensor.py | 4 +- .../components/unifiprotect/button.py | 10 +- .../components/unifiprotect/entity.py | 106 ++++++++++++++++- .../components/unifiprotect/event.py | 3 +- .../components/unifiprotect/models.py | 112 ------------------ .../components/unifiprotect/number.py | 10 +- .../components/unifiprotect/select.py | 10 +- .../components/unifiprotect/sensor.py | 5 +- .../components/unifiprotect/switch.py | 5 +- homeassistant/components/unifiprotect/text.py | 10 +- 10 files changed, 146 insertions(+), 129 deletions(-) delete mode 100644 homeassistant/components/unifiprotect/models.py diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 82b2deeae56..a88d4b65678 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -29,12 +29,14 @@ from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( BaseProtectEntity, EventEntityMixin, + PermRequired, ProtectDeviceEntity, + ProtectEntityDescription, + ProtectEventMixin, ProtectIsOnEntity, ProtectNVREntity, async_all_device_entities, ) -from .models import PermRequired, ProtectEntityDescription, ProtectEventMixin _KEY_DOOR = "door" diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 79985b9c7b2..b24c90be3ec 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -23,8 +23,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DEVICES_THAT_ADOPT, DOMAIN from .data import ProtectDeviceType, UFPConfigEntry -from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import PermRequired, ProtectEntityDescription, ProtectSetableKeysMixin, T +from .entity import ( + PermRequired, + ProtectDeviceEntity, + ProtectEntityDescription, + ProtectSetableKeysMixin, + T, + async_all_device_entities, +) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 34b4ec085af..1d68b18f1de 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -2,14 +2,24 @@ from __future__ import annotations -from collections.abc import Callable, Sequence +from collections.abc import Callable, Coroutine, Sequence +from dataclasses import dataclass from datetime import datetime +from enum import Enum from functools import partial import logging from operator import attrgetter -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Generic, TypeVar -from uiprotect.data import NVR, Event, ModelType, ProtectAdoptableDeviceModel, StateType +from uiprotect import make_enabled_getter, make_required_getter, make_value_getter +from uiprotect.data import ( + NVR, + Event, + ModelType, + ProtectAdoptableDeviceModel, + SmartDetectObjectType, + StateType, +) from homeassistant.core import callback import homeassistant.helpers.device_registry as dr @@ -24,10 +34,19 @@ from .const import ( DOMAIN, ) from .data import ProtectData, ProtectDeviceType -from .models import PermRequired, ProtectEntityDescription, ProtectEventMixin _LOGGER = logging.getLogger(__name__) +T = TypeVar("T", bound=ProtectAdoptableDeviceModel | NVR) + + +class PermRequired(int, Enum): + """Type of permission level required for entity.""" + + NO_WRITE = 1 + WRITE = 2 + DELETE = 3 + @callback def _async_device_entities( @@ -352,3 +371,82 @@ class EventEntityMixin(ProtectDeviceEntity): and prev_event_end and prev_event.id == event.id ) + + +@dataclass(frozen=True, kw_only=True) +class ProtectEntityDescription(EntityDescription, Generic[T]): + """Base class for protect entity descriptions.""" + + ufp_required_field: str | None = None + ufp_value: str | None = None + ufp_value_fn: Callable[[T], Any] | None = None + ufp_enabled: str | None = None + ufp_perm: PermRequired | None = None + + # The below are set in __post_init__ + has_required: Callable[[T], bool] = bool + get_ufp_enabled: Callable[[T], bool] | None = None + + def get_ufp_value(self, obj: T) -> Any: + """Return value from UniFi Protect device; overridden in __post_init__.""" + # ufp_value or ufp_value_fn are required, the + # RuntimeError is to catch any issues in the code + # with new descriptions. + raise RuntimeError( # pragma: no cover + f"`ufp_value` or `ufp_value_fn` is required for {self}" + ) + + def __post_init__(self) -> None: + """Override get_ufp_value, has_required, and get_ufp_enabled if required.""" + _setter = partial(object.__setattr__, self) + + if (ufp_value := self.ufp_value) is not None: + _setter("get_ufp_value", make_value_getter(ufp_value)) + elif (ufp_value_fn := self.ufp_value_fn) is not None: + _setter("get_ufp_value", ufp_value_fn) + + if (ufp_enabled := self.ufp_enabled) is not None: + _setter("get_ufp_enabled", make_enabled_getter(ufp_enabled)) + + if (ufp_required_field := self.ufp_required_field) is not None: + _setter("has_required", make_required_getter(ufp_required_field)) + + +@dataclass(frozen=True, kw_only=True) +class ProtectEventMixin(ProtectEntityDescription[T]): + """Mixin for events.""" + + ufp_event_obj: str | None = None + ufp_obj_type: SmartDetectObjectType | None = None + + def get_event_obj(self, obj: T) -> Event | None: + """Return value from UniFi Protect device.""" + return None + + def has_matching_smart(self, event: Event) -> bool: + """Determine if the detection type is a match.""" + return ( + not (obj_type := self.ufp_obj_type) or obj_type in event.smart_detect_types + ) + + def __post_init__(self) -> None: + """Override get_event_obj if ufp_event_obj is set.""" + if (_ufp_event_obj := self.ufp_event_obj) is not None: + object.__setattr__(self, "get_event_obj", attrgetter(_ufp_event_obj)) + super().__post_init__() + + +@dataclass(frozen=True, kw_only=True) +class ProtectSetableKeysMixin(ProtectEntityDescription[T]): + """Mixin for settable values.""" + + ufp_set_method: str | None = None + ufp_set_method_fn: Callable[[T, Any], Coroutine[Any, Any, None]] | None = None + + async def ufp_set(self, obj: T, value: Any) -> None: + """Set value for UniFi Protect device.""" + _LOGGER.debug("Setting %s to %s for %s", self.name, value, obj.display_name) + if self.ufp_set_method is not None: + await getattr(obj, self.ufp_set_method)(value) + elif self.ufp_set_method_fn is not None: + await self.ufp_set_method_fn(obj, value) diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index c8269e36326..8bbe568242b 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -16,8 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_EVENT_ID from .data import ProtectData, ProtectDeviceType, UFPConfigEntry -from .entity import EventEntityMixin, ProtectDeviceEntity -from .models import ProtectEventMixin +from .entity import EventEntityMixin, ProtectDeviceEntity, ProtectEventMixin @dataclasses.dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py deleted file mode 100644 index 23106a4e5d7..00000000000 --- a/homeassistant/components/unifiprotect/models.py +++ /dev/null @@ -1,112 +0,0 @@ -"""The unifiprotect integration models.""" - -from __future__ import annotations - -from collections.abc import Callable, Coroutine -from dataclasses import dataclass -from enum import Enum -from functools import partial -import logging -from operator import attrgetter -from typing import Any, Generic, TypeVar - -from uiprotect import make_enabled_getter, make_required_getter, make_value_getter -from uiprotect.data import ( - NVR, - Event, - ProtectAdoptableDeviceModel, - SmartDetectObjectType, -) - -from homeassistant.helpers.entity import EntityDescription - -_LOGGER = logging.getLogger(__name__) - -T = TypeVar("T", bound=ProtectAdoptableDeviceModel | NVR) - - -class PermRequired(int, Enum): - """Type of permission level required for entity.""" - - NO_WRITE = 1 - WRITE = 2 - DELETE = 3 - - -@dataclass(frozen=True, kw_only=True) -class ProtectEntityDescription(EntityDescription, Generic[T]): - """Base class for protect entity descriptions.""" - - ufp_required_field: str | None = None - ufp_value: str | None = None - ufp_value_fn: Callable[[T], Any] | None = None - ufp_enabled: str | None = None - ufp_perm: PermRequired | None = None - - # The below are set in __post_init__ - has_required: Callable[[T], bool] = bool - get_ufp_enabled: Callable[[T], bool] | None = None - - def get_ufp_value(self, obj: T) -> Any: - """Return value from UniFi Protect device; overridden in __post_init__.""" - # ufp_value or ufp_value_fn are required, the - # RuntimeError is to catch any issues in the code - # with new descriptions. - raise RuntimeError( # pragma: no cover - f"`ufp_value` or `ufp_value_fn` is required for {self}" - ) - - def __post_init__(self) -> None: - """Override get_ufp_value, has_required, and get_ufp_enabled if required.""" - _setter = partial(object.__setattr__, self) - - if (ufp_value := self.ufp_value) is not None: - _setter("get_ufp_value", make_value_getter(ufp_value)) - elif (ufp_value_fn := self.ufp_value_fn) is not None: - _setter("get_ufp_value", ufp_value_fn) - - if (ufp_enabled := self.ufp_enabled) is not None: - _setter("get_ufp_enabled", make_enabled_getter(ufp_enabled)) - - if (ufp_required_field := self.ufp_required_field) is not None: - _setter("has_required", make_required_getter(ufp_required_field)) - - -@dataclass(frozen=True, kw_only=True) -class ProtectEventMixin(ProtectEntityDescription[T]): - """Mixin for events.""" - - ufp_event_obj: str | None = None - ufp_obj_type: SmartDetectObjectType | None = None - - def get_event_obj(self, obj: T) -> Event | None: - """Return value from UniFi Protect device.""" - return None - - def has_matching_smart(self, event: Event) -> bool: - """Determine if the detection type is a match.""" - return ( - not (obj_type := self.ufp_obj_type) or obj_type in event.smart_detect_types - ) - - def __post_init__(self) -> None: - """Override get_event_obj if ufp_event_obj is set.""" - if (_ufp_event_obj := self.ufp_event_obj) is not None: - object.__setattr__(self, "get_event_obj", attrgetter(_ufp_event_obj)) - super().__post_init__() - - -@dataclass(frozen=True, kw_only=True) -class ProtectSetableKeysMixin(ProtectEntityDescription[T]): - """Mixin for settable values.""" - - ufp_set_method: str | None = None - ufp_set_method_fn: Callable[[T, Any], Coroutine[Any, Any, None]] | None = None - - async def ufp_set(self, obj: T, value: Any) -> None: - """Set value for UniFi Protect device.""" - _LOGGER.debug("Setting %s to %s for %s", self.name, value, obj.display_name) - if self.ufp_set_method is not None: - await getattr(obj, self.ufp_set_method)(value) - elif self.ufp_set_method_fn is not None: - await self.ufp_set_method_fn(obj, value) diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 2de3ef9f2cd..f6aacf81161 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -20,8 +20,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .data import ProtectData, ProtectDeviceType, UFPConfigEntry -from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import PermRequired, ProtectEntityDescription, ProtectSetableKeysMixin, T +from .entity import ( + PermRequired, + ProtectDeviceEntity, + ProtectEntityDescription, + ProtectSetableKeysMixin, + T, + async_all_device_entities, +) @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index e06ae7bfbec..00c277c957e 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -33,8 +33,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import TYPE_EMPTY_VALUE from .data import ProtectData, ProtectDeviceType, UFPConfigEntry -from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import PermRequired, ProtectEntityDescription, ProtectSetableKeysMixin, T +from .entity import ( + PermRequired, + ProtectDeviceEntity, + ProtectEntityDescription, + ProtectSetableKeysMixin, + T, + async_all_device_entities, +) from .utils import async_get_light_motion_current _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 786c5bd66c8..a91a94aa629 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -44,11 +44,14 @@ from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( BaseProtectEntity, EventEntityMixin, + PermRequired, ProtectDeviceEntity, + ProtectEntityDescription, + ProtectEventMixin, ProtectNVREntity, + T, async_all_device_entities, ) -from .models import PermRequired, ProtectEntityDescription, ProtectEventMixin, T from .utils import async_get_light_motion_current _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 9e1e0fa35d0..fa960261cf2 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -24,12 +24,15 @@ from homeassistant.helpers.restore_state import RestoreEntity from .data import ProtectData, ProtectDeviceType, UFPConfigEntry from .entity import ( BaseProtectEntity, + PermRequired, ProtectDeviceEntity, + ProtectEntityDescription, ProtectIsOnEntity, ProtectNVREntity, + ProtectSetableKeysMixin, + T, async_all_device_entities, ) -from .models import PermRequired, ProtectEntityDescription, ProtectSetableKeysMixin, T ATTR_PREV_MIC = "prev_mic_level" ATTR_PREV_RECORD = "prev_record_mode" diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index 9af946a7e11..0c7e1322f23 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -18,8 +18,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .data import ProtectDeviceType, UFPConfigEntry -from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import PermRequired, ProtectEntityDescription, ProtectSetableKeysMixin, T +from .entity import ( + PermRequired, + ProtectDeviceEntity, + ProtectEntityDescription, + ProtectSetableKeysMixin, + T, + async_all_device_entities, +) @dataclass(frozen=True, kw_only=True) From 8410c142abe83e4b113735751e7749e40133a9a1 Mon Sep 17 00:00:00 2001 From: Numa Perez <41305393+nprez83@users.noreply.github.com> Date: Mon, 23 Sep 2024 08:31:55 -0400 Subject: [PATCH 1008/1309] Fix Auto mode for TCC devices like the Lyric Round (#126091) --- homeassistant/components/lyric/climate.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 37810f33256..bf8e17527e8 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -208,7 +208,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): if LYRIC_HVAC_MODE_COOL in device.allowed_modes: self._attr_hvac_modes.append(HVACMode.COOL) - if LYRIC_HVAC_MODE_HEAT_COOL in device.allowed_modes: + # TCC devices like the Lyric round do not have the Auto + # option in allowed_modes, but still support Auto mode + if LYRIC_HVAC_MODE_HEAT_COOL in device.allowed_modes or ( + self._attr_thermostat_type is LyricThermostatType.TCC + and LYRIC_HVAC_MODE_HEAT in device.allowed_modes + and LYRIC_HVAC_MODE_COOL in device.allowed_modes + ): self._attr_hvac_modes.append(HVACMode.HEAT_COOL) # Setup supported features From 691b2879bddab27fd6c0ee5c9827eb0b0daca6cb Mon Sep 17 00:00:00 2001 From: Nicholas Pike Date: Mon, 23 Sep 2024 05:33:29 -0700 Subject: [PATCH 1009/1309] Fix image content-type validation case sensitivity (#125236) --- homeassistant/components/image/__init__.py | 2 +- tests/components/image/conftest.py | 15 ++++++++++++ tests/components/image/test_init.py | 27 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 692a398c577..66aab1fde79 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -70,7 +70,7 @@ class ImageContentTypeError(HomeAssistantError): def valid_image_content_type(content_type: str | None) -> str: """Validate the assigned content type is one of an image.""" - if content_type is None or content_type.split("/", 1)[0] != "image": + if content_type is None or content_type.split("/", 1)[0].lower() != "image": raise ImageContentTypeError return content_type diff --git a/tests/components/image/conftest.py b/tests/components/image/conftest.py index 8bb5d19b6db..e5e7649bee8 100644 --- a/tests/components/image/conftest.py +++ b/tests/components/image/conftest.py @@ -52,6 +52,21 @@ class MockImageEntityInvalidContentType(image.ImageEntity): return b"Test" +class MockImageEntityCapitalContentType(image.ImageEntity): + """Mock image entity with correct content type, but capitalized.""" + + _attr_name = "Test" + + async def async_added_to_hass(self): + """Set the update time and assign and incorrect content type.""" + self._attr_content_type = "Image/jpeg" + self._attr_image_last_updated = dt_util.utcnow() + + async def async_image(self) -> bytes | None: + """Return bytes of image.""" + return b"Test" + + class MockURLImageEntity(image.ImageEntity): """Mock image entity.""" diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py index 717e82a652d..90b750976ce 100644 --- a/tests/components/image/test_init.py +++ b/tests/components/image/test_init.py @@ -18,6 +18,7 @@ from homeassistant.setup import async_setup_component from .conftest import ( MockImageEntity, + MockImageEntityCapitalContentType, MockImageEntityInvalidContentType, MockImageNoStateEntity, MockImagePlatform, @@ -138,6 +139,32 @@ async def test_no_valid_content_type( assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR +async def test_valid_but_capitalized_content_type( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test invalid content type.""" + mock_integration(hass, MockModule(domain="test")) + mock_platform( + hass, "test.image", MockImagePlatform([MockImageEntityCapitalContentType(hass)]) + ) + assert await async_setup_component( + hass, image.DOMAIN, {"image": {"platform": "test"}} + ) + await hass.async_block_till_done() + + client = await hass_client() + + state = hass.states.get("image.test") + access_token = state.attributes["access_token"] + assert state.attributes == { + "access_token": access_token, + "entity_picture": f"/api/image_proxy/image.test?token={access_token}", + "friendly_name": "Test", + } + resp = await client.get(f"/api/image_proxy/image.test?token={access_token}") + assert resp.status == HTTPStatus.OK + + async def test_fetch_image_authenticated( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_image_platform: None ) -> None: From e81a1f7acf621f929a85d919bb57753f969a7c43 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 23 Sep 2024 08:34:24 -0400 Subject: [PATCH 1010/1309] Add config to ZHA to allow disabling polling of mains powered devices when the network is started (#125473) --- homeassistant/components/zha/const.py | 1 + homeassistant/components/zha/helpers.py | 3 +++ homeassistant/components/zha/strings.json | 1 + tests/components/zha/data.py | 14 ++++++++++++++ 4 files changed, 19 insertions(+) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 3986a99cf3f..18705c40608 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -49,6 +49,7 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_CONSIDER_UNAVAILABLE_MAINS = "consider_unavailable_mains" CONF_CONSIDER_UNAVAILABLE_BATTERY = "consider_unavailable_battery" +CONF_ENABLE_MAINS_STARTUP_POLLING = "enable_mains_startup_polling" CONF_ZIGPY = "zigpy_config" CONF_DEVICE_CONFIG = "device_config" diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 8e22e412e60..cc3fb2898e6 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -150,6 +150,7 @@ from .const import ( CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, CONF_ENABLE_IDENTIFY_ON_JOIN, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, + CONF_ENABLE_MAINS_STARTUP_POLLING, CONF_ENABLE_QUIRKS, CONF_FLOW_CONTROL, CONF_GROUP_MEMBERS_ASSUME_STATE, @@ -1163,6 +1164,7 @@ CONF_ZHA_OPTIONS_SCHEMA = vol.Schema( CONF_CONSIDER_UNAVAILABLE_BATTERY, default=CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, ): cv.positive_int, + vol.Required(CONF_ENABLE_MAINS_STARTUP_POLLING, default=True): cv.boolean, }, extra=vol.REMOVE_EXTRA, ) @@ -1235,6 +1237,7 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData: enable_identify_on_join=zha_options.get(CONF_ENABLE_IDENTIFY_ON_JOIN), consider_unavailable_mains=zha_options.get(CONF_CONSIDER_UNAVAILABLE_MAINS), consider_unavailable_battery=zha_options.get(CONF_CONSIDER_UNAVAILABLE_BATTERY), + enable_mains_startup_polling=zha_options.get(CONF_ENABLE_MAINS_STARTUP_POLLING), ) acp_options: AlarmControlPanelOptions = AlarmControlPanelOptions( master_code=ha_acp_options.get(CONF_ALARM_MASTER_CODE), diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 5d81556564a..f98ad170e0a 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -183,6 +183,7 @@ "enable_identify_on_join": "Enable identify effect when devices join the network", "default_light_transition": "Default light transition time (seconds)", "consider_unavailable_mains": "Consider mains powered devices unavailable after (seconds)", + "enable_mains_startup_polling": "Refresh state for mains powered devices on startup", "consider_unavailable_battery": "Consider battery powered devices unavailable after (seconds)" }, "zha_alarm_options": { diff --git a/tests/components/zha/data.py b/tests/components/zha/data.py index eb135c7e8fe..e5ed43e26a0 100644 --- a/tests/components/zha/data.py +++ b/tests/components/zha/data.py @@ -55,6 +55,12 @@ BASE_CUSTOM_CONFIGURATION = { "optional": True, "default": 21600, }, + { + "default": True, + "name": "enable_mains_startup_polling", + "required": True, + "type": "boolean", + }, ] }, "data": { @@ -65,6 +71,7 @@ BASE_CUSTOM_CONFIGURATION = { "always_prefer_xy_color_mode": True, "group_members_assume_state": False, "enable_identify_on_join": True, + "enable_mains_startup_polling": True, "consider_unavailable_mains": 7200, "consider_unavailable_battery": 21600, } @@ -126,6 +133,12 @@ CONFIG_WITH_ALARM_OPTIONS = { "optional": True, "default": 21600, }, + { + "default": True, + "name": "enable_mains_startup_polling", + "required": True, + "type": "boolean", + }, ], "zha_alarm_options": [ { @@ -157,6 +170,7 @@ CONFIG_WITH_ALARM_OPTIONS = { "always_prefer_xy_color_mode": True, "group_members_assume_state": False, "enable_identify_on_join": True, + "enable_mains_startup_polling": True, "consider_unavailable_mains": 7200, "consider_unavailable_battery": 21600, }, From 9fafbbff8118f04432edf888a575cf5c2347a39b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:56:14 +0200 Subject: [PATCH 1011/1309] Rename dynalite base entity module (#126536) --- homeassistant/components/dynalite/cover.py | 2 +- .../components/dynalite/{dynalitebase.py => entity.py} | 0 homeassistant/components/dynalite/light.py | 2 +- homeassistant/components/dynalite/switch.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename homeassistant/components/dynalite/{dynalitebase.py => entity.py} (100%) diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 2bac51e0b8b..d7f366d919c 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum from .bridge import DynaliteBridge -from .dynalitebase import DynaliteBase, async_setup_entry_base +from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/entity.py similarity index 100% rename from homeassistant/components/dynalite/dynalitebase.py rename to homeassistant/components/dynalite/entity.py diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index ffb97da49c1..e0dd8b147aa 100644 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .dynalitebase import DynaliteBase, async_setup_entry_base +from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py index 54e9b919b89..d24a098056a 100644 --- a/homeassistant/components/dynalite/switch.py +++ b/homeassistant/components/dynalite/switch.py @@ -8,7 +8,7 @@ from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .dynalitebase import DynaliteBase, async_setup_entry_base +from .entity import DynaliteBase, async_setup_entry_base async def async_setup_entry( From 225266b687611ea1edda4ca58725e877d163317f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:01:59 +0200 Subject: [PATCH 1012/1309] Move upcloud base entity to separate module (#126533) --- homeassistant/components/upcloud/__init__.py | 108 +----------------- .../components/upcloud/binary_sensor.py | 3 +- homeassistant/components/upcloud/const.py | 1 + homeassistant/components/upcloud/entity.py | 107 +++++++++++++++++ homeassistant/components/upcloud/switch.py | 5 +- 5 files changed, 119 insertions(+), 105 deletions(-) create mode 100644 homeassistant/components/upcloud/entity.py diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 4b65406f312..30d7cacba8e 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import dataclasses from datetime import timedelta import logging -from typing import Any import requests.exceptions import upcloud_api @@ -15,44 +14,26 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, - STATE_OFF, - STATE_ON, - STATE_PROBLEM, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import ( + CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE, + DATA_UPCLOUD, + DEFAULT_SCAN_INTERVAL, +) from .coordinator import UpCloudDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -ATTR_CORE_NUMBER = "core_number" -ATTR_HOSTNAME = "hostname" -ATTR_MEMORY_AMOUNT = "memory_amount" -ATTR_TITLE = "title" -ATTR_UUID = "uuid" -ATTR_ZONE = "zone" - -CONF_SERVERS = "servers" - -DATA_UPCLOUD = "data_upcloud" - -DEFAULT_COMPONENT_NAME = "UpCloud {}" - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] -SIGNAL_UPDATE_UPCLOUD = "upcloud_update" - -STATE_MAP = {"error": STATE_PROBLEM, "started": STATE_ON, "stopped": STATE_OFF} - @dataclasses.dataclass class UpCloudHassData: @@ -136,82 +117,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DATA_UPCLOUD].coordinators.pop(config_entry.data[CONF_USERNAME]) return unload_ok - - -class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): - """Entity class for UpCloud servers.""" - - def __init__( - self, - coordinator: UpCloudDataUpdateCoordinator, - uuid: str, - ) -> None: - """Initialize the UpCloud server entity.""" - super().__init__(coordinator) - self.uuid = uuid - - @property - def _server(self) -> upcloud_api.Server: - return self.coordinator.data[self.uuid] - - @property - def unique_id(self) -> str: - """Return unique ID for the entity.""" - return self.uuid - - @property - def name(self) -> str: - """Return the name of the component.""" - try: - return DEFAULT_COMPONENT_NAME.format(self._server.title) - except (AttributeError, KeyError, TypeError): - return DEFAULT_COMPONENT_NAME.format(self.uuid) - - @property - def icon(self) -> str: - """Return the icon of this server.""" - return "mdi:server" if self.is_on else "mdi:server-off" - - @property - def is_on(self) -> bool: - """Return true if the server is on.""" - try: - return STATE_MAP.get(self._server.state, self._server.state) == STATE_ON # type: ignore[no-any-return] - except AttributeError: - return False - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and STATE_MAP.get( - self._server.state, self._server.state - ) in (STATE_ON, STATE_OFF) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes of the UpCloud server.""" - return { - x: getattr(self._server, x, None) - for x in ( - ATTR_UUID, - ATTR_TITLE, - ATTR_HOSTNAME, - ATTR_ZONE, - ATTR_CORE_NUMBER, - ATTR_MEMORY_AMOUNT, - ) - } - - @property - def device_info(self) -> DeviceInfo: - """Return info for device registry.""" - assert self.coordinator.config_entry is not None - return DeviceInfo( - configuration_url="https://hub.upcloud.com", - model="Control Panel", - entry_type=DeviceEntryType.SERVICE, - identifiers={ - (DOMAIN, f"{self.coordinator.config_entry.data[CONF_USERNAME]}@hub") - }, - manufacturer="UpCloud Ltd", - ) diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py index 691edde8473..f135eea24b1 100644 --- a/homeassistant/components/upcloud/binary_sensor.py +++ b/homeassistant/components/upcloud/binary_sensor.py @@ -9,7 +9,8 @@ from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DATA_UPCLOUD, UpCloudServerEntity +from .const import DATA_UPCLOUD +from .entity import UpCloudServerEntity async def async_setup_entry( diff --git a/homeassistant/components/upcloud/const.py b/homeassistant/components/upcloud/const.py index 763462c37f4..a967a43c46e 100644 --- a/homeassistant/components/upcloud/const.py +++ b/homeassistant/components/upcloud/const.py @@ -3,5 +3,6 @@ from datetime import timedelta DOMAIN = "upcloud" +DATA_UPCLOUD = "data_upcloud" DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) CONFIG_ENTRY_UPDATE_SIGNAL_TEMPLATE = f"{DOMAIN}_config_entry_update:{{}}" diff --git a/homeassistant/components/upcloud/entity.py b/homeassistant/components/upcloud/entity.py new file mode 100644 index 00000000000..c64ca7be2ea --- /dev/null +++ b/homeassistant/components/upcloud/entity.py @@ -0,0 +1,107 @@ +"""Support for UpCloud.""" + +from __future__ import annotations + +import logging +from typing import Any + +import upcloud_api + +from homeassistant.const import CONF_USERNAME, STATE_OFF, STATE_ON, STATE_PROBLEM +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import UpCloudDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +ATTR_CORE_NUMBER = "core_number" +ATTR_HOSTNAME = "hostname" +ATTR_MEMORY_AMOUNT = "memory_amount" +ATTR_TITLE = "title" +ATTR_UUID = "uuid" +ATTR_ZONE = "zone" + +DEFAULT_COMPONENT_NAME = "UpCloud {}" + +STATE_MAP = {"error": STATE_PROBLEM, "started": STATE_ON, "stopped": STATE_OFF} + + +class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): + """Entity class for UpCloud servers.""" + + def __init__( + self, + coordinator: UpCloudDataUpdateCoordinator, + uuid: str, + ) -> None: + """Initialize the UpCloud server entity.""" + super().__init__(coordinator) + self.uuid = uuid + + @property + def _server(self) -> upcloud_api.Server: + return self.coordinator.data[self.uuid] + + @property + def unique_id(self) -> str: + """Return unique ID for the entity.""" + return self.uuid + + @property + def name(self) -> str: + """Return the name of the component.""" + try: + return DEFAULT_COMPONENT_NAME.format(self._server.title) + except (AttributeError, KeyError, TypeError): + return DEFAULT_COMPONENT_NAME.format(self.uuid) + + @property + def icon(self) -> str: + """Return the icon of this server.""" + return "mdi:server" if self.is_on else "mdi:server-off" + + @property + def is_on(self) -> bool: + """Return true if the server is on.""" + try: + return STATE_MAP.get(self._server.state, self._server.state) == STATE_ON # type: ignore[no-any-return] + except AttributeError: + return False + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and STATE_MAP.get( + self._server.state, self._server.state + ) in (STATE_ON, STATE_OFF) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the UpCloud server.""" + return { + x: getattr(self._server, x, None) + for x in ( + ATTR_UUID, + ATTR_TITLE, + ATTR_HOSTNAME, + ATTR_ZONE, + ATTR_CORE_NUMBER, + ATTR_MEMORY_AMOUNT, + ) + } + + @property + def device_info(self) -> DeviceInfo: + """Return info for device registry.""" + assert self.coordinator.config_entry is not None + return DeviceInfo( + configuration_url="https://hub.upcloud.com", + model="Control Panel", + entry_type=DeviceEntryType.SERVICE, + identifiers={ + (DOMAIN, f"{self.coordinator.config_entry.data[CONF_USERNAME]}@hub") + }, + manufacturer="UpCloud Ltd", + ) diff --git a/homeassistant/components/upcloud/switch.py b/homeassistant/components/upcloud/switch.py index 484b6875d8f..7495357ca9e 100644 --- a/homeassistant/components/upcloud/switch.py +++ b/homeassistant/components/upcloud/switch.py @@ -9,7 +9,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DATA_UPCLOUD, SIGNAL_UPDATE_UPCLOUD, UpCloudServerEntity +from .const import DATA_UPCLOUD +from .entity import UpCloudServerEntity + +SIGNAL_UPDATE_UPCLOUD = "upcloud_update" async def async_setup_entry( From 77b2895b0ead9fb1d9ee7059f791bfe73f31525e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:09:22 +0200 Subject: [PATCH 1013/1309] Rename pilight base entity module (#126538) --- homeassistant/components/pilight/{base_class.py => entity.py} | 0 homeassistant/components/pilight/light.py | 2 +- homeassistant/components/pilight/switch.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename homeassistant/components/pilight/{base_class.py => entity.py} (100%) diff --git a/homeassistant/components/pilight/base_class.py b/homeassistant/components/pilight/entity.py similarity index 100% rename from homeassistant/components/pilight/base_class.py rename to homeassistant/components/pilight/entity.py diff --git a/homeassistant/components/pilight/light.py b/homeassistant/components/pilight/light.py index 5665e96b9c9..c3d1a3c234c 100644 --- a/homeassistant/components/pilight/light.py +++ b/homeassistant/components/pilight/light.py @@ -18,8 +18,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .base_class import SWITCHES_SCHEMA, PilightBaseDevice from .const import CONF_DIMLEVEL_MAX, CONF_DIMLEVEL_MIN +from .entity import SWITCHES_SCHEMA, PilightBaseDevice LIGHTS_SCHEMA = SWITCHES_SCHEMA.extend( { diff --git a/homeassistant/components/pilight/switch.py b/homeassistant/components/pilight/switch.py index 5be63064b4a..a1976921269 100644 --- a/homeassistant/components/pilight/switch.py +++ b/homeassistant/components/pilight/switch.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .base_class import SWITCHES_SCHEMA, PilightBaseDevice +from .entity import SWITCHES_SCHEMA, PilightBaseDevice PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( {vol.Required(CONF_SWITCHES): vol.Schema({cv.string: SWITCHES_SCHEMA})} From 58770e5c797ac3be2e60662a5b5e548477cea75e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:11:04 +0200 Subject: [PATCH 1014/1309] Rename xbox base entity module (#126540) --- homeassistant/components/xbox/binary_sensor.py | 4 ++-- homeassistant/components/xbox/{base_sensor.py => entity.py} | 2 +- homeassistant/components/xbox/sensor.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename homeassistant/components/xbox/{base_sensor.py => entity.py} (97%) diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py index 0f0b9799d3d..af95834425a 100644 --- a/homeassistant/components/xbox/binary_sensor.py +++ b/homeassistant/components/xbox/binary_sensor.py @@ -10,9 +10,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .base_sensor import XboxBaseSensorEntity from .const import DOMAIN from .coordinator import XboxUpdateCoordinator +from .entity import XboxBaseEntity PRESENCE_ATTRIBUTES = ["online", "in_party", "in_game", "in_multiplayer"] @@ -32,7 +32,7 @@ async def async_setup_entry( update_friends() -class XboxBinarySensorEntity(XboxBaseSensorEntity, BinarySensorEntity): +class XboxBinarySensorEntity(XboxBaseEntity, BinarySensorEntity): """Representation of a Xbox presence state.""" @property diff --git a/homeassistant/components/xbox/base_sensor.py b/homeassistant/components/xbox/entity.py similarity index 97% rename from homeassistant/components/xbox/base_sensor.py rename to homeassistant/components/xbox/entity.py index f252385d4ca..d4a63b71b39 100644 --- a/homeassistant/components/xbox/base_sensor.py +++ b/homeassistant/components/xbox/entity.py @@ -11,7 +11,7 @@ from .const import DOMAIN from .coordinator import PresenceData, XboxUpdateCoordinator -class XboxBaseSensorEntity(CoordinatorEntity[XboxUpdateCoordinator]): +class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]): """Base Sensor for the Xbox Integration.""" def __init__( diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py index ff6591d5b3e..f269e0a5bb9 100644 --- a/homeassistant/components/xbox/sensor.py +++ b/homeassistant/components/xbox/sensor.py @@ -10,9 +10,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .base_sensor import XboxBaseSensorEntity from .const import DOMAIN from .coordinator import XboxUpdateCoordinator +from .entity import XboxBaseEntity SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"] @@ -34,7 +34,7 @@ async def async_setup_entry( update_friends() -class XboxSensorEntity(XboxBaseSensorEntity, SensorEntity): +class XboxSensorEntity(XboxBaseEntity, SensorEntity): """Representation of a Xbox presence state.""" @property From f5697ad5d2e16c02bc2cc2fd148cb25bb7db3714 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:11:58 +0200 Subject: [PATCH 1015/1309] Move vallox base entity to separate module (#126541) --- homeassistant/components/vallox/__init__.py | 23 -------------- .../components/vallox/binary_sensor.py | 2 +- homeassistant/components/vallox/date.py | 2 +- homeassistant/components/vallox/entity.py | 31 +++++++++++++++++++ homeassistant/components/vallox/fan.py | 2 +- homeassistant/components/vallox/number.py | 2 +- homeassistant/components/vallox/sensor.py | 2 +- homeassistant/components/vallox/switch.py | 2 +- 8 files changed, 37 insertions(+), 29 deletions(-) create mode 100644 homeassistant/components/vallox/entity.py diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 09080f1a5f6..ceb34bc6ff9 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -13,8 +13,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DEFAULT_FAN_SPEED_AWAY, @@ -234,24 +232,3 @@ class ValloxServiceHandler: # be observed by all parties involved. if result: await self._coordinator.async_request_refresh() - - -class ValloxEntity(CoordinatorEntity[ValloxDataUpdateCoordinator]): - """Representation of a Vallox entity.""" - - _attr_has_entity_name = True - - def __init__(self, name: str, coordinator: ValloxDataUpdateCoordinator) -> None: - """Initialize a Vallox entity.""" - super().__init__(coordinator) - - self._device_uuid = self.coordinator.data.uuid - assert self.coordinator.config_entry is not None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(self._device_uuid))}, - manufacturer=DEFAULT_NAME, - model=self.coordinator.data.model, - name=name, - sw_version=self.coordinator.data.sw_version, - configuration_url=f"http://{self.coordinator.config_entry.data[CONF_HOST]}", - ) diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index 20593fa4402..4a0efc7b101 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -13,9 +13,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ValloxEntity from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator +from .entity import ValloxEntity class ValloxBinarySensorEntity(ValloxEntity, BinarySensorEntity): diff --git a/homeassistant/components/vallox/date.py b/homeassistant/components/vallox/date.py index 0236117fd0f..33c3ebb253c 100644 --- a/homeassistant/components/vallox/date.py +++ b/homeassistant/components/vallox/date.py @@ -12,9 +12,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ValloxEntity from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator +from .entity import ValloxEntity class ValloxFilterChangeDateEntity(ValloxEntity, DateEntity): diff --git a/homeassistant/components/vallox/entity.py b/homeassistant/components/vallox/entity.py new file mode 100644 index 00000000000..b0657c561a8 --- /dev/null +++ b/homeassistant/components/vallox/entity.py @@ -0,0 +1,31 @@ +"""Support for Vallox ventilation units.""" + +from __future__ import annotations + +from homeassistant.const import CONF_HOST +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import ValloxDataUpdateCoordinator + + +class ValloxEntity(CoordinatorEntity[ValloxDataUpdateCoordinator]): + """Representation of a Vallox entity.""" + + _attr_has_entity_name = True + + def __init__(self, name: str, coordinator: ValloxDataUpdateCoordinator) -> None: + """Initialize a Vallox entity.""" + super().__init__(coordinator) + + self._device_uuid = self.coordinator.data.uuid + assert self.coordinator.config_entry is not None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(self._device_uuid))}, + manufacturer=DEFAULT_NAME, + model=self.coordinator.data.model, + name=name, + sw_version=self.coordinator.data.sw_version, + configuration_url=f"http://{self.coordinator.config_entry.data[CONF_HOST]}", + ) diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index c9226110332..5fac46177cb 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -14,7 +14,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import ValloxEntity from .const import ( DOMAIN, METRIC_KEY_MODE, @@ -27,6 +26,7 @@ from .const import ( VALLOX_PROFILE_TO_PRESET_MODE, ) from .coordinator import ValloxDataUpdateCoordinator +from .entity import ValloxEntity class ExtraStateAttributeDetails(NamedTuple): diff --git a/homeassistant/components/vallox/number.py b/homeassistant/components/vallox/number.py index 93190da1f16..96bc07b5a93 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -16,9 +16,9 @@ from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ValloxEntity from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator +from .entity import ValloxEntity class ValloxNumberEntity(ValloxEntity, NumberEntity): diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index fb9977cefaf..7165947861a 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -25,7 +25,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from . import ValloxEntity from .const import ( DOMAIN, METRIC_KEY_MODE, @@ -34,6 +33,7 @@ from .const import ( VALLOX_PROFILE_TO_PRESET_MODE, ) from .coordinator import ValloxDataUpdateCoordinator +from .entity import ValloxEntity class ValloxSensorEntity(ValloxEntity, SensorEntity): diff --git a/homeassistant/components/vallox/switch.py b/homeassistant/components/vallox/switch.py index d70de89606d..20b270f8f18 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -13,9 +13,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ValloxEntity from .const import DOMAIN from .coordinator import ValloxDataUpdateCoordinator +from .entity import ValloxEntity class ValloxSwitchEntity(ValloxEntity, SwitchEntity): From 8c4ea323bac55fac1c717e10877104549846ac58 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:12:55 +0200 Subject: [PATCH 1016/1309] Move venstar base entity to separate module (#126542) --- homeassistant/components/venstar/__init__.py | 37 +--------------- .../components/venstar/binary_sensor.py | 2 +- homeassistant/components/venstar/climate.py | 2 +- homeassistant/components/venstar/entity.py | 44 +++++++++++++++++++ homeassistant/components/venstar/sensor.py | 2 +- 5 files changed, 48 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/venstar/entity.py diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index 563a974fad6..3243c7a6f47 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -13,9 +13,7 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.core import HomeAssistant from .const import DOMAIN, VENSTAR_TIMEOUT from .coordinator import VenstarDataUpdateCoordinator @@ -59,36 +57,3 @@ async def async_unload_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(config.entry_id) return unload_ok - - -class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): - """Representation of a Venstar entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - venstar_data_coordinator: VenstarDataUpdateCoordinator, - config: ConfigEntry, - ) -> None: - """Initialize the data object.""" - super().__init__(venstar_data_coordinator) - self._config = config - self._client = venstar_data_coordinator.client - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.async_write_ha_state() - - @property - def device_info(self) -> DeviceInfo: - """Return the device information for this entity.""" - fw_ver_major, fw_ver_minor = self._client.get_firmware_ver() - return DeviceInfo( - identifiers={(DOMAIN, self._config.entry_id)}, - name=self._client.name, - manufacturer="Venstar", - model=f"{self._client.model}-{self._client.get_type()}", - sw_version=f"{fw_ver_major}.{fw_ver_minor}", - ) diff --git a/homeassistant/components/venstar/binary_sensor.py b/homeassistant/components/venstar/binary_sensor.py index 38bdc208d15..315df09b625 100644 --- a/homeassistant/components/venstar/binary_sensor.py +++ b/homeassistant/components/venstar/binary_sensor.py @@ -8,8 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VenstarEntity from .const import DOMAIN +from .entity import VenstarEntity async def async_setup_entry( diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index ea833dc3183..2865d64201e 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -36,7 +36,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import VenstarEntity from .const import ( _LOGGER, ATTR_FAN_STATE, @@ -47,6 +46,7 @@ from .const import ( HOLD_MODE_TEMPERATURE, ) from .coordinator import VenstarDataUpdateCoordinator +from .entity import VenstarEntity PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/venstar/entity.py b/homeassistant/components/venstar/entity.py new file mode 100644 index 00000000000..630da05324e --- /dev/null +++ b/homeassistant/components/venstar/entity.py @@ -0,0 +1,44 @@ +"""The venstar component.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import VenstarDataUpdateCoordinator + + +class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): + """Representation of a Venstar entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + venstar_data_coordinator: VenstarDataUpdateCoordinator, + config: ConfigEntry, + ) -> None: + """Initialize the data object.""" + super().__init__(venstar_data_coordinator) + self._config = config + self._client = venstar_data_coordinator.client + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() + + @property + def device_info(self) -> DeviceInfo: + """Return the device information for this entity.""" + fw_ver_major, fw_ver_minor = self._client.get_firmware_ver() + return DeviceInfo( + identifiers={(DOMAIN, self._config.entry_id)}, + name=self._client.name, + manufacturer="Venstar", + model=f"{self._client.model}-{self._client.get_type()}", + sw_version=f"{fw_ver_major}.{fw_ver_minor}", + ) diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py index 484aa711c1e..94180f6ad79 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -23,9 +23,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VenstarEntity from .const import DOMAIN from .coordinator import VenstarDataUpdateCoordinator +from .entity import VenstarEntity RUNTIME_HEAT1 = "heat1" RUNTIME_HEAT2 = "heat2" From 95948e4eb77c4a607026df368b1f8fb05d05c316 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:14:48 +0200 Subject: [PATCH 1017/1309] Move volvooncall base entity to separate module (#126543) --- .../components/volvooncall/__init__.py | 88 +------------------ .../components/volvooncall/binary_sensor.py | 3 +- .../components/volvooncall/device_tracker.py | 3 +- .../components/volvooncall/entity.py | 88 +++++++++++++++++++ homeassistant/components/volvooncall/lock.py | 3 +- .../components/volvooncall/sensor.py | 3 +- .../components/volvooncall/switch.py | 3 +- 7 files changed, 99 insertions(+), 92 deletions(-) create mode 100644 homeassistant/components/volvooncall/entity.py diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 2a99ac3e062..41a1e9f387d 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -17,13 +17,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_MUTABLE, @@ -188,84 +183,3 @@ class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=ha async with asyncio.timeout(10): await self.volvo_data.update() - - -class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]): - """Base class for all VOC entities.""" - - def __init__( - self, - vin: str, - component: str, - attribute: str, - slug_attr: str, - coordinator: VolvoUpdateCoordinator, - ) -> None: - """Initialize the entity.""" - super().__init__(coordinator) - - self.vin = vin - self.component = component - self.attribute = attribute - self.slug_attr = slug_attr - - @property - def instrument(self): - """Return corresponding instrument.""" - return self.coordinator.volvo_data.instrument( - self.vin, self.component, self.attribute, self.slug_attr - ) - - @property - def icon(self): - """Return the icon.""" - return self.instrument.icon - - @property - def vehicle(self): - """Return vehicle.""" - return self.instrument.vehicle - - @property - def _entity_name(self): - return self.instrument.name - - @property - def _vehicle_name(self): - return self.coordinator.volvo_data.vehicle_name(self.vehicle) - - @property - def name(self): - """Return full name of the entity.""" - return f"{self._vehicle_name} {self._entity_name}" - - @property - def assumed_state(self): - """Return true if unable to access real state of entity.""" - return True - - @property - def device_info(self) -> DeviceInfo: - """Return a inique set of attributes for each vehicle.""" - return DeviceInfo( - identifiers={(DOMAIN, self.vehicle.vin)}, - name=self._vehicle_name, - model=self.vehicle.vehicle_type, - manufacturer="Volvo", - ) - - @property - def extra_state_attributes(self): - """Return device specific state attributes.""" - return dict( - self.instrument.attributes, - model=f"{self.vehicle.vehicle_type}/{self.vehicle.model_year}", - ) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - slug_override = "" - if self.instrument.slug_override is not None: - slug_override = f"-{self.instrument.slug_override}" - return f"{self.vin}-{self.component}-{self.attribute}{slug_override}" diff --git a/homeassistant/components/volvooncall/binary_sensor.py b/homeassistant/components/volvooncall/binary_sensor.py index 604dc2313bf..52f2e3d067b 100644 --- a/homeassistant/components/volvooncall/binary_sensor.py +++ b/homeassistant/components/volvooncall/binary_sensor.py @@ -16,8 +16,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VolvoEntity, VolvoUpdateCoordinator +from . import VolvoUpdateCoordinator from .const import DOMAIN, VOLVO_DISCOVERY_NEW +from .entity import VolvoEntity async def async_setup_entry( diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py index 51c2f08130b..d4d164ff040 100644 --- a/homeassistant/components/volvooncall/device_tracker.py +++ b/homeassistant/components/volvooncall/device_tracker.py @@ -10,8 +10,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VolvoEntity, VolvoUpdateCoordinator +from . import VolvoUpdateCoordinator from .const import DOMAIN, VOLVO_DISCOVERY_NEW +from .entity import VolvoEntity async def async_setup_entry( diff --git a/homeassistant/components/volvooncall/entity.py b/homeassistant/components/volvooncall/entity.py new file mode 100644 index 00000000000..2258676a6bb --- /dev/null +++ b/homeassistant/components/volvooncall/entity.py @@ -0,0 +1,88 @@ +"""Support for Volvo On Call.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import VolvoUpdateCoordinator +from .const import DOMAIN + + +class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]): + """Base class for all VOC entities.""" + + def __init__( + self, + vin: str, + component: str, + attribute: str, + slug_attr: str, + coordinator: VolvoUpdateCoordinator, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self.vin = vin + self.component = component + self.attribute = attribute + self.slug_attr = slug_attr + + @property + def instrument(self): + """Return corresponding instrument.""" + return self.coordinator.volvo_data.instrument( + self.vin, self.component, self.attribute, self.slug_attr + ) + + @property + def icon(self): + """Return the icon.""" + return self.instrument.icon + + @property + def vehicle(self): + """Return vehicle.""" + return self.instrument.vehicle + + @property + def _entity_name(self): + return self.instrument.name + + @property + def _vehicle_name(self): + return self.coordinator.volvo_data.vehicle_name(self.vehicle) + + @property + def name(self): + """Return full name of the entity.""" + return f"{self._vehicle_name} {self._entity_name}" + + @property + def assumed_state(self): + """Return true if unable to access real state of entity.""" + return True + + @property + def device_info(self) -> DeviceInfo: + """Return a inique set of attributes for each vehicle.""" + return DeviceInfo( + identifiers={(DOMAIN, self.vehicle.vin)}, + name=self._vehicle_name, + model=self.vehicle.vehicle_type, + manufacturer="Volvo", + ) + + @property + def extra_state_attributes(self): + """Return device specific state attributes.""" + return dict( + self.instrument.attributes, + model=f"{self.vehicle.vehicle_type}/{self.vehicle.model_year}", + ) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + slug_override = "" + if self.instrument.slug_override is not None: + slug_override = f"-{self.instrument.slug_override}" + return f"{self.vin}-{self.component}-{self.attribute}{slug_override}" diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py index cccd64bce05..9d187e2b096 100644 --- a/homeassistant/components/volvooncall/lock.py +++ b/homeassistant/components/volvooncall/lock.py @@ -12,8 +12,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VolvoEntity, VolvoUpdateCoordinator +from . import VolvoUpdateCoordinator from .const import DOMAIN, VOLVO_DISCOVERY_NEW +from .entity import VolvoEntity async def async_setup_entry( diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py index a46c8671929..8ea7888769f 100644 --- a/homeassistant/components/volvooncall/sensor.py +++ b/homeassistant/components/volvooncall/sensor.py @@ -10,8 +10,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VolvoEntity, VolvoUpdateCoordinator +from . import VolvoUpdateCoordinator from .const import DOMAIN, VOLVO_DISCOVERY_NEW +from .entity import VolvoEntity async def async_setup_entry( diff --git a/homeassistant/components/volvooncall/switch.py b/homeassistant/components/volvooncall/switch.py index 23bc452ef66..d445881424b 100644 --- a/homeassistant/components/volvooncall/switch.py +++ b/homeassistant/components/volvooncall/switch.py @@ -12,8 +12,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VolvoEntity, VolvoUpdateCoordinator +from . import VolvoUpdateCoordinator from .const import DOMAIN, VOLVO_DISCOVERY_NEW +from .entity import VolvoEntity async def async_setup_entry( From 6d83a15ad5d1066dafb9ef6cbbd3279a486d0cc7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:15:04 +0200 Subject: [PATCH 1018/1309] Move yamaha_musiccast base entity to separate module (#126544) --- .../components/yamaha_musiccast/__init__.py | 123 +----------------- .../components/yamaha_musiccast/entity.py | 112 ++++++++++++++++ .../yamaha_musiccast/media_player.py | 3 +- .../components/yamaha_musiccast/number.py | 3 +- .../components/yamaha_musiccast/select.py | 3 +- .../components/yamaha_musiccast/switch.py | 3 +- 6 files changed, 127 insertions(+), 120 deletions(-) create mode 100644 homeassistant/components/yamaha_musiccast/entity.py diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index f8d9f77f120..4f540017b63 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -4,35 +4,22 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import TYPE_CHECKING from aiomusiccast import MusicCastConnectionException -from aiomusiccast.capabilities import Capability from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CONNECTIONS, ATTR_VIA_DEVICE, CONF_HOST, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceInfo, - format_mac, -) -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ( - BRAND, - CONF_SERIAL, - CONF_UPNP_DESC, - DEFAULT_ZONE, - DOMAIN, - ENTITY_CATEGORY_MAPPING, -) +from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN + +if TYPE_CHECKING: + from .entity import MusicCastDeviceEntity PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SWITCH] @@ -122,99 +109,3 @@ class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): # p except MusicCastConnectionException as exception: raise UpdateFailed from exception return self.musiccast.data - - -class MusicCastEntity(CoordinatorEntity[MusicCastDataUpdateCoordinator]): - """Defines a base MusicCast entity.""" - - def __init__( - self, - *, - name: str, - icon: str, - coordinator: MusicCastDataUpdateCoordinator, - enabled_default: bool = True, - ) -> None: - """Initialize the MusicCast entity.""" - super().__init__(coordinator) - self._attr_entity_registry_enabled_default = enabled_default - self._attr_icon = icon - self._attr_name = name - - -class MusicCastDeviceEntity(MusicCastEntity): - """Defines a MusicCast device entity.""" - - _zone_id: str = DEFAULT_ZONE - - @property - def device_id(self): - """Return the ID of the current device.""" - if self._zone_id == DEFAULT_ZONE: - return self.coordinator.data.device_id - return f"{self.coordinator.data.device_id}_{self._zone_id}" - - @property - def device_name(self): - """Return the name of the current device.""" - return self.coordinator.data.zones[self._zone_id].name - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this MusicCast device.""" - - device_info = DeviceInfo( - name=self.device_name, - identifiers={ - ( - DOMAIN, - self.device_id, - ) - }, - manufacturer=BRAND, - model=self.coordinator.data.model_name, - sw_version=self.coordinator.data.system_version, - ) - - if self._zone_id == DEFAULT_ZONE: - device_info[ATTR_CONNECTIONS] = { - (CONNECTION_NETWORK_MAC, format_mac(mac)) - for mac in self.coordinator.data.mac_addresses.values() - } - else: - device_info[ATTR_VIA_DEVICE] = (DOMAIN, self.coordinator.data.device_id) - - return device_info - - async def async_added_to_hass(self): - """Run when this Entity has been added to HA.""" - await super().async_added_to_hass() - # All entities should register callbacks to update HA when their state changes - self.coordinator.musiccast.register_callback(self.async_write_ha_state) - - async def async_will_remove_from_hass(self): - """Entity being removed from hass.""" - await super().async_will_remove_from_hass() - self.coordinator.musiccast.remove_callback(self.async_write_ha_state) - - -class MusicCastCapabilityEntity(MusicCastDeviceEntity): - """Base Entity type for all capabilities.""" - - def __init__( - self, - coordinator: MusicCastDataUpdateCoordinator, - capability: Capability, - zone_id: str | None = None, - ) -> None: - """Initialize a capability based entity.""" - if zone_id is not None: - self._zone_id = zone_id - self.capability = capability - super().__init__(name=capability.name, icon="", coordinator=coordinator) - self._attr_entity_category = ENTITY_CATEGORY_MAPPING.get(capability.entity_type) - - @property - def unique_id(self) -> str: - """Return the unique ID for this entity.""" - return f"{self.device_id}_{self.capability.id}" diff --git a/homeassistant/components/yamaha_musiccast/entity.py b/homeassistant/components/yamaha_musiccast/entity.py new file mode 100644 index 00000000000..b0effd63921 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/entity.py @@ -0,0 +1,112 @@ +"""The MusicCast integration.""" + +from __future__ import annotations + +from aiomusiccast.capabilities import Capability + +from homeassistant.const import ATTR_CONNECTIONS, ATTR_VIA_DEVICE +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import MusicCastDataUpdateCoordinator +from .const import BRAND, DEFAULT_ZONE, DOMAIN, ENTITY_CATEGORY_MAPPING + + +class MusicCastEntity(CoordinatorEntity[MusicCastDataUpdateCoordinator]): + """Defines a base MusicCast entity.""" + + def __init__( + self, + *, + name: str, + icon: str, + coordinator: MusicCastDataUpdateCoordinator, + enabled_default: bool = True, + ) -> None: + """Initialize the MusicCast entity.""" + super().__init__(coordinator) + self._attr_entity_registry_enabled_default = enabled_default + self._attr_icon = icon + self._attr_name = name + + +class MusicCastDeviceEntity(MusicCastEntity): + """Defines a MusicCast device entity.""" + + _zone_id: str = DEFAULT_ZONE + + @property + def device_id(self): + """Return the ID of the current device.""" + if self._zone_id == DEFAULT_ZONE: + return self.coordinator.data.device_id + return f"{self.coordinator.data.device_id}_{self._zone_id}" + + @property + def device_name(self): + """Return the name of the current device.""" + return self.coordinator.data.zones[self._zone_id].name + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this MusicCast device.""" + + device_info = DeviceInfo( + name=self.device_name, + identifiers={ + ( + DOMAIN, + self.device_id, + ) + }, + manufacturer=BRAND, + model=self.coordinator.data.model_name, + sw_version=self.coordinator.data.system_version, + ) + + if self._zone_id == DEFAULT_ZONE: + device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, format_mac(mac)) + for mac in self.coordinator.data.mac_addresses.values() + } + else: + device_info[ATTR_VIA_DEVICE] = (DOMAIN, self.coordinator.data.device_id) + + return device_info + + async def async_added_to_hass(self): + """Run when this Entity has been added to HA.""" + await super().async_added_to_hass() + # All entities should register callbacks to update HA when their state changes + self.coordinator.musiccast.register_callback(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """Entity being removed from hass.""" + await super().async_will_remove_from_hass() + self.coordinator.musiccast.remove_callback(self.async_write_ha_state) + + +class MusicCastCapabilityEntity(MusicCastDeviceEntity): + """Base Entity type for all capabilities.""" + + def __init__( + self, + coordinator: MusicCastDataUpdateCoordinator, + capability: Capability, + zone_id: str | None = None, + ) -> None: + """Initialize a capability based entity.""" + if zone_id is not None: + self._zone_id = zone_id + self.capability = capability + super().__init__(name=capability.name, icon="", coordinator=coordinator) + self._attr_entity_category = ENTITY_CATEGORY_MAPPING.get(capability.entity_type) + + @property + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + return f"{self.device_id}_{self.capability.id}" diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index a068ac6ddca..49d16db5f32 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import uuid -from . import MusicCastDataUpdateCoordinator, MusicCastDeviceEntity +from . import MusicCastDataUpdateCoordinator from .const import ( ATTR_MAIN_SYNC, ATTR_MC_LINK, @@ -38,6 +38,7 @@ from .const import ( MEDIA_CLASS_MAPPING, NULL_GROUP, ) +from .entity import MusicCastDeviceEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yamaha_musiccast/number.py b/homeassistant/components/yamaha_musiccast/number.py index a5a591379c6..384e725bc7d 100644 --- a/homeassistant/components/yamaha_musiccast/number.py +++ b/homeassistant/components/yamaha_musiccast/number.py @@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, MusicCastCapabilityEntity, MusicCastDataUpdateCoordinator +from . import DOMAIN, MusicCastDataUpdateCoordinator +from .entity import MusicCastCapabilityEntity async def async_setup_entry( diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py index b068b956e1b..163417d9fb9 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -9,8 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, MusicCastCapabilityEntity, MusicCastDataUpdateCoordinator +from . import DOMAIN, MusicCastDataUpdateCoordinator from .const import TRANSLATION_KEY_MAPPING +from .entity import MusicCastCapabilityEntity async def async_setup_entry( diff --git a/homeassistant/components/yamaha_musiccast/switch.py b/homeassistant/components/yamaha_musiccast/switch.py index 2ae8388027a..d8c3720908d 100644 --- a/homeassistant/components/yamaha_musiccast/switch.py +++ b/homeassistant/components/yamaha_musiccast/switch.py @@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, MusicCastCapabilityEntity, MusicCastDataUpdateCoordinator +from . import DOMAIN, MusicCastDataUpdateCoordinator +from .entity import MusicCastCapabilityEntity async def async_setup_entry( From d101fb33b3ff826ad509bc5498e7fa235d3e927e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:56:59 +0200 Subject: [PATCH 1019/1309] Move tolo coordinator to separate module (#126550) * Move tolo coordinator to separate module * Adjust tests --- homeassistant/components/tolo/__init__.py | 50 ++--------------- .../components/tolo/binary_sensor.py | 2 +- homeassistant/components/tolo/button.py | 2 +- homeassistant/components/tolo/climate.py | 2 +- homeassistant/components/tolo/coordinator.py | 54 +++++++++++++++++++ homeassistant/components/tolo/entity.py | 2 +- homeassistant/components/tolo/fan.py | 2 +- homeassistant/components/tolo/light.py | 2 +- homeassistant/components/tolo/number.py | 2 +- homeassistant/components/tolo/select.py | 2 +- homeassistant/components/tolo/sensor.py | 2 +- homeassistant/components/tolo/switch.py | 2 +- tests/components/tolo/test_config_flow.py | 2 +- 13 files changed, 68 insertions(+), 58 deletions(-) create mode 100644 homeassistant/components/tolo/coordinator.py diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py index 58ba9f550a9..d2a43ef525b 100644 --- a/homeassistant/components/tolo/__init__.py +++ b/homeassistant/components/tolo/__init__.py @@ -2,18 +2,12 @@ from __future__ import annotations -from datetime import timedelta -import logging -from typing import NamedTuple - -from tololib import ToloClient, ToloSettings, ToloStatus - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEFAULT_RETRY_COUNT, DEFAULT_RETRY_TIMEOUT, DOMAIN +from .const import DOMAIN +from .coordinator import ToloSaunaUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -27,8 +21,6 @@ PLATFORMS = [ Platform.SWITCH, ] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up tolo from a config entry.""" @@ -48,39 +40,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class ToloSaunaData(NamedTuple): - """Compound class for reflecting full state (status and info) of a TOLO Sauna.""" - - status: ToloStatus - settings: ToloSettings - - -class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): # pylint: disable=hass-enforce-class-module - """DataUpdateCoordinator for TOLO Sauna.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize ToloSaunaUpdateCoordinator.""" - self.client = ToloClient( - address=entry.data[CONF_HOST], - retry_timeout=DEFAULT_RETRY_TIMEOUT, - retry_count=DEFAULT_RETRY_COUNT, - ) - super().__init__( - hass=hass, - logger=_LOGGER, - name=f"{entry.title} ({entry.data[CONF_HOST]}) Data Update Coordinator", - update_interval=timedelta(seconds=5), - ) - - async def _async_update_data(self) -> ToloSaunaData: - return await self.hass.async_add_executor_job(self._get_tolo_sauna_data) - - def _get_tolo_sauna_data(self) -> ToloSaunaData: - try: - status = self.client.get_status() - settings = self.client.get_settings() - except TimeoutError as error: - raise UpdateFailed("communication timeout") from error - return ToloSaunaData(status, settings) diff --git a/homeassistant/components/tolo/binary_sensor.py b/homeassistant/components/tolo/binary_sensor.py index 835bc913a86..845f8ed22e3 100644 --- a/homeassistant/components/tolo/binary_sensor.py +++ b/homeassistant/components/tolo/binary_sensor.py @@ -9,8 +9,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .coordinator import ToloSaunaUpdateCoordinator from .entity import ToloSaunaCoordinatorEntity diff --git a/homeassistant/components/tolo/button.py b/homeassistant/components/tolo/button.py index 7c32d7d7a29..b7c4362ca7b 100644 --- a/homeassistant/components/tolo/button.py +++ b/homeassistant/components/tolo/button.py @@ -8,8 +8,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .coordinator import ToloSaunaUpdateCoordinator from .entity import ToloSaunaCoordinatorEntity diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index f6360e1d99b..8c5176b3e4e 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -25,8 +25,8 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTempera from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .coordinator import ToloSaunaUpdateCoordinator from .entity import ToloSaunaCoordinatorEntity diff --git a/homeassistant/components/tolo/coordinator.py b/homeassistant/components/tolo/coordinator.py new file mode 100644 index 00000000000..632cc819f5a --- /dev/null +++ b/homeassistant/components/tolo/coordinator.py @@ -0,0 +1,54 @@ +"""Component to control TOLO Sauna/Steam Bath.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import NamedTuple + +from tololib import ToloClient, ToloSettings, ToloStatus + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_RETRY_COUNT, DEFAULT_RETRY_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +class ToloSaunaData(NamedTuple): + """Compound class for reflecting full state (status and info) of a TOLO Sauna.""" + + status: ToloStatus + settings: ToloSettings + + +class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): + """DataUpdateCoordinator for TOLO Sauna.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize ToloSaunaUpdateCoordinator.""" + self.client = ToloClient( + address=entry.data[CONF_HOST], + retry_timeout=DEFAULT_RETRY_TIMEOUT, + retry_count=DEFAULT_RETRY_COUNT, + ) + super().__init__( + hass=hass, + logger=_LOGGER, + name=f"{entry.title} ({entry.data[CONF_HOST]}) Data Update Coordinator", + update_interval=timedelta(seconds=5), + ) + + async def _async_update_data(self) -> ToloSaunaData: + return await self.hass.async_add_executor_job(self._get_tolo_sauna_data) + + def _get_tolo_sauna_data(self) -> ToloSaunaData: + try: + status = self.client.get_status() + settings = self.client.get_settings() + except TimeoutError as error: + raise UpdateFailed("communication timeout") from error + return ToloSaunaData(status, settings) diff --git a/homeassistant/components/tolo/entity.py b/homeassistant/components/tolo/entity.py index 68ddc382e7f..261cfc7cb0c 100644 --- a/homeassistant/components/tolo/entity.py +++ b/homeassistant/components/tolo/entity.py @@ -6,8 +6,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .coordinator import ToloSaunaUpdateCoordinator class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py index 396dc0b0da4..9b62346a83b 100644 --- a/homeassistant/components/tolo/fan.py +++ b/homeassistant/components/tolo/fan.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .coordinator import ToloSaunaUpdateCoordinator from .entity import ToloSaunaCoordinatorEntity diff --git a/homeassistant/components/tolo/light.py b/homeassistant/components/tolo/light.py index 5491aa90ea4..eeb37305fe8 100644 --- a/homeassistant/components/tolo/light.py +++ b/homeassistant/components/tolo/light.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .coordinator import ToloSaunaUpdateCoordinator from .entity import ToloSaunaCoordinatorEntity diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index acdd26fe9c0..73505c5b251 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -20,8 +20,8 @@ from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .coordinator import ToloSaunaUpdateCoordinator from .entity import ToloSaunaCoordinatorEntity diff --git a/homeassistant/components/tolo/select.py b/homeassistant/components/tolo/select.py index b41595d3a34..fee1ac1774e 100644 --- a/homeassistant/components/tolo/select.py +++ b/homeassistant/components/tolo/select.py @@ -13,8 +13,8 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaUpdateCoordinator from .const import DOMAIN, AromaTherapySlot, LampMode +from .coordinator import ToloSaunaUpdateCoordinator from .entity import ToloSaunaCoordinatorEntity diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py index 8ea6b68ae95..0e94ec0ae1e 100644 --- a/homeassistant/components/tolo/sensor.py +++ b/homeassistant/components/tolo/sensor.py @@ -23,8 +23,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .coordinator import ToloSaunaUpdateCoordinator from .entity import ToloSaunaCoordinatorEntity diff --git a/homeassistant/components/tolo/switch.py b/homeassistant/components/tolo/switch.py index 9799d106658..d39dd17f0f3 100644 --- a/homeassistant/components/tolo/switch.py +++ b/homeassistant/components/tolo/switch.py @@ -13,8 +13,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ToloSaunaUpdateCoordinator from .const import DOMAIN +from .coordinator import ToloSaunaUpdateCoordinator from .entity import ToloSaunaCoordinatorEntity diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index 9dcca4b704f..73382944cf0 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -31,7 +31,7 @@ def coordinator_toloclient() -> Mock: Throw exception to abort entry setup and prevent socket IO. Only testing config flow. """ with patch( - "homeassistant.components.tolo.ToloClient", side_effect=Exception + "homeassistant.components.tolo.coordinator.ToloClient", side_effect=Exception ) as toloclient: yield toloclient From d2ab7dd9fb9a94750c9acce81b4de0ba61b510f1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:57:31 +0200 Subject: [PATCH 1020/1309] Move yamaha_musiccast coordinator to separate module (#126546) --- .../components/yamaha_musiccast/__init__.py | 30 +------------- .../yamaha_musiccast/coordinator.py | 41 +++++++++++++++++++ .../components/yamaha_musiccast/entity.py | 2 +- .../yamaha_musiccast/media_player.py | 2 +- .../components/yamaha_musiccast/number.py | 3 +- .../components/yamaha_musiccast/select.py | 4 +- .../components/yamaha_musiccast/switch.py | 3 +- 7 files changed, 51 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/yamaha_musiccast/coordinator.py diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 4f540017b63..a2ce98dde56 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -2,29 +2,22 @@ from __future__ import annotations -from datetime import timedelta import logging -from typing import TYPE_CHECKING -from aiomusiccast import MusicCastConnectionException -from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice +from aiomusiccast.musiccast_device import MusicCastDevice from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN - -if TYPE_CHECKING: - from .entity import MusicCastDeviceEntity +from .coordinator import MusicCastDataUpdateCoordinator PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=60) async def get_upnp_desc(hass: HomeAssistant, host: str): @@ -90,22 +83,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload config entry.""" await hass.config_entries.async_reload(entry.entry_id) - - -class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): # pylint: disable=hass-enforce-class-module - """Class to manage fetching data from the API.""" - - def __init__(self, hass: HomeAssistant, client: MusicCastDevice) -> None: - """Initialize.""" - self.musiccast = client - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - self.entities: list[MusicCastDeviceEntity] = [] - - async def _async_update_data(self) -> MusicCastData: - """Update data via library.""" - try: - await self.musiccast.fetch() - except MusicCastConnectionException as exception: - raise UpdateFailed from exception - return self.musiccast.data diff --git a/homeassistant/components/yamaha_musiccast/coordinator.py b/homeassistant/components/yamaha_musiccast/coordinator.py new file mode 100644 index 00000000000..d5e0c67310a --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/coordinator.py @@ -0,0 +1,41 @@ +"""The MusicCast integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TYPE_CHECKING + +from aiomusiccast import MusicCastConnectionException +from aiomusiccast.musiccast_device import MusicCastData, MusicCastDevice + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +if TYPE_CHECKING: + from .entity import MusicCastDeviceEntity + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) + + +class MusicCastDataUpdateCoordinator(DataUpdateCoordinator[MusicCastData]): + """Class to manage fetching data from the API.""" + + def __init__(self, hass: HomeAssistant, client: MusicCastDevice) -> None: + """Initialize.""" + self.musiccast = client + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.entities: list[MusicCastDeviceEntity] = [] + + async def _async_update_data(self) -> MusicCastData: + """Update data via library.""" + try: + await self.musiccast.fetch() + except MusicCastConnectionException as exception: + raise UpdateFailed from exception + return self.musiccast.data diff --git a/homeassistant/components/yamaha_musiccast/entity.py b/homeassistant/components/yamaha_musiccast/entity.py index b0effd63921..4f1add825e4 100644 --- a/homeassistant/components/yamaha_musiccast/entity.py +++ b/homeassistant/components/yamaha_musiccast/entity.py @@ -12,8 +12,8 @@ from homeassistant.helpers.device_registry import ( ) from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import MusicCastDataUpdateCoordinator from .const import BRAND, DEFAULT_ZONE, DOMAIN, ENTITY_CATEGORY_MAPPING +from .coordinator import MusicCastDataUpdateCoordinator class MusicCastEntity(CoordinatorEntity[MusicCastDataUpdateCoordinator]): diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 49d16db5f32..4384cc34836 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -27,7 +27,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import uuid -from . import MusicCastDataUpdateCoordinator from .const import ( ATTR_MAIN_SYNC, ATTR_MC_LINK, @@ -38,6 +37,7 @@ from .const import ( MEDIA_CLASS_MAPPING, NULL_GROUP, ) +from .coordinator import MusicCastDataUpdateCoordinator from .entity import MusicCastDeviceEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/yamaha_musiccast/number.py b/homeassistant/components/yamaha_musiccast/number.py index 384e725bc7d..02dd6720d91 100644 --- a/homeassistant/components/yamaha_musiccast/number.py +++ b/homeassistant/components/yamaha_musiccast/number.py @@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, MusicCastDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import MusicCastDataUpdateCoordinator from .entity import MusicCastCapabilityEntity diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py index 163417d9fb9..3a4649b9ae5 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, MusicCastDataUpdateCoordinator -from .const import TRANSLATION_KEY_MAPPING +from .const import DOMAIN, TRANSLATION_KEY_MAPPING +from .coordinator import MusicCastDataUpdateCoordinator from .entity import MusicCastCapabilityEntity diff --git a/homeassistant/components/yamaha_musiccast/switch.py b/homeassistant/components/yamaha_musiccast/switch.py index d8c3720908d..49d031a02b5 100644 --- a/homeassistant/components/yamaha_musiccast/switch.py +++ b/homeassistant/components/yamaha_musiccast/switch.py @@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import DOMAIN, MusicCastDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import MusicCastDataUpdateCoordinator from .entity import MusicCastCapabilityEntity From 380019dd560ae2aa6b858fe1c53c58d16306de9f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:57:47 +0200 Subject: [PATCH 1021/1309] Move volvooncall coordinator to separate module (#126548) --- .../components/volvooncall/__init__.py | 118 +----------------- .../components/volvooncall/binary_sensor.py | 2 +- .../components/volvooncall/config_flow.py | 2 +- .../components/volvooncall/coordinator.py | 34 +++++ .../components/volvooncall/device_tracker.py | 2 +- .../components/volvooncall/entity.py | 2 +- homeassistant/components/volvooncall/lock.py | 2 +- .../components/volvooncall/models.py | 100 +++++++++++++++ .../components/volvooncall/sensor.py | 2 +- .../components/volvooncall/switch.py | 2 +- 10 files changed, 143 insertions(+), 123 deletions(-) create mode 100644 homeassistant/components/volvooncall/coordinator.py create mode 100644 homeassistant/components/volvooncall/models.py diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 41a1e9f387d..9fc07dd92b0 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -1,11 +1,6 @@ """Support for Volvo On Call.""" -import asyncio -import logging - -from aiohttp.client_exceptions import ClientResponseError from volvooncall import Connection -from volvooncall.dashboard import Instrument from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -15,25 +10,17 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - CONF_MUTABLE, CONF_SCANDINAVIAN_MILES, - DEFAULT_UPDATE_INTERVAL, DOMAIN, PLATFORMS, - UNIT_SYSTEM_IMPERIAL, UNIT_SYSTEM_METRIC, UNIT_SYSTEM_SCANDINAVIAN_MILES, - VOLVO_DISCOVERY_NEW, ) -from .errors import InvalidAuth - -_LOGGER = logging.getLogger(__name__) +from .coordinator import VolvoUpdateCoordinator +from .models import VolvoData async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -82,104 +69,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class VolvoData: - """Hold component state.""" - - def __init__( - self, - hass: HomeAssistant, - connection: Connection, - entry: ConfigEntry, - ) -> None: - """Initialize the component state.""" - self.hass = hass - self.vehicles: set[str] = set() - self.instruments: set[Instrument] = set() - self.config_entry = entry - self.connection = connection - - def instrument(self, vin, component, attr, slug_attr): - """Return corresponding instrument.""" - return next( - instrument - for instrument in self.instruments - if instrument.vehicle.vin == vin - and instrument.component == component - and instrument.attr == attr - and instrument.slug_attr == slug_attr - ) - - def vehicle_name(self, vehicle): - """Provide a friendly name for a vehicle.""" - if vehicle.registration_number and vehicle.registration_number != "UNKNOWN": - return vehicle.registration_number - if vehicle.vin: - return vehicle.vin - return "Volvo" - - def discover_vehicle(self, vehicle): - """Load relevant platforms.""" - self.vehicles.add(vehicle.vin) - - dashboard = vehicle.dashboard( - mutable=self.config_entry.data[CONF_MUTABLE], - scandinavian_miles=( - self.config_entry.data[CONF_UNIT_SYSTEM] - == UNIT_SYSTEM_SCANDINAVIAN_MILES - ), - usa_units=( - self.config_entry.data[CONF_UNIT_SYSTEM] == UNIT_SYSTEM_IMPERIAL - ), - ) - - for instrument in ( - instrument - for instrument in dashboard.instruments - if instrument.component in PLATFORMS - ): - self.instruments.add(instrument) - async_dispatcher_send(self.hass, VOLVO_DISCOVERY_NEW, [instrument]) - - async def update(self): - """Update status from the online service.""" - try: - await self.connection.update(journal=True) - except ClientResponseError as ex: - if ex.status == 401: - raise ConfigEntryAuthFailed(ex) from ex - raise UpdateFailed(ex) from ex - - for vehicle in self.connection.vehicles: - if vehicle.vin not in self.vehicles: - self.discover_vehicle(vehicle) - - async def auth_is_valid(self): - """Check if provided username/password/region authenticate.""" - try: - await self.connection.get("customeraccounts") - except ClientResponseError as exc: - raise InvalidAuth from exc - - -class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-enforce-class-module - """Volvo coordinator.""" - - def __init__(self, hass: HomeAssistant, volvo_data: VolvoData) -> None: - """Initialize the data update coordinator.""" - - super().__init__( - hass, - _LOGGER, - name="volvooncall", - update_interval=DEFAULT_UPDATE_INTERVAL, - ) - - self.volvo_data = volvo_data - - async def _async_update_data(self) -> None: - """Fetch data from API endpoint.""" - - async with asyncio.timeout(10): - await self.volvo_data.update() diff --git a/homeassistant/components/volvooncall/binary_sensor.py b/homeassistant/components/volvooncall/binary_sensor.py index 52f2e3d067b..e6104f8d87c 100644 --- a/homeassistant/components/volvooncall/binary_sensor.py +++ b/homeassistant/components/volvooncall/binary_sensor.py @@ -16,8 +16,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VolvoUpdateCoordinator from .const import DOMAIN, VOLVO_DISCOVERY_NEW +from .coordinator import VolvoUpdateCoordinator from .entity import VolvoEntity diff --git a/homeassistant/components/volvooncall/config_flow.py b/homeassistant/components/volvooncall/config_flow.py index b3a1745351b..a5e860c9105 100644 --- a/homeassistant/components/volvooncall/config_flow.py +++ b/homeassistant/components/volvooncall/config_flow.py @@ -18,7 +18,6 @@ from homeassistant.const import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from . import VolvoData from .const import ( CONF_MUTABLE, DOMAIN, @@ -27,6 +26,7 @@ from .const import ( UNIT_SYSTEM_SCANDINAVIAN_MILES, ) from .errors import InvalidAuth +from .models import VolvoData _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/volvooncall/coordinator.py b/homeassistant/components/volvooncall/coordinator.py new file mode 100644 index 00000000000..5ac6a58acb0 --- /dev/null +++ b/homeassistant/components/volvooncall/coordinator.py @@ -0,0 +1,34 @@ +"""Support for Volvo On Call.""" + +import asyncio +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DEFAULT_UPDATE_INTERVAL +from .models import VolvoData + +_LOGGER = logging.getLogger(__name__) + + +class VolvoUpdateCoordinator(DataUpdateCoordinator[None]): + """Volvo coordinator.""" + + def __init__(self, hass: HomeAssistant, volvo_data: VolvoData) -> None: + """Initialize the data update coordinator.""" + + super().__init__( + hass, + _LOGGER, + name="volvooncall", + update_interval=DEFAULT_UPDATE_INTERVAL, + ) + + self.volvo_data = volvo_data + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + + async with asyncio.timeout(10): + await self.volvo_data.update() diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py index d4d164ff040..d0d6abde414 100644 --- a/homeassistant/components/volvooncall/device_tracker.py +++ b/homeassistant/components/volvooncall/device_tracker.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VolvoUpdateCoordinator from .const import DOMAIN, VOLVO_DISCOVERY_NEW +from .coordinator import VolvoUpdateCoordinator from .entity import VolvoEntity diff --git a/homeassistant/components/volvooncall/entity.py b/homeassistant/components/volvooncall/entity.py index 2258676a6bb..6ebc4bdc754 100644 --- a/homeassistant/components/volvooncall/entity.py +++ b/homeassistant/components/volvooncall/entity.py @@ -3,8 +3,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import VolvoUpdateCoordinator from .const import DOMAIN +from .coordinator import VolvoUpdateCoordinator class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]): diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py index 9d187e2b096..cff5df35750 100644 --- a/homeassistant/components/volvooncall/lock.py +++ b/homeassistant/components/volvooncall/lock.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VolvoUpdateCoordinator from .const import DOMAIN, VOLVO_DISCOVERY_NEW +from .coordinator import VolvoUpdateCoordinator from .entity import VolvoEntity diff --git a/homeassistant/components/volvooncall/models.py b/homeassistant/components/volvooncall/models.py new file mode 100644 index 00000000000..159379a908b --- /dev/null +++ b/homeassistant/components/volvooncall/models.py @@ -0,0 +1,100 @@ +"""Support for Volvo On Call.""" + +from aiohttp.client_exceptions import ClientResponseError +from volvooncall import Connection +from volvooncall.dashboard import Instrument + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIT_SYSTEM +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .const import ( + CONF_MUTABLE, + PLATFORMS, + UNIT_SYSTEM_IMPERIAL, + UNIT_SYSTEM_SCANDINAVIAN_MILES, + VOLVO_DISCOVERY_NEW, +) +from .errors import InvalidAuth + + +class VolvoData: + """Hold component state.""" + + def __init__( + self, + hass: HomeAssistant, + connection: Connection, + entry: ConfigEntry, + ) -> None: + """Initialize the component state.""" + self.hass = hass + self.vehicles: set[str] = set() + self.instruments: set[Instrument] = set() + self.config_entry = entry + self.connection = connection + + def instrument(self, vin, component, attr, slug_attr): + """Return corresponding instrument.""" + return next( + instrument + for instrument in self.instruments + if instrument.vehicle.vin == vin + and instrument.component == component + and instrument.attr == attr + and instrument.slug_attr == slug_attr + ) + + def vehicle_name(self, vehicle): + """Provide a friendly name for a vehicle.""" + if vehicle.registration_number and vehicle.registration_number != "UNKNOWN": + return vehicle.registration_number + if vehicle.vin: + return vehicle.vin + return "Volvo" + + def discover_vehicle(self, vehicle): + """Load relevant platforms.""" + self.vehicles.add(vehicle.vin) + + dashboard = vehicle.dashboard( + mutable=self.config_entry.data[CONF_MUTABLE], + scandinavian_miles=( + self.config_entry.data[CONF_UNIT_SYSTEM] + == UNIT_SYSTEM_SCANDINAVIAN_MILES + ), + usa_units=( + self.config_entry.data[CONF_UNIT_SYSTEM] == UNIT_SYSTEM_IMPERIAL + ), + ) + + for instrument in ( + instrument + for instrument in dashboard.instruments + if instrument.component in PLATFORMS + ): + self.instruments.add(instrument) + async_dispatcher_send(self.hass, VOLVO_DISCOVERY_NEW, [instrument]) + + async def update(self): + """Update status from the online service.""" + try: + await self.connection.update(journal=True) + except ClientResponseError as ex: + if ex.status == 401: + raise ConfigEntryAuthFailed(ex) from ex + raise UpdateFailed(ex) from ex + + for vehicle in self.connection.vehicles: + if vehicle.vin not in self.vehicles: + self.discover_vehicle(vehicle) + + async def auth_is_valid(self): + """Check if provided username/password/region authenticate.""" + try: + await self.connection.get("customeraccounts") + except ClientResponseError as exc: + raise InvalidAuth from exc diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py index 8ea7888769f..9916d37197b 100644 --- a/homeassistant/components/volvooncall/sensor.py +++ b/homeassistant/components/volvooncall/sensor.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VolvoUpdateCoordinator from .const import DOMAIN, VOLVO_DISCOVERY_NEW +from .coordinator import VolvoUpdateCoordinator from .entity import VolvoEntity diff --git a/homeassistant/components/volvooncall/switch.py b/homeassistant/components/volvooncall/switch.py index d445881424b..7e60f47fb44 100644 --- a/homeassistant/components/volvooncall/switch.py +++ b/homeassistant/components/volvooncall/switch.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import VolvoUpdateCoordinator from .const import DOMAIN, VOLVO_DISCOVERY_NEW +from .coordinator import VolvoUpdateCoordinator from .entity import VolvoEntity From 02ab2c1433a068010eb11f024b6d4d16b2851b93 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:58:02 +0200 Subject: [PATCH 1022/1309] Move ukraine_alarm coordinator to separate module (#126549) --- .../components/ukraine_alarm/__init__.py | 45 +---------------- .../components/ukraine_alarm/binary_sensor.py | 2 +- .../components/ukraine_alarm/coordinator.py | 49 +++++++++++++++++++ 3 files changed, 52 insertions(+), 44 deletions(-) create mode 100644 homeassistant/components/ukraine_alarm/coordinator.py diff --git a/homeassistant/components/ukraine_alarm/__init__.py b/homeassistant/components/ukraine_alarm/__init__.py index 772eb155fd5..d850ed6eba8 100644 --- a/homeassistant/components/ukraine_alarm/__init__.py +++ b/homeassistant/components/ukraine_alarm/__init__.py @@ -2,25 +2,13 @@ from __future__ import annotations -from datetime import timedelta -import logging -from typing import Any - -import aiohttp -from aiohttp import ClientSession -from uasiren.client import Client - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_REGION from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ALERT_TYPES, DOMAIN, PLATFORMS - -_LOGGER = logging.getLogger(__name__) - -UPDATE_INTERVAL = timedelta(seconds=10) +from .const import DOMAIN, PLATFORMS +from .coordinator import UkraineAlarmDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -45,32 +33,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # pylint: disable=hass-enforce-class-module - """Class to manage fetching Ukraine Alarm API.""" - - def __init__( - self, - hass: HomeAssistant, - session: ClientSession, - region_id: str, - ) -> None: - """Initialize.""" - self.region_id = region_id - self.uasiren = Client(session) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) - - async def _async_update_data(self) -> dict[str, Any]: - """Update data via library.""" - try: - res = await self.uasiren.get_alerts(self.region_id) - except aiohttp.ClientError as error: - raise UpdateFailed(f"Error fetching alerts from API: {error}") from error - - current = {alert_type: False for alert_type in ALERT_TYPES} - for alert in res[0]["activeAlerts"]: - current[alert["type"]] = True - - return current diff --git a/homeassistant/components/ukraine_alarm/binary_sensor.py b/homeassistant/components/ukraine_alarm/binary_sensor.py index 0eb8bd7b43c..30cb8e0f553 100644 --- a/homeassistant/components/ukraine_alarm/binary_sensor.py +++ b/homeassistant/components/ukraine_alarm/binary_sensor.py @@ -14,7 +14,6 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import UkraineAlarmDataUpdateCoordinator from .const import ( ALERT_TYPE_AIR, ALERT_TYPE_ARTILLERY, @@ -26,6 +25,7 @@ from .const import ( DOMAIN, MANUFACTURER, ) +from .coordinator import UkraineAlarmDataUpdateCoordinator BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( diff --git a/homeassistant/components/ukraine_alarm/coordinator.py b/homeassistant/components/ukraine_alarm/coordinator.py new file mode 100644 index 00000000000..fbf7c9f81c2 --- /dev/null +++ b/homeassistant/components/ukraine_alarm/coordinator.py @@ -0,0 +1,49 @@ +"""The ukraine_alarm component.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +import aiohttp +from aiohttp import ClientSession +from uasiren.client import Client + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ALERT_TYPES, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=10) + + +class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching Ukraine Alarm API.""" + + def __init__( + self, + hass: HomeAssistant, + session: ClientSession, + region_id: str, + ) -> None: + """Initialize.""" + self.region_id = region_id + self.uasiren = Client(session) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + res = await self.uasiren.get_alerts(self.region_id) + except aiohttp.ClientError as error: + raise UpdateFailed(f"Error fetching alerts from API: {error}") from error + + current = {alert_type: False for alert_type in ALERT_TYPES} + for alert in res[0]["activeAlerts"]: + current[alert["type"]] = True + + return current From f5852b4678492d4cae11f2a754f9140435696b33 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:58:20 +0200 Subject: [PATCH 1023/1309] Move point base entity to separate module (#126551) --- homeassistant/components/point/__init__.py | 91 +----------------- .../components/point/binary_sensor.py | 2 +- homeassistant/components/point/entity.py | 95 +++++++++++++++++++ homeassistant/components/point/sensor.py | 2 +- 4 files changed, 98 insertions(+), 92 deletions(-) create mode 100644 homeassistant/components/point/entity.py diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index ca764a3844e..dff3acd9e6b 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -27,18 +27,11 @@ from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, config_validation as cv, - device_registry as dr, ) -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp from . import api from .const import ( @@ -284,88 +277,6 @@ class MinutPointClient: return await self._client.alarm_arm(home_id) -class MinutPointEntity(Entity): # pylint: disable=hass-enforce-class-module # see PR 118243 - """Base Entity used by the sensors.""" - - _attr_should_poll = False - - def __init__(self, point_client, device_id, device_class) -> None: - """Initialize the entity.""" - self._async_unsub_dispatcher_connect = None - self._client = point_client - self._id = device_id - self._name = self.device.name - self._attr_device_class = device_class - self._updated = utc_from_timestamp(0) - self._attr_unique_id = f"point.{device_id}-{device_class}" - device = self.device.device - self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, - identifiers={(DOMAIN, device["device_id"])}, - manufacturer="Minut", - model=f"Point v{device['hardware_version']}", - name=device["description"], - sw_version=device["firmware"]["installed"], - via_device=(DOMAIN, device["home"]), - ) - if device_class: - self._attr_name = f"{self._name} {device_class.capitalize()}" - - def __str__(self) -> str: - """Return string representation of device.""" - return f"MinutPoint {self.name}" - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - _LOGGER.debug("Created device %s", self) - self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback - ) - await self._update_callback() - - async def async_will_remove_from_hass(self): - """Disconnect dispatcher listener when removed.""" - if self._async_unsub_dispatcher_connect: - self._async_unsub_dispatcher_connect() - - async def _update_callback(self): - """Update the value of the sensor.""" - - @property - def available(self): - """Return true if device is not offline.""" - return self._client.is_available(self.device_id) - - @property - def device(self): - """Return the representation of the device.""" - return self._client.device(self.device_id) - - @property - def device_id(self): - """Return the id of the device.""" - return self._id - - @property - def extra_state_attributes(self): - """Return status of device.""" - attrs = self.device.device_status - attrs["last_heard_from"] = as_local(self.last_update).strftime( - "%Y-%m-%d %H:%M:%S" - ) - return attrs - - @property - def is_updated(self): - """Return true if sensor have been updated.""" - return self.last_update > self._updated - - @property - def last_update(self): - """Return the last_update time for the device.""" - return parse_datetime(self.device.last_update) - - @dataclass class PointData: """Point Data.""" diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index 1443f6132ad..546c7d9cb0f 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -16,8 +16,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MinutPointEntity from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK +from .entity import MinutPointEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py new file mode 100644 index 00000000000..4784dd43180 --- /dev/null +++ b/homeassistant/components/point/entity.py @@ -0,0 +1,95 @@ +"""Support for Minut Point.""" + +import logging + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity +from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp + +from .const import DOMAIN, SIGNAL_UPDATE_ENTITY + +_LOGGER = logging.getLogger(__name__) + + +class MinutPointEntity(Entity): + """Base Entity used by the sensors.""" + + _attr_should_poll = False + + def __init__(self, point_client, device_id, device_class) -> None: + """Initialize the entity.""" + self._async_unsub_dispatcher_connect = None + self._client = point_client + self._id = device_id + self._name = self.device.name + self._attr_device_class = device_class + self._updated = utc_from_timestamp(0) + self._attr_unique_id = f"point.{device_id}-{device_class}" + device = self.device.device + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, + identifiers={(DOMAIN, device["device_id"])}, + manufacturer="Minut", + model=f"Point v{device['hardware_version']}", + name=device["description"], + sw_version=device["firmware"]["installed"], + via_device=(DOMAIN, device["home"]), + ) + if device_class: + self._attr_name = f"{self._name} {device_class.capitalize()}" + + def __str__(self) -> str: + """Return string representation of device.""" + return f"MinutPoint {self.name}" + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + _LOGGER.debug("Created device %s", self) + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback + ) + await self._update_callback() + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + + async def _update_callback(self): + """Update the value of the sensor.""" + + @property + def available(self): + """Return true if device is not offline.""" + return self._client.is_available(self.device_id) + + @property + def device(self): + """Return the representation of the device.""" + return self._client.device(self.device_id) + + @property + def device_id(self): + """Return the id of the device.""" + return self._id + + @property + def extra_state_attributes(self): + """Return status of device.""" + attrs = self.device.device_status + attrs["last_heard_from"] = as_local(self.last_update).strftime( + "%Y-%m-%d %H:%M:%S" + ) + return attrs + + @property + def is_updated(self): + """Return true if sensor have been updated.""" + return self.last_update > self._updated + + @property + def last_update(self): + """Return the last_update time for the device.""" + return parse_datetime(self.device.last_update) diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index f97000bae82..d864c8bb18c 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -17,8 +17,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import parse_datetime -from . import MinutPointEntity from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW +from .entity import MinutPointEntity _LOGGER = logging.getLogger(__name__) From b2982c18bbe28ed2f0d2ac6e4b9a35ce5b692966 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Sep 2024 16:49:21 +0200 Subject: [PATCH 1024/1309] Reinitialize zeroconf discovery flow on unignore (#125753) * Reinitialize zeroconf discovery flow on unignore * Adjust tests * Improve comments * Fix logic for updating discovery keys * Add tests * Use mock_config_flow helper in new config_entries test * Add discovery_keys attribute to ConfigEntry * Update zeroconf rediscovery * Change type of ConfigEntry.discovery_keys * Update tests * Fix DiscoveryKey.from_json_dict and add tests * Fix test --------- Co-authored-by: J. Nick Koston --- .../components/config/config_entries.py | 5 +- homeassistant/components/zeroconf/__init__.py | 46 +++ homeassistant/config_entries.py | 59 ++- homeassistant/helpers/discovery_flow.py | 33 +- tests/common.py | 2 + .../aemet/snapshots/test_diagnostics.ambr | 2 + .../airly/snapshots/test_diagnostics.ambr | 2 + .../airnow/snapshots/test_diagnostics.ambr | 2 + .../airvisual/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../airzone/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../components/androidtv/test_diagnostics.py | 2 +- tests/components/asuswrt/test_diagnostics.py | 2 +- .../axis/snapshots/test_diagnostics.ambr | 2 + .../blink/snapshots/test_diagnostics.ambr | 2 + .../braviatv/snapshots/test_diagnostics.ambr | 2 + .../co2signal/snapshots/test_diagnostics.ambr | 2 + .../coinbase/snapshots/test_diagnostics.ambr | 2 + .../components/config/test_config_entries.py | 26 +- .../deconz/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../ecovacs/snapshots/test_diagnostics.ambr | 4 + .../elgato/snapshots/test_config_flow.ambr | 6 + .../snapshots/test_config_flow.ambr | 2 + .../snapshots/test_diagnostics.ambr | 6 + .../esphome/snapshots/test_diagnostics.ambr | 2 + tests/components/esphome/test_diagnostics.py | 1 + .../forecast_solar/snapshots/test_init.ambr | 2 + .../fritz/snapshots/test_diagnostics.ambr | 2 + tests/components/fritzbox/test_diagnostics.py | 2 +- .../fronius/snapshots/test_diagnostics.ambr | 2 + .../fyta/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_config_flow.ambr | 4 + .../gios/snapshots/test_diagnostics.ambr | 2 + .../goodwe/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + tests/components/guardian/test_diagnostics.py | 1 + .../snapshots/test_config_flow.ambr | 8 + .../snapshots/test_diagnostics.ambr | 2 + .../imgw_pib/snapshots/test_diagnostics.ambr | 2 + .../iqvia/snapshots/test_diagnostics.ambr | 2 + .../kostal_plenticore/test_diagnostics.py | 1 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../madvr/snapshots/test_diagnostics.ambr | 2 + .../melcloud/snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../netatmo/snapshots/test_diagnostics.ambr | 2 + .../nextdns/snapshots/test_diagnostics.ambr | 2 + .../nice_go/snapshots/test_diagnostics.ambr | 2 + tests/components/notion/test_diagnostics.py | 1 + tests/components/nut/test_diagnostics.py | 2 +- .../onvif/snapshots/test_diagnostics.ambr | 2 + tests/components/openuv/test_diagnostics.py | 1 + .../snapshots/test_diagnostics.ambr | 2 + .../pi_hole/snapshots/test_diagnostics.ambr | 2 + .../proximity/snapshots/test_diagnostics.ambr | 2 + .../components/purpleair/test_diagnostics.py | 1 + .../rainforest_eagle/test_diagnostics.py | 2 +- .../rainforest_raven/test_diagnostics.py | 4 +- .../snapshots/test_diagnostics.ambr | 4 + .../recollect_waste/test_diagnostics.py | 1 + .../ridwell/snapshots/test_diagnostics.ambr | 2 + .../components/samsungtv/test_diagnostics.py | 3 + .../snapshots/test_diagnostics.ambr | 2 + tests/components/shelly/test_diagnostics.py | 4 +- .../components/simplisafe/test_diagnostics.py | 1 + .../solarlog/snapshots/test_diagnostics.ambr | 2 + .../switcher_kis/test_diagnostics.py | 1 + .../snapshots/test_diagnostics.ambr | 2 + .../tailwind/snapshots/test_config_flow.ambr | 4 + .../snapshots/test_diagnostics.ambr | 2 + .../tractive/snapshots/test_diagnostics.ambr | 2 + .../tuya/snapshots/test_config_flow.ambr | 6 + .../snapshots/test_config_flow.ambr | 4 + .../twinkly/snapshots/test_diagnostics.ambr | 2 + .../unifi/snapshots/test_diagnostics.ambr | 2 + .../uptime/snapshots/test_config_flow.ambr | 2 + .../snapshots/test_diagnostics.ambr | 2 + .../v2c/snapshots/test_diagnostics.ambr | 2 + .../vicare/snapshots/test_diagnostics.ambr | 2 + .../watttime/snapshots/test_diagnostics.ambr | 2 + .../webmin/snapshots/test_diagnostics.ambr | 2 + tests/components/webostv/test_diagnostics.py | 1 + .../whirlpool/snapshots/test_diagnostics.ambr | 2 + .../whois/snapshots/test_config_flow.ambr | 10 + .../wyoming/snapshots/test_config_flow.ambr | 6 + tests/components/zeroconf/test_init.py | 368 +++++++++++++++++- .../zha/snapshots/test_diagnostics.ambr | 2 + tests/helpers/test_discovery_flow.py | 42 +- tests/snapshots/test_config_entries.ambr | 2 + tests/test_config_entries.py | 221 ++++++++++- 97 files changed, 987 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index b16701f8bd0..9149ffe98e1 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -463,9 +463,12 @@ async def ignore_config_flow( ) return + context = {"source": config_entries.SOURCE_IGNORE} + if "discovery_key" in flow["context"]: + context["discovery_key"] = flow["context"]["discovery_key"] await hass.config_entries.flow.async_init( flow["handler"], - context={"source": config_entries.SOURCE_IGNORE}, + context=context, data={"unique_id": flow["context"]["unique_id"], "title": msg["title"]}, ) connection.send_result(msg["id"]) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index bdffdcf63a7..33057c501fd 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -33,6 +33,8 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import discovery_flow, instance_id import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery_flow import DiscoveryKey +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import ( @@ -379,11 +381,38 @@ class ZeroconfDiscovery: self.zeroconf, types, handlers=[self.async_service_update] ) + async_dispatcher_connect( + self.hass, + config_entries.SIGNAL_CONFIG_ENTRY_CHANGED, + self._handle_config_entry_changed, + ) + async def async_stop(self) -> None: """Cancel the service browser and stop processing the queue.""" if self.async_service_browser: await self.async_service_browser.async_cancel() + @callback + def _handle_config_entry_changed( + self, + change: config_entries.ConfigEntryChange, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + if ( + change != config_entries.ConfigEntryChange.REMOVED + or entry.source != config_entries.SOURCE_IGNORE + or not (discovery_keys := entry.discovery_keys) + ): + return + for discovery_key in discovery_keys: + if discovery_key.domain != DOMAIN or discovery_key.version != 1: + continue + _type = discovery_key.key[0] + name = discovery_key.key[1] + _LOGGER.debug("Rediscover unignored service %s.%s", _type, name) + self._async_service_update(self.zeroconf, _type, name) + def _async_dismiss_discoveries(self, name: str) -> None: """Dismiss all discoveries for the given name.""" for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( @@ -412,6 +441,16 @@ class ZeroconfDiscovery: self._async_dismiss_discoveries(name) return + self._async_service_update(zeroconf, service_type, name) + + @callback + def _async_service_update( + self, + zeroconf: HaZeroconf, + service_type: str, + name: str, + ) -> None: + """Service state added or changed.""" try: async_service_info = AsyncServiceInfo(service_type, name) except BadTypeInNameException as ex: @@ -453,6 +492,11 @@ class ZeroconfDiscovery: return _LOGGER.debug("Discovered new device %s %s", name, info) props: dict[str, str | None] = info.properties + discovery_key = DiscoveryKey( + domain=DOMAIN, + key=(info.type, info.name), + version=1, + ) domain = None # If we can handle it as a HomeKit discovery, we do that here. @@ -467,6 +511,7 @@ class ZeroconfDiscovery: homekit_discovery.domain, {"source": config_entries.SOURCE_HOMEKIT}, info, + discovery_key=discovery_key, ) # Continue on here as homekit_controller # still needs to get updates on devices @@ -515,6 +560,7 @@ class ZeroconfDiscovery: matcher_domain, context, info, + discovery_key=discovery_key, ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 395dcaf79a3..489afb723b7 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -49,6 +49,7 @@ from .exceptions import ( ) from .helpers import device_registry, entity_registry, issue_registry as ir, storage from .helpers.debounce import Debouncer +from .helpers.discovery_flow import DiscoveryKey from .helpers.dispatcher import SignalType, async_dispatcher_send_internal from .helpers.event import ( RANDOM_MICROSECOND_MAX, @@ -120,7 +121,7 @@ HANDLERS: Registry[str, type[ConfigFlow]] = Registry() STORAGE_KEY = "core.config_entries" STORAGE_VERSION = 1 -STORAGE_VERSION_MINOR = 3 +STORAGE_VERSION_MINOR = 4 SAVE_DELAY = 1 @@ -317,6 +318,7 @@ class ConfigEntry(Generic[_DataT]): _tries: int created_at: datetime modified_at: datetime + discovery_keys: tuple[DiscoveryKey, ...] def __init__( self, @@ -324,6 +326,7 @@ class ConfigEntry(Generic[_DataT]): created_at: datetime | None = None, data: Mapping[str, Any], disabled_by: ConfigEntryDisabler | None = None, + discovery_keys: tuple[DiscoveryKey, ...], domain: str, entry_id: str | None = None, minor_version: int, @@ -422,6 +425,7 @@ class ConfigEntry(Generic[_DataT]): _setter(self, "_tries", 0) _setter(self, "created_at", created_at or utcnow()) _setter(self, "modified_at", modified_at or utcnow()) + _setter(self, "discovery_keys", discovery_keys) def __repr__(self) -> str: """Representation of ConfigEntry.""" @@ -951,6 +955,7 @@ class ConfigEntry(Generic[_DataT]): return { "created_at": self.created_at.isoformat(), "data": dict(self.data), + "discovery_keys": self.discovery_keys, "disabled_by": self.disabled_by, "domain": self.domain, "entry_id": self.entry_id, @@ -1364,6 +1369,30 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: + # If there's an ignored config entry with a matching unique ID, + # update the discovery key. + if ( + (discovery_key := flow.context.get("discovery_key")) + and (unique_id := flow.unique_id) is not None + and ( + entry := self.config_entries.async_entry_for_domain_unique_id( + result["handler"], unique_id + ) + ) + and entry.source == SOURCE_IGNORE + and discovery_key not in (known_discovery_keys := entry.discovery_keys) + ): + new_discovery_keys = tuple([*known_discovery_keys, discovery_key][-10:]) + _LOGGER.debug( + "Updating discovery keys for %s entry %s %s -> %s", + entry.domain, + unique_id, + known_discovery_keys, + new_discovery_keys, + ) + self.config_entries.async_update_entry( + entry, discovery_keys=new_discovery_keys + ) return result # Avoid adding a config entry for a integration @@ -1420,8 +1449,11 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): if existing_entry is not None and existing_entry.state.recoverable: await self.config_entries.async_unload(existing_entry.entry_id) + discovery_key = flow.context.get("discovery_key") + discovery_keys = (discovery_key,) if discovery_key else () entry = ConfigEntry( data=result["data"], + discovery_keys=discovery_keys, domain=result["handler"], minor_version=result["minor_version"], options=result["options"], @@ -1649,6 +1681,11 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]): for entry in data["entries"]: entry["created_at"] = entry["modified_at"] = created_at + if old_minor_version < 4: + # Version 1.4 adds discovery_keys + for entry in data["entries"]: + entry["discovery_keys"] = [] + if old_major_version > 1: raise NotImplementedError return data @@ -1836,6 +1873,9 @@ class ConfigEntries: created_at=datetime.fromisoformat(entry["created_at"]), data=entry["data"], disabled_by=try_parse_enum(ConfigEntryDisabler, entry["disabled_by"]), + discovery_keys=tuple( + DiscoveryKey.from_json_dict(key) for key in entry["discovery_keys"] + ), domain=entry["domain"], entry_id=entry_id, minor_version=entry["minor_version"], @@ -1992,6 +2032,7 @@ class ConfigEntries: entry: ConfigEntry, *, data: Mapping[str, Any] | UndefinedType = UNDEFINED, + discovery_keys: tuple[DiscoveryKey, ...] | UndefinedType = UNDEFINED, minor_version: int | UndefinedType = UNDEFINED, options: Mapping[str, Any] | UndefinedType = UNDEFINED, pref_disable_new_entities: bool | UndefinedType = UNDEFINED, @@ -2021,6 +2062,7 @@ class ConfigEntries: changed = True for attr, value in ( + ("discovery_keys", discovery_keys), ("minor_version", minor_version), ("pref_disable_new_entities", pref_disable_new_entities), ("pref_disable_polling", pref_disable_polling), @@ -2451,7 +2493,20 @@ class ConfigFlow(ConfigEntryBaseFlow): ] async def async_step_ignore(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """Ignore this config flow.""" + """Ignore this config flow. + + Ignoring a config flow works by creating a config entry with source set to + SOURCE_IGNORE. + + There will only be a single active discovery flow per device, also when the + integration has multiple discovery sources for the same device. This method + is called when the user ignores a discovered device or service, we then store + the key for the flow being ignored. + + Once the ignore config entry is created, ConfigEntriesFlowManager.async_finish_flow + will make sure the discovery key is kept up to date since it may not be stable + unlike the unique id. + """ await self.async_set_unique_id(user_input["unique_id"], raise_on_progress=False) return self.async_create_entry(title=user_input["title"], data={}) diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index 9ec0b01dc56..8112be3dde4 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -3,25 +3,49 @@ from __future__ import annotations from collections.abc import Coroutine -from typing import Any, NamedTuple +import dataclasses +from typing import TYPE_CHECKING, Any, NamedTuple, Self -from homeassistant.config_entries import ConfigFlowResult from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.loader import bind_hass from homeassistant.util.async_ import gather_with_limited_concurrency from homeassistant.util.hass_dict import HassKey +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigFlowResult + FLOW_INIT_LIMIT = 20 DISCOVERY_FLOW_DISPATCHER: HassKey[FlowDispatcher] = HassKey( "discovery_flow_dispatcher" ) +@dataclasses.dataclass(kw_only=True, slots=True) +class DiscoveryKey: + """Serializable discovery key.""" + + domain: str + key: str | tuple[str, ...] + version: int + + @classmethod + def from_json_dict(cls, json_dict: dict[str, Any]) -> Self: + """Construct from JSON dict.""" + if type(key := json_dict["key"]) is list: + key = tuple(key) + return cls(domain=json_dict["domain"], key=key, version=json_dict["version"]) + + @bind_hass @callback def async_create_flow( - hass: HomeAssistant, domain: str, context: dict[str, Any], data: Any + hass: HomeAssistant, + domain: str, + context: dict[str, Any], + data: Any, + *, + discovery_key: DiscoveryKey | None = None, ) -> None: """Create a discovery flow.""" dispatcher: FlowDispatcher | None = None @@ -31,6 +55,9 @@ def async_create_flow( dispatcher = hass.data[DISCOVERY_FLOW_DISPATCHER] = FlowDispatcher(hass) dispatcher.async_setup() + if discovery_key: + context = context | {"discovery_key": discovery_key} + if not dispatcher or dispatcher.started: if init_coro := _async_init_flow(hass, domain, context, data): hass.async_create_background_task( diff --git a/tests/common.py b/tests/common.py index 21b5ee1e720..b0d471efe95 100644 --- a/tests/common.py +++ b/tests/common.py @@ -990,6 +990,7 @@ class MockConfigEntry(config_entries.ConfigEntry): *, data=None, disabled_by=None, + discovery_keys=(), domain="test", entry_id=None, minor_version=1, @@ -1007,6 +1008,7 @@ class MockConfigEntry(config_entries.ConfigEntry): kwargs = { "data": data or {}, "disabled_by": disabled_by, + "discovery_keys": discovery_keys, "domain": domain, "entry_id": entry_id or ulid_util.ulid_now(), "minor_version": minor_version, diff --git a/tests/components/aemet/snapshots/test_diagnostics.ambr b/tests/components/aemet/snapshots/test_diagnostics.ambr index 8d4132cad84..5200be7a54a 100644 --- a/tests/components/aemet/snapshots/test_diagnostics.ambr +++ b/tests/components/aemet/snapshots/test_diagnostics.ambr @@ -11,6 +11,8 @@ 'name': 'AEMET', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'aemet', 'entry_id': '7442b231f139e813fc1939281123f220', 'minor_version': 1, diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr index c22e96a2082..33f038cf6d4 100644 --- a/tests/components/airly/snapshots/test_diagnostics.ambr +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -9,6 +9,8 @@ 'name': 'Home', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'airly', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 71fda040c1d..4d9d94288de 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -24,6 +24,8 @@ 'longitude': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'airnow', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr index cb9d25b8790..bbc75b6b1c0 100644 --- a/tests/components/airvisual/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -36,6 +36,8 @@ 'longitude': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'airvisual', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr index be709621e31..a54b61812eb 100644 --- a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -91,6 +91,8 @@ 'password': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'airvisual_pro', 'entry_id': '6a2b3770e53c28dc1eeb2515e906b0ce', 'minor_version': 1, diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index 2adf50558e0..6fc57f0483e 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -238,6 +238,8 @@ 'port': 3000, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'airzone', 'entry_id': '6e7a0798c1734ba81d26ced0e690eaec', 'minor_version': 1, diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 2e6463d35a1..9f9285526e8 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -91,6 +91,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'airzone_cloud', 'entry_id': 'd186e31edb46d64d14b9b2f11f1ebd9f', 'minor_version': 1, diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr index b4aede7948c..ab6c485aabf 100644 --- a/tests/components/ambient_station/snapshots/test_diagnostics.ambr +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'app_key': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'ambient_station', 'entry_id': '382cf7643f016fd48b3fe52163fe8877', 'minor_version': 1, diff --git a/tests/components/androidtv/test_diagnostics.py b/tests/components/androidtv/test_diagnostics.py index 4ba53886739..2584f4b528c 100644 --- a/tests/components/androidtv/test_diagnostics.py +++ b/tests/components/androidtv/test_diagnostics.py @@ -36,4 +36,4 @@ async def test_diagnostics( hass, hass_client, mock_config_entry ) - assert result["entry"] == entry_dict + assert result["entry"] == entry_dict | {"discovery_keys": []} diff --git a/tests/components/asuswrt/test_diagnostics.py b/tests/components/asuswrt/test_diagnostics.py index 207f3ba25f0..09df309953d 100644 --- a/tests/components/asuswrt/test_diagnostics.py +++ b/tests/components/asuswrt/test_diagnostics.py @@ -38,4 +38,4 @@ async def test_diagnostics( hass, hass_client, mock_config_entry ) - assert result["entry"] == entry_dict + assert result["entry"] == entry_dict | {"discovery_keys": []} diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr index 3a643f55d3e..513357a76a3 100644 --- a/tests/components/axis/snapshots/test_diagnostics.ambr +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -37,6 +37,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'axis', 'entry_id': '676abe5b73621446e6550a2e86ffe3dd', 'minor_version': 1, diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr index 44554dad1e3..8d3c63b3d0a 100644 --- a/tests/components/blink/snapshots/test_diagnostics.ambr +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -38,6 +38,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'blink', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr index 2fd515b24e5..3ffaba03426 100644 --- a/tests/components/braviatv/snapshots/test_diagnostics.ambr +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -9,6 +9,8 @@ 'use_psk': True, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'braviatv', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr index 645e0bd87e9..db61938ad90 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'location': '', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'co2signal', 'entry_id': '904a74160aa6f335526706bee85dfb83', 'minor_version': 1, diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index 4f9e75dc38b..665bb4b47fb 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -30,6 +30,8 @@ 'api_token': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'coinbase', 'entry_id': '080272b77a4f80c41b94d7cdc86fd826', 'minor_version': 1, diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 4c61ab506e3..879e2dac9ff 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -17,6 +17,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_flow, config_validation as cv +from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -1317,8 +1318,27 @@ async def test_disable_entry_nonexisting( assert response["error"]["code"] == "not_found" +@pytest.mark.parametrize( + ( + "flow_context", + "entry_discovery_keys", + ), + [ + ( + {}, + (), + ), + ( + {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, + (DiscoveryKey(domain="test", key="blah", version=1),), + ), + ], +) async def test_ignore_flow( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + flow_context: dict, + entry_discovery_keys: tuple, ) -> None: """Test we can ignore a flow.""" assert await async_setup_component(hass, "config", {}) @@ -1341,7 +1361,7 @@ async def test_ignore_flow( with patch.dict(HANDLERS, {"test": TestFlow}): result = await hass.config_entries.flow.async_init( - "test", context={"source": core_ce.SOURCE_USER} + "test", context={"source": core_ce.SOURCE_USER} | flow_context ) assert result["type"] is FlowResultType.FORM @@ -1363,6 +1383,8 @@ async def test_ignore_flow( assert entry.source == "ignore" assert entry.unique_id == "mock-unique-id" assert entry.title == "Test Integration" + assert entry.data == {} + assert entry.discovery_keys == entry_discovery_keys async def test_ignore_flow_nonexisting( diff --git a/tests/components/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr index 911f2e134f2..fd543e6108c 100644 --- a/tests/components/deconz/snapshots/test_diagnostics.ambr +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -10,6 +10,8 @@ 'port': 80, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'deconz', 'entry_id': '1', 'minor_version': 1, diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr index 8c069de8f62..fbc39882442 100644 --- a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -38,6 +38,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'devolo_home_control', 'entry_id': '123456', 'minor_version': 1, diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr index 317aaac0116..86b6e441911 100644 --- a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -22,6 +22,8 @@ 'password': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'devolo_home_network', 'entry_id': '123456', 'minor_version': 1, diff --git a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr index c6bc616ffd3..2091ebbf1f3 100644 --- a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr +++ b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'data': dict({ }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'dsmr_reader', 'entry_id': 'TEST_ENTRY_ID', 'minor_version': 1, diff --git a/tests/components/ecovacs/snapshots/test_diagnostics.ambr b/tests/components/ecovacs/snapshots/test_diagnostics.ambr index a4291f9fe25..70f5d669b44 100644 --- a/tests/components/ecovacs/snapshots/test_diagnostics.ambr +++ b/tests/components/ecovacs/snapshots/test_diagnostics.ambr @@ -8,6 +8,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'ecovacs', 'minor_version': 1, 'options': dict({ @@ -59,6 +61,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'ecovacs', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/elgato/snapshots/test_config_flow.ambr b/tests/components/elgato/snapshots/test_config_flow.ambr index 39202d383fa..e25e243db07 100644 --- a/tests/components/elgato/snapshots/test_config_flow.ambr +++ b/tests/components/elgato/snapshots/test_config_flow.ambr @@ -24,6 +24,8 @@ 'port': 9123, }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'elgato', 'entry_id': , 'minor_version': 1, @@ -67,6 +69,8 @@ 'port': 9123, }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'elgato', 'entry_id': , 'minor_version': 1, @@ -109,6 +113,8 @@ 'port': 9123, }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'elgato', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr index 9b4b3bfc635..c96d21df54a 100644 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ b/tests/components/energyzero/snapshots/test_config_flow.ambr @@ -18,6 +18,8 @@ 'data': dict({ }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'energyzero', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index e849ab6ee43..7c1c6a5dfcc 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -10,6 +10,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'enphase_envoy', 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', 'minor_version': 1, @@ -441,6 +443,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'enphase_envoy', 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', 'minor_version': 1, @@ -913,6 +917,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'enphase_envoy', 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', 'minor_version': 1, diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 0d2f0e60b82..3599f207806 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -10,6 +10,8 @@ 'port': 6053, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'esphome', 'entry_id': '08d821dc059cf4f645cb024d32c8e708', 'minor_version': 1, diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index b66b6d72fce..031bb5e0080 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -70,6 +70,7 @@ async def test_diagnostics_with_bluetooth( "port": 6053, }, "disabled_by": None, + "discovery_keys": [], "domain": "esphome", "entry_id": ANY, "minor_version": 1, diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr index 43145bcef9e..e3eff26f2cd 100644 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -6,6 +6,8 @@ 'longitude': 4.42, }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'forecast_solar', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 4b5b8bdea3b..744f8c0fd22 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -52,6 +52,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'fritz', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/fritzbox/test_diagnostics.py b/tests/components/fritzbox/test_diagnostics.py index 38aaa623080..62cbecb0472 100644 --- a/tests/components/fritzbox/test_diagnostics.py +++ b/tests/components/fritzbox/test_diagnostics.py @@ -30,4 +30,4 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entries[0]) - assert result == {"entry": entry_dict, "data": {}} + assert result == {"entry": entry_dict | {"discovery_keys": []}, "data": {}} diff --git a/tests/components/fronius/snapshots/test_diagnostics.ambr b/tests/components/fronius/snapshots/test_diagnostics.ambr index f23d63a58e3..b596dbe5e1d 100644 --- a/tests/components/fronius/snapshots/test_diagnostics.ambr +++ b/tests/components/fronius/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'is_logger': True, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'fronius', 'entry_id': 'f1e2b9837e8adaed6fa682acaa216fd8', 'minor_version': 1, diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index cf6bcdb77ad..16e4724344e 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -9,6 +9,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'fyta', 'entry_id': 'ce5f5431554d101905d31797e1232da8', 'minor_version': 2, diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 98cba151c52..32c0d0821e7 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -39,6 +39,8 @@ 'address': '00000000-0000-0000-0000-000000000001', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'gardena_bluetooth', 'entry_id': , 'minor_version': 1, @@ -248,6 +250,8 @@ 'address': '00000000-0000-0000-0000-000000000001', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'gardena_bluetooth', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 1401b1e22a0..f70c1a56b0d 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'station_id': 123, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'gios', 'entry_id': '86129426118ae32020417a53712d6eef', 'minor_version': 1, diff --git a/tests/components/goodwe/snapshots/test_diagnostics.ambr b/tests/components/goodwe/snapshots/test_diagnostics.ambr index 4097848a34a..336e31d5bfc 100644 --- a/tests/components/goodwe/snapshots/test_diagnostics.ambr +++ b/tests/components/goodwe/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'model_family': 'ET', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'goodwe', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index 9a4ad8b3da3..a274a596e82 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -6,6 +6,8 @@ 'project_id': '1234', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'google_assistant', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index 3b3ed21bc65..f6ee1ebcfd5 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -41,6 +41,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, + "discovery_keys": [], }, "data": { "valve_controller": { diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr index 663d9153991..1d9e78eea2f 100644 --- a/tests/components/homewizard/snapshots/test_config_flow.ambr +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -20,6 +20,8 @@ 'ip_address': '127.0.0.1', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'homewizard', 'entry_id': , 'minor_version': 1, @@ -62,6 +64,8 @@ 'ip_address': '127.0.0.1', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'homewizard', 'entry_id': , 'minor_version': 1, @@ -104,6 +108,8 @@ 'ip_address': '127.0.0.1', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'homewizard', 'entry_id': , 'minor_version': 1, @@ -142,6 +148,8 @@ 'ip_address': '2.2.2.2', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'homewizard', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 5ffb826bb4a..41e1ae81741 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -175,6 +175,8 @@ }), }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'husqvarna_automower', 'entry_id': 'automower_test', 'minor_version': 1, diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 096e370ab02..1ca0c4874ea 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -6,6 +6,8 @@ 'station_id': '123', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'imgw_pib', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr index c46a2cc15e3..8627f31841f 100644 --- a/tests/components/iqvia/snapshots/test_diagnostics.ambr +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -348,6 +348,8 @@ 'zip_code': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'iqvia', 'entry_id': '690ac4b7e99855fc5ee7b987a758d5cb', 'minor_version': 1, diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index 0f358260be7..de5966c9cc7 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -56,6 +56,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, + "discovery_keys": [], }, "client": { "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index 9d880746ff9..f2ff166a62e 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -15,6 +15,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'lacrosse_view', 'entry_id': 'lacrosse_view_test_entry_id', 'minor_version': 1, diff --git a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr index 2543ca42156..cbbadcb63f9 100644 --- a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr +++ b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr @@ -63,6 +63,8 @@ 'site_id': 'test-site-id', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'linear_garage_door', 'entry_id': 'acefdd4b3a4a0911067d1cf51414201e', 'minor_version': 1, diff --git a/tests/components/madvr/snapshots/test_diagnostics.ambr b/tests/components/madvr/snapshots/test_diagnostics.ambr index f8008a651f2..fcfcca8c960 100644 --- a/tests/components/madvr/snapshots/test_diagnostics.ambr +++ b/tests/components/madvr/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'port': 44077, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'madvr', 'entry_id': '3bd2acb0e4f0476d40865546d0d91132', 'minor_version': 1, diff --git a/tests/components/melcloud/snapshots/test_diagnostics.ambr b/tests/components/melcloud/snapshots/test_diagnostics.ambr index 7b0173c240e..b14ecce2bb0 100644 --- a/tests/components/melcloud/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'data': dict({ }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'melcloud', 'entry_id': 'TEST_ENTRY_ID', 'minor_version': 1, diff --git a/tests/components/modern_forms/snapshots/test_diagnostics.ambr b/tests/components/modern_forms/snapshots/test_diagnostics.ambr index 56e299aa12a..336913dfdd4 100644 --- a/tests/components/modern_forms/snapshots/test_diagnostics.ambr +++ b/tests/components/modern_forms/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'mac': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'modern_forms', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr index ccb5b1ed87b..27bfa9cd041 100644 --- a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr +++ b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'mac_code': 'CCCC', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'motionblinds_ble', 'entry_id': 'mock_entry_id', 'minor_version': 1, diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 35cd0bfbf47..8b775d2f1f5 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -608,6 +608,8 @@ 'webhook_id': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'netatmo', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 5040c6e052e..d024f54132e 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'profile_id': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'nextdns', 'entry_id': 'd9aa37407ddac7b964a99e86312288d6', 'minor_version': 1, diff --git a/tests/components/nice_go/snapshots/test_diagnostics.ambr b/tests/components/nice_go/snapshots/test_diagnostics.ambr index 380a867ac60..60c43553e71 100644 --- a/tests/components/nice_go/snapshots/test_diagnostics.ambr +++ b/tests/components/nice_go/snapshots/test_diagnostics.ambr @@ -37,6 +37,8 @@ 'refresh_token': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'nice_go', 'entry_id': 'acefdd4b3a4a0911067d1cf51414201e', 'minor_version': 1, diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 4d87b6292e4..2156adfb57c 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -36,6 +36,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, + "discovery_keys": [], }, "data": { "bridges": [ diff --git a/tests/components/nut/test_diagnostics.py b/tests/components/nut/test_diagnostics.py index f91269f5196..948c3e9da27 100644 --- a/tests/components/nut/test_diagnostics.py +++ b/tests/components/nut/test_diagnostics.py @@ -39,5 +39,5 @@ async def test_diagnostics( result = await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry ) - assert result["entry"] == entry_dict + assert result["entry"] == entry_dict | {"discovery_keys": []} assert result["nut_data"] == nut_data_dict diff --git a/tests/components/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr index 68c92ec755d..78191fa4600 100644 --- a/tests/components/onvif/snapshots/test_diagnostics.ambr +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -11,6 +11,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'onvif', 'entry_id': '1', 'minor_version': 1, diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 4fe851eea53..cf7e7b05ec4 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -38,6 +38,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, + "discovery_keys": [], }, "data": { "protection_window": { diff --git a/tests/components/philips_js/snapshots/test_diagnostics.ambr b/tests/components/philips_js/snapshots/test_diagnostics.ambr index 5cff47c7d62..20d9b0e0023 100644 --- a/tests/components/philips_js/snapshots/test_diagnostics.ambr +++ b/tests/components/philips_js/snapshots/test_diagnostics.ambr @@ -85,6 +85,8 @@ }), }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'philips_js', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index 865494b5e9f..b663f8ed57e 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -23,6 +23,8 @@ 'verify_ssl': True, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'pi_hole', 'entry_id': 'pi_hole_mock_entry', 'minor_version': 1, diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index 68270dc3297..34bb64b3420 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -93,6 +93,8 @@ 'zone': 'zone.home', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'proximity', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/purpleair/test_diagnostics.py b/tests/components/purpleair/test_diagnostics.py index 599549bb723..191115c4774 100644 --- a/tests/components/purpleair/test_diagnostics.py +++ b/tests/components/purpleair/test_diagnostics.py @@ -37,6 +37,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, + "discovery_keys": [], }, "data": { "fields": [ diff --git a/tests/components/rainforest_eagle/test_diagnostics.py b/tests/components/rainforest_eagle/test_diagnostics.py index ed13c33f7b8..e68e3cd4ce0 100644 --- a/tests/components/rainforest_eagle/test_diagnostics.py +++ b/tests/components/rainforest_eagle/test_diagnostics.py @@ -27,7 +27,7 @@ async def test_entry_diagnostics( config_entry_dict["data"][CONF_CLOUD_ID] = REDACTED assert result == { - "config_entry": config_entry_dict, + "config_entry": config_entry_dict | {"discovery_keys": []}, "data": { var["Name"]: var["Value"] for var in MOCK_200_RESPONSE_WITHOUT_PRICE.values() diff --git a/tests/components/rainforest_raven/test_diagnostics.py b/tests/components/rainforest_raven/test_diagnostics.py index 86a86032ac6..04e125b05d9 100644 --- a/tests/components/rainforest_raven/test_diagnostics.py +++ b/tests/components/rainforest_raven/test_diagnostics.py @@ -40,7 +40,7 @@ async def test_entry_diagnostics_no_meters( config_entry_dict["data"][CONF_MAC] = REDACTED assert result == { - "config_entry": config_entry_dict, + "config_entry": config_entry_dict | {"discovery_keys": []}, "data": { "Meters": {}, "NetworkInfo": {**asdict(NETWORK_INFO), "device_mac_id": REDACTED}, @@ -58,7 +58,7 @@ async def test_entry_diagnostics( config_entry_dict["data"][CONF_MAC] = REDACTED assert result == { - "config_entry": config_entry_dict, + "config_entry": config_entry_dict | {"discovery_keys": []}, "data": { "Meters": { "**REDACTED0**": { diff --git a/tests/components/rainmachine/snapshots/test_diagnostics.ambr b/tests/components/rainmachine/snapshots/test_diagnostics.ambr index 9b5b5edc0c4..ed1a3dc5961 100644 --- a/tests/components/rainmachine/snapshots/test_diagnostics.ambr +++ b/tests/components/rainmachine/snapshots/test_diagnostics.ambr @@ -1131,6 +1131,8 @@ 'ssl': True, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'rainmachine', 'entry_id': '81bd010ed0a63b705f6da8407cb26d4b', 'minor_version': 1, @@ -2260,6 +2262,8 @@ 'ssl': True, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'rainmachine', 'entry_id': '81bd010ed0a63b705f6da8407cb26d4b', 'minor_version': 1, diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index 2b92892b1d1..7ae4ff4fb9c 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -33,6 +33,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, + "discovery_keys": [], }, "data": [ { diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr index d32b1d3f446..9e5b4eefb3f 100644 --- a/tests/components/ridwell/snapshots/test_diagnostics.ambr +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -34,6 +34,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'ridwell', 'entry_id': '11554ec901379b9cc8f5a6c1d11ce978', 'minor_version': 1, diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index b1bdf034bc1..7c2fd07d322 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -42,6 +42,7 @@ async def test_entry_diagnostics( "token": REDACTED, }, "disabled_by": None, + "discovery_keys": [], "domain": "samsungtv", "entry_id": "123456", "minor_version": 2, @@ -81,6 +82,7 @@ async def test_entry_diagnostics_encrypted( "session_id": REDACTED, }, "disabled_by": None, + "discovery_keys": [], "domain": "samsungtv", "entry_id": "123456", "minor_version": 2, @@ -119,6 +121,7 @@ async def test_entry_diagnostics_encrypte_offline( "session_id": REDACTED, }, "disabled_by": None, + "discovery_keys": [], "domain": "samsungtv", "entry_id": "123456", "minor_version": 2, diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index 534c77223d6..c27e8170d3e 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'port': 80, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'screenlogic', 'entry_id': 'screenlogictest', 'minor_version': 1, diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 395c7ccfeaf..a82ac7b7b0f 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -45,7 +45,7 @@ async def test_block_config_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == { - "entry": entry_dict, + "entry": entry_dict | {"discovery_keys": []}, "bluetooth": "not initialized", "device_info": { "name": "Test name", @@ -105,7 +105,7 @@ async def test_rpc_config_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == { - "entry": entry_dict, + "entry": entry_dict | {"discovery_keys": []}, "bluetooth": { "scanner": { "connectable": False, diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index 31bd44c6146..fb863fa3bd0 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -31,6 +31,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, + "discovery_keys": [], }, "subscription_data": { "12345": { diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index ef237b545bb..0ef8f3a735f 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -9,6 +9,8 @@ 'password': 'pwd', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'solarlog', 'entry_id': 'ce5f5431554d101905d31797e1232da8', 'minor_version': 3, diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index 89bcefa5138..07a89fad7ec 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -68,5 +68,6 @@ async def test_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, + "discovery_keys": [], }, } diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index 328065f6098..e7a99abee5e 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -34,6 +34,8 @@ 'data': dict({ }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'systemmonitor', 'minor_version': 3, 'options': dict({ diff --git a/tests/components/tailwind/snapshots/test_config_flow.ambr b/tests/components/tailwind/snapshots/test_config_flow.ambr index 5c01f35e09c..9cc1dc9c6a6 100644 --- a/tests/components/tailwind/snapshots/test_config_flow.ambr +++ b/tests/components/tailwind/snapshots/test_config_flow.ambr @@ -22,6 +22,8 @@ 'token': '987654', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'tailwind', 'entry_id': , 'minor_version': 1, @@ -66,6 +68,8 @@ 'token': '987654', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'tailwind', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index f52cb3a88a5..861509c5c85 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -26,6 +26,8 @@ ]), }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'tankerkoenig', 'entry_id': '8036b4412f2fae6bb9dbab7fe8e37f87', 'minor_version': 1, diff --git a/tests/components/tractive/snapshots/test_diagnostics.ambr b/tests/components/tractive/snapshots/test_diagnostics.ambr index a66247749b7..a777107bd5e 100644 --- a/tests/components/tractive/snapshots/test_diagnostics.ambr +++ b/tests/components/tractive/snapshots/test_diagnostics.ambr @@ -7,6 +7,8 @@ 'password': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'tractive', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr index 416a656c238..b85a8ca1dd3 100644 --- a/tests/components/tuya/snapshots/test_config_flow.ambr +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -14,6 +14,8 @@ 'user_code': '12345', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'tuya', 'entry_id': , 'minor_version': 1, @@ -42,6 +44,8 @@ 'user_code': '12345', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'tuya', 'entry_id': , 'minor_version': 1, @@ -93,6 +97,8 @@ 'user_code': '12345', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'tuya', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/twentemilieu/snapshots/test_config_flow.ambr b/tests/components/twentemilieu/snapshots/test_config_flow.ambr index 00b96062052..2a8e389f009 100644 --- a/tests/components/twentemilieu/snapshots/test_config_flow.ambr +++ b/tests/components/twentemilieu/snapshots/test_config_flow.ambr @@ -26,6 +26,8 @@ 'post_code': '1234AB', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'twentemilieu', 'entry_id': , 'minor_version': 1, @@ -70,6 +72,8 @@ 'post_code': '1234AB', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'twentemilieu', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 0601159ca4c..9274d2278ec 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -27,6 +27,8 @@ 'name': 'twinkly_test_device_name', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'twinkly', 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', 'minor_version': 1, diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr index fb7415c59ab..11beeafdbc6 100644 --- a/tests/components/unifi/snapshots/test_diagnostics.ambr +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -27,6 +27,8 @@ 'verify_ssl': False, }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'unifi', 'entry_id': '1', 'minor_version': 1, diff --git a/tests/components/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr index 3e5b492f871..968093c1345 100644 --- a/tests/components/uptime/snapshots/test_config_flow.ambr +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -17,6 +17,8 @@ 'data': dict({ }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'uptime', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index 28841854766..2eec7b358c3 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -5,6 +5,8 @@ 'data': dict({ }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'utility_meter', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index cc34cae87f8..181e5094c4f 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -6,6 +6,8 @@ 'host': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'v2c', 'entry_id': 'da58ee91f38c2406c2a36d0a1a7f8569', 'minor_version': 1, diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index 120bdf7a333..818aa9f226b 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4721,6 +4721,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'vicare', 'entry_id': '1234', 'minor_version': 1, diff --git a/tests/components/watttime/snapshots/test_diagnostics.ambr b/tests/components/watttime/snapshots/test_diagnostics.ambr index 2ed35c19ad1..dd4252eeadd 100644 --- a/tests/components/watttime/snapshots/test_diagnostics.ambr +++ b/tests/components/watttime/snapshots/test_diagnostics.ambr @@ -18,6 +18,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'watttime', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/webmin/snapshots/test_diagnostics.ambr b/tests/components/webmin/snapshots/test_diagnostics.ambr index a56d6b35641..5e889bd87a7 100644 --- a/tests/components/webmin/snapshots/test_diagnostics.ambr +++ b/tests/components/webmin/snapshots/test_diagnostics.ambr @@ -237,6 +237,8 @@ 'data': dict({ }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'webmin', 'entry_id': '**REDACTED**', 'minor_version': 1, diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index e2fbc43e187..74a7a50ded4 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -60,5 +60,6 @@ async def test_diagnostics( "disabled_by": None, "created_at": entry.created_at.isoformat(), "modified_at": entry.modified_at.isoformat(), + "discovery_keys": [], }, } diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index 5a0beb112e6..b922c221908 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -29,6 +29,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'whirlpool', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 08f3861dcd2..aaf95513219 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -20,6 +20,8 @@ 'domain': 'example.com', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'whois', 'entry_id': , 'minor_version': 1, @@ -58,6 +60,8 @@ 'domain': 'example.com', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'whois', 'entry_id': , 'minor_version': 1, @@ -96,6 +100,8 @@ 'domain': 'example.com', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'whois', 'entry_id': , 'minor_version': 1, @@ -134,6 +140,8 @@ 'domain': 'example.com', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'whois', 'entry_id': , 'minor_version': 1, @@ -172,6 +180,8 @@ 'domain': 'example.com', }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'whois', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index ee4c5533254..58617d9109d 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -26,6 +26,8 @@ 'port': 10200, }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'wyoming', 'entry_id': , 'minor_version': 1, @@ -70,6 +72,8 @@ 'port': 10200, }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'wyoming', 'entry_id': , 'minor_version': 1, @@ -114,6 +118,8 @@ 'port': 12345, }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'wyoming', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 0a552f37aa9..229329bea61 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -12,6 +12,7 @@ from zeroconf import ( ) from zeroconf.asyncio import AsyncServiceInfo +from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import ( EVENT_COMPONENT_LOADED, @@ -22,8 +23,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.generated import zeroconf as zc_gen +from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.setup import ATTR_COMPONENT, async_setup_component +from tests.common import MockConfigEntry, MockModule, mock_integration + NON_UTF8_VALUE = b"ABCDEF\x8a" NON_ASCII_KEY = b"non-ascii-key\x8a" PROPERTIES = { @@ -303,7 +307,14 @@ async def test_zeroconf_match_macaddress(hass: HomeAssistant) -> None: assert len(mock_service_browser.mock_calls) == 1 assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "shelly" - assert mock_config_flow.mock_calls[0][2]["context"] == {"source": "zeroconf"} + assert mock_config_flow.mock_calls[0][2]["context"] == { + "discovery_key": DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + "source": "zeroconf", + } @pytest.mark.usefixtures("mock_async_zeroconf") @@ -542,6 +553,11 @@ async def test_homekit_match_partial_space(hass: HomeAssistant) -> None: assert mock_config_flow.mock_calls[1][2]["context"] == { "source": "zeroconf", "alternative_domain": "lifx", + "discovery_key": DiscoveryKey( + domain="zeroconf", + key=("_hap._tcp.local.", "_name._hap._tcp.local."), + version=1, + ), } @@ -1381,3 +1397,353 @@ async def test_zeroconf_removed(hass: HomeAssistant) -> None: assert len(mock_service_browser.mock_calls) == 1 assert len(mock_async_progress_by_init_data_type.mock_calls) == 1 assert mock_async_abort.mock_calls[0][1][0] == "mock_flow_id" + + +@pytest.mark.usefixtures("mock_async_zeroconf") +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + ), + [ + # Matching discovery key + ( + "shelly", + ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ), + ), + # Matching discovery key + ( + "shelly", + ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + DiscoveryKey( + domain="other", + key="blah", + version=1, + ), + ), + ), + # Matching discovery key, other domain + # Note: Rediscovery is not currently restricted to the domain of the removed + # entry. Such a check can be added if needed. + ( + "comp", + ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ), + ), + ], +) +async def test_zeroconf_rediscover( + hass: HomeAssistant, + entry_domain: str, + entry_discovery_keys: tuple, +) -> None: + """Test we reinitiate flows when an ignored config entry is removed.""" + + def http_only_service_update_mock(zeroconf, services, handlers): + """Call service update handler.""" + handlers[0]( + zeroconf, + "_http._tcp.local.", + "Shelly108._http._tcp.local.", + ServiceStateChange.Added, + ) + + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.LOADED, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + + with ( + patch.dict( + zc_gen.ZEROCONF, + { + "_http._tcp.local.": [ + { + "domain": "shelly", + "name": "shelly*", + "properties": {"macaddress": "ffaadd*"}, + } + ] + }, + clear=True, + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + patch.object( + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + ) as mock_service_browser, + patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), + ), + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + "source": "zeroconf", + } + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "shelly" + assert mock_config_flow.mock_calls[0][2]["context"] == expected_context + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 3 + assert mock_config_flow.mock_calls[1][1][0] == entry_domain + assert mock_config_flow.mock_calls[1][2]["context"] == { + "source": "unignore", + } + assert mock_config_flow.mock_calls[2][1][0] == "shelly" + assert mock_config_flow.mock_calls[2][2]["context"] == expected_context + + +@pytest.mark.usefixtures("mock_async_zeroconf") +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + "entry_source", + "entry_unique_id", + ), + [ + # Discovery key from other domain + ( + "shelly", + ( + DiscoveryKey( + domain="bluetooth", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ), + config_entries.SOURCE_IGNORE, + "mock-unique-id", + ), + # Discovery key from the future + ( + "shelly", + ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=2, + ), + ), + config_entries.SOURCE_IGNORE, + "mock-unique-id", + ), + ], +) +async def test_zeroconf_rediscover_no_match( + hass: HomeAssistant, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, + entry_unique_id: str, +) -> None: + """Test we don't reinitiate flows when a non matching config entry is removed.""" + + def http_only_service_update_mock(zeroconf, services, handlers): + """Call service update handler.""" + handlers[0]( + zeroconf, + "_http._tcp.local.", + "Shelly108._http._tcp.local.", + ServiceStateChange.Added, + ) + + hass.config.components.add(entry_domain) + mock_integration(hass, MockModule(entry_domain)) + + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id=entry_unique_id, + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + with ( + patch.dict( + zc_gen.ZEROCONF, + { + "_http._tcp.local.": [ + { + "domain": "shelly", + "name": "shelly*", + "properties": {"macaddress": "ffaadd*"}, + } + ] + }, + clear=True, + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + patch.object( + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + ) as mock_service_browser, + patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), + ), + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + "source": "zeroconf", + } + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "shelly" + assert mock_config_flow.mock_calls[0][2]["context"] == expected_context + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[1][1][0] == entry_domain + assert mock_config_flow.mock_calls[1][2]["context"] == { + "source": "unignore", + } + + +@pytest.mark.usefixtures("mock_async_zeroconf") +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + "entry_source", + "entry_unique_id", + ), + [ + # Source not SOURCE_IGNORE + ( + "shelly", + ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ), + config_entries.SOURCE_ZEROCONF, + "mock-unique-id", + ), + ], +) +async def test_zeroconf_rediscover_no_match_2( + hass: HomeAssistant, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, + entry_unique_id: str, +) -> None: + """Test we don't reinitiate flows when a non matching config entry is removed. + + This test can be merged with test_zeroconf_rediscover_no_match when + async_step_unignore has been removed from the ConfigFlow base class. + """ + + def http_only_service_update_mock(zeroconf, services, handlers): + """Call service update handler.""" + handlers[0]( + zeroconf, + "_http._tcp.local.", + "Shelly108._http._tcp.local.", + ServiceStateChange.Added, + ) + + hass.config.components.add(entry_domain) + mock_integration(hass, MockModule(entry_domain)) + + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id=entry_unique_id, + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + with ( + patch.dict( + zc_gen.ZEROCONF, + { + "_http._tcp.local.": [ + { + "domain": "shelly", + "name": "shelly*", + "properties": {"macaddress": "ffaadd*"}, + } + ] + }, + clear=True, + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + patch.object( + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + ) as mock_service_browser, + patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), + ), + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + "source": "zeroconf", + } + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "shelly" + assert mock_config_flow.mock_calls[0][2]["context"] == expected_context + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 1 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index e0da54e2492..2745496256b 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -93,6 +93,8 @@ 'radio_type': 'ezsp', }), 'disabled_by': None, + 'discovery_keys': list([ + ]), 'domain': 'zha', 'minor_version': 1, 'options': dict({ diff --git a/tests/helpers/test_discovery_flow.py b/tests/helpers/test_discovery_flow.py index 0fa315d684b..2bb58f86c9a 100644 --- a/tests/helpers/test_discovery_flow.py +++ b/tests/helpers/test_discovery_flow.py @@ -8,7 +8,8 @@ import pytest from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers import discovery_flow +from homeassistant.helpers import discovery_flow, json as json_helper +from homeassistant.helpers.discovery_flow import DiscoveryKey @pytest.fixture @@ -20,8 +21,29 @@ def mock_flow_init(hass: HomeAssistant) -> Generator[AsyncMock]: yield mock_init +@pytest.mark.parametrize( + ("discovery_key", "context"), + [ + (None, {}), + ( + DiscoveryKey(domain="test", key="string_key", version=1), + {"discovery_key": DiscoveryKey(domain="test", key="string_key", version=1)}, + ), + ( + DiscoveryKey(domain="test", key=("one", "two"), version=1), + { + "discovery_key": DiscoveryKey( + domain="test", key=("one", "two"), version=1 + ) + }, + ), + ], +) async def test_async_create_flow( - hass: HomeAssistant, mock_flow_init: AsyncMock + hass: HomeAssistant, + mock_flow_init: AsyncMock, + discovery_key: DiscoveryKey | None, + context: {}, ) -> None: """Test we can create a flow.""" discovery_flow.async_create_flow( @@ -29,11 +51,12 @@ async def test_async_create_flow( "hue", {"source": config_entries.SOURCE_HOMEKIT}, {"properties": {"id": "aa:bb:cc:dd:ee:ff"}}, + discovery_key=discovery_key, ) assert mock_flow_init.mock_calls == [ call( "hue", - context={"source": "homekit"}, + context={"source": "homekit"} | context, data={"properties": {"id": "aa:bb:cc:dd:ee:ff"}}, ) ] @@ -118,3 +141,16 @@ async def test_async_create_flow_does_nothing_after_stop( {"properties": {"id": "aa:bb:cc:dd:ee:ff"}}, ) assert len(mock_flow_init.mock_calls) == 0 + + +@pytest.mark.parametrize("key", ["test", ("blah", "bleh")]) +def test_discovery_key_serialize_deserialize(key: str | tuple[str]) -> None: + """Test serialize and deserialize discovery key.""" + discovery_key_1 = discovery_flow.DiscoveryKey( + domain="test_domain", key=key, version=1 + ) + serialized = json_helper.json_dumps(discovery_key_1) + assert ( + discovery_flow.DiscoveryKey.from_json_dict(json_helper.json_loads(serialized)) + == discovery_key_1 + ) diff --git a/tests/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr index 136749dfb14..35f6272b772 100644 --- a/tests/snapshots/test_config_entries.ambr +++ b/tests/snapshots/test_config_entries.ambr @@ -5,6 +5,8 @@ 'data': dict({ }), 'disabled_by': None, + 'discovery_keys': tuple( + ), 'domain': 'test', 'entry_id': 'mock-entry', 'minor_version': 1, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 422fa516a2a..ebbe4c5fa2c 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -38,6 +38,7 @@ from homeassistant.exceptions import ( HomeAssistantError, ) from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -884,10 +885,21 @@ async def test_saving_and_loading( with patch("homeassistant.config_entries.HANDLERS.get", return_value=Test2Flow): await hass.config_entries.flow.async_init( - "test", context={"source": config_entries.SOURCE_USER} + "test", + context={ + "source": config_entries.SOURCE_USER, + "discovery_key": DiscoveryKey(domain="test", key=("blah"), version=1), + }, + ) + await hass.config_entries.flow.async_init( + "test", + context={ + "source": config_entries.SOURCE_USER, + "discovery_key": DiscoveryKey(domain="test", key=("a", "b"), version=1), + }, ) - assert len(hass.config_entries.async_entries()) == 2 + assert len(hass.config_entries.async_entries()) == 3 entry_1 = hass.config_entries.async_entries()[0] hass.config_entries.async_update_entry( @@ -906,7 +918,7 @@ async def test_saving_and_loading( manager = config_entries.ConfigEntries(hass, {}) await manager.async_initialize() - assert len(manager.async_entries()) == 2 + assert len(manager.async_entries()) == 3 # Ensure same order for orig, loaded in zip( @@ -2739,8 +2751,24 @@ async def test_finish_flow_aborts_progress( assert len(hass.config_entries.flow.async_progress()) == 0 +@pytest.mark.parametrize( + ("extra_context", "expected_entry_discovery_keys"), + [ + ( + {}, + (), + ), + ( + {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, + (DiscoveryKey(domain="test", key="blah", version=1),), + ), + ], +) async def test_unique_id_ignore( - hass: HomeAssistant, manager: config_entries.ConfigEntries + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + extra_context: dict, + expected_entry_discovery_keys: dict, ) -> None: """Test that we can ignore flows that are in progress and have a unique ID.""" async_setup_entry = AsyncMock(return_value=False) @@ -2766,7 +2794,7 @@ async def test_unique_id_ignore( result2 = await manager.flow.async_init( "comp", - context={"source": config_entries.SOURCE_IGNORE}, + context={"source": config_entries.SOURCE_IGNORE} | extra_context, data={"unique_id": "mock-unique-id", "title": "Ignored Title"}, ) @@ -2782,6 +2810,8 @@ async def test_unique_id_ignore( assert entry.source == "ignore" assert entry.unique_id == "mock-unique-id" assert entry.title == "Ignored Title" + assert entry.data == {} + assert entry.discovery_keys == expected_entry_discovery_keys async def test_manual_add_overrides_ignored_entry( @@ -2878,6 +2908,184 @@ async def test_manual_add_overrides_ignored_entry_singleton( assert p_entry.data == {"token": "supersecret"} +@pytest.mark.parametrize( + ( + "discovery_keys", + "entry_source", + "entry_unique_id", + "flow_context", + "flow_source", + "flow_result", + "updated_discovery_keys", + ), + [ + # No discovery key + ( + (), + config_entries.SOURCE_IGNORE, + "mock-unique-id", + {}, + config_entries.SOURCE_ZEROCONF, + data_entry_flow.FlowResultType.ABORT, + (), + ), + # Discovery key added to ignored entry data + ( + (), + config_entries.SOURCE_IGNORE, + "mock-unique-id", + {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + config_entries.SOURCE_ZEROCONF, + data_entry_flow.FlowResultType.ABORT, + ({"domain": "test", "key": "blah", "version": 1},), + ), + # Discovery key added to ignored entry data + ( + ({"domain": "test", "key": "bleh", "version": 1},), + config_entries.SOURCE_IGNORE, + "mock-unique-id", + {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + config_entries.SOURCE_ZEROCONF, + data_entry_flow.FlowResultType.ABORT, + ( + {"domain": "test", "key": "bleh", "version": 1}, + {"domain": "test", "key": "blah", "version": 1}, + ), + ), + # Discovery key added to ignored entry data + ( + ( + {"domain": "test", "key": "1", "version": 1}, + {"domain": "test", "key": "2", "version": 1}, + {"domain": "test", "key": "3", "version": 1}, + {"domain": "test", "key": "4", "version": 1}, + {"domain": "test", "key": "5", "version": 1}, + {"domain": "test", "key": "6", "version": 1}, + {"domain": "test", "key": "7", "version": 1}, + {"domain": "test", "key": "8", "version": 1}, + {"domain": "test", "key": "9", "version": 1}, + {"domain": "test", "key": "10", "version": 1}, + ), + config_entries.SOURCE_IGNORE, + "mock-unique-id", + {"discovery_key": {"domain": "test", "key": "11", "version": 1}}, + config_entries.SOURCE_ZEROCONF, + data_entry_flow.FlowResultType.ABORT, + ( + {"domain": "test", "key": "2", "version": 1}, + {"domain": "test", "key": "3", "version": 1}, + {"domain": "test", "key": "4", "version": 1}, + {"domain": "test", "key": "5", "version": 1}, + {"domain": "test", "key": "6", "version": 1}, + {"domain": "test", "key": "7", "version": 1}, + {"domain": "test", "key": "8", "version": 1}, + {"domain": "test", "key": "9", "version": 1}, + {"domain": "test", "key": "10", "version": 1}, + {"domain": "test", "key": "11", "version": 1}, + ), + ), + # Discovery key already in ignored entry data + ( + ({"domain": "test", "key": "blah", "version": 1},), + config_entries.SOURCE_IGNORE, + "mock-unique-id", + {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + config_entries.SOURCE_ZEROCONF, + data_entry_flow.FlowResultType.ABORT, + ({"domain": "test", "key": "blah", "version": 1},), + ), + # Discovery key not added to user entry data + ( + (), + config_entries.SOURCE_USER, + "mock-unique-id", + {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + config_entries.SOURCE_ZEROCONF, + data_entry_flow.FlowResultType.ABORT, + (), + ), + # Flow not aborted when unique id is not matching + ( + (), + config_entries.SOURCE_IGNORE, + "mock-unique-id-2", + {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + config_entries.SOURCE_ZEROCONF, + data_entry_flow.FlowResultType.FORM, + (), + ), + # Flow not aborted when user initiated flow + ( + (), + config_entries.SOURCE_IGNORE, + "mock-unique-id-2", + {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + config_entries.SOURCE_USER, + data_entry_flow.FlowResultType.FORM, + (), + ), + ], +) +async def test_ignored_entry_update_discovery_keys( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + discovery_keys: tuple, + entry_source: str, + entry_unique_id: str, + flow_context: dict, + flow_source: str, + flow_result: data_entry_flow.FlowResultType, + updated_discovery_keys: tuple, +) -> None: + """Test that discovery keys of an ignored entry can be updated.""" + hass.config.components.add("comp") + entry = MockConfigEntry( + domain="comp", + discovery_keys=discovery_keys, + unique_id=entry_unique_id, + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + mock_integration(hass, MockModule("comp")) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + await self.async_set_unique_id("mock-unique-id") + self._abort_if_unique_id_configured(reload_on_update=False) + return self.async_show_form(step_id="step2") + + async def async_step_step2(self, user_input=None): + raise NotImplementedError + + async def async_step_zeroconf(self, discovery_info=None): + """Test zeroconf step.""" + return await self.async_step_user(discovery_info) + + with ( + mock_config_flow("comp", TestFlow), + patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as async_reload, + ): + result = await manager.flow.async_init( + "comp", context={"source": flow_source} | flow_context + ) + await hass.async_block_till_done() + + assert result["type"] == flow_result + assert entry.data == {} + assert entry.discovery_keys == updated_discovery_keys + assert len(async_reload.mock_calls) == 0 + + async def test_async_current_entries_does_not_skip_ignore_non_user( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -5043,6 +5251,7 @@ async def test_unhashable_unique_id( entries = config_entries.ConfigEntryItems(hass) entry = config_entries.ConfigEntry( data={}, + discovery_keys=(), domain="test", entry_id="mock_id", minor_version=1, @@ -5075,6 +5284,7 @@ async def test_hashable_non_string_unique_id( entries = config_entries.ConfigEntryItems(hass) entry = config_entries.ConfigEntry( data={}, + discovery_keys=(), domain="test", entry_id="mock_id", minor_version=1, @@ -5976,6 +6186,7 @@ async def test_migration_from_1_2( "created_at": "1970-01-01T00:00:00+00:00", "data": {}, "disabled_by": None, + "discovery_keys": [], "domain": "sun", "entry_id": "0a8bd02d0d58c7debf5daf7941c9afe2", "minor_version": 1, From 84f19f72166bcb6acc53c688b5afe3135b63e2ad Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 23 Sep 2024 09:50:15 -0500 Subject: [PATCH 1025/1309] Bump intents to 2024.9.23 (#126553) --- 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 837ac9f9b1f..79869510027 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.4"] + "requirements": ["hassil==1.7.4", "home-assistant-intents==2024.9.23"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c1f6586988b..6dd9b32f06c 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==20240909.1 -home-assistant-intents==2024.9.4 +home-assistant-intents==2024.9.23 httpx==0.27.0 ifaddr==0.2.0 Jinja2==3.1.4 diff --git a/requirements_all.txt b/requirements_all.txt index b1fdee04607..6a8eb27f67b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1120,7 +1120,7 @@ holidays==0.57 home-assistant-frontend==20240909.1 # homeassistant.components.conversation -home-assistant-intents==2024.9.4 +home-assistant-intents==2024.9.23 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b07412bb2e9..0b7a8c26df6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -946,7 +946,7 @@ holidays==0.57 home-assistant-frontend==20240909.1 # homeassistant.components.conversation -home-assistant-intents==2024.9.4 +home-assistant-intents==2024.9.23 # homeassistant.components.home_connect homeconnect==0.8.0 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index e996bcc081a..ba9493ce654 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.12,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.1 tqdm==4.66.4 ruff==0.6.6 \ - PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.0 hassil==1.7.4 home-assistant-intents==2024.9.4 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.9.23 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 8a2dccddc55727929d5c173273218a8b31081ca0 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Mon, 23 Sep 2024 15:59:22 +0100 Subject: [PATCH 1026/1309] Add Model and Manufacturer details for Squeezebox devices (#126435) * Add models and manufacturer * Updates re: comments * Updates for test * Dedupe model * Update homeassistant/components/squeezebox/media_player.py * Change Squeezelite to SqueezeLite --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/squeezebox/media_player.py | 12 ++++++++++++ tests/components/squeezebox/conftest.py | 1 + .../squeezebox/snapshots/test_media_player.ambr | 4 ++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 610cb28d9ee..54cb07cafaf 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -220,11 +220,23 @@ class SqueezeBoxEntity(MediaPlayerEntity): self._query_result: bool | dict = {} self._remove_dispatcher: Callable | None = None self._attr_unique_id = format_mac(player.player_id) + _manufacturer = None + if player.model == "SqueezeLite" or "SqueezePlay" in player.model: + _manufacturer = "Ralph Irving" + elif ( + "Squeezebox" in player.model + or "Transporter" in player.model + or "Slim" in player.model + ): + _manufacturer = "Logitech" + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, name=player.name, connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)}, via_device=(DOMAIN, server.uuid), + model=player.model, + manufacturer=_manufacturer, ) @property diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index 9c8201cfbca..2a8c4aacbd3 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -221,6 +221,7 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.remote_title = None mock_player.title = None mock_player.image_url = None + mock_player.model = "SqueezeLite" return mock_player diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index cac53d9a5af..ddd5b9868a1 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -23,8 +23,8 @@ 'is_new': False, 'labels': set({ }), - 'manufacturer': 'https://lyrion.org/', - 'model': 'Lyrion Music Server', + 'manufacturer': 'Ralph Irving', + 'model': 'SqueezeLite', 'model_id': None, 'name': 'Test Player', 'name_by_user': None, From 8eb76ea68d8bc48d8be7390e25937390309730ff Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 23 Sep 2024 17:39:53 +0200 Subject: [PATCH 1027/1309] Change lawn_mower state to an enum (#126458) * Change lawn_mower state to an enum * annotate as string --- homeassistant/components/lawn_mower/__init__.py | 4 +--- tests/components/kitchen_sink/test_lawn_mower.py | 2 +- tests/components/lawn_mower/test_init.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index b4d174f6676..604a6580f97 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -86,9 +86,7 @@ class LawnMowerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def state(self) -> str | None: """Return the current state.""" - if (activity := self.activity) is None: - return None - return str(activity) + return self.activity @cached_property def activity(self) -> LawnMowerActivity | None: diff --git a/tests/components/kitchen_sink/test_lawn_mower.py b/tests/components/kitchen_sink/test_lawn_mower.py index e1ba201a722..5bd4fc834f8 100644 --- a/tests/components/kitchen_sink/test_lawn_mower.py +++ b/tests/components/kitchen_sink/test_lawn_mower.py @@ -100,7 +100,7 @@ async def test_mower( await hass.async_block_till_done() assert state_changes[0].data["entity_id"] == entity - assert state_changes[0].data["new_state"].state == str(next_activity.value) + assert state_changes[0].data["new_state"].state == next_activity.value @pytest.mark.parametrize( diff --git a/tests/components/lawn_mower/test_init.py b/tests/components/lawn_mower/test_init.py index 16f32da7e04..0735d4541ff 100644 --- a/tests/components/lawn_mower/test_init.py +++ b/tests/components/lawn_mower/test_init.py @@ -176,4 +176,4 @@ async def test_lawn_mower_state(hass: HomeAssistant) -> None: lawn_mower.hass = hass lawn_mower.start_mowing() - assert lawn_mower.state == str(LawnMowerActivity.MOWING) + assert lawn_mower.state == LawnMowerActivity.MOWING From 1d94e66b9c815bffd0643a4101e6a1822cd62c94 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Sep 2024 17:40:19 +0200 Subject: [PATCH 1028/1309] Add NYT Games integration (#126449) * Add NYT Games integration * Add NYT Games integration * Add NYT Games integration * Add NYT Games integration * Add test --- CODEOWNERS | 2 + .../components/nyt_games/__init__.py | 42 +++++++ .../components/nyt_games/config_flow.py | 42 +++++++ homeassistant/components/nyt_games/const.py | 7 ++ .../components/nyt_games/coordinator.py | 38 +++++++ homeassistant/components/nyt_games/entity.py | 21 ++++ homeassistant/components/nyt_games/icons.json | 9 ++ .../components/nyt_games/manifest.json | 10 ++ homeassistant/components/nyt_games/sensor.py | 72 ++++++++++++ .../components/nyt_games/strings.json | 29 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/nyt_games/__init__.py | 13 +++ tests/components/nyt_games/conftest.py | 54 +++++++++ .../components/nyt_games/fixtures/latest.json | 69 ++++++++++++ .../nyt_games/snapshots/test_init.ambr | 33 ++++++ .../nyt_games/snapshots/test_sensor.ambr | 51 +++++++++ .../components/nyt_games/test_config_flow.py | 104 ++++++++++++++++++ tests/components/nyt_games/test_init.py | 29 +++++ tests/components/nyt_games/test_sensor.py | 55 +++++++++ 22 files changed, 693 insertions(+) create mode 100644 homeassistant/components/nyt_games/__init__.py create mode 100644 homeassistant/components/nyt_games/config_flow.py create mode 100644 homeassistant/components/nyt_games/const.py create mode 100644 homeassistant/components/nyt_games/coordinator.py create mode 100644 homeassistant/components/nyt_games/entity.py create mode 100644 homeassistant/components/nyt_games/icons.json create mode 100644 homeassistant/components/nyt_games/manifest.json create mode 100644 homeassistant/components/nyt_games/sensor.py create mode 100644 homeassistant/components/nyt_games/strings.json create mode 100644 tests/components/nyt_games/__init__.py create mode 100644 tests/components/nyt_games/conftest.py create mode 100644 tests/components/nyt_games/fixtures/latest.json create mode 100644 tests/components/nyt_games/snapshots/test_init.ambr create mode 100644 tests/components/nyt_games/snapshots/test_sensor.ambr create mode 100644 tests/components/nyt_games/test_config_flow.py create mode 100644 tests/components/nyt_games/test_init.py create mode 100644 tests/components/nyt_games/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index a144f1b339b..c95c457b27e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1024,6 +1024,8 @@ build.json @home-assistant/supervisor /tests/components/nut/ @bdraco @ollo69 @pestevez /homeassistant/components/nws/ @MatthewFlamm @kamiyo /tests/components/nws/ @MatthewFlamm @kamiyo +/homeassistant/components/nyt_games/ @joostlek +/tests/components/nyt_games/ @joostlek /homeassistant/components/nzbget/ @chriscla /tests/components/nzbget/ @chriscla /homeassistant/components/obihai/ @dshokouhi @ejpenney diff --git a/homeassistant/components/nyt_games/__init__.py b/homeassistant/components/nyt_games/__init__.py new file mode 100644 index 00000000000..ae35b40d29f --- /dev/null +++ b/homeassistant/components/nyt_games/__init__.py @@ -0,0 +1,42 @@ +"""The NYT Games integration.""" + +from __future__ import annotations + +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 .coordinator import NYTGamesCoordinator + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +type NYTGamesConfigEntry = ConfigEntry[NYTGamesCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: NYTGamesConfigEntry) -> bool: + """Set up NYTGames from a config entry.""" + + client = NYTGamesClient( + entry.data[CONF_TOKEN], session=async_get_clientsession(hass) + ) + + coordinator = NYTGamesCoordinator(hass, client) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: NYTGamesConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nyt_games/config_flow.py b/homeassistant/components/nyt_games/config_flow.py new file mode 100644 index 00000000000..b8687e58f72 --- /dev/null +++ b/homeassistant/components/nyt_games/config_flow.py @@ -0,0 +1,42 @@ +"""Config flow for NYT Games.""" + +from typing import Any + +from nyt_games import NYTGamesAuthenticationError, NYTGamesClient, NYTGamesError +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 .const import DOMAIN + + +class NYTGamesConfigFlow(ConfigFlow, domain=DOMAIN): + """NYT Games config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input: + session = async_get_clientsession(self.hass) + client = NYTGamesClient(user_input[CONF_TOKEN], session=session) + try: + latest_stats = await client.get_latest_stats() + except NYTGamesAuthenticationError: + errors["base"] = "invalid_auth" + except NYTGamesError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(latest_stats.user_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="NYT Games", data=user_input) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), + errors=errors, + ) diff --git a/homeassistant/components/nyt_games/const.py b/homeassistant/components/nyt_games/const.py new file mode 100644 index 00000000000..c290e70b283 --- /dev/null +++ b/homeassistant/components/nyt_games/const.py @@ -0,0 +1,7 @@ +"""Constants for the NYT Games integration.""" + +import logging + +DOMAIN = "nyt_games" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/nyt_games/coordinator.py b/homeassistant/components/nyt_games/coordinator.py new file mode 100644 index 00000000000..4234df2e0b1 --- /dev/null +++ b/homeassistant/components/nyt_games/coordinator.py @@ -0,0 +1,38 @@ +"""Define an object to manage fetching NYT Games data.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from nyt_games import NYTGamesClient, NYTGamesError, Wordle + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +if TYPE_CHECKING: + from . import NYTGamesConfigEntry + + +class NYTGamesCoordinator(DataUpdateCoordinator[Wordle]): + """Class to manage fetching NYT Games data.""" + + config_entry: NYTGamesConfigEntry + + def __init__(self, hass: HomeAssistant, client: NYTGamesClient) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=LOGGER, + name="NYT Games", + update_interval=timedelta(minutes=15), + ) + self.client = client + + async def _async_update_data(self) -> Wordle: + try: + return (await self.client.get_latest_stats()).stats.wordle + except NYTGamesError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/nyt_games/entity.py b/homeassistant/components/nyt_games/entity.py new file mode 100644 index 00000000000..b5370805e27 --- /dev/null +++ b/homeassistant/components/nyt_games/entity.py @@ -0,0 +1,21 @@ +"""Base class for NYT Games entities.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NYTGamesCoordinator + + +class NYTGamesEntity(CoordinatorEntity[NYTGamesCoordinator]): + """Defines a base NYT Games entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: NYTGamesCoordinator) -> None: + """Initialize a NYT Games entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, + manufacturer="New York Times", + ) diff --git a/homeassistant/components/nyt_games/icons.json b/homeassistant/components/nyt_games/icons.json new file mode 100644 index 00000000000..fe18cddc5c7 --- /dev/null +++ b/homeassistant/components/nyt_games/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "wordles_played": { + "default": "mdi:text-long" + } + } + } +} diff --git a/homeassistant/components/nyt_games/manifest.json b/homeassistant/components/nyt_games/manifest.json new file mode 100644 index 00000000000..94a731c52a4 --- /dev/null +++ b/homeassistant/components/nyt_games/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "nyt_games", + "name": "NYT Games", + "codeowners": ["@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/nyt_games", + "integration_type": "service", + "iot_class": "cloud_polling", + "requirements": ["nyt_games==0.3.0"] +} diff --git a/homeassistant/components/nyt_games/sensor.py b/homeassistant/components/nyt_games/sensor.py new file mode 100644 index 00000000000..157b0311481 --- /dev/null +++ b/homeassistant/components/nyt_games/sensor.py @@ -0,0 +1,72 @@ +"""Support for NYT Games sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from nyt_games import Wordle + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import NYTGamesConfigEntry +from .coordinator import NYTGamesCoordinator +from .entity import NYTGamesEntity + + +@dataclass(frozen=True, kw_only=True) +class NYTGamesWordleSensorEntityDescription(SensorEntityDescription): + """Describes a NYT Games Wordle sensor entity.""" + + value_fn: Callable[[Wordle], StateType] + + +SENSOR_TYPES: tuple[NYTGamesWordleSensorEntityDescription, ...] = ( + NYTGamesWordleSensorEntityDescription( + key="wordles_played", + translation_key="wordles_played", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="games", + value_fn=lambda wordle: wordle.games_played, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NYTGamesConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up NYT Games sensor entities based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + NYTGamesSensor(coordinator, description) for description in SENSOR_TYPES + ) + + +class NYTGamesSensor(NYTGamesEntity, SensorEntity): + """Defines a NYT Games sensor.""" + + entity_description: NYTGamesWordleSensorEntityDescription + + def __init__( + self, + coordinator: NYTGamesCoordinator, + description: NYTGamesWordleSensorEntityDescription, + ) -> None: + """Initialize NYT Games sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/nyt_games/strings.json b/homeassistant/components/nyt_games/strings.json new file mode 100644 index 00000000000..ff7b0297f22 --- /dev/null +++ b/homeassistant/components/nyt_games/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "data": { + "token": "Token" + }, + "data_description": { + "token": "The NYT Games NYT-S cookie value." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "wordles_played": { + "name": "Wordles played" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e126558cc0d..40ddcbd86c0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -408,6 +408,7 @@ FLOWS = { "nuki", "nut", "nws", + "nyt_games", "nzbget", "obihai", "octoprint", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 528d10aaab8..9ed6ba531da 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4221,6 +4221,12 @@ "config_flow": false, "iot_class": "local_push" }, + "nyt_games": { + "name": "NYT Games", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "nzbget": { "name": "NZBGet", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 6a8eb27f67b..3257b170538 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,6 +1483,9 @@ numato-gpio==0.13.0 # homeassistant.components.trend numpy==1.26.0 +# homeassistant.components.nyt_games +nyt_games==0.3.0 + # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b7a8c26df6..5943e4b18aa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1231,6 +1231,9 @@ numato-gpio==0.13.0 # homeassistant.components.trend numpy==1.26.0 +# homeassistant.components.nyt_games +nyt_games==0.3.0 + # homeassistant.components.google oauth2client==4.1.3 diff --git a/tests/components/nyt_games/__init__.py b/tests/components/nyt_games/__init__.py new file mode 100644 index 00000000000..46dff12e5a1 --- /dev/null +++ b/tests/components/nyt_games/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the NYT Games integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/nyt_games/conftest.py b/tests/components/nyt_games/conftest.py new file mode 100644 index 00000000000..324327174f5 --- /dev/null +++ b/tests/components/nyt_games/conftest.py @@ -0,0 +1,54 @@ +"""NYTGames tests configuration.""" + +from collections.abc import Generator +from unittest.mock import patch + +from nyt_games.models import LatestData +import pytest + +from homeassistant.components.nyt_games.const import DOMAIN +from homeassistant.const import CONF_TOKEN + +from tests.common import MockConfigEntry, load_fixture +from tests.components.smhi.common import AsyncMock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nyt_games.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_nyt_games_client() -> Generator[AsyncMock]: + """Mock an NYTGames client.""" + with ( + patch( + "homeassistant.components.nyt_games.NYTGamesClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.nyt_games.config_flow.NYTGamesClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_latest_stats.return_value = LatestData.from_json( + load_fixture("latest.json", DOMAIN) + ).player + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="NYTGames", + data={CONF_TOKEN: "token"}, + unique_id="218886794", + ) diff --git a/tests/components/nyt_games/fixtures/latest.json b/tests/components/nyt_games/fixtures/latest.json new file mode 100644 index 00000000000..73a6f440fc0 --- /dev/null +++ b/tests/components/nyt_games/fixtures/latest.json @@ -0,0 +1,69 @@ +{ + "states": [], + "user_id": 218886794, + "player": { + "user_id": 218886794, + "last_updated": 1726831978, + "stats": { + "spelling_bee": { + "puzzles_started": 87, + "total_words": 362, + "total_pangrams": 15, + "longest_word": { + "word": "checkable", + "center_letter": "b", + "print_date": "2024-07-27" + }, + "ranks": { + "Beginner": 23, + "Good": 21, + "Good Start": 14, + "Moving Up": 16, + "Nice": 4, + "Solid": 9 + } + }, + "wordle": { + "legacyStats": { + "gamesPlayed": 70, + "gamesWon": 51, + "guesses": { + "1": 0, + "2": 1, + "3": 7, + "4": 11, + "5": 20, + "6": 12, + "fail": 19 + }, + "currentStreak": 1, + "maxStreak": 5, + "lastWonDayOffset": 1189, + "hasPlayed": true, + "autoOptInTimestamp": 1708273168957, + "hasMadeStatsChoice": false, + "timestamp": 1726831978 + }, + "calculatedStats": { + "gamesPlayed": 33, + "gamesWon": 26, + "guesses": { + "1": 0, + "2": 1, + "3": 4, + "4": 7, + "5": 10, + "6": 4, + "fail": 7 + }, + "currentStreak": 1, + "maxStreak": 5, + "lastWonPrintDate": "2024-09-20", + "lastCompletedPrintDate": "2024-09-20", + "hasPlayed": true, + "generation": 1 + } + } + } + } +} diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr new file mode 100644 index 00000000000..10a44f5d150 --- /dev/null +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'nyt_games', + '218886794', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'New York Times', + 'model': None, + 'model_id': None, + 'name': 'NYTGames', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..bb92d08f909 --- /dev/null +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_all_entities[sensor.nytgames_wordles_played-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nytgames_wordles_played', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wordles played', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wordles_played', + 'unique_id': '218886794-wordles_played', + 'unit_of_measurement': 'games', + }) +# --- +# name: test_all_entities[sensor.nytgames_wordles_played-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NYTGames Wordles played', + 'state_class': , + 'unit_of_measurement': 'games', + }), + 'context': , + 'entity_id': 'sensor.nytgames_wordles_played', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33', + }) +# --- diff --git a/tests/components/nyt_games/test_config_flow.py b/tests/components/nyt_games/test_config_flow.py new file mode 100644 index 00000000000..0cdd22aa96e --- /dev/null +++ b/tests/components/nyt_games/test_config_flow.py @@ -0,0 +1,104 @@ +"""Tests for the NYT Games config flow.""" + +from unittest.mock import AsyncMock + +from nyt_games import NYTGamesAuthenticationError, NYTGamesError +import pytest + +from homeassistant.components.nyt_games.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_nyt_games_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + 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["title"] == "NYT Games" + assert result["data"] == {CONF_TOKEN: "token"} + assert result["result"].unique_id == "218886794" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (NYTGamesAuthenticationError, "invalid_auth"), + (NYTGamesError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_nyt_games_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow errors.""" + mock_nyt_games_client.get_latest_stats.side_effect = exception + + 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.FORM + assert result["errors"] == {"base": error} + + mock_nyt_games_client.get_latest_stats.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "token"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate( + hass: HomeAssistant, + mock_nyt_games_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + 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.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/nyt_games/test_init.py b/tests/components/nyt_games/test_init.py new file mode 100644 index 00000000000..e8286066319 --- /dev/null +++ b/tests/components/nyt_games/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the NYT Games integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.nyt_games.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_nyt_games_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> 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 == snapshot diff --git a/tests/components/nyt_games/test_sensor.py b/tests/components/nyt_games/test_sensor.py new file mode 100644 index 00000000000..198164b56f1 --- /dev/null +++ b/tests/components/nyt_games/test_sensor.py @@ -0,0 +1,55 @@ +"""Tests for the NYT Games sensor platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from nyt_games import NYTGamesError +from syrupy import SnapshotAssertion + +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 + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_nyt_games_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_updating_exception( + hass: HomeAssistant, + mock_nyt_games_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test handling an exception during update.""" + await setup_integration(hass, mock_config_entry) + + mock_nyt_games_client.get_latest_stats.side_effect = NYTGamesError + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.nytgames_wordles_played").state == STATE_UNAVAILABLE + + mock_nyt_games_client.get_latest_stats.side_effect = None + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.nytgames_wordles_played").state != STATE_UNAVAILABLE From eaa25a33d73757520051e5453b3fe6a177547d7a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Sep 2024 18:09:53 +0200 Subject: [PATCH 1029/1309] Add more Wordle sensors (#126561) * Add more Wordle sensors * Add more Wordle sensors --- homeassistant/components/nyt_games/icons.json | 9 ++ homeassistant/components/nyt_games/sensor.py | 25 +++ .../components/nyt_games/strings.json | 9 ++ .../nyt_games/snapshots/test_sensor.ambr | 152 ++++++++++++++++++ 4 files changed, 195 insertions(+) diff --git a/homeassistant/components/nyt_games/icons.json b/homeassistant/components/nyt_games/icons.json index fe18cddc5c7..9e455cbf951 100644 --- a/homeassistant/components/nyt_games/icons.json +++ b/homeassistant/components/nyt_games/icons.json @@ -3,6 +3,15 @@ "sensor": { "wordles_played": { "default": "mdi:text-long" + }, + "wordles_won": { + "default": "mdi:trophy-award" + }, + "wordles_streak": { + "default": "mdi:calendar-range" + }, + "wordles_max_streak": { + "default": "mdi:calendar-month" } } } diff --git a/homeassistant/components/nyt_games/sensor.py b/homeassistant/components/nyt_games/sensor.py index 157b0311481..d677f2d166c 100644 --- a/homeassistant/components/nyt_games/sensor.py +++ b/homeassistant/components/nyt_games/sensor.py @@ -6,10 +6,12 @@ from dataclasses import dataclass from nyt_games import Wordle from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -34,6 +36,29 @@ SENSOR_TYPES: tuple[NYTGamesWordleSensorEntityDescription, ...] = ( native_unit_of_measurement="games", value_fn=lambda wordle: wordle.games_played, ), + NYTGamesWordleSensorEntityDescription( + key="wordles_won", + translation_key="wordles_won", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="games", + value_fn=lambda wordle: wordle.games_won, + ), + NYTGamesWordleSensorEntityDescription( + key="wordles_streak", + translation_key="wordles_streak", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfTime.DAYS, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda wordle: wordle.current_streak, + ), + NYTGamesWordleSensorEntityDescription( + key="wordles_max_streak", + translation_key="wordles_max_streak", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.DAYS, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda wordle: wordle.max_streak, + ), ) diff --git a/homeassistant/components/nyt_games/strings.json b/homeassistant/components/nyt_games/strings.json index ff7b0297f22..152d523ec57 100644 --- a/homeassistant/components/nyt_games/strings.json +++ b/homeassistant/components/nyt_games/strings.json @@ -23,6 +23,15 @@ "sensor": { "wordles_played": { "name": "Wordles played" + }, + "wordles_won": { + "name": "Wordles won" + }, + "wordles_streak": { + "name": "Current Wordle streak" + }, + "wordles_max_streak": { + "name": "Highest Wordle streak" } } } diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr index bb92d08f909..9f164f7da3b 100644 --- a/tests/components/nyt_games/snapshots/test_sensor.ambr +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -1,4 +1,106 @@ # serializer version: 1 +# name: test_all_entities[sensor.nytgames_current_wordle_streak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nytgames_current_wordle_streak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current Wordle streak', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wordles_streak', + 'unique_id': '218886794-wordles_streak', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nytgames_current_wordle_streak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'NYTGames Current Wordle streak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nytgames_current_wordle_streak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[sensor.nytgames_highest_wordle_streak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nytgames_highest_wordle_streak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest Wordle streak', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wordles_max_streak', + 'unique_id': '218886794-wordles_max_streak', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.nytgames_highest_wordle_streak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'NYTGames Highest Wordle streak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.nytgames_highest_wordle_streak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- # name: test_all_entities[sensor.nytgames_wordles_played-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -49,3 +151,53 @@ 'state': '33', }) # --- +# name: test_all_entities[sensor.nytgames_wordles_won-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.nytgames_wordles_won', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wordles won', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wordles_won', + 'unique_id': '218886794-wordles_won', + 'unit_of_measurement': 'games', + }) +# --- +# name: test_all_entities[sensor.nytgames_wordles_won-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'NYTGames Wordles won', + 'state_class': , + 'unit_of_measurement': 'games', + }), + 'context': , + 'entity_id': 'sensor.nytgames_wordles_won', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26', + }) +# --- From d0ed94ee8db67e28b038c30981d1ee3b4c73f39d Mon Sep 17 00:00:00 2001 From: Trekky12 Date: Mon, 23 Sep 2024 18:55:41 +0200 Subject: [PATCH 1030/1309] Remove trekky12 from pilight codeowners (#126559) Co-authored-by: Joostlek --- CODEOWNERS | 2 -- homeassistant/components/pilight/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index c95c457b27e..db7e1747647 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1104,8 +1104,6 @@ build.json @home-assistant/supervisor /tests/components/pi_hole/ @shenxn /homeassistant/components/picnic/ @corneyl /tests/components/picnic/ @corneyl -/homeassistant/components/pilight/ @trekky12 -/tests/components/pilight/ @trekky12 /homeassistant/components/ping/ @jpbede /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan diff --git a/homeassistant/components/pilight/manifest.json b/homeassistant/components/pilight/manifest.json index cd542f11a0c..341d0abdf67 100644 --- a/homeassistant/components/pilight/manifest.json +++ b/homeassistant/components/pilight/manifest.json @@ -1,7 +1,7 @@ { "domain": "pilight", "name": "Pilight", - "codeowners": ["@trekky12"], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/pilight", "iot_class": "local_push", "loggers": ["pilight"], From 86d8ddd289fcdc6531072447ccc53c132f37e6b3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 23 Sep 2024 18:57:32 +0200 Subject: [PATCH 1031/1309] Remove deprecated forecast key from template weather (#126132) Co-authored-by: Martin Hjelmare --- homeassistant/components/template/weather.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index ec6d1f08dd3..7f597f1d9a8 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -92,7 +92,6 @@ CONF_WIND_SPEED_TEMPLATE = "wind_speed_template" CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" CONF_OZONE_TEMPLATE = "ozone_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" -CONF_FORECAST_TEMPLATE = "forecast_template" CONF_FORECAST_DAILY_TEMPLATE = "forecast_daily_template" CONF_FORECAST_HOURLY_TEMPLATE = "forecast_hourly_template" CONF_FORECAST_TWICE_DAILY_TEMPLATE = "forecast_twice_daily_template" @@ -133,10 +132,7 @@ WEATHER_SCHEMA = vol.Schema( } ) -PLATFORM_SCHEMA = vol.All( - cv.deprecated(CONF_FORECAST_TEMPLATE), - WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema), -) +PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) async def async_setup_platform( From 4a424a66030dc658b8faa857a17d739a35b2f281 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 23 Sep 2024 19:10:30 +0200 Subject: [PATCH 1032/1309] Use Xiaomi Aqara gateway MAC address in `DeviceInfo.connections` (#126562) --- homeassistant/components/xiaomi_aqara/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py index 2b43b7e9315..db47015c0cf 100644 --- a/homeassistant/components/xiaomi_aqara/entity.py +++ b/homeassistant/components/xiaomi_aqara/entity.py @@ -83,6 +83,7 @@ class XiaomiDevice(Entity): if self._is_gateway: device_info = DeviceInfo( identifiers={(DOMAIN, self._device_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, self._device_id)}, model=self._model, ) else: From 28c2df37ed4f1e9fdd8a53419f6650b1fba5c46e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 23 Sep 2024 19:14:55 +0200 Subject: [PATCH 1033/1309] Remove deprecated YAML import from traccar (#125763) --- .../components/traccar/device_tracker.py | 141 +----------------- .../components/traccar_server/config_flow.py | 33 ---- .../traccar_server/test_config_flow.py | 126 ---------------- 3 files changed, 4 insertions(+), 296 deletions(-) diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 468d2fd4d05..c13f1970321 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -4,50 +4,15 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any -from pytraccar import ApiClient, TraccarException -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, - AsyncSeeCallback, - SourceType, - TrackerEntity, -) -from homeassistant.components.device_tracker.legacy import ( - YAML_DEVICES, - remove_device_from_config, -) -from homeassistant.config import load_yaml_config_file -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_EVENT, - CONF_HOST, - CONF_MONITORED_CONDITIONS, - CONF_PASSWORD, - CONF_PORT, - CONF_SSL, - CONF_USERNAME, - CONF_VERIFY_SSL, - EVENT_HOMEASSISTANT_STARTED, -) -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - Event, - HomeAssistant, - callback, -) -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import slugify from . import DOMAIN, TRACKER_UPDATE from .const import ( @@ -58,8 +23,6 @@ from .const import ( ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_SPEED, - CONF_MAX_ACCURACY, - CONF_SKIP_ACCURACY_ON, EVENT_ALARM, EVENT_ALL_EVENTS, EVENT_COMMAND_RESULT, @@ -104,28 +67,6 @@ EVENTS = [ EVENT_ALL_EVENTS, ] -PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=8082): cv.port, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Required(CONF_MAX_ACCURACY, default=0): cv.positive_int, - vol.Optional(CONF_SKIP_ACCURACY_ON, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_EVENT, default=[]): vol.All( - cv.ensure_list, - [vol.In(EVENTS)], - ), - } -) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -167,80 +108,6 @@ async def async_setup_entry( async_add_entities(entities) -async def async_setup_scanner( - hass: HomeAssistant, - config: ConfigType, - async_see: AsyncSeeCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> bool: - """Import configuration to the new integration.""" - api = ApiClient( - host=config[CONF_HOST], - port=config[CONF_PORT], - ssl=config[CONF_SSL], - username=config[CONF_USERNAME], - password=config[CONF_PASSWORD], - client_session=async_get_clientsession(hass, config[CONF_VERIFY_SSL]), - ) - - async def _run_import(_: Event): - known_devices: dict[str, dict[str, Any]] = {} - try: - known_devices = await hass.async_add_executor_job( - load_yaml_config_file, hass.config.path(YAML_DEVICES) - ) - except (FileNotFoundError, HomeAssistantError): - _LOGGER.debug( - "No valid known_devices.yaml found, " - "skip removal of devices from known_devices.yaml" - ) - - if known_devices: - traccar_devices: list[str] = [] - try: - resp = await api.get_devices() - traccar_devices = [slugify(device["name"]) for device in resp] - except TraccarException as exception: - _LOGGER.error("Error while getting device data: %s", exception) - return - - for dev_name in traccar_devices: - if dev_name in known_devices: - await hass.async_add_executor_job( - remove_device_from_config, hass, dev_name - ) - _LOGGER.debug("Removed device %s from known_devices.yaml", dev_name) - - if not hass.states.async_available(f"device_tracker.{dev_name}"): - hass.states.async_remove(f"device_tracker.{dev_name}") - - hass.async_create_task( - hass.config_entries.flow.async_init( - "traccar_server", - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.8.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Traccar", - }, - ) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _run_import) - return True - - class TraccarEntity(TrackerEntity, RestoreEntity): """Represent a tracked device.""" diff --git a/homeassistant/components/traccar_server/config_flow.py b/homeassistant/components/traccar_server/config_flow.py index a4d109030ae..b186424d32c 100644 --- a/homeassistant/components/traccar_server/config_flow.py +++ b/homeassistant/components/traccar_server/config_flow.py @@ -160,39 +160,6 @@ class TraccarServerConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import an entry.""" - configured_port = str(import_data[CONF_PORT]) - self._async_abort_entries_match( - { - CONF_HOST: import_data[CONF_HOST], - CONF_PORT: configured_port, - } - ) - if "all_events" in (imported_events := import_data.get("event", [])): - events = list(EVENTS.values()) - else: - events = imported_events - return self.async_create_entry( - title=f"{import_data[CONF_HOST]}:{configured_port}", - data={ - CONF_HOST: import_data[CONF_HOST], - CONF_PORT: configured_port, - CONF_SSL: import_data.get(CONF_SSL, False), - CONF_VERIFY_SSL: import_data.get(CONF_VERIFY_SSL, True), - CONF_USERNAME: import_data[CONF_USERNAME], - CONF_PASSWORD: import_data[CONF_PASSWORD], - }, - options={ - CONF_MAX_ACCURACY: import_data[CONF_MAX_ACCURACY], - CONF_EVENTS: events, - CONF_CUSTOM_ATTRIBUTES: import_data.get("monitored_conditions", []), - CONF_SKIP_ACCURACY_FILTER_FOR: import_data.get( - "skip_accuracy_filter_on", [] - ), - }, - ) - @staticmethod @callback def async_get_options_flow( diff --git a/tests/components/traccar_server/test_config_flow.py b/tests/components/traccar_server/test_config_flow.py index d9500441519..0418e4a5a72 100644 --- a/tests/components/traccar_server/test_config_flow.py +++ b/tests/components/traccar_server/test_config_flow.py @@ -1,23 +1,18 @@ """Test the Traccar Server config flow.""" from collections.abc import Generator -from typing import Any from unittest.mock import AsyncMock import pytest from pytraccar import TraccarException from homeassistant import config_entries - -# pylint: disable-next=hass-component-root-import -from homeassistant.components.traccar.device_tracker import PLATFORM_SCHEMA from homeassistant.components.traccar_server.const import ( CONF_CUSTOM_ATTRIBUTES, CONF_EVENTS, CONF_MAX_ACCURACY, CONF_SKIP_ACCURACY_FILTER_FOR, DOMAIN, - EVENTS, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -155,127 +150,6 @@ async def test_options( } -@pytest.mark.parametrize( - ("imported", "data", "options"), - [ - ( - { - CONF_HOST: "1.1.1.1", - CONF_PORT: 443, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - { - CONF_HOST: "1.1.1.1", - CONF_PORT: "443", - CONF_VERIFY_SSL: True, - CONF_SSL: False, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - { - CONF_EVENTS: [], - CONF_CUSTOM_ATTRIBUTES: [], - CONF_SKIP_ACCURACY_FILTER_FOR: [], - CONF_MAX_ACCURACY: 0, - }, - ), - ( - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_SSL: True, - "event": ["device_online", "device_offline"], - }, - { - CONF_HOST: "1.1.1.1", - CONF_PORT: "8082", - CONF_VERIFY_SSL: True, - CONF_SSL: True, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - { - CONF_EVENTS: ["device_online", "device_offline"], - CONF_CUSTOM_ATTRIBUTES: [], - CONF_SKIP_ACCURACY_FILTER_FOR: [], - CONF_MAX_ACCURACY: 0, - }, - ), - ( - { - CONF_HOST: "1.1.1.1", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_SSL: True, - "event": ["device_online", "device_offline", "all_events"], - }, - { - CONF_HOST: "1.1.1.1", - CONF_PORT: "8082", - CONF_VERIFY_SSL: True, - CONF_SSL: True, - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - { - CONF_EVENTS: list(EVENTS.values()), - CONF_CUSTOM_ATTRIBUTES: [], - CONF_SKIP_ACCURACY_FILTER_FOR: [], - CONF_MAX_ACCURACY: 0, - }, - ), - ], -) -async def test_import_from_yaml( - hass: HomeAssistant, - imported: dict[str, Any], - data: dict[str, Any], - options: dict[str, Any], - mock_traccar_api_client: Generator[AsyncMock], -) -> None: - """Test importing configuration from YAML.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=PLATFORM_SCHEMA({"platform": "traccar", **imported}), - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == f"{data[CONF_HOST]}:{data[CONF_PORT]}" - assert result["data"] == data - assert result["options"] == options - assert result["result"].state is ConfigEntryState.LOADED - - -async def test_abort_import_already_configured(hass: HomeAssistant) -> None: - """Test abort for existing server while importing.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "1.1.1.1", CONF_PORT: "8082"}, - ) - - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=PLATFORM_SCHEMA( - { - "platform": "traccar", - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - CONF_HOST: "1.1.1.1", - CONF_PORT: "8082", - } - ), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_abort_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 714a1cc31116e56dfcbab675708ec15fe989128f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Sep 2024 19:28:30 +0200 Subject: [PATCH 1034/1309] Bump nyt_games to 0.4.0 (#126564) --- homeassistant/components/nyt_games/config_flow.py | 4 ++-- homeassistant/components/nyt_games/coordinator.py | 2 +- homeassistant/components/nyt_games/entity.py | 4 +++- homeassistant/components/nyt_games/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nyt_games/conftest.py | 7 ++++--- tests/components/nyt_games/test_config_flow.py | 4 ++-- 8 files changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nyt_games/config_flow.py b/homeassistant/components/nyt_games/config_flow.py index b8687e58f72..fceeb5d13f1 100644 --- a/homeassistant/components/nyt_games/config_flow.py +++ b/homeassistant/components/nyt_games/config_flow.py @@ -24,7 +24,7 @@ class NYTGamesConfigFlow(ConfigFlow, domain=DOMAIN): session = async_get_clientsession(self.hass) client = NYTGamesClient(user_input[CONF_TOKEN], session=session) try: - latest_stats = await client.get_latest_stats() + user_id = await client.get_user_id() except NYTGamesAuthenticationError: errors["base"] = "invalid_auth" except NYTGamesError: @@ -32,7 +32,7 @@ class NYTGamesConfigFlow(ConfigFlow, domain=DOMAIN): except Exception: # noqa: BLE001 errors["base"] = "unknown" else: - await self.async_set_unique_id(str(latest_stats.user_id)) + 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_show_form( diff --git a/homeassistant/components/nyt_games/coordinator.py b/homeassistant/components/nyt_games/coordinator.py index 4234df2e0b1..d9e39ff814c 100644 --- a/homeassistant/components/nyt_games/coordinator.py +++ b/homeassistant/components/nyt_games/coordinator.py @@ -33,6 +33,6 @@ class NYTGamesCoordinator(DataUpdateCoordinator[Wordle]): async def _async_update_data(self) -> Wordle: try: - return (await self.client.get_latest_stats()).stats.wordle + return (await self.client.get_latest_stats()).wordle except NYTGamesError as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/nyt_games/entity.py b/homeassistant/components/nyt_games/entity.py index b5370805e27..eef1424d50b 100644 --- a/homeassistant/components/nyt_games/entity.py +++ b/homeassistant/components/nyt_games/entity.py @@ -15,7 +15,9 @@ class NYTGamesEntity(CoordinatorEntity[NYTGamesCoordinator]): def __init__(self, coordinator: NYTGamesCoordinator) -> None: """Initialize a NYT Games entity.""" super().__init__(coordinator) + unique_id = coordinator.config_entry.unique_id + assert unique_id is not None self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, + identifiers={(DOMAIN, unique_id)}, manufacturer="New York Times", ) diff --git a/homeassistant/components/nyt_games/manifest.json b/homeassistant/components/nyt_games/manifest.json index 94a731c52a4..922a29a489b 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.3.0"] + "requirements": ["nyt_games==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3257b170538..0d8cb385f4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1484,7 +1484,7 @@ numato-gpio==0.13.0 numpy==1.26.0 # homeassistant.components.nyt_games -nyt_games==0.3.0 +nyt_games==0.4.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5943e4b18aa..8fe9cbd42ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1232,7 +1232,7 @@ numato-gpio==0.13.0 numpy==1.26.0 # homeassistant.components.nyt_games -nyt_games==0.3.0 +nyt_games==0.4.0 # homeassistant.components.google oauth2client==4.1.3 diff --git a/tests/components/nyt_games/conftest.py b/tests/components/nyt_games/conftest.py index 324327174f5..3165021bc5b 100644 --- a/tests/components/nyt_games/conftest.py +++ b/tests/components/nyt_games/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import patch -from nyt_games.models import LatestData +from nyt_games.models import WordleStats import pytest from homeassistant.components.nyt_games.const import DOMAIN @@ -37,9 +37,10 @@ def mock_nyt_games_client() -> Generator[AsyncMock]: ), ): client = mock_client.return_value - client.get_latest_stats.return_value = LatestData.from_json( + client.get_latest_stats.return_value = WordleStats.from_json( load_fixture("latest.json", DOMAIN) - ).player + ).player.stats + client.get_user_id.return_value = 218886794 yield client diff --git a/tests/components/nyt_games/test_config_flow.py b/tests/components/nyt_games/test_config_flow.py index 0cdd22aa96e..144b3a3ad17 100644 --- a/tests/components/nyt_games/test_config_flow.py +++ b/tests/components/nyt_games/test_config_flow.py @@ -53,7 +53,7 @@ async def test_flow_errors( error: str, ) -> None: """Test flow errors.""" - mock_nyt_games_client.get_latest_stats.side_effect = exception + mock_nyt_games_client.get_user_id.side_effect = exception result = await hass.config_entries.flow.async_init( DOMAIN, @@ -70,7 +70,7 @@ async def test_flow_errors( assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - mock_nyt_games_client.get_latest_stats.side_effect = None + mock_nyt_games_client.get_user_id.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], From 788d9571b51a5240017beab55a6744be6b6780ff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 19:35:48 +0200 Subject: [PATCH 1035/1309] Add entity components to hass-enforce-class-module pylint plugin (#126545) --- pylint/plugins/hass_enforce_class_module.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index c0b363bbddf..2b8a836dfb4 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -65,8 +65,21 @@ _MODULES: dict[str, set[str]] = { "WeatherEntityDescription", }, } -_ENTITY_COMPONENTS: set[str] = {platform.value for platform in Platform} -_ENTITY_COMPONENTS.add("tag") +_ENTITY_COMPONENTS: set[str] = {platform.value for platform in Platform}.union( + { + "automation", + "counter", + "input_boolean", + "input_datetime", + "input_number", + "input_text", + "person", + "script", + "tag", + "template", + "timer", + } +) class HassEnforceClassModule(BaseChecker): From 88c751df7a59ea3444b997d16ebee01abd0752ab Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Mon, 23 Sep 2024 20:09:07 +0200 Subject: [PATCH 1036/1309] Fix point calls config entry to a platform multiple times (#126535) * fix multiple async_forward_entry_setups calls * ensure entity is at the right place --- homeassistant/components/point/__init__.py | 26 ++++++++++------------ homeassistant/components/point/const.py | 2 +- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index dff3acd9e6b..e446606f191 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -136,7 +136,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo entry.runtime_data = PointData(client) await async_setup_webhook(hass, entry, point_session) - # Entries are added in the client.update() function. + await hass.config_entries.async_forward_entry_setups( + entry, [*PLATFORMS, Platform.ALARM_CONTROL_PANEL] + ) return True @@ -225,27 +227,23 @@ class MinutPointClient: async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) return - async def new_device(device_id, platform): - """Load new device.""" - async_dispatcher_send( - self._hass, POINT_DISCOVERY_NEW.format(platform, DOMAIN), device_id - ) - self._is_available = True for home_id in self._client.homes: if home_id not in self._known_homes: - await self._hass.config_entries.async_forward_entry_setups( - self._config_entry, [Platform.ALARM_CONTROL_PANEL] + async_dispatcher_send( + self._hass, + POINT_DISCOVERY_NEW.format(Platform.ALARM_CONTROL_PANEL), + home_id, ) - await new_device(home_id, "alarm_control_panel") self._known_homes.add(home_id) for device in self._client.devices: if device.device_id not in self._known_devices: - await self._hass.config_entries.async_forward_entry_setups( - self._config_entry, PLATFORMS - ) for platform in PLATFORMS: - await new_device(device.device_id, platform) + async_dispatcher_send( + self._hass, + POINT_DISCOVERY_NEW.format(platform), + device.device_id, + ) self._known_devices.add(device.device_id) async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py index 1c2720749e6..1122cf69c0a 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -12,7 +12,7 @@ EVENT_RECEIVED = "point_webhook_received" SIGNAL_UPDATE_ENTITY = "point_update" SIGNAL_WEBHOOK = "point_webhook" -POINT_DISCOVERY_NEW = "point_new_{}_{}" +POINT_DISCOVERY_NEW = "point_new_{}" OAUTH2_AUTHORIZE = "https://api.minut.com/v8/oauth/authorize" OAUTH2_TOKEN = "https://api.minut.com/v8/oauth/token" From 9b96bc32ebcc464f4bf14a7ec721f12ca38bf5cd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:03:29 +0200 Subject: [PATCH 1037/1309] Add derived Entity classes in hass-enforce-class-module pylint plugin (#126494) --- homeassistant/components/homekit/type_cameras.py | 2 ++ homeassistant/components/roomba/braava.py | 2 +- homeassistant/components/roomba/entity.py | 2 +- homeassistant/components/roomba/roomba.py | 4 ++-- pylint/plugins/hass_enforce_class_module.py | 8 ++++---- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 13169c877a9..9e076f7d4d7 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -147,6 +147,8 @@ CONFIG_DEFAULTS = { @TYPES.register("Camera") +# False-positive on pylint, not a CameraEntity +# pylint: disable-next=hass-enforce-class-module class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] """Generate a Camera accessory.""" diff --git a/homeassistant/components/roomba/braava.py b/homeassistant/components/roomba/braava.py index 6a62a715a8a..8744561b2c5 100644 --- a/homeassistant/components/roomba/braava.py +++ b/homeassistant/components/roomba/braava.py @@ -27,7 +27,7 @@ BRAAVA_SPRAY_AMOUNT = [1, 2, 3] SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED -class BraavaJet(IRobotVacuum): +class BraavaJet(IRobotVacuum): # pylint: disable=hass-enforce-class-module """Braava Jet.""" _attr_supported_features = SUPPORT_BRAAVA diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index 07d05a28b89..10c3d36de12 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -156,7 +156,7 @@ class IRobotEntity(Entity): self.schedule_update_ha_state() -class IRobotVacuum(IRobotEntity, StateVacuumEntity): +class IRobotVacuum(IRobotEntity, StateVacuumEntity): # pylint: disable=hass-enforce-class-module """Base class for iRobot robots.""" _attr_name = None diff --git a/homeassistant/components/roomba/roomba.py b/homeassistant/components/roomba/roomba.py index a26f1912831..917fbb2bfff 100644 --- a/homeassistant/components/roomba/roomba.py +++ b/homeassistant/components/roomba/roomba.py @@ -20,7 +20,7 @@ FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE] SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED -class RoombaVacuum(IRobotVacuum): +class RoombaVacuum(IRobotVacuum): # pylint: disable=hass-enforce-class-module """Basic Roomba robot (without carpet boost).""" @property @@ -40,7 +40,7 @@ class RoombaVacuum(IRobotVacuum): return state_attrs -class RoombaVacuumCarpetBoost(RoombaVacuum): +class RoombaVacuumCarpetBoost(RoombaVacuum): # pylint: disable=hass-enforce-class-module """Roomba robot with carpet boost.""" _attr_fan_speed_list = FAN_SPEEDS diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index 2b8a836dfb4..6491a702b7f 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -20,14 +20,14 @@ _MODULES: dict[str, set[str]] = { "binary_sensor": {"BinarySensorEntity", "BinarySensorEntityDescription"}, "button": {"ButtonEntity", "ButtonEntityDescription"}, "calendar": {"CalendarEntity"}, - "camera": {"CameraEntity", "CameraEntityDescription"}, + "camera": {"Camera", "CameraEntityDescription"}, "climate": {"ClimateEntity", "ClimateEntityDescription"}, "coordinator": {"DataUpdateCoordinator"}, "conversation": {"ConversationEntity"}, "cover": {"CoverEntity", "CoverEntityDescription"}, "date": {"DateEntity", "DateEntityDescription"}, "datetime": {"DateTimeEntity", "DateTimeEntityDescription"}, - "device_tracker": {"DeviceTrackerEntity"}, + "device_tracker": {"DeviceTrackerEntity", "ScannerEntity", "TrackerEntity"}, "event": {"EventEntity", "EventEntityDescription"}, "fan": {"FanEntity", "FanEntityDescription"}, "geo_location": {"GeolocationEvent"}, @@ -54,8 +54,8 @@ _MODULES: dict[str, set[str]] = { "time": {"TimeEntity", "TimeEntityDescription"}, "todo": {"TodoListEntity"}, "tts": {"TextToSpeechEntity"}, - "update": {"UpdateEntityDescription"}, - "vacuum": {"VacuumEntity", "VacuumEntityDescription"}, + "update": {"UpdateEntity", "UpdateEntityDescription"}, + "vacuum": {"StateVacuumEntity", "VacuumEntity", "VacuumEntityDescription"}, "wake_word": {"WakeWordDetectionEntity"}, "water_heater": {"WaterHeaterEntity"}, "weather": { From d82bff1bc2d08c0b57a3f5ae018fe135e33edd3e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Sep 2024 21:48:11 +0200 Subject: [PATCH 1038/1309] Index config entry discovery_keys by discovery domain (#126563) * Index config entry discovery_keys by discovery domain * Add new signal * Update tests * Update homeassistant/config_entries.py Co-authored-by: J. Nick Koston * Fix imports --------- Co-authored-by: J. Nick Koston --- homeassistant/components/zeroconf/__init__.py | 16 +-- homeassistant/config_entries.py | 60 ++++++++-- tests/common.py | 3 +- .../aemet/snapshots/test_diagnostics.ambr | 4 +- .../airly/snapshots/test_diagnostics.ambr | 4 +- .../airnow/snapshots/test_diagnostics.ambr | 4 +- .../airvisual/snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../airzone/snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../components/androidtv/test_diagnostics.py | 2 +- tests/components/asuswrt/test_diagnostics.py | 2 +- .../axis/snapshots/test_diagnostics.ambr | 4 +- .../blink/snapshots/test_diagnostics.ambr | 4 +- .../braviatv/snapshots/test_diagnostics.ambr | 4 +- .../co2signal/snapshots/test_diagnostics.ambr | 4 +- .../coinbase/snapshots/test_diagnostics.ambr | 4 +- .../components/config/test_config_entries.py | 4 +- .../deconz/snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../ecovacs/snapshots/test_diagnostics.ambr | 8 +- .../elgato/snapshots/test_config_flow.ambr | 12 +- .../snapshots/test_config_flow.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 12 +- .../esphome/snapshots/test_diagnostics.ambr | 4 +- tests/components/esphome/test_diagnostics.py | 2 +- .../forecast_solar/snapshots/test_init.ambr | 4 +- .../fritz/snapshots/test_diagnostics.ambr | 4 +- tests/components/fritzbox/test_diagnostics.py | 2 +- .../fronius/snapshots/test_diagnostics.ambr | 4 +- .../fyta/snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_config_flow.ambr | 8 +- .../gios/snapshots/test_diagnostics.ambr | 4 +- .../goodwe/snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- tests/components/guardian/test_diagnostics.py | 2 +- .../snapshots/test_config_flow.ambr | 16 +-- .../snapshots/test_diagnostics.ambr | 4 +- .../imgw_pib/snapshots/test_diagnostics.ambr | 4 +- .../iqvia/snapshots/test_diagnostics.ambr | 4 +- .../kostal_plenticore/test_diagnostics.py | 2 +- .../snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../madvr/snapshots/test_diagnostics.ambr | 4 +- .../melcloud/snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../netatmo/snapshots/test_diagnostics.ambr | 4 +- .../nextdns/snapshots/test_diagnostics.ambr | 4 +- .../nice_go/snapshots/test_diagnostics.ambr | 4 +- tests/components/notion/test_diagnostics.py | 2 +- tests/components/nut/test_diagnostics.py | 2 +- .../onvif/snapshots/test_diagnostics.ambr | 4 +- tests/components/openuv/test_diagnostics.py | 2 +- .../snapshots/test_diagnostics.ambr | 4 +- .../pi_hole/snapshots/test_diagnostics.ambr | 4 +- .../proximity/snapshots/test_diagnostics.ambr | 4 +- .../components/purpleair/test_diagnostics.py | 2 +- .../rainforest_eagle/test_diagnostics.py | 2 +- .../rainforest_raven/test_diagnostics.py | 4 +- .../snapshots/test_diagnostics.ambr | 8 +- .../recollect_waste/test_diagnostics.py | 2 +- .../ridwell/snapshots/test_diagnostics.ambr | 4 +- .../components/samsungtv/test_diagnostics.py | 6 +- .../snapshots/test_diagnostics.ambr | 4 +- tests/components/shelly/test_diagnostics.py | 4 +- .../components/simplisafe/test_diagnostics.py | 2 +- .../solarlog/snapshots/test_diagnostics.ambr | 4 +- .../switcher_kis/test_diagnostics.py | 2 +- .../snapshots/test_diagnostics.ambr | 4 +- .../tailwind/snapshots/test_config_flow.ambr | 8 +- .../snapshots/test_diagnostics.ambr | 4 +- .../tractive/snapshots/test_diagnostics.ambr | 4 +- .../tuya/snapshots/test_config_flow.ambr | 12 +- .../snapshots/test_config_flow.ambr | 8 +- .../twinkly/snapshots/test_diagnostics.ambr | 4 +- .../unifi/snapshots/test_diagnostics.ambr | 4 +- .../uptime/snapshots/test_config_flow.ambr | 4 +- .../snapshots/test_diagnostics.ambr | 4 +- .../v2c/snapshots/test_diagnostics.ambr | 4 +- .../vicare/snapshots/test_diagnostics.ambr | 4 +- .../watttime/snapshots/test_diagnostics.ambr | 4 +- .../webmin/snapshots/test_diagnostics.ambr | 4 +- tests/components/webostv/test_diagnostics.py | 2 +- .../whirlpool/snapshots/test_diagnostics.ambr | 4 +- .../whois/snapshots/test_config_flow.ambr | 20 ++-- .../wyoming/snapshots/test_config_flow.ambr | 12 +- tests/components/zeroconf/test_init.py | 104 +++++++++------- .../zha/snapshots/test_diagnostics.ambr | 4 +- tests/snapshots/test_config_entries.ambr | 4 +- tests/test_config_entries.py | 112 +++++++++--------- 94 files changed, 378 insertions(+), 325 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 33057c501fd..196e16298ef 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -383,8 +383,8 @@ class ZeroconfDiscovery: async_dispatcher_connect( self.hass, - config_entries.SIGNAL_CONFIG_ENTRY_CHANGED, - self._handle_config_entry_changed, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, ) async def async_stop(self) -> None: @@ -393,20 +393,16 @@ class ZeroconfDiscovery: await self.async_service_browser.async_cancel() @callback - def _handle_config_entry_changed( + def _handle_config_entry_removed( self, change: config_entries.ConfigEntryChange, entry: config_entries.ConfigEntry, ) -> None: """Handle config entry changes.""" - if ( - change != config_entries.ConfigEntryChange.REMOVED - or entry.source != config_entries.SOURCE_IGNORE - or not (discovery_keys := entry.discovery_keys) - ): + if entry.source != config_entries.SOURCE_IGNORE: return - for discovery_key in discovery_keys: - if discovery_key.domain != DOMAIN or discovery_key.version != 1: + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1: continue _type = discovery_key.key[0] name = discovery_key.key[1] diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 489afb723b7..099b8ca2807 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -18,7 +18,7 @@ from copy import deepcopy from datetime import datetime from enum import Enum, StrEnum import functools -from functools import cached_property +from functools import cache, cached_property import logging from random import randint from types import MappingProxyType @@ -192,6 +192,15 @@ SIGNAL_CONFIG_ENTRY_CHANGED = SignalType["ConfigEntryChange", "ConfigEntry"]( "config_entry_changed" ) + +@cache +def signal_discovered_config_entry_removed( + discovery_domain: str, +) -> SignalType[ConfigEntryChange, ConfigEntry]: + """Format signal.""" + return SignalType(f"{discovery_domain}_discovered_config_entry_removed") + + NO_RESET_TRIES_STATES = { ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_IN_PROGRESS, @@ -318,7 +327,7 @@ class ConfigEntry(Generic[_DataT]): _tries: int created_at: datetime modified_at: datetime - discovery_keys: tuple[DiscoveryKey, ...] + discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]] def __init__( self, @@ -326,7 +335,7 @@ class ConfigEntry(Generic[_DataT]): created_at: datetime | None = None, data: Mapping[str, Any], disabled_by: ConfigEntryDisabler | None = None, - discovery_keys: tuple[DiscoveryKey, ...], + discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]], domain: str, entry_id: str | None = None, minor_version: int, @@ -955,7 +964,7 @@ class ConfigEntry(Generic[_DataT]): return { "created_at": self.created_at.isoformat(), "data": dict(self.data), - "discovery_keys": self.discovery_keys, + "discovery_keys": dict(self.discovery_keys), "disabled_by": self.disabled_by, "domain": self.domain, "entry_id": self.entry_id, @@ -1380,14 +1389,26 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): ) ) and entry.source == SOURCE_IGNORE - and discovery_key not in (known_discovery_keys := entry.discovery_keys) + and discovery_key + not in ( + known_discovery_keys := entry.discovery_keys.get( + discovery_key.domain, () + ) + ) ): - new_discovery_keys = tuple([*known_discovery_keys, discovery_key][-10:]) + new_discovery_keys = MappingProxyType( + entry.discovery_keys + | { + discovery_key.domain: tuple( + [*known_discovery_keys, discovery_key][-10:] + ) + } + ) _LOGGER.debug( "Updating discovery keys for %s entry %s %s -> %s", entry.domain, unique_id, - known_discovery_keys, + entry.discovery_keys, new_discovery_keys, ) self.config_entries.async_update_entry( @@ -1450,7 +1471,11 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): await self.config_entries.async_unload(existing_entry.entry_id) discovery_key = flow.context.get("discovery_key") - discovery_keys = (discovery_key,) if discovery_key else () + discovery_keys = ( + MappingProxyType({discovery_key.domain: (discovery_key,)}) + if discovery_key + else MappingProxyType({}) + ) entry = ConfigEntry( data=result["data"], discovery_keys=discovery_keys, @@ -1684,7 +1709,7 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]): if old_minor_version < 4: # Version 1.4 adds discovery_keys for entry in data["entries"]: - entry["discovery_keys"] = [] + entry["discovery_keys"] = {} if old_major_version > 1: raise NotImplementedError @@ -1846,6 +1871,13 @@ class ConfigEntries: ) self._async_dispatch(ConfigEntryChange.REMOVED, entry) + for discovery_domain in entry.discovery_keys: + async_dispatcher_send_internal( + self.hass, + signal_discovered_config_entry_removed(discovery_domain), + ConfigEntryChange.REMOVED, + entry, + ) return {"require_restart": not unload_success} @callback @@ -1873,8 +1905,11 @@ class ConfigEntries: created_at=datetime.fromisoformat(entry["created_at"]), data=entry["data"], disabled_by=try_parse_enum(ConfigEntryDisabler, entry["disabled_by"]), - discovery_keys=tuple( - DiscoveryKey.from_json_dict(key) for key in entry["discovery_keys"] + discovery_keys=MappingProxyType( + { + domain: tuple(DiscoveryKey.from_json_dict(key) for key in keys) + for domain, keys in entry["discovery_keys"].items() + } ), domain=entry["domain"], entry_id=entry_id, @@ -2032,7 +2067,8 @@ class ConfigEntries: entry: ConfigEntry, *, data: Mapping[str, Any] | UndefinedType = UNDEFINED, - discovery_keys: tuple[DiscoveryKey, ...] | UndefinedType = UNDEFINED, + discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]] + | UndefinedType = UNDEFINED, minor_version: int | UndefinedType = UNDEFINED, options: Mapping[str, Any] | UndefinedType = UNDEFINED, pref_disable_new_entities: bool | UndefinedType = UNDEFINED, diff --git a/tests/common.py b/tests/common.py index b0d471efe95..9603e7e2f29 100644 --- a/tests/common.py +++ b/tests/common.py @@ -990,7 +990,7 @@ class MockConfigEntry(config_entries.ConfigEntry): *, data=None, disabled_by=None, - discovery_keys=(), + discovery_keys=None, domain="test", entry_id=None, minor_version=1, @@ -1005,6 +1005,7 @@ class MockConfigEntry(config_entries.ConfigEntry): version=1, ) -> None: """Initialize a mock config entry.""" + discovery_keys = discovery_keys or {} kwargs = { "data": data or {}, "disabled_by": disabled_by, diff --git a/tests/components/aemet/snapshots/test_diagnostics.ambr b/tests/components/aemet/snapshots/test_diagnostics.ambr index 5200be7a54a..54546507dfa 100644 --- a/tests/components/aemet/snapshots/test_diagnostics.ambr +++ b/tests/components/aemet/snapshots/test_diagnostics.ambr @@ -11,8 +11,8 @@ 'name': 'AEMET', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'aemet', 'entry_id': '7442b231f139e813fc1939281123f220', 'minor_version': 1, diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr index 33f038cf6d4..ec501b2fd7e 100644 --- a/tests/components/airly/snapshots/test_diagnostics.ambr +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -9,8 +9,8 @@ 'name': 'Home', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'airly', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 4d9d94288de..3dd4788dc61 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -24,8 +24,8 @@ 'longitude': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'airnow', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr index bbc75b6b1c0..606d6082351 100644 --- a/tests/components/airvisual/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -36,8 +36,8 @@ 'longitude': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'airvisual', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr index a54b61812eb..cb1d3a7aee7 100644 --- a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -91,8 +91,8 @@ 'password': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'airvisual_pro', 'entry_id': '6a2b3770e53c28dc1eeb2515e906b0ce', 'minor_version': 1, diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index 6fc57f0483e..693550a3e1c 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -238,8 +238,8 @@ 'port': 3000, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'airzone', 'entry_id': '6e7a0798c1734ba81d26ced0e690eaec', 'minor_version': 1, diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 9f9285526e8..86b5c75b290 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -91,8 +91,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'airzone_cloud', 'entry_id': 'd186e31edb46d64d14b9b2f11f1ebd9f', 'minor_version': 1, diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr index ab6c485aabf..2f90b09d39f 100644 --- a/tests/components/ambient_station/snapshots/test_diagnostics.ambr +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'app_key': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'ambient_station', 'entry_id': '382cf7643f016fd48b3fe52163fe8877', 'minor_version': 1, diff --git a/tests/components/androidtv/test_diagnostics.py b/tests/components/androidtv/test_diagnostics.py index 2584f4b528c..40dba53bd9b 100644 --- a/tests/components/androidtv/test_diagnostics.py +++ b/tests/components/androidtv/test_diagnostics.py @@ -36,4 +36,4 @@ async def test_diagnostics( hass, hass_client, mock_config_entry ) - assert result["entry"] == entry_dict | {"discovery_keys": []} + assert result["entry"] == entry_dict | {"discovery_keys": {}} diff --git a/tests/components/asuswrt/test_diagnostics.py b/tests/components/asuswrt/test_diagnostics.py index 09df309953d..1acaf686567 100644 --- a/tests/components/asuswrt/test_diagnostics.py +++ b/tests/components/asuswrt/test_diagnostics.py @@ -38,4 +38,4 @@ async def test_diagnostics( hass, hass_client, mock_config_entry ) - assert result["entry"] == entry_dict | {"discovery_keys": []} + assert result["entry"] == entry_dict | {"discovery_keys": {}} diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr index 513357a76a3..ebd0061f416 100644 --- a/tests/components/axis/snapshots/test_diagnostics.ambr +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -37,8 +37,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'axis', 'entry_id': '676abe5b73621446e6550a2e86ffe3dd', 'minor_version': 1, diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr index 8d3c63b3d0a..edc2879a66b 100644 --- a/tests/components/blink/snapshots/test_diagnostics.ambr +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -38,8 +38,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'blink', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr index 3ffaba03426..cd29c647df7 100644 --- a/tests/components/braviatv/snapshots/test_diagnostics.ambr +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -9,8 +9,8 @@ 'use_psk': True, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'braviatv', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr index db61938ad90..9218e7343ec 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'location': '', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'co2signal', 'entry_id': '904a74160aa6f335526706bee85dfb83', 'minor_version': 1, diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index 665bb4b47fb..51bd946f140 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -30,8 +30,8 @@ 'api_token': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'coinbase', 'entry_id': '080272b77a4f80c41b94d7cdc86fd826', 'minor_version': 1, diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 879e2dac9ff..34697c2c2f1 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1326,11 +1326,11 @@ async def test_disable_entry_nonexisting( [ ( {}, - (), + {}, ), ( {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, - (DiscoveryKey(domain="test", key="blah", version=1),), + {"test": (DiscoveryKey(domain="test", key="blah", version=1),)}, ), ], ) diff --git a/tests/components/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr index fd543e6108c..1ca674a4fbe 100644 --- a/tests/components/deconz/snapshots/test_diagnostics.ambr +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -10,8 +10,8 @@ 'port': 80, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'deconz', 'entry_id': '1', 'minor_version': 1, diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr index fbc39882442..6a7ef1fc6d3 100644 --- a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -38,8 +38,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'devolo_home_control', 'entry_id': '123456', 'minor_version': 1, diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr index 86b6e441911..3da8c76c2b4 100644 --- a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -22,8 +22,8 @@ 'password': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'devolo_home_network', 'entry_id': '123456', 'minor_version': 1, diff --git a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr index 2091ebbf1f3..d407fe2dc5b 100644 --- a/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr +++ b/tests/components/dsmr_reader/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'data': dict({ }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'dsmr_reader', 'entry_id': 'TEST_ENTRY_ID', 'minor_version': 1, diff --git a/tests/components/ecovacs/snapshots/test_diagnostics.ambr b/tests/components/ecovacs/snapshots/test_diagnostics.ambr index 70f5d669b44..38c8a9a5ab9 100644 --- a/tests/components/ecovacs/snapshots/test_diagnostics.ambr +++ b/tests/components/ecovacs/snapshots/test_diagnostics.ambr @@ -8,8 +8,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'ecovacs', 'minor_version': 1, 'options': dict({ @@ -61,8 +61,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'ecovacs', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/elgato/snapshots/test_config_flow.ambr b/tests/components/elgato/snapshots/test_config_flow.ambr index e25e243db07..d5d005cff9c 100644 --- a/tests/components/elgato/snapshots/test_config_flow.ambr +++ b/tests/components/elgato/snapshots/test_config_flow.ambr @@ -24,8 +24,8 @@ 'port': 9123, }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'elgato', 'entry_id': , 'minor_version': 1, @@ -69,8 +69,8 @@ 'port': 9123, }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'elgato', 'entry_id': , 'minor_version': 1, @@ -113,8 +113,8 @@ 'port': 9123, }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'elgato', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr index c96d21df54a..72e504c97c8 100644 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ b/tests/components/energyzero/snapshots/test_config_flow.ambr @@ -18,8 +18,8 @@ 'data': dict({ }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'energyzero', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 7c1c6a5dfcc..76835098f27 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -10,8 +10,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'enphase_envoy', 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', 'minor_version': 1, @@ -443,8 +443,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'enphase_envoy', 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', 'minor_version': 1, @@ -917,8 +917,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'enphase_envoy', 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', 'minor_version': 1, diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index 3599f207806..4f7ea679b20 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -10,8 +10,8 @@ 'port': 6053, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'esphome', 'entry_id': '08d821dc059cf4f645cb024d32c8e708', 'minor_version': 1, diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 031bb5e0080..832e7d6572f 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -70,7 +70,7 @@ async def test_diagnostics_with_bluetooth( "port": 6053, }, "disabled_by": None, - "discovery_keys": [], + "discovery_keys": {}, "domain": "esphome", "entry_id": ANY, "minor_version": 1, diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr index e3eff26f2cd..6ae4c2f6198 100644 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -6,8 +6,8 @@ 'longitude': 4.42, }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'forecast_solar', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/fritz/snapshots/test_diagnostics.ambr b/tests/components/fritz/snapshots/test_diagnostics.ambr index 744f8c0fd22..53f7093a21b 100644 --- a/tests/components/fritz/snapshots/test_diagnostics.ambr +++ b/tests/components/fritz/snapshots/test_diagnostics.ambr @@ -52,8 +52,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'fritz', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/fritzbox/test_diagnostics.py b/tests/components/fritzbox/test_diagnostics.py index 62cbecb0472..21d70b4b6d6 100644 --- a/tests/components/fritzbox/test_diagnostics.py +++ b/tests/components/fritzbox/test_diagnostics.py @@ -30,4 +30,4 @@ async def test_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entries[0]) - assert result == {"entry": entry_dict | {"discovery_keys": []}, "data": {}} + assert result == {"entry": entry_dict | {"discovery_keys": {}}, "data": {}} diff --git a/tests/components/fronius/snapshots/test_diagnostics.ambr b/tests/components/fronius/snapshots/test_diagnostics.ambr index b596dbe5e1d..010de06e276 100644 --- a/tests/components/fronius/snapshots/test_diagnostics.ambr +++ b/tests/components/fronius/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'is_logger': True, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'fronius', 'entry_id': 'f1e2b9837e8adaed6fa682acaa216fd8', 'minor_version': 1, diff --git a/tests/components/fyta/snapshots/test_diagnostics.ambr b/tests/components/fyta/snapshots/test_diagnostics.ambr index 16e4724344e..5c68040f541 100644 --- a/tests/components/fyta/snapshots/test_diagnostics.ambr +++ b/tests/components/fyta/snapshots/test_diagnostics.ambr @@ -9,8 +9,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'fyta', 'entry_id': 'ce5f5431554d101905d31797e1232da8', 'minor_version': 2, diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 32c0d0821e7..11a287762b9 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -39,8 +39,8 @@ 'address': '00000000-0000-0000-0000-000000000001', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'gardena_bluetooth', 'entry_id': , 'minor_version': 1, @@ -250,8 +250,8 @@ 'address': '00000000-0000-0000-0000-000000000001', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'gardena_bluetooth', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index f70c1a56b0d..71e0afdc495 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'station_id': 123, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'gios', 'entry_id': '86129426118ae32020417a53712d6eef', 'minor_version': 1, diff --git a/tests/components/goodwe/snapshots/test_diagnostics.ambr b/tests/components/goodwe/snapshots/test_diagnostics.ambr index 336e31d5bfc..f52e47688e8 100644 --- a/tests/components/goodwe/snapshots/test_diagnostics.ambr +++ b/tests/components/goodwe/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'model_family': 'ET', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'goodwe', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index a274a596e82..edbbdb1ba28 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -6,8 +6,8 @@ 'project_id': '1234', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'google_assistant', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index f6ee1ebcfd5..faba2103000 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -41,7 +41,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, - "discovery_keys": [], + "discovery_keys": {}, }, "data": { "valve_controller": { diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr index 1d9e78eea2f..c3852a8c3fa 100644 --- a/tests/components/homewizard/snapshots/test_config_flow.ambr +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -20,8 +20,8 @@ 'ip_address': '127.0.0.1', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'homewizard', 'entry_id': , 'minor_version': 1, @@ -64,8 +64,8 @@ 'ip_address': '127.0.0.1', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'homewizard', 'entry_id': , 'minor_version': 1, @@ -108,8 +108,8 @@ 'ip_address': '127.0.0.1', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'homewizard', 'entry_id': , 'minor_version': 1, @@ -148,8 +148,8 @@ 'ip_address': '2.2.2.2', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'homewizard', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 41e1ae81741..0e7f0028e65 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -175,8 +175,8 @@ }), }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'husqvarna_automower', 'entry_id': 'automower_test', 'minor_version': 1, diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 1ca0c4874ea..494980ba4ce 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -6,8 +6,8 @@ 'station_id': '123', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'imgw_pib', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr index 8627f31841f..f2fa656cb0f 100644 --- a/tests/components/iqvia/snapshots/test_diagnostics.ambr +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -348,8 +348,8 @@ 'zip_code': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'iqvia', 'entry_id': '690ac4b7e99855fc5ee7b987a758d5cb', 'minor_version': 1, diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index de5966c9cc7..08f06684d9a 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -56,7 +56,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, - "discovery_keys": [], + "discovery_keys": {}, }, "client": { "version": "api_version='0.2.0' hostname='scb' name='PUCK RESTful API' sw_version='01.16.05025'", diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index f2ff166a62e..201bbbc971e 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -15,8 +15,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'lacrosse_view', 'entry_id': 'lacrosse_view_test_entry_id', 'minor_version': 1, diff --git a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr index cbbadcb63f9..c689d04949a 100644 --- a/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr +++ b/tests/components/linear_garage_door/snapshots/test_diagnostics.ambr @@ -63,8 +63,8 @@ 'site_id': 'test-site-id', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'linear_garage_door', 'entry_id': 'acefdd4b3a4a0911067d1cf51414201e', 'minor_version': 1, diff --git a/tests/components/madvr/snapshots/test_diagnostics.ambr b/tests/components/madvr/snapshots/test_diagnostics.ambr index fcfcca8c960..3a281391860 100644 --- a/tests/components/madvr/snapshots/test_diagnostics.ambr +++ b/tests/components/madvr/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'port': 44077, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'madvr', 'entry_id': '3bd2acb0e4f0476d40865546d0d91132', 'minor_version': 1, diff --git a/tests/components/melcloud/snapshots/test_diagnostics.ambr b/tests/components/melcloud/snapshots/test_diagnostics.ambr index b14ecce2bb0..e6a432de07e 100644 --- a/tests/components/melcloud/snapshots/test_diagnostics.ambr +++ b/tests/components/melcloud/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'data': dict({ }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'melcloud', 'entry_id': 'TEST_ENTRY_ID', 'minor_version': 1, diff --git a/tests/components/modern_forms/snapshots/test_diagnostics.ambr b/tests/components/modern_forms/snapshots/test_diagnostics.ambr index 336913dfdd4..75794aaca12 100644 --- a/tests/components/modern_forms/snapshots/test_diagnostics.ambr +++ b/tests/components/modern_forms/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'mac': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'modern_forms', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr index 27bfa9cd041..5b4b169c0fe 100644 --- a/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr +++ b/tests/components/motionblinds_ble/snapshots/test_diagnostics.ambr @@ -18,8 +18,8 @@ 'mac_code': 'CCCC', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'motionblinds_ble', 'entry_id': 'mock_entry_id', 'minor_version': 1, diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 8b775d2f1f5..463556ec657 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -608,8 +608,8 @@ 'webhook_id': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'netatmo', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index d024f54132e..827d6aeb6e5 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'profile_id': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'nextdns', 'entry_id': 'd9aa37407ddac7b964a99e86312288d6', 'minor_version': 1, diff --git a/tests/components/nice_go/snapshots/test_diagnostics.ambr b/tests/components/nice_go/snapshots/test_diagnostics.ambr index 60c43553e71..be67643c5b7 100644 --- a/tests/components/nice_go/snapshots/test_diagnostics.ambr +++ b/tests/components/nice_go/snapshots/test_diagnostics.ambr @@ -37,8 +37,8 @@ 'refresh_token': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'nice_go', 'entry_id': 'acefdd4b3a4a0911067d1cf51414201e', 'minor_version': 1, diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 2156adfb57c..890ce2dfc4a 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -36,7 +36,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, - "discovery_keys": [], + "discovery_keys": {}, }, "data": { "bridges": [ diff --git a/tests/components/nut/test_diagnostics.py b/tests/components/nut/test_diagnostics.py index 948c3e9da27..2586f224d73 100644 --- a/tests/components/nut/test_diagnostics.py +++ b/tests/components/nut/test_diagnostics.py @@ -39,5 +39,5 @@ async def test_diagnostics( result = await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry ) - assert result["entry"] == entry_dict | {"discovery_keys": []} + assert result["entry"] == entry_dict | {"discovery_keys": {}} assert result["nut_data"] == nut_data_dict diff --git a/tests/components/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr index 78191fa4600..c8a9ff75d62 100644 --- a/tests/components/onvif/snapshots/test_diagnostics.ambr +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -11,8 +11,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'onvif', 'entry_id': '1', 'minor_version': 1, diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index cf7e7b05ec4..61b68b5ad90 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -38,7 +38,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, - "discovery_keys": [], + "discovery_keys": {}, }, "data": { "protection_window": { diff --git a/tests/components/philips_js/snapshots/test_diagnostics.ambr b/tests/components/philips_js/snapshots/test_diagnostics.ambr index 20d9b0e0023..4f7a6176634 100644 --- a/tests/components/philips_js/snapshots/test_diagnostics.ambr +++ b/tests/components/philips_js/snapshots/test_diagnostics.ambr @@ -85,8 +85,8 @@ }), }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'philips_js', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index b663f8ed57e..3094fcef24b 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -23,8 +23,8 @@ 'verify_ssl': True, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'pi_hole', 'entry_id': 'pi_hole_mock_entry', 'minor_version': 1, diff --git a/tests/components/proximity/snapshots/test_diagnostics.ambr b/tests/components/proximity/snapshots/test_diagnostics.ambr index 34bb64b3420..3d9673ffd90 100644 --- a/tests/components/proximity/snapshots/test_diagnostics.ambr +++ b/tests/components/proximity/snapshots/test_diagnostics.ambr @@ -93,8 +93,8 @@ 'zone': 'zone.home', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'proximity', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/purpleair/test_diagnostics.py b/tests/components/purpleair/test_diagnostics.py index 191115c4774..ae4b28567be 100644 --- a/tests/components/purpleair/test_diagnostics.py +++ b/tests/components/purpleair/test_diagnostics.py @@ -37,7 +37,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, - "discovery_keys": [], + "discovery_keys": {}, }, "data": { "fields": [ diff --git a/tests/components/rainforest_eagle/test_diagnostics.py b/tests/components/rainforest_eagle/test_diagnostics.py index e68e3cd4ce0..5aa460415b3 100644 --- a/tests/components/rainforest_eagle/test_diagnostics.py +++ b/tests/components/rainforest_eagle/test_diagnostics.py @@ -27,7 +27,7 @@ async def test_entry_diagnostics( config_entry_dict["data"][CONF_CLOUD_ID] = REDACTED assert result == { - "config_entry": config_entry_dict | {"discovery_keys": []}, + "config_entry": config_entry_dict | {"discovery_keys": {}}, "data": { var["Name"]: var["Value"] for var in MOCK_200_RESPONSE_WITHOUT_PRICE.values() diff --git a/tests/components/rainforest_raven/test_diagnostics.py b/tests/components/rainforest_raven/test_diagnostics.py index 04e125b05d9..93cf12b434f 100644 --- a/tests/components/rainforest_raven/test_diagnostics.py +++ b/tests/components/rainforest_raven/test_diagnostics.py @@ -40,7 +40,7 @@ async def test_entry_diagnostics_no_meters( config_entry_dict["data"][CONF_MAC] = REDACTED assert result == { - "config_entry": config_entry_dict | {"discovery_keys": []}, + "config_entry": config_entry_dict | {"discovery_keys": {}}, "data": { "Meters": {}, "NetworkInfo": {**asdict(NETWORK_INFO), "device_mac_id": REDACTED}, @@ -58,7 +58,7 @@ async def test_entry_diagnostics( config_entry_dict["data"][CONF_MAC] = REDACTED assert result == { - "config_entry": config_entry_dict | {"discovery_keys": []}, + "config_entry": config_entry_dict | {"discovery_keys": {}}, "data": { "Meters": { "**REDACTED0**": { diff --git a/tests/components/rainmachine/snapshots/test_diagnostics.ambr b/tests/components/rainmachine/snapshots/test_diagnostics.ambr index ed1a3dc5961..acd5fd165b4 100644 --- a/tests/components/rainmachine/snapshots/test_diagnostics.ambr +++ b/tests/components/rainmachine/snapshots/test_diagnostics.ambr @@ -1131,8 +1131,8 @@ 'ssl': True, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'rainmachine', 'entry_id': '81bd010ed0a63b705f6da8407cb26d4b', 'minor_version': 1, @@ -2262,8 +2262,8 @@ 'ssl': True, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'rainmachine', 'entry_id': '81bd010ed0a63b705f6da8407cb26d4b', 'minor_version': 1, diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index 7ae4ff4fb9c..24c690bcb37 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -33,7 +33,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, - "discovery_keys": [], + "discovery_keys": {}, }, "data": [ { diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr index 9e5b4eefb3f..b03d87c7a89 100644 --- a/tests/components/ridwell/snapshots/test_diagnostics.ambr +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -34,8 +34,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'ridwell', 'entry_id': '11554ec901379b9cc8f5a6c1d11ce978', 'minor_version': 1, diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index 7c2fd07d322..0319d5dd8dd 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -42,7 +42,7 @@ async def test_entry_diagnostics( "token": REDACTED, }, "disabled_by": None, - "discovery_keys": [], + "discovery_keys": {}, "domain": "samsungtv", "entry_id": "123456", "minor_version": 2, @@ -82,7 +82,7 @@ async def test_entry_diagnostics_encrypted( "session_id": REDACTED, }, "disabled_by": None, - "discovery_keys": [], + "discovery_keys": {}, "domain": "samsungtv", "entry_id": "123456", "minor_version": 2, @@ -121,7 +121,7 @@ async def test_entry_diagnostics_encrypte_offline( "session_id": REDACTED, }, "disabled_by": None, - "discovery_keys": [], + "discovery_keys": {}, "domain": "samsungtv", "entry_id": "123456", "minor_version": 2, diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index c27e8170d3e..237d3eab257 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'port': 80, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'screenlogic', 'entry_id': 'screenlogictest', 'minor_version': 1, diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index a82ac7b7b0f..f576524ba60 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -45,7 +45,7 @@ async def test_block_config_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == { - "entry": entry_dict | {"discovery_keys": []}, + "entry": entry_dict | {"discovery_keys": {}}, "bluetooth": "not initialized", "device_info": { "name": "Test name", @@ -105,7 +105,7 @@ async def test_rpc_config_entry_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert result == { - "entry": entry_dict | {"discovery_keys": []}, + "entry": entry_dict | {"discovery_keys": {}}, "bluetooth": { "scanner": { "connectable": False, diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index fb863fa3bd0..d5479f00b06 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -31,7 +31,7 @@ async def test_entry_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, - "discovery_keys": [], + "discovery_keys": {}, }, "subscription_data": { "12345": { diff --git a/tests/components/solarlog/snapshots/test_diagnostics.ambr b/tests/components/solarlog/snapshots/test_diagnostics.ambr index 0ef8f3a735f..4b37ea63dce 100644 --- a/tests/components/solarlog/snapshots/test_diagnostics.ambr +++ b/tests/components/solarlog/snapshots/test_diagnostics.ambr @@ -9,8 +9,8 @@ 'password': 'pwd', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'solarlog', 'entry_id': 'ce5f5431554d101905d31797e1232da8', 'minor_version': 3, diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index 07a89fad7ec..53572085f9b 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -68,6 +68,6 @@ async def test_diagnostics( "disabled_by": None, "created_at": ANY, "modified_at": ANY, - "discovery_keys": [], + "discovery_keys": {}, }, } diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index e7a99abee5e..303074e3c2c 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -34,8 +34,8 @@ 'data': dict({ }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'systemmonitor', 'minor_version': 3, 'options': dict({ diff --git a/tests/components/tailwind/snapshots/test_config_flow.ambr b/tests/components/tailwind/snapshots/test_config_flow.ambr index 9cc1dc9c6a6..09bf25cb96e 100644 --- a/tests/components/tailwind/snapshots/test_config_flow.ambr +++ b/tests/components/tailwind/snapshots/test_config_flow.ambr @@ -22,8 +22,8 @@ 'token': '987654', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'tailwind', 'entry_id': , 'minor_version': 1, @@ -68,8 +68,8 @@ 'token': '987654', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'tailwind', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr index 861509c5c85..3180c7c0b1d 100644 --- a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -26,8 +26,8 @@ ]), }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'tankerkoenig', 'entry_id': '8036b4412f2fae6bb9dbab7fe8e37f87', 'minor_version': 1, diff --git a/tests/components/tractive/snapshots/test_diagnostics.ambr b/tests/components/tractive/snapshots/test_diagnostics.ambr index a777107bd5e..11427a84801 100644 --- a/tests/components/tractive/snapshots/test_diagnostics.ambr +++ b/tests/components/tractive/snapshots/test_diagnostics.ambr @@ -7,8 +7,8 @@ 'password': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'tractive', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', 'minor_version': 1, diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr index b85a8ca1dd3..a5a68a12a22 100644 --- a/tests/components/tuya/snapshots/test_config_flow.ambr +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -14,8 +14,8 @@ 'user_code': '12345', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'tuya', 'entry_id': , 'minor_version': 1, @@ -44,8 +44,8 @@ 'user_code': '12345', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'tuya', 'entry_id': , 'minor_version': 1, @@ -97,8 +97,8 @@ 'user_code': '12345', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'tuya', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/twentemilieu/snapshots/test_config_flow.ambr b/tests/components/twentemilieu/snapshots/test_config_flow.ambr index 2a8e389f009..a98119e81c9 100644 --- a/tests/components/twentemilieu/snapshots/test_config_flow.ambr +++ b/tests/components/twentemilieu/snapshots/test_config_flow.ambr @@ -26,8 +26,8 @@ 'post_code': '1234AB', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'twentemilieu', 'entry_id': , 'minor_version': 1, @@ -72,8 +72,8 @@ 'post_code': '1234AB', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'twentemilieu', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 9274d2278ec..28ec98cf572 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -27,8 +27,8 @@ 'name': 'twinkly_test_device_name', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'twinkly', 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', 'minor_version': 1, diff --git a/tests/components/unifi/snapshots/test_diagnostics.ambr b/tests/components/unifi/snapshots/test_diagnostics.ambr index 11beeafdbc6..4ba90a00113 100644 --- a/tests/components/unifi/snapshots/test_diagnostics.ambr +++ b/tests/components/unifi/snapshots/test_diagnostics.ambr @@ -27,8 +27,8 @@ 'verify_ssl': False, }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'unifi', 'entry_id': '1', 'minor_version': 1, diff --git a/tests/components/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr index 968093c1345..38312667375 100644 --- a/tests/components/uptime/snapshots/test_config_flow.ambr +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -17,8 +17,8 @@ 'data': dict({ }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'uptime', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index 2eec7b358c3..c69164264da 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -5,8 +5,8 @@ 'data': dict({ }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'utility_meter', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 181e5094c4f..96567b80c54 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -6,8 +6,8 @@ 'host': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'v2c', 'entry_id': 'da58ee91f38c2406c2a36d0a1a7f8569', 'minor_version': 1, diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index 818aa9f226b..ae9b05389c7 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4721,8 +4721,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'vicare', 'entry_id': '1234', 'minor_version': 1, diff --git a/tests/components/watttime/snapshots/test_diagnostics.ambr b/tests/components/watttime/snapshots/test_diagnostics.ambr index dd4252eeadd..0c137acc36b 100644 --- a/tests/components/watttime/snapshots/test_diagnostics.ambr +++ b/tests/components/watttime/snapshots/test_diagnostics.ambr @@ -18,8 +18,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'watttime', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/webmin/snapshots/test_diagnostics.ambr b/tests/components/webmin/snapshots/test_diagnostics.ambr index 5e889bd87a7..8299b0eafba 100644 --- a/tests/components/webmin/snapshots/test_diagnostics.ambr +++ b/tests/components/webmin/snapshots/test_diagnostics.ambr @@ -237,8 +237,8 @@ 'data': dict({ }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'webmin', 'entry_id': '**REDACTED**', 'minor_version': 1, diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index 74a7a50ded4..3d7cb00e021 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -60,6 +60,6 @@ async def test_diagnostics( "disabled_by": None, "created_at": entry.created_at.isoformat(), "modified_at": entry.modified_at.isoformat(), - "discovery_keys": [], + "discovery_keys": {}, }, } diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index b922c221908..c60ce17b952 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -29,8 +29,8 @@ 'username': '**REDACTED**', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'whirlpool', 'minor_version': 1, 'options': dict({ diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index aaf95513219..937502d4d6c 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -20,8 +20,8 @@ 'domain': 'example.com', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'whois', 'entry_id': , 'minor_version': 1, @@ -60,8 +60,8 @@ 'domain': 'example.com', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'whois', 'entry_id': , 'minor_version': 1, @@ -100,8 +100,8 @@ 'domain': 'example.com', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'whois', 'entry_id': , 'minor_version': 1, @@ -140,8 +140,8 @@ 'domain': 'example.com', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'whois', 'entry_id': , 'minor_version': 1, @@ -180,8 +180,8 @@ 'domain': 'example.com', }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'whois', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index 58617d9109d..8206c9bf20e 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -26,8 +26,8 @@ 'port': 10200, }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'wyoming', 'entry_id': , 'minor_version': 1, @@ -72,8 +72,8 @@ 'port': 10200, }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'wyoming', 'entry_id': , 'minor_version': 1, @@ -118,8 +118,8 @@ 'port': 12345, }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'wyoming', 'entry_id': , 'minor_version': 1, diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 229329bea61..5bcff48749d 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1409,42 +1409,50 @@ async def test_zeroconf_removed(hass: HomeAssistant) -> None: # Matching discovery key ( "shelly", - ( - DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, - ), - ), + { + "zeroconf": ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ) + }, ), # Matching discovery key ( "shelly", - ( - DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, + { + "zeroconf": ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), ), - DiscoveryKey( - domain="other", - key="blah", - version=1, + "other": ( + DiscoveryKey( + domain="other", + key="blah", + version=1, + ), ), - ), + }, ), # Matching discovery key, other domain # Note: Rediscovery is not currently restricted to the domain of the removed # entry. Such a check can be added if needed. ( "comp", - ( - DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, - ), - ), + { + "zeroconf": ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ) + }, ), ], ) @@ -1538,26 +1546,30 @@ async def test_zeroconf_rediscover( # Discovery key from other domain ( "shelly", - ( - DiscoveryKey( - domain="bluetooth", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, - ), - ), + { + "bluetooth": ( + DiscoveryKey( + domain="bluetooth", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ) + }, config_entries.SOURCE_IGNORE, "mock-unique-id", ), # Discovery key from the future ( "shelly", - ( - DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=2, - ), - ), + { + "zeroconf": ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=2, + ), + ) + }, config_entries.SOURCE_IGNORE, "mock-unique-id", ), @@ -1656,13 +1668,15 @@ async def test_zeroconf_rediscover_no_match( # Source not SOURCE_IGNORE ( "shelly", - ( - DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, - ), - ), + { + "bluetooth": ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ) + }, config_entries.SOURCE_ZEROCONF, "mock-unique-id", ), diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 2745496256b..f46a06e84b8 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -93,8 +93,8 @@ 'radio_type': 'ezsp', }), 'disabled_by': None, - 'discovery_keys': list([ - ]), + 'discovery_keys': dict({ + }), 'domain': 'zha', 'minor_version': 1, 'options': dict({ diff --git a/tests/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr index 35f6272b772..e30b2824af2 100644 --- a/tests/snapshots/test_config_entries.ambr +++ b/tests/snapshots/test_config_entries.ambr @@ -5,8 +5,8 @@ 'data': dict({ }), 'disabled_by': None, - 'discovery_keys': tuple( - ), + 'discovery_keys': dict({ + }), 'domain': 'test', 'entry_id': 'mock-entry', 'minor_version': 1, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index ebbe4c5fa2c..57730a9f014 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2756,11 +2756,11 @@ async def test_finish_flow_aborts_progress( [ ( {}, - (), + {}, ), ( {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, - (DiscoveryKey(domain="test", key="blah", version=1),), + {"test": (DiscoveryKey(domain="test", key="blah", version=1),)}, ), ], ) @@ -2921,108 +2921,114 @@ async def test_manual_add_overrides_ignored_entry_singleton( [ # No discovery key ( - (), + {}, config_entries.SOURCE_IGNORE, "mock-unique-id", {}, config_entries.SOURCE_ZEROCONF, data_entry_flow.FlowResultType.ABORT, - (), + {}, ), # Discovery key added to ignored entry data ( - (), + {}, config_entries.SOURCE_IGNORE, "mock-unique-id", - {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, config_entries.SOURCE_ZEROCONF, data_entry_flow.FlowResultType.ABORT, - ({"domain": "test", "key": "blah", "version": 1},), + {"test": (DiscoveryKey(domain="test", key="blah", version=1),)}, ), # Discovery key added to ignored entry data ( - ({"domain": "test", "key": "bleh", "version": 1},), + {"test": (DiscoveryKey(domain="test", key="bleh", version=1),)}, config_entries.SOURCE_IGNORE, "mock-unique-id", - {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, config_entries.SOURCE_ZEROCONF, data_entry_flow.FlowResultType.ABORT, - ( - {"domain": "test", "key": "bleh", "version": 1}, - {"domain": "test", "key": "blah", "version": 1}, - ), + { + "test": ( + DiscoveryKey(domain="test", key="bleh", version=1), + DiscoveryKey(domain="test", key="blah", version=1), + ) + }, ), # Discovery key added to ignored entry data ( - ( - {"domain": "test", "key": "1", "version": 1}, - {"domain": "test", "key": "2", "version": 1}, - {"domain": "test", "key": "3", "version": 1}, - {"domain": "test", "key": "4", "version": 1}, - {"domain": "test", "key": "5", "version": 1}, - {"domain": "test", "key": "6", "version": 1}, - {"domain": "test", "key": "7", "version": 1}, - {"domain": "test", "key": "8", "version": 1}, - {"domain": "test", "key": "9", "version": 1}, - {"domain": "test", "key": "10", "version": 1}, - ), + { + "test": ( + DiscoveryKey(domain="test", key="1", version=1), + DiscoveryKey(domain="test", key="2", version=1), + DiscoveryKey(domain="test", key="3", version=1), + DiscoveryKey(domain="test", key="4", version=1), + DiscoveryKey(domain="test", key="5", version=1), + DiscoveryKey(domain="test", key="6", version=1), + DiscoveryKey(domain="test", key="7", version=1), + DiscoveryKey(domain="test", key="8", version=1), + DiscoveryKey(domain="test", key="9", version=1), + DiscoveryKey(domain="test", key="10", version=1), + ) + }, config_entries.SOURCE_IGNORE, "mock-unique-id", - {"discovery_key": {"domain": "test", "key": "11", "version": 1}}, + {"discovery_key": DiscoveryKey(domain="test", key="11", version=1)}, config_entries.SOURCE_ZEROCONF, data_entry_flow.FlowResultType.ABORT, - ( - {"domain": "test", "key": "2", "version": 1}, - {"domain": "test", "key": "3", "version": 1}, - {"domain": "test", "key": "4", "version": 1}, - {"domain": "test", "key": "5", "version": 1}, - {"domain": "test", "key": "6", "version": 1}, - {"domain": "test", "key": "7", "version": 1}, - {"domain": "test", "key": "8", "version": 1}, - {"domain": "test", "key": "9", "version": 1}, - {"domain": "test", "key": "10", "version": 1}, - {"domain": "test", "key": "11", "version": 1}, - ), + { + "test": ( + DiscoveryKey(domain="test", key="2", version=1), + DiscoveryKey(domain="test", key="3", version=1), + DiscoveryKey(domain="test", key="4", version=1), + DiscoveryKey(domain="test", key="5", version=1), + DiscoveryKey(domain="test", key="6", version=1), + DiscoveryKey(domain="test", key="7", version=1), + DiscoveryKey(domain="test", key="8", version=1), + DiscoveryKey(domain="test", key="9", version=1), + DiscoveryKey(domain="test", key="10", version=1), + DiscoveryKey(domain="test", key="11", version=1), + ) + }, ), # Discovery key already in ignored entry data ( - ({"domain": "test", "key": "blah", "version": 1},), + {"test": (DiscoveryKey(domain="test", key="blah", version=1),)}, config_entries.SOURCE_IGNORE, "mock-unique-id", - {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, config_entries.SOURCE_ZEROCONF, data_entry_flow.FlowResultType.ABORT, - ({"domain": "test", "key": "blah", "version": 1},), + {"test": (DiscoveryKey(domain="test", key="blah", version=1),)}, ), # Discovery key not added to user entry data ( - (), + {}, config_entries.SOURCE_USER, "mock-unique-id", - {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, config_entries.SOURCE_ZEROCONF, data_entry_flow.FlowResultType.ABORT, - (), + {}, ), # Flow not aborted when unique id is not matching ( - (), + {}, config_entries.SOURCE_IGNORE, "mock-unique-id-2", - {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, config_entries.SOURCE_ZEROCONF, data_entry_flow.FlowResultType.FORM, - (), + {}, ), # Flow not aborted when user initiated flow ( - (), + {}, config_entries.SOURCE_IGNORE, "mock-unique-id-2", - {"discovery_key": {"domain": "test", "key": "blah", "version": 1}}, + {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, config_entries.SOURCE_USER, data_entry_flow.FlowResultType.FORM, - (), + {}, ), ], ) @@ -5251,7 +5257,7 @@ async def test_unhashable_unique_id( entries = config_entries.ConfigEntryItems(hass) entry = config_entries.ConfigEntry( data={}, - discovery_keys=(), + discovery_keys={}, domain="test", entry_id="mock_id", minor_version=1, @@ -5284,7 +5290,7 @@ async def test_hashable_non_string_unique_id( entries = config_entries.ConfigEntryItems(hass) entry = config_entries.ConfigEntry( data={}, - discovery_keys=(), + discovery_keys={}, domain="test", entry_id="mock_id", minor_version=1, @@ -6186,7 +6192,7 @@ async def test_migration_from_1_2( "created_at": "1970-01-01T00:00:00+00:00", "data": {}, "disabled_by": None, - "discovery_keys": [], + "discovery_keys": {}, "domain": "sun", "entry_id": "0a8bd02d0d58c7debf5daf7941c9afe2", "minor_version": 1, From dbf080194b5e44203388d3fb3efa5be771042640 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Sep 2024 15:43:33 -0500 Subject: [PATCH 1039/1309] Bump cached-ipaddress to 0.6.0 (#126571) --- homeassistant/components/dhcp/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/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 6023e55faf3..f5d431d6bac 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -16,6 +16,6 @@ "requirements": [ "aiodhcpwatcher==1.0.2", "aiodiscover==2.1.0", - "cached-ipaddress==0.5.0" + "cached-ipaddress==0.6.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6dd9b32f06c..dd43e8a7aec 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ bleak==0.22.2 bluetooth-adapters==0.19.4 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.20.0 -cached-ipaddress==0.5.0 +cached-ipaddress==0.6.0 certifi>=2021.5.30 ciso8601==2.3.1 cryptography==43.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0d8cb385f4d..3ff55b6089e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -658,7 +658,7 @@ btsmarthub-devicelist==0.2.3 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.5.0 +cached-ipaddress==0.6.0 # homeassistant.components.caldav caldav==1.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8fe9cbd42ff..58ffa771a2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -569,7 +569,7 @@ bthome-ble==3.9.1 buienradar==1.0.6 # homeassistant.components.dhcp -cached-ipaddress==0.5.0 +cached-ipaddress==0.6.0 # homeassistant.components.caldav caldav==1.3.9 From d26c449d87e1c509665cf6ff9f74bd16d342b547 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Sep 2024 17:28:32 -0500 Subject: [PATCH 1040/1309] Bump yarl to 1.12.0 (#126576) This is a prereq for aiohttp 3.10.6 which has some fixes that need yarl 1.12.0+ --- 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 dd43e8a7aec..dd7ed63213c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -63,7 +63,7 @@ uv==0.4.12 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.11.1 +yarl==1.12.0 zeroconf==0.134.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 2fa6ffe2c31..eb8fd58d3be 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.11.1", + "yarl==1.12.0", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index b7f5c8c6ec2..8e2b64afeb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,4 +42,4 @@ uv==0.4.12 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.11.1 +yarl==1.12.0 From fb45f4fcea5f64bcbedc21a87aa5fc75a1e65247 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Sep 2024 19:19:41 -0500 Subject: [PATCH 1041/1309] Bump yarl to 1.12.1 (#126580) --- 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 dd7ed63213c..9b41d3da10b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -63,7 +63,7 @@ uv==0.4.12 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 -yarl==1.12.0 +yarl==1.12.1 zeroconf==0.134.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index eb8fd58d3be..e0e1a25e610 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.0", + "yarl==1.12.1", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 8e2b64afeb4..4f9e0ff622f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,4 +42,4 @@ uv==0.4.12 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 -yarl==1.12.0 +yarl==1.12.1 From 48693099977c99b62a531e54089b1232d1ffd257 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 23 Sep 2024 19:36:53 -0500 Subject: [PATCH 1042/1309] Get updated Assist satellite config after setting it in ESPHome (#126552) Get updated config after setting it --- .../components/esphome/assist_satellite.py | 3 +++ tests/components/esphome/test_assist_satellite.py | 14 ++++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 1485d88a7d2..bfe07a24096 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -174,6 +174,9 @@ class EsphomeAssistSatellite( ) _LOGGER.debug("Set active wake words: %s", config.active_wake_words) + # Ensure configuration is updated + await self._update_satellite_config() + 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) diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 71bae989daf..cfa25489013 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Awaitable, Callable +from dataclasses import replace import io import socket from unittest.mock import ANY, Mock, patch @@ -1457,11 +1458,16 @@ async def test_get_set_configuration( actual_config = satellite.async_get_configuration() assert actual_config == expected_config - # Change active wake words - actual_config.active_wake_words = ["5678"] - await satellite.async_set_configuration(actual_config) + updated_config = replace(actual_config, active_wake_words=["5678"]) + mock_client.get_voice_assistant_configuration.return_value = updated_config - # Device should have been updated + # Change active wake words + await satellite.async_set_configuration(updated_config) + + # Set config method should be called mock_client.set_voice_assistant_configuration.assert_called_once_with( active_wake_words=["5678"] ) + + # Device should have been updated + assert satellite.async_get_configuration() == updated_config From 3c9f51fbbd95de31890884d1e431fc882b49bb03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 08:29:35 +0200 Subject: [PATCH 1043/1309] Reduce scope of JSON/XML test fixtures (#126590) --- tests/components/doorbird/conftest.py | 8 ++++---- tests/components/geniushub/conftest.py | 4 ++-- tests/components/ondilo_ico/conftest.py | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/components/doorbird/conftest.py b/tests/components/doorbird/conftest.py index 2e367e4e1d8..0da69a98303 100644 --- a/tests/components/doorbird/conftest.py +++ b/tests/components/doorbird/conftest.py @@ -32,13 +32,13 @@ class MockDoorbirdEntry: api: MagicMock -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def doorbird_info() -> dict[str, Any]: """Return a loaded DoorBird info fixture.""" return load_json_value_fixture("info.json", "doorbird")["BHA"]["VERSION"][0] -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def doorbird_schedule() -> list[DoorBirdScheduleEntry]: """Return a loaded DoorBird schedule fixture.""" return DoorBirdScheduleEntry.parse_all( @@ -46,7 +46,7 @@ def doorbird_schedule() -> list[DoorBirdScheduleEntry]: ) -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def doorbird_schedule_wrong_param() -> list[DoorBirdScheduleEntry]: """Return a loaded DoorBird schedule fixture with an incorrect param.""" return DoorBirdScheduleEntry.parse_all( @@ -54,7 +54,7 @@ def doorbird_schedule_wrong_param() -> list[DoorBirdScheduleEntry]: ) -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def doorbird_favorites() -> dict[str, dict[str, Any]]: """Return a loaded DoorBird favorites fixture.""" return load_json_value_fixture("favorites.json", "doorbird") diff --git a/tests/components/geniushub/conftest.py b/tests/components/geniushub/conftest.py index 15938eabc62..1d2e706a6a6 100644 --- a/tests/components/geniushub/conftest.py +++ b/tests/components/geniushub/conftest.py @@ -40,13 +40,13 @@ def mock_geniushub_client() -> Generator[AsyncMock]: yield client -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def zones() -> list[dict[str, Any]]: """Return a list of zones.""" return load_json_array_fixture("zones_cloud_test_data.json", DOMAIN) -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def devices() -> list[dict[str, Any]]: """Return a list of devices.""" return load_json_array_fixture("devices_cloud_test_data.json", DOMAIN) diff --git a/tests/components/ondilo_ico/conftest.py b/tests/components/ondilo_ico/conftest.py index a847c1df069..d35e5ac0003 100644 --- a/tests/components/ondilo_ico/conftest.py +++ b/tests/components/ondilo_ico/conftest.py @@ -46,37 +46,37 @@ def mock_ondilo_client( yield client -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def pool1() -> list[dict[str, Any]]: """First pool description.""" return [load_json_object_fixture("pool1.json", DOMAIN)] -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def pool2() -> list[dict[str, Any]]: """Second pool description.""" return [load_json_object_fixture("pool2.json", DOMAIN)] -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def ico_details1() -> dict[str, Any]: """ICO details of first pool.""" return load_json_object_fixture("ico_details1.json", DOMAIN) -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def ico_details2() -> dict[str, Any]: """ICO details of second pool.""" return load_json_object_fixture("ico_details2.json", DOMAIN) -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def last_measures() -> list[dict[str, Any]]: """Pool measurements.""" return load_json_array_fixture("last_measures.json", DOMAIN) -@pytest.fixture(scope="session") +@pytest.fixture(scope="package") def two_pools( pool1: list[dict[str, Any]], pool2: list[dict[str, Any]] ) -> list[dict[str, Any]]: From ce70f4ebac41cc61b0088dc7c1cc5cefb1c64597 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 08:30:01 +0200 Subject: [PATCH 1044/1309] Fix ecobee test helper (#126587) --- tests/components/ecobee/common.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/components/ecobee/common.py b/tests/components/ecobee/common.py index 423b0eee320..e320a08673a 100644 --- a/tests/components/ecobee/common.py +++ b/tests/components/ecobee/common.py @@ -5,7 +5,6 @@ from unittest.mock import patch from homeassistant.components.ecobee.const import CONF_REFRESH_TOKEN, DOMAIN from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -25,8 +24,7 @@ async def setup_platform( ) mock_entry.add_to_hass(hass) - with patch("homeassistant.components.ecobee.const.PLATFORMS", [platform]): - assert await async_setup_component(hass, DOMAIN, {}) + with patch("homeassistant.components.ecobee.PLATFORMS", [platform]): + await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() - return mock_entry From 99dbc99b6c2b8c9157d186323962f3d8f67f4fe0 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 23 Sep 2024 23:35:04 -0700 Subject: [PATCH 1045/1309] Remove unnecessary unique_id suffix from Google Cloud entities (#126585) Remove uncessary unique_id suffix --- homeassistant/components/google_cloud/stt.py | 2 +- homeassistant/components/google_cloud/tts.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index 13715ae29f8..99b7dadbb0e 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -55,7 +55,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): client: speech_v1.SpeechAsyncClient, ) -> None: """Init Google Cloud STT entity.""" - self._attr_unique_id = f"{entry.entry_id}-stt" + self._attr_unique_id = f"{entry.entry_id}" self._attr_name = entry.title self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 60cdfbee3ab..e7bb899361a 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -223,7 +223,7 @@ class GoogleCloudTTSEntity(BaseGoogleCloudProvider, TextToSpeechEntity): ) -> None: """Init Google Cloud TTS entity.""" super().__init__(client, voices, language, options_schema) - self._attr_unique_id = f"{entry.entry_id}-tts" + self._attr_unique_id = f"{entry.entry_id}" self._attr_name = entry.title self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, From 2db927b7f7d93727751a6402afae5520561a1ca1 Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Tue, 24 Sep 2024 02:42:59 -0400 Subject: [PATCH 1046/1309] Fix truncating password issue (#126581) --- homeassistant/components/jvc_projector/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index f24ec4df51c..b8c670277c8 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==1.1.0"] + "requirements": ["pyjvcprojector==1.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3ff55b6089e..e9a44f18d50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1981,7 +1981,7 @@ pyisy==3.1.14 pyitachip2ir==0.0.7 # homeassistant.components.jvc_projector -pyjvcprojector==1.1.0 +pyjvcprojector==1.1.2 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58ffa771a2f..2d9785595f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1589,7 +1589,7 @@ pyiss==1.0.1 pyisy==3.1.14 # homeassistant.components.jvc_projector -pyjvcprojector==1.1.0 +pyjvcprojector==1.1.2 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 From 1fdb34b1e1247a5929874a69898441c384c08052 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 08:43:18 +0200 Subject: [PATCH 1047/1309] Fix zeroconf rediscovery test (#126593) --- tests/components/zeroconf/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 5bcff48749d..935af9a339e 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1669,7 +1669,7 @@ async def test_zeroconf_rediscover_no_match( ( "shelly", { - "bluetooth": ( + "zeroconf": ( DiscoveryKey( domain="zeroconf", key=("_http._tcp.local.", "Shelly108._http._tcp.local."), From f1e8675756798c17e982f9e38cb0c3b4654e9078 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 08:43:32 +0200 Subject: [PATCH 1048/1309] Set autouse flag on session scope bluetooth fixture (#126589) --- tests/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index cfcfaf8526c..10c9a740256 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -418,7 +418,7 @@ def reset_hass_threading_local_object() -> Generator[None]: ha._hass.__dict__.clear() -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(autouse=True, scope="session") def bcrypt_cost() -> Generator[None]: """Run with reduced rounds during tests, to speed up uses.""" gensalt_orig = bcrypt.gensalt @@ -1715,7 +1715,7 @@ async def mock_enable_bluetooth( await hass.async_block_till_done() -@pytest.fixture(scope="session") +@pytest.fixture(autouse=True, scope="session") def mock_bluetooth_adapters() -> Generator[None]: """Fixture to mock bluetooth adapters.""" with ( From 4a66395d5144578af5156249bb0c3095eb49fbd6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 08:44:11 +0200 Subject: [PATCH 1049/1309] Simplify signal_discovered_config_entry_removed job (#126591) --- homeassistant/components/zeroconf/__init__.py | 1 - homeassistant/config_entries.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 196e16298ef..a5015e9fc8c 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -395,7 +395,6 @@ class ZeroconfDiscovery: @callback def _handle_config_entry_removed( self, - change: config_entries.ConfigEntryChange, entry: config_entries.ConfigEntry, ) -> None: """Handle config entry changes.""" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 099b8ca2807..5df7e9b9cb0 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -196,7 +196,7 @@ SIGNAL_CONFIG_ENTRY_CHANGED = SignalType["ConfigEntryChange", "ConfigEntry"]( @cache def signal_discovered_config_entry_removed( discovery_domain: str, -) -> SignalType[ConfigEntryChange, ConfigEntry]: +) -> SignalType[ConfigEntry]: """Format signal.""" return SignalType(f"{discovery_domain}_discovered_config_entry_removed") @@ -1875,7 +1875,6 @@ class ConfigEntries: async_dispatcher_send_internal( self.hass, signal_discovered_config_entry_removed(discovery_domain), - ConfigEntryChange.REMOVED, entry, ) return {"require_restart": not unload_success} From 450b682c5c0c292f171fdf78a93dfb15f834706a Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 24 Sep 2024 08:45:19 +0200 Subject: [PATCH 1050/1309] Update xknx to 3.2.0 (#126569) --- 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 76212496dec..01950107801 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,7 +11,7 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==3.1.1", + "xknx==3.2.0", "xknxproject==3.7.1", "knx-frontend==2024.9.10.221729" ], diff --git a/requirements_all.txt b/requirements_all.txt index e9a44f18d50..f2e97b23d0b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2989,7 +2989,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.32.0 # homeassistant.components.knx -xknx==3.1.1 +xknx==3.2.0 # homeassistant.components.knx xknxproject==3.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d9785595f3..006c4ff4a33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2378,7 +2378,7 @@ xbox-webapi==2.0.11 xiaomi-ble==0.32.0 # homeassistant.components.knx -xknx==3.1.1 +xknx==3.2.0 # homeassistant.components.knx xknxproject==3.7.1 From 31200040da1890c9c5225219922a2806c8a80eb7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Sep 2024 01:51:08 -0500 Subject: [PATCH 1051/1309] Bump aiohttp to 3.10.6rc2 (#126468) --- homeassistant/package_constraints.txt | 2 +- homeassistant/util/aiohttp.py | 21 ++++++++++++++++++++- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/media_player/test_init.py | 11 ++++++++++- tests/components/motioneye/test_camera.py | 22 ++++++++-------------- tests/test_util/aiohttp.py | 15 ++++++++++++++- 7 files changed, 55 insertions(+), 20 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9b41d3da10b..064034a1641 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.0b1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.10.5 +aiohttp==3.10.6rc2 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index 2a4616ee634..5571861f417 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -28,6 +28,19 @@ class MockStreamReader: return self._content.read(byte_count) +class MockPayloadWriter: + """Small mock to imitate payload writer.""" + + def enable_chunking(self) -> None: + """Enable chunking.""" + + async def write_headers(self, *args: Any, **kwargs: Any) -> None: + """Write headers.""" + + +_MOCK_PAYLOAD_WRITER = MockPayloadWriter() + + class MockRequest: """Mock an aiohttp request.""" @@ -49,8 +62,14 @@ class MockRequest: self.status = status self.headers: CIMultiDict[str] = CIMultiDict(headers or {}) self.query_string = query_string or "" + self.keep_alive = False + self.version = (1, 1) self._content = content self.mock_source = mock_source + self._payload_writer = _MOCK_PAYLOAD_WRITER + + async def _prepare_hook(self, response: Any) -> None: + """Prepare hook.""" @property def query(self) -> MultiDict[str]: @@ -90,7 +109,7 @@ def serialize_response(response: web.Response) -> dict[str, Any]: if (body := response.body) is None: body_decoded = None elif isinstance(body, payload.StringPayload): - body_decoded = body._value.decode(body.encoding) # noqa: SLF001 + body_decoded = body._value.decode(body.encoding or "utf-8") # noqa: SLF001 elif isinstance(body, bytes): body_decoded = body.decode(response.charset or "utf-8") else: diff --git a/pyproject.toml b/pyproject.toml index e0e1a25e610..c23da491db6 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.0b1", - "aiohttp==3.10.5", + "aiohttp==3.10.6rc2", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 4f9e0ff622f..0d1464b01b8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.1.0b1 -aiohttp==3.10.5 +aiohttp==3.10.6rc2 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 11898edfc36..8909995a3ff 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -298,10 +298,20 @@ async def test_enqueue_alert_exclusive(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + "media_content_id", + [ + "a/b c/d+e%2Fg{}", + "a/b c/d+e%2D", + "a/b c/d+e%2E", + "2012-06%20Pool%20party%20%2F%20BBQ", + ], +) async def test_get_async_get_browse_image_quoting( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, + media_content_id: str, ) -> None: """Test get browse image using media_content_id with special characters. @@ -325,7 +335,6 @@ async def test_get_async_get_browse_image_quoting( "homeassistant.components.media_player.MediaPlayerEntity." "async_get_browse_image", ) as mock_browse_image: - media_content_id = "a/b c/d+e%2Fg{}" url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index 0f3a7d6f904..8ef58cc968d 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -3,7 +3,6 @@ from asyncio import AbstractEventLoop from collections.abc import Callable import copy -from typing import cast from unittest.mock import AsyncMock, Mock, call from aiohttp import web @@ -46,6 +45,7 @@ from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.aiohttp import MockRequest import homeassistant.util.dt as dt_util from . import ( @@ -231,7 +231,7 @@ async def test_get_still_image_from_camera( ) -> None: """Test getting a still image.""" - image_handler = AsyncMock(return_value="") + image_handler = AsyncMock(return_value=web.Response(body="")) app = web.Application() app.add_routes( @@ -273,7 +273,8 @@ async def test_get_stream_from_camera( ) -> None: """Test getting a stream.""" - stream_handler = AsyncMock(return_value="") + stream_handler = AsyncMock(return_value=web.Response(body="")) + app = web.Application() app.add_routes([web.get("/", stream_handler)]) stream_server = await aiohttp_server(app) @@ -297,12 +298,7 @@ async def test_get_stream_from_camera( ) await hass.async_block_till_done() - # It won't actually get a stream from the dummy handler, so just catch - # the expected exception, then verify the right handler was called. - with pytest.raises(HTTPBadGateway): - await async_get_mjpeg_stream( - hass, cast(web.Request, None), TEST_CAMERA_ENTITY_ID - ) + await async_get_mjpeg_stream(hass, MockRequest(b"", "test"), TEST_CAMERA_ENTITY_ID) assert stream_handler.called @@ -358,7 +354,8 @@ async def test_camera_option_stream_url_template( """Verify camera with a stream URL template option.""" client = create_mock_motioneye_client() - stream_handler = AsyncMock(return_value="") + stream_handler = AsyncMock(return_value=web.Response(body="")) + app = web.Application() app.add_routes([web.get(f"/{TEST_CAMERA_NAME}/{TEST_CAMERA_ID}", stream_handler)]) stream_server = await aiohttp_server(app) @@ -384,10 +381,7 @@ async def test_camera_option_stream_url_template( ) await hass.async_block_till_done() - # It won't actually get a stream from the dummy handler, so just catch - # the expected exception, then verify the right handler was called. - with pytest.raises(HTTPBadGateway): - await async_get_mjpeg_stream(hass, Mock(), TEST_CAMERA_ENTITY_ID) + await async_get_mjpeg_stream(hass, MockRequest(b"", "test"), TEST_CAMERA_ENTITY_ID) assert AsyncMock.called assert not client.get_camera_stream_url.called diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 04d6db509e0..633f98dc5b3 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -5,6 +5,7 @@ from collections.abc import Iterator from contextlib import contextmanager from http import HTTPStatus import re +from types import TracebackType from typing import Any from unittest import mock from urllib.parse import parse_qs @@ -166,7 +167,7 @@ class AiohttpClientMockResponse: def __init__( self, method, - url, + url: URL, status=HTTPStatus.OK, response=None, json=None, @@ -297,6 +298,18 @@ class AiohttpClientMockResponse: raise ClientConnectionError("Connection closed") return self._response + async def __aenter__(self): + """Enter the context manager.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit the context manager.""" + @contextmanager def mock_aiohttp_client() -> Iterator[AiohttpClientMocker]: From 61ff40c299c6a03fedd63445f1a2867cdc8bcd1b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 24 Sep 2024 08:52:07 +0200 Subject: [PATCH 1052/1309] Add base Entity classes to enforce-class-module pylint plugin (#126473) --- .../bluetooth/passive_update_coordinator.py | 2 +- .../components/starlink/device_tracker.py | 4 ++- pylint/plugins/hass_enforce_class_module.py | 29 ++++++++++++++----- tests/pylint/test_enforce_class_module.py | 22 ++++++++++++++ 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index df06a7c534b..be232f87b24 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -98,7 +98,7 @@ class PassiveBluetoothDataUpdateCoordinator( self.async_update_listeners() -class PassiveBluetoothCoordinatorEntity( +class PassiveBluetoothCoordinatorEntity( # pylint: disable=hass-enforce-class-module BaseCoordinatorEntity[_PassiveBluetoothDataUpdateCoordinatorT] ): """A class for entities using DataUpdateCoordinator.""" diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index 34769d687ff..129efa0d025 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -28,7 +28,9 @@ async def async_setup_entry( @dataclass(frozen=True, kw_only=True) -class StarlinkDeviceTrackerEntityDescription(EntityDescription): +class StarlinkDeviceTrackerEntityDescription( # pylint: disable=hass-enforce-class-module + EntityDescription +): """Describes a Starlink button entity.""" latitude_fn: Callable[[StarlinkData], float] diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index 6491a702b7f..e48cae877a5 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -2,14 +2,23 @@ from __future__ import annotations -from ast import ClassDef - from astroid import nodes from pylint.checkers import BaseChecker from pylint.lint import PyLinter from homeassistant.const import Platform +_BASE_ENTITY_MODULES: set[str] = { + "BaseCoordinatorEntity", + "CoordinatorEntity", + "Entity", + "EntityDescription", + "ManualTriggerEntity", + "RestoreEntity", + "ToggleEntity", + "ToggleEntityDescription", + "TriggerBaseEntity", +} _MODULES: dict[str, set[str]] = { "air_quality": {"AirQualityEntity"}, "alarm_control_panel": { @@ -82,6 +91,11 @@ _ENTITY_COMPONENTS: set[str] = {platform.value for platform in Platform}.union( ) +_MODULE_CLASSES = { + class_name for classes in _MODULES.values() for class_name in classes +} + + class HassEnforceClassModule(BaseChecker): """Checker for class in correct module.""" @@ -106,11 +120,15 @@ class HassEnforceClassModule(BaseChecker): current_integration = parts[2] current_module = parts[3] if len(parts) > 3 else "" + ancestors = list(node.ancestors()) + if current_module != "entity" and current_integration not in _ENTITY_COMPONENTS: top_level_ancestors = list(node.ancestors(recurs=False)) for ancestor in top_level_ancestors: - if ancestor.name == "Entity": + if ancestor.name in _BASE_ENTITY_MODULES and not any( + anc.name in _MODULE_CLASSES for anc in ancestors + ): self.add_message( "hass-enforce-class-module", node=node, @@ -118,15 +136,10 @@ class HassEnforceClassModule(BaseChecker): ) return - ancestors: list[ClassDef] | None = None - for expected_module, classes in _MODULES.items(): if expected_module in (current_module, current_integration): continue - if ancestors is None: - ancestors = list(node.ancestors()) # cache result for other modules - for ancestor in ancestors: if ancestor.name in classes: self.add_message( diff --git a/tests/pylint/test_enforce_class_module.py b/tests/pylint/test_enforce_class_module.py index 8927147e89a..8b3ac563c6a 100644 --- a/tests/pylint/test_enforce_class_module.py +++ b/tests/pylint/test_enforce_class_module.py @@ -84,6 +84,12 @@ def test_enforce_class_platform_good( class CustomSensorEntity(SensorEntity): pass + + class CoordinatorEntity: + pass + + class CustomCoordinatorSensorEntity(CoordinatorEntity, SensorEntity): + pass """ root_node = astroid.parse(code, path) walker = ASTWalker(linter) @@ -115,6 +121,12 @@ def test_enforce_class_module_bad_simple( class TestCoordinator(DataUpdateCoordinator): pass + + class CoordinatorEntity: + pass + + class CustomCoordinatorSensorEntity(CoordinatorEntity): + pass """, path, ) @@ -133,6 +145,16 @@ def test_enforce_class_module_bad_simple( end_line=5, end_col_offset=21, ), + MessageTest( + msg_id="hass-enforce-class-module", + line=11, + node=root_node.body[3], + args=("CoordinatorEntity", "entity"), + confidence=UNDEFINED, + col_offset=0, + end_line=11, + end_col_offset=35, + ), ): walker.walk(root_node) From 2df68248566ceaa110069980232bd357cebd929d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 24 Sep 2024 08:54:55 +0200 Subject: [PATCH 1053/1309] Cleanup source_type type hints in device tracker components (#126592) --- homeassistant/components/starlink/device_tracker.py | 2 +- homeassistant/components/tesla_fleet/device_tracker.py | 2 +- homeassistant/components/tessie/device_tracker.py | 2 +- homeassistant/components/volvooncall/device_tracker.py | 2 +- tests/components/device_tracker/common.py | 2 +- tests/components/device_tracker/test_config_entry.py | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index 129efa0d025..de9f413778a 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -56,7 +56,7 @@ class StarlinkDeviceTrackerEntity(StarlinkEntity, TrackerEntity): entity_description: StarlinkDeviceTrackerEntityDescription @property - def source_type(self) -> SourceType | str: + def source_type(self) -> SourceType: """Return the source type, eg gps or router, of the device.""" return SourceType.GPS diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index 1d396286d7c..d27262c842d 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -65,7 +65,7 @@ class TeslaFleetDeviceTrackerEntity( return self._attr_longitude @property - def source_type(self) -> SourceType | str: + def source_type(self) -> SourceType: """Return the source type of the device tracker.""" return SourceType.GPS diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py index d90222bf821..20ab5aa829e 100644 --- a/homeassistant/components/tessie/device_tracker.py +++ b/homeassistant/components/tessie/device_tracker.py @@ -44,7 +44,7 @@ class TessieDeviceTrackerEntity(TessieEntity, TrackerEntity): super().__init__(vehicle, self.key) @property - def source_type(self) -> SourceType | str: + def source_type(self) -> SourceType: """Return the source type of the device tracker.""" return SourceType.GPS diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py index d0d6abde414..1f79bea7290 100644 --- a/homeassistant/components/volvooncall/device_tracker.py +++ b/homeassistant/components/volvooncall/device_tracker.py @@ -62,7 +62,7 @@ class VolvoTrackerEntity(VolvoEntity, TrackerEntity): return longitude @property - def source_type(self) -> SourceType | str: + def source_type(self) -> SourceType: """Return the source type (GPS).""" return SourceType.GPS diff --git a/tests/components/device_tracker/common.py b/tests/components/device_tracker/common.py index b6341443d36..4842a91ce42 100644 --- a/tests/components/device_tracker/common.py +++ b/tests/components/device_tracker/common.py @@ -69,7 +69,7 @@ class MockScannerEntity(ScannerEntity): self._mac_address = "ad:de:ef:be:ed:fe" @property - def source_type(self): + def source_type(self) -> SourceType: """Return the source type, eg gps or router, of the device.""" return SourceType.ROUTER diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index 5b9ce78e4f5..7041b2d59ab 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -162,7 +162,7 @@ class MockTrackerEntity(TrackerEntity): return self._battery_level @property - def source_type(self) -> SourceType | str: + def source_type(self) -> SourceType: """Return the source type, eg gps or router, of the device.""" return SourceType.GPS @@ -249,7 +249,7 @@ class MockScannerEntity(ScannerEntity): return False @property - def source_type(self) -> SourceType | str: + def source_type(self) -> SourceType: """Return the source type, eg gps or router, of the device.""" return SourceType.ROUTER From 615ec548dbffa51e7f21c0f55ae183c646fe3aac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Sep 2024 01:58:32 -0500 Subject: [PATCH 1054/1309] Change dhcp internal index to use mac address (#126573) --- homeassistant/components/dhcp/__init__.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 0897729ec72..bf3389b4111 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -213,19 +213,20 @@ class WatcherBase: # and since all consumers of this data are expecting it to be # formatted without colons we will continue to do so mac_address = formatted_mac.replace(":", "") + compressed_ip_address = made_ip_address.compressed - data = self._address_data.get(ip_address) + data = self._address_data.get(mac_address) if ( data - and data[MAC_ADDRESS] == mac_address + and data[IP_ADDRESS] == compressed_ip_address and data[HOSTNAME].startswith(hostname) ): # If the address data is the same no need # to process it return - data = {MAC_ADDRESS: mac_address, HOSTNAME: hostname} - self._address_data[ip_address] = data + data = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname} + self._address_data[mac_address] = data lowercase_hostname = hostname.lower() uppercase_mac = mac_address.upper() From 4c0fb04f616e7e94cbfc30732f442d83902ed088 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 24 Sep 2024 00:07:12 -0700 Subject: [PATCH 1055/1309] Make tts options of type list (such as profiles in google_cloud) work (#121582) * Allow tts options of type list such as profiles in google_cloud * Update tests/components/tts/test_media_source.py * Don't mix engine specific options with other options * Fix test * Update assist_pipeline snapshots --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen Co-authored-by: Erik Montnemery --- homeassistant/components/tts/media_source.py | 29 +++-- .../assist_pipeline/snapshots/test_init.ambr | 8 +- .../snapshots/test_websocket.ambr | 8 +- tests/components/tts/test_init.py | 19 ++- tests/components/tts/test_media_source.py | 115 ++++++++++++++++-- 5 files changed, 150 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 13c37681259..dce521621c5 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import mimetypes from typing import TypedDict @@ -22,6 +23,8 @@ from homeassistant.exceptions import HomeAssistantError from .const import DATA_TTS_MANAGER, DOMAIN, DOMAIN_DATA from .helper import get_engine_instance +URL_QUERY_TTS_OPTIONS = "tts_options" + async def async_get_media_source(hass: HomeAssistant) -> TTSMediaSource: """Set up tts media source.""" @@ -55,8 +58,7 @@ def generate_media_source_id( params["cache"] = "true" if cache else "false" if language is not None: params["language"] = language - if options is not None: - params.update(options) + params[URL_QUERY_TTS_OPTIONS] = json.dumps(options, separators=(",", ":")) return ms_generate_media_source_id( DOMAIN, @@ -78,19 +80,28 @@ class MediaSourceOptions(TypedDict): def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: """Turn a media source ID into options.""" parsed = URL(media_source_id) + if URL_QUERY_TTS_OPTIONS in parsed.query: + try: + options = json.loads(parsed.query[URL_QUERY_TTS_OPTIONS]) + except json.JSONDecodeError as err: + raise Unresolvable(f"Invalid TTS options: {err.msg}") from err + else: + options = { + k: v + for k, v in parsed.query.items() + if k not in ("message", "language", "cache") + } if "message" not in parsed.query: raise Unresolvable("No message specified.") - - options = dict(parsed.query) kwargs: MediaSourceOptions = { "engine": parsed.name, - "message": options.pop("message"), - "language": options.pop("language", None), + "message": parsed.query["message"], + "language": parsed.query.get("language"), "options": options, "cache": None, } - if "cache" in options: - kwargs["cache"] = options.pop("cache") == "true" + if "cache" in parsed.query: + kwargs["cache"] = parsed.query["cache"] == "true" return kwargs @@ -111,6 +122,8 @@ class TTSMediaSource(MediaSource): url = await self.hass.data[DATA_TTS_MANAGER].async_get_url_path( **media_source_id_to_kwargs(item.identifier) ) + except Unresolvable: + raise except HomeAssistantError as err: raise Unresolvable(str(err)) from err diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 7f29534e473..e14bbac1839 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -75,7 +75,7 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), @@ -164,7 +164,7 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", 'mime_type': 'audio/mpeg', 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', }), @@ -253,7 +253,7 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=Arnold+Schwarzenegger", + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22Arnold+Schwarzenegger%22%7D", 'mime_type': 'audio/mpeg', 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_2657c1a8ee_test.mp3', }), @@ -366,7 +366,7 @@ dict({ 'data': dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 7ea6af7e0bd..131444c17ac 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -71,7 +71,7 @@ # name: test_audio_pipeline.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), @@ -152,7 +152,7 @@ # name: test_audio_pipeline_debug.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), @@ -245,7 +245,7 @@ # name: test_audio_pipeline_with_enhancements.6 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), @@ -348,7 +348,7 @@ # name: test_audio_pipeline_with_wake_word_no_timeout.8 dict({ 'tts_output': dict({ - 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&voice=james_earl_jones", + 'media_id': "media-source://tts/test?message=Sorry,+I+couldn't+understand+that&language=en-US&tts_options=%7B%22voice%22:%22james_earl_jones%22%7D", 'mime_type': 'audio/mpeg', 'url': '/api/tts_proxy/dae2cdcb27a1d1c3b07ba2c7db91480f9d4bfd8f_en-us_031e2ec052_test.mp3', }), diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index cf04fbb175b..2ab6dc16629 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1318,10 +1318,16 @@ async def test_tags_with_wave() -> None: @pytest.mark.parametrize( ("engine", "language", "options", "cache", "result_query"), [ - (None, None, None, None, ""), - (None, "de_DE", None, None, "language=de_DE"), - (None, "de_DE", {"voice": "henk"}, None, "language=de_DE&voice=henk"), - (None, "de_DE", None, True, "cache=true&language=de_DE"), + (None, None, None, None, "&tts_options=null"), + (None, "de_DE", None, None, "&language=de_DE&tts_options=null"), + ( + None, + "de_DE", + {"voice": "henk"}, + None, + "&language=de_DE&tts_options=%7B%22voice%22:%22henk%22%7D", + ), + (None, "de_DE", None, True, "&cache=true&language=de_DE&tts_options=null"), ], ) async def test_generate_media_source_id( @@ -1343,8 +1349,9 @@ async def test_generate_media_source_id( _, _, engine_query = media_source_id.rpartition("/") engine, _, query = engine_query.partition("?") assert engine == result_engine - assert query.startswith("message=msg") - assert query[12:] == result_query + query_prefix = "message=msg" + assert query.startswith(query_prefix) + assert query[len(query_prefix) :] == result_query @pytest.mark.parametrize( diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index 367b24dd4d0..d90923b02ab 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -8,6 +8,11 @@ import pytest from homeassistant.components import media_source from homeassistant.components.media_player import BrowseError +from homeassistant.components.tts.media_source import ( + MediaSourceOptions, + generate_media_source_id, + media_source_id_to_kwargs, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -93,14 +98,24 @@ async def test_browsing(hass: HomeAssistant, setup: str) -> None: await media_source.async_browse_media(hass, "media-source://tts/non-existing") -@pytest.mark.parametrize("mock_provider", [MSProvider(DEFAULT_LANG)]) +@pytest.mark.parametrize( + ("mock_provider", "extra_options"), + [ + (MSProvider(DEFAULT_LANG), "&tts_options=%7B%22voice%22%3A%22Paulus%22%7D"), + (MSProvider(DEFAULT_LANG), "&voice=Paulus"), + ], +) async def test_legacy_resolving( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_provider: MSProvider + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_provider: MSProvider, + extra_options: str, ) -> None: """Test resolving legacy provider.""" await mock_setup(hass, mock_provider) mock_get_tts_audio = mock_provider.get_tts_audio + mock_get_tts_audio.reset_mock() media_id = "media-source://tts/test?message=Hello%20World" media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") @@ -115,7 +130,9 @@ async def test_legacy_resolving( # Pass language and options mock_get_tts_audio.reset_mock() - media_id = "media-source://tts/test?message=Bye%20World&language=de_DE&voice=Paulus" + media_id = ( + f"media-source://tts/test?message=Bye%20World&language=de_DE{extra_options}" + ) media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" @@ -128,14 +145,24 @@ async def test_legacy_resolving( assert mock_get_tts_audio.mock_calls[0][2]["options"] == {"voice": "Paulus"} -@pytest.mark.parametrize("mock_tts_entity", [MSEntity(DEFAULT_LANG)]) +@pytest.mark.parametrize( + ("mock_tts_entity", "extra_options"), + [ + (MSEntity(DEFAULT_LANG), "&tts_options=%7B%22voice%22%3A%22Paulus%22%7D"), + (MSEntity(DEFAULT_LANG), "&voice=Paulus"), + ], +) async def test_resolving( - hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts_entity: MSEntity + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_tts_entity: MSEntity, + extra_options: str, ) -> None: """Test resolving entity.""" await mock_config_entry_setup(hass, mock_tts_entity) mock_get_tts_audio = mock_tts_entity.get_tts_audio + mock_get_tts_audio.reset_mock() media_id = "media-source://tts/tts.test?message=Hello%20World" media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") @@ -151,7 +178,7 @@ async def test_resolving( # Pass language and options mock_get_tts_audio.reset_mock() media_id = ( - "media-source://tts/tts.test?message=Bye%20World&language=de_DE&voice=Paulus" + f"media-source://tts/tts.test?message=Bye%20World&language=de_DE{extra_options}" ) media = await media_source.async_resolve_media(hass, media_id, None) assert media.url.startswith("/api/tts_proxy/") @@ -191,6 +218,17 @@ async def test_resolving_errors(hass: HomeAssistant, setup: str, engine: str) -> hass, "media-source://tts/non-existing?message=bla", None ) + # Non-JSON tts options + with pytest.raises( + media_source.Unresolvable, + match="Invalid TTS options: Expecting property name enclosed in double quotes", + ): + await media_source.async_resolve_media( + hass, + f"media-source://tts/{engine}?message=bla&tts_options=%7Binvalid json", + None, + ) + # Non-existing option with pytest.raises( media_source.Unresolvable, @@ -198,6 +236,69 @@ async def test_resolving_errors(hass: HomeAssistant, setup: str, engine: str) -> ): await media_source.async_resolve_media( hass, - f"media-source://tts/{engine}?message=bla&non_existing_option=bla", + f"media-source://tts/{engine}?message=bla&tts_options=%7B%22non_existing_option%22%3A%22bla%22%7D", None, ) + + +@pytest.mark.parametrize( + ("setup", "result_engine"), + [ + ("mock_setup", "test"), + ("mock_config_entry_setup", "tts.test"), + ], + indirect=["setup"], +) +async def test_generate_media_source_id_and_media_source_id_to_kwargs( + hass: HomeAssistant, + setup: str, + result_engine: str, +) -> None: + """Test media_source_id and media_source_id_to_kwargs.""" + kwargs: MediaSourceOptions = { + "engine": None, + "message": "hello", + "language": "en_US", + "options": {"age": 5}, + "cache": True, + } + media_source_id = generate_media_source_id(hass, **kwargs) + assert media_source_id_to_kwargs(media_source_id) == { + "engine": result_engine, + "message": "hello", + "language": "en_US", + "options": {"age": 5}, + "cache": True, + } + + kwargs = { + "engine": None, + "message": "hello", + "language": "en_US", + "options": {"age": [5, 6]}, + "cache": True, + } + media_source_id = generate_media_source_id(hass, **kwargs) + assert media_source_id_to_kwargs(media_source_id) == { + "engine": result_engine, + "message": "hello", + "language": "en_US", + "options": {"age": [5, 6]}, + "cache": True, + } + + kwargs = { + "engine": None, + "message": "hello", + "language": "en_US", + "options": {"age": {"k1": [5, 6], "k2": "v2"}}, + "cache": True, + } + media_source_id = generate_media_source_id(hass, **kwargs) + assert media_source_id_to_kwargs(media_source_id) == { + "engine": result_engine, + "message": "hello", + "language": "en_US", + "options": {"age": {"k1": [5, 6], "k2": "v2"}}, + "cache": True, + } From 5186605cecb197a4ad87ebe3e0024a4109a6eb45 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 24 Sep 2024 18:32:38 +1000 Subject: [PATCH 1056/1309] Add energy history coordinator and sensors to Teslemetry (#126166) * start * More * fix init * Update requirements_all.txt * Update requirements_test_all.txt * Add Tests * Add missing fixture * first refresh history * Fix mock_energy_history * Remove failures prop * Update test_init.py * Actually add the sensors * Add more icons * suggested_display_precision * Fix updated_once * Fix fixture * Review changes * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Remove init data * Update homeassistant/components/teslemetry/coordinator.py * ruff --------- Co-authored-by: Joost Lekkerkerker --- .../components/teslemetry/__init__.py | 14 +- homeassistant/components/teslemetry/const.py | 24 + .../components/teslemetry/coordinator.py | 41 +- homeassistant/components/teslemetry/entity.py | 18 + .../components/teslemetry/icons.json | 63 + homeassistant/components/teslemetry/models.py | 2 + homeassistant/components/teslemetry/sensor.py | 43 + .../components/teslemetry/strings.json | 63 + tests/components/teslemetry/conftest.py | 11 + tests/components/teslemetry/const.py | 1 + .../teslemetry/fixtures/energy_history.json | 55 + .../teslemetry/snapshots/test_sensor.ambr | 1533 +++++++++++++++++ tests/components/teslemetry/test_init.py | 14 + 13 files changed, 1876 insertions(+), 6 deletions(-) create mode 100644 tests/components/teslemetry/fixtures/energy_history.json diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 6308d62f3a1..3bf19e0a218 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -23,6 +23,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER, MODELS from .coordinator import ( + TeslemetryEnergyHistoryCoordinator, TeslemetryEnergySiteInfoCoordinator, TeslemetryEnergySiteLiveCoordinator, TeslemetryVehicleDataCoordinator, @@ -120,8 +121,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - continue api = EnergySpecific(teslemetry.energy, site_id) - live_coordinator = TeslemetryEnergySiteLiveCoordinator(hass, api) - info_coordinator = TeslemetryEnergySiteInfoCoordinator(hass, api, product) device = DeviceInfo( identifiers={(DOMAIN, str(site_id))}, manufacturer="Tesla", @@ -133,8 +132,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - energysites.append( TeslemetryEnergyData( api=api, - live_coordinator=live_coordinator, - info_coordinator=info_coordinator, + live_coordinator=TeslemetryEnergySiteLiveCoordinator(hass, api), + info_coordinator=TeslemetryEnergySiteInfoCoordinator( + hass, api, product + ), + history_coordinator=TeslemetryEnergyHistoryCoordinator(hass, api), id=site_id, device=device, ) @@ -154,6 +156,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - energysite.info_coordinator.async_config_entry_first_refresh() for energysite in energysites ), + *( + energysite.history_coordinator.async_config_entry_first_refresh() + for energysite in energysites + ), ) # Add energy device models diff --git a/homeassistant/components/teslemetry/const.py b/homeassistant/components/teslemetry/const.py index 0c2dc68e7c7..01c6c33f505 100644 --- a/homeassistant/components/teslemetry/const.py +++ b/homeassistant/components/teslemetry/const.py @@ -16,6 +16,30 @@ MODELS = { "Y": "Model Y", } +ENERGY_HISTORY_FIELDS = [ + "solar_energy_exported", + "generator_energy_exported", + "grid_energy_imported", + "grid_services_energy_imported", + "grid_services_energy_exported", + "grid_energy_exported_from_solar", + "grid_energy_exported_from_generator", + "grid_energy_exported_from_battery", + "battery_energy_exported", + "battery_energy_imported_from_grid", + "battery_energy_imported_from_solar", + "battery_energy_imported_from_generator", + "consumer_energy_imported_from_grid", + "consumer_energy_imported_from_solar", + "consumer_energy_imported_from_battery", + "consumer_energy_imported_from_generator", + "total_home_usage", + "total_battery_charge", + "total_battery_discharge", + "total_solar_generation", + "total_grid_energy_exported", +] + class TeslemetryState(StrEnum): """Teslemetry Vehicle States.""" diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 11fc49e86ee..1dc61ad2595 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta from typing import Any from tesla_fleet_api import EnergySpecific, VehicleSpecific -from tesla_fleet_api.const import VehicleDataEndpoint +from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint from tesla_fleet_api.exceptions import ( Forbidden, InvalidToken, @@ -17,12 +17,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import LOGGER, TeslemetryState +from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslemetryState VEHICLE_INTERVAL = timedelta(seconds=30) VEHICLE_WAIT = timedelta(minutes=15) ENERGY_LIVE_INTERVAL = timedelta(seconds=30) ENERGY_INFO_INTERVAL = timedelta(seconds=30) +ENERGY_HISTORY_INTERVAL = timedelta(seconds=60) ENDPOINTS = [ VehicleDataEndpoint.CHARGE_STATE, @@ -178,3 +179,39 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]) raise UpdateFailed(e.message) from e return flatten(data) + + +class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching energy site info from the Teslemetry API.""" + + updated_once: bool + + def __init__(self, hass: HomeAssistant, api: EnergySpecific) -> None: + """Initialize Teslemetry Energy Info coordinator.""" + super().__init__( + hass, + LOGGER, + name=f"Teslemetry Energy History {api.energy_site_id}", + update_interval=ENERGY_HISTORY_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Update energy site data using Teslemetry API.""" + + try: + data = (await self.api.energy_history(TeslaEnergyPeriod.DAY))["response"] + except (InvalidToken, Forbidden, SubscriptionRequired) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise UpdateFailed(e.message) from e + + self.updated_once = True + + # Add all time periods together + output = {key: 0 for key in ENERGY_HISTORY_FIELDS} + for period in data.get("time_series", []): + for key in ENERGY_HISTORY_FIELDS: + output[key] += period.get(key, 0) + + return output diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index bba678f754b..724d9371396 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -11,6 +11,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ( + TeslemetryEnergyHistoryCoordinator, TeslemetryEnergySiteInfoCoordinator, TeslemetryEnergySiteLiveCoordinator, TeslemetryVehicleDataCoordinator, @@ -22,6 +23,7 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData class TeslemetryEntity( CoordinatorEntity[ TeslemetryVehicleDataCoordinator + | TeslemetryEnergyHistoryCoordinator | TeslemetryEnergySiteLiveCoordinator | TeslemetryEnergySiteInfoCoordinator ] @@ -33,6 +35,7 @@ class TeslemetryEntity( def __init__( self, coordinator: TeslemetryVehicleDataCoordinator + | TeslemetryEnergyHistoryCoordinator | TeslemetryEnergySiteLiveCoordinator | TeslemetryEnergySiteInfoCoordinator, api: VehicleSpecific | EnergySpecific, @@ -148,6 +151,21 @@ class TeslemetryEnergyInfoEntity(TeslemetryEntity): super().__init__(data.info_coordinator, data.api, key) +class TeslemetryEnergyHistoryEntity(TeslemetryEntity): + """Parent class for Teslemetry Energy History Entities.""" + + def __init__( + self, + data: TeslemetryEnergyData, + key: str, + ) -> None: + """Initialize common aspects of a Teslemetry Energy Site Info entity.""" + self._attr_unique_id = f"{data.id}-{key}" + self._attr_device_info = data.device + + super().__init__(data.history_coordinator, data.api, key) + + class TeslemetryWallConnectorEntity( TeslemetryEntity, CoordinatorEntity[TeslemetryEnergySiteLiveCoordinator] ): diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 1912d2265f6..501755bb691 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -219,6 +219,69 @@ }, "wall_connector_state": { "default": "mdi:ev-station" + }, + "total_home_usage": { + "default": "mdi:home-lightning-bolt" + }, + "total_battery_charge": { + "default": "mdi:battery-arrow-up" + }, + "total_battery_discharge": { + "default": "mdi:battery-arrow-down" + }, + "total_solar_production": { + "default": "mdi:solar-power-variant" + }, + "grid_energy_imported": { + "default": "mdi:transmission-tower-import" + }, + "total_grid_energy_exported": { + "default": "mdi:transmission-tower-export" + }, + "solar_energy_exported": { + "default": "mdi:solar-power-variant" + }, + "generator_energy_exported": { + "default": "mdi:generator-stationary" + }, + "grid_services_energy_imported": { + "default": "mdi:transmission-tower-import" + }, + "grid_services_energy_exported": { + "default": "mdi:transmission-tower-export" + }, + "grid_energy_exported_from_solar": { + "default": "mdi:solar-power" + }, + "grid_energy_exported_from_generator": { + "default": "mdi:generator-stationary" + }, + "grid_energy_exported_from_battery": { + "default": "mdi:battery-arrow-down" + }, + "battery_energy_exported": { + "default": "mdi:battery-arrow-down" + }, + "battery_energy_imported_from_grid": { + "default": "mdi:transmission-tower-import" + }, + "battery_energy_imported_from_solar": { + "default": "mdi:solar-power" + }, + "battery_energy_imported_from_generator": { + "default": "mdi:generator-stationary" + }, + "consumer_energy_imported_from_grid": { + "default": "mdi:transmission-tower-import" + }, + "consumer_energy_imported_from_solar": { + "default": "mdi:solar-power" + }, + "consumer_energy_imported_from_battery": { + "default": "mdi:home-battery" + }, + "consumer_energy_imported_from_generator": { + "default": "mdi:generator-stationary" } }, "switch": { diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py index d05d713c1eb..a6d549b8937 100644 --- a/homeassistant/components/teslemetry/models.py +++ b/homeassistant/components/teslemetry/models.py @@ -11,6 +11,7 @@ from tesla_fleet_api.const import Scope from homeassistant.helpers.device_registry import DeviceInfo from .coordinator import ( + TeslemetryEnergyHistoryCoordinator, TeslemetryEnergySiteInfoCoordinator, TeslemetryEnergySiteLiveCoordinator, TeslemetryVehicleDataCoordinator, @@ -44,5 +45,6 @@ class TeslemetryEnergyData: api: EnergySpecific live_coordinator: TeslemetryEnergySiteLiveCoordinator info_coordinator: TeslemetryEnergySiteInfoCoordinator + history_coordinator: TeslemetryEnergyHistoryCoordinator id: int device: DeviceInfo diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index b63f6b905b4..1a6eb0fb8c8 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -34,7 +34,9 @@ from homeassistant.util import dt as dt_util from homeassistant.util.variance import ignore_variance from . import TeslemetryConfigEntry +from .const import ENERGY_HISTORY_FIELDS from .entity import ( + TeslemetryEnergyHistoryEntity, TeslemetryEnergyInfoEntity, TeslemetryEnergyLiveEntity, TeslemetryVehicleEntity, @@ -414,6 +416,21 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription(key="version"), ) +ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple( + SensorEntityDescription( + key=key, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + suggested_display_precision=2, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=( + key.startswith("total") or key == "grid_energy_imported" + ), + ) + for key in ENERGY_HISTORY_FIELDS +) + async def async_setup_entry( hass: HomeAssistant, @@ -451,6 +468,13 @@ async def async_setup_entry( for description in ENERGY_INFO_DESCRIPTIONS if description.key in energysite.info_coordinator.data ), + ( # Add energy history sensor + TeslemetryEnergyHistorySensorEntity(energysite, description) + for energysite in entry.runtime_data.energysites + for description in ENERGY_HISTORY_DESCRIPTIONS + if energysite.info_coordinator.data.get("components_battery") + or energysite.info_coordinator.data.get("components_solar") + ), ) ) @@ -566,3 +590,22 @@ class TeslemetryEnergyInfoSensorEntity(TeslemetryEnergyInfoEntity, SensorEntity) """Update the attributes of the sensor.""" self._attr_available = not self.is_none self._attr_native_value = self._value + + +class TeslemetryEnergyHistorySensorEntity(TeslemetryEnergyHistoryEntity, SensorEntity): + """Base class for Tesla Fleet energy site metric sensors.""" + + entity_description: SensorEntityDescription + + def __init__( + self, + data: TeslemetryEnergyData, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the sensor.""" + self._attr_native_value = self._value diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 29c9ef3bbb7..b8d07c992a8 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -436,6 +436,69 @@ }, "wall_connector_state": { "name": "State code" + }, + "solar_energy_exported": { + "name": "Solar exported" + }, + "generator_energy_exported": { + "name": "Generator exported" + }, + "grid_energy_imported": { + "name": "Grid imported" + }, + "grid_services_energy_imported": { + "name": "Grid services imported" + }, + "grid_services_energy_exported": { + "name": "Grid services exported" + }, + "grid_energy_exported_from_solar": { + "name": "Grid exported from solar" + }, + "grid_energy_exported_from_generator": { + "name": "Grid exported from generator" + }, + "grid_energy_exported_from_battery": { + "name": "Grid exported from battery" + }, + "battery_energy_exported": { + "name": "Battery exported" + }, + "battery_energy_imported_from_grid": { + "name": "Battery imported from grid" + }, + "battery_energy_imported_from_solar": { + "name": "Battery imported from solar" + }, + "battery_energy_imported_from_generator": { + "name": "Battery imported from generator" + }, + "consumer_energy_imported_from_grid": { + "name": "Consumer imported from grid" + }, + "consumer_energy_imported_from_solar": { + "name": "Consumer imported from solar" + }, + "consumer_energy_imported_from_battery": { + "name": "Consumer imported from battery" + }, + "consumer_energy_imported_from_generator": { + "name": "Consumer imported from generator" + }, + "total_home_usage": { + "name": "Home usage" + }, + "total_battery_charge": { + "name": "Battery charged" + }, + "total_battery_discharge": { + "name": "Battery discharged" + }, + "total_solar_generation": { + "name": "Solar generated" + }, + "total_grid_energy_exported": { + "name": "Grid exported" } }, "switch": { diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 03b9e2c6eb6..d50986bdb43 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -10,6 +10,7 @@ import pytest from .const import ( COMMAND_OK, + ENERGY_HISTORY, LIVE_STATUS, METADATA, PRODUCTS, @@ -95,3 +96,13 @@ def mock_site_info(): side_effect=lambda: deepcopy(SITE_INFO), ) as mock_live_status: yield mock_live_status + + +@pytest.fixture(autouse=True) +def mock_energy_history(): + """Mock Teslemetry Energy Specific site_info method.""" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.energy_history", + return_value=ENERGY_HISTORY, + ) as mock_live_status: + yield mock_live_status diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 6a3a657a1b1..e459379ccf7 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -15,6 +15,7 @@ VEHICLE_DATA = load_json_object_fixture("vehicle_data.json", DOMAIN) VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) +ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} diff --git a/tests/components/teslemetry/fixtures/energy_history.json b/tests/components/teslemetry/fixtures/energy_history.json new file mode 100644 index 00000000000..2b787beafac --- /dev/null +++ b/tests/components/teslemetry/fixtures/energy_history.json @@ -0,0 +1,55 @@ +{ + "response": { + "serial_number": "xxxxxx", + "period": "day", + "installation_time_zone": "Australia/Brisbane", + "time_series": [ + { + "timestamp": "2024-09-18T00:00:00+10:00", + "solar_energy_exported": 0, + "generator_energy_exported": 0, + "grid_energy_imported": 0, + "grid_services_energy_imported": 0, + "grid_services_energy_exported": 0, + "grid_energy_exported_from_solar": 0, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 0, + "battery_energy_exported": 36, + "battery_energy_imported_from_grid": 0, + "battery_energy_imported_from_solar": 0, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 0, + "consumer_energy_imported_from_solar": 0, + "consumer_energy_imported_from_battery": 36, + "consumer_energy_imported_from_generator": 0, + "raw_timestamp": "2024-09-18T00:00:00+10:00", + "total_home_usage": 36, + "total_battery_discharge": 36 + }, + { + "timestamp": "2024-09-18T08:45:00+10:00", + "solar_energy_exported": 724, + "generator_energy_exported": 0, + "grid_energy_imported": 0, + "grid_services_energy_imported": 0, + "grid_services_energy_exported": 0, + "grid_energy_exported_from_solar": 2, + "grid_energy_exported_from_generator": 0, + "grid_energy_exported_from_battery": 0, + "battery_energy_exported": 0, + "battery_energy_imported_from_grid": 0, + "battery_energy_imported_from_solar": 684, + "battery_energy_imported_from_generator": 0, + "consumer_energy_imported_from_grid": 0, + "consumer_energy_imported_from_solar": 38, + "consumer_energy_imported_from_battery": 0, + "consumer_energy_imported_from_generator": 0, + "raw_timestamp": "2024-09-18T08:45:00+10:00", + "total_home_usage": 38, + "total_solar_generation": 724, + "total_battery_charge": 684, + "total_grid_energy_exported": 2 + } + ] + } +} diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 0b664e78626..36ce65b2c89 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -1,4 +1,442 @@ # serializer version: 1 +# name: test_sensors[sensor.energy_site_battery_charged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_charged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery charged', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_battery_charge', + 'unique_id': '123456-total_battery_charge', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_charged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery charged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.684', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_charged-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery charged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_charged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.684', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_discharged', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery discharged', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_battery_discharge', + 'unique_id': '123456-total_battery_discharge', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery discharged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_discharged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.036', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_discharged-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery discharged', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_discharged', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.036', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery exported', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_exported', + 'unique_id': '123456-battery_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.036', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.036', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from generator', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_generator', + 'unique_id': '123456-battery_energy_imported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from grid', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_grid', + 'unique_id': '123456-battery_energy_imported_from_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery imported from solar', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_energy_imported_from_solar', + 'unique_id': '123456-battery_energy_imported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.684', + }) +# --- +# name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Battery imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_battery_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.684', + }) +# --- # name: test_sensors[sensor.energy_site_battery_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -72,6 +510,298 @@ 'state': '5.06', }) # --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from battery', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_battery', + 'unique_id': '123456-consumer_energy_imported_from_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.036', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.036', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from generator', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_generator', + 'unique_id': '123456-consumer_energy_imported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from grid', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_grid', + 'unique_id': '123456-consumer_energy_imported_from_grid', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from grid', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Consumer imported from solar', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'consumer_energy_imported_from_solar', + 'unique_id': '123456-consumer_energy_imported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.038', + }) +# --- +# name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Consumer imported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_consumer_imported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.038', + }) +# --- # name: test_sensors[sensor.energy_site_energy_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -145,6 +875,79 @@ 'state': '38.8964736842105', }) # --- +# name: test_sensors[sensor.energy_site_generator_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_generator_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Generator exported', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'generator_energy_exported', + 'unique_id': '123456-generator_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Generator exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_generator_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Generator exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_generator_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_generator_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -218,6 +1021,371 @@ 'state': '0.0', }) # --- +# name: test_sensors[sensor.energy_site_grid_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_grid_energy_exported', + 'unique_id': '123456-total_grid_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.002', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.002', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported from battery', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_battery', + 'unique_id': '123456-grid_energy_exported_from_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from battery', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported from generator', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_generator', + 'unique_id': '123456-grid_energy_exported_from_generator', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from generator', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_generator', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid exported from solar', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_exported_from_solar', + 'unique_id': '123456-grid_energy_exported_from_solar', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.002', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid exported from solar', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_exported_from_solar', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.002', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid imported', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_energy_imported', + 'unique_id': '123456-grid_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_imported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_grid_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -291,6 +1459,152 @@ 'state': '0.0', }) # --- +# name: test_sensors[sensor.energy_site_grid_services_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid services exported', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_services_energy_exported', + 'unique_id': '123456-grid_services_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Grid services imported', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'grid_services_energy_imported', + 'unique_id': '123456-grid_services_energy_imported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[sensor.energy_site_grid_services_imported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Grid services imported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_grid_services_imported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[sensor.energy_site_grid_services_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -364,6 +1678,79 @@ 'state': '0.0', }) # --- +# name: test_sensors[sensor.energy_site_home_usage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_home_usage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Home usage', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_home_usage', + 'unique_id': '123456-total_home_usage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_home_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Home usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_home_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.074', + }) +# --- +# name: test_sensors[sensor.energy_site_home_usage-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Home usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_home_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.074', + }) +# --- # name: test_sensors[sensor.energy_site_load_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -568,6 +1955,152 @@ 'state': '95.5053740373966', }) # --- +# name: test_sensors[sensor.energy_site_solar_exported-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_exported', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar exported', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'solar_energy_exported', + 'unique_id': '123456-solar_energy_exported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_exported-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.724', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_exported-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar exported', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_exported', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.724', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.energy_site_solar_generated', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar generated', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_solar_generation', + 'unique_id': '123456-total_solar_generation', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.724', + }) +# --- +# name: test_sensors[sensor.energy_site_solar_generated-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Energy Site Solar generated', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.energy_site_solar_generated', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.724', + }) +# --- # name: test_sensors[sensor.energy_site_solar_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 5520a5549bd..b96ef42cd2e 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -188,3 +188,17 @@ async def test_energy_site_refresh_error( mock_site_info.side_effect = side_effect entry = await setup_platform(hass) assert entry.state is state + + +# Test Energy History Coordinator +@pytest.mark.parametrize(("side_effect", "state"), ERRORS) +async def test_energy_history_refresh_error( + hass: HomeAssistant, + mock_energy_history: AsyncMock, + side_effect: TeslaFleetError, + state: ConfigEntryState, +) -> None: + """Test coordinator refresh with an error.""" + mock_energy_history.side_effect = side_effect + entry = await setup_platform(hass) + assert entry.state is state From 010a5d2829d03c864278a940308a4fe69d173bbc Mon Sep 17 00:00:00 2001 From: "Steven B." <51370195+sdb9696@users.noreply.github.com> Date: Tue, 24 Sep 2024 09:53:19 +0100 Subject: [PATCH 1057/1309] Add snapshots to all ring platform tests (#126560) Add test snapshots to all ring platform tests --- tests/components/ring/device_mocks.py | 2 + .../ring/snapshots/test_binary_sensor.ambr | 241 ++++ .../ring/snapshots/test_button.ambr | 48 + .../ring/snapshots/test_camera.ambr | 159 +++ .../components/ring/snapshots/test_event.ambr | 337 +++++ .../components/ring/snapshots/test_light.ambr | 113 ++ .../ring/snapshots/test_sensor.ambr | 1149 +++++++++++++++++ tests/components/ring/test_binary_sensor.py | 55 +- tests/components/ring/test_button.py | 21 +- tests/components/ring/test_camera.py | 27 +- tests/components/ring/test_event.py | 20 +- tests/components/ring/test_light.py | 23 +- tests/components/ring/test_sensor.py | 85 +- tests/components/ring/test_switch.py | 16 - 14 files changed, 2197 insertions(+), 99 deletions(-) create mode 100644 tests/components/ring/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/ring/snapshots/test_button.ambr create mode 100644 tests/components/ring/snapshots/test_camera.ambr create mode 100644 tests/components/ring/snapshots/test_event.ambr create mode 100644 tests/components/ring/snapshots/test_light.ambr create mode 100644 tests/components/ring/snapshots/test_sensor.ambr diff --git a/tests/components/ring/device_mocks.py b/tests/components/ring/device_mocks.py index 4c475c0be87..a1833aaa8bd 100644 --- a/tests/components/ring/device_mocks.py +++ b/tests/components/ring/device_mocks.py @@ -34,6 +34,8 @@ CHIME_HEALTH = load_json_value_fixture("chime_health_attrs.json", DOMAIN) FRONT_DOOR_DEVICE_ID = 987654 INGRESS_DEVICE_ID = 185036587 FRONT_DEVICE_ID = 765432 +INTERNAL_DEVICE_ID = 345678 +DOWNSTAIRS_DEVICE_ID = 123456 def get_mock_devices(): diff --git a/tests/components/ring/snapshots/test_binary_sensor.ambr b/tests/components/ring/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..2f8e4d8a219 --- /dev/null +++ b/tests/components/ring/snapshots/test_binary_sensor.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_states[binary_sensor.front_door_ding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ding', + 'unique_id': '987654-ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.front_door_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'occupancy', + 'friendly_name': 'Front Door Ding', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_states[binary_sensor.front_door_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_door_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion', + 'unique_id': '987654-motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.front_door_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'motion', + 'friendly_name': 'Front Door Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.front_door_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_states[binary_sensor.front_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.front_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion', + 'unique_id': '765432-motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.front_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'motion', + 'friendly_name': 'Front Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.front_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_states[binary_sensor.ingress_ding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.ingress_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ding', + 'unique_id': '185036587-ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.ingress_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'occupancy', + 'friendly_name': 'Ingress Ding', + }), + 'context': , + 'entity_id': 'binary_sensor.ingress_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_states[binary_sensor.internal_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.internal_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion', + 'unique_id': '345678-motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[binary_sensor.internal_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'motion', + 'friendly_name': 'Internal Motion', + }), + 'context': , + 'entity_id': 'binary_sensor.internal_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/ring/snapshots/test_button.ambr b/tests/components/ring/snapshots/test_button.ambr new file mode 100644 index 00000000000..01f6525450b --- /dev/null +++ b/tests/components/ring/snapshots/test_button.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_states[button.ingress_open_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.ingress_open_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Open door', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'open_door', + 'unique_id': '185036587-open_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[button.ingress_open_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Open door', + }), + 'context': , + 'entity_id': 'button.ingress_open_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ring/snapshots/test_camera.ambr b/tests/components/ring/snapshots/test_camera.ambr new file mode 100644 index 00000000000..4347f302c72 --- /dev/null +++ b/tests/components/ring/snapshots/test_camera.ambr @@ -0,0 +1,159 @@ +# serializer version: 1 +# name: test_states[camera.front-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.front', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '765432', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[camera.front-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'attribution': 'Data provided by Ring.com', + 'entity_picture': '/api/camera_proxy/camera.front?token=1caab5c3b3', + 'friendly_name': 'Front', + 'last_video_id': None, + 'supported_features': , + 'video_url': None, + }), + 'context': , + 'entity_id': 'camera.front', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_states[camera.front_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.front_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '987654', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[camera.front_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'attribution': 'Data provided by Ring.com', + 'entity_picture': '/api/camera_proxy/camera.front_door?token=1caab5c3b3', + 'friendly_name': 'Front Door', + 'last_video_id': None, + 'motion_detection': True, + 'supported_features': , + 'video_url': None, + }), + 'context': , + 'entity_id': 'camera.front_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_states[camera.internal-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'camera', + 'entity_category': None, + 'entity_id': 'camera.internal', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '345678', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[camera.internal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'access_token': '1caab5c3b3', + 'attribution': 'Data provided by Ring.com', + 'entity_picture': '/api/camera_proxy/camera.internal?token=1caab5c3b3', + 'friendly_name': 'Internal', + 'last_video_id': None, + 'motion_detection': True, + 'supported_features': , + 'video_url': None, + }), + 'context': , + 'entity_id': 'camera.internal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/ring/snapshots/test_event.ambr b/tests/components/ring/snapshots/test_event.ambr new file mode 100644 index 00000000000..e97a01516bb --- /dev/null +++ b/tests/components/ring/snapshots/test_event.ambr @@ -0,0 +1,337 @@ +# serializer version: 1 +# name: test_states[event.front_door_ding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'ding', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.front_door_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ding', + 'unique_id': '987654-ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[event.front_door_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'doorbell', + 'event_type': None, + 'event_types': list([ + 'ding', + ]), + 'friendly_name': 'Front Door Ding', + }), + 'context': , + 'entity_id': 'event.front_door_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[event.front_door_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'motion', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.front_door_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion', + 'unique_id': '987654-motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[event.front_door_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'motion', + 'event_type': None, + 'event_types': list([ + 'motion', + ]), + 'friendly_name': 'Front Door Motion', + }), + 'context': , + 'entity_id': 'event.front_door_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[event.front_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'motion', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.front_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion', + 'unique_id': '765432-motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[event.front_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'motion', + 'event_type': None, + 'event_types': list([ + 'motion', + ]), + 'friendly_name': 'Front Motion', + }), + 'context': , + 'entity_id': 'event.front_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[event.ingress_ding-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'ding', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.ingress_ding', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ding', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'ding', + 'unique_id': '185036587-ding', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[event.ingress_ding-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'doorbell', + 'event_type': None, + 'event_types': list([ + 'ding', + ]), + 'friendly_name': 'Ingress Ding', + }), + 'context': , + 'entity_id': 'event.ingress_ding', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[event.ingress_intercom_unlock-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'intercom_unlock', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.ingress_intercom_unlock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Intercom unlock', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'intercom_unlock', + 'unique_id': '185036587-intercom_unlock', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[event.ingress_intercom_unlock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'intercom_unlock', + ]), + 'friendly_name': 'Ingress Intercom unlock', + }), + 'context': , + 'entity_id': 'event.ingress_intercom_unlock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[event.internal_motion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'motion', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.internal_motion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Motion', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'motion', + 'unique_id': '345678-motion', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[event.internal_motion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'motion', + 'event_type': None, + 'event_types': list([ + 'motion', + ]), + 'friendly_name': 'Internal Motion', + }), + 'context': , + 'entity_id': 'event.internal_motion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ring/snapshots/test_light.ambr b/tests/components/ring/snapshots/test_light.ambr new file mode 100644 index 00000000000..73874fda259 --- /dev/null +++ b/tests/components/ring/snapshots/test_light.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_states[light.front_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.front_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '765432', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[light.front_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'color_mode': None, + 'friendly_name': 'Front Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.front_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_states[light.internal_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.internal_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': '345678', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[light.internal_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'color_mode': , + 'friendly_name': 'Internal Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.internal_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/ring/snapshots/test_sensor.ambr b/tests/components/ring/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..063675ce214 --- /dev/null +++ b/tests/components/ring/snapshots/test_sensor.ambr @@ -0,0 +1,1149 @@ +# serializer version: 1 +# name: test_states[sensor.downstairs_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.downstairs_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '123456-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.downstairs_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Downstairs Volume', + }), + 'context': , + 'entity_id': 'sensor.downstairs_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_states[sensor.downstairs_wifi_signal_category-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.downstairs_wifi_signal_category', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal category', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_category', + 'unique_id': '123456-wifi_signal_category', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.downstairs_wifi_signal_category-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Downstairs Wi-Fi signal category', + }), + 'context': , + 'entity_id': 'sensor.downstairs_wifi_signal_category', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.downstairs_wifi_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.downstairs_wifi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_strength', + 'unique_id': '123456-wifi_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_states[sensor.downstairs_wifi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'signal_strength', + 'friendly_name': 'Downstairs Wi-Fi signal strength', + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.downstairs_wifi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '765432-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.front_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'battery', + 'friendly_name': 'Front Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.front_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_states[sensor.front_door_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '987654-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.front_door_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'battery', + 'friendly_name': 'Front Door Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.front_door_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_states[sensor.front_door_last_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_last_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last activity', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_activity', + 'unique_id': '987654-last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_last_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Door Last activity', + }), + 'context': , + 'entity_id': 'sensor.front_door_last_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_door_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_door_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': '765432-volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Volume', + }), + 'context': , + 'entity_id': 'sensor.front_door_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11', + }) +# --- +# name: test_states[sensor.front_door_wi_fi_signal_category-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_wi_fi_signal_category', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal category', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_category', + 'unique_id': '987654-wifi_signal_category', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_wifi_signal_category-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_wifi_signal_category', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal category', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_category', + 'unique_id': '987654-wifi_signal_category', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_door_wifi_signal_category-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Door Wi-Fi signal category', + }), + 'context': , + 'entity_id': 'sensor.front_door_wifi_signal_category', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_door_wifi_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_door_wifi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_strength', + 'unique_id': '987654-wifi_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_states[sensor.front_door_wifi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'signal_strength', + 'friendly_name': 'Front Door Wi-Fi signal strength', + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.front_door_wifi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_last_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.front_last_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last activity', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_activity', + 'unique_id': '765432-last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_last_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Front Last activity', + }), + 'context': , + 'entity_id': 'sensor.front_last_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_wifi_signal_category-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_wifi_signal_category', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal category', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_category', + 'unique_id': '765432-wifi_signal_category', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.front_wifi_signal_category-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Front Wi-Fi signal category', + }), + 'context': , + 'entity_id': 'sensor.front_wifi_signal_category', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.front_wifi_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.front_wifi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_strength', + 'unique_id': '765432-wifi_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_states[sensor.front_wifi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'signal_strength', + 'friendly_name': 'Front Wi-Fi signal strength', + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.front_wifi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.ingress_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ingress_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '185036587-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.ingress_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'battery', + 'friendly_name': 'Ingress Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ingress_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52', + }) +# --- +# name: test_states[sensor.ingress_doorbell_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ingress_doorbell_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Doorbell volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'doorbell_volume', + 'unique_id': '185036587-doorbell_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.ingress_doorbell_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Doorbell volume', + }), + 'context': , + 'entity_id': 'sensor.ingress_doorbell_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_states[sensor.ingress_last_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ingress_last_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last activity', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_activity', + 'unique_id': '185036587-last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.ingress_last_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Ingress Last activity', + }), + 'context': , + 'entity_id': 'sensor.ingress_last_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.ingress_mic_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ingress_mic_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Mic volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'mic_volume', + 'unique_id': '185036587-mic_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.ingress_mic_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Mic volume', + }), + 'context': , + 'entity_id': 'sensor.ingress_mic_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11', + }) +# --- +# name: test_states[sensor.ingress_voice_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.ingress_voice_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Voice volume', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'voice_volume', + 'unique_id': '185036587-voice_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.ingress_voice_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Voice volume', + }), + 'context': , + 'entity_id': 'sensor.ingress_voice_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11', + }) +# --- +# name: test_states[sensor.ingress_wifi_signal_category-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ingress_wifi_signal_category', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal category', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_category', + 'unique_id': '185036587-wifi_signal_category', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.ingress_wifi_signal_category-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Ingress Wi-Fi signal category', + }), + 'context': , + 'entity_id': 'sensor.ingress_wifi_signal_category', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.ingress_wifi_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.ingress_wifi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_strength', + 'unique_id': '185036587-wifi_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_states[sensor.ingress_wifi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'signal_strength', + 'friendly_name': 'Ingress Wi-Fi signal strength', + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.ingress_wifi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.internal_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.internal_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '345678-battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_states[sensor.internal_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'battery', + 'friendly_name': 'Internal Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.internal_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_states[sensor.internal_last_activity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.internal_last_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last activity', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_activity', + 'unique_id': '345678-last_activity', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.internal_last_activity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'timestamp', + 'friendly_name': 'Internal Last activity', + }), + 'context': , + 'entity_id': 'sensor.internal_last_activity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.internal_wifi_signal_category-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.internal_wifi_signal_category', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal category', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_category', + 'unique_id': '345678-wifi_signal_category', + 'unit_of_measurement': None, + }) +# --- +# name: test_states[sensor.internal_wifi_signal_category-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'friendly_name': 'Internal Wi-Fi signal category', + }), + 'context': , + 'entity_id': 'sensor.internal_wifi_signal_category', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_states[sensor.internal_wifi_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.internal_wifi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'ring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wifi_signal_strength', + 'unique_id': '345678-wifi_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_states[sensor.internal_wifi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Ring.com', + 'device_class': 'signal_strength', + 'friendly_name': 'Internal Wi-Fi signal strength', + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.internal_wifi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py index 6a4ce652573..81d7d6e6687 100644 --- a/tests/components/ring/test_binary_sensor.py +++ b/tests/components/ring/test_binary_sensor.py @@ -1,11 +1,12 @@ """The tests for the Ring binary sensor platform.""" import time -from unittest.mock import patch +from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest from ring_doorbell import Ring +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.ring.binary_sensor import RingEvent @@ -17,10 +18,56 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component -from .common import setup_automation -from .device_mocks import FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID +from .common import MockConfigEntry, setup_automation, setup_platform +from .device_mocks import ( + FRONT_DEVICE_ID, + FRONT_DOOR_DEVICE_ID, + INGRESS_DEVICE_ID, + INTERNAL_DEVICE_ID, +) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform + + +@pytest.fixture +def create_deprecated_binary_sensor_entities( + hass: HomeAssistant, + mock_config_entry: ConfigEntry, + 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=BINARY_SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=unique_id, + suggested_object_id=f"{device_name}_{key}", + config_entry=mock_config_entry, + ) + + create_entry("front", FRONT_DEVICE_ID, "motion") + create_entry("front_door", FRONT_DOOR_DEVICE_ID, "motion") + create_entry("internal", INTERNAL_DEVICE_ID, "motion") + + create_entry("ingress", INGRESS_DEVICE_ID, "ding") + create_entry("front_door", FRONT_DOOR_DEVICE_ID, "ding") + + +async def test_states( + hass: HomeAssistant, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + create_deprecated_binary_sensor_entities, +) -> None: + """Test states.""" + await setup_platform(hass, Platform.BINARY_SENSOR) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/ring/test_button.py b/tests/components/ring/test_button.py index 946a893c8ad..ada02f206f5 100644 --- a/tests/components/ring/test_button.py +++ b/tests/components/ring/test_button.py @@ -1,22 +1,29 @@ """The tests for the Ring button platform.""" +from unittest.mock import Mock + +from syrupy.assertion import SnapshotAssertion + from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from .common import MockConfigEntry, setup_platform + +from tests.common import snapshot_platform -async def test_entity_registry( +async def test_states( hass: HomeAssistant, - mock_ring_client, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Tests that the devices are registered in the entity registry.""" + """Test states.""" + mock_config_entry.add_to_hass(hass) await setup_platform(hass, Platform.BUTTON) - - entry = entity_registry.async_get("button.ingress_open_door") - assert entry.unique_id == "185036587-open_door" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_button_opens_door( diff --git a/tests/components/ring/test_camera.py b/tests/components/ring/test_camera.py index 245c4ce6228..94ddc335dac 100644 --- a/tests/components/ring/test_camera.py +++ b/tests/components/ring/test_camera.py @@ -1,11 +1,12 @@ """The tests for the Ring switch platform.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import make_mocked_request from freezegun.api import FrozenDateTimeFactory import pytest import ring_doorbell +from syrupy.assertion import SnapshotAssertion from homeassistant.components import camera from homeassistant.components.ring.camera import FORCE_REFRESH_INTERVAL @@ -17,9 +18,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util.aiohttp import MockStreamReader -from .common import setup_platform +from .common import MockConfigEntry, setup_platform -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform SMALLEST_VALID_JPEG = ( "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" @@ -29,19 +30,19 @@ SMALLEST_VALID_JPEG = ( SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) -async def test_entity_registry( +async def test_states( hass: HomeAssistant, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - mock_ring_client, + snapshot: SnapshotAssertion, ) -> None: - """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, Platform.CAMERA) - - entry = entity_registry.async_get("camera.front") - assert entry.unique_id == "765432" - - entry = entity_registry.async_get("camera.internal") - assert entry.unique_id == "345678" + """Test states.""" + mock_config_entry.add_to_hass(hass) + # Patch getrandbits so the access_token doesn't change on camera attributes + with patch("random.SystemRandom.getrandbits", return_value=123123123123): + await setup_platform(hass, Platform.CAMERA) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/ring/test_event.py b/tests/components/ring/test_event.py index c546f9ea136..5cd60382a97 100644 --- a/tests/components/ring/test_event.py +++ b/tests/components/ring/test_event.py @@ -2,19 +2,37 @@ from datetime import datetime import time +from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory import pytest from ring_doorbell import Ring +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ring.binary_sensor import RingEvent from homeassistant.components.ring.coordinator import RingEventListener from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from .common import MockConfigEntry, setup_platform from .device_mocks import FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID +from tests.common import snapshot_platform + + +async def test_states( + hass: HomeAssistant, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test states.""" + mock_config_entry.add_to_hass(hass) + await setup_platform(hass, Platform.EVENT) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + @pytest.mark.parametrize( ("device_id", "device_name", "alert_kind", "device_class"), diff --git a/tests/components/ring/test_light.py b/tests/components/ring/test_light.py index 8ac47ac2f1d..0be314c3135 100644 --- a/tests/components/ring/test_light.py +++ b/tests/components/ring/test_light.py @@ -1,7 +1,10 @@ """The tests for the Ring light platform.""" +from unittest.mock import Mock + import pytest import ring_doorbell +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import Platform @@ -9,22 +12,22 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import setup_platform +from .common import MockConfigEntry, setup_platform + +from tests.common import snapshot_platform -async def test_entity_registry( +async def test_states( hass: HomeAssistant, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - mock_ring_client, + snapshot: SnapshotAssertion, ) -> None: - """Tests that the devices are registered in the entity registry.""" + """Test states.""" + mock_config_entry.add_to_hass(hass) await setup_platform(hass, Platform.LIGHT) - - entry = entity_registry.async_get("light.front_light") - assert entry.unique_id == "765432" - - entry = entity_registry.async_get("light.internal_light") - assert entry.unique_id == "345678" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_light_off_reports_correctly( diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py index 07f35a3ff79..48f679c4524 100644 --- a/tests/components/ring/test_sensor.py +++ b/tests/components/ring/test_sensor.py @@ -1,32 +1,35 @@ """The tests for the Ring sensor platform.""" import logging -from unittest.mock import patch +from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest from ring_doorbell import Ring +from syrupy.assertion import SnapshotAssertion from homeassistant.components.ring.const import DOMAIN, SCAN_INTERVAL -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .common import setup_platform -from .device_mocks import FRONT_DEVICE_ID, FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID +from .common import MockConfigEntry, setup_platform +from .device_mocks import ( + DOWNSTAIRS_DEVICE_ID, + FRONT_DEVICE_ID, + FRONT_DOOR_DEVICE_ID, + INGRESS_DEVICE_ID, + INTERNAL_DEVICE_ID, +) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, snapshot_platform @pytest.fixture -def create_deprecated_sensor_entities( +def create_deprecated_and_disabled_sensor_entities( hass: HomeAssistant, mock_config_entry: ConfigEntry, entity_registry: er.EntityRegistry, @@ -48,48 +51,34 @@ def create_deprecated_sensor_entities( config_entry=mock_config_entry, ) - create_entry("downstairs", "volume", 123456) - create_entry("front_door", "volume", 987654) - create_entry("ingress", "doorbell_volume", 185036587) - create_entry("ingress", "mic_volume", 185036587) - create_entry("ingress", "voice_volume", 185036587) + # Deprecated + create_entry("downstairs", "volume", DOWNSTAIRS_DEVICE_ID) + create_entry("front_door", "volume", FRONT_DEVICE_ID) + create_entry("ingress", "doorbell_volume", INGRESS_DEVICE_ID) + create_entry("ingress", "mic_volume", INGRESS_DEVICE_ID) + create_entry("ingress", "voice_volume", INGRESS_DEVICE_ID) + + # Disabled + for desc in ("wifi_signal_category", "wifi_signal_strength"): + create_entry("downstairs", desc, DOWNSTAIRS_DEVICE_ID) + create_entry("front", desc, FRONT_DEVICE_ID) + create_entry("ingress", desc, INGRESS_DEVICE_ID) + create_entry("front_door", desc, FRONT_DOOR_DEVICE_ID) + create_entry("internal", desc, INTERNAL_DEVICE_ID) -async def test_sensor( +async def test_states( hass: HomeAssistant, - mock_ring_client, - create_deprecated_sensor_entities, + mock_ring_client: Mock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + create_deprecated_and_disabled_sensor_entities, ) -> None: - """Test the Ring sensors.""" - await setup_platform(hass, "sensor") - - front_battery_state = hass.states.get("sensor.front_battery") - assert front_battery_state is not None - assert front_battery_state.state == "80" - assert ( - front_battery_state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - ) - - front_door_battery_state = hass.states.get("sensor.front_door_battery") - assert front_door_battery_state is not None - assert front_door_battery_state.state == "100" - assert ( - front_door_battery_state.attributes[ATTR_STATE_CLASS] - == SensorStateClass.MEASUREMENT - ) - - downstairs_volume_state = hass.states.get("sensor.downstairs_volume") - assert downstairs_volume_state is not None - assert downstairs_volume_state.state == "2" - - ingress_mic_volume_state = hass.states.get("sensor.ingress_mic_volume") - assert ingress_mic_volume_state.state == "11" - - ingress_doorbell_volume_state = hass.states.get("sensor.ingress_doorbell_volume") - assert ingress_doorbell_volume_state.state == "8" - - ingress_voice_volume_state = hass.states.get("sensor.ingress_voice_volume") - assert ingress_voice_volume_state.state == "11" + """Test states.""" + mock_config_entry.add_to_hass(hass) + await setup_platform(hass, Platform.SENSOR) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @pytest.mark.parametrize( diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index d18add827ec..22b90253c23 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -50,22 +50,6 @@ def create_deprecated_siren_entity( create_entry("internal", 345678) -async def test_entity_registry( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_ring_client, - create_deprecated_siren_entity, -) -> None: - """Tests that the devices are registered in the entity registry.""" - await setup_platform(hass, Platform.SWITCH) - - entry = entity_registry.async_get("switch.front_siren") - assert entry.unique_id == "765432-siren" - - entry = entity_registry.async_get("switch.internal_siren") - assert entry.unique_id == "345678-siren" - - async def test_states( hass: HomeAssistant, mock_ring_client: Mock, From 58eccc1ed6949243fcf85db1635a709f582fe28a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 11:29:19 +0200 Subject: [PATCH 1058/1309] Bump deprecation of ESPHome assist in progress binary sensor (#126604) --- homeassistant/components/esphome/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 8c2353519fe..ac759aa7b17 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -92,7 +92,7 @@ class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntit self.hass, DOMAIN, f"assist_in_progress_deprecated_{self.registry_entry.id}", - breaks_in_ha_version="2025.3", + breaks_in_ha_version="2025.4", data={ "entity_id": self.entity_id, "entity_uuid": self.registry_entry.id, From c96d4991b9a854a09a0573709d56cc56696b18af Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 11:46:43 +0200 Subject: [PATCH 1059/1309] Add issue asking users to disable VoIP call_in_progress binary sensor (#126504) * Add issue asking users to disable VoIP call_in_progress binary sensor * Add tests * Add files * Update homeassistant/components/voip/binary_sensor.py Co-authored-by: Joost Lekkerkerker * Fix test --------- Co-authored-by: Joost Lekkerkerker --- .../components/assist_pipeline/strings.json | 4 +- .../components/voip/binary_sensor.py | 33 +++++ homeassistant/components/voip/repairs.py | 22 ++++ homeassistant/components/voip/strings.json | 12 ++ tests/components/voip/test_binary_sensor.py | 120 +++++++++++++++++- tests/components/voip/test_repairs.py | 13 ++ 6 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/voip/repairs.py create mode 100644 tests/components/voip/test_repairs.py diff --git a/homeassistant/components/assist_pipeline/strings.json b/homeassistant/components/assist_pipeline/strings.json index d81bcf83a1a..956c17dad60 100644 --- a/homeassistant/components/assist_pipeline/strings.json +++ b/homeassistant/components/assist_pipeline/strings.json @@ -24,11 +24,11 @@ }, "issues": { "assist_in_progress_deprecated": { - "title": "{integration_name} assist in progress binary sensors are deprecated", + "title": "{integration_name} in progress binary sensors are deprecated", "fix_flow": { "step": { "confirm_disable_entity": { - "description": "The {integration_name} assist in progress binary sensor `{entity_id}` is deprecated.\n\nMigrate your configuration to use the corresponding `{assist_satellite_domain}` entity and then click SUBMIT to disable the assist in progress binary sensor and fix this issue." + "description": "The {integration_name} in progress binary sensor `{entity_id}` is deprecated.\n\nMigrate your configuration to use the corresponding `{assist_satellite_domain}` entity and then click SUBMIT to disable the in progress binary sensor and fix this issue." } } } diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py index a1ef36a7086..f38b228c46c 100644 --- a/homeassistant/components/voip/binary_sensor.py +++ b/homeassistant/components/voip/binary_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -56,6 +57,38 @@ class VoIPCallInProgress(VoIPEntity, BinarySensorEntity): self.voip_device.async_listen_update(self._is_active_changed) ) + await super().async_added_to_hass() + if TYPE_CHECKING: + assert self.registry_entry is not None + ir.async_create_issue( + self.hass, + DOMAIN, + f"assist_in_progress_deprecated_{self.registry_entry.id}", + breaks_in_ha_version="2025.4", + data={ + "entity_id": self.entity_id, + "entity_uuid": self.registry_entry.id, + "integration_name": "VoIP", + }, + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="assist_in_progress_deprecated", + translation_placeholders={ + "integration_name": "VoIP", + }, + ) + + async def async_will_remove_from_hass(self) -> None: + """Remove issue.""" + await super().async_will_remove_from_hass() + if TYPE_CHECKING: + assert self.registry_entry is not None + ir.async_delete_issue( + self.hass, + DOMAIN, + f"assist_in_progress_deprecated_{self.registry_entry.id}", + ) + @callback def _is_active_changed(self, device: VoIPDevice) -> None: """Call when active state changed.""" diff --git a/homeassistant/components/voip/repairs.py b/homeassistant/components/voip/repairs.py new file mode 100644 index 00000000000..11cacbb7486 --- /dev/null +++ b/homeassistant/components/voip/repairs.py @@ -0,0 +1,22 @@ +"""Repairs implementation for the VoIP integration.""" + +from __future__ import annotations + +from homeassistant.components.assist_pipeline.repair_flows import ( + AssistInProgressDeprecatedRepairFlow, +) +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id.startswith("assist_in_progress_deprecated"): + return AssistInProgressDeprecatedRepairFlow(data) + # If VoIP adds confirm-only repairs in the future, this should be changed + # to return a ConfirmRepairFlow instead of raising a ValueError + raise ValueError(f"unknown repair {issue_id}") diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index 750f526ba1b..9da7cf7d534 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -47,6 +47,18 @@ } } }, + "issues": { + "assist_in_progress_deprecated": { + "title": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::title%]", + "fix_flow": { + "step": { + "confirm_disable_entity": { + "description": "[%key:component::assist_pipeline::issues::assist_in_progress_deprecated::fix_flow::step::confirm_disable_entity::description%]" + } + } + } + } + }, "options": { "step": { "init": { diff --git a/tests/components/voip/test_binary_sensor.py b/tests/components/voip/test_binary_sensor.py index 50a8c5d4141..44ac8e4d77f 100644 --- a/tests/components/voip/test_binary_sensor.py +++ b/tests/components/voip/test_binary_sensor.py @@ -1,11 +1,18 @@ """Test VoIP binary sensor devices.""" +from http import HTTPStatus + import pytest +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.components.voip import DOMAIN from homeassistant.components.voip.devices import VoIPDevice from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -45,3 +52,114 @@ async def test_assist_in_progress_disabled_by_default( assert entity_entry assert entity_entry.disabled assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_assist_in_progress_issue( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + voip_device: VoIPDevice, +) -> None: + """Test assist in progress binary sensor.""" + + call_in_progress_entity_id = "binary_sensor.192_168_1_210_call_in_progress" + + state = hass.states.get(call_in_progress_entity_id) + assert state is not None + + entity_entry = entity_registry.async_get(call_in_progress_entity_id) + issue = issue_registry.async_get_issue( + DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" + ) + assert issue is not None + + # Test issue goes away after disabling the entity + entity_registry.async_update_entity( + call_in_progress_entity_id, + disabled_by=er.RegistryEntryDisabler.USER, + ) + await hass.async_block_till_done() + issue = issue_registry.async_get_issue( + DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" + ) + assert issue is None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_assist_in_progress_repair_flow( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + issue_registry: ir.IssueRegistry, + voip_device: VoIPDevice, +) -> None: + """Test assist in progress binary sensor deprecation issue flow.""" + + call_in_progress_entity_id = "binary_sensor.192_168_1_210_call_in_progress" + + state = hass.states.get(call_in_progress_entity_id) + assert state is not None + + entity_entry = entity_registry.async_get(call_in_progress_entity_id) + assert entity_entry.disabled_by is None + issue = issue_registry.async_get_issue( + DOMAIN, f"assist_in_progress_deprecated_{entity_entry.id}" + ) + assert issue is not None + assert issue.data == { + "entity_id": call_in_progress_entity_id, + "entity_uuid": entity_entry.id, + "integration_name": "VoIP", + } + assert issue.translation_key == "assist_in_progress_deprecated" + assert issue.translation_placeholders == {"integration_name": "VoIP"} + + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + await hass.async_block_till_done() + await hass.async_start() + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "data_schema": [], + "description_placeholders": { + "assist_satellite_domain": "assist_satellite", + "entity_id": call_in_progress_entity_id, + "integration_name": "VoIP", + }, + "errors": None, + "flow_id": flow_id, + "handler": DOMAIN, + "last_step": None, + "preview": None, + "step_id": "confirm_disable_entity", + "type": "form", + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "description": None, + "description_placeholders": None, + "flow_id": flow_id, + "handler": DOMAIN, + "type": "create_entry", + } + + # Test the entity is disabled + entity_entry = entity_registry.async_get(call_in_progress_entity_id) + assert entity_entry.disabled_by is er.RegistryEntryDisabler.USER diff --git a/tests/components/voip/test_repairs.py b/tests/components/voip/test_repairs.py new file mode 100644 index 00000000000..ec2a2cfed96 --- /dev/null +++ b/tests/components/voip/test_repairs.py @@ -0,0 +1,13 @@ +"""Test VoIP repairs.""" + +import pytest + +from homeassistant.components.voip import repairs +from homeassistant.core import HomeAssistant + + +async def test_create_fix_flow_raises_on_unknown_issue_id(hass: HomeAssistant) -> None: + """Test reate_fix_flow raises on unknown issue_id.""" + + with pytest.raises(ValueError): + await repairs.async_create_fix_flow(hass, "no_such_issue", None) From f2092ef0838e6c395045fbde4744aa2bf27cc1e7 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 24 Sep 2024 12:02:01 +0200 Subject: [PATCH 1060/1309] Prevent KeyError in Matter select entity (#126605) --- homeassistant/components/matter/select.py | 8 ++++---- tests/components/matter/test_select.py | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index b46cad53123..2e9c44a8f8a 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -229,18 +229,18 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.CONFIG, translation_key="startup_on_off", options=["On", "Off", "Toggle", "Previous"], - measurement_to_ha=lambda x: { # pylint: disable=unnecessary-lambda + measurement_to_ha={ 0: "Off", 1: "On", 2: "Toggle", None: "Previous", - }.get(x), - ha_to_native_value=lambda x: { + }.get, + ha_to_native_value={ "Off": 0, "On": 1, "Toggle": 2, "Previous": None, - }[x], + }.get, ), entity_class=MatterSelectEntity, required_attributes=(clusters.OnOff.Attributes.StartUpOnOff,), diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index f84e5870392..e380e5d5925 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -97,3 +97,8 @@ async def test_attribute_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state.state == "On" + # test that an invalid value (e.g. 255) leads to an unknown state + set_node_attribute(light_node, 1, 6, 16387, 255) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state.state == "unknown" From 4ac9b339a1cad0a2abfbf62b5592609c58fd57e3 Mon Sep 17 00:00:00 2001 From: "Lektri.co" <137074859+Lektrico@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:08:28 +0300 Subject: [PATCH 1061/1309] Add select platform to the Lektrico integration (#126490) * Add select for Lektrico integration. * Rename lb_mode to load_balancing_mode. * Update homeassistant/components/lektrico/strings.json --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/lektrico/__init__.py | 6 +- homeassistant/components/lektrico/select.py | 91 +++++++++++++++++++ .../components/lektrico/strings.json | 11 +++ .../lektrico/fixtures/get_info.json | 3 +- .../lektrico/snapshots/test_select.ambr | 60 ++++++++++++ tests/components/lektrico/test_select.py | 31 +++++++ 6 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/lektrico/select.py create mode 100644 tests/components/lektrico/snapshots/test_select.ambr create mode 100644 tests/components/lektrico/test_select.py diff --git a/homeassistant/components/lektrico/__init__.py b/homeassistant/components/lektrico/__init__.py index bd2ca8de214..0691bfef72a 100644 --- a/homeassistant/components/lektrico/__init__.py +++ b/homeassistant/components/lektrico/__init__.py @@ -18,7 +18,11 @@ CHARGERS_PLATFORMS: list[Platform] = [ ] # List the platforms that load balancer device supports. -LB_DEVICES_PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR] +LB_DEVICES_PLATFORMS: list[Platform] = [ + Platform.BUTTON, + Platform.SELECT, + Platform.SENSOR, +] type LektricoConfigEntry = ConfigEntry[LektricoDeviceDataUpdateCoordinator] diff --git a/homeassistant/components/lektrico/select.py b/homeassistant/components/lektrico/select.py new file mode 100644 index 00000000000..ef45d97d697 --- /dev/null +++ b/homeassistant/components/lektrico/select.py @@ -0,0 +1,91 @@ +"""Support for Lektrico select entities.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from typing import Any + +from lektricowifi import Device + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator +from .entity import LektricoEntity + + +@dataclass(frozen=True, kw_only=True) +class LektricoSelectEntityDescription(SelectEntityDescription): + """Describes Lektrico select entity.""" + + value_fn: Callable[[dict[str, Any]], str] + set_value_fn: Callable[[Device, int], Coroutine[Any, Any, dict[Any, Any]]] + + +LB_MODE_OPTIONS = [ + "disabled", + "power", + "hybrid", + "green", +] + + +SELECTS: tuple[LektricoSelectEntityDescription, ...] = ( + LektricoSelectEntityDescription( + key="load_balancing_mode", + translation_key="load_balancing_mode", + options=LB_MODE_OPTIONS, + entity_category=EntityCategory.CONFIG, + value_fn=lambda data: LB_MODE_OPTIONS[data["lb_mode"]], + set_value_fn=lambda device, value: device.set_load_balancing_mode(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LektricoConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Lektrico select entities based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + LektricoSelect( + description, + coordinator, + f"{entry.data[CONF_TYPE]}_{entry.data[ATTR_SERIAL_NUMBER]}", + ) + for description in SELECTS + ) + + +class LektricoSelect(LektricoEntity, SelectEntity): + """Defines a Lektrico select entity.""" + + entity_description: LektricoSelectEntityDescription + + def __init__( + self, + description: LektricoSelectEntityDescription, + coordinator: LektricoDeviceDataUpdateCoordinator, + device_name: str, + ) -> None: + """Initialize Lektrico select.""" + super().__init__(coordinator, device_name) + self.entity_description = description + self._attr_unique_id = f"{coordinator.serial_number}_{description.key}" + + @property + def current_option(self) -> str | None: + """Return the state of the select.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.set_value_fn( + self.coordinator.device, LB_MODE_OPTIONS.index(option) + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json index 3f4a732a4a0..b749ea23490 100644 --- a/homeassistant/components/lektrico/strings.json +++ b/homeassistant/components/lektrico/strings.json @@ -38,6 +38,17 @@ "name": "Dynamic limit" } }, + "select": { + "load_balancing_mode": { + "name": "Load balancing mode", + "state": { + "disabled": "[%key:common::state::disabled%]", + "power": "Power", + "hybrid": "Hybrid", + "green": "Green" + } + } + }, "sensor": { "state": { "name": "State", diff --git a/tests/components/lektrico/fixtures/get_info.json b/tests/components/lektrico/fixtures/get_info.json index 7c2fc30b0b0..2f190d2f00c 100644 --- a/tests/components/lektrico/fixtures/get_info.json +++ b/tests/components/lektrico/fixtures/get_info.json @@ -12,5 +12,6 @@ "fw_version": "1.44", "led_max_brightness": 20, "dynamic_current": 32, - "user_current": 32 + "user_current": 32, + "lb_mode": 0 } diff --git a/tests/components/lektrico/snapshots/test_select.ambr b/tests/components/lektrico/snapshots/test_select.ambr new file mode 100644 index 00000000000..5a964f52ada --- /dev/null +++ b/tests/components/lektrico/snapshots/test_select.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_all_entities[select.1p7k_500006_load_balancing_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'disabled', + 'power', + 'hybrid', + 'green', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.1p7k_500006_load_balancing_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Load balancing mode', + 'platform': 'lektrico', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'load_balancing_mode', + 'unique_id': '500006_load_balancing_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[select.1p7k_500006_load_balancing_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '1p7k_500006 Load balancing mode', + 'options': list([ + 'disabled', + 'power', + 'hybrid', + 'green', + ]), + }), + 'context': , + 'entity_id': 'select.1p7k_500006_load_balancing_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disabled', + }) +# --- diff --git a/tests/components/lektrico/test_select.py b/tests/components/lektrico/test_select.py new file mode 100644 index 00000000000..cb09c47535e --- /dev/null +++ b/tests/components/lektrico/test_select.py @@ -0,0 +1,31 @@ +"""Tests for the Lektrico select platform.""" + +from unittest.mock import AsyncMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch.multiple( + "homeassistant.components.lektrico", + CHARGERS_PLATFORMS=[Platform.SELECT], + LB_DEVICES_PLATFORMS=[Platform.SELECT], + ): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 711e0ee5030249420fd7a1ac9b5cf1eaced9406f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 24 Sep 2024 12:12:01 +0200 Subject: [PATCH 1062/1309] Change camera state to an enum (#126558) * Change camera state to an enum * copy/paste mistake * Add test deprecated constants --- homeassistant/components/camera/__init__.py | 15 ++++++---- homeassistant/components/camera/const.py | 8 +++++ homeassistant/components/push/camera.py | 6 ++-- tests/components/abode/test_camera.py | 6 ++-- tests/components/august/test_camera.py | 4 +-- tests/components/camera/test_init.py | 21 +++++++++++-- .../camera/test_significant_change.py | 8 ++--- tests/components/demo/test_camera.py | 15 +++++----- tests/components/doorbird/test_camera.py | 8 ++--- tests/components/esphome/test_camera.py | 26 ++++++++-------- tests/components/nest/test_camera.py | 30 +++++++++---------- tests/components/netatmo/test_camera.py | 6 ++-- tests/components/reolink/test_camera.py | 12 +++++--- tests/components/unifiprotect/test_camera.py | 6 ++-- tests/components/uvc/test_camera.py | 14 ++++----- tests/components/yale/test_camera.py | 4 +-- 16 files changed, 110 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index ae081b96cd8..88162df6f1a 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -77,6 +77,7 @@ from .const import ( # noqa: F401 PREF_ORIENTATION, PREF_PRELOAD_STREAM, SERVICE_RECORD, + CameraState, StreamType, ) from .img_util import scale_jpeg_camera_image @@ -98,9 +99,11 @@ ATTR_FILENAME: Final = "filename" ATTR_MEDIA_PLAYER: Final = "media_player" ATTR_FORMAT: Final = "format" -STATE_RECORDING: Final = "recording" -STATE_STREAMING: Final = "streaming" -STATE_IDLE: Final = "idle" +# These constants are deprecated as of Home Assistant 2024.10 +# Please use the StreamType enum instead. +_DEPRECATED_STATE_RECORDING = DeprecatedConstantEnum(CameraState.RECORDING, "2025.10") +_DEPRECATED_STATE_STREAMING = DeprecatedConstantEnum(CameraState.STREAMING, "2025.10") +_DEPRECATED_STATE_IDLE = DeprecatedConstantEnum(CameraState.IDLE, "2025.10") class CameraEntityFeature(IntFlag): @@ -674,10 +677,10 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def state(self) -> str: """Return the camera state.""" if self.is_recording: - return STATE_RECORDING + return CameraState.RECORDING if self.is_streaming: - return STATE_STREAMING - return STATE_IDLE + return CameraState.STREAMING + return CameraState.IDLE @cached_property def is_on(self) -> bool: diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index 453506e7a90..c4327e922e6 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -40,6 +40,14 @@ CAMERA_STREAM_SOURCE_TIMEOUT: Final = 10 CAMERA_IMAGE_TIMEOUT: Final = 10 +class CameraState(StrEnum): + """Camera entity states.""" + + RECORDING = "recording" + STREAMING = "streaming" + IDLE = "idle" + + class StreamType(StrEnum): """Camera stream type. diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py index 6e75cbec420..37ac6144d0d 100644 --- a/homeassistant/components/push/camera.py +++ b/homeassistant/components/push/camera.py @@ -15,8 +15,8 @@ from homeassistant.components import webhook from homeassistant.components.camera import ( DOMAIN as CAMERA_DOMAIN, PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA, - STATE_IDLE, Camera, + CameraState, ) from homeassistant.const import CONF_NAME, CONF_TIMEOUT, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant, callback @@ -135,7 +135,7 @@ class PushCamera(Camera): async def update_image(self, image, filename): """Update the camera image.""" - if self.state == STATE_IDLE: + if self.state == CameraState.IDLE: self._attr_is_recording = True self._last_trip = dt_util.utcnow() self.queue.clear() @@ -165,7 +165,7 @@ class PushCamera(Camera): ) -> bytes | None: """Return a still image response.""" if self.queue: - if self.state == STATE_IDLE: + if self.state == CameraState.IDLE: self.queue.rotate(1) self._current_image = self.queue[0] diff --git a/tests/components/abode/test_camera.py b/tests/components/abode/test_camera.py index 5cf3263876b..1fcf250935e 100644 --- a/tests/components/abode/test_camera.py +++ b/tests/components/abode/test_camera.py @@ -3,8 +3,8 @@ from unittest.mock import patch from homeassistant.components.abode.const import DOMAIN as ABODE_DOMAIN -from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, STATE_IDLE +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN, CameraState +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -26,7 +26,7 @@ async def test_attributes(hass: HomeAssistant) -> None: await setup_platform(hass, CAMERA_DOMAIN) state = hass.states.get("camera.test_cam") - assert state.state == STATE_IDLE + assert state.state == CameraState.IDLE async def test_capture_image(hass: HomeAssistant) -> None: diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py index 5ab7d49c3b8..287620cc872 100644 --- a/tests/components/august/test_camera.py +++ b/tests/components/august/test_camera.py @@ -6,7 +6,7 @@ from unittest.mock import patch from yalexs.const import Brand from yalexs.doorbell import ContentTokenExpired -from homeassistant.const import STATE_IDLE +from homeassistant.components.camera import CameraState from homeassistant.core import HomeAssistant from .mocks import _create_august_with_devices, _mock_doorbell_from_fixture @@ -26,7 +26,7 @@ async def test_create_doorbell( await _create_august_with_devices(hass, [doorbell_one], brand=Brand.AUGUST) camera_state = hass.states.get("camera.k98gidt45gul_name_camera") - assert camera_state.state == STATE_IDLE + assert camera_state.state == CameraState.IDLE url = camera_state.attributes["entity_picture"] diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 098c321e63b..fd3ee8df22e 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -766,7 +766,7 @@ async def test_state_streaming(hass: HomeAssistant) -> None: """Camera state.""" demo_camera = hass.states.get("camera.demo_camera") assert demo_camera is not None - assert demo_camera.state == camera.STATE_STREAMING + assert demo_camera.state == camera.CameraState.STREAMING @pytest.mark.usefixtures("mock_camera", "mock_stream") @@ -819,7 +819,7 @@ async def test_stream_unavailable( demo_camera = hass.states.get("camera.demo_camera") assert demo_camera is not None - assert demo_camera.state == camera.STATE_STREAMING + assert demo_camera.state == camera.CameraState.STREAMING @pytest.mark.usefixtures("mock_camera", "mock_stream_source") @@ -1043,6 +1043,23 @@ def test_deprecated_stream_type_constants( ) +@pytest.mark.parametrize( + "enum", + list(camera.const.CameraState), +) +@pytest.mark.parametrize( + "module", + [camera], +) +def test_deprecated_state_constants( + caplog: pytest.LogCaptureFixture, + enum: camera.const.StreamType, + module: ModuleType, +) -> None: + """Test deprecated stream type constants.""" + import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10") + + @pytest.mark.parametrize( "entity_feature", list(camera.CameraEntityFeature), diff --git a/tests/components/camera/test_significant_change.py b/tests/components/camera/test_significant_change.py index a2a7ef20e71..b89b1c26747 100644 --- a/tests/components/camera/test_significant_change.py +++ b/tests/components/camera/test_significant_change.py @@ -1,6 +1,6 @@ """Test the Camera significant change platform.""" -from homeassistant.components.camera import STATE_IDLE, STATE_RECORDING +from homeassistant.components.camera import CameraState from homeassistant.components.camera.significant_change import ( async_check_significant_change, ) @@ -10,11 +10,11 @@ async def test_significant_change() -> None: """Detect Camera significant changes.""" attrs = {} assert not async_check_significant_change( - None, STATE_IDLE, attrs, STATE_IDLE, attrs + None, CameraState.IDLE, attrs, CameraState.IDLE, attrs ) assert not async_check_significant_change( - None, STATE_IDLE, attrs, STATE_IDLE, {"dummy": "dummy"} + None, CameraState.IDLE, attrs, CameraState.IDLE, {"dummy": "dummy"} ) assert async_check_significant_change( - None, STATE_IDLE, attrs, STATE_RECORDING, attrs + None, CameraState.IDLE, attrs, CameraState.RECORDING, attrs ) diff --git a/tests/components/demo/test_camera.py b/tests/components/demo/test_camera.py index 89dd8e0cdf7..c8d8e1ef2e4 100644 --- a/tests/components/demo/test_camera.py +++ b/tests/components/demo/test_camera.py @@ -11,8 +11,7 @@ from homeassistant.components.camera import ( SERVICE_ENABLE_MOTION, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_IDLE, - STATE_STREAMING, + CameraState, async_get_image, ) from homeassistant.components.demo import DOMAIN @@ -46,7 +45,7 @@ async def demo_camera(hass: HomeAssistant, camera_only: None) -> None: async def test_init_state_is_streaming(hass: HomeAssistant) -> None: """Demo camera initialize as streaming.""" state = hass.states.get(ENTITY_CAMERA) - assert state.state == STATE_STREAMING + assert state.state == CameraState.STREAMING with patch( "homeassistant.components.demo.camera.Path.read_bytes", return_value=b"ON" @@ -59,21 +58,21 @@ async def test_init_state_is_streaming(hass: HomeAssistant) -> None: async def test_turn_on_state_back_to_streaming(hass: HomeAssistant) -> None: """After turn on state back to streaming.""" state = hass.states.get(ENTITY_CAMERA) - assert state.state == STATE_STREAMING + assert state.state == CameraState.STREAMING await hass.services.async_call( CAMERA_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_CAMERA}, blocking=True ) state = hass.states.get(ENTITY_CAMERA) - assert state.state == STATE_IDLE + assert state.state == CameraState.IDLE await hass.services.async_call( CAMERA_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_CAMERA}, blocking=True ) state = hass.states.get(ENTITY_CAMERA) - assert state.state == STATE_STREAMING + assert state.state == CameraState.STREAMING async def test_turn_off_image(hass: HomeAssistant) -> None: @@ -90,7 +89,7 @@ async def test_turn_off_image(hass: HomeAssistant) -> None: async def test_turn_off_invalid_camera(hass: HomeAssistant) -> None: """Turn off non-exist camera should quietly fail.""" state = hass.states.get(ENTITY_CAMERA) - assert state.state == STATE_STREAMING + assert state.state == CameraState.STREAMING await hass.services.async_call( CAMERA_DOMAIN, @@ -100,7 +99,7 @@ async def test_turn_off_invalid_camera(hass: HomeAssistant) -> None: ) state = hass.states.get(ENTITY_CAMERA) - assert state.state == STATE_STREAMING + assert state.state == CameraState.STREAMING async def test_motion_detection(hass: HomeAssistant) -> None: diff --git a/tests/components/doorbird/test_camera.py b/tests/components/doorbird/test_camera.py index 228a6c81daa..a310bcb88cc 100644 --- a/tests/components/doorbird/test_camera.py +++ b/tests/components/doorbird/test_camera.py @@ -4,7 +4,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.camera import ( - STATE_IDLE, + CameraState, async_get_image, async_get_stream_source, ) @@ -23,11 +23,11 @@ async def test_doorbird_cameras( """Test the doorbird cameras.""" doorbird_entry = await doorbird_mocker() live_camera_entity_id = "camera.mydoorbird_live" - assert hass.states.get(live_camera_entity_id).state == STATE_IDLE + assert hass.states.get(live_camera_entity_id).state == CameraState.IDLE last_motion_camera_entity_id = "camera.mydoorbird_last_motion" - assert hass.states.get(last_motion_camera_entity_id).state == STATE_IDLE + assert hass.states.get(last_motion_camera_entity_id).state == CameraState.IDLE last_ring_camera_entity_id = "camera.mydoorbird_last_ring" - assert hass.states.get(last_ring_camera_entity_id).state == STATE_IDLE + assert hass.states.get(last_ring_camera_entity_id).state == CameraState.IDLE assert await async_get_stream_source(hass, live_camera_entity_id) is not None api = doorbird_entry.api api.get_image.side_effect = mock_not_found_exception() diff --git a/tests/components/esphome/test_camera.py b/tests/components/esphome/test_camera.py index c6a61cd18e8..87b86b039fd 100644 --- a/tests/components/esphome/test_camera.py +++ b/tests/components/esphome/test_camera.py @@ -5,13 +5,13 @@ from collections.abc import Awaitable, Callable from aioesphomeapi import ( APIClient, CameraInfo, - CameraState, + CameraState as ESPHomeCameraState, EntityInfo, EntityState, UserService, ) -from homeassistant.components.camera import STATE_IDLE +from homeassistant.components.camera import CameraState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -55,10 +55,10 @@ async def test_camera_single_image( ) state = hass.states.get("camera.test_mycamera") assert state is not None - assert state.state == STATE_IDLE + assert state.state == CameraState.IDLE def _mock_camera_image(): - mock_device.set_state(CameraState(key=1, data=SMALLEST_VALID_JPEG_BYTES)) + mock_device.set_state(ESPHomeCameraState(key=1, data=SMALLEST_VALID_JPEG_BYTES)) mock_client.request_single_image = _mock_camera_image @@ -67,7 +67,7 @@ async def test_camera_single_image( await hass.async_block_till_done() state = hass.states.get("camera.test_mycamera") assert state is not None - assert state.state == STATE_IDLE + assert state.state == CameraState.IDLE assert resp.status == 200 assert resp.content_type == "image/jpeg" @@ -103,7 +103,7 @@ async def test_camera_single_image_unavailable_before_requested( ) state = hass.states.get("camera.test_mycamera") assert state is not None - assert state.state == STATE_IDLE + assert state.state == CameraState.IDLE await mock_device.mock_disconnect(False) client = await hass_client() @@ -144,7 +144,7 @@ async def test_camera_single_image_unavailable_during_request( ) state = hass.states.get("camera.test_mycamera") assert state is not None - assert state.state == STATE_IDLE + assert state.state == CameraState.IDLE def _mock_camera_image(): hass.async_create_task(mock_device.mock_disconnect(False)) @@ -189,7 +189,7 @@ async def test_camera_stream( ) state = hass.states.get("camera.test_mycamera") assert state is not None - assert state.state == STATE_IDLE + assert state.state == CameraState.IDLE remaining_responses = 3 def _mock_camera_image(): @@ -197,7 +197,7 @@ async def test_camera_stream( if remaining_responses == 0: return remaining_responses -= 1 - mock_device.set_state(CameraState(key=1, data=SMALLEST_VALID_JPEG_BYTES)) + mock_device.set_state(ESPHomeCameraState(key=1, data=SMALLEST_VALID_JPEG_BYTES)) mock_client.request_image_stream = _mock_camera_image mock_client.request_single_image = _mock_camera_image @@ -207,7 +207,7 @@ async def test_camera_stream( await hass.async_block_till_done() state = hass.states.get("camera.test_mycamera") assert state is not None - assert state.state == STATE_IDLE + assert state.state == CameraState.IDLE assert resp.status == 200 assert resp.content_type == "multipart/x-mixed-replace" @@ -249,7 +249,7 @@ async def test_camera_stream_unavailable( ) state = hass.states.get("camera.test_mycamera") assert state is not None - assert state.state == STATE_IDLE + assert state.state == CameraState.IDLE await mock_device.mock_disconnect(False) @@ -289,7 +289,7 @@ async def test_camera_stream_with_disconnection( ) state = hass.states.get("camera.test_mycamera") assert state is not None - assert state.state == STATE_IDLE + assert state.state == CameraState.IDLE remaining_responses = 3 def _mock_camera_image(): @@ -299,7 +299,7 @@ async def test_camera_stream_with_disconnection( if remaining_responses == 2: hass.async_create_task(mock_device.mock_disconnect(False)) remaining_responses -= 1 - mock_device.set_state(CameraState(key=1, data=SMALLEST_VALID_JPEG_BYTES)) + mock_device.set_state(ESPHomeCameraState(key=1, data=SMALLEST_VALID_JPEG_BYTES)) mock_client.request_image_stream = _mock_camera_image mock_client.request_single_image = _mock_camera_image diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 6aa25134563..dda7bcfa093 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -15,7 +15,7 @@ from google_nest_sdm.event import EventMessage import pytest from homeassistant.components import camera -from homeassistant.components.camera import STATE_IDLE, STATE_STREAMING, StreamType +from homeassistant.components.camera import CameraState, StreamType from homeassistant.components.nest.const import DOMAIN from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.const import ATTR_FRIENDLY_NAME @@ -218,7 +218,7 @@ async def test_camera_device( assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.my_camera") assert camera is not None - assert camera.state == STATE_STREAMING + assert camera.state == CameraState.STREAMING assert camera.attributes.get(ATTR_FRIENDLY_NAME) == "My Camera" entry = entity_registry.async_get("camera.my_camera") @@ -245,7 +245,7 @@ async def test_camera_stream( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING assert cam.attributes["frontend_stream_type"] == StreamType.HLS stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") @@ -267,7 +267,7 @@ async def test_camera_ws_stream( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING assert cam.attributes["frontend_stream_type"] == StreamType.HLS client = await hass_ws_client(hass) @@ -300,7 +300,7 @@ async def test_camera_ws_stream_failure( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING client = await hass_ws_client(hass) await client.send_json( @@ -341,7 +341,7 @@ async def test_camera_stream_missing_trait( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_IDLE + assert cam.state == CameraState.IDLE stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") assert stream_source is None @@ -375,7 +375,7 @@ async def test_refresh_expired_stream_token( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING # Request a stream for the camera entity to exercise nest cam + camera interaction # and shutdown on url expiration @@ -446,7 +446,7 @@ async def test_stream_response_already_expired( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING # The stream is expired, but we return it anyway stream_source = await camera.async_get_stream_source(hass, "camera.my_camera") @@ -474,7 +474,7 @@ async def test_camera_removed( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING # Start a stream, exercising cleanup on remove auth.responses = [ @@ -502,7 +502,7 @@ async def test_camera_remove_failure( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING # Start a stream, exercising cleanup on remove auth.responses = [ @@ -543,7 +543,7 @@ async def test_refresh_expired_stream_failure( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING # Request an HLS stream with patch("homeassistant.components.camera.create_stream") as create_stream: @@ -602,7 +602,7 @@ async def test_camera_web_rtc( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC client = await hass_ws_client(hass) @@ -639,7 +639,7 @@ async def test_camera_web_rtc_unsupported( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING assert cam.attributes["frontend_stream_type"] == StreamType.HLS client = await hass_ws_client(hass) @@ -676,7 +676,7 @@ async def test_camera_web_rtc_offer_failure( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING client = await hass_ws_client(hass) await client.send_json( @@ -741,7 +741,7 @@ async def test_camera_multiple_streams( assert len(hass.states.async_all()) == 1 cam = hass.states.get("camera.my_camera") assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING # Prefer WebRTC over RTSP/HLS assert cam.attributes["frontend_stream_type"] == StreamType.WEB_RTC diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index c7398d64e1d..43904ed8f71 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -9,7 +9,7 @@ import pytest from syrupy import SnapshotAssertion from homeassistant.components import camera -from homeassistant.components.camera import STATE_STREAMING +from homeassistant.components.camera import CameraState from homeassistant.components.netatmo.const import ( NETATMO_EVENT, SERVICE_SET_CAMERA_LIGHT, @@ -176,7 +176,7 @@ async def test_camera_image_local( cam = hass.states.get(camera_entity_indoor) assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING assert cam.name == "Hall" stream_source = await camera.async_get_stream_source(hass, camera_entity_indoor) @@ -204,7 +204,7 @@ async def test_camera_image_vpn( cam = hass.states.get(camera_entity_indoor) assert cam is not None - assert cam.state == STATE_STREAMING + assert cam.state == CameraState.STREAMING stream_source = await camera.async_get_stream_source(hass, camera_entity_indoor) assert stream_source == stream_uri diff --git a/tests/components/reolink/test_camera.py b/tests/components/reolink/test_camera.py index 21ebb242882..4f18f769e02 100644 --- a/tests/components/reolink/test_camera.py +++ b/tests/components/reolink/test_camera.py @@ -5,9 +5,13 @@ from unittest.mock import MagicMock, patch import pytest from reolink_aio.exceptions import ReolinkError -from homeassistant.components.camera import async_get_image, async_get_stream_source +from homeassistant.components.camera import ( + CameraState, + async_get_image, + async_get_stream_source, +) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_IDLE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -30,7 +34,7 @@ async def test_camera( assert config_entry.state is ConfigEntryState.LOADED entity_id = f"{Platform.CAMERA}.{TEST_NVR_NAME}_fluent" - assert hass.states.get(entity_id).state == STATE_IDLE + assert hass.states.get(entity_id).state == CameraState.IDLE # check getting a image from the camera reolink_connect.get_snapshot.return_value = b"image" @@ -62,4 +66,4 @@ async def test_camera_no_stream_source( assert config_entry.state is ConfigEntryState.LOADED entity_id = f"{Platform.CAMERA}.{TEST_NVR_NAME}_snapshots_fluent_lens_0" - assert hass.states.get(entity_id).state == STATE_IDLE + assert hass.states.get(entity_id).state == CameraState.IDLE diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index ea7a7ae942d..75a0beb23d9 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -10,8 +10,8 @@ from uiprotect.exceptions import NvrError from uiprotect.websocket import WebsocketState from homeassistant.components.camera import ( - STATE_IDLE, CameraEntityFeature, + CameraState, async_get_image, async_get_stream_source, ) @@ -431,7 +431,7 @@ async def test_camera_websocket_disconnected( entity_id = "camera.test_camera_high_resolution_channel" state = hass.states.get(entity_id) - assert state and state.state == STATE_IDLE + assert state and state.state == CameraState.IDLE # websocket disconnects ufp.ws_state_subscription(WebsocketState.DISCONNECTED) @@ -445,7 +445,7 @@ async def test_camera_websocket_disconnected( await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state and state.state == STATE_IDLE + assert state and state.state == CameraState.IDLE async def test_camera_ws_update( diff --git a/tests/components/uvc/test_camera.py b/tests/components/uvc/test_camera.py index 3d41e725209..43216e354c7 100644 --- a/tests/components/uvc/test_camera.py +++ b/tests/components/uvc/test_camera.py @@ -10,8 +10,8 @@ from homeassistant.components.camera import ( DEFAULT_CONTENT_TYPE, SERVICE_DISABLE_MOTION, SERVICE_ENABLE_MOTION, - STATE_RECORDING, CameraEntityFeature, + CameraState, async_get_image, async_get_stream_source, ) @@ -336,7 +336,7 @@ async def test_properties(hass: HomeAssistant, mock_remote) -> None: assert state assert state.name == "Front" - assert state.state == STATE_RECORDING + assert state.state == CameraState.RECORDING assert state.attributes["brand"] == "Ubiquiti" assert state.attributes["model_name"] == "UVC" assert state.attributes["supported_features"] == CameraEntityFeature.STREAM @@ -354,7 +354,7 @@ async def test_motion_recording_mode_properties( state = hass.states.get("camera.front") assert state - assert state.state == STATE_RECORDING + assert state.state == CameraState.RECORDING mock_remote.return_value.get_camera.return_value["recordingSettings"][ "fullTimeRecordEnabled" @@ -369,7 +369,7 @@ async def test_motion_recording_mode_properties( state = hass.states.get("camera.front") assert state - assert state.state != STATE_RECORDING + assert state.state != CameraState.RECORDING assert state.attributes["last_recording_start_time"] == datetime( 2021, 1, 8, 1, 56, 32, 367000, tzinfo=UTC ) @@ -382,7 +382,7 @@ async def test_motion_recording_mode_properties( state = hass.states.get("camera.front") assert state - assert state.state != STATE_RECORDING + assert state.state != CameraState.RECORDING mock_remote.return_value.get_camera.return_value["recordingIndicator"] = ( "MOTION_INPROGRESS" @@ -394,7 +394,7 @@ async def test_motion_recording_mode_properties( state = hass.states.get("camera.front") assert state - assert state.state == STATE_RECORDING + assert state.state == CameraState.RECORDING mock_remote.return_value.get_camera.return_value["recordingIndicator"] = ( "MOTION_FINISHED" @@ -406,7 +406,7 @@ async def test_motion_recording_mode_properties( state = hass.states.get("camera.front") assert state - assert state.state == STATE_RECORDING + assert state.state == CameraState.RECORDING async def test_stream(hass: HomeAssistant, mock_remote) -> None: diff --git a/tests/components/yale/test_camera.py b/tests/components/yale/test_camera.py index 502945b19c1..122f3c65def 100644 --- a/tests/components/yale/test_camera.py +++ b/tests/components/yale/test_camera.py @@ -6,7 +6,7 @@ from unittest.mock import patch from yalexs.const import Brand from yalexs.doorbell import ContentTokenExpired -from homeassistant.const import STATE_IDLE +from homeassistant.components.camera import CameraState from homeassistant.core import HomeAssistant from .mocks import _create_yale_with_devices, _mock_doorbell_from_fixture @@ -28,7 +28,7 @@ async def test_create_doorbell( camera_k98gidt45gul_name_camera = hass.states.get( "camera.k98gidt45gul_name_camera" ) - assert camera_k98gidt45gul_name_camera.state == STATE_IDLE + assert camera_k98gidt45gul_name_camera.state == CameraState.IDLE url = hass.states.get("camera.k98gidt45gul_name_camera").attributes[ "entity_picture" From acebf1fb48d23bd086f5bb93a09793efa74ce9d2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 24 Sep 2024 12:19:39 +0200 Subject: [PATCH 1063/1309] Adjust _ENTITY_COMPONENTS in hass-enforce-class-module (#126603) --- homeassistant/components/dominos/__init__.py | 2 +- homeassistant/components/microsoft_face/__init__.py | 2 +- homeassistant/components/plant/__init__.py | 2 +- homeassistant/components/template/trigger_entity.py | 4 +++- pylint/plugins/hass_enforce_class_module.py | 9 ++++++++- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py index 609cb93ba0d..9b11b667e84 100644 --- a/homeassistant/components/dominos/__init__.py +++ b/homeassistant/components/dominos/__init__.py @@ -182,7 +182,7 @@ class DominosProductListView(http.HomeAssistantView): return self.json(self.dominos.get_menu()) -class DominosOrder(Entity): # pylint: disable=hass-enforce-class-module +class DominosOrder(Entity): """Represents a Dominos order entity.""" def __init__(self, order_info, dominos): diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 6a7e2d42fd9..fa4de7f9c99 100644 --- a/homeassistant/components/microsoft_face/__init__.py +++ b/homeassistant/components/microsoft_face/__init__.py @@ -214,7 +214,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class MicrosoftFaceGroupEntity(Entity): # pylint: disable=hass-enforce-class-module +class MicrosoftFaceGroupEntity(Entity): """Person-Group state/data Entity.""" _attr_should_poll = False diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index b3e1084f501..c6e527290df 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -127,7 +127,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -class Plant(Entity): # pylint: disable=hass-enforce-class-module +class Plant(Entity): """Plant monitors the well-being of a plant. It also checks the measurements against diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 697cd827b9e..df84ce057c3 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -9,7 +9,9 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import TriggerUpdateCoordinator -class TriggerEntity(TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinator]): +class TriggerEntity( # pylint: disable=hass-enforce-class-module + TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinator] +): """Template entity based on trigger data.""" def __init__( diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index e48cae877a5..95527126a30 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -76,16 +76,23 @@ _MODULES: dict[str, set[str]] = { } _ENTITY_COMPONENTS: set[str] = {platform.value for platform in Platform}.union( { + "alert", "automation", "counter", + "dominos", "input_boolean", + "input_button", "input_datetime", "input_number", + "input_select", "input_text", + "microsoft_face", "person", + "plant", + "remember_the_milk", + "schedule", "script", "tag", - "template", "timer", } ) From 93aade6e8e42103d145862bf33b7e1f57623e42e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 24 Sep 2024 12:30:50 +0200 Subject: [PATCH 1064/1309] Change lock state to an enum (#126379) * Add new LockState enum for lock states * Add rest * Fix insteon tests * Fix mqtt tests * Fix tesla_fleet * Revert back ST_STATE_LOCKED * Add back constant --- .../components/alexa/capabilities.py | 9 +- homeassistant/components/demo/lock.py | 45 +++---- .../components/google_assistant/trait.py | 7 +- homeassistant/components/group/lock.py | 19 +-- homeassistant/components/group/registry.py | 20 ++- .../components/homekit/type_locks.py | 50 ++++--- .../components/homekit_controller/lock.py | 36 +++-- homeassistant/components/isy994/const.py | 7 +- homeassistant/components/kitchen_sink/lock.py | 21 ++- homeassistant/components/kiwi/lock.py | 11 +- homeassistant/components/lock/__init__.py | 26 ++-- homeassistant/components/lock/const.py | 14 ++ .../components/lock/device_condition.py | 23 ++-- .../components/lock/device_trigger.py | 23 ++-- .../components/lock/reproduce_state.py | 26 ++-- homeassistant/components/surepetcare/lock.py | 19 ++- homeassistant/components/template/lock.py | 16 +-- homeassistant/components/verisure/lock.py | 14 +- homeassistant/components/xiaomi_aqara/lock.py | 9 +- .../components/yale_smart_alarm/lock.py | 17 +-- homeassistant/components/zwave_js/lock.py | 17 ++- homeassistant/const.py | 34 ++++- homeassistant/helpers/state.py | 7 +- tests/components/abode/test_lock.py | 5 +- tests/components/alexa/test_capabilities.py | 14 +- tests/components/august/test_init.py | 5 +- tests/components/august/test_lock.py | 65 +++++---- tests/components/deconz/test_lock.py | 11 +- tests/components/demo/test_lock.py | 38 ++---- tests/components/esphome/test_lock.py | 24 ++-- tests/components/freedompro/test_lock.py | 15 ++- .../components/google_assistant/test_trait.py | 10 +- tests/components/group/test_init.py | 76 +++++------ tests/components/group/test_lock.py | 125 ++++++++---------- tests/components/homekit/test_type_locks.py | 21 +-- .../components/homematicip_cloud/test_lock.py | 15 +-- tests/components/insteon/test_lock.py | 15 +-- tests/components/kitchen_sink/test_lock.py | 26 ++-- .../components/lock/test_device_condition.py | 29 ++-- tests/components/lock/test_device_trigger.py | 37 ++---- tests/components/lock/test_init.py | 50 ++++--- tests/components/loqed/test_lock.py | 7 +- tests/components/matter/test_lock.py | 25 ++-- tests/components/mqtt/test_lock.py | 112 ++++++++-------- tests/components/prometheus/test_init.py | 7 +- tests/components/recorder/test_init.py | 11 +- tests/components/schlage/test_lock.py | 14 +- tests/components/switch_as_x/__init__.py | 15 +-- tests/components/switch_as_x/test_init.py | 5 +- tests/components/switch_as_x/test_lock.py | 28 ++-- tests/components/tedee/test_lock.py | 15 +-- tests/components/template/test_lock.py | 29 ++-- tests/components/tesla_fleet/test_lock.py | 15 +-- tests/components/teslemetry/test_lock.py | 15 +-- tests/components/tessie/test_lock.py | 13 +- tests/components/unifiprotect/test_lock.py | 16 +-- tests/components/vera/test_lock.py | 8 +- tests/components/yale/test_init.py | 5 +- tests/components/yale/test_lock.py | 63 ++++----- tests/components/zha/test_lock.py | 10 +- tests/components/zwave_js/test_lock.py | 13 +- tests/helpers/test_state.py | 13 +- tests/test_const.py | 32 ++++- 63 files changed, 710 insertions(+), 812 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 03ba353bb5b..6633cda8a97 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -29,6 +29,7 @@ from homeassistant.components.alarm_control_panel import ( CodeFormat, ) from homeassistant.components.climate import HVACMode +from homeassistant.components.lock import LockState from homeassistant.const import ( ATTR_CODE_FORMAT, ATTR_SUPPORTED_FEATURES, @@ -40,16 +41,12 @@ from homeassistant.const import ( STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_IDLE, - STATE_LOCKED, - STATE_LOCKING, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKED, - STATE_UNLOCKING, UnitOfLength, UnitOfMass, UnitOfTemperature, @@ -500,10 +497,10 @@ class AlexaLockController(AlexaCapability): raise UnsupportedProperty(name) # If its unlocking its still locked and not unlocked yet - if self.entity.state in (STATE_UNLOCKING, STATE_LOCKED): + if self.entity.state in (LockState.UNLOCKING, LockState.LOCKED): return "LOCKED" # If its locking its still unlocked and not locked yet - if self.entity.state in (STATE_LOCKING, STATE_UNLOCKED): + if self.entity.state in (LockState.LOCKING, LockState.UNLOCKED): return "UNLOCKED" return "JAMMED" diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index c17e10edd85..1f25445af7f 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -5,17 +5,8 @@ from __future__ import annotations import asyncio from typing import Any -from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.components.lock import LockEntity, LockEntityFeature, LockState from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_OPEN, - STATE_OPENING, - STATE_UNLOCKED, - STATE_UNLOCKING, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,10 +21,10 @@ async def async_setup_entry( """Set up the Demo config entry.""" async_add_entities( [ - DemoLock("Front Door", STATE_LOCKED), - DemoLock("Kitchen Door", STATE_UNLOCKED), - DemoLock("Poorly Installed Door", STATE_UNLOCKED, False, True), - DemoLock("Openable Lock", STATE_LOCKED, True), + DemoLock("Front Door", LockState.LOCKED), + DemoLock("Kitchen Door", LockState.UNLOCKED), + DemoLock("Poorly Installed Door", LockState.UNLOCKED, False, True), + DemoLock("Openable Lock", LockState.LOCKED, True), ] ) @@ -61,56 +52,56 @@ class DemoLock(LockEntity): @property def is_locking(self) -> bool: """Return true if lock is locking.""" - return self._state == STATE_LOCKING + return self._state == LockState.LOCKING @property def is_unlocking(self) -> bool: """Return true if lock is unlocking.""" - return self._state == STATE_UNLOCKING + return self._state == LockState.UNLOCKING @property def is_jammed(self) -> bool: """Return true if lock is jammed.""" - return self._state == STATE_JAMMED + return self._state == LockState.JAMMED @property def is_locked(self) -> bool: """Return true if lock is locked.""" - return self._state == STATE_LOCKED + return self._state == LockState.LOCKED @property def is_open(self) -> bool: """Return true if lock is open.""" - return self._state == STATE_OPEN + return self._state == LockState.OPEN @property def is_opening(self) -> bool: """Return true if lock is opening.""" - return self._state == STATE_OPENING + return self._state == LockState.OPENING async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" - self._state = STATE_LOCKING + self._state = LockState.LOCKING self.async_write_ha_state() await asyncio.sleep(LOCK_UNLOCK_DELAY) if self._jam_on_operation: - self._state = STATE_JAMMED + self._state = LockState.JAMMED else: - self._state = STATE_LOCKED + self._state = LockState.LOCKED self.async_write_ha_state() async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - self._state = STATE_UNLOCKING + self._state = LockState.UNLOCKING self.async_write_ha_state() await asyncio.sleep(LOCK_UNLOCK_DELAY) - self._state = STATE_UNLOCKED + self._state = LockState.UNLOCKED self.async_write_ha_state() async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - self._state = STATE_OPENING + self._state = LockState.OPENING self.async_write_ha_state() await asyncio.sleep(LOCK_UNLOCK_DELAY) - self._state = STATE_OPEN + self._state = LockState.OPEN self.async_write_ha_state() diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 145eb4b2935..95faf7c3321 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -40,7 +40,7 @@ from homeassistant.components.cover import CoverEntityFeature from homeassistant.components.fan import FanEntityFeature from homeassistant.components.humidifier import HumidifierEntityFeature from homeassistant.components.light import LightEntityFeature -from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING +from homeassistant.components.lock import LockState from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.components.valve import ValveEntityFeature @@ -71,7 +71,6 @@ from homeassistant.const import ( STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_IDLE, - STATE_LOCKED, STATE_OFF, STATE_ON, STATE_PAUSED, @@ -1524,11 +1523,11 @@ class LockUnlockTrait(_Trait): def query_attributes(self) -> dict[str, Any]: """Return LockUnlock query attributes.""" - if self.state.state == STATE_JAMMED: + if self.state.state == LockState.JAMMED: return {"isJammed": True} # If its unlocking its not yet unlocked so we consider is locked - return {"isLocked": self.state.state in (STATE_UNLOCKING, STATE_LOCKED)} + return {"isLocked": self.state.state in (LockState.UNLOCKING, LockState.LOCKED)} async def execute(self, command, data, params, challenge): """Execute an LockUnlock command.""" diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 73e8c30bfde..e22e1ecd85c 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -12,6 +12,7 @@ from homeassistant.components.lock import ( PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, LockEntityFeature, + LockState, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -22,14 +23,8 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_OPEN, - STATE_OPENING, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -183,11 +178,11 @@ class LockGroup(GroupEntity, LockEntity): self._attr_is_locked = None else: # Set attributes based on member states and let the lock entity sort out the correct state - self._attr_is_jammed = STATE_JAMMED in states - self._attr_is_locking = STATE_LOCKING in states - self._attr_is_opening = STATE_OPENING in states - self._attr_is_open = STATE_OPEN in states - self._attr_is_unlocking = STATE_UNLOCKING in states - self._attr_is_locked = all(state == STATE_LOCKED for state in states) + self._attr_is_jammed = LockState.JAMMED in states + self._attr_is_locking = LockState.LOCKING in states + self._attr_is_opening = LockState.OPENING in states + self._attr_is_open = LockState.OPEN in states + self._attr_is_unlocking = LockState.UNLOCKING in states + self._attr_is_locked = all(state == LockState.LOCKED for state in states) self._attr_available = any(state != STATE_UNAVAILABLE for state in states) diff --git a/homeassistant/components/group/registry.py b/homeassistant/components/group/registry.py index 96fa8721271..e0a74d32f44 100644 --- a/homeassistant/components/group/registry.py +++ b/homeassistant/components/group/registry.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from typing import Protocol from homeassistant.components.climate import HVACMode +from homeassistant.components.lock import LockState from homeassistant.components.vacuum import STATE_CLEANING, STATE_ERROR, STATE_RETURNING from homeassistant.components.water_heater import ( STATE_ECO, @@ -28,19 +29,14 @@ from homeassistant.const import ( STATE_CLOSED, STATE_HOME, STATE_IDLE, - STATE_LOCKED, - STATE_LOCKING, STATE_NOT_HOME, STATE_OFF, STATE_OK, STATE_ON, STATE_OPEN, - STATE_OPENING, STATE_PAUSED, STATE_PLAYING, STATE_PROBLEM, - STATE_UNLOCKED, - STATE_UNLOCKING, Platform, ) from homeassistant.core import HomeAssistant, callback @@ -90,14 +86,14 @@ ON_OFF_STATES: dict[Platform | str, tuple[set[str], str, str]] = { Platform.DEVICE_TRACKER: ({STATE_HOME}, STATE_HOME, STATE_NOT_HOME), Platform.LOCK: ( { - STATE_LOCKING, - STATE_OPEN, - STATE_OPENING, - STATE_UNLOCKED, - STATE_UNLOCKING, + LockState.LOCKING, + LockState.OPEN, + LockState.OPENING, + LockState.UNLOCKED, + LockState.UNLOCKING, }, - STATE_UNLOCKED, - STATE_LOCKED, + LockState.UNLOCKED, + LockState.LOCKED, ), Platform.MEDIA_PLAYER: ( { diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 52dc71078d0..70570a8fca5 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -5,14 +5,7 @@ from typing import Any from pyhap.const import CATEGORY_DOOR_LOCK -from homeassistant.components.lock import ( - DOMAIN as LOCK_DOMAIN, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_UNLOCKED, - STATE_UNLOCKING, -) +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import State, callback @@ -22,35 +15,40 @@ from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK _LOGGER = logging.getLogger(__name__) HASS_TO_HOMEKIT_CURRENT = { - STATE_UNLOCKED: 0, - STATE_UNLOCKING: 1, - STATE_LOCKING: 0, - STATE_LOCKED: 1, - STATE_JAMMED: 2, + LockState.UNLOCKED.value: 0, + LockState.UNLOCKING.value: 1, + LockState.LOCKING.value: 0, + LockState.LOCKED.value: 1, + LockState.JAMMED.value: 2, STATE_UNKNOWN: 3, } HASS_TO_HOMEKIT_TARGET = { - STATE_UNLOCKED: 0, - STATE_UNLOCKING: 0, - STATE_LOCKING: 1, - STATE_LOCKED: 1, + LockState.UNLOCKED.value: 0, + LockState.UNLOCKING.value: 0, + LockState.LOCKING.value: 1, + LockState.LOCKED.value: 1, } -VALID_TARGET_STATES = {STATE_LOCKING, STATE_UNLOCKING, STATE_LOCKED, STATE_UNLOCKED} +VALID_TARGET_STATES = { + LockState.LOCKING.value, + LockState.UNLOCKING.value, + LockState.LOCKED.value, + LockState.UNLOCKED.value, +} HOMEKIT_TO_HASS = { - 0: STATE_UNLOCKED, - 1: STATE_LOCKED, - 2: STATE_JAMMED, + 0: LockState.UNLOCKED.value, + 1: LockState.LOCKED.value, + 2: LockState.JAMMED.value, 3: STATE_UNKNOWN, } STATE_TO_SERVICE = { - STATE_LOCKING: "unlock", - STATE_LOCKED: "lock", - STATE_UNLOCKING: "lock", - STATE_UNLOCKED: "unlock", + LockState.LOCKING.value: "unlock", + LockState.LOCKED.value: "lock", + LockState.UNLOCKING.value: "lock", + LockState.UNLOCKED.value: "unlock", } @@ -74,7 +72,7 @@ class Lock(HomeAccessory): ) self.char_target_state = serv_lock_mechanism.configure_char( CHAR_LOCK_TARGET_STATE, - value=HASS_TO_HOMEKIT_CURRENT[STATE_LOCKED], + value=HASS_TO_HOMEKIT_CURRENT[LockState.LOCKED.value], setter_callback=self.set_state, ) self.async_update_state(state) diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 8e1bcd424d4..98974c4a514 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -7,15 +7,9 @@ from typing import Any from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from homeassistant.components.lock import STATE_JAMMED, LockEntity +from homeassistant.components.lock import LockEntity, LockState from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - STATE_LOCKED, - STATE_UNKNOWN, - STATE_UNLOCKED, - Platform, -) +from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,13 +18,13 @@ from .connection import HKDevice from .entity import HomeKitEntity CURRENT_STATE_MAP = { - 0: STATE_UNLOCKED, - 1: STATE_LOCKED, - 2: STATE_JAMMED, + 0: LockState.UNLOCKED, + 1: LockState.LOCKED, + 2: LockState.JAMMED, 3: STATE_UNKNOWN, } -TARGET_STATE_MAP = {STATE_UNLOCKED: 0, STATE_LOCKED: 1} +TARGET_STATE_MAP = {LockState.UNLOCKED: 0, LockState.LOCKED: 1} REVERSED_TARGET_STATE_MAP = {v: k for k, v in TARGET_STATE_MAP.items()} @@ -76,7 +70,7 @@ class HomeKitLock(HomeKitEntity, LockEntity): value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) if CURRENT_STATE_MAP[value] == STATE_UNKNOWN: return None - return CURRENT_STATE_MAP[value] == STATE_LOCKED + return CURRENT_STATE_MAP[value] == LockState.LOCKED @property def is_locking(self) -> bool: @@ -88,8 +82,8 @@ class HomeKitLock(HomeKitEntity, LockEntity): CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE ) return ( - CURRENT_STATE_MAP[current_value] == STATE_UNLOCKED - and REVERSED_TARGET_STATE_MAP.get(target_value) == STATE_LOCKED + CURRENT_STATE_MAP[current_value] == LockState.UNLOCKED + and REVERSED_TARGET_STATE_MAP.get(target_value) == LockState.LOCKED ) @property @@ -102,25 +96,25 @@ class HomeKitLock(HomeKitEntity, LockEntity): CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE ) return ( - CURRENT_STATE_MAP[current_value] == STATE_LOCKED - and REVERSED_TARGET_STATE_MAP.get(target_value) == STATE_UNLOCKED + CURRENT_STATE_MAP[current_value] == LockState.LOCKED + and REVERSED_TARGET_STATE_MAP.get(target_value) == LockState.UNLOCKED ) @property def is_jammed(self) -> bool: """Return true if device is jammed.""" value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) - return CURRENT_STATE_MAP[value] == STATE_JAMMED + return CURRENT_STATE_MAP[value] == LockState.JAMMED async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" - await self._set_lock_state(STATE_LOCKED) + await self._set_lock_state(LockState.LOCKED) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" - await self._set_lock_state(STATE_UNLOCKED) + await self._set_lock_state(LockState.UNLOCKED) - async def _set_lock_state(self, state: str) -> None: + async def _set_lock_state(self, state: LockState) -> None: """Send state command.""" await self.async_put_characteristics( {CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE: TARGET_STATE_MAP[state]} diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index 57b30c88075..b43385a0e5d 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -15,6 +15,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) +from homeassistant.components.lock import LockState from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, @@ -29,14 +30,12 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, STATE_CLOSED, STATE_CLOSING, - STATE_LOCKED, STATE_OFF, STATE_ON, STATE_OPEN, STATE_OPENING, STATE_PROBLEM, STATE_UNKNOWN, - STATE_UNLOCKED, UV_INDEX, Platform, UnitOfApparentPower, @@ -451,8 +450,8 @@ UOM_FRIENDLY_NAME = { UOM_TO_STATES = { "11": { # Deadbolt Status - 0: STATE_UNLOCKED, - 100: STATE_LOCKED, + 0: LockState.UNLOCKED, + 100: LockState.LOCKED, 101: STATE_UNKNOWN, 102: STATE_PROBLEM, }, diff --git a/homeassistant/components/kitchen_sink/lock.py b/homeassistant/components/kitchen_sink/lock.py index 9b8093c2f0b..80ecc57d0d9 100644 --- a/homeassistant/components/kitchen_sink/lock.py +++ b/homeassistant/components/kitchen_sink/lock.py @@ -4,9 +4,8 @@ from __future__ import annotations from typing import Any -from homeassistant.components.lock import LockEntity, LockEntityFeature +from homeassistant.components.lock import LockEntity, LockEntityFeature, LockState from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_LOCKED, STATE_OPEN, STATE_UNLOCKED from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -24,24 +23,24 @@ async def async_setup_platform( DemoLock( "kitchen_sink_lock_001", "Openable lock", - STATE_LOCKED, + LockState.LOCKED, LockEntityFeature.OPEN, ), DemoLock( "kitchen_sink_lock_002", "Another openable lock", - STATE_UNLOCKED, + LockState.UNLOCKED, LockEntityFeature.OPEN, ), DemoLock( "kitchen_sink_lock_003", "Basic lock", - STATE_LOCKED, + LockState.LOCKED, ), DemoLock( "kitchen_sink_lock_004", "Another basic lock", - STATE_UNLOCKED, + LockState.UNLOCKED, ), ] ) @@ -77,19 +76,19 @@ class DemoLock(LockEntity): @property def is_locked(self) -> bool: """Return true if lock is locked.""" - return self._state == STATE_LOCKED + return self._state == LockState.LOCKED @property def is_open(self) -> bool: """Return true if lock is open.""" - return self._state == STATE_OPEN + return self._state == LockState.OPEN async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" self._attr_is_locking = True self.async_write_ha_state() self._attr_is_locking = False - self._state = STATE_LOCKED + self._state = LockState.LOCKED self.async_write_ha_state() async def async_unlock(self, **kwargs: Any) -> None: @@ -97,10 +96,10 @@ class DemoLock(LockEntity): self._attr_is_unlocking = True self.async_write_ha_state() self._attr_is_unlocking = False - self._state = STATE_UNLOCKED + self._state = LockState.UNLOCKED self.async_write_ha_state() async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - self._state = STATE_OPEN + self._state = LockState.OPEN self.async_write_ha_state() diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index fb4272dfa63..887747d4ca4 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components.lock import ( PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, + LockState, ) from homeassistant.const import ( ATTR_ID, @@ -18,8 +19,6 @@ from homeassistant.const import ( ATTR_LONGITUDE, CONF_PASSWORD, CONF_USERNAME, - STATE_LOCKED, - STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -68,7 +67,7 @@ class KiwiLock(LockEntity): self._sensor = kiwi_lock self._client = client self.lock_id = kiwi_lock["sensor_id"] - self._state = STATE_LOCKED + self._state = LockState.LOCKED address = kiwi_lock.get("address") address.update( @@ -96,7 +95,7 @@ class KiwiLock(LockEntity): @property def is_locked(self) -> bool: """Return true if lock is locked.""" - return self._state == STATE_LOCKED + return self._state == LockState.LOCKED @property def extra_state_attributes(self) -> dict[str, Any]: @@ -106,7 +105,7 @@ class KiwiLock(LockEntity): @callback def clear_unlock_state(self, _): """Clear unlock state automatically.""" - self._state = STATE_LOCKED + self._state = LockState.LOCKED self.async_write_ha_state() def unlock(self, **kwargs: Any) -> None: @@ -117,7 +116,7 @@ class KiwiLock(LockEntity): except KiwiException: _LOGGER.error("Failed to open door") else: - self._state = STATE_UNLOCKED + self._state = LockState.UNLOCKED self.hass.add_job( async_call_later, self.hass, diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index d9123497696..d70c6383ce0 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -13,19 +13,19 @@ from typing import TYPE_CHECKING, Any, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( +from homeassistant.const import ( # noqa: F401 + _DEPRECATED_STATE_JAMMED, + _DEPRECATED_STATE_LOCKED, + _DEPRECATED_STATE_LOCKING, + _DEPRECATED_STATE_UNLOCKED, + _DEPRECATED_STATE_UNLOCKING, ATTR_CODE, ATTR_CODE_FORMAT, SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, STATE_OPEN, STATE_OPENING, - STATE_UNLOCKED, - STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError @@ -41,7 +41,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN +from .const import DOMAIN, LockState _LOGGER = logging.getLogger(__name__) @@ -274,18 +274,18 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def state(self) -> str | None: """Return the state.""" if self.is_jammed: - return STATE_JAMMED + return LockState.JAMMED if self.is_opening: - return STATE_OPENING + return LockState.OPENING if self.is_locking: - return STATE_LOCKING + return LockState.LOCKING if self.is_open: - return STATE_OPEN + return LockState.OPEN if self.is_unlocking: - return STATE_UNLOCKING + return LockState.UNLOCKING if (locked := self.is_locked) is None: return None - return STATE_LOCKED if locked else STATE_UNLOCKED + return LockState.LOCKED if locked else LockState.UNLOCKED @cached_property def supported_features(self) -> LockEntityFeature: diff --git a/homeassistant/components/lock/const.py b/homeassistant/components/lock/const.py index 1370a26ab36..7a06bc12b05 100644 --- a/homeassistant/components/lock/const.py +++ b/homeassistant/components/lock/const.py @@ -1,3 +1,17 @@ """Constants for the lock entity platform.""" +from enum import StrEnum + DOMAIN = "lock" + + +class LockState(StrEnum): + """State of lock entities.""" + + JAMMED = "jammed" + OPENING = "opening" + LOCKING = "locking" + OPEN = "open" + UNLOCKING = "unlocking" + LOCKED = "locked" + UNLOCKED = "unlocked" diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index ec6373c889f..c104abd82a4 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -11,13 +11,6 @@ from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_OPEN, - STATE_OPENING, - STATE_UNLOCKED, - STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( @@ -28,7 +21,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import DOMAIN +from . import DOMAIN, LockState # mypy: disallow-any-generics @@ -81,19 +74,19 @@ def async_condition_from_config( ) -> condition.ConditionCheckerType: """Create a function to test a device condition.""" if config[CONF_TYPE] == "is_jammed": - state = STATE_JAMMED + state = LockState.JAMMED elif config[CONF_TYPE] == "is_opening": - state = STATE_OPENING + state = LockState.OPENING elif config[CONF_TYPE] == "is_locking": - state = STATE_LOCKING + state = LockState.LOCKING elif config[CONF_TYPE] == "is_open": - state = STATE_OPEN + state = LockState.OPEN elif config[CONF_TYPE] == "is_unlocking": - state = STATE_UNLOCKING + state = LockState.UNLOCKING elif config[CONF_TYPE] == "is_locked": - state = STATE_LOCKED + state = LockState.LOCKED else: - state = STATE_UNLOCKED + state = LockState.UNLOCKED registry = er.async_get(hass) entity_id = er.async_resolve_entity_id(registry, config[ATTR_ENTITY_ID]) diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 336fe127ca6..06e4e5b6431 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -13,20 +13,13 @@ from homeassistant.const import ( CONF_FOR, CONF_PLATFORM, CONF_TYPE, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_OPEN, - STATE_OPENING, - STATE_UNLOCKED, - STATE_UNLOCKING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import DOMAIN +from . import DOMAIN, LockState TRIGGER_TYPES = { "jammed", @@ -93,19 +86,19 @@ async def async_attach_trigger( ) -> CALLBACK_TYPE: """Attach a trigger.""" if config[CONF_TYPE] == "jammed": - to_state = STATE_JAMMED + to_state = LockState.JAMMED elif config[CONF_TYPE] == "opening": - to_state = STATE_OPENING + to_state = LockState.OPENING elif config[CONF_TYPE] == "locking": - to_state = STATE_LOCKING + to_state = LockState.LOCKING elif config[CONF_TYPE] == "open": - to_state = STATE_OPEN + to_state = LockState.OPEN elif config[CONF_TYPE] == "unlocking": - to_state = STATE_UNLOCKING + to_state = LockState.UNLOCKING elif config[CONF_TYPE] == "locked": - to_state = STATE_LOCKED + to_state = LockState.LOCKED else: - to_state = STATE_UNLOCKED + to_state = LockState.UNLOCKED state_config = { CONF_PLATFORM: "state", diff --git a/homeassistant/components/lock/reproduce_state.py b/homeassistant/components/lock/reproduce_state.py index 5fc3345c1f6..252528c9985 100644 --- a/homeassistant/components/lock/reproduce_state.py +++ b/homeassistant/components/lock/reproduce_state.py @@ -12,26 +12,20 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_LOCKED, - STATE_LOCKING, - STATE_OPEN, - STATE_OPENING, - STATE_UNLOCKED, - STATE_UNLOCKING, ) from homeassistant.core import Context, HomeAssistant, State -from . import DOMAIN +from . import DOMAIN, LockState _LOGGER = logging.getLogger(__name__) VALID_STATES = { - STATE_LOCKED, - STATE_LOCKING, - STATE_OPEN, - STATE_OPENING, - STATE_UNLOCKED, - STATE_UNLOCKING, + LockState.LOCKED, + LockState.LOCKING, + LockState.OPEN, + LockState.OPENING, + LockState.UNLOCKED, + LockState.UNLOCKING, } @@ -59,11 +53,11 @@ async def _async_reproduce_state( service_data = {ATTR_ENTITY_ID: state.entity_id} - if state.state in {STATE_LOCKED, STATE_LOCKING}: + if state.state in {LockState.LOCKED, LockState.LOCKING}: service = SERVICE_LOCK - elif state.state in {STATE_UNLOCKED, STATE_UNLOCKING}: + elif state.state in {LockState.UNLOCKED, LockState.UNLOCKING}: service = SERVICE_UNLOCK - elif state.state in {STATE_OPEN, STATE_OPENING}: + elif state.state in {LockState.OPEN, LockState.OPENING}: service = SERVICE_OPEN await hass.services.async_call( diff --git a/homeassistant/components/surepetcare/lock.py b/homeassistant/components/surepetcare/lock.py index cd79e06c5c3..f960400bcbc 100644 --- a/homeassistant/components/surepetcare/lock.py +++ b/homeassistant/components/surepetcare/lock.py @@ -5,11 +5,10 @@ from __future__ import annotations from typing import Any from surepy.entities import SurepyEntity -from surepy.enums import EntityType, LockState +from surepy.enums import EntityType, LockState as SurepyLockState -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import LockEntity, LockState from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,9 +29,9 @@ async def async_setup_entry( for surepy_entity in coordinator.data.values() if surepy_entity.type in [EntityType.CAT_FLAP, EntityType.PET_FLAP] for lock_state in ( - LockState.LOCKED_IN, - LockState.LOCKED_OUT, - LockState.LOCKED_ALL, + SurepyLockState.LOCKED_IN, + SurepyLockState.LOCKED_OUT, + SurepyLockState.LOCKED_ALL, ) ) @@ -44,7 +43,7 @@ class SurePetcareLock(SurePetcareEntity, LockEntity): self, surepetcare_id: int, coordinator: SurePetcareDataCoordinator, - lock_state: LockState, + lock_state: SurepyLockState, ) -> None: """Initialize a Sure Petcare lock.""" self._lock_state = lock_state.name.lower() @@ -66,14 +65,14 @@ class SurePetcareLock(SurePetcareEntity, LockEntity): status = surepy_entity.raw_data()["status"] self._attr_is_locked = ( - LockState(status["locking"]["mode"]).name.lower() == self._lock_state + SurepyLockState(status["locking"]["mode"]).name.lower() == self._lock_state ) self._available = bool(status.get("online")) async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - if self.state != STATE_UNLOCKED: + if self.state != LockState.UNLOCKED: return self._attr_is_locking = True self.async_write_ha_state() @@ -87,7 +86,7 @@ class SurePetcareLock(SurePetcareEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - if self.state != STATE_LOCKED: + if self.state != LockState.LOCKED: return self._attr_is_unlocking = True self.async_write_ha_state() diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 5c0b67a23dc..6ea8aff4c1a 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -8,10 +8,8 @@ import voluptuous as vol from homeassistant.components.lock import ( PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, - STATE_JAMMED, - STATE_LOCKING, - STATE_UNLOCKING, LockEntity, + LockState, ) from homeassistant.const import ( ATTR_CODE, @@ -19,9 +17,7 @@ from homeassistant.const import ( CONF_OPTIMISTIC, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, - STATE_LOCKED, STATE_ON, - STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError @@ -102,22 +98,22 @@ class TemplateLock(TemplateEntity, LockEntity): @property def is_locked(self) -> bool: """Return true if lock is locked.""" - return self._state in ("true", STATE_ON, STATE_LOCKED) + return self._state in ("true", STATE_ON, LockState.LOCKED) @property def is_jammed(self) -> bool: """Return true if lock is jammed.""" - return self._state == STATE_JAMMED + return self._state == LockState.JAMMED @property def is_unlocking(self) -> bool: """Return true if lock is unlocking.""" - return self._state == STATE_UNLOCKING + return self._state == LockState.UNLOCKING @property def is_locking(self) -> bool: """Return true if lock is locking.""" - return self._state == STATE_LOCKING + return self._state == LockState.LOCKING @callback def _update_state(self, result): @@ -128,7 +124,7 @@ class TemplateLock(TemplateEntity, LockEntity): return if isinstance(result, bool): - self._state = STATE_LOCKED if result else STATE_UNLOCKED + self._state = LockState.LOCKED if result else LockState.UNLOCKED return if isinstance(result, str): diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 5c56fc0df2c..87f5c53880e 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -7,9 +7,9 @@ from typing import Any from verisure import Error as VerisureError -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import LockEntity, LockState from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_CODE, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( @@ -130,19 +130,19 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt """Send unlock command.""" code = kwargs.get(ATTR_CODE) if code: - await self.async_set_lock_state(code, STATE_UNLOCKED) + await self.async_set_lock_state(code, LockState.UNLOCKED) async def async_lock(self, **kwargs: Any) -> None: """Send lock command.""" code = kwargs.get(ATTR_CODE) if code: - await self.async_set_lock_state(code, STATE_LOCKED) + await self.async_set_lock_state(code, LockState.LOCKED) - async def async_set_lock_state(self, code: str, state: str) -> None: + async def async_set_lock_state(self, code: str, state: LockState) -> None: """Send set lock state command.""" command = ( self.coordinator.verisure.door_lock(self.serial_number, code) - if state == STATE_LOCKED + if state == LockState.LOCKED else self.coordinator.verisure.door_unlock(self.serial_number, code) ) lock_request = await self.hass.async_add_executor_job( @@ -151,7 +151,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt ) LOGGER.debug("Verisure doorlock %s", state) transaction_id = lock_request.get("data", {}).get(command["operationName"]) - target_state = "LOCKED" if state == STATE_LOCKED else "UNLOCKED" + target_state = "LOCKED" if state == LockState.LOCKED else "UNLOCKED" lock_status = None attempts = 0 while lock_status != "OK": diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index f64f6ae527a..5e538f25699 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -2,9 +2,8 @@ from __future__ import annotations -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import LockEntity, LockState from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later @@ -50,7 +49,7 @@ class XiaomiAqaraLock(LockEntity, XiaomiDevice): def is_locked(self) -> bool | None: """Return true if lock is locked.""" if self._state is not None: - return self._state == STATE_LOCKED + return self._state == LockState.LOCKED return None @property @@ -66,7 +65,7 @@ class XiaomiAqaraLock(LockEntity, XiaomiDevice): @callback def clear_unlock_state(self, _): """Clear unlock state automatically.""" - self._state = STATE_LOCKED + self._state = LockState.LOCKED self.async_write_ha_state() def parse_data(self, data, raw_data): @@ -79,7 +78,7 @@ class XiaomiAqaraLock(LockEntity, XiaomiDevice): if (value := data.get(key)) is not None: self._changed_by = int(value) self._verified_wrong_times = 0 - self._state = STATE_UNLOCKED + self._state = LockState.UNLOCKED async_call_later( self.hass, UNLOCK_MAINTAIN_TIME, self.clear_unlock_state ) diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 65913dbb3bd..243299658ed 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -6,12 +6,7 @@ from typing import Any from yalesmartalarmclient import YaleLock, YaleLockState -from homeassistant.components.lock import ( - STATE_LOCKED, - STATE_OPEN, - STATE_UNLOCKED, - LockEntity, -) +from homeassistant.components.lock import LockEntity, LockState from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -28,9 +23,9 @@ from .coordinator import YaleDataUpdateCoordinator from .entity import YaleLockEntity LOCK_STATE_MAP = { - YaleLockState.LOCKED: STATE_LOCKED, - YaleLockState.UNLOCKED: STATE_UNLOCKED, - YaleLockState.DOOR_OPEN: STATE_OPEN, + YaleLockState.LOCKED: LockState.LOCKED, + YaleLockState.UNLOCKED: LockState.UNLOCKED, + YaleLockState.DOOR_OPEN: LockState.OPEN, } @@ -108,9 +103,9 @@ class YaleDoorlock(YaleLockEntity, LockEntity): @property def is_locked(self) -> bool | None: """Return true if the lock is locked.""" - return LOCK_STATE_MAP.get(self.lock_data.state()) == STATE_LOCKED + return LOCK_STATE_MAP.get(self.lock_data.state()) == LockState.LOCKED @property def is_open(self) -> bool | None: """Return true if the lock is open.""" - return LOCK_STATE_MAP.get(self.lock_data.state()) == STATE_OPEN + return LOCK_STATE_MAP.get(self.lock_data.state()) == LockState.OPEN diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index b16c1090ef3..c14517f4b03 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -19,9 +19,8 @@ from zwave_js_server.const.command_class.lock import ( from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.util.lock import clear_usercode, set_configuration, set_usercode -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity, LockState from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform @@ -49,12 +48,12 @@ PARALLEL_UPDATES = 0 STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = { CommandClass.DOOR_LOCK: { - STATE_UNLOCKED: DoorLockMode.UNSECURED, - STATE_LOCKED: DoorLockMode.SECURED, + LockState.UNLOCKED: DoorLockMode.UNSECURED, + LockState.LOCKED: DoorLockMode.SECURED, }, CommandClass.LOCK: { - STATE_UNLOCKED: False, - STATE_LOCKED: True, + LockState.UNLOCKED: False, + LockState.LOCKED: True, }, } UNIT16_SCHEMA = vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) @@ -140,7 +139,7 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): == self.info.primary_value.value ) - async def _set_lock_state(self, target_state: str, **kwargs: Any) -> None: + async def _set_lock_state(self, target_state: LockState, **kwargs: Any) -> None: """Set the lock state.""" target_value = self.get_zwave_value( LOCK_CMD_CLASS_TO_PROPERTY_MAP[ @@ -155,11 +154,11 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - await self._set_lock_state(STATE_LOCKED) + await self._set_lock_state(LockState.LOCKED) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - await self._set_lock_state(STATE_UNLOCKED) + await self._set_lock_state(LockState.UNLOCKED) async def async_set_lock_usercode(self, code_slot: int, usercode: str) -> None: """Set the usercode to index X on the lock.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index 257fcd2bfd2..4ce98d7e69c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -487,15 +487,39 @@ STATE_ALARM_PENDING: Final = "pending" STATE_ALARM_ARMING: Final = "arming" STATE_ALARM_DISARMING: Final = "disarming" STATE_ALARM_TRIGGERED: Final = "triggered" -STATE_LOCKED: Final = "locked" -STATE_UNLOCKED: Final = "unlocked" -STATE_LOCKING: Final = "locking" -STATE_UNLOCKING: Final = "unlocking" -STATE_JAMMED: Final = "jammed" STATE_UNAVAILABLE: Final = "unavailable" STATE_OK: Final = "ok" STATE_PROBLEM: Final = "problem" +# #### LOCK STATES #### +# STATE_* below are deprecated as of 2024.10 +# use the LockState enum instead. +_DEPRECATED_STATE_LOCKED: Final = DeprecatedConstant( + "locked", + "LockState.LOCKED", + "2025.10", +) +_DEPRECATED_STATE_UNLOCKED: Final = DeprecatedConstant( + "unlocked", + "LockState.UNLOCKED", + "2025.10", +) +_DEPRECATED_STATE_LOCKING: Final = DeprecatedConstant( + "locking", + "LockState.LOCKING", + "2025.10", +) +_DEPRECATED_STATE_UNLOCKING: Final = DeprecatedConstant( + "unlocking", + "LockState.UNLOCKING", + "2025.10", +) +_DEPRECATED_STATE_JAMMED: Final = DeprecatedConstant( + "jammed", + "LockState.JAMMED", + "2025.10", +) + # #### STATE AND EVENT ATTRIBUTES #### # Attribution ATTR_ATTRIBUTION: Final = "attribution" diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 71b1b2658e2..70f64d5296a 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -9,17 +9,16 @@ import logging from types import ModuleType from typing import Any +from homeassistant.components.lock import LockState from homeassistant.components.sun import STATE_ABOVE_HORIZON, STATE_BELOW_HORIZON from homeassistant.const import ( STATE_CLOSED, STATE_HOME, - STATE_LOCKED, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_UNKNOWN, - STATE_UNLOCKED, ) from homeassistant.core import Context, HomeAssistant, State from homeassistant.loader import IntegrationNotFound, async_get_integration, bind_hass @@ -79,7 +78,7 @@ def state_as_number(state: State) -> float: """ if state.state in ( STATE_ON, - STATE_LOCKED, + LockState.LOCKED, STATE_ABOVE_HORIZON, STATE_OPEN, STATE_HOME, @@ -87,7 +86,7 @@ def state_as_number(state: State) -> float: return 1 if state.state in ( STATE_OFF, - STATE_UNLOCKED, + LockState.UNLOCKED, STATE_UNKNOWN, STATE_BELOW_HORIZON, STATE_CLOSED, diff --git a/tests/components/abode/test_lock.py b/tests/components/abode/test_lock.py index 6be1aef22ca..fe203d0b0f4 100644 --- a/tests/components/abode/test_lock.py +++ b/tests/components/abode/test_lock.py @@ -3,13 +3,12 @@ from unittest.mock import patch from homeassistant.components.abode import ATTR_DEVICE_ID -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, SERVICE_LOCK, SERVICE_UNLOCK, - STATE_LOCKED, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -34,7 +33,7 @@ async def test_attributes(hass: HomeAssistant) -> None: await setup_platform(hass, LOCK_DOMAIN) state = hass.states.get(DEVICE_ID) - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED assert state.attributes.get(ATTR_DEVICE_ID) == "ZW:00000004" assert not state.attributes.get("battery_low") assert not state.attributes.get("no_response") diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index b56d8054d7b..5acdbdb271a 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -11,7 +11,7 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING +from homeassistant.components.lock import LockState from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.valve import ValveEntityFeature from homeassistant.components.water_heater import ( @@ -28,11 +28,9 @@ from homeassistant.const import ( STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_LOCKED, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKED, UnitOfTemperature, ) from homeassistant.core import HomeAssistant @@ -392,11 +390,11 @@ async def test_api_remote_set_power_state( async def test_report_lock_state(hass: HomeAssistant) -> None: """Test LockController implements lockState property.""" - hass.states.async_set("lock.locked", STATE_LOCKED, {}) - hass.states.async_set("lock.unlocked", STATE_UNLOCKED, {}) - hass.states.async_set("lock.unlocking", STATE_UNLOCKING, {}) - hass.states.async_set("lock.locking", STATE_LOCKING, {}) - hass.states.async_set("lock.jammed", STATE_JAMMED, {}) + hass.states.async_set("lock.locked", LockState.LOCKED, {}) + hass.states.async_set("lock.unlocked", LockState.UNLOCKED, {}) + hass.states.async_set("lock.unlocking", LockState.UNLOCKING, {}) + hass.states.async_set("lock.locking", LockState.LOCKING, {}) + hass.states.async_set("lock.jammed", LockState.JAMMED, {}) hass.states.async_set("lock.unknown", STATE_UNKNOWN, {}) properties = await reported_properties(hass, "lock.locked") diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 1bbe8033ec8..3343e85d60a 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -9,14 +9,13 @@ from yalexs.const import Brand from yalexs.exceptions import AugustApiAIOHTTPError from homeassistant.components.august.const import DOMAIN -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_LOCKED, STATE_ON, ) from homeassistant.core import HomeAssistant @@ -192,7 +191,7 @@ async def test_inoperative_locks_are_filtered_out(hass: HomeAssistant) -> None: lock_a6697750d607098bae8d6baa11ef8063_name = hass.states.get( "lock.a6697750d607098bae8d6baa11ef8063_name" ) - assert lock_a6697750d607098bae8d6baa11ef8063_name.state == STATE_LOCKED + assert lock_a6697750d607098bae8d6baa11ef8063_name.state == LockState.LOCKED async def test_lock_has_doorsense(hass: HomeAssistant) -> None: diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index e786cebf3e1..1b8c98e299c 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -10,21 +10,14 @@ from syrupy import SnapshotAssertion from yalexs.manager.activity import INITIAL_LOCK_RESYNC_TIME from yalexs.pubnub_async import AugustPubNub -from homeassistant.components.lock import ( - DOMAIN as LOCK_DOMAIN, - STATE_JAMMED, - STATE_LOCKING, - STATE_UNLOCKING, -) +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_LOCKED, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -65,7 +58,7 @@ async def test_lock_changed_by(hass: HomeAssistant) -> None: lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED assert lock_state.attributes["changed_by"] == "Your favorite elven princess" @@ -76,7 +69,7 @@ async def test_state_locking(hass: HomeAssistant) -> None: activities = await _mock_activities_from_fixture(hass, "get_activity.locking.json") await _create_august_with_devices(hass, [lock_one], activities=activities) - assert hass.states.get("lock.online_with_doorsense_name").state == STATE_LOCKING + assert hass.states.get("lock.online_with_doorsense_name").state == LockState.LOCKING async def test_state_unlocking(hass: HomeAssistant) -> None: @@ -88,7 +81,9 @@ async def test_state_unlocking(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - assert hass.states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING + assert ( + hass.states.get("lock.online_with_doorsense_name").state == LockState.UNLOCKING + ) async def test_state_jammed(hass: HomeAssistant) -> None: @@ -98,7 +93,7 @@ async def test_state_jammed(hass: HomeAssistant) -> None: activities = await _mock_activities_from_fixture(hass, "get_activity.jammed.json") await _create_august_with_devices(hass, [lock_one], activities=activities) - assert hass.states.get("lock.online_with_doorsense_name").state == STATE_JAMMED + assert hass.states.get("lock.online_with_doorsense_name").state == LockState.JAMMED async def test_one_lock_operation( @@ -111,7 +106,7 @@ async def test_one_lock_operation( lock_state = states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" @@ -120,14 +115,14 @@ async def test_one_lock_operation( await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) lock_state = states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_UNLOCKED + assert lock_state.state == LockState.UNLOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKED + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKED # No activity means it will be unavailable until the activity feed has data lock_operator_sensor = entity_registry.async_get( @@ -145,13 +140,13 @@ async def test_open_lock_operation(hass: HomeAssistant) -> None: await _create_august_with_devices(hass, [lock_with_unlatch]) lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") - assert lock_online_with_unlatch_name.state == STATE_LOCKED + assert lock_online_with_unlatch_name.state == LockState.LOCKED data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") - assert lock_online_with_unlatch_name.state == STATE_UNLOCKED + assert lock_online_with_unlatch_name.state == LockState.UNLOCKED async def test_open_lock_operation_pubnub_connected( @@ -167,7 +162,7 @@ async def test_open_lock_operation_pubnub_connected( await _create_august_with_devices(hass, [lock_with_unlatch], pubnub=pubnub) pubnub.connected = True - assert hass.states.get("lock.online_with_unlatch_name").state == STATE_LOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == LockState.LOCKED data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) @@ -185,7 +180,7 @@ async def test_open_lock_operation_pubnub_connected( await hass.async_block_till_done() await hass.async_block_till_done() - assert hass.states.get("lock.online_with_unlatch_name").state == STATE_UNLOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == LockState.UNLOCKED await hass.async_block_till_done() @@ -204,7 +199,7 @@ async def test_one_lock_operation_pubnub_connected( lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" @@ -226,7 +221,7 @@ async def test_one_lock_operation_pubnub_connected( await hass.async_block_till_done() lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_UNLOCKED + assert lock_state.state == LockState.UNLOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" @@ -247,7 +242,7 @@ async def test_one_lock_operation_pubnub_connected( await hass.async_block_till_done() lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED # No activity means it will be unavailable until the activity feed has data lock_operator_sensor = entity_registry.async_get( @@ -274,7 +269,7 @@ async def test_one_lock_operation_pubnub_connected( await hass.async_block_till_done() lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_UNLOCKED + assert lock_state.state == LockState.UNLOCKED async def test_lock_jammed(hass: HomeAssistant) -> None: @@ -294,7 +289,7 @@ async def test_lock_jammed(hass: HomeAssistant) -> None: lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" @@ -303,7 +298,7 @@ async def test_lock_jammed(hass: HomeAssistant) -> None: await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_JAMMED + assert lock_state.state == LockState.JAMMED async def test_lock_throws_exception_on_unknown_status_code( @@ -325,7 +320,7 @@ async def test_lock_throws_exception_on_unknown_status_code( lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" @@ -367,7 +362,7 @@ async def test_lock_bridge_online(hass: HomeAssistant) -> None: ) await _create_august_with_devices(hass, [lock_one], activities=activities) - assert hass.states.get("lock.online_with_doorsense_name").state == STATE_LOCKED + assert hass.states.get("lock.online_with_doorsense_name").state == LockState.LOCKED async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: @@ -383,7 +378,7 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: ) pubnub.connected = True - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKED + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKED pubnub.message( pubnub, @@ -399,7 +394,7 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.UNLOCKING pubnub.message( pubnub, @@ -415,21 +410,21 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - assert hass.states.get("lock.online_with_doorsense_name").state == STATE_LOCKING + assert hass.states.get("lock.online_with_doorsense_name").state == LockState.LOCKING pubnub.connected = True async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKING # Ensure pubnub status is always preserved async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2)) await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKING pubnub.message( pubnub, @@ -444,11 +439,11 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.UNLOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4)) await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.UNLOCKING await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/deconz/test_lock.py b/tests/components/deconz/test_lock.py index 28d60e403ef..70a7bd732bb 100644 --- a/tests/components/deconz/test_lock.py +++ b/tests/components/deconz/test_lock.py @@ -8,8 +8,9 @@ from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK, + LockState, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from .conftest import WebsocketDataType @@ -43,10 +44,10 @@ async def test_lock_from_light( ) -> None: """Test that all supported lock entities based on lights are created.""" assert len(hass.states.async_all()) == 1 - assert hass.states.get("lock.door_lock").state == STATE_UNLOCKED + assert hass.states.get("lock.door_lock").state == LockState.UNLOCKED await light_ws_data({"state": {"on": True}}) - assert hass.states.get("lock.door_lock").state == STATE_LOCKED + assert hass.states.get("lock.door_lock").state == LockState.LOCKED # Verify service calls @@ -107,10 +108,10 @@ async def test_lock_from_sensor( ) -> None: """Test that all supported lock entities based on sensors are created.""" assert len(hass.states.async_all()) == 2 - assert hass.states.get("lock.door_lock").state == STATE_UNLOCKED + assert hass.states.get("lock.door_lock").state == LockState.UNLOCKED await sensor_ws_data({"state": {"lockstate": "locked"}}) - assert hass.states.get("lock.door_lock").state == STATE_LOCKED + assert hass.states.get("lock.door_lock").state == LockState.LOCKED # Verify service calls diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py index 853b9197ab7..1fc4209d300 100644 --- a/tests/components/demo/test_lock.py +++ b/tests/components/demo/test_lock.py @@ -10,19 +10,9 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_UNLOCKED, - STATE_UNLOCKING, -) -from homeassistant.const import ( - ATTR_ENTITY_ID, - EVENT_STATE_CHANGED, - STATE_OPEN, - STATE_OPENING, - Platform, + LockState, ) +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -57,7 +47,7 @@ async def setup_comp(hass: HomeAssistant, lock_only: None): async def test_locking(hass: HomeAssistant) -> None: """Test the locking of a lock.""" state = hass.states.get(KITCHEN) - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED await hass.async_block_till_done() state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) @@ -67,17 +57,17 @@ async def test_locking(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert state_changes[0].data["entity_id"] == KITCHEN - assert state_changes[0].data["new_state"].state == STATE_LOCKING + assert state_changes[0].data["new_state"].state == LockState.LOCKING assert state_changes[1].data["entity_id"] == KITCHEN - assert state_changes[1].data["new_state"].state == STATE_LOCKED + assert state_changes[1].data["new_state"].state == LockState.LOCKED @patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) async def test_unlocking(hass: HomeAssistant) -> None: """Test the unlocking of a lock.""" state = hass.states.get(FRONT) - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED await hass.async_block_till_done() state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) @@ -87,17 +77,17 @@ async def test_unlocking(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert state_changes[0].data["entity_id"] == FRONT - assert state_changes[0].data["new_state"].state == STATE_UNLOCKING + assert state_changes[0].data["new_state"].state == LockState.UNLOCKING assert state_changes[1].data["entity_id"] == FRONT - assert state_changes[1].data["new_state"].state == STATE_UNLOCKED + assert state_changes[1].data["new_state"].state == LockState.UNLOCKED @patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) async def test_opening(hass: HomeAssistant) -> None: """Test the opening of a lock.""" state = hass.states.get(OPENABLE_LOCK) - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED await hass.async_block_till_done() state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) @@ -107,17 +97,17 @@ async def test_opening(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert state_changes[0].data["entity_id"] == OPENABLE_LOCK - assert state_changes[0].data["new_state"].state == STATE_OPENING + assert state_changes[0].data["new_state"].state == LockState.OPENING assert state_changes[1].data["entity_id"] == OPENABLE_LOCK - assert state_changes[1].data["new_state"].state == STATE_OPEN + assert state_changes[1].data["new_state"].state == LockState.OPEN @patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) async def test_jammed_when_locking(hass: HomeAssistant) -> None: """Test the locking of a lock jams.""" state = hass.states.get(POORLY_INSTALLED) - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED await hass.async_block_till_done() state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) @@ -127,10 +117,10 @@ async def test_jammed_when_locking(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert state_changes[0].data["entity_id"] == POORLY_INSTALLED - assert state_changes[0].data["new_state"].state == STATE_LOCKING + assert state_changes[0].data["new_state"].state == LockState.LOCKING assert state_changes[1].data["entity_id"] == POORLY_INSTALLED - assert state_changes[1].data["new_state"].state == STATE_JAMMED + assert state_changes[1].data["new_state"].state == LockState.JAMMED async def test_opening_mocked(hass: HomeAssistant) -> None: diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index 82c24b59a2c..ae54b16d6e2 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -2,16 +2,20 @@ from unittest.mock import call -from aioesphomeapi import APIClient, LockCommand, LockEntityState, LockInfo, LockState +from aioesphomeapi import ( + APIClient, + LockCommand, + LockEntityState, + LockInfo, + LockState as ESPHomeLockState, +) from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_LOCKED, - STATE_LOCKING, - STATE_UNLOCKING, + LockState, ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -31,7 +35,7 @@ async def test_lock_entity_no_open( requires_code=False, ) ] - states = [LockEntityState(key=1, state=LockState.UNLOCKING)] + states = [LockEntityState(key=1, state=ESPHomeLockState.UNLOCKING)] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -41,7 +45,7 @@ async def test_lock_entity_no_open( ) state = hass.states.get("lock.test_mylock") assert state is not None - assert state.state == STATE_UNLOCKING + assert state.state == LockState.UNLOCKING await hass.services.async_call( LOCK_DOMAIN, @@ -65,7 +69,7 @@ async def test_lock_entity_start_locked( unique_id="my_lock", ) ] - states = [LockEntityState(key=1, state=LockState.LOCKED)] + states = [LockEntityState(key=1, state=ESPHomeLockState.LOCKED)] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -75,7 +79,7 @@ async def test_lock_entity_start_locked( ) state = hass.states.get("lock.test_mylock") assert state is not None - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED async def test_lock_entity_supports_open( @@ -92,7 +96,7 @@ async def test_lock_entity_supports_open( requires_code=True, ) ] - states = [LockEntityState(key=1, state=LockState.LOCKING)] + states = [LockEntityState(key=1, state=ESPHomeLockState.LOCKING)] user_service = [] await mock_generic_device_entry( mock_client=mock_client, @@ -102,7 +106,7 @@ async def test_lock_entity_supports_open( ) state = hass.states.get("lock.test_mylock") assert state is not None - assert state.state == STATE_LOCKING + assert state.state == LockState.LOCKING await hass.services.async_call( LOCK_DOMAIN, diff --git a/tests/components/freedompro/test_lock.py b/tests/components/freedompro/test_lock.py index 94f5609ee47..a17217c49e8 100644 --- a/tests/components/freedompro/test_lock.py +++ b/tests/components/freedompro/test_lock.py @@ -7,8 +7,9 @@ from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK, + LockState, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity @@ -39,7 +40,7 @@ async def test_lock_get_state( entity_id = "lock.lock" state = hass.states.get(entity_id) assert state - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get("friendly_name") == "lock" entry = entity_registry.async_get(entity_id) @@ -63,7 +64,7 @@ async def test_lock_get_state( assert entry assert entry.unique_id == uid - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED async def test_lock_set_unlock( @@ -87,7 +88,7 @@ async def test_lock_set_unlock( state = hass.states.get(entity_id) assert state - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED assert state.attributes.get("friendly_name") == "lock" entry = entity_registry.async_get(entity_id) @@ -113,7 +114,7 @@ async def test_lock_set_unlock( await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED async def test_lock_set_lock( @@ -126,7 +127,7 @@ async def test_lock_set_lock( entity_id = "lock.lock" state = hass.states.get(entity_id) assert state - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get("friendly_name") == "lock" entry = entity_registry.async_get(entity_id) @@ -153,4 +154,4 @@ async def test_lock_set_lock( await hass.async_block_till_done() state = hass.states.get(entity_id) - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 06e898a62fa..77a9027e76d 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1602,7 +1602,7 @@ async def test_lock_unlock_lock(hass: HomeAssistant) -> None: assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, LockEntityFeature.OPEN, None) trt = trait.LockUnlockTrait( - hass, State("lock.front_door", lock.STATE_LOCKED), PIN_CONFIG + hass, State("lock.front_door", lock.LockState.LOCKED), PIN_CONFIG ) assert trt.sync_attributes() == {} @@ -1628,7 +1628,7 @@ async def test_lock_unlock_unlocking(hass: HomeAssistant) -> None: assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, LockEntityFeature.OPEN, None) trt = trait.LockUnlockTrait( - hass, State("lock.front_door", lock.STATE_UNLOCKING), PIN_CONFIG + hass, State("lock.front_door", lock.LockState.UNLOCKING), PIN_CONFIG ) assert trt.sync_attributes() == {} @@ -1645,7 +1645,7 @@ async def test_lock_unlock_lock_jammed(hass: HomeAssistant) -> None: assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, LockEntityFeature.OPEN, None) trt = trait.LockUnlockTrait( - hass, State("lock.front_door", lock.STATE_JAMMED), PIN_CONFIG + hass, State("lock.front_door", lock.LockState.JAMMED), PIN_CONFIG ) assert trt.sync_attributes() == {} @@ -1670,7 +1670,7 @@ async def test_lock_unlock_unlock(hass: HomeAssistant) -> None: ) trt = trait.LockUnlockTrait( - hass, State("lock.front_door", lock.STATE_LOCKED), PIN_CONFIG + hass, State("lock.front_door", lock.LockState.LOCKED), PIN_CONFIG ) assert trt.sync_attributes() == {} @@ -1706,7 +1706,7 @@ async def test_lock_unlock_unlock(hass: HomeAssistant) -> None: # Test without pin trt = trait.LockUnlockTrait( - hass, State("lock.front_door", lock.STATE_LOCKED), BASIC_CONFIG + hass, State("lock.front_door", lock.LockState.LOCKED), BASIC_CONFIG ) with pytest.raises(error.SmartHomeError) as err: diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index bbbe22cba83..9e6e352e46c 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -11,6 +11,7 @@ import pytest from homeassistant.components import group from homeassistant.components.group.registry import GroupIntegrationRegistry +from homeassistant.components.lock import LockState from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME, @@ -19,17 +20,10 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_CLOSED, STATE_HOME, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, STATE_NOT_HOME, STATE_OFF, STATE_ON, - STATE_OPEN, - STATE_OPENING, STATE_UNKNOWN, - STATE_UNLOCKED, - STATE_UNLOCKING, ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import entity_registry as er @@ -740,78 +734,78 @@ async def test_is_on(hass: HomeAssistant) -> None: ), ( ("cover", "cover"), - (STATE_OPEN, STATE_CLOSED), + (LockState.OPEN, STATE_CLOSED), (STATE_CLOSED, STATE_CLOSED), - (STATE_OPEN, True), + (LockState.OPEN, True), (STATE_CLOSED, False), ), ( ("lock", "lock"), - (STATE_UNLOCKED, STATE_LOCKED), - (STATE_LOCKED, STATE_LOCKED), - (STATE_UNLOCKED, True), - (STATE_LOCKED, False), + (LockState.UNLOCKED, LockState.LOCKED), + (LockState.LOCKED, LockState.LOCKED), + (LockState.UNLOCKED, True), + (LockState.LOCKED, False), ), ( ("cover", "lock"), - (STATE_OPEN, STATE_LOCKED), - (STATE_CLOSED, STATE_LOCKED), + (LockState.OPEN, LockState.LOCKED), + (STATE_CLOSED, LockState.LOCKED), (STATE_ON, True), (STATE_OFF, False), ), ( ("cover", "lock"), - (STATE_OPEN, STATE_UNLOCKED), - (STATE_CLOSED, STATE_LOCKED), + (LockState.OPEN, LockState.UNLOCKED), + (STATE_CLOSED, LockState.LOCKED), (STATE_ON, True), (STATE_OFF, False), ), ( ("cover", "lock", "light"), - (STATE_OPEN, STATE_LOCKED, STATE_ON), - (STATE_CLOSED, STATE_LOCKED, STATE_OFF), + (LockState.OPEN, LockState.LOCKED, STATE_ON), + (STATE_CLOSED, LockState.LOCKED, STATE_OFF), (STATE_ON, True), (STATE_OFF, False), ), ( ("lock", "lock"), - (STATE_OPEN, STATE_LOCKED), - (STATE_LOCKED, STATE_LOCKED), - (STATE_UNLOCKED, True), - (STATE_LOCKED, False), + (LockState.OPEN, LockState.LOCKED), + (LockState.LOCKED, LockState.LOCKED), + (LockState.UNLOCKED, True), + (LockState.LOCKED, False), ), ( ("lock", "lock"), - (STATE_OPENING, STATE_LOCKED), - (STATE_LOCKED, STATE_LOCKED), - (STATE_UNLOCKED, True), - (STATE_LOCKED, False), + (LockState.OPENING, LockState.LOCKED), + (LockState.LOCKED, LockState.LOCKED), + (LockState.UNLOCKED, True), + (LockState.LOCKED, False), ), ( ("lock", "lock"), - (STATE_UNLOCKING, STATE_LOCKED), - (STATE_LOCKED, STATE_LOCKED), - (STATE_UNLOCKED, True), - (STATE_LOCKED, False), + (LockState.UNLOCKING, LockState.LOCKED), + (LockState.LOCKED, LockState.LOCKED), + (LockState.UNLOCKED, True), + (LockState.LOCKED, False), ), ( ("lock", "lock"), - (STATE_LOCKING, STATE_LOCKED), - (STATE_LOCKED, STATE_LOCKED), - (STATE_UNLOCKED, True), - (STATE_LOCKED, False), + (LockState.LOCKING, LockState.LOCKED), + (LockState.LOCKED, LockState.LOCKED), + (LockState.UNLOCKED, True), + (LockState.LOCKED, False), ), ( ("lock", "lock"), - (STATE_JAMMED, STATE_LOCKED), - (STATE_LOCKED, STATE_LOCKED), - (STATE_LOCKED, False), - (STATE_LOCKED, False), + (LockState.JAMMED, LockState.LOCKED), + (LockState.LOCKED, LockState.LOCKED), + (LockState.LOCKED, False), + (LockState.LOCKED, False), ), ( ("cover", "lock"), - (STATE_OPEN, STATE_OPEN), - (STATE_CLOSED, STATE_LOCKED), + (LockState.OPEN, LockState.OPEN), + (STATE_CLOSED, LockState.LOCKED), (STATE_ON, True), (STATE_OFF, False), ), diff --git a/tests/components/group/test_lock.py b/tests/components/group/test_lock.py index 0c62913ae3e..cc255264183 100644 --- a/tests/components/group/test_lock.py +++ b/tests/components/group/test_lock.py @@ -12,18 +12,9 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + LockState, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_OPEN, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - STATE_UNLOCKED, - STATE_UNLOCKING, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -55,7 +46,7 @@ async def test_default_state( state = hass.states.get("lock.door_group") assert state is not None - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED assert state.attributes.get(ATTR_ENTITY_ID) == ["lock.front", "lock.back"] entry = entity_registry.async_get("lock.door_group") @@ -109,63 +100,63 @@ async def test_state_reporting(hass: HomeAssistant) -> None: # At least one member jammed -> group jammed for state_1 in ( - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, + LockState.JAMMED, + LockState.LOCKED, + LockState.LOCKING, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKED, - STATE_UNLOCKING, + LockState.UNLOCKED, + LockState.UNLOCKING, ): hass.states.async_set("lock.test1", state_1) - hass.states.async_set("lock.test2", STATE_JAMMED) + hass.states.async_set("lock.test2", LockState.JAMMED) await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_JAMMED + assert hass.states.get("lock.lock_group").state == LockState.JAMMED # At least one member locking -> group unlocking for state_1 in ( - STATE_LOCKED, - STATE_LOCKING, + LockState.LOCKED, + LockState.LOCKING, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKED, - STATE_UNLOCKING, + LockState.UNLOCKED, + LockState.UNLOCKING, ): hass.states.async_set("lock.test1", state_1) - hass.states.async_set("lock.test2", STATE_LOCKING) + hass.states.async_set("lock.test2", LockState.LOCKING) await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_LOCKING + assert hass.states.get("lock.lock_group").state == LockState.LOCKING # At least one member unlocking -> group unlocking for state_1 in ( - STATE_LOCKED, + LockState.LOCKED, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKED, - STATE_UNLOCKING, + LockState.UNLOCKED, + LockState.UNLOCKING, ): hass.states.async_set("lock.test1", state_1) - hass.states.async_set("lock.test2", STATE_UNLOCKING) + hass.states.async_set("lock.test2", LockState.UNLOCKING) await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNLOCKING + assert hass.states.get("lock.lock_group").state == LockState.UNLOCKING # At least one member unlocked -> group unlocked for state_1 in ( - STATE_LOCKED, + LockState.LOCKED, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKED, + LockState.UNLOCKED, ): hass.states.async_set("lock.test1", state_1) - hass.states.async_set("lock.test2", STATE_UNLOCKED) + hass.states.async_set("lock.test2", LockState.UNLOCKED) await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED + assert hass.states.get("lock.lock_group").state == LockState.UNLOCKED # Otherwise -> locked - hass.states.async_set("lock.test1", STATE_LOCKED) - hass.states.async_set("lock.test2", STATE_LOCKED) + hass.states.async_set("lock.test1", LockState.LOCKED) + hass.states.async_set("lock.test2", LockState.LOCKED) await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_LOCKED + assert hass.states.get("lock.lock_group").state == LockState.LOCKED # All group members removed from the state machine -> unavailable hass.states.async_remove("lock.test1") @@ -195,9 +186,9 @@ async def test_service_calls_openable(hass: HomeAssistant) -> None: await hass.async_block_till_done() group_state = hass.states.get("lock.lock_group") - assert group_state.state == STATE_UNLOCKED - assert hass.states.get("lock.openable_lock").state == STATE_LOCKED - assert hass.states.get("lock.another_openable_lock").state == STATE_UNLOCKED + assert group_state.state == LockState.UNLOCKED + assert hass.states.get("lock.openable_lock").state == LockState.LOCKED + assert hass.states.get("lock.another_openable_lock").state == LockState.UNLOCKED await hass.services.async_call( LOCK_DOMAIN, @@ -205,8 +196,8 @@ async def test_service_calls_openable(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "lock.lock_group"}, blocking=True, ) - assert hass.states.get("lock.openable_lock").state == STATE_OPEN - assert hass.states.get("lock.another_openable_lock").state == STATE_OPEN + assert hass.states.get("lock.openable_lock").state == LockState.OPEN + assert hass.states.get("lock.another_openable_lock").state == LockState.OPEN await hass.services.async_call( LOCK_DOMAIN, @@ -214,8 +205,8 @@ async def test_service_calls_openable(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "lock.lock_group"}, blocking=True, ) - assert hass.states.get("lock.openable_lock").state == STATE_LOCKED - assert hass.states.get("lock.another_openable_lock").state == STATE_LOCKED + assert hass.states.get("lock.openable_lock").state == LockState.LOCKED + assert hass.states.get("lock.another_openable_lock").state == LockState.LOCKED await hass.services.async_call( LOCK_DOMAIN, @@ -223,8 +214,8 @@ async def test_service_calls_openable(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "lock.lock_group"}, blocking=True, ) - assert hass.states.get("lock.openable_lock").state == STATE_UNLOCKED - assert hass.states.get("lock.another_openable_lock").state == STATE_UNLOCKED + assert hass.states.get("lock.openable_lock").state == LockState.UNLOCKED + assert hass.states.get("lock.another_openable_lock").state == LockState.UNLOCKED async def test_service_calls_basic(hass: HomeAssistant) -> None: @@ -248,9 +239,9 @@ async def test_service_calls_basic(hass: HomeAssistant) -> None: await hass.async_block_till_done() group_state = hass.states.get("lock.lock_group") - assert group_state.state == STATE_UNLOCKED - assert hass.states.get("lock.basic_lock").state == STATE_LOCKED - assert hass.states.get("lock.another_basic_lock").state == STATE_UNLOCKED + assert group_state.state == LockState.UNLOCKED + assert hass.states.get("lock.basic_lock").state == LockState.LOCKED + assert hass.states.get("lock.another_basic_lock").state == LockState.UNLOCKED await hass.services.async_call( LOCK_DOMAIN, @@ -258,8 +249,8 @@ async def test_service_calls_basic(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "lock.lock_group"}, blocking=True, ) - assert hass.states.get("lock.basic_lock").state == STATE_LOCKED - assert hass.states.get("lock.another_basic_lock").state == STATE_LOCKED + assert hass.states.get("lock.basic_lock").state == LockState.LOCKED + assert hass.states.get("lock.another_basic_lock").state == LockState.LOCKED await hass.services.async_call( LOCK_DOMAIN, @@ -267,8 +258,8 @@ async def test_service_calls_basic(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "lock.lock_group"}, blocking=True, ) - assert hass.states.get("lock.basic_lock").state == STATE_UNLOCKED - assert hass.states.get("lock.another_basic_lock").state == STATE_UNLOCKED + assert hass.states.get("lock.basic_lock").state == LockState.UNLOCKED + assert hass.states.get("lock.another_basic_lock").state == LockState.UNLOCKED with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -303,7 +294,7 @@ async def test_reload(hass: HomeAssistant) -> None: await hass.async_start() await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED + assert hass.states.get("lock.lock_group").state == LockState.UNLOCKED yaml_path = get_fixture_path("configuration.yaml", "group") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): @@ -322,7 +313,7 @@ async def test_reload(hass: HomeAssistant) -> None: async def test_reload_with_platform_not_setup(hass: HomeAssistant) -> None: """Test the ability to reload locks.""" - hass.states.async_set("lock.something", STATE_UNLOCKED) + hass.states.async_set("lock.something", LockState.UNLOCKED) await async_setup_component( hass, LOCK_DOMAIN, @@ -372,11 +363,11 @@ async def test_reload_with_base_integration_platform_not_setup( }, ) await hass.async_block_till_done() - hass.states.async_set("lock.front_lock", STATE_LOCKED) - hass.states.async_set("lock.back_lock", STATE_UNLOCKED) + hass.states.async_set("lock.front_lock", LockState.LOCKED) + hass.states.async_set("lock.back_lock", LockState.UNLOCKED) - hass.states.async_set("lock.outside_lock", STATE_LOCKED) - hass.states.async_set("lock.outside_lock_2", STATE_LOCKED) + hass.states.async_set("lock.outside_lock", LockState.LOCKED) + hass.states.async_set("lock.outside_lock_2", LockState.LOCKED) yaml_path = get_fixture_path("configuration.yaml", "group") with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): @@ -391,8 +382,8 @@ async def test_reload_with_base_integration_platform_not_setup( assert hass.states.get("lock.lock_group") is None assert hass.states.get("lock.inside_locks_g") is not None assert hass.states.get("lock.outside_locks_g") is not None - assert hass.states.get("lock.inside_locks_g").state == STATE_UNLOCKED - assert hass.states.get("lock.outside_locks_g").state == STATE_LOCKED + assert hass.states.get("lock.inside_locks_g").state == LockState.UNLOCKED + assert hass.states.get("lock.outside_locks_g").state == LockState.LOCKED @patch.object(demo_lock, "LOCK_UNLOCK_DELAY", 0) @@ -426,7 +417,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: state = hass.states.get("lock.some_group") assert state is not None - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ENTITY_ID) == [ "lock.front_door", "lock.kitchen_door", @@ -434,7 +425,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: state = hass.states.get("lock.nested_group") assert state is not None - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ENTITY_ID) == ["lock.some_group"] # Test controlling the nested group @@ -444,7 +435,7 @@ async def test_nested_group(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: "lock.nested_group"}, blocking=True, ) - assert hass.states.get("lock.front_door").state == STATE_LOCKED - assert hass.states.get("lock.kitchen_door").state == STATE_LOCKED - assert hass.states.get("lock.some_group").state == STATE_LOCKED - assert hass.states.get("lock.nested_group").state == STATE_LOCKED + assert hass.states.get("lock.front_door").state == LockState.LOCKED + assert hass.states.get("lock.kitchen_door").state == LockState.LOCKED + assert hass.states.get("lock.some_group").state == LockState.LOCKED + assert hass.states.get("lock.nested_group").state == LockState.LOCKED diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index 5b5b355d10f..2961fe52170 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -4,19 +4,12 @@ import pytest from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.components.homekit.type_locks import Lock -from homeassistant.components.lock import ( - DOMAIN as LOCK_DOMAIN, - STATE_JAMMED, - STATE_LOCKING, - STATE_UNLOCKING, -) +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, - STATE_LOCKED, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKED, ) from homeassistant.core import Event, HomeAssistant @@ -40,27 +33,27 @@ async def test_lock_unlock(hass: HomeAssistant, hk_driver, events: list[Event]) assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 1 - hass.states.async_set(entity_id, STATE_LOCKED) + hass.states.async_set(entity_id, LockState.LOCKED) await hass.async_block_till_done() assert acc.char_current_state.value == 1 assert acc.char_target_state.value == 1 - hass.states.async_set(entity_id, STATE_LOCKING) + hass.states.async_set(entity_id, LockState.LOCKING) await hass.async_block_till_done() assert acc.char_current_state.value == 0 assert acc.char_target_state.value == 1 - hass.states.async_set(entity_id, STATE_UNLOCKED) + hass.states.async_set(entity_id, LockState.UNLOCKED) await hass.async_block_till_done() assert acc.char_current_state.value == 0 assert acc.char_target_state.value == 0 - hass.states.async_set(entity_id, STATE_UNLOCKING) + hass.states.async_set(entity_id, LockState.UNLOCKING) await hass.async_block_till_done() assert acc.char_current_state.value == 1 assert acc.char_target_state.value == 0 - hass.states.async_set(entity_id, STATE_JAMMED) + hass.states.async_set(entity_id, LockState.JAMMED) await hass.async_block_till_done() assert acc.char_current_state.value == 2 assert acc.char_target_state.value == 0 @@ -78,7 +71,7 @@ async def test_lock_unlock(hass: HomeAssistant, hk_driver, events: list[Event]) assert acc.char_target_state.value == 0 assert acc.available is False - hass.states.async_set(entity_id, STATE_UNLOCKED) + hass.states.async_set(entity_id, LockState.UNLOCKED) await hass.async_block_till_done() assert acc.char_current_state.value == 0 assert acc.char_target_state.value == 0 diff --git a/tests/components/homematicip_cloud/test_lock.py b/tests/components/homematicip_cloud/test_lock.py index 4eef4526a7a..cb8a0188639 100644 --- a/tests/components/homematicip_cloud/test_lock.py +++ b/tests/components/homematicip_cloud/test_lock.py @@ -2,15 +2,14 @@ from unittest.mock import patch -from homematicip.base.enums import LockState, MotorState +from homematicip.base.enums import LockState as HomematicLockState, MotorState import pytest from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, - STATE_LOCKING, - STATE_UNLOCKING, LockEntityFeature, + LockState, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES from homeassistant.core import HomeAssistant @@ -52,7 +51,7 @@ async def test_hmip_doorlockdrive( blocking=True, ) assert hmip_device.mock_calls[-1][0] == "set_lock_state" - assert hmip_device.mock_calls[-1][1] == (LockState.OPEN,) + assert hmip_device.mock_calls[-1][1] == (HomematicLockState.OPEN,) await hass.services.async_call( "lock", @@ -61,7 +60,7 @@ async def test_hmip_doorlockdrive( blocking=True, ) assert hmip_device.mock_calls[-1][0] == "set_lock_state" - assert hmip_device.mock_calls[-1][1] == (LockState.LOCKED,) + assert hmip_device.mock_calls[-1][1] == (HomematicLockState.LOCKED,) await hass.services.async_call( "lock", @@ -71,19 +70,19 @@ async def test_hmip_doorlockdrive( ) assert hmip_device.mock_calls[-1][0] == "set_lock_state" - assert hmip_device.mock_calls[-1][1] == (LockState.UNLOCKED,) + assert hmip_device.mock_calls[-1][1] == (HomematicLockState.UNLOCKED,) await async_manipulate_test_data( hass, hmip_device, "motorState", MotorState.CLOSING ) ha_state = hass.states.get(entity_id) - assert ha_state.state == STATE_LOCKING + assert ha_state.state == LockState.LOCKING await async_manipulate_test_data( hass, hmip_device, "motorState", MotorState.OPENING ) ha_state = hass.states.get(entity_id) - assert ha_state.state == STATE_UNLOCKING + assert ha_state.state == LockState.UNLOCKING async def test_hmip_doorlockdrive_handle_errors( diff --git a/tests/components/insteon/test_lock.py b/tests/components/insteon/test_lock.py index f0ed0bbe66f..ec236059c74 100644 --- a/tests/components/insteon/test_lock.py +++ b/tests/components/insteon/test_lock.py @@ -10,15 +10,8 @@ from homeassistant.components.insteon import ( entity as insteon_entity, utils as insteon_utils, ) -from homeassistant.components.lock import ( # SERVICE_LOCK,; SERVICE_UNLOCK, - DOMAIN as LOCK_DOMAIN, -) -from homeassistant.const import ( # ATTR_ENTITY_ID,; - EVENT_HOMEASSISTANT_STOP, - STATE_LOCKED, - STATE_UNLOCKED, - Platform, -) +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -73,7 +66,7 @@ async def test_lock_lock( try: lock = entity_registry.async_get("lock.device_55_55_55_55_55_55") state = hass.states.get(lock.entity_id) - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED # lock via UI await hass.services.async_call( @@ -102,7 +95,7 @@ async def test_lock_unlock( lock = entity_registry.async_get("lock.device_55_55_55_55_55_55") state = hass.states.get(lock.entity_id) - assert state.state is STATE_LOCKED + assert state.state == LockState.LOCKED # lock via UI await hass.services.async_call( diff --git a/tests/components/kitchen_sink/test_lock.py b/tests/components/kitchen_sink/test_lock.py index e86300a4d35..a626cccd45c 100644 --- a/tests/components/kitchen_sink/test_lock.py +++ b/tests/components/kitchen_sink/test_lock.py @@ -11,17 +11,9 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_LOCKED, - STATE_LOCKING, - STATE_UNLOCKED, - STATE_UNLOCKING, -) -from homeassistant.const import ( - ATTR_ENTITY_ID, - EVENT_STATE_CHANGED, - STATE_OPEN, - Platform, + LockState, ) +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -58,7 +50,7 @@ async def test_states(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: async def test_locking(hass: HomeAssistant) -> None: """Test the locking of a lock.""" state = hass.states.get(UNLOCKED_LOCK) - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED await hass.async_block_till_done() state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) @@ -68,16 +60,16 @@ async def test_locking(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert state_changes[0].data["entity_id"] == UNLOCKED_LOCK - assert state_changes[0].data["new_state"].state == STATE_LOCKING + assert state_changes[0].data["new_state"].state == LockState.LOCKING assert state_changes[1].data["entity_id"] == UNLOCKED_LOCK - assert state_changes[1].data["new_state"].state == STATE_LOCKED + assert state_changes[1].data["new_state"].state == LockState.LOCKED async def test_unlocking(hass: HomeAssistant) -> None: """Test the unlocking of a lock.""" state = hass.states.get(LOCKED_LOCK) - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED await hass.async_block_till_done() state_changes = async_capture_events(hass, EVENT_STATE_CHANGED) @@ -87,10 +79,10 @@ async def test_unlocking(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert state_changes[0].data["entity_id"] == LOCKED_LOCK - assert state_changes[0].data["new_state"].state == STATE_UNLOCKING + assert state_changes[0].data["new_state"].state == LockState.UNLOCKING assert state_changes[1].data["entity_id"] == LOCKED_LOCK - assert state_changes[1].data["new_state"].state == STATE_UNLOCKED + assert state_changes[1].data["new_state"].state == LockState.UNLOCKED async def test_opening_mocked(hass: HomeAssistant) -> None: @@ -108,4 +100,4 @@ async def test_opening(hass: HomeAssistant) -> None: LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True ) state = hass.states.get(OPENABLE_LOCK) - assert state.state == STATE_OPEN + assert state.state == LockState.OPEN diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 74910e1909f..1818d4933b8 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -5,17 +5,8 @@ from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.lock import DOMAIN -from homeassistant.const import ( - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_OPEN, - STATE_OPENING, - STATE_UNLOCKED, - STATE_UNLOCKING, - EntityCategory, -) +from homeassistant.components.lock import DOMAIN, LockState +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider @@ -142,7 +133,7 @@ async def test_if_state( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_LOCKED) + hass.states.async_set(entry.entity_id, LockState.LOCKED) assert await async_setup_component( hass, @@ -284,38 +275,38 @@ async def test_if_state( assert len(service_calls) == 1 assert service_calls[0].data["some"] == "is_locked - event - test_event1" - hass.states.async_set(entry.entity_id, STATE_UNLOCKED) + hass.states.async_set(entry.entity_id, LockState.UNLOCKED) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() assert len(service_calls) == 2 assert service_calls[1].data["some"] == "is_unlocked - event - test_event2" - hass.states.async_set(entry.entity_id, STATE_UNLOCKING) + hass.states.async_set(entry.entity_id, LockState.UNLOCKING) hass.bus.async_fire("test_event3") await hass.async_block_till_done() assert len(service_calls) == 3 assert service_calls[2].data["some"] == "is_unlocking - event - test_event3" - hass.states.async_set(entry.entity_id, STATE_LOCKING) + hass.states.async_set(entry.entity_id, LockState.LOCKING) hass.bus.async_fire("test_event4") await hass.async_block_till_done() assert len(service_calls) == 4 assert service_calls[3].data["some"] == "is_locking - event - test_event4" - hass.states.async_set(entry.entity_id, STATE_JAMMED) + hass.states.async_set(entry.entity_id, LockState.JAMMED) hass.bus.async_fire("test_event5") await hass.async_block_till_done() assert len(service_calls) == 5 assert service_calls[4].data["some"] == "is_jammed - event - test_event5" - hass.states.async_set(entry.entity_id, STATE_OPENING) + hass.states.async_set(entry.entity_id, LockState.OPENING) hass.bus.async_fire("test_event6") await hass.async_block_till_done() assert len(service_calls) == 6 assert service_calls[5].data["some"] == "is_opening - event - test_event6" - hass.states.async_set(entry.entity_id, STATE_OPEN) + hass.states.async_set(entry.entity_id, LockState.OPEN) hass.bus.async_fire("test_event7") await hass.async_block_till_done() assert len(service_calls) == 7 @@ -339,7 +330,7 @@ async def test_if_state_legacy( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_LOCKED) + hass.states.async_set(entry.entity_id, LockState.LOCKED) assert await async_setup_component( hass, diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index f64334fa29b..3ecdf2a9bca 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -7,17 +7,8 @@ from pytest_unordered import unordered from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.lock import DOMAIN, LockEntityFeature -from homeassistant.const import ( - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_OPEN, - STATE_OPENING, - STATE_UNLOCKED, - STATE_UNLOCKING, - EntityCategory, -) +from homeassistant.components.lock import DOMAIN, LockEntityFeature, LockState +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryHider @@ -218,7 +209,7 @@ async def test_if_fires_on_state_change( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_UNLOCKED) + hass.states.async_set(entry.entity_id, LockState.UNLOCKED) assert await async_setup_component( hass, @@ -287,7 +278,7 @@ async def test_if_fires_on_state_change( ) # Fake that the entity is turning on. - hass.states.async_set(entry.entity_id, STATE_LOCKED) + hass.states.async_set(entry.entity_id, LockState.LOCKED) await hass.async_block_till_done() assert len(service_calls) == 1 assert ( @@ -296,7 +287,7 @@ async def test_if_fires_on_state_change( ) # Fake that the entity is turning off. - hass.states.async_set(entry.entity_id, STATE_UNLOCKED) + hass.states.async_set(entry.entity_id, LockState.UNLOCKED) await hass.async_block_till_done() assert len(service_calls) == 2 assert ( @@ -305,7 +296,7 @@ async def test_if_fires_on_state_change( ) # Fake that the entity is opens. - hass.states.async_set(entry.entity_id, STATE_OPEN) + hass.states.async_set(entry.entity_id, LockState.OPEN) await hass.async_block_till_done() assert len(service_calls) == 3 assert ( @@ -331,7 +322,7 @@ async def test_if_fires_on_state_change_legacy( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_UNLOCKED) + hass.states.async_set(entry.entity_id, LockState.UNLOCKED) assert await async_setup_component( hass, @@ -362,7 +353,7 @@ async def test_if_fires_on_state_change_legacy( ) # Fake that the entity is turning on. - hass.states.async_set(entry.entity_id, STATE_LOCKED) + hass.states.async_set(entry.entity_id, LockState.LOCKED) await hass.async_block_till_done() assert len(service_calls) == 1 assert ( @@ -388,7 +379,7 @@ async def test_if_fires_on_state_change_with_for( DOMAIN, "test", "5678", device_id=device_entry.id ) - hass.states.async_set(entry.entity_id, STATE_UNLOCKED) + hass.states.async_set(entry.entity_id, LockState.UNLOCKED) assert await async_setup_component( hass, @@ -511,7 +502,7 @@ async def test_if_fires_on_state_change_with_for( await hass.async_block_till_done() assert len(service_calls) == 0 - hass.states.async_set(entry.entity_id, STATE_LOCKED) + hass.states.async_set(entry.entity_id, LockState.LOCKED) await hass.async_block_till_done() assert len(service_calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -523,7 +514,7 @@ async def test_if_fires_on_state_change_with_for( == f"turn_off device - {entry.entity_id} - unlocked - locked - 0:00:05" ) - hass.states.async_set(entry.entity_id, STATE_UNLOCKING) + hass.states.async_set(entry.entity_id, LockState.UNLOCKING) await hass.async_block_till_done() assert len(service_calls) == 1 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=16)) @@ -535,7 +526,7 @@ async def test_if_fires_on_state_change_with_for( == f"turn_on device - {entry.entity_id} - locked - unlocking - 0:00:05" ) - hass.states.async_set(entry.entity_id, STATE_JAMMED) + hass.states.async_set(entry.entity_id, LockState.JAMMED) await hass.async_block_till_done() assert len(service_calls) == 2 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=21)) @@ -547,7 +538,7 @@ async def test_if_fires_on_state_change_with_for( == f"turn_off device - {entry.entity_id} - unlocking - jammed - 0:00:05" ) - hass.states.async_set(entry.entity_id, STATE_LOCKING) + hass.states.async_set(entry.entity_id, LockState.LOCKING) await hass.async_block_till_done() assert len(service_calls) == 3 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=27)) @@ -559,7 +550,7 @@ async def test_if_fires_on_state_change_with_for( == f"turn_on device - {entry.entity_id} - jammed - locking - 0:00:05" ) - hass.states.async_set(entry.entity_id, STATE_OPENING) + hass.states.async_set(entry.entity_id, LockState.OPENING) await hass.async_block_till_done() assert len(service_calls) == 4 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=27)) diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index f0547fbbeae..a80aa78cec2 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -2,6 +2,7 @@ from __future__ import annotations +from enum import Enum import re from typing import Any @@ -15,14 +16,9 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_UNLOCKED, - STATE_UNLOCKING, LockEntityFeature, + LockState, ) -from homeassistant.const import STATE_OPEN, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.entity_registry as er @@ -67,37 +63,37 @@ async def test_lock_states(hass: HomeAssistant, mock_lock_entity: MockLock) -> N mock_lock_entity._attr_is_locking = True assert mock_lock_entity.is_locking - assert mock_lock_entity.state == STATE_LOCKING + assert mock_lock_entity.state == LockState.LOCKING mock_lock_entity._attr_is_locked = True mock_lock_entity._attr_is_locking = False assert mock_lock_entity.is_locked - assert mock_lock_entity.state == STATE_LOCKED + assert mock_lock_entity.state == LockState.LOCKED mock_lock_entity._attr_is_unlocking = True assert mock_lock_entity.is_unlocking - assert mock_lock_entity.state == STATE_UNLOCKING + assert mock_lock_entity.state == LockState.UNLOCKING mock_lock_entity._attr_is_locked = False mock_lock_entity._attr_is_unlocking = False assert not mock_lock_entity.is_locked - assert mock_lock_entity.state == STATE_UNLOCKED + assert mock_lock_entity.state == LockState.UNLOCKED mock_lock_entity._attr_is_jammed = True assert mock_lock_entity.is_jammed - assert mock_lock_entity.state == STATE_JAMMED + assert mock_lock_entity.state == LockState.JAMMED assert not mock_lock_entity.is_locked mock_lock_entity._attr_is_jammed = False mock_lock_entity._attr_is_opening = True assert mock_lock_entity.is_opening - assert mock_lock_entity.state == STATE_OPENING + assert mock_lock_entity.state == LockState.OPENING assert mock_lock_entity.is_opening mock_lock_entity._attr_is_opening = False mock_lock_entity._attr_is_open = True assert not mock_lock_entity.is_opening - assert mock_lock_entity.state == STATE_OPEN + assert mock_lock_entity.state == LockState.OPEN assert not mock_lock_entity.is_opening assert mock_lock_entity.is_open @@ -393,13 +389,35 @@ def test_all() -> None: help_test_all(lock) -@pytest.mark.parametrize(("enum"), list(LockEntityFeature)) +def _create_tuples( + enum: type[Enum], constant_prefix: str, remove_in_version: str +) -> list[tuple[Enum, str]]: + return [ + (enum_field, constant_prefix, remove_in_version) + for enum_field in enum + if enum_field + not in [ + lock.LockState.OPEN, + lock.LockState.OPENING, + ] + ] + + +@pytest.mark.parametrize( + ("enum", "constant_prefix", "remove_in_version"), + _create_tuples(lock.LockEntityFeature, "SUPPORT_", "2025.1") + + _create_tuples(lock.LockState, "STATE_", "2025.10"), +) def test_deprecated_constants( caplog: pytest.LogCaptureFixture, - enum: LockEntityFeature, + enum: Enum, + constant_prefix: str, + remove_in_version: str, ) -> None: """Test deprecated constants.""" - import_and_test_deprecated_constant_enum(caplog, lock, enum, "SUPPORT_", "2025.1") + import_and_test_deprecated_constant_enum( + caplog, lock, enum, constant_prefix, remove_in_version + ) def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: diff --git a/tests/components/loqed/test_lock.py b/tests/components/loqed/test_lock.py index 5fd00b66c43..89a7888571a 100644 --- a/tests/components/loqed/test_lock.py +++ b/tests/components/loqed/test_lock.py @@ -2,6 +2,7 @@ from loqedAPI import loqed +from homeassistant.components.lock import LockState from homeassistant.components.loqed import LoqedDataCoordinator from homeassistant.components.loqed.const import DOMAIN from homeassistant.const import ( @@ -9,8 +10,6 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_LOCKED, - STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant @@ -27,7 +26,7 @@ async def test_lock_entity( state = hass.states.get(entity_id) assert state - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED async def test_lock_responds_to_bolt_state_updates( @@ -43,7 +42,7 @@ async def test_lock_responds_to_bolt_state_updates( state = hass.states.get(entity_id) assert state - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED async def test_lock_transition_to_unlocked( diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index f279430b393..ee2f3154f31 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -6,13 +6,8 @@ from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode import pytest -from homeassistant.components.lock import ( - STATE_LOCKED, - STATE_OPEN, - STATE_UNLOCKED, - LockEntityFeature, -) -from homeassistant.const import ATTR_CODE, STATE_LOCKING, STATE_OPENING, STATE_UNKNOWN +from homeassistant.components.lock import LockEntityFeature, LockState +from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.entity_registry as er @@ -67,28 +62,28 @@ async def test_lock( await hass.async_block_till_done() state = hass.states.get("lock.mock_door_lock_lock") assert state - assert state.state == STATE_LOCKING + assert state.state == LockState.LOCKING set_node_attribute(door_lock, 1, 257, 0, 0) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("lock.mock_door_lock_lock") assert state - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED set_node_attribute(door_lock, 1, 257, 0, 2) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("lock.mock_door_lock_lock") assert state - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED set_node_attribute(door_lock, 1, 257, 0, 1) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("lock.mock_door_lock_lock") assert state - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED set_node_attribute(door_lock, 1, 257, 0, None) await trigger_subscription_callback(hass, matter_client) @@ -178,7 +173,7 @@ async def test_lock_with_unbolt( """Test door lock.""" state = hass.states.get("lock.mock_door_lock_lock") assert state - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED assert state.attributes["supported_features"] & LockEntityFeature.OPEN # test unlock/unbolt await hass.services.async_call( @@ -218,18 +213,18 @@ async def test_lock_with_unbolt( await hass.async_block_till_done() state = hass.states.get("lock.mock_door_lock_lock") assert state - assert state.state == STATE_OPENING + assert state.state == LockState.OPENING set_node_attribute(door_lock_with_unbolt, 1, 257, 0, 0) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("lock.mock_door_lock_lock") assert state - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED set_node_attribute(door_lock_with_unbolt, 1, 257, 0, 3) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("lock.mock_door_lock_lock") assert state - assert state.state == STATE_OPEN + assert state.state == LockState.OPEN diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 331f21a0a7c..034f9b5ff6e 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -10,14 +10,8 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_OPEN, - STATE_OPENING, - STATE_UNLOCKED, - STATE_UNLOCKING, LockEntityFeature, + LockState, ) from homeassistant.components.mqtt.lock import MQTT_LOCK_ATTRIBUTES_BLOCKED from homeassistant.const import ( @@ -89,12 +83,12 @@ CONFIG_WITH_STATES = { @pytest.mark.parametrize( ("hass_config", "payload", "lock_state"), [ - (CONFIG_WITH_STATES, "closed", STATE_LOCKED), - (CONFIG_WITH_STATES, "closing", STATE_LOCKING), - (CONFIG_WITH_STATES, "open", STATE_OPEN), - (CONFIG_WITH_STATES, "opening", STATE_OPENING), - (CONFIG_WITH_STATES, "unlocked", STATE_UNLOCKED), - (CONFIG_WITH_STATES, "unlocking", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "closed", LockState.LOCKED), + (CONFIG_WITH_STATES, "closing", LockState.LOCKING), + (CONFIG_WITH_STATES, "open", LockState.OPEN), + (CONFIG_WITH_STATES, "opening", LockState.OPENING), + (CONFIG_WITH_STATES, "unlocked", LockState.UNLOCKED), + (CONFIG_WITH_STATES, "unlocking", LockState.UNLOCKING), ], ) async def test_controlling_state_via_topic( @@ -115,18 +109,18 @@ async def test_controlling_state_via_topic( await hass.async_block_till_done() state = hass.states.get("lock.test") - assert state.state is lock_state + assert state.state == lock_state @pytest.mark.parametrize( ("hass_config", "payload", "lock_state"), [ - (CONFIG_WITH_STATES, "closed", STATE_LOCKED), - (CONFIG_WITH_STATES, "closing", STATE_LOCKING), - (CONFIG_WITH_STATES, "open", STATE_OPEN), - (CONFIG_WITH_STATES, "opening", STATE_OPENING), - (CONFIG_WITH_STATES, "unlocked", STATE_UNLOCKED), - (CONFIG_WITH_STATES, "unlocking", STATE_UNLOCKING), + (CONFIG_WITH_STATES, "closed", LockState.LOCKED), + (CONFIG_WITH_STATES, "closing", LockState.LOCKING), + (CONFIG_WITH_STATES, "open", LockState.OPEN), + (CONFIG_WITH_STATES, "opening", LockState.OPENING), + (CONFIG_WITH_STATES, "unlocked", LockState.UNLOCKED), + (CONFIG_WITH_STATES, "unlocking", LockState.UNLOCKING), (CONFIG_WITH_STATES, "None", STATE_UNKNOWN), ], ) @@ -146,13 +140,13 @@ async def test_controlling_non_default_state_via_topic( async_fire_mqtt_message(hass, "state-topic", payload) state = hass.states.get("lock.test") - assert state.state is lock_state + assert state.state == lock_state # Empty state is ignored async_fire_mqtt_message(hass, "state-topic", "") state = hass.states.get("lock.test") - assert state.state is lock_state + assert state.state == lock_state @pytest.mark.parametrize( @@ -165,7 +159,7 @@ async def test_controlling_non_default_state_via_topic( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"closed"}', - STATE_LOCKED, + LockState.LOCKED, ), ( help_custom_config( @@ -174,7 +168,7 @@ async def test_controlling_non_default_state_via_topic( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"closing"}', - STATE_LOCKING, + LockState.LOCKING, ), ( help_custom_config( @@ -183,7 +177,7 @@ async def test_controlling_non_default_state_via_topic( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"unlocking"}', - STATE_UNLOCKING, + LockState.UNLOCKING, ), ( help_custom_config( @@ -192,7 +186,7 @@ async def test_controlling_non_default_state_via_topic( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"open"}', - STATE_OPEN, + LockState.OPEN, ), ( help_custom_config( @@ -201,7 +195,7 @@ async def test_controlling_non_default_state_via_topic( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"opening"}', - STATE_OPENING, + LockState.OPENING, ), ( help_custom_config( @@ -210,7 +204,7 @@ async def test_controlling_non_default_state_via_topic( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"unlocked"}', - STATE_UNLOCKED, + LockState.UNLOCKED, ), ( help_custom_config( @@ -238,7 +232,7 @@ async def test_controlling_state_via_topic_and_json_message( async_fire_mqtt_message(hass, "state-topic", payload) state = hass.states.get("lock.test") - assert state.state is lock_state + assert state.state == lock_state @pytest.mark.parametrize( @@ -251,7 +245,7 @@ async def test_controlling_state_via_topic_and_json_message( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"closed"}', - STATE_LOCKED, + LockState.LOCKED, ), ( help_custom_config( @@ -260,7 +254,7 @@ async def test_controlling_state_via_topic_and_json_message( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"closing"}', - STATE_LOCKING, + LockState.LOCKING, ), ( help_custom_config( @@ -269,7 +263,7 @@ async def test_controlling_state_via_topic_and_json_message( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"open"}', - STATE_OPEN, + LockState.OPEN, ), ( help_custom_config( @@ -278,7 +272,7 @@ async def test_controlling_state_via_topic_and_json_message( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"opening"}', - STATE_OPENING, + LockState.OPENING, ), ( help_custom_config( @@ -287,7 +281,7 @@ async def test_controlling_state_via_topic_and_json_message( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"unlocked"}', - STATE_UNLOCKED, + LockState.UNLOCKED, ), ( help_custom_config( @@ -296,7 +290,7 @@ async def test_controlling_state_via_topic_and_json_message( ({"value_template": "{{ value_json.val }}"},), ), '{"val":"unlocking"}', - STATE_UNLOCKING, + LockState.UNLOCKING, ), ], ) @@ -315,7 +309,7 @@ async def test_controlling_non_default_state_via_topic_and_json_message( async_fire_mqtt_message(hass, "state-topic", payload) state = hass.states.get("lock.test") - assert state.state is lock_state + assert state.state == lock_state @pytest.mark.parametrize( @@ -342,7 +336,7 @@ async def test_sending_mqtt_commands_and_optimistic( mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) await hass.services.async_call( @@ -352,7 +346,7 @@ async def test_sending_mqtt_commands_and_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "LOCK", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_LOCKED + assert state.state == LockState.LOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) await hass.services.async_call( @@ -362,7 +356,7 @@ async def test_sending_mqtt_commands_and_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "UNLOCK", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) @@ -393,7 +387,7 @@ async def test_sending_mqtt_commands_with_template( mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) await hass.services.async_call( @@ -408,7 +402,7 @@ async def test_sending_mqtt_commands_with_template( ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_LOCKED + assert state.state == LockState.LOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) await hass.services.async_call( @@ -423,7 +417,7 @@ async def test_sending_mqtt_commands_with_template( ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) @@ -453,7 +447,7 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) await hass.services.async_call( @@ -463,7 +457,7 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "LOCK", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_LOCKED + assert state.state == LockState.LOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) await hass.services.async_call( @@ -473,7 +467,7 @@ async def test_sending_mqtt_commands_and_explicit_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "UNLOCK", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) @@ -502,7 +496,7 @@ async def test_sending_mqtt_commands_support_open_and_optimistic( mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == LockEntityFeature.OPEN @@ -513,7 +507,7 @@ async def test_sending_mqtt_commands_support_open_and_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "LOCK", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_LOCKED + assert state.state == LockState.LOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) await hass.services.async_call( @@ -523,7 +517,7 @@ async def test_sending_mqtt_commands_support_open_and_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "UNLOCK", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) await hass.services.async_call( @@ -533,7 +527,7 @@ async def test_sending_mqtt_commands_support_open_and_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "OPEN", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_OPEN + assert state.state == LockState.OPEN assert state.attributes.get(ATTR_ASSUMED_STATE) @@ -564,7 +558,7 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( mqtt_mock = await mqtt_mock_entry() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == LockEntityFeature.OPEN @@ -575,7 +569,7 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "LOCK", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_LOCKED + assert state.state == LockState.LOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) await hass.services.async_call( @@ -585,7 +579,7 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "UNLOCK", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes.get(ATTR_ASSUMED_STATE) await hass.services.async_call( @@ -595,7 +589,7 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( mqtt_mock.async_publish.assert_called_once_with("command-topic", "OPEN", 0, False) mqtt_mock.async_publish.reset_mock() state = hass.states.get("lock.test") - assert state.state is STATE_OPEN + assert state.state == LockState.OPEN assert state.attributes.get(ATTR_ASSUMED_STATE) @@ -644,7 +638,7 @@ async def test_sending_mqtt_commands_pessimistic( await hass.async_block_till_done() state = hass.states.get("lock.test") - assert state.state is STATE_LOCKED + assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: "lock.test"}, blocking=True @@ -658,7 +652,7 @@ async def test_sending_mqtt_commands_pessimistic( await hass.async_block_till_done() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: "lock.test"}, blocking=True @@ -672,7 +666,7 @@ async def test_sending_mqtt_commands_pessimistic( await hass.async_block_till_done() state = hass.states.get("lock.test") - assert state.state is STATE_UNLOCKED + assert state.state == LockState.UNLOCKED # send lock command to lock await hass.services.async_call( @@ -688,21 +682,21 @@ async def test_sending_mqtt_commands_pessimistic( await hass.async_block_till_done() state = hass.states.get("lock.test") - assert state.state is STATE_LOCKING + assert state.state == LockState.LOCKING # receive jammed state from lock async_fire_mqtt_message(hass, "state-topic", "JAMMED") await hass.async_block_till_done() state = hass.states.get("lock.test") - assert state.state is STATE_JAMMED + assert state.state == LockState.JAMMED # receive solved state from lock async_fire_mqtt_message(hass, "state-topic", "LOCKED") await hass.async_block_till_done() state = hass.states.get("lock.test") - assert state.state is STATE_LOCKED + assert state.state == LockState.LOCKED @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 0dfa3210671..b505fc81a35 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -50,6 +50,7 @@ from homeassistant.components.fan import ( DIRECTION_REVERSE, ) from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES +from homeassistant.components.lock import LockState from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -67,14 +68,12 @@ from homeassistant.const import ( STATE_CLOSED, STATE_CLOSING, STATE_HOME, - STATE_LOCKED, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_OPENING, STATE_UNAVAILABLE, - STATE_UNLOCKED, UnitOfEnergy, UnitOfTemperature, ) @@ -1571,7 +1570,7 @@ async def lock_fixture( suggested_object_id="front_door", original_name="Front Door", ) - set_state_with_entry(hass, lock_1, STATE_LOCKED) + set_state_with_entry(hass, lock_1, LockState.LOCKED) data["lock_1"] = lock_1 lock_2 = entity_registry.async_get_or_create( @@ -1581,7 +1580,7 @@ async def lock_fixture( suggested_object_id="kitchen_door", original_name="Kitchen Door", ) - set_state_with_entry(hass, lock_2, STATE_UNLOCKED) + set_state_with_entry(hass, lock_2, LockState.UNLOCKED) data["lock_2"] = lock_2 await hass.async_block_till_done() diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 3bbc78e21ce..d16712e0c70 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -17,6 +17,7 @@ from sqlalchemy.exc import DatabaseError, OperationalError, SQLAlchemyError from sqlalchemy.pool import QueuePool from homeassistant.components import recorder +from homeassistant.components.lock import LockState from homeassistant.components.recorder import ( CONF_AUTO_PURGE, CONF_AUTO_REPACK, @@ -69,8 +70,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, - STATE_LOCKED, - STATE_UNLOCKED, ) from homeassistant.core import Context, CoreState, Event, HomeAssistant, State, callback from homeassistant.helpers import ( @@ -834,8 +833,8 @@ async def test_saving_state_and_removing_entity( ) -> None: """Test saving the state of a removed entity.""" entity_id = "lock.mine" - hass.states.async_set(entity_id, STATE_LOCKED) - hass.states.async_set(entity_id, STATE_UNLOCKED) + hass.states.async_set(entity_id, LockState.LOCKED) + hass.states.async_set(entity_id, LockState.UNLOCKED) hass.states.async_remove(entity_id) await async_wait_recording_done(hass) @@ -848,9 +847,9 @@ async def test_saving_state_and_removing_entity( ) assert len(states) == 3 assert states[0].entity_id == entity_id - assert states[0].state == STATE_LOCKED + assert states[0].state == LockState.LOCKED assert states[1].entity_id == entity_id - assert states[1].state == STATE_UNLOCKED + assert states[1].state == LockState.UNLOCKED assert states[2].entity_id == entity_id assert states[2].state is None diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index 74af80dce84..518c723d581 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -5,15 +5,9 @@ from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ENTITY_ID, - SERVICE_LOCK, - SERVICE_UNLOCK, - STATE_JAMMED, - STATE_UNLOCKED, -) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.core import HomeAssistant from tests.common import async_fire_time_changed @@ -29,7 +23,7 @@ async def test_lock_attributes( """Test lock attributes.""" lock = hass.states.get("lock.vault_door") assert lock is not None - assert lock.state == STATE_UNLOCKED + assert lock.state == LockState.UNLOCKED assert lock.attributes["changed_by"] == "thumbturn" mock_lock.is_locked = False @@ -40,7 +34,7 @@ async def test_lock_attributes( await hass.async_block_till_done(wait_background_tasks=True) lock = hass.states.get("lock.vault_door") assert lock is not None - assert lock.state == STATE_JAMMED + assert lock.state == LockState.JAMMED async def test_lock_services( diff --git a/tests/components/switch_as_x/__init__.py b/tests/components/switch_as_x/__init__.py index de6f1bac790..2addb832462 100644 --- a/tests/components/switch_as_x/__init__.py +++ b/tests/components/switch_as_x/__init__.py @@ -1,14 +1,7 @@ """The tests for Switch as X platforms.""" -from homeassistant.const import ( - STATE_CLOSED, - STATE_LOCKED, - STATE_OFF, - STATE_ON, - STATE_OPEN, - STATE_UNLOCKED, - Platform, -) +from homeassistant.components.lock import LockState +from homeassistant.const import STATE_CLOSED, STATE_OFF, STATE_ON, STATE_OPEN, Platform PLATFORMS_TO_TEST = ( Platform.COVER, @@ -24,7 +17,7 @@ STATE_MAP = { Platform.COVER: {STATE_ON: STATE_OPEN, STATE_OFF: STATE_CLOSED}, Platform.FAN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, Platform.LIGHT: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, - Platform.LOCK: {STATE_ON: STATE_UNLOCKED, STATE_OFF: STATE_LOCKED}, + Platform.LOCK: {STATE_ON: LockState.UNLOCKED, STATE_OFF: LockState.LOCKED}, Platform.SIREN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, Platform.VALVE: {STATE_ON: STATE_OPEN, STATE_OFF: STATE_CLOSED}, }, @@ -32,7 +25,7 @@ STATE_MAP = { Platform.COVER: {STATE_ON: STATE_CLOSED, STATE_OFF: STATE_OPEN}, Platform.FAN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, Platform.LIGHT: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, - Platform.LOCK: {STATE_ON: STATE_LOCKED, STATE_OFF: STATE_UNLOCKED}, + Platform.LOCK: {STATE_ON: LockState.LOCKED, STATE_OFF: LockState.UNLOCKED}, Platform.SIREN: {STATE_ON: STATE_ON, STATE_OFF: STATE_OFF}, Platform.VALVE: {STATE_ON: STATE_CLOSED, STATE_OFF: STATE_OPEN}, }, diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index e250cacb7ac..cd80fab69bc 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -7,6 +7,7 @@ from unittest.mock import patch import pytest from homeassistant.components.homeassistant import exposed_entities +from homeassistant.components.lock import LockState from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler from homeassistant.components.switch_as_x.const import ( CONF_INVERT, @@ -17,11 +18,9 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_ENTITY_ID, STATE_CLOSED, - STATE_LOCKED, STATE_OFF, STATE_ON, STATE_OPEN, - STATE_UNLOCKED, EntityCategory, Platform, ) @@ -74,7 +73,7 @@ async def test_config_entry_unregistered_uuid( (Platform.COVER, STATE_OPEN, STATE_CLOSED), (Platform.FAN, STATE_ON, STATE_OFF), (Platform.LIGHT, STATE_ON, STATE_OFF), - (Platform.LOCK, STATE_UNLOCKED, STATE_LOCKED), + (Platform.LOCK, LockState.UNLOCKED, LockState.LOCKED), (Platform.SIREN, STATE_ON, STATE_OFF), (Platform.VALVE, STATE_OPEN, STATE_CLOSED), ], diff --git a/tests/components/switch_as_x/test_lock.py b/tests/components/switch_as_x/test_lock.py index f7d61cf6895..c2a0806778d 100644 --- a/tests/components/switch_as_x/test_lock.py +++ b/tests/components/switch_as_x/test_lock.py @@ -1,6 +1,6 @@ """Tests for the Switch as X Lock platform.""" -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler from homeassistant.components.switch_as_x.const import ( @@ -15,10 +15,8 @@ from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK, - STATE_LOCKED, STATE_OFF, STATE_ON, - STATE_UNLOCKED, Platform, ) from homeassistant.core import HomeAssistant @@ -70,7 +68,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED await hass.services.async_call( LOCK_DOMAIN, @@ -80,7 +78,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED await hass.services.async_call( LOCK_DOMAIN, @@ -90,7 +88,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -100,7 +98,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -110,7 +108,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -120,7 +118,7 @@ async def test_service_calls(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED async def test_service_calls_inverted(hass: HomeAssistant) -> None: @@ -143,7 +141,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED await hass.services.async_call( LOCK_DOMAIN, @@ -153,7 +151,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED await hass.services.async_call( LOCK_DOMAIN, @@ -163,7 +161,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -173,7 +171,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -183,7 +181,7 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_ON - assert hass.states.get("lock.decorative_lights").state == STATE_LOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.LOCKED await hass.services.async_call( SWITCH_DOMAIN, @@ -193,4 +191,4 @@ async def test_service_calls_inverted(hass: HomeAssistant) -> None: ) assert hass.states.get("switch.decorative_lights").state == STATE_OFF - assert hass.states.get("lock.decorative_lights").state == STATE_UNLOCKED + assert hass.states.get("lock.decorative_lights").state == LockState.UNLOCKED diff --git a/tests/components/tedee/test_lock.py b/tests/components/tedee/test_lock.py index 741bc3156cb..d43cbccd48a 100644 --- a/tests/components/tedee/test_lock.py +++ b/tests/components/tedee/test_lock.py @@ -19,10 +19,7 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_LOCKED, - STATE_LOCKING, - STATE_UNLOCKED, - STATE_UNLOCKING, + LockState, ) from homeassistant.components.webhook import async_generate_url from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN @@ -75,7 +72,7 @@ async def test_lock( mock_tedee.lock.assert_called_once_with(12345) state = hass.states.get("lock.lock_1a2b") assert state - assert state.state == STATE_LOCKING + assert state.state == LockState.LOCKING await hass.services.async_call( LOCK_DOMAIN, @@ -90,7 +87,7 @@ async def test_lock( mock_tedee.unlock.assert_called_once_with(12345) state = hass.states.get("lock.lock_1a2b") assert state - assert state.state == STATE_UNLOCKING + assert state.state == LockState.UNLOCKING await hass.services.async_call( LOCK_DOMAIN, @@ -105,7 +102,7 @@ async def test_lock( mock_tedee.open.assert_called_once_with(12345) state = hass.states.get("lock.lock_1a2b") assert state - assert state.state == STATE_UNLOCKING + assert state.state == LockState.UNLOCKING async def test_lock_without_pullspring( @@ -279,7 +276,7 @@ async def test_new_lock( @pytest.mark.parametrize( ("lib_state", "expected_state"), [ - (TedeeLockState.LOCKED, STATE_LOCKED), + (TedeeLockState.LOCKED, LockState.LOCKED), (TedeeLockState.HALF_OPEN, STATE_UNKNOWN), (TedeeLockState.UNKNOWN, STATE_UNKNOWN), (TedeeLockState.UNCALIBRATED, STATE_UNAVAILABLE), @@ -296,7 +293,7 @@ async def test_webhook_update( state = hass.states.get("lock.lock_1a2b") assert state - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED webhook_data = {"dummystate": lib_state.value} # is updated in the lib, so mock and assert below diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index f4e81cbfd63..c2b4960ca0e 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -4,6 +4,7 @@ import pytest from homeassistant import setup from homeassistant.components import lock +from homeassistant.components.lock import LockState from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -71,13 +72,13 @@ async def test_template_state(hass: HomeAssistant, start_ha) -> None: await hass.async_block_till_done() state = hass.states.get("lock.test_template_lock") - assert state.state == lock.STATE_LOCKED + assert state.state == LockState.LOCKED hass.states.async_set("switch.test_state", STATE_OFF) await hass.async_block_till_done() state = hass.states.get("lock.test_template_lock") - assert state.state == lock.STATE_UNLOCKED + assert state.state == LockState.UNLOCKED @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @@ -95,7 +96,7 @@ async def test_template_state(hass: HomeAssistant, start_ha) -> None: async def test_template_state_boolean_on(hass: HomeAssistant, start_ha) -> None: """Test the setting of the state with boolean on.""" state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_LOCKED + assert state.state == LockState.LOCKED @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @@ -113,7 +114,7 @@ async def test_template_state_boolean_on(hass: HomeAssistant, start_ha) -> None: async def test_template_state_boolean_off(hass: HomeAssistant, start_ha) -> None: """Test the setting of the state with off.""" state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_UNLOCKED + assert state.state == LockState.UNLOCKED @pytest.mark.parametrize(("count", "domain"), [(0, lock.DOMAIN)]) @@ -200,12 +201,12 @@ async def test_template_syntax_error(hass: HomeAssistant, start_ha) -> None: async def test_template_static(hass: HomeAssistant, start_ha) -> None: """Test that we allow static templates.""" state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_UNLOCKED + assert state.state == LockState.UNLOCKED - hass.states.async_set("lock.template_lock", lock.STATE_LOCKED) + hass.states.async_set("lock.template_lock", LockState.LOCKED) await hass.async_block_till_done() state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_LOCKED + assert state.state == LockState.LOCKED @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) @@ -229,7 +230,7 @@ async def test_lock_action( await hass.async_block_till_done() state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_UNLOCKED + assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, @@ -264,7 +265,7 @@ async def test_unlock_action( await hass.async_block_till_done() state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_LOCKED + assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, @@ -300,7 +301,7 @@ async def test_lock_action_with_code( await hass.async_block_till_done() state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_UNLOCKED + assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, @@ -337,7 +338,7 @@ async def test_unlock_action_with_code( await hass.async_block_till_done() state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_LOCKED + assert state.state == LockState.LOCKED await hass.services.async_call( lock.DOMAIN, @@ -446,7 +447,7 @@ async def test_actions_with_none_as_codeformat_ignores_code( await hass.async_block_till_done() state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_UNLOCKED + assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, @@ -484,7 +485,7 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute( await hass.async_block_till_done() state = hass.states.get("lock.template_lock") - assert state.state == lock.STATE_UNLOCKED + assert state.state == LockState.UNLOCKED await hass.services.async_call( lock.DOMAIN, @@ -519,7 +520,7 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute( ], ) @pytest.mark.parametrize( - "test_state", [lock.STATE_UNLOCKING, lock.STATE_LOCKING, lock.STATE_JAMMED] + "test_state", [LockState.UNLOCKING, LockState.LOCKING, LockState.JAMMED] ) async def test_lock_state(hass: HomeAssistant, test_state, start_ha) -> None: """Test value template.""" diff --git a/tests/components/tesla_fleet/test_lock.py b/tests/components/tesla_fleet/test_lock.py index c576496284f..00b77aefcaf 100644 --- a/tests/components/tesla_fleet/test_lock.py +++ b/tests/components/tesla_fleet/test_lock.py @@ -10,14 +10,9 @@ from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK, + LockState, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - STATE_LOCKED, - STATE_UNKNOWN, - STATE_UNLOCKED, - Platform, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -74,7 +69,7 @@ async def test_lock_services( blocking=True, ) state = hass.states.get(entity_id) - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED call.assert_called_once() with patch( @@ -88,7 +83,7 @@ async def test_lock_services( blocking=True, ) state = hass.states.get(entity_id) - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED call.assert_called_once() entity_id = "lock.test_charge_cable_lock" @@ -112,5 +107,5 @@ async def test_lock_services( blocking=True, ) state = hass.states.get(entity_id) - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED call.assert_called_once() diff --git a/tests/components/teslemetry/test_lock.py b/tests/components/teslemetry/test_lock.py index a50e97fe6ad..bd8e72a1df3 100644 --- a/tests/components/teslemetry/test_lock.py +++ b/tests/components/teslemetry/test_lock.py @@ -10,14 +10,9 @@ from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK, + LockState, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - STATE_LOCKED, - STATE_UNKNOWN, - STATE_UNLOCKED, - Platform, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -69,7 +64,7 @@ async def test_lock_services( blocking=True, ) state = hass.states.get(entity_id) - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED call.assert_called_once() with patch( @@ -83,7 +78,7 @@ async def test_lock_services( blocking=True, ) state = hass.states.get(entity_id) - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED call.assert_called_once() entity_id = "lock.test_charge_cable_lock" @@ -107,5 +102,5 @@ async def test_lock_services( blocking=True, ) state = hass.states.get(entity_id) - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED call.assert_called_once() diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py index cfb6168b399..43f8e23fb50 100644 --- a/tests/components/tessie/test_lock.py +++ b/tests/components/tessie/test_lock.py @@ -10,8 +10,9 @@ from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK, + LockState, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er, issue_registry as ir @@ -49,7 +50,7 @@ async def test_locks( blocking=True, ) mock_run.assert_called_once() - assert hass.states.get(entity_id).state == STATE_LOCKED + assert hass.states.get(entity_id).state == LockState.LOCKED with patch("homeassistant.components.tessie.lock.unlock") as mock_run: await hass.services.async_call( @@ -59,7 +60,7 @@ async def test_locks( blocking=True, ) mock_run.assert_called_once() - assert hass.states.get(entity_id).state == STATE_UNLOCKED + assert hass.states.get(entity_id).state == LockState.UNLOCKED # Test charge cable lock set value functions entity_id = "lock.test_charge_cable_lock" @@ -80,7 +81,7 @@ async def test_locks( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert hass.states.get(entity_id).state == STATE_UNLOCKED + assert hass.states.get(entity_id).state == LockState.UNLOCKED mock_run.assert_called_once() @@ -119,7 +120,7 @@ async def test_speed_limit_lock( {ATTR_ENTITY_ID: [entity.entity_id], ATTR_CODE: "1234"}, blocking=True, ) - assert hass.states.get(entity.entity_id).state == STATE_LOCKED + assert hass.states.get(entity.entity_id).state == LockState.LOCKED mock_enable_speed_limit.assert_called_once() # Assert issue has been raised in the issue register assert issue_registry.async_get_issue(DOMAIN, "deprecated_speed_limit_locked") @@ -133,7 +134,7 @@ async def test_speed_limit_lock( {ATTR_ENTITY_ID: [entity.entity_id], ATTR_CODE: "1234"}, blocking=True, ) - assert hass.states.get(entity.entity_id).state == STATE_UNLOCKED + assert hass.states.get(entity.entity_id).state == LockState.UNLOCKED mock_disable_speed_limit.assert_called_once() assert issue_registry.async_get_issue(DOMAIN, "deprecated_speed_limit_unlocked") diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index 62a1cb9ff46..8b37b1c5928 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -6,16 +6,12 @@ from unittest.mock import AsyncMock, Mock from uiprotect.data import Doorlock, LockStatusType +from homeassistant.components.lock import LockState from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_ENTITY_ID, - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, STATE_UNAVAILABLE, - STATE_UNLOCKED, - STATE_UNLOCKING, Platform, ) from homeassistant.core import HomeAssistant @@ -64,7 +60,7 @@ async def test_lock_setup( state = hass.states.get(entity_id) assert state - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION @@ -92,7 +88,7 @@ async def test_lock_locked( state = hass.states.get("lock.test_lock_lock") assert state - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED async def test_lock_unlocking( @@ -119,7 +115,7 @@ async def test_lock_unlocking( state = hass.states.get("lock.test_lock_lock") assert state - assert state.state == STATE_UNLOCKING + assert state.state == LockState.UNLOCKING async def test_lock_locking( @@ -146,7 +142,7 @@ async def test_lock_locking( state = hass.states.get("lock.test_lock_lock") assert state - assert state.state == STATE_LOCKING + assert state.state == LockState.LOCKING async def test_lock_jammed( @@ -173,7 +169,7 @@ async def test_lock_jammed( state = hass.states.get("lock.test_lock_lock") assert state - assert state.state == STATE_JAMMED + assert state.state == LockState.JAMMED async def test_lock_unavailable( diff --git a/tests/components/vera/test_lock.py b/tests/components/vera/test_lock.py index 4139a494e1f..d24a0e1265f 100644 --- a/tests/components/vera/test_lock.py +++ b/tests/components/vera/test_lock.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock import pyvera as pv -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.components.lock import LockState from homeassistant.core import HomeAssistant from .common import ComponentFactory, new_simple_controller_config @@ -29,7 +29,7 @@ async def test_lock( ) update_callback = component_data.controller_data[0].update_callback - assert hass.states.get(entity_id).state == STATE_UNLOCKED + assert hass.states.get(entity_id).state == LockState.UNLOCKED await hass.services.async_call( "lock", @@ -41,7 +41,7 @@ async def test_lock( vera_device.is_locked.return_value = True update_callback(vera_device) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_LOCKED + assert hass.states.get(entity_id).state == LockState.LOCKED await hass.services.async_call( "lock", @@ -53,4 +53,4 @@ async def test_lock( vera_device.is_locked.return_value = False update_callback(vera_device) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNLOCKED + assert hass.states.get(entity_id).state == LockState.UNLOCKED diff --git a/tests/components/yale/test_init.py b/tests/components/yale/test_init.py index 4f0a853710c..c028924199e 100644 --- a/tests/components/yale/test_init.py +++ b/tests/components/yale/test_init.py @@ -6,7 +6,7 @@ from aiohttp import ClientResponseError import pytest from yalexs.exceptions import InvalidAuth, YaleApiError -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.components.yale.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -14,7 +14,6 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_LOCKED, STATE_ON, ) from homeassistant.core import HomeAssistant @@ -151,7 +150,7 @@ async def test_inoperative_locks_are_filtered_out(hass: HomeAssistant) -> None: lock_a6697750d607098bae8d6baa11ef8063_name = hass.states.get( "lock.a6697750d607098bae8d6baa11ef8063_name" ) - assert lock_a6697750d607098bae8d6baa11ef8063_name.state == STATE_LOCKED + assert lock_a6697750d607098bae8d6baa11ef8063_name.state == LockState.LOCKED async def test_lock_has_doorsense(hass: HomeAssistant) -> None: diff --git a/tests/components/yale/test_lock.py b/tests/components/yale/test_lock.py index 2bbb7408953..f0fe018759c 100644 --- a/tests/components/yale/test_lock.py +++ b/tests/components/yale/test_lock.py @@ -8,21 +8,14 @@ import pytest from syrupy import SnapshotAssertion from yalexs.manager.activity import INITIAL_LOCK_RESYNC_TIME -from homeassistant.components.lock import ( - DOMAIN as LOCK_DOMAIN, - STATE_JAMMED, - STATE_LOCKING, - STATE_UNLOCKING, -) +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, - STATE_LOCKED, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -62,7 +55,7 @@ async def test_lock_changed_by(hass: HomeAssistant) -> None: await _create_yale_with_devices(hass, [lock_one], activities=activities) lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED assert lock_state.attributes["changed_by"] == "Your favorite elven princess" @@ -73,7 +66,7 @@ async def test_state_locking(hass: HomeAssistant) -> None: activities = await _mock_activities_from_fixture(hass, "get_activity.locking.json") await _create_yale_with_devices(hass, [lock_one], activities=activities) - assert hass.states.get("lock.online_with_doorsense_name").state == STATE_LOCKING + assert hass.states.get("lock.online_with_doorsense_name").state == LockState.LOCKING async def test_state_unlocking(hass: HomeAssistant) -> None: @@ -87,7 +80,7 @@ async def test_state_unlocking(hass: HomeAssistant) -> None: lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKING + assert lock_online_with_doorsense_name.state == LockState.UNLOCKING async def test_state_jammed(hass: HomeAssistant) -> None: @@ -97,7 +90,7 @@ async def test_state_jammed(hass: HomeAssistant) -> None: activities = await _mock_activities_from_fixture(hass, "get_activity.jammed.json") await _create_yale_with_devices(hass, [lock_one], activities=activities) - assert hass.states.get("lock.online_with_doorsense_name").state == STATE_JAMMED + assert hass.states.get("lock.online_with_doorsense_name").state == LockState.JAMMED async def test_one_lock_operation( @@ -109,7 +102,7 @@ async def test_one_lock_operation( lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" @@ -118,7 +111,7 @@ async def test_one_lock_operation( await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_UNLOCKED + assert lock_state.state == LockState.UNLOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" @@ -126,7 +119,7 @@ async def test_one_lock_operation( await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED # No activity means it will be unavailable until the activity feed has data assert entity_registry.async_get("sensor.online_with_doorsense_name_operator") @@ -139,12 +132,12 @@ async def test_open_lock_operation(hass: HomeAssistant) -> None: lock_with_unlatch = await _mock_lock_with_unlatch(hass) await _create_yale_with_devices(hass, [lock_with_unlatch]) - assert hass.states.get("lock.online_with_unlatch_name").state == STATE_LOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == LockState.LOCKED data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) - assert hass.states.get("lock.online_with_unlatch_name").state == STATE_UNLOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == LockState.UNLOCKED async def test_open_lock_operation_socketio_connected( @@ -159,7 +152,7 @@ async def test_open_lock_operation_socketio_connected( _, socketio = await _create_yale_with_devices(hass, [lock_with_unlatch]) socketio.connected = True - assert hass.states.get("lock.online_with_unlatch_name").state == STATE_LOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == LockState.LOCKED data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) @@ -176,7 +169,7 @@ async def test_open_lock_operation_socketio_connected( await hass.async_block_till_done() await hass.async_block_till_done() - assert hass.states.get("lock.online_with_unlatch_name").state == STATE_UNLOCKED + assert hass.states.get("lock.online_with_unlatch_name").state == LockState.UNLOCKED await hass.async_block_till_done() @@ -194,7 +187,7 @@ async def test_one_lock_operation_socketio_connected( socketio.connected = True lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" @@ -214,7 +207,7 @@ async def test_one_lock_operation_socketio_connected( await hass.async_block_till_done() lock_state = states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_UNLOCKED + assert lock_state.state == LockState.UNLOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" @@ -231,7 +224,7 @@ async def test_one_lock_operation_socketio_connected( await hass.async_block_till_done() await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKED + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKED # No activity means it will be unavailable until the activity feed has data assert entity_registry.async_get("sensor.online_with_doorsense_name_operator") @@ -251,7 +244,7 @@ async def test_one_lock_operation_socketio_connected( await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKED + assert states.get("lock.online_with_doorsense_name").state == LockState.UNLOCKED async def test_lock_jammed(hass: HomeAssistant) -> None: @@ -271,14 +264,14 @@ async def test_lock_jammed(hass: HomeAssistant) -> None: states = hass.states lock_state = states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) - assert states.get("lock.online_with_doorsense_name").state == STATE_JAMMED + assert states.get("lock.online_with_doorsense_name").state == LockState.JAMMED async def test_lock_throws_exception_on_unknown_status_code( @@ -299,7 +292,7 @@ async def test_lock_throws_exception_on_unknown_status_code( ) lock_state = hass.states.get("lock.online_with_doorsense_name") - assert lock_state.state == STATE_LOCKED + assert lock_state.state == LockState.LOCKED assert lock_state.attributes["battery_level"] == 92 assert lock_state.attributes["friendly_name"] == "online_with_doorsense Name" @@ -342,7 +335,7 @@ async def test_lock_bridge_online(hass: HomeAssistant) -> None: await _create_yale_with_devices(hass, [lock_one], activities=activities) states = hass.states - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKED + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKED async def test_lock_update_via_socketio(hass: HomeAssistant) -> None: @@ -357,7 +350,7 @@ async def test_lock_update_via_socketio(hass: HomeAssistant) -> None: socketio.connected = True states = hass.states - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKED + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKED listener = list(socketio._listeners)[0] listener( @@ -371,7 +364,7 @@ async def test_lock_update_via_socketio(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.UNLOCKING listener( lock_one.device_id, @@ -384,21 +377,21 @@ async def test_lock_update_via_socketio(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKING socketio.connected = True async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKING # Ensure socketio status is always preserved async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2)) await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_LOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.LOCKING listener( lock_one.device_id, @@ -411,11 +404,11 @@ async def test_lock_update_via_socketio(hass: HomeAssistant) -> None: await hass.async_block_till_done() await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.UNLOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4)) await hass.async_block_till_done() - assert states.get("lock.online_with_doorsense_name").state == STATE_UNLOCKING + assert states.get("lock.online_with_doorsense_name").state == LockState.UNLOCKING await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py index 4e1d092af9b..dd4afb0ae14 100644 --- a/tests/components/zha/test_lock.py +++ b/tests/components/zha/test_lock.py @@ -8,14 +8,14 @@ from zigpy.zcl import Cluster from zigpy.zcl.clusters import closures, general import zigpy.zcl.foundation as zcl_f -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.components.zha.helpers import ( ZHADeviceProxy, ZHAGatewayProxy, get_zha_gateway, get_zha_gateway_proxy, ) -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .common import find_entity_id, send_attributes_report @@ -65,7 +65,7 @@ async def test_lock(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: cluster = zigpy_device.endpoints[1].door_lock assert entity_id is not None - assert hass.states.get(entity_id).state == STATE_UNLOCKED + assert hass.states.get(entity_id).state == LockState.UNLOCKED # set state to locked await send_attributes_report( @@ -73,7 +73,7 @@ async def test_lock(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: cluster, {closures.DoorLock.AttributeDefs.lock_state.id: closures.LockState.Locked}, ) - assert hass.states.get(entity_id).state == STATE_LOCKED + assert hass.states.get(entity_id).state == LockState.LOCKED # set state to unlocked await send_attributes_report( @@ -81,7 +81,7 @@ async def test_lock(hass: HomeAssistant, setup_zha, zigpy_device_mock) -> None: cluster, {closures.DoorLock.AttributeDefs.lock_state.id: closures.LockState.Unlocked}, ) - assert hass.states.get(entity_id).state == STATE_UNLOCKED + assert hass.states.get(entity_id).state == LockState.UNLOCKED # lock from HA await async_lock(hass, cluster, entity_id) diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 274444d813e..47e680570f0 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -15,6 +15,7 @@ from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK, + LockState, ) from homeassistant.components.zwave_js.const import ( ATTR_LOCK_TIMEOUT, @@ -27,13 +28,7 @@ from homeassistant.components.zwave_js.lock import ( SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - STATE_LOCKED, - STATE_UNAVAILABLE, - STATE_UNKNOWN, - STATE_UNLOCKED, -) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -52,7 +47,7 @@ async def test_door_lock( state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) assert state - assert state.state == STATE_UNLOCKED + assert state.state == LockState.UNLOCKED # Test locking await hass.services.async_call( @@ -97,7 +92,7 @@ async def test_door_lock( state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) assert state - assert state.state == STATE_LOCKED + assert state.state == LockState.LOCKED client.async_send_command.reset_mock() diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py index 150f31f5fe9..ea7c1f6827f 100644 --- a/tests/helpers/test_state.py +++ b/tests/helpers/test_state.py @@ -5,18 +5,17 @@ from unittest.mock import patch import pytest +from homeassistant.components.lock import LockState from homeassistant.components.sun import STATE_ABOVE_HORIZON, STATE_BELOW_HORIZON from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_CLOSED, STATE_HOME, - STATE_LOCKED, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, - STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import state @@ -143,11 +142,17 @@ async def test_as_number_states(hass: HomeAssistant) -> None: zero_states = ( STATE_OFF, STATE_CLOSED, - STATE_UNLOCKED, + LockState.UNLOCKED, STATE_BELOW_HORIZON, STATE_NOT_HOME, ) - one_states = (STATE_ON, STATE_OPEN, STATE_LOCKED, STATE_ABOVE_HORIZON, STATE_HOME) + one_states = ( + STATE_ON, + STATE_OPEN, + LockState.LOCKED, + STATE_ABOVE_HORIZON, + STATE_HOME, + ) for _state in zero_states: assert state.state_as_number(State("domain.test", _state, {})) == 0 for _state in one_states: diff --git a/tests/test_const.py b/tests/test_const.py index 64ccb875cf5..a370d0f28cd 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -5,7 +5,7 @@ from enum import Enum import pytest from homeassistant import const -from homeassistant.components import sensor +from homeassistant.components import lock, sensor from .common import ( help_test_all, @@ -182,3 +182,33 @@ def test_deprecated_constant_name_changes( replacement, "2025.1", ) + + +def _create_tuples_lock_states( + enum: type[Enum], constant_prefix: str, remove_in_version: str +) -> list[tuple[Enum, str]]: + return [ + (enum_field, constant_prefix, remove_in_version) + for enum_field in enum + if enum_field + not in [ + lock.LockState.OPEN, + lock.LockState.OPENING, + ] + ] + + +@pytest.mark.parametrize( + ("enum", "constant_prefix", "remove_in_version"), + _create_tuples_lock_states(lock.LockState, "STATE_", "2025.10"), +) +def test_deprecated_constants_lock( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + remove_in_version: str, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, const, enum, constant_prefix, remove_in_version + ) From 283033f90250ba6fc12e52196528ce71dc4a01e7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 24 Sep 2024 12:33:55 +0200 Subject: [PATCH 1065/1309] Start deprecation for media_player constants (#126351) Co-authored-by: Martin Hjelmare --- .../components/media_player/__init__.py | 68 ++++--- .../components/media_player/const.py | 191 ++++++++++++------ tests/components/media_player/test_init.py | 69 +++++++ 3 files changed, 241 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index b160305e6d6..bd1872422bb 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -54,6 +54,12 @@ from homeassistant.const import ( # noqa: F401 from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.network import get_url @@ -63,6 +69,26 @@ from homeassistant.util.hass_dict import HassKey from .browse_media import BrowseMedia, async_process_play_media_url # noqa: F401 from .const import ( # noqa: F401 + _DEPRECATED_MEDIA_CLASS_DIRECTORY, + _DEPRECATED_SUPPORT_BROWSE_MEDIA, + _DEPRECATED_SUPPORT_CLEAR_PLAYLIST, + _DEPRECATED_SUPPORT_GROUPING, + _DEPRECATED_SUPPORT_NEXT_TRACK, + _DEPRECATED_SUPPORT_PAUSE, + _DEPRECATED_SUPPORT_PLAY, + _DEPRECATED_SUPPORT_PLAY_MEDIA, + _DEPRECATED_SUPPORT_PREVIOUS_TRACK, + _DEPRECATED_SUPPORT_REPEAT_SET, + _DEPRECATED_SUPPORT_SEEK, + _DEPRECATED_SUPPORT_SELECT_SOUND_MODE, + _DEPRECATED_SUPPORT_SELECT_SOURCE, + _DEPRECATED_SUPPORT_SHUFFLE_SET, + _DEPRECATED_SUPPORT_STOP, + _DEPRECATED_SUPPORT_TURN_OFF, + _DEPRECATED_SUPPORT_TURN_ON, + _DEPRECATED_SUPPORT_VOLUME_MUTE, + _DEPRECATED_SUPPORT_VOLUME_SET, + _DEPRECATED_SUPPORT_VOLUME_STEP, ATTR_APP_ID, ATTR_APP_NAME, ATTR_ENTITY_PICTURE_LOCAL, @@ -96,7 +122,6 @@ from .const import ( # noqa: F401 ATTR_SOUND_MODE_LIST, CONTENT_AUTH_EXPIRY_TIME, DOMAIN, - MEDIA_CLASS_DIRECTORY, REPEAT_MODES, SERVICE_CLEAR_PLAYLIST, SERVICE_JOIN, @@ -104,25 +129,6 @@ from .const import ( # noqa: F401 SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SERVICE_UNJOIN, - SUPPORT_BROWSE_MEDIA, - SUPPORT_CLEAR_PLAYLIST, - SUPPORT_GROUPING, - SUPPORT_NEXT_TRACK, - SUPPORT_PAUSE, - SUPPORT_PLAY, - SUPPORT_PLAY_MEDIA, - SUPPORT_PREVIOUS_TRACK, - SUPPORT_REPEAT_SET, - SUPPORT_SEEK, - SUPPORT_SELECT_SOUND_MODE, - SUPPORT_SELECT_SOURCE, - SUPPORT_SHUFFLE_SET, - SUPPORT_STOP, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, - SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_STEP, MediaClass, MediaPlayerEntityFeature, MediaPlayerState, @@ -172,10 +178,16 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(MediaPlayerDeviceClass)) # DEVICE_CLASS* below are deprecated as of 2021.12 # use the MediaPlayerDeviceClass enum instead. +_DEPRECATED_DEVICE_CLASS_TV = DeprecatedConstantEnum( + MediaPlayerDeviceClass.TV, "2025.10" +) +_DEPRECATED_DEVICE_CLASS_SPEAKER = DeprecatedConstantEnum( + MediaPlayerDeviceClass.SPEAKER, "2025.10" +) +_DEPRECATED_DEVICE_CLASS_RECEIVER = DeprecatedConstantEnum( + MediaPlayerDeviceClass.RECEIVER, "2025.10" +) DEVICE_CLASSES = [cls.value for cls in MediaPlayerDeviceClass] -DEVICE_CLASS_TV = MediaPlayerDeviceClass.TV.value -DEVICE_CLASS_SPEAKER = MediaPlayerDeviceClass.SPEAKER.value -DEVICE_CLASS_RECEIVER = MediaPlayerDeviceClass.RECEIVER.value MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = { @@ -1358,3 +1370,13 @@ async def async_fetch_image( logger.warning("Error retrieving proxied image from %s", url) return content, content_type + + +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 9b69ee62846..ca2f3307846 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -1,6 +1,14 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial + +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) # How long our auth signature on the content should be valid for CONTENT_AUTH_EXPIRY_TIME = 3600 * 24 @@ -79,26 +87,34 @@ class MediaClass(StrEnum): # These MEDIA_CLASS_* constants are deprecated as of Home Assistant 2022.10. # Please use the MediaClass enum instead. -MEDIA_CLASS_ALBUM = "album" -MEDIA_CLASS_APP = "app" -MEDIA_CLASS_ARTIST = "artist" -MEDIA_CLASS_CHANNEL = "channel" -MEDIA_CLASS_COMPOSER = "composer" -MEDIA_CLASS_CONTRIBUTING_ARTIST = "contributing_artist" -MEDIA_CLASS_DIRECTORY = "directory" -MEDIA_CLASS_EPISODE = "episode" -MEDIA_CLASS_GAME = "game" -MEDIA_CLASS_GENRE = "genre" -MEDIA_CLASS_IMAGE = "image" -MEDIA_CLASS_MOVIE = "movie" -MEDIA_CLASS_MUSIC = "music" -MEDIA_CLASS_PLAYLIST = "playlist" -MEDIA_CLASS_PODCAST = "podcast" -MEDIA_CLASS_SEASON = "season" -MEDIA_CLASS_TRACK = "track" -MEDIA_CLASS_TV_SHOW = "tv_show" -MEDIA_CLASS_URL = "url" -MEDIA_CLASS_VIDEO = "video" +_DEPRECATED_MEDIA_CLASS_ALBUM = DeprecatedConstantEnum(MediaClass.ALBUM, "2025.10") +_DEPRECATED_MEDIA_CLASS_APP = DeprecatedConstantEnum(MediaClass.APP, "2025.10") +_DEPRECATED_MEDIA_CLASS_ARTIST = DeprecatedConstantEnum(MediaClass.ARTIST, "2025.10") +_DEPRECATED_MEDIA_CLASS_CHANNEL = DeprecatedConstantEnum(MediaClass.CHANNEL, "2025.10") +_DEPRECATED_MEDIA_CLASS_COMPOSER = DeprecatedConstantEnum( + MediaClass.COMPOSER, "2025.10" +) +_DEPRECATED_MEDIA_CLASS_CONTRIBUTING_ARTIST = DeprecatedConstantEnum( + MediaClass.CONTRIBUTING_ARTIST, "2025.10" +) +_DEPRECATED_MEDIA_CLASS_DIRECTORY = DeprecatedConstantEnum( + MediaClass.DIRECTORY, "2025.10" +) +_DEPRECATED_MEDIA_CLASS_EPISODE = DeprecatedConstantEnum(MediaClass.EPISODE, "2025.10") +_DEPRECATED_MEDIA_CLASS_GAME = DeprecatedConstantEnum(MediaClass.GAME, "2025.10") +_DEPRECATED_MEDIA_CLASS_GENRE = DeprecatedConstantEnum(MediaClass.GENRE, "2025.10") +_DEPRECATED_MEDIA_CLASS_IMAGE = DeprecatedConstantEnum(MediaClass.IMAGE, "2025.10") +_DEPRECATED_MEDIA_CLASS_MOVIE = DeprecatedConstantEnum(MediaClass.MOVIE, "2025.10") +_DEPRECATED_MEDIA_CLASS_MUSIC = DeprecatedConstantEnum(MediaClass.MUSIC, "2025.10") +_DEPRECATED_MEDIA_CLASS_PLAYLIST = DeprecatedConstantEnum( + MediaClass.PLAYLIST, "2025.10" +) +_DEPRECATED_MEDIA_CLASS_PODCAST = DeprecatedConstantEnum(MediaClass.PODCAST, "2025.10") +_DEPRECATED_MEDIA_CLASS_SEASON = DeprecatedConstantEnum(MediaClass.SEASON, "2025.10") +_DEPRECATED_MEDIA_CLASS_TRACK = DeprecatedConstantEnum(MediaClass.TRACK, "2025.10") +_DEPRECATED_MEDIA_CLASS_TV_SHOW = DeprecatedConstantEnum(MediaClass.TV_SHOW, "2025.10") +_DEPRECATED_MEDIA_CLASS_URL = DeprecatedConstantEnum(MediaClass.URL, "2025.10") +_DEPRECATED_MEDIA_CLASS_VIDEO = DeprecatedConstantEnum(MediaClass.VIDEO, "2025.10") class MediaType(StrEnum): @@ -129,27 +145,30 @@ class MediaType(StrEnum): # These MEDIA_TYPE_* constants are deprecated as of Home Assistant 2022.10. # Please use the MediaType enum instead. -MEDIA_TYPE_ALBUM = "album" -MEDIA_TYPE_APP = "app" -MEDIA_TYPE_APPS = "apps" -MEDIA_TYPE_ARTIST = "artist" -MEDIA_TYPE_CHANNEL = "channel" -MEDIA_TYPE_CHANNELS = "channels" -MEDIA_TYPE_COMPOSER = "composer" -MEDIA_TYPE_CONTRIBUTING_ARTIST = "contributing_artist" -MEDIA_TYPE_EPISODE = "episode" -MEDIA_TYPE_GAME = "game" -MEDIA_TYPE_GENRE = "genre" -MEDIA_TYPE_IMAGE = "image" -MEDIA_TYPE_MOVIE = "movie" -MEDIA_TYPE_MUSIC = "music" -MEDIA_TYPE_PLAYLIST = "playlist" -MEDIA_TYPE_PODCAST = "podcast" -MEDIA_TYPE_SEASON = "season" -MEDIA_TYPE_TRACK = "track" -MEDIA_TYPE_TVSHOW = "tvshow" -MEDIA_TYPE_URL = "url" -MEDIA_TYPE_VIDEO = "video" +_DEPRECATED_MEDIA_TYPE_ALBUM = DeprecatedConstantEnum(MediaType.ALBUM, "2025.10") +_DEPRECATED_MEDIA_TYPE_APP = DeprecatedConstantEnum(MediaType.APP, "2025.10") +_DEPRECATED_MEDIA_TYPE_APPS = DeprecatedConstantEnum(MediaType.APPS, "2025.10") +_DEPRECATED_MEDIA_TYPE_ARTIST = DeprecatedConstantEnum(MediaType.ARTIST, "2025.10") +_DEPRECATED_MEDIA_TYPE_CHANNEL = DeprecatedConstantEnum(MediaType.CHANNEL, "2025.10") +_DEPRECATED_MEDIA_TYPE_CHANNELS = DeprecatedConstantEnum(MediaType.CHANNELS, "2025.10") +_DEPRECATED_MEDIA_TYPE_COMPOSER = DeprecatedConstantEnum(MediaType.COMPOSER, "2025.10") +_DEPRECATED_MEDIA_TYPE_CONTRIBUTING_ARTIST = DeprecatedConstantEnum( + MediaType.CONTRIBUTING_ARTIST, "2025.10" +) +_DEPRECATED_MEDIA_TYPE_EPISODE = DeprecatedConstantEnum(MediaType.EPISODE, "2025.10") +_DEPRECATED_MEDIA_TYPE_GAME = DeprecatedConstantEnum(MediaType.GAME, "2025.10") +_DEPRECATED_MEDIA_TYPE_GENRE = DeprecatedConstantEnum(MediaType.GENRE, "2025.10") +_DEPRECATED_MEDIA_TYPE_IMAGE = DeprecatedConstantEnum(MediaType.IMAGE, "2025.10") +_DEPRECATED_MEDIA_TYPE_MOVIE = DeprecatedConstantEnum(MediaType.MOVIE, "2025.10") +_DEPRECATED_MEDIA_TYPE_MUSIC = DeprecatedConstantEnum(MediaType.MUSIC, "2025.10") +_DEPRECATED_MEDIA_TYPE_PLAYLIST = DeprecatedConstantEnum(MediaType.PLAYLIST, "2025.10") +_DEPRECATED_MEDIA_TYPE_PODCAST = DeprecatedConstantEnum(MediaType.PODCAST, "2025.10") +_DEPRECATED_MEDIA_TYPE_SEASON = DeprecatedConstantEnum(MediaType.SEASON, "2025.10") +_DEPRECATED_MEDIA_TYPE_TRACK = DeprecatedConstantEnum(MediaType.TRACK, "2025.10") +_DEPRECATED_MEDIA_TYPE_TVSHOW = DeprecatedConstantEnum(MediaType.TVSHOW, "2025.10") +_DEPRECATED_MEDIA_TYPE_URL = DeprecatedConstantEnum(MediaType.URL, "2025.10") +_DEPRECATED_MEDIA_TYPE_VIDEO = DeprecatedConstantEnum(MediaType.VIDEO, "2025.10") + SERVICE_CLEAR_PLAYLIST = "clear_playlist" SERVICE_JOIN = "join" @@ -169,10 +188,10 @@ class RepeatMode(StrEnum): # These REPEAT_MODE_* constants are deprecated as of Home Assistant 2022.10. # Please use the RepeatMode enum instead. -REPEAT_MODE_ALL = "all" -REPEAT_MODE_OFF = "off" -REPEAT_MODE_ONE = "one" -REPEAT_MODES = [REPEAT_MODE_OFF, REPEAT_MODE_ALL, REPEAT_MODE_ONE] +_DEPRECATED_REPEAT_MODE_ALL = DeprecatedConstantEnum(RepeatMode.ALL, "2025.10") +_DEPRECATED_REPEAT_MODE_OFF = DeprecatedConstantEnum(RepeatMode.OFF, "2025.10") +_DEPRECATED_REPEAT_MODE_ONE = DeprecatedConstantEnum(RepeatMode.ONE, "2025.10") +REPEAT_MODES = [cls.value for cls in RepeatMode] class MediaPlayerEntityFeature(IntFlag): @@ -204,23 +223,67 @@ class MediaPlayerEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the MediaPlayerEntityFeature enum instead. -SUPPORT_PAUSE = 1 -SUPPORT_SEEK = 2 -SUPPORT_VOLUME_SET = 4 -SUPPORT_VOLUME_MUTE = 8 -SUPPORT_PREVIOUS_TRACK = 16 -SUPPORT_NEXT_TRACK = 32 +_DEPRECATED_SUPPORT_PAUSE = DeprecatedConstantEnum( + MediaPlayerEntityFeature.PAUSE, "2025.10" +) +_DEPRECATED_SUPPORT_SEEK = DeprecatedConstantEnum( + MediaPlayerEntityFeature.SEEK, "2025.10" +) +_DEPRECATED_SUPPORT_VOLUME_SET = DeprecatedConstantEnum( + MediaPlayerEntityFeature.VOLUME_SET, "2025.10" +) +_DEPRECATED_SUPPORT_VOLUME_MUTE = DeprecatedConstantEnum( + MediaPlayerEntityFeature.VOLUME_MUTE, "2025.10" +) +_DEPRECATED_SUPPORT_PREVIOUS_TRACK = DeprecatedConstantEnum( + MediaPlayerEntityFeature.PREVIOUS_TRACK, "2025.10" +) +_DEPRECATED_SUPPORT_NEXT_TRACK = DeprecatedConstantEnum( + MediaPlayerEntityFeature.NEXT_TRACK, "2025.10" +) +_DEPRECATED_SUPPORT_TURN_ON = DeprecatedConstantEnum( + MediaPlayerEntityFeature.TURN_ON, "2025.10" +) +_DEPRECATED_SUPPORT_TURN_OFF = DeprecatedConstantEnum( + MediaPlayerEntityFeature.TURN_OFF, "2025.10" +) +_DEPRECATED_SUPPORT_PLAY_MEDIA = DeprecatedConstantEnum( + MediaPlayerEntityFeature.PLAY_MEDIA, "2025.10" +) +_DEPRECATED_SUPPORT_VOLUME_STEP = DeprecatedConstantEnum( + MediaPlayerEntityFeature.VOLUME_STEP, "2025.10" +) +_DEPRECATED_SUPPORT_SELECT_SOURCE = DeprecatedConstantEnum( + MediaPlayerEntityFeature.SELECT_SOURCE, "2025.10" +) +_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum( + MediaPlayerEntityFeature.STOP, "2025.10" +) +_DEPRECATED_SUPPORT_CLEAR_PLAYLIST = DeprecatedConstantEnum( + MediaPlayerEntityFeature.CLEAR_PLAYLIST, "2025.10" +) +_DEPRECATED_SUPPORT_PLAY = DeprecatedConstantEnum( + MediaPlayerEntityFeature.PLAY, "2025.10" +) +_DEPRECATED_SUPPORT_SHUFFLE_SET = DeprecatedConstantEnum( + MediaPlayerEntityFeature.SHUFFLE_SET, "2025.10" +) +_DEPRECATED_SUPPORT_SELECT_SOUND_MODE = DeprecatedConstantEnum( + MediaPlayerEntityFeature.SELECT_SOUND_MODE, "2025.10" +) +_DEPRECATED_SUPPORT_BROWSE_MEDIA = DeprecatedConstantEnum( + MediaPlayerEntityFeature.BROWSE_MEDIA, "2025.10" +) +_DEPRECATED_SUPPORT_REPEAT_SET = DeprecatedConstantEnum( + MediaPlayerEntityFeature.REPEAT_SET, "2025.10" +) +_DEPRECATED_SUPPORT_GROUPING = DeprecatedConstantEnum( + MediaPlayerEntityFeature.GROUPING, "2025.10" +) -SUPPORT_TURN_ON = 128 -SUPPORT_TURN_OFF = 256 -SUPPORT_PLAY_MEDIA = 512 -SUPPORT_VOLUME_STEP = 1024 -SUPPORT_SELECT_SOURCE = 2048 -SUPPORT_STOP = 4096 -SUPPORT_CLEAR_PLAYLIST = 8192 -SUPPORT_PLAY = 16384 -SUPPORT_SHUFFLE_SET = 32768 -SUPPORT_SELECT_SOUND_MODE = 65536 -SUPPORT_BROWSE_MEDIA = 131072 -SUPPORT_REPEAT_SET = 262144 -SUPPORT_GROUPING = 524288 +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 8909995a3ff..47f0530f0ff 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -1,11 +1,14 @@ """Test the base functions of the media player.""" +from enum import Enum from http import HTTPStatus +from types import ModuleType from unittest.mock import patch import pytest import voluptuous as vol +from homeassistant.components import media_player from homeassistant.components.media_player import ( BrowseMedia, MediaClass, @@ -18,6 +21,7 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import help_test_all, import_and_test_deprecated_constant_enum from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -28,6 +32,71 @@ async def setup_homeassistant(hass: HomeAssistant): await async_setup_component(hass, "homeassistant", {}) +def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, str]]: + return [ + (enum_field, constant_prefix) + for enum_field in enum + if enum_field + not in [ + MediaPlayerEntityFeature.MEDIA_ANNOUNCE, + MediaPlayerEntityFeature.MEDIA_ENQUEUE, + ] + ] + + +@pytest.mark.parametrize( + "module", + [media_player, media_player.const], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(media_player.MediaPlayerEntityFeature, "SUPPORT_") + + _create_tuples(media_player.MediaPlayerDeviceClass, "DEVICE_CLASS_"), +) +@pytest.mark.parametrize( + "module", + [media_player], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2025.10" + ) + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(media_player.MediaClass, "MEDIA_CLASS_") + + _create_tuples(media_player.MediaPlayerEntityFeature, "SUPPORT_") + + _create_tuples(media_player.MediaType, "MEDIA_TYPE_") + + _create_tuples(media_player.RepeatMode, "REPEAT_MODE_"), +) +@pytest.mark.parametrize( + "module", + [media_player.const], +) +def test_deprecated_constants_const( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2025.10" + ) + + async def test_get_image_http( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: From ef88425d257884a45fe2ae3b092c9b30cc72140b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 24 Sep 2024 12:53:42 +0200 Subject: [PATCH 1066/1309] Start deprecation vacuum constants for feature flags (#126354) --- homeassistant/components/vacuum/__init__.py | 62 ++++++++++++++++----- tests/components/vacuum/test_init.py | 37 ++++++++++++ 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 069371c9b17..b74ccb5fc7a 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -23,6 +23,12 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + all_with_deprecated_constants, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.icon import icon_for_battery_level @@ -84,20 +90,38 @@ class VacuumEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the VacuumEntityFeature enum instead. -SUPPORT_TURN_ON = 1 -SUPPORT_TURN_OFF = 2 -SUPPORT_PAUSE = 4 -SUPPORT_STOP = 8 -SUPPORT_RETURN_HOME = 16 -SUPPORT_FAN_SPEED = 32 -SUPPORT_BATTERY = 64 -SUPPORT_STATUS = 128 -SUPPORT_SEND_COMMAND = 256 -SUPPORT_LOCATE = 512 -SUPPORT_CLEAN_SPOT = 1024 -SUPPORT_MAP = 2048 -SUPPORT_STATE = 4096 -SUPPORT_START = 8192 +_DEPRECATED_SUPPORT_TURN_ON = DeprecatedConstantEnum( + VacuumEntityFeature.TURN_ON, "2025.10" +) +_DEPRECATED_SUPPORT_TURN_OFF = DeprecatedConstantEnum( + VacuumEntityFeature.TURN_OFF, "2025.10" +) +_DEPRECATED_SUPPORT_PAUSE = DeprecatedConstantEnum(VacuumEntityFeature.PAUSE, "2025.10") +_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(VacuumEntityFeature.STOP, "2025.10") +_DEPRECATED_SUPPORT_RETURN_HOME = DeprecatedConstantEnum( + VacuumEntityFeature.RETURN_HOME, "2025.10" +) +_DEPRECATED_SUPPORT_FAN_SPEED = DeprecatedConstantEnum( + VacuumEntityFeature.FAN_SPEED, "2025.10" +) +_DEPRECATED_SUPPORT_BATTERY = DeprecatedConstantEnum( + VacuumEntityFeature.BATTERY, "2025.10" +) +_DEPRECATED_SUPPORT_STATUS = DeprecatedConstantEnum( + VacuumEntityFeature.STATUS, "2025.10" +) +_DEPRECATED_SUPPORT_SEND_COMMAND = DeprecatedConstantEnum( + VacuumEntityFeature.SEND_COMMAND, "2025.10" +) +_DEPRECATED_SUPPORT_LOCATE = DeprecatedConstantEnum( + VacuumEntityFeature.LOCATE, "2025.10" +) +_DEPRECATED_SUPPORT_CLEAN_SPOT = DeprecatedConstantEnum( + VacuumEntityFeature.CLEAN_SPOT, "2025.10" +) +_DEPRECATED_SUPPORT_MAP = DeprecatedConstantEnum(VacuumEntityFeature.MAP, "2025.10") +_DEPRECATED_SUPPORT_STATE = DeprecatedConstantEnum(VacuumEntityFeature.STATE, "2025.10") +_DEPRECATED_SUPPORT_START = DeprecatedConstantEnum(VacuumEntityFeature.START, "2025.10") # mypy: disallow-any-generics @@ -381,3 +405,13 @@ class StateVacuumEntity( This method must be run in the event loop. """ await self.hass.async_add_executor_job(self.pause) + + +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# These can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial( + dir_with_deprecated_constants, module_globals_keys=[*globals().keys()] +) +__all__ = all_with_deprecated_constants(globals()) diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index efd2a63f0f7..d03f1d28b58 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -2,10 +2,13 @@ from __future__ import annotations +from enum import Enum +from types import ModuleType from typing import Any import pytest +from homeassistant.components import vacuum from homeassistant.components.vacuum import ( DOMAIN, SERVICE_CLEAN_SPOT, @@ -30,11 +33,45 @@ from . import MockVacuum, help_async_setup_entry_init, help_async_unload_entry from tests.common import ( MockConfigEntry, MockModule, + help_test_all, + import_and_test_deprecated_constant_enum, mock_integration, setup_test_component_platform, ) +def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, str]]: + return [(enum_field, constant_prefix) for enum_field in enum if enum_field] + + +@pytest.mark.parametrize( + "module", + [vacuum], +) +def test_all(module: ModuleType) -> None: + """Test module.__all__ is correctly set.""" + help_test_all(module) + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), _create_tuples(vacuum.VacuumEntityFeature, "SUPPORT_") +) +@pytest.mark.parametrize( + "module", + [vacuum], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2025.10" + ) + + @pytest.mark.parametrize( ("service", "expected_state"), [ From 004941cc5750ad4c0378447f965a280b8c92d50f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:13:04 +0200 Subject: [PATCH 1067/1309] Fix lamarzocco ParamSpec typing (#126616) --- homeassistant/components/lamarzocco/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index 2c78a925ca4..c33933cef54 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -113,7 +113,7 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]): **kwargs: _P.kwargs, ) -> None: try: - await func() + await func(*args, **kwargs) except AuthFail as ex: msg = "Authentication failed." _LOGGER.debug(msg, exc_info=True) From 589910b49bbd249ea1064c950aebea5be4f417c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 13:37:28 +0200 Subject: [PATCH 1068/1309] Reinitialize zeroconf discovery flow on config entry removal (#126595) --- homeassistant/components/zeroconf/__init__.py | 4 +- homeassistant/config_entries.py | 1 - tests/components/zeroconf/test_init.py | 250 ++++++++++-------- tests/test_config_entries.py | 102 +++++-- 4 files changed, 226 insertions(+), 131 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index a5015e9fc8c..b0a78a1ff88 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -398,14 +398,12 @@ class ZeroconfDiscovery: entry: config_entries.ConfigEntry, ) -> None: """Handle config entry changes.""" - if entry.source != config_entries.SOURCE_IGNORE: - return for discovery_key in entry.discovery_keys[DOMAIN]: if discovery_key.version != 1: continue _type = discovery_key.key[0] name = discovery_key.key[1] - _LOGGER.debug("Rediscover unignored service %s.%s", _type, name) + _LOGGER.debug("Rediscover service %s.%s", _type, name) self._async_service_update(self.zeroconf, _type, name) def _async_dismiss_discoveries(self, name: str) -> None: diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 5df7e9b9cb0..be7f74582eb 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1388,7 +1388,6 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): result["handler"], unique_id ) ) - and entry.source == SOURCE_IGNORE and discovery_key not in ( known_discovery_keys := entry.discovery_keys.get( diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 935af9a339e..8dd8d118d72 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1456,10 +1456,12 @@ async def test_zeroconf_removed(hass: HomeAssistant) -> None: ), ], ) +@pytest.mark.parametrize("entry_source", [config_entries.SOURCE_IGNORE]) async def test_zeroconf_rediscover( hass: HomeAssistant, entry_domain: str, entry_discovery_keys: tuple, + entry_source: str, ) -> None: """Test we reinitiate flows when an ignored config entry is removed.""" @@ -1477,7 +1479,7 @@ async def test_zeroconf_rediscover( discovery_keys=entry_discovery_keys, unique_id="mock-unique-id", state=config_entries.ConfigEntryState.LOADED, - source=config_entries.SOURCE_IGNORE, + source=entry_source, ) entry.add_to_hass(hass) @@ -1534,6 +1536,145 @@ async def test_zeroconf_rediscover( assert mock_config_flow.mock_calls[2][2]["context"] == expected_context +@pytest.mark.usefixtures("mock_async_zeroconf") +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + ), + [ + # Matching discovery key + ( + "shelly", + { + "zeroconf": ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ) + }, + ), + # Matching discovery key + ( + "shelly", + { + "zeroconf": ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ), + "other": ( + DiscoveryKey( + domain="other", + key="blah", + version=1, + ), + ), + }, + ), + # Matching discovery key, other domain + # Note: Rediscovery is not currently restricted to the domain of the removed + # entry. Such a check can be added if needed. + ( + "comp", + { + "zeroconf": ( + DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + ) + }, + ), + ], +) +@pytest.mark.parametrize( + "entry_source", [config_entries.SOURCE_USER, config_entries.SOURCE_ZEROCONF] +) +async def test_zeroconf_rediscover_2( + hass: HomeAssistant, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, +) -> None: + """Test we reinitiate flows when an ignored config entry is removed. + + This test can be merged with test_zeroconf_rediscover when + async_step_unignore has been removed from the ConfigFlow base class. + """ + + def http_only_service_update_mock(zeroconf, services, handlers): + """Call service update handler.""" + handlers[0]( + zeroconf, + "_http._tcp.local.", + "Shelly108._http._tcp.local.", + ServiceStateChange.Added, + ) + + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + with ( + patch.dict( + zc_gen.ZEROCONF, + { + "_http._tcp.local.": [ + { + "domain": "shelly", + "name": "shelly*", + "properties": {"macaddress": "ffaadd*"}, + } + ] + }, + clear=True, + ), + patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, + patch.object( + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock + ) as mock_service_browser, + patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), + ), + ): + assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey( + domain="zeroconf", + key=("_http._tcp.local.", "Shelly108._http._tcp.local."), + version=1, + ), + "source": "zeroconf", + } + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "shelly" + assert mock_config_flow.mock_calls[0][2]["context"] == expected_context + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_service_browser.mock_calls) == 1 + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[1][1][0] == "shelly" + assert mock_config_flow.mock_calls[1][2]["context"] == expected_context + + @pytest.mark.usefixtures("mock_async_zeroconf") @pytest.mark.parametrize( ( @@ -1654,110 +1795,3 @@ async def test_zeroconf_rediscover_no_match( assert mock_config_flow.mock_calls[1][2]["context"] == { "source": "unignore", } - - -@pytest.mark.usefixtures("mock_async_zeroconf") -@pytest.mark.parametrize( - ( - "entry_domain", - "entry_discovery_keys", - "entry_source", - "entry_unique_id", - ), - [ - # Source not SOURCE_IGNORE - ( - "shelly", - { - "zeroconf": ( - DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, - ), - ) - }, - config_entries.SOURCE_ZEROCONF, - "mock-unique-id", - ), - ], -) -async def test_zeroconf_rediscover_no_match_2( - hass: HomeAssistant, - entry_domain: str, - entry_discovery_keys: tuple, - entry_source: str, - entry_unique_id: str, -) -> None: - """Test we don't reinitiate flows when a non matching config entry is removed. - - This test can be merged with test_zeroconf_rediscover_no_match when - async_step_unignore has been removed from the ConfigFlow base class. - """ - - def http_only_service_update_mock(zeroconf, services, handlers): - """Call service update handler.""" - handlers[0]( - zeroconf, - "_http._tcp.local.", - "Shelly108._http._tcp.local.", - ServiceStateChange.Added, - ) - - hass.config.components.add(entry_domain) - mock_integration(hass, MockModule(entry_domain)) - - entry = MockConfigEntry( - domain=entry_domain, - discovery_keys=entry_discovery_keys, - unique_id=entry_unique_id, - state=config_entries.ConfigEntryState.LOADED, - source=entry_source, - ) - entry.add_to_hass(hass) - - with ( - patch.dict( - zc_gen.ZEROCONF, - { - "_http._tcp.local.": [ - { - "domain": "shelly", - "name": "shelly*", - "properties": {"macaddress": "ffaadd*"}, - } - ] - }, - clear=True, - ), - patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, - patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock - ) as mock_service_browser, - patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", - side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), - ), - ): - assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - expected_context = { - "discovery_key": DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, - ), - "source": "zeroconf", - } - assert len(mock_service_browser.mock_calls) == 1 - assert len(mock_config_flow.mock_calls) == 1 - assert mock_config_flow.mock_calls[0][1][0] == "shelly" - assert mock_config_flow.mock_calls[0][2]["context"] == expected_context - - await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - - assert len(mock_service_browser.mock_calls) == 1 - assert len(mock_config_flow.mock_calls) == 1 diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 57730a9f014..53bcb459c60 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2911,7 +2911,6 @@ async def test_manual_add_overrides_ignored_entry_singleton( @pytest.mark.parametrize( ( "discovery_keys", - "entry_source", "entry_unique_id", "flow_context", "flow_source", @@ -2922,7 +2921,6 @@ async def test_manual_add_overrides_ignored_entry_singleton( # No discovery key ( {}, - config_entries.SOURCE_IGNORE, "mock-unique-id", {}, config_entries.SOURCE_ZEROCONF, @@ -2932,7 +2930,6 @@ async def test_manual_add_overrides_ignored_entry_singleton( # Discovery key added to ignored entry data ( {}, - config_entries.SOURCE_IGNORE, "mock-unique-id", {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, config_entries.SOURCE_ZEROCONF, @@ -2942,7 +2939,6 @@ async def test_manual_add_overrides_ignored_entry_singleton( # Discovery key added to ignored entry data ( {"test": (DiscoveryKey(domain="test", key="bleh", version=1),)}, - config_entries.SOURCE_IGNORE, "mock-unique-id", {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, config_entries.SOURCE_ZEROCONF, @@ -2970,7 +2966,6 @@ async def test_manual_add_overrides_ignored_entry_singleton( DiscoveryKey(domain="test", key="10", version=1), ) }, - config_entries.SOURCE_IGNORE, "mock-unique-id", {"discovery_key": DiscoveryKey(domain="test", key="11", version=1)}, config_entries.SOURCE_ZEROCONF, @@ -2993,33 +2988,102 @@ async def test_manual_add_overrides_ignored_entry_singleton( # Discovery key already in ignored entry data ( {"test": (DiscoveryKey(domain="test", key="blah", version=1),)}, - config_entries.SOURCE_IGNORE, "mock-unique-id", {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, config_entries.SOURCE_ZEROCONF, data_entry_flow.FlowResultType.ABORT, {"test": (DiscoveryKey(domain="test", key="blah", version=1),)}, ), - # Discovery key not added to user entry data - ( - {}, - config_entries.SOURCE_USER, - "mock-unique-id", - {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, - config_entries.SOURCE_ZEROCONF, - data_entry_flow.FlowResultType.ABORT, - {}, - ), # Flow not aborted when unique id is not matching ( {}, - config_entries.SOURCE_IGNORE, "mock-unique-id-2", {"discovery_key": DiscoveryKey(domain="test", key="blah", version=1)}, config_entries.SOURCE_ZEROCONF, data_entry_flow.FlowResultType.FORM, {}, ), + ], +) +@pytest.mark.parametrize( + "entry_source", + [ + config_entries.SOURCE_IGNORE, + config_entries.SOURCE_USER, + config_entries.SOURCE_ZEROCONF, + ], +) +async def test_update_discovery_keys( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + discovery_keys: tuple, + entry_source: str, + entry_unique_id: str, + flow_context: dict, + flow_source: str, + flow_result: data_entry_flow.FlowResultType, + updated_discovery_keys: tuple, +) -> None: + """Test that discovery keys of an entry can be updated.""" + hass.config.components.add("comp") + entry = MockConfigEntry( + domain="comp", + discovery_keys=discovery_keys, + unique_id=entry_unique_id, + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + mock_integration(hass, MockModule("comp")) + mock_platform(hass, "comp.config_flow", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + await self.async_set_unique_id("mock-unique-id") + self._abort_if_unique_id_configured(reload_on_update=False) + return self.async_show_form(step_id="step2") + + async def async_step_step2(self, user_input=None): + raise NotImplementedError + + async def async_step_zeroconf(self, discovery_info=None): + """Test zeroconf step.""" + return await self.async_step_user(discovery_info) + + with ( + mock_config_flow("comp", TestFlow), + patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as async_reload, + ): + result = await manager.flow.async_init( + "comp", context={"source": flow_source} | flow_context + ) + await hass.async_block_till_done() + + assert result["type"] == flow_result + assert entry.data == {} + assert entry.discovery_keys == updated_discovery_keys + assert len(async_reload.mock_calls) == 0 + + +@pytest.mark.parametrize( + ( + "discovery_keys", + "entry_source", + "entry_unique_id", + "flow_context", + "flow_source", + "flow_result", + "updated_discovery_keys", + ), + [ # Flow not aborted when user initiated flow ( {}, @@ -3032,7 +3096,7 @@ async def test_manual_add_overrides_ignored_entry_singleton( ), ], ) -async def test_ignored_entry_update_discovery_keys( +async def test_update_discovery_keys_2( hass: HomeAssistant, manager: config_entries.ConfigEntries, discovery_keys: tuple, @@ -3043,7 +3107,7 @@ async def test_ignored_entry_update_discovery_keys( flow_result: data_entry_flow.FlowResultType, updated_discovery_keys: tuple, ) -> None: - """Test that discovery keys of an ignored entry can be updated.""" + """Test that discovery keys of an entry can be updated.""" hass.config.components.add("comp") entry = MockConfigEntry( domain="comp", From 77029b0197d9d01c1c07cab7b6b1e0702cba9a1f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 13:38:07 +0200 Subject: [PATCH 1069/1309] Make NYT Games a service (#126613) --- homeassistant/components/nyt_games/entity.py | 3 ++- tests/components/nyt_games/snapshots/test_init.ambr | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nyt_games/entity.py b/homeassistant/components/nyt_games/entity.py index eef1424d50b..ba4234ab48b 100644 --- a/homeassistant/components/nyt_games/entity.py +++ b/homeassistant/components/nyt_games/entity.py @@ -1,6 +1,6 @@ """Base class for NYT Games entities.""" -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -19,5 +19,6 @@ class NYTGamesEntity(CoordinatorEntity[NYTGamesCoordinator]): assert unique_id is not None self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, manufacturer="New York Times", ) diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr index 10a44f5d150..60759f25baf 100644 --- a/tests/components/nyt_games/snapshots/test_init.ambr +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -7,7 +7,7 @@ 'connections': set({ }), 'disabled_by': None, - 'entry_type': None, + 'entry_type': , 'hw_version': None, 'id': , 'identifiers': set({ From c9d3c3d36986df7ead8904dfe72572b20656a1de Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:39:09 +0200 Subject: [PATCH 1070/1309] Update pre-commit to 3.8.0 (#126617) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 382bd3c2d85..a6fdc8d56e5 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.6.0 freezegun==1.5.1 mock-open==1.4.0 mypy-dev==1.12.0a3 -pre-commit==3.7.1 +pre-commit==3.8.0 pydantic==1.10.17 pylint==3.3.0 pylint-per-file-ignores==1.3.2 From 9e703b822461e03401ef05a43713512607724319 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:40:01 +0200 Subject: [PATCH 1071/1309] Update coverage to 7.6.1 (#126615) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index a6fdc8d56e5..92837ea9759 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.3.3 -coverage==7.6.0 +coverage==7.6.1 freezegun==1.5.1 mock-open==1.4.0 mypy-dev==1.12.0a3 From d3889cab9e13bc98fafe096666684c381d77082c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 13:40:38 +0200 Subject: [PATCH 1072/1309] Make Matter select entity values translatable (#126608) * Make Matter select entity values lowercase * Make Matter select entity values lowercase --- homeassistant/components/matter/select.py | 18 +++++++++--------- homeassistant/components/matter/strings.json | 8 +++++++- tests/components/matter/test_select.py | 8 ++++---- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 2e9c44a8f8a..f6bf75d9e93 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -228,18 +228,18 @@ DISCOVERY_SCHEMAS = [ key="MatterStartUpOnOff", entity_category=EntityCategory.CONFIG, translation_key="startup_on_off", - options=["On", "Off", "Toggle", "Previous"], + options=["on", "off", "toggle", "previous"], measurement_to_ha={ - 0: "Off", - 1: "On", - 2: "Toggle", - None: "Previous", + 0: "off", + 1: "on", + 2: "toggle", + None: "previous", }.get, ha_to_native_value={ - "Off": 0, - "On": 1, - "Toggle": 2, - "Previous": None, + "off": 0, + "on": 1, + "toggle": 2, + "previous": None, }.get, ), entity_class=MatterSelectEntity, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index e69c7ae3090..14de4105f40 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -136,7 +136,13 @@ "name": "Mode" }, "startup_on_off": { - "name": "Power-on behavior on Startup" + "name": "Power-on behavior on startup", + "state": { + "on": "[%key:common::state::on%]", + "off": "[%key:common::state::off%]", + "toggle": "[%key:common::action::toggle%]", + "previous": "Previous" + } } }, "sensor": { diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index e380e5d5925..bda2a933d42 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -87,16 +87,16 @@ async def test_attribute_select_entities( entity_id = "select.mock_dimmable_light_power_on_behavior_on_startup" state = hass.states.get(entity_id) assert state - assert state.state == "Previous" - assert state.attributes["options"] == ["On", "Off", "Toggle", "Previous"] + assert state.state == "previous" + assert state.attributes["options"] == ["on", "off", "toggle", "previous"] assert ( state.attributes["friendly_name"] - == "Mock Dimmable Light Power-on behavior on Startup" + == "Mock Dimmable Light Power-on behavior on startup" ) set_node_attribute(light_node, 1, 6, 16387, 1) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) - assert state.state == "On" + assert state.state == "on" # test that an invalid value (e.g. 255) leads to an unknown state set_node_attribute(light_node, 1, 6, 16387, 255) await trigger_subscription_callback(hass, matter_client) From ba5f1ac2a9e6dfff26bdc66a5f471cc507e3988b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 13:45:37 +0200 Subject: [PATCH 1073/1309] Bump version of recorder context ID data migrators (#125293) --- .../components/recorder/migration.py | 2 + .../recorder/test_migration_from_schema_32.py | 218 ++++++++++++++++++ 2 files changed, 220 insertions(+) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 9a27a44d706..6bfba613c01 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -2300,6 +2300,7 @@ class StatesContextIDMigration(BaseRunTimeMigrationWithQuery): required_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION migration_id = "state_context_id_as_binary" + migration_version = 2 index_to_drop = ("states", "ix_states_context_id") def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: @@ -2342,6 +2343,7 @@ class EventsContextIDMigration(BaseRunTimeMigrationWithQuery): required_schema_version = CONTEXT_ID_AS_BINARY_SCHEMA_VERSION migration_id = "event_context_id_as_binary" + migration_version = 2 index_to_drop = ("events", "ix_events_context_id") def migrate_data_impl(self, instance: Recorder) -> DataMigrationStatus: diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index 95146b970f3..17f6e24e228 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -3,6 +3,7 @@ import datetime import importlib import sys +import threading from typing import Any from unittest.mock import patch import uuid @@ -24,6 +25,7 @@ from homeassistant.components.recorder import ( from homeassistant.components.recorder.db_schema import ( Events, EventTypes, + MigrationChanges, States, StatesMeta, ) @@ -338,6 +340,114 @@ async def test_migrate_events_context_ids( assert get_index_by_name(session, "events", "ix_events_context_id") is None +@pytest.mark.parametrize("persistent_database", [True]) +@pytest.mark.parametrize("enable_migrate_event_context_ids", [True]) +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage +async def test_finish_migrate_events_context_ids( + async_test_recorder: RecorderInstanceGenerator, +) -> None: + """Test we re migrate old uuid context ids and ulid context ids to binary format. + + Before PR https://github.com/home-assistant/core/pull/125214, the migrator would + mark the migration as done before ensuring unused indices were dropped. This + test makes sure we drop the unused indices. + """ + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] + + def _insert_migration(): + with session_scope(hass=hass) as session: + session.merge( + MigrationChanges( + migration_id=migration.EventsContextIDMigration.migration_id, + version=1, + ) + ) + + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.EventsContextIDMigration, "migrate_data"), + patch.object( + migration.EventIDPostMigration, + "needs_migrate_impl", + return_value=migration.DataMigrationStatus( + needs_migrate=False, migration_done=True + ), + ), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + # Check the index which will be removed by the migrator exists + with session_scope(hass=hass) as session: + assert get_index_by_name(session, "events", "ix_events_context_id") + + await hass.async_stop() + await hass.async_block_till_done() + + # Run once with new schema, fake migration did not complete + with ( + patch.object(migration.EventsContextIDMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + # Fake migration ran with old version + await instance.async_add_executor_job(_insert_migration) + await async_wait_recording_done(hass) + + # Check the index which will be removed by the migrator exists + with session_scope(hass=hass) as session: + assert get_index_by_name(session, "events", "ix_events_context_id") + + await hass.async_stop() + await hass.async_block_till_done() + + # Run again with new schema, let migration complete + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + migration_changes = await instance.async_add_executor_job( + _get_migration_id, hass + ) + # Check migration ran again + assert ( + migration_changes[migration.EventsContextIDMigration.migration_id] + == migration.EventsContextIDMigration.migration_version + ) + + # Check the index which will be removed by the migrator no longer exists + with session_scope(hass=hass) as session: + assert get_index_by_name(session, "events", "ix_events_context_id") is None + + await hass.async_stop() + await hass.async_block_till_done() + + @pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) @pytest.mark.usefixtures("db_schema_32") async def test_migrate_states_context_ids( @@ -540,6 +650,114 @@ async def test_migrate_states_context_ids( assert get_index_by_name(session, "states", "ix_states_context_id") is None +@pytest.mark.parametrize("persistent_database", [True]) +@pytest.mark.parametrize("enable_migrate_state_context_ids", [True]) +@pytest.mark.usefixtures("hass_storage") # Prevent test hass from writing to storage +async def test_finish_migrate_states_context_ids( + async_test_recorder: RecorderInstanceGenerator, +) -> None: + """Test we re migrate old uuid context ids and ulid context ids to binary format. + + Before PR https://github.com/home-assistant/core/pull/125214, the migrator would + mark the migration as done before ensuring unused indices were dropped. This + test makes sure we drop the unused indices. + """ + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] + + def _insert_migration(): + with session_scope(hass=hass) as session: + session.merge( + MigrationChanges( + migration_id=migration.StatesContextIDMigration.migration_id, + version=1, + ) + ) + + # Create database with old schema + with ( + patch.object(recorder, "db_schema", old_db_schema), + patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION), + patch.object(migration.StatesContextIDMigration, "migrate_data"), + patch.object( + migration.EventIDPostMigration, + "needs_migrate_impl", + return_value=migration.DataMigrationStatus( + needs_migrate=False, migration_done=True + ), + ), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + # Check the index which will be removed by the migrator exists + with session_scope(hass=hass) as session: + assert get_index_by_name(session, "states", "ix_states_context_id") + + await hass.async_stop() + await hass.async_block_till_done() + + # Run once with new schema, fake migration did not complete + with ( + patch.object(migration.StatesContextIDMigration, "migrate_data"), + patch(CREATE_ENGINE_TARGET, new=_create_engine_test), + ): + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + # Fake migration ran with old version + await instance.async_add_executor_job(_insert_migration) + await async_wait_recording_done(hass) + + # Check the index which will be removed by the migrator exists + with session_scope(hass=hass) as session: + assert get_index_by_name(session, "states", "ix_states_context_id") + + await hass.async_stop() + await hass.async_block_till_done() + + # Run again with new schema, let migration complete + async with ( + async_test_home_assistant() as hass, + async_test_recorder(hass) as instance, + ): + instance.recorder_and_worker_thread_ids.add(threading.get_ident()) + + await hass.async_block_till_done() + await async_wait_recording_done(hass) + await async_wait_recording_done(hass) + + migration_changes = await instance.async_add_executor_job( + _get_migration_id, hass + ) + # Check migration ran again + assert ( + migration_changes[migration.StatesContextIDMigration.migration_id] + == migration.StatesContextIDMigration.migration_version + ) + + # Check the index which will be removed by the migrator no longer exists + with session_scope(hass=hass) as session: + assert get_index_by_name(session, "states", "ix_states_context_id") is None + + await hass.async_stop() + await hass.async_block_till_done() + + @pytest.mark.parametrize("enable_migrate_event_type_ids", [True]) @pytest.mark.usefixtures("db_schema_32") async def test_migrate_event_type_ids( From b856f543336ae45b79f9994fb486cc4fb6c327f6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:07:25 +0200 Subject: [PATCH 1074/1309] Update pipdeptree to 2.23.4 (#126619) * Update pipdeptree to 2.23.4 * Update Dockerfile --- requirements_test.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 92837ea9759..55ce17c5086 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ pre-commit==3.8.0 pydantic==1.10.17 pylint==3.3.0 pylint-per-file-ignores==1.3.2 -pipdeptree==2.23.1 +pipdeptree==2.23.4 pip-licenses==4.5.1 pytest-asyncio==0.23.8 pytest-aiohttp==1.0.5 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index ba9493ce654..48621bd6238 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.12,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.23.1 tqdm==4.66.4 ruff==0.6.6 \ + stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.4 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 LABEL "name"="hassfest" From ca0f1ef8daaee2e03152f78a5071e116f4235c99 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:07:52 +0200 Subject: [PATCH 1075/1309] Update pytest-asyncio to 0.24.0 (#126621) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 55ce17c5086..6a7130eedae 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -18,7 +18,7 @@ pylint==3.3.0 pylint-per-file-ignores==1.3.2 pipdeptree==2.23.4 pip-licenses==4.5.1 -pytest-asyncio==0.23.8 +pytest-asyncio==0.24.0 pytest-aiohttp==1.0.5 pytest-cov==5.0.0 pytest-freezer==0.4.8 From e3c438ff476232f0c19d1fec15558422a1bd7e87 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:10:09 +0200 Subject: [PATCH 1076/1309] Update pytest to 8.3.3 (#126623) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 6a7130eedae..8f5b21df299 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -29,7 +29,7 @@ pytest-timeout==2.3.1 pytest-unordered==0.6.1 pytest-picked==0.5.0 pytest-xdist==3.6.1 -pytest==8.3.1 +pytest==8.3.3 requests-mock==1.12.1 respx==0.21.1 syrupy==4.6.1 From 09ae0946b791ee7a77875aacc027143a31f5a7bc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:10:43 +0200 Subject: [PATCH 1077/1309] Update syrupy to 4.7.1 (#126625) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 8f5b21df299..80fdadd560c 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -32,7 +32,7 @@ pytest-xdist==3.6.1 pytest==8.3.3 requests-mock==1.12.1 respx==0.21.1 -syrupy==4.6.1 +syrupy==4.7.1 tqdm==4.66.4 types-aiofiles==23.2.0.20240623 types-atomicwrites==1.4.5.1 From b9c28bed193d5c6c5c6514bf94a79c0359945e73 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:26:52 +0200 Subject: [PATCH 1078/1309] Update pylint to 3.3.1 (#126614) * Update astroid to 3.3.4 * Update pylint to 3.3.1 --- requirements_test.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 80fdadd560c..f6e824bbd7d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,14 +7,14 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.3.3 +astroid==3.3.4 coverage==7.6.1 freezegun==1.5.1 mock-open==1.4.0 mypy-dev==1.12.0a3 pre-commit==3.8.0 pydantic==1.10.17 -pylint==3.3.0 +pylint==3.3.1 pylint-per-file-ignores==1.3.2 pipdeptree==2.23.4 pip-licenses==4.5.1 From b6fe3a3022914c2b9cad731e06cfdedbc3f06f36 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 14:42:46 +0200 Subject: [PATCH 1079/1309] Reinitialize bluetooth discovery flow on config entry removal (#126555) * Reinitialize bluetooth discovery flow on unignore * Update homeassistant/components/bluetooth/manager.py Co-authored-by: J. Nick Koston * Update tests * Rediscover on any removed config entry --------- Co-authored-by: J. Nick Koston --- homeassistant/components/bluetooth/manager.py | 35 ++ tests/components/bluetooth/test_manager.py | 591 +++++++++++++++++- .../snapshots/test_config_flow.ambr | 17 + 3 files changed, 642 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 9355fca6cdc..e192423484c 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -20,7 +20,9 @@ from homeassistant.core import ( callback as hass_callback, ) from homeassistant.helpers import discovery_flow +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .const import DOMAIN from .match import ( ADDRESS, CALLBACK, @@ -75,12 +77,18 @@ class HomeAssistantBluetoothManager(BluetoothManager): self, service_info: BluetoothServiceInfoBleak ) -> None: """Trigger discovery for matching domains.""" + discovery_key = discovery_flow.DiscoveryKey( + domain=DOMAIN, + key=service_info.address, + version=1, + ) for domain in self._integration_matcher.match_domains(service_info): discovery_flow.async_create_flow( self.hass, domain, {"source": config_entries.SOURCE_BLUETOOTH}, service_info, + discovery_key=discovery_key, ) @hass_callback @@ -110,12 +118,21 @@ class HomeAssistantBluetoothManager(BluetoothManager): except Exception: _LOGGER.exception("Error in bluetooth callback") + if not matched_domains: + return # avoid creating DiscoveryKey if there are no matches + + discovery_key = discovery_flow.DiscoveryKey( + domain=DOMAIN, + key=service_info.address, + version=1, + ) for domain in matched_domains: discovery_flow.async_create_flow( self.hass, domain, {"source": config_entries.SOURCE_BLUETOOTH}, service_info, + discovery_key=discovery_key, ) def _address_disappeared(self, address: str) -> None: @@ -145,6 +162,11 @@ class HomeAssistantBluetoothManager(BluetoothManager): continue seen.add(address) self._async_trigger_matching_discovery(service_info) + async_dispatcher_connect( + self.hass, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, + ) def async_register_callback( self, @@ -230,3 +252,16 @@ class HomeAssistantBluetoothManager(BluetoothManager): unregister = super().async_register_scanner(scanner, connection_slots) return partial(self._async_unregister_scanner, scanner, unregister) + + @hass_callback + def _handle_config_entry_removed( + self, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1 or not isinstance(discovery_key.key, str): + continue + address = discovery_key.key + _LOGGER.debug("Rediscover address %s", address) + self.async_rediscover_address(address) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 0ac49aa72cd..caff31d74d2 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -13,6 +13,7 @@ from bluetooth_adapters import AdvertisementHistory from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest +from homeassistant import config_entries from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -36,6 +37,7 @@ from homeassistant.components.bluetooth.const import ( UNAVAILABLE_TRACK_SECONDS, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads @@ -52,7 +54,13 @@ from . import ( patch_bluetooth_time, ) -from tests.common import async_fire_time_changed, load_fixture +from tests.common import ( + MockConfigEntry, + MockModule, + async_fire_time_changed, + load_fixture, + mock_integration, +) @pytest.fixture @@ -1002,6 +1010,12 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "switchbot" + assert mock_config_flow.mock_calls[0][2]["context"] == { + "discovery_key": DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + "source": "bluetooth", + } assert async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None assert async_scanner_count(hass, connectable=False) == 1 @@ -1075,6 +1089,12 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( ) assert len(mock_config_flow.mock_calls) == 1 assert mock_config_flow.mock_calls[0][1][0] == "switchbot" + assert mock_config_flow.mock_calls[0][2]["context"] == { + "discovery_key": DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + "source": "bluetooth", + } cancel_unavailable() @@ -1268,3 +1288,572 @@ async def test_set_fallback_interval_big(hass: HomeAssistant) -> None: # We should forget fallback interval after it expires assert async_get_fallback_availability_interval(hass, "44:44:33:11:23:12") is None + + +@pytest.mark.usefixtures("mock_bluetooth_adapters") +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + ), + [ + # Matching discovery key + ( + "switchbot", + { + "bluetooth": ( + DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + ) + }, + ), + # Matching discovery key + ( + "switchbot", + { + "bluetooth": ( + DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + ), + "other": (DiscoveryKey(domain="other", key="blah", version=1),), + }, + ), + # Matching discovery key, other domain + # Note: Rediscovery is not currently restricted to the domain of the removed + # entry. Such a check can be added if needed. + ( + "comp", + { + "bluetooth": ( + DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + ) + }, + ), + ], +) +@pytest.mark.parametrize("entry_source", [config_entries.SOURCE_IGNORE]) +async def test_bluetooth_rediscover( + hass: HomeAssistant, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, +) -> None: + """Test we reinitiate flows when an ignored config entry is removed.""" + mock_bt = [ + { + "domain": "switchbot", + "service_data_uuid": "050a021a-0000-1000-8000-00805f9b34fb", + "connectable": False, + }, + ] + with patch( + "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt + ): + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + + assert async_scanner_count(hass, connectable=False) == 0 + switchbot_device_non_connectable = generate_ble_device( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], + service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + callbacks = [] + + def _fake_subscriber( + service_info: BluetoothServiceInfo, + change: BluetoothChange, + ) -> None: + """Fake subscriber for the BleakScanner.""" + callbacks.append((service_info, change)) + + cancel = bluetooth.async_register_callback( + hass, + _fake_subscriber, + {"address": "44:44:33:11:23:45", "connectable": False}, + BluetoothScanningMode.ACTIVE, + ) + + class FakeScanner(BaseHaRemoteScanner): + def inject_advertisement( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), + ) + + def clear_all_devices(self) -> None: + """Clear all devices.""" + self._discovered_device_advertisement_datas.clear() + self._discovered_device_timestamps.clear() + self._previous_service_info.clear() + + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + non_connectable_scanner = FakeScanner( + "connectable", + "connectable", + connector, + False, + ) + unsetup_connectable_scanner = non_connectable_scanner.async_setup() + cancel_connectable_scanner = _get_manager().async_register_scanner( + non_connectable_scanner + ) + with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: + non_connectable_scanner.inject_advertisement( + switchbot_device_non_connectable, switchbot_device_adv + ) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + "source": "bluetooth", + } + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "switchbot" + assert mock_config_flow.mock_calls[0][2]["context"] == expected_context + + hass.config.components.add(entry_domain) + mock_integration(hass, MockModule(entry_domain)) + + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + assert ( + async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None + ) + assert async_scanner_count(hass, connectable=False) == 1 + assert len(callbacks) == 1 + + assert ( + "44:44:33:11:23:45" + in non_connectable_scanner.discovered_devices_and_advertisement_data + ) + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert ( + async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None + ) + assert async_scanner_count(hass, connectable=False) == 1 + assert len(callbacks) == 1 + + assert len(mock_config_flow.mock_calls) == 3 + assert mock_config_flow.mock_calls[1][1][0] == entry_domain + assert mock_config_flow.mock_calls[1][2]["context"] == { + "source": "unignore", + } + assert mock_config_flow.mock_calls[2][1][0] == "switchbot" + assert mock_config_flow.mock_calls[2][2]["context"] == expected_context + + cancel() + unsetup_connectable_scanner() + cancel_connectable_scanner() + + +@pytest.mark.usefixtures("mock_bluetooth_adapters") +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + ), + [ + # Matching discovery key + ( + "switchbot", + { + "bluetooth": ( + DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + ) + }, + ), + # Matching discovery key + ( + "switchbot", + { + "bluetooth": ( + DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + ), + "other": (DiscoveryKey(domain="other", key="blah", version=1),), + }, + ), + # Matching discovery key, other domain + # Note: Rediscovery is not currently restricted to the domain of the removed + # entry. Such a check can be added if needed. + ( + "comp", + { + "bluetooth": ( + DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + ) + }, + ), + ], +) +@pytest.mark.parametrize( + "entry_source", [config_entries.SOURCE_BLUETOOTH, config_entries.SOURCE_USER] +) +async def test_bluetooth_rediscover_2( + hass: HomeAssistant, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, +) -> None: + """Test we reinitiate flows when an ignored config entry is removed. + + This test can be merged with test_zeroconf_rediscover when + async_step_unignore has been removed from the ConfigFlow base class. + """ + mock_bt = [ + { + "domain": "switchbot", + "service_data_uuid": "050a021a-0000-1000-8000-00805f9b34fb", + "connectable": False, + }, + ] + with patch( + "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt + ): + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + + assert async_scanner_count(hass, connectable=False) == 0 + switchbot_device_non_connectable = generate_ble_device( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], + service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + callbacks = [] + + def _fake_subscriber( + service_info: BluetoothServiceInfo, + change: BluetoothChange, + ) -> None: + """Fake subscriber for the BleakScanner.""" + callbacks.append((service_info, change)) + + cancel = bluetooth.async_register_callback( + hass, + _fake_subscriber, + {"address": "44:44:33:11:23:45", "connectable": False}, + BluetoothScanningMode.ACTIVE, + ) + + class FakeScanner(BaseHaRemoteScanner): + def inject_advertisement( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), + ) + + def clear_all_devices(self) -> None: + """Clear all devices.""" + self._discovered_device_advertisement_datas.clear() + self._discovered_device_timestamps.clear() + self._previous_service_info.clear() + + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + non_connectable_scanner = FakeScanner( + "connectable", + "connectable", + connector, + False, + ) + unsetup_connectable_scanner = non_connectable_scanner.async_setup() + cancel_connectable_scanner = _get_manager().async_register_scanner( + non_connectable_scanner + ) + with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: + non_connectable_scanner.inject_advertisement( + switchbot_device_non_connectable, switchbot_device_adv + ) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + "source": "bluetooth", + } + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "switchbot" + assert mock_config_flow.mock_calls[0][2]["context"] == expected_context + + hass.config.components.add(entry_domain) + mock_integration(hass, MockModule(entry_domain)) + + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + assert ( + async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None + ) + assert async_scanner_count(hass, connectable=False) == 1 + assert len(callbacks) == 1 + + assert ( + "44:44:33:11:23:45" + in non_connectable_scanner.discovered_devices_and_advertisement_data + ) + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert ( + async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None + ) + assert async_scanner_count(hass, connectable=False) == 1 + assert len(callbacks) == 1 + + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[1][1][0] == "switchbot" + assert mock_config_flow.mock_calls[1][2]["context"] == expected_context + + cancel() + unsetup_connectable_scanner() + cancel_connectable_scanner() + + +@pytest.mark.usefixtures("mock_bluetooth_adapters") +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + "entry_source", + "entry_unique_id", + ), + [ + # Discovery key from other domain + ( + "switchbot", + { + "zeroconf": ( + DiscoveryKey(domain="zeroconf", key="44:44:33:11:23:45", version=1), + ) + }, + config_entries.SOURCE_IGNORE, + "mock-unique-id", + ), + # Discovery key from the future + ( + "switchbot", + { + "bluetooth": ( + DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=2 + ), + ) + }, + config_entries.SOURCE_IGNORE, + "mock-unique-id", + ), + ], +) +async def test_bluetooth_rediscover_no_match( + hass: HomeAssistant, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, + entry_unique_id: str, +) -> None: + """Test we don't reinitiate flows when a non matching config entry is removed.""" + mock_bt = [ + { + "domain": "switchbot", + "service_data_uuid": "050a021a-0000-1000-8000-00805f9b34fb", + "connectable": False, + }, + ] + with patch( + "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt + ): + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + + assert async_scanner_count(hass, connectable=False) == 0 + switchbot_device_non_connectable = generate_ble_device( + "44:44:33:11:23:45", + "wohand", + {}, + rssi=-100, + ) + switchbot_device_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], + service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01"}, + rssi=-100, + ) + callbacks = [] + + def _fake_subscriber( + service_info: BluetoothServiceInfo, + change: BluetoothChange, + ) -> None: + """Fake subscriber for the BleakScanner.""" + callbacks.append((service_info, change)) + + cancel = bluetooth.async_register_callback( + hass, + _fake_subscriber, + {"address": "44:44:33:11:23:45", "connectable": False}, + BluetoothScanningMode.ACTIVE, + ) + + class FakeScanner(BaseHaRemoteScanner): + def inject_advertisement( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), + ) + + def clear_all_devices(self) -> None: + """Clear all devices.""" + self._discovered_device_advertisement_datas.clear() + self._discovered_device_timestamps.clear() + self._previous_service_info.clear() + + connector = ( + HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), + ) + non_connectable_scanner = FakeScanner( + "connectable", + "connectable", + connector, + False, + ) + unsetup_connectable_scanner = non_connectable_scanner.async_setup() + cancel_connectable_scanner = _get_manager().async_register_scanner( + non_connectable_scanner + ) + with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: + non_connectable_scanner.inject_advertisement( + switchbot_device_non_connectable, switchbot_device_adv + ) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey( + domain="bluetooth", key="44:44:33:11:23:45", version=1 + ), + "source": "bluetooth", + } + assert len(mock_config_flow.mock_calls) == 1 + assert mock_config_flow.mock_calls[0][1][0] == "switchbot" + assert mock_config_flow.mock_calls[0][2]["context"] == expected_context + + hass.config.components.add(entry_domain) + mock_integration(hass, MockModule(entry_domain)) + + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id=entry_unique_id, + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + assert ( + async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None + ) + assert async_scanner_count(hass, connectable=False) == 1 + assert len(callbacks) == 1 + + assert ( + "44:44:33:11:23:45" + in non_connectable_scanner.discovered_devices_and_advertisement_data + ) + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert ( + async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None + ) + assert async_scanner_count(hass, connectable=False) == 1 + assert len(callbacks) == 1 + + assert len(mock_config_flow.mock_calls) == 2 + assert mock_config_flow.mock_calls[1][1][0] == entry_domain + assert mock_config_flow.mock_calls[1][2]["context"] == { + "source": "unignore", + } + + cancel() + unsetup_connectable_scanner() + cancel_connectable_scanner() diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 11a287762b9..42ae66addf0 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -3,6 +3,11 @@ FlowResultSnapshot({ 'context': dict({ 'confirm_only': True, + 'discovery_key': dict({ + 'domain': 'bluetooth', + 'key': '00000000-0000-0000-0000-000000000001', + 'version': 1, + }), 'source': 'bluetooth', 'title_placeholders': dict({ 'name': 'Gardena Water Computer', @@ -18,6 +23,11 @@ FlowResultSnapshot({ 'context': dict({ 'confirm_only': True, + 'discovery_key': dict({ + 'domain': 'bluetooth', + 'key': '00000000-0000-0000-0000-000000000001', + 'version': 1, + }), 'source': 'bluetooth', 'title_placeholders': dict({ 'name': 'Gardena Water Computer', @@ -40,6 +50,13 @@ }), 'disabled_by': None, 'discovery_keys': dict({ + 'bluetooth': tuple( + dict({ + 'domain': 'bluetooth', + 'key': '00000000-0000-0000-0000-000000000001', + 'version': 1, + }), + ), }), 'domain': 'gardena_bluetooth', 'entry_id': , From 972dc89c0f5046e6ada78e957179a577492b3663 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 14:43:05 +0200 Subject: [PATCH 1080/1309] Reinitialize dhcp discovery flow on config entry removal (#126556) * Reinitialize dhcp discovery flow on unignore * Tweak * Rediscover on any removed config entry * Adjust log message --- homeassistant/components/dhcp/__init__.py | 57 +++- tests/components/dhcp/test_init.py | 337 ++++++++++++++++++++-- 2 files changed, 375 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index bf3389b4111..2de676ef52a 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -51,6 +51,7 @@ from homeassistant.helpers import ( discovery_flow, ) from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac +from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import ( async_track_state_added_domain, @@ -155,6 +156,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await dhcp_watcher.async_start() watchers.append(dhcp_watcher) + rediscovery_watcher = RediscoveryWatcher( + hass, address_data, integration_matchers + ) + rediscovery_watcher.async_start() + watchers.append(rediscovery_watcher) + @callback def _async_stop(event: Event) -> None: for watcher in watchers: @@ -192,7 +199,11 @@ class WatcherBase: @callback def async_process_client( - self, ip_address: str, hostname: str, unformatted_mac_address: str + self, + ip_address: str, + hostname: str, + unformatted_mac_address: str, + force: bool = False, ) -> None: """Process a client.""" if (made_ip_address := cached_ip_addresses(ip_address)) is None: @@ -217,7 +228,8 @@ class WatcherBase: data = self._address_data.get(mac_address) if ( - data + not force + and data and data[IP_ADDRESS] == compressed_ip_address and data[HOSTNAME].startswith(hostname) ): @@ -271,6 +283,14 @@ class WatcherBase: _LOGGER.debug("Matched %s against %s", data, matcher) matched_domains.add(domain) + if not matched_domains: + return # avoid creating DiscoveryKey if there are no matches + + discovery_key = DiscoveryKey( + domain=DOMAIN, + key=mac_address, + version=1, + ) for domain in matched_domains: discovery_flow.async_create_flow( self.hass, @@ -281,6 +301,7 @@ class WatcherBase: hostname=lowercase_hostname, macaddress=mac_address, ), + discovery_key=discovery_key, ) @@ -414,6 +435,38 @@ class DHCPWatcher(WatcherBase): self._unsub = await aiodhcpwatcher.async_start(self._async_process_dhcp_request) +class RediscoveryWatcher(WatcherBase): + """Class to trigger rediscovery on config entry removal.""" + + @callback + def _handle_config_entry_removed( + self, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1 or not isinstance(discovery_key.key, str): + continue + mac_address = discovery_key.key + _LOGGER.debug("Rediscover service %s", mac_address) + if data := self._address_data.get(mac_address): + self.async_process_client( + data[IP_ADDRESS], + data[HOSTNAME], + mac_address, + True, # Force rediscovery + ) + + @callback + def async_start(self) -> None: + """Start watching for config entry removals.""" + self._unsub = async_dispatcher_connect( + self.hass, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, + ) + + @lru_cache(maxsize=4096, typed=True) def _compile_fnmatch(pattern: str) -> re.Pattern: """Compile a fnmatch pattern.""" diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 7c652c8ea3e..3916a854247 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -35,11 +35,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + MockModule, + async_fire_time_changed, + mock_integration, +) # connect b8:b7:f1:6d:b5:33 192.168.210.56 RAW_DHCP_REQUEST = ( @@ -138,11 +144,15 @@ RAW_DHCP_REQUEST_WITHOUT_HOSTNAME = ( async def _async_get_handle_dhcp_packet( - hass: HomeAssistant, integration_matchers: dhcp.DhcpMatchers + hass: HomeAssistant, + integration_matchers: dhcp.DhcpMatchers, + address_data: dict | None = None, ) -> Callable[[Any], Awaitable[None]]: + if address_data is None: + address_data = {} dhcp_watcher = dhcp.DHCPWatcher( hass, - {}, + address_data, integration_matchers, ) with patch("aiodhcpwatcher.async_start"): @@ -177,7 +187,8 @@ async def test_dhcp_match_hostname_and_macaddress(hass: HomeAssistant) -> None: assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", @@ -205,7 +216,8 @@ async def test_dhcp_renewal_match_hostname_and_macaddress(hass: HomeAssistant) - assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="50147903852c", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.1.120", @@ -254,7 +266,8 @@ async def test_registered_devices( assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="50147903852c", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.1.120", @@ -280,7 +293,8 @@ async def test_dhcp_match_hostname(hass: HomeAssistant) -> None: assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", @@ -306,7 +320,8 @@ async def test_dhcp_match_macaddress(hass: HomeAssistant) -> None: assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", @@ -335,7 +350,8 @@ async def test_dhcp_multiple_match_only_one_flow(hass: HomeAssistant) -> None: assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", @@ -361,7 +377,8 @@ async def test_dhcp_match_macaddress_without_hostname(hass: HomeAssistant) -> No assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="606bbd59e4b4", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.107.151", @@ -687,7 +704,8 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start( assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", @@ -724,7 +742,8 @@ async def test_device_tracker_registered(hass: HomeAssistant) -> None: assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", @@ -803,7 +822,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start( assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", @@ -1012,7 +1032,8 @@ async def test_aiodiscover_finds_new_hosts(hass: HomeAssistant) -> None: assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", @@ -1074,7 +1095,8 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname( assert len(mock_init.mock_calls) == 2 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", @@ -1083,7 +1105,8 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname( ) assert mock_init.mock_calls[1][1][0] == "mock-domain" assert mock_init.mock_calls[1][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[1][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", @@ -1140,10 +1163,290 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) - assert len(mock_init.mock_calls) == 1 assert mock_init.mock_calls[0][1][0] == "mock-domain" assert mock_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_DHCP + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, } assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( ip="192.168.210.56", hostname="connect", macaddress="b8b7f16db533", ) + + +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + ), + [ + # Matching discovery key + ( + "mock-domain", + {"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),)}, + ), + # Matching discovery key + ( + "mock-domain", + { + "dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),), + "other": (DiscoveryKey(domain="other", key="blah", version=1),), + }, + ), + # Matching discovery key, other domain + # Note: Rediscovery is not currently restricted to the domain of the removed + # entry. Such a check can be added if needed. + ( + "comp", + {"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),)}, + ), + ], +) +@pytest.mark.parametrize("entry_source", [config_entries.SOURCE_IGNORE]) +async def test_dhcp_rediscover( + hass: HomeAssistant, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, +) -> None: + """Test we reinitiate flows when an ignored config entry is removed.""" + + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + address_data = {} + integration_matchers = dhcp.async_index_integration_matchers( + [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}] + ) + packet = Ether(RAW_DHCP_REQUEST) + + async_handle_dhcp_packet = await _async_get_handle_dhcp_packet( + hass, integration_matchers, address_data + ) + rediscovery_watcher = dhcp.RediscoveryWatcher( + hass, address_data, integration_matchers + ) + rediscovery_watcher.async_start() + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + await async_handle_dhcp_packet(packet) + # Ensure no change is ignored + await async_handle_dhcp_packet(packet) + + # Assert the cached MAC address is hexstring without : + assert address_data == { + "b8b7f16db533": {"hostname": "connect", "ip": "192.168.210.56"} + } + + expected_context = { + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, + } + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == "mock-domain" + assert mock_init.mock_calls[0][2]["context"] == expected_context + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) + + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_init.mock_calls) == 2 + assert mock_init.mock_calls[0][1][0] == entry_domain + assert mock_init.mock_calls[0][2]["context"] == {"source": "unignore"} + assert mock_init.mock_calls[1][1][0] == "mock-domain" + assert mock_init.mock_calls[1][2]["context"] == expected_context + + +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + ), + [ + # Matching discovery key + ( + "mock-domain", + {"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),)}, + ), + # Matching discovery key + ( + "mock-domain", + { + "dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),), + "other": (DiscoveryKey(domain="other", key="blah", version=1),), + }, + ), + # Matching discovery key, other domain + # Note: Rediscovery is not currently restricted to the domain of the removed + # entry. Such a check can be added if needed. + ( + "comp", + {"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),)}, + ), + ], +) +@pytest.mark.parametrize( + "entry_source", [config_entries.SOURCE_USER, config_entries.SOURCE_ZEROCONF] +) +async def test_dhcp_rediscover_2( + hass: HomeAssistant, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, +) -> None: + """Test we reinitiate flows when an ignored config entry is removed. + + This test can be merged with test_zeroconf_rediscover when + async_step_unignore has been removed from the ConfigFlow base class. + """ + + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + address_data = {} + integration_matchers = dhcp.async_index_integration_matchers( + [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}] + ) + packet = Ether(RAW_DHCP_REQUEST) + + async_handle_dhcp_packet = await _async_get_handle_dhcp_packet( + hass, integration_matchers, address_data + ) + rediscovery_watcher = dhcp.RediscoveryWatcher( + hass, address_data, integration_matchers + ) + rediscovery_watcher.async_start() + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + await async_handle_dhcp_packet(packet) + # Ensure no change is ignored + await async_handle_dhcp_packet(packet) + + # Assert the cached MAC address is hexstring without : + assert address_data == { + "b8b7f16db533": {"hostname": "connect", "ip": "192.168.210.56"} + } + + expected_context = { + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, + } + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == "mock-domain" + assert mock_init.mock_calls[0][2]["context"] == expected_context + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) + + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == "mock-domain" + assert mock_init.mock_calls[0][2]["context"] == expected_context + + +@pytest.mark.usefixtures("mock_async_zeroconf") +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + "entry_source", + "entry_unique_id", + ), + [ + # Discovery key from other domain + ( + "mock-domain", + { + "bluetooth": ( + DiscoveryKey(domain="bluetooth", key="b8b7f16db533", version=1), + ) + }, + config_entries.SOURCE_IGNORE, + "mock-unique-id", + ), + # Discovery key from the future + ( + "mock-domain", + {"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=2),)}, + config_entries.SOURCE_IGNORE, + "mock-unique-id", + ), + ], +) +async def test_dhcp_rediscover_no_match( + hass: HomeAssistant, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, + entry_unique_id: str, +) -> None: + """Test we don't reinitiate flows when a non matching config entry is removed.""" + + mock_integration(hass, MockModule(entry_domain)) + + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id=entry_unique_id, + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + address_data = {} + integration_matchers = dhcp.async_index_integration_matchers( + [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}] + ) + packet = Ether(RAW_DHCP_REQUEST) + + async_handle_dhcp_packet = await _async_get_handle_dhcp_packet( + hass, integration_matchers, address_data + ) + rediscovery_watcher = dhcp.RediscoveryWatcher( + hass, address_data, integration_matchers + ) + rediscovery_watcher.async_start() + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + await async_handle_dhcp_packet(packet) + # Ensure no change is ignored + await async_handle_dhcp_packet(packet) + + expected_context = { + "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), + "source": config_entries.SOURCE_DHCP, + } + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == "mock-domain" + assert mock_init.mock_calls[0][2]["context"] == expected_context + assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( + ip="192.168.210.56", + hostname="connect", + macaddress="b8b7f16db533", + ) + + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == entry_domain + assert mock_init.mock_calls[0][2]["context"] == {"source": "unignore"} From e15be0433e679505f98e204143fc5841dec7d625 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 14:54:52 +0200 Subject: [PATCH 1081/1309] Remove unnecessary lambda in Matter (#126633) --- homeassistant/components/matter/binary_sensor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index a6d68682e9d..fe999487fbc 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -150,13 +150,12 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="LockDoorStateSensor", device_class=BinarySensorDeviceClass.DOOR, - # pylint: disable=unnecessary-lambda - measurement_to_ha=lambda x: { + measurement_to_ha={ clusters.DoorLock.Enums.DoorStateEnum.kDoorOpen: True, clusters.DoorLock.Enums.DoorStateEnum.kDoorJammed: True, clusters.DoorLock.Enums.DoorStateEnum.kDoorForcedOpen: True, clusters.DoorLock.Enums.DoorStateEnum.kDoorClosed: False, - }.get(x), + }.get, ), entity_class=MatterBinarySensor, required_attributes=(clusters.DoorLock.Attributes.DoorState,), From d06d0a8f839a3349ecc794245ff23bcdaa9e9cd5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 24 Sep 2024 14:56:46 +0200 Subject: [PATCH 1082/1309] Fix tesla_fleet climate temp high/low test (#126631) --- tests/components/tesla_fleet/test_climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/tesla_fleet/test_climate.py b/tests/components/tesla_fleet/test_climate.py index 902faaba922..75474698d09 100644 --- a/tests/components/tesla_fleet/test_climate.py +++ b/tests/components/tesla_fleet/test_climate.py @@ -418,7 +418,7 @@ async def test_climate_noscope( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("entity_id", "high", "low"), + ("entity_id", "low", "high"), [ ("climate.test_climate", 16, 28), ("climate.test_cabin_overheat_protection", 30, 40), From 03d43cf50daec9998b48b70ecba374b2d233ac8b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:58:25 +0200 Subject: [PATCH 1083/1309] Update tqdm to 4.66.5 (#126626) --- requirements_test.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index f6e824bbd7d..966373a2438 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -33,7 +33,7 @@ pytest==8.3.3 requests-mock==1.12.1 respx==0.21.1 syrupy==4.7.1 -tqdm==4.66.4 +tqdm==4.66.5 types-aiofiles==23.2.0.20240623 types-atomicwrites==1.4.5.1 types-croniter==2.0.0.20240423 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 48621bd6238..7a2a166bde0 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.4.12,source=/uv,target=/bin/uv \ --no-cache \ -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.4 ruff==0.6.6 \ + 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 LABEL "name"="hassfest" From f78b4a0feb74621bb45ecdf952683f2c93138915 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:58:45 +0200 Subject: [PATCH 1084/1309] Update pip-licenses to 5.0.0 (#126620) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 966373a2438..083fc4468f1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -17,7 +17,7 @@ pydantic==1.10.17 pylint==3.3.1 pylint-per-file-ignores==1.3.2 pipdeptree==2.23.4 -pip-licenses==4.5.1 +pip-licenses==5.0.0 pytest-asyncio==0.24.0 pytest-aiohttp==1.0.5 pytest-cov==5.0.0 From 9daf1b062fa018c190b8b2ec9def57a8ae69ed98 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:59:11 +0200 Subject: [PATCH 1085/1309] Update uv to 0.4.15 (#126627) * Update uv to 0.4.15 * Fix --- Dockerfile | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 51929f481c0..5bb0fff736f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.4.12 +RUN pip3 install uv==0.4.15 WORKDIR /usr/src diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 064034a1641..5f4c1b85aa3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -59,7 +59,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.4.12 +uv==0.4.15 voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 diff --git a/pyproject.toml b/pyproject.toml index c23da491db6..6fe1b08ef89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ dependencies = [ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2 # https://github.com/home-assistant/core/issues/97248 "urllib3>=1.26.5,<2", - "uv==0.4.12", + "uv==0.4.15", "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.0.5", diff --git a/requirements.txt b/requirements.txt index 0d1464b01b8..cf1cce5fe6d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,7 +38,7 @@ SQLAlchemy==2.0.31 typing-extensions>=4.12.2,<5.0 ulid-transform==1.0.2 urllib3>=1.26.5,<2 -uv==0.4.12 +uv==0.4.15 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.5 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 7a2a166bde0..970e987cc1d 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -14,7 +14,7 @@ WORKDIR "/github/workspace" COPY . /usr/src/homeassistant # Uv is only needed during build -RUN --mount=from=ghcr.io/astral-sh/uv:0.4.12,source=/uv,target=/bin/uv \ +RUN --mount=from=ghcr.io/astral-sh/uv:0.4.15,source=/uv,target=/bin/uv \ # Required for PyTurboJPEG apk add --no-cache libturbojpeg \ && uv pip install \ From f699a69e8312082692f350eb50c1be077424624f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 14:59:41 +0200 Subject: [PATCH 1086/1309] Update cryptography to 43.0.1 (#126628) --- 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 5f4c1b85aa3..02c4debbf66 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ bluetooth-data-tools==1.20.0 cached-ipaddress==0.6.0 certifi>=2021.5.30 ciso8601==2.3.1 -cryptography==43.0.0 +cryptography==43.0.1 dbus-fast==2.24.0 fnv-hash-fast==1.0.2 ha-av==10.1.1 diff --git a/pyproject.toml b/pyproject.toml index 6fe1b08ef89..bdf43e77cc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dependencies = [ "lru-dict==1.3.0", "PyJWT==2.9.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==43.0.0", + "cryptography==43.0.1", "Pillow==10.4.0", "pyOpenSSL==24.2.1", "orjson==3.10.7", diff --git a/requirements.txt b/requirements.txt index cf1cce5fe6d..819fcee37e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ ifaddr==0.2.0 Jinja2==3.1.4 lru-dict==1.3.0 PyJWT==2.9.0 -cryptography==43.0.0 +cryptography==43.0.1 Pillow==10.4.0 pyOpenSSL==24.2.1 orjson==3.10.7 From 81d5c22800ce8270196901d8cc400f5363f8f6e0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:00:06 +0200 Subject: [PATCH 1087/1309] Update bcrypt to 4.2.0 (#126629) --- 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 02c4debbf66..6408cf5c5e9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -14,7 +14,7 @@ async-upnp-client==0.40.0 atomicwrites-homeassistant==1.4.1 attrs==23.2.0 awesomeversion==24.6.0 -bcrypt==4.1.3 +bcrypt==4.2.0 bleak-retry-connector==3.5.0 bleak==0.22.2 bluetooth-adapters==0.19.4 diff --git a/pyproject.toml b/pyproject.toml index bdf43e77cc7..e1748dae09e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "attrs==23.2.0", "atomicwrites-homeassistant==1.4.1", "awesomeversion==24.6.0", - "bcrypt==4.1.3", + "bcrypt==4.2.0", "certifi>=2021.5.30", "ciso8601==2.3.1", "fnv-hash-fast==1.0.2", diff --git a/requirements.txt b/requirements.txt index 819fcee37e0..a1ded82471e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ async-interrupt==1.2.0 attrs==23.2.0 atomicwrites-homeassistant==1.4.1 awesomeversion==24.6.0 -bcrypt==4.1.3 +bcrypt==4.2.0 certifi>=2021.5.30 ciso8601==2.3.1 fnv-hash-fast==1.0.2 From ade4ee810b59effb6d87ab0371e43afe9a1173c5 Mon Sep 17 00:00:00 2001 From: Lenn <78048721+LennP@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:05:00 +0200 Subject: [PATCH 1088/1309] Fix motionblinds_ble sensor tests (#126635) --- tests/components/motionblinds_ble/conftest.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/components/motionblinds_ble/conftest.py b/tests/components/motionblinds_ble/conftest.py index ffd3bc5a2ab..ef4f2e1e15d 100644 --- a/tests/components/motionblinds_ble/conftest.py +++ b/tests/components/motionblinds_ble/conftest.py @@ -19,6 +19,11 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import generate_advertisement_data, generate_ble_device +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth: None) -> None: + """Auto mock bluetooth.""" + + @pytest.fixture def address() -> str: """Address fixture.""" From 622f4975ef326808d23f2711dfea143dbdc715ed Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 15:12:04 +0200 Subject: [PATCH 1089/1309] Use icon translations in Matter (#126634) --- homeassistant/components/matter/icons.json | 11 +++++++++++ homeassistant/components/matter/sensor.py | 3 --- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 94da41931de..ed0ebfce46e 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -16,6 +16,17 @@ } } } + }, + "sensor": { + "air_quality": { + "default": "mdi:air-filter" + }, + "hepa_filter_condition": { + "default": "mdi:filter-check" + }, + "activated_carbon_filter_condition": { + "default": "mdi:filter-check" + } } } } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index ee780993a55..4e9d27aed3e 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -307,7 +307,6 @@ DISCOVERY_SCHEMAS = [ # convert to set first to remove the duplicate unknown value options=list(set(AIR_QUALITY_MAP.values())), measurement_to_ha=lambda x: AIR_QUALITY_MAP[x], - icon="mdi:air-filter", ), entity_class=MatterSensor, required_attributes=(clusters.AirQuality.Attributes.AirQuality,), @@ -359,7 +358,6 @@ DISCOVERY_SCHEMAS = [ device_class=None, state_class=SensorStateClass.MEASUREMENT, translation_key="hepa_filter_condition", - icon="mdi:filter-check", ), entity_class=MatterSensor, required_attributes=(clusters.HepaFilterMonitoring.Attributes.Condition,), @@ -372,7 +370,6 @@ DISCOVERY_SCHEMAS = [ device_class=None, state_class=SensorStateClass.MEASUREMENT, translation_key="activated_carbon_filter_condition", - icon="mdi:filter-check", ), entity_class=MatterSensor, required_attributes=( From 9dc84bfdca1352cc8290b218f4439536271bc828 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:21:33 +0200 Subject: [PATCH 1090/1309] Add shorthand attributes to device_tracker entities (#126599) * Add shorthand attributes to device_tracker entities * Simplify * Update config_entry.py * Update config_entry.py * Update device_tracker.py * Update device_tracker.py --- .../components/device_tracker/config_entry.py | 49 +++++++++++++++---- .../devolo_home_network/device_tracker.py | 16 ++---- .../components/tractive/device_tracker.py | 27 +++------- 3 files changed, 50 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 0e8a9d940da..505014b3def 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -170,6 +170,7 @@ class BaseTrackerEntity(Entity): _attr_device_info: None = None _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_source_type: SourceType @cached_property def battery_level(self) -> int | None: @@ -182,6 +183,8 @@ class BaseTrackerEntity(Entity): @property def source_type(self) -> SourceType | str: """Return the source type, eg gps or router, of the device.""" + if hasattr(self, "_attr_source_type"): + return self._attr_source_type raise NotImplementedError @property @@ -195,9 +198,24 @@ class BaseTrackerEntity(Entity): return attr -class TrackerEntity(BaseTrackerEntity): +CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = { + "latitude", + "location_accuracy", + "location_name", + "longitude", +} + + +class TrackerEntity( + BaseTrackerEntity, cached_properties=CACHED_TRACKER_PROPERTIES_WITH_ATTR_ +): """Base class for a tracked device.""" + _attr_latitude: float | None = None + _attr_location_accuracy: int = 0 + _attr_location_name: str | None = None + _attr_longitude: float | None = None + @cached_property def should_poll(self) -> bool: """No polling for entities that have location pushed.""" @@ -214,22 +232,22 @@ class TrackerEntity(BaseTrackerEntity): Value in meters. """ - return 0 + return self._attr_location_accuracy @cached_property def location_name(self) -> str | None: """Return a location name for the current location of the device.""" - return None + return self._attr_location_name @cached_property def latitude(self) -> float | None: """Return latitude value of the device.""" - return None + return self._attr_latitude @cached_property def longitude(self) -> float | None: """Return longitude value of the device.""" - return None + return self._attr_longitude @property def state(self) -> str | None: @@ -266,23 +284,36 @@ class TrackerEntity(BaseTrackerEntity): return attr -class ScannerEntity(BaseTrackerEntity): +CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = { + "ip_address", + "mac_address", + "hostname", +} + + +class ScannerEntity( + BaseTrackerEntity, cached_properties=CACHED_SCANNER_PROPERTIES_WITH_ATTR_ +): """Base class for a tracked device that is on a scanned network.""" + _attr_hostname: str | None = None + _attr_ip_address: str | None = None + _attr_mac_address: str | None = None + @cached_property def ip_address(self) -> str | None: """Return the primary ip address of the device.""" - return None + return self._attr_ip_address @cached_property def mac_address(self) -> str | None: """Return the mac address of the device.""" - return None + return self._attr_mac_address @cached_property def hostname(self) -> str | None: """Return hostname of the device.""" - return None + return self._attr_hostname @property def state(self) -> str: diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 960069191ee..ce644da4e1d 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -89,6 +89,8 @@ class DevoloScannerEntity( ): """Representation of a devolo device tracker.""" + _attr_source_type = SourceType.ROUTER + def __init__( self, coordinator: DataUpdateCoordinator[list[ConnectedStationInfo]], @@ -98,7 +100,7 @@ class DevoloScannerEntity( """Initialize entity.""" super().__init__(coordinator) self._device = device - self._mac = mac + self._attr_mac_address = mac @property def extra_state_attributes(self) -> dict[str, str]: @@ -140,17 +142,7 @@ class DevoloScannerEntity( if station.mac_address == self.mac_address ) - @property - def mac_address(self) -> str: - """Return mac_address.""" - return self._mac - - @property - def source_type(self) -> SourceType: - """Return tracker source type.""" - return SourceType.ROUTER - @property def unique_id(self) -> str: """Return unique ID of the entity.""" - return f"{self._device.serial_number}_{self._mac}" + return f"{self._device.serial_number}_{self.mac_address}" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index d5d6f5f541c..f31afaf92f6 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -47,9 +47,9 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): ) self._battery_level: int | None = item.hw_info.get("battery_level") - self._latitude: float = item.pos_report["latlong"][0] - self._longitude: float = item.pos_report["latlong"][1] - self._accuracy: int = item.pos_report["pos_uncertainty"] + self._attr_latitude = item.pos_report["latlong"][0] + self._attr_longitude = item.pos_report["latlong"][1] + self._attr_location_accuracy: int = item.pos_report["pos_uncertainty"] self._source_type: str = item.pos_report["sensor_used"] self._attr_unique_id = item.trackable["_id"] @@ -62,21 +62,6 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): return SourceType.ROUTER return SourceType.GPS - @property - def latitude(self) -> float: - """Return latitude value of the device.""" - return self._latitude - - @property - def longitude(self) -> float: - """Return longitude value of the device.""" - return self._longitude - - @property - def location_accuracy(self) -> int: - """Return the gps accuracy of the device.""" - return self._accuracy - @property def battery_level(self) -> int | None: """Return the battery level of the device.""" @@ -90,9 +75,9 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity): @callback def _handle_position_update(self, event: dict[str, Any]) -> None: - self._latitude = event["latitude"] - self._longitude = event["longitude"] - self._accuracy = event["accuracy"] + self._attr_latitude = event["latitude"] + self._attr_longitude = event["longitude"] + self._attr_location_accuracy = event["accuracy"] self._source_type = event["sensor_used"] self._attr_available = True self.async_write_ha_state() From adcdb7a90040330b62f771ff6bef0f11646eb8a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 15:30:01 +0200 Subject: [PATCH 1091/1309] Map unknown air quality to None in Matter (#126639) Map unknown to None in Matter --- homeassistant/components/matter/sensor.py | 6 +++--- homeassistant/components/matter/strings.json | 3 +-- tests/components/matter/test_sensor.py | 1 - 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 4e9d27aed3e..5e02fe640ab 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -48,8 +48,8 @@ AIR_QUALITY_MAP = { clusters.AirQuality.Enums.AirQualityEnum.kFair: "fair", clusters.AirQuality.Enums.AirQualityEnum.kGood: "good", clusters.AirQuality.Enums.AirQualityEnum.kModerate: "moderate", - clusters.AirQuality.Enums.AirQualityEnum.kUnknown: "unknown", - clusters.AirQuality.Enums.AirQualityEnum.kUnknownEnumValue: "unknown", + clusters.AirQuality.Enums.AirQualityEnum.kUnknown: None, + clusters.AirQuality.Enums.AirQualityEnum.kUnknownEnumValue: None, } @@ -305,7 +305,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, state_class=None, # convert to set first to remove the duplicate unknown value - options=list(set(AIR_QUALITY_MAP.values())), + options=[x for x in AIR_QUALITY_MAP.values() if x is not None], measurement_to_ha=lambda x: AIR_QUALITY_MAP[x], ), entity_class=MatterSensor, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 14de4105f40..3ecaf6a8151 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -157,8 +157,7 @@ "poor": "Poor", "fair": "Fair", "good": "Good", - "moderate": "Moderate", - "unknown": "Unknown" + "moderate": "Moderate" } }, "flow": { diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 20ecef8609b..c8f89eb8f0c 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -499,7 +499,6 @@ async def test_air_purifier_sensor( "fair", "good", "moderate", - "unknown", ] assert set(state.attributes["options"]) == set(expected_options) assert state.attributes["device_class"] == "enum" From c289248ac5412b720090c6f6cb3d534ed3c2a7b2 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 24 Sep 2024 15:33:08 +0200 Subject: [PATCH 1092/1309] Bump Python Matter Server to 6.5.2 (#126636) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 5488df01e4e..24229fad5d9 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==6.3.0"], + "requirements": ["python-matter-server==6.5.2"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index f2e97b23d0b..2c3dd552ffe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2349,7 +2349,7 @@ python-linkplay==0.0.9 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==6.3.0 +python-matter-server==6.5.2 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 006c4ff4a33..15269a017f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1867,7 +1867,7 @@ python-kasa[speedups]==0.7.3 python-linkplay==0.0.9 # homeassistant.components.matter -python-matter-server==6.3.0 +python-matter-server==6.5.2 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From 2fa711378799efadcaeb2dd0a687aa8003e6f530 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 15:35:10 +0200 Subject: [PATCH 1093/1309] Raise issue if SSL is set but no external URL configured (#121768) * Raise issue if SSL is set but no external URL configured * Add cloud * Add cloud * Fix strings * Attempt * Fix * Fix * Move strings * Fixes * fix * Fix * Fix * Fix * Break tests * Fix tests --- homeassistant/components/http/__init__.py | 34 +++- homeassistant/components/http/strings.json | 8 + tests/components/http/test_init.py | 151 +++++++++++++++++- .../components/logbook/test_websocket_api.py | 4 + 4 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/http/strings.json diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 5b68f91e494..a8721720dfb 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -30,10 +30,14 @@ import voluptuous as vol from yarl import URL from homeassistant.components.network import async_get_source_ip -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, + SERVER_PORT, +) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import frame, storage +from homeassistant.helpers import frame, issue_registry as ir, storage import homeassistant.helpers.config_validation as cv from homeassistant.helpers.http import ( KEY_ALLOW_CONFIGURED_CORS, @@ -264,6 +268,32 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: local_ip, host, server_port, ssl_certificate is not None ) + @callback + def _async_check_ssl_issue(_: Event) -> None: + if ( + ssl_certificate is not None + and (hass.config.external_url or hass.config.internal_url) is None + ): + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.cloud import ( + CloudNotAvailable, + async_remote_ui_url, + ) + + try: + async_remote_ui_url(hass) + except CloudNotAvailable: + ir.async_create_issue( + hass, + DOMAIN, + "ssl_configured_without_configured_urls", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="ssl_configured_without_configured_urls", + ) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_check_ssl_issue) + return True diff --git a/homeassistant/components/http/strings.json b/homeassistant/components/http/strings.json new file mode 100644 index 00000000000..5dbd8faec20 --- /dev/null +++ b/homeassistant/components/http/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "ssl_configured_without_configured_urls": { + "title": "SSL is configured without an external URL or internal URL", + "description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration." + } + } +} diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 2895209b5f9..4d96f2267fa 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -12,8 +12,10 @@ from unittest.mock import Mock, patch import pytest from homeassistant.auth.providers.homeassistant import HassAuthProvider -from homeassistant.components import http +from homeassistant.components import cloud, http +from homeassistant.components.cloud import CloudNotAvailable from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.http import KEY_HASS from homeassistant.helpers.network import NoURLAvailableError from homeassistant.setup import async_setup_component @@ -545,3 +547,150 @@ async def test_register_static_paths( "event loop, instead call " "`await hass.http.async_register_static_paths" ) in caplog.text + + +async def test_ssl_issue_if_no_urls_configured( + hass: HomeAssistant, + tmp_path: Path, + issue_registry: ir.IssueRegistry, +) -> None: + """Test raising SSL issue if no external or internal URL is configured.""" + + assert hass.config.external_url is None + assert hass.config.internal_url is None + + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmp_path + ) + + with ( + patch("ssl.SSLContext.load_cert_chain"), + patch( + "homeassistant.util.ssl.server_context_modern", + side_effect=server_context_modern, + ), + ): + assert await async_setup_component( + hass, + "http", + {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}, + ) + await hass.async_start() + await hass.async_block_till_done() + + assert ("http", "ssl_configured_without_configured_urls") in issue_registry.issues + + +async def test_ssl_issue_if_using_cloud( + hass: HomeAssistant, + tmp_path: Path, + issue_registry: ir.IssueRegistry, +) -> None: + """Test raising no SSL issue if not right configured but using cloud.""" + assert hass.config.external_url is None + assert hass.config.internal_url is None + + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmp_path + ) + + with ( + patch("ssl.SSLContext.load_cert_chain"), + patch.object(cloud, "async_remote_ui_url", return_value="https://example.com"), + patch( + "homeassistant.util.ssl.server_context_modern", + side_effect=server_context_modern, + ), + ): + assert await async_setup_component( + hass, + "http", + {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}, + ) + await hass.async_start() + await hass.async_block_till_done() + + assert ( + "http", + "ssl_configured_without_configured_urls", + ) not in issue_registry.issues + + +async def test_ssl_issue_if_not_connected_to_cloud( + hass: HomeAssistant, + tmp_path: Path, + issue_registry: ir.IssueRegistry, +) -> None: + """Test raising no SSL issue if not right configured and not connected to cloud.""" + assert hass.config.external_url is None + assert hass.config.internal_url is None + + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmp_path + ) + + with ( + patch("ssl.SSLContext.load_cert_chain"), + patch( + "homeassistant.util.ssl.server_context_modern", + side_effect=server_context_modern, + ), + patch( + "homeassistant.components.cloud.async_remote_ui_url", + side_effect=CloudNotAvailable, + ), + ): + assert await async_setup_component( + hass, + "http", + {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}, + ) + await hass.async_start() + await hass.async_block_till_done() + + assert ("http", "ssl_configured_without_configured_urls") in issue_registry.issues + + +@pytest.mark.parametrize( + ("external_url", "internal_url"), + [ + ("https://example.com", "https://example.local"), + (None, "http://example.local"), + ("https://example.com", None), + ], +) +async def test_ssl_issue_urls_configured( + hass: HomeAssistant, + tmp_path: Path, + issue_registry: ir.IssueRegistry, + external_url: str | None, + internal_url: str | None, +) -> None: + """Test raising SSL issue if no external or internal URL is configured.""" + + cert_path, key_path, _ = await hass.async_add_executor_job( + _setup_empty_ssl_pem_files, tmp_path + ) + + hass.config.external_url = external_url + hass.config.internal_url = internal_url + + with ( + patch("ssl.SSLContext.load_cert_chain"), + patch( + "homeassistant.util.ssl.server_context_modern", + side_effect=server_context_modern, + ), + ): + assert await async_setup_component( + hass, + "http", + {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}, + ) + await hass.async_start() + await hass.async_block_till_done() + + assert ( + "http", + "ssl_configured_without_configured_urls", + ) not in issue_registry.issues diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index e5649564f94..2a97556f5ad 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -1181,6 +1181,10 @@ async def test_subscribe_unsubscribe_logbook_stream( await async_wait_recording_done(hass) websocket_client = await hass_ws_client() init_listeners = hass.bus.async_listeners() + init_listeners = { + **init_listeners, + EVENT_HOMEASSISTANT_START: init_listeners[EVENT_HOMEASSISTANT_START] - 1, + } await websocket_client.send_json( {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} ) From 751794890028067cbc99240e48b2a549b0b6d71b Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 24 Sep 2024 09:47:29 -0400 Subject: [PATCH 1094/1309] Replace more addon management with aiohasupervisor (#126236) * Replace start_addon with library call * restart_addon to library and error issues in tests * stop_addon to library * uninstall_addon to library * Add output typing Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/hassio/__init__.py | 4 -- .../components/hassio/addon_manager.py | 12 ++--- homeassistant/components/hassio/handler.py | 48 ------------------- tests/components/conftest.py | 33 +++++-------- tests/components/hassio/common.py | 36 +------------- tests/components/hassio/test_addon_manager.py | 27 ++++++----- .../homeassistant_hardware/conftest.py | 9 ---- .../test_silabs_multiprotocol_addon.py | 25 +++++----- .../homeassistant_sky_connect/conftest.py | 9 ---- tests/components/matter/test_config_flow.py | 31 ++++++------ tests/components/matter/test_init.py | 27 ++++++----- tests/components/mqtt/test_config_flow.py | 5 +- tests/components/zwave_js/test_config_flow.py | 30 ++++++------ tests/components/zwave_js/test_init.py | 25 +++++----- 14 files changed, 106 insertions(+), 215 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 73e3ae5d7ff..7aa4285314d 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -107,13 +107,9 @@ from .handler import ( # noqa: F401 async_get_yellow_settings, async_install_addon, async_reboot_host, - async_restart_addon, async_set_addon_options, async_set_green_settings, async_set_yellow_settings, - async_start_addon, - async_stop_addon, - async_uninstall_addon, async_update_addon, async_update_core, async_update_diagnostics, diff --git a/homeassistant/components/hassio/addon_manager.py b/homeassistant/components/hassio/addon_manager.py index 01babdc3a33..1d51ef30e0f 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -25,11 +25,7 @@ from .handler import ( async_get_addon_discovery_info, async_get_addon_store_info, async_install_addon, - async_restart_addon, async_set_addon_options, - async_start_addon, - async_stop_addon, - async_uninstall_addon, async_update_addon, get_supervisor_client, ) @@ -208,7 +204,7 @@ class AddonManager: @api_error("Failed to uninstall the {addon_name} add-on") async def async_uninstall_addon(self) -> None: """Uninstall the managed add-on.""" - await async_uninstall_addon(self._hass, self.addon_slug) + await get_supervisor_client(self._hass).addons.uninstall_addon(self.addon_slug) @api_error("Failed to update the {addon_name} add-on") async def async_update_addon(self) -> None: @@ -229,17 +225,17 @@ class AddonManager: @api_error("Failed to start the {addon_name} add-on") async def async_start_addon(self) -> None: """Start the managed add-on.""" - await async_start_addon(self._hass, self.addon_slug) + await get_supervisor_client(self._hass).addons.start_addon(self.addon_slug) @api_error("Failed to restart the {addon_name} add-on") async def async_restart_addon(self) -> None: """Restart the managed add-on.""" - await async_restart_addon(self._hass, self.addon_slug) + await get_supervisor_client(self._hass).addons.restart_addon(self.addon_slug) @api_error("Failed to stop the {addon_name} add-on") async def async_stop_addon(self) -> None: """Stop the managed add-on.""" - await async_stop_addon(self._hass, self.addon_slug) + await get_supervisor_client(self._hass).addons.stop_addon(self.addon_slug) @api_error("Failed to create a backup of the {addon_name} add-on") async def async_create_backup(self) -> None: diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 8db1c616512..afa5cb31aba 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -96,18 +96,6 @@ async def async_install_addon(hass: HomeAssistant, slug: str) -> dict: return await hassio.send_command(command, timeout=None) -@bind_hass -@api_data -async def async_uninstall_addon(hass: HomeAssistant, slug: str) -> dict: - """Uninstall add-on. - - The caller of the function should handle HassioAPIError. - """ - hassio: HassIO = hass.data[DOMAIN] - command = f"/addons/{slug}/uninstall" - return await hassio.send_command(command, timeout=60) - - @bind_hass @api_data async def async_update_addon( @@ -128,42 +116,6 @@ async def async_update_addon( ) -@bind_hass -@api_data -async def async_start_addon(hass: HomeAssistant, slug: str) -> dict: - """Start add-on. - - The caller of the function should handle HassioAPIError. - """ - hassio: HassIO = hass.data[DOMAIN] - command = f"/addons/{slug}/start" - return await hassio.send_command(command, timeout=60) - - -@bind_hass -@api_data -async def async_restart_addon(hass: HomeAssistant, slug: str) -> dict: - """Restart add-on. - - The caller of the function should handle HassioAPIError. - """ - hassio: HassIO = hass.data[DOMAIN] - command = f"/addons/{slug}/restart" - return await hassio.send_command(command, timeout=None) - - -@bind_hass -@api_data -async def async_stop_addon(hass: HomeAssistant, slug: str) -> dict: - """Stop add-on. - - The caller of the function should handle HassioAPIError. - """ - hassio: HassIO = hass.data[DOMAIN] - command = f"/addons/{slug}/stop" - return await hassio.send_command(command, timeout=60) - - @bind_hass @api_data async def async_set_addon_options( diff --git a/tests/components/conftest.py b/tests/components/conftest.py index e6c685a1342..5ac9ba8ec6c 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -321,12 +321,12 @@ def start_addon_side_effect_fixture( @pytest.fixture(name="start_addon") -def start_addon_fixture(start_addon_side_effect: Any | None) -> Generator[AsyncMock]: +def start_addon_fixture( + supervisor_client: AsyncMock, start_addon_side_effect: Any | None +) -> AsyncMock: """Mock start add-on.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_start_addon - - yield from mock_start_addon(start_addon_side_effect) + supervisor_client.addons.start_addon.side_effect = start_addon_side_effect + return supervisor_client.addons.start_addon @pytest.fixture(name="restart_addon_side_effect") @@ -337,22 +337,18 @@ def restart_addon_side_effect_fixture() -> Any | None: @pytest.fixture(name="restart_addon") def restart_addon_fixture( + supervisor_client: AsyncMock, restart_addon_side_effect: Any | None, -) -> Generator[AsyncMock]: +) -> AsyncMock: """Mock restart add-on.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_restart_addon - - yield from mock_restart_addon(restart_addon_side_effect) + supervisor_client.addons.restart_addon.side_effect = restart_addon_side_effect + return supervisor_client.addons.restart_addon @pytest.fixture(name="stop_addon") -def stop_addon_fixture() -> Generator[AsyncMock]: +def stop_addon_fixture(supervisor_client: AsyncMock) -> AsyncMock: """Mock stop add-on.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_stop_addon - - yield from mock_stop_addon() + return supervisor_client.addons.stop_addon @pytest.fixture(name="addon_options") @@ -387,12 +383,9 @@ def set_addon_options_fixture( @pytest.fixture(name="uninstall_addon") -def uninstall_addon_fixture() -> Generator[AsyncMock]: +def uninstall_addon_fixture(supervisor_client: AsyncMock) -> AsyncMock: """Mock uninstall add-on.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_uninstall_addon - - yield from mock_uninstall_addon() + return supervisor_client.addons.uninstall_addon @pytest.fixture(name="create_backup") diff --git a/tests/components/hassio/common.py b/tests/components/hassio/common.py index 8aee2b35a5f..0a990a0db3f 100644 --- a/tests/components/hassio/common.py +++ b/tests/components/hassio/common.py @@ -166,7 +166,7 @@ def mock_start_addon_side_effect( ) -> Any | None: """Return the start add-on options side effect.""" - async def start_addon(hass: HomeAssistant, slug): + async def start_addon(addon: str) -> None: """Mock start add-on.""" addon_store_info.return_value = { "available": True, @@ -180,40 +180,6 @@ def mock_start_addon_side_effect( return start_addon -def mock_start_addon(start_addon_side_effect: Any | None) -> Generator[AsyncMock]: - """Mock start add-on.""" - with patch( - "homeassistant.components.hassio.addon_manager.async_start_addon", - side_effect=start_addon_side_effect, - ) as start_addon: - yield start_addon - - -def mock_stop_addon() -> Generator[AsyncMock]: - """Mock stop add-on.""" - with patch( - "homeassistant.components.hassio.addon_manager.async_stop_addon" - ) as stop_addon: - yield stop_addon - - -def mock_restart_addon(restart_addon_side_effect: Any | None) -> Generator[AsyncMock]: - """Mock restart add-on.""" - with patch( - "homeassistant.components.hassio.addon_manager.async_restart_addon", - side_effect=restart_addon_side_effect, - ) as restart_addon: - yield restart_addon - - -def mock_uninstall_addon() -> Generator[AsyncMock]: - """Mock uninstall add-on.""" - with patch( - "homeassistant.components.hassio.addon_manager.async_uninstall_addon" - ) as uninstall_addon: - yield uninstall_addon - - def mock_addon_options(addon_info: AsyncMock) -> dict[str, Any]: """Mock add-on options.""" return addon_info.return_value.options diff --git a/tests/components/hassio/test_addon_manager.py b/tests/components/hassio/test_addon_manager.py index c1b47f67d3c..09a7475ae10 100644 --- a/tests/components/hassio/test_addon_manager.py +++ b/tests/components/hassio/test_addon_manager.py @@ -6,6 +6,7 @@ import asyncio from typing import Any from unittest.mock import AsyncMock, call +from aiohasupervisor import SupervisorError import pytest from homeassistant.components.hassio.addon_manager import ( @@ -136,7 +137,7 @@ async def test_get_addon_info( "addon_store_info_error", "addon_store_info_calls", ), - [(HassioAPIError("Boom"), 1, None, 1), (None, 0, HassioAPIError("Boom"), 1)], + [(SupervisorError("Boom"), 1, None, 1), (None, 0, HassioAPIError("Boom"), 1)], ) async def test_get_addon_info_error( addon_manager: AddonManager, @@ -303,7 +304,7 @@ async def test_uninstall_addon_error( addon_manager: AddonManager, uninstall_addon: AsyncMock ) -> None: """Test uninstall addon raises error.""" - uninstall_addon.side_effect = HassioAPIError("Boom") + uninstall_addon.side_effect = SupervisorError("Boom") with pytest.raises(AddonError) as err: await addon_manager.async_uninstall_addon() @@ -324,7 +325,7 @@ async def test_start_addon_error( addon_manager: AddonManager, start_addon: AsyncMock ) -> None: """Test start addon raises error.""" - start_addon.side_effect = HassioAPIError("Boom") + start_addon.side_effect = SupervisorError("Boom") with pytest.raises(AddonError) as err: await addon_manager.async_start_addon() @@ -366,7 +367,7 @@ async def test_schedule_start_addon_error( start_addon: AsyncMock, ) -> None: """Test schedule start addon raises error.""" - start_addon.side_effect = HassioAPIError("Boom") + start_addon.side_effect = SupervisorError("Boom") with pytest.raises(AddonError) as err: await addon_manager.async_schedule_start_addon() @@ -383,7 +384,7 @@ async def test_schedule_start_addon_logs_error( caplog: pytest.LogCaptureFixture, ) -> None: """Test schedule start addon logs error.""" - start_addon.side_effect = HassioAPIError("Boom") + start_addon.side_effect = SupervisorError("Boom") await addon_manager.async_schedule_start_addon(catch_error=True) @@ -404,7 +405,7 @@ async def test_restart_addon_error( addon_manager: AddonManager, restart_addon: AsyncMock ) -> None: """Test restart addon raises error.""" - restart_addon.side_effect = HassioAPIError("Boom") + restart_addon.side_effect = SupervisorError("Boom") with pytest.raises(AddonError) as err: await addon_manager.async_restart_addon() @@ -446,7 +447,7 @@ async def test_schedule_restart_addon_error( restart_addon: AsyncMock, ) -> None: """Test schedule restart addon raises error.""" - restart_addon.side_effect = HassioAPIError("Boom") + restart_addon.side_effect = SupervisorError("Boom") with pytest.raises(AddonError) as err: await addon_manager.async_schedule_restart_addon() @@ -463,7 +464,7 @@ async def test_schedule_restart_addon_logs_error( caplog: pytest.LogCaptureFixture, ) -> None: """Test schedule restart addon logs error.""" - restart_addon.side_effect = HassioAPIError("Boom") + restart_addon.side_effect = SupervisorError("Boom") await addon_manager.async_schedule_restart_addon(catch_error=True) @@ -482,7 +483,7 @@ async def test_stop_addon_error( addon_manager: AddonManager, stop_addon: AsyncMock ) -> None: """Test stop addon raises error.""" - stop_addon.side_effect = HassioAPIError("Boom") + stop_addon.side_effect = SupervisorError("Boom") with pytest.raises(AddonError) as err: await addon_manager.async_stop_addon() @@ -811,7 +812,7 @@ async def test_schedule_install_setup_addon( 1, None, 1, - HassioAPIError("Boom"), + SupervisorError("Boom"), 1, "Failed to start the Test add-on: Boom", ), @@ -880,7 +881,7 @@ async def test_schedule_install_setup_addon_error( 1, None, 1, - HassioAPIError("Boom"), + SupervisorError("Boom"), 1, "Failed to start the Test add-on: Boom", ), @@ -964,7 +965,7 @@ async def test_schedule_setup_addon( ( None, 1, - HassioAPIError("Boom"), + SupervisorError("Boom"), 1, "Failed to start the Test add-on: Boom", ), @@ -1013,7 +1014,7 @@ async def test_schedule_setup_addon_error( ( None, 1, - HassioAPIError("Boom"), + SupervisorError("Boom"), 1, "Failed to start the Test add-on: Boom", ), diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index c63dca74391..ddf18305b2a 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -47,12 +47,3 @@ def mock_zha_get_last_network_settings() -> Generator[None]: AsyncMock(return_value=None), ): yield - - -@pytest.fixture(name="stop_addon") -def stop_addon_fixture(): - """Mock stop add-on.""" - with patch( - "homeassistant.components.hassio.addon_manager.async_stop_addon" - ) as stop_addon: - yield stop_addon diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 7d4b1dc9df0..f2d9c0f10ad 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -6,6 +6,7 @@ from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock, Mock, patch +from aiohasupervisor import SupervisorError import pytest from homeassistant.components.hassio import ( @@ -265,7 +266,7 @@ async def test_option_flow_install_multi_pan_addon( ) await hass.async_block_till_done() - start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + start_addon.assert_called_once_with("core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -360,7 +361,7 @@ async def test_option_flow_install_multi_pan_addon_zha( assert zha_config_entry.title == "Test Multiprotocol" await hass.async_block_till_done() - start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + start_addon.assert_called_once_with("core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -436,7 +437,7 @@ async def test_option_flow_install_multi_pan_addon_zha_other_radio( ) await hass.async_block_till_done() - start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + start_addon.assert_called_once_with("core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -699,7 +700,7 @@ async def test_option_flow_addon_installed_same_device_uninstall( assert result["progress_action"] == "uninstall_multiprotocol_addon" await hass.async_block_till_done() - uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + uninstall_addon.assert_called_once_with("core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS @@ -864,7 +865,7 @@ async def test_option_flow_addon_installed_same_device_flasher_already_installed assert result["progress_action"] == "uninstall_multiprotocol_addon" await hass.async_block_till_done() - uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + uninstall_addon.assert_called_once_with("core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS @@ -996,10 +997,10 @@ async def test_option_flow_flasher_addon_flash_failure( assert result["step_id"] == "uninstall_multiprotocol_addon" assert result["progress_action"] == "uninstall_multiprotocol_addon" - start_addon.side_effect = HassioAPIError("Boom") + start_addon.side_effect = SupervisorError("Boom") await hass.async_block_till_done() - uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + uninstall_addon.assert_called_once_with("core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS @@ -1133,7 +1134,7 @@ async def test_option_flow_uninstall_migration_finish_failure( ) await hass.async_block_till_done() - uninstall_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + uninstall_addon.assert_called_once_with("core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.SHOW_PROGRESS @@ -1230,7 +1231,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( ) -> None: """Test installing the multi pan addon.""" - start_addon.side_effect = HassioAPIError("Boom") + start_addon.side_effect = SupervisorError("Boom") # Setup the config entry config_entry = MockConfigEntry( @@ -1275,7 +1276,7 @@ async def test_option_flow_install_multi_pan_addon_start_fails( ) await hass.async_block_till_done() - start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + start_addon.assert_called_once_with("core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT @@ -1470,7 +1471,7 @@ async def test_option_flow_install_multi_pan_addon_zha_migration_fails_step_2( ) await hass.async_block_till_done() - start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + start_addon.assert_called_once_with("core_silabs_multiprotocol") result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.ABORT @@ -1678,7 +1679,7 @@ async def test_check_multi_pan_addon_auto_start( with pytest.raises(HomeAssistantError): await silabs_multiprotocol_addon.check_multi_pan_addon(hass) - start_addon.assert_called_once_with(hass, "core_silabs_multiprotocol") + start_addon.assert_called_once_with("core_silabs_multiprotocol") async def test_check_multi_pan_addon( diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index d71bf4305b3..c5bfa4bd609 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -47,12 +47,3 @@ def mock_zha_get_last_network_settings() -> Generator[None]: AsyncMock(return_value=None), ): yield - - -@pytest.fixture(name="stop_addon") -def stop_addon_fixture(): - """Mock stop add-on.""" - with patch( - "homeassistant.components.hassio.addon_manager.async_stop_addon" - ) as stop_addon: - yield stop_addon diff --git a/tests/components/matter/test_config_flow.py b/tests/components/matter/test_config_flow.py index a4ddc18802f..fb132c8972f 100644 --- a/tests/components/matter/test_config_flow.py +++ b/tests/components/matter/test_config_flow.py @@ -6,6 +6,7 @@ from collections.abc import Generator from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, call, patch +from aiohasupervisor import SupervisorError from matter_server.client.exceptions import CannotConnect, InvalidServerVersion import pytest @@ -380,7 +381,7 @@ async def test_zeroconf_not_onboarded_installed( await hass.async_block_till_done() assert addon_info.call_count == 1 - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") assert client_connect.call_count == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Matter" @@ -418,7 +419,7 @@ async def test_zeroconf_not_onboarded_not_installed( assert addon_info.call_count == 0 assert addon_store_info.call_count == 2 assert install_addon.call_args == call(hass, "core_matter_server") - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") assert client_connect.call_count == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Matter" @@ -468,7 +469,7 @@ async def test_supervisor_discovery( @pytest.mark.parametrize( ("discovery_info", "error"), - [({"config": ADDON_DISCOVERY_INFO}, HassioAPIError())], + [({"config": ADDON_DISCOVERY_INFO}, SupervisorError())], ) async def test_supervisor_discovery_addon_info_failed( hass: HomeAssistant, @@ -682,7 +683,7 @@ async def test_supervisor_discovery_addon_not_running( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") assert client_connect.call_count == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Matter" @@ -740,7 +741,7 @@ async def test_supervisor_discovery_addon_not_installed( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") assert client_connect.call_count == 1 assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Matter" @@ -868,7 +869,7 @@ async def test_addon_running( {"config": ADDON_DISCOVERY_INFO}, None, None, - HassioAPIError(), + SupervisorError(), "addon_info_failed", False, False, @@ -954,7 +955,7 @@ async def test_addon_running_failures( {"config": ADDON_DISCOVERY_INFO}, None, None, - HassioAPIError(), + SupervisorError(), "addon_info_failed", False, False, @@ -1062,7 +1063,7 @@ async def test_addon_installed( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Matter" assert result["data"] == { @@ -1084,7 +1085,7 @@ async def test_addon_installed( [ ( {"config": ADDON_DISCOVERY_INFO}, - HassioAPIError(), + SupervisorError(), None, False, False, @@ -1140,7 +1141,7 @@ async def test_addon_installed_failures( await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") assert get_addon_discovery_info.called is discovery_info_called assert client_connect.called is client_connect_called assert result["type"] is FlowResultType.ABORT @@ -1159,7 +1160,7 @@ async def test_addon_installed_failures( [ ( {"config": ADDON_DISCOVERY_INFO}, - HassioAPIError(), + SupervisorError(), None, False, False, @@ -1205,7 +1206,7 @@ async def test_addon_installed_failures_zeroconf( await hass.async_block_till_done() assert addon_info.call_count == 1 - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") assert get_addon_discovery_info.called is discovery_info_called assert client_connect.called is client_connect_called assert result["type"] is FlowResultType.ABORT @@ -1250,7 +1251,7 @@ async def test_addon_installed_already_configured( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfiguration_successful" assert entry.data["url"] == "ws://host1:5581/ws" @@ -1298,7 +1299,7 @@ async def test_addon_not_installed( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Matter" assert result["data"] == { @@ -1417,7 +1418,7 @@ async def test_addon_not_installed_already_configured( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") assert client_connect.call_count == 1 assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfiguration_successful" diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index 1296604f390..099376abd07 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, call, patch +from aiohasupervisor import SupervisorError from matter_server.client.exceptions import ( CannotConnect, ServerVersionTooNew, @@ -298,7 +299,7 @@ async def test_start_addon( assert addon_info.call_count == 1 assert install_addon.call_count == 0 assert start_addon.call_count == 1 - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") async def test_install_addon( @@ -327,7 +328,7 @@ async def test_install_addon( assert install_addon.call_count == 1 assert install_addon.call_args == call(hass, "core_matter_server") assert start_addon.call_count == 1 - assert start_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call("core_matter_server") async def test_addon_info_failure( @@ -338,7 +339,7 @@ async def test_addon_info_failure( start_addon: AsyncMock, ) -> None: """Test failure to get add-on info for Matter add-on during entry setup.""" - addon_info.side_effect = HassioAPIError("Boom") + addon_info.side_effect = SupervisorError("Boom") entry = MockConfigEntry( domain=DOMAIN, title="Matter", @@ -492,7 +493,7 @@ async def test_issue_registry_invalid_version( ("stop_addon_side_effect", "entry_state"), [ (None, ConfigEntryState.NOT_LOADED), - (HassioAPIError("Boom"), ConfigEntryState.LOADED), + (SupervisorError("Boom"), ConfigEntryState.LOADED), ], ) async def test_stop_addon( @@ -531,7 +532,7 @@ async def test_stop_addon( assert entry.state == entry_state assert stop_addon.call_count == 1 - assert stop_addon.call_args == call(hass, "core_matter_server") + assert stop_addon.call_args == call("core_matter_server") async def test_remove_entry( @@ -570,7 +571,7 @@ async def test_remove_entry( await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 - assert stop_addon.call_args == call(hass, "core_matter_server") + assert stop_addon.call_args == call("core_matter_server") assert create_backup.call_count == 1 assert create_backup.call_args == call( hass, @@ -578,7 +579,7 @@ async def test_remove_entry( partial=True, ) assert uninstall_addon.call_count == 1 - assert uninstall_addon.call_args == call(hass, "core_matter_server") + assert uninstall_addon.call_args == call("core_matter_server") assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 stop_addon.reset_mock() @@ -588,12 +589,12 @@ async def test_remove_entry( # test add-on stop failure entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - stop_addon.side_effect = HassioAPIError() + stop_addon.side_effect = SupervisorError() await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 - assert stop_addon.call_args == call(hass, "core_matter_server") + assert stop_addon.call_args == call("core_matter_server") assert create_backup.call_count == 0 assert uninstall_addon.call_count == 0 assert entry.state is ConfigEntryState.NOT_LOADED @@ -612,7 +613,7 @@ async def test_remove_entry( await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 - assert stop_addon.call_args == call(hass, "core_matter_server") + assert stop_addon.call_args == call("core_matter_server") assert create_backup.call_count == 1 assert create_backup.call_args == call( hass, @@ -631,12 +632,12 @@ async def test_remove_entry( # test add-on uninstall failure entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - uninstall_addon.side_effect = HassioAPIError() + uninstall_addon.side_effect = SupervisorError() await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 - assert stop_addon.call_args == call(hass, "core_matter_server") + assert stop_addon.call_args == call("core_matter_server") assert create_backup.call_count == 1 assert create_backup.call_args == call( hass, @@ -644,7 +645,7 @@ async def test_remove_entry( partial=True, ) assert uninstall_addon.call_count == 1 - assert uninstall_addon.call_args == call(hass, "core_matter_server") + assert uninstall_addon.call_args == call("core_matter_server") assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to uninstall the Matter Server add-on" in caplog.text diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 70231cc6115..6812ab39247 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -8,6 +8,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 +from aiohasupervisor import SupervisorError import pytest import voluptuous as vol @@ -671,7 +672,7 @@ async def test_addon_not_running_api_error( Case: The Mosquitto add-on start fails on a API error. """ - start_addon.side_effect = HassioAPIError() + start_addon.side_effect = SupervisorError() result = await hass.config_entries.flow.async_init( "mqtt", context={"source": config_entries.SOURCE_USER} @@ -758,7 +759,7 @@ async def test_addon_info_error( Case: The Mosquitto add-on info could not be retrieved. """ - addon_info.side_effect = AddonError() + addon_info.side_effect = SupervisorError() result = await hass.config_entries.flow.async_init( "mqtt", context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index d6081d24b18..d9111d0cb4c 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -632,7 +632,7 @@ async def test_usb_discovery( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE @@ -733,7 +733,7 @@ async def test_usb_discovery_addon_not_running( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE @@ -828,7 +828,7 @@ async def test_discovery_addon_not_running( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE @@ -931,7 +931,7 @@ async def test_discovery_addon_not_installed( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE @@ -1344,7 +1344,7 @@ async def test_addon_installed( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE @@ -1428,7 +1428,7 @@ async def test_addon_installed_start_failure( await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_start_failed" @@ -1507,7 +1507,7 @@ async def test_addon_installed_failures( await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_start_failed" @@ -1655,7 +1655,7 @@ async def test_addon_installed_already_configured( await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @@ -1750,7 +1750,7 @@ async def test_addon_not_installed( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == TITLE @@ -2007,7 +2007,7 @@ async def test_options_addon_running( result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done() - assert restart_addon.call_args == call(hass, "core_zwave_js") + assert restart_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.CREATE_ENTRY assert entry.data["url"] == "ws://host1:3001" @@ -2286,7 +2286,7 @@ async def test_options_different_device( await hass.async_block_till_done() assert restart_addon.call_count == 1 - assert restart_addon.call_args == call(hass, "core_zwave_js") + assert restart_addon.call_args == call("core_zwave_js") result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done() @@ -2308,7 +2308,7 @@ async def test_options_different_device( await hass.async_block_till_done() assert restart_addon.call_count == 2 - assert restart_addon.call_args == call(hass, "core_zwave_js") + assert restart_addon.call_args == call("core_zwave_js") result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done() @@ -2452,7 +2452,7 @@ async def test_options_addon_restart_failed( await hass.async_block_till_done() assert restart_addon.call_count == 1 - assert restart_addon.call_args == call(hass, "core_zwave_js") + assert restart_addon.call_args == call("core_zwave_js") result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done() @@ -2471,7 +2471,7 @@ async def test_options_addon_restart_failed( await hass.async_block_till_done() assert restart_addon.call_count == 2 - assert restart_addon.call_args == call(hass, "core_zwave_js") + assert restart_addon.call_args == call("core_zwave_js") result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done() @@ -2709,7 +2709,7 @@ async def test_options_addon_not_installed( await hass.async_block_till_done() assert start_addon.call_count == 1 - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") result = await hass.config_entries.options.async_configure(result["flow_id"]) await hass.async_block_till_done() diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index a83ed2603dc..4c77d6d3c41 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -5,6 +5,7 @@ from copy import deepcopy import logging from unittest.mock import AsyncMock, call, patch +from aiohasupervisor import SupervisorError import pytest from zwave_js_server.client import Client from zwave_js_server.event import Event @@ -556,7 +557,7 @@ async def test_start_addon( hass, "core_zwave_js", {"options": addon_options} ) assert start_addon.call_count == 1 - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") async def test_install_addon( @@ -605,7 +606,7 @@ async def test_install_addon( hass, "core_zwave_js", {"options": addon_options} ) assert start_addon.call_count == 1 - assert start_addon.call_args == call(hass, "core_zwave_js") + assert start_addon.call_args == call("core_zwave_js") @pytest.mark.parametrize("addon_info_side_effect", [HassioAPIError("Boom")]) @@ -845,7 +846,7 @@ async def test_issue_registry( ("stop_addon_side_effect", "entry_state"), [ (None, ConfigEntryState.NOT_LOADED), - (HassioAPIError("Boom"), ConfigEntryState.LOADED), + (SupervisorError("Boom"), ConfigEntryState.LOADED), ], ) async def test_stop_addon( @@ -888,7 +889,7 @@ async def test_stop_addon( assert entry.state == entry_state assert stop_addon.call_count == 1 - assert stop_addon.call_args == call(hass, "core_zwave_js") + assert stop_addon.call_args == call("core_zwave_js") async def test_remove_entry( @@ -927,7 +928,7 @@ async def test_remove_entry( await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 - assert stop_addon.call_args == call(hass, "core_zwave_js") + assert stop_addon.call_args == call("core_zwave_js") assert create_backup.call_count == 1 assert create_backup.call_args == call( hass, @@ -935,7 +936,7 @@ async def test_remove_entry( partial=True, ) assert uninstall_addon.call_count == 1 - assert uninstall_addon.call_args == call(hass, "core_zwave_js") + assert uninstall_addon.call_args == call("core_zwave_js") assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 stop_addon.reset_mock() @@ -945,12 +946,12 @@ async def test_remove_entry( # test add-on stop failure entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - stop_addon.side_effect = HassioAPIError() + stop_addon.side_effect = SupervisorError() await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 - assert stop_addon.call_args == call(hass, "core_zwave_js") + assert stop_addon.call_args == call("core_zwave_js") assert create_backup.call_count == 0 assert uninstall_addon.call_count == 0 assert entry.state is ConfigEntryState.NOT_LOADED @@ -969,7 +970,7 @@ async def test_remove_entry( await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 - assert stop_addon.call_args == call(hass, "core_zwave_js") + assert stop_addon.call_args == call("core_zwave_js") assert create_backup.call_count == 1 assert create_backup.call_args == call( hass, @@ -988,12 +989,12 @@ async def test_remove_entry( # test add-on uninstall failure entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - uninstall_addon.side_effect = HassioAPIError() + uninstall_addon.side_effect = SupervisorError() await hass.config_entries.async_remove(entry.entry_id) assert stop_addon.call_count == 1 - assert stop_addon.call_args == call(hass, "core_zwave_js") + assert stop_addon.call_args == call("core_zwave_js") assert create_backup.call_count == 1 assert create_backup.call_args == call( hass, @@ -1001,7 +1002,7 @@ async def test_remove_entry( partial=True, ) assert uninstall_addon.call_count == 1 - assert uninstall_addon.call_args == call(hass, "core_zwave_js") + assert uninstall_addon.call_args == call("core_zwave_js") assert entry.state is ConfigEntryState.NOT_LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 0 assert "Failed to uninstall the Z-Wave JS add-on" in caplog.text From 03bba6d0c3341617c9b941a2abc663ed36879169 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 24 Sep 2024 15:56:30 +0200 Subject: [PATCH 1095/1309] Climate check target min lower than target high (#124488) * Guard target high_temp higher than low_temp in ClimateEntity * Fixes * Update string * Forgot to fix tests --- homeassistant/components/climate/__init__.py | 13 +++- homeassistant/components/climate/strings.json | 3 + tests/components/climate/test_init.py | 63 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 7213a2ebca0..1aa082f8c6c 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1008,11 +1008,22 @@ async def async_service_temperature_set( ) hass = entity.hass - kwargs = {} + kwargs: dict[str, Any] = {} min_temp = entity.min_temp max_temp = entity.max_temp temp_unit = entity.temperature_unit + if ( + (target_low_temp := service_call.data.get(ATTR_TARGET_TEMP_LOW)) + and (target_high_temp := service_call.data.get(ATTR_TARGET_TEMP_HIGH)) + and target_low_temp > target_high_temp + ): + # Ensure target_low_temp is not higher than target_high_temp. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="low_temp_higher_than_high_temp", + ) + for value, temp in service_call.data.items(): if value in CONVERTIBLE_ATTRIBUTE: kwargs[value] = check_temp = TemperatureConverter.convert( diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 3ff8d325da5..fc0bdaf0d72 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -270,6 +270,9 @@ "temp_out_of_range": { "message": "Provided temperature {check_temp} is not valid. Accepted range is {min_temp} to {max_temp}." }, + "low_temp_higher_than_high_temp": { + "message": "Target temperature low can not be higher than Target temperature high." + }, "humidity_out_of_range": { "message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}." } diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 1c9144b40f7..2b09c2801df 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -1224,3 +1224,66 @@ async def test_temperature_validation( state = hass.states.get("climate.test") assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 10 assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25 + + +async def test_target_temp_high_higher_than_low( + hass: HomeAssistant, register_test_integration: MockConfigEntry +) -> None: + """Test that target high is higher than target low.""" + + class MockClimateEntityTemp(MockClimateEntity): + """Mock climate class with mocked aux heater.""" + + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + _attr_current_temperature = 15 + _attr_target_temperature = 15 + _attr_target_temperature_high = 18 + _attr_target_temperature_low = 10 + _attr_target_temperature_step = PRECISION_WHOLE + + def set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if ATTR_TEMPERATURE in kwargs: + self._attr_target_temperature = kwargs[ATTR_TEMPERATURE] + if ATTR_TARGET_TEMP_HIGH in kwargs: + self._attr_target_temperature_high = kwargs[ATTR_TARGET_TEMP_HIGH] + self._attr_target_temperature_low = kwargs[ATTR_TARGET_TEMP_LOW] + + test_climate = MockClimateEntityTemp( + name="Test", + unique_id="unique_climate_test", + ) + + setup_test_component_platform( + hass, DOMAIN, entities=[test_climate], from_config_entry=True + ) + await hass.config_entries.async_setup(register_test_integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("climate.test") + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 15 + assert state.attributes.get(ATTR_MIN_TEMP) == 7 + assert state.attributes.get(ATTR_MAX_TEMP) == 35 + + with pytest.raises( + ServiceValidationError, + match="Target temperature low can not be higher than Target temperature high", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + "entity_id": "climate.test", + ATTR_TARGET_TEMP_HIGH: "15", + ATTR_TARGET_TEMP_LOW: "20", + }, + blocking=True, + ) + assert ( + str(exc.value) + == "Target temperature low can not be higher than Target temperature high" + ) + assert exc.value.translation_key == "low_temp_higher_than_high_temp" From d661eee93d761755770eebbdd2b7268414857c2a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:04:11 +0200 Subject: [PATCH 1096/1309] Update types packages (#126632) --- .github/workflows/ci.yaml | 2 +- requirements_test.txt | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 45e7ec77a8e..00eda06042c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,7 +39,7 @@ on: env: CACHE_VERSION: 10 UV_CACHE_VERSION: 1 - MYPY_CACHE_VERSION: 8 + MYPY_CACHE_VERSION: 9 HA_SHORT_VERSION: "2024.10" DEFAULT_PYTHON: "3.12" ALL_PYTHON_VERSIONS: "['3.12']" diff --git a/requirements_test.txt b/requirements_test.txt index 083fc4468f1..7314335a3b3 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -34,20 +34,20 @@ requests-mock==1.12.1 respx==0.21.1 syrupy==4.7.1 tqdm==4.66.5 -types-aiofiles==23.2.0.20240623 +types-aiofiles==24.1.0.20240626 types-atomicwrites==1.4.5.1 types-croniter==2.0.0.20240423 -types-beautifulsoup4==4.12.0.20240511 -types-caldav==1.3.0.20240331 +types-beautifulsoup4==4.12.0.20240907 +types-caldav==1.3.0.20240824 types-chardet==0.1.5 types-decorator==5.1.8.20240310 types-paho-mqtt==1.6.0.20240321 -types-pillow==10.2.0.20240520 -types-protobuf==4.24.0.20240106 -types-psutil==6.0.0.20240621 -types-python-dateutil==2.9.0.20240316 +types-pillow==10.2.0.20240822 +types-protobuf==4.25.0.20240417 +types-psutil==6.0.0.20240901 +types-python-dateutil==2.9.0.20240906 types-python-slugify==8.0.2.20240310 -types-pytz==2024.1.0.20240417 -types-PyYAML==6.0.12.20240311 +types-pytz==2024.2.0.20240913 +types-PyYAML==6.0.12.20240917 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 From 741b025751e572778aedc02a3ae49d4870d943a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 24 Sep 2024 16:33:19 +0200 Subject: [PATCH 1097/1309] Add EveCluster ValvePosition Attribute (#125809) --- homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/sensor.py | 10 + homeassistant/components/matter/strings.json | 3 + .../matter/fixtures/nodes/eve-thermo.json | 406 ++++++++++++++++++ tests/components/matter/test_sensor.py | 29 ++ 5 files changed, 451 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/eve-thermo.json diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index ed0ebfce46e..c191dedbcea 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -26,6 +26,9 @@ }, "activated_carbon_filter_condition": { "default": "mdi:filter-check" + }, + "valve_position": { + "default": "mdi:valve" } } } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 5e02fe640ab..9bd21e1a95d 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -232,6 +232,16 @@ DISCOVERY_SCHEMAS = [ required_attributes=(EveCluster.Attributes.Current,), absent_clusters=(clusters.ElectricalPowerMeasurement,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveThermoValvePosition", + translation_key="valve_position", + native_unit_of_measurement=PERCENTAGE, + ), + entity_class=MatterSensor, + required_attributes=(EveCluster.Attributes.ValvePosition,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 3ecaf6a8151..dd01da56d7f 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -168,6 +168,9 @@ }, "switch_current_position": { "name": "Current switch position" + }, + "valve_position": { + "name": "Valve position" } }, "switch": { diff --git a/tests/components/matter/fixtures/nodes/eve-thermo.json b/tests/components/matter/fixtures/nodes/eve-thermo.json new file mode 100644 index 00000000000..e00b55d2cfc --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve-thermo.json @@ -0,0 +1,406 @@ +{ + "node_id": 33, + "date_commissioned": "2024-09-11T05:47:53.888591", + "last_interview": "2024-09-11T05:48:45.828762", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 18, + "1": 1 + }, + { + "0": 17, + "1": 1 + }, + { + "0": 22, + "1": 2 + } + ], + "0/29/1": [29, 31, 40, 42, 47, 48, 49, 50, 51, 52, 53, 56, 60, 62, 63, 70], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 1 + }, + { + "254": 2 + }, + { + "254": 3 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 4 + } + ], + "0/31/1": [], + "0/31/2": 10, + "0/31/3": 3, + "0/31/4": 5, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Eve Systems", + "0/40/2": 4874, + "0/40/3": "Eve Thermo", + "0/40/4": 79, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "1.1", + "0/40/9": 9217, + "0/40/10": "3.5.0", + "0/40/15": "**REDACTED**", + "0/40/18": "**REDACTED**", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 21, 22, 65528, 65529, 65531, + 65532, 65533 + ], + "0/42/0": [ + { + "1": 556220604, + "2": 0, + "254": 1 + } + ], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/47/0": 1, + "0/47/1": 0, + "0/47/2": "Battery", + "0/47/11": 3050, + "0/47/12": 200, + "0/47/14": 0, + "0/47/15": false, + "0/47/16": 2, + "0/47/18": [], + "0/47/19": "", + "0/47/25": 1, + "0/47/31": [], + "0/47/65532": 10, + "0/47/65533": 2, + "0/47/65528": [], + "0/47/65529": [], + "0/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 18, 19, 25, 31, 65528, 65529, 65531, 65532, + 65533 + ], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "**REDACTED**", + "0/49/7": null, + "0/49/9": 4, + "0/49/10": 4, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [], + "0/51/1": 2, + "0/51/2": 306352, + "0/51/3": 85, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/52/1": 10168, + "0/52/2": 1948, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [1, 2, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 2, + "0/53/2": "**REDACTED**", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "**REDACTED**", + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": 867525816, + "0/53/10": 68, + "0/53/11": 127, + "0/53/12": 197, + "0/53/13": 17, + "0/53/14": 4, + "0/53/15": 4, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 13, + "0/53/19": 3, + "0/53/20": 0, + "0/53/21": 3, + "0/53/22": 167566, + "0/53/23": 167438, + "0/53/24": 128, + "0/53/25": 167438, + "0/53/26": 167326, + "0/53/27": 128, + "0/53/28": 14672, + "0/53/29": 152900, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 30814, + "0/53/34": 63, + "0/53/35": 0, + "0/53/36": 37, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 16473, + "0/53/40": 7569, + "0/53/41": 23, + "0/53/42": 7273, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 6541, + "0/53/49": 319, + "0/53/50": 105, + "0/53/51": 1500, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 681, + "0/53/55": 54, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 2, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/56/0": 779348920474853, + "0/56/1": 4, + "0/56/2": 2, + "0/56/3": null, + "0/56/5": [ + { + "0": 3600, + "1": 0, + "2": "Europe/Paris" + } + ], + "0/56/6": [ + { + "0": 3600, + "1": 0, + "2": 783306000000000 + }, + { + "0": 0, + "1": 783306000000000, + "2": 796611600000000 + } + ], + "0/56/7": 779356121143951, + "0/56/8": 2, + "0/56/10": 2, + "0/56/11": 2, + "0/56/65532": 9, + "0/56/65533": 2, + "0/56/65528": [3], + "0/56/65529": [0, 1, 2, 4], + "0/56/65531": [ + 0, 1, 2, 3, 5, 6, 7, 8, 10, 11, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 1, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [], + "0/62/1": [], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [], + "0/62/5": 4, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/70/0": 120, + "0/70/1": 300, + "0/70/2": 2000, + "0/70/65532": 0, + "0/70/65533": 2, + "0/70/65528": [], + "0/70/65529": [], + "0/70/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 4, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 769, + "1": 3 + } + ], + "1/29/1": [3, 29, 30, 513, 516, 319486977], + "1/29/2": [1026], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/30/0": [], + "1/30/65532": 0, + "1/30/65533": 1, + "1/30/65528": [], + "1/30/65529": [], + "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/513/0": 2100, + "1/513/3": 1000, + "1/513/4": 3000, + "1/513/16": 0, + "1/513/18": 1700, + "1/513/21": 1000, + "1/513/22": 3000, + "1/513/26": 0, + "1/513/27": 2, + "1/513/28": 4, + "1/513/65532": 1, + "1/513/65533": 6, + "1/513/65528": [], + "1/513/65529": [0], + "1/513/65531": [ + 0, 3, 4, 16, 18, 21, 22, 26, 27, 28, 65528, 65529, 65531, 65532, 65533 + ], + "1/516/0": 0, + "1/516/1": 0, + "1/516/2": 0, + "1/516/65532": 0, + "1/516/65533": 2, + "1/516/65528": [], + "1/516/65529": [], + "1/516/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/319486977/319422464": "AAFPCwIAAAMCEyQEDENNMzRNMUE0NzgxNZwBAP8EAQIIMPkBAR0BAD4AOwhTVEVHVDIxMjwBADcBAD8BACYBAScBHk8GAAAgICoq/wMjAQBFDQUCAAAAAAACAYk0BaVGVAXKISyfJEkCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEkGBQwIEIABRBEFHAAFAzwAAADhKT5Ch1orv0cRBSoh/CGWImgjtAAAADwAAABIBgUAAAAAAEoGBQAAAAAA/ygiCRABAAAAAAAAAAIb0XT3kNTbRpuy/pzwUAklhFBhciBkw6lmYXV0", + "1/319486977/319422466": "xqwEAFjkAwBNnpAsBgECEQIQARIBHQEjAgwCABAAAAAAEQAAAAEAAA==", + "1/319486977/319422467": "EwoCAAC8rAQAPwIIKAoUAQADDAwLAgAAvKwEACDqCw==", + "1/319486977/319422476": 0, + "1/319486977/319422482": 12296, + "1/319486977/319422487": false, + "1/319486977/319422488": 10, + "1/319486977/319422489": 30240, + "1/319486977/319422490": 0, + "1/319486977/65532": 0, + "1/319486977/65533": 1, + "1/319486977/65528": [], + "1/319486977/65529": [319422464], + "1/319486977/65531": [ + 65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467, + 319422468, 319422469, 319422476, 319422482, 319422487, 319422488, + 319422489, 319422490, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index c8f89eb8f0c..d887ff4d233 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -74,6 +74,14 @@ async def eve_energy_plug_node_fixture( ) +@pytest.fixture(name="eve_thermo_node") +async def eve_thermo_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Eve Thermo node.""" + return await setup_integration_with_node_fixture(hass, "eve-thermo", matter_client) + + @pytest.fixture(name="eve_energy_plug_patched_node") async def eve_energy_plug_patched_node_fixture( hass: HomeAssistant, matter_client: MagicMock @@ -384,6 +392,27 @@ async def test_energy_sensors( assert state is None +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_eve_thermo_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + eve_thermo_node: MatterNode, +) -> None: + """Test Eve Thermo.""" + # Valve position + state = hass.states.get("sensor.eve_thermo_valve_position") + assert state + assert state.state == "10" + + set_node_attribute(eve_thermo_node, 1, 319486977, 319422488, 0) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.eve_thermo_valve_position") + assert state + assert state.state == "0" + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_air_quality_sensor( From 27bed0cdcb5d654b280b200e652fff397c004f7f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Sep 2024 07:34:40 -0700 Subject: [PATCH 1098/1309] Update Google Photos to have a DataUpdateCoordinator for loading albums (#126443) * Update Google Photos to have a data update coordiantor for loading albums * Remove album from services * Remove action string changes * Revert services.yaml change * Simplify integration by blocking startup on album loading * Update homeassistant/components/google_photos/coordinator.py --------- Co-authored-by: Joost Lekkerkerker --- .../components/google_photos/__init__.py | 5 +- .../components/google_photos/coordinator.py | 61 +++++++++++++++++++ .../components/google_photos/media_source.py | 14 ++--- .../components/google_photos/services.py | 2 +- .../components/google_photos/strings.json | 3 + .../components/google_photos/types.py | 6 +- tests/components/google_photos/conftest.py | 11 ++++ tests/components/google_photos/test_init.py | 13 +++- .../google_photos/test_media_source.py | 37 +---------- 9 files changed, 101 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/google_photos/coordinator.py diff --git a/homeassistant/components/google_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index 950995e72c0..2a7109d8189 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -12,6 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import api from .const import DOMAIN +from .coordinator import GooglePhotosUpdateCoordinator from .services import async_register_services from .types import GooglePhotosConfigEntry @@ -42,7 +43,9 @@ async def async_setup_entry( raise ConfigEntryNotReady from err except ClientError as err: raise ConfigEntryNotReady from err - entry.runtime_data = GooglePhotosLibraryApi(auth) + coordinator = GooglePhotosUpdateCoordinator(hass, GooglePhotosLibraryApi(auth)) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator async_register_services(hass) diff --git a/homeassistant/components/google_photos/coordinator.py b/homeassistant/components/google_photos/coordinator.py new file mode 100644 index 00000000000..1c22740cbd0 --- /dev/null +++ b/homeassistant/components/google_photos/coordinator.py @@ -0,0 +1,61 @@ +"""Coordinator for fetching data from Google Photos API. + +This coordinator fetches the list of Google Photos albums that were created by +Home Assistant, which for large libraries may take some time. The list of album +ids and titles is cached and this provides a method to refresh urls since they +are short lived. +""" + +import asyncio +import datetime +import logging +from typing import Final + +from google_photos_library_api.api import GooglePhotosLibraryApi +from google_photos_library_api.exceptions import GooglePhotosApiError +from google_photos_library_api.model import Album + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL: Final = datetime.timedelta(hours=24) +ALBUM_PAGE_SIZE = 50 + + +class GooglePhotosUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Coordinator for fetching Google Photos albums. + + The `data` object is a dict from Album ID to Album title. + """ + + def __init__(self, hass: HomeAssistant, client: GooglePhotosLibraryApi) -> None: + """Initialize TaskUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name="Google Photos", + update_interval=UPDATE_INTERVAL, + ) + self.client = client + + async def _async_update_data(self) -> dict[str, str]: + """Fetch albums from API endpoint.""" + albums: dict[str, str] = {} + try: + async for album_result in await self.client.list_albums( + page_size=ALBUM_PAGE_SIZE + ): + for album in album_result.albums: + albums[album.id] = album.title + except GooglePhotosApiError as err: + _LOGGER.debug("Error listing albums: %s", err) + raise UpdateFailed(f"Error listing albums: {err}") from err + return albums + + async def list_albums(self) -> list[Album]: + """Return Albums with refreshed URLs based on the cached list of album ids.""" + return await asyncio.gather( + *(self.client.get_album(album_id) for album_id in self.data) + ) diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index 2388869d75b..2bf913541cd 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -149,7 +149,7 @@ class GooglePhotosMediaSource(MediaSource): f"Could not resolve identiifer that is not a Photo: {identifier}" ) entry = self._async_config_entry(identifier.config_entry_id) - client = entry.runtime_data + client = entry.runtime_data.client media_item = await client.get_media_item(media_item_id=identifier.media_id) if not media_item.mime_type: raise BrowseError("Could not determine mime type of media item") @@ -189,7 +189,8 @@ class GooglePhotosMediaSource(MediaSource): # Determine the configuration entry for this item identifier = PhotosIdentifier.of(item.identifier) entry = self._async_config_entry(identifier.config_entry_id) - client = entry.runtime_data + coordinator = entry.runtime_data + client = coordinator.client source = _build_account(entry, identifier) if identifier.id_type is None: @@ -202,15 +203,8 @@ class GooglePhotosMediaSource(MediaSource): ) for special_album in SpecialAlbum ] - albums: list[Album] = [] - try: - async for album_result in await client.list_albums( - page_size=ALBUM_PAGE_SIZE - ): - albums.extend(album_result.albums) - except GooglePhotosApiError as err: - raise BrowseError(f"Error listing albums: {err}") from err + albums = await coordinator.list_albums() source.children.extend( _build_album( album.title, diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index 1687e812b1d..0be213c6981 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -97,7 +97,7 @@ def async_register_services(hass: HomeAssistant) -> None: translation_placeholders={"target": DOMAIN}, ) - client_api = config_entry.runtime_data + client_api = config_entry.runtime_data.client upload_tasks = [] file_results = await hass.async_add_executor_job( _read_file_contents, hass, call.data[CONF_FILENAME] diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index aaed29b124d..17e018dabee 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -54,6 +54,9 @@ }, "api_error": { "message": "Google Photos API responded with error: {message}" + }, + "albums_failed": { + "message": "Cannot fetch albums from the Google Photos API" } }, "services": { diff --git a/homeassistant/components/google_photos/types.py b/homeassistant/components/google_photos/types.py index 2fe57fe1d15..4f4cc1845e4 100644 --- a/homeassistant/components/google_photos/types.py +++ b/homeassistant/components/google_photos/types.py @@ -1,7 +1,7 @@ """Google Photos types.""" -from google_photos_library_api.api import GooglePhotosLibraryApi - from homeassistant.config_entries import ConfigEntry -type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosLibraryApi] +from .coordinator import GooglePhotosUpdateCoordinator + +type GooglePhotosConfigEntry = ConfigEntry[GooglePhotosUpdateCoordinator] diff --git a/tests/components/google_photos/conftest.py b/tests/components/google_photos/conftest.py index c657cd14a53..c848122a9fd 100644 --- a/tests/components/google_photos/conftest.py +++ b/tests/components/google_photos/conftest.py @@ -171,6 +171,17 @@ def mock_client_api( mock_api.list_albums.return_value.__aiter__ = list_albums mock_api.list_albums.return_value.__anext__ = list_albums mock_api.list_albums.side_effect = api_error + + # Mock a point lookup by reading contents of the album fixture above + async def get_album(album_id: str, **kwargs: Any) -> Mock: + for album in load_json_object_fixture("list_albums.json", DOMAIN)["albums"]: + if album["id"] == album_id: + return Album.from_dict(album) + return None + + mock_api.get_album = get_album + mock_api.get_album.side_effect = api_error + return mock_api diff --git a/tests/components/google_photos/test_init.py b/tests/components/google_photos/test_init.py index ea236cfc712..80b051d092d 100644 --- a/tests/components/google_photos/test_init.py +++ b/tests/components/google_photos/test_init.py @@ -4,6 +4,7 @@ import http import time from aiohttp import ClientError +from google_photos_library_api.exceptions import GooglePhotosApiError import pytest from homeassistant.components.google_photos.const import OAUTH2_TOKEN @@ -20,6 +21,7 @@ async def test_setup( config_entry: MockConfigEntry, ) -> None: """Test successful setup and unload.""" + await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(config_entry.entry_id) @@ -68,7 +70,6 @@ async def test_expired_token_refresh_success( config_entry: MockConfigEntry, ) -> None: """Test expired token is refreshed.""" - assert config_entry.state is ConfigEntryState.LOADED assert config_entry.data["token"]["access_token"] == "updated-access-token" assert config_entry.data["token"]["expires_in"] == 3600 @@ -107,3 +108,13 @@ async def test_expired_token_refresh_failure( """Test failure while refreshing token with a transient error.""" assert config_entry.state is expected_state + + +@pytest.mark.usefixtures("setup_integration") +@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) +async def test_coordinator_init_failure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test init failure to load albums.""" + assert config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index 9d287998fa8..fd20117b86e 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -156,24 +156,6 @@ async def test_browse_invalid_path(hass: HomeAssistant) -> None: ) -@pytest.mark.usefixtures("setup_integration") -@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) -async def test_invalid_album_id(hass: HomeAssistant, mock_api: Mock) -> None: - """Test browsing to an album id that does not exist.""" - browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") - assert browse.domain == DOMAIN - assert browse.identifier is None - assert browse.title == "Google Photos" - assert [(child.identifier, child.title) for child in browse.children] == [ - (CONFIG_ENTRY_ID, "Account Name") - ] - - with pytest.raises(BrowseError, match="Error listing media items"): - await async_browse_media( - hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}/a/invalid-album-id" - ) - - @pytest.mark.usefixtures("setup_integration") @pytest.mark.parametrize( ("identifier", "expected_error"), @@ -193,8 +175,7 @@ async def test_missing_photo_id( @pytest.mark.usefixtures("setup_integration", "mock_api") -@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) -async def test_list_albums_failure(hass: HomeAssistant) -> None: +async def test_list_media_items_failure(hass: HomeAssistant, mock_api: Mock) -> None: """Test browsing to an album id that does not exist.""" browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN @@ -204,21 +185,7 @@ async def test_list_albums_failure(hass: HomeAssistant) -> None: (CONFIG_ENTRY_ID, "Account Name") ] - with pytest.raises(BrowseError, match="Error listing albums"): - await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{CONFIG_ENTRY_ID}") - - -@pytest.mark.usefixtures("setup_integration", "mock_api") -@pytest.mark.parametrize("api_error", [GooglePhotosApiError("some error")]) -async def test_list_media_items_failure(hass: HomeAssistant) -> None: - """Test browsing to an album id that does not exist.""" - browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") - assert browse.domain == DOMAIN - assert browse.identifier is None - assert browse.title == "Google Photos" - assert [(child.identifier, child.title) for child in browse.children] == [ - (CONFIG_ENTRY_ID, "Account Name") - ] + mock_api.list_media_items.side_effect = GooglePhotosApiError("some error") with pytest.raises(BrowseError, match="Error listing media items"): await async_browse_media( From fc37218311e6bfe5901311e1eadd6cda67ed19f3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 16:41:35 +0200 Subject: [PATCH 1099/1309] Update httpx to 0.27.2 (#126630) --- 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 6408cf5c5e9..48c3e572bd2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ hassil==1.7.4 home-assistant-bluetooth==1.12.2 home-assistant-frontend==20240909.1 home-assistant-intents==2024.9.23 -httpx==0.27.0 +httpx==0.27.2 ifaddr==0.2.0 Jinja2==3.1.4 lru-dict==1.3.0 diff --git a/pyproject.toml b/pyproject.toml index e1748dae09e..c20aca4d769 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ "hass-nabucasa==0.81.1", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.27.0", + "httpx==0.27.2", "home-assistant-bluetooth==1.12.2", "ifaddr==0.2.0", "Jinja2==3.1.4", diff --git a/requirements.txt b/requirements.txt index a1ded82471e..1400394382d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ certifi>=2021.5.30 ciso8601==2.3.1 fnv-hash-fast==1.0.2 hass-nabucasa==0.81.1 -httpx==0.27.0 +httpx==0.27.2 home-assistant-bluetooth==1.12.2 ifaddr==0.2.0 Jinja2==3.1.4 From 2ded9d551a1d401411302bf5a06d0ba6bd826f0d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 16:42:01 +0200 Subject: [PATCH 1100/1309] Remove unignore flow from dlna_dmr (#126647) --- .../components/dlna_dmr/config_flow.py | 25 ------ tests/components/dlna_dmr/test_config_flow.py | 77 ------------------- 2 files changed, 102 deletions(-) diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index 265c78fd9a9..3f6c2c290b7 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -195,31 +195,6 @@ class DlnaDmrFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_unignore( - self, user_input: Mapping[str, Any] - ) -> ConfigFlowResult: - """Rediscover previously ignored devices by their unique_id.""" - LOGGER.debug("async_step_unignore: user_input: %s", user_input) - self._udn = user_input["unique_id"] - assert self._udn - await self.async_set_unique_id(self._udn) - - # Find a discovery matching the unignored unique_id for a DMR device - for dev_type in DmrDevice.DEVICE_TYPES: - discovery = await ssdp.async_get_discovery_info_by_udn_st( - self.hass, self._udn, dev_type - ) - if discovery: - break - else: - return self.async_abort(reason="discovery_error") - - await self._async_set_info_from_discovery(discovery, abort_if_configured=False) - - self.context["title_placeholders"] = {"name": self._name} - - return await self.async_step_confirm() - async def async_step_confirm( self, user_input: FlowInput = None ) -> ConfigFlowResult: diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index d60a8f17b83..cb32001e1e5 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -671,83 +671,6 @@ async def test_ignore_flow_no_ssdp( } -async def test_unignore_flow(hass: HomeAssistant, ssdp_scanner_mock: Mock) -> None: - """Test a config flow started by unignoring a device.""" - # Create ignored entry (with no extra info from SSDP) - ssdp_scanner_mock.async_get_discovery_info_by_udn_st.return_value = None - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_IGNORE}, - data={"unique_id": MOCK_DEVICE_UDN, "title": MOCK_DEVICE_NAME}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == MOCK_DEVICE_NAME - - # Device was found via SSDP, matching the 2nd device type tried - ssdp_scanner_mock.async_get_discovery_info_by_udn_st.side_effect = [ - None, - MOCK_DISCOVERY, - None, - None, - None, - ] - - # Unignore it and expect config flow to start - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_UNIGNORE}, - data={"unique_id": MOCK_DEVICE_UDN}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == MOCK_DEVICE_NAME - assert result["data"] == { - CONF_URL: MOCK_DEVICE_LOCATION, - CONF_DEVICE_ID: MOCK_DEVICE_UDN, - CONF_TYPE: MOCK_DEVICE_TYPE, - CONF_MAC: MOCK_MAC_ADDRESS, - } - assert result["options"] == {} - - -async def test_unignore_flow_offline( - hass: HomeAssistant, ssdp_scanner_mock: Mock -) -> None: - """Test a config flow started by unignoring a device, but the device is offline.""" - # Create ignored entry (with no extra info from SSDP) - ssdp_scanner_mock.async_get_discovery_info_by_udn_st.return_value = None - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_IGNORE}, - data={"unique_id": MOCK_DEVICE_UDN, "title": MOCK_DEVICE_NAME}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == MOCK_DEVICE_NAME - - # Device is not in the SSDP discoveries (perhaps HA restarted between ignore and unignore) - ssdp_scanner_mock.async_get_discovery_info_by_udn_st.return_value = None - - # Unignore it and expect config flow to start then abort - result = await hass.config_entries.flow.async_init( - DLNA_DOMAIN, - context={"source": config_entries.SOURCE_UNIGNORE}, - data={"unique_id": MOCK_DEVICE_UDN}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "discovery_error" - - async def test_get_mac_address_ipv4( hass: HomeAssistant, mock_get_mac_address: Mock ) -> None: From 264927926e29d18c68574da94c9a5214671cdb5b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 16:43:12 +0200 Subject: [PATCH 1101/1309] Remove unignore flow from homekit controller (#126637) --- .../homekit_controller/config_flow.py | 22 --------- .../homekit_controller/test_config_flow.py | 48 ------------------- 2 files changed, 70 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 2ca32ccb911..fdf71b6d55b 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -168,28 +168,6 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): ), ) - async def async_step_unignore(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """Rediscover a previously ignored discover.""" - unique_id = user_input["unique_id"] - await self.async_set_unique_id(unique_id) - - if self.controller is None: - await self._async_setup_controller() - - assert self.controller - - try: - discovery = await self.controller.async_find(unique_id) - except aiohomekit.AccessoryNotFoundError: - return self.async_abort(reason="accessory_not_found_error") - - self.name = discovery.description.name - self.model = getattr(discovery.description, "model", BLE_DEFAULT_NAME) - self.category = discovery.description.category - self.hkid = discovery.description.id - - return self._async_step_pair_show_form() - @callback def _hkid_is_homekit(self, hkid: str) -> bool: """Determine if the device is a homekit bridge or accessory.""" diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 8c83d8e4b1b..976adeac8a8 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -959,54 +959,6 @@ async def test_user_no_unpaired_devices(hass: HomeAssistant, controller) -> None assert result["reason"] == "no_devices" -async def test_unignore_works(hass: HomeAssistant, controller) -> None: - """Test rediscovery triggered disovers work.""" - device = setup_mock_accessory(controller) - - # Device is unignored - result = await hass.config_entries.flow.async_init( - "homekit_controller", - context={"source": config_entries.SOURCE_UNIGNORE}, - data={"unique_id": device.description.id}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pair" - assert get_flow_context(hass, result) == { - "title_placeholders": {"name": "TestDevice", "category": "Other"}, - "unique_id": "00:00:00:00:00:00", - "source": config_entries.SOURCE_UNIGNORE, - } - - # User initiates pairing by clicking on 'configure' - device enters pairing mode and displays code - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "pair" - - # Pairing finalized - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"pairing_code": "111-22-333"} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "Koogeek-LS1-20833F" - - -async def test_unignore_ignores_missing_devices( - hass: HomeAssistant, controller -) -> None: - """Test rediscovery triggered disovers handle devices that have gone away.""" - setup_mock_accessory(controller) - - # Device is unignored - result = await hass.config_entries.flow.async_init( - "homekit_controller", - context={"source": config_entries.SOURCE_UNIGNORE}, - data={"unique_id": "00:00:00:00:00:01"}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "accessory_not_found_error" - - async def test_discovery_dismiss_existing_flow_on_paired( hass: HomeAssistant, controller ) -> None: From 437bbe5c6e1217e83cf727ca4b40c2763007295a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Sep 2024 08:22:24 -0700 Subject: [PATCH 1102/1309] Limit Google Photos media source to Home Assistant created albums (#126653) --- .../components/google_photos/media_source.py | 49 ++----------------- .../google_photos/test_media_source.py | 2 - 2 files changed, 5 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/google_photos/media_source.py b/homeassistant/components/google_photos/media_source.py index 2bf913541cd..997220fbb88 100644 --- a/homeassistant/components/google_photos/media_source.py +++ b/homeassistant/components/google_photos/media_source.py @@ -1,9 +1,9 @@ """Media source for Google Photos.""" from dataclasses import dataclass -from enum import Enum, StrEnum +from enum import StrEnum import logging -from typing import Any, Self, cast +from typing import Self, cast from google_photos_library_api.exceptions import GooglePhotosApiError from google_photos_library_api.model import Album, MediaItem @@ -30,29 +30,6 @@ THUMBNAIL_SIZE = 256 LARGE_IMAGE_SIZE = 2160 -@dataclass -class SpecialAlbumDetails: - """Details for a Special album.""" - - path: str - title: str - list_args: dict[str, Any] - - -class SpecialAlbum(Enum): - """Special Album types.""" - - UPLOADED = SpecialAlbumDetails("uploaded", "Uploaded", {}) - - @classmethod - def of(cls, path: str) -> Self | None: - """Parse a PhotosIdentifierType by string value.""" - for enum in cls: - if enum.value.path == path: - return enum - return None - - # The PhotosIdentifier can be in the following forms: # config-entry-id # config-entry-id/a/album-media-id @@ -194,18 +171,8 @@ class GooglePhotosMediaSource(MediaSource): source = _build_account(entry, identifier) if identifier.id_type is None: - source.children = [ - _build_album( - special_album.value.title, - PhotosIdentifier.album( - identifier.config_entry_id, special_album.value.path - ), - ) - for special_album in SpecialAlbum - ] - albums = await coordinator.list_albums() - source.children.extend( + source.children = [ _build_album( album.title, PhotosIdentifier.album( @@ -215,7 +182,7 @@ class GooglePhotosMediaSource(MediaSource): _cover_photo_url(album, THUMBNAIL_SIZE), ) for album in albums - ) + ] return source if ( @@ -224,16 +191,10 @@ class GooglePhotosMediaSource(MediaSource): ): raise BrowseError(f"Unsupported identifier: {identifier}") - list_args: dict[str, Any] - if special_album := SpecialAlbum.of(identifier.media_id): - list_args = special_album.value.list_args - else: - list_args = {"album_id": identifier.media_id} - media_items: list[MediaItem] = [] try: async for media_item_result in await client.list_media_items( - **list_args, page_size=MEDIA_ITEMS_PAGE_SIZE + album_id=identifier.media_id, page_size=MEDIA_ITEMS_PAGE_SIZE ): media_items.extend(media_item_result.media_items) except GooglePhotosApiError as err: diff --git a/tests/components/google_photos/test_media_source.py b/tests/components/google_photos/test_media_source.py index fd20117b86e..ce059e4fce5 100644 --- a/tests/components/google_photos/test_media_source.py +++ b/tests/components/google_photos/test_media_source.py @@ -66,7 +66,6 @@ async def test_no_read_scopes( @pytest.mark.parametrize( ("album_path", "expected_album_title"), [ - (f"{CONFIG_ENTRY_ID}/a/uploaded", "Uploaded Photos"), (f"{CONFIG_ENTRY_ID}/a/album-media-id-1", "Album title"), ], ) @@ -108,7 +107,6 @@ async def test_browse_albums( assert browse.identifier == CONFIG_ENTRY_ID assert browse.title == "Account Name" assert [(child.identifier, child.title) for child in browse.children] == [ - (f"{CONFIG_ENTRY_ID}/a/uploaded", "Uploaded"), (f"{CONFIG_ENTRY_ID}/a/album-media-id-1", "Album title"), ] From 412489c10282e53305263f123a20a3a33abe5c70 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Sep 2024 08:26:33 -0700 Subject: [PATCH 1103/1309] Require Google Photos uploads to target an album (#126651) * Require uploads to target an album * Remove edge case where albums are not loaded on startup which no longer happens * Update homeassistant/components/google_photos/strings.json --------- Co-authored-by: Joost Lekkerkerker --- .../components/google_photos/coordinator.py | 12 +- .../components/google_photos/services.py | 23 +++- .../components/google_photos/services.yaml | 4 + .../components/google_photos/strings.json | 8 ++ .../components/google_photos/test_services.py | 112 +++++++++++++++++- 5 files changed, 153 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_photos/coordinator.py b/homeassistant/components/google_photos/coordinator.py index 1c22740cbd0..3ba5a8124d6 100644 --- a/homeassistant/components/google_photos/coordinator.py +++ b/homeassistant/components/google_photos/coordinator.py @@ -13,7 +13,7 @@ from typing import Final from google_photos_library_api.api import GooglePhotosLibraryApi from google_photos_library_api.exceptions import GooglePhotosApiError -from google_photos_library_api.model import Album +from google_photos_library_api.model import Album, NewAlbum from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -59,3 +59,13 @@ class GooglePhotosUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): return await asyncio.gather( *(self.client.get_album(album_id) for album_id in self.data) ) + + async def get_or_create_album(self, album: str) -> str: + """Return an existing album id or create a new one.""" + for album_id, album_title in self.data.items(): + if album_title == album: + return album_id + new_album = await self.client.create_album(NewAlbum(title=album)) + _LOGGER.debug("Created new album: %s", new_album) + self.data[new_album.id] = new_album.title + return new_album.id diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index 0be213c6981..f23a706b2e2 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -24,12 +24,14 @@ from .const import DOMAIN, UPLOAD_SCOPE from .types import GooglePhotosConfigEntry CONF_CONFIG_ENTRY_ID = "config_entry_id" +CONF_ALBUM = "album" UPLOAD_SERVICE = "upload" UPLOAD_SERVICE_SCHEMA = vol.Schema( { vol.Required(CONF_CONFIG_ENTRY_ID): cv.string, vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_ALBUM): cv.string, } ) CONTENT_SIZE_LIMIT = 20 * 1024 * 1024 @@ -96,12 +98,23 @@ def async_register_services(hass: HomeAssistant) -> None: translation_key="missing_upload_permission", translation_placeholders={"target": DOMAIN}, ) - - client_api = config_entry.runtime_data.client + coordinator = config_entry.runtime_data + client_api = coordinator.client upload_tasks = [] file_results = await hass.async_add_executor_job( _read_file_contents, hass, call.data[CONF_FILENAME] ) + + album = call.data[CONF_ALBUM] + try: + album_id = await coordinator.get_or_create_album(album) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="create_album_error", + translation_placeholders={"message": str(err)}, + ) from err + for mime_type, content in file_results: upload_tasks.append(client_api.upload_content(content, mime_type)) try: @@ -119,7 +132,8 @@ def async_register_services(hass: HomeAssistant) -> None: SimpleMediaItem(upload_token=upload_result.upload_token) ) for upload_result in upload_results - ] + ], + album_id=album_id, ) except GooglePhotosApiError as err: raise HomeAssistantError( @@ -135,7 +149,8 @@ def async_register_services(hass: HomeAssistant) -> None: for item_result in upload_result.new_media_item_results if item_result.media_item and item_result.media_item.id } - ] + ], + "album_id": album_id, } return None diff --git a/homeassistant/components/google_photos/services.yaml b/homeassistant/components/google_photos/services.yaml index 047305c0bca..ec3b94c453b 100644 --- a/homeassistant/components/google_photos/services.yaml +++ b/homeassistant/components/google_photos/services.yaml @@ -9,3 +9,7 @@ upload: required: false selector: object: + album: + required: true + selector: + text: diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index 17e018dabee..2333783fc00 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -52,6 +52,9 @@ "upload_error": { "message": "Failed to upload content: {message}" }, + "create_album_error": { + "message": "Failed to create album: {message}" + }, "api_error": { "message": "Google Photos API responded with error: {message}" }, @@ -72,6 +75,11 @@ "name": "Filename", "description": "Path to the image or video to upload.", "example": "/config/www/image.jpg" + }, + "album": { + "name": "Album", + "description": "Album name that is the destination for the uploaded content.", + "example": "Family photos" } } } diff --git a/tests/components/google_photos/test_services.py b/tests/components/google_photos/test_services.py index 10f4543bcc2..381fb1c431f 100644 --- a/tests/components/google_photos/test_services.py +++ b/tests/components/google_photos/test_services.py @@ -7,6 +7,7 @@ from unittest.mock import Mock, patch from google_photos_library_api.exceptions import GooglePhotosApiError from google_photos_library_api.model import ( + Album, CreateMediaItemsResult, MediaItem, NewMediaItemResult, @@ -16,6 +17,7 @@ import pytest from homeassistant.components.google_photos.const import DOMAIN, READ_SCOPE from homeassistant.components.google_photos.services import ( + CONF_ALBUM, CONF_CONFIG_ENTRY_ID, UPLOAD_SERVICE, ) @@ -27,6 +29,7 @@ from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry TEST_FILENAME = "doorbell_snapshot.jpg" +ALBUM_TITLE = "Album title" @dataclass @@ -96,12 +99,16 @@ async def test_upload_service( { CONF_CONFIG_ENTRY_ID: config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: ALBUM_TITLE, }, blocking=True, return_response=True, ) - assert response == {"media_items": [{"media_item_id": "new-media-item-id-1"}]} + assert response == { + "media_items": [{"media_item_id": "new-media-item-id-1"}], + "album_id": "album-media-id-1", + } @pytest.mark.usefixtures("setup_integration") @@ -117,6 +124,7 @@ async def test_upload_service_config_entry_not_found( { CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id", CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: ALBUM_TITLE, }, blocking=True, return_response=True, @@ -141,6 +149,7 @@ async def test_config_entry_not_loaded( { CONF_CONFIG_ENTRY_ID: config_entry.unique_id, CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: ALBUM_TITLE, }, blocking=True, return_response=True, @@ -163,6 +172,7 @@ async def test_path_is_not_allowed( { CONF_CONFIG_ENTRY_ID: config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: ALBUM_TITLE, }, blocking=True, return_response=True, @@ -183,6 +193,7 @@ async def test_filename_does_not_exist( { CONF_CONFIG_ENTRY_ID: config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: ALBUM_TITLE, }, blocking=True, return_response=True, @@ -206,6 +217,7 @@ async def test_upload_service_upload_content_failure( { CONF_CONFIG_ENTRY_ID: config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: ALBUM_TITLE, }, blocking=True, return_response=True, @@ -231,6 +243,7 @@ async def test_upload_service_fails_create( { CONF_CONFIG_ENTRY_ID: config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: ALBUM_TITLE, }, blocking=True, return_response=True, @@ -257,6 +270,7 @@ async def test_upload_service_no_scope( { CONF_CONFIG_ENTRY_ID: config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: ALBUM_TITLE, }, blocking=True, return_response=True, @@ -280,6 +294,102 @@ async def test_upload_size_limit( { CONF_CONFIG_ENTRY_ID: config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: ALBUM_TITLE, + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("setup_integration") +async def test_upload_to_new_album( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_api: Mock, +) -> None: + """Test service call to upload content to a new album.""" + assert hass.services.has_service(DOMAIN, "upload") + + mock_api.create_media_items.return_value = CreateMediaItemsResult( + new_media_item_results=[ + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-1"), + ) + ] + ) + mock_api.create_album.return_value = Album(id="album-media-id-2", title="New Album") + response = await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: "New Album", + }, + blocking=True, + return_response=True, + ) + + # Verify media item was created with the new album id + mock_api.create_album.assert_awaited() + assert response == { + "media_items": [{"media_item_id": "new-media-item-id-1"}], + "album_id": "album-media-id-2", + } + + # Upload an additional item to the same album and assert that no new album is created + mock_api.create_album.reset_mock() + mock_api.create_media_items.reset_mock() + mock_api.create_media_items.return_value = CreateMediaItemsResult( + new_media_item_results=[ + NewMediaItemResult( + upload_token="some-upload-token", + status=Status(code=200), + media_item=MediaItem(id="new-media-item-id-3"), + ) + ] + ) + response = await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: "New Album", + }, + blocking=True, + return_response=True, + ) + + # Verify the album created last time is used + mock_api.create_album.assert_not_awaited() + assert response == { + "media_items": [{"media_item_id": "new-media-item-id-3"}], + "album_id": "album-media-id-2", + } + + +@pytest.mark.usefixtures("setup_integration") +async def test_create_album_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_api: Mock, +) -> None: + """Test service call to upload content to a new album but creating the album fails.""" + assert hass.services.has_service(DOMAIN, "upload") + + mock_api.create_album.side_effect = GooglePhotosApiError() + + with pytest.raises(HomeAssistantError, match="Failed to create album"): + await hass.services.async_call( + DOMAIN, + UPLOAD_SERVICE, + { + CONF_CONFIG_ENTRY_ID: config_entry.entry_id, + CONF_FILENAME: TEST_FILENAME, + CONF_ALBUM: "New Album", }, blocking=True, return_response=True, From 4e465a2066abc5ccbec23b8f787e4fb978bc2af3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 24 Sep 2024 17:27:39 +0200 Subject: [PATCH 1104/1309] Remove unused string in dlna_dmr (#126652) --- homeassistant/components/dlna_dmr/strings.json | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/dlna_dmr/strings.json b/homeassistant/components/dlna_dmr/strings.json index 48f347a0908..e0610e37133 100644 --- a/homeassistant/components/dlna_dmr/strings.json +++ b/homeassistant/components/dlna_dmr/strings.json @@ -27,7 +27,6 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "alternative_integration": "Device is better supported by another integration", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "discovery_error": "Failed to discover a matching DLNA device", "incomplete_config": "Configuration is missing a required variable", "non_unique_id": "Multiple devices found with the same unique ID", "not_dmr": "Device is not a supported Digital Media Renderer" From 2ee93d974d1a4615ed16e5b174b1c0eaded2d164 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 17:38:33 +0200 Subject: [PATCH 1105/1309] Reinitialize ssdp discovery flow on unignore (#126557) --- homeassistant/components/ssdp/__init__.py | 62 +++- tests/components/ssdp/test_init.py | 360 +++++++++++++++++++++- 2 files changed, 402 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index f5e2a012730..ccd69961975 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -12,7 +12,7 @@ from ipaddress import IPv4Address, IPv6Address import logging import socket from time import time -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urljoin import xml.etree.ElementTree as ET @@ -47,6 +47,7 @@ from homeassistant.core import Event, HassJob, HomeAssistant, callback as core_c from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.instance_id import async_get as async_get_instance_id from homeassistant.helpers.network import NoURLAvailableError, get_url @@ -394,6 +395,12 @@ class Scanner: self.hass, self.async_scan, SCAN_INTERVAL, name="SSDP scanner" ) + async_dispatcher_connect( + self.hass, + config_entries.signal_discovered_config_entry_removed(DOMAIN), + self._handle_config_entry_removed, + ) + # Trigger the initial-scan. await self.async_scan() @@ -502,6 +509,7 @@ class Scanner: dst: DeviceOrServiceType, source: SsdpSource, info_desc: Mapping[str, Any], + skip_callbacks: bool = False, ) -> None: """Handle a device/service change.""" matching_domains: set[str] = set() @@ -526,7 +534,7 @@ class Scanner: ) discovery_info.x_homeassistant_matching_domains = matching_domains - if callbacks: + if callbacks and not skip_callbacks: ssdp_change = SSDP_SOURCE_SSDP_CHANGE_MAPPING[source] _async_process_callbacks(self.hass, callbacks, discovery_info, ssdp_change) @@ -537,14 +545,20 @@ class Scanner: _LOGGER.debug("Discovery info: %s", discovery_info) - location = ssdp_device.location + if not matching_domains: + return # avoid creating DiscoveryKey if there are no matches + + discovery_key = discovery_flow.DiscoveryKey( + domain=DOMAIN, key=ssdp_device.udn, version=1 + ) for domain in matching_domains: - _LOGGER.debug("Discovered %s at %s", domain, location) + _LOGGER.debug("Discovered %s at %s", domain, ssdp_device.location) discovery_flow.async_create_flow( self.hass, domain, {"source": config_entries.SOURCE_SSDP}, discovery_info, + discovery_key=discovery_key, ) def _async_dismiss_discoveries( @@ -565,14 +579,13 @@ class Scanner: ) -> Mapping[str, str]: """Get description dict.""" assert self._description_cache is not None + cache = self._description_cache - has_description, description = self._description_cache.peek_description_dict( - location - ) + has_description, description = cache.peek_description_dict(location) if has_description: return description or {} - return await self._description_cache.async_get_description_dict(location) or {} + return await cache.async_get_description_dict(location) or {} async def _async_headers_to_discovery_info( self, ssdp_device: SsdpDevice, headers: CaseInsensitiveDict @@ -581,8 +594,6 @@ class Scanner: Building this is a bit expensive so we only do it on demand. """ - assert self._description_cache is not None - location = headers["location"] info_desc = await self._async_get_description_dict(location) return discovery_info_from_headers_and_description( @@ -618,6 +629,37 @@ class Scanner: if ssdp_device.udn == udn ] + @core_callback + def _handle_config_entry_removed( + self, + entry: config_entries.ConfigEntry, + ) -> None: + """Handle config entry changes.""" + if TYPE_CHECKING: + assert self._description_cache is not None + cache = self._description_cache + for discovery_key in entry.discovery_keys[DOMAIN]: + if discovery_key.version != 1 or not isinstance(discovery_key.key, str): + continue + udn = discovery_key.key + _LOGGER.debug("Rediscover service %s", udn) + + for ssdp_device in self._ssdp_devices: + if ssdp_device.udn != udn: + continue + for dst in ssdp_device.all_combined_headers: + has_cached_desc, info_desc = cache.peek_description_dict( + ssdp_device.location + ) + if has_cached_desc and info_desc: + self._ssdp_listener_process_callback( + ssdp_device, + dst, + SsdpSource.SEARCH, + info_desc, + True, # Skip integration callbacks + ) + def discovery_info_from_headers_and_description( ssdp_device: SsdpDevice, diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index d10496500d2..5592f7a6809 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -18,10 +18,16 @@ from homeassistant.const import ( MATCH_ALL, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import ( + MockConfigEntry, + MockModule, + async_fire_time_changed, + mock_integration, +) from tests.test_util.aiohttp import AiohttpClientMocker @@ -65,7 +71,8 @@ async def test_ssdp_flow_dispatched_on_st( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, } mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" @@ -108,7 +115,8 @@ async def test_ssdp_flow_dispatched_on_manufacturer_url( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, } mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] assert mock_call_data.ssdp_st == "mock-st" @@ -163,7 +171,8 @@ async def test_scan_match_upnp_devicedesc_manufacturer( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, } @@ -208,7 +217,8 @@ async def test_scan_match_upnp_devicedesc_devicetype( assert len(mock_flow_init.mock_calls) == 1 assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" assert mock_flow_init.mock_calls[0][2]["context"] == { - "source": config_entries.SOURCE_SSDP + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, } @@ -339,7 +349,14 @@ async def test_flow_start_only_alive( await hass.async_block_till_done(wait_background_tasks=True) mock_flow_init.assert_awaited_once_with( - "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + "mock-domain", + context={ + "discovery_key": DiscoveryKey( + domain="ssdp", key="uuid:mock-udn", version=1 + ), + "source": config_entries.SOURCE_SSDP, + }, + data=ANY, ) # ssdp:alive advertisement should start a flow @@ -356,7 +373,14 @@ async def test_flow_start_only_alive( ssdp_listener._on_alive(mock_ssdp_advertisement) await hass.async_block_till_done() mock_flow_init.assert_awaited_once_with( - "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + "mock-domain", + context={ + "discovery_key": DiscoveryKey( + domain="ssdp", key="uuid:mock-udn", version=1 + ), + "source": config_entries.SOURCE_SSDP, + }, + data=ANY, ) # ssdp:byebye advertisement should not start a flow @@ -372,7 +396,14 @@ async def test_flow_start_only_alive( ssdp_listener._on_update(mock_ssdp_advertisement) await hass.async_block_till_done() mock_flow_init.assert_awaited_once_with( - "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + "mock-domain", + context={ + "discovery_key": DiscoveryKey( + domain="ssdp", key="uuid:mock-udn", version=1 + ), + "source": config_entries.SOURCE_SSDP, + }, + data=ANY, ) @@ -824,7 +855,14 @@ async def test_flow_dismiss_on_byebye( await hass.async_block_till_done(wait_background_tasks=True) mock_flow_init.assert_awaited_once_with( - "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + "mock-domain", + context={ + "discovery_key": DiscoveryKey( + domain="ssdp", key="uuid:mock-udn", version=1 + ), + "source": config_entries.SOURCE_SSDP, + }, + data=ANY, ) # ssdp:alive advertisement should start a flow @@ -841,7 +879,14 @@ async def test_flow_dismiss_on_byebye( ssdp_listener._on_alive(mock_ssdp_advertisement) await hass.async_block_till_done(wait_background_tasks=True) mock_flow_init.assert_awaited_once_with( - "mock-domain", context={"source": config_entries.SOURCE_SSDP}, data=ANY + "mock-domain", + context={ + "discovery_key": DiscoveryKey( + domain="ssdp", key="uuid:mock-udn", version=1 + ), + "source": config_entries.SOURCE_SSDP, + }, + data=ANY, ) mock_ssdp_advertisement["nts"] = "ssdp:byebye" @@ -859,3 +904,298 @@ async def test_flow_dismiss_on_byebye( assert len(mock_async_progress_by_init_data_type.mock_calls) == 1 assert mock_async_abort.mock_calls[0][1][0] == "mock_flow_id" + + +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"st": "mock-st"}]}, +) +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + ), + [ + # Matching discovery key + ( + "mock-domain", + {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),)}, + ), + # Matching discovery key + ( + "mock-domain", + { + "ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),), + "other": (DiscoveryKey(domain="other", key="blah", version=1),), + }, + ), + # Matching discovery key, other domain + # Note: Rediscovery is not currently restricted to the domain of the removed + # entry. Such a check can be added if needed. + ( + "comp", + {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),)}, + ), + ], +) +@pytest.mark.parametrize("entry_source", [config_entries.SOURCE_IGNORE]) +async def test_ssdp_rediscover( + mock_get_ssdp, + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_flow_init, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, +) -> None: + """Test we reinitiate flows when an ignored config entry is removed.""" + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "server": "mock-server", + "ext": "", + "_source": "search", + } + ) + aioclient_mock.get( + "http://1.1.1.1", + text=""" + + + Paulus + Paulus + + + """, + ) + ssdp_listener = await init_ssdp_component(hass) + ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, + } + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == expected_context + mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + assert mock_call_data.ssdp_st == "mock-st" + assert mock_call_data.ssdp_location == "http://1.1.1.1" + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_flow_init.mock_calls) == 3 + assert mock_flow_init.mock_calls[1][1][0] == entry_domain + assert mock_flow_init.mock_calls[1][2]["context"] == {"source": "unignore"} + assert mock_flow_init.mock_calls[2][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[2][2]["context"] == expected_context + assert ( + mock_flow_init.mock_calls[2][2]["data"] + == mock_flow_init.mock_calls[0][2]["data"] + ) + + +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"st": "mock-st"}]}, +) +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + ), + [ + # Matching discovery key + ( + "mock-domain", + {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),)}, + ), + # Matching discovery key + ( + "mock-domain", + { + "ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),), + "other": (DiscoveryKey(domain="other", key="blah", version=1),), + }, + ), + # Matching discovery key, other domain + # Note: Rediscovery is not currently restricted to the domain of the removed + # entry. Such a check can be added if needed. + ( + "comp", + {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),)}, + ), + ], +) +@pytest.mark.parametrize( + "entry_source", [config_entries.SOURCE_USER, config_entries.SOURCE_ZEROCONF] +) +async def test_ssdp_rediscover_2( + mock_get_ssdp, + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_flow_init, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, +) -> None: + """Test we reinitiate flows when an ignored config entry is removed. + + This test can be merged with test_zeroconf_rediscover when + async_step_unignore has been removed from the ConfigFlow base class. + """ + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "server": "mock-server", + "ext": "", + "_source": "search", + } + ) + aioclient_mock.get( + "http://1.1.1.1", + text=""" + + + Paulus + Paulus + + + """, + ) + ssdp_listener = await init_ssdp_component(hass) + ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, + } + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == expected_context + mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + assert mock_call_data.ssdp_st == "mock-st" + assert mock_call_data.ssdp_location == "http://1.1.1.1" + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_flow_init.mock_calls) == 2 + assert mock_flow_init.mock_calls[1][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[1][2]["context"] == expected_context + assert ( + mock_flow_init.mock_calls[1][2]["data"] + == mock_flow_init.mock_calls[0][2]["data"] + ) + + +@patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value={"mock-domain": [{"st": "mock-st"}]}, +) +@pytest.mark.parametrize( + ( + "entry_domain", + "entry_discovery_keys", + "entry_source", + "entry_unique_id", + ), + [ + # Discovery key from other domain + ( + "mock-domain", + {"dhcp": (DiscoveryKey(domain="dhcp", key="uuid:mock-udn", version=1),)}, + config_entries.SOURCE_IGNORE, + "mock-unique-id", + ), + # Discovery key from the future + ( + "mock-domain", + {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=2),)}, + config_entries.SOURCE_IGNORE, + "mock-unique-id", + ), + ], +) +async def test_ssdp_rediscover_no_match( + mock_get_ssdp, + hass: HomeAssistant, + mock_flow_init, + entry_domain: str, + entry_discovery_keys: tuple, + entry_source: str, + entry_unique_id: str, +) -> None: + """Test we don't reinitiate flows when a non matching config entry is removed.""" + mock_integration(hass, MockModule(entry_domain)) + entry = MockConfigEntry( + domain=entry_domain, + discovery_keys=entry_discovery_keys, + unique_id=entry_unique_id, + state=config_entries.ConfigEntryState.LOADED, + source=entry_source, + ) + entry.add_to_hass(hass) + + mock_ssdp_search_response = _ssdp_headers( + { + "st": "mock-st", + "location": "http://1.1.1.1", + "usn": "uuid:mock-udn::mock-st", + "server": "mock-server", + "ext": "", + "_source": "search", + } + ) + ssdp_listener = await init_ssdp_component(hass) + ssdp_listener._on_search(mock_ssdp_search_response) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + expected_context = { + "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), + "source": config_entries.SOURCE_SSDP, + } + assert len(mock_flow_init.mock_calls) == 1 + assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" + assert mock_flow_init.mock_calls[0][2]["context"] == expected_context + mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] + assert mock_call_data.ssdp_st == "mock-st" + assert mock_call_data.ssdp_location == "http://1.1.1.1" + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(mock_flow_init.mock_calls) == 2 + assert mock_flow_init.mock_calls[1][1][0] == entry_domain + assert mock_flow_init.mock_calls[1][2]["context"] == {"source": "unignore"} From a66e287903f92b558c27b8dec8f261ce2b87cca5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 17:50:10 +0200 Subject: [PATCH 1106/1309] Update pyoverkiz to 1.14.1 (#126657) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 19850f0b57e..52fd1dfc669 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -20,7 +20,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.13.14"], + "requirements": ["pyoverkiz==1.14.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 2c3dd552ffe..445d0c79988 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2127,7 +2127,7 @@ pyotgw==2.2.0 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.14 +pyoverkiz==1.14.1 # homeassistant.components.onewire pyownet==0.10.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15269a017f6..1f21182dc25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1711,7 +1711,7 @@ pyotgw==2.2.0 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.13.14 +pyoverkiz==1.14.1 # homeassistant.components.onewire pyownet==0.10.0.post1 From 31a1ad8409855f1505e7cf113e52eeb616841bc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 24 Sep 2024 17:59:58 +0200 Subject: [PATCH 1107/1309] Add Pressure and Altitude discovery schemas for Matter Eve Weather device (#125690) * Update number.py to add EveWeatherAltitude attribute * Update sensor.py to add EveCluster Pressure Attribute * Update strings.json * Create eve-weather-sensor.json * Update test_sensor.py * Update eve-weather-sensor.json * Update test_sensor.py Pressure AttributeId: 319422484 (0x00130a0014) - Value type: float32 * Update test_sensor.py * Update test_sensor.py * Update test_sensor.py * Update manifest.json Bump to python-matter-server==6.5.0 * Update requirements_all.txt Bump requirements to python-matter-server 6.5.0 * Update requirements_test_all.txt Bump requirements to python-matter-server 6.5.0 * Update test_sensor.py * Update test_sensor.py * Update sensor.py * Update sensor.py * Update test_sensor.py * Update sensor.py * Update test_sensor.py * Update test_sensor.py * Update test_sensor.py * fix test fixture * Update requirements_all.txt * Update requirements_test_all.txt * Update manifest.json * fix tests * Update test_sensor.py * add device class --------- Co-authored-by: Marcel van der Veldt --- homeassistant/components/matter/number.py | 20 +- homeassistant/components/matter/sensor.py | 12 + homeassistant/components/matter/strings.json | 3 + .../fixtures/nodes/eve-weather-sensor.json | 322 ++++++++++++++++++ tests/components/matter/test_number.py | 52 ++- tests/components/matter/test_sensor.py | 68 ++-- 6 files changed, 455 insertions(+), 22 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/eve-weather-sensor.json diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index c9b40ef71a0..cc312cdc66a 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -5,15 +5,17 @@ from __future__ import annotations from dataclasses import dataclass from chip.clusters import Objects as clusters +from matter_server.common import custom_clusters from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.number import ( + NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, Platform, UnitOfTime +from homeassistant.const import EntityCategory, Platform, UnitOfLength, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -137,4 +139,20 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterNumber, required_attributes=(clusters.LevelControl.Attributes.OnOffTransitionTime,), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="EveWeatherAltitude", + device_class=NumberDeviceClass.DISTANCE, + entity_category=EntityCategory.CONFIG, + translation_key="altitude", + native_max_value=9000, + native_min_value=0, + native_unit_of_measurement=UnitOfLength.METERS, + native_step=1, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=(custom_clusters.EveCluster.Attributes.Altitude,), + ), ] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 9bd21e1a95d..1d6d7ac77f3 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -242,6 +242,18 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(EveCluster.Attributes.ValvePosition,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EveWeatherPressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + suggested_display_precision=1, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(EveCluster.Attributes.Pressure,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index dd01da56d7f..f75695cc3bc 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -119,6 +119,9 @@ }, "on_off_transition_time": { "name": "On/Off transition time" + }, + "altitude": { + "name": "Altitude above Sea Level" } }, "light": { diff --git a/tests/components/matter/fixtures/nodes/eve-weather-sensor.json b/tests/components/matter/fixtures/nodes/eve-weather-sensor.json new file mode 100644 index 00000000000..dacba8d336b --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve-weather-sensor.json @@ -0,0 +1,322 @@ +{ + "node_id": 29, + "date_commissioned": "2024-09-10T13:34:48.252332", + "last_interview": "2024-09-10T13:34:48.252334", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 47, 48, 49, 51, 53, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 4 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Eve Systems", + "0/40/2": 4874, + "0/40/3": "Eve Weather", + "0/40/4": 87, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "1.1", + "0/40/9": 7143, + "0/40/10": "3.3.0", + "0/40/15": "**REDACTED**", + "0/40/18": "**REDACTED**", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/47/0": 1, + "0/47/1": 0, + "0/47/2": "Battery", + "0/47/11": 2956, + "0/47/12": 200, + "0/47/14": 0, + "0/47/15": false, + "0/47/16": 2, + "0/47/18": [], + "0/47/19": "", + "0/47/25": 1, + "0/47/65532": 10, + "0/47/65533": 1, + "0/47/65528": [], + "0/47/65529": [], + "0/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 18, 19, 25, 65528, 65529, 65531, 65532, 65533 + ], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "**REDACTED**", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [], + "0/51/1": 1, + "0/51/2": 3416207, + "0/51/3": 948, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 2, + "0/53/2": "**REDACTED**", + "0/53/3": 4660, + "0/53/4": 12054125955590472924, + "0/53/5": "**REDACTED**", + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": 867525816, + "0/53/10": 68, + "0/53/11": 127, + "0/53/12": 197, + "0/53/13": 17, + "0/53/14": 244, + "0/53/15": 243, + "0/53/16": 0, + "0/53/17": 0, + "0/53/18": 334, + "0/53/19": 6, + "0/53/20": 0, + "0/53/21": 221, + "0/53/22": 1814103, + "0/53/23": 1812208, + "0/53/24": 1895, + "0/53/25": 1812220, + "0/53/26": 1806871, + "0/53/27": 1895, + "0/53/28": 144123, + "0/53/29": 1670020, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 515245, + "0/53/34": 1061, + "0/53/35": 0, + "0/53/36": 25, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 310675, + "0/53/40": 180775, + "0/53/41": 783, + "0/53/42": 171240, + "0/53/43": 0, + "0/53/44": 4, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 110041, + "0/53/49": 10200, + "0/53/50": 818, + "0/53/51": 11698, + "0/53/52": 0, + "0/53/53": 114, + "0/53/54": 6189, + "0/53/55": 371, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//4A==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [0, 0, 0, 0], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [], + "0/62/1": [], + "0/62/2": 5, + "0/62/3": 4, + "0/62/4": [], + "0/62/5": 4, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 4, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 770, + "1": 2 + } + ], + "1/29/1": [3, 29, 1026, 319486977], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/1026/0": 1603, + "1/1026/1": -4000, + "1/1026/2": 8500, + "1/1026/65532": 0, + "1/1026/65533": 4, + "1/1026/65528": [], + "1/1026/65529": [], + "1/1026/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "1/319486977/319422464": "AAFXCwIAAAMC/xsEDFNWNDNMMUEwMzg2MJwBAP8EAQJsNPkBAR0BACUE9griHksEfgeAA1EBAA==", + "1/319486977/319422466": "Ps00AOODMwBqe48sBgECAgIDAicBLwEjAlAPABAABwAA6gERAAEA", + "1/319486977/319422467": "EiMTAACLYy0AH74Fwx88JwQOEiQTAADjZS0AH7wFzB87JwQOEiUTAAA7aC0AH7oF1B86JwQOEiYTAACTai0AH7kF5x86JwQOEicTAADrbC0AH7sF8B85JwQOEigTAABDby0AH7wFAiA4JwQOEikTAACbcS0AH7sFFCA3JwQOEioTAADzcy0AH7EFMiA1JwQOEisTAABLdi0AH6gFVyA0JwQOEiwTAACjeC0AH6gFaiAzJwQOEi0TAAD7ei0AH6YFfCAyJwQOEi4TAABTfS0AH6YFgCAzJwQOEi8TAACrfy0AH6MFhyA0JwQOEjATAAADgi0AH58FnSA1JwQOEjETAABbhC0AH58FtSA1JwQOEjITAACzhi0AH5wFwSA0JwQOEjMTAAALiS0AH5cF1SA0JwQOEjQTAABjiy0AH58F3yA0JwIGEjUTAAC7jS0AH6EF7yA0JwIGEjYTAAATkC0AH60F+yAzJwIGEjcTAABrki0AH68FAiEyJwIGEjgTAADDlC0AH7kFACEyJwIGEjkTAAAbly0AH8QF7SAyJwIGEjoTAABzmS0AH9QF1SAzJwIGEjsTAADLmy0AH98FvyAzJwIG", + "1/319486977/319422482": 13420, + "1/319486977/319422483": 40.0, + "1/319486977/319422484": 1008.5, + "1/319486977/319422485": 6, + "1/319486977/319422486": 0, + "1/319486977/65533": 1, + "1/319486977/65528": [], + "1/319486977/65529": [], + "1/319486977/65531": [ + 65528, 65529, 65531, 319422464, 319422465, 319422466, 319422467, + 319422468, 319422469, 319422482, 319422483, 319422484, 319422485, + 319422486, 65533 + ], + "2/3/0": 0, + "2/3/1": 4, + "2/3/65532": 0, + "2/3/65533": 4, + "2/3/65528": [], + "2/3/65529": [0], + "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 775, + "1": 2 + } + ], + "2/29/1": [3, 29, 1029], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 1, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/1029/0": 8066, + "2/1029/1": 0, + "2/1029/2": 10000, + "2/1029/65532": 0, + "2/1029/65533": 3, + "2/1029/65528": [], + "2/1029/65529": [], + "2/1029/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 917f8138c7a..257875d6715 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -1,8 +1,10 @@ """Test Matter number entities.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call from matter_server.client.models.node import MatterNode +from matter_server.common import custom_clusters +from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest from homeassistant.core import HomeAssistant @@ -24,6 +26,16 @@ async def dimmable_light_node_fixture( ) +@pytest.fixture(name="eve_weather_sensor_node") +async def eve_weather_sensor_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Eve Weather sensor node.""" + return await setup_integration_with_node_fixture( + hass, "eve-weather-sensor", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_level_control_config_entities( @@ -54,3 +66,41 @@ async def test_level_control_config_entities( state = hass.states.get("number.mock_dimmable_light_on_level") assert state assert state.state == "20" + + +async def test_eve_weather_sensor_altitude( + hass: HomeAssistant, + matter_client: MagicMock, + eve_weather_sensor_node: MatterNode, +) -> None: + """Test weather sensor created from (Eve) custom cluster.""" + # pressure sensor on Eve custom cluster + state = hass.states.get("number.eve_weather_altitude_above_sea_level") + assert state + assert state.state == "40.0" + + set_node_attribute(eve_weather_sensor_node, 1, 319486977, 319422483, 800) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("number.eve_weather_altitude_above_sea_level") + assert state + assert state.state == "800.0" + + # test set value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.eve_weather_altitude_above_sea_level", + "value": 500, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args_list[0] == call( + node_id=eve_weather_sensor_node.node_id, + attribute_path=create_attribute_path_from_attribute( + endpoint_id=1, + attribute=custom_clusters.EveCluster.Attributes.Altitude, + ), + value=500, + ) diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index d887ff4d233..0d0429f785f 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -92,6 +92,16 @@ async def eve_energy_plug_patched_node_fixture( ) +@pytest.fixture(name="eve_weather_sensor_node") +async def eve_weather_sensor_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Eve Weather sensor node.""" + return await setup_integration_with_node_fixture( + hass, "eve-weather-sensor", matter_client + ) + + @pytest.fixture(name="air_quality_sensor_node") async def air_quality_sensor_node_fixture( hass: HomeAssistant, matter_client: MagicMock @@ -192,26 +202,6 @@ async def test_light_sensor( assert state.state == "2.0" -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_pressure_sensor( - hass: HomeAssistant, - matter_client: MagicMock, - pressure_sensor_node: MatterNode, -) -> None: - """Test pressure sensor.""" - state = hass.states.get("sensor.mock_pressure_sensor_pressure") - assert state - assert state.state == "0.0" - - set_node_attribute(pressure_sensor_node, 1, 1027, 0, 1010) - await trigger_subscription_callback(hass, matter_client) - - state = hass.states.get("sensor.mock_pressure_sensor_pressure") - assert state - assert state.state == "101.0" - - # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_temperature_sensor( @@ -413,6 +403,44 @@ async def test_eve_thermo_sensor( assert state.state == "0" +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_pressure_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + pressure_sensor_node: MatterNode, +) -> None: + """Test pressure sensor.""" + state = hass.states.get("sensor.mock_pressure_sensor_pressure") + assert state + assert state.state == "0.0" + + set_node_attribute(pressure_sensor_node, 1, 1027, 0, 1010) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.mock_pressure_sensor_pressure") + assert state + assert state.state == "101.0" + + +async def test_eve_weather_sensor_custom_cluster( + hass: HomeAssistant, + matter_client: MagicMock, + eve_weather_sensor_node: MatterNode, +) -> None: + """Test weather sensor created from (Eve) custom cluster.""" + # pressure sensor on Eve custom cluster + state = hass.states.get("sensor.eve_weather_pressure") + assert state + assert state.state == "1008.5" + + set_node_attribute(eve_weather_sensor_node, 1, 319486977, 319422484, 800) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("sensor.eve_weather_pressure") + assert state + assert state.state == "800.0" + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_air_quality_sensor( From 962b9915f00c2089240aa262f2c307705406a9ae Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Tue, 24 Sep 2024 18:11:17 +0200 Subject: [PATCH 1108/1309] Plugwise test maintenance (#126421) --- tests/components/plugwise/conftest.py | 30 +- .../fixtures/legacy_anna/all_data.json | 68 ++++ .../all_data.json | 9 - .../fixtures/stretch_v23/all_data.json | 340 ------------------ .../plugwise/snapshots/test_diagnostics.ambr | 9 - .../components/plugwise/test_binary_sensor.py | 13 +- tests/components/plugwise/test_climate.py | 61 ++-- tests/components/plugwise/test_config_flow.py | 27 +- tests/components/plugwise/test_select.py | 9 + tests/components/plugwise/test_sensor.py | 8 +- tests/components/plugwise/test_switch.py | 19 +- 11 files changed, 174 insertions(+), 419 deletions(-) create mode 100644 tests/components/plugwise/fixtures/legacy_anna/all_data.json rename tests/components/plugwise/fixtures/{adam_multiple_devices_per_zone => m_adam_multiple_devices_per_zone}/all_data.json (98%) delete mode 100644 tests/components/plugwise/fixtures/stretch_v23/all_data.json diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index 825a82e7595..2504f4d90bd 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -74,7 +74,7 @@ def mock_smile_config_flow() -> Generator[MagicMock]: @pytest.fixture def mock_smile_adam() -> Generator[MagicMock]: """Create a Mock Adam environment for testing exceptions.""" - chosen_env = "adam_multiple_devices_per_zone" + chosen_env = "m_adam_multiple_devices_per_zone" with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True @@ -309,6 +309,34 @@ def mock_smile_p1_2() -> Generator[MagicMock]: yield smile +@pytest.fixture +def mock_smile_legacy_anna() -> Generator[None, MagicMock, None]: + """Create a Mock legacy Anna environment for testing exceptions.""" + chosen_env = "legacy_anna" + with patch( + "homeassistant.components.plugwise.coordinator.Smile", autospec=True + ) as smile_mock: + smile = smile_mock.return_value + + smile.gateway_id = "0000aaaa0000aaaa0000aaaa0000aa00" + smile.heater_id = "04e4cbfe7f4340f090f85ec3b9e6a950" + smile.smile_version = "1.8.22" + smile.smile_type = "thermostat" + smile.smile_hostname = "smile98765" + smile.smile_model = "Gateway" + smile.smile_model_id = None + smile.smile_name = "Smile Anna" + + smile.connect.return_value = True + + all_data = _read_json(chosen_env, "all_data") + smile.async_update.return_value = PlugwiseData( + all_data["gateway"], all_data["devices"] + ) + + yield smile + + @pytest.fixture def mock_stretch() -> Generator[MagicMock]: """Create a Mock Stretch environment for testing exceptions.""" diff --git a/tests/components/plugwise/fixtures/legacy_anna/all_data.json b/tests/components/plugwise/fixtures/legacy_anna/all_data.json new file mode 100644 index 00000000000..1eca4e285cc --- /dev/null +++ b/tests/components/plugwise/fixtures/legacy_anna/all_data.json @@ -0,0 +1,68 @@ +{ + "devices": { + "0000aaaa0000aaaa0000aaaa0000aa00": { + "dev_class": "gateway", + "firmware": "1.8.22", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "mac_address": "01:23:45:67:89:AB", + "model": "Gateway", + "name": "Smile Anna", + "vendor": "Plugwise" + }, + "04e4cbfe7f4340f090f85ec3b9e6a950": { + "binary_sensors": { + "flame_state": true, + "heating_state": true + }, + "dev_class": "heater_central", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "maximum_boiler_temperature": { + "lower_bound": 50.0, + "resolution": 1.0, + "setpoint": 50.0, + "upper_bound": 90.0 + }, + "model": "Generic heater", + "name": "OpenTherm", + "sensors": { + "dhw_temperature": 51.2, + "intended_boiler_temperature": 17.0, + "modulation_level": 0.0, + "return_temperature": 21.7, + "water_pressure": 1.2, + "water_temperature": 23.6 + }, + "vendor": "Bosch Thermotechniek B.V." + }, + "0d266432d64443e283b5d708ae98b455": { + "active_preset": "home", + "dev_class": "thermostat", + "firmware": "2017-03-13T11:54:58+01:00", + "hardware": "6539-1301-500", + "location": "0000aaaa0000aaaa0000aaaa0000aa00", + "mode": "heat", + "model": "ThermoTouch", + "name": "Anna", + "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], + "sensors": { + "illuminance": 151, + "setpoint": 20.5, + "temperature": 20.4 + }, + "thermostat": { + "lower_bound": 4.0, + "resolution": 0.1, + "setpoint": 20.5, + "upper_bound": 30.0 + }, + "vendor": "Plugwise" + } + }, + "gateway": { + "cooling_present": false, + "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", + "heater_id": "04e4cbfe7f4340f090f85ec3b9e6a950", + "item_count": 41, + "smile_name": "Smile Anna" + } +} diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json similarity index 98% rename from tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json rename to tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json index 374c75ee338..7a61bf10602 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json @@ -403,14 +403,6 @@ "e7693eb9582644e5b865dba8d4447cf1": { "active_preset": "no_frost", "available": true, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], "binary_sensors": { "low_battery": false }, @@ -423,7 +415,6 @@ "model_id": "106-03", "name": "CV Kraan Garage", "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "off", "sensors": { "battery": 68, "setpoint": 5.5, diff --git a/tests/components/plugwise/fixtures/stretch_v23/all_data.json b/tests/components/plugwise/fixtures/stretch_v23/all_data.json deleted file mode 100644 index 27142c7111f..00000000000 --- a/tests/components/plugwise/fixtures/stretch_v23/all_data.json +++ /dev/null @@ -1,340 +0,0 @@ -{ - "devices": { - "0000aaaa0000aaaa0000aaaa0000aa00": { - "dev_class": "gateway", - "firmware": "2.3.12", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "mac_address": "01:23:45:67:89:AB", - "model": "Gateway", - "name": "Stretch", - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" - }, - "09c8ce93d7064fa6a233c0e4c2449bfe": { - "dev_class": "lamp", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "kerstboom buiten 043B016", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": false - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" - }, - "199fd4b2caa44197aaf5b3128f6464ed": { - "dev_class": "airconditioner", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4026", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Airco 25F69E3", - "sensors": { - "electricity_consumed": 2.06, - "electricity_consumed_interval": 1.62, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A10" - }, - "24b2ed37c8964c73897db6340a39c129": { - "dev_class": "router", - "firmware": "2011-06-27T10:47:37+02:00", - "hardware": "6539-0700-7325", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle+ type F", - "name": "MK Netwerk 1A4455E", - "sensors": { - "electricity_consumed": 4.63, - "electricity_consumed_interval": 0.65, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "0123456789AB" - }, - "2587a7fcdd7e482dab03fda256076b4b": { - "dev_class": "zz_misc", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "00469CA1", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A16" - }, - "2cc9a0fe70ef4441a9e4f55dfd64b776": { - "dev_class": "lamp", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4026", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Lamp TV 025F698F", - "sensors": { - "electricity_consumed": 4.0, - "electricity_consumed_interval": 0.58, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A15" - }, - "305452ce97c243c0a7b4ab2a4ebfe6e3": { - "dev_class": "lamp", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4026", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Lamp piano 025F6819", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": false, - "relay": false - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A05" - }, - "33a1c784a9ff4c2d8766a0212714be09": { - "dev_class": "lighting", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4026", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Barverlichting", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": false, - "relay": false - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A13" - }, - "407aa1c1099d463c9137a3a9eda787fd": { - "dev_class": "zz_misc", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "0043B013", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": false - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A09" - }, - "6518f3f72a82486c97b91e26f2e9bd1d": { - "dev_class": "charger", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4026", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Bed 025F6768", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A14" - }, - "713427748874454ca1eb4488d7919cf2": { - "dev_class": "freezer", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Leeg 043220D", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": false - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A12" - }, - "71e3e65ffc5a41518b19460c6e8ee34f": { - "dev_class": "tv", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Leeg 043AEC6", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": false - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A08" - }, - "828f6ce1e36744689baacdd6ddb1d12c": { - "dev_class": "washingmachine", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Wasmachine 043AEC7", - "sensors": { - "electricity_consumed": 3.5, - "electricity_consumed_interval": 0.5, - "electricity_produced": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A02" - }, - "a28e6f5afc0e4fc68498c1f03e82a052": { - "dev_class": "lamp", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4026", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Lamp bank 25F67F8", - "sensors": { - "electricity_consumed": 4.19, - "electricity_consumed_interval": 0.62, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A03" - }, - "bc0adbebc50d428d9444a5d805c89da9": { - "dev_class": "watercooker", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Waterkoker 043AF7F", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07" - }, - "c71f1cb2100b42ca942f056dcb7eb01f": { - "dev_class": "tv", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4026", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Tv hoek 25F6790", - "sensors": { - "electricity_consumed": 33.3, - "electricity_consumed_interval": 4.93, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A11" - }, - "f7b145c8492f4dd7a4de760456fdef3e": { - "dev_class": "switching", - "members": ["407aa1c1099d463c9137a3a9eda787fd"], - "model": "Switchgroup", - "name": "Test", - "switches": { - "relay": false - } - }, - "fd1b74f59e234a9dae4e23b2b5cf07ed": { - "dev_class": "dryer", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Wasdroger 043AECA", - "sensors": { - "electricity_consumed": 1.31, - "electricity_consumed_interval": 0.21, - "electricity_produced": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A04" - } - }, - "gateway": { - "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", - "item_count": 229, - "smile_name": "Stretch" - } -} diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index fda8c62b66d..30aae633125 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -423,14 +423,6 @@ 'e7693eb9582644e5b865dba8d4447cf1': dict({ 'active_preset': 'no_frost', 'available': True, - 'available_schedules': list([ - 'CV Roan', - 'Bios Schema met Film Avond', - 'GF7 Woonkamer', - 'Badkamer Schema', - 'CV Jessie', - 'off', - ]), 'binary_sensors': dict({ 'low_battery': False, }), @@ -449,7 +441,6 @@ 'vacation', 'no_frost', ]), - 'select_schedule': 'off', 'sensors': dict({ 'battery': 68, 'setpoint': 5.5, diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py index 878300bddb4..5c0e3fbdd2e 100644 --- a/tests/components/plugwise/test_binary_sensor.py +++ b/tests/components/plugwise/test_binary_sensor.py @@ -56,7 +56,7 @@ async def test_anna_climate_binary_sensor_change( async def test_adam_climate_binary_sensor_change( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test change of climate related binary_sensor entities.""" + """Test of a climate related plugwise-notification binary_sensor.""" state = hass.states.get("binary_sensor.adam_plugwise_notification") assert state assert state.state == STATE_ON @@ -64,3 +64,14 @@ async def test_adam_climate_binary_sensor_change( assert "unreachable" in state.attributes["warning_msg"][0] assert not state.attributes.get("error_msg") assert not state.attributes.get("other_msg") + + +async def test_p1_v4_binary_sensor_entity( + hass: HomeAssistant, mock_smile_p1_2: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test of a Smile P1 related plugwise-notification binary_sensor.""" + state = hass.states.get("binary_sensor.smile_p1_plugwise_notification") + assert state + assert state.state == STATE_ON + assert "warning_msg" in state.attributes + assert "connected" in state.attributes["warning_msg"][0] diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 70cef16bcdc..f846e818b6e 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -3,6 +3,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from plugwise.exceptions import PlugwiseError import pytest @@ -15,7 +16,6 @@ from homeassistant.components.climate import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -90,11 +90,13 @@ async def test_adam_2_climate_entity_attributes( async def test_adam_3_climate_entity_attributes( - hass: HomeAssistant, mock_smile_adam_3: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_adam_3: MagicMock, + init_integration: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test creation of adam climate device environment.""" state = hass.states.get("climate.anna") - assert state assert state.state == HVACMode.COOL assert state.attributes["hvac_action"] == "cooling" @@ -115,17 +117,20 @@ async def test_adam_3_climate_entity_attributes( "heating_state" ] = True with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.HEAT - assert state.attributes["hvac_action"] == "heating" - assert state.attributes["hvac_modes"] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.HEAT, - ] + + state = hass.states.get("climate.anna") + assert state + assert state.state == HVACMode.HEAT + assert state.attributes["hvac_action"] == "heating" + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.HEAT, + ] + data = mock_smile_adam_3.async_update.return_value data.devices["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = ( "cooling" @@ -138,23 +143,25 @@ async def test_adam_3_climate_entity_attributes( "heating_state" ] = False with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("climate.anna") - assert state - assert state.state == HVACMode.COOL - assert state.attributes["hvac_action"] == "cooling" - assert state.attributes["hvac_modes"] == [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.COOL, - ] + + state = hass.states.get("climate.anna") + assert state + assert state.state == HVACMode.COOL + assert state.attributes["hvac_action"] == "cooling" + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + ] async def test_adam_climate_adjust_negative_testing( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: - """Test exceptions of climate entities.""" + """Test PlugwiseError exception.""" mock_smile_adam.set_temperature.side_effect = PlugwiseError with pytest.raises(HomeAssistantError): @@ -356,6 +363,7 @@ async def test_anna_climate_entity_climate_changes( hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test handling of user requests in anna climate device environment.""" await hass.services.async_call( @@ -400,11 +408,14 @@ async def test_anna_climate_entity_climate_changes( mock_smile_anna.set_schedule_state.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", "off" ) + data = mock_smile_anna.async_update.return_value data.devices["3cb70739631c4d17a86b8b12e8a5161b"].pop("available_schedules") with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data): - async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() + state = hass.states.get("climate.anna") assert state.state == HVACMode.HEAT assert state.attributes["hvac_modes"] == [HVACMode.HEAT_COOL] diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 4b7c567baa8..44a5b5409ed 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -1,14 +1,13 @@ """Test the Plugwise config flow.""" from ipaddress import ip_address -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from plugwise.exceptions import ( ConnectionFailedError, InvalidAuthentication, InvalidSetupError, InvalidXMLError, - ResponseError, UnsupportedDeviceError, ) import pytest @@ -95,22 +94,6 @@ TEST_DISCOVERY_ADAM = ZeroconfServiceInfo( ) -@pytest.fixture(name="mock_smile") -def mock_smile(): - """Create a Mock Smile for testing exceptions.""" - with patch( - "homeassistant.components.plugwise.config_flow.Smile", - ) as smile_mock: - smile_mock.ConnectionFailedError = ConnectionFailedError - smile_mock.InvalidAuthentication = InvalidAuthentication - smile_mock.InvalidSetupError = InvalidSetupError - smile_mock.InvalidXMLError = InvalidXMLError - smile_mock.ResponseError = ResponseError - smile_mock.UnsupportedDeviceError = UnsupportedDeviceError - smile_mock.return_value.connect.return_value = True - yield smile_mock.return_value - - async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -165,11 +148,12 @@ async def test_zeroconf_flow( result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, - data=discovery, + data=TEST_DISCOVERY, ) assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} assert result.get("step_id") == "user" + assert "flow_id" in result result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -183,7 +167,7 @@ async def test_zeroconf_flow( CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: DEFAULT_PORT, - CONF_USERNAME: username, + CONF_USERNAME: TEST_USERNAME, PW_TYPE: API, } @@ -205,6 +189,7 @@ async def test_zeroconf_flow_stretch( assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} assert result.get("step_id") == "user" + assert "flow_id" in result result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -276,7 +261,6 @@ async def test_zercoconf_discovery_update_configuration( (InvalidAuthentication, "invalid_auth"), (InvalidSetupError, "invalid_setup"), (InvalidXMLError, "response_error"), - (ResponseError, "response_error"), (RuntimeError, "unknown"), (UnsupportedDeviceError, "unsupported"), ], @@ -296,6 +280,7 @@ async def test_flow_errors( assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {} assert result.get("step_id") == "user" + assert "flow_id" in result mock_smile_config_flow.connect.side_effect = side_effect result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/plugwise/test_select.py b/tests/components/plugwise/test_select.py index b9dec283bc4..f521787714b 100644 --- a/tests/components/plugwise/test_select.py +++ b/tests/components/plugwise/test_select.py @@ -77,3 +77,12 @@ async def test_adam_select_regulation_mode( "heating", "on", ) + + +async def test_legacy_anna_select_entities( + hass: HomeAssistant, + mock_smile_legacy_anna: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test not creating a select-entity for a legacy Anna without a thermostat-schedule.""" + assert not hass.states.get("select.anna_thermostat_schedule") diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py index 9a20a37824d..0745adb786a 100644 --- a/tests/components/plugwise/test_sensor.py +++ b/tests/components/plugwise/test_sensor.py @@ -3,10 +3,10 @@ from unittest.mock import MagicMock from homeassistant.components.plugwise.const import DOMAIN -from homeassistant.const import Platform +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +import homeassistant.helpers.entity_registry as er from tests.common import MockConfigEntry @@ -58,7 +58,7 @@ async def test_unique_id_migration_humidity( # Entry to migrate entity_registry.async_get_or_create( - Platform.SENSOR, + SENSOR_DOMAIN, DOMAIN, "f61f1a2535f54f52ad006a3d18e459ca-relative_humidity", config_entry=mock_config_entry, @@ -67,7 +67,7 @@ async def test_unique_id_migration_humidity( ) # Entry not needing migration entity_registry.async_get_or_create( - Platform.SENSOR, + SENSOR_DOMAIN, DOMAIN, "f61f1a2535f54f52ad006a3d18e459ca-battery", config_entry=mock_config_entry, diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py index 5da76bb0ebd..d9a4792ddb1 100644 --- a/tests/components/plugwise/test_switch.py +++ b/tests/components/plugwise/test_switch.py @@ -11,11 +11,12 @@ from homeassistant.const import ( SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_OFF, STATE_ON, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.entity_registry as er from tests.common import MockConfigEntry @@ -49,7 +50,7 @@ async def test_adam_climate_switch_negative_testing( assert mock_smile_adam.set_switch_state.call_count == 1 mock_smile_adam.set_switch_state.assert_called_with( - "78d1126fc4c743db81b61c20e88342a7", None, "relay", "off" + "78d1126fc4c743db81b61c20e88342a7", None, "relay", STATE_OFF ) with pytest.raises(HomeAssistantError): @@ -62,7 +63,7 @@ async def test_adam_climate_switch_negative_testing( assert mock_smile_adam.set_switch_state.call_count == 2 mock_smile_adam.set_switch_state.assert_called_with( - "a28f588dc4a049a483fd03a30361ad3a", None, "relay", "on" + "a28f588dc4a049a483fd03a30361ad3a", None, "relay", STATE_ON ) @@ -79,7 +80,7 @@ async def test_adam_climate_switch_changes( assert mock_smile_adam.set_switch_state.call_count == 1 mock_smile_adam.set_switch_state.assert_called_with( - "78d1126fc4c743db81b61c20e88342a7", None, "relay", "off" + "78d1126fc4c743db81b61c20e88342a7", None, "relay", STATE_OFF ) await hass.services.async_call( @@ -91,7 +92,7 @@ async def test_adam_climate_switch_changes( assert mock_smile_adam.set_switch_state.call_count == 2 mock_smile_adam.set_switch_state.assert_called_with( - "a28f588dc4a049a483fd03a30361ad3a", None, "relay", "off" + "a28f588dc4a049a483fd03a30361ad3a", None, "relay", STATE_OFF ) await hass.services.async_call( @@ -103,7 +104,7 @@ async def test_adam_climate_switch_changes( assert mock_smile_adam.set_switch_state.call_count == 3 mock_smile_adam.set_switch_state.assert_called_with( - "a28f588dc4a049a483fd03a30361ad3a", None, "relay", "on" + "a28f588dc4a049a483fd03a30361ad3a", None, "relay", STATE_ON ) @@ -132,7 +133,7 @@ async def test_stretch_switch_changes( ) assert mock_stretch.set_switch_state.call_count == 1 mock_stretch.set_switch_state.assert_called_with( - "e1c884e7dede431dadee09506ec4f859", None, "relay", "off" + "e1c884e7dede431dadee09506ec4f859", None, "relay", STATE_OFF ) await hass.services.async_call( @@ -143,7 +144,7 @@ async def test_stretch_switch_changes( ) assert mock_stretch.set_switch_state.call_count == 2 mock_stretch.set_switch_state.assert_called_with( - "cfe95cf3de1948c0b8955125bf754614", None, "relay", "off" + "cfe95cf3de1948c0b8955125bf754614", None, "relay", STATE_OFF ) await hass.services.async_call( @@ -154,7 +155,7 @@ async def test_stretch_switch_changes( ) assert mock_stretch.set_switch_state.call_count == 3 mock_stretch.set_switch_state.assert_called_with( - "cfe95cf3de1948c0b8955125bf754614", None, "relay", "on" + "cfe95cf3de1948c0b8955125bf754614", None, "relay", STATE_ON ) From d81e836b37465e384bbd37570168db256dfd08c7 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 24 Sep 2024 18:18:02 +0200 Subject: [PATCH 1109/1309] Bump aioautomower to 2024.9.2 (#126659) --- homeassistant/components/husqvarna_automower/calendar.py | 5 ----- .../components/husqvarna_automower/device_tracker.py | 6 ------ homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 3 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index 2e1d9433fb7..87fac58beb2 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -7,7 +7,6 @@ from aioautomower.model import make_name_string from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -49,8 +48,6 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): def event(self) -> CalendarEvent | None: """Return the current or next upcoming event.""" schedule = self.mower_attributes.calendar - if schedule.timeline is None: - return None cursor = schedule.timeline.active_after(dt_util.now()) program_event = next(cursor, None) _LOGGER.debug("program_event %s", program_event) @@ -76,8 +73,6 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): This is only called when opening the calendar in the UI. """ schedule = self.mower_attributes.calendar - if schedule.timeline is None: - raise HomeAssistantError("Unable to get events: No schedule set") cursor = schedule.timeline.overlapping( start_date, end_date, diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py index 74ad624a515..66997e1e86e 100644 --- a/homeassistant/components/husqvarna_automower/device_tracker.py +++ b/homeassistant/components/husqvarna_automower/device_tracker.py @@ -1,7 +1,5 @@ """Creates the device tracker entity for the mower.""" -from typing import TYPE_CHECKING - from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -47,13 +45,9 @@ class AutomowerDeviceTrackerEntity(AutomowerBaseEntity, TrackerEntity): @property def latitude(self) -> float: """Return latitude value of the device.""" - if TYPE_CHECKING: - assert self.mower_attributes.positions is not None return self.mower_attributes.positions[0].latitude @property def longitude(self) -> float: """Return longitude value of the device.""" - if TYPE_CHECKING: - assert self.mower_attributes.positions is not None return self.mower_attributes.positions[0].longitude diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 84d206c3363..aab633378ed 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", "iot_class": "cloud_push", "loggers": ["aioautomower"], - "requirements": ["aioautomower==2024.9.1"] + "requirements": ["aioautomower==2024.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 445d0c79988..afe19a225c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -198,7 +198,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.9.1 +aioautomower==2024.9.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f21182dc25..d57a7059df2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -186,7 +186,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.9.1 +aioautomower==2024.9.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From 0bf90d18ef6ba1d9c9f5970c4d4108ad1fbf5a33 Mon Sep 17 00:00:00 2001 From: Indrajit Raychaudhuri Date: Tue, 24 Sep 2024 11:18:17 -0500 Subject: [PATCH 1110/1309] Ensure that HomeKit names start and end with alphanumeric character (#126413) --- homeassistant/components/homekit/util.py | 7 ++++++- tests/components/homekit/test_type_sensors.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 4d4620477cb..ae7e35030be 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -114,6 +114,7 @@ _LOGGER = logging.getLogger(__name__) NUMBERS_ONLY_RE = re.compile(r"[^\d.]+") VERSION_RE = re.compile(r"([0-9]+)(\.[0-9]+)?(\.[0-9]+)?") +INVALID_END_CHARS = "-_" MAX_VERSION_PART = 2**32 - 1 @@ -414,7 +415,11 @@ def cleanup_name_for_homekit(name: str | None) -> str: # likely isn't a problem if name is None: return "None" # None crashes apple watches - return name.translate(HOMEKIT_CHAR_TRANSLATIONS)[:MAX_NAME_LENGTH] + return ( + name.translate(HOMEKIT_CHAR_TRANSLATIONS) + .lstrip(INVALID_END_CHARS)[:MAX_NAME_LENGTH] + .rstrip(INVALID_END_CHARS) + ) def temperature_to_homekit(temperature: float, unit: str) -> float: diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index 3e8e05fdcfd..ef1c124781a 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -655,7 +655,7 @@ async def test_bad_name(hass: HomeAssistant, hk_driver) -> None: assert acc.category == 10 # Sensor assert acc.char_humidity.value == 20 - assert acc.display_name == "--Humid--" + assert acc.display_name == "Humid" async def test_empty_name(hass: HomeAssistant, hk_driver) -> None: From 60807e5d4dbee6c17f0fa16b1ceda920872b6006 Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 24 Sep 2024 18:23:08 +0200 Subject: [PATCH 1111/1309] Bump bring-api to 0.9.0 (#126650) --- homeassistant/components/bring/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index 17c742415ff..79336c086ed 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bring", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["bring-api==0.8.1"] + "requirements": ["bring-api==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index afe19a225c1..16901848c25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -628,7 +628,7 @@ boto3==1.34.131 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.8.1 +bring-api==0.9.0 # homeassistant.components.broadlink broadlink==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d57a7059df2..d2c3367acaf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -548,7 +548,7 @@ boschshcpy==0.2.91 botocore==1.34.131 # homeassistant.components.bring -bring-api==0.8.1 +bring-api==0.9.0 # homeassistant.components.broadlink broadlink==0.19.0 From c8964a1c8091da2904218f68085f187b9dbbf480 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 18:23:20 +0200 Subject: [PATCH 1112/1309] Update numpy to 1.26.4 (#126660) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index e166ca716cb..caae9190bca 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@Petro31"], "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", - "requirements": ["numpy==1.26.0"] + "requirements": ["numpy==1.26.4"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index ce519de1b67..6142fa1349e 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==1.26.0", "pyiqvia==2022.04.0"] + "requirements": ["numpy==1.26.4", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index dffd6d65a6e..00387d97b83 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.5", "ha-av==10.1.1", "numpy==1.26.0"] + "requirements": ["PyTurboJPEG==1.7.5", "ha-av==10.1.1", "numpy==1.26.4"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 941ec130db2..4f2b6f19285 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -9,7 +9,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==1.26.0", + "numpy==1.26.4", "Pillow==10.4.0" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 110bab99e52..56b4b811171 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -7,5 +7,5 @@ "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==1.26.0"] + "requirements": ["numpy==1.26.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 48c3e572bd2..9989e532a0a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -108,7 +108,7 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.26.0 +numpy==1.26.4 # Constrain multidict to avoid typing issues # https://github.com/home-assistant/core/pull/67046 diff --git a/requirements_all.txt b/requirements_all.txt index 16901848c25..15668373eec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1481,7 +1481,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.26.0 +numpy==1.26.4 # homeassistant.components.nyt_games nyt_games==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2c3367acaf..4a22e04ad1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1229,7 +1229,7 @@ numato-gpio==0.13.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.26.0 +numpy==1.26.4 # homeassistant.components.nyt_games nyt_games==0.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 47a6412bcfd..29b78e1ed9f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -127,7 +127,7 @@ httpcore==1.0.5 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.26.0 +numpy==1.26.4 # Constrain multidict to avoid typing issues # https://github.com/home-assistant/core/pull/67046 From ffa76dfd24802444ab90833589af10bea7d21c7f Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 24 Sep 2024 18:23:45 +0200 Subject: [PATCH 1113/1309] Add discovery schemas for Matter Smoke and CO Alarm Cluster (#126622) Co-authored-by: Joostlek --- .../components/matter/binary_sensor.py | 101 ++++++++ homeassistant/components/matter/icons.json | 8 + homeassistant/components/matter/select.py | 21 ++ homeassistant/components/matter/sensor.py | 33 +++ homeassistant/components/matter/strings.json | 41 +++ tests/components/matter/conftest.py | 10 + .../matter/fixtures/nodes/smoke-detector.json | 238 ++++++++++++++++++ tests/components/matter/test_binary_sensor.py | 42 +++- tests/components/matter/test_sensor.py | 20 ++ 9 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 tests/components/matter/fixtures/nodes/smoke-detector.json diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index fe999487fbc..875b063dc88 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -160,4 +160,105 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterBinarySensor, required_attributes=(clusters.DoorLock.Attributes.DoorState,), ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="SmokeCoAlarmDeviceMutedSensor", + measurement_to_ha=lambda x: ( + x == clusters.SmokeCoAlarm.Enums.MuteStateEnum.kMuted + ), + translation_key="muted", + entity_category=EntityCategory.DIAGNOSTIC, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.SmokeCoAlarm.Attributes.DeviceMuted,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="SmokeCoAlarmEndfOfServiceSensor", + measurement_to_ha=lambda x: ( + x == clusters.SmokeCoAlarm.Enums.EndOfServiceEnum.kExpired + ), + translation_key="end_of_service", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.SmokeCoAlarm.Attributes.EndOfServiceAlert,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="SmokeCoAlarmBatteryAlertSensor", + measurement_to_ha=lambda x: ( + x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal + ), + translation_key="battery_alert", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.SmokeCoAlarm.Attributes.BatteryAlert,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="SmokeCoAlarmTestInProgressSensor", + translation_key="test_in_progress", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.SmokeCoAlarm.Attributes.TestInProgress,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="SmokeCoAlarmHardwareFaultAlertSensor", + translation_key="hardware_fault", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.SmokeCoAlarm.Attributes.HardwareFaultAlert,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="SmokeCoAlarmSmokeStateSensor", + device_class=BinarySensorDeviceClass.SMOKE, + measurement_to_ha=lambda x: ( + x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal + ), + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.SmokeCoAlarm.Attributes.SmokeState,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="SmokeCoAlarmInterconnectSmokeAlarmSensor", + device_class=BinarySensorDeviceClass.SMOKE, + measurement_to_ha=lambda x: ( + x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal + ), + translation_key="interconnected_smoke_alarm", + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.SmokeCoAlarm.Attributes.InterconnectSmokeAlarm,), + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="SmokeCoAlarmInterconnectCOAlarmSensor", + device_class=BinarySensorDeviceClass.CO, + measurement_to_ha=lambda x: ( + x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal + ), + translation_key="interconnected_co_alarm", + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.SmokeCoAlarm.Attributes.InterconnectCOAlarm,), + ), ] diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index c191dedbcea..3e520adce62 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -1,5 +1,10 @@ { "entity": { + "binary_sensor": { + "muted": { + "default": "mdi:bell-off" + } + }, "fan": { "fan": { "state_attributes": { @@ -18,6 +23,9 @@ } }, "sensor": { + "contamination_state": { + "default": "mdi:air-filter" + }, "air_quality": { "default": "mdi:air-filter" }, diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index f6bf75d9e93..d91953610e9 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -245,4 +245,25 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSelectEntity, required_attributes=(clusters.OnOff.Attributes.StartUpOnOff,), ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterSelectEntityDescription( + key="SmokeCOSmokeSensitivityLevel", + entity_category=EntityCategory.CONFIG, + translation_key="sensitivity_level", + options=["high", "standard", "low"], + measurement_to_ha={ + 0: "high", + 1: "standard", + 2: "low", + }.get, + ha_to_native_value={ + "high": 0, + "standard": 1, + "low": 2, + }.get, + ), + entity_class=MatterSelectEntity, + required_attributes=(clusters.SmokeCoAlarm.Attributes.SmokeSensitivityLevel,), + ), ] diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 1d6d7ac77f3..499eb20aa59 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue @@ -52,6 +53,13 @@ AIR_QUALITY_MAP = { clusters.AirQuality.Enums.AirQualityEnum.kUnknownEnumValue: None, } +CONTAMINATION_STATE_MAP = { + clusters.SmokeCoAlarm.Enums.ContaminationStateEnum.kNormal: "normal", + clusters.SmokeCoAlarm.Enums.ContaminationStateEnum.kLow: "low", + clusters.SmokeCoAlarm.Enums.ContaminationStateEnum.kWarning: "warning", + clusters.SmokeCoAlarm.Enums.ContaminationStateEnum.kCritical: "critical", +} + async def async_setup_entry( hass: HomeAssistant, @@ -568,4 +576,29 @@ DISCOVERY_SCHEMAS = [ clusters.ElectricalEnergyMeasurement.Attributes.CumulativeEnergyImported, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="SmokeCOAlarmContaminationState", + translation_key="contamination_state", + device_class=SensorDeviceClass.ENUM, + # convert to set first to remove the duplicate unknown value + options=list(set(CONTAMINATION_STATE_MAP.values())), + measurement_to_ha=CONTAMINATION_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.SmokeCoAlarm.Attributes.ContaminationState,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="SmokeCOAlarmExpiryDate", + translation_key="expiry_date", + device_class=SensorDeviceClass.TIMESTAMP, + # raw value is epoch seconds + measurement_to_ha=datetime.fromtimestamp, + ), + entity_class=MatterSensor, + required_attributes=(clusters.SmokeCoAlarm.Attributes.ExpiryDate,), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index f75695cc3bc..d7258c02f95 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -46,6 +46,24 @@ }, "entity": { "binary_sensor": { + "battery_alert": { + "name": "Battery alert" + }, + "end_of_service": { + "name": "End of service" + }, + "hardware_fault": { + "name": "Hardware fault" + }, + "interconnected_smoke_alarm": { + "name": "Interconnected smoke alarm" + }, + "interconnected_co_alarm": { + "name": "Interconnected CO alarm" + }, + "test_in_progress": { + "name": "Test in progress" + }, "water_leak": { "name": "Water leak" }, @@ -54,6 +72,9 @@ }, "rain": { "name": "Rain" + }, + "muted": { + "name": "Muted" } }, "climate": { @@ -138,6 +159,14 @@ "mode": { "name": "Mode" }, + "sensitivity_level": { + "name": "Sensitivity", + "state": { + "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "standard": "Standard", + "high": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::high%]" + } + }, "startup_on_off": { "name": "Power-on behavior on startup", "state": { @@ -152,6 +181,15 @@ "activated_carbon_filter_condition": { "name": "Activated carbon filter condition" }, + "contamination_state": { + "name": "Contamination state", + "state": { + "normal": "Normal", + "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]", + "warning": "Warning", + "critical": "Critical" + } + }, "air_quality": { "name": "Air quality", "state": { @@ -163,6 +201,9 @@ "moderate": "Moderate" } }, + "expiry_date": { + "name": "Expiry date" + }, "flow": { "name": "Flow" }, diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index b4af00a0b47..ef1c2ae59d9 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -78,6 +78,16 @@ async def door_lock_fixture( return await setup_integration_with_node_fixture(hass, "door-lock", matter_client) +@pytest.fixture(name="smoke_detector") +async def smoke_detector_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a smoke detector node.""" + return await setup_integration_with_node_fixture( + hass, "smoke-detector", matter_client + ) + + @pytest.fixture(name="door_lock_with_unbolt") async def door_lock_with_unbolt_fixture( hass: HomeAssistant, matter_client: MagicMock diff --git a/tests/components/matter/fixtures/nodes/smoke-detector.json b/tests/components/matter/fixtures/nodes/smoke-detector.json new file mode 100644 index 00000000000..7ba525a7552 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/smoke-detector.json @@ -0,0 +1,238 @@ +{ + "node_id": 1, + "date_commissioned": "2024-09-13T20:07:21.672257", + "last_interview": "2024-09-13T21:10:36.026041", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 2 + } + ], + "0/29/1": [29, 31, 40, 42, 48, 49, 51, 60, 62, 63, 70], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65530": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65530": [0, 1], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "HEIMAN", + "0/40/2": 4619, + "0/40/3": "Smoke sensor", + "0/40/4": 4099, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "0.0", + "0/40/9": 16, + "0/40/10": "1.0", + "0/40/11": "20240403", + "0/40/14": "", + "0/40/15": "2404034099000007", + "0/40/16": false, + "0/40/18": "redacted", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 2, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65530": [0, 2], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 14, 15, 16, 18, 19, 65528, 65529, + 65530, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65530": [0, 1, 2], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65530": [], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65530, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "+uApc5vSQm4=", + "1": true + } + ], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "+uApc5vSQm4=", + "0/49/7": null, + "0/49/65532": 2, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65530": [], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/51/0": [], + "0/51/1": 1, + "0/51/2": 247340, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65530": [3], + "0/51/65531": [ + 0, 1, 2, 4, 5, 6, 7, 8, 65528, 65529, 65530, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65530": [], + "0/60/65531": [0, 1, 2, 65528, 65529, 65530, 65531, 65532, 65533], + "0/62/0": [], + "0/62/1": [], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65530": [], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65530, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65530": [], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "0/70/0": 300, + "0/70/1": 6000, + "0/70/2": 500, + "0/70/3": [], + "0/70/4": 0, + "0/70/5": 2, + "0/70/65532": 1, + "0/70/65533": 1, + "0/70/65528": [1], + "0/70/65529": [0, 2, 3], + "0/70/65530": [], + "0/70/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65530, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65530": [], + "1/3/65531": [0, 1, 65528, 65529, 65530, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 118, + "1": 1 + } + ], + "1/29/1": [3, 29, 47, 92], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65530": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65530, 65531, 65532, 65533], + "1/47/0": 0, + "1/47/1": 2, + "1/47/2": "B2", + "1/47/11": 0, + "1/47/12": 188, + "1/47/14": 0, + "1/47/15": false, + "1/47/16": 0, + "1/47/19": "CR123A", + "1/47/20": 0, + "1/47/24": 0, + "1/47/25": 0, + "1/47/31": [], + "1/47/65532": 10, + "1/47/65533": 2, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65530": [1], + "1/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 19, 20, 24, 25, 31, 65528, 65529, 65530, + 65531, 65532, 65533 + ], + "1/92/0": 0, + "1/92/1": 0, + "1/92/3": 0, + "1/92/4": 0, + "1/92/5": false, + "1/92/6": false, + "1/92/7": 0, + "1/92/65532": 1, + "1/92/65533": 1, + "1/92/65528": [], + "1/92/65529": [0], + "1/92/65530": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "1/92/65531": [ + 0, 1, 3, 4, 5, 6, 7, 65528, 65529, 65530, 65531, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index f419a12c59f..7feeb56ee7e 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.matter.binary_sensor import ( DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS, ) -from homeassistant.const import EntityCategory, Platform +from homeassistant.const import STATE_OFF, EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -128,3 +128,43 @@ async def test_battery_sensor( assert entry assert entry.entity_category == EntityCategory.DIAGNOSTIC + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_smoke_alarm( + hass: HomeAssistant, + matter_client: MagicMock, + smoke_detector: MatterNode, +) -> None: + """Test smoke detector.""" + + # Muted + state = hass.states.get("binary_sensor.smoke_sensor_muted") + assert state + assert state.state == STATE_OFF + + # End of service + state = hass.states.get("binary_sensor.smoke_sensor_end_of_service") + assert state + assert state.state == STATE_OFF + + # Battery alert + state = hass.states.get("binary_sensor.smoke_sensor_battery_alert") + assert state + assert state.state == STATE_OFF + + # Test in progress + state = hass.states.get("binary_sensor.smoke_sensor_test_in_progress") + assert state + assert state.state == STATE_OFF + + # Hardware fault + state = hass.states.get("binary_sensor.smoke_sensor_hardware_fault") + assert state + assert state.state == STATE_OFF + + # Smoke + state = hass.states.get("binary_sensor.smoke_sensor_smoke") + assert state + assert state.state == STATE_OFF diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 0d0429f785f..61234e6afcd 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -602,3 +602,23 @@ async def test_air_purifier_sensor( assert state.state == "100" assert state.attributes["state_class"] == "measurement" assert state.attributes["unit_of_measurement"] == "%" + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_smoke_alarm( + hass: HomeAssistant, + matter_client: MagicMock, + smoke_detector: MatterNode, +) -> None: + """Test smoke detector.""" + + # Battery + state = hass.states.get("sensor.smoke_sensor_battery") + assert state + assert state.state == "94" + + # Voltage + state = hass.states.get("sensor.smoke_sensor_voltage") + assert state + assert state.state == "0.0" From c1781cd79340e8f20b3fbfddb30c275a057b971e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 18:26:01 +0200 Subject: [PATCH 1114/1309] Only raise missing integration issue for config entry integrations (#126654) --- homeassistant/setup.py | 2 +- .../components/homeassistant/test_repairs.py | 2 ++ tests/test_setup.py | 27 +++++++++++++++---- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 102c48e1d07..331389da7c6 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -281,7 +281,7 @@ async def _async_setup_component( integration = await loader.async_get_integration(hass, domain) except loader.IntegrationNotFound: _log_error_setup_error(hass, domain, None, "Integration not found.") - if not hass.config.safe_mode: + if not hass.config.safe_mode and hass.config_entries.async_entries(domain): ir.async_create_issue( hass, HOMEASSISTANT_DOMAIN, diff --git a/tests/components/homeassistant/test_repairs.py b/tests/components/homeassistant/test_repairs.py index f81eaa694fa..f84b29d8d2d 100644 --- a/tests/components/homeassistant/test_repairs.py +++ b/tests/components/homeassistant/test_repairs.py @@ -23,6 +23,7 @@ async def test_integration_not_found_confirm_step( await hass.async_block_till_done() assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) await hass.async_block_till_done() + MockConfigEntry(domain="test1").add_to_hass(hass) assert await async_setup_component(hass, "test1", {}) is False await hass.async_block_till_done() entry1 = MockConfigEntry(domain="test1") @@ -83,6 +84,7 @@ async def test_integration_not_found_ignore_step( await hass.async_block_till_done() assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) await hass.async_block_till_done() + MockConfigEntry(domain="test1").add_to_hass(hass) assert await async_setup_component(hass, "test1", {}) is False await hass.async_block_till_done() entry1 = MockConfigEntry(domain="test1") diff --git a/tests/test_setup.py b/tests/test_setup.py index c50f8392d66..2d15c670cf7 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -248,22 +248,39 @@ async def test_component_not_found( hass: HomeAssistant, issue_registry: IssueRegistry ) -> None: """setup_component should raise a repair issue if component doesn't exist.""" + MockConfigEntry(domain="non_existing").add_to_hass(hass) assert await setup.async_setup_component(hass, "non_existing", {}) is False assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "integration_not_found.non_existing" - ) - assert issue - assert issue.translation_key == "integration_not_found" + assert ( + HOMEASSISTANT_DOMAIN, + "integration_not_found.non_existing", + ) in issue_registry.issues + + +async def test_yaml_component_not_found( + hass: HomeAssistant, issue_registry: IssueRegistry +) -> None: + """setup_component should only raise an exception for missing config entry integrations.""" + assert await setup.async_setup_component(hass, "non_existing", {}) is False + assert len(issue_registry.issues) == 0 + assert ( + HOMEASSISTANT_DOMAIN, + "integration_not_found.non_existing", + ) not in issue_registry.issues async def test_component_missing_not_raising_in_safe_mode( hass: HomeAssistant, issue_registry: IssueRegistry ) -> None: """setup_component should not raise an issue if component doesn't exist in safe.""" + MockConfigEntry(domain="non_existing").add_to_hass(hass) hass.config.safe_mode = True assert await setup.async_setup_component(hass, "non_existing", {}) is False assert len(issue_registry.issues) == 0 + assert ( + HOMEASSISTANT_DOMAIN, + "integration_not_found.non_existing", + ) not in issue_registry.issues async def test_component_not_double_initialized(hass: HomeAssistant) -> None: From c9351fdeeb9a06fa3c714ca85ec5fbe5ed8b1d4e Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 24 Sep 2024 18:54:06 +0200 Subject: [PATCH 1115/1309] Simplify cleanup in Husqvarna Automower (#126666) Simplify cleanup in Hsuqvarna Automower --- homeassistant/components/husqvarna_automower/__init__.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 117ded0dcf9..c7d69866313 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -13,7 +13,6 @@ from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, device_registry as dr, - entity_registry as er, ) from . import api @@ -87,12 +86,6 @@ def cleanup_removed_devices( hass: HomeAssistant, config_entry: ConfigEntry, available_devices: list[str] ) -> None: """Cleanup entity and device registry from removed devices.""" - entity_reg = er.async_get(hass) - for entity in er.async_entries_for_config_entry(entity_reg, config_entry.entry_id): - if entity.unique_id.split("_")[0] not in available_devices: - _LOGGER.debug("Removing obsolete entity entry %s", entity.entity_id) - entity_reg.async_remove(entity.entity_id) - device_reg = dr.async_get(hass) identifiers = {(DOMAIN, mower_id) for mower_id in available_devices} for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id): From dc77b2d5839c4c2a29c67ff2d1c88bcf6e6f38d4 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 24 Sep 2024 18:57:47 +0200 Subject: [PATCH 1116/1309] Add work area switch for Husqvarna Automower (#126376) * Add work area switch for Husqvarna Automower * move work area deletion test to separate file * stale doctsrings * don't use custom test file * use _attr_name * ruff * add available property * hassfest * fix tests * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * constants --------- Co-authored-by: Joost Lekkerkerker --- .../components/husqvarna_automower/entity.py | 72 ++++++++- .../components/husqvarna_automower/number.py | 70 +++------ .../husqvarna_automower/strings.json | 7 +- .../components/husqvarna_automower/switch.py | 55 ++++++- .../snapshots/test_number.ambr | 6 +- .../snapshots/test_switch.ambr | 138 ++++++++++++++++++ .../husqvarna_automower/test_init.py | 43 +++++- .../husqvarna_automower/test_number.py | 25 ---- .../husqvarna_automower/test_switch.py | 93 ++++++++++-- 9 files changed, 404 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index d6af85aaad7..fd9e7578fb2 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -4,17 +4,18 @@ import asyncio from collections.abc import Awaitable, Callable, Coroutine import functools import logging -from typing import Any +from typing import TYPE_CHECKING, Any from aioautomower.exceptions import ApiException -from aioautomower.model import MowerActivities, MowerAttributes, MowerStates +from aioautomower.model import MowerActivities, MowerAttributes, MowerStates, WorkArea -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AutomowerDataUpdateCoordinator +from . import AutomowerConfigEntry, AutomowerDataUpdateCoordinator from .const import DOMAIN, EXECUTION_TIME_DELAY _LOGGER = logging.getLogger(__name__) @@ -44,6 +45,38 @@ def _check_error_free(mower_attributes: MowerAttributes) -> bool: ) +@callback +def _work_area_translation_key(work_area_id: int, key: str) -> str: + """Return the translation key.""" + if work_area_id == 0: + return f"my_lawn_{key}" + return f"work_area_{key}" + + +@callback +def async_remove_work_area_entities( + hass: HomeAssistant, + coordinator: AutomowerDataUpdateCoordinator, + entry: AutomowerConfigEntry, + mower_id: str, +) -> None: + """Remove deleted work areas from Home Assistant.""" + entity_reg = er.async_get(hass) + active_work_areas = set() + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + for work_area_id in _work_areas: + uid = f"{mower_id}_{work_area_id}_cutting_height_work_area" + active_work_areas.add(uid) + for entity_entry in er.async_entries_for_config_entry(entity_reg, entry.entry_id): + if ( + (split := entity_entry.unique_id.split("_"))[0] == mower_id + and split[-1] == "area" + and entity_entry.unique_id not in active_work_areas + ): + entity_reg.async_remove(entity_entry.entity_id) + + def handle_sending_exception( poll_after_sending: bool = False, ) -> Callable[ @@ -120,3 +153,34 @@ class AutomowerControlEntity(AutomowerAvailableEntity): def available(self) -> bool: """Return True if the device is available.""" return super().available and _check_error_free(self.mower_attributes) + + +class WorkAreaControlEntity(AutomowerControlEntity): + """Base entity work work areas with control function.""" + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + work_area_id: int, + ) -> None: + """Initialize AutomowerEntity.""" + super().__init__(mower_id, coordinator) + self.work_area_id = work_area_id + + @property + def work_areas(self) -> dict[int, WorkArea]: + """Get the work areas from the mower attributes.""" + if TYPE_CHECKING: + assert self.mower_attributes.work_areas is not None + return self.mower_attributes.work_areas + + @property + def work_area_attributes(self) -> WorkArea: + """Get the work area attributes of the current work area.""" + return self.work_areas[self.work_area_id] + + @property + def available(self) -> bool: + """Return True if the work area is available and the mower has no errors.""" + return super().available and self.work_area_id in self.work_areas diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index 5fc79ea72f7..c22bb4d37f7 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -9,14 +9,19 @@ from aioautomower.model import MowerAttributes, WorkArea from aioautomower.session import AutomowerSession from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.const import PERCENTAGE, EntityCategory, Platform +from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator -from .entity import AutomowerControlEntity, handle_sending_exception +from .entity import ( + AutomowerControlEntity, + WorkAreaControlEntity, + _work_area_translation_key, + async_remove_work_area_entities, + handle_sending_exception, +) _LOGGER = logging.getLogger(__name__) @@ -30,14 +35,6 @@ def _async_get_cutting_height(data: MowerAttributes) -> int: return data.settings.cutting_height -@callback -def _work_area_translation_key(work_area_id: int) -> str: - """Return the translation key.""" - if work_area_id == 0: - return "my_lawn_cutting_height" - return "work_area_cutting_height" - - async def async_set_work_area_cutting_height( coordinator: AutomowerDataUpdateCoordinator, mower_id: str, @@ -88,7 +85,7 @@ class AutomowerWorkAreaNumberEntityDescription(NumberEntityDescription): """Describes Automower work area number entity.""" value_fn: Callable[[WorkArea], int] - translation_key_fn: Callable[[int], str] + translation_key_fn: Callable[[int, str], str] set_value_fn: Callable[ [AutomowerDataUpdateCoordinator, str, float, int], Awaitable[Any] ] @@ -126,7 +123,7 @@ async def async_setup_entry( for description in WORK_AREA_NUMBER_TYPES for work_area_id in _work_areas ) - async_remove_entities(hass, coordinator, entry, mower_id) + async_remove_work_area_entities(hass, coordinator, entry, mower_id) entities.extend( AutomowerNumberEntity(mower_id, coordinator, description) for description in NUMBER_TYPES @@ -164,7 +161,7 @@ class AutomowerNumberEntity(AutomowerControlEntity, NumberEntity): ) -class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity): +class AutomowerWorkAreaNumberEntity(WorkAreaControlEntity, NumberEntity): """Defining the AutomowerWorkAreaNumberEntity with AutomowerWorkAreaNumberEntityDescription.""" entity_description: AutomowerWorkAreaNumberEntityDescription @@ -177,28 +174,24 @@ class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity): work_area_id: int, ) -> None: """Set up AutomowerNumberEntity.""" - super().__init__(mower_id, coordinator) + super().__init__(mower_id, coordinator, work_area_id) self.entity_description = description - self.work_area_id = work_area_id self._attr_unique_id = f"{mower_id}_{work_area_id}_{description.key}" - self._attr_translation_placeholders = {"work_area": self.work_area.name} - - @property - def work_area(self) -> WorkArea: - """Get the mower attributes of the current mower.""" - if TYPE_CHECKING: - assert self.mower_attributes.work_areas is not None - return self.mower_attributes.work_areas[self.work_area_id] + self._attr_translation_placeholders = { + "work_area": self.work_area_attributes.name + } @property def translation_key(self) -> str: """Return the translation key of the work area.""" - return self.entity_description.translation_key_fn(self.work_area_id) + return self.entity_description.translation_key_fn( + self.work_area_id, self.entity_description.key + ) @property def native_value(self) -> float: """Return the state of the number.""" - return self.entity_description.value_fn(self.work_area) + return self.entity_description.value_fn(self.work_area_attributes) @handle_sending_exception(poll_after_sending=True) async def async_set_native_value(self, value: float) -> None: @@ -206,28 +199,3 @@ class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity): await self.entity_description.set_value_fn( self.coordinator, self.mower_id, value, self.work_area_id ) - - -@callback -def async_remove_entities( - hass: HomeAssistant, - coordinator: AutomowerDataUpdateCoordinator, - entry: AutomowerConfigEntry, - mower_id: str, -) -> None: - """Remove deleted work areas from Home Assistant.""" - entity_reg = er.async_get(hass) - active_work_areas = set() - _work_areas = coordinator.data[mower_id].work_areas - if _work_areas is not None: - for work_area_id in _work_areas: - uid = f"{mower_id}_{work_area_id}_cutting_height_work_area" - active_work_areas.add(uid) - for entity_entry in er.async_entries_for_config_entry(entity_reg, entry.entry_id): - if ( - entity_entry.domain == Platform.NUMBER - and (split := entity_entry.unique_id.split("_"))[0] == mower_id - and split[-1] == "area" - and entity_entry.unique_id not in active_work_areas - ): - entity_reg.async_remove(entity_entry.entity_id) diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index f251a8bf5e0..5930a04376d 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -54,10 +54,10 @@ "cutting_height": { "name": "Cutting height" }, - "my_lawn_cutting_height": { + "my_lawn_cutting_height_work_area": { "name": "My lawn cutting height" }, - "work_area_cutting_height": { + "work_area_cutting_height_work_area": { "name": "{work_area} cutting height" } }, @@ -271,6 +271,9 @@ }, "stay_out_zones": { "name": "Avoid {stay_out_zone}" + }, + "my_lawn_work_area": { + "name": "My lawn" } } }, diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py index a4b60054583..1808b651d3d 100644 --- a/homeassistant/components/husqvarna_automower/switch.py +++ b/homeassistant/components/husqvarna_automower/switch.py @@ -13,7 +13,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import AutomowerConfigEntry from .coordinator import AutomowerDataUpdateCoordinator -from .entity import AutomowerControlEntity, handle_sending_exception +from .entity import ( + AutomowerControlEntity, + WorkAreaControlEntity, + _work_area_translation_key, + handle_sending_exception, +) _LOGGER = logging.getLogger(__name__) @@ -41,6 +46,13 @@ async def async_setup_entry( for stay_out_zone_uid in _stay_out_zones.zones ) async_remove_entities(hass, coordinator, entry, mower_id) + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + WorkAreaSwitchEntity(coordinator, mower_id, work_area_id) + for work_area_id in _work_areas + ) async_add_entities(entities) @@ -131,6 +143,47 @@ class AutomowerStayOutZoneSwitchEntity(AutomowerControlEntity, SwitchEntity): ) +class WorkAreaSwitchEntity(WorkAreaControlEntity, SwitchEntity): + """Defining the Automower work area switch.""" + + def __init__( + self, + coordinator: AutomowerDataUpdateCoordinator, + mower_id: str, + work_area_id: int, + ) -> None: + """Set up Automower switch.""" + super().__init__(mower_id, coordinator, work_area_id) + key = "work_area" + self._attr_translation_key = _work_area_translation_key(work_area_id, key) + self._attr_unique_id = f"{mower_id}_{work_area_id}_{key}" + if self.work_area_attributes.name == "my_lawn": + self._attr_translation_placeholders = { + "work_area": self.work_area_attributes.name + } + else: + self._attr_name = self.work_area_attributes.name + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.work_area_attributes.enabled + + @handle_sending_exception(poll_after_sending=True) + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.api.commands.workarea_settings( + self.mower_id, self.work_area_id, enabled=False + ) + + @handle_sending_exception(poll_after_sending=True) + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.api.commands.workarea_settings( + self.mower_id, self.work_area_id, enabled=True + ) + + @callback def async_remove_entities( hass: HomeAssistant, diff --git a/tests/components/husqvarna_automower/snapshots/test_number.ambr b/tests/components/husqvarna_automower/snapshots/test_number.ambr index 63e42ee5d5c..b0ccce5800a 100644 --- a/tests/components/husqvarna_automower/snapshots/test_number.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_number.ambr @@ -32,7 +32,7 @@ 'platform': 'husqvarna_automower', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'work_area_cutting_height', + 'translation_key': 'work_area_cutting_height_work_area', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_654321_cutting_height_work_area', 'unit_of_measurement': '%', }) @@ -143,7 +143,7 @@ 'platform': 'husqvarna_automower', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'work_area_cutting_height', + 'translation_key': 'work_area_cutting_height_work_area', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_123456_cutting_height_work_area', 'unit_of_measurement': '%', }) @@ -199,7 +199,7 @@ 'platform': 'husqvarna_automower', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'my_lawn_cutting_height', + 'translation_key': 'my_lawn_cutting_height_work_area', 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_0_cutting_height_work_area', 'unit_of_measurement': '%', }) diff --git a/tests/components/husqvarna_automower/snapshots/test_switch.ambr b/tests/components/husqvarna_automower/snapshots/test_switch.ambr index 4bc851fa73d..8f8f6b367c0 100644 --- a/tests/components/husqvarna_automower/snapshots/test_switch.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_switch.ambr @@ -91,6 +91,52 @@ 'state': 'on', }) # --- +# name: test_switch_snapshot[switch.test_mower_1_back_lawn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_mower_1_back_lawn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Back lawn', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'work_area_work_area', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_654321_work_area', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_mower_1_back_lawn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Back lawn', + }), + 'context': , + 'entity_id': 'switch.test_mower_1_back_lawn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_snapshot[switch.test_mower_1_enable_schedule-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -137,6 +183,98 @@ 'state': 'on', }) # --- +# name: test_switch_snapshot[switch.test_mower_1_front_lawn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_mower_1_front_lawn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Front lawn', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'work_area_work_area', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_123456_work_area', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_mower_1_front_lawn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Front lawn', + }), + 'context': , + 'entity_id': 'switch.test_mower_1_front_lawn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_snapshot[switch.test_mower_1_my_lawn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test_mower_1_my_lawn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My lawn', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'my_lawn_work_area', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_0_work_area', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_snapshot[switch.test_mower_1_my_lawn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 My lawn', + }), + 'context': , + 'entity_id': 'switch.test_mower_1_my_lawn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_snapshot[switch.test_mower_2_enable_schedule-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ab80aea5a3f..bdbb13ff37e 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -167,6 +167,31 @@ async def test_device_info( assert reg_device == snapshot +async def test_workarea_deleted( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test if work area is deleted after removed.""" + + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + current_entries = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + + del values[TEST_MOWER_ID].work_areas[123456] + mock_automower_client.get_status.return_value = values + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) == (current_entries - 2) + + async def test_coordinator_automatic_registry_cleanup( hass: HomeAssistant, mock_automower_client: AsyncMock, @@ -179,8 +204,12 @@ async def test_coordinator_automatic_registry_cleanup( entry = hass.config_entries.async_entries(DOMAIN)[0] await hass.async_block_till_done() - assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 42 - assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 2 + current_entites = len( + er.async_entries_for_config_entry(entity_registry, entry.entry_id) + ) + current_devices = len( + dr.async_entries_for_config_entry(device_registry, entry.entry_id) + ) values = mower_list_to_dictionary_dataclass( load_json_value_fixture("mower.json", DOMAIN) @@ -190,5 +219,11 @@ async def test_coordinator_automatic_registry_cleanup( await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 - assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 1 + assert ( + len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) + == current_entites - 33 + ) + assert ( + len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) + == current_devices - 1 + ) diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index 10092528866..b7ff84e14e6 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -109,31 +109,6 @@ async def test_number_workarea_commands( assert len(mocked_method.mock_calls) == 2 -async def test_workarea_deleted( - hass: HomeAssistant, - mock_automower_client: AsyncMock, - mock_config_entry: MockConfigEntry, - entity_registry: er.EntityRegistry, -) -> None: - """Test if work area is deleted after removed.""" - - values = mower_list_to_dictionary_dataclass( - load_json_value_fixture("mower.json", DOMAIN) - ) - await setup_integration(hass, mock_config_entry) - current_entries = len( - er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) - ) - - del values[TEST_MOWER_ID].work_areas[123456] - mock_automower_client.get_status.return_value = values - await hass.config_entries.async_reload(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert len( - er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) - ) == (current_entries - 1) - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_snapshot( hass: HomeAssistant, diff --git a/tests/components/husqvarna_automower/test_switch.py b/tests/components/husqvarna_automower/test_switch.py index 5b4e465e253..8c62ff89154 100644 --- a/tests/components/husqvarna_automower/test_switch.py +++ b/tests/components/husqvarna_automower/test_switch.py @@ -15,7 +15,14 @@ from homeassistant.components.husqvarna_automower.const import ( EXECUTION_TIME_DELAY, ) from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL -from homeassistant.const import Platform +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -31,6 +38,7 @@ from tests.common import ( ) TEST_ZONE_ID = "AAAAAAAA-BBBB-CCCC-DDDD-123456789101" +TEST_AREA_ID = 0 async def test_switch_states( @@ -61,9 +69,9 @@ async def test_switch_states( @pytest.mark.parametrize( ("service", "aioautomower_command"), [ - ("turn_off", "park_until_further_notice"), - ("turn_on", "resume_schedule"), - ("toggle", "park_until_further_notice"), + (SERVICE_TURN_OFF, "park_until_further_notice"), + (SERVICE_TURN_ON, "resume_schedule"), + (SERVICE_TOGGLE, "park_until_further_notice"), ], ) async def test_switch_commands( @@ -76,9 +84,9 @@ async def test_switch_commands( """Test switch commands.""" await setup_integration(hass, mock_config_entry) await hass.services.async_call( - domain="switch", + domain=SWITCH_DOMAIN, service=service, - service_data={"entity_id": "switch.test_mower_1_enable_schedule"}, + service_data={ATTR_ENTITY_ID: "switch.test_mower_1_enable_schedule"}, blocking=True, ) mocked_method = getattr(mock_automower_client.commands, aioautomower_command) @@ -90,9 +98,9 @@ async def test_switch_commands( match="Failed to send command: Test error", ): await hass.services.async_call( - domain="switch", + domain=SWITCH_DOMAIN, service=service, - service_data={"entity_id": "switch.test_mower_1_enable_schedule"}, + service_data={ATTR_ENTITY_ID: "switch.test_mower_1_enable_schedule"}, blocking=True, ) assert len(mocked_method.mock_calls) == 2 @@ -101,9 +109,9 @@ async def test_switch_commands( @pytest.mark.parametrize( ("service", "boolean", "excepted_state"), [ - ("turn_off", False, "off"), - ("turn_on", True, "on"), - ("toggle", True, "on"), + (SERVICE_TURN_OFF, False, "off"), + (SERVICE_TURN_ON, True, "on"), + (SERVICE_TOGGLE, True, "on"), ], ) async def test_stay_out_zone_switch_commands( @@ -126,9 +134,9 @@ async def test_stay_out_zone_switch_commands( mocked_method = AsyncMock() setattr(mock_automower_client.commands, "switch_stay_out_zone", mocked_method) await hass.services.async_call( - domain="switch", + domain=SWITCH_DOMAIN, service=service, - service_data={"entity_id": entity_id}, + service_data={ATTR_ENTITY_ID: entity_id}, blocking=False, ) freezer.tick(timedelta(seconds=EXECUTION_TIME_DELAY)) @@ -145,9 +153,64 @@ async def test_stay_out_zone_switch_commands( match="Failed to send command: Test error", ): await hass.services.async_call( - domain="switch", + domain=SWITCH_DOMAIN, service=service, - service_data={"entity_id": entity_id}, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert len(mocked_method.mock_calls) == 2 + + +@pytest.mark.parametrize( + ("service", "boolean", "excepted_state"), + [ + (SERVICE_TURN_OFF, False, "off"), + (SERVICE_TURN_ON, True, "on"), + (SERVICE_TOGGLE, True, "on"), + ], +) +async def test_work_area_switch_commands( + hass: HomeAssistant, + service: str, + boolean: bool, + excepted_state: str, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test switch commands.""" + entity_id = "switch.test_mower_1_my_lawn" + await setup_integration(hass, mock_config_entry) + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + values[TEST_MOWER_ID].work_areas[TEST_AREA_ID].enabled = boolean + mock_automower_client.get_status.return_value = values + mocked_method = AsyncMock() + setattr(mock_automower_client.commands, "workarea_settings", mocked_method) + await hass.services.async_call( + domain=SWITCH_DOMAIN, + service=service, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=False, + ) + freezer.tick(timedelta(seconds=EXECUTION_TIME_DELAY)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mocked_method.assert_called_once_with(TEST_MOWER_ID, TEST_AREA_ID, enabled=boolean) + state = hass.states.get(entity_id) + assert state is not None + assert state.state == excepted_state + + mocked_method.side_effect = ApiException("Test error") + with pytest.raises( + HomeAssistantError, + match="Failed to send command: Test error", + ): + await hass.services.async_call( + domain=SWITCH_DOMAIN, + service=service, + service_data={ATTR_ENTITY_ID: entity_id}, blocking=True, ) assert len(mocked_method.mock_calls) == 2 From c099f4f50f706fdb1be06fa62c792976196be885 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 24 Sep 2024 19:09:19 +0200 Subject: [PATCH 1117/1309] Use vol.Coerce for SourceType in mqtt device_tracker (#126594) --- homeassistant/components/mqtt/device_tracker.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 13b89256e21..b87db40ccf7 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -9,11 +9,7 @@ from typing import TYPE_CHECKING import voluptuous as vol from homeassistant.components import device_tracker -from homeassistant.components.device_tracker import ( - SOURCE_TYPES, - SourceType, - TrackerEntity, -) +from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_GPS_ACCURACY, @@ -65,8 +61,8 @@ PLATFORM_SCHEMA_MODERN_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string, vol.Optional(CONF_PAYLOAD_NOT_HOME, default=STATE_NOT_HOME): cv.string, vol.Optional(CONF_PAYLOAD_RESET, default=DEFAULT_PAYLOAD_RESET): cv.string, - vol.Optional(CONF_SOURCE_TYPE, default=DEFAULT_SOURCE_TYPE): vol.In( - SOURCE_TYPES + vol.Optional(CONF_SOURCE_TYPE, default=DEFAULT_SOURCE_TYPE): vol.Coerce( + SourceType ), }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) @@ -191,7 +187,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): return self._location_name @property - def source_type(self) -> SourceType | str: + def source_type(self) -> SourceType: """Return the source type, eg gps or router, of the device.""" - source_type: SourceType | str = self._config[CONF_SOURCE_TYPE] + source_type: SourceType = self._config[CONF_SOURCE_TYPE] return source_type From 354ee35ee4c473432d53ce27e4b6acd0b54c0b81 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 24 Sep 2024 19:34:34 +0200 Subject: [PATCH 1118/1309] Extend the lists of Matter climate devices that need special treatment (#126644) --- homeassistant/components/matter/climate.py | 87 ++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 4eec539c0db..f41fa3baaba 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -46,7 +46,36 @@ SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = { # We were told this is just some legacy inheritance from zigbee specs. # In the list below specify tuples of (vendorid, productid) of devices for # which we just need a single setpoint to control both heating and cooling. + (0x1209, 0x8000), + (0x1209, 0x8001), + (0x1209, 0x8002), + (0x1209, 0x8003), + (0x1209, 0x8004), + (0x1209, 0x8005), + (0x1209, 0x8006), (0x1209, 0x8007), + (0x1209, 0x8008), + (0x1209, 0x8009), + (0x1209, 0x800A), + (0x1209, 0x800B), + (0x1209, 0x800C), + (0x1209, 0x800D), + (0x1209, 0x800E), + (0x1209, 0x8010), + (0x1209, 0x8011), + (0x1209, 0x8012), + (0x1209, 0x8013), + (0x1209, 0x8014), + (0x1209, 0x8020), + (0x1209, 0x8021), + (0x1209, 0x8022), + (0x1209, 0x8023), + (0x1209, 0x8024), + (0x1209, 0x8025), + (0x1209, 0x8026), + (0x1209, 0x8027), + (0x1209, 0x8028), + (0x1209, 0x8029), } SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = { @@ -55,7 +84,36 @@ SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = { # support dry mode. (0x0001, 0x0108), (0x0001, 0x010A), + (0x1209, 0x8000), + (0x1209, 0x8001), + (0x1209, 0x8002), + (0x1209, 0x8003), + (0x1209, 0x8004), + (0x1209, 0x8005), + (0x1209, 0x8006), (0x1209, 0x8007), + (0x1209, 0x8008), + (0x1209, 0x8009), + (0x1209, 0x800A), + (0x1209, 0x800B), + (0x1209, 0x800C), + (0x1209, 0x800D), + (0x1209, 0x800E), + (0x1209, 0x8010), + (0x1209, 0x8011), + (0x1209, 0x8012), + (0x1209, 0x8013), + (0x1209, 0x8014), + (0x1209, 0x8020), + (0x1209, 0x8021), + (0x1209, 0x8022), + (0x1209, 0x8023), + (0x1209, 0x8024), + (0x1209, 0x8025), + (0x1209, 0x8026), + (0x1209, 0x8027), + (0x1209, 0x8028), + (0x1209, 0x8029), } SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = { @@ -64,7 +122,36 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = { # support fan-only mode. (0x0001, 0x0108), (0x0001, 0x010A), + (0x1209, 0x8000), + (0x1209, 0x8001), + (0x1209, 0x8002), + (0x1209, 0x8003), + (0x1209, 0x8004), + (0x1209, 0x8005), + (0x1209, 0x8006), (0x1209, 0x8007), + (0x1209, 0x8008), + (0x1209, 0x8009), + (0x1209, 0x800A), + (0x1209, 0x800B), + (0x1209, 0x800C), + (0x1209, 0x800D), + (0x1209, 0x800E), + (0x1209, 0x8010), + (0x1209, 0x8011), + (0x1209, 0x8012), + (0x1209, 0x8013), + (0x1209, 0x8014), + (0x1209, 0x8020), + (0x1209, 0x8021), + (0x1209, 0x8022), + (0x1209, 0x8023), + (0x1209, 0x8024), + (0x1209, 0x8025), + (0x1209, 0x8026), + (0x1209, 0x8027), + (0x1209, 0x8028), + (0x1209, 0x8029), } SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum From 5e7d5c6312ab1b16b835114708646ad964baff18 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 24 Sep 2024 19:36:09 +0200 Subject: [PATCH 1119/1309] Prevent KeyError when Matter device has invalid value for ModeSelect (#126672) --- homeassistant/components/matter/select.py | 2 +- tests/components/matter/test_select.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index d91953610e9..1bba18b2c5b 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -105,7 +105,7 @@ class MatterModeSelectEntity(MatterSelectEntity): ) modes = {mode.mode: mode.label for mode in cluster.supportedModes} self._attr_options = list(modes.values()) - self._attr_current_option = modes[cluster.currentMode] + self._attr_current_option = modes.get(cluster.currentMode) # handle optional Description attribute as descriptive name for the mode if desc := getattr(cluster, "description", None): self._attr_name = desc diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index bda2a933d42..27ce6d32c22 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -97,8 +97,8 @@ async def test_attribute_select_entities( await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state.state == "on" - # test that an invalid value (e.g. 255) leads to an unknown state - set_node_attribute(light_node, 1, 6, 16387, 255) + # test that an invalid value (e.g. 253) leads to an unknown state + set_node_attribute(light_node, 1, 6, 16387, 253) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state.state == "unknown" From 08bdf797f0289dbb372529b2b21ae25c3da365b5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 24 Sep 2024 19:48:44 +0200 Subject: [PATCH 1120/1309] Update RestrictedPython to 7.2 (#126662) --- 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 dcc0e38c737..34b1d414915 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.0"] + "requirements": ["RestrictedPython==7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15668373eec..03a76744ec1 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.0 +RestrictedPython==7.2 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a22e04ad1e..0d1815481a9 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.0 +RestrictedPython==7.2 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 From 9dfabc3fb763050cfe6d59fe7888572ec87bf20d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 24 Sep 2024 20:03:23 +0200 Subject: [PATCH 1121/1309] Adjust automation to plural triggers/conditions/actions keys (#123823) * Adjust automation to plural triggers/conditions/actions keys * Fix some tests * Adjust websocket tests * Fix search tests * Convert blueprint and blueprint inputs to modern schema * Pass schema when creating Blueprint object * Update tests * Adjust websocket api --------- Co-authored-by: Joostlek Co-authored-by: Erik --- .../components/automation/__init__.py | 14 +- homeassistant/components/automation/config.py | 65 +++- homeassistant/components/automation/const.py | 2 + .../components/automation/helpers.py | 10 +- .../components/blueprint/__init__.py | 2 +- .../components/blueprint/importer.py | 18 +- homeassistant/components/blueprint/models.py | 12 +- .../components/blueprint/websocket_api.py | 5 +- homeassistant/components/config/automation.py | 11 +- homeassistant/components/script/helpers.py | 9 +- .../components/websocket_api/commands.py | 16 +- tests/components/automation/test_blueprint.py | 5 +- tests/components/automation/test_init.py | 278 ++++++++++++++---- tests/components/automation/test_recorder.py | 2 +- .../blueprint/test_default_blueprints.py | 4 +- tests/components/blueprint/test_models.py | 29 +- .../blueprint/test_websocket_api.py | 23 +- tests/components/config/test_automation.py | 33 ++- .../components/device_automation/test_init.py | 8 +- tests/components/script/test_blueprint.py | 11 +- tests/components/search/test_init.py | 4 +- tests/components/trace/test_websocket_api.py | 68 ++--- .../components/websocket_api/test_commands.py | 20 +- .../automation/test_event_service.yaml | 4 +- .../test_event_service_legacy_schema.yaml | 18 ++ 25 files changed, 488 insertions(+), 183 deletions(-) create mode 100644 tests/testing_config/blueprints/automation/test_event_service_legacy_schema.yaml diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 1db5125a8a6..a40df67e2ca 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( ATTR_MODE, ATTR_NAME, CONF_ALIAS, - CONF_CONDITION, + CONF_CONDITIONS, CONF_DEVICE_ID, CONF_ENTITY_ID, CONF_EVENT_DATA, @@ -98,11 +98,11 @@ from homeassistant.util.hass_dict import HassKey from .config import AutomationConfig, ValidationStatus from .const import ( - CONF_ACTION, + CONF_ACTIONS, CONF_INITIAL_STATE, CONF_TRACE, - CONF_TRIGGER, CONF_TRIGGER_VARIABLES, + CONF_TRIGGERS, DEFAULT_INITIAL_STATE, DOMAIN, LOGGER, @@ -955,7 +955,7 @@ async def _create_automation_entities( action_script = Script( hass, - config_block[CONF_ACTION], + config_block[CONF_ACTIONS], name, DOMAIN, running_description="automation actions", @@ -968,7 +968,7 @@ async def _create_automation_entities( # and so will pass them on to the script. ) - if CONF_CONDITION in config_block: + if CONF_CONDITIONS in config_block: cond_func = await _async_process_if(hass, name, config_block) if cond_func is None: @@ -991,7 +991,7 @@ async def _create_automation_entities( entity = AutomationEntity( automation_id, name, - config_block[CONF_TRIGGER], + config_block[CONF_TRIGGERS], cond_func, action_script, initial_state, @@ -1131,7 +1131,7 @@ async def _async_process_if( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> IfAction | None: """Process if checks.""" - if_configs = config[CONF_CONDITION] + if_configs = config[CONF_CONDITIONS] try: if_action = await condition.async_conditions_from_config( diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index cc4e9aba7fb..fe74865ca92 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -16,6 +16,7 @@ from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_CONDITION, + CONF_CONDITIONS, CONF_DESCRIPTION, CONF_ID, CONF_VARIABLES, @@ -30,11 +31,13 @@ from homeassistant.util.yaml.input import UndefinedSubstitution from .const import ( CONF_ACTION, + CONF_ACTIONS, CONF_HIDE_ENTITY, CONF_INITIAL_STATE, CONF_TRACE, CONF_TRIGGER, CONF_TRIGGER_VARIABLES, + CONF_TRIGGERS, DOMAIN, LOGGER, ) @@ -52,7 +55,41 @@ _MINIMAL_PLATFORM_SCHEMA = vol.Schema( ) +def _backward_compat_schema(value: Any | None) -> Any: + """Backward compatibility for automations.""" + + if not isinstance(value, dict): + return value + + # `trigger` has been renamed to `triggers` + if CONF_TRIGGER in value: + if CONF_TRIGGERS in value: + raise vol.Invalid( + "Cannot specify both 'trigger' and 'triggers'. Please use 'triggers' only." + ) + value[CONF_TRIGGERS] = value.pop(CONF_TRIGGER) + + # `condition` has been renamed to `conditions` + if CONF_CONDITION in value: + if CONF_CONDITIONS in value: + raise vol.Invalid( + "Cannot specify both 'condition' and 'conditions'. Please use 'conditions' only." + ) + value[CONF_CONDITIONS] = value.pop(CONF_CONDITION) + + # `action` has been renamed to `actions` + if CONF_ACTION in value: + if CONF_ACTIONS in value: + raise vol.Invalid( + "Cannot specify both 'action' and 'actions'. Please use 'actions' only." + ) + value[CONF_ACTIONS] = value.pop(CONF_ACTION) + + return value + + PLATFORM_SCHEMA = vol.All( + _backward_compat_schema, cv.deprecated(CONF_HIDE_ENTITY), script.make_script_schema( { @@ -63,16 +100,20 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_TRACE, default={}): TRACE_CONFIG_SCHEMA, vol.Optional(CONF_INITIAL_STATE): cv.boolean, vol.Optional(CONF_HIDE_ENTITY): cv.boolean, - vol.Required(CONF_TRIGGER): cv.TRIGGER_SCHEMA, - vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA, + vol.Required(CONF_TRIGGERS): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, vol.Optional(CONF_TRIGGER_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, - vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, + vol.Required(CONF_ACTIONS): cv.SCRIPT_SCHEMA, }, script.SCRIPT_MODE_SINGLE, ), ) +AUTOMATION_BLUEPRINT_SCHEMA = vol.All( + _backward_compat_schema, blueprint.schemas.BLUEPRINT_SCHEMA +) + async def _async_validate_config_item( # noqa: C901 hass: HomeAssistant, @@ -151,7 +192,9 @@ async def _async_validate_config_item( # noqa: C901 uses_blueprint = True blueprints = async_get_blueprints(hass) try: - blueprint_inputs = await blueprints.async_inputs_from_config(config) + blueprint_inputs = await blueprints.async_inputs_from_config( + _backward_compat_schema(config) + ) except blueprint.BlueprintException as err: if warn_on_errors: LOGGER.error( @@ -199,8 +242,8 @@ async def _async_validate_config_item( # noqa: C901 automation_config.raw_config = raw_config try: - automation_config[CONF_TRIGGER] = await async_validate_trigger_config( - hass, validated_config[CONF_TRIGGER] + automation_config[CONF_TRIGGERS] = await async_validate_trigger_config( + hass, validated_config[CONF_TRIGGERS] ) except ( vol.Invalid, @@ -216,10 +259,10 @@ async def _async_validate_config_item( # noqa: C901 ) return automation_config - if CONF_CONDITION in validated_config: + if CONF_CONDITIONS in validated_config: try: - automation_config[CONF_CONDITION] = await async_validate_conditions_config( - hass, validated_config[CONF_CONDITION] + automation_config[CONF_CONDITIONS] = await async_validate_conditions_config( + hass, validated_config[CONF_CONDITIONS] ) except ( vol.Invalid, @@ -239,8 +282,8 @@ async def _async_validate_config_item( # noqa: C901 return automation_config try: - automation_config[CONF_ACTION] = await script.async_validate_actions_config( - hass, validated_config[CONF_ACTION] + automation_config[CONF_ACTIONS] = await script.async_validate_actions_config( + hass, validated_config[CONF_ACTIONS] ) except ( vol.Invalid, diff --git a/homeassistant/components/automation/const.py b/homeassistant/components/automation/const.py index e6be35494d7..c4ac636282e 100644 --- a/homeassistant/components/automation/const.py +++ b/homeassistant/components/automation/const.py @@ -3,7 +3,9 @@ import logging CONF_ACTION = "action" +CONF_ACTIONS = "actions" CONF_TRIGGER = "trigger" +CONF_TRIGGERS = "triggers" CONF_TRIGGER_VARIABLES = "trigger_variables" DOMAIN = "automation" diff --git a/homeassistant/components/automation/helpers.py b/homeassistant/components/automation/helpers.py index 6aefa2b150a..c529fbd504e 100644 --- a/homeassistant/components/automation/helpers.py +++ b/homeassistant/components/automation/helpers.py @@ -28,6 +28,14 @@ async def _reload_blueprint_automations( @callback def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: """Get automation blueprints.""" + # pylint: disable-next=import-outside-toplevel + from .config import AUTOMATION_BLUEPRINT_SCHEMA + return blueprint.DomainBlueprints( - hass, DOMAIN, LOGGER, _blueprint_in_use, _reload_blueprint_automations + hass, + DOMAIN, + LOGGER, + _blueprint_in_use, + _reload_blueprint_automations, + AUTOMATION_BLUEPRINT_SCHEMA, ) diff --git a/homeassistant/components/blueprint/__init__.py b/homeassistant/components/blueprint/__init__.py index 92d94708e0f..4c7b8e7f4c3 100644 --- a/homeassistant/components/blueprint/__init__.py +++ b/homeassistant/components/blueprint/__init__.py @@ -15,7 +15,7 @@ from .errors import ( # noqa: F401 MissingInput, ) from .models import Blueprint, BlueprintInputs, DomainBlueprints # noqa: F401 -from .schemas import is_blueprint_instance_config # noqa: F401 +from .schemas import BLUEPRINT_SCHEMA, is_blueprint_instance_config # noqa: F401 CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index c231a33991a..c10da532324 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -16,7 +16,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.util import yaml from .models import Blueprint -from .schemas import is_blueprint_config +from .schemas import BLUEPRINT_SCHEMA, is_blueprint_config COMMUNITY_TOPIC_PATTERN = re.compile( r"^https://community.home-assistant.io/t/[a-z0-9-]+/(?P\d+)(?:/(?P\d+)|)$" @@ -126,7 +126,7 @@ def _extract_blueprint_from_community_topic( continue assert isinstance(data, dict) - blueprint = Blueprint(data) + blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) break if blueprint is None: @@ -169,7 +169,7 @@ async def fetch_blueprint_from_github_url( raw_yaml = await resp.text() data = yaml.parse_yaml(raw_yaml) assert isinstance(data, dict) - blueprint = Blueprint(data) + blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) parsed_import_url = yarl.URL(import_url) suggested_filename = f"{parsed_import_url.parts[1]}/{parsed_import_url.parts[-1]}" @@ -211,7 +211,7 @@ async def fetch_blueprint_from_github_gist_url( continue assert isinstance(data, dict) - blueprint = Blueprint(data) + blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) break if blueprint is None: @@ -238,7 +238,7 @@ async def fetch_blueprint_from_website_url( raw_yaml = await resp.text() data = yaml.parse_yaml(raw_yaml) assert isinstance(data, dict) - blueprint = Blueprint(data) + blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) parsed_import_url = yarl.URL(url) suggested_filename = f"homeassistant/{parsed_import_url.parts[-1][:-5]}" @@ -256,7 +256,7 @@ async def fetch_blueprint_from_generic_url( data = yaml.parse_yaml(raw_yaml) assert isinstance(data, dict) - blueprint = Blueprint(data) + blueprint = Blueprint(data, schema=BLUEPRINT_SCHEMA) parsed_import_url = yarl.URL(url) suggested_filename = f"{parsed_import_url.host}/{parsed_import_url.parts[-1][:-5]}" @@ -273,7 +273,11 @@ FETCH_FUNCTIONS = ( async def fetch_blueprint_from_url(hass: HomeAssistant, url: str) -> ImportedBlueprint: - """Get a blueprint from a url.""" + """Get a blueprint from a url. + + The returned blueprint will only be validated with BLUEPRINT_SCHEMA, not the domain + specific schema. + """ for func in FETCH_FUNCTIONS: with suppress(UnsupportedUrl): imported_bp = await func(hass, url) diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 02a215ca103..f32c3f04989 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -44,7 +44,7 @@ from .errors import ( InvalidBlueprintInputs, MissingInput, ) -from .schemas import BLUEPRINT_INSTANCE_FIELDS, BLUEPRINT_SCHEMA +from .schemas import BLUEPRINT_INSTANCE_FIELDS class Blueprint: @@ -56,10 +56,11 @@ class Blueprint: *, path: str | None = None, expected_domain: str | None = None, + schema: Callable[[Any], Any], ) -> None: """Initialize a blueprint.""" try: - data = self.data = BLUEPRINT_SCHEMA(data) + data = self.data = schema(data) except vol.Invalid as err: raise InvalidBlueprint(expected_domain, path, data, err) from err @@ -197,6 +198,7 @@ class DomainBlueprints: logger: logging.Logger, blueprint_in_use: Callable[[HomeAssistant, str], bool], reload_blueprint_consumers: Callable[[HomeAssistant, str], Awaitable[None]], + blueprint_schema: Callable[[Any], Any], ) -> None: """Initialize a domain blueprints instance.""" self.hass = hass @@ -206,6 +208,7 @@ class DomainBlueprints: self._reload_blueprint_consumers = reload_blueprint_consumers self._blueprints: dict[str, Blueprint | None] = {} self._load_lock = asyncio.Lock() + self._blueprint_schema = blueprint_schema hass.data.setdefault(DOMAIN, {})[domain] = self @@ -233,7 +236,10 @@ class DomainBlueprints: raise FailedToLoad(self.domain, blueprint_path, err) from err return Blueprint( - blueprint_data, expected_domain=self.domain, path=blueprint_path + blueprint_data, + expected_domain=self.domain, + path=blueprint_path, + schema=self._blueprint_schema, ) def _load_blueprints(self) -> dict[str, Blueprint | BlueprintException | None]: diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 9d3329d8195..3be925c7c8f 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -18,6 +18,7 @@ from homeassistant.util import yaml from . import importer, models from .const import DOMAIN from .errors import BlueprintException, FailedToLoad, FileAlreadyExists +from .schemas import BLUEPRINT_SCHEMA @callback @@ -174,7 +175,9 @@ async def ws_save_blueprint( try: yaml_data = cast(dict[str, Any], yaml.parse_yaml(msg["yaml"])) - blueprint = models.Blueprint(yaml_data, expected_domain=domain) + blueprint = models.Blueprint( + yaml_data, expected_domain=domain, schema=BLUEPRINT_SCHEMA + ) if "source_url" in msg: blueprint.update_metadata(source_url=msg["source_url"]) except HomeAssistantError as err: diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 519a40450ed..54af1df8c54 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -70,7 +70,16 @@ class EditAutomationConfigView(EditIdBasedConfigView): updated_value = {CONF_ID: config_key} # Iterate through some keys that we want to have ordered in the output - for key in ("alias", "description", "trigger", "condition", "action"): + for key in ( + "alias", + "description", + "triggers", + "trigger", + "conditions", + "condition", + "actions", + "action", + ): if key in new_value: updated_value[key] = new_value[key] diff --git a/homeassistant/components/script/helpers.py b/homeassistant/components/script/helpers.py index b070a4d60ce..31aac506b35 100644 --- a/homeassistant/components/script/helpers.py +++ b/homeassistant/components/script/helpers.py @@ -1,6 +1,6 @@ """Helpers for automation integration.""" -from homeassistant.components.blueprint import DomainBlueprints +from homeassistant.components.blueprint import BLUEPRINT_SCHEMA, DomainBlueprints from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton @@ -27,5 +27,10 @@ async def _reload_blueprint_scripts(hass: HomeAssistant, blueprint_path: str) -> def async_get_blueprints(hass: HomeAssistant) -> DomainBlueprints: """Get script blueprints.""" return DomainBlueprints( - hass, DOMAIN, LOGGER, _blueprint_in_use, _reload_blueprint_scripts + hass, + DOMAIN, + LOGGER, + _blueprint_in_use, + _reload_blueprint_scripts, + BLUEPRINT_SCHEMA, ) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index c9347012183..cfa132b71eb 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -859,9 +859,9 @@ def handle_fire_event( @decorators.websocket_command( { vol.Required("type"): "validate_config", - vol.Optional("trigger"): cv.match_all, - vol.Optional("condition"): cv.match_all, - vol.Optional("action"): cv.match_all, + vol.Optional("triggers"): cv.match_all, + vol.Optional("conditions"): cv.match_all, + vol.Optional("actions"): cv.match_all, } ) @decorators.async_response @@ -876,9 +876,13 @@ async def handle_validate_config( result = {} for key, schema, validator in ( - ("trigger", cv.TRIGGER_SCHEMA, trigger.async_validate_trigger_config), - ("condition", cv.CONDITIONS_SCHEMA, condition.async_validate_conditions_config), - ("action", cv.SCRIPT_SCHEMA, script.async_validate_actions_config), + ("triggers", cv.TRIGGER_SCHEMA, trigger.async_validate_trigger_config), + ( + "conditions", + cv.CONDITIONS_SCHEMA, + condition.async_validate_conditions_config, + ), + ("actions", cv.SCRIPT_SCHEMA, script.async_validate_actions_config), ): if key not in msg: continue diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index 2c92d7a5242..1095c625fb2 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -38,7 +38,10 @@ def patch_blueprint( return orig_load(self, path) return models.Blueprint( - yaml.load_yaml(data_path), expected_domain=self.domain, path=path + yaml.load_yaml(data_path), + expected_domain=self.domain, + path=path, + schema=automation.config.AUTOMATION_BLUEPRINT_SCHEMA, ) with patch( diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index d8f04f10458..aaeb4f2e41e 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -240,7 +240,7 @@ async def test_trigger_service_ignoring_condition( automation.DOMAIN: { "alias": "test", "trigger": [{"platform": "event", "event_type": "test_event"}], - "condition": { + "conditions": { "condition": "numeric_state", "entity_id": "non.existing", "above": "1", @@ -292,8 +292,8 @@ async def test_two_conditions_with_and( automation.DOMAIN, { automation.DOMAIN: { - "trigger": [{"platform": "event", "event_type": "test_event"}], - "condition": [ + "triggers": [{"platform": "event", "event_type": "test_event"}], + "conditions": [ {"condition": "state", "entity_id": entity_id, "state": "100"}, { "condition": "numeric_state", @@ -301,7 +301,7 @@ async def test_two_conditions_with_and( "below": 150, }, ], - "action": {"action": "test.automation"}, + "actions": {"action": "test.automation"}, } }, ) @@ -331,9 +331,9 @@ async def test_shorthand_conditions_template( automation.DOMAIN, { automation.DOMAIN: { - "trigger": [{"platform": "event", "event_type": "test_event"}], - "condition": "{{ is_state('test.entity', 'hello') }}", - "action": {"action": "test.automation"}, + "triggers": [{"platform": "event", "event_type": "test_event"}], + "conditions": "{{ is_state('test.entity', 'hello') }}", + "actions": {"action": "test.automation"}, } }, ) @@ -807,8 +807,8 @@ async def test_reload_unchanged_does_not_stop( config = { automation.DOMAIN: { "alias": "hello", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [ + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [ {"event": "running"}, {"wait_template": "{{ is_state('test.entity', 'goodbye') }}"}, {"action": "test.automation"}, @@ -854,8 +854,8 @@ async def test_reload_single_unchanged_does_not_stop( automation.DOMAIN: { "id": "sun", "alias": "hello", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [ + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [ {"event": "running"}, {"wait_template": "{{ is_state('test.entity', 'goodbye') }}"}, {"action": "test.automation"}, @@ -1092,13 +1092,13 @@ async def test_reload_moved_automation_without_alias( config = { automation.DOMAIN: [ { - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [{"action": "test.automation"}], + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [{"action": "test.automation"}], }, { "alias": "automation_with_alias", - "trigger": {"platform": "event", "event_type": "test_event2"}, - "action": [{"action": "test.automation"}], + "triggers": {"platform": "event", "event_type": "test_event2"}, + "actions": [{"action": "test.automation"}], }, ] } @@ -1148,18 +1148,18 @@ async def test_reload_identical_automations_without_id( automation.DOMAIN: [ { "alias": "dolly", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [{"action": "test.automation"}], + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [{"action": "test.automation"}], }, { "alias": "dolly", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [{"action": "test.automation"}], + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [{"action": "test.automation"}], }, { "alias": "dolly", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [{"action": "test.automation"}], + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [{"action": "test.automation"}], }, ] } @@ -1245,13 +1245,13 @@ async def test_reload_identical_automations_without_id( "automation_config", [ { - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [{"action": "test.automation"}], + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [{"action": "test.automation"}], }, # An automation using templates { - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [{"action": "{{ 'test.automation' }}"}], + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [{"action": "{{ 'test.automation' }}"}], }, # An automation using blueprint { @@ -1277,14 +1277,14 @@ async def test_reload_identical_automations_without_id( }, { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [{"action": "test.automation"}], + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [{"action": "test.automation"}], }, # An automation using templates { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [{"action": "{{ 'test.automation' }}"}], + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [{"action": "{{ 'test.automation' }}"}], }, # An automation using blueprint { @@ -1380,8 +1380,8 @@ async def test_reload_automation_when_blueprint_changes( # Reload the automations without any change, but with updated blueprint blueprint_path = automation.async_get_blueprints(hass).blueprint_folder blueprint_config = yaml.load_yaml(blueprint_path / "test_event_service.yaml") - blueprint_config["action"] = [blueprint_config["action"]] - blueprint_config["action"].append(blueprint_config["action"][-1]) + blueprint_config["actions"] = [blueprint_config["actions"]] + blueprint_config["actions"].append(blueprint_config["actions"][-1]) with ( patch( @@ -1650,13 +1650,13 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None: ( {}, "could not be validated", - "required key not provided @ data['action']", + "required key not provided @ data['actions']", "validation_failed_schema", ), ( { - "trigger": {"platform": "automation"}, - "action": [], + "triggers": {"platform": "automation"}, + "actions": [], }, "failed to setup triggers", "Integration 'automation' does not provide trigger support.", @@ -1664,14 +1664,14 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None: ), ( { - "trigger": {"platform": "event", "event_type": "test_event"}, - "condition": { + "triggers": {"platform": "event", "event_type": "test_event"}, + "conditions": { "condition": "state", # The UUID will fail being resolved to en entity_id "entity_id": "abcdabcdabcdabcdabcdabcdabcdabcd", "state": "blah", }, - "action": [], + "actions": [], }, "failed to setup conditions", "Unknown entity registry entry abcdabcdabcdabcdabcdabcdabcdabcd.", @@ -1679,8 +1679,8 @@ async def test_automation_not_trigger_on_bootstrap(hass: HomeAssistant) -> None: ), ( { - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": { + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": { "condition": "state", # The UUID will fail being resolved to en entity_id "entity_id": "abcdabcdabcdabcdabcdabcdabcdabcd", @@ -1712,8 +1712,8 @@ async def test_automation_bad_config_validation( {"alias": "bad_automation", **broken_config}, { "alias": "good_automation", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": { + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": { "action": "test.automation", "entity_id": "hello.world", }, @@ -1970,7 +1970,7 @@ async def test_extraction_functions( DOMAIN: [ { "alias": "test1", - "trigger": [ + "triggers": [ {"platform": "state", "entity_id": "sensor.trigger_state"}, { "platform": "numeric_state", @@ -2006,12 +2006,12 @@ async def test_extraction_functions( "event_data": {"entity_id": 123}, }, ], - "condition": { + "conditions": { "condition": "state", "entity_id": "light.condition_state", "state": "on", }, - "action": [ + "actions": [ { "action": "test.script", "data": {"entity_id": "light.in_both"}, @@ -2042,7 +2042,7 @@ async def test_extraction_functions( }, { "alias": "test2", - "trigger": [ + "triggers": [ { "platform": "device", "domain": "light", @@ -2078,14 +2078,14 @@ async def test_extraction_functions( "event_data": {"device_id": 123}, }, ], - "condition": { + "conditions": { "condition": "device", "device_id": condition_device.id, "domain": "light", "type": "is_on", "entity_id": "light.bla", }, - "action": [ + "actions": [ { "action": "test.script", "data": {"entity_id": "light.in_both"}, @@ -2112,7 +2112,7 @@ async def test_extraction_functions( }, { "alias": "test3", - "trigger": [ + "triggers": [ { "platform": "event", "event_type": "esphome.button_pressed", @@ -2131,14 +2131,14 @@ async def test_extraction_functions( "event_data": {"area_id": 123}, }, ], - "condition": { + "conditions": { "condition": "device", "device_id": condition_device.id, "domain": "light", "type": "is_on", "entity_id": "light.bla", }, - "action": [ + "actions": [ { "action": "test.script", "data": {"entity_id": "light.in_both"}, @@ -2287,8 +2287,8 @@ async def test_automation_variables( "event_type": "{{ trigger.event.event_type }}", "this_variables": "{{this.entity_id}}", }, - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": { + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": { "action": "test.automation", "data": { "value": "{{ test_var }}", @@ -2303,11 +2303,11 @@ async def test_automation_variables( "test_var": "defined_in_config", }, "trigger": {"platform": "event", "event_type": "test_event_2"}, - "condition": { + "conditions": { "condition": "template", "value_template": "{{ trigger.event.data.pass_condition }}", }, - "action": { + "actions": { "action": "test.automation", }, }, @@ -2315,8 +2315,8 @@ async def test_automation_variables( "variables": { "test_var": "{{ trigger.event.data.break + 1 }}", }, - "trigger": {"platform": "event", "event_type": "test_event_3"}, - "action": { + "triggers": {"platform": "event", "event_type": "test_event_3"}, + "actions": { "action": "test.automation", }, }, @@ -2517,6 +2517,107 @@ async def test_blueprint_automation( ] +async def test_blueprint_automation_legacy_schema( + hass: HomeAssistant, calls: list[ServiceCall] +) -> None: + """Test blueprint automation where the blueprint is using legacy schema.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "use_blueprint": { + "path": "test_event_service_legacy_schema.yaml", + "input": { + "trigger_event": "blueprint_event", + "service_to_call": "test.automation", + "a_number": 5, + }, + } + } + }, + ) + hass.bus.async_fire("blueprint_event") + await hass.async_block_till_done() + assert len(calls) == 1 + assert automation.entities_in_automation(hass, "automation.automation_0") == [ + "light.kitchen" + ] + assert ( + automation.blueprint_in_automation(hass, "automation.automation_0") + == "test_event_service_legacy_schema.yaml" + ) + assert automation.automations_with_blueprint( + hass, "test_event_service_legacy_schema.yaml" + ) == ["automation.automation_0"] + + +@pytest.mark.parametrize( + ("blueprint", "override"), + [ + # Override a blueprint with modern schema with legacy schema + ( + "test_event_service.yaml", + {"trigger": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with modern schema with modern schema + ( + "test_event_service.yaml", + {"triggers": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with legacy schema with legacy schema + ( + "test_event_service_legacy_schema.yaml", + {"trigger": {"platform": "event", "event_type": "override"}}, + ), + # Override a blueprint with legacy schema with modern schema + ( + "test_event_service_legacy_schema.yaml", + {"triggers": {"platform": "event", "event_type": "override"}}, + ), + ], +) +async def test_blueprint_automation_override( + hass: HomeAssistant, calls: list[ServiceCall], blueprint: str, override: dict +) -> None: + """Test blueprint automation where the automation config overrides the blueprint.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "use_blueprint": { + "path": blueprint, + "input": { + "trigger_event": "blueprint_event", + "service_to_call": "test.automation", + "a_number": 5, + }, + }, + } + | override + }, + ) + + hass.bus.async_fire("blueprint_event") + await hass.async_block_till_done() + assert len(calls) == 0 + + hass.bus.async_fire("override") + await hass.async_block_till_done() + assert len(calls) == 1 + + assert automation.entities_in_automation(hass, "automation.automation_0") == [ + "light.kitchen" + ] + assert ( + automation.blueprint_in_automation(hass, "automation.automation_0") == blueprint + ) + assert automation.automations_with_blueprint(hass, blueprint) == [ + "automation.automation_0" + ] + + @pytest.mark.parametrize( ("blueprint_inputs", "problem", "details"), [ @@ -2542,7 +2643,7 @@ async def test_blueprint_automation( "Blueprint 'Call service based on event' generated invalid automation", ( "value should be a string for dictionary value @" - " data['action'][0]['action']" + " data['actions'][0]['action']" ), ), ], @@ -3020,8 +3121,8 @@ async def test_websocket_config( """Test config command.""" config = { "alias": "hello", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"action": "test.automation", "data": 100}, + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": {"action": "test.automation", "data": 100}, } assert await async_setup_component( hass, automation.DOMAIN, {automation.DOMAIN: config} @@ -3303,16 +3404,26 @@ async def test_two_automation_call_restart_script_right_after_each_other( assert len(events) == 1 -async def test_action_service_backward_compatibility( +async def test_action_backward_compatibility( hass: HomeAssistant, calls: list[ServiceCall] ) -> None: - """Test we can still use the service call method.""" + """Test we can still use old-style automations. + + - Services action using the `service` key instead of `action` + - Singular `trigger` instead of `triggers` + - Singular `condition` instead of `conditions` + - Singular `action` instead of `actions` + """ assert await async_setup_component( hass, automation.DOMAIN, { automation.DOMAIN: { "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": { + "condition": "template", + "value_template": "{{ True }}", + }, "action": { "service": "test.automation", "entity_id": "hello.world", @@ -3327,3 +3438,48 @@ async def test_action_service_backward_compatibility( assert len(calls) == 1 assert calls[0].data.get(ATTR_ENTITY_ID) == ["hello.world"] assert calls[0].data.get("event") == "test_event" + + +@pytest.mark.parametrize( + ("config", "message"), + [ + ( + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "triggers": {"platform": "event", "event_type": "test_event2"}, + "actions": [], + }, + "Cannot specify both 'trigger' and 'triggers'. Please use 'triggers' only.", + ), + ( + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "condition": {"condition": "template", "value_template": "{{ True }}"}, + "conditions": {"condition": "template", "value_template": "{{ True }}"}, + }, + "Cannot specify both 'condition' and 'conditions'. Please use 'conditions' only.", + ), + ( + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": {"service": "test.automation", "entity_id": "hello.world"}, + "actions": {"service": "test.automation", "entity_id": "hello.world"}, + }, + "Cannot specify both 'action' and 'actions'. Please use 'actions' only.", + ), + ], +) +async def test_invalid_configuration( + hass: HomeAssistant, + config: dict[str, Any], + message: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test for invalid automation configurations.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + {automation.DOMAIN: config}, + ) + await hass.async_block_till_done() + assert message in caplog.text diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index be354abe9d2..513fee566db 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -40,7 +40,7 @@ async def test_exclude_attributes( { automation.DOMAIN: { "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"action": "test.automation", "entity_id": "hello.world"}, + "actions": {"action": "test.automation", "entity_id": "hello.world"}, } }, ) diff --git a/tests/components/blueprint/test_default_blueprints.py b/tests/components/blueprint/test_default_blueprints.py index 9bd60a7cb6b..f69126a7f25 100644 --- a/tests/components/blueprint/test_default_blueprints.py +++ b/tests/components/blueprint/test_default_blueprints.py @@ -6,7 +6,7 @@ import pathlib import pytest -from homeassistant.components.blueprint import models +from homeassistant.components.blueprint import BLUEPRINT_SCHEMA, models from homeassistant.components.blueprint.const import BLUEPRINT_FOLDER from homeassistant.util import yaml @@ -26,4 +26,4 @@ def test_default_blueprints(domain: str) -> None: LOGGER.info("Processing %s", fil) assert fil.name.endswith(".yaml") data = yaml.load_yaml(fil) - models.Blueprint(data, expected_domain=domain) + models.Blueprint(data, expected_domain=domain, schema=BLUEPRINT_SCHEMA) diff --git a/tests/components/blueprint/test_models.py b/tests/components/blueprint/test_models.py index 45e35474e4c..0ce8c1f397a 100644 --- a/tests/components/blueprint/test_models.py +++ b/tests/components/blueprint/test_models.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.blueprint import errors, models +from homeassistant.components.blueprint import BLUEPRINT_SCHEMA, errors, models from homeassistant.core import HomeAssistant from homeassistant.util.yaml import Input @@ -22,7 +22,8 @@ def blueprint_1() -> models.Blueprint: "input": {"test-input": {"name": "Name", "description": "Description"}}, }, "example": Input("test-input"), - } + }, + schema=BLUEPRINT_SCHEMA, ) @@ -57,26 +58,32 @@ def blueprint_2(request: pytest.FixtureRequest) -> models.Blueprint: } }, } - return models.Blueprint(blueprint) + return models.Blueprint(blueprint, schema=BLUEPRINT_SCHEMA) @pytest.fixture def domain_bps(hass: HomeAssistant) -> models.DomainBlueprints: """Domain blueprints fixture.""" return models.DomainBlueprints( - hass, "automation", logging.getLogger(__name__), None, AsyncMock() + hass, + "automation", + logging.getLogger(__name__), + None, + AsyncMock(), + BLUEPRINT_SCHEMA, ) def test_blueprint_model_init() -> None: """Test constructor validation.""" with pytest.raises(errors.InvalidBlueprint): - models.Blueprint({}) + models.Blueprint({}, schema=BLUEPRINT_SCHEMA) with pytest.raises(errors.InvalidBlueprint): models.Blueprint( {"blueprint": {"name": "Hello", "domain": "automation"}}, expected_domain="not-automation", + schema=BLUEPRINT_SCHEMA, ) with pytest.raises(errors.InvalidBlueprint): @@ -88,7 +95,8 @@ def test_blueprint_model_init() -> None: "input": {"something": None}, }, "trigger": {"platform": Input("non-existing")}, - } + }, + schema=BLUEPRINT_SCHEMA, ) @@ -115,7 +123,8 @@ def test_blueprint_update_metadata() -> None: "name": "Hello", "domain": "automation", }, - } + }, + schema=BLUEPRINT_SCHEMA, ) bp.update_metadata(source_url="http://bla.com") @@ -131,7 +140,8 @@ def test_blueprint_validate() -> None: "name": "Hello", "domain": "automation", }, - } + }, + schema=BLUEPRINT_SCHEMA, ).validate() is None ) @@ -143,7 +153,8 @@ def test_blueprint_validate() -> None: "domain": "automation", "homeassistant": {"min_version": "100000.0.0"}, }, - } + }, + schema=BLUEPRINT_SCHEMA, ).validate() == ["Requires at least Home Assistant 100000.0.0"] diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 13615803569..f8ff0fdd540 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -64,6 +64,17 @@ async def test_list_blueprints( "name": "Call service based on event", }, }, + "test_event_service_legacy_schema.yaml": { + "metadata": { + "domain": "automation", + "input": { + "service_to_call": None, + "trigger_event": {"selector": {"text": {}}}, + "a_number": {"selector": {"number": {"mode": "box", "step": 1.0}}}, + }, + "name": "Call service based on event", + }, + }, "in_folder/in_folder_blueprint.yaml": { "metadata": { "domain": "automation", @@ -212,16 +223,16 @@ async def test_save_blueprint( " input:\n trigger_event:\n selector:\n text: {}\n " " service_to_call:\n a_number:\n selector:\n number:\n " " mode: box\n step: 1.0\n source_url:" - " https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntrigger:\n" - " platform: event\n event_type: !input 'trigger_event'\naction:\n " + " https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntriggers:\n" + " platform: event\n event_type: !input 'trigger_event'\nactions:\n " " service: !input 'service_to_call'\n entity_id: light.kitchen\n" # c dumper will not quote the value after !input "blueprint:\n name: Call service based on event\n domain: automation\n " " input:\n trigger_event:\n selector:\n text: {}\n " " service_to_call:\n a_number:\n selector:\n number:\n " " mode: box\n step: 1.0\n source_url:" - " https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntrigger:\n" - " platform: event\n event_type: !input trigger_event\naction:\n service:" + " https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntriggers:\n" + " platform: event\n event_type: !input trigger_event\nactions:\n service:" " !input service_to_call\n entity_id: light.kitchen\n" ) # Make sure ita parsable and does not raise @@ -483,11 +494,11 @@ async def test_substituting_blueprint_inputs( assert msg["success"] assert msg["result"]["substituted_config"] == { - "action": { + "actions": { "entity_id": "light.kitchen", "service": "test.automation", }, - "trigger": { + "triggers": { "event_type": "test_event", "platform": "event", }, diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 89113070367..9cd2a25de3a 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -78,7 +78,7 @@ async def test_update_automation_config( resp = await client.post( "/api/config/automation/config/moon", - data=json.dumps({"trigger": [], "action": [], "condition": []}), + data=json.dumps({"triggers": [], "actions": [], "conditions": []}), ) await hass.async_block_till_done() assert sorted(hass.states.async_entity_ids("automation")) == [ @@ -91,8 +91,13 @@ async def test_update_automation_config( assert result == {"result": "ok"} new_data = hass_config_store["automations.yaml"] - assert list(new_data[1]) == ["id", "trigger", "condition", "action"] - assert new_data[1] == {"id": "moon", "trigger": [], "condition": [], "action": []} + assert list(new_data[1]) == ["id", "triggers", "conditions", "actions"] + assert new_data[1] == { + "id": "moon", + "triggers": [], + "conditions": [], + "actions": [], + } @pytest.mark.parametrize("automation_config", [{}]) @@ -101,7 +106,7 @@ async def test_update_automation_config( [ ( {"action": []}, - "required key not provided @ data['trigger']", + "required key not provided @ data['triggers']", ), ( { @@ -254,7 +259,7 @@ async def test_update_remove_key_automation_config( resp = await client.post( "/api/config/automation/config/moon", - data=json.dumps({"trigger": [], "action": [], "condition": []}), + data=json.dumps({"triggers": [], "actions": [], "conditions": []}), ) await hass.async_block_till_done() assert sorted(hass.states.async_entity_ids("automation")) == [ @@ -267,8 +272,13 @@ async def test_update_remove_key_automation_config( assert result == {"result": "ok"} new_data = hass_config_store["automations.yaml"] - assert list(new_data[1]) == ["id", "trigger", "condition", "action"] - assert new_data[1] == {"id": "moon", "trigger": [], "condition": [], "action": []} + assert list(new_data[1]) == ["id", "triggers", "conditions", "actions"] + assert new_data[1] == { + "id": "moon", + "triggers": [], + "conditions": [], + "actions": [], + } @pytest.mark.parametrize("automation_config", [{}]) @@ -297,7 +307,7 @@ async def test_bad_formatted_automations( resp = await client.post( "/api/config/automation/config/moon", - data=json.dumps({"trigger": [], "action": [], "condition": []}), + data=json.dumps({"triggers": [], "actions": [], "conditions": []}), ) await hass.async_block_till_done() assert sorted(hass.states.async_entity_ids("automation")) == [ @@ -312,7 +322,12 @@ async def test_bad_formatted_automations( # Verify ID added new_data = hass_config_store["automations.yaml"] assert "id" in new_data[0] - assert new_data[1] == {"id": "moon", "trigger": [], "condition": [], "action": []} + assert new_data[1] == { + "id": "moon", + "triggers": [], + "conditions": [], + "actions": [], + } @pytest.mark.parametrize( diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 750817f3c41..cb1abecd6ff 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1307,7 +1307,7 @@ async def test_automation_with_bad_action( }, ) - assert expected_error.format(path="['action'][0]") in caplog.text + assert expected_error.format(path="['actions'][0]") in caplog.text @patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) @@ -1341,7 +1341,7 @@ async def test_automation_with_bad_condition_action( }, ) - assert expected_error.format(path="['action'][0]") in caplog.text + assert expected_error.format(path="['actions'][0]") in caplog.text @patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) @@ -1375,7 +1375,7 @@ async def test_automation_with_bad_condition( }, ) - assert expected_error.format(path="['condition'][0]") in caplog.text + assert expected_error.format(path="['conditions'][0]") in caplog.text async def test_automation_with_sub_condition( @@ -1541,7 +1541,7 @@ async def test_automation_with_bad_sub_condition( }, ) - path = "['condition'][0]['conditions'][0]" + path = "['conditions'][0]['conditions'][0]" assert expected_error.format(path=path) in caplog.text diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index 86567d2f16f..7f03a89c548 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -9,7 +9,11 @@ from unittest.mock import patch import pytest from homeassistant.components import script -from homeassistant.components.blueprint import Blueprint, DomainBlueprints +from homeassistant.components.blueprint import ( + BLUEPRINT_SCHEMA, + Blueprint, + DomainBlueprints, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, template @@ -33,7 +37,10 @@ def patch_blueprint(blueprint_path: str, data_path: str) -> Iterator[None]: return orig_load(self, path) return Blueprint( - yaml.load_yaml(data_path), expected_domain=self.domain, path=path + yaml.load_yaml(data_path), + expected_domain=self.domain, + path=path, + schema=BLUEPRINT_SCHEMA, ) with patch( diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 9b2b959e0dd..2c00c3bf6f2 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -250,7 +250,7 @@ async def test_search( { "id": "unique_id", "alias": "blueprint_automation_1", - "trigger": {"platform": "template", "value_template": "true"}, + "triggers": {"platform": "template", "value_template": "true"}, "use_blueprint": { "path": "test_event_service.yaml", "input": { @@ -262,7 +262,7 @@ async def test_search( }, { "alias": "blueprint_automation_2", - "trigger": {"platform": "template", "value_template": "true"}, + "triggers": {"platform": "template", "value_template": "true"}, "use_blueprint": { "path": "test_event_service.yaml", "input": { diff --git a/tests/components/trace/test_websocket_api.py b/tests/components/trace/test_websocket_api.py index 7b292ed39e3..43664c6e7ce 100644 --- a/tests/components/trace/test_websocket_api.py +++ b/tests/components/trace/test_websocket_api.py @@ -47,7 +47,7 @@ async def _setup_automation_or_script( ) -> None: """Set up automations or scripts from automation config.""" if domain == "script": - configs = {config["id"]: {"sequence": config["action"]} for config in configs} + configs = {config["id"]: {"sequence": config["actions"]} for config in configs} if script_config: if domain == "automation": @@ -85,7 +85,7 @@ async def _run_automation_or_script( def _assert_raw_config(domain, config, trace): if domain == "script": - config = {"sequence": config["action"]} + config = {"sequence": config["actions"]} assert trace["config"] == config @@ -152,20 +152,20 @@ async def test_get_trace( sun_config = { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"service": "test.automation"}, + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": {"service": "test.automation"}, } moon_config = { "id": "moon", - "trigger": [ + "triggers": [ {"platform": "event", "event_type": "test_event2"}, {"platform": "event", "event_type": "test_event3"}, ], - "condition": { + "conditions": { "condition": "template", "value_template": "{{ trigger.event.event_type=='test_event2' }}", }, - "action": {"event": "another_event"}, + "actions": {"event": "another_event"}, } sun_action = { @@ -551,13 +551,13 @@ async def test_trace_overflow( sun_config = { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"event": "some_event"}, + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": {"event": "some_event"}, } moon_config = { "id": "moon", - "trigger": {"platform": "event", "event_type": "test_event2"}, - "action": {"event": "another_event"}, + "triggers": {"platform": "event", "event_type": "test_event2"}, + "actions": {"event": "another_event"}, } await _setup_automation_or_script( hass, domain, [sun_config, moon_config], stored_traces=stored_traces @@ -632,13 +632,13 @@ async def test_restore_traces_overflow( hass_storage["trace.saved_traces"] = saved_traces sun_config = { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"event": "some_event"}, + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": {"event": "some_event"}, } moon_config = { "id": "moon", - "trigger": {"platform": "event", "event_type": "test_event2"}, - "action": {"event": "another_event"}, + "triggers": {"platform": "event", "event_type": "test_event2"}, + "actions": {"event": "another_event"}, } await _setup_automation_or_script(hass, domain, [sun_config, moon_config]) await hass.async_start() @@ -713,13 +713,13 @@ async def test_restore_traces_late_overflow( hass_storage["trace.saved_traces"] = saved_traces sun_config = { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"event": "some_event"}, + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": {"event": "some_event"}, } moon_config = { "id": "moon", - "trigger": {"platform": "event", "event_type": "test_event2"}, - "action": {"event": "another_event"}, + "triggers": {"platform": "event", "event_type": "test_event2"}, + "actions": {"event": "another_event"}, } await _setup_automation_or_script(hass, domain, [sun_config, moon_config]) await hass.async_start() @@ -765,8 +765,8 @@ async def test_trace_no_traces( sun_config = { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"event": "some_event"}, + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": {"event": "some_event"}, } await _setup_automation_or_script(hass, domain, [sun_config], stored_traces=0) @@ -832,20 +832,20 @@ async def test_list_traces( sun_config = { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"service": "test.automation"}, + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": {"service": "test.automation"}, } moon_config = { "id": "moon", - "trigger": [ + "triggers": [ {"platform": "event", "event_type": "test_event2"}, {"platform": "event", "event_type": "test_event3"}, ], - "condition": { + "conditions": { "condition": "template", "value_template": "{{ trigger.event.event_type=='test_event2' }}", }, - "action": {"event": "another_event"}, + "actions": {"event": "another_event"}, } await _setup_automation_or_script(hass, domain, [sun_config, moon_config]) @@ -965,8 +965,8 @@ async def test_nested_traces( sun_config = { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": {"service": "script.moon"}, + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": {"service": "script.moon"}, } moon_config = {"moon": {"sequence": {"event": "another_event"}}} await _setup_automation_or_script(hass, domain, [sun_config], moon_config) @@ -1036,8 +1036,8 @@ async def test_breakpoints( sun_config = { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [ + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [ {"event": "event0"}, {"event": "event1"}, {"event": "event2"}, @@ -1206,8 +1206,8 @@ async def test_breakpoints_2( sun_config = { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [ + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [ {"event": "event0"}, {"event": "event1"}, {"event": "event2"}, @@ -1311,8 +1311,8 @@ async def test_breakpoints_3( sun_config = { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, - "action": [ + "triggers": {"platform": "event", "event_type": "test_event"}, + "actions": [ {"event": "event0"}, {"event": "event1"}, {"event": "event2"}, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 54a87e033dc..9c41bb8ddd2 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2566,18 +2566,18 @@ async def test_integration_setup_info( @pytest.mark.parametrize( ("key", "config"), [ - ("trigger", {"platform": "event", "event_type": "hello"}), - ("trigger", [{"platform": "event", "event_type": "hello"}]), + ("triggers", {"platform": "event", "event_type": "hello"}), + ("triggers", [{"platform": "event", "event_type": "hello"}]), ( - "condition", + "conditions", {"condition": "state", "entity_id": "hello.world", "state": "paulus"}, ), ( - "condition", + "conditions", [{"condition": "state", "entity_id": "hello.world", "state": "paulus"}], ), - ("action", {"service": "domain_test.test_service"}), - ("action", [{"service": "domain_test.test_service"}]), + ("actions", {"service": "domain_test.test_service"}), + ("actions", [{"service": "domain_test.test_service"}]), ], ) async def test_validate_config_works( @@ -2599,13 +2599,13 @@ async def test_validate_config_works( [ # Raises vol.Invalid ( - "trigger", + "triggers", {"platform": "non_existing", "event_type": "hello"}, "Invalid platform 'non_existing' specified", ), # Raises vol.Invalid ( - "condition", + "conditions", { "condition": "non_existing", "entity_id": "hello.world", @@ -2619,7 +2619,7 @@ async def test_validate_config_works( ), # Raises HomeAssistantError ( - "condition", + "conditions", { "above": 50, "condition": "device", @@ -2632,7 +2632,7 @@ async def test_validate_config_works( ), # Raises vol.Invalid ( - "action", + "actions", {"non_existing": "domain_test.test_service"}, "Unable to determine action @ data[0]", ), diff --git a/tests/testing_config/blueprints/automation/test_event_service.yaml b/tests/testing_config/blueprints/automation/test_event_service.yaml index ba7462ed2e0..035278df258 100644 --- a/tests/testing_config/blueprints/automation/test_event_service.yaml +++ b/tests/testing_config/blueprints/automation/test_event_service.yaml @@ -10,9 +10,9 @@ blueprint: selector: number: mode: "box" -trigger: +triggers: platform: event event_type: !input trigger_event -action: +actions: service: !input service_to_call entity_id: light.kitchen diff --git a/tests/testing_config/blueprints/automation/test_event_service_legacy_schema.yaml b/tests/testing_config/blueprints/automation/test_event_service_legacy_schema.yaml new file mode 100644 index 00000000000..ba7462ed2e0 --- /dev/null +++ b/tests/testing_config/blueprints/automation/test_event_service_legacy_schema.yaml @@ -0,0 +1,18 @@ +blueprint: + name: "Call service based on event" + domain: automation + input: + trigger_event: + selector: + text: + service_to_call: + a_number: + selector: + number: + mode: "box" +trigger: + platform: event + event_type: !input trigger_event +action: + service: !input service_to_call + entity_id: light.kitchen From 3995d001ec595f54dd6b26a65ee31e0c94c40f48 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 24 Sep 2024 20:56:01 +0200 Subject: [PATCH 1122/1309] Set default source_type on TrackerEntity and ScannerEntity (#126648) * Set default source_type on TrackerEntity and ScannerEntity * Add samples * Two more * Adjust tests --- homeassistant/components/device_tracker/config_entry.py | 2 ++ .../components/devolo_home_network/device_tracker.py | 3 --- homeassistant/components/renault/device_tracker.py | 7 +------ homeassistant/components/starlink/device_tracker.py | 7 +------ homeassistant/components/unifi/device_tracker.py | 6 ------ tests/components/device_tracker/test_config_entry.py | 6 ++---- 6 files changed, 6 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 505014b3def..306f056dbcc 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -215,6 +215,7 @@ class TrackerEntity( _attr_location_accuracy: int = 0 _attr_location_name: str | None = None _attr_longitude: float | None = None + _attr_source_type: SourceType = SourceType.GPS @cached_property def should_poll(self) -> bool: @@ -299,6 +300,7 @@ class ScannerEntity( _attr_hostname: str | None = None _attr_ip_address: str | None = None _attr_mac_address: str | None = None + _attr_source_type: SourceType = SourceType.ROUTER @cached_property def ip_address(self) -> str | None: diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index ce644da4e1d..d372ba3d468 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -8,7 +8,6 @@ from devolo_plc_api.device_api import ConnectedStationInfo from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ScannerEntity, - SourceType, ) from homeassistant.const import STATE_UNKNOWN, UnitOfFrequency from homeassistant.core import HomeAssistant, callback @@ -89,8 +88,6 @@ class DevoloScannerEntity( ): """Representation of a devolo device tracker.""" - _attr_source_type = SourceType.ROUTER - def __init__( self, coordinator: DataUpdateCoordinator[list[ConnectedStationInfo]], diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index db889868cae..1fde6c80cd6 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from renault_api.kamereon.models import KamereonVehicleLocationData -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -42,11 +42,6 @@ class RenaultDeviceTracker( """Return longitude value of the device.""" return self.coordinator.data.gpsLongitude if self.coordinator.data else None - @property - def source_type(self) -> SourceType: - """Return the source type of the device.""" - return SourceType.GPS - DEVICE_TRACKER_TYPES: tuple[RenaultDataEntityDescription, ...] = ( RenaultDataEntityDescription( diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index de9f413778a..13861823722 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription @@ -55,11 +55,6 @@ class StarlinkDeviceTrackerEntity(StarlinkEntity, TrackerEntity): entity_description: StarlinkDeviceTrackerEntityDescription - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - @property def latitude(self) -> float | None: """Return latitude value of the device.""" diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index eff8d9813db..5cdb3488367 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -21,7 +21,6 @@ from aiounifi.models.event import Event, EventKey from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ScannerEntity, - SourceType, ) from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -275,11 +274,6 @@ class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): """Return the mac address of the device.""" return self._obj_id - @cached_property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.ROUTER - @cached_property def unique_id(self) -> str: """Return a unique ID.""" diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index 7041b2d59ab..bc721803450 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -505,8 +505,7 @@ async def test_scanner_entity_state( def test_tracker_entity() -> None: """Test coverage for base TrackerEntity class.""" entity = TrackerEntity() - with pytest.raises(NotImplementedError): - assert entity.source_type is None + assert entity.source_type is SourceType.GPS assert entity.latitude is None assert entity.longitude is None assert entity.location_name is None @@ -539,8 +538,7 @@ def test_tracker_entity() -> None: def test_scanner_entity() -> None: """Test coverage for base ScannerEntity entity class.""" entity = ScannerEntity() - with pytest.raises(NotImplementedError): - assert entity.source_type is None + assert entity.source_type is SourceType.ROUTER with pytest.raises(NotImplementedError): assert entity.is_connected is None with pytest.raises(NotImplementedError): From e3e7aec73cb95656a55e0c565d216b59931876bf Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Tue, 24 Sep 2024 20:07:22 +0100 Subject: [PATCH 1123/1309] Rename an evohome test fixture (#126680) --- tests/components/evohome/const.py | 2 +- .../fixtures/{system_004 => sys_004}/status_3164610.json | 0 .../fixtures/{system_004 => sys_004}/user_locations.json | 0 tests/components/evohome/snapshots/test_init.ambr | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) rename tests/components/evohome/fixtures/{system_004 => sys_004}/status_3164610.json (100%) rename tests/components/evohome/fixtures/{system_004 => sys_004}/user_locations.json (100%) diff --git a/tests/components/evohome/const.py b/tests/components/evohome/const.py index c25a259e602..c8981529cc2 100644 --- a/tests/components/evohome/const.py +++ b/tests/components/evohome/const.py @@ -15,5 +15,5 @@ TEST_INSTALLS: Final = ( "default", # evohome (multi-zone, with DHW & ghost zones) "h032585", # VisionProWifi (no preset_mode for TCS) "h099625", # RoundThermostat - "system_004", # RoundModulation + "sys_004", # RoundModulation ) diff --git a/tests/components/evohome/fixtures/system_004/status_3164610.json b/tests/components/evohome/fixtures/sys_004/status_3164610.json similarity index 100% rename from tests/components/evohome/fixtures/system_004/status_3164610.json rename to tests/components/evohome/fixtures/sys_004/status_3164610.json diff --git a/tests/components/evohome/fixtures/system_004/user_locations.json b/tests/components/evohome/fixtures/sys_004/user_locations.json similarity index 100% rename from tests/components/evohome/fixtures/system_004/user_locations.json rename to tests/components/evohome/fixtures/sys_004/user_locations.json diff --git a/tests/components/evohome/snapshots/test_init.ambr b/tests/components/evohome/snapshots/test_init.ambr index 8e5338ecb9b..e79e750370d 100644 --- a/tests/components/evohome/snapshots/test_init.ambr +++ b/tests/components/evohome/snapshots/test_init.ambr @@ -778,7 +778,7 @@ }), ]) # --- -# name: test_entities[system_004] +# name: test_entities[sys_004] list([ StateSnapshot({ 'attributes': ReadOnlyDict({ From 739165585adfc0800f0d4fe55339ac368c42a1b0 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 24 Sep 2024 15:10:01 -0400 Subject: [PATCH 1124/1309] Bump aiorussound to 3.1.5 (#126664) --- .../components/russound_rio/entity.py | 10 ++++----- .../components/russound_rio/manifest.json | 2 +- .../components/russound_rio/media_player.py | 22 ++++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 4d458118939..292e14e3d6d 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -43,7 +43,7 @@ class RussoundBaseEntity(Entity): controller: Controller, ) -> None: """Initialize the entity.""" - self._instance = controller.instance + self._client = controller.client self._controller = controller self._primary_mac_address = ( controller.mac_address or controller.parent_controller.mac_address @@ -60,9 +60,9 @@ class RussoundBaseEntity(Entity): model=controller.controller_type, sw_version=controller.firmware_version, ) - if isinstance(self._instance.connection_handler, RussoundTcpConnectionHandler): + if isinstance(self._client.connection_handler, RussoundTcpConnectionHandler): self._attr_device_info["configuration_url"] = ( - f"http://{self._instance.connection_handler.host}" + f"http://{self._client.connection_handler.host}" ) if controller.parent_controller: self._attr_device_info["via_device"] = ( @@ -82,12 +82,12 @@ class RussoundBaseEntity(Entity): async def async_added_to_hass(self) -> None: """Register callbacks.""" - self._instance.connection_handler.add_connection_callback( + self._client.connection_handler.add_connection_callback( self._is_connected_updated ) async def async_will_remove_from_hass(self) -> None: """Remove callbacks.""" - self._instance.connection_handler.remove_connection_callback( + self._client.connection_handler.remove_connection_callback( self._is_connected_updated ) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 0a18bdb3b8a..55b88c33c45 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.0.5"] + "requirements": ["aiorussound==3.1.5"] } diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index a5bb392a028..2a2b951cf2b 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -4,7 +4,8 @@ from __future__ import annotations import logging -from aiorussound import Source, Zone +from aiorussound import RussoundClient, Source, Zone +from aiorussound.models import CallbackType from homeassistant.components.media_player import ( MediaPlayerDeviceClass, @@ -130,25 +131,26 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): 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.instance.supported_features: + if flag in zone.client.supported_features: self._attr_supported_features |= feature - def _callback_handler(self, device_str, *args): - if ( - device_str == self._zone.device_str() - or device_str == self._current_source().device_str() - ): - self.schedule_update_ha_state() + 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() async def async_added_to_hass(self) -> None: """Register callback handlers.""" await super().async_added_to_hass() - self._zone.add_callback(self._callback_handler) + 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() - self._zone.remove_callback(self._callback_handler) + await self._client.unregister_state_update_callbacks( + self._state_update_callback + ) def _current_source(self) -> Source: return self._zone.fetch_current_source() diff --git a/requirements_all.txt b/requirements_all.txt index 03a76744ec1..8f46fb0203d 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.0.5 +aiorussound==3.1.5 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d1815481a9..0e7dd29c64c 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.0.5 +aiorussound==3.1.5 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 86f8901c9667fc75323a38e87cae98f3603fe5b5 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 24 Sep 2024 14:24:42 -0500 Subject: [PATCH 1125/1309] Fix pipeline restart in VoIP (#126668) --- .../components/voip/assist_satellite.py | 75 +++++++++---------- homeassistant/components/voip/util.py | 28 ------- tests/components/voip/test_util.py | 47 ------------ 3 files changed, 34 insertions(+), 116 deletions(-) delete mode 100644 homeassistant/components/voip/util.py delete mode 100644 tests/components/voip/test_util.py diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 2f37a8a63e1..6eb1aee209f 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -14,11 +14,7 @@ import wave from voip_utils import RtpDatagramProtocol from homeassistant.components import tts -from homeassistant.components.assist_pipeline import ( - PipelineEvent, - PipelineEventType, - PipelineNotFound, -) +from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.assist_satellite import ( AssistSatelliteConfiguration, AssistSatelliteEntity, @@ -31,7 +27,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CHANNELS, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH from .devices import VoIPDevice from .entity import VoIPEntity -from .util import queue_to_iterable if TYPE_CHECKING: from . import DomainData @@ -101,9 +96,9 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self.config_entry = config_entry - self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue() + self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue() self._audio_chunk_timeout: float = 2.0 - self._pipeline_task: asyncio.Task | None = None + self._run_pipeline_task: asyncio.Task | None = None self._pipeline_had_error: bool = False self._tts_done = asyncio.Event() self._tts_extra_timeout: float = 1.0 @@ -161,11 +156,11 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol def on_chunk(self, audio_bytes: bytes) -> None: """Handle raw audio chunk.""" - if self._pipeline_task is None: - self._clear_audio_queue() - + if self._run_pipeline_task is None: # Run pipeline until voice command finishes, then start over - self._pipeline_task = self.config_entry.async_create_background_task( + self._clear_audio_queue() + self._tts_done.clear() + self._run_pipeline_task = self.config_entry.async_create_background_task( self.hass, self._run_pipeline(), "voip_pipeline_run", @@ -173,27 +168,28 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol self._audio_queue.put_nowait(audio_bytes) - async def _run_pipeline( - self, - ) -> None: - """Forward audio to pipeline STT and handle TTS.""" + async def _run_pipeline(self) -> None: + _LOGGER.debug("Starting pipeline") + self.async_set_context(Context(user_id=self.config_entry.data["user"])) self.voip_device.set_is_active(True) + async def stt_stream(): + while True: + async with asyncio.timeout(self._audio_chunk_timeout): + chunk = await self._audio_queue.get() + if not chunk: + break + + yield chunk + # Play listening tone at the start of each cycle await self._play_tone(Tones.LISTENING, silence_before=0.2) try: - self._tts_done.clear() - - # Run pipeline with a timeout - _LOGGER.debug("Starting pipeline") - async with asyncio.timeout(_PIPELINE_TIMEOUT_SEC): - await self.async_accept_pipeline_from_satellite( - audio_stream=queue_to_iterable( - self._audio_queue, timeout=self._audio_chunk_timeout - ), - ) + await self.async_accept_pipeline_from_satellite( + audio_stream=stt_stream(), + ) if self._pipeline_had_error: self._pipeline_had_error = False @@ -204,20 +200,15 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol # This is set in _send_tts and has a timeout that's based on the # length of the TTS audio. await self._tts_done.wait() - - _LOGGER.debug("Pipeline finished") - except PipelineNotFound: - _LOGGER.warning("Pipeline not found") - except (asyncio.CancelledError, TimeoutError): - # Expected after caller hangs up - _LOGGER.debug("Pipeline cancelled or timed out") - self.disconnect() - self._clear_audio_queue() + except TimeoutError: + self.disconnect() # caller hung up finally: - self.voip_device.set_is_active(False) + # Stop audio stream + await self._audio_queue.put(None) - # Allow pipeline to run again - self._pipeline_task = None + self.voip_device.set_is_active(False) + self._run_pipeline_task = None + _LOGGER.debug("Pipeline finished") def _clear_audio_queue(self) -> None: """Ensure audio queue is empty.""" @@ -247,6 +238,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol elif event.type == PipelineEventType.ERROR: # Play error tone instead of wait for TTS when pipeline is finished. self._pipeline_had_error = True + _LOGGER.warning(event) async def _send_tts(self, media_id: str) -> None: """Send TTS audio to caller via RTP.""" @@ -264,6 +256,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol if (self._tones & Tones.PROCESSING) == Tones.PROCESSING: # Don't overlap TTS and processing beep + _LOGGER.debug("Waiting for processing tone") await self._processing_tone_done.wait() with io.BytesIO(data) as wav_io: @@ -297,12 +290,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol _LOGGER.warning("TTS timeout") raise finally: - # Signal pipeline to restart - self._tts_done.set() - # Update satellite state self.tts_response_finished() + # Signal pipeline to restart + self._tts_done.set() + async def _async_send_audio(self, audio_bytes: bytes, **kwargs): """Send audio in executor.""" await self.hass.async_add_executor_job( diff --git a/homeassistant/components/voip/util.py b/homeassistant/components/voip/util.py deleted file mode 100644 index bfda96ba810..00000000000 --- a/homeassistant/components/voip/util.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Voip util functions.""" - -from __future__ import annotations - -from asyncio import Queue, timeout as async_timeout -from collections.abc import AsyncIterable -from typing import Any - -from typing_extensions import TypeVar - -_DataT = TypeVar("_DataT", default=Any) - - -async def queue_to_iterable( - queue: Queue[_DataT], timeout: float | None = None -) -> AsyncIterable[_DataT]: - """Stream items from a queue until None with an optional timeout per item.""" - if timeout is None: - while (item := await queue.get()) is not None: - yield item - else: - async with async_timeout(timeout): - item = await queue.get() - - while item is not None: - yield item - async with async_timeout(timeout): - item = await queue.get() diff --git a/tests/components/voip/test_util.py b/tests/components/voip/test_util.py deleted file mode 100644 index 85dfdbac2be..00000000000 --- a/tests/components/voip/test_util.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Test VoIP utils.""" - -import asyncio - -import pytest - -from homeassistant.components.voip.util import queue_to_iterable - - -async def test_queue_to_iterable() -> None: - """Test queue_to_iterable.""" - queue: asyncio.Queue[int | None] = asyncio.Queue() - expected_items = list(range(10)) - - for i in expected_items: - await queue.put(i) - - # Will terminate the stream - await queue.put(None) - - actual_items = [item async for item in queue_to_iterable(queue)] - - assert expected_items == actual_items - - # Check timeout - assert queue.empty() - - # Time out on first item - async with asyncio.timeout(1): - with pytest.raises(asyncio.TimeoutError): # noqa: PT012 - # Should time out very quickly - async for _item in queue_to_iterable(queue, timeout=0.01): - await asyncio.sleep(1) - - # Check timeout on second item - assert queue.empty() - await queue.put(12345) - - # Time out on second item - async with asyncio.timeout(1): - with pytest.raises(asyncio.TimeoutError): # noqa: PT012 - # Should time out very quickly - async for item in queue_to_iterable(queue, timeout=0.01): - if item != 12345: - await asyncio.sleep(1) - - assert queue.empty() From b370893e58fb401a5ee9e02296952bff5f6b3aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 24 Sep 2024 21:30:30 +0200 Subject: [PATCH 1126/1309] Add support for OperationalState Attribute from Matter OperationalState cluster (#125627) --- homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/sensor.py | 63 +- homeassistant/components/matter/strings.json | 9 + .../fixtures/nodes/silabs-dishwasher.json | 657 ++++++++++++++++++ tests/components/matter/test_sensor.py | 36 + 5 files changed, 766 insertions(+), 2 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/silabs-dishwasher.json diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 3e520adce62..5d9a7aaf477 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -35,6 +35,9 @@ "activated_carbon_filter_condition": { "default": "mdi:filter-check" }, + "operational_state": { + "default": "mdi:play-pause" + }, "valve_position": { "default": "mdi:valve" } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 499eb20aa59..e10f081d497 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime +from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue @@ -37,6 +38,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -61,6 +63,15 @@ CONTAMINATION_STATE_MAP = { } +OPERATIONAL_STATE_MAP = { + # enum with known Operation state values which we can translate + clusters.OperationalState.Enums.OperationalStateEnum.kStopped: "stopped", + clusters.OperationalState.Enums.OperationalStateEnum.kRunning: "running", + clusters.OperationalState.Enums.OperationalStateEnum.kPaused: "paused", + clusters.OperationalState.Enums.OperationalStateEnum.kError: "error", +} + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -93,6 +104,42 @@ class MatterSensor(MatterEntity, SensorEntity): self._attr_native_value = value +class MatterOperationalStateSensor(MatterSensor): + """Representation of a sensor for Matter Operational State.""" + + states_map: dict[int, str] + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + # the operational state list is a list of the possible operational states + # this is a dynamic list and is condition, device and manufacturer specific + # therefore it is not possible to provide a fixed list of options + # or to provide a mapping to a translateable string for all options + operational_state_list = self.get_matter_attribute_value( + clusters.OperationalState.Attributes.OperationalStateList + ) + if TYPE_CHECKING: + operational_state_list = cast( + list[clusters.OperationalState.Structs.OperationalStateStruct], + operational_state_list, + ) + states_map: dict[int, str] = {} + for state in operational_state_list: + # prefer translateable (known) state from mapping, + # fallback to the raw state label as given by the device/manufacturer + states_map[state.operationalStateID] = OPERATIONAL_STATE_MAP.get( + state.operationalStateID, slugify(state.operationalStateLabel) + ) + self.states_map = states_map + self._attr_options = list(states_map.values()) + self._attr_native_value = states_map.get( + self.get_matter_attribute_value( + clusters.OperationalState.Attributes.OperationalState + ) + ) + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -582,8 +629,7 @@ DISCOVERY_SCHEMAS = [ key="SmokeCOAlarmContaminationState", translation_key="contamination_state", device_class=SensorDeviceClass.ENUM, - # convert to set first to remove the duplicate unknown value - options=list(set(CONTAMINATION_STATE_MAP.values())), + options=list(CONTAMINATION_STATE_MAP.values()), measurement_to_ha=CONTAMINATION_STATE_MAP.get, ), entity_class=MatterSensor, @@ -601,4 +647,17 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.ExpiryDate,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="OperationalState", + device_class=SensorDeviceClass.ENUM, + translation_key="operational_state", + ), + entity_class=MatterOperationalStateSensor, + required_attributes=( + clusters.OperationalState.Attributes.OperationalState, + clusters.OperationalState.Attributes.OperationalStateList, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index d7258c02f95..fdff15ce0a4 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -210,6 +210,15 @@ "hepa_filter_condition": { "name": "Hepa filter condition" }, + "operational_state": { + "name": "Operational state", + "state": { + "stopped": "Stopped", + "running": "Running", + "paused": "[%key:common::state::paused%]", + "error": "Error" + } + }, "switch_current_position": { "name": "Current switch position" }, diff --git a/tests/components/matter/fixtures/nodes/silabs-dishwasher.json b/tests/components/matter/fixtures/nodes/silabs-dishwasher.json new file mode 100644 index 00000000000..f060066e100 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs-dishwasher.json @@ -0,0 +1,657 @@ +{ + "node_id": 54, + "date_commissioned": "2024-08-15T07:14:29.055273", + "last_interview": "2024-08-15T11:36:27.830863", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [ + 29, 31, 40, 42, 43, 44, 45, 48, 49, 50, 51, 52, 53, 60, 62, 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "Dishwasher", + "0/40/4": 32773, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1", + "0/40/11": "20200101", + "0/40/12": "Dishwasher", + "0/40/13": "Dishwasher", + "0/40/14": "", + "0/40/15": "", + "0/40/16": false, + "0/40/18": "**REDACTED**", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [ + { + "1": 556220604, + "2": 0, + "254": 1 + } + ], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/45/0": 1, + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "**REDACTED**", + "0/49/7": null, + "0/49/9": 10, + "0/49/10": 4, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [], + "0/51/1": 6, + "0/51/2": 10, + "0/51/3": 4, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "0": 3, + "1": "UART", + "3": 128 + }, + { + "0": 9, + "1": "DishWash", + "3": 766 + }, + { + "0": 2, + "1": "OT Stack", + "3": 719 + }, + { + "0": 12, + "1": "Bluetoot", + "3": 40 + }, + { + "0": 1, + "1": "Bluetoot", + "3": 282 + }, + { + "0": 11, + "1": "Bluetoot", + "3": 210 + }, + { + "0": 8, + "1": "shell", + "3": 323 + }, + { + "0": 6, + "1": "Tmr Svc", + "3": 594 + }, + { + "0": 5, + "1": "IDLE", + "3": 266 + }, + { + "0": 7, + "1": "CHIP", + "3": 705 + } + ], + "0/52/1": 100824, + "0/52/2": 16984, + "0/52/3": 4294959062, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "**REDACTED**", + "0/53/3": 39055, + "0/53/4": 12054125955590472924, + "0/53/5": "**REDACTED**", + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": 1773502518, + "0/53/10": 64, + "0/53/11": 88, + "0/53/12": 225, + "0/53/13": 22, + "0/53/14": 1, + "0/53/15": 0, + "0/53/16": 1, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 693, + "0/53/23": 686, + "0/53/24": 7, + "0/53/25": 686, + "0/53/26": 686, + "0/53/27": 7, + "0/53/28": 693, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 61, + "0/53/34": 0, + "0/53/35": 0, + "0/53/36": 2, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 87, + "0/53/40": 87, + "0/53/41": 0, + "0/53/42": 86, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 1, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": 0, + "0/53/57": 0, + "0/53/58": 0, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 2, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [], + "0/62/1": [], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "0": "room", + "1": "bedroom 2" + }, + { + "0": "orientation", + "1": "North" + }, + { + "0": "floor", + "1": "2" + }, + { + "0": "direction", + "1": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 117, + "1": 1 + } + ], + "1/29/1": [3, 29, 30, 89, 96], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/30/0": [], + "1/30/65532": 0, + "1/30/65533": 1, + "1/30/65528": [], + "1/30/65529": [], + "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/89/0": null, + "1/89/1": null, + "1/89/65532": null, + "1/89/65533": 2, + "1/89/65528": [1], + "1/89/65529": [0], + "1/89/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/96/0": null, + "1/96/1": null, + "1/96/3": [ + { + "0": 0 + }, + { + "0": 1 + }, + { + "0": 2 + }, + { + "0": 3 + }, + { + "0": 8, + "1": "Extra state" + } + ], + "1/96/4": 0, + "1/96/5": { + "0": 0 + }, + "1/96/65532": 0, + "1/96/65533": 1, + "1/96/65528": [4], + "1/96/65529": [0, 1, 2, 3], + "1/96/65531": [0, 1, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 1296, + "1": 1 + } + ], + "2/29/1": [29, 144, 145, 156], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/144/0": 2, + "2/144/1": 3, + "2/144/2": [ + { + "0": 5, + "1": true, + "2": -50000000, + "3": 50000000, + "4": [ + { + "0": -50000000, + "1": -10000000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -9999999, + "1": 9999999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 10000000, + "1": 50000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": -100000, + "3": 100000, + "4": [ + { + "0": -100000, + "1": -5000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -4999, + "1": 4999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 5000, + "1": 100000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": -500000, + "3": 500000, + "4": [ + { + "0": -500000, + "1": -100000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -99999, + "1": 99999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 100000, + "1": 500000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "2/144/3": [ + { + "0": 0, + "1": 0, + "2": 300, + "7": 101, + "8": 101, + "9": 101, + "10": 101 + }, + { + "0": 1, + "1": 0, + "2": 500, + "7": 101, + "8": 101, + "9": 101, + "10": 101 + }, + { + "0": 2, + "1": 0, + "2": 1000, + "7": 101, + "8": 101, + "9": 101, + "10": 101 + } + ], + "2/144/4": 120000, + "2/144/5": 0, + "2/144/6": 0, + "2/144/7": 0, + "2/144/8": 0, + "2/144/9": 0, + "2/144/10": 0, + "2/144/11": 120000, + "2/144/12": 0, + "2/144/13": 0, + "2/144/14": 60, + "2/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/17": 9800, + "2/144/18": 0, + "2/144/65532": 31, + "2/144/65533": 1, + "2/144/65528": [], + "2/144/65529": [], + "2/144/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 65528, + 65529, 65531, 65532, 65533 + ], + "2/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 0, + "1": 1000000000000000, + "2": 500, + "3": 50 + } + ] + }, + "2/145/1": { + "0": 0, + "1": 9, + "2": 12, + "3": 9649, + "4": 12530 + }, + "2/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "2/145/65532": 5, + "2/145/65533": 1, + "2/145/65528": [], + "2/145/65529": [], + "2/145/65531": [0, 1, 5, 65528, 65529, 65531, 65532, 65533], + "2/156/0": [0, 1, 2], + "2/156/1": null, + "2/156/65532": 12, + "2/156/65533": 1, + "2/156/65528": [], + "2/156/65529": [], + "2/156/65531": [0, 1, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 61234e6afcd..dd0e52b8c7c 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -122,6 +122,16 @@ async def air_purifier_node_fixture( ) +@pytest.fixture(name="dishwasher_node") +async def dishwasher_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for an dishwasher node.""" + return await setup_integration_with_node_fixture( + hass, "silabs-dishwasher", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -622,3 +632,29 @@ async def test_smoke_alarm( state = hass.states.get("sensor.smoke_sensor_voltage") assert state assert state.state == "0.0" + + +async def test_operational_state_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + dishwasher_node: MatterNode, +) -> None: + """Test dishwasher sensor.""" + # OperationalState Cluster / OperationalState attribute (1/96/4) + state = hass.states.get("sensor.dishwasher_operational_state") + assert state + assert state.state == "stopped" + assert state.attributes["options"] == [ + "stopped", + "running", + "paused", + "error", + "extra_state", + ] + + set_node_attribute(dishwasher_node, 1, 96, 4, 8) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.dishwasher_operational_state") + assert state + assert state.state == "extra_state" From 69ecdda5f5cd04642128b2c3fbfa5dac3fbfc7b5 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 24 Sep 2024 21:31:52 +0200 Subject: [PATCH 1127/1309] Add SSL Cipher option to aiohttp async_get_clientsession (#126317) Co-authored-by: J. Nick Koston --- homeassistant/helpers/aiohttp_client.py | 30 +++--- homeassistant/util/ssl.py | 57 +++++++---- tests/helpers/test_aiohttp_client.py | 122 +++++++++++++++++++----- tests/util/test_ssl.py | 23 ++--- 4 files changed, 164 insertions(+), 68 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index d61f889d4b5..2f4c1980468 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -32,11 +32,11 @@ if TYPE_CHECKING: from aiohttp.typedefs import JSONDecoder -DATA_CONNECTOR: HassKey[dict[tuple[bool, int], aiohttp.BaseConnector]] = HassKey( +DATA_CONNECTOR: HassKey[dict[tuple[bool, int, str], aiohttp.BaseConnector]] = HassKey( "aiohttp_connector" ) -DATA_CLIENTSESSION: HassKey[dict[tuple[bool, int], aiohttp.ClientSession]] = HassKey( - "aiohttp_clientsession" +DATA_CLIENTSESSION: HassKey[dict[tuple[bool, int, str], aiohttp.ClientSession]] = ( + HassKey("aiohttp_clientsession") ) SERVER_SOFTWARE = ( @@ -86,12 +86,13 @@ def async_get_clientsession( hass: HomeAssistant, verify_ssl: bool = True, family: socket.AddressFamily = socket.AF_UNSPEC, + ssl_cipher: ssl_util.SSLCipherList = ssl_util.SSLCipherList.PYTHON_DEFAULT, ) -> aiohttp.ClientSession: """Return default aiohttp ClientSession. This method must be run in the event loop. """ - session_key = _make_key(verify_ssl, family) + session_key = _make_key(verify_ssl, family, ssl_cipher) sessions = hass.data.setdefault(DATA_CLIENTSESSION, {}) if session_key not in sessions: @@ -100,6 +101,7 @@ def async_get_clientsession( verify_ssl, auto_cleanup_method=_async_register_default_clientsession_shutdown, family=family, + ssl_cipher=ssl_cipher, ) sessions[session_key] = session else: @@ -115,6 +117,7 @@ def async_create_clientsession( verify_ssl: bool = True, auto_cleanup: bool = True, family: socket.AddressFamily = socket.AF_UNSPEC, + ssl_cipher: ssl_util.SSLCipherList = ssl_util.SSLCipherList.PYTHON_DEFAULT, **kwargs: Any, ) -> aiohttp.ClientSession: """Create a new ClientSession with kwargs, i.e. for cookies. @@ -135,6 +138,7 @@ def async_create_clientsession( verify_ssl, auto_cleanup_method=auto_cleanup_method, family=family, + ssl_cipher=ssl_cipher, **kwargs, ) @@ -146,11 +150,12 @@ def _async_create_clientsession( auto_cleanup_method: Callable[[HomeAssistant, aiohttp.ClientSession], None] | None = None, family: socket.AddressFamily = socket.AF_UNSPEC, + ssl_cipher: ssl_util.SSLCipherList = ssl_util.SSLCipherList.PYTHON_DEFAULT, **kwargs: Any, ) -> aiohttp.ClientSession: """Create a new ClientSession with kwargs, i.e. for cookies.""" clientsession = aiohttp.ClientSession( - connector=_async_get_connector(hass, verify_ssl, family), + connector=_async_get_connector(hass, verify_ssl, family, ssl_cipher), json_serialize=json_dumps, response_class=HassClientResponse, **kwargs, @@ -279,10 +284,12 @@ def _async_register_default_clientsession_shutdown( @callback def _make_key( - verify_ssl: bool = True, family: socket.AddressFamily = socket.AF_UNSPEC -) -> tuple[bool, socket.AddressFamily]: + verify_ssl: bool = True, + family: socket.AddressFamily = socket.AF_UNSPEC, + ssl_cipher: ssl_util.SSLCipherList = ssl_util.SSLCipherList.PYTHON_DEFAULT, +) -> tuple[bool, socket.AddressFamily, ssl_util.SSLCipherList]: """Make a key for connector or session pool.""" - return (verify_ssl, family) + return (verify_ssl, family, ssl_cipher) class HomeAssistantTCPConnector(aiohttp.TCPConnector): @@ -305,21 +312,22 @@ def _async_get_connector( hass: HomeAssistant, verify_ssl: bool = True, family: socket.AddressFamily = socket.AF_UNSPEC, + ssl_cipher: ssl_util.SSLCipherList = ssl_util.SSLCipherList.PYTHON_DEFAULT, ) -> aiohttp.BaseConnector: """Return the connector pool for aiohttp. This method must be run in the event loop. """ - connector_key = _make_key(verify_ssl, family) + connector_key = _make_key(verify_ssl, family, ssl_cipher) connectors = hass.data.setdefault(DATA_CONNECTOR, {}) if connector_key in connectors: return connectors[connector_key] if verify_ssl: - ssl_context: SSLContext = ssl_util.get_default_context() + ssl_context: SSLContext = ssl_util.client_context(ssl_cipher) else: - ssl_context = ssl_util.get_default_no_verify_context() + ssl_context = ssl_util.client_context_no_verify(ssl_cipher) connector = HomeAssistantTCPConnector( family=family, diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 7c1e653ce75..a22fd0c8fb4 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -15,6 +15,7 @@ class SSLCipherList(StrEnum): PYTHON_DEFAULT = "python_default" INTERMEDIATE = "intermediate" MODERN = "modern" + INSECURE = "insecure" SSL_CIPHER_LISTS = { @@ -58,11 +59,12 @@ SSL_CIPHER_LISTS = { "ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:" "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256" ), + SSLCipherList.INSECURE: "DEFAULT:@SECLEVEL=0", } @cache -def _create_no_verify_ssl_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: +def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: # This is a copy of aiohttp's create_default_context() function, with the # ssl verify turned off. # https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 @@ -80,16 +82,10 @@ def _create_no_verify_ssl_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLCont return sslcontext -def create_no_verify_ssl_context( +@cache +def _client_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: - """Return an SSL context that does not verify the server certificate.""" - - return _create_no_verify_ssl_context(ssl_cipher_list=ssl_cipher_list) - - -@cache -def _client_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: # Reuse environment variable definition from requests, since it's already a # requirement. If the environment variable has no value, fall back to using # certs from certifi package. @@ -104,17 +100,19 @@ def _client_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: return sslcontext -def client_context( - ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, -) -> ssl.SSLContext: - """Return an SSL context for making requests.""" - - return _client_context(ssl_cipher_list=ssl_cipher_list) - - # Create this only once and reuse it -_DEFAULT_SSL_CONTEXT = client_context() -_DEFAULT_NO_VERIFY_SSL_CONTEXT = create_no_verify_ssl_context() +_DEFAULT_SSL_CONTEXT = _client_context(SSLCipherList.PYTHON_DEFAULT) +_DEFAULT_NO_VERIFY_SSL_CONTEXT = _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT) +_NO_VERIFY_SSL_CONTEXTS = { + SSLCipherList.INTERMEDIATE: _client_context_no_verify(SSLCipherList.INTERMEDIATE), + SSLCipherList.MODERN: _client_context_no_verify(SSLCipherList.MODERN), + SSLCipherList.INSECURE: _client_context_no_verify(SSLCipherList.INSECURE), +} +_SSL_CONTEXTS = { + SSLCipherList.INTERMEDIATE: _client_context(SSLCipherList.INTERMEDIATE), + SSLCipherList.MODERN: _client_context(SSLCipherList.MODERN), + SSLCipherList.INSECURE: _client_context(SSLCipherList.INSECURE), +} def get_default_context() -> ssl.SSLContext: @@ -127,6 +125,27 @@ def get_default_no_verify_context() -> ssl.SSLContext: return _DEFAULT_NO_VERIFY_SSL_CONTEXT +def client_context_no_verify( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return a SSL context with no verification with a specific ssl cipher.""" + return _NO_VERIFY_SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_NO_VERIFY_SSL_CONTEXT) + + +def client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an SSL context for making requests.""" + return _SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_SSL_CONTEXT) + + +def create_no_verify_ssl_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an SSL context that does not verify the server certificate.""" + return _client_context_no_verify(ssl_cipher_list) + + def server_context_modern() -> ssl.SSLContext: """Return an SSL context following the Mozilla recommendations. diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 4feb03493e9..126ed3f9287 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -23,6 +23,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.aiohttp_client as client from homeassistant.util.color import RGBColor +from homeassistant.util.ssl import SSLCipherList from tests.common import ( MockConfigEntry, @@ -62,11 +63,14 @@ async def test_get_clientsession_with_ssl(hass: HomeAssistant) -> None: """Test init clientsession with ssl.""" client.async_get_clientsession(hass) verify_ssl = True + ssl_cipher = SSLCipherList.PYTHON_DEFAULT family = 0 - client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)] + client_session = hass.data[client.DATA_CLIENTSESSION][ + (verify_ssl, family, ssl_cipher) + ] assert isinstance(client_session, aiohttp.ClientSession) - connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family, ssl_cipher)] assert isinstance(connector, aiohttp.TCPConnector) @@ -74,33 +78,63 @@ async def test_get_clientsession_without_ssl(hass: HomeAssistant) -> None: """Test init clientsession without ssl.""" client.async_get_clientsession(hass, verify_ssl=False) verify_ssl = False + ssl_cipher = SSLCipherList.PYTHON_DEFAULT family = 0 - client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)] + client_session = hass.data[client.DATA_CLIENTSESSION][ + (verify_ssl, family, ssl_cipher) + ] assert isinstance(client_session, aiohttp.ClientSession) - connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family, ssl_cipher)] assert isinstance(connector, aiohttp.TCPConnector) @pytest.mark.parametrize( - ("verify_ssl", "expected_family"), + ("verify_ssl", "expected_family", "ssl_cipher"), [ - (True, socket.AF_UNSPEC), - (False, socket.AF_UNSPEC), - (True, socket.AF_INET), - (False, socket.AF_INET), - (True, socket.AF_INET6), - (False, socket.AF_INET6), + (True, socket.AF_UNSPEC, SSLCipherList.PYTHON_DEFAULT), + (True, socket.AF_INET, SSLCipherList.PYTHON_DEFAULT), + (True, socket.AF_INET6, SSLCipherList.PYTHON_DEFAULT), + (True, socket.AF_UNSPEC, SSLCipherList.INTERMEDIATE), + (True, socket.AF_INET, SSLCipherList.INTERMEDIATE), + (True, socket.AF_INET6, SSLCipherList.INTERMEDIATE), + (True, socket.AF_UNSPEC, SSLCipherList.MODERN), + (True, socket.AF_INET, SSLCipherList.MODERN), + (True, socket.AF_INET6, SSLCipherList.MODERN), + (True, socket.AF_UNSPEC, SSLCipherList.INSECURE), + (True, socket.AF_INET, SSLCipherList.INSECURE), + (True, socket.AF_INET6, SSLCipherList.INSECURE), + (False, socket.AF_UNSPEC, SSLCipherList.PYTHON_DEFAULT), + (False, socket.AF_INET, SSLCipherList.PYTHON_DEFAULT), + (False, socket.AF_INET6, SSLCipherList.PYTHON_DEFAULT), + (False, socket.AF_UNSPEC, SSLCipherList.INTERMEDIATE), + (False, socket.AF_INET, SSLCipherList.INTERMEDIATE), + (False, socket.AF_INET6, SSLCipherList.INTERMEDIATE), + (False, socket.AF_UNSPEC, SSLCipherList.MODERN), + (False, socket.AF_INET, SSLCipherList.MODERN), + (False, socket.AF_INET6, SSLCipherList.MODERN), + (False, socket.AF_UNSPEC, SSLCipherList.INSECURE), + (False, socket.AF_INET, SSLCipherList.INSECURE), + (False, socket.AF_INET6, SSLCipherList.INSECURE), ], ) async def test_get_clientsession( - hass: HomeAssistant, verify_ssl: bool, expected_family: int + hass: HomeAssistant, + verify_ssl: bool, + expected_family: int, + ssl_cipher: SSLCipherList, ) -> None: """Test init clientsession combinations.""" - client.async_get_clientsession(hass, verify_ssl=verify_ssl, family=expected_family) - client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, expected_family)] + client.async_get_clientsession( + hass, verify_ssl=verify_ssl, family=expected_family, ssl_cipher=ssl_cipher + ) + client_session = hass.data[client.DATA_CLIENTSESSION][ + (verify_ssl, expected_family, ssl_cipher) + ] assert isinstance(client_session, aiohttp.ClientSession) - connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, expected_family)] + connector = hass.data[client.DATA_CONNECTOR][ + (verify_ssl, expected_family, ssl_cipher) + ] assert isinstance(connector, aiohttp.TCPConnector) @@ -110,10 +144,11 @@ async def test_create_clientsession_with_ssl_and_cookies(hass: HomeAssistant) -> assert isinstance(session, aiohttp.ClientSession) verify_ssl = True + ssl_cipher = SSLCipherList.PYTHON_DEFAULT family = 0 assert client.DATA_CLIENTSESSION not in hass.data - connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family, ssl_cipher)] assert isinstance(connector, aiohttp.TCPConnector) @@ -125,26 +160,61 @@ async def test_create_clientsession_without_ssl_and_cookies( assert isinstance(session, aiohttp.ClientSession) verify_ssl = False + ssl_cipher = SSLCipherList.PYTHON_DEFAULT family = 0 assert client.DATA_CLIENTSESSION not in hass.data - connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family, ssl_cipher)] assert isinstance(connector, aiohttp.TCPConnector) @pytest.mark.parametrize( - ("verify_ssl", "expected_family"), - [(True, 0), (False, 0), (True, 4), (False, 4), (True, 6), (False, 6)], + ("verify_ssl", "expected_family", "ssl_cipher"), + [ + (True, 0, SSLCipherList.PYTHON_DEFAULT), + (True, 4, SSLCipherList.PYTHON_DEFAULT), + (True, 6, SSLCipherList.PYTHON_DEFAULT), + (True, 0, SSLCipherList.INTERMEDIATE), + (True, 4, SSLCipherList.INTERMEDIATE), + (True, 6, SSLCipherList.INTERMEDIATE), + (True, 0, SSLCipherList.MODERN), + (True, 4, SSLCipherList.MODERN), + (True, 6, SSLCipherList.MODERN), + (True, 0, SSLCipherList.INSECURE), + (True, 4, SSLCipherList.INSECURE), + (True, 6, SSLCipherList.INSECURE), + (False, 0, SSLCipherList.PYTHON_DEFAULT), + (False, 4, SSLCipherList.PYTHON_DEFAULT), + (False, 6, SSLCipherList.PYTHON_DEFAULT), + (False, 0, SSLCipherList.INTERMEDIATE), + (False, 4, SSLCipherList.INTERMEDIATE), + (False, 6, SSLCipherList.INTERMEDIATE), + (False, 0, SSLCipherList.MODERN), + (False, 4, SSLCipherList.MODERN), + (False, 6, SSLCipherList.MODERN), + (False, 0, SSLCipherList.INSECURE), + (False, 4, SSLCipherList.INSECURE), + (False, 6, SSLCipherList.INSECURE), + ], ) async def test_get_clientsession_cleanup( - hass: HomeAssistant, verify_ssl: bool, expected_family: int + hass: HomeAssistant, + verify_ssl: bool, + expected_family: int, + ssl_cipher: SSLCipherList, ) -> None: """Test init clientsession cleanup.""" - client.async_get_clientsession(hass, verify_ssl=verify_ssl, family=expected_family) + client.async_get_clientsession( + hass, verify_ssl=verify_ssl, family=expected_family, ssl_cipher=ssl_cipher + ) - client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, expected_family)] + client_session = hass.data[client.DATA_CLIENTSESSION][ + (verify_ssl, expected_family, ssl_cipher) + ] assert isinstance(client_session, aiohttp.ClientSession) - connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, expected_family)] + connector = hass.data[client.DATA_CONNECTOR][ + (verify_ssl, expected_family, ssl_cipher) + ] assert isinstance(connector, aiohttp.TCPConnector) hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) @@ -158,17 +228,19 @@ async def test_get_clientsession_patched_close(hass: HomeAssistant) -> None: """Test closing clientsession does not work.""" verify_ssl = True + ssl_cipher = SSLCipherList.PYTHON_DEFAULT family = 0 with patch("aiohttp.ClientSession.close") as mock_close: session = client.async_get_clientsession(hass) assert isinstance( - hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)], + hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family, ssl_cipher)], aiohttp.ClientSession, ) assert isinstance( - hass.data[client.DATA_CONNECTOR][(verify_ssl, family)], aiohttp.TCPConnector + hass.data[client.DATA_CONNECTOR][(verify_ssl, family, ssl_cipher)], + aiohttp.TCPConnector, ) with pytest.raises(RuntimeError): diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index d0c7ce3bfb6..c0cd2fdba10 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -5,7 +5,6 @@ from unittest.mock import MagicMock, Mock, patch import pytest from homeassistant.util.ssl import ( - SSL_CIPHER_LISTS, SSLCipherList, client_context, create_no_verify_ssl_context, @@ -25,14 +24,13 @@ def test_client_context(mock_sslcontext) -> None: mock_sslcontext.set_ciphers.assert_not_called() client_context(SSLCipherList.MODERN) - mock_sslcontext.set_ciphers.assert_called_with( - SSL_CIPHER_LISTS[SSLCipherList.MODERN] - ) + mock_sslcontext.set_ciphers.assert_not_called() client_context(SSLCipherList.INTERMEDIATE) - mock_sslcontext.set_ciphers.assert_called_with( - SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE] - ) + mock_sslcontext.set_ciphers.assert_not_called() + + client_context(SSLCipherList.INSECURE) + mock_sslcontext.set_ciphers.assert_not_called() def test_no_verify_ssl_context(mock_sslcontext) -> None: @@ -42,14 +40,13 @@ def test_no_verify_ssl_context(mock_sslcontext) -> None: mock_sslcontext.set_ciphers.assert_not_called() create_no_verify_ssl_context(SSLCipherList.MODERN) - mock_sslcontext.set_ciphers.assert_called_with( - SSL_CIPHER_LISTS[SSLCipherList.MODERN] - ) + mock_sslcontext.set_ciphers.assert_not_called() create_no_verify_ssl_context(SSLCipherList.INTERMEDIATE) - mock_sslcontext.set_ciphers.assert_called_with( - SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE] - ) + mock_sslcontext.set_ciphers.assert_not_called() + + create_no_verify_ssl_context(SSLCipherList.INSECURE) + mock_sslcontext.set_ciphers.assert_not_called() def test_ssl_context_caching() -> None: From d2d3ab2d98efc00efb18ed93fda0f616f5148da9 Mon Sep 17 00:00:00 2001 From: Doron Somech Date: Tue, 24 Sep 2024 22:38:09 +0300 Subject: [PATCH 1128/1309] Add fan support for KNX climate entities (#126368) * Add fan mode support to knx climate * fix linting errors * remove unneeded None protection from CONF_FAN_PERCENTAGES_MODES * Update homeassistant/components/knx/climate.py Co-authored-by: Matthias Alphart * Update homeassistant/components/knx/climate.py Co-authored-by: Matthias Alphart * Update homeassistant/components/knx/climate.py Co-authored-by: Matthias Alphart * Update homeassistant/components/knx/schema.py Co-authored-by: Matthias Alphart * find closest percentage when not in fan modes * new field for fan speed mode, max steps apply to both step and percentage * not picking FAN_OFF when the percentage is closest to zero * add fan zero mode to support auto mode * use StrEnum for FanZeroMode * change default to 'percent' * fix mypy errors --------- Co-authored-by: Matthias Alphart --- homeassistant/components/knx/climate.py | 75 +++++ homeassistant/components/knx/const.py | 11 +- homeassistant/components/knx/schema.py | 20 +- tests/components/knx/test_climate.py | 380 ++++++++++++++++++++++++ 4 files changed, 482 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 2eb3b913195..879e1421bd4 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -10,10 +10,15 @@ from xknx.devices import ( ClimateMode as XknxClimateMode, Device as XknxDevice, ) +from xknx.devices.fan import FanSpeedMode from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode from homeassistant import config_entries from homeassistant.components.climate import ( + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_ON, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -126,6 +131,11 @@ def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate: min_temp=config.get(ClimateSchema.CONF_MIN_TEMP), max_temp=config.get(ClimateSchema.CONF_MAX_TEMP), mode=climate_mode, + group_address_fan_speed=config.get(ClimateSchema.CONF_FAN_SPEED_ADDRESS), + group_address_fan_speed_state=config.get( + ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS + ), + fan_speed_mode=config[ClimateSchema.CONF_FAN_SPEED_MODE], ) @@ -166,6 +176,36 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): self._attr_preset_modes = [ mode.name.lower() for mode in self._device.mode.operation_modes ] + + fan_max_step = config[ClimateSchema.CONF_FAN_MAX_STEP] + self._fan_modes_percentages = [ + int(100 * i / fan_max_step) for i in range(fan_max_step + 1) + ] + self.fan_zero_mode: str = config[ClimateSchema.CONF_FAN_ZERO_MODE] + + if self._device.fan_speed is not None and self._device.fan_speed.initialized: + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE + + if fan_max_step == 3: + self._attr_fan_modes = [ + self.fan_zero_mode, + FAN_LOW, + FAN_MEDIUM, + FAN_HIGH, + ] + elif fan_max_step == 2: + self._attr_fan_modes = [self.fan_zero_mode, FAN_LOW, FAN_HIGH] + elif fan_max_step == 1: + self._attr_fan_modes = [self.fan_zero_mode, FAN_ON] + elif self._device.fan_speed_mode == FanSpeedMode.STEP: + self._attr_fan_modes = [self.fan_zero_mode] + [ + str(i) for i in range(1, fan_max_step + 1) + ] + else: + self._attr_fan_modes = [self.fan_zero_mode] + [ + f"{percentage}%" for percentage in self._fan_modes_percentages[1:] + ] + self._attr_target_temperature_step = self._device.temperature_step self._attr_unique_id = ( f"{self._device.temperature.group_address_state}_" @@ -322,6 +362,41 @@ class KNXClimate(KnxYamlEntity, ClimateEntity): ) self.async_write_ha_state() + @property + def fan_mode(self) -> str: + """Return the fan setting.""" + + fan_speed = self._device.current_fan_speed + + if not fan_speed or self._attr_fan_modes is None: + return self.fan_zero_mode + + if self._device.fan_speed_mode == FanSpeedMode.STEP: + return self._attr_fan_modes[fan_speed] + + # Find the closest fan mode percentage + closest_percentage = min( + self._fan_modes_percentages[1:], # fan_speed == 0 is handled above + key=lambda x: abs(x - fan_speed), + ) + return self._attr_fan_modes[ + self._fan_modes_percentages.index(closest_percentage) + ] + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + + if self._attr_fan_modes is None: + return + + fan_mode_index = self._attr_fan_modes.index(fan_mode) + + if self._device.fan_speed_mode == FanSpeedMode.STEP: + await self._device.set_fan_speed(fan_mode_index) + return + + await self._device.set_fan_speed(self._fan_modes_percentages[fan_mode_index]) + @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return device specific state attributes.""" diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index a7aee794264..e22546d3806 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -3,13 +3,13 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from enum import Enum +from enum import Enum, StrEnum from typing import TYPE_CHECKING, Final, TypedDict from xknx.dpt.dpt_20 import HVACControllerMode from xknx.telegram import Telegram -from homeassistant.components.climate import HVACAction, HVACMode +from homeassistant.components.climate import FAN_AUTO, FAN_OFF, HVACAction, HVACMode from homeassistant.const import Platform from homeassistant.util.hass_dict import HassKey @@ -129,6 +129,13 @@ class ColorTempModes(Enum): RELATIVE = "5.001" +class FanZeroMode(StrEnum): + """Enum for setting the fan zero mode.""" + + OFF = FAN_OFF + AUTO = FAN_AUTO + + SUPPORTED_PLATFORMS_YAML: Final = { Platform.BINARY_SENSOR, Platform.BUTTON, diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index c31b3d30ad0..cc65a399da7 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -7,7 +7,7 @@ from collections import OrderedDict from typing import ClassVar, Final import voluptuous as vol -from xknx.devices.climate import SetpointShiftMode +from xknx.devices.climate import FanSpeedMode, SetpointShiftMode from xknx.dpt import DPTBase, DPTNumeric from xknx.dpt.dpt_20 import HVACControllerMode, HVACOperationMode from xknx.exceptions import ConversionError, CouldNotParseTelegram @@ -15,7 +15,7 @@ from xknx.exceptions import ConversionError, CouldNotParseTelegram from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, ) -from homeassistant.components.climate import HVACMode +from homeassistant.components.climate import FAN_OFF, HVACMode from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA as COVER_DEVICE_CLASSES_SCHEMA, ) @@ -54,6 +54,7 @@ from .const import ( CONF_SYNC_STATE, KNX_ADDRESS, ColorTempModes, + FanZeroMode, ) from .validation import ( backwards_compatible_xknx_climate_enum_member, @@ -341,6 +342,11 @@ class ClimateSchema(KNXPlatformSchema): CONF_ON_OFF_INVERT = "on_off_invert" CONF_MIN_TEMP = "min_temp" CONF_MAX_TEMP = "max_temp" + CONF_FAN_SPEED_ADDRESS = "fan_speed_address" + CONF_FAN_SPEED_STATE_ADDRESS = "fan_speed_state_address" + CONF_FAN_MAX_STEP = "fan_max_step" + CONF_FAN_SPEED_MODE = "fan_speed_mode" + CONF_FAN_ZERO_MODE = "fan_zero_mode" DEFAULT_NAME = "KNX Climate" DEFAULT_SETPOINT_SHIFT_MODE = "DPT6010" @@ -348,6 +354,7 @@ class ClimateSchema(KNXPlatformSchema): DEFAULT_SETPOINT_SHIFT_MIN = -6 DEFAULT_TEMPERATURE_STEP = 0.1 DEFAULT_ON_OFF_INVERT = False + DEFAULT_FAN_SPEED_MODE = "percent" ENTITY_SCHEMA = vol.All( # deprecated since September 2020 @@ -423,6 +430,15 @@ class ClimateSchema(KNXPlatformSchema): vol.Optional(CONF_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + vol.Optional(CONF_FAN_SPEED_ADDRESS): ga_list_validator, + vol.Optional(CONF_FAN_SPEED_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_FAN_MAX_STEP, default=3): cv.byte, + vol.Optional( + CONF_FAN_SPEED_MODE, default=DEFAULT_FAN_SPEED_MODE + ): vol.All(vol.Upper, cv.enum(FanSpeedMode)), + vol.Optional(CONF_FAN_ZERO_MODE, default=FAN_OFF): vol.Coerce( + FanZeroMode + ), } ), ) diff --git a/tests/components/knx/test_climate.py b/tests/components/knx/test_climate.py index ec0498dc447..487fab5d723 100644 --- a/tests/components/knx/test_climate.py +++ b/tests/components/knx/test_climate.py @@ -439,3 +439,383 @@ async def test_command_value_idle_mode(hass: HomeAssistant, knx: KNXTestKit) -> knx.assert_state( "climate.test", HVACMode.HEAT, command_value=0, hvac_action=STATE_IDLE ) + + +async def test_fan_speed_3_steps(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate fan speed 3 steps.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", + ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", + ClimateSchema.CONF_FAN_SPEED_MODE: "step", + ClimateSchema.CONF_FAN_MAX_STEP: 3, + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", (0x01,)) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + fan_mode="low", + fan_modes=["off", "low", "medium", "high"], + ) + + # set fan mode + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "medium"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x02,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="medium") + + # turn off + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x0,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off") + + +async def test_fan_speed_2_steps(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate fan speed 2 steps.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", + ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", + ClimateSchema.CONF_FAN_SPEED_MODE: "step", + ClimateSchema.CONF_FAN_MAX_STEP: 2, + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", (0x01,)) + knx.assert_state( + "climate.test", HVACMode.HEAT, fan_mode="low", fan_modes=["off", "low", "high"] + ) + + # set fan mode + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "high"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x02,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="high") + + # turn off + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x0,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off") + + +async def test_fan_speed_1_step(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate fan speed 1 step.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", + ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", + ClimateSchema.CONF_FAN_SPEED_MODE: "step", + ClimateSchema.CONF_FAN_MAX_STEP: 1, + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", (0x01,)) + knx.assert_state( + "climate.test", HVACMode.HEAT, fan_mode="on", fan_modes=["off", "on"] + ) + + # turn off + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x0,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off") + + +async def test_fan_speed_5_steps(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate fan speed 5 steps.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", + ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", + ClimateSchema.CONF_FAN_SPEED_MODE: "step", + ClimateSchema.CONF_FAN_MAX_STEP: 5, + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", (0x01,)) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + fan_mode="1", + fan_modes=["off", "1", "2", "3", "4", "5"], + ) + + # set fan mode + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "4"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x04,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="4") + + # turn off + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x0,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off") + + +async def test_fan_speed_percentage(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate fan speed percentage.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", + ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", + ClimateSchema.CONF_FAN_SPEED_MODE: "percent", + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", (84,)) # 84 / 255 = 33% + knx.assert_state( + "climate.test", + HVACMode.HEAT, + fan_mode="low", + fan_modes=["off", "low", "medium", "high"], + ) + + # set fan mode + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "medium"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (168,)) # 168 / 255 = 66% + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="medium") + + # turn off + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x0,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off") + + # check fan mode that is not in the fan modes list + await knx.receive_write("1/2/6", (127,)) # 127 / 255 = 50% + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="medium") + + # check FAN_OFF is not picked when fan_speed is closest to zero + await knx.receive_write("1/2/6", (3,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="low") + + +async def test_fan_speed_percentage_4_steps( + hass: HomeAssistant, knx: KNXTestKit +) -> None: + """Test KNX climate fan speed percentage with 4 steps.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", + ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", + ClimateSchema.CONF_FAN_SPEED_MODE: "percent", + ClimateSchema.CONF_FAN_MAX_STEP: 4, + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", (64,)) # 64 / 255 = 25% + knx.assert_state( + "climate.test", + HVACMode.HEAT, + fan_mode="25%", + fan_modes=["off", "25%", "50%", "75%", "100%"], + ) + + # set fan mode + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "50%"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (128,)) # 128 / 255 = 50% + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="50%") + + # turn off + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "off"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x0,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="off") + + # check fan mode that is not in the fan modes list + await knx.receive_write("1/2/6", (168,)) # 168 / 255 = 66% + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="75%") + + +async def test_fan_speed_zero_mode_auto(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX climate fan speed 3 steps.""" + await knx.setup_integration( + { + ClimateSchema.PLATFORM: { + CONF_NAME: "test", + ClimateSchema.CONF_TEMPERATURE_ADDRESS: "1/2/3", + ClimateSchema.CONF_TARGET_TEMPERATURE_ADDRESS: "1/2/4", + ClimateSchema.CONF_TARGET_TEMPERATURE_STATE_ADDRESS: "1/2/5", + ClimateSchema.CONF_FAN_SPEED_ADDRESS: "1/2/6", + ClimateSchema.CONF_FAN_SPEED_STATE_ADDRESS: "1/2/7", + ClimateSchema.CONF_FAN_MAX_STEP: 3, + ClimateSchema.CONF_FAN_SPEED_MODE: "step", + ClimateSchema.CONF_FAN_ZERO_MODE: "auto", + } + } + ) + + # read states state updater + await knx.assert_read("1/2/3") + await knx.assert_read("1/2/5") + + # StateUpdater initialize state + await knx.receive_response("1/2/5", RAW_FLOAT_22_0) + await knx.receive_response("1/2/3", RAW_FLOAT_21_0) + + # Query status + await knx.assert_read("1/2/7") + await knx.receive_response("1/2/7", (0x01,)) + knx.assert_state( + "climate.test", + HVACMode.HEAT, + fan_mode="low", + fan_modes=["auto", "low", "medium", "high"], + ) + + # set auto + await hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": "climate.test", "fan_mode": "auto"}, + blocking=True, + ) + await knx.assert_write("1/2/6", (0x0,)) + knx.assert_state("climate.test", HVACMode.HEAT, fan_mode="auto") From 9a4a66b33f5324ae9342653b44d5a18c16d83947 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 24 Sep 2024 21:50:45 +0200 Subject: [PATCH 1129/1309] Use insecure SSL cipher for Reolink aiohttp clientsession (#126687) --- homeassistant/components/reolink/host.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 527f40469b4..a90b9314440 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -30,6 +30,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.util.ssl import SSLCipherList from .const import CONF_USE_HTTPS, DOMAIN from .exceptions import ( @@ -69,7 +70,11 @@ class ReolinkHost: def get_aiohttp_session() -> aiohttp.ClientSession: """Return the HA aiohttp session.""" - return async_get_clientsession(hass, verify_ssl=False) + return async_get_clientsession( + hass, + verify_ssl=False, + ssl_cipher=SSLCipherList.INSECURE, + ) self._api = Host( config[CONF_HOST], From 5e2955845a065cc840a4a9908e861f93d9ea0483 Mon Sep 17 00:00:00 2001 From: jvmahon Date: Tue, 24 Sep 2024 16:07:29 -0400 Subject: [PATCH 1130/1309] Add button platform to Matter integration (#123665) * Add files via upload * add test * add discovery schemas for operational state commands * tests * add filter resets * add filter reset buttons * Apply suggestions from code review * tweak test --------- Co-authored-by: Marcel van der Veldt Co-authored-by: Joost Lekkerkerker --- homeassistant/components/matter/button.py | 149 ++++++++++++++++++ homeassistant/components/matter/discovery.py | 12 ++ homeassistant/components/matter/entity.py | 2 - homeassistant/components/matter/icons.json | 14 ++ homeassistant/components/matter/models.py | 7 +- homeassistant/components/matter/strings.json | 17 ++ .../matter/fixtures/nodes/dimmable-light.json | 7 - .../fixtures/nodes/silabs-dishwasher.json | 2 +- tests/components/matter/test_button.py | 89 +++++++++++ 9 files changed, 288 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/matter/button.py create mode 100644 tests/components/matter/test_button.py diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py new file mode 100644 index 00000000000..918b334061b --- /dev/null +++ b/homeassistant/components/matter/button.py @@ -0,0 +1,149 @@ +"""Matter Button platform.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from chip.clusters import Objects as clusters + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import MatterEntity, MatterEntityDescription +from .helpers import get_matter +from .models import MatterDiscoverySchema + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter Button platform.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.BUTTON, async_add_entities) + + +@dataclass(frozen=True) +class MatterButtonEntityDescription(ButtonEntityDescription, MatterEntityDescription): + """Describe Matter Button entities.""" + + command: Callable[[], Any] | None = None + + +class MatterCommandButton(MatterEntity, ButtonEntity): + """Representation of a Matter Button entity.""" + + entity_description: MatterButtonEntityDescription + + async def async_press(self) -> None: + """Handle the button press leveraging a Matter command.""" + if TYPE_CHECKING: + assert self.entity_description.command is not None + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=self.entity_description.command(), + ) + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="IdentifyButton", + entity_category=EntityCategory.CONFIG, + device_class=ButtonDeviceClass.IDENTIFY, + command=lambda: clusters.Identify.Commands.Identify(identifyTime=15), + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.Identify.Attributes.AcceptedCommandList,), + value_contains=clusters.Identify.Commands.Identify.command_id, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="OperationalStatePauseButton", + translation_key="pause", + command=clusters.OperationalState.Commands.Pause, + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.OperationalState.Attributes.AcceptedCommandList,), + value_contains=clusters.OperationalState.Commands.Pause.command_id, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="OperationalStateResumeButton", + translation_key="resume", + command=clusters.OperationalState.Commands.Resume, + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.OperationalState.Attributes.AcceptedCommandList,), + value_contains=clusters.OperationalState.Commands.Resume.command_id, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="OperationalStateStartButton", + translation_key="start", + command=clusters.OperationalState.Commands.Start, + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.OperationalState.Attributes.AcceptedCommandList,), + value_contains=clusters.OperationalState.Commands.Start.command_id, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="OperationalStateStopButton", + translation_key="stop", + command=clusters.OperationalState.Commands.Stop, + ), + entity_class=MatterCommandButton, + required_attributes=(clusters.OperationalState.Attributes.AcceptedCommandList,), + value_contains=clusters.OperationalState.Commands.Stop.command_id, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="HepaFilterMonitoringResetButton", + translation_key="reset_filter_condition", + command=clusters.HepaFilterMonitoring.Commands.ResetCondition, + ), + entity_class=MatterCommandButton, + required_attributes=( + clusters.HepaFilterMonitoring.Attributes.AcceptedCommandList, + ), + value_contains=clusters.HepaFilterMonitoring.Commands.ResetCondition.command_id, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="ActivatedCarbonFilterMonitoringResetButton", + translation_key="reset_filter_condition", + command=clusters.ActivatedCarbonFilterMonitoring.Commands.ResetCondition, + ), + entity_class=MatterCommandButton, + required_attributes=( + clusters.ActivatedCarbonFilterMonitoring.Attributes.AcceptedCommandList, + ), + value_contains=clusters.ActivatedCarbonFilterMonitoring.Commands.ResetCondition.command_id, + allow_multi=True, + ), +] diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index c3e347e9808..5544409e0ba 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -11,6 +11,7 @@ from homeassistant.const import Platform from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS +from .button import DISCOVERY_SCHEMAS as BUTTON_SCHEMAS from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS @@ -26,6 +27,7 @@ from .update import DISCOVERY_SCHEMAS as UPDATE_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, + Platform.BUTTON: BUTTON_SCHEMAS, Platform.CLIMATE: CLIMATE_SENSOR_SCHEMAS, Platform.COVER: COVER_SCHEMAS, Platform.EVENT: EVENT_SCHEMAS, @@ -114,6 +116,16 @@ def async_discover_entities( ): continue + # check for required value in (primary) attribute + if schema.value_contains is not None and ( + (primary_attribute := next((x for x in schema.required_attributes), None)) + is None + or (value := endpoint.get_attribute_value(None, primary_attribute)) is None + or not isinstance(value, list) + or schema.value_contains not in value + ): + continue + # all checks passed, this value belongs to an entity attributes_to_watch = list(schema.required_attributes) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 61e29477585..5e6007f4418 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass from functools import cached_property @@ -158,7 +157,6 @@ class MatterEntity(Entity): self.async_write_ha_state() @callback - @abstractmethod def _update_from_device(self) -> None: """Update data from Matter device.""" diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 5d9a7aaf477..32c9f057e47 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -5,6 +5,20 @@ "default": "mdi:bell-off" } }, + "button": { + "pause": { + "default": "mdi:pause" + }, + "resume": { + "default": "mdi:play-pause" + }, + "start": { + "default": "mdi:play" + }, + "stop": { + "default": "mdi:stop" + } + }, "fan": { "fan": { "state_attributes": { diff --git a/homeassistant/components/matter/models.py b/homeassistant/components/matter/models.py index c9488437a06..f04c0f7e107 100644 --- a/homeassistant/components/matter/models.py +++ b/homeassistant/components/matter/models.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TypedDict +from typing import Any, TypedDict from chip.clusters import Objects as clusters from chip.clusters.Objects import Cluster, ClusterAttributeDescriptor @@ -108,6 +108,11 @@ class MatterDiscoverySchema: # are not discovered by other entities optional_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None + # [optional] the primary attribute value must contain this value + # for example for the AcceptedCommandList + # NOTE: only works for list values + value_contains: Any | None = None + # [optional] bool to specify if this primary value may be discovered # by multiple platforms allow_multi: bool = False diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index fdff15ce0a4..5a268c1c371 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -77,6 +77,23 @@ "name": "Muted" } }, + "button": { + "pause": { + "name": "[%key:common::action::pause%]" + }, + "resume": { + "name": "Resume" + }, + "start": { + "name": "[%key:common::action::start%]" + }, + "stop": { + "name": "[%key:common::action::stop%]" + }, + "reset_filter_condition": { + "name": "Reset filter condition" + } + }, "climate": { "thermostat": { "name": "Thermostat" diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable-light.json index 58c22f1b807..f8a3b28fb9e 100644 --- a/tests/components/matter/fixtures/nodes/dimmable-light.json +++ b/tests/components/matter/fixtures/nodes/dimmable-light.json @@ -305,13 +305,6 @@ "0/65/65528": [], "0/65/65529": [], "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], - "1/3/0": 0, - "1/3/1": 0, - "1/3/65532": 0, - "1/3/65533": 4, - "1/3/65528": [], - "1/3/65529": [0, 64], - "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/4/0": 128, "1/4/65532": 1, "1/4/65533": 4, diff --git a/tests/components/matter/fixtures/nodes/silabs-dishwasher.json b/tests/components/matter/fixtures/nodes/silabs-dishwasher.json index f060066e100..c5015bc1c34 100644 --- a/tests/components/matter/fixtures/nodes/silabs-dishwasher.json +++ b/tests/components/matter/fixtures/nodes/silabs-dishwasher.json @@ -444,7 +444,7 @@ "1/96/65532": 0, "1/96/65533": 1, "1/96/65528": [4], - "1/96/65529": [0, 1, 2, 3], + "1/96/65529": [0, 1, 2], "1/96/65531": [0, 1, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], "2/29/0": [ { diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py new file mode 100644 index 00000000000..e57a20d1533 --- /dev/null +++ b/tests/components/matter/test_button.py @@ -0,0 +1,89 @@ +"""Test Matter switches.""" + +from unittest.mock import MagicMock, call + +from chip.clusters import Objects as clusters +from matter_server.client.models.node import MatterNode +import pytest + +from homeassistant.core import HomeAssistant + +from .common import setup_integration_with_node_fixture + + +@pytest.fixture(name="powerplug_node") +async def powerplug_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a Powerplug node.""" + return await setup_integration_with_node_fixture( + hass, "eve-energy-plug", matter_client + ) + + +@pytest.fixture(name="dishwasher_node") +async def dishwasher_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for an dishwasher node.""" + return await setup_integration_with_node_fixture( + hass, "silabs-dishwasher", matter_client + ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_identify_button( + hass: HomeAssistant, + matter_client: MagicMock, + powerplug_node: MatterNode, +) -> None: + """Test button entity is created for a Matter Identify Cluster.""" + state = hass.states.get("button.eve_energy_plug_identify") + assert state + assert state.attributes["friendly_name"] == "Eve Energy Plug Identify" + # test press action + await hass.services.async_call( + "button", + "press", + { + "entity_id": "button.eve_energy_plug_identify", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=powerplug_node.node_id, + endpoint_id=1, + command=clusters.Identify.Commands.Identify(identifyTime=15), + ) + + +async def test_operational_state_buttons( + hass: HomeAssistant, + matter_client: MagicMock, + dishwasher_node: MatterNode, +) -> None: + """Test if button entities are created for operational state commands.""" + assert hass.states.get("button.dishwasher_pause") + assert hass.states.get("button.dishwasher_start") + assert hass.states.get("button.dishwasher_stop") + + # resume may not be disocvered as its missing in the supported command list + assert hass.states.get("button.dishwasher_resume") is None + + # test press action + await hass.services.async_call( + "button", + "press", + { + "entity_id": "button.dishwasher_pause", + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=dishwasher_node.node_id, + endpoint_id=1, + command=clusters.OperationalState.Commands.Pause(), + ) From c53a760ba33fbca311ba8e06238352a59ec7e4c9 Mon Sep 17 00:00:00 2001 From: civita <14911217+civita@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:12:24 -0700 Subject: [PATCH 1131/1309] Update strings in tailscale (#124143) --- homeassistant/components/tailscale/config_flow.py | 7 ++++--- homeassistant/components/tailscale/strings.json | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tailscale/config_flow.py b/homeassistant/components/tailscale/config_flow.py index ef70ed0afcc..c5888e64f71 100644 --- a/homeassistant/components/tailscale/config_flow.py +++ b/homeassistant/components/tailscale/config_flow.py @@ -15,6 +15,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_TAILNET, DOMAIN +AUTHKEYS_URL = "https://login.tailscale.com/admin/settings/keys" + async def validate_input(hass: HomeAssistant, *, tailnet: str, api_key: str) -> None: """Try using the give tailnet & api key against the Tailscale API.""" @@ -66,9 +68,7 @@ class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - description_placeholders={ - "authkeys_url": "https://login.tailscale.com/admin/settings/authkeys" - }, + description_placeholders={"authkeys_url": AUTHKEYS_URL}, data_schema=vol.Schema( { vol.Required( @@ -123,6 +123,7 @@ class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", + description_placeholders={"authkeys_url": AUTHKEYS_URL}, data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), errors=errors, ) diff --git a/homeassistant/components/tailscale/strings.json b/homeassistant/components/tailscale/strings.json index 8d7fcc0c87b..89a1d4554b2 100644 --- a/homeassistant/components/tailscale/strings.json +++ b/homeassistant/components/tailscale/strings.json @@ -2,14 +2,14 @@ "config": { "step": { "user": { - "description": "This integration monitors your Tailscale network, it **DOES NOT** make your Home Assistant accessible via Tailscale VPN. \n\nTo authenticate with Tailscale you'll need to create an API key at {authkeys_url}.\n\nA Tailnet is the name of your Tailscale network. You can find it in the top left corner in the Tailscale Admin Panel (beside the Tailscale logo).", + "description": "This integration monitors your Tailscale network, it **DOES NOT** make your Home Assistant accessible via Tailscale VPN. \n\nTo authenticate with Tailscale you'll need to create an API access token at {authkeys_url}.\n\nA Tailnet is the name of your Tailscale network. You can find it in the top left corner in the Tailscale Admin Panel (beside the Tailscale logo).", "data": { "tailnet": "Tailnet", "api_key": "[%key:common::config_flow::data::api_key%]" } }, "reauth_confirm": { - "description": "Tailscale API tokens are valid for 90-days. You can create a fresh Tailscale API key at https://login.tailscale.com/admin/settings/authkeys.", + "description": "Tailscale API access tokens are valid for 90-days. You can create a fresh Tailscale API access token at {authkeys_url}.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } From 686d591f4fbe0435dd1fb6dfd85f55a238c0092e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 22:24:40 +0200 Subject: [PATCH 1132/1309] Add coordinator to Spotify (#123548) --- homeassistant/components/spotify/__init__.py | 20 +- .../components/spotify/browse_media.py | 8 +- .../components/spotify/coordinator.py | 113 ++++++++++ .../components/spotify/media_player.py | 210 +++++++----------- homeassistant/components/spotify/models.py | 13 +- tests/components/spotify/conftest.py | 10 +- 6 files changed, 218 insertions(+), 156 deletions(-) create mode 100644 homeassistant/components/spotify/coordinator.py diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index becf90b04cd..4a0409df383 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -21,7 +21,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .browse_media import async_browse_media from .const import DOMAIN, LOGGER, SPOTIFY_SCOPES -from .models import HomeAssistantSpotifyData +from .coordinator import SpotifyCoordinator +from .models import SpotifyData from .util import ( is_spotify_media_type, resolve_spotify_media_type, @@ -39,7 +40,7 @@ __all__ = [ ] -type SpotifyConfigEntry = ConfigEntry[HomeAssistantSpotifyData] +type SpotifyConfigEntry = ConfigEntry[SpotifyData] async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> bool: @@ -54,13 +55,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b spotify = Spotify(auth=session.token["access_token"]) - try: - current_user = await hass.async_add_executor_job(spotify.me) - except SpotifyException as err: - raise ConfigEntryNotReady from err + coordinator = SpotifyCoordinator(hass, spotify, session) - if not current_user: - raise ConfigEntryNotReady + await coordinator.async_config_entry_first_refresh() async def _update_devices() -> list[dict[str, Any]]: if not session.valid_token: @@ -92,12 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SpotifyConfigEntry) -> b ) await device_coordinator.async_config_entry_first_refresh() - entry.runtime_data = HomeAssistantSpotifyData( - client=spotify, - current_user=current_user, - devices=device_coordinator, - session=session, - ) + entry.runtime_data = SpotifyData(coordinator, session, device_coordinator) if not set(session.token["scope"].split(" ")).issuperset(SPOTIFY_SCOPES): raise ConfigEntryAuthFailed diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index abcb6df6205..58b14e1183a 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -16,11 +16,11 @@ from homeassistant.components.media_player import ( MediaClass, MediaType, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from .const import DOMAIN, MEDIA_PLAYER_PREFIX, MEDIA_TYPE_SHOW, PLAYABLE_MEDIA_TYPES -from .models import HomeAssistantSpotifyData from .util import fetch_image_url BROWSE_LIMIT = 48 @@ -183,7 +183,7 @@ async def async_browse_media( or hass.config_entries.async_get_entry(host.upper()) ) is None - or not isinstance(entry.runtime_data, HomeAssistantSpotifyData) + or entry.state is not ConfigEntryState.LOADED ): raise BrowseError("Invalid Spotify account specified") media_content_id = parsed_url.name @@ -191,9 +191,9 @@ async def async_browse_media( result = await async_browse_media_internal( hass, - info.client, + info.coordinator.client, info.session, - info.current_user, + info.coordinator.current_user, media_content_type, media_content_id, can_play_artist=can_play_artist, diff --git a/homeassistant/components/spotify/coordinator.py b/homeassistant/components/spotify/coordinator.py new file mode 100644 index 00000000000..72efdefa7a5 --- /dev/null +++ b/homeassistant/components/spotify/coordinator.py @@ -0,0 +1,113 @@ +"""Coordinator for Spotify.""" + +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging +from typing import Any + +from spotipy import Spotify, SpotifyException + +from homeassistant.components.media_player import MediaType +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class SpotifyCoordinatorData: + """Class to hold Spotify data.""" + + current_playback: dict[str, Any] + position_updated_at: datetime | None + playlist: dict[str, Any] | None + + +# This is a minimal representation of the DJ playlist that Spotify now offers +# The DJ is not fully integrated with the playlist API, so needs to have the +# playlist response mocked in order to maintain functionality +SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": "DJ"} + + +class SpotifyCoordinator(DataUpdateCoordinator[SpotifyCoordinatorData]): + """Class to manage fetching Spotify data.""" + + current_user: dict[str, Any] + + def __init__( + self, hass: HomeAssistant, client: Spotify, session: OAuth2Session + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.client = client + self._playlist: dict[str, Any] | None = None + self.session = session + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + try: + self.current_user = await self.hass.async_add_executor_job(self.client.me) + except SpotifyException as err: + raise UpdateFailed("Error communicating with Spotify API") from err + if not self.current_user: + raise UpdateFailed("Could not retrieve user") + + async def _async_update_data(self) -> SpotifyCoordinatorData: + if not self.session.valid_token: + await self.session.async_ensure_token_valid() + await self.hass.async_add_executor_job( + self.client.set_auth, self.session.token["access_token"] + ) + return await self.hass.async_add_executor_job(self._sync_update_data) + + def _sync_update_data(self) -> SpotifyCoordinatorData: + current = self.client.current_playback(additional_types=[MediaType.EPISODE]) + currently_playing = current or {} + # Record the last updated time, because Spotify's timestamp property is unreliable + # and doesn't actually return the fetch time as is mentioned in the API description + position_updated_at = dt_util.utcnow() if current is not None else None + + context = currently_playing.get("context") or {} + + # For some users in some cases, the uri is formed like + # "spotify:user:{name}:playlist:{id}" and spotipy wants + # the type to be playlist. + uri = context.get("uri") + if uri is not None: + parts = uri.split(":") + if len(parts) == 5 and parts[1] == "user" and parts[3] == "playlist": + uri = ":".join([parts[0], parts[3], parts[4]]) + + if context and (self._playlist is None or self._playlist["uri"] != uri): + self._playlist = None + if context["type"] == MediaType.PLAYLIST: + # The Spotify API does not currently support doing a lookup for + # the DJ playlist,so just use the minimal mock playlist object + if uri == SPOTIFY_DJ_PLAYLIST["uri"]: + self._playlist = SPOTIFY_DJ_PLAYLIST + else: + # Make sure any playlist lookups don't break the current + # playback state update + try: + self._playlist = self.client.playlist(uri) + except SpotifyException: + _LOGGER.debug( + "Unable to load spotify playlist '%s'. " + "Continuing without playlist data", + uri, + ) + self._playlist = None + return SpotifyCoordinatorData( + current_playback=currently_playing, + position_updated_at=position_updated_at, + playlist=self._playlist, + ) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 3653bdb149a..ad27e2919b2 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -2,8 +2,8 @@ from __future__ import annotations -from asyncio import run_coroutine_threadsafe from collections.abc import Callable +import datetime as dt from datetime import timedelta import logging from typing import Any, Concatenate @@ -27,12 +27,15 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utcnow +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import SpotifyConfigEntry from .browse_media import async_browse_media_internal from .const import DOMAIN, MEDIA_PLAYER_PREFIX, PLAYABLE_MEDIA_TYPES -from .models import HomeAssistantSpotifyData +from .coordinator import SpotifyCoordinator from .util import fetch_image_url _LOGGER = logging.getLogger(__name__) @@ -63,10 +66,6 @@ REPEAT_MODE_MAPPING_TO_SPOTIFY = { value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items() } -# This is a minimal representation of the DJ playlist that Spotify now offers -# The DJ is not fully integrated with the playlist API, so needs to have the playlist response mocked in order to maintain functionality -SPOTIFY_DJ_PLAYLIST = {"uri": "spotify:playlist:37i9dQZF1EYkqdzj48dyYq", "name": "DJ"} - async def async_setup_entry( hass: HomeAssistant, @@ -74,12 +73,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Spotify based on a config entry.""" + data = entry.runtime_data spotify = SpotifyMediaPlayer( - entry.runtime_data, + data.coordinator, + data.devices, entry.data[CONF_ID], entry.title, ) - async_add_entities([spotify], True) + async_add_entities([spotify]) def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R]( @@ -110,7 +111,7 @@ def spotify_exception_handler[_SpotifyMediaPlayerT: SpotifyMediaPlayer, **_P, _R return wrapper -class SpotifyMediaPlayer(MediaPlayerEntity): +class SpotifyMediaPlayer(CoordinatorEntity[SpotifyCoordinator], MediaPlayerEntity): """Representation of a Spotify controller.""" _attr_has_entity_name = True @@ -120,97 +121,106 @@ class SpotifyMediaPlayer(MediaPlayerEntity): def __init__( self, - data: HomeAssistantSpotifyData, + coordinator: SpotifyCoordinator, + device_coordinator: DataUpdateCoordinator[list[dict[str, Any]]], user_id: str, name: str, ) -> None: """Initialize.""" - self._id = user_id - self.data = data + super().__init__(coordinator) + self.devices = device_coordinator self._attr_unique_id = user_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, user_id)}, manufacturer="Spotify AB", - model=f"Spotify {data.current_user['product']}", + model=f"Spotify {coordinator.current_user['product']}", name=f"Spotify {name}", entry_type=DeviceEntryType.SERVICE, configuration_url="https://open.spotify.com", ) - self._currently_playing: dict | None = {} - self._playlist: dict | None = None - self._restricted_device: bool = False + + @property + def currently_playing(self) -> dict[str, Any]: + """Return the current playback.""" + return self.coordinator.data.current_playback @property def supported_features(self) -> MediaPlayerEntityFeature: """Return the supported features.""" - if self.data.current_user["product"] != "premium": + if self.coordinator.current_user["product"] != "premium": return MediaPlayerEntityFeature(0) - if self._restricted_device or not self._currently_playing: + if not self.currently_playing or self.currently_playing.get("device", {}).get( + "is_restricted" + ): return MediaPlayerEntityFeature.SELECT_SOURCE return SUPPORT_SPOTIFY @property def state(self) -> MediaPlayerState: """Return the playback state.""" - if not self._currently_playing: + if not self.currently_playing: return MediaPlayerState.IDLE - if self._currently_playing["is_playing"]: + if self.currently_playing["is_playing"]: return MediaPlayerState.PLAYING return MediaPlayerState.PAUSED @property def volume_level(self) -> float | None: """Return the device volume.""" - if not self._currently_playing: + if not self.currently_playing: return None - return self._currently_playing.get("device", {}).get("volume_percent", 0) / 100 + return self.currently_playing.get("device", {}).get("volume_percent", 0) / 100 @property def media_content_id(self) -> str | None: """Return the media URL.""" - if not self._currently_playing: + if not self.currently_playing: return None - item = self._currently_playing.get("item") or {} + item = self.currently_playing.get("item") or {} return item.get("uri") @property def media_content_type(self) -> str | None: """Return the media type.""" - if not self._currently_playing: + if not self.currently_playing: return None - item = self._currently_playing.get("item") or {} + item = self.currently_playing.get("item") or {} is_episode = item.get("type") == MediaType.EPISODE return MediaType.PODCAST if is_episode else MediaType.MUSIC @property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" - if ( - self._currently_playing is None - or self._currently_playing.get("item") is None - ): + if self.currently_playing is None or self.currently_playing.get("item") is None: return None - return self._currently_playing["item"]["duration_ms"] / 1000 + return self.currently_playing["item"]["duration_ms"] / 1000 @property def media_position(self) -> int | None: """Position of current playing media in seconds.""" if ( - not self._currently_playing - or self._currently_playing.get("progress_ms") is None + not self.currently_playing + or self.currently_playing.get("progress_ms") is None ): return None - return self._currently_playing["progress_ms"] / 1000 + return self.currently_playing["progress_ms"] / 1000 + + @property + def media_position_updated_at(self) -> dt.datetime | None: + """When was the position of the current playing media valid.""" + if not self.currently_playing: + return None + return self.coordinator.data.position_updated_at @property def media_image_url(self) -> str | None: """Return the media image URL.""" - if not self._currently_playing or self._currently_playing.get("item") is None: + if not self.currently_playing or self.currently_playing.get("item") is None: return None - item = self._currently_playing["item"] + item = self.currently_playing["item"] if item["type"] == MediaType.EPISODE: if item["images"]: return fetch_image_url(item) @@ -225,18 +235,18 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @property def media_title(self) -> str | None: """Return the media title.""" - if not self._currently_playing: + if not self.currently_playing: return None - item = self._currently_playing.get("item") or {} + item = self.currently_playing.get("item") or {} return item.get("name") @property def media_artist(self) -> str | None: """Return the media artist.""" - if not self._currently_playing or self._currently_playing.get("item") is None: + if not self.currently_playing or self.currently_playing.get("item") is None: return None - item = self._currently_playing["item"] + item = self.currently_playing["item"] if item["type"] == MediaType.EPISODE: return item["show"]["publisher"] @@ -245,10 +255,10 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @property def media_album_name(self) -> str | None: """Return the media album.""" - if not self._currently_playing or self._currently_playing.get("item") is None: + if not self.currently_playing or self.currently_playing.get("item") is None: return None - item = self._currently_playing["item"] + item = self.currently_playing["item"] if item["type"] == MediaType.EPISODE: return item["show"]["name"] @@ -257,43 +267,43 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @property def media_track(self) -> int | None: """Track number of current playing media, music track only.""" - if not self._currently_playing: + if not self.currently_playing: return None - item = self._currently_playing.get("item") or {} + item = self.currently_playing.get("item") or {} return item.get("track_number") @property def media_playlist(self): """Title of Playlist currently playing.""" - if self._playlist is None: + if self.coordinator.data.playlist is None: return None - return self._playlist["name"] + return self.coordinator.data.playlist["name"] @property def source(self) -> str | None: """Return the current playback device.""" - if not self._currently_playing: + if not self.currently_playing: return None - return self._currently_playing.get("device", {}).get("name") + return self.currently_playing.get("device", {}).get("name") @property def source_list(self) -> list[str] | None: """Return a list of source devices.""" - return [device["name"] for device in self.data.devices.data] + return [device["name"] for device in self.devices.data] @property def shuffle(self) -> bool | None: """Shuffling state.""" - if not self._currently_playing: + if not self.currently_playing: return None - return self._currently_playing.get("shuffle_state") + return self.currently_playing.get("shuffle_state") @property def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" if ( - not self._currently_playing - or (repeat_state := self._currently_playing.get("repeat_state")) is None + not self.currently_playing + or (repeat_state := self.currently_playing.get("repeat_state")) is None ): return None return REPEAT_MODE_MAPPING_TO_HA.get(repeat_state) @@ -301,32 +311,32 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @spotify_exception_handler def set_volume_level(self, volume: float) -> None: """Set the volume level.""" - self.data.client.volume(int(volume * 100)) + self.coordinator.client.volume(int(volume * 100)) @spotify_exception_handler def media_play(self) -> None: """Start or resume playback.""" - self.data.client.start_playback() + self.coordinator.client.start_playback() @spotify_exception_handler def media_pause(self) -> None: """Pause playback.""" - self.data.client.pause_playback() + self.coordinator.client.pause_playback() @spotify_exception_handler def media_previous_track(self) -> None: """Skip to previous track.""" - self.data.client.previous_track() + self.coordinator.client.previous_track() @spotify_exception_handler def media_next_track(self) -> None: """Skip to next track.""" - self.data.client.next_track() + self.coordinator.client.next_track() @spotify_exception_handler def media_seek(self, position: float) -> None: """Send seek command.""" - self.data.client.seek_track(int(position * 1000)) + self.coordinator.client.seek_track(int(position * 1000)) @spotify_exception_handler def play_media( @@ -354,11 +364,11 @@ class SpotifyMediaPlayer(MediaPlayerEntity): return if ( - self._currently_playing - and not self._currently_playing.get("device") - and self.data.devices.data + self.currently_playing + and not self.currently_playing.get("device") + and self.devices.data ): - kwargs["device_id"] = self.data.devices.data[0].get("id") + kwargs["device_id"] = self.devices.data[0].get("id") if enqueue == MediaPlayerEnqueue.ADD: if media_type not in { @@ -369,17 +379,17 @@ class SpotifyMediaPlayer(MediaPlayerEntity): raise ValueError( f"Media type {media_type} is not supported when enqueue is ADD" ) - self.data.client.add_to_queue(media_id, kwargs.get("device_id")) + self.coordinator.client.add_to_queue(media_id, kwargs.get("device_id")) return - self.data.client.start_playback(**kwargs) + self.coordinator.client.start_playback(**kwargs) @spotify_exception_handler def select_source(self, source: str) -> None: """Select playback device.""" - for device in self.data.devices.data: + for device in self.devices.data: if device["name"] == source: - self.data.client.transfer_playback( + self.coordinator.client.transfer_playback( device["id"], self.state == MediaPlayerState.PLAYING ) return @@ -387,66 +397,14 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @spotify_exception_handler def set_shuffle(self, shuffle: bool) -> None: """Enable/Disable shuffle mode.""" - self.data.client.shuffle(shuffle) + self.coordinator.client.shuffle(shuffle) @spotify_exception_handler def set_repeat(self, repeat: RepeatMode) -> None: """Set repeat mode.""" if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY: raise ValueError(f"Unsupported repeat mode: {repeat}") - self.data.client.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat]) - - @spotify_exception_handler - def update(self) -> None: - """Update state and attributes.""" - if not self.enabled: - return - - if not self.data.session.valid_token or self.data.client is None: - run_coroutine_threadsafe( - self.data.session.async_ensure_token_valid(), self.hass.loop - ).result() - self.data.client.set_auth(auth=self.data.session.token["access_token"]) - - current = self.data.client.current_playback( - additional_types=[MediaType.EPISODE] - ) - self._currently_playing = current or {} - # Record the last updated time, because Spotify's timestamp property is unreliable - # and doesn't actually return the fetch time as is mentioned in the API description - self._attr_media_position_updated_at = utcnow() if current is not None else None - - context = self._currently_playing.get("context") or {} - - # For some users in some cases, the uri is formed like - # "spotify:user:{name}:playlist:{id}" and spotipy wants - # the type to be playlist. - uri = context.get("uri") - if uri is not None: - parts = uri.split(":") - if len(parts) == 5 and parts[1] == "user" and parts[3] == "playlist": - uri = ":".join([parts[0], parts[3], parts[4]]) - - if context and (self._playlist is None or self._playlist["uri"] != uri): - self._playlist = None - if context["type"] == MediaType.PLAYLIST: - # The Spotify API does not currently support doing a lookup for the DJ playlist, so just use the minimal mock playlist object - if uri == SPOTIFY_DJ_PLAYLIST["uri"]: - self._playlist = SPOTIFY_DJ_PLAYLIST - else: - # Make sure any playlist lookups don't break the current playback state update - try: - self._playlist = self.data.client.playlist(uri) - except SpotifyException: - _LOGGER.debug( - "Unable to load spotify playlist '%s'. Continuing without playlist data", - uri, - ) - self._playlist = None - - device = self._currently_playing.get("device") - if device is not None: - self._restricted_device = device["is_restricted"] + self.coordinator.client.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat]) async def async_browse_media( self, @@ -457,9 +415,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): return await async_browse_media_internal( self.hass, - self.data.client, - self.data.session, - self.data.current_user, + self.coordinator.client, + self.coordinator.session, + self.coordinator.current_user, media_content_type, media_content_id, ) @@ -475,5 +433,5 @@ class SpotifyMediaPlayer(MediaPlayerEntity): """When entity is added to hass.""" await super().async_added_to_hass() self.async_on_remove( - self.data.devices.async_add_listener(self._handle_devices_update) + self.devices.async_add_listener(self._handle_devices_update) ) diff --git a/homeassistant/components/spotify/models.py b/homeassistant/components/spotify/models.py index bbec134d89d..daeee560d58 100644 --- a/homeassistant/components/spotify/models.py +++ b/homeassistant/components/spotify/models.py @@ -3,17 +3,16 @@ from dataclasses import dataclass from typing import Any -from spotipy import Spotify - from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .coordinator import SpotifyCoordinator + @dataclass -class HomeAssistantSpotifyData: - """Spotify data stored in the Home Assistant data object.""" +class SpotifyData: + """Class to hold Spotify data.""" - client: Spotify - current_user: dict[str, Any] - devices: DataUpdateCoordinator[list[dict[str, Any]]] + coordinator: SpotifyCoordinator session: OAuth2Session + devices: DataUpdateCoordinator[list[dict[str, Any]]] diff --git a/tests/components/spotify/conftest.py b/tests/components/spotify/conftest.py index 3f248b54529..722851d097c 100644 --- a/tests/components/spotify/conftest.py +++ b/tests/components/spotify/conftest.py @@ -10,12 +10,14 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.spotify import DOMAIN +from homeassistant.components.spotify.const import DOMAIN, SPOTIFY_SCOPES from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +SCOPES = " ".join(SPOTIFY_SCOPES) + @pytest.fixture def mock_config_entry_1() -> MockConfigEntry: @@ -30,7 +32,7 @@ def mock_config_entry_1() -> MockConfigEntry: "token_type": "Bearer", "expires_in": 3600, "refresh_token": "RefreshToken", - "scope": "playlist-read-private ...", + "scope": SCOPES, "expires_at": 1724198975.8829377, }, "id": "32oesphrnacjcf7vw5bf6odx3oiu", @@ -54,7 +56,7 @@ def mock_config_entry_2() -> MockConfigEntry: "token_type": "Bearer", "expires_in": 3600, "refresh_token": "RefreshToken", - "scope": "playlist-read-private ...", + "scope": SCOPES, "expires_at": 1724198975.8829377, }, "id": "55oesphrnacjcf7vw5bf6odx3oiu", @@ -123,6 +125,4 @@ async def spotify_setup( mock_config_entry_2.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry_2.entry_id) await hass.async_block_till_done(wait_background_tasks=True) - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done(wait_background_tasks=True) yield From 03968b44bd14d8160d38855fe4f4494e8d61a45f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 22:25:54 +0200 Subject: [PATCH 1133/1309] Improve typing in Yamaha (#123982) Co-authored-by: Franck Nijhof --- .../components/yamaha/media_player.py | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index bccb7b437f8..c16433b3c37 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -7,6 +7,7 @@ from typing import Any import requests import rxv +from rxv import RXV import voluptuous as vol from homeassistant.components.media_player import ( @@ -112,7 +113,7 @@ class YamahaConfigInfo: self.from_discovery = True -def _discovery(config_info): +def _discovery(config_info: YamahaConfigInfo) -> list[RXV]: """Discover list of zone controllers from configuration in the network.""" if config_info.from_discovery: _LOGGER.debug("Discovery Zones") @@ -163,6 +164,7 @@ async def async_setup_platform( _LOGGER.debug("Ignore receiver zone: %s %s", config_info.name, zctrl.zone) continue + assert config_info.name entity = YamahaDeviceZone( config_info.name, zctrl, @@ -206,16 +208,24 @@ async def async_setup_platform( class YamahaDeviceZone(MediaPlayerEntity): """Representation of a Yamaha device zone.""" - def __init__(self, name, zctrl, source_ignore, source_names, zone_names): + _reverse_mapping: dict[str, str] + + def __init__( + self, + name: str, + zctrl: RXV, + source_ignore: list[str] | None, + source_names: dict[str, str] | None, + zone_names: dict[str, str] | None, + ) -> None: """Initialize the Yamaha Receiver.""" self.zctrl = zctrl self._attr_is_volume_muted = False self._attr_volume_level = 0 self._attr_state = MediaPlayerState.OFF - self._source_ignore = source_ignore or [] - self._source_names = source_names or {} - self._zone_names = zone_names or {} - self._reverse_mapping = None + self._source_ignore: list[str] = source_ignore or [] + self._source_names: dict[str, str] = source_names or {} + self._zone_names: dict[str, str] = zone_names or {} self._playback_support = None self._is_playback_supported = False self._play_status = None @@ -267,7 +277,7 @@ class YamahaDeviceZone(MediaPlayerEntity): self._attr_sound_mode = None self._attr_sound_mode_list = None - def build_source_list(self): + def build_source_list(self) -> None: """Build the source list.""" self._reverse_mapping = { alias: source for source, alias in self._source_names.items() @@ -280,7 +290,7 @@ class YamahaDeviceZone(MediaPlayerEntity): ) @property - def name(self): + def name(self) -> str: """Return the name of the device.""" name = self._name zone_name = self._zone_names.get(self._zone, self._zone) @@ -290,7 +300,7 @@ class YamahaDeviceZone(MediaPlayerEntity): return name @property - def zone_id(self): + def zone_id(self) -> str: """Return a zone_id to ensure 1 media player per zone.""" return f"{self.zctrl.ctrl_url}:{self._zone}" @@ -387,15 +397,15 @@ class YamahaDeviceZone(MediaPlayerEntity): if media_type == "NET RADIO": self.zctrl.net_radio(media_id) - def enable_output(self, port, enabled): + def enable_output(self, port: str, enabled: bool) -> None: """Enable or disable an output port..""" self.zctrl.enable_output(port, enabled) - def menu_cursor(self, cursor): + def menu_cursor(self, cursor: str) -> None: """Press a menu cursor button.""" getattr(self.zctrl, CURSOR_TYPE_MAP[cursor])() - def set_scene(self, scene): + def set_scene(self, scene: str) -> None: """Set the current scene.""" try: self.zctrl.scene = scene From ab8e2d92c83ec7ac9926ee122c01a4cb586b39c4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 24 Sep 2024 22:37:54 +0200 Subject: [PATCH 1134/1309] Add diagnostics to Workday (#126691) --- .../components/workday/diagnostics.py | 18 +++++++ .../workday/snapshots/test_diagnostics.ambr | 48 +++++++++++++++++++ tests/components/workday/test_diagnostics.py | 28 +++++++++++ 3 files changed, 94 insertions(+) create mode 100644 homeassistant/components/workday/diagnostics.py create mode 100644 tests/components/workday/snapshots/test_diagnostics.ambr create mode 100644 tests/components/workday/test_diagnostics.py diff --git a/homeassistant/components/workday/diagnostics.py b/homeassistant/components/workday/diagnostics.py new file mode 100644 index 00000000000..84e5073ca5b --- /dev/null +++ b/homeassistant/components/workday/diagnostics.py @@ -0,0 +1,18 @@ +"""Diagnostics support for Workday.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return { + "config_entry": entry, + } diff --git a/tests/components/workday/snapshots/test_diagnostics.ambr b/tests/components/workday/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f41b86b7f6d --- /dev/null +++ b/tests/components/workday/snapshots/test_diagnostics.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'workday', + 'entry_id': '1', + 'minor_version': 1, + 'options': dict({ + 'add_holidays': list([ + '2022-12-01', + '2022-12-05,2022-12-15', + ]), + 'country': 'DE', + 'days_offset': 0, + 'excludes': list([ + 'sat', + 'sun', + 'holiday', + ]), + 'language': 'de', + 'name': 'Workday Sensor', + 'province': 'BW', + 'remove_holidays': list([ + '2022-12-04', + '2022-12-24,2022-12-26', + ]), + 'workdays': list([ + 'mon', + 'tue', + 'wed', + 'thu', + 'fri', + ]), + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + }) +# --- diff --git a/tests/components/workday/test_diagnostics.py b/tests/components/workday/test_diagnostics.py new file mode 100644 index 00000000000..13206a361f1 --- /dev/null +++ b/tests/components/workday/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test Workday diagnostics.""" + +from __future__ import annotations + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from . import TEST_CONFIG_ADD_REMOVE_DATE_RANGE, init_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for a config entry.""" + entry = await init_integration(hass, TEST_CONFIG_ADD_REMOVE_DATE_RANGE) + + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert diag == snapshot( + exclude=props("full_features", "created_at", "modified_at"), + ) From 2dcd5e55e21b34f64b34824c6a74d927288eaa56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Sep 2024 15:38:24 -0500 Subject: [PATCH 1135/1309] Bump aiohttp to 3.10.6 (#126690) --- 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 9989e532a0a..d464d04e7fa 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.0b1 aiohttp-fast-zlib==0.1.1 -aiohttp==3.10.6rc2 +aiohttp==3.10.6 aiohttp_cors==0.7.0 aiozoneinfo==0.2.1 astral==2.2 diff --git a/pyproject.toml b/pyproject.toml index c20aca4d769..d1ceb1f62f4 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.0b1", - "aiohttp==3.10.6rc2", + "aiohttp==3.10.6", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.1.1", "aiozoneinfo==0.2.1", diff --git a/requirements.txt b/requirements.txt index 1400394382d..ec1c0438a40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.2.0 aiohasupervisor==0.1.0b1 -aiohttp==3.10.6rc2 +aiohttp==3.10.6 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.1.1 aiozoneinfo==0.2.1 From 8d0e9eb8ac1dcdf9e86e936fc727176888f8249d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Sep 2024 13:38:40 -0700 Subject: [PATCH 1136/1309] Improve Roborock error handling (#124267) --- homeassistant/components/roborock/number.py | 15 ++++++-- .../components/roborock/strings.json | 3 ++ homeassistant/components/roborock/switch.py | 28 ++++++++++---- homeassistant/components/roborock/time.py | 15 ++++++-- tests/components/roborock/test_button.py | 38 ++++++++++++++++++- tests/components/roborock/test_number.py | 35 +++++++++++++++++ tests/components/roborock/test_select.py | 2 +- tests/components/roborock/test_switch.py | 36 ++++++++++++++++++ tests/components/roborock/test_time.py | 34 +++++++++++++++++ 9 files changed, 189 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index 9f0d578cae4..7f568ae824b 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -13,9 +13,10 @@ from roborock.version_1_apis.roborock_client_v1 import AttributeCache from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RoborockConfigEntry +from . import DOMAIN, RoborockConfigEntry from .coordinator import RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 @@ -107,6 +108,12 @@ class RoborockNumberEntity(RoborockEntityV1, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set number value.""" - await self.entity_description.update_value( - self.get_cache(self.entity_description.cache_key), value - ) + try: + await self.entity_description.update_value( + self.get_cache(self.entity_description.cache_key), value + ) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index d1fc50f27e8..8ff82cae393 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -419,6 +419,9 @@ }, "no_coordinators": { "message": "No devices were able to successfully setup" + }, + "update_options_failed": { + "message": "Failed to update Roborock options" } }, "services": { diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 407ec51103c..b0c8c880188 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -9,14 +9,16 @@ import logging from typing import Any from roborock.command_cache import CacheableAttribute +from roborock.exceptions import RoborockException from roborock.version_1_apis.roborock_client_v1 import AttributeCache from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RoborockConfigEntry +from . import DOMAIN, RoborockConfigEntry from .coordinator import RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 @@ -149,15 +151,27 @@ class RoborockSwitch(RoborockEntityV1, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" - await self.entity_description.update_value( - self.get_cache(self.entity_description.cache_key), False - ) + try: + await self.entity_description.update_value( + self.get_cache(self.entity_description.cache_key), False + ) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" - await self.entity_description.update_value( - self.get_cache(self.entity_description.cache_key), True - ) + try: + await self.entity_description.update_value( + self.get_cache(self.entity_description.cache_key), True + ) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index a705eb69ea1..1dd681dff1f 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -15,9 +15,10 @@ from roborock.version_1_apis.roborock_client_v1 import AttributeCache from homeassistant.components.time import TimeEntity, TimeEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RoborockConfigEntry +from . import DOMAIN, RoborockConfigEntry from .coordinator import RoborockDataUpdateCoordinator from .entity import RoborockEntityV1 @@ -172,6 +173,12 @@ class RoborockTimeEntity(RoborockEntityV1, TimeEntity): async def async_set_value(self, value: time) -> None: """Set the time.""" - await self.entity_description.update_value( - self.get_cache(self.entity_description.cache_key), value - ) + try: + await self.entity_description.update_value( + self.get_cache(self.entity_description.cache_key), value + ) + except RoborockException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_options_failed", + ) from err diff --git a/tests/components/roborock/test_button.py b/tests/components/roborock/test_button.py index 88cf5beab15..43ef043f79c 100644 --- a/tests/components/roborock/test_button.py +++ b/tests/components/roborock/test_button.py @@ -3,9 +3,11 @@ from unittest.mock import patch import pytest +import roborock from homeassistant.components.button import SERVICE_PRESS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry @@ -16,7 +18,7 @@ from tests.common import MockConfigEntry ("button.roborock_s7_maxv_reset_sensor_consumable"), ("button.roborock_s7_maxv_reset_air_filter_consumable"), ("button.roborock_s7_maxv_reset_side_brush_consumable"), - "button.roborock_s7_maxv_reset_main_brush_consumable", + ("button.roborock_s7_maxv_reset_main_brush_consumable"), ], ) @pytest.mark.freeze_time("2023-10-30 08:50:00") @@ -41,3 +43,37 @@ async def test_update_success( ) assert mock_send_message.assert_called_once assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("button.roborock_s7_maxv_reset_air_filter_consumable"), + ], +) +@pytest.mark.freeze_time("2023-10-30 08:50:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update_failure( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test failure while pressing the button entity.""" + # Ensure that the entity exist, as these test can pass even if there is no entity. + assert hass.states.get(entity_id).state == "unknown" + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message", + side_effect=roborock.exceptions.RoborockTimeout, + ) as mock_send_message, + pytest.raises(HomeAssistantError, match="Error while calling RESET_CONSUMABLE"), + ): + await hass.services.async_call( + "button", + SERVICE_PRESS, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once + assert hass.states.get(entity_id).state == "2023-10-30T08:50:00+00:00" diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py index 3291dd2a7dc..7e87b49253e 100644 --- a/tests/components/roborock/test_number.py +++ b/tests/components/roborock/test_number.py @@ -3,9 +3,11 @@ from unittest.mock import patch import pytest +import roborock from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry @@ -37,3 +39,36 @@ async def test_update_success( target={"entity_id": entity_id}, ) assert mock_send_message.assert_called_once + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("number.roborock_s7_maxv_volume", 3.0), + ], +) +async def test_update_failed( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + value: float, +) -> None: + """Test allowed changing values for number entities.""" + # Ensure that the entity exist, as these test can pass even if there is no entity. + assert hass.states.get(entity_id) is not None + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message", + side_effect=roborock.exceptions.RoborockTimeout, + ) as mock_send_message, + pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once diff --git a/tests/components/roborock/test_select.py b/tests/components/roborock/test_select.py index ce846107d93..784150e24c7 100644 --- a/tests/components/roborock/test_select.py +++ b/tests/components/roborock/test_select.py @@ -59,7 +59,7 @@ async def test_update_failure( "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.send_message", side_effect=RoborockException(), ), - pytest.raises(HomeAssistantError), + pytest.raises(HomeAssistantError, match="Error while calling SET_MOP_MOD"), ): await hass.services.async_call( "select", diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 3afa72b319d..5de3c208c1e 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -3,9 +3,11 @@ from unittest.mock import patch import pytest +import roborock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry @@ -49,3 +51,37 @@ async def test_update_success( target={"entity_id": entity_id}, ) assert mock_send_message.assert_called_once + + +@pytest.mark.parametrize( + ("entity_id", "service"), + [ + ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_ON), + ("switch.roborock_s7_maxv_status_indicator_light", SERVICE_TURN_OFF), + ], +) +async def test_update_failed( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + service: str, +) -> None: + """Test a failure while updating a switch.""" + # Ensure that the entity exist, as these test can pass even if there is no entity. + assert hass.states.get(entity_id) is not None + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", + side_effect=roborock.exceptions.RoborockTimeout, + ) as mock_send_message, + pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), + ): + await hass.services.async_call( + "switch", + service, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index ca6507f887b..836a86bd114 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -4,9 +4,11 @@ from datetime import time from unittest.mock import patch import pytest +import roborock from homeassistant.components.time import SERVICE_SET_VALUE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry @@ -38,3 +40,35 @@ async def test_update_success( target={"entity_id": entity_id}, ) assert mock_send_message.assert_called_once + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("time.roborock_s7_maxv_do_not_disturb_begin"), + ], +) +async def test_update_failure( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test turning switch entities on and off.""" + # Ensure that the entity exist, as these test can pass even if there is no entity. + assert hass.states.get(entity_id) is not None + with ( + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1._send_command", + side_effect=roborock.exceptions.RoborockTimeout, + ) as mock_send_message, + pytest.raises(HomeAssistantError, match="Failed to update Roborock options"), + ): + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=1, minute=1)}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once From c66e2dc07686c80119e84ed8ada5f50327a1fad6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 24 Sep 2024 22:51:16 +0200 Subject: [PATCH 1137/1309] Remove leftover wrong icon from Reolink (#126698) Remove wrong icon --- homeassistant/components/reolink/switch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 07f75ca5fa3..162679965fb 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -249,7 +249,6 @@ NVR_SWITCH_ENTITIES = ( key="buzzer", cmd_key="GetBuzzerAlarmV20", translation_key="hub_ringtone_on_event", - icon="mdi:room-service", entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "buzzer"), value=lambda api: api.buzzer_enabled(), From 20030ab60445b54fd2e1b53318d85e3ca03695b8 Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 24 Sep 2024 22:55:48 +0200 Subject: [PATCH 1138/1309] Add sensor platform to Bring integration (#126642) * Add sensor platform to Bring integration * Add more tests * unignore typedef check * Update language sensor * update snapshot * changes * add entities Co-authored-by: Joost Lekkerkerker * add units * lowercase * snapshot --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/bring/__init__.py | 2 +- homeassistant/components/bring/const.py | 1 + homeassistant/components/bring/coordinator.py | 17 +- homeassistant/components/bring/entity.py | 1 - homeassistant/components/bring/icons.json | 14 + homeassistant/components/bring/sensor.py | 121 +++++ homeassistant/components/bring/strings.json | 38 ++ homeassistant/components/bring/todo.py | 9 +- homeassistant/components/bring/util.py | 40 ++ tests/components/bring/conftest.py | 3 + tests/components/bring/fixtures/items.json | 22 +- .../bring/fixtures/usersettings.json | 60 +++ .../bring/snapshots/test_sensor.ambr | 467 ++++++++++++++++++ tests/components/bring/test_init.py | 9 +- tests/components/bring/test_sensor.py | 44 ++ tests/components/bring/test_todo.py | 15 +- tests/components/bring/test_util.py | 56 +++ 17 files changed, 910 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/bring/sensor.py create mode 100644 homeassistant/components/bring/util.py create mode 100644 tests/components/bring/fixtures/usersettings.json create mode 100644 tests/components/bring/snapshots/test_sensor.ambr create mode 100644 tests/components/bring/test_sensor.py create mode 100644 tests/components/bring/test_util.py diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index f55e75c70bf..80b7a843cc0 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import BringDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.TODO] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.TODO] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py index 911c08a835d..d44b7eb9423 100644 --- a/homeassistant/components/bring/const.py +++ b/homeassistant/components/bring/const.py @@ -9,3 +9,4 @@ ATTR_ITEM_NAME: Final = "item" ATTR_NOTIFICATION_TYPE: Final = "message" SERVICE_PUSH_NOTIFICATION = "send_message" +UNIT_ITEMS = "items" diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index 439eb552de4..7678213f117 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -11,7 +11,7 @@ from bring_api import ( BringParseException, BringRequestException, ) -from bring_api.types import BringItemsResponse, BringList +from bring_api.types import BringItemsResponse, BringList, BringUserSettingsResponse from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL @@ -32,6 +32,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): """A Bring Data Update Coordinator.""" config_entry: ConfigEntry + user_settings: BringUserSettingsResponse def __init__(self, hass: HomeAssistant, bring: Bring) -> None: """Initialize the Bring data coordinator.""" @@ -81,3 +82,17 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]): list_dict[lst["listUuid"]] = BringData(**lst, **items) return list_dict + + async def _async_setup(self) -> None: + """Set up coordinator.""" + + await self.async_refresh_user_settings() + + async def async_refresh_user_settings(self) -> None: + """Refresh user settings.""" + try: + self.user_settings = await self.bring.get_all_user_settings() + except (BringAuthException, BringRequestException, BringParseException) as e: + raise UpdateFailed( + "Unable to connect and retrieve user settings from bring" + ) from e diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index c5e0b84a190..5b6bf975764 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -23,7 +23,6 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): super().__init__(coordinator) self._list_uuid = bring_list["listUuid"] - self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{self._list_uuid}" self.device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json index 6b79fab3c94..7a4775066cf 100644 --- a/homeassistant/components/bring/icons.json +++ b/homeassistant/components/bring/icons.json @@ -1,5 +1,19 @@ { "entity": { + "sensor": { + "urgent": { + "default": "mdi:run-fast" + }, + "discounted": { + "default": "mdi:brightness-percent" + }, + "convenient": { + "default": "mdi:fridge-outline" + }, + "list_language": { + "default": "mdi:earth" + } + }, "todo": { "shopping_list": { "default": "mdi:cart" diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py new file mode 100644 index 00000000000..edc1da3d59b --- /dev/null +++ b/homeassistant/components/bring/sensor.py @@ -0,0 +1,121 @@ +"""Sensor platform for the Bring! integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from bring_api import BringUserSettingsResponse +from bring_api.const import BRING_SUPPORTED_LOCALES + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import BringConfigEntry +from .const import UNIT_ITEMS +from .coordinator import BringData, BringDataUpdateCoordinator +from .entity import BringBaseEntity +from .util import list_language, sum_attributes + + +@dataclass(kw_only=True, frozen=True) +class BringSensorEntityDescription(SensorEntityDescription): + """Bring Sensor Description.""" + + value_fn: Callable[[BringData, BringUserSettingsResponse], StateType] + + +class BringSensor(StrEnum): + """Bring sensors.""" + + URGENT = "urgent" + CONVENIENT = "convenient" + DISCOUNTED = "discounted" + LIST_LANGUAGE = "list_language" + + +SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = ( + BringSensorEntityDescription( + key=BringSensor.URGENT, + translation_key=BringSensor.URGENT, + value_fn=lambda lst, _: sum_attributes(lst, "urgent"), + native_unit_of_measurement=UNIT_ITEMS, + ), + BringSensorEntityDescription( + key=BringSensor.CONVENIENT, + translation_key=BringSensor.CONVENIENT, + value_fn=lambda lst, _: sum_attributes(lst, "convenient"), + native_unit_of_measurement=UNIT_ITEMS, + ), + BringSensorEntityDescription( + key=BringSensor.DISCOUNTED, + translation_key=BringSensor.DISCOUNTED, + value_fn=lambda lst, _: sum_attributes(lst, "discounted"), + native_unit_of_measurement=UNIT_ITEMS, + ), + BringSensorEntityDescription( + key=BringSensor.LIST_LANGUAGE, + translation_key=BringSensor.LIST_LANGUAGE, + value_fn=( + lambda lst, settings: x.lower() + if (x := list_language(lst["listUuid"], settings)) + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + options=[x.lower() for x in BRING_SUPPORTED_LOCALES], + device_class=SensorDeviceClass.ENUM, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BringConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + + async_add_entities( + BringSensorEntity( + coordinator, + bring_list, + description, + ) + for description in SENSOR_DESCRIPTIONS + for bring_list in coordinator.data.values() + ) + + +class BringSensorEntity(BringBaseEntity, SensorEntity): + """A sensor entity.""" + + entity_description: BringSensorEntityDescription + + def __init__( + self, + coordinator: BringDataUpdateCoordinator, + bring_list: BringData, + entity_description: BringSensorEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, bring_list) + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{self._list_uuid}_{self.entity_description.key}" + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + + return self.entity_description.value_fn( + self.coordinator.data[self._list_uuid], + self.coordinator.user_settings, + ) diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index e3e700d75f9..8044e1b2637 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -26,6 +26,44 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "entity": { + "sensor": { + "urgent": { + "name": "Urgent" + }, + "convenient": { + "name": "On occasion" + }, + "discounted": { + "name": "Discount only" + }, + "list_language": { + "name": "Region & language", + "state": { + "de-at": "Austria", + "de-ch": "Switzerland (German)", + "de-de": "Germany", + "en-au": "Australia", + "en-ca": "Canada", + "en-gb": "United Kingdom", + "en-us": "United States", + "es-es": "Spain", + "fr-ch": "Switzerland (French)", + "fr-fr": "France", + "hu-hu": "Hungary", + "it-ch": "Switzerland (Italian)", + "it-it": "Italy", + "nb-no": "Norway", + "nl-nl": "Netherlands", + "pl-pl": "Poland", + "pt-br": "Portugal", + "ru-ru": "Russia", + "sv-se": "Sweden", + "tr-tr": "Turkey" + } + } + } + }, "exceptions": { "todo_save_item_failed": { "message": "Failed to save item {name} to Bring! list" diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 97d7eba48bd..319aedc6b80 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -31,7 +31,7 @@ from .const import ( DOMAIN, SERVICE_PUSH_NOTIFICATION, ) -from .coordinator import BringData +from .coordinator import BringData, BringDataUpdateCoordinator from .entity import BringBaseEntity @@ -77,6 +77,13 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity): | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) + def __init__( + self, coordinator: BringDataUpdateCoordinator, bring_list: BringData + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, bring_list) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{self._list_uuid}" + @property def todo_items(self) -> list[TodoItem]: """Return the todo items.""" diff --git a/homeassistant/components/bring/util.py b/homeassistant/components/bring/util.py new file mode 100644 index 00000000000..b706156a3d3 --- /dev/null +++ b/homeassistant/components/bring/util.py @@ -0,0 +1,40 @@ +"""Utility functions for Bring.""" + +from __future__ import annotations + +from bring_api import BringUserSettingsResponse + +from .coordinator import BringData + + +def list_language( + list_uuid: str, + user_settings: BringUserSettingsResponse, +) -> str | None: + """Get the lists language setting.""" + try: + list_settings = next( + filter( + lambda x: x["listUuid"] == list_uuid, + user_settings["userlistsettings"], + ) + ) + + return next( + filter( + lambda x: x["key"] == "listArticleLanguage", + list_settings["usersettings"], + ) + )["value"] + + except (StopIteration, KeyError): + return None + + +def sum_attributes(bring_list: BringData, attribute: str) -> int: + """Count items with given attribute set.""" + return sum( + item["attributes"][0]["content"][attribute] + for item in bring_list["purchase"] + if len(item.get("attributes", [])) + ) diff --git a/tests/components/bring/conftest.py b/tests/components/bring/conftest.py index 60c13a1c208..62aa38d4e92 100644 --- a/tests/components/bring/conftest.py +++ b/tests/components/bring/conftest.py @@ -46,6 +46,9 @@ def mock_bring_client() -> Generator[AsyncMock]: client.login.return_value = cast(BringAuthResponse, {"name": "Bring"}) client.load_lists.return_value = load_json_object_fixture("lists.json", DOMAIN) client.get_list.return_value = load_json_object_fixture("items.json", DOMAIN) + client.get_all_user_settings.return_value = load_json_object_fixture( + "usersettings.json", DOMAIN + ) yield client diff --git a/tests/components/bring/fixtures/items.json b/tests/components/bring/fixtures/items.json index 43e05a39fbb..e0b9006167b 100644 --- a/tests/components/bring/fixtures/items.json +++ b/tests/components/bring/fixtures/items.json @@ -6,13 +6,31 @@ "uuid": "b5d0790b-5f32-4d5c-91da-e29066f167de", "itemId": "Paprika", "specification": "Rot", - "attributes": [] + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] }, { "uuid": "72d370ab-d8ca-4e41-b956-91df94795b4e", "itemId": "Pouletbrüstli", "specification": "Bio", - "attributes": [] + "attributes": [ + { + "type": "PURCHASE_CONDITIONS", + "content": { + "urgent": true, + "convenient": true, + "discounted": true + } + } + ] } ], "recently": [ diff --git a/tests/components/bring/fixtures/usersettings.json b/tests/components/bring/fixtures/usersettings.json new file mode 100644 index 00000000000..6c93cdc7d83 --- /dev/null +++ b/tests/components/bring/fixtures/usersettings.json @@ -0,0 +1,60 @@ +{ + "userlistsettings": [ + { + "listUuid": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + "usersettings": [ + { + "key": "listSectionOrder", + "value": "[\"Früchte & Gemüse\",\"Brot & Gebäck\",\"Milch & Käse\",\"Fleisch & Fisch\",\"Zutaten & Gewürze\",\"Fertig- & Tiefkühlprodukte\",\"Getreideprodukte\",\"Snacks & Süsswaren\",\"Getränke & Tabak\",\"Haushalt & Gesundheit\",\"Pflege & Gesundheit\",\"Tierbedarf\",\"Baumarkt & Garten\",\"Eigene Artikel\"]" + }, + { + "key": "listArticleLanguage", + "value": "de-DE" + } + ] + }, + { + "listUuid": "b4776778-7f6c-496e-951b-92a35d3db0dd", + "usersettings": [ + { + "key": "listSectionOrder", + "value": "[\"Früchte & Gemüse\",\"Brot & Gebäck\",\"Milch & Käse\",\"Fleisch & Fisch\",\"Zutaten & Gewürze\",\"Fertig- & Tiefkühlprodukte\",\"Getreideprodukte\",\"Snacks & Süsswaren\",\"Getränke & Tabak\",\"Haushalt & Gesundheit\",\"Pflege & Gesundheit\",\"Tierbedarf\",\"Baumarkt & Garten\",\"Eigene Artikel\"]" + }, + { + "key": "listArticleLanguage", + "value": "en-US" + } + ] + } + ], + "usersettings": [ + { + "key": "autoPush", + "value": "ON" + }, + { + "key": "premiumHideOffersBadge", + "value": "ON" + }, + { + "key": "premiumHideSponsoredCategories", + "value": "ON" + }, + { + "key": "premiumHideInspirationsBadge", + "value": "ON" + }, + { + "key": "onboardClient", + "value": "android" + }, + { + "key": "premiumHideOffersOnMain", + "value": "ON" + }, + { + "key": "defaultListUUID", + "value": "e542eef6-dba7-4c31-a52c-29e6ab9d83a5" + } + ] +} diff --git a/tests/components/bring/snapshots/test_sensor.ambr b/tests/components/bring/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..08e554632e9 --- /dev/null +++ b/tests/components/bring/snapshots/test_sensor.ambr @@ -0,0 +1,467 @@ +# serializer version: 1 +# name: test_setup[sensor.baumarkt_discount_only-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baumarkt_discount_only', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Discount only', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_discounted', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_setup[sensor.baumarkt_discount_only-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Baumarkt Discount only', + 'unit_of_measurement': 'items', + }), + 'context': , + 'entity_id': 'sensor.baumarkt_discount_only', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_setup[sensor.baumarkt_on_occasion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baumarkt_on_occasion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'On occasion', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_convenient', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_setup[sensor.baumarkt_on_occasion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Baumarkt On occasion', + 'unit_of_measurement': 'items', + }), + 'context': , + 'entity_id': 'sensor.baumarkt_on_occasion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_setup[sensor.baumarkt_region_language-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'de-at', + 'de-ch', + 'de-de', + 'en-au', + 'en-ca', + 'en-gb', + 'en-us', + 'es-es', + 'fr-ch', + 'fr-fr', + 'hu-hu', + 'it-ch', + 'it-it', + 'nb-no', + 'nl-nl', + 'pl-pl', + 'pt-br', + 'ru-ru', + 'sv-se', + 'tr-tr', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.baumarkt_region_language', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Region & language', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_list_language', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.baumarkt_region_language-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Baumarkt Region & language', + 'options': list([ + 'de-at', + 'de-ch', + 'de-de', + 'en-au', + 'en-ca', + 'en-gb', + 'en-us', + 'es-es', + 'fr-ch', + 'fr-fr', + 'hu-hu', + 'it-ch', + 'it-it', + 'nb-no', + 'nl-nl', + 'pl-pl', + 'pt-br', + 'ru-ru', + 'sv-se', + 'tr-tr', + ]), + }), + 'context': , + 'entity_id': 'sensor.baumarkt_region_language', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'en-us', + }) +# --- +# name: test_setup[sensor.baumarkt_urgent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.baumarkt_urgent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Urgent', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-00000000-00000000-00000000_b4776778-7f6c-496e-951b-92a35d3db0dd_urgent', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_setup[sensor.baumarkt_urgent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Baumarkt Urgent', + 'unit_of_measurement': 'items', + }), + 'context': , + 'entity_id': 'sensor.baumarkt_urgent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_setup[sensor.einkauf_discount_only-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.einkauf_discount_only', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Discount only', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_discounted', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_setup[sensor.einkauf_discount_only-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Einkauf Discount only', + 'unit_of_measurement': 'items', + }), + 'context': , + 'entity_id': 'sensor.einkauf_discount_only', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_setup[sensor.einkauf_on_occasion-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.einkauf_on_occasion', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'On occasion', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_convenient', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_setup[sensor.einkauf_on_occasion-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Einkauf On occasion', + 'unit_of_measurement': 'items', + }), + 'context': , + 'entity_id': 'sensor.einkauf_on_occasion', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_setup[sensor.einkauf_region_language-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'de-at', + 'de-ch', + 'de-de', + 'en-au', + 'en-ca', + 'en-gb', + 'en-us', + 'es-es', + 'fr-ch', + 'fr-fr', + 'hu-hu', + 'it-ch', + 'it-it', + 'nb-no', + 'nl-nl', + 'pl-pl', + 'pt-br', + 'ru-ru', + 'sv-se', + 'tr-tr', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.einkauf_region_language', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Region & language', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_list_language', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.einkauf_region_language-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Einkauf Region & language', + 'options': list([ + 'de-at', + 'de-ch', + 'de-de', + 'en-au', + 'en-ca', + 'en-gb', + 'en-us', + 'es-es', + 'fr-ch', + 'fr-fr', + 'hu-hu', + 'it-ch', + 'it-it', + 'nb-no', + 'nl-nl', + 'pl-pl', + 'pt-br', + 'ru-ru', + 'sv-se', + 'tr-tr', + ]), + }), + 'context': , + 'entity_id': 'sensor.einkauf_region_language', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'de-de', + }) +# --- +# name: test_setup[sensor.einkauf_urgent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.einkauf_urgent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Urgent', + 'platform': 'bring', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '00000000-00000000-00000000-00000000_e542eef6-dba7-4c31-a52c-29e6ab9d83a5_urgent', + 'unit_of_measurement': 'items', + }) +# --- +# name: test_setup[sensor.einkauf_urgent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Einkauf Urgent', + 'unit_of_measurement': 'items', + }), + 'context': , + 'entity_id': 'sensor.einkauf_urgent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- diff --git a/tests/components/bring/test_init.py b/tests/components/bring/test_init.py index 613b65e38b6..5ee66999ea4 100644 --- a/tests/components/bring/test_init.py +++ b/tests/components/bring/test_init.py @@ -90,7 +90,14 @@ async def test_init_exceptions( @pytest.mark.parametrize("exception", [BringRequestException, BringParseException]) -@pytest.mark.parametrize("bring_method", ["load_lists", "get_list"]) +@pytest.mark.parametrize( + "bring_method", + [ + "load_lists", + "get_list", + "get_all_user_settings", + ], +) async def test_config_entry_not_ready( hass: HomeAssistant, bring_config_entry: MockConfigEntry, diff --git a/tests/components/bring/test_sensor.py b/tests/components/bring/test_sensor.py new file mode 100644 index 00000000000..a36b0163165 --- /dev/null +++ b/tests/components/bring/test_sensor.py @@ -0,0 +1,44 @@ +"""Test for sensor platform of the Bring! integration.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.bring.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_setup( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor platform.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform( + hass, entity_registry, snapshot, bring_config_entry.entry_id + ) diff --git a/tests/components/bring/test_todo.py b/tests/components/bring/test_todo.py index d67429e8f49..9cc4ae3d888 100644 --- a/tests/components/bring/test_todo.py +++ b/tests/components/bring/test_todo.py @@ -1,7 +1,8 @@ """Test for todo platform of the Bring! integration.""" +from collections.abc import Generator import re -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from bring_api import BringItemOperation, BringRequestException import pytest @@ -15,7 +16,7 @@ from homeassistant.components.todo import ( TodoServices, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -23,6 +24,16 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture(autouse=True) +def todo_only() -> Generator[None]: + """Enable only the todo platform.""" + with patch( + "homeassistant.components.bring.PLATFORMS", + [Platform.TODO], + ): + yield + + @pytest.mark.usefixtures("mock_bring_client") async def test_todo( hass: HomeAssistant, diff --git a/tests/components/bring/test_util.py b/tests/components/bring/test_util.py new file mode 100644 index 00000000000..0d9ed0c5345 --- /dev/null +++ b/tests/components/bring/test_util.py @@ -0,0 +1,56 @@ +"""Test for utility functions of the Bring! integration.""" + +from typing import cast + +from bring_api import BringUserSettingsResponse +import pytest + +from homeassistant.components.bring import DOMAIN +from homeassistant.components.bring.coordinator import BringData +from homeassistant.components.bring.util import list_language, sum_attributes + +from tests.common import load_json_object_fixture + + +@pytest.mark.parametrize( + ("list_uuid", "expected"), + [ + ("e542eef6-dba7-4c31-a52c-29e6ab9d83a5", "de-DE"), + ("b4776778-7f6c-496e-951b-92a35d3db0dd", "en-US"), + ("00000000-0000-0000-0000-00000000", None), + ], +) +def test_list_language(list_uuid: str, expected: str | None) -> None: + """Test function list_language.""" + + result = list_language( + list_uuid, + cast( + BringUserSettingsResponse, + load_json_object_fixture("usersettings.json", DOMAIN), + ), + ) + + assert result == expected + + +@pytest.mark.parametrize( + ("attribute", "expected"), + [ + ("urgent", 2), + ("convenient", 2), + ("discounted", 2), + ], +) +def test_sum_attributes(attribute: str, expected: int) -> None: + """Test function sum_attributes.""" + + result = sum_attributes( + cast( + BringData, + load_json_object_fixture("items.json", DOMAIN), + ), + attribute, + ) + + assert result == expected From 161f37bb98b47e62f8a28d5c769771bf943a45aa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Sep 2024 23:00:00 +0200 Subject: [PATCH 1139/1309] Add tests which directly test the recorder job wrappers (#125338) --- tests/components/recorder/test_util.py | 124 ++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index d850778d214..ad68e415df5 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -1,10 +1,12 @@ """Test util methods.""" +from contextlib import AbstractContextManager, nullcontext as does_not_raise from datetime import UTC, datetime, timedelta import os from pathlib import Path import sqlite3 import threading +from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest @@ -16,7 +18,11 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components import recorder from homeassistant.components.recorder import Recorder, util -from homeassistant.components.recorder.const import DOMAIN, SQLITE_URL_PREFIX +from homeassistant.components.recorder.const import ( + DOMAIN, + SQLITE_URL_PREFIX, + SupportedDialect, +) from homeassistant.components.recorder.db_schema import RecorderRuns from homeassistant.components.recorder.history.modern import ( _get_single_entity_start_time_stmt, @@ -27,10 +33,14 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.components.recorder.util import ( MIN_VERSION_SQLITE, + RETRYABLE_MYSQL_ERRORS, UPCOMING_MIN_VERSION_SQLITE, + database_job_retry_wrapper, end_incomplete_runs, is_second_sunday, resolve_period, + retryable_database_job, + retryable_database_job_method, session_scope, ) from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -1117,3 +1127,115 @@ async def test_resolve_period(hass: HomeAssistant) -> None: } } ) == (now - timedelta(hours=1, minutes=25), now - timedelta(minutes=25)) + + +NonRetryable = OperationalError(None, None, BaseException()) +Retryable = OperationalError(None, None, BaseException(RETRYABLE_MYSQL_ERRORS[0], "")) + + +@pytest.mark.parametrize( + ("side_effect", "dialect", "expected_result", "num_calls"), + [ + (None, SupportedDialect.MYSQL, does_not_raise(), 1), + (ValueError, SupportedDialect.MYSQL, pytest.raises(ValueError), 1), + (NonRetryable, SupportedDialect.MYSQL, pytest.raises(OperationalError), 1), + (Retryable, SupportedDialect.MYSQL, pytest.raises(OperationalError), 5), + (NonRetryable, SupportedDialect.SQLITE, pytest.raises(OperationalError), 1), + (Retryable, SupportedDialect.SQLITE, pytest.raises(OperationalError), 1), + ], +) +def test_database_job_retry_wrapper( + side_effect: Any, + dialect: str, + expected_result: AbstractContextManager, + num_calls: int, +) -> None: + """Test database_job_retry_wrapper.""" + + instance = Mock() + instance.db_retry_wait = 0 + instance.engine.dialect.name = dialect + mock_job = Mock(side_effect=side_effect) + + @database_job_retry_wrapper(description="test") + def job(instance, *args, **kwargs) -> None: + mock_job() + + with expected_result: + job(instance) + + assert len(mock_job.mock_calls) == num_calls + + +@pytest.mark.parametrize( + ("side_effect", "dialect", "retval", "expected_result"), + [ + (None, SupportedDialect.MYSQL, False, does_not_raise()), + (None, SupportedDialect.MYSQL, True, does_not_raise()), + (ValueError, SupportedDialect.MYSQL, False, pytest.raises(ValueError)), + (NonRetryable, SupportedDialect.MYSQL, True, does_not_raise()), + (Retryable, SupportedDialect.MYSQL, False, does_not_raise()), + (NonRetryable, SupportedDialect.SQLITE, True, does_not_raise()), + (Retryable, SupportedDialect.SQLITE, True, does_not_raise()), + ], +) +def test_retryable_database_job( + side_effect: Any, + retval: bool, + expected_result: AbstractContextManager, + dialect: str, +) -> None: + """Test retryable_database_job.""" + + instance = Mock() + instance.db_retry_wait = 0 + instance.engine.dialect.name = dialect + mock_job = Mock(side_effect=side_effect) + + @retryable_database_job(description="test") + def job(instance, *args, **kwargs) -> bool: + mock_job() + return retval + + with expected_result: + assert job(instance) == retval + + assert len(mock_job.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "dialect", "retval", "expected_result"), + [ + (None, SupportedDialect.MYSQL, False, does_not_raise()), + (None, SupportedDialect.MYSQL, True, does_not_raise()), + (ValueError, SupportedDialect.MYSQL, False, pytest.raises(ValueError)), + (NonRetryable, SupportedDialect.MYSQL, True, does_not_raise()), + (Retryable, SupportedDialect.MYSQL, False, does_not_raise()), + (NonRetryable, SupportedDialect.SQLITE, True, does_not_raise()), + (Retryable, SupportedDialect.SQLITE, True, does_not_raise()), + ], +) +def test_retryable_database_job_method( + side_effect: Any, + retval: bool, + expected_result: AbstractContextManager, + dialect: str, +) -> None: + """Test retryable_database_job_method.""" + + instance = Mock() + instance.db_retry_wait = 0 + instance.engine.dialect.name = dialect + mock_job = Mock(side_effect=side_effect) + + class Test: + @retryable_database_job_method(description="test") + def job(self, instance, *args, **kwargs) -> bool: + mock_job() + return retval + + test = Test() + with expected_result: + assert test.job(instance) == retval + + assert len(mock_job.mock_calls) == 1 From 3d4ac7ca631515e072a9e907ee8d314542bb7e68 Mon Sep 17 00:00:00 2001 From: Manu Date: Tue, 24 Sep 2024 23:00:43 +0200 Subject: [PATCH 1140/1309] Add diagnostics platform to Bring integration (#126695) --- homeassistant/components/bring/diagnostics.py | 16 +++++ .../bring/snapshots/test_diagnostics.ambr | 69 +++++++++++++++++++ tests/components/bring/test_diagnostics.py | 27 ++++++++ 3 files changed, 112 insertions(+) create mode 100644 homeassistant/components/bring/diagnostics.py create mode 100644 tests/components/bring/snapshots/test_diagnostics.ambr create mode 100644 tests/components/bring/test_diagnostics.py diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py new file mode 100644 index 00000000000..f4193a9993c --- /dev/null +++ b/homeassistant/components/bring/diagnostics.py @@ -0,0 +1,16 @@ +"""Diagnostics support for Bring.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from . import BringConfigEntry +from .coordinator import BringData + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: BringConfigEntry +) -> dict[str, BringData]: + """Return diagnostics for a config entry.""" + + return config_entry.runtime_data.data diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..db0801447e1 --- /dev/null +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -0,0 +1,69 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'b4776778-7f6c-496e-951b-92a35d3db0dd': dict({ + 'listUuid': 'b4776778-7f6c-496e-951b-92a35d3db0dd', + 'name': 'Baumarkt', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + 'status': 'REGISTERED', + 'theme': 'ch.publisheria.bring.theme.home', + 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + }), + 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5': dict({ + 'listUuid': 'e542eef6-dba7-4c31-a52c-29e6ab9d83a5', + 'name': 'Einkauf', + 'purchase': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Paprika', + 'specification': 'Rot', + 'uuid': 'b5d0790b-5f32-4d5c-91da-e29066f167de', + }), + dict({ + 'attributes': list([ + ]), + 'itemId': 'Pouletbrüstli', + 'specification': 'Bio', + 'uuid': '72d370ab-d8ca-4e41-b956-91df94795b4e', + }), + ]), + 'recently': list([ + dict({ + 'attributes': list([ + ]), + 'itemId': 'Ananas', + 'specification': '', + 'uuid': 'fc8db30a-647e-4e6c-9d71-3b85d6a2d954', + }), + ]), + 'status': 'REGISTERED', + 'theme': 'ch.publisheria.bring.theme.home', + 'uuid': '77a151f8-77c4-47a3-8295-c750a0e69d4f', + }), + }) +# --- diff --git a/tests/components/bring/test_diagnostics.py b/tests/components/bring/test_diagnostics.py new file mode 100644 index 00000000000..a86de5a0d2d --- /dev/null +++ b/tests/components/bring/test_diagnostics.py @@ -0,0 +1,27 @@ +"""Test for diagnostics platform of the Bring! integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bring_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, bring_config_entry) + == snapshot + ) From 2a0c779a02c813e53365cfce2513074db6bca887 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 24 Sep 2024 23:01:47 +0200 Subject: [PATCH 1141/1309] Avoid raw string in device_tracker source_type (#126601) --- homeassistant/components/device_tracker/config_entry.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 306f056dbcc..8fbd85ae288 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -181,7 +181,7 @@ class BaseTrackerEntity(Entity): return None @property - def source_type(self) -> SourceType | str: + def source_type(self) -> SourceType: """Return the source type, eg gps or router, of the device.""" if hasattr(self, "_attr_source_type"): return self._attr_source_type diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f696bc55177..65931a152e9 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -1318,7 +1318,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="source_type", - return_type=["SourceType", "str"], + return_type="SourceType", ), ], ), From c5d562a56fbb89287732b584bae3ca445ef99c2e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 23:09:58 +0200 Subject: [PATCH 1142/1309] Add Spelling Bee and connections support to NYT Games (#126567) --- .../components/nyt_games/coordinator.py | 24 +- homeassistant/components/nyt_games/entity.py | 39 +- homeassistant/components/nyt_games/icons.json | 21 +- homeassistant/components/nyt_games/sensor.py | 162 +++++- .../components/nyt_games/strings.json | 29 +- tests/components/nyt_games/conftest.py | 5 +- .../nyt_games/fixtures/connections.json | 24 + .../nyt_games/snapshots/test_init.ambr | 70 ++- .../nyt_games/snapshots/test_sensor.ambr | 461 ++++++++++++++++-- tests/components/nyt_games/test_init.py | 11 +- tests/components/nyt_games/test_sensor.py | 6 +- 11 files changed, 784 insertions(+), 68 deletions(-) create mode 100644 tests/components/nyt_games/fixtures/connections.json diff --git a/homeassistant/components/nyt_games/coordinator.py b/homeassistant/components/nyt_games/coordinator.py index d9e39ff814c..75aa79f62ba 100644 --- a/homeassistant/components/nyt_games/coordinator.py +++ b/homeassistant/components/nyt_games/coordinator.py @@ -2,10 +2,11 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING -from nyt_games import NYTGamesClient, NYTGamesError, Wordle +from nyt_games import Connections, NYTGamesClient, NYTGamesError, SpellingBee, Wordle from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -16,7 +17,16 @@ if TYPE_CHECKING: from . import NYTGamesConfigEntry -class NYTGamesCoordinator(DataUpdateCoordinator[Wordle]): +@dataclass +class NYTGamesData: + """Class for NYT Games data.""" + + wordle: Wordle + spelling_bee: SpellingBee + connections: Connections + + +class NYTGamesCoordinator(DataUpdateCoordinator[NYTGamesData]): """Class to manage fetching NYT Games data.""" config_entry: NYTGamesConfigEntry @@ -31,8 +41,14 @@ class NYTGamesCoordinator(DataUpdateCoordinator[Wordle]): ) self.client = client - async def _async_update_data(self) -> Wordle: + async def _async_update_data(self) -> NYTGamesData: try: - return (await self.client.get_latest_stats()).wordle + stats_data = await self.client.get_latest_stats() + connections_data = await self.client.get_connections() except NYTGamesError as error: raise UpdateFailed(error) from error + return NYTGamesData( + wordle=stats_data.wordle, + spelling_bee=stats_data.spelling_bee, + connections=connections_data, + ) diff --git a/homeassistant/components/nyt_games/entity.py b/homeassistant/components/nyt_games/entity.py index ba4234ab48b..40ca6ca973f 100644 --- a/homeassistant/components/nyt_games/entity.py +++ b/homeassistant/components/nyt_games/entity.py @@ -12,13 +12,50 @@ class NYTGamesEntity(CoordinatorEntity[NYTGamesCoordinator]): _attr_has_entity_name = True + +class WordleEntity(NYTGamesEntity): + """Defines a NYT Games entity.""" + def __init__(self, coordinator: NYTGamesCoordinator) -> None: """Initialize a NYT Games entity.""" super().__init__(coordinator) unique_id = coordinator.config_entry.unique_id assert unique_id is not None self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, + identifiers={(DOMAIN, f"{unique_id}_wordle")}, entry_type=DeviceEntryType.SERVICE, manufacturer="New York Times", + name="Wordle", + ) + + +class SpellingBeeEntity(NYTGamesEntity): + """Defines a NYT Games entity.""" + + def __init__(self, coordinator: NYTGamesCoordinator) -> None: + """Initialize a NYT Games entity.""" + super().__init__(coordinator) + unique_id = coordinator.config_entry.unique_id + assert unique_id is not None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{unique_id}_spelling_bee")}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="New York Times", + name="Spelling Bee", + ) + + +class ConnectionsEntity(NYTGamesEntity): + """Defines a NYT Games entity.""" + + def __init__(self, coordinator: NYTGamesCoordinator) -> None: + """Initialize a NYT Games entity.""" + super().__init__(coordinator) + unique_id = coordinator.config_entry.unique_id + assert unique_id is not None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{unique_id}_connections")}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="New York Times", + name="Connections", ) diff --git a/homeassistant/components/nyt_games/icons.json b/homeassistant/components/nyt_games/icons.json index 9e455cbf951..1f7b737a51b 100644 --- a/homeassistant/components/nyt_games/icons.json +++ b/homeassistant/components/nyt_games/icons.json @@ -4,14 +4,29 @@ "wordles_played": { "default": "mdi:text-long" }, - "wordles_won": { + "won": { "default": "mdi:trophy-award" }, - "wordles_streak": { + "streak": { "default": "mdi:calendar-range" }, - "wordles_max_streak": { + "max_streak": { "default": "mdi:calendar-month" + }, + "spelling_bees_played": { + "default": "mdi:beehive-outline" + }, + "total_words": { + "default": "mdi:beehive-outline" + }, + "total_pangrams": { + "default": "mdi:beehive-outline" + }, + "connections_played": { + "default": "mdi:table-large" + }, + "last_played": { + "default": "mdi:beehive-outline" } } } diff --git a/homeassistant/components/nyt_games/sensor.py b/homeassistant/components/nyt_games/sensor.py index d677f2d166c..6e243a908b4 100644 --- a/homeassistant/components/nyt_games/sensor.py +++ b/homeassistant/components/nyt_games/sensor.py @@ -2,8 +2,9 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import date -from nyt_games import Wordle +from nyt_games import Connections, SpellingBee, Wordle from homeassistant.components.sensor import ( SensorDeviceClass, @@ -18,7 +19,7 @@ from homeassistant.helpers.typing import StateType from . import NYTGamesConfigEntry from .coordinator import NYTGamesCoordinator -from .entity import NYTGamesEntity +from .entity import ConnectionsEntity, SpellingBeeEntity, WordleEntity @dataclass(frozen=True, kw_only=True) @@ -28,7 +29,7 @@ class NYTGamesWordleSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Wordle], StateType] -SENSOR_TYPES: tuple[NYTGamesWordleSensorEntityDescription, ...] = ( +WORDLE_SENSORS: tuple[NYTGamesWordleSensorEntityDescription, ...] = ( NYTGamesWordleSensorEntityDescription( key="wordles_played", translation_key="wordles_played", @@ -38,14 +39,14 @@ SENSOR_TYPES: tuple[NYTGamesWordleSensorEntityDescription, ...] = ( ), NYTGamesWordleSensorEntityDescription( key="wordles_won", - translation_key="wordles_won", + translation_key="won", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement="games", value_fn=lambda wordle: wordle.games_won, ), NYTGamesWordleSensorEntityDescription( key="wordles_streak", - translation_key="wordles_streak", + translation_key="streak", state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfTime.DAYS, device_class=SensorDeviceClass.DURATION, @@ -53,7 +54,7 @@ SENSOR_TYPES: tuple[NYTGamesWordleSensorEntityDescription, ...] = ( ), NYTGamesWordleSensorEntityDescription( key="wordles_max_streak", - translation_key="wordles_max_streak", + translation_key="max_streak", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.DAYS, device_class=SensorDeviceClass.DURATION, @@ -62,6 +63,87 @@ SENSOR_TYPES: tuple[NYTGamesWordleSensorEntityDescription, ...] = ( ) +@dataclass(frozen=True, kw_only=True) +class NYTGamesSpellingBeeSensorEntityDescription(SensorEntityDescription): + """Describes a NYT Games Spelling Bee sensor entity.""" + + value_fn: Callable[[SpellingBee], StateType] + + +SPELLING_BEE_SENSORS: tuple[NYTGamesSpellingBeeSensorEntityDescription, ...] = ( + NYTGamesSpellingBeeSensorEntityDescription( + key="spelling_bees_played", + translation_key="spelling_bees_played", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="games", + value_fn=lambda spelling_bee: spelling_bee.puzzles_started, + ), + NYTGamesSpellingBeeSensorEntityDescription( + key="spelling_bees_total_words", + translation_key="total_words", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="words", + entity_registry_enabled_default=False, + value_fn=lambda spelling_bee: spelling_bee.total_words, + ), + NYTGamesSpellingBeeSensorEntityDescription( + key="spelling_bees_total_pangrams", + translation_key="total_pangrams", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="pangrams", + entity_registry_enabled_default=False, + value_fn=lambda spelling_bee: spelling_bee.total_pangrams, + ), +) + + +@dataclass(frozen=True, kw_only=True) +class NYTGamesConnectionsSensorEntityDescription(SensorEntityDescription): + """Describes a NYT Games Connections sensor entity.""" + + value_fn: Callable[[Connections], StateType | date] + + +CONNECTIONS_SENSORS: tuple[NYTGamesConnectionsSensorEntityDescription, ...] = ( + NYTGamesConnectionsSensorEntityDescription( + key="connections_played", + translation_key="connections_played", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="games", + value_fn=lambda connections: connections.puzzles_completed, + ), + NYTGamesConnectionsSensorEntityDescription( + key="connections_won", + translation_key="won", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement="games", + value_fn=lambda connections: connections.puzzles_won, + ), + NYTGamesConnectionsSensorEntityDescription( + key="connections_last_played", + translation_key="last_played", + device_class=SensorDeviceClass.DATE, + value_fn=lambda connections: connections.last_completed, + ), + NYTGamesConnectionsSensorEntityDescription( + key="connections_streak", + translation_key="streak", + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfTime.DAYS, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda connections: connections.current_streak, + ), + NYTGamesConnectionsSensorEntityDescription( + key="connections_max_streak", + translation_key="max_streak", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfTime.DAYS, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda connections: connections.current_streak, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: NYTGamesConfigEntry, @@ -71,12 +153,22 @@ async def async_setup_entry( coordinator = entry.runtime_data - async_add_entities( - NYTGamesSensor(coordinator, description) for description in SENSOR_TYPES + entities: list[SensorEntity] = [ + NYTGamesWordleSensor(coordinator, description) for description in WORDLE_SENSORS + ] + entities.extend( + NYTGamesSpellingBeeSensor(coordinator, description) + for description in SPELLING_BEE_SENSORS + ) + entities.extend( + NYTGamesConnectionsSensor(coordinator, description) + for description in CONNECTIONS_SENSORS ) + async_add_entities(entities) -class NYTGamesSensor(NYTGamesEntity, SensorEntity): + +class NYTGamesWordleSensor(WordleEntity, SensorEntity): """Defines a NYT Games sensor.""" entity_description: NYTGamesWordleSensorEntityDescription @@ -89,9 +181,57 @@ class NYTGamesSensor(NYTGamesEntity, SensorEntity): """Initialize NYT Games sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.unique_id}-{description.key}" + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}-wordle-{description.key}" + ) @property def native_value(self) -> StateType: """Return the state of the sensor.""" - return self.entity_description.value_fn(self.coordinator.data) + return self.entity_description.value_fn(self.coordinator.data.wordle) + + +class NYTGamesSpellingBeeSensor(SpellingBeeEntity, SensorEntity): + """Defines a NYT Games sensor.""" + + entity_description: NYTGamesSpellingBeeSensorEntityDescription + + def __init__( + self, + coordinator: NYTGamesCoordinator, + description: NYTGamesSpellingBeeSensorEntityDescription, + ) -> None: + """Initialize NYT Games sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}-spelling_bee-{description.key}" + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data.spelling_bee) + + +class NYTGamesConnectionsSensor(ConnectionsEntity, SensorEntity): + """Defines a NYT Games sensor.""" + + entity_description: NYTGamesConnectionsSensorEntityDescription + + def __init__( + self, + coordinator: NYTGamesCoordinator, + description: NYTGamesConnectionsSensorEntityDescription, + ) -> None: + """Initialize NYT Games sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}-connections-{description.key}" + ) + + @property + def native_value(self) -> StateType | date: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data.connections) diff --git a/homeassistant/components/nyt_games/strings.json b/homeassistant/components/nyt_games/strings.json index 152d523ec57..9a3771aebd9 100644 --- a/homeassistant/components/nyt_games/strings.json +++ b/homeassistant/components/nyt_games/strings.json @@ -22,16 +22,31 @@ "entity": { "sensor": { "wordles_played": { - "name": "Wordles played" + "name": "Played" }, - "wordles_won": { - "name": "Wordles won" + "won": { + "name": "Won" }, - "wordles_streak": { - "name": "Current Wordle streak" + "streak": { + "name": "Current streak" }, - "wordles_max_streak": { - "name": "Highest Wordle streak" + "max_streak": { + "name": "Highest streak" + }, + "spelling_bees_played": { + "name": "[%key:component::nyt_games::entity::sensor::wordles_played::name%]" + }, + "total_words": { + "name": "Total words found" + }, + "total_pangrams": { + "name": "Total pangrams found" + }, + "connections_played": { + "name": "[%key:component::nyt_games::entity::sensor::wordles_played::name%]" + }, + "last_played": { + "name": "Last played" } } } diff --git a/tests/components/nyt_games/conftest.py b/tests/components/nyt_games/conftest.py index 3165021bc5b..2999ae115b1 100644 --- a/tests/components/nyt_games/conftest.py +++ b/tests/components/nyt_games/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import patch -from nyt_games.models import WordleStats +from nyt_games.models import ConnectionsStats, WordleStats import pytest from homeassistant.components.nyt_games.const import DOMAIN @@ -41,6 +41,9 @@ def mock_nyt_games_client() -> Generator[AsyncMock]: load_fixture("latest.json", DOMAIN) ).player.stats client.get_user_id.return_value = 218886794 + client.get_connections.return_value = ConnectionsStats.from_json( + load_fixture("connections.json", DOMAIN) + ).player.stats yield client diff --git a/tests/components/nyt_games/fixtures/connections.json b/tests/components/nyt_games/fixtures/connections.json new file mode 100644 index 00000000000..8c1ea18199a --- /dev/null +++ b/tests/components/nyt_games/fixtures/connections.json @@ -0,0 +1,24 @@ +{ + "states": [], + "user_id": 218886794, + "player": { + "user_id": 218886794, + "last_updated": 1727097528, + "stats": { + "connections": { + "puzzles_completed": 9, + "puzzles_won": 3, + "last_played_print_date": "2024-09-23", + "current_streak": 0, + "max_streak": 2, + "mistakes": { + "0": 2, + "1": 0, + "2": 1, + "3": 0, + "4": 6 + } + } + } + } +} diff --git a/tests/components/nyt_games/snapshots/test_init.ambr b/tests/components/nyt_games/snapshots/test_init.ambr index 60759f25baf..383bed0e106 100644 --- a/tests/components/nyt_games/snapshots/test_init.ambr +++ b/tests/components/nyt_games/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_device_info +# name: test_device_info[device_connections] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -13,7 +13,7 @@ 'identifiers': set({ tuple( 'nyt_games', - '218886794', + '218886794_connections', ), }), 'is_new': False, @@ -22,7 +22,71 @@ 'manufacturer': 'New York Times', 'model': None, 'model_id': None, - 'name': 'NYTGames', + 'name': 'Connections', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_info[device_spelling_bee] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'nyt_games', + '218886794_spelling_bee', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'New York Times', + 'model': None, + 'model_id': None, + 'name': 'Spelling Bee', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_device_info[device_wordle] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'nyt_games', + '218886794_wordle', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'New York Times', + 'model': None, + 'model_id': None, + 'name': 'Wordle', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr index 9f164f7da3b..7c4c2b57253 100644 --- a/tests/components/nyt_games/snapshots/test_sensor.ambr +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_all_entities[sensor.nytgames_current_wordle_streak-entry] +# name: test_all_entities[sensor.connections_current_streak-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13,7 +13,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.nytgames_current_wordle_streak', + 'entity_id': 'sensor.connections_current_streak', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -25,32 +25,431 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Current Wordle streak', + 'original_name': 'Current streak', 'platform': 'nyt_games', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wordles_streak', - 'unique_id': '218886794-wordles_streak', + 'translation_key': 'streak', + 'unique_id': '218886794-connections-connections_streak', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.nytgames_current_wordle_streak-state] +# name: test_all_entities[sensor.connections_current_streak-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'NYTGames Current Wordle streak', + 'friendly_name': 'Connections Current streak', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.nytgames_current_wordle_streak', + 'entity_id': 'sensor.connections_current_streak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.connections_highest_streak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.connections_highest_streak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Highest streak', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'max_streak', + 'unique_id': '218886794-connections-connections_max_streak', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.connections_highest_streak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Connections Highest streak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.connections_highest_streak', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.connections_last_played-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.connections_last_played', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last played', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'last_played', + 'unique_id': '218886794-connections-connections_last_played', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.connections_last_played-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'date', + 'friendly_name': 'Connections Last played', + }), + 'context': , + 'entity_id': 'sensor.connections_last_played', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-09-23', + }) +# --- +# name: test_all_entities[sensor.connections_played-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.connections_played', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Played', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'connections_played', + 'unique_id': '218886794-connections-connections_played', + 'unit_of_measurement': 'games', + }) +# --- +# name: test_all_entities[sensor.connections_played-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Connections Played', + 'state_class': , + 'unit_of_measurement': 'games', + }), + 'context': , + 'entity_id': 'sensor.connections_played', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9', + }) +# --- +# name: test_all_entities[sensor.connections_won-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.connections_won', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Won', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'won', + 'unique_id': '218886794-connections-connections_won', + 'unit_of_measurement': 'games', + }) +# --- +# name: test_all_entities[sensor.connections_won-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Connections Won', + 'state_class': , + 'unit_of_measurement': 'games', + }), + 'context': , + 'entity_id': 'sensor.connections_won', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_all_entities[sensor.spelling_bee_played-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.spelling_bee_played', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Played', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'spelling_bees_played', + 'unique_id': '218886794-spelling_bee-spelling_bees_played', + 'unit_of_measurement': 'games', + }) +# --- +# name: test_all_entities[sensor.spelling_bee_played-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spelling Bee Played', + 'state_class': , + 'unit_of_measurement': 'games', + }), + 'context': , + 'entity_id': 'sensor.spelling_bee_played', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '87', + }) +# --- +# name: test_all_entities[sensor.spelling_bee_total_pangrams_found-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.spelling_bee_total_pangrams_found', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total pangrams found', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_pangrams', + 'unique_id': '218886794-spelling_bee-spelling_bees_total_pangrams', + 'unit_of_measurement': 'pangrams', + }) +# --- +# name: test_all_entities[sensor.spelling_bee_total_pangrams_found-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spelling Bee Total pangrams found', + 'state_class': , + 'unit_of_measurement': 'pangrams', + }), + 'context': , + 'entity_id': 'sensor.spelling_bee_total_pangrams_found', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15', + }) +# --- +# name: test_all_entities[sensor.spelling_bee_total_words_found-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.spelling_bee_total_words_found', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total words found', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_words', + 'unique_id': '218886794-spelling_bee-spelling_bees_total_words', + 'unit_of_measurement': 'words', + }) +# --- +# name: test_all_entities[sensor.spelling_bee_total_words_found-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Spelling Bee Total words found', + 'state_class': , + 'unit_of_measurement': 'words', + }), + 'context': , + 'entity_id': 'sensor.spelling_bee_total_words_found', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '362', + }) +# --- +# name: test_all_entities[sensor.wordle_current_streak-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.wordle_current_streak', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current streak', + 'platform': 'nyt_games', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'streak', + 'unique_id': '218886794-wordle-wordles_streak', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.wordle_current_streak-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Wordle Current streak', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.wordle_current_streak', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '1', }) # --- -# name: test_all_entities[sensor.nytgames_highest_wordle_streak-entry] +# name: test_all_entities[sensor.wordle_highest_streak-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -64,7 +463,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.nytgames_highest_wordle_streak', + 'entity_id': 'sensor.wordle_highest_streak', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -76,32 +475,32 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Highest Wordle streak', + 'original_name': 'Highest streak', 'platform': 'nyt_games', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wordles_max_streak', - 'unique_id': '218886794-wordles_max_streak', + 'translation_key': 'max_streak', + 'unique_id': '218886794-wordle-wordles_max_streak', 'unit_of_measurement': , }) # --- -# name: test_all_entities[sensor.nytgames_highest_wordle_streak-state] +# name: test_all_entities[sensor.wordle_highest_streak-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', - 'friendly_name': 'NYTGames Highest Wordle streak', + 'friendly_name': 'Wordle Highest streak', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.nytgames_highest_wordle_streak', + 'entity_id': 'sensor.wordle_highest_streak', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '5', }) # --- -# name: test_all_entities[sensor.nytgames_wordles_played-entry] +# name: test_all_entities[sensor.wordle_played-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -115,7 +514,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.nytgames_wordles_played', + 'entity_id': 'sensor.wordle_played', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -127,31 +526,31 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wordles played', + 'original_name': 'Played', 'platform': 'nyt_games', 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wordles_played', - 'unique_id': '218886794-wordles_played', + 'unique_id': '218886794-wordle-wordles_played', 'unit_of_measurement': 'games', }) # --- -# name: test_all_entities[sensor.nytgames_wordles_played-state] +# name: test_all_entities[sensor.wordle_played-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'NYTGames Wordles played', + 'friendly_name': 'Wordle Played', 'state_class': , 'unit_of_measurement': 'games', }), 'context': , - 'entity_id': 'sensor.nytgames_wordles_played', + 'entity_id': 'sensor.wordle_played', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '33', }) # --- -# name: test_all_entities[sensor.nytgames_wordles_won-entry] +# name: test_all_entities[sensor.wordle_won-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -165,7 +564,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.nytgames_wordles_won', + 'entity_id': 'sensor.wordle_won', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -177,24 +576,24 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wordles won', + 'original_name': 'Won', 'platform': 'nyt_games', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': 'wordles_won', - 'unique_id': '218886794-wordles_won', + 'translation_key': 'won', + 'unique_id': '218886794-wordle-wordles_won', 'unit_of_measurement': 'games', }) # --- -# name: test_all_entities[sensor.nytgames_wordles_won-state] +# name: test_all_entities[sensor.wordle_won-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'NYTGames Wordles won', + 'friendly_name': 'Wordle Won', 'state_class': , 'unit_of_measurement': 'games', }), 'context': , - 'entity_id': 'sensor.nytgames_wordles_won', + 'entity_id': 'sensor.wordle_won', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/nyt_games/test_init.py b/tests/components/nyt_games/test_init.py index e8286066319..2e1a8c92f90 100644 --- a/tests/components/nyt_games/test_init.py +++ b/tests/components/nyt_games/test_init.py @@ -22,8 +22,9 @@ async def test_device_info( ) -> 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 == snapshot + for entity in ("wordle", "spelling_bee", "connections"): + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"{mock_config_entry.unique_id}_{entity}")} + ) + assert device_entry is not None + assert device_entry == snapshot(name=f"device_{entity}") diff --git a/tests/components/nyt_games/test_sensor.py b/tests/components/nyt_games/test_sensor.py index 198164b56f1..3866b6afab0 100644 --- a/tests/components/nyt_games/test_sensor.py +++ b/tests/components/nyt_games/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory from nyt_games import NYTGamesError +import pytest from syrupy import SnapshotAssertion from homeassistant.const import STATE_UNAVAILABLE @@ -16,6 +17,7 @@ from . import setup_integration from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -44,7 +46,7 @@ async def test_updating_exception( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("sensor.nytgames_wordles_played").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.wordle_played").state == STATE_UNAVAILABLE mock_nyt_games_client.get_latest_stats.side_effect = None @@ -52,4 +54,4 @@ async def test_updating_exception( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("sensor.nytgames_wordles_played").state != STATE_UNAVAILABLE + assert hass.states.get("sensor.wordle_played").state != STATE_UNAVAILABLE From 636ea82bf17f9f0d94e8a8c1582969579a9f367c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Sep 2024 23:19:06 +0200 Subject: [PATCH 1143/1309] Add Aqara brand (#126658) --- homeassistant/brands/aqara.json | 5 +++++ homeassistant/generated/integrations.json | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 homeassistant/brands/aqara.json diff --git a/homeassistant/brands/aqara.json b/homeassistant/brands/aqara.json new file mode 100644 index 00000000000..672a8350c63 --- /dev/null +++ b/homeassistant/brands/aqara.json @@ -0,0 +1,5 @@ +{ + "domain": "aqara", + "name": "Aqara", + "iot_standards": ["matter", "zigbee"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9ed6ba531da..423f239ce2d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -418,6 +418,13 @@ "config_flow": true, "iot_class": "local_polling" }, + "aqara": { + "name": "Aqara", + "iot_standards": [ + "matter", + "zigbee" + ] + }, "aquacell": { "name": "AquaCell", "integration_type": "device", From 242a3c66167758707ba399227db06b4e5121fa64 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Sep 2024 17:13:12 -0500 Subject: [PATCH 1144/1309] Bump google-generativeai to 0.8.2 (#126696) changelog: https://github.com/google-gemini/generative-ai-python/compare/v0.7.2...v0.8.2 --- .../components/google_generative_ai_conversation/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_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index a15da4906f8..f390b1f83e9 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["google-generativeai==0.7.2"] + "requirements": ["google-generativeai==0.8.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8f46fb0203d..75a31aee56e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1001,7 +1001,7 @@ google-cloud-speech==2.27.0 google-cloud-texttospeech==2.17.2 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.7.2 +google-generativeai==0.8.2 # homeassistant.components.nest google-nest-sdm==5.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e7dd29c64c..060f235f1f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -851,7 +851,7 @@ google-cloud-speech==2.27.0 google-cloud-texttospeech==2.17.2 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.7.2 +google-generativeai==0.8.2 # homeassistant.components.nest google-nest-sdm==5.0.1 From e10d73104944c6c1c5660ee39f8f3b6bce063670 Mon Sep 17 00:00:00 2001 From: Manu Date: Wed, 25 Sep 2024 04:27:20 +0200 Subject: [PATCH 1145/1309] Update snapshot for Bring tests (#126699) --- .../bring/snapshots/test_diagnostics.ambr | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/components/bring/snapshots/test_diagnostics.ambr b/tests/components/bring/snapshots/test_diagnostics.ambr index db0801447e1..6d830a12133 100644 --- a/tests/components/bring/snapshots/test_diagnostics.ambr +++ b/tests/components/bring/snapshots/test_diagnostics.ambr @@ -7,6 +7,14 @@ 'purchase': list([ dict({ 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), ]), 'itemId': 'Paprika', 'specification': 'Rot', @@ -14,6 +22,14 @@ }), dict({ 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), ]), 'itemId': 'Pouletbrüstli', 'specification': 'Bio', @@ -39,6 +55,14 @@ 'purchase': list([ dict({ 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), ]), 'itemId': 'Paprika', 'specification': 'Rot', @@ -46,6 +70,14 @@ }), dict({ 'attributes': list([ + dict({ + 'content': dict({ + 'convenient': True, + 'discounted': True, + 'urgent': True, + }), + 'type': 'PURCHASE_CONDITIONS', + }), ]), 'itemId': 'Pouletbrüstli', 'specification': 'Bio', From 1adaaf49cc10e939b93f62b1ae9b61a7bb8363ab Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Sep 2024 07:28:29 +0200 Subject: [PATCH 1146/1309] Add specific EntityDescription to describe device tracker entities (#126586) * Add TrackerEntityDescription to describe tracker entities * Improve * Adjust components * Add ScannerEntityDescription * Simplify * Revert * Set TrackerEntity default source type to SourceType.GPS * Fix rebase * Adjust default * Remove source_type from EntityDescription * Fix rebase * Docstring * Remove BaseTrackerEntityDescription --- .../components/device_tracker/__init__.py | 2 ++ .../components/device_tracker/config_entry.py | 12 ++++++++++- .../components/renault/device_tracker.py | 20 ++++++++++++++++--- .../components/starlink/device_tracker.py | 10 +++++----- .../components/unifi/device_tracker.py | 5 ++++- pylint/plugins/hass_enforce_class_module.py | 8 +++++++- 6 files changed, 46 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 92c961eb148..28991483cda 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -16,7 +16,9 @@ from homeassistant.loader import bind_hass from .config_entry import ( # noqa: F401 ScannerEntity, + ScannerEntityDescription, TrackerEntity, + TrackerEntityDescription, async_setup_entry, async_unload_entry, ) diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 8fbd85ae288..fe2b4aa4369 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -24,7 +24,7 @@ from homeassistant.helpers.device_registry import ( EventDeviceRegistryUpdatedData, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.typing import StateType @@ -198,6 +198,10 @@ class BaseTrackerEntity(Entity): return attr +class TrackerEntityDescription(EntityDescription, frozen_or_thawed=True): + """A class that describes tracker entities.""" + + CACHED_TRACKER_PROPERTIES_WITH_ATTR_ = { "latitude", "location_accuracy", @@ -211,6 +215,7 @@ class TrackerEntity( ): """Base class for a tracked device.""" + entity_description: TrackerEntityDescription _attr_latitude: float | None = None _attr_location_accuracy: int = 0 _attr_location_name: str | None = None @@ -285,6 +290,10 @@ class TrackerEntity( return attr +class ScannerEntityDescription(EntityDescription, frozen_or_thawed=True): + """A class that describes tracker entities.""" + + CACHED_SCANNER_PROPERTIES_WITH_ATTR_ = { "ip_address", "mac_address", @@ -297,6 +306,7 @@ class ScannerEntity( ): """Base class for a tracked device that is on a scanned network.""" + entity_description: ScannerEntityDescription _attr_hostname: str | None = None _attr_ip_address: str | None = None _attr_mac_address: str | None = None diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 1fde6c80cd6..2f7aeda5c39 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -2,9 +2,14 @@ from __future__ import annotations +from dataclasses import dataclass + from renault_api.kamereon.models import KamereonVehicleLocationData -from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityDescription, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -12,6 +17,13 @@ from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription +@dataclass(frozen=True, kw_only=True) +class RenaultTrackerEntityDescription( + TrackerEntityDescription, RenaultDataEntityDescription +): + """Class describing Renault tracker entities.""" + + async def async_setup_entry( hass: HomeAssistant, config_entry: RenaultConfigEntry, @@ -32,6 +44,8 @@ class RenaultDeviceTracker( ): """Mixin for device tracker specific attributes.""" + entity_description: RenaultTrackerEntityDescription + @property def latitude(self) -> float | None: """Return latitude value of the device.""" @@ -43,8 +57,8 @@ class RenaultDeviceTracker( return self.coordinator.data.gpsLongitude if self.coordinator.data else None -DEVICE_TRACKER_TYPES: tuple[RenaultDataEntityDescription, ...] = ( - RenaultDataEntityDescription( +DEVICE_TRACKER_TYPES: tuple[RenaultTrackerEntityDescription, ...] = ( + RenaultTrackerEntityDescription( key="location", coordinator="location", translation_key="location", diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index 13861823722..5174be19760 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -4,10 +4,12 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any -from homeassistant.components.device_tracker import TrackerEntity +from homeassistant.components.device_tracker import ( + TrackerEntity, + TrackerEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_ALTITUDE, DOMAIN @@ -28,9 +30,7 @@ async def async_setup_entry( @dataclass(frozen=True, kw_only=True) -class StarlinkDeviceTrackerEntityDescription( # pylint: disable=hass-enforce-class-module - EntityDescription -): +class StarlinkDeviceTrackerEntityDescription(TrackerEntityDescription): """Describes a Starlink button entity.""" latitude_fn: Callable[[StarlinkData], float] diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 5cdb3488367..c6694fce109 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -21,6 +21,7 @@ from aiounifi.models.event import Event, EventKey from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ScannerEntity, + ScannerEntityDescription, ) from homeassistant.core import Event as core_Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -141,7 +142,9 @@ def async_device_heartbeat_timedelta_fn(hub: UnifiHub, obj_id: str) -> timedelta @dataclass(frozen=True, kw_only=True) -class UnifiTrackerEntityDescription(UnifiEntityDescription[HandlerT, ApiItemT]): +class UnifiTrackerEntityDescription( + UnifiEntityDescription[HandlerT, ApiItemT], ScannerEntityDescription +): """Class describing UniFi device tracker entity.""" heartbeat_timedelta_fn: Callable[[UnifiHub, str], timedelta] diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index 95527126a30..2320a4af8b7 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -36,7 +36,13 @@ _MODULES: dict[str, set[str]] = { "cover": {"CoverEntity", "CoverEntityDescription"}, "date": {"DateEntity", "DateEntityDescription"}, "datetime": {"DateTimeEntity", "DateTimeEntityDescription"}, - "device_tracker": {"DeviceTrackerEntity", "ScannerEntity", "TrackerEntity"}, + "device_tracker": { + "DeviceTrackerEntity", + "ScannerEntity", + "ScannerEntityDescription", + "TrackerEntity", + "TrackerEntityDescription", + }, "event": {"EventEntity", "EventEntityDescription"}, "fan": {"FanEntity", "FanEntityDescription"}, "geo_location": {"GeolocationEvent"}, From e351f8ba0705e09797b45b900d7f69b7e0112878 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Sep 2024 23:45:58 -0700 Subject: [PATCH 1147/1309] Bump python-google-photos-library-api to 0.12.1 (#126709) --- homeassistant/components/google_photos/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_photos/manifest.json b/homeassistant/components/google_photos/manifest.json index 28cd2512432..9a2e7bc13f4 100644 --- a/homeassistant/components/google_photos/manifest.json +++ b/homeassistant/components/google_photos/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_photos", "iot_class": "cloud_polling", "loggers": ["google_photos_library_api"], - "requirements": ["google-photos-library-api==0.12.0"] + "requirements": ["google-photos-library-api==0.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 75a31aee56e..7f9a0f22117 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ google-generativeai==0.8.2 google-nest-sdm==5.0.1 # homeassistant.components.google_photos -google-photos-library-api==0.12.0 +google-photos-library-api==0.12.1 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 060f235f1f5..20ddf8ad892 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -857,7 +857,7 @@ google-generativeai==0.8.2 google-nest-sdm==5.0.1 # homeassistant.components.google_photos -google-photos-library-api==0.12.0 +google-photos-library-api==0.12.1 # homeassistant.components.google_travel_time googlemaps==2.5.1 From 7e41b40441135bdb882dfc425d3c29ebc1a2d243 Mon Sep 17 00:00:00 2001 From: Tal Atlas Date: Wed, 25 Sep 2024 02:47:53 -0400 Subject: [PATCH 1148/1309] Update Tuya integration with target distance (#126700) Co-authored-by: Franck Nijhof --- homeassistant/components/tuya/binary_sensor.py | 2 +- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/number.py | 7 +++++++ homeassistant/components/tuya/strings.json | 3 +++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 4759a24905a..a8c9157caa7 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -150,7 +150,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "hps": ( TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, - device_class=BinarySensorDeviceClass.MOTION, + device_class=BinarySensorDeviceClass.OCCUPANCY, on_value="presence", ), ), diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index eb56761d26a..08bdef474ef 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -326,6 +326,7 @@ class DPCode(StrEnum): SWITCH_USB6 = "switch_usb6" # USB 6 SWITCH_VERTICAL = "switch_vertical" # Vertical swing flap switch SWITCH_VOICE = "switch_voice" # Voice switch + TARGET_DIS_CLOSEST = "target_dis_closest" # Closest target distance TEMP = "temp" # Temperature setting TEMP_BOILING_C = "temp_boiling_c" TEMP_BOILING_F = "temp_boiling_f" diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index d989cad07bb..d2e381d9982 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -87,13 +87,20 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.NEAR_DETECTION, translation_key="near_detection", + device_class=NumberDeviceClass.DISTANCE, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.FAR_DETECTION, translation_key="far_detection", + device_class=NumberDeviceClass.DISTANCE, entity_category=EntityCategory.CONFIG, ), + NumberEntityDescription( + key=DPCode.TARGET_DIS_CLOSEST, + translation_key="target_dis_closest", + device_class=NumberDeviceClass.DISTANCE, + ), ), # Coffee maker # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 865fbaffbbe..0f005821cbb 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -146,6 +146,9 @@ "far_detection": { "name": "Far detection" }, + "target_dis_closest": { + "name": "Clostest target distance" + }, "water_level": { "name": "Water level" }, From a3c2a7e1e0d041398dc3c22390d0a24ab0ec3abb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:56:42 +0200 Subject: [PATCH 1149/1309] Remove redundant source_type property from TrackerEntities (#126717) --- .../components/bmw_connected_drive/device_tracker.py | 7 +------ homeassistant/components/geofency/device_tracker.py | 7 +------ homeassistant/components/gpslogger/device_tracker.py | 7 +------ .../components/husqvarna_automower/device_tracker.py | 7 +------ homeassistant/components/icloud/device_tracker.py | 7 +------ homeassistant/components/locative/device_tracker.py | 7 +------ homeassistant/components/mobile_app/device_tracker.py | 6 ------ homeassistant/components/mysensors/device_tracker.py | 7 +------ homeassistant/components/starline/device_tracker.py | 7 +------ homeassistant/components/subaru/device_tracker.py | 6 ------ homeassistant/components/tado/device_tracker.py | 6 ------ .../components/tesla_fleet/device_tracker.py | 6 ------ homeassistant/components/teslemetry/device_tracker.py | 6 ------ homeassistant/components/tessie/device_tracker.py | 6 ------ homeassistant/components/tile/device_tracker.py | 11 +---------- homeassistant/components/traccar/device_tracker.py | 7 +------ .../components/traccar_server/device_tracker.py | 7 +------ .../components/volvooncall/device_tracker.py | 7 +------ 18 files changed, 12 insertions(+), 112 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 8266576e1d5..977fd531e2c 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -7,7 +7,7 @@ from typing import Any from bimmer_connected.vehicle import MyBMWVehicle -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -84,8 +84,3 @@ class BMWDeviceTracker(BMWBaseEntity, TrackerEntity): and self.vehicle.vehicle_location.location else None ) - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index b72ad4bc04c..35752ffe9c4 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -1,6 +1,6 @@ """Support for the Geofency device tracker platform.""" -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant, callback @@ -97,11 +97,6 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): name=self._name, ) - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - async def async_added_to_hass(self) -> None: """Register state update callback.""" await super().async_added_to_hass() diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index b1c7ad9091f..8801acf8c2a 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -1,6 +1,6 @@ """Support for the GPSLogger device tracking.""" -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -117,11 +117,6 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): name=self._name, ) - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - async def async_added_to_hass(self) -> None: """Register state update callback.""" await super().async_added_to_hass() diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py index 66997e1e86e..5e84b7cc67d 100644 --- a/homeassistant/components/husqvarna_automower/device_tracker.py +++ b/homeassistant/components/husqvarna_automower/device_tracker.py @@ -1,6 +1,6 @@ """Creates the device tracker entity for the mower.""" -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -37,11 +37,6 @@ class AutomowerDeviceTrackerEntity(AutomowerBaseEntity, TrackerEntity): super().__init__(mower_id, coordinator) self._attr_unique_id = mower_id - @property - def source_type(self) -> SourceType: - """Return the source type of the device.""" - return SourceType.GPS - @property def latitude(self) -> float: """Return latitude value of the device.""" diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 48070a7f153..11a18a10020 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -87,11 +87,6 @@ class IcloudTrackerEntity(TrackerEntity): """Return the battery level of the device.""" return self._device.battery_level - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - @property def icon(self) -> str: """Return the icon.""" diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index 0b5cb32c22b..133f59d235a 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -1,6 +1,6 @@ """Support for the Locative platform.""" -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -59,11 +59,6 @@ class LocativeEntity(TrackerEntity): """Return the name of the device.""" return self._name - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - async def async_added_to_hass(self) -> None: """Register state update callback.""" self._unsub_dispatcher = async_dispatcher_connect( diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 2c7a4147811..7e84930e2e9 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -5,7 +5,6 @@ from homeassistant.components.device_tracker import ( ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, - SourceType, TrackerEntity, ) from homeassistant.config_entries import ConfigEntry @@ -103,11 +102,6 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): """Return the name of the device.""" return self._entry.data[ATTR_DEVICE_NAME] - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - @property def device_info(self): """Return the device info.""" diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index af684ea195d..f36adb41311 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback @@ -60,11 +60,6 @@ class MySensorsDeviceTracker(MySensorsChildEntity, TrackerEntity): """Return longitude value of the device.""" return self._longitude - @property - def source_type(self) -> SourceType: - """Return the source type of the device.""" - return SourceType.GPS - @callback def _async_update(self) -> None: """Update the controller with the latest value from a device.""" diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py index 11b0d433787..610317b72c3 100644 --- a/homeassistant/components/starline/device_tracker.py +++ b/homeassistant/components/starline/device_tracker.py @@ -1,6 +1,6 @@ """StarLine device tracker.""" -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -56,8 +56,3 @@ class StarlineDeviceTracker(StarlineEntity, TrackerEntity, RestoreEntity): def longitude(self): """Return longitude value of the device.""" return self._device.position["y"] - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py index 5d25056312e..d406234c36e 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -6,7 +6,6 @@ from typing import Any from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP -from homeassistant.components.device_tracker import SourceType from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -78,11 +77,6 @@ class SubaruDeviceTracker( """Return longitude value of the vehicle.""" return self.coordinator.data[self.vin][VEHICLE_STATUS].get(LONGITUDE) - @property - def source_type(self) -> SourceType: - """Return the source type of the vehicle.""" - return SourceType.GPS - @property def available(self) -> bool: """Return if entity is available.""" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index b4456591b49..08e610aead2 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -6,7 +6,6 @@ import logging from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, - SourceType, TrackerEntity, ) from homeassistant.const import STATE_HOME, STATE_NOT_HOME @@ -170,8 +169,3 @@ class TadoDeviceTrackerEntity(TrackerEntity): def longitude(self) -> None: """Return longitude value of the device.""" return None - - @property - def source_type(self) -> SourceType: - """Return the source type.""" - return SourceType.GPS diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index d27262c842d..37cad4cea32 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.components.device_tracker import SourceType from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -64,11 +63,6 @@ class TeslaFleetDeviceTrackerEntity( """Return longitude value of the device.""" return self._attr_longitude - @property - def source_type(self) -> SourceType: - """Return the source type of the device tracker.""" - return SourceType.GPS - class TeslaFleetDeviceTrackerLocationEntity(TeslaFleetDeviceTrackerEntity): """Vehicle Location device tracker Class.""" diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 399d28533f1..6577bcf88d6 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.components.device_tracker import SourceType from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -62,11 +61,6 @@ class TeslemetryDeviceTrackerEntity(TeslemetryVehicleEntity, TrackerEntity): """Return longitude value of the device.""" return self.get(self.lon_key) - @property - def source_type(self) -> SourceType: - """Return the source type of the device tracker.""" - return SourceType.GPS - class TeslemetryDeviceTrackerLocationEntity(TeslemetryDeviceTrackerEntity): """Vehicle location device tracker class.""" diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py index 20ab5aa829e..df74cd2a7a7 100644 --- a/homeassistant/components/tessie/device_tracker.py +++ b/homeassistant/components/tessie/device_tracker.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.components.device_tracker import SourceType from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -43,11 +42,6 @@ class TessieDeviceTrackerEntity(TessieEntity, TrackerEntity): """Initialize the device tracker.""" super().__init__(vehicle, self.key) - @property - def source_type(self) -> SourceType: - """Return the source type of the device tracker.""" - return SourceType.GPS - class TessieDeviceTrackerLocationEntity(TessieDeviceTrackerEntity): """Vehicle Location Device Tracker Class.""" diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index 270922b91d5..35d481788e7 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -6,11 +6,7 @@ import logging from pytile.tile import Tile -from homeassistant.components.device_tracker import ( - AsyncSeeCallback, - SourceType, - TrackerEntity, -) +from homeassistant.components.device_tracker import AsyncSeeCallback, TrackerEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -131,11 +127,6 @@ class TileDeviceTracker(CoordinatorEntity[DataUpdateCoordinator[None]], TrackerE return None return self._tile.longitude - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - @callback def _handle_coordinator_update(self) -> None: """Respond to a DataUpdateCoordinator update.""" diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index c13f1970321..9d0e3f378d0 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import timedelta import logging -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -163,11 +163,6 @@ class TraccarEntity(TrackerEntity, RestoreEntity): identifiers={(DOMAIN, self._unique_id)}, ) - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - async def async_added_to_hass(self) -> None: """Register state update callback.""" await super().async_added_to_hass() diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py index e7dba3ad99d..9e5a3c0ee9f 100644 --- a/homeassistant/components/traccar_server/device_tracker.py +++ b/homeassistant/components/traccar_server/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -57,8 +57,3 @@ class TraccarServerDeviceTracker(TraccarServerEntity, TrackerEntity): def location_accuracy(self) -> int: """Return the gps accuracy of the device.""" return self.traccar_position["accuracy"] - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py index 1f79bea7290..96fe5a644bb 100644 --- a/homeassistant/components/volvooncall/device_tracker.py +++ b/homeassistant/components/volvooncall/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from volvooncall.dashboard import Instrument -from homeassistant.components.device_tracker import SourceType, TrackerEntity +from homeassistant.components.device_tracker import TrackerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -61,11 +61,6 @@ class VolvoTrackerEntity(VolvoEntity, TrackerEntity): _, longitude = self._get_pos() return longitude - @property - def source_type(self) -> SourceType: - """Return the source type (GPS).""" - return SourceType.GPS - def _get_pos(self) -> tuple[float, float]: volvo_data = self.coordinator.volvo_data instrument = volvo_data.instrument( From b48c439bff308606e60957da661962b2bed0790a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:58:54 +0200 Subject: [PATCH 1150/1309] Remove redundant source_type property from ScannerEntities (#126716) --- homeassistant/components/asuswrt/device_tracker.py | 7 +------ homeassistant/components/freebox/device_tracker.py | 7 +------ homeassistant/components/fritz/device_tracker.py | 7 +------ homeassistant/components/huawei_lte/device_tracker.py | 6 ------ homeassistant/components/keenetic_ndms2/device_tracker.py | 6 ------ homeassistant/components/mikrotik/device_tracker.py | 6 ------ homeassistant/components/netgear/device_tracker.py | 7 +------ homeassistant/components/nmap_tracker/device_tracker.py | 7 +------ homeassistant/components/ping/device_tracker.py | 6 ------ .../components/ruckus_unleashed/device_tracker.py | 7 +------ homeassistant/components/tplink_omada/device_tracker.py | 7 +------ .../components/vodafone_station/device_tracker.py | 7 +------ homeassistant/components/zha/device_tracker.py | 7 +------ 13 files changed, 9 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index d2330801bd5..95d2e4c8000 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -2,7 +2,7 @@ from __future__ import annotations -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -71,11 +71,6 @@ class AsusWrtDevice(ScannerEntity): """Return true if the device is connected to the network.""" return self._device.is_connected - @property - def source_type(self) -> SourceType: - """Return the source type.""" - return SourceType.ROUTER - @property def hostname(self) -> str | None: """Return the hostname of device.""" diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py index 0f5b7eb4837..1fa37ebc270 100644 --- a/homeassistant/components/freebox/device_tracker.py +++ b/homeassistant/components/freebox/device_tracker.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Any -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -98,11 +98,6 @@ class FreeboxDevice(ScannerEntity): """Return true if the device is connected to the network.""" return self._active - @property - def source_type(self) -> SourceType: - """Return the source type.""" - return SourceType.ROUTER - @callback def async_on_demand_update(self) -> None: """Update state.""" diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 6bf182458e0..d1270a0510c 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -5,7 +5,7 @@ from __future__ import annotations import datetime import logging -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -112,8 +112,3 @@ class FritzBoxTracker(FritzDeviceBase, ScannerEntity): if device.ssid: attrs["ssid"] = device.ssid return attrs - - @property - def source_type(self) -> SourceType: - """Return tracker source type.""" - return SourceType.ROUTER diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 6a05b237160..df849d4f712 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -11,7 +11,6 @@ from stringcase import snakecase from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ScannerEntity, - SourceType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -195,11 +194,6 @@ class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): def _device_unique_id(self) -> str: return self.mac_address - @property - def source_type(self) -> SourceType: - """Return SourceType.ROUTER.""" - return SourceType.ROUTER - @property def ip_address(self) -> str | None: """Return the primary ip address of the device.""" diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index 34c5cb502c6..efd2a88b1f8 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -9,7 +9,6 @@ from ndms2_client import Device from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ScannerEntity, - SourceType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -103,11 +102,6 @@ class KeeneticTracker(ScannerEntity): < self._router.consider_home_interval ) - @property - def source_type(self) -> SourceType: - """Return the source type of the client.""" - return SourceType.ROUTER - @property def name(self) -> str: """Return the name of the device.""" diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index aa19da01369..c2d9e0d2f33 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -7,7 +7,6 @@ from typing import Any from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER, ScannerEntity, - SourceType, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -94,11 +93,6 @@ class MikrotikDataUpdateCoordinatorTracker( return True return False - @property - def source_type(self) -> SourceType: - """Return the source type of the client.""" - return SourceType.ROUTER - @property def hostname(self) -> str: """Return the hostname of the client.""" diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index ee3d010e443..b17430d2abb 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -81,11 +81,6 @@ class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): """Return true if the device is connected to the router.""" return self._active - @property - def source_type(self) -> SourceType: - """Return the source type.""" - return SourceType.ROUTER - @property def ip_address(self) -> str: """Return the IP address.""" diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 3f07926eaef..c8e7e7c25ea 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -95,11 +95,6 @@ class NmapTrackerEntity(ScannerEntity): return None return short_hostname(self._device.hostname) - @property - def source_type(self) -> SourceType: - """Return tracker source type.""" - return SourceType.ROUTER - @callback def async_process_update(self, online: bool) -> None: """Update device.""" diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index ce7cc4522a0..29a4e922234 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -8,7 +8,6 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ScannerEntity, - SourceType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -57,11 +56,6 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) """Return a unique ID.""" return self.config_entry.entry_id - @property - def source_type(self) -> SourceType: - """Return the source type which is router.""" - return SourceType.ROUTER - @property def is_connected(self) -> bool: """Return true if ping returns is_alive or considered home.""" diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 704272bf4c9..8a5e8b79294 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -121,8 +121,3 @@ class RuckusDevice(CoordinatorEntity, ScannerEntity): def is_connected(self) -> bool: """Return true if the device is connected to the network.""" return self._mac in self.coordinator.data[KEY_SYS_CLIENTS] - - @property - def source_type(self) -> SourceType: - """Return the source type.""" - return SourceType.ROUTER diff --git a/homeassistant/components/tplink_omada/device_tracker.py b/homeassistant/components/tplink_omada/device_tracker.py index 12c519b883f..e5a85186f24 100644 --- a/homeassistant/components/tplink_omada/device_tracker.py +++ b/homeassistant/components/tplink_omada/device_tracker.py @@ -4,7 +4,7 @@ import logging from tplink_omada_client.clients import OmadaWirelessClient -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -61,11 +61,6 @@ class OmadaClientScannerEntity( self._client_id = client_id self._attr_name = display_name - @property - def source_type(self) -> SourceType: - """Return the source type of the device.""" - return SourceType.ROUTER - def _do_update(self) -> None: self._client_details = self.coordinator.data.get(self._client_id) diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py index 85ad834cd23..004614f578d 100644 --- a/homeassistant/components/vodafone_station/device_tracker.py +++ b/homeassistant/components/vodafone_station/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations from aiovodafone import VodafoneStationDevice -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -91,11 +91,6 @@ class VodafoneStationTracker(CoordinatorEntity[VodafoneStationRouter], ScannerEn """Return true if the device is connected to the network.""" return self._device_info.home - @property - def source_type(self) -> SourceType: - """Return the source type.""" - return SourceType.ROUTER - @property def hostname(self) -> str | None: """Return the hostname of device.""" diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 247219777f4..fc374f6c44d 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -4,7 +4,7 @@ from __future__ import annotations import functools -from homeassistant.components.device_tracker import ScannerEntity, SourceType +from homeassistant.components.device_tracker import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -53,11 +53,6 @@ class ZHADeviceScannerEntity(ScannerEntity, ZHAEntity): """Return true if the device is connected to the network.""" return self.entity_data.entity.is_connected - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.ROUTER - @property def battery_level(self) -> int | None: """Return the battery level of the device. From 1c33561fbf633d6b49550abac81d78532d7ae206 Mon Sep 17 00:00:00 2001 From: Oliver <10700296+ol-iver@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:59:42 +0200 Subject: [PATCH 1151/1309] Update `denonavr` to `v1.0.0` (#126703) --- homeassistant/components/denonavr/manifest.json | 2 +- homeassistant/components/denonavr/media_player.py | 1 - homeassistant/components/denonavr/receiver.py | 7 ++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 9188009bde5..eff70b94a18 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.6"], + "requirements": ["denonavr==1.0.0"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index a6a94404fd3..03d1b00cfaf 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -125,7 +125,6 @@ async def async_setup_entry( unique_id = f"{config_entry.unique_id}-{receiver_zone.zone}" else: unique_id = f"{config_entry.entry_id}-{receiver_zone.zone}" - await receiver_zone.async_setup() entities.append( DenonDevice( receiver_zone, diff --git a/homeassistant/components/denonavr/receiver.py b/homeassistant/components/denonavr/receiver.py index abee5ed74d2..ebe09f518fb 100644 --- a/homeassistant/components/denonavr/receiver.py +++ b/homeassistant/components/denonavr/receiver.py @@ -93,9 +93,10 @@ class ConnectDenonAVR: await receiver.async_setup() # Do an initial update if telnet is used. if self._use_telnet: - await receiver.async_update() - if self._update_audyssey: - await receiver.async_update_audyssey() + for zone in receiver.zones.values(): + await zone.async_update() + if self._update_audyssey: + await zone.async_update_audyssey() await receiver.async_telnet_connect() self._receiver = receiver diff --git a/requirements_all.txt b/requirements_all.txt index 7f9a0f22117..f6c5485c801 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -738,7 +738,7 @@ deluge-client==1.10.2 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.6 +denonavr==1.0.0 # homeassistant.components.devialet devialet==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20ddf8ad892..cb577187ab5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -628,7 +628,7 @@ deluge-client==1.10.2 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.6 +denonavr==1.0.0 # homeassistant.components.devialet devialet==1.4.5 From c021074db429aabfd807153ed67409c7ce0feb1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Sep 2024 08:59:56 +0200 Subject: [PATCH 1152/1309] Bump github/codeql-action from 3.26.8 to 3.26.9 (#126715) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3568ad8bc7a..9370e689fc4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v4.1.7 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.26.8 + uses: github/codeql-action/init@v3.26.9 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.26.8 + uses: github/codeql-action/analyze@v3.26.9 with: category: "/language:python" From a5a54ab8706d0695e40b671b2d10efdb4bb9d513 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Sep 2024 02:02:00 -0500 Subject: [PATCH 1153/1309] Bump zeroconf to 0.135.0 (#126706) --- homeassistant/components/zeroconf/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/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 1176be80839..8246085e405 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.134.0"] + "requirements": ["zeroconf==0.135.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d464d04e7fa..22255685aa2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -64,7 +64,7 @@ voluptuous-openapi==0.0.5 voluptuous-serialize==2.6.0 voluptuous==0.15.2 yarl==1.12.1 -zeroconf==0.134.0 +zeroconf==0.135.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index f6c5485c801..6758787e24e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3041,7 +3041,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.134.0 +zeroconf==0.135.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb577187ab5..fa1464399d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2421,7 +2421,7 @@ yt-dlp==2024.08.06 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.134.0 +zeroconf==0.135.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From eccb7bb55ff1033e71d8fdfcee1bf2000359846c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 25 Sep 2024 17:05:33 +1000 Subject: [PATCH 1154/1309] Add Storm watch active to Tesla integrations (#126704) --- .../components/tesla_fleet/binary_sensor.py | 1 + .../components/tesla_fleet/icons.json | 6 ++ .../components/tesla_fleet/strings.json | 3 + .../components/teslemetry/binary_sensor.py | 1 + .../components/teslemetry/icons.json | 6 ++ .../components/teslemetry/strings.json | 3 + .../components/tessie/binary_sensor.py | 1 + homeassistant/components/tessie/icons.json | 6 ++ homeassistant/components/tessie/strings.json | 3 + .../snapshots/test_binary_sensors.ambr | 59 +++++++++++++++++++ .../snapshots/test_binary_sensors.ambr | 59 +++++++++++++++++++ .../tessie/snapshots/test_binary_sensors.ambr | 46 +++++++++++++++ 12 files changed, 194 insertions(+) diff --git a/homeassistant/components/tesla_fleet/binary_sensor.py b/homeassistant/components/tesla_fleet/binary_sensor.py index 2469092513a..b92ef9233d1 100644 --- a/homeassistant/components/tesla_fleet/binary_sensor.py +++ b/homeassistant/components/tesla_fleet/binary_sensor.py @@ -165,6 +165,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetBinarySensorEntityDescription, ...] = ( ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription(key="backup_capable"), BinarySensorEntityDescription(key="grid_services_active"), + BinarySensorEntityDescription(key="storm_mode_active"), ) diff --git a/homeassistant/components/tesla_fleet/icons.json b/homeassistant/components/tesla_fleet/icons.json index aa5c1c920d4..3e842c0997a 100644 --- a/homeassistant/components/tesla_fleet/icons.json +++ b/homeassistant/components/tesla_fleet/icons.json @@ -7,6 +7,12 @@ "on": "mdi:hvac" } }, + "storm_mode_active": { + "default": "mdi:weather-sunny", + "state": { + "on": "mdi:weather-lightning-rainy" + } + }, "vehicle_state_is_user_present": { "state": { "off": "mdi:account-remove-outline", diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 8f7f91b4960..09040de13b0 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -64,6 +64,9 @@ "state": { "name": "Status" }, + "storm_mode_active": { + "name": "Storm watch active" + }, "vehicle_state_dashcam_state": { "name": "Dashcam" }, diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index e3f9a5716f6..b51a67a0b4e 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -165,6 +165,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription(key="backup_capable"), BinarySensorEntityDescription(key="grid_services_active"), + BinarySensorEntityDescription(key="storm_mode_active"), ) diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 501755bb691..6559acf89dc 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -7,6 +7,12 @@ "on": "mdi:hvac" } }, + "storm_mode_active": { + "default": "mdi:weather-sunny", + "state": { + "on": "mdi:weather-lightning-rainy" + } + }, "vehicle_state_is_user_present": { "state": { "off": "mdi:account-remove-outline", diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index b8d07c992a8..b7ba06fbce4 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -56,6 +56,9 @@ "state": { "name": "Status" }, + "storm_mode_active": { + "name": "Storm watch active" + }, "vehicle_state_dashcam_state": { "name": "Dashcam" }, diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py index f425cd10134..fd6565b62b7 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -163,6 +163,7 @@ VEHICLE_DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription(key="backup_capable"), BinarySensorEntityDescription(key="grid_services_active"), + BinarySensorEntityDescription(key="storm_mode_active"), ) diff --git a/homeassistant/components/tessie/icons.json b/homeassistant/components/tessie/icons.json index a967c70e285..0ae087f98e2 100644 --- a/homeassistant/components/tessie/icons.json +++ b/homeassistant/components/tessie/icons.json @@ -22,6 +22,12 @@ "climate_state_auto_steering_wheel_heat": { "default": "mdi:steering" }, + "storm_mode_active": { + "default": "mdi:weather-sunny", + "state": { + "on": "mdi:weather-lightning-rainy" + } + }, "grid_services_power": { "default": "mdi:transmission-tower" }, diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index df488523900..c7408df1ddb 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -391,6 +391,9 @@ "components_grid_services_enabled": { "name": "Grid services enabled" }, + "storm_mode_active": { + "name": "Storm watch active" + }, "grid_services_active": { "name": "Grid services active" }, diff --git a/tests/components/tesla_fleet/snapshots/test_binary_sensors.ambr b/tests/components/tesla_fleet/snapshots/test_binary_sensors.ambr index 05ef4879de6..479d647e1c7 100644 --- a/tests/components/tesla_fleet/snapshots/test_binary_sensors.ambr +++ b/tests/components/tesla_fleet/snapshots/test_binary_sensors.ambr @@ -137,6 +137,52 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.energy_site_storm_watch_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.energy_site_storm_watch_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Storm watch active', + 'platform': 'tesla_fleet', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storm_mode_active', + 'unique_id': '123456-storm_mode_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.energy_site_storm_watch_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Storm watch active', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_storm_watch_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor[binary_sensor.test_battery_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1252,6 +1298,19 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.energy_site_storm_watch_active-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Storm watch active', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_storm_watch_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/teslemetry/snapshots/test_binary_sensors.ambr b/tests/components/teslemetry/snapshots/test_binary_sensors.ambr index 6f35fe9da25..383db58b336 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensors.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensors.ambr @@ -137,6 +137,52 @@ 'state': 'off', }) # --- +# name: test_binary_sensor[binary_sensor.energy_site_storm_watch_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.energy_site_storm_watch_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Storm watch active', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storm_mode_active', + 'unique_id': '123456-storm_mode_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.energy_site_storm_watch_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Storm watch active', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_storm_watch_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor[binary_sensor.test_battery_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1252,6 +1298,19 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_refresh[binary_sensor.energy_site_storm_watch_active-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Storm watch active', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_storm_watch_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/tessie/snapshots/test_binary_sensors.ambr b/tests/components/tessie/snapshots/test_binary_sensors.ambr index e8912bb0e7f..6c0da044df2 100644 --- a/tests/components/tessie/snapshots/test_binary_sensors.ambr +++ b/tests/components/tessie/snapshots/test_binary_sensors.ambr @@ -137,6 +137,52 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.energy_site_storm_watch_active-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.energy_site_storm_watch_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Storm watch active', + 'platform': 'tessie', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'storm_mode_active', + 'unique_id': '123456-storm_mode_active', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.energy_site_storm_watch_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Energy Site Storm watch active', + }), + 'context': , + 'entity_id': 'binary_sensor.energy_site_storm_watch_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[binary_sensor.test_auto_seat_climate_left-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 2339211403d499fa4947dc5a32bbce5825af8228 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:30:57 +0200 Subject: [PATCH 1155/1309] Fix pytest-asyncio DeprecationWarning (#126718) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index d1ceb1f62f4..f2eb220cedf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -446,6 +446,7 @@ norecursedirs = [ log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s" log_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" filterwarnings = [ "error::sqlalchemy.exc.SAWarning", From 65abe1c8759725699d64eb5b6aecfad2332834e2 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:36:27 +0200 Subject: [PATCH 1156/1309] Add workaround to avoid blocking imports by dnspython (#121702) --- .../components/minecraft_server/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 0a9eee6a0d5..8f016e2de00 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -5,6 +5,10 @@ from __future__ import annotations import logging from typing import Any +import dns.rdata +import dns.rdataclass +import dns.rdatatype + from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ADDRESS, @@ -28,9 +32,19 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +def load_dnspython_rdata_classes() -> None: + """Load dnspython rdata classes used by mcstatus.""" + for rdtype in dns.rdatatype.RdataType: + if not dns.rdatatype.is_metatype(rdtype) or rdtype == dns.rdatatype.OPT: + dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call] + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" + # Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083) + hass.async_add_executor_job(load_dnspython_rdata_classes) + # Create API instance. api = MinecraftServer( hass, From dff0e2cc9f370c135929364eac3173a2b1a8c683 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Sep 2024 09:41:23 +0200 Subject: [PATCH 1157/1309] Move pylint decorator plugin and add tests (#126719) --- pylint/plugins/hass_decorator.py | 33 ++++++++++++ pylint/plugins/hass_enforce_type_hints.py | 15 +----- pyproject.toml | 1 + tests/pylint/conftest.py | 17 ++++++ tests/pylint/test_decorator.py | 64 +++++++++++++++++++++++ 5 files changed, 117 insertions(+), 13 deletions(-) create mode 100644 pylint/plugins/hass_decorator.py create mode 100644 tests/pylint/test_decorator.py diff --git a/pylint/plugins/hass_decorator.py b/pylint/plugins/hass_decorator.py new file mode 100644 index 00000000000..51bdd99cd2b --- /dev/null +++ b/pylint/plugins/hass_decorator.py @@ -0,0 +1,33 @@ +"""Plugin to check decorators.""" + +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + + +class HassDecoratorChecker(BaseChecker): + """Checker for decorators.""" + + name = "hass_decorator" + priority = -1 + msgs = { + "W7471": ( + "A coroutine function should not be decorated with @callback", + "hass-async-callback-decorator", + "Used when a coroutine function has an invalid @callback decorator", + ), + } + + def visit_asyncfunctiondef(self, node: nodes.AsyncFunctionDef) -> None: + """Apply checks on an AsyncFunctionDef node.""" + if ( + decoratornames := node.decoratornames() + ) and "homeassistant.core.callback" in decoratornames: + self.add_message("hass-async-callback-decorator", node=node) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassDecoratorChecker(linter)) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 65931a152e9..a837650f3b5 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -3093,11 +3093,6 @@ class HassTypeHintChecker(BaseChecker): "hass-consider-usefixtures-decorator", "Used when an argument type is None and could be a fixture", ), - "W7434": ( - "A coroutine function should not be decorated with @callback", - "hass-async-callback-decorator", - "Used when a coroutine function has an invalid @callback decorator", - ), } options = ( ( @@ -3200,14 +3195,6 @@ class HassTypeHintChecker(BaseChecker): self._check_function(function_node, match, annotations) checked_class_methods.add(function_node.name) - def visit_asyncfunctiondef(self, node: nodes.AsyncFunctionDef) -> None: - """Apply checks on an AsyncFunctionDef node.""" - if ( - decoratornames := node.decoratornames() - ) and "homeassistant.core.callback" in decoratornames: - self.add_message("hass-async-callback-decorator", node=node) - self.visit_functiondef(node) - def visit_functiondef(self, node: nodes.FunctionDef) -> None: """Apply relevant type hint checks on a FunctionDef node.""" annotations = _get_all_annotations(node) @@ -3247,6 +3234,8 @@ class HassTypeHintChecker(BaseChecker): continue self._check_function(node, match, annotations) + visit_asyncfunctiondef = visit_functiondef + def _check_function( self, node: nodes.FunctionDef, diff --git a/pyproject.toml b/pyproject.toml index f2eb220cedf..116fc5b74ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", + "hass_decorator", "hass_enforce_class_module", "hass_enforce_sorted_platforms", "hass_enforce_super_call", diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 5e8ed28da6b..8ae291ac0b7 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -121,3 +121,20 @@ def enforce_class_module_fixture(hass_enforce_class_module, linter) -> BaseCheck ) enforce_class_module_checker.module = "homeassistant.components.pylint_test" return enforce_class_module_checker + + +@pytest.fixture(name="hass_decorator", scope="package") +def hass_decorator_fixture() -> ModuleType: + """Fixture to provide a pylint plugin.""" + return _load_plugin_from_file( + "hass_imports", + "pylint/plugins/hass_decorator.py", + ) + + +@pytest.fixture(name="decorator_checker") +def decorator_checker_fixture(hass_decorator, linter) -> BaseChecker: + """Fixture to provide a pylint checker.""" + type_hint_checker = hass_decorator.HassDecoratorChecker(linter) + type_hint_checker.module = "homeassistant.components.pylint_test" + return type_hint_checker diff --git a/tests/pylint/test_decorator.py b/tests/pylint/test_decorator.py new file mode 100644 index 00000000000..05a443c1456 --- /dev/null +++ b/tests/pylint/test_decorator.py @@ -0,0 +1,64 @@ +"""Tests for pylint hass_enforce_type_hints plugin.""" + +from __future__ import annotations + +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import UNDEFINED +from pylint.testutils import MessageTest +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker + +from . import assert_adds_messages, assert_no_messages + + +def test_good_callback(linter: UnittestLinter, decorator_checker: BaseChecker) -> None: + """Test good `@callback` decorator.""" + code = """ + from homeassistant.core import callback + + @callback + def setup( + arg1, arg2 + ): + pass + """ + + root_node = astroid.parse(code) + walker = ASTWalker(linter) + walker.add_checker(decorator_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +def test_bad_callback(linter: UnittestLinter, decorator_checker: BaseChecker) -> None: + """Test bad `@callback` decorator.""" + code = """ + from homeassistant.core import callback + + @callback + async def setup( + arg1, arg2 + ): + pass + """ + + root_node = astroid.parse(code) + walker = ASTWalker(linter) + walker.add_checker(decorator_checker) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-async-callback-decorator", + line=5, + node=root_node.body[1], + args=None, + confidence=UNDEFINED, + col_offset=0, + end_line=5, + end_col_offset=15, + ), + ): + walker.walk(root_node) From 31d722f1ef2e842b35737f2d976b4d1c273a12be Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Sep 2024 09:46:32 +0200 Subject: [PATCH 1158/1309] Introduce snapshot testing to matter (#126693) * Introduce snapshot testing to matter * Introduce snapshot testing to matter --- tests/components/matter/conftest.py | 16 + .../matter/snapshots/test_binary_sensor.ambr | 376 +++++ .../matter/snapshots/test_sensor.ambr | 1209 +++++++++++++++++ tests/components/matter/test_binary_sensor.py | 53 +- tests/components/matter/test_sensor.py | 254 +--- 5 files changed, 1636 insertions(+), 272 deletions(-) create mode 100644 tests/components/matter/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/matter/snapshots/test_sensor.ambr diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index ef1c2ae59d9..d1df9687376 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -70,6 +70,22 @@ async def integration_fixture( return entry +@pytest.fixture( + params=[ + "door-lock", + "smoke-detector", + "air-purifier", + "eve-energy-plug-patched", + "eve-energy-plug", + ] +) +async def matter_devices( + hass: HomeAssistant, matter_client: MagicMock, request: pytest.FixtureRequest +) -> MatterNode: + """Fixture for a Matter device.""" + return await setup_integration_with_node_fixture(hass, request.param, matter_client) + + @pytest.fixture(name="door_lock") async def door_lock_fixture( hass: HomeAssistant, matter_client: MagicMock diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..e72f6ed2410 --- /dev/null +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -0,0 +1,376 @@ +# serializer version: 1 +# name: test_binary_sensors[door-lock-True][binary_sensor.mock_door_lock_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.mock_door_lock_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-BatteryChargeLevel-47-14', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[door-lock-True][binary_sensor.mock_door_lock_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Mock Door Lock Battery', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_door_lock_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[door-lock-True][binary_sensor.mock_door_lock_door-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.mock_door_lock_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-LockDoorStateSensor-257-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[door-lock-True][binary_sensor.mock_door_lock_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Mock Door Lock Door', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_door_lock_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_battery_alert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.smoke_sensor_battery_alert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery alert', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'battery_alert', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmBatteryAlertSensor-92-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_battery_alert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smoke sensor Battery alert', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_sensor_battery_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_end_of_service-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.smoke_sensor_end_of_service', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'End of service', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'end_of_service', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmEndfOfServiceSensor-92-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_end_of_service-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Smoke sensor End of service', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_sensor_end_of_service', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_hardware_fault-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.smoke_sensor_hardware_fault', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hardware fault', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hardware_fault', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmHardwareFaultAlertSensor-92-6', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_hardware_fault-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Smoke sensor Hardware fault', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_sensor_hardware_fault', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_muted-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.smoke_sensor_muted', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Muted', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'muted', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmDeviceMutedSensor-92-4', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_muted-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smoke sensor Muted', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_sensor_muted', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_smoke-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.smoke_sensor_smoke', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smoke', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmSmokeStateSensor-92-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_smoke-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'smoke', + 'friendly_name': 'Smoke sensor Smoke', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_sensor_smoke', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_test_in_progress-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.smoke_sensor_test_in_progress', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Test in progress', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'test_in_progress', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SmokeCoAlarmTestInProgressSensor-92-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_test_in_progress-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Smoke sensor Test in progress', + }), + 'context': , + 'entity_id': 'binary_sensor.smoke_sensor_test_in_progress', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..63024d3a320 --- /dev/null +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -0,0 +1,1209 @@ +# serializer version: 1 +# name: test_sensors[air-purifier-True][sensor.air_purifier_activated_carbon_filter_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_activated_carbon_filter_condition', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Activated carbon filter condition', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'activated_carbon_filter_condition', + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-ActivatedCarbonFilterCondition-114-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_activated_carbon_filter_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier Activated carbon filter condition', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_activated_carbon_filter_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_air_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'extremely_poor', + 'very_poor', + 'poor', + 'fair', + 'good', + 'moderate', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-AirQuality-91-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_air_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Air Purifier Air quality', + 'options': list([ + 'extremely_poor', + 'very_poor', + 'poor', + 'fair', + 'good', + 'moderate', + ]), + }), + 'context': , + 'entity_id': 'sensor.air_purifier_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'good', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-CarbonDioxideSensor-1037-0', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Air Purifier Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_carbon_monoxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_carbon_monoxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon monoxide', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-CarbonMonoxideSensor-1036-0', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_carbon_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_monoxide', + 'friendly_name': 'Air Purifier Carbon monoxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_carbon_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_hepa_filter_condition-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_hepa_filter_condition', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hepa filter condition', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'hepa_filter_condition', + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-1-HepaFilterCondition-113-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_hepa_filter_condition-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier Hepa filter condition', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_hepa_filter_condition', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-4-HumiditySensor-1029-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Air Purifier Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_nitrogen_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_nitrogen_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Nitrogen dioxide', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-NitrogenDioxideSensor-1043-0', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_nitrogen_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'nitrogen_dioxide', + 'friendly_name': 'Air Purifier Nitrogen dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_nitrogen_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_ozone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_ozone', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Ozone', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-OzoneConcentrationSensor-1045-0', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_ozone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'ozone', + 'friendly_name': 'Air Purifier Ozone', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_ozone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-PM1Sensor-1068-0', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': 'Air Purifier PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-PM10Sensor-1069-0', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Air Purifier PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-PM25Sensor-1066-0', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Air Purifier PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-3-TemperatureSensor-1026-0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Air Purifier Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.air_purifier_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_vocs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_vocs', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'VOCs', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000008F-MatterNodeDevice-2-TotalVolatileOrganicCompoundsSensor-1070-0', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensors[air-purifier-True][sensor.air_purifier_vocs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds_parts', + 'friendly_name': 'Air Purifier VOCs', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_vocs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_energy_plug_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-EveEnergySensorWattCurrent-319486977-319422473', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Eve Energy Plug Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_energy_plug_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_energy_plug_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-EveEnergySensorWattAccumulated-319486977-319422475', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eve Energy Plug Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_energy_plug_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.220000028610229', + }) +# --- +# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_energy_plug_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-EveEnergySensorWatt-319486977-319422474', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Eve Energy Plug Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_energy_plug_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_energy_plug_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000053-MatterNodeDevice-1-EveEnergySensorVoltage-319486977-319422472', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Eve Energy Plug Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_energy_plug_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '238.800003051758', + }) +# --- +# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_energy_plug_patched_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Eve Energy Plug Patched Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_energy_plug_patched_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_energy_plug_patched_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalEnergyMeasurementCumulativeEnergyImported-145-1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Eve Energy Plug Patched Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_energy_plug_patched_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0025', + }) +# --- +# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_energy_plug_patched_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementWatt-144-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Eve Energy Plug Patched Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_energy_plug_patched_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '550.0', + }) +# --- +# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.eve_energy_plug_patched_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-00000000000000B7-MatterNodeDevice-2-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Eve Energy Plug Patched Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_energy_plug_patched_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220.0', + }) +# --- +# name: test_sensors[smoke-detector-True][sensor.smoke_sensor_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_sensor_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSource-47-12', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[smoke-detector-True][sensor.smoke_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smoke sensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smoke_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '94', + }) +# --- +# name: test_sensors[smoke-detector-True][sensor.smoke_sensor_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.smoke_sensor_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[smoke-detector-True][sensor.smoke_sensor_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Smoke sensor Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.smoke_sensor_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 7feeb56ee7e..61518053897 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -5,11 +5,12 @@ from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode import pytest +from syrupy import SnapshotAssertion from homeassistant.components.matter.binary_sensor import ( DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS, ) -from homeassistant.const import STATE_OFF, EntityCategory, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -124,47 +125,21 @@ async def test_battery_sensor( assert state assert state.state == "on" - entry = entity_registry.async_get(entity_id) - - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC - # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_smoke_alarm( +async def test_binary_sensors( hass: HomeAssistant, matter_client: MagicMock, - smoke_detector: MatterNode, + matter_devices: MatterNode, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: - """Test smoke detector.""" - - # Muted - state = hass.states.get("binary_sensor.smoke_sensor_muted") - assert state - assert state.state == STATE_OFF - - # End of service - state = hass.states.get("binary_sensor.smoke_sensor_end_of_service") - assert state - assert state.state == STATE_OFF - - # Battery alert - state = hass.states.get("binary_sensor.smoke_sensor_battery_alert") - assert state - assert state.state == STATE_OFF - - # Test in progress - state = hass.states.get("binary_sensor.smoke_sensor_test_in_progress") - assert state - assert state.state == STATE_OFF - - # Hardware fault - state = hass.states.get("binary_sensor.smoke_sensor_hardware_fault") - assert state - assert state.state == STATE_OFF - - # Smoke - state = hass.states.get("binary_sensor.smoke_sensor_smoke") - assert state - assert state.state == STATE_OFF + """Test binary sensors.""" + entities = hass.states.async_all(Platform.BINARY_SENSOR) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + state = hass.states.get(entity_entry.entity_id) + assert state, f"State not found for {entity_entry.entity_id}" + assert state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index dd0e52b8c7c..0d67c33bbfb 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -4,8 +4,9 @@ from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode import pytest +from syrupy import SnapshotAssertion -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -286,112 +287,6 @@ async def test_battery_sensor_voltage( assert entry.entity_category == EntityCategory.DIAGNOSTIC -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_energy_sensors_custom_cluster( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - matter_client: MagicMock, - eve_energy_plug_node: MatterNode, -) -> None: - """Test Energy sensors created from (Eve) custom cluster (Matter 1.3 energy clusters absent).""" - # power sensor on Eve custom cluster - entity_id = "sensor.eve_energy_plug_power" - state = hass.states.get(entity_id) - assert state - assert state.state == "0.0" - assert state.attributes["unit_of_measurement"] == "W" - assert state.attributes["device_class"] == "power" - assert state.attributes["friendly_name"] == "Eve Energy Plug Power" - - # voltage sensor on Eve custom cluster - entity_id = "sensor.eve_energy_plug_voltage" - state = hass.states.get(entity_id) - assert state - assert state.state == "238.800003051758" - assert state.attributes["unit_of_measurement"] == "V" - assert state.attributes["device_class"] == "voltage" - assert state.attributes["friendly_name"] == "Eve Energy Plug Voltage" - - # energy sensor on Eve custom cluster - entity_id = "sensor.eve_energy_plug_energy" - state = hass.states.get(entity_id) - assert state - assert state.state == "0.220000028610229" - assert state.attributes["unit_of_measurement"] == "kWh" - assert state.attributes["device_class"] == "energy" - assert state.attributes["friendly_name"] == "Eve Energy Plug Energy" - assert state.attributes["state_class"] == "total_increasing" - - # current sensor on Eve custom cluster - entity_id = "sensor.eve_energy_plug_current" - state = hass.states.get(entity_id) - assert state - assert state.state == "0.0" - assert state.attributes["unit_of_measurement"] == "A" - assert state.attributes["device_class"] == "current" - assert state.attributes["friendly_name"] == "Eve Energy Plug Current" - - -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_energy_sensors( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - matter_client: MagicMock, - eve_energy_plug_patched_node: MatterNode, -) -> None: - """Test Energy sensors created from official Matter 1.3 energy clusters.""" - # power sensor on Matter 1.3 ElectricalPowermeasurement cluster - entity_id = "sensor.eve_energy_plug_patched_power" - state = hass.states.get(entity_id) - assert state - assert state.state == "550.0" - assert state.attributes["unit_of_measurement"] == "W" - assert state.attributes["device_class"] == "power" - assert state.attributes["friendly_name"] == "Eve Energy Plug Patched Power" - # ensure we do not have a duplicated entity from the custom cluster - state = hass.states.get(f"{entity_id}_1") - assert state is None - - # voltage sensor on Matter 1.3 ElectricalPowermeasurement cluster - entity_id = "sensor.eve_energy_plug_patched_voltage" - state = hass.states.get(entity_id) - assert state - assert state.state == "220.0" - assert state.attributes["unit_of_measurement"] == "V" - assert state.attributes["device_class"] == "voltage" - assert state.attributes["friendly_name"] == "Eve Energy Plug Patched Voltage" - # ensure we do not have a duplicated entity from the custom cluster - state = hass.states.get(f"{entity_id}_1") - assert state is None - - # energy sensor on Matter 1.3 ElectricalEnergymeasurement cluster - entity_id = "sensor.eve_energy_plug_patched_energy" - state = hass.states.get(entity_id) - assert state - assert state.state == "0.0025" - assert state.attributes["unit_of_measurement"] == "kWh" - assert state.attributes["device_class"] == "energy" - assert state.attributes["friendly_name"] == "Eve Energy Plug Patched Energy" - assert state.attributes["state_class"] == "total_increasing" - # ensure we do not have a duplicated entity from the custom cluster - state = hass.states.get(f"{entity_id}_1") - assert state is None - - # current sensor on Matter 1.3 ElectricalPowermeasurement cluster - entity_id = "sensor.eve_energy_plug_patched_current" - state = hass.states.get(entity_id) - assert state - assert state.state == "2.0" - assert state.attributes["unit_of_measurement"] == "A" - assert state.attributes["device_class"] == "current" - assert state.attributes["friendly_name"] == "Eve Energy Plug Patched Current" - # ensure we do not have a duplicated entity from the custom cluster - state = hass.states.get(f"{entity_id}_1") - assert state is None - - # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_eve_thermo_sensor( @@ -508,132 +403,6 @@ async def test_air_quality_sensor( assert state.state == "50.0" -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_air_purifier_sensor( - hass: HomeAssistant, - matter_client: MagicMock, - air_purifier_node: MatterNode, -) -> None: - """Test Air quality sensors are creayted for air purifier device.""" - # Carbon Dioxide - state = hass.states.get("sensor.air_purifier_carbon_dioxide") - assert state - assert state.state == "2.0" - - # PM1 - state = hass.states.get("sensor.air_purifier_pm1") - assert state - assert state.state == "2.0" - - # PM2.5 - state = hass.states.get("sensor.air_purifier_pm2_5") - assert state - assert state.state == "2.0" - - # PM10 - state = hass.states.get("sensor.air_purifier_pm10") - assert state - assert state.state == "2.0" - - # Temperature - state = hass.states.get("sensor.air_purifier_temperature") - assert state - assert state.state == "20.0" - - # Humidity - state = hass.states.get("sensor.air_purifier_humidity") - assert state - assert state.state == "50.0" - - # VOCS - state = hass.states.get("sensor.air_purifier_vocs") - assert state - assert state.state == "2.0" - assert state.attributes["state_class"] == "measurement" - assert state.attributes["unit_of_measurement"] == "ppm" - assert state.attributes["device_class"] == "volatile_organic_compounds_parts" - assert state.attributes["friendly_name"] == "Air Purifier VOCs" - - # Air Quality - state = hass.states.get("sensor.air_purifier_air_quality") - assert state - assert state.state == "good" - expected_options = [ - "extremely_poor", - "very_poor", - "poor", - "fair", - "good", - "moderate", - ] - assert set(state.attributes["options"]) == set(expected_options) - assert state.attributes["device_class"] == "enum" - assert state.attributes["friendly_name"] == "Air Purifier Air quality" - - # Carbon MonoOxide - state = hass.states.get("sensor.air_purifier_carbon_monoxide") - assert state - assert state.state == "2.0" - assert state.attributes["state_class"] == "measurement" - assert state.attributes["unit_of_measurement"] == "ppm" - assert state.attributes["device_class"] == "carbon_monoxide" - assert state.attributes["friendly_name"] == "Air Purifier Carbon monoxide" - - # Nitrogen Dioxide - state = hass.states.get("sensor.air_purifier_nitrogen_dioxide") - assert state - assert state.state == "2.0" - assert state.attributes["state_class"] == "measurement" - assert state.attributes["unit_of_measurement"] == "ppm" - assert state.attributes["device_class"] == "nitrogen_dioxide" - assert state.attributes["friendly_name"] == "Air Purifier Nitrogen dioxide" - - # Ozone Concentration - state = hass.states.get("sensor.air_purifier_ozone") - assert state - assert state.state == "2.0" - assert state.attributes["state_class"] == "measurement" - assert state.attributes["unit_of_measurement"] == "ppm" - assert state.attributes["device_class"] == "ozone" - assert state.attributes["friendly_name"] == "Air Purifier Ozone" - - # Hepa Filter Condition - state = hass.states.get("sensor.air_purifier_hepa_filter_condition") - assert state - assert state.state == "100" - assert state.attributes["state_class"] == "measurement" - assert state.attributes["unit_of_measurement"] == "%" - assert state.attributes["friendly_name"] == "Air Purifier Hepa filter condition" - - # Activated Carbon Filter Condition - state = hass.states.get("sensor.air_purifier_activated_carbon_filter_condition") - assert state - assert state.state == "100" - assert state.attributes["state_class"] == "measurement" - assert state.attributes["unit_of_measurement"] == "%" - - -# This tests needs to be adjusted to remove lingering tasks -@pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_smoke_alarm( - hass: HomeAssistant, - matter_client: MagicMock, - smoke_detector: MatterNode, -) -> None: - """Test smoke detector.""" - - # Battery - state = hass.states.get("sensor.smoke_sensor_battery") - assert state - assert state.state == "94" - - # Voltage - state = hass.states.get("sensor.smoke_sensor_voltage") - assert state - assert state.state == "0.0" - - async def test_operational_state_sensor( hass: HomeAssistant, matter_client: MagicMock, @@ -658,3 +427,22 @@ async def test_operational_state_sensor( state = hass.states.get("sensor.dishwasher_operational_state") assert state assert state.state == "extra_state" + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_sensors( + hass: HomeAssistant, + matter_client: MagicMock, + matter_devices: MatterNode, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test sensors.""" + entities = hass.states.async_all(Platform.SENSOR) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + state = hass.states.get(entity_entry.entity_id) + assert state, f"State not found for {entity_entry.entity_id}" + assert state == snapshot(name=f"{entity_entry.entity_id}-state") From d6e34e09849db28640d9e2634a933fe4820a5a91 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 25 Sep 2024 01:40:59 -0700 Subject: [PATCH 1159/1309] Add an entity description for Google Calendar (#125469) --- homeassistant/components/google/calendar.py | 162 +++++++++++++------- 1 file changed, 109 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index f51bf64d400..3a5a620876d 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -2,13 +2,15 @@ from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import Any, cast from gcal_sync.api import Range, SyncEventsRequest from gcal_sync.exceptions import ApiException -from gcal_sync.model import AccessRole, DateOrDatetime, Event +from gcal_sync.model import AccessRole, Calendar, DateOrDatetime, Event from gcal_sync.store import ScopedCalendarStore from gcal_sync.sync import CalendarEventSyncManager @@ -32,7 +34,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_O from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import entity_platform, entity_registry as er -from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.entity import EntityDescription, generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -81,6 +83,83 @@ RRULE_PREFIX = "RRULE:" SERVICE_CREATE_EVENT = "create_event" +@dataclass(frozen=True, kw_only=True) +class GoogleCalendarEntityDescription(EntityDescription): + """Google calendar entity description.""" + + name: str + entity_id: str + read_only: bool + ignore_availability: bool + offset: str | None + search: str | None + local_sync: bool + device_id: str + + +def _get_entity_descriptions( + hass: HomeAssistant, + config_entry: ConfigEntry, + calendar_item: Calendar, + calendar_info: Mapping[str, Any], +) -> list[GoogleCalendarEntityDescription]: + """Create entity descriptions for the calendar. + + The entity descriptions are based on the type of Calendar from the API + and optional calendar_info yaml configuration that is the older way to + configure calendars before they supported UI based config. + + The yaml config may map one calendar to multiple entities and they do not + have a unique id. The yaml config also supports additional options like + offsets or search. + """ + calendar_id = calendar_item.id + num_entities = len(calendar_info[CONF_ENTITIES]) + entity_descriptions = [] + for data in calendar_info[CONF_ENTITIES]: + if num_entities > 1: + key = "" + else: + key = calendar_id + entity_enabled = data.get(CONF_TRACK, True) + if not entity_enabled: + _LOGGER.warning( + "The 'track' option in google_calendars.yaml has been deprecated." + " The setting has been imported to the UI, and should now be" + " removed from google_calendars.yaml" + ) + read_only = not ( + calendar_item.access_role.is_writer + and get_feature_access(hass, config_entry) is FeatureAccess.read_write + ) + # Prefer calendar sync down of resources when possible. However, + # sync does not work for search. Also free-busy calendars denormalize + # recurring events as individual events which is not efficient for sync + local_sync = True + if ( + search := data.get(CONF_SEARCH) + ) or calendar_item.access_role == AccessRole.FREE_BUSY_READER: + read_only = True + local_sync = False + entity_descriptions.append( + GoogleCalendarEntityDescription( + key=key, + name=data[CONF_NAME].capitalize(), + entity_id=generate_entity_id( + ENTITY_ID_FORMAT, data[CONF_DEVICE_ID], hass=hass + ), + read_only=read_only, + ignore_availability=data.get(CONF_IGNORE_AVAILABILITY, False), + offset=data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET), + search=search, + local_sync=local_sync, + entity_registry_enabled_default=entity_enabled, + device_id=data[CONF_DEVICE_ID], + ) + ) + return entity_descriptions + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -117,30 +196,21 @@ async def async_setup_entry( hass, calendar_item.dict(exclude_unset=True) ) new_calendars.append(calendar_info) - # Yaml calendar config may map one calendar to multiple entities - # with extra options like offsets or search criteria. - num_entities = len(calendar_info[CONF_ENTITIES]) - for data in calendar_info[CONF_ENTITIES]: - entity_enabled = data.get(CONF_TRACK, True) - if not entity_enabled: - _LOGGER.warning( - "The 'track' option in google_calendars.yaml has been deprecated." - " The setting has been imported to the UI, and should now be" - " removed from google_calendars.yaml" - ) - entity_name = data[CONF_DEVICE_ID] - # The unique id is based on the config entry and calendar id since - # multiple accounts can have a common calendar id - # (e.g. `en.usa#holiday@group.v.calendar.google.com`). - # When using google_calendars.yaml with multiple entities for a - # single calendar, we have no way to set a unique id. - if num_entities > 1: - unique_id = None - else: - unique_id = f"{config_entry.unique_id}-{calendar_id}" + + for entity_description in _get_entity_descriptions( + hass, config_entry, calendar_item, calendar_info + ): + unique_id = ( + f"{config_entry.unique_id}-{entity_description.key}" + if entity_description.key + else None + ) # Migrate to new unique_id format which supports # multiple config entries as of 2022.7 - for old_unique_id in (calendar_id, f"{calendar_id}-{entity_name}"): + for old_unique_id in ( + calendar_id, + f"{calendar_id}-{entity_description.device_id}", + ): if not (entity_entry := entity_entry_map.get(old_unique_id)): continue if unique_id: @@ -163,24 +233,14 @@ async def async_setup_entry( entity_entry.entity_id, ) coordinator: CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator - # Prefer calendar sync down of resources when possible. However, - # sync does not work for search. Also free-busy calendars denormalize - # recurring events as individual events which is not efficient for sync - support_write = ( - calendar_item.access_role.is_writer - and get_feature_access(hass, config_entry) is FeatureAccess.read_write - ) - if ( - search := data.get(CONF_SEARCH) - ) or calendar_item.access_role == AccessRole.FREE_BUSY_READER: + if not entity_description.local_sync: coordinator = CalendarQueryUpdateCoordinator( hass, calendar_service, - data[CONF_NAME], + entity_description.name, calendar_id, - search, + entity_description.search, ) - support_write = False else: request_template = SyncEventsRequest( calendar_id=calendar_id, @@ -188,23 +248,22 @@ async def async_setup_entry( ) sync = CalendarEventSyncManager( calendar_service, - store=ScopedCalendarStore(store, unique_id or entity_name), + store=ScopedCalendarStore( + store, unique_id or entity_description.device_id + ), request_template=request_template, ) coordinator = CalendarSyncUpdateCoordinator( hass, sync, - data[CONF_NAME], + entity_description.name, ) entities.append( GoogleCalendarEntity( coordinator, calendar_id, - data, - generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass), + entity_description, unique_id, - entity_enabled, - support_write, ) ) @@ -238,29 +297,26 @@ class GoogleCalendarEntity( ): """A calendar event entity.""" + entity_description: GoogleCalendarEntityDescription _attr_has_entity_name = True def __init__( self, coordinator: CalendarSyncUpdateCoordinator | CalendarQueryUpdateCoordinator, calendar_id: str, - data: dict[str, Any], - entity_id: str, + entity_description: GoogleCalendarEntityDescription, unique_id: str | None, - entity_enabled: bool, - supports_write: bool, ) -> None: """Create the Calendar event device.""" super().__init__(coordinator) self.calendar_id = calendar_id - self._ignore_availability: bool = data.get(CONF_IGNORE_AVAILABILITY, False) + self.entity_description = entity_description + self._ignore_availability = entity_description.ignore_availability + self._offset = entity_description.offset self._event: CalendarEvent | None = None - self._attr_name = data[CONF_NAME].capitalize() - self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET) - self.entity_id = entity_id + self.entity_id = entity_description.entity_id self._attr_unique_id = unique_id - self._attr_entity_registry_enabled_default = entity_enabled - if supports_write: + if not entity_description.read_only: self._attr_supported_features = ( CalendarEntityFeature.CREATE_EVENT | CalendarEntityFeature.DELETE_EVENT ) From 771575cfc50a3e597a2e2270fcbf71ce88537e41 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Sep 2024 11:11:11 +0200 Subject: [PATCH 1160/1309] Make statistics validation create issue registry issues (#122595) * Make statistics validation create issue registry issues * Disable creating issue about outdated MariaDB version in tests * Use call_soon_threadsafe instead of run_callback_threadsafe * Update tests * Fix flapping test * Disable creating issue about outdated SQLite version in tests * Implement agreed changes * Add translation strings for issue titles * Update test --- homeassistant/components/recorder/const.py | 6 +- .../components/recorder/statistics.py | 22 ++ .../components/recorder/websocket_api.py | 20 ++ homeassistant/components/sensor/recorder.py | 170 ++++++++---- homeassistant/components/sensor/strings.json | 10 + tests/components/recorder/test_statistics.py | 19 +- .../components/recorder/test_websocket_api.py | 12 + tests/components/sensor/test_recorder.py | 241 +++++++++++++----- tests/helpers/test_translation.py | 4 +- 9 files changed, 395 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index bc909448317..409641e54c9 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -62,13 +62,15 @@ LAST_REPORTED_SCHEMA_VERSION = 43 LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics" -INTEGRATION_PLATFORM_VALIDATE_STATISTICS = "validate_statistics" INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" +INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES = "update_statistics_issues" +INTEGRATION_PLATFORM_VALIDATE_STATISTICS = "validate_statistics" INTEGRATION_PLATFORM_METHODS = { INTEGRATION_PLATFORM_COMPILE_STATISTICS, - INTEGRATION_PLATFORM_VALIDATE_STATISTICS, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, + INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, + INTEGRATION_PLATFORM_VALIDATE_STATISTICS, } diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index ba19c016d19..4ffe7c72971 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -52,6 +52,7 @@ from .const import ( EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, INTEGRATION_PLATFORM_COMPILE_STATISTICS, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, + INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, SupportedDialect, ) @@ -586,6 +587,17 @@ def _compile_statistics( ): new_short_term_stats.append(new_stat) + if start.minute == 50: + # Once every hour, update issues + for platform in instance.hass.data[DOMAIN].recorder_platforms.values(): + if not ( + platform_update_issues := getattr( + platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None + ) + ): + continue + platform_update_issues(instance.hass, session) + if start.minute == 55: # A full hour is ready, summarize it _compile_hourly_statistics(session, start) @@ -2212,6 +2224,16 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]] return platform_validation +def update_statistics_issues(hass: HomeAssistant) -> None: + """Update statistics issues.""" + with session_scope(hass=hass, read_only=True) as session: + for platform in hass.data[DOMAIN].recorder_platforms.values(): + if platform_update_statistics_issues := getattr( + platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None + ): + platform_update_statistics_issues(hass, session) + + def _statistics_exists( session: Session, table: type[StatisticsBase], diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index f08f7bdcb97..6ac2207b1e0 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -43,6 +43,7 @@ from .statistics import ( list_statistic_ids, statistic_during_period, statistics_during_period, + update_statistics_issues, validate_statistics, ) from .util import PERIOD_SCHEMA, get_instance, resolve_period @@ -80,6 +81,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, ws_get_statistics_metadata) websocket_api.async_register_command(hass, ws_list_statistic_ids) websocket_api.async_register_command(hass, ws_import_statistics) + websocket_api.async_register_command(hass, ws_update_statistics_issues) websocket_api.async_register_command(hass, ws_update_statistics_metadata) websocket_api.async_register_command(hass, ws_validate_statistics) @@ -292,6 +294,24 @@ async def ws_validate_statistics( connection.send_result(msg["id"], statistic_ids) +@websocket_api.websocket_command( + { + vol.Required("type"): "recorder/update_statistics_issues", + } +) +@websocket_api.async_response +async def ws_update_statistics_issues( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Update statistics issues.""" + instance = get_instance(hass) + await instance.async_add_executor_job( + update_statistics_issues, + hass, + ) + connection.send_result(msg["id"]) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 462b25dd552..f81c3308943 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -5,6 +5,7 @@ from __future__ import annotations from collections import defaultdict from collections.abc import Callable, Iterable import datetime +from functools import partial import itertools import logging import math @@ -30,8 +31,9 @@ from homeassistant.const import ( UnitOfSoundPressure, UnitOfVolume, ) -from homeassistant.core import HomeAssistant, State, split_entity_id +from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity import entity_sources from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.loader import async_suggest_report_issue @@ -672,6 +674,113 @@ def list_statistic_ids( return result +@callback +def _update_issues( + report_issue: Callable[[str, str, dict[str, Any]], None], + clear_issue: Callable[[str, str], None], + sensor_states: list[State], + metadatas: dict[str, tuple[int, StatisticMetaData]], +) -> None: + """Update repair issues.""" + for state in sensor_states: + entity_id = state.entity_id + 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: + # Sensor no longer has a valid state class + report_issue( + "unsupported_state_class", + entity_id, + { + "statistic_id": entity_id, + "state_class": state_class, + }, + ) + else: + clear_issue("unsupported_state_class", entity_id) + + 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}): + # The unit has changed, and it's not possible to convert + report_issue( + "units_changed", + entity_id, + { + "statistic_id": entity_id, + "state_unit": state_unit, + "metadata_unit": metadata_unit, + "supported_unit": metadata_unit, + }, + ) + else: + clear_issue("units_changed", entity_id) + elif 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)) + report_issue( + "units_changed", + entity_id, + { + "statistic_id": entity_id, + "state_unit": state_unit, + "metadata_unit": metadata_unit, + "supported_unit": valid_units_str, + }, + ) + else: + clear_issue("units_changed", entity_id) + + +def update_statistics_issues( + hass: HomeAssistant, + session: Session, +) -> None: + """Validate statistics.""" + instance = get_instance(hass) + sensor_states = hass.states.all(DOMAIN) + metadatas = statistics.get_metadata_with_session( + instance, session, statistic_source=RECORDER_DOMAIN + ) + + def create_issue_registry_issue( + issue_type: str, statistic_id: str, data: dict[str, Any] + ) -> None: + """Create an issue registry issue.""" + hass.loop.call_soon_threadsafe( + partial( + ir.async_create_issue, + hass, + DOMAIN, + f"{issue_type}_{statistic_id}", + data=data | {"issue_type": issue_type}, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=issue_type, + translation_placeholders=data, + ) + ) + + def delete_issue_registry_issue(issue_type: str, statistic_id: str) -> None: + """Delete an issue registry issue.""" + hass.loop.call_soon_threadsafe( + ir.async_delete_issue, hass, DOMAIN, f"{issue_type}_{statistic_id}" + ) + + _update_issues( + create_issue_registry_issue, + delete_issue_registry_issue, + sensor_states, + metadatas, + ) + + def validate_statistics( hass: HomeAssistant, ) -> dict[str, list[statistics.ValidationIssue]]: @@ -685,14 +794,28 @@ def validate_statistics( instance = get_instance(hass) entity_filter = instance.entity_filter + def create_statistic_validation_issue( + issue_type: str, statistic_id: str, data: dict[str, Any] + ) -> None: + """Create a statistic validation issue.""" + validation_result[statistic_id].append( + statistics.ValidationIssue(issue_type, data) + ) + + _update_issues( + create_statistic_validation_issue, + lambda issue_type, statistic_id: None, + sensor_states, + metadatas, + ) + for state in sensor_states: entity_id = state.entity_id 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 entity_id in metadatas: if entity_filter and not entity_filter(state.entity_id): # Sensor was previously recorded, but no longer is validation_result[entity_id].append( @@ -701,47 +824,6 @@ def validate_statistics( {"statistic_id": entity_id}, ) ) - - if state_class is None: - # Sensor no longer has a valid state class - validation_result[entity_id].append( - statistics.ValidationIssue( - "unsupported_state_class", - {"statistic_id": entity_id, "state_class": state_class}, - ) - ) - - 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}): - # The unit has changed, and it's not possible to convert - validation_result[entity_id].append( - statistics.ValidationIssue( - "units_changed", - { - "statistic_id": entity_id, - "state_unit": state_unit, - "metadata_unit": metadata_unit, - "supported_unit": metadata_unit, - }, - ) - ) - elif 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)) - validation_result[entity_id].append( - statistics.ValidationIssue( - "units_changed", - { - "statistic_id": entity_id, - "state_unit": state_unit, - "metadata_unit": metadata_unit, - "supported_unit": valid_units_str, - }, - ) - ) elif state_class is not None: if entity_filter and not entity_filter(state.entity_id): # Sensor is not recorded diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index fc85f4b05a9..4ef7dbc74f0 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -287,5 +287,15 @@ "wind_speed": { "name": "Wind speed" } + }, + "issues": { + "units_changed": { + "title": "The unit of {statistic_id} has changed", + "description": "" + }, + "unsupported_state_class": { + "title": "The state class of {statistic_id} is not supported", + "description": "" + } } } diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 5cbb29afc91..bdf39c5ef4a 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -2512,6 +2512,7 @@ async def test_recorder_platform_with_statistics( recorder_platform = Mock( compile_statistics=Mock(wraps=_mock_compile_statistics), list_statistic_ids=Mock(wraps=_mock_list_statistic_ids), + update_statistics_issues=Mock(), validate_statistics=Mock(wraps=_mock_validate_statistics), ) @@ -2523,16 +2524,20 @@ async def test_recorder_platform_with_statistics( recorder_platform.compile_statistics.assert_not_called() recorder_platform.list_statistic_ids.assert_not_called() + recorder_platform.update_statistics_issues.assert_not_called() recorder_platform.validate_statistics.assert_not_called() - # Test compile statistics - zero = get_start_time(dt_util.utcnow()) + # Test compile statistics + update statistics issues + # Issues are updated hourly when minutes = 50, trigger one hour later to make + # sure statistics is not suppressed by an existing row in StatisticsRuns + zero = get_start_time(dt_util.utcnow()).replace(minute=50) + timedelta(hours=1) do_adhoc_statistics(hass, start=zero) await async_wait_recording_done(hass) recorder_platform.compile_statistics.assert_called_once_with( hass, ANY, zero, zero + timedelta(minutes=5) ) + recorder_platform.update_statistics_issues.assert_called_once_with(hass, ANY) recorder_platform.list_statistic_ids.assert_not_called() recorder_platform.validate_statistics.assert_not_called() @@ -2542,6 +2547,7 @@ async def test_recorder_platform_with_statistics( recorder_platform.list_statistic_ids.assert_called_once_with( hass, statistic_ids=None, statistic_type=None ) + recorder_platform.update_statistics_issues.assert_called_once() recorder_platform.validate_statistics.assert_not_called() # Test validate statistics @@ -2551,6 +2557,7 @@ async def test_recorder_platform_with_statistics( ) recorder_platform.compile_statistics.assert_called_once() recorder_platform.list_statistic_ids.assert_called_once() + recorder_platform.update_statistics_issues.assert_called_once() recorder_platform.validate_statistics.assert_called_once_with(hass) @@ -2575,6 +2582,7 @@ async def test_recorder_platform_without_statistics( [ ("compile_statistics",), ("list_statistic_ids",), + ("update_statistics_issues",), ("validate_statistics",), ], ) @@ -2601,6 +2609,7 @@ async def test_recorder_platform_with_partial_statistics_support( mock_impl = { "compile_statistics": _mock_compile_statistics, "list_statistic_ids": _mock_list_statistic_ids, + "update_statistics_issues": None, "validate_statistics": _mock_validate_statistics, } @@ -2620,8 +2629,10 @@ async def test_recorder_platform_with_partial_statistics_support( for meth in supported_methods: getattr(recorder_platform, meth).assert_not_called() - # Test compile statistics - zero = get_start_time(dt_util.utcnow()) + # Test compile statistics + update statistics issues + # Issues are updated hourly when minutes = 50, trigger one hour later to make + # sure statistics is not suppressed by an existing row in StatisticsRuns + zero = get_start_time(dt_util.utcnow()).replace(minute=50) + timedelta(hours=1) do_adhoc_statistics(hass, start=zero) await async_wait_recording_done(hass) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 8efbf226bc1..badf2540654 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -1984,6 +1984,18 @@ async def test_validate_statistics( await assert_validation_result(client, {}) +async def test_update_statistics_issues( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test update_statistics_issues can be called.""" + + client = await hass_ws_client() + await client.send_json_auto_id({"type": "recorder/update_statistics_issues"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] is None + + async def test_clear_statistics( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 4d271785114..821c10e02d9 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,10 +1,11 @@ """The tests for sensor recorder platform.""" +from collections.abc import Iterable from datetime import datetime, timedelta import math from statistics import mean from typing import Any, Literal -from unittest.mock import patch +from unittest.mock import ANY, patch from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory @@ -37,6 +38,7 @@ from homeassistant.components.recorder.util import get_instance, session_scope from homeassistant.components.sensor import ATTR_OPTIONS, DOMAIN, SensorDeviceClass from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM @@ -110,6 +112,24 @@ def setup_recorder(recorder_mock: Recorder) -> Recorder: """Set up recorder.""" +@pytest.fixture(autouse=True) +def disable_mariadb_issue() -> None: + """Disable creating issue about outdated MariaDB version.""" + with patch( + "homeassistant.components.recorder.util._async_create_mariadb_range_index_regression_issue" + ): + yield + + +@pytest.fixture(autouse=True) +def disable_sqlite_issue() -> None: + """Disable creating issue about outdated SQLite version.""" + with patch( + "homeassistant.components.recorder.util._async_create_issue_deprecated_version" + ): + yield + + async def async_list_statistic_ids( hass: HomeAssistant, statistic_ids: set[str] | None = None, @@ -137,15 +157,61 @@ async def assert_statistic_ids( ) +def assert_issues( + hass: HomeAssistant, + expected_issues: dict[str, dict[str, Any]], +) -> None: + """Assert statistics issues.""" + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == len(expected_issues) + for issue_id, expected_issue_data in expected_issues.items(): + expected_translation_placeholders = dict(expected_issue_data) + expected_translation_placeholders.pop("issue_type") + expected_issue = ir.IssueEntry( + active=True, + breaks_in_ha_version=None, + created=ANY, + data=expected_issue_data, + dismissed_version=None, + domain=DOMAIN, + is_fixable=False, + is_persistent=False, + issue_domain=None, + issue_id=issue_id, + learn_more_url=None, + severity=ir.IssueSeverity.WARNING, + translation_key=expected_issue_data["issue_type"], + translation_placeholders=expected_translation_placeholders, + ) + assert (DOMAIN, issue_id) in issue_registry.issues + assert issue_registry.issues[(DOMAIN, issue_id)] == expected_issue + + async def assert_validation_result( + hass: HomeAssistant, client: MockHAClientWebSocket, - expected_result: dict[str, list[dict[str, Any]]], + expected_validation_result: dict[str, list[dict[str, Any]]], + expected_issues: Iterable[str], ) -> None: """Assert statistics validation result.""" await client.send_json_auto_id({"type": "recorder/validate_statistics"}) response = await client.receive_json() assert response["success"] - assert response["result"] == expected_result + assert response["result"] == expected_validation_result + await hass.async_block_till_done() + + # Check we get corresponding issues + await client.send_json_auto_id({"type": "recorder/update_statistics_issues"}) + response = await client.receive_json() + assert response["success"] + expected_issue_registry_issues = { + f"{issue['type']}_{statistic_id}": issue["data"] | {"issue_type": issue["type"]} + for statistic_id, issues in expected_validation_result.items() + for issue in issues + if issue["type"] in expected_issues + } + + assert_issues(hass, expected_issue_registry_issues) @pytest.mark.parametrize( @@ -4219,7 +4285,7 @@ async def test_validate_unit_change_convertible( client = await hass_ws_client() # No statistics, no state - empty response - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, unit in state matching device class - empty response hass.states.async_set( @@ -4229,7 +4295,7 @@ async def test_validate_unit_change_convertible( timestamp=now.timestamp(), ) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, unit in state not matching device class - empty response hass.states.async_set( @@ -4239,7 +4305,7 @@ async def test_validate_unit_change_convertible( timestamp=now.timestamp(), ) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Statistics has run, incompatible unit - expect error await async_recorder_block_till_done(hass) @@ -4264,7 +4330,7 @@ async def test_validate_unit_change_convertible( } ], } - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {"units_changed"}) # Valid state - empty response hass.states.async_set( @@ -4274,12 +4340,12 @@ async def test_validate_unit_change_convertible( timestamp=now.timestamp(), ) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Valid state, statistic runs again - empty response do_adhoc_statistics(hass, start=now + timedelta(hours=1)) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Valid state in compatible unit - empty response hass.states.async_set( @@ -4289,12 +4355,12 @@ async def test_validate_unit_change_convertible( timestamp=now.timestamp(), ) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Valid state, statistic runs again - empty response do_adhoc_statistics(hass, start=now + timedelta(hours=2)) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Remove the state - expect error about missing state hass.states.async_remove("sensor.test") @@ -4306,7 +4372,7 @@ async def test_validate_unit_change_convertible( } ], } - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {}) @pytest.mark.parametrize( @@ -4333,7 +4399,7 @@ async def test_validate_statistics_unit_ignore_device_class( client = await hass_ws_client() # No statistics, no state - empty response - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, no device class - empty response initial_attributes = {"state_class": "measurement", "unit_of_measurement": "dogs"} @@ -4341,7 +4407,7 @@ async def test_validate_statistics_unit_ignore_device_class( "sensor.test", 10, attributes=initial_attributes, timestamp=now.timestamp() ) await hass.async_block_till_done() - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Statistics has run, device class set not matching unit - empty response do_adhoc_statistics(hass, start=now) @@ -4353,7 +4419,7 @@ async def test_validate_statistics_unit_ignore_device_class( timestamp=now.timestamp(), ) await hass.async_block_till_done() - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) @pytest.mark.parametrize( @@ -4418,7 +4484,7 @@ async def test_validate_statistics_unit_change_no_device_class( client = await hass_ws_client() # No statistics, no state - empty response - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, sensor state set - empty response hass.states.async_set( @@ -4428,7 +4494,7 @@ async def test_validate_statistics_unit_change_no_device_class( timestamp=now.timestamp(), ) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, sensor state set to an incompatible unit - empty response hass.states.async_set( @@ -4438,7 +4504,7 @@ async def test_validate_statistics_unit_change_no_device_class( timestamp=now.timestamp(), ) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Statistics has run, incompatible unit - expect error await async_recorder_block_till_done(hass) @@ -4463,7 +4529,7 @@ async def test_validate_statistics_unit_change_no_device_class( } ], } - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {"units_changed"}) # Valid state - empty response hass.states.async_set( @@ -4473,12 +4539,12 @@ async def test_validate_statistics_unit_change_no_device_class( timestamp=now.timestamp(), ) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Valid state, statistic runs again - empty response do_adhoc_statistics(hass, start=now + timedelta(hours=1)) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Valid state in compatible unit - empty response hass.states.async_set( @@ -4488,12 +4554,12 @@ async def test_validate_statistics_unit_change_no_device_class( timestamp=now.timestamp(), ) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Valid state, statistic runs again - empty response do_adhoc_statistics(hass, start=now + timedelta(hours=2)) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Remove the state - expect error about missing state hass.states.async_remove("sensor.test") @@ -4505,7 +4571,7 @@ async def test_validate_statistics_unit_change_no_device_class( } ], } - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {}) @pytest.mark.parametrize( @@ -4530,19 +4596,19 @@ async def test_validate_statistics_unsupported_state_class( client = await hass_ws_client() # No statistics, no state - empty response - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, valid state - empty response hass.states.async_set( "sensor.test", 10, attributes=attributes, timestamp=now.timestamp() ) await hass.async_block_till_done() - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Statistics has run, empty response do_adhoc_statistics(hass, start=now) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # State update with invalid state class, expect error _attributes = dict(attributes) @@ -4562,7 +4628,7 @@ async def test_validate_statistics_unsupported_state_class( } ], } - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {"unsupported_state_class"}) @pytest.mark.parametrize( @@ -4587,19 +4653,19 @@ async def test_validate_statistics_sensor_no_longer_recorded( client = await hass_ws_client() # No statistics, no state - empty response - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, valid state - empty response hass.states.async_set( "sensor.test", 10, attributes=attributes, timestamp=now.timestamp() ) await hass.async_block_till_done() - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Statistics has run, empty response do_adhoc_statistics(hass, start=now) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Sensor no longer recorded, expect error expected = { @@ -4616,7 +4682,7 @@ async def test_validate_statistics_sensor_no_longer_recorded( "entity_filter", return_value=False, ): - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {}) @pytest.mark.parametrize( @@ -4641,7 +4707,7 @@ async def test_validate_statistics_sensor_not_recorded( client = await hass_ws_client() # No statistics, no state - empty response - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Sensor not recorded, expect error expected = { @@ -4662,12 +4728,12 @@ async def test_validate_statistics_sensor_not_recorded( "sensor.test", 10, attributes=attributes, timestamp=now.timestamp() ) await hass.async_block_till_done() - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {}) # Statistics has run, expect same error do_adhoc_statistics(hass, start=now) await async_recorder_block_till_done(hass) - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {}) @pytest.mark.parametrize( @@ -4692,19 +4758,19 @@ async def test_validate_statistics_sensor_removed( client = await hass_ws_client() # No statistics, no state - empty response - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, valid state - empty response hass.states.async_set( "sensor.test", 10, attributes=attributes, timestamp=now.timestamp() ) await hass.async_block_till_done() - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Statistics has run, empty response do_adhoc_statistics(hass, start=now) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Sensor removed, expect error hass.states.async_remove("sensor.test") @@ -4716,7 +4782,7 @@ async def test_validate_statistics_sensor_removed( } ], } - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {}) @pytest.mark.parametrize( @@ -4741,7 +4807,7 @@ async def test_validate_statistics_unit_change_no_conversion( client = await hass_ws_client() # No statistics, no state - empty response - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, original unit - empty response hass.states.async_set( @@ -4750,7 +4816,7 @@ async def test_validate_statistics_unit_change_no_conversion( attributes={**attributes, "unit_of_measurement": unit1}, timestamp=now.timestamp(), ) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, changed unit - empty response hass.states.async_set( @@ -4759,7 +4825,7 @@ async def test_validate_statistics_unit_change_no_conversion( attributes={**attributes, "unit_of_measurement": unit2}, timestamp=now.timestamp(), ) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Run statistics, no statistics will be generated because of conflicting units await async_recorder_block_till_done(hass) @@ -4774,7 +4840,7 @@ async def test_validate_statistics_unit_change_no_conversion( attributes={**attributes, "unit_of_measurement": unit1}, timestamp=now.timestamp(), ) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Run statistics one hour later, only the state with unit1 will be considered await async_recorder_block_till_done(hass) @@ -4783,7 +4849,7 @@ async def test_validate_statistics_unit_change_no_conversion( await assert_statistic_ids( hass, [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] ) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Change unit - expect error hass.states.async_set( @@ -4806,7 +4872,7 @@ async def test_validate_statistics_unit_change_no_conversion( } ], } - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {"units_changed"}) # Original unit - empty response hass.states.async_set( @@ -4816,13 +4882,13 @@ async def test_validate_statistics_unit_change_no_conversion( timestamp=now.timestamp(), ) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Valid state, statistic runs again - empty response await async_recorder_block_till_done(hass) do_adhoc_statistics(hass, start=now + timedelta(hours=2)) await async_recorder_block_till_done(hass) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Remove the state - expect error hass.states.async_remove("sensor.test") @@ -4834,7 +4900,7 @@ async def test_validate_statistics_unit_change_no_conversion( } ], } - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {}) @pytest.mark.parametrize( @@ -4864,7 +4930,7 @@ async def test_validate_statistics_unit_change_equivalent_units( client = await hass_ws_client() # No statistics, no state - empty response - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, original unit - empty response hass.states.async_set( @@ -4873,7 +4939,7 @@ async def test_validate_statistics_unit_change_equivalent_units( attributes={**attributes, "unit_of_measurement": unit1}, timestamp=now.timestamp(), ) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Run statistics await async_recorder_block_till_done(hass) @@ -4890,7 +4956,7 @@ async def test_validate_statistics_unit_change_equivalent_units( attributes={**attributes, "unit_of_measurement": unit2}, timestamp=now.timestamp() + 1, ) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Run statistics one hour later, metadata will be updated await async_recorder_block_till_done(hass) @@ -4899,7 +4965,7 @@ async def test_validate_statistics_unit_change_equivalent_units( await assert_statistic_ids( hass, [{"statistic_id": "sensor.test", "unit_of_measurement": unit2}] ) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) @pytest.mark.parametrize( @@ -4928,7 +4994,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2( client = await hass_ws_client() # No statistics, no state - empty response - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # No statistics, original unit - empty response hass.states.async_set( @@ -4937,7 +5003,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2( attributes={**attributes, "unit_of_measurement": unit1}, timestamp=now.timestamp(), ) - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) # Run statistics await async_recorder_block_till_done(hass) @@ -4967,7 +5033,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2( } ], } - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {"units_changed"}) # Run statistics one hour later, metadata will not be updated await async_recorder_block_till_done(hass) @@ -4976,7 +5042,7 @@ async def test_validate_statistics_unit_change_equivalent_units_2( await assert_statistic_ids( hass, [{"statistic_id": "sensor.test", "unit_of_measurement": unit1}] ) - await assert_validation_result(client, expected) + await assert_validation_result(hass, client, expected, {"units_changed"}) async def test_validate_statistics_other_domain( @@ -5009,7 +5075,68 @@ async def test_validate_statistics_other_domain( await async_recorder_block_till_done(hass) # We should not get complains about the missing number entity - await assert_validation_result(client, {}) + await assert_validation_result(hass, client, {}, {}) + + +@pytest.mark.parametrize( + ("units", "attributes", "unit"), + [ + (US_CUSTOMARY_SYSTEM, POWER_SENSOR_ATTRIBUTES, "W"), + ], +) +async def test_update_statistics_issues( + hass: HomeAssistant, + units, + attributes, + unit, +) -> None: + """Test update_statistics_issues.""" + + async def one_hour_stats(start: datetime) -> datetime: + """Generate 5-minute statistics for one hour.""" + for _ in range(12): + do_adhoc_statistics(hass, start=start) + await async_wait_recording_done(hass) + start += timedelta(minutes=5) + return start + + now = get_start_time(dt_util.utcnow()) + + hass.config.units = units + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + + # No statistics, no state - no issues + now = await one_hour_stats(now) + assert_issues(hass, {}) + + # Statistics, valid state - no issues + hass.states.async_set( + "sensor.test", 10, attributes=attributes, timestamp=now.timestamp() + ) + await hass.async_block_till_done() + now = await one_hour_stats(now) + assert_issues(hass, {}) + + # State update with invalid state class, statistics did not run again + _attributes = dict(attributes) + _attributes.pop("state_class") + hass.states.async_set( + "sensor.test", 12, attributes=_attributes, timestamp=now.timestamp() + ) + await hass.async_block_till_done() + assert_issues(hass, {}) + + # 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, + "statistic_id": "sensor.test", + } + } + assert_issues(hass, expected) async def async_record_meter_states( diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 73cd243a0c6..3b60c7f695b 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -425,10 +425,10 @@ async def test_caching(hass: HomeAssistant) -> None: side_effect=translation.build_resources, ) as mock_build_resources: load1 = await translation.async_get_translations(hass, "en", "entity_component") - assert len(mock_build_resources.mock_calls) == 6 + assert len(mock_build_resources.mock_calls) == 7 load2 = await translation.async_get_translations(hass, "en", "entity_component") - assert len(mock_build_resources.mock_calls) == 6 + assert len(mock_build_resources.mock_calls) == 7 assert load1 == load2 From bebd1dc23599d2ce76af1e26dec9321466542799 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Sep 2024 11:53:42 +0200 Subject: [PATCH 1161/1309] Enable Zwave notification sensors by default (#125326) * Enable Zwave notification sensors by default * Enable Zwave notification sensors by default * Enable Zwave notification sensors by default * Enable Zwave notification sensors by default * Enable Zwave notification sensors by default * Enable Zwave notification sensors by default * Enable Zwave notification sensors by default * Enable Zwave notification sensors by default * Fix the check to (dis)allow discovering a value multiple times * Prevent discovery of duplicate Notification CC sensors * alarm sensors disabled by default * one more fix * Update diagnostics tests --------- Co-authored-by: Marcel van der Veldt Co-authored-by: Martin Hjelmare --- .../components/zwave_js/binary_sensor.py | 17 +- .../components/zwave_js/diagnostics.py | 2 +- .../components/zwave_js/discovery.py | 72 +- homeassistant/components/zwave_js/sensor.py | 6 +- .../zwave_js/snapshots/test_diagnostics.ambr | 3428 +++++++++++++++++ tests/components/zwave_js/test_diagnostics.py | 30 +- tests/components/zwave_js/test_init.py | 13 - tests/components/zwave_js/test_sensor.py | 56 - 8 files changed, 3508 insertions(+), 116 deletions(-) create mode 100644 tests/components/zwave_js/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index bd5ce2d810b..0f1495fc6e6 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -248,6 +248,16 @@ BOOLEAN_SENSOR_MAPPINGS: dict[int, BinarySensorEntityDescription] = { } +@callback +def is_valid_notification_binary_sensor( + info: ZwaveDiscoveryInfo, +) -> bool | NotificationZWaveJSEntityDescription: + """Return if the notification CC Value is valid as binary sensor.""" + if not info.primary_value.metadata.states: + return False + return len(info.primary_value.metadata.states) > 1 + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -264,16 +274,18 @@ async def async_setup_entry( entities: list[BinarySensorEntity] = [] if info.platform_hint == "notification": + # ensure the notification CC Value is valid as binary sensor + if not is_valid_notification_binary_sensor(info): + return # Get all sensors from Notification CC states for state_key in info.primary_value.metadata.states: # ignore idle key (0) if state_key == "0": continue - + # get (optional) description for this state notification_description: ( NotificationZWaveJSEntityDescription | None ) = None - for description in NOTIFICATION_SENSOR_MAPPINGS: if ( int(description.key) @@ -289,7 +301,6 @@ async def async_setup_entry( and notification_description.off_state == state_key ): continue - entities.append( ZWaveNotificationBinarySensor( config_entry, driver, info, state_key, notification_description diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 2bb656c97f5..5515100b20b 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -80,7 +80,7 @@ def get_device_entities( er.async_get(hass), device.id, include_disabled_entities=True ) entities = [] - for entry in entity_entries: + for entry in sorted(entity_entries): # Skip entities that are not part of this integration if entry.config_entry_id != config_entry.entry_id: continue diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 6de5a56dc33..bd2b3a4b3ce 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -885,17 +885,6 @@ DISCOVERY_SCHEMAS = [ type={ValueType.BOOLEAN}, ), ), - ZWaveDiscoverySchema( - platform=Platform.BINARY_SENSOR, - hint="notification", - primary_value=ZWaveValueDiscoverySchema( - command_class={ - CommandClass.NOTIFICATION, - }, - type={ValueType.NUMBER}, - ), - allow_multi=True, - ), # binary sensor for Indicator CC ZWaveDiscoverySchema( platform=Platform.BINARY_SENSOR, @@ -957,19 +946,6 @@ DISCOVERY_SCHEMAS = [ ), data_template=NumericSensorDataTemplate(), ), - # special list sensors (Notification CC) - ZWaveDiscoverySchema( - platform=Platform.SENSOR, - hint="list_sensor", - primary_value=ZWaveValueDiscoverySchema( - command_class={ - CommandClass.NOTIFICATION, - }, - type={ValueType.NUMBER}, - ), - allow_multi=True, - entity_registry_enabled_default=False, - ), # number for Indicator CC (exclude property keys 3-5) ZWaveDiscoverySchema( platform=Platform.NUMBER, @@ -1196,6 +1172,7 @@ DISCOVERY_SCHEMAS = [ type={ValueType.NUMBER}, any_available_states={(0, "idle")}, ), + allow_multi=True, ), # event # stateful = False @@ -1218,6 +1195,43 @@ DISCOVERY_SCHEMAS = [ ), entity_category=EntityCategory.DIAGNOSTIC, ), + ZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + hint="notification", + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.NOTIFICATION, + }, + type={ValueType.NUMBER}, + ), + # set allow-multi to true because some of the notification sensors + # can not be mapped to a binary sensor and must be handled as a regular sensor + allow_multi=True, + ), + # alarmType, alarmLevel (Notification CC) + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="notification_alarm", + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.NOTIFICATION, + }, + property={"alarmType", "alarmLevel"}, + type={ValueType.NUMBER}, + ), + entity_registry_enabled_default=False, + ), + # fallback sensors within Notification CC + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="notification", + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.NOTIFICATION, + }, + type={ValueType.NUMBER}, + ), + ), ] @@ -1237,8 +1251,11 @@ def async_discover_single_value( value: ZwaveValue, device: DeviceEntry, discovered_value_ids: dict[str, set[str]] ) -> Generator[ZwaveDiscoveryInfo]: """Run discovery on a single ZWave value and return matching schema info.""" - discovered_value_ids[device.id].add(value.value_id) for schema in DISCOVERY_SCHEMAS: + # abort if attribute(s) already discovered + if value.value_id in discovered_value_ids[device.id]: + continue + # check manufacturer_id, product_id, product_type if ( ( @@ -1342,10 +1359,9 @@ def async_discover_single_value( entity_category=schema.entity_category, ) + # prevent re-discovery of the (primary) value if not allowed if not schema.allow_multi: - # return early since this value may not be discovered - # by other schemas/platforms - return + discovered_value_ids[device.id].add(value.value_id) if value.command_class == CommandClass.CONFIGURATION: yield from async_discover_single_configuration_value( diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index f52801109a1..b259711d21b 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -51,6 +51,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType +from .binary_sensor import is_valid_notification_binary_sensor from .const import ( ATTR_METER_TYPE, ATTR_METER_TYPE_NAME, @@ -580,7 +581,10 @@ async def async_setup_entry( data.unit_of_measurement, ) ) - elif info.platform_hint == "list_sensor": + elif info.platform_hint == "notification": + # prevent duplicate entities for values that are already represented as binary sensors + if is_valid_notification_binary_sensor(info): + return entities.append( ZWaveListSensor(config_entry, driver, info, entity_description) ) diff --git a/tests/components/zwave_js/snapshots/test_diagnostics.ambr b/tests/components/zwave_js/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..dc0dbba59b5 --- /dev/null +++ b/tests/components/zwave_js/snapshots/test_diagnostics.ambr @@ -0,0 +1,3428 @@ +# serializer version: 1 +# name: test_device_diagnostics + dict({ + 'entities': list([ + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.multisensor_6_any', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Any', + 'primary_value': dict({ + 'command_class': 48, + 'command_class_name': 'Binary Sensor', + 'endpoint': 0, + 'property': 'Any', + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Any', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-48-0-Any', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'binary_sensor.multisensor_6_low_battery_level', + 'hidden_by': None, + 'original_device_class': 'battery', + 'original_icon': None, + 'original_name': 'Low battery level', + 'primary_value': dict({ + 'command_class': 128, + 'command_class_name': 'Battery', + 'endpoint': 0, + 'property': 'isLow', + 'property_key': None, + 'property_key_name': None, + 'property_name': 'isLow', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-128-0-isLow', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.multisensor_6_motion_detection', + 'hidden_by': None, + 'original_device_class': 'motion', + 'original_icon': None, + 'original_name': 'Motion detection', + 'primary_value': dict({ + 'command_class': 113, + 'command_class_name': 'Notification', + 'endpoint': 0, + 'property': 'Home Security', + 'property_key': 'Motion sensor status', + 'property_key_name': 'Motion sensor status', + 'property_name': 'Home Security', + 'state_key': 8, + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-113-0-Home Security-Motion sensor status', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'binary_sensor.multisensor_6_tampering_product_cover_removed', + 'hidden_by': None, + 'original_device_class': 'tamper', + 'original_icon': None, + 'original_name': 'Tampering, product cover removed', + 'primary_value': dict({ + 'command_class': 113, + 'command_class_name': 'Notification', + 'endpoint': 0, + 'property': 'Home Security', + 'property_key': 'Cover status', + 'property_key_name': 'Cover status', + 'property_name': 'Home Security', + 'state_key': 3, + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-113-0-Home Security-Cover status', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': 'config', + 'entity_id': 'button.multisensor_6_idle_home_security_cover_status', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Idle Home Security Cover status', + 'primary_value': dict({ + 'command_class': 113, + 'command_class_name': 'Notification', + 'endpoint': 0, + 'property': 'Home Security', + 'property_key': 'Cover status', + 'property_key_name': 'Cover status', + 'property_name': 'Home Security', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-113-0-Home Security-Cover status', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': 'config', + 'entity_id': 'button.multisensor_6_idle_home_security_motion_sensor_status', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Idle Home Security Motion sensor status', + 'primary_value': dict({ + 'command_class': 113, + 'command_class_name': 'Notification', + 'endpoint': 0, + 'property': 'Home Security', + 'property_key': 'Motion sensor status', + 'property_key_name': 'Motion sensor status', + 'property_name': 'Home Security', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-113-0-Home Security-Motion sensor status', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.multisensor_6_basic', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basic', + 'primary_value': dict({ + 'command_class': 32, + 'command_class_name': 'Basic', + 'endpoint': 0, + 'property': 'currentValue', + 'property_key': None, + 'property_key_name': None, + 'property_name': 'currentValue', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-32-0-currentValue', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_battery_threshold', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery Threshold', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 44, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Battery Threshold', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-44', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_default_unit_of_the_automatic_temperature_report', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Default unit of the automatic temperature report', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 64, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Default unit of the automatic temperature report', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-64', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_group_1_report_interval', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 1 Report Interval', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 111, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Group 1 Report Interval', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-111', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_group_2_report_interval', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 2 Report Interval', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 112, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Group 2 Report Interval', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-112', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_group_3_report_interval', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 3 Report Interval', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 113, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Group 3 Report Interval', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-113', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_humidity_sensor_calibration', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Humidity Sensor Calibration', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 202, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Humidity Sensor Calibration', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-202', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_humidity_threshold', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Humidity Threshold', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 42, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Humidity Threshold', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-42', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_low_battery_report', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Low Battery Report', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 39, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Low Battery Report', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-39', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_lower_limit_value_of_humidity_sensor', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lower limit value of humidity sensor', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 52, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Lower limit value of humidity sensor', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-52', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_lower_limit_value_of_lighting_sensor', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lower limit value of Lighting sensor', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 54, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Lower limit value of Lighting sensor', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-54', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_lower_limit_value_of_ultraviolet_sensor', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lower limit value of ultraviolet sensor', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 56, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Lower limit value of ultraviolet sensor', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-56', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_lower_temperature_limit', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Lower temperature limit', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 50, + 'property_key': 4294901760, + 'property_key_name': None, + 'property_name': 'Lower temperature limit', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-50-4294901760', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_luminance_sensor_calibration', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Luminance Sensor Calibration', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 203, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Luminance Sensor Calibration', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-203', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_luminance_threshold', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Luminance Threshold', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 43, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Luminance Threshold', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-43', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_motion_sensor_reset_timeout', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion Sensor reset timeout', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 3, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Motion Sensor reset timeout', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-3', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_recover_limit_value_of_humidity_sensor', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Recover limit value of humidity sensor', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 58, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Recover limit value of humidity sensor', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-58', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_recover_limit_value_of_lighting_sensor', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Recover limit value of Lighting sensor.', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 59, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Recover limit value of Lighting sensor.', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-59', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_recover_limit_value_of_temperature_sensor', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Recover limit value of temperature sensor', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 57, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Recover limit value of temperature sensor', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-57', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_recover_limit_value_of_ultraviolet_sensor', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Recover limit value of Ultraviolet sensor', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 60, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Recover limit value of Ultraviolet sensor', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-60', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_send_a_report_if_the_measurement_is_out_of_limits', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Send a report if the measurement is out of limits', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 48, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Send a report if the measurement is out of limits', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-48', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_temperature_calibration', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature Calibration', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 201, + 'property_key': 65280, + 'property_key_name': None, + 'property_name': 'Temperature Calibration', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-201-65280', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_temperature_threshold', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature Threshold', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 41, + 'property_key': 16776960, + 'property_key_name': None, + 'property_name': 'Temperature Threshold', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-41-16776960', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_timeout_after_wake_up', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Timeout after wake up', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 8, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Timeout after wake up', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-8', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_ultraviolet_sensor_calibration', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ultraviolet Sensor Calibration', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 204, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Ultraviolet Sensor Calibration', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-204', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_ultraviolet_threshold', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ultraviolet Threshold', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 45, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Ultraviolet Threshold', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-45', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_upper_limit_value_of_humidity_sensor', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Upper limit value of humidity sensor', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 51, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Upper limit value of humidity sensor', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-51', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_upper_limit_value_of_lighting_sensor', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Upper limit value of Lighting sensor', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 53, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Upper limit value of Lighting sensor', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-53', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_upper_limit_value_of_ultraviolet_sensor', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Upper limit value of ultraviolet sensor', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 55, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Upper limit value of ultraviolet sensor', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-55', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'number', + 'entity_category': 'config', + 'entity_id': 'number.multisensor_6_upper_temperature_limit', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Upper temperature limit', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 49, + 'property_key': 4294901760, + 'property_key_name': None, + 'property_name': 'Upper temperature limit', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-49-4294901760', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'select', + 'entity_category': 'config', + 'entity_id': 'select.multisensor_6_disable_enable_configuration_lock', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disable/Enable Configuration Lock', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 252, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Disable/Enable Configuration Lock', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-252', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'select', + 'entity_category': 'config', + 'entity_id': 'select.multisensor_6_led_function', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LED function', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 81, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'LED function', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-81', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'select', + 'entity_category': 'config', + 'entity_id': 'select.multisensor_6_motion_sensor_sensitivity', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion sensor sensitivity', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 4, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Motion sensor sensitivity', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-4', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'select', + 'entity_category': 'config', + 'entity_id': 'select.multisensor_6_motion_sensor_triggered_command', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Motion Sensor Triggered Command', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 5, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Motion Sensor Triggered Command', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-5', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'select', + 'entity_category': 'config', + 'entity_id': 'select.multisensor_6_selective_reporting', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Selective Reporting', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 40, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Selective Reporting', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-40', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'select', + 'entity_category': 'config', + 'entity_id': 'select.multisensor_6_send_alarm_report_if_low_temperature', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Send Alarm Report if low temperature', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 46, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Send Alarm Report if low temperature', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-46', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'select', + 'entity_category': 'config', + 'entity_id': 'select.multisensor_6_stay_awake_in_battery_mode', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Stay Awake in Battery Mode', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 2, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Stay Awake in Battery Mode', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-2', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'select', + 'entity_category': 'config', + 'entity_id': 'select.multisensor_6_temperature_calibration_unit', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature Calibration (Unit)', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 201, + 'property_key': 255, + 'property_key_name': None, + 'property_name': 'Temperature Calibration (Unit)', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-201-255', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'select', + 'entity_category': 'config', + 'entity_id': 'select.multisensor_6_temperature_threshold_unit', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Temperature Threshold (Unit)', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 41, + 'property_key': 15, + 'property_key_name': None, + 'property_name': 'Temperature Threshold (Unit)', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-41-15', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.multisensor_6_air_temperature', + 'hidden_by': None, + 'original_device_class': 'temperature', + 'original_icon': None, + 'original_name': 'Air temperature', + 'primary_value': dict({ + 'command_class': 49, + 'command_class_name': 'Multilevel Sensor', + 'endpoint': 0, + 'property': 'Air temperature', + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Air temperature', + }), + 'supported_features': 0, + 'unit_of_measurement': '°C', + 'value_id': '52-49-0-Air temperature', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.multisensor_6_battery_level', + 'hidden_by': None, + 'original_device_class': 'battery', + 'original_icon': None, + 'original_name': 'Battery level', + 'primary_value': dict({ + 'command_class': 128, + 'command_class_name': 'Battery', + 'endpoint': 0, + 'property': 'level', + 'property_key': None, + 'property_key_name': None, + 'property_name': 'level', + }), + 'supported_features': 0, + 'unit_of_measurement': '%', + 'value_id': '52-128-0-level', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.multisensor_6_humidity', + 'hidden_by': None, + 'original_device_class': 'humidity', + 'original_icon': None, + 'original_name': 'Humidity', + 'primary_value': dict({ + 'command_class': 49, + 'command_class_name': 'Multilevel Sensor', + 'endpoint': 0, + 'property': 'Humidity', + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Humidity', + }), + 'supported_features': 0, + 'unit_of_measurement': '%', + 'value_id': '52-49-0-Humidity', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.multisensor_6_illuminance', + 'hidden_by': None, + 'original_device_class': 'illuminance', + 'original_icon': None, + 'original_name': 'Illuminance', + 'primary_value': dict({ + 'command_class': 49, + 'command_class_name': 'Multilevel Sensor', + 'endpoint': 0, + 'property': 'Illuminance', + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Illuminance', + }), + 'supported_features': 0, + 'unit_of_measurement': 'lx', + 'value_id': '52-49-0-Illuminance', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.multisensor_6_out_of_limit_state_of_the_sensors', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Out-of-limit state of the Sensors', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 61, + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Out-of-limit state of the Sensors', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-61', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.multisensor_6_power_mode', + 'hidden_by': None, + 'original_device_class': 'enum', + 'original_icon': None, + 'original_name': 'Power Mode', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 9, + 'property_key': 256, + 'property_key_name': None, + 'property_name': 'Power Mode', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-9-256', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.multisensor_6_sleep_state', + 'hidden_by': None, + 'original_device_class': 'enum', + 'original_icon': None, + 'original_name': 'Sleep State', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 9, + 'property_key': 1, + 'property_key_name': None, + 'property_name': 'Sleep State', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-9-1', + }), + dict({ + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.multisensor_6_ultraviolet', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ultraviolet', + 'primary_value': dict({ + 'command_class': 49, + 'command_class_name': 'Multilevel Sensor', + 'endpoint': 0, + 'property': 'Ultraviolet', + 'property_key': None, + 'property_key_name': None, + 'property_name': 'Ultraviolet', + }), + 'supported_features': 0, + 'unit_of_measurement': 'UV index', + 'value_id': '52-49-0-Ultraviolet', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_1_send_battery_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 1: Send battery reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 101, + 'property_key': 1, + 'property_key_name': None, + 'property_name': 'Group 1: Send battery reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-101-1', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_1_send_humidity_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 1: Send humidity reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 101, + 'property_key': 64, + 'property_key_name': None, + 'property_name': 'Group 1: Send humidity reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-101-64', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_1_send_luminance_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 1: Send luminance reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 101, + 'property_key': 128, + 'property_key_name': None, + 'property_name': 'Group 1: Send luminance reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-101-128', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_1_send_temperature_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 1: Send temperature reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 101, + 'property_key': 32, + 'property_key_name': None, + 'property_name': 'Group 1: Send temperature reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-101-32', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_1_send_ultraviolet_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 1: Send ultraviolet reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 101, + 'property_key': 16, + 'property_key_name': None, + 'property_name': 'Group 1: Send ultraviolet reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-101-16', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_2_send_battery_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 2: Send battery reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 102, + 'property_key': 1, + 'property_key_name': None, + 'property_name': 'Group 2: Send battery reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-102-1', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_2_send_humidity_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 2: Send humidity reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 102, + 'property_key': 64, + 'property_key_name': None, + 'property_name': 'Group 2: Send humidity reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-102-64', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_2_send_luminance_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 2: Send luminance reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 102, + 'property_key': 128, + 'property_key_name': None, + 'property_name': 'Group 2: Send luminance reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-102-128', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_2_send_temperature_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 2: Send temperature reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 102, + 'property_key': 32, + 'property_key_name': None, + 'property_name': 'Group 2: Send temperature reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-102-32', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_2_send_ultraviolet_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 2: Send ultraviolet reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 102, + 'property_key': 16, + 'property_key_name': None, + 'property_name': 'Group 2: Send ultraviolet reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-102-16', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_3_send_battery_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 3: Send battery reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 103, + 'property_key': 1, + 'property_key_name': None, + 'property_name': 'Group 3: Send battery reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-103-1', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_3_send_humidity_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 3: Send humidity reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 103, + 'property_key': 64, + 'property_key_name': None, + 'property_name': 'Group 3: Send humidity reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-103-64', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_3_send_luminance_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 3: Send luminance reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 103, + 'property_key': 128, + 'property_key_name': None, + 'property_name': 'Group 3: Send luminance reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-103-128', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_3_send_temperature_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 3: Send temperature reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 103, + 'property_key': 32, + 'property_key_name': None, + 'property_name': 'Group 3: Send temperature reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-103-32', + }), + dict({ + 'disabled': True, + 'disabled_by': 'integration', + 'domain': 'switch', + 'entity_category': 'config', + 'entity_id': 'switch.multisensor_6_group_3_send_ultraviolet_reports', + 'hidden_by': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group 3: Send ultraviolet reports', + 'primary_value': dict({ + 'command_class': 112, + 'command_class_name': 'Configuration', + 'endpoint': 0, + 'property': 103, + 'property_key': 16, + 'property_key_name': None, + 'property_name': 'Group 3: Send ultraviolet reports', + }), + 'supported_features': 0, + 'unit_of_measurement': None, + 'value_id': '52-112-0-103-16', + }), + ]), + 'state': dict({ + 'deviceClass': dict({ + 'basic': dict({ + 'key': 2, + 'label': 'Static Controller', + }), + 'generic': dict({ + 'key': 21, + 'label': 'Multilevel Sensor', + }), + 'mandatoryControlledCCs': list([ + ]), + 'mandatorySupportedCCs': list([ + ]), + 'specific': dict({ + 'key': 1, + 'label': 'Routing Multilevel Sensor', + }), + }), + 'deviceConfig': dict({ + 'description': 'Multisensor 6', + 'devices': list([ + dict({ + 'productId': '0x0064', + 'productType': '0x0002', + }), + dict({ + 'productId': '0x0064', + 'productType': '0x0102', + }), + dict({ + 'productId': '0x0064', + 'productType': '0x0202', + }), + ]), + 'firmwareVersion': dict({ + 'max': '255.255', + 'min': '1.10', + }), + 'label': 'ZW100', + 'manufacturer': 'AEON Labs', + 'manufacturerId': 134, + 'paramInformation': dict({ + '_map': dict({ + }), + }), + }), + 'endpoints': dict({ + '0': dict({ + 'commandClasses': list([ + dict({ + 'id': 113, + 'isSecure': False, + 'name': 'Notification', + 'version': 8, + }), + ]), + 'index': 0, + 'installerIcon': 3079, + 'nodeId': 52, + 'userIcon': 3079, + }), + }), + 'firmwareVersion': '1.12', + 'highestSecurityClass': 7, + 'index': 0, + 'installerIcon': 3079, + 'interviewAttempts': 1, + 'isBeaming': True, + 'isControllerNode': False, + 'isFrequentListening': False, + 'isListening': True, + 'isRouting': True, + 'isSecure': False, + 'label': 'ZW100', + 'manufacturerId': 134, + 'maxBaudRate': 40000, + 'neighbors': list([ + 1, + 32, + ]), + 'nodeId': 52, + 'nodeType': 0, + 'productId': 100, + 'productType': 258, + 'ready': True, + 'roleType': 5, + 'status': 1, + 'userIcon': 3079, + 'values': dict({ + '52-112-0-100': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 0, + 'description': 'Reset 101-103 to defaults', + 'format': 0, + 'isFromConfig': True, + 'label': 'Set parameters 101-103 to default.', + 'max': 1, + 'min': 0, + 'readable': False, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 100, + 'propertyName': 'Set parameters 101-103 to default.', + }), + '52-112-0-101-1': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include battery information in periodic reports to Group 1', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 1: Send battery reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 101, + 'propertyKey': 1, + 'propertyName': 'Group 1: Send battery reports', + 'value': 1, + }), + '52-112-0-101-128': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include luminance information in periodic reports to Group 1', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 1: Send luminance reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 101, + 'propertyKey': 128, + 'propertyName': 'Group 1: Send luminance reports', + 'value': 1, + }), + '52-112-0-101-16': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include ultraviolet information in periodic reports to Group 1', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 1: Send ultraviolet reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 101, + 'propertyKey': 16, + 'propertyName': 'Group 1: Send ultraviolet reports', + 'value': 1, + }), + '52-112-0-101-32': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include temperature information in periodic reports to Group 1', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 1: Send temperature reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 101, + 'propertyKey': 32, + 'propertyName': 'Group 1: Send temperature reports', + 'value': 1, + }), + '52-112-0-101-64': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include humidity information in periodic reports to Group 1', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 1: Send humidity reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 101, + 'propertyKey': 64, + 'propertyName': 'Group 1: Send humidity reports', + 'value': 1, + }), + '52-112-0-102-1': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include battery information in periodic reports to Group 2', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 2: Send battery reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 102, + 'propertyKey': 1, + 'propertyName': 'Group 2: Send battery reports', + 'value': 0, + }), + '52-112-0-102-128': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include luminance information in periodic reports to Group 2', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 2: Send luminance reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 102, + 'propertyKey': 128, + 'propertyName': 'Group 2: Send luminance reports', + 'value': 0, + }), + '52-112-0-102-16': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include ultraviolet information in periodic reports to Group 2', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 2: Send ultraviolet reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 102, + 'propertyKey': 16, + 'propertyName': 'Group 2: Send ultraviolet reports', + 'value': 0, + }), + '52-112-0-102-32': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include temperature information in periodic reports to Group 2', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 2: Send temperature reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 102, + 'propertyKey': 32, + 'propertyName': 'Group 2: Send temperature reports', + 'value': 0, + }), + '52-112-0-102-64': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include humidity information in periodic reports to Group 2', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 2: Send humidity reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 102, + 'propertyKey': 64, + 'propertyName': 'Group 2: Send humidity reports', + 'value': 0, + }), + '52-112-0-103-1': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include battery information in periodic reports to Group 3', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 3: Send battery reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 103, + 'propertyKey': 1, + 'propertyName': 'Group 3: Send battery reports', + 'value': 0, + }), + '52-112-0-103-128': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include luminance information in periodic reports to Group 3', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 3: Send luminance reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 103, + 'propertyKey': 128, + 'propertyName': 'Group 3: Send luminance reports', + 'value': 0, + }), + '52-112-0-103-16': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include ultraviolet information in periodic reports to Group 3', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 3: Send ultraviolet reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 103, + 'propertyKey': 16, + 'propertyName': 'Group 3: Send ultraviolet reports', + 'value': 0, + }), + '52-112-0-103-32': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include temperature information in periodic reports to Group 3', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 3: Send temperature reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 103, + 'propertyKey': 32, + 'propertyName': 'Group 3: Send temperature reports', + 'value': 0, + }), + '52-112-0-103-64': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Include humidity information in periodic reports to Group 3', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 3: Send humidity reports', + 'max': 1, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 103, + 'propertyKey': 64, + 'propertyName': 'Group 3: Send humidity reports', + 'value': 0, + }), + '52-112-0-110': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 0, + 'description': 'Set parameters 111-113 to default.', + 'format': 0, + 'isFromConfig': True, + 'label': 'Set parameters 111-113 to default.', + 'max': 1, + 'min': 0, + 'readable': False, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 110, + 'propertyName': 'Set parameters 111-113 to default.', + }), + '52-112-0-111': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 3600, + 'description': 'How often to update Group 1', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 1 Report Interval', + 'max': 2678400, + 'min': 5, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 111, + 'propertyName': 'Group 1 Report Interval', + 'value': 3600, + }), + '52-112-0-112': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 3600, + 'description': 'Group 2 Report Interval', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 2 Report Interval', + 'max': 2678400, + 'min': 5, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 112, + 'propertyName': 'Group 2 Report Interval', + 'value': 3600, + }), + '52-112-0-113': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 3600, + 'description': 'Group 3 Report Interval', + 'format': 0, + 'isFromConfig': True, + 'label': 'Group 3 Report Interval', + 'max': 2678400, + 'min': 5, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 113, + 'propertyName': 'Group 3 Report Interval', + 'value': 3600, + }), + '52-112-0-2': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 0, + 'description': 'Stay awake for 10 minutes at power on', + 'format': 0, + 'isFromConfig': True, + 'label': 'Stay Awake in Battery Mode', + 'max': 1, + 'min': 0, + 'readable': True, + 'states': dict({ + '0': 'Disable', + '1': 'Enable', + }), + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 2, + 'propertyName': 'Stay Awake in Battery Mode', + 'value': 0, + }), + '52-112-0-201-255': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 1, + 'format': 0, + 'isFromConfig': True, + 'label': 'Temperature Calibration (Unit)', + 'max': 2, + 'min': 1, + 'readable': True, + 'states': dict({ + '1': 'Celsius', + '2': 'Fahrenheit', + }), + 'type': 'number', + 'valueSize': 2, + 'writeable': True, + }), + 'property': 201, + 'propertyKey': 255, + 'propertyName': 'Temperature Calibration (Unit)', + 'value': 2, + }), + '52-112-0-201-65280': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 0, + 'format': 0, + 'isFromConfig': True, + 'label': 'Temperature Calibration', + 'max': 127, + 'min': -127, + 'readable': True, + 'type': 'number', + 'valueSize': 2, + 'writeable': True, + }), + 'property': 201, + 'propertyKey': 65280, + 'propertyName': 'Temperature Calibration', + 'value': 0, + }), + '52-112-0-202': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 0, + 'description': 'Humidity Sensor Calibration', + 'format': 0, + 'isFromConfig': True, + 'label': 'Humidity Sensor Calibration', + 'max': 50, + 'min': -50, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 202, + 'propertyName': 'Humidity Sensor Calibration', + 'value': 0, + }), + '52-112-0-203': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 0, + 'description': 'Luminance Sensor Calibration', + 'format': 0, + 'isFromConfig': True, + 'label': 'Luminance Sensor Calibration', + 'max': 1000, + 'min': -1000, + 'readable': True, + 'type': 'number', + 'valueSize': 2, + 'writeable': True, + }), + 'property': 203, + 'propertyName': 'Luminance Sensor Calibration', + 'value': 0, + }), + '52-112-0-204': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 0, + 'description': 'Ultraviolet Sensor Calibration', + 'format': 0, + 'isFromConfig': True, + 'label': 'Ultraviolet Sensor Calibration', + 'max': 10, + 'min': -10, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 204, + 'propertyName': 'Ultraviolet Sensor Calibration', + 'value': 0, + }), + '52-112-0-252': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 0, + 'description': 'Disable/Enable Configuration Lock (0=Disable, 1=Enable)', + 'format': 0, + 'isFromConfig': True, + 'label': 'Disable/Enable Configuration Lock', + 'max': 1, + 'min': 0, + 'readable': True, + 'states': dict({ + '0': 'Disable', + '1': 'Enable', + }), + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 252, + 'propertyName': 'Disable/Enable Configuration Lock', + 'value': 0, + }), + '52-112-0-255': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 0, + 'format': 0, + 'isFromConfig': True, + 'label': 'Reset to default factory settings', + 'max': 1431655765, + 'min': 0, + 'readable': False, + 'states': dict({ + '1': 'Resets all configuration parameters to defaults', + '1431655765': 'Reset to default factory settings and be excluded', + }), + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 255, + 'propertyName': 'Reset to default factory settings', + }), + '52-112-0-3': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 240, + 'description': 'Motion Sensor reset timeout', + 'format': 0, + 'isFromConfig': True, + 'label': 'Motion Sensor reset timeout', + 'max': 3600, + 'min': 10, + 'readable': True, + 'type': 'number', + 'valueSize': 2, + 'writeable': True, + }), + 'property': 3, + 'propertyName': 'Motion Sensor reset timeout', + 'value': 240, + }), + '52-112-0-39': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 20, + 'description': 'Report Low Battery if below this value', + 'format': 0, + 'isFromConfig': True, + 'label': 'Low Battery Report', + 'max': 50, + 'min': 10, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 39, + 'propertyName': 'Low Battery Report', + 'value': 20, + }), + '52-112-0-4': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 5, + 'description': 'Sensitivity level of PIR sensor (1=minimum, 5=maximum)', + 'format': 1, + 'isFromConfig': True, + 'label': 'Motion sensor sensitivity', + 'max': 255, + 'min': 0, + 'readable': True, + 'states': dict({ + '0': 'Disable', + '1': 'Enable, sensitivity level 1 (minimum)', + '2': 'Enable, sensitivity level 2', + '3': 'Enable, sensitivity level 3', + '4': 'Enable, sensitivity level 4', + '5': 'Enable, sensitivity level 5 (maximum)', + }), + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 4, + 'propertyName': 'Motion sensor sensitivity', + 'value': 5, + }), + '52-112-0-40': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 0, + 'description': 'Select to report on thresholds', + 'format': 0, + 'isFromConfig': True, + 'label': 'Selective Reporting', + 'max': 1, + 'min': 0, + 'readable': True, + 'states': dict({ + '0': 'Disable', + '1': 'Enable', + }), + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 40, + 'propertyName': 'Selective Reporting', + 'value': 0, + }), + '52-112-0-41-15': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 1, + 'format': 0, + 'isFromConfig': True, + 'label': 'Temperature Threshold (Unit)', + 'max': 2, + 'min': 1, + 'readable': True, + 'states': dict({ + '1': 'Celsius', + '2': 'Fahrenheit', + }), + 'type': 'number', + 'valueSize': 3, + 'writeable': True, + }), + 'property': 41, + 'propertyKey': 15, + 'propertyName': 'Temperature Threshold (Unit)', + 'value': 0, + }), + '52-112-0-41-16776960': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 20, + 'description': 'Threshold change in temperature to induce an automatic report.', + 'format': 0, + 'isFromConfig': True, + 'label': 'Temperature Threshold', + 'max': 100, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 3, + 'writeable': True, + }), + 'property': 41, + 'propertyKey': 16776960, + 'propertyName': 'Temperature Threshold', + 'value': 5122, + }), + '52-112-0-42': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 10, + 'description': 'Humidity percent change threshold', + 'format': 0, + 'isFromConfig': True, + 'label': 'Humidity Threshold', + 'max': 100, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 42, + 'propertyName': 'Humidity Threshold', + 'value': 10, + }), + '52-112-0-43': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 100, + 'description': 'Luminance change threshold', + 'format': 0, + 'isFromConfig': True, + 'label': 'Luminance Threshold', + 'max': 1000, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 2, + 'writeable': True, + }), + 'property': 43, + 'propertyName': 'Luminance Threshold', + 'value': 100, + }), + '52-112-0-44': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 10, + 'description': 'Battery level threshold', + 'format': 0, + 'isFromConfig': True, + 'label': 'Battery Threshold', + 'max': 100, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 44, + 'propertyName': 'Battery Threshold', + 'value': 10, + }), + '52-112-0-45': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 2, + 'description': 'Ultraviolet change threshold', + 'format': 0, + 'isFromConfig': True, + 'label': 'Ultraviolet Threshold', + 'max': 100, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 45, + 'propertyName': 'Ultraviolet Threshold', + 'value': 2, + }), + '52-112-0-46': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 0, + 'description': 'Send an alarm report if temperature is less than -15 °C', + 'format': 1, + 'isFromConfig': True, + 'label': 'Send Alarm Report if low temperature', + 'max': 255, + 'min': 0, + 'readable': True, + 'states': dict({ + '0': 'Disable', + '1': 'Enable', + }), + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 46, + 'propertyName': 'Send Alarm Report if low temperature', + 'value': 0, + }), + '52-112-0-48': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 0, + 'description': 'Send report when measurement is at upper/lower limit', + 'format': 1, + 'isFromConfig': True, + 'label': 'Send a report if the measurement is out of limits', + 'max': 255, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 48, + 'propertyName': 'Send a report if the measurement is out of limits', + 'value': 0, + }), + '52-112-0-49-4294901760': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 280, + 'format': 0, + 'isFromConfig': True, + 'label': 'Upper temperature limit', + 'max': 2120, + 'min': -400, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 49, + 'propertyKey': 4294901760, + 'propertyName': 'Upper temperature limit', + 'value': 824, + }), + '52-112-0-49-65280': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 1, + 'format': 0, + 'isFromConfig': True, + 'label': 'Upper temperature limit (Unit)', + 'max': 2, + 'min': 1, + 'readable': False, + 'states': dict({ + '1': 'Celsius', + '2': 'Fahrenheit', + }), + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 49, + 'propertyKey': 65280, + 'propertyName': 'Upper temperature limit (Unit)', + 'value': 2, + }), + '52-112-0-5': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 1, + 'format': 1, + 'isFromConfig': True, + 'label': 'Motion Sensor Triggered Command', + 'max': 255, + 'min': 0, + 'readable': True, + 'states': dict({ + '1': 'Send Basic Set CC', + '2': 'Send Sensor Binary Report CC', + }), + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 5, + 'propertyName': 'Motion Sensor Triggered Command', + 'value': 1, + }), + '52-112-0-50-4294901760': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 0, + 'format': 0, + 'isFromConfig': True, + 'label': 'Lower temperature limit', + 'max': 2120, + 'min': -400, + 'readable': True, + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 50, + 'propertyKey': 4294901760, + 'propertyName': 'Lower temperature limit', + 'value': 320, + }), + '52-112-0-50-65280': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 1, + 'format': 0, + 'isFromConfig': True, + 'label': 'Lower temperature limit (Unit)', + 'max': 2, + 'min': 1, + 'readable': False, + 'states': dict({ + '1': 'Celsius', + '2': 'Fahrenheit', + }), + 'type': 'number', + 'valueSize': 4, + 'writeable': True, + }), + 'property': 50, + 'propertyKey': 65280, + 'propertyName': 'Lower temperature limit (Unit)', + 'value': 2, + }), + '52-112-0-51': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 60, + 'description': 'Upper limit value of humidity sensor', + 'format': 0, + 'isFromConfig': True, + 'label': 'Upper limit value of humidity sensor', + 'max': 100, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 51, + 'propertyName': 'Upper limit value of humidity sensor', + 'value': 60, + }), + '52-112-0-52': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 50, + 'description': 'Lower limit value of humidity sensor', + 'format': 0, + 'isFromConfig': True, + 'label': 'Lower limit value of humidity sensor', + 'max': 100, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 52, + 'propertyName': 'Lower limit value of humidity sensor', + 'value': 50, + }), + '52-112-0-53': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1000, + 'description': 'Upper limit value of Lighting sensor', + 'format': 0, + 'isFromConfig': True, + 'label': 'Upper limit value of Lighting sensor', + 'max': 30000, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 2, + 'writeable': True, + }), + 'property': 53, + 'propertyName': 'Upper limit value of Lighting sensor', + 'value': 1000, + }), + '52-112-0-54': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 100, + 'description': 'Lower limit value of Lighting sensor', + 'format': 0, + 'isFromConfig': True, + 'label': 'Lower limit value of Lighting sensor', + 'max': 30000, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 2, + 'writeable': True, + }), + 'property': 54, + 'propertyName': 'Lower limit value of Lighting sensor', + 'value': 100, + }), + '52-112-0-55': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 8, + 'description': 'Upper limit value of ultraviolet sensor', + 'format': 0, + 'isFromConfig': True, + 'label': 'Upper limit value of ultraviolet sensor', + 'max': 11, + 'min': 1, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 55, + 'propertyName': 'Upper limit value of ultraviolet sensor', + 'value': 8, + }), + '52-112-0-56': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 4, + 'description': 'Lower limit value of ultraviolet sensor', + 'format': 0, + 'isFromConfig': True, + 'label': 'Lower limit value of ultraviolet sensor', + 'max': 11, + 'min': 1, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 56, + 'propertyName': 'Lower limit value of ultraviolet sensor', + 'value': 4, + }), + '52-112-0-57': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 0, + 'description': 'Recover limit value of temperature sensor', + 'format': 1, + 'isFromConfig': True, + 'label': 'Recover limit value of temperature sensor', + 'max': 65535, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 2, + 'writeable': True, + }), + 'property': 57, + 'propertyName': 'Recover limit value of temperature sensor', + 'value': 5122, + }), + '52-112-0-58': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 5, + 'description': 'Recover limit value of humidity sensor', + 'format': 0, + 'isFromConfig': True, + 'label': 'Recover limit value of humidity sensor', + 'max': 50, + 'min': 1, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 58, + 'propertyName': 'Recover limit value of humidity sensor', + 'value': 5, + }), + '52-112-0-59': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 10, + 'description': 'Recover limit value of Lighting sensor.', + 'format': 1, + 'isFromConfig': True, + 'label': 'Recover limit value of Lighting sensor.', + 'max': 255, + 'min': 1, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 59, + 'propertyName': 'Recover limit value of Lighting sensor.', + 'value': 10, + }), + '52-112-0-60': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 2, + 'description': 'Recover limit value of Ultraviolet sensor', + 'format': 0, + 'isFromConfig': True, + 'label': 'Recover limit value of Ultraviolet sensor', + 'max': 5, + 'min': 1, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 60, + 'propertyName': 'Recover limit value of Ultraviolet sensor', + 'value': 2, + }), + '52-112-0-61': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 0, + 'description': 'Out-of-limit state of the Sensors', + 'format': 1, + 'isFromConfig': True, + 'label': 'Out-of-limit state of the Sensors', + 'max': 255, + 'min': 0, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': False, + }), + 'property': 61, + 'propertyName': 'Out-of-limit state of the Sensors', + 'value': 0, + }), + '52-112-0-64': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 1, + 'description': 'Default unit of the automatic temperature report', + 'format': 0, + 'isFromConfig': True, + 'label': 'Default unit of the automatic temperature report', + 'max': 2, + 'min': 1, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 64, + 'propertyName': 'Default unit of the automatic temperature report', + 'value': 2, + }), + '52-112-0-8': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': True, + 'default': 30, + 'description': 'Set the timeout of awake after the Wake Up CC is sent out...', + 'format': 1, + 'isFromConfig': True, + 'label': 'Timeout after wake up', + 'max': 255, + 'min': 8, + 'readable': True, + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 8, + 'propertyName': 'Timeout after wake up', + 'value': 15, + }), + '52-112-0-81': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 0, + 'description': 'Disable/Enable LED function', + 'format': 0, + 'isFromConfig': True, + 'label': 'LED function', + 'max': 2, + 'min': 0, + 'readable': True, + 'states': dict({ + '0': 'Enable LED blinking', + '1': 'Disable PIR LED', + '2': 'Disable ALL', + }), + 'type': 'number', + 'valueSize': 1, + 'writeable': True, + }), + 'property': 81, + 'propertyName': 'LED function', + 'value': 0, + }), + '52-112-0-9-1': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 0, + 'format': 0, + 'isFromConfig': True, + 'label': 'Sleep State', + 'max': 1, + 'min': 0, + 'readable': True, + 'states': dict({ + '0': 'Asleep', + '1': 'Awake', + }), + 'type': 'number', + 'valueSize': 2, + 'writeable': False, + }), + 'property': 9, + 'propertyKey': 1, + 'propertyName': 'Sleep State', + 'value': 0, + }), + '52-112-0-9-256': dict({ + 'commandClass': 112, + 'commandClassName': 'Configuration', + 'endpoint': 0, + 'metadata': dict({ + 'allowManualEntry': False, + 'default': 0, + 'format': 0, + 'isFromConfig': True, + 'label': 'Power Mode', + 'max': 1, + 'min': 0, + 'readable': True, + 'states': dict({ + '0': 'USB', + '1': 'Battery', + }), + 'type': 'number', + 'valueSize': 2, + 'writeable': False, + }), + 'property': 9, + 'propertyKey': 256, + 'propertyName': 'Power Mode', + 'value': 0, + }), + '52-113-0-Home Security-Cover status': dict({ + 'commandClass': 113, + 'commandClassName': 'Notification', + 'endpoint': 0, + 'metadata': dict({ + 'ccSpecific': dict({ + 'notificationType': 7, + }), + 'label': 'Cover status', + 'max': 255, + 'min': 0, + 'readable': True, + 'states': dict({ + '0': 'idle', + '3': 'Tampering, product cover removed', + }), + 'type': 'number', + 'writeable': False, + }), + 'property': 'Home Security', + 'propertyKey': 'Cover status', + 'propertyKeyName': 'Cover status', + 'propertyName': 'Home Security', + 'value': 0, + }), + '52-113-0-Home Security-Motion sensor status': dict({ + 'commandClass': 113, + 'commandClassName': 'Notification', + 'endpoint': 0, + 'metadata': dict({ + 'ccSpecific': dict({ + 'notificationType': 7, + }), + 'label': 'Motion sensor status', + 'max': 255, + 'min': 0, + 'readable': True, + 'states': dict({ + '0': 'idle', + '8': 'Motion detection', + }), + 'type': 'number', + 'writeable': False, + }), + 'property': 'Home Security', + 'propertyKey': 'Motion sensor status', + 'propertyKeyName': 'Motion sensor status', + 'propertyName': 'Home Security', + 'value': 8, + }), + '52-114-0-manufacturerId': dict({ + 'commandClass': 114, + 'commandClassName': 'Manufacturer Specific', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Manufacturer ID', + 'max': 65535, + 'min': 0, + 'readable': True, + 'type': 'number', + 'writeable': False, + }), + 'property': 'manufacturerId', + 'propertyName': 'manufacturerId', + 'value': 134, + }), + '52-114-0-productId': dict({ + 'commandClass': 114, + 'commandClassName': 'Manufacturer Specific', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Product ID', + 'max': 65535, + 'min': 0, + 'readable': True, + 'type': 'number', + 'writeable': False, + }), + 'property': 'productId', + 'propertyName': 'productId', + 'value': 100, + }), + '52-114-0-productType': dict({ + 'commandClass': 114, + 'commandClassName': 'Manufacturer Specific', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Product type', + 'max': 65535, + 'min': 0, + 'readable': True, + 'type': 'number', + 'writeable': False, + }), + 'property': 'productType', + 'propertyName': 'productType', + 'value': 258, + }), + '52-128-0-isLow': dict({ + 'commandClass': 128, + 'commandClassName': 'Battery', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Low battery level', + 'readable': True, + 'type': 'boolean', + 'writeable': False, + }), + 'property': 'isLow', + 'propertyName': 'isLow', + 'value': False, + }), + '52-128-0-level': dict({ + 'commandClass': 128, + 'commandClassName': 'Battery', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Battery level', + 'max': 100, + 'min': 0, + 'readable': True, + 'type': 'number', + 'unit': '%', + 'writeable': False, + }), + 'property': 'level', + 'propertyName': 'level', + 'value': 100, + }), + '52-132-0-controllerNodeId': dict({ + 'commandClass': 132, + 'commandClassName': 'Wake Up', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Node ID of the controller', + 'readable': True, + 'type': 'any', + 'writeable': False, + }), + 'property': 'controllerNodeId', + 'propertyName': 'controllerNodeId', + 'value': 1, + }), + '52-132-0-wakeUpInterval': dict({ + 'commandClass': 132, + 'commandClassName': 'Wake Up', + 'endpoint': 0, + 'metadata': dict({ + 'default': 3600, + 'label': 'Wake Up interval', + 'max': 3600, + 'min': 240, + 'readable': False, + 'steps': 60, + 'type': 'number', + 'writeable': True, + }), + 'property': 'wakeUpInterval', + 'propertyName': 'wakeUpInterval', + 'value': 3600, + }), + '52-134-0-firmwareVersions': dict({ + 'commandClass': 134, + 'commandClassName': 'Version', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Z-Wave chip firmware versions', + 'readable': True, + 'type': 'any', + 'writeable': False, + }), + 'property': 'firmwareVersions', + 'propertyName': 'firmwareVersions', + 'value': list([ + '1.12', + ]), + }), + '52-134-0-hardwareVersion': dict({ + 'commandClass': 134, + 'commandClassName': 'Version', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Z-Wave chip hardware version', + 'readable': True, + 'type': 'any', + 'writeable': False, + }), + 'property': 'hardwareVersion', + 'propertyName': 'hardwareVersion', + }), + '52-134-0-libraryType': dict({ + 'commandClass': 134, + 'commandClassName': 'Version', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Libary type', + 'readable': True, + 'type': 'any', + 'writeable': False, + }), + 'property': 'libraryType', + 'propertyName': 'libraryType', + 'value': 3, + }), + '52-134-0-protocolVersion': dict({ + 'commandClass': 134, + 'commandClassName': 'Version', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Z-Wave protocol version', + 'readable': True, + 'type': 'any', + 'writeable': False, + }), + 'property': 'protocolVersion', + 'propertyName': 'protocolVersion', + 'value': '4.54', + }), + '52-32-0-currentValue': dict({ + 'commandClass': 32, + 'commandClassName': 'Basic', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Current value', + 'max': 99, + 'min': 0, + 'readable': True, + 'type': 'number', + 'writeable': False, + }), + 'property': 'currentValue', + 'propertyName': 'currentValue', + 'value': 255, + }), + '52-32-0-targetValue': dict({ + 'commandClass': 32, + 'commandClassName': 'Basic', + 'endpoint': 0, + 'metadata': dict({ + 'label': 'Target value', + 'max': 99, + 'min': 0, + 'readable': True, + 'type': 'number', + 'writeable': True, + }), + 'property': 'targetValue', + 'propertyName': 'targetValue', + }), + '52-48-0-Any': dict({ + 'commandClass': 48, + 'commandClassName': 'Binary Sensor', + 'endpoint': 0, + 'metadata': dict({ + 'ccSpecific': dict({ + 'sensorType': 255, + }), + 'label': 'Any', + 'readable': True, + 'type': 'boolean', + 'writeable': False, + }), + 'property': 'Any', + 'propertyName': 'Any', + 'value': False, + }), + '52-49-0-Air temperature': dict({ + 'commandClass': 49, + 'commandClassName': 'Multilevel Sensor', + 'endpoint': 0, + 'metadata': dict({ + 'ccSpecific': dict({ + 'scale': 0, + 'sensorType': 1, + }), + 'label': 'Air temperature', + 'readable': True, + 'type': 'number', + 'unit': '°C', + 'writeable': False, + }), + 'property': 'Air temperature', + 'propertyName': 'Air temperature', + 'value': 9, + }), + '52-49-0-Humidity': dict({ + 'commandClass': 49, + 'commandClassName': 'Multilevel Sensor', + 'endpoint': 0, + 'metadata': dict({ + 'ccSpecific': dict({ + 'scale': 0, + 'sensorType': 5, + }), + 'label': 'Humidity', + 'readable': True, + 'type': 'number', + 'unit': '%', + 'writeable': False, + }), + 'property': 'Humidity', + 'propertyName': 'Humidity', + 'value': 65, + }), + '52-49-0-Illuminance': dict({ + 'commandClass': 49, + 'commandClassName': 'Multilevel Sensor', + 'endpoint': 0, + 'metadata': dict({ + 'ccSpecific': dict({ + 'scale': 1, + 'sensorType': 3, + }), + 'label': 'Illuminance', + 'readable': True, + 'type': 'number', + 'unit': 'Lux', + 'writeable': False, + }), + 'property': 'Illuminance', + 'propertyName': 'Illuminance', + 'value': 0, + }), + '52-49-0-Ultraviolet': dict({ + 'commandClass': 49, + 'commandClassName': 'Multilevel Sensor', + 'endpoint': 0, + 'metadata': dict({ + 'ccSpecific': dict({ + 'scale': 0, + 'sensorType': 27, + }), + 'label': 'Ultraviolet', + 'readable': True, + 'type': 'number', + 'writeable': False, + }), + 'property': 'Ultraviolet', + 'propertyName': 'Ultraviolet', + 'value': 1, + }), + }), + 'version': 4, + 'zwavePlusVersion': 1, + }), + 'versionInfo': dict({ + 'driverVersion': '6.0.0-beta.0', + 'maxSchemaVersion': 0, + 'minSchemaVersion': 0, + 'serverVersion': '1.0.0', + }), + }) +# --- diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index 0e6645d9d61..835b85177fe 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -1,9 +1,11 @@ """Test the Z-Wave JS diagnostics.""" import copy +from typing import Any, cast from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from zwave_js_server.const import CommandClass from zwave_js_server.event import Event from zwave_js_server.model.node import Node @@ -13,7 +15,6 @@ from homeassistant.components.zwave_js.diagnostics import ( ZwaveValueMatcher, async_get_device_diagnostics, ) -from homeassistant.components.zwave_js.discovery import async_discover_node_values from homeassistant.components.zwave_js.helpers import ( get_device_id, get_value_id_from_unique_id, @@ -58,6 +59,7 @@ async def test_device_diagnostics( integration, hass_client: ClientSessionGenerator, version_state, + snapshot: SnapshotAssertion, ) -> None: """Test the device level diagnostics data dump.""" device = device_registry.async_get_device( @@ -113,18 +115,18 @@ async def test_device_diagnostics( # Entities that are created outside of discovery (e.g. node status sensor and # ping button) as well as helper entities created from other integrations should # not be in dump. - assert len(diagnostics_data["entities"]) == len( - list(async_discover_node_values(multisensor_6, device, {device.id: set()})) - ) + assert diagnostics_data == snapshot + assert any( - entity.entity_id == "test.unrelated_entity" - for entity in er.async_entries_for_device(entity_registry, device.id) + entity_entry.entity_id == "test.unrelated_entity" + for entity_entry in er.async_entries_for_device(entity_registry, device.id) ) # Explicitly check that the entity that is not part of this config entry is not # in the dump. + diagnostics_entities = cast(list[dict[str, Any]], diagnostics_data["entities"]) assert not any( entity["entity_id"] == "test.unrelated_entity" - for entity in diagnostics_data["entities"] + for entity in diagnostics_entities ) assert diagnostics_data["state"] == { **multisensor_6.data, @@ -171,6 +173,7 @@ async def test_device_diagnostics_missing_primary_value( entity_id = "sensor.multisensor_6_air_temperature" entry = entity_registry.async_get(entity_id) + assert entry # check that the primary value for the entity exists in the diagnostics diagnostics_data = await get_diagnostics_for_device( @@ -180,9 +183,8 @@ async def test_device_diagnostics_missing_primary_value( value = multisensor_6.values.get(get_value_id_from_unique_id(entry.unique_id)) assert value - air_entity = next( - x for x in diagnostics_data["entities"] if x["entity_id"] == entity_id - ) + diagnostics_entities = cast(list[dict[str, Any]], diagnostics_data["entities"]) + air_entity = next(x for x in diagnostics_entities if x["entity_id"] == entity_id) assert air_entity["value_id"] == value.value_id assert air_entity["primary_value"] == { @@ -218,9 +220,8 @@ async def test_device_diagnostics_missing_primary_value( hass, hass_client, integration, device ) - air_entity = next( - x for x in diagnostics_data["entities"] if x["entity_id"] == entity_id - ) + diagnostics_entities = cast(list[dict[str, Any]], diagnostics_data["entities"]) + air_entity = next(x for x in diagnostics_entities if x["entity_id"] == entity_id) assert air_entity["value_id"] == value.value_id assert air_entity["primary_value"] is None @@ -266,5 +267,6 @@ async def test_device_diagnostics_secret_value( diagnostics_data = await get_diagnostics_for_device( hass, hass_client, integration, device ) - test_value = _find_ultraviolet_val(diagnostics_data["state"]) + diagnostics_node_state = cast(dict[str, Any], diagnostics_data["state"]) + test_value = _find_ultraviolet_val(diagnostics_node_state) assert test_value["value"] == REDACTED diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4c77d6d3c41..ad268ee8af3 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1574,13 +1574,9 @@ async def test_disabled_entity_on_value_removed( hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, client, integration ) -> None: """Test that when entity primary values are removed the entity is removed.""" - # re-enable this default-disabled entity - sensor_cover_entity = "sensor.4_in_1_sensor_home_security_cover_status" idle_cover_status_button_entity = ( "button.4_in_1_sensor_idle_home_security_cover_status" ) - entity_registry.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None) - await hass.async_block_till_done() # must reload the integration when enabling an entity await hass.config_entries.async_unload(integration.entry_id) @@ -1591,10 +1587,6 @@ async def test_disabled_entity_on_value_removed( await hass.async_block_till_done() assert integration.state is ConfigEntryState.LOADED - state = hass.states.get(sensor_cover_entity) - assert state - assert state.state != STATE_UNAVAILABLE - state = hass.states.get(idle_cover_status_button_entity) assert state assert state.state != STATE_UNAVAILABLE @@ -1688,10 +1680,6 @@ async def test_disabled_entity_on_value_removed( assert state assert state.state == STATE_UNAVAILABLE - state = hass.states.get(sensor_cover_entity) - assert state - assert state.state == STATE_UNAVAILABLE - state = hass.states.get(idle_cover_status_button_entity) assert state assert state.state == STATE_UNAVAILABLE @@ -1707,7 +1695,6 @@ async def test_disabled_entity_on_value_removed( | { battery_level_entity, binary_cover_entity, - sensor_cover_entity, idle_cover_status_button_entity, } == new_unavailable_entities diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 34c50b8d449..c93b722334b 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -9,7 +9,6 @@ from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node from homeassistant.components.sensor import ( - ATTR_OPTIONS, ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, @@ -54,7 +53,6 @@ from .common import ( ENERGY_SENSOR, HUMIDITY_SENSOR, METER_ENERGY_SENSOR, - NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, VOLTAGE_SENSOR, ) @@ -227,60 +225,6 @@ async def test_basic_cc_sensor( assert state.state == "255.0" -async def test_disabled_notification_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration -) -> None: - """Test sensor is created from Notification CC and is disabled.""" - entity_entry = entity_registry.async_get(NOTIFICATION_MOTION_SENSOR) - - assert entity_entry - assert entity_entry.disabled - assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Test enabling entity - updated_entry = entity_registry.async_update_entity( - entity_entry.entity_id, disabled_by=None - ) - assert updated_entry != entity_entry - assert updated_entry.disabled is False - - # reload integration and check if entity is correctly there - await hass.config_entries.async_reload(integration.entry_id) - await hass.async_block_till_done() - - state = hass.states.get(NOTIFICATION_MOTION_SENSOR) - assert state.state == "Motion detection" - assert state.attributes[ATTR_VALUE] == 8 - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM - assert state.attributes[ATTR_OPTIONS] == ["idle", "Motion detection"] - - event = Event( - "value updated", - { - "source": "node", - "event": "value updated", - "nodeId": multisensor_6.node_id, - "args": { - "commandClassName": "Notification", - "commandClass": 113, - "endpoint": 0, - "property": "Home Security", - "propertyKey": "Motion sensor status", - "newValue": None, - "prevValue": 0, - "propertyName": "Home Security", - "propertyKeyName": "Motion sensor status", - }, - }, - ) - - multisensor_6.receive_event(event) - await hass.async_block_till_done() - state = hass.states.get(NOTIFICATION_MOTION_SENSOR) - assert state - assert state.state == STATE_UNKNOWN - - async def test_config_parameter_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 49efa4d47bf6aaab7edb058b16eab38f00fe3261 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Sep 2024 11:57:11 +0200 Subject: [PATCH 1162/1309] Add specific EntityDescription to describe calendar entities (#126726) --- homeassistant/components/calendar/__init__.py | 8 +++++++- homeassistant/components/google/calendar.py | 5 +++-- pylint/plugins/hass_enforce_class_module.py | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index e1f206ca661..fa7b82a9e1e 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -33,7 +33,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_time from homeassistant.helpers.template import DATE_STR_FORMAT @@ -483,9 +483,15 @@ def is_offset_reached( return start + offset_time <= dt_util.now(start.tzinfo) +class CalendarEntityDescription(EntityDescription, frozen_or_thawed=True): + """A class that describes calendar entities.""" + + class CalendarEntity(Entity): """Base class for calendar event entities.""" + entity_description: CalendarEntityDescription + _entity_component_unrecorded_attributes = frozenset({"description"}) _alarm_unsubs: list[CALLBACK_TYPE] | None = None diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 3a5a620876d..ed3a27ce614 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -24,6 +24,7 @@ from homeassistant.components.calendar import ( EVENT_START, EVENT_SUMMARY, CalendarEntity, + CalendarEntityDescription, CalendarEntityFeature, CalendarEvent, extract_offset, @@ -34,7 +35,7 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_O from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers import entity_platform, entity_registry as er -from homeassistant.helpers.entity import EntityDescription, generate_entity_id +from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util @@ -84,7 +85,7 @@ SERVICE_CREATE_EVENT = "create_event" @dataclass(frozen=True, kw_only=True) -class GoogleCalendarEntityDescription(EntityDescription): +class GoogleCalendarEntityDescription(CalendarEntityDescription): """Google calendar entity description.""" name: str diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index 2320a4af8b7..09fe61b68c6 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -28,7 +28,7 @@ _MODULES: dict[str, set[str]] = { "assist_satellite": {"AssistSatelliteEntity", "AssistSatelliteEntityDescription"}, "binary_sensor": {"BinarySensorEntity", "BinarySensorEntityDescription"}, "button": {"ButtonEntity", "ButtonEntityDescription"}, - "calendar": {"CalendarEntity"}, + "calendar": {"CalendarEntity", "CalendarEntityDescription"}, "camera": {"Camera", "CameraEntityDescription"}, "climate": {"ClimateEntity", "ClimateEntityDescription"}, "coordinator": {"DataUpdateCoordinator"}, From a5b556b21bcd39b0f3b1006a7646bacd7bde1438 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 25 Sep 2024 12:11:55 +0200 Subject: [PATCH 1163/1309] Use entity selector in Homekit bridge config flow (#126340) Use entity selector in homekit bridge config flow --- .../components/homekit/config_flow.py | 81 ++++++++++++------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index f88aa646f04..a63e365ead7 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -39,6 +39,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, + selector, ) from homeassistant.loader import async_get_integrations @@ -178,12 +179,12 @@ def _async_build_entities_filter( ) -def _async_cameras_from_entities(entities: list[str]) -> dict[str, str]: - return { - entity_id: entity_id +def _async_cameras_from_entities(entities: list[str]) -> list[str]: + return [ + entity_id for entity_id in entities if entity_id.startswith(CAMERA_ENTITY_PREFIX) - } + ] async def _async_name_to_type_map(hass: HomeAssistant) -> dict[str, str]: @@ -371,7 +372,7 @@ class OptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry self.hk_options: dict[str, Any] = {} - self.included_cameras: dict[str, str] = {} + self.included_cameras: list[str] = [] async def async_step_yaml( self, user_input: dict[str, Any] | None = None @@ -461,13 +462,21 @@ class OptionsFlowHandler(OptionsFlow): data_schema = vol.Schema( { vol.Optional( - CONF_CAMERA_COPY, - default=cameras_with_copy, - ): cv.multi_select(self.included_cameras), + CONF_CAMERA_COPY, default=cameras_with_copy + ): selector.EntitySelector( + selector.EntitySelectorConfig( + multiple=True, + include_entities=(self.included_cameras), + ) + ), vol.Optional( - CONF_CAMERA_AUDIO, - default=cameras_with_audio, - ): cv.multi_select(self.included_cameras), + CONF_CAMERA_AUDIO, default=cameras_with_audio + ): selector.EntitySelector( + selector.EntitySelectorConfig( + multiple=True, + include_entities=(self.included_cameras), + ) + ), } ) return self.async_show_form(step_id="cameras", data_schema=data_schema) @@ -508,9 +517,13 @@ class OptionsFlowHandler(OptionsFlow): step_id="accessory", data_schema=vol.Schema( { - vol.Required(CONF_ENTITIES, default=default_value): vol.In( - all_supported_entities - ) + vol.Required( + CONF_ENTITIES, default=default_value + ): selector.EntitySelector( + selector.EntitySelectorConfig( + include_entities=all_supported_entities, + ) + ), } ), ) @@ -546,9 +559,14 @@ class OptionsFlowHandler(OptionsFlow): }, data_schema=vol.Schema( { - vol.Optional(CONF_ENTITIES, default=default_value): cv.multi_select( - all_supported_entities - ) + vol.Optional( + CONF_ENTITIES, default=default_value + ): selector.EntitySelector( + selector.EntitySelectorConfig( + multiple=True, + include_entities=all_supported_entities, + ) + ), } ), ) @@ -561,17 +579,17 @@ class OptionsFlowHandler(OptionsFlow): domains = hk_options[CONF_DOMAINS] if user_input is not None: - self.included_cameras = {} + self.included_cameras = [] entities = cv.ensure_list(user_input[CONF_ENTITIES]) if CAMERA_DOMAIN in domains: camera_entities = _async_get_matching_entities( self.hass, [CAMERA_DOMAIN] ) - self.included_cameras = { - entity_id: entity_id + self.included_cameras = [ + entity_id for entity_id in camera_entities if entity_id not in entities - } + ] hk_options[CONF_FILTER] = _make_entity_filter( include_domains=domains, exclude_entities=entities ) @@ -598,9 +616,14 @@ class OptionsFlowHandler(OptionsFlow): }, data_schema=vol.Schema( { - vol.Optional(CONF_ENTITIES, default=default_value): cv.multi_select( - all_supported_entities - ) + vol.Optional( + CONF_ENTITIES, default=default_value + ): selector.EntitySelector( + selector.EntitySelectorConfig( + multiple=True, + include_entities=all_supported_entities, + ) + ), } ), ) @@ -684,13 +707,11 @@ def _async_get_matching_entities( domains: list[str] | None = None, include_entity_category: bool = False, include_hidden: bool = False, -) -> dict[str, str]: +) -> list[str]: """Fetch all entities or entities in the given domains.""" ent_reg = er.async_get(hass) - return { - state.entity_id: ( - f"{state.attributes.get(ATTR_FRIENDLY_NAME, state.entity_id)} ({state.entity_id})" - ) + return [ + state.entity_id for state in sorted( hass.states.async_all(domains and set(domains)), key=lambda item: item.entity_id, @@ -698,7 +719,7 @@ def _async_get_matching_entities( if not _exclude_by_entity_registry( ent_reg, state.entity_id, include_entity_category, include_hidden ) - } + ] def _domains_set_from_entities(entity_ids: Iterable[str]) -> set[str]: From 18766905f447bebdce027d1aa9fe63123bb300ed Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 25 Sep 2024 12:45:24 +0200 Subject: [PATCH 1164/1309] Don't crash entire Matter integration setup when one node is failing (#126491) Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/adapter.py | 20 ++++++----- tests/components/matter/common.py | 25 +++++++++----- tests/components/matter/test_adapter.py | 40 ++++++++++++++++------ tests/components/matter/test_init.py | 13 ++----- 4 files changed, 59 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 410f86ef473..475e4a44538 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -56,10 +56,6 @@ class MatterAdapter: """Set up all existing nodes and subscribe to new nodes.""" initialized_nodes: set[int] = set() for node in self.matter_client.get_nodes(): - if not node.available: - # ignore un-initialized nodes at startup - # catch them later when they become available. - continue initialized_nodes.add(node.node_id) self._setup_node(node) @@ -143,10 +139,18 @@ class MatterAdapter: def _setup_node(self, node: MatterNode) -> None: """Set up an node.""" LOGGER.debug("Setting up entities for node %s", node.node_id) - - for endpoint in node.endpoints.values(): - # Node endpoints are translated into HA devices - self._setup_endpoint(endpoint) + try: + for endpoint in node.endpoints.values(): + # Node endpoints are translated into HA devices + self._setup_endpoint(endpoint) + except Exception as err: # noqa: BLE001 + # We don't want to crash the whole setup when a single node fails to setup + # for whatever reason, so we catch all exceptions here. + LOGGER.exception( + "Error setting up node %s: %s", + node.node_id, + err, + ) def _create_device_registry( self, diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index 541f7383f1d..a1cdcf699a6 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -34,15 +34,7 @@ async def setup_integration_with_node_fixture( override_attributes: dict[str, Any] | None = None, ) -> MatterNode: """Set up Matter integration with fixture as node.""" - node_data = load_and_parse_node_fixture(node_fixture) - if override_attributes: - node_data["attributes"].update(override_attributes) - node = MatterNode( - dataclass_from_dict( - MatterNodeData, - node_data, - ) - ) + node = create_node_from_fixture(node_fixture, override_attributes) client.get_nodes.return_value = [node] client.get_node.return_value = node config_entry = MockConfigEntry( @@ -56,6 +48,21 @@ async def setup_integration_with_node_fixture( return node +def create_node_from_fixture( + node_fixture: str, override_attributes: dict[str, Any] | None = None +) -> MatterNode: + """Create a node from a fixture.""" + node_data = load_and_parse_node_fixture(node_fixture) + if override_attributes: + node_data["attributes"].update(override_attributes) + return MatterNode( + dataclass_from_dict( + MatterNodeData, + node_data, + ) + ) + + def set_node_attribute( node: MatterNode, endpoint: int, diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 522128e5968..63d468e02d3 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -4,9 +4,7 @@ from __future__ import annotations from unittest.mock import MagicMock -from matter_server.client.models.node import MatterNode -from matter_server.common.helpers.util import dataclass_from_dict -from matter_server.common.models import EventType, MatterNodeData +from matter_server.common.models import EventType import pytest from homeassistant.components.matter.adapter import get_clean_name @@ -14,7 +12,9 @@ from homeassistant.components.matter.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .common import load_and_parse_node_fixture, setup_integration_with_node_fixture +from .common import create_node_from_fixture, setup_integration_with_node_fixture + +from tests.common import MockConfigEntry # This tests needs to be adjusted to remove lingering tasks @@ -156,13 +156,7 @@ async def test_node_added_subscription( ) node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"] - node_data = load_and_parse_node_fixture("onoff-light") - node = MatterNode( - dataclass_from_dict( - MatterNodeData, - node_data, - ) - ) + node = create_node_from_fixture("onoff-light") entity_state = hass.states.get("light.mock_onoff_light_light") assert not entity_state @@ -218,3 +212,27 @@ async def test_get_clean_name_() -> None: assert get_clean_name("") is None assert get_clean_name("Mock device") == "Mock device" assert get_clean_name("Mock device \x00") == "Mock device" + + +async def test_bad_node_not_crash_integration( + hass: HomeAssistant, + matter_client: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a bad node does not crash the integration.""" + good_node = create_node_from_fixture("onoff-light") + bad_node = create_node_from_fixture("onoff-light") + del bad_node.endpoints[0].node + matter_client.get_nodes.return_value = [good_node, bad_node] + config_entry = MockConfigEntry( + domain="matter", data={"url": "http://mock-matter-server-url"} + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert matter_client.get_nodes.call_count == 1 + assert hass.states.get("light.mock_onoff_light_light") is not None + assert len(hass.states.async_all("light")) == 1 + assert "Error setting up node" in caplog.text diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index 099376abd07..33df9a7ec67 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -12,10 +12,7 @@ from matter_server.client.exceptions import ( ServerVersionTooNew, ServerVersionTooOld, ) -from matter_server.client.models.node import MatterNode from matter_server.common.errors import MatterError -from matter_server.common.helpers.util import dataclass_from_dict -from matter_server.common.models import MatterNodeData import pytest from homeassistant.components.hassio import HassioAPIError @@ -30,7 +27,7 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component -from .common import load_and_parse_node_fixture, setup_integration_with_node_fixture +from .common import create_node_from_fixture, setup_integration_with_node_fixture from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -57,13 +54,7 @@ async def test_entry_setup_unload( matter_client: MagicMock, ) -> None: """Test the integration set up and unload.""" - node_data = load_and_parse_node_fixture("onoff-light") - node = MatterNode( - dataclass_from_dict( - MatterNodeData, - node_data, - ) - ) + node = create_node_from_fixture("onoff-light") matter_client.get_nodes.return_value = [node] matter_client.get_node.return_value = node entry = MockConfigEntry(domain="matter", data={"url": "ws://localhost:5580/ws"}) From 6bf8ec2df0582d69ce0ab28233e64bf6a268f2ff Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:37:20 +0200 Subject: [PATCH 1165/1309] Update isal to 1.7.1 (#126742) --- homeassistant/components/isal/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/isal/manifest.json b/homeassistant/components/isal/manifest.json index d367b1c8eb9..1aa5666f410 100644 --- a/homeassistant/components/isal/manifest.json +++ b/homeassistant/components/isal/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["isal==1.6.1"] + "requirements": ["isal==1.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6758787e24e..d8e7be9ab81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1203,7 +1203,7 @@ iottycloud==0.2.1 iperf3==0.1.11 # homeassistant.components.isal -isal==1.6.1 +isal==1.7.1 # homeassistant.components.gogogate2 ismartgate==5.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa1464399d4..60b37ad7c5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1011,7 +1011,7 @@ intellifire4py==4.1.9 iottycloud==0.2.1 # homeassistant.components.isal -isal==1.6.1 +isal==1.7.1 # homeassistant.components.gogogate2 ismartgate==5.0.1 From c6385377311b333603fe37dbd24a8a6bd0d7ffad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Sep 2024 13:37:43 +0200 Subject: [PATCH 1166/1309] Use 'select' instead of 'click' or 'press' when guiding users in flows (#126731) --- homeassistant/components/deconz/strings.json | 2 +- homeassistant/components/dlna_dmr/strings.json | 2 +- homeassistant/components/ecobee/strings.json | 2 +- homeassistant/components/econet/strings.json | 2 +- homeassistant/components/elkm1/strings.json | 2 +- homeassistant/components/freebox/strings.json | 2 +- homeassistant/components/google/strings.json | 2 +- homeassistant/components/google_assistant_sdk/strings.json | 2 +- homeassistant/components/google_mail/strings.json | 2 +- homeassistant/components/google_photos/strings.json | 2 +- homeassistant/components/google_sheets/strings.json | 2 +- homeassistant/components/google_tasks/strings.json | 2 +- homeassistant/components/iotawatt/strings.json | 2 +- homeassistant/components/kitchen_sink/strings.json | 6 +++--- homeassistant/components/mqtt/strings.json | 6 +++--- homeassistant/components/myuplink/strings.json | 2 +- homeassistant/components/nanoleaf/strings.json | 2 +- homeassistant/components/nest/strings.json | 6 +++--- homeassistant/components/nexia/strings.json | 2 +- homeassistant/components/octoprint/strings.json | 2 +- homeassistant/components/ps4/strings.json | 4 ++-- homeassistant/components/rachio/strings.json | 2 +- homeassistant/components/snooz/strings.json | 2 +- homeassistant/components/systemmonitor/strings.json | 2 +- homeassistant/components/tellduslive/strings.json | 2 +- homeassistant/components/tessie/strings.json | 6 +++--- homeassistant/components/weatherflow/strings.json | 2 +- homeassistant/components/webostv/strings.json | 4 ++-- homeassistant/components/xiaomi_miio/strings.json | 2 +- 29 files changed, 39 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index c06a07e6ce5..b894bdf5f84 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -18,7 +18,7 @@ }, "link": { "title": "Link with deCONZ", - "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Press \"Authenticate app\" button" + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Select \"Authenticate app\" button" }, "hassio_confirm": { "title": "deCONZ Zigbee gateway via Home Assistant add-on", diff --git a/homeassistant/components/dlna_dmr/strings.json b/homeassistant/components/dlna_dmr/strings.json index e0610e37133..a2b71785535 100644 --- a/homeassistant/components/dlna_dmr/strings.json +++ b/homeassistant/components/dlna_dmr/strings.json @@ -17,7 +17,7 @@ } }, "import_turn_on": { - "description": "Please turn on the device and click submit to continue migration" + "description": "Please turn on the device and select submit to continue migration" }, "confirm": { "description": "[%key:common::config_flow::description::confirm_setup%]" diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 5483ca2299d..a7041e683e4 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -8,7 +8,7 @@ } }, "authorize": { - "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, press Submit." + "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, select Submit." } }, "error": { diff --git a/homeassistant/components/econet/strings.json b/homeassistant/components/econet/strings.json index 83d66dde144..b473bf2f466 100644 --- a/homeassistant/components/econet/strings.json +++ b/homeassistant/components/econet/strings.json @@ -25,7 +25,7 @@ "fix_flow": { "step": { "confirm": { - "description": "The EcoNet `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, Press submit to fix this issue.", + "description": "The EcoNet `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, select submit to fix this issue.", "title": "[%key:component::econet::issues::migrate_aux_heat::title%]" } } diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index 302f14b3f44..8ed34138f66 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -196,7 +196,7 @@ "fix_flow": { "step": { "confirm": { - "description": "The Elk-M1 `set_aux_heat` action has been migrated. A new emergency heat switch entity is available for each thermostat.\n\nUpdate any automations to use the new emergency heat switch entity. When this is done, Press submit to fix this issue.", + "description": "The Elk-M1 `set_aux_heat` action has been migrated. A new emergency heat switch entity is available for each thermostat.\n\nUpdate any automations to use the new emergency heat switch entity. When this is done, select submit to fix this issue.", "title": "[%key:component::elkm1::issues::migrate_aux_heat::title%]" } } diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index eaa56a38da1..cc7ca5b5aaa 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/strings.json @@ -12,7 +12,7 @@ }, "link": { "title": "Link Freebox router", - "description": "Click \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n![Location of button on the router](/static/images/config_freebox.png)" + "description": "Select \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n![Location of button on the router](/static/images/config_freebox.png)" } }, "error": { diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index c2b35d63c63..fd817f82246 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -44,7 +44,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type." }, "services": { "add_event": { diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 7690790e0a9..4fd817aadce 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -40,7 +40,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "services": { "send_text_command": { diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index 4b0b515a346..2c6e24109c3 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -32,7 +32,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Mail. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Mail. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "entity": { "sensor": { diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index 2333783fc00..21942ce71a7 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Photos. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Photos. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "config": { "step": { diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index bc48f8821ad..d8cb06d9bcd 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -31,7 +31,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Sheets. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Sheets. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "services": { "append_sheet": { diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index c7635ebd6e4..447da5e24c2 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Tasks. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Tasks. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." }, "config": { "step": { diff --git a/homeassistant/components/iotawatt/strings.json b/homeassistant/components/iotawatt/strings.json index 266b32c5c31..01a82b721a2 100644 --- a/homeassistant/components/iotawatt/strings.json +++ b/homeassistant/components/iotawatt/strings.json @@ -14,7 +14,7 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, - "description": "The IoTawatt device requires authentication. Please enter the username and password and click the Submit button." + "description": "The IoTawatt device requires authentication. Please enter the username and password and select the Submit button." } }, "error": { diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index b10534eac00..d1d6fc17676 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "reauth_confirm": { - "description": "Press SUBMIT to reauthenticate" + "description": "Select SUBMIT to reauthenticate" } } }, @@ -38,7 +38,7 @@ "step": { "confirm": { "title": "The power supply needs to be replaced", - "description": "Press SUBMIT to confirm the power supply has been replaced" + "description": "Select SUBMIT to confirm the power supply has been replaced" } } } @@ -49,7 +49,7 @@ "step": { "confirm": { "title": "Blinker fluid needs to be refilled", - "description": "Press SUBMIT when blinker fluid has been refilled" + "description": "Select SUBMIT when blinker fluid has been refilled" } } } diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 75855f6d9f3..b6cff750fd1 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -56,15 +56,15 @@ "port": "The port your MQTT broker listens to. For example 1883.", "username": "The username to login to your MQTT broker.", "password": "The password to login to your MQTT broker.", - "advanced_options": "Enable and click `next` to set advanced options.", + "advanced_options": "Enable and select `next` to set advanced options.", "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", "client_cert": "The client certificate to authenticate against your MQTT broker.", "client_key": "The private key file that belongs to your client certificate.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", - "set_ca_cert": "Select `Auto` for automatic CA validation, or `Custom` and click `next` to set a custom CA certificate, to allow validating your MQTT brokers certificate.", - "set_client_cert": "Enable and click `next` to set a client certifificate and private key to authenticate against your MQTT broker.", + "set_ca_cert": "Select `Auto` for automatic CA validation, or `Custom` and select `next` to set a custom CA certificate, to allow validating your MQTT brokers certificate.", + "set_client_cert": "Enable and select `next` to set a client certifificate and private key to authenticate against your MQTT broker.", "transport": "The transport to be used for the connection to your MQTT broker.", "ws_headers": "The WebSocket headers to pass through the WebSocket based connection to your MQTT broker.", "ws_path": "The WebSocket path to be used for the connection to your MQTT broker." diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 4e344e55c43..3351901b50b 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or click **Create New Application**.\n1. Set appropriate Application name and Description\n2. Enter `{callback_url}` as Callback Url" + "description": "Follow the [instructions]({more_info_url}) to give Home Assistant access to your myUplink account. You also need to create application credentials linked to your account:\n1. Go to [Applications at myUplink developer site]({create_creds_url}) and get credentials from an existing application or select **Create New Application**.\n1. Set appropriate Application name and Description\n2. Enter `{callback_url}` as Callback Url" }, "config": { "step": { diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json index ef7df8c0ab5..50eec80d8bc 100644 --- a/homeassistant/components/nanoleaf/strings.json +++ b/homeassistant/components/nanoleaf/strings.json @@ -12,7 +12,7 @@ }, "link": { "title": "Link Nanoleaf", - "description": "Press and hold the power button on your Nanoleaf for 5 seconds until the button LEDs start flashing, then click **SUBMIT** within 30 seconds." + "description": "Press and hold the power button on your Nanoleaf for 5 seconds until the button LEDs start flashing, then select **SUBMIT** within 30 seconds." } }, "error": { diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index b80c86c357c..8e40bf27d1f 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -1,12 +1,12 @@ { "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web Application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web Application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." }, "config": { "step": { "create_cloud_project": { "title": "Nest: Create and configure Cloud Project", - "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up." + "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, select **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then select **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and select **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and select **Enable**.\n\nProceed when your cloud project is set up." }, "cloud_project": { "title": "Nest: Enter Cloud Project ID", @@ -17,7 +17,7 @@ }, "device_project": { "title": "Nest: Create a Device Access Project", - "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", + "description": "Create a Nest Device Access project which **requires paying Google a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Select on **Create project**\n1. Give your Device Access project a name and select **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).", "data": { "project_id": "Device Access Project ID" } diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index acb57352d24..508c04b7e32 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -103,7 +103,7 @@ "fix_flow": { "step": { "confirm": { - "description": "The Nexia `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new Emergency heat switch entity. When this is done, press submit to fix this issue.", + "description": "The Nexia `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new Emergency heat switch entity. When this is done, select submit to fix this issue.", "title": "[%key:component::nexia::issues::migrate_aux_heat::title%]" } } diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index e9df0ed755c..0c3f24fe49e 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -33,7 +33,7 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "progress": { - "get_api_key": "Open the OctoPrint UI and click 'Allow' on the Access Request for 'Home Assistant'." + "get_api_key": "Open the OctoPrint UI and select 'Allow' on the Access Request for 'Home Assistant'." } }, "exceptions": { diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index 163f2cc9b94..3c06d7b35fb 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "creds": { - "description": "Credentials needed. Press 'Submit' and then in the PS4 2nd Screen App, refresh devices and select the 'Home-Assistant' device to continue." + "description": "Credentials needed. Select 'Submit' and then in the PS4 2nd Screen App, refresh devices and select the 'Home-Assistant' device to continue." }, "mode": { "data": { @@ -26,7 +26,7 @@ } }, "error": { - "credential_timeout": "Credential service timed out. Press submit to restart.", + "credential_timeout": "Credential service timed out. Select submit to restart.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.", "no_ipaddress": "Enter the IP address of the PlayStation 4 you would like to configure." diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json index ad7a277d23a..308403d805d 100644 --- a/homeassistant/components/rachio/strings.json +++ b/homeassistant/components/rachio/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Connect to your Rachio device", - "description": "You will need the API Key from https://app.rach.io/. Go to Settings, then click 'GET API KEY'.", + "description": "You will need the API Key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index 5a31cea6cac..94ca434e589 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -12,7 +12,7 @@ "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, "pairing_timeout": { - "description": "The device did not enter pairing mode. Click Submit to try again.\n\n### Troubleshooting\n1. Check that the device isn't connected to the mobile app.\n2. Unplug the device for 5 seconds, then plug it back in." + "description": "The device did not enter pairing mode. Select Submit to try again.\n\n### Troubleshooting\n1. Check that the device isn't connected to the mobile app.\n2. Unplug the device for 5 seconds, then plug it back in." } }, "progress": { diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index dde97918bc3..d7cc9491d8d 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "Press submit for initial setup. On the created config entry, press configure to add sensors for selected processes" + "description": "Select submit for initial setup. On the created config entry, select configure to add sensors for selected processes" } } }, diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 16c847f0077..5554e6e14e7 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -11,7 +11,7 @@ }, "step": { "auth": { - "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})", + "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (select **Yes**).\n 4. Come back here and select **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})", "title": "Authenticate against TelldusLive" }, "user": { diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index c7408df1ddb..aaa9dad4e64 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -539,7 +539,7 @@ "step": { "confirm": { "title": "[%key:component::tessie::issues::deprecated_speed_limit_entity::title%]", - "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\nHome Assistant detected that entity `{entity}` is being used in `{info}`\n\nYou should remove the speed limit lock entity from `{info}` then click submit to fix this issue." + "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\nHome Assistant detected that entity `{entity}` is being used in `{info}`\n\nYou should remove the speed limit lock entity from `{info}` then select submit to fix this issue." } } } @@ -550,7 +550,7 @@ "step": { "confirm": { "title": "[%key:component::tessie::issues::deprecated_speed_limit_locked::title%]", - "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\n\nPlease remove this entity from any automation or script, disable the entity then click submit to fix this issue." + "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\n\nPlease remove this entity from any automation or script, disable the entity then select submit to fix this issue." } } } @@ -561,7 +561,7 @@ "step": { "confirm": { "title": "[%key:component::tessie::issues::deprecated_speed_limit_unlocked::title%]", - "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\n\nPlease remove this entity from any automation or script, disable the entity then click submit to fix this issue." + "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\n\nPlease remove this entity from any automation or script, disable the entity then select submit to fix this issue." } } } diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index d075ee34a05..7594b6a2cc6 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Unable to discover Tempest WeatherFlow devices. Click submit to try again.", + "description": "Unable to discover Tempest WeatherFlow devices. Select submit to try again.", "data": { "host": "[%key:common::config_flow::data::host%]" }, diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index 1d045d48ba5..9ca5066fd2d 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -3,7 +3,7 @@ "flow_title": "LG webOS Smart TV", "step": { "user": { - "description": "Turn on TV, fill the following fields click submit", + "description": "Turn on TV, fill the following fields and select submit", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" @@ -14,7 +14,7 @@ }, "pairing": { "title": "webOS TV Pairing", - "description": "Click submit and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" + "description": "Select submit and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" }, "reauth_confirm": { "title": "[%key:component::webostv::config::step::pairing::title%]", diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 6419c9056a5..8280c85f914 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -439,7 +439,7 @@ }, "remote_learn_command": { "name": "Remote learn command", - "description": "Learns an IR command, press \"Perform action\", point the remote at the IR device, and the learned command will be shown as a notification in Overview.", + "description": "Learns an IR command, select \"Perform action\", point the remote at the IR device, and the learned command will be shown as a notification in Overview.", "fields": { "slot": { "name": "Slot", From 9d293075326fde1139be0993746459ab4d693d6f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:44:05 +0200 Subject: [PATCH 1167/1309] Update lxml to 5.3.0 (#126725) --- homeassistant/components/scrape/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index f39f662de3e..56b9470b4f7 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.12.3", "lxml==5.1.0"] + "requirements": ["beautifulsoup4==4.12.3", "lxml==5.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d8e7be9ab81..be3acb3707a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1321,7 +1321,7 @@ lupupy==0.3.2 lw12==0.9.2 # homeassistant.components.scrape -lxml==5.1.0 +lxml==5.3.0 # homeassistant.components.matrix matrix-nio==0.25.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60b37ad7c5a..50d08a8313a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1096,7 +1096,7 @@ luftdaten==0.7.4 lupupy==0.3.2 # homeassistant.components.scrape -lxml==5.1.0 +lxml==5.3.0 # homeassistant.components.matrix matrix-nio==0.25.1 From a1906b434f64d87003ff2e3c44c2973767cfe74d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Sep 2024 14:19:58 +0200 Subject: [PATCH 1168/1309] Change trigger platform key to trigger (#124357) * fix * Fix * Fix * Update homeassistant/helpers/config_validation.py Co-authored-by: Erik Montnemery * Fix * Fix * Fix * Fix * Add more tests * Fix * Fix tests * Add tests * Let's see what the CI does * It fails on the code that tested the thing ofc * It fails on the code that tested the thing ofc * Revert test thingy * Now the test works again, lovely * Another one * Fix websocket thingy * Only copy when needed * Improve comment * Remove test * Fix docstring * I think this now also work since this transforms trigger to platform * Add comment * Update homeassistant/helpers/config_validation.py Co-authored-by: Erik Montnemery * Update homeassistant/helpers/config_validation.py Co-authored-by: Erik Montnemery * Update homeassistant/helpers/config_validation.py Co-authored-by: Erik Montnemery * Check for mapping * Add test * Update homeassistant/helpers/config_validation.py Co-authored-by: Erik Montnemery * Update test to also test for trigger keys --------- Co-authored-by: Erik Montnemery --- .../components/device_automation/__init__.py | 7 +- homeassistant/const.py | 1 + homeassistant/helpers/config_validation.py | 31 ++++- homeassistant/helpers/llm.py | 2 +- tests/components/automation/test_init.py | 114 ++++++++++++------ tests/components/automation/test_recorder.py | 2 +- .../blueprint/test_websocket_api.py | 6 +- tests/components/config/test_automation.py | 10 +- .../components/device_automation/test_init.py | 8 +- tests/helpers/test_config_validation.py | 33 ++++- tests/helpers/test_selector.py | 27 ++++- .../automation/test_event_service.yaml | 2 +- 12 files changed, 185 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index b54fe788a3d..2c6e80e5f49 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -481,8 +481,11 @@ async def websocket_device_automation_get_condition_capabilities( @websocket_api.websocket_command( { vol.Required("type"): "device_automation/trigger/capabilities", - vol.Required("trigger"): DEVICE_TRIGGER_BASE_SCHEMA.extend( - {}, extra=vol.ALLOW_EXTRA + # The frontend responds with `trigger` as key, while the + # `DEVICE_TRIGGER_BASE_SCHEMA` expects `platform1` as key. + vol.Required("trigger"): vol.All( + cv._backward_compat_trigger_schema, # noqa: SLF001 + DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), ), } ) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4ce98d7e69c..c5648a9e096 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -282,6 +282,7 @@ CONF_THEN: Final = "then" CONF_TIMEOUT: Final = "timeout" CONF_TIME_ZONE: Final = "time_zone" CONF_TOKEN: Final = "token" +CONF_TRIGGER: Final = "trigger" CONF_TRIGGERS: Final = "triggers" CONF_TRIGGER_TIME: Final = "trigger_time" CONF_TTL: Final = "ttl" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index fd8d54fc6e0..8b190abad92 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -4,7 +4,7 @@ # with PEP 695 syntax. Fixed in Python 3.13. # from __future__ import annotations -from collections.abc import Callable, Hashable +from collections.abc import Callable, Hashable, Mapping import contextlib from contextvars import ContextVar from datetime import ( @@ -81,6 +81,7 @@ from homeassistant.const import ( CONF_TARGET, CONF_THEN, CONF_TIMEOUT, + CONF_TRIGGER, CONF_TRIGGERS, CONF_UNTIL, CONF_VALUE_TEMPLATE, @@ -1769,6 +1770,30 @@ CONDITION_ACTION_SCHEMA: vol.Schema = vol.Schema( ) ) + +def _backward_compat_trigger_schema(value: Any | None) -> Any: + """Rewrite trigger `trigger` to `platform`. + + `platform` has been renamed to `trigger` in user documentation and in the automation + editor. The Python trigger implementation still uses `platform`, so we need to + rename `trigger` to `platform. + """ + + if not isinstance(value, Mapping): + # If the value is not a mapping, we let that be handled by the TRIGGER_SCHEMA + return value + + if CONF_TRIGGER in value: + if CONF_PLATFORM in value: + raise vol.Invalid( + "Cannot specify both 'platform' and 'trigger'. Please use 'trigger' only." + ) + value = dict(value) + value[CONF_PLATFORM] = value.pop(CONF_TRIGGER) + + return value + + TRIGGER_BASE_SCHEMA = vol.Schema( { vol.Optional(CONF_ALIAS): str, @@ -1804,7 +1829,9 @@ def _base_trigger_validator(value: Any) -> Any: TRIGGER_SCHEMA = vol.All( - ensure_list, _base_trigger_list_flatten, [_base_trigger_validator] + ensure_list, + _base_trigger_list_flatten, + [vol.All(_backward_compat_trigger_schema, _base_trigger_validator)], ) _SCRIPT_DELAY_SCHEMA = vol.Schema( diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index b8d8d66615d..8b2e0660687 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -602,7 +602,7 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 return {"type": "string", "format": "time"} if isinstance(schema, selector.TriggerSelector): - return convert(cv.TRIGGER_SCHEMA) + return {"type": "array", "items": {"type": "string"}} if schema.config.get("multiple"): return {"type": "array", "items": {"type": "string"}} diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index aaeb4f2e41e..2bdc0f7516b 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1971,37 +1971,37 @@ async def test_extraction_functions( { "alias": "test1", "triggers": [ - {"platform": "state", "entity_id": "sensor.trigger_state"}, + {"trigger": "state", "entity_id": "sensor.trigger_state"}, { - "platform": "numeric_state", + "trigger": "numeric_state", "entity_id": "sensor.trigger_numeric_state", "above": 10, }, { - "platform": "calendar", + "trigger": "calendar", "entity_id": "calendar.trigger_calendar", "event": "start", }, { - "platform": "event", + "trigger": "event", "event_type": "state_changed", "event_data": {"entity_id": "sensor.trigger_event"}, }, # entity_id is a list of strings (not supported) { - "platform": "event", + "trigger": "event", "event_type": "state_changed", "event_data": {"entity_id": ["sensor.trigger_event2"]}, }, # entity_id is not a valid entity ID { - "platform": "event", + "trigger": "event", "event_type": "state_changed", "event_data": {"entity_id": "abc"}, }, # entity_id is not a string { - "platform": "event", + "trigger": "event", "event_type": "state_changed", "event_data": {"entity_id": 123}, }, @@ -2044,36 +2044,36 @@ async def test_extraction_functions( "alias": "test2", "triggers": [ { - "platform": "device", + "trigger": "device", "domain": "light", "type": "turned_on", "entity_id": "light.trigger_2", "device_id": trigger_device_2.id, }, { - "platform": "tag", + "trigger": "tag", "tag_id": "1234", "device_id": "device-trigger-tag1", }, { - "platform": "tag", + "trigger": "tag", "tag_id": "1234", "device_id": ["device-trigger-tag2", "device-trigger-tag3"], }, { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"device_id": "device-trigger-event"}, }, # device_id is a list of strings (not supported) { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"device_id": ["device-trigger-event2"]}, }, # device_id is not a string { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"device_id": 123}, }, @@ -2114,19 +2114,19 @@ async def test_extraction_functions( "alias": "test3", "triggers": [ { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"area_id": "area-trigger-event"}, }, # area_id is a list of strings (not supported) { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"area_id": ["area-trigger-event2"]}, }, # area_id is not a string { - "platform": "event", + "trigger": "event", "event_type": "esphome.button_pressed", "event_data": {"area_id": 123}, }, @@ -2287,7 +2287,7 @@ async def test_automation_variables( "event_type": "{{ trigger.event.event_type }}", "this_variables": "{{this.entity_id}}", }, - "triggers": {"platform": "event", "event_type": "test_event"}, + "triggers": {"trigger": "event", "event_type": "test_event"}, "actions": { "action": "test.automation", "data": { @@ -2302,7 +2302,7 @@ async def test_automation_variables( "variables": { "test_var": "defined_in_config", }, - "trigger": {"platform": "event", "event_type": "test_event_2"}, + "trigger": {"trigger": "event", "event_type": "test_event_2"}, "conditions": { "condition": "template", "value_template": "{{ trigger.event.data.pass_condition }}", @@ -2315,7 +2315,7 @@ async def test_automation_variables( "variables": { "test_var": "{{ trigger.event.data.break + 1 }}", }, - "triggers": {"platform": "event", "event_type": "test_event_3"}, + "triggers": {"trigger": "event", "event_type": "test_event_3"}, "actions": { "action": "test.automation", }, @@ -2371,7 +2371,7 @@ async def test_automation_trigger_variables( "trigger_variables": { "test_var": "defined_in_config", }, - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": { "action": "test.automation", "data": { @@ -2389,7 +2389,7 @@ async def test_automation_trigger_variables( "test_var": "defined_in_config", "this_trigger_variables": "{{this.entity_id}}", }, - "trigger": {"platform": "event", "event_type": "test_event_2"}, + "trigger": {"trigger": "event", "event_type": "test_event_2"}, "action": { "action": "test.automation", "data": { @@ -2436,7 +2436,7 @@ async def test_automation_bad_trigger_variables( "trigger_variables": { "test_var": "{{ states('foo.bar') }}", }, - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": { "action": "test.automation", }, @@ -2463,7 +2463,7 @@ async def test_automation_this_var_always( { automation.DOMAIN: [ { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": { "action": "test.automation", "data": { @@ -2739,7 +2739,7 @@ async def test_trigger_service(hass: HomeAssistant, calls: list[ServiceCall]) -> { automation.DOMAIN: { "alias": "hello", - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": { "action": "test.automation", "data_template": {"trigger": "{{ trigger }}"}, @@ -2771,9 +2771,9 @@ async def test_trigger_condition_implicit_id( { automation.DOMAIN: { "trigger": [ - {"platform": "event", "event_type": "test_event1"}, - {"platform": "event", "event_type": "test_event2"}, - {"platform": "event", "event_type": "test_event3"}, + {"trigger": "event", "event_type": "test_event1"}, + {"trigger": "event", "event_type": "test_event2"}, + {"trigger": "event", "event_type": "test_event3"}, ], "action": { "choose": [ @@ -2823,8 +2823,8 @@ async def test_trigger_condition_explicit_id( { automation.DOMAIN: { "trigger": [ - {"platform": "event", "event_type": "test_event1", "id": "one"}, - {"platform": "event", "event_type": "test_event2", "id": "two"}, + {"trigger": "event", "event_type": "test_event1", "id": "one"}, + {"trigger": "event", "event_type": "test_event2", "id": "two"}, ], "action": { "choose": [ @@ -2938,7 +2938,7 @@ async def test_recursive_automation_starting_script( automation.DOMAIN: { "mode": automation_mode, "trigger": [ - {"platform": "event", "event_type": "trigger_automation"}, + {"trigger": "event", "event_type": "trigger_automation"}, ], "action": [ {"action": "test.automation_started"}, @@ -3020,7 +3020,7 @@ async def test_recursive_automation( automation.DOMAIN: { "mode": automation_mode, "trigger": [ - {"platform": "event", "event_type": "trigger_automation"}, + {"trigger": "event", "event_type": "trigger_automation"}, ], "action": [ {"event": "trigger_automation"}, @@ -3082,7 +3082,7 @@ async def test_recursive_automation_restart_mode( automation.DOMAIN: { "mode": SCRIPT_MODE_RESTART, "trigger": [ - {"platform": "event", "event_type": "trigger_automation"}, + {"trigger": "event", "event_type": "trigger_automation"}, ], "action": [ {"event": "trigger_automation"}, @@ -3121,7 +3121,7 @@ async def test_websocket_config( """Test config command.""" config = { "alias": "hello", - "triggers": {"platform": "event", "event_type": "test_event"}, + "triggers": {"trigger": "event", "event_type": "test_event"}, "actions": {"action": "test.automation", "data": 100}, } assert await async_setup_component( @@ -3191,7 +3191,7 @@ async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> Non automation.DOMAIN: [ { "trigger": { - "platform": "state", + "trigger": "state", "entity_id": "binary_sensor.presence", "from": "on", }, @@ -3209,7 +3209,7 @@ async def test_automation_turns_off_other_automation(hass: HomeAssistant) -> Non }, { "trigger": { - "platform": "state", + "trigger": "state", "entity_id": "binary_sensor.presence", "from": "on", "for": { @@ -3302,7 +3302,7 @@ async def test_two_automations_call_restart_script_same_time( automation.DOMAIN: [ { "trigger": { - "platform": "state", + "trigger": "state", "entity_id": "binary_sensor.presence", "to": "on", }, @@ -3314,7 +3314,7 @@ async def test_two_automations_call_restart_script_same_time( }, { "trigger": { - "platform": "state", + "trigger": "state", "entity_id": "binary_sensor.presence", "to": "on", }, @@ -3360,7 +3360,7 @@ async def test_two_automation_call_restart_script_right_after_each_other( automation.DOMAIN: [ { "trigger": { - "platform": "state", + "trigger": "state", "entity_id": ["input_boolean.test_1", "input_boolean.test_1"], "from": "off", "to": "on", @@ -3419,7 +3419,7 @@ async def test_action_backward_compatibility( automation.DOMAIN, { automation.DOMAIN: { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "condition": { "condition": "template", "value_template": "{{ True }}", @@ -3467,6 +3467,17 @@ async def test_action_backward_compatibility( }, "Cannot specify both 'action' and 'actions'. Please use 'actions' only.", ), + ( + { + "trigger": { + "platform": "event", + "trigger": "event", + "event_type": "test_event2", + }, + "action": [], + }, + "Cannot specify both 'platform' and 'trigger'. Please use 'trigger' only.", + ), ], ) async def test_invalid_configuration( @@ -3483,3 +3494,28 @@ async def test_invalid_configuration( ) await hass.async_block_till_done() assert message in caplog.text + + +@pytest.mark.parametrize( + ("trigger_key"), + ["trigger", "platform"], +) +async def test_valid_configuration( + hass: HomeAssistant, + trigger_key: str, +) -> None: + """Test for valid automation configurations.""" + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "triggers": { + trigger_key: "event", + "event_type": "test_event2", + }, + "action": [], + } + }, + ) + await hass.async_block_till_done() diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index 513fee566db..c1defdd0339 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -39,7 +39,7 @@ async def test_exclude_attributes( automation.DOMAIN, { automation.DOMAIN: { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "actions": {"action": "test.automation", "entity_id": "hello.world"}, } }, diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index f8ff0fdd540..921088d8ac6 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -224,7 +224,7 @@ async def test_save_blueprint( " service_to_call:\n a_number:\n selector:\n number:\n " " mode: box\n step: 1.0\n source_url:" " https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntriggers:\n" - " platform: event\n event_type: !input 'trigger_event'\nactions:\n " + " trigger: event\n event_type: !input 'trigger_event'\nactions:\n " " service: !input 'service_to_call'\n entity_id: light.kitchen\n" # c dumper will not quote the value after !input "blueprint:\n name: Call service based on event\n domain: automation\n " @@ -232,7 +232,7 @@ async def test_save_blueprint( " service_to_call:\n a_number:\n selector:\n number:\n " " mode: box\n step: 1.0\n source_url:" " https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntriggers:\n" - " platform: event\n event_type: !input trigger_event\nactions:\n service:" + " trigger: event\n event_type: !input trigger_event\nactions:\n service:" " !input service_to_call\n entity_id: light.kitchen\n" ) # Make sure ita parsable and does not raise @@ -500,7 +500,7 @@ async def test_substituting_blueprint_inputs( }, "triggers": { "event_type": "test_event", - "platform": "event", + "trigger": "event", }, } diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 9cd2a25de3a..40a9c85a8d3 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -110,14 +110,14 @@ async def test_update_automation_config( ), ( { - "trigger": {"platform": "automation"}, + "trigger": {"trigger": "automation"}, "action": [], }, "Integration 'automation' does not provide trigger support", ), ( { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "condition": { "condition": "state", # The UUID will fail being resolved to en entity_id @@ -130,7 +130,7 @@ async def test_update_automation_config( ), ( { - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": { "condition": "state", # The UUID will fail being resolved to en entity_id @@ -336,12 +336,12 @@ async def test_bad_formatted_automations( [ { "id": "sun", - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": {"service": "test.automation"}, }, { "id": "moon", - "trigger": {"platform": "event", "event_type": "test_event"}, + "trigger": {"trigger": "event", "event_type": "test_event"}, "action": {"service": "test.automation"}, }, ], diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index cb1abecd6ff..ab8dfcf756f 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -720,12 +720,17 @@ async def test_async_get_device_automations_all_devices_action_exception_throw( assert "KeyError" in caplog.text +@pytest.mark.parametrize( + "trigger_key", + ["trigger", "platform"], +) async def test_websocket_get_trigger_capabilities( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, fake_integration, + trigger_key: str, ) -> None: """Test we get the expected trigger capabilities through websocket.""" await async_setup_component(hass, "device_automation", {}) @@ -767,11 +772,12 @@ async def test_websocket_get_trigger_capabilities( assert msg["id"] == 1 assert msg["type"] == TYPE_RESULT assert msg["success"] - triggers = msg["result"] + triggers: dict = msg["result"] msg_id = 2 assert len(triggers) == 3 # toggled, turned_on, turned_off for trigger in triggers: + trigger[trigger_key] = trigger.pop("platform") await client.send_json( { "id": msg_id, diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 0eae0c88581..4fd87d6d2fe 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1841,7 +1841,7 @@ async def test_nested_trigger_list() -> None: "event_type": "trigger_3", }, { - "platform": "event", + "trigger": "event", "event_type": "trigger_4", }, ], @@ -1891,7 +1891,36 @@ async def test_nested_trigger_list_extra() -> None: validated_triggers = TRIGGER_SCHEMA(trigger_config) - assert validated_triggers == trigger_config + assert validated_triggers == [ + { + "platform": "other", + "triggers": [ + { + "platform": "event", + "event_type": "trigger_1", + }, + { + "platform": "event", + "event_type": "trigger_2", + }, + ], + }, + ] + + +async def test_trigger_backwards_compatibility() -> None: + """Test triggers with backwards compatibility.""" + + assert cv._backward_compat_trigger_schema("str") == "str" + assert cv._backward_compat_trigger_schema({"platform": "abc"}) == { + "platform": "abc" + } + assert cv._backward_compat_trigger_schema({"trigger": "abc"}) == {"platform": "abc"} + with pytest.raises( + vol.Invalid, + match="Cannot specify both 'platform' and 'trigger'. Please use 'trigger' only.", + ): + cv._backward_compat_trigger_schema({"trigger": "abc", "platform": "def"}) async def test_is_entity_service_schema( diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index de8c3555831..f73808a0625 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1,6 +1,7 @@ """Test selectors.""" from enum import Enum +from typing import Any import pytest import voluptuous as vol @@ -1107,6 +1108,13 @@ def test_condition_selector_schema( ( {}, ( + [ + { + "platform": "numeric_state", + "entity_id": ["sensor.temperature"], + "below": 20, + } + ], [ { "platform": "numeric_state", @@ -1122,7 +1130,24 @@ def test_condition_selector_schema( ) def test_trigger_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test trigger sequence selector.""" - _test_selector("trigger", schema, valid_selections, invalid_selections) + + def _custom_trigger_serializer( + triggers: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + res = [] + for trigger in triggers: + if "trigger" in trigger: + trigger["platform"] = trigger.pop("trigger") + res.append(trigger) + return res + + _test_selector( + "trigger", + schema, + valid_selections, + invalid_selections, + _custom_trigger_serializer, + ) @pytest.mark.parametrize( diff --git a/tests/testing_config/blueprints/automation/test_event_service.yaml b/tests/testing_config/blueprints/automation/test_event_service.yaml index 035278df258..ec11f24fc63 100644 --- a/tests/testing_config/blueprints/automation/test_event_service.yaml +++ b/tests/testing_config/blueprints/automation/test_event_service.yaml @@ -11,7 +11,7 @@ blueprint: number: mode: "box" triggers: - platform: event + trigger: event event_type: !input trigger_event actions: service: !input service_to_call From 10b9e3b29ca255aea895dccbe1564364039d6739 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Sep 2024 14:21:36 +0200 Subject: [PATCH 1169/1309] Use shorthand attributes in tesla_fleet device tracker (#126736) --- .../components/tesla_fleet/device_tracker.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py index 37cad4cea32..62c084c9fe5 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -32,9 +32,6 @@ class TeslaFleetDeviceTrackerEntity( ): """Base class for Tesla Fleet device tracker entities.""" - _attr_latitude: float | None = None - _attr_longitude: float | None = None - def __init__( self, vehicle: TeslaFleetVehicleData, @@ -53,16 +50,6 @@ class TeslaFleetDeviceTrackerEntity( self._attr_latitude = state.attributes.get("latitude") self._attr_longitude = state.attributes.get("longitude") - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - return self._attr_latitude - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - return self._attr_longitude - class TeslaFleetDeviceTrackerLocationEntity(TeslaFleetDeviceTrackerEntity): """Vehicle Location device tracker Class.""" From 6e4e5ba8c52b0c0c3d86deeaacde06754cb5689e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Sep 2024 15:12:48 +0200 Subject: [PATCH 1170/1309] Make Matter snapshot logic a shared function (#126744) --- tests/components/matter/common.py | 17 +++++++++++++++++ tests/components/matter/test_binary_sensor.py | 9 ++------- tests/components/matter/test_sensor.py | 9 ++------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/tests/components/matter/common.py b/tests/components/matter/common.py index a1cdcf699a6..519b4c4027d 100644 --- a/tests/components/matter/common.py +++ b/tests/components/matter/common.py @@ -10,8 +10,11 @@ from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode from matter_server.common.helpers.util import dataclass_from_dict from matter_server.common.models import EventType, MatterNodeData +from syrupy import SnapshotAssertion +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, load_fixture @@ -89,3 +92,17 @@ async def trigger_subscription_callback( if event_filter in (None, event): callback(event, data) await hass.async_block_till_done() + + +def snapshot_matter_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + platform: Platform, +) -> None: + """Snapshot Matter entities.""" + entities = hass.states.async_all(platform) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 61518053897..e0dd445cd72 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -17,6 +17,7 @@ from homeassistant.helpers import entity_registry as er from .common import ( set_node_attribute, setup_integration_with_node_fixture, + snapshot_matter_entities, trigger_subscription_callback, ) @@ -136,10 +137,4 @@ async def test_binary_sensors( snapshot: SnapshotAssertion, ) -> None: """Test binary sensors.""" - entities = hass.states.async_all(Platform.BINARY_SENSOR) - for entity_state in entities: - entity_entry = entity_registry.async_get(entity_state.entity_id) - assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") - state = hass.states.get(entity_entry.entity_id) - assert state, f"State not found for {entity_entry.entity_id}" - assert state == snapshot(name=f"{entity_entry.entity_id}-state") + snapshot_matter_entities(hass, entity_registry, snapshot, Platform.BINARY_SENSOR) diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 0d67c33bbfb..b914e2160b5 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -13,6 +13,7 @@ from homeassistant.helpers import entity_registry as er from .common import ( set_node_attribute, setup_integration_with_node_fixture, + snapshot_matter_entities, trigger_subscription_callback, ) @@ -439,10 +440,4 @@ async def test_sensors( snapshot: SnapshotAssertion, ) -> None: """Test sensors.""" - entities = hass.states.async_all(Platform.SENSOR) - for entity_state in entities: - entity_entry = entity_registry.async_get(entity_state.entity_id) - assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") - state = hass.states.get(entity_entry.entity_id) - assert state, f"State not found for {entity_entry.entity_id}" - assert state == snapshot(name=f"{entity_entry.entity_id}-state") + snapshot_matter_entities(hass, entity_registry, snapshot, Platform.SENSOR) From fb913771393211170dd7dc192181c716dc340c79 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:21:50 +0200 Subject: [PATCH 1171/1309] Use shorthand attributes in mysensors device tracker (#126738) --- .../components/mysensors/device_tracker.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index f36adb41311..5abe6a64e2d 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -47,19 +47,6 @@ async def async_setup_entry( class MySensorsDeviceTracker(MySensorsChildEntity, TrackerEntity): """Represent a MySensors device tracker.""" - _latitude: float | None = None - _longitude: float | None = None - - @property - def latitude(self) -> float | None: - """Return latitude value of the device.""" - return self._latitude - - @property - def longitude(self) -> float | None: - """Return longitude value of the device.""" - return self._longitude - @callback def _async_update(self) -> None: """Update the controller with the latest value from a device.""" @@ -68,5 +55,5 @@ class MySensorsDeviceTracker(MySensorsChildEntity, TrackerEntity): child = node.children[self.child_id] position: str = child.values[self.value_type] latitude, longitude, _ = position.split(",") - self._latitude = float(latitude) - self._longitude = float(longitude) + self._attr_latitude = float(latitude) + self._attr_longitude = float(longitude) From 083b586d1972155ea3e2417a2ba4dcca956545d2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:27:57 +0200 Subject: [PATCH 1172/1309] Add pylint checks for fixture scope (#126723) * Prevent session scope fixtures in component tests * Link message to the decorator - not the function * Add checks for package also * Add check for session scope autouse * Rename variable * Adjust message * Ignore fancy autouse * Simplify --- pylint/plugins/hass_decorator.py | 94 +++++++++++++- tests/pylint/test_decorator.py | 204 +++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+), 4 deletions(-) diff --git a/pylint/plugins/hass_decorator.py b/pylint/plugins/hass_decorator.py index 51bdd99cd2b..7e509776a86 100644 --- a/pylint/plugins/hass_decorator.py +++ b/pylint/plugins/hass_decorator.py @@ -18,14 +18,100 @@ class HassDecoratorChecker(BaseChecker): "hass-async-callback-decorator", "Used when a coroutine function has an invalid @callback decorator", ), + "W7472": ( + "Fixture %s is invalid here, please %s", + "hass-pytest-fixture-decorator", + "Used when a pytest fixture is invalid", + ), } + def _get_pytest_fixture_node(self, node: nodes.FunctionDef) -> nodes.Call | None: + for decorator in node.decorators.nodes: + if ( + isinstance(decorator, nodes.Call) + and decorator.func.as_string() == "pytest.fixture" + ): + return decorator + + return None + + def _get_pytest_fixture_node_keyword( + self, decorator: nodes.Call, search_arg: str + ) -> nodes.Keyword | None: + for keyword in decorator.keywords: + if keyword.arg == search_arg: + return keyword + + return None + + def _check_pytest_fixture( + self, node: nodes.FunctionDef, decoratornames: set[str] + ) -> None: + if ( + "_pytest.fixtures.FixtureFunctionMarker" not in decoratornames + or not (root_name := node.root().name).startswith("tests.") + or (decorator := self._get_pytest_fixture_node(node)) is None + or not ( + scope_keyword := self._get_pytest_fixture_node_keyword( + decorator, "scope" + ) + ) + or not isinstance(scope_keyword.value, nodes.Const) + or not (scope := scope_keyword.value.value) + ): + return + + parts = root_name.split(".") + test_component: str | None = None + if root_name.startswith("tests.components.") and parts[2] != "conftest": + test_component = parts[2] + + if scope == "session": + if test_component: + self.add_message( + "hass-pytest-fixture-decorator", + node=decorator, + args=("scope `session`", "use `package` or lower"), + ) + return + if not ( + autouse_keyword := self._get_pytest_fixture_node_keyword( + decorator, "autouse" + ) + ) or ( + isinstance(autouse_keyword.value, nodes.Const) + and not autouse_keyword.value.value + ): + self.add_message( + "hass-pytest-fixture-decorator", + node=decorator, + args=( + "scope/autouse combination", + "set `autouse=True` or reduce scope", + ), + ) + return + + test_module = parts[3] if len(parts) > 3 else "" + + if test_component and scope == "package" and test_module != "conftest": + self.add_message( + "hass-pytest-fixture-decorator", + node=decorator, + args=("scope `package`", "use `module` or lower"), + ) + def visit_asyncfunctiondef(self, node: nodes.AsyncFunctionDef) -> None: """Apply checks on an AsyncFunctionDef node.""" - if ( - decoratornames := node.decoratornames() - ) and "homeassistant.core.callback" in decoratornames: - self.add_message("hass-async-callback-decorator", node=node) + if decoratornames := node.decoratornames(): + if "homeassistant.core.callback" in decoratornames: + self.add_message("hass-async-callback-decorator", node=node) + self._check_pytest_fixture(node, decoratornames) + + def visit_functiondef(self, node: nodes.FunctionDef) -> None: + """Apply checks on an AsyncFunctionDef node.""" + if decoratornames := node.decoratornames(): + self._check_pytest_fixture(node, decoratornames) def register(linter: PyLinter) -> None: diff --git a/tests/pylint/test_decorator.py b/tests/pylint/test_decorator.py index 05a443c1456..c2e45e5a433 100644 --- a/tests/pylint/test_decorator.py +++ b/tests/pylint/test_decorator.py @@ -8,6 +8,7 @@ from pylint.interfaces import UNDEFINED from pylint.testutils import MessageTest from pylint.testutils.unittest_linter import UnittestLinter from pylint.utils.ast_walker import ASTWalker +import pytest from . import assert_adds_messages, assert_no_messages @@ -62,3 +63,206 @@ def test_bad_callback(linter: UnittestLinter, decorator_checker: BaseChecker) -> ), ): walker.walk(root_node) + + +@pytest.mark.parametrize( + ("keywords", "path"), + [ + ('scope="function"', "tests.test_bootstrap"), + ('scope="class"', "tests.test_bootstrap"), + ('scope="module"', "tests.test_bootstrap"), + ('scope="package"', "tests.test_bootstrap"), + ('scope="session", autouse=True', "tests.test_bootstrap"), + ('scope="function"', "tests.components.conftest"), + ('scope="class"', "tests.components.conftest"), + ('scope="module"', "tests.components.conftest"), + ('scope="package"', "tests.components.conftest"), + ('scope="session", autouse=True', "tests.components.conftest"), + ( + 'scope="session", autouse=find_spec("zeroconf") is not None', + "tests.components.conftest", + ), + ('scope="function"', "tests.components.pylint_tests.conftest"), + ('scope="class"', "tests.components.pylint_tests.conftest"), + ('scope="module"', "tests.components.pylint_tests.conftest"), + ('scope="package"', "tests.components.pylint_tests.conftest"), + ('scope="function"', "tests.components.pylint_test"), + ('scope="class"', "tests.components.pylint_test"), + ('scope="module"', "tests.components.pylint_test"), + ], +) +def test_good_fixture( + linter: UnittestLinter, decorator_checker: BaseChecker, keywords: str, path: str +) -> None: + """Test good `@pytest.fixture` decorator.""" + code = f""" + import pytest + + @pytest.fixture + def setup( + arg1, arg2 + ): + pass + + @pytest.fixture({keywords}) + def setup_session( + arg1, arg2 + ): + pass + """ + + root_node = astroid.parse(code, path) + walker = ASTWalker(linter) + walker.add_checker(decorator_checker) + + with assert_no_messages(linter): + walker.walk(root_node) + + +@pytest.mark.parametrize( + "path", + [ + "tests.components.pylint_test", + "tests.components.pylint_test.conftest", + "tests.components.pylint_test.module", + ], +) +def test_bad_fixture_session_scope( + linter: UnittestLinter, decorator_checker: BaseChecker, path: str +) -> None: + """Test bad `@pytest.fixture` decorator.""" + code = """ + import pytest + + @pytest.fixture + def setup( + arg1, arg2 + ): + pass + + @pytest.fixture(scope="session") + def setup_session( + arg1, arg2 + ): + pass + """ + + root_node = astroid.parse(code, path) + walker = ASTWalker(linter) + walker.add_checker(decorator_checker) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-pytest-fixture-decorator", + line=10, + node=root_node.body[2].decorators.nodes[0], + args=("scope `session`", "use `package` or lower"), + confidence=UNDEFINED, + col_offset=1, + end_line=10, + end_col_offset=32, + ), + ): + walker.walk(root_node) + + +@pytest.mark.parametrize( + "path", + [ + "tests.components.pylint_test", + "tests.components.pylint_test.module", + ], +) +def test_bad_fixture_package_scope( + linter: UnittestLinter, decorator_checker: BaseChecker, path: str +) -> None: + """Test bad `@pytest.fixture` decorator.""" + code = """ + import pytest + + @pytest.fixture + def setup( + arg1, arg2 + ): + pass + + @pytest.fixture(scope="package") + def setup_session( + arg1, arg2 + ): + pass + """ + + root_node = astroid.parse(code, path) + walker = ASTWalker(linter) + walker.add_checker(decorator_checker) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-pytest-fixture-decorator", + line=10, + node=root_node.body[2].decorators.nodes[0], + args=("scope `package`", "use `module` or lower"), + confidence=UNDEFINED, + col_offset=1, + end_line=10, + end_col_offset=32, + ), + ): + walker.walk(root_node) + + +@pytest.mark.parametrize( + "keywords", + [ + 'scope="session"', + 'scope="session", autouse=False', + ], +) +@pytest.mark.parametrize( + "path", + [ + "tests.test_bootstrap", + "tests.components.conftest", + ], +) +def test_bad_fixture_autouse( + linter: UnittestLinter, decorator_checker: BaseChecker, keywords: str, path: str +) -> None: + """Test bad `@pytest.fixture` decorator.""" + code = f""" + import pytest + + @pytest.fixture + def setup( + arg1, arg2 + ): + pass + + @pytest.fixture({keywords}) + def setup_session( + arg1, arg2 + ): + pass + """ + + root_node = astroid.parse(code, path) + walker = ASTWalker(linter) + walker.add_checker(decorator_checker) + + with assert_adds_messages( + linter, + MessageTest( + msg_id="hass-pytest-fixture-decorator", + line=10, + node=root_node.body[2].decorators.nodes[0], + args=("scope/autouse combination", "set `autouse=True` or reduce scope"), + confidence=UNDEFINED, + col_offset=1, + end_line=10, + end_col_offset=17 + len(keywords), + ), + ): + walker.walk(root_node) From 662a70416543f8c447d6502fed5d8c5980714aa3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Sep 2024 15:29:17 +0200 Subject: [PATCH 1173/1309] Use snake case in matter fixture nodes (#126743) --- tests/components/matter/conftest.py | 18 ++-- .../{air-purifier.json => air_purifier.json} | 0 ...ty-sensor.json => air_quality_sensor.json} | 0 ...ight.json => color_temperature_light.json} | 0 ...immable-light.json => dimmable_light.json} | 0 ...in-unit.json => dimmable_plugin_unit.json} | 0 .../nodes/{door-lock.json => door_lock.json} | 0 ...unbolt.json => door_lock_with_unbolt.json} | 0 ...ct-sensor.json => eve_contact_sensor.json} | 0 ...-energy-plug.json => eve_energy_plug.json} | 0 ...ched.json => eve_energy_plug_patched.json} | 0 .../{eve-thermo.json => eve_thermo.json} | 0 ...er-sensor.json => eve_weather_sensor.json} | 0 ...r-light.json => extended_color_light.json} | 0 .../{flow-sensor.json => flow_sensor.json} | 0 ...eneric-switch.json => generic_switch.json} | 0 ...h-multi.json => generic_switch_multi.json} | 0 ...idity-sensor.json => humidity_sensor.json} | 0 .../{leak-sensor.json => leak_sensor.json} | 0 .../{light-sensor.json => light_sensor.json} | 0 ...icrowave-oven.json => microwave_oven.json} | 0 ...t-light.json => multi_endpoint_light.json} | 0 ...ancy-sensor.json => occupancy_sensor.json} | 0 ...ugin-unit.json => on_off_plugin_unit.json} | 0 .../{onoff-light.json => onoff_light.json} | 0 ...lt-name.json => onoff_light_alt_name.json} | 0 ...-no-name.json => onoff_light_no_name.json} | 0 ...noff_light_with_levelcontrol_present.json} | 0 ...ssure-sensor.json => pressure_sensor.json} | 0 ...ditioner.json => room_airconditioner.json} | 0 ...dishwasher.json => silabs_dishwasher.json} | 0 ...moke-detector.json => smoke_detector.json} | 0 .../{switch-unit.json => switch_unit.json} | 0 ...re-sensor.json => temperature_sensor.json} | 0 ...ng_full.json => window_covering_full.json} | 0 ...ng_lift.json => window_covering_lift.json} | 0 ...lift.json => window_covering_pa_lift.json} | 0 ...tilt.json => window_covering_pa_tilt.json} | 0 ...ng_tilt.json => window_covering_tilt.json} | 0 .../matter/snapshots/test_binary_sensor.ambr | 32 +++---- .../matter/snapshots/test_sensor.ambr | 92 +++++++++---------- tests/components/matter/test_adapter.py | 20 ++-- tests/components/matter/test_api.py | 10 +- tests/components/matter/test_binary_sensor.py | 6 +- tests/components/matter/test_button.py | 4 +- tests/components/matter/test_climate.py | 2 +- tests/components/matter/test_cover.py | 32 +++---- tests/components/matter/test_event.py | 4 +- tests/components/matter/test_fan.py | 2 +- tests/components/matter/test_init.py | 2 +- tests/components/matter/test_light.py | 24 ++--- tests/components/matter/test_number.py | 4 +- tests/components/matter/test_select.py | 2 +- tests/components/matter/test_sensor.py | 24 ++--- tests/components/matter/test_switch.py | 6 +- tests/components/matter/test_update.py | 4 +- 56 files changed, 144 insertions(+), 144 deletions(-) rename tests/components/matter/fixtures/nodes/{air-purifier.json => air_purifier.json} (100%) rename tests/components/matter/fixtures/nodes/{air-quality-sensor.json => air_quality_sensor.json} (100%) rename tests/components/matter/fixtures/nodes/{color-temperature-light.json => color_temperature_light.json} (100%) rename tests/components/matter/fixtures/nodes/{dimmable-light.json => dimmable_light.json} (100%) rename tests/components/matter/fixtures/nodes/{dimmable-plugin-unit.json => dimmable_plugin_unit.json} (100%) rename tests/components/matter/fixtures/nodes/{door-lock.json => door_lock.json} (100%) rename tests/components/matter/fixtures/nodes/{door-lock-with-unbolt.json => door_lock_with_unbolt.json} (100%) rename tests/components/matter/fixtures/nodes/{eve-contact-sensor.json => eve_contact_sensor.json} (100%) rename tests/components/matter/fixtures/nodes/{eve-energy-plug.json => eve_energy_plug.json} (100%) rename tests/components/matter/fixtures/nodes/{eve-energy-plug-patched.json => eve_energy_plug_patched.json} (100%) rename tests/components/matter/fixtures/nodes/{eve-thermo.json => eve_thermo.json} (100%) rename tests/components/matter/fixtures/nodes/{eve-weather-sensor.json => eve_weather_sensor.json} (100%) rename tests/components/matter/fixtures/nodes/{extended-color-light.json => extended_color_light.json} (100%) rename tests/components/matter/fixtures/nodes/{flow-sensor.json => flow_sensor.json} (100%) rename tests/components/matter/fixtures/nodes/{generic-switch.json => generic_switch.json} (100%) rename tests/components/matter/fixtures/nodes/{generic-switch-multi.json => generic_switch_multi.json} (100%) rename tests/components/matter/fixtures/nodes/{humidity-sensor.json => humidity_sensor.json} (100%) rename tests/components/matter/fixtures/nodes/{leak-sensor.json => leak_sensor.json} (100%) rename tests/components/matter/fixtures/nodes/{light-sensor.json => light_sensor.json} (100%) rename tests/components/matter/fixtures/nodes/{microwave-oven.json => microwave_oven.json} (100%) rename tests/components/matter/fixtures/nodes/{multi-endpoint-light.json => multi_endpoint_light.json} (100%) rename tests/components/matter/fixtures/nodes/{occupancy-sensor.json => occupancy_sensor.json} (100%) rename tests/components/matter/fixtures/nodes/{on-off-plugin-unit.json => on_off_plugin_unit.json} (100%) rename tests/components/matter/fixtures/nodes/{onoff-light.json => onoff_light.json} (100%) rename tests/components/matter/fixtures/nodes/{onoff-light-alt-name.json => onoff_light_alt_name.json} (100%) rename tests/components/matter/fixtures/nodes/{onoff-light-no-name.json => onoff_light_no_name.json} (100%) rename tests/components/matter/fixtures/nodes/{onoff-light-with-levelcontrol-present.json => onoff_light_with_levelcontrol_present.json} (100%) rename tests/components/matter/fixtures/nodes/{pressure-sensor.json => pressure_sensor.json} (100%) rename tests/components/matter/fixtures/nodes/{room-airconditioner.json => room_airconditioner.json} (100%) rename tests/components/matter/fixtures/nodes/{silabs-dishwasher.json => silabs_dishwasher.json} (100%) rename tests/components/matter/fixtures/nodes/{smoke-detector.json => smoke_detector.json} (100%) rename tests/components/matter/fixtures/nodes/{switch-unit.json => switch_unit.json} (100%) rename tests/components/matter/fixtures/nodes/{temperature-sensor.json => temperature_sensor.json} (100%) rename tests/components/matter/fixtures/nodes/{window-covering_full.json => window_covering_full.json} (100%) rename tests/components/matter/fixtures/nodes/{window-covering_lift.json => window_covering_lift.json} (100%) rename tests/components/matter/fixtures/nodes/{window-covering_pa-lift.json => window_covering_pa_lift.json} (100%) rename tests/components/matter/fixtures/nodes/{window-covering_pa-tilt.json => window_covering_pa_tilt.json} (100%) rename tests/components/matter/fixtures/nodes/{window-covering_tilt.json => window_covering_tilt.json} (100%) diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index d1df9687376..0aa58945744 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -72,11 +72,11 @@ async def integration_fixture( @pytest.fixture( params=[ - "door-lock", - "smoke-detector", - "air-purifier", - "eve-energy-plug-patched", - "eve-energy-plug", + "door_lock", + "smoke_detector", + "air_purifier", + "eve_energy_plug_patched", + "eve_energy_plug", ] ) async def matter_devices( @@ -91,7 +91,7 @@ async def door_lock_fixture( hass: HomeAssistant, matter_client: MagicMock ) -> MatterNode: """Fixture for a door lock node.""" - return await setup_integration_with_node_fixture(hass, "door-lock", matter_client) + return await setup_integration_with_node_fixture(hass, "door_lock", matter_client) @pytest.fixture(name="smoke_detector") @@ -100,7 +100,7 @@ async def smoke_detector_fixture( ) -> MatterNode: """Fixture for a smoke detector node.""" return await setup_integration_with_node_fixture( - hass, "smoke-detector", matter_client + hass, "smoke_detector", matter_client ) @@ -110,7 +110,7 @@ async def door_lock_with_unbolt_fixture( ) -> MatterNode: """Fixture for a door lock node with unbolt feature.""" return await setup_integration_with_node_fixture( - hass, "door-lock-with-unbolt", matter_client + hass, "door_lock_with_unbolt", matter_client ) @@ -120,5 +120,5 @@ async def eve_contact_sensor_node_fixture( ) -> MatterNode: """Fixture for a contact sensor node.""" return await setup_integration_with_node_fixture( - hass, "eve-contact-sensor", matter_client + hass, "eve_contact_sensor", matter_client ) diff --git a/tests/components/matter/fixtures/nodes/air-purifier.json b/tests/components/matter/fixtures/nodes/air_purifier.json similarity index 100% rename from tests/components/matter/fixtures/nodes/air-purifier.json rename to tests/components/matter/fixtures/nodes/air_purifier.json diff --git a/tests/components/matter/fixtures/nodes/air-quality-sensor.json b/tests/components/matter/fixtures/nodes/air_quality_sensor.json similarity index 100% rename from tests/components/matter/fixtures/nodes/air-quality-sensor.json rename to tests/components/matter/fixtures/nodes/air_quality_sensor.json diff --git a/tests/components/matter/fixtures/nodes/color-temperature-light.json b/tests/components/matter/fixtures/nodes/color_temperature_light.json similarity index 100% rename from tests/components/matter/fixtures/nodes/color-temperature-light.json rename to tests/components/matter/fixtures/nodes/color_temperature_light.json diff --git a/tests/components/matter/fixtures/nodes/dimmable-light.json b/tests/components/matter/fixtures/nodes/dimmable_light.json similarity index 100% rename from tests/components/matter/fixtures/nodes/dimmable-light.json rename to tests/components/matter/fixtures/nodes/dimmable_light.json diff --git a/tests/components/matter/fixtures/nodes/dimmable-plugin-unit.json b/tests/components/matter/fixtures/nodes/dimmable_plugin_unit.json similarity index 100% rename from tests/components/matter/fixtures/nodes/dimmable-plugin-unit.json rename to tests/components/matter/fixtures/nodes/dimmable_plugin_unit.json diff --git a/tests/components/matter/fixtures/nodes/door-lock.json b/tests/components/matter/fixtures/nodes/door_lock.json similarity index 100% rename from tests/components/matter/fixtures/nodes/door-lock.json rename to tests/components/matter/fixtures/nodes/door_lock.json diff --git a/tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json b/tests/components/matter/fixtures/nodes/door_lock_with_unbolt.json similarity index 100% rename from tests/components/matter/fixtures/nodes/door-lock-with-unbolt.json rename to tests/components/matter/fixtures/nodes/door_lock_with_unbolt.json diff --git a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json b/tests/components/matter/fixtures/nodes/eve_contact_sensor.json similarity index 100% rename from tests/components/matter/fixtures/nodes/eve-contact-sensor.json rename to tests/components/matter/fixtures/nodes/eve_contact_sensor.json diff --git a/tests/components/matter/fixtures/nodes/eve-energy-plug.json b/tests/components/matter/fixtures/nodes/eve_energy_plug.json similarity index 100% rename from tests/components/matter/fixtures/nodes/eve-energy-plug.json rename to tests/components/matter/fixtures/nodes/eve_energy_plug.json diff --git a/tests/components/matter/fixtures/nodes/eve-energy-plug-patched.json b/tests/components/matter/fixtures/nodes/eve_energy_plug_patched.json similarity index 100% rename from tests/components/matter/fixtures/nodes/eve-energy-plug-patched.json rename to tests/components/matter/fixtures/nodes/eve_energy_plug_patched.json diff --git a/tests/components/matter/fixtures/nodes/eve-thermo.json b/tests/components/matter/fixtures/nodes/eve_thermo.json similarity index 100% rename from tests/components/matter/fixtures/nodes/eve-thermo.json rename to tests/components/matter/fixtures/nodes/eve_thermo.json diff --git a/tests/components/matter/fixtures/nodes/eve-weather-sensor.json b/tests/components/matter/fixtures/nodes/eve_weather_sensor.json similarity index 100% rename from tests/components/matter/fixtures/nodes/eve-weather-sensor.json rename to tests/components/matter/fixtures/nodes/eve_weather_sensor.json diff --git a/tests/components/matter/fixtures/nodes/extended-color-light.json b/tests/components/matter/fixtures/nodes/extended_color_light.json similarity index 100% rename from tests/components/matter/fixtures/nodes/extended-color-light.json rename to tests/components/matter/fixtures/nodes/extended_color_light.json diff --git a/tests/components/matter/fixtures/nodes/flow-sensor.json b/tests/components/matter/fixtures/nodes/flow_sensor.json similarity index 100% rename from tests/components/matter/fixtures/nodes/flow-sensor.json rename to tests/components/matter/fixtures/nodes/flow_sensor.json diff --git a/tests/components/matter/fixtures/nodes/generic-switch.json b/tests/components/matter/fixtures/nodes/generic_switch.json similarity index 100% rename from tests/components/matter/fixtures/nodes/generic-switch.json rename to tests/components/matter/fixtures/nodes/generic_switch.json diff --git a/tests/components/matter/fixtures/nodes/generic-switch-multi.json b/tests/components/matter/fixtures/nodes/generic_switch_multi.json similarity index 100% rename from tests/components/matter/fixtures/nodes/generic-switch-multi.json rename to tests/components/matter/fixtures/nodes/generic_switch_multi.json diff --git a/tests/components/matter/fixtures/nodes/humidity-sensor.json b/tests/components/matter/fixtures/nodes/humidity_sensor.json similarity index 100% rename from tests/components/matter/fixtures/nodes/humidity-sensor.json rename to tests/components/matter/fixtures/nodes/humidity_sensor.json diff --git a/tests/components/matter/fixtures/nodes/leak-sensor.json b/tests/components/matter/fixtures/nodes/leak_sensor.json similarity index 100% rename from tests/components/matter/fixtures/nodes/leak-sensor.json rename to tests/components/matter/fixtures/nodes/leak_sensor.json diff --git a/tests/components/matter/fixtures/nodes/light-sensor.json b/tests/components/matter/fixtures/nodes/light_sensor.json similarity index 100% rename from tests/components/matter/fixtures/nodes/light-sensor.json rename to tests/components/matter/fixtures/nodes/light_sensor.json diff --git a/tests/components/matter/fixtures/nodes/microwave-oven.json b/tests/components/matter/fixtures/nodes/microwave_oven.json similarity index 100% rename from tests/components/matter/fixtures/nodes/microwave-oven.json rename to tests/components/matter/fixtures/nodes/microwave_oven.json diff --git a/tests/components/matter/fixtures/nodes/multi-endpoint-light.json b/tests/components/matter/fixtures/nodes/multi_endpoint_light.json similarity index 100% rename from tests/components/matter/fixtures/nodes/multi-endpoint-light.json rename to tests/components/matter/fixtures/nodes/multi_endpoint_light.json diff --git a/tests/components/matter/fixtures/nodes/occupancy-sensor.json b/tests/components/matter/fixtures/nodes/occupancy_sensor.json similarity index 100% rename from tests/components/matter/fixtures/nodes/occupancy-sensor.json rename to tests/components/matter/fixtures/nodes/occupancy_sensor.json diff --git a/tests/components/matter/fixtures/nodes/on-off-plugin-unit.json b/tests/components/matter/fixtures/nodes/on_off_plugin_unit.json similarity index 100% rename from tests/components/matter/fixtures/nodes/on-off-plugin-unit.json rename to tests/components/matter/fixtures/nodes/on_off_plugin_unit.json diff --git a/tests/components/matter/fixtures/nodes/onoff-light.json b/tests/components/matter/fixtures/nodes/onoff_light.json similarity index 100% rename from tests/components/matter/fixtures/nodes/onoff-light.json rename to tests/components/matter/fixtures/nodes/onoff_light.json diff --git a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json b/tests/components/matter/fixtures/nodes/onoff_light_alt_name.json similarity index 100% rename from tests/components/matter/fixtures/nodes/onoff-light-alt-name.json rename to tests/components/matter/fixtures/nodes/onoff_light_alt_name.json diff --git a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json b/tests/components/matter/fixtures/nodes/onoff_light_no_name.json similarity index 100% rename from tests/components/matter/fixtures/nodes/onoff-light-no-name.json rename to tests/components/matter/fixtures/nodes/onoff_light_no_name.json diff --git a/tests/components/matter/fixtures/nodes/onoff-light-with-levelcontrol-present.json b/tests/components/matter/fixtures/nodes/onoff_light_with_levelcontrol_present.json similarity index 100% rename from tests/components/matter/fixtures/nodes/onoff-light-with-levelcontrol-present.json rename to tests/components/matter/fixtures/nodes/onoff_light_with_levelcontrol_present.json diff --git a/tests/components/matter/fixtures/nodes/pressure-sensor.json b/tests/components/matter/fixtures/nodes/pressure_sensor.json similarity index 100% rename from tests/components/matter/fixtures/nodes/pressure-sensor.json rename to tests/components/matter/fixtures/nodes/pressure_sensor.json diff --git a/tests/components/matter/fixtures/nodes/room-airconditioner.json b/tests/components/matter/fixtures/nodes/room_airconditioner.json similarity index 100% rename from tests/components/matter/fixtures/nodes/room-airconditioner.json rename to tests/components/matter/fixtures/nodes/room_airconditioner.json diff --git a/tests/components/matter/fixtures/nodes/silabs-dishwasher.json b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json similarity index 100% rename from tests/components/matter/fixtures/nodes/silabs-dishwasher.json rename to tests/components/matter/fixtures/nodes/silabs_dishwasher.json diff --git a/tests/components/matter/fixtures/nodes/smoke-detector.json b/tests/components/matter/fixtures/nodes/smoke_detector.json similarity index 100% rename from tests/components/matter/fixtures/nodes/smoke-detector.json rename to tests/components/matter/fixtures/nodes/smoke_detector.json diff --git a/tests/components/matter/fixtures/nodes/switch-unit.json b/tests/components/matter/fixtures/nodes/switch_unit.json similarity index 100% rename from tests/components/matter/fixtures/nodes/switch-unit.json rename to tests/components/matter/fixtures/nodes/switch_unit.json diff --git a/tests/components/matter/fixtures/nodes/temperature-sensor.json b/tests/components/matter/fixtures/nodes/temperature_sensor.json similarity index 100% rename from tests/components/matter/fixtures/nodes/temperature-sensor.json rename to tests/components/matter/fixtures/nodes/temperature_sensor.json diff --git a/tests/components/matter/fixtures/nodes/window-covering_full.json b/tests/components/matter/fixtures/nodes/window_covering_full.json similarity index 100% rename from tests/components/matter/fixtures/nodes/window-covering_full.json rename to tests/components/matter/fixtures/nodes/window_covering_full.json diff --git a/tests/components/matter/fixtures/nodes/window-covering_lift.json b/tests/components/matter/fixtures/nodes/window_covering_lift.json similarity index 100% rename from tests/components/matter/fixtures/nodes/window-covering_lift.json rename to tests/components/matter/fixtures/nodes/window_covering_lift.json diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json b/tests/components/matter/fixtures/nodes/window_covering_pa_lift.json similarity index 100% rename from tests/components/matter/fixtures/nodes/window-covering_pa-lift.json rename to tests/components/matter/fixtures/nodes/window_covering_pa_lift.json diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json b/tests/components/matter/fixtures/nodes/window_covering_pa_tilt.json similarity index 100% rename from tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json rename to tests/components/matter/fixtures/nodes/window_covering_pa_tilt.json diff --git a/tests/components/matter/fixtures/nodes/window-covering_tilt.json b/tests/components/matter/fixtures/nodes/window_covering_tilt.json similarity index 100% rename from tests/components/matter/fixtures/nodes/window-covering_tilt.json rename to tests/components/matter/fixtures/nodes/window_covering_tilt.json diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index e72f6ed2410..9161c9dc797 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensors[door-lock-True][binary_sensor.mock_door_lock_battery-entry] +# name: test_binary_sensors[door_lock-True][binary_sensor.mock_door_lock_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -32,7 +32,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[door-lock-True][binary_sensor.mock_door_lock_battery-state] +# name: test_binary_sensors[door_lock-True][binary_sensor.mock_door_lock_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -46,7 +46,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[door-lock-True][binary_sensor.mock_door_lock_door-entry] +# name: test_binary_sensors[door_lock-True][binary_sensor.mock_door_lock_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -79,7 +79,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[door-lock-True][binary_sensor.mock_door_lock_door-state] +# name: test_binary_sensors[door_lock-True][binary_sensor.mock_door_lock_door-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'door', @@ -93,7 +93,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_battery_alert-entry] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_battery_alert-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -126,7 +126,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_battery_alert-state] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_battery_alert-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -140,7 +140,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_end_of_service-entry] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_end_of_service-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -173,7 +173,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_end_of_service-state] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_end_of_service-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', @@ -187,7 +187,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_hardware_fault-entry] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_hardware_fault-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -220,7 +220,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_hardware_fault-state] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_hardware_fault-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', @@ -234,7 +234,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_muted-entry] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_muted-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -267,7 +267,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_muted-state] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_muted-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Smoke sensor Muted', @@ -280,7 +280,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_smoke-entry] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_smoke-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -313,7 +313,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_smoke-state] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_smoke-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'smoke', @@ -327,7 +327,7 @@ 'state': 'off', }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_test_in_progress-entry] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_test_in_progress-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -360,7 +360,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[smoke-detector-True][binary_sensor.smoke_sensor_test_in_progress-state] +# name: test_binary_sensors[smoke_detector-True][binary_sensor.smoke_sensor_test_in_progress-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 63024d3a320..a4d56769c77 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensors[air-purifier-True][sensor.air_purifier_activated_carbon_filter_condition-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_activated_carbon_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_activated_carbon_filter_condition-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_activated_carbon_filter_condition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Air Purifier Activated carbon filter condition', @@ -49,7 +49,7 @@ 'state': '100', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_air_quality-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_air_quality-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -91,7 +91,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_air_quality-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_air_quality-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', @@ -113,7 +113,7 @@ 'state': 'good', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_carbon_dioxide-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_carbon_dioxide-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -148,7 +148,7 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_carbon_dioxide-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_carbon_dioxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'carbon_dioxide', @@ -164,7 +164,7 @@ 'state': '2.0', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_carbon_monoxide-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_carbon_monoxide-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -199,7 +199,7 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_carbon_monoxide-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_carbon_monoxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'carbon_monoxide', @@ -215,7 +215,7 @@ 'state': '2.0', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_hepa_filter_condition-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_hepa_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -250,7 +250,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_hepa_filter_condition-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_hepa_filter_condition-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Air Purifier Hepa filter condition', @@ -265,7 +265,7 @@ 'state': '100', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_humidity-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -300,7 +300,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_humidity-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_humidity-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'humidity', @@ -316,7 +316,7 @@ 'state': '50.0', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_nitrogen_dioxide-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_nitrogen_dioxide-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -351,7 +351,7 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_nitrogen_dioxide-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_nitrogen_dioxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'nitrogen_dioxide', @@ -367,7 +367,7 @@ 'state': '2.0', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_ozone-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_ozone-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -402,7 +402,7 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_ozone-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_ozone-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'ozone', @@ -418,7 +418,7 @@ 'state': '2.0', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_pm1-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_pm1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -453,7 +453,7 @@ 'unit_of_measurement': 'µg/m³', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_pm1-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_pm1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'pm1', @@ -469,7 +469,7 @@ 'state': '2.0', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_pm10-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_pm10-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -504,7 +504,7 @@ 'unit_of_measurement': 'µg/m³', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_pm10-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_pm10-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'pm10', @@ -520,7 +520,7 @@ 'state': '2.0', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_pm2_5-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_pm2_5-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -555,7 +555,7 @@ 'unit_of_measurement': 'µg/m³', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_pm2_5-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_pm2_5-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'pm25', @@ -571,7 +571,7 @@ 'state': '2.0', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_temperature-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -606,7 +606,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_temperature-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -622,7 +622,7 @@ 'state': '20.0', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_vocs-entry] +# name: test_sensors[air_purifier-True][sensor.air_purifier_vocs-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -657,7 +657,7 @@ 'unit_of_measurement': 'ppm', }) # --- -# name: test_sensors[air-purifier-True][sensor.air_purifier_vocs-state] +# name: test_sensors[air_purifier-True][sensor.air_purifier_vocs-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'volatile_organic_compounds_parts', @@ -673,7 +673,7 @@ 'state': '2.0', }) # --- -# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_current-entry] +# name: test_sensors[eve_energy_plug-True][sensor.eve_energy_plug_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -711,7 +711,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_current-state] +# name: test_sensors[eve_energy_plug-True][sensor.eve_energy_plug_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -727,7 +727,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_energy-entry] +# name: test_sensors[eve_energy_plug-True][sensor.eve_energy_plug_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -765,7 +765,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_energy-state] +# name: test_sensors[eve_energy_plug-True][sensor.eve_energy_plug_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -781,7 +781,7 @@ 'state': '0.220000028610229', }) # --- -# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_power-entry] +# name: test_sensors[eve_energy_plug-True][sensor.eve_energy_plug_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -819,7 +819,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_power-state] +# name: test_sensors[eve_energy_plug-True][sensor.eve_energy_plug_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -835,7 +835,7 @@ 'state': '0.0', }) # --- -# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_voltage-entry] +# name: test_sensors[eve_energy_plug-True][sensor.eve_energy_plug_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -873,7 +873,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[eve-energy-plug-True][sensor.eve_energy_plug_voltage-state] +# name: test_sensors[eve_energy_plug-True][sensor.eve_energy_plug_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -889,7 +889,7 @@ 'state': '238.800003051758', }) # --- -# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_current-entry] +# name: test_sensors[eve_energy_plug_patched-True][sensor.eve_energy_plug_patched_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -927,7 +927,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_current-state] +# name: test_sensors[eve_energy_plug_patched-True][sensor.eve_energy_plug_patched_current-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'current', @@ -943,7 +943,7 @@ 'state': '2.0', }) # --- -# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_energy-entry] +# name: test_sensors[eve_energy_plug_patched-True][sensor.eve_energy_plug_patched_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -981,7 +981,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_energy-state] +# name: test_sensors[eve_energy_plug_patched-True][sensor.eve_energy_plug_patched_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', @@ -997,7 +997,7 @@ 'state': '0.0025', }) # --- -# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_power-entry] +# name: test_sensors[eve_energy_plug_patched-True][sensor.eve_energy_plug_patched_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1035,7 +1035,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_power-state] +# name: test_sensors[eve_energy_plug_patched-True][sensor.eve_energy_plug_patched_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', @@ -1051,7 +1051,7 @@ 'state': '550.0', }) # --- -# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_voltage-entry] +# name: test_sensors[eve_energy_plug_patched-True][sensor.eve_energy_plug_patched_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1089,7 +1089,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[eve-energy-plug-patched-True][sensor.eve_energy_plug_patched_voltage-state] +# name: test_sensors[eve_energy_plug_patched-True][sensor.eve_energy_plug_patched_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', @@ -1105,7 +1105,7 @@ 'state': '220.0', }) # --- -# name: test_sensors[smoke-detector-True][sensor.smoke_sensor_battery-entry] +# name: test_sensors[smoke_detector-True][sensor.smoke_sensor_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1140,7 +1140,7 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensors[smoke-detector-True][sensor.smoke_sensor_battery-state] +# name: test_sensors[smoke_detector-True][sensor.smoke_sensor_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', @@ -1156,7 +1156,7 @@ 'state': '94', }) # --- -# name: test_sensors[smoke-detector-True][sensor.smoke_sensor_voltage-entry] +# name: test_sensors[smoke_detector-True][sensor.smoke_sensor_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1191,7 +1191,7 @@ 'unit_of_measurement': , }) # --- -# name: test_sensors[smoke-detector-True][sensor.smoke_sensor_voltage-state] +# name: test_sensors[smoke_detector-True][sensor.smoke_sensor_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 63d468e02d3..b0a9d2d617e 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -22,9 +22,9 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( ("node_fixture", "name"), [ - ("onoff-light", "Mock OnOff Light"), - ("onoff-light-alt-name", "Mock OnOff Light"), - ("onoff-light-no-name", "Mock Light"), + ("onoff_light", "Mock OnOff Light"), + ("onoff_light_alt_name", "Mock OnOff Light"), + ("onoff_light_no_name", "Mock Light"), ], ) async def test_device_registry_single_node_device( @@ -70,7 +70,7 @@ async def test_device_registry_single_node_device_alt( """Test additional device with different attribute values.""" await setup_integration_with_node_fixture( hass, - "on-off-plugin-unit", + "on_off_plugin_unit", matter_client, ) @@ -98,7 +98,7 @@ async def test_device_registry_bridge( """Test bridge devices are set up correctly with via_device.""" await setup_integration_with_node_fixture( hass, - "fake-bridge-two-light", + "fake_bridge_two_light", matter_client, ) @@ -156,7 +156,7 @@ async def test_node_added_subscription( ) node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"] - node = create_node_from_fixture("onoff-light") + node = create_node_from_fixture("onoff_light") entity_state = hass.states.get("light.mock_onoff_light_light") assert not entity_state @@ -175,7 +175,7 @@ async def test_device_registry_single_node_composed_device( """Test that a composed device within a standalone node only creates one HA device entry.""" await setup_integration_with_node_fixture( hass, - "air-purifier", + "air_purifier", matter_client, ) dev_reg = dr.async_get(hass) @@ -189,7 +189,7 @@ async def test_multi_endpoint_name( """Test that the entity name gets postfixed if the device has multiple primary endpoints.""" await setup_integration_with_node_fixture( hass, - "multi-endpoint-light", + "multi_endpoint_light", matter_client, ) entity_state = hass.states.get("light.inovelli_light_1") @@ -220,8 +220,8 @@ async def test_bad_node_not_crash_integration( caplog: pytest.LogCaptureFixture, ) -> None: """Test that a bad node does not crash the integration.""" - good_node = create_node_from_fixture("onoff-light") - bad_node = create_node_from_fixture("onoff-light") + good_node = create_node_from_fixture("onoff_light") + bad_node = create_node_from_fixture("onoff_light") del bad_node.endpoints[0].node matter_client.get_nodes.return_value = [good_node, bad_node] config_entry = MockConfigEntry( diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index 853da113e21..828e1797af9 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -209,7 +209,7 @@ async def test_node_diagnostics( # setup (mock) integration with a random node fixture await setup_integration_with_node_fixture( hass, - "onoff-light", + "onoff_light", matter_client, ) # get the device registry entry for the mocked node @@ -283,7 +283,7 @@ async def test_ping_node( # setup (mock) integration with a random node fixture await setup_integration_with_node_fixture( hass, - "onoff-light", + "onoff_light", matter_client, ) # get the device registry entry for the mocked node @@ -343,7 +343,7 @@ async def test_open_commissioning_window( # setup (mock) integration with a random node fixture await setup_integration_with_node_fixture( hass, - "onoff-light", + "onoff_light", matter_client, ) # get the device registry entry for the mocked node @@ -409,7 +409,7 @@ async def test_remove_matter_fabric( # setup (mock) integration with a random node fixture await setup_integration_with_node_fixture( hass, - "onoff-light", + "onoff_light", matter_client, ) # get the device registry entry for the mocked node @@ -465,7 +465,7 @@ async def test_interview_node( # setup (mock) integration with a random node fixture await setup_integration_with_node_fixture( hass, - "onoff-light", + "onoff_light", matter_client, ) # get the device registry entry for the mocked node diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index e0dd445cd72..8fe962e7697 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -40,7 +40,7 @@ async def occupancy_sensor_node_fixture( ) -> MatterNode: """Fixture for a occupancy sensor node.""" return await setup_integration_with_node_fixture( - hass, "occupancy-sensor", matter_client + hass, "occupancy_sensor", matter_client ) @@ -71,8 +71,8 @@ async def test_occupancy_sensor( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("eve-contact-sensor", "binary_sensor.eve_door_door"), - ("leak-sensor", "binary_sensor.water_leak_detector_water_leak"), + ("eve_contact_sensor", "binary_sensor.eve_door_door"), + ("leak_sensor", "binary_sensor.water_leak_detector_water_leak"), ], ) async def test_boolean_state_sensors( diff --git a/tests/components/matter/test_button.py b/tests/components/matter/test_button.py index e57a20d1533..c585671a9c1 100644 --- a/tests/components/matter/test_button.py +++ b/tests/components/matter/test_button.py @@ -17,7 +17,7 @@ async def powerplug_node_fixture( ) -> MatterNode: """Fixture for a Powerplug node.""" return await setup_integration_with_node_fixture( - hass, "eve-energy-plug", matter_client + hass, "eve_energy_plug", matter_client ) @@ -27,7 +27,7 @@ async def dishwasher_node_fixture( ) -> MatterNode: """Fixture for an dishwasher node.""" return await setup_integration_with_node_fixture( - hass, "silabs-dishwasher", matter_client + hass, "silabs_dishwasher", matter_client ) diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 4d6978edfde..4a7d0867d3e 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -31,7 +31,7 @@ async def room_airconditioner( ) -> MatterNode: """Fixture for a room air conditioner node.""" return await setup_integration_with_node_fixture( - hass, "room-airconditioner", matter_client + hass, "room_airconditioner", matter_client ) diff --git a/tests/components/matter/test_cover.py b/tests/components/matter/test_cover.py index f526205234d..a989fb584b0 100644 --- a/tests/components/matter/test_cover.py +++ b/tests/components/matter/test_cover.py @@ -27,11 +27,11 @@ from .common import ( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_lift", "cover.mock_lift_window_covering_cover"), - ("window-covering_pa-lift", "cover.longan_link_wncv_da01_cover"), - ("window-covering_tilt", "cover.mock_tilt_window_covering_cover"), - ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering_cover"), - ("window-covering_full", "cover.mock_full_window_covering_cover"), + ("window_covering_lift", "cover.mock_lift_window_covering_cover"), + ("window_covering_pa_lift", "cover.longan_link_wncv_da01_cover"), + ("window_covering_tilt", "cover.mock_tilt_window_covering_cover"), + ("window_covering_pa_tilt", "cover.mock_pa_tilt_window_covering_cover"), + ("window_covering_full", "cover.mock_full_window_covering_cover"), ], ) async def test_cover( @@ -105,9 +105,9 @@ async def test_cover( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_lift", "cover.mock_lift_window_covering_cover"), - ("window-covering_pa-lift", "cover.longan_link_wncv_da01_cover"), - ("window-covering_full", "cover.mock_full_window_covering_cover"), + ("window_covering_lift", "cover.mock_lift_window_covering_cover"), + ("window_covering_pa_lift", "cover.longan_link_wncv_da01_cover"), + ("window_covering_full", "cover.mock_full_window_covering_cover"), ], ) async def test_cover_lift( @@ -162,7 +162,7 @@ async def test_cover_lift( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_lift", "cover.mock_lift_window_covering_cover"), + ("window_covering_lift", "cover.mock_lift_window_covering_cover"), ], ) async def test_cover_lift_only( @@ -207,7 +207,7 @@ async def test_cover_lift_only( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_pa-lift", "cover.longan_link_wncv_da01_cover"), + ("window_covering_pa_lift", "cover.longan_link_wncv_da01_cover"), ], ) async def test_cover_position_aware_lift( @@ -259,9 +259,9 @@ async def test_cover_position_aware_lift( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_tilt", "cover.mock_tilt_window_covering_cover"), - ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering_cover"), - ("window-covering_full", "cover.mock_full_window_covering_cover"), + ("window_covering_tilt", "cover.mock_tilt_window_covering_cover"), + ("window_covering_pa_tilt", "cover.mock_pa_tilt_window_covering_cover"), + ("window_covering_full", "cover.mock_full_window_covering_cover"), ], ) async def test_cover_tilt( @@ -317,7 +317,7 @@ async def test_cover_tilt( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_tilt", "cover.mock_tilt_window_covering_cover"), + ("window_covering_tilt", "cover.mock_tilt_window_covering_cover"), ], ) async def test_cover_tilt_only( @@ -360,7 +360,7 @@ async def test_cover_tilt_only( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering_cover"), + ("window_covering_pa_tilt", "cover.mock_pa_tilt_window_covering_cover"), ], ) async def test_cover_position_aware_tilt( @@ -407,7 +407,7 @@ async def test_cover_full_features( window_covering = await setup_integration_with_node_fixture( hass, - "window-covering_full", + "window_covering_full", matter_client, ) entity_id = "cover.mock_full_window_covering_cover" diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index 183867642f5..61effe71938 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -18,7 +18,7 @@ async def switch_node_fixture( ) -> MatterNode: """Fixture for a GenericSwitch node.""" return await setup_integration_with_node_fixture( - hass, "generic-switch", matter_client + hass, "generic_switch", matter_client ) @@ -28,7 +28,7 @@ async def multi_switch_node_fixture( ) -> MatterNode: """Fixture for a GenericSwitch node with multiple buttons.""" return await setup_integration_with_node_fixture( - hass, "generic-switch-multi", matter_client + hass, "generic_switch_multi", matter_client ) diff --git a/tests/components/matter/test_fan.py b/tests/components/matter/test_fan.py index 690209b1165..6cd504d4386 100644 --- a/tests/components/matter/test_fan.py +++ b/tests/components/matter/test_fan.py @@ -42,7 +42,7 @@ async def air_purifier_fixture( ) -> MatterNode: """Fixture for a Air Purifier node (containing Fan cluster).""" return await setup_integration_with_node_fixture( - hass, "air-purifier", matter_client + hass, "air_purifier", matter_client ) diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index 33df9a7ec67..5492ff29535 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -54,7 +54,7 @@ async def test_entry_setup_unload( matter_client: MagicMock, ) -> None: """Test the integration set up and unload.""" - node = create_node_from_fixture("onoff-light") + node = create_node_from_fixture("onoff_light") matter_client.get_nodes.return_value = [node] matter_client.get_node.return_value = node entry = MockConfigEntry(domain="matter", data={"url": "ws://localhost:5580/ws"}) diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 14a3a6ca97e..1fd99c6e4b9 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -21,18 +21,18 @@ from .common import ( ("fixture", "entity_id", "supported_color_modes"), [ ( - "extended-color-light", + "extended_color_light", "light.mock_extended_color_light_light", ["color_temp", "hs", "xy"], ), ( - "color-temperature-light", + "color_temperature_light", "light.mock_color_temperature_light_light", ["color_temp"], ), - ("dimmable-light", "light.mock_dimmable_light_light", ["brightness"]), - ("onoff-light", "light.mock_onoff_light_light", ["onoff"]), - ("onoff-light-with-levelcontrol-present", "light.d215s_light", ["onoff"]), + ("dimmable_light", "light.mock_dimmable_light_light", ["brightness"]), + ("onoff_light", "light.mock_onoff_light_light", ["onoff"]), + ("onoff_light_with_levelcontrol_present", "light.d215s_light", ["onoff"]), ], ) async def test_light_turn_on_off( @@ -113,10 +113,10 @@ async def test_light_turn_on_off( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("extended-color-light", "light.mock_extended_color_light_light"), - ("color-temperature-light", "light.mock_color_temperature_light_light"), - ("dimmable-light", "light.mock_dimmable_light_light"), - ("dimmable-plugin-unit", "light.dimmable_plugin_unit_light"), + ("extended_color_light", "light.mock_extended_color_light_light"), + ("color_temperature_light", "light.mock_color_temperature_light_light"), + ("dimmable_light", "light.mock_dimmable_light_light"), + ("dimmable_plugin_unit", "light.dimmable_plugin_unit_light"), ], ) async def test_dimmable_light( @@ -189,8 +189,8 @@ async def test_dimmable_light( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("extended-color-light", "light.mock_extended_color_light_light"), - ("color-temperature-light", "light.mock_color_temperature_light_light"), + ("extended_color_light", "light.mock_extended_color_light_light"), + ("color_temperature_light", "light.mock_color_temperature_light_light"), ], ) async def test_color_temperature_light( @@ -287,7 +287,7 @@ async def test_color_temperature_light( @pytest.mark.parametrize( ("fixture", "entity_id"), [ - ("extended-color-light", "light.mock_extended_color_light_light"), + ("extended_color_light", "light.mock_extended_color_light_light"), ], ) async def test_extended_color_light( diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index 257875d6715..047b0aa4481 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -22,7 +22,7 @@ async def dimmable_light_node_fixture( ) -> MatterNode: """Fixture for a flow sensor node.""" return await setup_integration_with_node_fixture( - hass, "dimmable-light", matter_client + hass, "dimmable_light", matter_client ) @@ -32,7 +32,7 @@ async def eve_weather_sensor_node_fixture( ) -> MatterNode: """Fixture for a Eve Weather sensor node.""" return await setup_integration_with_node_fixture( - hass, "eve-weather-sensor", matter_client + hass, "eve_weather_sensor", matter_client ) diff --git a/tests/components/matter/test_select.py b/tests/components/matter/test_select.py index 27ce6d32c22..20b8d47db2d 100644 --- a/tests/components/matter/test_select.py +++ b/tests/components/matter/test_select.py @@ -21,7 +21,7 @@ async def dimmable_light_node_fixture( ) -> MatterNode: """Fixture for a dimmable light node.""" return await setup_integration_with_node_fixture( - hass, "dimmable-light", matter_client + hass, "dimmable_light", matter_client ) diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index b914e2160b5..cca49437599 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -23,7 +23,7 @@ async def flow_sensor_node_fixture( hass: HomeAssistant, matter_client: MagicMock ) -> MatterNode: """Fixture for a flow sensor node.""" - return await setup_integration_with_node_fixture(hass, "flow-sensor", matter_client) + return await setup_integration_with_node_fixture(hass, "flow_sensor", matter_client) @pytest.fixture(name="humidity_sensor_node") @@ -32,7 +32,7 @@ async def humidity_sensor_node_fixture( ) -> MatterNode: """Fixture for a humidity sensor node.""" return await setup_integration_with_node_fixture( - hass, "humidity-sensor", matter_client + hass, "humidity_sensor", matter_client ) @@ -42,7 +42,7 @@ async def light_sensor_node_fixture( ) -> MatterNode: """Fixture for a light sensor node.""" return await setup_integration_with_node_fixture( - hass, "light-sensor", matter_client + hass, "light_sensor", matter_client ) @@ -52,7 +52,7 @@ async def pressure_sensor_node_fixture( ) -> MatterNode: """Fixture for a pressure sensor node.""" return await setup_integration_with_node_fixture( - hass, "pressure-sensor", matter_client + hass, "pressure_sensor", matter_client ) @@ -62,7 +62,7 @@ async def temperature_sensor_node_fixture( ) -> MatterNode: """Fixture for a temperature sensor node.""" return await setup_integration_with_node_fixture( - hass, "temperature-sensor", matter_client + hass, "temperature_sensor", matter_client ) @@ -72,7 +72,7 @@ async def eve_energy_plug_node_fixture( ) -> MatterNode: """Fixture for a Eve Energy Plug node.""" return await setup_integration_with_node_fixture( - hass, "eve-energy-plug", matter_client + hass, "eve_energy_plug", matter_client ) @@ -81,7 +81,7 @@ async def eve_thermo_node_fixture( hass: HomeAssistant, matter_client: MagicMock ) -> MatterNode: """Fixture for a Eve Thermo node.""" - return await setup_integration_with_node_fixture(hass, "eve-thermo", matter_client) + return await setup_integration_with_node_fixture(hass, "eve_thermo", matter_client) @pytest.fixture(name="eve_energy_plug_patched_node") @@ -90,7 +90,7 @@ async def eve_energy_plug_patched_node_fixture( ) -> MatterNode: """Fixture for a Eve Energy Plug node (patched to include Matter 1.3 energy clusters).""" return await setup_integration_with_node_fixture( - hass, "eve-energy-plug-patched", matter_client + hass, "eve_energy_plug_patched", matter_client ) @@ -100,7 +100,7 @@ async def eve_weather_sensor_node_fixture( ) -> MatterNode: """Fixture for a Eve Weather sensor node.""" return await setup_integration_with_node_fixture( - hass, "eve-weather-sensor", matter_client + hass, "eve_weather_sensor", matter_client ) @@ -110,7 +110,7 @@ async def air_quality_sensor_node_fixture( ) -> MatterNode: """Fixture for an air quality sensor (LightFi AQ1) node.""" return await setup_integration_with_node_fixture( - hass, "air-quality-sensor", matter_client + hass, "air_quality_sensor", matter_client ) @@ -120,7 +120,7 @@ async def air_purifier_node_fixture( ) -> MatterNode: """Fixture for an air purifier node.""" return await setup_integration_with_node_fixture( - hass, "air-purifier", matter_client + hass, "air_purifier", matter_client ) @@ -130,7 +130,7 @@ async def dishwasher_node_fixture( ) -> MatterNode: """Fixture for an dishwasher node.""" return await setup_integration_with_node_fixture( - hass, "silabs-dishwasher", matter_client + hass, "silabs_dishwasher", matter_client ) diff --git a/tests/components/matter/test_switch.py b/tests/components/matter/test_switch.py index 0327e9ea5fe..063b7a7472d 100644 --- a/tests/components/matter/test_switch.py +++ b/tests/components/matter/test_switch.py @@ -21,7 +21,7 @@ async def powerplug_node_fixture( ) -> MatterNode: """Fixture for a Powerplug node.""" return await setup_integration_with_node_fixture( - hass, "on-off-plugin-unit", matter_client + hass, "on_off_plugin_unit", matter_client ) @@ -30,7 +30,7 @@ async def switch_unit_fixture( hass: HomeAssistant, matter_client: MagicMock ) -> MatterNode: """Fixture for a Switch Unit node.""" - return await setup_integration_with_node_fixture(hass, "switch-unit", matter_client) + return await setup_integration_with_node_fixture(hass, "switch_unit", matter_client) # This tests needs to be adjusted to remove lingering tasks @@ -123,7 +123,7 @@ async def test_power_switch( ) -> None: """Test if a Power switch entity is created for a device that supports that.""" await setup_integration_with_node_fixture( - hass, "room-airconditioner", matter_client + hass, "room_airconditioner", matter_client ) state = hass.states.get("switch.room_airconditioner_power") assert state diff --git a/tests/components/matter/test_update.py b/tests/components/matter/test_update.py index 19c57b0f3c7..3de85be2130 100644 --- a/tests/components/matter/test_update.py +++ b/tests/components/matter/test_update.py @@ -84,7 +84,7 @@ async def updateable_node_fixture( ) -> MatterNode: """Fixture for a flow sensor node.""" return await setup_integration_with_node_fixture( - hass, "dimmable-light", matter_client + hass, "dimmable_light", matter_client ) @@ -392,7 +392,7 @@ async def test_update_state_restore( ), ), ) - await setup_integration_with_node_fixture(hass, "dimmable-light", matter_client) + await setup_integration_with_node_fixture(hass, "dimmable_light", matter_client) assert check_node_update.call_count == 0 From 33d83e43deed9a0ad772673da356ec0a4ad76bf0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Sep 2024 15:33:03 +0200 Subject: [PATCH 1174/1309] Update trigger validation message (#126749) --- homeassistant/helpers/trigger.py | 2 +- tests/components/websocket_api/test_commands.py | 2 +- tests/helpers/test_trigger.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index a0abbaa390c..67e9010df79 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -225,7 +225,7 @@ async def _async_get_trigger_platform( try: integration = await async_get_integration(hass, platform) except IntegrationNotFound: - raise vol.Invalid(f"Invalid platform '{platform}' specified") from None + raise vol.Invalid(f"Invalid trigger '{platform}' specified") from None try: return await integration.async_get_platform("trigger") except ImportError: diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 9c41bb8ddd2..c1a043f915b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2601,7 +2601,7 @@ async def test_validate_config_works( ( "triggers", {"platform": "non_existing", "event_type": "hello"}, - "Invalid platform 'non_existing' specified", + "Invalid trigger 'non_existing' specified", ), # Raises vol.Invalid ( diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 4fde2d0ee0a..77f48be170b 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -20,7 +20,7 @@ async def test_bad_trigger_platform(hass: HomeAssistant) -> None: """Test bad trigger platform.""" with pytest.raises(vol.Invalid) as ex: await async_validate_trigger_config(hass, [{"platform": "not_a_platform"}]) - assert "Invalid platform 'not_a_platform' specified" in str(ex) + assert "Invalid trigger 'not_a_platform' specified" in str(ex) async def test_trigger_subtype(hass: HomeAssistant) -> None: From 866ffcf639d3be0f4e6a35884b41fa3e6bff83b2 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:33:23 +0200 Subject: [PATCH 1175/1309] Use bold to markup UI strings (#126748) * Use bold to markup UI strings * Use bold to markup UI strings --- homeassistant/components/automation/strings.json | 2 +- homeassistant/components/awair/strings.json | 2 +- homeassistant/components/calendar/strings.json | 2 +- homeassistant/components/deconz/strings.json | 2 +- homeassistant/components/dlna_dmr/strings.json | 2 +- homeassistant/components/ecobee/strings.json | 2 +- homeassistant/components/econet/strings.json | 2 +- homeassistant/components/ecovacs/strings.json | 2 +- homeassistant/components/ecowitt/strings.json | 2 +- homeassistant/components/elkm1/strings.json | 2 +- homeassistant/components/freebox/strings.json | 2 +- homeassistant/components/hassio/strings.json | 4 ++-- homeassistant/components/homeassistant/strings.json | 2 +- homeassistant/components/homematicip_cloud/strings.json | 2 +- homeassistant/components/kitchen_sink/strings.json | 6 +++--- homeassistant/components/mqtt/strings.json | 6 +++--- homeassistant/components/nanoleaf/strings.json | 2 +- homeassistant/components/nexia/strings.json | 2 +- homeassistant/components/octoprint/strings.json | 2 +- homeassistant/components/onvif/strings.json | 2 +- homeassistant/components/ps4/strings.json | 6 +++--- homeassistant/components/roon/strings.json | 2 +- homeassistant/components/systemmonitor/strings.json | 2 +- homeassistant/components/tellduslive/strings.json | 2 +- homeassistant/components/tessie/strings.json | 6 +++--- homeassistant/components/weather/strings.json | 2 +- homeassistant/components/weatherflow/strings.json | 2 +- homeassistant/components/webostv/strings.json | 6 +++--- homeassistant/components/xiaomi_miio/strings.json | 2 +- 29 files changed, 40 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index d8a3fa14f40..88410658afc 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -42,7 +42,7 @@ "step": { "confirm": { "title": "[%key:component::automation::issues::service_not_found::title%]", - "description": "The automation \"{name}\" (`{entity_id}`) has an unknown action: `{service}`.\n\nThis error prevents the automation from running correctly. Maybe this action is no longer available, or perhaps a typo caused it.\n\nTo fix this error, [edit the automation]({edit}) and remove this action.\n\nClick on SUBMIT below to confirm you have fixed this automation." + "description": "The automation \"{name}\" (`{entity_id}`) has an unknown action: `{service}`.\n\nThis error prevents the automation from running correctly. Maybe this action is no longer available, or perhaps a typo caused it.\n\nTo fix this error, [edit the automation]({edit}) and remove this action.\n\nSelect **Submit** below to confirm you have fixed this automation." } } } diff --git a/homeassistant/components/awair/strings.json b/homeassistant/components/awair/strings.json index 731cd5db8dd..071893ce7a2 100644 --- a/homeassistant/components/awair/strings.json +++ b/homeassistant/components/awair/strings.json @@ -9,7 +9,7 @@ } }, "local": { - "description": "Follow [these instructions]({url}) on how to enable the Awair Local API.\n\nClick submit when done." + "description": "Follow [these instructions]({url}) on how to enable the Awair Local API.\n\nSelect **Submit** when done." }, "local_pick": { "data": { diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 83a7d01d8ae..1b6037781df 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -116,7 +116,7 @@ "step": { "confirm": { "title": "[%key:component::calendar::issues::deprecated_service_calendar_list_events::title%]", - "description": "Use `calendar.get_events` instead which supports multiple entities.\n\nPlease replace this action and adjust your automations and scripts and select **submit** to close this issue." + "description": "Use `calendar.get_events` instead which supports multiple entities.\n\nPlease replace this action and adjust your automations and scripts and select **Submit** to close this issue." } } } diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index b894bdf5f84..52059aa8785 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -18,7 +18,7 @@ }, "link": { "title": "Link with deCONZ", - "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Select \"Authenticate app\" button" + "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings > Gateway > Advanced\n2. Select the **Authenticate app** button" }, "hassio_confirm": { "title": "deCONZ Zigbee gateway via Home Assistant add-on", diff --git a/homeassistant/components/dlna_dmr/strings.json b/homeassistant/components/dlna_dmr/strings.json index a2b71785535..be4336ea8a5 100644 --- a/homeassistant/components/dlna_dmr/strings.json +++ b/homeassistant/components/dlna_dmr/strings.json @@ -17,7 +17,7 @@ } }, "import_turn_on": { - "description": "Please turn on the device and select submit to continue migration" + "description": "Please turn on the device and select **Submit** to continue migration" }, "confirm": { "description": "[%key:common::config_flow::description::confirm_setup%]" diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index a7041e683e4..2af6e5a90f9 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -8,7 +8,7 @@ } }, "authorize": { - "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, select Submit." + "description": "Please authorize this app at https://www.ecobee.com/consumerportal/index.html with PIN code:\n\n{pin}\n\nThen, select **Submit**." } }, "error": { diff --git a/homeassistant/components/econet/strings.json b/homeassistant/components/econet/strings.json index b473bf2f466..212ff83007b 100644 --- a/homeassistant/components/econet/strings.json +++ b/homeassistant/components/econet/strings.json @@ -25,7 +25,7 @@ "fix_flow": { "step": { "confirm": { - "description": "The EcoNet `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, select submit to fix this issue.", + "description": "The EcoNet `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, select **Submit** to fix this issue.", "title": "[%key:component::econet::issues::migrate_aux_heat::title%]" } } diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 8222cabed07..c9de461ad5b 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -31,7 +31,7 @@ "mode": "[%key:common::config_flow::data::mode%]" }, "data_description": { - "mode": "Select the mode you want to use to connect to Ecovacs. If you are unsure, select 'Cloud'.\n\nSelect 'Self-hosted' only if you have a working self-hosted instance." + "mode": "Select the mode you want to use to connect to Ecovacs. If you are unsure, select **Cloud**.\n\nSelect **Self-hosted** only if you have a working self-hosted instance." } } } diff --git a/homeassistant/components/ecowitt/strings.json b/homeassistant/components/ecowitt/strings.json index cca51c1129e..95fcc3c3bb0 100644 --- a/homeassistant/components/ecowitt/strings.json +++ b/homeassistant/components/ecowitt/strings.json @@ -6,7 +6,7 @@ } }, "create_entry": { - "default": "To finish setting up the integration, use the Ecowitt App (on your phone) or access the Ecowitt WebUI in a browser at the station IP address.\n\nPick your station -> Menu Others -> DIY Upload Servers. Hit next and select 'Customized'\n\n- Server IP: `{server}`\n- Path: `{path}`\n- Port: `{port}`\n\nClick on 'Save'." + "default": "To finish setting up the integration, use the Ecowitt App (on your phone) or access the Ecowitt WebUI in a browser at the station IP address.\n\nPick your station -> Menu Others -> DIY Upload Servers. Hit next and select 'Customized'\n\n- Server IP: `{server}`\n- Path: `{path}`\n- Port: `{port}`\n\nSelect **Save**." } } } diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index 8ed34138f66..6318231c281 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -196,7 +196,7 @@ "fix_flow": { "step": { "confirm": { - "description": "The Elk-M1 `set_aux_heat` action has been migrated. A new emergency heat switch entity is available for each thermostat.\n\nUpdate any automations to use the new emergency heat switch entity. When this is done, select submit to fix this issue.", + "description": "The Elk-M1 `set_aux_heat` action has been migrated. A new emergency heat switch entity is available for each thermostat.\n\nUpdate any automations to use the new emergency heat switch entity. When this is done, select **Submit** to fix this issue.", "title": "[%key:component::elkm1::issues::migrate_aux_heat::title%]" } } diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index cc7ca5b5aaa..0d91daaa290 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/strings.json @@ -12,7 +12,7 @@ }, "link": { "title": "Link Freebox router", - "description": "Select \"Submit\", then touch the right arrow on the router to register Freebox with Home Assistant.\n\n![Location of button on the router](/static/images/config_freebox.png)" + "description": "Select **Submit**, then touch the right arrow on the router to register Freebox with Home Assistant.\n\n![Location of button on the router](/static/images/config_freebox.png)" } }, "error": { diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 7c3aa70b559..c304373b27b 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -26,7 +26,7 @@ "fix_flow": { "step": { "addon_execute_remove": { - "description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nClicking submit will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." + "description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nSelecting **Submit** will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." } }, "abort": { @@ -76,7 +76,7 @@ } }, "system_adopt_data_disk": { - "description": "Select submit to make `{reference}` the active data disk. The one and only.\n\nYou won't have access anymore to the current Home Assistant data (will be marked as inactive data disk). After reboot, your system will be in the state of the Home Assistant data on `{reference}`." + "description": "Select **Submit** to make `{reference}` the active data disk. The one and only.\n\nYou won't have access anymore to the current Home Assistant data (will be marked as inactive data disk). After reboot, your system will be in the state of the Home Assistant data on `{reference}`." } }, "abort": { diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index aef751b71a6..f0789b17ab2 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -48,7 +48,7 @@ "step": { "confirm": { "title": "[%key:component::homeassistant::issues::storage_corruption::title%]", - "description": "The `{storage_key}` storage could not be parsed and has been renamed to `{corrupt_path}` to allow Home Assistant to continue.\n\nA default `{storage_key}` may have been created automatically.\n\nIf you made manual edits to the storage file, fix any syntax errors in `{corrupt_path}`, restore the file to the original path `{original_path}`, and restart Home Assistant. Otherwise, restore the system from a backup.\n\nClick SUBMIT below to confirm you have repaired the file or restored from a backup.\n\nThe exact error was: {error}" + "description": "The `{storage_key}` storage could not be parsed and has been renamed to `{corrupt_path}` to allow Home Assistant to continue.\n\nA default `{storage_key}` may have been created automatically.\n\nIf you made manual edits to the storage file, fix any syntax errors in `{corrupt_path}`, restore the file to the original path `{original_path}`, and restart Home Assistant. Otherwise, restore the system from a backup.\n\nSelect **Submit** below to confirm you have repaired the file or restored from a backup.\n\nThe exact error was: {error}" } } } diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index a7c795c81f6..ac7b184e513 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -11,7 +11,7 @@ }, "link": { "title": "Link Access point", - "description": "Press the blue button on the access point and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" + "description": "Press the blue button on the access point and the **Submit** button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" } }, "error": { diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json index d1d6fc17676..74cddb9f2c0 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "reauth_confirm": { - "description": "Select SUBMIT to reauthenticate" + "description": "Select **Submit** to reauthenticate" } } }, @@ -38,7 +38,7 @@ "step": { "confirm": { "title": "The power supply needs to be replaced", - "description": "Select SUBMIT to confirm the power supply has been replaced" + "description": "Select **Submit** to confirm the power supply has been replaced" } } } @@ -49,7 +49,7 @@ "step": { "confirm": { "title": "Blinker fluid needs to be refilled", - "description": "Select SUBMIT when blinker fluid has been refilled" + "description": "Select **Submit** when blinker fluid has been refilled" } } } diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index b6cff750fd1..8ab31e37857 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -56,15 +56,15 @@ "port": "The port your MQTT broker listens to. For example 1883.", "username": "The username to login to your MQTT broker.", "password": "The password to login to your MQTT broker.", - "advanced_options": "Enable and select `next` to set advanced options.", + "advanced_options": "Enable and select **Next** to set advanced options.", "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", "client_cert": "The client certificate to authenticate against your MQTT broker.", "client_key": "The private key file that belongs to your client certificate.", "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", - "set_ca_cert": "Select `Auto` for automatic CA validation, or `Custom` and select `next` to set a custom CA certificate, to allow validating your MQTT brokers certificate.", - "set_client_cert": "Enable and select `next` to set a client certifificate and private key to authenticate against your MQTT broker.", + "set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT brokers certificate.", + "set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.", "transport": "The transport to be used for the connection to your MQTT broker.", "ws_headers": "The WebSocket headers to pass through the WebSocket based connection to your MQTT broker.", "ws_path": "The WebSocket path to be used for the connection to your MQTT broker." diff --git a/homeassistant/components/nanoleaf/strings.json b/homeassistant/components/nanoleaf/strings.json index 50eec80d8bc..ecc511d658f 100644 --- a/homeassistant/components/nanoleaf/strings.json +++ b/homeassistant/components/nanoleaf/strings.json @@ -12,7 +12,7 @@ }, "link": { "title": "Link Nanoleaf", - "description": "Press and hold the power button on your Nanoleaf for 5 seconds until the button LEDs start flashing, then select **SUBMIT** within 30 seconds." + "description": "Press and hold the power button on your Nanoleaf for 5 seconds until the button LEDs start flashing, then select **Submit** within 30 seconds." } }, "error": { diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index 508c04b7e32..aec145b8806 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -103,7 +103,7 @@ "fix_flow": { "step": { "confirm": { - "description": "The Nexia `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new Emergency heat switch entity. When this is done, select submit to fix this issue.", + "description": "The Nexia `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat.\n\nUpdate any automations to use the new Emergency heat switch entity. When this is done, select **Submit** to fix this issue.", "title": "[%key:component::nexia::issues::migrate_aux_heat::title%]" } } diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 0c3f24fe49e..5687ab36033 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -33,7 +33,7 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "progress": { - "get_api_key": "Open the OctoPrint UI and select 'Allow' on the Access Request for 'Home Assistant'." + "get_api_key": "Open the OctoPrint UI and select **Allow** on the Access Request for **Home Assistant**." } }, "exceptions": { diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index c3f0b89df3b..0afb5e59e8e 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -20,7 +20,7 @@ "auto": "Search automatically" }, "title": "ONVIF device setup", - "description": "By clicking submit, we will search your network for ONVIF devices that support Profile S.\n\nSome manufacturers have started to disable ONVIF by default. Please ensure ONVIF is enabled in your camera's configuration." + "description": "By selecting **Submit**, we will search your network for ONVIF devices that support Profile S.\n\nSome manufacturers have started to disable ONVIF by default. Please ensure ONVIF is enabled in your camera's configuration." }, "device": { "data": { diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index 3c06d7b35fb..6b1d4cd690b 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "creds": { - "description": "Credentials needed. Select 'Submit' and then in the PS4 2nd Screen App, refresh devices and select the 'Home-Assistant' device to continue." + "description": "Credentials needed. Select **Submit** and then in the PS4 2nd Screen App, refresh devices and select the **Home-Assistant** device to continue." }, "mode": { "data": { @@ -21,12 +21,12 @@ "ip_address": "[%key:common::config_flow::data::ip%]" }, "data_description": { - "code": "Navigate to 'Settings' on your PlayStation 4 console. Then navigate to 'Mobile App Connection Settings' and select 'Add Device' to get the pin." + "code": "On your PlayStation 4 console, go to **Settings**. Then, go to **Mobile App Connection Settings** and select **Add Device** to get the pin." } } }, "error": { - "credential_timeout": "Credential service timed out. Select submit to restart.", + "credential_timeout": "Credential service timed out. Select **Submit** to restart.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.", "no_ipaddress": "Enter the IP address of the PlayStation 4 you would like to configure." diff --git a/homeassistant/components/roon/strings.json b/homeassistant/components/roon/strings.json index 853bcc6c585..85cb53b9010 100644 --- a/homeassistant/components/roon/strings.json +++ b/homeassistant/components/roon/strings.json @@ -11,7 +11,7 @@ }, "link": { "title": "Authorize HomeAssistant in Roon", - "description": "You must authorize Home Assistant in Roon. After you click submit, go to the Roon Core application, open Settings and enable HomeAssistant on the Extensions tab." + "description": "You must authorize Home Assistant in Roon. After you select **Submit**, go to the Roon Core application, open **Settings** and enable HomeAssistant on the **Extensions** tab." } }, "error": { diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index d7cc9491d8d..e595e628853 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "Select submit for initial setup. On the created config entry, select configure to add sensors for selected processes" + "description": "Select **Submit** for initial setup. On the created config entry, select configure to add sensors for selected processes" } } }, diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json index 5554e6e14e7..e363aced667 100644 --- a/homeassistant/components/tellduslive/strings.json +++ b/homeassistant/components/tellduslive/strings.json @@ -11,7 +11,7 @@ }, "step": { "auth": { - "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (select **Yes**).\n 4. Come back here and select **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})", + "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (select **Yes**).\n 4. Come back here and select **Submit**.\n\n [Link TelldusLive account]({auth_url})", "title": "Authenticate against TelldusLive" }, "user": { diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json index aaa9dad4e64..52c03c8700b 100644 --- a/homeassistant/components/tessie/strings.json +++ b/homeassistant/components/tessie/strings.json @@ -539,7 +539,7 @@ "step": { "confirm": { "title": "[%key:component::tessie::issues::deprecated_speed_limit_entity::title%]", - "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\nHome Assistant detected that entity `{entity}` is being used in `{info}`\n\nYou should remove the speed limit lock entity from `{info}` then select submit to fix this issue." + "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\nHome Assistant detected that entity `{entity}` is being used in `{info}`\n\nYou should remove the speed limit lock entity from `{info}` then select **Submit** to fix this issue." } } } @@ -550,7 +550,7 @@ "step": { "confirm": { "title": "[%key:component::tessie::issues::deprecated_speed_limit_locked::title%]", - "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\n\nPlease remove this entity from any automation or script, disable the entity then select submit to fix this issue." + "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\n\nPlease remove this entity from any automation or script, disable the entity then select **Submit** to fix this issue." } } } @@ -561,7 +561,7 @@ "step": { "confirm": { "title": "[%key:component::tessie::issues::deprecated_speed_limit_unlocked::title%]", - "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\n\nPlease remove this entity from any automation or script, disable the entity then select submit to fix this issue." + "description": "The Tessie integration's speed limit lock entity has been deprecated and will be remove in 2024.11.0.\n\nPlease remove this entity from any automation or script, disable the entity then select **Submit** to fix this issue." } } } diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 77c9cce864b..521d8ab9afe 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -116,7 +116,7 @@ "step": { "confirm": { "title": "[%key:component::weather::issues::deprecated_service_weather_get_forecast::title%]", - "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **submit** to close this issue." + "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **Submit** to close this issue." } } } diff --git a/homeassistant/components/weatherflow/strings.json b/homeassistant/components/weatherflow/strings.json index 7594b6a2cc6..8fb3a3cdf31 100644 --- a/homeassistant/components/weatherflow/strings.json +++ b/homeassistant/components/weatherflow/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "Unable to discover Tempest WeatherFlow devices. Select submit to try again.", + "description": "Unable to discover Tempest WeatherFlow devices. Select **Submit** to try again.", "data": { "host": "[%key:common::config_flow::data::host%]" }, diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index 9ca5066fd2d..3ceab5f50a3 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -3,7 +3,7 @@ "flow_title": "LG webOS Smart TV", "step": { "user": { - "description": "Turn on TV, fill the following fields and select submit", + "description": "Turn on TV, fill the following fields and select **Submit**", "data": { "host": "[%key:common::config_flow::data::host%]", "name": "[%key:common::config_flow::data::name%]" @@ -14,7 +14,7 @@ }, "pairing": { "title": "webOS TV Pairing", - "description": "Select submit and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" + "description": "Select **Submit** and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" }, "reauth_confirm": { "title": "[%key:component::webostv::config::step::pairing::title%]", @@ -22,7 +22,7 @@ } }, "error": { - "cannot_connect": "Failed to connect, please turn on your TV or check ip address" + "cannot_connect": "Failed to connect, please turn on your TV or check the IP address" }, "abort": { "error_pairing": "Connected to LG webOS TV but not paired", diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 8280c85f914..31fe547b162 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -439,7 +439,7 @@ }, "remote_learn_command": { "name": "Remote learn command", - "description": "Learns an IR command, select \"Perform action\", point the remote at the IR device, and the learned command will be shown as a notification in Overview.", + "description": "Learns an IR command, select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.", "fields": { "slot": { "name": "Slot", From 2699eb62bdd2d7025796ef99032908137ff019bf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Sep 2024 15:53:58 +0200 Subject: [PATCH 1176/1309] Rename DOMAIN_DATA HassKey constants to DATA_COMPONENT (#126746) * Rename DOMAIN_DATA HassKey constant to DATA * DATA -> DATA_COMPONENT --- .../components/air_quality/__init__.py | 8 ++++---- .../alarm_control_panel/__init__.py | 8 ++++---- .../components/assist_satellite/__init__.py | 8 ++++---- .../components/assist_satellite/const.py | 2 +- .../assist_satellite/websocket_api.py | 8 ++++---- .../components/automation/__init__.py | 20 +++++++++---------- .../components/binary_sensor/__init__.py | 8 ++++---- homeassistant/components/button/__init__.py | 8 ++++---- homeassistant/components/calendar/__init__.py | 14 ++++++------- homeassistant/components/calendar/const.py | 2 +- homeassistant/components/calendar/trigger.py | 4 ++-- homeassistant/components/camera/__init__.py | 10 +++++----- homeassistant/components/camera/const.py | 2 +- .../components/camera/media_source.py | 6 +++--- homeassistant/components/climate/__init__.py | 8 ++++---- .../components/conversation/__init__.py | 10 +++++----- .../components/conversation/agent_manager.py | 4 ++-- .../components/conversation/const.py | 2 +- homeassistant/components/conversation/http.py | 4 ++-- homeassistant/components/cover/__init__.py | 8 ++++---- homeassistant/components/date/__init__.py | 8 ++++---- homeassistant/components/datetime/__init__.py | 8 ++++---- .../components/device_tracker/config_entry.py | 6 +++--- homeassistant/components/event/__init__.py | 8 ++++---- homeassistant/components/fan/__init__.py | 8 ++++---- .../components/geo_location/__init__.py | 8 ++++---- homeassistant/components/group/__init__.py | 4 ++-- homeassistant/components/group/const.py | 2 +- homeassistant/components/group/entity.py | 6 +++--- .../components/humidifier/__init__.py | 8 ++++---- homeassistant/components/image/__init__.py | 8 ++++---- homeassistant/components/image/const.py | 2 +- .../components/image/media_source.py | 6 +++--- .../components/lawn_mower/__init__.py | 8 ++++---- homeassistant/components/light/__init__.py | 8 ++++---- homeassistant/components/lock/__init__.py | 8 ++++---- .../components/media_player/__init__.py | 10 +++++----- homeassistant/components/notify/__init__.py | 8 ++++---- homeassistant/components/number/__init__.py | 8 ++++---- homeassistant/components/remote/__init__.py | 8 ++++---- homeassistant/components/scene/__init__.py | 8 ++++---- homeassistant/components/select/__init__.py | 8 ++++---- homeassistant/components/sensor/__init__.py | 8 ++++---- homeassistant/components/siren/__init__.py | 8 ++++---- homeassistant/components/stt/__init__.py | 16 +++++++-------- homeassistant/components/stt/const.py | 2 +- homeassistant/components/switch/__init__.py | 8 ++++---- homeassistant/components/text/__init__.py | 8 ++++---- homeassistant/components/time/__init__.py | 8 ++++---- homeassistant/components/todo/__init__.py | 14 ++++++------- homeassistant/components/todo/const.py | 2 +- homeassistant/components/todo/intent.py | 4 ++-- homeassistant/components/tts/__init__.py | 18 ++++++++--------- homeassistant/components/tts/const.py | 2 +- homeassistant/components/tts/helper.py | 4 ++-- homeassistant/components/tts/media_source.py | 4 ++-- homeassistant/components/update/__init__.py | 10 +++++----- homeassistant/components/vacuum/__init__.py | 8 ++++---- homeassistant/components/valve/__init__.py | 8 ++++---- .../components/wake_word/__init__.py | 12 +++++------ .../components/water_heater/__init__.py | 8 ++++---- homeassistant/components/weather/__init__.py | 8 ++++---- homeassistant/components/weather/const.py | 2 +- .../components/weather/websocket_api.py | 4 ++-- 64 files changed, 233 insertions(+), 233 deletions(-) diff --git a/homeassistant/components/air_quality/__init__.py b/homeassistant/components/air_quality/__init__.py index 605a34a69e0..1e2a0525f29 100644 --- a/homeassistant/components/air_quality/__init__.py +++ b/homeassistant/components/air_quality/__init__.py @@ -19,7 +19,7 @@ from .const import DOMAIN _LOGGER: Final = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[AirQualityEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[AirQualityEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -56,7 +56,7 @@ PROP_TO_ATTR: Final[dict[str, str]] = { async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the air quality component.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[AirQualityEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[AirQualityEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -65,12 +65,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class AirQualityEntity(Entity): diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 91d3a83df8e..5cc13c86729 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -53,7 +53,7 @@ from .const import ( # noqa: F401 _LOGGER: Final = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[AlarmControlPanelEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[AlarmControlPanelEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA: Final = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE: Final = cv.PLATFORM_SCHEMA_BASE @@ -71,7 +71,7 @@ ALARM_SERVICE_SCHEMA: Final = make_entity_service_schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for sensors.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[AlarmControlPanelEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[AlarmControlPanelEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -124,12 +124,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class AlarmControlPanelEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 6932fa3180c..dd940e8cdbe 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -13,8 +13,8 @@ from homeassistant.helpers.typing import ConfigType from .connection_test import ConnectionTestView from .const import ( CONNECTION_TEST_DATA, + DATA_COMPONENT, DOMAIN, - DOMAIN_DATA, AssistSatelliteEntityFeature, ) from .entity import ( @@ -44,7 +44,7 @@ PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - component = hass.data[DOMAIN_DATA] = EntityComponent[AssistSatelliteEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[AssistSatelliteEntity]( _LOGGER, DOMAIN, hass ) await component.async_setup(config) @@ -72,9 +72,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index 73bc126f7ba..61ac7ecb39d 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: DOMAIN = "assist_satellite" -DOMAIN_DATA: HassKey[EntityComponent[AssistSatelliteEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[AssistSatelliteEntity]] = HassKey(DOMAIN) CONNECTION_TEST_DATA: HassKey[dict[str, asyncio.Event]] = HassKey( f"{DOMAIN}_connection_tests" ) diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 4c95d9555aa..c81648c6ee3 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -16,8 +16,8 @@ from homeassistant.util import uuid as uuid_util from .connection_test import CONNECTION_TEST_URL_BASE from .const import ( CONNECTION_TEST_DATA, + DATA_COMPONENT, DOMAIN, - DOMAIN_DATA, AssistSatelliteEntityFeature, ) from .entity import AssistSatelliteEntity @@ -48,7 +48,7 @@ async def websocket_intercept_wake_word( msg: dict[str, Any], ) -> None: """Intercept the next wake word from a satellite.""" - satellite = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) + satellite = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"]) if satellite is None: connection.send_error( msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" @@ -86,7 +86,7 @@ def websocket_get_configuration( msg: dict[str, Any], ) -> None: """Get the current satellite configuration.""" - satellite = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) + satellite = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"]) if satellite is None: connection.send_error( msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" @@ -115,7 +115,7 @@ async def websocket_set_wake_words( msg: dict[str, Any], ) -> None: """Set the active wake words for the satellite.""" - satellite = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) + satellite = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"]) if satellite is None: connection.send_error( msg["id"], websocket_api.ERR_NOT_FOUND, "Entity not found" diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index a40df67e2ca..8f1a38c2cd0 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -110,7 +110,7 @@ from .const import ( from .helpers import async_get_blueprints from .trace import trace_automation -DOMAIN_DATA: HassKey[EntityComponent[BaseAutomationEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[BaseAutomationEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -163,12 +163,12 @@ def _automations_with_x( hass: HomeAssistant, referenced_id: str, property_name: str ) -> list[str]: """Return all automations that reference the x.""" - if DOMAIN_DATA not in hass.data: + if DATA_COMPONENT not in hass.data: return [] return [ automation_entity.entity_id - for automation_entity in hass.data[DOMAIN_DATA].entities + for automation_entity in hass.data[DATA_COMPONENT].entities if referenced_id in getattr(automation_entity, property_name) ] @@ -177,10 +177,10 @@ def _x_in_automation( hass: HomeAssistant, entity_id: str, property_name: str ) -> list[str]: """Return all x in an automation.""" - if DOMAIN_DATA not in hass.data: + if DATA_COMPONENT not in hass.data: return [] - if (automation_entity := hass.data[DOMAIN_DATA].get_entity(entity_id)) is None: + if (automation_entity := hass.data[DATA_COMPONENT].get_entity(entity_id)) is None: return [] return list(getattr(automation_entity, property_name)) @@ -254,7 +254,7 @@ def automations_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list return [ automation_entity.entity_id - for automation_entity in hass.data[DOMAIN_DATA].entities + for automation_entity in hass.data[DATA_COMPONENT].entities if automation_entity.referenced_blueprint == blueprint_path ] @@ -262,10 +262,10 @@ def automations_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list @callback def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None: """Return the blueprint the automation is based on or None.""" - if DOMAIN_DATA not in hass.data: + if DATA_COMPONENT not in hass.data: return None - if (automation_entity := hass.data[DOMAIN_DATA].get_entity(entity_id)) is None: + if (automation_entity := hass.data[DATA_COMPONENT].get_entity(entity_id)) is None: return None return automation_entity.referenced_blueprint @@ -273,7 +273,7 @@ def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up all automations.""" - hass.data[DOMAIN_DATA] = component = EntityComponent[BaseAutomationEntity]( + hass.data[DATA_COMPONENT] = component = EntityComponent[BaseAutomationEntity]( LOGGER, DOMAIN, hass ) @@ -1204,7 +1204,7 @@ def websocket_config( msg: dict[str, Any], ) -> None: """Get automation config.""" - automation = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) + automation = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"]) if automation is None: connection.send_error( diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 5ed6014030f..1aa6903d64d 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -29,7 +29,7 @@ from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) DOMAIN = "binary_sensor" -DOMAIN_DATA: HassKey[EntityComponent[BinarySensorEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[BinarySensorEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -219,7 +219,7 @@ _DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for binary sensors.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[BinarySensorEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[BinarySensorEntity]( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL ) @@ -229,12 +229,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class BinarySensorEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 614a6e6dba3..1f06a41bf2d 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -25,7 +25,7 @@ from .const import DOMAIN, SERVICE_PRESS _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[ButtonEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[ButtonEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -49,7 +49,7 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(ButtonDeviceClass)) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Button entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[ButtonEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[ButtonEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -65,12 +65,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class ButtonEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index fa7b82a9e1e..40d6952fa64 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -43,8 +43,8 @@ from homeassistant.util.json import JsonValueType from .const import ( CONF_EVENT, + DATA_COMPONENT, DOMAIN, - DOMAIN_DATA, EVENT_DESCRIPTION, EVENT_DURATION, EVENT_END, @@ -286,7 +286,7 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for calendars.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[CalendarEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[CalendarEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -319,12 +319,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) def get_date(date: dict[str, Any]) -> datetime.datetime: @@ -707,7 +707,7 @@ async def handle_calendar_event_create( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle creation of a calendar event.""" - if not (entity := hass.data[DOMAIN_DATA].get_entity(msg["entity_id"])): + if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return @@ -747,7 +747,7 @@ async def handle_calendar_event_delete( ) -> None: """Handle delete of a calendar event.""" - if not (entity := hass.data[DOMAIN_DATA].get_entity(msg["entity_id"])): + if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return @@ -792,7 +792,7 @@ async def handle_calendar_event_update( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle creation of a calendar event.""" - if not (entity := hass.data[DOMAIN_DATA].get_entity(msg["entity_id"])): + if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index 6266a604c81..821fe24c383 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from . import CalendarEntity DOMAIN = "calendar" -DOMAIN_DATA: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[CalendarEntity]] = HassKey(DOMAIN) CONF_EVENT = "event" diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index 4daa32f7fc7..ca69a4b662f 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -24,7 +24,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from . import CalendarEntity, CalendarEvent -from .const import DOMAIN, DOMAIN_DATA +from .const import DATA_COMPONENT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -95,7 +95,7 @@ type QueuedEventFetcher = Callable[[Timespan], Awaitable[list[QueuedCalendarEven def get_entity(hass: HomeAssistant, entity_id: str) -> CalendarEntity: """Get the calendar entity for the provided entity_id.""" - component: EntityComponent[CalendarEntity] = hass.data[DOMAIN_DATA] + component: EntityComponent[CalendarEntity] = hass.data[DATA_COMPONENT] if not (entity := component.get_entity(entity_id)) or not isinstance( entity, CalendarEntity ): diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 88162df6f1a..e5bce1b545b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -71,9 +71,9 @@ from .const import ( # noqa: F401 CONF_DURATION, CONF_LOOKBACK, DATA_CAMERA_PREFS, + DATA_COMPONENT, DATA_RTSP_TO_WEB_RTC, DOMAIN, - DOMAIN_DATA, PREF_ORIENTATION, PREF_PRELOAD_STREAM, SERVICE_RECORD, @@ -366,7 +366,7 @@ def async_register_rtsp_to_web_rtc_provider( async def _async_refresh_providers(hass: HomeAssistant) -> None: """Check all cameras for any state changes for registered providers.""" - component = hass.data[DOMAIN_DATA] + component = hass.data[DATA_COMPONENT] await asyncio.gather( *(camera.async_refresh_providers() for camera in component.entities) ) @@ -382,7 +382,7 @@ def _async_get_rtsp_to_web_rtc_providers( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the camera component.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[Camera]( + component = hass.data[DATA_COMPONENT] = EntityComponent[Camera]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -457,12 +457,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) CACHED_PROPERTIES_WITH_ATTR_ = { diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index c4327e922e6..1286e0f3976 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: from .prefs import CameraPreferences DOMAIN: Final = "camera" -DOMAIN_DATA: HassKey[EntityComponent[Camera]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[Camera]] = HassKey(DOMAIN) DATA_CAMERA_PREFS: HassKey[CameraPreferences] = HassKey("camera_prefs") DATA_RTSP_TO_WEB_RTC: HassKey[dict[str, RtspToWebRtcProviderType]] = HassKey( diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py index 00c0e83b46f..ea30dafb09e 100644 --- a/homeassistant/components/camera/media_source.py +++ b/homeassistant/components/camera/media_source.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from . import Camera, _async_stream_endpoint_url -from .const import DOMAIN, DOMAIN_DATA, StreamType +from .const import DATA_COMPONENT, DOMAIN, StreamType async def async_get_media_source(hass: HomeAssistant) -> CameraMediaSource: @@ -58,7 +58,7 @@ class CameraMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" - component = self.hass.data[DOMAIN_DATA] + component = self.hass.data[DATA_COMPONENT] camera = component.get_entity(item.identifier) if not camera: @@ -107,7 +107,7 @@ class CameraMediaSource(MediaSource): return _media_source_for_camera(self.hass, camera, content_type) - component = self.hass.data[DOMAIN_DATA] + component = self.hass.data[DATA_COMPONENT] results = await asyncio.gather( *(_filter_browsable_camera(camera) for camera in component.entities), return_exceptions=True, diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 1aa082f8c6c..cd2ce3b563b 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -115,7 +115,7 @@ from .const import ( # noqa: F401 _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[ClimateEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[ClimateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -152,7 +152,7 @@ SET_TEMPERATURE_SCHEMA = vol.All( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up climate entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[ClimateEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[ClimateEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -225,12 +225,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class ClimateEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index a1325171af2..17f3b6f5ccc 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -35,9 +35,9 @@ from .const import ( ATTR_CONVERSATION_ID, ATTR_LANGUAGE, ATTR_TEXT, + DATA_COMPONENT, DATA_DEFAULT_ENTITY, DOMAIN, - DOMAIN_DATA, HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT, SERVICE_PROCESS, @@ -149,7 +149,7 @@ def async_get_conversation_languages( agents = [agent] else: - agents = list(hass.data[DOMAIN_DATA].entities) + agents = list(hass.data[DATA_COMPONENT].entities) for info in agent_manager.async_get_agent_info(): agent = agent_manager.async_get_agent(info.id) assert agent is not None @@ -210,7 +210,7 @@ async def async_prepare_agent( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass) - hass.data[DOMAIN_DATA] = entity_component + hass.data[DATA_COMPONENT] = entity_component await async_setup_default_agent( hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) @@ -269,9 +269,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 25b2a5a4220..7516d9d22ef 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -12,8 +12,8 @@ from homeassistant.core import Context, HomeAssistant, async_get_hass, callback from homeassistant.helpers import config_validation as cv, singleton from .const import ( + DATA_COMPONENT, DATA_DEFAULT_ENTITY, - DOMAIN_DATA, HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT, ) @@ -57,7 +57,7 @@ def async_get_agent( return hass.data[DATA_DEFAULT_ENTITY] if "." in agent_id: - return hass.data[DOMAIN_DATA].get_entity(agent_id) + return hass.data[DATA_COMPONENT].get_entity(agent_id) manager = get_agent_manager(hass) diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index f4599ef8991..619a41fd002 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -26,7 +26,7 @@ ATTR_CONVERSATION_ID = "conversation_id" SERVICE_PROCESS = "process" SERVICE_RELOAD = "reload" -DOMAIN_DATA: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[ConversationEntity]] = HassKey(DOMAIN) DATA_DEFAULT_ENTITY: HassKey[DefaultAgent] = HassKey(f"{DOMAIN}_default_entity") diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 181afeb8525..df1ffc7f74f 100644 --- a/homeassistant/components/conversation/http.py +++ b/homeassistant/components/conversation/http.py @@ -27,7 +27,7 @@ from .agent_manager import ( async_get_agent, get_agent_manager, ) -from .const import DATA_DEFAULT_ENTITY, DOMAIN_DATA +from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY from .default_agent import ( METADATA_CUSTOM_FILE, METADATA_CUSTOM_SENTENCE, @@ -114,7 +114,7 @@ async def websocket_list_agents( language = msg.get("language") agents = [] - for entity in hass.data[DOMAIN_DATA].entities: + for entity in hass.data[DATA_COMPONENT].entities: supported_languages = entity.supported_languages if language and supported_languages != MATCH_ALL: supported_languages = language_util.matches( diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index d64358896ba..a9327965c4e 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -47,7 +47,7 @@ from .const import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER # noqa: F401 _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[CoverEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[CoverEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -153,7 +153,7 @@ def is_closed(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for covers.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[CoverEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[CoverEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -233,12 +233,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class CoverEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 701db594c67..f361d0a7896 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -22,7 +22,7 @@ from .const import DOMAIN, SERVICE_SET_VALUE _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[DateEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[DateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -39,7 +39,7 @@ async def _async_set_value(entity: DateEntity, service_call: ServiceCall) -> Non async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Date entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[DateEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[DateEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -53,12 +53,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class DateEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index e3e742e107c..7e83da9c3cb 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -22,7 +22,7 @@ from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[DateTimeEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[DateTimeEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -42,7 +42,7 @@ async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Date/Time entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[DateTimeEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[DateTimeEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -60,12 +60,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class DateTimeEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index fe2b4aa4369..bea091c3fec 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -41,7 +41,7 @@ from .const import ( SourceType, ) -DOMAIN_DATA: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[BaseTrackerEntity]] = HassKey(DOMAIN) DATA_KEY: HassKey[dict[str, tuple[str, str]]] = HassKey(f"{DOMAIN}_mac") # mypy: disallow-any-generics @@ -54,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if component is not None: return await component.async_setup_entry(entry) - component = hass.data[DOMAIN_DATA] = EntityComponent[BaseTrackerEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[BaseTrackerEntity]( LOGGER, DOMAIN, hass ) component.register_shutdown() @@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) @callback diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index b73babd5edc..a7d96860a48 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -22,7 +22,7 @@ from homeassistant.util.hass_dict import HassKey from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[EventEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[EventEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -53,7 +53,7 @@ __all__ = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Event entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[EventEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[EventEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -62,12 +62,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class EventEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 3256168d3c5..e05ed967eb3 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -43,7 +43,7 @@ from homeassistant.util.percentage import ( _LOGGER = logging.getLogger(__name__) DOMAIN = "fan" -DOMAIN_DATA: HassKey[EntityComponent[FanEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[FanEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -121,7 +121,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Expose fan control via statemachine and services.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[FanEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[FanEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -203,12 +203,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class FanEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index ca32c479549..cafd30d7658 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -19,7 +19,7 @@ from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) DOMAIN = "geo_location" -DOMAIN_DATA: HassKey[EntityComponent[GeolocationEvent]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[GeolocationEvent]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -34,7 +34,7 @@ ATTR_SOURCE = "source" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Geolocation component.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[GeolocationEvent]( + component = hass.data[DATA_COMPONENT] = EntityComponent[GeolocationEvent]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -43,12 +43,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) CACHED_PROPERTIES_WITH_ATTR_ = { diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index e863eb41211..c48cd8529a2 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -48,8 +48,8 @@ from .const import ( # noqa: F401 ATTR_ORDER, ATTR_REMOVE_ENTITIES, CONF_HIDE_MEMBERS, + DATA_COMPONENT, DOMAIN, - DOMAIN_DATA, GROUP_ORDER, REG_KEY, ) @@ -131,7 +131,7 @@ def groups_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]: return [ group.entity_id - for group in hass.data[DOMAIN_DATA].entities + for group in hass.data[DATA_COMPONENT].entities if entity_id in group.tracking ] diff --git a/homeassistant/components/group/const.py b/homeassistant/components/group/const.py index 790e643eb14..c706247ae01 100644 --- a/homeassistant/components/group/const.py +++ b/homeassistant/components/group/const.py @@ -16,7 +16,7 @@ CONF_HIDE_MEMBERS = "hide_members" CONF_IGNORE_NON_NUMERIC = "ignore_non_numeric" DOMAIN = "group" -DOMAIN_DATA: HassKey[EntityComponent[Group]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[Group]] = HassKey(DOMAIN) REG_KEY: HassKey[GroupIntegrationRegistry] = HassKey(f"{DOMAIN}_registry") GROUP_ORDER: HassKey[int] = HassKey("group_order") diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 02926cfc97b..03a8be4bed5 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event -from .const import ATTR_AUTO, ATTR_ORDER, DOMAIN, DOMAIN_DATA, GROUP_ORDER, REG_KEY +from .const import ATTR_AUTO, ATTR_ORDER, DATA_COMPONENT, DOMAIN, GROUP_ORDER, REG_KEY from .registry import GroupIntegrationRegistry, SingleStateType ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -478,8 +478,8 @@ class Group(Entity): def async_get_component(hass: HomeAssistant) -> EntityComponent[Group]: """Get the group entity component.""" - if (component := hass.data.get(DOMAIN_DATA)) is None: - component = hass.data[DOMAIN_DATA] = EntityComponent[Group]( + if (component := hass.data.get(DATA_COMPONENT)) is None: + component = hass.data[DATA_COMPONENT] = EntityComponent[Group]( _PACKAGE_LOGGER, DOMAIN, hass ) return component diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 12b5b38696a..3979b66397f 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -62,7 +62,7 @@ from .const import ( # noqa: F401 _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[HumidifierEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[HumidifierEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -96,7 +96,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up humidifier devices.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[HumidifierEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[HumidifierEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -125,12 +125,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class HumidifierEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 66aab1fde79..5fb5790f25c 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -30,7 +30,7 @@ from homeassistant.helpers.event import ( from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType -from .const import DOMAIN, DOMAIN_DATA, IMAGE_TIMEOUT +from .const import DATA_COMPONENT, DOMAIN, IMAGE_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -88,7 +88,7 @@ async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the image component.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[ImageEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[ImageEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -120,12 +120,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) CACHED_PROPERTIES_WITH_ATTR_ = { diff --git a/homeassistant/components/image/const.py b/homeassistant/components/image/const.py index 7746e40afbb..a646b0dd3d5 100644 --- a/homeassistant/components/image/const.py +++ b/homeassistant/components/image/const.py @@ -13,6 +13,6 @@ if TYPE_CHECKING: DOMAIN: Final = "image" -DOMAIN_DATA: HassKey[EntityComponent[ImageEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[ImageEntity]] = HassKey(DOMAIN) IMAGE_TIMEOUT: Final = 10 diff --git a/homeassistant/components/image/media_source.py b/homeassistant/components/image/media_source.py index 4ed24498453..8d06ec3807f 100644 --- a/homeassistant/components/image/media_source.py +++ b/homeassistant/components/image/media_source.py @@ -15,7 +15,7 @@ from homeassistant.components.media_source import ( from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant, State -from .const import DOMAIN, DOMAIN_DATA +from .const import DATA_COMPONENT, DOMAIN async def async_get_media_source(hass: HomeAssistant) -> ImageMediaSource: @@ -35,7 +35,7 @@ class ImageMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" - image = self.hass.data[DOMAIN_DATA].get_entity(item.identifier) + image = self.hass.data[DATA_COMPONENT].get_entity(item.identifier) if not image: raise Unresolvable(f"Could not resolve media item: {item.identifier}") @@ -65,7 +65,7 @@ class ImageMediaSource(MediaSource): can_play=True, can_expand=False, ) - for image in self.hass.data[DOMAIN_DATA].entities + for image in self.hass.data[DATA_COMPONENT].entities ] return BrowseMediaSource( diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index 604a6580f97..b9d5f70f9ed 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -26,7 +26,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=60) @@ -34,7 +34,7 @@ SCAN_INTERVAL = timedelta(seconds=60) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the lawn_mower component.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[LawnMowerEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[LawnMowerEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -57,12 +57,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up lawn mower devices.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class LawnMowerEntityEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 94b27664b99..a496404401a 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -32,7 +32,7 @@ import homeassistant.util.color as color_util from homeassistant.util.hass_dict import HassKey DOMAIN = "light" -DOMAIN_DATA: HassKey[EntityComponent[LightEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[LightEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -395,7 +395,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Expose light control via state machine and services.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[LightEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[LightEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -672,12 +672,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) def _coerce_none(value: str) -> None: diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index d70c6383ce0..7bc0d88addc 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -45,7 +45,7 @@ from .const import DOMAIN, LockState _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[LockEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[LockEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -78,7 +78,7 @@ PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT} async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for locks.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[LockEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[LockEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -102,12 +102,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class LockEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index bd1872422bb..2323c14b688 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -139,7 +139,7 @@ from .errors import BrowseError _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[MediaPlayerEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[MediaPlayerEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -278,7 +278,7 @@ def _rename_keys(**keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for media_players.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[MediaPlayerEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[MediaPlayerEntity]( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL ) @@ -452,12 +452,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class MediaPlayerEntityDescription(EntityDescription, frozen_or_thawed=True): @@ -1294,7 +1294,7 @@ async def websocket_browse_media( To use, media_player integrations can implement MediaPlayerEntity.async_browse_media() """ - player = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) + player = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"]) if player is None: connection.send_error(msg["id"], "entity_not_found", "Entity not found") diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 75b4b65ac5b..a4ebfc7f6de 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -47,7 +47,7 @@ from .repairs import migrate_notify_issue # noqa: F401 # Platform specific data ATTR_TITLE_DEFAULT = "Home Assistant" -DOMAIN_DATA: HassKey[EntityComponent[NotifyEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[NotifyEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) @@ -78,7 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # legacy platforms to finish setting up. hass.async_create_task(setup, eager_start=True) - component = hass.data[DOMAIN_DATA] = EntityComponent[NotifyEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[NotifyEntity]( _LOGGER, DOMAIN, hass ) component.async_register_entity_service( @@ -117,12 +117,12 @@ class NotifyEntityDescription(EntityDescription, frozen_or_thawed=True): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class NotifyEntity(RestoreEntity): diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 7ff86dca7a8..2b2faba8f18 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -50,7 +50,7 @@ from .websocket_api import async_setup as async_setup_ws_api _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[NumberEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[NumberEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -83,7 +83,7 @@ __all__ = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Number entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[NumberEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[NumberEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) async_setup_ws_api(hass) @@ -126,12 +126,12 @@ async def async_set_value(entity: NumberEntity, service_call: ServiceCall) -> No async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class NumberEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 28019727ffb..8d027b95eef 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -37,7 +37,7 @@ from homeassistant.util.hass_dict import HassKey _LOGGER = logging.getLogger(__name__) DOMAIN = "remote" -DOMAIN_DATA: HassKey[EntityComponent[RemoteEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[RemoteEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -100,7 +100,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for remotes.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[RemoteEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[RemoteEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -157,12 +157,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class RemoteEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 6fcebbdfb67..d1b34b50770 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -20,7 +20,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey DOMAIN: Final = "scene" -DOMAIN_DATA: HassKey[EntityComponent[Scene]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[Scene]] = HassKey(DOMAIN) STATES: Final = "states" @@ -62,7 +62,7 @@ PLATFORM_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the scenes.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[Scene]( + component = hass.data[DATA_COMPONENT] = EntityComponent[Scene]( logging.getLogger(__name__), DOMAIN, hass ) @@ -85,12 +85,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class Scene(RestoreEntity): diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 62592428da0..b317f4ec601 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -32,7 +32,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[SelectEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[SelectEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -61,7 +61,7 @@ __all__ = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Select entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[SelectEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[SelectEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -101,12 +101,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class SelectEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 29d31d10ffc..88d35217556 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -89,7 +89,7 @@ from .websocket_api import async_setup as async_setup_ws_api _LOGGER: Final = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[SensorEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[SensorEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -117,7 +117,7 @@ __all__ = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for sensors.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[SensorEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[SensorEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -128,12 +128,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class SensorEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 34c3e22f094..15a46adeb3b 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -39,7 +39,7 @@ from .const import ( # noqa: F401 _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[SirenEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[SirenEntity]] = HassKey(DOMAIN) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = timedelta(seconds=60) @@ -106,7 +106,7 @@ def process_turn_on_params( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up siren devices.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[SirenEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[SirenEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -145,12 +145,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class SirenEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 2fb3b652c5c..d3c85aba1e7 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -30,9 +30,9 @@ from homeassistant.loader import async_suggest_report_issue from homeassistant.util import dt as dt_util, language as language_util from .const import ( + DATA_COMPONENT, DATA_PROVIDERS, DOMAIN, - DOMAIN_DATA, AudioBitRates, AudioChannels, AudioCodecs, @@ -75,7 +75,7 @@ def async_default_engine(hass: HomeAssistant) -> str | None: """Return the domain or entity id of the default engine.""" default_entity_id: str | None = None - for entity in hass.data[DOMAIN_DATA].entities: + for entity in hass.data[DATA_COMPONENT].entities: if entity.platform and entity.platform.platform_name == "cloud": return entity.entity_id @@ -90,7 +90,7 @@ def async_get_speech_to_text_entity( hass: HomeAssistant, entity_id: str ) -> SpeechToTextEntity | None: """Return stt entity.""" - return hass.data[DOMAIN_DATA].get_entity(entity_id) + return hass.data[DATA_COMPONENT].get_entity(entity_id) @callback @@ -108,7 +108,7 @@ def async_get_speech_to_text_languages(hass: HomeAssistant) -> set[str]: """Return a set with the union of languages supported by stt engines.""" languages = set() - for entity in hass.data[DOMAIN_DATA].entities: + for entity in hass.data[DATA_COMPONENT].entities: for language_tag in entity.supported_languages: languages.add(language_tag) @@ -123,7 +123,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up STT.""" websocket_api.async_register_command(hass, websocket_list_engines) - component = hass.data[DOMAIN_DATA] = EntityComponent[SpeechToTextEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[SpeechToTextEntity]( _LOGGER, DOMAIN, hass ) @@ -145,12 +145,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class SpeechToTextEntity(RestoreEntity): @@ -424,7 +424,7 @@ def websocket_list_engines( providers = [] provider_info: dict[str, Any] - for entity in hass.data[DOMAIN_DATA].entities: + for entity in hass.data[DATA_COMPONENT].entities: provider_info = { "engine_id": entity.entity_id, "supported_languages": entity.supported_languages, diff --git a/homeassistant/components/stt/const.py b/homeassistant/components/stt/const.py index 5c805494cef..1c4172cfc89 100644 --- a/homeassistant/components/stt/const.py +++ b/homeassistant/components/stt/const.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from .legacy import Provider DOMAIN = "stt" -DOMAIN_DATA: HassKey[EntityComponent[SpeechToTextEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[SpeechToTextEntity]] = HassKey(DOMAIN) DATA_PROVIDERS: HassKey[dict[str, Provider]] = HassKey(f"{DOMAIN}_providers") diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index e1320fe4469..e11b392ec07 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -34,7 +34,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[SwitchEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[SwitchEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -76,7 +76,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for switches.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[SwitchEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[SwitchEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -90,12 +90,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class SwitchEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index 5c4fbf2c15c..633c29e7beb 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -34,7 +34,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[TextEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[TextEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -48,7 +48,7 @@ __all__ = ["DOMAIN", "TextEntity", "TextEntityDescription", "TextMode"] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Text entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[TextEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[TextEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -83,12 +83,12 @@ async def _async_set_value(entity: TextEntity, service_call: ServiceCall) -> Non async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class TextMode(StrEnum): diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 7230ce490bd..4888b525dee 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -22,7 +22,7 @@ from .const import DOMAIN, SERVICE_SET_VALUE _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[TimeEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[TimeEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -39,7 +39,7 @@ async def _async_set_value(entity: TimeEntity, service_call: ServiceCall) -> Non async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Time entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[TimeEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[TimeEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -53,12 +53,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class TimeEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 533ae354dd2..fa3241cd884 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -38,8 +38,8 @@ from .const import ( ATTR_ITEM, ATTR_RENAME, ATTR_STATUS, + DATA_COMPONENT, DOMAIN, - DOMAIN_DATA, TodoItemStatus, TodoListEntityFeature, TodoServices, @@ -114,7 +114,7 @@ def _validate_supported_features( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Todo entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[TodoListEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[TodoListEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -197,12 +197,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) @dataclasses.dataclass @@ -334,7 +334,7 @@ async def websocket_handle_subscribe_todo_items( """Subscribe to To-do list item updates.""" entity_id: str = msg["entity_id"] - if not (entity := hass.data[DOMAIN_DATA].get_entity(entity_id)): + if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)): connection.send_error( msg["id"], "invalid_entity_id", @@ -389,7 +389,7 @@ async def websocket_handle_todo_item_list( """Handle the list of To-do items in a To-do- list.""" if ( not (entity_id := msg[CONF_ENTITY_ID]) - or not (entity := hass.data[DOMAIN_DATA].get_entity(entity_id)) + or not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)) or not isinstance(entity, TodoListEntity) ): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") @@ -422,7 +422,7 @@ async def websocket_handle_todo_item_move( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle move of a To-do item within a To-do list.""" - if not (entity := hass.data[DOMAIN_DATA].get_entity(msg["entity_id"])): + if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py index 634075d7f32..3b0aa37fa7b 100644 --- a/homeassistant/components/todo/const.py +++ b/homeassistant/components/todo/const.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from . import TodoListEntity DOMAIN = "todo" -DOMAIN_DATA: HassKey[EntityComponent[TodoListEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[TodoListEntity]] = HassKey(DOMAIN) ATTR_DUE = "due" ATTR_DUE_DATE = "due_date" diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py index 6520e6c12b7..6233ea6029e 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import intent from . import TodoItem, TodoItemStatus, TodoListEntity -from .const import DOMAIN, DOMAIN_DATA +from .const import DATA_COMPONENT, DOMAIN INTENT_LIST_ADD_ITEM = "HassListAddItem" @@ -49,7 +49,7 @@ class ListAddItemIntent(intent.IntentHandler): result=match_result, constraints=match_constraints ) - target_list = hass.data[DOMAIN_DATA].get_entity( + target_list = hass.data[DATA_COMPONENT].get_entity( match_result.states[0].entity_id ) if target_list is None: diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 5ecbe15601d..671d5b13f37 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -57,12 +57,12 @@ from .const import ( CONF_CACHE, CONF_CACHE_DIR, CONF_TIME_MEMORY, + DATA_COMPONENT, DATA_TTS_MANAGER, DEFAULT_CACHE, DEFAULT_CACHE_DIR, DEFAULT_TIME_MEMORY, DOMAIN, - DOMAIN_DATA, TtsAudioType, ) from .helper import get_engine_instance @@ -140,7 +140,7 @@ def async_default_engine(hass: HomeAssistant) -> str | None: """ default_entity_id: str | None = None - for entity in hass.data[DOMAIN_DATA].entities: + for entity in hass.data[DATA_COMPONENT].entities: if entity.platform and entity.platform.platform_name == "cloud": return entity.entity_id @@ -158,7 +158,7 @@ def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None: """ if engine is not None: if ( - not hass.data[DOMAIN_DATA].get_entity(engine) + not hass.data[DATA_COMPONENT].get_entity(engine) and engine not in hass.data[DATA_TTS_MANAGER].providers ): return None @@ -200,7 +200,7 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: """Return a set with the union of languages supported by tts engines.""" languages = set() - for entity in hass.data[DOMAIN_DATA].entities: + for entity in hass.data[DATA_COMPONENT].entities: for language_tag in entity.supported_languages: languages.add(language_tag) @@ -317,7 +317,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return False hass.data[DATA_TTS_MANAGER] = tts - component = hass.data[DOMAIN_DATA] = EntityComponent[TextToSpeechEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[TextToSpeechEntity]( _LOGGER, DOMAIN, hass ) @@ -365,12 +365,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) CACHED_PROPERTIES_WITH_ATTR_ = { @@ -1101,7 +1101,7 @@ def websocket_list_engines( provider_info: dict[str, Any] entity_domains: set[str] = set() - for entity in hass.data[DOMAIN_DATA].entities: + for entity in hass.data[DATA_COMPONENT].entities: provider_info = { "engine_id": entity.entity_id, "supported_languages": entity.supported_languages, @@ -1149,7 +1149,7 @@ def websocket_get_engine( provider: TextToSpeechEntity | Provider | None = next( ( entity - for entity in hass.data[DOMAIN_DATA].entities + for entity in hass.data[DATA_COMPONENT].entities if entity.entity_id == engine_id ), None, diff --git a/homeassistant/components/tts/const.py b/homeassistant/components/tts/const.py index b465dfb15dd..42c7d710ad4 100644 --- a/homeassistant/components/tts/const.py +++ b/homeassistant/components/tts/const.py @@ -26,7 +26,7 @@ DEFAULT_CACHE_DIR = "tts" DEFAULT_TIME_MEMORY = 300 DOMAIN = "tts" -DOMAIN_DATA: HassKey[EntityComponent[TextToSpeechEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[TextToSpeechEntity]] = HassKey(DOMAIN) DATA_TTS_MANAGER: HassKey[SpeechManager] = HassKey("tts_manager") diff --git a/homeassistant/components/tts/helper.py b/homeassistant/components/tts/helper.py index 41b938f7e0b..614d848ea6a 100644 --- a/homeassistant/components/tts/helper.py +++ b/homeassistant/components/tts/helper.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant -from .const import DATA_TTS_MANAGER, DOMAIN_DATA +from .const import DATA_COMPONENT, DATA_TTS_MANAGER if TYPE_CHECKING: from . import TextToSpeechEntity @@ -17,7 +17,7 @@ def get_engine_instance( hass: HomeAssistant, engine: str ) -> TextToSpeechEntity | Provider | None: """Get engine instance.""" - if entity := hass.data[DOMAIN_DATA].get_entity(engine): + if entity := hass.data[DATA_COMPONENT].get_entity(engine): return entity return hass.data[DATA_TTS_MANAGER].providers.get(engine) diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index dce521621c5..4f1fa59f001 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -20,7 +20,7 @@ from homeassistant.components.media_source import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .const import DATA_TTS_MANAGER, DOMAIN, DOMAIN_DATA +from .const import DATA_COMPONENT, DATA_TTS_MANAGER, DOMAIN from .helper import get_engine_instance URL_QUERY_TTS_OPTIONS = "tts_options" @@ -146,7 +146,7 @@ class TTSMediaSource(MediaSource): for engine in self.hass.data[DATA_TTS_MANAGER].providers ] + [ self._engine_item(entity.entity_id) - for entity in self.hass.data[DOMAIN_DATA].entities + for entity in self.hass.data[DATA_COMPONENT].entities ] return BrowseMediaSource( domain=DOMAIN, diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index 699f8bad51f..8897e9cc442 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -42,7 +42,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[UpdateEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[UpdateEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -80,7 +80,7 @@ __all__ = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Select entities.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[UpdateEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[UpdateEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -113,12 +113,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None: @@ -492,7 +492,7 @@ async def websocket_release_notes( msg: dict[str, Any], ) -> None: """Get the full release notes for a entity.""" - entity = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) + entity = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"]) if entity is None: connection.send_error( diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index b74ccb5fc7a..0922ee75ee7 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -40,7 +40,7 @@ from .const import DOMAIN, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, STATE_RETU _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[StateVacuumEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[StateVacuumEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -134,7 +134,7 @@ def is_on(hass: HomeAssistant, entity_id: str) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the vacuum component.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[StateVacuumEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[StateVacuumEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -197,12 +197,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py index c6b49a9a7c2..7df6f8eac51 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -33,7 +33,7 @@ from .const import DOMAIN, ValveState _LOGGER = logging.getLogger(__name__) -DOMAIN_DATA: HassKey[EntityComponent[ValveEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[ValveEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -67,7 +67,7 @@ ATTR_POSITION = "position" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track states and offer events for valves.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[ValveEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[ValveEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) @@ -111,12 +111,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 00db5a7355b..8b3a5bbf331 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -36,7 +36,7 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -DOMAIN_DATA: HassKey[EntityComponent[WakeWordDetectionEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[WakeWordDetectionEntity]] = HassKey(DOMAIN) TIMEOUT_FETCH_WAKE_WORDS = 10 @@ -52,14 +52,14 @@ def async_get_wake_word_detection_entity( hass: HomeAssistant, entity_id: str ) -> WakeWordDetectionEntity | None: """Return wake word entity.""" - return hass.data[DOMAIN_DATA].get_entity(entity_id) + return hass.data[DATA_COMPONENT].get_entity(entity_id) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up wake word.""" websocket_api.async_register_command(hass, websocket_entity_info) - component = hass.data[DOMAIN_DATA] = EntityComponent[WakeWordDetectionEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[WakeWordDetectionEntity]( _LOGGER, DOMAIN, hass ) component.register_shutdown() @@ -69,12 +69,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class WakeWordDetectionEntity(RestoreEntity): @@ -141,7 +141,7 @@ async def websocket_entity_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Get info about wake word entity.""" - entity = hass.data[DOMAIN_DATA].get_entity(msg["entity_id"]) + entity = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"]) if entity is None: connection.send_error( diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index da8b49bd171..502f7d226b0 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -40,7 +40,7 @@ from homeassistant.util.unit_conversion import TemperatureConverter from .const import DOMAIN -DOMAIN_DATA: HassKey[EntityComponent[WaterHeaterEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[WaterHeaterEntity]] = HassKey(DOMAIN) ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE @@ -111,7 +111,7 @@ SET_OPERATION_MODE_SCHEMA: VolDictType = { async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up water_heater devices.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[WaterHeaterEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[WaterHeaterEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) await component.async_setup(config) @@ -139,12 +139,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class WaterHeaterEntityEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 03b8addc1c9..4db90f70bd8 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -62,8 +62,8 @@ from .const import ( # noqa: F401 ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED_UNIT, + DATA_COMPONENT, DOMAIN, - DOMAIN_DATA, INTENT_GET_WEATHER, UNIT_CONVERSIONS, VALID_UNITS, @@ -197,7 +197,7 @@ class Forecast(TypedDict, total=False): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the weather component.""" - component = hass.data[DOMAIN_DATA] = EntityComponent[WeatherEntity]( + component = hass.data[DATA_COMPONENT] = EntityComponent[WeatherEntity]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) component.async_register_entity_service( @@ -218,12 +218,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - return await hass.data[DOMAIN_DATA].async_setup_entry(entry) + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.data[DOMAIN_DATA].async_unload_entry(entry) + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) class WeatherEntityDescription(EntityDescription, frozen_or_thawed=True): diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index ef8eada2b3f..f532b891e3e 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -54,7 +54,7 @@ ATTR_WEATHER_CLOUD_COVERAGE = "cloud_coverage" ATTR_WEATHER_UV_INDEX = "uv_index" DOMAIN: Final = "weather" -DOMAIN_DATA: HassKey[EntityComponent[WeatherEntity]] = HassKey(DOMAIN) +DATA_COMPONENT: HassKey[EntityComponent[WeatherEntity]] = HassKey(DOMAIN) INTENT_GET_WEATHER = "HassGetWeather" diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py index fb9759c9bdf..a96c4fa9973 100644 --- a/homeassistant/components/weather/websocket_api.py +++ b/homeassistant/components/weather/websocket_api.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.util.json import JsonValueType -from .const import DOMAIN, DOMAIN_DATA, VALID_UNITS, WeatherEntityFeature +from .const import DATA_COMPONENT, DOMAIN, VALID_UNITS, WeatherEntityFeature FORECAST_TYPE_TO_FLAG = { "daily": WeatherEntityFeature.FORECAST_DAILY, @@ -58,7 +58,7 @@ async def ws_subscribe_forecast( entity_id: str = msg["entity_id"] forecast_type: Literal["daily", "hourly", "twice_daily"] = msg["forecast_type"] - if not (entity := hass.data[DOMAIN_DATA].get_entity(msg["entity_id"])): + if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error( msg["id"], "invalid_entity_id", From bb29c7a02f18089c75a178b401c19cb8621d6844 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Wed, 25 Sep 2024 15:58:24 +0200 Subject: [PATCH 1177/1309] Add sound modes to Bang & Olufsen devices (#121209) * Add sound mode functionality * Fix naming * Change unique sound mode symbol * Add testing for sound modes * Add test typing * Use constants for service call parameters * Add state assertions * Remove invalid decorator * Add valid sound mode check * Add test for invalid sound mode --- .../components/bang_olufsen/const.py | 1 + .../components/bang_olufsen/media_player.py | 45 +++++++++++ .../components/bang_olufsen/strings.json | 3 + .../components/bang_olufsen/websocket.py | 12 +++ tests/components/bang_olufsen/conftest.py | 34 +++++++++ tests/components/bang_olufsen/const.py | 12 +++ .../bang_olufsen/test_media_player.py | 74 +++++++++++++++++++ 7 files changed, 181 insertions(+) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index 6803a141cee..64ee4cf275d 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -68,6 +68,7 @@ class BangOlufsenModel(StrEnum): class WebsocketNotification(StrEnum): """Enum for WebSocket notification types.""" + ACTIVE_LISTENING_MODE = "active_listening_mode" PLAYBACK_ERROR = "playback_error" PLAYBACK_METADATA = "playback_metadata" PLAYBACK_PROGRESS = "playback_progress" diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index bd74f15ddf9..474319fc3a1 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -13,6 +13,8 @@ from mozart_api.models import ( Action, Art, BeolinkLeader, + ListeningModeProps, + ListeningModeRef, OverlayPlayRequest, OverlayPlayRequestTextToSpeechTextToSpeech, PlaybackContentMetadata, @@ -88,6 +90,7 @@ BANG_OLUFSEN_FEATURES = ( | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) @@ -137,6 +140,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self._sources: dict[str, str] = {} self._state: str = MediaPlayerState.IDLE self._video_sources: dict[str, str] = {} + self._sound_modes: dict[str, int] = {} # Beolink compatible sources self._beolink_sources: dict[str, bool] = {} @@ -148,6 +152,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): signal_handlers: dict[str, Callable] = { CONNECTION_STATUS: self._async_update_connection_state, + WebsocketNotification.ACTIVE_LISTENING_MODE: self._async_update_sound_modes, WebsocketNotification.BEOLINK: self._async_update_beolink, WebsocketNotification.PLAYBACK_ERROR: self._async_update_playback_error, WebsocketNotification.PLAYBACK_METADATA: self._async_update_playback_metadata_and_beolink, @@ -211,6 +216,8 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # If the device has been updated with new sources, then the API will fail here. await self._async_update_sources() + await self._async_update_sound_modes() + async def _async_update_sources(self) -> None: """Get sources for the specific product.""" @@ -432,6 +439,29 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # Return JID return cast(str, config_entry.data[CONF_BEOLINK_JID]) + async def _async_update_sound_modes( + self, active_sound_mode: ListeningModeProps | ListeningModeRef | None = None + ) -> None: + """Update the available sound modes.""" + sound_modes = await self._client.get_listening_mode_set() + + if active_sound_mode is None: + active_sound_mode = await self._client.get_active_listening_mode() + + # Add the key to make the labels unique (As labels are not required to be unique on B&O devices) + for sound_mode in sound_modes: + label = f"{sound_mode.name} ({sound_mode.id})" + + self._sound_modes[label] = sound_mode.id + + if sound_mode.id == active_sound_mode.id: + self._attr_sound_mode = label + + # Set available options + self._attr_sound_mode_list = list(self._sound_modes.keys()) + + self.async_write_ha_state() + @property def state(self) -> MediaPlayerState: """Return the current state of the media player.""" @@ -620,6 +650,21 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): # Video await self._client.post_remote_trigger(id=key) + async def async_select_sound_mode(self, sound_mode: str) -> None: + """Select a sound mode.""" + # Ensure only known sound modes known by the integration can be activated. + if sound_mode not in self._sound_modes: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_sound_mode", + translation_placeholders={ + "invalid_sound_mode": sound_mode, + "valid_sound_modes": ", ".join(list(self._sound_modes.keys())), + }, + ) + + await self._client.activate_listening_mode(id=self._sound_modes[sound_mode]) + async def async_play_media( self, media_type: MediaType | str, diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json index 6c4b7f1370c..b0cb88985d2 100644 --- a/homeassistant/components/bang_olufsen/strings.json +++ b/homeassistant/components/bang_olufsen/strings.json @@ -43,6 +43,9 @@ }, "invalid_grouping_entity": { "message": "Entity with id: {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?" + }, + "invalid_sound_mode": { + "message": "{invalid_sound_mode} is an invalid sound mode. Valid values are: {valid_sound_modes}." } } } diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 6e5c1d4c76c..3519fcd9a48 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging from mozart_api.models import ( + ListeningModeProps, PlaybackContentMetadata, PlaybackError, PlaybackProgress, @@ -50,6 +51,9 @@ class BangOlufsenWebsocket(BangOlufsenBase): self._client.get_notification_notifications(self.on_notification_notification) self._client.get_on_connection_lost(self.on_connection_lost) self._client.get_on_connection(self.on_connection) + self._client.get_active_listening_mode_notifications( + self.on_active_listening_mode + ) self._client.get_playback_error_notifications( self.on_playback_error_notification ) @@ -89,6 +93,14 @@ class BangOlufsenWebsocket(BangOlufsenBase): _LOGGER.error("Lost connection to the %s", self.entry.title) self._update_connection_status() + def on_active_listening_mode(self, notification: ListeningModeProps) -> None: + """Send active_listening_mode dispatch.""" + async_dispatcher_send( + self.hass, + f"{self._unique_id}_{WebsocketNotification.ACTIVE_LISTENING_MODE}", + notification, + ) + def on_notification_notification( self, notification: WebsocketNotificationTag ) -> None: diff --git a/tests/components/bang_olufsen/conftest.py b/tests/components/bang_olufsen/conftest.py index 0ad9d34a170..ff29592b137 100644 --- a/tests/components/bang_olufsen/conftest.py +++ b/tests/components/bang_olufsen/conftest.py @@ -7,6 +7,10 @@ from mozart_api.models import ( Action, BeolinkPeer, ContentItem, + ListeningMode, + ListeningModeFeatures, + ListeningModeRef, + ListeningModeTrigger, PlaybackContentMetadata, PlaybackProgress, PlaybackState, @@ -38,6 +42,9 @@ from .const import ( TEST_NAME_2, TEST_SERIAL_NUMBER, TEST_SERIAL_NUMBER_2, + TEST_SOUND_MODE, + TEST_SOUND_MODE_2, + TEST_SOUND_MODE_NAME, ) from tests.common import MockConfigEntry @@ -263,6 +270,32 @@ def mock_mozart_client() -> Generator[AsyncMock]: BeolinkPeer(friendly_name=TEST_FRIENDLY_NAME_3, jid=TEST_JID_3), ] + client.get_listening_mode_set = AsyncMock() + client.get_listening_mode_set.return_value = [ + ListeningMode( + id=TEST_SOUND_MODE, + name=TEST_SOUND_MODE_NAME, + features=ListeningModeFeatures(), + triggers=[ListeningModeTrigger()], + ), + ListeningMode( + id=TEST_SOUND_MODE_2, + name=TEST_SOUND_MODE_NAME, + features=ListeningModeFeatures(), + triggers=[ListeningModeTrigger()], + ), + ListeningMode( + id=345, + name=f"{TEST_SOUND_MODE_NAME} 2", + features=ListeningModeFeatures(), + triggers=[ListeningModeTrigger()], + ), + ] + client.get_active_listening_mode = AsyncMock() + client.get_active_listening_mode.return_value = ListeningModeRef( + href="", + id=123, + ) client.post_standby = AsyncMock() client.set_current_volume_level = AsyncMock() client.set_volume_mute = AsyncMock() @@ -283,6 +316,7 @@ def mock_mozart_client() -> Generator[AsyncMock]: client.post_beolink_leave = AsyncMock() client.post_beolink_allstandby = AsyncMock() client.join_latest_beolink_experience = AsyncMock() + client.activate_listening_mode = AsyncMock() # Non-REST API client methods client.check_device_connection = AsyncMock() diff --git a/tests/components/bang_olufsen/const.py b/tests/components/bang_olufsen/const.py index e8d8653c5b7..7cbe81dc06a 100644 --- a/tests/components/bang_olufsen/const.py +++ b/tests/components/bang_olufsen/const.py @@ -6,6 +6,7 @@ from unittest.mock import Mock from mozart_api.exceptions import ApiException from mozart_api.models import ( Action, + ListeningModeRef, OverlayPlayRequest, OverlayPlayRequestTextToSpeechTextToSpeech, PlaybackContentMetadata, @@ -197,3 +198,14 @@ TEST_DEEZER_INVALID_FLOW = ApiException( data='{"message": "Couldn\'t start user flow for me"}', # codespell:ignore ), ) +TEST_SOUND_MODE = 123 +TEST_SOUND_MODE_2 = 234 +TEST_SOUND_MODE_NAME = "Test Listening Mode" +TEST_ACTIVE_SOUND_MODE_NAME = f"{TEST_SOUND_MODE_NAME} ({TEST_SOUND_MODE})" +TEST_ACTIVE_SOUND_MODE_NAME_2 = f"{TEST_SOUND_MODE_NAME} ({TEST_SOUND_MODE_2})" +TEST_LISTENING_MODE_REF = ListeningModeRef(href="", id=TEST_SOUND_MODE_2) +TEST_SOUND_MODES = [ + TEST_ACTIVE_SOUND_MODE_NAME, + TEST_ACTIVE_SOUND_MODE_NAME_2, + f"{TEST_SOUND_MODE_NAME} 2 (345)", +] diff --git a/tests/components/bang_olufsen/test_media_player.py b/tests/components/bang_olufsen/test_media_player.py index 12dee794709..ff42ae2a867 100644 --- a/tests/components/bang_olufsen/test_media_player.py +++ b/tests/components/bang_olufsen/test_media_player.py @@ -37,6 +37,8 @@ from homeassistant.components.media_player import ( ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST, SERVICE_MEDIA_NEXT_TRACK, @@ -45,6 +47,7 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, SERVICE_PLAY_MEDIA, + SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SERVICE_TURN_OFF, SERVICE_VOLUME_MUTE, @@ -58,6 +61,8 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component from .const import ( + TEST_ACTIVE_SOUND_MODE_NAME, + TEST_ACTIVE_SOUND_MODE_NAME_2, TEST_AUDIO_SOURCES, TEST_DEEZER_FLOW, TEST_DEEZER_INVALID_FLOW, @@ -66,6 +71,7 @@ from .const import ( TEST_FALLBACK_SOURCES, TEST_FRIENDLY_NAME_2, TEST_JID_2, + TEST_LISTENING_MODE_REF, TEST_MEDIA_PLAYER_ENTITY_ID, TEST_MEDIA_PLAYER_ENTITY_ID_2, TEST_MEDIA_PLAYER_ENTITY_ID_3, @@ -79,6 +85,8 @@ from .const import ( TEST_PLAYBACK_STATE_TURN_OFF, TEST_RADIO_STATION, TEST_SEEK_POSITION_HOME_ASSISTANT_FORMAT, + TEST_SOUND_MODE_2, + TEST_SOUND_MODES, TEST_SOURCES, TEST_VIDEO_SOURCES, TEST_VOLUME, @@ -113,12 +121,15 @@ async def test_initialization( assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) assert states.attributes[ATTR_INPUT_SOURCE_LIST] == TEST_SOURCES assert states.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] + assert states.attributes[ATTR_SOUND_MODE_LIST] == TEST_SOUND_MODES # Check API calls mock_mozart_client.get_softwareupdate_status.assert_called_once() mock_mozart_client.get_product_state.assert_called_once() mock_mozart_client.get_available_sources.assert_called_once() mock_mozart_client.get_remote_menu.assert_called_once() + mock_mozart_client.get_listening_mode_set.assert_called_once() + mock_mozart_client.get_active_listening_mode.assert_called_once() async def test_async_update_sources_audio_only( @@ -779,6 +790,69 @@ async def test_async_select_source( assert mock_mozart_client.post_remote_trigger.call_count == video_source_call +async def test_async_select_sound_mode( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_select_sound_mode.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states.attributes[ATTR_SOUND_MODE] == TEST_ACTIVE_SOUND_MODE_NAME + + active_listening_mode_callback = ( + mock_mozart_client.get_active_listening_mode_notifications.call_args[0][0] + ) + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + ATTR_SOUND_MODE: TEST_ACTIVE_SOUND_MODE_NAME_2, + }, + blocking=True, + ) + + active_listening_mode_callback(TEST_LISTENING_MODE_REF) + + assert (states := hass.states.get(TEST_MEDIA_PLAYER_ENTITY_ID)) + assert states.attributes[ATTR_SOUND_MODE] == TEST_ACTIVE_SOUND_MODE_NAME_2 + + mock_mozart_client.activate_listening_mode.assert_called_once_with( + id=TEST_SOUND_MODE_2 + ) + + +async def test_async_select_sound_mode_invalid( + hass: HomeAssistant, + mock_mozart_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_select_sound_mode with an invalid sound_mode.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + with pytest.raises(ServiceValidationError) as exc_info: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + { + ATTR_ENTITY_ID: TEST_MEDIA_PLAYER_ENTITY_ID, + ATTR_SOUND_MODE: "invalid_sound_mode", + }, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_sound_mode" + assert exc_info.errisinstance(ServiceValidationError) + + async def test_async_play_media_invalid_type( hass: HomeAssistant, mock_mozart_client: AsyncMock, From f4c339db8c8f3089a7b178be14afa400c9facab2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Sep 2024 09:00:04 -0500 Subject: [PATCH 1178/1309] Fix license check for new aiocache (#126753) --- script/licenses.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/licenses.py b/script/licenses.py index 72906da2a89..177fc8e4b25 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -170,7 +170,7 @@ EXCEPTIONS = { TODO = { "aiocache": AwesomeVersion( - "0.12.2" + "0.12.3" ), # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved? } From 3810c3cbaf2037f3474ed19c0b9bfd19e1a308a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Sep 2024 16:44:14 +0200 Subject: [PATCH 1179/1309] Improve trigger schema validation to ask for `trigger` instead of `platform` (#126750) * Add check for missing trigger * Fix * Fix * Escape --- .../components/device_automation/__init__.py | 2 +- homeassistant/helpers/config_validation.py | 6 ++++-- tests/helpers/test_config_validation.py | 16 ++++++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 2c6e80e5f49..a75a4216475 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -484,7 +484,7 @@ async def websocket_device_automation_get_condition_capabilities( # The frontend responds with `trigger` as key, while the # `DEVICE_TRIGGER_BASE_SCHEMA` expects `platform1` as key. vol.Required("trigger"): vol.All( - cv._backward_compat_trigger_schema, # noqa: SLF001 + cv._trigger_pre_validator, # noqa: SLF001 DEVICE_TRIGGER_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), ), } diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 8b190abad92..98a2cd71931 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1771,7 +1771,7 @@ CONDITION_ACTION_SCHEMA: vol.Schema = vol.Schema( ) -def _backward_compat_trigger_schema(value: Any | None) -> Any: +def _trigger_pre_validator(value: Any | None) -> Any: """Rewrite trigger `trigger` to `platform`. `platform` has been renamed to `trigger` in user documentation and in the automation @@ -1790,6 +1790,8 @@ def _backward_compat_trigger_schema(value: Any | None) -> Any: ) value = dict(value) value[CONF_PLATFORM] = value.pop(CONF_TRIGGER) + elif CONF_PLATFORM not in value: + raise vol.Invalid("required key not provided", [CONF_TRIGGER]) return value @@ -1831,7 +1833,7 @@ def _base_trigger_validator(value: Any) -> Any: TRIGGER_SCHEMA = vol.All( ensure_list, _base_trigger_list_flatten, - [vol.All(_backward_compat_trigger_schema, _base_trigger_validator)], + [vol.All(_trigger_pre_validator, _base_trigger_validator)], ) _SCRIPT_DELAY_SCHEMA = vol.Schema( diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 4fd87d6d2fe..7202cef6f5f 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -6,6 +6,7 @@ import enum from functools import partial import logging import os +import re from socket import _GLOBAL_DEFAULT_TIMEOUT import threading from typing import Any @@ -1911,16 +1912,19 @@ async def test_nested_trigger_list_extra() -> None: async def test_trigger_backwards_compatibility() -> None: """Test triggers with backwards compatibility.""" - assert cv._backward_compat_trigger_schema("str") == "str" - assert cv._backward_compat_trigger_schema({"platform": "abc"}) == { - "platform": "abc" - } - assert cv._backward_compat_trigger_schema({"trigger": "abc"}) == {"platform": "abc"} + assert cv._trigger_pre_validator("str") == "str" + assert cv._trigger_pre_validator({"platform": "abc"}) == {"platform": "abc"} + assert cv._trigger_pre_validator({"trigger": "abc"}) == {"platform": "abc"} with pytest.raises( vol.Invalid, match="Cannot specify both 'platform' and 'trigger'. Please use 'trigger' only.", ): - cv._backward_compat_trigger_schema({"trigger": "abc", "platform": "def"}) + cv._trigger_pre_validator({"trigger": "abc", "platform": "def"}) + with pytest.raises( + vol.Invalid, + match=re.escape("required key not provided @ data['trigger']"), + ): + cv._trigger_pre_validator({}) async def test_is_entity_service_schema( From e7e86d7a323d29599bfd8a6850645e6d21c04a67 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 25 Sep 2024 17:36:45 +0200 Subject: [PATCH 1180/1309] Update frontend to 20240925.0 (#126763) --- 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 7f394611375..0ec8d4f3aa1 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==20240909.1"] + "requirements": ["home-assistant-frontend==20240925.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 22255685aa2..fbee44ed73c 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==20240909.1 +home-assistant-frontend==20240925.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 be3acb3707a..3901e93c6f6 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==20240909.1 +home-assistant-frontend==20240925.0 # homeassistant.components.conversation home-assistant-intents==2024.9.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50d08a8313a..4fb03e9e878 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==20240909.1 +home-assistant-frontend==20240925.0 # homeassistant.components.conversation home-assistant-intents==2024.9.23 From d7ac53ae93c52cc4fc604b6e814cf8bd896ba7b0 Mon Sep 17 00:00:00 2001 From: Euan de Kock Date: Thu, 26 Sep 2024 00:04:03 +0800 Subject: [PATCH 1181/1309] Update const.py to add new Australian Server URL (#126714) Growatt is now redirecting Australian users to a new server. This adds support for this server. --- homeassistant/components/growatt_server/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index fe8622bea7f..4ad62aa812b 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -12,6 +12,7 @@ SERVER_URLS = [ "https://openapi.growatt.com/", # Other regional server "https://openapi-cn.growatt.com/", # Chinese server "https://openapi-us.growatt.com/", # North American server + "https://openapi-au.growatt.com/", # Australia Server "http://server.smten.com/", # smten server ] From 0fa1478f90853b8fad2579a0d219152e5b4625de Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Wed, 25 Sep 2024 18:19:41 +0200 Subject: [PATCH 1182/1309] Remove unnecessary dict .keys() calls from Bang & Olufsen (#126762) Remove useless .keys() calls --- homeassistant/components/bang_olufsen/media_player.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 474319fc3a1..ecf571d5456 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -458,7 +458,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): self._attr_sound_mode = label # Set available options - self._attr_sound_mode_list = list(self._sound_modes.keys()) + self._attr_sound_mode_list = list(self._sound_modes) self.async_write_ha_state() @@ -659,7 +659,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): translation_key="invalid_sound_mode", translation_placeholders={ "invalid_sound_mode": sound_mode, - "valid_sound_modes": ", ".join(list(self._sound_modes.keys())), + "valid_sound_modes": ", ".join(list(self._sound_modes)), }, ) @@ -855,7 +855,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): translation_key="invalid_source", translation_placeholders={ "invalid_source": cast(str, self._source_change.id), - "valid_sources": ", ".join(list(self._beolink_sources.keys())), + "valid_sources": ", ".join(list(self._beolink_sources)), }, ) From fbf5d3966d763b06e6cbdfc102877d5265715de8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Sep 2024 18:27:18 +0200 Subject: [PATCH 1183/1309] Use shorthand attributes in locative device tracker (#126740) --- .../components/locative/device_tracker.py | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index 133f59d235a..47a498331eb 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -35,25 +35,11 @@ class LocativeEntity(TrackerEntity): def __init__(self, device, location, location_name): """Set up Locative entity.""" self._name = device - self._location = location - self._location_name = location_name + self._attr_latitude = location[0] + self._attr_longitude = location[1] + self._attr_location_name = location_name self._unsub_dispatcher = None - @property - def latitude(self): - """Return latitude value of the device.""" - return self._location[0] - - @property - def longitude(self): - """Return longitude value of the device.""" - return self._location[1] - - @property - def location_name(self): - """Return a location name for the current location of the device.""" - return self._location_name - @property def name(self): """Return the name of the device.""" @@ -74,6 +60,7 @@ class LocativeEntity(TrackerEntity): """Update device data.""" if device != self._name: return - self._location_name = location_name - self._location = location + self._attr_location_name = location_name + self._attr_latitude = location[0] + self._attr_longitude = location[1] self.async_write_ha_state() From 0a44c9456cda357fcc2e2cc5f682351834289a9e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 25 Sep 2024 12:44:51 -0400 Subject: [PATCH 1184/1309] Bump ZHA to 0.0.34 (#126766) --- homeassistant/components/zha/const.py | 1 - homeassistant/components/zha/entity.py | 31 ++++++++++++++-------- homeassistant/components/zha/helpers.py | 3 --- homeassistant/components/zha/light.py | 8 ------ homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 1 - requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/data.py | 14 ---------- 9 files changed, 23 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 18705c40608..270a3d3fb66 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -43,7 +43,6 @@ CONF_CUSTOM_QUIRKS_PATH = "custom_quirks_path" CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition" CONF_ENABLE_ENHANCED_LIGHT_TRANSITION = "enhanced_light_transition" CONF_ENABLE_LIGHT_TRANSITIONING_FLAG = "light_transitioning_flag" -CONF_ALWAYS_PREFER_XY_COLOR_MODE = "always_prefer_xy_color_mode" CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 348e545f1c4..b9e2e0fb3d2 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable -import functools +from functools import cached_property, partial import logging from typing import Any @@ -16,6 +16,7 @@ from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .const import DOMAIN from .helpers import SIGNAL_REMOVE_ENTITIES, EntityData, convert_zha_error_to_ha_error @@ -43,15 +44,6 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): meta = self.entity_data.entity.info_object self._attr_unique_id = meta.unique_id - if meta.translation_key is not None: - self._attr_translation_key = meta.translation_key - elif meta.fallback_name is not None: - # Only custom quirks will create entities with just a fallback name! - # - # This is to allow local development and to register niche devices, since - # their translation_key will probably never be added to `zha/strings.json`. - self._attr_name = meta.fallback_name - if meta.entity_category is not None: self._attr_entity_category = EntityCategory(meta.entity_category) @@ -59,6 +51,23 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): meta.entity_registry_enabled_default ) + if meta.translation_key is not None: + self._attr_translation_key = meta.translation_key + + @cached_property + def name(self) -> str | UndefinedType | None: + """Return the name of the entity.""" + meta = self.entity_data.entity.info_object + original_name = super().name + + if original_name not in (UNDEFINED, None) or meta.fallback_name is None: + return original_name + + # This is to allow local development and to register niche devices, since + # their translation_key will probably never be added to `zha/strings.json`. + self._attr_name = meta.fallback_name + return super().name + @property def available(self) -> bool: """Return entity availability.""" @@ -102,7 +111,7 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity): async_dispatcher_connect( self.hass, remove_signal, - functools.partial(self.async_remove, force_remove=True), + partial(self.async_remove, force_remove=True), ) ) self.entity_data.device_proxy.gateway_proxy.register_entity_reference( diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index cc3fb2898e6..b91565835a7 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -140,7 +140,6 @@ from .const import ( CONF_ALARM_ARM_REQUIRES_CODE, CONF_ALARM_FAILED_TRIES, CONF_ALARM_MASTER_CODE, - CONF_ALWAYS_PREFER_XY_COLOR_MODE, CONF_BAUDRATE, CONF_CONSIDER_UNAVAILABLE_BATTERY, CONF_CONSIDER_UNAVAILABLE_MAINS, @@ -1153,7 +1152,6 @@ CONF_ZHA_OPTIONS_SCHEMA = vol.Schema( ), vol.Required(CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, default=False): cv.boolean, vol.Required(CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, default=True): cv.boolean, - vol.Required(CONF_ALWAYS_PREFER_XY_COLOR_MODE, default=True): cv.boolean, vol.Required(CONF_GROUP_MEMBERS_ASSUME_STATE, default=True): cv.boolean, vol.Required(CONF_ENABLE_IDENTIFY_ON_JOIN, default=True): cv.boolean, vol.Optional( @@ -1230,7 +1228,6 @@ def create_zha_config(hass: HomeAssistant, ha_zha_data: HAZHAData) -> ZHAData: enable_light_transitioning_flag=zha_options.get( CONF_ENABLE_LIGHT_TRANSITIONING_FLAG ), - always_prefer_xy_color_mode=zha_options.get(CONF_ALWAYS_PREFER_XY_COLOR_MODE), group_members_assume_state=zha_options.get(CONF_GROUP_MEMBERS_ASSUME_STATE), ) device_options: DeviceOptions = DeviceOptions( diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 4a36030a0dd..fa83ad1cab6 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -18,7 +18,6 @@ from homeassistant.components.light import ( ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, - ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, ColorMode, @@ -143,11 +142,6 @@ class Light(LightEntity, ZHAEntity): """Return the warmest color_temp that this light supports.""" return self.entity_data.entity.max_mireds - @property - def hs_color(self) -> tuple[float, float] | None: - """Return the hs color value [int, int].""" - return self.entity_data.entity.hs_color - @property def xy_color(self) -> tuple[float, float] | None: """Return the xy color value [float, float].""" @@ -185,7 +179,6 @@ class Light(LightEntity, ZHAEntity): flash=kwargs.get(ATTR_FLASH), color_temp=kwargs.get(ATTR_COLOR_TEMP), xy_color=kwargs.get(ATTR_XY_COLOR), - hs_color=kwargs.get(ATTR_HS_COLOR), ) self.async_write_ha_state() @@ -207,7 +200,6 @@ class Light(LightEntity, ZHAEntity): brightness=state.attributes.get(ATTR_BRIGHTNESS), color_temp=state.attributes.get(ATTR_COLOR_TEMP), xy_color=state.attributes.get(ATTR_XY_COLOR), - hs_color=state.attributes.get(ATTR_HS_COLOR), color_mode=( HA_TO_ZHA_COLOR_MODE[ColorMode(state.attributes[ATTR_COLOR_MODE])] if state.attributes.get(ATTR_COLOR_MODE) is not None diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 7046642160c..dd15fb99960 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.33"], + "requirements": ["universal-silabs-flasher==0.0.22", "zha==0.0.34"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index f98ad170e0a..6123081fcd7 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -178,7 +178,6 @@ "title": "Global Options", "enhanced_light_transition": "Enable enhanced light color/temperature transition from an off-state", "light_transitioning_flag": "Enable enhanced brightness slider during light transition", - "always_prefer_xy_color_mode": "Always prefer XY color mode", "group_members_assume_state": "Group members assume state of group", "enable_identify_on_join": "Enable identify effect when devices join the network", "default_light_transition": "Default light transition time (seconds)", diff --git a/requirements_all.txt b/requirements_all.txt index 3901e93c6f6..7f878b5dcdc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3047,7 +3047,7 @@ zeroconf==0.135.0 zeversolar==0.3.1 # homeassistant.components.zha -zha==0.0.33 +zha==0.0.34 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4fb03e9e878..1bdbdbb6058 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2427,7 +2427,7 @@ zeroconf==0.135.0 zeversolar==0.3.1 # homeassistant.components.zha -zha==0.0.33 +zha==0.0.34 # homeassistant.components.zwave_js zwave-js-server-python==0.58.0 diff --git a/tests/components/zha/data.py b/tests/components/zha/data.py index e5ed43e26a0..80a3df524cd 100644 --- a/tests/components/zha/data.py +++ b/tests/components/zha/data.py @@ -23,12 +23,6 @@ BASE_CUSTOM_CONFIGURATION = { "required": True, "default": True, }, - { - "type": "boolean", - "name": "always_prefer_xy_color_mode", - "required": True, - "default": True, - }, { "type": "boolean", "name": "group_members_assume_state", @@ -68,7 +62,6 @@ BASE_CUSTOM_CONFIGURATION = { "enhanced_light_transition": True, "default_light_transition": 0, "light_transitioning_flag": True, - "always_prefer_xy_color_mode": True, "group_members_assume_state": False, "enable_identify_on_join": True, "enable_mains_startup_polling": True, @@ -101,12 +94,6 @@ CONFIG_WITH_ALARM_OPTIONS = { "required": True, "default": True, }, - { - "type": "boolean", - "name": "always_prefer_xy_color_mode", - "required": True, - "default": True, - }, { "type": "boolean", "name": "group_members_assume_state", @@ -167,7 +154,6 @@ CONFIG_WITH_ALARM_OPTIONS = { "enhanced_light_transition": True, "default_light_transition": 0, "light_transitioning_flag": True, - "always_prefer_xy_color_mode": True, "group_members_assume_state": False, "enable_identify_on_join": True, "enable_mains_startup_polling": True, From 6d1e5886ec1a4eec71108ad00a50b4acaa702782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 25 Sep 2024 20:19:10 +0200 Subject: [PATCH 1185/1309] Add Valve platform to Matter integration (#123311) * Create water_valve.py * Update water_valve.py ValveEntity * Update water_valve.py ValveDeviceClass * Update water_valve.py * Update water_valve.py OperationalStatus * Update water_valve.py * Update water_valve.py Commands * Update water_valve.py Platform.VALVE * Update water_valve.py * Update water_valve.py operational_status * Update water_valve.py current_valve_position * Update water_valve.py * Update water_valve.py * Update water_valve.py attributes * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Open command * Match Valve entity methods * Update water_valve.py * Update water_valve.py * Update water_valve.py * ruff-format * Update water_valve.py * Update water_valve.py * Update water_valve.py Attributes.CurrentLevel * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py async_set_valve_position * Update water_valve.py * Update water_valve.py Bitmaps * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update homeassistant/components/matter/water_valve.py Co-authored-by: Marcel van der Veldt * Update homeassistant/components/matter/water_valve.py Co-authored-by: Marcel van der Veldt * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update discovery.py to add WaterValve * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update discovery.py * Update discovery.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Update water_valve.py * Rename water_valve.py to valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Update valve.py * Create test_valve.py * Update test_valve.py * Update test_valve.py * Update test_valve.py * Update test_valve.py * Update test_valve.py * Update test_valve.py * Update test_valve.py * Update test_valve.py * Update test_valve.py * Create valve.json * Update air-purifier.json * Revert "Update air-purifier.json" This reverts commit b68dce0ccc81bc6fb1db36191de1c296ce54cac3. * Update valve.json * Update valve.json * Update valve.json * Update test_valve.py * Update valve.json * Update test_valve.py * Update valve.json * Update valve.json * Update valve.json * Update test_valve.py * Update valve.py * Update valve.py * Update valve.py * add tests * cleanup * Clean up variable * Format * add tests for state updates * adjust * add tests for position --------- Co-authored-by: Marcel van der Veldt Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/discovery.py | 2 + homeassistant/components/matter/strings.json | 5 + homeassistant/components/matter/valve.py | 152 ++++++++++ .../matter/fixtures/nodes/valve.json | 260 ++++++++++++++++++ tests/components/matter/test_valve.py | 131 +++++++++ 5 files changed, 550 insertions(+) create mode 100644 homeassistant/components/matter/valve.py create mode 100644 tests/components/matter/fixtures/nodes/valve.json create mode 100644 tests/components/matter/test_valve.py diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 5544409e0ba..342522787ab 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -24,6 +24,7 @@ from .select import DISCOVERY_SCHEMAS as SELECT_SCHEMAS from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS from .update import DISCOVERY_SCHEMAS as UPDATE_SCHEMAS +from .valve import DISCOVERY_SCHEMAS as VALVE_SCHEMAS DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, @@ -39,6 +40,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.SENSOR: SENSOR_SCHEMAS, Platform.SWITCH: SWITCH_SCHEMAS, Platform.UPDATE: UPDATE_SCHEMAS, + Platform.VALVE: VALVE_SCHEMAS, } SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 5a268c1c371..b4ef5b79340 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -250,6 +250,11 @@ "power": { "name": "Power" } + }, + "valve": { + "valve": { + "name": "[%key:component::valve::title%]" + } } }, "issues": { diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py new file mode 100644 index 00000000000..f2e212246ca --- /dev/null +++ b/homeassistant/components/matter/valve.py @@ -0,0 +1,152 @@ +"""Matter valve platform.""" + +from __future__ import annotations + +from chip.clusters import Objects as clusters +from matter_server.client.models import device_types + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +ValveConfigurationAndControl = clusters.ValveConfigurationAndControl + +ValveStateEnum = ValveConfigurationAndControl.Enums.ValveStateEnum + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter valve platform from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.VALVE, async_add_entities) + + +class MatterValve(MatterEntity, ValveEntity): + """Representation of a Matter Valve.""" + + _feature_map: int | None = None + entity_description: ValveEntityDescription + + async def send_device_command( + self, + command: clusters.ClusterCommand, + ) -> None: + """Send a command to the device.""" + await self.matter_client.send_device_command( + node_id=self._endpoint.node.node_id, + endpoint_id=self._endpoint.endpoint_id, + command=command, + ) + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.send_device_command(ValveConfigurationAndControl.Commands.Open()) + + async def async_close_valve(self) -> None: + """Close the valve.""" + await self.send_device_command(ValveConfigurationAndControl.Commands.Close()) + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + await self.send_device_command( + ValveConfigurationAndControl.Commands.Open(targetLevel=position) + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + self._calculate_features() + current_state: int + current_state = self.get_matter_attribute_value( + ValveConfigurationAndControl.Attributes.CurrentState + ) + target_state: int + target_state = self.get_matter_attribute_value( + ValveConfigurationAndControl.Attributes.TargetState + ) + if ( + current_state == ValveStateEnum.kTransitioning + and target_state == ValveStateEnum.kOpen + ): + self._attr_is_opening = True + self._attr_is_closing = False + elif ( + current_state == ValveStateEnum.kTransitioning + and target_state == ValveStateEnum.kClosed + ): + self._attr_is_opening = False + self._attr_is_closing = True + elif current_state == ValveStateEnum.kClosed: + self._attr_is_opening = False + self._attr_is_closing = False + self._attr_is_closed = True + else: + self._attr_is_opening = False + self._attr_is_closing = False + self._attr_is_closed = False + # handle optional position + if self.supported_features & ValveEntityFeature.SET_POSITION: + self._attr_current_valve_position = self.get_matter_attribute_value( + ValveConfigurationAndControl.Attributes.CurrentLevel + ) + + @callback + def _calculate_features( + self, + ) -> None: + """Calculate features for HA Valve platform from Matter FeatureMap.""" + feature_map = int( + self.get_matter_attribute_value( + ValveConfigurationAndControl.Attributes.FeatureMap + ) + ) + # NOTE: the featuremap can dynamically change, so we need to update the + # supported features if the featuremap changes. + # work out supported features and presets from matter featuremap + if self._feature_map == feature_map: + return + self._feature_map = feature_map + self._attr_supported_features = ValveEntityFeature(0) + if feature_map & ValveConfigurationAndControl.Bitmaps.Feature.kLevel: + self._attr_supported_features |= ValveEntityFeature.SET_POSITION + self._attr_reports_position = True + else: + self._attr_reports_position = False + + self._attr_supported_features |= ( + ValveEntityFeature.CLOSE | ValveEntityFeature.OPEN + ) + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.VALVE, + entity_description=ValveEntityDescription( + key="MatterValve", + device_class=ValveDeviceClass.WATER, + translation_key="valve", + ), + entity_class=MatterValve, + required_attributes=( + ValveConfigurationAndControl.Attributes.CurrentState, + ValveConfigurationAndControl.Attributes.TargetState, + ), + optional_attributes=(ValveConfigurationAndControl.Attributes.CurrentLevel,), + device_type=(device_types.WaterValve,), + ), +] diff --git a/tests/components/matter/fixtures/nodes/valve.json b/tests/components/matter/fixtures/nodes/valve.json new file mode 100644 index 00000000000..5ba06412ca9 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/valve.json @@ -0,0 +1,260 @@ +{ + "node_id": 75, + "date_commissioned": "2024-09-02T09:32:00.380607", + "last_interview": "2024-09-02T09:32:00.380611", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 43, 48, 49, 50, 51, 60, 62, 63], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 18, + "0/40/1": "Mock", + "0/40/2": 65521, + "0/40/3": "Valve", + "0/40/4": 32768, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/18": "A3586AC56A2CCCDB", + "0/40/19": { + "0": 3, + "1": 65535 + }, + "0/40/21": 17039360, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 2, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 2, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "0": "ZW5zMzM=", + "1": true + } + ], + "0/49/2": 0, + "0/49/3": 0, + "0/49/4": true, + "0/49/5": null, + "0/49/6": null, + "0/49/7": null, + "0/49/65532": 4, + "0/49/65533": 2, + "0/49/65528": [], + "0/49/65529": [], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "0": "ens33", + "1": true, + "2": null, + "3": null, + "4": "AAwpp2CV", + "5": ["wKgBjg=="], + "6": [ + "/adI27DsyURo2mqau/5wuw==", + "/adI27DsyUSOe4PwnMXbYg==", + "KgEOCgKzOZD9M4Fh8k4Abg==", + "KgEOCgKzOZCNpPnLBN7MTQ==", + "/oAAAAAAAADvX1kMcjUM+w==" + ], + "7": 2 + }, + { + "0": "lo", + "1": true, + "2": null, + "3": null, + "4": "AAAAAAAA", + "5": ["fwAAAQ=="], + "6": ["AAAAAAAAAAAAAAAAAAAAAQ=="], + "7": 0 + } + ], + "0/51/1": 1, + "0/51/2": 77, + "0/51/3": 0, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRSxgkBwEkCAEwCUEEPt5xWN1i0R+dLM+MnDvosL8hjyrRoHq5ja+iCtZbpXTIXt17ueMKWDc7pgeEvHn9opOCiFvmqjEZ1L4hDk27MTcKNQEoARgkAgE2AwQCBAEYMAQUUPvMnV9FkGhfQedEwlqazBFbVfUwBRQ1L3KS8MJ5RVnuryNgRxdXueDAoxgwC0CA4m5xhFuvxC4iDehajKmbdNvZdo2alIbL8hGTor2jMFIPAowJeA0ZaS0+ocRsA6xxHRrpmmF095qUHbSONrPIGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEBjOABseGNfeoeNqgBxhNV78q8SfQP8putY2hpTVwmJVaWzyqw4F/OhdJRHTZjXkSV87jHOZ58ivEb3GjFiT+OTcKNQEpARgkAmAwBBQ1L3KS8MJ5RVnuryNgRxdXueDAozAFFM2vLItbAuvwSMsedKJS5Tw7Aa2pGDALQCPtpgnYiXc8JmJmEi25z0BIPFYaf27j9yhVSmm45vjpdSZd3p8uOGjHd23m8w/22q2eWvkzU02qTVLgnV42cgkY", + "254": 1 + } + ], + "0/62/1": [ + { + "1": "BPUiJZj+BQknF7mbNOh2d9ZtKB+gQJLND+2qjIAAaMJb+2BW+xFhqDYYiA8p9YegdTb0wHA1NQY8TXMPyDwoP9Q=", + "2": 4939, + "3": 2, + "4": 75, + "5": "", + "254": 1 + } + ], + "0/62/2": 16, + "0/62/3": 1, + "0/62/4": [ + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEE9SIlmP4FCScXuZs06HZ31m0oH6BAks0P7aqMgABowlv7YFb7EWGoNhiIDyn1h6B1NvTAcDU1BjxNcw/IPCg/1DcKNQEpARgkAmAwBBTNryyLWwLr8EjLHnSiUuU8OwGtqTAFFM2vLItbAuvwSMsedKJS5Tw7Aa2pGDALQKL0AGnKE3ezVrBBzJA+9INd8GTFOC3oX/EeCpI4CSKlc7LijfauiDVtJ5gfqR0gf1TKLcWfSUe7mIIvXzzvg0UY" + ], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 2, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 3, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 66, + "1": 1 + } + ], + "1/29/1": [3, 4, 29, 129], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/129/0": 0, + "1/129/1": 0, + "1/129/2": 0, + "1/129/3": null, + "1/129/4": 0, + "1/129/5": 0, + "1/129/6": 0, + "1/129/7": 0, + "1/129/8": 100, + "1/129/9": 0, + "1/129/10": 0, + "1/129/65532": 0, + "1/129/65533": 1, + "1/129/65528": [], + "1/129/65529": [0, 1], + "1/129/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 65528, 65529, 65531, 65532, 65533 + ] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_valve.py b/tests/components/matter/test_valve.py new file mode 100644 index 00000000000..203f16ac1c5 --- /dev/null +++ b/tests/components/matter/test_valve.py @@ -0,0 +1,131 @@ +"""Test Matter valve.""" + +from unittest.mock import MagicMock, call + +from chip.clusters import Objects as clusters +from matter_server.client.models.node import MatterNode +import pytest + +from homeassistant.core import HomeAssistant + +from .common import ( + set_node_attribute, + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +@pytest.fixture(name="valve_node") +async def valve_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a valve node.""" + return await setup_integration_with_node_fixture(hass, "valve", matter_client) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_valve( + hass: HomeAssistant, + matter_client: MagicMock, + valve_node: MatterNode, +) -> None: + """Test valve entity is created for a Matter ValveConfigurationAndControl Cluster.""" + entity_id = "valve.valve_valve" + state = hass.states.get(entity_id) + assert state + assert state.state == "closed" + assert state.attributes["friendly_name"] == "Valve Valve" + + # test close_valve action + await hass.services.async_call( + "valve", + "close_valve", + { + "entity_id": entity_id, + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=valve_node.node_id, + endpoint_id=1, + command=clusters.ValveConfigurationAndControl.Commands.Close(), + ) + matter_client.send_device_command.reset_mock() + + # test open_valve action + await hass.services.async_call( + "valve", + "open_valve", + { + "entity_id": entity_id, + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=valve_node.node_id, + endpoint_id=1, + command=clusters.ValveConfigurationAndControl.Commands.Open(), + ) + matter_client.send_device_command.reset_mock() + + # set changing state to 'opening' + set_node_attribute(valve_node, 1, 129, 4, 2) + set_node_attribute(valve_node, 1, 129, 5, 1) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "opening" + + # set changing state to 'closing' + set_node_attribute(valve_node, 1, 129, 4, 2) + set_node_attribute(valve_node, 1, 129, 5, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "closing" + + # set changing state to 'open' + set_node_attribute(valve_node, 1, 129, 4, 1) + set_node_attribute(valve_node, 1, 129, 5, 0) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "open" + + # add support for setting position by updating the featuremap + set_node_attribute(valve_node, 1, 129, 65532, 2) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["current_position"] == 0 + + # update current position + set_node_attribute(valve_node, 1, 129, 6, 50) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["current_position"] == 50 + + # test set_position action + await hass.services.async_call( + "valve", + "set_valve_position", + { + "entity_id": entity_id, + "position": 100, + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=valve_node.node_id, + endpoint_id=1, + command=clusters.ValveConfigurationAndControl.Commands.Open(targetLevel=100), + ) + matter_client.send_device_command.reset_mock() From f53411b95a653c0986cd1db08cf8c0c0f7c5cd76 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 25 Sep 2024 20:28:22 +0200 Subject: [PATCH 1186/1309] Bump aioautomower to 2024.9.3 (#126769) * Bump aioautomower to 2024.9.3 * tests --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index aab633378ed..85acfaf66a2 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower", "iot_class": "cloud_push", "loggers": ["aioautomower"], - "requirements": ["aioautomower==2024.9.2"] + "requirements": ["aioautomower==2024.9.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7f878b5dcdc..2b10caf2a93 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -198,7 +198,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.9.2 +aioautomower==2024.9.3 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1bdbdbb6058..2d8a2691102 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -186,7 +186,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2024.9.2 +aioautomower==2024.9.3 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 0e7f0028e65..f0036e653a8 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -138,21 +138,21 @@ '0': dict({ 'cutting_height': 50, 'enabled': False, - 'last_time_completed_naive': '1970-01-20T22:43:59.269000', + 'last_time_completed_naive': '2024-08-12T05:07:49', 'name': 'my_lawn', 'progress': 20, }), '123456': dict({ 'cutting_height': 50, 'enabled': True, - 'last_time_completed_naive': '1970-01-20T22:44:09.269000', + 'last_time_completed_naive': '2024-08-12T07:54:29', 'name': 'Front lawn', 'progress': 40, }), '654321': dict({ 'cutting_height': 25, 'enabled': True, - 'last_time_completed_naive': '1970-01-20T22:27:29.269000', + 'last_time_completed_naive': '2024-07-31T18:07:49', 'name': 'Back lawn', 'progress': 30, }), From 7d61cb1ef5797f2169d729207c9f951c01831ae8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Sep 2024 20:29:14 +0200 Subject: [PATCH 1187/1309] Remove unignore flow (#126765) --- homeassistant/config_entries.py | 26 +-- tests/components/bluetooth/test_manager.py | 215 +-------------------- tests/components/dhcp/test_init.py | 112 +---------- tests/components/ssdp/test_init.py | 123 +----------- tests/components/zeroconf/test_init.py | 158 +-------------- tests/test_config_entries.py | 133 ------------- 6 files changed, 37 insertions(+), 730 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index be7f74582eb..404ae1c91dd 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -106,11 +106,6 @@ SOURCE_ZEROCONF = "zeroconf" # source and while it exists normal discoveries with the same unique id are ignored. SOURCE_IGNORE = "ignore" -# This is used when a user uses the "Stop Ignoring" button in the UI (the -# config_entries/ignore_flow websocket command). It's triggered after the -# "ignore" config entry has been removed and unloaded. -SOURCE_UNIGNORE = "unignore" - # This is used to signal that re-authentication is required by the user. SOURCE_REAUTH = "reauth" @@ -179,7 +174,6 @@ DISCOVERY_SOURCES = { SOURCE_INTEGRATION_DISCOVERY, SOURCE_MQTT, SOURCE_SSDP, - SOURCE_UNIGNORE, SOURCE_USB, SOURCE_ZEROCONF, } @@ -1264,7 +1258,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): # a single config entry, but which already has an entry if ( context.get("source") - not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_UNIGNORE, SOURCE_RECONFIGURE} + not in {SOURCE_IGNORE, SOURCE_REAUTH, SOURCE_RECONFIGURE} and self.config_entries.async_has_entries(handler, include_ignore=False) and await _support_single_config_entry_only(self.hass, handler) ): @@ -1855,20 +1849,6 @@ class ConfigEntries: issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}" ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id) - # After we have fully removed an "ignore" config entry we can try and rediscover - # it so that a user is able to immediately start configuring it. We do this by - # starting a new flow with the 'unignore' step. If the integration doesn't - # implement async_step_unignore then this will be a no-op. - if entry.source == SOURCE_IGNORE: - self.hass.async_create_task_internal( - self.hass.config_entries.flow.async_init( - entry.domain, - context={"source": SOURCE_UNIGNORE}, - data={"unique_id": entry.unique_id}, - ), - f"config entry unignore {entry.title} {entry.domain} {entry.unique_id}", - ) - self._async_dispatch(ConfigEntryChange.REMOVED, entry) for discovery_domain in entry.discovery_keys: async_dispatcher_send_internal( @@ -2544,10 +2524,6 @@ class ConfigFlow(ConfigEntryBaseFlow): await self.async_set_unique_id(user_input["unique_id"], raise_on_progress=False) return self.async_create_entry(title=user_input["title"], data={}) - async def async_step_unignore(self, user_input: dict[str, Any]) -> ConfigFlowResult: - """Rediscover a config entry by it's unique_id.""" - return self.async_abort(reason="not_implemented") - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index caff31d74d2..2542b88cef3 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1335,7 +1335,14 @@ async def test_set_fallback_interval_big(hass: HomeAssistant) -> None: ), ], ) -@pytest.mark.parametrize("entry_source", [config_entries.SOURCE_IGNORE]) +@pytest.mark.parametrize( + "entry_source", + [ + config_entries.SOURCE_BLUETOOTH, + config_entries.SOURCE_IGNORE, + config_entries.SOURCE_USER, + ], +) async def test_bluetooth_rediscover( hass: HomeAssistant, entry_domain: str, @@ -1386,205 +1393,6 @@ async def test_bluetooth_rediscover( BluetoothScanningMode.ACTIVE, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - def clear_all_devices(self) -> None: - """Clear all devices.""" - self._discovered_device_advertisement_datas.clear() - self._discovered_device_timestamps.clear() - self._previous_service_info.clear() - - connector = ( - HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), - ) - non_connectable_scanner = FakeScanner( - "connectable", - "connectable", - connector, - False, - ) - unsetup_connectable_scanner = non_connectable_scanner.async_setup() - cancel_connectable_scanner = _get_manager().async_register_scanner( - non_connectable_scanner - ) - with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: - non_connectable_scanner.inject_advertisement( - switchbot_device_non_connectable, switchbot_device_adv - ) - await hass.async_block_till_done() - - expected_context = { - "discovery_key": DiscoveryKey( - domain="bluetooth", key="44:44:33:11:23:45", version=1 - ), - "source": "bluetooth", - } - assert len(mock_config_flow.mock_calls) == 1 - assert mock_config_flow.mock_calls[0][1][0] == "switchbot" - assert mock_config_flow.mock_calls[0][2]["context"] == expected_context - - hass.config.components.add(entry_domain) - mock_integration(hass, MockModule(entry_domain)) - - entry = MockConfigEntry( - domain=entry_domain, - discovery_keys=entry_discovery_keys, - unique_id="mock-unique-id", - state=config_entries.ConfigEntryState.LOADED, - source=entry_source, - ) - entry.add_to_hass(hass) - - assert ( - async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None - ) - assert async_scanner_count(hass, connectable=False) == 1 - assert len(callbacks) == 1 - - assert ( - "44:44:33:11:23:45" - in non_connectable_scanner.discovered_devices_and_advertisement_data - ) - - await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - - assert ( - async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None - ) - assert async_scanner_count(hass, connectable=False) == 1 - assert len(callbacks) == 1 - - assert len(mock_config_flow.mock_calls) == 3 - assert mock_config_flow.mock_calls[1][1][0] == entry_domain - assert mock_config_flow.mock_calls[1][2]["context"] == { - "source": "unignore", - } - assert mock_config_flow.mock_calls[2][1][0] == "switchbot" - assert mock_config_flow.mock_calls[2][2]["context"] == expected_context - - cancel() - unsetup_connectable_scanner() - cancel_connectable_scanner() - - -@pytest.mark.usefixtures("mock_bluetooth_adapters") -@pytest.mark.parametrize( - ( - "entry_domain", - "entry_discovery_keys", - ), - [ - # Matching discovery key - ( - "switchbot", - { - "bluetooth": ( - DiscoveryKey( - domain="bluetooth", key="44:44:33:11:23:45", version=1 - ), - ) - }, - ), - # Matching discovery key - ( - "switchbot", - { - "bluetooth": ( - DiscoveryKey( - domain="bluetooth", key="44:44:33:11:23:45", version=1 - ), - ), - "other": (DiscoveryKey(domain="other", key="blah", version=1),), - }, - ), - # Matching discovery key, other domain - # Note: Rediscovery is not currently restricted to the domain of the removed - # entry. Such a check can be added if needed. - ( - "comp", - { - "bluetooth": ( - DiscoveryKey( - domain="bluetooth", key="44:44:33:11:23:45", version=1 - ), - ) - }, - ), - ], -) -@pytest.mark.parametrize( - "entry_source", [config_entries.SOURCE_BLUETOOTH, config_entries.SOURCE_USER] -) -async def test_bluetooth_rediscover_2( - hass: HomeAssistant, - entry_domain: str, - entry_discovery_keys: tuple, - entry_source: str, -) -> None: - """Test we reinitiate flows when an ignored config entry is removed. - - This test can be merged with test_zeroconf_rediscover when - async_step_unignore has been removed from the ConfigFlow base class. - """ - mock_bt = [ - { - "domain": "switchbot", - "service_data_uuid": "050a021a-0000-1000-8000-00805f9b34fb", - "connectable": False, - }, - ] - with patch( - "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt - ): - assert await async_setup_component(hass, bluetooth.DOMAIN, {}) - await hass.async_block_till_done() - - assert async_scanner_count(hass, connectable=False) == 0 - switchbot_device_non_connectable = generate_ble_device( - "44:44:33:11:23:45", - "wohand", - {}, - rssi=-100, - ) - switchbot_device_adv = generate_advertisement_data( - local_name="wohand", - service_uuids=["050a021a-0000-1000-8000-00805f9b34fb"], - service_data={"050a021a-0000-1000-8000-00805f9b34fb": b"\n\xff"}, - manufacturer_data={1: b"\x01"}, - rssi=-100, - ) - callbacks = [] - - def _fake_subscriber( - service_info: BluetoothServiceInfo, - change: BluetoothChange, - ) -> None: - """Fake subscriber for the BleakScanner.""" - callbacks.append((service_info, change)) - - cancel = bluetooth.async_register_callback( - hass, - _fake_subscriber, - {"address": "44:44:33:11:23:45", "connectable": False}, - BluetoothScanningMode.ACTIVE, - ) - class FakeScanner(BaseHaRemoteScanner): def inject_advertisement( self, device: BLEDevice, advertisement_data: AdvertisementData @@ -1847,12 +1655,7 @@ async def test_bluetooth_rediscover_no_match( ) assert async_scanner_count(hass, connectable=False) == 1 assert len(callbacks) == 1 - - assert len(mock_config_flow.mock_calls) == 2 - assert mock_config_flow.mock_calls[1][1][0] == entry_domain - assert mock_config_flow.mock_calls[1][2]["context"] == { - "source": "unignore", - } + assert len(mock_config_flow.mock_calls) == 1 cancel() unsetup_connectable_scanner() diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 3916a854247..c5dbba43c91 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -1201,7 +1201,14 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) - ), ], ) -@pytest.mark.parametrize("entry_source", [config_entries.SOURCE_IGNORE]) +@pytest.mark.parametrize( + "entry_source", + [ + config_entries.SOURCE_DHCP, + config_entries.SOURCE_IGNORE, + config_entries.SOURCE_USER, + ], +) async def test_dhcp_rediscover( hass: HomeAssistant, entry_domain: str, @@ -1255,105 +1262,6 @@ async def test_dhcp_rediscover( macaddress="b8b7f16db533", ) - with patch.object(hass.config_entries.flow, "async_init") as mock_init: - await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - - assert len(mock_init.mock_calls) == 2 - assert mock_init.mock_calls[0][1][0] == entry_domain - assert mock_init.mock_calls[0][2]["context"] == {"source": "unignore"} - assert mock_init.mock_calls[1][1][0] == "mock-domain" - assert mock_init.mock_calls[1][2]["context"] == expected_context - - -@pytest.mark.parametrize( - ( - "entry_domain", - "entry_discovery_keys", - ), - [ - # Matching discovery key - ( - "mock-domain", - {"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),)}, - ), - # Matching discovery key - ( - "mock-domain", - { - "dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),), - "other": (DiscoveryKey(domain="other", key="blah", version=1),), - }, - ), - # Matching discovery key, other domain - # Note: Rediscovery is not currently restricted to the domain of the removed - # entry. Such a check can be added if needed. - ( - "comp", - {"dhcp": (DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1),)}, - ), - ], -) -@pytest.mark.parametrize( - "entry_source", [config_entries.SOURCE_USER, config_entries.SOURCE_ZEROCONF] -) -async def test_dhcp_rediscover_2( - hass: HomeAssistant, - entry_domain: str, - entry_discovery_keys: tuple, - entry_source: str, -) -> None: - """Test we reinitiate flows when an ignored config entry is removed. - - This test can be merged with test_zeroconf_rediscover when - async_step_unignore has been removed from the ConfigFlow base class. - """ - - entry = MockConfigEntry( - domain=entry_domain, - discovery_keys=entry_discovery_keys, - unique_id="mock-unique-id", - state=config_entries.ConfigEntryState.LOADED, - source=entry_source, - ) - entry.add_to_hass(hass) - - address_data = {} - integration_matchers = dhcp.async_index_integration_matchers( - [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}] - ) - packet = Ether(RAW_DHCP_REQUEST) - - async_handle_dhcp_packet = await _async_get_handle_dhcp_packet( - hass, integration_matchers, address_data - ) - rediscovery_watcher = dhcp.RediscoveryWatcher( - hass, address_data, integration_matchers - ) - rediscovery_watcher.async_start() - with patch.object(hass.config_entries.flow, "async_init") as mock_init: - await async_handle_dhcp_packet(packet) - # Ensure no change is ignored - await async_handle_dhcp_packet(packet) - - # Assert the cached MAC address is hexstring without : - assert address_data == { - "b8b7f16db533": {"hostname": "connect", "ip": "192.168.210.56"} - } - - expected_context = { - "discovery_key": DiscoveryKey(domain="dhcp", key="b8b7f16db533", version=1), - "source": config_entries.SOURCE_DHCP, - } - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == "mock-domain" - assert mock_init.mock_calls[0][2]["context"] == expected_context - assert mock_init.mock_calls[0][2]["data"] == dhcp.DhcpServiceInfo( - ip="192.168.210.56", - hostname="connect", - macaddress="b8b7f16db533", - ) - with patch.object(hass.config_entries.flow, "async_init") as mock_init: await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() @@ -1447,6 +1355,4 @@ async def test_dhcp_rediscover_no_match( await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() - assert len(mock_init.mock_calls) == 1 - assert mock_init.mock_calls[0][1][0] == entry_domain - assert mock_init.mock_calls[0][2]["context"] == {"source": "unignore"} + assert len(mock_init.mock_calls) == 0 diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 5592f7a6809..aa8d0234246 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -938,7 +938,14 @@ async def test_flow_dismiss_on_byebye( ), ], ) -@pytest.mark.parametrize("entry_source", [config_entries.SOURCE_IGNORE]) +@pytest.mark.parametrize( + "entry_source", + [ + config_entries.SOURCE_IGNORE, + config_entries.SOURCE_SSDP, + config_entries.SOURCE_USER, + ], +) async def test_ssdp_rediscover( mock_get_ssdp, hass: HomeAssistant, @@ -999,116 +1006,6 @@ async def test_ssdp_rediscover( await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() - assert len(mock_flow_init.mock_calls) == 3 - assert mock_flow_init.mock_calls[1][1][0] == entry_domain - assert mock_flow_init.mock_calls[1][2]["context"] == {"source": "unignore"} - assert mock_flow_init.mock_calls[2][1][0] == "mock-domain" - assert mock_flow_init.mock_calls[2][2]["context"] == expected_context - assert ( - mock_flow_init.mock_calls[2][2]["data"] - == mock_flow_init.mock_calls[0][2]["data"] - ) - - -@patch( - "homeassistant.components.ssdp.async_get_ssdp", - return_value={"mock-domain": [{"st": "mock-st"}]}, -) -@pytest.mark.parametrize( - ( - "entry_domain", - "entry_discovery_keys", - ), - [ - # Matching discovery key - ( - "mock-domain", - {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),)}, - ), - # Matching discovery key - ( - "mock-domain", - { - "ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),), - "other": (DiscoveryKey(domain="other", key="blah", version=1),), - }, - ), - # Matching discovery key, other domain - # Note: Rediscovery is not currently restricted to the domain of the removed - # entry. Such a check can be added if needed. - ( - "comp", - {"ssdp": (DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1),)}, - ), - ], -) -@pytest.mark.parametrize( - "entry_source", [config_entries.SOURCE_USER, config_entries.SOURCE_ZEROCONF] -) -async def test_ssdp_rediscover_2( - mock_get_ssdp, - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - mock_flow_init, - entry_domain: str, - entry_discovery_keys: tuple, - entry_source: str, -) -> None: - """Test we reinitiate flows when an ignored config entry is removed. - - This test can be merged with test_zeroconf_rediscover when - async_step_unignore has been removed from the ConfigFlow base class. - """ - entry = MockConfigEntry( - domain=entry_domain, - discovery_keys=entry_discovery_keys, - unique_id="mock-unique-id", - state=config_entries.ConfigEntryState.LOADED, - source=entry_source, - ) - entry.add_to_hass(hass) - - mock_ssdp_search_response = _ssdp_headers( - { - "st": "mock-st", - "location": "http://1.1.1.1", - "usn": "uuid:mock-udn::mock-st", - "server": "mock-server", - "ext": "", - "_source": "search", - } - ) - aioclient_mock.get( - "http://1.1.1.1", - text=""" - - - Paulus - Paulus - - - """, - ) - ssdp_listener = await init_ssdp_component(hass) - ssdp_listener._on_search(mock_ssdp_search_response) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - expected_context = { - "discovery_key": DiscoveryKey(domain="ssdp", key="uuid:mock-udn", version=1), - "source": config_entries.SOURCE_SSDP, - } - assert len(mock_flow_init.mock_calls) == 1 - assert mock_flow_init.mock_calls[0][1][0] == "mock-domain" - assert mock_flow_init.mock_calls[0][2]["context"] == expected_context - mock_call_data: ssdp.SsdpServiceInfo = mock_flow_init.mock_calls[0][2]["data"] - assert mock_call_data.ssdp_st == "mock-st" - assert mock_call_data.ssdp_location == "http://1.1.1.1" - - await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - assert len(mock_flow_init.mock_calls) == 2 assert mock_flow_init.mock_calls[1][1][0] == "mock-domain" assert mock_flow_init.mock_calls[1][2]["context"] == expected_context @@ -1196,6 +1093,4 @@ async def test_ssdp_rediscover_no_match( await hass.config_entries.async_remove(entry.entry_id) await hass.async_block_till_done() - assert len(mock_flow_init.mock_calls) == 2 - assert mock_flow_init.mock_calls[1][1][0] == entry_domain - assert mock_flow_init.mock_calls[1][2]["context"] == {"source": "unignore"} + assert len(mock_flow_init.mock_calls) == 1 diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 8dd8d118d72..103b2f609e0 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1456,7 +1456,14 @@ async def test_zeroconf_removed(hass: HomeAssistant) -> None: ), ], ) -@pytest.mark.parametrize("entry_source", [config_entries.SOURCE_IGNORE]) +@pytest.mark.parametrize( + "entry_source", + [ + config_entries.SOURCE_IGNORE, + config_entries.SOURCE_USER, + config_entries.SOURCE_ZEROCONF, + ], +) async def test_zeroconf_rediscover( hass: HomeAssistant, entry_domain: str, @@ -1483,149 +1490,6 @@ async def test_zeroconf_rediscover( ) entry.add_to_hass(hass) - with ( - patch.dict( - zc_gen.ZEROCONF, - { - "_http._tcp.local.": [ - { - "domain": "shelly", - "name": "shelly*", - "properties": {"macaddress": "ffaadd*"}, - } - ] - }, - clear=True, - ), - patch.object(hass.config_entries.flow, "async_init") as mock_config_flow, - patch.object( - zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock - ) as mock_service_browser, - patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", - side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), - ), - ): - assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - expected_context = { - "discovery_key": DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, - ), - "source": "zeroconf", - } - assert len(mock_service_browser.mock_calls) == 1 - assert len(mock_config_flow.mock_calls) == 1 - assert mock_config_flow.mock_calls[0][1][0] == "shelly" - assert mock_config_flow.mock_calls[0][2]["context"] == expected_context - - await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - - assert len(mock_service_browser.mock_calls) == 1 - assert len(mock_config_flow.mock_calls) == 3 - assert mock_config_flow.mock_calls[1][1][0] == entry_domain - assert mock_config_flow.mock_calls[1][2]["context"] == { - "source": "unignore", - } - assert mock_config_flow.mock_calls[2][1][0] == "shelly" - assert mock_config_flow.mock_calls[2][2]["context"] == expected_context - - -@pytest.mark.usefixtures("mock_async_zeroconf") -@pytest.mark.parametrize( - ( - "entry_domain", - "entry_discovery_keys", - ), - [ - # Matching discovery key - ( - "shelly", - { - "zeroconf": ( - DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, - ), - ) - }, - ), - # Matching discovery key - ( - "shelly", - { - "zeroconf": ( - DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, - ), - ), - "other": ( - DiscoveryKey( - domain="other", - key="blah", - version=1, - ), - ), - }, - ), - # Matching discovery key, other domain - # Note: Rediscovery is not currently restricted to the domain of the removed - # entry. Such a check can be added if needed. - ( - "comp", - { - "zeroconf": ( - DiscoveryKey( - domain="zeroconf", - key=("_http._tcp.local.", "Shelly108._http._tcp.local."), - version=1, - ), - ) - }, - ), - ], -) -@pytest.mark.parametrize( - "entry_source", [config_entries.SOURCE_USER, config_entries.SOURCE_ZEROCONF] -) -async def test_zeroconf_rediscover_2( - hass: HomeAssistant, - entry_domain: str, - entry_discovery_keys: tuple, - entry_source: str, -) -> None: - """Test we reinitiate flows when an ignored config entry is removed. - - This test can be merged with test_zeroconf_rediscover when - async_step_unignore has been removed from the ConfigFlow base class. - """ - - def http_only_service_update_mock(zeroconf, services, handlers): - """Call service update handler.""" - handlers[0]( - zeroconf, - "_http._tcp.local.", - "Shelly108._http._tcp.local.", - ServiceStateChange.Added, - ) - - entry = MockConfigEntry( - domain=entry_domain, - discovery_keys=entry_discovery_keys, - unique_id="mock-unique-id", - state=config_entries.ConfigEntryState.LOADED, - source=entry_source, - ) - entry.add_to_hass(hass) - with ( patch.dict( zc_gen.ZEROCONF, @@ -1790,8 +1654,4 @@ async def test_zeroconf_rediscover_no_match( await hass.async_block_till_done() assert len(mock_service_browser.mock_calls) == 1 - assert len(mock_config_flow.mock_calls) == 2 - assert mock_config_flow.mock_calls[1][1][0] == entry_domain - assert mock_config_flow.mock_calls[1][2]["context"] == { - "source": "unignore", - } + assert len(mock_config_flow.mock_calls) == 1 diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 53bcb459c60..9cba19ef3b1 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3271,129 +3271,6 @@ async def test_async_current_entries_explicit_include_ignore( assert len(mock_setup_entry.mock_calls) == 0 -async def test_unignore_step_form( - hass: HomeAssistant, manager: config_entries.ConfigEntries -) -> None: - """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" - async_setup_entry = AsyncMock(return_value=True) - mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_platform(hass, "comp.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 1 - - async def async_step_unignore(self, user_input): - """Test unignore step.""" - unique_id = user_input["unique_id"] - await self.async_set_unique_id(unique_id) - return self.async_show_form(step_id="discovery") - - with mock_config_flow("comp", TestFlow): - result = await manager.flow.async_init( - "comp", - context={"source": config_entries.SOURCE_IGNORE}, - data={"unique_id": "mock-unique-id", "title": "Ignored Title"}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - entry = hass.config_entries.async_entries("comp")[0] - assert entry.source == "ignore" - assert entry.unique_id == "mock-unique-id" - assert entry.domain == "comp" - assert entry.title == "Ignored Title" - - await manager.async_remove(entry.entry_id) - - # But after a 'tick' the unignore step has run and we can see an active flow again. - await hass.async_block_till_done() - assert len(hass.config_entries.flow.async_progress_by_handler("comp")) == 1 - - # and still not config entries - assert len(hass.config_entries.async_entries("comp")) == 0 - - -async def test_unignore_create_entry( - hass: HomeAssistant, manager: config_entries.ConfigEntries -) -> None: - """Test that we can ignore flows that are in progress and have a unique ID, then rediscover them.""" - async_setup_entry = AsyncMock(return_value=True) - mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_platform(hass, "comp.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 1 - - async def async_step_unignore(self, user_input): - """Test unignore step.""" - unique_id = user_input["unique_id"] - await self.async_set_unique_id(unique_id) - return self.async_create_entry(title="yo", data={}) - - with mock_config_flow("comp", TestFlow): - result = await manager.flow.async_init( - "comp", - context={"source": config_entries.SOURCE_IGNORE}, - data={"unique_id": "mock-unique-id", "title": "Ignored Title"}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - entry = hass.config_entries.async_entries("comp")[0] - assert entry.source == "ignore" - assert entry.unique_id == "mock-unique-id" - assert entry.domain == "comp" - assert entry.title == "Ignored Title" - - await manager.async_remove(entry.entry_id) - - # But after a 'tick' the unignore step has run and we can see a config entry. - await hass.async_block_till_done() - entry = hass.config_entries.async_entries("comp")[0] - assert entry.source == config_entries.SOURCE_UNIGNORE - assert entry.unique_id == "mock-unique-id" - assert entry.title == "yo" - - # And still no active flow - assert len(hass.config_entries.flow.async_progress_by_handler("comp")) == 0 - - -async def test_unignore_default_impl( - hass: HomeAssistant, manager: config_entries.ConfigEntries -) -> None: - """Test that resdicovery is a no-op by default.""" - async_setup_entry = AsyncMock(return_value=True) - mock_integration(hass, MockModule("comp", async_setup_entry=async_setup_entry)) - mock_platform(hass, "comp.config_flow", None) - - class TestFlow(config_entries.ConfigFlow): - """Test flow.""" - - VERSION = 1 - - with mock_config_flow("comp", TestFlow): - result = await manager.flow.async_init( - "comp", - context={"source": config_entries.SOURCE_IGNORE}, - data={"unique_id": "mock-unique-id", "title": "Ignored Title"}, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - - entry = hass.config_entries.async_entries("comp")[0] - assert entry.source == "ignore" - assert entry.unique_id == "mock-unique-id" - assert entry.domain == "comp" - assert entry.title == "Ignored Title" - - await manager.async_remove(entry.entry_id) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries("comp")) == 0 - assert len(hass.config_entries.flow.async_progress()) == 0 - - async def test_partial_flows_hidden( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: @@ -5396,11 +5273,6 @@ async def test_hashable_non_string_unique_id( None, {"type": data_entry_flow.FlowResultType.FORM, "step_id": "reauth_confirm"}, ), - ( - config_entries.SOURCE_UNIGNORE, - None, - {"type": data_entry_flow.FlowResultType.ABORT, "reason": "not_implemented"}, - ), ( config_entries.SOURCE_USER, None, @@ -5485,11 +5357,6 @@ async def test_starting_config_flow_on_single_config_entry( None, {"type": data_entry_flow.FlowResultType.FORM, "step_id": "reauth_confirm"}, ), - ( - config_entries.SOURCE_UNIGNORE, - None, - {"type": data_entry_flow.FlowResultType.ABORT, "reason": "not_implemented"}, - ), ( config_entries.SOURCE_USER, None, From 53cf8628faf62281e7bef7350e0771931762a588 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Sep 2024 20:34:22 +0200 Subject: [PATCH 1188/1309] 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 1189/1309] 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 1190/1309] 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 1191/1309] 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 1192/1309] 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 1193/1309] 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 1194/1309] 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 1195/1309] 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 1196/1309] 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 1197/1309] 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 1198/1309] 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 1199/1309] 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 1200/1309] 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 1201/1309] 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 1202/1309] 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 1203/1309] 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 1204/1309] 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 1205/1309] 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 1206/1309] 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 1207/1309] 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 1208/1309] 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 1209/1309] 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 1210/1309] 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 1211/1309] 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 1212/1309] 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 1213/1309] 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 1214/1309] 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 1215/1309] 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 1216/1309] 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 1217/1309] 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 1218/1309] 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 1219/1309] 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 1220/1309] 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 1221/1309] 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 1222/1309] 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 1223/1309] 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 1224/1309] 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 1225/1309] 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 1226/1309] 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 1227/1309] 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 1228/1309] 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 1229/1309] 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 1230/1309] 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 1231/1309] 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 1232/1309] 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 1233/1309] 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 1234/1309] 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 1235/1309] 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 1236/1309] 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 1237/1309] 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 1238/1309] 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 1239/1309] 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 1240/1309] 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 1241/1309] 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 1242/1309] 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 1243/1309] 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 1244/1309] 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 1245/1309] 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 1246/1309] 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 1247/1309] 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 1248/1309] 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 1249/1309] 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 1250/1309] 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 1251/1309] 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 1252/1309] 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 1253/1309] 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 1254/1309] 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 1255/1309] 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 1256/1309] 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 1257/1309] 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 1258/1309] 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 1259/1309] 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 1260/1309] 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 1261/1309] 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 1262/1309] 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 1263/1309] 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 1264/1309] 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 1265/1309] 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 1266/1309] 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 1267/1309] 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 1268/1309] 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 1269/1309] 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 1270/1309] 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 1271/1309] 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 1272/1309] 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 1273/1309] 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 1274/1309] 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 1275/1309] 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 1276/1309] 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 zcmdpdmLwYGJ3 z_x248j*L!B&CJd(t*owZY;N!Fe>prpJ^y-nb^ZOv&tJdq{``G-e3vFdT1}pxn->m; zbpCHhM6`^M+h71?`(GT56b6+4`{4hR|A)JlNBm{YQMrl$fOZ$7nTh@pAyM0^53%f5=Q>-M%CK1Y|Cj}g$eS^-*TX;J7MPIjJSu z2m@M7+6tT)m2#2Wp=o{QxTNaej{<^pf;{@vxDnEu_Z7CgC^lU*9QziB{3Ys}oXhmIS?4!4Iv zK*1fTF*8RaP1CraZ;WfYmS9F=$9?)lV2DbyoR?k`REI2|PR}qcMtP7Vx~c(LUB`LZ z<>qS;SqNp9667CLo}LMDXS6uS*n7?#z*T*8kXCvih4KVX$(`0B80jQCZmi=-6kUda zZ?vIX86W{F*-rS?moWi_#v<(fqnpbbGP0tZ6HKFboK?R%QQNgTomZ!&^C$=B&SsQa zF+e-mvRZIL{FZ3gZ&qCtcIy}knqC5EN#3QF2HHi1c)F#Iq7(XJ`C7MZKS?s?38$u} z5DcO{&MwpfhGz{=Of?g}neXYwJXN56xYHdvMLrpIqpO9FJE-DY-9#|Ot87${>J>#k z<&Il^^}`=CQ&WwgHt+cWlKvzhO`eU7o|11JheOY}PL56ZCyI1o_AHcfUiTagoe+Pn zg8t3BgK#sIpX{Je#lF6Dq^>S07`xl{?SfBTvANRPWdzon zkZ7WDAYBx!kodUq!tEa9T;_e6{g9FQrv=cg=Bp!)uw6|b0#VS~F(%IvDWZoK%r$_> z6QBJ3S`&q}^ydA2t!bbxd&nQu-A3K$^0}~_1hga1S0%+?)jcHGbyohxe~ZK{?Pfy2 zryL?kY+578N*#f*$OLxK5y@wI+_HWh>mfber}bl)$&OTRDAtk6vP#g@5t-e+(%kqG zqO1(D5@pAc6xX*sE^?e*piF6F7dkj~g57NWY)p*A` z)QnY{)Y7z@8}0@vn2$MHiJ6nz?q0lp+trZWEHV(G*7gSuXh+74#SS4Zf$Un%URU-T zc@gaF*M z8oi@tb_;h+=P@Qc`!nmF(N*`iuEs?+RBi!tE_;iqs6;eLZ!PRhk4T5(UTOB0AkWYk zu`{W%|Bd$s34_#eJ?6{tTZ(eFu=qcpzuVMm1~Zq_NmH*ECp6GM=}9U6C`N8c|N4So zoNN4P=VVZawy(%6vtj+V36{Y?*a2}XWWvjtA zMlH)@e)GOwlFDz(JFv%A-+NrJ+aefjy|RtREMvnSnS$hGM0>Z(z@6!UduU9ME3os7 zw#q{&HB#U%hmg@TDz$}&G4V}gGHjOlJ9{jas$RNrYmDPRem($LtP8xgj5Eizly;B# z^@o!JL+&ZwC(Qx8%A2J6(8+esaWaZXWCg#CtTRRb7XUk#pt9-xkF;*Jq!_LBpCF0W zPyKT8FJraM!Nk(rJx3^gbgbS&jFE5>@|SGe`<9+h6Nq8T^k}77v!419vA*LoI*hWi zV_nNez6B#OMlTK}PPNQlIbYp%y|Z)`Hn#j_OyS;9V=9?8{zGHZk#aoD4(HllclJgP zeW-*s_s|J|ExsdK3wJplr+JJ&L4pI4nhrr$#4EDur@AnC z$bvKpaiKW!28A-Z;~*-vm>8x63yGUg3e8~aR6h64(5UA`=nfMk0fH>AMyq&-r;8=k zc$td{eYH8QX-mJ*&`nO*56Zupzv#vBW8T6eIdc6T1K^i!1#eavMJ8l3#A;^le34mZ zE;v(j>f>TNC`9gHt2Ph<_Q7Dxs06e*>Hq=h7Q~Rje1rL0{f#q`KfS*#;({{z*EK7Z z^*z^#r8XqThkA2MSK~%U_<5t&i9E=|{VVyrDwKrYJ;{jqxMlMG;W^6dbkVLP05HmQ z2a*-z`VxvU5sWE~00=K+mE`n^jpH2?JW;N%g}$M$Erphr)f$WRhY1e%KR(|BSk^aB zYZ={&hUgA`BFH$2)KsO`Q*m{Rv}WXC{RChrd>}vw++rtXpFYbTt+g>5Z6&Dq=`@km zOK_YmJI>~l2Rj@+GAegOgyT-YXwNI9&B7f70O`!z_(!;!WHSAuF@V0NrhF|Cp2}IM zj1n11>I4c~j7)Rba%~>|yi9q#shNpcNN3Q@daJ2~{rfIfQ|rs?3utDZ-*!};?QOtO zlYZ}@FmBJwPyQF#c$$H)dMiFj`*)$IXOe8kett6@Ns{~b!`CJ5N0*n@9!ig@&KXRA zGGaiusNowIgXj$E#VS#>y-t(b#r-$lKLEDCp~T~OtJVtbTElXvlKlA?iIq{~HS7kG zHtCnof4Y1l}+6 zpI!mjy(kj3rEXPAd51R{WI^asnJT(bj)a;#NE87)Q2#MuwKzEyMMshkPwKS&x-fxq z*bjzA`p6g)zdg|}mzvFCji;o7xtPR8RI$(ZRjSYB^efvE3iHH7!|G_YhO48I!wv1T?+0W^U|3!BKw0UH06I zJTI!8o}nUapTS)6=uSu4I>j)#iBZOQWL2|FEs8$hOaw)-B$gmWl&+$0t8A z?vKAsMvI&k&APZm50BHYeK-Cb7-IHnDqC+z1CQU=d*}?AkoF*rfKTr%(;hwYxlTIK zov^`j2+%pXmwSRc;Y`N_rB^dT9QWc}!4{ 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 z-%Xg>b*FRNo5D3;Yy#~kXFeBI3TlwYFHbMndD&Ts>+9^!0-{B!Mtw*Jje!Nuh7v#Otd5)7gK=oe7wqwG`$~^1!t+bNO#Djj(@urmxmPW90ol zOY6%8=xFo==Lv=;;np`MxyN5H3WefVWg+aOyb9UMYiS&E6IyN|Rp?M$e(n9f*jJVP z$lYd?6R%R%t-T!=0&=J*o?O`BY;S`5m>qWtqqU23k5)lobinG2oi~MsAu;&G;9O*! z5WnE!98oi|-IA}<+45VYQ-4oLiKA84W3n!5Msqmi$q>JZFYcQ1L-c)KVq|gAn9^3I z{a^Ww1)cDhKXAdrqI3Bs;yg?pF}jDJV>z-ZB?-LR5PfD=vR6*fxD|bX(e-(j&{D+g zu`@H@(XGQKsFT*B+#*x(2t!6A(F$o#S5)b$J8Q(NqWKTy?j9Bh*Q+2-HdP^LxIx!A z)UjaL5^bX-t+>QvQ4@9f%J@6P`(=^ZS9A^La>CK?SD~+Rjp{C`ng$h}h=Jr|>hB94 z`^vC?Hjs+*7l0$@$gkBS${3G*4vWc|4sz5pJ--qX>M3fkzb1-4yorf_fF4F?(;(lt zNX`qm^*booiz12}imd1h4#L9il5gpZNyRU*KcYn3t$voh=cW-RfZ?r=aYZ9|bo|KuY+zswJE6 zc!uve$Dsy$T?O{Mrt634qXT6yzOb{z(`%ZqmXD{DY=bznxw*gnXkuv=7lO{uy+i5@TUb4Ggo^oLUzmYxi?vkPeh>s~5O>`%gPoZ_h^)OZO^v~0B$Ez-Yil@WNF z4kNhlZAa#el40bO;dzNymZKe0PodHs~ox)rVO_rLxt1X z29oc>4~Fhr(Xlx5D|&oL(W}p9Y0hO&hpWCWrsCWKaLk~8f_yS@mVo2_G_R9==VU{! zZO&UUlPjAp$3vaN1vLzL2e?wn10)i*@TZ#PlqBhGKRho<)9(FJqkY+kO#bK79+vl8 zqwQ!@hy_X4A3ukZhj#_TOIp7;-niHT{$(fDinGYTp^KOz=3WHg#3&a;+RvvVvRt`zK#0@Zw;Pl?hrpd&1WR$B&bmUin>y2QZ#N+%U zW!E%^JY_gRyO&JQjG;Kbk!_p{q)>MnyG|O^$qC?xENc#@(sHxX3wBOG-qKlLxpgpo z50Zb?x!#ucrCMo0lZtoc>6I754`6fEUstAjZEi(5GH5gc0KK2$Y@7MuaTBf>Wta-2 zzq%n}reIK%f&Nibd96NY-AuB?r2$T++i*PlC6>PQHbZN~%Z&&EXBT;Lr&>63CNf@` zh77*Ptqjh8u@nM`vwxW%ju@q@@8Q7N}!3#^>8x47N9(f;7eV;;LjcHEyvHV$3L|% zL(-9`(UBj1e$1Sy?Cnx80*(D^;`8J?iL71Q$IZDgTnrS1>5nO&;us1AXb9f?6O>q zY`1FFGUWV;`9%TPTt}r}frHJ=3;1@GVY6HDRH9aLh&ccCc+pHOjH+)-dnhq;|zcZyT+fy8v zGPD3;dy$|C(O>1!wQ^ESRrR~Y;~pJkqKl&@8)yAR%>eN1*tMMOeC56&vrdU^9V0RBT=ZnycM5!4vQZ~31R|=LbNA_oy-NO zIQIaoOQ`&s_6<*ZPS?Pgq-k3DAvyyVVFMVe`&a23S#9Yl-9alR=XulQZSR@3@k6!J z2wjg@WO%HyrN28d>t3maUK+J=chT0n`oa<5@}5J|T*|PzI7BGDssz<$>Aw1&D;C<8 zoMJ)P6AH)|l`-UmED1AZ1jaoSZ(tnS7GKO{3frH6i`X*cI!#*$i(Mg$*Zj%F-2GNR zA26%t<2czl(*O5(y>U}|O>W4OE!mZtCw03`iisMAEdukgF+=ah3VWG^1pBMLd8~RD zjGac4_%a@D%~hQEnO2fRbk)9hyR{AAt>|Vkqoqe-h&-}%NY*6l=O(LYe9uL?r9(SZ z^xfsjO#zRA&dyCzjoh(EZpYx`4_6fP@>^6GuKq|-tv6X9C-5HelmtiF{AFM46yX## zF1Wa0;5g{b&i9$q?tEs6E$t_G1#{$hOC04YxZw>DL4O=gD=JyOh`(yl$z%w7?yco81Myf8wb1ZEtx7LO~KwF8M9Ufk25QucQ)R+_+ zvx8a-V8j^Yq-mB``<69~*}~k0^Lq_6elsasON1mRQHR{3F$iDt${;F7Z95+@L zZ&}rnJtmY+K<+pdS$rbs>L+iB$`+|ZYMbj~*}-!#3d>>WzvGuefURlvma%YBc~d?w zZy;teO2~-$;AG;h?8HS06Q#1M8bJf1vmAO$(j}AwLdMeek zZ~HthqgAe4neX^nQ-7A}$PQfVEfMb{D|73}9^>8;gQ3*Z- z0ER@?5JW=#&8&1>5g;9f5O_EkYe)|`vQ|1tGAIgm-xgj-TSBH~{xCMh=$wU0k$PV; zMT;=vMm5oAu@LDaJj#MQ9>arA*Os92_8pIP36oxPe6_TfMCDB-7OTnNt9`S%z!N-> znl)l4j>|=UyV669bM^6Uld|+{Zs5+FozOgtm&}AM#aL?z03c2c1#y|uuN@yDpi)08 zDMrOPy0CE>myK2q0>WkWT=UqCXF|lFN4CZ}2@-q9l@IRri7*fATfFRLuUKL(w@arL zXiuywKFPqN`f>=%&g2%DbH??HWlMDY+4x_dd0$&oc>~9bP9HEXhMd?qpdfEPf$3-R z9$k1q;_QOH<+Hf=#K)Zf=$rVkVl@DCya{5Bs(EF@5YH!>@4(k={rFLU!+b)<@_eNg zdGC2c_eYgf^}Ri)UBa-Npc5{_jhCZ`=j?E0_}>+p@YOItUYps~lKp&**ER2#V2PaDsTIEEZP%{i&sl)oUpLyoRUUEM{rv>PiF+& z`7#W+H22D}#4Oe>Zd74Wyo$qUU@ao(<%3nCUwMApFI3^LISdV7)?>YOt^S;F@AbEz zxxT4Xw_%imZ6f^3s`)5eaje~`1NU7h94sOs^p;QeIPN0=K3`S_*H`uH zJYf}@{Y&etpdFJ+;&ki6T##AgPM#5=*{9-cYy=5+=-T|eSa_$!8XF$H9o(N~Ha+=# zdLgfN!&e}$hvoh?-CjedcQx$`gMtv=Ex9uHXNH z{0ZoAO)`$ImnX~Rgt;_=zz05C`=dV%>bez4-8UupJIzH=0Z#R`|8ah~M4 z>nFH2Qga_UdkdoiR1`(G9Fcyz&21@0>9?yEyToinNWT_Zl29dMOPxVI+$4yOHtD8R zs00s#G526*M0QXC>y4)udmgOw;8YCPH8Vz5N%R#nrO0_@o_3xRwD+F2>Ep5Y_EMf@ zLIB|GlxEmIt89MS zpR9exIF%eudc+uoCQi8oObma1hDj&wjD39J+%C!nZ=n|pYI*|8o}^I@<6@{? zYUhEsiPB9P^Q-1MdwD-Em;xU@c6=v|WXrM2x0X!2KPZ(J3>MgKY)b#pD1U6B*<@f@ z-{(fRW3w$t2L$%s^vKSa7a1fPxs_w8YRjr^l6c|uUEu=u+rRAv^5e;$2*%e8TTwk& z=NN65jWD%Snm_XUg)awBcN?VdpLlg=l70}{Cb5*t2=S8Pboq~2o zN@g$9*K?(k7cEh({UoXT<&?d^tqmJNirH z&84PBA9+EHxOQ7|lEwXyh=B6l=GxoN7*0d3EoHQ}n ziTkE@rr@q1BId4BYFKkQOS#!mNSB=bh!mtGc;hIjkdtp|*2C&>+E!T_MFBbS@u_$BU1*5>Ur zWCMm*WnkUlvK6!8mHEv2>r%Z;fBUV4r(h!TF?lMXNCmVD1+ppLL?}C#$ys!5)rxx- zX(nlkRYpa{T)3D|sF)x^Ug0ex%x*WTvM9W>Zi>Kk&E12n-n_^yhPs_}XLq{s=$F<` zxb@LU{C6M9eQFM6A}cT`NkyMdkMYn?w&Y8+3o>iP;>Xx`-TA%FPa1-{?kbI+oG*Y)g6l2N&O^Muoc-|%*gAQP z`D%T%%G&{!FLtcTKsCRjxb2*Ab|N{I#P*{8Th zCzEvEKyJouLFa|f9%A?^(<{roHy;noEaU)N65*NIQ;v!k!^5Usv_$iDri-=p?)1#Y z;Ckcld>aM&3Fx11Sgs-?wMMfYLShw=JIhDITV99XR@Vlq_5&S`zKpJ}g_8kV6n&5f zLil6lyWsIhO#(_b{<*_k~3q0h*_(SD28^N2QJK>S;A z2+A^f2$ncx(Mc>gq3ws|3?Gi-a?pP?cy!d9^6Ls!`mp(0DGwO{UUF*`EDtqTJ7~ha zJSv2*g6|tr+r?0H`D1bvC?A+1$cUPOE%V3w^^tU_0=IE4PHAuqIy!oGbo6i=Y_&kZ z#FHK-)mMHF-WxV4)OL69m`l36axCkV(O9QN0&?JZ0Aurs5Ss>U>BnF0H_z2q zxu1Qem?gt%X-6yc$La83tr{twh-W%VW2uO54zDb@6Gi#$^q*?}v2~PUn@>k^=3vJwy08KuFqO6{CnU3(@$-L04K3nfSa_Tj31=J?87p7OZN$;YN_kLuE_=J z-tlAaG6JeEF<1hHOL==lYQMGs%o>LK)Z7K2F18T5n}`_RS!}6#CUX7Dr;`7o|F53> z=ev`|5}-?W&c~&(XnV9f)LyLKwvj83^&%^HYdb~gFbdO5efDp&Tl_z&{_-C^tS&cP z>jKadE3vQ<*Yh8p|NE-z8XOSdd!{#`gAYze9>bb=^nA(23u6e_wF&KZ#0BOQFBOik zGyk9J|E=Ob*T4M-!1(J5##HN2NX*XA9p=An^Bb5g-`=9?-viJ#I{a{`6UBudB3?jw@Nbp;gZytl1awCv`D4g!OIIVD zi#HNE{z3lV-lFPX2VQzh@G=69;wF(0$Bjhxf2-slz`SD{K>rW& f|MnJD|3qXP7abRX2gJcsA@JZ_?cXZ-2l@X2(<9OF literal 36780 zcmb@tJq*1d zIuHng8Nu`YC27OQP!%U5(VvOlm3_bBREWWK%r&*}DNy(0Hqt4=vOjN)zHG9gXUQ_3 zz#{QR_q{8}nQt5-S9xSCWh?S*iJ>2~Gt+B&_@SyT*Ug`gMG2L4bK3XIDUEy=87q}7 zH6)(mtb5VD{(}Sj&+cW2Bj_i*^M{rvUIRliWWT&~e*6f&@AU2hPJsI*+i& z*VHbDyIvnX_AJ<9!t1hG@v6*r%kUazVk;RA3wjOoCooU`zq>C701HEla@I=);4mFH z1n%;BA4$m zkzwD_fSY7nLdbdAv+1YabeR0L(H@&%kk;HN%>Ct$=$__6DNu0%BSi={dYK>^i-#Vj z1Wv-lOvNTGS$w6cc_eGaM}lhRa1foy%+D$xDmEjSF=m^YOeyn@%Kq4A8h;aNuL5|o z42iEtEr)-uiTT1P58&B>I>X%of!#*C!Rx2@!6#2n+D4z84th;q#S6WuzjLwhb4lfi zb5s0(Ck1YFEzVBX(|N1T+B--B%I~`XpvWR;u!x~BJuG$`#z(_%DgbRb9@K)POQEfT zs6g8oXiBhhknU4-I4l+|obY!xtmf71BiMJo;_ucN_B=2iT~$)U5PHT(LQ>8rvS^{< zO~mj}El>|T7HAcUL>4KJmsja_$%jpH2f>lc>me=OklZ&;$1@8hP&N`XBdbru%d z0L1#M|NJoxG`zJD9bf>eZuc6VNDf^Hy4)*?j^}s108GL_I znehIFi*z|f&Xnks>FJd86PQml~x;u_^B zLYcok%Ix&@^^KQ{PJj!2{fED;w44c~D+I9%_X9Y%{?C*u9;~XGAFehw^YE`_Rfl6^ z8>iK#eD$|gP5iR__@vHNRmmLW_cFLxD8ob9|33WRa|Hm$c`GKP)rVK^?uJ(fsLN&1 zznXO@57o`z!4oJc$mH?xAEEW*Ak@s9Q9m6B7ZPsCMh*(GM8~khk?8}Cs9=T0;;%D9 z{v#BEX)LLC>}-iCo*12o1cONE#?7y5K%ns+29$q*Fx6_x5ifMq2Gb}xOEB^%mxM#5 zBhfM4_~>H>jM~UaVz_vuO~PG!86M4jPgHkImd)ewVlg-2YWVwAhvP9-CRu1K7#M&2 ziZcaM`@;^=KZkL$S6+c!ramNSo|)9az}6_;ah6jS4X_zPusag`zcbz^+yR>W4KBqu znG~~yFEUmx@3=MZ<>p5<$A%O!>CuB084*ZKOA0j9*L^AT0bnyCgboCjl1qd_LFnex zqy<_mjmCK1Xwv-M7=&O(TUW5a6D7w!!aO*KlBE`V$#KCmdCIKYI&?;VJaW4g&QPW; zmqul_evk2nVWzQC5%zRr7gv>k#V$d!7dd?6pTp7JJtvqKg2jRQ*bU%<6ciSqwN}~I zlZ<^KAP#5}T^>EXr!paU0WdPC4gT*8s8}JH56E5rd)^*ge?RtZ_D+&N<#J+IJOA9E z^^3=5crXCOScjs%oY;>JBBlg^v8_UJz@3j|1VqvGrV0@AY$C)DgdmV}PMMi}w-8J| z?h21hau5l!1Lk()2x<{53;`t+4$)42GwPAb!gHRf7?UXMJ_Q>Qu+dvlX}M|TzTf%v zPOBtj=pD$K&JO1thnzMaOOz20E~7&ZG5L1!9JkauiRx3H43pE}g<&@&ww-lNq_%V3 z=r_bRFUhr7RFs1@Hh`aqTmgBel%fGlPuc%F8vq#GT>Lz^w|G6NGyShT0DCNVB4a4Rv>iS8S(-f_(7mJi3w|9O?= z)R56XK60^S|0Fwi;$p0(gSpLkX2obXzOnmTepCHjkOXl5itl%Zv4g7DAn;|Z_j4Cv z@DWp8Xa2<`V0x{Ytu#+o54H__&Jv6Ya6sDG$WijtNPJW)DYQ2yY!Sn%H=K+nU=bur z2SQ3g)VN|wx+AmHWWZ2l1euzcR6+M@*%-HsK));710j;wb3vYw$E$q5dAU(hj%{DZ zr-Q-W6Ca6a{*DACEI~A9zih7&s;S6*8Bm-Vhk_WKJ;;ZKiLtuzF(Ju;@O}F4ScF+g zZ!9X_Qmdg02>nQea&U8cMGxj%q_&H-j3Dh-Tv@;K zsujDg^>DZzb?IK?mBYzf*g#2Rq+Sl2EfzoI*mV z)Re-fV3BIl-Io3PoMT#~W1Gt$$n((DJ^{>k4}_qx=P$X2v%i@!aK1zlV%gXJ(#+!S z>4K~mq7MOOFH7kmX)r^jZ?oh;udIB~3liDFX%##|n~hDWMLs~++kbe&V zqZ5A7FDCQvyB7C<0KjfW@V)S$-^9AZfb~dMEj(+mw%~ohi!ZZKkf;%;n~rphc|>60 zw|8tr=aYx2>pJPKL@P6+iR>x$~61ObFh{LMmg!S}z%=imR#bV=G zm6-z3FqSa%%*T6ltbVf}m$FVnqDV19oPHn7>X``n|1W6kNQ(!sQn$|JK}! zC-$Q?Hf~iuf5rdzR(Cf??qYk>e{Np9Yq5Lhzfq`@5zKBoa$t7_0QsI>!TATlE$_z4 zyqa8?2K;EW8>3RdEG7XU(GZX$ajX^ybN*{ABoFD=ZOR)<3zF-0m5qghT>JW{AVTUP zOOReQ7!o<`0VM@N$w8s-DnB}=gC7Vzft>x|R(ZT8GsF8pi20JpuZhLoV-(rY@*g2k zceir*cb`Y>mQxb5VtJ7jiXK9k%?3yM*BR>uVYNmmD})& z??RV^S$(eHHN7|QeW0Llt+x(+QKvm7)=x@Xewf4V32<%f+qqNZT}XwwYE2K>#uE#j zu3#7>#BM}V%7I|`*s|C&<^n^iy7b>cFcG}X9u-iiXe_ZUR0Q-rGR7!}G~Mdml37-) zhY?x?4vl;y2hngcG6bIb?st!BS4|qy1FF)-^*zOXfQ^W*J$icqag=$J%sqGFd6VwO zt?}cNET))TGb7ouFoTl!;uAP9`Sh&L3afS+G{ z;?wcJAM1=3FzG5TYnQO3B7L;Co2VFNJDgNA-x)GPHrGAn>i?dSv^S%dDT1HAsOhk< z_X%#wC1{9+f1hT36!)@KBNv#;&FF!k(w5wmqPi_NtQKV z1HscSU=;X1`!tjDOap--&!mOelz_(A?OFgw&+jdc?t2xvOzGy&4h~8+iqWQel9`%h z!TT7mK|w&5m?QIVH@~p3>^rg76~kQ`KEIWkR2;njVBm0- z>7$v2fbhUn#I01zWgEGArlm$3(oiompj1scDL(DoJ{@4GzZ_?otLqSKfC!9%iVEo{{q6Z*cijj;DPoH zgr&s=YbTcFBv*w$5YmO5NiwKDZbSLd5dszEE(tu+*xVgQmAYE~BlLlqP6-+sZQ0`} zu4dF7-5r9dMk#YR0f~(zlQo@(ve{FDSj3jzYg1RkJ9KW8raXtuh-Zql!=J<^h5tT# zp2npg-kCl)*L0Q$&izx-)O?U(ZgFe7NbR!wI_7R&EZq=bIAgpFdzolg{?|mHvH1BF zaR1!ZedVBB`*HqBvvqdLsH;Rufw$34PZBQZSqKOT>g!)fy~NR4+BWU8GBQ?&f43Hh z(1t_#Vc<97(Q0s`QhowVsimYEECo(jc7PQ^IuReLt3>-Evj#8P8ohwA3bt4>fAS{d zuWrj|Z6vwbi7ac~Rr{h4X9K_ax6>Ha*VVxrz(Nb)ekIu7tgW+n>`&=DDIRncw9?XY zaq*_(@(-{!_YMAoFhSld9=*{K{`>1kT^+)}2qlb75FHPk27^pi3y%*2qZczV0D_)G z!VP^Af=skY#_{}6P!`!;awA3q8i^AXoKuee;6;UzGpv=kEuq<_r)tQ62I0`Ld@0EZ z?KzfH(oK3G8vQ(IdcR=@|w z0aYQB-{g+TuEB;kye;y9L%R;R-0Y32AmTpz-5PTLU{sBHBku4k=H=nvG8piF3W+@T zApHs0l@oSn%0J|XgR@7Giv1@7o94Z}5_)L2 z3Ro=(j5L`_^*Iz|G4{oU3pX5C61~xZNMIvM%Gq6h?Noz)H}9+b#N6@wWc0&g{T-?^ z7*Ggn;*M(WUp_h1 z-X^;5+G{wQ?3{EIaC_5~zbSG5__k5g0szi#gJ#FiQkKmvewFuRQWw6Xxx%FpxMPDG zgV-MkWkSvrnNWltOTosYwNSO3IwXn3ZGRm6xdl~iXD>;CkP+Bwf-#r_XxeIq;4e}( z;W@?N12O@eCbZBd%)~P?MMe^gCb4kx18-yx6wVs@!xj`DK4ZztCfzSNO9D}%7RMDA zp$Sz?{U$8>_4Ue0YiK?{*cH9sXq{tj z`Df$Ba|-Y_7B!S5IgLn#faDpEnl^9qv1-?UWdSffndg6bMn zI2047mk#%_D?&~HQ7@A3IZO+!LTm~=2ID|$W~nt;5n8x$_%szmjr6^Y&_wiKup)=Q zzDfOfAgtjRsc)G~4)JnuNn51R_nJOZX_fuizhjaqvt)iW@zt8Hyn2siDSc1p-}MzG zN%3*JYRWL@PD?Owa067e0mp6$pP4Hre*S8ssial4Y1Z_W*R!EApL1R-RTn;Lo@qYL zY&fepo@aWmYfo6^MwTV)T#;UFMPnG|%qT|J)O#?GHZP0$n~6^d*XB)fhrk+EG2@F~ zlc*+$U+>d+Mzo$;+|lhEd>e8c3`5+Iu|C$=M1>IKJc*IEb!JILK?N=$s12*h=;L<~ z82p(3X~YC*8jKd!{W|~OXHF}|yn&3zE44K720b`fQ$A9*k~l(+)O5nd(+`BUW6uwng`3C_*5~@Z z29}L3QvyQDF~@4_C~dGgt7}Am>4619TxEQEQfnbr;T6bF_z((Ak)aStd354MRah^l zJaHa$e*`%evODEi_{EYr0KRpbj`ng2K`4WcfiLrFQ1tzQ@ z)EP9CMTZPIoD=4{c@8}=F&%99vPm2}to>T}-2SV4e=Q7j-DQlY_ zr3j*>K+2ce5lv7{PZw#35Q-3E!NgY|ou{&+eaPCE_PKZMJa`l{M~_izE%4{Xah}li zeY}FXfqz^edtpa>U>Y?C|&tAN4= z56$>-2p3*v^mWd&#Y_qwD34**Ixf_@CKG{)!=~93bn~QIlu4z=daO5DXfT{L(fWmiDySx2~GRMBF>qZi*{?u6i)M~fCd zExYilqzH4AnmkdQ)t4ATC2KjHITrWE@5^Mvav%0Tn@L=d zE#6;t=m`x-tZO=BcC>?V7NeeOP_a^hB?vuAWNXYS`q$p;G7(D_K9*;pU@I^U3%8Yl zlQcF&!TmY2VK`$v-`0SSbLn`X2Mqz~AL!60r)?IzkhN@BIeDYPbqL;s!6TE`Vgqz` z{<%5fAvHInbBi=wK~hWPwP(=cn)X9GNRvG6n29-t>_$EC_JS+SAmYla zE{qmGfuVqmA5&Hg7BeY1D*5^3KIr+{&DHa?ZCXSgZMEEpOr$j%&0EStzg@Nn4p=Jr z88q{$2-~GigB&~j5NFmWqyTQoDNCPt28X=no7a}?h=tpMAkne6=pgCBLYXBI5*ix$ zY$S}0Nh=O5!4Cw5ae-3h7wDofV18_ZLQp;!8;l&+K3*d0fe;XT0dtYVUn9Mg<#vRj zYB?>)J8E~=a^!l86{;U9P@@mGIm9I>eM<2W7prwKuXxY7>^SJ4r9*h@$=XfU-|HEd z=B;~oKNY3cWNm@ZRi883Ypq3ISbdu}9P4^^plzWusXur4ta98?+iNFjI3I7^gT9l4 ze(AkUhK(YSTD{}UuS3$+D&hZ_tNSWH7_d0XxLOGwbh&zUgjmMv?D23Gh?yChw2o5ZQVT zTm`$CmC9clT<{SD4vy9Xhg}Om6R^Vk;iRohXmW?HRn&N?+C6$6GZzLS3x^H0U=Sk7 zkz4Fi5Oop+(&xn(_fSFaCRhyz$p^9D}Nw#6n8Q0+KaI!jk@rl`dsQ# z)DvrZs1DV&SUuEo7JNe`QUv^-qC61r2P|h&iH}eX;hJ8{S3V4AWfU#@A&dr?ikbUV zI0FnGcjOn(hU_m-Y-)rfI@vqE^9-IIUMG7jBn7E?159rBAI>KkAQw}Q>I|e;YTbc++S-yVT z$%_49l+UHE^aKDl)~>##d%k|{{XT}g%5%(#xb}h2eB6bJi|mJ0>Qkm8ga?T6lJckt z;_gw6EN`(wf-;sod!b^M(^Vo699f_7>FSAqe7XKQy;$A>(+yLSSf;7e?g|qKW493< zd2GJiU*s4iVL6?Eh!PJLJca=q#>tfJCx?TEG1nS`2*Iqz3$e>9V{-+gAtJ4z#@?lG z>j^{x8D?wLD_a~jOx4Wh`wh=c6nqX3y#|l&*AFw=7Jb_X%Y`n?j^0$obvd>r9J~Fx z{7_j}fBuUy{crv8KmQN8Z6&SFpSewJKCtiE&Z<#Tp$+|!{_JFVq4@hl24K`%OX2bvwq0%po{43fmMG}Ez&^ClnnUZRW*N8$4 z{(K0FAx=M)DYq|Cy6J^C=4E~qwSfOzeso-N^jY}$&4pR>jpEH-@OW_7UH)2=uP09i z@8^7O((Ha0?xV&giQv3eVEt`uPVnh-py_t_!vmrAxQirLS^QO*84dyzA(l%@LG`%V+~-WFRL`KJ^Ygn>!N#Xm|@><(cR$$w#kqi4Yfg@zMuNy0a%ycwfo z`Re#qJ|%kjBpR26Yk8(hR~}p1h|D#TY7zP7rxI!P6yO~;xAAXdr)NeY)=X3kpb$I8 zAG(MTE1{8e@0$HsxA!+~EGzI7ebsD(UG}s@pgQ>{~>97$IJ+*D%+~W4*X;+Q5CQESL2ca00Q0*T4x{>|8)DFJ3 zE!2ii6-KYy3PIe6~JEVo`b3pNqrsRq8_8{ z-q}5wrN>qbV=;ZiiA!%AkXYRx4x2*KVM>}f9&SnElJ6k6bZBsoZ*;UH>aM2mpJnra z6YGqFCo6k~t$KN9zhz}U_sgWnt*SP7?EOHffZzhpRW@an{gnM>ve<-ACxHe*B7llu(vi?V5=2g{$!RavY7)3Sm-5Q_pu4wpEOU-HSe4%G|c zMXn~CKVgDHIA>t561WmcFE~Vw1MIZ(4>}riIFp^S?*10c0Ae94dMkTIS-}wArfQ3s-M~j>F%xF-nFlb8*g2Lq-1O=@t+7kAfZp+GA2OIOOR3r$D;PfiUOyg$~;o-!s&W13>vtvB95e z<1u+~Y1yq*BL@}Cp5$cTt0x{uqRcJjMw-K1K@mIBGVnL|G?~|2acgXo0TvL^kcRFA2u9DSzsvkHAR3 zn3kRTIndHp=Ox1&0N3bfWYg#r8*@dIt`?D;SVki zIR>`J{tdENs)T{{3cI{ISy68d-24iv`_#>Z_^-AqOQQcsD^gH;q6ODk1eu1ZmN>y8 zynoIHwX32epqpTe_O}-+6Zgx1-XLt*4!G8HaAjE{@xLNrPqE2Z8LfmAaGU5~<-pP& zh3j~PLV0FM8}OhQ#poY}_=rHW=vu*|2(%rAuXZnI;JS3LOIl|`sf`lbi?KRAE5s=f zci%UD=xr4+Z_#Fsk9ms-Bzw{33cfl}m#BJq*j}IMdLKZO`pQtE!^fX2=#DnOUgvF1 z^QZmevB*-jk$$PVfCx6kXBw&pLWtP2J_?1$?Z%s2>V6NE0;zEmF|VttR>4CaN?1~Z z^cLt7YhM4m=S*YWG2LtJq(qebP13Dx&`S2DlZ9QrnBlZo#M zUK848t+?MA2Z;eA1&fYsS=eqxYFy<`8#vkE!?gKX>8H{11&jLQS6Ae{Y0sZ=H{34+ zlRP*6Z!>?~eGg2$pW$%xB0XqW3cmXFd(=xK$LVxqbUl9(IG#-z?xY&Pd=+j@e(}-I zOAWX8xObp9g7;?kc<;aYP(C`$6H4DqX-`kBcTmDUn(gh z1|vBpq#$B!@Ml8Q>hStFcWm^sPdLa-dOGBUB36m(j+bGjX|FOU&;INbGVGR_4oJ~P4=0k0K`~mvZRm~UrYb-Bv z_EM{!F)R1KBaSY@Q=l*g?t*`r%XROp(yA(Y1GlXxdGeW0?c^%}+iY#=fX;1%OY6(_ zh;@V!1(*PA2chW^8BxlMmE4qDom(2ClDD0eB8jC|*zAY0tK|s5zV!X7wjszJ6Qh12 z$d5+H`1@;YA6hU*h!@xvMstJI;ZRv$ErgohSx-k(H4&VH*e zT;|O9S()Oxe&+Mnw$|+O^}}yMHBF_owQ8~N?AV7OLvol_5MpsCRd<91;GQ`>6uTb> z!i#VqA3C>v@Sz%l^8lAV6rnAy@rNn+rM71+s@hgWb_o7AeHe+!ta=-aN}yeL>l-(& zBR2`ynyOTuYn4hxL<$J$Iusf@0eHWR4d(z%z(WaxpwNVW3Y>xkcYr4okjaYU@#Zqn zhq7hjlZHw&%4?-@QX5xfrq1GKo(mmm9%DqE9zXxB;UQFL96xA$cuJbU;pDQf;2iu? z_us33+*P^1=9cRc6c+?kp2^)v&~N zR1gabz~t7&0DXgFZbSLRx5+Rq1m-|dikpL>FB2o)u^Drd)2uWhoVA}(#a_O821L~n z7kwwfR$H_!J2!ay%0#lwaKye!dohz}W+;n#&0(yrw#xLqL6I0U*9)=Ws*43p7Y>%l z)Urr+ERr2;WRn=)LSM7HeE$u+WZ4;&-`!6P{_V(&Pt)Mv3A zSquTxLSud0ImD?iWrgIFoBKaPBySstHC+{vA0K=uVOc8-D;@!r5_AS(plJ#65z=^z zVQBH=YTIDx=jtlN$%w0{#>yMs(fL>o29~ORy;xtoQJyv7Hi)YdH{?^v zJg7_ATYemr%fDW0Yq2YHHwBCtDpnpkI7p3XRS-shA7cg^odBGFfY>MZ{y~fR_YPh= z!3(G;bOBt8X9$ko{-`{@PO8v&X@0qyQHj#~xss`w^JWs@J>s-2gsC^_-K^}j z|7>ZbhzcX%Er_1?BoXX_P%CVbq!<=36WLcakSd%WJg|k$KnSYU>8AMddHmdfc-aNK!| zOD{eu4RLUz(h$p~4nYGUuSa=wolqeX?eQjLTLCedgb&|e>i>ZeU!+FL3TMDITBuSP z9Kdluiim5=AEV^tr)#3TDUe|i-Le;tNke4)iZFp|O)RR|+Dc~4=V_GuEc}$Otor5! zH%npaNQvy?tHv(Zw#y@XYgPw$&gQxSvGfvqV&w!`LUAno7+jj;;>W z8x)c|$$)Y_{nWEe6Q*+LOd}ZT$TpKH$4Y%cAL@ZaMVQqPQdC7X#1R^$6`Vx~N}{Jg zWP%!E73g(_`=tAIx%_Wv>_|CbMhgylLg9M9p8O zQN~-$M5CrhE0-_Ksw!Xp{Na1`GsVPw&i7eI_UC6mJ4Sb#CZgvmM}mo-{I$mlDy;?bRP>`B z5UkME-XkM5+Qq|O|3bAV(SN59qH2uqx%%W7VmFQ8q2vbxjARfsCPDbr1ECRub9>ib zR6FFsQj2O>na_#mQ9;f_ap;rKf1(e)SW*VMboA8s@vaYWJFO$!IeOT?4j%?Hsx_0t zsXt_;JY(y;L%!F zdcIDaLhYp$v$(8(J#I9E9w~D&$8QL(OhFHpK`TKm0K7w{Zdh6 zxU?ndR=<@KaO}Bpn=!jT%CK@mRWc%S0sFsw7;*#b(#F$y)*qA29th3HoohPxepqFn zVdrMEM72X&!w)`GI#k|*N<-PpB=rWPD%r>Ba;%bfEpL65H{SeXWO%M3G0L=4RhZuC zpqHgS6xYRjSJpG2^t9&wT;I~0LS;HRr zlTW^_pDkW}8J1C6+EQ#M3tT}YZ*4Bwaj@^#ybOfKkG&aI1SFUO(Kx=+= zjp*CS@ybj_Ra+hjck2r`_pT^IEldF1TfwM1ky5SP?8}kPFhNgce#{hwx zE5Lyz?&RxyPUnaICiich>tBX{*#~IAXZ!l}Ec)llK3%=`T-y)YCP)z{R1T2oZz>T@ z^Ovw(BK~dxwAPPquD%XSP3QXe+yUGBQi*$2ehtj zCVz7;mz&9Eemr)^RS{V^bD{gqo#WZ@MZmSM^RN1~vxWP&<~b8T_^BxS>vN3y8N)55 zsWMlUK*?Bm#Qt6Xn0*fO*Ek^2CQXiBrN^yli?#nP4%WACbcjK2$5wv_DIa-}M7L&H zULsd&g1#U{9;-c`)f%tx>rvm;3VTr+u*=uX{^WTgvk|3 zSez~j^-jt9ZACIaq&AW?`ic%6j%*DT_=}p;UM8!~IQUNGs$7b-6_@M-41CMPV+%Ga zF-PLeCn%!-=0@7!`LfNlkHs7L68H;=-!R00@er0DV-&H3fs1>K`&M%nhfJA9HUo#u zCtph+iZ#F$=&katckP-D=R;PQss#^Hk0DnOQQAZ#V{A?x(0s8YuvOOs~< z&eAf@vOgDe_i#uMV2B(2bp)Ed4h8|mZWvwe1EHz7b3T+Tw?=#lJ3^pVeJ+Wws|k4> z%axG-MY+Nskug6AH5PoVwf}5cPZtd|%_sJpE>jNb|0-|z$w!i;VZ(Ru{4l-tMB`gi zieagEt=WFp+O(rHBm2I*{XAtFko76(M9-^X5bD(=vYkUbI4JUEx|GH$oP1;VV%I-U zRC4pI;Bn!jo7al~ka2z1EDU&VU!RD+(~MDyA=QXu%|$2=w5g%V4E1*?#fItTZ0#?N ziN_CV$*D2L{Oz|DjfL)OZ_YU>OT`Uu(&&wi2}s{)%7wYF`wNP~Xwn%TF>x)CS5=ZKP z)%LPjGV3Fbdd%wNz=|#wo<@l1>aYFS9YxXAJ!ujlhR1qi#u~~dzZMVmr-MFuO~?O$ zbEqA7R~;>i61J!;${(HobPt$6SspozVinzwwik!@7RgL#)BO%C!(s1-kfvB1sq?|9+Hu?wrp2Z8SS%@@ z$yKaa9=VEsKSl+DWSeJsp;(|1#fqU&rR|r)1{a~)T3k!xShTDDiPtw6oYw&!jf@ip z`9ndv$yb*@Gk@T&?PZ*>``<}YzpBYvnvR*dim=P{7O@M^8u|RJE%=&z?XAWfw;JtG zG$}u4w8rD6kA`?$MuA;`*j+tgsQ;By)O%zuM+gE~~Lz3H9^$L3K=E~#)4?eU+aPHtDn}U)QILA>7jf|HhX!R%`a)>S#qDRSc z%lu+vV`)xvDbr>;U2?xVkB<{vSoQ(gCz+2%S&M{&Qtj%^UEK3eEA_KV+LxjVd6J`x ziJ%M~)3VXo0DJncD|X{@pUSr6z&X@rA~R|L7kmS>Z%DdyF`Dr|DP?$Z+iyQ3U(*zi z7r_QoLqCHj{^RGQvcX*}qkHZt&4Qje9HzyKj!?t;eYvWJ1)X`+-G4$7nK~GQzWzA{ z!r-hTZVc75Hu`D(l8{z&tjWxY-Q8Q4$YrI^-QevRtZ9RGnx6{y5le|$JR*Y~&o@t@ zyVR`Tg6E=WjZ4=szWvJ^pVsnY`$3OC{Z6YypKiXK<{7-ByArB{s_BcOqgfH3^-e=r zJ)w@imK91O8^Be-G$X^%*mv>3tabXa?VG$6ATzk@)^FH8p}jWCdkeqR;sblqC(|Xh zOg?mUWSXxB1TFNiN1AV}91<}}B{#-_vokjIlz{oCa5kg|ovxx})mq9?t0zR7-ls%{ zE|xHC_43rK-(XfB;q-ApvvB6WN`c@U+qI7jRVnb){^vt_^37mgln)KI6h2f6@e@0; zI_q*oQO9okQ3I;_}tP&{#Q5PP3$ zLCn5^*i@#<;*{; z$UmI62^i~_*l1S?beniFqJ{p2LjRD_C;voJf6b&1T_Q>qlkOpWx&&t%4yDf3vIK!X z`ef;ZfO0;j3U>Ni-3S&)+A#@_PL z&#LF^FT2E4V2Q<`DpEM>F9v9nn9S{8d^mYTn<1w)t=%l8rD$U z+)vxnY2!zz=4|)JYIK%c1VqdCl>D3z)0H0^6I~BWuEMWM3W5;^9u}mHwM@_1ftjnR zzb}~dfR8cb!uoxLVk?QeDTrhQkeIQUQ%c@Zq|7+6X(cJAd7sXlRn}vB@U5OWyP1?` z6gG9eezN9q@znQK+vo$K4TAG`sE!pCZj+hXE$Hk+ue$DgGlT*4qz2B873ta&94|4 z9Brb*`XOGjf297hP&@kR5ZwYJCTA7{ru#nx(U~2eMAr=bQoSGDa6xIZ$7WZztqZ*~ zOoCzyp!QCR4*Qw6a=+t5kcqTodYTeLp@eaH$~!%##j)7E50o;5tUXP49AaP9QbF53bf;QsC{|;wag$b;qILSaa@q?3)%6Kq0zkZ zs=NBJ$#Q#|ALv;A!K6VcEL-gSi^^vv za-Z-M(Xy3P5PFfJAEdzV7_i4Be96GG&vXz|Q=+Fujr1=<8@AuPp$^$Ct8P0ux$JZ} zuKH0C&wZ~_-av=0?R%LZ3BeY!!Ei}@L@c(biB3Y5;I?)JZhF7M1A7|)QkAH=ksqj{`eZI{JWhvdGtt0G>R(zXCzaPxoI@5 zd}ZNxIowPo!oE~m?19CrA>i%Jg2(bb$=-DaEQ>b_b;hf6M@8Pg$UJ&!Y%LkA53myS z!my78Bph0nFmBvpG^TZa9-dx~`FU6p7G3ls0kyAdg9Y7RKtj%;sF{+jRK;%>(o16+ z@+6F+O{G(*gRbK?le`)UUUvasvmLE>Yd-geLT9fnWsXonO zDW9x`&BbDRCtX_IMBQ4IWXkn&zF$E3D}y{2bIsRp72%k9uWGczCi|0nQXQk7;BGbm zCmYqBCBya`Nk?JWdgFB{z2myhUo+C-%YIz;PN9b|az30Bi&6#+j4zvW0pr2&X7ZHh z^L=v0P0#!jJA@#uCOhb-@T?5RHt8RKk(jk%~a*j$_(||1ht#{eEBDov`f7%yXDt*fq~MG*r&08zOB>uQ)AjYobWeVmUy?R6Y9rA@n^5vh3uJhHy*p04Gs+wD8>Q@D%dm32FC1DR9T}Kq_OXvd5yiOFn~e*XbRztr@GQFwL+QaaJ{L*I^6j-IG^j_-HBxWljkR|kG=-y=3m#g zXT&}b+Kf35bVU(DX$s^2ML81o2FRp`om8vf_lH-(*EfQl^Ri}xj#da?JFX$#Pb`aT zty|qpSg9?iHNgdIp?4=2^De=SlVmHxquKfXZ!2nrMd!jvMY;QN^frrO@o-9QLlZEj z0Id+fe9QdmbQ~VBSEhUWf!%Hy71c+(fahz7y1%;5-&xnWkd&Fy4JxB6rar=xl3?Kx zco@iP&9>03#?;(f6Ay!o7?1efK?WXRSX?h~a3BRBB*!W!5v^4{5y5;``)u9WJ|6UW+7-sMg5`pLH6$l^D;q$xP<|IiFo1 z+4DcxxVws3Px>3OCr1&kA3+iB1ORW7w#wD$E@>qLkMaM|Y68IJ%1PIi{d3g2>dS3s zr8eHy-8=io!(m_^7#)5JVRHg((!BV!KqM@f#ReAA1*4~vg@ky*=rLel_@JDaBKwRW zX#pj)9d2wBDbUHJr3;y-3zhkT+3`h(U8G$A5#yxtD!WdOk$` zFTIq(UxjP&jQ=-c)xxbL(>Sz?u5W(O<(5g`QS;H@%44%0*^=X0IX<%*s3QAdO8n7_ zDH1myyN*ZQiI*w9{&QVy?`)~(u;gRcYC}g_p+y$0brxm{K`?xZfk`C>@$v7B!lLed zP{6qsm%shW;)p_X5}C-Q#Deiv@LfQ4&{0}`*FuNSO7pbG%VCC}(L-3#-v*-yIlHB?hFZxCmQXXCvay*}*%vXluFvpJV97Vm_c{I8a+R}Jn;!wuqNDev(hKygIpm}Uh%KyjHRRzSc1=+!Ea0wpV-GT>qcXtm2 zhX6r?ySux)I|O%kLU4!RL1%}&-R+P0?Cv|~R@J#Br-goAl}@0JuuL}6P7n=6YMIDM zQS^S8C>IhVG&T1R)NV$AVij3iZis6vsIM{@t`fi7f}t6hBTSf!TRiTNO!&~A8uO0O zDZ(AGZQqZd^4IL=?`_*hO2!Q+Q)lLW)lGT5fQZ=-I>GzX*}SaH;pIsOiq;$sO<%}K zeM^e1COXnTMaeNsA56*9VaKQE%dD2&1qR~H(P?#0aSbV(O-APrU$wijO5y0IrucQr`0ZXrJJ7Y zmV2+{);;5fE%>0^X2tzsT7Se0EcgR*T9U7e=<^rKk*k5w+8s)lcbA_Z4Dl)qhrXQO zcLsxcDlKa^@7X^Z$3EWQ^=Mme0}>}1oRLmBR&8abQ%R&2dR~1*ZrPaWX~;H5I$!2C z!4woU=c9D|Ay!GIRhp4)?PoX^^midsAcL#55Cl9~Y5{gy!b}LhBvx4xon;O$?#Q*( zOrFpN6Ac^9WSHoHkfKdy>x@M%Ehf62b%+T(?+6`4 z-9CQli&z)#XOaKcSg$NxhlmQj7+UsU9GXbSYqo7N(-aj9OqO)EY^Uds-fM1Xp&B>A zNNH16Nq;DsY{=J_r)v)jE6;5pg1?F(32%de=N3=p2TN*9gL1Zyr<3jd9CgCY#C%Uv z3_+knZ_sDZ!b9YnUb+NZoGjL#{S*lCBx^Qtnjs)5zo}I6P*I_uz0OD24;1|-E;vIR z!35v;aFr1QC&@_Edm->c1^tbXDX<0&Hg9vk_UoAJ;2fB&CFBsAG@|J+RysAZ5$={- z-m%WXGG5vbQo4LwZ}c2lOYk8s`fBpq=$de>*fr*$9!_>a1sKBdVC5AoJvGGXAh>D+ zmodP|UuH9=LMY`EF@!NBx%K28M+ zQ6ynJHaZwlh&)^tILIak7RUoQ05IB52vyaKRzDN>URmpHq$5VQE0ghmstAJqmKhEs3u^05h2I0^iT!YJ?j~ap>t2jNlx9fF=DI#X$0$B5uv=ss7IH0pvbmRPRb#Yc1 zLM>KHktii@QUxf$;36Q24qzP0Z|mQQbu6EJ773I@CdQG=R9&fMA~7>{rbEL;hEZgF zdB&~LbkNCU7dw7e!nrpV>qiub1An3W3|!u}W!r6_F;3H_xTJUm`5*2TElL)+y%?YPtt^SqNJ z%`)4uv-r@^_@jr+FXhQ}0msVWjF{-yh;SbbU$3>4tvB+S)Fg5~)^`o{+!%r$&form z-mVtwkV}oFbMTEp1=3&VDEi`*#0n7u9V4D%aJw0m(@d#SG12S7rSwy&6if@W0|2&u zM3R&^xD?=mnNo=)F)sokYo5PPdV7rW;@Y%8lFi(c{-|3b|2}WC)K16BsBU<%{zdzb z3xf$ao(9R(p!rpl9@+)e)lF;@+hW`ytP=V#%`UQw9v|)sX6eW;jg9k_#9X%w#_&H! z$p+Aaavf-hGlfk8z?vEkUp-uf7O>!);PaV5Z;#I3w~zT6%~M-GXoEO?LB62#%gKW* zV%>G~YF^F~3_(i3!k1zxDLP780OK6I5s85nKb?72x5vhBQ{3NQthmUpO{E(}g!A)b zNlb;Bn@I#EfA73dE)+|E5ceFKY)li_Bz-8XBsuI0W0il`zaz8{e=Bb*%HB?^!esYf zP653RTz!C%YWlB&r)wF1rb1?p+Qe2vPMUD--zjaqbxM{u|xK8_GKUQCJR{9V+NpSO}wLGb8f@19-x(O43xQ!olXjKv*d7*jhN^h^!E4&>!e<0IB~G zPPM0o_vzQylEYKE2V|YM#kz@;8*i*FeL`V^p~iP()@d({TPuG-Mx8|(?k9?AH~TJ5gXSzb$428)F@*Rw+oTALm;?cC zDGR&-KT-qUU{LEHbtP(xillFk%d5M~w_)uSJKgUHZNuN}TmNim#}i?S`VXPDCTM2l zPrbAMjXt#KS6r^QmQ_Cy@ehJxQfr=MN%cLN1$d5ivrR5yzx{2A*eQGUN?O+U(ycP! z{@a$F=_Ke(6&GHHELpe;r57x8WEO(~?<5pVPE6Ql31@i-dWp%r;gpv)_Gn<=4jyPgK`ATH$wku1f?6NC;7sP0f)%3Khr2151%yFB!`lesrIueQ-*bu?E*SLm%Ns#7wWLt)JilWM5ST?D*rWKb*eDa^V zs-DZFQX)HUr6<}`wWJcmpHCjceWT4y-x1nGy*0M&huRRDVMPA#Jha#ZkLm~|)bd|_ zs3$Or+IIA^JoLcgVu!kEtR;Msix!+jp;EbQ_I!NyG*m2$wEd{8e}~GphTN7q96#YI z8=h(t9yZYL9B4&0jUtMMRLGASCJx$Sw9FYBuh+ye)8Di7?feM3ezCL%d2V=~fd+k} ze>|mG$3qR*$?&HVM)?QelHuN?ZAZ_}rfL+CXmfi7SO@6-EcPD z&#Et&%f`7&p9g(wwtfnqPdu?K3CjpSP!*S+3u$y|RA(40i2iV-UHnx3L(Qf_R4GKM ze%iFdBd*G(%~fR0I^ihAD(h;qsd)ovRsC3w|4BES!ZZaIr5rVa;R^X@DA@vcYb#5E$a*$~35nVkrIyRZ<$e>S8d|IQSkq+0&fhgLai<4cmO zhDtZxpC?jR-0Gjzy@#f5uR?E#9}EdzYkJsPSP#byIxa!lbs+cA6?z%#s7=euliG}E zVl3~(&n_D%-!S9rwjMvSG4v$MDVjc%b;;v&5}t#1ALzAbqx!IWv)|qW>qOfCz2VnH zk6$v-2?rMrGAwj7A=RG*uds~{mm{bz)Cwof@-UNa1`h{HjSgB3=u34vNaX5+L69u~ zcfMuVEf^Kv+@_cs_V&3rTD9U8)7tXU_lplz@_GF)x{{zaB29IdPZ}=i<)v+-ba8(# zxRY92-#oUhcZ={aX>P-8k8WqpBT?oFNXJ5c_FelI5EG$bp$$NYiDA}#E_n5p8bN8N z2xzH4Y=83<{QL$2skW`^PHZyz-i3%D83_+Zr*g35ql>e!LfuepU$Q`}i@NxEumnOt zk;ej9FAGx8Am9TK@qnS~`TBWr=2WL@JKDq;2h6N<%Uy@kAO(%zMKh6n8efitXSIyCs zeQv;&o(+HDV{NbClDJiuF|GP(p`d8ek!h`ntiEG5PV@<%r37W=&x@Wz?KV7q9W+w8 zFGzQzMrU~)R18w+XaT>sZ@a;`Jo=&VZ0HFIB8jvb>xhP=Rw@Gm%LwPE&gIV-kph^W ztlA$4aU%%`fuR6+zduqWCR#W#eSVl}Q*IUyj$;SkUp#t1OT3rQA2@fD*rsb9p<04Y zWs-dxELPu5=rHp3&Q9pZy08#?l#~*f$|(=l!7JK90p80)5Cq#=k(S^lmp|+5-{o>> zw#mv#V~t(H1L%T60 zmk;_+XjOn7_gv1N`G-tFC~O#ti4j}i#erU~9=zIK4=*gm)sj)nbuf;j#_vY7c}h!Vxk=nX36wV*(5Y>Ic6@Feq2=Ttv*4PQ0=T_%{a zBPcBoyj@o_vk3#%^MRycX)WnRw#gK_FY#vQ&RC(z&Efh@{^q0$lm-TScz9Kf)KjZa z#y?Cmv8Mal?w;_jVPhoG)#vNYODWCE;d}g0z@&wqob2}9&m8kHD4O%O#%0-Gic1C` zu2UYi)_wASeZ9~m{(3Us8$EJ=f6f0{96AmG{*wv3@wZT$eL}~2L5I(3+%Wh9P(i4w z00`GW03;PV6ape7^ue`{4|nBd2GEDwGcdF^7_PT4mDcOn$zTVj?zSFUiH8cE+O~63 zU{HB(f4vUwdP1QKh z-?(d-F-6v>B@~SnnZkfpTi`UKrqAfvgG;|H)jyQ#pz?;h>psQ-w8DJI4! z{chx$_m>kjCeGtTw|FAD;f9Pq7;Sed;>(c~9qJ*NDp+=a4m*ZDzbxW4Fbo#I@=-(+ zhZ4lQq_OO`0o1;1e77g0@JSl7TNyTrw@^KJ`v46e3T)k!5&N;$Lt@trX_I|imCP6F zWnOKO|J60(QRX<`{eXJ6{V6)bJPZAv*3;^=LrU5NSKTILZft%xn>f$pHl7au0E3E5 zwW4h8KD9_YTToyLGet#1VXq9AD2JIGD@#oW37*L8Bj(C14F?4ewnJ`S*hpJ`kk7`m z7sGUH8Q#cC)fezKdj`FBcc?aU6|b$cnU8KI#4)5Fcnn$y zHcxl45q#qM=$!mLuV;LD70iNK&3YbzZe@;*$EzA?A=wg0X5q0;X3D?PdRasv z1EGq@qd?hjzZ|>U(3#9eePj*Q!OQzFzW@86!L>9XPT*%c30PA}C{DPHhSgY00v?7P z&yRN+RebO;j2KC+6ry~E-go41ixV=NqRXKB>T>(X3Z1ND zv*V_0##7zoleg@=lW#gYv($^l?mFH0YXU0gZq%s;@#Q^Q}OT;L&T)FQdgjD~3KZ;WmQ-F%%_g;_Esuv0{>iC1>(PA?V&up>{Ha)&JSwA*gq z5dx>Szk$f8|7-L@1}7as)zb?@&?z72fqvZlG5wG3sIfquO59?h z2Qfw*GPNm?B~_6tD~%h;liMqnZ8{;sfNe&ZCBP`RwJs(iA3wZ_I0N83z|1*NzUjVR zRi)_el%Q-lsPZtc_HZK#%TFgU2`na$#(grx;&jPgvJRezOrejmKPWVe8?TO$T0iW1 zSP;yVBfxk5?A5R0F1{fw&FCuPXexdrq)h18_o@;OlldnD82Wn^QYbL@3=k}wEeqP( z5%iRDBdYYso(JwLU-xnFyu+m888 zlLtHIrK#MIiNCZ29agAuY0y|0T6gr5nXZ>6H$*?*dYWNsWs2fCXd7QLvIk0TMROc- zTqyD#y1NA*)FH}G#LkyUDYgc%;ipNCs8LXdCMJeP60`S!&OO{)www_hM0q^*@c9ox z(_oD#`)BFts}t=y*;poR)=^D~S^3gMb#9Y#LX*+49B4!CkOxEv79s&P z)*e7EXytu))|Ut6n->uBrM9@~JyinLvdVsn;qqB=-O{E3VNqN^!>Fk)%DWA1BH!xT z3L|w0%`yJhrl33tMkwl2Z^M6+;8KCkcNkLD2fK`#bdfFJ$1ThFkaM~O(z`9kEWWJP zbW}gS@Le33?HkK2sv!m7@l&9f1gK0h3ePOv09*G)OQOzb z3xPp++~f2rTz`tTm`v<9a@$@+y=LbK<)YrHs>6lew$uedoHE zrq`3rkhkl)C<~i!pkAPqmi@YsMDCTJWQh@_H^yurxXS3g={78S@)1*S?S^xLV$4Iv znt=bx8Ce_lpopTsabw_UWh6p`DQ`kzKjb#w^@GNT|oktxod)GTiRu5}x}xSwBLW%O{8`Nu0OBjH|A zLbHkNxfVGRj&r}q`)|bu^{b`=zr5<>EVKZjN0w=6+4C?k8`%f%2yKVms@n=f6>wbB zOV$R5znl4+;fmNM%UccoJk%ZqM zaPGwSwd1?nXosEH7UT2DZ?kR5x{Dl6X(Bp)Lwe51bU;m z1Ci!>TQSbb3MH^rV#=*leezR9qdb&2x-r4*_Caiarv(_Zb81Vyd(u(}BQIH7r=&Nu zRg1@6@md(Hfzo$YX-z|I+Ar?PKJOeCy5AcI4Y&)L1Cw0AWk_Dzkd(=<3DU0hzf zZN792Sa$ovWFUtG0olHg0)l8uk(XJxWjQF%+1w)8Jl`M_4{U-ZK7{T`8nY0Og#1{l zpbK)M&D_WVetwQ_ngls+c~oa>xTPe%G-o{ze3DHouD>mzq5fw>fSaW+qVwVS`Ap~k zxEy9%6J&K1Yj5{Ib-BEUnO_7+G9s&W)24*!(4QvyXD-x+Xe4E!jT*Ep9^BdE=%0V# z=uQ-YerKjB_TwQmRn6AndFFd?6wt4*aVZEzjzeOIIry^M5)^O~YVJ$|+=f+*W>fp*!^oDz zVx{AS0)l0k+cg$M_M9uX9cnc)O&)0(f6t5QzG|sz40R#)9cMw(?l`zO21SHGI75v6 zMq-eJaIhiLVutIh?TGmN_<6i{ToppQzl|(TAM^kPo?E&t&&boE7f&2)zMvD%Un#{k zMug=y5~E_eV=-f6)yZY+SL2z&Va3H<0I-JN$f9dWl;d(lRKw(u{h(HxblkMpkr~E} zd`iu^Xz%9U+2&2|nVUPooXx}ZseZ~L8Y+9K!;p#n?(&Z*#Jsa@r~>&vOvqq_XztOi z8=z5w&_kQvwaZdgcHP^nNNh4=aEAD8qF;S`5NSj#iToLC9>(Eyp#gH5D``2S$IL@EMirp{qL|HcG##sU;yK?d^+B zn-bI5zV?28ReVD|{>-2(nHZoGpg~jhbb2{v_;?eOaq9TeuqZQz##UoJ2ZzLxOUI!e zf~)$Y07*cd1dxvqF_+dg=6G0$m(oe(U>&TAw^G1jEZ(NhsXaB`A|j|;YRftG28m~BhkV>s49 zDnzA>%)B?&7v#l+?VMdJD9=KMAC_*se_%>sNTODfSJjJF z>C(AN)k_W}G9ci~+%LPh3`zWgE7LG_UB90+qX3+&cZRqE&)bvMV==~}aCtkF+TIF@ zoWx6#!J<@#r!99C+@S&54*qoE=$##U9nkg2X zz@{aUU~UCT&hRD?71e?u*{;S_b1Yv_x9Zdl9&u#8s4E-XkYNZo%`<)TS@165zN9T4 z5C=eEK<}m{q;bicp~)8lLSjzzq)kqnL<)8TG0`WfVSyhhCafUPx*`Jssrk%&xHUPK zS_ZLnhsobrvIC87^rl3(WqPW8yU2Mwk2dMqQ0U!@Zi!=<-Ea8P7N7O^e63yY&k5Ky z37sFRdmfKG;n{MyGJWn(-KT%~Sbn;Pws6iLYT0y+d)p5EK5yTi$nC4ZMS zfIly*W03O~vdmP&Ci_URooxebJNbNo!jkp^KCCi7ZMfl9)_JZkY(3mfu=VkdCQC>x z-B}_~>-|FTMT!E<;AUY$sG69Z-_GB>BeWfQD`VUD6YS*}CQIL$0w#I`fGH|&WQlVd z06wu|dCVT-IyRtYh4Zd*{YW`q2wq9^nM`m5*GXG4_A|GfIk8(jcD*gJ7|i6fgN7t7 zI6Ga^k!ce~2F*o{=N)f78SrAAXrD6MK0tTw)$-F8NZ6Qtxh5aKyZ-$2T)k!8Gx2rZ zwXFkIZ;aKKSTs4FmZlg=L>QM02kobXK=&|h87xMu1wNz%1pEPxUzNw4kidvsPU2Rl z`>!-;;Z>t-<1CVA@=e{&Xti?<^g@h13XuW+%i*yHjLt6e2ZRMm3SI8IFQnbyKq)+m zchrYz-R@w0uBYi6Z~e1t0xwOD)TnrJd$Romiz$;5+1SyPVs)2D7h%%;!9gkam;KY0 zv8)o7)CT9Jb-NGLD2BdZ2LM`HJKy^3!OOhH>$;z>k(n(YH0zM)Pj7<;ZIP9Az^P)H zi*UdUq;iHF!hjv3_eWZSz=zsXNX$8Qm=`b9;A@tCb-FG&-6>w3)?Krd)o@SyX4BK; z^k;41#K73`+0Hu0eaf@zbG2kkc;Y)kzanl)ZTq17^_*DjzP)FClt!DNtAkj3+quAT zC}U-WY3yu#_An!QUcuAw*ATDUL)vS=l>A}QV_MBwAN1>&$k!^&%4c*68P{rI7{sYY zvr%mi5&{8qZ5Tl6Tw-A&8h}0T+4s$mze2T?i3NsmB0hWsDvEB_)Ixhtw=xGj?sZvn zvh>4p^h_dhbWoxs2tSXKbPuZ~_b}IU2oP`}$4{jt49A~&fEWZ3ZH49u0V^lm9sx*X z{PLpZV*fDFZ}EL>T;lj%(^uJXvWAnh<`c)AeZ$6_)1i-DKnM>uA9=-&Vr9By$O*w#}-mPFsX zyW38KD7P>ZWV|fA{4CT(mI{G3Fjsg6y?8trWEJ6V+N(NB27!+)Pi}KNC2O=00Py$y= zppr}7S4Cw*SK7q`O>E>Xeg2K+cZ7~&ZpUqVp$bg@e0taa1GoA}H$b8UA@|Pw*QQXO z_>*mCT0NbN_Q2$_@PczK?zxJs+Oe*2quXtk-3}WHd%z+-P_@+Qhq2LbNk@M)2Y(|y zfRTd`T9AKfljUdMljmnPEy?{)J2uAiTxR0ShoJpbP|y8a-bKVyH0O4iEWQP{iaZ_A zK^`SQEs%i@681;QFd{>6@Q7)UaJcce8Gb?udgn7Gq;-FLAH!chLw#6v{WrhVtz(^ID2_ab1e7HaGID;w~JA6>*ng* zHqIz)p3UGEG5Z>FTyw1^^3hdNfMo<0NpG6V4$-zUJ`3C)SU7ok8DXC`?X9Xnj*Lqz ziBo{}wT_G%i+BlMCD(7ewz+Ww33EkP4jvoMFbLtdlCDs4B7lO)l70GyOP^eYp8v$~ z7;7K+G)fLhYbmAKkg6E`uVB*FEy9s z(u;^x3$c!d^eGMT&K3(3JIjKM*H@n$5Zsh|cJK;DRd zGGUq)B09OVa3^T=^Mkp&hX-(r$mG0~ec4)cO<}Yth$eKFb3uf%pjMW{d=8|hU3#zX zCU4am}#JK48n@{$7VXJ+fsAOc1SjR=GyOqe<7>7(YkWxYbv7a}<`j%sHm zRlHHw^f9!F!#!9@wK$MjQ-(cg?T*LeDT2KzKePf(nr?A)veW<(Haf51ASNL$wme*w zE)W0(<1hE}z@eXed6obbEJ6+q8T`=%$t|~B{}kj=X~{^6^uePdy|?piS>LqbTys)> zv0TqRYd87!>vA43-8(|tVK?Qr{U&+xe|XQq8k%60(=0HT3t}9_Y9;~e(B{1nBL%u! zD|gBiaSXhWfmoM8`rknxm05LUoMGTfg?30(z;4JW!~_tV2h2k=+J_S&$rV!4BiQEO zJuOMfYQN{B$(Op#uyRI8 zo3tuSC4`B?YsFU?LH~pci2U}%#^1Q6*h@mfnl6Hf13hq{VTNws)AjY}ONur>sQg3C zBdO20dbfqwX+&dF#K(=^%ZLjcx=+3?r3atXL*=|SW$)P4BEanNYD@j+1AYEta{2~v z8b?r!IP)2OP-V;$G8dO;aL6Amk%Yu|3$*%pWBHr>xQ&H?!>=lJa=`8DF;Cr<4p^sR z?QOs*tGR|OR-jCBcen7mu21CNUmDlnOp!+i0H6|*+to@(-WOtF?Ccfa#fDzfqCs0B z4xs`T_q-nTJCO>H2L7-*6`lzHe67)|aGGS3xTe2;oUe9C^pTuO9a@2kF?l4OGqfON zc_`|G(_#@HMTXS;J3?CsH*0pnnEs-FKHHVDfDuA5Zh}LBLG9gYCIxf3JpINiv=|s)r-PJ79kvdN{r*LB!NP8gte`aN>td`FnPT$B>2d^M1(mx{GFB4ZF`<~<9e|X z6gnu(Az=ns)%YfPRd)s+P}OPn)07Cv zFyqUCZw_Ua_dGB+N!Arp=B%ORqq+Me=}gymLuCPjt%Zl>7aiZXuJ0<2>hY2C$n}M{ zZko8iJEh1%oW4mJwLzR9hDV_U9EA5X*I3+|S&%uYFIEyBi%HNLU~;*09r57doE&M2 zm$%Ifi@O(i3nq3y{n?sZ-Kc{uNP-?^b={0a16Tk0&q_@E0c}}!1*OA_;O}KA-{%5= zkbYyLz3e1b$OADD06%3xqAStTBuX?c8_zpRb(=g-18Z|JIWG63*-H@HN2d%+seCnO zHkAocVHvUB{qWIugiaA|8NT%X1S6!39Et|8K2m_FgRO=^A0=)k@dMkCmQr7Fm4_%O zXU*;6+|bwAOKiJ&=C`zdH-z%wNI) z{ImII8z2>6ko(x1bNwJnav7!%pL8Bl zgXZdv?DC#kulAAC^P$>4U~>O)Q4^%}`PFix)6_2GCaW;LIBm&F5`HnqsV!F+Uu?N( zrq0)(a{9Vl<2~`#{Hcoy+_cN9+^Ar}+Lq8p6AMAPu`)48qfLfBwa)khVX`0V(Fe6h zt#4dd5xqz~VQe8;wI{fTX}sCV3HA5dX>}t5XfEz|)$XJ3N2i|^K4r5Zl4wz!*f9ib zoU}T2g_iE>A6C!F#?{pqLD=6JUayvyMX_eXY`M3CTV05;6O)Zx5Qs_I4xuVCF=0dy zOhn_bs36e(y1)8twFum5oOz7(!<&inM2X^w2dRxtl zto%dzeHoC)Ww!Yn!CJU^W)0)YY;3BjBverNI3u^c?PUlFN9!9 zRltD=%}Qm<3PYEu{iELH-eB>mgA%k1YAJcvc}q*WsJx!KlvoM2W}j6mE~9c$Hxm=j z&%Oxl0rfmQQJvBK8%_boAwt{!u{`l>@u+t%cZ90m1X&e?+NZz;{$5gv^*|^p9$AuD zFx-z2;Q(RdeTZlhG+ZH~Vd}1?Im^wtsmmsVL3?s8)BRtZ3 z03-lKCiIVT2um=cTB>S=`Eqra0cfG>>}2^3wCdi_W!P==AfWb9caH&&2uJbmp<Ml*COSpp#3T&ApHK%rKQ7K#;@6kH1`ZD=p-hRC*G)|Qq;CaUz^hET2=AnBsAR~4Icg&-Pop9$hsuCL6o zbkv=98m%_mtivL(>d910=ozLT9-YU#?zRO;Z7=d#d9kER#fB$8QgCi8;9DtFie=`L z#|3xrt7;QQyt-XKPh6ZS{>qXS>`>3HF@d>_B2fEDzD6xtH>T?RBV}Zpk2I(mP}`T! zj#n>y&hIDsBWXDn7XksMMAUU@cOM7|5JG{tA_M`U{Q+WBAG`bdNTe`;gij1CEG$}k zwY9(hT$xjy;xK3(+AUXV)eUUu=_w67@VCfhi%$!j6h1H>rnokYH&WDZJaoaec6OeK zwplJNHT%4(3xlA@19L(P6-;;oqiLlSjq{VJc3e{k&jN&pv1~KHaKc|u_yc=sbW!*C z1i=Y~)Ay_b=p|XKo=0YIaN7;v%rC+9{A^%@Z?~LxzzsiNYC`u2IB^{J`6s#WoI@7n z)L~hLiu#!CwFYpA%&6p=mgh zUh$+SW2yBR;2oiZup4>XzOj7yKT7ANXaL(IEU+XAavgG?1sEapk`)q2NXcpISsEFP z$e8RosX%1^+Qg2N#(@zTHobAhCd{uUn(yMiMWF`skWLgbsC@{7mVhc=>eF zZFg|k79t>78}or)&h8@$7CsStKerJ-{7*j$;(*$(#mSI6Xy94qSeT!F-|=CFz#_H# z&;J0*;nnD~nttn18;-#`=ba8|dUI@E=@T>(4=UAceA0d28=YUtSV zb+|>Fo6KJ^4l4}RR7JX#G1?solc*ttk)t4-kYjX?BRspthWr_V3+wyT^Y01Zl@y-W zW4|Lb7ITYgD<%uXxE4AOWdTSX$ssfV5Fk*)Lb-AQnCvU|@qY?Gvz<9O3Pa*08JoZc z4`-IL#Yg&&k|9^t6L*Hc9e<6v0}dr08}2VU7U$~hJ4Y*2`rI(H*|hyk;2`}%GE4dk zRWuAMm{(0!W&YJi(cM()NNS%BZCHC2j!>_1Y|w%`pPeDLD}itg+;;$MQ{Hs1Vm+Yq#O+-;Yt-`EL3idxocMzx{=DvyAiO)#a2%YAlSxk4BXE0_$|;ntbi=b@=9WsPmTa#bz$ zY|gaWH**d5t6wa1nsEgI5fj>=$X|mYppoyE_6Lm_lF(>O z-=DO8s8!12U1Jn-aO|#~F`ZT@lNFeWz&k-tBTa$LXEdT-;u$5b=|po(9mAAh)(+V} zxUhV0;7#+Z{}Xx{&-qEIq3`EYi-lq}Q~5jU&p;vlVosg-60ecxXge7$;I=0*>4azAJ;=md%i=keBsI+l_NVHx}R!8^X2&;;rYDHtFL z#agYXQZ#7WdtepVgz&OQ`QF1&NiIGV{0s#&C3479TjM%4S4lrZJo;5n1Fz*uPn&kv zQf(QjqwY^N7EGX$k(HYOO}i2oO7sq|jFwTfly(n@C%GxCgvFz3BA^@*24BWaWihc$ zZY5EP`kS{Im+k%NbkmaHf%%iKiIs+H*O;OTRoSK3uo>#6f9d%tDQTDJoz=YdoUteC zkzQw}^k=)@mT%QH%ZC50?A2bCaVO)*foBgLp@m$WG=XoXZ>qG!y;_Dwh*e}%2&b*x zCNf=X*5mJ%laInTdf)Dc<#X3_GWT`+acT{X(&{ccgjs3pJlplK7#7dY-oLGEA3p?J zzIHK>f8KY;IE&<+GEN;zHATjy1NDP?lM z^09D7@N5ZPDr{4^?aWzkVdunFELT_I&uFMrKl<*yBEBOu7jr}Kp9v{5{tH7u^*R6o za`vck^KSr{D1aOv$hqPKvP3GM!kI-9EQ}Q8vT4MSFw>9&1|SNW`MJJELlsepg@8)# z>j)%VEZw}yHmTae4~5*)`?oDDcypEG5E4&ZM2Jk#_^>8rJNGRv3f)&RAXr0+*16ERgcT(XL!&a$$tLq{Rd3sl^~*aDm_*=H2seRk{@__7>5%TfACP>eEqw zMrg99v^lZODahNczOW`cZB|+9mEYNS8&xiiFMDpiF1cTBA2!J68hb88G@ng;kn_f8 zd&0Iy=)Lk=Dpg<*lB>fo73UQsilSa((|!5x5uoRrv;%jLA^#_Pnu{-)M~QFRoENw$ zbP4_Q4gTf$n28$2_U>LX8xc0XZFp^7cNxtOwssL9S4#r(LwiIF@l%wuW4%td$K)Iw zL=j#BD8*hA*WuiiqsrN9LRVPDw;pheEWz12r4=`&>E4;EaCn>bm^rnnZ_6~FXT(?+x#yKiGn9e z$(jdY;1bD0NAy^}eS<~~RB~X`GW&*z!6FBP1ZAW|%naIk-ZPwfGpEct&1HAx*g&K3 zWRSPi8NiE9ZNd}dZAOdOUgK5CK@|bvQRuu-x%`CWKzK7Hq2uj2@`1&qZfHw* zcgM1MmZgzJUkf7PA1vUXr=C})wnJkAe&dq;Y=T%ub$_Q~H@u}ipIY8gKN0X3@2t&0 zOCBHV>JDBhb4ES0%rCERoBRvoSUl?jR?Q9~_6{OaKh>il6RR*&6TaURJO~56#@x~8 zaI~eL8>gwTm=PgQctvr}jPru1IOfplLm~W=16lukQ7R&5Zo7Az8a~|O+aBjzwu|EF zh`^qiq|VwQ`mpg$gfGZ?&y^lEd`2{A@F1?g2v*$}ES2?schGD5!a|zif!)$I&)np} z4$N9TUwZ{QxI;nAw%==nDUZc7AL~Le+lYB@aMKs7X6(_!`||VFU2zXyc*^I%t59uk z>xBKFAsw&`ZM@l9%=M<7P=JuAgHqK$8$vv9f&|-8p9}angx;`9tfka_h8r)IH>wX% zPOE2w{Lwp7O^(~s16scP$2=$MOZ;cId<5lwkvEs}KB<3UqYXE=y<}Njdfpt5capO0 zhqft0S(jRD$OH*qMom;dC}LL6>IJRX>vBEEl8)YE$6hHvH7E00UCp9#^93fRdduxz z5=EK*t!>niRkHlIWbn^j^I}aEvmrB^b}@eKN~VTs?t}$fsA_q7t}9*{g9#p^h7(J1 zYsl*iD@yRBeF(_4C;P7D9u-u9sJgT?Fl(5VT3wpzC}vNb%B>s4rsPsQCSpADDVhxM z2?O}?(%vpR(p%kBqnP2RVk*r5&4L)NYHD48hH4#`BWYZIrE1*p$Q|Wv5_QLcxo$$% zLLQz+6&#PqBxy4~aZP5tG%v(eZ)~Doh7^ng6VU&69T}Vrtnb~w`3~^Aw-gtb?UDWE z*>5-{#zQ3o)~E2P1?G zka?6{qsI+O`11F>#E4X!IXXv63(f~#vZ=YU7ne|wv*i zPMwQ2ptnV*7+u4{h;8&MMsH)+KZ)GUu3HHeikJ{$bYv)u=D~96JeN^cFP?r~E35D5 zxwllQ0xbpkjGta#PJ<4ok{+lNH?U;lu{UbiYZIr`yGk$PGGE>vdIYDPDn|A&ldFC>s8NbH?9uPLB={A1s*Qz&eJE` z4;3(%?wk%0?CWoNi|W4Tpo*N*3o?3hl}b&UQrz-#=m3ioDs*(J1avL881a4acN^M< zz(luwr*ic&=imdY^$`%g39b}burIZl#06|al!XgnHIzS&WA=FMdko(<^urf@O6#P2 z_z6!ucv0=J{PO-W31vss`sy-*A*?9-K<{5;?3u={+1GjXqq06;o52U;0*iZDFkGX* zVhAK;x)XLp^C3kFAmSZrN0e5q(rL3WDUgC`5TN@YCHlMmZ`(cp>Y8=4O{&CBAmGy^ zN5u7)v}OAC-K8x6dixh{2oQc%cdG2Twn8H+q@`)Z!7?a<@I%6B^{+6Y9qo4ibP z8rP%7Ptvw8sx&q2+Yb z0Lrp~)=lqBq3;{`IE3D?k}1(yoA^z37)>qJKq1fSuY(3fd?bt>S;8>pHt2f#!pCwG zg`BfE$UG=p#bzfUlHqq=bRps@H2jqbZ$NClMLg|(X4m!gUhT_u;zzphr8c&mNKP&> z+33kkW@w!{QX~nnZSkpsJ~WwNB(~rG@6)y}hYjWS>BJdBq=D%1FvXPvl^E%_fUqB( zxV%Uc@MwXlhJKuW;d|O?H5&TXuCyt0@|8(bb&~7Tn8&~Q>)F9oyVEq@0^+Q&6)Y;%*^RL zHL*%@-~Q_z)3b3W~N;cwm(SxZJbm zBED%ojA(5Sk`3qWb0iA})y!X%?rsFW*LOw-c%C2gXBHFp9Mth@L)iY6dKNOoSkrk? zRrLZB9T>c_qr!5j;8pz`W4a<+LNgm~w@Na8}J5s+Vyd0AG3lg3@c>K;-^ngHshTyn#FbQPmtD_pJWRf|%9 zC>d2wZPm?vr=nvSFf&5guEa=f$3CJ+%N&qB4guF7oo2zlU)2Dl;V!$wENk4%b%}vlmih)L zg-i)T(;2n;0GD$N<_4ISYRQByLM$W*?QOehHTV?pA?%=-C0@tzqeSq%A-$E~3%y+S zsidd|4ESq}KR@jsmSZb=pkW`=*(v7Nj(Wzq{K$zjzr|?BXE~zn+@_CLbvTw8oKof{fm4H++m9p zT27Yhmrh@NP!`Odn&G^5HTX4sO6@qmi+|@Ya=aeStE1y;Dam)|#jV-q3eK%W-ld7n zPl1o%cKYY6ZzaQz?>9u>Tv@U!=v-eA4^kT6Uh(Pu)qNx)AWS3Bf@V)*>!-b!d&I%! zqORP^xX?8DF(~l8R`RHrh4UZ6_onWS=M~#Ja<7>DmQ&o!Bz6d*-QJ1$NmwrPEVB4pGu-qs!G9I6Swu0S0-O#63Z*Z1!H#%;=d?vHmX~;KD zb}#|T77sLL?{x;xy}CDH^{MihHbURwWmssCw9vNJ@TI$N$2AUBcIC*nWs@y^l|MtO z|1+v}=b}dHPx;4&>q_#L*Hb4CnSx`SKBYNcAF$BOR_K1kbdES)!AW=sZrkX&>NDyW z-=DE)TTjJ=otzhHk1pJ6|NXv9kAbHaES-MGZ;@PN-p#b=hQ7lh3}u69EBKx0ejxwb zv_#p`T{)NEs^({LIOZ>5pAq zsvRZ`FaJ?x>Nw#W;q!XIW=w7Sw#ST`KC4Xiy=IQShIp%^rK#>VDFZY6EJ{>vr#iz` zKp;q+;LRQ1in61bOB;jlf%n{vK7)ebs7{fpm>PA)L#ctJ#M!0zZ6t8}Fw+e$8( z9T8Ts##TpJ9>#)U?p2#EW4H>2lI(ECzEvcf=Omw&BSC0Jd2am6iPhe{ssRKF?^8Sm zAaw0!Q~uZU5XmK$d6xWLZfjzv`C7(#vrD9pu~W4Nq6L$N*zIWa2e5$|7#iWX7O#xF z>w0rGe13&hz+ZO>wBnt|JZxISNBUj%nDvWZR5wSB*W@inn(J!qJ7~Q}(H+ZBcVyGr zTXApQg!uwWzxA7ii3!H^bieiTo5!LbI=9_QgEato9(X2g$k`QC98$(V=YELVt%Sx;rFkvCi`fsJ`-kZa%_V42)U z5Ag)2miB2nAVK?bkES>Dk$KXPmw5~Zn?6>Z&FyBEUr)!Q(B;DFB_Jt>O5y2D?%kz4 z|Fyhl=3c`@l#raeE~2g`<~}y5;|^te3V;s4Dipod+ZD-A37VOdC#o`q7!tvih%>Xm zV#~i#ksvfBwFN^ZP*8`sWFYDTgtnE*ytU2gJDYs!ba>D!aokE%#91bPb!d%{nuhU^?w> z;Yam7;AA_%t5QSYTk#ES~>zq!T# yVTOOHkRX(;JYPrQULGR)n}NVtAY=q^!G0S^%%TGV;gf&|MgJn=|IPnX3;Yerp@#SX From ce5f19321961593d99f612ee0fccf42a6e036362 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 1 Oct 2024 14:42:31 +0200 Subject: [PATCH 1277/1309] 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 1278/1309] 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 1279/1309] 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 1280/1309] 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 1281/1309] 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 1282/1309] 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 1283/1309] 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 1284/1309] 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 1285/1309] 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 1286/1309] 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 1287/1309] 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 1288/1309] 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 1289/1309] 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 1290/1309] 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 1291/1309] 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 1292/1309] 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 1293/1309] 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 1294/1309] 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 1295/1309] 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 1296/1309] 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 1297/1309] 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 1298/1309] 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 1299/1309] 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 1300/1309] 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 1301/1309] 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 1302/1309] 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 1303/1309] 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 1304/1309] 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 1305/1309] 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 1306/1309] 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 1307/1309] 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 1308/1309] 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 1309/1309] 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"